From 1fa519721bf1e085c18d4006ceb4201709acf038 Mon Sep 17 00:00:00 2001 From: Bernardo Gomes Date: Thu, 11 Jun 2026 17:59:04 -0300 Subject: [PATCH 1/2] fix(export): normalize WebCodecs decoder codec strings --- src/lib/exporter/forwardFrameSource.ts | 24 +------ src/lib/exporter/streamingDecoder.ts | 20 +----- src/lib/exporter/videoDecoderConfig.test.ts | 74 +++++++++++++++++++++ src/lib/exporter/videoDecoderConfig.ts | 63 ++++++++++++++++++ 4 files changed, 142 insertions(+), 39 deletions(-) create mode 100644 src/lib/exporter/videoDecoderConfig.test.ts create mode 100644 src/lib/exporter/videoDecoderConfig.ts diff --git a/src/lib/exporter/forwardFrameSource.ts b/src/lib/exporter/forwardFrameSource.ts index 34fbc1f29..8ac00d614 100644 --- a/src/lib/exporter/forwardFrameSource.ts +++ b/src/lib/exporter/forwardFrameSource.ts @@ -1,10 +1,8 @@ import { WebDemuxer } from "web-demuxer"; import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming"; +import { createReadableMediaResourceFile, resolveMediaResourceUrl } from "./localMediaSource"; import { getDecodedFrameTimelineOffsetUs } from "./streamingDecoder"; -import { - createReadableMediaResourceFile, - resolveMediaResourceUrl, -} from "./localMediaSource"; +import { configureVideoDecoder } from "./videoDecoderConfig"; const DEFAULT_MAX_DECODE_QUEUE = 12; const DEFAULT_MAX_PENDING_FRAMES = 32; @@ -108,8 +106,6 @@ export class ForwardFrameSource { } const decoderConfig = await this.demuxer.getDecoderConfig("video"); - const codec = this.metadata.codec.toLowerCase(); - const shouldPreferSoftwareDecode = codec.includes("av01") || codec.includes("av1"); this.decoder = new VideoDecoder({ output: (frame: VideoFrame) => { @@ -133,21 +129,7 @@ export class ForwardFrameSource { }, }); - const preferredDecoderConfig = shouldPreferSoftwareDecode - ? { - ...decoderConfig, - hardwareAcceleration: "prefer-software" as const, - } - : decoderConfig; - - try { - this.decoder.configure(preferredDecoderConfig); - } catch (error) { - if (!shouldPreferSoftwareDecode) { - throw error; - } - this.decoder.configure(decoderConfig); - } + await configureVideoDecoder(this.decoder, decoderConfig); const readEndSec = Math.max( diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 861d6720a..f25733432 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -2,6 +2,7 @@ import { WebDemuxer } from "web-demuxer"; import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types"; import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming"; import { createReadableMediaResourceFile, resolveMediaResourceUrl } from "./localMediaSource"; +import { configureVideoDecoder } from "./videoDecoderConfig"; const DEFAULT_MAX_DECODE_QUEUE = 12; const DEFAULT_MAX_PENDING_FRAMES = 32; @@ -203,8 +204,6 @@ export class StreamingVideoDecoder { } const decoderConfig = await this.demuxer.getDecoderConfig("video"); - const codec = this.metadata.codec.toLowerCase(); - const shouldPreferSoftwareDecode = codec.includes("av01") || codec.includes("av1"); const effectiveVideoDuration = getEffectiveVideoStreamDurationSeconds({ duration: this.metadata.duration, streamDuration: this.metadata.streamDuration, @@ -282,22 +281,7 @@ export class StreamingVideoDecoder { notifyBackpressureProgress(); }, }); - const preferredDecoderConfig = shouldPreferSoftwareDecode - ? { - ...decoderConfig, - hardwareAcceleration: "prefer-software" as const, - } - : decoderConfig; - - try { - this.decoder.configure(preferredDecoderConfig); - } catch (error) { - if (!shouldPreferSoftwareDecode) { - throw error; - } - // Fall back to default decoder config if software preference is unsupported. - this.decoder.configure(decoderConfig); - } + await configureVideoDecoder(this.decoder, decoderConfig); const getNextFrame = (): Promise => { if (decodeError) throw decodeError; diff --git a/src/lib/exporter/videoDecoderConfig.test.ts b/src/lib/exporter/videoDecoderConfig.test.ts new file mode 100644 index 000000000..917bc4755 --- /dev/null +++ b/src/lib/exporter/videoDecoderConfig.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + configureVideoDecoder, + FALLBACK_H264_CODEC, + normalizeVideoDecoderConfig, +} from "./videoDecoderConfig"; + +describe("normalizeVideoDecoderConfig", () => { + it.each([ + ["av01", "av01.0.01M.08"], + ["h264", FALLBACK_H264_CODEC], + ["avc1", FALLBACK_H264_CODEC], + ["vp08", "vp8"], + ["vp09", "vp9"], + ])("normalizes %s to %s", (codec, expected) => { + expect(normalizeVideoDecoderConfig({ codec }).codec).toBe(expected); + }); + + it("preserves complete codec strings and decoder metadata", () => { + const description = new Uint8Array([1, 2, 3]); + const config: VideoDecoderConfig = { + codec: "avc1.42c032", + codedWidth: 1920, + codedHeight: 1080, + description, + }; + + expect(normalizeVideoDecoderConfig(config)).toBe(config); + }); +}); + +describe("configureVideoDecoder", () => { + const configure = vi.fn(); + const isConfigSupported = vi.fn(); + + beforeEach(() => { + configure.mockReset(); + isConfigSupported.mockReset(); + vi.stubGlobal("VideoDecoder", { isConfigSupported }); + }); + + it("falls back when a malformed AVC codec string is unsupported", async () => { + isConfigSupported.mockImplementation(async (config: VideoDecoderConfig) => ({ + supported: config.codec === FALLBACK_H264_CODEC, + config, + })); + + const configured = await configureVideoDecoder({ configure } as unknown as VideoDecoder, { + codec: "avc1.2420032", + codedWidth: 2048, + codedHeight: 1152, + }); + + expect(configured.codec).toBe(FALLBACK_H264_CODEC); + expect(configure).toHaveBeenCalledOnce(); + expect(configure).toHaveBeenCalledWith( + expect.objectContaining({ codec: FALLBACK_H264_CODEC }), + ); + }); + + it("tries software decoding first for VP9", async () => { + isConfigSupported.mockResolvedValue({ supported: true }); + + await configureVideoDecoder({ configure } as unknown as VideoDecoder, { + codec: "vp09", + codedWidth: 1920, + codedHeight: 1080, + }); + + expect(configure).toHaveBeenCalledWith( + expect.objectContaining({ codec: "vp9", hardwareAcceleration: "prefer-software" }), + ); + }); +}); diff --git a/src/lib/exporter/videoDecoderConfig.ts b/src/lib/exporter/videoDecoderConfig.ts new file mode 100644 index 000000000..25fd931ea --- /dev/null +++ b/src/lib/exporter/videoDecoderConfig.ts @@ -0,0 +1,63 @@ +export const FALLBACK_H264_CODEC = "avc1.640033"; + +export function normalizeVideoDecoderConfig(config: VideoDecoderConfig): VideoDecoderConfig { + let codec = config.codec; + + if (/^av01$/i.test(codec)) codec = "av01.0.01M.08"; + if (/^vp08$/i.test(codec)) codec = "vp8"; + if (/^vp09$/i.test(codec)) codec = "vp9"; + if (/^(avc1|h264)$/i.test(codec)) codec = FALLBACK_H264_CODEC; + + return codec === config.codec ? config : { ...config, codec }; +} + +function prefersSoftwareDecode(codec: string): boolean { + const normalizedCodec = codec.toLowerCase(); + return ( + normalizedCodec.includes("av01") || + normalizedCodec.includes("av1") || + normalizedCodec === "vp9" + ); +} + +function decoderConfigCandidates(config: VideoDecoderConfig): VideoDecoderConfig[] { + const normalizedConfig = normalizeVideoDecoderConfig(config); + const candidates: VideoDecoderConfig[] = []; + + if (prefersSoftwareDecode(normalizedConfig.codec)) { + candidates.push({ ...normalizedConfig, hardwareAcceleration: "prefer-software" }); + } + + candidates.push(normalizedConfig); + + if ( + /^avc1/i.test(normalizedConfig.codec) && + normalizedConfig.codec.toLowerCase() !== FALLBACK_H264_CODEC + ) { + candidates.push({ ...normalizedConfig, codec: FALLBACK_H264_CODEC }); + } + + return candidates; +} + +export async function configureVideoDecoder( + decoder: VideoDecoder, + config: VideoDecoderConfig, +): Promise { + let lastError: unknown; + + for (const candidate of decoderConfigCandidates(config)) { + try { + const support = await VideoDecoder.isConfigSupported(candidate); + if (!support.supported) continue; + + decoder.configure(candidate); + return candidate; + } catch (error) { + lastError = error; + } + } + + if (lastError instanceof Error) throw lastError; + throw new Error(`Unsupported video codec: ${config.codec}`); +} From 41017b0df5307e6c41c158740f1288032ef6ea73 Mon Sep 17 00:00:00 2001 From: Bernardo Gomes Date: Thu, 11 Jun 2026 18:07:33 -0300 Subject: [PATCH 2/2] fix: apply CodeRabbit review feedback --- src/lib/exporter/videoDecoderConfig.test.ts | 9 ++++++--- src/lib/exporter/videoDecoderConfig.ts | 7 ++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/lib/exporter/videoDecoderConfig.test.ts b/src/lib/exporter/videoDecoderConfig.test.ts index 917bc4755..ad36486f3 100644 --- a/src/lib/exporter/videoDecoderConfig.test.ts +++ b/src/lib/exporter/videoDecoderConfig.test.ts @@ -58,17 +58,20 @@ describe("configureVideoDecoder", () => { ); }); - it("tries software decoding first for VP9", async () => { + it.each(["vp09", "vp09.00.10.08"])("tries software decoding first for %s", async (codec) => { isConfigSupported.mockResolvedValue({ supported: true }); await configureVideoDecoder({ configure } as unknown as VideoDecoder, { - codec: "vp09", + codec, codedWidth: 1920, codedHeight: 1080, }); expect(configure).toHaveBeenCalledWith( - expect.objectContaining({ codec: "vp9", hardwareAcceleration: "prefer-software" }), + expect.objectContaining({ + codec: codec === "vp09" ? "vp9" : codec, + hardwareAcceleration: "prefer-software", + }), ); }); }); diff --git a/src/lib/exporter/videoDecoderConfig.ts b/src/lib/exporter/videoDecoderConfig.ts index 25fd931ea..c76b703ca 100644 --- a/src/lib/exporter/videoDecoderConfig.ts +++ b/src/lib/exporter/videoDecoderConfig.ts @@ -1,5 +1,6 @@ export const FALLBACK_H264_CODEC = "avc1.640033"; +/** Normalizes demuxer codec aliases into WebCodecs-compatible identifiers. */ export function normalizeVideoDecoderConfig(config: VideoDecoderConfig): VideoDecoderConfig { let codec = config.codec; @@ -11,15 +12,18 @@ export function normalizeVideoDecoderConfig(config: VideoDecoderConfig): VideoDe return codec === config.codec ? config : { ...config, codec }; } +/** Returns whether software decoding should be attempted before the default decoder path. */ function prefersSoftwareDecode(codec: string): boolean { const normalizedCodec = codec.toLowerCase(); return ( normalizedCodec.includes("av01") || normalizedCodec.includes("av1") || - normalizedCodec === "vp9" + normalizedCodec === "vp9" || + normalizedCodec.startsWith("vp09.") ); } +/** Builds decoder candidates in preference and fallback order. */ function decoderConfigCandidates(config: VideoDecoderConfig): VideoDecoderConfig[] { const normalizedConfig = normalizeVideoDecoderConfig(config); const candidates: VideoDecoderConfig[] = []; @@ -40,6 +44,7 @@ function decoderConfigCandidates(config: VideoDecoderConfig): VideoDecoderConfig return candidates; } +/** Configures a decoder with the first supported normalized codec candidate. */ export async function configureVideoDecoder( decoder: VideoDecoder, config: VideoDecoderConfig,