Skip to content
Merged
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
79 changes: 79 additions & 0 deletions packages/producer/src/services/distributed/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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"/);
});
});
67 changes: 55 additions & 12 deletions packages/producer/src/services/distributed/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -425,7 +436,7 @@ function buildLockedRenderConfig(input: {
runtimeEnv: Record<string, string>;
}): 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,
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions packages/producer/src/services/distributed/renderChunk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
});
23 changes: 22 additions & 1 deletion packages/producer/src/services/distributed/renderChunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
Loading