@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.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,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
|
|
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
|
-
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 (
|
|
407
|
+
download(filename, url) {
|
|
408
|
+
if (filename && url) {
|
|
303
409
|
const link = document.createElement("a");
|
|
304
|
-
link.href =
|
|
305
|
-
link.download =
|
|
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
|
-
|
|
514
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
576
|
-
|
|
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.
|
|
601
|
-
|
|
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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";
|