mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(engine): add lockGopForChunkConcat option to buildEncoderArgs
Part of Phase 2 of the distributed rendering plan (determinism hardening). See DISTRIBUTED-RENDERING-PLAN.md §7.1 and §17.2 (gating table). Adds two optional fields to EncoderOptions: lockGopForChunkConcat?: boolean // default false gopSize?: number // required when lockGopForChunkConcat=true When the flag is true on the SW libx264 / libx265 paths, buildEncoderArgs emits closed-GOP / forced-keyframe args so the resulting chunk file can be losslessly concatenated (`ffmpeg -f concat -c copy`) with sibling chunks: -g <gopSize> -keyint_min <gopSize> -sc_threshold 0 -force_key_frames "expr:eq(mod(n,<gopSize>),0)" -x264-params "...:scenecut=0:open-gop=0:repeat-headers=1" -x265-params "keyint=<gopSize>:min-keyint=<gopSize>:scenecut=0:open-gop=0:repeat-headers=1" -bf 0 (added for h265 too when locked) GPU encoders, vp9, and prores ignore the flag (their concat-copy story is separate — see plan §7.2 / §8). In-process behavior is unchanged: the default (false) path emits no new args. New unit tests pin both branches in packages/engine/src/services/ chunkEncoder.test.ts. This is part of a stack of 10 PRs; this is PR 1 of 10. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -391,6 +391,245 @@ describe("getEncoderPreset HDR", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildEncoderArgs lockGopForChunkConcat", () => {
|
||||
const baseOptions = { fps: { num: 30, den: 1 }, width: 1920, height: 1080 };
|
||||
const inputArgs = ["-framerate", "30", "-i", "frames/%04d.png"];
|
||||
|
||||
// Default path must emit zero closed-GOP args — in-process renders rely on
|
||||
// libx264/libx265 defaults to stay byte-identical with their PSNR baselines.
|
||||
it("default (false) omits closed-GOP args for libx264", () => {
|
||||
const args = buildEncoderArgs(
|
||||
{ ...baseOptions, codec: "h264", preset: "medium", quality: 23 },
|
||||
inputArgs,
|
||||
"out.mp4",
|
||||
);
|
||||
expect(args).not.toContain("-g");
|
||||
expect(args).not.toContain("-keyint_min");
|
||||
expect(args).not.toContain("-force_key_frames");
|
||||
expect(args).not.toContain("-sc_threshold");
|
||||
const paramIdx = args.indexOf("-x264-params");
|
||||
expect(args[paramIdx + 1]).not.toContain("scenecut=0");
|
||||
expect(args[paramIdx + 1]).not.toContain("open-gop=0");
|
||||
expect(args[paramIdx + 1]).not.toContain("repeat-headers=1");
|
||||
});
|
||||
|
||||
it("default (false) omits closed-GOP args for libx265", () => {
|
||||
const args = buildEncoderArgs(
|
||||
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23 },
|
||||
inputArgs,
|
||||
"out.mp4",
|
||||
);
|
||||
expect(args).not.toContain("-g");
|
||||
expect(args).not.toContain("-keyint_min");
|
||||
expect(args).not.toContain("-force_key_frames");
|
||||
expect(args).not.toContain("-sc_threshold");
|
||||
const paramIdx = args.indexOf("-x265-params");
|
||||
expect(args[paramIdx + 1]).not.toContain("scenecut=0");
|
||||
expect(args[paramIdx + 1]).not.toContain("keyint=");
|
||||
expect(args[paramIdx + 1]).not.toContain("open-gop=0");
|
||||
expect(args[paramIdx + 1]).not.toContain("repeat-headers=1");
|
||||
});
|
||||
|
||||
it("true appends closed-GOP ffmpeg flags and x264-params for libx264", () => {
|
||||
const args = buildEncoderArgs(
|
||||
{
|
||||
...baseOptions,
|
||||
codec: "h264",
|
||||
preset: "medium",
|
||||
quality: 23,
|
||||
lockGopForChunkConcat: true,
|
||||
gopSize: 240,
|
||||
},
|
||||
inputArgs,
|
||||
"out.mp4",
|
||||
);
|
||||
expect(args[args.indexOf("-g") + 1]).toBe("240");
|
||||
expect(args[args.indexOf("-keyint_min") + 1]).toBe("240");
|
||||
expect(args[args.indexOf("-sc_threshold") + 1]).toBe("0");
|
||||
expect(args[args.indexOf("-force_key_frames") + 1]).toBe("expr:eq(mod(n,240),0)");
|
||||
const paramIdx = args.indexOf("-x264-params");
|
||||
expect(args[paramIdx + 1]).toContain("scenecut=0");
|
||||
expect(args[paramIdx + 1]).toContain("open-gop=0");
|
||||
expect(args[paramIdx + 1]).toContain("repeat-headers=1");
|
||||
// -bf 0 was already present for h264; closed-GOP doesn't change that.
|
||||
expect(args).toContain("-bf");
|
||||
expect(args[args.indexOf("-bf") + 1]).toBe("0");
|
||||
// 90000 timescale is required for clean concat-copy — already enforced for h264/h265.
|
||||
expect(args[args.indexOf("-video_track_timescale") + 1]).toBe("90000");
|
||||
});
|
||||
|
||||
it("true appends closed-GOP x265-params keyint controls for libx265", () => {
|
||||
const args = buildEncoderArgs(
|
||||
{
|
||||
...baseOptions,
|
||||
codec: "h265",
|
||||
preset: "medium",
|
||||
quality: 23,
|
||||
lockGopForChunkConcat: true,
|
||||
gopSize: 360,
|
||||
},
|
||||
inputArgs,
|
||||
"out.mp4",
|
||||
);
|
||||
expect(args[args.indexOf("-g") + 1]).toBe("360");
|
||||
expect(args[args.indexOf("-keyint_min") + 1]).toBe("360");
|
||||
expect(args[args.indexOf("-sc_threshold") + 1]).toBe("0");
|
||||
expect(args[args.indexOf("-force_key_frames") + 1]).toBe("expr:eq(mod(n,360),0)");
|
||||
const paramIdx = args.indexOf("-x265-params");
|
||||
expect(args[paramIdx + 1]).toContain("keyint=360");
|
||||
expect(args[paramIdx + 1]).toContain("min-keyint=360");
|
||||
expect(args[paramIdx + 1]).toContain("scenecut=0");
|
||||
expect(args[paramIdx + 1]).toContain("open-gop=0");
|
||||
expect(args[paramIdx + 1]).toContain("repeat-headers=1");
|
||||
// h265 normally tolerates B-frames; closed-GOP concat-copy doesn't.
|
||||
expect(args[args.indexOf("-bf") + 1]).toBe("0");
|
||||
});
|
||||
|
||||
it("true preserves the x264-params anti-banding controls", () => {
|
||||
// The closed-GOP params join onto the existing aq-mode/deblock string —
|
||||
// make sure we didn't accidentally drop the anti-banding tuning.
|
||||
const args = buildEncoderArgs(
|
||||
{
|
||||
...baseOptions,
|
||||
codec: "h264",
|
||||
preset: "medium",
|
||||
quality: 23,
|
||||
lockGopForChunkConcat: true,
|
||||
gopSize: 240,
|
||||
},
|
||||
inputArgs,
|
||||
"out.mp4",
|
||||
);
|
||||
const paramIdx = args.indexOf("-x264-params");
|
||||
expect(args[paramIdx + 1]).toContain("aq-mode=3");
|
||||
expect(args[paramIdx + 1]).toContain("aq-strength=0.8");
|
||||
expect(args[paramIdx + 1]).toContain("deblock=1,1");
|
||||
expect(args[paramIdx + 1]).toContain("colorprim=bt709");
|
||||
});
|
||||
|
||||
it("true with ultrafast preset still emits closed-GOP params and skips deblock", () => {
|
||||
const args = buildEncoderArgs(
|
||||
{
|
||||
...baseOptions,
|
||||
codec: "h264",
|
||||
preset: "ultrafast",
|
||||
quality: 28,
|
||||
lockGopForChunkConcat: true,
|
||||
gopSize: 240,
|
||||
},
|
||||
inputArgs,
|
||||
"out.mp4",
|
||||
);
|
||||
expect(args[args.indexOf("-g") + 1]).toBe("240");
|
||||
const paramIdx = args.indexOf("-x264-params");
|
||||
expect(args[paramIdx + 1]).toContain("aq-mode=3");
|
||||
expect(args[paramIdx + 1]).toContain("scenecut=0");
|
||||
expect(args[paramIdx + 1]).not.toContain("deblock");
|
||||
});
|
||||
|
||||
it("true is a no-op on GPU encoders", () => {
|
||||
// GPU encoders take a separate code path; lockGopForChunkConcat does not
|
||||
// wire `-g` / `-keyint_min` into nvenc/qsv/vaapi.
|
||||
const args = buildEncoderArgs(
|
||||
{
|
||||
...baseOptions,
|
||||
codec: "h264",
|
||||
preset: "medium",
|
||||
quality: 23,
|
||||
useGpu: true,
|
||||
lockGopForChunkConcat: true,
|
||||
gopSize: 240,
|
||||
},
|
||||
inputArgs,
|
||||
"out.mp4",
|
||||
"nvenc",
|
||||
);
|
||||
expect(args).not.toContain("-g");
|
||||
expect(args).not.toContain("-keyint_min");
|
||||
expect(args).not.toContain("-force_key_frames");
|
||||
expect(args).not.toContain("-sc_threshold");
|
||||
expect(args.indexOf("-x264-params")).toBe(-1);
|
||||
});
|
||||
|
||||
it("true is a no-op on VP9", () => {
|
||||
const args = buildEncoderArgs(
|
||||
{
|
||||
...baseOptions,
|
||||
codec: "vp9",
|
||||
preset: "good",
|
||||
quality: 23,
|
||||
lockGopForChunkConcat: true,
|
||||
gopSize: 240,
|
||||
},
|
||||
inputArgs,
|
||||
"out.webm",
|
||||
);
|
||||
expect(args).not.toContain("-g");
|
||||
expect(args).not.toContain("-keyint_min");
|
||||
expect(args).not.toContain("-force_key_frames");
|
||||
});
|
||||
|
||||
it("true is a no-op on ProRes (intra-only — no GOP forcing needed)", () => {
|
||||
const args = buildEncoderArgs(
|
||||
{
|
||||
...baseOptions,
|
||||
codec: "prores",
|
||||
preset: "4444",
|
||||
quality: 23,
|
||||
lockGopForChunkConcat: true,
|
||||
gopSize: 240,
|
||||
},
|
||||
inputArgs,
|
||||
"out.mov",
|
||||
);
|
||||
expect(args).not.toContain("-g");
|
||||
expect(args).not.toContain("-keyint_min");
|
||||
expect(args).not.toContain("-force_key_frames");
|
||||
});
|
||||
|
||||
it("true with missing or invalid gopSize throws", () => {
|
||||
for (const bad of [undefined, 0, -10, NaN, Infinity]) {
|
||||
expect(() =>
|
||||
buildEncoderArgs(
|
||||
{
|
||||
...baseOptions,
|
||||
codec: "h264",
|
||||
preset: "medium",
|
||||
quality: 23,
|
||||
lockGopForChunkConcat: true,
|
||||
gopSize: bad as number | undefined,
|
||||
},
|
||||
inputArgs,
|
||||
"out.mp4",
|
||||
),
|
||||
).toThrow(/lockGopForChunkConcat=true requires a positive integer gopSize/);
|
||||
}
|
||||
});
|
||||
|
||||
it("HDR + closed-GOP keeps HDR mastering metadata in x265-params", () => {
|
||||
const args = buildEncoderArgs(
|
||||
{
|
||||
...baseOptions,
|
||||
codec: "h265",
|
||||
preset: "medium",
|
||||
quality: 23,
|
||||
hdr: { transfer: "pq" },
|
||||
lockGopForChunkConcat: true,
|
||||
gopSize: 240,
|
||||
},
|
||||
inputArgs,
|
||||
"out.mp4",
|
||||
);
|
||||
const paramIdx = args.indexOf("-x265-params");
|
||||
expect(args[paramIdx + 1]).toContain("colorprim=bt2020");
|
||||
expect(args[paramIdx + 1]).toContain("transfer=smpte2084");
|
||||
expect(args[paramIdx + 1]).toContain("master-display=");
|
||||
expect(args[paramIdx + 1]).toContain("max-cll=");
|
||||
expect(args[paramIdx + 1]).toContain("keyint=240");
|
||||
expect(args[paramIdx + 1]).toContain("scenecut=0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildEncoderArgs HDR color space", () => {
|
||||
const baseOptions = { fps: { num: 30, den: 1 }, width: 1920, height: 1080 };
|
||||
const inputArgs = ["-framerate", "30", "-i", "frames/%04d.png"];
|
||||
|
||||
@@ -165,6 +165,36 @@ export function buildEncoderArgs(
|
||||
if (bitrate) args.push("-b:v", bitrate);
|
||||
else args.push("-crf", String(quality));
|
||||
|
||||
// Closed-GOP / forced-keyframe args so an external orchestrator can
|
||||
// ffmpeg-concat chunk files with `-c copy`. Without these, libx264 /
|
||||
// libx265 emit open-GOP frames with mid-chunk scenecut keyframes; the
|
||||
// first frame of each chunk isn't an independently-decodable IDR and
|
||||
// concat-copy playback freezes at chunk seams on some decoders.
|
||||
const lockGop = options.lockGopForChunkConcat === true;
|
||||
let gop = 0;
|
||||
if (lockGop) {
|
||||
if (
|
||||
typeof options.gopSize !== "number" ||
|
||||
!Number.isFinite(options.gopSize) ||
|
||||
options.gopSize <= 0
|
||||
) {
|
||||
throw new Error(
|
||||
`[chunkEncoder] lockGopForChunkConcat=true requires a positive integer gopSize (received ${String(options.gopSize)})`,
|
||||
);
|
||||
}
|
||||
gop = Math.floor(options.gopSize);
|
||||
args.push(
|
||||
"-g",
|
||||
String(gop),
|
||||
"-keyint_min",
|
||||
String(gop),
|
||||
"-sc_threshold",
|
||||
"0",
|
||||
"-force_key_frames",
|
||||
`expr:eq(mod(n,${gop}),0)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Disable B-frames. Standard h264 with B-frames produces negative DTS
|
||||
// at the start of the stream (the first B-frame's decode order is
|
||||
// "before" the first I-frame's presentation time). VS Code's video
|
||||
@@ -173,7 +203,12 @@ export function buildEncoderArgs(
|
||||
// -bf 0 makes PTS == DTS at every frame, eliminating the issue at the
|
||||
// source. Quality cost is ~5–10% larger files at the same CRF — a
|
||||
// worthwhile trade for "the file plays everywhere".
|
||||
if (codec === "h264") {
|
||||
//
|
||||
// Also emit `-bf 0` for h265 when closed-GOP is locked: chunked
|
||||
// concat-copy of h265 with B-frames hits the same negative-DTS hazard
|
||||
// at every chunk boundary, even though single-stream h265 normally
|
||||
// tolerates B-frames fine.
|
||||
if (codec === "h264" || (codec === "h265" && lockGop)) {
|
||||
args.push("-bf", "0");
|
||||
}
|
||||
|
||||
@@ -182,15 +217,33 @@ export function buildEncoderArgs(
|
||||
// For HDR x265 paths we additionally embed BT.2020 + transfer + HDR static
|
||||
// mastering metadata via x265-params; libx264 only carries BT.709 tags
|
||||
// since HDR through H.264 is not supported by this encoder path.
|
||||
//
|
||||
// When closed-GOP is locked we additionally bake the keyint/scenecut
|
||||
// controls into the codec param string so libx264's slice-type decisions
|
||||
// and libx265's rate-control respect the IDR cadence end-to-end (without
|
||||
// these, ffmpeg's `-force_key_frames` is honored but the underlying
|
||||
// encoder may still insert mini-GOPs with open-GOP references that
|
||||
// break concat-copy on some decoders). `repeat-headers=1` writes SPS/PPS
|
||||
// at every keyframe so each chunk file is self-contained.
|
||||
const xParamsFlag = codec === "h264" ? "-x264-params" : "-x265-params";
|
||||
const colorParams =
|
||||
codec === "h265" && options.hdr
|
||||
? getHdrEncoderColorParams(options.hdr.transfer).x265ColorParams
|
||||
: "colorprim=bt709:transfer=bt709:colormatrix=bt709";
|
||||
let gopParams = "";
|
||||
if (lockGop) {
|
||||
const shared = "scenecut=0:open-gop=0:repeat-headers=1";
|
||||
gopParams = codec === "h264" ? shared : `keyint=${gop}:min-keyint=${gop}:${shared}`;
|
||||
}
|
||||
const joinParams = (...parts: string[]): string =>
|
||||
parts.filter((p) => p.length > 0).join(":");
|
||||
if (preset === "ultrafast") {
|
||||
args.push(xParamsFlag, `aq-mode=3:${colorParams}`);
|
||||
args.push(xParamsFlag, joinParams("aq-mode=3", colorParams, gopParams));
|
||||
} else {
|
||||
args.push(xParamsFlag, `aq-mode=3:aq-strength=0.8:deblock=1,1:${colorParams}`);
|
||||
args.push(
|
||||
xParamsFlag,
|
||||
joinParams("aq-mode=3", "aq-strength=0.8", "deblock=1,1", colorParams, gopParams),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Apple devices require hvc1 tag for HEVC playback (default hev1 won't open in QuickTime)
|
||||
|
||||
@@ -13,6 +13,25 @@ export interface EncoderOptions {
|
||||
pixelFormat?: string;
|
||||
useGpu?: boolean;
|
||||
hdr?: { transfer: HdrTransfer };
|
||||
/**
|
||||
* When `true`, force closed-GOP encoding with a keyframe at every
|
||||
* `gopSize` boundary so the resulting chunk file can be losslessly
|
||||
* concatenated (`ffmpeg -f concat -c copy`) with sibling chunks.
|
||||
*
|
||||
* Default `false`: GOP placement is left to libx264/libx265 defaults
|
||||
* (open-GOP, scenecut-driven keyframes), preserving the in-process
|
||||
* renderer's byte-identical output.
|
||||
*
|
||||
* Only honored by the SW libx264 / libx265 paths. GPU encoders, vp9, and
|
||||
* prores ignore the flag (their concat-copy story is separate).
|
||||
*/
|
||||
lockGopForChunkConcat?: boolean;
|
||||
/**
|
||||
* Required when `lockGopForChunkConcat` is `true`. Number of frames per
|
||||
* GOP — set to `chunkSize` so every chunk starts on an IDR keyframe and
|
||||
* concat-copy boundaries land on independently-decodable frames.
|
||||
*/
|
||||
gopSize?: number;
|
||||
}
|
||||
|
||||
export interface EncodeResult {
|
||||
|
||||
Reference in New Issue
Block a user