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
+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)