reframe-video 0.1.0 → 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, 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;
@@ -30,12 +30,33 @@ export declare function line(props: {
30
30
  export declare function text(props: {
31
31
  id: string;
32
32
  } & TextProps): NodeIR;
33
+ export declare function image(props: {
34
+ id: string;
35
+ } & ImageProps): NodeIR;
36
+ export declare function path(props: {
37
+ id: string;
38
+ } & PathProps): NodeIR;
33
39
  export declare function group(props: {
34
40
  id: string;
35
41
  } & GroupProps, children: NodeIR[]): NodeIR;
36
42
  export declare function seq(...children: TimelineIR[]): TimelineIR;
37
43
  export declare function par(...children: TimelineIR[]): TimelineIR;
38
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;
39
60
  export declare function to(state: string, opts?: {
40
61
  duration?: number;
41
62
  ease?: Ease;
@@ -49,6 +70,14 @@ export declare function tween(target: string, props: Record<string, PropValue>,
49
70
  label?: string;
50
71
  }): TimelineIR;
51
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;
52
81
  export interface BehaviorWindow {
53
82
  from?: number;
54
83
  until?: number;
@@ -53,6 +53,23 @@ export type DisplayOp = (OpBase & {
53
53
  letterSpacing: number;
54
54
  align: TextAlign;
55
55
  baseline: TextBaseline;
56
+ }) | (OpBase & {
57
+ type: "image";
58
+ /** Raw src string as authored in the IR — consumers resolve it. */
59
+ src: string;
60
+ width: number;
61
+ height: number;
62
+ offsetX: number;
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;
56
73
  });
57
74
  export type DisplayList = DisplayOp[];
58
75
  export declare function evaluate(compiled: CompiledScene, t: number): DisplayList;
@@ -2,8 +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";
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,37 @@ 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
+ }
88
+ export interface ImageProps extends BaseProps {
89
+ /**
90
+ * Image file path: absolute, or relative to the scene file. Drawn
91
+ * stretched to width×height. As a string prop it switches discretely at
92
+ * segment start (no crossfade) — for hard-cut sequences stack image
93
+ * nodes and step their opacity instead.
94
+ */
95
+ src: string;
96
+ width: number;
97
+ height: number;
98
+ }
68
99
  export type NodeIR = {
69
100
  type: "rect";
70
101
  id: string;
@@ -81,6 +112,14 @@ export type NodeIR = {
81
112
  type: "text";
82
113
  id: string;
83
114
  props: TextProps;
115
+ } | {
116
+ type: "image";
117
+ id: string;
118
+ props: ImageProps;
119
+ } | {
120
+ type: "path";
121
+ id: string;
122
+ props: PathProps;
84
123
  } | {
85
124
  type: "group";
86
125
  id: string;
@@ -126,6 +165,47 @@ export type TimelineIR = {
126
165
  kind: "wait";
127
166
  duration: number;
128
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[];
129
209
  };
130
210
  export interface BehaviorIR {
131
211
  target: string;
@@ -210,4 +290,5 @@ export interface SceneIR {
210
290
  }
211
291
  export declare const DEFAULT_TO_DURATION = 0.5;
212
292
  export declare const DEFAULT_TWEEN_DURATION = 0.5;
293
+ export declare const DEFAULT_MOTIONPATH_DURATION = 1;
213
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,18 @@ 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.
44
+ - `image({ id, src, x, y, width, height, opacity?, rotation?, scale?, anchor? })` —
45
+ `src` is a file path, absolute or relative to the scene file; drawn stretched
46
+ to `width`×`height` (png/jpg/webp). `src` switches discretely (no crossfade) —
47
+ for hard-cut frame sequences stack image nodes and step their `opacity`; for
48
+ a dissolve, crossfade two nodes' opacity.
37
49
  - `group({ id, x, y, opacity?, rotation?, scale?, anchor? }, children)` — children's
38
50
  coordinates are relative to the group; group opacity/transform multiply down.
39
51
 
@@ -70,11 +82,23 @@ them with normal TS (`Object.fromEntries`, `.map`) for data-driven scenes.
70
82
  - `to(stateName, opts)` — transition into a named state (see above).
71
83
  - `tween(nodeId, { prop: value, ... }, { duration, ease })` — low-level escape hatch
72
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).
73
92
  - `wait(seconds)` — hold.
74
93
 
75
94
  Eases: `linear`, `easeIn/Out/InOutQuad`, `easeIn/Out/InOutCubic`,
76
95
  `easeIn/Out/InOutQuart`, `easeIn/Out/InOutExpo`, or `{ cubicBezier: [x1,y1,x2,y2] }`.
77
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`.
78
102
  Scene duration is inferred from the timeline.
79
103
 
80
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.0",
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",
@@ -5,7 +5,7 @@
5
5
  * path never uses wall-clock time.
6
6
  */
7
7
 
8
- import { evaluate, type DisplayOp, type SceneIR } from "@reframe/core";
8
+ import { collectImageSrcs, evaluate, type DisplayOp, type SceneIR } from "@reframe/core";
9
9
  import { renderFrame } from "@reframe/renderer-canvas";
10
10
  import { userScenes } from "virtual:reframe-user-scenes";
11
11
  import { buildPanel } from "./panel.js";
@@ -13,17 +13,23 @@ import { EditorStore } from "./store.js";
13
13
 
14
14
  interface SceneEntry {
15
15
  label: string;
16
+ /** Absolute directory of the scene file — relative image srcs resolve here. */
17
+ dir: string;
16
18
  load: () => Promise<{ default: SceneIR }>;
17
19
  }
18
20
 
19
21
  const exampleModules = ({} as Record<string, () => Promise<{ default: SceneIR }>>);
20
22
  const modules: Record<string, SceneEntry> = {};
21
23
  for (const path of Object.keys(exampleModules).sort()) {
22
- modules[path] = { label: path.split("/").pop()!.replace(".ts", ""), load: exampleModules[path]! };
24
+ modules[path] = {
25
+ label: path.split("/").pop()!.replace(".ts", ""),
26
+ dir: __REFRAME_EXAMPLES_DIR__,
27
+ load: exampleModules[path]!,
28
+ };
23
29
  }
24
30
  // scenes from the directory `reframe preview` was invoked in
25
- for (const { name, load } of userScenes) {
26
- modules[`user:${name}`] ??= { label: `${name} (cwd)`, load };
31
+ for (const { name, dir, load } of userScenes) {
32
+ modules[`user:${name}`] ??= { label: `${name} (cwd)`, dir, load };
27
33
  }
28
34
 
29
35
  const canvas = document.getElementById("canvas") as HTMLCanvasElement;
@@ -47,9 +53,43 @@ for (const [key, entry] of Object.entries(modules)) {
47
53
  select.appendChild(option);
48
54
  }
49
55
 
56
+ // decoded images keyed by raw src; missing entries render as a placeholder
57
+ const images = new Map<string, CanvasImageSource>();
58
+ const imageLoads = new Map<string, Promise<void>>();
59
+ let sceneDir = "";
60
+
61
+ /** Load any not-yet-loaded srcs of the current scene via /@fs. */
62
+ function ensureImages(): Promise<void> {
63
+ 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);
85
+ }
86
+
50
87
  async function loadScene(path: string) {
51
88
  const mod = await modules[path]!.load();
52
89
  store = new EditorStore(mod.default);
90
+ sceneDir = modules[path]!.dir;
91
+ images.clear();
92
+ imageLoads.clear();
53
93
  (window as unknown as { __store: EditorStore }).__store = store; // debug/testing hook
54
94
  panel = buildPanel(store, panelRoot);
55
95
  canvas.width = store.compiled.ir.size.width;
@@ -58,9 +98,11 @@ async function loadScene(path: string) {
58
98
  t = Math.min(t, store!.compiled.duration);
59
99
  if (kind === "structure") panel!.rebuild();
60
100
  else panel!.refreshReport();
101
+ void ensureImages(); // an edited src loads lazily, then redraws
61
102
  draw();
62
103
  });
63
104
  await document.fonts.ready;
105
+ await ensureImages();
64
106
  t = 0;
65
107
  panel.rebuild();
66
108
  draw();
@@ -73,7 +115,8 @@ function applyMat(m: number[], x: number, y: number): [number, number] {
73
115
  function opCorners(op: DisplayOp): [number, number][] {
74
116
  switch (op.type) {
75
117
  case "rect":
76
- case "ellipse": {
118
+ case "ellipse":
119
+ case "image": {
77
120
  const { offsetX: x, offsetY: y, width: w, height: h } = op;
78
121
  return [[x, y], [x + w, y], [x + w, y + h], [x, y + h]].map(([px, py]) =>
79
122
  applyMat(op.transform, px!, py!),
@@ -91,12 +134,15 @@ function opCorners(op: DisplayOp): [number, number][] {
91
134
  applyMat(op.transform, px!, py!),
92
135
  );
93
136
  }
137
+ case "path":
138
+ // No cheap bbox for an arbitrary `d`; mark the origin for selection.
139
+ return [applyMat(op.transform, 0, 0)];
94
140
  }
95
141
  }
96
142
 
97
143
  function draw() {
98
144
  if (!store) return;
99
- renderFrame(ctx, store.compiled, t);
145
+ renderFrame(ctx, store.compiled, t, images);
100
146
 
101
147
  if (store.selectedId) {
102
148
  const ops = evaluate(store.compiled, t).filter((op) => op.id === store!.selectedId);
@@ -115,11 +161,70 @@ function draw() {
115
161
  ctx.restore();
116
162
  }
117
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
+
118
189
  const duration = store.compiled.duration;
119
190
  scrub.value = String(duration ? t / duration : 0);
120
191
  timeLabel.textContent = `${t.toFixed(3)} / ${duration.toFixed(3)}`;
121
192
  }
122
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
+
123
228
  function tick(now: number) {
124
229
  if (playing && store) {
125
230
  t += (now - lastTick) / 1000;