feat(engine): render sets a keyframe interval and a peak bitrate cap (#6444)

* feat(engine): encoders cap peak bitrate and place keyframes on a set interval

* style(engine): shorten the rate-cap comment

* style(engine): drop the doc lines the comment ratchet flagged

* feat(producer): mp4 renders honour a keyframe interval and a peak bitrate cap on every encoder path

* style(producer): shorten the delivery encoder file headers

* style(producer): drop a comment line from the segment manifest

* style(producer): shorten the integration test header

* test: delivery encoder tests follow the ffprobe argv contract and run in the integration lane

* feat: the render request, server and Lambda plan carry the keyframe interval and peak bitrate cap

* fix(studio): the Studio render route passes the keyframe interval and bitrate cap to the encoder

* chore(studio-server): drop a comment the helper's name already says

* feat(cli): render and lambda render take --keyframe-interval, --max-bitrate and --buffer-size
This commit is contained in:
Miguel Ángel
2026-10-27 00:29:06 +00:00
committed by GitHub
parent 95e3930faf
commit 54d0af57eb
50 changed files with 1101 additions and 38 deletions
+12
View File
@@ -143,6 +143,18 @@ MOV is an alpha-preserving editing intermediate and always uses the fixed
ProRes 4444 profile. `--crf` and `--video-bitrate` do not map to that profile
and are rejected for MOV; choose MP4 or WebM when you need those controls.
For delivery to a player or platform, an MP4 render can also set how often a
keyframe lands and how high the bitrate may peak. `--keyframe-interval 2` puts a
keyframe at least every two seconds. `--max-bitrate 8M` caps the peak while the
quality target still decides the rest, and `--buffer-size` (default twice the cap)
sets the window the cap is measured over. Left out, the encode is unchanged.
On Lambda the interval is a maximum, because joining chunks adds keyframes, and
the cap holds per chunk, so a join can burst up to one buffer.
```bash
npx hyperframes render --keyframe-interval 2 --max-bitrate 8M --output delivery.mp4
```
```bash
# Faster review version
npx hyperframes render --quality draft --output review.mp4
+6
View File
@@ -1496,6 +1496,9 @@ Quality and file size:
| ---------------------- | --------------- | -------------------------------------------------------------------------------------------------------- |
| `--crf` | from `--quality`| Override MP4/WebM encoder CRF, 0–51. Lower is better quality. Not with `--video-bitrate`; rejected for MOV. |
| `--video-bitrate` | from `--quality`| Target MP4/WebM bitrate, e.g. `10M` or `5000k`. Not with `--crf`; rejected for MOV. |
| `--keyframe-interval` | encoder default | MP4 only. A keyframe at least every N seconds, e.g. `2`, so a player can seek or switch rendition at that spacing. |
| `--max-bitrate` | uncapped | MP4 only. Peak video bitrate cap, e.g. `8M`, held alongside the `--crf` or `--quality` target rather than replacing it. |
| `--buffer-size` | 2 × `--max-bitrate` | MP4 only. Buffer the cap is measured over, e.g. `16M`. Needs `--max-bitrate`. |
| `--vp9-cpu-used` | encoder default | libvpx-vp9 speed/quality trade-off for WebM, -8 to 8. Env: `PRODUCER_VP9_CPU_USED`. |
| `--gif-loop` | `0` | GIF loop count, `0` for forever. Range 0–65535, `--format gif` only. |
| `--hls-segment-seconds`| `4` | HLS target segment length in whole seconds, 1–60. `--format hls` only. Also fixes the encoder GOP so every segment starts on a keyframe. |
@@ -2277,6 +2280,9 @@ And these drive `render` and `render-batch`:
| `--chunk-size` | auto | Frames per chunk. By default each chunk is sized to finish inside the Lambda timeout |
| `--max-parallel-chunks` | auto | Chunks per render. By default one wave across the deploy's `--concurrency` (or 90% of the account's unreserved concurrency), widened when a chunk would outrun the Lambda timeout |
| `--target-chunk-frames` | — | Ceiling on frames per chunk. The planner adds chunks — up to `--max-parallel-chunks` — to stay under it, and short videos still collapse to fewer. Ignored when `--chunk-size` is set. |
| `--keyframe-interval` | — | MP4 only. Longest gap between keyframes, in seconds. Chunk joins add keyframes, so this is a maximum; with a chunk shorter than the interval the chunk length wins. |
| `--max-bitrate` | — | MP4 only. Peak video bitrate cap, e.g. `8M`. Each chunk is capped on its own, so a join between two chunks can burst up to one `--buffer-size`. |
| `--buffer-size` | 2 × `--max-bitrate` | MP4 only. Buffer for the cap, e.g. `16M`. Needs `--max-bitrate`. |
| `--variables` | — | JSON object reaching every chunk worker as `window.__hfVariables` |
| `--variables-file` | — | Read those from a JSON file |
| `--strict-variables` | off | Fail instead of warn on an undeclared or mistyped key |
+4
View File
@@ -16,6 +16,7 @@ import { parseOutputResolutionFlag } from "../utils/parseOutputResolution.js";
import type { Example } from "./_examples.js";
import type { VideoArch } from "./lambda/videoImage.js";
import { c } from "../ui/colors.js";
import { deliveryFlagArgs, parseDeliveryFlags } from "../utils/deliveryFlags.js";
import { defaultCloudFps } from "../utils/compositionFps.js";
import { isMissingPackageError, missingPackageMessage } from "../utils/missingPackage.js";
@@ -153,6 +154,7 @@ export default defineCommand({
format: { type: "string", description: "mp4 | mov | png-sequence | webm (default: mp4)" },
codec: { type: "string", description: "h264 | h265 (mp4 only)" },
quality: { type: "string", description: "draft | standard | high" },
...deliveryFlagArgs,
"chunk-size": {
type: "string",
description:
@@ -340,6 +342,7 @@ export default defineCommand({
format: parseFormat(args.format),
codec: parseCodec(args.codec),
quality: parseQuality(args.quality),
...parseDeliveryFlags(args),
chunkSize: parsePositiveInt(args["chunk-size"], "--chunk-size"),
maxParallelChunks: parsePositiveInt(args["max-parallel-chunks"], "--max-parallel-chunks"),
targetChunkFrames: parsePositiveInt(args["target-chunk-frames"], "--target-chunk-frames"),
@@ -397,6 +400,7 @@ export default defineCommand({
format: parseFormat(args.format),
codec: parseCodec(args.codec),
quality: parseQuality(args.quality),
...parseDeliveryFlags(args),
chunkSize: parsePositiveInt(args["chunk-size"], "--chunk-size"),
maxParallelChunks: parsePositiveInt(args["max-parallel-chunks"], "--max-parallel-chunks"),
targetChunkFrames: parsePositiveInt(args["target-chunk-frames"], "--target-chunk-frames"),
@@ -168,6 +168,12 @@ describe("buildLambdaBatchRenderConfig — aspect-agnostic wire threading", () =
expect(config.outputResolutionAspectAgnostic).toBeUndefined();
});
it("sends the delivery options to every batch entry", () => {
expect(
buildLambdaBatchRenderConfig({ ...baseArgs, keyframeIntervalSeconds: 2, maxBitrate: "8M" }),
).toMatchObject({ keyframeIntervalSeconds: 2, maxBitrate: "8M" });
});
it("hands the deploy's reserved concurrency to the planner", () => {
expect(buildLambdaBatchRenderConfig(baseArgs, 64).workerConcurrency).toBe(64);
});
@@ -41,6 +41,7 @@ import { normalizeErrorMessage } from "../../utils/errorMessage.js";
import { resolveOutputResolutionFromSize } from "./_dimensions.js";
import { runWithConcurrencyLimit } from "../../utils/concurrency.js";
import { requireStack } from "./state.js";
import type { DeliveryFlagValues } from "../../utils/deliveryFlags.js";
// Dynamic-import the SDK so tsup keeps it out of the static-import head of
// the CLI bundle. See sites.ts loadSDK() for the full rationale.
@@ -49,7 +50,7 @@ async function loadSDK(): Promise<typeof import("@hyperframes/aws-lambda/sdk")>
}
/** Arguments accepted by `hyperframes lambda render-batch`. */
export interface RenderBatchArgs {
export interface RenderBatchArgs extends DeliveryFlagValues {
projectDir: string;
stackName: string;
/** Path to the JSONL batch file. Each line is a {@link BatchEntry}. */
@@ -361,6 +362,9 @@ export function buildLambdaBatchRenderConfig(
chunkSize: args.chunkSize,
maxParallelChunks: args.maxParallelChunks,
targetChunkFrames: args.targetChunkFrames,
keyframeIntervalSeconds: args.keyframeIntervalSeconds,
maxBitrate: args.maxBitrate,
bufferSize: args.bufferSize,
workerConcurrency,
runtimeCap: "lambda",
};
@@ -51,6 +51,21 @@ describe("buildLambdaRenderConfig — aspect-agnostic wire threading", () => {
expect(config.outputResolutionAspectAgnostic).toBeUndefined();
});
it("sends the delivery options on the wire and nothing when they are unset", () => {
const config = buildLambdaRenderConfig(
{ ...BASE_ARGS, keyframeIntervalSeconds: 2, maxBitrate: "8M", bufferSize: "12M" },
undefined,
);
expect(config).toMatchObject({
keyframeIntervalSeconds: 2,
maxBitrate: "8M",
bufferSize: "12M",
});
expect(
JSON.parse(JSON.stringify(buildLambdaRenderConfig(BASE_ARGS, undefined))),
).not.toHaveProperty("maxBitrate");
});
it("carries variables verbatim through the wire config", () => {
const config = buildLambdaRenderConfig(BASE_ARGS, { alice: "hello" });
expect(config.variables).toEqual({ alice: "hello" });
+5 -1
View File
@@ -13,6 +13,7 @@ import type {
} from "@hyperframes/aws-lambda/sdk";
import type { CanvasResolution, DistributedFps } from "@hyperframes/core";
import { c } from "../../ui/colors.js";
import type { DeliveryFlagValues } from "../../utils/deliveryFlags.js";
import {
reportVariableIssues,
resolveVariablesArg,
@@ -27,7 +28,7 @@ async function loadSDK(): Promise<typeof import("@hyperframes/aws-lambda/sdk")>
return import("@hyperframes/aws-lambda/sdk");
}
export interface RenderArgs {
export interface RenderArgs extends DeliveryFlagValues {
projectDir: string;
stackName: string;
siteId?: string;
@@ -255,6 +256,9 @@ export function buildLambdaRenderConfig(
chunkSize: args.chunkSize,
maxParallelChunks: args.maxParallelChunks,
targetChunkFrames: args.targetChunkFrames,
keyframeIntervalSeconds: args.keyframeIntervalSeconds,
maxBitrate: args.maxBitrate,
bufferSize: args.bufferSize,
workerConcurrency,
runtimeCap: "lambda",
variables,
+11
View File
@@ -6,6 +6,7 @@ import type { Example } from "./_examples.js";
import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
import { createRenderPlan, resolveBrowserGpuForCli, type RenderFormat } from "./render/plan.js";
import { seedProjectAuthoringSkill } from "../utils/projectConfig.js";
import { deliveryFlagArgs } from "../utils/deliveryFlags.js";
import type { CatalogUsage } from "../utils/catalogUsage.js";
import { presentRenderPlan } from "./render/present.js";
import { executeRenderPlan, renderLintContinuationHint, runRenderLint } from "./render/execute.js";
@@ -261,6 +262,7 @@ export default defineCommand({
description:
"Target MP4/WebM video bitrate such as 10M. Mutually exclusive with --crf; unsupported for MOV.",
},
...deliveryFlagArgs,
"vp9-cpu-used": {
type: "string",
description:
@@ -516,6 +518,9 @@ export interface RenderOptions {
crf?: number;
vp9CpuUsed?: number;
videoBitrate?: string;
keyframeIntervalSeconds?: number;
maxBitrate?: string;
bufferSize?: string;
videoFrameFormat?: VideoFrameFormat;
pageMotionBlur?: "off";
quiet: boolean;
@@ -846,6 +851,9 @@ async function renderDocker(
crf: options.crf,
vp9CpuUsed: options.vp9CpuUsed,
videoBitrate: options.videoBitrate,
keyframeIntervalSeconds: options.keyframeIntervalSeconds,
maxBitrate: options.maxBitrate,
bufferSize: options.bufferSize,
videoFrameFormat: options.videoFrameFormat,
pageMotionBlur: options.pageMotionBlur,
quiet: options.quiet,
@@ -1125,6 +1133,9 @@ async function executeLocalRender(
hdrMode: options.hdrMode,
crf: options.crf,
videoBitrate: options.videoBitrate,
keyframeIntervalSeconds: options.keyframeIntervalSeconds,
maxBitrate: options.maxBitrate,
bufferSize: options.bufferSize,
videoFrameFormat: options.videoFrameFormat,
pageMotionBlur: options.pageMotionBlur,
variables: options.variables,
@@ -152,6 +152,9 @@ export function renderOptionsFromPlan(
crf: plan.crf,
vp9CpuUsed: plan.vp9CpuUsed,
videoBitrate: plan.videoBitrate,
keyframeIntervalSeconds: plan.keyframeIntervalSeconds,
maxBitrate: plan.maxBitrate,
bufferSize: plan.bufferSize,
videoFrameFormat: plan.videoFrameFormat,
pageMotionBlur: plan.pageMotionBlur,
quiet: plan.quiet,
@@ -357,6 +360,9 @@ export function batchRowRenderOptions(
crf: plan.crf,
vp9CpuUsed: plan.vp9CpuUsed,
videoBitrate: plan.videoBitrate,
keyframeIntervalSeconds: plan.keyframeIntervalSeconds,
maxBitrate: plan.maxBitrate,
bufferSize: plan.bufferSize,
videoFrameFormat: plan.videoFrameFormat,
pageMotionBlur: plan.pageMotionBlur,
quiet: batchQuiet,
@@ -222,6 +222,19 @@ describe("createRenderPlan", () => {
).toBe("10M");
});
it("carries the delivery options into the plan for MP4 and refuses them for other formats", () => {
const flags = { "keyframe-interval": "2", "max-bitrate": "8M", "buffer-size": "12M" };
expect(createRenderPlan({ dir: projectDir, format: "mp4", ...flags })).toMatchObject({
keyframeIntervalSeconds: 2,
maxBitrate: "8M",
bufferSize: "12M",
});
expect(createRenderPlan({ dir: projectDir }).maxBitrate).toBeUndefined();
for (const format of ["webm", "mov", "gif", "hls"]) {
expect(() => createRenderPlan({ dir: projectDir, format, ...flags })).toThrow(CliUsageError);
}
});
it("rejects batch and single-render variables before execution", () => {
expect(() =>
createRenderPlan({ dir: projectDir, batch: "rows.json", variables: '{"name":"Ada"}' }),
+18
View File
@@ -18,6 +18,7 @@ import {
type VideoFrameFormat,
} from "@hyperframes/engine";
import { errorBox } from "../../ui/format.js";
import { parseDeliveryFlags } from "../../utils/deliveryFlags.js";
import { parseEnumFlag } from "../../cloud/parsing.js";
import { failUsage } from "../../utils/commandResult.js";
import { resolveProject } from "../../utils/project.js";
@@ -83,6 +84,9 @@ export interface RenderCommandArgs {
sdr?: boolean;
crf?: string;
"video-bitrate"?: string;
"keyframe-interval"?: string;
"max-bitrate"?: string;
"buffer-size"?: string;
"vp9-cpu-used"?: string;
gpu?: boolean;
"browser-gpu"?: boolean;
@@ -161,6 +165,9 @@ export interface RenderPlan {
crf?: number;
vp9CpuUsed?: number;
videoBitrate?: string;
keyframeIntervalSeconds?: number;
maxBitrate?: string;
bufferSize?: string;
hdrMode: HdrMode;
pageSideCompositing: boolean;
experimentalFastCapture: boolean;
@@ -516,6 +523,16 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
failUsage();
}
const delivery = parseDeliveryFlags(args);
if (Object.keys(delivery).length > 0 && format !== "mp4") {
errorBox(
"Delivery options apply to MP4",
"--keyframe-interval, --max-bitrate and --buffer-size set how the MP4 encoder delivers the file.",
"Remove them, or render --format mp4.",
);
failUsage();
}
const quiet = args.quiet ?? false;
const batchJson = args.json ?? false;
return Object.freeze({
@@ -562,6 +579,7 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
crf,
vp9CpuUsed,
videoBitrate,
...delivery,
hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
pageSideCompositing: args["page-side-compositing"] !== false,
experimentalFastCapture: args["experimental-fast-capture"] === true,
+3
View File
@@ -19,6 +19,9 @@ Requires: Docker installed and running.
- `-w, --workers` — Parallel workers 1-8 (default: auto)
- `--crf` — Override encoder CRF (mutually exclusive with `--video-bitrate`)
- `--video-bitrate` — Target video bitrate such as `10M` (mutually exclusive with `--crf`)
- `--keyframe-interval` — MP4 only: a keyframe at least every N seconds (default: encoder's choice)
- `--max-bitrate` — MP4 only: peak video bitrate cap such as `8M`, kept alongside the quality target (default: uncapped)
- `--buffer-size` — MP4 only: buffer for `--max-bitrate`, such as `16M` (default: twice `--max-bitrate`)
- `--vp9-cpu-used` — WebM VP9 speed/quality tradeoff (`-8` to `8`, default: `4`). Higher values encode faster with larger output / quality tradeoff.
- `--video-frame-format` — Source video frame extraction format: `auto`, `jpg`, or `png` (default: `auto`). Use `png` for UI recordings, screen captures, and color-sensitive source videos.
- `--gpu` — Use GPU encoding (NVENC, VideoToolbox, AMF, VAAPI, QSV)
+2 -4
View File
@@ -66,6 +66,7 @@ import {
HistoryClosedError,
historyCache,
} from "@hyperframes/studio-server";
import { encoderJobOptions } from "@hyperframes/studio-server/encoder-options";
import { resolveAutoProxy } from "../utils/projectConfig.js";
import {
clearElementScreenshotIsolation,
@@ -685,10 +686,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
...(manualEditsRenderScript ? { renderBodyScripts: [manualEditsRenderScript] } : {}),
...(opts.composition ? { entryFile: opts.composition } : {}),
...(opts.variables ? { variables: opts.variables } : {}),
...(opts.crf !== undefined ? { crf: opts.crf } : {}),
...(opts.videoBitrate ? { videoBitrate: opts.videoBitrate } : {}),
...(opts.videoFrameFormat ? { videoFrameFormat: opts.videoFrameFormat } : {}),
...(opts.hdrMode ? { hdrMode: opts.hdrMode } : {}),
...encoderJobOptions(opts),
...(opts.useGpu ? { useGpu: true } : {}),
...(opts.pageMotionBlur ? { pageMotionBlur: opts.pageMotionBlur } : {}),
});
@@ -0,0 +1,33 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CliUsageError } from "./commandResult.js";
import { parseDeliveryFlags } from "./deliveryFlags.js";
describe("parseDeliveryFlags", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => vi.restoreAllMocks());
it("returns nothing when no flag is set", () => {
expect(parseDeliveryFlags({})).toEqual({});
});
it("reads the interval as seconds and keeps the bitrate strings as typed", () => {
expect(
parseDeliveryFlags({
"keyframe-interval": "2.5",
"max-bitrate": " 8M ",
"buffer-size": "12M",
}),
).toEqual({ keyframeIntervalSeconds: 2.5, maxBitrate: "8M", bufferSize: "12M" });
});
it.each([["0"], ["-1"], ["abc"], [""]])("rejects keyframe interval %j", (value) => {
expect(() => parseDeliveryFlags({ "keyframe-interval": value })).toThrow(CliUsageError);
});
it("rejects a bad cap and a buffer without a cap before any render starts", () => {
expect(() => parseDeliveryFlags({ "max-bitrate": "fast" })).toThrow(CliUsageError);
expect(() => parseDeliveryFlags({ "buffer-size": "2M" })).toThrow(CliUsageError);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { appendRateCapArgs } from "@hyperframes/engine";
import { errorBox } from "../ui/format.js";
import { failUsage } from "./commandResult.js";
/** The delivery flags `hyperframes render` and `hyperframes lambda render` share. */
export const deliveryFlagArgs = {
"keyframe-interval": {
type: "string",
description:
"MP4 keyframe interval in seconds, such as 2 (a keyframe at least this often; default: encoder's choice).",
},
"max-bitrate": {
type: "string",
description:
"MP4 peak video bitrate cap, such as 8M, kept alongside the quality target (default: uncapped).",
},
"buffer-size": {
type: "string",
description: "Buffer for --max-bitrate, such as 16M (default: twice --max-bitrate).",
},
} as const;
export interface DeliveryFlagValues {
keyframeIntervalSeconds?: number;
maxBitrate?: string;
bufferSize?: string;
}
interface DeliveryFlagArgs {
"keyframe-interval"?: string;
"max-bitrate"?: string;
"buffer-size"?: string;
}
/** Parse the delivery flags, exiting with a usage error on a value the encoder would refuse. */
export function parseDeliveryFlags(args: DeliveryFlagArgs): DeliveryFlagValues {
const interval = args["keyframe-interval"]?.trim();
const maxBitrate = args["max-bitrate"]?.trim();
const bufferSize = args["buffer-size"]?.trim();
const values: DeliveryFlagValues = {};
if (interval !== undefined) {
const seconds = Number(interval);
if (interval === "" || !Number.isFinite(seconds) || seconds <= 0) {
errorBox(
"Invalid keyframe-interval",
`Got "${interval}". Must be a positive number of seconds.`,
);
failUsage();
}
values.keyframeIntervalSeconds = seconds;
}
if (maxBitrate !== undefined) values.maxBitrate = maxBitrate;
if (bufferSize !== undefined) values.bufferSize = bufferSize;
try {
appendRateCapArgs([], values);
} catch (error) {
errorBox("Invalid bitrate cap", error instanceof Error ? error.message : String(error));
failUsage();
}
return values;
}
@@ -296,6 +296,20 @@ describe("buildDockerRunArgs", () => {
expect(args[loopIdx + 1]).toBe("0");
});
it("forwards the delivery options to the container only when set", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, keyframeIntervalSeconds: 2, maxBitrate: "8M", bufferSize: "12M" },
});
const after = (flag: string) => args[args.indexOf(flag) + 1];
expect(after("--keyframe-interval")).toBe("2");
expect(after("--max-bitrate")).toBe("8M");
expect(after("--buffer-size")).toBe("12M");
const plain = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(plain).not.toContain("--max-bitrate");
expect(plain).not.toContain("--keyframe-interval");
});
it("forwards --video-bitrate to the container when set", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
+8
View File
@@ -50,6 +50,9 @@ export interface DockerRenderOptions {
crf?: number;
vp9CpuUsed?: number;
videoBitrate?: string;
keyframeIntervalSeconds?: number;
maxBitrate?: string;
bufferSize?: string;
videoFrameFormat?: "auto" | "jpg" | "png";
pageMotionBlur?: "off";
quiet: boolean;
@@ -143,6 +146,11 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
...(options.crf != null ? ["--crf", String(options.crf)] : []),
...(options.vp9CpuUsed != null ? ["--vp9-cpu-used", String(options.vp9CpuUsed)] : []),
...(options.videoBitrate ? ["--video-bitrate", options.videoBitrate] : []),
...(options.keyframeIntervalSeconds != null
? ["--keyframe-interval", String(options.keyframeIntervalSeconds)]
: []),
...(options.maxBitrate ? ["--max-bitrate", options.maxBitrate] : []),
...(options.bufferSize ? ["--buffer-size", options.bufferSize] : []),
...(options.videoFrameFormat && options.videoFrameFormat !== "auto"
? ["--video-frame-format", options.videoFrameFormat]
: []),
+2
View File
@@ -191,6 +191,8 @@ export {
detectGpuEncoder,
ENCODER_PRESETS,
getEncoderPreset,
appendRateCapArgs,
keyframeIntervalToGopSize,
HLS_MASTER_PLAYLIST,
HLS_VIDEO_PLAYLIST,
HLS_AUDIO_PLAYLIST,
@@ -225,6 +225,43 @@ export function lockedGopCodecParams(codec: "h264" | "h265", gopSize: number): s
return codec === "h264" ? shared : `keyint=${gopSize}:min-keyint=${gopSize}:${shared}`;
}
/** Frames per keyframe interval: the nearest whole frame, never below one. */
export function keyframeIntervalToGopSize(fps: Fps, seconds: number): number {
return Math.max(1, Math.round(fpsToNumber(fps) * seconds));
}
const BITRATE_PATTERN = /^(\d+(?:\.\d+)?)([kKmMgG]?)$/;
function bitrateToBitsPerSecond(value: string): number {
const match = BITRATE_PATTERN.exec(value);
if (!match) throw new Error(`[chunkEncoder] invalid bitrate ${JSON.stringify(value)}`);
const unit = match[2]!.toLowerCase();
const scale = { g: 1_000_000_000, m: 1_000_000, k: 1_000 }[unit] ?? 1;
return Number(match[1]) * scale;
}
/** `-maxrate` / `-bufsize` for the VBV cap, written once for both arg builders. Buffer defaults to 2x. */
export function appendRateCapArgs(
args: string[],
options: { codec?: string; maxBitrate?: string; bufferSize?: string },
): void {
const { maxBitrate, bufferSize } = options;
if (maxBitrate === undefined) {
if (bufferSize !== undefined) throw new Error("[chunkEncoder] bufferSize requires maxBitrate");
return;
}
const codec = options.codec ?? "h264";
if (codec !== "h264" && codec !== "h265") {
throw new Error(`[chunkEncoder] maxBitrate is not supported for codec ${codec}`);
}
const cap = bitrateToBitsPerSecond(maxBitrate);
const buffer = bufferSize !== undefined ? bitrateToBitsPerSecond(bufferSize) : cap * 2;
if (cap <= 0 || buffer <= 0) {
throw new Error("[chunkEncoder] maxBitrate and bufferSize must be positive");
}
args.push("-maxrate", String(Math.round(cap)), "-bufsize", String(Math.round(buffer)));
}
export function buildEncoderArgs(
options: EncoderOptions,
inputArgs: string[],
@@ -257,6 +294,7 @@ export function buildEncoderArgs(
// Without -xerror, ffmpeg ends the video at the first frame file it cannot read and still exits 0.
const args: string[] = ["-xerror", ...inputArgs, "-r", fpsToFfmpegArg(fps)];
const shouldUseGpu = useGpu && gpuEncoder !== null;
appendRateCapArgs(args, options);
if (codec === "h264" || codec === "h265") {
if (shouldUseGpu) {
@@ -10,6 +10,8 @@ export interface EncoderOptions {
preset?: string;
quality?: number;
bitrate?: string;
maxBitrate?: string;
bufferSize?: string;
pixelFormat?: string;
/** libvpx-vp9 -cpu-used value. Defaults to the engine VP9 setting. */
vp9CpuUsed?: number;
@@ -0,0 +1,164 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import { getFfmpegBinary, getFfprobeBinary } from "../utils/ffmpegBinaries.js";
import {
appendRateCapArgs,
buildEncoderArgs,
keyframeIntervalToGopSize,
type EncoderOptions,
} from "./chunkEncoder.js";
import { buildStreamingArgs } from "./streamingEncoder.js";
const HAS_FFMPEG = spawnSync(getFfmpegBinary(), ["-version"], { encoding: "utf-8" }).status === 0;
const base = {
fps: { num: 30, den: 1 },
width: 320,
height: 240,
preset: "ultrafast",
};
const capOf = (args: string[]) =>
args.slice(args.indexOf("-maxrate"), args.indexOf("-maxrate") + 4);
describe("keyframeIntervalToGopSize", () => {
it("converts seconds to the nearest whole frame, never below one", () => {
expect(keyframeIntervalToGopSize({ num: 30, den: 1 }, 2)).toBe(60);
expect(keyframeIntervalToGopSize({ num: 30000, den: 1001 }, 2)).toBe(60);
expect(keyframeIntervalToGopSize({ num: 24, den: 1 }, 0.01)).toBe(1);
});
});
describe("rate cap args", () => {
it("adds nothing without maxBitrate", () => {
expect(buildEncoderArgs(base, [], "/tmp/o.mp4")).not.toContain("-maxrate");
expect(buildStreamingArgs(base, "/tmp/o.mp4")).not.toContain("-bufsize");
});
it("caps with a buffer of twice the rate by default, in both builders alike", () => {
const opts = { ...base, maxBitrate: "8M" };
expect(capOf(buildEncoderArgs(opts, [], "/tmp/o.mp4"))).toEqual([
"-maxrate",
"8000000",
"-bufsize",
"16000000",
]);
expect(capOf(buildStreamingArgs(opts, "/tmp/o.mp4"))).toEqual(
capOf(buildEncoderArgs(opts, [], "/tmp/o.mp4")),
);
});
it("honours an explicit buffer and keeps CRF", () => {
const args = buildEncoderArgs(
{ ...base, maxBitrate: "5000k", bufferSize: "2M", quality: 20 },
[],
"/tmp/o.mp4",
);
expect(capOf(args)).toEqual(["-maxrate", "5000000", "-bufsize", "2000000"]);
expect(args[args.indexOf("-crf") + 1]).toBe("20");
});
it("reads a gigabit cap the way the HTTP parsers accept it", () => {
expect(capOf(buildEncoderArgs({ ...base, maxBitrate: "1G" }, [], "/tmp/o.mp4"))).toEqual([
"-maxrate",
"1000000000",
"-bufsize",
"2000000000",
]);
});
it("refuses a buffer without a rate, a bad rate and codecs it cannot cap", () => {
expect(() => appendRateCapArgs([], { bufferSize: "2M" })).toThrow(/requires maxBitrate/);
expect(() => appendRateCapArgs([], { maxBitrate: "fast" })).toThrow(/invalid bitrate/);
expect(() => appendRateCapArgs([], { maxBitrate: "0" })).toThrow(/positive/);
for (const codec of ["vp9", "prores"] as const) {
const opts: EncoderOptions = { ...base, codec, maxBitrate: "8M" };
expect(() => buildEncoderArgs(opts, [], "/tmp/o.mov")).toThrow(/not supported for codec/);
expect(() => buildStreamingArgs(opts, "/tmp/o.mov")).toThrow(/not supported for codec/);
}
});
});
describe.skipIf(!HAS_FFMPEG)("delivery options on a real encode", () => {
const dir = mkdtempSync(join(tmpdir(), "delivery-opts-"));
afterAll(() => rmSync(dir, { recursive: true, force: true }));
// High-entropy moving noise: unconstrained, x264 spends several Mbps on it.
const input = [
"-f",
"lavfi",
"-i",
"testsrc2=size=320x240:rate=30:duration=8,noise=alls=60:allf=t",
];
function encode(name: string, extra: Partial<EncoderOptions>): string {
const out = join(dir, name);
const args = buildEncoderArgs({ ...base, quality: 18, ...extra }, input, out);
const run = spawnSync(getFfmpegBinary(), ["-v", "error"].concat(args), {
encoding: "utf-8",
});
expect(run.status, run.stderr).toBe(0);
return out;
}
const probe = (inputPath: string, args: readonly string[]): string => {
const argv = ["-v", "error", ...args, "--", inputPath];
return spawnSync(getFfprobeBinary(), argv, { encoding: "utf-8" }).stdout;
};
function packets(file: string): { time: number; bytes: number; key: boolean }[] {
const out = probe(file, [
"-select_streams",
"v:0",
"-show_entries",
"packet=pts_time,size,flags",
"-of",
"csv=p=0",
]);
return out
.trim()
.split("\n")
.map((l) => l.split(","))
.map(([time, size, flags]) => ({
time: Number(time),
bytes: Number(size),
key: flags!.includes("K"),
}))
.sort((a, b) => a.time - b.time);
}
/** Worst excess of any packet window's bits over `rate * window + buffer`; <= 0 means the VBV bound holds. */
function worstExcess(file: string, rate: number, buffer: number): number {
const p = packets(file);
let worst = -Infinity;
for (let i = 0; i < p.length; i++) {
let bits = 0;
for (let j = i; j < p.length; j++) {
bits += p[j]!.bytes * 8;
worst = Math.max(worst, bits - (rate * (p[j]!.time - p[i]!.time + 1 / 30) + buffer));
}
}
return worst;
}
it("puts a keyframe exactly every interval", () => {
const file = encode("gop.mp4", {
lockGopForChunkConcat: true,
gopSize: keyframeIntervalToGopSize(base.fps, 2),
});
const keys = packets(file)
.filter((x) => x.key)
.map((x) => x.time);
expect(keys).toEqual([0, 2, 4, 6]);
});
it("keeps the stream inside the VBV bound only when capped", () => {
const rate = 600_000;
const buffer = 2 * rate;
expect(worstExcess(encode("free.mp4", {}), rate, buffer)).toBeGreaterThan(0);
const capped = encode("capped.mp4", { maxBitrate: "600k" });
expect(worstExcess(capped, rate, buffer)).toBeLessThanOrEqual(0);
});
});
@@ -49,7 +49,12 @@ import { appendVp9CpuUsedArg } from "./vp9Options.js";
import { appendSoftwareEncoderThreadsArg } from "./softwareEncoderThreads.js";
import { appendRenderProvenanceArgs } from "../utils/renderProvenance.js";
import { appendLockedGopArgs, lockedGopCodecParams, resolveLockedGopSize } from "./chunkEncoder.js";
import {
appendLockedGopArgs,
appendRateCapArgs,
lockedGopCodecParams,
resolveLockedGopSize,
} from "./chunkEncoder.js";
// Re-export EncoderOptions so callers can reference the type via this module.
export type { EncoderOptions } from "./chunkEncoder.types.js";
@@ -157,6 +162,8 @@ export interface StreamingEncoderOptions {
preset?: string;
quality?: number;
bitrate?: string;
maxBitrate?: string;
bufferSize?: string;
pixelFormat?: string;
/** libvpx-vp9 -cpu-used value. Defaults to the engine VP9 setting. */
vp9CpuUsed?: number;
@@ -281,6 +288,7 @@ export function buildStreamingArgs(
args.push("-r", fpsToFfmpegArg(fps));
const shouldUseGpu = useGpu && gpuEncoder !== null;
appendRateCapArgs(args, options);
if (codec === "h264" || codec === "h265") {
if (shouldUseGpu) {
@@ -52,6 +52,20 @@ describe("validateDistributedRenderConfig", () => {
);
});
it("accepts the delivery encoder options on mp4 and rejects them elsewhere or malformed", () => {
const delivery = { keyframeIntervalSeconds: 2, maxBitrate: "8M", bufferSize: "12M" };
expect(validateDistributedRenderConfig({ ...base(), ...delivery })).toMatchObject(delivery);
expect(() =>
validateDistributedRenderConfig({ ...base(), format: "webm", maxBitrate: "8M" } as never),
).toThrow(/apply to mp4/);
expect(() => validateDistributedRenderConfig({ ...base(), maxBitrate: "fast" })).toThrow(
/invalid bitrate/,
);
expect(() =>
validateDistributedRenderConfig({ ...base(), keyframeIntervalSeconds: 0 }),
).toThrow(/positive/);
});
it("rejects codec with a non-mp4 format", () => {
expect(() =>
validateDistributedRenderConfig({ ...base(), format: "webm", codec: "h264" } as never),
@@ -25,6 +25,7 @@
"src/services/htmlCompiler.mediaType.test.ts",
"src/services/htmlCompiler.naturalDuration.test.ts",
"src/services/htmlCompiler.probeCount.test.ts",
"src/services/render/deliveryRender.integration.test.ts",
"src/services/render/firstFrameStartingState.integration.test.ts",
"src/services/render/hlsRender.integration.test.ts",
"src/services/render/motionBlurVideo.integration.test.ts",
@@ -64,6 +64,32 @@ describe("RenderRequest", () => {
).toBe("off");
});
it("carries the delivery encoder options through every adapter", () => {
const value = request();
Object.assign(value.options, {
keyframeIntervalSeconds: 2,
maxBitrate: "8M",
bufferSize: "12M",
});
const expected = { keyframeIntervalSeconds: 2, maxBitrate: "8M", bufferSize: "12M" };
expect(renderConfigFromRequest(value)).toMatchObject(expected);
const config = distributedConfigFromRequest(value);
expect(config).toMatchObject(expected);
expect(
renderRequestFromDistributedConfig({
projectDir: value.projectDir,
outputPath: value.outputPath,
config,
}).options,
).toMatchObject(expected);
expect(() =>
parseRenderRequest({ ...value, options: { ...value.options, maxBitrate: 8 } }),
).toThrow();
expect(() =>
parseRenderRequest({ ...value, options: { ...value.options, keyframeIntervalSeconds: 0 } }),
).toThrow(/positive/);
});
it("round-trips through JSON without dropping nested options", () => {
const value = request();
expect(parseRenderRequest(serializeRenderRequest(value))).toEqual(value);
+18 -1
View File
@@ -56,6 +56,9 @@ export interface RenderRequestOptions {
entryFile?: string;
crf?: number;
videoBitrate?: string;
keyframeIntervalSeconds?: number;
maxBitrate?: string;
bufferSize?: string;
videoFrameFormat?: VideoFrameFormat;
hdrMode?: RenderConfig["hdrMode"];
pageMotionBlur?: RenderConfig["pageMotionBlur"];
@@ -118,6 +121,13 @@ function assertOptionalInteger(options: Record<string, unknown>, field: string,
}
}
function assertOptionalPositiveNumber(options: Record<string, unknown>, field: string): void {
const value = options[field];
if (value !== undefined && !(typeof value === "number" && value > 0)) {
throw new Error(`Render request ${field} must be a positive number`);
}
}
function assertOptionalString(options: Record<string, unknown>, field: string): void {
if (options[field] !== undefined && typeof options[field] !== "string") {
throw new Error(`Render request ${field} must be a string`);
@@ -175,10 +185,11 @@ function assertRequestOptionScalars(options: Record<string, unknown>): void {
assertOptionalInteger(options, "workers", 1);
assertOptionalInteger(options, "maxRenderMs");
assertOptionalInteger(options, "crf");
assertOptionalPositiveNumber(options, "keyframeIntervalSeconds");
for (const field of ["useGpu", "debug", "outputResolutionAspectAgnostic"] as const) {
assertOptionalBoolean(options, field);
}
for (const field of ["entryFile", "videoBitrate"] as const) {
for (const field of ["entryFile", "videoBitrate", "maxBitrate", "bufferSize"] as const) {
assertOptionalString(options, field);
}
assertOptionalEnum(options, "strictness", ["strict", "best-effort"]);
@@ -303,6 +314,9 @@ export function distributedConfigFromRequest(
crf: options.crf,
pageMotionBlur: options.pageMotionBlur,
bitrate: options.videoBitrate,
keyframeIntervalSeconds: options.keyframeIntervalSeconds,
maxBitrate: options.maxBitrate,
bufferSize: options.bufferSize,
videoFrameFormat: options.videoFrameFormat,
outputResolution: options.outputResolution,
outputResolutionAspectAgnostic: options.outputResolutionAspectAgnostic,
@@ -359,6 +373,9 @@ export function renderRequestFromDistributedConfig(input: {
...optionalProperty("crf", config.crf),
...optionalProperty("pageMotionBlur", config.pageMotionBlur),
...optionalProperty("videoBitrate", config.bitrate),
...optionalProperty("keyframeIntervalSeconds", config.keyframeIntervalSeconds),
...optionalProperty("maxBitrate", config.maxBitrate),
...optionalProperty("bufferSize", config.bufferSize),
...optionalProperty("videoFrameFormat", config.videoFrameFormat),
...optionalProperty("outputResolution", config.outputResolution),
...optionalProperty("outputResolutionAspectAgnostic", config.outputResolutionAspectAgnostic),
+11 -1
View File
@@ -91,6 +91,9 @@ interface RenderInput {
maxRenderMs?: number;
crf?: number;
videoBitrate?: string;
keyframeIntervalSeconds?: number;
maxBitrate?: string;
bufferSize?: string;
useGpu: boolean;
debug: boolean;
strictness: RenderConfig["strictness"];
@@ -245,7 +248,8 @@ export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderIn
parseServerOutputDynamicRange(body.outputDynamicRange) ??
fromRenderHdrMode(parseLegacyServerHdrMode(body.hdrMode));
const encoder = parseEncoderOptions(body, format ?? "mp4");
const { crf, videoBitrate, videoFrameFormat } = encoder.ok ? encoder.options : {};
const { crf, videoBitrate, videoFrameFormat, keyframeIntervalSeconds, maxBitrate, bufferSize } =
encoder.ok ? encoder.options : {};
const { variables, outputResolution, outputResolutionAspectAgnostic } =
parseRenderOverrides(body);
@@ -258,6 +262,9 @@ export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderIn
maxRenderMs,
crf,
videoBitrate,
keyframeIntervalSeconds,
maxBitrate,
bufferSize,
useGpu,
debug,
strictness,
@@ -320,6 +327,9 @@ function buildRenderJobConfig(input: RenderInput, outputPath: string, log: Produ
maxRenderMs: input.maxRenderMs,
crf: input.crf,
videoBitrate: input.videoBitrate,
keyframeIntervalSeconds: input.keyframeIntervalSeconds,
maxBitrate: input.maxBitrate,
bufferSize: input.bufferSize,
useGpu: input.useGpu,
debug: input.debug,
strictness: input.strictness,
@@ -668,6 +668,20 @@ describe("assemble()", () => {
expect(args[args.indexOf("-threads") + 1]).toBe(String(availableParallelism()));
});
it("cfr re-encode keeps the keyframe interval and the peak cap", () => {
const args = cfrEncodeArgs(
["-i", "chunk.mp4"],
{ maxBitrate: "8M", bufferSize: "12M", keyframeIntervalFrames: 60 },
"30",
);
expect(args[args.indexOf("-g") + 1]).toBe("60");
expect(args[args.indexOf("-maxrate") + 1]).toBe("8000000");
expect(args[args.indexOf("-bufsize") + 1]).toBe("12000000");
const plain = cfrEncodeArgs(["-i", "chunk.mp4"], {}, "30");
expect(plain).not.toContain("-g");
expect(plain).not.toContain("-maxrate");
});
it("cfr re-encode keeps the chunks' rate control", () => {
expect(cfrRateControlArgs({})).toEqual(["-preset", "medium", "-crf", "18"]);
expect(cfrRateControlArgs({ quality: "high" })).toEqual(["-preset", "slow", "-crf", "15"]);
@@ -32,6 +32,7 @@ import {
} from "node:fs";
import { dirname, join } from "node:path";
import {
appendRateCapArgs,
appendRenderProvenanceArgs,
describeFfmpegFailure,
getEncoderPreset,
@@ -86,7 +87,12 @@ interface PlanJsonForAssemble {
export type AssembleChunkPaths = readonly (string | readonly string[])[];
type CfrRateControlSource = Partial<Pick<LockedRenderConfig, "quality" | "crf" | "bitrate">>;
type CfrRateControlSource = Partial<
Pick<
LockedRenderConfig,
"quality" | "crf" | "bitrate" | "maxBitrate" | "bufferSize" | "keyframeIntervalFrames"
>
>;
export function cfrRateControlArgs(locked: CfrRateControlSource): string[] {
const preset = getEncoderPreset(locked.quality ?? "standard", "mp4");
@@ -285,6 +291,9 @@ export function cfrEncodeArgs(
"-r",
fpsArg,
];
if (encoder.keyframeIntervalFrames !== undefined)
args.push("-g", String(encoder.keyframeIntervalFrames));
appendRateCapArgs(args, encoder);
appendSoftwareEncoderThreadsArg(args, "libx264");
return args;
}
@@ -47,6 +47,7 @@ import {
type EngineConfig,
type VideoFrameFormat,
getEncoderPreset,
keyframeIntervalToGopSize,
normalizeVp9CpuUsed,
resolveConfig,
type AudioProcessingFailure,
@@ -155,6 +156,12 @@ export interface DistributedRenderConfig extends Pick<RenderRequestOptions, "pag
crf?: number;
/** Target video bitrate (e.g. `"10M"`); mutually exclusive with `crf`. */
bitrate?: string;
/** Longest gap between keyframes, in seconds (mp4). Chunk joins add keyframes, so this is a maximum. */
keyframeIntervalSeconds?: number;
/** Peak bitrate cap (mp4), per chunk. */
maxBitrate?: string;
/** Rate-cap buffer; defaults to twice `maxBitrate`. */
bufferSize?: string;
/**
* Source-video frame extraction format. Defaults to `"auto"`, matching the
* in-process renderer: alpha/alpha-capable sources extract as PNG, other
@@ -950,6 +957,12 @@ function buildLockedRenderConfig(input: {
preset,
crf: config.crf,
bitrate: config.bitrate,
maxBitrate: config.maxBitrate,
bufferSize: config.bufferSize,
keyframeIntervalFrames:
config.keyframeIntervalSeconds === undefined
? undefined
: keyframeIntervalToGopSize({ num: config.fps, den: 1 }, config.keyframeIntervalSeconds),
...locksVp9CpuUsed,
// GOP === chunkSize so every chunk's first frame is an IDR keyframe and
// ffmpeg concat-copy round-trips losslessly.
@@ -34,6 +34,7 @@
* In-process renders (`executeRenderJob`) leave them off.
*/
import { chunkGopFrames } from "../render/deliveryEncoder.js";
import { randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { cpus } from "node:os";
@@ -946,7 +947,9 @@ export async function renderChunk(
// first frame is an IDR keyframe and concat-copy at assemble time
// round-trips losslessly.
lockGopForChunkConcat: !isPngSequence,
gopSize: framesInChunk,
gopSize: chunkGopFrames(framesInChunk, encoder.keyframeIntervalFrames),
maxBitrate: encoder.maxBitrate,
bufferSize: encoder.bufferSize,
};
const captureStarted = Date.now();
captureMode = await runCaptureWithScreenshotFallback({
@@ -17,6 +17,7 @@
* needs the actual planner.
*/
import { validateDeliveryEncoderConfig } from "../render/deliveryEncoder.js";
import {
VIDEO_FRAME_FORMATS,
isVideoFrameFormat,
@@ -148,6 +149,21 @@ export function validateDistributedRenderConfig(
);
}
try {
validateDeliveryEncoderConfig({
format: config.format,
fps: { num: config.fps, den: 1 },
keyframeIntervalSeconds: config.keyframeIntervalSeconds,
maxBitrate: config.maxBitrate,
bufferSize: config.bufferSize,
});
} catch (error) {
throw new InvalidConfigError(
"config.maxBitrate",
error instanceof Error ? error.message : String(error),
);
}
if (config.chunkSize !== undefined) {
if (!Number.isInteger(config.chunkSize) || config.chunkSize < 1) {
throw new InvalidConfigError(
@@ -0,0 +1,139 @@
import { describe, expect, it } from "bun:test";
import {
chunkGopFrames,
resolveDeliveryEncoderOptions,
validateDeliveryEncoderConfig,
} from "./deliveryEncoder.js";
import { computeSegmentPlanHash } from "./segmentManifest.js";
const fps = { num: 30, den: 1 };
describe("validateDeliveryEncoderConfig", () => {
it("is a no-op when no delivery option is set, for every format", () => {
for (const format of ["mp4", "webm", "mov", "hls", "gif", undefined]) {
expect(() => validateDeliveryEncoderConfig({ format, fps })).not.toThrow();
}
});
it("accepts the options on an mp4 render", () => {
expect(() =>
validateDeliveryEncoderConfig({
format: "mp4",
fps,
keyframeIntervalSeconds: 2,
maxBitrate: "8M",
bufferSize: "16M",
}),
).not.toThrow();
expect(() => validateDeliveryEncoderConfig({ fps, maxBitrate: "5000k" })).not.toThrow();
});
it("rejects what the encoders cannot honor", () => {
expect(() => validateDeliveryEncoderConfig({ format: "webm", fps, maxBitrate: "8M" })).toThrow(
/apply to mp4/,
);
expect(() =>
validateDeliveryEncoderConfig({ format: "hls", fps, keyframeIntervalSeconds: 2 }),
).toThrow(/hls/);
for (const bad of [0, -1, Number.NaN]) {
expect(() => validateDeliveryEncoderConfig({ fps, keyframeIntervalSeconds: bad })).toThrow(
/positive/,
);
}
expect(() => validateDeliveryEncoderConfig({ fps, maxBitrate: "fast" })).toThrow(
/invalid bitrate/,
);
expect(() => validateDeliveryEncoderConfig({ fps, bufferSize: "2M" })).toThrow(
/requires maxBitrate/,
);
});
});
describe("chunkGopFrames", () => {
it("uses the chunk when no interval is set or the interval is longer", () => {
expect(chunkGopFrames(120)).toBe(120);
expect(chunkGopFrames(120, 300)).toBe(120);
});
it("uses the interval when it is shorter than the chunk", () => {
expect(chunkGopFrames(120, 60)).toBe(60);
});
});
describe("resolveDeliveryEncoderOptions", () => {
it("leaves every encoder path as it was when nothing is set", () => {
expect(resolveDeliveryEncoderOptions({ format: "mp4", fps })).toEqual({
maxBitrate: undefined,
bufferSize: undefined,
lockGopForChunkConcat: false,
gopSize: undefined,
});
});
it("turns a keyframe interval into a locked GOP at the render's fps", () => {
const options = resolveDeliveryEncoderOptions({
format: "mp4",
fps,
keyframeIntervalSeconds: 2,
});
expect(options.lockGopForChunkConcat).toBe(true);
expect(options.gopSize).toBe(60);
expect(
resolveDeliveryEncoderOptions({
format: "mp4",
fps: { num: 24, den: 1 },
keyframeIntervalSeconds: 2.5,
}).gopSize,
).toBe(60);
});
it("passes the rate cap through and keeps HLS on its segment-length GOP", () => {
expect(
resolveDeliveryEncoderOptions({ format: "mp4", fps, maxBitrate: "8M", bufferSize: "12M" }),
).toMatchObject({ maxBitrate: "8M", bufferSize: "12M", lockGopForChunkConcat: false });
expect(
resolveDeliveryEncoderOptions({ format: "hls", fps, hlsSegmentSeconds: 4 }),
).toMatchObject({
lockGopForChunkConcat: true,
gopSize: 120,
});
});
});
describe("computeSegmentPlanHash", () => {
const base = {
compositionHash: "c",
cliVersion: "v",
totalFrames: 300,
segmentFrames: 100,
fps,
width: 1920,
height: 1080,
codec: "h264",
preset: "medium",
quality: 23,
bitrate: undefined,
maxBitrate: undefined,
bufferSize: undefined,
gopSize: undefined,
pixelFormat: "yuv420p",
imageFormat: "jpeg",
useGpu: false,
outputWidth: 1920,
outputHeight: 1080,
motionBlur: "",
pageMotionBlur: undefined,
captureQuality: undefined,
};
it("changes with the cap and the keyframe interval, so a cached segment is never reused across them", () => {
const plain = computeSegmentPlanHash(base);
expect(computeSegmentPlanHash({ ...base, maxBitrate: "8M" })).not.toBe(plain);
expect(computeSegmentPlanHash({ ...base, gopSize: 60 })).not.toBe(plain);
expect(computeSegmentPlanHash({ ...base, bufferSize: "2M" })).not.toBe(plain);
});
it("keeps the hash of a render that sets none of them", () => {
expect(computeSegmentPlanHash(base)).toBe(computeSegmentPlanHash({ ...base }));
});
});
@@ -0,0 +1,65 @@
/** Delivery encoder options: their fail-fast gate and the one resolved value every encoder path spreads. */
import { appendRateCapArgs, keyframeIntervalToGopSize } from "@hyperframes/engine";
import type { Fps } from "@hyperframes/core";
import { resolveHlsEncoderGopLock } from "./hlsConfig.js";
export interface DeliveryEncoderConfig {
format?: string;
fps: Fps;
hlsSegmentSeconds?: number;
keyframeIntervalSeconds?: number;
maxBitrate?: string;
bufferSize?: string;
}
/** The `EncoderOptions` fields the delivery config sets, ready to spread into any encoder input. */
export interface DeliveryEncoderOptions {
maxBitrate?: string;
bufferSize?: string;
lockGopForChunkConcat: boolean;
gopSize: number | undefined;
}
/** Reject what the encoders cannot honor, before any frame is captured. */
export function validateDeliveryEncoderConfig(config: DeliveryEncoderConfig): void {
const { keyframeIntervalSeconds, maxBitrate, bufferSize, format } = config;
if (keyframeIntervalSeconds === undefined && maxBitrate === undefined && bufferSize === undefined)
return;
if (keyframeIntervalSeconds !== undefined) {
if (!Number.isFinite(keyframeIntervalSeconds) || keyframeIntervalSeconds <= 0) {
throw new Error(
`keyframeIntervalSeconds must be a positive number of seconds; got ${keyframeIntervalSeconds}`,
);
}
if (format === "hls") {
throw new Error(
"keyframeIntervalSeconds does not apply to hls; hlsSegmentSeconds sets its keyframes",
);
}
}
if (format !== undefined && format !== "mp4") {
throw new Error(`keyframeIntervalSeconds and maxBitrate apply to mp4 renders, not ${format}`);
}
// The engine owns bitrate parsing; running its builder here rejects a bad value up front.
appendRateCapArgs([], { maxBitrate, bufferSize });
}
/** A chunk starts on a keyframe, so its GOP is the chunk, or the interval when that is shorter. */
export function chunkGopFrames(framesInChunk: number, intervalFrames?: number): number {
return Math.min(framesInChunk, intervalFrames ?? framesInChunk);
}
export function resolveDeliveryEncoderOptions(
config: DeliveryEncoderConfig,
): DeliveryEncoderOptions {
const { maxBitrate, bufferSize, keyframeIntervalSeconds } = config;
const gop =
keyframeIntervalSeconds === undefined
? resolveHlsEncoderGopLock(config.format ?? "mp4", config.fps, config.hlsSegmentSeconds)
: {
lockGopForChunkConcat: true,
gopSize: keyframeIntervalToGopSize(config.fps, keyframeIntervalSeconds),
};
return { maxBitrate, bufferSize, ...gop };
}
@@ -0,0 +1,139 @@
/** Two real mp4 renders, plain and with a keyframe interval and peak cap (ffprobe). Needs Chrome + ffmpeg. */
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getFfmpegBinary, getFfprobeBinary, resolveConfig } from "@hyperframes/engine";
import { createRenderJob, executeRenderJob } from "../renderOrchestrator.js";
const FFMPEG = getFfmpegBinary();
const FFPROBE = getFfprobeBinary();
const HAS_FFMPEG = spawnSync(FFMPEG, ["-version"], { encoding: "utf-8" }).status === 0;
const DURATION_SECONDS = 8;
const FPS = 30;
const CAP_BITS_PER_SECOND = 300_000;
const COMPOSITION = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #111; }
video { position: absolute; inset: 0; width: 320px; height: 180px; }
</style>
</head>
<body>
<div id="root" data-composition-id="delivery" data-start="0" data-duration="${DURATION_SECONDS}"
data-width="320" data-height="180" data-fps="${FPS}" data-no-timeline>
<video src="assets/noise.mp4" data-start="0" data-duration="${DURATION_SECONDS}" data-track-index="1" muted playsinline></video>
</div>
</body>
</html>
`;
function run(command: string, args: readonly string[]): string {
const result = spawnSync(command, args, { encoding: "utf-8" });
if (result.status !== 0) throw new Error(`${command} failed: ${result.stderr}`);
return result.stdout.trim();
}
function packets(file: string): { time: number; bytes: number; key: boolean }[] {
const rows = run(FFPROBE, [
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"packet=pts_time,size,flags",
"-of",
"csv=p=0",
"--",
file,
]);
return rows
.split("\n")
.map((line) => line.split(","))
.map(([time, size, flags]) => ({
time: Number(time),
bytes: Number(size),
key: flags!.includes("K"),
}))
.sort((a, b) => a.time - b.time);
}
/** Worst excess of any packet window's bits over `rate * window + buffer`; <= 0 means the bound holds. */
function worstExcess(file: string, rate: number, buffer: number): number {
const p = packets(file);
let worst = -Infinity;
for (let i = 0; i < p.length; i++) {
let bits = 0;
for (let j = i; j < p.length; j++) {
bits += p[j]!.bytes * 8;
worst = Math.max(worst, bits - (rate * (p[j]!.time - p[i]!.time + 1 / FPS) + buffer));
}
}
return worst;
}
describe.skipIf(!HAS_FFMPEG)("mp4 delivery options — real render", () => {
let root: string;
let plain: string;
let delivery: string;
async function render(output: string, extra: Record<string, unknown>): Promise<void> {
const job = createRenderJob({
fps: FPS,
quality: "draft",
format: "mp4",
hdrMode: "force-sdr",
workers: 1,
logger: { info() {}, warn() {}, error() {}, debug() {} },
producerConfig: resolveConfig({ browserGpuMode: "software" }),
...extra,
});
await executeRenderJob(job, root, output);
}
beforeAll(async () => {
root = mkdtempSync(join(tmpdir(), "hf-delivery-render-"));
mkdirSync(join(root, "assets"), { recursive: true });
writeFileSync(join(root, "index.html"), COMPOSITION);
run(FFMPEG, [
"-v",
"error",
"-f",
"lavfi",
"-i",
`testsrc2=size=320x180:rate=${FPS}:duration=${DURATION_SECONDS},noise=alls=60:allf=t`,
"-pix_fmt",
"yuv420p",
"-y",
join(root, "assets", "noise.mp4"),
]);
plain = join(root, "plain.mp4");
delivery = join(root, "delivery.mp4");
await render(plain, {});
await render(delivery, { keyframeIntervalSeconds: 2, maxBitrate: String(CAP_BITS_PER_SECOND) });
}, 900_000);
afterAll(() => rmSync(root, { recursive: true, force: true }));
it("puts a keyframe every interval, which a plain render does not", () => {
const keys = (file: string) =>
packets(file)
.filter((p) => p.key)
.map((p) => Math.round(p.time * 1000) / 1000);
expect(keys(delivery)).toEqual([0, 2, 4, 6]);
expect(keys(plain)).not.toEqual([0, 2, 4, 6]);
});
it("holds the peak bitrate cap, which a plain render exceeds", () => {
const buffer = 2 * CAP_BITS_PER_SECOND;
expect(worstExcess(plain, CAP_BITS_PER_SECOND, buffer)).toBeGreaterThan(0);
expect(worstExcess(delivery, CAP_BITS_PER_SECOND, buffer)).toBeLessThanOrEqual(0);
});
});
@@ -12,7 +12,8 @@
* `lockGopForChunkConcat: true, gopSize`.
*/
import { fpsToNumber, type Fps } from "@hyperframes/core";
import { keyframeIntervalToGopSize } from "@hyperframes/engine";
import type { Fps } from "@hyperframes/core";
/** ffmpeg's own `-hls_time` default is 2 s; 4 s is the VOD consumer's ask. */
export const DEFAULT_HLS_SEGMENT_SECONDS = 4;
@@ -79,7 +80,7 @@ export function resolveHlsSegmentSeconds(config: HlsRenderConfig): number {
* pathologically low fps must not turn that into a render-time crash.
*/
export function resolveHlsGopSize(fps: Fps, segmentSeconds: number): number {
return Math.max(1, Math.round(fpsToNumber(fps) * segmentSeconds));
return keyframeIntervalToGopSize(fps, segmentSeconds);
}
/**
@@ -40,6 +40,9 @@ export interface SegmentPlanHashInput {
preset: string;
quality: number | undefined;
bitrate: string | undefined;
maxBitrate: string | undefined;
bufferSize: string | undefined;
gopSize: number | undefined;
pixelFormat: string | undefined;
imageFormat: string;
/** Hardware encoder or not: h264_nvenc and libx264 both report codec "h264" but produce different bytes. */
@@ -75,8 +78,10 @@ export function computeSegmentPlanHash(input: SegmentPlanHashInput): string {
input.motionBlur,
input.pageMotionBlur ?? "",
input.captureQuality === undefined ? "" : String(input.captureQuality),
].join(" ");
return createHash("sha256").update(ordered).digest("hex").slice(0, 16);
];
const delivery = [input.maxBitrate, input.bufferSize, input.gopSize].map((v) => v ?? "");
if (delivery.some((v) => v !== "")) ordered.push(...delivery.map(String));
return createHash("sha256").update(ordered.join(" ")).digest("hex").slice(0, 16);
}
export function segmentDirFor(projectRendersDir: string, planHash: string): string {
@@ -78,6 +78,7 @@ import { runHybridLayeredFrameLoop } from "./captureHdrHybridLoop.js";
import { wrapCaptureStageError } from "../captureStageError.js";
import { encoderFailureError } from "../encoderInterruption.js";
import type { HdrLayeredCapturePlan } from "../capturePlan.js";
import type { DeliveryEncoderOptions } from "../deliveryEncoder.js";
export interface CaptureHdrStageInput {
job: RenderJob;
@@ -107,6 +108,7 @@ export interface CaptureHdrStageInput {
preset: ReturnType<typeof getEncoderPreset>;
effectiveQuality: number;
effectiveBitrate: string | undefined;
deliveryEncoder: DeliveryEncoderOptions;
fileServer: FileServerHandle;
buildCaptureOptions: () => CaptureOptions;
@@ -157,6 +159,7 @@ export async function runCaptureHdrStage(
preset,
effectiveQuality,
effectiveBitrate,
deliveryEncoder,
fileServer,
buildCaptureOptions,
createRenderVideoFrameInjector,
@@ -271,6 +274,7 @@ export async function runCaptureHdrStage(
preset: preset.preset,
quality: effectiveQuality,
bitrate: effectiveBitrate,
...deliveryEncoder,
pixelFormat: preset.pixelFormat,
hdr: preset.hdr,
rawInputFormat: "rgb48le",
@@ -144,7 +144,7 @@ export function segmentOutputPath(segmentDir: string, index: number): string {
/**
* One closed-GOP encoder per segment. `gopSize` is the segment's own frame
* count so the segment is exactly one GOP and concat-copy has an IDR at every
* count (or the keyframe interval if shorter) so concat-copy has an IDR at every
* boundary; without it ffmpeg picks its own keyframes and a boundary can land
* mid-GOP, which decodes as a glitch rather than an error.
*/
@@ -155,7 +155,7 @@ function segmentEncoderOptions(
return {
...base,
lockGopForChunkConcat: true,
gopSize: segment.endFrame - segment.startFrame,
gopSize: Math.min(base.gopSize ?? Infinity, segment.endFrame - segment.startFrame),
};
}
@@ -111,6 +111,8 @@ export interface EncodeStageInput {
preset: ReturnType<typeof getEncoderPreset>;
effectiveQuality: number;
effectiveBitrate: string | undefined;
maxBitrate?: string;
bufferSize?: string;
/** Producer config — enables the chunked-concat encoder when on. */
enableChunkedEncode: boolean;
chunkedEncodeSize: number;
@@ -276,6 +278,8 @@ function videoEncoderOptions(input: EncodeStageInput, engineCfg: Pick<EngineConf
preset: preset.preset,
quality: input.effectiveQuality,
bitrate: input.effectiveBitrate,
maxBitrate: input.maxBitrate,
bufferSize: input.bufferSize,
pixelFormat: preset.pixelFormat,
vp9CpuUsed: engineCfg.vp9CpuUsed,
useGpu: job.config.useGpu,
@@ -56,6 +56,9 @@ export interface LockedRenderConfig extends Pick<RenderRequestOptions, "pageMoti
preset: string;
crf?: number;
bitrate?: string;
maxBitrate?: string;
bufferSize?: string;
keyframeIntervalFrames?: number;
/**
* VP9-only: new WebM plans persist the resolved libvpx-vp9 `-cpu-used`
* value so distributed workers reproduce the controller's speed/quality
@@ -128,11 +128,11 @@ import {
outputUsesH264Pipeline,
type RenderOutputFormat,
} from "./render/renderFormat.js";
import { resolveHlsSegmentSeconds, validateHlsRenderConfig } from "./render/hlsConfig.js";
import {
resolveHlsEncoderGopLock,
resolveHlsSegmentSeconds,
validateHlsRenderConfig,
} from "./render/hlsConfig.js";
resolveDeliveryEncoderOptions,
validateDeliveryEncoderConfig,
} from "./render/deliveryEncoder.js";
import { createMemorySampler, type MemorySampler, updateJobStatus } from "./render/shared.js";
import { frameCacheOptions } from "./render/frameCacheBudget.js";
import { buildRenderErrorDetails } from "./render/cleanup.js";
@@ -415,6 +415,12 @@ export interface RenderConfig {
crf?: number;
/** Target video bitrate (e.g. "10M"). Mutually exclusive with `crf`. */
videoBitrate?: string;
/** Keyframe every N seconds (mp4). A maximum where chunks join, as on Lambda. */
keyframeIntervalSeconds?: number;
/** Peak bitrate cap (e.g. "8M", mp4) alongside CRF. */
maxBitrate?: string;
/** Rate-cap buffer; defaults to twice `maxBitrate`. */
bufferSize?: string;
/**
* Source-video frame extraction format. Defaults to `"auto"`, which preserves
* the historical behavior: alpha/alpha-capable sources extract as PNG, all
@@ -2901,6 +2907,7 @@ export async function executeRenderJob(
// Ahead of the work dir / log file / execution context: a config the format
// cannot honor must fail before anything is written to disk.
validateHlsRenderConfig(job.config);
validateDeliveryEncoderConfig(job.config);
const debugDir = resolveRenderDebugDir();
const outputDir = dirname(outputPath);
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
@@ -2996,15 +3003,18 @@ async function executeRenderPipeline(input: {
const isGif = outputFormat === "gif";
const isHls = outputFormat === "hls";
// Segment length and the matching GOP, resolved once. Both encoder paths
// spread the same `hlsEncoderGopLock`, so they cannot disagree — a GOP that
// spread the same `deliveryEncoder`, so they cannot disagree — a GOP that
// is not exactly `segmentSeconds × fps` frames silently produces
// variable-length segments instead of failing.
const hlsSegmentSeconds = isHls ? resolveHlsSegmentSeconds(job.config) : undefined;
const hlsEncoderGopLock = resolveHlsEncoderGopLock(
outputFormat,
job.config.fps,
job.config.hlsSegmentSeconds,
);
const deliveryEncoder = resolveDeliveryEncoderOptions({
format: outputFormat,
fps: job.config.fps,
hlsSegmentSeconds: job.config.hlsSegmentSeconds,
keyframeIntervalSeconds: job.config.keyframeIntervalSeconds,
maxBitrate: job.config.maxBitrate,
bufferSize: job.config.bufferSize,
});
const artifactTransaction = new ArtifactTransaction(
outputPath,
isPngSequence || isHls ? "directory" : "file",
@@ -4591,6 +4601,7 @@ async function executeRenderPipeline(input: {
preset,
effectiveQuality,
effectiveBitrate,
deliveryEncoder,
fileServer: activeFileServer,
buildCaptureOptions,
createRenderVideoFrameInjector,
@@ -4636,6 +4647,9 @@ async function executeRenderPipeline(input: {
preset: preset.preset,
quality: effectiveQuality,
bitrate: effectiveBitrate,
maxBitrate: deliveryEncoder.maxBitrate,
bufferSize: deliveryEncoder.bufferSize,
gopSize: deliveryEncoder.gopSize,
pixelFormat: preset.pixelFormat,
imageFormat: segmentImageFormat,
useGpu: job.config.useGpu === true,
@@ -4710,8 +4724,8 @@ async function executeRenderPipeline(input: {
useGpu: job.config.useGpu,
imageFormat: segmentImageFormat,
hdr: preset.hdr,
// No hlsEncoderGopLock here: each segment sets its own GOP to
// its own length, and hls never reaches this route.
// Each segment sets its own GOP (at most the interval); hls never reaches this route.
...deliveryEncoder,
},
buildCaptureOptions,
createRenderVideoFrameInjector,
@@ -4883,7 +4897,7 @@ async function executeRenderPipeline(input: {
// assemble stage's `-c copy` segmentation cuts exactly on
// the segment boundary. Every other format leaves the
// encoder's open-GOP output untouched.
...hlsEncoderGopLock,
...deliveryEncoder,
},
buildCaptureOptions,
createRenderVideoFrameInjector,
@@ -5127,7 +5141,7 @@ async function executeRenderPipeline(input: {
engineConfig: cfg,
// Same value the streaming encoder above spreads — the disk and
// chunked-concat encoders need an identical GOP lock for HLS.
...hlsEncoderGopLock,
...deliveryEncoder,
abortSignal: executionSignal,
assertNotAborted,
onProgress,
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { parseEncoderOptions } from "./encoderOptions";
import { encoderJobOptions, parseEncoderOptions } from "./encoderOptions";
describe("parseEncoderOptions", () => {
it("returns no overrides for an empty request", () => {
@@ -40,4 +40,60 @@ describe("parseEncoderOptions", () => {
expect(parseEncoderOptions({ videoFrameFormat: "webp" }, "mp4").ok).toBe(false);
expect(parseEncoderOptions({ hdrMode: "on" }, "mp4").ok).toBe(false);
});
it("accepts a keyframe interval and a rate cap for mp4", () => {
expect(
parseEncoderOptions(
{ keyframeIntervalSeconds: 2, maxBitrate: "8M", bufferSize: "12M", crf: 20 },
"mp4",
),
).toEqual({
ok: true,
options: { crf: 20, keyframeIntervalSeconds: 2, maxBitrate: "8M", bufferSize: "12M" },
});
});
it("rejects delivery options on other formats, bad values and a buffer without a cap", () => {
for (const format of ["webm", "mov", "gif", "hls"]) {
expect(parseEncoderOptions({ maxBitrate: "8M" }, format).ok).toBe(false);
expect(parseEncoderOptions({ keyframeIntervalSeconds: 2 }, format).ok).toBe(false);
}
for (const bad of [0, -1, "2", Number.NaN]) {
expect(parseEncoderOptions({ keyframeIntervalSeconds: bad }, "mp4").ok).toBe(false);
}
expect(parseEncoderOptions({ maxBitrate: "fast" }, "mp4").ok).toBe(false);
expect(parseEncoderOptions({ bufferSize: "2M" }, "mp4")).toEqual({
ok: false,
error: "bufferSize requires maxBitrate",
});
});
});
describe("encoderJobOptions", () => {
it("passes every encoder field of a parsed request on to the render job", () => {
const parsed = parseEncoderOptions(
{
crf: 20,
keyframeIntervalSeconds: 2,
maxBitrate: "8M",
bufferSize: "12M",
videoFrameFormat: "png",
hdrMode: "force-sdr",
},
"mp4",
);
if (!parsed.ok) throw new Error(parsed.error);
expect(encoderJobOptions(parsed.options)).toEqual({
crf: 20,
keyframeIntervalSeconds: 2,
maxBitrate: "8M",
bufferSize: "12M",
videoFrameFormat: "png",
hdrMode: "force-sdr",
});
});
it("leaves out what the request did not set", () => {
expect(encoderJobOptions({})).toEqual({});
});
});
@@ -9,6 +9,9 @@ interface EncoderOptions {
videoBitrate?: string;
videoFrameFormat?: (typeof VIDEO_FRAME_FORMATS)[number];
hdrMode?: (typeof HDR_MODES)[number];
keyframeIntervalSeconds?: number;
maxBitrate?: string;
bufferSize?: string;
}
export interface EncoderOptionsBody {
@@ -17,6 +20,9 @@ export interface EncoderOptionsBody {
videoBitrate?: unknown;
videoFrameFormat?: unknown;
hdrMode?: unknown;
keyframeIntervalSeconds?: unknown;
maxBitrate?: unknown;
bufferSize?: unknown;
}
type Parsed = { ok: true; options: EncoderOptions } | { ok: false; error: string };
@@ -52,11 +58,51 @@ function parseRateControl(body: EncoderOptionsBody, format: string): Parsed {
return { ok: true, options: {} };
}
function parseKeyframeInterval(value: unknown): Parsed {
if (typeof value !== "number" || !(value > 0)) {
return { ok: false, error: "keyframeIntervalSeconds must be a positive number of seconds" };
}
return { ok: true, options: { keyframeIntervalSeconds: value } };
}
function parseBitrateField(name: "maxBitrate" | "bufferSize", value: unknown): Parsed {
if (typeof value !== "string" || !VIDEO_BITRATE.test(value)) {
return { ok: false, error: `${name} must look like "8M", "5000k" or "8000000"` };
}
return { ok: true, options: { [name]: value } };
}
const DELIVERY_FIELDS = [
["keyframeIntervalSeconds", parseKeyframeInterval],
["maxBitrate", (value: unknown) => parseBitrateField("maxBitrate", value)],
["bufferSize", (value: unknown) => parseBitrateField("bufferSize", value)],
] as const;
function parseDelivery(body: EncoderOptionsBody, format: string): Parsed {
const present = DELIVERY_FIELDS.filter(([name]) => isSet(body[name]));
if (present.length === 0) return { ok: true, options: {} };
if (format !== "mp4") {
return { ok: false, error: "keyframeIntervalSeconds and maxBitrate apply to mp4 renders" };
}
if (isSet(body.bufferSize) && !isSet(body.maxBitrate)) {
return { ok: false, error: "bufferSize requires maxBitrate" };
}
const options: EncoderOptions = {};
for (const [name, parse] of present) {
const parsed = parse(body[name]);
if (!parsed.ok) return parsed;
Object.assign(options, parsed.options);
}
return { ok: true, options };
}
/** Explicit encoder overrides from a render request. MOV (ProRes) takes no rate control. */
export function parseEncoderOptions(body: EncoderOptionsBody, format: string): Parsed {
const rate = parseRateControl(body, format);
if (!rate.ok) return rate;
const options: EncoderOptions = { ...rate.options };
const delivery = parseDelivery(body, format);
if (!delivery.ok) return delivery;
const options: EncoderOptions = { ...rate.options, ...delivery.options };
if (isSet(body.gifLoop)) {
if (
typeof body.gifLoop !== "number" ||
@@ -85,3 +131,16 @@ export function parseEncoderOptions(body: EncoderOptionsBody, format: string): P
}
return { ok: true, options };
}
export function encoderJobOptions(opts: EncoderOptions): Omit<EncoderOptions, "gifLoop"> {
const { crf, videoBitrate, keyframeIntervalSeconds, maxBitrate, bufferSize } = opts;
return {
...(crf !== undefined ? { crf } : {}),
...(videoBitrate ? { videoBitrate } : {}),
...(keyframeIntervalSeconds !== undefined ? { keyframeIntervalSeconds } : {}),
...(maxBitrate ? { maxBitrate } : {}),
...(bufferSize ? { bufferSize } : {}),
...(opts.videoFrameFormat ? { videoFrameFormat: opts.videoFrameFormat } : {}),
...(opts.hdrMode ? { hdrMode: opts.hdrMode } : {}),
};
}
@@ -374,6 +374,20 @@ describe("POST /projects/:id/render — crf", () => {
expect(both.opts).toBeUndefined();
});
it("forwards the keyframe interval and the rate cap to the render adapter", async () => {
const { res, opts } = await post({
format: "mp4",
keyframeIntervalSeconds: 2,
maxBitrate: "8M",
bufferSize: "12M",
});
expect(res.status).toBe(200);
expect(opts).toMatchObject({ keyframeIntervalSeconds: 2, maxBitrate: "8M", bufferSize: "12M" });
const webm = await post({ format: "webm", maxBitrate: "8M" });
expect(webm.res.status).toBe(400);
expect(webm.opts).toBeUndefined();
});
it.each([
["mp4", 52],
["mp4", -1],
+3
View File
@@ -178,6 +178,9 @@ export interface StudioApiAdapter {
gifLoop?: number;
crf?: number;
videoBitrate?: string;
keyframeIntervalSeconds?: number;
maxBitrate?: string;
bufferSize?: string;
videoFrameFormat?: "auto" | "jpg" | "png";
hdrMode?: "auto" | "force-hdr" | "force-sdr";
jobId: string;
+2 -4
View File
@@ -24,6 +24,7 @@ import {
openProjectHistory,
historyCache,
} from "@hyperframes/studio-server";
import { encoderJobOptions } from "@hyperframes/studio-server/encoder-options";
import type { RegistryItem } from "@hyperframes/core/registry";
import type { BundleOptions } from "@hyperframes/core/compiler";
import { createRetryingModuleLoader, ensureProducerDist } from "./vite.producer";
@@ -352,10 +353,7 @@ export function createViteAdapter(
outputResolution: opts.outputResolution,
...(opts.composition ? { entryFile: opts.composition } : {}),
...(opts.variables ? { variables: opts.variables } : {}),
...(opts.crf !== undefined ? { crf: opts.crf } : {}),
...(opts.videoBitrate ? { videoBitrate: opts.videoBitrate } : {}),
...(opts.videoFrameFormat ? { videoFrameFormat: opts.videoFrameFormat } : {}),
...(opts.hdrMode ? { hdrMode: opts.hdrMode } : {}),
...encoderJobOptions(opts),
...(opts.useGpu ? { useGpu: true } : {}),
...(opts.pageMotionBlur ? { pageMotionBlur: opts.pageMotionBlur } : {}),
});
+1 -1
View File
@@ -10,7 +10,7 @@
"files": 77
},
"hyperframes-cli": {
"hash": "08a4a3a815d71988",
"hash": "5cbeeb7c70f13fb8",
"files": 13
},
"hyperframes-registry": {
@@ -92,6 +92,9 @@ Starts a Step Functions execution. Returns immediately with a `renderId` unless
| `--chunk-size` | Frames per chunk (default `240`) |
| `--max-parallel-chunks` | Max concurrent chunks (default `16`) |
| `--target-chunk-frames` | Cap frames per chunk and let the planner add chunks up to the parallel limit |
| `--keyframe-interval` | MP4 only: a keyframe at least every N seconds (a maximum: chunk joins add keyframes) |
| `--max-bitrate` | MP4 only: peak bitrate cap, held per chunk (a join can burst up to one buffer) |
| `--buffer-size` | MP4 only: buffer for `--max-bitrate` (default twice it) |
| `--site-id` | Reuse an existing site (skip upload) |
| `--execution-name` | Explicit Step Functions execution name |
| `--output-key` | Explicit final S3 object key |
@@ -128,6 +128,9 @@ npx hyperframes render --docker # byte-identical
| `--resolution` | landscape, portrait, landscape-4k, portrait-4k, square, square-4k (+ aliases `1080p`, `4k`, `uhd`) | — | Supersample via Chrome `deviceScaleFactor`. Aspect ratio must match composition; scale must be an integer. Not with `--hdr`. |
| `--crf` | 0-51 | — | Encoder CRF (lower = higher quality). Mutually exclusive with `--video-bitrate`. |
| `--video-bitrate` | e.g. `10M`, `5000k` | — | Target bitrate. Mutually exclusive with `--crf`. |
| `--keyframe-interval` | seconds, e.g. `2` | — | MP4 only. A keyframe at least every N seconds. |
| `--max-bitrate` | e.g. `8M` | — | MP4 only. Peak bitrate cap alongside the CRF/quality target. Not for WebM/MOV. |
| `--buffer-size` | e.g. `16M` | — | MP4 only. Buffer for `--max-bitrate` (default 2x it). Needs `--max-bitrate`. |
| `--hdr` | flag | off | Force HDR output even with SDR sources. MP4 only. |
| `--sdr` | flag | off | Force SDR even with HDR sources. |
| `--workers` | number or `auto` | auto | Each worker spawns Chrome (~256 MB) |