Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export {

// ── Encoding ───────────────────────────────────────────────────────────────────
export {
buildEncoderArgs,
encodeFramesFromDir,
encodeFramesChunkedConcat,
muxVideoWithAudio,
Expand Down
81 changes: 79 additions & 2 deletions packages/engine/src/services/chunkEncoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)", () => {
Expand Down
52 changes: 51 additions & 1 deletion packages/engine/src/services/chunkEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
11 changes: 9 additions & 2 deletions packages/engine/src/services/chunkEncoder.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
Loading
Loading