feat(cli): hyperframes lambda render-batch verb

New subcommand for automated template-rendering pipelines. Given a
project dir + a JSONL batch file, fans out N personalised renders by
calling renderToLambda once per batch row with per-entry variables and
outputKey:

  hyperframes lambda render-batch ./my-template \
    --batch ./users.jsonl \
    --width 1920 --height 1080 \
    --max-concurrent 10

JSONL format (one JSON object per line):

  {"outputKey": "renders/alice.mp4", "variables": {"name": "Alice"}}
  {"outputKey": "renders/bob.mp4",   "variables": {"name": "Bob"}}

The verb deploys the site once and reuses it across renders (--site-id
skips the deploy when the project was pre-uploaded). Concurrent Step
Functions starts are capped at --max-concurrent (default 50) via a
semaphore so a 10 000-entry batch doesn't try to spawn 10 000
executions simultaneously and trip the AWS account's concurrent-
execution quota.

Per-entry results land in a manifest (one row per input line) with
executionArn + status. --json emits the manifest as machine-readable
JSON. --dry-run prints the manifest with status: "would-invoke" for
every entry without calling AWS, so callers can lint their batch file
before paying for N executions.

Variables in each batch entry pre-validate against the composition's
data-composition-variables declaration (mirroring the local
hyperframes render UX). --strict-variables aborts the run on the first
failing entry before any AWS call. The reportVariableIssues helper from
PR 9.3 is reused so the warning format matches the single-render path
exactly.

Distinction from --max-parallel-chunks: --max-concurrent caps
ORCHESTRATOR-side fan-out (how many StartExecution calls run at once);
--max-parallel-chunks caps chunks PER render. AWS account-level Lambda
concurrent-execution limits live one level up and render-batch can't
enforce those; pick --max-concurrent based on your account quota +
the reserved concurrency you provisioned via lambda deploy.

Tests cover the concurrency-cap semaphore (preserve-order,
peak-in-flight, empty-input, limit > inputs.length, propagate
rejection) and the JSONL parser (blank-line handling, malformed JSON,
missing outputKey, non-object variables).

Phase 9 PR 9.4 of the distributed rendering plan.
This commit is contained in:
James
2026-05-19 19:54:30 -04:00
committed by James Russo
parent cb948d5fcf
commit f0a2740f6e
4 changed files with 702 additions and 1 deletions
+77 -1
View File
@@ -31,6 +31,10 @@ export const examples: Example[] = [
"Render with variables from a JSON file",
"hyperframes lambda render ./my-template --site-id abc1234deadbeef0 --width 1920 --height 1080 --variables-file ./alice.json",
],
[
"Batch-render N personalised videos from a JSONL file (deploys the site once)",
"hyperframes lambda render-batch ./my-template --batch ./users.jsonl --width 1920 --height 1080 --max-concurrent 10",
],
["Check progress for a started render", "hyperframes lambda progress hf-render-abcd1234"],
[
"Pre-upload a project so multiple renders share the upload",
@@ -53,6 +57,7 @@ ${c.bold("SUBCOMMANDS:")}
${c.accent("deploy")} ${c.dim("Provision the Lambda + Step Functions + S3 stack via SAM")}
${c.accent("sites create")} ${c.dim("Tar + upload a project to S3 (reusable across renders)")}
${c.accent("render")} ${c.dim("Start a distributed render (returns a renderId)")}
${c.accent("render-batch")} ${c.dim("Fan out N personalised renders from a JSONL batch file")}
${c.accent("progress")} ${c.dim("Print progress + cost for an in-flight or finished render")}
${c.accent("destroy")} ${c.dim("Tear the stack down (S3 bucket is retained)")}
${c.accent("policies")} ${c.dim("Print or validate the IAM permissions the CLI needs")}
@@ -141,6 +146,23 @@ export default defineCommand({
"Fail the render command if any --variables key is undeclared or has a wrong type vs the composition's data-composition-variables. Without this flag, mismatches are warnings.",
default: false,
},
// render-batch
batch: {
type: "string",
description:
'Path to a JSONL batch file for `render-batch`. Each line: {"outputKey":"...","variables":{...}}',
},
"max-concurrent": {
type: "string",
description:
"Max in-flight Step Functions executions for `render-batch` (default: 50). Distinct from --max-parallel-chunks (which caps chunks per render).",
},
"dry-run": {
type: "boolean",
description:
"For `render-batch`: parse the batch file and print the manifest without invoking AWS. Every entry's status becomes `would-invoke`.",
default: false,
},
wait: { type: "boolean", description: "Block until the render finishes" },
"wait-interval-ms": {
type: "string",
@@ -179,7 +201,14 @@ export default defineCommand({
// dep) so the published CLI install stays small for users who don't
// deploy to Lambda. Subverbs other than `policies` need aws-lambda;
// catch the missing-module error here and turn it into a friendly hint.
const verbsNeedingSDK = new Set(["deploy", "sites", "render", "progress", "destroy"]);
const verbsNeedingSDK = new Set([
"deploy",
"sites",
"render",
"render-batch",
"progress",
"destroy",
]);
if (verbsNeedingSDK.has(subcommand)) {
try {
await import("@hyperframes/aws-lambda/sdk");
@@ -278,6 +307,53 @@ export default defineCommand({
});
return;
}
case "render-batch": {
const projectDir = args.target as string | undefined;
if (!projectDir) {
console.error(
"[lambda render-batch] usage: hyperframes lambda render-batch <projectDir> --batch <path.jsonl> --width <px> --height <px>",
);
process.exit(1);
}
const batch = args.batch as string | undefined;
if (!batch) {
console.error(
"[lambda render-batch] --batch <path.jsonl> is required. Each line is a JSON object with at least { outputKey: '...' }.",
);
process.exit(1);
}
const width = parsePositiveInt(args.width, "--width");
const height = parsePositiveInt(args.height, "--height");
if (width === undefined || height === undefined) {
console.error("[lambda render-batch] --width and --height are required.");
process.exit(1);
}
const fpsRaw = parseIntFlag(args.fps) ?? 30;
if (fpsRaw !== 24 && fpsRaw !== 30 && fpsRaw !== 60) {
console.error(`[lambda render-batch] --fps must be 24, 30, or 60; got ${fpsRaw}.`);
process.exit(1);
}
const { runRenderBatch } = await import("./lambda/render-batch.js");
await runRenderBatch({
projectDir,
stackName,
batch,
siteId: args["site-id"] as string | undefined,
fps: fpsRaw,
width,
height,
format: parseFormat(args.format),
codec: parseCodec(args.codec),
quality: parseQuality(args.quality),
chunkSize: parsePositiveInt(args["chunk-size"], "--chunk-size"),
maxParallelChunks: parsePositiveInt(args["max-parallel-chunks"], "--max-parallel-chunks"),
maxConcurrent: parsePositiveInt(args["max-concurrent"], "--max-concurrent"),
strictVariables: Boolean(args["strict-variables"]),
dryRun: Boolean(args["dry-run"]),
json: Boolean(args.json),
});
return;
}
case "progress": {
const target = args.target as string | undefined;
if (!target) {