Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 3 additions & 21 deletions src/lib/exporter/forwardFrameSource.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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) => {
Expand All @@ -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(
Expand Down
20 changes: 2 additions & 18 deletions src/lib/exporter/streamingDecoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<VideoFrame | null> => {
if (decodeError) throw decodeError;
Expand Down
77 changes: 77 additions & 0 deletions src/lib/exporter/videoDecoderConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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",
}),
);
});
});
68 changes: 68 additions & 0 deletions src/lib/exporter/videoDecoderConfig.ts
Original file line number Diff line number Diff line change
@@ -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.")
);
Comment thread
bernardopg marked this conversation as resolved.
}

/** 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<VideoDecoderConfig> {
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}`);
}