mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
feat(engine): lock GOP on every encoder path (#3973)
## What this does Video encoders normally choose their own keyframe positions. A keyframe is a frame that can be decoded on its own, and you can only cut a video cleanly at one. The engine already has a flag, `lockGopForChunkConcat`, that forces a keyframe every `gopSize` frames so chunk files can be joined with `ffmpeg -c copy`. Today that logic is copy-pasted in two places inside `buildEncoderArgs` and does not exist at all in the streaming encoder. This PR: 1. **Extracts the locked-GOP logic into three small shared helpers** in `chunkEncoder.ts`: `resolveLockedGopSize` (validation), `appendLockedGopArgs` (the `-g` / `-keyint_min` / `-sc_threshold` / `-force_key_frames` args), and `lockedGopCodecParams` (the x264/x265 param fragment). `buildEncoderArgs` now calls them instead of inlining the same block twice. 2. **Adds the same flag to the streaming encoder** (`StreamingEncoderOptions.lockGopForChunkConcat` + `gopSize`), which is the path most renders under four minutes actually use. It emits the identical args as the disk encoder, including `-bf 0` for h265 under a lock so B-frame reordering cannot reintroduce negative DTS at a boundary. ## What does not change - Any render that does not set the flag produces a byte-identical ffmpeg arg list. The flag defaults to `false` everywhere. - Nothing in the tree sets the flag yet except the distributed chunk worker, which already used the disk encoder. No user-facing behavior changes. - GPU encoders, VP9 and ProRes streaming encodes continue to ignore the flag, exactly as `EncoderOptions` documents. An earlier revision of this PR made GPU encoders honor it; that was dropped because the path is unreachable until HLS lands and was verified only on videotoolbox. ## Why Groundwork for `format: "hls"` in `@hyperframes/producer`. HLS packaging stream-copies the encoded video into fixed-length segments with `-f hls -c copy`, and the segmenter can only cut on keyframes the encoder already produced. Locking the GOP at encode time is what makes every segment land exactly on a 4-second boundary without re-encoding. The follow-up PR adds the format itself and should reject `--gpu` for `hls` in v1. ## Testing - `chunkEncoder.test.ts`: the three helpers tested directly; existing locked-GOP and GPU no-op tests unchanged. - `streamingEncoder.test.ts`: new `lockGopForChunkConcat` block covering default off, libx264, libx265, and invalid `gopSize`. - Engine typecheck, oxlint, oxfmt clean. Full engine suite matches main's baseline (the 4 `ffprobe.test.ts` HDR fixture failures are pre-existing on `main`). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -3,7 +3,14 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { ENCODER_PRESETS, getEncoderPreset, buildEncoderArgs } from "./chunkEncoder.js";
|
||||
import {
|
||||
ENCODER_PRESETS,
|
||||
appendLockedGopArgs,
|
||||
buildEncoderArgs,
|
||||
getEncoderPreset,
|
||||
lockedGopCodecParams,
|
||||
resolveLockedGopSize,
|
||||
} from "./chunkEncoder.js";
|
||||
import { renderProvenanceArgs } from "../utils/renderProvenance.js";
|
||||
|
||||
const TINY_PNG = Buffer.from(
|
||||
@@ -1473,6 +1480,39 @@ describe("buildEncoderArgs lockGopForChunkConcat", () => {
|
||||
).toThrow(/lockGopForChunkConcat=true requires a positive integer gopSize/);
|
||||
});
|
||||
|
||||
it("resolveLockedGopSize floors, passes through null, and rejects bad sizes", () => {
|
||||
expect(resolveLockedGopSize({})).toBeNull();
|
||||
expect(resolveLockedGopSize({ gopSize: 120 })).toBeNull();
|
||||
expect(resolveLockedGopSize({ lockGopForChunkConcat: true, gopSize: 120.7 })).toBe(120);
|
||||
for (const bad of [undefined, 0, -10, NaN, Infinity]) {
|
||||
expect(() =>
|
||||
resolveLockedGopSize({ lockGopForChunkConcat: true, gopSize: bad as number | undefined }),
|
||||
).toThrow(/lockGopForChunkConcat=true requires a positive integer gopSize/);
|
||||
}
|
||||
});
|
||||
|
||||
it("appendLockedGopArgs emits the closed-GOP quartet in order", () => {
|
||||
const args: string[] = [];
|
||||
appendLockedGopArgs(args, 120);
|
||||
expect(args).toEqual([
|
||||
"-g",
|
||||
"120",
|
||||
"-keyint_min",
|
||||
"120",
|
||||
"-sc_threshold",
|
||||
"0",
|
||||
"-force_key_frames",
|
||||
"expr:eq(mod(n,120),0)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lockedGopCodecParams adds keyint only for h265", () => {
|
||||
expect(lockedGopCodecParams("h264", 120)).toBe("scenecut=0:open-gop=0:repeat-headers=1");
|
||||
expect(lockedGopCodecParams("h265", 120)).toBe(
|
||||
"keyint=120:min-keyint=120:scenecut=0:open-gop=0:repeat-headers=1",
|
||||
);
|
||||
});
|
||||
|
||||
it("true is a no-op on ProRes (intra-only — no GOP forcing needed)", () => {
|
||||
const args = buildEncoderArgs(
|
||||
{
|
||||
|
||||
@@ -159,6 +159,60 @@ export function getEncoderPreset(
|
||||
// Re-export GPU utilities so existing consumers that import from chunkEncoder still work.
|
||||
export { detectGpuEncoder, type GpuEncoder } from "../utils/gpuEncoder.js";
|
||||
|
||||
/** The `lockGopForChunkConcat` / `gopSize` pair, shared by every encoder entry point. */
|
||||
export interface LockedGopOptions {
|
||||
lockGopForChunkConcat?: boolean;
|
||||
gopSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Integer GOP length, or `null` when no lock was requested. Throws on a lock
|
||||
* with an invalid size — a silent fallback ships open-GOP output that only
|
||||
* surfaces later as a broken playback seam.
|
||||
*/
|
||||
export function resolveLockedGopSize(options: LockedGopOptions): number | null {
|
||||
if (options.lockGopForChunkConcat !== true) return null;
|
||||
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)})`,
|
||||
);
|
||||
}
|
||||
return Math.floor(options.gopSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed-GOP / forced-keyframe args for libx264 / libx265, so an orchestrator
|
||||
* can concat chunks with `-c copy` or cut the stream into segments with
|
||||
* `-f hls -c copy`. Without them the encoder picks its own keyframes and a
|
||||
* boundary may not land on an independently decodable IDR.
|
||||
*/
|
||||
export function appendLockedGopArgs(args: string[], gopSize: number): void {
|
||||
args.push(
|
||||
"-g",
|
||||
String(gopSize),
|
||||
"-keyint_min",
|
||||
String(gopSize),
|
||||
"-sc_threshold",
|
||||
"0",
|
||||
"-force_key_frames",
|
||||
`expr:eq(mod(n,${gopSize}),0)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `-x264-params` / `-x265-params` fragment that bakes the IDR cadence into
|
||||
* the encoder itself — `-force_key_frames` alone still permits mini-GOPs with
|
||||
* open-GOP references. `repeat-headers=1` keeps each boundary self-contained.
|
||||
*/
|
||||
export function lockedGopCodecParams(codec: "h264" | "h265", gopSize: number): string {
|
||||
const shared = "scenecut=0:open-gop=0:repeat-headers=1";
|
||||
return codec === "h264" ? shared : `keyint=${gopSize}:min-keyint=${gopSize}:${shared}`;
|
||||
}
|
||||
|
||||
export function buildEncoderArgs(
|
||||
options: EncoderOptions,
|
||||
inputArgs: string[],
|
||||
@@ -258,34 +312,10 @@ export function buildEncoderArgs(
|
||||
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)`,
|
||||
);
|
||||
}
|
||||
// ffmpeg-concat chunk files with `-c copy`. See `appendLockedGopArgs`.
|
||||
const gop = resolveLockedGopSize(options);
|
||||
const lockGop = gop !== null;
|
||||
if (gop !== null) appendLockedGopArgs(args, gop);
|
||||
|
||||
// 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
|
||||
@@ -322,11 +352,7 @@ export function buildEncoderArgs(
|
||||
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 gopParams = gop !== null ? lockedGopCodecParams(codec, gop) : "";
|
||||
const joinParams = (...parts: string[]): string =>
|
||||
parts.filter((p) => p.length > 0).join(":");
|
||||
if (preset === "ultrafast") {
|
||||
@@ -354,19 +380,10 @@ export function buildEncoderArgs(
|
||||
// displayable reference when alt-ref is on. The shared `vp9CpuUsed`
|
||||
// option pins speed/quality against libvpx-vp9 default drift across
|
||||
// versions for both chunked and streaming WebM encodes.
|
||||
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");
|
||||
const vp9Gop = resolveLockedGopSize(options);
|
||||
const lockGopVp9 = vp9Gop !== null;
|
||||
if (vp9Gop !== null) {
|
||||
args.push("-g", String(vp9Gop), "-keyint_min", String(vp9Gop), "-auto-alt-ref", "0");
|
||||
}
|
||||
if (pixelFormat === "yuva420p") {
|
||||
// Alpha + alt-ref is unsupported by libvpx-vp9. The closed-GOP
|
||||
|
||||
@@ -363,6 +363,64 @@ describe("buildStreamingArgs", () => {
|
||||
expect(args[vfIdx + 1]).toBe("scale=in_range=pc:out_range=tv,format=nv12,hwupload");
|
||||
});
|
||||
});
|
||||
|
||||
// The HLS format locks the GOP so `-f hls -c copy` can cut segments on
|
||||
// exact time boundaries. Every other format must keep the old arg list.
|
||||
describe("lockGopForChunkConcat", () => {
|
||||
it("omits closed-GOP args by default", () => {
|
||||
const args = buildStreamingArgs(baseSdr, "/tmp/out.mp4");
|
||||
expect(args).not.toContain("-g");
|
||||
expect(args).not.toContain("-keyint_min");
|
||||
expect(args).not.toContain("-sc_threshold");
|
||||
expect(args).not.toContain("-force_key_frames");
|
||||
const paramIdx = args.indexOf("-x264-params");
|
||||
expect(args[paramIdx + 1]).not.toContain("open-gop=0");
|
||||
});
|
||||
|
||||
it("emits closed-GOP args and x264-params for libx264", () => {
|
||||
const args = buildStreamingArgs(
|
||||
{ ...baseSdr, lockGopForChunkConcat: true, gopSize: 120 },
|
||||
"/tmp/out.mp4",
|
||||
);
|
||||
expect(args[args.indexOf("-g") + 1]).toBe("120");
|
||||
expect(args[args.indexOf("-keyint_min") + 1]).toBe("120");
|
||||
expect(args[args.indexOf("-sc_threshold") + 1]).toBe("0");
|
||||
expect(args[args.indexOf("-force_key_frames") + 1]).toBe("expr:eq(mod(n,120),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");
|
||||
expect(args[paramIdx + 1]).toContain("aq-strength=0.8");
|
||||
expect(args[args.indexOf("-bf") + 1]).toBe("0");
|
||||
});
|
||||
|
||||
it("emits keyint in x265-params and disables B-frames for libx265", () => {
|
||||
const args = buildStreamingArgs(
|
||||
{
|
||||
...baseSdr,
|
||||
codec: "h265",
|
||||
lockGopForChunkConcat: true,
|
||||
gopSize: 90,
|
||||
},
|
||||
"/tmp/out.mp4",
|
||||
);
|
||||
expect(args[args.indexOf("-g") + 1]).toBe("90");
|
||||
expect(getX265ParamsValue(args)).toContain("keyint=90");
|
||||
expect(getX265ParamsValue(args)).toContain("min-keyint=90");
|
||||
expect(args[args.indexOf("-bf") + 1]).toBe("0");
|
||||
});
|
||||
|
||||
it("throws on a missing or invalid gopSize", () => {
|
||||
for (const bad of [undefined, 0, -10, NaN, Infinity]) {
|
||||
expect(() =>
|
||||
buildStreamingArgs(
|
||||
{ ...baseSdr, lockGopForChunkConcat: true, gopSize: bad as number | undefined },
|
||||
"/tmp/out.mp4",
|
||||
),
|
||||
).toThrow(/lockGopForChunkConcat=true requires a positive integer gopSize/);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFrameReorderBuffer", () => {
|
||||
|
||||
@@ -40,6 +40,8 @@ import { fpsToFfmpegArg, fpsToNumber, type Fps } from "@hyperframes/core";
|
||||
import { appendVp9CpuUsedArg } from "./vp9Options.js";
|
||||
import { appendRenderProvenanceArgs } from "../utils/renderProvenance.js";
|
||||
|
||||
import { appendLockedGopArgs, lockedGopCodecParams, resolveLockedGopSize } from "./chunkEncoder.js";
|
||||
|
||||
// Re-export EncoderOptions so callers can reference the type via this module.
|
||||
export type { EncoderOptions } from "./chunkEncoder.types.js";
|
||||
|
||||
@@ -154,6 +156,16 @@ export interface StreamingEncoderOptions {
|
||||
hdr?: { transfer: import("../utils/hdr.js").HdrTransfer };
|
||||
/** When set, use rawvideo input instead of image2pipe. For HDR PQ-encoded frames. */
|
||||
rawInputFormat?: "rgb48le";
|
||||
/**
|
||||
* Force an IDR keyframe every `gopSize` frames. Set by the HLS format so
|
||||
* `-f hls -c copy` can cut segments on exact time boundaries — the segmenter
|
||||
* only splits at keyframes the encoder already emitted. Default `false`
|
||||
* leaves the arg list byte-identical. libx264 / libx265 only (GPU encoders,
|
||||
* VP9 and ProRes ignore it); see `EncoderOptions.lockGopForChunkConcat`.
|
||||
*/
|
||||
lockGopForChunkConcat?: boolean;
|
||||
/** Required when `lockGopForChunkConcat` is `true`. Frames per GOP. */
|
||||
gopSize?: number;
|
||||
}
|
||||
|
||||
export interface StreamingEncoderResult {
|
||||
@@ -320,11 +332,18 @@ export function buildStreamingArgs(
|
||||
if (bitrate) args.push("-b:v", bitrate);
|
||||
else args.push("-crf", String(quality));
|
||||
|
||||
// Same closed-GOP lock as chunkEncoder.buildEncoderArgs, so the HLS
|
||||
// packager can cut `-c copy` segments on exact keyframe boundaries.
|
||||
const lockedGop = resolveLockedGopSize(options);
|
||||
if (lockedGop !== null) appendLockedGopArgs(args, lockedGop);
|
||||
|
||||
// Mirrors chunkEncoder: disable B-frames for h264 so PTS == DTS, no
|
||||
// negative DTS at stream start. Without this, files freeze on the
|
||||
// first frame in VS Code preview, several browsers, and some HW
|
||||
// decoders. See chunkEncoder.buildEncoderArgs for the full reasoning.
|
||||
if (codec === "h264") {
|
||||
// h265 also gets `-bf 0` under a locked GOP: B-frame reordering across
|
||||
// a segment boundary brings the same negative-DTS hazard back at every seam.
|
||||
if (codec === "h264" || lockedGop !== null) {
|
||||
args.push("-bf", "0");
|
||||
}
|
||||
|
||||
@@ -338,10 +357,11 @@ export function buildStreamingArgs(
|
||||
options.rawInputFormat && options.hdr
|
||||
? getHdrEncoderColorParams(options.hdr.transfer).x265ColorParams
|
||||
: "colorprim=bt709:transfer=bt709:colormatrix=bt709";
|
||||
const gopParams = lockedGop !== null ? `:${lockedGopCodecParams(codec, lockedGop)}` : "";
|
||||
if (preset === "ultrafast") {
|
||||
args.push(xParamsFlag, `aq-mode=3:${colorParams}`);
|
||||
args.push(xParamsFlag, `aq-mode=3:${colorParams}${gopParams}`);
|
||||
} else {
|
||||
args.push(xParamsFlag, `aq-mode=3:aq-strength=0.8:deblock=1,1:${colorParams}`);
|
||||
args.push(xParamsFlag, `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)
|
||||
if (codec === "h265") {
|
||||
|
||||
Reference in New Issue
Block a user