@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/src/recorder.ts
CHANGED
|
@@ -11,6 +11,7 @@ import play_icon from "../img/play-icon.svg";
|
|
|
11
11
|
import record_icon from "../img/record-icon.svg";
|
|
12
12
|
import {
|
|
13
13
|
CreateURLError,
|
|
14
|
+
NoFileNameError,
|
|
14
15
|
NoStopPromiseError,
|
|
15
16
|
NoWebCamElementError,
|
|
16
17
|
RecorderInitializeError,
|
|
@@ -18,8 +19,10 @@ import {
|
|
|
18
19
|
StreamActiveOnResetError,
|
|
19
20
|
StreamDataInitializeError,
|
|
20
21
|
StreamInactiveInitializeError,
|
|
22
|
+
TimeoutError,
|
|
21
23
|
} from "./errors";
|
|
22
|
-
import { CSSWidthHeight } from "./types";
|
|
24
|
+
import { CSSWidthHeight, StopOptions, StopResult } from "./types";
|
|
25
|
+
import { promiseWithTimeout } from "./utils";
|
|
23
26
|
|
|
24
27
|
declare const window: LookitWindow;
|
|
25
28
|
|
|
@@ -32,10 +35,11 @@ export default class Recorder {
|
|
|
32
35
|
private localDownload: boolean =
|
|
33
36
|
process.env.LOCAL_DOWNLOAD?.toLowerCase() === "true";
|
|
34
37
|
private filename?: string;
|
|
35
|
-
private stopPromise?: Promise<
|
|
38
|
+
private stopPromise?: Promise<string>;
|
|
36
39
|
private webcam_element_id = "lookit-jspsych-webcam";
|
|
37
40
|
private mimeType = "video/webm";
|
|
38
41
|
|
|
42
|
+
// persistent clone of the original stream so we can re-initialize
|
|
39
43
|
private streamClone: MediaStream;
|
|
40
44
|
|
|
41
45
|
/**
|
|
@@ -117,11 +121,15 @@ export default class Recorder {
|
|
|
117
121
|
* the video config plugin).
|
|
118
122
|
*/
|
|
119
123
|
public reset() {
|
|
124
|
+
// Reset can only be called after the current stream has been fully stopped.
|
|
120
125
|
if (this.stream.active) {
|
|
121
126
|
throw new StreamActiveOnResetError();
|
|
122
127
|
}
|
|
128
|
+
// Ensure later recordings have a valid active stream.
|
|
123
129
|
this.initializeRecorder(this.streamClone.clone());
|
|
130
|
+
// Clear blob buffer (any pending uploads are handled by LookitS3 instances and tracked globally)
|
|
124
131
|
this.blobs = [];
|
|
132
|
+
// TO DO: reset S3/filename/URL?
|
|
125
133
|
}
|
|
126
134
|
|
|
127
135
|
/**
|
|
@@ -255,7 +263,7 @@ export default class Recorder {
|
|
|
255
263
|
|
|
256
264
|
// create a stop promise and pass the resolve function as an argument to the stop event callback,
|
|
257
265
|
// so that the stop event handler can resolve the stop promise
|
|
258
|
-
this.stopPromise = new Promise((resolve) => {
|
|
266
|
+
this.stopPromise = new Promise<string>((resolve) => {
|
|
259
267
|
this.recorder.addEventListener("stop", this.handleStop(resolve));
|
|
260
268
|
});
|
|
261
269
|
|
|
@@ -282,24 +290,122 @@ export default class Recorder {
|
|
|
282
290
|
* tracks, clear the webcam feed element (if there is one), and return the
|
|
283
291
|
* stop promise. This should only be called after recording has started.
|
|
284
292
|
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
293
|
+
* When calling recorder.stop, plugins may:
|
|
294
|
+
*
|
|
295
|
+
* - Await the 'stopped' promise
|
|
296
|
+
* - Await the 'uploaded' promise to wait for the upload to finish before
|
|
297
|
+
* continuing
|
|
298
|
+
* - Not await either promise to continue immediately and regardless of the
|
|
299
|
+
* stop/upload outcomes
|
|
300
|
+
*
|
|
301
|
+
* @param options - Object with the following:
|
|
302
|
+
* @param options.maintain_container_size - Optional boolean indicating
|
|
303
|
+
* whether or not to maintain the current size of the webcam feed container
|
|
304
|
+
* when removing the video element. Default is false. If true, the container
|
|
305
|
+
* will be resized to match the dimensions of the video element before it is
|
|
289
306
|
* removed. This is useful for avoiding layout jumps when the webcam
|
|
290
307
|
* container will be re-used during the trial.
|
|
291
|
-
* @
|
|
292
|
-
*
|
|
293
|
-
*
|
|
308
|
+
* @param options.stop_timeout_ms - Number of seconds to wait for the stop
|
|
309
|
+
* process to complete.
|
|
310
|
+
* @param options.upload_timeout_ms - Number of seconds to wait for the upload
|
|
311
|
+
* process to complete.
|
|
312
|
+
* @returns Object with two promises:
|
|
313
|
+
*
|
|
314
|
+
* - Stopped: Promise<void> - Promise that resolves after the media recorder has
|
|
315
|
+
* stopped and final 'dataavailable' event has occurred, when the "stop"
|
|
316
|
+
* event-related callback function is called.
|
|
317
|
+
* - Uploaded: Promise<void> - Promise that resolves when the S3 upload
|
|
318
|
+
* completes.
|
|
294
319
|
*/
|
|
295
|
-
public stop(
|
|
320
|
+
public stop({
|
|
321
|
+
maintain_container_size = false,
|
|
322
|
+
stop_timeout_ms = null,
|
|
323
|
+
upload_timeout_ms = 10000,
|
|
324
|
+
}: StopOptions = {}): StopResult {
|
|
325
|
+
this.preStopCheck();
|
|
296
326
|
this.clearWebcamFeed(maintain_container_size);
|
|
297
327
|
this.stopTracks();
|
|
298
328
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
329
|
+
// Snapshot anything needed for upload before the Recorder instance is reset.
|
|
330
|
+
// URL is placeholder because it will not be defined until after the stop promise is resolved.
|
|
331
|
+
const snapshot = {
|
|
332
|
+
s3: !this.localDownload ? this.s3 : null,
|
|
333
|
+
filename: this.filename,
|
|
334
|
+
localDownload: this.localDownload,
|
|
335
|
+
url: "null",
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// Wrap the existing stopPromise with timeout if needed, otherwise return as is.
|
|
339
|
+
const stopped: Promise<string> = stop_timeout_ms
|
|
340
|
+
? promiseWithTimeout(
|
|
341
|
+
this.stopPromise!,
|
|
342
|
+
`${snapshot.filename}-stopped`,
|
|
343
|
+
stop_timeout_ms,
|
|
344
|
+
this.createTimeoutHandler("stop", snapshot.filename!),
|
|
345
|
+
)
|
|
346
|
+
: this.stopPromise!;
|
|
347
|
+
|
|
348
|
+
// Chain reset off the stop promise, which is either the original stop promise or a promise race with the timeout.
|
|
349
|
+
stopped.finally(() => {
|
|
350
|
+
try {
|
|
351
|
+
// It's safe to reset because recording is fully stopped and S3 info has been snapshotted.
|
|
352
|
+
this.reset();
|
|
353
|
+
} catch (err) {
|
|
354
|
+
console.error("Error while resetting recorder after stop: ", err);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Create the upload (or local download) promise
|
|
359
|
+
const uploadPromise: Promise<void> = (async () => {
|
|
360
|
+
let url: string;
|
|
361
|
+
try {
|
|
362
|
+
url = await stopped;
|
|
363
|
+
if (url == "timeout") {
|
|
364
|
+
// Stop failed, throw so that the upload promise reflects this failure
|
|
365
|
+
throw new TimeoutError("Recorder stop timed out.");
|
|
366
|
+
}
|
|
367
|
+
} catch (err) {
|
|
368
|
+
console.warn("Upload failed because recorder stop timed out");
|
|
369
|
+
throw err;
|
|
370
|
+
}
|
|
371
|
+
snapshot.url = url;
|
|
372
|
+
if (snapshot.localDownload) {
|
|
373
|
+
try {
|
|
374
|
+
this.download(snapshot.filename!, snapshot.url);
|
|
375
|
+
await Promise.resolve();
|
|
376
|
+
} catch (err) {
|
|
377
|
+
console.error("Local download failed: ", err);
|
|
378
|
+
throw err;
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
try {
|
|
382
|
+
await snapshot.s3!.completeUpload();
|
|
383
|
+
} catch (err) {
|
|
384
|
+
console.error("Upload failed: ", err);
|
|
385
|
+
throw err;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
})();
|
|
389
|
+
|
|
390
|
+
// Wrap the upload promise in a timeout if needed, otherwise return as is.
|
|
391
|
+
const uploaded: Promise<void | string> = upload_timeout_ms
|
|
392
|
+
? promiseWithTimeout(
|
|
393
|
+
uploadPromise,
|
|
394
|
+
`${snapshot.filename}-uploaded`,
|
|
395
|
+
upload_timeout_ms,
|
|
396
|
+
this.createTimeoutHandler("upload", snapshot.filename!),
|
|
397
|
+
)
|
|
398
|
+
: uploadPromise;
|
|
399
|
+
|
|
400
|
+
// Track background uploads in case the consuming plugin is not awaiting the upload.
|
|
401
|
+
// We don't want the timeout version because this one can continue in the background.
|
|
402
|
+
window.chs.pendingUploads.push({
|
|
403
|
+
promise: uploadPromise,
|
|
404
|
+
file: snapshot.filename!,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Return the pair of promises so that the calling plugin can await them.
|
|
408
|
+
return { stopped, uploaded };
|
|
303
409
|
}
|
|
304
410
|
|
|
305
411
|
/** Throw Error if there isn't a recorder provided by jsPsych. */
|
|
@@ -317,31 +423,42 @@ export default class Recorder {
|
|
|
317
423
|
}
|
|
318
424
|
}
|
|
319
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Check for necessary conditions before stop/upload process, and throw errors
|
|
428
|
+
* if needed.
|
|
429
|
+
*/
|
|
430
|
+
private preStopCheck() {
|
|
431
|
+
if (!this.recorder) {
|
|
432
|
+
throw new RecorderInitializeError();
|
|
433
|
+
}
|
|
434
|
+
if (!this.stream.active) {
|
|
435
|
+
throw new StreamInactiveInitializeError();
|
|
436
|
+
}
|
|
437
|
+
if (!this.stopPromise) {
|
|
438
|
+
throw new NoStopPromiseError();
|
|
439
|
+
}
|
|
440
|
+
if (!this.filename) {
|
|
441
|
+
throw new NoFileNameError();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
320
445
|
/**
|
|
321
446
|
* Handle the recorder's stop event. This is a function that takes the stop
|
|
322
447
|
* promise's 'resolve' as an argument and returns a function that resolves
|
|
323
|
-
* that stop promise
|
|
324
|
-
* "stop" event-related callback function.
|
|
448
|
+
* that stop promise with the URL that is created from the recording.
|
|
325
449
|
*
|
|
326
|
-
* @param resolve -
|
|
327
|
-
*
|
|
450
|
+
* @param resolve - Resolve function that resolves the stop promise that was
|
|
451
|
+
* created upon the start of recording.
|
|
452
|
+
* @returns Function that is called on the recorder's "stop" event. This
|
|
453
|
+
* function resolves the stop promise for this recording with a URL.
|
|
328
454
|
*/
|
|
329
|
-
private handleStop(resolve: () => void) {
|
|
330
|
-
return
|
|
455
|
+
private handleStop(resolve: (value: string | PromiseLike<string>) => void) {
|
|
456
|
+
return () => {
|
|
331
457
|
if (this.blobs.length === 0) {
|
|
332
458
|
throw new CreateURLError();
|
|
333
459
|
}
|
|
334
460
|
this.url = URL.createObjectURL(new Blob(this.blobs));
|
|
335
|
-
|
|
336
|
-
if (this.localDownload) {
|
|
337
|
-
this.download();
|
|
338
|
-
} else {
|
|
339
|
-
await this.s3.completeUpload();
|
|
340
|
-
}
|
|
341
|
-
// Reset the recorder. This is necessary to create another active media stream from the stream clone, because the current stream is fully stopped/inactive and cannot be used again.
|
|
342
|
-
this.reset();
|
|
343
|
-
|
|
344
|
-
resolve();
|
|
461
|
+
resolve(this.url);
|
|
345
462
|
};
|
|
346
463
|
}
|
|
347
464
|
|
|
@@ -351,18 +468,27 @@ export default class Recorder {
|
|
|
351
468
|
* @param event - Event containing blob data.
|
|
352
469
|
*/
|
|
353
470
|
private handleDataAvailable(event: BlobEvent) {
|
|
471
|
+
// Store locally for URL creation
|
|
354
472
|
this.blobs.push(event.data);
|
|
355
473
|
if (!this.localDownload) {
|
|
474
|
+
// Forward to LookitS3 instance, which manages uploading
|
|
356
475
|
this.s3.onDataAvailable(event.data);
|
|
357
476
|
}
|
|
358
477
|
}
|
|
359
478
|
|
|
360
|
-
/**
|
|
361
|
-
|
|
362
|
-
|
|
479
|
+
/**
|
|
480
|
+
* Download data url used in local development. This can be used on
|
|
481
|
+
* snapshotted recordings after the recorder has been reset, so we need to
|
|
482
|
+
* pass in the filename and URL rather than relying on this.
|
|
483
|
+
*
|
|
484
|
+
* @param filename - Filename for the recording that will be downloaded.
|
|
485
|
+
* @param url - URL containing the file data to be downloaded.
|
|
486
|
+
*/
|
|
487
|
+
private download(filename: string, url: string) {
|
|
488
|
+
if (filename && url) {
|
|
363
489
|
const link = document.createElement("a");
|
|
364
|
-
link.href =
|
|
365
|
-
link.download =
|
|
490
|
+
link.href = url;
|
|
491
|
+
link.download = filename;
|
|
366
492
|
link.click();
|
|
367
493
|
}
|
|
368
494
|
}
|
|
@@ -417,4 +543,26 @@ export default class Recorder {
|
|
|
417
543
|
const rand_digits = Math.floor(Math.random() * 1000);
|
|
418
544
|
return `${prefix}_${window.chs.study.id}_${trial_id}_${window.chs.response.id}_${new Date().getTime()}_${rand_digits}.webm`;
|
|
419
545
|
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Create the timeout handler function for events that we're awaiting with a
|
|
549
|
+
* timeout.
|
|
550
|
+
*
|
|
551
|
+
* @param eventName - Name of the event we're awaiting, e.g. 'stop', 'upload'
|
|
552
|
+
* @param id - String to identify the promise that we're awaiting.
|
|
553
|
+
* @returns Callback function if the event promise times out.
|
|
554
|
+
*/
|
|
555
|
+
private createTimeoutHandler(eventName: string, id: string) {
|
|
556
|
+
return () => {
|
|
557
|
+
console.warn(`Recorder ${eventName} timed out: ${id}`);
|
|
558
|
+
// check if reset is needed
|
|
559
|
+
if (!this.stream.active) {
|
|
560
|
+
try {
|
|
561
|
+
this.reset();
|
|
562
|
+
} catch (err) {
|
|
563
|
+
console.error("Error while resetting recorder after timeout: ", err);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
}
|
|
420
568
|
}
|
package/src/start.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LookitWindow } from "@lookit/data/dist/types";
|
|
2
|
-
import
|
|
2
|
+
import chsTemplates from "@lookit/templates";
|
|
3
|
+
import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
|
|
3
4
|
import { version } from "../package.json";
|
|
4
5
|
import { ExistingRecordingError } from "./errors";
|
|
5
6
|
import Recorder from "./recorder";
|
|
@@ -9,7 +10,30 @@ declare let window: LookitWindow;
|
|
|
9
10
|
const info = <const>{
|
|
10
11
|
name: "start-record-plugin",
|
|
11
12
|
version,
|
|
12
|
-
parameters: {
|
|
13
|
+
parameters: {
|
|
14
|
+
/**
|
|
15
|
+
* This string can contain HTML markup. Any content provided will be
|
|
16
|
+
* displayed while the video recording connection is established. If null
|
|
17
|
+
* (the default), then the default 'establishing video connection, please
|
|
18
|
+
* wait' (or appropriate translation based on 'locale') will be displayed.
|
|
19
|
+
* Use a blank string for no message/content.
|
|
20
|
+
*/
|
|
21
|
+
wait_for_connection_message: {
|
|
22
|
+
type: ParameterType.HTML_STRING,
|
|
23
|
+
default: null as null | string,
|
|
24
|
+
},
|
|
25
|
+
/**
|
|
26
|
+
* Locale code used for translating the default 'establishing video
|
|
27
|
+
* connection, please wait' message. This code must be present in the
|
|
28
|
+
* translation files. If the code is not found then English will be used. If
|
|
29
|
+
* the 'wait_for_connection_message' parameter is specified then this value
|
|
30
|
+
* is ignored.
|
|
31
|
+
*/
|
|
32
|
+
locale: {
|
|
33
|
+
type: ParameterType.STRING,
|
|
34
|
+
default: "en-us",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
13
37
|
data: {},
|
|
14
38
|
};
|
|
15
39
|
type Info = typeof info;
|
|
@@ -33,11 +57,27 @@ export default class StartRecordPlugin implements JsPsychPlugin<Info> {
|
|
|
33
57
|
}
|
|
34
58
|
}
|
|
35
59
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Trial function called by jsPsych.
|
|
62
|
+
*
|
|
63
|
+
* @param display_element - DOM element where jsPsych content is being
|
|
64
|
+
* rendered (set in initJsPsych and automatically made available to a
|
|
65
|
+
* plugin's trial method via jsPsych core).
|
|
66
|
+
* @param trial - Trial object with parameters/values.
|
|
67
|
+
*/
|
|
68
|
+
public async trial(
|
|
69
|
+
display_element: HTMLElement,
|
|
70
|
+
trial: TrialType<Info>,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
if (trial.wait_for_connection_message == null) {
|
|
73
|
+
display_element.innerHTML = chsTemplates.establishingConnection(trial);
|
|
74
|
+
} else {
|
|
75
|
+
display_element.innerHTML = trial.wait_for_connection_message;
|
|
76
|
+
}
|
|
77
|
+
await this.recorder
|
|
39
78
|
.start(false, `${StartRecordPlugin.info.name}-multiframe`)
|
|
40
79
|
.then(() => {
|
|
80
|
+
display_element.innerHTML = "";
|
|
41
81
|
this.jsPsych.finishTrial();
|
|
42
82
|
});
|
|
43
83
|
}
|
package/src/stop.ts
CHANGED
|
@@ -33,6 +33,14 @@ const info = <const>{
|
|
|
33
33
|
type: ParameterType.STRING,
|
|
34
34
|
default: "en-us",
|
|
35
35
|
},
|
|
36
|
+
/**
|
|
37
|
+
* Maximum duration (in seconds) to wait for the session recording to finish
|
|
38
|
+
* uploading before continuing with the experiment.
|
|
39
|
+
*/
|
|
40
|
+
max_upload_seconds: {
|
|
41
|
+
type: ParameterType.INT,
|
|
42
|
+
default: 10,
|
|
43
|
+
},
|
|
36
44
|
},
|
|
37
45
|
data: {},
|
|
38
46
|
};
|
|
@@ -64,22 +72,31 @@ export default class StopRecordPlugin implements JsPsychPlugin<Info> {
|
|
|
64
72
|
* plugin's trial method via jsPsych core).
|
|
65
73
|
* @param trial - Trial object with parameters/values.
|
|
66
74
|
*/
|
|
67
|
-
public trial(
|
|
75
|
+
public async trial(
|
|
76
|
+
display_element: HTMLElement,
|
|
77
|
+
trial: TrialType<Info>,
|
|
78
|
+
): Promise<void> {
|
|
68
79
|
if (trial.wait_for_upload_message == null) {
|
|
69
80
|
display_element.innerHTML = chsTemplates.uploadingVideo(trial);
|
|
70
81
|
} else {
|
|
71
82
|
display_element.innerHTML = trial.wait_for_upload_message;
|
|
72
83
|
}
|
|
73
|
-
this.recorder
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
const { stopped, uploaded } = this.recorder.stop({
|
|
85
|
+
upload_timeout_ms:
|
|
86
|
+
trial.max_upload_seconds !== null
|
|
87
|
+
? trial.max_upload_seconds! * 1000
|
|
88
|
+
: null,
|
|
89
|
+
});
|
|
90
|
+
try {
|
|
91
|
+
await stopped;
|
|
92
|
+
await uploaded;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.error("StopRecordPlugin: recorder stop/upload failed.", err);
|
|
95
|
+
// TO DO: display translated error msg and/or researcher contact info
|
|
96
|
+
} finally {
|
|
97
|
+
window.chs.sessionRecorder = null;
|
|
98
|
+
display_element.innerHTML = "";
|
|
99
|
+
this.jsPsych.finishTrial();
|
|
100
|
+
}
|
|
84
101
|
}
|
|
85
102
|
}
|
package/src/trial.ts
CHANGED
|
@@ -30,12 +30,18 @@ interface Parameters {
|
|
|
30
30
|
* Locale code used for translating the default 'uploading video, please
|
|
31
31
|
* wait...' message. This code must be present in the translation files. If
|
|
32
32
|
* the code is not found then English will be used. If the
|
|
33
|
-
* 'wait_for_upload_message' parameter is specified then this value
|
|
34
|
-
*
|
|
33
|
+
* 'wait_for_upload_message' parameter is specified then this value is
|
|
34
|
+
* ignored.
|
|
35
35
|
*
|
|
36
36
|
* @default "en-us"
|
|
37
37
|
*/
|
|
38
38
|
locale?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Maximum duration (in seconds) to wait for the trial recording to finish
|
|
41
|
+
* uploading before continuing with the experiment. Default is 10 seconds (set
|
|
42
|
+
* during initialize).
|
|
43
|
+
*/
|
|
44
|
+
max_upload_seconds?: null | number;
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
/** This extension allows researchers to record webcam audio/video during trials. */
|
|
@@ -50,6 +56,7 @@ export default class TrialRecordExtension implements JsPsychExtension {
|
|
|
50
56
|
private pluginName: string | undefined;
|
|
51
57
|
private uploadMsg: null | string = null;
|
|
52
58
|
private locale: string = "en-us";
|
|
59
|
+
private maxUploadSeconds: undefined | null | number = undefined;
|
|
53
60
|
|
|
54
61
|
/**
|
|
55
62
|
* Video recording extension.
|
|
@@ -72,12 +79,16 @@ export default class TrialRecordExtension implements JsPsychExtension {
|
|
|
72
79
|
*/
|
|
73
80
|
public async initialize(params?: Parameters) {
|
|
74
81
|
await new Promise<void>((resolve) => {
|
|
82
|
+
// set parameter defaults
|
|
75
83
|
this.uploadMsg = params?.wait_for_upload_message
|
|
76
84
|
? params.wait_for_upload_message
|
|
77
85
|
: null;
|
|
78
86
|
this.locale = params?.locale ? params.locale : "en-us";
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
// null is a valid value - only set default if no parameter value was provided
|
|
88
|
+
this.maxUploadSeconds =
|
|
89
|
+
params?.max_upload_seconds === undefined
|
|
90
|
+
? 10
|
|
91
|
+
: params.max_upload_seconds;
|
|
81
92
|
resolve();
|
|
82
93
|
});
|
|
83
94
|
}
|
|
@@ -97,16 +108,16 @@ export default class TrialRecordExtension implements JsPsychExtension {
|
|
|
97
108
|
if (startParams?.locale) {
|
|
98
109
|
this.locale = startParams.locale;
|
|
99
110
|
}
|
|
100
|
-
|
|
101
|
-
|
|
111
|
+
if (startParams?.max_upload_seconds !== undefined) {
|
|
112
|
+
this.maxUploadSeconds = startParams?.max_upload_seconds;
|
|
113
|
+
}
|
|
102
114
|
this.recorder = new Recorder(this.jsPsych);
|
|
115
|
+
this.pluginName = this.getCurrentPluginName();
|
|
116
|
+
this.recorder.start(false, `${this.pluginName}`);
|
|
103
117
|
}
|
|
104
118
|
|
|
105
119
|
/** Runs when the trial has loaded. */
|
|
106
|
-
public on_load() {
|
|
107
|
-
this.pluginName = this.getCurrentPluginName();
|
|
108
|
-
this.recorder?.start(false, `${this.pluginName}`);
|
|
109
|
-
}
|
|
120
|
+
public on_load() {}
|
|
110
121
|
|
|
111
122
|
/**
|
|
112
123
|
* Runs when trial has finished.
|
|
@@ -124,14 +135,29 @@ export default class TrialRecordExtension implements JsPsychExtension {
|
|
|
124
135
|
} else {
|
|
125
136
|
displayEl.innerHTML = this.uploadMsg;
|
|
126
137
|
}
|
|
127
|
-
|
|
128
|
-
|
|
138
|
+
if (this.recorder) {
|
|
139
|
+
const { stopped, uploaded } = this.recorder.stop({
|
|
140
|
+
upload_timeout_ms:
|
|
141
|
+
this.maxUploadSeconds !== null ? this.maxUploadSeconds! * 1000 : null,
|
|
142
|
+
});
|
|
143
|
+
try {
|
|
144
|
+
await stopped;
|
|
145
|
+
await uploaded;
|
|
146
|
+
displayEl.innerHTML = "";
|
|
147
|
+
return {};
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error(
|
|
150
|
+
"TrialRecordExtension: recorder stop/upload failed.",
|
|
151
|
+
err,
|
|
152
|
+
);
|
|
153
|
+
displayEl.innerHTML = "";
|
|
154
|
+
return {};
|
|
155
|
+
// TO DO: display translated error msg and/or researcher contact info
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
129
158
|
displayEl.innerHTML = "";
|
|
130
|
-
|
|
131
|
-
console.error("TrialRecordExtension: recorder stop/upload failed.", err);
|
|
132
|
-
// TO DO: display translated error msg and/or researcher contact info
|
|
159
|
+
return {};
|
|
133
160
|
}
|
|
134
|
-
return {};
|
|
135
161
|
}
|
|
136
162
|
|
|
137
163
|
/**
|
package/src/types.ts
CHANGED
|
@@ -14,3 +14,24 @@ export type CSSWidthHeight =
|
|
|
14
14
|
| number
|
|
15
15
|
| `${number}${"px" | "cm" | "mm" | "em" | "%"}`
|
|
16
16
|
| "auto";
|
|
17
|
+
|
|
18
|
+
/** Options for the stop method */
|
|
19
|
+
export interface StopOptions {
|
|
20
|
+
maintain_container_size?: boolean;
|
|
21
|
+
stop_timeout_ms?: number | null;
|
|
22
|
+
upload_timeout_ms?: number | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Result returned by the stop method */
|
|
26
|
+
export interface StopResult {
|
|
27
|
+
/**
|
|
28
|
+
* Promise that resolves with the URL when the recorder has fully stopped.
|
|
29
|
+
* Returns a string with either the completion URL or "timeout".
|
|
30
|
+
*/
|
|
31
|
+
stopped: Promise<string>;
|
|
32
|
+
/**
|
|
33
|
+
* Promise that resolves when the upload (or local download) completes.
|
|
34
|
+
* Returns void if succeeds or "timeout" if times out.
|
|
35
|
+
*/
|
|
36
|
+
uploaded: Promise<void | string>;
|
|
37
|
+
}
|