@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/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.0.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,18 +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
- resolve();
396
+ resolve(this.url);
291
397
  };
292
398
  }
293
399
  handleDataAvailable(event) {
@@ -296,11 +402,11 @@ class Recorder {
296
402
  this.s3.onDataAvailable(event.data);
297
403
  }
298
404
  }
299
- download() {
300
- if (this.filename && this.url) {
405
+ download(filename, url) {
406
+ if (filename && url) {
301
407
  const link = document.createElement("a");
302
- link.href = this.url;
303
- link.download = this.filename;
408
+ link.href = url;
409
+ link.download = filename;
304
410
  link.click();
305
411
  }
306
412
  }
@@ -329,6 +435,18 @@ class Recorder {
329
435
  const rand_digits = Math.floor(Math.random() * 1e3);
330
436
  return `${prefix}_${window.chs.study.id}_${trial_id}_${window.chs.response.id}_${new Date().getTime()}_${rand_digits}.webm`;
331
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
+ }
332
450
  }
333
451
 
334
452
  const info$3 = {
@@ -381,6 +499,11 @@ const info$3 = {
381
499
  prompt_only_adults: { type: ParameterType.BOOL, default: false },
382
500
  consent_statement_text: { type: ParameterType.STRING, default: "" },
383
501
  omit_injury_phrase: { type: ParameterType.BOOL, default: false }
502
+ },
503
+ data: {
504
+ chs_type: {
505
+ type: ParameterType.STRING
506
+ }
384
507
  }
385
508
  };
386
509
  const _VideoConsentPlugin = class {
@@ -508,8 +631,10 @@ const _VideoConsentPlugin = class {
508
631
  stop.addEventListener("click", async () => {
509
632
  stop.disabled = true;
510
633
  this.addMessage(display, this.uploadingMsg);
511
- await this.recorder.stop(true);
512
- this.recorder.reset();
634
+ const { stopped, uploaded } = this.recorder.stop({
635
+ maintain_container_size: true
636
+ });
637
+ await stopped;
513
638
  this.recordFeed(display);
514
639
  this.getImg(display, "record-icon").style.visibility = "hidden";
515
640
  this.addMessage(display, this.notRecordingMsg);
@@ -534,7 +659,21 @@ const _VideoConsentPlugin = class {
534
659
  let VideoConsentPlugin = _VideoConsentPlugin;
535
660
  VideoConsentPlugin.info = info$3;
536
661
 
537
- const info$2 = { name: "start-record-plugin", parameters: {} };
662
+ const info$2 = {
663
+ name: "start-record-plugin",
664
+ version: _package.version,
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
+ },
675
+ data: {}
676
+ };
538
677
  const _StartRecordPlugin = class {
539
678
  constructor(jsPsych) {
540
679
  this.jsPsych = jsPsych;
@@ -545,8 +684,14 @@ const _StartRecordPlugin = class {
545
684
  throw new ExistingRecordingError();
546
685
  }
547
686
  }
548
- trial() {
549
- 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 = "";
550
695
  this.jsPsych.finishTrial();
551
696
  });
552
697
  }
@@ -556,9 +701,22 @@ StartRecordPlugin.info = info$2;
556
701
 
557
702
  const info$1 = {
558
703
  name: "stop-record-plugin",
704
+ version: _package.version,
559
705
  parameters: {
560
- locale: { type: ParameterType.STRING, default: "en-us" }
561
- }
706
+ wait_for_upload_message: {
707
+ type: ParameterType.HTML_STRING,
708
+ default: null
709
+ },
710
+ locale: {
711
+ type: ParameterType.STRING,
712
+ default: "en-us"
713
+ },
714
+ max_upload_seconds: {
715
+ type: ParameterType.INT,
716
+ default: 10
717
+ }
718
+ },
719
+ data: {}
562
720
  };
563
721
  class StopRecordPlugin {
564
722
  constructor(jsPsych) {
@@ -569,13 +727,25 @@ class StopRecordPlugin {
569
727
  throw new NoSessionRecordingError();
570
728
  }
571
729
  }
572
- trial(display_element, trial) {
573
- display_element.innerHTML = chsTemplates.uploadingVideo(trial);
574
- this.recorder.stop().then(() => {
730
+ async trial(display_element, trial) {
731
+ if (trial.wait_for_upload_message == null) {
732
+ display_element.innerHTML = chsTemplates.uploadingVideo(trial);
733
+ } else {
734
+ display_element.innerHTML = trial.wait_for_upload_message;
735
+ }
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 {
575
745
  window.chs.sessionRecorder = null;
576
746
  display_element.innerHTML = "";
577
747
  this.jsPsych.finishTrial();
578
- });
748
+ }
579
749
  }
580
750
  }
581
751
  StopRecordPlugin.info = info$1;
@@ -583,20 +753,66 @@ StopRecordPlugin.info = info$1;
583
753
  class TrialRecordExtension {
584
754
  constructor(jsPsych) {
585
755
  this.jsPsych = jsPsych;
756
+ this.uploadMsg = null;
757
+ this.locale = "en-us";
758
+ this.maxUploadSeconds = void 0;
586
759
  autoBind(this);
587
760
  }
588
- async initialize() {
761
+ async initialize(params) {
762
+ await new Promise((resolve) => {
763
+ this.uploadMsg = params?.wait_for_upload_message ? params.wait_for_upload_message : null;
764
+ this.locale = params?.locale ? params.locale : "en-us";
765
+ this.maxUploadSeconds = params?.max_upload_seconds === void 0 ? 10 : params.max_upload_seconds;
766
+ resolve();
767
+ });
589
768
  }
590
- on_start() {
769
+ on_start(startParams) {
770
+ if (startParams?.wait_for_upload_message) {
771
+ this.uploadMsg = startParams.wait_for_upload_message;
772
+ }
773
+ if (startParams?.locale) {
774
+ this.locale = startParams.locale;
775
+ }
776
+ if (startParams?.max_upload_seconds !== void 0) {
777
+ this.maxUploadSeconds = startParams?.max_upload_seconds;
778
+ }
591
779
  this.recorder = new Recorder(this.jsPsych);
780
+ this.pluginName = this.getCurrentPluginName();
781
+ this.recorder.start(false, `${this.pluginName}`);
592
782
  }
593
783
  on_load() {
594
- this.pluginName = this.getCurrentPluginName();
595
- this.recorder?.start(false, `${this.pluginName}`);
596
784
  }
597
- on_finish() {
598
- this.recorder?.stop();
599
- return {};
785
+ async on_finish() {
786
+ const displayEl = this.jsPsych.getDisplayElement();
787
+ if (this.uploadMsg == null) {
788
+ displayEl.innerHTML = chsTemplates.uploadingVideo({
789
+ type: this.jsPsych.getCurrentTrial().type,
790
+ locale: this.locale
791
+ });
792
+ } else {
793
+ displayEl.innerHTML = this.uploadMsg;
794
+ }
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 {
813
+ displayEl.innerHTML = "";
814
+ return {};
815
+ }
600
816
  }
601
817
  getCurrentPluginName() {
602
818
  const current_plugin_class = this.jsPsych.getCurrentTrial().type;
@@ -604,7 +820,9 @@ class TrialRecordExtension {
604
820
  }
605
821
  }
606
822
  TrialRecordExtension.info = {
607
- name: "chs-trial-record-extension"
823
+ name: "chs-trial-record-extension",
824
+ version: _package.version,
825
+ data: {}
608
826
  };
609
827
 
610
828
  var img$6 = "data:image/png;base64,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";