@pixerate/editor 0.56.0 → 0.58.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.
@@ -17,7 +17,7 @@ import {
17
17
  vectorDocumentToSvg,
18
18
  vectorNodeBounds,
19
19
  vectorPathWorldBezier
20
- } from "../chunk-NONDTTH7.js";
20
+ } from "../chunk-PYMV7HLI.js";
21
21
  import {
22
22
  bezierPathLength,
23
23
  bezierPathPointAtLength,
@@ -27,6 +27,10 @@ import {
27
27
  import {
28
28
  HistoryManager
29
29
  } from "../chunk-QVZZGT4X.js";
30
+ import {
31
+ behaviorProgress,
32
+ motionSeedFromId
33
+ } from "../chunk-3NYXDBBW.js";
30
34
  import {
31
35
  AnimationPlayer
32
36
  } from "../chunk-SHOVAHUB.js";
@@ -41,7 +45,8 @@ import {
41
45
  import {
42
46
  ANIMATION_EASING_PRESETS,
43
47
  evaluateEasing,
44
- isAnimationEasing
48
+ isAnimationEasing,
49
+ resolveEasing
45
50
  } from "../chunk-UB2OY7FD.js";
46
51
 
47
52
  // src/animation-editor/types.ts
@@ -58,11 +63,17 @@ var MOTION_COMMAND_TYPES = [
58
63
  "follow",
59
64
  "pulse",
60
65
  "shake",
66
+ "teeter",
67
+ "wave",
68
+ "wiggle",
69
+ "color-pulse",
61
70
  "effect",
62
71
  "wait"
63
72
  ];
64
- var MOTION_ENTER_STYLES = ["fade", "pop", "grow", "slide", "draw"];
65
- var MOTION_EXIT_STYLES = ["fade", "shrink", "slide", "undraw"];
73
+ var MOTION_ENTER_STYLES = ["fade", "pop", "grow", "slide", "draw", "zoom", "rise", "wipe", "iris", "split"];
74
+ var MOTION_EXIT_STYLES = ["fade", "shrink", "slide", "undraw", "wipe"];
75
+ var MOTION_IRIS_SHAPES = ["circle", "box"];
76
+ var MOTION_SPLIT_ORIENTATIONS = ["horizontal", "vertical"];
66
77
  var MOTION_DIRECTIONS = ["up", "down", "left", "right"];
67
78
  var MOTION_DEFAULT_DURATIONS = {
68
79
  enter: 600,
@@ -76,12 +87,37 @@ var MOTION_DEFAULT_DURATIONS = {
76
87
  follow: 1400,
77
88
  pulse: 600,
78
89
  shake: 500,
90
+ teeter: 600,
91
+ wave: 1e3,
92
+ wiggle: 1500,
93
+ "color-pulse": 600,
79
94
  effect: 900
80
95
  };
81
96
  var MOTION_DEFAULT_HOLD = 600;
82
97
  var MOTION_CAMERA_MS = 1e3;
83
98
  var MOTION_DRAW_MS_PER_UNIT = 1.5;
84
99
  var MOTION_DRAW_RANGE = [500, 2500];
100
+ function motionFlairPrimitive(command2) {
101
+ switch (command2.type) {
102
+ case "enter":
103
+ return `entrance.${command2.style ?? "fade"}`;
104
+ case "exit":
105
+ return `exit.${command2.style ?? "fade"}`;
106
+ case "pulse":
107
+ case "shake":
108
+ case "teeter":
109
+ case "wave":
110
+ case "wiggle":
111
+ case "color-pulse":
112
+ return `emphasis.${command2.type}`;
113
+ case "rotate":
114
+ return command2.by !== void 0 ? "emphasis.spin" : void 0;
115
+ case "follow":
116
+ return "path.follow";
117
+ default:
118
+ return void 0;
119
+ }
120
+ }
85
121
 
86
122
  // src/animation-editor/issues.ts
87
123
  var MOTION_ISSUE_CODES = {
@@ -90,13 +126,14 @@ var MOTION_ISSUE_CODES = {
90
126
  "unknown-target": { severity: "error", wording: "A command names a node the artwork does not have." },
91
127
  "unknown-path": { severity: "error", wording: "A follow command names a path the artwork does not have." },
92
128
  "not-a-path": { severity: "error", wording: "A follow path must be a path node, not a group." },
129
+ "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." },
93
130
  "unknown-shape": { severity: "error", wording: "A morph names a shape the artwork does not have." },
94
131
  "unknown-effect": { severity: "error", wording: "An effect command names an effect the node does not have." },
95
132
  "unknown-effect-param": { severity: "error", wording: "An effect command names a parameter that is not a number of that effect." },
96
133
  "unknown-frame": { severity: "error", wording: "A camera frames a node the artwork does not have." },
97
134
  "invalid-camera": { severity: "error", wording: "A camera rectangle has no area." },
98
135
  "invalid-color": { severity: "error", wording: "A colour cannot be read." },
99
- "paint-not-color": { severity: "warning", wording: "A style command recolours a gradient or empty paint, which only switches at the end." },
136
+ "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." },
100
137
  "overlap": { severity: "warning", wording: "Two commands change the same property of a node at the same time; the later one wins." },
101
138
  "enter-visible": { severity: "warning", wording: "A node enters while it is already showing, so it blinks out first." },
102
139
  "exit-hidden": { severity: "warning", wording: "A node exits or animates while it is hidden." },
@@ -164,7 +201,7 @@ var SHOT_KEYS = ["id", "label", "camera", "run", "hold"];
164
201
  var CAMERA_KEYS = ["x", "y", "width", "height", "frame", "padding", "ease", "ms"];
165
202
  var BASE_KEYS = ["type", "targets", "duration", "delay", "stagger", "with", "ease", "note"];
166
203
  var COMMAND_KEYS = {
167
- enter: [...BASE_KEYS, "style", "direction", "distance"],
204
+ enter: [...BASE_KEYS, "style", "direction", "distance", "scale", "shape", "orientation"],
168
205
  exit: [...BASE_KEYS, "style", "direction", "distance"],
169
206
  draw: [...BASE_KEYS, "from", "to"],
170
207
  move: [...BASE_KEYS, "by", "to"],
@@ -175,7 +212,11 @@ var COMMAND_KEYS = {
175
212
  morph: [...BASE_KEYS, "into"],
176
213
  follow: [...BASE_KEYS, "path", "rotate", "from", "to"],
177
214
  pulse: [...BASE_KEYS, "amount", "count"],
178
- shake: [...BASE_KEYS, "amount", "count"],
215
+ shake: [...BASE_KEYS, "amount", "count", "axis"],
216
+ teeter: [...BASE_KEYS, "angle", "count"],
217
+ wave: [...BASE_KEYS, "amount", "count"],
218
+ wiggle: [...BASE_KEYS, "amount", "frequency", "octaves", "rotate", "seed"],
219
+ "color-pulse": [...BASE_KEYS, "color", "paint", "count"],
179
220
  effect: [...BASE_KEYS, "effect", "param", "to"],
180
221
  wait: ["type", "duration", "with", "note"]
181
222
  };
@@ -222,6 +263,9 @@ function commandShapeProblems(command2, where) {
222
263
  p(`"style" must be one of ${styles.join(", ")}${typeof command2.style === "string" ? ` (got ${namedWithSuggestion(command2.style, styles)})` : ""}.`);
223
264
  if (command2.direction !== void 0 && !MOTION_DIRECTIONS.includes(command2.direction)) p(`"direction" must be one of ${MOTION_DIRECTIONS.join(", ")}.`);
224
265
  optionalNumber("distance");
266
+ 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).`);
267
+ 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).`);
268
+ 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).`);
225
269
  break;
226
270
  }
227
271
  case "draw":
@@ -263,8 +307,22 @@ function commandShapeProblems(command2, where) {
263
307
  break;
264
308
  case "pulse":
265
309
  case "shake":
310
+ case "teeter":
311
+ case "wave":
312
+ case "color-pulse":
266
313
  optionalNumber("amount");
314
+ optionalNumber("angle");
267
315
  if (command2.count !== void 0 && !(Number.isInteger(command2.count) && command2.count >= 1)) p(`"count" must be a whole number \u2265 1.`);
316
+ if (command2.axis !== void 0 && command2.axis !== "horizontal" && command2.axis !== "vertical") p(`"axis" must be "horizontal" or "vertical".`);
317
+ if (command2.color !== void 0 && typeof command2.color !== "string") p(`"color" must be a CSS colour.`);
318
+ if (command2.paint !== void 0 && command2.paint !== "fill" && command2.paint !== "stroke") p(`"paint" must be "fill" or "stroke".`);
319
+ break;
320
+ case "wiggle":
321
+ optionalNumber("amount");
322
+ optionalNumber("rotate");
323
+ if (command2.frequency !== void 0 && !(isNum(command2.frequency) && command2.frequency > 0)) p(`"frequency" must be a number of Hz > 0.`);
324
+ 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.`);
325
+ if (command2.seed !== void 0 && !(Number.isInteger(command2.seed) && command2.seed >= 0)) p(`"seed" must be a whole number \u2265 0.`);
268
326
  break;
269
327
  case "effect":
270
328
  if (typeof command2.effect !== "string" || !command2.effect) p(`"effect" must be the id of one of the node's effects.`);
@@ -357,7 +415,7 @@ function indexMotionNodes(nodes) {
357
415
  const local = vectorNodeBounds(node);
358
416
  const bounds = vectorNodeBounds(node, parentWorld);
359
417
  const center = local ? { x: local.x + local.width / 2, y: local.y + local.height / 2 } : { x: 0, y: 0 };
360
- if (!out.has(node.id)) out.set(node.id, { node, ancestors, parentWorld, center, bounds });
418
+ if (!out.has(node.id)) out.set(node.id, { node, ancestors, parentWorld, center, bounds, box: local });
361
419
  if (node.type === "group") walk(node.children, multiplyMatrices(nodeMatrix(node), parentWorld), [...ancestors, node.id]);
362
420
  }
363
421
  };
@@ -374,6 +432,56 @@ function firstPaint(node, key) {
374
432
  }
375
433
  return void 0;
376
434
  }
435
+ function strokeReach(node) {
436
+ let reach = 0;
437
+ for (const p of node.type === "path" ? [node] : collectPathNodes(node)) {
438
+ if (p.stroke == null || p.stroke === "none") continue;
439
+ const width = p.strokeWidth ?? 1;
440
+ reach = Math.max(reach, p.strokeAlign === "outside" ? width : p.strokeAlign === "inside" ? 0 : width / 2);
441
+ }
442
+ return reach;
443
+ }
444
+ function motionRevealPadding(entry) {
445
+ const box = entry.box;
446
+ if (!box || !(box.width > 0) || !(box.height > 0)) return null;
447
+ const s = strokeReach(entry.node);
448
+ return [Math.min(1, s / box.width + 0.02), Math.min(1, s / box.height + 0.02)];
449
+ }
450
+ var clipRound = (n) => Math.round(n * 1e4) / 1e4 || 0;
451
+ function revealFull(property, [px, py]) {
452
+ 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);
453
+ }
454
+ var OPPOSITE = { up: "down", down: "up", left: "right", right: "left" };
455
+ function motionRevealClip(entry, reveal) {
456
+ const pad = motionRevealPadding(entry);
457
+ if (!pad) return null;
458
+ const [px, py] = pad;
459
+ const [w, h] = [1 + 2 * px, 1 + 2 * py];
460
+ const rect = (from, to) => ({ property: "clipRect", from: from.map(clipRound), to: to.map(clipRound) });
461
+ if (reveal.style === "iris") {
462
+ if (reveal.shape === "box") return rect([0.5, 0.5, 0, 0], [-px, -py, w, h]);
463
+ return { property: "clipCircle", from: [0.5, 0.5, 0], to: revealFull("clipCircle", pad) };
464
+ }
465
+ if (reveal.style === "split") {
466
+ if (reveal.orientation === "vertical") return rect([0.5, -py, 0, h], [-px, -py, w, h]);
467
+ return rect([-px, 0.5, w, 0], [-px, -py, w, h]);
468
+ }
469
+ const direction = reveal.direction ?? "right";
470
+ if (reveal.hide) {
471
+ const shown = motionRevealClip(entry, { style: "wipe", direction: OPPOSITE[direction] });
472
+ return { ...shown, from: shown.to, to: shown.from };
473
+ }
474
+ switch (direction) {
475
+ case "right":
476
+ return rect([-px, -py, 0, h], [-px, -py, w, h]);
477
+ case "left":
478
+ return rect([1 + px, -py, 0, h], [-px, -py, w, h]);
479
+ case "down":
480
+ return rect([-px, -py, w, 0], [-px, -py, w, h]);
481
+ case "up":
482
+ return rect([-px, 1 + py, w, 0], [-px, -py, w, h]);
483
+ }
484
+ }
377
485
  function motionRestValue(entry, property, artwork) {
378
486
  const node = entry?.node;
379
487
  switch (property) {
@@ -401,6 +509,11 @@ function motionRestValue(entry, property, artwork) {
401
509
  }
402
510
  case "shape":
403
511
  return node?.id ?? "";
512
+ case "clipRect":
513
+ case "clipCircle": {
514
+ const pad = entry && motionRevealPadding(entry);
515
+ return pad ? revealFull(property, pad) : [];
516
+ }
404
517
  case "camera":
405
518
  return [0, 0, artwork.width, artwork.height];
406
519
  }
@@ -423,15 +536,13 @@ function interpolateMotionValue(from, to, u) {
423
536
  function segmentProgress(segment, time) {
424
537
  const span = segment.end - segment.start;
425
538
  const u = span <= 0 ? 1 : Math.min(1, Math.max(0, (time - segment.start) / span));
426
- return { u, eased: segment.wave ? u : evaluateEasing(segment.ease, u, span) };
539
+ return { u, eased: evaluateEasing(segment.ease, u, span) };
427
540
  }
428
- function segmentValue(segment, time) {
429
- const { u, eased } = segmentProgress(segment, time);
430
- const v = interpolateMotionValue(segment.from, segment.to, eased);
431
- if (!segment.wave || typeof v !== "number") return v;
432
- const { kind, amount, count } = segment.wave;
433
- if (kind === "pulse") return v * (1 + amount * Math.sin(Math.PI * u * count) ** 2);
434
- return v + amount * Math.sin(2 * Math.PI * u * count) * (1 - u);
541
+ function segmentValue(segment, time, raw = false) {
542
+ const { eased } = segmentProgress(segment, time);
543
+ if (!segment.behavior || raw) return interpolateMotionValue(segment.from, segment.to, eased);
544
+ const p = behaviorProgress(segment.behavior, eased, segment.index ?? 0, segment.end - segment.start);
545
+ return typeof segment.from === "string" ? interpolateMotionValue(segment.from, segment.to, Math.min(1, Math.max(0, p))) : interpolateMotionValue(segment.from, segment.to, p);
435
546
  }
436
547
  function activeSegment(track, time) {
437
548
  if (!track?.length) return void 0;
@@ -439,10 +550,10 @@ function activeSegment(track, time) {
439
550
  for (const s of track) if (s.start <= time && (!found || s.start >= found.start)) found = s;
440
551
  return found;
441
552
  }
442
- function trackValue(track, time) {
553
+ function trackValue(track, time, raw = false) {
443
554
  if (!track?.length) return void 0;
444
555
  const s = activeSegment(track, time);
445
- if (s) return segmentValue(s, time);
556
+ if (s) return segmentValue(s, time, raw);
446
557
  let first = track[0];
447
558
  for (const t of track) if (t.start < first.start) first = t;
448
559
  return first.from;
@@ -581,15 +692,27 @@ function compileMotionScene(scene) {
581
692
  case "enter": {
582
693
  const style = command2.style ?? "fade";
583
694
  const opacity = rest("opacity");
695
+ 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;
696
+ for (const p of ["clipRect", "clipCircle"]) if (p !== reveal?.property && tracks.has(motionTrackKey(node, p))) seg(p, rest(p), "linear", start, start);
697
+ if (reveal) {
698
+ seg("opacity", opacity, "linear", start, start);
699
+ seg(reveal.property, reveal.to, ease ?? MOTION_EASE_OUT, start, end, { from: reveal.from });
700
+ break;
701
+ }
584
702
  if (style === "draw") {
585
703
  seg("drawEnd", 1, ease ?? MOTION_EASE_IN_OUT, start, start + duration2 * 0.75, { from: 0 });
586
704
  seg("fillOpacity", 1, MOTION_EASE_OUT, start + duration2 * 0.6, end, { from: 0 });
587
705
  break;
588
706
  }
589
- if (style === "pop") {
590
- seg("opacity", opacity, MOTION_EASE_OUT, start, start + duration2 * 0.4, { from: 0 });
591
- seg("scaleX", 1, ease ?? "ease-out-back", start, end, { from: 0.5 });
592
- seg("scaleY", 1, ease ?? "ease-out-back", start, end, { from: 0.5 });
707
+ if (style === "pop" || style === "zoom") {
708
+ const from = command2.scale ?? (style === "pop" ? 0.5 : 1.6);
709
+ seg("opacity", opacity, MOTION_EASE_OUT, start, start + duration2 * (style === "pop" ? 0.4 : 0.5), { from: 0 });
710
+ seg("scaleX", 1, ease ?? (style === "pop" ? "ease-out-back" : MOTION_EASE_OUT), start, end, { from });
711
+ seg("scaleY", 1, ease ?? (style === "pop" ? "ease-out-back" : MOTION_EASE_OUT), start, end, { from });
712
+ } else if (style === "rise") {
713
+ const y = num("y");
714
+ seg("opacity", opacity, MOTION_EASE_OUT, start, start + duration2 * 0.5, { from: 0 });
715
+ seg("y", y, ease ?? "ease-out-back", start, end, { from: y + (command2.distance ?? 20) });
593
716
  } else if (style === "grow") {
594
717
  seg("opacity", opacity, MOTION_EASE_OUT, start, start + duration2 * 0.3, { from: 0 });
595
718
  seg("scaleX", 1, ease ?? MOTION_EASE_OUT, start, end, { from: 0 });
@@ -607,7 +730,9 @@ function compileMotionScene(scene) {
607
730
  }
608
731
  case "exit": {
609
732
  const style = command2.style ?? "fade";
610
- if (style === "undraw") {
733
+ const wipe = style === "wipe" ? motionRevealClip(entry, { style, direction: command2.direction, hide: true }) : null;
734
+ if (wipe) seg(wipe.property, wipe.to, ease ?? "ease-in", start, end, { from: wipe.from });
735
+ else if (style === "undraw") {
611
736
  seg("fillOpacity", 0, "ease-in", start, start + duration2 * 0.4);
612
737
  seg("drawEnd", 0, ease ?? MOTION_EASE_IN_OUT);
613
738
  } else if (style === "shrink") {
@@ -623,13 +748,11 @@ function compileMotionScene(scene) {
623
748
  } else seg("opacity", 0, ease ?? "ease-in");
624
749
  break;
625
750
  }
626
- case "draw":
627
- {
628
- const drawn = tracks.has(motionTrackKey(node, "drawEnd"));
629
- seg("drawEnd", command2.to ?? 1, ease ?? MOTION_EASE_IN_OUT, start, end, command2.from !== void 0 ? { from: command2.from } : drawn ? {} : { from: 0 });
630
- break;
631
- }
751
+ case "draw": {
752
+ const drawn = tracks.has(motionTrackKey(node, "drawEnd"));
753
+ seg("drawEnd", command2.to ?? 1, ease ?? MOTION_EASE_IN_OUT, start, end, command2.from !== void 0 ? { from: command2.from } : drawn ? {} : { from: 0 });
632
754
  break;
755
+ }
633
756
  case "move": {
634
757
  const e = ease ?? MOTION_EASE_IN_OUT;
635
758
  let dx = command2.by?.x ?? 0;
@@ -667,14 +790,35 @@ function compileMotionScene(scene) {
667
790
  case "follow":
668
791
  seg("follow", command2.to ?? 1, ease ?? MOTION_EASE_IN_OUT, start, end, { from: command2.from ?? 0, path: command2.path, rotate: command2.rotate ?? false });
669
792
  break;
793
+ // Emphasis: a change toward a peak that a behaviour swings round (as Flair's primitives do).
670
794
  case "pulse": {
671
- const wave = { kind: "pulse", amount: command2.amount ?? 0.15, count: command2.count ?? 1 };
672
- seg("scaleX", num("scaleX"), "linear", start, end, { wave });
673
- seg("scaleY", num("scaleY"), "linear", start, end, { wave });
795
+ const behavior = { type: "pulse", count: command2.count ?? 1 };
796
+ const k = 1 + (command2.amount ?? 0.15);
797
+ seg("scaleX", num("scaleX") * k, ease ?? "linear", start, end, { behavior });
798
+ seg("scaleY", num("scaleY") * k, ease ?? "linear", start, end, { behavior });
799
+ break;
800
+ }
801
+ case "shake": {
802
+ const p = command2.axis === "vertical" ? "y" : "x";
803
+ seg(p, num(p) + (command2.amount ?? 8), ease ?? "linear", start, end, { behavior: { type: "oscillate", count: command2.count ?? 3, decay: 1 } });
804
+ break;
805
+ }
806
+ case "teeter":
807
+ seg("rotation", num("rotation") + (command2.angle ?? 8), ease ?? "linear", start, end, { behavior: { type: "oscillate", count: command2.count ?? 3, decay: 1 } });
808
+ break;
809
+ case "wave":
810
+ seg("y", num("y") - (command2.amount ?? 6), ease ?? "linear", start, end, { behavior: { type: "oscillate", count: command2.count ?? 2 } });
811
+ break;
812
+ case "wiggle": {
813
+ const behavior = { type: "wriggle", frequency: command2.frequency ?? 3, octaves: command2.octaves ?? 2, seed: command2.seed || motionSeedFromId(node) };
814
+ const amount = command2.amount ?? 6;
815
+ seg("x", num("x") + amount, ease ?? "linear", start, end, { behavior, index: 0 });
816
+ seg("y", num("y") + amount, ease ?? "linear", start, end, { behavior, index: 1 });
817
+ if (command2.rotate) seg("rotation", num("rotation") + command2.rotate, ease ?? "linear", start, end, { behavior, index: 2 });
674
818
  break;
675
819
  }
676
- case "shake":
677
- seg("x", num("x"), "linear", start, end, { wave: { kind: "shake", amount: command2.amount ?? 8, count: command2.count ?? 3 } });
820
+ case "color-pulse":
821
+ seg(command2.paint ?? "fill", command2.color ?? "#f59e0b", ease ?? "linear", start, end, { behavior: { type: "pulse", count: command2.count ?? 1 } });
678
822
  break;
679
823
  case "effect":
680
824
  seg(`effect:${command2.effect}:${command2.param}`, command2.to, ease ?? MOTION_EASE_IN_OUT);
@@ -756,44 +900,53 @@ function shapeAt(entry, timeline, nodes, time) {
756
900
  if (u >= 1) return b === entry ? null : shapeIn(entry, b);
757
901
  return morphBetween(shapeIn(entry, a), shapeIn(entry, b)).at(u);
758
902
  }
903
+ function motionFollowRoute(entry, path) {
904
+ if (!path || path.node.type !== "path") return null;
905
+ try {
906
+ return transformBezierPath(vectorPathWorldBezier(path.node, path.parentWorld, { effects: false }), invertMatrix(entry.parentWorld));
907
+ } catch {
908
+ return null;
909
+ }
910
+ }
759
911
  function followAt(entry, timeline, nodes, time) {
760
912
  const track = timeline.tracks.get(motionTrackKey(entry.node.id, "follow"));
761
913
  const s = activeSegment(track, time) ?? track?.[0];
762
914
  if (!s?.path) return null;
763
- const path = nodes.get(s.path);
764
- if (!path || path.node.type !== "path") return null;
915
+ const route = motionFollowRoute(entry, nodes.get(s.path));
765
916
  const progress = Number(trackValue(track, time));
766
- if (!(progress >= 0)) return null;
917
+ if (!route || !(progress >= 0)) return null;
767
918
  try {
768
- const bezier = vectorPathWorldBezier(path.node, path.parentWorld, { effects: false });
769
- const at = bezierPathPointAtLength(bezier, Math.min(1, Math.max(0, progress)) * bezierPathLength(bezier));
919
+ const at = bezierPathPointAtLength(route, Math.min(1, Math.max(0, progress)) * bezierPathLength(route));
770
920
  if (!at) return null;
771
- const p = applyMatrix(invertMatrix(entry.parentWorld), { x: at.point[0], y: at.point[1] });
772
- return { x: p.x, y: p.y, angle: s.rotate ? at.angle * 180 / Math.PI : 0 };
921
+ return { x: at.point[0], y: at.point[1], angle: s.rotate ? at.angle * 180 / Math.PI : 0 };
773
922
  } catch {
774
923
  return null;
775
924
  }
776
925
  }
777
- function motionNodeOffset(entry, timeline, time, nodes = indexMotionNodes([entry.node])) {
926
+ function motionNodeOffset(entry, timeline, time, nodes = indexMotionNodes([entry.node]), raw = false, follow = true) {
778
927
  const read = (p, rest) => {
779
- const v = trackValue(timeline.tracks.get(motionTrackKey(entry.node.id, p)), time);
928
+ const v = trackValue(timeline.tracks.get(motionTrackKey(entry.node.id, p)), time, raw);
780
929
  return typeof v === "number" ? v : rest;
781
930
  };
782
- if (!TRANSFORM_PROPS.some((p) => timeline.tracks.has(motionTrackKey(entry.node.id, p)))) return null;
783
- let dx = read("x", 0);
784
- let dy = read("y", 0);
785
- let rotation = read("rotation", 0);
931
+ if (!TRANSFORM_PROPS.some((p) => (follow || p !== "follow") && timeline.tracks.has(motionTrackKey(entry.node.id, p)))) return null;
932
+ const dx = read("x", 0);
933
+ const dy = read("y", 0);
934
+ const rotation = read("rotation", 0);
786
935
  const scaleX = read("scaleX", 1);
787
936
  const scaleY = read("scaleY", 1);
788
- const follow = followAt(entry, timeline, nodes, time);
789
- if (follow) {
790
- dx += follow.x - entry.center.x;
791
- dy += follow.y - entry.center.y;
792
- rotation += follow.angle;
793
- }
794
937
  const c = entry.center;
795
- const matrix = multiplyMatrices(multiplyMatrices(scaleMatrix(scaleX, scaleY, c), rotateMatrix(rotation, c)), translateMatrix(dx, dy));
796
- return { matrix, dx, dy, rotation, scaleX, scaleY };
938
+ let matrix = multiplyMatrices(multiplyMatrices(scaleMatrix(scaleX, scaleY, c), rotateMatrix(rotation, c)), translateMatrix(dx, dy));
939
+ const along = follow ? followAt(entry, timeline, nodes, time) : null;
940
+ if (along) matrix = multiplyMatrices(matrix, multiplyMatrices(multiplyMatrices(translateMatrix(-c.x, -c.y), rotateMatrix(along.angle)), translateMatrix(along.x, along.y)));
941
+ return { matrix, dx, dy, rotation, scaleX, scaleY, follow: along };
942
+ }
943
+ function motionNodeClips(id, timeline, time) {
944
+ const out = [];
945
+ const rect = trackValue(timeline.tracks.get(motionTrackKey(id, "clipRect")), time);
946
+ 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])) });
947
+ const circle = trackValue(timeline.tracks.get(motionTrackKey(id, "clipCircle")), time);
948
+ 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])) });
949
+ return out;
797
950
  }
798
951
  function motionFrameAt(scene, time, options = {}) {
799
952
  const s = applyMotionControlValues(scene, options.values);
@@ -802,12 +955,15 @@ function motionFrameAt(scene, time, options = {}) {
802
955
  const drawOn = options.draw !== false;
803
956
  const value = (id, p) => trackValue(timeline.tracks.get(motionTrackKey(id, p)), time);
804
957
  const animated = new Set(timeline.segments.map((seg) => seg.node));
958
+ const clips = {};
805
959
  const visit = (node) => {
806
960
  let out = node.type === "group" ? { ...node, children: node.children.map(visit) } : node;
807
961
  if (!animated.has(node.id)) return out;
808
962
  const entry = nodes.get(node.id);
809
963
  if (!entry) return out;
810
964
  const id = node.id;
965
+ const clip = motionNodeClips(id, timeline, time);
966
+ if (clip.length) clips[id] = clip;
811
967
  const shape = shapeAt(entry, timeline, nodes, time);
812
968
  if (shape) {
813
969
  const { name, visible, opacity: opacity2, locked } = node;
@@ -871,11 +1027,19 @@ function motionFrameAt(scene, time, options = {}) {
871
1027
  const document = { ...s.artwork, nodes: s.artwork.nodes.map(visit) };
872
1028
  const cam = trackValue(timeline.tracks.get(motionTrackKey("", "camera")), time);
873
1029
  const box = Array.isArray(cam) ? cam : [0, 0, s.artwork.width, s.artwork.height];
874
- return { time, document, camera: { x: tidy(box[0]), y: tidy(box[1]), width: tidy(box[2]), height: tidy(box[3]) } };
1030
+ return { time, document, camera: { x: tidy(box[0]), y: tidy(box[1]), width: tidy(box[2]), height: tidy(box[3]) }, clips };
1031
+ }
1032
+ function motionClipAttrs(clip) {
1033
+ const { shape: _shape, ...attrs } = clip;
1034
+ return attrs;
875
1035
  }
876
1036
  function motionFrameToSvg(scene, time, options = {}) {
877
1037
  const frame = motionFrameAt(scene, time, options);
878
- return withViewBox(vectorDocumentToSvg(frame.document, options), frame.camera);
1038
+ const prefix = options.idPrefix ?? "vg-";
1039
+ const wrap = { ...options.wrap };
1040
+ for (const [id, clips] of Object.entries(frame.clips))
1041
+ wrap[id] = [...wrap[id] ?? [], ...clips.map((clip, i) => ({ clip: { id: `${prefix}reveal-${id}-${i}`, shape: clip.shape, attrs: motionClipAttrs(clip) } }))];
1042
+ return withViewBox(vectorDocumentToSvg(frame.document, { ...options, wrap }), frame.camera);
879
1043
  }
880
1044
  function withViewBox(svg, box) {
881
1045
  const f = (n) => String(Math.round(n * 1e3) / 1e3);
@@ -910,6 +1074,11 @@ function visibleAt(timeline, id, time, node) {
910
1074
  if (node.visible === false) return false;
911
1075
  if (num("opacity", node.opacity ?? 1) <= 0.01) return false;
912
1076
  if (Math.abs(num("scaleX", 1) * num("scaleY", 1)) <= 1e-4) return false;
1077
+ const clip = (p) => trackValue(timeline.tracks.get(motionTrackKey(id, p)), time);
1078
+ const rect = clip("clipRect");
1079
+ if (Array.isArray(rect) && (rect[2] <= 1e-4 || rect[3] <= 1e-4)) return false;
1080
+ const circle = clip("clipCircle");
1081
+ if (Array.isArray(circle) && circle[2] <= 1e-4) return false;
913
1082
  const hasFill = node.type === "path" ? !!node.fill && node.fill !== "none" : collectPathNodes(node).some((p) => p.fill && p.fill !== "none");
914
1083
  return num("drawEnd", 1) > 0.01 || hasFill && num("fillOpacity", 1) > 0.01;
915
1084
  }
@@ -949,6 +1118,12 @@ function checkMotionScene(scene) {
949
1118
  if (command2.type === "follow" && known(command2.path, "unknown-path", "path", where) && nodes.get(command2.path).node.type !== "path")
950
1119
  issues.push(motionIssue("not-a-path", `"${command2.path}" is a group; follow a single path node.`, { ...where, ids: [command2.path] }));
951
1120
  if (command2.type === "morph") known(command2.into, "unknown-shape", "shape", where);
1121
+ const style = command2.type === "enter" || command2.type === "exit" ? command2.style : void 0;
1122
+ if (style === "wipe" || style === "iris" || style === "split") {
1123
+ for (const id of targets)
1124
+ if (!motionRevealPadding(nodes.get(id)))
1125
+ 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] }));
1126
+ }
952
1127
  if (command2.type === "style") {
953
1128
  for (const key of ["fill", "stroke"]) {
954
1129
  const color = command2[key];
@@ -965,6 +1140,18 @@ function checkMotionScene(scene) {
965
1140
  }
966
1141
  }
967
1142
  }
1143
+ if (command2.type === "color-pulse") {
1144
+ const key = command2.paint ?? "fill";
1145
+ const color = command2.color ?? "#f59e0b";
1146
+ 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));
1147
+ else
1148
+ for (const id of targets) {
1149
+ const node = nodes.get(id).node;
1150
+ const paths = node.type === "path" ? [node] : collectPathNodes(node);
1151
+ if (paths.some((p) => typeof p[key] !== "string"))
1152
+ 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] }));
1153
+ }
1154
+ }
968
1155
  if (command2.type === "effect") {
969
1156
  for (const id of targets) {
970
1157
  const node = nodes.get(id).node;
@@ -1231,6 +1418,14 @@ function flairEaseFor(ease) {
1231
1418
  }
1232
1419
  return void 0;
1233
1420
  }
1421
+ function flairCurveFor(ease, curves = true) {
1422
+ const name = flairEaseFor(ease);
1423
+ if (name || !curves) return name;
1424
+ const resolved = resolveEasing(ease);
1425
+ if (resolved === "hold" || resolved.type !== "cubic-bezier") return void 0;
1426
+ return [resolved.x1, resolved.y1, resolved.x2, resolved.y2];
1427
+ }
1428
+ var overshoots = (ease) => typeof ease !== "string" && (ease[1] < 0 || ease[1] > 1 || ease[3] < 0 || ease[3] > 1);
1234
1429
  var TRANSFORM_PROPS2 = ["x", "y", "rotation", "scaleX", "scaleY", "follow"];
1235
1430
  var LINEAR_TRANSFORM = /* @__PURE__ */ new Set(["x", "y", "rotation", "scaleX", "scaleY"]);
1236
1431
  function hex6(color) {
@@ -1240,6 +1435,9 @@ function hex6(color) {
1240
1435
  function toFlairSvgMotion(scene, options = {}) {
1241
1436
  const fps = options.fps ?? scene.fps ?? 30;
1242
1437
  const precision = options.precision ?? 3;
1438
+ const curves = options.curves ?? true;
1439
+ const behaviors = options.behaviors ?? true;
1440
+ const plays = (s) => !s.behavior || behaviors;
1243
1441
  const round2 = (n) => Math.round(n * 10 ** precision) / 10 ** precision;
1244
1442
  const f = (n) => String(round2(n));
1245
1443
  const timeline = compileMotionScene(scene);
@@ -1254,25 +1452,72 @@ function toFlairSvgMotion(scene, options = {}) {
1254
1452
  };
1255
1453
  const animatedNodes = new Set(timeline.segments.map((s) => s.node).filter(Boolean));
1256
1454
  const pathsSeen = /* @__PURE__ */ new Set();
1455
+ const wrap = {};
1456
+ const routes = [];
1457
+ const follows = [];
1458
+ const usedIds = new Set(nodes.keys());
1459
+ const freshId = (base) => {
1460
+ let id = base;
1461
+ for (let n = 2; usedIds.has(id); n++) id = `${base}-${n}`;
1462
+ usedIds.add(id);
1463
+ return id;
1464
+ };
1257
1465
  for (const id of animatedNodes) {
1258
1466
  const entry = nodes.get(id);
1259
1467
  const node = entry.node;
1468
+ const drawn = [...entry.ancestors.map((a) => nodes.get(a).node), node].every((n) => n.visible !== false);
1469
+ for (const [p, name] of [["clipRect", "reveal"], ["clipCircle", "iris"]]) {
1470
+ if (!drawn || !timeline.tracks.has(motionTrackKey(id, p))) continue;
1471
+ const wrapper = freshId(`${id}--${name}`);
1472
+ const clipId = freshId(`${wrapper}-clip`);
1473
+ const shapeId = freshId(`${wrapper}-clip-shape`);
1474
+ const clipAt = (t) => motionNodeClips(id, timeline, t).find((c) => c.shape === (p === "clipRect" ? "rect" : "circle"));
1475
+ const rest = clipAt(0);
1476
+ (wrap[id] ??= []).push({ id: wrapper, clip: { id: clipId, shapeId, shape: rest.shape, attrs: motionClipAttrs(rest) } });
1477
+ 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) });
1478
+ }
1479
+ const followSegments = track(id, "follow");
1480
+ if (drawn && followSegments.length) {
1481
+ const ids = /* @__PURE__ */ new Map();
1482
+ for (const seg of followSegments) {
1483
+ if (!seg.path || ids.has(seg.path)) continue;
1484
+ const route = motionFollowRoute(entry, nodes.get(seg.path));
1485
+ if (!route) continue;
1486
+ const routeId = freshId(`${id}--follow-route`);
1487
+ ids.set(seg.path, routeId);
1488
+ routes.push(`<path id="${routeId}" d="${bezierPathToSvg(route, { precision })}" fill="none"/>`);
1489
+ }
1490
+ if (ids.size) {
1491
+ const wrapper = freshId(`${id}--follow`);
1492
+ const centre = freshId(`${id}--follow-centre`);
1493
+ const c = entry.center;
1494
+ const at = motionNodeOffset(entry, timeline, 0, nodes)?.follow;
1495
+ const placed = at ? `translate(${f(at.x)} ${f(at.y)})${at.angle ? ` rotate(${f(at.angle)})` : ""}` : void 0;
1496
+ (wrap[id] ??= []).push({ id: wrapper, ...placed ? { transform: placed } : {} }, { id: centre, transform: `translate(${f(-c.x)} ${f(-c.y)})` });
1497
+ follows.push({ id, wrapper, routes: ids, segments: followSegments.filter((seg) => seg.path && ids.has(seg.path)) });
1498
+ }
1499
+ }
1260
1500
  if (timeline.tracks.has(motionTrackKey(id, "opacity")))
1261
- 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))))) });
1262
- const transformSegments = TRANSFORM_PROPS2.flatMap((p) => track(id, p));
1501
+ 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))))) });
1502
+ const followed = follows.some((x) => x.id === id);
1503
+ const transformSegments = TRANSFORM_PROPS2.flatMap((p) => followed && p === "follow" ? [] : track(id, p));
1263
1504
  if (transformSegments.length) {
1264
1505
  const base = node.transform ? ` matrix(${node.transform.map(f).join(" ")})` : "";
1265
1506
  const c = entry.center;
1507
+ const transformAt = (t, raw) => {
1508
+ const o = motionNodeOffset(entry, timeline, t, nodes, raw, !followed);
1509
+ 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}`;
1510
+ };
1266
1511
  channels.push({
1267
1512
  id,
1268
1513
  kind: "attr",
1269
1514
  attr: "transform",
1270
1515
  segments: transformSegments,
1271
- exact: (s) => LINEAR_TRANSFORM.has(s.property) && !s.wave,
1272
- value: (t) => {
1273
- const o = motionNodeOffset(entry, timeline, t, nodes);
1274
- 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}`;
1275
- }
1516
+ exact: (s) => LINEAR_TRANSFORM.has(s.property) && plays(s),
1517
+ overshoot: true,
1518
+ // x, y and the angle are the transform's numbers 0, 1 and 2 (`MotionSegment.index`).
1519
+ value: (t) => transformAt(t, false),
1520
+ raw: (t) => transformAt(t, true)
1276
1521
  });
1277
1522
  }
1278
1523
  const paths = node.type === "path" ? [node] : collectPathNodes(node);
@@ -1281,7 +1526,7 @@ function toFlairSvgMotion(scene, options = {}) {
1281
1526
  pathsSeen.add(path.id);
1282
1527
  const paint = (p, attr) => {
1283
1528
  const from = owner(path, p);
1284
- if (from) channels.push({ id: path.id, kind: "attr", attr, segments: [...track(from, p)], exact: () => true, value: (t) => hex6(trackValue(track(from, p), t)) });
1529
+ 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)) });
1285
1530
  };
1286
1531
  paint("fill", "fill");
1287
1532
  paint("stroke", "stroke");
@@ -1316,19 +1561,26 @@ function toFlairSvgMotion(scene, options = {}) {
1316
1561
  }
1317
1562
  }
1318
1563
  const camera = track("", "camera");
1319
- if (camera.length) channels.push({ id: "", kind: "view", segments: [...camera], exact: () => true, value: (t) => trackValue(camera, t).map(round2) });
1564
+ if (camera.length) channels.push({ id: "", kind: "view", segments: [...camera], exact: () => true, overshoot: true, value: (t) => trackValue(camera, t).map(round2) });
1320
1565
  const animate = [];
1321
1566
  let sampled = 0;
1322
1567
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
1323
- const emit = (channel, delay, ms, from, to, ease) => {
1324
- const base = { ...channel.id ? { id: channel.id } : {}, ...delay > 0 ? { delay: round2(delay) } : {}, ms: round2(ms), ease };
1568
+ const emit = (channel, delay, ms, from, to, ease, behavior) => {
1569
+ const base = { ...channel.id ? { id: channel.id } : {}, ...delay > 0 ? { delay: round2(delay) } : {}, ms: round2(ms), ease: typeof ease === "string" ? ease : ease.map(round2), ...behavior ? { behavior } : {} };
1325
1570
  if (channel.kind === "view") animate.push({ ...base, view: to });
1326
1571
  else if (channel.kind === "draw") animate.push({ ...base, draw: [from, to] });
1327
- else animate.push({ ...base, attrs: { [channel.attr]: [from, to] } });
1572
+ else if (channel.kind === "attrs") {
1573
+ const [a, b] = [from, to];
1574
+ const attrs = Object.fromEntries(channel.attrs.flatMap((name, k) => a[k] !== b[k] ? [[name, [a[k], b[k]]]] : []));
1575
+ animate.push({ ...base, attrs });
1576
+ } else animate.push({ ...base, attrs: { [channel.attr]: [from, to] } });
1328
1577
  };
1329
1578
  for (const channel of channels) {
1330
1579
  const cuts = channel.segments.filter((s) => s.end <= s.start);
1331
- for (const cut of cuts) emit(channel, cut.start, 0, channel.value(Math.max(0, cut.start - 1e-3)), channel.value(cut.start), "linear");
1580
+ for (const cut of cuts) {
1581
+ const [from, to] = [channel.value(Math.max(0, cut.start - 1e-3)), channel.value(cut.start)];
1582
+ if (cut.start <= 0 || !same(from, to) || channel.kind === "view") emit(channel, cut.start, 0, from, to, "linear");
1583
+ }
1332
1584
  const times = [...new Set(channel.segments.flatMap((s) => [s.start, s.end]))].sort((a, b) => a - b);
1333
1585
  for (let i = 0; i + 1 < times.length; i++) {
1334
1586
  const a = times[i];
@@ -1336,12 +1588,15 @@ function toFlairSvgMotion(scene, options = {}) {
1336
1588
  const active = channel.segments.filter((s) => s.start < b && s.end > a);
1337
1589
  if (!active.length) continue;
1338
1590
  const first = active[0];
1339
- const together = active.every((x) => x.start === a && x.end === b && channel.exact(x) && JSON.stringify(x.ease) === JSON.stringify(first.ease));
1340
- const ease = together ? flairEaseFor(first.ease) : void 0;
1591
+ const together = active.every(
1592
+ (x) => x.start === a && x.end === b && channel.exact(x) && JSON.stringify(x.ease) === JSON.stringify(first.ease) && JSON.stringify(x.behavior) === JSON.stringify(first.behavior)
1593
+ );
1594
+ const curve = together ? flairCurveFor(first.ease, curves) : void 0;
1595
+ const ease = curve && (!overshoots(curve) || channel.overshoot) ? curve : void 0;
1341
1596
  if (ease) {
1342
1597
  const from = channel.value(a);
1343
- const to = channel.value(b);
1344
- if (!same(from, to) || channel.kind === "view") emit(channel, a, b - a, from, to, ease);
1598
+ const to = first.behavior ? (channel.raw ?? channel.value)(b) : channel.value(b);
1599
+ if (!same(from, to) || channel.kind === "view") emit(channel, a, b - a, from, to, ease, first.behavior);
1345
1600
  continue;
1346
1601
  }
1347
1602
  const n = Math.max(1, Math.ceil((b - a) * fps / 1e3));
@@ -1358,8 +1613,29 @@ function toFlairSvgMotion(scene, options = {}) {
1358
1613
  }
1359
1614
  }
1360
1615
  }
1616
+ for (const follow of follows) {
1617
+ const starts = follow.segments.map((seg) => seg.start);
1618
+ for (const seg of follow.segments) {
1619
+ const along = { id: follow.wrapper, along: follow.routes.get(seg.path), ...seg.rotate ? { orient: true } : {} };
1620
+ const progress = (t) => round2(Math.min(1, Math.max(0, Number(segmentValue(seg, t)))));
1621
+ const curve = seg.end > seg.start ? flairCurveFor(seg.ease, curves) : "linear";
1622
+ if (curve && !(overshoots(curve) && seg.rotate)) {
1623
+ animate.push({ ...along, ...seg.start > 0 ? { delay: round2(seg.start) } : {}, ms: round2(seg.end - seg.start), ease: typeof curve === "string" ? curve : curve.map(round2), at: [round2(Number(seg.from)), round2(Number(seg.to))] });
1624
+ continue;
1625
+ }
1626
+ const until = Math.min(seg.end, ...starts.filter((t) => t > seg.start));
1627
+ const n = Math.max(1, Math.ceil((until - seg.start) * fps / 1e3));
1628
+ for (let k = 1; k <= n; k++) {
1629
+ const t0 = seg.start + (until - seg.start) * (k - 1) / n;
1630
+ const t1 = seg.start + (until - seg.start) * k / n;
1631
+ animate.push({ ...along, ...t0 > 0 ? { delay: round2(t0) } : {}, ms: round2(t1 - t0), ease: "linear", at: [progress(t0), progress(t1)] });
1632
+ sampled++;
1633
+ }
1634
+ }
1635
+ }
1361
1636
  animate.sort((x, y) => (x.delay ?? 0) - (y.delay ?? 0));
1362
- let svg = vectorDocumentToSvg({ ...scene.artwork, nodes: expandVectorAppearances(scene.artwork.nodes) }, { pretty: false, precision });
1637
+ let svg = vectorDocumentToSvg({ ...scene.artwork, nodes: expandVectorAppearances(scene.artwork.nodes) }, { pretty: false, precision, wrap });
1638
+ if (routes.length) svg = svg.replace(/^(<svg[^>]*>)/, `$1<defs>${routes.join("")}</defs>`);
1363
1639
  const lastChange = animate.reduce((m, a) => Math.max(m, (a.delay ?? 0) + (a.ms ?? 0)), 0);
1364
1640
  let motion = { svg, animate };
1365
1641
  if (scene.controls?.length) {
@@ -1668,13 +1944,20 @@ var MOTION_COMMAND_SCHEMA = {
1668
1944
  description: `A command (one of ${MOTION_COMMAND_TYPES.join(", ")}). Commands in a shot play one after another unless "with": "previous".`,
1669
1945
  oneOf: [
1670
1946
  command("enter", "Brings hidden targets in; they are hidden before this shot.", {
1671
- style: { type: "string", enum: [...MOTION_ENTER_STYLES], description: "fade (default), pop, grow, slide, or draw (outline drawn on, then the fill fades in)." },
1672
- direction: { type: "string", enum: [...MOTION_DIRECTIONS], description: "For slide: direction of travel (default up)." },
1673
- distance: { type: "number", description: "For slide: document units (default 40)." }
1947
+ style: {
1948
+ type: "string",
1949
+ enum: [...MOTION_ENTER_STYLES],
1950
+ 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)."
1951
+ },
1952
+ 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)." },
1953
+ distance: { type: "number", description: "For slide (default 40) and rise (default 20): document units." },
1954
+ scale: { type: "number", minimum: 0, description: "For pop (default 0.5) and zoom (default 1.6): the starting scale (1 = rest size)." },
1955
+ 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." },
1956
+ 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." }
1674
1957
  }),
1675
1958
  command("exit", "Takes targets out.", {
1676
- style: { type: "string", enum: [...MOTION_EXIT_STYLES], description: "fade (default), shrink, slide or undraw." },
1677
- direction: { type: "string", enum: [...MOTION_DIRECTIONS], description: "For slide: direction of travel (default down)." },
1959
+ 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)." },
1960
+ 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)." },
1678
1961
  distance: { type: "number", description: "For slide: document units (default 40)." }
1679
1962
  }),
1680
1963
  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).") }),
@@ -1690,12 +1973,30 @@ var MOTION_COMMAND_SCHEMA = {
1690
1973
  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"]),
1691
1974
  command("follow", "Moves targets along a path node, centre on the path.", {
1692
1975
  path: { type: "string", minLength: 1, description: "Id of a path node (it can be hidden)." },
1693
- rotate: { type: "boolean", description: "Turn with the path (default false)." },
1976
+ rotate: { type: "boolean", description: "Turn with the path (default false); moves and shakes made meanwhile turn with it too." },
1694
1977
  from: FRACTION("Where to start along the path (default 0)."),
1695
1978
  to: FRACTION("Where to end along the path (default 1).")
1696
1979
  }, ["path"]),
1697
1980
  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)." } }),
1698
- command("shake", "Shakes side to side, settling.", { amount: { type: "number", description: "Document units (default 8)." }, count: { type: "integer", minimum: 1, description: "Shakes (default 3)." } }),
1981
+ command("shake", "Shakes side to side (or up and down), settling.", {
1982
+ amount: { type: "number", description: "Document units (default 8)." },
1983
+ count: { type: "integer", minimum: 1, description: "Shakes (default 3)." },
1984
+ axis: { type: "string", enum: ["horizontal", "vertical"], description: "Default horizontal." }
1985
+ }),
1986
+ 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)." } }),
1987
+ 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)." } }),
1988
+ 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.", {
1989
+ amount: { type: "number", description: "Up to about this far, in document units (default 6)." },
1990
+ frequency: { type: "number", exclusiveMinimum: 0, description: "Wanders per second, in Hz (default 3)." },
1991
+ octaves: { type: "integer", minimum: 1, maximum: 8, description: "Layers of finer detail (default 2)." },
1992
+ rotate: { type: "number", description: "Also turns about the centre by up to about this many degrees (default 0)." },
1993
+ seed: { type: "integer", minimum: 0, description: "Default: one picked from the target id, so each target wanders its own way." }
1994
+ }),
1995
+ command("color-pulse", "Tints solid paint toward a colour and back (mixed in OKLab). On a group it tints every path in it.", {
1996
+ color: { type: "string", description: 'The tint, a CSS colour (default "#f59e0b").' },
1997
+ paint: { type: "string", enum: ["fill", "stroke"], description: "Which paint (default fill)." },
1998
+ count: { type: "integer", minimum: 1, description: "Pulses (default 1)." }
1999
+ }),
1699
2000
  command("effect", "Tweens a numeric parameter of a live path effect on the targets (twist angle, wiggle time, zig zag size, trim offset\u2026).", {
1700
2001
  effect: { type: "string", minLength: 1, description: `Effect id from get_animation's element "effects".` },
1701
2002
  param: { type: "string", minLength: 1, description: "A numeric parameter of that effect (listed with it)." },
@@ -1792,7 +2093,8 @@ function describeMotionShots(scene) {
1792
2093
  ...shot.hold !== void 0 ? { hold: shot.hold } : {},
1793
2094
  commands: shot.run.map((c, index) => {
1794
2095
  const ct = timeline.commands.find((x) => x.shotIndex === i && x.command === index);
1795
- return { index, ...c, start: ct.start, end: ct.end };
2096
+ const primitive = motionFlairPrimitive(c);
2097
+ return { index, ...c, start: ct.start, end: ct.end, ...primitive ? { flairPrimitive: primitive } : {} };
1796
2098
  })
1797
2099
  };
1798
2100
  });
@@ -1828,7 +2130,7 @@ function createAnimationEditorToolProvider(controller, options = {}) {
1828
2130
  const getAnimation = {
1829
2131
  name: "get_animation",
1830
2132
  title: "Read the animation",
1831
- 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.",
2133
+ 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.`,
1832
2134
  inputSchema: object({ includeElements: { type: "boolean", description: "Include the artwork's elements (default true)." } }),
1833
2135
  annotations: { title: "Read the animation", ...READ },
1834
2136
  handler: ({ includeElements = true }) => {
@@ -1914,13 +2216,19 @@ function createAnimationEditorToolProvider(controller, options = {}) {
1914
2216
  return { time: t, svg };
1915
2217
  }
1916
2218
  };
2219
+ 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.";
2220
+ 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.";
1917
2221
  const exportFlairMotion = {
1918
2222
  name: "export_flair_motion",
1919
2223
  title: "Export as Flair vector motion",
1920
2224
  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.",
1921
- inputSchema: object({ fps: { type: "number", minimum: 1, maximum: 120, description: "Sampling rate for changes Flair cannot express exactly (default: the scene's fps, else 30)." } }),
2225
+ inputSchema: object({
2226
+ fps: { type: "number", minimum: 1, maximum: 120, description: "Sampling rate for changes Flair cannot express exactly (default: the scene's fps, else 30)." },
2227
+ curves: { type: "boolean", description: CURVES_DESCRIPTION },
2228
+ behaviors: { type: "boolean", description: BEHAVIORS_DESCRIPTION }
2229
+ }),
1922
2230
  annotations: { title: "Export as Flair vector motion", ...READ },
1923
- handler: ({ fps }) => controller.toFlairMotion(fps ? { fps } : {})
2231
+ handler: ({ fps, curves, behaviors }) => controller.toFlairMotion({ ...fps ? { fps } : {}, ...curves === void 0 ? {} : { curves }, ...behaviors === void 0 ? {} : { behaviors } })
1924
2232
  };
1925
2233
  const exportFlairEffect = {
1926
2234
  name: "export_flair_effect",
@@ -1931,7 +2239,9 @@ function createAnimationEditorToolProvider(controller, options = {}) {
1931
2239
  name: { type: "string", minLength: 1, description: "Display name (default: the scene's name)." },
1932
2240
  description: { type: "string", description: "One sentence (default: the scene's description)." },
1933
2241
  category: { type: "string", minLength: 1, description: "Flair catalog category, e.g. icon, loader, ui, data, text, morph (default icon)." },
1934
- tags: { type: "array", items: { type: "string" }, description: "Search tags (default none)." }
2242
+ tags: { type: "array", items: { type: "string" }, description: "Search tags (default none)." },
2243
+ curves: { type: "boolean", description: CURVES_DESCRIPTION },
2244
+ behaviors: { type: "boolean", description: BEHAVIORS_DESCRIPTION }
1935
2245
  }),
1936
2246
  annotations: { title: "Export as a Flair effect", ...READ },
1937
2247
  handler: (args) => controller.toFlairEffect(args)
@@ -2143,8 +2453,12 @@ How it plays
2143
2453
 
2144
2454
  Commands
2145
2455
  - enter (fade, pop, grow, slide, draw) and exit (fade, shrink, slide, undraw) bring things in and out.
2456
+ - 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.
2146
2457
  - 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.
2147
- - 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.
2458
+ - enter also has zoom (settles from larger) and rise (rises a short way, overshooting a touch); pop and zoom take a starting "scale".
2459
+ - 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).
2460
+ - 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).
2461
+ - 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.
2148
2462
  - effect tweens a numeric parameter of a live path effect already on the node (twist angle, wiggle time, zig zag size).
2149
2463
 
2150
2464
  Working method
@@ -2172,8 +2486,10 @@ export {
2172
2486
  MOTION_EDIT_OPS,
2173
2487
  MOTION_ENTER_STYLES,
2174
2488
  MOTION_EXIT_STYLES,
2489
+ MOTION_IRIS_SHAPES,
2175
2490
  MOTION_ISSUE_CODES,
2176
2491
  MOTION_MIN_CHANGE_MS,
2492
+ MOTION_SPLIT_ORIENTATIONS,
2177
2493
  MotionSceneFormatError,
2178
2494
  activeSegment,
2179
2495
  applyMotionControlValues,
@@ -2186,21 +2502,28 @@ export {
2186
2502
  describeMotionElements,
2187
2503
  describeMotionShots,
2188
2504
  fitMotionRect,
2505
+ flairCurveFor,
2189
2506
  flairEaseFor,
2190
2507
  freshShotId,
2191
2508
  indexMotionNodes,
2192
2509
  interpolateMotionValue,
2193
2510
  isMotionCommand,
2194
2511
  isMotionShot,
2512
+ motionClipAttrs,
2195
2513
  motionCommandDuration,
2514
+ motionFlairPrimitive,
2515
+ motionFollowRoute,
2196
2516
  motionFrameAt,
2197
2517
  motionFrameToSvg,
2198
2518
  motionIssue,
2199
2519
  motionIssueKey,
2200
2520
  motionIssueLine,
2521
+ motionNodeClips,
2201
2522
  motionNodeOffset,
2202
2523
  motionPathLength,
2203
2524
  motionRestValue,
2525
+ motionRevealClip,
2526
+ motionRevealPadding,
2204
2527
  motionSceneShapeProblems,
2205
2528
  motionSceneToJson,
2206
2529
  motionShotAt,