@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.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: "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,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,6 +437,18 @@ 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 = {
@@ -516,7 +633,10 @@ const _VideoConsentPlugin = class {
516
633
  stop.addEventListener("click", async () => {
517
634
  stop.disabled = true;
518
635
  this.addMessage(display, this.uploadingMsg);
519
- await this.recorder.stop(true);
636
+ const { stopped, uploaded } = this.recorder.stop({
637
+ maintain_container_size: true
638
+ });
639
+ await stopped;
520
640
  this.recordFeed(display);
521
641
  this.getImg(display, "record-icon").style.visibility = "hidden";
522
642
  this.addMessage(display, this.notRecordingMsg);
@@ -544,7 +664,16 @@ VideoConsentPlugin.info = info$3;
544
664
  const info$2 = {
545
665
  name: "start-record-plugin",
546
666
  version: _package.version,
547
- parameters: {},
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
+ },
548
677
  data: {}
549
678
  };
550
679
  const _StartRecordPlugin = class {
@@ -557,8 +686,14 @@ const _StartRecordPlugin = class {
557
686
  throw new ExistingRecordingError();
558
687
  }
559
688
  }
560
- trial() {
561
- 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 = "";
562
697
  this.jsPsych.finishTrial();
563
698
  });
564
699
  }
@@ -577,6 +712,10 @@ const info$1 = {
577
712
  locale: {
578
713
  type: jspsych.ParameterType.STRING,
579
714
  default: "en-us"
715
+ },
716
+ max_upload_seconds: {
717
+ type: jspsych.ParameterType.INT,
718
+ default: 10
580
719
  }
581
720
  },
582
721
  data: {}
@@ -590,19 +729,25 @@ class StopRecordPlugin {
590
729
  throw new NoSessionRecordingError();
591
730
  }
592
731
  }
593
- trial(display_element, trial) {
732
+ async trial(display_element, trial) {
594
733
  if (trial.wait_for_upload_message == null) {
595
734
  display_element.innerHTML = chsTemplates.uploadingVideo(trial);
596
735
  } else {
597
736
  display_element.innerHTML = trial.wait_for_upload_message;
598
737
  }
599
- this.recorder.stop().then(() => {
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 {
600
747
  window.chs.sessionRecorder = null;
601
748
  display_element.innerHTML = "";
602
749
  this.jsPsych.finishTrial();
603
- }).catch((err) => {
604
- console.error("StopRecordPlugin: recorder stop/upload failed.", err);
605
- });
750
+ }
606
751
  }
607
752
  }
608
753
  StopRecordPlugin.info = info$1;
@@ -612,14 +757,14 @@ class TrialRecordExtension {
612
757
  this.jsPsych = jsPsych;
613
758
  this.uploadMsg = null;
614
759
  this.locale = "en-us";
760
+ this.maxUploadSeconds = void 0;
615
761
  autoBind(this);
616
762
  }
617
763
  async initialize(params) {
618
764
  await new Promise((resolve) => {
619
765
  this.uploadMsg = params?.wait_for_upload_message ? params.wait_for_upload_message : null;
620
766
  this.locale = params?.locale ? params.locale : "en-us";
621
- console.log(this.uploadMsg);
622
- console.log(this.locale);
767
+ this.maxUploadSeconds = params?.max_upload_seconds === void 0 ? 10 : params.max_upload_seconds;
623
768
  resolve();
624
769
  });
625
770
  }
@@ -630,13 +775,14 @@ class TrialRecordExtension {
630
775
  if (startParams?.locale) {
631
776
  this.locale = startParams.locale;
632
777
  }
633
- console.log(this.uploadMsg);
634
- console.log(this.locale);
778
+ if (startParams?.max_upload_seconds !== void 0) {
779
+ this.maxUploadSeconds = startParams?.max_upload_seconds;
780
+ }
635
781
  this.recorder = new Recorder(this.jsPsych);
782
+ this.pluginName = this.getCurrentPluginName();
783
+ this.recorder.start(false, `${this.pluginName}`);
636
784
  }
637
785
  on_load() {
638
- this.pluginName = this.getCurrentPluginName();
639
- this.recorder?.start(false, `${this.pluginName}`);
640
786
  }
641
787
  async on_finish() {
642
788
  const displayEl = this.jsPsych.getDisplayElement();
@@ -648,13 +794,27 @@ class TrialRecordExtension {
648
794
  } else {
649
795
  displayEl.innerHTML = this.uploadMsg;
650
796
  }
651
- try {
652
- await this.recorder?.stop();
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 {
653
815
  displayEl.innerHTML = "";
654
- } catch (err) {
655
- console.error("TrialRecordExtension: recorder stop/upload failed.", err);
816
+ return {};
656
817
  }
657
- return {};
658
818
  }
659
819
  getCurrentPluginName() {
660
820
  const current_plugin_class = this.jsPsych.getCurrentTrial().type;