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.
Files changed (50) hide show
  1. package/assets/sfx/LICENSE.md +2 -1
  2. package/assets/sfx/bong_001.ogg +0 -0
  3. package/assets/sfx/click_001.ogg +0 -0
  4. package/assets/sfx/confirmation_002.ogg +0 -0
  5. package/assets/sfx/confirmation_003.ogg +0 -0
  6. package/assets/sfx/confirmation_004.ogg +0 -0
  7. package/assets/sfx/footstep_001.ogg +0 -0
  8. package/assets/sfx/footstep_002.ogg +0 -0
  9. package/assets/sfx/footstep_003.ogg +0 -0
  10. package/assets/sfx/glass_001.ogg +0 -0
  11. package/assets/sfx/maximize_001.ogg +0 -0
  12. package/assets/sfx/maximize_002.ogg +0 -0
  13. package/assets/sfx/maximize_005.ogg +0 -0
  14. package/assets/sfx/maximize_009.ogg +0 -0
  15. package/assets/sfx/open_001.ogg +0 -0
  16. package/assets/sfx/pluck_001.ogg +0 -0
  17. package/assets/sfx/pluck_002.ogg +0 -0
  18. package/assets/sfx/select_001.ogg +0 -0
  19. package/assets/sfx/select_002.ogg +0 -0
  20. package/assets/sfx/select_003.ogg +0 -0
  21. package/dist/bin.js +271 -49
  22. package/dist/browserEntry.js +179 -68
  23. package/dist/cli.js +445 -85
  24. package/dist/index.js +1187 -116
  25. package/dist/labels.js +606 -0
  26. package/dist/renderer-canvas.js +15 -0
  27. package/dist/trace-cli.js +9 -9
  28. package/dist/types/audio.d.ts +9 -0
  29. package/dist/types/characterPreset.d.ts +39 -0
  30. package/dist/types/compile.d.ts +1 -0
  31. package/dist/types/compose.d.ts +18 -2
  32. package/dist/types/composeComposition.d.ts +27 -0
  33. package/dist/types/devicePreset.d.ts +65 -0
  34. package/dist/types/dsl.d.ts +12 -1
  35. package/dist/types/evaluate.d.ts +32 -0
  36. package/dist/types/figure.d.ts +32 -0
  37. package/dist/types/index.d.ts +9 -3
  38. package/dist/types/interpolate.d.ts +3 -2
  39. package/dist/types/ir.d.ts +68 -0
  40. package/dist/types/motionOps.d.ts +36 -0
  41. package/dist/types/path.d.ts +7 -3
  42. package/dist/types/rig.d.ts +87 -0
  43. package/dist/types/validate.d.ts +4 -1
  44. package/guides/edsl-guide.md +54 -1
  45. package/guides/regen-contract.md +11 -0
  46. package/package.json +1 -1
  47. package/preview/index.html +56 -3
  48. package/preview/src/main.ts +1132 -46
  49. package/preview/src/panel.ts +478 -8
  50. package/preview/src/store.ts +323 -6
@@ -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.selectedId === node.id ? " selected" : ""}` },
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
- // --- selected node props with scope expansion ---
150
- if (store.selectedId) {
151
- const node = findNode(ir.nodes, store.selectedId);
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
- const easeRow = el("div", { class: `prop-row${store.hasTimelineEdit(label, "ease") ? " edited" : ""}` }, el("label", {}, "ease"), easeSelect);
270
- card.append(easeRow);
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(