Skip to content

Commit

Permalink
Video file names in standard format (#70)
Browse files Browse the repository at this point in the history
* add recorder method for constructing video file name and add/modify params for recorder.start (consent boolean, trial type string)
* update recorder.start params in consentVideo test
* add mock for jsPsych.getCurrentTrial (passes current trial type from trial recording extension to recorder.start)
* update recorder.start params in existing tests, mock getLastTrialData, test new createFileName method
* add changeset
* add private getCurrentPluginName method to trial extension, get plugin name from info, fix types, fix/add tests
  • Loading branch information
becky-gilbert authored Oct 25, 2024
1 parent 9114f0a commit 2a3ce6d
Show file tree
Hide file tree
Showing 9 changed files with 142 additions and 16 deletions.
6 changes: 6 additions & 0 deletions .changeset/rotten-cars-swim.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/record/src/consentVideo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/record/src/consentVideo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export class VideoConsentPlugin implements JsPsychPlugin<Info> {
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);
});
}

Expand Down
12 changes: 9 additions & 3 deletions packages/record/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } } }),
}),
}));

/**
Expand Down Expand Up @@ -41,14 +44,17 @@ 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();
trialRec.on_finish();

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 () => {
Expand Down
66 changes: 64 additions & 2 deletions packages/record/src/recorder.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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"),
Expand All @@ -35,6 +49,13 @@ jest.mock("jspsych", () => ({
},
}),
},
data: {
getLastTrialData: jest.fn().mockReturnValue({
values: jest
.fn()
.mockReturnValue([{ trial_type: "test-type", trial_index: 0 }]),
}),
},
}),
}));

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
);

Expand Down Expand Up @@ -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();
});
38 changes: 33 additions & 5 deletions packages/record/src/recorder.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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 "_<TIMESTAMP>.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) {
Expand Down Expand Up @@ -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`;
}
}
8 changes: 5 additions & 3 deletions packages/record/src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ export default class StartRecordPlugin implements JsPsychPlugin<Info> {

/** 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();
});
}
}
16 changes: 15 additions & 1 deletion packages/record/src/trial.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -9,6 +10,7 @@ export default class TrialRecordExtension implements JsPsychExtension {
};

private recorder?: Recorder;
private pluginName: string | undefined;

/**
* Video recording extension.
Expand All @@ -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}`);
}

/**
Expand All @@ -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;
}
}
8 changes: 8 additions & 0 deletions packages/record/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import { JsPsychPlugin, PluginInfo } from "jspsych";
import { Class } from "type-fest";

export interface jsPsychPluginWithInfo
extends Class<JsPsychPlugin<PluginInfo>> {
info: PluginInfo;
}

/**
* A valid CSS height/width value, which can be a number, a string containing a
* number with units, or 'auto'.
Expand Down

0 comments on commit 2a3ce6d

Please sign in to comment.