@lookit/record 4.1.0 → 5.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/src/recorder.ts CHANGED
@@ -11,6 +11,7 @@ import play_icon from "../img/play-icon.svg";
11
11
  import record_icon from "../img/record-icon.svg";
12
12
  import {
13
13
  CreateURLError,
14
+ NoFileNameError,
14
15
  NoStopPromiseError,
15
16
  NoWebCamElementError,
16
17
  RecorderInitializeError,
@@ -18,8 +19,10 @@ import {
18
19
  StreamActiveOnResetError,
19
20
  StreamDataInitializeError,
20
21
  StreamInactiveInitializeError,
22
+ TimeoutError,
21
23
  } from "./errors";
22
- import { CSSWidthHeight } from "./types";
24
+ import { CSSWidthHeight, StopOptions, StopResult } from "./types";
25
+ import { promiseWithTimeout } from "./utils";
23
26
 
24
27
  declare const window: LookitWindow;
25
28
 
@@ -32,10 +35,11 @@ export default class Recorder {
32
35
  private localDownload: boolean =
33
36
  process.env.LOCAL_DOWNLOAD?.toLowerCase() === "true";
34
37
  private filename?: string;
35
- private stopPromise?: Promise<void>;
38
+ private stopPromise?: Promise<string>;
36
39
  private webcam_element_id = "lookit-jspsych-webcam";
37
40
  private mimeType = "video/webm";
38
41
 
42
+ // persistent clone of the original stream so we can re-initialize
39
43
  private streamClone: MediaStream;
40
44
 
41
45
  /**
@@ -117,11 +121,15 @@ export default class Recorder {
117
121
  * the video config plugin).
118
122
  */
119
123
  public reset() {
124
+ // Reset can only be called after the current stream has been fully stopped.
120
125
  if (this.stream.active) {
121
126
  throw new StreamActiveOnResetError();
122
127
  }
128
+ // Ensure later recordings have a valid active stream.
123
129
  this.initializeRecorder(this.streamClone.clone());
130
+ // Clear blob buffer (any pending uploads are handled by LookitS3 instances and tracked globally)
124
131
  this.blobs = [];
132
+ // TO DO: reset S3/filename/URL?
125
133
  }
126
134
 
127
135
  /**
@@ -255,7 +263,7 @@ export default class Recorder {
255
263
 
256
264
  // create a stop promise and pass the resolve function as an argument to the stop event callback,
257
265
  // so that the stop event handler can resolve the stop promise
258
- this.stopPromise = new Promise((resolve) => {
266
+ this.stopPromise = new Promise<string>((resolve) => {
259
267
  this.recorder.addEventListener("stop", this.handleStop(resolve));
260
268
  });
261
269
 
@@ -282,24 +290,122 @@ export default class Recorder {
282
290
  * tracks, clear the webcam feed element (if there is one), and return the
283
291
  * stop promise. This should only be called after recording has started.
284
292
  *
285
- * @param maintain_container_size - Optional boolean indicating whether or not
286
- * to maintain the current size of the webcam feed container when removing
287
- * the video element. Default is false. If true, the container will be
288
- * resized to match the dimensions of the video element before it is
293
+ * When calling recorder.stop, plugins may:
294
+ *
295
+ * - Await the 'stopped' promise
296
+ * - Await the 'uploaded' promise to wait for the upload to finish before
297
+ * continuing
298
+ * - Not await either promise to continue immediately and regardless of the
299
+ * stop/upload outcomes
300
+ *
301
+ * @param options - Object with the following:
302
+ * @param options.maintain_container_size - Optional boolean indicating
303
+ * whether or not to maintain the current size of the webcam feed container
304
+ * when removing the video element. Default is false. If true, the container
305
+ * will be resized to match the dimensions of the video element before it is
289
306
  * removed. This is useful for avoiding layout jumps when the webcam
290
307
  * container will be re-used during the trial.
291
- * @returns Promise that resolves after the media recorder has stopped and
292
- * final 'dataavailable' event has occurred, when the "stop" event-related
293
- * callback function is called.
308
+ * @param options.stop_timeout_ms - Number of seconds to wait for the stop
309
+ * process to complete.
310
+ * @param options.upload_timeout_ms - Number of seconds to wait for the upload
311
+ * process to complete.
312
+ * @returns Object with two promises:
313
+ *
314
+ * - Stopped: Promise<void> - Promise that resolves after the media recorder has
315
+ * stopped and final 'dataavailable' event has occurred, when the "stop"
316
+ * event-related callback function is called.
317
+ * - Uploaded: Promise<void> - Promise that resolves when the S3 upload
318
+ * completes.
294
319
  */
295
- public stop(maintain_container_size: boolean = false) {
320
+ public stop({
321
+ maintain_container_size = false,
322
+ stop_timeout_ms = null,
323
+ upload_timeout_ms = 10000,
324
+ }: StopOptions = {}): StopResult {
325
+ this.preStopCheck();
296
326
  this.clearWebcamFeed(maintain_container_size);
297
327
  this.stopTracks();
298
328
 
299
- if (!this.stopPromise) {
300
- throw new NoStopPromiseError();
301
- }
302
- return this.stopPromise;
329
+ // Snapshot anything needed for upload before the Recorder instance is reset.
330
+ // URL is placeholder because it will not be defined until after the stop promise is resolved.
331
+ const snapshot = {
332
+ s3: !this.localDownload ? this.s3 : null,
333
+ filename: this.filename,
334
+ localDownload: this.localDownload,
335
+ url: "null",
336
+ };
337
+
338
+ // Wrap the existing stopPromise with timeout if needed, otherwise return as is.
339
+ const stopped: Promise<string> = stop_timeout_ms
340
+ ? promiseWithTimeout(
341
+ this.stopPromise!,
342
+ `${snapshot.filename}-stopped`,
343
+ stop_timeout_ms,
344
+ this.createTimeoutHandler("stop", snapshot.filename!),
345
+ )
346
+ : this.stopPromise!;
347
+
348
+ // Chain reset off the stop promise, which is either the original stop promise or a promise race with the timeout.
349
+ stopped.finally(() => {
350
+ try {
351
+ // It's safe to reset because recording is fully stopped and S3 info has been snapshotted.
352
+ this.reset();
353
+ } catch (err) {
354
+ console.error("Error while resetting recorder after stop: ", err);
355
+ }
356
+ });
357
+
358
+ // Create the upload (or local download) promise
359
+ const uploadPromise: Promise<void> = (async () => {
360
+ let url: string;
361
+ try {
362
+ url = await stopped;
363
+ if (url == "timeout") {
364
+ // Stop failed, throw so that the upload promise reflects this failure
365
+ throw new TimeoutError("Recorder stop timed out.");
366
+ }
367
+ } catch (err) {
368
+ console.warn("Upload failed because recorder stop timed out");
369
+ throw err;
370
+ }
371
+ snapshot.url = url;
372
+ if (snapshot.localDownload) {
373
+ try {
374
+ this.download(snapshot.filename!, snapshot.url);
375
+ await Promise.resolve();
376
+ } catch (err) {
377
+ console.error("Local download failed: ", err);
378
+ throw err;
379
+ }
380
+ } else {
381
+ try {
382
+ await snapshot.s3!.completeUpload();
383
+ } catch (err) {
384
+ console.error("Upload failed: ", err);
385
+ throw err;
386
+ }
387
+ }
388
+ })();
389
+
390
+ // Wrap the upload promise in a timeout if needed, otherwise return as is.
391
+ const uploaded: Promise<void | string> = upload_timeout_ms
392
+ ? promiseWithTimeout(
393
+ uploadPromise,
394
+ `${snapshot.filename}-uploaded`,
395
+ upload_timeout_ms,
396
+ this.createTimeoutHandler("upload", snapshot.filename!),
397
+ )
398
+ : uploadPromise;
399
+
400
+ // Track background uploads in case the consuming plugin is not awaiting the upload.
401
+ // We don't want the timeout version because this one can continue in the background.
402
+ window.chs.pendingUploads.push({
403
+ promise: uploadPromise,
404
+ file: snapshot.filename!,
405
+ });
406
+
407
+ // Return the pair of promises so that the calling plugin can await them.
408
+ return { stopped, uploaded };
303
409
  }
304
410
 
305
411
  /** Throw Error if there isn't a recorder provided by jsPsych. */
@@ -317,31 +423,42 @@ export default class Recorder {
317
423
  }
318
424
  }
319
425
 
426
+ /**
427
+ * Check for necessary conditions before stop/upload process, and throw errors
428
+ * if needed.
429
+ */
430
+ private preStopCheck() {
431
+ if (!this.recorder) {
432
+ throw new RecorderInitializeError();
433
+ }
434
+ if (!this.stream.active) {
435
+ throw new StreamInactiveInitializeError();
436
+ }
437
+ if (!this.stopPromise) {
438
+ throw new NoStopPromiseError();
439
+ }
440
+ if (!this.filename) {
441
+ throw new NoFileNameError();
442
+ }
443
+ }
444
+
320
445
  /**
321
446
  * Handle the recorder's stop event. This is a function that takes the stop
322
447
  * promise's 'resolve' as an argument and returns a function that resolves
323
- * that stop promise. The function that is returned is used as the recorder's
324
- * "stop" event-related callback function.
448
+ * that stop promise with the URL that is created from the recording.
325
449
  *
326
- * @param resolve - Promise resolve function.
327
- * @returns Function that is called on the recorder's "stop" event.
450
+ * @param resolve - Resolve function that resolves the stop promise that was
451
+ * created upon the start of recording.
452
+ * @returns Function that is called on the recorder's "stop" event. This
453
+ * function resolves the stop promise for this recording with a URL.
328
454
  */
329
- private handleStop(resolve: () => void) {
330
- return async () => {
455
+ private handleStop(resolve: (value: string | PromiseLike<string>) => void) {
456
+ return () => {
331
457
  if (this.blobs.length === 0) {
332
458
  throw new CreateURLError();
333
459
  }
334
460
  this.url = URL.createObjectURL(new Blob(this.blobs));
335
-
336
- if (this.localDownload) {
337
- this.download();
338
- } else {
339
- await this.s3.completeUpload();
340
- }
341
- // Reset the recorder. This is necessary to create another active media stream from the stream clone, because the current stream is fully stopped/inactive and cannot be used again.
342
- this.reset();
343
-
344
- resolve();
461
+ resolve(this.url);
345
462
  };
346
463
  }
347
464
 
@@ -351,18 +468,27 @@ export default class Recorder {
351
468
  * @param event - Event containing blob data.
352
469
  */
353
470
  private handleDataAvailable(event: BlobEvent) {
471
+ // Store locally for URL creation
354
472
  this.blobs.push(event.data);
355
473
  if (!this.localDownload) {
474
+ // Forward to LookitS3 instance, which manages uploading
356
475
  this.s3.onDataAvailable(event.data);
357
476
  }
358
477
  }
359
478
 
360
- /** Download data url used in local development. */
361
- private download() {
362
- if (this.filename && this.url) {
479
+ /**
480
+ * Download data url used in local development. This can be used on
481
+ * snapshotted recordings after the recorder has been reset, so we need to
482
+ * pass in the filename and URL rather than relying on this.
483
+ *
484
+ * @param filename - Filename for the recording that will be downloaded.
485
+ * @param url - URL containing the file data to be downloaded.
486
+ */
487
+ private download(filename: string, url: string) {
488
+ if (filename && url) {
363
489
  const link = document.createElement("a");
364
- link.href = this.url;
365
- link.download = this.filename;
490
+ link.href = url;
491
+ link.download = filename;
366
492
  link.click();
367
493
  }
368
494
  }
@@ -417,4 +543,26 @@ export default class Recorder {
417
543
  const rand_digits = Math.floor(Math.random() * 1000);
418
544
  return `${prefix}_${window.chs.study.id}_${trial_id}_${window.chs.response.id}_${new Date().getTime()}_${rand_digits}.webm`;
419
545
  }
546
+
547
+ /**
548
+ * Create the timeout handler function for events that we're awaiting with a
549
+ * timeout.
550
+ *
551
+ * @param eventName - Name of the event we're awaiting, e.g. 'stop', 'upload'
552
+ * @param id - String to identify the promise that we're awaiting.
553
+ * @returns Callback function if the event promise times out.
554
+ */
555
+ private createTimeoutHandler(eventName: string, id: string) {
556
+ return () => {
557
+ console.warn(`Recorder ${eventName} timed out: ${id}`);
558
+ // check if reset is needed
559
+ if (!this.stream.active) {
560
+ try {
561
+ this.reset();
562
+ } catch (err) {
563
+ console.error("Error while resetting recorder after timeout: ", err);
564
+ }
565
+ }
566
+ };
567
+ }
420
568
  }
package/src/start.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { LookitWindow } from "@lookit/data/dist/types";
2
- import { JsPsych, JsPsychPlugin } from "jspsych";
2
+ import chsTemplates from "@lookit/templates";
3
+ import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
3
4
  import { version } from "../package.json";
4
5
  import { ExistingRecordingError } from "./errors";
5
6
  import Recorder from "./recorder";
@@ -9,7 +10,30 @@ declare let window: LookitWindow;
9
10
  const info = <const>{
10
11
  name: "start-record-plugin",
11
12
  version,
12
- parameters: {},
13
+ parameters: {
14
+ /**
15
+ * This string can contain HTML markup. Any content provided will be
16
+ * displayed while the video recording connection is established. If null
17
+ * (the default), then the default 'establishing video connection, please
18
+ * wait' (or appropriate translation based on 'locale') will be displayed.
19
+ * Use a blank string for no message/content.
20
+ */
21
+ wait_for_connection_message: {
22
+ type: ParameterType.HTML_STRING,
23
+ default: null as null | string,
24
+ },
25
+ /**
26
+ * Locale code used for translating the default 'establishing video
27
+ * connection, please wait' message. This code must be present in the
28
+ * translation files. If the code is not found then English will be used. If
29
+ * the 'wait_for_connection_message' parameter is specified then this value
30
+ * is ignored.
31
+ */
32
+ locale: {
33
+ type: ParameterType.STRING,
34
+ default: "en-us",
35
+ },
36
+ },
13
37
  data: {},
14
38
  };
15
39
  type Info = typeof info;
@@ -33,11 +57,27 @@ export default class StartRecordPlugin implements JsPsychPlugin<Info> {
33
57
  }
34
58
  }
35
59
 
36
- /** Trial function called by jsPsych. */
37
- public trial() {
38
- this.recorder
60
+ /**
61
+ * Trial function called by jsPsych.
62
+ *
63
+ * @param display_element - DOM element where jsPsych content is being
64
+ * rendered (set in initJsPsych and automatically made available to a
65
+ * plugin's trial method via jsPsych core).
66
+ * @param trial - Trial object with parameters/values.
67
+ */
68
+ public async trial(
69
+ display_element: HTMLElement,
70
+ trial: TrialType<Info>,
71
+ ): Promise<void> {
72
+ if (trial.wait_for_connection_message == null) {
73
+ display_element.innerHTML = chsTemplates.establishingConnection(trial);
74
+ } else {
75
+ display_element.innerHTML = trial.wait_for_connection_message;
76
+ }
77
+ await this.recorder
39
78
  .start(false, `${StartRecordPlugin.info.name}-multiframe`)
40
79
  .then(() => {
80
+ display_element.innerHTML = "";
41
81
  this.jsPsych.finishTrial();
42
82
  });
43
83
  }
package/src/stop.ts CHANGED
@@ -33,6 +33,14 @@ const info = <const>{
33
33
  type: ParameterType.STRING,
34
34
  default: "en-us",
35
35
  },
36
+ /**
37
+ * Maximum duration (in seconds) to wait for the session recording to finish
38
+ * uploading before continuing with the experiment.
39
+ */
40
+ max_upload_seconds: {
41
+ type: ParameterType.INT,
42
+ default: 10,
43
+ },
36
44
  },
37
45
  data: {},
38
46
  };
@@ -64,22 +72,31 @@ export default class StopRecordPlugin implements JsPsychPlugin<Info> {
64
72
  * plugin's trial method via jsPsych core).
65
73
  * @param trial - Trial object with parameters/values.
66
74
  */
67
- public trial(display_element: HTMLElement, trial: TrialType<Info>): void {
75
+ public async trial(
76
+ display_element: HTMLElement,
77
+ trial: TrialType<Info>,
78
+ ): Promise<void> {
68
79
  if (trial.wait_for_upload_message == null) {
69
80
  display_element.innerHTML = chsTemplates.uploadingVideo(trial);
70
81
  } else {
71
82
  display_element.innerHTML = trial.wait_for_upload_message;
72
83
  }
73
- this.recorder
74
- .stop()
75
- .then(() => {
76
- window.chs.sessionRecorder = null;
77
- display_element.innerHTML = "";
78
- this.jsPsych.finishTrial();
79
- })
80
- .catch((err) => {
81
- console.error("StopRecordPlugin: recorder stop/upload failed.", err);
82
- // TO DO: display translated error msg and/or researcher contact info
83
- });
84
+ const { stopped, uploaded } = this.recorder.stop({
85
+ upload_timeout_ms:
86
+ trial.max_upload_seconds !== null
87
+ ? trial.max_upload_seconds! * 1000
88
+ : null,
89
+ });
90
+ try {
91
+ await stopped;
92
+ await uploaded;
93
+ } catch (err) {
94
+ console.error("StopRecordPlugin: recorder stop/upload failed.", err);
95
+ // TO DO: display translated error msg and/or researcher contact info
96
+ } finally {
97
+ window.chs.sessionRecorder = null;
98
+ display_element.innerHTML = "";
99
+ this.jsPsych.finishTrial();
100
+ }
84
101
  }
85
102
  }
package/src/trial.ts CHANGED
@@ -30,12 +30,18 @@ interface Parameters {
30
30
  * Locale code used for translating the default 'uploading video, please
31
31
  * wait...' message. This code must be present in the translation files. If
32
32
  * the code is not found then English will be used. If the
33
- * 'wait_for_upload_message' parameter is specified then this value
34
- * isignored.
33
+ * 'wait_for_upload_message' parameter is specified then this value is
34
+ * ignored.
35
35
  *
36
36
  * @default "en-us"
37
37
  */
38
38
  locale?: string;
39
+ /**
40
+ * Maximum duration (in seconds) to wait for the trial recording to finish
41
+ * uploading before continuing with the experiment. Default is 10 seconds (set
42
+ * during initialize).
43
+ */
44
+ max_upload_seconds?: null | number;
39
45
  }
40
46
 
41
47
  /** This extension allows researchers to record webcam audio/video during trials. */
@@ -50,6 +56,7 @@ export default class TrialRecordExtension implements JsPsychExtension {
50
56
  private pluginName: string | undefined;
51
57
  private uploadMsg: null | string = null;
52
58
  private locale: string = "en-us";
59
+ private maxUploadSeconds: undefined | null | number = undefined;
53
60
 
54
61
  /**
55
62
  * Video recording extension.
@@ -72,12 +79,16 @@ export default class TrialRecordExtension implements JsPsychExtension {
72
79
  */
73
80
  public async initialize(params?: Parameters) {
74
81
  await new Promise<void>((resolve) => {
82
+ // set parameter defaults
75
83
  this.uploadMsg = params?.wait_for_upload_message
76
84
  ? params.wait_for_upload_message
77
85
  : null;
78
86
  this.locale = params?.locale ? params.locale : "en-us";
79
- console.log(this.uploadMsg);
80
- console.log(this.locale);
87
+ // null is a valid value - only set default if no parameter value was provided
88
+ this.maxUploadSeconds =
89
+ params?.max_upload_seconds === undefined
90
+ ? 10
91
+ : params.max_upload_seconds;
81
92
  resolve();
82
93
  });
83
94
  }
@@ -97,16 +108,16 @@ export default class TrialRecordExtension implements JsPsychExtension {
97
108
  if (startParams?.locale) {
98
109
  this.locale = startParams.locale;
99
110
  }
100
- console.log(this.uploadMsg);
101
- console.log(this.locale);
111
+ if (startParams?.max_upload_seconds !== undefined) {
112
+ this.maxUploadSeconds = startParams?.max_upload_seconds;
113
+ }
102
114
  this.recorder = new Recorder(this.jsPsych);
115
+ this.pluginName = this.getCurrentPluginName();
116
+ this.recorder.start(false, `${this.pluginName}`);
103
117
  }
104
118
 
105
119
  /** Runs when the trial has loaded. */
106
- public on_load() {
107
- this.pluginName = this.getCurrentPluginName();
108
- this.recorder?.start(false, `${this.pluginName}`);
109
- }
120
+ public on_load() {}
110
121
 
111
122
  /**
112
123
  * Runs when trial has finished.
@@ -124,14 +135,29 @@ export default class TrialRecordExtension implements JsPsychExtension {
124
135
  } else {
125
136
  displayEl.innerHTML = this.uploadMsg;
126
137
  }
127
- try {
128
- await this.recorder?.stop();
138
+ if (this.recorder) {
139
+ const { stopped, uploaded } = this.recorder.stop({
140
+ upload_timeout_ms:
141
+ this.maxUploadSeconds !== null ? this.maxUploadSeconds! * 1000 : null,
142
+ });
143
+ try {
144
+ await stopped;
145
+ await uploaded;
146
+ displayEl.innerHTML = "";
147
+ return {};
148
+ } catch (err) {
149
+ console.error(
150
+ "TrialRecordExtension: recorder stop/upload failed.",
151
+ err,
152
+ );
153
+ displayEl.innerHTML = "";
154
+ return {};
155
+ // TO DO: display translated error msg and/or researcher contact info
156
+ }
157
+ } else {
129
158
  displayEl.innerHTML = "";
130
- } catch (err) {
131
- console.error("TrialRecordExtension: recorder stop/upload failed.", err);
132
- // TO DO: display translated error msg and/or researcher contact info
159
+ return {};
133
160
  }
134
- return {};
135
161
  }
136
162
 
137
163
  /**
package/src/types.ts CHANGED
@@ -14,3 +14,24 @@ export type CSSWidthHeight =
14
14
  | number
15
15
  | `${number}${"px" | "cm" | "mm" | "em" | "%"}`
16
16
  | "auto";
17
+
18
+ /** Options for the stop method */
19
+ export interface StopOptions {
20
+ maintain_container_size?: boolean;
21
+ stop_timeout_ms?: number | null;
22
+ upload_timeout_ms?: number | null;
23
+ }
24
+
25
+ /** Result returned by the stop method */
26
+ export interface StopResult {
27
+ /**
28
+ * Promise that resolves with the URL when the recorder has fully stopped.
29
+ * Returns a string with either the completion URL or "timeout".
30
+ */
31
+ stopped: Promise<string>;
32
+ /**
33
+ * Promise that resolves when the upload (or local download) completes.
34
+ * Returns void if succeeds or "timeout" if times out.
35
+ */
36
+ uploaded: Promise<void | string>;
37
+ }