diff --git a/README.md b/README.md index b475e6b..0d7af14 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,10 @@ render: deterministic mp4 (same input → byte-identical frames) ◀── ove ``` Everything is a pure function of time: `evaluate(scene, t)` — no wall clocks, -no randomness without a seed, scrubbing and distributed rendering for free. +no randomness without a seed, scrubbing and distributed rendering for free. And +it's *enforced*: `reframe lint` compiles the scene twice and flags any IR that +differs (a `Math.random()` or `Date` baked into a prop), so a scene that would +silently render differently each time fails the gate before you ship it. ## Generative choreography at scale @@ -270,8 +273,16 @@ your scene. |---|---| | `pnpm reframe render [--overlay f]... [-o out]` | deterministic mp4 (mode inferred from extension; output defaults to `out/`) | | `pnpm reframe batch [-o dir] [--overlay f]...` | one mp4 per data row (rows = overlays), parallel, with a per-row report | -| `pnpm reframe compile [-o out.json] [--json]` | bundle + validate a scene to SceneIR JSON, no render (fast; no ffmpeg/chromium) | +| `pnpm reframe compile [-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 [--t ] [-o out.png]` | render one frame at time `t` to a PNG (chromium only, no mux) — for a render-and-look loop | +| `pnpm reframe manifest [--json]` | dump the addressable surface: every node, state, timeline label, and beat with the overlay address that reaches it | +| `pnpm reframe lint [--json] [--strict]` | the studio-readiness gate: flag un-addressable motion + verify the scene is a pure function of time; `--strict` exits non-zero | +| `pnpm reframe verify-overlay ... [--json]` | compose an overlay onto a base and report applied-vs-orphaned, no render — the regen-survival check (non-zero exit on orphans) | +| `pnpm reframe labels ` | print the compiled event clock (every timeline label → exact seconds) — the timing source for audio cues | +| `pnpm reframe assemble [-o name]` | probe images/videos (ffprobe) and scaffold an editable montage scene `.ts` wired with `photoMontage` | +| `pnpm reframe player [-o out.html]` | bundle a scene into one self-contained HTML that plays the motion live in any browser | +| `pnpm reframe logo [--motion ]` | animate a logo (or a simple-icons brand) into a sting | +| `pnpm reframe diff [scene.ts] [--t ] [--mode side\|blend\|diff\|grid]` | compare a render against a reference image | | `pnpm reframe preview` | scrub/play/edit UI; edits export as overlay JSON | | `pnpm reframe new ` | scaffold a documented starter scene | | `pnpm reframe motion ` | calibrated motion profile (speeds, easing, discontinuities) | @@ -293,8 +304,55 @@ them with a diagnosis naming the likely rename. The failure hierarchy: 2. Contract broken → loud orphan report. 3. Never: silent edit loss, or a render failure caused by base drift. +### Restructure, don't just re-skin + +An overlay isn't limited to patching props and timing — it can change the +**structure** of the cut, keyed by the same stable addresses, and those edits +survive regeneration too: **reorder** a beat (`timeline..order`), +**remove** one (`removeTimeline: ["shot-3"]`), or **insert** a whole new unit +(`insertNodes` + `insertTimeline { into: "montage", after: "shot-2" }`). A +montage is built so each shot is the self-contained beat `shot-${i}`, so an +overlay can drop, reorder, or splice in a card without touching the base: + +```jsonc +// reorder two cards, drop one, retitle another — all survive an AI regen +{ "removeTimeline": ["shot-3"], + "timeline": { "shot-1": { "order": 2 }, "shot-2": { "order": 1 } }, + "nodes": { "shot-0-title": { "content": "REORDERED" } } } +``` + +Reproduce it with the pure-vector demo (no assets) — the same overlay reorders, +removes, and inserts a card, and every edit is reported applied with zero +orphans: + +```bash +pnpm reframe verify-overlay examples/scenes/vector-montage.ts \ + examples/overlays/vector-montage-restructure.json # 4 applied, 0 orphaned +pnpm reframe render examples/scenes/vector-montage.ts \ + --overlay examples/overlays/vector-montage-insert.json # a new card spliced into the cut +``` + ![The preview editor: knobs write into a non-destructive overlay](docs/assets/preview-editor.png) +## Embedding reframe (in-process API) + +The CLI is a thin shell over an importable, server-side API — load, validate, +and check a scene without spawning a process. From `reframe-video/compile`: + +```ts +import { loadScene, loadSceneFromCode, checkDeterminism } from "reframe-video/compile"; + +const compiled = await loadScene("scene.ts"); // bundle + validate → CompiledScene +// On failure a SceneLoadError carries .kind ("bundle" | "eval" | "validation") +// and .issues: { code, path, message }[] — structured, not prose to parse. +const { deterministic, findings } = await checkDeterminism("scene.ts"); +``` + +`reframe-video/renderer` exposes `renderFrame` / `drawDisplayList` (DisplayList → +Canvas 2D) for live browser playback, and `reframe compile --json` returns the +same structured shape on the CLI: `{ ok: false, kind, issues: [{ code, path, message }] }` +— the feedback loop an agent or a UI reads to point at the exact broken node. + ## Documentation The [`docs/`](docs/) folder is a [Mintlify](https://mintlify.com)-ready site (`docs/docs.json`): @@ -303,7 +361,7 @@ The [`docs/`](docs/) folder is a [Mintlify](https://mintlify.com)-ready site (`d |---|---| | [Introduction](docs/introduction.mdx) · [Quickstart](docs/quickstart.mdx) · [The loop](docs/the-loop.mdx) | the pitch, install, and the AI-write / human-edit / deterministic-render model | | [Gallery](docs/gallery.mdx) | a curated visual reel of scenes | -| [Examples](examples/README.md) | all 66 example scenes, by category | +| [Examples](examples/README.md) | all 67 example scenes, by category | | [Guides](docs/guides/) | the eDSL, directing, HTML/GSAP, and regeneration-contract guides (also `pnpm reframe guide`) | Curated renders live in [`docs/assets/gallery/`](docs/assets/gallery) and accumulate via `pnpm gallery` (the committed home; `out/` stays scratch). @@ -316,7 +374,7 @@ Curated renders live in [`docs/assets/gallery/`](docs/assets/gallery) and accumu | `packages/renderer-canvas` | DisplayList → Canvas 2D (browser + capture shared) | | `packages/render-cli` | Playwright capture + ffmpeg encode; also renders arbitrary HTML/GSAP deterministically via a virtual clock | | `packages/preview` | the Vite editor | -| `examples/` | 66 example scenes (see [`examples/README.md`](examples/README.md)), overlays, compositions, the edit-survival demo | +| `examples/` | 67 example scenes (see [`examples/README.md`](examples/README.md)), overlays, compositions, the edit-survival demo | | `labs/` | experiments and product probes (live-data → baked scene → render), kept out of `examples/` so it stays purely demonstrative | | `docs/` | the [Mintlify](https://mintlify.com)-ready docs site + the authoring guides (also `pnpm reframe guide`) | | `benchmark/` | **measurement artifacts, not product code**: LLM generation benchmark (RESULTS/ANALYSIS.md), regeneration-contract experiment (regen/), calibrated motion profiler (harness/motion/, MOTION.md) | @@ -342,5 +400,5 @@ edit survival across AI regeneration, and a five-turn natural-language iteration loop with zero silent edit loss. Receipts: `benchmark/ANALYSIS.md`, `benchmark/MOTION.md`, `benchmark/regen/REGEN-ANALYSIS.md`, `benchmark/nl-loop/NL-LOOP.md`. What "alpha" means honestly: it has not met -strangers yet — surface area is intentionally small (6 node types, one font, +strangers yet — surface area is intentionally small (8 node types, one font, Canvas 2D) and the IR/overlay schema has no compatibility promise before 1.0. diff --git a/docs/api.mdx b/docs/api.mdx new file mode 100644 index 0000000..1fbfac9 --- /dev/null +++ b/docs/api.mdx @@ -0,0 +1,68 @@ +--- +title: Programmatic API +description: "Load, validate, and check a scene in-process — the importable surface under the CLI." +--- + +The CLI is a thin shell over a server-side, importable API. A backend can do NL → eDSL → IR → (preview + diff) without shelling out or re-implementing the evaluator. Three subpath exports of the `reframe-video` npm package: + +| import | what | +|---|---| +| `reframe-video` | the core eDSL + IR — `scene`, `compileScene`, `composeScene`, `evaluate`, `sceneManifest`, `lintScene`, `validateScene` | +| `reframe-video/compile` | load + validate + determinism — `loadScene`, `loadSceneFromCode`, `loadModule`, `checkDeterminism`, `SceneLoadError`, `ValidationIssue` | +| `reframe-video/renderer` | `DisplayList` → Canvas 2D — `renderFrame`, `drawDisplayList`, `ImageRegistry`, `VideoRegistry`, `coverRect` | + + +`reframe-video/compile` executes the scene module in-process. Treat untrusted or model-authored source as code: bound it with a timeout and run it where a misbehaving module can't do harm. True sandboxing is a separate concern. + + +## Load & validate a scene + +```ts +import { loadScene, loadSceneFromCode, SceneLoadError } from "reframe-video/compile"; + +try { + const compiled = await loadScene("scene.ts"); // bundle + validate → CompiledScene + // or: await loadSceneFromCode(sourceString) // no file on disk +} catch (err) { + if (err instanceof SceneLoadError) { + err.kind; // "bundle" | "eval" | "validation" — which stage failed + err.issues; // ValidationIssue[] on a validation failure + } +} +``` + +A `ValidationIssue` is structured, not prose — so a UI can point at the exact broken node: + +```ts +interface ValidationIssue { + code: string; // stable category, e.g. "unknown-blend", "duplicate-node-id" + path: string; // locator, e.g. "nodes.box", "timeline.beat(intro)[0]", "camera.zoom" + message: string; // the human-readable line +} +``` + +The CLI mirrors this: `reframe compile --json` returns `{ ok: false, error, kind, issues }` on failure. + +## Check determinism + +```ts +import { checkDeterminism } from "reframe-video/compile"; + +const { deterministic, findings } = await checkDeterminism("scene.ts"); +// compiles the source twice and diffs the IR; `findings` pins the first +// differing address when a Math.random() / Date leaked into a prop. +``` + +This is what `reframe lint` runs for the source-purity half of its gate. + +## Render a frame + +```ts +import { renderFrame } from "reframe-video/renderer"; +import { compileScene, evaluate } from "reframe-video"; + +const ctx = canvas.getContext("2d")!; // any Canvas 2D context +renderFrame(ctx, compiled, t); // draw the scene at time t +``` + +`renderFrame` evaluates the compiled scene at `t` and paints the resulting `DisplayList`; pass an `ImageRegistry` / `VideoRegistry` to supply raster sources for `image` / `video` nodes. This is the same path the live browser preview uses. diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx new file mode 100644 index 0000000..909f223 --- /dev/null +++ b/docs/cli-reference.mdx @@ -0,0 +1,51 @@ +--- +title: CLI reference +description: "Every reframe command — render, inspect, validate, and edit a scene from the terminal." +--- + +Run any command with `npx reframe-video ` (no clone needed) or `pnpm reframe ` in a checkout. Scene paths resolve against the directory you invoke from; outputs default to `out/`. + +## Render & output + +| command | what it does | +|---|---| +| `render [--overlay f]... [-o out]` | deterministic mp4 (mode inferred from extension; composes any `--overlay` first) | +| `batch [-o dir] [--overlay f]...` | one mp4 per data row (row keys are overlay addresses), in parallel, with a per-row report | +| `frame [--t ] [-o out.png]` | render one frame at time `t` to a PNG (chromium only, no mux) — for a render-and-look loop | +| `player [-o out.html]` | bundle a scene into one self-contained HTML that plays the motion live in any browser | +| `logo [--motion ] [--energy n] [--seed n]` | animate a logo (or a simple-icons brand) into a sting | +| `assemble [-o name] [--title "…"] [--bgm ]` | probe images/videos (ffprobe) and scaffold an editable montage scene `.ts` | + +## Inspect & validate + +| command | what it does | +|---|---| +| `compile [-o out.json] [--json]` | bundle + validate a scene to SceneIR JSON, no render (fast; no ffmpeg/chromium). `--json` returns `{ ok, kind, issues }` | +| `manifest [--json]` | dump the addressable surface — every node, state, timeline label, and beat with the overlay address that reaches it | +| `lint [--json] [--strict]` | the studio-readiness gate — flag un-addressable motion + verify the scene is a pure function of time. `--strict` exits non-zero on findings | +| `labels ` | print the compiled event clock (every timeline label → exact seconds) — the timing source for `audio.cues` | + +## Edit & verify + +| command | what it does | +|---|---| +| `verify-overlay ... [--json]` | compose an overlay onto a base and report applied-vs-orphaned, no render. Non-zero exit on orphans — the regen-survival check | +| `preview` | scrub / play / edit UI; edits export as overlay JSON | +| `demo` | render the edit-survival demo (base, base + overlay, AI-regenerated base + the same overlay) | + +## Author & measure + +| command | what it does | +|---|---| +| `new ` | scaffold a documented starter scene `.ts` | +| `diff [scene.ts] [--t ] [--mode side\|blend\|diff\|grid]` | compare a render against a reference image | +| `motion ` | calibrated motion profile of a clip (speeds, easing, discontinuities) | +| `trace [--apply scene.ts]` | extract a video's motion structure; `--apply` re-tells it on your own nodes | +| `guide [--directing\|--regen\|--html]` | print a guide: eDSL syntax (default), the directing workflow, the regeneration contract, or HTML/GSAP scenes | +| `skill [--path]` | print the authoring skill for an agent; `--path` prints the plugin dir to load | + + +Before patching a scene, run `manifest` to see the real addresses; after a regen, run `verify-overlay` against the new base to prove every edit still applies; gate CI with `lint --strict`. + + +The same load / validate / determinism surface is importable in-process — see the [programmatic API](/api). diff --git a/docs/docs.json b/docs/docs.json index ee67e40..3dcb5ed 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -24,7 +24,9 @@ "guides/edsl-guide", "guides/directing-guide", "guides/html-guide", - "guides/regen-contract" + "guides/regen-contract", + "cli-reference", + "api" ] }, { diff --git a/docs/examples.mdx b/docs/examples.mdx index d71bc5b..9d880a3 100644 --- a/docs/examples.mdx +++ b/docs/examples.mdx @@ -1,6 +1,6 @@ --- title: Examples -description: "All 66 example scenes, by category — each a single self-contained file you can render." +description: "All 67 example scenes, by category — each a single self-contained file you can render." --- Every scene is one `.ts` file in [`examples/scenes/`](https://github.com/kiyeonjeon21/reframe/tree/main/examples/scenes) — self-contained and dependency-free. Render any of them: @@ -44,7 +44,7 @@ The [gallery](/gallery) has the curated visual reel; the [repo README](https://g [camera-demo](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/camera-demo.ts) · [isometric-stack](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/isometric-stack.ts) · [nova-teaser](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/nova-teaser.ts) · [orbit](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/orbit.ts) · [particle-swarm](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/particle-swarm.ts) · [rocket-launch](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/rocket-launch.ts) · [solar-system](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/solar-system.ts) · [spacex-cursor](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/spacex-cursor.ts) · [wavefield](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/wavefield.ts) · [zoom-to-space](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/zoom-to-space.ts) ## Edit-survival / regen -[survive-base](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/survive-base.ts) · [survive-cut](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/survive-cut.ts) · [survive-regen](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/survive-regen.ts) +[survive-base](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/survive-base.ts) · [survive-cut](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/survive-cut.ts) · [survive-regen](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/survive-regen.ts) · [vector-montage](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/vector-montage.ts) ## Other [glitch-vhs](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/glitch-vhs.ts) · [glyph-reveal](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/glyph-reveal.ts) · [lower-third](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/lower-third.ts) · [photo-montage](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/photo-montage.ts) · [player-sting](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/player-sting.ts) · [video-demo](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/video-demo.ts) · [video-montage](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/video-montage.ts) · [worldcup-glyph](https://github.com/kiyeonjeon21/reframe/blob/main/examples/scenes/worldcup-glyph.ts) diff --git a/docs/gallery.mdx b/docs/gallery.mdx index ea2febb..eabb65d 100644 --- a/docs/gallery.mdx +++ b/docs/gallery.mdx @@ -3,7 +3,7 @@ title: Gallery description: "A reel of reframe scenes — each a few-line declaration, each a deterministic render." --- -Every clip below is a scene in [`examples/scenes/`](https://github.com/kiyeonjeon21/reframe/tree/main/examples/scenes). Render any of them yourself with `npx reframe-video render examples/scenes/.ts`. The full list — 66 scenes by category — is on the [Examples](/examples) page. +Every clip below is a scene in [`examples/scenes/`](https://github.com/kiyeonjeon21/reframe/tree/main/examples/scenes). Render any of them yourself with `npx reframe-video render examples/scenes/.ts`. The full list — 67 scenes by category — is on the [Examples](/examples) page. These gifs are the curated showcase. New renders accumulate here via `pnpm gallery` (the committed home, vs the gitignored `out/` scratch). diff --git a/docs/guides/directing-guide.md b/docs/guides/directing-guide.md index c19b1ad..8a07b34 100644 --- a/docs/guides/directing-guide.md +++ b/docs/guides/directing-guide.md @@ -76,6 +76,10 @@ Mind the tiers so you're not full-rendering to check small things: `compile` is the cheap visual look (one PNG, ~1s); `motion`/`trace` below need a finished render or reference video, so they're end-stage measurement, not the per-edit loop. +- `reframe manifest scene.ts` — every editable address (nodes, states, labels, beats). Read + it before patching an overlay so you target real, stable addresses. +- `reframe lint scene.ts --strict` — flags un-addressable motion and verifies the scene is a + pure function of time (no `Math.random()` / `Date`). A clean lint is the studio-readiness gate. - `reframe labels scene.ts` — every label → exact seconds. The timing source for audio + a sanity check that beats land when you think. - `reframe motion out.mp4` — speeds, static fraction, oscillation rhythm, spikes. A vague diff --git a/docs/guides/edsl-guide.md b/docs/guides/edsl-guide.md index 51c9534..9499d6a 100644 --- a/docs/guides/edsl-guide.md +++ b/docs/guides/edsl-guide.md @@ -664,7 +664,9 @@ with `examples/scenes/sfx-showcase.ts` and the samples with `sample-showcase.ts` ## Rules - Everything must be a pure function of time: no `Math.random()` (use `wiggle` - with a seed), no `Date`, no async. + with a seed), no `Date`, no async. `reframe lint --strict` enforces this (and + flags un-addressable motion); for structural overlays (add/remove/reorder/insert) + see the [regeneration contract](/guides/regen-contract). - Node ids must be unique; states/tweens may only reference existing ids and real props of that node type. - Overshoot pops are two steps: tween scale past 1 (`1.15`), then settle to 1. diff --git a/docs/guides/regen-contract.md b/docs/guides/regen-contract.md index c37cab7..dc46685 100644 --- a/docs/guides/regen-contract.md +++ b/docs/guides/regen-contract.md @@ -28,10 +28,26 @@ joint `name`s** for any character/device that survives the redesign — overlay edits (a retimed wave, a nudged limb angle) reference those exact ids. Renaming a joint orphans the edit, exactly like renaming a hand-authored node id. -## Structural edits (reorder / remove a beat) +## Structural edits (add / remove / reorder / insert) Beyond patching props and timing, an overlay can change the **structure** of the -timeline — and those edits survive regen the same way, keyed by stable labels: +timeline and the node tree — and those edits survive regen the same way, keyed by +stable labels. The full vocabulary: + +| field | does | orphans when | +|---|---|---| +| `addNodes` | append new node(s) at the root, painted on top | unknown `before`/`after` sibling id | +| `insertNodes` | splice node(s) at a position (`before`/`after` a sibling, or `index`) | unknown `before`/`after` sibling id | +| `removeNodes` | remove an overlay-*added* node (a base node is refused — hide it with `opacity: 0`) | a base node id, or an unknown id | +| `addTimeline` | append motion (a `par` with the base) onto existing nodes | the target node is missing | +| `insertTimeline` | splice a step/beat into a named beat (`{ into, before/after/index, step }`) | unknown `into`, target node, or sibling | +| `removeTimeline` | splice a beat/step out of its parent by label | unknown label, or base regen dropped it | +| `order` (on a beat) | re-sort beats within their parent `seq` | — (a normal `timeline.` patch) | + +The patch-existing ops (reorder/remove) and the create ops (add/insert) differ in +one way: insert/add **create** elements, so the overlay carries the full node + beat +JSON (a consumer or you author it; reframe does not generate the payload). A single +overlay can combine them — reorder, remove, retitle, and insert in one document: - **Reorder** — patch a beat's `order` (the existing `timeline..order` param): `{ "timeline": { "shot-2": { "order": 0 }, "shot-0": { "order": 2 } } }` @@ -50,6 +66,24 @@ timeline — and those edits survive regen the same way, keyed by stable labels: generate the shot payload. Unknown `into`/`before`/`after` or a step targeting a missing node is an orphan. +A combined overlay — reorder + remove + retitle, then insert a new unit: + +```jsonc +{ + "reframeOverlay": 1, + "target": "vector-montage", + "removeTimeline": ["shot-3"], + "timeline": { "shot-1": { "order": 2 }, "shot-2": { "order": 1 } }, + "nodes": { "shot-0-title": { "content": "REORDERED" } }, + "insertNodes": [ + { "after": "shot-2", "node": { "type": "group", "id": "shot-x", "props": { "opacity": 0 }, "children": [/* … */] } } + ], + "insertTimeline": [ + { "into": "montage", "after": "shot-2", "step": { "kind": "beat", "name": "shot-x", "nodes": ["shot-x"], "children": [/* … */] } } + ] +} +``` + These ride the addressable surface a `photoMontage` already exposes — each shot is the named beat `shot-${i}`, so `removeTimeline: ["shot-3"]` drops a shot (its layer just stays invisible — it never fades in) and an `order` patch reshuffles the cut, @@ -61,7 +95,17 @@ first slot drops its opening fade-up, since the crossfade offset was baked for t original order — a cosmetic detail, not a break.) See `examples/overlays/montage-restructure.json` (reorder + remove) and -`examples/overlays/montage-insert.json` (insert a hand-authored shot). +`examples/overlays/montage-insert.json` (insert a hand-authored shot) for the photo +montage, or `examples/scenes/vector-montage.ts` with `vector-montage-restructure.json` +/ `vector-montage-insert.json` for a pure-vector version that renders standalone (no +image assets): + +```bash +reframe verify-overlay examples/scenes/vector-montage.ts \ + examples/overlays/vector-montage-restructure.json # 4 applied, 0 orphaned +reframe render examples/scenes/vector-montage.ts \ + --overlay examples/overlays/vector-montage-insert.json +``` ## Tooling diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index f50f638..d2f9324 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -29,6 +29,13 @@ No clone needed — [`reframe-video` is on npm](https://www.npmjs.com/package/re ``` That overlay keeps working even after the scene is redesigned. + + ```bash + npx reframe-video manifest hello.ts # every editable address (nodes, labels, beats) + npx reframe-video lint hello.ts --strict # un-addressable motion + determinism gate + ``` + `manifest` lists the real addresses to patch; `lint` verifies every motion is addressable and the scene is a pure function of time (no `Math.random()` / `Date`). See the [CLI reference](/cli-reference) for the full surface. + ## Using Claude Code diff --git a/docs/the-loop.mdx b/docs/the-loop.mdx index 9774e5c..6259914 100644 --- a/docs/the-loop.mdx +++ b/docs/the-loop.mdx @@ -15,7 +15,7 @@ render: deterministic mp4 (same input → byte-identical frames) ◀── ove regenerates the base ``` -Everything is a pure function of time: `evaluate(scene, t)` — no wall clocks, no randomness without a seed, so scrubbing and distributed rendering come for free. +Everything is a pure function of time: `evaluate(scene, t)` — no wall clocks, no randomness without a seed, so scrubbing and distributed rendering come for free. `reframe lint` *enforces* it: the scene is compiled twice and any IR that differs (a `Math.random()` or `Date` baked into a prop) is flagged, so a scene that would render differently each time fails the gate before you ship it. ## How edits survive regeneration @@ -25,6 +25,25 @@ Overlays address the scene by **node id, state name, and timeline label** — ne 2. Contract broken → loud orphan report. 3. Never: silent edit loss, or a render failure caused by base drift. +## Restructure, not just re-skin + +An overlay isn't limited to patching props and timing — it can change the **structure** of the cut, keyed by the same stable addresses, and those edits survive regeneration too: + +- **Reorder** a beat — patch `timeline..order`; beats move as whole units, so child labels and any edits on them ride along. +- **Remove** a beat — `removeTimeline: ["shot-3"]` splices it out by label; later steps ripple up. +- **Insert** a whole new unit — `insertNodes` adds the node tree and `insertTimeline: { into: "montage", after: "shot-2", step }` splices its beat into a named parent. + +A montage is built so each shot is the self-contained beat `shot-${i}`, so an overlay can drop, reorder, or splice in a card without touching the base. Try it on the pure-vector demo (no assets needed): + +```bash +reframe verify-overlay examples/scenes/vector-montage.ts \ + examples/overlays/vector-montage-restructure.json # 4 applied, 0 orphaned +reframe render examples/scenes/vector-montage.ts \ + --overlay examples/overlays/vector-montage-insert.json # a new card spliced into the cut +``` + +See the [regeneration contract](/guides/regen-contract) for the full structural-edit vocabulary and overlay JSON schema. + ![The preview editor: knobs write into a non-destructive overlay](/assets/preview-editor.png) ## Address-keyed everything @@ -33,11 +52,11 @@ The same stable-address namespace powers more than hand edits: - **Batch**: every data row is an overlay. Row keys are addresses (`nodes.name.content`, `timeline.enter.duration`) — N personalized deterministic videos from one template, in parallel. - **Sound**: `audio.cues` anchor to timeline labels, so retime a step (or regenerate the scene) and the sound design moves with it. -- **Tooling**: `reframe manifest` dumps a scene's editable surface; `reframe verify-overlay` proves an overlay still applies after a regen; `reframe lint` flags motion that has no stable address. +- **Tooling**: `reframe manifest` dumps a scene's editable surface; `reframe verify-overlay` proves an overlay still applies after a regen; `reframe lint` flags motion that has no stable address *and* verifies the scene is deterministic. See the [CLI reference](/cli-reference) for the full command surface and the [programmatic API](/api) to embed it. ## Address it before you render -Because the scene is data, structure validates **before** any pixels — wrong props, unknown labels, off-frame addresses surface as actionable errors, and motion is computable straight from the IR. +Because the scene is data, structure validates **before** any pixels — wrong props, unknown labels, off-frame addresses surface as actionable errors, and motion is computable straight from the IR. The errors are structured, not prose: `reframe compile --json` returns `{ ok: false, kind, issues: [{ code, path, message }] }`, so an agent or a UI can point at the exact broken node. The regeneration contract — the exact rules an AI follows so edits reapply. diff --git a/examples/README.md b/examples/README.md index 4d506b7..2e1d3eb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,6 @@ # Examples -66 curated scenes, one per `.ts` file in [`scenes/`](scenes). Each is a single, self-contained, dependency-free document — render any of them: +67 curated scenes, one per `.ts` file in [`scenes/`](scenes). Each is a single, self-contained, dependency-free document — render any of them: ```bash pnpm reframe render examples/scenes/.ts # in this repo @@ -123,6 +123,7 @@ Also here: [`overlays/`](overlays) (human-edit layers), [`compositions/`](compos | `survive-base` | The edit-survival base (v1) with stable addresses + states. | | `survive-cut` | The edit-survival story: human cursor edits surviving an AI redesign. | | `survive-regen` | The base v2 — same stable addresses on a completely different layout. | +| `vector-montage` | Pure-vector montage (no assets): structural overlay edits — reorder, remove, insert a card — render standalone. Overlays: `vector-montage-restructure.json`, `vector-montage-insert.json`. | ## Other diff --git a/examples/overlays/vector-montage-insert.json b/examples/overlays/vector-montage-insert.json new file mode 100644 index 0000000..5e24f62 --- /dev/null +++ b/examples/overlays/vector-montage-insert.json @@ -0,0 +1,61 @@ +{ + "reframeOverlay": 1, + "name": "vector-montage-insert", + "target": "vector-montage", + "insertNodes": [ + { + "after": "shot-2", + "node": { + "type": "group", + "id": "shot-x", + "props": { "x": 960, "y": 540, "opacity": 0, "scale": 1 }, + "children": [ + { + "type": "rect", + "id": "shot-x-rect", + "props": { + "x": 0, "y": 0, "width": 1920, "height": 1080, "anchor": "center", + "fill": { "kind": "linear", "angle": 60, "stops": [ + { "offset": 0, "color": "#46E5A0" }, + { "offset": 1, "color": "#1E7F5C" } + ] } + } + }, + { + "type": "text", + "id": "shot-x-title", + "props": { + "x": 0, "y": 0, "anchor": "center", "content": "INSERTED", + "fontFamily": "Inter", "fontSize": 200, "fontWeight": 800, + "fill": "#FFFFFF", "letterSpacing": 6 + } + } + ] + } + } + ], + "insertTimeline": [ + { + "into": "montage", + "after": "shot-2", + "step": { + "kind": "beat", + "name": "shot-x", + "parallel": true, + "gap": -0.6, + "nodes": ["shot-x"], + "children": [ + { "kind": "tween", "target": "shot-x", "props": { "opacity": 1 }, "duration": 0.6, "ease": "linear", "label": "shot-x-in" }, + { "kind": "tween", "target": "shot-x", "props": { "scale": 1.06 }, "duration": 2.6, "ease": "easeInOutQuad", "label": "shot-x-kb" }, + { + "kind": "seq", + "children": [ + { "kind": "wait", "duration": 2.0 }, + { "kind": "tween", "target": "shot-x", "props": { "opacity": 0 }, "duration": 0.6, "ease": "linear", "label": "cross-x" } + ] + } + ] + } + } + ] +} diff --git a/examples/overlays/vector-montage-restructure.json b/examples/overlays/vector-montage-restructure.json new file mode 100644 index 0000000..e70d6ad --- /dev/null +++ b/examples/overlays/vector-montage-restructure.json @@ -0,0 +1,13 @@ +{ + "reframeOverlay": 1, + "name": "vector-montage-restructure", + "target": "vector-montage", + "removeTimeline": ["shot-3"], + "timeline": { + "shot-1": { "order": 2 }, + "shot-2": { "order": 1 } + }, + "nodes": { + "shot-0-title": { "content": "REORDERED" } + } +} diff --git a/examples/scenes/vector-montage.ts b/examples/scenes/vector-montage.ts new file mode 100644 index 0000000..8ebc120 --- /dev/null +++ b/examples/scenes/vector-montage.ts @@ -0,0 +1,76 @@ +// Vector montage: a pure-vector slideshow (gradient "cards", NO image assets) that +// mirrors `photoMontage`'s addressing contract — so STRUCTURAL overlay edits work and +// it renders standalone from the npm package. Each card is a SELF-CONTAINED named beat +// `shot-${i}`: its group starts at opacity 0, then fades in ∥ scales (a vector Ken +// Burns) ∥ fades out. All the shot beats are flattened under one `montage` beat, so an +// overlay can reorder a card (beat `order`), drop one (`removeTimeline`), or insert one +// (`insertNodes` + `insertTimeline`) and survive an AI regeneration of the base. +// +// Pure + deterministic (no Math.random / Date). The structural-edit overlays live in +// examples/overlays/vector-montage-restructure.json (reorder + remove) and +// vector-montage-insert.json (insert). See docs/guides/regen-contract.md. + +import { + scene, group, rect, text, + beat, seq, tween, wait, + linearGradient, radialGradient, + type Gradient, type NodeIR, type TimelineIR, +} from "@reframe/core"; + +const W = 1920, H = 1080; +const HOLD = 2.6; // seconds each card holds on screen +const CROSS = 0.6; // crossfade seconds (also each card's fade-in / fade-out) + +interface Card { word: string; fill: Gradient; } +const CARDS: Card[] = [ + { word: "CAPTURE", fill: linearGradient(["#FF5C3A", "#FFC24B"], { angle: 60 }) }, + { word: "COMPOSE", fill: linearGradient(["#9B7CFF", "#2A1E5C"], { angle: 120 }) }, + { word: "REFRAME", fill: linearGradient(["#00C2A8", "#3AA0FF"], { angle: 60 }) }, + { word: "RENDER", fill: linearGradient(["#FF4D6D", "#7C5CFF"], { angle: 90 }) }, +]; +const n = CARDS.length; + +// One card = a group `shot-${i}` (full-frame gradient rect + the word), centred on the +// frame and hidden (opacity 0) until its beat plays. Scaling the group scales about the +// frame centre, so the 1.06 Ken Burns never reveals an edge. +const cardNode = (i: number, c: Card): NodeIR => + group({ id: `shot-${i}`, x: W / 2, y: H / 2, opacity: 0, scale: 1 }, [ + rect({ id: `shot-${i}-rect`, x: 0, y: 0, width: W, height: H, anchor: "center", fill: c.fill }), + text({ id: `shot-${i}-title`, x: 0, y: 0, anchor: "center", content: c.word, + fontFamily: "Inter", fontSize: 200, fontWeight: 800, fill: "#FFFFFF", letterSpacing: 6 }), + ]); + +// One card's motion: a self-contained named beat. Its NAME is the stable `shot-${i}` +// address; every interior tween is labelled, so the motion is fully addressable and +// lint-clean. Adjacent cards overlap by `gap: -CROSS` so the outgoing fade-out and the +// incoming fade-in cross. +const cardBeat = (i: number): TimelineIR => + beat(`shot-${i}`, { nodes: [`shot-${i}`], parallel: true, ...(i > 0 && { gap: -CROSS }) }, [ + tween(`shot-${i}`, { opacity: 1 }, { duration: CROSS, ease: "linear", label: `shot-${i}-in` }), + tween(`shot-${i}`, { scale: 1.06 }, { duration: HOLD, ease: "easeInOutQuad", label: `shot-${i}-kb` }), + seq( + wait(HOLD - CROSS), + tween(`shot-${i}`, { opacity: 0 }, { duration: CROSS, ease: "linear", + label: i < n - 1 ? `cross-${i + 1}` : `shot-${i}-out` }), + ), + ]); + +export default scene({ + id: "vector-montage", + size: { width: W, height: H }, + fps: 30, + background: "#06070C", + nodes: [ + ...CARDS.map((c, i) => cardNode(i, c)), + // static cinematic vignette on top (no motion — purely a grade) + rect({ id: "vignette", x: 0, y: 0, width: W, height: H, + fill: radialGradient([{ offset: 0.55, color: "#FFFFFF" }, { offset: 1, color: "#5A5A5A" }], + { cx: 0.5, cy: 0.5, r: 0.75 }), + blend: "multiply" }), + ], + // The shots are the DIRECT children of the "montage" beat (a beat groups its children + // as a seq), so the play-order list is addressable as the beat "montage": an overlay + // can `insertTimeline { into: "montage", ... }`, and reorder / removeTimeline operate + // on these same `shot-${i}` beats. + timeline: seq(beat("montage", { nodes: CARDS.map((_, i) => `shot-${i}`) }, CARDS.map((_, i) => cardBeat(i)))), +});