reframe-video 0.1.2 → 0.2.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 (42) hide show
  1. package/assets/sfx/LICENSE.md +1 -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/glass_001.ogg +0 -0
  8. package/assets/sfx/maximize_001.ogg +0 -0
  9. package/assets/sfx/maximize_002.ogg +0 -0
  10. package/assets/sfx/maximize_005.ogg +0 -0
  11. package/assets/sfx/maximize_009.ogg +0 -0
  12. package/assets/sfx/open_001.ogg +0 -0
  13. package/assets/sfx/pluck_001.ogg +0 -0
  14. package/assets/sfx/pluck_002.ogg +0 -0
  15. package/assets/sfx/select_001.ogg +0 -0
  16. package/assets/sfx/select_002.ogg +0 -0
  17. package/assets/sfx/select_003.ogg +0 -0
  18. package/dist/bin.js +724 -131
  19. package/dist/browserEntry.js +130 -68
  20. package/dist/cli.js +445 -85
  21. package/dist/index.js +674 -86
  22. package/dist/labels.js +606 -0
  23. package/dist/renderer-canvas.js +15 -0
  24. package/dist/trace-cli.js +9 -9
  25. package/dist/types/audio.d.ts +9 -0
  26. package/dist/types/compile.d.ts +1 -0
  27. package/dist/types/compose.d.ts +18 -2
  28. package/dist/types/composeComposition.d.ts +27 -0
  29. package/dist/types/devicePreset.d.ts +65 -0
  30. package/dist/types/dsl.d.ts +12 -1
  31. package/dist/types/evaluate.d.ts +32 -0
  32. package/dist/types/index.d.ts +6 -3
  33. package/dist/types/ir.d.ts +68 -0
  34. package/dist/types/motionOps.d.ts +36 -0
  35. package/dist/types/path.d.ts +7 -3
  36. package/dist/types/validate.d.ts +4 -1
  37. package/guides/edsl-guide.md +2 -1
  38. package/package.json +1 -1
  39. package/preview/index.html +56 -3
  40. package/preview/src/main.ts +1132 -46
  41. package/preview/src/panel.ts +478 -8
  42. package/preview/src/store.ts +323 -6
@@ -8,10 +8,15 @@
8
8
  import {
9
9
  composeScene,
10
10
  compileScene,
11
+ motionOp,
12
+ motionOpLabel,
11
13
  SceneValidationError,
12
14
  type BehaviorIR,
13
15
  type CompiledScene,
14
16
  type ComposeReport,
17
+ type MotionOpName,
18
+ type MotionOpOpts,
19
+ type NodeIR,
15
20
  type OverlayDoc,
16
21
  type PropValue,
17
22
  type SceneIR,
@@ -21,6 +26,12 @@ import {
21
26
  export type ChangeKind = "value" | "structure";
22
27
  type Listener = (kind: ChangeKind) => void;
23
28
 
29
+ interface AddedOp {
30
+ name: MotionOpName;
31
+ target: string;
32
+ opts: MotionOpOpts;
33
+ }
34
+
24
35
  export class EditorStore {
25
36
  base: SceneIR;
26
37
  draft: OverlayDoc;
@@ -28,8 +39,20 @@ export class EditorStore {
28
39
  report: ComposeReport | null = null;
29
40
  /** Set when the last recompose threw (overlay defect); compiled stays last-good. */
30
41
  composeError: string | null = null;
31
- selectedId: string | null = null;
42
+ /** The selection (ordered; the last is the "primary"). Source of truth. */
43
+ selectedIds: string[] = [];
44
+ /** Primary selection (last selected), or null — keeps single-select reads working. */
45
+ get selectedId(): string | null {
46
+ return this.selectedIds[this.selectedIds.length - 1] ?? null;
47
+ }
32
48
  overlayName: string;
49
+ /** Motion ops added in the editor, keyed by their beat label (the source of
50
+ * truth that regenerates draft.addTimeline + the ops' setup base props). */
51
+ addedOps = new Map<string, AddedOp>();
52
+ /** Editor-authored motionPaths (a node with no motion gets one via "add move"),
53
+ * keyed by label — the source of truth for their draggable waypoints. */
54
+ addedPaths = new Map<string, { target: string; points: [number, number][]; duration: number; autoRotate?: boolean }>();
55
+ private opSetupKeys = new Set<string>();
33
56
 
34
57
  private listeners = new Set<Listener>();
35
58
 
@@ -64,6 +87,12 @@ export class EditorStore {
64
87
  this.recompose("value");
65
88
  }
66
89
 
90
+ /** Apply many node-prop patches in one recompose (multi-drag / bulk edit). */
91
+ setNodeProps(patches: { id: string; prop: string; value: PropValue }[]) {
92
+ for (const p of patches) ((this.draft.nodes ??= {})[p.id] ??= {})[p.prop] = p.value;
93
+ this.recompose("value");
94
+ }
95
+
67
96
  unsetNodeProp(id: string, prop: string) {
68
97
  delete this.draft.nodes?.[id]?.[prop];
69
98
  this.prune();
@@ -94,7 +123,7 @@ export class EditorStore {
94
123
 
95
124
  setTimelineParam(
96
125
  label: string,
97
- key: "duration" | "ease" | "stagger" | "at" | "gap" | "scale" | "order",
126
+ key: "duration" | "ease" | "stagger" | "at" | "gap" | "scale" | "order" | "curviness",
98
127
  value: number | string,
99
128
  ) {
100
129
  ((this.draft.timeline ??= {})[label] ??= {})[key] = value as never;
@@ -113,15 +142,274 @@ export class EditorStore {
113
142
  return out;
114
143
  }
115
144
 
116
- /** A dragged waypoint writes the whole points array as a timeline patch. */
145
+ /** A dragged/added waypoint writes the whole points array. For an editor-added
146
+ * path the points live in addedPaths (its source of truth); for a base
147
+ * motionPath it's an overlay timeline patch on the stable label. */
117
148
  setMotionPathPoints(label: string, points: [number, number][]) {
149
+ const added = this.addedPaths.get(label);
150
+ if (added) {
151
+ added.points = points;
152
+ this.regenerateOps();
153
+ return;
154
+ }
118
155
  ((this.draft.timeline ??= {})[label] ??= {}).points = points;
119
156
  this.recompose("value");
120
157
  }
121
158
 
159
+ /** Toggle "rotate to face the direction of travel" on a motionPath. Routes to
160
+ * the editor-added path's source, or an overlay patch on a base path. */
161
+ setAutoRotate(label: string, on: boolean) {
162
+ const added = this.addedPaths.get(label);
163
+ if (added) {
164
+ added.autoRotate = on;
165
+ this.regenerateOps();
166
+ return;
167
+ }
168
+ ((this.draft.timeline ??= {})[label] ??= {}).autoRotate = on;
169
+ this.recompose("structure");
170
+ }
171
+
172
+ /** Current autoRotate state of a motionPath (composed). */
173
+ motionPathAutoRotate(label: string): boolean {
174
+ let on = false;
175
+ const walk = (tl: TimelineIR) => {
176
+ if (tl.kind === "motionPath" && tl.label === label) on = tl.autoRotate ?? false;
177
+ if ("children" in tl) tl.children.forEach(walk);
178
+ };
179
+ if (this.compiled.ir.timeline) walk(this.compiled.ir.timeline);
180
+ return on;
181
+ }
182
+
183
+ /** Give a motionless node its first move: a 2-point path from where it sits
184
+ * now to `to`. Further bends come from double-clicking the path (as usual). */
185
+ addMove(target: string, to: [number, number], duration = 1) {
186
+ const node = this.findNode(this.compiled.ir.nodes, target);
187
+ if (!node || !("x" in node.props)) return;
188
+ const from: [number, number] = [
189
+ Math.round((node.props as { x: number }).x),
190
+ Math.round((node.props as { y: number }).y),
191
+ ];
192
+ this.addedPaths.set(`move-${target}`, { target, points: [from, [Math.round(to[0]), Math.round(to[1])]], duration });
193
+ this.regenerateOps();
194
+ }
195
+
196
+ /** "Add move" arming: the node awaiting a destination click on the canvas. */
197
+ pendingMove: string | null = null;
198
+ armMove(id: string) {
199
+ this.pendingMove = id;
200
+ this.notify("structure");
201
+ }
202
+ disarmMove() {
203
+ if (this.pendingMove !== null) {
204
+ this.pendingMove = null;
205
+ this.notify("structure");
206
+ }
207
+ }
208
+
209
+ /** True if the node is a scene-root node (its x/y are scene coords, so a
210
+ * canvas click maps to its move path 1:1). */
211
+ isTopLevel(id: string): boolean {
212
+ return this.compiled.ir.nodes.some((n) => n.id === id);
213
+ }
214
+
215
+ /** True if the node already has a motionPath (base or editor-added). */
216
+ hasMotionPath(target: string): boolean {
217
+ let found = false;
218
+ const walk = (tl: TimelineIR) => {
219
+ if (tl.kind === "motionPath" && tl.target === target) found = true;
220
+ if ("children" in tl) tl.children.forEach(walk);
221
+ };
222
+ if (this.compiled.ir.timeline) walk(this.compiled.ir.timeline);
223
+ return found;
224
+ }
225
+
226
+ /** A reshaped ease curve writes a cubic-bezier ease on a timeline step.
227
+ * (setTimelineParam only takes number|string, so eases need their own setter.) */
228
+ setTimelineEase(label: string, bezier: [number, number, number, number]) {
229
+ ((this.draft.timeline ??= {})[label] ??= {}).ease = { cubicBezier: bezier };
230
+ this.recompose("value");
231
+ }
232
+
233
+ private findBaseNode(id: string): NodeIR | null {
234
+ const walk = (nodes: NodeIR[]): NodeIR | null => {
235
+ for (const n of nodes) {
236
+ if (n.id === id) return n;
237
+ if (n.type === "group") {
238
+ const hit = walk(n.children);
239
+ if (hit) return hit;
240
+ }
241
+ }
242
+ return null;
243
+ };
244
+ return walk(this.base.nodes);
245
+ }
246
+
247
+ /** Add a motion op to a node ("add motion ▸ <op>"). Captures the node's base
248
+ * transform so scale/position ops are correct; appended via addTimeline. */
249
+ addMotionOp(name: MotionOpName, target: string, amount = 1) {
250
+ const p = (this.findBaseNode(target)?.props ?? {}) as { scale?: number; x?: number; y?: number; rotation?: number };
251
+ const opts: MotionOpOpts = { amount, base: { scale: p.scale ?? 1, x: p.x ?? 0, y: p.y ?? 0, rotation: p.rotation ?? 0 } };
252
+ this.addedOps.set(motionOpLabel(name, target), { name, target, opts });
253
+ this.regenerateOps();
254
+ }
255
+
256
+ setOpAmount(label: string, amount: number) {
257
+ const op = this.addedOps.get(label);
258
+ if (!op) return;
259
+ op.opts = { ...op.opts, amount };
260
+ this.regenerateOps();
261
+ }
262
+
263
+ removeMotionOp(label: string) {
264
+ if (this.addedOps.delete(label)) this.regenerateOps();
265
+ }
266
+
267
+ /** Rebuild draft.addTimeline + the ops' setup base props from addedOps. */
268
+ private regenerateOps() {
269
+ for (const k of this.opSetupKeys) {
270
+ const dot = k.lastIndexOf(".");
271
+ const id = k.slice(0, dot);
272
+ const prop = k.slice(dot + 1);
273
+ if (this.draft.nodes?.[id]) delete this.draft.nodes[id]![prop];
274
+ }
275
+ this.opSetupKeys.clear();
276
+ const frags: TimelineIR[] = [];
277
+ for (const op of this.addedOps.values()) {
278
+ const r = motionOp(op.name, op.target, op.opts);
279
+ frags.push(r.timeline);
280
+ for (const [id, props] of Object.entries(r.setup ?? {})) {
281
+ for (const [prop, val] of Object.entries(props)) {
282
+ ((this.draft.nodes ??= {})[id] ??= {})[prop] = val;
283
+ this.opSetupKeys.add(`${id}.${prop}`);
284
+ }
285
+ }
286
+ }
287
+ for (const [label, p] of this.addedPaths) {
288
+ frags.push({ kind: "motionPath", target: p.target, points: p.points, duration: p.duration, label, ...(p.autoRotate && { autoRotate: true }) });
289
+ }
290
+ if (frags.length > 0) this.draft.addTimeline = frags;
291
+ else delete this.draft.addTimeline;
292
+ this.recompose("structure");
293
+ }
294
+
295
+ // --- add / duplicate / remove nodes (overlay-owned) ---
296
+
297
+ /** Every node id currently in the composed scene (base + overlay-added). */
298
+ private allNodeIds(): Set<string> {
299
+ const ids = new Set<string>();
300
+ const walk = (nodes: NodeIR[]) => {
301
+ for (const n of nodes) {
302
+ ids.add(n.id);
303
+ if (n.type === "group") walk(n.children);
304
+ }
305
+ };
306
+ walk(this.compiled.ir.nodes);
307
+ for (const n of this.draft.addNodes ?? []) ids.add(n.id);
308
+ return ids;
309
+ }
310
+
311
+ private uniqueId(base: string): string {
312
+ const ids = this.allNodeIds();
313
+ if (!ids.has(base)) return base;
314
+ for (let i = 2; ; i++) if (!ids.has(`${base}-${i}`)) return `${base}-${i}`;
315
+ }
316
+
317
+ /** True for nodes this overlay added (the only ones removeNode can drop). */
318
+ isAddedNode(id: string): boolean {
319
+ return Boolean(this.draft.addNodes?.some((n) => n.id === id));
320
+ }
321
+
322
+ /** Append a fresh node of any type at scene centre; owned by the overlay.
323
+ * `extra` carries type-specific inputs (image `src`, path `d`/`fill`). */
324
+ addNode(type: NodeIR["type"], extra: Record<string, unknown> = {}): string {
325
+ const x = extra.x !== undefined ? Math.round(Number(extra.x)) : Math.round(this.base.size.width / 2);
326
+ const y = extra.y !== undefined ? Math.round(Number(extra.y)) : Math.round(this.base.size.height / 2);
327
+ const id = this.uniqueId(type);
328
+ let node: NodeIR;
329
+ switch (type) {
330
+ case "text":
331
+ node = { type, id, props: { x, y, anchor: "center", content: "Text", fontFamily: "Inter", fontSize: 64, fontWeight: 700, fill: "#FFFFFF" } };
332
+ break;
333
+ case "rect":
334
+ node = { type, id, props: { x, y, anchor: "center", width: 220, height: 130, fill: "#FF4D00", radius: 10 } };
335
+ break;
336
+ case "ellipse":
337
+ node = { type, id, props: { x, y, anchor: "center", width: 160, height: 160, fill: "#00C2A8" } };
338
+ break;
339
+ case "line":
340
+ node = { type, id, props: { x1: x - 120, y1: y, x2: x + 120, y2: y, stroke: "#FFFFFF", strokeWidth: 4 } };
341
+ break;
342
+ case "image":
343
+ node = { type, id, props: { x, y, anchor: "center", src: String(extra.src ?? ""), width: Number(extra.width ?? 320), height: Number(extra.height ?? 200) } };
344
+ break;
345
+ case "path":
346
+ node = { type, id, props: { x, y, anchor: "center", d: String(extra.d ?? ""), ...(extra.fill !== undefined && { fill: String(extra.fill) }), ...(extra.stroke !== undefined && { stroke: String(extra.stroke) }), originX: Number(extra.originX ?? 0), originY: Number(extra.originY ?? 0), ...(extra.scale !== undefined && { scale: Number(extra.scale) }) } };
347
+ break;
348
+ default:
349
+ node = { type: "group", id, props: { x, y }, children: [] };
350
+ }
351
+ (this.draft.addNodes ??= []).push(node);
352
+ this.selectedIds = [id];
353
+ this.recompose("structure");
354
+ return id;
355
+ }
356
+
357
+ /** Clone a node to the scene root (overlay-owned), offset so it's visible. */
358
+ duplicateNode(id: string): string | null {
359
+ const src = this.findNode(this.compiled.ir.nodes, id);
360
+ if (!src) return null;
361
+ const clone = structuredClone(src) as NodeIR;
362
+ clone.id = this.uniqueId(`${id}-copy`);
363
+ const reId = (n: NodeIR) => {
364
+ if (n.id !== clone.id) n.id = this.uniqueId(n.id);
365
+ if (n.type === "group") n.children.forEach(reId);
366
+ };
367
+ if (clone.type === "group") clone.children.forEach(reId);
368
+ if ("x" in clone.props) {
369
+ (clone.props as { x: number; y: number }).x += 24;
370
+ (clone.props as { x: number; y: number }).y += 24;
371
+ }
372
+ (this.draft.addNodes ??= []).push(clone);
373
+ this.selectedIds = [clone.id];
374
+ this.recompose("structure");
375
+ return clone.id;
376
+ }
377
+
378
+ /** Remove an overlay-added node (base nodes can't be removed — hide instead).
379
+ * Returns false if id is a base node so the caller can offer "hide". */
380
+ removeNode(id: string): boolean {
381
+ const list = this.draft.addNodes;
382
+ const index = list?.findIndex((n) => n.id === id) ?? -1;
383
+ if (!list || index < 0) return false;
384
+ list.splice(index, 1);
385
+ if (list.length === 0) delete this.draft.addNodes;
386
+ delete this.draft.nodes?.[id];
387
+ for (const [label, op] of this.addedOps) if (op.target === id) this.addedOps.delete(label);
388
+ this.selectedIds = this.selectedIds.filter((s) => s !== id);
389
+ this.prune();
390
+ this.regenerateOps(); // rebuilds addTimeline + recomposes
391
+ return true;
392
+ }
393
+
394
+ /** Non-destructive "delete" for a base node: hide it via opacity 0. */
395
+ hideNode(id: string) {
396
+ this.setNodeProp(id, "opacity", 0);
397
+ }
398
+
399
+ private findNode(nodes: NodeIR[], id: string): NodeIR | null {
400
+ for (const n of nodes) {
401
+ if (n.id === id) return n;
402
+ if (n.type === "group") {
403
+ const hit = this.findNode(n.children, id);
404
+ if (hit) return hit;
405
+ }
406
+ }
407
+ return null;
408
+ }
409
+
122
410
  unsetTimelineParam(
123
411
  label: string,
124
- key: "duration" | "ease" | "stagger" | "at" | "gap" | "scale" | "order",
412
+ key: "duration" | "ease" | "stagger" | "at" | "gap" | "scale" | "order" | "curviness",
125
413
  ) {
126
414
  delete this.draft.timeline?.[label]?.[key];
127
415
  this.prune();
@@ -153,8 +441,30 @@ export class EditorStore {
153
441
  this.recompose("structure");
154
442
  }
155
443
 
156
- select(id: string | null) {
157
- this.selectedId = id;
444
+ /** Select a node: replace the selection, or (additive) toggle it in/out. null clears. */
445
+ select(id: string | null, additive = false) {
446
+ if (id === null) {
447
+ this.selectedIds = [];
448
+ } else if (additive) {
449
+ const i = this.selectedIds.indexOf(id);
450
+ if (i >= 0) this.selectedIds.splice(i, 1);
451
+ else this.selectedIds.push(id);
452
+ } else {
453
+ this.selectedIds = [id];
454
+ }
455
+ if (this.pendingMove !== null && !this.selectedIds.includes(this.pendingMove)) this.pendingMove = null;
456
+ this.notify("structure");
457
+ }
458
+
459
+ /** Set/merge a whole list of ids as the selection (marquee, select-all). */
460
+ selectMany(ids: string[], additive = false) {
461
+ if (additive) {
462
+ const set = new Set(this.selectedIds);
463
+ for (const id of ids) set.add(id);
464
+ this.selectedIds = [...set];
465
+ } else {
466
+ this.selectedIds = [...new Set(ids)];
467
+ }
158
468
  this.notify("structure");
159
469
  }
160
470
 
@@ -237,6 +547,13 @@ export class EditorStore {
237
547
  this.composeError =
238
548
  err instanceof SceneValidationError ? err.message : String(err);
239
549
  }
550
+ // drop any selected ids that no longer exist (a delete/import removed them)
551
+ if (this.selectedIds.length > 0) {
552
+ const live = this.allNodeIds();
553
+ if (this.selectedIds.some((id) => !live.has(id))) {
554
+ this.selectedIds = this.selectedIds.filter((id) => live.has(id));
555
+ }
556
+ }
240
557
  this.notify(kind);
241
558
  }
242
559