fix(distributed): reject cfr:true with h265 codec (per review)

The cfr re-encode pass hardcodes `-c:v libx264`. Pairing it with
`codec: "h265"` would silently transcode the h265 chunks to h264.
Detect the encoder discriminant in `meta/encoder.json` and throw a
typed error parallel to the existing non-mp4 format guard, so callers
surface the conflict instead of producing a wrong-codec deliverable.

— Rames Jusso
This commit is contained in:
James
2026-05-24 00:24:30 -04:00
committed by James Russo
parent 71d1889da6
commit 90bf485db8
2 changed files with 61 additions and 1 deletions
@@ -46,6 +46,7 @@ function buildPlanDir(
chunks: ChunkSliceJson[],
totalFrames: number,
hasAudio: boolean,
encoder: "libx264-software" | "libx265-software" = "libx264-software",
): string {
const planDir = mkdtempSync(join(runRoot, `plan-${format}-`));
mkdirSync(join(planDir, "meta"), { recursive: true });
@@ -60,6 +61,10 @@ function buildPlanDir(
"utf-8",
);
writeFileSync(join(planDir, "meta", "chunks.json"), JSON.stringify(chunks), "utf-8");
// Minimal encoder.json — assemble reads this when cfr=true to detect h265
// chunks (the cfr re-encode hardcodes libx264 and would silently transcode
// h265). Tests default to libx264 to match the in-production default.
writeFileSync(join(planDir, "meta", "encoder.json"), JSON.stringify({ encoder }), "utf-8");
return planDir;
}
@@ -462,6 +467,39 @@ describe("assemble()", () => {
TIMEOUT_MS,
);
it(
"cfr:true rejects h265 chunks with a clear error",
async () => {
if (!hasFfmpeg) {
console.warn("[assemble.test] skipping cfr-h265 test — ffmpeg not available");
return;
}
// The cfr re-encode hardcodes `-c:v libx264`; pairing it with h265
// chunks would silently transcode them to h264. Assemble must throw
// a typed error instead of producing a wrong-codec deliverable. We
// stage a plan whose `meta/encoder.json` reports `libx265-software`
// and chunks built with libx264 (the bytes don't matter — the guard
// trips on the encoder discriminant before the re-encode runs).
const chunks: ChunkSliceJson[] = [{ index: 0, startFrame: 0, endFrame: 5 }];
const planDir = buildPlanDir("mp4", chunks, 5, false, "libx265-software");
const chunkPath = join(planDir, "chunk-0.mp4");
makeMp4Chunk(chunkPath, 5);
let caught: unknown;
try {
await assemble(planDir, [chunkPath], null, join(planDir, "out.mp4"), { cfr: true });
} catch (err) {
caught = err;
}
expect(caught).toBeDefined();
expect((caught as Error).message).toContain(
`cfr=true is not yet supported with codec: "h265"`,
);
},
TIMEOUT_MS,
);
it(
"merges png-sequence chunk directories with continuous global numbering",
() => {
@@ -221,7 +221,10 @@ export async function assemble(
// opt-in re-encode with `-fps_mode cfr -r <fps>` lands the stream's
// avg-frame-rate on the requested rational exactly. Restricted to
// mp4 / libx264 — webm and mov go through their own stream-copy
// paths that don't exhibit the same avg-frame-rate drift.
// paths that don't exhibit the same avg-frame-rate drift, and h265
// mp4 would silently transcode to h264 under the hardcoded
// `-c:v libx264` re-encode (a typed throw is preferable to silent
// codec loss).
let postConcatPath = concatOutputPath;
if (cfr) {
if (plan.dimensions.format !== "mp4") {
@@ -231,6 +234,25 @@ export async function assemble(
`already produce exact avg_frame_rate; cfr re-encode is not needed.`,
);
}
// Read `meta/encoder.json` to detect the chunk encoder. The cfr
// re-encode hardcodes `-c:v libx264`; pairing it with h265 chunks
// would silently transcode them to h264. Throw a typed error so the
// caller surfaces the conflict instead of producing a wrong-codec
// deliverable.
const encoderJsonPath = join(planDir, "meta", "encoder.json");
if (!existsSync(encoderJsonPath)) {
throw new Error(`[assemble] planDir missing meta/encoder.json: ${encoderJsonPath}`);
}
const encoderJson = JSON.parse(readFileSync(encoderJsonPath, "utf-8")) as {
encoder?: string;
};
if (encoderJson.encoder === "libx265-software") {
throw new Error(
`[assemble] cfr=true is not yet supported with codec: "h265". The ` +
`cfr re-encode pass uses libx264 and would silently transcode the ` +
`h265 chunks. Either disable cfr or render with codec: "h264".`,
);
}
const cfrOutputPath = join(workDir, `cfr.${plan.dimensions.format}`);
const cfrArgs = [
"-i",