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:
James
2026-05-13 04:36:11 +00:00
co-authored by Claude Opus 4.7
parent c2648bc863
commit 2d6372ac2a
3 changed files with 314 additions and 3 deletions
@@ -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"];
+56 -3
View File
@@ -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 ~510% 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 {