// A recording session: one MediaRecorder per track (screen, camera), started and paused together. // Chunks are uploaded to the server as they are produced, so a browser crash loses at most a second. import type { TrackKind } from '../../shared/types.ts'; import { api } from '../api.ts'; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); export function pickMimeType(): string { const candidates = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm']; return candidates.find((m) => MediaRecorder.isTypeSupported(m)) ?? ''; } // Uploads chunks strictly in order. If a chunk fails permanently, later chunks are not sent, // since appending them would corrupt the file. class ChunkUploader { private chain: Promise = Promise.resolve(); private videoId: string; private takeId: string; private track: TrackKind; error: Error | null = null; constructor(videoId: string, takeId: string, track: TrackKind) { this.videoId = videoId; this.takeId = takeId; this.track = track; } push(blob: Blob) { this.chain = this.chain.then(async () => { for (let attempt = 0; ; attempt++) { try { await api.uploadChunk(this.videoId, this.takeId, this.track, blob); return; } catch (e) { if (attempt >= 8) { this.error = e as Error; throw e; } await sleep(Math.min(5000, 300 * 2 ** attempt)); } } }); } flush() { return this.chain; } } interface Part { track: TrackKind; recorder: MediaRecorder; uploader: ChunkUploader; stopped: Promise; } export class RecordingSession { private parts: Part[] = []; constructor(videoId: string, takeId: string, streams: Partial>, cameraHeight: number) { const mimeType = pickMimeType(); for (const track of ['screen', 'camera'] as TrackKind[]) { const stream = streams[track]; if (!stream) continue; const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: track === 'screen' ? 6_000_000 : cameraHeight >= 1080 ? 5_000_000 : 3_000_000, audioBitsPerSecond: 128_000, }); const uploader = new ChunkUploader(videoId, takeId, track); recorder.ondataavailable = (e) => { if (e.data.size > 0) uploader.push(e.data); }; const stopped = new Promise((resolve) => recorder.addEventListener('stop', () => resolve(), { once: true })); this.parts.push({ track, recorder, uploader, stopped }); } } start() { for (const p of this.parts) p.recorder.start(1000); } pause() { for (const p of this.parts) if (p.recorder.state === 'recording') p.recorder.pause(); } resume() { for (const p of this.parts) if (p.recorder.state === 'paused') p.recorder.resume(); } // Stops recording and waits until every chunk has been uploaded. async stop() { for (const p of this.parts) if (p.recorder.state !== 'inactive') p.recorder.stop(); await Promise.all(this.parts.map((p) => p.stopped)); await Promise.all(this.parts.map((p) => p.uploader.flush())); } get uploadError(): Error | null { return this.parts.find((p) => p.uploader.error)?.uploader.error ?? null; } }