Skip to content

Commit a4c4b2f

Browse files
committed
fix(distributed): enforce exact framerate at concat + mux boundaries
When the distributed render path stitches chunks with `-c copy`, ffmpeg averages the container framerate from PTS rather than carrying the source's exact rational rate, producing values like `360000/12001` instead of `30/1` and ~5ms duration drift over 60s. This is a known ffmpeg behavior at the concat-demuxer-copy boundary. The industry-standard fix is `-r <fps>` as an input flag on the concat step plus an output flag on the subsequent mux step — both with `-c copy` retained, no re-encode required. Three sites updated: - `assemble.ts` concat step: `-r <fps>` input flag. - `chunkEncoder.muxVideoWithAudio`: `-r <fps>` output flag. - `chunkEncoder.applyFaststart`: same, threaded from caller. Adds `r_frame_rate` + duration-equivalence assertions to `assemble.test.ts` to close the regression hole.
1 parent 3560678 commit a4c4b2f

4 files changed

Lines changed: 60 additions & 4 deletions

File tree

packages/engine/src/services/chunkEncoder.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
} from "../utils/gpuEncoder.js";
1919
import { type HdrTransfer, getHdrEncoderColorParams } from "../utils/hdr.js";
2020
import { formatFfmpegError, runFfmpeg } from "../utils/runFfmpeg.js";
21-
import { fpsToFfmpegArg } from "@hyperframes/core";
21+
import { type Fps, fpsToFfmpegArg } from "@hyperframes/core";
2222
import type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js";
2323

2424
export type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js";
@@ -622,6 +622,7 @@ export async function muxVideoWithAudio(
622622
outputPath: string,
623623
signal?: AbortSignal,
624624
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
625+
fps?: Fps,
625626
): Promise<MuxResult> {
626627
const outputDir = dirname(outputPath);
627628
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
@@ -640,6 +641,12 @@ export async function muxVideoWithAudio(
640641
// PTS bases can diverge during mux and reintroduce negative DTS. See
641642
// buildEncoderArgs for the full reasoning on why that breaks playback.
642643
args.push("-avoid_negative_ts", "make_zero");
644+
if (fps !== undefined) {
645+
// Set the exact output framerate so the muxer doesn't PTS-average a
646+
// fractional rational like `360000/12001` instead of `30/1` into the
647+
// output container metadata. `-c:v copy` is retained; no re-encode.
648+
args.push("-r", fpsToFfmpegArg(fps));
649+
}
643650
args.push("-shortest", "-y", outputPath);
644651

645652
const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
@@ -666,14 +673,22 @@ export async function applyFaststart(
666673
outputPath: string,
667674
signal?: AbortSignal,
668675
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
676+
fps?: Fps,
669677
): Promise<MuxResult> {
670678
// faststart is MP4-only (moves moov atom to file start for streaming).
671679
// WebM and MOV don't need it — skip the re-mux.
672680
if (outputPath.endsWith(".webm") || outputPath.endsWith(".mov")) {
673681
if (inputPath !== outputPath) copyFileSync(inputPath, outputPath);
674682
return { success: true, outputPath, durationMs: 0 };
675683
}
676-
const args = ["-i", inputPath, "-c", "copy", "-movflags", "+faststart", "-y", outputPath];
684+
const args = ["-i", inputPath, "-c", "copy", "-movflags", "+faststart"];
685+
if (fps !== undefined) {
686+
// Set the exact output framerate so the final remux doesn't PTS-average
687+
// a fractional rational like `360000/12001` instead of `30/1` into the
688+
// output container metadata. `-c copy` is retained; no re-encode.
689+
args.push("-r", fpsToFfmpegArg(fps));
690+
}
691+
args.push("-y", outputPath);
677692

678693
const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
679694
const result = await runFfmpeg(args, { signal, timeout: processTimeout });

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,17 @@ describe("assemble()", () => {
195195
const probedFrames = Number(videoStream?.nb_read_packets ?? videoStream?.nb_frames);
196196
expect(probedFrames).toBe(10);
197197

198+
// ── ffprobe: exact framerate + duration equivalence ────────────────
199+
// The container's `r_frame_rate` must match the planDir's exact
200+
// rational (30/1 here) — not a PTS-averaged fraction like
201+
// `360000/12001`. This guards the `-r` flag on the concat /
202+
// mux / faststart steps from regressing.
203+
expect(videoStream?.r_frame_rate).toBe("30/1");
204+
// Duration must equal `totalFrames * fpsDen / fpsNum` within 1ms.
205+
const expectedDuration = (10 * 1) / 30;
206+
const probedDuration = Number(videoStream?.duration ?? 0);
207+
expect(Math.abs(probedDuration - expectedDuration)).toBeLessThan(0.001);
208+
198209
// ── faststart applied ──────────────────────────────────────────────
199210
// Bun.file is async; resolve before asserting.
200211
const buf = await Bun.file(outputPath).arrayBuffer();

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
} from "node:fs";
3636
import { dirname, join } from "node:path";
3737
import { applyFaststart, muxVideoWithAudio, runFfmpeg } from "@hyperframes/engine";
38+
import { fpsToFfmpegArg } from "@hyperframes/core";
3839
import { defaultLogger, type ProducerLogger } from "../../logger.js";
3940
import { padOrTrimAudioToVideoFrameCount } from "../render/audioPadTrim.js";
4041
import type { ChunkSliceJson } from "../render/stages/freezePlan.js";
@@ -138,7 +139,17 @@ export async function assemble(
138139
writeFileSync(concatListPath, `${concatBody}\n`, "utf-8");
139140

140141
const concatOutputPath = join(workDir, `concat.${plan.dimensions.format}`);
142+
const fpsArg = fpsToFfmpegArg({
143+
num: plan.dimensions.fpsNum,
144+
den: plan.dimensions.fpsDen,
145+
});
146+
// Set the exact input framerate so the concat demuxer doesn't
147+
// PTS-average a fractional rational like `360000/12001` instead
148+
// of `30/1` into the output container metadata. `-c copy` is
149+
// retained; no re-encode.
141150
const concatArgs = [
151+
"-r",
152+
fpsArg,
142153
"-f",
143154
"concat",
144155
"-safe",
@@ -190,6 +201,8 @@ export async function assemble(
190201
audioForMux,
191202
muxOutputPath,
192203
abortSignal,
204+
undefined,
205+
{ num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen },
193206
);
194207
if (!muxResult.success) {
195208
throw new Error(`[assemble] audio mux failed: ${muxResult.error}`);
@@ -198,7 +211,16 @@ export async function assemble(
198211

199212
// applyFaststart is a no-op for `.mov` (it copies the input to output);
200213
// we still call it so the success path produces `outputPath` regardless.
201-
const faststartResult = await applyFaststart(muxOutputPath, outputPath, abortSignal);
214+
const faststartResult = await applyFaststart(
215+
muxOutputPath,
216+
outputPath,
217+
abortSignal,
218+
undefined,
219+
{
220+
num: plan.dimensions.fpsNum,
221+
den: plan.dimensions.fpsDen,
222+
},
223+
);
202224
if (!faststartResult.success) {
203225
throw new Error(`[assemble] faststart failed: ${faststartResult.error}`);
204226
}

packages/producer/src/services/render/stages/assembleStage.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,21 @@ export async function runAssembleStage(input: AssembleStageInput): Promise<Assem
6060
audioOutputPath,
6161
outputPath,
6262
abortSignal,
63+
undefined,
64+
job.config.fps,
6365
);
6466
assertNotAborted();
6567
if (!muxResult.success) {
6668
throw new Error(`Audio muxing failed: ${muxResult.error}`);
6769
}
6870
} else {
69-
const faststartResult = await applyFaststart(videoOnlyPath, outputPath, abortSignal);
71+
const faststartResult = await applyFaststart(
72+
videoOnlyPath,
73+
outputPath,
74+
abortSignal,
75+
undefined,
76+
job.config.fps,
77+
);
7078
assertNotAborted();
7179
if (!faststartResult.success) {
7280
throw new Error(`Faststart failed: ${faststartResult.error}`);

0 commit comments

Comments
 (0)