diff --git a/packages/producer/src/services/distributed/plan.test.ts b/packages/producer/src/services/distributed/plan.test.ts index 91e87d59f..42efb383b 100644 --- a/packages/producer/src/services/distributed/plan.test.ts +++ b/packages/producer/src/services/distributed/plan.test.ts @@ -209,3 +209,82 @@ describe("plan() — golden planDir + planHash determinism", () => { TIMEOUT_MS, ); }); + +describe("plan() — codec knob", () => { + const TIMEOUT_MS = 30_000; + + it( + "defaults `codec` to h264 (libx264-software) for mp4", + async () => { + const planDir = join(runRoot, "plan-codec-default"); + mkdirSync(planDir, { recursive: true }); + await plan(projectDir, { fps: 30, width: 320, height: 240, format: "mp4" }, planDir); + const encoder = JSON.parse( + readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"), + ) as Record; + expect(encoder.encoder).toBe("libx264-software"); + expect(encoder.pixelFormat).toBe("yuv420p"); + }, + TIMEOUT_MS, + ); + + it( + 'maps `codec: "h265"` to libx265-software for mp4', + async () => { + const planDir = join(runRoot, "plan-codec-h265"); + mkdirSync(planDir, { recursive: true }); + await plan( + projectDir, + { fps: 30, width: 320, height: 240, format: "mp4", codec: "h265" }, + planDir, + ); + const encoder = JSON.parse( + readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"), + ) as Record; + expect(encoder.encoder).toBe("libx265-software"); + // SDR 8-bit yuv420p, same as h264. Distributed mode is SDR-only — + // anyone reading this and tempted to bump to 10-bit, that's HDR + // territory and lives in v1.5. + expect(encoder.pixelFormat).toBe("yuv420p"); + }, + TIMEOUT_MS, + ); + + it("rejects `codec` with format other than mp4", async () => { + const planDir = join(runRoot, "plan-codec-bad-format"); + mkdirSync(planDir, { recursive: true }); + let caught: unknown; + try { + await plan( + projectDir, + // @ts-expect-error — runtime check is the test's purpose. + { fps: 30, width: 320, height: 240, format: "mov", codec: "h265" }, + planDir, + ); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toMatch(/codec.*only valid for format="mp4"/); + }); + + it("rejects unknown codec strings for format=mp4 (no silent fall-through to h264)", async () => { + const planDir = join(runRoot, "plan-codec-unknown"); + mkdirSync(planDir, { recursive: true }); + let caught: unknown; + try { + await plan( + projectDir, + // @ts-expect-error — runtime check is the test's purpose. Catches + // typos ("H265") and future codec additions ("av1") that a JS + // caller building config from JSON might pass. + { fps: 30, width: 320, height: 240, format: "mp4", codec: "h266" }, + planDir, + ); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toMatch(/codec must be "h264" or "h265"/); + }); +}); diff --git a/packages/producer/src/services/distributed/plan.ts b/packages/producer/src/services/distributed/plan.ts index d9a3e6f8c..40d7d979b 100644 --- a/packages/producer/src/services/distributed/plan.ts +++ b/packages/producer/src/services/distributed/plan.ts @@ -80,6 +80,17 @@ export interface DistributedRenderConfig { * supports both. */ format: "mp4" | "mov" | "png-sequence"; + /** + * Codec selection for `format: "mp4"`. `"h264"` (the default) → libx264 + + * yuv420p; `"h265"` → libx265 + yuv420p with closed-GOP keyint params + * (`min-keyint=N:scenecut=0:open-gop=0:repeat-headers=1`) so chunked + * concat-copy round-trips losslessly the same way h264 does. Ignored for + * `format: "mov"` (always ProRes 4444) and `format: "png-sequence"` + * (no encoder). Passing `codec` with a non-mp4 format throws at plan + * time so caller errors surface immediately rather than producing a + * silently-wrong planDir. + */ + codec?: "h264" | "h265"; quality?: "draft" | "standard" | "high"; /** Constant-rate-factor override; mutually exclusive with `bitrate`. */ crf?: number; @@ -425,7 +436,7 @@ function buildLockedRenderConfig(input: { runtimeEnv: Record; }): LockedRenderConfig { const { config, forceScreenshot, deviceScaleFactor, ffmpegVersion } = input; - const { encoder, pixelFormat, preset } = FORMAT_ENCODER_TABLE[config.format]; + const { encoder, pixelFormat, preset } = resolveEncoderTriple(config); return { captureMode: forceScreenshot ? "screenshot" : "beginframe", forceScreenshot, @@ -455,18 +466,50 @@ function buildLockedRenderConfig(input: { } /** - * Per-format encoder + pixel-format + preset triple. Distributed mode is - * SDR-only: H.264 8-bit for mp4, ProRes 4444 for mov, raw RGBA for - * png-sequence. + * Resolve the encoder + pixel-format + preset triple for a distributed + * render. Distributed mode is SDR-only: H.264 or H.265 8-bit for mp4, + * ProRes 4444 for mov, raw RGBA for png-sequence. + * + * `config.codec` is consulted only when `config.format === "mp4"`. Passing + * `codec` with a non-mp4 format throws at plan time — surfaces the + * caller error immediately rather than producing a silently-wrong planDir + * whose chunk worker would override the codec choice. */ -const FORMAT_ENCODER_TABLE: Record< - DistributedRenderConfig["format"], - { encoder: LockedRenderConfig["encoder"]; pixelFormat: string; preset: string } -> = { - mp4: { encoder: "libx264-software", pixelFormat: "yuv420p", preset: "medium" }, - mov: { encoder: "prores-software", pixelFormat: "yuva444p10le", preset: "4444" }, - "png-sequence": { encoder: "png-sequence", pixelFormat: "rgba", preset: "lossless" }, -}; +function resolveEncoderTriple(config: DistributedRenderConfig): { + encoder: LockedRenderConfig["encoder"]; + pixelFormat: string; + preset: string; +} { + if (config.format === "mp4") { + const codec = config.codec ?? "h264"; + // Explicit unknown-codec throw rather than silent fall-through to h264. + // A JS caller building config from JSON who passes `codec: "h266"` or + // `codec: "H265"` (typo / wrong case) would otherwise produce h264 + // output with no signal. The non-mp4-format branch below already throws + // for the symmetric "wrong combination" case — match that shape. + if (codec !== "h264" && codec !== "h265") { + throw new Error( + `[plan] DistributedRenderConfig.codec must be "h264" or "h265" for format="mp4"; ` + + `received ${JSON.stringify(codec)}. Omit codec to default to h264.`, + ); + } + if (codec === "h265") { + return { encoder: "libx265-software", pixelFormat: "yuv420p", preset: "medium" }; + } + return { encoder: "libx264-software", pixelFormat: "yuv420p", preset: "medium" }; + } + if (config.codec !== undefined) { + throw new Error( + `[plan] DistributedRenderConfig.codec is only valid for format="mp4"; received ` + + `codec=${JSON.stringify(config.codec)} with format=${JSON.stringify(config.format)}. ` + + `Omit codec for non-mp4 formats — mov is always ProRes 4444 and png-sequence has no encoder.`, + ); + } + if (config.format === "mov") { + return { encoder: "prores-software", pixelFormat: "yuva444p10le", preset: "4444" }; + } + return { encoder: "png-sequence", pixelFormat: "rgba", preset: "lossless" }; +} /** * Activity A of the distributed render pipeline. Produces a self-contained diff --git a/packages/producer/src/services/distributed/renderChunk.test.ts b/packages/producer/src/services/distributed/renderChunk.test.ts index 375ff6ec1..61e79c5ea 100644 --- a/packages/producer/src/services/distributed/renderChunk.test.ts +++ b/packages/producer/src/services/distributed/renderChunk.test.ts @@ -29,6 +29,7 @@ import { PLAN_HASH_MISMATCH, renderChunk, RenderChunkValidationError, + resolvePresetForLockedEncoder, } from "./renderChunk.js"; // Tiny fixture: 5 frames at 30fps. Captures finish in a few seconds on the @@ -309,3 +310,41 @@ describe("renderChunk()", () => { TIMEOUT_MS, ); }); + +describe("resolvePresetForLockedEncoder", () => { + // Tiny fast tests for the codec-override helper. No Chrome, no ffmpeg — + // exists so a refactor that moves the override (e.g. into + // `getEncoderPreset` itself) gets caught here before the heavyweight + // Docker fixture is even run. + it("flips codec from h264 to h265 when encoder is libx265-software", () => { + const base = { preset: "medium", quality: 18, codec: "h264" as const, pixelFormat: "yuv420p" }; + const out = resolvePresetForLockedEncoder(base, "libx265-software"); + expect(out.codec).toBe("h265"); + expect(out.preset).toBe("medium"); + expect(out.quality).toBe(18); + expect(out.pixelFormat).toBe("yuv420p"); + }); + + it("leaves the preset unchanged for libx264-software", () => { + const base = { preset: "medium", quality: 18, codec: "h264" as const, pixelFormat: "yuv420p" }; + const out = resolvePresetForLockedEncoder(base, "libx264-software"); + expect(out).toBe(base); + }); + + it("leaves the preset unchanged for prores-software", () => { + const base = { + preset: "4444", + quality: 18, + codec: "prores" as const, + pixelFormat: "yuva444p10le", + }; + const out = resolvePresetForLockedEncoder(base, "prores-software"); + expect(out).toBe(base); + }); + + it("leaves the preset unchanged for png-sequence", () => { + const base = { preset: "medium", quality: 18, codec: "h264" as const, pixelFormat: "yuv420p" }; + const out = resolvePresetForLockedEncoder(base, "png-sequence"); + expect(out).toBe(base); + }); +}); diff --git a/packages/producer/src/services/distributed/renderChunk.ts b/packages/producer/src/services/distributed/renderChunk.ts index 17bee2f33..0cc494d8b 100644 --- a/packages/producer/src/services/distributed/renderChunk.ts +++ b/packages/producer/src/services/distributed/renderChunk.ts @@ -290,6 +290,26 @@ function hashChunkOutput(outputPath: string, kind: "file" | "frame-dir"): string return sha256Hex(lines.join("\0")); } +/** + * Apply the planDir's locked-encoder choice on top of an + * `EncoderPreset` from `getEncoderPreset`. `getEncoderPreset` returns + * h265 only on the HDR branch, but distributed mode is SDR-only — for + * an `libx265-software` planDir we still need to flip the preset's + * codec to h265 so `runEncodeStage` invokes libx265. Exported so a + * unit test can pin the override independently of the heavyweight + * Docker fixture: a refactor that moves the override (e.g. into + * `getEncoderPreset` itself) shouldn't be able to silently regress + * the contract without a fast-test signal. + */ +export function resolvePresetForLockedEncoder< + P extends { codec: "h264" | "h265" | "vp9" | "prores" }, +>(basePreset: P, lockedEncoder: LockedRenderConfig["encoder"]): P { + if (lockedEncoder === "libx265-software") { + return { ...basePreset, codec: "h265" as const }; + } + return basePreset; +} + /** * Activity B: render a single chunk of the planDir. The `outputChunkPath` * argument is a file for mp4/mov outputs and a directory for png-sequence @@ -552,7 +572,8 @@ export async function renderChunk( const presetFormat: "mp4" | "mov" | "webm" = isPngSequence ? "mp4" : (plan.dimensions.format as "mp4" | "mov"); - const preset = getEncoderPreset(job.config.quality, presetFormat, undefined); + const basePreset = getEncoderPreset(job.config.quality, presetFormat, undefined); + const preset = resolvePresetForLockedEncoder(basePreset, encoder.encoder); const effectiveQuality = encoder.crf ?? preset.quality; const effectiveBitrate = encoder.crf != null ? undefined : encoder.bitrate; // For non-pngseq, encodeStage writes to `outputPath` when `isPngSequence`