From 90bf485db80f7d6dabbbc3443c387497ef5b2939 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 24 May 2026 03:33:18 +0000 Subject: [PATCH] fix(distributed): reject cfr:true with h265 codec (per review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/services/distributed/assemble.test.ts | 38 +++++++++++++++++++ .../src/services/distributed/assemble.ts | 24 +++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/producer/src/services/distributed/assemble.test.ts b/packages/producer/src/services/distributed/assemble.test.ts index d38a9b53c..74599f8e4 100644 --- a/packages/producer/src/services/distributed/assemble.test.ts +++ b/packages/producer/src/services/distributed/assemble.test.ts @@ -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", () => { diff --git a/packages/producer/src/services/distributed/assemble.ts b/packages/producer/src/services/distributed/assemble.ts index 741f32e24..214cfc451 100644 --- a/packages/producer/src/services/distributed/assemble.ts +++ b/packages/producer/src/services/distributed/assemble.ts @@ -221,7 +221,10 @@ export async function assemble( // opt-in re-encode with `-fps_mode cfr -r ` 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",