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