@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/README.md +199 -8
- package/dist/index.browser.js +257 -39
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.min.js +16 -16
- package/dist/index.browser.min.js.map +1 -1
- package/dist/index.cjs +256 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +185 -14
- package/dist/index.js +256 -38
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/consentVideo.spec.ts +9 -1
- package/src/consentVideo.ts +10 -2
- package/src/errors.ts +28 -0
- package/src/index.spec.ts +835 -17
- package/src/recorder.spec.ts +677 -73
- package/src/recorder.ts +191 -35
- package/src/start.ts +51 -5
- package/src/stop.ts +56 -5
- package/src/trial.ts +128 -16
- package/src/types.ts +21 -0
- package/src/utils.spec.ts +129 -0
- package/src/utils.ts +45 -0
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: "
|
|
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.
|
|
51
|
-
"@lookit/templates": "^
|
|
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(
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
405
|
+
download(filename, url) {
|
|
406
|
+
if (filename && url) {
|
|
301
407
|
const link = document.createElement("a");
|
|
302
|
-
link.href =
|
|
303
|
-
link.download =
|
|
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
|
-
|
|
512
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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.
|
|
599
|
-
|
|
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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";
|