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
319 changes: 319 additions & 0 deletions docs/deploy/templates-on-lambda.mdx

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@
"group": "Deploy",
"pages": [
"deploy/aws-lambda",
"deploy/templates-on-lambda",
"deploy/migrating-to-hyperframes-lambda"
]
}
Expand Down
56 changes: 56 additions & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,62 @@ hyperframes lambda render ./my-project \

`--json` swaps the human-readable output for a machine-parseable JSON snapshot.

The composition can be parameterised with `--variables` / `--variables-file`, mirroring the local `hyperframes render` flags. Variables flow into the Step Functions execution input and reach every chunk worker as `window.__hfVariables`. Mismatches against the composition's `data-composition-variables` declaration print as warnings; pass `--strict-variables` to fail the command instead.

```bash
hyperframes lambda render ./my-template --site-id=abc1234deadbeef0 \
--width=1920 --height=1080 \
--variables '{"title":"Hello Alice","accent":"#ff0000"}'
```

```bash
hyperframes lambda render ./my-template --site-id=abc1234deadbeef0 \
--width=1920 --height=1080 \
--variables-file ./alice.json --strict-variables
```

Variables travel inside the Step Functions Standard execution input, which AWS caps at 256 KiB for the entire payload. Pass typed data (strings, numbers, structured records) through variables; URL-reference media assets (images, audio, video) the composition resolves at render time rather than inlining bytes. The SDK validates the size client-side and rejects oversize inputs with a clear error before any AWS call runs — see the [templates-on-lambda guide](/deploy/templates-on-lambda) for the URL-your-assets convention.

#### `lambda render-batch <projectDir>`

Fans out N personalised renders from a JSONL batch file — the headline ergonomic for automated template-rendering pipelines. Deploys the site once (or skips with `--site-id`), then invokes `renderToLambda` per batch row with per-entry `variables` and `outputKey`. Concurrent Step Functions starts are capped at `--max-concurrent` (default 50) so a 10 000-entry batch doesn't try to spawn 10 000 executions at once and trip AWS account limits.

Batch file format (JSONL — one JSON object per line):

```jsonl
{"outputKey": "renders/alice.mp4", "variables": {"name": "Alice", "accent": "#ff0000"}}
{"outputKey": "renders/bob.mp4", "variables": {"name": "Bob", "accent": "#0000ff"}}
{"outputKey": "renders/carol.mp4", "variables": {"name": "Carol"}, "executionName": "hf-carol-001"}
```

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

The verb prints a manifest — one row per input line — with `executionArn` + status:

```
Batch dispatched: 3 started, 0 failed-to-start.

✓ line 1 renders/alice.mp4 arn:aws:states:us-east-1:1234:execution:hf:hf-render-...
✓ line 2 renders/bob.mp4 arn:aws:states:us-east-1:1234:execution:hf:hf-render-...
✓ line 3 renders/carol.mp4 arn:aws:states:us-east-1:1234:execution:hf:hf-carol-001
```

Pass `--json` for the machine-readable form. Poll each execution via `hyperframes lambda progress <renderId>` (or use the returned `executionArn`).

`--dry-run` skips the AWS calls and prints the manifest with `status: "would-invoke"` for every entry — use it to lint the batch file before committing to N billable executions:

```bash
hyperframes lambda render-batch ./my-template --batch ./users.jsonl \
--width 1920 --height 1080 --dry-run --json
```

`--max-concurrent` is orchestrator-side only: it caps how many `StartExecution` calls run simultaneously, not how many Lambda invocations the account can run. AWS account-level Lambda concurrency limits live one level up and `render-batch` cannot enforce them; pick `--max-concurrent` based on your account's `concurrent-execution` quota and the Lambda reserved concurrency you provisioned via `lambda deploy --concurrency=<N>`.

#### `lambda progress <renderId | executionArn>`

Prints one progress snapshot — overall percent, frames rendered, Lambda invocations, accrued cost, and any errors. Accepts either a bare `renderId` (resolved against the stack's state-machine ARN) or a full SFN execution ARN.
Expand Down
108 changes: 107 additions & 1 deletion packages/cli/src/commands/lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ export const examples: Example[] = [
"Render and stream progress until done",
"hyperframes lambda render ./my-project --width 1920 --height 1080 --wait",
],
[
"Render with composition variables (personalised template)",
'hyperframes lambda render ./my-template --site-id abc1234deadbeef0 --width 1920 --height 1080 --variables \'{"title":"Hello Alice","accent":"#ff0000"}\'',
],
[
"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",
Expand All @@ -45,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")}
Expand Down Expand Up @@ -114,6 +127,42 @@ export default defineCommand({
type: "string",
description: "Final output S3 key (default: renders/<exec>/output.<ext>)",
},
// Variables — mirrors the local `hyperframes render` UX. Inline JSON or
// file path, plus --strict-variables for type-checked validation against
// the composition's `data-composition-variables` declaration.
variables: {
type: "string",
description:
'JSON object of variable values for the composition. Example: --variables \'{"title":"Hello"}\'. Values flow into window.__hfVariables on the Lambda chunk workers.',
},
"variables-file": {
type: "string",
description:
"Path to a JSON file with variable values (alternative to --variables). The file must contain a single JSON object.",
},
"strict-variables": {
type: "boolean",
description:
"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",
Expand Down Expand Up @@ -152,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");
Expand Down Expand Up @@ -242,12 +298,62 @@ export default defineCommand({
maxParallelChunks: parsePositiveInt(args["max-parallel-chunks"], "--max-parallel-chunks"),
executionName: args["execution-name"] as string | undefined,
outputKey: args["output-key"] as string | undefined,
variables: args.variables as string | undefined,
variablesFile: args["variables-file"] as string | undefined,
strictVariables: Boolean(args["strict-variables"]),
json: Boolean(args.json),
wait: Boolean(args.wait),
waitIntervalMs: parsePositiveInt(args["wait-interval-ms"], "--wait-interval-ms") ?? 5000,
});
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) {
Expand Down
139 changes: 139 additions & 0 deletions packages/cli/src/commands/lambda/render-batch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseBatchFile, runWithConcurrencyLimit } from "./render-batch.js";

let tmpDir: string;

beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "hf-render-batch-"));
});

afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});

function writeBatch(content: string): string {
const p = join(tmpDir, "batch.jsonl");
writeFileSync(p, content, "utf8");
return p;
}

describe("runWithConcurrencyLimit", () => {
it("preserves input order in the output array regardless of completion order", async () => {
// First input takes longest to resolve; output array still positional.
const delays = [40, 10, 20];
const out = await runWithConcurrencyLimit(delays, 3, async (ms, i) => {
await new Promise((r) => setTimeout(r, ms));
return `done-${i}`;
});
expect(out).toEqual(["done-0", "done-1", "done-2"]);
});

it("caps simultaneous in-flight work to the limit", async () => {
let inFlight = 0;
let peak = 0;
const inputs = Array.from({ length: 12 }, (_, i) => i);
const worker = async (i: number): Promise<number> => {
inFlight++;
peak = Math.max(peak, inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight--;
return i;
};
await runWithConcurrencyLimit(inputs, 3, worker);
expect(peak).toBe(3);
});

it("does not exceed the input length even when limit > inputs.length", async () => {
let inFlight = 0;
let peak = 0;
const inputs = [1, 2];
await runWithConcurrencyLimit(inputs, 50, async (n) => {
inFlight++;
peak = Math.max(peak, inFlight);
await new Promise((r) => setTimeout(r, 2));
inFlight--;
return n;
});
// Only 2 inputs → only 2 concurrent workers, even with limit=50.
expect(peak).toBe(2);
});

it("rejects a limit < 1", async () => {
await expect(runWithConcurrencyLimit([1, 2], 0, async (n) => n)).rejects.toThrow(
/limit must be/,
);
});

it("returns immediately for an empty input array", async () => {
const out = await runWithConcurrencyLimit([], 10, async (n: number) => n * 2);
expect(out).toEqual([]);
});

it("propagates the first worker rejection", async () => {
await expect(
runWithConcurrencyLimit([1, 2, 3], 2, async (n) => {
if (n === 2) throw new Error("boom");
return n;
}),
).rejects.toThrow(/boom/);
});
});

describe("parseBatchFile", () => {
it("parses a JSONL file into ordered entries (line numbers preserve source order)", () => {
const path = writeBatch(
[
'{"outputKey":"renders/alice.mp4","variables":{"name":"Alice"}}',
'{"outputKey":"renders/bob.mp4","variables":{"name":"Bob"},"executionName":"hf-bob-001"}',
].join("\n") + "\n",
);
const out = parseBatchFile(path);
expect(out).toHaveLength(2);
expect(out[0]?.entry.outputKey).toBe("renders/alice.mp4");
expect(out[0]?.entry.variables).toEqual({ name: "Alice" });
expect(out[0]?.lineNumber).toBe(1);
expect(out[1]?.entry.executionName).toBe("hf-bob-001");
expect(out[1]?.lineNumber).toBe(2);
});

it("skips blank lines and preserves line numbers", () => {
const path = writeBatch(
["", '{"outputKey":"renders/a.mp4"}', "", "", '{"outputKey":"renders/b.mp4"}'].join("\n") +
"\n",
);
const out = parseBatchFile(path);
expect(out).toHaveLength(2);
expect(out[0]?.lineNumber).toBe(2);
expect(out[1]?.lineNumber).toBe(5);
});

// Helper: stub `process.exit` to throw a sentinel, run the parser, and
// verify it called exit(1). Dedupes the 3 error-path tests so each one
// is a single readable assertion.
function expectExitOne(content: string): void {
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("EXIT_CALLED");
});
try {
expect(() => parseBatchFile(writeBatch(content))).toThrow(/EXIT_CALLED/);
expect(exitSpy).toHaveBeenCalledWith(1);
} finally {
exitSpy.mockRestore();
}
}

it("exits with a clear message on malformed JSON, naming the offending line", () => {
expectExitOne(['{"outputKey":"renders/a.mp4"}', "{not json"].join("\n"));
});

it("rejects entries missing outputKey", () => {
expectExitOne('{"variables":{"name":"Alice"}}\n');
});

it("rejects variables that's not a plain object", () => {
expectExitOne('{"outputKey":"renders/a.mp4","variables":[1,2,3]}\n');
});
});
Loading
Loading