diff --git a/packages/producer/src/regression-harness-distributed.ts b/packages/producer/src/regression-harness-distributed.ts index 6fe3f6582..c62435b23 100644 --- a/packages/producer/src/regression-harness-distributed.ts +++ b/packages/producer/src/regression-harness-distributed.ts @@ -117,6 +117,12 @@ export interface RunDistributedSimulatedInput { /** From the fixture's renderConfig — must pass `checkDistributedSupport`. */ fps: 24 | 30 | 60; format: "mp4" | "mov" | "png-sequence"; + /** + * Codec for `format: "mp4"`. Defaults to `"h264"`; pass `"h265"` to + * exercise the libx265 closed-GOP path. Ignored for non-mp4 formats — + * `plan()` throws if codec is passed with a non-mp4 format. + */ + codec?: "h264" | "h265"; /** Optional chunkSize override; defaults to the plan's 240. */ chunkSize?: number; /** Optional maxParallelChunks override; defaults to the plan's 16. */ @@ -147,23 +153,38 @@ export async function runDistributedSimulatedRender( mkdirSync(planDir, { recursive: true }); mkdirSync(chunksDir, { recursive: true }); - // Step A: plan. + // Step A: plan. `codec` is only forwarded when the format actually + // accepts it — `plan()` throws if codec is set for a non-mp4 format, + // and a caller passing `format: "mov", codec: undefined` would still + // surface that field in the resulting object. We omit it conditionally + // to keep the off-path planDir identical to pre-codec-knob output. const planResult = await plan( input.projectDir, - { - fps: input.fps, - // Required-by-type but overridden by the composition's own attrs; - // see docstring above. Any positive integer works. - width: 1920, - height: 1080, - format: input.format, - chunkSize: input.chunkSize, - maxParallelChunks: input.maxParallelChunks, - // Force the SDR path explicitly — `auto` would still resolve to - // force-sdr in distributed mode, but pinning it here keeps the - // harness's behavior independent of any future auto-detect changes. - hdrMode: "force-sdr", - }, + input.format === "mp4" + ? { + fps: input.fps, + width: 1920, + height: 1080, + format: "mp4", + codec: input.codec, + chunkSize: input.chunkSize, + maxParallelChunks: input.maxParallelChunks, + hdrMode: "force-sdr", + } + : { + fps: input.fps, + // Required-by-type but overridden by the composition's own attrs; + // see docstring above. Any positive integer works. + width: 1920, + height: 1080, + format: input.format, + chunkSize: input.chunkSize, + maxParallelChunks: input.maxParallelChunks, + // Force the SDR path explicitly — `auto` would still resolve to + // force-sdr in distributed mode, but pinning it here keeps the + // harness's behavior independent of any future auto-detect changes. + hdrMode: "force-sdr", + }, planDir, ); diff --git a/packages/producer/src/regression-harness.ts b/packages/producer/src/regression-harness.ts index 296b20c53..776d14145 100644 --- a/packages/producer/src/regression-harness.ts +++ b/packages/producer/src/regression-harness.ts @@ -56,6 +56,17 @@ type TestMetadata = { * time; the in-process renderer accepts it. */ format?: "mp4" | "webm" | "mov" | "png-sequence"; + /** + * Codec selection for `format: "mp4"`, forwarded to + * `DistributedRenderConfig.codec`. The in-process renderer doesn't take + * a codec hint — for the baseline it always picks the format's default + * (h264 for mp4 SDR), so `codec: "h265"` is exercised exclusively in + * `--mode=distributed-simulated`. The PSNR comparison against the + * baseline therefore measures "h265 chunked + concat" ≈ "h264 single- + * pass" rather than byte equality. Fixtures asserting a tighter + * contract should explicitly pin a higher `minPsnr`. + */ + codec?: "h264" | "h265"; workers?: number; // Optional: auto-calculates if omitted /** Force HDR in the harness; omitted/false preserves historical SDR-only test behavior. */ hdr?: boolean; @@ -250,6 +261,25 @@ function validateMetadata(meta: unknown): TestMetadata { "meta.json: 'renderConfig.format' must be 'mp4', 'webm', 'mov', or 'png-sequence' (or omit for mp4)", ); } + if (rc.codec !== undefined && rc.codec !== "h264" && rc.codec !== "h265") { + throw new Error( + "meta.json: 'renderConfig.codec' must be 'h264' or 'h265' (or omit for the format's default)", + ); + } + // Normalize the implicit default before comparing so a fixture that + // omits `format` (which defaults to "mp4" everywhere downstream) doesn't + // get accidentally treated as "format is missing, so codec is illegal." + // The previous formulation `rc.format !== undefined && rc.format !== "mp4"` + // worked but relied on the reader knowing the default; this reads the + // intent more directly. + const effectiveFormat = (rc.format as string | undefined) ?? "mp4"; + if (rc.codec !== undefined && effectiveFormat !== "mp4") { + throw new Error( + `meta.json: 'renderConfig.codec' is only valid for format='mp4' (got format=${JSON.stringify( + rc.format, + )})`, + ); + } 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)"); @@ -822,6 +852,7 @@ async function runTestSuite( renderedOutputPath, fps: fpsNum, format: outputFormat as "mp4" | "mov" | "png-sequence", + codec: suite.meta.renderConfig.codec, chunkSize: suite.meta.renderConfig.chunkSize, maxParallelChunks: suite.meta.renderConfig.maxParallelChunks, variables: suite.meta.renderConfig.variables, diff --git a/packages/producer/tests/distributed/mp4-h265-sdr/meta.json b/packages/producer/tests/distributed/mp4-h265-sdr/meta.json new file mode 100644 index 000000000..6fa3ddfc5 --- /dev/null +++ b/packages/producer/tests/distributed/mp4-h265-sdr/meta.json @@ -0,0 +1,18 @@ +{ + "name": "Distributed: mp4 H.265 SDR", + "description": "60-frame composition (2s @ 30fps) with text, a crossfade transition, and a small rotating SVG icon. renderConfig.format=mp4 + codec=h265 routes the distributed pipeline through libx265 with closed-GOP keyint params (min-keyint=N:scenecut=0:open-gop=0:repeat-headers=1). The in-process baseline is rendered as h264 because the in-process renderer doesn't expose a codec hint — the harness's PSNR comparison therefore measures 'libx265 chunked + concat' against 'libx264 single-pass', catching gross codec failures while accepting normal cross-codec PSNR drift (~30-35 dB at standard quality).", + "tags": ["distributed", "mp4", "h265", "hevc", "sdr"], + + "minPsnr": 30, + "maxFrameFailures": 0, + + "minAudioCorrelation": 0.9, + "maxAudioLagWindows": 120, + + "renderConfig": { + "fps": 30, + "format": "mp4", + "codec": "h265", + "chunkSize": 15 + } +} diff --git a/packages/producer/tests/distributed/mp4-h265-sdr/output/compiled.html b/packages/producer/tests/distributed/mp4-h265-sdr/output/compiled.html new file mode 100644 index 000000000..e3a9ca8f1 --- /dev/null +++ b/packages/producer/tests/distributed/mp4-h265-sdr/output/compiled.html @@ -0,0 +1,121 @@ + + +
+ + +