Skip to content

Commit 03b82e6

Browse files
authored
feat(core,cli,engine,producer): getVariables() helper + --variables render flag (PR 1/4) (#600)
## What Adds the parametrized-render primitive from [hf#592](#592) by introducing a `getVariables()` runtime helper plus a CLI `--variables` / `--variables-file` flag. Compositions declare variables once on the root `<html>` element (the existing `data-composition-variables` attribute, which already drives Studio editing UI), read them at runtime via `window.__hyperframes.getVariables()`, and CLI users override them at render time without touching the composition source. This is **PR 1 of a 4-PR stack**: 1. **PR 1 (this one)** — runtime helper + CLI flag + engine injection (top-level renders). 2. PR 2 — sub-comp per-instance scoping (carry the host's `data-variable-values` into the inlined sub-comp's `getVariables()`). 3. PR 3 — schema validation + lint rules (warn on undeclared variable IDs, optional `--strict-variables`). 4. PR 4 — skill / scaffold distribution (SKILL.md, AGENTS.md scaffolds, openai/plugins mirror). ## Why The existing `data-composition-variables` schema declares variable types and defaults but isn't readable from composition scripts and can't be overridden at render time. To produce N variations of a composition today, an agent has to fork the composition or edit the source HTML before each render. `--variables` collapses that into one render call per variation, matching Editframe's `--data` UX without copying their `getRenderData` framing — `getVariables()` is named for the codebase's existing "variables" terminology and works equally in dev preview and at render time. ## How - **Runtime helper** (`packages/core/src/runtime/getVariables.ts`): reads `data-composition-variables` from `document.documentElement`, extracts `{id: default}` defaults, merges `window.__hfVariables` (override) on top, returns `Partial<T>`. Same code path in dev preview (no override) and at render (with override). Generic parameter for typed editor ergonomics. Exposed both as a named export from `@hyperframes/core` and on `window.__hyperframes.getVariables` for vanilla compositions. - **CLI flag** (`packages/cli/src/commands/render.ts`): `--variables '<json>'` and `--variables-file <path>`. `parseVariablesArg` is split out as a pure function (returns a discriminated `{ ok: true } | { ok: false }` union) so all validation paths are unit-testable; the side-effecting `resolveVariablesArg` wraps it with `errorBox` + `process.exit`. Mutually exclusive with `--variables-file`; fail-fast on conflicts, missing file, unparseable JSON, or non-object payloads (string, number, array, null). - **Engine injection** (`packages/engine/src/services/frameCapture.ts`): added an `evaluateOnNewDocument` step right after the `__name` polyfill that sets `window.__hfVariables` to the parsed JSON before any page script runs. Skipped when payload is empty so we don't add pointless init scripts. Plumbed through `CaptureOptions.variables` and `RenderConfig.variables`. Docker mode forwards the flag to the in-container CLI via `dockerRunArgs`. - **Why a separate `__hfVariables` global** instead of writing into `__hyperframes.getVariables()` directly: the helper is an IIFE that has to be defined before composition scripts execute, but the *override* needs to land before *that*. `evaluateOnNewDocument` is the only reliable hook that runs before the runtime IIFE evaluates. Storing the raw value on `__hfVariables` and merging in the helper keeps both paths order-independent. ## Test plan - [x] Unit tests added/updated - 9 jsdom tests for `getVariables()` covering empty state, declared defaults only, override merge, override-wins, declared-only, invalid JSON, non-array payloads, non-object overrides, typed generic. - 7 tests for `parseVariablesArg` covering all validation paths. - 2 integration tests for `renderLocal` confirming `variables` reach `createRenderJob`. - 3 new `dockerRunArgs` assertions for `--variables` passthrough (set / not-set / empty-object). - All existing tests green: core 611, cli 208, engine 519. - [x] Manual testing performed - `npx tsx packages/cli/src/cli.ts render --help` shows both flags + the two new examples. - [x] Documentation updated - `docs/packages/cli.mdx` — added flags to the table and a "Parametrized renders" section with a worked example. - `docs/concepts/data-attributes.mdx` — added `data-composition-variables` row. ## Backwards compatibility Fully backwards compatible. Compositions without `data-composition-variables` work unchanged; `getVariables()` returns `{}` and the engine skips the injection step. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents aeae676 + 8c8dd6a commit 03b82e6

14 files changed

Lines changed: 526 additions & 3 deletions

File tree

docs/concepts/data-attributes.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Hyperframes uses HTML data attributes to control timing, media playback, and [co
3030
| `data-height` | `"1080"` | Composition height in pixels |
3131
| `data-composition-src` | `"./intro.html"` | Path to external [composition](/concepts/compositions) HTML file |
3232
| `data-variable-values` | `'{"title":"Hello"}'` | JSON object of values passed to a nested composition. HyperFrames carries these values through, but your composition script must read and apply them manually. |
33+
| `data-composition-variables` | `'[{"id":"title","type":"string","label":"Title","default":"Hello"}]'` | JSON array of declared variables (`id`, `type`, `label`, `default`). Drives Studio editing UI and provides defaults read by `window.__hyperframes.getVariables()`. The CLI flag `hyperframes render --variables '<json>'` overrides these defaults at render time. |
3334

3435
## Element Visibility
3536

docs/packages/cli.mdx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,9 +606,49 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
606606
| `--browser-gpu` / `--no-browser-gpu` || on locally, off in Docker | Use or opt out of host GPU acceleration for local Chrome/WebGL capture |
607607
| `--docker` || off | Use Docker for [deterministic rendering](/concepts/determinism) |
608608
| `--quiet` || off | Suppress verbose output |
609+
| `--variables` | JSON object || Variable overrides merged over `data-composition-variables` defaults. Read via `window.__hyperframes.getVariables()` |
610+
| `--variables-file` | path || Path to a JSON file with variable overrides (alternative to `--variables`) |
609611

610612
CRF and target bitrate default to the `--quality` preset. Use `--crf` or `--video-bitrate` for fine-grained overrides; `RenderConfig.crf` and `RenderConfig.videoBitrate` accept the same overrides programmatically.
611613

614+
#### Parametrized renders
615+
616+
Render the same composition with different content by declaring variables on the composition root and overriding them at render time:
617+
618+
```html index.html
619+
<html
620+
data-composition-id="root"
621+
data-composition-variables='[
622+
{"id":"title","label":"Title","type":"string","default":"Hello"},
623+
{"id":"theme","label":"Theme","type":"enum","options":[
624+
{"value":"light","label":"Light"},
625+
{"value":"dark","label":"Dark"}
626+
],"default":"light"}
627+
]'>
628+
<body>
629+
<h1 id="hero" class="clip" data-start="0" data-duration="3"></h1>
630+
<script>
631+
const vars = window.__hyperframes.getVariables();
632+
document.getElementById("hero").textContent = vars.title;
633+
document.body.dataset.theme = vars.theme;
634+
</script>
635+
</body>
636+
</html>
637+
```
638+
639+
```bash
640+
# Render with declared defaults (preview also uses the defaults)
641+
npx hyperframes render --output default.mp4
642+
643+
# Override at render time — missing keys fall through to declared defaults
644+
npx hyperframes render --variables '{"title":"Q4 Report","theme":"dark"}' --output q4.mp4
645+
646+
# Pass values from a JSON file
647+
npx hyperframes render --variables-file ./vars.json --output out.mp4
648+
```
649+
650+
`getVariables()` returns the merged result of declared defaults and any `--variables` overrides, so the same composition runs unchanged in dev preview and in production renders.
651+
612652
#### WebM with Transparency
613653

614654
Use `--format webm` to render compositions with a transparent background. This produces VP9 video with alpha channel in a WebM container — the standard format for overlayable video.

packages/cli/src/commands/render.test.ts

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
22

33
const producerState = vi.hoisted(() => ({
44
createdJobs: [] as Array<Record<string, unknown>>,
@@ -99,4 +99,109 @@ describe("renderLocal browser GPU config", () => {
9999
expect(resolveBrowserGpuForCli(false, false, "hardware")).toBe(false);
100100
expect(resolveBrowserGpuForCli(true, undefined, "hardware")).toBe(false);
101101
});
102+
103+
it("forwards parsed --variables payload to createRenderJob", async () => {
104+
const { renderLocal } = await import("./render.js");
105+
await renderLocal("/tmp/project", "/tmp/out.mp4", {
106+
fps: 30,
107+
quality: "standard",
108+
format: "mp4",
109+
gpu: false,
110+
browserGpu: false,
111+
hdrMode: "auto",
112+
quiet: true,
113+
variables: { title: "Hello", count: 3 },
114+
});
115+
116+
expect(producerState.createdJobs[0]?.variables).toEqual({ title: "Hello", count: 3 });
117+
});
118+
119+
it("omits variables from createRenderJob when not provided", async () => {
120+
const { renderLocal } = await import("./render.js");
121+
await renderLocal("/tmp/project", "/tmp/out.mp4", {
122+
fps: 30,
123+
quality: "standard",
124+
format: "mp4",
125+
gpu: false,
126+
browserGpu: false,
127+
hdrMode: "auto",
128+
quiet: true,
129+
});
130+
131+
expect(producerState.createdJobs[0]?.variables).toBeUndefined();
132+
});
133+
});
134+
135+
describe("parseVariablesArg", () => {
136+
let parseVariablesArg: typeof import("./render.js").parseVariablesArg;
137+
138+
beforeAll(async () => {
139+
({ parseVariablesArg } = await import("./render.js"));
140+
});
141+
142+
function expectErr<T extends { kind: string }>(
143+
result: import("./render.js").VariablesParseResult,
144+
): T {
145+
if (result.ok) throw new Error(`expected error, got ${JSON.stringify(result.value)}`);
146+
return result.error as T;
147+
}
148+
149+
it("returns undefined when neither flag is set", () => {
150+
expect(parseVariablesArg(undefined, undefined)).toEqual({ ok: true, value: undefined });
151+
});
152+
153+
it("parses inline JSON object", () => {
154+
expect(parseVariablesArg('{"title":"Hello","n":3}', undefined)).toEqual({
155+
ok: true,
156+
value: { title: "Hello", n: 3 },
157+
});
158+
});
159+
160+
it("parses file JSON via injected reader", () => {
161+
const fakeReader = (path: string) => {
162+
if (path === "vars.json") return '{"theme":"dark"}';
163+
throw new Error("unexpected path");
164+
};
165+
expect(parseVariablesArg(undefined, "vars.json", fakeReader)).toEqual({
166+
ok: true,
167+
value: { theme: "dark" },
168+
});
169+
});
170+
171+
it("rejects when both flags are set", () => {
172+
const err = expectErr(parseVariablesArg('{"a":1}', "vars.json"));
173+
expect(err).toEqual({ kind: "conflict" });
174+
});
175+
176+
it("rejects unparseable JSON with a source-aware kind", () => {
177+
expect(expectErr(parseVariablesArg("{not json", undefined))).toMatchObject({
178+
kind: "parse-error",
179+
source: "inline",
180+
});
181+
expect(expectErr(parseVariablesArg(undefined, "x", () => "{not json"))).toMatchObject({
182+
kind: "parse-error",
183+
source: "file",
184+
});
185+
});
186+
187+
it("rejects non-object payloads (array, string, null, number)", () => {
188+
for (const payload of ["[1,2]", '"hello"', "null", "42"]) {
189+
expect(expectErr(parseVariablesArg(payload, undefined))).toEqual({ kind: "shape-error" });
190+
}
191+
});
192+
193+
it("surfaces filesystem errors from --variables-file", () => {
194+
const err = expectErr<{
195+
kind: "read-error";
196+
path: string;
197+
cause: string;
198+
}>(
199+
parseVariablesArg(undefined, "missing.json", () => {
200+
throw new Error("ENOENT: no such file");
201+
}),
202+
);
203+
expect(err.kind).toBe("read-error");
204+
expect(err.path).toBe("missing.json");
205+
expect(err.cause).toMatch(/ENOENT/);
206+
});
102207
});

packages/cli/src/commands/render.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ export const examples: Example[] = [
1111
["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"],
1212
["Opt out of browser GPU render", "hyperframes render --no-browser-gpu --output cpu.mp4"],
1313
["HDR output (auto-detected)", "hyperframes render --output hdr-output.mp4"],
14+
[
15+
"Override composition variables (parametrized render)",
16+
'hyperframes render --variables \'{"title":"Q4 Report","theme":"dark"}\' --output q4.mp4',
17+
],
18+
[
19+
"Variables from a JSON file",
20+
"hyperframes render --variables-file ./vars.json --output out.mp4",
21+
],
1422
];
1523
import { cpus, freemem, tmpdir } from "node:os";
1624
import { resolve, dirname, join, basename } from "node:path";
@@ -124,6 +132,16 @@ export default defineCommand({
124132
type: "string",
125133
description: "Max concurrent renders when using the producer server (1-10). Default: 2.",
126134
},
135+
variables: {
136+
type: "string",
137+
description:
138+
'JSON object of variable values, merged over the composition\'s data-composition-variables defaults. Example: --variables \'{"title":"Hello"}\'. Read inside the composition via window.__hyperframes.getVariables().',
139+
},
140+
"variables-file": {
141+
type: "string",
142+
description:
143+
"Path to a JSON file with variable values (alternative to --variables). The file must contain a single JSON object.",
144+
},
127145
},
128146
async run({ args }) {
129147
// ── Resolve project ────────────────────────────────────────────────────
@@ -328,6 +346,9 @@ export default defineCommand({
328346
process.exit(1);
329347
}
330348

349+
// ── Resolve --variables / --variables-file ──────────────────────────
350+
const variables = resolveVariablesArg(args.variables, args["variables-file"]);
351+
331352
// ── Render ────────────────────────────────────────────────────────────
332353
if (useDocker) {
333354
await renderDocker(project.dir, outputPath, {
@@ -341,6 +362,7 @@ export default defineCommand({
341362
crf,
342363
videoBitrate,
343364
quiet,
365+
variables,
344366
});
345367
} else {
346368
await renderLocal(project.dir, outputPath, {
@@ -355,6 +377,7 @@ export default defineCommand({
355377
videoBitrate,
356378
quiet,
357379
browserPath,
380+
variables,
358381
});
359382
}
360383
},
@@ -372,6 +395,118 @@ interface RenderOptions {
372395
videoBitrate?: string;
373396
quiet: boolean;
374397
browserPath?: string;
398+
variables?: Record<string, unknown>;
399+
}
400+
401+
export type VariablesParseError =
402+
| { kind: "conflict" }
403+
| { kind: "read-error"; path: string; cause: string }
404+
| { kind: "parse-error"; source: "inline" | "file"; cause: string }
405+
| { kind: "shape-error" };
406+
407+
export type VariablesParseResult =
408+
| { ok: true; value: Record<string, unknown> | undefined }
409+
| { ok: false; error: VariablesParseError };
410+
411+
/**
412+
* Pure parser for `--variables` / `--variables-file` flag pair. Splits out
413+
* from `resolveVariablesArg` so validation paths are unit-testable without
414+
* triggering `process.exit`. Reports failures via a structured `kind`
415+
* discriminant so the side-effecting wrapper owns all UI strings.
416+
*/
417+
export function parseVariablesArg(
418+
inline: string | undefined,
419+
filePath: string | undefined,
420+
readFile: (path: string) => string = (p) => readFileSync(resolve(p), "utf8"),
421+
): VariablesParseResult {
422+
if (inline != null && filePath != null) {
423+
return { ok: false, error: { kind: "conflict" } };
424+
}
425+
let raw: string | undefined;
426+
let source: "inline" | "file" | undefined;
427+
if (inline != null) {
428+
raw = inline;
429+
source = "inline";
430+
} else if (filePath != null) {
431+
try {
432+
raw = readFile(filePath);
433+
source = "file";
434+
} catch (error: unknown) {
435+
return {
436+
ok: false,
437+
error: {
438+
kind: "read-error",
439+
path: filePath,
440+
cause: error instanceof Error ? error.message : String(error),
441+
},
442+
};
443+
}
444+
}
445+
if (raw == null) return { ok: true, value: undefined };
446+
447+
let parsed: unknown;
448+
try {
449+
parsed = JSON.parse(raw);
450+
} catch (error: unknown) {
451+
return {
452+
ok: false,
453+
error: {
454+
kind: "parse-error",
455+
source: source ?? "inline",
456+
cause: error instanceof Error ? error.message : String(error),
457+
},
458+
};
459+
}
460+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
461+
return { ok: false, error: { kind: "shape-error" } };
462+
}
463+
return { ok: true, value: parsed as Record<string, unknown> };
464+
}
465+
466+
function variablesErrorMessage(error: VariablesParseError): { title: string; message: string } {
467+
switch (error.kind) {
468+
case "conflict":
469+
return {
470+
title: "Conflicting variables flags",
471+
message: "Use either --variables or --variables-file, not both.",
472+
};
473+
case "read-error":
474+
return {
475+
title: "Could not read --variables-file",
476+
message: `${error.path}: ${error.cause}`,
477+
};
478+
case "parse-error":
479+
return {
480+
title:
481+
error.source === "file"
482+
? "Invalid JSON in --variables-file"
483+
: "Invalid JSON in --variables",
484+
message: error.cause,
485+
};
486+
case "shape-error":
487+
return {
488+
title: "Invalid variables payload",
489+
message: 'Variables must be a JSON object (e.g. {"title":"Hello"}).',
490+
};
491+
}
492+
}
493+
494+
/**
495+
* Resolve `--variables` / `--variables-file` into a plain object, or
496+
* `undefined` when neither flag is set. Exits the process with a friendly
497+
* error box on any validation failure.
498+
*/
499+
export function resolveVariablesArg(
500+
inline: string | undefined,
501+
filePath: string | undefined,
502+
): Record<string, unknown> | undefined {
503+
const result = parseVariablesArg(inline, filePath);
504+
if (!result.ok) {
505+
const { title, message } = variablesErrorMessage(result.error);
506+
errorBox(title, message);
507+
process.exit(1);
508+
}
509+
return result.value;
375510
}
376511

377512
export function resolveBrowserGpuForCli(
@@ -507,6 +642,7 @@ async function renderDocker(
507642
crf: options.crf,
508643
videoBitrate: options.videoBitrate,
509644
quiet: options.quiet,
645+
variables: options.variables,
510646
},
511647
});
512648

@@ -575,6 +711,7 @@ export async function renderLocal(
575711
hdrMode: options.hdrMode,
576712
crf: options.crf,
577713
videoBitrate: options.videoBitrate,
714+
variables: options.variables,
578715
});
579716

580717
const onProgress = options.quiet

packages/cli/src/utils/dockerRunArgs.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,4 +187,27 @@ describe("buildDockerRunArgs", () => {
187187
expect(args).toContain("10M");
188188
expect(args).not.toContain("--crf");
189189
});
190+
191+
it("forwards --variables JSON to the container when set", () => {
192+
const args = buildDockerRunArgs({
193+
...FIXED_INPUT,
194+
options: { ...BASE, variables: { title: "Hello", n: 3 } },
195+
});
196+
const idx = args.indexOf("--variables");
197+
expect(idx).toBeGreaterThan(-1);
198+
expect(args[idx + 1]).toBe('{"title":"Hello","n":3}');
199+
});
200+
201+
it("omits --variables when none provided", () => {
202+
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
203+
expect(args).not.toContain("--variables");
204+
});
205+
206+
it("omits --variables when payload is empty", () => {
207+
const args = buildDockerRunArgs({
208+
...FIXED_INPUT,
209+
options: { ...BASE, variables: {} },
210+
});
211+
expect(args).not.toContain("--variables");
212+
});
190213
});

0 commit comments

Comments
 (0)