@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.cjs CHANGED
@@ -8,7 +8,7 @@ var Handlebars = require('handlebars');
8
8
 
9
9
  var _package = {
10
10
  name: "@lookit/record",
11
- version: "4.0.0",
11
+ version: "5.0.0",
12
12
  description: "Recording extensions and plugins for CHS studies.",
13
13
  homepage: "https://github.com/lookit/lookit-jspsych#readme",
14
14
  bugs: {
@@ -49,8 +49,8 @@ var _package = {
49
49
  typescript: "^5.6.2"
50
50
  },
51
51
  peerDependencies: {
52
- "@lookit/data": "^0.2.0",
53
- "@lookit/templates": "^2.1.0",
52
+ "@lookit/data": "^0.3.0",
53
+ "@lookit/templates": "^3.0.0",
54
54
  jspsych: "^8.0.3"
55
55
  }
56
56
  };
@@ -125,6 +125,12 @@ class S3UndefinedError extends Error {
125
125
  this.name = "S3UndefinedError";
126
126
  }
127
127
  }
128
+ class NoFileNameError extends Error {
129
+ constructor() {
130
+ super("No filename found for recording.");
131
+ this.name = "NoFileNameError";
132
+ }
133
+ }
128
134
  class StreamActiveOnResetError extends Error {
129
135
  constructor() {
130
136
  super("Won't reset recorder. Stream is still active.");
@@ -149,6 +155,12 @@ class ElementNotFoundError extends Error {
149
155
  this.name = "ElementNotFoundError";
150
156
  }
151
157
  }
158
+ class TimeoutError extends Error {
159
+ constructor(msg) {
160
+ super(`${msg}`);
161
+ this.name = "TimeoutError";
162
+ }
163
+ }
152
164
 
153
165
  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>";
154
166
 
@@ -160,6 +172,31 @@ var img$8 = "data:image/svg+xml,%3c%3fxml version='1.0' encoding='utf-8' %3f%3e%
160
172
 
161
173
  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";
162
174
 
175
+ const promiseWithTimeout = (promise, promiseId, timeoutMs, onTimeoutCleanup) => {
176
+ let timeoutHandle;
177
+ const timeout = new Promise((resolve) => {
178
+ timeoutHandle = setTimeout(() => {
179
+ onTimeoutCleanup?.();
180
+ resolve("timeout");
181
+ }, timeoutMs);
182
+ });
183
+ return Promise.race([promise, timeout]).then(
184
+ (value) => {
185
+ if (value == "timeout") {
186
+ console.log(`Upload for ${promiseId} timed out.`);
187
+ } else {
188
+ console.log(`Upload for ${promiseId} completed.`);
189
+ clearTimeout(timeoutHandle);
190
+ }
191
+ return value;
192
+ },
193
+ (err) => {
194
+ clearTimeout(timeoutHandle);
195
+ throw err;
196
+ }
197
+ );
198
+ };
199
+
163
200
  class Recorder {
164
201
  constructor(jsPsych) {
165
202
  this.jsPsych = jsPsych;
@@ -259,13 +296,73 @@ class Recorder {
259
296
  this.recorder.stop();
260
297
  this.stream.getTracks().map((t) => t.stop());
261
298
  }
262
- stop(maintain_container_size = false) {
299
+ stop({
300
+ maintain_container_size = false,
301
+ stop_timeout_ms = null,
302
+ upload_timeout_ms = 1e4
303
+ } = {}) {
304
+ this.preStopCheck();
263
305
  this.clearWebcamFeed(maintain_container_size);
264
306
  this.stopTracks();
265
- if (!this.stopPromise) {
266
- throw new NoStopPromiseError();
267
- }
268
- return this.stopPromise;
307
+ const snapshot = {
308
+ s3: !this.localDownload ? this.s3 : null,
309
+ filename: this.filename,
310
+ localDownload: this.localDownload,
311
+ url: "null"
312
+ };
313
+ const stopped = stop_timeout_ms ? promiseWithTimeout(
314
+ this.stopPromise,
315
+ `${snapshot.filename}-stopped`,
316
+ stop_timeout_ms,
317
+ this.createTimeoutHandler("stop", snapshot.filename)
318
+ ) : this.stopPromise;
319
+ stopped.finally(() => {
320
+ try {
321
+ this.reset();
322
+ } catch (err) {
323
+ console.error("Error while resetting recorder after stop: ", err);
324
+ }
325
+ });
326
+ const uploadPromise = (async () => {
327
+ let url;
328
+ try {
329
+ url = await stopped;
330
+ if (url == "timeout") {
331
+ throw new TimeoutError("Recorder stop timed out.");
332
+ }
333
+ } catch (err) {
334
+ console.warn("Upload failed because recorder stop timed out");
335
+ throw err;
336
+ }
337
+ snapshot.url = url;
338
+ if (snapshot.localDownload) {
339
+ try {
340
+ this.download(snapshot.filename, snapshot.url);
341
+ await Promise.resolve();
342
+ } catch (err) {
343
+ console.error("Local download failed: ", err);
344
+ throw err;
345
+ }
346
+ } else {
347
+ try {
348
+ await snapshot.s3.completeUpload();
349
+ } catch (err) {
350
+ console.error("Upload failed: ", err);
351
+ throw err;
352
+ }
353
+ }
354
+ })();
355
+ const uploaded = upload_timeout_ms ? promiseWithTimeout(
356
+ uploadPromise,
357
+ `${snapshot.filename}-uploaded`,
358
+ upload_timeout_ms,
359
+ this.createTimeoutHandler("upload", snapshot.filename)
360
+ ) : uploadPromise;
361
+ window.chs.pendingUploads.push({
362
+ promise: uploadPromise,
363
+ file: snapshot.filename
364
+ });
365
+ return { stopped, uploaded };
269
366
  }
270
367
  initializeCheck() {
271
368
  if (!this.recorder) {
@@ -278,18 +375,27 @@ class Recorder {
278
375
  throw new StreamDataInitializeError();
279
376
  }
280
377
  }
378
+ preStopCheck() {
379
+ if (!this.recorder) {
380
+ throw new RecorderInitializeError();
381
+ }
382
+ if (!this.stream.active) {
383
+ throw new StreamInactiveInitializeError();
384
+ }
385
+ if (!this.stopPromise) {
386
+ throw new NoStopPromiseError();
387
+ }
388
+ if (!this.filename) {
389
+ throw new NoFileNameError();
390
+ }
391
+ }
281
392
  handleStop(resolve) {
282
- return async () => {
393
+ return () => {
283
394
  if (this.blobs.length === 0) {
284
395
  throw new CreateURLError();
285
396
  }
286
397
  this.url = URL.createObjectURL(new Blob(this.blobs));
287
- if (this.localDownload) {
288
- this.download();
289
- } else {
290
- await this.s3.completeUpload();
291
- }
292
- resolve();
398
+ resolve(this.url);
293
399
  };
294
400
  }
295
401
  handleDataAvailable(event) {
@@ -298,11 +404,11 @@ class Recorder {
298
404
  this.s3.onDataAvailable(event.data);
299
405
  }
300
406
  }
301
- download() {
302
- if (this.filename && this.url) {
407
+ download(filename, url) {
408
+ if (filename && url) {
303
409
  const link = document.createElement("a");
304
- link.href = this.url;
305
- link.download = this.filename;
410
+ link.href = url;
411
+ link.download = filename;
306
412
  link.click();
307
413
  }
308
414
  }
@@ -331,6 +437,18 @@ class Recorder {
331
437
  const rand_digits = Math.floor(Math.random() * 1e3);
332
438
  return `${prefix}_${window.chs.study.id}_${trial_id}_${window.chs.response.id}_${new Date().getTime()}_${rand_digits}.webm`;
333
439
  }
440
+ createTimeoutHandler(eventName, id) {
441
+ return () => {
442
+ console.warn(`Recorder ${eventName} timed out: ${id}`);
443
+ if (!this.stream.active) {
444
+ try {
445
+ this.reset();
446
+ } catch (err) {
447
+ console.error("Error while resetting recorder after timeout: ", err);
448
+ }
449
+ }
450
+ };
451
+ }
334
452
  }
335
453
 
336
454
  const info$3 = {
@@ -383,6 +501,11 @@ const info$3 = {
383
501
  prompt_only_adults: { type: jspsych.ParameterType.BOOL, default: false },
384
502
  consent_statement_text: { type: jspsych.ParameterType.STRING, default: "" },
385
503
  omit_injury_phrase: { type: jspsych.ParameterType.BOOL, default: false }
504
+ },
505
+ data: {
506
+ chs_type: {
507
+ type: jspsych.ParameterType.STRING
508
+ }
386
509
  }
387
510
  };
388
511
  const _VideoConsentPlugin = class {
@@ -510,8 +633,10 @@ const _VideoConsentPlugin = class {
510
633
  stop.addEventListener("click", async () => {
511
634
  stop.disabled = true;
512
635
  this.addMessage(display, this.uploadingMsg);
513
- await this.recorder.stop(true);
514
- this.recorder.reset();
636
+ const { stopped, uploaded } = this.recorder.stop({
637
+ maintain_container_size: true
638
+ });
639
+ await stopped;
515
640
  this.recordFeed(display);
516
641
  this.getImg(display, "record-icon").style.visibility = "hidden";
517
642
  this.addMessage(display, this.notRecordingMsg);
@@ -536,7 +661,21 @@ const _VideoConsentPlugin = class {
536
661
  let VideoConsentPlugin = _VideoConsentPlugin;
537
662
  VideoConsentPlugin.info = info$3;
538
663
 
539
- const info$2 = { name: "start-record-plugin", parameters: {} };
664
+ const info$2 = {
665
+ name: "start-record-plugin",
666
+ version: _package.version,
667
+ parameters: {
668
+ wait_for_connection_message: {
669
+ type: jspsych.ParameterType.HTML_STRING,
670
+ default: null
671
+ },
672
+ locale: {
673
+ type: jspsych.ParameterType.STRING,
674
+ default: "en-us"
675
+ }
676
+ },
677
+ data: {}
678
+ };
540
679
  const _StartRecordPlugin = class {
541
680
  constructor(jsPsych) {
542
681
  this.jsPsych = jsPsych;
@@ -547,8 +686,14 @@ const _StartRecordPlugin = class {
547
686
  throw new ExistingRecordingError();
548
687
  }
549
688
  }
550
- trial() {
551
- this.recorder.start(false, `${_StartRecordPlugin.info.name}-multiframe`).then(() => {
689
+ async trial(display_element, trial) {
690
+ if (trial.wait_for_connection_message == null) {
691
+ display_element.innerHTML = chsTemplates.establishingConnection(trial);
692
+ } else {
693
+ display_element.innerHTML = trial.wait_for_connection_message;
694
+ }
695
+ await this.recorder.start(false, `${_StartRecordPlugin.info.name}-multiframe`).then(() => {
696
+ display_element.innerHTML = "";
552
697
  this.jsPsych.finishTrial();
553
698
  });
554
699
  }
@@ -558,9 +703,22 @@ StartRecordPlugin.info = info$2;
558
703
 
559
704
  const info$1 = {
560
705
  name: "stop-record-plugin",
706
+ version: _package.version,
561
707
  parameters: {
562
- locale: { type: jspsych.ParameterType.STRING, default: "en-us" }
563
- }
708
+ wait_for_upload_message: {
709
+ type: jspsych.ParameterType.HTML_STRING,
710
+ default: null
711
+ },
712
+ locale: {
713
+ type: jspsych.ParameterType.STRING,
714
+ default: "en-us"
715
+ },
716
+ max_upload_seconds: {
717
+ type: jspsych.ParameterType.INT,
718
+ default: 10
719
+ }
720
+ },
721
+ data: {}
564
722
  };
565
723
  class StopRecordPlugin {
566
724
  constructor(jsPsych) {
@@ -571,13 +729,25 @@ class StopRecordPlugin {
571
729
  throw new NoSessionRecordingError();
572
730
  }
573
731
  }
574
- trial(display_element, trial) {
575
- display_element.innerHTML = chsTemplates.uploadingVideo(trial);
576
- this.recorder.stop().then(() => {
732
+ async trial(display_element, trial) {
733
+ if (trial.wait_for_upload_message == null) {
734
+ display_element.innerHTML = chsTemplates.uploadingVideo(trial);
735
+ } else {
736
+ display_element.innerHTML = trial.wait_for_upload_message;
737
+ }
738
+ const { stopped, uploaded } = this.recorder.stop({
739
+ upload_timeout_ms: trial.max_upload_seconds !== null ? trial.max_upload_seconds * 1e3 : null
740
+ });
741
+ try {
742
+ await stopped;
743
+ await uploaded;
744
+ } catch (err) {
745
+ console.error("StopRecordPlugin: recorder stop/upload failed.", err);
746
+ } finally {
577
747
  window.chs.sessionRecorder = null;
578
748
  display_element.innerHTML = "";
579
749
  this.jsPsych.finishTrial();
580
- });
750
+ }
581
751
  }
582
752
  }
583
753
  StopRecordPlugin.info = info$1;
@@ -585,20 +755,66 @@ StopRecordPlugin.info = info$1;
585
755
  class TrialRecordExtension {
586
756
  constructor(jsPsych) {
587
757
  this.jsPsych = jsPsych;
758
+ this.uploadMsg = null;
759
+ this.locale = "en-us";
760
+ this.maxUploadSeconds = void 0;
588
761
  autoBind(this);
589
762
  }
590
- async initialize() {
763
+ async initialize(params) {
764
+ await new Promise((resolve) => {
765
+ this.uploadMsg = params?.wait_for_upload_message ? params.wait_for_upload_message : null;
766
+ this.locale = params?.locale ? params.locale : "en-us";
767
+ this.maxUploadSeconds = params?.max_upload_seconds === void 0 ? 10 : params.max_upload_seconds;
768
+ resolve();
769
+ });
591
770
  }
592
- on_start() {
771
+ on_start(startParams) {
772
+ if (startParams?.wait_for_upload_message) {
773
+ this.uploadMsg = startParams.wait_for_upload_message;
774
+ }
775
+ if (startParams?.locale) {
776
+ this.locale = startParams.locale;
777
+ }
778
+ if (startParams?.max_upload_seconds !== void 0) {
779
+ this.maxUploadSeconds = startParams?.max_upload_seconds;
780
+ }
593
781
  this.recorder = new Recorder(this.jsPsych);
782
+ this.pluginName = this.getCurrentPluginName();
783
+ this.recorder.start(false, `${this.pluginName}`);
594
784
  }
595
785
  on_load() {
596
- this.pluginName = this.getCurrentPluginName();
597
- this.recorder?.start(false, `${this.pluginName}`);
598
786
  }
599
- on_finish() {
600
- this.recorder?.stop();
601
- return {};
787
+ async on_finish() {
788
+ const displayEl = this.jsPsych.getDisplayElement();
789
+ if (this.uploadMsg == null) {
790
+ displayEl.innerHTML = chsTemplates.uploadingVideo({
791
+ type: this.jsPsych.getCurrentTrial().type,
792
+ locale: this.locale
793
+ });
794
+ } else {
795
+ displayEl.innerHTML = this.uploadMsg;
796
+ }
797
+ if (this.recorder) {
798
+ const { stopped, uploaded } = this.recorder.stop({
799
+ upload_timeout_ms: this.maxUploadSeconds !== null ? this.maxUploadSeconds * 1e3 : null
800
+ });
801
+ try {
802
+ await stopped;
803
+ await uploaded;
804
+ displayEl.innerHTML = "";
805
+ return {};
806
+ } catch (err) {
807
+ console.error(
808
+ "TrialRecordExtension: recorder stop/upload failed.",
809
+ err
810
+ );
811
+ displayEl.innerHTML = "";
812
+ return {};
813
+ }
814
+ } else {
815
+ displayEl.innerHTML = "";
816
+ return {};
817
+ }
602
818
  }
603
819
  getCurrentPluginName() {
604
820
  const current_plugin_class = this.jsPsych.getCurrentTrial().type;
@@ -606,7 +822,9 @@ class TrialRecordExtension {
606
822
  }
607
823
  }
608
824
  TrialRecordExtension.info = {
609
- name: "chs-trial-record-extension"
825
+ name: "chs-trial-record-extension",
826
+ version: _package.version,
827
+ data: {}
610
828
  };
611
829
 
612
830
  var img$6 = "data:image/png;base64,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";