diff --git a/.changeset/rotten-cars-swim.md b/.changeset/rotten-cars-swim.md new file mode 100644 index 0000000..da1a0d9 --- /dev/null +++ b/.changeset/rotten-cars-swim.md @@ -0,0 +1,6 @@ +--- +"@lookit/record": patch +--- + +Change video file names to match existing format, and to pass necessary info to +AWS Lambda for saving video files to database. diff --git a/packages/record/src/consentVideo.spec.ts b/packages/record/src/consentVideo.spec.ts index fa048dd..26d03cc 100644 --- a/packages/record/src/consentVideo.spec.ts +++ b/packages/record/src/consentVideo.spec.ts @@ -233,7 +233,7 @@ test("recordButton", async () => { // Start recorder expect(Recorder.prototype.start).toHaveBeenCalledTimes(1); - expect(Recorder.prototype.start).toHaveBeenCalledWith("consent"); + expect(Recorder.prototype.start).toHaveBeenCalledWith(true, "consent-video"); }); test("playButton", () => { diff --git a/packages/record/src/consentVideo.ts b/packages/record/src/consentVideo.ts index 8d79349..4c5e52d 100644 --- a/packages/record/src/consentVideo.ts +++ b/packages/record/src/consentVideo.ts @@ -212,7 +212,7 @@ export class VideoConsentPlugin implements JsPsychPlugin { play.disabled = true; next.disabled = true; this.getImg(display, "record-icon").style.visibility = "visible"; - await this.recorder.start("consent"); + await this.recorder.start(true, VideoConsentPlugin.info.name); }); } diff --git a/packages/record/src/index.spec.ts b/packages/record/src/index.spec.ts index aa97f29..cf17c3d 100644 --- a/packages/record/src/index.spec.ts +++ b/packages/record/src/index.spec.ts @@ -10,9 +10,12 @@ jest.mock("./recorder"); jest.mock("@lookit/data"); jest.mock("jspsych", () => ({ ...jest.requireActual("jspsych"), - initJsPsych: jest - .fn() - .mockReturnValue({ finishTrial: jest.fn().mockImplementation() }), + initJsPsych: jest.fn().mockReturnValue({ + finishTrial: jest.fn().mockImplementation(), + getCurrentTrial: jest + .fn() + .mockReturnValue({ type: { info: { name: "test-type" } } }), + }), })); /** @@ -41,6 +44,7 @@ test("Trial recording", () => { const mockRecStop = jest.spyOn(Recorder.prototype, "stop"); const jsPsych = initJsPsych(); const trialRec = new Rec.TrialRecordExtension(jsPsych); + const getCurrentPluginNameSpy = jest.spyOn(trialRec, "getCurrentPluginName"); trialRec.on_start(); trialRec.on_load(); @@ -48,7 +52,9 @@ test("Trial recording", () => { expect(Recorder).toHaveBeenCalledTimes(1); expect(mockRecStart).toHaveBeenCalledTimes(1); + expect(mockRecStart).toHaveBeenCalledWith(false, "test-type"); expect(mockRecStop).toHaveBeenCalledTimes(1); + expect(getCurrentPluginNameSpy).toHaveBeenCalledTimes(1); }); test("Trial recording's initialize does nothing", async () => { diff --git a/packages/record/src/recorder.spec.ts b/packages/record/src/recorder.spec.ts index de9ead4..2fb77c8 100644 --- a/packages/record/src/recorder.spec.ts +++ b/packages/record/src/recorder.spec.ts @@ -1,4 +1,5 @@ import Data from "@lookit/data"; +import { LookitWindow } from "@lookit/data/dist/types"; import Handlebars from "handlebars"; import { initJsPsych } from "jspsych"; import playbackFeed from "../hbs/playback-feed.hbs"; @@ -19,6 +20,19 @@ import { import Recorder from "./recorder"; import { CSSWidthHeight } from "./types"; +declare const window: LookitWindow; + +window.chs = { + study: { + id: "123", + }, + response: { + id: "456", + }, +} as typeof window.chs; + +let originalDate: DateConstructor; + jest.mock("@lookit/data"); jest.mock("jspsych", () => ({ ...jest.requireActual("jspsych"), @@ -35,6 +49,13 @@ jest.mock("jspsych", () => ({ }, }), }, + data: { + getLastTrialData: jest.fn().mockReturnValue({ + values: jest + .fn() + .mockReturnValue([{ trial_type: "test-type", trial_index: 0 }]), + }), + }, }), })); @@ -68,7 +89,7 @@ test("Recorder start", async () => { const jsPsych = initJsPsych(); const rec = new Recorder(jsPsych); const media = jsPsych.pluginAPI.getCameraRecorder(); - await rec.start("consent"); + await rec.start(true, "video-consent"); expect(media.addEventListener).toHaveBeenCalledTimes(2); expect(media.start).toHaveBeenCalledTimes(1); @@ -112,7 +133,7 @@ test("Recorder initialize error", () => { .fn() .mockReturnValue(undefined); - expect(async () => await rec.start("consent")).rejects.toThrow( + expect(async () => await rec.start(true, "video-consent")).rejects.toThrow( RecorderInitializeError, ); @@ -444,3 +465,44 @@ test("Recorder insert record Feed with height/width", () => { Handlebars.compile(recordFeed)(view), ); }); + +test("Recorder createFileName constructs video file names correctly", () => { + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych); + + // Mock Date().getTime() timestamp + originalDate = Date; + const mockTimestamp = 1634774400000; + jest.spyOn(global, "Date").mockImplementation(() => { + return new originalDate(mockTimestamp); + }); + // Mock random 3-digit number + jest.spyOn(global.Math, "random").mockReturnValue(0.123456789); + const rand_digits = Math.floor(Math.random() * 1000); + + const index = jsPsych.data.getLastTrialData().values()[0].trial_index + 1; + const trial_type = "test-type"; + + // Consent prefix is "consent-videoStream" + expect(rec["createFileName"](true, trial_type)).toBe( + `consent-videoStream_${window.chs.study.id}_${index.toString()}-${trial_type}_${window.chs.response.id}_${mockTimestamp.toString()}_${rand_digits.toString()}.webm`, + ); + + // Non-consent prefix is "videoStream" + expect(rec["createFileName"](false, trial_type)).toBe( + `videoStream_${window.chs.study.id}_${index.toString()}-${trial_type}_${window.chs.response.id}_${mockTimestamp.toString()}_${rand_digits.toString()}.webm`, + ); + + // Trial index is 0 if there's no value for 'last trial index' (jsPsych data is empty) + jsPsych.data.getLastTrialData = jest.fn().mockReturnValueOnce({ + values: jest.fn().mockReturnValue([]), + }); + expect(rec["createFileName"](false, trial_type)).toBe( + `videoStream_${window.chs.study.id}_${0}-${trial_type}_${window.chs.response.id}_${mockTimestamp.toString()}_${rand_digits.toString()}.webm`, + ); + + // Restore the original Date constructor + global.Date = originalDate; + // Restore Math.random + jest.spyOn(global.Math, "random").mockRestore(); +}); diff --git a/packages/record/src/recorder.ts b/packages/record/src/recorder.ts index e0b3462..c802bb5 100644 --- a/packages/record/src/recorder.ts +++ b/packages/record/src/recorder.ts @@ -1,5 +1,6 @@ import Data from "@lookit/data"; import LookitS3 from "@lookit/data/dist/lookitS3"; +import { LookitWindow } from "@lookit/data/dist/types"; import autoBind from "auto-bind"; import Handlebars from "handlebars"; import { JsPsych } from "jspsych"; @@ -20,6 +21,8 @@ import { } from "./errors"; import { CSSWidthHeight } from "./types"; +declare const window: LookitWindow; + /** Recorder handles the state of recording and data storage. */ export default class Recorder { private url?: string; @@ -219,14 +222,16 @@ 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". + * @param consent - Boolean indicating whether or not the recording is consent + * footage. + * @param trial_type - Trial type, as saved in the jsPsych data. This comes + * from the plugin info "name" value (not the class name). */ - public async start(prefix: "consent" | "session_video" | "trial_video") { + public async start(consent: boolean, trial_type: string) { this.initializeCheck(); - // Set filename - this.filename = `${prefix}_${new Date().getTime()}.webm`; + // Set video filename + this.filename = this.createFileName(consent, trial_type); // Instantiate s3 object if (!this.localDownload) { @@ -350,4 +355,27 @@ export default class Recorder { webcam_feed_element.remove(); } } + + /** + * Creates a valid video file name based on parameters + * + * @param consent - Boolean indicating whether or not the recording is consent + * footage. + * @param trial_type - Trial type, as saved in the jsPsych data. This comes + * from the plugin info "name" value (not the class name). + * @returns File name string with .webm extension. + */ + private createFileName(consent: boolean, trial_type: string) { + // File name formats: + // consent: consent-videoStream_{study}_{frame_id}_{response}_{timestamp}_{random_digits}.webm + // non-consent: videoStream_{study}_{frame_id}_{response}_{timestamp}_{random_digits}.webm + const prefix = consent ? "consent-videoStream" : "videoStream"; + const last_data = this.jsPsych.data.getLastTrialData().values(); + const curr_trial_index = ( + last_data.length > 0 ? last_data[last_data.length - 1].trial_index + 1 : 0 + ).toString(); + const trial_id = `${curr_trial_index}-${trial_type}`; + const rand_digits = Math.floor(Math.random() * 1000); + return `${prefix}_${window.chs.study.id}_${trial_id}_${window.chs.response.id}_${new Date().getTime()}_${rand_digits}.webm`; + } } diff --git a/packages/record/src/start.ts b/packages/record/src/start.ts index 6e56958..5220922 100644 --- a/packages/record/src/start.ts +++ b/packages/record/src/start.ts @@ -29,8 +29,10 @@ export default class StartRecordPlugin implements JsPsychPlugin { /** Trial function called by jsPsych. */ public trial() { - this.recorder.start("session_video").then(() => { - this.jsPsych.finishTrial(); - }); + this.recorder + .start(false, `${StartRecordPlugin.info.name}-multiframe`) + .then(() => { + this.jsPsych.finishTrial(); + }); } } diff --git a/packages/record/src/trial.ts b/packages/record/src/trial.ts index f31576e..d5d6091 100644 --- a/packages/record/src/trial.ts +++ b/packages/record/src/trial.ts @@ -1,6 +1,7 @@ import autoBind from "auto-bind"; import { JsPsych, JsPsychExtension, JsPsychExtensionInfo } from "jspsych"; import Recorder from "./recorder"; +import { jsPsychPluginWithInfo } from "./types"; /** This extension will allow reasearchers to record trials. */ export default class TrialRecordExtension implements JsPsychExtension { @@ -9,6 +10,7 @@ export default class TrialRecordExtension implements JsPsychExtension { }; private recorder?: Recorder; + private pluginName: string | undefined; /** * Video recording extension. @@ -32,7 +34,8 @@ export default class TrialRecordExtension implements JsPsychExtension { /** Ran when the trial has loaded. */ public on_load() { - this.recorder?.start("trial_video"); + this.pluginName = this.getCurrentPluginName(); + this.recorder?.start(false, `${this.pluginName}`); } /** @@ -44,4 +47,15 @@ export default class TrialRecordExtension implements JsPsychExtension { this.recorder?.stop(); return {}; } + + /** + * Gets the plugin name for the trial that is being extended. This is same as + * the "trial_type" value that is stored in the data for this trial. + * + * @returns Plugin name string from the plugin class's info. + */ + private getCurrentPluginName() { + const current_plugin_class = this.jsPsych.getCurrentTrial().type; + return (current_plugin_class as jsPsychPluginWithInfo).info.name; + } } diff --git a/packages/record/src/types.ts b/packages/record/src/types.ts index 69b5043..24f04ab 100644 --- a/packages/record/src/types.ts +++ b/packages/record/src/types.ts @@ -1,3 +1,11 @@ +import { JsPsychPlugin, PluginInfo } from "jspsych"; +import { Class } from "type-fest"; + +export interface jsPsychPluginWithInfo + extends Class> { + info: PluginInfo; +} + /** * A valid CSS height/width value, which can be a number, a string containing a * number with units, or 'auto'.