From b1b3b1c9d1e3e4a384983435e08b5a6a588e0724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Villagr=C3=A1n?= Date: Sat, 16 May 2026 21:47:34 -0400 Subject: [PATCH] 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. --- .../__tests__/media-recorder-exporter.test.ts | 96 +++++++++ .../renderer/media-recorder-exporter.ts | 195 ++++++++++++++++++ .../renderer/media-recorder-support.ts | 38 ++++ .../src/services/renderer/scene-exporter.ts | 69 +++++++ 4 files changed, 398 insertions(+) create mode 100644 apps/web/src/services/renderer/__tests__/media-recorder-exporter.test.ts create mode 100644 apps/web/src/services/renderer/media-recorder-exporter.ts create mode 100644 apps/web/src/services/renderer/media-recorder-support.ts diff --git a/apps/web/src/services/renderer/__tests__/media-recorder-exporter.test.ts b/apps/web/src/services/renderer/__tests__/media-recorder-exporter.test.ts new file mode 100644 index 00000000..fb6847d0 --- /dev/null +++ b/apps/web/src/services/renderer/__tests__/media-recorder-exporter.test.ts @@ -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, + }); + }); +}); diff --git a/apps/web/src/services/renderer/media-recorder-exporter.ts b/apps/web/src/services/renderer/media-recorder-exporter.ts new file mode 100644 index 00000000..d62c9450 --- /dev/null +++ b/apps/web/src/services/renderer/media-recorder-exporter.ts @@ -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 `