// 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[] = []; private videoId: string; private takeId: string; private systemAudio: boolean; private keepAlive = 0; // With systemAudio, the server is capturing computer audio: it is told the wall-clock time of each start, // pause, and resume so that it can align that capture, and it gets a keep-alive while recording or paused. constructor(videoId: string, takeId: string, streams: Partial>, cameraHeight: number, systemAudio = false) { this.videoId = videoId; this.takeId = takeId; this.systemAudio = systemAudio; 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' ? screenBitrate(stream) : 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 }); } } private event(type: 'start' | 'pause' | 'resume') { if (!this.systemAudio) return; const ev = { type, at: Date.now() }; // Retried like chunks, since a lost event would misalign the computer audio. const send = (attempt: number): Promise => api.takeEvent(this.videoId, this.takeId, ev).catch(() => (attempt < 8 ? sleep(300 * 2 ** attempt).then(() => send(attempt + 1)) : undefined)); send(0); } start() { for (const p of this.parts) p.recorder.start(1000); this.event('start'); if (this.systemAudio) this.keepAlive = window.setInterval(() => api.takeEvent(this.videoId, this.takeId, null).catch(() => {}), 5000); } pause() { for (const p of this.parts) if (p.recorder.state === 'recording') p.recorder.pause(); this.event('pause'); } resume() { for (const p of this.parts) if (p.recorder.state === 'paused') p.recorder.resume(); this.event('resume'); } // Stops recording and waits until every chunk has been uploaded. async stop() { clearInterval(this.keepAlive); 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; } } // 6 Mbit/s for a 1080p screen, raised in proportion to the pixel count for larger screens (a // 2560x1600 laptop panel gets about 12, a 4K monitor 20), so that small text stays sharp when the // export keeps the screen at full size. function screenBitrate(stream: MediaStream): number { const { width, height } = stream.getVideoTracks()[0]?.getSettings() ?? {}; const pixels = width && height ? width * height : 1920 * 1080; return Math.round(Math.min(20_000_000, Math.max(6_000_000, (6_000_000 * pixels) / (1920 * 1080)))); }