@lookit/record 0.0.3
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 +376 -0
- package/dist/consentVideo.d.ts +353 -0
- package/dist/errors.d.ts +158 -0
- package/dist/index.browser.js +26321 -0
- package/dist/index.browser.js.map +1 -0
- package/dist/index.browser.min.js +4 -0
- package/dist/index.browser.min.js.map +1 -0
- package/dist/index.cjs +26321 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +26319 -0
- package/dist/index.js.map +1 -0
- package/dist/mic_check.d.ts +33 -0
- package/dist/recorder.d.ts +146 -0
- package/dist/start.d.ts +24 -0
- package/dist/stop.d.ts +30 -0
- package/dist/trial.d.ts +28 -0
- package/dist/types.d.ts +5 -0
- package/dist/utils.d.ts +21 -0
- package/dist/video_config.d.ts +296 -0
- package/package.json +52 -0
- package/src/consentVideo.spec.ts +274 -0
- package/src/consentVideo.ts +284 -0
- package/src/environment.d.ts +10 -0
- package/src/errors.ts +243 -0
- package/src/img-import.d.ts +1 -0
- package/src/index.spec.ts +104 -0
- package/src/index.ts +13 -0
- package/src/mic_check.d.ts +1 -0
- package/src/mic_check.js +74 -0
- package/src/mic_check.ts +77 -0
- package/src/recorder.spec.ts +446 -0
- package/src/recorder.ts +353 -0
- package/src/start.ts +36 -0
- package/src/stop.ts +46 -0
- package/src/string-import.d.ts +14 -0
- package/src/trial.ts +47 -0
- package/src/types.ts +8 -0
- package/src/utils.spec.ts +55 -0
- package/src/utils.ts +119 -0
- package/src/video_config.spec.ts +1113 -0
- package/src/video_config.ts +665 -0
- package/src/video_config_mic_check.spec.ts +268 -0
package/src/recorder.ts
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import Data from "@lookit/data";
|
|
2
|
+
import LookitS3 from "@lookit/data/dist/lookitS3";
|
|
3
|
+
import autoBind from "auto-bind";
|
|
4
|
+
import Handlebars from "handlebars";
|
|
5
|
+
import { JsPsych } from "jspsych";
|
|
6
|
+
import play_icon from "../img/play-icon.svg";
|
|
7
|
+
import record_icon from "../img/record-icon.svg";
|
|
8
|
+
import playbackFeed from "../templates/playback-feed.hbs";
|
|
9
|
+
import recordFeed from "../templates/record-feed.hbs";
|
|
10
|
+
import webcamFeed from "../templates/webcam-feed.hbs";
|
|
11
|
+
import {
|
|
12
|
+
CreateURLError,
|
|
13
|
+
NoStopPromiseError,
|
|
14
|
+
NoWebCamElementError,
|
|
15
|
+
RecorderInitializeError,
|
|
16
|
+
S3UndefinedError,
|
|
17
|
+
StreamActiveOnResetError,
|
|
18
|
+
StreamDataInitializeError,
|
|
19
|
+
StreamInactiveInitializeError,
|
|
20
|
+
} from "./errors";
|
|
21
|
+
import { CSSWidthHeight } from "./types";
|
|
22
|
+
|
|
23
|
+
/** Recorder handles the state of recording and data storage. */
|
|
24
|
+
export default class Recorder {
|
|
25
|
+
private url?: string;
|
|
26
|
+
private _s3?: LookitS3;
|
|
27
|
+
|
|
28
|
+
private blobs: Blob[] = [];
|
|
29
|
+
private localDownload: boolean =
|
|
30
|
+
process.env.LOCAL_DOWNLOAD?.toLowerCase() === "true";
|
|
31
|
+
private filename?: string;
|
|
32
|
+
private stopPromise?: Promise<void>;
|
|
33
|
+
private webcam_element_id = "lookit-jspsych-webcam";
|
|
34
|
+
|
|
35
|
+
private streamClone: MediaStream;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Recorder for online experiments.
|
|
39
|
+
*
|
|
40
|
+
* @param jsPsych - Object supplied by jsPsych.
|
|
41
|
+
*/
|
|
42
|
+
public constructor(private jsPsych: JsPsych) {
|
|
43
|
+
this.streamClone = this.stream.clone();
|
|
44
|
+
autoBind(this);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get recorder from jsPsych plugin API.
|
|
49
|
+
*
|
|
50
|
+
* If camera recorder hasn't been initialized, then return the microphone
|
|
51
|
+
* recorder.
|
|
52
|
+
*
|
|
53
|
+
* @returns MediaRecorder from the plugin API.
|
|
54
|
+
*/
|
|
55
|
+
private get recorder() {
|
|
56
|
+
return (
|
|
57
|
+
this.jsPsych.pluginAPI.getCameraRecorder() ||
|
|
58
|
+
this.jsPsych.pluginAPI.getMicrophoneRecorder()
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get stream from either recorder.
|
|
64
|
+
*
|
|
65
|
+
* @returns MediaStream from the plugin API.
|
|
66
|
+
*/
|
|
67
|
+
private get stream() {
|
|
68
|
+
return this.recorder.stream;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get s3 class variable. Throw error if doesn't exist.
|
|
73
|
+
*
|
|
74
|
+
* @returns - S3 object.
|
|
75
|
+
*/
|
|
76
|
+
private get s3() {
|
|
77
|
+
if (!this._s3) {
|
|
78
|
+
throw new S3UndefinedError();
|
|
79
|
+
}
|
|
80
|
+
return this._s3;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Set s3 class variable. */
|
|
84
|
+
private set s3(value: LookitS3) {
|
|
85
|
+
this._s3 = value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Initialize recorder using the jsPsych plugin API. There should always be a
|
|
90
|
+
* stream initialized when the Recorder class is instantiated. This method is
|
|
91
|
+
* just used to re-initialize the stream with a clone when the recorder needs
|
|
92
|
+
* to be reset during a trial.
|
|
93
|
+
*
|
|
94
|
+
* @param stream - Media stream returned from getUserMedia that should be used
|
|
95
|
+
* to set up the jsPsych recorder.
|
|
96
|
+
* @param opts - Media recorder options to use when setting up the recorder.
|
|
97
|
+
*/
|
|
98
|
+
public initializeRecorder(stream: MediaStream, opts?: MediaRecorderOptions) {
|
|
99
|
+
this.jsPsych.pluginAPI.initializeCameraRecorder(stream, opts);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Reset the recorder to be used again. */
|
|
103
|
+
public reset() {
|
|
104
|
+
if (this.stream.active) {
|
|
105
|
+
throw new StreamActiveOnResetError();
|
|
106
|
+
}
|
|
107
|
+
this.initializeRecorder(this.streamClone.clone());
|
|
108
|
+
this.blobs = [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Insert a rendered template into an element.
|
|
113
|
+
*
|
|
114
|
+
* @param element - Element to have video inserted into.
|
|
115
|
+
* @param template - Template string
|
|
116
|
+
* @param insertStream - Should the stream be attributed to the webcam
|
|
117
|
+
* element.
|
|
118
|
+
* @returns Webcam element
|
|
119
|
+
*/
|
|
120
|
+
private insertVideoFeed(
|
|
121
|
+
element: HTMLDivElement,
|
|
122
|
+
template: string,
|
|
123
|
+
insertStream: boolean = true,
|
|
124
|
+
) {
|
|
125
|
+
const { webcam_element_id, stream } = this;
|
|
126
|
+
|
|
127
|
+
element.innerHTML = template;
|
|
128
|
+
|
|
129
|
+
const webcam = element.querySelector<HTMLVideoElement>(
|
|
130
|
+
`#${webcam_element_id}`,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
if (!webcam) {
|
|
134
|
+
throw new NoWebCamElementError();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (insertStream) {
|
|
138
|
+
webcam.srcObject = stream;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return webcam;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Insert a video element containing the webcam feed onto the page.
|
|
146
|
+
*
|
|
147
|
+
* @param element - The HTML div element that should serve as the container
|
|
148
|
+
* for the webcam display.
|
|
149
|
+
* @param width - The width of the video element containing the webcam feed,
|
|
150
|
+
* in CSS units (optional). Default is `'100%'`
|
|
151
|
+
* @param height - The height of the video element containing the webcam feed,
|
|
152
|
+
* in CSS units (optional). Default is `'auto'`
|
|
153
|
+
*/
|
|
154
|
+
public insertWebcamFeed(
|
|
155
|
+
element: HTMLDivElement,
|
|
156
|
+
width: CSSWidthHeight = "100%",
|
|
157
|
+
height: CSSWidthHeight = "auto",
|
|
158
|
+
) {
|
|
159
|
+
const { webcam_element_id } = this;
|
|
160
|
+
const view = { height, width, webcam_element_id };
|
|
161
|
+
this.insertVideoFeed(element, Handlebars.compile(webcamFeed)(view));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Insert video playback feed into supplied element.
|
|
166
|
+
*
|
|
167
|
+
* @param element - The HTML div element that should serve as the container
|
|
168
|
+
* for the webcam display.
|
|
169
|
+
* @param on_ended - Callback function called when playing video ends.
|
|
170
|
+
* @param width - The width of the video element containing the webcam feed,
|
|
171
|
+
* in CSS units (optional). Default is `'100%'`
|
|
172
|
+
* @param height - The height of the video element containing the webcam feed,
|
|
173
|
+
* in CSS units (optional). Default is `'auto'`
|
|
174
|
+
*/
|
|
175
|
+
public insertPlaybackFeed(
|
|
176
|
+
element: HTMLDivElement,
|
|
177
|
+
on_ended: (this: HTMLVideoElement, e: Event) => void,
|
|
178
|
+
width: CSSWidthHeight = "100%",
|
|
179
|
+
height: CSSWidthHeight = "auto",
|
|
180
|
+
) {
|
|
181
|
+
const { webcam_element_id } = this;
|
|
182
|
+
const view = {
|
|
183
|
+
src: this.url,
|
|
184
|
+
width,
|
|
185
|
+
height,
|
|
186
|
+
webcam_element_id,
|
|
187
|
+
play_icon,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const playbackElement = this.insertVideoFeed(
|
|
191
|
+
element,
|
|
192
|
+
Handlebars.compile(playbackFeed)(view),
|
|
193
|
+
false,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
playbackElement.addEventListener("ended", on_ended, { once: true });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Insert a feed to be used for recording into an element.
|
|
201
|
+
*
|
|
202
|
+
* @param element - Element to have record feed inserted into.
|
|
203
|
+
* @param width - The width of the video element containing the webcam feed,
|
|
204
|
+
* in CSS units (optional). Default is `'100%'`
|
|
205
|
+
* @param height - The height of the video element containing the webcam feed,
|
|
206
|
+
* in CSS units (optional). Default is `'auto'`
|
|
207
|
+
*/
|
|
208
|
+
public insertRecordFeed(
|
|
209
|
+
element: HTMLDivElement,
|
|
210
|
+
width: CSSWidthHeight = "100%",
|
|
211
|
+
height: CSSWidthHeight = "auto",
|
|
212
|
+
) {
|
|
213
|
+
const { webcam_element_id } = this;
|
|
214
|
+
const view = { height, width, webcam_element_id, record_icon };
|
|
215
|
+
this.insertVideoFeed(element, Handlebars.compile(recordFeed)(view));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Start recording. Also, adds event listeners for handling data and checks
|
|
220
|
+
* for recorder initialization.
|
|
221
|
+
*
|
|
222
|
+
* @param prefix - Prefix for the video recording file name (string). This is
|
|
223
|
+
* the string that comes before "_<TIMESTAMP>.webm".
|
|
224
|
+
*/
|
|
225
|
+
public async start(prefix: "consent" | "session_video" | "trial_video") {
|
|
226
|
+
this.initializeCheck();
|
|
227
|
+
|
|
228
|
+
// Set filename
|
|
229
|
+
this.filename = `${prefix}_${new Date().getTime()}.webm`;
|
|
230
|
+
|
|
231
|
+
// Instantiate s3 object
|
|
232
|
+
if (!this.localDownload) {
|
|
233
|
+
this.s3 = new Data.LookitS3(this.filename);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
this.recorder.addEventListener("dataavailable", this.handleDataAvailable);
|
|
237
|
+
|
|
238
|
+
// create a stop promise and pass the resolve function as an argument to the stop event callback,
|
|
239
|
+
// so that the stop event handler can resolve the stop promise
|
|
240
|
+
this.stopPromise = new Promise((resolve) => {
|
|
241
|
+
this.recorder.addEventListener("stop", this.handleStop(resolve));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (!this.localDownload) {
|
|
245
|
+
await this.s3.createUpload();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
this.recorder.start();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Stop all streams/tracks. This stops any in-progress recordings and releases
|
|
253
|
+
* the media devices. This is can be called when recording is not in progress,
|
|
254
|
+
* e.g. To end the camera/mic access when the experiment is displaying the
|
|
255
|
+
* camera feed but not recording (e.g. Video-config).
|
|
256
|
+
*/
|
|
257
|
+
public stopTracks() {
|
|
258
|
+
this.recorder.stop();
|
|
259
|
+
this.stream.getTracks().map((t) => t.stop());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Stop recording and camera/microphone. This will stop accessing all media
|
|
264
|
+
* tracks, clear the webcam feed element (if there is one), and return the
|
|
265
|
+
* stop promise. This should only be called after recording has started.
|
|
266
|
+
*
|
|
267
|
+
* @returns Promise that resolves after the media recorder has stopped and
|
|
268
|
+
* final 'dataavailable' event has occurred, when the "stop" event-related
|
|
269
|
+
* callback function is called.
|
|
270
|
+
*/
|
|
271
|
+
public stop() {
|
|
272
|
+
this.stopTracks();
|
|
273
|
+
this.clearWebcamFeed();
|
|
274
|
+
|
|
275
|
+
if (!this.stopPromise) {
|
|
276
|
+
throw new NoStopPromiseError();
|
|
277
|
+
}
|
|
278
|
+
return this.stopPromise;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Throw Error if there isn't a recorder provided by jsPsych. */
|
|
282
|
+
private initializeCheck() {
|
|
283
|
+
if (!this.recorder) {
|
|
284
|
+
throw new RecorderInitializeError();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!this.stream.active) {
|
|
288
|
+
throw new StreamInactiveInitializeError();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (this.blobs.length !== 0) {
|
|
292
|
+
throw new StreamDataInitializeError();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Handle the recorder's stop event. This is a function that takes the stop
|
|
298
|
+
* promise's 'resolve' as an argument and returns a function that resolves
|
|
299
|
+
* that stop promise. The function that is returned is used as the recorder's
|
|
300
|
+
* "stop" event-related callback function.
|
|
301
|
+
*
|
|
302
|
+
* @param resolve - Promise resolve function.
|
|
303
|
+
* @returns Function that is called on the recorder's "stop" event.
|
|
304
|
+
*/
|
|
305
|
+
private handleStop(resolve: () => void) {
|
|
306
|
+
return async () => {
|
|
307
|
+
if (this.blobs.length === 0) {
|
|
308
|
+
throw new CreateURLError();
|
|
309
|
+
}
|
|
310
|
+
this.url = URL.createObjectURL(new Blob(this.blobs));
|
|
311
|
+
|
|
312
|
+
if (this.localDownload) {
|
|
313
|
+
this.download();
|
|
314
|
+
} else {
|
|
315
|
+
await this.s3.completeUpload();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
resolve();
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Function ran at each time slice and when the recorder stopped.
|
|
324
|
+
*
|
|
325
|
+
* @param event - Event containing blob data.
|
|
326
|
+
*/
|
|
327
|
+
private handleDataAvailable(event: BlobEvent) {
|
|
328
|
+
this.blobs.push(event.data);
|
|
329
|
+
if (!this.localDownload) {
|
|
330
|
+
this.s3.onDataAvailable(event.data);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Download data url used in local development. */
|
|
335
|
+
private download() {
|
|
336
|
+
if (this.filename && this.url) {
|
|
337
|
+
const link = document.createElement("a");
|
|
338
|
+
link.href = this.url;
|
|
339
|
+
link.download = this.filename;
|
|
340
|
+
link.click();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Private helper to clear the webcam feed, if there is one. */
|
|
345
|
+
private clearWebcamFeed() {
|
|
346
|
+
const webcam_feed_element = document.querySelector(
|
|
347
|
+
`#${this.webcam_element_id}`,
|
|
348
|
+
) as HTMLVideoElement;
|
|
349
|
+
if (webcam_feed_element) {
|
|
350
|
+
webcam_feed_element.remove();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
package/src/start.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { LookitWindow } from "@lookit/data/dist/types";
|
|
2
|
+
import { JsPsych, JsPsychPlugin } from "jspsych";
|
|
3
|
+
import { ExistingRecordingError } from "./errors";
|
|
4
|
+
import Recorder from "./recorder";
|
|
5
|
+
|
|
6
|
+
declare let window: LookitWindow;
|
|
7
|
+
|
|
8
|
+
const info = <const>{ name: "start-record-plugin", parameters: {} };
|
|
9
|
+
type Info = typeof info;
|
|
10
|
+
|
|
11
|
+
/** Start recording. Used by researchers who want to record across trials. */
|
|
12
|
+
export default class StartRecordPlugin implements JsPsychPlugin<Info> {
|
|
13
|
+
public static readonly info = info;
|
|
14
|
+
private recorder: Recorder;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Plugin used to start recording.
|
|
18
|
+
*
|
|
19
|
+
* @param jsPsych - Object provided by jsPsych.
|
|
20
|
+
*/
|
|
21
|
+
public constructor(private jsPsych: JsPsych) {
|
|
22
|
+
this.recorder = new Recorder(this.jsPsych);
|
|
23
|
+
if (!window.chs.sessionRecorder) {
|
|
24
|
+
window.chs.sessionRecorder = this.recorder;
|
|
25
|
+
} else {
|
|
26
|
+
throw new ExistingRecordingError();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Trial function called by jsPsych. */
|
|
31
|
+
public trial() {
|
|
32
|
+
this.recorder.start("session_video").then(() => {
|
|
33
|
+
this.jsPsych.finishTrial();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/stop.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { LookitWindow } from "@lookit/data/dist/types";
|
|
2
|
+
import Handlebars from "handlebars";
|
|
3
|
+
import { JsPsych, JsPsychPlugin } from "jspsych";
|
|
4
|
+
import uploadingVideo from "../templates/uploading-video.hbs";
|
|
5
|
+
import { NoSessionRecordingError } from "./errors";
|
|
6
|
+
import Recorder from "./recorder";
|
|
7
|
+
|
|
8
|
+
declare let window: LookitWindow;
|
|
9
|
+
|
|
10
|
+
const info = <const>{ name: "stop-record-plugin", parameters: {} };
|
|
11
|
+
type Info = typeof info;
|
|
12
|
+
|
|
13
|
+
/** Stop recording. Used by researchers who want to record across trials. */
|
|
14
|
+
export default class StopRecordPlugin implements JsPsychPlugin<Info> {
|
|
15
|
+
public static readonly info = info;
|
|
16
|
+
private recorder: Recorder;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Plugin used to stop recording.
|
|
20
|
+
*
|
|
21
|
+
* @param jsPsych - Object provided by jsPsych.
|
|
22
|
+
*/
|
|
23
|
+
public constructor(private jsPsych: JsPsych) {
|
|
24
|
+
if (window.chs.sessionRecorder) {
|
|
25
|
+
this.recorder = window.chs.sessionRecorder as Recorder;
|
|
26
|
+
} else {
|
|
27
|
+
throw new NoSessionRecordingError();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Trial function called by jsPsych.
|
|
33
|
+
*
|
|
34
|
+
* @param display_element - DOM element where jsPsych content is being
|
|
35
|
+
* rendered (set in initJsPsych and automatically made available to a
|
|
36
|
+
* plugin's trial method via jsPsych core).
|
|
37
|
+
*/
|
|
38
|
+
public trial(display_element: HTMLElement): void {
|
|
39
|
+
display_element.innerHTML = Handlebars.compile(uploadingVideo)({});
|
|
40
|
+
this.recorder.stop().then(() => {
|
|
41
|
+
window.chs.sessionRecorder = null;
|
|
42
|
+
display_element.innerHTML = "";
|
|
43
|
+
this.jsPsych.finishTrial();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/trial.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import autoBind from "auto-bind";
|
|
2
|
+
import { JsPsych, JsPsychExtension, JsPsychExtensionInfo } from "jspsych";
|
|
3
|
+
import Recorder from "./recorder";
|
|
4
|
+
|
|
5
|
+
/** This extension will allow reasearchers to record trials. */
|
|
6
|
+
export default class TrialRecordExtension implements JsPsychExtension {
|
|
7
|
+
public static readonly info: JsPsychExtensionInfo = {
|
|
8
|
+
name: "chs-trial-record-extension",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
private recorder?: Recorder;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Video recording extension.
|
|
15
|
+
*
|
|
16
|
+
* @param jsPsych - JsPsych object passed into extensions.
|
|
17
|
+
*/
|
|
18
|
+
public constructor(private jsPsych: JsPsych) {
|
|
19
|
+
autoBind(this);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Ran on the initialize step for extensions, called when an instance of
|
|
24
|
+
* jsPsych is first initialized through initJsPsych().
|
|
25
|
+
*/
|
|
26
|
+
public async initialize() {}
|
|
27
|
+
|
|
28
|
+
/** Ran at the start of a trial. */
|
|
29
|
+
public on_start() {
|
|
30
|
+
this.recorder = new Recorder(this.jsPsych);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Ran when the trial has loaded. */
|
|
34
|
+
public on_load() {
|
|
35
|
+
this.recorder?.start("trial_video");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ran when trial has finished.
|
|
40
|
+
*
|
|
41
|
+
* @returns Trial data.
|
|
42
|
+
*/
|
|
43
|
+
public on_finish() {
|
|
44
|
+
this.recorder?.stop();
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import Yaml from "js-yaml";
|
|
2
|
+
import en_us from "../i18n/en-us.yaml";
|
|
3
|
+
import eu from "../i18n/eu.yaml";
|
|
4
|
+
import fr from "../i18n/fr.yaml";
|
|
5
|
+
import hu from "../i18n/hu.yaml";
|
|
6
|
+
import it from "../i18n/it.yaml";
|
|
7
|
+
import ja from "../i18n/ja.yaml";
|
|
8
|
+
import nl from "../i18n/nl.yaml";
|
|
9
|
+
import pt_br from "../i18n/pt-br.yaml";
|
|
10
|
+
import pt from "../i18n/pt.yaml";
|
|
11
|
+
import { TranslationNotFoundError } from "./errors";
|
|
12
|
+
import { expFormat, getTranslation } from "./utils";
|
|
13
|
+
|
|
14
|
+
test("expFormat convert written text to format well in HTML", () => {
|
|
15
|
+
expect(expFormat("abcdefg")).toStrictEqual("abcdefg");
|
|
16
|
+
expect(expFormat("AAABBBCCC")).toStrictEqual("AAABBBCCC");
|
|
17
|
+
expect(expFormat("A normal sentence with multiple words.")).toStrictEqual(
|
|
18
|
+
"A normal sentence with multiple words.",
|
|
19
|
+
);
|
|
20
|
+
expect(expFormat(["Array", "of", "strings"])).toStrictEqual(
|
|
21
|
+
"Array<br><br>of<br><br>strings",
|
|
22
|
+
);
|
|
23
|
+
expect(expFormat("carriage return an newline\r\n")).toStrictEqual(
|
|
24
|
+
"carriage return an newline<br>",
|
|
25
|
+
);
|
|
26
|
+
expect(expFormat("new line\n")).toStrictEqual("new line<br>");
|
|
27
|
+
expect(expFormat("carriage return\r")).toStrictEqual("carriage return<br>");
|
|
28
|
+
expect(expFormat("\tTabbed text")).toStrictEqual(
|
|
29
|
+
" Tabbed text",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("Get translation file for specified locale", () => {
|
|
34
|
+
const translations = {
|
|
35
|
+
ja,
|
|
36
|
+
pt,
|
|
37
|
+
eu,
|
|
38
|
+
fr,
|
|
39
|
+
hu,
|
|
40
|
+
it,
|
|
41
|
+
nl,
|
|
42
|
+
"en-us": en_us,
|
|
43
|
+
"pt-br": pt_br,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
for (const [k, v] of Object.entries<string>(translations)) {
|
|
47
|
+
expect(getTranslation(new Intl.Locale(k))).toStrictEqual(Yaml.load(v));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
expect(pt_br).not.toStrictEqual(pt);
|
|
51
|
+
|
|
52
|
+
expect(() => getTranslation(new Intl.Locale("not-a2code"))).toThrow(
|
|
53
|
+
TranslationNotFoundError,
|
|
54
|
+
);
|
|
55
|
+
});
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import Handlebars from "handlebars";
|
|
2
|
+
import i18next from "i18next";
|
|
3
|
+
import ICU from "i18next-icu";
|
|
4
|
+
import Yaml from "js-yaml";
|
|
5
|
+
import { PluginInfo, TrialType } from "jspsych";
|
|
6
|
+
import en_us from "../i18n/en-us.yaml";
|
|
7
|
+
import eu from "../i18n/eu.yaml";
|
|
8
|
+
import fr from "../i18n/fr.yaml";
|
|
9
|
+
import hu from "../i18n/hu.yaml";
|
|
10
|
+
import it from "../i18n/it.yaml";
|
|
11
|
+
import ja from "../i18n/ja.yaml";
|
|
12
|
+
import nl from "../i18n/nl.yaml";
|
|
13
|
+
import pt_br from "../i18n/pt-br.yaml";
|
|
14
|
+
import pt from "../i18n/pt.yaml";
|
|
15
|
+
import { TranslationNotFoundError } from "./errors";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Pulled from EFP. Function to convert researcher's text to HTML.
|
|
19
|
+
*
|
|
20
|
+
* @param text - Text
|
|
21
|
+
* @returns Formatted string
|
|
22
|
+
*/
|
|
23
|
+
export const expFormat = (text?: string | string[]) => {
|
|
24
|
+
if (!text) {
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (Array.isArray(text)) {
|
|
29
|
+
text = text.join("\n\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return text
|
|
33
|
+
.replace(/(\r\n|\n|\r)/gm, "<br>")
|
|
34
|
+
.replace(/\t/gm, " ");
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get a translation file based on selected language.
|
|
39
|
+
*
|
|
40
|
+
* @param lcl - Locale object with locale
|
|
41
|
+
* @returns Translations from i18next
|
|
42
|
+
*/
|
|
43
|
+
export const getTranslation = (lcl: Intl.Locale) => {
|
|
44
|
+
/**
|
|
45
|
+
* Switch case to find language from a string. Will throw error is language
|
|
46
|
+
* not found.
|
|
47
|
+
*
|
|
48
|
+
* @param baseName - Base name from locale (en-us)
|
|
49
|
+
* @returns Language yaml file
|
|
50
|
+
*/
|
|
51
|
+
const getYaml = (baseName: string) => {
|
|
52
|
+
switch (baseName) {
|
|
53
|
+
case "en-US":
|
|
54
|
+
return en_us;
|
|
55
|
+
case "eu":
|
|
56
|
+
return eu;
|
|
57
|
+
case "fr":
|
|
58
|
+
return fr;
|
|
59
|
+
case "hu":
|
|
60
|
+
return hu;
|
|
61
|
+
case "it":
|
|
62
|
+
return it;
|
|
63
|
+
case "ja":
|
|
64
|
+
return ja;
|
|
65
|
+
case "nl":
|
|
66
|
+
return nl;
|
|
67
|
+
case "pt-BR":
|
|
68
|
+
return pt_br;
|
|
69
|
+
case "pt":
|
|
70
|
+
return pt;
|
|
71
|
+
default:
|
|
72
|
+
throw new TranslationNotFoundError(baseName);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return Yaml.load(getYaml(lcl.baseName)) as Record<string, string>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Initialize i18next with parameters from trial.
|
|
81
|
+
*
|
|
82
|
+
* @param trial - Trial data including user supplied parameters.
|
|
83
|
+
*/
|
|
84
|
+
const initI18next = (trial: TrialType<PluginInfo>) => {
|
|
85
|
+
const debug = process.env.DEBUG === "true";
|
|
86
|
+
const lcl = new Intl.Locale(trial.locale);
|
|
87
|
+
const translation = getTranslation(lcl);
|
|
88
|
+
|
|
89
|
+
i18next.use(ICU).init({
|
|
90
|
+
lng: lcl.baseName,
|
|
91
|
+
debug,
|
|
92
|
+
resources: {
|
|
93
|
+
[lcl.language]: {
|
|
94
|
+
translation,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Initialize handlebars helpers. This could be done globally, but it does go
|
|
102
|
+
* hand in hand with initializing i18n.
|
|
103
|
+
*/
|
|
104
|
+
const initHandlebars = () => {
|
|
105
|
+
Handlebars.registerHelper("t", (context, { hash }) =>
|
|
106
|
+
i18next.t(context, hash),
|
|
107
|
+
);
|
|
108
|
+
Handlebars.registerHelper("exp-format", (context) => expFormat(context));
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Initialize both i18next and Handlebars.
|
|
113
|
+
*
|
|
114
|
+
* @param trial - Yup
|
|
115
|
+
*/
|
|
116
|
+
export const initI18nAndTemplates = (trial: TrialType<PluginInfo>) => {
|
|
117
|
+
initI18next(trial);
|
|
118
|
+
initHandlebars();
|
|
119
|
+
};
|