diff --git a/docs/guides/rendering.mdx b/docs/guides/rendering.mdx index 971fc9226b..5db79892b3 100644 --- a/docs/guides/rendering.mdx +++ b/docs/guides/rendering.mdx @@ -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 diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 0a7be6f6cf..a969a0a8ec 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -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 | diff --git a/packages/cli/src/commands/lambda.ts b/packages/cli/src/commands/lambda.ts index c2e4798346..004d57ed03 100644 --- a/packages/cli/src/commands/lambda.ts +++ b/packages/cli/src/commands/lambda.ts @@ -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"), diff --git a/packages/cli/src/commands/lambda/render-batch.test.ts b/packages/cli/src/commands/lambda/render-batch.test.ts index c8acd1fc35..ded8385af6 100644 --- a/packages/cli/src/commands/lambda/render-batch.test.ts +++ b/packages/cli/src/commands/lambda/render-batch.test.ts @@ -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); }); diff --git a/packages/cli/src/commands/lambda/render-batch.ts b/packages/cli/src/commands/lambda/render-batch.ts index 690551f9c9..4f073de2bd 100644 --- a/packages/cli/src/commands/lambda/render-batch.ts +++ b/packages/cli/src/commands/lambda/render-batch.ts @@ -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 } /** 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", }; diff --git a/packages/cli/src/commands/lambda/render.test.ts b/packages/cli/src/commands/lambda/render.test.ts index 9c8648f74c..e991e50049 100644 --- a/packages/cli/src/commands/lambda/render.test.ts +++ b/packages/cli/src/commands/lambda/render.test.ts @@ -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" }); diff --git a/packages/cli/src/commands/lambda/render.ts b/packages/cli/src/commands/lambda/render.ts index 139501f1cb..fa6667d322 100644 --- a/packages/cli/src/commands/lambda/render.ts +++ b/packages/cli/src/commands/lambda/render.ts @@ -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 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, diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 4806ffdfce..eee473e92b 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -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, diff --git a/packages/cli/src/commands/render/execute.ts b/packages/cli/src/commands/render/execute.ts index 555fa85465..eba5e80fc0 100644 --- a/packages/cli/src/commands/render/execute.ts +++ b/packages/cli/src/commands/render/execute.ts @@ -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, diff --git a/packages/cli/src/commands/render/plan.test.ts b/packages/cli/src/commands/render/plan.test.ts index 0ff9487a5c..04b84b3d07 100644 --- a/packages/cli/src/commands/render/plan.test.ts +++ b/packages/cli/src/commands/render/plan.test.ts @@ -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"}' }), diff --git a/packages/cli/src/commands/render/plan.ts b/packages/cli/src/commands/render/plan.ts index 0ce39b445c..533a6e8b5e 100644 --- a/packages/cli/src/commands/render/plan.ts +++ b/packages/cli/src/commands/render/plan.ts @@ -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, diff --git a/packages/cli/src/docs/rendering.md b/packages/cli/src/docs/rendering.md index f7963df7a8..9dbda15224 100644 --- a/packages/cli/src/docs/rendering.md +++ b/packages/cli/src/docs/rendering.md @@ -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) diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index dd0480d020..bf6a6d52ee 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -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 } : {}), }); diff --git a/packages/cli/src/utils/deliveryFlags.test.ts b/packages/cli/src/utils/deliveryFlags.test.ts new file mode 100644 index 0000000000..2df96da659 --- /dev/null +++ b/packages/cli/src/utils/deliveryFlags.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/utils/deliveryFlags.ts b/packages/cli/src/utils/deliveryFlags.ts new file mode 100644 index 0000000000..4ee45b2cef --- /dev/null +++ b/packages/cli/src/utils/deliveryFlags.ts @@ -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; +} diff --git a/packages/cli/src/utils/dockerRunArgs.test.ts b/packages/cli/src/utils/dockerRunArgs.test.ts index c3e27eeca1..e49c5e5069 100644 --- a/packages/cli/src/utils/dockerRunArgs.test.ts +++ b/packages/cli/src/utils/dockerRunArgs.test.ts @@ -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, diff --git a/packages/cli/src/utils/dockerRunArgs.ts b/packages/cli/src/utils/dockerRunArgs.ts index 7887d17bb7..9bab25df0c 100644 --- a/packages/cli/src/utils/dockerRunArgs.ts +++ b/packages/cli/src/utils/dockerRunArgs.ts @@ -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] : []), diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index fa77d00c70..d90f00e3e8 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -191,6 +191,8 @@ export { detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, + appendRateCapArgs, + keyframeIntervalToGopSize, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index cac4f0545c..c506af6d8a 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -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) { diff --git a/packages/engine/src/services/chunkEncoder.types.ts b/packages/engine/src/services/chunkEncoder.types.ts index 8c403d584e..650d3b7f65 100644 --- a/packages/engine/src/services/chunkEncoder.types.ts +++ b/packages/engine/src/services/chunkEncoder.types.ts @@ -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; diff --git a/packages/engine/src/services/deliveryEncoderOptions.test.ts b/packages/engine/src/services/deliveryEncoderOptions.test.ts new file mode 100644 index 0000000000..2b5d016f9c --- /dev/null +++ b/packages/engine/src/services/deliveryEncoderOptions.test.ts @@ -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): 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); + }); +}); diff --git a/packages/engine/src/services/streamingEncoder.ts b/packages/engine/src/services/streamingEncoder.ts index ad5a27c192..51559326ab 100644 --- a/packages/engine/src/services/streamingEncoder.ts +++ b/packages/engine/src/services/streamingEncoder.ts @@ -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) { diff --git a/packages/gcp-cloud-run/src/sdk/validateConfig.test.ts b/packages/gcp-cloud-run/src/sdk/validateConfig.test.ts index 96c00b3f26..986d7fc956 100644 --- a/packages/gcp-cloud-run/src/sdk/validateConfig.test.ts +++ b/packages/gcp-cloud-run/src/sdk/validateConfig.test.ts @@ -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), diff --git a/packages/producer/scripts/integration-tests.json b/packages/producer/scripts/integration-tests.json index 127ca4f783..3735fb02d7 100644 --- a/packages/producer/scripts/integration-tests.json +++ b/packages/producer/scripts/integration-tests.json @@ -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", diff --git a/packages/producer/src/renderRequest.test.ts b/packages/producer/src/renderRequest.test.ts index ae1020457d..a59830ceeb 100644 --- a/packages/producer/src/renderRequest.test.ts +++ b/packages/producer/src/renderRequest.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); diff --git a/packages/producer/src/renderRequest.ts b/packages/producer/src/renderRequest.ts index 3bc695c2ed..65b0ef7201 100644 --- a/packages/producer/src/renderRequest.ts +++ b/packages/producer/src/renderRequest.ts @@ -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, field: string, } } +function assertOptionalPositiveNumber(options: Record, 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, 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): 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), diff --git a/packages/producer/src/server.ts b/packages/producer/src/server.ts index 3ea87c8125..6fe066c83b 100644 --- a/packages/producer/src/server.ts +++ b/packages/producer/src/server.ts @@ -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): Omit): Omit { 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"]); diff --git a/packages/producer/src/services/distributed/assemble.ts b/packages/producer/src/services/distributed/assemble.ts index 692625c820..c4b22b2818 100644 --- a/packages/producer/src/services/distributed/assemble.ts +++ b/packages/producer/src/services/distributed/assemble.ts @@ -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>; +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; } diff --git a/packages/producer/src/services/distributed/plan.ts b/packages/producer/src/services/distributed/plan.ts index 10e3302359..023c88d975 100644 --- a/packages/producer/src/services/distributed/plan.ts +++ b/packages/producer/src/services/distributed/plan.ts @@ -47,6 +47,7 @@ import { type EngineConfig, type VideoFrameFormat, getEncoderPreset, + keyframeIntervalToGopSize, normalizeVp9CpuUsed, resolveConfig, type AudioProcessingFailure, @@ -155,6 +156,12 @@ export interface DistributedRenderConfig extends Pick { + 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 })); + }); +}); diff --git a/packages/producer/src/services/render/deliveryEncoder.ts b/packages/producer/src/services/render/deliveryEncoder.ts new file mode 100644 index 0000000000..c86e652c99 --- /dev/null +++ b/packages/producer/src/services/render/deliveryEncoder.ts @@ -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 }; +} diff --git a/packages/producer/src/services/render/deliveryRender.integration.test.ts b/packages/producer/src/services/render/deliveryRender.integration.test.ts new file mode 100644 index 0000000000..d556e6125c --- /dev/null +++ b/packages/producer/src/services/render/deliveryRender.integration.test.ts @@ -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 = ` + + + + + + +
+ +
+ + +`; + +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): Promise { + 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); + }); +}); diff --git a/packages/producer/src/services/render/hlsConfig.ts b/packages/producer/src/services/render/hlsConfig.ts index 4f1002aadf..09d3bd4e21 100644 --- a/packages/producer/src/services/render/hlsConfig.ts +++ b/packages/producer/src/services/render/hlsConfig.ts @@ -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); } /** diff --git a/packages/producer/src/services/render/segmentManifest.ts b/packages/producer/src/services/render/segmentManifest.ts index a484aa00be..862d7cca89 100644 --- a/packages/producer/src/services/render/segmentManifest.ts +++ b/packages/producer/src/services/render/segmentManifest.ts @@ -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 { diff --git a/packages/producer/src/services/render/stages/captureHdrStage.ts b/packages/producer/src/services/render/stages/captureHdrStage.ts index e2dd713af2..a45948da9d 100644 --- a/packages/producer/src/services/render/stages/captureHdrStage.ts +++ b/packages/producer/src/services/render/stages/captureHdrStage.ts @@ -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; 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", diff --git a/packages/producer/src/services/render/stages/captureSegmentedStage.ts b/packages/producer/src/services/render/stages/captureSegmentedStage.ts index a4aa740b01..678cbb0b00 100644 --- a/packages/producer/src/services/render/stages/captureSegmentedStage.ts +++ b/packages/producer/src/services/render/stages/captureSegmentedStage.ts @@ -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), }; } diff --git a/packages/producer/src/services/render/stages/encodeStage.ts b/packages/producer/src/services/render/stages/encodeStage.ts index af7f5f7170..167842804a 100644 --- a/packages/producer/src/services/render/stages/encodeStage.ts +++ b/packages/producer/src/services/render/stages/encodeStage.ts @@ -111,6 +111,8 @@ export interface EncodeStageInput { preset: ReturnType; 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 { 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({}); + }); }); diff --git a/packages/studio-server/src/helpers/encoderOptions.ts b/packages/studio-server/src/helpers/encoderOptions.ts index 0e2da70f5a..17384fbbd0 100644 --- a/packages/studio-server/src/helpers/encoderOptions.ts +++ b/packages/studio-server/src/helpers/encoderOptions.ts @@ -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 { + 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 } : {}), + }; +} diff --git a/packages/studio-server/src/routes/render.test.ts b/packages/studio-server/src/routes/render.test.ts index c408a8d976..20ef482818 100644 --- a/packages/studio-server/src/routes/render.test.ts +++ b/packages/studio-server/src/routes/render.test.ts @@ -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], diff --git a/packages/studio-server/src/types.ts b/packages/studio-server/src/types.ts index a779faa24a..360d3d4a97 100644 --- a/packages/studio-server/src/types.ts +++ b/packages/studio-server/src/types.ts @@ -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; diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts index 19dcfd2dec..c54a8839f7 100644 --- a/packages/studio/vite.adapter.ts +++ b/packages/studio/vite.adapter.ts @@ -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 } : {}), }); diff --git a/skills-manifest.json b/skills-manifest.json index d454309aa9..3ba4d318fc 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -10,7 +10,7 @@ "files": 77 }, "hyperframes-cli": { - "hash": "08a4a3a815d71988", + "hash": "5cbeeb7c70f13fb8", "files": 13 }, "hyperframes-registry": { diff --git a/skills/hyperframes-cli/references/lambda.md b/skills/hyperframes-cli/references/lambda.md index b625538d87..a1985a2b51 100644 --- a/skills/hyperframes-cli/references/lambda.md +++ b/skills/hyperframes-cli/references/lambda.md @@ -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 | diff --git a/skills/hyperframes-cli/references/preview-render.md b/skills/hyperframes-cli/references/preview-render.md index 128844c4aa..ddb32564c1 100644 --- a/skills/hyperframes-cli/references/preview-render.md +++ b/skills/hyperframes-cli/references/preview-render.md @@ -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) |