reframe-video 0.1.3 → 0.3.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/assets/sfx/LICENSE.md +2 -1
- package/assets/sfx/bong_001.ogg +0 -0
- package/assets/sfx/click_001.ogg +0 -0
- package/assets/sfx/confirmation_002.ogg +0 -0
- package/assets/sfx/confirmation_003.ogg +0 -0
- package/assets/sfx/confirmation_004.ogg +0 -0
- package/assets/sfx/footstep_001.ogg +0 -0
- package/assets/sfx/footstep_002.ogg +0 -0
- package/assets/sfx/footstep_003.ogg +0 -0
- package/assets/sfx/glass_001.ogg +0 -0
- package/assets/sfx/maximize_001.ogg +0 -0
- package/assets/sfx/maximize_002.ogg +0 -0
- package/assets/sfx/maximize_005.ogg +0 -0
- package/assets/sfx/maximize_009.ogg +0 -0
- package/assets/sfx/open_001.ogg +0 -0
- package/assets/sfx/pluck_001.ogg +0 -0
- package/assets/sfx/pluck_002.ogg +0 -0
- package/assets/sfx/select_001.ogg +0 -0
- package/assets/sfx/select_002.ogg +0 -0
- package/assets/sfx/select_003.ogg +0 -0
- package/dist/bin.js +271 -49
- package/dist/browserEntry.js +179 -68
- package/dist/cli.js +445 -85
- package/dist/index.js +1187 -116
- package/dist/labels.js +606 -0
- package/dist/renderer-canvas.js +15 -0
- package/dist/trace-cli.js +9 -9
- package/dist/types/audio.d.ts +9 -0
- package/dist/types/characterPreset.d.ts +39 -0
- package/dist/types/compile.d.ts +1 -0
- package/dist/types/compose.d.ts +18 -2
- package/dist/types/composeComposition.d.ts +27 -0
- package/dist/types/devicePreset.d.ts +65 -0
- package/dist/types/dsl.d.ts +12 -1
- package/dist/types/evaluate.d.ts +32 -0
- package/dist/types/figure.d.ts +32 -0
- package/dist/types/index.d.ts +9 -3
- package/dist/types/interpolate.d.ts +3 -2
- package/dist/types/ir.d.ts +68 -0
- package/dist/types/motionOps.d.ts +36 -0
- package/dist/types/path.d.ts +7 -3
- package/dist/types/rig.d.ts +87 -0
- package/dist/types/validate.d.ts +4 -1
- package/guides/edsl-guide.md +54 -1
- package/guides/regen-contract.md +11 -0
- package/package.json +1 -1
- package/preview/index.html +56 -3
- package/preview/src/main.ts +1132 -46
- package/preview/src/panel.ts +478 -8
- package/preview/src/store.ts +323 -6
package/preview/src/panel.ts
CHANGED
|
@@ -6,21 +6,34 @@
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
EASE_NAMES,
|
|
9
|
+
MOTION_OPS,
|
|
9
10
|
PROPS_BY_TYPE,
|
|
10
11
|
isColor,
|
|
12
|
+
composeScene,
|
|
13
|
+
compileScene,
|
|
14
|
+
evaluate,
|
|
15
|
+
type CompiledScene,
|
|
16
|
+
type MotionOpName,
|
|
11
17
|
type NodeIR,
|
|
12
18
|
type OverlayDoc,
|
|
13
19
|
type PropValue,
|
|
20
|
+
type SceneIR,
|
|
14
21
|
type TimelineIR,
|
|
15
22
|
} from "@reframe/core";
|
|
16
23
|
import type { EditorStore } from "./store.js";
|
|
17
24
|
|
|
18
|
-
const NUMERIC_DEFAULTS: Record<string, number> = { opacity: 1, scale: 1, rotation: 0 };
|
|
25
|
+
const NUMERIC_DEFAULTS: Record<string, number> = { opacity: 1, scale: 1, scaleX: 1, scaleY: 1, rotation: 0, skewX: 0, skewY: 0 };
|
|
19
26
|
const RANGES: Record<string, [number, number, number]> = {
|
|
20
27
|
opacity: [0, 1, 0.01],
|
|
21
28
|
progress: [0, 1, 0.01],
|
|
22
29
|
scale: [0, 3, 0.01],
|
|
30
|
+
scaleX: [0, 3, 0.01],
|
|
31
|
+
scaleY: [0, 3, 0.01],
|
|
23
32
|
rotation: [-360, 360, 1],
|
|
33
|
+
skewX: [-60, 60, 1],
|
|
34
|
+
skewY: [-60, 60, 1],
|
|
35
|
+
curviness: [0, 2, 0.05],
|
|
36
|
+
amount: [0, 3, 0.1],
|
|
24
37
|
};
|
|
25
38
|
const ANCHORS = [
|
|
26
39
|
"top-left", "top-center", "top-right",
|
|
@@ -53,6 +66,107 @@ function findNode(nodes: NodeIR[], id: string): NodeIR | null {
|
|
|
53
66
|
return null;
|
|
54
67
|
}
|
|
55
68
|
|
|
69
|
+
type Bz = [number, number, number, number];
|
|
70
|
+
const round3 = (n: number) => Math.round(n * 1000) / 1000;
|
|
71
|
+
const cubic = (t: number, a: number, b: number, c: number, d: number) => {
|
|
72
|
+
const u = 1 - t;
|
|
73
|
+
return u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * c + t * t * t * d;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** A draggable cubic-bezier ease editor (GSAP CustomEase pattern). Dragging a
|
|
77
|
+
* control point writes {cubicBezier:[x1,y1,x2,y2]} via store.setTimelineEase. */
|
|
78
|
+
function buildEaseEditor(label: string, current: unknown, store: EditorStore): HTMLCanvasElement {
|
|
79
|
+
const S = 150;
|
|
80
|
+
const PAD = 22;
|
|
81
|
+
const Y0 = -0.45;
|
|
82
|
+
const Y1 = 1.45;
|
|
83
|
+
const SPAN = Y1 - Y0;
|
|
84
|
+
const bz: Bz =
|
|
85
|
+
current && typeof current === "object" && "cubicBezier" in current
|
|
86
|
+
? ([...(current as { cubicBezier: number[] }).cubicBezier] as Bz)
|
|
87
|
+
: [0.33, 0, 0.67, 1];
|
|
88
|
+
const c = el("canvas", { style: "background:#0e0f15;border-radius:8px;cursor:grab;touch-action:none;display:block" });
|
|
89
|
+
c.width = S;
|
|
90
|
+
c.height = S;
|
|
91
|
+
const cx = c.getContext("2d")!;
|
|
92
|
+
const toPx = (ex: number, ey: number): [number, number] => [
|
|
93
|
+
PAD + ex * (S - 2 * PAD),
|
|
94
|
+
S - PAD - ((ey - Y0) / SPAN) * (S - 2 * PAD),
|
|
95
|
+
];
|
|
96
|
+
const toEase = (px: number, py: number): [number, number] => [
|
|
97
|
+
(px - PAD) / (S - 2 * PAD),
|
|
98
|
+
Y0 + ((S - PAD - py) / (S - 2 * PAD)) * SPAN,
|
|
99
|
+
];
|
|
100
|
+
function render() {
|
|
101
|
+
cx.clearRect(0, 0, S, S);
|
|
102
|
+
const [gx0, gy0] = toPx(0, 0);
|
|
103
|
+
const [gx1, gy1] = toPx(1, 1);
|
|
104
|
+
cx.strokeStyle = "#23252f";
|
|
105
|
+
cx.lineWidth = 1;
|
|
106
|
+
cx.strokeRect(Math.min(gx0, gx1), Math.min(gy0, gy1), Math.abs(gx1 - gx0), Math.abs(gy1 - gy0));
|
|
107
|
+
const P1 = toPx(bz[0], bz[1]);
|
|
108
|
+
const P2 = toPx(bz[2], bz[3]);
|
|
109
|
+
cx.strokeStyle = "#3a3f55";
|
|
110
|
+
cx.beginPath();
|
|
111
|
+
cx.moveTo(gx0, gy0);
|
|
112
|
+
cx.lineTo(P1[0], P1[1]);
|
|
113
|
+
cx.moveTo(gx1, gy1);
|
|
114
|
+
cx.lineTo(P2[0], P2[1]);
|
|
115
|
+
cx.stroke();
|
|
116
|
+
cx.strokeStyle = "#7d9aff";
|
|
117
|
+
cx.lineWidth = 2;
|
|
118
|
+
cx.beginPath();
|
|
119
|
+
for (let i = 0; i <= 48; i++) {
|
|
120
|
+
const t = i / 48;
|
|
121
|
+
const [px, py] = toPx(cubic(t, 0, bz[0], bz[2], 1), cubic(t, 0, bz[1], bz[3], 1));
|
|
122
|
+
if (i) cx.lineTo(px, py);
|
|
123
|
+
else cx.moveTo(px, py);
|
|
124
|
+
}
|
|
125
|
+
cx.stroke();
|
|
126
|
+
for (const [hx, hy] of [P1, P2]) {
|
|
127
|
+
cx.beginPath();
|
|
128
|
+
cx.arc(hx, hy, 5, 0, Math.PI * 2);
|
|
129
|
+
cx.fillStyle = "#fff";
|
|
130
|
+
cx.fill();
|
|
131
|
+
cx.strokeStyle = "#7d9aff";
|
|
132
|
+
cx.lineWidth = 2;
|
|
133
|
+
cx.stroke();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
let dragIdx = -1;
|
|
137
|
+
const localPos = (ev: PointerEvent): [number, number] => {
|
|
138
|
+
const r = c.getBoundingClientRect();
|
|
139
|
+
return [((ev.clientX - r.left) * S) / r.width, ((ev.clientY - r.top) * S) / r.height];
|
|
140
|
+
};
|
|
141
|
+
c.addEventListener("pointerdown", (ev) => {
|
|
142
|
+
const [mx, my] = localPos(ev);
|
|
143
|
+
const P1 = toPx(bz[0], bz[1]);
|
|
144
|
+
const P2 = toPx(bz[2], bz[3]);
|
|
145
|
+
dragIdx = Math.hypot(P1[0] - mx, P1[1] - my) <= 11 ? 0 : Math.hypot(P2[0] - mx, P2[1] - my) <= 11 ? 1 : -1;
|
|
146
|
+
if (dragIdx >= 0) c.setPointerCapture(ev.pointerId);
|
|
147
|
+
});
|
|
148
|
+
c.addEventListener("pointermove", (ev) => {
|
|
149
|
+
if (dragIdx < 0) return;
|
|
150
|
+
let [ex, ey] = toEase(...localPos(ev));
|
|
151
|
+
ex = Math.max(0, Math.min(1, ex));
|
|
152
|
+
ey = Math.max(Y0, Math.min(Y1, ey));
|
|
153
|
+
if (dragIdx === 0) {
|
|
154
|
+
bz[0] = ex;
|
|
155
|
+
bz[1] = ey;
|
|
156
|
+
} else {
|
|
157
|
+
bz[2] = ex;
|
|
158
|
+
bz[3] = ey;
|
|
159
|
+
}
|
|
160
|
+
render();
|
|
161
|
+
store.setTimelineEase(label, [round3(bz[0]), round3(bz[1]), round3(bz[2]), round3(bz[3])]);
|
|
162
|
+
});
|
|
163
|
+
c.addEventListener("pointerup", () => {
|
|
164
|
+
dragIdx = -1;
|
|
165
|
+
});
|
|
166
|
+
render();
|
|
167
|
+
return c;
|
|
168
|
+
}
|
|
169
|
+
|
|
56
170
|
/** Value editor for one PropValue; numbers get ranges where it makes sense. */
|
|
57
171
|
function makeControl(
|
|
58
172
|
prop: string,
|
|
@@ -100,6 +214,286 @@ function makeControl(
|
|
|
100
214
|
return row;
|
|
101
215
|
}
|
|
102
216
|
|
|
217
|
+
// --- variation grid: seeded perturbations of the editable motion ---
|
|
218
|
+
function mulberry32(seed: number): () => number {
|
|
219
|
+
let a = (seed >>> 0) || 1;
|
|
220
|
+
return () => {
|
|
221
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
222
|
+
let x = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
223
|
+
x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
|
|
224
|
+
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const clampN = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
|
228
|
+
|
|
229
|
+
function firstMotionPathTarget(tl: TimelineIR | undefined): string | null {
|
|
230
|
+
let found: string | null = null;
|
|
231
|
+
const walk = (s: TimelineIR) => {
|
|
232
|
+
if (found) return;
|
|
233
|
+
if (s.kind === "motionPath") found = s.target;
|
|
234
|
+
if ("children" in s) s.children.forEach(walk);
|
|
235
|
+
};
|
|
236
|
+
if (tl) walk(tl);
|
|
237
|
+
return found;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** A seeded variation of the current motion (curviness + interior waypoints +
|
|
241
|
+
* ease curves jittered) as an overlay, built on top of the current draft. */
|
|
242
|
+
function makeVariant(draft: OverlayDoc, compiled: CompiledScene, seed: number): OverlayDoc {
|
|
243
|
+
const rng = mulberry32(Math.imul(seed, 2654435761));
|
|
244
|
+
const v: OverlayDoc = structuredClone(draft);
|
|
245
|
+
const tl = (v.timeline ??= {});
|
|
246
|
+
const walk = (s: TimelineIR) => {
|
|
247
|
+
if (s.kind === "motionPath" && s.label) {
|
|
248
|
+
const cur = tl[s.label] ?? {};
|
|
249
|
+
const baseCurv = (cur.curviness ?? s.curviness ?? 1) as number;
|
|
250
|
+
const basePts = (cur.points ?? s.points) as [number, number][];
|
|
251
|
+
tl[s.label] = {
|
|
252
|
+
...cur,
|
|
253
|
+
curviness: clampN(baseCurv + (rng() - 0.5) * 1.3, 0, 2),
|
|
254
|
+
points: basePts.map((p, i, arr) =>
|
|
255
|
+
i === 0 || i === arr.length - 1
|
|
256
|
+
? p
|
|
257
|
+
: [Math.round(p[0] + (rng() - 0.5) * 130), Math.round(p[1] + (rng() - 0.5) * 130)],
|
|
258
|
+
),
|
|
259
|
+
};
|
|
260
|
+
} else if ((s.kind === "to" || s.kind === "tween") && s.label) {
|
|
261
|
+
const cur = tl[s.label] ?? {};
|
|
262
|
+
const curEase = cur.ease ?? ("ease" in s ? s.ease : undefined);
|
|
263
|
+
const bz = (
|
|
264
|
+
curEase && typeof curEase === "object" && "cubicBezier" in curEase
|
|
265
|
+
? [...curEase.cubicBezier]
|
|
266
|
+
: [0.33, 0, 0.67, 1]
|
|
267
|
+
) as number[];
|
|
268
|
+
tl[s.label] = {
|
|
269
|
+
...cur,
|
|
270
|
+
ease: {
|
|
271
|
+
cubicBezier: [
|
|
272
|
+
clampN(bz[0]! + (rng() - 0.5) * 0.4, 0, 1),
|
|
273
|
+
clampN(bz[1]! + (rng() - 0.5) * 1.2, -0.4, 1.5),
|
|
274
|
+
clampN(bz[2]! + (rng() - 0.5) * 0.4, 0, 1),
|
|
275
|
+
clampN(bz[3]! + (rng() - 0.5) * 1.2, -0.4, 1.5),
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
if ("children" in s) s.children.forEach(walk);
|
|
281
|
+
};
|
|
282
|
+
if (compiled.ir.timeline) walk(compiled.ir.timeline);
|
|
283
|
+
return v;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Draw the target node's trail (position over time) into a thumbnail canvas. */
|
|
287
|
+
function renderThumb(c: HTMLCanvasElement, base: SceneIR, variant: OverlayDoc, target: string) {
|
|
288
|
+
const cx = c.getContext("2d")!;
|
|
289
|
+
cx.fillStyle = "#0e0f15";
|
|
290
|
+
cx.fillRect(0, 0, c.width, c.height);
|
|
291
|
+
let compiled: CompiledScene;
|
|
292
|
+
try {
|
|
293
|
+
compiled = compileScene(composeScene(base, variant).ir);
|
|
294
|
+
} catch {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const D = compiled.duration;
|
|
298
|
+
const W = compiled.ir.size.width;
|
|
299
|
+
const H = compiled.ir.size.height;
|
|
300
|
+
const sc = Math.min(c.width / W, c.height / H) * 0.9;
|
|
301
|
+
const ox = (c.width - W * sc) / 2;
|
|
302
|
+
const oy = (c.height - H * sc) / 2;
|
|
303
|
+
const pts: [number, number][] = [];
|
|
304
|
+
for (let i = 0; i <= 28; i++) {
|
|
305
|
+
const op = evaluate(compiled, (i / 28) * D).find((o) => o.id === target);
|
|
306
|
+
if (op) pts.push([ox + op.transform[4] * sc, oy + op.transform[5] * sc]);
|
|
307
|
+
}
|
|
308
|
+
if (pts.length < 2) return;
|
|
309
|
+
cx.strokeStyle = "#7d9aff";
|
|
310
|
+
cx.lineWidth = 1.5;
|
|
311
|
+
cx.beginPath();
|
|
312
|
+
pts.forEach((p, i) => (i ? cx.lineTo(p[0], p[1]) : cx.moveTo(p[0], p[1])));
|
|
313
|
+
cx.stroke();
|
|
314
|
+
for (const p of pts) {
|
|
315
|
+
cx.beginPath();
|
|
316
|
+
cx.arc(p[0], p[1], 1.4, 0, Math.PI * 2);
|
|
317
|
+
cx.fillStyle = "#9db4ff";
|
|
318
|
+
cx.fill();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** "vary ×4" → seeded motion variations as adoptable trail thumbnails
|
|
323
|
+
* (recognition over recall). Click one to adopt; click vary again to branch. */
|
|
324
|
+
function renderVariations(root: HTMLElement, store: EditorStore) {
|
|
325
|
+
const target = firstMotionPathTarget(store.compiled.ir.timeline);
|
|
326
|
+
if (!target) return; // a trail needs a motionPath to preview
|
|
327
|
+
root.append(el("h3", {}, "Variations"));
|
|
328
|
+
const grid = el("div", { style: "display:flex;gap:6px;flex-wrap:wrap;margin:4px 0" });
|
|
329
|
+
let round = 0;
|
|
330
|
+
const btn = el("button", { title: "generate motion variations" }, "vary ×4");
|
|
331
|
+
btn.addEventListener("click", () => {
|
|
332
|
+
grid.replaceChildren();
|
|
333
|
+
for (let k = 1; k <= 4; k++) {
|
|
334
|
+
const variant = makeVariant(store.draft, store.compiled, k + round * 4 + 1);
|
|
335
|
+
const c = el("canvas", { title: "click to adopt this motion", style: "border-radius:6px;cursor:pointer;border:1px solid #333" });
|
|
336
|
+
c.width = 150;
|
|
337
|
+
c.height = 92;
|
|
338
|
+
renderThumb(c, store.base, variant, target);
|
|
339
|
+
c.addEventListener("click", () => store.importDraft(variant));
|
|
340
|
+
grid.append(c);
|
|
341
|
+
}
|
|
342
|
+
round++;
|
|
343
|
+
});
|
|
344
|
+
root.append(btn, grid);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** "Add motion ▸ <op>" on the selected node + a list of added ops (amount + remove).
|
|
348
|
+
* Single-select only — bulk add-motion for a multi-selection lives in the Multi panel. */
|
|
349
|
+
function renderMotionOps(root: HTMLElement, store: EditorStore) {
|
|
350
|
+
const selId = store.selectedIds.length === 1 ? store.selectedIds[0]! : null;
|
|
351
|
+
if (selId) {
|
|
352
|
+
root.append(el("h3", {}, "Add motion"));
|
|
353
|
+
const sel = el("select");
|
|
354
|
+
for (const op of MOTION_OPS) sel.append(el("option", { value: op }, op));
|
|
355
|
+
const add = el("button", { title: "add this motion to the selected node" }, "+ add");
|
|
356
|
+
add.addEventListener("click", () => store.addMotionOp(sel.value as MotionOpName, selId));
|
|
357
|
+
root.append(el("div", { class: "prop-row" }, el("label", {}, `▸ ${selId}`), sel, add));
|
|
358
|
+
// a motionless top-level node can get its FIRST move (a path to a destination)
|
|
359
|
+
if (!store.hasMotionPath(selId) && store.isTopLevel(selId)) {
|
|
360
|
+
if (store.pendingMove === selId) {
|
|
361
|
+
const cancel = el("button", { title: "cancel" }, "cancel");
|
|
362
|
+
cancel.addEventListener("click", () => store.disarmMove());
|
|
363
|
+
const armed = el("div", { class: "prop-row" }, el("label", {}, "▸ click a destination on the canvas…"), cancel);
|
|
364
|
+
armed.querySelector("label")!.setAttribute("style", "color:#7d9aff");
|
|
365
|
+
root.append(armed);
|
|
366
|
+
} else {
|
|
367
|
+
const mv = el("button", { title: "give this node a move: then click where it should go" }, "+ move");
|
|
368
|
+
mv.addEventListener("click", () => store.armMove(selId));
|
|
369
|
+
root.append(el("div", { class: "prop-row" }, el("label", {}, "▸ no motion yet"), mv));
|
|
370
|
+
root.append(el("div", { class: "hint" }, "or double-click the canvas to set a destination"));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (store.addedOps.size > 0) {
|
|
375
|
+
root.append(el("h3", {}, "Added motion"));
|
|
376
|
+
for (const [label, op] of store.addedOps) {
|
|
377
|
+
const head = el("div", {}, `${op.name} `, el("span", { class: "kind" }, `→ ${op.target}`));
|
|
378
|
+
const rm = el("button", { class: "revert", title: "remove" }, "✕");
|
|
379
|
+
rm.addEventListener("click", () => store.removeMotionOp(label));
|
|
380
|
+
head.append(rm);
|
|
381
|
+
const card = el("div", { class: "step-card" }, head);
|
|
382
|
+
const amtRow = makeControl(
|
|
383
|
+
"amount",
|
|
384
|
+
op.opts.amount ?? 1,
|
|
385
|
+
false,
|
|
386
|
+
(v) => store.setOpAmount(label, Number(v)),
|
|
387
|
+
() => undefined,
|
|
388
|
+
);
|
|
389
|
+
amtRow.prepend(el("label", {}, "amount"));
|
|
390
|
+
card.append(amtRow);
|
|
391
|
+
root.append(card);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Pull <path> data out of pasted SVG markup → path nodes (fill/stroke + a
|
|
397
|
+
* scale that fits the art to ~40% of the frame, pivoting on the viewBox centre). */
|
|
398
|
+
function svgToPathNodes(markup: string, store: EditorStore): number {
|
|
399
|
+
const doc = new DOMParser().parseFromString(markup, "image/svg+xml");
|
|
400
|
+
if (doc.querySelector("parsererror")) return 0;
|
|
401
|
+
const svg = doc.querySelector("svg");
|
|
402
|
+
const paths = Array.from(doc.querySelectorAll("path")).filter((p) => p.getAttribute("d"));
|
|
403
|
+
if (paths.length === 0) return 0;
|
|
404
|
+
// viewBox (or width/height) gives the art box → centre pivot + a fit scale
|
|
405
|
+
const vb = (svg?.getAttribute("viewBox") ?? "").split(/[ ,]+/).map(Number);
|
|
406
|
+
const vw = vb.length === 4 ? vb[2]! : Number(svg?.getAttribute("width")) || 100;
|
|
407
|
+
const vh = vb.length === 4 ? vb[3]! : Number(svg?.getAttribute("height")) || 100;
|
|
408
|
+
const ox = (vb.length === 4 ? vb[0]! : 0) + vw / 2;
|
|
409
|
+
const oy = (vb.length === 4 ? vb[1]! : 0) + vh / 2;
|
|
410
|
+
const fit = Math.min((store.base.size.width * 0.4) / vw, (store.base.size.height * 0.4) / vh);
|
|
411
|
+
for (const p of paths) {
|
|
412
|
+
store.addNode("path", {
|
|
413
|
+
d: p.getAttribute("d")!,
|
|
414
|
+
...(p.getAttribute("fill") && p.getAttribute("fill") !== "none" ? { fill: p.getAttribute("fill") } : {}),
|
|
415
|
+
...(p.getAttribute("stroke") ? { stroke: p.getAttribute("stroke") } : {}),
|
|
416
|
+
originX: ox,
|
|
417
|
+
originY: oy,
|
|
418
|
+
scale: Math.round(fit * 1000) / 1000,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
return paths.length;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** "Add node" — every IR type. image/svg need input (a src / pasted markup). */
|
|
425
|
+
function renderAddNode(root: HTMLElement, store: EditorStore) {
|
|
426
|
+
root.append(el("h3", {}, "Add node"));
|
|
427
|
+
const row = el("div", { class: "prop-row" }, el("label", {}, "▸ new"));
|
|
428
|
+
for (const type of ["text", "rect", "ellipse", "line"] as const) {
|
|
429
|
+
const b = el("button", { title: `add a ${type} at scene centre` }, type);
|
|
430
|
+
b.addEventListener("click", () => store.addNode(type));
|
|
431
|
+
row.append(b);
|
|
432
|
+
}
|
|
433
|
+
const img = el("button", { title: "add an image (URL, or a path relative to the scene file)" }, "image");
|
|
434
|
+
img.addEventListener("click", () => {
|
|
435
|
+
const src = prompt("Image URL or path (relative to the scene file):", "");
|
|
436
|
+
if (src) store.addNode("image", { src });
|
|
437
|
+
});
|
|
438
|
+
const svg = el("button", { title: "paste SVG markup → vector path node(s) (e.g. a logo)" }, "svg / logo");
|
|
439
|
+
svg.addEventListener("click", () => {
|
|
440
|
+
const markup = prompt("Paste SVG markup:", "");
|
|
441
|
+
if (!markup) return;
|
|
442
|
+
if (svgToPathNodes(markup, store) === 0) alert("no <path d=…> found in that SVG");
|
|
443
|
+
});
|
|
444
|
+
row.append(img, svg);
|
|
445
|
+
root.append(row);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Multi-selection editor: common props (shared value or "mixed") edited on all
|
|
449
|
+
* at once, plus bulk actions. Per-anchor scale/rotation/skew just works (set per id). */
|
|
450
|
+
function renderMultiProps(root: HTMLElement, store: EditorStore, ir: SceneIR) {
|
|
451
|
+
const sel = store.selectedIds.map((id) => findNode(ir.nodes, id)).filter((n): n is NodeIR => !!n);
|
|
452
|
+
if (sel.length < 2) return;
|
|
453
|
+
root.append(el("h3", {}, `Multi · ${sel.length} selected`));
|
|
454
|
+
|
|
455
|
+
// bulk actions: add motion to all, hide all, delete the overlay-added ones
|
|
456
|
+
const actions = el("div", { class: "prop-row" });
|
|
457
|
+
const opSel = el("select", { title: "motion to add to every selected node" });
|
|
458
|
+
for (const op of MOTION_OPS) opSel.append(el("option", { value: op }, op));
|
|
459
|
+
const addMo = el("button", {}, "+ motion");
|
|
460
|
+
addMo.addEventListener("click", () => {
|
|
461
|
+
for (const n of sel) store.addMotionOp(opSel.value as MotionOpName, n.id);
|
|
462
|
+
});
|
|
463
|
+
const hideAll = el("button", { title: "hide all (opacity 0)" }, "hide");
|
|
464
|
+
hideAll.addEventListener("click", () => store.setNodeProps(sel.map((n) => ({ id: n.id, prop: "opacity", value: 0 }))));
|
|
465
|
+
const delAdded = el("button", { title: "delete the overlay-added ones" }, "delete added");
|
|
466
|
+
delAdded.addEventListener("click", () => {
|
|
467
|
+
for (const id of sel.map((n) => n.id)) store.removeNode(id);
|
|
468
|
+
});
|
|
469
|
+
actions.append(opSel, addMo, hideAll, delAdded);
|
|
470
|
+
root.append(actions);
|
|
471
|
+
|
|
472
|
+
// common props = present in EVERY selected node's type (ordered by the first)
|
|
473
|
+
const lists = sel.map((n) => PROPS_BY_TYPE[n.type]);
|
|
474
|
+
const common = PROPS_BY_TYPE[sel[0]!.type].filter((p) => p !== "anchor" && lists.every((l) => l.includes(p)));
|
|
475
|
+
for (const prop of common) {
|
|
476
|
+
const vals = sel.map((n) => {
|
|
477
|
+
const props = n.props as unknown as Record<string, PropValue | undefined>;
|
|
478
|
+
return props[prop] ?? (prop in NUMERIC_DEFAULTS ? NUMERIC_DEFAULTS[prop] : undefined);
|
|
479
|
+
});
|
|
480
|
+
const firstVal = vals[0];
|
|
481
|
+
if (firstVal === undefined) continue; // optional prop unset on all
|
|
482
|
+
const mixed = vals.some((v) => v !== firstVal);
|
|
483
|
+
const row = makeControl(
|
|
484
|
+
prop,
|
|
485
|
+
firstVal, // show the primary's value; the label flags when they differ
|
|
486
|
+
false,
|
|
487
|
+
(v) => store.setNodeProps(sel.map((n) => ({ id: n.id, prop, value: v }))),
|
|
488
|
+
() => undefined,
|
|
489
|
+
);
|
|
490
|
+
const label = el("label", {}, prop);
|
|
491
|
+
if (mixed) label.append(el("span", { class: "scope" }, " (mixed)"));
|
|
492
|
+
row.prepend(label);
|
|
493
|
+
root.append(row);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
103
497
|
export function buildPanel(store: EditorStore, root: HTMLElement) {
|
|
104
498
|
let reportBox: HTMLElement | null = null;
|
|
105
499
|
|
|
@@ -128,6 +522,11 @@ export function buildPanel(store: EditorStore, root: HTMLElement) {
|
|
|
128
522
|
dur.prepend(el("label", {}, "duration (s)"));
|
|
129
523
|
root.append(dur);
|
|
130
524
|
|
|
525
|
+
// --- variations + add-node + add-motion (motion ops) ---
|
|
526
|
+
renderVariations(root, store);
|
|
527
|
+
renderAddNode(root, store);
|
|
528
|
+
renderMotionOps(root, store);
|
|
529
|
+
|
|
131
530
|
// --- node tree ---
|
|
132
531
|
root.append(el("h3", {}, "Nodes"));
|
|
133
532
|
const renderTree = (nodes: NodeIR[], depth: number) => {
|
|
@@ -135,23 +534,43 @@ export function buildPanel(store: EditorStore, root: HTMLElement) {
|
|
|
135
534
|
const edits = store.nodeEditCount(node.id);
|
|
136
535
|
const item = el(
|
|
137
536
|
"div",
|
|
138
|
-
{ class: `tree-item${store.
|
|
537
|
+
{ class: `tree-item${store.selectedIds.includes(node.id) ? " selected" : ""}` },
|
|
139
538
|
el("span", { style: `padding-left:${depth * 14}px` }, `${node.id} `),
|
|
140
539
|
el("span", { class: "badge" }, edits > 0 ? `●${edits}` : ""),
|
|
141
540
|
);
|
|
142
|
-
item.addEventListener("click", () => store.select(node.id));
|
|
541
|
+
item.addEventListener("click", (ev) => store.select(node.id, ev.shiftKey || ev.metaKey || ev.ctrlKey));
|
|
143
542
|
root.append(item);
|
|
144
543
|
if (node.type === "group") renderTree(node.children, depth + 1);
|
|
145
544
|
}
|
|
146
545
|
};
|
|
147
546
|
renderTree(ir.nodes, 0);
|
|
148
547
|
|
|
149
|
-
// ---
|
|
150
|
-
if (store.
|
|
151
|
-
|
|
548
|
+
// --- multi-selection: common props edited on all at once ---
|
|
549
|
+
if (store.selectedIds.length > 1) {
|
|
550
|
+
renderMultiProps(root, store, ir);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// --- selected node props with scope expansion (single selection) ---
|
|
554
|
+
if (store.selectedIds.length === 1) {
|
|
555
|
+
const node = findNode(ir.nodes, store.selectedIds[0]!);
|
|
152
556
|
if (node) {
|
|
153
557
|
const id = node.id;
|
|
154
558
|
root.append(el("h3", {}, `Props: ${id} (${node.type})`));
|
|
559
|
+
const added = store.isAddedNode(id);
|
|
560
|
+
const actions = el("div", { class: "prop-row" });
|
|
561
|
+
const dup = el("button", { title: "duplicate this node" }, "duplicate");
|
|
562
|
+
dup.addEventListener("click", () => store.duplicateNode(id));
|
|
563
|
+
actions.append(dup);
|
|
564
|
+
if (added) {
|
|
565
|
+
const del = el("button", { title: "remove this overlay-added node" }, "delete");
|
|
566
|
+
del.addEventListener("click", () => store.removeNode(id));
|
|
567
|
+
actions.append(del);
|
|
568
|
+
} else {
|
|
569
|
+
const hide = el("button", { title: "base node — hide it (opacity 0) instead of deleting" }, "hide");
|
|
570
|
+
hide.addEventListener("click", () => store.hideNode(id));
|
|
571
|
+
actions.append(hide);
|
|
572
|
+
}
|
|
573
|
+
root.append(actions);
|
|
155
574
|
const props = node.props as unknown as Record<string, PropValue | undefined>;
|
|
156
575
|
const states = ir.states ?? {};
|
|
157
576
|
const initial = ir.initial;
|
|
@@ -229,6 +648,28 @@ export function buildPanel(store: EditorStore, root: HTMLElement) {
|
|
|
229
648
|
() => store.unsetTimelineParam(b.name, "scale"));
|
|
230
649
|
scaleRow.prepend(el("label", {}, "scale"));
|
|
231
650
|
card.append(scaleRow);
|
|
651
|
+
|
|
652
|
+
// the intent graph: nodes this beat OWNS (track group) + its member
|
|
653
|
+
// labels (the motion-graph lanes under it) as markers.
|
|
654
|
+
if ((b.nodes ?? []).length > 0) {
|
|
655
|
+
const group = el("div", { class: "beat-group" });
|
|
656
|
+
for (const id of b.nodes!) {
|
|
657
|
+
const known = findNode(ir.nodes, id) !== null;
|
|
658
|
+
const lane = el("div", { class: `beat-lane${known ? "" : " missing"}${store.selectedId === id ? " selected" : ""}` }, `◢ ${id}`);
|
|
659
|
+
if (known) lane.addEventListener("click", () => store.select(id));
|
|
660
|
+
group.append(lane);
|
|
661
|
+
}
|
|
662
|
+
card.append(group);
|
|
663
|
+
}
|
|
664
|
+
const memberLabels: string[] = [];
|
|
665
|
+
const collectLabels = (tl: TimelineIR) => {
|
|
666
|
+
if ("label" in tl && tl.label !== undefined) memberLabels.push(tl.label);
|
|
667
|
+
if ("children" in tl) tl.children.forEach(collectLabels);
|
|
668
|
+
};
|
|
669
|
+
b.children.forEach(collectLabels);
|
|
670
|
+
if (memberLabels.length > 0) {
|
|
671
|
+
card.append(el("div", { class: "beat-markers" }, memberLabels.map((l) => `▸${l}`).join(" ")));
|
|
672
|
+
}
|
|
232
673
|
root.append(card);
|
|
233
674
|
}
|
|
234
675
|
}
|
|
@@ -257,6 +698,23 @@ export function buildPanel(store: EditorStore, root: HTMLElement) {
|
|
|
257
698
|
);
|
|
258
699
|
durRow.prepend(el("label", {}, "duration"));
|
|
259
700
|
card.append(durRow);
|
|
701
|
+
if (step.kind === "motionPath") {
|
|
702
|
+
const cvRow = makeControl(
|
|
703
|
+
"curviness",
|
|
704
|
+
step.curviness ?? 1,
|
|
705
|
+
store.hasTimelineEdit(label, "curviness"),
|
|
706
|
+
(v) => store.setTimelineParam(label, "curviness", Number(v)),
|
|
707
|
+
() => store.unsetTimelineParam(label, "curviness"),
|
|
708
|
+
);
|
|
709
|
+
cvRow.prepend(el("label", {}, "curviness"));
|
|
710
|
+
card.append(cvRow);
|
|
711
|
+
// auto-rotate: turn the node to face its direction of travel
|
|
712
|
+
const ar = el("input", { type: "checkbox" }) as HTMLInputElement;
|
|
713
|
+
ar.checked = store.motionPathAutoRotate(label);
|
|
714
|
+
ar.addEventListener("change", () => store.setAutoRotate(label, ar.checked));
|
|
715
|
+
const arRow = el("div", { class: "prop-row" }, el("label", {}, "auto-rotate"), ar);
|
|
716
|
+
card.append(arRow);
|
|
717
|
+
}
|
|
260
718
|
if (step.kind === "to" || step.kind === "tween") {
|
|
261
719
|
const easeSelect = el("select");
|
|
262
720
|
const current = "ease" in step ? step.ease : undefined;
|
|
@@ -266,8 +724,20 @@ export function buildPanel(store: EditorStore, root: HTMLElement) {
|
|
|
266
724
|
easeSelect.addEventListener("change", () => {
|
|
267
725
|
if (easeSelect.value !== "__custom") store.setTimelineParam(label, "ease", easeSelect.value);
|
|
268
726
|
});
|
|
269
|
-
|
|
270
|
-
|
|
727
|
+
// ✎ toggles an inline draggable cubic-bezier curve editor for this step
|
|
728
|
+
const curveBtn = el("button", { class: "mini", title: "edit ease curve" }, "✎");
|
|
729
|
+
const editorBox = el("div", { style: "display:none;margin-top:6px" });
|
|
730
|
+
curveBtn.addEventListener("click", () => {
|
|
731
|
+
if (editorBox.style.display === "none") {
|
|
732
|
+
editorBox.replaceChildren(buildEaseEditor(label, "ease" in step ? step.ease : undefined, store));
|
|
733
|
+
editorBox.style.display = "block";
|
|
734
|
+
} else {
|
|
735
|
+
editorBox.replaceChildren();
|
|
736
|
+
editorBox.style.display = "none";
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
const easeRow = el("div", { class: `prop-row${store.hasTimelineEdit(label, "ease") ? " edited" : ""}` }, el("label", {}, "ease"), easeSelect, curveBtn);
|
|
740
|
+
card.append(easeRow, editorBox);
|
|
271
741
|
}
|
|
272
742
|
if (step.kind === "to") {
|
|
273
743
|
const stRow = makeControl(
|