reframe-video 0.4.0 → 0.5.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.
package/dist/bin.js CHANGED
@@ -1044,6 +1044,14 @@ var init_devicePreset = __esm({
1044
1044
  }
1045
1045
  });
1046
1046
 
1047
+ // ../core/src/cursor.ts
1048
+ var init_cursor = __esm({
1049
+ "../core/src/cursor.ts"() {
1050
+ "use strict";
1051
+ init_dsl();
1052
+ }
1053
+ });
1054
+
1047
1055
  // ../core/src/rig.ts
1048
1056
  var init_rig = __esm({
1049
1057
  "../core/src/rig.ts"() {
@@ -1304,6 +1312,7 @@ var init_src = __esm({
1304
1312
  init_path();
1305
1313
  init_presets();
1306
1314
  init_devicePreset();
1315
+ init_cursor();
1307
1316
  init_rig();
1308
1317
  init_characterPreset();
1309
1318
  init_figure();
package/dist/index.js CHANGED
@@ -1089,6 +1089,11 @@ function deviceBounds(name, opts = {}) {
1089
1089
  const b = BOUNDS[name];
1090
1090
  return isLandscape(name, opts) ? { width: b.height, height: b.width } : { ...b };
1091
1091
  }
1092
+ function deviceScreenPoint(name, opts, local) {
1093
+ const c = deviceScreenCenter(name, opts);
1094
+ const s = opts.scale ?? 1;
1095
+ return [(opts.x ?? 0) + s * (c.x + local[0]), (opts.y ?? 0) + s * (c.y + local[1])];
1096
+ }
1092
1097
  function screenGroup(id, p, o, cx, cy, dims, content) {
1093
1098
  return group({ id: `${id}-screen`, x: cx, y: cy, clip: { kind: "rect", x: -dims.width / 2, y: -dims.height / 2, width: dims.width, height: dims.height, radius: dims.radius } }, [
1094
1099
  rect({ id: `${id}-screenbg`, x: 0, y: 0, anchor: "center", width: dims.width, height: dims.height, fill: o.screen ?? p.screen }),
@@ -1263,6 +1268,73 @@ function devicePreset(name, opts = {}) {
1263
1268
  );
1264
1269
  }
1265
1270
 
1271
+ // ../core/src/cursor.ts
1272
+ var ARROW_D = "M0 0 L0 30 L8 23 L12.6 33 L17 31 L12.4 21.4 L21 21.4 Z";
1273
+ function cursor(opts = {}) {
1274
+ const id = opts.id ?? "cursor";
1275
+ const style = opts.style ?? "arrow";
1276
+ const fill = opts.fill ?? "#FFFFFF";
1277
+ const accent = opts.accent ?? "#FF5A1F";
1278
+ const art = style === "arrow" ? [path({ id: `${id}-arrow`, d: ARROW_D, x: 0, y: 0, fill, stroke: "#15171E", strokeWidth: 2 })] : style === "dot" ? [ellipse({ id: `${id}-dot`, x: 0, y: 0, width: 18, height: 18, fill: accent, anchor: "center" })] : [ellipse({ id: `${id}-ring`, x: 0, y: 0, width: 22, height: 22, fill: "none", stroke: accent, strokeWidth: 3, anchor: "center" })];
1279
+ return group(
1280
+ { id, x: opts.x ?? 0, y: opts.y ?? 0, scale: opts.scale ?? 1, opacity: opts.opacity ?? 1 },
1281
+ [
1282
+ // ripple ring (behind the pointer), emanates from the hotspot on click
1283
+ ellipse({ id: `${id}-ripple`, x: 0, y: 0, width: 30, height: 30, fill: "none", stroke: accent, strokeWidth: 3, opacity: 0, scale: 0, anchor: "center" }),
1284
+ // the pointer art lives in its own group so a click "tap" can scale it
1285
+ // independently of the cursor's resting scale
1286
+ group({ id: `${id}-art`, x: 0, y: 0 }, art)
1287
+ ]
1288
+ );
1289
+ }
1290
+ var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
1291
+ function cursorTo(id, from, to2, opts = {}) {
1292
+ const dx = to2[0] - from[0], dy = to2[1] - from[1];
1293
+ const dist = Math.hypot(dx, dy) || 1;
1294
+ const arc = opts.arc ?? 0.12;
1295
+ const mid = [(from[0] + to2[0]) / 2 + -dy / dist * arc * dist, (from[1] + to2[1]) / 2 + dx / dist * arc * dist];
1296
+ const duration = opts.duration ?? clamp(dist / 1400, 0.4, 0.9);
1297
+ return motionPath(id, [from, mid, to2], { duration, ease: opts.ease ?? "easeInOutCubic", curviness: 1, ...opts.label && { label: opts.label } });
1298
+ }
1299
+ function cursorPath(id, points, opts = {}) {
1300
+ return motionPath(id, points, {
1301
+ duration: opts.duration ?? clamp(points.length * 0.5, 0.5, 4),
1302
+ ease: opts.ease ?? "easeInOutCubic",
1303
+ curviness: opts.curviness ?? 1,
1304
+ ...opts.label && { label: opts.label }
1305
+ });
1306
+ }
1307
+ function clickBody(id, o) {
1308
+ const sp = Math.max(0.25, o.speed ?? 1);
1309
+ const d = (b) => b / sp;
1310
+ const out = [
1311
+ // the pointer taps
1312
+ seq(tween(`${id}-art`, { scale: 0.82 }, { duration: d(0.08), ease: "easeOutQuad" }), tween(`${id}-art`, { scale: 1 }, { duration: d(0.1), ease: "easeOutBack" }))
1313
+ ];
1314
+ if (o.ripple !== false) {
1315
+ out.push(seq(
1316
+ tween(`${id}-ripple`, { scale: 0.2, opacity: 0.55 }, { duration: 1e-3 }),
1317
+ par(
1318
+ tween(`${id}-ripple`, { scale: 5 }, { duration: d(0.5), ease: "easeOutCubic" }),
1319
+ tween(`${id}-ripple`, { opacity: 0 }, { duration: d(0.5), ease: "easeOutQuad" })
1320
+ )
1321
+ ));
1322
+ }
1323
+ if (o.press) {
1324
+ out.push(seq(tween(o.press, { scale: 0.94 }, { duration: d(0.08), ease: "easeOutQuad" }), tween(o.press, { scale: 1 }, { duration: d(0.14), ease: "easeOutBack" })));
1325
+ }
1326
+ return out;
1327
+ }
1328
+ function cursorClick(id, opts = {}) {
1329
+ return beat(opts.label ?? "cursor-click", {}, [par(...clickBody(id, opts))]);
1330
+ }
1331
+ function cursorDouble(id, opts = {}) {
1332
+ const sp = Math.max(0.25, opts.speed ?? 1);
1333
+ return beat(opts.label ?? "cursor-double", {}, [
1334
+ seq(par(...clickBody(id, { ...opts, ripple: false })), wait(0.12 / sp), par(...clickBody(id, opts)))
1335
+ ]);
1336
+ }
1337
+
1266
1338
  // ../core/src/rig.ts
1267
1339
  var DEFAULT_LINE = "#FFE3D2";
1268
1340
  var DEFAULT_FILL = "#0E1424";
@@ -2008,7 +2080,7 @@ function splitText(textStr, opts) {
2008
2080
  chars.forEach((ch, i) => {
2009
2081
  total += advance(ch, weight, fontSize) + (i < chars.length - 1 ? ls : 0);
2010
2082
  });
2011
- let cursor = align === "center" ? x - total / 2 : x;
2083
+ let cursor2 = align === "center" ? x - total / 2 : x;
2012
2084
  const glyphs = [];
2013
2085
  const nodes = [];
2014
2086
  const mk = (ch, cx, adv, lsProp) => {
@@ -2034,13 +2106,13 @@ function splitText(textStr, opts) {
2034
2106
  let i = 0;
2035
2107
  while (i < chars.length) {
2036
2108
  if (chars[i] === " ") {
2037
- cursor += advance(" ", weight, fontSize) + ls;
2109
+ cursor2 += advance(" ", weight, fontSize) + ls;
2038
2110
  i++;
2039
2111
  continue;
2040
2112
  }
2041
2113
  let word = "";
2042
2114
  let w = 0;
2043
- const startCursor = cursor;
2115
+ const startCursor = cursor2;
2044
2116
  while (i < chars.length && chars[i] !== " ") {
2045
2117
  const a = advance(chars[i], weight, fontSize);
2046
2118
  word += chars[i];
@@ -2048,13 +2120,13 @@ function splitText(textStr, opts) {
2048
2120
  i++;
2049
2121
  }
2050
2122
  mk(word, startCursor + w / 2, w, ls);
2051
- cursor = startCursor + w + ls;
2123
+ cursor2 = startCursor + w + ls;
2052
2124
  }
2053
2125
  } else {
2054
2126
  chars.forEach((ch) => {
2055
2127
  const a = advance(ch, weight, fontSize);
2056
- if (ch !== " ") mk(ch, cursor + a / 2, a);
2057
- cursor += a + ls;
2128
+ if (ch !== " ") mk(ch, cursor2 + a / 2, a);
2129
+ cursor2 += a + ls;
2058
2130
  });
2059
2131
  }
2060
2132
  return { nodes, glyphs, ids: glyphs.map((g) => g.id), width: total, x, y, fontSize };
@@ -2963,10 +3035,16 @@ export {
2963
3035
  compileScene,
2964
3036
  composeScene,
2965
3037
  composition,
3038
+ cursor,
3039
+ cursorClick,
3040
+ cursorDouble,
3041
+ cursorPath,
3042
+ cursorTo,
2966
3043
  deviceBounds,
2967
3044
  devicePreset,
2968
3045
  deviceScreen,
2969
3046
  deviceScreenCenter,
3047
+ deviceScreenPoint,
2970
3048
  ellipse,
2971
3049
  evaluate,
2972
3050
  figure,
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Cursor / pointer motion — a vector mouse pointer that glides across the scene
3
+ * and clicks things (the UI-demo staple). `cursor()` returns a NodeIR (like
4
+ * `devicePreset`); `cursorTo` / `cursorPath` / `cursorClick` return TimelineIR
5
+ * (like `characterPreset`). The pointer's HOTSPOT is the group origin (0,0), so a
6
+ * move lands the tip exactly on a target. Pairs with `deviceScreenPoint` to click
7
+ * UI inside a `devicePreset` screen.
8
+ *
9
+ * nodes: [devicePreset("browser", { id: "d", x, y, scale, content }), cursor({ id: "cur" })]
10
+ * timeline: seq(cursorTo("cur", [start], deviceScreenPoint("browser", dOpts, [lx, ly])),
11
+ * cursorClick("cur", { press: "d-ui-cta" }))
12
+ */
13
+ import type { Ease, NodeIR, TimelineIR } from "./ir.js";
14
+ export type CursorStyle = "arrow" | "dot" | "ring";
15
+ export interface CursorOpts {
16
+ id?: string;
17
+ x?: number;
18
+ y?: number;
19
+ scale?: number;
20
+ opacity?: number;
21
+ style?: CursorStyle;
22
+ /** Pointer body colour (default white for arrow). */
23
+ fill?: string;
24
+ /** Accent for dot/ring body and the click ripple. */
25
+ accent?: string;
26
+ }
27
+ export declare function cursor(opts?: CursorOpts): NodeIR;
28
+ export interface CursorToOpts {
29
+ duration?: number;
30
+ ease?: Ease;
31
+ /** perpendicular bow as a fraction of distance (default 0.12; 0 = straight). */
32
+ arc?: number;
33
+ label?: string;
34
+ }
35
+ /** Glide the cursor from `from` to `to` along a gentle human arc. */
36
+ export declare function cursorTo(id: string, from: [number, number], to: [number, number], opts?: CursorToOpts): TimelineIR;
37
+ export interface CursorPathOpts {
38
+ duration?: number;
39
+ ease?: Ease;
40
+ curviness?: number;
41
+ label?: string;
42
+ }
43
+ /** Move the cursor through a tour of waypoints (one smooth path). */
44
+ export declare function cursorPath(id: string, points: [number, number][], opts?: CursorPathOpts): TimelineIR;
45
+ export interface CursorClickOpts {
46
+ /** overall click duration scale (default 1). */
47
+ speed?: number;
48
+ /** node id to "press" (a quick scale dip) when the cursor clicks it. */
49
+ press?: string;
50
+ /** show the expanding ripple ring (default true). */
51
+ ripple?: boolean;
52
+ label?: string;
53
+ }
54
+ /** A click: the pointer taps, a ripple ring expands, and an optional target presses. */
55
+ export declare function cursorClick(id: string, opts?: CursorClickOpts): TimelineIR;
56
+ /** Two quick clicks. */
57
+ export declare function cursorDouble(id: string, opts?: CursorClickOpts): TimelineIR;
@@ -61,5 +61,9 @@ export declare function deviceBounds(name: DevicePresetName, opts?: DevicePreset
61
61
  width: number;
62
62
  height: number;
63
63
  };
64
+ /** Map a SCREEN-LOCAL point (origin = screen centre, the coords `content` is
65
+ * authored in) to absolute SCENE coords, given the same `opts` passed to
66
+ * `devicePreset`. For aiming a `cursor` at on-screen UI. */
67
+ export declare function deviceScreenPoint(name: DevicePresetName, opts: DevicePresetOpts, local: [number, number]): [number, number];
64
68
  /** Build a device-mockup frame (a group) with a clipped screen content slot. */
65
69
  export declare function devicePreset(name: DevicePresetName, opts?: DevicePresetOpts): NodeIR;
@@ -6,7 +6,8 @@ export { composeScene, formatComposeReport, type OverlayDoc, type ComposeReport,
6
6
  export { compileScene, type CompiledScene, type PropertySegment, type LabelSpan, type MotionDriver } from "./compile.js";
7
7
  export { pathPoint, pathTangentAngle, type Pt } from "./path.js";
8
8
  export { motionPreset, PRESET_NAMES, type PresetName, type PresetRig, type PresetOpts } from "./presets.js";
9
- export { devicePreset, deviceScreen, deviceScreenCenter, deviceBounds, DEVICE_PRESET_NAMES, type DevicePresetName, type DevicePresetOpts } from "./devicePreset.js";
9
+ export { devicePreset, deviceScreen, deviceScreenCenter, deviceBounds, deviceScreenPoint, DEVICE_PRESET_NAMES, type DevicePresetName, type DevicePresetOpts } from "./devicePreset.js";
10
+ export { cursor, cursorTo, cursorPath, cursorClick, cursorDouble, type CursorStyle, type CursorOpts, type CursorToOpts, type CursorPathOpts, type CursorClickOpts } from "./cursor.js";
10
11
  export { rig, rigPose, poseTo, ikReach, humanoid, ovalPath, type Bone, type RigOpts, type Pose, type HumanoidOpts } from "./rig.js";
11
12
  export { characterPreset, CHARACTER_PRESET_NAMES, type CharacterPresetName, type CharacterPresetOpts } from "./characterPreset.js";
12
13
  export { figure, type FigureStyle, type FigureOpts, type FigurePalette } from "./figure.js";
@@ -199,6 +199,33 @@ const T = splitText("MOTION IS DATA", { id: "t", x: 960, y: 470, fontSize: 130 }
199
199
  Every effect is seeded (same `seed` → identical) and pure keyframes. To time a
200
200
  `textLoop` window, add up the `textIn` beat length (≈ `(n-1)·stagger + glyphDur`).
201
201
 
202
+ ## Cursor (UI demos)
203
+
204
+ A vector mouse pointer that glides across the scene and clicks things — for app
205
+ walkthroughs. `cursor()` returns a node; the moves/clicks return timeline steps.
206
+ The pointer's **hotspot is the group origin**, so a move lands the tip on a target.
207
+
208
+ - `cursor({ id, x, y, scale?, opacity?, style?, accent? }) → NodeIR` — styles
209
+ `arrow` (default), `dot`, `ring`. Draw it LAST so it sits on top. Carries a
210
+ hidden `${id}-ripple` ring for clicks.
211
+ - `cursorTo(id, from, to, { duration?, ease?, arc? }) → TimelineIR` — glide along
212
+ a gentle human arc (`arc` is the bow, default 0.12). Thread the position: start
213
+ = the node's `x/y`, each `to` becomes the next `from`.
214
+ - `cursorPath(id, points, opts)` — a multi-stop tour through waypoints.
215
+ - `cursorClick(id, { press?, ripple?, label? })` / `cursorDouble(...)` — the
216
+ pointer taps, a ripple ring expands, and the `press` node (a button) dips. Pass
217
+ a unique `label` when you click more than once in a scene.
218
+ - `deviceScreenPoint(name, deviceOpts, [lx, ly]) → [x, y]` — map a UI element's
219
+ screen-local coords (the coords `devicePreset` `content` is authored in) to
220
+ scene coords, so the cursor clicks on-screen UI precisely (account for the
221
+ device's `scale` at click time and any `slot` offset).
222
+
223
+ ```ts
224
+ // nodes: devicePreset("browser", { id:"d", x, y, scale:0.88, content }), cursor({ id:"cur" })
225
+ const cta = deviceScreenPoint("browser", { x, y, scale: 0.88 }, [lx, ly]);
226
+ seq(cursorTo("cur", [sx, sy], cta), cursorClick("cur", { press: "browser-ui-cta" }))
227
+ ```
228
+
202
229
  ## Audio (optional)
203
230
 
204
231
  Label-anchored sound design — cues follow retiming and regeneration:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reframe-video",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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",