diff --git a/packages/producer/src/services/distributed/assemble.test.ts b/packages/producer/src/services/distributed/assemble.test.ts index 72af446b8e..58c6d96104 100644 --- a/packages/producer/src/services/distributed/assemble.test.ts +++ b/packages/producer/src/services/distributed/assemble.test.ts @@ -35,7 +35,7 @@ import { renderProvenanceArgs, } from "@hyperframes/engine"; import type { ChunkSliceJson } from "../render/stages/freezePlan.js"; -import { assemble } from "./assemble.js"; +import { assemble, cfrRateControlArgs } from "./assemble.js"; import type { DistributedFormat } from "./shared.js"; let runRoot: string; @@ -550,6 +550,50 @@ describe("assemble()", () => { TIMEOUT_MS, ); + it("cfr re-encode keeps the chunks' rate control", () => { + expect(cfrRateControlArgs({})).toEqual(["-preset", "medium", "-crf", "18"]); + expect(cfrRateControlArgs({ quality: "high" })).toEqual(["-preset", "slow", "-crf", "15"]); + expect(cfrRateControlArgs({ quality: "standard", crf: 12 })).toEqual([ + "-preset", + "medium", + "-crf", + "12", + ]); + expect(cfrRateControlArgs({ bitrate: "10M" })).toEqual(["-preset", "medium", "-b:v", "10M"]); + expect(cfrRateControlArgs({ crf: 12, bitrate: "10M" })).toEqual([ + "-preset", + "medium", + "-crf", + "12", + ]); + }); + + it( + "cfr:true output size follows the locked crf", + async () => { + if (!hasFfmpeg) { + console.warn("[assemble.test] skipping cfr crf test — ffmpeg not available on this host"); + return; + } + const sizeAt = async (crf: number) => { + const chunks: ChunkSliceJson[] = [{ index: 0, startFrame: 0, endFrame: 10 }]; + const planDir = buildPlanDir("mp4", chunks, 10, false); + writeFileSync( + join(planDir, "meta", "encoder.json"), + JSON.stringify({ encoder: "libx264-software", quality: "standard", crf }), + "utf-8", + ); + const chunkPath = join(planDir, "chunk-0.mp4"); + makeMp4Chunk(chunkPath, 10); + const outputPath = join(planDir, `output-crf${crf}.mp4`); + await assemble(planDir, [chunkPath], null, outputPath, { cfr: true }); + return statSync(outputPath).size; + }; + expect(await sizeAt(10)).toBeGreaterThan(await sizeAt(40)); + }, + TIMEOUT_MS, + ); + it( "cfr:true rejects non-mp4 formats with a clear error", async () => { diff --git a/packages/producer/src/services/distributed/assemble.ts b/packages/producer/src/services/distributed/assemble.ts index 81f2fb65a9..4780915436 100644 --- a/packages/producer/src/services/distributed/assemble.ts +++ b/packages/producer/src/services/distributed/assemble.ts @@ -39,6 +39,7 @@ import { appendRenderProvenanceArgs, applyFaststart, describeFfmpegFailure, + getEncoderPreset, MIXED_AUDIO_FILENAME, muxVideoWithAudio, resolveConfig, @@ -49,7 +50,7 @@ import { defaultLogger, type ProducerLogger } from "../../logger.js"; import { formatExportFrameName } from "../../utils/paths.js"; import { padOrTrimAudioToVideoFrameCount } from "../render/audioPadTrim.js"; import { encoderFailureError } from "../render/encoderInterruption.js"; -import type { ChunkSliceJson } from "../render/stages/freezePlan.js"; +import type { ChunkSliceJson, LockedRenderConfig } from "../render/stages/freezePlan.js"; import { DISTRIBUTED_RENDER_CAPABILITIES, readPlanProtocolV1 } from "./planProtocol.js"; import { validatePlanV2MaterializedTarget } from "./planV2.js"; import type { DistributedFormat } from "./shared.js"; @@ -81,6 +82,16 @@ interface PlanJsonForAssemble { }; } +type CfrRateControlSource = Partial>; + +export function cfrRateControlArgs(locked: CfrRateControlSource): string[] { + const preset = getEncoderPreset(locked.quality ?? "standard", "mp4"); + if (locked.crf == null && locked.bitrate) { + return ["-preset", preset.preset, "-b:v", locked.bitrate]; + } + return ["-preset", preset.preset, "-crf", String(locked.crf ?? preset.quality)]; +} + /** * Assemble the chunk outputs into a single deliverable. * @@ -112,7 +123,7 @@ export async function assemble( * duration lands on the requested `frameCount / fps` to ms * precision, with no PTS-derived drift). Trade-off: ~2-5x the * stitch time for a 60s 1080p clip plus second-generation H.264 - * quality loss (negligible at `-crf 18` but non-zero). Default + * quality loss (at the chunks' own CRF or bitrate, non-zero). Default * `false` preserves the existing `-c copy` behavior. mp4 only * (libx264); webm / mov pass through unchanged because their * stream-copy paths don't exhibit the same avg-frame-rate drift. @@ -266,9 +277,9 @@ export async function assemble( if (!existsSync(encoderJsonPath)) { throw new Error(`[assemble] planDir missing meta/encoder.json: ${encoderJsonPath}`); } - const encoderJson = JSON.parse(readFileSync(encoderJsonPath, "utf-8")) as { - encoder?: string; - }; + const encoderJson = JSON.parse( + readFileSync(encoderJsonPath, "utf-8"), + ) as CfrRateControlSource & { encoder?: string }; if (encoderJson.encoder === "libx265-software") { throw new Error( `[assemble] cfr=true is not yet supported with codec: "h265". The ` + @@ -282,10 +293,7 @@ export async function assemble( concatOutputPath, "-c:v", "libx264", - "-preset", - "medium", - "-crf", - "18", + ...cfrRateControlArgs(encoderJson), "-pix_fmt", "yuv420p", "-fps_mode",