From 78c59c8ccef57551f70d604eadaddcd22ca87213 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 11 Jun 2026 00:09:23 +0000 Subject: [PATCH 1/2] feat(producer): optional targetChunkFrames to bound per-chunk frames --- .../src/services/distributed/plan.test.ts | 75 +++++++++++++++++++ .../producer/src/services/distributed/plan.ts | 41 +++++++++- .../distributed/renderConfigValidation.ts | 15 ++++ 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/packages/producer/src/services/distributed/plan.test.ts b/packages/producer/src/services/distributed/plan.test.ts index 42e333d5db..9295b45faa 100644 --- a/packages/producer/src/services/distributed/plan.test.ts +++ b/packages/producer/src/services/distributed/plan.test.ts @@ -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", () => { diff --git a/packages/producer/src/services/distributed/plan.ts b/packages/producer/src/services/distributed/plan.ts index b97125dc14..e93dc1f7de 100644 --- a/packages/producer/src/services/distributed/plan.ts +++ b/packages/producer/src/services/distributed/plan.ts @@ -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); diff --git a/packages/producer/src/services/distributed/renderConfigValidation.ts b/packages/producer/src/services/distributed/renderConfigValidation.ts index 9610d6c061..97f9930f41 100644 --- a/packages/producer/src/services/distributed/renderConfigValidation.ts +++ b/packages/producer/src/services/distributed/renderConfigValidation.ts @@ -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", From 7cf9b2143d85edf567337e5d7559bbf0470e5cf5 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 11 Jun 2026 00:31:32 +0000 Subject: [PATCH 2/2] feat(cli): expose --target-chunk-frames on lambda + cloudrun render; document it --- docs/deploy/migrating-to-hyperframes-lambda.mdx | 1 + docs/deploy/templates-on-lambda.mdx | 1 + docs/packages/cli.mdx | 2 +- packages/cli/src/commands/cloudrun.ts | 6 ++++++ packages/cli/src/commands/lambda.ts | 7 +++++++ packages/cli/src/commands/lambda/render-batch.ts | 2 ++ packages/cli/src/commands/lambda/render.ts | 2 ++ 7 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/deploy/migrating-to-hyperframes-lambda.mdx b/docs/deploy/migrating-to-hyperframes-lambda.mdx index 9c15d598ca..46159437b6 100644 --- a/docs/deploy/migrating-to-hyperframes-lambda.mdx +++ b/docs/deploy/migrating-to-hyperframes-lambda.mdx @@ -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) diff --git a/docs/deploy/templates-on-lambda.mdx b/docs/deploy/templates-on-lambda.mdx index efe2dc143a..e05ca56f97 100644 --- a/docs/deploy/templates-on-lambda.mdx +++ b/docs/deploy/templates-on-lambda.mdx @@ -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=`): 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. diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index e2d3bc023b..db8f5b5c67 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -1234,7 +1234,7 @@ Tar + upload a project to GCS once and reuse it across renders. `--site-id` over #### `cloudrun render ` -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 ` diff --git a/packages/cli/src/commands/cloudrun.ts b/packages/cli/src/commands/cloudrun.ts index 01219ed873..5953733f38 100644 --- a/packages/cli/src/commands/cloudrun.ts +++ b/packages/cli/src/commands/cloudrun.ts @@ -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, }); diff --git a/packages/cli/src/commands/lambda.ts b/packages/cli/src/commands/lambda.ts index 4aadf0f267..6530d3bdd5 100644 --- a/packages/cli/src/commands/lambda.ts +++ b/packages/cli/src/commands/lambda.ts @@ -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-)", @@ -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"]), diff --git a/packages/cli/src/commands/lambda/render-batch.ts b/packages/cli/src/commands/lambda/render-batch.ts index 2239bc33d7..96c8322fec 100644 --- a/packages/cli/src/commands/lambda/render-batch.ts +++ b/packages/cli/src/commands/lambda/render-batch.ts @@ -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 { quality: args.quality, chunkSize: args.chunkSize, maxParallelChunks: args.maxParallelChunks, + targetChunkFrames: args.targetChunkFrames, runtimeCap: "lambda", }; diff --git a/packages/cli/src/commands/lambda/render.ts b/packages/cli/src/commands/lambda/render.ts index efab3c000a..d9b31e2c97 100644 --- a/packages/cli/src/commands/lambda/render.ts +++ b/packages/cli/src/commands/lambda/render.ts @@ -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 { quality: args.quality, chunkSize: args.chunkSize, maxParallelChunks: args.maxParallelChunks, + targetChunkFrames: args.targetChunkFrames, runtimeCap: "lambda", variables, };