diff --git a/src/lib/exporter/forwardFrameSource.ts b/src/lib/exporter/forwardFrameSource.ts index 34fbc1f2..8ac00d61 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 861d6720..f2573343 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 00000000..ad36486f --- /dev/null +++ b/src/lib/exporter/videoDecoderConfig.test.ts @@ -0,0 +1,77 @@ +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.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, + codedWidth: 1920, + codedHeight: 1080, + }); + + expect(configure).toHaveBeenCalledWith( + expect.objectContaining({ + codec: codec === "vp09" ? "vp9" : codec, + hardwareAcceleration: "prefer-software", + }), + ); + }); +}); diff --git a/src/lib/exporter/videoDecoderConfig.ts b/src/lib/exporter/videoDecoderConfig.ts new file mode 100644 index 00000000..c76b703c --- /dev/null +++ b/src/lib/exporter/videoDecoderConfig.ts @@ -0,0 +1,68 @@ +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; + + 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 }; +} + +/** 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.startsWith("vp09.") + ); +} + +/** Builds decoder candidates in preference and fallback order. */ +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; +} + +/** Configures a decoder with the first supported normalized codec candidate. */ +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}`); +}