Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/deploy/migrating-to-hyperframes-lambda.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs/deploy/templates-on-lambda.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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>`

Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/commands/cloudrun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
});
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/commands/lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>)",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"]),
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/lambda/render-batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
};

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/lambda/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down Expand Up @@ -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,
};
Expand Down
75 changes: 75 additions & 0 deletions packages/producer/src/services/distributed/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
41 changes: 40 additions & 1 deletion packages/producer/src/services/distributed/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand All @@ -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));
Expand Down Expand Up @@ -890,6 +928,7 @@ export async function plan(
totalFrames,
config.chunkSize,
maxParallel,
config.targetChunkFrames,
);
const chunks = buildChunkSlices(totalFrames, chunkCount, effectiveChunkSize);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading