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.
- package/LICENSE +24 -0
- package/README.md +77 -0
- package/assets/fonts/inter-400.woff2 +0 -0
- package/assets/fonts/inter-700.woff2 +0 -0
- package/assets/fonts/inter-800.woff2 +0 -0
- package/assets/sfx/LICENSE.md +12 -0
- package/assets/sfx/click_002.ogg +0 -0
- package/assets/sfx/click_003.ogg +0 -0
- package/assets/sfx/click_004.ogg +0 -0
- package/assets/sfx/confirmation_001.ogg +0 -0
- package/assets/sfx/keypress-001.wav +0 -0
- package/assets/sfx/keypress-004.wav +0 -0
- package/assets/sfx/keypress-007.wav +0 -0
- package/assets/sfx/keypress-010.wav +0 -0
- package/assets/sfx/keypress-014.wav +0 -0
- package/dist/analyze.js +344 -0
- package/dist/bin.js +1677 -0
- package/dist/browserEntry.js +532 -0
- package/dist/cli.js +1205 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +889 -0
- package/dist/renderer-canvas.js +89 -0
- package/dist/types/audio.d.ts +53 -0
- package/dist/types/behaviors.d.ts +7 -0
- package/dist/types/compile.d.ts +38 -0
- package/dist/types/compose.d.ts +64 -0
- package/dist/types/dsl.d.ts +66 -0
- package/dist/types/evaluate.d.ts +59 -0
- package/dist/types/index.d.ts +9 -0
- package/dist/types/interpolate.d.ts +12 -0
- package/dist/types/ir.d.ts +213 -0
- package/dist/types/validate.d.ts +12 -0
- package/guides/edsl-guide.md +202 -0
- package/guides/regen-contract.md +18 -0
- package/package.json +55 -0
- package/preview/index.html +60 -0
- package/preview/src/main.ts +162 -0
- package/preview/src/panel.ts +347 -0
- package/preview/src/store.ts +220 -0
- package/preview/src/virtual.d.ts +4 -0
- 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
|
+
}
|