From a146c9e527f96194cfb2324e70f8768c0e363ecd Mon Sep 17 00:00:00 2001 From: James Date: Fri, 27 Mar 2026 02:19:43 +0000 Subject: [PATCH] test(render): add WebM regression test and getEncoderPreset unit tests - New regression test `webm-transparency`: minimal transparent composition rendered to WebM, validates VP9 codec and visual quality at 100 checkpoints against golden baseline - Extend regression harness: `renderConfig.format` field ("mp4" | "webm"), format-aware output paths and snapshot filenames - Fix `extractMonoPcm16` to gracefully handle videos without audio streams (WebM without audio was throwing instead of returning empty) - Unit tests for `getEncoderPreset()`: VP9/yuva420p for WebM, h264/yuv420p for MP4, preset mapping, quality preservation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../engine/src/services/chunkEncoder.test.ts | 39 +++++++++- packages/producer/src/regression-harness.ts | 62 +++++++++------ .../tests/webm-transparency/meta.json | 14 ++++ .../webm-transparency/output/compiled.html | 49 ++++++++++++ .../webm-transparency/output/output.webm | Bin 0 -> 20997 bytes .../tests/webm-transparency/src/index.html | 71 ++++++++++++++++++ 6 files changed, 211 insertions(+), 24 deletions(-) create mode 100644 packages/producer/tests/webm-transparency/meta.json create mode 100644 packages/producer/tests/webm-transparency/output/compiled.html create mode 100644 packages/producer/tests/webm-transparency/output/output.webm create mode 100644 packages/producer/tests/webm-transparency/src/index.html 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 0000000000000000000000000000000000000000..e9f89d3980b0a270f376420cf395e58f8573848a GIT binary patch literal 20997 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

rb?1eeAd1p|7-PP-eEuw!` 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 kTOvX+Z9R3|b`NwA + + + + + + +

+ +
+ + +
Overlay Test
+ + + +
+ +