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
@@ -5,8 +5,8 @@
5
5
  * path never uses wall-clock time.
6
6
  */
7
7
 
8
- import { collectImageSrcs, evaluate, type DisplayOp, type SceneIR } from "@reframe/core";
9
- import { renderFrame } from "@reframe/renderer-canvas";
8
+ import { collectImageSrcs, compileComposition, evaluate, nodeParentMatrix, type CompositionIR, type DisplayOp, type NodeIR, type SceneIR, type TimelineIR } from "@reframe/core";
9
+ import { renderFrame, drawDisplayList } from "@reframe/renderer-canvas";
10
10
  import { userScenes } from "virtual:reframe-user-scenes";
11
11
  import { buildPanel } from "./panel.js";
12
12
  import { EditorStore } from "./store.js";
@@ -32,6 +32,17 @@ for (const { name, dir, load } of userScenes) {
32
32
  modules[`user:${name}`] ??= { label: `${name} (cwd)`, dir, load };
33
33
  }
34
34
 
35
+ // compositions (the layer above a scene) — picked from the same dropdown, keyed
36
+ // "comp:<path>"; selecting one opens a scene navigator over its scenes.
37
+ const compositionModules = ({} as Record<string, () => Promise<{ default: CompositionIR }>>);
38
+ const compositions: Record<string, { label: string; load: () => Promise<{ default: CompositionIR }> }> = {};
39
+ for (const path of Object.keys(compositionModules).sort()) {
40
+ compositions[`comp:${path}`] = {
41
+ label: `▤ ${path.split("/").pop()!.replace(".ts", "")} (composition)`,
42
+ load: compositionModules[path]!,
43
+ };
44
+ }
45
+
35
46
  const canvas = document.getElementById("canvas") as HTMLCanvasElement;
36
47
  const ctx = canvas.getContext("2d")!;
37
48
  const select = document.getElementById("scene-select") as HTMLSelectElement;
@@ -39,12 +50,37 @@ const playBtn = document.getElementById("play") as HTMLButtonElement;
39
50
  const scrub = document.getElementById("scrub") as HTMLInputElement;
40
51
  const timeLabel = document.getElementById("time") as HTMLSpanElement;
41
52
  const panelRoot = document.getElementById("panel") as HTMLDivElement;
53
+ const loopBtn = document.getElementById("loop") as HTMLButtonElement;
54
+ const markInBtn = document.getElementById("mark-in") as HTMLButtonElement;
55
+ const markOutBtn = document.getElementById("mark-out") as HTMLButtonElement;
56
+ const speedSel = document.getElementById("speed") as HTMLSelectElement;
57
+ const loopBand = document.getElementById("loop-band") as HTMLDivElement;
58
+ const playAllBtn = document.getElementById("play-all") as HTMLButtonElement;
59
+ const compTimelineEl = document.getElementById("comp-timeline") as HTMLDivElement;
60
+ let compPlayhead: HTMLDivElement | null = null;
61
+ let compTotal = 0;
62
+ let compStarts: number[] = []; // composition scene start times (play-all + playhead)
63
+ let tracksOpen = false; // node-track dope sheet expanded
64
+ let tkPlayhead: HTMLDivElement | null = null;
65
+ const expandedGroups = new Set<string>(); // which track groups are unfolded
66
+ // play-all: a continuous-playback MODE that draws the whole composition from
67
+ // precompiled scenes (no editor-store swaps), blending crossfades.
68
+ let playAll = false;
69
+ let compT = 0; // global composition time in play-all mode
70
+ let playCC: import("@reframe/core").CompiledComposition | null = null;
42
71
 
43
72
  let store: EditorStore | null = null;
44
73
  let panel: ReturnType<typeof buildPanel> | null = null;
45
74
  let t = 0;
46
75
  let playing = false;
47
76
  let lastTick = 0;
77
+ let speed = 1;
78
+ let loopOn = false;
79
+ let tIn = 0;
80
+ let tOut = Infinity;
81
+ // active composition (when a "comp:" entry is selected) + which scene is open
82
+ let activeComposition: CompositionIR | null = null;
83
+ let activeSceneIndex = 0;
48
84
 
49
85
  for (const [key, entry] of Object.entries(modules)) {
50
86
  const option = document.createElement("option");
@@ -52,42 +88,59 @@ for (const [key, entry] of Object.entries(modules)) {
52
88
  option.textContent = entry.label;
53
89
  select.appendChild(option);
54
90
  }
91
+ for (const [key, entry] of Object.entries(compositions)) {
92
+ const option = document.createElement("option");
93
+ option.value = key;
94
+ option.textContent = entry.label;
95
+ select.appendChild(option);
96
+ }
55
97
 
56
98
  // decoded images keyed by raw src; missing entries render as a placeholder
57
99
  const images = new Map<string, CanvasImageSource>();
58
100
  const imageLoads = new Map<string, Promise<void>>();
59
101
  let sceneDir = "";
60
102
 
103
+ /** Load one src via /@fs (deduped); resolves when decoded or on error. */
104
+ function loadSrc(src: string, dir: string): Promise<void> {
105
+ const existing = imageLoads.get(src);
106
+ if (images.has(src)) return Promise.resolve();
107
+ if (existing) return existing;
108
+ // data:/http(s):/blob: URLs load directly; everything else resolves via /@fs
109
+ const url = /^(data:|https?:|blob:)/.test(src) ? src : `/@fs${src.startsWith("/") ? src : `${dir}/${src}`}`;
110
+ const load = new Promise<void>((done) => {
111
+ const img = new Image();
112
+ img.onload = () => {
113
+ images.set(src, img);
114
+ done();
115
+ draw();
116
+ };
117
+ img.onerror = () => {
118
+ console.warn(`image "${src}" failed to load (${url}) — rendering placeholder`);
119
+ done();
120
+ };
121
+ img.src = url;
122
+ });
123
+ imageLoads.set(src, load);
124
+ return load;
125
+ }
126
+
61
127
  /** Load any not-yet-loaded srcs of the current scene via /@fs. */
62
128
  function ensureImages(): Promise<void> {
63
129
  if (!store) return Promise.resolve();
64
- const pending: Promise<void>[] = [];
65
- for (const src of collectImageSrcs(store.compiled.ir)) {
66
- if (images.has(src) || imageLoads.has(src)) continue;
67
- const url = `/@fs${src.startsWith("/") ? src : `${sceneDir}/${src}`}`;
68
- const load = new Promise<void>((done) => {
69
- const img = new Image();
70
- img.onload = () => {
71
- images.set(src, img);
72
- done();
73
- draw();
74
- };
75
- img.onerror = () => {
76
- console.warn(`image "${src}" failed to load (${url}) — rendering placeholder`);
77
- done();
78
- };
79
- img.src = url;
80
- });
81
- imageLoads.set(src, load);
82
- pending.push(load);
83
- }
84
- return Promise.all(pending).then(() => undefined);
130
+ return Promise.all([...collectImageSrcs(store.compiled.ir)].map((s) => loadSrc(s, sceneDir))).then(() => undefined);
85
131
  }
86
132
 
87
- async function loadScene(path: string) {
88
- const mod = await modules[path]!.load();
89
- store = new EditorStore(mod.default);
90
- sceneDir = modules[path]!.dir;
133
+ /** Preload images across every scene of a composition (for continuous play). */
134
+ function ensureCompositionImages(cc: import("@reframe/core").CompiledComposition): Promise<void> {
135
+ return Promise.all(
136
+ cc.scenes.flatMap((p) => [...collectImageSrcs(p.scene)].map((s) => loadSrc(s, __REFRAME_EXAMPLES_DIR__))),
137
+ ).then(() => undefined);
138
+ }
139
+
140
+ /** Build the editor over a SceneIR (from a scene file or a composition scene). */
141
+ async function openSceneIR(ir: SceneIR, dir: string, writeUrl = true) {
142
+ store = new EditorStore(ir);
143
+ sceneDir = dir;
91
144
  images.clear();
92
145
  imageLoads.clear();
93
146
  (window as unknown as { __store: EditorStore }).__store = store; // debug/testing hook
@@ -96,8 +149,10 @@ async function loadScene(path: string) {
96
149
  canvas.height = store.compiled.ir.size.height;
97
150
  store.subscribe((kind) => {
98
151
  t = Math.min(t, store!.compiled.duration);
99
- if (kind === "structure") panel!.rebuild();
100
- else panel!.refreshReport();
152
+ if (kind === "structure") {
153
+ panel!.rebuild();
154
+ buildTimeline(); // beats may have changed
155
+ } else panel!.refreshReport();
101
156
  void ensureImages(); // an edited src loads lazily, then redraws
102
157
  draw();
103
158
  });
@@ -105,7 +160,387 @@ async function loadScene(path: string) {
105
160
  await ensureImages();
106
161
  t = 0;
107
162
  panel.rebuild();
163
+ buildTimeline();
108
164
  draw();
165
+ if (writeUrl) syncUrl();
166
+ tIn = 0;
167
+ tOut = store.compiled.duration;
168
+ updateLoopBand();
169
+ (window as unknown as { __reframeReady: boolean }).__reframeReady = true;
170
+ }
171
+
172
+ async function loadScene(path: string) {
173
+ activeComposition = null;
174
+ leavePlayAllMode();
175
+ playAllBtn.style.display = "none";
176
+ compTimelineEl.classList.remove("on");
177
+ const mod = await modules[path]!.load();
178
+ await openSceneIR(mod.default, modules[path]!.dir);
179
+ }
180
+
181
+ /** Open a composition: build the bottom scene timeline and open its first scene. */
182
+ async function loadComposition(key: string) {
183
+ const mod = await compositions[key]!.load();
184
+ activeComposition = mod.default;
185
+ activeSceneIndex = 0;
186
+ compT = 0;
187
+ playAllBtn.style.display = "";
188
+ await openScene(0);
189
+ }
190
+
191
+ /** Open the Nth scene of the active composition into the per-scene editor. */
192
+ async function openScene(index: number) {
193
+ if (!activeComposition) return;
194
+ activeSceneIndex = index;
195
+ await openSceneIR(activeComposition.scenes[index]!.scene, __REFRAME_EXAMPLES_DIR__, false);
196
+ }
197
+
198
+ interface TimelineSeg {
199
+ label: string;
200
+ start: number;
201
+ end: number;
202
+ suffix: string;
203
+ active: boolean;
204
+ beat: boolean;
205
+ onClick: () => void;
206
+ }
207
+
208
+ const round3 = (n: number) => Math.round(n * 1000) / 1000;
209
+
210
+ type BeatIR = Extract<TimelineIR, { kind: "beat" }>;
211
+ function findBeat(tl: TimelineIR | undefined, name: string): BeatIR | null {
212
+ let found: BeatIR | null = null;
213
+ const walk = (s: TimelineIR) => {
214
+ if (s.kind === "beat" && s.name === name) found = s;
215
+ if ("children" in s) s.children.forEach(walk);
216
+ };
217
+ if (tl) walk(tl);
218
+ return found;
219
+ }
220
+
221
+ /** Drag a beat band to retime its chapter: horizontal = move (`gap`), right edge
222
+ * = stretch (`scale`). Persists as a timeline overlay → survives regen. A click
223
+ * without movement falls through to the band's seek. */
224
+ function attachBeatDrag(band: HTMLElement, seg: TimelineSeg) {
225
+ band.style.cursor = "grab";
226
+ band.addEventListener("mousedown", (ev) => {
227
+ if (!store) return;
228
+ ev.preventDefault();
229
+ const lane = document.getElementById("comp-track");
230
+ const pxPerSec = (lane?.clientWidth ?? 1) / (compTotal || 1);
231
+ const startX = ev.clientX;
232
+ const rect = band.getBoundingClientRect();
233
+ const stretch = ev.clientX > rect.right - 8; // grabbed the right edge
234
+ const beat = findBeat(store.compiled.ir.timeline, seg.label);
235
+ const origGap = beat?.gap ?? 0;
236
+ const origScale = beat?.scale ?? 1;
237
+ const origLeftPct = parseFloat(band.style.left) || 0;
238
+ const origWidthPct = parseFloat(band.style.width) || 0;
239
+ let moved = false;
240
+ band.style.cursor = stretch ? "ew-resize" : "grabbing";
241
+ const onMove = (e: MouseEvent) => {
242
+ const dpx = e.clientX - startX;
243
+ if (Math.abs(dpx) > 3) moved = true;
244
+ const dT = dpx / pxPerSec;
245
+ if (stretch) {
246
+ const newWidthPct = Math.max(0.4, origWidthPct + (dT / (compTotal || 1)) * 100);
247
+ band.style.width = `${newWidthPct}%`;
248
+ store!.setTimelineParam(seg.label, "scale", round3(origScale * (newWidthPct / (origWidthPct || 1))));
249
+ } else {
250
+ band.style.left = `${Math.max(0, origLeftPct + (dT / (compTotal || 1)) * 100)}%`;
251
+ store!.setTimelineParam(seg.label, "gap", round3(Math.max(0, origGap + dT)));
252
+ }
253
+ };
254
+ const onUp = () => {
255
+ window.removeEventListener("mousemove", onMove);
256
+ window.removeEventListener("mouseup", onUp);
257
+ band.style.cursor = "grab";
258
+ if (!moved) seg.onClick(); // a plain click seeks
259
+ else buildTimeline(); // settle bands + tracks from the composed result
260
+ };
261
+ window.addEventListener("mousemove", onMove);
262
+ window.addEventListener("mouseup", onUp);
263
+ });
264
+ }
265
+
266
+ /** Greedy lane assignment so time-overlapping bands stack on separate rows
267
+ * (a crossfade or parallel beat shows clearly instead of colliding on one line). */
268
+ function assignLanes(segs: TimelineSeg[]): number[] {
269
+ const laneEnd: number[] = [];
270
+ return segs.map((s) => {
271
+ let lane = laneEnd.findIndex((e) => e <= s.start + 1e-6);
272
+ if (lane < 0) lane = laneEnd.length;
273
+ laneEnd[lane] = s.end;
274
+ return lane;
275
+ });
276
+ }
277
+
278
+ /** Top-level beats of the open scene (a single scene's "chapters"), as segments.
279
+ * A beat's window is the union of its descendant label spans — the time it
280
+ * actually animates — so chapters built as par(beat-with-leading-wait, …)
281
+ * still separate cleanly instead of all starting at the structural t=0. */
282
+ function topLevelBeatSegs(): TimelineSeg[] {
283
+ if (!store) return [];
284
+ const ir = store.compiled.ir;
285
+ const lt = store.compiled.labelTimes;
286
+ const segs: TimelineSeg[] = [];
287
+ const visit = (tl: import("@reframe/core").TimelineIR, insideBeat: boolean) => {
288
+ if (tl.kind === "beat") {
289
+ if (!insideBeat) {
290
+ let t0 = Infinity;
291
+ let t1 = -Infinity;
292
+ const collect = (s: import("@reframe/core").TimelineIR) => {
293
+ if ("label" in s && s.label !== undefined) {
294
+ const sp = lt.get(s.label);
295
+ if (sp) {
296
+ t0 = Math.min(t0, sp.t0);
297
+ t1 = Math.max(t1, sp.t1);
298
+ }
299
+ }
300
+ if ("children" in s) s.children.forEach(collect);
301
+ };
302
+ tl.children.forEach(collect);
303
+ const span = store!.compiled.beatTimes.get(tl.name);
304
+ // start where the chapter first animates (skips the leading wait); run to
305
+ // the beat's structural end so chapters tile instead of leaving gaps.
306
+ const start = t0 === Infinity ? (span?.t0 ?? 0) : t0;
307
+ const end = span ? span.t1 : t1 === -Infinity ? start : t1;
308
+ if (end >= start) {
309
+ segs.push({ label: tl.name, start, end, suffix: "", active: false, beat: true, onClick: () => setTime(start) });
310
+ }
311
+ }
312
+ tl.children.forEach((c) => visit(c, true));
313
+ return;
314
+ }
315
+ if ("children" in tl) tl.children.forEach((c) => visit(c, insideBeat));
316
+ };
317
+ if (ir.timeline) visit(ir.timeline, false);
318
+ return segs;
319
+ }
320
+
321
+ /** The bottom timeline: scene bands for a composition, else the open scene's
322
+ * top-level beat bands. Overlapping bands stack on lanes; click to jump. */
323
+ function buildTimeline() {
324
+ compTimelineEl.replaceChildren();
325
+ compPlayhead = null;
326
+ tkPlayhead = null;
327
+ let segs: TimelineSeg[];
328
+ let title: string;
329
+ if (activeComposition) {
330
+ const cc = compileComposition(activeComposition);
331
+ compTotal = cc.duration || 1;
332
+ compStarts = cc.scenes.map((p) => p.start);
333
+ segs = cc.scenes.map((p, i) => ({
334
+ label: p.id,
335
+ start: p.start,
336
+ end: p.start + p.duration,
337
+ suffix: p.transition === "crossfade" ? " ⤫" : "",
338
+ active: i === activeSceneIndex,
339
+ beat: false,
340
+ onClick: () => {
341
+ leavePlayAllMode();
342
+ void openScene(i);
343
+ },
344
+ }));
345
+ title = `▤ ${activeComposition.id} — ${cc.scenes.length} scenes · ${cc.duration.toFixed(1)}s`;
346
+ } else {
347
+ segs = topLevelBeatSegs();
348
+ compTotal = store?.compiled.duration || 1;
349
+ compStarts = [];
350
+ title = `beats — ${segs.length} · ${compTotal.toFixed(1)}s`;
351
+ }
352
+ if (segs.length === 0) {
353
+ compTimelineEl.classList.remove("on");
354
+ return;
355
+ }
356
+ compTimelineEl.classList.add("on");
357
+ compTimelineEl.append(el("div", { class: "ct-title" }, title));
358
+
359
+ const lanes = assignLanes(segs);
360
+ const laneCount = Math.max(...lanes) + 1;
361
+ const LANE_H = 28;
362
+ const GAP = 4;
363
+ const track = el("div", { id: "comp-track" });
364
+ track.style.height = `${laneCount * LANE_H + (laneCount - 1) * GAP}px`;
365
+ segs.forEach((s, i) => {
366
+ const band = el(
367
+ "div",
368
+ { class: `ct-scene${s.active ? " active" : ""}${s.beat ? " beat" : ""}`, title: s.label },
369
+ s.label,
370
+ el("span", { class: "ct-range" }, `${s.start.toFixed(1)}–${s.end.toFixed(1)}s${s.suffix}`),
371
+ );
372
+ band.style.left = `${(s.start / compTotal) * 100}%`;
373
+ band.style.width = `${Math.max(0, (s.end - s.start) / compTotal) * 100}%`;
374
+ band.style.top = `${lanes[i]! * (LANE_H + GAP)}px`;
375
+ band.style.height = `${LANE_H}px`;
376
+ // beat bands drag to retime (move/stretch); scene bands just open the scene
377
+ if (s.beat) attachBeatDrag(band, s);
378
+ else band.addEventListener("click", s.onClick);
379
+ track.append(band);
380
+ });
381
+ compPlayhead = el("div", { id: "ct-playhead" });
382
+ track.append(compPlayhead);
383
+ // a 120px label gutter (matching the node-track rows) so the band lane and the
384
+ // node lanes share one time axis — both playheads then land at the same x.
385
+ const bandRow = el("div", { class: "ct-bandrow" }, el("div", { class: "tk-label" }, activeComposition ? "scenes" : "beats"), track);
386
+ compTimelineEl.append(bandRow);
387
+ updateCompPlayhead();
388
+
389
+ // node-track dope sheet (the open scene's nodes ↔ their motion on the timeline)
390
+ if (store) {
391
+ const toggle = el("button", { class: "tk-toggle" }, `${tracksOpen ? "▾" : "▸"} node tracks`);
392
+ toggle.addEventListener("click", () => {
393
+ tracksOpen = !tracksOpen;
394
+ buildTimeline();
395
+ });
396
+ compTimelineEl.append(toggle);
397
+ const tracks = el("div", { id: "comp-tracks", class: tracksOpen ? "on" : "" });
398
+ compTimelineEl.append(tracks);
399
+ if (tracksOpen) buildTracks(tracks);
400
+ }
401
+ }
402
+
403
+ interface Bar {
404
+ t0: number;
405
+ t1: number;
406
+ prop: string;
407
+ }
408
+
409
+ /** Merge overlapping/adjacent bars into clean active windows (a group envelope). */
410
+ function mergeWindows(bars: Bar[]): { t0: number; t1: number }[] {
411
+ const sorted = [...bars].sort((a, b) => a.t0 - b.t0);
412
+ const out: { t0: number; t1: number }[] = [];
413
+ for (const b of sorted) {
414
+ const last = out[out.length - 1];
415
+ if (last && b.t0 <= last.t1 + 1e-3) last.t1 = Math.max(last.t1, b.t1);
416
+ else out.push({ t0: b.t0, t1: b.t1 });
417
+ }
418
+ return out;
419
+ }
420
+
421
+ /** Ancestor group ids on the path to `id` (so a selection can auto-unfold). */
422
+ function ancestorGroupIds(nodes: NodeIR[], id: string, path: string[] = []): string[] | null {
423
+ for (const n of nodes) {
424
+ if (n.id === id) return path;
425
+ if (n.type === "group") {
426
+ const r = ancestorGroupIds(n.children, id, [...path, n.id]);
427
+ if (r) return r;
428
+ }
429
+ }
430
+ return null;
431
+ }
432
+
433
+ /** The dope sheet: node motion (from compiled.segments/motionPaths — the graph
434
+ * reframe already computes) grouped by the scene graph and collapsible. A group
435
+ * row summarizes its subtree's active windows; unfold it for per-node lanes. */
436
+ function buildTracks(container: HTMLElement) {
437
+ if (!store) return;
438
+ const c = store.compiled;
439
+ const dur = c.duration || 1;
440
+ const bars = new Map<string, Bar[]>();
441
+ const push = (id: string, b: Bar) => {
442
+ const arr = bars.get(id);
443
+ if (arr) arr.push(b);
444
+ else bars.set(id, [b]);
445
+ };
446
+ for (const [key, segs] of c.segments) {
447
+ const dot = key.lastIndexOf(".");
448
+ const id = key.slice(0, dot);
449
+ const prop = key.slice(dot + 1);
450
+ for (const s of segs) push(id, { t0: s.t0, t1: s.t1, prop });
451
+ }
452
+ for (const [id, drivers] of c.motionPaths) {
453
+ for (const d of drivers) push(id, { t0: d.t0, t1: d.t1, prop: "path" });
454
+ }
455
+
456
+ // a selection auto-unfolds the groups on the way to it
457
+ if (store.selectedId) {
458
+ for (const anc of ancestorGroupIds(c.ir.nodes, store.selectedId) ?? []) expandedGroups.add(anc);
459
+ }
460
+
461
+ // a node's own animation PLUS its descendants' (a group can be tweened directly
462
+ // while its children stay static — include both).
463
+ const subtreeBars = (node: NodeIR): Bar[] => {
464
+ const own = bars.get(node.id) ?? [];
465
+ return node.type === "group" ? [...own, ...node.children.flatMap(subtreeBars)] : own;
466
+ };
467
+ const childBars = (node: NodeIR): Bar[] =>
468
+ node.type === "group" ? node.children.flatMap(subtreeBars) : [];
469
+
470
+ const left = (t: number) => `${(t / dur) * 100}%`;
471
+ const width = (t0: number, t1: number) => `${Math.max(0.4, ((t1 - t0) / dur) * 100)}%`;
472
+
473
+ const renderNode = (node: NodeIR, depth: number) => {
474
+ const sub = subtreeBars(node);
475
+ if (sub.length === 0) return; // nothing in this subtree animates
476
+ // only groups with animated CHILDREN unfold (a self-animated group is a leaf row)
477
+ const isGroup = node.type === "group" && childBars(node).length > 0;
478
+ const expanded = expandedGroups.has(node.id);
479
+ const row = el("div", { class: `tk-row${store!.selectedId === node.id ? " selected" : ""}` });
480
+ const caret = isGroup ? (expanded ? "▾ " : "▸ ") : "";
481
+ const label = el("div", { class: "tk-label", title: node.id, style: `padding-left:${6 + depth * 12}px` }, caret + node.id);
482
+ label.addEventListener("click", () => {
483
+ if (isGroup) {
484
+ if (expanded) expandedGroups.delete(node.id);
485
+ else expandedGroups.add(node.id);
486
+ buildTimeline();
487
+ } else store!.select(node.id);
488
+ });
489
+ const lane = el("div", { class: "tk-lane" });
490
+ if (isGroup) {
491
+ // group summary: merged active windows of the whole subtree
492
+ for (const w of mergeWindows(sub)) {
493
+ const bar = el("div", { class: "tk-bar group", title: `${node.id} ${w.t0.toFixed(2)}–${w.t1.toFixed(2)}s` });
494
+ bar.style.left = left(w.t0);
495
+ bar.style.width = width(w.t0, w.t1);
496
+ bar.addEventListener("click", () => setTime(w.t0));
497
+ lane.append(bar);
498
+ }
499
+ } else {
500
+ for (const b of sub) {
501
+ const bar = el("div", { class: `tk-bar${b.prop === "path" ? " path" : ""}`, title: `${b.prop} ${b.t0.toFixed(2)}–${b.t1.toFixed(2)}s` });
502
+ bar.style.left = left(b.t0);
503
+ bar.style.width = width(b.t0, b.t1);
504
+ bar.addEventListener("click", () => {
505
+ store!.select(node.id);
506
+ setTime(b.t0);
507
+ });
508
+ lane.append(bar);
509
+ }
510
+ }
511
+ row.append(label, lane);
512
+ container.append(row);
513
+ if (isGroup && expanded) for (const ch of node.children) renderNode(ch, depth + 1);
514
+ };
515
+ for (const node of c.ir.nodes) renderNode(node, 0);
516
+
517
+ tkPlayhead = el("div", { id: "tk-playhead" });
518
+ container.append(tkPlayhead);
519
+ updateTkPlayhead();
520
+ }
521
+
522
+ /** Position the playhead: composition time (open scene start + local t), or
523
+ * just local t for a single scene. */
524
+ function updateCompPlayhead() {
525
+ if (compPlayhead) {
526
+ const global = activeComposition ? (compStarts[activeSceneIndex] ?? 0) + t : t;
527
+ compPlayhead.style.left = `${(global / compTotal) * 100}%`;
528
+ }
529
+ updateTkPlayhead();
530
+ }
531
+
532
+ /** Node-track playhead at the open scene's local time (over the 120px label gutter). */
533
+ function updateTkPlayhead() {
534
+ if (!tkPlayhead || !store) return;
535
+ const frac = t / (store.compiled.duration || 1);
536
+ tkPlayhead.style.left = `calc(120px + ${frac} * (100% - 120px))`;
537
+ }
538
+
539
+ function el<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string, string>, ...kids: (HTMLElement | string)[]): HTMLElementTagNameMap[K] {
540
+ const n = document.createElement(tag);
541
+ for (const [k, v] of Object.entries(attrs)) k === "class" ? (n.className = v) : n.setAttribute(k, v);
542
+ n.append(...kids);
543
+ return n;
109
544
  }
110
545
 
111
546
  function applyMat(m: number[], x: number, y: number): [number, number] {
@@ -140,27 +575,144 @@ function opCorners(op: DisplayOp): [number, number][] {
140
575
  }
141
576
  }
142
577
 
578
+ function centroid(corners: [number, number][]): [number, number] {
579
+ let sx = 0;
580
+ let sy = 0;
581
+ for (const [x, y] of corners) {
582
+ sx += x;
583
+ sy += y;
584
+ }
585
+ return [sx / corners.length, sy / corners.length];
586
+ }
587
+
588
+ /** Onion-skin the SELECTED node across time: faint ghosts of its shape + a
589
+ * trail line + dots at uniform time samples (so the spacing reveals the ease —
590
+ * closer dots = slower). Makes the motion visible while editing one frame. */
591
+ function drawMotionPreview() {
592
+ if (!store || !store.selectedId) return;
593
+ const id = store.selectedId;
594
+ const D = store.compiled.duration;
595
+ if (!(D > 0)) return;
596
+ const N = 22;
597
+ const pts: ([number, number] | null)[] = [];
598
+ for (let i = 0; i <= N; i++) {
599
+ const op = evaluate(store.compiled, (i / N) * D).find((o) => o.id === id);
600
+ pts.push(op ? centroid(opCorners(op)) : null);
601
+ }
602
+ const real = pts.filter((p): p is [number, number] => !!p);
603
+ if (real.length < 2) return;
604
+ const xs = real.map((p) => p[0]);
605
+ const ys = real.map((p) => p[1]);
606
+ const spread = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
607
+ if (spread < 8) return; // a static node has no motion to preview
608
+
609
+ // faint ghosts of the node's shape at a few sampled times
610
+ for (let i = 0; i <= N; i += 4) {
611
+ const ops = evaluate(store.compiled, (i / N) * D)
612
+ .filter((o) => o.id === id)
613
+ .map((o) => ({ ...o, opacity: o.opacity * 0.12 }));
614
+ if (ops.length) drawDisplayList(ctx, ops, images);
615
+ }
616
+ ctx.save();
617
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
618
+ ctx.strokeStyle = "rgba(125,154,255,0.3)";
619
+ ctx.lineWidth = 1.5;
620
+ ctx.beginPath();
621
+ let started = false;
622
+ for (const p of pts) {
623
+ if (!p) continue;
624
+ if (!started) {
625
+ ctx.moveTo(p[0], p[1]);
626
+ started = true;
627
+ } else ctx.lineTo(p[0], p[1]);
628
+ }
629
+ ctx.stroke();
630
+ for (const p of real) {
631
+ ctx.beginPath();
632
+ ctx.arc(p[0], p[1], 2.6, 0, Math.PI * 2);
633
+ ctx.fillStyle = "#9db4ff";
634
+ ctx.fill();
635
+ }
636
+ const curOp = evaluate(store.compiled, t).find((o) => o.id === id);
637
+ if (curOp) {
638
+ const [cx, cy] = centroid(opCorners(curOp));
639
+ ctx.beginPath();
640
+ ctx.arc(cx, cy, 6, 0, Math.PI * 2);
641
+ ctx.strokeStyle = "#fff";
642
+ ctx.lineWidth = 2;
643
+ ctx.stroke();
644
+ }
645
+ ctx.restore();
646
+ }
647
+
143
648
  function draw() {
144
649
  if (!store) return;
145
650
  renderFrame(ctx, store.compiled, t, images);
651
+ drawMotionPreview();
146
652
 
147
- if (store.selectedId) {
148
- const ops = evaluate(store.compiled, t).filter((op) => op.id === store!.selectedId);
653
+ if (store.selectedIds.length > 0) {
654
+ const allOps = evaluate(store.compiled, t);
149
655
  ctx.save();
150
656
  ctx.setTransform(1, 0, 0, 1, 0, 0);
151
657
  ctx.strokeStyle = "#7d9aff";
152
658
  ctx.lineWidth = 2;
153
659
  ctx.setLineDash([6, 4]);
154
- for (const op of ops) {
155
- const corners = opCorners(op);
660
+ // a box per selected node (a group is boxed by the union of its descendants)
661
+ for (const selId of store.selectedIds) {
662
+ const selNode = findNodeById(store.compiled.ir.nodes, selId);
663
+ if (selNode && selNode.type === "group") {
664
+ const ids = new Set(descendantLeafIds(selNode));
665
+ const pts = allOps.filter((op) => ids.has(op.id)).flatMap(opCorners);
666
+ if (pts.length > 0) {
667
+ const xs = pts.map((p) => p[0]);
668
+ const ys = pts.map((p) => p[1]);
669
+ ctx.strokeRect(Math.min(...xs) - 6, Math.min(...ys) - 6, Math.max(...xs) - Math.min(...xs) + 12, Math.max(...ys) - Math.min(...ys) + 12);
670
+ }
671
+ } else {
672
+ for (const op of allOps.filter((op) => op.id === selId)) {
673
+ const corners = opCorners(op);
674
+ ctx.beginPath();
675
+ corners.forEach(([x, y], i) => (i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)));
676
+ if (corners.length > 2) ctx.closePath();
677
+ ctx.stroke();
678
+ }
679
+ }
680
+ }
681
+ // transform gizmo (single-select only): corner squares + a rotate handle
682
+ const g = selectedGizmo();
683
+ if (g) {
684
+ ctx.setLineDash([]);
685
+ ctx.fillStyle = "#7d9aff";
686
+ const topMid: [number, number] = [(g.corners[0]![0] + g.corners[1]![0]) / 2, (g.corners[0]![1] + g.corners[1]![1]) / 2];
156
687
  ctx.beginPath();
157
- corners.forEach(([x, y], i) => (i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)));
158
- if (corners.length > 2) ctx.closePath();
688
+ ctx.moveTo(topMid[0], topMid[1]);
689
+ ctx.lineTo(g.rot[0], g.rot[1]);
690
+ ctx.stroke();
691
+ for (const [hx, hy] of g.corners) ctx.fillRect(hx - 4, hy - 4, 8, 8);
692
+ ctx.beginPath();
693
+ ctx.arc(g.rot[0], g.rot[1], 5, 0, Math.PI * 2);
694
+ ctx.fill();
695
+ ctx.strokeStyle = "#0b0b12";
696
+ ctx.lineWidth = 2;
159
697
  ctx.stroke();
160
698
  }
161
699
  ctx.restore();
162
700
  }
163
701
 
702
+ // marquee rubber-band
703
+ if (drag?.kind === "marquee") {
704
+ const r = normRect(drag);
705
+ ctx.save();
706
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
707
+ ctx.fillStyle = "rgba(125,154,255,0.12)";
708
+ ctx.strokeStyle = "#7d9aff";
709
+ ctx.lineWidth = 1;
710
+ ctx.setLineDash([4, 3]);
711
+ ctx.fillRect(r.minX, r.minY, r.maxX - r.minX, r.maxY - r.minY);
712
+ ctx.strokeRect(r.minX, r.minY, r.maxX - r.minX, r.maxY - r.minY);
713
+ ctx.restore();
714
+ }
715
+
164
716
  // motionPath waypoint handles — drag to reshape the curve (writes a
165
717
  // timeline.<label>.points overlay patch that survives base regeneration).
166
718
  // Points are in the target's parent space; correct for top-level targets.
@@ -189,6 +741,8 @@ function draw() {
189
741
  const duration = store.compiled.duration;
190
742
  scrub.value = String(duration ? t / duration : 0);
191
743
  timeLabel.textContent = `${t.toFixed(3)} / ${duration.toFixed(3)}`;
744
+ updateCompPlayhead();
745
+ canvas.style.cursor = store.pendingMove ? "crosshair" : "";
192
746
  }
193
747
 
194
748
  const HANDLE_R = 9;
@@ -199,36 +753,450 @@ function clientToScene(ev: MouseEvent): [number, number] {
199
753
  return [((ev.clientX - r.left) * canvas.width) / r.width, ((ev.clientY - r.top) * canvas.height) / r.height];
200
754
  }
201
755
 
202
- let drag: { label: string; index: number; points: [number, number][] } | null = null;
756
+ /** Is (x,y) inside the op's outline? Polygon for shaped nodes, radius for a path origin. */
757
+ function hitOp(op: DisplayOp, x: number, y: number): boolean {
758
+ const c = opCorners(op);
759
+ if (c.length >= 3) {
760
+ let inside = false;
761
+ for (let i = 0, j = c.length - 1; i < c.length; j = i++) {
762
+ const [xi, yi] = c[i]!;
763
+ const [xj, yj] = c[j]!;
764
+ if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
765
+ }
766
+ return inside;
767
+ }
768
+ return c.some(([px, py]) => Math.hypot(px - x, py - y) <= 16);
769
+ }
770
+
771
+ type DragState =
772
+ | { kind: "waypoint"; label: string; index: number; points: [number, number][] }
773
+ // `inv` is the inverse of the node's parent-matrix linear part, mapping a
774
+ // scene-space drag delta into the node's parent space (identity for a
775
+ // top-level node, so the delta is 1:1; non-trivial for nested children).
776
+ | { kind: "node"; id: string; startX: number; startY: number; px: number; py: number; inv: [number, number, number, number] }
777
+ // transform gizmo: scale/rotate the node around its anchor (`pivot`). The
778
+ // parent transform cancels in the distance/angle ratios, so this is correct
779
+ // for nested nodes too.
780
+ | { kind: "scale"; id: string; pivot: [number, number]; origScale: number; origDist: number }
781
+ | { kind: "rotate"; id: string; pivot: [number, number]; origRot: number; startAngle: number }
782
+ // multi-drag: move every selected node by one scene delta (each via its own inv)
783
+ | { kind: "nodes"; items: { id: string; startX: number; startY: number; inv: [number, number, number, number] }[]; px: number; py: number }
784
+ // marquee: rubber-band box select (additive with shift)
785
+ | { kind: "marquee"; x0: number; y0: number; x1: number; y1: number; additive: boolean };
786
+ let drag: DragState | null = null;
787
+
788
+ /** The selected node's box op + gizmo geometry (anchor pivot, rotation handle),
789
+ * or null if it isn't a box-shaped leaf, or more than one node is selected. */
790
+ function selectedGizmo(): { op: DisplayOp; corners: [number, number][]; pivot: [number, number]; rot: [number, number] } | null {
791
+ if (!store || store.selectedIds.length !== 1) return null;
792
+ const op = evaluate(store.compiled, t).find((o) => o.id === store!.selectedId);
793
+ if (!op) return null;
794
+ const corners = opCorners(op);
795
+ if (corners.length < 4) return null;
796
+ const pivot: [number, number] = [op.transform[4], op.transform[5]]; // anchor (local origin) in scene coords
797
+ const c = centroid(corners);
798
+ const topMid: [number, number] = [(corners[0]![0] + corners[1]![0]) / 2, (corners[0]![1] + corners[1]![1]) / 2];
799
+ let dx = topMid[0] - c[0];
800
+ let dy = topMid[1] - c[1];
801
+ const len = Math.hypot(dx, dy) || 1;
802
+ dx /= len;
803
+ dy /= len;
804
+ const rot: [number, number] = [topMid[0] + dx * 28, topMid[1] + dy * 28];
805
+ return { op, corners, pivot, rot };
806
+ }
807
+
808
+ function findNodeById(nodes: NodeIR[], id: string): NodeIR | null {
809
+ for (const node of nodes) {
810
+ if (node.id === id) return node;
811
+ if (node.type === "group") {
812
+ const hit = findNodeById(node.children, id);
813
+ if (hit) return hit;
814
+ }
815
+ }
816
+ return null;
817
+ }
818
+
819
+ /** Leaf (drawable) descendant ids of a node — the ops that form its hit area. */
820
+ function descendantLeafIds(node: NodeIR): string[] {
821
+ if (node.type !== "group") return [node.id];
822
+ return node.children.flatMap(descendantLeafIds);
823
+ }
824
+
825
+ /** Begin dragging a node's x/y, capturing the inverse parent-space mapping. */
826
+ function startNodeDrag(id: string, x: number, y: number) {
827
+ if (!store) return;
828
+ const node = findNodeById(store.compiled.ir.nodes, id);
829
+ if (!node || !("x" in node.props)) return;
830
+ const props = node.props as { x: number; y: number };
831
+ const p = nodeParentMatrix(store.compiled, id, t) ?? [1, 0, 0, 1, 0, 0];
832
+ const [a, b, c, d] = p;
833
+ const det = a * d - b * c || 1;
834
+ drag = { kind: "node", id, startX: props.x, startY: props.y, px: x, py: y, inv: [d / det, -b / det, -c / det, a / det] };
835
+ playing = false;
836
+ playBtn.textContent = "play";
837
+ }
838
+
839
+ /** Leaf ids covered by the current selection (selected groups expand to leaves). */
840
+ function selectedLeafIds(): Set<string> {
841
+ const out = new Set<string>();
842
+ if (!store) return out;
843
+ for (const id of store.selectedIds) {
844
+ const n = findNodeById(store.compiled.ir.nodes, id);
845
+ if (n) for (const lid of descendantLeafIds(n)) out.add(lid);
846
+ else out.add(id);
847
+ }
848
+ return out;
849
+ }
850
+
851
+ /** Begin moving ALL selected nodes together (each by its own parent-space delta).
852
+ * Descendants of a selected group are dropped so they don't double-move. */
853
+ function startNodesDrag(x: number, y: number) {
854
+ if (!store) return;
855
+ const groupDesc = new Set<string>();
856
+ for (const id of store.selectedIds) {
857
+ const n = findNodeById(store.compiled.ir.nodes, id);
858
+ if (n?.type === "group") for (const d of descendantLeafIds(n)) groupDesc.add(d);
859
+ }
860
+ const items: { id: string; startX: number; startY: number; inv: [number, number, number, number] }[] = [];
861
+ for (const id of store.selectedIds) {
862
+ if (groupDesc.has(id)) continue;
863
+ const node = findNodeById(store.compiled.ir.nodes, id);
864
+ if (!node || !("x" in node.props)) continue;
865
+ const props = node.props as { x: number; y: number };
866
+ const p = nodeParentMatrix(store.compiled, id, t) ?? [1, 0, 0, 1, 0, 0];
867
+ const [a, b, c, d] = p;
868
+ const det = a * d - b * c || 1;
869
+ items.push({ id, startX: props.x, startY: props.y, inv: [d / det, -b / det, -c / det, a / det] });
870
+ }
871
+ drag = { kind: "nodes", items, px: x, py: y };
872
+ playing = false;
873
+ playBtn.textContent = "play";
874
+ }
875
+
876
+ const normRect = (m: { x0: number; y0: number; x1: number; y1: number }) => ({
877
+ minX: Math.min(m.x0, m.x1),
878
+ minY: Math.min(m.y0, m.y1),
879
+ maxX: Math.max(m.x0, m.x1),
880
+ maxY: Math.max(m.y0, m.y1),
881
+ });
882
+
883
+ /** AABB-overlap test of an op's bounding box against a marquee rect. */
884
+ function rectIntersectsOp(op: DisplayOp, r: { minX: number; minY: number; maxX: number; maxY: number }): boolean {
885
+ const c = opCorners(op);
886
+ if (c.length === 0) return false;
887
+ const xs = c.map((p) => p[0]);
888
+ const ys = c.map((p) => p[1]);
889
+ return Math.min(...xs) <= r.maxX && Math.max(...xs) >= r.minX && Math.min(...ys) <= r.maxY && Math.max(...ys) >= r.minY;
890
+ }
891
+
203
892
  canvas.addEventListener("mousedown", (ev) => {
204
893
  if (!store) return;
205
894
  const [x, y] = clientToScene(ev);
895
+ // 0) "add move" armed: this click is the destination for the node's first move
896
+ if (store.pendingMove) {
897
+ const id = store.pendingMove;
898
+ store.disarmMove();
899
+ store.addMove(id, [x, y]);
900
+ draw();
901
+ ev.preventDefault();
902
+ return;
903
+ }
904
+ // 1) motionPath waypoint handles take priority
206
905
  for (const mp of store.motionPaths()) {
207
906
  const i = mp.points.findIndex(([px, py]) => Math.hypot(px - x, py - y) <= HANDLE_R + 4);
208
907
  if (i >= 0) {
209
- drag = { label: mp.label, index: i, points: mp.points.map((p) => [...p] as [number, number]) };
908
+ drag = { kind: "waypoint", label: mp.label, index: i, points: mp.points.map((p) => [...p] as [number, number]) };
210
909
  playing = false;
211
910
  playBtn.textContent = "play";
212
911
  ev.preventDefault();
213
912
  return;
214
913
  }
215
914
  }
915
+ // 1.5) transform gizmo handles on the selected node (single-select only)
916
+ const g = selectedGizmo();
917
+ if (g && store.selectedId) {
918
+ const id = store.selectedId;
919
+ const node = findNodeById(store.compiled.ir.nodes, id)!;
920
+ const p = node.props as { scale?: number; rotation?: number };
921
+ if (Math.hypot(g.rot[0] - x, g.rot[1] - y) <= HANDLE_R) {
922
+ drag = { kind: "rotate", id, pivot: g.pivot, origRot: p.rotation ?? 0, startAngle: Math.atan2(y - g.pivot[1], x - g.pivot[0]) };
923
+ playing = false;
924
+ playBtn.textContent = "play";
925
+ ev.preventDefault();
926
+ return;
927
+ }
928
+ for (const cor of g.corners) {
929
+ if (Math.hypot(cor[0] - x, cor[1] - y) <= HANDLE_R) {
930
+ const origDist = Math.hypot(cor[0] - g.pivot[0], cor[1] - g.pivot[1]) || 1;
931
+ drag = { kind: "scale", id, pivot: g.pivot, origScale: p.scale ?? 1, origDist };
932
+ playing = false;
933
+ playBtn.textContent = "play";
934
+ ev.preventDefault();
935
+ return;
936
+ }
937
+ }
938
+ }
939
+ const ops = evaluate(store.compiled, t);
940
+ // 2) a single SELECTED group moves as a whole when you press inside its content.
941
+ const sel = store.selectedIds.length === 1 ? findNodeById(store.compiled.ir.nodes, store.selectedIds[0]!) : null;
942
+ if (sel && sel.type === "group") {
943
+ const ids = new Set(descendantLeafIds(sel));
944
+ if (ops.some((op) => ids.has(op.id) && hitOp(op, x, y))) {
945
+ startNodeDrag(sel.id, x, y);
946
+ ev.preventDefault();
947
+ return;
948
+ }
949
+ }
950
+ // 3) the top-most leaf under the cursor: shift toggles; an already-selected node
951
+ // starts a multi-drag of all selected; otherwise replace + drag just it.
952
+ let hit: DisplayOp | null = null;
953
+ for (let i = ops.length - 1; i >= 0; i--) {
954
+ const op = ops[i]!;
955
+ if (op.type === "line" || !hitOp(op, x, y)) continue;
956
+ hit = op;
957
+ break;
958
+ }
959
+ if (hit) {
960
+ if (ev.shiftKey) {
961
+ store.select(hit.id, true); // toggle, no drag
962
+ } else if (selectedLeafIds().has(hit.id)) {
963
+ startNodesDrag(x, y); // drag the whole selection, keep it
964
+ } else {
965
+ store.select(hit.id);
966
+ startNodeDrag(hit.id, x, y);
967
+ }
968
+ ev.preventDefault();
969
+ return;
970
+ }
971
+ // 4) empty canvas → marquee box-select (clears first unless shift-adding)
972
+ if (!ev.shiftKey) store.select(null);
973
+ drag = { kind: "marquee", x0: x, y0: y, x1: x, y1: y, additive: ev.shiftKey };
974
+ ev.preventDefault();
216
975
  });
976
+
217
977
  window.addEventListener("mousemove", (ev) => {
218
978
  if (!drag || !store) return;
219
979
  const [x, y] = clientToScene(ev);
220
- drag.points[drag.index] = [Math.round(x), Math.round(y)];
221
- store.setMotionPathPoints(drag.label, drag.points);
980
+ if (drag.kind === "waypoint") {
981
+ drag.points[drag.index] = [Math.round(x), Math.round(y)];
982
+ store.setMotionPathPoints(drag.label, drag.points);
983
+ } else if (drag.kind === "scale") {
984
+ const dist = Math.hypot(x - drag.pivot[0], y - drag.pivot[1]);
985
+ store.setNodeProp(drag.id, "scale", Math.max(0.02, round3((drag.origScale * dist) / drag.origDist)));
986
+ } else if (drag.kind === "rotate") {
987
+ const deg = round3(drag.origRot + ((Math.atan2(y - drag.pivot[1], x - drag.pivot[0]) - drag.startAngle) * 180) / Math.PI);
988
+ store.setNodeProp(drag.id, "rotation", deg);
989
+ } else if (drag.kind === "marquee") {
990
+ drag.x1 = x;
991
+ drag.y1 = y;
992
+ } else if (drag.kind === "nodes") {
993
+ const dx = x - drag.px;
994
+ const dy = y - drag.py;
995
+ store.setNodeProps(
996
+ drag.items.flatMap((it) => {
997
+ const [ia, ib, ic, id] = it.inv;
998
+ return [
999
+ { id: it.id, prop: "x", value: Math.round(it.startX + ia * dx + ic * dy) },
1000
+ { id: it.id, prop: "y", value: Math.round(it.startY + ib * dx + id * dy) },
1001
+ ];
1002
+ }),
1003
+ );
1004
+ } else {
1005
+ const dx = x - drag.px;
1006
+ const dy = y - drag.py;
1007
+ const [ia, ib, ic, id] = drag.inv;
1008
+ store.setNodeProp(drag.id, "x", Math.round(drag.startX + ia * dx + ic * dy));
1009
+ store.setNodeProp(drag.id, "y", Math.round(drag.startY + ib * dx + id * dy));
1010
+ }
222
1011
  draw();
223
1012
  });
224
1013
  window.addEventListener("mouseup", () => {
1014
+ if (drag?.kind === "marquee" && store) {
1015
+ const r = normRect(drag);
1016
+ const ids = evaluate(store.compiled, t)
1017
+ .filter((op) => op.type !== "line" && rectIntersectsOp(op, r))
1018
+ .map((op) => op.id);
1019
+ if (drag.additive) store.selectMany(ids, true);
1020
+ else store.selectMany(ids);
1021
+ draw();
1022
+ }
225
1023
  drag = null;
226
1024
  });
227
1025
 
1026
+ /** Every selectable leaf id (drawable, non-line), for Cmd/Ctrl-A. */
1027
+ function allSelectableIds(): string[] {
1028
+ if (!store) return [];
1029
+ const out: string[] = [];
1030
+ const walk = (nodes: NodeIR[]) => {
1031
+ for (const n of nodes) {
1032
+ if (n.type === "group") walk(n.children);
1033
+ else if (n.type !== "line") out.push(n.id);
1034
+ }
1035
+ };
1036
+ walk(store.compiled.ir.nodes);
1037
+ return out;
1038
+ }
1039
+
1040
+ window.addEventListener("keydown", (ev) => {
1041
+ if (!store) return;
1042
+ const tag = (ev.target as HTMLElement | null)?.tagName;
1043
+ if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return; // don't hijack form fields
1044
+ if (ev.key === "Escape") {
1045
+ drag = null;
1046
+ store.select(null);
1047
+ draw();
1048
+ } else if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === "a") {
1049
+ ev.preventDefault();
1050
+ store.selectMany(allSelectableIds());
1051
+ draw();
1052
+ }
1053
+ });
1054
+
1055
+ // drag-and-drop an image file onto the canvas → an image node at the drop point,
1056
+ // sized to the image (fit to ~half the frame), src as a data URL.
1057
+ canvas.addEventListener("dragover", (ev) => {
1058
+ if (ev.dataTransfer?.types.includes("Files")) {
1059
+ ev.preventDefault();
1060
+ ev.dataTransfer.dropEffect = "copy";
1061
+ }
1062
+ });
1063
+ canvas.addEventListener("drop", (ev) => {
1064
+ if (!store) return;
1065
+ const file = ev.dataTransfer?.files?.[0];
1066
+ if (!file || !file.type.startsWith("image/")) return;
1067
+ ev.preventDefault();
1068
+ const [x, y] = clientToScene(ev);
1069
+ const reader = new FileReader();
1070
+ reader.onload = () => {
1071
+ const src = String(reader.result);
1072
+ const img = new Image();
1073
+ img.onload = () => {
1074
+ const fit = Math.min(1, (store!.base.size.width * 0.5) / img.width, (store!.base.size.height * 0.5) / img.height);
1075
+ const width = Math.round(img.width * fit);
1076
+ const height = Math.round(img.height * fit);
1077
+ store!.addNode("image", { src, width, height, x: Math.round(x), y: Math.round(y) });
1078
+ };
1079
+ img.src = src;
1080
+ };
1081
+ reader.readAsDataURL(file);
1082
+ });
1083
+
1084
+ /** Distance from point p to segment a→b. */
1085
+ function distToSeg(p: [number, number], a: [number, number], b: [number, number]): number {
1086
+ const dx = b[0] - a[0];
1087
+ const dy = b[1] - a[1];
1088
+ const len2 = dx * dx + dy * dy;
1089
+ let u = len2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2 : 0;
1090
+ u = Math.max(0, Math.min(1, u));
1091
+ return Math.hypot(p[0] - (a[0] + u * dx), p[1] - (a[1] + u * dy));
1092
+ }
1093
+
1094
+ // double-click: on a waypoint handle → remove it (keep ≥2); near a path
1095
+ // segment → insert a new waypoint there (more bends). MotionPathHelper pattern.
1096
+ canvas.addEventListener("dblclick", (ev) => {
1097
+ if (!store) return;
1098
+ const [x, y] = clientToScene(ev);
1099
+ for (const mp of store.motionPaths()) {
1100
+ const onHandle = mp.points.findIndex(([px, py]) => Math.hypot(px - x, py - y) <= HANDLE_R + 4);
1101
+ if (onHandle >= 0) {
1102
+ if (mp.points.length > 2) {
1103
+ store.setMotionPathPoints(mp.label, mp.points.filter((_, i) => i !== onHandle));
1104
+ draw();
1105
+ }
1106
+ ev.preventDefault();
1107
+ return;
1108
+ }
1109
+ for (let i = 0; i < mp.points.length - 1; i++) {
1110
+ if (distToSeg([x, y], mp.points[i]!, mp.points[i + 1]!) <= 14) {
1111
+ const next = mp.points.map((p) => [...p] as [number, number]);
1112
+ next.splice(i + 1, 0, [Math.round(x), Math.round(y)]);
1113
+ store.setMotionPathPoints(mp.label, next);
1114
+ draw();
1115
+ ev.preventDefault();
1116
+ return;
1117
+ }
1118
+ }
1119
+ }
1120
+ // not on a waypoint → dive in: select the top-most leaf under the cursor (so a
1121
+ // child inside a selected group becomes selectable for direct dragging).
1122
+ const ops = evaluate(store.compiled, t);
1123
+ for (let i = ops.length - 1; i >= 0; i--) {
1124
+ const op = ops[i]!;
1125
+ if (op.type === "line" || !hitOp(op, x, y)) continue;
1126
+ store.select(op.id);
1127
+ draw();
1128
+ ev.preventDefault();
1129
+ return;
1130
+ }
1131
+ // empty space + a selected, motionless, top-level node → give it its FIRST
1132
+ // move: a path from where it sits to here (then double-click the path to bend).
1133
+ const sel = store.selectedId;
1134
+ if (sel && !store.hasMotionPath(sel)) {
1135
+ const node = findNodeById(store.compiled.ir.nodes, sel);
1136
+ const topLevel = store.compiled.ir.nodes.some((n) => n.id === sel);
1137
+ if (node && topLevel && node.type !== "line") {
1138
+ store.addMove(sel, [x, y]);
1139
+ draw();
1140
+ ev.preventDefault();
1141
+ }
1142
+ }
1143
+ });
1144
+
1145
+ /** The scene index whose window contains composition time T (the later one in a
1146
+ * crossfade overlap — the incoming scene). */
1147
+ function sceneIndexAt(T: number): number {
1148
+ let idx = 0;
1149
+ for (let i = 0; i < compStarts.length; i++) if (compStarts[i]! <= T + 1e-6) idx = i;
1150
+ return idx;
1151
+ }
1152
+
1153
+ /** Draw one scene's frame onto the canvas at `alpha` (its own background fills
1154
+ * the frame, so alpha<1 cross-dissolves it over what's already drawn). */
1155
+ function drawSceneAt(compiled: import("@reframe/core").CompiledScene, localT: number, alpha: number) {
1156
+ ctx.save();
1157
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1158
+ ctx.globalAlpha = alpha;
1159
+ ctx.fillStyle = compiled.ir.background ?? "#000";
1160
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
1161
+ drawDisplayList(ctx, evaluate(compiled, localT), images);
1162
+ ctx.restore();
1163
+ }
1164
+
1165
+ /** Render the whole composition at global time T, blending crossfades — driven
1166
+ * from precompiled scenes, so no editor-store swap (smooth, continuous). */
1167
+ function drawComposition(T: number) {
1168
+ if (!playCC) return;
1169
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1170
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1171
+ const active = playCC.scenes
1172
+ .filter((p) => T >= p.start - 1e-6 && T < p.start + p.duration)
1173
+ .sort((a, b) => a.start - b.start);
1174
+ for (const p of active) {
1175
+ // an incoming crossfade ramps 0→1 over its overlap; everything else is opaque
1176
+ const alpha =
1177
+ p.transition === "crossfade" && p.overlap > 0 && T < p.start + p.overlap
1178
+ ? Math.max(0, Math.min(1, (T - p.start) / p.overlap))
1179
+ : 1;
1180
+ drawSceneAt(p.compiled, T - p.start, alpha);
1181
+ }
1182
+ if (compPlayhead) compPlayhead.style.left = `${(T / compTotal) * 100}%`;
1183
+ scrub.value = String(compTotal ? T / compTotal : 0);
1184
+ timeLabel.textContent = `${T.toFixed(3)} / ${compTotal.toFixed(3)}`;
1185
+ }
1186
+
228
1187
  function tick(now: number) {
229
- if (playing && store) {
230
- t += (now - lastTick) / 1000;
231
- if (t > store.compiled.duration) t = 0; // loop
1188
+ const dt = (now - lastTick) / 1000;
1189
+ if (playAll && playCC) {
1190
+ if (playing) {
1191
+ compT += dt * speed;
1192
+ if (compT >= compTotal) compT = 0; // loop the whole composition
1193
+ drawComposition(compT);
1194
+ }
1195
+ } else if (playing && store) {
1196
+ t += dt * speed;
1197
+ const lo = loopOn ? tIn : 0;
1198
+ const hi = loopOn ? Math.min(tOut, store.compiled.duration) : store.compiled.duration;
1199
+ if (t > hi || t < lo) t = lo; // loop the [in, out] range (or the whole clip)
232
1200
  draw();
233
1201
  }
234
1202
  lastTick = now;
@@ -236,17 +1204,127 @@ function tick(now: number) {
236
1204
  }
237
1205
  requestAnimationFrame(tick);
238
1206
 
1207
+ /** Enter continuous-play mode: precompile every scene, preload images, drive
1208
+ * the canvas globally. The editor store is left frozen until we exit. */
1209
+ async function enterPlayAll() {
1210
+ if (!activeComposition) return;
1211
+ playCC = compileComposition(activeComposition);
1212
+ compTotal = playCC.duration || 1;
1213
+ compStarts = playCC.scenes.map((p) => p.start);
1214
+ compT = (compStarts[activeSceneIndex] ?? 0) + t; // continue from where we are
1215
+ const size = playCC.scenes[0]!.compiled.ir.size;
1216
+ canvas.width = size.width;
1217
+ canvas.height = size.height;
1218
+ await ensureCompositionImages(playCC);
1219
+ playAll = true;
1220
+ playing = true;
1221
+ playAllBtn.classList.add("on");
1222
+ playAllBtn.textContent = "stop all";
1223
+ playBtn.textContent = "pause";
1224
+ drawComposition(compT);
1225
+ }
1226
+
1227
+ /** Leave continuous-play mode WITHOUT opening a scene (caller opens one). */
1228
+ function leavePlayAllMode() {
1229
+ playAll = false;
1230
+ playing = false;
1231
+ playCC = null;
1232
+ playAllBtn.classList.remove("on");
1233
+ playAllBtn.textContent = "play all";
1234
+ playBtn.textContent = "play";
1235
+ }
1236
+
1237
+ /** Exit to the editor at the current global time (open the scene under T). */
1238
+ async function exitPlayAll() {
1239
+ if (!playAll) return;
1240
+ const T = compT;
1241
+ const idx = sceneIndexAt(T);
1242
+ leavePlayAllMode();
1243
+ await openScene(idx);
1244
+ if (store) t = Math.max(0, Math.min(T - (compStarts[idx] ?? 0), store.compiled.duration));
1245
+ draw();
1246
+ }
1247
+
1248
+ playAllBtn.addEventListener("click", () => {
1249
+ if (!activeComposition) return;
1250
+ if (playAll) void exitPlayAll();
1251
+ else void enterPlayAll();
1252
+ });
1253
+
1254
+ /** Position the loop-range band over the scrubber. */
1255
+ function updateLoopBand() {
1256
+ if (!store || !loopOn || !(store.compiled.duration > 0)) {
1257
+ loopBand.style.display = "none";
1258
+ return;
1259
+ }
1260
+ const D = store.compiled.duration;
1261
+ const a = Math.max(0, Math.min(1, tIn / D));
1262
+ const b = Math.max(a, Math.min(1, Math.min(tOut, D) / D));
1263
+ loopBand.style.display = "block";
1264
+ loopBand.style.left = `${a * 100}%`;
1265
+ loopBand.style.width = `${(b - a) * 100}%`;
1266
+ }
1267
+ markInBtn.addEventListener("click", () => {
1268
+ tIn = t;
1269
+ if (tOut <= tIn) tOut = store?.compiled.duration ?? Infinity;
1270
+ updateLoopBand();
1271
+ });
1272
+ markOutBtn.addEventListener("click", () => {
1273
+ tOut = t;
1274
+ if (tIn >= tOut) tIn = 0;
1275
+ updateLoopBand();
1276
+ });
1277
+ loopBtn.addEventListener("click", () => {
1278
+ loopOn = !loopOn;
1279
+ loopBtn.classList.toggle("on", loopOn);
1280
+ if (loopOn && tOut === Infinity) tOut = store?.compiled.duration ?? Infinity;
1281
+ updateLoopBand();
1282
+ });
1283
+ speedSel.addEventListener("change", () => {
1284
+ speed = Number(speedSel.value);
1285
+ });
1286
+
1287
+ // --- deep-linking: ?scene=<label>&t=<sec> lets a driver open an exact frame ---
1288
+ function keyForLabel(label: string): string | undefined {
1289
+ return Object.keys(modules).find((k) => modules[k]!.label === label);
1290
+ }
1291
+ function syncUrl() {
1292
+ if (activeComposition) return; // compositions are navigated, not deep-linked
1293
+ const label = modules[select.value]?.label ?? select.value;
1294
+ history.replaceState(null, "", `?scene=${encodeURIComponent(label)}&t=${t.toFixed(3)}`);
1295
+ }
1296
+ /** Set the scrub time (also exposed as window.__setTime for automation). */
1297
+ function setTime(sec: number) {
1298
+ if (!store) return;
1299
+ t = Math.max(0, Math.min(sec, store.compiled.duration));
1300
+ playing = false;
1301
+ playBtn.textContent = "play";
1302
+ draw();
1303
+ syncUrl();
1304
+ }
1305
+ (window as unknown as { __setTime: (s: number) => void }).__setTime = setTime;
1306
+ (window as unknown as { __gizmo: typeof selectedGizmo }).__gizmo = selectedGizmo; // debug/testing hook
1307
+
239
1308
  playBtn.addEventListener("click", () => {
240
1309
  playing = !playing;
241
1310
  playBtn.textContent = playing ? "pause" : "play";
242
1311
  });
243
1312
 
244
1313
  scrub.addEventListener("input", () => {
1314
+ // in play-all the scrubber spans the whole composition (global time)
1315
+ if (playAll && playCC) {
1316
+ playing = false;
1317
+ playBtn.textContent = "play";
1318
+ compT = Number(scrub.value) * compTotal;
1319
+ drawComposition(compT);
1320
+ return;
1321
+ }
245
1322
  if (!store) return;
246
1323
  playing = false;
247
1324
  playBtn.textContent = "play";
248
1325
  t = Number(scrub.value) * store.compiled.duration;
249
1326
  draw();
1327
+ syncUrl();
250
1328
  });
251
1329
 
252
1330
  let currentPath = "";
@@ -256,12 +1334,20 @@ select.addEventListener("change", () => {
256
1334
  return;
257
1335
  }
258
1336
  currentPath = select.value;
259
- void loadScene(select.value);
1337
+ if (currentPath.startsWith("comp:")) void loadComposition(currentPath);
1338
+ else void loadScene(currentPath);
260
1339
  });
261
- if (Object.keys(modules).length === 0) {
1340
+ if (Object.keys(modules).length === 0 && Object.keys(compositions).length === 0) {
262
1341
  panelRoot.innerHTML =
263
1342
  "<p style='padding:12px;color:#aab'>No scenes found. Scaffold one in this directory with <code>reframe new my-scene</code>, then reload.</p>";
264
1343
  } else {
265
- currentPath = select.value || Object.keys(modules)[0]!;
266
- void loadScene(currentPath);
1344
+ const params = new URLSearchParams(location.search);
1345
+ const fromUrl = params.get("scene");
1346
+ currentPath = (fromUrl && keyForLabel(fromUrl)) || select.value || Object.keys(modules)[0]!;
1347
+ select.value = currentPath;
1348
+ const tParam = params.get("t");
1349
+ const initial = currentPath.startsWith("comp:") ? loadComposition(currentPath) : loadScene(currentPath);
1350
+ void initial.then(() => {
1351
+ if (tParam !== null) setTime(Number(tParam));
1352
+ });
267
1353
  }