feat(producer): optional targetChunkFrames to bound per-chunk frames (#1332)

* feat(producer): optional targetChunkFrames to bound per-chunk frames

* feat(cli): expose --target-chunk-frames on lambda + cloudrun render; document it
This commit is contained in:
James Russo
2026-06-10 18:09:59 -07:00
committed by GitHub
parent 69eac249d5
commit 9b18fadccd
10 changed files with 150 additions and 2 deletions
@@ -46,6 +46,7 @@ Most adopters' render config maps directly:
| Quality preset | `--quality=draft / standard / high` | Maps onto ffmpeg encoder presets. |
| Chunk size in frames | `--chunk-size=240` (default 240) | ~8s at 30 fps; sized to fit Lambda's 15-min cap with headroom. |
| Max parallel chunks | `--max-parallel-chunks=16` (default 16) | Caps the Map state's fan-out. |
| Per-chunk frame ceiling | `--target-chunk-frames=N` (optional) | Caps frames per chunk so one chunk can't run past Lambda's 15-min cap on a long video: the planner adds chunks (up to `--max-parallel-chunks`) to keep each at or below `N`, and short videos still collapse to fewer chunks. A ceiling, not a fixed size; ignored when `--chunk-size` is set. |
| Bitrate / CRF | `--bitrate=10M` or `--crf=18` | Mutually exclusive. |
## Variables (inputProps)
+1
View File
@@ -286,6 +286,7 @@ Each personalised render is one Step Functions execution + N chunk Lambda invoca
The cost knobs:
- **`--max-parallel-chunks`**: per render, default 16. Smaller compositions don't fan out beyond `ceil(totalFrames / chunkSize)`. Higher values pay more Lambda invocations but finish faster.
- **`--target-chunk-frames`**: optional per-chunk frame ceiling. With the default count-based sizing, a long composition's chunks grow with its length (`maxParallelChunks` chunks of `ceil(totalFrames / maxParallelChunks)` frames each), so a long enough render produces chunks too big to finish inside Lambda's 15-min cap. Setting this caps frames per chunk — the planner uses `clamp(ceil(totalFrames / targetChunkFrames), 1, maxParallelChunks)` chunks, adding chunks on long videos to keep each under the bound while still collapsing short videos to fewer chunks. It's a ceiling, not a fixed size, and is ignored when `--chunk-size` is set. A render long enough to need more than `maxParallelChunks` chunks stays at the cap (chunks then exceed the target — raise `--max-parallel-chunks` or shorten the render).
- **Lambda reserved concurrency** (`lambda deploy --concurrency=<N>`): caps how many Lambda invocations the render function can run in parallel. Other workloads in the same AWS account share the same account-level concurrency pool (~1 000 in most regions by default), so reserved concurrency keeps the render function from starving them and vice-versa.
- **`render-batch --max-concurrent`**: orchestrator-side. Caps how many `StartExecution` calls run simultaneously — distinct from the Lambda concurrency cap, which lives one level below at the chunk-invoke layer. The CLI cannot enforce Lambda's account limit; it can only avoid creating excess Step Functions executions queued against it.
- **Lambda memory** (`lambda deploy --memory`): default 10 240 MB (max). Higher memory buys faster Chrome capture + more vCPUs per chunk; lower memory saves cost but risks `15 min` timeouts on heavy compositions.
+1 -1
View File
@@ -1234,7 +1234,7 @@ Tar + upload a project to GCS once and reuse it across renders. `--site-id` over
#### `cloudrun render <projectDir>`
Start a distributed render. `--width` / `--height` are required; `--fps` (24/30/60), `--format`, `--codec`, `--quality`, `--chunk-size`, `--max-parallel-chunks`, and `--output-resolution` (deviceScaleFactor supersampling, e.g. `4k`) mirror the local render flags. Pass composition variables with `--variables '{"title":"Hi"}'` or `--variables-file alice.json`; add `--strict-variables` to fail on a key that's undeclared or mistyped vs the composition's `data-composition-variables`. `--wait` polls until the render finishes and prints the output URI + cost; without it the command returns an execution name.
Start a distributed render. `--width` / `--height` are required; `--fps` (24/30/60), `--format`, `--codec`, `--quality`, `--chunk-size`, `--max-parallel-chunks`, `--target-chunk-frames`, and `--output-resolution` (deviceScaleFactor supersampling, e.g. `4k`) mirror the local render flags. `--target-chunk-frames` caps the frames per chunk so a single chunk can't run past a per-chunk timeout on a long video: the planner uses the fewest chunks that keep each at or below the bound, up to `--max-parallel-chunks`, and short videos still collapse to fewer chunks. It's a ceiling, not a fixed size, and is ignored when `--chunk-size` is set. Pass composition variables with `--variables '{"title":"Hi"}'` or `--variables-file alice.json`; add `--strict-variables` to fail on a key that's undeclared or mistyped vs the composition's `data-composition-variables`. `--wait` polls until the render finishes and prints the output URI + cost; without it the command returns an execution name.
#### `cloudrun render-batch <projectDir>`
+6
View File
@@ -144,6 +144,11 @@ export default defineCommand({
quality: { type: "string", description: "draft | standard | high" },
"chunk-size": { type: "string", description: "Frames per chunk" },
"max-parallel-chunks": { type: "string", description: "Max concurrent chunks" },
"target-chunk-frames": {
type: "string",
description:
"Cap per-chunk frames; auto-adds chunks (up to --max-parallel-chunks) to keep each under this. Ignored if --chunk-size is set.",
},
"output-resolution": {
type: "string",
description:
@@ -781,6 +786,7 @@ function buildRenderConfig(
quality: parseQuality(args.quality),
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"),
outputResolution: parseOutputResolution(args["output-resolution"]),
variables,
});
+7
View File
@@ -133,6 +133,11 @@ export default defineCommand({
quality: { type: "string", description: "draft | standard | high" },
"chunk-size": { type: "string", description: "Frames per chunk (default: 240)" },
"max-parallel-chunks": { type: "string", description: "Max concurrent chunks (default: 16)" },
"target-chunk-frames": {
type: "string",
description:
"Cap per-chunk frames; auto-adds chunks (up to --max-parallel-chunks) to keep each under this. Ignored if --chunk-size is set.",
},
"execution-name": {
type: "string",
description: "Step Functions execution name (default: hf-render-<uuid>)",
@@ -311,6 +316,7 @@ export default defineCommand({
quality: parseQuality(args.quality),
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"),
executionName: args["execution-name"] as string | undefined,
outputKey: args["output-key"] as string | undefined,
variables: args.variables as string | undefined,
@@ -363,6 +369,7 @@ export default defineCommand({
quality: parseQuality(args.quality),
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"),
maxConcurrent: parsePositiveInt(args["max-concurrent"], "--max-concurrent"),
strictVariables: Boolean(args["strict-variables"]),
dryRun: Boolean(args["dry-run"]),
@@ -69,6 +69,7 @@ export interface RenderBatchArgs {
quality?: "draft" | "standard" | "high";
chunkSize?: number;
maxParallelChunks?: number;
targetChunkFrames?: number;
/**
* Maximum in-flight Step Functions starts at any moment. Caps fan-out
* so a 10 000-entry batch doesn't try to spawn 10 000 executions
@@ -217,6 +218,7 @@ export async function runRenderBatch(args: RenderBatchArgs): Promise<void> {
quality: args.quality,
chunkSize: args.chunkSize,
maxParallelChunks: args.maxParallelChunks,
targetChunkFrames: args.targetChunkFrames,
runtimeCap: "lambda",
};
@@ -47,6 +47,7 @@ export interface RenderArgs {
quality?: "draft" | "standard" | "high";
chunkSize?: number;
maxParallelChunks?: number;
targetChunkFrames?: number;
executionName?: string;
outputKey?: string;
/** Inline JSON for `--variables '{...}'`. Mutually exclusive with `variablesFile`. */
@@ -123,6 +124,7 @@ export async function runRender(args: RenderArgs): Promise<void> {
quality: args.quality,
chunkSize: args.chunkSize,
maxParallelChunks: args.maxParallelChunks,
targetChunkFrames: args.targetChunkFrames,
runtimeCap: "lambda",
variables,
};
@@ -162,6 +162,81 @@ describe("resolveChunkPlan", () => {
}
}
});
// ── targetChunkFrames (optional per-chunk frame ceiling) ──
it("targetChunkFrames omitted is a no-op: identical to the 3-arg auto-sized result", () => {
// The default path must be byte-identical whether the 4th arg is absent or
// explicitly undefined.
for (const totalFrames of [50, 660, 1466, 54000]) {
for (const maxParallel of [8, 16, 64]) {
const base = resolveChunkPlan(totalFrames, undefined, maxParallel);
const withUndef = resolveChunkPlan(totalFrames, undefined, maxParallel, undefined);
expect(withUndef).toEqual(base);
}
}
});
it("targetChunkFrames collapses a short video to fewer chunks than the parallelism cap", () => {
// 1466 frames, target 300, cap 16: ceil(1466/300)=5 chunks (not 16), each
// ~293 frames. Fewer chunks → less per-chunk fixed overhead.
const result = resolveChunkPlan(1466, undefined, 16, 300);
expect(result.chunkCount).toBe(5);
expect(result.effectiveChunkSize).toBeLessThanOrEqual(300);
});
it("targetChunkFrames bounds a long video's per-chunk frames, adding chunks up to the cap", () => {
// 54000 frames (30 min @30fps), target 1600, cap 64: ceil(54000/1600)=34
// chunks, each <= 1600 frames so per-chunk render time stays under budget.
const result = resolveChunkPlan(54000, undefined, 64, 1600);
expect(result.chunkCount).toBe(34);
expect(result.effectiveChunkSize).toBeLessThanOrEqual(1600);
});
it("targetChunkFrames is clamped by maxParallelChunks: an extreme length stays at the cap (still over budget)", () => {
// 216000 frames (2 h), target 1600, cap 64: needs 135 chunks but clamps to
// 64; per-chunk frames then exceed the target — the genuine tier ceiling.
const result = resolveChunkPlan(216000, undefined, 64, 1600);
expect(result.chunkCount).toBe(64);
expect(result.effectiveChunkSize).toBeGreaterThan(1600);
});
it("explicit chunkSize wins over targetChunkFrames (targetChunkFrames is a no-op)", () => {
const withTarget = resolveChunkPlan(54000, 240, 64, 1600);
const chunkSizeOnly = resolveChunkPlan(54000, 240, 64);
expect(withTarget).toEqual(chunkSizeOnly);
});
it("rejects a non-positive or non-integer targetChunkFrames", () => {
expect(() => resolveChunkPlan(1466, undefined, 16, 0)).toThrow(/positive integer/);
expect(() => resolveChunkPlan(1466, undefined, 16, -100)).toThrow(/positive integer/);
expect(() => resolveChunkPlan(1466, undefined, 16, 300.5)).toThrow(/positive integer/);
});
it("never emits an empty or inverted slice across a grid of targetChunkFrames", () => {
for (const totalFrames of [37, 660, 1466, 12793, 54000]) {
for (const maxParallel of [8, 16, 64]) {
for (const target of [100, 300, 1600]) {
const { chunkCount, effectiveChunkSize } = resolveChunkPlan(
totalFrames,
undefined,
maxParallel,
target,
);
expect(chunkCount).toBeGreaterThanOrEqual(1);
expect(chunkCount).toBeLessThanOrEqual(maxParallel);
const slices = buildChunkSlices(totalFrames, chunkCount, effectiveChunkSize);
let cursor = 0;
for (const s of slices) {
expect(s.startFrame).toBe(cursor);
expect(s.endFrame).toBeGreaterThan(s.startFrame);
cursor = s.endFrame;
}
expect(cursor).toBe(totalFrames);
}
}
}
});
});
describe("buildChunkSlices", () => {
@@ -128,6 +128,23 @@ export interface DistributedRenderConfig {
chunkSize?: number;
/** Default `16`. Caps long renders to fewer-but-longer chunks for operational fairness. */
maxParallelChunks?: number;
/**
* Upper bound on frames-per-chunk, in frames. Optional; when omitted (the
* default) chunk sizing is unchanged. When set, chunking targets the fewest
* chunks whose per-chunk frame count stays at or below this bound, still
* capped by `maxParallelChunks`:
*
* chunkCount = clamp(ceil(totalFrames / targetChunkFrames), 1, maxParallelChunks)
*
* This bounds per-chunk render *time* (which scales with frames-per-chunk) so
* a single chunk can't exceed a downstream per-chunk timeout on a long video,
* while short videos still collapse to few chunks. It is a ceiling, not a
* fixed size: a video short enough to fit in fewer chunks gets fewer. Ignored
* when `chunkSize` is set (an explicit fixed size already pins per-chunk
* frames). Mutually exclusive with `chunkSize` in intent; if both are passed,
* `chunkSize` wins and `targetChunkFrames` is a no-op.
*/
targetChunkFrames?: number;
/** Runtime hint; consumed by future per-runtime budget checks. The current implementation records the value but does not enforce. */
runtimeCap?: "lambda" | "temporal" | "cloud-run-job" | "k8s-job" | "none";
@@ -410,11 +427,18 @@ export function measurePlanDirBytes(planDir: string): number {
* the caller's fan-out intent: passing `maxParallelChunks=16` without
* `chunkSize` produces 16 chunks (subject to the `MIN_CHUNK_SIZE` floor
* on tiny renders). Explicit numbers, including `240`, take precedence.
*
* Optional `targetChunkFrames` caps per-chunk frames in the auto-sized path:
* the auto-sizer then targets `clamp(ceil(totalFrames / targetChunkFrames), 1,
* maxParallelChunks)` chunks, so short videos collapse to fewer chunks and long
* videos add chunks (up to the cap) to keep each one under the bound. It is a
* no-op when omitted, and ignored when `configChunkSize` is set.
*/
export function resolveChunkPlan(
totalFrames: number,
configChunkSize: number | undefined,
maxParallelChunks: number,
targetChunkFrames?: number,
): { chunkCount: number; effectiveChunkSize: number } {
// Integer-only inputs: a fractional `totalFrames` (e.g. 10.5) would
// otherwise produce a last chunk with non-integer `endFrame`, and the
@@ -430,8 +454,22 @@ export function resolveChunkPlan(
if (configChunkSize !== undefined) {
assertPositiveInteger("configChunkSize", configChunkSize);
}
if (targetChunkFrames !== undefined) {
assertPositiveInteger("targetChunkFrames", targetChunkFrames);
}
// `targetChunkFrames` lowers the auto-sizer's effective parallelism so the
// chosen chunk count keeps frames-per-chunk at or below the bound, without
// ever exceeding `maxParallelChunks`. It only affects the auto-sized path
// (`configChunkSize === undefined`); an explicit `chunkSize` already pins
// per-chunk frames and takes precedence. When `targetChunkFrames` is
// undefined, `autoSizeParallel === maxParallelChunks` and the auto-sized
// chunk size is identical to the prior behavior.
const autoSizeParallel =
targetChunkFrames === undefined
? maxParallelChunks
: Math.min(maxParallelChunks, Math.max(1, Math.ceil(totalFrames / targetChunkFrames)));
const resolvedChunkSize =
configChunkSize ?? Math.max(MIN_CHUNK_SIZE, Math.ceil(totalFrames / maxParallelChunks));
configChunkSize ?? Math.max(MIN_CHUNK_SIZE, Math.ceil(totalFrames / autoSizeParallel));
const naiveCount = Math.ceil(totalFrames / resolvedChunkSize);
const chunkCount = Math.min(maxParallelChunks, Math.max(1, naiveCount));
const effectiveChunkSize = Math.max(resolvedChunkSize, Math.ceil(totalFrames / chunkCount));
@@ -890,6 +928,7 @@ export async function plan(
totalFrames,
config.chunkSize,
maxParallel,
config.targetChunkFrames,
);
const chunks = buildChunkSlices(totalFrames, chunkCount, effectiveChunkSize);
@@ -160,6 +160,21 @@ export function validateDistributedRenderConfig(
}
}
if (config.targetChunkFrames !== undefined) {
if (!Number.isInteger(config.targetChunkFrames) || config.targetChunkFrames < 1) {
throw new InvalidConfigError(
"config.targetChunkFrames",
`must be a positive integer; got ${config.targetChunkFrames}`,
);
}
if (config.targetChunkFrames > MAX_CHUNK_SIZE) {
throw new InvalidConfigError(
"config.targetChunkFrames",
`must be <= ${MAX_CHUNK_SIZE}; got ${config.targetChunkFrames}`,
);
}
}
if (config.runtimeCap !== undefined && !ALLOWED_RUNTIME_CAPS.includes(config.runtimeCap)) {
throw new InvalidConfigError(
"config.runtimeCap",