import { uploadClip } from "./upload-clip.js"; import type { ClipDropCode } from "./clip-error-codes.js"; import { createAudioLevelAnalyzer, type AudioClipVolumeAnalysis, } from "./audio-level-analysis.js"; export type { AudioClipVolumeAnalysis, AudioVolumeClassification, } from "./audio-level-analysis.js"; /** * Ad-hoc video clip recorder. Unlike screen-share (continuous chunks) * or webcam photos (passive sampling), this records a single blob * triggered by an explicit customer action — eg. answering a video * question — and uploads the whole file once on stop. * * Stream sourcing strategy: * 1. If the customer hands us a stream, use it directly. We don't own * the lifecycle; we just read frames. * 2. Otherwise, if the webcam observer is running, reuse its stream. * 3. Otherwise, call getUserMedia ourselves and own the stream. * * Why this matters: webcam photos + a video clip should share a single * camera handle so the candidate doesn't see two permission prompts or * two camera lights. Same principle as Option C in the webcam * observer. * * Auto-stop safety: a maxDurationMs cap (default 120s) prevents * runaway recordings if the customer forgets to call stop(). */ export interface VideoClipRecorderOptions { /** * Discriminator: `"video"` records video (+ optional audio * track) and uploads to /video-clips. `"audio"` records audio * only and uploads to /audio-clips, with a default MIME type * of `audio/webm;codecs=opus`. Both paths share this class * because the MediaRecorder pipeline is identical -- only the * track-type check + the MIME default + the upload URL differ. * Default: "video" (backward-compatible). */ kind?: "video" | "audio"; /** * sessionId is needed for the upload URL. */ sessionId: string; /** * Events ingest URL — the recorder derives the origin and joins the * clip path under it. */ ingestUrl: string; /** Public app key for org routing. */ appId?: string; /** * Per-device fingerprint. Forwarded to the upload as * `x-fingerprint-id` so the server can attribute the clip * to the device that recorded it. Optional. */ fingerprintId?: string; /** * 1-based clip number. The SDK assigns this sequentially across * recordings in the same session. Customers don't pick it. */ clipNumber: number; /** * The MediaStream to record from. The recorder will not * `addTrack`/`removeTrack` on this stream — doing so transitions * MediaRecorder to inactive. Callers that need to combine borrowed * + freshly-acquired tracks must build a fresh stream themselves * and pass it in. See `recordVideoClip` in `index.ts` for the * canonical pattern. */ stream: MediaStream; /** * Tracks the recorder will `.stop()` on finalise. The recorder * never stops other tracks on the stream, even when they're part * of `stream.getTracks()`. This lets callers mix borrowed and * owned tracks safely: * - Path 1 (customer-provided stream): pass [] — customer owns it. * - Path 2 (webcam observer's video + on-demand mic): pass [mic] * only. The webcam observer owns the video track lifetime. * - Path 3 (fresh getUserMedia): pass all tracks of the stream. */ tracksToStop: MediaStreamTrack[]; /** * Video MIME type to record. Default: video/webm with VP9 if the * browser supports it. */ mimeType?: string; /** * Bitrate hint for the video track. Default 1 Mbps — higher than * the continuous screen-share default because clips are short and * usually contain faces / voice rather than static screens. */ videoBitrate?: number; /** * Hard cap on recording duration (ms). Auto-stops at this point so * a forgotten stop() doesn't leak a multi-hour blob. Default: 120000. */ maxDurationMs?: number; /** Fired when the clip uploads successfully. */ onUploaded?: ( clipNumber: number, byteSize: number, durationMs: number, volumeAnalysis?: AudioClipVolumeAnalysis, ) => void; /** * Fired when the clip can't be uploaded (network, 4xx, 5xx) or * recorded nothing. `code` is the stable classification; `reason` is * the human-readable detail. */ onDropped?: ( clipNumber: number, reason: string, volumeAnalysis?: AudioClipVolumeAnalysis, code?: ClipDropCode, ) => void; /** * Best-effort sink for the finalized clip blob, fired in parallel * with the upload to the proctoring server. Lets a caller mirror the * clip to their own backend WITHOUT affecting the canonical outcome: * the server upload remains the source of truth, so this rejecting * never turns the clip into a drop. The recorder neither awaits this * before resolving stop() nor surfaces its rejection — the caller is * responsible for observing failure (we re-throw into it so it can). */ onClipData?: (clip: { blob: Blob; mimeType: string; durationMs: number; clipNumber: number; }) => Promise; } /** * Public handle returned to the customer. The only operation is * stop(); everything else is internal. */ export interface VideoClipHandle { /** 1-based identifier for this clip within the session. */ readonly clipNumber: number; /** Stops recording immediately and uploads the resulting blob. */ stop(): Promise; } export class VideoClipRecorder implements VideoClipHandle { readonly clipNumber: number; private readonly opts: Required< Pick< VideoClipRecorderOptions, "kind" | "sessionId" | "ingestUrl" | "clipNumber" | "stream" | "mimeType" | "videoBitrate" | "maxDurationMs" > > & { appId: string | undefined; fingerprintId: string | undefined; tracksToStop: MediaStreamTrack[]; onUploaded: VideoClipRecorderOptions["onUploaded"]; onDropped: VideoClipRecorderOptions["onDropped"]; onClipData: VideoClipRecorderOptions["onClipData"]; }; private recorder: MediaRecorder | null = null; private blobs: Blob[] = []; private startedAt = 0; private autoStopTimer: ReturnType | null = null; private audioLevelAnalyzer: ReturnType = null; private stopped = false; private finishPromise: Promise | null = null; constructor(opts: VideoClipRecorderOptions) { this.clipNumber = opts.clipNumber; const kind = opts.kind ?? "video"; this.opts = { kind, sessionId: opts.sessionId, ingestUrl: opts.ingestUrl, appId: opts.appId, fingerprintId: opts.fingerprintId, clipNumber: opts.clipNumber, stream: opts.stream, mimeType: opts.mimeType ?? (kind === "audio" ? pickAudioMimeType() : pickMimeType()) ?? (kind === "audio" ? "audio/webm" : "video/webm"), videoBitrate: opts.videoBitrate ?? 1_000_000, maxDurationMs: opts.maxDurationMs ?? 120_000, tracksToStop: opts.tracksToStop, onUploaded: opts.onUploaded, onDropped: opts.onDropped, onClipData: opts.onClipData, }; } /** * The stream being recorded while this clip is active, else null (once * stopped). The runtime checkpoint reads it to piggyback mic/camera * liveness onto an in-flight clip — no separate getUserMedia. */ activeStream(): MediaStream | null { return this.stopped ? null : this.opts.stream; } /** * Begin recording. Called once by the client; do not call again. * Throws if MediaRecorder is unavailable or the stream has no * video tracks. */ start(): void { if (this.recorder) return; if (typeof MediaRecorder === "undefined") { throw new Error("MediaRecorder is not available in this browser"); } // Track-type check is per-kind: video clips need a video // track; audio clips need an audio track. The error message // tells the caller which contract they violated. if (this.opts.kind === "audio") { if (this.opts.stream.getAudioTracks().length === 0) { throw new Error("Stream has no audio tracks"); } } else if (this.opts.stream.getVideoTracks().length === 0) { throw new Error("Stream has no video tracks"); } // videoBitsPerSecond is a video-only hint; passing it on an // audio-only MediaRecorder is harmless but redundant. Omit it // to keep the option bag minimal. const options: MediaRecorderOptions = this.opts.kind === "audio" ? {} : { videoBitsPerSecond: this.opts.videoBitrate }; if (MediaRecorder.isTypeSupported(this.opts.mimeType)) { options.mimeType = this.opts.mimeType; } const recorder = new MediaRecorder(this.opts.stream, options); recorder.addEventListener("dataavailable", (event) => { if (event.data && event.data.size > 0) this.blobs.push(event.data); }); recorder.start(); this.recorder = recorder; this.startedAt = Date.now(); if (this.opts.kind === "audio") { this.audioLevelAnalyzer = createAudioLevelAnalyzer(this.opts.stream); } // Safety cap — fire-and-forget; if stop() runs first, this is a // no-op because stopped checks the flag. this.autoStopTimer = setTimeout(() => { void this.stop(); }, this.opts.maxDurationMs); } async stop(): Promise { if (this.stopped) return this.finishPromise ?? Promise.resolve(); this.stopped = true; if (this.autoStopTimer) { clearTimeout(this.autoStopTimer); this.autoStopTimer = null; } this.finishPromise = this.finalize(); return this.finishPromise; } private async finalize(): Promise { const durationMs = Math.max(0, Date.now() - this.startedAt); // MediaRecorder.stop() flushes a final dataavailable event // synchronously after the call returns. Wait for the stop event // before assembling the blob so we don't miss the tail. if (this.recorder && this.recorder.state !== "inactive") { await new Promise((resolve) => { const rec = this.recorder!; rec.addEventListener("stop", () => resolve(), { once: true }); try { rec.stop(); } catch { // If the recorder is already broken (track ended) just // resolve and upload whatever we have. resolve(); } }); } const volumeAnalysis = await this.stopAudioLevelAnalyzer(); // Stop only the tracks the caller said we own. Borrowed tracks // (eg. the webcam observer's video track) stay alive — the other // consumer is responsible for them. We do NOT touch the stream // wrapper itself; the rule "don't mutate a stream that's being // recorded by some other MediaRecorder" applies retroactively // here, even though our own recorder is already done. for (const track of this.opts.tracksToStop) { try { track.stop(); } catch { // Track already stopped or otherwise unusable; ignore. } } if (this.blobs.length === 0) { this.opts.onDropped?.( this.clipNumber, "empty-recording", volumeAnalysis, "empty-recording", ); return; } const blob = new Blob(this.blobs, { type: this.opts.mimeType }); // Fire the best-effort mirror in parallel with the canonical // upload. We kick it off first (so both run concurrently) but do // NOT await it here and do NOT let it affect the clip outcome — the // server upload below is the source of truth. The caller's own // onClipData is responsible for observing its own failure; we only // guard against an unhandled rejection crashing finalize. if (this.opts.onClipData) { void Promise.resolve( this.opts.onClipData({ blob, mimeType: this.opts.mimeType, durationMs, clipNumber: this.clipNumber, }), ).catch(() => { // Swallowed here on purpose: surfacing is the caller's job // (recordAudioClip wraps onClipData to emit a failure event). }); } await this.upload(blob, durationMs, volumeAnalysis); } private async upload( blob: Blob, durationMs: number, volumeAnalysis: AudioClipVolumeAnalysis | undefined, ): Promise { const result = await uploadClip({ kind: this.opts.kind, sessionId: this.opts.sessionId, ingestUrl: this.opts.ingestUrl, appId: this.opts.appId, ...(this.opts.fingerprintId ? { fingerprintId: this.opts.fingerprintId } : {}), clipNumber: this.clipNumber, blob, durationMs, }); if (result.kind === "uploaded") { this.opts.onUploaded?.( result.clipNumber, result.byteSize, result.durationMs, volumeAnalysis, ); } else { this.opts.onDropped?.( result.clipNumber, result.reason, volumeAnalysis, result.code, ); } } private async stopAudioLevelAnalyzer(): Promise< AudioClipVolumeAnalysis | undefined > { const analyzer = this.audioLevelAnalyzer; this.audioLevelAnalyzer = null; if (!analyzer) return undefined; try { return await analyzer.stop(); } catch { return undefined; } } } /** Audio MIME picker. Opus inside WebM is universally supported * by browsers that have MediaRecorder; fall back to bare audio/webm * on the rare client without explicit support. */ function pickAudioMimeType(): string | undefined { if (typeof MediaRecorder === "undefined") return undefined; const candidates = [ "audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus", "audio/ogg", ]; for (const type of candidates) { if (MediaRecorder.isTypeSupported(type)) return type; } return undefined; } function pickMimeType(): string | undefined { if (typeof MediaRecorder === "undefined") return undefined; const candidates = [ "video/webm;codecs=vp9,opus", "video/webm;codecs=vp9", "video/webm;codecs=vp8,opus", "video/webm;codecs=vp8", "video/webm", ]; for (const type of candidates) { if (MediaRecorder.isTypeSupported(type)) return type; } return undefined; }