@lookit/record 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -39
- package/dist/index.browser.js +96 -33
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.min.js +13 -13
- package/dist/index.browser.min.js.map +1 -1
- package/dist/index.cjs +95 -32
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +42 -3
- package/dist/index.js +95 -32
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/consentVideo.spec.ts +38 -11
- package/src/consentVideo.ts +75 -18
- package/src/errors.ts +7 -27
- package/src/recorder.spec.ts +208 -1
- package/src/recorder.ts +36 -5
- package/src/videoConfig.spec.ts +115 -0
- package/src/videoConfig.ts +38 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lookit/record",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Recording extensions and plugins for CHS studies.",
|
|
5
5
|
"homepage": "https://github.com/lookit/lookit-jspsych#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"@lookit/data": "^0.2.0",
|
|
45
|
-
"@lookit/templates": "^2.
|
|
45
|
+
"@lookit/templates": "^2.1.0",
|
|
46
46
|
"jspsych": "^8.0.3"
|
|
47
47
|
}
|
|
48
48
|
}
|
package/src/consentVideo.spec.ts
CHANGED
|
@@ -6,11 +6,7 @@ import { initJsPsych, PluginInfo, TrialType } from "jspsych";
|
|
|
6
6
|
import playbackFeed from "../hbs/playback-feed.hbs";
|
|
7
7
|
import recordFeed from "../hbs/record-feed.hbs";
|
|
8
8
|
import { VideoConsentPlugin } from "./consentVideo";
|
|
9
|
-
import {
|
|
10
|
-
ButtonNotFoundError,
|
|
11
|
-
ImageNotFoundError,
|
|
12
|
-
VideoContainerNotFoundError,
|
|
13
|
-
} from "./errors";
|
|
9
|
+
import { ElementNotFoundError } from "./errors";
|
|
14
10
|
import Recorder from "./recorder";
|
|
15
11
|
|
|
16
12
|
declare const window: LookitWindow;
|
|
@@ -67,7 +63,21 @@ test("GetVideoContainer error when no container", () => {
|
|
|
67
63
|
const plugin = new VideoConsentPlugin(jsPsych);
|
|
68
64
|
expect(() =>
|
|
69
65
|
plugin["getVideoContainer"](document.createElement("div")),
|
|
70
|
-
).toThrow(
|
|
66
|
+
).toThrow(ElementNotFoundError);
|
|
67
|
+
expect(() =>
|
|
68
|
+
plugin["getVideoContainer"](document.createElement("div")),
|
|
69
|
+
).toThrow(`"${plugin["video_container_id"]}" div not found.`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("getMessageContainer error when no container", () => {
|
|
73
|
+
const jsPsych = initJsPsych();
|
|
74
|
+
const plugin = new VideoConsentPlugin(jsPsych);
|
|
75
|
+
expect(() =>
|
|
76
|
+
plugin["getMessageContainer"](document.createElement("div")),
|
|
77
|
+
).toThrow(ElementNotFoundError);
|
|
78
|
+
expect(() =>
|
|
79
|
+
plugin["getMessageContainer"](document.createElement("div")),
|
|
80
|
+
).toThrow(`"${plugin["msg_container_id"]}" div not found.`);
|
|
71
81
|
});
|
|
72
82
|
|
|
73
83
|
test("GetVideoContainer", () => {
|
|
@@ -81,6 +91,17 @@ test("GetVideoContainer", () => {
|
|
|
81
91
|
expect(html).toBe(`<div id="lookit-jspsych-video-container"></div>`);
|
|
82
92
|
});
|
|
83
93
|
|
|
94
|
+
test("getMessageContainer", () => {
|
|
95
|
+
const jsPsych = initJsPsych();
|
|
96
|
+
const plugin = new VideoConsentPlugin(jsPsych);
|
|
97
|
+
const display = document.createElement("div");
|
|
98
|
+
|
|
99
|
+
display.innerHTML = `<div id="${plugin["msg_container_id"]}"></div>`;
|
|
100
|
+
|
|
101
|
+
const html = plugin["getMessageContainer"](display).outerHTML;
|
|
102
|
+
expect(html).toBe(`<div id="lookit-jspsych-video-msg-container"></div>`);
|
|
103
|
+
});
|
|
104
|
+
|
|
84
105
|
test("recordFeed", () => {
|
|
85
106
|
const jsPsych = initJsPsych();
|
|
86
107
|
const plugin = new VideoConsentPlugin(jsPsych);
|
|
@@ -103,7 +124,7 @@ test("playbackFeed", () => {
|
|
|
103
124
|
const vidContainer = "some video container";
|
|
104
125
|
|
|
105
126
|
plugin["getVideoContainer"] = jest.fn().mockReturnValue(vidContainer);
|
|
106
|
-
plugin["
|
|
127
|
+
plugin["onPlaybackEnded"] = jest.fn().mockReturnValue("some func");
|
|
107
128
|
plugin["playbackFeed"](display);
|
|
108
129
|
|
|
109
130
|
expect(Recorder.prototype.insertPlaybackFeed).toHaveBeenCalledWith(
|
|
@@ -112,7 +133,7 @@ test("playbackFeed", () => {
|
|
|
112
133
|
);
|
|
113
134
|
});
|
|
114
135
|
|
|
115
|
-
test("
|
|
136
|
+
test("onPlaybackEnded", () => {
|
|
116
137
|
const jsPsych = initJsPsych();
|
|
117
138
|
const plugin = new VideoConsentPlugin(jsPsych);
|
|
118
139
|
const display = document.createElement("div");
|
|
@@ -145,7 +166,7 @@ test("onEnded", () => {
|
|
|
145
166
|
return "";
|
|
146
167
|
});
|
|
147
168
|
|
|
148
|
-
plugin["
|
|
169
|
+
plugin["onPlaybackEnded"](display)();
|
|
149
170
|
|
|
150
171
|
expect(plugin["recordFeed"]).toHaveBeenCalledWith(display);
|
|
151
172
|
expect(plugin["recordFeed"]).toHaveBeenCalledTimes(1);
|
|
@@ -174,7 +195,10 @@ test("getButton error when button not found", () => {
|
|
|
174
195
|
const display = document.createElement("div");
|
|
175
196
|
|
|
176
197
|
expect(() => plugin["getButton"](display, "next")).toThrow(
|
|
177
|
-
|
|
198
|
+
ElementNotFoundError,
|
|
199
|
+
);
|
|
200
|
+
expect(() => plugin["getButton"](display, "next")).toThrow(
|
|
201
|
+
`"next" button not found.`,
|
|
178
202
|
);
|
|
179
203
|
});
|
|
180
204
|
|
|
@@ -183,7 +207,10 @@ test("getImg error when image not found", () => {
|
|
|
183
207
|
const plugin = new VideoConsentPlugin(jsPsych);
|
|
184
208
|
const display = document.createElement("div");
|
|
185
209
|
expect(() => plugin["getImg"](display, "record-icon")).toThrow(
|
|
186
|
-
|
|
210
|
+
ElementNotFoundError,
|
|
211
|
+
);
|
|
212
|
+
expect(() => plugin["getImg"](display, "record-icon")).toThrow(
|
|
213
|
+
`"record-icon" img not found.`,
|
|
187
214
|
);
|
|
188
215
|
});
|
|
189
216
|
|
package/src/consentVideo.ts
CHANGED
|
@@ -3,11 +3,7 @@ import { LookitWindow } from "@lookit/data/dist/types";
|
|
|
3
3
|
import chsTemplates from "@lookit/templates";
|
|
4
4
|
import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
|
|
5
5
|
import { version } from "../package.json";
|
|
6
|
-
import {
|
|
7
|
-
ButtonNotFoundError,
|
|
8
|
-
ImageNotFoundError,
|
|
9
|
-
VideoContainerNotFoundError,
|
|
10
|
-
} from "./errors";
|
|
6
|
+
import { ElementNotFoundError } from "./errors";
|
|
11
7
|
import Recorder from "./recorder";
|
|
12
8
|
|
|
13
9
|
declare const window: LookitWindow;
|
|
@@ -71,6 +67,11 @@ export class VideoConsentPlugin implements JsPsychPlugin<Info> {
|
|
|
71
67
|
public static readonly info = info;
|
|
72
68
|
private readonly recorder: Recorder;
|
|
73
69
|
private readonly video_container_id = "lookit-jspsych-video-container";
|
|
70
|
+
private readonly msg_container_id = "lookit-jspsych-video-msg-container";
|
|
71
|
+
private uploadingMsg: string | null = null;
|
|
72
|
+
private startingMsg: string | null = null;
|
|
73
|
+
private recordingMsg: string | null = null;
|
|
74
|
+
private notRecordingMsg: string | null = null;
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
77
|
* Instantiate video consent plugin.
|
|
@@ -89,7 +90,7 @@ export class VideoConsentPlugin implements JsPsychPlugin<Info> {
|
|
|
89
90
|
* @param trial - Trial data including user supplied parameters.
|
|
90
91
|
*/
|
|
91
92
|
public trial(display: HTMLElement, trial: TrialType<Info>) {
|
|
92
|
-
// Get trial HTML string
|
|
93
|
+
// Get trial HTML string from templates package. This will also set the i18n locale.
|
|
93
94
|
const consentVideo = chsTemplates.consentVideo(trial);
|
|
94
95
|
|
|
95
96
|
// Add rendered document to display HTML
|
|
@@ -97,11 +98,26 @@ export class VideoConsentPlugin implements JsPsychPlugin<Info> {
|
|
|
97
98
|
|
|
98
99
|
// Video recording HTML
|
|
99
100
|
this.recordFeed(display);
|
|
101
|
+
// Set event listeners for buttons.
|
|
100
102
|
this.recordButton(display);
|
|
101
103
|
this.stopButton(display);
|
|
102
104
|
this.playButton(display);
|
|
103
105
|
this.nextButton(display);
|
|
106
|
+
// Translate and store any messages that may need to be shown. Locale has already been set via the chsTemplates consentVideo method.
|
|
107
|
+
this.uploadingMsg = chsTemplates.translateString(
|
|
108
|
+
"exp-lookit-video-consent.Stopping-and-uploading",
|
|
109
|
+
);
|
|
110
|
+
this.startingMsg = chsTemplates.translateString(
|
|
111
|
+
"exp-lookit-video-consent.Starting-recorder",
|
|
112
|
+
);
|
|
113
|
+
this.recordingMsg = chsTemplates.translateString(
|
|
114
|
+
"exp-lookit-video-consent.Recording",
|
|
115
|
+
);
|
|
116
|
+
this.notRecordingMsg = chsTemplates.translateString(
|
|
117
|
+
"exp-lookit-video-consent.Not-recording",
|
|
118
|
+
);
|
|
104
119
|
}
|
|
120
|
+
|
|
105
121
|
/**
|
|
106
122
|
* Retrieve video container element.
|
|
107
123
|
*
|
|
@@ -114,7 +130,7 @@ export class VideoConsentPlugin implements JsPsychPlugin<Info> {
|
|
|
114
130
|
);
|
|
115
131
|
|
|
116
132
|
if (!videoContainer) {
|
|
117
|
-
throw new
|
|
133
|
+
throw new ElementNotFoundError(this.video_container_id, "div");
|
|
118
134
|
}
|
|
119
135
|
|
|
120
136
|
return videoContainer;
|
|
@@ -138,23 +154,59 @@ export class VideoConsentPlugin implements JsPsychPlugin<Info> {
|
|
|
138
154
|
*/
|
|
139
155
|
private playbackFeed(display: HTMLElement) {
|
|
140
156
|
const videoContainer = this.getVideoContainer(display);
|
|
141
|
-
this.recorder.insertPlaybackFeed(
|
|
157
|
+
this.recorder.insertPlaybackFeed(
|
|
158
|
+
videoContainer,
|
|
159
|
+
this.onPlaybackEnded(display),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get message container that appears alongside the video element.
|
|
165
|
+
*
|
|
166
|
+
* @param display - HTML element for experiment.
|
|
167
|
+
* @returns Message container div element.
|
|
168
|
+
*/
|
|
169
|
+
private getMessageContainer(display: HTMLElement) {
|
|
170
|
+
const msgContainer = display.querySelector<HTMLDivElement>(
|
|
171
|
+
`div#${this.msg_container_id}`,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
if (!msgContainer) {
|
|
175
|
+
throw new ElementNotFoundError(this.msg_container_id, "div");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return msgContainer;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Add HTML-formatted message alongside the video feed, e.g. for waiting
|
|
183
|
+
* periods during webcam feed transitions (starting, stopping/uploading). This
|
|
184
|
+
* will also replace an existing message with the new one. To clear any
|
|
185
|
+
* existing messages, pass an empty string.
|
|
186
|
+
*
|
|
187
|
+
* @param display - HTML element for experiment.
|
|
188
|
+
* @param message - HTML content for message div.
|
|
189
|
+
*/
|
|
190
|
+
private addMessage(display: HTMLElement, message: string) {
|
|
191
|
+
const msgContainer = this.getMessageContainer(display);
|
|
192
|
+
msgContainer.innerHTML = message;
|
|
142
193
|
}
|
|
143
194
|
|
|
144
195
|
/**
|
|
145
|
-
* Put back the webcam feed once the video recording has ended. This
|
|
146
|
-
* with the "ended" Event.
|
|
196
|
+
* Put back the webcam feed once the video recording playback has ended. This
|
|
197
|
+
* is used with the "ended" Event.
|
|
147
198
|
*
|
|
148
199
|
* @param display - JsPsych display HTML element.
|
|
149
200
|
* @returns Event function
|
|
150
201
|
*/
|
|
151
|
-
private
|
|
202
|
+
private onPlaybackEnded(display: HTMLElement) {
|
|
152
203
|
return () => {
|
|
153
204
|
const next = this.getButton(display, "next");
|
|
154
205
|
const play = this.getButton(display, "play");
|
|
155
206
|
const record = this.getButton(display, "record");
|
|
156
207
|
|
|
157
208
|
this.recordFeed(display);
|
|
209
|
+
this.addMessage(display, this.notRecordingMsg!);
|
|
158
210
|
next.disabled = false;
|
|
159
211
|
play.disabled = false;
|
|
160
212
|
record.disabled = false;
|
|
@@ -174,7 +226,7 @@ export class VideoConsentPlugin implements JsPsychPlugin<Info> {
|
|
|
174
226
|
) {
|
|
175
227
|
const btn = display.querySelector<HTMLButtonElement>(`button#${id}`);
|
|
176
228
|
if (!btn) {
|
|
177
|
-
throw new
|
|
229
|
+
throw new ElementNotFoundError(id, "button");
|
|
178
230
|
}
|
|
179
231
|
return btn;
|
|
180
232
|
}
|
|
@@ -190,7 +242,7 @@ export class VideoConsentPlugin implements JsPsychPlugin<Info> {
|
|
|
190
242
|
const img = display.querySelector<HTMLImageElement>(`img#${id}`);
|
|
191
243
|
|
|
192
244
|
if (!img) {
|
|
193
|
-
throw new
|
|
245
|
+
throw new ElementNotFoundError(id, "img");
|
|
194
246
|
}
|
|
195
247
|
|
|
196
248
|
return img;
|
|
@@ -208,12 +260,14 @@ export class VideoConsentPlugin implements JsPsychPlugin<Info> {
|
|
|
208
260
|
const next = this.getButton(display, "next");
|
|
209
261
|
|
|
210
262
|
record.addEventListener("click", async () => {
|
|
263
|
+
this.addMessage(display, this.startingMsg!);
|
|
211
264
|
record.disabled = true;
|
|
212
|
-
stop.disabled = false;
|
|
213
265
|
play.disabled = true;
|
|
214
266
|
next.disabled = true;
|
|
215
|
-
this.getImg(display, "record-icon").style.visibility = "visible";
|
|
216
267
|
await this.recorder.start(true, VideoConsentPlugin.info.name);
|
|
268
|
+
this.getImg(display, "record-icon").style.visibility = "visible";
|
|
269
|
+
this.addMessage(display, this.recordingMsg!);
|
|
270
|
+
stop.disabled = false;
|
|
217
271
|
});
|
|
218
272
|
}
|
|
219
273
|
|
|
@@ -245,11 +299,14 @@ export class VideoConsentPlugin implements JsPsychPlugin<Info> {
|
|
|
245
299
|
|
|
246
300
|
stop.addEventListener("click", async () => {
|
|
247
301
|
stop.disabled = true;
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
await this.recorder.stop();
|
|
302
|
+
this.addMessage(display, this.uploadingMsg!);
|
|
303
|
+
await this.recorder.stop(true);
|
|
251
304
|
this.recorder.reset();
|
|
252
305
|
this.recordFeed(display);
|
|
306
|
+
this.getImg(display, "record-icon").style.visibility = "hidden";
|
|
307
|
+
this.addMessage(display, this.notRecordingMsg!);
|
|
308
|
+
play.disabled = false;
|
|
309
|
+
record.disabled = false;
|
|
253
310
|
});
|
|
254
311
|
}
|
|
255
312
|
/**
|
package/src/errors.ts
CHANGED
|
@@ -196,36 +196,16 @@ export class CreateURLError extends Error {
|
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
/** Error thrown when
|
|
200
|
-
export class
|
|
201
|
-
/** No video container found. */
|
|
202
|
-
public constructor() {
|
|
203
|
-
super("Video Container could not be found.");
|
|
204
|
-
this.name = "VideoContainerError";
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** Error thrown when button not found. */
|
|
209
|
-
export class ButtonNotFoundError extends Error {
|
|
199
|
+
/** Error thrown when an HTML element is not found. */
|
|
200
|
+
export class ElementNotFoundError extends Error {
|
|
210
201
|
/**
|
|
211
|
-
*
|
|
202
|
+
* Element couldn't be found by ID field.
|
|
212
203
|
*
|
|
213
204
|
* @param id - HTML ID parameter.
|
|
205
|
+
* @param tag - HTML tag of the element.
|
|
214
206
|
*/
|
|
215
|
-
public constructor(id: string) {
|
|
216
|
-
super(`"${id}"
|
|
217
|
-
this.name = "
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Throw Error when image couldn't be found. */
|
|
222
|
-
export class ImageNotFoundError extends Error {
|
|
223
|
-
/**
|
|
224
|
-
* Error when image couldn't be found by ID field.
|
|
225
|
-
*
|
|
226
|
-
* @param id - HTML ID parameter
|
|
227
|
-
*/
|
|
228
|
-
public constructor(id: string) {
|
|
229
|
-
super(`"${id}" image not found.`);
|
|
207
|
+
public constructor(id: string, tag: string) {
|
|
208
|
+
super(`"${id}" ${tag} not found.`);
|
|
209
|
+
this.name = "ElementNotFoundError";
|
|
230
210
|
}
|
|
231
211
|
}
|
package/src/recorder.spec.ts
CHANGED
|
@@ -40,6 +40,7 @@ jest.mock("jspsych", () => ({
|
|
|
40
40
|
pluginAPI: {
|
|
41
41
|
getCameraRecorder: jest.fn().mockReturnValue({
|
|
42
42
|
addEventListener: jest.fn(),
|
|
43
|
+
mimeType: "video/webm",
|
|
43
44
|
start: jest.fn(),
|
|
44
45
|
stop: jest.fn(),
|
|
45
46
|
stream: {
|
|
@@ -122,6 +123,7 @@ test("Recorder no stop promise", () => {
|
|
|
122
123
|
|
|
123
124
|
expect(async () => await rec.stop()).rejects.toThrow(NoStopPromiseError);
|
|
124
125
|
});
|
|
126
|
+
|
|
125
127
|
test("Recorder initialize error", () => {
|
|
126
128
|
const jsPsych = initJsPsych();
|
|
127
129
|
const rec = new Recorder(jsPsych);
|
|
@@ -273,6 +275,125 @@ test("Webcam feed is removed when stream access stops", async () => {
|
|
|
273
275
|
document.body.innerHTML = "";
|
|
274
276
|
});
|
|
275
277
|
|
|
278
|
+
test("Webcam feed container maintains size with recorder.stop(true)", async () => {
|
|
279
|
+
// Add webcam container to document body.
|
|
280
|
+
const webcam_container_id = "webcam-container";
|
|
281
|
+
document.body.innerHTML = `<div id="${webcam_container_id}"></div>`;
|
|
282
|
+
const webcam_div = document.getElementById(
|
|
283
|
+
webcam_container_id,
|
|
284
|
+
) as HTMLDivElement;
|
|
285
|
+
|
|
286
|
+
const jsPsych = initJsPsych();
|
|
287
|
+
const rec = new Recorder(jsPsych);
|
|
288
|
+
const stopPromise = Promise.resolve();
|
|
289
|
+
|
|
290
|
+
rec["stopPromise"] = stopPromise;
|
|
291
|
+
rec.insertWebcamFeed(webcam_div);
|
|
292
|
+
|
|
293
|
+
// Mock the return values for the video element's offsetHeight/offsetWidth, which are used to set the container size
|
|
294
|
+
jest
|
|
295
|
+
.spyOn(document.getElementsByTagName("video")[0], "offsetWidth", "get")
|
|
296
|
+
.mockImplementation(() => 400);
|
|
297
|
+
jest
|
|
298
|
+
.spyOn(document.getElementsByTagName("video")[0], "offsetHeight", "get")
|
|
299
|
+
.mockImplementation(() => 300);
|
|
300
|
+
|
|
301
|
+
await rec.stop(true);
|
|
302
|
+
|
|
303
|
+
// Container div's dimensions should match the video element dimensions
|
|
304
|
+
expect(
|
|
305
|
+
(document.getElementById(webcam_container_id) as HTMLDivElement).style
|
|
306
|
+
.width,
|
|
307
|
+
).toBe("400px");
|
|
308
|
+
expect(
|
|
309
|
+
(document.getElementById(webcam_container_id) as HTMLDivElement).style
|
|
310
|
+
.height,
|
|
311
|
+
).toBe("300px");
|
|
312
|
+
|
|
313
|
+
document.body.innerHTML = "";
|
|
314
|
+
// restore the offsetWidth/offsetHeight getters
|
|
315
|
+
jest.restoreAllMocks();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("Webcam feed container size is not maintained with recorder.stop(false)", async () => {
|
|
319
|
+
// Add webcam container to document body.
|
|
320
|
+
const webcam_container_id = "webcam-container";
|
|
321
|
+
document.body.innerHTML = `<div id="${webcam_container_id}"></div>`;
|
|
322
|
+
const webcam_div = document.getElementById(
|
|
323
|
+
webcam_container_id,
|
|
324
|
+
) as HTMLDivElement;
|
|
325
|
+
|
|
326
|
+
const jsPsych = initJsPsych();
|
|
327
|
+
const rec = new Recorder(jsPsych);
|
|
328
|
+
const stopPromise = Promise.resolve();
|
|
329
|
+
|
|
330
|
+
rec["stopPromise"] = stopPromise;
|
|
331
|
+
rec.insertWebcamFeed(webcam_div);
|
|
332
|
+
|
|
333
|
+
// Mock the return values for the video element offsetHeight/offsetWidth, which are used to set the container size
|
|
334
|
+
jest
|
|
335
|
+
.spyOn(document.getElementsByTagName("video")[0], "offsetWidth", "get")
|
|
336
|
+
.mockImplementation(() => 400);
|
|
337
|
+
jest
|
|
338
|
+
.spyOn(document.getElementsByTagName("video")[0], "offsetHeight", "get")
|
|
339
|
+
.mockImplementation(() => 300);
|
|
340
|
+
|
|
341
|
+
await rec.stop(false);
|
|
342
|
+
|
|
343
|
+
// Container div's dimensions should not be set
|
|
344
|
+
expect(
|
|
345
|
+
(document.getElementById(webcam_container_id) as HTMLDivElement).style
|
|
346
|
+
.width,
|
|
347
|
+
).toBe("");
|
|
348
|
+
expect(
|
|
349
|
+
(document.getElementById(webcam_container_id) as HTMLDivElement).style
|
|
350
|
+
.height,
|
|
351
|
+
).toBe("");
|
|
352
|
+
|
|
353
|
+
document.body.innerHTML = "";
|
|
354
|
+
// restore the offsetWidth/offsetHeight getters
|
|
355
|
+
jest.restoreAllMocks();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("Webcam feed container size is not maintained with recorder.stop()", async () => {
|
|
359
|
+
// Add webcam container to document body.
|
|
360
|
+
const webcam_container_id = "webcam-container";
|
|
361
|
+
document.body.innerHTML = `<div id="${webcam_container_id}"></div>`;
|
|
362
|
+
const webcam_div = document.getElementById(
|
|
363
|
+
webcam_container_id,
|
|
364
|
+
) as HTMLDivElement;
|
|
365
|
+
|
|
366
|
+
const jsPsych = initJsPsych();
|
|
367
|
+
const rec = new Recorder(jsPsych);
|
|
368
|
+
const stopPromise = Promise.resolve();
|
|
369
|
+
|
|
370
|
+
rec["stopPromise"] = stopPromise;
|
|
371
|
+
rec.insertWebcamFeed(webcam_div);
|
|
372
|
+
|
|
373
|
+
// Mock the return values for the video element offsetHeight/offsetWidth, which are used to set the container size
|
|
374
|
+
jest
|
|
375
|
+
.spyOn(document.getElementsByTagName("video")[0], "offsetWidth", "get")
|
|
376
|
+
.mockImplementation(() => 400);
|
|
377
|
+
jest
|
|
378
|
+
.spyOn(document.getElementsByTagName("video")[0], "offsetHeight", "get")
|
|
379
|
+
.mockImplementation(() => 300);
|
|
380
|
+
|
|
381
|
+
await rec.stop();
|
|
382
|
+
|
|
383
|
+
// Container div's dimensions should not be set
|
|
384
|
+
expect(
|
|
385
|
+
(document.getElementById(webcam_container_id) as HTMLDivElement).style
|
|
386
|
+
.width,
|
|
387
|
+
).toBe("");
|
|
388
|
+
expect(
|
|
389
|
+
(document.getElementById(webcam_container_id) as HTMLDivElement).style
|
|
390
|
+
.height,
|
|
391
|
+
).toBe("");
|
|
392
|
+
|
|
393
|
+
document.body.innerHTML = "";
|
|
394
|
+
jest.restoreAllMocks();
|
|
395
|
+
});
|
|
396
|
+
|
|
276
397
|
test("Recorder initializeRecorder", () => {
|
|
277
398
|
const jsPsych = initJsPsych();
|
|
278
399
|
const rec = new Recorder(jsPsych);
|
|
@@ -356,7 +477,7 @@ test("Recorder reset", () => {
|
|
|
356
477
|
expect(jsPsych.pluginAPI.initializeCameraRecorder).toHaveBeenCalledTimes(1);
|
|
357
478
|
expect(jsPsych.pluginAPI.initializeCameraRecorder).toHaveBeenCalledWith(
|
|
358
479
|
streamClone,
|
|
359
|
-
|
|
480
|
+
{ mimeType: "video/webm" },
|
|
360
481
|
);
|
|
361
482
|
expect(rec["blobs"]).toStrictEqual([]);
|
|
362
483
|
|
|
@@ -506,3 +627,89 @@ test("Recorder createFileName constructs video file names correctly", () => {
|
|
|
506
627
|
// Restore Math.random
|
|
507
628
|
jest.spyOn(global.Math, "random").mockRestore();
|
|
508
629
|
});
|
|
630
|
+
|
|
631
|
+
test("Initializing a new recorder gets the mime type from the initialization", () => {
|
|
632
|
+
const jsPsych = initJsPsych();
|
|
633
|
+
const originalInitializeCameraRecorder =
|
|
634
|
+
jsPsych.pluginAPI.initializeCameraRecorder;
|
|
635
|
+
|
|
636
|
+
const stream = {
|
|
637
|
+
active: true,
|
|
638
|
+
clone: jest.fn(),
|
|
639
|
+
getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]),
|
|
640
|
+
} as unknown as MediaStream;
|
|
641
|
+
|
|
642
|
+
jsPsych.pluginAPI.initializeCameraRecorder = jest
|
|
643
|
+
.fn()
|
|
644
|
+
.mockImplementation(
|
|
645
|
+
(stream: MediaStream, recorder_options: MediaRecorderOptions) => {
|
|
646
|
+
return {
|
|
647
|
+
addEventListener: jest.fn(),
|
|
648
|
+
mimeType: recorder_options.mimeType,
|
|
649
|
+
start: jest.fn(),
|
|
650
|
+
stop: jest.fn(),
|
|
651
|
+
stream: stream,
|
|
652
|
+
};
|
|
653
|
+
},
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockImplementation(() => {
|
|
657
|
+
return jsPsych.pluginAPI.initializeCameraRecorder(stream, recorder_options);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
// Initialize with vp9
|
|
661
|
+
let recorder_options: MediaRecorderOptions = {
|
|
662
|
+
mimeType: "video/webm;codecs=vp9,opus",
|
|
663
|
+
};
|
|
664
|
+
jsPsych.pluginAPI.initializeCameraRecorder(stream, recorder_options);
|
|
665
|
+
const rec_1 = new Recorder(jsPsych);
|
|
666
|
+
// Called twice per construction - once for stream clone and once for mime type
|
|
667
|
+
expect(jsPsych.pluginAPI.getCameraRecorder).toHaveBeenCalledTimes(2);
|
|
668
|
+
expect(rec_1["mimeType"]).toBe("video/webm;codecs=vp9,opus");
|
|
669
|
+
|
|
670
|
+
// Initialize with vp8
|
|
671
|
+
recorder_options = {
|
|
672
|
+
mimeType: "video/webm;codecs=vp8,opus",
|
|
673
|
+
};
|
|
674
|
+
jsPsych.pluginAPI.initializeCameraRecorder(stream, recorder_options);
|
|
675
|
+
const rec_2 = new Recorder(jsPsych);
|
|
676
|
+
expect(jsPsych.pluginAPI.getCameraRecorder).toHaveBeenCalledTimes(4);
|
|
677
|
+
expect(rec_2["mimeType"]).toBe("video/webm;codecs=vp8,opus");
|
|
678
|
+
|
|
679
|
+
jsPsych.pluginAPI.initializeCameraRecorder = originalInitializeCameraRecorder;
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test("New recorder uses a default mime type if none is set already", () => {
|
|
683
|
+
const jsPsych = initJsPsych();
|
|
684
|
+
const originalInitializeCameraRecorder =
|
|
685
|
+
jsPsych.pluginAPI.initializeCameraRecorder;
|
|
686
|
+
|
|
687
|
+
const stream = {
|
|
688
|
+
active: true,
|
|
689
|
+
clone: jest.fn(),
|
|
690
|
+
getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]),
|
|
691
|
+
} as unknown as MediaStream;
|
|
692
|
+
|
|
693
|
+
jsPsych.pluginAPI.initializeCameraRecorder = jest
|
|
694
|
+
.fn()
|
|
695
|
+
.mockImplementation((stream: MediaStream) => {
|
|
696
|
+
return {
|
|
697
|
+
addEventListener: jest.fn(),
|
|
698
|
+
start: jest.fn(),
|
|
699
|
+
stop: jest.fn(),
|
|
700
|
+
stream: stream,
|
|
701
|
+
};
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockImplementation(() => {
|
|
705
|
+
return jsPsych.pluginAPI.initializeCameraRecorder(stream);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
jsPsych.pluginAPI.initializeCameraRecorder(stream);
|
|
709
|
+
const rec = new Recorder(jsPsych);
|
|
710
|
+
// Called twice per construction - once for stream clone and once for mime type
|
|
711
|
+
expect(jsPsych.pluginAPI.getCameraRecorder).toHaveBeenCalledTimes(2);
|
|
712
|
+
expect(rec["mimeType"]).toBe("video/webm");
|
|
713
|
+
|
|
714
|
+
jsPsych.pluginAPI.initializeCameraRecorder = originalInitializeCameraRecorder;
|
|
715
|
+
});
|
package/src/recorder.ts
CHANGED
|
@@ -34,6 +34,7 @@ export default class Recorder {
|
|
|
34
34
|
private filename?: string;
|
|
35
35
|
private stopPromise?: Promise<void>;
|
|
36
36
|
private webcam_element_id = "lookit-jspsych-webcam";
|
|
37
|
+
private mimeType = "video/webm";
|
|
37
38
|
|
|
38
39
|
private streamClone: MediaStream;
|
|
39
40
|
|
|
@@ -45,6 +46,8 @@ export default class Recorder {
|
|
|
45
46
|
public constructor(private jsPsych: JsPsych) {
|
|
46
47
|
this.streamClone = this.stream.clone();
|
|
47
48
|
autoBind(this);
|
|
49
|
+
// Use the class instance's mimeType default as a fallback if we can't get the mime type from the initialized jsPsych recorder.
|
|
50
|
+
this.mimeType = this.recorder?.mimeType || this.mimeType;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
/**
|
|
@@ -99,7 +102,11 @@ export default class Recorder {
|
|
|
99
102
|
* @param opts - Media recorder options to use when setting up the recorder.
|
|
100
103
|
*/
|
|
101
104
|
public initializeRecorder(stream: MediaStream, opts?: MediaRecorderOptions) {
|
|
102
|
-
|
|
105
|
+
const recorder_options: MediaRecorderOptions = {
|
|
106
|
+
...opts,
|
|
107
|
+
mimeType: this.mimeType,
|
|
108
|
+
};
|
|
109
|
+
this.jsPsych.pluginAPI.initializeCameraRecorder(stream, recorder_options);
|
|
103
110
|
}
|
|
104
111
|
|
|
105
112
|
/** Reset the recorder to be used again. */
|
|
@@ -269,13 +276,19 @@ export default class Recorder {
|
|
|
269
276
|
* tracks, clear the webcam feed element (if there is one), and return the
|
|
270
277
|
* stop promise. This should only be called after recording has started.
|
|
271
278
|
*
|
|
279
|
+
* @param maintain_container_size - Optional boolean indicating whether or not
|
|
280
|
+
* to maintain the current size of the webcam feed container when removing
|
|
281
|
+
* the video element. Default is false. If true, the container will be
|
|
282
|
+
* resized to match the dimensions of the video element before it is
|
|
283
|
+
* removed. This is useful for avoiding layout jumps when the webcam
|
|
284
|
+
* container will be re-used during the trial.
|
|
272
285
|
* @returns Promise that resolves after the media recorder has stopped and
|
|
273
286
|
* final 'dataavailable' event has occurred, when the "stop" event-related
|
|
274
287
|
* callback function is called.
|
|
275
288
|
*/
|
|
276
|
-
public stop() {
|
|
289
|
+
public stop(maintain_container_size: boolean = false) {
|
|
290
|
+
this.clearWebcamFeed(maintain_container_size);
|
|
277
291
|
this.stopTracks();
|
|
278
|
-
this.clearWebcamFeed();
|
|
279
292
|
|
|
280
293
|
if (!this.stopPromise) {
|
|
281
294
|
throw new NoStopPromiseError();
|
|
@@ -346,12 +359,30 @@ export default class Recorder {
|
|
|
346
359
|
}
|
|
347
360
|
}
|
|
348
361
|
|
|
349
|
-
/**
|
|
350
|
-
|
|
362
|
+
/**
|
|
363
|
+
* Private helper to clear the webcam feed, if there is one. If remove is
|
|
364
|
+
* false, the video element source attribute is cleared and the parent div
|
|
365
|
+
* will be set to the same dimensions. This is useful for avoiding layout
|
|
366
|
+
* jumps when the webcam container and video element will be re-used during
|
|
367
|
+
* the trial.
|
|
368
|
+
*
|
|
369
|
+
* @param maintain_container_size - Boolean indicating whether or not to set
|
|
370
|
+
* the webcam feed container size before removing the video element
|
|
371
|
+
*/
|
|
372
|
+
private clearWebcamFeed(maintain_container_size: boolean) {
|
|
351
373
|
const webcam_feed_element = document.querySelector(
|
|
352
374
|
`#${this.webcam_element_id}`,
|
|
353
375
|
) as HTMLVideoElement;
|
|
354
376
|
if (webcam_feed_element) {
|
|
377
|
+
if (maintain_container_size) {
|
|
378
|
+
const parent_div = webcam_feed_element.parentElement as HTMLDivElement;
|
|
379
|
+
if (parent_div) {
|
|
380
|
+
const width = webcam_feed_element.offsetWidth;
|
|
381
|
+
const height = webcam_feed_element.offsetHeight;
|
|
382
|
+
parent_div.style.height = `${height}px`;
|
|
383
|
+
parent_div.style.width = `${width}px`;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
355
386
|
webcam_feed_element.remove();
|
|
356
387
|
}
|
|
357
388
|
}
|