Skip to content
Merged
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
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,15 +161,16 @@ no `Math.random()`/`Date` (use `wiggle` with a seed, or pass a `seed` knob).
- Design tokens / brand (`packages/core/src/theme.ts`, `DESIGN.md`) — the house brand as code:
`brand` (the DESIGN.md values) + `theme(overrides)` (deep-merge a reusable brand kit). `brand.color.accent`
bakes the literal at author time; `token("color.accent")` is a DEFERRED ref the compiler resolves
against the scene's `design?: DeepPartial<Theme>` field (falling back to `brand`), scoped to color
props (`fill`/`stroke`/`shadowColor`). Golden-safe via `CompiledScene.hasDesign` (no `design`/`$ref`
against the scene's `design?: DeepPartial<Theme>` field (falling back to `brand`). Resolves on color
props (`fill`/`stroke`/`shadowColor`), the scene `background`, and gradient stops (the last in
`evaluate` via `CompiledScene.effectiveTheme`/`.background`). Golden-safe via `CompiledScene.hasDesign` (no `design`/`$ref`
→ byte-identical; resolution only touches `$`-strings on color props, so a text `content:"$5M"` is
untouched). Overlay-addressable as `design.<token.path>` (`compose.ts`, validated against the brand
shape, regen-stable by name, orphan-clean); `sceneManifest().design` surfaces them. Re-skin via
`render/frame/player --theme brand.json` (a nested partial theme, flattened in `render-cli/overlay.ts`
`loadThemeDoc`) or a `batch` `design.<token.path>` column (one mp4 per brand). Demo
`examples/scenes/themed-card.ts` + `examples/data/themed-card-brands.json`. Numeric/type/gradient-stop
tokens + scene-`background` tokens are a later phase.
`examples/scenes/themed-card.ts` + `examples/data/themed-card-brands.json`. Numeric/type tokens
(IR type-widening) are a later phase.
- Photo/video montage (`packages/core/src/montage.ts`) — `photoMontage(shots, opts)` /
`videoMontage` (same generator) turn a list of shots — images AND video clips, mixed
(video detected by src extension, plays as a clip for its `hold`, audio muted by default
Expand Down
4 changes: 2 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ rect({ id: "bar", fill: token("color.accent") });
Then re-skin with no edit to the scene: `reframe frame scene.ts --theme brand.json` (a brand kit
is a nested partial theme), or a `batch` data file with a `design.<token.path>` column (one mp4
per brand). An overlay can patch `design.color.accent` directly, and the re-skin survives an AI
regen of the base (the address is the token name). Color props resolve tokens today; numeric and
type tokens are a later phase.
regen of the base (the address is the token name). Tokens resolve on color props, the scene
background, and gradient stops; numeric and type tokens are a later phase.

## Brand

Expand Down
3 changes: 2 additions & 1 deletion docs/guides/edsl-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ Re-skin without touching the scene:
`reframe batch scene.ts brands.json`.

An overlay can also patch `design.color.accent` directly, and that re-skin survives an AI regen
of the base (the address is the token name). Only color props resolve tokens today.
of the base (the address is the token name). Tokens resolve on color props (`fill`/`stroke`/
`shadowColor`), the scene `background`, and gradient stops; numeric and type tokens are a later phase.

## Nodes

Expand Down
10 changes: 5 additions & 5 deletions examples/scenes/themed-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,21 @@
// reframe frame themed-card.ts --t 1 --overlay examples/overlays/themed-card-reskin.json
// The overlay address `design.color.accent` survives an AI regen of the base.

import { scene, rect, text, token, seq, par, tween, wait } from "@reframe/core";
import { scene, rect, text, token, linearGradient, seq, par, tween, wait } from "@reframe/core";

const W = 1920, H = 1080, CX = 960, CY = 540;

export default scene({
id: "themed-card",
size: { width: W, height: H },
fps: 30,
background: "#000000",
background: token("color.bg"), // the scene background re-skins too
nodes: [
// full-bleed themed background (scene `background` tokens are a later phase)
rect({ id: "stage", x: 0, y: 0, width: W, height: H, fill: token("color.bg") }),
rect({ id: "card", x: CX, y: CY, width: 760, height: 380, radius: 24, anchor: "center", opacity: 0, scale: 0.94,
fill: token("color.surface"), shadowColor: "#04060C", shadowBlur: 60, shadowY: 30 }),
rect({ id: "bar", x: CX - 340, y: CY - 150, width: 8, height: 120, anchor: "top-left", opacity: 0, fill: token("color.accent") }),
// accent bar: a gradient whose stops are tokens (gradient-stop re-skin)
rect({ id: "bar", x: CX - 340, y: CY - 150, width: 8, height: 120, anchor: "top-left", opacity: 0,
fill: linearGradient([token("color.accent"), token("color.accent2")], { angle: 90 }) }),
text({ id: "title", x: CX - 300, y: CY - 110, anchor: "center-left", opacity: 0,
content: "On brand, by token", fontFamily: "Inter", fontSize: 56, fontWeight: 800, fill: token("color.fg") }),
text({ id: "sub", x: CX - 300, y: CY - 36, anchor: "center-left", opacity: 0,
Expand Down
19 changes: 16 additions & 3 deletions packages/core/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ export interface CompiledScene {
zSort: boolean;
/** True iff the scene sets `design` or any color prop resolves a `token()` ref. */
hasDesign: boolean;
/** The scene's design tokens merged onto the house brand (`brand` when no `design`). Used to resolve gradient-stop tokens in evaluate. */
effectiveTheme: Theme;
/** The scene background with any `token()` ref resolved (undefined when no background). Prefer over `ir.background`. */
background?: string;
}

const key = (target: string, prop: string) => `${target}.${prop}`;
Expand Down Expand Up @@ -134,15 +138,22 @@ export function compileScene(ir: SceneIR): CompiledScene {
// when no token refs exist; `usedDesign` records whether any actually resolved.
const effectiveTheme: Theme = ir.design ? theme(ir.design) : brand;
let usedDesign = false;
const resolveToken = (value: PropValue, prop: string): PropValue => {
if (typeof value !== "string" || !value.startsWith("$") || !COLOR_PROPS.has(prop)) return value;
// Resolve a "$color.bg" string ref to its theme value (no prop gate). Unknown token:
// return the literal (never crash). Used for color props, the scene background, and
// gradient stop colors (the last resolved in evaluate, which shares effectiveTheme).
const resolveColorString = (value: string): string => {
if (!value.startsWith("$")) return value;
const resolved = getDeepPath(effectiveTheme, value.slice(1));
if (typeof resolved === "string") {
usedDesign = true;
return resolved;
}
return value; // unknown token: render the literal, never crash
return value;
};
const resolveToken = (value: PropValue, prop: string): PropValue =>
typeof value === "string" && COLOR_PROPS.has(prop) ? resolveColorString(value) : value;
// the scene background is a color slot too (`background: token("color.bg")`)
const background = typeof ir.background === "string" ? resolveColorString(ir.background) : undefined;

const initialValues = new Map<string, PropValue>();
for (const [id, node] of nodeById) {
Expand Down Expand Up @@ -447,5 +458,7 @@ export function compileScene(ir: SceneIR): CompiledScene {
hasPerspective,
zSort,
hasDesign: ir.design !== undefined || usedDesign,
effectiveTheme,
...(background !== undefined && { background }),
};
}
26 changes: 21 additions & 5 deletions packages/core/src/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { sampleBehavior } from "./behaviors.js";
import { cameraMatrix } from "./camera.js";
import type { CompiledScene, MotionDriver, PropertySegment } from "./compile.js";
import { isGradient } from "./gradient.js";
import type { Anchor, BackdropIR, BlendMode, ClipShape, ImageFit, MatteMode, NodeIR, Paint, PropValue } from "./ir.js";
import type { Anchor, BackdropIR, BlendMode, ClipShape, Gradient, ImageFit, MatteMode, NodeIR, Paint, PropValue } from "./ir.js";
import { getDeepPath } from "./theme.js";
import { lerpValue, resolveEase } from "./interpolate.js";
import { pathBBox, pathPoint, pathTangentAngle } from "./path.js";

Expand Down Expand Up @@ -381,6 +382,21 @@ export function evaluate(compiled: CompiledScene, t: number): DisplayList {
return v === "" && base === undefined ? undefined : String(v);
};

// Resolve any `token()` ref in a gradient's stop colors against the scene's theme.
// Gradients are static objects that bypass the value system, so they're resolved here.
// Returns the SAME object when no stop is a `$ref`, so non-token gradients stay byte-identical.
const resolveGradient = (g: Gradient): Gradient => {
if (!g.stops.some((s) => s.color.startsWith("$"))) return g;
return {
...g,
stops: g.stops.map((s) => {
if (!s.color.startsWith("$")) return s;
const r = getDeepPath(compiled.effectiveTheme, s.color.slice(1));
return typeof r === "string" ? { ...s, color: r } : s;
}),
};
};

// Sample blur + drop-shadow/glow into a partial spread onto the op. Only the
// authored effects are included → absent ⇒ no op fields ⇒ byte-identical.
type Fx = { blur?: number; shadowColor?: string; shadowBlur?: number; shadowX?: number; shadowY?: number; blend?: BlendMode };
Expand Down Expand Up @@ -539,8 +555,8 @@ export function evaluate(compiled: CompiledScene, t: number): DisplayList {
// a gradient paint passes through as-is; a color string samples (animatable) via opt()
const fillP = node.props.fill;
const strokeP = node.props.stroke;
const fill = isGradient(fillP) ? fillP : opt(id, "fill", fillP);
const stroke = isGradient(strokeP) ? strokeP : opt(id, "stroke", strokeP);
const fill = isGradient(fillP) ? resolveGradient(fillP) : opt(id, "fill", fillP);
const stroke = isGradient(strokeP) ? resolveGradient(strokeP) : opt(id, "stroke", strokeP);
ops.push({
type: node.type,
id,
Expand Down Expand Up @@ -616,8 +632,8 @@ export function evaluate(compiled: CompiledScene, t: number): DisplayList {
const oy = num(id, "originY", node.props.originY ?? 0);
const fillP = node.props.fill;
const strokeP = node.props.stroke;
const fill = isGradient(fillP) ? fillP : opt(id, "fill", fillP);
const stroke = isGradient(strokeP) ? strokeP : opt(id, "stroke", strokeP);
const fill = isGradient(fillP) ? resolveGradient(fillP) : opt(id, "fill", fillP);
const stroke = isGradient(strokeP) ? resolveGradient(strokeP) : opt(id, "stroke", strokeP);
const dStr = str(id, "d", node.props.d);
const needsBox = isGradient(fill) || isGradient(stroke); // gradient maps to the path's bbox
ops.push({
Expand Down
40 changes: 40 additions & 0 deletions packages/core/test/design.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
compileScene,
composeScene,
sceneManifest,
evaluate,
linearGradient,
isGradient,
type SceneInput,
} from "../src/index.js";

Expand Down Expand Up @@ -101,6 +104,43 @@ describe("overlay re-skin (design.* address)", () => {
});
});

describe("scene background tokens", () => {
it("resolves a background token onto CompiledScene.background", () => {
const c = compileScene(scene({ ...base([box("#fff")]), background: token("color.bg") }));
expect(c.background).toBe(brand.color.bg);
});

it("leaves a literal background untouched (golden-safe)", () => {
const c = compileScene(scene({ ...base([box("#fff")]), background: "#123456" }));
expect(c.background).toBe("#123456");
expect(c.hasDesign).toBe(false);
});
});

describe("gradient-stop tokens", () => {
const gbox = (fill: ReturnType<typeof linearGradient>) =>
rect({ id: "g", x: 0, y: 0, width: 10, height: 10, anchor: "center", fill });

it("resolves a token in a gradient stop at evaluate time", () => {
const c = compileScene(
scene(base([gbox(linearGradient([token("color.accent"), "#ffffff"]))], { color: { accent: "#1E90FF" } })),
);
const op = evaluate(c, 0).find((o) => o.id === "g");
const fill = op && "fill" in op ? op.fill : undefined;
expect(isGradient(fill) ? fill.stops[0]?.color : undefined).toBe("#1E90FF");
});

it("returns the same gradient object when no stop is a token (byte-identical)", () => {
const grad = linearGradient(["#aaaaaa", "#bbbbbb"]);
const c = compileScene(scene(base([gbox(grad)])));
const op = evaluate(c, 0).find((o) => o.id === "g");
const evFill = op && "fill" in op ? op.fill : undefined;
const node = c.nodeById.get("g");
const srcFill = node && "fill" in node.props ? node.props.fill : undefined;
expect(evFill).toBe(srcFill); // referential equality preserved
});
});

describe("manifest surfaces design tokens", () => {
it("lists design.<path> addresses with the current effective value", () => {
const m = sceneManifest(compileScene(scene(base([box(token("color.accent"))], { color: { accent: "#1E90FF" } }))));
Expand Down
2 changes: 1 addition & 1 deletion packages/preview/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1166,7 +1166,7 @@ function drawSceneAt(compiled: import("@reframe/core").CompiledScene, localT: nu
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalAlpha = alpha;
ctx.fillStyle = compiled.ir.background ?? "#000";
ctx.fillStyle = compiled.background ?? compiled.ir.background ?? "#000";
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawDisplayList(ctx, evaluate(compiled, localT), images);
ctx.restore();
Expand Down
3 changes: 2 additions & 1 deletion packages/renderer-canvas/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ export function renderFrame(
images?: ImageRegistry,
videos?: VideoRegistry,
): void {
const { size, background } = compiled.ir;
const { size } = compiled.ir;
const background = compiled.background ?? compiled.ir.background; // resolved token() bg, else literal
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, size.width, size.height);
if (background) {
Expand Down
Loading