diff --git a/docs/concepts/data-attributes.mdx b/docs/concepts/data-attributes.mdx index dc1c3fc1f..064b55aed 100644 --- a/docs/concepts/data-attributes.mdx +++ b/docs/concepts/data-attributes.mdx @@ -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 ''` overrides these defaults at render time. | ## Element Visibility diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index c2b601181..d25d02e81 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -559,9 +559,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 + + +

+ + + + ``` + + ```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. diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index 569ccf3ef..08ce30c4f 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -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>, @@ -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( + 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/); + }); }); diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 519dbfafb..46ac96260 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -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; +} + +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 | 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 }; +} + +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 | 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 diff --git a/packages/cli/src/utils/dockerRunArgs.test.ts b/packages/cli/src/utils/dockerRunArgs.test.ts index 3f9559bb7..5877985f1 100644 --- a/packages/cli/src/utils/dockerRunArgs.test.ts +++ b/packages/cli/src/utils/dockerRunArgs.test.ts @@ -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"); + }); }); diff --git a/packages/cli/src/utils/dockerRunArgs.ts b/packages/cli/src/utils/dockerRunArgs.ts index e7c6e0ee5..5e6303b52 100644 --- a/packages/cli/src/utils/dockerRunArgs.ts +++ b/packages/cli/src/utils/dockerRunArgs.ts @@ -29,6 +29,7 @@ export interface DockerRenderOptions { crf?: number; videoBitrate?: string; quiet: boolean; + variables?: Record; } 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)] + : []), ]; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 15dabecd1..9480b6614 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/runtime/entry.ts b/packages/core/src/runtime/entry.ts index 8b5201840..3f04e4920 100644 --- a/packages/core/src/runtime/entry.ts +++ b/packages/core/src/runtime/entry.ts @@ -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 { diff --git a/packages/core/src/runtime/getVariables.test.ts b/packages/core/src/runtime/getVariables.test.ts new file mode 100644 index 000000000..db3ecf668 --- /dev/null +++ b/packages/core/src/runtime/getVariables.test.ts @@ -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(); + expect(vars.title).toBe("Hello"); + expect(vars.missing).toBeUndefined(); + }); +}); diff --git a/packages/core/src/runtime/getVariables.ts b/packages/core/src/runtime/getVariables.ts new file mode 100644 index 000000000..5bae2f9d0 --- /dev/null +++ b/packages/core/src/runtime/getVariables.ts @@ -0,0 +1,54 @@ +/** + * Reads the resolved variables for the current composition. + * + * Resolves to declared defaults from `` + * merged with `window.__hfVariables` (set at render time by the engine when + * the user passes `hyperframes render --variables ''`). + * + * Returns `Partial` 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(); + */ +export function getVariables< + T extends Record = Record, +>(): Partial { + if (typeof document === "undefined") return {} as Partial; + + const declaredDefaults = readDeclaredDefaults(document.documentElement); + const overrides = readOverrides(); + + return { ...declaredDefaults, ...overrides } as Partial; +} + +function readDeclaredDefaults(root: Element | null): Record { + 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 = {}; + for (const entry of parsed) { + if (!entry || typeof entry !== "object") continue; + const e = entry as Record; + if (typeof e.id !== "string" || !("default" in e)) continue; + out[e.id] = e.default; + } + return out; +} + +function readOverrides(): Record { + 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; +} diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index ceb2ece0e..779891fb3 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -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 ''`. Read indirectly via + * `window.__hyperframes.getVariables()` (or the named `getVariables` + * export from `@hyperframes/core`), which merges these over the + * declared defaults from ``. + */ + __hfVariables?: Record; } } diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 39605a986..9e4cff429 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -155,6 +155,22 @@ export async function createCaptureSession( w.__name = (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 }; + 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)) { diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index efb7f94e3..963bcaa7d 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -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 ''` / + * `--variables-file `. Must be a JSON-serializable plain object. + */ + variables?: Record; } export interface CaptureVideoMetadataHint { diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index d97722e17..b3a60eeee 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -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 ``. + * + * Populated by the CLI from `--variables ''` / + * `--variables-file `. Must be a JSON-serializable plain object. + */ + variables?: Record; } 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