Skip to content

Commit d0a1444

Browse files
jrusso1020claude
andcommitted
feat(core): declarative variable bindings — data-var-src, data-var-text, css custom props
Eighth PR of the template-variables stack: the no-script consumption channel, so binding a variable to an element needs zero composition code. - runtime resolves at init (values are fixed per page load — seek-safe, deterministic, identical in preview and render): - data-var-src="id" → sets the element's src from the variable value (URL string or image {url}); authored src stays as the fallback - data-var-text="id" → sets text content from a scalar value - every scalar variable (and a font value's family name) is applied as a --{id} CSS custom property on its composition root, so plain var(--id) CSS bindings respond to render/preview overrides instead of only the persisted default - bindings resolve against the element's owning composition (same scope chain as the color-grading runtime: __hfVariablesByComp for inlined sub-comps, then the top-level merge) - sdk: getVariableUsage counts data-var-* bindings as usage - docs: "Declarative Bindings (No Script Required)" section in concepts/variables.mdx This is the foundation for the Studio "select an element → make this property dynamic" gesture (next PR). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 94a938e commit d0a1444

17 files changed

Lines changed: 513 additions & 34 deletions

docs/concepts/data-attributes.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ Hyperframes uses HTML data attributes to control timing, media playback, and [co
3232
| `data-composition-src` | `"./intro.html"` | Path to external [composition](/concepts/compositions) HTML file |
3333
| `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). |
3434
| `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`. |
35+
| `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. |
36+
| `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. |
3537

3638
## Element Visibility
3739

docs/concepts/variables.mdx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,31 @@ Inside any composition script, call `window.__hyperframes.getVariables()` to get
196196

197197
`__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.
198198

199+
## Declarative Bindings (No Script Required)
200+
201+
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:
202+
203+
- **`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:
204+
205+
```html
206+
<img class="clip" data-start="0" data-duration="5"
207+
data-var-src="heroImage" src="fallback.jpg" />
208+
```
209+
210+
- **`data-var-text="id"`** — sets the element's text content from a scalar variable:
211+
212+
```html
213+
<h1 class="clip" data-start="0" data-duration="5" data-var-text="title">Fallback title</h1>
214+
```
215+
216+
- **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:
217+
218+
```css
219+
.card-title { color: var(--accent); font-family: var(--brandFont), sans-serif; }
220+
```
221+
222+
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).
223+
199224
## Per-instance Overrides (Sub-compositions)
200225

201226
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:

docs/reference/html-schema.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ Common sizes:
5858
| `data-volume` | audio, video | No | Volume level from `0` to `1`. Default: `1`. |
5959
| `data-composition-id` | div | On compositions | Unique composition ID. Must match the key used in `window.__timelines`. |
6060
| `data-composition-src` | div | No | Path to external composition HTML file (for [nested compositions](#composition-clips)). |
61-
| `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. |
61+
| `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. |
62+
| `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. |
63+
| `data-var-text` | any | No | Binds the element's own text to a scalar variable id. Element children are preserved. |
6264
| `data-width` | div | On compositions | Composition width in pixels. |
6365
| `data-height` | div | On compositions | Composition height in pixels. |
6466

@@ -152,7 +154,7 @@ Common sizes:
152154
- Each nested composition has its own `window.__timelines` entry, registered by its own `<script>` block
153155
- The framework automatically nests sub-timelines — do not manually add them to the parent timeline
154156
- Any composition can be nested inside any other — there is no special "root" type
155-
- Per-instance values can be passed with `data-variable-values`, but the nested composition must read and apply those values itself
157+
- 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
156158

157159
For more on how compositions work, see [Compositions](/concepts/compositions).
158160
</Accordion>
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
5+
import { applyVariableBindings } from "./applyVariableBindings";
6+
import { getVariables } from "./getVariables";
7+
8+
type TestWindow = Window & {
9+
__hfVariables?: unknown;
10+
__hfVariablesByComp?: Record<string, Record<string, unknown>>;
11+
__hyperframes?: { getVariables?: () => Record<string, unknown> };
12+
};
13+
14+
const win = window as TestWindow;
15+
16+
beforeEach(() => {
17+
win.__hyperframes = { getVariables };
18+
});
19+
20+
afterEach(() => {
21+
delete win.__hfVariables;
22+
delete win.__hfVariablesByComp;
23+
delete win.__hyperframes;
24+
document.documentElement.removeAttribute("data-composition-variables");
25+
document.body.innerHTML = "";
26+
});
27+
28+
function setDeclared(decls: unknown[]): void {
29+
document.documentElement.setAttribute("data-composition-variables", JSON.stringify(decls));
30+
}
31+
32+
describe("applyVariableBindings", () => {
33+
it("sets src from a string variable via data-var-src", () => {
34+
setDeclared([{ id: "hero", type: "image", label: "Hero", default: "default.jpg" }]);
35+
document.body.innerHTML = `
36+
<div data-hf-root data-composition-id="c1">
37+
<img id="img" data-var-src="hero" src="fallback.jpg" />
38+
</div>`;
39+
applyVariableBindings(document);
40+
expect(document.getElementById("img")?.getAttribute("src")).toBe("default.jpg");
41+
});
42+
43+
it("render-time overrides win, and {url} image values resolve", () => {
44+
setDeclared([{ id: "hero", type: "image", label: "Hero", default: "default.jpg" }]);
45+
win.__hfVariables = { hero: { url: "https://cdn/override.png" } };
46+
document.body.innerHTML = `
47+
<div data-hf-root><video data-var-src="hero" src="fallback.mp4"></video></div>`;
48+
applyVariableBindings(document);
49+
expect(document.querySelector("video")?.getAttribute("src")).toBe("https://cdn/override.png");
50+
});
51+
52+
it("keeps the authored src when the variable resolves to nothing", () => {
53+
document.body.innerHTML = `<div data-hf-root><img data-var-src="ghost" src="keep.jpg" /></div>`;
54+
applyVariableBindings(document);
55+
expect(document.querySelector("img")?.getAttribute("src")).toBe("keep.jpg");
56+
});
57+
58+
it("sets text content from a scalar via data-var-text", () => {
59+
setDeclared([{ id: "title", type: "string", label: "Title", default: "Hello" }]);
60+
win.__hfVariables = { title: "Overridden" };
61+
document.body.innerHTML = `<div data-hf-root><h1 data-var-text="title">Authored</h1></div>`;
62+
applyVariableBindings(document);
63+
expect(document.querySelector("h1")?.textContent).toBe("Overridden");
64+
});
65+
66+
it("applies scalar variables as --{id} custom props on the root", () => {
67+
setDeclared([
68+
{ id: "accent", type: "color", label: "Accent", default: "#00C3FF" },
69+
{ id: "count", type: "number", label: "Count", default: 3 },
70+
]);
71+
win.__hfVariables = { accent: "#ff0000" };
72+
document.body.innerHTML = `<div id="root" data-hf-root></div>`;
73+
applyVariableBindings(document);
74+
const root = document.getElementById("root");
75+
expect(root?.style.getPropertyValue("--accent")).toBe("#ff0000");
76+
expect(root?.style.getPropertyValue("--count")).toBe("3");
77+
});
78+
79+
it("applies a font value's family name, and skips other objects", () => {
80+
win.__hfVariables = {
81+
brandFont: { name: "Inter", source: "https://fonts" },
82+
img: { url: "x" },
83+
};
84+
document.body.innerHTML = `<div id="root" data-hf-root></div>`;
85+
applyVariableBindings(document);
86+
const root = document.getElementById("root");
87+
expect(root?.style.getPropertyValue("--brandFont")).toBe("Inter");
88+
expect(root?.style.getPropertyValue("--img")).toBe("");
89+
});
90+
91+
it("preserves element children when binding text on a container", () => {
92+
win.__hfVariables = { title: "Replaced" };
93+
document.body.innerHTML = `
94+
<div data-hf-root>
95+
<h1 data-var-text="title">Hello <em id="kid" class="clip">world</em></h1>
96+
</div>`;
97+
applyVariableBindings(document);
98+
const h1 = document.querySelector("h1");
99+
expect(document.getElementById("kid")?.textContent).toBe("world");
100+
expect(h1?.childNodes[0]?.nodeValue).toBe("Replaced");
101+
});
102+
103+
it("is idempotent across re-application (loader re-apply path)", () => {
104+
win.__hfVariables = { title: "Once" };
105+
document.body.innerHTML = `<div data-hf-root><h1 data-var-text="title">t</h1></div>`;
106+
applyVariableBindings(document);
107+
applyVariableBindings(document);
108+
expect(document.querySelector("h1")?.textContent).toBe("Once");
109+
});
110+
111+
it("resolves sub-composition elements against their scoped values", () => {
112+
win.__hfVariablesByComp = { sub: { label: "Scoped" } };
113+
win.__hfVariables = { label: "TopLevel" };
114+
document.body.innerHTML = `
115+
<div data-hf-root data-composition-id="main">
116+
<p id="top" data-var-text="label">t</p>
117+
<div data-composition-id="sub"><p id="inner" data-var-text="label">s</p></div>
118+
</div>`;
119+
applyVariableBindings(document);
120+
expect(document.getElementById("inner")?.textContent).toBe("Scoped");
121+
expect(document.getElementById("top")?.textContent).toBe("TopLevel");
122+
});
123+
});
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* Declarative variable bindings — the no-script consumption channel for
3+
* composition variables (values are fixed for the page's lifetime, so this is
4+
* seek-safe and deterministic):
5+
*
6+
* - `data-var-src="id"` — sets the element's `src` from the variable value
7+
* (a URL string or an image value `{url}`). The authored src stays as the
8+
* fallback when the variable resolves to nothing.
9+
* - `data-var-text="id"` — sets the element's OWN text from a scalar variable
10+
* value. Elements with element children keep them: only the direct text
11+
* node is replaced, mirroring the SDK's setOwnText semantics — a text
12+
* binding must never delete nested clips or animation targets.
13+
* - Every scalar variable (and a font value's family name) is applied as a
14+
* `--{id}` CSS custom property on its composition root, so CSS bindings
15+
* like `color: var(--accent)` respond to render/preview overrides instead
16+
* of only the persisted default.
17+
*
18+
* Values resolve against the element's owning composition — the same scope
19+
* chain the color-grading runtime uses: `__hfVariablesByComp[compId]` for
20+
* inlined sub-compositions, then the top-level merged `getVariables()`.
21+
*
22+
* Applied at init AND re-applied after the composition loader inlines
23+
* external / template sub-compositions (their DOM and per-instance scoped
24+
* values don't exist at init). Idempotent: re-applying writes the same
25+
* values.
26+
*/
27+
28+
import { readVariablesForElement } from "./variableScope";
29+
import { isScalarVariableValue as isScalar } from "@hyperframes/parsers/composition";
30+
31+
function resolveUrl(value: unknown): string | null {
32+
if (typeof value === "string" && value.length > 0) return value;
33+
if (value !== null && typeof value === "object") {
34+
const url = (value as { url?: unknown }).url;
35+
if (typeof url === "string" && url.length > 0) return url;
36+
}
37+
return null;
38+
}
39+
40+
/** CSS custom-property value for a variable, or null when not CSS-applicable. */
41+
function cssValueFor(value: unknown): string | null {
42+
if (isScalar(value)) return String(value);
43+
if (value !== null && typeof value === "object") {
44+
// Font values apply their family name; the face itself must be loaded by
45+
// the composition (or the media pipeline).
46+
const name = (value as { name?: unknown }).name;
47+
if (typeof name === "string" && name.length > 0) return name;
48+
}
49+
return null;
50+
}
51+
52+
/**
53+
* Per-run memo of scope element → resolved values, so N bound elements in
54+
* one scope pay for one resolution (the top-level path re-parses the
55+
* declarations attribute on every getVariables() call).
56+
*/
57+
type ScopeValuesCache = Map<Element | null, Record<string, unknown>>;
58+
59+
function valuesForElement(el: Element, cache: ScopeValuesCache): Record<string, unknown> {
60+
const scope = el.closest("[data-composition-id]");
61+
const cached = cache.get(scope);
62+
if (cached) return cached;
63+
const values = readVariablesForElement(el);
64+
cache.set(scope, values);
65+
return values;
66+
}
67+
68+
/**
69+
* Replace the element's own text while preserving element children (nested
70+
* clips, animation-target spans). Mirrors the SDK's setOwnText: write the
71+
* first direct text node, clear the others; append when none exists.
72+
*/
73+
function setOwnTextPreservingChildren(el: Element, text: string): void {
74+
if (el.childElementCount === 0) {
75+
el.textContent = text;
76+
return;
77+
}
78+
let written = false;
79+
for (const node of Array.from(el.childNodes)) {
80+
if (node.nodeType !== Node.TEXT_NODE) continue;
81+
node.nodeValue = written ? "" : text;
82+
written = true;
83+
}
84+
if (!written) {
85+
el.insertBefore(el.ownerDocument.createTextNode(text), el.firstChild);
86+
}
87+
}
88+
89+
/**
90+
* Composition root, matching the SDK's findRoot chain exactly — the SDK
91+
* persists `--{id}` defaults on this element, so the runtime must write
92+
* overrides to the SAME element or an inline default on a descendant would
93+
* shadow an override applied higher up.
94+
*/
95+
function findTopRoot(doc: Document): Element | null {
96+
return (
97+
doc.querySelector("[data-hf-root]") ??
98+
doc.getElementById("stage") ??
99+
doc.body?.firstElementChild ??
100+
doc.body
101+
);
102+
}
103+
104+
function applyCssCustomProperties(doc: Document, cache: ScopeValuesCache): void {
105+
// Top-level root plus every inlined sub-composition root; custom props
106+
// inherit, so descendants of each root see its scope's values.
107+
const roots = new Set<Element>();
108+
const topRoot = findTopRoot(doc);
109+
if (topRoot) roots.add(topRoot);
110+
for (const el of Array.from(doc.querySelectorAll("[data-composition-id]"))) {
111+
roots.add(el);
112+
}
113+
for (const root of roots) {
114+
const values = valuesForElement(root, cache);
115+
for (const [id, value] of Object.entries(values)) {
116+
const css = cssValueFor(value);
117+
if (css !== null && root instanceof HTMLElement) {
118+
root.style.setProperty(`--${id}`, css);
119+
}
120+
}
121+
}
122+
}
123+
124+
export function applyVariableBindings(doc: Document): void {
125+
const cache: ScopeValuesCache = new Map();
126+
applyCssCustomProperties(doc, cache);
127+
128+
for (const el of Array.from(doc.querySelectorAll("[data-var-src]"))) {
129+
const id = el.getAttribute("data-var-src")?.trim();
130+
if (!id) continue;
131+
const url = resolveUrl(valuesForElement(el, cache)[id]);
132+
if (url !== null) el.setAttribute("src", url);
133+
}
134+
135+
for (const el of Array.from(doc.querySelectorAll("[data-var-text]"))) {
136+
const id = el.getAttribute("data-var-text")?.trim();
137+
if (!id) continue;
138+
const value = valuesForElement(el, cache)[id];
139+
if (isScalar(value)) setOwnTextPreservingChildren(el, String(value));
140+
}
141+
}

packages/core/src/runtime/colorGrading.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
isHfColorGradingActive,
55
normalizeHfColorGrading,
66
normalizeHfColorGradingWithVariables,
7-
type HfColorGradingVariableMap,
87
type HfColorGradingTarget,
98
type NormalizedHfColorGrading,
109
} from "../colorGrading";
@@ -16,6 +15,7 @@ import {
1615
type CubeLutVec3,
1716
} from "../colorLuts";
1817
import { copyMediaVisualStyles } from "../inline-scripts/parityContract";
18+
import { readVariablesForElement } from "./variableScope";
1919
import { swallow } from "./diagnostics";
2020

2121
type ColorGradingMediaElement = HTMLVideoElement | HTMLImageElement;
@@ -207,20 +207,6 @@ const DEFAULT_COMPARE: RuntimeColorGradingCompareState = {
207207
lineWidth: 2,
208208
};
209209

210-
function readVariablesForElement(element: Element): HfColorGradingVariableMap {
211-
const win = window as WindowWithColorGrading;
212-
const scope = element.closest("[data-composition-id]");
213-
const compositionId = scope?.getAttribute("data-composition-id")?.trim() ?? "";
214-
const scoped = compositionId ? win.__hfVariablesByComp?.[compositionId] : undefined;
215-
if (scoped) return scoped;
216-
217-
const fromHelper = win.__hyperframes?.getVariables?.();
218-
if (fromHelper && typeof fromHelper === "object") {
219-
return fromHelper;
220-
}
221-
return win.__hfVariables ?? {};
222-
}
223-
224210
function readColorGradingAttribute(element: Element): NormalizedHfColorGrading | null {
225211
const raw = element.getAttribute(HF_COLOR_GRADING_ATTR);
226212
if (raw == null) return null;

packages/core/src/runtime/init.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { createClipTree } from "./clipTree";
2929
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
3030
import { applyCaptionOverrides } from "./captionOverrides";
3131
import { applyPositionEdits } from "./positionEdits";
32+
import { applyVariableBindings } from "./applyVariableBindings";
3233
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
3334
import { TransportClock } from "./clock";
3435
import { WebAudioTransport } from "./webAudioTransport";
@@ -84,6 +85,10 @@ export function initSandboxRuntimeModular(): void {
8485
// parsed their tweens, so GSAP (when present) won't fold the translate.
8586
// Re-applied on every timeline bind for the rebind/soft-reload paths.
8687
applyPositionEdits(document);
88+
// Declarative variable bindings (data-var-src / data-var-text / --{id} CSS
89+
// custom props) — values are fixed for the page's lifetime, so applying
90+
// once at init keeps renders deterministic and seeks safe.
91+
applyVariableBindings(document);
8792
const exportRenderFps = resolveExportRenderFps();
8893
state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps;
8994
if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) {
@@ -2005,6 +2010,10 @@ export function initSandboxRuntimeModular(): void {
20052010
bindMediaMetadataListeners();
20062011
installAssetFailureDiagnostics();
20072012
applyCaptionOverrides();
2013+
// Runtime-loaded sub-compositions (and their per-instance scoped
2014+
// values) don't exist at the init-time binding pass — re-apply so
2015+
// data-var-* / --{id} bindings inside them resolve. Idempotent.
2016+
applyVariableBindings(document);
20082017
maybePublishRenderReady();
20092018
});
20102019
} else {

0 commit comments

Comments
 (0)