reframe-video 0.1.1 → 0.1.2

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.
@@ -18,6 +18,16 @@ export interface PropertySegment {
18
18
  to: PropValue;
19
19
  ease?: Ease;
20
20
  }
21
+ /** A path driver overrides a node's x/y (and rotation, if autoRotate) over [t0, t1], holding the end. */
22
+ export interface MotionDriver {
23
+ t0: number;
24
+ t1: number;
25
+ ease?: Ease;
26
+ points: [number, number][];
27
+ closed: boolean;
28
+ autoRotate: boolean;
29
+ rotateOffset: number;
30
+ }
21
31
  export interface LabelSpan {
22
32
  t0: number;
23
33
  t1: number;
@@ -27,12 +37,16 @@ export interface CompiledScene {
27
37
  duration: number;
28
38
  /** Keyed by `${target}.${prop}`, sorted by t0. */
29
39
  segments: Map<string, PropertySegment[]>;
40
+ /** Path drivers per target node, sorted by t0 — override x/y/rotation. */
41
+ motionPaths: Map<string, MotionDriver[]>;
30
42
  /** Base props merged with the initial state, keyed by `${target}.${prop}`. */
31
43
  initialValues: Map<string, PropValue>;
32
44
  nodeById: Map<string, NodeIR>;
33
45
  /** Declaration order — defines stagger order. */
34
46
  nodeOrder: string[];
35
- /** Absolute [start, end] of every labeled timeline step. */
47
+ /** Absolute [start, end] of every labeled timeline step (beat names included). */
36
48
  labelTimes: Map<string, LabelSpan>;
49
+ /** The subset of label spans that come from beat nodes — keyed by beat name. */
50
+ beatTimes: Map<string, LabelSpan>;
37
51
  }
38
52
  export declare function compileScene(ir: SceneIR): CompiledScene;
@@ -35,13 +35,23 @@ export interface OverlayDoc {
35
35
  /** Complete nodes appended at the scene root, owned by this overlay. */
36
36
  addNodes?: NodeIR[];
37
37
  /**
38
- * Parameter patches on labeled timeline steps. Patchable per kind:
39
- * to -> duration/ease/stagger, tween -> duration/ease, wait -> duration.
38
+ * Parameter patches on labeled timeline steps (or beats by name). Patchable
39
+ * per kind: to -> duration/ease/stagger, tween -> duration/ease,
40
+ * wait -> duration, motionPath -> points/duration/ease, beat ->
41
+ * at/gap/scale/duration/order. A beat move is rigid, so child labels inside
42
+ * it keep their relative timing and any overlay edits on those children
43
+ * survive. A dragged motionPath waypoint is a `points` patch — it survives a
44
+ * knob-driven base regen because the step label is stable.
40
45
  */
41
46
  timeline?: Record<string, {
42
47
  duration?: number;
43
48
  ease?: Ease;
44
49
  stagger?: number;
50
+ at?: number;
51
+ gap?: number;
52
+ scale?: number;
53
+ order?: number;
54
+ points?: [number, number][];
45
55
  }>;
46
56
  }
47
57
  export interface ComposeReport {
@@ -2,7 +2,7 @@
2
2
  * The eDSL surface: thin factories that build plain IR objects.
3
3
  * `scene()` validates and returns the IR — the return value is the document.
4
4
  */
5
- import type { AudioIR, BehaviorIR, Ease, EllipseProps, GroupProps, ImageProps, LineProps, NodeIR, PropValue, RectProps, SceneIR, Size, StateOverride, TextProps, TimelineIR } from "./ir.js";
5
+ import type { AudioIR, BehaviorIR, Ease, EllipseProps, GroupProps, ImageProps, LineProps, NodeIR, PathProps, PropValue, RectProps, SceneIR, Size, StateOverride, TextProps, TimelineIR } from "./ir.js";
6
6
  export interface SceneInput {
7
7
  id: string;
8
8
  size: Size;
@@ -33,12 +33,30 @@ export declare function text(props: {
33
33
  export declare function image(props: {
34
34
  id: string;
35
35
  } & ImageProps): NodeIR;
36
+ export declare function path(props: {
37
+ id: string;
38
+ } & PathProps): NodeIR;
36
39
  export declare function group(props: {
37
40
  id: string;
38
41
  } & GroupProps, children: NodeIR[]): NodeIR;
39
42
  export declare function seq(...children: TimelineIR[]): TimelineIR;
40
43
  export declare function par(...children: TimelineIR[]): TimelineIR;
41
44
  export declare function stagger(interval: number, ...children: TimelineIR[]): TimelineIR;
45
+ export interface BeatOpts {
46
+ /** Group children in parallel instead of sequence. */
47
+ parallel?: boolean;
48
+ /** Absolute start (rigid placement). */
49
+ at?: number;
50
+ /** Relative shift before the beat. */
51
+ gap?: number;
52
+ /** Interior time-stretch factor. */
53
+ scale?: number;
54
+ /** Target total duration (→ scale). */
55
+ duration?: number;
56
+ /** Sort key within a parent seq (reorder). */
57
+ order?: number;
58
+ }
59
+ export declare function beat(name: string, opts: BeatOpts, children: TimelineIR[]): TimelineIR;
42
60
  export declare function to(state: string, opts?: {
43
61
  duration?: number;
44
62
  ease?: Ease;
@@ -52,6 +70,14 @@ export declare function tween(target: string, props: Record<string, PropValue>,
52
70
  label?: string;
53
71
  }): TimelineIR;
54
72
  export declare function wait(duration: number, label?: string): TimelineIR;
73
+ export declare function motionPath(target: string, points: [number, number][], opts?: {
74
+ duration?: number;
75
+ ease?: Ease;
76
+ closed?: boolean;
77
+ autoRotate?: boolean;
78
+ rotateOffset?: number;
79
+ label?: string;
80
+ }): TimelineIR;
55
81
  export interface BehaviorWindow {
56
82
  from?: number;
57
83
  until?: number;
@@ -61,6 +61,15 @@ export type DisplayOp = (OpBase & {
61
61
  height: number;
62
62
  offsetX: number;
63
63
  offsetY: number;
64
+ }) | (OpBase & {
65
+ type: "path";
66
+ /** SVG path data, drawn via Path2D. */
67
+ d: string;
68
+ /** 0..1 fraction of the stroke outline drawn (draw-on). */
69
+ progress: number;
70
+ fill?: string;
71
+ stroke?: string;
72
+ strokeWidth?: number;
64
73
  });
65
74
  export type DisplayList = DisplayOp[];
66
75
  export declare function evaluate(compiled: CompiledScene, t: number): DisplayList;
@@ -2,9 +2,12 @@ export * from "./ir.js";
2
2
  export * from "./dsl.js";
3
3
  export { validateScene, SceneValidationError, PROPS_BY_TYPE } from "./validate.js";
4
4
  export { composeScene, formatComposeReport, type OverlayDoc, type ComposeReport, } from "./compose.js";
5
- export { compileScene, type CompiledScene, type PropertySegment, type LabelSpan } from "./compile.js";
5
+ export { compileScene, type CompiledScene, type PropertySegment, type LabelSpan, type MotionDriver } from "./compile.js";
6
+ export { pathPoint, pathTangentAngle, type Pt } from "./path.js";
7
+ export { motionPreset, PRESET_NAMES, type PresetName, type PresetRig, type PresetOpts } from "./presets.js";
6
8
  export { resolveAudioPlan, SFX_DURATION, type AudioPlan, type ResolvedCue, } from "./audio.js";
7
9
  export { evaluate, type DisplayList, type DisplayOp, type Mat2D, type TextAlign, type TextBaseline, } from "./evaluate.js";
8
10
  export { resolveEase, lerpValue, isColor, EASE_NAMES } from "./interpolate.js";
9
11
  export { sampleBehavior } from "./behaviors.js";
10
12
  export { collectImageSrcs } from "./assets.js";
13
+ export { sketchToTimeline, type MotionSketch, type MotionEvent, type MotionEventKind, type MotionRegion, } from "./motion.js";
@@ -8,7 +8,7 @@
8
8
  * Semantics: a scene is evaluated as a pure function of continuous time
9
9
  * `evaluate(scene, tSeconds) -> DisplayList`. `fps` is a render hint only.
10
10
  */
11
- export type EaseName = "linear" | "easeInQuad" | "easeOutQuad" | "easeInOutQuad" | "easeInCubic" | "easeOutCubic" | "easeInOutCubic" | "easeInQuart" | "easeOutQuart" | "easeInOutQuart" | "easeInExpo" | "easeOutExpo" | "easeInOutExpo";
11
+ export type EaseName = "linear" | "easeInQuad" | "easeOutQuad" | "easeInOutQuad" | "easeInCubic" | "easeOutCubic" | "easeInOutCubic" | "easeInQuart" | "easeOutQuart" | "easeInOutQuart" | "easeInExpo" | "easeOutExpo" | "easeInOutExpo" | "easeInBack" | "easeOutBack" | "easeInOutBack" | "easeInElastic" | "easeOutElastic" | "easeInOutElastic" | "easeInBounce" | "easeOutBounce" | "easeInOutBounce";
12
12
  export type Ease = EaseName | {
13
13
  cubicBezier: [number, number, number, number];
14
14
  };
@@ -65,6 +65,26 @@ export interface TextProps extends BaseProps {
65
65
  }
66
66
  export interface GroupProps extends BaseProps {
67
67
  }
68
+ export interface PathProps extends BaseProps {
69
+ /** SVG path data (the `d` attribute). Drawn as a true vector — crisp at any zoom. */
70
+ d: string;
71
+ fill?: string;
72
+ stroke?: string;
73
+ strokeWidth?: number;
74
+ /**
75
+ * 0..1 — fraction of the OUTLINE drawn, for a self-drawing "draw-on" effect
76
+ * (animate 0→1). Applies to the stroke; pair a stroke path (draw-on) with a
77
+ * separate fill path (fade-in) for the classic logo reveal. Default 1.
78
+ */
79
+ progress?: number;
80
+ /**
81
+ * Local pivot in the path's own coordinate space — scale/rotation happen
82
+ * around this point. Set it to the art's centre (e.g. the viewBox centre) so
83
+ * a logo zooms/spins about its middle. Default (0,0).
84
+ */
85
+ originX?: number;
86
+ originY?: number;
87
+ }
68
88
  export interface ImageProps extends BaseProps {
69
89
  /**
70
90
  * Image file path: absolute, or relative to the scene file. Drawn
@@ -96,6 +116,10 @@ export type NodeIR = {
96
116
  type: "image";
97
117
  id: string;
98
118
  props: ImageProps;
119
+ } | {
120
+ type: "path";
121
+ id: string;
122
+ props: PathProps;
99
123
  } | {
100
124
  type: "group";
101
125
  id: string;
@@ -141,6 +165,47 @@ export type TimelineIR = {
141
165
  kind: "wait";
142
166
  duration: number;
143
167
  label?: string;
168
+ } | {
169
+ /**
170
+ * Drive a node's x/y along a Catmull-Rom spline through `points`
171
+ * (absolute coords in the node's parent space). With `autoRotate`, the
172
+ * node's rotation tracks the path tangent (plus `rotateOffset`). The
173
+ * position HOLDS at the final point after the path completes, so a swoop
174
+ * is a positioning move, not a one-shot that snaps back.
175
+ */
176
+ kind: "motionPath";
177
+ target: string;
178
+ points: [number, number][];
179
+ closed?: boolean;
180
+ duration?: number;
181
+ ease?: Ease;
182
+ autoRotate?: boolean;
183
+ /** Degrees added to the tangent angle (e.g. 90 if the art faces "up"). */
184
+ rotateOffset?: number;
185
+ label?: string;
186
+ } | {
187
+ /**
188
+ * A named, retimable, reorderable span wrapping timeline steps — the
189
+ * semantic unit ("brand-reveal", "feature-cascade") humans and AI revise.
190
+ * Lowers to its grouping (seq, or par if `parallel`) before timing, so
191
+ * `beat(name, {}, children)` is byte-identical to `seq(children)`.
192
+ * Ops are RIGID: they translate/stretch the whole span, preserving the
193
+ * interior's relative timing (so sub-beat overlay edits survive a move).
194
+ */
195
+ kind: "beat";
196
+ name: string;
197
+ parallel?: boolean;
198
+ /** Absolute start (rigid placement). Overrides sequential flow. */
199
+ at?: number;
200
+ /** Relative shift: a leading delay before the beat (and everything after). */
201
+ gap?: number;
202
+ /** Interior time-stretch factor (every child offset and duration ×scale). */
203
+ scale?: number;
204
+ /** Target total duration → scale = duration / natural duration. */
205
+ duration?: number;
206
+ /** Sort key within a parent seq (reorder); default = declaration index. */
207
+ order?: number;
208
+ children: TimelineIR[];
144
209
  };
145
210
  export interface BehaviorIR {
146
211
  target: string;
@@ -225,4 +290,5 @@ export interface SceneIR {
225
290
  }
226
291
  export declare const DEFAULT_TO_DURATION = 0.5;
227
292
  export declare const DEFAULT_TWEEN_DURATION = 0.5;
293
+ export declare const DEFAULT_MOTIONPATH_DURATION = 1;
228
294
  export declare const DEFAULT_FPS = 30;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * MotionSketch — the intermediate representation between a video's pixel
3
+ * motion signal and a reframe timeline. Extraction (pixels → sketch) lives in
4
+ * the motion harness (it needs the profiler); this module owns the sketch
5
+ * *vocabulary* and the *emission* (sketch → TimelineIR), both of which are
6
+ * pure and dependency-free so core can host them.
7
+ *
8
+ * Reconstruction is deliberately geometry-free: events emit opacity/scale
9
+ * tweens whose targets are absolute (1, or 1+magnitude), so a sketch re-applies
10
+ * to nodes without knowing their rest positions — which is what makes the
11
+ * round-trip verifier independent of node layout.
12
+ */
13
+ import type { TimelineIR } from "./ir.js";
14
+ export type MotionEventKind = "enter" | "exit" | "move" | "scale" | "emphasis";
15
+ /** Normalized 0..1 activity bounding box — a coarse region, NOT a tracked object. */
16
+ export interface MotionRegion {
17
+ x: number;
18
+ y: number;
19
+ w: number;
20
+ h: number;
21
+ }
22
+ export interface MotionEvent {
23
+ /** Onset / settle, seconds. */
24
+ t0: number;
25
+ t1: number;
26
+ kind: MotionEventKind;
27
+ region: MotionRegion;
28
+ /** Displacement or scale delta, normalized to the frame. */
29
+ magnitude: number;
30
+ easing: {
31
+ class: string;
32
+ thirdsRatio: number | null;
33
+ reliable: boolean;
34
+ };
35
+ }
36
+ export interface MotionSketch {
37
+ duration: number;
38
+ fps: number;
39
+ events: MotionEvent[];
40
+ /** Global cadence; periodicityHz null when no clear oscillation. */
41
+ rhythm: {
42
+ periodicityHz: number | null;
43
+ beatCount: number;
44
+ };
45
+ }
46
+ /**
47
+ * Emit a reframe timeline reproducing a sketch's timing, applied to `nodeIds`.
48
+ * Events are assigned to nodes in onset order (v1: region→node mapping is
49
+ * goal-2's concern; here ids are given). Geometry-free: enter/exit drive
50
+ * opacity, emphasis/scale drive scale, so the emitted timeline needs no node
51
+ * coordinates. For an `enter` to read as an entrance the target node should
52
+ * start hidden (opacity 0) — idiomatic reframe `initial` state.
53
+ */
54
+ export declare function sketchToTimeline(sketch: MotionSketch, nodeIds: string[]): TimelineIR;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * MotionPath geometry — a Catmull-Rom spline through waypoints, evaluated as a
3
+ * pure function of progress u in [0,1]. Used by evaluate() to drive a node's
4
+ * x/y (and, with autoRotate, rotation along the tangent) along a curve.
5
+ *
6
+ * Pure math, no DOM: same result in Node (tests/golden) and the browser
7
+ * renderer. Uniform parameterisation — each segment owns an equal slice of u.
8
+ * Good for hand-authored sting waypoints; centripetal can come later if uneven
9
+ * spacing ever overshoots.
10
+ */
11
+ export type Pt = [number, number];
12
+ /** Position on the spline at progress u in [0,1]. */
13
+ export declare function pathPoint(points: Pt[], closed: boolean, u: number): Pt;
14
+ /** Tangent angle (degrees) at progress u — the direction of travel along the path. */
15
+ export declare function pathTangentAngle(points: Pt[], closed: boolean, u: number): number;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Motion presets — a small NAMED vocabulary of logo-sting motions, each a
3
+ * SEEDED GENERATOR (not a frozen template). `motionPreset(name, opts)` returns
4
+ * a `beat` (goal-2's addressable, retimable unit) composed from the existing
5
+ * primitives (motionPath, tween, stagger, path draw-on, expressive eases).
6
+ *
7
+ * Two-determinisms: same (name, knobs, seed) → identical IR (reproducible);
8
+ * a different `seed` perturbs waypoints / timing / accents WITHIN BOUNDED
9
+ * ranges → a measurably different motion that is still the same family. The
10
+ * seed feeds a deterministic PRNG — no Math.random, ever.
11
+ *
12
+ * Universal knobs (every preset): `energy` 0..1 (settle ↔ springy overshoot),
13
+ * `speed` (duration multiplier, >1 faster). Plus a signature knob `intensity`
14
+ * 0..1 (the preset's amplitude) and, where spatial, `from`.
15
+ */
16
+ import type { TimelineIR } from "./ir.js";
17
+ export type PresetName = "draw-bloom" | "punch-in" | "rise-settle" | "slide-bank" | "reveal-orbit" | "spin-forge";
18
+ export declare const PRESET_NAMES: PresetName[];
19
+ /** The rig a preset drives: a group at `center` scaled to `baseScale`, with
20
+ * fill layers (bloom) and ink layers (draw-on outline). */
21
+ export interface PresetRig {
22
+ group: string;
23
+ center: [number, number];
24
+ baseScale: number;
25
+ fills: string[];
26
+ inks: string[];
27
+ }
28
+ export interface PresetOpts {
29
+ target: PresetRig;
30
+ /** 0..1 — clean settle ↔ springy overshoot. Default 0.5. */
31
+ energy?: number;
32
+ /** Duration multiplier; >1 faster, <1 slower. Default 1. */
33
+ speed?: number;
34
+ /** 0..1 — the signature amplitude (orbit radius / rise distance / spins). Default 0.5. */
35
+ intensity?: number;
36
+ /** Entry direction for spatial presets. Default per preset. */
37
+ from?: "left" | "right" | "top" | "bottom";
38
+ /** Deterministic variation. Same seed → identical; different seed → same family. Default 0. */
39
+ seed?: number;
40
+ }
41
+ export declare function motionPreset(name: PresetName, opts: PresetOpts): TimelineIR;
@@ -34,6 +34,13 @@ Factories return plain data. Every node needs a unique `id`.
34
34
  `content` may be a number; numeric content interpolates (count-up) and renders
35
35
  via `toFixed(contentDecimals ?? 0)`. For a "8.2"-style label, set
36
36
  `contentDecimals: 1`.
37
+ - `path({ id, d, x, y, fill?, stroke?, strokeWidth?, progress?, originX?, originY?, opacity?, rotation?, scale?, anchor? })` —
38
+ a true vector shape from an SVG path `d` string (crisp at any zoom; recolour by
39
+ animating `fill`/`stroke`). `progress` 0..1 draws the stroke OUTLINE on (animate
40
+ 0→1 for a self-drawing logo). `originX`/`originY` is the local pivot — set it to
41
+ the art's centre (e.g. the viewBox centre) so `scale`/`rotation` happen about the
42
+ middle. `d` is drawn in its own coords; `x`/`y` place that pivot. Classic logo
43
+ reveal: a stroke path drawing on, then a fill path fading in over it.
37
44
  - `image({ id, src, x, y, width, height, opacity?, rotation?, scale?, anchor? })` —
38
45
  `src` is a file path, absolute or relative to the scene file; drawn stretched
39
46
  to `width`×`height` (png/jpg/webp). `src` switches discretely (no crossfade) —
@@ -75,11 +82,23 @@ them with normal TS (`Object.fromEntries`, `.map`) for data-driven scenes.
75
82
  - `to(stateName, opts)` — transition into a named state (see above).
76
83
  - `tween(nodeId, { prop: value, ... }, { duration, ease })` — low-level escape hatch
77
84
  for one node. Colors (`"#rrggbb"`) interpolate; numbers interpolate.
85
+ - `motionPath(nodeId, [[x,y], ...], { duration, ease, autoRotate?, rotateOffset?, closed? })`
86
+ — drive a node's `x`/`y` along a smooth Catmull-Rom curve through the waypoints
87
+ (parent-space coords). `autoRotate: true` banks the node along the path tangent
88
+ (`rotateOffset` degrees if the art faces "up", e.g. `-90`). The node HOLDS at the
89
+ final point after the path finishes (a positioning move, not a one-shot), so a
90
+ later `tween` can chain from there. Use it for swoops/arcs/orbits — straight
91
+ `tween`s on x and y can't curve. `closed: true` loops the waypoints (orbit).
78
92
  - `wait(seconds)` — hold.
79
93
 
80
94
  Eases: `linear`, `easeIn/Out/InOutQuad`, `easeIn/Out/InOutCubic`,
81
95
  `easeIn/Out/InOutQuart`, `easeIn/Out/InOutExpo`, or `{ cubicBezier: [x1,y1,x2,y2] }`.
82
96
  Decelerating entrances = `easeOut*`, accelerating exits = `easeIn*`.
97
+ Expressive eases for a premium feel: `easeIn/Out/InOutBack` (overshoots past the
98
+ target then settles — a pop/snap), `easeIn/Out/InOutElastic` (rings around the
99
+ target — a playful spring), `easeIn/Out/InOutBounce` (drops and bounces to rest).
100
+ A logo or card "popping" in usually wants `easeOutBack`; a stamp landing,
101
+ `easeOutBounce`.
83
102
  Scene duration is inferred from the timeline.
84
103
 
85
104
  ## Behaviors: continuous motion during holds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reframe-video",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Declarative motion graphics that AI can write and humans can tweak — human edits survive AI regeneration. Deterministic mp4 renders from a plain-data scene format.",
5
5
  "keywords": [
6
6
  "motion-graphics",
@@ -134,6 +134,9 @@ function opCorners(op: DisplayOp): [number, number][] {
134
134
  applyMat(op.transform, px!, py!),
135
135
  );
136
136
  }
137
+ case "path":
138
+ // No cheap bbox for an arbitrary `d`; mark the origin for selection.
139
+ return [applyMat(op.transform, 0, 0)];
137
140
  }
138
141
  }
139
142
 
@@ -158,11 +161,70 @@ function draw() {
158
161
  ctx.restore();
159
162
  }
160
163
 
164
+ // motionPath waypoint handles — drag to reshape the curve (writes a
165
+ // timeline.<label>.points overlay patch that survives base regeneration).
166
+ // Points are in the target's parent space; correct for top-level targets.
167
+ ctx.save();
168
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
169
+ for (const mp of store.motionPaths()) {
170
+ ctx.strokeStyle = "rgba(125,154,255,0.4)";
171
+ ctx.lineWidth = 1.5;
172
+ ctx.setLineDash([4, 4]);
173
+ ctx.beginPath();
174
+ mp.points.forEach(([x, y], i) => (i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)));
175
+ ctx.stroke();
176
+ ctx.setLineDash([]);
177
+ for (const [x, y] of mp.points) {
178
+ ctx.beginPath();
179
+ ctx.arc(x, y, HANDLE_R, 0, Math.PI * 2);
180
+ ctx.fillStyle = "#7d9aff";
181
+ ctx.fill();
182
+ ctx.strokeStyle = "#0b0b12";
183
+ ctx.lineWidth = 2;
184
+ ctx.stroke();
185
+ }
186
+ }
187
+ ctx.restore();
188
+
161
189
  const duration = store.compiled.duration;
162
190
  scrub.value = String(duration ? t / duration : 0);
163
191
  timeLabel.textContent = `${t.toFixed(3)} / ${duration.toFixed(3)}`;
164
192
  }
165
193
 
194
+ const HANDLE_R = 9;
195
+
196
+ /** Map a mouse event to scene coordinates (canvas is rendered at scene size, displayed scaled). */
197
+ function clientToScene(ev: MouseEvent): [number, number] {
198
+ const r = canvas.getBoundingClientRect();
199
+ return [((ev.clientX - r.left) * canvas.width) / r.width, ((ev.clientY - r.top) * canvas.height) / r.height];
200
+ }
201
+
202
+ let drag: { label: string; index: number; points: [number, number][] } | null = null;
203
+ canvas.addEventListener("mousedown", (ev) => {
204
+ if (!store) return;
205
+ const [x, y] = clientToScene(ev);
206
+ for (const mp of store.motionPaths()) {
207
+ const i = mp.points.findIndex(([px, py]) => Math.hypot(px - x, py - y) <= HANDLE_R + 4);
208
+ if (i >= 0) {
209
+ drag = { label: mp.label, index: i, points: mp.points.map((p) => [...p] as [number, number]) };
210
+ playing = false;
211
+ playBtn.textContent = "play";
212
+ ev.preventDefault();
213
+ return;
214
+ }
215
+ }
216
+ });
217
+ window.addEventListener("mousemove", (ev) => {
218
+ if (!drag || !store) return;
219
+ const [x, y] = clientToScene(ev);
220
+ drag.points[drag.index] = [Math.round(x), Math.round(y)];
221
+ store.setMotionPathPoints(drag.label, drag.points);
222
+ draw();
223
+ });
224
+ window.addEventListener("mouseup", () => {
225
+ drag = null;
226
+ });
227
+
166
228
  function tick(now: number) {
167
229
  if (playing && store) {
168
230
  t += (now - lastTick) / 1000;
@@ -200,6 +200,39 @@ export function buildPanel(store: EditorStore, root: HTMLElement) {
200
200
  }
201
201
  }
202
202
 
203
+ // --- beats (semantic groups) ---
204
+ const beats: Extract<TimelineIR, { kind: "beat" }>[] = [];
205
+ const beatOf = new Map<string, string>();
206
+ const walkBeats = (tl: TimelineIR, owner?: string) => {
207
+ if (tl.kind === "beat") {
208
+ beats.push(tl);
209
+ tl.children.forEach((c) => walkBeats(c, tl.name));
210
+ return;
211
+ }
212
+ if ("label" in tl && tl.label !== undefined && owner) beatOf.set(tl.label, owner);
213
+ if ("children" in tl) tl.children.forEach((c) => walkBeats(c, owner));
214
+ };
215
+ if (ir.timeline) walkBeats(ir.timeline);
216
+ if (beats.length > 0) {
217
+ root.append(el("h3", {}, "Beats"));
218
+ for (const b of beats) {
219
+ const card = el("div", { class: "step-card beat-card" },
220
+ el("div", {}, `${b.name} `, el("span", { class: "kind" }, "(beat)")),
221
+ );
222
+ const gapRow = makeControl("gap", b.gap ?? 0, store.hasTimelineEdit(b.name, "gap"),
223
+ (v) => store.setTimelineParam(b.name, "gap", Number(v)),
224
+ () => store.unsetTimelineParam(b.name, "gap"));
225
+ gapRow.prepend(el("label", {}, "gap"));
226
+ card.append(gapRow);
227
+ const scaleRow = makeControl("scale", b.scale ?? 1, store.hasTimelineEdit(b.name, "scale"),
228
+ (v) => store.setTimelineParam(b.name, "scale", Number(v)),
229
+ () => store.unsetTimelineParam(b.name, "scale"));
230
+ scaleRow.prepend(el("label", {}, "scale"));
231
+ card.append(scaleRow);
232
+ root.append(card);
233
+ }
234
+ }
235
+
203
236
  // --- labeled timeline steps ---
204
237
  const steps: Extract<TimelineIR, { label?: string }>[] = [];
205
238
  const walkTl = (tl: TimelineIR) => {
@@ -211,9 +244,10 @@ export function buildPanel(store: EditorStore, root: HTMLElement) {
211
244
  root.append(el("h3", {}, "Timeline"));
212
245
  for (const step of steps) {
213
246
  const label = step.label!;
214
- const card = el("div", { class: "step-card" },
215
- el("div", {}, `${label} `, el("span", { class: "kind" }, `(${step.kind})`)),
216
- );
247
+ const beatName = beatOf.get(label);
248
+ const head = el("div", {}, `${label} `, el("span", { class: "kind" }, `(${step.kind})`));
249
+ if (beatName) head.append(el("span", { class: "badge" }, ` ↳ ${beatName}`));
250
+ const card = el("div", { class: "step-card" }, head);
217
251
  const durRow = makeControl(
218
252
  "duration",
219
253
  "duration" in step ? (step.duration ?? 0.5) : 0.5,
@@ -15,6 +15,7 @@ import {
15
15
  type OverlayDoc,
16
16
  type PropValue,
17
17
  type SceneIR,
18
+ type TimelineIR,
18
19
  } from "@reframe/core";
19
20
 
20
21
  export type ChangeKind = "value" | "structure";
@@ -91,12 +92,37 @@ export class EditorStore {
91
92
  this.recompose("structure");
92
93
  }
93
94
 
94
- setTimelineParam(label: string, key: "duration" | "ease" | "stagger", value: number | string) {
95
+ setTimelineParam(
96
+ label: string,
97
+ key: "duration" | "ease" | "stagger" | "at" | "gap" | "scale" | "order",
98
+ value: number | string,
99
+ ) {
95
100
  ((this.draft.timeline ??= {})[label] ??= {})[key] = value as never;
96
101
  this.recompose("value");
97
102
  }
98
103
 
99
- unsetTimelineParam(label: string, key: "duration" | "ease" | "stagger") {
104
+ /** Labeled motionPath steps with their (possibly overlay-edited) waypoints —
105
+ * drives the preview's draggable handles and is exposed on window.__store. */
106
+ motionPaths(): { label: string; points: [number, number][] }[] {
107
+ const out: { label: string; points: [number, number][] }[] = [];
108
+ const walk = (tl: TimelineIR) => {
109
+ if (tl.kind === "motionPath" && tl.label) out.push({ label: tl.label, points: tl.points });
110
+ if ("children" in tl) tl.children.forEach(walk);
111
+ };
112
+ if (this.compiled.ir.timeline) walk(this.compiled.ir.timeline);
113
+ return out;
114
+ }
115
+
116
+ /** A dragged waypoint writes the whole points array as a timeline patch. */
117
+ setMotionPathPoints(label: string, points: [number, number][]) {
118
+ ((this.draft.timeline ??= {})[label] ??= {}).points = points;
119
+ this.recompose("value");
120
+ }
121
+
122
+ unsetTimelineParam(
123
+ label: string,
124
+ key: "duration" | "ease" | "stagger" | "at" | "gap" | "scale" | "order",
125
+ ) {
100
126
  delete this.draft.timeline?.[label]?.[key];
101
127
  this.prune();
102
128
  this.recompose("structure");