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.
This commit is contained in:
James
2026-05-23 01:12:19 -04:00
committed by James Russo
parent 3560678bb2
commit a4c4b2ff03
4 changed files with 60 additions and 4 deletions
+17 -2
View File
@@ -18,7 +18,7 @@ import {
} from "../utils/gpuEncoder.js";
import { type HdrTransfer, getHdrEncoderColorParams } from "../utils/hdr.js";
import { formatFfmpegError, runFfmpeg } from "../utils/runFfmpeg.js";
import { fpsToFfmpegArg } from "@hyperframes/core";
import { type Fps, fpsToFfmpegArg } from "@hyperframes/core";
import type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js";
export type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js";
@@ -622,6 +622,7 @@ export async function muxVideoWithAudio(
outputPath: string,
signal?: AbortSignal,
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
fps?: Fps,
): Promise<MuxResult> {
const outputDir = dirname(outputPath);
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
@@ -640,6 +641,12 @@ export async function muxVideoWithAudio(
// PTS bases can diverge during mux and reintroduce negative DTS. See
// buildEncoderArgs for the full reasoning on why that breaks playback.
args.push("-avoid_negative_ts", "make_zero");
if (fps !== undefined) {
// Set the exact output framerate so the muxer doesn't PTS-average a
// fractional rational like `360000/12001` instead of `30/1` into the
// output container metadata. `-c:v copy` is retained; no re-encode.
args.push("-r", fpsToFfmpegArg(fps));
}
args.push("-shortest", "-y", outputPath);
const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
@@ -666,6 +673,7 @@ export async function applyFaststart(
outputPath: string,
signal?: AbortSignal,
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
fps?: Fps,
): Promise<MuxResult> {
// faststart is MP4-only (moves moov atom to file start for streaming).
// WebM and MOV don't need it — skip the re-mux.
@@ -673,7 +681,14 @@ export async function applyFaststart(
if (inputPath !== outputPath) copyFileSync(inputPath, outputPath);
return { success: true, outputPath, durationMs: 0 };
}
const args = ["-i", inputPath, "-c", "copy", "-movflags", "+faststart", "-y", outputPath];
const args = ["-i", inputPath, "-c", "copy", "-movflags", "+faststart"];
if (fps !== undefined) {
// Set the exact output framerate so the final remux doesn't PTS-average
// a fractional rational like `360000/12001` instead of `30/1` into the
// output container metadata. `-c copy` is retained; no re-encode.
args.push("-r", fpsToFfmpegArg(fps));
}
args.push("-y", outputPath);
const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
const result = await runFfmpeg(args, { signal, timeout: processTimeout });
@@ -195,6 +195,17 @@ describe("assemble()", () => {
const probedFrames = Number(videoStream?.nb_read_packets ?? videoStream?.nb_frames);
expect(probedFrames).toBe(10);
// ── ffprobe: exact framerate + duration equivalence ────────────────
// The container's `r_frame_rate` must match the planDir's exact
// rational (30/1 here) — not a PTS-averaged fraction like
// `360000/12001`. This guards the `-r` flag on the concat /
// mux / faststart steps from regressing.
expect(videoStream?.r_frame_rate).toBe("30/1");
// Duration must equal `totalFrames * fpsDen / fpsNum` within 1ms.
const expectedDuration = (10 * 1) / 30;
const probedDuration = Number(videoStream?.duration ?? 0);
expect(Math.abs(probedDuration - expectedDuration)).toBeLessThan(0.001);
// ── faststart applied ──────────────────────────────────────────────
// Bun.file is async; resolve before asserting.
const buf = await Bun.file(outputPath).arrayBuffer();
@@ -35,6 +35,7 @@ import {
} from "node:fs";
import { dirname, join } from "node:path";
import { applyFaststart, muxVideoWithAudio, runFfmpeg } from "@hyperframes/engine";
import { fpsToFfmpegArg } from "@hyperframes/core";
import { defaultLogger, type ProducerLogger } from "../../logger.js";
import { padOrTrimAudioToVideoFrameCount } from "../render/audioPadTrim.js";
import type { ChunkSliceJson } from "../render/stages/freezePlan.js";
@@ -138,7 +139,17 @@ export async function assemble(
writeFileSync(concatListPath, `${concatBody}\n`, "utf-8");
const concatOutputPath = join(workDir, `concat.${plan.dimensions.format}`);
const fpsArg = fpsToFfmpegArg({
num: plan.dimensions.fpsNum,
den: plan.dimensions.fpsDen,
});
// Set the exact input framerate so the concat demuxer doesn't
// PTS-average a fractional rational like `360000/12001` instead
// of `30/1` into the output container metadata. `-c copy` is
// retained; no re-encode.
const concatArgs = [
"-r",
fpsArg,
"-f",
"concat",
"-safe",
@@ -190,6 +201,8 @@ export async function assemble(
audioForMux,
muxOutputPath,
abortSignal,
undefined,
{ num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen },
);
if (!muxResult.success) {
throw new Error(`[assemble] audio mux failed: ${muxResult.error}`);
@@ -198,7 +211,16 @@ export async function assemble(
// applyFaststart is a no-op for `.mov` (it copies the input to output);
// we still call it so the success path produces `outputPath` regardless.
const faststartResult = await applyFaststart(muxOutputPath, outputPath, abortSignal);
const faststartResult = await applyFaststart(
muxOutputPath,
outputPath,
abortSignal,
undefined,
{
num: plan.dimensions.fpsNum,
den: plan.dimensions.fpsDen,
},
);
if (!faststartResult.success) {
throw new Error(`[assemble] faststart failed: ${faststartResult.error}`);
}
@@ -60,13 +60,21 @@ export async function runAssembleStage(input: AssembleStageInput): Promise<Assem
audioOutputPath,
outputPath,
abortSignal,
undefined,
job.config.fps,
);
assertNotAborted();
if (!muxResult.success) {
throw new Error(`Audio muxing failed: ${muxResult.error}`);
}
} else {
const faststartResult = await applyFaststart(videoOnlyPath, outputPath, abortSignal);
const faststartResult = await applyFaststart(
videoOnlyPath,
outputPath,
abortSignal,
undefined,
job.config.fps,
);
assertNotAborted();
if (!faststartResult.success) {
throw new Error(`Faststart failed: ${faststartResult.error}`);