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
+3
View File
@@ -166,6 +166,9 @@ export { createGSAPFrameAdapter } from "./adapters/gsap";
export { fitTextFontSize } from "./text/index.js";
export type { FitTextOptions, FitTextResult } from "./text/index.js";
// Runtime helpers (composition-side)
export { getVariables } from "./runtime/getVariables.js";
// Registry
export type {
ItemType,
+6 -2
View File
@@ -1,10 +1,12 @@
import { initSandboxRuntimeModular } from "./init";
import { fitTextFontSize } from "../text/fitTextFontSize";
import { getVariables } from "./getVariables";
type HyperframeWindow = Window & {
__hyperframeRuntimeBootstrapped?: boolean;
__hyperframes?: {
fitTextFontSize: typeof fitTextFontSize;
getVariables: typeof getVariables;
};
};
@@ -12,10 +14,12 @@ type HyperframeWindow = Window & {
// Ensure timeline registry exists at script evaluation time.
(window as HyperframeWindow).__timelines = (window as HyperframeWindow).__timelines || {};
// Expose text utilities immediately so composition scripts can use them
// before DOMContentLoaded (font sizing runs during script evaluation).
// Expose runtime helpers immediately so composition scripts can use them
// before DOMContentLoaded (font sizing runs during script evaluation, and
// getVariables is read by composition setup before the timeline is built).
(window as HyperframeWindow).__hyperframes = {
fitTextFontSize,
getVariables,
};
function bootstrapHyperframeRuntime(): void {
@@ -0,0 +1,106 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { getVariables } from "./getVariables";
const VARIABLES_ATTR = "data-composition-variables";
function setDeclared(json: string | null) {
if (json == null) {
document.documentElement.removeAttribute(VARIABLES_ATTR);
} else {
document.documentElement.setAttribute(VARIABLES_ATTR, json);
}
}
function setOverrides(value: unknown) {
(window as Window & { __hfVariables?: unknown }).__hfVariables = value;
}
describe("getVariables", () => {
beforeEach(() => {
setDeclared(null);
setOverrides(undefined);
});
afterEach(() => {
setDeclared(null);
setOverrides(undefined);
});
it("returns {} when nothing is declared and no overrides", () => {
expect(getVariables()).toEqual({});
});
it("returns declared defaults when no overrides", () => {
setDeclared(
JSON.stringify([
{ id: "title", type: "string", label: "Title", default: "Hello" },
{ id: "count", type: "number", label: "Count", default: 3 },
{ id: "active", type: "boolean", label: "Active", default: true },
]),
);
expect(getVariables()).toEqual({ title: "Hello", count: 3, active: true });
});
it("merges overrides over declared defaults (overrides win)", () => {
setDeclared(
JSON.stringify([
{ id: "title", type: "string", label: "Title", default: "Hello" },
{ id: "theme", type: "string", label: "Theme", default: "light" },
]),
);
setOverrides({ title: "Custom Title" });
expect(getVariables()).toEqual({ title: "Custom Title", theme: "light" });
});
it("includes override keys not declared in the schema", () => {
setDeclared(JSON.stringify([{ id: "title", type: "string", label: "Title", default: "x" }]));
setOverrides({ extra: 42 });
expect(getVariables()).toEqual({ title: "x", extra: 42 });
});
it("returns {} when the declared JSON is invalid", () => {
setDeclared("{not-json");
expect(getVariables()).toEqual({});
});
it("ignores declared entries without an id or default", () => {
setDeclared(
JSON.stringify([
{ id: "ok", type: "string", label: "Ok", default: "yes" },
{ type: "string", label: "no-id", default: "nope" },
{ id: "no-default", type: "string", label: "No default" },
"not-an-object",
null,
]),
);
expect(getVariables()).toEqual({ ok: "yes" });
});
it("ignores non-array declared payloads", () => {
setDeclared(JSON.stringify({ title: "Hello" }));
expect(getVariables()).toEqual({});
});
it("ignores non-object overrides (string, array, null)", () => {
setDeclared(JSON.stringify([{ id: "title", type: "string", label: "Title", default: "x" }]));
setOverrides("not-an-object");
expect(getVariables()).toEqual({ title: "x" });
setOverrides([1, 2, 3]);
expect(getVariables()).toEqual({ title: "x" });
setOverrides(null);
expect(getVariables()).toEqual({ title: "x" });
});
it("supports the typed generic for editor ergonomics", () => {
setDeclared(
JSON.stringify([{ id: "title", type: "string", label: "Title", default: "Hello" }]),
);
type Vars = { title: string; missing?: number };
const vars = getVariables<Vars>();
expect(vars.title).toBe("Hello");
expect(vars.missing).toBeUndefined();
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* Reads the resolved variables for the current composition.
*
* Resolves to declared defaults from `<html data-composition-variables="...">`
* merged with `window.__hfVariables` (set at render time by the engine when
* the user passes `hyperframes render --variables '<json>'`).
*
* Returns `Partial<T>` because not every declared variable is guaranteed to
* have a default, and not every key in `__hfVariables` is guaranteed to be
* declared. Callers are expected to destructure with their own fallbacks
* where strictness matters:
*
* const { title = "Untitled", theme = "light" } = getVariables<MyVars>();
*/
export function getVariables<
T extends Record<string, unknown> = Record<string, unknown>,
>(): Partial<T> {
if (typeof document === "undefined") return {} as Partial<T>;
const declaredDefaults = readDeclaredDefaults(document.documentElement);
const overrides = readOverrides();
return { ...declaredDefaults, ...overrides } as Partial<T>;
}
function readDeclaredDefaults(root: Element | null): Record<string, unknown> {
if (!root) return {};
const raw = root.getAttribute("data-composition-variables");
if (!raw) return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return {};
}
if (!Array.isArray(parsed)) return {};
const out: Record<string, unknown> = {};
for (const entry of parsed) {
if (!entry || typeof entry !== "object") continue;
const e = entry as Record<string, unknown>;
if (typeof e.id !== "string" || !("default" in e)) continue;
out[e.id] = e.default;
}
return out;
}
function readOverrides(): Record<string, unknown> {
if (typeof window === "undefined") return {};
const raw = (window as Window & { __hfVariables?: unknown }).__hfVariables;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
return raw as Record<string, unknown>;
}
+8
View File
@@ -78,6 +78,14 @@ declare global {
* window.__hfLottie.push(anim);
*/
__hfLottie?: unknown[];
/**
* Render-time variable overrides injected by the engine when the user
* passes `hyperframes render --variables '<json>'`. Read indirectly via
* `window.__hyperframes.getVariables()` (or the named `getVariables`
* export from `@hyperframes/core`), which merges these over the
* declared defaults from `<html data-composition-variables="...">`.
*/
__hfVariables?: Record<string, unknown>;
}
}