mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
: []),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user