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
16 changes: 14 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ deterministic mp4 render. Human edits survive AI regeneration of the base.

## Commands

- `pnpm reframe render <scene.ts|.html> [--overlay f] [-o out]` — mp4 into `out/`
- `pnpm reframe batch <scene.ts> <data.json|csv>` — one mp4 per row (row keys are overlay addresses like `nodes.<id>.<prop>`)
- `pnpm reframe render <scene.ts|.html> [--overlay f] [--theme brand.json] [-o out]` — mp4 into `out/`; `--theme` re-skins `token()` colors (a brand kit is a nested partial theme; also on `frame`/`player`)
- `pnpm reframe batch <scene.ts> <data.json|csv>` — one mp4 per row (row keys are overlay addresses like `nodes.<id>.<prop>` or `design.<token.path>` for a per-brand re-skin)
- `pnpm reframe logo <logo.svg | brand-slug> [--motion <preset>] [--energy n] [--seed n]` — animate a logo into a sting (published CLI command; `packages/render-cli/src/logoSting.ts`)
- `pnpm reframe labels <scene.ts>` — print the compiled event clock (every timeline label → exact seconds; the timing source for `audio.cues` and beat debugging)
- `pnpm reframe compile <scene.ts|.json> [-o out.json] [--stdin] [--code "<src>"] [--json]` — bundle + validate eDSL source into SceneIR JSON, NO render (no ffmpeg/chromium; fast). On failure: a concise classified error (`bundle`/`eval`/`validation`), never the base64 bundle; `--json` makes it `{ok:false,error,kind,issues?}` where `issues` is the structured validation problems (each `{code,path,message}` — e.g. `code:"unknown-blend", path:"nodes.box"`). The in-process equivalent is exported as `reframe-video/compile` (`loadScene`/`loadSceneFromCode`/`checkDeterminism`, server-only); a thrown `SceneValidationError` carries `.issues` (and `.problems` for back-compat), and `SceneLoadError.issues` propagates them across the scene bundle. Entry `packages/render-cli/src/compile.ts`; loader `loadScene.ts`.
Expand Down Expand Up @@ -158,6 +158,18 @@ no `Math.random()`/`Date` (use `wiggle` with a seed, or pass a `seed` knob).
spread into node `x`/`y` (a row of cards, a grid of tiles) — pure math, no nodes. Both
added after a fresh-user reproducibility test flagged hand-positioned affixes + absolute-only
layout as the main friction.
- 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`
→ 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.
- 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
18 changes: 16 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,22 @@ const myBrand = theme({ color: { accent: "#1E90FF" } });

`packages/core/src/theme.ts` is the source of truth for the values; this document mirrors and
explains them. (`brand` is pure data, so referencing a token renders byte-identical to writing
the literal. An engine-level `theme` field that the compiler resolves and an overlay can re-skin
is a later phase.)
the literal.)

For a scene you want to RE-SKIN later, use `token("color.accent")` on a color prop instead of a
literal. It is a deferred reference the compiler resolves against the scene's `design` (or the
house brand), so the same scene renders in any brand:

```ts
import { rect, token } from "reframe-video";
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.

## Brand

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,8 @@ and `liquid-glass-nav.ts` (nav bar over a photo).

| command | what it does |
|---|---|
| `pnpm reframe render <scene.ts\|.json\|.html> [--overlay f]... [-o out]` | deterministic mp4 (mode inferred from extension; output defaults to `out/`) |
| `pnpm reframe batch <scene.ts> <data.json\|csv> [-o dir] [--overlay f]...` | one mp4 per data row (rows = overlays), parallel, with a per-row report |
| `pnpm reframe render <scene.ts\|.json\|.html> [--overlay f]... [--theme brand.json] [-o out]` | deterministic mp4 (mode inferred from extension; output defaults to `out/`); `--theme` re-skins `token()` colors |
| `pnpm reframe batch <scene.ts> <data.json\|csv> [-o dir] [--overlay f]...` | one mp4 per data row (rows = overlays; a `design.<token.path>` column = one mp4 per brand), parallel, with a per-row report |
| `pnpm reframe compile <scene.ts\|.json> [-o out.json] [--json]` | bundle + validate a scene to SceneIR JSON, no render (fast; no ffmpeg/chromium); `--json` returns `{ok, kind, issues}` |
| `pnpm reframe frame <scene.ts\|.json> [--t <sec>] [--overlay f]... [-o out.png]` | render one frame at time `t` to a PNG (chromium only, no mux); `--overlay` previews edits |
| `pnpm reframe compose <scene.ts\|.json> --overlay f... [-o out.json] [--json]` | compose overlay(s) onto a scene and emit composed SceneIR, no render (feed to `player`/`frame` for live preview) |
Expand Down
24 changes: 24 additions & 0 deletions docs/guides/edsl-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,30 @@ const myBrand = theme({ color: { accent: "#1E90FF" } });
`brand.motion.*`, and `brand.layout.*` cover the tokens above. Referencing a token is
byte-identical to writing the literal.

### Re-skinnable scenes (`token()`)

`brand.color.accent` bakes the literal at author time. For a scene you want to RE-SKIN later,
use `token("color.accent")` on a color prop (`fill` / `stroke` / `shadowColor`) instead. It is a
deferred reference the compiler resolves against the scene's `design` (or the house brand), so
the same scene renders in any brand:

```ts
import { rect, token } from "@reframe/core";
rect({ id: "bar", fill: token("color.accent") }); // resolves to the active theme
```

Re-skin without touching the scene:

- one frame/render: `reframe frame scene.ts --theme brand.json` (a brand kit is a nested partial
theme, e.g. `{ "color": { "accent": "#1E90FF" } }`).
- many brands at once: a `batch` data file with a `design.<token.path>` column (plus `_name`)
renders one mp4 per brand:
`[{ "_name": "ocean", "design.color.accent": "#1E90FF" }, …]` →
`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.

## Nodes

Factories return plain data. Every node needs a unique `id`.
Expand Down
5 changes: 5 additions & 0 deletions examples/data/themed-card-brands.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[
{ "_name": "vivid", "nodes.title.content": "Vivid", "design.color.accent": "#FF3D6E", "design.color.bg": "#140A1E", "design.color.surface": "#241433" },
{ "_name": "mono", "nodes.title.content": "Mono", "design.color.accent": "#E6E6EA", "design.color.bg": "#0C0C0E", "design.color.surface": "#1A1A1E" },
{ "_name": "ocean", "nodes.title.content": "Ocean", "design.color.accent": "#1E90FF", "design.color.bg": "#0B1020", "design.color.surface": "#141B2E" }
]
7 changes: 7 additions & 0 deletions examples/themes/ocean.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"color": {
"accent": "#1E90FF",
"bg": "#0B1020",
"surface": "#141B2E"
}
}
4 changes: 2 additions & 2 deletions packages/reframe-video/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ npx reframe-video render hello.ts # → out/hello.mp4

| command | what it does |
|---|---|
| `reframe render <scene.ts> [--overlay edits.json] [-o out.mp4]` | deterministic mp4 |
| `reframe batch <scene.ts> <data.json\|csv>` | one mp4 per data row (row keys are overlay addresses) |
| `reframe render <scene.ts> [--overlay edits.json] [--theme brand.json] [-o out.mp4]` | deterministic mp4; `--theme` re-skins `token()` colors |
| `reframe batch <scene.ts> <data.json\|csv>` | one mp4 per data row (row keys are overlay addresses; a `design.<token.path>` column = one mp4 per brand) |
| `reframe compile <scene.ts> [-o out.json] [--json]` | bundle + validate a scene to SceneIR JSON, no render (fast; no ffmpeg/chromium) |
| `reframe frame <scene.ts> [--t <sec>] [-o out.png]` | render one frame at time `t` to a PNG (chromium only, no mux) |
| `reframe compose <scene.ts> --overlay f... [-o out.json]` | compose overlay(s) onto a scene and emit composed SceneIR, no render |
Expand Down
5 changes: 4 additions & 1 deletion packages/render-cli/src/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,12 @@ export function overlayFromFlat(row: FlatRow, name: string): OverlayDoc {
);
}
(doc.scene ??= {})[sceneKey as "background" | "duration" | "fps"] = value as never;
} else if (head === "design" && parts.length >= 2) {
// a design-token re-skin column, e.g. "design.color.accent" -> one mp4 per brand
(doc.design ??= {})[parts.slice(1).join(".")] = value;
} else {
throw new Error(
`row key "${key}" is not a valid overlay address — expected nodes.<id>.<prop>, states.<state>.<id>.<prop>, timeline.<label>.<duration|ease|stagger>, or scene.<background|duration|fps>`,
`row key "${key}" is not a valid overlay address — expected nodes.<id>.<prop>, states.<state>.<id>.<prop>, timeline.<label>.<duration|ease|stagger>, scene.<background|duration|fps>, or design.<token.path>`,
);
}
}
Expand Down
10 changes: 6 additions & 4 deletions packages/render-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface Args {
keepFrames: boolean;
framesDir?: string;
overlays: string[];
theme?: string;
noAudio: boolean;
/** Composition: render only this scene id, standalone. */
scene?: string;
Expand Down Expand Up @@ -54,6 +55,7 @@ function parseArgs(argv: string[]): Args {
else if (a === "--keep-frames") args.keepFrames = true;
else if (a === "--frames-dir") args.framesDir = resolve(rest[++i]!);
else if (a === "--overlay") args.overlays.push(resolve(rest[++i]!));
else if (a === "--theme") args.theme = resolve(rest[++i]!);
else if (a === "--no-audio") args.noAudio = true;
else if (a === "--scene") args.scene = rest[++i]!;
else {
Expand All @@ -71,8 +73,8 @@ async function main() {
// concatenates (its own frame/temp handling), so branch before the per-scene path.
const loaded = args.mode === "ir" ? await loadModule(args.input) : null;
if (loaded?.kind === "composition") {
if (args.overlays.length > 0) {
console.error("note: overlays apply per-scene, not to a composition — ignored here");
if (args.overlays.length > 0 || args.theme) {
console.error("note: overlays / --theme apply per-scene, not to a composition — ignored here");
}
const { duration, sceneCount } = await renderComposition(loaded.ir, {
compositionPath: args.input,
Expand All @@ -95,8 +97,8 @@ async function main() {
let audioJob: { plan: import("@reframe/core").AudioPlan; videoOut: string } | null = null;
if (args.mode === "ir") {
let ir = loaded!.ir;
if (args.overlays.length > 0) {
const composed = await applyOverlays(ir, args.overlays);
if (args.overlays.length > 0 || args.theme) {
const composed = await applyOverlays(ir, args.overlays, args.theme);
console.error(formatComposeReport(composed.report));
ir = composed.ir;
}
Expand Down
6 changes: 4 additions & 2 deletions packages/render-cli/src/frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ async function main(): Promise<void> {
let t = 0;
let out = "";
const overlays: string[] = [];
let theme: string | undefined;
for (let i = 1; i < argv.length; i++) {
const a = argv[i]!;
if (a === "--t") t = Number(argv[++i]);
else if (a === "-o") out = argv[++i]!;
else if (a === "--overlay") overlays.push(resolve(argv[++i]!));
else if (a === "--theme") theme = resolve(argv[++i]!);
else {
console.error(`unknown argument: ${a}`);
process.exit(2);
Expand All @@ -46,8 +48,8 @@ async function main(): Promise<void> {
process.exit(2);
}
let ir = loaded.ir;
if (overlays.length > 0) {
const composed = await applyOverlays(ir, overlays);
if (overlays.length > 0 || theme) {
const composed = await applyOverlays(ir, overlays, theme);
console.error(formatComposeReport(composed.report));
ir = composed.ir;
}
Expand Down
42 changes: 34 additions & 8 deletions packages/render-cli/src/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,46 @@
* overlays and report applied/orphans identically.
*/
import { readFile } from "node:fs/promises";
import { composeScene, type ComposeReport, type OverlayDoc, type SceneIR } from "@reframe/core";
import { basename } from "node:path";
import { composeScene, type ComposeReport, type OverlayDoc, type PropValue, type SceneIR } from "@reframe/core";

/** Flatten a nested theme object to dotted scalar leaves (skipping arrays): `{color:{accent}}` -> `{"color.accent":…}`. */
export function flattenTokens(obj: unknown, prefix = ""): Record<string, PropValue> {
const out: Record<string, PropValue> = {};
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return out;
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
const path = prefix ? `${prefix}.${k}` : k;
if (typeof v === "string" || typeof v === "number") out[path] = v;
else if (v && typeof v === "object" && !Array.isArray(v)) Object.assign(out, flattenTokens(v, path));
}
return out;
}

/**
* Load a brand-kit JSON (a NESTED partial theme, e.g. `{ "color": { "accent": "#1E90FF" } }`)
* as a `design` OverlayDoc — the `--theme` path. Flattens its scalar leaves to the dotted
* `design.<path>` address form `composeScene` applies.
*/
export async function loadThemeDoc(themePath: string): Promise<OverlayDoc> {
const parsed = JSON.parse(await readFile(themePath, "utf8")) as unknown;
return { reframeOverlay: 1, name: basename(themePath), design: flattenTokens(parsed) };
}

/**
* Compose `overlayPaths` (JSON OverlayDocs) onto `ir`. Returns the composed IR and
* the report (applied/orphans/warnings). With no overlays, returns `ir` unchanged
* and an empty report. The composed IR is validated by `composeScene`.
* Compose `overlayPaths` (JSON OverlayDocs) onto `ir`, optionally prefixed by a `--theme`
* brand kit (`themePath`). The theme is composed FIRST so a `--overlay` can still override it.
* Returns the composed IR and the report. With nothing to apply, returns `ir` unchanged.
*/
export async function applyOverlays(
ir: SceneIR,
overlayPaths: string[],
themePath?: string,
): Promise<{ ir: SceneIR; report: ComposeReport }> {
if (overlayPaths.length === 0) return { ir, report: { applied: [], orphans: [], warnings: [] } };
const docs = await Promise.all(
overlayPaths.map(async (p) => JSON.parse(await readFile(p, "utf8")) as OverlayDoc),
);
if (overlayPaths.length === 0 && !themePath) {
return { ir, report: { applied: [], orphans: [], warnings: [] } };
}
const docs: OverlayDoc[] = [];
if (themePath) docs.push(await loadThemeDoc(themePath));
for (const p of overlayPaths) docs.push(JSON.parse(await readFile(p, "utf8")) as OverlayDoc);
return composeScene(ir, ...docs);
}
14 changes: 9 additions & 5 deletions packages/render-cli/src/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { fileURLToPath } from "node:url";
import type { OverlayDoc } from "@reframe/core";
import { formatComposeReport } from "@reframe/core";
import { loadModule } from "./loadScene.js";
import { applyOverlays } from "./overlay.js";
import { applyOverlays, loadThemeDoc } from "./overlay.js";

const PACKAGED = process.env.REFRAME_PACKAGED === "1";
const HERE = dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -123,17 +123,19 @@ async function main(): Promise<void> {
let edit = false;
let editOrigin = "*";
let initialT = 0;
let theme: string | undefined;
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a === "--overlay") overlays.push(resolve(argv[++i]!));
else if (a === "--theme") theme = resolve(argv[++i]!);
else if (a === "--edit") edit = true;
else if (a === "--edit-origin") editOrigin = argv[++i]!;
else if (a === "--t") initialT = Number(argv[++i]);
else positional.push(a);
}
const [scenePath, outPath] = positional;
if (!scenePath || !outPath) {
console.error("usage: player <scene.ts|.json> [--overlay <doc.json>]... [--edit [--t <sec>]] <out.html>");
console.error("usage: player <scene.ts|.json> [--overlay <doc.json>]... [--theme <brand.json>] [--edit [--t <sec>]] <out.html>");
process.exit(2);
}

Expand All @@ -145,18 +147,20 @@ async function main(): Promise<void> {
console.error("player --edit needs a single scene (not a composition)");
process.exit(2);
}
const docs = await Promise.all(overlays.map(async (p) => JSON.parse(await readFile(p, "utf8")) as OverlayDoc));
const docs: OverlayDoc[] = [];
if (theme) docs.push(await loadThemeDoc(theme));
for (const p of overlays) docs.push(JSON.parse(await readFile(p, "utf8")) as OverlayDoc);
entry = editEntry(JSON.stringify(loaded.ir), JSON.stringify(docs), initialT, JSON.stringify(editOrigin));
} else {
// default build: import the scene file, or inline the composed IR when --overlay is given
let sceneSource = `import sceneIR from ${JSON.stringify(scenePath)};`;
if (overlays.length > 0) {
if (overlays.length > 0 || theme) {
const loaded = await loadModule(scenePath);
if (loaded.kind !== "scene") {
console.error("player needs a single scene (not a composition)");
process.exit(2);
}
const composed = await applyOverlays(loaded.ir, overlays);
const composed = await applyOverlays(loaded.ir, overlays, theme);
console.error(formatComposeReport(composed.report));
sceneSource = `const sceneIR = ${JSON.stringify(composed.ir)};`;
}
Expand Down
Loading
Loading