feat(core,cli,engine,producer): getVariables() helper + --variables render flag (PR 1/4) (#600)

## What

Adds the parametrized-render primitive from [hf#592](https://github.com/heygen-com/hyperframes/issues/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)
This commit is contained in:
James Russo
2026-05-04 12:41:18 -07:00
committed by GitHub
14 changed files with 526 additions and 3 deletions
+106 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const producerState = vi.hoisted(() => ({
createdJobs: [] as Array<Record<string, unknown>>,
@@ -99,4 +99,109 @@ describe("renderLocal browser GPU config", () => {
expect(resolveBrowserGpuForCli(false, false, "hardware")).toBe(false);
expect(resolveBrowserGpuForCli(true, undefined, "hardware")).toBe(false);
});
it("forwards parsed --variables payload to createRenderJob", async () => {
const { renderLocal } = await import("./render.js");
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: 30,
quality: "standard",
format: "mp4",
gpu: false,
browserGpu: false,
hdrMode: "auto",
quiet: true,
variables: { title: "Hello", count: 3 },
});
expect(producerState.createdJobs[0]?.variables).toEqual({ title: "Hello", count: 3 });
});
it("omits variables from createRenderJob when not provided", async () => {
const { renderLocal } = await import("./render.js");
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: 30,
quality: "standard",
format: "mp4",
gpu: false,
browserGpu: false,
hdrMode: "auto",
quiet: true,
});
expect(producerState.createdJobs[0]?.variables).toBeUndefined();
});
});
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/);
});
});
+137
View File
@@ -11,6 +11,14 @@ export const examples: Example[] = [
["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"],
["Opt out of browser GPU render", "hyperframes render --no-browser-gpu --output cpu.mp4"],
["HDR output (auto-detected)", "hyperframes render --output hdr-output.mp4"],
[
"Override composition variables (parametrized render)",
'hyperframes render --variables \'{"title":"Q4 Report","theme":"dark"}\' --output q4.mp4',
],
[
"Variables from a JSON file",
"hyperframes render --variables-file ./vars.json --output out.mp4",
],
];
import { cpus, freemem, tmpdir } from "node:os";
import { resolve, dirname, join, basename } from "node:path";
@@ -124,6 +132,16 @@ export default defineCommand({
type: "string",
description: "Max concurrent renders when using the producer server (1-10). Default: 2.",
},
variables: {
type: "string",
description:
'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().',
},
"variables-file": {
type: "string",
description:
"Path to a JSON file with variable values (alternative to --variables). The file must contain a single JSON object.",
},
},
async run({ args }) {
// ── Resolve project ────────────────────────────────────────────────────
@@ -328,6 +346,9 @@ export default defineCommand({
process.exit(1);
}
// ── Resolve --variables / --variables-file ──────────────────────────
const variables = resolveVariablesArg(args.variables, args["variables-file"]);
// ── Render ────────────────────────────────────────────────────────────
if (useDocker) {
await renderDocker(project.dir, outputPath, {
@@ -341,6 +362,7 @@ export default defineCommand({
crf,
videoBitrate,
quiet,
variables,
});
} else {
await renderLocal(project.dir, outputPath, {
@@ -355,6 +377,7 @@ export default defineCommand({
videoBitrate,
quiet,
browserPath,
variables,
});
}
},
@@ -372,6 +395,118 @@ interface RenderOptions {
videoBitrate?: string;
quiet: boolean;
browserPath?: string;
variables?: Record<string, unknown>;
}
export type VariablesParseError =
| { kind: "conflict" }
| { kind: "read-error"; path: string; cause: string }
| { kind: "parse-error"; source: "inline" | "file"; cause: string }
| { kind: "shape-error" };
export type VariablesParseResult =
| { ok: true; value: Record<string, unknown> | undefined }
| { ok: false; error: VariablesParseError };
/**
* Pure parser for `--variables` / `--variables-file` flag pair. Splits out
* from `resolveVariablesArg` so validation paths are unit-testable without
* triggering `process.exit`. Reports failures via a structured `kind`
* discriminant so the side-effecting wrapper owns all UI strings.
*/
export function parseVariablesArg(
inline: string | undefined,
filePath: string | undefined,
readFile: (path: string) => string = (p) => readFileSync(resolve(p), "utf8"),
): VariablesParseResult {
if (inline != null && filePath != null) {
return { ok: false, error: { kind: "conflict" } };
}
let raw: string | undefined;
let source: "inline" | "file" | undefined;
if (inline != null) {
raw = inline;
source = "inline";
} else if (filePath != null) {
try {
raw = readFile(filePath);
source = "file";
} catch (error: unknown) {
return {
ok: false,
error: {
kind: "read-error",
path: filePath,
cause: error instanceof Error ? error.message : String(error),
},
};
}
}
if (raw == null) return { ok: true, value: undefined };
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error: unknown) {
return {
ok: false,
error: {
kind: "parse-error",
source: source ?? "inline",
cause: error instanceof Error ? error.message : String(error),
},
};
}
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, error: { kind: "shape-error" } };
}
return { ok: true, value: parsed as Record<string, unknown> };
}
function variablesErrorMessage(error: VariablesParseError): { title: string; message: string } {
switch (error.kind) {
case "conflict":
return {
title: "Conflicting variables flags",
message: "Use either --variables or --variables-file, not both.",
};
case "read-error":
return {
title: "Could not read --variables-file",
message: `${error.path}: ${error.cause}`,
};
case "parse-error":
return {
title:
error.source === "file"
? "Invalid JSON in --variables-file"
: "Invalid JSON in --variables",
message: error.cause,
};
case "shape-error":
return {
title: "Invalid variables payload",
message: 'Variables must be a JSON object (e.g. {"title":"Hello"}).',
};
}
}
/**
* Resolve `--variables` / `--variables-file` into a plain object, or
* `undefined` when neither flag is set. Exits the process with a friendly
* error box on any validation failure.
*/
export function resolveVariablesArg(
inline: string | undefined,
filePath: string | undefined,
): Record<string, unknown> | undefined {
const result = parseVariablesArg(inline, filePath);
if (!result.ok) {
const { title, message } = variablesErrorMessage(result.error);
errorBox(title, message);
process.exit(1);
}
return result.value;
}
export function resolveBrowserGpuForCli(
@@ -507,6 +642,7 @@ async function renderDocker(
crf: options.crf,
videoBitrate: options.videoBitrate,
quiet: options.quiet,
variables: options.variables,
},
});
@@ -575,6 +711,7 @@ export async function renderLocal(
hdrMode: options.hdrMode,
crf: options.crf,
videoBitrate: options.videoBitrate,
variables: options.variables,
});
const onProgress = options.quiet
@@ -187,4 +187,27 @@ describe("buildDockerRunArgs", () => {
expect(args).toContain("10M");
expect(args).not.toContain("--crf");
});
it("forwards --variables JSON to the container when set", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, variables: { title: "Hello", n: 3 } },
});
const idx = args.indexOf("--variables");
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe('{"title":"Hello","n":3}');
});
it("omits --variables when none provided", () => {
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(args).not.toContain("--variables");
});
it("omits --variables when payload is empty", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, variables: {} },
});
expect(args).not.toContain("--variables");
});
});
+4
View File
@@ -29,6 +29,7 @@ export interface DockerRenderOptions {
crf?: number;
videoBitrate?: string;
quiet: boolean;
variables?: Record<string, unknown>;
}
export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
@@ -63,5 +64,8 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
...(options.browserGpu ? [] : ["--no-browser-gpu"]),
...(options.hdrMode === "force-hdr" ? ["--hdr"] : []),
...(options.hdrMode === "force-sdr" ? ["--sdr"] : []),
...(options.variables && Object.keys(options.variables).length > 0
? ["--variables", JSON.stringify(options.variables)]
: []),
];
}