@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lookit/record",
3
- "version": "3.0.0",
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.0.0",
45
+ "@lookit/templates": "^2.1.0",
46
46
  "jspsych": "^8.0.3"
47
47
  }
48
48
  }
@@ -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(VideoContainerNotFoundError);
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["onEnded"] = jest.fn().mockReturnValue("some func");
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("onEnded", () => {
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["onEnded"](display)();
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
- ButtonNotFoundError,
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
- ImageNotFoundError,
210
+ ElementNotFoundError,
211
+ );
212
+ expect(() => plugin["getImg"](display, "record-icon")).toThrow(
213
+ `"record-icon" img not found.`,
187
214
  );
188
215
  });
189
216
 
@@ -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 VideoContainerNotFoundError();
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(videoContainer, this.onEnded(display));
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 is used
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 onEnded(display: HTMLElement) {
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 ButtonNotFoundError(id);
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 ImageNotFoundError(id);
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
- record.disabled = false;
249
- play.disabled = false;
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 video container couldn't be found. */
200
- export class VideoContainerNotFoundError extends Error {
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
- * Button couldn't be found by ID field.
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}" button not found.`);
217
- this.name = "ButtonNotFoundError";
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
  }
@@ -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
- undefined,
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
- this.jsPsych.pluginAPI.initializeCameraRecorder(stream, opts);
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
- /** Private helper to clear the webcam feed, if there is one. */
350
- private clearWebcamFeed() {
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
  }