mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -30,6 +30,7 @@ Hyperframes uses HTML data attributes to control timing, media playback, and [co
|
||||
| `data-height` | `"1080"` | Composition height in pixels |
|
||||
| `data-composition-src` | `"./intro.html"` | Path to external [composition](/concepts/compositions) HTML file |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
## Element Visibility
|
||||
|
||||
|
||||
@@ -606,9 +606,49 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
|
||||
| `--browser-gpu` / `--no-browser-gpu` | — | on locally, off in Docker | Use or opt out of host GPU acceleration for local Chrome/WebGL capture |
|
||||
| `--docker` | — | off | Use Docker for [deterministic rendering](/concepts/determinism) |
|
||||
| `--quiet` | — | off | Suppress verbose output |
|
||||
| `--variables` | JSON object | — | Variable overrides merged over `data-composition-variables` defaults. Read via `window.__hyperframes.getVariables()` |
|
||||
| `--variables-file` | path | — | Path to a JSON file with variable overrides (alternative to `--variables`) |
|
||||
|
||||
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.
|
||||
|
||||
#### Parametrized renders
|
||||
|
||||
Render the same composition with different content by declaring variables on the composition root and overriding them at render time:
|
||||
|
||||
```html index.html
|
||||
<html
|
||||
data-composition-id="root"
|
||||
data-composition-variables='[
|
||||
{"id":"title","label":"Title","type":"string","default":"Hello"},
|
||||
{"id":"theme","label":"Theme","type":"enum","options":[
|
||||
{"value":"light","label":"Light"},
|
||||
{"value":"dark","label":"Dark"}
|
||||
],"default":"light"}
|
||||
]'>
|
||||
<body>
|
||||
<h1 id="hero" class="clip" data-start="0" data-duration="3"></h1>
|
||||
<script>
|
||||
const vars = window.__hyperframes.getVariables();
|
||||
document.getElementById("hero").textContent = vars.title;
|
||||
document.body.dataset.theme = vars.theme;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
```bash
|
||||
# Render with declared defaults (preview also uses the defaults)
|
||||
npx hyperframes render --output default.mp4
|
||||
|
||||
# Override at render time — missing keys fall through to declared defaults
|
||||
npx hyperframes render --variables '{"title":"Q4 Report","theme":"dark"}' --output q4.mp4
|
||||
|
||||
# Pass values from a JSON file
|
||||
npx hyperframes render --variables-file ./vars.json --output out.mp4
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
#### WebM with Transparency
|
||||
|
||||
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.
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -155,6 +155,22 @@ export async function createCaptureSession(
|
||||
w.__name = <T>(fn: T, _name: string): T => fn;
|
||||
}
|
||||
});
|
||||
// Inject render-time variable overrides before any page script runs, so the
|
||||
// runtime helper `getVariables()` returns the merged result on its first
|
||||
// call. Pass the JSON string and parse inside the page so we don't require
|
||||
// any JSON-incompatible value to round-trip through Puppeteer's serializer.
|
||||
if (options.variables && Object.keys(options.variables).length > 0) {
|
||||
const variablesJson = JSON.stringify(options.variables);
|
||||
await page.evaluateOnNewDocument((json: string) => {
|
||||
type WindowWithVariables = Window & { __hfVariables?: Record<string, unknown> };
|
||||
try {
|
||||
(window as WindowWithVariables).__hfVariables = JSON.parse(json);
|
||||
} catch {
|
||||
// The CLI validated the JSON before this point — a parse failure here
|
||||
// means the page swapped JSON.parse, which is the page's problem.
|
||||
}
|
||||
}, variablesJson);
|
||||
}
|
||||
const browserVersion = await browser.version();
|
||||
const expectedMajor = config?.expectedChromiumMajor;
|
||||
if (Number.isFinite(expectedMajor)) {
|
||||
|
||||
@@ -102,6 +102,17 @@ export interface CaptureOptions {
|
||||
* intrinsic media dimensions.
|
||||
*/
|
||||
skipReadinessVideoIds?: readonly string[];
|
||||
/**
|
||||
* Render-time variable overrides for the composition. The engine injects
|
||||
* these as `window.__hfVariables` via `evaluateOnNewDocument` before any
|
||||
* page script runs, so the runtime helper `getVariables()` returns the
|
||||
* merged result of declared defaults (`data-composition-variables`) and
|
||||
* these overrides on its first call.
|
||||
*
|
||||
* The CLI populates this from `--variables '<json>'` /
|
||||
* `--variables-file <path>`. Must be a JSON-serializable plain object.
|
||||
*/
|
||||
variables?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CaptureVideoMetadataHint {
|
||||
|
||||
@@ -268,6 +268,16 @@ export interface RenderConfig {
|
||||
* - `force-sdr`: skip probing entirely; always render SDR.
|
||||
*/
|
||||
hdrMode?: "auto" | "force-hdr" | "force-sdr";
|
||||
/**
|
||||
* Render-time variable overrides for the composition. Injected as
|
||||
* `window.__hfVariables` before any page script runs and consumed by the
|
||||
* runtime helper `getVariables()`, which merges them over the declared
|
||||
* defaults from `<html data-composition-variables="...">`.
|
||||
*
|
||||
* Populated by the CLI from `--variables '<json>'` /
|
||||
* `--variables-file <path>`. Must be a JSON-serializable plain object.
|
||||
*/
|
||||
variables?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RenderPerfSummary {
|
||||
@@ -2512,6 +2522,7 @@ export async function executeRenderJob(
|
||||
fps: job.config.fps,
|
||||
format: needsAlpha ? "png" : "jpeg",
|
||||
quality: needsAlpha ? undefined : job.config.quality === "draft" ? 80 : 95,
|
||||
variables: job.config.variables,
|
||||
};
|
||||
|
||||
// Capture sessions do not need native browser metadata for videos whose
|
||||
|
||||
Reference in New Issue
Block a user