@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/dist/index.d.ts CHANGED
@@ -404,7 +404,30 @@ declare class VideoConsentPlugin implements JsPsychPlugin<Info$3> {
404
404
  declare const info$2: {
405
405
  readonly name: "start-record-plugin";
406
406
  readonly version: string;
407
- readonly parameters: {};
407
+ readonly parameters: {
408
+ /**
409
+ * This string can contain HTML markup. Any content provided will be
410
+ * displayed while the video recording connection is established. If null
411
+ * (the default), then the default 'establishing video connection, please
412
+ * wait' (or appropriate translation based on 'locale') will be displayed.
413
+ * Use a blank string for no message/content.
414
+ */
415
+ readonly wait_for_connection_message: {
416
+ readonly type: ParameterType.HTML_STRING;
417
+ readonly default: string | null;
418
+ };
419
+ /**
420
+ * Locale code used for translating the default 'establishing video
421
+ * connection, please wait' message. This code must be present in the
422
+ * translation files. If the code is not found then English will be used. If
423
+ * the 'wait_for_connection_message' parameter is specified then this value
424
+ * is ignored.
425
+ */
426
+ readonly locale: {
427
+ readonly type: ParameterType.STRING;
428
+ readonly default: "en-us";
429
+ };
430
+ };
408
431
  readonly data: {};
409
432
  };
410
433
  type Info$2 = typeof info$2;
@@ -414,7 +437,30 @@ declare class StartRecordPlugin implements JsPsychPlugin<Info$2> {
414
437
  static readonly info: {
415
438
  readonly name: "start-record-plugin";
416
439
  readonly version: string;
417
- readonly parameters: {};
440
+ readonly parameters: {
441
+ /**
442
+ * This string can contain HTML markup. Any content provided will be
443
+ * displayed while the video recording connection is established. If null
444
+ * (the default), then the default 'establishing video connection, please
445
+ * wait' (or appropriate translation based on 'locale') will be displayed.
446
+ * Use a blank string for no message/content.
447
+ */
448
+ readonly wait_for_connection_message: {
449
+ readonly type: ParameterType.HTML_STRING;
450
+ readonly default: string | null;
451
+ };
452
+ /**
453
+ * Locale code used for translating the default 'establishing video
454
+ * connection, please wait' message. This code must be present in the
455
+ * translation files. If the code is not found then English will be used. If
456
+ * the 'wait_for_connection_message' parameter is specified then this value
457
+ * is ignored.
458
+ */
459
+ readonly locale: {
460
+ readonly type: ParameterType.STRING;
461
+ readonly default: "en-us";
462
+ };
463
+ };
418
464
  readonly data: {};
419
465
  };
420
466
  private recorder;
@@ -424,8 +470,15 @@ declare class StartRecordPlugin implements JsPsychPlugin<Info$2> {
424
470
  * @param jsPsych - Object provided by jsPsych.
425
471
  */
426
472
  constructor(jsPsych: JsPsych);
427
- /** Trial function called by jsPsych. */
428
- trial(): void;
473
+ /**
474
+ * Trial function called by jsPsych.
475
+ *
476
+ * @param display_element - DOM element where jsPsych content is being
477
+ * rendered (set in initJsPsych and automatically made available to a
478
+ * plugin's trial method via jsPsych core).
479
+ * @param trial - Trial object with parameters/values.
480
+ */
481
+ trial(display_element: HTMLElement, trial: TrialType<Info$2>): Promise<void>;
429
482
  }
430
483
 
431
484
  declare const info$1: {
@@ -454,6 +507,14 @@ declare const info$1: {
454
507
  readonly type: ParameterType.STRING;
455
508
  readonly default: "en-us";
456
509
  };
510
+ /**
511
+ * Maximum duration (in seconds) to wait for the session recording to finish
512
+ * uploading before continuing with the experiment.
513
+ */
514
+ readonly max_upload_seconds: {
515
+ readonly type: ParameterType.INT;
516
+ readonly default: 10;
517
+ };
457
518
  };
458
519
  readonly data: {};
459
520
  };
@@ -487,6 +548,14 @@ declare class StopRecordPlugin implements JsPsychPlugin<Info$1> {
487
548
  readonly type: ParameterType.STRING;
488
549
  readonly default: "en-us";
489
550
  };
551
+ /**
552
+ * Maximum duration (in seconds) to wait for the session recording to finish
553
+ * uploading before continuing with the experiment.
554
+ */
555
+ readonly max_upload_seconds: {
556
+ readonly type: ParameterType.INT;
557
+ readonly default: 10;
558
+ };
490
559
  };
491
560
  readonly data: {};
492
561
  };
@@ -505,7 +574,7 @@ declare class StopRecordPlugin implements JsPsychPlugin<Info$1> {
505
574
  * plugin's trial method via jsPsych core).
506
575
  * @param trial - Trial object with parameters/values.
507
576
  */
508
- trial(display_element: HTMLElement, trial: TrialType<Info$1>): void;
577
+ trial(display_element: HTMLElement, trial: TrialType<Info$1>): Promise<void>;
509
578
  }
510
579
 
511
580
  interface Parameters {
@@ -526,12 +595,18 @@ interface Parameters {
526
595
  * Locale code used for translating the default 'uploading video, please
527
596
  * wait...' message. This code must be present in the translation files. If
528
597
  * the code is not found then English will be used. If the
529
- * 'wait_for_upload_message' parameter is specified then this value
530
- * isignored.
598
+ * 'wait_for_upload_message' parameter is specified then this value is
599
+ * ignored.
531
600
  *
532
601
  * @default "en-us"
533
602
  */
534
603
  locale?: string;
604
+ /**
605
+ * Maximum duration (in seconds) to wait for the trial recording to finish
606
+ * uploading before continuing with the experiment. Default is 10 seconds (set
607
+ * during initialize).
608
+ */
609
+ max_upload_seconds?: null | number;
535
610
  }
536
611
  /** This extension allows researchers to record webcam audio/video during trials. */
537
612
  declare class TrialRecordExtension implements JsPsychExtension {
@@ -541,6 +616,7 @@ declare class TrialRecordExtension implements JsPsychExtension {
541
616
  private pluginName;
542
617
  private uploadMsg;
543
618
  private locale;
619
+ private maxUploadSeconds;
544
620
  /**
545
621
  * Video recording extension.
546
622
  *
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import Handlebars from 'handlebars';
6
6
 
7
7
  var _package = {
8
8
  name: "@lookit/record",
9
- version: "4.1.0",
9
+ version: "5.0.0",
10
10
  description: "Recording extensions and plugins for CHS studies.",
11
11
  homepage: "https://github.com/lookit/lookit-jspsych#readme",
12
12
  bugs: {
@@ -47,8 +47,8 @@ var _package = {
47
47
  typescript: "^5.6.2"
48
48
  },
49
49
  peerDependencies: {
50
- "@lookit/data": "^0.2.0",
51
- "@lookit/templates": "^2.1.0",
50
+ "@lookit/data": "^0.3.0",
51
+ "@lookit/templates": "^3.0.0",
52
52
  jspsych: "^8.0.3"
53
53
  }
54
54
  };
@@ -123,6 +123,12 @@ class S3UndefinedError extends Error {
123
123
  this.name = "S3UndefinedError";
124
124
  }
125
125
  }
126
+ class NoFileNameError extends Error {
127
+ constructor() {
128
+ super("No filename found for recording.");
129
+ this.name = "NoFileNameError";
130
+ }
131
+ }
126
132
  class StreamActiveOnResetError extends Error {
127
133
  constructor() {
128
134
  super("Won't reset recorder. Stream is still active.");
@@ -147,6 +153,12 @@ class ElementNotFoundError extends Error {
147
153
  this.name = "ElementNotFoundError";
148
154
  }
149
155
  }
156
+ class TimeoutError extends Error {
157
+ constructor(msg) {
158
+ super(`${msg}`);
159
+ this.name = "TimeoutError";
160
+ }
161
+ }
150
162
 
151
163
  var playbackFeed = "<video\n autoplay\n playsinline\n src=\"{{{src}}}\"\n class=\"webcam-feed\"\n id=\"{{webcam_element_id}}\"\n width=\"{{width}}\"\n height=\"{{height}}\"\n controls\n></video>";
152
164
 
@@ -158,6 +170,31 @@ var img$8 = "data:image/svg+xml,%3c%3fxml version='1.0' encoding='utf-8' %3f%3e%
158
170
 
159
171
  var img$7 = "data:image/svg+xml,%3c%3fxml version='1.0' encoding='utf-8' %3f%3e%3csvg viewBox='-1 -1 18 18' xmlns='http://www.w3.org/2000/svg'%3e %3ccircle fill='red' stroke='black' stroke-width='0.5' cx='8' cy='8' r='8'%3e%3c/circle%3e%3c/svg%3e";
160
172
 
173
+ const promiseWithTimeout = (promise, promiseId, timeoutMs, onTimeoutCleanup) => {
174
+ let timeoutHandle;
175
+ const timeout = new Promise((resolve) => {
176
+ timeoutHandle = setTimeout(() => {
177
+ onTimeoutCleanup?.();
178
+ resolve("timeout");
179
+ }, timeoutMs);
180
+ });
181
+ return Promise.race([promise, timeout]).then(
182
+ (value) => {
183
+ if (value == "timeout") {
184
+ console.log(`Upload for ${promiseId} timed out.`);
185
+ } else {
186
+ console.log(`Upload for ${promiseId} completed.`);
187
+ clearTimeout(timeoutHandle);
188
+ }
189
+ return value;
190
+ },
191
+ (err) => {
192
+ clearTimeout(timeoutHandle);
193
+ throw err;
194
+ }
195
+ );
196
+ };
197
+
161
198
  class Recorder {
162
199
  constructor(jsPsych) {
163
200
  this.jsPsych = jsPsych;
@@ -257,13 +294,73 @@ class Recorder {
257
294
  this.recorder.stop();
258
295
  this.stream.getTracks().map((t) => t.stop());
259
296
  }
260
- stop(maintain_container_size = false) {
297
+ stop({
298
+ maintain_container_size = false,
299
+ stop_timeout_ms = null,
300
+ upload_timeout_ms = 1e4
301
+ } = {}) {
302
+ this.preStopCheck();
261
303
  this.clearWebcamFeed(maintain_container_size);
262
304
  this.stopTracks();
263
- if (!this.stopPromise) {
264
- throw new NoStopPromiseError();
265
- }
266
- return this.stopPromise;
305
+ const snapshot = {
306
+ s3: !this.localDownload ? this.s3 : null,
307
+ filename: this.filename,
308
+ localDownload: this.localDownload,
309
+ url: "null"
310
+ };
311
+ const stopped = stop_timeout_ms ? promiseWithTimeout(
312
+ this.stopPromise,
313
+ `${snapshot.filename}-stopped`,
314
+ stop_timeout_ms,
315
+ this.createTimeoutHandler("stop", snapshot.filename)
316
+ ) : this.stopPromise;
317
+ stopped.finally(() => {
318
+ try {
319
+ this.reset();
320
+ } catch (err) {
321
+ console.error("Error while resetting recorder after stop: ", err);
322
+ }
323
+ });
324
+ const uploadPromise = (async () => {
325
+ let url;
326
+ try {
327
+ url = await stopped;
328
+ if (url == "timeout") {
329
+ throw new TimeoutError("Recorder stop timed out.");
330
+ }
331
+ } catch (err) {
332
+ console.warn("Upload failed because recorder stop timed out");
333
+ throw err;
334
+ }
335
+ snapshot.url = url;
336
+ if (snapshot.localDownload) {
337
+ try {
338
+ this.download(snapshot.filename, snapshot.url);
339
+ await Promise.resolve();
340
+ } catch (err) {
341
+ console.error("Local download failed: ", err);
342
+ throw err;
343
+ }
344
+ } else {
345
+ try {
346
+ await snapshot.s3.completeUpload();
347
+ } catch (err) {
348
+ console.error("Upload failed: ", err);
349
+ throw err;
350
+ }
351
+ }
352
+ })();
353
+ const uploaded = upload_timeout_ms ? promiseWithTimeout(
354
+ uploadPromise,
355
+ `${snapshot.filename}-uploaded`,
356
+ upload_timeout_ms,
357
+ this.createTimeoutHandler("upload", snapshot.filename)
358
+ ) : uploadPromise;
359
+ window.chs.pendingUploads.push({
360
+ promise: uploadPromise,
361
+ file: snapshot.filename
362
+ });
363
+ return { stopped, uploaded };
267
364
  }
268
365
  initializeCheck() {
269
366
  if (!this.recorder) {
@@ -276,19 +373,27 @@ class Recorder {
276
373
  throw new StreamDataInitializeError();
277
374
  }
278
375
  }
376
+ preStopCheck() {
377
+ if (!this.recorder) {
378
+ throw new RecorderInitializeError();
379
+ }
380
+ if (!this.stream.active) {
381
+ throw new StreamInactiveInitializeError();
382
+ }
383
+ if (!this.stopPromise) {
384
+ throw new NoStopPromiseError();
385
+ }
386
+ if (!this.filename) {
387
+ throw new NoFileNameError();
388
+ }
389
+ }
279
390
  handleStop(resolve) {
280
- return async () => {
391
+ return () => {
281
392
  if (this.blobs.length === 0) {
282
393
  throw new CreateURLError();
283
394
  }
284
395
  this.url = URL.createObjectURL(new Blob(this.blobs));
285
- if (this.localDownload) {
286
- this.download();
287
- } else {
288
- await this.s3.completeUpload();
289
- }
290
- this.reset();
291
- resolve();
396
+ resolve(this.url);
292
397
  };
293
398
  }
294
399
  handleDataAvailable(event) {
@@ -297,11 +402,11 @@ class Recorder {
297
402
  this.s3.onDataAvailable(event.data);
298
403
  }
299
404
  }
300
- download() {
301
- if (this.filename && this.url) {
405
+ download(filename, url) {
406
+ if (filename && url) {
302
407
  const link = document.createElement("a");
303
- link.href = this.url;
304
- link.download = this.filename;
408
+ link.href = url;
409
+ link.download = filename;
305
410
  link.click();
306
411
  }
307
412
  }
@@ -330,6 +435,18 @@ class Recorder {
330
435
  const rand_digits = Math.floor(Math.random() * 1e3);
331
436
  return `${prefix}_${window.chs.study.id}_${trial_id}_${window.chs.response.id}_${new Date().getTime()}_${rand_digits}.webm`;
332
437
  }
438
+ createTimeoutHandler(eventName, id) {
439
+ return () => {
440
+ console.warn(`Recorder ${eventName} timed out: ${id}`);
441
+ if (!this.stream.active) {
442
+ try {
443
+ this.reset();
444
+ } catch (err) {
445
+ console.error("Error while resetting recorder after timeout: ", err);
446
+ }
447
+ }
448
+ };
449
+ }
333
450
  }
334
451
 
335
452
  const info$3 = {
@@ -514,7 +631,10 @@ const _VideoConsentPlugin = class {
514
631
  stop.addEventListener("click", async () => {
515
632
  stop.disabled = true;
516
633
  this.addMessage(display, this.uploadingMsg);
517
- await this.recorder.stop(true);
634
+ const { stopped, uploaded } = this.recorder.stop({
635
+ maintain_container_size: true
636
+ });
637
+ await stopped;
518
638
  this.recordFeed(display);
519
639
  this.getImg(display, "record-icon").style.visibility = "hidden";
520
640
  this.addMessage(display, this.notRecordingMsg);
@@ -542,7 +662,16 @@ VideoConsentPlugin.info = info$3;
542
662
  const info$2 = {
543
663
  name: "start-record-plugin",
544
664
  version: _package.version,
545
- parameters: {},
665
+ parameters: {
666
+ wait_for_connection_message: {
667
+ type: ParameterType.HTML_STRING,
668
+ default: null
669
+ },
670
+ locale: {
671
+ type: ParameterType.STRING,
672
+ default: "en-us"
673
+ }
674
+ },
546
675
  data: {}
547
676
  };
548
677
  const _StartRecordPlugin = class {
@@ -555,8 +684,14 @@ const _StartRecordPlugin = class {
555
684
  throw new ExistingRecordingError();
556
685
  }
557
686
  }
558
- trial() {
559
- this.recorder.start(false, `${_StartRecordPlugin.info.name}-multiframe`).then(() => {
687
+ async trial(display_element, trial) {
688
+ if (trial.wait_for_connection_message == null) {
689
+ display_element.innerHTML = chsTemplates.establishingConnection(trial);
690
+ } else {
691
+ display_element.innerHTML = trial.wait_for_connection_message;
692
+ }
693
+ await this.recorder.start(false, `${_StartRecordPlugin.info.name}-multiframe`).then(() => {
694
+ display_element.innerHTML = "";
560
695
  this.jsPsych.finishTrial();
561
696
  });
562
697
  }
@@ -575,6 +710,10 @@ const info$1 = {
575
710
  locale: {
576
711
  type: ParameterType.STRING,
577
712
  default: "en-us"
713
+ },
714
+ max_upload_seconds: {
715
+ type: ParameterType.INT,
716
+ default: 10
578
717
  }
579
718
  },
580
719
  data: {}
@@ -588,19 +727,25 @@ class StopRecordPlugin {
588
727
  throw new NoSessionRecordingError();
589
728
  }
590
729
  }
591
- trial(display_element, trial) {
730
+ async trial(display_element, trial) {
592
731
  if (trial.wait_for_upload_message == null) {
593
732
  display_element.innerHTML = chsTemplates.uploadingVideo(trial);
594
733
  } else {
595
734
  display_element.innerHTML = trial.wait_for_upload_message;
596
735
  }
597
- this.recorder.stop().then(() => {
736
+ const { stopped, uploaded } = this.recorder.stop({
737
+ upload_timeout_ms: trial.max_upload_seconds !== null ? trial.max_upload_seconds * 1e3 : null
738
+ });
739
+ try {
740
+ await stopped;
741
+ await uploaded;
742
+ } catch (err) {
743
+ console.error("StopRecordPlugin: recorder stop/upload failed.", err);
744
+ } finally {
598
745
  window.chs.sessionRecorder = null;
599
746
  display_element.innerHTML = "";
600
747
  this.jsPsych.finishTrial();
601
- }).catch((err) => {
602
- console.error("StopRecordPlugin: recorder stop/upload failed.", err);
603
- });
748
+ }
604
749
  }
605
750
  }
606
751
  StopRecordPlugin.info = info$1;
@@ -610,14 +755,14 @@ class TrialRecordExtension {
610
755
  this.jsPsych = jsPsych;
611
756
  this.uploadMsg = null;
612
757
  this.locale = "en-us";
758
+ this.maxUploadSeconds = void 0;
613
759
  autoBind(this);
614
760
  }
615
761
  async initialize(params) {
616
762
  await new Promise((resolve) => {
617
763
  this.uploadMsg = params?.wait_for_upload_message ? params.wait_for_upload_message : null;
618
764
  this.locale = params?.locale ? params.locale : "en-us";
619
- console.log(this.uploadMsg);
620
- console.log(this.locale);
765
+ this.maxUploadSeconds = params?.max_upload_seconds === void 0 ? 10 : params.max_upload_seconds;
621
766
  resolve();
622
767
  });
623
768
  }
@@ -628,13 +773,14 @@ class TrialRecordExtension {
628
773
  if (startParams?.locale) {
629
774
  this.locale = startParams.locale;
630
775
  }
631
- console.log(this.uploadMsg);
632
- console.log(this.locale);
776
+ if (startParams?.max_upload_seconds !== void 0) {
777
+ this.maxUploadSeconds = startParams?.max_upload_seconds;
778
+ }
633
779
  this.recorder = new Recorder(this.jsPsych);
780
+ this.pluginName = this.getCurrentPluginName();
781
+ this.recorder.start(false, `${this.pluginName}`);
634
782
  }
635
783
  on_load() {
636
- this.pluginName = this.getCurrentPluginName();
637
- this.recorder?.start(false, `${this.pluginName}`);
638
784
  }
639
785
  async on_finish() {
640
786
  const displayEl = this.jsPsych.getDisplayElement();
@@ -646,13 +792,27 @@ class TrialRecordExtension {
646
792
  } else {
647
793
  displayEl.innerHTML = this.uploadMsg;
648
794
  }
649
- try {
650
- await this.recorder?.stop();
795
+ if (this.recorder) {
796
+ const { stopped, uploaded } = this.recorder.stop({
797
+ upload_timeout_ms: this.maxUploadSeconds !== null ? this.maxUploadSeconds * 1e3 : null
798
+ });
799
+ try {
800
+ await stopped;
801
+ await uploaded;
802
+ displayEl.innerHTML = "";
803
+ return {};
804
+ } catch (err) {
805
+ console.error(
806
+ "TrialRecordExtension: recorder stop/upload failed.",
807
+ err
808
+ );
809
+ displayEl.innerHTML = "";
810
+ return {};
811
+ }
812
+ } else {
651
813
  displayEl.innerHTML = "";
652
- } catch (err) {
653
- console.error("TrialRecordExtension: recorder stop/upload failed.", err);
814
+ return {};
654
815
  }
655
- return {};
656
816
  }
657
817
  getCurrentPluginName() {
658
818
  const current_plugin_class = this.jsPsych.getCurrentTrial().type;