Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/concepts/data-attributes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Hyperframes uses HTML data attributes to control timing, media playback, and [co
| `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. Inside the sub-composition, read them via `window.__hyperframes.getVariables()` — the runtime layers these over the sub-comp's own `data-composition-variables` defaults and exposes the merged result on a per-instance basis (the same source can be embedded multiple times with different values). |
| `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 top-level render time; host elements override them per-instance via `data-variable-values`. |
| `data-var-src` | `"heroImage"` | Binds the element's `src` to a declared variable — the runtime substitutes the value (URL string or image `{url}`) in preview and render; the authored `src` stays as the fallback. |
| `data-var-text` | `"title"` | Binds the element's own text to a scalar variable. Element children (nested clips, animated spans) are preserved. Scalar variables are also applied as `--{id}` CSS custom properties on the composition root, so `color: var(--accent)` responds to overrides. |

## Element Visibility

Expand Down
25 changes: 25 additions & 0 deletions docs/concepts/variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,31 @@ Inside any composition script, call `window.__hyperframes.getVariables()` to get

`__hyperframes.getVariables()` is a shorthand for `window.__hyperframes.getVariables()` and works in both top-level and sub-composition scripts. The runtime automatically scopes sub-compositions so each instance sees its own resolved values.

## Declarative Bindings (No Script Required)

For the common cases — replaceable media, dynamic text, and CSS-driven styling — you don't need a script at all. The runtime resolves these bindings once at load, identically in preview and render:

- **`data-var-src="id"`** — sets the element's `src` from the variable value (a URL string, or an image value's `{url}`). The authored `src` stays as the fallback when the variable resolves to nothing:

```html
<img class="clip" data-start="0" data-duration="5"
data-var-src="heroImage" src="fallback.jpg" />
```

- **`data-var-text="id"`** — sets the element's text content from a scalar variable:

```html
<h1 class="clip" data-start="0" data-duration="5" data-var-text="title">Fallback title</h1>
```

- **CSS custom properties** — every scalar variable is applied as `--{id}` on its composition root (font values apply their family name), so plain CSS bindings respond to render/preview overrides:

```css
.card-title { color: var(--accent); font-family: var(--brandFont), sans-serif; }
```

Bindings resolve against the element's owning composition, so sub-composition instances see their own per-instance values. The Studio Variables panel counts these bindings as usage. Use `getVariables()` in a script only when you need logic beyond direct substitution (loops, conditionals, derived values).

## Per-instance Overrides (Sub-compositions)

When embedding a composition inside another, use `data-variable-values` on the host element to pass a JSON object of override values for that particular instance:
Expand Down
6 changes: 4 additions & 2 deletions docs/reference/html-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ Common sizes:
| `data-volume` | audio, video | No | Volume level from `0` to `1`. Default: `1`. |
| `data-composition-id` | div | On compositions | Unique composition ID. Must match the key used in `window.__timelines`. |
| `data-composition-src` | div | No | Path to external composition HTML file (for [nested compositions](#composition-clips)). |
| `data-variable-values` | div | No | JSON object of values passed to a nested composition. The framework carries the values through, but your composition script must read and apply them manually. |
| `data-variable-values` | div | No | JSON object of values passed to a nested composition. Read via `getVariables()` in scripts, or consumed automatically by declarative bindings. |
| `data-var-src` | img, video, audio | No | Binds the element's `src` to a declared variable id — the runtime substitutes the value (URL string or image `{url}`); the authored `src` is the fallback. |
| `data-var-text` | any | No | Binds the element's own text to a scalar variable id. Element children are preserved. |
| `data-width` | div | On compositions | Composition width in pixels. |
| `data-height` | div | On compositions | Composition height in pixels. |

Expand Down Expand Up @@ -152,7 +154,7 @@ Common sizes:
- Each nested composition has its own `window.__timelines` entry, registered by its own `<script>` block
- The framework automatically nests sub-timelines — do not manually add them to the parent timeline
- Any composition can be nested inside any other — there is no special "root" type
- Per-instance values can be passed with `data-variable-values`, but the nested composition must read and apply those values itself
- Per-instance values can be passed with `data-variable-values`; sub-composition scripts read them via `getVariables()`, and `data-var-*` bindings / `var(--id)` CSS resolve them automatically

For more on how compositions work, see [Compositions](/concepts/compositions).
</Accordion>
Expand Down
123 changes: 123 additions & 0 deletions packages/core/src/runtime/applyVariableBindings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { applyVariableBindings } from "./applyVariableBindings";
import { getVariables } from "./getVariables";

type TestWindow = Window & {
__hfVariables?: unknown;
__hfVariablesByComp?: Record<string, Record<string, unknown>>;
__hyperframes?: { getVariables?: () => Record<string, unknown> };
};

const win = window as TestWindow;

beforeEach(() => {
win.__hyperframes = { getVariables };
});

afterEach(() => {
delete win.__hfVariables;
delete win.__hfVariablesByComp;
delete win.__hyperframes;
document.documentElement.removeAttribute("data-composition-variables");
document.body.innerHTML = "";
});

function setDeclared(decls: unknown[]): void {
document.documentElement.setAttribute("data-composition-variables", JSON.stringify(decls));
}

describe("applyVariableBindings", () => {
it("sets src from a string variable via data-var-src", () => {
setDeclared([{ id: "hero", type: "image", label: "Hero", default: "default.jpg" }]);
document.body.innerHTML = `
<div data-hf-root data-composition-id="c1">
<img id="img" data-var-src="hero" src="fallback.jpg" />
</div>`;
applyVariableBindings(document);
expect(document.getElementById("img")?.getAttribute("src")).toBe("default.jpg");
});

it("render-time overrides win, and {url} image values resolve", () => {
setDeclared([{ id: "hero", type: "image", label: "Hero", default: "default.jpg" }]);
win.__hfVariables = { hero: { url: "https://cdn/override.png" } };
document.body.innerHTML = `
<div data-hf-root><video data-var-src="hero" src="fallback.mp4"></video></div>`;
applyVariableBindings(document);
expect(document.querySelector("video")?.getAttribute("src")).toBe("https://cdn/override.png");
});

it("keeps the authored src when the variable resolves to nothing", () => {
document.body.innerHTML = `<div data-hf-root><img data-var-src="ghost" src="keep.jpg" /></div>`;
applyVariableBindings(document);
expect(document.querySelector("img")?.getAttribute("src")).toBe("keep.jpg");
});

it("sets text content from a scalar via data-var-text", () => {
setDeclared([{ id: "title", type: "string", label: "Title", default: "Hello" }]);
win.__hfVariables = { title: "Overridden" };
document.body.innerHTML = `<div data-hf-root><h1 data-var-text="title">Authored</h1></div>`;
applyVariableBindings(document);
expect(document.querySelector("h1")?.textContent).toBe("Overridden");
});

it("applies scalar variables as --{id} custom props on the root", () => {
setDeclared([
{ id: "accent", type: "color", label: "Accent", default: "#00C3FF" },
{ id: "count", type: "number", label: "Count", default: 3 },
]);
win.__hfVariables = { accent: "#ff0000" };
document.body.innerHTML = `<div id="root" data-hf-root></div>`;
applyVariableBindings(document);
const root = document.getElementById("root");
expect(root?.style.getPropertyValue("--accent")).toBe("#ff0000");
expect(root?.style.getPropertyValue("--count")).toBe("3");
});

it("applies a font value's family name, and skips other objects", () => {
win.__hfVariables = {
brandFont: { name: "Inter", source: "https://fonts" },
img: { url: "x" },
};
document.body.innerHTML = `<div id="root" data-hf-root></div>`;
applyVariableBindings(document);
const root = document.getElementById("root");
expect(root?.style.getPropertyValue("--brandFont")).toBe("Inter");
expect(root?.style.getPropertyValue("--img")).toBe("");
});

it("preserves element children when binding text on a container", () => {
win.__hfVariables = { title: "Replaced" };
document.body.innerHTML = `
<div data-hf-root>
<h1 data-var-text="title">Hello <em id="kid" class="clip">world</em></h1>
</div>`;
applyVariableBindings(document);
const h1 = document.querySelector("h1");
expect(document.getElementById("kid")?.textContent).toBe("world");
expect(h1?.childNodes[0]?.nodeValue).toBe("Replaced");
});

it("is idempotent across re-application (loader re-apply path)", () => {
win.__hfVariables = { title: "Once" };
document.body.innerHTML = `<div data-hf-root><h1 data-var-text="title">t</h1></div>`;
applyVariableBindings(document);
applyVariableBindings(document);
expect(document.querySelector("h1")?.textContent).toBe("Once");
});

it("resolves sub-composition elements against their scoped values", () => {
win.__hfVariablesByComp = { sub: { label: "Scoped" } };
win.__hfVariables = { label: "TopLevel" };
document.body.innerHTML = `
<div data-hf-root data-composition-id="main">
<p id="top" data-var-text="label">t</p>
<div data-composition-id="sub"><p id="inner" data-var-text="label">s</p></div>
</div>`;
applyVariableBindings(document);
expect(document.getElementById("inner")?.textContent).toBe("Scoped");
expect(document.getElementById("top")?.textContent).toBe("TopLevel");
});
});
141 changes: 141 additions & 0 deletions packages/core/src/runtime/applyVariableBindings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Declarative variable bindings — the no-script consumption channel for
* composition variables (values are fixed for the page's lifetime, so this is
* seek-safe and deterministic):
*
* - `data-var-src="id"` — sets the element's `src` from the variable value
* (a URL string or an image value `{url}`). The authored src stays as the
* fallback when the variable resolves to nothing.
* - `data-var-text="id"` — sets the element's OWN text from a scalar variable
* value. Elements with element children keep them: only the direct text
* node is replaced, mirroring the SDK's setOwnText semantics — a text
* binding must never delete nested clips or animation targets.
* - Every scalar variable (and a font value's family name) is applied as a
* `--{id}` CSS custom property on its composition root, so CSS bindings
* like `color: var(--accent)` respond to render/preview overrides instead
* of only the persisted default.
*
* Values resolve against the element's owning composition — the same scope
* chain the color-grading runtime uses: `__hfVariablesByComp[compId]` for
* inlined sub-compositions, then the top-level merged `getVariables()`.
*
* Applied at init AND re-applied after the composition loader inlines
* external / template sub-compositions (their DOM and per-instance scoped
* values don't exist at init). Idempotent: re-applying writes the same
* values.
*/

import { readVariablesForElement } from "./variableScope";
import { isScalarVariableValue as isScalar } from "@hyperframes/parsers/composition";

function resolveUrl(value: unknown): string | null {
if (typeof value === "string" && value.length > 0) return value;
if (value !== null && typeof value === "object") {
const url = (value as { url?: unknown }).url;
if (typeof url === "string" && url.length > 0) return url;
}
return null;
}

/** CSS custom-property value for a variable, or null when not CSS-applicable. */
function cssValueFor(value: unknown): string | null {
if (isScalar(value)) return String(value);
if (value !== null && typeof value === "object") {
// Font values apply their family name; the face itself must be loaded by
// the composition (or the media pipeline).
const name = (value as { name?: unknown }).name;
if (typeof name === "string" && name.length > 0) return name;
}
return null;
}

/**
* Per-run memo of scope element → resolved values, so N bound elements in
* one scope pay for one resolution (the top-level path re-parses the
* declarations attribute on every getVariables() call).
*/
type ScopeValuesCache = Map<Element | null, Record<string, unknown>>;

function valuesForElement(el: Element, cache: ScopeValuesCache): Record<string, unknown> {
const scope = el.closest("[data-composition-id]");
const cached = cache.get(scope);
if (cached) return cached;
const values = readVariablesForElement(el);
cache.set(scope, values);
return values;
}

/**
* Replace the element's own text while preserving element children (nested
* clips, animation-target spans). Mirrors the SDK's setOwnText: write the
* first direct text node, clear the others; append when none exists.
*/
function setOwnTextPreservingChildren(el: Element, text: string): void {
if (el.childElementCount === 0) {
el.textContent = text;
return;
}
let written = false;
for (const node of Array.from(el.childNodes)) {
if (node.nodeType !== Node.TEXT_NODE) continue;
node.nodeValue = written ? "" : text;
written = true;
}
if (!written) {
el.insertBefore(el.ownerDocument.createTextNode(text), el.firstChild);
}
}

/**
* Composition root, matching the SDK's findRoot chain exactly — the SDK
* persists `--{id}` defaults on this element, so the runtime must write
* overrides to the SAME element or an inline default on a descendant would
* shadow an override applied higher up.
*/
function findTopRoot(doc: Document): Element | null {
return (
doc.querySelector("[data-hf-root]") ??
doc.getElementById("stage") ??
doc.body?.firstElementChild ??
doc.body
);
}

function applyCssCustomProperties(doc: Document, cache: ScopeValuesCache): void {
// Top-level root plus every inlined sub-composition root; custom props
// inherit, so descendants of each root see its scope's values.
const roots = new Set<Element>();
const topRoot = findTopRoot(doc);
if (topRoot) roots.add(topRoot);
for (const el of Array.from(doc.querySelectorAll("[data-composition-id]"))) {
roots.add(el);
}
for (const root of roots) {
const values = valuesForElement(root, cache);
for (const [id, value] of Object.entries(values)) {
const css = cssValueFor(value);
if (css !== null && root instanceof HTMLElement) {
root.style.setProperty(`--${id}`, css);
}
}
}
}

export function applyVariableBindings(doc: Document): void {
const cache: ScopeValuesCache = new Map();
applyCssCustomProperties(doc, cache);

for (const el of Array.from(doc.querySelectorAll("[data-var-src]"))) {
const id = el.getAttribute("data-var-src")?.trim();
if (!id) continue;
const url = resolveUrl(valuesForElement(el, cache)[id]);
if (url !== null) el.setAttribute("src", url);
}

for (const el of Array.from(doc.querySelectorAll("[data-var-text]"))) {
const id = el.getAttribute("data-var-text")?.trim();
if (!id) continue;
const value = valuesForElement(el, cache)[id];
if (isScalar(value)) setOwnTextPreservingChildren(el, String(value));
}
}
16 changes: 1 addition & 15 deletions packages/core/src/runtime/colorGrading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
isHfColorGradingActive,
normalizeHfColorGrading,
normalizeHfColorGradingWithVariables,
type HfColorGradingVariableMap,
type HfColorGradingTarget,
type NormalizedHfColorGrading,
} from "../colorGrading";
Expand All @@ -16,6 +15,7 @@ import {
type CubeLutVec3,
} from "../colorLuts";
import { copyMediaVisualStyles } from "../inline-scripts/parityContract";
import { readVariablesForElement } from "./variableScope";
import { swallow } from "./diagnostics";

type ColorGradingMediaElement = HTMLVideoElement | HTMLImageElement;
Expand Down Expand Up @@ -207,20 +207,6 @@ const DEFAULT_COMPARE: RuntimeColorGradingCompareState = {
lineWidth: 2,
};

function readVariablesForElement(element: Element): HfColorGradingVariableMap {
const win = window as WindowWithColorGrading;
const scope = element.closest("[data-composition-id]");
const compositionId = scope?.getAttribute("data-composition-id")?.trim() ?? "";
const scoped = compositionId ? win.__hfVariablesByComp?.[compositionId] : undefined;
if (scoped) return scoped;

const fromHelper = win.__hyperframes?.getVariables?.();
if (fromHelper && typeof fromHelper === "object") {
return fromHelper;
}
return win.__hfVariables ?? {};
}

function readColorGradingAttribute(element: Element): NormalizedHfColorGrading | null {
const raw = element.getAttribute(HF_COLOR_GRADING_ATTR);
if (raw == null) return null;
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { createClipTree } from "./clipTree";
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
import { applyCaptionOverrides } from "./captionOverrides";
import { applyPositionEdits } from "./positionEdits";
import { applyVariableBindings } from "./applyVariableBindings";
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
import { TransportClock } from "./clock";
import { WebAudioTransport } from "./webAudioTransport";
Expand Down Expand Up @@ -84,6 +85,10 @@ export function initSandboxRuntimeModular(): void {
// parsed their tweens, so GSAP (when present) won't fold the translate.
// Re-applied on every timeline bind for the rebind/soft-reload paths.
applyPositionEdits(document);
// Declarative variable bindings (data-var-src / data-var-text / --{id} CSS
// custom props) — values are fixed for the page's lifetime, so applying
// once at init keeps renders deterministic and seeks safe.
applyVariableBindings(document);
const exportRenderFps = resolveExportRenderFps();
state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps;
if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) {
Expand Down Expand Up @@ -2005,6 +2010,10 @@ export function initSandboxRuntimeModular(): void {
bindMediaMetadataListeners();
installAssetFailureDiagnostics();
applyCaptionOverrides();
// Runtime-loaded sub-compositions (and their per-instance scoped
// values) don't exist at the init-time binding pass — re-apply so
// data-var-* / --{id} bindings inside them resolve. Idempotent.
applyVariableBindings(document);
maybePublishRenderReady();
});
} else {
Expand Down
Loading
Loading