feat(export): MediaRecorder fallback when WebCodecs encoders are missing

The existing exporter uses mediabunny, which delegates to the
WebCodecs VideoEncoder and AudioEncoder. On browsers where those
APIs are not exposed (the same environments that needed the decode
fallback in #794), export currently fails with
"AudioEncoder is not supported by this browser" before producing
any output.

This change adds a parallel fallback path in SceneExporter:

- New helpers \`isWebCodecsExportSupported()\` and
  \`detectMediaRecorderSupport()\` live in a tiny dependency-free
  module so they can be unit-tested without dragging in WASM.
- New \`exportWithMediaRecorder\` drives the existing CanvasRenderer
  at real-time wall-clock, captures the output canvas with
  \`HTMLCanvasElement.captureStream(fps)\`, mixes the project audio
  buffer through a \`MediaStreamAudioDestinationNode\`, and records
  the combined MediaStream with MediaRecorder, producing a WebM
  blob the same browser can play back natively.
- \`SceneExporter.export()\` detects \`!isWebCodecsExportSupported()\`
  at entry and delegates to the new path. The WebCodecs path is
  unchanged for capable browsers.

Trade-offs of the fallback path:

- Output is always WebM (most browsers don't expose an MP4 muxer
  via MediaRecorder). The user's requested format is honoured
  best-effort.
- Rendering runs at real time, so a 60-second timeline takes
  roughly 60 seconds to export. mediabunny's WebCodecs path
  continues to render faster than real-time on capable browsers.
- Bitrate is mapped per quality preset to a numeric value
  compatible with \`MediaRecorder.videoBitsPerSecond\`.

Unit tests cover the feature-detection helpers across the four
states of \`VideoEncoder\` / \`AudioEncoder\` / \`MediaRecorder\`
availability.
This commit is contained in:
Andrés Villagrán 2026-05-16 21:47:34 -04:00
parent 7204146467
commit b1b3b1c9d1
4 changed files with 398 additions and 0 deletions

View File

@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
detectMediaRecorderSupport,
isWebCodecsExportSupported,
} from "../media-recorder-support";
describe("isWebCodecsExportSupported", () => {
const root = globalThis as {
VideoEncoder?: unknown;
AudioEncoder?: unknown;
};
let originalVideoEncoder: unknown;
let originalAudioEncoder: unknown;
beforeEach(() => {
originalVideoEncoder = root.VideoEncoder;
originalAudioEncoder = root.AudioEncoder;
});
afterEach(() => {
if (originalVideoEncoder === undefined) delete root.VideoEncoder;
else root.VideoEncoder = originalVideoEncoder;
if (originalAudioEncoder === undefined) delete root.AudioEncoder;
else root.AudioEncoder = originalAudioEncoder;
});
test("false when either WebCodecs encoder is missing", () => {
delete root.VideoEncoder;
delete root.AudioEncoder;
expect(isWebCodecsExportSupported()).toBe(false);
root.VideoEncoder = class {};
delete root.AudioEncoder;
expect(isWebCodecsExportSupported()).toBe(false);
delete root.VideoEncoder;
root.AudioEncoder = class {};
expect(isWebCodecsExportSupported()).toBe(false);
});
test("true only when both encoders are defined", () => {
root.VideoEncoder = class {};
root.AudioEncoder = class {};
expect(isWebCodecsExportSupported()).toBe(true);
});
});
describe("detectMediaRecorderSupport", () => {
const root = globalThis as {
MediaRecorder?: unknown;
};
let originalMediaRecorder: unknown;
beforeEach(() => {
originalMediaRecorder = root.MediaRecorder;
});
afterEach(() => {
if (originalMediaRecorder === undefined) delete root.MediaRecorder;
else root.MediaRecorder = originalMediaRecorder;
});
test("unsupported when MediaRecorder is undefined", () => {
delete root.MediaRecorder;
expect(detectMediaRecorderSupport()).toEqual({
supported: false,
mimeType: null,
});
});
test("returns the first supported mime type", () => {
class FakeMediaRecorder {
static isTypeSupported(mimeType: string): boolean {
return mimeType === "video/webm;codecs=vp8,opus";
}
}
root.MediaRecorder = FakeMediaRecorder;
expect(detectMediaRecorderSupport()).toEqual({
supported: true,
mimeType: "video/webm;codecs=vp8,opus",
});
});
test("unsupported when no candidate mime type matches", () => {
class FakeMediaRecorder {
static isTypeSupported(): boolean {
return false;
}
}
root.MediaRecorder = FakeMediaRecorder;
expect(detectMediaRecorderSupport()).toEqual({
supported: false,
mimeType: null,
});
});
});

View File

@ -0,0 +1,195 @@
import type { FrameRate } from "opencut-wasm";
import { TICKS_PER_SECOND } from "@/wasm";
import { frameRateToFloat } from "@/fps/utils";
import type { RootNode } from "./nodes/root-node";
import type { CanvasRenderer } from "./canvas-renderer";
import { detectMediaRecorderSupport } from "./media-recorder-support";
export {
detectMediaRecorderSupport,
isWebCodecsExportSupported,
} from "./media-recorder-support";
export type { MediaRecorderSupport } from "./media-recorder-support";
/**
* MediaRecorder-based exporter used when WebCodecs encoders
* (VideoEncoder / AudioEncoder) are not available in the current browser.
*
* Captures the renderer's output canvas via `canvas.captureStream(fps)` and
* mixes the project audio buffer through a `MediaStreamAudioDestinationNode`.
* The combined MediaStream is recorded with `MediaRecorder`, producing a
* WebM blob (which `<video>` can play natively on the same browser without
* WebCodecs).
*
* Trade-offs vs. the WebCodecs path:
* - Output is always WebM (most browsers don't expose an MP4 muxer in
* MediaRecorder). The caller's requested format is honoured best-effort
* but a WebM container is the realistic floor.
* - The renderer is driven at real time, so a 60-second timeline takes
* ~60 seconds to export.
*/
export interface MediaRecorderExportParams {
renderer: CanvasRenderer;
rootNode: RootNode;
fps: FrameRate;
bitrate: number;
audioBuffer?: AudioBuffer | null;
onProgress?: (progress: number) => void;
signal?: { cancelled: boolean };
}
export async function exportWithMediaRecorder({
renderer,
rootNode,
fps,
bitrate,
audioBuffer,
onProgress,
signal,
}: MediaRecorderExportParams): Promise<ArrayBuffer | null> {
const support = detectMediaRecorderSupport();
if (!support.supported || !support.mimeType) {
throw new Error(
"MediaRecorder is not available in this browser, so export is not supported.",
);
}
const fpsFloat = frameRateToFloat(fps);
const ticksPerFrame = Math.round(
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
);
const frameCount = Math.max(1, Math.floor(rootNode.duration / ticksPerFrame));
const canvas = renderer.getOutputCanvas();
const videoStream = canvas.captureStream(fpsFloat);
const combinedStream = new MediaStream();
for (const track of videoStream.getVideoTracks()) {
combinedStream.addTrack(track);
}
const audioGraph = audioBuffer
? createAudioGraphForStream({ audioBuffer })
: null;
if (audioGraph) {
for (const track of audioGraph.stream.getAudioTracks()) {
combinedStream.addTrack(track);
}
}
const recorder = new MediaRecorder(combinedStream, {
mimeType: support.mimeType,
videoBitsPerSecond: bitrate,
});
const chunks: Blob[] = [];
recorder.addEventListener("dataavailable", (event) => {
if (event.data && event.data.size > 0) {
chunks.push(event.data);
}
});
const recordingFinished = new Promise<Blob>((resolve, reject) => {
recorder.addEventListener("stop", () => {
resolve(new Blob(chunks, { type: support.mimeType ?? "video/webm" }));
});
recorder.addEventListener("error", () => {
reject(new Error("MediaRecorder failed during export."));
});
});
try {
recorder.start();
// Kick off audio playback into the destination node at the same wall-clock
// moment as the renderer loop so the captured stream is aligned.
if (audioGraph) {
audioGraph.start();
}
const startedAt = performance.now();
for (let frame = 0; frame < frameCount; frame++) {
if (signal?.cancelled) {
recorder.stop();
await recordingFinished.catch(() => null);
return null;
}
const targetWallTimeMs = startedAt + (frame * 1000) / fpsFloat;
const waitMs = targetWallTimeMs - performance.now();
if (waitMs > 0) {
await sleep({ ms: waitMs });
}
const timeTicks = frame * ticksPerFrame;
await renderer.render({ node: rootNode, time: timeTicks });
onProgress?.(frame / frameCount);
}
// Give the recorder a moment to flush the last frames after rendering ends.
await sleep({ ms: 250 });
recorder.stop();
onProgress?.(1);
const blob = await recordingFinished;
if (!blob) return null;
return await blob.arrayBuffer();
} finally {
if (audioGraph) {
audioGraph.dispose();
}
for (const track of combinedStream.getTracks()) {
track.stop();
}
}
}
interface AudioGraph {
stream: MediaStream;
start: () => void;
dispose: () => void;
}
function createAudioGraphForStream({
audioBuffer,
}: {
audioBuffer: AudioBuffer;
}): AudioGraph {
const audioContext = new AudioContext({
sampleRate: audioBuffer.sampleRate,
});
const destination = audioContext.createMediaStreamDestination();
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(destination);
let started = false;
let disposed = false;
return {
stream: destination.stream,
start() {
if (started || disposed) return;
started = true;
source.start(0);
},
dispose() {
if (disposed) return;
disposed = true;
try {
source.stop();
} catch {
// already stopped
}
source.disconnect();
destination.disconnect();
void audioContext.close();
},
};
}
function sleep({ ms }: { ms: number }): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

View File

@ -0,0 +1,38 @@
/**
* Lightweight feature-detection helpers for the MediaRecorder-based export
* fallback. Kept dependency-free so unit tests don't drag in WASM modules.
*/
export interface MediaRecorderSupport {
supported: boolean;
mimeType: string | null;
}
const MEDIA_RECORDER_CANDIDATE_MIME_TYPES = [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm",
] as const;
export function detectMediaRecorderSupport(): MediaRecorderSupport {
if (typeof MediaRecorder === "undefined") {
return { supported: false, mimeType: null };
}
for (const candidate of MEDIA_RECORDER_CANDIDATE_MIME_TYPES) {
if (MediaRecorder.isTypeSupported(candidate)) {
return { supported: true, mimeType: candidate };
}
}
return { supported: false, mimeType: null };
}
export function isWebCodecsExportSupported(): boolean {
const root = globalThis as {
VideoEncoder?: unknown;
AudioEncoder?: unknown;
};
return (
typeof root.VideoEncoder !== "undefined" &&
typeof root.AudioEncoder !== "undefined"
);
}

View File

@ -19,6 +19,10 @@ import { frameRateToFloat } from "@/fps/utils";
import type { RootNode } from "./nodes/root-node";
import type { ExportFormat, ExportQuality } from "@/export";
import { CanvasRenderer } from "./canvas-renderer";
import {
exportWithMediaRecorder,
isWebCodecsExportSupported,
} from "./media-recorder-exporter";
type ExportParams = {
width: number;
@ -37,6 +41,15 @@ const qualityMap = {
very_high: QUALITY_VERY_HIGH,
};
// Numeric bitrate (bits-per-second) used by the MediaRecorder export path,
// which doesn't accept mediabunny's opaque `Quality` presets.
const mediaRecorderBitrateMap: Record<ExportQuality, number> = {
low: 2_500_000,
medium: 5_000_000,
high: 10_000_000,
very_high: 20_000_000,
};
export type SceneExporterEvents = {
progress: [progress: number];
complete: [buffer: ArrayBuffer];
@ -84,6 +97,10 @@ export class SceneExporter extends EventEmitter<SceneExporterEvents> {
}: {
rootNode: RootNode;
}): Promise<ArrayBuffer | null> {
if (!isWebCodecsExportSupported()) {
return this.exportViaMediaRecorder({ rootNode });
}
const fps = this.renderer.fps;
const fpsFloat = frameRateToFloat(fps);
const ticksPerFrame = Math.round(
@ -168,4 +185,56 @@ export class SceneExporter extends EventEmitter<SceneExporterEvents> {
this.emit("complete", buffer);
return buffer;
}
private async exportViaMediaRecorder({
rootNode,
}: {
rootNode: RootNode;
}): Promise<ArrayBuffer | null> {
try {
const cancellationSignal = { cancelled: false };
const onTickCancel = () => {
if (this.isCancelled) {
cancellationSignal.cancelled = true;
}
};
const buffer = await exportWithMediaRecorder({
renderer: this.renderer,
rootNode,
fps: this.renderer.fps,
bitrate: mediaRecorderBitrateMap[this.quality],
audioBuffer: this.shouldIncludeAudio ? this.audioBuffer : null,
onProgress: (progress) => {
onTickCancel();
this.emit("progress", progress);
},
signal: cancellationSignal,
});
if (cancellationSignal.cancelled) {
this.emit("cancelled");
return null;
}
if (!buffer) {
this.emit("error", new Error("Failed to export video"));
return null;
}
this.emit("complete", buffer);
return buffer;
} catch (error) {
const err =
error instanceof Error
? error
: new Error(
typeof error === "string"
? error
: "MediaRecorder export failed",
);
this.emit("error", err);
return null;
}
}
}