mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user