feat(engine): closed-GOP VP9 encoder args + concat-copy smoke test (#950)

## Description

PR 1 of 4 in the WebM (VP9) distributed-rendering series. A gating
experiment that proves closed-GOP libvpx-vp9 chunks survive
`ffmpeg -f concat -c copy` losslessly, so the rest of the stack can
ship Path A (concat-copy) rather than the slower
re-encode-in-assemble fallback.

Two changes:

1. **Closed-GOP VP9 encoder args.** `buildEncoderArgs` now lays
   `-g <chunkSize>`, `-keyint_min <chunkSize>`, `-auto-alt-ref 0`, and
   `-cpu-used 2` on libvpx-vp9 when `lockGopForChunkConcat=true`.
   Mirrors the existing libx264/libx265 branches. The alt-ref disable
   is load-bearing — libvpx-vp9's default non-displayable alt-ref
   frames can reach across chunk seams and break concat-copy.
   `-cpu-used 2` pins the speed/quality tradeoff so chunks encoded on
   workers with different libvpx-vp9 defaults produce visually
   consistent output across seams. Default (`lockGopForChunkConcat`
   unset) preserves the existing in-process VP9 path unchanged.

2. **Concat-copy smoke test** at
   `packages/producer/tests/distributed/_smoke/webm-concat-copy.test.ts`.
   Generates 60 PNGs via lavfi `testsrc2`, encodes them as 4 VP9 chunks
   of 15 frames using `buildEncoderArgs` with
   `lockGopForChunkConcat=true`, concat-copies via `ffmpeg -f concat -c
   copy`, then runs three independent verifications:
   `ffprobe -show_streams`, `ffmpeg -f null -` decode test, and
   `ffprobe -count_frames`. Each verification surfaces its failure
   fingerprint in the error message.

Smoke test passes 6/6 locally → Path A works; the rest of the stack
takes it.

Also exports `buildEncoderArgs` from `@hyperframes/engine` so
adapters / tests can construct args without re-implementing the
contract.

## Testing

- [x] `bunx vitest run --root packages/engine src/services/chunkEncoder.test.ts` — 62/62 pass (new VP9 closed-GOP tests included)
- [x] `bun test packages/producer/tests/distributed/_smoke/webm-concat-copy.test.ts` — passes
- [x] `bunx oxlint` + `bunx oxfmt --check` on all changed files — clean
- [x] `bunx tsc --noEmit -p packages/engine/tsconfig.json` — clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
James Russo
2026-05-19 01:53:08 -04:00
committed by GitHub
parent efc5f0584b
commit 07de7e61ed
5 changed files with 446 additions and 5 deletions
+1
View File
@@ -93,6 +93,7 @@ export {
// ── Encoding ───────────────────────────────────────────────────────────────────
export {
buildEncoderArgs,
encodeFramesFromDir,
encodeFramesChunkedConcat,
muxVideoWithAudio,
@@ -551,7 +551,11 @@ describe("buildEncoderArgs lockGopForChunkConcat", () => {
expect(args.indexOf("-x264-params")).toBe(-1);
});
it("true is a no-op on VP9", () => {
// Closed-GOP for libvpx-vp9 is required to make `ffmpeg -f concat -c copy`
// stitch VP9 chunks losslessly: every chunk's first frame must be an
// independently-decodable keyframe with no alt-ref references reaching
// back across the seam.
it("true appends closed-GOP args for libvpx-vp9", () => {
const args = buildEncoderArgs(
{
...baseOptions,
@@ -564,9 +568,82 @@ describe("buildEncoderArgs lockGopForChunkConcat", () => {
inputArgs,
"out.webm",
);
expect(args[args.indexOf("-g") + 1]).toBe("240");
expect(args[args.indexOf("-keyint_min") + 1]).toBe("240");
// Alt-ref frames are non-displayable references that break concat-copy
// at chunk seams; closed-GOP must disable them.
expect(args[args.indexOf("-auto-alt-ref") + 1]).toBe("0");
// cpu-used is locked so workers with different libvpx-vp9 defaults
// produce visually consistent output across chunk boundaries.
expect(args[args.indexOf("-cpu-used") + 1]).toBe("2");
// libvpx-vp9 uses `-deadline good` for non-ultrafast presets — the
// closed-GOP path doesn't change that.
expect(args[args.indexOf("-deadline") + 1]).toBe("good");
// x264/x265-only params must not leak into the VP9 branch.
expect(args.indexOf("-x264-params")).toBe(-1);
expect(args.indexOf("-x265-params")).toBe(-1);
expect(args.indexOf("-sc_threshold")).toBe(-1);
expect(args.indexOf("-force_key_frames")).toBe(-1);
});
it("default (false) omits closed-GOP args for libvpx-vp9", () => {
const args = buildEncoderArgs(
{ ...baseOptions, codec: "vp9", preset: "good", quality: 23 },
inputArgs,
"out.webm",
);
expect(args).not.toContain("-g");
expect(args).not.toContain("-keyint_min");
expect(args).not.toContain("-force_key_frames");
expect(args).not.toContain("-cpu-used");
// The non-locked, non-alpha VP9 path leaves `-auto-alt-ref` at the
// libvpx default. Alpha branches still emit `-auto-alt-ref 0` for an
// unrelated reason (alpha + alt-ref is unsupported), but that's a
// separate test below.
expect(args).not.toContain("-auto-alt-ref");
});
it("true with alpha pixel format keeps alpha metadata and emits -auto-alt-ref once", () => {
// Regression: alpha + closed-GOP must NOT double-push `-auto-alt-ref 0`.
// Both paths want it disabled; the encoder branch emits it exactly once.
const args = buildEncoderArgs(
{
...baseOptions,
codec: "vp9",
preset: "good",
quality: 23,
pixelFormat: "yuva420p",
lockGopForChunkConcat: true,
gopSize: 240,
},
inputArgs,
"out.webm",
);
const autoAltRefIndices = args.reduce<number[]>((acc, a, i) => {
if (a === "-auto-alt-ref") acc.push(i);
return acc;
}, []);
expect(autoAltRefIndices.length).toBe(1);
expect(args[autoAltRefIndices[0] + 1]).toBe("0");
expect(args[args.indexOf("-metadata:s:v:0") + 1]).toBe("alpha_mode=1");
expect(args[args.indexOf("-g") + 1]).toBe("240");
});
it("vp9 + lockGopForChunkConcat=true throws on missing gopSize", () => {
// Mirrors the libx264/libx265 branch: closed-GOP without a GOP size
// makes no sense — surface the caller error eagerly.
expect(() =>
buildEncoderArgs(
{
...baseOptions,
codec: "vp9",
preset: "good",
quality: 23,
lockGopForChunkConcat: true,
},
inputArgs,
"out.webm",
),
).toThrow(/lockGopForChunkConcat=true requires a positive integer gopSize/);
});
it("true is a no-op on ProRes (intra-only — no GOP forcing needed)", () => {
+51 -1
View File
@@ -254,8 +254,58 @@ export function buildEncoderArgs(
args.push("-c:v", "libvpx-vp9", "-b:v", bitrate || "0", "-crf", String(quality));
args.push("-deadline", preset === "ultrafast" ? "realtime" : "good");
args.push("-row-mt", "1");
// Closed-GOP args for distributed chunk concat-copy. Mirrors the
// libx264/libx265 branch above: `lockGopForChunkConcat=true` lays a
// keyframe at every chunk boundary so `ffmpeg -f concat -c copy` can
// stitch sibling chunks losslessly.
//
// VP9-specific: `-auto-alt-ref 0` is mandatory. Alt-ref (a.k.a.
// "ARNR") frames are non-displayable references libvpx-vp9 inserts
// anywhere in the GOP for compression; they break concat-copy at
// chunk seams because the boundary frame is no longer the first
// displayable reference. The alpha branch below already disables
// alt-ref for an unrelated reason (alpha + alt-ref is unsupported);
// closed-GOP extends that to every pixel format.
//
// `-cpu-used 2` pins the libvpx-vp9 speed/quality tradeoff so chunks
// encoded on workers with different default cpu-used values still
// produce visually consistent output across seams. libvpx-vp9's
// default with `-deadline good` has drifted across versions
// historically — locking it makes the planHash round-trip
// deterministic.
const lockGopVp9 = options.lockGopForChunkConcat === true;
if (lockGopVp9) {
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)})`,
);
}
const gop = Math.floor(options.gopSize);
args.push(
"-g",
String(gop),
"-keyint_min",
String(gop),
"-auto-alt-ref",
"0",
"-cpu-used",
"2",
);
}
if (pixelFormat === "yuva420p") {
args.push("-auto-alt-ref", "0");
// Alpha + alt-ref is unsupported by libvpx-vp9. The closed-GOP
// branch above already disables alt-ref; only push the flag for
// the non-locked alpha case to keep the args list clean (a second
// `-auto-alt-ref 0` is harmless but noisier in `ffmpeg -loglevel`
// diagnostics).
if (!lockGopVp9) {
args.push("-auto-alt-ref", "0");
}
args.push("-metadata:s:v:0", "alpha_mode=1");
}
} else if (codec === "prores") {
@@ -22,8 +22,15 @@ export interface EncoderOptions {
* (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).
* Honored by the SW libx264 / libx265 / libvpx-vp9 paths. GPU encoders
* and ProRes ignore the flag GPU concat-copy is a separate story and
* ProRes is intra-only (every frame is already a keyframe, so no
* closed-GOP forcing is needed).
*
* For libvpx-vp9, closed-GOP also forces `-auto-alt-ref 0` so the
* boundary frame between chunks remains independently decodable
* libvpx-vp9's default alt-ref frames can land anywhere in the GOP
* for compression and break concat-copy seams.
*/
lockGopForChunkConcat?: boolean;
/**