@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/README.md +82 -10
- package/dist/index.browser.js +201 -41
- 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 +200 -40
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +83 -7
- package/dist/index.js +200 -40
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/consentVideo.spec.ts +9 -0
- package/src/consentVideo.ts +5 -1
- package/src/errors.ts +28 -0
- package/src/index.spec.ts +497 -54
- package/src/recorder.spec.ts +669 -109
- package/src/recorder.ts +184 -36
- package/src/start.ts +45 -5
- package/src/stop.ts +29 -12
- package/src/trial.ts +42 -16
- package/src/types.ts +21 -0
- package/src/utils.spec.ts +129 -0
- package/src/utils.ts +45 -0
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: "
|
|
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.
|
|
53
|
-
"@lookit/templates": "^
|
|
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(
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
407
|
+
download(filename, url) {
|
|
408
|
+
if (filename && url) {
|
|
304
409
|
const link = document.createElement("a");
|
|
305
|
-
link.href =
|
|
306
|
-
link.download =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
634
|
-
|
|
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
|
-
|
|
652
|
-
|
|
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
|
-
|
|
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;
|