diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index 1628292c2..28c714200 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { ENCODER_PRESETS } from "./chunkEncoder.js"; +import { ENCODER_PRESETS, getEncoderPreset } from "./chunkEncoder.js"; describe("ENCODER_PRESETS", () => { it("has draft, standard, and high presets", () => { @@ -25,3 +25,40 @@ describe("ENCODER_PRESETS", () => { expect(ENCODER_PRESETS.standard.quality).toBeLessThan(ENCODER_PRESETS.draft.quality); }); }); + +describe("getEncoderPreset", () => { + it("returns h264 with yuv420p for mp4 format", () => { + const preset = getEncoderPreset("standard", "mp4"); + expect(preset.codec).toBe("h264"); + expect(preset.pixelFormat).toBe("yuv420p"); + }); + + it("returns vp9 with yuva420p for webm format", () => { + const preset = getEncoderPreset("standard", "webm"); + expect(preset.codec).toBe("vp9"); + expect(preset.pixelFormat).toBe("yuva420p"); + }); + + it("maps draft ultrafast to vp9 realtime deadline", () => { + const preset = getEncoderPreset("draft", "webm"); + expect(preset.preset).toBe("realtime"); + expect(preset.codec).toBe("vp9"); + }); + + it("maps standard/high to vp9 good deadline", () => { + expect(getEncoderPreset("standard", "webm").preset).toBe("good"); + expect(getEncoderPreset("high", "webm").preset).toBe("good"); + }); + + it("preserves quality values across formats", () => { + for (const q of ["draft", "standard", "high"] as const) { + expect(getEncoderPreset(q, "webm").quality).toBe(ENCODER_PRESETS[q].quality); + } + }); + + it("defaults to mp4 when format is omitted", () => { + const preset = getEncoderPreset("standard"); + expect(preset.codec).toBe("h264"); + expect(preset.pixelFormat).toBe("yuv420p"); + }); +}); diff --git a/packages/producer/src/regression-harness.ts b/packages/producer/src/regression-harness.ts index 41acc51ee..6f30440ec 100644 --- a/packages/producer/src/regression-harness.ts +++ b/packages/producer/src/regression-harness.ts @@ -31,6 +31,7 @@ type TestMetadata = { maxAudioLagWindows: number; renderConfig: { fps: 24 | 30 | 60; + format?: "mp4" | "webm"; // Optional: defaults to "mp4" workers?: number; // Optional: auto-calculates if omitted }; }; @@ -145,6 +146,9 @@ function validateMetadata(meta: unknown): TestMetadata { if (![24, 30, 60].includes(rc.fps as number)) { throw new Error("meta.json: 'renderConfig.fps' must be 24, 30, or 60"); } + if (rc.format !== undefined && rc.format !== "mp4" && rc.format !== "webm") { + throw new Error("meta.json: 'renderConfig.format' must be 'mp4' or 'webm' (or omit for mp4)"); + } if (rc.workers !== undefined) { if (typeof rc.workers !== "number" || rc.workers < 1) { throw new Error("meta.json: 'renderConfig.workers' must be >= 1 (or omit to auto-calculate)"); @@ -306,28 +310,33 @@ function psnrAtCheckpoint( } function extractMonoPcm16(videoPath: string): Int16Array { - const { stdout } = runFfmpeg( - [ - "-hide_banner", - "-loglevel", - "error", - "-i", - videoPath, - "-vn", - "-ac", - "1", - "-ar", - "16000", - "-f", - "s16le", - "-", - ], - `Audio extraction (${videoPath})`, - ); - if (stdout.byteLength < 2) { + try { + const { stdout } = runFfmpeg( + [ + "-hide_banner", + "-loglevel", + "error", + "-i", + videoPath, + "-vn", + "-ac", + "1", + "-ar", + "16000", + "-f", + "s16le", + "-", + ], + `Audio extraction (${videoPath})`, + ); + if (stdout.byteLength < 2) { + return new Int16Array(0); + } + return new Int16Array(stdout.buffer, stdout.byteOffset, Math.floor(stdout.byteLength / 2)); + } catch { + // No audio stream in the video (e.g., WebM without audio) return new Int16Array(0); } - return new Int16Array(stdout.buffer, stdout.byteOffset, Math.floor(stdout.byteLength / 2)); } function buildRmsEnvelope(samples: Int16Array, windowSize = 2048, hopSize = 1024): number[] { @@ -535,12 +544,14 @@ async function runTestSuite( mkdirSync(tempRoot, { recursive: true }); const tempDownloadDir = join(tempRoot, "downloads"); - const renderedOutputPath = join(tempRoot, "output.mp4"); + const outputFormat = suite.meta.renderConfig.format ?? "mp4"; + const videoExt = outputFormat === "webm" ? ".webm" : ".mp4"; + const renderedOutputPath = join(tempRoot, `output${videoExt}`); // Snapshot files stored in test's output/ directory const snapshotDir = join(suite.dir, "output"); const snapshotCompiledPath = join(snapshotDir, "compiled.html"); - const snapshotVideoPath = join(snapshotDir, "output.mp4"); + const snapshotVideoPath = join(snapshotDir, `output${videoExt}`); console.log(JSON.stringify({ event: "test_start", suite: suite.id, name: suite.meta.name })); logPretty(`Running test: ${suite.meta.name}`, "🧪"); @@ -626,6 +637,7 @@ async function runTestSuite( const job = createRenderJob({ fps: suite.meta.renderConfig.fps, quality: "high", // Always use max quality for tests + format: outputFormat, workers: suite.meta.renderConfig.workers, useGpu: false, debug: false, @@ -643,7 +655,11 @@ async function runTestSuite( } copyFileSync(renderedOutputPath, snapshotVideoPath); console.log( - JSON.stringify({ event: "snapshot_updated", suite: suite.id, file: "output/output.mp4" }), + JSON.stringify({ + event: "snapshot_updated", + suite: suite.id, + file: `output/output${videoExt}`, + }), ); result.visual = { passed: true, failedFrames: 0, checkpoints: [] }; result.audio = { passed: true, correlation: 1, lagWindows: 0 }; diff --git a/packages/producer/tests/webm-transparency/meta.json b/packages/producer/tests/webm-transparency/meta.json new file mode 100644 index 000000000..709f10927 --- /dev/null +++ b/packages/producer/tests/webm-transparency/meta.json @@ -0,0 +1,14 @@ +{ + "name": "webm-transparency", + "description": "WebM VP9 render with transparent background (alpha channel). Validates the full WebM pipeline: PNG capture, VP9 encoding with yuva420p, and alpha_mode=1 metadata.", + "tags": ["webm", "transparency", "fast"], + "minPsnr": 30, + "maxFrameFailures": 2, + "minAudioCorrelation": 0.0, + "maxAudioLagWindows": 120, + "renderConfig": { + "fps": 30, + "format": "webm", + "workers": 1 + } +} diff --git a/packages/producer/tests/webm-transparency/output/compiled.html b/packages/producer/tests/webm-transparency/output/compiled.html new file mode 100644 index 000000000..253f5fdd1 --- /dev/null +++ b/packages/producer/tests/webm-transparency/output/compiled.html @@ -0,0 +1,49 @@ + + + + + + + +
+ +
+ + +
Overlay Test
+ + + +
+ + diff --git a/packages/producer/tests/webm-transparency/output/output.webm b/packages/producer/tests/webm-transparency/output/output.webm new file mode 100644 index 000000000..e9f89d398 Binary files /dev/null and b/packages/producer/tests/webm-transparency/output/output.webm differ diff --git a/packages/producer/tests/webm-transparency/src/index.html b/packages/producer/tests/webm-transparency/src/index.html new file mode 100644 index 000000000..fc5ed22b4 --- /dev/null +++ b/packages/producer/tests/webm-transparency/src/index.html @@ -0,0 +1,71 @@ + + + + + + + +
+ +
+ + +
Overlay Test
+ + + +
+ +