diff --git a/examples/jspsych-html-video-response.html b/examples/jspsych-html-video-response.html new file mode 100644 index 00000000..a433b55b --- /dev/null +++ b/examples/jspsych-html-video-response.html @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f7bb4a94..0d50f14a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2640,6 +2640,10 @@ "resolved": "packages/plugin-html-slider-response", "link": true }, + "node_modules/@jspsych/plugin-html-video-response": { + "resolved": "packages/plugin-html-video-response", + "link": true + }, "node_modules/@jspsych/plugin-iat-html": { "resolved": "packages/plugin-iat-html", "link": true @@ -16650,6 +16654,7 @@ } }, "packages/extension-record-video": { + "name": "@jspsych/extension-record-video", "version": "0.0.1", "license": "MIT", "devDependencies": { @@ -16941,6 +16946,18 @@ "jspsych": ">=7.1.0" } }, + "packages/plugin-html-video-response": { + "version": "0.0.1", + "license": "MIT", + "devDependencies": { + "@jspsych/config": "^1.3.0", + "@jspsych/test-utils": "^1.1.0", + "@types/resize-observer-browser": "^0.1.6" + }, + "peerDependencies": { + "jspsych": ">=7.1.0" + } + }, "packages/plugin-iat-html": { "name": "@jspsych/plugin-iat-html", "version": "1.1.1", @@ -19360,6 +19377,14 @@ "@jspsych/test-utils": "^1.1.0" } }, + "@jspsych/plugin-html-video-response": { + "version": "file:packages/plugin-html-video-response", + "requires": { + "@jspsych/config": "^1.3.0", + "@jspsych/test-utils": "^1.1.0", + "@types/resize-observer-browser": "^0.1.6" + } + }, "@jspsych/plugin-iat-html": { "version": "file:packages/plugin-iat-html", "requires": { diff --git a/packages/plugin-html-video-response/jest.config.cjs b/packages/plugin-html-video-response/jest.config.cjs new file mode 100644 index 00000000..6ac19d5c --- /dev/null +++ b/packages/plugin-html-video-response/jest.config.cjs @@ -0,0 +1 @@ +module.exports = require("@jspsych/config/jest").makePackageConfig(__dirname); diff --git a/packages/plugin-html-video-response/package.json b/packages/plugin-html-video-response/package.json new file mode 100644 index 00000000..f707a898 --- /dev/null +++ b/packages/plugin-html-video-response/package.json @@ -0,0 +1,44 @@ +{ + "name": "@jspsych/plugin-html-video-response", + "version": "0.0.1", + "description": "jsPsych plugin for displaying a stimulus and recording a video response through a camera", + "type": "module", + "main": "dist/index.cjs", + "exports": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "typings": "dist/index.d.ts", + "unpkg": "dist/index.browser.min.js", + "files": [ + "src", + "dist" + ], + "source": "src/index.ts", + "scripts": { + "test": "jest --passWithNoTests", + "test:watch": "npm test -- --watch", + "tsc": "tsc", + "build": "rollup --config", + "build:watch": "npm run build -- --watch" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/jspsych/jsPsych.git", + "directory": "packages/plugin-html-video-response" + }, + "author": "Josh de Leeuw", + "license": "MIT", + "bugs": { + "url": "https://github.com/jspsych/jsPsych/issues" + }, + "homepage": "https://www.jspsych.org/latest/plugins/html-video-response", + "peerDependencies": { + "jspsych": ">=7.1.0" + }, + "devDependencies": { + "@jspsych/config": "^1.3.0", + "@jspsych/test-utils": "^1.1.0", + "@types/resize-observer-browser": "^0.1.6" + } +} diff --git a/packages/plugin-html-video-response/rollup.config.mjs b/packages/plugin-html-video-response/rollup.config.mjs new file mode 100644 index 00000000..ca5428a9 --- /dev/null +++ b/packages/plugin-html-video-response/rollup.config.mjs @@ -0,0 +1,3 @@ +import { makeRollupConfig } from "@jspsych/config/rollup"; + +export default makeRollupConfig("jsPsychHtmlVideoResponse"); diff --git a/packages/plugin-html-video-response/src/index.ts b/packages/plugin-html-video-response/src/index.ts new file mode 100644 index 00000000..ae290661 --- /dev/null +++ b/packages/plugin-html-video-response/src/index.ts @@ -0,0 +1,235 @@ +import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych"; + +const info = { + name: "html-video-response", + parameters: { + /** The HTML string to be displayed */ + stimulus: { + type: ParameterType.HTML_STRING, + default: undefined, + }, + /** How long to show the stimulus. */ + stimulus_duration: { + type: ParameterType.INT, + default: null, + }, + /** How long to show the trial. */ + recording_duration: { + type: ParameterType.INT, + default: 2000, + }, + show_done_button: { + type: ParameterType.BOOL, + default: true, + }, + done_button_label: { + type: ParameterType.STRING, + default: "Continue", + }, + record_again_button_label: { + type: ParameterType.STRING, + default: "Record again", + }, + accept_button_label: { + type: ParameterType.STRING, + default: "Continue", + }, + allow_playback: { + type: ParameterType.BOOL, + default: false, + }, + save_video_url: { + type: ParameterType.BOOL, + default: false, + }, + }, +}; + +type Info = typeof info; + +/** + * html-video-response + * jsPsych plugin for displaying a stimulus and recording a video response through a camera + * @author Josh de Leeuw + * @see {@link https://www.jspsych.org/plugins/jspsych-html-video-response/ html-video-response plugin documentation on jspsych.org} + */ +class HtmlVideoResponsePlugin implements JsPsychPlugin { + static info = info; + private stimulus_start_time; + private recorder_start_time; + private recorder: MediaRecorder; + private video_url; + private response; + private load_resolver; + private rt: number = null; + private start_event_handler; + private stop_event_handler; + private data_available_handler; + private recorded_data_chunks = []; + + constructor(private jsPsych: JsPsych) {} + + trial(display_element: HTMLElement, trial: TrialType) { + this.recorder = this.jsPsych.pluginAPI.getCameraRecorder(); + + this.setupRecordingEvents(display_element, trial); + + this.startRecording(); + } + + private showDisplay(display_element, trial) { + let html = `
${trial.stimulus}
`; + + if (trial.show_done_button) { + html += `

`; + } + + display_element.innerHTML = html; + } + + private hideStimulus(display_element: HTMLElement) { + const el: HTMLElement = display_element.querySelector("#jspsych-html-video-response-stimulus"); + if (el) { + el.style.visibility = "hidden"; + } + } + + private addButtonEvent(display_element, trial) { + const btn = display_element.querySelector("#finish-trial"); + if (btn) { + btn.addEventListener("click", () => { + const end_time = performance.now(); + this.rt = Math.round(end_time - this.stimulus_start_time); + this.stopRecording().then(() => { + if (trial.allow_playback) { + this.showPlaybackControls(display_element, trial); + } else { + this.endTrial(display_element, trial); + } + }); + }); + } + } + + private setupRecordingEvents(display_element, trial) { + this.data_available_handler = (e) => { + if (e.data.size > 0) { + this.recorded_data_chunks.push(e.data); + } + }; + + this.stop_event_handler = () => { + const data = new Blob(this.recorded_data_chunks, { type: 'video/webm;codecs="vp9"' }); + this.video_url = URL.createObjectURL(data); + const reader = new FileReader(); + reader.addEventListener("load", () => { + const base64 = (reader.result as string).split(",")[1]; + this.response = base64; + this.load_resolver(); + }); + reader.readAsDataURL(data); + }; + + this.start_event_handler = (e) => { + // resets the recorded data + this.recorded_data_chunks.length = 0; + + this.recorder_start_time = e.timeStamp; + this.showDisplay(display_element, trial); + this.addButtonEvent(display_element, trial); + + // setup timer for hiding the stimulus + if (trial.stimulus_duration !== null) { + this.jsPsych.pluginAPI.setTimeout(() => { + this.hideStimulus(display_element); + }, trial.stimulus_duration); + } + + // setup timer for ending the trial + if (trial.recording_duration !== null) { + this.jsPsych.pluginAPI.setTimeout(() => { + // this check is necessary for cases where the + // done_button is clicked before the timer expires + if (this.recorder.state !== "inactive") { + this.stopRecording().then(() => { + if (trial.allow_playback) { + this.showPlaybackControls(display_element, trial); + } else { + this.endTrial(display_element, trial); + } + }); + } + }, trial.recording_duration); + } + }; + + this.recorder.addEventListener("dataavailable", this.data_available_handler); + + this.recorder.addEventListener("stop", this.stop_event_handler); + + this.recorder.addEventListener("start", this.start_event_handler); + } + + private startRecording() { + this.recorder.start(); + } + + private stopRecording() { + this.recorder.stop(); + return new Promise((resolve) => { + this.load_resolver = resolve; + }); + } + + private showPlaybackControls(display_element, trial) { + display_element.innerHTML = ` +

+ + + `; + + display_element.querySelector("#record-again").addEventListener("click", () => { + // release object url to save memory + URL.revokeObjectURL(this.video_url); + this.startRecording(); + }); + display_element.querySelector("#continue").addEventListener("click", () => { + this.endTrial(display_element, trial); + }); + + // const video = display_element.querySelector('#playback'); + // video.src = + } + + private endTrial(display_element, trial) { + // clear recordering event handler + + this.recorder.removeEventListener("dataavailable", this.data_available_handler); + this.recorder.removeEventListener("start", this.start_event_handler); + this.recorder.removeEventListener("stop", this.stop_event_handler); + + // kill any remaining setTimeout handlers + this.jsPsych.pluginAPI.clearAllTimeouts(); + + // gather the data to store for the trial + var trial_data: any = { + rt: this.rt, + stimulus: trial.stimulus, + response: this.response, + }; + + if (trial.save_video_url) { + trial_data.video_url = this.video_url; + } else { + URL.revokeObjectURL(this.video_url); + } + + // clear the display + display_element.innerHTML = ""; + + // move on to the next trial + this.jsPsych.finishTrial(trial_data); + } +} + +export default HtmlVideoResponsePlugin; diff --git a/packages/plugin-html-video-response/tsconfig.json b/packages/plugin-html-video-response/tsconfig.json new file mode 100644 index 00000000..588f0448 --- /dev/null +++ b/packages/plugin-html-video-response/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@jspsych/config/tsconfig.core.json", + "compilerOptions": { + "baseUrl": "." + }, + "include": ["src"] +}