Skip to content

Commit 71d1889

Browse files
committed
feat(distributed): add optional cfr flag for exact constant frame rate
Distributed-render output today uses -c:v copy through concat → mux → faststart, which means PTS timestamps from each chunk pass through unchanged. Container r_frame_rate is exact (#1040 + this PR's parent), but stream-level avg_frame_rate stays PTS-derived and can land on fractional rationals like 27648000/921677 over a 60s render. Same for sub-ms duration drift. This is the achievable bar within -c copy stream-copy concat. For most consumers (browser playback, YouTube, etc.) the difference is invisible. For downstream tools that strict-check avg_frame_rate or ms-precision duration (broadcast workflows, frame-accurate compositors, some third-party transcoders), it matters. Adds an opt-in cfr config flag (default false). When true, the assemble step's final pass re-encodes with -fps_mode cfr -r <fps> instead of -c copy, producing exact CFR output. Trade-off: ~2-5x the stitch time for a 60s 1080p clip; second-generation H.264 quality loss is negligible at -crf 18 but is non-zero.
1 parent 4729254 commit 71d1889

5 files changed

Lines changed: 244 additions & 5 deletions

File tree

packages/aws-lambda/src/events.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ export interface AssembleEvent {
8181
OutputS3Uri: string;
8282
/** Output container format; drives file vs frame-dir handling. */
8383
Format: DistributedFormat;
84+
/**
85+
* Optional exact-CFR re-encode at assemble time. When `true`, the final
86+
* assembled video is re-encoded with `-fps_mode cfr -r <fps>` so the
87+
* stream's `avg_frame_rate` matches the container's `r_frame_rate`
88+
* exactly (and the file's duration is exact, not PTS-derived). Trade-off
89+
* is ~2-5x the assemble wall-clock. mp4 only — webm / mov stream-copy
90+
* paths already produce exact avg_frame_rate. Default `false` /
91+
* unset preserves current `-c copy` behavior.
92+
*/
93+
Cfr?: boolean;
8494
}
8595

8696
/**

packages/aws-lambda/src/handler.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,9 @@ async function handleAssemble(
407407
? join(work, "output-frames")
408408
: join(work, `output${formatExtension(event.Format)}`);
409409

410-
const result: AssembleResult = await primitive(planDir, chunkPaths, audioPath, finalOutput);
410+
const result: AssembleResult = await primitive(planDir, chunkPaths, audioPath, finalOutput, {
411+
cfr: event.Cfr === true,
412+
});
411413

412414
if (event.Format === "png-sequence") {
413415
const tarball = `${finalOutput}.tar.gz`;

packages/producer/src/services/distributed/assemble.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,147 @@ describe("assemble()", () => {
321321
TIMEOUT_MS,
322322
);
323323

324+
it(
325+
"cfr:true re-encodes for exact avg_frame_rate matching r_frame_rate",
326+
async () => {
327+
if (!hasFfmpeg) {
328+
console.warn("[assemble.test] skipping cfr test — ffmpeg not available on this host");
329+
return;
330+
}
331+
332+
// Opt-in CFR: the re-encode pass with `-fps_mode cfr -r <fps>` must
333+
// land the stream's `avg_frame_rate` on the requested rational
334+
// exactly, not a PTS-derived fraction. Default `cfr=false` path is
335+
// covered by the existing concat-copy tests above.
336+
const chunks: ChunkSliceJson[] = [
337+
{ index: 0, startFrame: 0, endFrame: 5 },
338+
{ index: 1, startFrame: 5, endFrame: 10 },
339+
];
340+
const planDir = buildPlanDir("mp4", chunks, 10, false);
341+
342+
const chunkAPath = join(planDir, "chunk-0.mp4");
343+
const chunkBPath = join(planDir, "chunk-1.mp4");
344+
makeMp4Chunk(chunkAPath, 5);
345+
makeMp4Chunk(chunkBPath, 5);
346+
347+
const outputPath = join(planDir, "output-cfr.mp4");
348+
const result = await assemble(planDir, [chunkAPath, chunkBPath], null, outputPath, {
349+
cfr: true,
350+
});
351+
352+
expect(result.outputPath).toBe(outputPath);
353+
expect(existsSync(outputPath)).toBe(true);
354+
expect(result.framesEncoded).toBe(10);
355+
356+
// ffprobe both r_frame_rate AND avg_frame_rate — the CFR re-encode's
357+
// contract is that they're equal and both exactly match the
358+
// requested rate.
359+
const probe = spawnSync(
360+
"ffprobe",
361+
[
362+
"-v",
363+
"error",
364+
"-select_streams",
365+
"v:0",
366+
"-show_entries",
367+
"stream=r_frame_rate,avg_frame_rate,duration",
368+
"-of",
369+
"json",
370+
outputPath,
371+
],
372+
{ stdio: "pipe" },
373+
);
374+
expect(probe.status).toBe(0);
375+
const parsed = JSON.parse(probe.stdout.toString()) as {
376+
streams?: Array<{ r_frame_rate?: string; avg_frame_rate?: string; duration?: string }>;
377+
};
378+
const stream = parsed.streams?.[0];
379+
expect(stream).toBeDefined();
380+
expect(stream?.r_frame_rate).toBe("30/1");
381+
expect(stream?.avg_frame_rate).toBe("30/1");
382+
const expectedDuration = 10 / 30;
383+
const probedDuration = Number(stream?.duration ?? 0);
384+
expect(Math.abs(probedDuration - expectedDuration)).toBeLessThan(0.001);
385+
},
386+
TIMEOUT_MS,
387+
);
388+
389+
it(
390+
"cfr:true rejects non-mp4 formats with a clear error",
391+
async () => {
392+
const chunks: ChunkSliceJson[] = [{ index: 0, startFrame: 0, endFrame: 5 }];
393+
// png-sequence path short-circuits before the cfr check; webm/mov
394+
// would hit the runtime guard. We rebuild plan.json with a non-mp4
395+
// format manually so this test runs without a webm encoder.
396+
const planDir = mkdtempSync(join(runRoot, "plan-webm-cfr-"));
397+
mkdirSync(join(planDir, "meta"), { recursive: true });
398+
writeFileSync(
399+
join(planDir, "plan.json"),
400+
JSON.stringify({
401+
planHash: "fake",
402+
totalFrames: 5,
403+
hasAudio: false,
404+
dimensions: { fpsNum: 30, fpsDen: 1, width: 160, height: 120, format: "webm" },
405+
}),
406+
"utf-8",
407+
);
408+
writeFileSync(join(planDir, "meta", "chunks.json"), JSON.stringify(chunks), "utf-8");
409+
// Fabricate a placeholder file so the existence check passes — the
410+
// cfr-guard error fires before we actually run the concat invocation
411+
// in the multi-chunk branch; the single-chunk remux path runs first
412+
// here, then we hit the cfr guard. Since the remux is real, only
413+
// run this test when ffmpeg is present.
414+
if (!hasFfmpeg) {
415+
console.warn("[assemble.test] skipping cfr-non-mp4 test — ffmpeg not available");
416+
return;
417+
}
418+
const chunkPath = join(planDir, "chunk-0.webm");
419+
// Build a real 5-frame webm chunk so the concat step succeeds and
420+
// the cfr guard is what actually trips.
421+
const buildResult = spawnSync("ffmpeg", [
422+
"-v",
423+
"error",
424+
"-f",
425+
"lavfi",
426+
"-i",
427+
"testsrc=size=160x120:rate=30:duration=0.166666",
428+
"-c:v",
429+
"libvpx-vp9",
430+
"-row-mt",
431+
"1",
432+
"-deadline",
433+
"realtime",
434+
"-cpu-used",
435+
"8",
436+
"-g",
437+
"5",
438+
"-keyint_min",
439+
"5",
440+
"-pix_fmt",
441+
"yuv420p",
442+
"-vframes",
443+
"5",
444+
"-y",
445+
chunkPath,
446+
]);
447+
if (buildResult.status !== 0) {
448+
console.warn(
449+
"[assemble.test] skipping cfr-non-mp4 test — libvpx-vp9 not available on this host",
450+
);
451+
return;
452+
}
453+
let caught: unknown;
454+
try {
455+
await assemble(planDir, [chunkPath], null, join(planDir, "out.webm"), { cfr: true });
456+
} catch (err) {
457+
caught = err;
458+
}
459+
expect(caught).toBeDefined();
460+
expect((caught as Error).message).toContain("cfr=true is only supported");
461+
},
462+
TIMEOUT_MS,
463+
);
464+
324465
it(
325466
"merges png-sequence chunk directories with continuous global numbering",
326467
() => {

packages/producer/src/services/distributed/assemble.ts

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,29 @@ export async function assemble(
8787
chunkPaths: readonly string[],
8888
audioPath: string | null,
8989
outputPath: string,
90-
options?: { logger?: ProducerLogger; abortSignal?: AbortSignal },
90+
options?: {
91+
logger?: ProducerLogger;
92+
abortSignal?: AbortSignal;
93+
/**
94+
* Opt-in exact-CFR re-encode. When `true`, the assembled video is
95+
* re-encoded once at the end of the concat/single-chunk step with
96+
* `-fps_mode cfr -r <fps>` so the stream-level `avg_frame_rate`
97+
* matches the container's `r_frame_rate` exactly (and the file's
98+
* duration lands on the requested `frameCount / fps` to ms
99+
* precision, with no PTS-derived drift). Trade-off: ~2-5x the
100+
* stitch time for a 60s 1080p clip plus second-generation H.264
101+
* quality loss (negligible at `-crf 18` but non-zero). Default
102+
* `false` preserves the existing `-c copy` behavior. mp4 only
103+
* (libx264); webm / mov pass through unchanged because their
104+
* stream-copy paths don't exhibit the same avg-frame-rate drift.
105+
*/
106+
cfr?: boolean;
107+
},
91108
): Promise<AssembleResult> {
92109
const start = Date.now();
93110
const log = options?.logger ?? defaultLogger;
94111
const abortSignal = options?.abortSignal;
112+
const cfr = options?.cfr === true;
95113

96114
// ── 1. Validate planDir manifest matches chunkPaths shape ──────────────
97115
const planJsonPath = join(planDir, "plan.json");
@@ -193,12 +211,66 @@ export async function assemble(
193211
}
194212
}
195213

214+
// ── 2c. Optional exact-CFR re-encode ──────────────────────────────────
215+
// The concat / single-chunk step produces a stream-copy intermediate
216+
// whose container `r_frame_rate` is exact but whose stream-level
217+
// `avg_frame_rate` stays PTS-derived (concat-copy carries each chunk's
218+
// original PTS unmodified). For consumers that strict-check
219+
// `avg_frame_rate` or ms-precision duration (broadcast workflows,
220+
// frame-accurate compositors, some third-party transcoders), an
221+
// opt-in re-encode with `-fps_mode cfr -r <fps>` lands the stream's
222+
// avg-frame-rate on the requested rational exactly. Restricted to
223+
// mp4 / libx264 — webm and mov go through their own stream-copy
224+
// paths that don't exhibit the same avg-frame-rate drift.
225+
let postConcatPath = concatOutputPath;
226+
if (cfr) {
227+
if (plan.dimensions.format !== "mp4") {
228+
throw new Error(
229+
`[assemble] cfr=true is only supported for format="mp4" (got ` +
230+
`"${plan.dimensions.format}"). Stream-copy paths for webm and mov ` +
231+
`already produce exact avg_frame_rate; cfr re-encode is not needed.`,
232+
);
233+
}
234+
const cfrOutputPath = join(workDir, `cfr.${plan.dimensions.format}`);
235+
const cfrArgs = [
236+
"-i",
237+
concatOutputPath,
238+
"-c:v",
239+
"libx264",
240+
"-preset",
241+
"medium",
242+
"-crf",
243+
"18",
244+
"-pix_fmt",
245+
"yuv420p",
246+
"-fps_mode",
247+
"cfr",
248+
"-r",
249+
fpsArg,
250+
"-y",
251+
cfrOutputPath,
252+
];
253+
const cfrResult = await runFfmpeg(cfrArgs, { signal: abortSignal });
254+
if (!cfrResult.success) {
255+
throw new Error(
256+
`[assemble] ffmpeg cfr re-encode failed (exit ${cfrResult.exitCode}): ` +
257+
`${cfrResult.stderr.slice(-400)}`,
258+
);
259+
}
260+
postConcatPath = cfrOutputPath;
261+
log.info("[assemble] cfr re-encode applied", {
262+
format: plan.dimensions.format,
263+
fpsNum: plan.dimensions.fpsNum,
264+
fpsDen: plan.dimensions.fpsDen,
265+
});
266+
}
267+
196268
// ── 3. Audio: pad-or-trim then mux ────────────────────────────────────
197269
let audioForMux: string | null = null;
198270
if (audioPath !== null && existsSync(audioPath)) {
199271
const paddedAudioPath = join(workDir, "audio-padded.aac");
200272
const padTrimResult = await padOrTrimAudioToVideoFrameCount({
201-
videoPath: concatOutputPath,
273+
videoPath: postConcatPath,
202274
audioPath,
203275
outputPath: paddedAudioPath,
204276
});
@@ -218,10 +290,10 @@ export async function assemble(
218290
// because it operates on a `RenderJob` and emits `updateJobStatus`
219291
// payloads — the distributed activity has no job to thread through.
220292
const muxOutputPath =
221-
audioForMux !== null ? join(workDir, `mux.${plan.dimensions.format}`) : concatOutputPath;
293+
audioForMux !== null ? join(workDir, `mux.${plan.dimensions.format}`) : postConcatPath;
222294
if (audioForMux !== null) {
223295
const muxResult = await muxVideoWithAudio(
224-
concatOutputPath,
296+
postConcatPath,
225297
audioForMux,
226298
muxOutputPath,
227299
abortSignal,

packages/producer/src/services/distributed/plan.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,20 @@ export interface DistributedRenderConfig {
146146
/** HDR is not supported in distributed mode; `force-hdr` trips a `FormatNotSupportedInDistributedError`. Defaults to `force-sdr`. */
147147
hdrMode?: "auto" | "force-sdr";
148148

149+
/**
150+
* Opt-in exact-CFR re-encode at the assemble stage. When `true`, the
151+
* stitched output is re-encoded once with `-fps_mode cfr -r <fps>` so
152+
* the stream-level `avg_frame_rate` matches the container's
153+
* `r_frame_rate` exactly (and the file duration is exact, not
154+
* PTS-derived). Useful for downstream consumers that strict-check
155+
* `avg_frame_rate` or ms-precision duration. Default `false` retains
156+
* the existing `-c copy` stitch path, which is faster and lossless.
157+
* mp4 only — webm / mov stream-copy paths already produce exact
158+
* avg_frame_rate. Consumed by `assemble`; does not affect `planHash`
159+
* (chunks render identically; only the final stitch step differs).
160+
*/
161+
cfr?: boolean;
162+
149163
logger?: ProducerLogger;
150164
/** Optional engine config override (env vars are not read when provided). */
151165
producerConfig?: EngineConfig;

0 commit comments

Comments
 (0)