// src/recording/VideoRecorder.ts
// Singleton wrapping MediaRecorder on a canvas.captureStream(). The browser
// renders to the canvas every frame anyway; we just tap that stream and
// finalise a webm Blob on stop.
const CANDIDATE_MIME_TYPES = [
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm',
'video/mp4',
];
const VIDEO_BITS_PER_SECOND = 8_000_000;
const CHUNK_MS = 1000;
function pickMimeType(): string {
if (typeof MediaRecorder === 'undefined') return '';
for (const m of CANDIDATE_MIME_TYPES) {
if (MediaRecorder.isTypeSupported(m)) return m;
}
return '';
}
class VideoRecorder {
private recorder: MediaRecorder | null = null;
private stream: MediaStream | null = null;
private chunks: Blob[] = [];
private mimeType = '';
get active(): boolean {
return this.recorder?.state === 'recording';
}
start(canvas: HTMLCanvasElement, fps = 60): void {
if (this.active) return;
if (typeof MediaRecorder === 'undefined') {
throw new Error('MediaRecorder unavailable');
}
// captureStream is on HTMLCanvasElement but not in lib.dom's base typings
// in every TS release — narrow with a guard rather than a `// @ts-ignore`.
const captureStream = (
canvas as HTMLCanvasElement & { captureStream?: (fps?: number) => MediaStream }
).captureStream;
if (typeof captureStream !== 'function') {
throw new Error('canvas.captureStream unavailable');
}
this.stream = captureStream.call(canvas, fps);
this.chunks = [];
this.mimeType = pickMimeType();
const opts: MediaRecorderOptions = { videoBitsPerSecond: VIDEO_BITS_PER_SECOND };
if (this.mimeType) opts.mimeType = this.mimeType;
this.recorder = new MediaRecorder(this.stream, opts);
this.recorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) this.chunks.push(e.data);
};
this.recorder.start(CHUNK_MS);
}
async stop(): Promise<Blob | null> {
const rec = this.recorder;
if (!rec) return null;
return new Promise<Blob | null>((resolve) => {
rec.onstop = () => {
const type = rec.mimeType || this.mimeType || 'video/webm';
const blob = this.chunks.length > 0 ? new Blob(this.chunks, { type }) : null;
this.cleanup();
resolve(blob);
};
rec.stop();
});
}
/** Best-effort filename extension derived from the active mime type. */
fileExt(): string {
return (this.recorder?.mimeType || this.mimeType).startsWith('video/mp4') ? 'mp4' : 'webm';
}
private cleanup(): void {
if (this.stream) {
for (const t of this.stream.getTracks()) t.stop();
this.stream = null;
}
this.recorder = null;
this.chunks = [];
}
}
export const videoRecorder = new VideoRecorder();