Skip to content
Closed
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
16 changes: 16 additions & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,22 @@ 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 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
30 changes: 30 additions & 0 deletions packages/cli/src/commands/lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ 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",
],
["Check progress for a started render", "hyperframes lambda progress hf-render-abcd1234"],
[
"Pre-upload a project so multiple renders share the upload",
Expand Down Expand Up @@ -114,6 +122,25 @@ 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,
},
wait: { type: "boolean", description: "Block until the render finishes" },
"wait-interval-ms": {
type: "string",
Expand Down Expand Up @@ -242,6 +269,9 @@ 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,
Expand Down
53 changes: 52 additions & 1 deletion packages/cli/src/commands/lambda/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@
* poll — use `hyperframes lambda progress` for that.
*/

import { resolve as resolvePath } from "node:path";
import { existsSync } from "node:fs";
import { join, resolve as resolvePath } from "node:path";
import type {
DistributedFormat,
SerializableDistributedRenderConfig,
} from "@hyperframes/aws-lambda/sdk";
import { c } from "../../ui/colors.js";
import {
reportVariableIssues,
resolveVariablesArg,
validateVariablesAgainstProject,
} from "../../utils/variables.js";
import { requireStack, stateFilePath } from "./state.js";

// Dynamic-import the SDK so tsup keeps it out of the static-import head of
Expand All @@ -33,6 +39,17 @@ export interface RenderArgs {
maxParallelChunks?: number;
executionName?: string;
outputKey?: string;
/** Inline JSON for `--variables '{...}'`. Mutually exclusive with `variablesFile`. */
variables?: string;
/** Path to a JSON file for `--variables-file ./vars.json`. */
variablesFile?: string;
/**
* Fail the 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 (matches the local `hyperframes render`
* behavior).
*/
strictVariables?: boolean;
/** Print machine-readable JSON instead of the human-friendly summary. */
json: boolean;
/** Block until the render finishes. Polls `progress` until SUCCEEDED/FAILED. */
Expand All @@ -41,10 +58,43 @@ export interface RenderArgs {
waitIntervalMs: number;
}

// fallow-ignore-next-line complexity
export async function runRender(args: RenderArgs): Promise<void> {
const stack = requireStack(args.stackName);
const projectDir = resolvePath(args.projectDir);

// Resolve --variables / --variables-file using the same parser the local
// `hyperframes render` uses. `resolveVariablesArg` exits(1) with a friendly
// errorBox on parse errors so callers don't have to.
const variables = resolveVariablesArg(args.variables, args.variablesFile);

// Validate against the composition's `data-composition-variables`
// declaration when present. The local CLI silently treats unreadable
// index.html as "no declarations" — mirror that. Skip validation
// entirely when the project dir is missing on disk (e.g. `--site-id`
// pointing at a pre-uploaded site that was packaged on another machine).
if (variables && Object.keys(variables).length > 0) {
const indexPath = join(projectDir, "index.html");
if (existsSync(indexPath)) {
const issues = validateVariablesAgainstProject(indexPath, variables);
// Suppress the warning block when --json is set; stdout is reserved
// for the manifest. The strict-mode errorBox still prints to stderr
// and exits, so machine consumers still get a non-zero exit.
reportVariableIssues(issues, { strict: args.strictVariables ?? false, quiet: args.json });
} else if (args.strictVariables && !args.json) {
// --strict-variables asks for typed checking but there's no
// index.html to check against (typical with --site-id pointing at a
// pre-uploaded site). Make that silent skip visible so the flag
// doesn't quietly become a no-op.
console.warn(
c.warn(
`--strict-variables: no ${indexPath} on disk — schema validation skipped. ` +
"Variables flow through unchecked. To enable strict checking, run from a project dir that contains the composition.",
),
);
}
}

const config: SerializableDistributedRenderConfig = {
fps: args.fps,
width: args.width,
Expand All @@ -55,6 +105,7 @@ export async function runRender(args: RenderArgs): Promise<void> {
chunkSize: args.chunkSize,
maxParallelChunks: args.maxParallelChunks,
runtimeCap: "lambda",
variables,
};

// When the caller passes only --site-id, synthesise the minimum-shape
Expand Down
141 changes: 1 addition & 140 deletions packages/cli/src/commands/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,143 +261,4 @@ describe("renderLocal browser GPU config", () => {
});
});

describe("parseVariablesArg", () => {
let parseVariablesArg: typeof import("./render.js").parseVariablesArg;

beforeAll(async () => {
({ parseVariablesArg } = await import("./render.js"));
});

function expectErr<T extends { kind: string }>(
result: import("./render.js").VariablesParseResult,
): T {
if (result.ok) throw new Error(`expected error, got ${JSON.stringify(result.value)}`);
return result.error as T;
}

it("returns undefined when neither flag is set", () => {
expect(parseVariablesArg(undefined, undefined)).toEqual({ ok: true, value: undefined });
});

it("parses inline JSON object", () => {
expect(parseVariablesArg('{"title":"Hello","n":3}', undefined)).toEqual({
ok: true,
value: { title: "Hello", n: 3 },
});
});

it("parses file JSON via injected reader", () => {
const fakeReader = (path: string) => {
if (path === "vars.json") return '{"theme":"dark"}';
throw new Error("unexpected path");
};
expect(parseVariablesArg(undefined, "vars.json", fakeReader)).toEqual({
ok: true,
value: { theme: "dark" },
});
});

it("rejects when both flags are set", () => {
const err = expectErr(parseVariablesArg('{"a":1}', "vars.json"));
expect(err).toEqual({ kind: "conflict" });
});

it("rejects unparseable JSON with a source-aware kind", () => {
expect(expectErr(parseVariablesArg("{not json", undefined))).toMatchObject({
kind: "parse-error",
source: "inline",
});
expect(expectErr(parseVariablesArg(undefined, "x", () => "{not json"))).toMatchObject({
kind: "parse-error",
source: "file",
});
});

it("rejects non-object payloads (array, string, null, number)", () => {
for (const payload of ["[1,2]", '"hello"', "null", "42"]) {
expect(expectErr(parseVariablesArg(payload, undefined))).toEqual({ kind: "shape-error" });
}
});

it("surfaces filesystem errors from --variables-file", () => {
const err = expectErr<{
kind: "read-error";
path: string;
cause: string;
}>(
parseVariablesArg(undefined, "missing.json", () => {
throw new Error("ENOENT: no such file");
}),
);
expect(err.kind).toBe("read-error");
expect(err.path).toBe("missing.json");
expect(err.cause).toMatch(/ENOENT/);
});
});

describe("validateVariablesAgainstProject", () => {
let validateVariablesAgainstProject: typeof import("./render.js").validateVariablesAgainstProject;
let tmpDir: string;
let mkdtempSync: typeof import("node:fs").mkdtempSync;
let writeFileSync: typeof import("node:fs").writeFileSync;
let rmSync: typeof import("node:fs").rmSync;
let join: typeof import("node:path").join;
let tmpdir: typeof import("node:os").tmpdir;

beforeAll(async () => {
({ validateVariablesAgainstProject } = await import("./render.js"));
({ mkdtempSync, writeFileSync, rmSync } = await import("node:fs"));
({ join } = await import("node:path"));
({ tmpdir } = await import("node:os"));
});

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

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

function writeIndex(html: string): string {
const path = join(tmpDir, "index.html");
writeFileSync(path, html);
return path;
}

it("returns [] when the project has no data-composition-variables declarations", () => {
const indexPath = writeIndex(`<html><body><div data-composition-id="x"></div></body></html>`);
expect(validateVariablesAgainstProject(indexPath, { title: "Hello" })).toEqual([]);
});

it("returns [] when every value matches its declaration", () => {
const indexPath = writeIndex(
`<html data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"x"}]'><body><div data-composition-id="root"></div></body></html>`,
);
expect(validateVariablesAgainstProject(indexPath, { title: "Hello" })).toEqual([]);
});

it("flags undeclared keys", () => {
const indexPath = writeIndex(
`<html data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"x"}]'><body><div data-composition-id="root"></div></body></html>`,
);
expect(validateVariablesAgainstProject(indexPath, { title: "Hello", extra: 1 })).toEqual([
{ kind: "undeclared", variableId: "extra" },
]);
});

it("flags type mismatches", () => {
const indexPath = writeIndex(
`<html data-composition-variables='[{"id":"count","type":"number","label":"Count","default":0}]'><body><div data-composition-id="root"></div></body></html>`,
);
expect(validateVariablesAgainstProject(indexPath, { count: "three" })).toEqual([
{ kind: "type-mismatch", variableId: "count", expected: "number", actual: "string" },
]);
});

it("returns [] when the index file cannot be read (lint owns that diagnostic)", () => {
expect(
validateVariablesAgainstProject(join(tmpDir, "missing.html"), { title: "Hello" }),
).toEqual([]);
});
});
// Variables-helper tests live in `../utils/variables.test.ts`.
Loading
Loading