/ proof-of-concept / takes
Sign in
proof-of-concept / takes
takes / src / lib / recorder.ts
134 lines · 4.8 KBBlameHistoryRaw
1// A recording session: one MediaRecorder per track (screen, camera), started and paused together.
2// Chunks are uploaded to the server as they are produced, so a browser crash loses at most a second.
4import type { TrackKind } from '../../shared/types.ts';
5import { api } from '../api.ts';
7const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
9export function pickMimeType(): string {
10 const candidates = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'];
11 return candidates.find((m) => MediaRecorder.isTypeSupported(m)) ?? '';
14// Uploads chunks strictly in order. If a chunk fails permanently, later chunks are not sent,
15// since appending them would corrupt the file.
16class ChunkUploader {
17 private chain: Promise<void> = Promise.resolve();
18 private videoId: string;
19 private takeId: string;
20 private track: TrackKind;
21 error: Error | null = null;
23 constructor(videoId: string, takeId: string, track: TrackKind) {
24 this.videoId = videoId;
25 this.takeId = takeId;
26 this.track = track;
27 }
29 push(blob: Blob) {
30 this.chain = this.chain.then(async () => {
31 for (let attempt = 0; ; attempt++) {
32 try {
33 await api.uploadChunk(this.videoId, this.takeId, this.track, blob);
34 return;
35 } catch (e) {
36 if (attempt >= 8) {
37 this.error = e as Error;
38 throw e;
39 }
40 await sleep(Math.min(5000, 300 * 2 ** attempt));
41 }
42 }
43 });
44 }
46 flush() {
47 return this.chain;
48 }
51interface Part {
52 track: TrackKind;
53 recorder: MediaRecorder;
54 uploader: ChunkUploader;
55 stopped: Promise<void>;
58export class RecordingSession {
59 private parts: Part[] = [];
60 private videoId: string;
61 private takeId: string;
62 private systemAudio: boolean;
63 private keepAlive = 0;
65 // With systemAudio, the server is capturing computer audio: it is told the wall-clock time of each start,
66 // pause, and resume so that it can align that capture, and it gets a keep-alive while recording or paused.
67 constructor(videoId: string, takeId: string, streams: Partial<Record<TrackKind, MediaStream>>, cameraHeight: number, systemAudio = false) {
68 this.videoId = videoId;
69 this.takeId = takeId;
70 this.systemAudio = systemAudio;
71 const mimeType = pickMimeType();
72 for (const track of ['screen', 'camera'] as TrackKind[]) {
73 const stream = streams[track];
74 if (!stream) continue;
75 const recorder = new MediaRecorder(stream, {
76 mimeType,
77 videoBitsPerSecond: track === 'screen' ? screenBitrate(stream) : cameraHeight >= 1080 ? 5_000_000 : 3_000_000,
78 audioBitsPerSecond: 128_000,
79 });
80 const uploader = new ChunkUploader(videoId, takeId, track);
81 recorder.ondataavailable = (e) => {
82 if (e.data.size > 0) uploader.push(e.data);
83 };
84 const stopped = new Promise<void>((resolve) => recorder.addEventListener('stop', () => resolve(), { once: true }));
85 this.parts.push({ track, recorder, uploader, stopped });
86 }
87 }
89 private event(type: 'start' | 'pause' | 'resume') {
90 if (!this.systemAudio) return;
91 const ev = { type, at: Date.now() };
92 // Retried like chunks, since a lost event would misalign the computer audio.
93 const send = (attempt: number): Promise<unknown> =>
94 api.takeEvent(this.videoId, this.takeId, ev).catch(() => (attempt < 8 ? sleep(300 * 2 ** attempt).then(() => send(attempt + 1)) : undefined));
95 send(0);
96 }
98 start() {
99 for (const p of this.parts) p.recorder.start(1000);
100 this.event('start');
101 if (this.systemAudio) this.keepAlive = window.setInterval(() => api.takeEvent(this.videoId, this.takeId, null).catch(() => {}), 5000);
102 }
104 pause() {
105 for (const p of this.parts) if (p.recorder.state === 'recording') p.recorder.pause();
106 this.event('pause');
107 }
109 resume() {
110 for (const p of this.parts) if (p.recorder.state === 'paused') p.recorder.resume();
111 this.event('resume');
112 }
114 // Stops recording and waits until every chunk has been uploaded.
115 async stop() {
116 clearInterval(this.keepAlive);
117 for (const p of this.parts) if (p.recorder.state !== 'inactive') p.recorder.stop();
118 await Promise.all(this.parts.map((p) => p.stopped));
119 await Promise.all(this.parts.map((p) => p.uploader.flush()));
120 }
122 get uploadError(): Error | null {
123 return this.parts.find((p) => p.uploader.error)?.uploader.error ?? null;
124 }
127// 6 Mbit/s for a 1080p screen, raised in proportion to the pixel count for larger screens (a
128// 2560x1600 laptop panel gets about 12, a 4K monitor 20), so that small text stays sharp when the
129// export keeps the screen at full size.
130function screenBitrate(stream: MediaStream): number {
131 const { width, height } = stream.getVideoTracks()[0]?.getSettings() ?? {};
132 const pixels = width && height ? width * height : 1920 * 1080;
133 return Math.round(Math.min(20_000_000, Math.max(6_000_000, (6_000_000 * pixels) / (1920 * 1080))));
↑↓ move↵openescclose