Zum Inhalt

src/recording/VideoRecorder.ts

// 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();