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"]
+}