mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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:
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user