diff --git a/.changeset/large-deers-deny.md b/.changeset/large-deers-deny.md new file mode 100644 index 00000000..8065d7e0 --- /dev/null +++ b/.changeset/large-deers-deny.md @@ -0,0 +1,8 @@ +--- +"@lookit/surveys": patch +"@lookit/record": patch +"@lookit/style": patch +"@lookit/data": patch +--- + +Add consent video trial diff --git a/.gitignore b/.gitignore index 97c233e3..2bf7ce66 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ dist coverage Procfile .env -site \ No newline at end of file +site +.DS_Store \ No newline at end of file diff --git a/jest.cjs b/jest.cjs index e279d258..21fb57b2 100644 --- a/jest.cjs +++ b/jest.cjs @@ -5,6 +5,7 @@ module.exports.makePackageConfig = () => { transform: { ...config.transform, "^.+\\.mustache$": "/../../jest.text.loader.js", + "^.+\\.svg$": "/../../jest.text.loader.js", }, moduleNameMapper: { "@lookit/data": "/../../packages/data/src" }, coverageThreshold: { diff --git a/package-lock.json b/package-lock.json index ee8a1cc2..c670b729 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4381,6 +4381,28 @@ "node": ">=10" } }, + "node_modules/@rollup/plugin-image": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-image/-/plugin-image-3.0.3.tgz", + "integrity": "sha512-qXWQwsXpvD4trSb8PeFPFajp8JLpRtqqOeNYRUKnEQNHm7e5UP7fuSRcbjQAJ7wDZBbnJvSdY5ujNBQd9B1iFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "mini-svg-data-uri": "^1.4.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/plugin-json": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.0.1.tgz", @@ -14718,6 +14740,16 @@ "node": ">=4" } }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -20089,12 +20121,12 @@ "version": "0.0.1", "license": "ISC", "dependencies": { - "@lookit/data": "^0.0.1", "auto-bind": "^5.0.1", "mustache": "^4.2.0" }, "devDependencies": { "@jspsych/config": "^2.0.0", + "@rollup/plugin-image": "^3.0.3", "@types/audioworklet": "^0.0.60", "@types/mustache": "^4.2.5", "rollup-plugin-dotenv": "^0.5.1", @@ -20102,6 +20134,7 @@ "typescript": "^5.6.2" }, "peerDependencies": { + "@lookit/data": "^0.0.1", "jspsych": "^8.0.2" } }, @@ -20125,6 +20158,7 @@ "license": "ISC", "devDependencies": { "@jspsych/config": "^3.0.0", + "@lookit/record": "^0.0.1", "@lookit/surveys": "^0.0.1", "rollup-plugin-scss": "^4.0.0", "sass": "^1.78.0", diff --git a/packages/data/src/errors.ts b/packages/data/src/errors.ts index 9ba761df..6200db53 100644 --- a/packages/data/src/errors.ts +++ b/packages/data/src/errors.ts @@ -37,3 +37,11 @@ export class AWSConfigError extends Error { this.name = "AWSConfigError"; } } +/** Error for when URL from Django API is not formatted as expected. */ +export class URLWrongError extends Error { + /** Throw error when URL is not formatted correctly. */ + public constructor() { + super("URL is different than expected."); + this.name = "URLWrongError"; + } +} diff --git a/packages/data/src/utils.ts b/packages/data/src/utils.ts index 3433ddf5..4add759f 100644 --- a/packages/data/src/utils.ts +++ b/packages/data/src/utils.ts @@ -1,3 +1,4 @@ +import { URLWrongError } from "./errors"; import { ApiResponse } from "./types"; const CONFIG = { url_base: "/api/v2/" }; @@ -75,6 +76,6 @@ export const getUuids = () => { if (locationHref.includes("studies/j/") && uuids && uuids.length === 2) { return { study: uuids[0], child: uuids[1] }; } else { - throw new Error("URL is different than expected."); + throw new URLWrongError(); } }; diff --git a/packages/record/img/play-icon.svg b/packages/record/img/play-icon.svg new file mode 100644 index 00000000..1623021a --- /dev/null +++ b/packages/record/img/play-icon.svg @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/packages/record/img/record-icon.svg b/packages/record/img/record-icon.svg new file mode 100644 index 00000000..b6029743 --- /dev/null +++ b/packages/record/img/record-icon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/record/package.json b/packages/record/package.json index 4df3ba24..48eb6223 100644 --- a/packages/record/package.json +++ b/packages/record/package.json @@ -26,12 +26,12 @@ "test": "jest --coverage" }, "dependencies": { - "@lookit/data": "^0.0.1", "auto-bind": "^5.0.1", "mustache": "^4.2.0" }, "devDependencies": { "@jspsych/config": "^2.0.0", + "@rollup/plugin-image": "^3.0.3", "@types/audioworklet": "^0.0.60", "@types/mustache": "^4.2.5", "rollup-plugin-dotenv": "^0.5.1", @@ -39,6 +39,7 @@ "typescript": "^5.6.2" }, "peerDependencies": { + "@lookit/data": "^0.0.1", "jspsych": "^8.0.2" } } diff --git a/packages/record/rollup.config.mjs b/packages/record/rollup.config.mjs index b0fffdab..d9d36ff9 100644 --- a/packages/record/rollup.config.mjs +++ b/packages/record/rollup.config.mjs @@ -1,3 +1,4 @@ +import image from "@rollup/plugin-image"; import dotenv from "rollup-plugin-dotenv"; import { importAsString } from "rollup-plugin-string-import"; import { makeRollupConfig } from "../../rollup.mjs"; @@ -13,6 +14,8 @@ export default makeRollupConfig("chsRecord").map((config) => { importAsString({ include: ["**/*.mustache"], }), + + image(), ], }; }); diff --git a/packages/record/scss/index.scss b/packages/record/scss/index.scss new file mode 100644 index 00000000..0882d7e5 --- /dev/null +++ b/packages/record/scss/index.scss @@ -0,0 +1,12 @@ +img#record-icon, +img#play-icon { + top: 7px; + left: 7px; + height: 25px; + position: absolute; +} + +div#lookit-jspsych-video-container { + position: relative; + width: min-content; +} diff --git a/packages/record/src/consentVideo.spec.ts b/packages/record/src/consentVideo.spec.ts new file mode 100644 index 00000000..21bf9ae5 --- /dev/null +++ b/packages/record/src/consentVideo.spec.ts @@ -0,0 +1,253 @@ +import { initJsPsych } from "jspsych"; +import Mustache from "mustache"; +import consentVideoTrial from "../templates/consent-video-trial.mustache"; +import webcamFeed from "../templates/webcam-feed.mustache"; +import { VideoConsentPlugin } from "./consentVideo"; +import { + ButtonNotFoundError, + ImageNotFoundError, + VideoContainerNotFoundError, +} from "./errors"; +import Recorder from "./recorder"; + +jest.mock("./recorder"); + +test("Instantiate recorder", () => { + const jsPsych = initJsPsych(); + const plugin = new VideoConsentPlugin(jsPsych); + + expect(plugin["recorder"]).toBeDefined(); +}); + +test("Trial", () => { + const jsPsych = initJsPsych(); + const plugin = new VideoConsentPlugin(jsPsych); + // const display = { insertAdjacentHTML: jest.fn() } as unknown as HTMLElement; + const display = document.createElement("div"); + + plugin["webcamFeed"] = jest.fn(); + plugin["recordButton"] = jest.fn(); + plugin["stopButton"] = jest.fn(); + plugin["playButton"] = jest.fn(); + plugin["nextButton"] = jest.fn(); + + plugin.trial(display); + + expect(plugin["webcamFeed"]).toHaveBeenCalledTimes(1); + expect(plugin["recordButton"]).toHaveBeenCalledTimes(1); + expect(plugin["stopButton"]).toHaveBeenCalledTimes(1); + expect(plugin["playButton"]).toHaveBeenCalledTimes(1); + expect(plugin["nextButton"]).toHaveBeenCalledTimes(1); +}); + +test("GetVideoContainer error when no container", () => { + const jsPsych = initJsPsych(); + const plugin = new VideoConsentPlugin(jsPsych); + expect(() => + plugin["getVideoContainer"](document.createElement("div")), + ).toThrow(VideoContainerNotFoundError); +}); + +test("GetVideoContainer", () => { + const jsPsych = initJsPsych(); + const plugin = new VideoConsentPlugin(jsPsych); + const display = document.createElement("div"); + + display.innerHTML = `
`; + + const html = plugin["getVideoContainer"](display).outerHTML; + expect(html).toBe(`
`); +}); + +test("webcamFeed", () => { + const jsPsych = initJsPsych(); + const plugin = new VideoConsentPlugin(jsPsych); + const display = document.createElement("div"); + + display.innerHTML = `
`; + plugin["getVideoContainer"] = jest.fn(); + plugin["webcamFeed"](display); + + expect(display.innerHTML).toContain( + ``, + ); + expect(Recorder.prototype.insertWebcamFeed).toHaveBeenCalledTimes(1); +}); + +test("playbackFeed", () => { + const jsPsych = initJsPsych(); + const plugin = new VideoConsentPlugin(jsPsych); + const display = document.createElement("div"); + const vidContainer = "some video container"; + + plugin["getVideoContainer"] = jest.fn().mockReturnValue(vidContainer); + plugin["onEnded"] = jest.fn().mockReturnValue("some func"); + plugin["playbackFeed"](display); + + expect(Recorder.prototype.insertPlaybackFeed).toHaveBeenCalledWith( + vidContainer, + "some func", + "300px", + ); +}); + +test("onEnded", () => { + const jsPsych = initJsPsych(); + const plugin = new VideoConsentPlugin(jsPsych); + const display = document.createElement("div"); + const play = document.createElement("button"); + const next = document.createElement("button"); + + display.innerHTML = Mustache.render(consentVideoTrial, {}); + plugin["webcamFeed"] = jest.fn(); + plugin["getButton"] = jest.fn().mockImplementation((_display, id) => { + if (id === "play") { + return play; + } else if (id === "next") { + return next; + } + return; + }); + + plugin["onEnded"](display)(); + + expect(plugin["webcamFeed"]).toHaveBeenCalledWith(display); + expect(plugin["webcamFeed"]).toHaveBeenCalledTimes(1); + expect(plugin["getButton"]).toHaveBeenCalledTimes(2); + expect(play.disabled).toBeFalsy(); + expect(next.disabled).toBeFalsy(); +}); + +test("getButton", () => { + const jsPsych = initJsPsych(); + const plugin = new VideoConsentPlugin(jsPsych); + const display = document.createElement("div"); + + display.innerHTML = Mustache.render(consentVideoTrial, {}); + + expect(plugin["getButton"](display, "next").id).toStrictEqual("next"); +}); + +test("getButton error when button not found", () => { + const jsPsych = initJsPsych(); + const plugin = new VideoConsentPlugin(jsPsych); + const display = document.createElement("div"); + + expect(() => plugin["getButton"](display, "next")).toThrow( + ButtonNotFoundError, + ); +}); + +test("getImg error when image not found", () => { + const jsPsych = initJsPsych(); + const plugin = new VideoConsentPlugin(jsPsych); + const display = document.createElement("div"); + expect(() => plugin["getImg"](display, "record-icon")).toThrow( + ImageNotFoundError, + ); +}); + +test("recordButton", async () => { + const jsPsych = initJsPsych(); + const plugin = new VideoConsentPlugin(jsPsych); + const display = document.createElement("div"); + + display.innerHTML = + Mustache.render(consentVideoTrial, {}) + Mustache.render(webcamFeed, {}); + + plugin["recordButton"](display); + + // Trigger event + const click = new Event("click"); + await display + .querySelector("button#record")! + .dispatchEvent(click); + + // Check for query length + const disabledButtons = display.querySelectorAll( + "button#record, button#play, button#next", + ); + expect(disabledButtons.length).toStrictEqual(3); + + // Check for buttons to be disabled. + disabledButtons.forEach((button) => { + expect(button.disabled).toBeTruthy(); + }); + + // Stop button should not be disabled. + expect( + display.querySelector("button#stop")!.disabled, + ).toBeFalsy(); + + // Show record record icon + expect( + display.querySelector("img#record-icon")!.style + .visibility, + ).toStrictEqual("visible"); + + // Start recorder + expect(Recorder.prototype.start).toHaveBeenCalledTimes(1); + expect(Recorder.prototype.start).toHaveBeenCalledWith("consent"); +}); + +test("playButton", () => { + const jsPsych = initJsPsych(); + const plugin = new VideoConsentPlugin(jsPsych); + const display = document.createElement("div"); + + plugin["playbackFeed"] = jest.fn(); + + display.innerHTML = Mustache.render(consentVideoTrial, {}); + + plugin["playButton"](display); + + const playButton = display.querySelector("button#play"); + + playButton!.dispatchEvent(new Event("click")); + + expect(playButton!.disabled).toBeTruthy(); + expect(plugin["playbackFeed"]).toHaveBeenCalledTimes(1); + expect(plugin["playbackFeed"]).toHaveBeenCalledWith(display); +}); + +test("stopButton", async () => { + const jsPsych = initJsPsych(); + const plugin = new VideoConsentPlugin(jsPsych); + const display = document.createElement("div"); + + display.innerHTML = + Mustache.render(consentVideoTrial, { + video_container_id: plugin["video_container_id"], + }) + Mustache.render(webcamFeed, {}); + + plugin["webcamFeed"] = jest.fn(); + plugin["stopButton"](display); + + const stopButton = display.querySelector("button#stop"); + await stopButton!.dispatchEvent(new Event("click")); + + display + .querySelectorAll("button#record, button#play") + .forEach((button) => expect(button.disabled).toBeFalsy()); + expect(stopButton!.disabled).toBeTruthy(); + expect(Recorder.prototype.stop).toHaveBeenCalledTimes(1); + expect(Recorder.prototype.reset).toHaveBeenCalledTimes(1); + expect(plugin["webcamFeed"]).toHaveBeenCalledTimes(1); + expect(plugin["webcamFeed"]).toHaveBeenCalledWith(display); +}); + +test("nextButton", () => { + const jsPsych = initJsPsych(); + const plugin = new VideoConsentPlugin(jsPsych); + const display = document.createElement("div"); + + display.innerHTML = Mustache.render(consentVideoTrial, {}); + jsPsych.finishTrial = jest.fn(); + + plugin["nextButton"](display); + display + .querySelector("button#next")! + .dispatchEvent(new Event("click")); + + expect(jsPsych.finishTrial).toHaveBeenCalledTimes(1); +}); diff --git a/packages/record/src/consentVideo.ts b/packages/record/src/consentVideo.ts new file mode 100644 index 00000000..8b412866 --- /dev/null +++ b/packages/record/src/consentVideo.ts @@ -0,0 +1,219 @@ +import { JsPsych, JsPsychPlugin } from "jspsych"; +import Mustache from "mustache"; +import { version } from "../package.json"; +import consentVideo from "../templates/consent-video-trial.mustache"; +import { + ButtonNotFoundError, + ImageNotFoundError, + VideoContainerNotFoundError, +} from "./errors"; +import Recorder from "./recorder"; +import { CSSWidthHeight } from "./types"; + +const info = { + name: "consent-video", + version, + parameters: {}, + data: {}, +}; +type Info = typeof info; + +/** The video consent plugin. */ +export class VideoConsentPlugin implements JsPsychPlugin { + public static readonly info = info; + private recorder: Recorder; + + // Template variables + private video_container_id = "lookit-jspsych-video-container"; + + // Style variables + private videoWidth: CSSWidthHeight = "300px"; + + /** + * Instantiate video consent plugin. + * + * @param jsPsych - JsPsych object + */ + public constructor(private jsPsych: JsPsych) { + this.jsPsych = jsPsych; + this.recorder = new Recorder(this.jsPsych); + } + + /** + * Create/Show trial view. + * + * @param display - HTML element for experiment. + */ + public trial(display: HTMLElement) { + const { video_container_id } = this; + + display.insertAdjacentHTML( + "afterbegin", + Mustache.render(consentVideo, { video_container_id }), + ); + + // Set up trial HTML + this.webcamFeed(display); + this.recordButton(display); + this.stopButton(display); + this.playButton(display); + this.nextButton(display); + } + /** + * Retrieve video container element. + * + * @param display - HTML element for experiment. + * @returns Video container + */ + private getVideoContainer(display: HTMLElement) { + const videoContainer = display.querySelector( + `div#${this.video_container_id}`, + ); + + if (!videoContainer) { + throw new VideoContainerNotFoundError(); + } + + return videoContainer; + } + + /** + * Add webcam feed to HTML. + * + * @param display - HTML element for experiment. + */ + private webcamFeed(display: HTMLElement) { + const videoContainer = this.getVideoContainer(display); + this.recorder.insertWebcamFeed(videoContainer, this.videoWidth); + this.getImg(display, "record-icon").style.visibility = "hidden"; + } + + /** + * Playback Feed + * + * @param display - JsPsych display HTML element. + */ + private playbackFeed(display: HTMLElement) { + const videoContainer = this.getVideoContainer(display); + this.recorder.insertPlaybackFeed( + videoContainer, + this.onEnded(display), + this.videoWidth, + ); + } + + /** + * Put back the webcam feed once the video recording has ended. This is used + * with the "ended" Event. + * + * @param display - JsPsych display HTML element. + * @returns Event function + */ + private onEnded(display: HTMLElement) { + return () => { + const next = this.getButton(display, "next"); + const play = this.getButton(display, "play"); + this.webcamFeed(display); + next.disabled = false; + play.disabled = false; + }; + } + + /** + * Retrieve button element from DOM. + * + * @param display - HTML element for experiment. + * @param id - Element id + * @returns Button element + */ + private getButton( + display: HTMLElement, + id: "play" | "next" | "stop" | "record", + ) { + const btn = display.querySelector(`button#${id}`); + if (!btn) { + throw new ButtonNotFoundError(id); + } + return btn; + } + + /** + * Select and return the image element. + * + * @param display - HTML element for experiment. + * @param id - ID string of Image element + * @returns Image Element + */ + private getImg(display: HTMLElement, id: "record-icon") { + const img = display.querySelector(`img#${id}`); + + if (!img) { + throw new ImageNotFoundError(id); + } + + return img; + } + + /** + * Add record button to HTML. + * + * @param display - HTML element for experiment. + */ + private recordButton(display: HTMLElement) { + const record = this.getButton(display, "record"); + const stop = this.getButton(display, "stop"); + const play = this.getButton(display, "play"); + const next = this.getButton(display, "next"); + + record.addEventListener("click", async () => { + record.disabled = true; + stop.disabled = false; + play.disabled = true; + next.disabled = true; + this.getImg(display, "record-icon").style.visibility = "visible"; + await this.recorder.start("consent"); + }); + } + + /** + * Set up play button to playback last recorded video. + * + * @param display - HTML element for experiment. + */ + private playButton(display: HTMLElement) { + const play = this.getButton(display, "play"); + + play.addEventListener("click", () => { + play.disabled = true; + this.playbackFeed(display); + }); + } + + /** + * Add stop button to HTML. + * + * @param display - HTML element for experiment. + */ + private stopButton(display: HTMLElement) { + const stop = this.getButton(display, "stop"); + const record = this.getButton(display, "record"); + const play = this.getButton(display, "play"); + stop.addEventListener("click", async () => { + stop.disabled = true; + record.disabled = false; + play.disabled = false; + await this.recorder.stop(); + this.recorder.reset(); + this.webcamFeed(display); + }); + } + /** + * Add next button to HTML. + * + * @param display - HTML element for experiment. + */ + private nextButton(display: HTMLElement) { + const next = this.getButton(display, "next"); + next.addEventListener("click", () => this.jsPsych.finishTrial()); + } +} diff --git a/packages/record/src/error.ts b/packages/record/src/error.ts deleted file mode 100644 index 9cd2ce55..00000000 --- a/packages/record/src/error.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** Error thrown when recorder is null. */ -export class RecorderInitializeError extends Error { - /** - * When there isn't a recorder, provide the user with an explanation of what - * they could do to resolve the issue. - */ - public constructor() { - const message = "Neither camera nor microphone has been initialized."; - super(message); - this.name = "RecorderInitializeError"; - } -} - -/** - * Error thrown when trying to stop an active session recording that cannot be - * found. - */ -export class NoSessionRecordingError extends Error { - /** - * When trying to stop a recording that isn't found, provide the user with an - * explanation of what they could do to resolve the issue. - */ - public constructor() { - const message = - "Cannot stop a session recording because no active session recording was found. Maybe it needs to be started, or there was a problem starting the recording."; - super(message); - this.name = "NoSessionRecordingError"; - } -} - -/** - * Error thrown when trying to trying to start a recording while another is - * already active. - */ -export class ExistingRecordingError extends Error { - /** - * When trying to start a recording but there is already an active recording - * in progress, provide the user with an explanation of what they could do to - * resolve the issue. - */ - public constructor() { - const message = - "Cannot start a new recording because an active recording was found. Maybe a session recording needs to be stopped, trial recording is being used during session recording, or there was a problem stopping a prior recording."; - super(message); - this.name = "ExistingRecordingError"; - } -} - -/** - * Error thrown when trying to to stop the recorder and the stop promise doesn't - * exist. - */ -export class NoStopPromiseError extends Error { - /** - * When attempting to stop a recording but there's no stop promise to ensure - * the stop has completed. - */ - public constructor() { - const message = - "There is no Stop Promise, which means the recorder wasn't started properly."; - super(message); - this.name = "NoStopPromiseError"; - } -} - -/** - * Error thrown when attempting an action that relies on an input stream, such - * as the mic volume check, but no such stream is found. - */ -export class NoStreamError extends Error { - /** - * When attempting an action that requires an input stream, such as the mic - * check, but no stream is found. - */ - public constructor() { - const message = - "No input stream found. Maybe the recorder was not initialized with intializeRecorder."; - super(message); - this.name = "NoStreamError"; - } -} - -/** - * Error thrown if there's a problem setting up the microphone input level - * check. - */ -export class MicCheckError extends Error { - /** - * Occurs if there's a problem setting up the mic check, including setting up - * the audio context and stream source, loading the audio worklet processor - * script, setting up the port message event handler, and resolving the - * promise chain via message events passed to onMicActivityLevel. - * - * @param err - Error passed into this error that is thrown in the catch - * block, if any. - */ - public constructor(err: Error) { - const message = `There was a problem setting up and running the microphone check. ${err.message}`; - super(message); - this.name = "MicCheckError"; - } -} diff --git a/packages/record/src/errors.ts b/packages/record/src/errors.ts new file mode 100644 index 00000000..f52e9132 --- /dev/null +++ b/packages/record/src/errors.ts @@ -0,0 +1,231 @@ +/** Error thrown when recorder is null. */ +export class RecorderInitializeError extends Error { + /** + * When there isn't a recorder, provide the user with an explanation of what + * they could do to resolve the issue. + */ + public constructor() { + const message = "Neither camera nor microphone has been initialized."; + super(message); + this.name = "RecorderInitializeError"; + } +} + +/** Error thrown when stream is inactive and recorder is started. */ +export class StreamInactiveInitializeError extends Error { + /** + * Error check on initialize. Attempting to validate recorder is ready to + * start recording. + */ + public constructor() { + super( + "Stream is inactive when attempting to start recording. Recorder reset might be needed.", + ); + this.name = "StreamInactiveInitializeError"; + } +} + +/** Error thrown when stream data is available and recorder is started. */ +export class StreamDataInitializeError extends Error { + /** + * Error check on recorder initialize. Attempt to validate recorder data array + * is empty and ready to start recording. + */ + public constructor() { + super( + "Stream data from another recording still available when attempting to start recording. Recorder reset might be needed. ", + ); + this.name = "StreamDataInitializeError"; + } +} + +/** + * Error thrown when trying to stop an active session recording that cannot be + * found. + */ +export class NoSessionRecordingError extends Error { + /** + * When trying to stop a recording that isn't found, provide the user with an + * explanation of what they could do to resolve the issue. + */ + public constructor() { + const message = + "Cannot stop a session recording because no active session recording was found. Maybe it needs to be started, or there was a problem starting the recording."; + super(message); + this.name = "NoSessionRecordingError"; + } +} + +/** + * Error thrown when trying to trying to start a recording while another is + * already active. + */ +export class ExistingRecordingError extends Error { + /** + * When trying to start a recording but there is already an active recording + * in progress, provide the user with an explanation of what they could do to + * resolve the issue. + */ + public constructor() { + const message = + "Cannot start a new recording because an active recording was found. Maybe a session recording needs to be stopped, trial recording is being used during session recording, or there was a problem stopping a prior recording."; + super(message); + this.name = "ExistingRecordingError"; + } +} + +/** + * Error thrown when trying to to stop the recorder and the stop promise doesn't + * exist. + */ +export class NoStopPromiseError extends Error { + /** + * When attempting to stop a recording but there's no stop promise to ensure + * the stop has completed. + */ + public constructor() { + const message = + "There is no Stop Promise, which means the recorder wasn't started properly."; + super(message); + this.name = "NoStopPromiseError"; + } +} + +/** + * Error thrown when attempting an action that relies on an input stream, such + * as the mic volume check, but no such stream is found. + */ +export class NoStreamError extends Error { + /** + * When attempting an action that requires an input stream, such as the mic + * check, but no stream is found. + */ + public constructor() { + const message = + "No input stream found. Maybe the recorder was not initialized with intializeRecorder."; + super(message); + this.name = "NoStreamError"; + } +} + +/** + * Error thrown if there's a problem setting up the microphone input level + * check. + */ +export class MicCheckError extends Error { + /** + * Occurs if there's a problem setting up the mic check, including setting up + * the audio context and stream source, loading the audio worklet processor + * script, setting up the port message event handler, and resolving the + * promise chain via message events passed to onMicActivityLevel. + * + * @param err - Error passed into this error that is thrown in the catch + * block, if any. + */ + public constructor(err: Error) { + const message = `There was a problem setting up and running the microphone check. ${err.message}`; + super(message); + this.name = "MicCheckError"; + } +} + +/** + * Error thrown when attempting to access S3 object and it's, unknowingly, + * undefined. + */ +export class S3UndefinedError extends Error { + /** + * Provide feed back when recorder attempts to use S3 object and it's + * undefined. + */ + public constructor() { + super("S3 object is undefined."); + this.name = "S3UndefinedError"; + } +} + +/** + * Error thrown when attempting to reset recorder, but its stream is still + * active. + */ +export class StreamActiveOnResetError extends Error { + /** + * This error will be thrown when developer attempts to reset recorder while + * active. + */ + public constructor() { + super("Won't reset recorder. Stream is still active."); + this.name = "StreamActiveOnResetError"; + } +} + +/** Error thrown when attempting to select webcam element and it's not found. */ +export class NoWebCamElementError extends Error { + /** + * Error thrown when attempting to retrieve webcam element and it's not in the + * DOM. + */ + public constructor() { + super("No webcam element found."); + this.name = "NoWebCamElementError"; + } +} +/** Error thrown when playback element wasn't found in the DOM. */ +export class NoPlayBackElementError extends Error { + /** + * This error will be thrown when attempting to retrieve the playback element + * and it wasn't found in the DOM. + */ + public constructor() { + super("No playback element found."); + this.name = "NoPlayBackElementError"; + } +} +/** + * Error thrown when attempting to create playback/download url and data array + * is empty. + */ +export class CreateURLError extends Error { + /** + * Throw this error when data array is empty and url still needs to be + * created. Sometimes this means the "reset()" method was called too early. + */ + public constructor() { + super("Video/audio URL couldn't be created. No data available."); + this.name = "CreateURLError"; + } +} + +/** Error thrown when video container couldn't be found. */ +export class VideoContainerNotFoundError extends Error { + /** No video container found. */ + public constructor() { + super("Video Container could not be found."); + this.name = "VideoContainerError"; + } +} + +/** Error thrown when button not found. */ +export class ButtonNotFoundError extends Error { + /** + * Button couldn't be found by ID field. + * + * @param id - HTML ID parameter. + */ + public constructor(id: string) { + super(`"${id}" button not found.`); + this.name = "ButtonNotFoundError"; + } +} + +/** Throw Error when image couldn't be found. */ +export class ImageNotFoundError extends Error { + /** + * Error when image couldn't be found by ID field. + * + * @param id - HTML ID parameter + */ + public constructor(id: string) { + super(`"${id}" image not found.`); + } +} diff --git a/packages/record/src/index.spec.ts b/packages/record/src/index.spec.ts index 5d44d5a7..49a9d134 100644 --- a/packages/record/src/index.spec.ts +++ b/packages/record/src/index.spec.ts @@ -1,6 +1,6 @@ import { LookitWindow } from "@lookit/data/dist/types"; import { initJsPsych } from "jspsych"; -import { ExistingRecordingError, NoSessionRecordingError } from "./error"; +import { ExistingRecordingError, NoSessionRecordingError } from "./errors"; import Rec from "./index"; import Recorder from "./recorder"; diff --git a/packages/record/src/index.ts b/packages/record/src/index.ts index 23a2c1c0..9c1c0349 100644 --- a/packages/record/src/index.ts +++ b/packages/record/src/index.ts @@ -1,3 +1,4 @@ +import { VideoConsentPlugin } from "./consentVideo"; import StartRecordPlugin from "./start"; import StopRecordPlugin from "./stop"; import TrialRecordExtension from "./trial"; @@ -6,4 +7,5 @@ export default { TrialRecordExtension, StartRecordPlugin, StopRecordPlugin, + VideoConsentPlugin, }; diff --git a/packages/record/src/recorder.spec.ts b/packages/record/src/recorder.spec.ts index 409916cf..dabcb9c3 100644 --- a/packages/record/src/recorder.spec.ts +++ b/packages/record/src/recorder.spec.ts @@ -1,36 +1,65 @@ import Data from "@lookit/data"; import { initJsPsych } from "jspsych"; import Mustache from "mustache"; +import play_icon from "../img/play-icon.svg"; +import record_icon from "../img/record-icon.svg"; +import playbackFeed from "../templates/playback-feed.mustache"; import webcamFeed from "../templates/webcam-feed.mustache"; import { + CreateURLError, + NoPlayBackElementError, NoStopPromiseError, NoStreamError, + NoWebCamElementError, RecorderInitializeError, -} from "./error"; + S3UndefinedError, + StreamActiveOnResetError, + StreamDataInitializeError, + StreamInactiveInitializeError, +} from "./errors"; import Recorder from "./recorder"; import { CSSWidthHeight } from "./types"; jest.mock("@lookit/data"); - +jest.mock("jspsych", () => ({ + ...jest.requireActual("jspsych"), + initJsPsych: jest.fn().mockReturnValue({ + pluginAPI: { + getCameraRecorder: jest.fn().mockReturnValue({ + addEventListener: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + stream: { + active: true, + clone: jest.fn(), + getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]), + }, + }), + }, + }), +})); +/** + * Remove new lines, indents (tabs or spaces), and empty HTML property values. + * + * @param html - HTML string + * @returns Cleaned String + */ +const cleanHTML = (html: string) => { + return html + .replace(/(\r\n|\n|\r|\t| {4})/gm, "") + .replace(/(="")/gm, "") + .replaceAll(" ", " ") + .replaceAll(">", ">"); +}; afterEach(() => { jest.clearAllMocks(); }); -test("Recorder filename", () => { - const prefix = "prefix"; - const rec = new Recorder(initJsPsych(), prefix); - expect(rec["filename"]).toContain(prefix); -}); - test("Recorder start", async () => { const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); - const media = { addEventListener: jest.fn(), start: jest.fn() }; - - // manual mock - jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); - - await rec.start(); + const rec = new Recorder(jsPsych); + const media = jsPsych.pluginAPI.getCameraRecorder(); + await rec.start("prefix"); expect(media.addEventListener).toHaveBeenCalledTimes(2); expect(media.start).toHaveBeenCalledTimes(1); @@ -38,16 +67,12 @@ test("Recorder start", async () => { test("Recorder stop", async () => { const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); + const rec = new Recorder(jsPsych); const stopPromise = Promise.resolve(); - const media = { - stop: jest.fn(), - stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, - }; + const media = jsPsych.pluginAPI.getCameraRecorder(); // manual mocks rec["stopPromise"] = stopPromise; - jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); // check that the "stop promise" is returned on stop expect(rec.stop()).toStrictEqual(stopPromise); @@ -60,23 +85,17 @@ test("Recorder stop", async () => { test("Recorder no stop promise", () => { const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); - const media = { - stop: jest.fn(), - stream: { getTracks: jest.fn().mockReturnValue([]) }, - }; + const rec = new Recorder(jsPsych); // no stop promise rec["stopPromise"] = undefined; - jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); - expect(async () => await rec.stop()).rejects.toThrow(NoStopPromiseError); }); - test("Recorder initialize error", () => { const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); + const rec = new Recorder(jsPsych); + const getCameraRecorder = jsPsych.pluginAPI.getCameraRecorder; // no recorder jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(undefined); @@ -84,27 +103,32 @@ test("Recorder initialize error", () => { .fn() .mockReturnValue(undefined); - expect(async () => await rec.start()).rejects.toThrow( + expect(async () => await rec.start("prefix")).rejects.toThrow( RecorderInitializeError, ); + + jsPsych.pluginAPI.getCameraRecorder = getCameraRecorder; }); test("Recorder handleStop", async () => { - const rec = new Recorder(initJsPsych(), "prefix"); + const rec = new Recorder(initJsPsych()); const download = jest.fn(); const resolve = jest.fn(); const handleStop = rec["handleStop"](resolve); // manual mock rec["download"] = download; + rec["blobs"] = ["some recorded data" as unknown as Blob]; + URL.createObjectURL = jest.fn(); // let's download the file locally rec["localDownload"] = true; await handleStop(); - // Upload the file to s3 + // // Upload the file to s3 rec["localDownload"] = false; + rec["_s3"] = new Data.LookitS3("some key"); await handleStop(); @@ -112,8 +136,15 @@ test("Recorder handleStop", async () => { expect(Data.LookitS3.prototype.completeUpload).toHaveBeenCalledTimes(1); }); +test("Recorder handleStop error with no url", () => { + const rec = new Recorder(initJsPsych()); + const resolve = jest.fn(); + const handleStop = rec["handleStop"](resolve); + expect(async () => await handleStop()).rejects.toThrow(CreateURLError); +}); + test("Recorder handleDataAvailable", () => { - const rec = new Recorder(initJsPsych(), "prefix"); + const rec = new Recorder(initJsPsych()); const handleDataAvailable = rec["handleDataAvailable"]; const event = jest.fn() as unknown as BlobEvent; @@ -122,6 +153,7 @@ test("Recorder handleDataAvailable", () => { expect(Data.LookitS3.prototype.onDataAvailable).toHaveBeenCalledTimes(0); rec["localDownload"] = false; + rec["_s3"] = new Data.LookitS3("some key"); handleDataAvailable(event); expect(Data.LookitS3.prototype.onDataAvailable).toHaveBeenCalledTimes(1); }); @@ -135,12 +167,7 @@ test("Recorder insert webcam display without height/width", () => { ) as HTMLDivElement; const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); - - const media = { - stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, - }; - jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); + const rec = new Recorder(jsPsych); // Should add the video element with webcam stream to the webcam container. rec.insertWebcamFeed(webcam_div); @@ -149,17 +176,10 @@ test("Recorder insert webcam display without height/width", () => { const height: CSSWidthHeight = "auto"; const width: CSSWidthHeight = "100%"; const webcam_element_id: string = "lookit-jspsych-webcam"; - const params = { height, width, webcam_element_id }; - let rendered_webcam_html = Mustache.render(webcamFeed, params); + const params = { height, width, webcam_element_id, record_icon }; - // Remove new lines, indents (tabs or spaces), and empty HTML property values. - rendered_webcam_html = rendered_webcam_html.replace( - /(\r\n|\n|\r|\t| {4})/gm, - "", - ); - let displayed_html = document.body.innerHTML; - displayed_html = displayed_html.replace(/(\r\n|\n|\r|\t| {4})/gm, ""); - displayed_html = displayed_html.replace(/(="")/gm, ""); + const rendered_webcam_html = cleanHTML(Mustache.render(webcamFeed, params)); + const displayed_html = cleanHTML(document.body.innerHTML); expect(displayed_html).toContain(rendered_webcam_html); @@ -176,12 +196,7 @@ test("Recorder insert webcam display with height/width", () => { ) as HTMLDivElement; const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); - - const media = { - stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, - }; - jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); + const rec = new Recorder(jsPsych); // Should add the video element with webcam stream to the webcam container, // with the specified height and width. @@ -191,17 +206,9 @@ test("Recorder insert webcam display with height/width", () => { // Use the HTML template and settings to figure out what HTML should have been added. const webcam_element_id: string = "lookit-jspsych-webcam"; - const params = { height, width, webcam_element_id }; - let rendered_webcam_html = Mustache.render(webcamFeed, params); - - // Remove new lines, indents (tabs or spaces), and empty HTML property values. - rendered_webcam_html = rendered_webcam_html.replace( - /(\r\n|\n|\r|\t| {4})/gm, - "", - ); - let displayed_html = document.body.innerHTML; - displayed_html = displayed_html.replace(/(\r\n|\n|\r|\t| {4})/gm, ""); - displayed_html = displayed_html.replace(/(="")/gm, ""); + const params = { height, width, webcam_element_id, record_icon }; + const rendered_webcam_html = cleanHTML(Mustache.render(webcamFeed, params)); + const displayed_html = cleanHTML(document.body.innerHTML); expect(displayed_html).toContain(rendered_webcam_html); @@ -218,145 +225,41 @@ test("Webcam feed is removed when stream access stops", async () => { ) as HTMLDivElement; const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); + const rec = new Recorder(jsPsych); const stopPromise = Promise.resolve(); - const media = { - stop: jest.fn(), - stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, - }; - rec["stopPromise"] = stopPromise; - jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); + rec["stopPromise"] = stopPromise; rec.insertWebcamFeed(webcam_div); expect(document.body.innerHTML).toContain(" { - const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); - - expect(rec["s3"]).not.toBe(null); - - const media = { - stop: jest.fn(), - stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, - }; - jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); - - // Destroy with no in-progress upload or mic check. - // This should just stop the tracks and set s3 to null. - await rec.destroy(); - - expect(media.stop).toHaveBeenCalledTimes(1); - expect(media.stream.getTracks).toHaveBeenCalledTimes(1); - expect(rec["s3"]).toBe(null); - expect(Data.LookitS3.prototype.completeUpload).not.toHaveBeenCalled(); -}); - -test("Recorder destroy with in-progress upload", async () => { - const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); - const media = { - addEventListener: jest.fn(), - start: jest.fn(), - stop: jest.fn(), - stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, - }; - jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); - - await rec.start(); - expect(media.start).toHaveBeenCalledTimes(1); - - const stopPromise = Promise.resolve(); - rec["stopPromise"] = stopPromise; - - Object.defineProperty(rec["s3"], "uploadInProgress", { - /** - * Overwrite the getter method for S3's uploadInProgress. - * - * @returns Boolean. - */ - get: () => true, - }); - - // Destroy with in-progress upload. - // This should call stop on the recorder and complete the upload. - await rec.destroy(); - expect(media.stop).toHaveBeenCalledTimes(1); - expect(media.stream.getTracks).toHaveBeenCalledTimes(1); - expect(rec["s3"]).toBe(null); - expect(Data.LookitS3.prototype.completeUpload).toHaveBeenCalledTimes(1); -}); - -test("Recorder destroy with webcam display", async () => { - // Add webcam container to document body. - const webcam_container_id = "webcam-container"; - document.body.innerHTML = `
`; - const webcam_div = document.getElementById( - webcam_container_id, - ) as HTMLDivElement; - - const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); - const media = { - stop: jest.fn(), - stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, - }; - jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); - - rec.insertWebcamFeed(webcam_div); - expect(document.body.innerHTML).toContain(" { const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); + const rec = new Recorder(jsPsych); + const getCameraRecorder = jsPsych.pluginAPI.getCameraRecorder; - // No recorder initialized - expect(rec.camMicAccess()).toBe(false); + expect(rec.camMicAccess()).toBe(true); - // Recorder initialized but stream is not active - const stream_active_undefined = { - stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, - }; jsPsych.pluginAPI.getCameraRecorder = jest .fn() - .mockReturnValue(stream_active_undefined); + .mockReturnValue({ stream: { active: false } }); + expect(rec.camMicAccess()).toBe(false); - const stream_inactive = { - stream: { - active: false, - getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]), - }, - }; - jsPsych.pluginAPI.getCameraRecorder = jest + + jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(undefined); + jsPsych.pluginAPI.getMicrophoneRecorder = jest .fn() - .mockReturnValue(stream_inactive); + .mockReturnValue(undefined); + expect(rec.camMicAccess()).toBe(false); - // Recorder exists with active stream - const stream_active = { - stop: jest.fn(), - stream: { - active: true, - getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]), - }, - }; - jsPsych.pluginAPI.getCameraRecorder = jest - .fn() - .mockReturnValue(stream_active); - expect(rec.camMicAccess()).toBe(true); + jsPsych.pluginAPI.getCameraRecorder = getCameraRecorder; }); test("Recorder requestPermission", async () => { @@ -375,7 +278,7 @@ test("Recorder requestPermission", async () => { }); const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); + const rec = new Recorder(jsPsych); const constraints = { video: true, audio: true }; const returnedStream = await rec.requestPermission(constraints); @@ -387,7 +290,7 @@ test("Recorder requestPermission", async () => { test("Recorder getDeviceLists", async () => { const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); + const rec = new Recorder(jsPsych); const mic1 = { deviceId: "mic1", @@ -458,7 +361,8 @@ test("Recorder getDeviceLists", async () => { test("Recorder initializeRecorder", () => { const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); + const rec = new Recorder(jsPsych); + const getCameraRecorder = jsPsych.pluginAPI.getCameraRecorder; // MediaRecorder is not available in Jest/jsDom, so mock the implementation of jsPsych.pluginAPI.initializeCameraRecorder (which calls new MediaRecorder) and jsPsych.pluginAPI.getCameraRecorder (which gets the private recorder that was created via jsPsych's initializeCameraRecorder). const stream = { fake: "stream" } as unknown as MediaStream; @@ -474,6 +378,7 @@ test("Recorder initializeRecorder", () => { resume: jest.fn(), }; }); + jsPsych.pluginAPI.initializeCameraRecorder = jest .fn() .mockImplementation((stream: MediaStream) => { @@ -486,13 +391,13 @@ test("Recorder initializeRecorder", () => { rec.intializeRecorder(stream); expect(jsPsych.pluginAPI.initializeCameraRecorder).toHaveBeenCalled(); - expect(rec["recorder"]).toBeDefined(); - expect(rec["recorder"]).not.toBeNull(); expect(rec["stream"]).toStrictEqual(stream); + + jsPsych.pluginAPI.getCameraRecorder = getCameraRecorder; }); test("Recorder onMicActivityLevel", () => { - const rec = new Recorder(initJsPsych(), "prefix"); + const rec = new Recorder(initJsPsych()); type micEventType = { currentActivityLevel: number; @@ -532,28 +437,145 @@ test("Recorder onMicActivityLevel", () => { test("Recorder mic check throws error if no stream", () => { const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); - const media = {}; - jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); + const rec = new Recorder(jsPsych); + const getCameraRecorder = jsPsych.pluginAPI.getCameraRecorder; + + jsPsych.pluginAPI.getCameraRecorder = jest + .fn() + .mockReturnValueOnce({ stream: false }); + expect(async () => { await rec.checkMic(); }).rejects.toThrow(NoStreamError); -}); - -test("Recorder download", async () => { - const click = jest.fn(); - Object.defineProperty(global, "document", { - value: { - addEventListener: jest.fn(), - createElement: jest.fn().mockReturnValue({ click }), - }, - }); + jsPsych.pluginAPI.getCameraRecorder = getCameraRecorder; +}); - const rec = new Recorder(initJsPsych(), "prefix"); +test("Recorder download", () => { + const rec = new Recorder(initJsPsych()); + rec["url"] = "some url"; + rec["filename"] = "some filename"; const download = rec["download"]; + const click = jest.spyOn(HTMLAnchorElement.prototype, "click"); - await download(); + download(); expect(click).toHaveBeenCalledTimes(1); }); + +test("Recorder s3 get error when undefined", () => { + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych); + + expect(() => rec["s3"]).toThrow(S3UndefinedError); +}); + +test("Recorder reset error when stream active", () => { + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych); + expect(() => rec.reset()).toThrow(StreamActiveOnResetError); +}); + +test("Recorder reset", () => { + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych); + const getCameraRecorder = jsPsych.pluginAPI.getCameraRecorder; + const streamClone = jest.fn(); + + jsPsych.pluginAPI.getCameraRecorder = jest + .fn() + .mockReturnValue({ stream: { active: false } }); + rec["streamClone"] = { + clone: jest.fn().mockReturnValue(streamClone), + } as unknown as MediaStream; + rec["blobs"] = ["some stream data" as unknown as Blob]; + + expect(rec["blobs"]).not.toStrictEqual([]); + + rec.reset(); + + expect(jsPsych.pluginAPI.initializeCameraRecorder).toHaveBeenCalledTimes(1); + expect(jsPsych.pluginAPI.initializeCameraRecorder).toHaveBeenCalledWith( + streamClone, + undefined, + ); + expect(rec["blobs"]).toStrictEqual([]); + + jsPsych.pluginAPI.getCameraRecorder = getCameraRecorder; +}); + +test("Record insert webcam feed error when no element", () => { + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych); + const div = { querySelector: jest.fn() } as unknown as HTMLDivElement; + expect(() => rec.insertWebcamFeed(div)).toThrow(NoWebCamElementError); +}); + +test("Record insert Playback feed", () => { + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych); + const webcam_container_id = "webcam-container"; + const height: CSSWidthHeight = "auto"; + const width: CSSWidthHeight = "100%"; + const playback_element_id: string = "lookit-jspsych-playback"; + + rec["url"] = "some url"; + + const view = { + src: rec["url"], + width, + height, + playback_element_id, + play_icon, + }; + + document.body.innerHTML = `
`; + const webcam_div = document.getElementById( + webcam_container_id, + ) as HTMLDivElement; + + rec.insertPlaybackFeed(webcam_div, () => {}); + const tempHtml = cleanHTML(Mustache.render(playbackFeed, view)); + const docHtml = cleanHTML(document.body.innerHTML); + + expect(docHtml).toContain(tempHtml); + + document.body.innerHTML = ""; +}); + +test("Record insert Playback feed error if no container", () => { + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych); + const div = { + querySelector: jest.fn(), + insertAdjacentHTML: jest.fn(), + } as unknown as HTMLDivElement; + expect(() => rec.insertPlaybackFeed(div, () => {})).toThrow( + NoPlayBackElementError, + ); +}); + +test("Record initialize error inactive stream", () => { + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych); + const initializeCheck = rec["initializeCheck"]; + const getCameraRecorder = jsPsych.pluginAPI.getCameraRecorder; + + jsPsych.pluginAPI.getCameraRecorder = jest + .fn() + .mockReturnValue({ stream: { active: false } }); + + expect(() => initializeCheck()).toThrow(StreamInactiveInitializeError); + + jsPsych.pluginAPI.getCameraRecorder = getCameraRecorder; +}); + +test("Record initialize error inactive stream", () => { + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych); + const initializeCheck = rec["initializeCheck"]; + + rec["blobs"] = ["some stream data" as unknown as Blob]; + + expect(() => initializeCheck()).toThrow(StreamDataInitializeError); +}); diff --git a/packages/record/src/recorder.ts b/packages/record/src/recorder.ts index 8b6aaffd..84c384d5 100644 --- a/packages/record/src/recorder.ts +++ b/packages/record/src/recorder.ts @@ -1,58 +1,103 @@ import Data from "@lookit/data"; -import lookitS3 from "@lookit/data/dist/lookitS3"; +import LookitS3 from "@lookit/data/dist/lookitS3"; import autoBind from "auto-bind"; import { JsPsych } from "jspsych"; import Mustache from "mustache"; +import play_icon from "../img/play-icon.svg"; +import record_icon from "../img/record-icon.svg"; +import playbackFeed from "../templates/playback-feed.mustache"; import webcamFeed from "../templates/webcam-feed.mustache"; import { + CreateURLError, MicCheckError, + NoPlayBackElementError, NoStopPromiseError, NoStreamError, + NoWebCamElementError, RecorderInitializeError, -} from "./error"; + S3UndefinedError, + StreamActiveOnResetError, + StreamDataInitializeError, + StreamInactiveInitializeError, +} from "./errors"; import { CSSWidthHeight } from "./types"; // import MicCheckProcessor from './mic_check'; // TO DO: fix or remove this. See: https://github.com/lookit/lookit-jspsych/issues/44 /** Recorder handles the state of recording and data storage. */ export default class Recorder { + private url?: string; + private _s3?: LookitS3; + private blobs: Blob[] = []; private localDownload: boolean = process.env.LOCAL_DOWNLOAD?.toLowerCase() === "true"; - private filename: string; - private stopPromise: Promise | undefined; + private filename?: string; private minVolume: number = 0.1; - private webcam_element_id = "lookit-jspsych-webcam"; public micChecked: boolean = false; + /** * Use null rather than undefined so that we can set these back to null when * destroying. */ private processorNode: AudioWorkletNode | null = null; - private s3: lookitS3 | null = null; - /** - * Store the reject function for the stop promise so that we can reject it in - * the destroy recorder method. - */ - private rejectStopPromise: (reason: string) => void = () => {}; + + private stopPromise?: Promise; + private webcam_element_id = "lookit-jspsych-webcam"; + private playback_element_id = "lookit-jspsych-playback"; + + private streamClone: MediaStream; /** * Recorder for online experiments. * * @param jsPsych - Object supplied by jsPsych. - * @param fileNamePrefix - Prefix for the video recording file name (string). - * This is the string that comes before "_.webm". */ - public constructor( - private jsPsych: JsPsych, - fileNamePrefix: string, - ) { - this.filename = this.createFilename(fileNamePrefix); - if (!this.localDownload) { - this.s3 = new Data.LookitS3(this.filename); - } + public constructor(private jsPsych: JsPsych) { + this.streamClone = this.stream.clone(); autoBind(this); } + /** + * Get recorder from jsPsych plugin API. + * + * If camera recorder hasn't been initialized, then return the microphone + * recorder. + * + * @returns MediaRecorder from the plugin API. + */ + private get recorder() { + return ( + this.jsPsych.pluginAPI.getCameraRecorder() || + this.jsPsych.pluginAPI.getMicrophoneRecorder() + ); + } + + /** + * Get stream from either recorder. + * + * @returns MediaStream from the plugin API. + */ + private get stream() { + return this.recorder.stream; + } + + /** + * Get s3 class variable. Throw error if doesn't exist. + * + * @returns - S3 object. + */ + private get s3() { + if (!this._s3) { + throw new S3UndefinedError(); + } + return this._s3; + } + + /** Set s3 class variable. */ + private set s3(value: LookitS3) { + this._s3 = value; + } + /** * Request permission to use the webcam and/or microphone. This can be used * with and without specific device selection (and other constraints). @@ -133,28 +178,13 @@ export default class Recorder { this.jsPsych.pluginAPI.initializeCameraRecorder(stream, opts); } - /** - * Get recorder from jsPsych plugin API. - * - * If camera recorder hasn't been initialized, then return the microphone - * recorder. - * - * @returns MediaRecorder from the plugin API. - */ - private get recorder() { - return ( - this.jsPsych.pluginAPI.getCameraRecorder() || - this.jsPsych.pluginAPI.getMicrophoneRecorder() - ); - } - - /** - * Get stream from either recorder. - * - * @returns MediaStream from the plugin API. - */ - private get stream() { - return this.recorder?.stream; + /** Reset the recorder to be used again. */ + public reset() { + if (this.stream.active) { + throw new StreamActiveOnResetError(); + } + this.intializeRecorder(this.streamClone.clone()); + this.blobs = []; } /** @@ -173,11 +203,61 @@ export default class Recorder { height: CSSWidthHeight = "auto", ) { const { webcam_element_id, stream } = this; - const view = { height, width, webcam_element_id }; + const view = { height, width, webcam_element_id, record_icon }; element.innerHTML = Mustache.render(webcamFeed, view); - element.querySelector( + const webcam = element.querySelector( `#${webcam_element_id}`, - )!.srcObject = stream; + ); + + if (!webcam) { + throw new NoWebCamElementError(); + } + + webcam.srcObject = stream; + } + + /** + * Insert video playback feed into supplied element. + * + * @param element - The HTML div element that should serve as the container + * for the webcam display. + * @param on_ended - Callback function called when playing video ends. + * @param width - The width of the video element containing the webcam feed, + * in CSS units (optional). Default is `'100%'` + * @param height - The height of the video element containing the webcam feed, + * in CSS units (optional). Default is `'auto'` + */ + public insertPlaybackFeed( + element: HTMLDivElement, + on_ended: (this: HTMLVideoElement, e: Event) => void, + width: CSSWidthHeight = "100%", + height: CSSWidthHeight = "auto", + ) { + const { playback_element_id } = this; + const view = { + src: this.url, + width, + height, + playback_element_id, + play_icon, + }; + + this.clearWebcamFeed(); + + element.insertAdjacentHTML( + "afterbegin", + Mustache.render(playbackFeed, view), + ); + + const playbackElement = element.querySelector( + `video#${this.playback_element_id}`, + ); + + if (!playbackElement) { + throw new NoPlayBackElementError(); + } + + playbackElement.addEventListener("ended", on_ended, { once: true }); } /** @@ -209,19 +289,33 @@ export default class Recorder { /** * Start recording. Also, adds event listeners for handling data and checks * for recorder initialization. + * + * @param prefix - Prefix for the video recording file name (string). This is + * the string that comes before "_.webm". */ - public async start() { + public async start(prefix: "consent" | "session_video" | "trial_video") { this.initializeCheck(); + + // Set filename + this.filename = `${prefix}_${new Date().getTime()}.webm`; + + // Instantiate s3 object + if (!this.localDownload) { + this.s3 = new Data.LookitS3(this.filename); + } + this.recorder.addEventListener("dataavailable", this.handleDataAvailable); + // create a stop promise and pass the resolve function as an argument to the stop event callback, // so that the stop event handler can resolve the stop promise - this.stopPromise = new Promise((resolve, reject) => { + this.stopPromise = new Promise((resolve) => { this.recorder.addEventListener("stop", this.handleStop(resolve)); - this.rejectStopPromise = reject; }); + if (!this.localDownload) { - await this.s3?.createUpload(); + await this.s3.createUpload(); } + this.recorder.start(); } @@ -248,6 +342,7 @@ export default class Recorder { public stop() { this.stopTracks(); this.clearWebcamFeed(); + if (!this.stopPromise) { throw new NoStopPromiseError(); } @@ -255,32 +350,12 @@ export default class Recorder { } /** - * Destroy the recorder. When a plugin/extension destroys the recorder, it - * will set the whole Recorder class instance to null, so we don't need to - * reset the Recorder instance variables/states. We should complete the S3 - * upload and stop any async processes that might continue to run (audio - * worklet for the mic check, stop promise). We also need to stop the tracks - * to release the media devices (even if they're not recording). Setting S3 to - * null should release the video blob data from memory. + * Check access to webcam/mic stream. + * + * @returns Whether or not the recorder has webcam/mic access. */ - public async destroy() { - // Stop the audio worklet processor if it's running - if (this.processorNode !== null) { - this.processorNode.port.postMessage({ micChecked: true }); - this.processorNode = null; - } - if (this.stopPromise) { - await this.stop(); - // Complete any MPU that might've been created - if (this.s3?.uploadInProgress) { - await this.s3?.completeUpload(); - } - } else { - this.stopTracks(); - this.clearWebcamFeed(); - } - // Clear any blob data - this.s3 = null; + public camMicAccess(): boolean { + return !!this.recorder && this.stream.active; } /** Throw Error if there isn't a recorder provided by jsPsych. */ @@ -288,6 +363,14 @@ export default class Recorder { if (!this.recorder) { throw new RecorderInitializeError(); } + + if (!this.stream.active) { + throw new StreamInactiveInitializeError(); + } + + if (this.blobs.length !== 0) { + throw new StreamDataInitializeError(); + } } /** @@ -296,18 +379,22 @@ export default class Recorder { * that stop promise. The function that is returned is used as the recorder's * "stop" event-related callback function. * + * @param resolve - Promise resolve function. * @returns Function that is called on the recorder's "stop" event. */ - private handleStop(resolve: { - (value: void | PromiseLike): void; - (): void; - }) { + private handleStop(resolve: () => void) { return async () => { + if (this.blobs.length === 0) { + throw new CreateURLError(); + } + this.url = URL.createObjectURL(new Blob(this.blobs)); + if (this.localDownload) { - await this.download(); + this.download(); } else { - await this.s3?.completeUpload(); + await this.s3.completeUpload(); } + resolve(); }; } @@ -320,56 +407,10 @@ export default class Recorder { private handleDataAvailable(event: BlobEvent) { this.blobs.push(event.data); if (!this.localDownload) { - this.s3?.onDataAvailable(event.data); + this.s3.onDataAvailable(event.data); } } - /** Temp method to download data url. */ - private async download() { - const data = (await this.bytesToBase64DataUrl( - new Blob(this.blobs), - )) as string; - const link = document.createElement("a"); - link.href = data; - link.download = this.filename; - link.click(); - } - - /** - * Temp method to convert blobs to a data url. - * - * @param bytes - Bytes or blobs. - * @param type - Mimetype. - * @returns Result of reading data as url. - */ - private bytesToBase64DataUrl( - bytes: BlobPart, - type = "video/webm; codecs=vp8", - ) { - return new Promise((resolve) => { - const reader = Object.assign(new FileReader(), { - /** - * When promise resolves, it'll return the result. - * - * @returns Result of reading data as url. - */ - onload: () => resolve(reader.result), - }); - reader.readAsDataURL(new File([bytes], "", { type })); - }); - } - - /** - * Function to create a video recording filename. - * - * @param prefix - (string): Start of the file name for the video recording. - * @returns Filename string, including the prefix, date/time and webm - * extension. - */ - private createFilename(prefix: string) { - return `${prefix}_${new Date().getTime()}.webm`; - } - /** * Private helper to handle the mic level messages that are sent via an * AudioWorkletProcessor. This checks the current level against the minimum @@ -455,13 +496,14 @@ export default class Recorder { }); } - /** - * Check access to webcam/mic stream. - * - * @returns Whether or not the recorder has webcam/mic access. - */ - public camMicAccess(): boolean { - return !!this.recorder && !!this.stream?.active; + /** Download data url used in local development. */ + private download() { + if (this.filename && this.url) { + const link = document.createElement("a"); + link.href = this.url; + link.download = this.filename; + link.click(); + } } /** Private helper to clear the webcam feed, if there is one. */ diff --git a/packages/record/src/recorder_WebAudioAPI.spec.ts b/packages/record/src/recorder_WebAudioAPI.spec.ts index a76d1edd..84c5113f 100644 --- a/packages/record/src/recorder_WebAudioAPI.spec.ts +++ b/packages/record/src/recorder_WebAudioAPI.spec.ts @@ -4,7 +4,7 @@ import { audioContextMock, AudioWorkletNodeMock, } from "../fixtures/MockWebAudioAPI"; -import { MicCheckError } from "./error"; +import { MicCheckError } from "./errors"; import Recorder from "./recorder"; // Some of the recorder's methods rely on the WebAudio API, which is not available in Node/Jest/jsdom, so we'll mock it here. @@ -16,19 +16,32 @@ global.AudioWorkletNode = AudioWorkletNodeMock as any; /** Add mock registerProcessor to the global scope. */ global.registerProcessor = () => {}; +jest.mock("jspsych", () => ({ + ...jest.requireActual("jspsych"), + initJsPsych: jest.fn().mockReturnValue({ + pluginAPI: { + getCameraRecorder: jest.fn().mockReturnValue({ + addEventListener: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + stream: { + active: true, + clone: jest.fn(), + getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]), + }, + }), + }, + }), +})); + afterEach(() => { jest.clearAllMocks(); }); test("Recorder check mic", async () => { const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); - const media = { - stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, - }; - jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); + const rec = new Recorder(jsPsych); - // Mock the resolution of the last promise in checkMic so that it resolves and we can check the mocks/spies. rec["setupPortOnMessage"] = jest .fn() .mockReturnValue(() => Promise.resolve()); @@ -47,7 +60,9 @@ test("Recorder check mic", async () => { await rec.checkMic(); - expect(createMediaStreamSourceSpy).toHaveBeenCalledWith(media.stream); + expect(createMediaStreamSourceSpy).toHaveBeenCalledWith( + jsPsych.pluginAPI.getCameraRecorder().stream, + ); expect(addModuleSpy).toHaveBeenCalledWith("/static/js/mic_check.js"); expect(createConnectProcessorSpy).toHaveBeenCalledWith( expectedAudioContext, @@ -58,11 +73,7 @@ test("Recorder check mic", async () => { test("Throws MicCheckError with createConnectProcessor error", () => { const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); - const media = { - stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, - }; - jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); + const rec = new Recorder(jsPsych); // Mock the resolution of the last promise in checkMic to make sure that the rejection/error occurs before this point. rec["setupPortOnMessage"] = jest @@ -83,11 +94,7 @@ test("Throws MicCheckError with createConnectProcessor error", () => { test("Throws MicCheckError with addModule error", () => { const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); - const media = { - stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, - }; - jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); + const rec = new Recorder(jsPsych); // Mock the resolution of the last promise in checkMic to make sure that the rejection/error occurs before this point. rec["setupPortOnMessage"] = jest @@ -108,11 +115,7 @@ test("Throws MicCheckError with addModule error", () => { test("Throws MicCheckError with setupPortOnMessage error", () => { const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); - const media = { - stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, - }; - jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); + const rec = new Recorder(jsPsych); // Mock the resolution of the last promise in checkMic to make sure that the rejection/error occurs before this point. rec["setupPortOnMessage"] = jest @@ -133,11 +136,7 @@ test("Throws MicCheckError with setupPortOnMessage error", () => { test("checkMic should process microphone input and handle messages", () => { const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); - const media = { - stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, - }; - jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); + const rec = new Recorder(jsPsych); const onMicActivityLevelSpy = jest.spyOn(rec, "onMicActivityLevel" as never); @@ -194,40 +193,9 @@ test("checkMic should process microphone input and handle messages", () => { expect(rec.micChecked).toBe(true); }); -test("Destroy method should set processorNode to null", async () => { - const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); - const media = { - stop: jest.fn(), - stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, - }; - jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); - - expect(rec["processorNode"]).toBe(null); - - // Setup the processor node. - const audioContext = new AudioContext(); - rec["processorNode"] = new AudioWorkletNode( - audioContext, - "mic-check-processor", - ); - expect(rec["processorNode"]).toBeTruthy(); - rec["setupPortOnMessage"](rec["minVolume"]); - expect(rec["processorNode"].port.onmessage).toBeTruthy(); - - expect(rec["processorNode"]).toBeTruthy(); - await rec.destroy(); - expect(rec["processorNode"]).toBe(null); -}); - test("Recorder setupPortOnMessage should setup port's on message callback", () => { const jsPsych = initJsPsych(); - const rec = new Recorder(jsPsych, "prefix"); - const media = { - stop: jest.fn(), - stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, - }; - jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); + const rec = new Recorder(jsPsych); expect(rec["processorNode"]).toBe(null); diff --git a/packages/record/src/start.ts b/packages/record/src/start.ts index f2ed78a6..6e569589 100644 --- a/packages/record/src/start.ts +++ b/packages/record/src/start.ts @@ -1,6 +1,6 @@ import { LookitWindow } from "@lookit/data/dist/types"; import { JsPsych, JsPsychPlugin } from "jspsych"; -import { ExistingRecordingError } from "./error"; +import { ExistingRecordingError } from "./errors"; import Recorder from "./recorder"; declare let window: LookitWindow; @@ -19,7 +19,7 @@ export default class StartRecordPlugin implements JsPsychPlugin { * @param jsPsych - Object provided by jsPsych. */ public constructor(private jsPsych: JsPsych) { - this.recorder = new Recorder(this.jsPsych, "session_video"); + this.recorder = new Recorder(this.jsPsych); if (!window.chs.sessionRecorder) { window.chs.sessionRecorder = this.recorder; } else { @@ -29,7 +29,7 @@ export default class StartRecordPlugin implements JsPsychPlugin { /** Trial function called by jsPsych. */ public trial() { - this.recorder.start().then(() => { + this.recorder.start("session_video").then(() => { this.jsPsych.finishTrial(); }); } diff --git a/packages/record/src/stop.ts b/packages/record/src/stop.ts index 90e97568..b03225c0 100644 --- a/packages/record/src/stop.ts +++ b/packages/record/src/stop.ts @@ -2,7 +2,7 @@ import { LookitWindow } from "@lookit/data/dist/types"; import { JsPsych, JsPsychPlugin } from "jspsych"; import Mustache from "mustache"; import uploadingVideo from "../templates/uploading-video.mustache"; -import { NoSessionRecordingError } from "./error"; +import { NoSessionRecordingError } from "./errors"; import Recorder from "./recorder"; declare let window: LookitWindow; diff --git a/packages/record/src/string-import.d.ts b/packages/record/src/string-import.d.ts index 59511a32..682a5ec4 100644 --- a/packages/record/src/string-import.d.ts +++ b/packages/record/src/string-import.d.ts @@ -2,3 +2,8 @@ declare module "*.mustache" { const file: string; export default file; } + +declare module "*.svg" { + const file: string; + export default file; +} diff --git a/packages/record/src/trial.ts b/packages/record/src/trial.ts index c52d49cf..f31576e6 100644 --- a/packages/record/src/trial.ts +++ b/packages/record/src/trial.ts @@ -27,12 +27,12 @@ export default class TrialRecordExtension implements JsPsychExtension { /** Ran at the start of a trial. */ public on_start() { - this.recorder = new Recorder(this.jsPsych, "trial_video"); + this.recorder = new Recorder(this.jsPsych); } /** Ran when the trial has loaded. */ public on_load() { - this.recorder?.start(); + this.recorder?.start("trial_video"); } /** diff --git a/packages/record/templates/consent-video-trial.mustache b/packages/record/templates/consent-video-trial.mustache new file mode 100644 index 00000000..64a4b269 --- /dev/null +++ b/packages/record/templates/consent-video-trial.mustache @@ -0,0 +1,8 @@ + +
+

+ + + + +

diff --git a/packages/record/templates/playback-feed.mustache b/packages/record/templates/playback-feed.mustache new file mode 100644 index 00000000..e31f2a10 --- /dev/null +++ b/packages/record/templates/playback-feed.mustache @@ -0,0 +1,12 @@ + + diff --git a/packages/record/templates/uploading-video.mustache b/packages/record/templates/uploading-video.mustache index 7770085d..d33351eb 100644 --- a/packages/record/templates/uploading-video.mustache +++ b/packages/record/templates/uploading-video.mustache @@ -1 +1 @@ -
Uploading video, please wait...
\ No newline at end of file +
Uploading video, please wait...
diff --git a/packages/record/templates/webcam-feed.mustache b/packages/record/templates/webcam-feed.mustache index 93f1c00e..eb4f0a02 100644 --- a/packages/record/templates/webcam-feed.mustache +++ b/packages/record/templates/webcam-feed.mustache @@ -1,9 +1,10 @@ - \ No newline at end of file + + \ No newline at end of file diff --git a/packages/record/tsconfig.json b/packages/record/tsconfig.json index c0697c56..bd1b3c2b 100644 --- a/packages/record/tsconfig.json +++ b/packages/record/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "baseUrl": ".", "esModuleInterop": true, + "resolveJsonModule": true, "strict": true }, "extends": "@jspsych/config/tsconfig.json", diff --git a/packages/style/package.json b/packages/style/package.json index b5583462..5182f7f2 100644 --- a/packages/style/package.json +++ b/packages/style/package.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@jspsych/config": "^3.0.0", + "@lookit/record": "^0.0.1", "@lookit/surveys": "^0.0.1", "rollup-plugin-scss": "^4.0.0", "sass": "^1.78.0", diff --git a/packages/style/scss/index.scss b/packages/style/scss/index.scss index d822a2b7..2a66a0fd 100644 --- a/packages/style/scss/index.scss +++ b/packages/style/scss/index.scss @@ -1 +1,2 @@ @import "@lookit/surveys/scss"; +@import "@lookit/record/scss"; diff --git a/packages/style/src/index.css b/packages/style/src/index.css index c285ef8d..386fb351 100644 --- a/packages/style/src/index.css +++ b/packages/style/src/index.css @@ -8902,4 +8902,17 @@ div#sv-nav-complete.sv-action.sv-action--hidden { .jspsych-row-multiple > div { min-width: unset !important; +} + +img#record-icon, +img#play-icon { + top: 7px; + left: 7px; + height: 25px; + position: absolute; +} + +div#lookit-jspsych-video-container { + position: relative; + width: min-content; } \ No newline at end of file diff --git a/packages/surveys/README.md b/packages/surveys/README.md index 4a8798e7..2f9bf97c 100644 --- a/packages/surveys/README.md +++ b/packages/surveys/README.md @@ -10,7 +10,7 @@ The Consent Survey will will give you two things out of the box: - Support for Markdown will be added. ```javascript -const consentSurvey = { type: chsSurvey.consent }; +const consentSurvey = { type: chsSurvey.ConsentSurveyPlugin }; ``` Other than that, the rest of the survey is entirely designed by you. Please refer to [jsPsych's Documentation]({{ jsPsych }}plugins/survey/) for the full explanation on how to use their plugin. @@ -20,7 +20,7 @@ Other than that, the rest of the survey is entirely designed by you. Please refe Unlike the consent survey, this survey is already designed with a few parameters for you to adjust to suit your study. ```javascript -const exitSurvey = { type: chsSurvey.exit }; +const exitSurvey = { type: chsSurvey.ExitSurveyPlugin }; ``` ### Parameters diff --git a/packages/surveys/src/consent.spec.ts b/packages/surveys/src/consentSurvey.spec.ts similarity index 65% rename from packages/surveys/src/consent.spec.ts rename to packages/surveys/src/consentSurvey.spec.ts index 0f85fc35..2577f75a 100644 --- a/packages/surveys/src/consent.spec.ts +++ b/packages/surveys/src/consentSurvey.spec.ts @@ -1,4 +1,6 @@ -import { ConsentSurveyPlugin } from "./consent"; +import { ConsentSurveyPlugin } from "./consentSurvey"; + +jest.mock("jspsych"); test("Does consent survey return chsData correctly?", () => { expect(ConsentSurveyPlugin.chsData()).toMatchObject({ chs_type: "consent" }); diff --git a/packages/surveys/src/consent.ts b/packages/surveys/src/consentSurvey.ts similarity index 96% rename from packages/surveys/src/consent.ts rename to packages/surveys/src/consentSurvey.ts index 9c42349d..b3cd5064 100644 --- a/packages/surveys/src/consent.ts +++ b/packages/surveys/src/consentSurvey.ts @@ -3,7 +3,7 @@ import { TrialType } from "jspsych"; import { consentSurveyFunction } from "./utils"; type Info = typeof SurveyPlugin.info; -type Trial = TrialType; +export type Trial = TrialType; /** Consent Survey plugin extends jsPsych's Survey Plugin. */ export class ConsentSurveyPlugin extends SurveyPlugin { diff --git a/packages/surveys/src/exit.json b/packages/surveys/src/exit.json deleted file mode 100644 index ea87f289..00000000 --- a/packages/surveys/src/exit.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "pages": [ - { - "elements": [ - { - "description": "We ask again just to check for typos during registration or accidental selection of a different child at the start of the study.", - "inputType": "date", - "isRequired": true, - "maxValueExpression": "today()", - "name": "birthDate", - "title": "Please confirm your child's birthdate.", - "type": "text" - }, - { - "description": "Only authorized researchers will have access to information in the library. Researchers who are granted access must agree to maintain confidentiality and not use information for commercial purposes. Data sharing will lead to faster progress in research on human development and behavior. If you have any questions about the data-sharing library, please visit [Databrary](https://nyu.databrary.org/) or email ethics@databrary.org.", - "enableIf": "({withdrawal} empty) or ({withdrawal.length} = 0)", - "isRequired": true, - "name": "databraryShare", - "title": "Would you like to share your video and other data from this session with authorized users of the secure data library Databrary?", - "type": "boolean", - "valueFalse": "no", - "valueTrue": "yes" - }, - { - "choices": [ - { - "text": "**Private**: Video may only be viewed by authorized scientists", - "value": "private" - }, - { - "text": "**Scientific and educational**: Video may be shared for scientific or educational purposes. For example, we might show a video clip in a talk at a scientific conference or an undergraduate class about cognitive development, or include an image or video in a scientific paper. In some circumstances, video or images may be available online, for instance as supplemental material in a scientific paper.", - "value": "scientific" - }, - { - "text": "**Publicity**: Please select this option if you'd be excited about seeing your child featured on the Lookit website or in a news article about this study! Your video may be shared for publicity as well as scientific and educational purposes; it will never be used for commercial purposes. Video clips shared may be available online to the general public.", - "value": "public" - } - ], - "description": "", - "enableIf": "({withdrawal} empty) or ({withdrawal.length} = 0)", - "isRequired": true, - "name": "useOfMedia", - "title": "Use of video clips and images:", - "type": "radiogroup" - }, - { - "choices": [], - "defaultValue": [], - "isRequired": false, - "name": "withdrawal", - "title": "Withdrawal of video data", - "type": "checkbox" - }, - { - "autoGrow": true, - "name": "feedback", - "rows": 3, - "title": "How did it go? Do you have any suggestions for improving the study?", - "type": "comment" - } - ], - "name": "page1" - } - ], - "showQuestionNumbers": "off" -} diff --git a/packages/surveys/src/index.spec.ts b/packages/surveys/src/index.spec.ts index a9a0f96a..bc801dae 100644 --- a/packages/surveys/src/index.spec.ts +++ b/packages/surveys/src/index.spec.ts @@ -1,6 +1,6 @@ import SurveyPlugin from "@jspsych/plugin-survey"; import { initJsPsych } from "jspsych"; -import { Trial as ConsentTrial } from "./consent"; +import { Trial as ConsentTrial } from "./consentSurvey"; import { Trial as ExitTrial } from "./exit"; import Surveys from "./index"; import { consentSurveyFunction, exitSurveyFunction } from "./utils"; @@ -14,15 +14,8 @@ afterEach(() => { }); test("Consent Survey", () => { - Object.defineProperty(global, "document", { - value: { - addEventListener: jest.fn(), - querySelector: jest.fn().mockReturnValue({ style: { display: "" } }), - }, - }); - const jsPsych = initJsPsych(); - const consent = new Surveys.consent(jsPsych); + const consent = new Surveys.ConsentSurveyPlugin(jsPsych); const display_element = jest.fn() as unknown as HTMLElement; const trialInfo = { survey_function: jest.fn() } as unknown as ConsentTrial; @@ -42,7 +35,7 @@ test("Exit Survey", () => { }, }); - const exit = new Surveys.exit(initJsPsych()); + const exit = new Surveys.ExitSurveyPlugin(initJsPsych()); const display_element = jest.fn() as unknown as HTMLElement; const trialInfo = { survey_function: jest.fn(), @@ -55,12 +48,13 @@ test("Exit Survey", () => { expect(SurveyPlugin.prototype.trial).toHaveBeenCalledWith(display_element, { ...trialInfo, survey_function: exitSurveyFunction, - survey_json: Surveys.exit.prototype["surveyParameters"](trialInfo), + survey_json: + Surveys.ExitSurveyPlugin.prototype["surveyParameters"](trialInfo), }); }); test("Exit Survey private level only", () => { - const exit = new Surveys.exit(initJsPsych()); + const exit = new Surveys.ExitSurveyPlugin(initJsPsych()); const display_element = jest.fn() as unknown as HTMLElement; const trialInfo = { survey_function: jest.fn(), @@ -72,7 +66,7 @@ test("Exit Survey private level only", () => { }); test("Exit Survey include withdrawal example", () => { - const exit = new Surveys.exit(initJsPsych()); + const exit = new Surveys.ExitSurveyPlugin(initJsPsych()); const display_element = jest.fn() as unknown as HTMLElement; const trialInfo = { survey_function: jest.fn(), diff --git a/packages/surveys/src/index.ts b/packages/surveys/src/index.ts index 0f3d04d2..5da29037 100644 --- a/packages/surveys/src/index.ts +++ b/packages/surveys/src/index.ts @@ -1,4 +1,4 @@ -import { ConsentSurveyPlugin } from "./consent"; +import { ConsentSurveyPlugin } from "./consentSurvey"; import { ExitSurveyPlugin } from "./exit"; -export default { exit: ExitSurveyPlugin, consent: ConsentSurveyPlugin }; +export default { ExitSurveyPlugin, ConsentSurveyPlugin };