@pixerate/editor 0.67.1 → 0.69.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.
@@ -56,6 +56,7 @@ import {
56
56
 
57
57
  // src/animation-editor/types.ts
58
58
  var MOTION_TRIGGERS = ["click", "hover", "press", "scroll"];
59
+ var MOTION_CLIP_TARGET = "$";
59
60
  var MOTION_COMMAND_TYPES = [
60
61
  "enter",
61
62
  "exit",
@@ -67,6 +68,7 @@ var MOTION_COMMAND_TYPES = [
67
68
  "style",
68
69
  "morph",
69
70
  "match",
71
+ "play",
70
72
  "follow",
71
73
  "pulse",
72
74
  "shake",
@@ -94,6 +96,8 @@ var MOTION_DEFAULT_DURATIONS = {
94
96
  style: 500,
95
97
  morph: 900,
96
98
  match: 900,
99
+ /** A clip's length comes from its commands (this is only a fallback). */
100
+ play: 600,
97
101
  follow: 1400,
98
102
  pulse: 600,
99
103
  shake: 500,
@@ -137,6 +141,7 @@ var MOTION_ISSUE_CODES = {
137
141
  "unknown-path": { severity: "error", wording: "A follow command names a path the artwork does not have." },
138
142
  "not-a-path": { severity: "error", wording: "A follow path must be a path node, not a group." },
139
143
  "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." },
144
+ "unknown-clip": { severity: "error", wording: "A play command names a clip the scene does not have." },
140
145
  "unknown-shape": { severity: "error", wording: "A morph names a shape the artwork does not have." },
141
146
  "match-self": { severity: "error", wording: "A match turns a group into itself or into a group it holds (or that holds it)." },
142
147
  "match-unpaired": { severity: "note", wording: `Parts of a match's "into" group have no partner in the target, so they do not appear.` },
@@ -214,7 +219,7 @@ ${problems.map((p) => `- ${p}`).join("\n")}`);
214
219
  this.problems = problems;
215
220
  }
216
221
  };
217
- var SCENE_KEYS = ["version", "name", "description", "artwork", "shots", "loop", "trigger", "fps", "steps", "beat", "narration", "target", "controls", "presets"];
222
+ var SCENE_KEYS = ["version", "name", "description", "artwork", "shots", "clips", "loop", "trigger", "fps", "steps", "beat", "narration", "target", "controls", "presets"];
218
223
  var SHOT_KEYS = ["id", "label", "camera", "at", "run", "hold", "speed"];
219
224
  var CAMERA_KEYS = ["x", "y", "width", "height", "frame", "padding", "ease", "ms", "geometric"];
220
225
  var BASE_KEYS = ["type", "targets", "duration", "delay", "stagger", "with", "ease", "note", "repeat", "repeatMode", "steps"];
@@ -222,13 +227,14 @@ var COMMAND_KEYS = {
222
227
  enter: [...BASE_KEYS, "style", "direction", "distance", "scale", "shape", "orientation"],
223
228
  exit: [...BASE_KEYS, "style", "direction", "distance"],
224
229
  draw: [...BASE_KEYS, "from", "to"],
225
- move: [...BASE_KEYS, "by", "to", "blend"],
230
+ move: [...BASE_KEYS, "by", "to", "via", "pacing", "blend"],
226
231
  rotate: [...BASE_KEYS, "by", "to", "blend"],
227
232
  scale: [...BASE_KEYS, "to", "geometric", "blend"],
228
233
  fade: [...BASE_KEYS, "to"],
229
234
  style: [...BASE_KEYS, "fill", "stroke", "strokeWidth"],
230
235
  morph: [...BASE_KEYS, "into"],
231
236
  match: [...BASE_KEYS, "into"],
237
+ play: ["type", "targets", "delay", "stagger", "with", "note", "clip", "speed"],
232
238
  follow: [...BASE_KEYS, "path", "rotate", "from", "to"],
233
239
  pulse: [...BASE_KEYS, "amount", "count", "blend"],
234
240
  shake: [...BASE_KEYS, "amount", "count", "axis", "blend"],
@@ -303,6 +309,9 @@ function commandShapeProblems(command2, where) {
303
309
  if (command2.by !== void 0 && command2.to !== void 0) p(`give "by" or "to", not both.`);
304
310
  if (command2.by !== void 0 && !isPoint(command2.by)) p(`"by" must be { "x": number, "y": number }.`);
305
311
  if (command2.to !== void 0 && !isPoint(command2.to)) p(`"to" must be { "x": number, "y": number }.`);
312
+ if (command2.via !== void 0 && !(Array.isArray(command2.via) && command2.via.length > 0 && command2.via.length <= 64 && command2.via.every(isPoint))) p(`"via" must be a list of 1 to 64 points { "x": number, "y": number } (document units).`);
313
+ if (command2.pacing !== void 0 && command2.pacing !== "even" && command2.pacing !== "equal") p(`"pacing" must be "even" (steady speed) or "equal" (each leg the same time).`);
314
+ if (command2.pacing !== void 0 && command2.via === void 0) p(`"pacing" goes with "via".`);
306
315
  break;
307
316
  case "rotate":
308
317
  if (command2.by === void 0 && command2.to === void 0) p(`give "by" or "to" (degrees).`);
@@ -323,6 +332,10 @@ function commandShapeProblems(command2, where) {
323
332
  if (command2.stroke !== void 0 && typeof command2.stroke !== "string") p(`"stroke" must be a CSS colour.`);
324
333
  if (command2.strokeWidth !== void 0 && !(isNum(command2.strokeWidth) && command2.strokeWidth >= 0)) p(`"strokeWidth" must be a number \u2265 0.`);
325
334
  break;
335
+ case "play":
336
+ if (typeof command2.clip !== "string" || !command2.clip) p(`"clip" must be the name of one of the scene's clips.`);
337
+ if (command2.speed !== void 0 && !(isNum(command2.speed) && command2.speed >= 0.05 && command2.speed <= 20)) p(`"speed" must be a number from 0.05 to 20 (2 plays the clip twice as fast).`);
338
+ break;
326
339
  case "morph":
327
340
  case "match":
328
341
  if (typeof command2.into !== "string" || !command2.into) p(`"into" must be the id of a ${type === "match" ? "group to become" : "node whose shape to take"}.`);
@@ -360,6 +373,25 @@ function commandShapeProblems(command2, where) {
360
373
  }
361
374
  return problems;
362
375
  }
376
+ function clipsShapeProblems(clips) {
377
+ if (!isObject(clips)) return ['Scene: "clips" must be an object of named clips { "pop": { "run": [ \u2026 ] } }.'];
378
+ const problems = [];
379
+ for (const [name, clip] of Object.entries(clips)) {
380
+ const where = `Clip "${name}"`;
381
+ if (!/^[A-Za-z_][\w-]*$/.test(name)) problems.push(`${where}: a clip name is letters, digits, "_" or "-" (e.g. "pop-in").`);
382
+ if (!isObject(clip) || !Array.isArray(clip.run)) {
383
+ problems.push(`${where} must be { "description"?, "run": [commands] }.`);
384
+ continue;
385
+ }
386
+ unknownKeys(clip, ["description", "run"], where, problems);
387
+ if (clip.description !== void 0 && typeof clip.description !== "string") problems.push(`${where}: "description" must be a string.`);
388
+ clip.run.forEach((command2, i) => {
389
+ if (isObject(command2) && command2.type === "play") problems.push(`${where}, command ${i + 1}: a clip cannot play another clip; copy its commands in instead.`);
390
+ else problems.push(...commandShapeProblems(command2, `${where}, command ${i + 1}`));
391
+ });
392
+ }
393
+ return problems;
394
+ }
363
395
  function shotShapeProblems(shot, where) {
364
396
  if (!isObject(shot)) return [`${where} must be an object.`];
365
397
  const problems = [];
@@ -407,6 +439,7 @@ function motionSceneShapeProblems(input) {
407
439
  const art = input.artwork;
408
440
  if (!isObject(art) || !isNum(art.width) || !isNum(art.height) || !(art.width > 0) || !(art.height > 0) || !Array.isArray(art.nodes))
409
441
  problems.push('Scene: "artwork" must be a vector document { width, height, nodes }.');
442
+ if (input.clips !== void 0) problems.push(...clipsShapeProblems(input.clips));
410
443
  if (input.trigger !== void 0 && !MOTION_TRIGGERS.includes(input.trigger)) problems.push(`Scene: "trigger" must be one of ${MOTION_TRIGGERS.join(", ")}.`);
411
444
  if (input.loop !== void 0 && !["none", "loop", "ping-pong"].includes(input.loop)) problems.push('Scene: "loop" must be "none", "loop" or "ping-pong".');
412
445
  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.');
@@ -503,6 +536,45 @@ function matchMotionNodes(from, into) {
503
536
  return out;
504
537
  }
505
538
 
539
+ // src/animation-editor/smooth.ts
540
+ function smoothMotionLegs(times, values) {
541
+ const n = values.length;
542
+ const slope = new Array(n).fill(0);
543
+ const secant = (i) => (values[i + 1] - values[i]) / Math.max(1e-9, times[i + 1] - times[i]);
544
+ for (let i = 1; i < n - 1; i++) {
545
+ const a = secant(i - 1);
546
+ const b = secant(i);
547
+ if (a * b <= 0) continue;
548
+ const ha = times[i] - times[i - 1];
549
+ const hb = times[i + 1] - times[i];
550
+ slope[i] = 3 * (ha + hb) / ((2 * hb + ha) / a + (hb + 2 * ha) / b);
551
+ }
552
+ const legs = [];
553
+ for (let i = 0; i + 1 < n; i++) {
554
+ const dt = times[i + 1] - times[i];
555
+ const dv = values[i + 1] - values[i];
556
+ if (Math.abs(dv) < 1e-9) {
557
+ legs.push({ start: times[i], end: times[i + 1], from: values[i], to: values[i + 1], ease: "linear" });
558
+ continue;
559
+ }
560
+ const y1 = slope[i] * dt / (3 * dv);
561
+ const y2 = 1 - slope[i + 1] * dt / (3 * dv);
562
+ legs.push({ start: times[i], end: times[i + 1], from: values[i], to: values[i + 1], ease: { type: "cubic-bezier", x1: 1 / 3, y1, x2: 2 / 3, y2 } });
563
+ }
564
+ return legs;
565
+ }
566
+ function smoothMotionTimes(points, start, duration, pacing = "even") {
567
+ const legs = points.slice(1).map((p, i) => pacing === "equal" ? 1 : Math.hypot(p.x - points[i].x, p.y - points[i].y));
568
+ const total = legs.reduce((a, b) => a + b, 0) || legs.length || 1;
569
+ const times = [start];
570
+ let acc = 0;
571
+ for (const l of legs) {
572
+ acc += total > 0 ? l : 1;
573
+ times.push(start + duration * acc / total);
574
+ }
575
+ return times;
576
+ }
577
+
506
578
  // src/animation-editor/compile.ts
507
579
  var indexCache = /* @__PURE__ */ new WeakMap();
508
580
  function indexMotionNodes(nodes) {
@@ -751,8 +823,38 @@ function motionCommandRepeat(command2) {
751
823
  function motionCommandDuration(command2, scene, target) {
752
824
  return motionCycleDuration(command2, scene, target) * motionCommandRepeat(command2);
753
825
  }
826
+ function motionClipLayout(scene, clip, target, speed = 1) {
827
+ const run = scene.clips?.[clip]?.run;
828
+ if (!run) return { items: [], length: 0 };
829
+ const f = speed > 0 && Number.isFinite(speed) ? 1 / speed : 1;
830
+ const items = [];
831
+ let runEnd = 0;
832
+ let prevStart = 0;
833
+ run.forEach((raw, i) => {
834
+ const base = raw.with === "previous" && i > 0 ? prevStart : runEnd;
835
+ if (raw.type === "wait") {
836
+ prevStart = base;
837
+ runEnd = Math.max(runEnd, base + raw.duration * f);
838
+ return;
839
+ }
840
+ if (raw.type === "play") return;
841
+ const command2 = { ...raw, targets: raw.targets.map((t) => t === MOTION_CLIP_TARGET ? target : t) };
842
+ const begin = base + (command2.delay ?? 0) * f;
843
+ let end = begin;
844
+ command2.targets.forEach((t, k) => {
845
+ const cycle = motionCycleDuration(command2, scene, t) * f;
846
+ const s = begin + k * (command2.stagger ?? 0) * f;
847
+ items.push({ command: command2, target: t, start: s, cycle });
848
+ end = Math.max(end, s + cycle * motionCommandRepeat(command2));
849
+ });
850
+ prevStart = begin;
851
+ runEnd = Math.max(runEnd, end);
852
+ });
853
+ return { items, length: runEnd };
854
+ }
754
855
  function motionCycleDuration(command2, scene, target) {
755
856
  if (command2.type === "wait") return command2.duration;
857
+ if (command2.type === "play") return motionClipLayout(scene, command2.clip, target ?? MOTION_CLIP_TARGET, command2.speed).length;
756
858
  if (command2.duration !== void 0) return command2.duration;
757
859
  if (command2.ease !== void 0) {
758
860
  const ease = resolveEasing(command2.ease);
@@ -764,6 +866,7 @@ function motionCycleDuration(command2, scene, target) {
764
866
  const ms = length * MOTION_DRAW_MS_PER_UNIT;
765
867
  return Math.round(Math.min(MOTION_DRAW_RANGE[1], Math.max(MOTION_DRAW_RANGE[0], ms)));
766
868
  }
869
+ if (command2.type === "move" && command2.via?.length) return MOTION_DEFAULT_DURATIONS.move + 400 * command2.via.length;
767
870
  return MOTION_DEFAULT_DURATIONS[command2.type];
768
871
  }
769
872
  var MOTION_EASE_OUT = { type: "cubic-bezier", x1: 1 / 3, y1: 1, x2: 2 / 3, y2: 1 };
@@ -841,6 +944,19 @@ function compileMotionScene(scene) {
841
944
  const shot = scene.shots[timing.shotIndex];
842
945
  const command2 = shot.run[timing.command];
843
946
  if (command2.type === "wait") continue;
947
+ if (command2.type === "play") {
948
+ const f = motionShotTimeScale(shot);
949
+ command2.targets.forEach((target, k) => {
950
+ if (!nodes.has(target)) return;
951
+ const at = timing.start + k * (command2.stagger ?? 0) * f;
952
+ for (const item of motionClipLayout(scene, command2.clip, target, (command2.speed ?? 1) / f).items) {
953
+ const entry = nodes.get(item.target);
954
+ if (!entry) continue;
955
+ jobs.push({ start: at + item.start, order: order++, run: () => compileRepeated(item.command, item.target, entry, at + item.start, item.cycle, timing) });
956
+ }
957
+ });
958
+ continue;
959
+ }
844
960
  command2.targets.forEach((target, k) => {
845
961
  const entry = nodes.get(target);
846
962
  if (!entry) return;
@@ -962,6 +1078,20 @@ function compileMotionScene(scene) {
962
1078
  dx = target.x - (entry.center.x + num("x"));
963
1079
  dy = target.y - (entry.center.y + num("y"));
964
1080
  }
1081
+ if (command2.via?.length) {
1082
+ const inv = invertMatrix(entry.parentWorld);
1083
+ const x0 = num("x");
1084
+ const y0 = num("y");
1085
+ const add = command2.blend === "add";
1086
+ const offsets = [{ x: 0, y: 0 }, ...command2.via.map((p) => applyMatrix(inv, p)).map((p) => ({ x: p.x - entry.center.x - x0, y: p.y - entry.center.y - y0 })), { x: dx, y: dy }];
1087
+ const times = smoothMotionTimes(offsets, start, end - start, command2.pacing ?? "even");
1088
+ for (const axis of ["x", "y"]) {
1089
+ const base = add ? 0 : axis === "x" ? x0 : y0;
1090
+ for (const leg of smoothMotionLegs(times, offsets.map((o) => o[axis])))
1091
+ seg(axis, base + leg.to, leg.ease, leg.start, leg.end, { from: base + leg.from, ...add ? { blend: "add" } : {} });
1092
+ }
1093
+ break;
1094
+ }
965
1095
  if (command2.blend === "add") {
966
1096
  seg("x", dx, e, start, end, { from: 0, blend: "add" });
967
1097
  seg("y", dy, e, start, end, { from: 0, blend: "add" });
@@ -1426,6 +1556,8 @@ function checkMotionScene(scene) {
1426
1556
  if (command2.type === "follow" && known(command2.path, "unknown-path", "path", where) && nodes.get(command2.path).node.type !== "path")
1427
1557
  issues.push(motionIssue("not-a-path", `"${command2.path}" is a group; follow a single path node.`, { ...where, ids: [command2.path] }));
1428
1558
  if (command2.type === "morph") known(command2.into, "unknown-shape", "shape", where);
1559
+ if (command2.type === "play" && !scene.clips?.[command2.clip])
1560
+ issues.push(motionIssue("unknown-clip", `There is no clip ${namedWithSuggestion(command2.clip, Object.keys(scene.clips ?? {}))}; define it with set_animation_clip.`, where));
1429
1561
  if (command2.type === "match" && known(command2.into, "unknown-shape", "group", where)) {
1430
1562
  const into = nodes.get(command2.into);
1431
1563
  for (const id of targets) {
@@ -1498,6 +1630,12 @@ function checkMotionScene(scene) {
1498
1630
  }
1499
1631
  });
1500
1632
  });
1633
+ for (const [name, clip] of Object.entries(scene.clips ?? {}))
1634
+ for (const command2 of clip.run)
1635
+ if (command2.type !== "wait") {
1636
+ for (const id of command2.targets)
1637
+ if (id !== MOTION_CLIP_TARGET && !nodes.has(id)) issues.push(motionIssue("unknown-target", `Clip "${name}" names ${namedWithSuggestion(id, [...nodes.keys()])}, which the artwork does not have; use "$" for the node the clip is played on.`, { ids: [id] }));
1638
+ }
1501
1639
  if (scene.trigger) {
1502
1640
  if ((scene.loop ?? "none") !== "none") issues.push(motionIssue("trigger-loop", `The scene plays on ${scene.trigger}, so its "${scene.loop}" loop does not apply; set loop to "none" or remove the trigger.`, {}));
1503
1641
  if (scene.trigger === "click") {
@@ -1581,6 +1719,7 @@ var MOTION_EDIT_OPS = [
1581
1719
  "move_command",
1582
1720
  "set_artwork",
1583
1721
  "set_controls",
1722
+ "set_clip",
1584
1723
  "replace_scene"
1585
1724
  ];
1586
1725
  var EditError = class extends Error {
@@ -1637,6 +1776,23 @@ function applyOne(scene, edit) {
1637
1776
  else if (edit.target !== void 0) next.target = edit.target;
1638
1777
  return next;
1639
1778
  }
1779
+ case "set_clip": {
1780
+ if (typeof edit.name !== "string" || !edit.name) fail('Give the clip a "name".');
1781
+ const clips = { ...scene.clips ?? {} };
1782
+ if (edit.clip === null) {
1783
+ if (!clips[edit.name]) fail(`There is no clip ${namedWithSuggestion(edit.name, Object.keys(clips))} to delete.`);
1784
+ const playing = scene.shots.filter((s) => s.run.some((c) => c.type === "play" && c.clip === edit.name)).map((s) => `"${s.id}"`);
1785
+ if (playing.length) fail(`Clip "${edit.name}" is played in shot${playing.length > 1 ? "s" : ""} ${playing.join(", ")}; remove those play commands first.`);
1786
+ delete clips[edit.name];
1787
+ } else {
1788
+ const problems = clipsShapeProblems({ [edit.name]: edit.clip });
1789
+ if (problems.length) fail(problems.join(" "));
1790
+ clips[edit.name] = edit.clip;
1791
+ }
1792
+ const next = { ...scene, clips };
1793
+ if (!Object.keys(clips).length) delete next.clips;
1794
+ return next;
1795
+ }
1640
1796
  case "insert_shots": {
1641
1797
  if (!edit.shots.length) fail("Give at least one shot.");
1642
1798
  edit.shots.forEach((shot, i) => {
@@ -2937,7 +3093,7 @@ var MOTION_COMMAND_SCHEMA = {
2937
3093
  distance: { type: "number", description: "For slide: document units (default 40)." }
2938
3094
  }),
2939
3095
  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).") }),
2940
- command("move", "Moves targets by an offset or so their centre lands on a point.", { blend: BLEND("replace"), by: POINT("Offset in document units."), to: POINT("Point (document coordinates) for the centre.") }),
3096
+ command("move", "Moves targets by an offset or so their centre lands on a point.", { via: { type: "array", items: POINT("A point the centre passes through."), minItems: 1, maxItems: 64, description: 'Waypoints (document units) the centre passes through on the way to "to" or "by", smoothly (velocity carries through each) and without overshooting any. Default length 700 ms + 400 per waypoint.' }, pacing: { type: "string", enum: ["even", "equal"], description: "With via: even (default) keeps the speed steady along the path; equal gives each stretch the same time." }, blend: BLEND("replace"), by: POINT("Offset in document units."), to: POINT("Point (document coordinates) for the centre.") }),
2941
3097
  command("rotate", "Turns targets about their centre (degrees, clockwise).", { blend: BLEND("replace"), by: { type: "number", description: "Degrees to turn by." }, to: { type: "number", description: "Angle to turn to, in degrees from rest." } }),
2942
3098
  command("scale", "Scales targets about their centre (1 = rest size).", { blend: BLEND("replace"), 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"]),
2943
3099
  command("fade", "Changes opacity.", { to: FRACTION("Opacity at the end (0\u20131).") }, ["to"]),
@@ -2946,6 +3102,20 @@ var MOTION_COMMAND_SCHEMA = {
2946
3102
  stroke: { type: "string", description: "CSS colour (hex, rgb(), rgba())." },
2947
3103
  strokeWidth: { type: "number", minimum: 0, description: "Document units." }
2948
3104
  }),
3105
+ object(
3106
+ {
3107
+ type: { const: "play", description: 'Plays a clip (set_animation_clip) on the targets: its commands, with "$" standing for each target, timed as in a shot.' },
3108
+ targets: NODE_IDS,
3109
+ clip: { type: "string", minLength: 1, description: "The clip's name (get_animation lists them)." },
3110
+ speed: { type: "number", minimum: 0.05, maximum: 20, description: "Play it this many times as fast (default 1)." },
3111
+ delay: BASE.delay,
3112
+ stagger: BASE.stagger,
3113
+ with: BASE.with,
3114
+ note: BASE.note
3115
+ },
3116
+ ["type", "targets", "clip"],
3117
+ "Plays a clip on the targets."
3118
+ ),
2949
3119
  command("match", `Smart Animate: tweens a group into another group (usually hidden), part by part. Parts pair by name, then by id with the group ids taken out (card-a-title \u2194 card-b-title), then by kind in order; each takes its partner's shape, paint and place. Unpaired parts fade out; parts of "into" with no partner do not appear (check_animation lists them).`, { into: { type: "string", minLength: 1, description: "Id of the group to become." } }, ["into"]),
2950
3120
  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"]),
2951
3121
  command("follow", "Moves targets along a path node, centre on the path.", {
@@ -3154,6 +3324,7 @@ function createAnimationEditorToolProvider(controller, options = {}) {
3154
3324
  ...s.target ? { target: s.target } : {},
3155
3325
  ...includeElements ? { elements: describeMotionElements(s) } : {},
3156
3326
  shots: describeMotionShots(s),
3327
+ ...s.clips ? { clips: Object.fromEntries(Object.entries(s.clips).map(([name, c]) => [name, { ...c.description ? { description: c.description } : {}, commands: c.run.length, length: Math.round(motionClipLayout(s, name, "$").length) }])) } : {},
3157
3328
  ...s.controls ? { controls: s.controls } : {},
3158
3329
  ...s.presets ? { presets: s.presets } : {},
3159
3330
  issues: issueSummary(controller.getState().issues),
@@ -3399,6 +3570,68 @@ function createAnimationEditorToolProvider(controller, options = {}) {
3399
3570
  return apply(edits, "Fit to length", () => report);
3400
3571
  }
3401
3572
  };
3573
+ const setClip = {
3574
+ name: "set_animation_clip",
3575
+ title: "Define a reusable clip",
3576
+ description: `Defines, replaces or (with clip null) deletes a named clip: commands written against the target "$" that a play command plays on any node, e.g. a "pop-settle" or "attention" used many times. One undo step. Returns the clip's length and the timing.`,
3577
+ inputSchema: object(
3578
+ {
3579
+ name: { type: "string", pattern: "^[A-Za-z_][\\w-]*$", description: 'Clip name, e.g. "pop-settle".' },
3580
+ clip: {
3581
+ anyOf: [object({ description: { type: "string", description: "What it does, in a few words." }, run: { type: "array", items: MOTION_COMMAND_SCHEMA, minItems: 1, description: 'Commands, timed as in a shot; target "$" for the node it is played on. Clips cannot play clips.' } }, ["run"]), { type: "null" }],
3582
+ description: "The clip, or null to delete it (refused while a play command uses it)."
3583
+ }
3584
+ },
3585
+ ["name", "clip"]
3586
+ ),
3587
+ annotations: { title: "Define a reusable clip", ...WRITE_IDEMPOTENT },
3588
+ handler: ({ name, clip }) => apply({ op: "set_clip", name, clip }, clip === null ? "Delete clip" : "Set clip", (s) => clip === null ? { deleted: name } : { clip: name, length: Math.round(motionClipLayout(s, name, "$").length) })
3589
+ };
3590
+ const copyAnimation = {
3591
+ name: "copy_animation",
3592
+ title: "Copy one node's animation to others",
3593
+ description: 'Copies every command that animates "from" (in all shots, or the ones given) so it also animates "to": each copy goes right after its original and starts with it. One undo step. Returns how many commands were copied per shot. For motion you will reuse often, a clip (set_animation_clip + play) is tidier.',
3594
+ inputSchema: object(
3595
+ {
3596
+ from: { type: "string", minLength: 1, description: "Element id whose commands to copy." },
3597
+ to: { type: "array", items: { type: "string", minLength: 1 }, minItems: 1, description: "Element ids to give the same motion." },
3598
+ shots: { type: "array", items: SHOT_ID, description: "Only these shots (default all)." },
3599
+ stagger: MS("Start each copy this much after the one before (default 0)")
3600
+ },
3601
+ ["from", "to"]
3602
+ ),
3603
+ annotations: { title: "Copy one node's animation to others", ...WRITE },
3604
+ handler: ({ from, to, shots, stagger }) => {
3605
+ const s = scene();
3606
+ const ids = describeMotionElements(s).map((e) => e.id);
3607
+ for (const id of [from, ...to]) if (!ids.includes(id)) throw new Error(`There is no element ${namedWithSuggestion(id, ids)}. Nothing was changed.`);
3608
+ if (to.includes(from)) throw new Error(`"to" includes "${from}" itself; give other elements. Nothing was changed.`);
3609
+ const only = shots ? new Set(shots) : null;
3610
+ if (only) {
3611
+ for (const id of only) if (!s.shots.some((x) => x.id === id)) throw new Error(`There is no shot ${namedWithSuggestion(id, s.shots.map((x) => x.id))}. Nothing was changed.`);
3612
+ }
3613
+ const copied = {};
3614
+ const edits = [];
3615
+ for (const shot of s.shots) {
3616
+ if (only && !only.has(shot.id)) continue;
3617
+ const run = [];
3618
+ let n = 0;
3619
+ for (const c of shot.run) {
3620
+ run.push(c);
3621
+ if (c.type === "wait" || !c.targets.includes(from)) continue;
3622
+ const { delay: _delay, ...rest } = c;
3623
+ run.push({ ...rest, targets: to, with: "previous", ...stagger ? { stagger } : {} });
3624
+ n++;
3625
+ }
3626
+ if (n) {
3627
+ copied[shot.id] = n;
3628
+ edits.push({ op: "update_shot", shot: shot.id, set: { run } });
3629
+ }
3630
+ }
3631
+ if (!edits.length) throw new Error(`No command animates "${from}"${only ? " in those shots" : ""}; nothing to copy.`);
3632
+ return apply(edits, "Copy animation", () => ({ copied }));
3633
+ }
3634
+ };
3402
3635
  const insertShots = {
3403
3636
  name: "insert_shots",
3404
3637
  title: "Add shots",
@@ -3575,7 +3808,7 @@ function createAnimationEditorToolProvider(controller, options = {}) {
3575
3808
  }
3576
3809
  };
3577
3810
  const reads = [getAnimation, getShot, findElements, getMotionPath, getTimingGrid, checkAnimation, renderFrame, exportFlairMotion, exportFlairEffect, exportLottie, seekAnimation];
3578
- const writes = [setArtworkSvg, fitAnimation, importFlairEffect, insertShots, updateShot, deleteShots, moveShot, addCommands, updateCommand, deleteCommands, moveCommand, setSettings, setControls, editAnimation, writeAnimation, undo, redo];
3811
+ const writes = [setArtworkSvg, setClip, copyAnimation, fitAnimation, importFlairEffect, insertShots, updateShot, deleteShots, moveShot, addCommands, updateCommand, deleteCommands, moveCommand, setSettings, setControls, editAnimation, writeAnimation, undo, redo];
3579
3812
  return {
3580
3813
  id: options.id ?? "animation",
3581
3814
  namespace: options.namespace,
@@ -3607,6 +3840,8 @@ Commands
3607
3840
  - enter (fade, pop, grow, slide, draw) and exit (fade, shrink, slide, undraw) bring things in and out.
3608
3841
  - 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.
3609
3842
  - 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.
3843
+ - Reuse motion: set_animation_clip defines a clip (commands targeting "$"), and { "type": "play", "clip": name, "targets": [...] } plays it on any nodes (with stagger and speed). copy_animation gives other nodes one node's motion.
3844
+ - move takes "via" waypoints for a curved, smooth path that passes exactly through each point (steady speed by default); use it instead of chaining moves, which stop at each point.
3610
3845
  - enter also has zoom (settles from larger) and rise (rises a short way, overshooting a touch); pop and zoom take a starting "scale".
3611
3846
  - morph turns a shape into another node's shape (keep the target hidden); match does the same for a whole group, part by part (Smart Animate: draw the end state as a second, hidden group whose parts share names with the first); follow moves along a path node (with "rotate", moves made meanwhile turn with the path).
3612
3847
  - 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).
@@ -3745,6 +3980,10 @@ function defaultMotionCommand(type, targets, scene) {
3745
3980
  const into = other((e) => e.node.type === "group" && e.node.visible === false) ?? other((e) => e.node.type === "group");
3746
3981
  return into ? { type, ...base, into } : { error: "Match needs another group to become; group the end state (it can stay hidden)." };
3747
3982
  }
3983
+ case "play": {
3984
+ const clip = Object.keys(scene.clips ?? {})[0];
3985
+ return clip ? { type, ...base, clip } : { error: 'Play needs a clip; define one first (set_animation_clip), with "$" as its target.' };
3986
+ }
3748
3987
  case "follow": {
3749
3988
  const path = other((e) => e.node.type === "path" && e.node.visible === false) ?? other((e) => e.node.type === "path");
3750
3989
  return path ? { type, ...base, path } : { error: "Follow needs a path to travel along; draw one (it can stay hidden)." };
@@ -3908,6 +4147,7 @@ export {
3908
4147
  MOTION_ADDITIVE_PROPERTIES,
3909
4148
  MOTION_BLENDS,
3910
4149
  MOTION_CAMERA_MS,
4150
+ MOTION_CLIP_TARGET,
3911
4151
  MOTION_COMMAND_SCHEMA,
3912
4152
  MOTION_COMMAND_TYPES,
3913
4153
  MOTION_DEFAULT_DURATIONS,
@@ -3934,6 +4174,7 @@ export {
3934
4174
  applyMotionControlValues,
3935
4175
  applyMotionEdits,
3936
4176
  checkMotionScene,
4177
+ clipsShapeProblems,
3937
4178
  commandShapeProblems,
3938
4179
  compileMotionScene,
3939
4180
  createAnimationEditorToolProvider,
@@ -3962,6 +4203,7 @@ export {
3962
4203
  motionBeatAt,
3963
4204
  motionBeatTime,
3964
4205
  motionClipAttrs,
4206
+ motionClipLayout,
3965
4207
  motionCommandDuration,
3966
4208
  motionCommandFields,
3967
4209
  motionCommandRepeat,
@@ -4012,6 +4254,8 @@ export {
4012
4254
  segmentProgress,
4013
4255
  segmentValue,
4014
4256
  shotShapeProblems,
4257
+ smoothMotionLegs,
4258
+ smoothMotionTimes,
4015
4259
  suggestName,
4016
4260
  toFlairEffectDocument,
4017
4261
  toFlairSvgMotion,