@lookit/record 4.1.0 → 6.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.1.0",
11
+ version: "6.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.1.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,19 +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
- this.reset();
293
- resolve();
398
+ resolve(this.url);
294
399
  };
295
400
  }
296
401
  handleDataAvailable(event) {
@@ -299,11 +404,11 @@ class Recorder {
299
404
  this.s3.onDataAvailable(event.data);
300
405
  }
301
406
  }
302
- download() {
303
- if (this.filename && this.url) {
407
+ download(filename, url) {
408
+ if (filename && url) {
304
409
  const link = document.createElement("a");
305
- link.href = this.url;
306
- link.download = this.filename;
410
+ link.href = url;
411
+ link.download = filename;
307
412
  link.click();
308
413
  }
309
414
  }
@@ -332,13 +437,33 @@ class Recorder {
332
437
  const rand_digits = Math.floor(Math.random() * 1e3);
333
438
  return `${prefix}_${window.chs.study.id}_${trial_id}_${window.chs.response.id}_${new Date().getTime()}_${rand_digits}.webm`;
334
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
+ }
335
452
  }
336
453
 
337
454
  const info$3 = {
338
455
  name: "consent-video",
339
456
  version: _package.version,
340
457
  parameters: {
341
- template: { type: jspsych.ParameterType.STRING, default: "consent-template-5" },
458
+ template: {
459
+ type: jspsych.ParameterType.SELECT,
460
+ options: [
461
+ "consent-template-5",
462
+ "consent-garden",
463
+ "consent-recording-only"
464
+ ],
465
+ default: "consent-template-5"
466
+ },
342
467
  locale: { type: jspsych.ParameterType.STRING, default: "en-us" },
343
468
  additional_video_privacy_statement: {
344
469
  type: jspsych.ParameterType.STRING,
@@ -383,7 +508,8 @@ const info$3 = {
383
508
  prompt_all_adults: { type: jspsych.ParameterType.BOOL, default: false },
384
509
  prompt_only_adults: { type: jspsych.ParameterType.BOOL, default: false },
385
510
  consent_statement_text: { type: jspsych.ParameterType.STRING, default: "" },
386
- omit_injury_phrase: { type: jspsych.ParameterType.BOOL, default: false }
511
+ omit_injury_phrase: { type: jspsych.ParameterType.BOOL, default: false },
512
+ only_consent_on_chs: { type: jspsych.ParameterType.BOOL, default: false }
387
513
  },
388
514
  data: {
389
515
  chs_type: {
@@ -516,7 +642,10 @@ const _VideoConsentPlugin = class {
516
642
  stop.addEventListener("click", async () => {
517
643
  stop.disabled = true;
518
644
  this.addMessage(display, this.uploadingMsg);
519
- await this.recorder.stop(true);
645
+ const { stopped, uploaded } = this.recorder.stop({
646
+ maintain_container_size: true
647
+ });
648
+ await stopped;
520
649
  this.recordFeed(display);
521
650
  this.getImg(display, "record-icon").style.visibility = "hidden";
522
651
  this.addMessage(display, this.notRecordingMsg);
@@ -544,7 +673,16 @@ VideoConsentPlugin.info = info$3;
544
673
  const info$2 = {
545
674
  name: "start-record-plugin",
546
675
  version: _package.version,
547
- parameters: {},
676
+ parameters: {
677
+ wait_for_connection_message: {
678
+ type: jspsych.ParameterType.HTML_STRING,
679
+ default: null
680
+ },
681
+ locale: {
682
+ type: jspsych.ParameterType.STRING,
683
+ default: "en-us"
684
+ }
685
+ },
548
686
  data: {}
549
687
  };
550
688
  const _StartRecordPlugin = class {
@@ -557,8 +695,14 @@ const _StartRecordPlugin = class {
557
695
  throw new ExistingRecordingError();
558
696
  }
559
697
  }
560
- trial() {
561
- this.recorder.start(false, `${_StartRecordPlugin.info.name}-multiframe`).then(() => {
698
+ async trial(display_element, trial) {
699
+ if (trial.wait_for_connection_message == null) {
700
+ display_element.innerHTML = chsTemplates.establishingConnection(trial);
701
+ } else {
702
+ display_element.innerHTML = trial.wait_for_connection_message;
703
+ }
704
+ await this.recorder.start(false, `${_StartRecordPlugin.info.name}-multiframe`).then(() => {
705
+ display_element.innerHTML = "";
562
706
  this.jsPsych.finishTrial();
563
707
  });
564
708
  }
@@ -577,6 +721,10 @@ const info$1 = {
577
721
  locale: {
578
722
  type: jspsych.ParameterType.STRING,
579
723
  default: "en-us"
724
+ },
725
+ max_upload_seconds: {
726
+ type: jspsych.ParameterType.INT,
727
+ default: 10
580
728
  }
581
729
  },
582
730
  data: {}
@@ -590,19 +738,25 @@ class StopRecordPlugin {
590
738
  throw new NoSessionRecordingError();
591
739
  }
592
740
  }
593
- trial(display_element, trial) {
741
+ async trial(display_element, trial) {
594
742
  if (trial.wait_for_upload_message == null) {
595
743
  display_element.innerHTML = chsTemplates.uploadingVideo(trial);
596
744
  } else {
597
745
  display_element.innerHTML = trial.wait_for_upload_message;
598
746
  }
599
- this.recorder.stop().then(() => {
747
+ const { stopped, uploaded } = this.recorder.stop({
748
+ upload_timeout_ms: trial.max_upload_seconds !== null ? trial.max_upload_seconds * 1e3 : null
749
+ });
750
+ try {
751
+ await stopped;
752
+ await uploaded;
753
+ } catch (err) {
754
+ console.error("StopRecordPlugin: recorder stop/upload failed.", err);
755
+ } finally {
600
756
  window.chs.sessionRecorder = null;
601
757
  display_element.innerHTML = "";
602
758
  this.jsPsych.finishTrial();
603
- }).catch((err) => {
604
- console.error("StopRecordPlugin: recorder stop/upload failed.", err);
605
- });
759
+ }
606
760
  }
607
761
  }
608
762
  StopRecordPlugin.info = info$1;
@@ -612,14 +766,14 @@ class TrialRecordExtension {
612
766
  this.jsPsych = jsPsych;
613
767
  this.uploadMsg = null;
614
768
  this.locale = "en-us";
769
+ this.maxUploadSeconds = void 0;
615
770
  autoBind(this);
616
771
  }
617
772
  async initialize(params) {
618
773
  await new Promise((resolve) => {
619
774
  this.uploadMsg = params?.wait_for_upload_message ? params.wait_for_upload_message : null;
620
775
  this.locale = params?.locale ? params.locale : "en-us";
621
- console.log(this.uploadMsg);
622
- console.log(this.locale);
776
+ this.maxUploadSeconds = params?.max_upload_seconds === void 0 ? 10 : params.max_upload_seconds;
623
777
  resolve();
624
778
  });
625
779
  }
@@ -630,13 +784,14 @@ class TrialRecordExtension {
630
784
  if (startParams?.locale) {
631
785
  this.locale = startParams.locale;
632
786
  }
633
- console.log(this.uploadMsg);
634
- console.log(this.locale);
787
+ if (startParams?.max_upload_seconds !== void 0) {
788
+ this.maxUploadSeconds = startParams?.max_upload_seconds;
789
+ }
635
790
  this.recorder = new Recorder(this.jsPsych);
791
+ this.pluginName = this.getCurrentPluginName();
792
+ this.recorder.start(false, `${this.pluginName}`);
636
793
  }
637
794
  on_load() {
638
- this.pluginName = this.getCurrentPluginName();
639
- this.recorder?.start(false, `${this.pluginName}`);
640
795
  }
641
796
  async on_finish() {
642
797
  const displayEl = this.jsPsych.getDisplayElement();
@@ -648,13 +803,27 @@ class TrialRecordExtension {
648
803
  } else {
649
804
  displayEl.innerHTML = this.uploadMsg;
650
805
  }
651
- try {
652
- await this.recorder?.stop();
806
+ if (this.recorder) {
807
+ const { stopped, uploaded } = this.recorder.stop({
808
+ upload_timeout_ms: this.maxUploadSeconds !== null ? this.maxUploadSeconds * 1e3 : null
809
+ });
810
+ try {
811
+ await stopped;
812
+ await uploaded;
813
+ displayEl.innerHTML = "";
814
+ return {};
815
+ } catch (err) {
816
+ console.error(
817
+ "TrialRecordExtension: recorder stop/upload failed.",
818
+ err
819
+ );
820
+ displayEl.innerHTML = "";
821
+ return {};
822
+ }
823
+ } else {
653
824
  displayEl.innerHTML = "";
654
- } catch (err) {
655
- console.error("TrialRecordExtension: recorder stop/upload failed.", err);
825
+ return {};
656
826
  }
657
- return {};
658
827
  }
659
828
  getCurrentPluginName() {
660
829
  const current_plugin_class = this.jsPsych.getCurrentTrial().type;