reframe-video 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +77 -0
  3. package/assets/fonts/inter-400.woff2 +0 -0
  4. package/assets/fonts/inter-700.woff2 +0 -0
  5. package/assets/fonts/inter-800.woff2 +0 -0
  6. package/assets/sfx/LICENSE.md +12 -0
  7. package/assets/sfx/click_002.ogg +0 -0
  8. package/assets/sfx/click_003.ogg +0 -0
  9. package/assets/sfx/click_004.ogg +0 -0
  10. package/assets/sfx/confirmation_001.ogg +0 -0
  11. package/assets/sfx/keypress-001.wav +0 -0
  12. package/assets/sfx/keypress-004.wav +0 -0
  13. package/assets/sfx/keypress-007.wav +0 -0
  14. package/assets/sfx/keypress-010.wav +0 -0
  15. package/assets/sfx/keypress-014.wav +0 -0
  16. package/dist/analyze.js +344 -0
  17. package/dist/bin.js +1677 -0
  18. package/dist/browserEntry.js +532 -0
  19. package/dist/cli.js +1205 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +889 -0
  22. package/dist/renderer-canvas.js +89 -0
  23. package/dist/types/audio.d.ts +53 -0
  24. package/dist/types/behaviors.d.ts +7 -0
  25. package/dist/types/compile.d.ts +38 -0
  26. package/dist/types/compose.d.ts +64 -0
  27. package/dist/types/dsl.d.ts +66 -0
  28. package/dist/types/evaluate.d.ts +59 -0
  29. package/dist/types/index.d.ts +9 -0
  30. package/dist/types/interpolate.d.ts +12 -0
  31. package/dist/types/ir.d.ts +213 -0
  32. package/dist/types/validate.d.ts +12 -0
  33. package/guides/edsl-guide.md +202 -0
  34. package/guides/regen-contract.md +18 -0
  35. package/package.json +55 -0
  36. package/preview/index.html +60 -0
  37. package/preview/src/main.ts +162 -0
  38. package/preview/src/panel.ts +347 -0
  39. package/preview/src/store.ts +220 -0
  40. package/preview/src/virtual.d.ts +4 -0
  41. package/preview/vite.config.ts +52 -0
@@ -0,0 +1,202 @@
1
+ # reframe eDSL guide
2
+
3
+ You write a motion-graphics scene as **declarative data** using the reframe
4
+ TypeScript eDSL. Your output is a single `.ts` file that default-exports a
5
+ `scene({...})` call. Everything imports from `@reframe/core`.
6
+
7
+ ```ts
8
+ import { scene, group, rect, ellipse, line, text,
9
+ seq, par, stagger, to, tween, wait,
10
+ oscillate, wiggle } from "@reframe/core";
11
+
12
+ export default scene({
13
+ id: "my-scene",
14
+ size: { width: 1920, height: 1080 },
15
+ fps: 30,
16
+ background: "#101014",
17
+ nodes: [/* ... */],
18
+ states: {/* ... */},
19
+ initial: "hidden",
20
+ timeline: seq(/* ... */),
21
+ behaviors: [/* optional */],
22
+ });
23
+ ```
24
+
25
+ ## Nodes
26
+
27
+ Factories return plain data. Every node needs a unique `id`.
28
+
29
+ - `rect({ id, x, y, width, height, fill?, stroke?, strokeWidth?, radius?, opacity?, rotation?, scale?, anchor? })`
30
+ - `ellipse({ id, x, y, width, height, fill?, stroke?, strokeWidth?, ... })`
31
+ - `line({ id, x1, y1, x2, y2, stroke, strokeWidth?, opacity?, progress? })` —
32
+ `progress` 0..1 draws the line on (1 = full line).
33
+ - `text({ id, x, y, content, contentDecimals?, fontFamily, fontSize, fontWeight?, fill?, letterSpacing?, ... })` —
34
+ `content` may be a number; numeric content interpolates (count-up) and renders
35
+ via `toFixed(contentDecimals ?? 0)`. For a "8.2"-style label, set
36
+ `contentDecimals: 1`.
37
+ - `group({ id, x, y, opacity?, rotation?, scale?, anchor? }, children)` — children's
38
+ coordinates are relative to the group; group opacity/transform multiply down.
39
+
40
+ `anchor` controls placement and scale/rotation origin:
41
+ `"top-left"` (default) | `"top-center"` | `"top-right"` | `"center-left"` |
42
+ `"center"` | `"center-right"` | `"bottom-left"` | `"bottom-center"` | `"bottom-right"`.
43
+ Example: a bar that grows upward = `anchor: "bottom-left"` + animate `height`.
44
+ Font: use `fontFamily: "Inter"` (weights 400/700/800 are available).
45
+
46
+ ## States: declare looks, not motion
47
+
48
+ Base props on nodes describe the **finished design**. A state is a sparse
49
+ override — only the props that differ:
50
+
51
+ ```ts
52
+ states: {
53
+ hidden: { title: { opacity: 0, y: 560 }, bar: { height: 0 } },
54
+ shown: { title: { opacity: 1, y: 540 }, bar: { height: 300 } },
55
+ },
56
+ initial: "hidden",
57
+ ```
58
+
59
+ `to("shown", { duration, ease, stagger?, filter? })` synthesizes a transition
60
+ from each node's *current* value to the state's value. `stagger: 0.1` offsets
61
+ the affected nodes 0.1s apart in declaration order. `filter: ["a", "b"]`
62
+ restricts the transition to those nodes. States are plain objects — generate
63
+ them with normal TS (`Object.fromEntries`, `.map`) for data-driven scenes.
64
+
65
+ ## Timeline: compose time
66
+
67
+ - `seq(...steps)` — one after another.
68
+ - `par(...steps)` — all start together; ends when the longest ends.
69
+ - `stagger(interval, ...steps)` — like `par` but each child starts `interval` later.
70
+ - `to(stateName, opts)` — transition into a named state (see above).
71
+ - `tween(nodeId, { prop: value, ... }, { duration, ease })` — low-level escape hatch
72
+ for one node. Colors (`"#rrggbb"`) interpolate; numbers interpolate.
73
+ - `wait(seconds)` — hold.
74
+
75
+ Eases: `linear`, `easeIn/Out/InOutQuad`, `easeIn/Out/InOutCubic`,
76
+ `easeIn/Out/InOutQuart`, `easeIn/Out/InOutExpo`, or `{ cubicBezier: [x1,y1,x2,y2] }`.
77
+ Decelerating entrances = `easeOut*`, accelerating exits = `easeIn*`.
78
+ Scene duration is inferred from the timeline.
79
+
80
+ ## Behaviors: continuous motion during holds
81
+
82
+ Composed additively on top of the timeline:
83
+
84
+ - `oscillate(nodeId, prop, { amplitude, frequency, phase? }, window?)` — sine.
85
+ - `wiggle(nodeId, prop, { amplitude, frequency, seed }, window?)` — smooth seeded noise.
86
+
87
+ The optional 4th argument `{ from?, until?, ramp? }` limits the behavior to a
88
+ time window (seconds) with a linear fade of `ramp` (default 0.2s) at each
89
+ bound — e.g. a pulse only during the hold:
90
+ `oscillate("title", "scale", { amplitude: 0.04, frequency: 1.2 }, { from: 1.5, until: 3.5 })`.
91
+ Omit the window to run for the whole scene.
92
+
93
+ ## Audio (optional)
94
+
95
+ Label-anchored sound design — cues follow retiming and regeneration:
96
+
97
+ ```ts
98
+ audio: {
99
+ bgm: { synth: "ambient-pad", gain: 0.3, fadeIn: 1, fadeOut: 2, duck: { depth: 0.5 } },
100
+ cues: [
101
+ { at: "enter", sfx: "whoosh", gain: 0.8 }, // anchored to a timeline label
102
+ { at: "enter", offset: 0.2, sfx: "pop" },
103
+ { at: 5.0, file: "keypress-001.wav", gain: 0.5 }, // absolute seconds; file from assets/sfx/
104
+ ],
105
+ }
106
+ ```
107
+
108
+ Procedural sfx names: `whoosh` `pop` `tick` `rise` `shimmer` `thud` (deterministic,
109
+ seedable via `params: { seed }`). Exactly one of `sfx`/`file` per cue.
110
+
111
+ ## Rules
112
+
113
+ - Everything must be a pure function of time: no `Math.random()` (use `wiggle`
114
+ with a seed), no `Date`, no async.
115
+ - Node ids must be unique; states/tweens may only reference existing ids and
116
+ real props of that node type.
117
+ - Overshoot pops are two steps: tween scale past 1 (`1.15`), then settle to 1.
118
+ - When a node enters by scaling from 0, start it at `opacity: 0` too and fade
119
+ in alongside — a scale-0 shape can still rasterize as a 1px dot at frame 0.
120
+
121
+ ## Worked example A — countdown (3, 2, 1, GO!)
122
+
123
+ ```ts
124
+ import { scene, ellipse, text, seq, par, tween, wait } from "@reframe/core";
125
+
126
+ const numbers = ["3", "2", "1"];
127
+
128
+ export default scene({
129
+ id: "countdown",
130
+ size: { width: 1920, height: 1080 },
131
+ fps: 30,
132
+ background: "#101014",
133
+ nodes: [
134
+ ellipse({ id: "ring", x: 960, y: 540, width: 360, height: 360,
135
+ anchor: "center", stroke: "#3B82F6", strokeWidth: 10, opacity: 0 }),
136
+ ...numbers.map((n, i) =>
137
+ text({ id: `num-${i}`, x: 960, y: 540, anchor: "center", content: n,
138
+ fontFamily: "Inter", fontSize: 220, fontWeight: 800, fill: "#FFFFFF",
139
+ opacity: 0, scale: 0.5 })),
140
+ text({ id: "go", x: 960, y: 540, anchor: "center", content: "GO!",
141
+ fontFamily: "Inter", fontSize: 320, fontWeight: 800, fill: "#FF4D00",
142
+ opacity: 0, scale: 0.5 }),
143
+ ],
144
+ timeline: seq(
145
+ tween("ring", { opacity: 1 }, { duration: 0.3, ease: "easeOutCubic" }),
146
+ ...numbers.map((_, i) =>
147
+ seq(
148
+ par(
149
+ tween(`num-${i}`, { opacity: 1 }, { duration: 0.15, ease: "easeOutQuad" }),
150
+ tween(`num-${i}`, { scale: 1.1 }, { duration: 0.2, ease: "easeOutCubic" }),
151
+ ),
152
+ tween(`num-${i}`, { scale: 1 }, { duration: 0.1, ease: "easeInOutQuad" }),
153
+ wait(0.45),
154
+ tween(`num-${i}`, { opacity: 0, scale: 0.7 }, { duration: 0.1, ease: "easeInQuad" }),
155
+ )),
156
+ par(
157
+ tween("go", { opacity: 1 }, { duration: 0.15, ease: "easeOutQuad" }),
158
+ tween("go", { scale: 1.15 }, { duration: 0.25, ease: "easeOutCubic" }),
159
+ tween("ring", { scale: 1.6, opacity: 0 }, { duration: 0.4, ease: "easeOutCubic" }),
160
+ ),
161
+ tween("go", { scale: 1 }, { duration: 0.15, ease: "easeInOutQuad" }),
162
+ wait(0.6),
163
+ ),
164
+ });
165
+ ```
166
+
167
+ ## Worked example B — badge pop (overshoot + wiggle + drop)
168
+
169
+ ```ts
170
+ import { scene, group, rect, text, seq, par, tween, wait, oscillate } from "@reframe/core";
171
+
172
+ export default scene({
173
+ id: "badge-pop",
174
+ size: { width: 1920, height: 1080 },
175
+ fps: 30,
176
+ background: "#15151A",
177
+ nodes: [
178
+ group({ id: "badge", x: 960, y: 540, scale: 0, opacity: 0 }, [
179
+ rect({ id: "plate", x: 0, y: 0, width: 420, height: 160,
180
+ anchor: "center", fill: "#E11D48", radius: 28 }),
181
+ text({ id: "label", x: 0, y: 6, anchor: "center", content: "NEW",
182
+ fontFamily: "Inter", fontSize: 88, fontWeight: 800, fill: "#FFFFFF",
183
+ letterSpacing: 6 }),
184
+ ]),
185
+ ],
186
+ timeline: seq(
187
+ wait(0.2),
188
+ par(
189
+ tween("badge", { opacity: 1 }, { duration: 0.15, ease: "easeOutQuad" }),
190
+ tween("badge", { scale: 1.18 }, { duration: 0.28, ease: "easeOutCubic" }),
191
+ ),
192
+ tween("badge", { scale: 1 }, { duration: 0.18, ease: "easeInOutQuad" }),
193
+ wait(1.6),
194
+ par(
195
+ tween("badge", { y: 720 }, { duration: 0.35, ease: "easeInCubic" }),
196
+ tween("badge", { opacity: 0 }, { duration: 0.35, ease: "easeInQuad" }),
197
+ ),
198
+ wait(0.2),
199
+ ),
200
+ behaviors: [oscillate("badge", "rotation", { amplitude: 2.5, frequency: 0.8 })],
201
+ });
202
+ ```
@@ -0,0 +1,18 @@
1
+ # Regeneration contract
2
+
3
+ Overlay documents address nodes by `id` and states by name. For human edits to
4
+ survive an AI regeneration of the base scene, the regeneration prompt must
5
+ include the contract below, alongside the current scene IR (JSON or eDSL
6
+ source):
7
+
8
+ > You are regenerating an existing reframe scene. The current scene is
9
+ > provided. You may change layout, timing, styling, and add new nodes freely —
10
+ > but for every node whose concept survives your redesign, you MUST keep its
11
+ > `id` unchanged, and you MUST keep state names unchanged. The same applies to
12
+ > timeline step `label`s. Node ids, state names, and timeline labels are
13
+ > stable addresses that external edit layers reference; renaming one silently
14
+ > orphans a human's edit.
15
+
16
+ When the contract is broken anyway, `composeScene` skips the affected edits
17
+ and reports them as orphans with the known-ids list — loud, diagnosable,
18
+ never a silent drop and never a render failure.
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "reframe-video",
3
+ "version": "0.1.0",
4
+ "description": "Declarative motion graphics that AI can write and humans can tweak — human edits survive AI regeneration. Deterministic mp4 renders from a plain-data scene format.",
5
+ "keywords": [
6
+ "motion-graphics",
7
+ "video",
8
+ "animation",
9
+ "generative",
10
+ "ai",
11
+ "deterministic",
12
+ "claude",
13
+ "ffmpeg"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Kiyeon Jeon",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/kiyeonjeon21/reframe.git"
20
+ },
21
+ "homepage": "https://github.com/kiyeonjeon21/reframe#readme",
22
+ "type": "module",
23
+ "bin": {
24
+ "reframe": "./dist/bin.js",
25
+ "reframe-video": "./dist/bin.js"
26
+ },
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ }
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "assets",
36
+ "guides",
37
+ "preview"
38
+ ],
39
+ "engines": {
40
+ "node": ">=20"
41
+ },
42
+ "scripts": {
43
+ "build": "tsx scripts/build.ts",
44
+ "typecheck": "tsc --noEmit"
45
+ },
46
+ "dependencies": {
47
+ "esbuild": "^0.27.0",
48
+ "playwright": "^1.60.0",
49
+ "vite": "^6.0.0"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^22.0.0",
53
+ "tsx": "^4.19.0"
54
+ }
55
+ }
@@ -0,0 +1,60 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>reframe preview</title>
6
+ <style>
7
+ @font-face { font-family: "Inter"; font-weight: 400; src: url("../assets/fonts/inter-400.woff2") format("woff2"); }
8
+ @font-face { font-family: "Inter"; font-weight: 700; src: url("../assets/fonts/inter-700.woff2") format("woff2"); }
9
+ @font-face { font-family: "Inter"; font-weight: 800; src: url("../assets/fonts/inter-800.woff2") format("woff2"); }
10
+ body { margin: 0; background: #1b1b20; color: #ddd; font: 13px system-ui; display: flex; flex-direction: column; height: 100vh; }
11
+ #content { flex: 1; display: flex; min-height: 0; }
12
+ #stage-wrap { flex: 1; display: flex; align-items: center; justify-content: center; min-width: 0; padding: 16px; }
13
+ canvas { max-width: 100%; max-height: 100%; box-shadow: 0 4px 32px rgba(0,0,0,.5); }
14
+ #bar { display: flex; gap: 12px; align-items: center; padding: 12px 16px; background: #232329; }
15
+ #scrub { flex: 1; }
16
+ select, button, input[type=text], input[type=number] { background: #2e2e36; color: #ddd; border: 1px solid #444; border-radius: 4px; padding: 3px 8px; font: 12px system-ui; }
17
+ input[type=number] { width: 64px; }
18
+ input[type=color] { width: 40px; height: 22px; padding: 0; border: 1px solid #444; background: none; }
19
+ #time { font-variant-numeric: tabular-nums; min-width: 96px; text-align: right; }
20
+
21
+ /* inspector */
22
+ #panel { width: 320px; overflow-y: auto; background: #202026; border-left: 1px solid #333; padding: 10px 12px 24px; }
23
+ #panel h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #8a8a96; margin: 16px 0 6px; }
24
+ .tree-item { padding: 2px 6px; border-radius: 4px; cursor: pointer; display: flex; justify-content: space-between; }
25
+ .tree-item:hover { background: #2a2a32; }
26
+ .tree-item.selected { background: #31313c; color: #fff; }
27
+ .badge { color: #ffb454; font-size: 11px; }
28
+ .prop-row { display: flex; align-items: center; gap: 6px; margin: 3px 0; }
29
+ .prop-row label { flex: 1; color: #aab; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
30
+ .prop-row label .scope { color: #7d9aff; }
31
+ .prop-row.edited input, .prop-row.edited select { border-color: #ffb454; }
32
+ .prop-row.dead { opacity: 0.45; }
33
+ .hint { font-size: 11px; color: #777; margin: -2px 0 4px; }
34
+ .revert { background: none; border: none; color: #888; cursor: pointer; padding: 0 2px; }
35
+ .revert:hover { color: #ff6b6b; }
36
+ .step-card, .behavior-card { background: #26262d; border-radius: 6px; padding: 6px 8px; margin: 6px 0; }
37
+ .step-card .kind { color: #8a8a96; font-size: 11px; }
38
+ #report { font-size: 12px; }
39
+ #report .orphan { color: #ff7b72; margin: 2px 0; }
40
+ #report .warning { color: #ffb454; margin: 2px 0; }
41
+ #report .error { color: #ff7b72; background: #3a2226; border-radius: 4px; padding: 6px; margin: 4px 0; white-space: pre-wrap; }
42
+ #report details { color: #8a8a96; }
43
+ #io { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
44
+ #overlay-name { width: 100%; margin-bottom: 6px; box-sizing: border-box; }
45
+ </style>
46
+ </head>
47
+ <body>
48
+ <div id="content">
49
+ <div id="stage-wrap"><canvas id="canvas"></canvas></div>
50
+ <div id="panel"></div>
51
+ </div>
52
+ <div id="bar">
53
+ <select id="scene-select"></select>
54
+ <button id="play">play</button>
55
+ <input id="scrub" type="range" min="0" max="1" step="0.001" value="0" />
56
+ <span id="time">0.000 / 0.000</span>
57
+ </div>
58
+ <script type="module" src="/src/main.ts"></script>
59
+ </body>
60
+ </html>
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Preview + editor shell: scene picker, scrub/play, canvas, selection
3
+ * highlight. Edits live in EditorStore as an OverlayDoc draft; everything
4
+ * here only reads store.compiled. rAF lives ONLY in this file — the export
5
+ * path never uses wall-clock time.
6
+ */
7
+
8
+ import { evaluate, type DisplayOp, type SceneIR } from "@reframe/core";
9
+ import { renderFrame } from "@reframe/renderer-canvas";
10
+ import { userScenes } from "virtual:reframe-user-scenes";
11
+ import { buildPanel } from "./panel.js";
12
+ import { EditorStore } from "./store.js";
13
+
14
+ interface SceneEntry {
15
+ label: string;
16
+ load: () => Promise<{ default: SceneIR }>;
17
+ }
18
+
19
+ const exampleModules = ({} as Record<string, () => Promise<{ default: SceneIR }>>);
20
+ const modules: Record<string, SceneEntry> = {};
21
+ for (const path of Object.keys(exampleModules).sort()) {
22
+ modules[path] = { label: path.split("/").pop()!.replace(".ts", ""), load: exampleModules[path]! };
23
+ }
24
+ // scenes from the directory `reframe preview` was invoked in
25
+ for (const { name, load } of userScenes) {
26
+ modules[`user:${name}`] ??= { label: `${name} (cwd)`, load };
27
+ }
28
+
29
+ const canvas = document.getElementById("canvas") as HTMLCanvasElement;
30
+ const ctx = canvas.getContext("2d")!;
31
+ const select = document.getElementById("scene-select") as HTMLSelectElement;
32
+ const playBtn = document.getElementById("play") as HTMLButtonElement;
33
+ const scrub = document.getElementById("scrub") as HTMLInputElement;
34
+ const timeLabel = document.getElementById("time") as HTMLSpanElement;
35
+ const panelRoot = document.getElementById("panel") as HTMLDivElement;
36
+
37
+ let store: EditorStore | null = null;
38
+ let panel: ReturnType<typeof buildPanel> | null = null;
39
+ let t = 0;
40
+ let playing = false;
41
+ let lastTick = 0;
42
+
43
+ for (const [key, entry] of Object.entries(modules)) {
44
+ const option = document.createElement("option");
45
+ option.value = key;
46
+ option.textContent = entry.label;
47
+ select.appendChild(option);
48
+ }
49
+
50
+ async function loadScene(path: string) {
51
+ const mod = await modules[path]!.load();
52
+ store = new EditorStore(mod.default);
53
+ (window as unknown as { __store: EditorStore }).__store = store; // debug/testing hook
54
+ panel = buildPanel(store, panelRoot);
55
+ canvas.width = store.compiled.ir.size.width;
56
+ canvas.height = store.compiled.ir.size.height;
57
+ store.subscribe((kind) => {
58
+ t = Math.min(t, store!.compiled.duration);
59
+ if (kind === "structure") panel!.rebuild();
60
+ else panel!.refreshReport();
61
+ draw();
62
+ });
63
+ await document.fonts.ready;
64
+ t = 0;
65
+ panel.rebuild();
66
+ draw();
67
+ }
68
+
69
+ function applyMat(m: number[], x: number, y: number): [number, number] {
70
+ return [m[0]! * x + m[2]! * y + m[4]!, m[1]! * x + m[3]! * y + m[5]!];
71
+ }
72
+
73
+ function opCorners(op: DisplayOp): [number, number][] {
74
+ switch (op.type) {
75
+ case "rect":
76
+ case "ellipse": {
77
+ const { offsetX: x, offsetY: y, width: w, height: h } = op;
78
+ return [[x, y], [x + w, y], [x + w, y + h], [x, y + h]].map(([px, py]) =>
79
+ applyMat(op.transform, px!, py!),
80
+ );
81
+ }
82
+ case "line":
83
+ return [applyMat(op.transform, op.x1, op.y1), applyMat(op.transform, op.x2, op.y2)];
84
+ case "text": {
85
+ ctx.font = `${op.fontWeight} ${op.fontSize}px ${op.fontFamily}`;
86
+ const w = ctx.measureText(op.content).width;
87
+ const h = op.fontSize * 1.2;
88
+ const x0 = op.align === "right" ? -w : op.align === "center" ? -w / 2 : 0;
89
+ const y0 = op.baseline === "bottom" ? -h : op.baseline === "middle" ? -h / 2 : 0;
90
+ return [[x0, y0], [x0 + w, y0], [x0 + w, y0 + h], [x0, y0 + h]].map(([px, py]) =>
91
+ applyMat(op.transform, px!, py!),
92
+ );
93
+ }
94
+ }
95
+ }
96
+
97
+ function draw() {
98
+ if (!store) return;
99
+ renderFrame(ctx, store.compiled, t);
100
+
101
+ if (store.selectedId) {
102
+ const ops = evaluate(store.compiled, t).filter((op) => op.id === store!.selectedId);
103
+ ctx.save();
104
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
105
+ ctx.strokeStyle = "#7d9aff";
106
+ ctx.lineWidth = 2;
107
+ ctx.setLineDash([6, 4]);
108
+ for (const op of ops) {
109
+ const corners = opCorners(op);
110
+ ctx.beginPath();
111
+ corners.forEach(([x, y], i) => (i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)));
112
+ if (corners.length > 2) ctx.closePath();
113
+ ctx.stroke();
114
+ }
115
+ ctx.restore();
116
+ }
117
+
118
+ const duration = store.compiled.duration;
119
+ scrub.value = String(duration ? t / duration : 0);
120
+ timeLabel.textContent = `${t.toFixed(3)} / ${duration.toFixed(3)}`;
121
+ }
122
+
123
+ function tick(now: number) {
124
+ if (playing && store) {
125
+ t += (now - lastTick) / 1000;
126
+ if (t > store.compiled.duration) t = 0; // loop
127
+ draw();
128
+ }
129
+ lastTick = now;
130
+ requestAnimationFrame(tick);
131
+ }
132
+ requestAnimationFrame(tick);
133
+
134
+ playBtn.addEventListener("click", () => {
135
+ playing = !playing;
136
+ playBtn.textContent = playing ? "pause" : "play";
137
+ });
138
+
139
+ scrub.addEventListener("input", () => {
140
+ if (!store) return;
141
+ playing = false;
142
+ playBtn.textContent = "play";
143
+ t = Number(scrub.value) * store.compiled.duration;
144
+ draw();
145
+ });
146
+
147
+ let currentPath = "";
148
+ select.addEventListener("change", () => {
149
+ if (store?.dirty && !confirm("Discard unsaved overlay edits?")) {
150
+ select.value = currentPath;
151
+ return;
152
+ }
153
+ currentPath = select.value;
154
+ void loadScene(select.value);
155
+ });
156
+ if (Object.keys(modules).length === 0) {
157
+ panelRoot.innerHTML =
158
+ "<p style='padding:12px;color:#aab'>No scenes found. Scaffold one in this directory with <code>reframe new my-scene</code>, then reload.</p>";
159
+ } else {
160
+ currentPath = select.value || Object.keys(modules)[0]!;
161
+ void loadScene(currentPath);
162
+ }