@pixerate/editor 0.57.0 → 0.59.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 (53) hide show
  1. package/dist/animation/index.cjs +23 -3
  2. package/dist/animation/index.cjs.map +1 -1
  3. package/dist/animation/index.d.cts +3 -2
  4. package/dist/animation/index.d.ts +3 -2
  5. package/dist/animation/index.js +23 -3
  6. package/dist/animation-editor/index.cjs +836 -158
  7. package/dist/animation-editor/index.cjs.map +1 -1
  8. package/dist/animation-editor/index.d.cts +454 -40
  9. package/dist/animation-editor/index.d.ts +454 -40
  10. package/dist/animation-editor/index.js +790 -112
  11. package/dist/animation-editor/index.js.map +1 -1
  12. package/dist/behavior-Ymbld6jL.d.cts +60 -0
  13. package/dist/behavior-Ymbld6jL.d.ts +60 -0
  14. package/dist/canvas/index.cjs +35 -29
  15. package/dist/canvas/index.cjs.map +1 -1
  16. package/dist/canvas/index.d.cts +4 -4
  17. package/dist/canvas/index.d.ts +4 -4
  18. package/dist/canvas/index.js +9 -3
  19. package/dist/canvas/index.js.map +1 -1
  20. package/dist/{chunk-O4ONBPZW.cjs → chunk-2FGRTYJN.cjs} +4 -4
  21. package/dist/{chunk-O4ONBPZW.cjs.map → chunk-2FGRTYJN.cjs.map} +1 -1
  22. package/dist/{chunk-XZ4L3R4K.cjs → chunk-2FHLHZAL.cjs} +39 -3
  23. package/dist/chunk-2FHLHZAL.cjs.map +1 -0
  24. package/dist/chunk-3NYXDBBW.js +122 -0
  25. package/dist/chunk-3NYXDBBW.js.map +1 -0
  26. package/dist/{chunk-NONDTTH7.js → chunk-3TLL5OID.js} +17 -2
  27. package/dist/{chunk-NONDTTH7.js.map → chunk-3TLL5OID.js.map} +1 -1
  28. package/dist/{chunk-UB2OY7FD.js → chunk-AGR4P4UZ.js} +38 -2
  29. package/dist/chunk-AGR4P4UZ.js.map +1 -0
  30. package/dist/{chunk-SHOVAHUB.js → chunk-JK6M6V7G.js} +2 -2
  31. package/dist/{chunk-XCXQDPID.cjs → chunk-YJLCBA7V.cjs} +25 -10
  32. package/dist/chunk-YJLCBA7V.cjs.map +1 -0
  33. package/dist/chunk-ZCNLBRT6.cjs +122 -0
  34. package/dist/chunk-ZCNLBRT6.cjs.map +1 -0
  35. package/dist/{easing-CNGk-W6d.d.ts → easing-lM0Xo9Ni.d.cts} +14 -2
  36. package/dist/{easing-CNGk-W6d.d.cts → easing-lM0Xo9Ni.d.ts} +14 -2
  37. package/dist/parameters/index.cjs +2 -2
  38. package/dist/parameters/index.d.cts +1 -1
  39. package/dist/parameters/index.d.ts +1 -1
  40. package/dist/parameters/index.js +1 -1
  41. package/dist/{player-W-5DX3Go.d.ts → player-DV4Jujdq.d.ts} +1 -1
  42. package/dist/{player-Dg0FT0UI.d.cts → player-DrcP2xqj.d.cts} +1 -1
  43. package/dist/{svg-CPK82TtU.d.ts → svg-Dh17jCyA.d.ts} +22 -1
  44. package/dist/{svg-CLg2zqZA.d.cts → svg-ymShkNtu.d.cts} +22 -1
  45. package/dist/vector/index.cjs +472 -472
  46. package/dist/vector/index.d.cts +3 -3
  47. package/dist/vector/index.d.ts +3 -3
  48. package/dist/vector/index.js +2 -2
  49. package/package.json +1 -1
  50. package/dist/chunk-UB2OY7FD.js.map +0 -1
  51. package/dist/chunk-XCXQDPID.cjs.map +0 -1
  52. package/dist/chunk-XZ4L3R4K.cjs.map +0 -1
  53. /package/dist/{chunk-SHOVAHUB.js.map → chunk-JK6M6V7G.js.map} +0 -0
@@ -10,6 +10,7 @@ import {
10
10
  isIdentityMatrix,
11
11
  multiplyMatrices,
12
12
  nodeMatrix,
13
+ parseSvgTransform,
13
14
  parseVectorSvg,
14
15
  rotateMatrix,
15
16
  scaleMatrix,
@@ -17,7 +18,7 @@ import {
17
18
  vectorDocumentToSvg,
18
19
  vectorNodeBounds,
19
20
  vectorPathWorldBezier
20
- } from "../chunk-NONDTTH7.js";
21
+ } from "../chunk-3TLL5OID.js";
21
22
  import {
22
23
  bezierPathLength,
23
24
  bezierPathPointAtLength,
@@ -27,9 +28,13 @@ import {
27
28
  import {
28
29
  HistoryManager
29
30
  } from "../chunk-QVZZGT4X.js";
31
+ import {
32
+ behaviorProgress,
33
+ motionSeedFromId
34
+ } from "../chunk-3NYXDBBW.js";
30
35
  import {
31
36
  AnimationPlayer
32
- } from "../chunk-SHOVAHUB.js";
37
+ } from "../chunk-JK6M6V7G.js";
33
38
  import {
34
39
  mixColorsOklab
35
40
  } from "../chunk-ZRJMJIQU.js";
@@ -42,8 +47,10 @@ import {
42
47
  ANIMATION_EASING_PRESETS,
43
48
  evaluateEasing,
44
49
  isAnimationEasing,
45
- resolveEasing
46
- } from "../chunk-UB2OY7FD.js";
50
+ resolveEasing,
51
+ reverseCubicBezier,
52
+ springSettleMs
53
+ } from "../chunk-AGR4P4UZ.js";
47
54
 
48
55
  // src/animation-editor/types.ts
49
56
  var MOTION_COMMAND_TYPES = [
@@ -59,11 +66,18 @@ var MOTION_COMMAND_TYPES = [
59
66
  "follow",
60
67
  "pulse",
61
68
  "shake",
69
+ "teeter",
70
+ "wave",
71
+ "wiggle",
72
+ "color-pulse",
62
73
  "effect",
63
74
  "wait"
64
75
  ];
65
- var MOTION_ENTER_STYLES = ["fade", "pop", "grow", "slide", "draw"];
66
- var MOTION_EXIT_STYLES = ["fade", "shrink", "slide", "undraw"];
76
+ var MOTION_ENTER_STYLES = ["fade", "pop", "grow", "slide", "draw", "zoom", "rise", "wipe", "iris", "split"];
77
+ var MOTION_REPEAT_MODES = ["loop", "mirror", "offset"];
78
+ var MOTION_EXIT_STYLES = ["fade", "shrink", "slide", "undraw", "wipe"];
79
+ var MOTION_IRIS_SHAPES = ["circle", "box"];
80
+ var MOTION_SPLIT_ORIENTATIONS = ["horizontal", "vertical"];
67
81
  var MOTION_DIRECTIONS = ["up", "down", "left", "right"];
68
82
  var MOTION_DEFAULT_DURATIONS = {
69
83
  enter: 600,
@@ -77,12 +91,37 @@ var MOTION_DEFAULT_DURATIONS = {
77
91
  follow: 1400,
78
92
  pulse: 600,
79
93
  shake: 500,
94
+ teeter: 600,
95
+ wave: 1e3,
96
+ wiggle: 1500,
97
+ "color-pulse": 600,
80
98
  effect: 900
81
99
  };
82
100
  var MOTION_DEFAULT_HOLD = 600;
83
101
  var MOTION_CAMERA_MS = 1e3;
84
102
  var MOTION_DRAW_MS_PER_UNIT = 1.5;
85
103
  var MOTION_DRAW_RANGE = [500, 2500];
104
+ function motionFlairPrimitive(command2) {
105
+ switch (command2.type) {
106
+ case "enter":
107
+ return `entrance.${command2.style ?? "fade"}`;
108
+ case "exit":
109
+ return `exit.${command2.style ?? "fade"}`;
110
+ case "pulse":
111
+ case "shake":
112
+ case "teeter":
113
+ case "wave":
114
+ case "wiggle":
115
+ case "color-pulse":
116
+ return `emphasis.${command2.type}`;
117
+ case "rotate":
118
+ return command2.by !== void 0 ? "emphasis.spin" : void 0;
119
+ case "follow":
120
+ return "path.follow";
121
+ default:
122
+ return void 0;
123
+ }
124
+ }
86
125
 
87
126
  // src/animation-editor/issues.ts
88
127
  var MOTION_ISSUE_CODES = {
@@ -91,18 +130,20 @@ var MOTION_ISSUE_CODES = {
91
130
  "unknown-target": { severity: "error", wording: "A command names a node the artwork does not have." },
92
131
  "unknown-path": { severity: "error", wording: "A follow command names a path the artwork does not have." },
93
132
  "not-a-path": { severity: "error", wording: "A follow path must be a path node, not a group." },
133
+ "reveal-no-box": { severity: "warning", wording: "A wipe, iris or split cannot clip a node with no width or height (a lone straight line), so it fades instead." },
94
134
  "unknown-shape": { severity: "error", wording: "A morph names a shape the artwork does not have." },
95
135
  "unknown-effect": { severity: "error", wording: "An effect command names an effect the node does not have." },
96
136
  "unknown-effect-param": { severity: "error", wording: "An effect command names a parameter that is not a number of that effect." },
97
137
  "unknown-frame": { severity: "error", wording: "A camera frames a node the artwork does not have." },
98
138
  "invalid-camera": { severity: "error", wording: "A camera rectangle has no area." },
99
139
  "invalid-color": { severity: "error", wording: "A colour cannot be read." },
100
- "paint-not-color": { severity: "warning", wording: "A style command recolours a gradient or empty paint, which only switches at the end." },
140
+ "paint-not-color": { severity: "warning", wording: "A style command recolours a gradient or empty paint, which only switches at the end, or a colour pulse has no solid paint to tint." },
101
141
  "overlap": { severity: "warning", wording: "Two commands change the same property of a node at the same time; the later one wins." },
102
142
  "enter-visible": { severity: "warning", wording: "A node enters while it is already showing, so it blinks out first." },
103
143
  "exit-hidden": { severity: "warning", wording: "A node exits or animates while it is hidden." },
104
144
  "out-of-frame": { severity: "warning", wording: "A node a command animates ends up outside the camera." },
105
145
  "too-fast": { severity: "warning", wording: "A change is too quick to follow." },
146
+ "spring-cut-short": { severity: "warning", wording: "A spring is given a duration shorter than it takes to settle, so it jumps to its end." },
106
147
  "scene-too-long": { severity: "warning", wording: "The scene runs past its target length." },
107
148
  "scene-too-short": { severity: "warning", wording: "The scene ends before its target length." },
108
149
  "unknown-control-color": { severity: "warning", wording: "A control stands for a colour the scene never uses." },
@@ -160,23 +201,27 @@ ${problems.map((p) => `- ${p}`).join("\n")}`);
160
201
  this.problems = problems;
161
202
  }
162
203
  };
163
- var SCENE_KEYS = ["version", "name", "description", "artwork", "shots", "loop", "fps", "target", "controls", "presets"];
204
+ var SCENE_KEYS = ["version", "name", "description", "artwork", "shots", "loop", "fps", "steps", "target", "controls", "presets"];
164
205
  var SHOT_KEYS = ["id", "label", "camera", "run", "hold"];
165
- var CAMERA_KEYS = ["x", "y", "width", "height", "frame", "padding", "ease", "ms"];
166
- var BASE_KEYS = ["type", "targets", "duration", "delay", "stagger", "with", "ease", "note"];
206
+ var CAMERA_KEYS = ["x", "y", "width", "height", "frame", "padding", "ease", "ms", "geometric"];
207
+ var BASE_KEYS = ["type", "targets", "duration", "delay", "stagger", "with", "ease", "note", "repeat", "repeatMode", "steps"];
167
208
  var COMMAND_KEYS = {
168
- enter: [...BASE_KEYS, "style", "direction", "distance"],
209
+ enter: [...BASE_KEYS, "style", "direction", "distance", "scale", "shape", "orientation"],
169
210
  exit: [...BASE_KEYS, "style", "direction", "distance"],
170
211
  draw: [...BASE_KEYS, "from", "to"],
171
212
  move: [...BASE_KEYS, "by", "to"],
172
213
  rotate: [...BASE_KEYS, "by", "to"],
173
- scale: [...BASE_KEYS, "to"],
214
+ scale: [...BASE_KEYS, "to", "geometric"],
174
215
  fade: [...BASE_KEYS, "to"],
175
216
  style: [...BASE_KEYS, "fill", "stroke", "strokeWidth"],
176
217
  morph: [...BASE_KEYS, "into"],
177
218
  follow: [...BASE_KEYS, "path", "rotate", "from", "to"],
178
219
  pulse: [...BASE_KEYS, "amount", "count"],
179
- shake: [...BASE_KEYS, "amount", "count"],
220
+ shake: [...BASE_KEYS, "amount", "count", "axis"],
221
+ teeter: [...BASE_KEYS, "angle", "count"],
222
+ wave: [...BASE_KEYS, "amount", "count"],
223
+ wiggle: [...BASE_KEYS, "amount", "frequency", "octaves", "rotate", "seed"],
224
+ "color-pulse": [...BASE_KEYS, "color", "paint", "count"],
180
225
  effect: [...BASE_KEYS, "effect", "param", "to"],
181
226
  wait: ["type", "duration", "with", "note"]
182
227
  };
@@ -214,7 +259,13 @@ function commandShapeProblems(command2, where) {
214
259
  nonNegative("duration");
215
260
  nonNegative("delay");
216
261
  nonNegative("stagger");
217
- if (command2.ease !== void 0 && !isAnimationEasing(command2.ease)) p(`"ease" is not an easing; use a preset such as "ease-in-out" or { "type": "cubic-bezier", \u2026 }.`);
262
+ if (command2.ease !== void 0 && !isAnimationEasing(command2.ease)) p(`"ease" is not an easing; use a preset such as "ease-in-out" or "spring-bouncy", or { "type": "cubic-bezier", \u2026 } or { "type": "spring", \u2026 }.`);
263
+ if (command2.repeat !== void 0) {
264
+ if (type === "enter" || type === "exit") p(`"repeat" is not for ${type} (it brings things in or out once); repeat an emphasis or a move instead.`);
265
+ else if (!(Number.isInteger(command2.repeat) && command2.repeat >= 1 && command2.repeat <= 1e3)) p(`"repeat" must be a whole number from 1 to 1000.`);
266
+ }
267
+ if (command2.repeatMode !== void 0 && !MOTION_REPEAT_MODES.includes(command2.repeatMode)) p(`"repeatMode" must be one of ${MOTION_REPEAT_MODES.join(", ")}.`);
268
+ if (command2.steps !== void 0 && !(isNum(command2.steps) && command2.steps > 0 && command2.steps <= 120)) p(`"steps" must be frames per second from above 0 to 120 (12 holds each pose for two frames at 24 fps).`);
218
269
  switch (type) {
219
270
  case "enter":
220
271
  case "exit": {
@@ -223,6 +274,9 @@ function commandShapeProblems(command2, where) {
223
274
  p(`"style" must be one of ${styles.join(", ")}${typeof command2.style === "string" ? ` (got ${namedWithSuggestion(command2.style, styles)})` : ""}.`);
224
275
  if (command2.direction !== void 0 && !MOTION_DIRECTIONS.includes(command2.direction)) p(`"direction" must be one of ${MOTION_DIRECTIONS.join(", ")}.`);
225
276
  optionalNumber("distance");
277
+ if (command2.scale !== void 0 && !(isNum(command2.scale) && command2.scale >= 0)) p(`"scale" must be a number \u2265 0 (the starting scale for pop and zoom).`);
278
+ if (command2.shape !== void 0 && !MOTION_IRIS_SHAPES.includes(command2.shape)) p(`"shape" must be one of ${MOTION_IRIS_SHAPES.join(", ")} (the opening of an iris).`);
279
+ if (command2.orientation !== void 0 && !MOTION_SPLIT_ORIENTATIONS.includes(command2.orientation)) p(`"orientation" must be one of ${MOTION_SPLIT_ORIENTATIONS.join(", ")} (the line a split opens from).`);
226
280
  break;
227
281
  }
228
282
  case "draw":
@@ -243,6 +297,7 @@ function commandShapeProblems(command2, where) {
243
297
  break;
244
298
  case "scale":
245
299
  if (!(isNum(command2.to) || isPoint(command2.to))) p(`"to" must be a number or { "x": number, "y": number }.`);
300
+ if (command2.geometric !== void 0 && typeof command2.geometric !== "boolean") p(`"geometric" must be true or false.`);
246
301
  break;
247
302
  case "fade":
248
303
  if (!(isNum(command2.to) && command2.to >= 0 && command2.to <= 1)) p(`"to" must be an opacity from 0 to 1.`);
@@ -264,8 +319,22 @@ function commandShapeProblems(command2, where) {
264
319
  break;
265
320
  case "pulse":
266
321
  case "shake":
322
+ case "teeter":
323
+ case "wave":
324
+ case "color-pulse":
267
325
  optionalNumber("amount");
326
+ optionalNumber("angle");
268
327
  if (command2.count !== void 0 && !(Number.isInteger(command2.count) && command2.count >= 1)) p(`"count" must be a whole number \u2265 1.`);
328
+ if (command2.axis !== void 0 && command2.axis !== "horizontal" && command2.axis !== "vertical") p(`"axis" must be "horizontal" or "vertical".`);
329
+ if (command2.color !== void 0 && typeof command2.color !== "string") p(`"color" must be a CSS colour.`);
330
+ if (command2.paint !== void 0 && command2.paint !== "fill" && command2.paint !== "stroke") p(`"paint" must be "fill" or "stroke".`);
331
+ break;
332
+ case "wiggle":
333
+ optionalNumber("amount");
334
+ optionalNumber("rotate");
335
+ if (command2.frequency !== void 0 && !(isNum(command2.frequency) && command2.frequency > 0)) p(`"frequency" must be a number of Hz > 0.`);
336
+ if (command2.octaves !== void 0 && !(Number.isInteger(command2.octaves) && command2.octaves >= 1 && command2.octaves <= 8)) p(`"octaves" must be a whole number from 1 to 8.`);
337
+ if (command2.seed !== void 0 && !(Number.isInteger(command2.seed) && command2.seed >= 0)) p(`"seed" must be a whole number \u2265 0.`);
269
338
  break;
270
339
  case "effect":
271
340
  if (typeof command2.effect !== "string" || !command2.effect) p(`"effect" must be the id of one of the node's effects.`);
@@ -294,6 +363,7 @@ function shotShapeProblems(shot, where) {
294
363
  } else if (!isRect) problems.push(`${name} camera: give { x, y, width, height } or { frame: "<node id>" }.`);
295
364
  if (c.ease !== void 0 && !isAnimationEasing(c.ease)) problems.push(`${name} camera: "ease" is not an easing.`);
296
365
  if (c.ms !== void 0 && !(isNum(c.ms) && c.ms >= 0)) problems.push(`${name} camera: "ms" must be a number \u2265 0.`);
366
+ if (c.geometric !== void 0 && typeof c.geometric !== "boolean") problems.push(`${name} camera: "geometric" must be true or false.`);
297
367
  }
298
368
  }
299
369
  if (!Array.isArray(shot.run)) problems.push(`${name}: "run" must be a list of commands (it may be empty).`);
@@ -312,6 +382,7 @@ function motionSceneShapeProblems(input) {
312
382
  problems.push('Scene: "artwork" must be a vector document { width, height, nodes }.');
313
383
  if (input.loop !== void 0 && !["none", "loop", "ping-pong"].includes(input.loop)) problems.push('Scene: "loop" must be "none", "loop" or "ping-pong".');
314
384
  if (input.fps !== void 0 && !(isNum(input.fps) && input.fps > 0 && input.fps <= 120)) problems.push('Scene: "fps" must be a number from 1 to 120.');
385
+ if (input.steps !== void 0 && !(isNum(input.steps) && input.steps > 0 && input.steps <= 120)) problems.push('Scene: "steps" must be frames per second from above 0 to 120.');
315
386
  if (input.target !== void 0 && !(Array.isArray(input.target) && input.target.length === 2 && input.target.every(isNum) && input.target[0] <= input.target[1]))
316
387
  problems.push('Scene: "target" must be [min, max] seconds.');
317
388
  if (input.controls !== void 0) {
@@ -358,7 +429,7 @@ function indexMotionNodes(nodes) {
358
429
  const local = vectorNodeBounds(node);
359
430
  const bounds = vectorNodeBounds(node, parentWorld);
360
431
  const center = local ? { x: local.x + local.width / 2, y: local.y + local.height / 2 } : { x: 0, y: 0 };
361
- if (!out.has(node.id)) out.set(node.id, { node, ancestors, parentWorld, center, bounds });
432
+ if (!out.has(node.id)) out.set(node.id, { node, ancestors, parentWorld, center, bounds, box: local });
362
433
  if (node.type === "group") walk(node.children, multiplyMatrices(nodeMatrix(node), parentWorld), [...ancestors, node.id]);
363
434
  }
364
435
  };
@@ -375,6 +446,56 @@ function firstPaint(node, key) {
375
446
  }
376
447
  return void 0;
377
448
  }
449
+ function strokeReach(node) {
450
+ let reach = 0;
451
+ for (const p of node.type === "path" ? [node] : collectPathNodes(node)) {
452
+ if (p.stroke == null || p.stroke === "none") continue;
453
+ const width = p.strokeWidth ?? 1;
454
+ reach = Math.max(reach, p.strokeAlign === "outside" ? width : p.strokeAlign === "inside" ? 0 : width / 2);
455
+ }
456
+ return reach;
457
+ }
458
+ function motionRevealPadding(entry) {
459
+ const box = entry.box;
460
+ if (!box || !(box.width > 0) || !(box.height > 0)) return null;
461
+ const s = strokeReach(entry.node);
462
+ return [Math.min(1, s / box.width + 0.02), Math.min(1, s / box.height + 0.02)];
463
+ }
464
+ var clipRound = (n) => Math.round(n * 1e4) / 1e4 || 0;
465
+ function revealFull(property, [px, py]) {
466
+ return (property === "clipRect" ? [-px, -py, 1 + 2 * px, 1 + 2 * py] : [0.5, 0.5, Math.hypot(0.5 + px, 0.5 + py)]).map(clipRound);
467
+ }
468
+ var OPPOSITE = { up: "down", down: "up", left: "right", right: "left" };
469
+ function motionRevealClip(entry, reveal) {
470
+ const pad = motionRevealPadding(entry);
471
+ if (!pad) return null;
472
+ const [px, py] = pad;
473
+ const [w, h] = [1 + 2 * px, 1 + 2 * py];
474
+ const rect = (from, to) => ({ property: "clipRect", from: from.map(clipRound), to: to.map(clipRound) });
475
+ if (reveal.style === "iris") {
476
+ if (reveal.shape === "box") return rect([0.5, 0.5, 0, 0], [-px, -py, w, h]);
477
+ return { property: "clipCircle", from: [0.5, 0.5, 0], to: revealFull("clipCircle", pad) };
478
+ }
479
+ if (reveal.style === "split") {
480
+ if (reveal.orientation === "vertical") return rect([0.5, -py, 0, h], [-px, -py, w, h]);
481
+ return rect([-px, 0.5, w, 0], [-px, -py, w, h]);
482
+ }
483
+ const direction = reveal.direction ?? "right";
484
+ if (reveal.hide) {
485
+ const shown = motionRevealClip(entry, { style: "wipe", direction: OPPOSITE[direction] });
486
+ return { ...shown, from: shown.to, to: shown.from };
487
+ }
488
+ switch (direction) {
489
+ case "right":
490
+ return rect([-px, -py, 0, h], [-px, -py, w, h]);
491
+ case "left":
492
+ return rect([1 + px, -py, 0, h], [-px, -py, w, h]);
493
+ case "down":
494
+ return rect([-px, -py, w, 0], [-px, -py, w, h]);
495
+ case "up":
496
+ return rect([-px, 1 + py, w, 0], [-px, -py, w, h]);
497
+ }
498
+ }
378
499
  function motionRestValue(entry, property, artwork) {
379
500
  const node = entry?.node;
380
501
  switch (property) {
@@ -402,6 +523,11 @@ function motionRestValue(entry, property, artwork) {
402
523
  }
403
524
  case "shape":
404
525
  return node?.id ?? "";
526
+ case "clipRect":
527
+ case "clipCircle": {
528
+ const pad = entry && motionRevealPadding(entry);
529
+ return pad ? revealFull(property, pad) : [];
530
+ }
405
531
  case "camera":
406
532
  return [0, 0, artwork.width, artwork.height];
407
533
  }
@@ -423,16 +549,36 @@ function interpolateMotionValue(from, to, u) {
423
549
  }
424
550
  function segmentProgress(segment, time) {
425
551
  const span = segment.end - segment.start;
426
- const u = span <= 0 ? 1 : Math.min(1, Math.max(0, (time - segment.start) / span));
427
- return { u, eased: segment.wave ? u : evaluateEasing(segment.ease, u, span) };
552
+ let elapsed = time - segment.start;
553
+ if (segment.steps && segment.steps > 0 && elapsed < span) elapsed = Math.floor(elapsed * segment.steps / 1e3 + 1e-9) / segment.steps * 1e3;
554
+ const u = span <= 0 ? 1 : Math.min(1, Math.max(0, elapsed / span));
555
+ return { u, eased: evaluateEasing(segment.ease, segment.reverse ? 1 - u : u, span) };
428
556
  }
429
- function segmentValue(segment, time) {
430
- const { u, eased } = segmentProgress(segment, time);
431
- const v = interpolateMotionValue(segment.from, segment.to, eased);
432
- if (!segment.wave || typeof v !== "number") return v;
433
- const { kind, amount, count } = segment.wave;
434
- if (kind === "pulse") return v * (1 + amount * Math.sin(Math.PI * u * count) ** 2);
435
- return v + amount * Math.sin(2 * Math.PI * u * count) * (1 - u);
557
+ function geometricValue(from, to, e) {
558
+ const grow = (a, b) => a > 0 && b > 0 ? a * (b / a) ** e : void 0;
559
+ if (typeof from === "number" && typeof to === "number") return grow(from, to);
560
+ if (Array.isArray(from) && Array.isArray(to) && from.length === 4 && to.length === 4) {
561
+ const [x0, y0, w0, h0] = from;
562
+ const [x1, y1, w1, h1] = to;
563
+ const w = grow(w0, w1);
564
+ const h = grow(h0, h1);
565
+ if (w === void 0 || h === void 0) return void 0;
566
+ const f = Math.abs(w1 - w0) > 1e-9 ? (w - w0) / (w1 - w0) : e;
567
+ const cx = x0 + w0 / 2 + (x1 + w1 / 2 - x0 - w0 / 2) * f;
568
+ const cy = y0 + h0 / 2 + (y1 + h1 / 2 - y0 - h0 / 2) * f;
569
+ return [cx - w / 2, cy - h / 2, w, h];
570
+ }
571
+ return void 0;
572
+ }
573
+ function segmentValue(segment, time, raw = false) {
574
+ const { eased } = segmentProgress(segment, time);
575
+ if (segment.geometric && !segment.behavior) {
576
+ const g = geometricValue(segment.from, segment.to, eased);
577
+ if (g !== void 0) return g;
578
+ }
579
+ if (!segment.behavior || raw) return interpolateMotionValue(segment.from, segment.to, eased);
580
+ const p = behaviorProgress(segment.behavior, eased, segment.index ?? 0, segment.end - segment.start);
581
+ return typeof segment.from === "string" ? interpolateMotionValue(segment.from, segment.to, Math.min(1, Math.max(0, p))) : interpolateMotionValue(segment.from, segment.to, p);
436
582
  }
437
583
  function activeSegment(track, time) {
438
584
  if (!track?.length) return void 0;
@@ -440,10 +586,10 @@ function activeSegment(track, time) {
440
586
  for (const s of track) if (s.start <= time && (!found || s.start >= found.start)) found = s;
441
587
  return found;
442
588
  }
443
- function trackValue(track, time) {
589
+ function trackValue(track, time, raw = false) {
444
590
  if (!track?.length) return void 0;
445
591
  const s = activeSegment(track, time);
446
- if (s) return segmentValue(s, time);
592
+ if (s) return segmentValue(s, time, raw);
447
593
  let first = track[0];
448
594
  for (const t of track) if (t.start < first.start) first = t;
449
595
  return first.from;
@@ -480,9 +626,21 @@ function motionPathLength(node, parentWorld = IDENTITY_MATRIX) {
480
626
  }
481
627
  return longest;
482
628
  }
629
+ function motionCommandRepeat(command2) {
630
+ if (command2.type === "wait" || command2.type === "enter" || command2.type === "exit") return 1;
631
+ const n = command2.repeat ?? 1;
632
+ return Number.isInteger(n) && n >= 1 ? n : 1;
633
+ }
483
634
  function motionCommandDuration(command2, scene, target) {
635
+ return motionCycleDuration(command2, scene, target) * motionCommandRepeat(command2);
636
+ }
637
+ function motionCycleDuration(command2, scene, target) {
484
638
  if (command2.type === "wait") return command2.duration;
485
639
  if (command2.duration !== void 0) return command2.duration;
640
+ if (command2.ease !== void 0) {
641
+ const ease = resolveEasing(command2.ease);
642
+ if (ease !== "hold" && ease.type === "spring") return springSettleMs(ease);
643
+ }
486
644
  if (command2.type === "draw" || command2.type === "enter" && command2.style === "draw" || command2.type === "exit" && command2.style === "undraw") {
487
645
  const entry = target ? indexMotionNodes(scene.artwork.nodes).get(target) : void 0;
488
646
  const length = entry ? motionPathLength(entry.node) : 0;
@@ -511,7 +669,7 @@ function compileMotionScene(scene) {
511
669
  const rect = resolveMotionCamera(shot.camera, scene);
512
670
  if (rect && rect.width > 0 && rect.height > 0) {
513
671
  const ms = index === 0 ? 0 : shot.camera.ms ?? MOTION_CAMERA_MS;
514
- cameraMoves.push({ shot: shot.id, start, end: start + ms, to: rect, ease: shot.camera.ease ?? MOTION_EASE_IN_OUT });
672
+ cameraMoves.push({ shot: shot.id, start, end: start + ms, to: rect, ease: shot.camera.ease ?? MOTION_EASE_IN_OUT, geometric: shot.camera.geometric === true });
515
673
  cursor = start + ms;
516
674
  }
517
675
  }
@@ -552,7 +710,7 @@ function compileMotionScene(scene) {
552
710
  jobs.push({
553
711
  start: move.start,
554
712
  order: order++,
555
- run: () => push({ node: "", property: "camera", start: move.start, end: move.end, from: valueAt("", "camera", move.start), to: [move.to.x, move.to.y, move.to.width, move.to.height], ease: move.ease, shot: move.shot, command: -1 })
713
+ run: () => push({ node: "", property: "camera", start: move.start, end: move.end, from: valueAt("", "camera", move.start), to: [move.to.x, move.to.y, move.to.width, move.to.height], ease: move.ease, shot: move.shot, command: -1, ...move.geometric ? { geometric: true } : {} })
556
714
  });
557
715
  }
558
716
  for (const timing of commands) {
@@ -563,11 +721,37 @@ function compileMotionScene(scene) {
563
721
  const entry = nodes.get(target);
564
722
  if (!entry) return;
565
723
  const start = timing.start + k * (command2.stagger ?? 0);
566
- const duration2 = motionCommandDuration(command2, scene, target);
567
- jobs.push({ start, order: order++, run: () => compileCommand(command2, target, entry, start, duration2, timing) });
724
+ const cycle = motionCycleDuration(command2, scene, target);
725
+ jobs.push({ start, order: order++, run: () => compileRepeated(command2, target, entry, start, cycle, timing) });
568
726
  });
569
727
  }
570
728
  jobs.sort((a, b) => a.start - b.start || a.order - b.order).forEach((job) => job.run());
729
+ function compileRepeated(command2, node, entry, start, cycle, timing) {
730
+ const first = segments.length;
731
+ compileCommand(command2, node, entry, start, cycle, timing);
732
+ const made = segments.slice(first);
733
+ const steps = command2.steps ?? scene.steps;
734
+ if (steps && steps > 0) for (const s of made) s.steps = steps;
735
+ const n = motionCommandRepeat(command2);
736
+ const mode = command2.repeatMode ?? "loop";
737
+ const shift = (v, by, k) => {
738
+ if (typeof v === "number" && typeof by === "number") return v + by * k;
739
+ if (Array.isArray(v) && Array.isArray(by) && v.length === by.length) return v.map((x, i) => x + by[i] * k);
740
+ return v;
741
+ };
742
+ for (let k = 1; k < n; k++) {
743
+ const at = start + k * cycle;
744
+ for (const s of made) {
745
+ if (mode === "mirror" && k % 2 === 1) {
746
+ push({ ...s, start: at + (start + cycle - s.end), end: at + (start + cycle - s.start), reverse: !s.reverse });
747
+ continue;
748
+ }
749
+ const delta = typeof s.from === "number" && typeof s.to === "number" ? s.to - s.from : Array.isArray(s.from) && Array.isArray(s.to) && s.from.length === s.to.length ? s.to.map((x, i) => x - s.from[i]) : 0;
750
+ const by = mode === "offset" ? delta : 0;
751
+ push({ ...s, start: at + (s.start - start), end: at + (s.end - start), from: shift(s.from, by, k), to: shift(s.to, by, k) });
752
+ }
753
+ }
754
+ }
571
755
  function compileCommand(command2, node, entry, start, duration2, timing) {
572
756
  const end = start + duration2;
573
757
  const where = { shot: timing.shot, command: timing.command };
@@ -582,15 +766,27 @@ function compileMotionScene(scene) {
582
766
  case "enter": {
583
767
  const style = command2.style ?? "fade";
584
768
  const opacity = rest("opacity");
769
+ const reveal = style === "wipe" ? motionRevealClip(entry, { style, direction: command2.direction }) : style === "iris" ? motionRevealClip(entry, { style, shape: command2.shape }) : style === "split" ? motionRevealClip(entry, { style, orientation: command2.orientation }) : null;
770
+ for (const p of ["clipRect", "clipCircle"]) if (p !== reveal?.property && tracks.has(motionTrackKey(node, p))) seg(p, rest(p), "linear", start, start);
771
+ if (reveal) {
772
+ seg("opacity", opacity, "linear", start, start);
773
+ seg(reveal.property, reveal.to, ease ?? MOTION_EASE_OUT, start, end, { from: reveal.from });
774
+ break;
775
+ }
585
776
  if (style === "draw") {
586
777
  seg("drawEnd", 1, ease ?? MOTION_EASE_IN_OUT, start, start + duration2 * 0.75, { from: 0 });
587
778
  seg("fillOpacity", 1, MOTION_EASE_OUT, start + duration2 * 0.6, end, { from: 0 });
588
779
  break;
589
780
  }
590
- if (style === "pop") {
591
- seg("opacity", opacity, MOTION_EASE_OUT, start, start + duration2 * 0.4, { from: 0 });
592
- seg("scaleX", 1, ease ?? "ease-out-back", start, end, { from: 0.5 });
593
- seg("scaleY", 1, ease ?? "ease-out-back", start, end, { from: 0.5 });
781
+ if (style === "pop" || style === "zoom") {
782
+ const from = command2.scale ?? (style === "pop" ? 0.5 : 1.6);
783
+ seg("opacity", opacity, MOTION_EASE_OUT, start, start + duration2 * (style === "pop" ? 0.4 : 0.5), { from: 0 });
784
+ seg("scaleX", 1, ease ?? (style === "pop" ? "ease-out-back" : MOTION_EASE_OUT), start, end, { from });
785
+ seg("scaleY", 1, ease ?? (style === "pop" ? "ease-out-back" : MOTION_EASE_OUT), start, end, { from });
786
+ } else if (style === "rise") {
787
+ const y = num("y");
788
+ seg("opacity", opacity, MOTION_EASE_OUT, start, start + duration2 * 0.5, { from: 0 });
789
+ seg("y", y, ease ?? "ease-out-back", start, end, { from: y + (command2.distance ?? 20) });
594
790
  } else if (style === "grow") {
595
791
  seg("opacity", opacity, MOTION_EASE_OUT, start, start + duration2 * 0.3, { from: 0 });
596
792
  seg("scaleX", 1, ease ?? MOTION_EASE_OUT, start, end, { from: 0 });
@@ -608,7 +804,9 @@ function compileMotionScene(scene) {
608
804
  }
609
805
  case "exit": {
610
806
  const style = command2.style ?? "fade";
611
- if (style === "undraw") {
807
+ const wipe = style === "wipe" ? motionRevealClip(entry, { style, direction: command2.direction, hide: true }) : null;
808
+ if (wipe) seg(wipe.property, wipe.to, ease ?? "ease-in", start, end, { from: wipe.from });
809
+ else if (style === "undraw") {
612
810
  seg("fillOpacity", 0, "ease-in", start, start + duration2 * 0.4);
613
811
  seg("drawEnd", 0, ease ?? MOTION_EASE_IN_OUT);
614
812
  } else if (style === "shrink") {
@@ -624,13 +822,11 @@ function compileMotionScene(scene) {
624
822
  } else seg("opacity", 0, ease ?? "ease-in");
625
823
  break;
626
824
  }
627
- case "draw":
628
- {
629
- const drawn = tracks.has(motionTrackKey(node, "drawEnd"));
630
- seg("drawEnd", command2.to ?? 1, ease ?? MOTION_EASE_IN_OUT, start, end, command2.from !== void 0 ? { from: command2.from } : drawn ? {} : { from: 0 });
631
- break;
632
- }
825
+ case "draw": {
826
+ const drawn = tracks.has(motionTrackKey(node, "drawEnd"));
827
+ seg("drawEnd", command2.to ?? 1, ease ?? MOTION_EASE_IN_OUT, start, end, command2.from !== void 0 ? { from: command2.from } : drawn ? {} : { from: 0 });
633
828
  break;
829
+ }
634
830
  case "move": {
635
831
  const e = ease ?? MOTION_EASE_IN_OUT;
636
832
  let dx = command2.by?.x ?? 0;
@@ -650,8 +846,9 @@ function compileMotionScene(scene) {
650
846
  break;
651
847
  case "scale": {
652
848
  const to = typeof command2.to === "number" ? { x: command2.to, y: command2.to } : command2.to;
653
- seg("scaleX", to.x, ease ?? MOTION_EASE_IN_OUT);
654
- seg("scaleY", to.y, ease ?? MOTION_EASE_IN_OUT);
849
+ const geometric = command2.geometric ? { geometric: true } : {};
850
+ seg("scaleX", to.x, ease ?? MOTION_EASE_IN_OUT, start, end, geometric);
851
+ seg("scaleY", to.y, ease ?? MOTION_EASE_IN_OUT, start, end, geometric);
655
852
  break;
656
853
  }
657
854
  case "fade":
@@ -668,14 +865,35 @@ function compileMotionScene(scene) {
668
865
  case "follow":
669
866
  seg("follow", command2.to ?? 1, ease ?? MOTION_EASE_IN_OUT, start, end, { from: command2.from ?? 0, path: command2.path, rotate: command2.rotate ?? false });
670
867
  break;
868
+ // Emphasis: a change toward a peak that a behaviour swings round (as Flair's primitives do).
671
869
  case "pulse": {
672
- const wave = { kind: "pulse", amount: command2.amount ?? 0.15, count: command2.count ?? 1 };
673
- seg("scaleX", num("scaleX"), "linear", start, end, { wave });
674
- seg("scaleY", num("scaleY"), "linear", start, end, { wave });
870
+ const behavior = { type: "pulse", count: command2.count ?? 1 };
871
+ const k = 1 + (command2.amount ?? 0.15);
872
+ seg("scaleX", num("scaleX") * k, ease ?? "linear", start, end, { behavior });
873
+ seg("scaleY", num("scaleY") * k, ease ?? "linear", start, end, { behavior });
675
874
  break;
676
875
  }
677
- case "shake":
678
- seg("x", num("x"), "linear", start, end, { wave: { kind: "shake", amount: command2.amount ?? 8, count: command2.count ?? 3 } });
876
+ case "shake": {
877
+ const p = command2.axis === "vertical" ? "y" : "x";
878
+ seg(p, num(p) + (command2.amount ?? 8), ease ?? "linear", start, end, { behavior: { type: "oscillate", count: command2.count ?? 3, decay: 1 } });
879
+ break;
880
+ }
881
+ case "teeter":
882
+ seg("rotation", num("rotation") + (command2.angle ?? 8), ease ?? "linear", start, end, { behavior: { type: "oscillate", count: command2.count ?? 3, decay: 1 } });
883
+ break;
884
+ case "wave":
885
+ seg("y", num("y") - (command2.amount ?? 6), ease ?? "linear", start, end, { behavior: { type: "oscillate", count: command2.count ?? 2 } });
886
+ break;
887
+ case "wiggle": {
888
+ const behavior = { type: "wriggle", frequency: command2.frequency ?? 3, octaves: command2.octaves ?? 2, seed: command2.seed || motionSeedFromId(node) };
889
+ const amount = command2.amount ?? 6;
890
+ seg("x", num("x") + amount, ease ?? "linear", start, end, { behavior, index: 0 });
891
+ seg("y", num("y") + amount, ease ?? "linear", start, end, { behavior, index: 1 });
892
+ if (command2.rotate) seg("rotation", num("rotation") + command2.rotate, ease ?? "linear", start, end, { behavior, index: 2 });
893
+ break;
894
+ }
895
+ case "color-pulse":
896
+ seg(command2.paint ?? "fill", command2.color ?? "#f59e0b", ease ?? "linear", start, end, { behavior: { type: "pulse", count: command2.count ?? 1 } });
679
897
  break;
680
898
  case "effect":
681
899
  seg(`effect:${command2.effect}:${command2.param}`, command2.to, ease ?? MOTION_EASE_IN_OUT);
@@ -757,44 +975,53 @@ function shapeAt(entry, timeline, nodes, time) {
757
975
  if (u >= 1) return b === entry ? null : shapeIn(entry, b);
758
976
  return morphBetween(shapeIn(entry, a), shapeIn(entry, b)).at(u);
759
977
  }
978
+ function motionFollowRoute(entry, path) {
979
+ if (!path || path.node.type !== "path") return null;
980
+ try {
981
+ return transformBezierPath(vectorPathWorldBezier(path.node, path.parentWorld, { effects: false }), invertMatrix(entry.parentWorld));
982
+ } catch {
983
+ return null;
984
+ }
985
+ }
760
986
  function followAt(entry, timeline, nodes, time) {
761
987
  const track = timeline.tracks.get(motionTrackKey(entry.node.id, "follow"));
762
988
  const s = activeSegment(track, time) ?? track?.[0];
763
989
  if (!s?.path) return null;
764
- const path = nodes.get(s.path);
765
- if (!path || path.node.type !== "path") return null;
990
+ const route = motionFollowRoute(entry, nodes.get(s.path));
766
991
  const progress = Number(trackValue(track, time));
767
- if (!(progress >= 0)) return null;
992
+ if (!route || !(progress >= 0)) return null;
768
993
  try {
769
- const bezier = vectorPathWorldBezier(path.node, path.parentWorld, { effects: false });
770
- const at = bezierPathPointAtLength(bezier, Math.min(1, Math.max(0, progress)) * bezierPathLength(bezier));
994
+ const at = bezierPathPointAtLength(route, Math.min(1, Math.max(0, progress)) * bezierPathLength(route));
771
995
  if (!at) return null;
772
- const p = applyMatrix(invertMatrix(entry.parentWorld), { x: at.point[0], y: at.point[1] });
773
- return { x: p.x, y: p.y, angle: s.rotate ? at.angle * 180 / Math.PI : 0 };
996
+ return { x: at.point[0], y: at.point[1], angle: s.rotate ? at.angle * 180 / Math.PI : 0 };
774
997
  } catch {
775
998
  return null;
776
999
  }
777
1000
  }
778
- function motionNodeOffset(entry, timeline, time, nodes = indexMotionNodes([entry.node])) {
1001
+ function motionNodeOffset(entry, timeline, time, nodes = indexMotionNodes([entry.node]), raw = false, follow = true) {
779
1002
  const read = (p, rest) => {
780
- const v = trackValue(timeline.tracks.get(motionTrackKey(entry.node.id, p)), time);
1003
+ const v = trackValue(timeline.tracks.get(motionTrackKey(entry.node.id, p)), time, raw);
781
1004
  return typeof v === "number" ? v : rest;
782
1005
  };
783
- if (!TRANSFORM_PROPS.some((p) => timeline.tracks.has(motionTrackKey(entry.node.id, p)))) return null;
784
- let dx = read("x", 0);
785
- let dy = read("y", 0);
786
- let rotation = read("rotation", 0);
1006
+ if (!TRANSFORM_PROPS.some((p) => (follow || p !== "follow") && timeline.tracks.has(motionTrackKey(entry.node.id, p)))) return null;
1007
+ const dx = read("x", 0);
1008
+ const dy = read("y", 0);
1009
+ const rotation = read("rotation", 0);
787
1010
  const scaleX = read("scaleX", 1);
788
1011
  const scaleY = read("scaleY", 1);
789
- const follow = followAt(entry, timeline, nodes, time);
790
- if (follow) {
791
- dx += follow.x - entry.center.x;
792
- dy += follow.y - entry.center.y;
793
- rotation += follow.angle;
794
- }
795
1012
  const c = entry.center;
796
- const matrix = multiplyMatrices(multiplyMatrices(scaleMatrix(scaleX, scaleY, c), rotateMatrix(rotation, c)), translateMatrix(dx, dy));
797
- return { matrix, dx, dy, rotation, scaleX, scaleY };
1013
+ let matrix = multiplyMatrices(multiplyMatrices(scaleMatrix(scaleX, scaleY, c), rotateMatrix(rotation, c)), translateMatrix(dx, dy));
1014
+ const along = follow ? followAt(entry, timeline, nodes, time) : null;
1015
+ if (along) matrix = multiplyMatrices(matrix, multiplyMatrices(multiplyMatrices(translateMatrix(-c.x, -c.y), rotateMatrix(along.angle)), translateMatrix(along.x, along.y)));
1016
+ return { matrix, dx, dy, rotation, scaleX, scaleY, follow: along };
1017
+ }
1018
+ function motionNodeClips(id, timeline, time) {
1019
+ const out = [];
1020
+ const rect = trackValue(timeline.tracks.get(motionTrackKey(id, "clipRect")), time);
1021
+ if (Array.isArray(rect) && rect.length === 4) out.push({ shape: "rect", x: tidy(rect[0]), y: tidy(rect[1]), width: Math.max(0, tidy(rect[2])), height: Math.max(0, tidy(rect[3])) });
1022
+ const circle = trackValue(timeline.tracks.get(motionTrackKey(id, "clipCircle")), time);
1023
+ if (Array.isArray(circle) && circle.length === 3) out.push({ shape: "circle", cx: tidy(circle[0]), cy: tidy(circle[1]), r: Math.max(0, tidy(circle[2])) });
1024
+ return out;
798
1025
  }
799
1026
  function motionFrameAt(scene, time, options = {}) {
800
1027
  const s = applyMotionControlValues(scene, options.values);
@@ -803,12 +1030,15 @@ function motionFrameAt(scene, time, options = {}) {
803
1030
  const drawOn = options.draw !== false;
804
1031
  const value = (id, p) => trackValue(timeline.tracks.get(motionTrackKey(id, p)), time);
805
1032
  const animated = new Set(timeline.segments.map((seg) => seg.node));
1033
+ const clips = {};
806
1034
  const visit = (node) => {
807
1035
  let out = node.type === "group" ? { ...node, children: node.children.map(visit) } : node;
808
1036
  if (!animated.has(node.id)) return out;
809
1037
  const entry = nodes.get(node.id);
810
1038
  if (!entry) return out;
811
1039
  const id = node.id;
1040
+ const clip = motionNodeClips(id, timeline, time);
1041
+ if (clip.length) clips[id] = clip;
812
1042
  const shape = shapeAt(entry, timeline, nodes, time);
813
1043
  if (shape) {
814
1044
  const { name, visible, opacity: opacity2, locked } = node;
@@ -872,11 +1102,19 @@ function motionFrameAt(scene, time, options = {}) {
872
1102
  const document = { ...s.artwork, nodes: s.artwork.nodes.map(visit) };
873
1103
  const cam = trackValue(timeline.tracks.get(motionTrackKey("", "camera")), time);
874
1104
  const box = Array.isArray(cam) ? cam : [0, 0, s.artwork.width, s.artwork.height];
875
- return { time, document, camera: { x: tidy(box[0]), y: tidy(box[1]), width: tidy(box[2]), height: tidy(box[3]) } };
1105
+ return { time, document, camera: { x: tidy(box[0]), y: tidy(box[1]), width: tidy(box[2]), height: tidy(box[3]) }, clips };
1106
+ }
1107
+ function motionClipAttrs(clip) {
1108
+ const { shape: _shape, ...attrs } = clip;
1109
+ return attrs;
876
1110
  }
877
1111
  function motionFrameToSvg(scene, time, options = {}) {
878
1112
  const frame = motionFrameAt(scene, time, options);
879
- return withViewBox(vectorDocumentToSvg(frame.document, options), frame.camera);
1113
+ const prefix = options.idPrefix ?? "vg-";
1114
+ const wrap = { ...options.wrap };
1115
+ for (const [id, clips] of Object.entries(frame.clips))
1116
+ wrap[id] = [...wrap[id] ?? [], ...clips.map((clip, i) => ({ clip: { id: `${prefix}reveal-${id}-${i}`, shape: clip.shape, attrs: motionClipAttrs(clip) } }))];
1117
+ return withViewBox(vectorDocumentToSvg(frame.document, { ...options, wrap }), frame.camera);
880
1118
  }
881
1119
  function withViewBox(svg, box) {
882
1120
  const f = (n) => String(Math.round(n * 1e3) / 1e3);
@@ -911,6 +1149,11 @@ function visibleAt(timeline, id, time, node) {
911
1149
  if (node.visible === false) return false;
912
1150
  if (num("opacity", node.opacity ?? 1) <= 0.01) return false;
913
1151
  if (Math.abs(num("scaleX", 1) * num("scaleY", 1)) <= 1e-4) return false;
1152
+ const clip = (p) => trackValue(timeline.tracks.get(motionTrackKey(id, p)), time);
1153
+ const rect = clip("clipRect");
1154
+ if (Array.isArray(rect) && (rect[2] <= 1e-4 || rect[3] <= 1e-4)) return false;
1155
+ const circle = clip("clipCircle");
1156
+ if (Array.isArray(circle) && circle[2] <= 1e-4) return false;
914
1157
  const hasFill = node.type === "path" ? !!node.fill && node.fill !== "none" : collectPathNodes(node).some((p) => p.fill && p.fill !== "none");
915
1158
  return num("drawEnd", 1) > 0.01 || hasFill && num("fillOpacity", 1) > 0.01;
916
1159
  }
@@ -947,9 +1190,23 @@ function checkMotionScene(scene) {
947
1190
  const timing = timeline.commands.find((c) => c.shot === shot.id && c.command === i);
948
1191
  if (timing && timing.duration > 0 && timing.duration < MOTION_MIN_CHANGE_MS)
949
1192
  issues.push(motionIssue("too-fast", `This ${command2.type} lasts ${timing.duration} ms, too quick to follow; make it at least ${MOTION_MIN_CHANGE_MS} ms.`, { ...where, measured: timing.duration, needed: MOTION_MIN_CHANGE_MS, unit: "ms", ids: targets }));
1193
+ if (command2.duration !== void 0 && command2.ease !== void 0) {
1194
+ const ease = resolveEasing(command2.ease);
1195
+ if (ease !== "hold" && ease.type === "spring") {
1196
+ const settle = springSettleMs(ease);
1197
+ if (command2.duration < settle * 0.9)
1198
+ issues.push(motionIssue("spring-cut-short", `This ${command2.type}'s spring takes ${settle} ms to settle but lasts ${command2.duration} ms, so it jumps to its end; leave "duration" out (the spring sets it) or make it at least ${settle} ms.`, { ...where, measured: command2.duration, needed: settle, unit: "ms", ids: targets }));
1199
+ }
1200
+ }
950
1201
  if (command2.type === "follow" && known(command2.path, "unknown-path", "path", where) && nodes.get(command2.path).node.type !== "path")
951
1202
  issues.push(motionIssue("not-a-path", `"${command2.path}" is a group; follow a single path node.`, { ...where, ids: [command2.path] }));
952
1203
  if (command2.type === "morph") known(command2.into, "unknown-shape", "shape", where);
1204
+ const style = command2.type === "enter" || command2.type === "exit" ? command2.style : void 0;
1205
+ if (style === "wipe" || style === "iris" || style === "split") {
1206
+ for (const id of targets)
1207
+ if (!motionRevealPadding(nodes.get(id)))
1208
+ issues.push(motionIssue("reveal-no-box", `"${id}" has no width or height (a straight line), so a ${style} cannot clip it and it fades instead; use style "${command2.type === "enter" ? "draw" : "undraw"}" for a line.`, { ...where, ids: [id] }));
1209
+ }
953
1210
  if (command2.type === "style") {
954
1211
  for (const key of ["fill", "stroke"]) {
955
1212
  const color = command2[key];
@@ -966,6 +1223,18 @@ function checkMotionScene(scene) {
966
1223
  }
967
1224
  }
968
1225
  }
1226
+ if (command2.type === "color-pulse") {
1227
+ const key = command2.paint ?? "fill";
1228
+ const color = command2.color ?? "#f59e0b";
1229
+ if (normalizeTokenColor(color) === null) issues.push(motionIssue("invalid-color", `color "${color}" is not a colour this editor reads; use hex ("#0ea5e9"), rgb() or rgba().`, where));
1230
+ else
1231
+ for (const id of targets) {
1232
+ const node = nodes.get(id).node;
1233
+ const paths = node.type === "path" ? [node] : collectPathNodes(node);
1234
+ if (paths.some((p) => typeof p[key] !== "string"))
1235
+ issues.push(motionIssue("paint-not-color", `"${id}" has a gradient or no ${key}, so a colour pulse cannot tint it; give it a solid ${key} or pulse the ${key === "fill" ? "stroke" : "fill"}.`, { ...where, ids: [id] }));
1236
+ }
1237
+ }
969
1238
  if (command2.type === "effect") {
970
1239
  for (const id of targets) {
971
1240
  const node = nodes.get(id).node;
@@ -1086,6 +1355,11 @@ function applyOne(scene, edit) {
1086
1355
  else if (edit.description !== void 0) next.description = edit.description;
1087
1356
  if (edit.loop !== void 0) next.loop = edit.loop;
1088
1357
  if (edit.fps !== void 0) next.fps = edit.fps;
1358
+ if (edit.steps === null) delete next.steps;
1359
+ else if (edit.steps !== void 0) {
1360
+ if (!(typeof edit.steps === "number" && edit.steps > 0 && edit.steps <= 120)) fail('"steps" must be frames per second from above 0 to 120 (12 is "on twos" at 24 fps), or null for smooth.');
1361
+ next.steps = edit.steps;
1362
+ }
1089
1363
  if (edit.target === null) delete next.target;
1090
1364
  else if (edit.target !== void 0) next.target = edit.target;
1091
1365
  return next;
@@ -1221,6 +1495,13 @@ var FLAIR_EASES = {
1221
1495
  "in-out": (t) => t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2,
1222
1496
  out: (t) => 1 - (1 - t) ** 3
1223
1497
  };
1498
+ var FLAIR_EXPORT_VERSION = "0.39";
1499
+ function flairAtLeast(version, min) {
1500
+ const a = version.split(".").map((n) => parseInt(n, 10) || 0);
1501
+ const b = min.split(".").map((n) => parseInt(n, 10) || 0);
1502
+ for (let i = 0; i < Math.max(a.length, b.length); i++) if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
1503
+ return true;
1504
+ }
1224
1505
  function flairEaseFor(ease) {
1225
1506
  for (const name of ["linear", "out", "in-out"]) {
1226
1507
  let worst = 0;
@@ -1239,7 +1520,7 @@ function flairCurveFor(ease, curves = true) {
1239
1520
  if (resolved === "hold" || resolved.type !== "cubic-bezier") return void 0;
1240
1521
  return [resolved.x1, resolved.y1, resolved.x2, resolved.y2];
1241
1522
  }
1242
- var overshoots = (ease) => typeof ease !== "string" && (ease[1] < 0 || ease[1] > 1 || ease[3] < 0 || ease[3] > 1);
1523
+ var overshoots = (ease) => typeof ease !== "string" && ("spring" in ease || ease[1] < 0 || ease[1] > 1 || ease[3] < 0 || ease[3] > 1);
1243
1524
  var TRANSFORM_PROPS2 = ["x", "y", "rotation", "scaleX", "scaleY", "follow"];
1244
1525
  var LINEAR_TRANSFORM = /* @__PURE__ */ new Set(["x", "y", "rotation", "scaleX", "scaleY"]);
1245
1526
  function hex6(color) {
@@ -1249,7 +1530,24 @@ function hex6(color) {
1249
1530
  function toFlairSvgMotion(scene, options = {}) {
1250
1531
  const fps = options.fps ?? scene.fps ?? 30;
1251
1532
  const precision = options.precision ?? 3;
1252
- const curves = options.curves ?? true;
1533
+ const flair = options.flair ?? FLAIR_EXPORT_VERSION;
1534
+ const curves = options.curves ?? flairAtLeast(flair, "0.36");
1535
+ const behaviors = options.behaviors ?? flairAtLeast(flair, "0.38");
1536
+ const springs = flairAtLeast(flair, "0.39");
1537
+ const strobes = flairAtLeast(flair, "0.39");
1538
+ const writeEase = (ease) => typeof ease === "string" || "spring" in ease ? ease : ease.map(round2);
1539
+ const easeOf = (segment) => {
1540
+ const resolved = resolveEasing(segment.ease);
1541
+ if (resolved !== "hold" && resolved.type === "spring") {
1542
+ if (!springs || segment.reverse) return void 0;
1543
+ const { mass, stiffness, damping, velocity } = resolved;
1544
+ return { spring: { mass, stiffness, damping, velocity } };
1545
+ }
1546
+ if (!segment.reverse) return flairCurveFor(segment.ease, curves);
1547
+ const back = reverseCubicBezier(segment.ease);
1548
+ return back && curves ? flairCurveFor(back, curves) : void 0;
1549
+ };
1550
+ const plays = (s) => (!s.behavior || behaviors) && (!s.steps || strobes) && !s.geometric;
1253
1551
  const round2 = (n) => Math.round(n * 10 ** precision) / 10 ** precision;
1254
1552
  const f = (n) => String(round2(n));
1255
1553
  const timeline = compileMotionScene(scene);
@@ -1264,26 +1562,72 @@ function toFlairSvgMotion(scene, options = {}) {
1264
1562
  };
1265
1563
  const animatedNodes = new Set(timeline.segments.map((s) => s.node).filter(Boolean));
1266
1564
  const pathsSeen = /* @__PURE__ */ new Set();
1565
+ const wrap = {};
1566
+ const routes = [];
1567
+ const follows = [];
1568
+ const usedIds = new Set(nodes.keys());
1569
+ const freshId = (base) => {
1570
+ let id = base;
1571
+ for (let n = 2; usedIds.has(id); n++) id = `${base}-${n}`;
1572
+ usedIds.add(id);
1573
+ return id;
1574
+ };
1267
1575
  for (const id of animatedNodes) {
1268
1576
  const entry = nodes.get(id);
1269
1577
  const node = entry.node;
1578
+ const drawn = [...entry.ancestors.map((a) => nodes.get(a).node), node].every((n) => n.visible !== false);
1579
+ for (const [p, name] of [["clipRect", "reveal"], ["clipCircle", "iris"]]) {
1580
+ if (!drawn || !timeline.tracks.has(motionTrackKey(id, p))) continue;
1581
+ const wrapper = freshId(`${id}--${name}`);
1582
+ const clipId = freshId(`${wrapper}-clip`);
1583
+ const shapeId = freshId(`${wrapper}-clip-shape`);
1584
+ const clipAt = (t) => motionNodeClips(id, timeline, t).find((c) => c.shape === (p === "clipRect" ? "rect" : "circle"));
1585
+ const rest = clipAt(0);
1586
+ (wrap[id] ??= []).push({ id: wrapper, clip: { id: clipId, shapeId, shape: rest.shape, attrs: motionClipAttrs(rest) } });
1587
+ channels.push({ id: shapeId, kind: "attrs", attrs: Object.keys(motionClipAttrs(rest)), segments: [...track(id, p)], exact: () => true, value: (t) => Object.values(motionClipAttrs(clipAt(t))).map((v) => Math.round(v * 1e4) / 1e4) });
1588
+ }
1589
+ const followSegments = track(id, "follow");
1590
+ if (drawn && followSegments.length) {
1591
+ const ids = /* @__PURE__ */ new Map();
1592
+ for (const seg of followSegments) {
1593
+ if (!seg.path || ids.has(seg.path)) continue;
1594
+ const route = motionFollowRoute(entry, nodes.get(seg.path));
1595
+ if (!route) continue;
1596
+ const routeId = freshId(`${id}--follow-route`);
1597
+ ids.set(seg.path, routeId);
1598
+ routes.push(`<path id="${routeId}" d="${bezierPathToSvg(route, { precision })}" fill="none"/>`);
1599
+ }
1600
+ if (ids.size) {
1601
+ const wrapper = freshId(`${id}--follow`);
1602
+ const centre = freshId(`${id}--follow-centre`);
1603
+ const c = entry.center;
1604
+ const at = motionNodeOffset(entry, timeline, 0, nodes)?.follow;
1605
+ const placed = at ? `translate(${f(at.x)} ${f(at.y)})${at.angle ? ` rotate(${f(at.angle)})` : ""}` : void 0;
1606
+ (wrap[id] ??= []).push({ id: wrapper, ...placed ? { transform: placed } : {} }, { id: centre, transform: `translate(${f(-c.x)} ${f(-c.y)})` });
1607
+ follows.push({ id, wrapper, routes: ids, segments: followSegments.filter((seg) => seg.path && ids.has(seg.path)) });
1608
+ }
1609
+ }
1270
1610
  if (timeline.tracks.has(motionTrackKey(id, "opacity")))
1271
- channels.push({ id, kind: "attr", attr: "opacity", segments: [...track(id, "opacity")], exact: (s) => !s.wave, value: (t) => round2(Math.min(1, Math.max(0, Number(trackValue(track(id, "opacity"), t))))) });
1272
- const transformSegments = TRANSFORM_PROPS2.flatMap((p) => track(id, p));
1611
+ channels.push({ id, kind: "attr", attr: "opacity", segments: [...track(id, "opacity")], exact: plays, value: (t) => round2(Math.min(1, Math.max(0, Number(trackValue(track(id, "opacity"), t))))) });
1612
+ const followed = follows.some((x) => x.id === id);
1613
+ const transformSegments = TRANSFORM_PROPS2.flatMap((p) => followed && p === "follow" ? [] : track(id, p));
1273
1614
  if (transformSegments.length) {
1274
1615
  const base = node.transform ? ` matrix(${node.transform.map(f).join(" ")})` : "";
1275
1616
  const c = entry.center;
1617
+ const transformAt = (t, raw) => {
1618
+ const o = motionNodeOffset(entry, timeline, t, nodes, raw, !followed);
1619
+ return `translate(${f(o.dx)} ${f(o.dy)}) rotate(${f(o.rotation)} ${f(c.x)} ${f(c.y)}) translate(${f(c.x)} ${f(c.y)}) scale(${f(o.scaleX)} ${f(o.scaleY)}) translate(${f(-c.x)} ${f(-c.y)})${base}`;
1620
+ };
1276
1621
  channels.push({
1277
1622
  id,
1278
1623
  kind: "attr",
1279
1624
  attr: "transform",
1280
1625
  segments: transformSegments,
1281
- exact: (s) => LINEAR_TRANSFORM.has(s.property) && !s.wave,
1626
+ exact: (s) => LINEAR_TRANSFORM.has(s.property) && plays(s),
1282
1627
  overshoot: true,
1283
- value: (t) => {
1284
- const o = motionNodeOffset(entry, timeline, t, nodes);
1285
- return `translate(${f(o.dx)} ${f(o.dy)}) rotate(${f(o.rotation)} ${f(c.x)} ${f(c.y)}) translate(${f(c.x)} ${f(c.y)}) scale(${f(o.scaleX)} ${f(o.scaleY)}) translate(${f(-c.x)} ${f(-c.y)})${base}`;
1286
- }
1628
+ // x, y and the angle are the transform's numbers 0, 1 and 2 (`MotionSegment.index`).
1629
+ value: (t) => transformAt(t, false),
1630
+ raw: (t) => transformAt(t, true)
1287
1631
  });
1288
1632
  }
1289
1633
  const paths = node.type === "path" ? [node] : collectPathNodes(node);
@@ -1292,7 +1636,7 @@ function toFlairSvgMotion(scene, options = {}) {
1292
1636
  pathsSeen.add(path.id);
1293
1637
  const paint = (p, attr) => {
1294
1638
  const from = owner(path, p);
1295
- if (from) channels.push({ id: path.id, kind: "attr", attr, segments: [...track(from, p)], exact: () => true, value: (t) => hex6(trackValue(track(from, p), t)) });
1639
+ if (from) channels.push({ id: path.id, kind: "attr", attr, segments: [...track(from, p)], exact: plays, value: (t) => hex6(trackValue(track(from, p), t)), raw: (t) => hex6(trackValue(track(from, p), t, true)) });
1296
1640
  };
1297
1641
  paint("fill", "fill");
1298
1642
  paint("stroke", "stroke");
@@ -1331,15 +1675,30 @@ function toFlairSvgMotion(scene, options = {}) {
1331
1675
  const animate = [];
1332
1676
  let sampled = 0;
1333
1677
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
1334
- const emit = (channel, delay, ms, from, to, ease) => {
1335
- const base = { ...channel.id ? { id: channel.id } : {}, ...delay > 0 ? { delay: round2(delay) } : {}, ms: round2(ms), ease: typeof ease === "string" ? ease : ease.map(round2) };
1678
+ const emit = (channel, delay, ms, from, to, ease, segment) => {
1679
+ const written = writeEase(ease);
1680
+ const base = {
1681
+ ...channel.id ? { id: channel.id } : {},
1682
+ ...delay > 0 ? { delay: round2(delay) } : {},
1683
+ ms: round2(ms),
1684
+ ease: written,
1685
+ ...segment?.behavior ? { behavior: segment.behavior } : {},
1686
+ ...segment?.steps ? { strobe: segment.steps } : {}
1687
+ };
1336
1688
  if (channel.kind === "view") animate.push({ ...base, view: to });
1337
1689
  else if (channel.kind === "draw") animate.push({ ...base, draw: [from, to] });
1338
- else animate.push({ ...base, attrs: { [channel.attr]: [from, to] } });
1690
+ else if (channel.kind === "attrs") {
1691
+ const [a, b] = [from, to];
1692
+ const attrs = Object.fromEntries(channel.attrs.flatMap((name, k) => a[k] !== b[k] ? [[name, [a[k], b[k]]]] : []));
1693
+ animate.push({ ...base, attrs });
1694
+ } else animate.push({ ...base, attrs: { [channel.attr]: [from, to] } });
1339
1695
  };
1340
1696
  for (const channel of channels) {
1341
1697
  const cuts = channel.segments.filter((s) => s.end <= s.start);
1342
- for (const cut of cuts) emit(channel, cut.start, 0, channel.value(Math.max(0, cut.start - 1e-3)), channel.value(cut.start), "linear");
1698
+ for (const cut of cuts) {
1699
+ const [from, to] = [channel.value(Math.max(0, cut.start - 1e-3)), channel.value(cut.start)];
1700
+ if (cut.start <= 0 || !same(from, to) || channel.kind === "view") emit(channel, cut.start, 0, from, to, "linear");
1701
+ }
1343
1702
  const times = [...new Set(channel.segments.flatMap((s) => [s.start, s.end]))].sort((a, b) => a - b);
1344
1703
  for (let i = 0; i + 1 < times.length; i++) {
1345
1704
  const a = times[i];
@@ -1347,13 +1706,14 @@ function toFlairSvgMotion(scene, options = {}) {
1347
1706
  const active = channel.segments.filter((s) => s.start < b && s.end > a);
1348
1707
  if (!active.length) continue;
1349
1708
  const first = active[0];
1350
- const together = active.every((x) => x.start === a && x.end === b && channel.exact(x) && JSON.stringify(x.ease) === JSON.stringify(first.ease));
1351
- const curve = together ? flairCurveFor(first.ease, curves) : void 0;
1709
+ const key = (x) => JSON.stringify([x.ease, x.behavior, x.reverse ?? false, x.steps ?? 0]);
1710
+ const together = active.every((x) => x.start === a && x.end === b && channel.exact(x) && key(x) === key(first));
1711
+ const curve = together ? easeOf(first) : void 0;
1352
1712
  const ease = curve && (!overshoots(curve) || channel.overshoot) ? curve : void 0;
1353
1713
  if (ease) {
1354
1714
  const from = channel.value(a);
1355
- const to = channel.value(b);
1356
- if (!same(from, to) || channel.kind === "view") emit(channel, a, b - a, from, to, ease);
1715
+ const to = first.behavior ? (channel.raw ?? channel.value)(b) : channel.value(b);
1716
+ if (!same(from, to) || channel.kind === "view") emit(channel, a, b - a, from, to, ease, first);
1357
1717
  continue;
1358
1718
  }
1359
1719
  const n = Math.max(1, Math.ceil((b - a) * fps / 1e3));
@@ -1370,8 +1730,30 @@ function toFlairSvgMotion(scene, options = {}) {
1370
1730
  }
1371
1731
  }
1372
1732
  }
1733
+ for (const follow of follows) {
1734
+ const starts = follow.segments.map((seg) => seg.start);
1735
+ for (const seg of follow.segments) {
1736
+ const along = { id: follow.wrapper, along: follow.routes.get(seg.path), ...seg.rotate ? { orient: true } : {} };
1737
+ const progress = (t) => round2(Math.min(1, Math.max(0, Number(segmentValue(seg, t)))));
1738
+ const curve = seg.end > seg.start ? plays(seg) ? easeOf(seg) : void 0 : "linear";
1739
+ if (curve && !(overshoots(curve) && seg.rotate)) {
1740
+ const ends = seg.reverse ? [Number(seg.to), Number(seg.from)] : [Number(seg.from), Number(seg.to)];
1741
+ animate.push({ ...along, ...seg.start > 0 ? { delay: round2(seg.start) } : {}, ms: round2(seg.end - seg.start), ease: writeEase(curve), ...seg.steps ? { strobe: seg.steps } : {}, at: [round2(ends[0]), round2(ends[1])] });
1742
+ continue;
1743
+ }
1744
+ const until = Math.min(seg.end, ...starts.filter((t) => t > seg.start));
1745
+ const n = Math.max(1, Math.ceil((until - seg.start) * fps / 1e3));
1746
+ for (let k = 1; k <= n; k++) {
1747
+ const t0 = seg.start + (until - seg.start) * (k - 1) / n;
1748
+ const t1 = seg.start + (until - seg.start) * k / n;
1749
+ animate.push({ ...along, ...t0 > 0 ? { delay: round2(t0) } : {}, ms: round2(t1 - t0), ease: "linear", at: [progress(t0), progress(t1)] });
1750
+ sampled++;
1751
+ }
1752
+ }
1753
+ }
1373
1754
  animate.sort((x, y) => (x.delay ?? 0) - (y.delay ?? 0));
1374
- let svg = vectorDocumentToSvg({ ...scene.artwork, nodes: expandVectorAppearances(scene.artwork.nodes) }, { pretty: false, precision });
1755
+ let svg = vectorDocumentToSvg({ ...scene.artwork, nodes: expandVectorAppearances(scene.artwork.nodes) }, { pretty: false, precision, wrap });
1756
+ if (routes.length) svg = svg.replace(/^(<svg[^>]*>)/, `$1<defs>${routes.join("")}</defs>`);
1375
1757
  const lastChange = animate.reduce((m, a) => Math.max(m, (a.delay ?? 0) + (a.ms ?? 0)), 0);
1376
1758
  let motion = { svg, animate };
1377
1759
  if (scene.controls?.length) {
@@ -1414,6 +1796,196 @@ function toFlairEffectDocument(scene, options = {}) {
1414
1796
  };
1415
1797
  }
1416
1798
 
1799
+ // src/animation-editor/flair-import.ts
1800
+ var PLACEHOLDER = /\{\{\s*([A-Za-z_]\w*)\s*(?::\s*([A-Za-z0-9]*)([^}]*))?\}\}/g;
1801
+ function attributeOf(attrs, name) {
1802
+ const m = new RegExp(`(?:^|\\s)${name}=(?:'([^']*)'|(\\S+))`).exec(attrs);
1803
+ return m ? m[1] ?? m[2] : void 0;
1804
+ }
1805
+ function resolvePlaceholders(motion, notes) {
1806
+ const text = JSON.stringify(motion);
1807
+ const declared = /* @__PURE__ */ new Map();
1808
+ for (const m of text.matchAll(PLACEHOLDER)) {
1809
+ if (!m[2] || declared.has(m[1])) continue;
1810
+ const attrs = m[3] ?? "";
1811
+ const value = attributeOf(attrs, "default");
1812
+ const fallback = m[2] === "color" ? "#000000" : "0";
1813
+ declared.set(m[1], { type: m[2], value: m[2] === "color" ? (value ?? fallback).toLowerCase() : value ?? fallback, ...attributeOf(attrs, "label") ? { label: attributeOf(attrs, "label") } : {} });
1814
+ }
1815
+ const controls = [];
1816
+ for (const [name, d] of declared) {
1817
+ if (d.type === "color" && /^#[0-9a-fA-F]{6}$/.test(d.value)) controls.push({ name, type: "color", default: d.value.toLowerCase(), ...d.label ? { label: d.label } : {} });
1818
+ else notes.push(`Control "${name}" (${d.type}) is fixed at its default ${d.value}: scenes take colour controls only.`);
1819
+ }
1820
+ const unknown = /* @__PURE__ */ new Set();
1821
+ const filled = text.replace(PLACEHOLDER, (_, name) => {
1822
+ const d = declared.get(name);
1823
+ if (!d) unknown.add(name);
1824
+ return (d?.value ?? "").replace(/"/g, '\\"');
1825
+ });
1826
+ for (const name of unknown) notes.push(`Placeholder "{{${name}}}" has no declaration, so it was left empty.`);
1827
+ return { motion: JSON.parse(filled), controls };
1828
+ }
1829
+ function easingOf(ease, fallback) {
1830
+ if (ease === void 0) return fallback;
1831
+ if (ease === "linear") return "linear";
1832
+ if (ease === "out") return MOTION_EASE_OUT;
1833
+ if (ease === "in-out") return MOTION_EASE_IN_OUT;
1834
+ if (Array.isArray(ease)) return { type: "cubic-bezier", x1: ease[0], y1: ease[1], x2: ease[2], y2: ease[3] };
1835
+ if (typeof ease === "object" && "spring" in ease) return { type: "spring", ...ease.spring };
1836
+ return fallback;
1837
+ }
1838
+ function poseOf(value, entry) {
1839
+ const t = parseSvgTransform(String(value));
1840
+ const o = multiplyMatrices(invertMatrix(nodeMatrix(entry.node)) ?? [1, 0, 0, 1, 0, 0], t);
1841
+ const sx = Math.hypot(o[0], o[1]);
1842
+ if (!(sx > 1e-9)) return null;
1843
+ const sy = (o[0] * o[3] - o[1] * o[2]) / sx;
1844
+ if (Math.abs(o[2] + o[1] / sx * sy) > 1e-6 || Math.abs(o[3] - o[0] / sx * sy) > 1e-6) return null;
1845
+ const c = entry.center;
1846
+ const moved = applyMatrix(o, c);
1847
+ return { dx: moved.x - c.x, dy: moved.y - c.y, rotation: Math.atan2(o[1], o[0]) * 180 / Math.PI, sx, sy };
1848
+ }
1849
+ var near = (a, b, eps = 1e-6) => Math.abs(a - b) < eps;
1850
+ var r3 = (n) => Math.round(n * 1e3) / 1e3;
1851
+ function commandsFor(anim, nodes, entered, notes) {
1852
+ const id = anim.id;
1853
+ const entry = nodes.get(id);
1854
+ const where = `"${id}"`;
1855
+ if (!entry) {
1856
+ notes.push(`A change to ${where} was skipped: the artwork has no element with that id.`);
1857
+ return [];
1858
+ }
1859
+ const targets = [id];
1860
+ const duration = anim.ms ?? 800;
1861
+ const timing = {
1862
+ duration,
1863
+ ...anim.strobe ? { steps: anim.strobe } : {},
1864
+ ...anim.repeat && anim.repeat > 1 ? { repeat: anim.repeat, ...anim.alternate ? { repeatMode: "mirror" } : {} } : {}
1865
+ };
1866
+ const ease = (fallback) => ({ ease: easingOf(anim.ease, fallback) });
1867
+ const out = [];
1868
+ const behavior = anim.behavior;
1869
+ const unsupported = ["count", "write", "scramble", "solo"].filter((k) => anim[k] !== void 0);
1870
+ if (unsupported.length) notes.push(`${where}: ${unsupported.join(", ")} ${unsupported.length > 1 ? "are" : "is"} not part of scenes and was skipped.`);
1871
+ if (anim.draw !== void 0 && anim.draw !== false) {
1872
+ const [from, to] = anim.draw === true ? [0, 1] : anim.draw;
1873
+ out.push({ type: "draw", targets, from, to, ...timing, ...ease(MOTION_EASE_IN_OUT) });
1874
+ }
1875
+ if (anim.along) {
1876
+ const path = nodes.get(anim.along);
1877
+ if (!path || path.node.type !== "path") notes.push(`${where} follows "${anim.along}", which is not a path in the artwork; the follow was skipped.`);
1878
+ else out.push({ type: "follow", targets, path: anim.along, from: anim.at?.[0] ?? 0, to: anim.at?.[1] ?? 1, ...anim.orient ? { rotate: true } : {}, ...timing, ...ease(MOTION_EASE_IN_OUT) });
1879
+ }
1880
+ for (const [attr, [a, b]] of Object.entries(anim.attrs ?? {})) {
1881
+ if (attr === "opacity") {
1882
+ const [from, to] = [Number(a), Number(b)];
1883
+ if (behavior) notes.push(`${where}: an opacity ${behavior.type} became a plain fade.`);
1884
+ if (from === 0 && to > 0 && !entered.has(id)) {
1885
+ entered.add(id);
1886
+ out.push({ type: "enter", targets, style: "fade", ...timing, ...ease(MOTION_EASE_OUT) });
1887
+ } else out.push({ type: "fade", targets, to: Math.min(1, Math.max(0, to)), ...timing, ...ease(MOTION_EASE_IN_OUT) });
1888
+ } else if (attr === "fill" || attr === "stroke") {
1889
+ if (behavior?.type === "pulse") out.push({ type: "color-pulse", targets, color: String(b), paint: attr, count: behavior.count ?? 1, ...timing, ...ease("linear") });
1890
+ else {
1891
+ if (behavior) notes.push(`${where}: a ${attr} ${behavior.type} became a plain colour change.`);
1892
+ out.push({ type: "style", targets, [attr]: String(b), ...timing, ...ease(MOTION_EASE_IN_OUT) });
1893
+ }
1894
+ } else if (attr === "stroke-width") {
1895
+ out.push({ type: "style", targets, strokeWidth: Number(b), ...timing, ...ease(MOTION_EASE_IN_OUT) });
1896
+ } else if (attr === "transform") {
1897
+ const p0 = poseOf(a, entry);
1898
+ const p1 = poseOf(b, entry);
1899
+ if (!p0 || !p1) {
1900
+ notes.push(`${where}: a transform that skews was skipped (scenes move, turn and scale).`);
1901
+ continue;
1902
+ }
1903
+ const d = { x: r3(p1.dx - p0.dx), y: r3(p1.dy - p0.dy), r: r3(p1.rotation - p0.rotation), sx: p1.sx / p0.sx, sy: p1.sy / p0.sy };
1904
+ if (behavior) {
1905
+ const linear = ease("linear");
1906
+ const moves = d.x !== 0 || d.y !== 0;
1907
+ const turns = d.r !== 0;
1908
+ const scales = !near(d.sx, 1) || !near(d.sy, 1);
1909
+ if (behavior.type === "pulse" && scales && !moves && !turns) out.push({ type: "pulse", targets, amount: r3(d.sx - 1), count: behavior.count ?? 1, ...timing, ...linear });
1910
+ else if (behavior.type === "oscillate" && (behavior.decay ?? 0) > 0 && turns && !moves && !scales) out.push({ type: "teeter", targets, angle: d.r, count: behavior.count ?? 1, ...timing, ...linear });
1911
+ else if (behavior.type === "oscillate" && (behavior.decay ?? 0) > 0 && moves && !turns && !scales && (d.x === 0 || d.y === 0))
1912
+ out.push({ type: "shake", targets, amount: d.x || d.y, count: behavior.count ?? 1, ...d.x === 0 ? { axis: "vertical" } : {}, ...timing, ...linear });
1913
+ else if (behavior.type === "oscillate" && !behavior.decay && d.x === 0 && d.y !== 0 && !turns && !scales) out.push({ type: "wave", targets, amount: -d.y, count: behavior.count ?? 1, ...timing, ...linear });
1914
+ else if (behavior.type === "wriggle" && !scales)
1915
+ out.push({ type: "wiggle", targets, amount: Math.max(Math.abs(d.x), Math.abs(d.y)), ...turns ? { rotate: Math.abs(d.r) } : {}, frequency: behavior.frequency ?? 2, octaves: behavior.octaves ?? 2, seed: behavior.seed ?? 42, ...timing, ...linear });
1916
+ else notes.push(`${where}: a transform ${behavior.type} that is not a pulse, shake, teeter, wave or wiggle was skipped.`);
1917
+ continue;
1918
+ }
1919
+ const e = ease(MOTION_EASE_IN_OUT);
1920
+ if (d.x !== 0 || d.y !== 0) out.push({ type: "move", targets, by: { x: d.x, y: d.y }, ...timing, ...e });
1921
+ if (d.r !== 0) out.push({ type: "rotate", targets, to: r3(p1.rotation), ...timing, ...e });
1922
+ if (!near(d.sx, 1) || !near(d.sy, 1)) out.push({ type: "scale", targets, to: near(p1.sx, p1.sy) ? r3(p1.sx) : { x: r3(p1.sx), y: r3(p1.sy) }, ...timing, ...e });
1923
+ } else notes.push(`${where}: changing "${attr}" is not part of scenes and was skipped.`);
1924
+ }
1925
+ return out;
1926
+ }
1927
+ function fromFlairSvgMotion(input, options = {}) {
1928
+ const notes = [];
1929
+ if (!input || typeof input.svg !== "string") throw new Error('Give a Flair SvgMotion: { svg: "<svg \u2026>", animate: [ \u2026 ] }.');
1930
+ const { motion, controls } = resolvePlaceholders(input, notes);
1931
+ const artwork = parseVectorSvg(motion.svg);
1932
+ const nodes = indexMotionNodes(artwork.nodes);
1933
+ const byStep = /* @__PURE__ */ new Map();
1934
+ for (const anim of motion.animate ?? []) {
1935
+ const step = Math.max(0, Math.floor(anim.step ?? 0));
1936
+ if (!byStep.has(step)) byStep.set(step, []);
1937
+ byStep.get(step).push(anim);
1938
+ }
1939
+ const steps = [...byStep.keys()].sort((a, b) => a - b);
1940
+ if (steps.length > 1) notes.push("Each Flair step became a shot played after the one before (Flair steps wait for clicks or set times).");
1941
+ const entered = /* @__PURE__ */ new Set();
1942
+ const shots = steps.map((step, index) => {
1943
+ const anims = [...byStep.get(step)].sort((a, b) => (a.delay ?? 0) - (b.delay ?? 0));
1944
+ let camera;
1945
+ let cameraMs = 0;
1946
+ const run = [];
1947
+ let last = 0;
1948
+ for (const anim of anims) {
1949
+ if (anim.view !== void 0) {
1950
+ if (camera || (anim.delay ?? 0) > 0) {
1951
+ notes.push(`Step ${step}: a camera move ${camera ? "after the first" : `${anim.delay} ms in`} was dropped; a shot frames the camera once, at its start.`);
1952
+ continue;
1953
+ }
1954
+ const ms = anim.ms ?? 800;
1955
+ const ease = easingOf(anim.ease, MOTION_EASE_IN_OUT);
1956
+ camera = typeof anim.view === "string" ? { frame: anim.view, padding: 0, ms, ease } : { x: anim.view[0], y: anim.view[1], width: anim.view[2], height: anim.view[3], ms, ease };
1957
+ cameraMs = index === 0 ? 0 : ms;
1958
+ continue;
1959
+ }
1960
+ if (!anim.id) continue;
1961
+ const at = Math.max(0, (anim.delay ?? 0) - cameraMs);
1962
+ if (cameraMs && (anim.delay ?? 0) < cameraMs) notes.push(`Step ${step}: a change to "${anim.id}" during the camera move now starts after it.`);
1963
+ for (const command2 of commandsFor(anim, nodes, entered, notes)) {
1964
+ const first = run.length === 0;
1965
+ run.push({ ...command2, ...first ? {} : { with: "previous" }, ...at - last > 0 || first ? { delay: first ? at : at - last } : {} });
1966
+ if (first && at === 0) delete run[run.length - 1].delay;
1967
+ last = at;
1968
+ }
1969
+ }
1970
+ return { id: `step-${step}`, ...camera ? { camera } : {}, run, ...index === steps.length - 1 ? { hold: options.hold ?? 600 } : {} };
1971
+ });
1972
+ const scene = { version: 1, name: options.name ?? "from-flair", artwork, shots, ...controls.length ? { controls } : {} };
1973
+ const problems = motionSceneShapeProblems(scene);
1974
+ if (problems.length) throw new Error(`The imported scene is not well formed:
1975
+ ${problems.join("\n")}`);
1976
+ return { scene, notes: [...new Set(notes)], issues: checkMotionScene(scene) };
1977
+ }
1978
+ function fromFlairVectorEffect(effect, options = {}) {
1979
+ if (!effect || effect.kind !== "vector" || !effect.motion) throw new Error('Give a Flair vector effect: { kind: "vector", id, name, motion: { svg, animate } }.');
1980
+ const result = fromFlairSvgMotion(effect.motion, { name: options.name ?? effect.id ?? effect.name, hold: options.hold ?? effect.hold });
1981
+ const names = new Set((result.scene.controls ?? []).map((c) => c.name));
1982
+ const presets = (effect.presets ?? []).map((p) => ({ name: p.name, values: Object.fromEntries(Object.entries(p.values).filter(([k]) => names.has(k))) })).filter((p) => Object.keys(p.values).length);
1983
+ const scene = { ...result.scene, ...effect.description ? { description: effect.description } : {}, ...presets.length ? { presets } : {} };
1984
+ const notes = [...result.notes];
1985
+ if (effect.trigger) notes.push(`The effect plays on ${effect.trigger}; scenes play through, so the trigger is not kept (pass it back to toFlairVectorEffect).`);
1986
+ return { scene, notes, issues: checkMotionScene(scene) };
1987
+ }
1988
+
1417
1989
  // src/animation-editor/controller.ts
1418
1990
  var BLANK = { width: 800, height: 450, background: "#ffffff", nodes: [] };
1419
1991
  var AnimationEditorController = class {
@@ -1660,10 +2232,29 @@ var MS = (what) => ({ type: "number", minimum: 0, description: `${what} in milli
1660
2232
  var FRACTION = (what) => ({ type: "number", minimum: 0, maximum: 1, description: what });
1661
2233
  var POINT = (what) => object({ x: { type: "number", description: "Document units." }, y: { type: "number", description: "Document units." } }, ["x", "y"], what);
1662
2234
  var EASE = {
1663
- description: `Easing: a preset (${ANIMATION_EASING_PRESETS.join(", ")}) or { "type": "cubic-bezier", "x1", "y1", "x2", "y2" }. Default: a cubic ease-out for entrances, cubic ease-in-out for moves.`,
2235
+ description: `Easing: a preset (${ANIMATION_EASING_PRESETS.join(", ")}), { "type": "cubic-bezier", "x1", "y1", "x2", "y2" }, { "type": "spring", \u2026 } or { "type": "elastic", \u2026 }. The spring presets are Figma's: gentle, quick, bouncy, slow. A command with a spring and no "duration" lasts until the spring settles. Default: a cubic ease-out for entrances, cubic ease-in-out for moves.`,
1664
2236
  anyOf: [
1665
2237
  { type: "string", enum: [...ANIMATION_EASING_PRESETS] },
1666
- object({ type: { const: "cubic-bezier", description: "Curve kind." }, x1: { type: "number", description: "Out handle x (0\u20131)." }, y1: { type: "number", description: "Out handle y." }, x2: { type: "number", description: "In handle x (0\u20131)." }, y2: { type: "number", description: "In handle y." } }, ["type", "x1", "y1", "x2", "y2"])
2238
+ object({ type: { const: "cubic-bezier", description: "Curve kind." }, x1: { type: "number", description: "Out handle x (0\u20131)." }, y1: { type: "number", description: "Out handle y." }, x2: { type: "number", description: "In handle x (0\u20131)." }, y2: { type: "number", description: "In handle y." } }, ["type", "x1", "y1", "x2", "y2"]),
2239
+ object(
2240
+ {
2241
+ type: { const: "spring", description: "A damped spring (may overshoot)." },
2242
+ stiffness: { type: "number", exclusiveMinimum: 0, description: "Stiffness (default 170; quick 300, bouncy 600)." },
2243
+ damping: { type: "number", minimum: 0, description: "Damping (default 26; lower bounces more)." },
2244
+ mass: { type: "number", exclusiveMinimum: 0, description: "Mass (default 1)." },
2245
+ velocity: { type: "number", description: "Starting speed, in changes per second (default 0)." }
2246
+ },
2247
+ ["type"]
2248
+ ),
2249
+ object(
2250
+ {
2251
+ type: { const: "elastic", description: "A decaying wobble." },
2252
+ amplitude: { type: "number", minimum: 0, description: "Overshoot factor (default 1)." },
2253
+ period: { type: "number", exclusiveMinimum: 0, description: "Wobble period as a fraction of the length (default 0.3)." },
2254
+ mode: { type: "string", enum: ["out", "in", "in-out"], description: "Where it wobbles (default out)." }
2255
+ },
2256
+ ["type"]
2257
+ )
1667
2258
  ]
1668
2259
  };
1669
2260
  var BASE = {
@@ -1673,26 +2264,36 @@ var BASE = {
1673
2264
  stagger: MS("Start each further target this much later (default 0)"),
1674
2265
  with: { type: "string", enum: ["after", "previous"], description: '"after" (default) starts when the commands before it finish; "previous" starts together with the command before.' },
1675
2266
  ease: EASE,
1676
- note: { type: "string", description: "Why this command is here, in a few words." }
2267
+ note: { type: "string", description: "Why this command is here, in a few words." },
2268
+ repeat: { type: "integer", minimum: 1, maximum: 1e3, description: 'Play it this many times (default 1); "duration" is one cycle. Not for enter or exit.' },
2269
+ repeatMode: { type: "string", enum: [...MOTION_REPEAT_MODES], description: "loop (default) restarts each cycle, mirror plays every other one backwards, offset carries on from where the last ended (rotate by 90 with repeat 4 makes a full turn)." },
2270
+ steps: { type: "number", exclusiveMinimum: 0, maximum: 120, description: `Stepped time: hold on a grid of this many frames per second (12 = "on twos"). Default: the scene's steps, else smooth.` }
1677
2271
  };
1678
2272
  var command = (type, description, extra = {}, required = []) => object({ type: { const: type, description }, ...BASE, ...extra }, ["type", "targets", ...required], description);
1679
2273
  var MOTION_COMMAND_SCHEMA = {
1680
2274
  description: `A command (one of ${MOTION_COMMAND_TYPES.join(", ")}). Commands in a shot play one after another unless "with": "previous".`,
1681
2275
  oneOf: [
1682
2276
  command("enter", "Brings hidden targets in; they are hidden before this shot.", {
1683
- style: { type: "string", enum: [...MOTION_ENTER_STYLES], description: "fade (default), pop, grow, slide, or draw (outline drawn on, then the fill fades in)." },
1684
- direction: { type: "string", enum: [...MOTION_DIRECTIONS], description: "For slide: direction of travel (default up)." },
1685
- distance: { type: "number", description: "For slide: document units (default 40)." }
2277
+ style: {
2278
+ type: "string",
2279
+ enum: [...MOTION_ENTER_STYLES],
2280
+ description: "fade (default), pop (scales up past full size and settles), grow (from nothing), slide, draw (outline drawn on, then the fill fades in), zoom (settles from larger), rise (rises a short way, overshooting a touch), or a reveal through a clip over the target's box: wipe (behind a travelling edge), iris (from the centre outwards) or split (from a line through the middle). Reveals need a box with a width and a height (not a lone straight line)."
2281
+ },
2282
+ direction: { type: "string", enum: [...MOTION_DIRECTIONS], description: "For slide: direction of travel (default up). For wipe: the way the edge travels (default right, revealing from the left)." },
2283
+ distance: { type: "number", description: "For slide (default 40) and rise (default 20): document units." },
2284
+ scale: { type: "number", minimum: 0, description: "For pop (default 0.5) and zoom (default 1.6): the starting scale (1 = rest size)." },
2285
+ shape: { type: "string", enum: [...MOTION_IRIS_SHAPES], description: "For iris: open through a circle (default; an ellipse on a box that is not square) or a box." },
2286
+ orientation: { type: "string", enum: [...MOTION_SPLIT_ORIENTATIONS], description: "For split: horizontal (default) opens up and down from a horizontal line through the middle; vertical opens left and right." }
1686
2287
  }),
1687
2288
  command("exit", "Takes targets out.", {
1688
- style: { type: "string", enum: [...MOTION_EXIT_STYLES], description: "fade (default), shrink, slide or undraw." },
1689
- direction: { type: "string", enum: [...MOTION_DIRECTIONS], description: "For slide: direction of travel (default down)." },
2289
+ style: { type: "string", enum: [...MOTION_EXIT_STYLES], description: "fade (default), shrink, slide, undraw, or wipe (hides behind a travelling edge through a clip over the target's box: a wipe in, reversed)." },
2290
+ direction: { type: "string", enum: [...MOTION_DIRECTIONS], description: "For slide: direction of travel (default down). For wipe: the way the edge travels (default right, hiding from the left)." },
1690
2291
  distance: { type: "number", description: "For slide: document units (default 40)." }
1691
2292
  }),
1692
2293
  command("draw", "Draws strokes on (or off) to a fraction of their length.", { to: FRACTION("Fraction drawn at the end (default 1)."), from: FRACTION("Fraction drawn at the start (default: what is drawn now).") }),
1693
2294
  command("move", "Moves targets by an offset or so their centre lands on a point.", { by: POINT("Offset in document units."), to: POINT("Point (document coordinates) for the centre.") }),
1694
2295
  command("rotate", "Turns targets about their centre (degrees, clockwise).", { by: { type: "number", description: "Degrees to turn by." }, to: { type: "number", description: "Angle to turn to, in degrees from rest." } }),
1695
- command("scale", "Scales targets about their centre (1 = rest size).", { to: { description: "A factor, or { x, y } factors.", anyOf: [{ type: "number" }, POINT("Factors per axis.")] } }, ["to"]),
2296
+ command("scale", "Scales targets about their centre (1 = rest size).", { to: { description: "A factor, or { x, y } factors.", anyOf: [{ type: "number" }, POINT("Factors per axis.")] }, geometric: { type: "boolean", description: "Grow by a steady factor, which reads as an even zoom (default false)." } }, ["to"]),
1696
2297
  command("fade", "Changes opacity.", { to: FRACTION("Opacity at the end (0\u20131).") }, ["to"]),
1697
2298
  command("style", "Blends solid fill / stroke colours (in OKLab) and stroke width. On a group it changes every path in it.", {
1698
2299
  fill: { type: "string", description: "CSS colour (hex, rgb(), rgba())." },
@@ -1702,12 +2303,30 @@ var MOTION_COMMAND_SCHEMA = {
1702
2303
  command("morph", "Morphs each target into the shape (and paint) of another node, which usually stays hidden.", { into: { type: "string", minLength: 1, description: "Id of the node whose shape to take." } }, ["into"]),
1703
2304
  command("follow", "Moves targets along a path node, centre on the path.", {
1704
2305
  path: { type: "string", minLength: 1, description: "Id of a path node (it can be hidden)." },
1705
- rotate: { type: "boolean", description: "Turn with the path (default false)." },
2306
+ rotate: { type: "boolean", description: "Turn with the path (default false); moves and shakes made meanwhile turn with it too." },
1706
2307
  from: FRACTION("Where to start along the path (default 0)."),
1707
2308
  to: FRACTION("Where to end along the path (default 1).")
1708
2309
  }, ["path"]),
1709
2310
  command("pulse", "Scales up and back to draw the eye.", { amount: { type: "number", description: "Extra scale at the peak (default 0.15)." }, count: { type: "integer", minimum: 1, description: "Pulses (default 1)." } }),
1710
- command("shake", "Shakes side to side, settling.", { amount: { type: "number", description: "Document units (default 8)." }, count: { type: "integer", minimum: 1, description: "Shakes (default 3)." } }),
2311
+ command("shake", "Shakes side to side (or up and down), settling.", {
2312
+ amount: { type: "number", description: "Document units (default 8)." },
2313
+ count: { type: "integer", minimum: 1, description: "Shakes (default 3)." },
2314
+ axis: { type: "string", enum: ["horizontal", "vertical"], description: "Default horizontal." }
2315
+ }),
2316
+ command("teeter", "Rocks about the centre either way, settling.", { angle: { type: "number", description: "Degrees either way (default 8)." }, count: { type: "integer", minimum: 1, description: "Rocks (default 3)." } }),
2317
+ command("wave", "Bobs up and down without settling, like something floating (default 1000 ms).", { amount: { type: "number", description: "Height in document units (default 6)." }, count: { type: "integer", minimum: 1, description: "Bobs (default 2)." } }),
2318
+ command("wiggle", "Wanders about its place as smooth seeded noise, easing out of and back to rest (default 1500 ms). The same seed always wanders the same way.", {
2319
+ amount: { type: "number", description: "Up to about this far, in document units (default 6)." },
2320
+ frequency: { type: "number", exclusiveMinimum: 0, description: "Wanders per second, in Hz (default 3)." },
2321
+ octaves: { type: "integer", minimum: 1, maximum: 8, description: "Layers of finer detail (default 2)." },
2322
+ rotate: { type: "number", description: "Also turns about the centre by up to about this many degrees (default 0)." },
2323
+ seed: { type: "integer", minimum: 0, description: "Default: one picked from the target id, so each target wanders its own way." }
2324
+ }),
2325
+ command("color-pulse", "Tints solid paint toward a colour and back (mixed in OKLab). On a group it tints every path in it.", {
2326
+ color: { type: "string", description: 'The tint, a CSS colour (default "#f59e0b").' },
2327
+ paint: { type: "string", enum: ["fill", "stroke"], description: "Which paint (default fill)." },
2328
+ count: { type: "integer", minimum: 1, description: "Pulses (default 1)." }
2329
+ }),
1711
2330
  command("effect", "Tweens a numeric parameter of a live path effect on the targets (twist angle, wiggle time, zig zag size, trim offset\u2026).", {
1712
2331
  effect: { type: "string", minLength: 1, description: `Effect id from get_animation's element "effects".` },
1713
2332
  param: { type: "string", minLength: 1, description: "A numeric parameter of that effect (listed with it)." },
@@ -1720,11 +2339,12 @@ var MOTION_COMMAND_SCHEMA = {
1720
2339
  )
1721
2340
  ]
1722
2341
  };
2342
+ var GEOMETRIC_CAMERA = { type: "boolean", description: "Zoom at a steady rate, so a big zoom does not rush at the start (default false)." };
1723
2343
  var CAMERA = {
1724
2344
  description: `Where the camera looks: a rectangle { x, y, width, height } in document units, or { frame: "<node id>", padding } to frame a node (fitted to the artwork's shape). It eases there over "ms" (default 1000); the first shot cuts.`,
1725
2345
  anyOf: [
1726
- object({ x: { type: "number", description: "Left." }, y: { type: "number", description: "Top." }, width: { type: "number", exclusiveMinimum: 0, description: "Width." }, height: { type: "number", exclusiveMinimum: 0, description: "Height." }, ease: EASE, ms: MS("Camera move length") }, ["x", "y", "width", "height"]),
1727
- object({ frame: { type: "string", minLength: 1, description: "Node id to frame." }, padding: { type: "number", description: "Margin around it (default 24)." }, ease: EASE, ms: MS("Camera move length") }, ["frame"])
2346
+ object({ x: { type: "number", description: "Left." }, y: { type: "number", description: "Top." }, width: { type: "number", exclusiveMinimum: 0, description: "Width." }, height: { type: "number", exclusiveMinimum: 0, description: "Height." }, ease: EASE, ms: MS("Camera move length"), geometric: GEOMETRIC_CAMERA }, ["x", "y", "width", "height"]),
2347
+ object({ frame: { type: "string", minLength: 1, description: "Node id to frame." }, padding: { type: "number", description: "Margin around it (default 24)." }, ease: EASE, ms: MS("Camera move length"), geometric: GEOMETRIC_CAMERA }, ["frame"])
1728
2348
  ]
1729
2349
  };
1730
2350
  var SHOT_FIELDS = {
@@ -1750,6 +2370,7 @@ var SETTINGS = {
1750
2370
  description: { type: ["string", "null"], description: "What it shows; null clears." },
1751
2371
  loop: { type: "string", enum: ["none", "loop", "ping-pong"], description: "Playback past the end (default none)." },
1752
2372
  fps: { type: "number", minimum: 1, maximum: 120, description: "Sampling rate for exports (default 30)." },
2373
+ steps: { anyOf: [{ type: "number", exclusiveMinimum: 0, maximum: 120 }, { type: "null" }], description: 'Stepped time for every command without its own: changes hold on a grid of this many frames per second (12 = "on twos" at 24 fps) for a hand-drawn look; null makes it smooth again (default).' },
1753
2374
  target: { anyOf: [{ type: "array", items: { type: "number", minimum: 0 }, minItems: 2, maxItems: 2 }, { type: "null" }], description: "Wanted length [min, max] in seconds; null clears." }
1754
2375
  };
1755
2376
  var EDIT_OP = {
@@ -1804,7 +2425,8 @@ function describeMotionShots(scene) {
1804
2425
  ...shot.hold !== void 0 ? { hold: shot.hold } : {},
1805
2426
  commands: shot.run.map((c, index) => {
1806
2427
  const ct = timeline.commands.find((x) => x.shotIndex === i && x.command === index);
1807
- return { index, ...c, start: ct.start, end: ct.end };
2428
+ const primitive = motionFlairPrimitive(c);
2429
+ return { index, ...c, start: ct.start, end: ct.end, ...primitive ? { flairPrimitive: primitive } : {} };
1808
2430
  })
1809
2431
  };
1810
2432
  });
@@ -1840,7 +2462,7 @@ function createAnimationEditorToolProvider(controller, options = {}) {
1840
2462
  const getAnimation = {
1841
2463
  name: "get_animation",
1842
2464
  title: "Read the animation",
1843
- description: "Returns the vector animation: its name, artwork size, length (ms), loop mode, the artwork's elements (id, type, name, parent, bounds, solid paints, live effects with their numeric parameters), every shot with its commands and their start/end times, Flair controls, and a summary of issues. Use the ids it returns with the write tools.",
2465
+ description: `Returns the vector animation: its name, artwork size, length (ms), loop mode, the artwork's elements (id, type, name, parent, bounds, solid paints, live effects with their numeric parameters), every shot with its commands and their start/end times (and, where Flair has one, the Flair motion primitive a command plays as "flairPrimitive", e.g. "entrance.pop"), Flair controls, and a summary of issues. Use the ids it returns with the write tools.`,
1844
2466
  inputSchema: object({ includeElements: { type: "boolean", description: "Include the artwork's elements (default true)." } }),
1845
2467
  annotations: { title: "Read the animation", ...READ },
1846
2468
  handler: ({ includeElements = true }) => {
@@ -1927,16 +2549,20 @@ function createAnimationEditorToolProvider(controller, options = {}) {
1927
2549
  }
1928
2550
  };
1929
2551
  const CURVES_DESCRIPTION = "Write cubic-bezier easings (ease, ease-in, ease-out, ease-in-out, back eases, custom curves) as one change with ease: [x1, y1, x2, y2] (default true). Needs Flair 0.36 or later; false samples them into linear steps for older Flair.";
2552
+ const FLAIR_DESCRIPTION = "The Flair version to export for (default 0.39): springs and stepped time need 0.39, behaviours 0.38, cubic-bezier curves 0.36; older versions get those sampled into linear steps.";
2553
+ const BEHAVIORS_DESCRIPTION = "Write pulses, shakes, teeters, waves, wiggles and colour pulses as one change with a Flair behavior, so they play exactly (default true; needs Flair 0.38 or later). False samples them into linear steps for older Flair.";
1930
2554
  const exportFlairMotion = {
1931
2555
  name: "export_flair_motion",
1932
2556
  title: "Export as Flair vector motion",
1933
2557
  description: "Returns the animation as a Flair SvgMotion ({ svg, animate }) that Flair's SvgPlayer plays, plus its hold, how many changes were sampled into linear steps, and notes on anything not carried over.",
1934
2558
  inputSchema: object({
1935
2559
  fps: { type: "number", minimum: 1, maximum: 120, description: "Sampling rate for changes Flair cannot express exactly (default: the scene's fps, else 30)." },
1936
- curves: { type: "boolean", description: CURVES_DESCRIPTION }
2560
+ curves: { type: "boolean", description: CURVES_DESCRIPTION },
2561
+ behaviors: { type: "boolean", description: BEHAVIORS_DESCRIPTION },
2562
+ flair: { type: "string", pattern: "^\\d+\\.\\d+(\\.\\d+)?$", description: FLAIR_DESCRIPTION }
1937
2563
  }),
1938
2564
  annotations: { title: "Export as Flair vector motion", ...READ },
1939
- handler: ({ fps, curves }) => controller.toFlairMotion({ ...fps ? { fps } : {}, ...curves === void 0 ? {} : { curves } })
2565
+ handler: ({ fps, curves, behaviors, flair }) => controller.toFlairMotion({ ...fps ? { fps } : {}, ...curves === void 0 ? {} : { curves }, ...behaviors === void 0 ? {} : { behaviors }, ...flair ? { flair } : {} })
1940
2566
  };
1941
2567
  const exportFlairEffect = {
1942
2568
  name: "export_flair_effect",
@@ -1948,7 +2574,9 @@ function createAnimationEditorToolProvider(controller, options = {}) {
1948
2574
  description: { type: "string", description: "One sentence (default: the scene's description)." },
1949
2575
  category: { type: "string", minLength: 1, description: "Flair catalog category, e.g. icon, loader, ui, data, text, morph (default icon)." },
1950
2576
  tags: { type: "array", items: { type: "string" }, description: "Search tags (default none)." },
1951
- curves: { type: "boolean", description: CURVES_DESCRIPTION }
2577
+ curves: { type: "boolean", description: CURVES_DESCRIPTION },
2578
+ behaviors: { type: "boolean", description: BEHAVIORS_DESCRIPTION },
2579
+ flair: { type: "string", pattern: "^\\d+\\.\\d+(\\.\\d+)?$", description: FLAIR_DESCRIPTION }
1952
2580
  }),
1953
2581
  annotations: { title: "Export as a Flair effect", ...READ },
1954
2582
  handler: (args) => controller.toFlairEffect(args)
@@ -1969,6 +2597,34 @@ function createAnimationEditorToolProvider(controller, options = {}) {
1969
2597
  return apply({ op: "set_artwork", artwork }, "Replace artwork", (s) => ({ elements: describeMotionElements(s) }));
1970
2598
  }
1971
2599
  };
2600
+ const importFlairEffect = {
2601
+ name: "import_flair_effect",
2602
+ title: "Import a Flair effect",
2603
+ description: "Replaces the whole animation with a Flair vector effect or SvgMotion (one undo step): its SVG becomes the artwork, {{name: color}} placeholders become colour controls, and each Flair step becomes a shot of commands (fade, enter, style, move, rotate, scale, draw, follow, pulse, shake, teeter, wave, wiggle, color-pulse). Returns the shots, the elements, notes on anything not carried over and the issues. With dry_run it only reports what the import would give.",
2604
+ inputSchema: object(
2605
+ {
2606
+ effect: { type: "object", description: 'A Flair catalog vector effect { kind: "vector", id, name, motion: { svg, animate }, hold?, presets? }. Give this or "motion".' },
2607
+ motion: { type: "object", description: 'A Flair SvgMotion { svg, animate }. Give this or "effect".' },
2608
+ name: { type: "string", minLength: 1, description: `Name for the scene (default: the effect's id, else "from-flair").` },
2609
+ dry_run: { type: "boolean", description: "Report without replacing anything (default false)." }
2610
+ },
2611
+ []
2612
+ ),
2613
+ annotations: { title: "Import a Flair effect", ...DESTRUCTIVE },
2614
+ handler: ({ effect, motion, name, dry_run = false }) => {
2615
+ if (effect === void 0 === (motion === void 0)) throw new Error('Give exactly one of "effect" (a Flair vector effect) or "motion" (a Flair SvgMotion). Nothing was changed.');
2616
+ let result;
2617
+ try {
2618
+ result = effect !== void 0 ? fromFlairVectorEffect(effect, name ? { name } : {}) : fromFlairSvgMotion(motion, name ? { name } : {});
2619
+ } catch (error) {
2620
+ throw new Error(`The Flair effect could not be imported: ${error.message} Nothing was changed.`);
2621
+ }
2622
+ const report = { notes: result.notes, issues: issueSummary(result.issues), shots: describeMotionShots(result.scene).map(({ id, start, end, commands }) => ({ id, start, end, commands: commands.length })) };
2623
+ if (dry_run) return { dry_run: true, ...report, hint: "Nothing was replaced; call again without dry_run to import." };
2624
+ const applied = controller.apply({ op: "replace_scene", scene: result.scene }, "Import Flair effect");
2625
+ return outcome(applied, applied.ok ? { ...report, elements: describeMotionElements(result.scene) } : {});
2626
+ }
2627
+ };
1972
2628
  const insertShots = {
1973
2629
  name: "insert_shots",
1974
2630
  title: "Add shots",
@@ -2051,7 +2707,7 @@ function createAnimationEditorToolProvider(controller, options = {}) {
2051
2707
  const setSettings = {
2052
2708
  name: "set_animation_settings",
2053
2709
  title: "Change animation settings",
2054
- description: "Sets the name, description, loop mode, export frame rate or target length. Returns the timing.",
2710
+ description: "Sets the name, description, loop mode, export frame rate, stepped time or target length. Returns the timing.",
2055
2711
  inputSchema: object(SETTINGS),
2056
2712
  annotations: { title: "Change animation settings", ...WRITE_IDEMPOTENT },
2057
2713
  handler: (args) => apply({ op: "set_settings", ...args }, "Change settings")
@@ -2130,7 +2786,7 @@ function createAnimationEditorToolProvider(controller, options = {}) {
2130
2786
  }
2131
2787
  };
2132
2788
  const reads = [getAnimation, getShot, findElements, checkAnimation, renderFrame, exportFlairMotion, exportFlairEffect, seekAnimation];
2133
- const writes = [setArtworkSvg, insertShots, updateShot, deleteShots, moveShot, addCommands, updateCommand, deleteCommands, setSettings, setControls, editAnimation, writeAnimation, undo, redo];
2789
+ const writes = [setArtworkSvg, importFlairEffect, insertShots, updateShot, deleteShots, moveShot, addCommands, updateCommand, deleteCommands, setSettings, setControls, editAnimation, writeAnimation, undo, redo];
2134
2790
  return {
2135
2791
  id: options.id ?? "animation",
2136
2792
  namespace: options.namespace,
@@ -2160,12 +2816,19 @@ How it plays
2160
2816
 
2161
2817
  Commands
2162
2818
  - enter (fade, pop, grow, slide, draw) and exit (fade, shrink, slide, undraw) bring things in and out.
2819
+ - enter also reveals through a clip over the target's box: wipe (behind an edge travelling "direction", default right), iris (from the centre; "shape": circle or box) and split (from a line through the middle; "orientation": horizontal or vertical). exit wipe hides the same way. Reveals need a width and a height: use draw / undraw for a straight line.
2163
2820
  - draw draws strokes on to a fraction; move, rotate, scale and fade change the pose (about the centre); style blends solid colours and stroke width.
2164
- - morph turns a shape into another node's shape (keep the target hidden); follow moves along a path node; pulse and shake draw the eye.
2821
+ - enter also has zoom (settles from larger) and rise (rises a short way, overshooting a touch); pop and zoom take a starting "scale".
2822
+ - morph turns a shape into another node's shape (keep the target hidden); follow moves along a path node (with "rotate", moves made meanwhile turn with the path).
2823
+ - Emphasis draws the eye and returns to rest: pulse (swell), shake (side to side, or "axis": "vertical"), teeter (rock), wave (bob, no settling), wiggle (seeded wander; the same seed wanders the same way) and color-pulse (tint and back).
2824
+ - Any command but enter and exit can "repeat" (loop, mirror back and forth, or offset to carry on: rotate by 90, repeat 4 is a full turn); "duration" is one cycle.
2825
+ - Springs ("spring-gentle", "spring-quick", "spring-bouncy", "spring-slow" or { "type": "spring", \u2026 }) feel physical; leave "duration" out and the spring sets it.
2826
+ - "steps": 12 (on a command, or for the whole scene with set_animation_settings) holds poses "on twos" for a hand-drawn look. "geometric": true on scale or a camera zooms at an even rate.
2827
+ - Commands share their names and defaults with Flair's motion primitives (get_animation shows each one's "flairPrimitive"), and emphasis, reveals and follows export to Flair exactly.
2165
2828
  - effect tweens a numeric parameter of a live path effect already on the node (twist angle, wiggle time, zig zag size).
2166
2829
 
2167
2830
  Working method
2168
- 1. get_animation to learn the element ids (set_animation_artwork_svg first if there is no artwork: give every element you will animate an id).
2831
+ 1. get_animation to learn the element ids (set_animation_artwork_svg first if there is no artwork: give every element you will animate an id; or import_flair_effect to start from a Flair effect and edit it).
2169
2832
  2. Plan a few shots, each one idea. Add them with insert_shots, or edit_animation to make several edits as one step (dry_run first when unsure).
2170
2833
  3. check_animation and fix every error and warning; render_animation_frame (shot + "end") to look at the result.
2171
2834
  4. To make a reusable Flair effect, set_animation_controls (colour controls stand for every use of their default colour) and export_flair_effect.
@@ -2175,6 +2838,7 @@ export {
2175
2838
  ANIMATION_EDITOR_AGENT_INSTRUCTIONS,
2176
2839
  AnimationEditorController,
2177
2840
  FLAIR_EASES,
2841
+ FLAIR_EXPORT_VERSION,
2178
2842
  MOTION_CAMERA_MS,
2179
2843
  MOTION_COMMAND_SCHEMA,
2180
2844
  MOTION_COMMAND_TYPES,
@@ -2189,8 +2853,11 @@ export {
2189
2853
  MOTION_EDIT_OPS,
2190
2854
  MOTION_ENTER_STYLES,
2191
2855
  MOTION_EXIT_STYLES,
2856
+ MOTION_IRIS_SHAPES,
2192
2857
  MOTION_ISSUE_CODES,
2193
2858
  MOTION_MIN_CHANGE_MS,
2859
+ MOTION_REPEAT_MODES,
2860
+ MOTION_SPLIT_ORIENTATIONS,
2194
2861
  MotionSceneFormatError,
2195
2862
  activeSegment,
2196
2863
  applyMotionControlValues,
@@ -2203,22 +2870,33 @@ export {
2203
2870
  describeMotionElements,
2204
2871
  describeMotionShots,
2205
2872
  fitMotionRect,
2873
+ flairAtLeast,
2206
2874
  flairCurveFor,
2207
2875
  flairEaseFor,
2208
2876
  freshShotId,
2877
+ fromFlairSvgMotion,
2878
+ fromFlairVectorEffect,
2209
2879
  indexMotionNodes,
2210
2880
  interpolateMotionValue,
2211
2881
  isMotionCommand,
2212
2882
  isMotionShot,
2883
+ motionClipAttrs,
2213
2884
  motionCommandDuration,
2885
+ motionCommandRepeat,
2886
+ motionCycleDuration,
2887
+ motionFlairPrimitive,
2888
+ motionFollowRoute,
2214
2889
  motionFrameAt,
2215
2890
  motionFrameToSvg,
2216
2891
  motionIssue,
2217
2892
  motionIssueKey,
2218
2893
  motionIssueLine,
2894
+ motionNodeClips,
2219
2895
  motionNodeOffset,
2220
2896
  motionPathLength,
2221
2897
  motionRestValue,
2898
+ motionRevealClip,
2899
+ motionRevealPadding,
2222
2900
  motionSceneShapeProblems,
2223
2901
  motionSceneToJson,
2224
2902
  motionShotAt,