From 8eb4c39cd210710d4c61cf94d2dcc2998c02144d Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Mon, 16 Sep 2024 13:14:04 -0700 Subject: [PATCH] Add recorder features: webcam display, request/enumerate devices, destroy (#40) Adds webcam display, request/enumerate devices, and destroy methods to recorder in record package. --------- Co-authored-by: CJ Green <44074998+okaycj@users.noreply.github.com> --- eslint.config.mjs | 1 - jest.cjs | 7 +- jest.text.loader.js | 7 + package-lock.json | 296 +++++++++++++- package.json | 4 +- packages/data/src/errors.ts | 14 + packages/data/src/lookitS3.spec.ts | 12 + packages/data/src/lookitS3.ts | 37 +- packages/data/src/lookitS3config.spec.ts | 14 + packages/record/README.md | 34 +- packages/record/package.json | 8 +- packages/record/rollup.config.mjs | 7 +- packages/record/src/index.ts | 6 +- packages/record/src/recorder.spec.ts | 368 ++++++++++++++++++ packages/record/src/recorder.ts | 194 ++++++++- packages/record/src/stop.ts | 4 +- packages/record/src/string-import.d.ts | 4 + .../src/templates/uploading-video.mustache | 1 + .../record/src/templates/webcam-feed.mustache | 9 + packages/record/src/types.ts | 8 + packages/record/tsconfig.json | 1 + 21 files changed, 971 insertions(+), 65 deletions(-) create mode 100644 jest.text.loader.js create mode 100644 packages/data/src/lookitS3config.spec.ts create mode 100644 packages/record/src/string-import.d.ts create mode 100644 packages/record/src/templates/uploading-video.mustache create mode 100644 packages/record/src/templates/webcam-feed.mustache create mode 100644 packages/record/src/types.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index dcaca94b..8f4acce5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -23,7 +23,6 @@ export default [ }, rules: { "jsdoc/tag-lines": "off", - "jsdoc/require-description-complete-sentence": "error", "jsdoc/require-description": "error", "jsdoc/require-hyphen-before-param-description": "error", "jsdoc/require-jsdoc": [ diff --git a/jest.cjs b/jest.cjs index c68d6fc9..e279d258 100644 --- a/jest.cjs +++ b/jest.cjs @@ -1,6 +1,11 @@ module.exports.makePackageConfig = () => { + const config = require("@jspsych/config/jest").makePackageConfig(__dirname); return { - ...require("@jspsych/config/jest").makePackageConfig(__dirname), + ...config, + transform: { + ...config.transform, + "^.+\\.mustache$": "/../../jest.text.loader.js", + }, moduleNameMapper: { "@lookit/data": "/../../packages/data/src" }, coverageThreshold: { global: { diff --git a/jest.text.loader.js b/jest.text.loader.js new file mode 100644 index 00000000..bd3e74f6 --- /dev/null +++ b/jest.text.loader.js @@ -0,0 +1,7 @@ +module.exports = { + process(sourceText, sourcePath, options) { + return { + code: `module.exports = ${JSON.stringify(sourceText)};`, + }; + }, +}; diff --git a/package-lock.json b/package-lock.json index 681bf76f..740e6963 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4564,12 +4564,28 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.3.tgz", + "integrity": "sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.2.tgz", - "integrity": "sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.3.tgz", + "integrity": "sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==", "cpu": [ "arm64" ], @@ -4593,10 +4609,55 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.3.tgz", + "integrity": "sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.3.tgz", + "integrity": "sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.3.tgz", + "integrity": "sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.2.tgz", - "integrity": "sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.3.tgz", + "integrity": "sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==", "cpu": [ "x64" ], @@ -5449,6 +5510,13 @@ "node": ">= 10" } }, + "node_modules/@types/audioworklet": { + "version": "0.0.58", + "resolved": "https://registry.npmjs.org/@types/audioworklet/-/audioworklet-0.0.58.tgz", + "integrity": "sha512-uHlows3ykQFfxDdMEcLChlCtVI63OvKCKNViOc7pOeyS8JqqjuzAPcp4Yo2QopnEH4Rh54vLauQZKJRgnrBG/A==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -5637,6 +5705,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mustache": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.5.tgz", + "integrity": "sha512-PLwiVvTBg59tGFL/8VpcGvqOu3L4OuveNvPi0EYbWchRdEVP++yRUXJPFl+CApKEq13017/4Nf7aQ5lTtHUNsA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "12.20.55", "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", @@ -14832,6 +14907,15 @@ "dev": true, "license": "MIT" }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/mute-stdout": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mute-stdout/-/mute-stdout-1.0.1.tgz", @@ -16608,20 +16692,39 @@ } }, "node_modules/rollup": { - "version": "3.29.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", - "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.3.tgz", + "integrity": "sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==", "dev": true, "license": "MIT", "peer": true, + "dependencies": { + "@types/estree": "1.0.5" + }, "bin": { "rollup": "dist/bin/rollup" }, "engines": { - "node": ">=14.18.0", + "node": ">=18.0.0", "npm": ">=8.0.0" }, "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.21.3", + "@rollup/rollup-android-arm64": "4.21.3", + "@rollup/rollup-darwin-arm64": "4.21.3", + "@rollup/rollup-darwin-x64": "4.21.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.21.3", + "@rollup/rollup-linux-arm-musleabihf": "4.21.3", + "@rollup/rollup-linux-arm64-gnu": "4.21.3", + "@rollup/rollup-linux-arm64-musl": "4.21.3", + "@rollup/rollup-linux-powerpc64le-gnu": "4.21.3", + "@rollup/rollup-linux-riscv64-gnu": "4.21.3", + "@rollup/rollup-linux-s390x-gnu": "4.21.3", + "@rollup/rollup-linux-x64-gnu": "4.21.3", + "@rollup/rollup-linux-x64-musl": "4.21.3", + "@rollup/rollup-win32-arm64-msvc": "4.21.3", + "@rollup/rollup-win32-ia32-msvc": "4.21.3", + "@rollup/rollup-win32-x64-msvc": "4.21.3", "fsevents": "~2.3.2" } }, @@ -16734,6 +16837,19 @@ "opener": "1" } }, + "node_modules/rollup-plugin-string-import": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rollup-plugin-string-import/-/rollup-plugin-string-import-1.2.4.tgz", + "integrity": "sha512-EOeLRDqxo9Mt/TAfSRVU586wId/HY2QkbxN3Qg6iyKIqfFTXi64gUejivy/7kf24WjvxEtIIQoIrnOcqXrDeGA==", + "dev": true, + "license": "GPL-3.0-or-later", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "peerDependencies": { + "rollup": "4.20.0" + } + }, "node_modules/rollup-plugin-typescript2": { "version": "0.36.0", "resolved": "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.36.0.tgz", @@ -16865,6 +16981,156 @@ "dev": true, "license": "MIT" }, + "node_modules/rollup/node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.3.tgz", + "integrity": "sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true + }, + "node_modules/rollup/node_modules/@rollup/rollup-android-arm64": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.3.tgz", + "integrity": "sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true + }, + "node_modules/rollup/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.3.tgz", + "integrity": "sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/rollup/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.3.tgz", + "integrity": "sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.3.tgz", + "integrity": "sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.3.tgz", + "integrity": "sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.3.tgz", + "integrity": "sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.3.tgz", + "integrity": "sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.3.tgz", + "integrity": "sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.3.tgz", + "integrity": "sha512-fOvu7PCQjAj4eWDEuD8Xz5gpzFqXzGlxHZozHP4b9Jxv9APtdxL6STqztDzMLuRXEc4UpXGGhx029Xgm91QBeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -19826,11 +20092,15 @@ "license": "ISC", "dependencies": { "@lookit/data": "^0.0.1", - "auto-bind": "^5.0.1" + "auto-bind": "^5.0.1", + "mustache": "^4.2.0" }, "devDependencies": { "@jspsych/config": "^2.0.0", - "rollup-plugin-dotenv": "^0.5.1" + "@types/audioworklet": "^0.0.58", + "@types/mustache": "^4.2.5", + "rollup-plugin-dotenv": "^0.5.1", + "rollup-plugin-string-import": "^1.2.4" }, "peerDependencies": { "jspsych": "^8.0.2" diff --git a/package.json b/package.json index b5e116ef..5b95382d 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,9 @@ "build": "npm run build --workspaces", "changeset": "changeset", "clean": "trash \"./packages/**/dist\" \"./packages/**/coverage\" \"./packages/**/node_modules\" \"./node_modules\"", - "fix": "prettier './' -lw && npm run lint", + "fix": "prettier './' -lw && eslint './packages/**/src/**/*.ts' --fix", "format": "prettier './' -c", - "lint": "eslint './packages/**/src/**/*.ts' --fix", + "lint": "eslint './packages/**/src/**/*.ts'", "test": "npm test --workspaces" }, "devDependencies": { diff --git a/packages/data/src/errors.ts b/packages/data/src/errors.ts index ade94873..9ba761df 100644 --- a/packages/data/src/errors.ts +++ b/packages/data/src/errors.ts @@ -23,3 +23,17 @@ export class UploadPartError extends Error { this.name = "UploadPartError"; } } + +/** Error for when the AWS config fails */ +export class AWSConfigError extends Error { + /** + * AWS cofiguration error. This could be due to incorrect credentials, bucket + * name, and/or region. + * + * @param errorMsg - Message property of error object from the AWS response. + */ + public constructor(errorMsg: string) { + super(`AWS configuration error: ${errorMsg}`); + this.name = "AWSConfigError"; + } +} diff --git a/packages/data/src/lookitS3.spec.ts b/packages/data/src/lookitS3.spec.ts index bd31083b..6287ec4d 100644 --- a/packages/data/src/lookitS3.spec.ts +++ b/packages/data/src/lookitS3.spec.ts @@ -90,3 +90,15 @@ test("Upload to S3 missing Etag", async () => { expect(CreateMultipartUploadCommand).toHaveBeenCalledTimes(1); expect(CompleteMultipartUploadCommand).toHaveBeenCalledTimes(0); }); + +test("Upload in progress", async () => { + mockSendRtn = { UploadId: "upload id", ETag: "etag" }; + const s3 = new LookitS3("key value"); + + expect(s3.uploadInProgress).toBe(false); + await s3.createUpload(); + expect(s3.uploadInProgress).toBe(true); + s3.onDataAvailable(largeBlob); + await s3.completeUpload(); + expect(s3.uploadInProgress).toBe(false); +}); diff --git a/packages/data/src/lookitS3.ts b/packages/data/src/lookitS3.ts index f3efff81..a1a0bcf3 100644 --- a/packages/data/src/lookitS3.ts +++ b/packages/data/src/lookitS3.ts @@ -4,7 +4,7 @@ import { S3Client, UploadPartCommand, } from "@aws-sdk/client-s3"; -import { AWSMissingAttrError, UploadPartError } from "./errors"; +import { AWSConfigError, AWSMissingAttrError, UploadPartError } from "./errors"; /** Provides functionality to upload videos incrementally to an AWS S3 Bucket. */ class LookitS3 { @@ -16,6 +16,7 @@ class LookitS3 { private uploadId: string = ""; private key: string; private bucket: string = process.env.S3_BUCKET; + private complete: boolean = false; public static readonly minUploadSize: number = 5 * 1024 * 1024; @@ -27,13 +28,22 @@ class LookitS3 { */ public constructor(key: string) { this.key = key; - this.s3 = new S3Client({ - region: process.env.S3_REGION, - credentials: { - accessKeyId: process.env.S3_ACCESS_KEY_ID, - secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, - }, - }); + try { + this.s3 = new S3Client({ + region: process.env.S3_REGION, + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY_ID, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, + }, + }); + } catch (e) { + console.error(`Error setting up S3 client: ${e}`); + let err_msg = ""; + if (e instanceof Error) { + err_msg = e.message; + } + throw new AWSConfigError(err_msg); + } } /** @@ -148,6 +158,7 @@ class LookitS3 { }; const command = new CompleteMultipartUploadCommand(input); const response = await this.s3.send(command); + this.complete = true; this.logRecordingEvent(`Upload complete: ${response.Location}`); } @@ -176,6 +187,16 @@ class LookitS3 { const timestamp = new Date().toISOString(); console.log(`Recording log: ${timestamp}\nFile: ${this.key}\n${msg}\n`); } + + /** + * Whether or not an upload is in progress (created and not yet completed). + * + * @returns Boolean indicating whether or not an upload has been created but + * not yet completed. + */ + public get uploadInProgress(): boolean { + return this.uploadId !== "" && !this.complete; + } } export default LookitS3; diff --git a/packages/data/src/lookitS3config.spec.ts b/packages/data/src/lookitS3config.spec.ts new file mode 100644 index 00000000..eb9eedbd --- /dev/null +++ b/packages/data/src/lookitS3config.spec.ts @@ -0,0 +1,14 @@ +import LookitS3 from "./lookitS3"; + +// This is in a separate file because imports can only be mocked once per file, at the top level (not inside test functions), and the config failure test requires a different mock than the rest of the lookitS3 tests. +jest.mock("@aws-sdk/client-s3", () => ({ + S3Client: jest.fn().mockImplementation(() => { + throw new Error("Error"); + }), +})); + +test.only("Lookit S3 constructor throws error when S3 Client initialization fails", () => { + expect(() => { + new LookitS3("key value"); + }).toThrow(); +}); diff --git a/packages/record/README.md b/packages/record/README.md index 2ed914be..9560b89f 100644 --- a/packages/record/README.md +++ b/packages/record/README.md @@ -2,32 +2,16 @@ This package contains the plugins and extensions to record audio and/or video of either a single trial or multiple trials. -## Initialize Camera +## Video Configuration -To record video, you will have to add a trial that allows the user to give permissions and select the correct camera. +To record any video during an experiment, including a consent video, you will have to add a video configuration trial that allows the user to give permissions and select the correct camera and microphone. This trial also does some basic checks on the webcam and mic inputs, so that the participant can fix common problems before the experiment starts. -```javascript -const initCamera = { type: jsPsychInitializeCamera }; -``` - -To enable audio you will have to set the `include_audio` parameter. - -```javascript -const initCamera = { type: jsPsychInitializeCamera, include_audio: true }; -``` - -See [jsPsych's initialize-camera]({{ jsPsych }}plugins/initialize-camera/#initialize-camera) docs for more information. - -## Intialize Microphone - -To record audio, just as with video, you will have to add a trial. +Create a video configuration trial and put it in your experiment timeline prior to any other trials that use the participant's webcam/microphone. The trial type is `chsRecord.VideoConfigPlugin`. ```javascript -const initMicrophone = { type: jsPsychInitializeMicrophone }; +const videoConfig = { type: chsRecord.VideoConfigPlugin }; ``` -See [jsPsych's initialize-microphone]({{ jsPsych }}plugins/initialize-microphone/#initialize-microphone) docs for more information. - ## Trial Recording To record a single trial, you will have to first load the extension in `initJsPsych`. @@ -38,7 +22,7 @@ const jsPsych = initJsPsych({ }); ``` -Next, initialize the camera/microphone as described above. For now, we'll use the camera initialization. Add trial recording to the extensions parameter of the trial that needs to be recorded. Any trial you design can be recorded by add this extension. +Next, create a video configuration trial as described above. Add trial recording to the extensions parameter of the trial that needs to be recorded. Any trial you design can be recorded by add this extension. ```javascript const trialRec = { @@ -50,7 +34,7 @@ const trialRec = { Finally, insert the trials into the timeline. ```javascript -jsPsych.run([initCamera, trialRec]); +jsPsych.run([videoConfig, trialRec]); ``` ## Session Recording @@ -72,14 +56,14 @@ const evening = {type: jsPsychHtmlKeyboardResponse stimulus: "Good evening!"}; const night = { type: jsPsychHtmlKeyboardResponse, stimulus: "Good night!" }; ``` -Lastly, add these trials to the timeline. +Lastly, add these trials to the timeline. Again, the video configuration trial must come before any other recording trials. ```javascript -jsPsych.run([initCamera, startRec, morning, evening, night, stopRec]); +jsPsych.run([videoConfig, startRec, morning, evening, night, stopRec]); ``` It's possible to record only some of the trials. This can be done by moving the stop or start recording trials within the timeline. ```javascript -jsPsych.run([initCamera, startRec, morning, evening, stopRec, night]); +jsPsych.run([videoConfig, startRec, morning, evening, stopRec, night]); ``` diff --git a/packages/record/package.json b/packages/record/package.json index ffa74235..221703a9 100644 --- a/packages/record/package.json +++ b/packages/record/package.json @@ -26,11 +26,15 @@ }, "dependencies": { "@lookit/data": "^0.0.1", - "auto-bind": "^5.0.1" + "auto-bind": "^5.0.1", + "mustache": "^4.2.0" }, "devDependencies": { "@jspsych/config": "^2.0.0", - "rollup-plugin-dotenv": "^0.5.1" + "@types/audioworklet": "^0.0.58", + "@types/mustache": "^4.2.5", + "rollup-plugin-dotenv": "^0.5.1", + "rollup-plugin-string-import": "^1.2.4" }, "peerDependencies": { "jspsych": "^8.0.2" diff --git a/packages/record/rollup.config.mjs b/packages/record/rollup.config.mjs index abfc4654..b0fffdab 100644 --- a/packages/record/rollup.config.mjs +++ b/packages/record/rollup.config.mjs @@ -1,13 +1,18 @@ import dotenv from "rollup-plugin-dotenv"; +import { importAsString } from "rollup-plugin-string-import"; import { makeRollupConfig } from "../../rollup.mjs"; export default makeRollupConfig("chsRecord").map((config) => { return { ...config, plugins: [ + ...config.plugins, // Add support for .env files dotenv(), - ...config.plugins, + // Add support to import mustache template files as strings + importAsString({ + include: ["**/*.mustache"], + }), ], }; }); diff --git a/packages/record/src/index.ts b/packages/record/src/index.ts index 711b9994..23a2c1c0 100644 --- a/packages/record/src/index.ts +++ b/packages/record/src/index.ts @@ -2,4 +2,8 @@ import StartRecordPlugin from "./start"; import StopRecordPlugin from "./stop"; import TrialRecordExtension from "./trial"; -export default { TrialRecordExtension, StartRecordPlugin, StopRecordPlugin }; +export default { + TrialRecordExtension, + StartRecordPlugin, + StopRecordPlugin, +}; diff --git a/packages/record/src/recorder.spec.ts b/packages/record/src/recorder.spec.ts index 4292facf..64228166 100644 --- a/packages/record/src/recorder.spec.ts +++ b/packages/record/src/recorder.spec.ts @@ -1,7 +1,10 @@ import Data from "@lookit/data"; import { initJsPsych } from "jspsych"; +import Mustache from "mustache"; import { NoStopPromiseError, RecorderInitializeError } from "./error"; import Recorder from "./recorder"; +import webcamFeed from "./templates/webcam-feed.mustache"; +import { CSSWidthHeight } from "./types"; jest.mock("@lookit/data"); @@ -119,6 +122,371 @@ test("Recorder handleDataAvailable", () => { expect(Data.LookitS3.prototype.onDataAvailable).toHaveBeenCalledTimes(1); }); +test("Recorder insert webcam display without height/width", () => { + // Add webcam container to document body. + const webcam_container_id = "webcam-container"; + document.body.innerHTML = `
`; + const webcam_div = document.getElementById( + webcam_container_id, + ) as HTMLDivElement; + + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych, "prefix"); + + const media = { + stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, + }; + jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); + + // Should add the video element with webcam stream to the webcam container. + rec.insertWebcamFeed(webcam_div); + + // Use the HTML template and settings to figure out what HTML should have been added. + const height: CSSWidthHeight = "auto"; + const width: CSSWidthHeight = "100%"; + const webcam_element_id: string = "lookit-jspsych-webcam"; + const params = { height, width, webcam_element_id }; + let rendered_webcam_html = Mustache.render(webcamFeed, params); + + // Remove new lines, indents (tabs or spaces), and empty HTML property values. + rendered_webcam_html = rendered_webcam_html.replace( + /(\r\n|\n|\r|\t| {4})/gm, + "", + ); + let displayed_html = document.body.innerHTML; + displayed_html = displayed_html.replace(/(\r\n|\n|\r|\t| {4})/gm, ""); + displayed_html = displayed_html.replace(/(="")/gm, ""); + + expect(displayed_html).toContain(rendered_webcam_html); + + // Reset the document body. + document.body.innerHTML = ""; +}); + +test("Recorder insert webcam display with height/width", () => { + // Add webcam container to document body. + const webcam_container_id = "webcam-container"; + document.body.innerHTML = `
`; + const webcam_div = document.getElementById( + webcam_container_id, + ) as HTMLDivElement; + + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych, "prefix"); + + const media = { + stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, + }; + jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); + + // Should add the video element with webcam stream to the webcam container, + // with the specified height and width. + const height: CSSWidthHeight = "400px"; + const width: CSSWidthHeight = "auto"; + rec.insertWebcamFeed(webcam_div, width, height); + + // Use the HTML template and settings to figure out what HTML should have been added. + const webcam_element_id: string = "lookit-jspsych-webcam"; + const params = { height, width, webcam_element_id }; + let rendered_webcam_html = Mustache.render(webcamFeed, params); + + // Remove new lines, indents (tabs or spaces), and empty HTML property values. + rendered_webcam_html = rendered_webcam_html.replace( + /(\r\n|\n|\r|\t| {4})/gm, + "", + ); + let displayed_html = document.body.innerHTML; + displayed_html = displayed_html.replace(/(\r\n|\n|\r|\t| {4})/gm, ""); + displayed_html = displayed_html.replace(/(="")/gm, ""); + + expect(displayed_html).toContain(rendered_webcam_html); + + // Reset the document body. + document.body.innerHTML = ""; +}); + +test("Webcam feed is removed when stream access stops", async () => { + // Add webcam container to document body. + const webcam_container_id = "webcam-container"; + document.body.innerHTML = `
`; + const webcam_div = document.getElementById( + webcam_container_id, + ) as HTMLDivElement; + + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych, "prefix"); + const stopPromise = Promise.resolve(); + const media = { + stop: jest.fn(), + stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, + }; + rec["stopPromise"] = stopPromise; + jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); + + rec.insertWebcamFeed(webcam_div); + expect(document.body.innerHTML).toContain(" { + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych, "prefix"); + + expect(rec.s3).not.toBe(null); + + const media = { + stop: jest.fn(), + stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, + }; + jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); + + // Destroy with no in-progress upload or mic check. + // This should just stop the tracks and set s3 to null. + await rec.destroy(); + + expect(media.stop).toHaveBeenCalledTimes(1); + expect(media.stream.getTracks).toHaveBeenCalledTimes(1); + expect(rec.s3).toBe(null); + expect(Data.LookitS3.prototype.completeUpload).not.toHaveBeenCalled(); +}); + +test("Recorder destroy with in-progress upload", async () => { + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych, "prefix"); + const media = { + addEventListener: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, + }; + jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); + + await rec.start(); + expect(media.start).toHaveBeenCalledTimes(1); + + const stopPromise = Promise.resolve(); + rec["stopPromise"] = stopPromise; + + Object.defineProperty(rec.s3, "uploadInProgress", { + /** + * Overwrite the getter method for S3's uploadInProgress. + * + * @returns Boolean. + */ + get: () => true, + }); + + // Destroy with in-progress upload. + // This should call stop on the recorder and complete the upload. + await rec.destroy(); + expect(media.stop).toHaveBeenCalledTimes(1); + expect(media.stream.getTracks).toHaveBeenCalledTimes(1); + expect(rec.s3).toBe(null); + expect(Data.LookitS3.prototype.completeUpload).toHaveBeenCalledTimes(1); +}); + +test("Recorder destroy with webcam display", async () => { + // Add webcam container to document body. + const webcam_container_id = "webcam-container"; + document.body.innerHTML = `
`; + const webcam_div = document.getElementById( + webcam_container_id, + ) as HTMLDivElement; + + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych, "prefix"); + const media = { + stop: jest.fn(), + stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, + }; + jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockReturnValue(media); + + rec.insertWebcamFeed(webcam_div); + expect(document.body.innerHTML).toContain(" { + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych, "prefix"); + + // No recorder initialized + expect(rec.camMicAccess()).toBe(false); + + // Recorder initialized but stream is not active + const stream_active_undefined = { + stream: { getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]) }, + }; + jsPsych.pluginAPI.getCameraRecorder = jest + .fn() + .mockReturnValue(stream_active_undefined); + expect(rec.camMicAccess()).toBe(false); + const stream_inactive = { + stream: { + active: false, + getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]), + }, + }; + jsPsych.pluginAPI.getCameraRecorder = jest + .fn() + .mockReturnValue(stream_inactive); + expect(rec.camMicAccess()).toBe(false); + + // Recorder exists with active stream + const stream_active = { + stop: jest.fn(), + stream: { + active: true, + getTracks: jest.fn().mockReturnValue([{ stop: jest.fn() }]), + }, + }; + jsPsych.pluginAPI.getCameraRecorder = jest + .fn() + .mockReturnValue(stream_active); + expect(rec.camMicAccess()).toBe(true); +}); + +test("Recorder requestPermission", async () => { + const stream = { fake: "stream" } as unknown as MediaStream; + const mockGetUserMedia = jest.fn( + () => + new Promise((resolve) => { + resolve(stream); + }), + ); + Object.defineProperty(global.navigator, "mediaDevices", { + writable: true, + value: { + getUserMedia: mockGetUserMedia, + }, + }); + + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych, "prefix"); + const constraints = { video: true, audio: true }; + + const returnedStream = await rec.requestPermission(constraints); + expect(returnedStream).toStrictEqual(stream); + expect(global.navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith( + constraints, + ); +}); + +test("Recorder getDeviceLists", async () => { + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych, "prefix"); + + const mic1 = { + deviceId: "mic1", + kind: "audioinput", + label: "", + groupId: "default", + } as MediaDeviceInfo; + const cam1 = { + deviceId: "cam1", + kind: "videoinput", + label: "", + groupId: "default", + } as MediaDeviceInfo; + const mic2 = { + deviceId: "mic2", + kind: "audioinput", + label: "", + groupId: "other", + } as MediaDeviceInfo; + const cam2 = { + deviceId: "cam2", + kind: "videoinput", + label: "", + groupId: "other", + } as MediaDeviceInfo; + + // Returns the mic/cam devices from navigator.mediaDevices.enumerateDevices as an object with 'cameras' and 'mics' (arrays of media device info objects). + const devices = [mic1, mic2, cam1, cam2]; + Object.defineProperty(global.navigator, "mediaDevices", { + writable: true, + value: { + enumerateDevices: jest.fn( + () => + new Promise((resolve) => { + resolve(devices); + }), + ), + }, + }); + + const returnedDevices = await rec.getDeviceLists(); + expect(global.navigator.mediaDevices.enumerateDevices).toHaveBeenCalledTimes( + 1, + ); + expect(returnedDevices).toHaveProperty("cameras"); + expect(returnedDevices).toHaveProperty("mics"); + expect(returnedDevices.cameras.sort()).toStrictEqual([cam1, cam2].sort()); + expect(returnedDevices.mics.sort()).toStrictEqual([mic1, mic2].sort()); + + // Removes duplicate devices and handles empty device categories. + const devices_duplicate = [mic1, mic1, mic1]; + Object.defineProperty(global.navigator, "mediaDevices", { + writable: true, + value: { + enumerateDevices: jest.fn( + () => + new Promise((resolve) => { + resolve(devices_duplicate); + }), + ), + }, + }); + + const returnedDevicesDuplicates = await rec.getDeviceLists(); + expect(returnedDevicesDuplicates.cameras).toStrictEqual([]); + expect(returnedDevicesDuplicates.mics).toStrictEqual([mic1]); +}); + +test("Recorder initializeRecorder", () => { + const jsPsych = initJsPsych(); + const rec = new Recorder(jsPsych, "prefix"); + + // MediaRecorder is not available in Jest/jsDom, so mock the implementation of jsPsych.pluginAPI.initializeCameraRecorder (which calls new MediaRecorder) and jsPsych.pluginAPI.getCameraRecorder (which gets the private recorder that was created via jsPsych's initializeCameraRecorder). + const stream = { fake: "stream" } as unknown as MediaStream; + const recorder = jest.fn((stream: MediaStream) => { + return { + stream: stream, + start: jest.fn(), + ondataavailable: jest.fn(), + onerror: jest.fn(), + state: "", + stop: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + }; + }); + jsPsych.pluginAPI.initializeCameraRecorder = jest + .fn() + .mockImplementation((stream: MediaStream) => { + return recorder(stream); + }); + jsPsych.pluginAPI.getCameraRecorder = jest.fn().mockImplementation(() => { + return jsPsych.pluginAPI.initializeCameraRecorder(stream); + }); + + rec.intializeRecorder(stream); + + expect(jsPsych.pluginAPI.initializeCameraRecorder).toHaveBeenCalled(); + expect(rec.recorder).toBeDefined(); + expect(rec.recorder).not.toBeNull(); + expect(rec.stream).toStrictEqual(stream); +}); + test("Recorder download", async () => { const click = jest.fn(); diff --git a/packages/record/src/recorder.ts b/packages/record/src/recorder.ts index 4eed1911..1ce3d582 100644 --- a/packages/record/src/recorder.ts +++ b/packages/record/src/recorder.ts @@ -2,16 +2,29 @@ import Data from "@lookit/data"; import lookitS3 from "@lookit/data/dist/lookitS3"; import autoBind from "auto-bind"; import { JsPsych } from "jspsych"; +import Mustache from "mustache"; import { NoStopPromiseError, RecorderInitializeError } from "./error"; +import webcamFeed from "./templates/webcam-feed.mustache"; +import { CSSWidthHeight } from "./types"; /** Recorder handles the state of recording and data storage. */ export default class Recorder { private blobs: Blob[] = []; private localDownload: boolean = process.env.LOCAL_DOWNLOAD?.toLowerCase() === "true"; - private s3?: lookitS3; private filename: string; - private stopPromise?: Promise; + private stopPromise: Promise | undefined; + private webcam_element_id = "lookit-jspsych-webcam"; + /** + * Use null rather than undefined so that we can set these back to null when + * destroying. + */ + private s3: lookitS3 | null = null; + /** + * Store the reject function for the stop promise so that we can reject it in + * the destroy recorder method. + */ + private rejectStopPromise: (reason: string) => void = () => {}; /** * Recorder for online experiments. @@ -31,6 +44,86 @@ export default class Recorder { autoBind(this); } + /** + * Request permission to use the webcam and/or microphone. This can be used + * with and without specific device selection (and other constraints). + * + * @param constraints - Media stream constraints object with 'video' and + * 'audio' properties, whose values can be boolean or a + * MediaTrackConstraints object or undefined. + * @param constraints.video - If false, do not include video. If true, use the + * default webcam device. If a media track constraints object is passed, + * then it can contain the properties of all media tracks and video tracks: + * https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints. + * @param constraints.audio - If false, do not include audio. If true, use the + * default mic device. If a media track constraints object is passed, then + * it can contain the properties of all media tracks and audio tracks: + * https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints. + * @returns Camera/microphone stream. + */ + public async requestPermission(constraints: MediaStreamConstraints) { + const stream = await navigator.mediaDevices.getUserMedia(constraints); + return stream; + } + + /** + * Gets the lists of available cameras and mics (via Media Devices + * 'enumerateDevices'). These lists can be used to populate camera/mic + * selection elements. + * + * @param include_audio - Whether or not to include audio capture (mic) + * devices. Optional, default is true. + * @param include_camera - Whether or not to include the webcam (video) + * devices. Optional, default is true. + * @returns Promise that resolves with an object with properties 'cameras' and + * 'mics', containing lists of available devices. + */ + public getDeviceLists( + include_audio: boolean = true, + include_camera: boolean = true, + ): Promise<{ cameras: MediaDeviceInfo[]; mics: MediaDeviceInfo[] }> { + return navigator.mediaDevices.enumerateDevices().then((devices) => { + let unique_cameras: Array = []; + let unique_mics: Array = []; + if (include_camera) { + const cams = devices.filter( + (d) => + d.kind === "videoinput" && + d.deviceId !== "default" && + d.deviceId !== "communications", + ); + unique_cameras = cams.filter( + (cam, index, arr) => + arr.findIndex((v) => v.groupId == cam.groupId) == index, + ); + } + if (include_audio) { + const mics = devices.filter( + (d) => + d.kind === "audioinput" && + d.deviceId !== "default" && + d.deviceId !== "communications", + ); + unique_mics = mics.filter( + (mic, index, arr) => + arr.findIndex((v) => v.groupId == mic.groupId) == index, + ); + } + return { cameras: unique_cameras, mics: unique_mics }; + }); + } + + /** + * Initialize recorder using the jsPsych plugin API. + * + * @param stream - Media stream returned from getUserMedia that should be used + * to set up the jsPsych recorder. + * @param opts - Media recorder options to use when setting up the recorder. + */ + public intializeRecorder(stream: MediaStream, opts?: MediaRecorderOptions) { + this.jsPsych.pluginAPI.initializeCameraRecorder(stream, opts); + } + /** * Get recorder from jsPsych plugin API. * @@ -52,7 +145,30 @@ export default class Recorder { * @returns MediaStream from the plugin API. */ private get stream() { - return this.recorder.stream; + return this.recorder?.stream; + } + + /** + * Insert a video element containing the webcam feed onto the page. + * + * @param element - The HTML div element that should serve as the container + * for the webcam display. + * @param width - The width of the video element containing the webcam feed, + * in CSS units (optional). Default is `'100%'` + * @param height - The height of the video element containing the webcam feed, + * in CSS units (optional). Default is `'auto'` + */ + public insertWebcamFeed( + element: HTMLDivElement, + width: CSSWidthHeight = "100%", + height: CSSWidthHeight = "auto", + ) { + const { webcam_element_id, stream } = this; + const view = { height, width, webcam_element_id }; + element.innerHTML = Mustache.render(webcamFeed, view); + element.querySelector( + `#${webcam_element_id}`, + )!.srcObject = stream; } /** @@ -64,8 +180,9 @@ export default class Recorder { this.recorder.addEventListener("dataavailable", this.handleDataAvailable); // create a stop promise and pass the resolve function as an argument to the stop event callback, // so that the stop event handler can resolve the stop promise - this.stopPromise = new Promise((resolve) => { + this.stopPromise = new Promise((resolve, reject) => { this.recorder.addEventListener("stop", this.handleStop(resolve)); + this.rejectStopPromise = reject; }); if (!this.localDownload) { await this.s3?.createUpload(); @@ -74,23 +191,58 @@ export default class Recorder { } /** - * Stop recording and camera/microphone. + * Stop all streams/tracks. This stops any in-progress recordings and releases + * the media devices. This is can be called when recording is not in progress, + * e.g. To end the camera/mic access when the experiment is displaying the + * camera feed but not recording (e.g. Video-config). + */ + private stopTracks() { + this.recorder.stop(); + this.stream.getTracks().map((t) => t.stop()); + } + + /** + * Stop recording and camera/microphone. This will stop accessing all media + * tracks, clear the webcam feed element (if there is one), and return the + * stop promise. * * @returns Promise that resolves after the media recorder has stopped and * final 'dataavailable' event has occurred, when the "stop" event-related * callback function is called. */ public stop() { - this.recorder.stop(); - this.stream.getTracks().map((t) => t.stop()); - + this.stopTracks(); + this.clearWebcamFeed(); if (!this.stopPromise) { throw new NoStopPromiseError(); } - return this.stopPromise; } + /** + * Destroy the recorder. When a plugin/extension destroys the recorder, it + * will set the whole Recorder class instance to null, so we don't need to + * reset the Recorder instance variables/states. We should complete the S3 + * upload and stop any async processes that might continue to run (stop + * promise). We also need to stop the tracks to release the media devices + * (even if they're not recording). Setting S3 to null should release the + * video blob data from memory. + */ + public async destroy() { + if (this.stopPromise) { + await this.stop(); + // Complete any MPU that might've been created + if (this.s3?.uploadInProgress) { + await this.s3?.completeUpload(); + } + } else { + this.stopTracks(); + this.clearWebcamFeed(); + } + // Clear any blob data + this.s3 = null; + } + /** Throw Error if there isn't a recorder provided by jsPsych. */ private initializeCheck() { if (!this.recorder) { @@ -106,7 +258,10 @@ export default class Recorder { * * @returns Function that is called on the recorder's "stop" event. */ - private handleStop(resolve: { (value: void | PromiseLike): void }) { + private handleStop(resolve: { + (value: void | PromiseLike): void; + (): void; + }) { return async () => { if (this.localDownload) { await this.download(); @@ -174,4 +329,23 @@ export default class Recorder { private createFilename(prefix: string) { return `${prefix}_${new Date().getTime()}.webm`; } + + /** + * Check access to webcam/mic stream. + * + * @returns Whether or not the recorder has webcam/mic access. + */ + public camMicAccess(): boolean { + return !!this.recorder && !!this.stream?.active; + } + + /** Private helper to clear the webcam feed, if there is one. */ + private clearWebcamFeed() { + const webcam_feed_element = document.querySelector( + `#${this.webcam_element_id}`, + ) as HTMLVideoElement; + if (webcam_feed_element) { + webcam_feed_element.remove(); + } + } } diff --git a/packages/record/src/stop.ts b/packages/record/src/stop.ts index 77ee18d8..ad85f44f 100644 --- a/packages/record/src/stop.ts +++ b/packages/record/src/stop.ts @@ -1,7 +1,9 @@ import { LookitWindow } from "@lookit/data/dist/types"; import { JsPsych, JsPsychPlugin } from "jspsych"; +import Mustache from "mustache"; import { NoSessionRecordingError } from "./error"; import Recorder from "./recorder"; +import uploadingVideo from "./templates/uploading-video.mustache"; declare let window: LookitWindow; @@ -34,7 +36,7 @@ export default class StopRecordPlugin implements JsPsychPlugin { * plugin's trial method via jsPsych core). */ public trial(display_element: HTMLElement): void { - display_element.innerHTML = "
Uploading video, please wait...
"; + display_element.innerHTML = Mustache.render(uploadingVideo, {}); this.recorder.stop().then(() => { window.chs.sessionRecorder = null; display_element.innerHTML = ""; diff --git a/packages/record/src/string-import.d.ts b/packages/record/src/string-import.d.ts new file mode 100644 index 00000000..59511a32 --- /dev/null +++ b/packages/record/src/string-import.d.ts @@ -0,0 +1,4 @@ +declare module "*.mustache" { + const file: string; + export default file; +} diff --git a/packages/record/src/templates/uploading-video.mustache b/packages/record/src/templates/uploading-video.mustache new file mode 100644 index 00000000..7770085d --- /dev/null +++ b/packages/record/src/templates/uploading-video.mustache @@ -0,0 +1 @@ +
Uploading video, please wait...
\ No newline at end of file diff --git a/packages/record/src/templates/webcam-feed.mustache b/packages/record/src/templates/webcam-feed.mustache new file mode 100644 index 00000000..93f1c00e --- /dev/null +++ b/packages/record/src/templates/webcam-feed.mustache @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/packages/record/src/types.ts b/packages/record/src/types.ts new file mode 100644 index 00000000..69b5043a --- /dev/null +++ b/packages/record/src/types.ts @@ -0,0 +1,8 @@ +/** + * A valid CSS height/width value, which can be a number, a string containing a + * number with units, or 'auto'. + */ +export type CSSWidthHeight = + | number + | `${number}${"px" | "cm" | "mm" | "em" | "%"}` + | "auto"; diff --git a/packages/record/tsconfig.json b/packages/record/tsconfig.json index 5a9987dc..c0697c56 100644 --- a/packages/record/tsconfig.json +++ b/packages/record/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "baseUrl": ".", + "esModuleInterop": true, "strict": true }, "extends": "@jspsych/config/tsconfig.json",