@lookit/record 4.0.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
  /**
@@ -109,13 +113,23 @@ export default class Recorder {
109
113
  this.jsPsych.pluginAPI.initializeCameraRecorder(stream, recorder_options);
110
114
  }
111
115
 
112
- /** Reset the recorder to be used again. */
116
+ /**
117
+ * Reset the recorder. This is used internally after stopping/uploading a
118
+ * recording, in order to create a new active stream that can be used by a new
119
+ * Recorder instance. This can also be used by the consuming plugin/extension
120
+ * when a recorder needs to be reset without the stop/upload events (e.g. in
121
+ * the video config plugin).
122
+ */
113
123
  public reset() {
124
+ // Reset can only be called after the current stream has been fully stopped.
114
125
  if (this.stream.active) {
115
126
  throw new StreamActiveOnResetError();
116
127
  }
128
+ // Ensure later recordings have a valid active stream.
117
129
  this.initializeRecorder(this.streamClone.clone());
130
+ // Clear blob buffer (any pending uploads are handled by LookitS3 instances and tracked globally)
118
131
  this.blobs = [];
132
+ // TO DO: reset S3/filename/URL?
119
133
  }
120
134
 
121
135
  /**
@@ -249,7 +263,7 @@ export default class Recorder {
249
263
 
250
264
  // create a stop promise and pass the resolve function as an argument to the stop event callback,
251
265
  // so that the stop event handler can resolve the stop promise
252
- this.stopPromise = new Promise((resolve) => {
266
+ this.stopPromise = new Promise<string>((resolve) => {
253
267
  this.recorder.addEventListener("stop", this.handleStop(resolve));
254
268
  });
255
269
 
@@ -276,24 +290,122 @@ export default class Recorder {
276
290
  * tracks, clear the webcam feed element (if there is one), and return the
277
291
  * stop promise. This should only be called after recording has started.
278
292
  *
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
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
283
306
  * removed. This is useful for avoiding layout jumps when the webcam
284
307
  * container will be re-used during the trial.
285
- * @returns Promise that resolves after the media recorder has stopped and
286
- * final 'dataavailable' event has occurred, when the "stop" event-related
287
- * 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.
288
319
  */
289
- 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();
290
326
  this.clearWebcamFeed(maintain_container_size);
291
327
  this.stopTracks();
292
328
 
293
- if (!this.stopPromise) {
294
- throw new NoStopPromiseError();
295
- }
296
- 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 };
297
409
  }
298
410
 
299
411
  /** Throw Error if there isn't a recorder provided by jsPsych. */
@@ -311,29 +423,42 @@ export default class Recorder {
311
423
  }
312
424
  }
313
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
+
314
445
  /**
315
446
  * Handle the recorder's stop event. This is a function that takes the stop
316
447
  * promise's 'resolve' as an argument and returns a function that resolves
317
- * that stop promise. The function that is returned is used as the recorder's
318
- * "stop" event-related callback function.
448
+ * that stop promise with the URL that is created from the recording.
319
449
  *
320
- * @param resolve - Promise resolve function.
321
- * @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.
322
454
  */
323
- private handleStop(resolve: () => void) {
324
- return async () => {
455
+ private handleStop(resolve: (value: string | PromiseLike<string>) => void) {
456
+ return () => {
325
457
  if (this.blobs.length === 0) {
326
458
  throw new CreateURLError();
327
459
  }
328
460
  this.url = URL.createObjectURL(new Blob(this.blobs));
329
-
330
- if (this.localDownload) {
331
- this.download();
332
- } else {
333
- await this.s3.completeUpload();
334
- }
335
-
336
- resolve();
461
+ resolve(this.url);
337
462
  };
338
463
  }
339
464
 
@@ -343,18 +468,27 @@ export default class Recorder {
343
468
  * @param event - Event containing blob data.
344
469
  */
345
470
  private handleDataAvailable(event: BlobEvent) {
471
+ // Store locally for URL creation
346
472
  this.blobs.push(event.data);
347
473
  if (!this.localDownload) {
474
+ // Forward to LookitS3 instance, which manages uploading
348
475
  this.s3.onDataAvailable(event.data);
349
476
  }
350
477
  }
351
478
 
352
- /** Download data url used in local development. */
353
- private download() {
354
- 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) {
355
489
  const link = document.createElement("a");
356
- link.href = this.url;
357
- link.download = this.filename;
490
+ link.href = url;
491
+ link.download = filename;
358
492
  link.click();
359
493
  }
360
494
  }
@@ -409,4 +543,26 @@ export default class Recorder {
409
543
  const rand_digits = Math.floor(Math.random() * 1000);
410
544
  return `${prefix}_${window.chs.study.id}_${trial_id}_${window.chs.response.id}_${new Date().getTime()}_${rand_digits}.webm`;
411
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
+ }
412
568
  }
package/src/start.ts CHANGED
@@ -1,11 +1,41 @@
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";
4
+ import { version } from "../package.json";
3
5
  import { ExistingRecordingError } from "./errors";
4
6
  import Recorder from "./recorder";
5
7
 
6
8
  declare let window: LookitWindow;
7
9
 
8
- const info = <const>{ name: "start-record-plugin", parameters: {} };
10
+ const info = <const>{
11
+ name: "start-record-plugin",
12
+ version,
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
+ },
37
+ data: {},
38
+ };
9
39
  type Info = typeof info;
10
40
 
11
41
  /** Start recording. Used by researchers who want to record across trials. */
@@ -27,11 +57,27 @@ export default class StartRecordPlugin implements JsPsychPlugin<Info> {
27
57
  }
28
58
  }
29
59
 
30
- /** Trial function called by jsPsych. */
31
- public trial() {
32
- 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
33
78
  .start(false, `${StartRecordPlugin.info.name}-multiframe`)
34
79
  .then(() => {
80
+ display_element.innerHTML = "";
35
81
  this.jsPsych.finishTrial();
36
82
  });
37
83
  }
package/src/stop.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { LookitWindow } from "@lookit/data/dist/types";
2
2
  import chsTemplates from "@lookit/templates";
3
3
  import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
4
+ import { version } from "../package.json";
4
5
  import { NoSessionRecordingError } from "./errors";
5
6
  import Recorder from "./recorder";
6
7
 
@@ -8,9 +9,40 @@ declare let window: LookitWindow;
8
9
 
9
10
  const info = <const>{
10
11
  name: "stop-record-plugin",
12
+ version,
11
13
  parameters: {
12
- locale: { type: ParameterType.STRING, default: "en-us" },
14
+ /**
15
+ * This string can contain HTML markup. Any content provided will be
16
+ * displayed while the recording is uploading. If null (the default), then
17
+ * the default 'uploading video, please wait' (or appropriate translation
18
+ * based on 'locale') will be displayed. Use a blank string for no
19
+ * message/content.
20
+ */
21
+ wait_for_upload_message: {
22
+ type: ParameterType.HTML_STRING,
23
+ default: null as null | string,
24
+ },
25
+ /**
26
+ * Locale code used for translating the default 'uploading video, please
27
+ * wait' message. This code must be present in the translation files. If the
28
+ * code is not found then English will be used. If the
29
+ * 'wait_for_upload_message' parameter is specified then this value is
30
+ * ignored.
31
+ */
32
+ locale: {
33
+ type: ParameterType.STRING,
34
+ default: "en-us",
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
+ },
13
44
  },
45
+ data: {},
14
46
  };
15
47
  type Info = typeof info;
16
48
 
@@ -40,12 +72,31 @@ export default class StopRecordPlugin implements JsPsychPlugin<Info> {
40
72
  * plugin's trial method via jsPsych core).
41
73
  * @param trial - Trial object with parameters/values.
42
74
  */
43
- public trial(display_element: HTMLElement, trial: TrialType<Info>): void {
44
- display_element.innerHTML = chsTemplates.uploadingVideo(trial);
45
- this.recorder.stop().then(() => {
75
+ public async trial(
76
+ display_element: HTMLElement,
77
+ trial: TrialType<Info>,
78
+ ): Promise<void> {
79
+ if (trial.wait_for_upload_message == null) {
80
+ display_element.innerHTML = chsTemplates.uploadingVideo(trial);
81
+ } else {
82
+ display_element.innerHTML = trial.wait_for_upload_message;
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 {
46
97
  window.chs.sessionRecorder = null;
47
98
  display_element.innerHTML = "";
48
99
  this.jsPsych.finishTrial();
49
- });
100
+ }
50
101
  }
51
102
  }
package/src/trial.ts CHANGED
@@ -1,16 +1,62 @@
1
+ import chsTemplates from "@lookit/templates";
1
2
  import autoBind from "auto-bind";
2
- import { JsPsych, JsPsychExtension, JsPsychExtensionInfo } from "jspsych";
3
+ import {
4
+ JsPsych,
5
+ JsPsychExtension,
6
+ JsPsychExtensionInfo,
7
+ PluginInfo,
8
+ TrialType,
9
+ } from "jspsych";
10
+ import { version } from "../package.json";
3
11
  import Recorder from "./recorder";
4
12
  import { jsPsychPluginWithInfo } from "./types";
5
13
 
6
- /** This extension will allow reasearchers to record trials. */
14
+ // JsPsychExtensionInfo does not allow parameters, so we define them as interfaces and use these to type the arguments passed to extension initialize and on_start functions.
15
+ interface Parameters {
16
+ /**
17
+ * Content that should be displayed while the recording is uploading. If null
18
+ * (the default), then the default 'uploading video, please wait...' (or
19
+ * appropriate translation based on 'locale') will be displayed. Use a blank
20
+ * string for no message/content. Otherwise this parameter can be set to a
21
+ * custom string and can contain HTML markup. If you want to embed
22
+ * images/video/audio in this HTML string, be sure to preload the media files
23
+ * with the `preload` plugin and manual preloading. Use a blank string (`""`)
24
+ * for no message/content.
25
+ *
26
+ * @default null
27
+ */
28
+ wait_for_upload_message?: null | string;
29
+ /**
30
+ * Locale code used for translating the default 'uploading video, please
31
+ * wait...' message. This code must be present in the translation files. If
32
+ * the code is not found then English will be used. If the
33
+ * 'wait_for_upload_message' parameter is specified then this value is
34
+ * ignored.
35
+ *
36
+ * @default "en-us"
37
+ */
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;
45
+ }
46
+
47
+ /** This extension allows researchers to record webcam audio/video during trials. */
7
48
  export default class TrialRecordExtension implements JsPsychExtension {
8
49
  public static readonly info: JsPsychExtensionInfo = {
9
50
  name: "chs-trial-record-extension",
51
+ version,
52
+ data: {},
10
53
  };
11
54
 
12
55
  private recorder?: Recorder;
13
56
  private pluginName: string | undefined;
57
+ private uploadMsg: null | string = null;
58
+ private locale: string = "en-us";
59
+ private maxUploadSeconds: undefined | null | number = undefined;
14
60
 
15
61
  /**
16
62
  * Video recording extension.
@@ -22,30 +68,96 @@ export default class TrialRecordExtension implements JsPsychExtension {
22
68
  }
23
69
 
24
70
  /**
25
- * Ran on the initialize step for extensions, called when an instance of
71
+ * Runs on the initialize step for extensions, called when an instance of
26
72
  * jsPsych is first initialized through initJsPsych().
73
+ *
74
+ * @param params - Parameters object
75
+ * @param params.wait_for_upload_message - Message to display while waiting
76
+ * for upload. String or null (default)
77
+ * @param params.locale - Message to display while waiting for upload. String
78
+ * or null (default).
27
79
  */
28
- public async initialize() {}
29
-
30
- /** Ran at the start of a trial. */
31
- public on_start() {
32
- this.recorder = new Recorder(this.jsPsych);
80
+ public async initialize(params?: Parameters) {
81
+ await new Promise<void>((resolve) => {
82
+ // set parameter defaults
83
+ this.uploadMsg = params?.wait_for_upload_message
84
+ ? params.wait_for_upload_message
85
+ : null;
86
+ this.locale = params?.locale ? params.locale : "en-us";
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;
92
+ resolve();
93
+ });
33
94
  }
34
95
 
35
- /** Ran when the trial has loaded. */
36
- public on_load() {
96
+ /**
97
+ * Runs at the start of a trial.
98
+ *
99
+ * @param startParams - Parameters object
100
+ * @param startParams.wait_for_upload_message - Message to display while
101
+ * waiting for upload. String or null (default). If given, this will
102
+ * overwrite the value used during initialization.
103
+ */
104
+ public on_start(startParams?: Parameters) {
105
+ if (startParams?.wait_for_upload_message) {
106
+ this.uploadMsg = startParams.wait_for_upload_message;
107
+ }
108
+ if (startParams?.locale) {
109
+ this.locale = startParams.locale;
110
+ }
111
+ if (startParams?.max_upload_seconds !== undefined) {
112
+ this.maxUploadSeconds = startParams?.max_upload_seconds;
113
+ }
114
+ this.recorder = new Recorder(this.jsPsych);
37
115
  this.pluginName = this.getCurrentPluginName();
38
- this.recorder?.start(false, `${this.pluginName}`);
116
+ this.recorder.start(false, `${this.pluginName}`);
39
117
  }
40
118
 
119
+ /** Runs when the trial has loaded. */
120
+ public on_load() {}
121
+
41
122
  /**
42
- * Ran when trial has finished.
123
+ * Runs when trial has finished.
43
124
  *
44
- * @returns Trial data.
125
+ * @returns Any data from the trial extension that should be added to the rest
126
+ * of the trial data.
45
127
  */
46
- public on_finish() {
47
- this.recorder?.stop();
48
- return {};
128
+ public async on_finish() {
129
+ const displayEl = this.jsPsych.getDisplayElement();
130
+ if (this.uploadMsg == null) {
131
+ displayEl.innerHTML = chsTemplates.uploadingVideo({
132
+ type: this.jsPsych.getCurrentTrial().type,
133
+ locale: this.locale,
134
+ } as TrialType<PluginInfo>);
135
+ } else {
136
+ displayEl.innerHTML = this.uploadMsg;
137
+ }
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 {
158
+ displayEl.innerHTML = "";
159
+ return {};
160
+ }
49
161
  }
50
162
 
51
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
+ }