mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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:
@@ -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)", () => {
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user