@pixerate/editor 0.68.0 → 0.70.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,14 +219,15 @@ ${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"];
226
+ var DRAW_ORDER_KEYS = ["order", "pace", "align", "sortBy", "origin"];
221
227
  var COMMAND_KEYS = {
222
- enter: [...BASE_KEYS, "style", "direction", "distance", "scale", "shape", "orientation"],
223
- exit: [...BASE_KEYS, "style", "direction", "distance"],
224
- draw: [...BASE_KEYS, "from", "to"],
228
+ enter: [...BASE_KEYS, ...DRAW_ORDER_KEYS, "style", "direction", "distance", "scale", "shape", "orientation"],
229
+ exit: [...BASE_KEYS, ...DRAW_ORDER_KEYS, "style", "direction", "distance"],
230
+ draw: [...BASE_KEYS, ...DRAW_ORDER_KEYS, "from", "to", "comet"],
225
231
  move: [...BASE_KEYS, "by", "to", "via", "pacing", "blend"],
226
232
  rotate: [...BASE_KEYS, "by", "to", "blend"],
227
233
  scale: [...BASE_KEYS, "to", "geometric", "blend"],
@@ -229,6 +235,7 @@ var COMMAND_KEYS = {
229
235
  style: [...BASE_KEYS, "fill", "stroke", "strokeWidth"],
230
236
  morph: [...BASE_KEYS, "into"],
231
237
  match: [...BASE_KEYS, "into"],
238
+ play: ["type", "targets", "delay", "stagger", "with", "note", "clip", "speed"],
232
239
  follow: [...BASE_KEYS, "path", "rotate", "from", "to"],
233
240
  pulse: [...BASE_KEYS, "amount", "count", "blend"],
234
241
  shake: [...BASE_KEYS, "amount", "count", "axis", "blend"],
@@ -264,6 +271,14 @@ function commandShapeProblems(command2, where) {
264
271
  if (command2[key] !== void 0 && !isNum(command2[key])) p(`"${key}" must be a number.`);
265
272
  };
266
273
  if (command2.with !== void 0 && command2.with !== "after" && command2.with !== "previous") p(`"with" must be "after" or "previous".`);
274
+ if (DRAW_ORDER_KEYS.some((k) => command2[k] !== void 0)) {
275
+ if (type === "enter" && command2.style !== "draw" || type === "exit" && command2.style !== "undraw") p(`"order", "pace", "align", "sortBy" and "origin" go with drawing: style "${type === "enter" ? "draw" : "undraw"}".`);
276
+ if (command2.order !== void 0 && command2.order !== "together" && command2.order !== "sequence") p(`"order" must be "together" or "sequence" (one stroke after another).`);
277
+ if (command2.pace !== void 0 && command2.pace !== "time" && command2.pace !== "speed") p(`"pace" must be "time" (each stroke takes the whole time) or "speed" (all at one speed).`);
278
+ if (command2.align !== void 0 && command2.align !== "start" && command2.align !== "end") p(`"align" must be "start" or "end".`);
279
+ if (command2.sortBy !== void 0 && command2.sortBy !== "document" && command2.sortBy !== "distance") p(`"sortBy" must be "document" or "distance".`);
280
+ if (command2.origin !== void 0 && !isPoint(command2.origin)) p(`"origin" must be { "x": number, "y": number } (document units).`);
281
+ }
267
282
  if (command2.note !== void 0 && typeof command2.note !== "string") p(`"note" must be a string.`);
268
283
  if (type === "wait") {
269
284
  if (!isNum(command2.duration) || command2.duration < 0) p(`"duration" must be a number of milliseconds \u2265 0.`);
@@ -297,6 +312,8 @@ function commandShapeProblems(command2, where) {
297
312
  case "draw":
298
313
  fraction("from");
299
314
  fraction("to");
315
+ fraction("comet");
316
+ if (command2.comet !== void 0 && (command2.from !== void 0 || command2.to !== void 0)) p(`"comet" runs the whole path; leave out "from" and "to".`);
300
317
  break;
301
318
  case "move":
302
319
  if (command2.by === void 0 && command2.to === void 0) p(`give "by" (an offset) or "to" (a point).`);
@@ -326,6 +343,10 @@ function commandShapeProblems(command2, where) {
326
343
  if (command2.stroke !== void 0 && typeof command2.stroke !== "string") p(`"stroke" must be a CSS colour.`);
327
344
  if (command2.strokeWidth !== void 0 && !(isNum(command2.strokeWidth) && command2.strokeWidth >= 0)) p(`"strokeWidth" must be a number \u2265 0.`);
328
345
  break;
346
+ case "play":
347
+ if (typeof command2.clip !== "string" || !command2.clip) p(`"clip" must be the name of one of the scene's clips.`);
348
+ 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).`);
349
+ break;
329
350
  case "morph":
330
351
  case "match":
331
352
  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"}.`);
@@ -363,6 +384,25 @@ function commandShapeProblems(command2, where) {
363
384
  }
364
385
  return problems;
365
386
  }
387
+ function clipsShapeProblems(clips) {
388
+ if (!isObject(clips)) return ['Scene: "clips" must be an object of named clips { "pop": { "run": [ \u2026 ] } }.'];
389
+ const problems = [];
390
+ for (const [name, clip] of Object.entries(clips)) {
391
+ const where = `Clip "${name}"`;
392
+ if (!/^[A-Za-z_][\w-]*$/.test(name)) problems.push(`${where}: a clip name is letters, digits, "_" or "-" (e.g. "pop-in").`);
393
+ if (!isObject(clip) || !Array.isArray(clip.run)) {
394
+ problems.push(`${where} must be { "description"?, "run": [commands] }.`);
395
+ continue;
396
+ }
397
+ unknownKeys(clip, ["description", "run"], where, problems);
398
+ if (clip.description !== void 0 && typeof clip.description !== "string") problems.push(`${where}: "description" must be a string.`);
399
+ clip.run.forEach((command2, i) => {
400
+ if (isObject(command2) && command2.type === "play") problems.push(`${where}, command ${i + 1}: a clip cannot play another clip; copy its commands in instead.`);
401
+ else problems.push(...commandShapeProblems(command2, `${where}, command ${i + 1}`));
402
+ });
403
+ }
404
+ return problems;
405
+ }
366
406
  function shotShapeProblems(shot, where) {
367
407
  if (!isObject(shot)) return [`${where} must be an object.`];
368
408
  const problems = [];
@@ -410,6 +450,7 @@ function motionSceneShapeProblems(input) {
410
450
  const art = input.artwork;
411
451
  if (!isObject(art) || !isNum(art.width) || !isNum(art.height) || !(art.width > 0) || !(art.height > 0) || !Array.isArray(art.nodes))
412
452
  problems.push('Scene: "artwork" must be a vector document { width, height, nodes }.');
453
+ if (input.clips !== void 0) problems.push(...clipsShapeProblems(input.clips));
413
454
  if (input.trigger !== void 0 && !MOTION_TRIGGERS.includes(input.trigger)) problems.push(`Scene: "trigger" must be one of ${MOTION_TRIGGERS.join(", ")}.`);
414
455
  if (input.loop !== void 0 && !["none", "loop", "ping-pong"].includes(input.loop)) problems.push('Scene: "loop" must be "none", "loop" or "ping-pong".');
415
456
  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.');
@@ -793,8 +834,38 @@ function motionCommandRepeat(command2) {
793
834
  function motionCommandDuration(command2, scene, target) {
794
835
  return motionCycleDuration(command2, scene, target) * motionCommandRepeat(command2);
795
836
  }
837
+ function motionClipLayout(scene, clip, target, speed = 1) {
838
+ const run = scene.clips?.[clip]?.run;
839
+ if (!run) return { items: [], length: 0 };
840
+ const f = speed > 0 && Number.isFinite(speed) ? 1 / speed : 1;
841
+ const items = [];
842
+ let runEnd = 0;
843
+ let prevStart = 0;
844
+ run.forEach((raw, i) => {
845
+ const base = raw.with === "previous" && i > 0 ? prevStart : runEnd;
846
+ if (raw.type === "wait") {
847
+ prevStart = base;
848
+ runEnd = Math.max(runEnd, base + raw.duration * f);
849
+ return;
850
+ }
851
+ if (raw.type === "play") return;
852
+ const command2 = { ...raw, targets: raw.targets.map((t) => t === MOTION_CLIP_TARGET ? target : t) };
853
+ const begin = base + (command2.delay ?? 0) * f;
854
+ let end = begin;
855
+ command2.targets.forEach((t, k) => {
856
+ const cycle = motionCycleDuration(command2, scene, t) * f;
857
+ const s = begin + k * (command2.stagger ?? 0) * f;
858
+ items.push({ command: command2, target: t, start: s, cycle });
859
+ end = Math.max(end, s + cycle * motionCommandRepeat(command2));
860
+ });
861
+ prevStart = begin;
862
+ runEnd = Math.max(runEnd, end);
863
+ });
864
+ return { items, length: runEnd };
865
+ }
796
866
  function motionCycleDuration(command2, scene, target) {
797
867
  if (command2.type === "wait") return command2.duration;
868
+ if (command2.type === "play") return motionClipLayout(scene, command2.clip, target ?? MOTION_CLIP_TARGET, command2.speed).length;
798
869
  if (command2.duration !== void 0) return command2.duration;
799
870
  if (command2.ease !== void 0) {
800
871
  const ease = resolveEasing(command2.ease);
@@ -884,6 +955,19 @@ function compileMotionScene(scene) {
884
955
  const shot = scene.shots[timing.shotIndex];
885
956
  const command2 = shot.run[timing.command];
886
957
  if (command2.type === "wait") continue;
958
+ if (command2.type === "play") {
959
+ const f = motionShotTimeScale(shot);
960
+ command2.targets.forEach((target, k) => {
961
+ if (!nodes.has(target)) return;
962
+ const at = timing.start + k * (command2.stagger ?? 0) * f;
963
+ for (const item of motionClipLayout(scene, command2.clip, target, (command2.speed ?? 1) / f).items) {
964
+ const entry = nodes.get(item.target);
965
+ if (!entry) continue;
966
+ jobs.push({ start: at + item.start, order: order++, run: () => compileRepeated(item.command, item.target, entry, at + item.start, item.cycle, timing) });
967
+ }
968
+ });
969
+ continue;
970
+ }
887
971
  command2.targets.forEach((target, k) => {
888
972
  const entry = nodes.get(target);
889
973
  if (!entry) return;
@@ -930,6 +1014,43 @@ function compileMotionScene(scene) {
930
1014
  return typeof v === "number" ? v : 0;
931
1015
  };
932
1016
  const ease = command2.ease;
1017
+ const drawWindows = (s, e, o) => {
1018
+ const order2 = o.order ?? "together";
1019
+ const pace = o.pace ?? "time";
1020
+ if (order2 === "together" && pace === "time") return null;
1021
+ const paths = entry.node.type === "path" ? [entry.node] : collectPathNodes(entry.node);
1022
+ let list = paths.map((p) => {
1023
+ const at = nodes.get(p.id);
1024
+ let first = { x: 0, y: 0 };
1025
+ try {
1026
+ const m = vectorPathWorldBezier(p, at?.parentWorld ?? IDENTITY_MATRIX, { effects: false })[0];
1027
+ if (m && m.type === "M") first = { x: m.x, y: m.y };
1028
+ } catch {
1029
+ }
1030
+ return { id: p.id, len: Math.max(1e-6, motionPathLength(p, at?.parentWorld)), first };
1031
+ });
1032
+ if (!list.length) return null;
1033
+ const span = Math.max(0, e - s);
1034
+ if (order2 === "sequence") {
1035
+ if (o.sortBy === "distance") {
1036
+ const b = entry.bounds;
1037
+ const origin = o.origin ?? (b ? { x: b.x + b.width / 2, y: b.y + b.height / 2 } : { x: 0, y: 0 });
1038
+ list = [...list].sort((a, c) => Math.hypot(a.first.x - origin.x, a.first.y - origin.y) - Math.hypot(c.first.x - origin.x, c.first.y - origin.y));
1039
+ }
1040
+ const total = list.reduce((sum, p) => sum + p.len, 0);
1041
+ let at = s;
1042
+ return list.map((p) => {
1043
+ const w = { id: p.id, start: at, end: at + span * p.len / total };
1044
+ at = w.end;
1045
+ return w;
1046
+ });
1047
+ }
1048
+ const longest = Math.max(...list.map((p) => p.len));
1049
+ return list.map((p) => {
1050
+ const d = span * p.len / longest;
1051
+ return o.align === "end" ? { id: p.id, start: e - d, end: e } : { id: p.id, start: s, end: s + d };
1052
+ });
1053
+ };
933
1054
  switch (command2.type) {
934
1055
  case "enter": {
935
1056
  const style = command2.style ?? "fade";
@@ -942,7 +1063,9 @@ function compileMotionScene(scene) {
942
1063
  break;
943
1064
  }
944
1065
  if (style === "draw") {
945
- seg("drawEnd", 1, ease ?? MOTION_EASE_IN_OUT, start, start + duration2 * 0.75, { from: 0 });
1066
+ const windows = drawWindows(start, start + duration2 * 0.75, command2);
1067
+ if (windows) for (const w of windows) push({ node: w.id, property: "drawEnd", start: w.start, end: w.end, from: 0, to: 1, ease: ease ?? MOTION_EASE_IN_OUT, ...where });
1068
+ else seg("drawEnd", 1, ease ?? MOTION_EASE_IN_OUT, start, start + duration2 * 0.75, { from: 0 });
946
1069
  seg("fillOpacity", 1, MOTION_EASE_OUT, start + duration2 * 0.6, end, { from: 0 });
947
1070
  break;
948
1071
  }
@@ -976,7 +1099,9 @@ function compileMotionScene(scene) {
976
1099
  if (wipe) seg(wipe.property, wipe.to, ease ?? "ease-in", start, end, { from: wipe.from });
977
1100
  else if (style === "undraw") {
978
1101
  seg("fillOpacity", 0, "ease-in", start, start + duration2 * 0.4);
979
- seg("drawEnd", 0, ease ?? MOTION_EASE_IN_OUT);
1102
+ const windows = drawWindows(start, end, command2);
1103
+ if (windows) for (const w of windows) push({ node: w.id, property: "drawEnd", start: w.start, end: w.end, from: valueAt(w.id, "drawEnd", w.start), to: 0, ease: ease ?? MOTION_EASE_IN_OUT, ...where });
1104
+ else seg("drawEnd", 0, ease ?? MOTION_EASE_IN_OUT);
980
1105
  } else if (style === "shrink") {
981
1106
  seg("scaleX", 0, ease ?? "ease-in");
982
1107
  seg("scaleY", 0, ease ?? "ease-in");
@@ -991,8 +1116,18 @@ function compileMotionScene(scene) {
991
1116
  break;
992
1117
  }
993
1118
  case "draw": {
994
- const drawn = tracks.has(motionTrackKey(node, "drawEnd"));
995
- seg("drawEnd", command2.to ?? 1, ease ?? MOTION_EASE_IN_OUT, start, end, command2.from !== void 0 ? { from: command2.from } : drawn ? {} : { from: 0 });
1119
+ const e = ease ?? MOTION_EASE_IN_OUT;
1120
+ const comet = command2.comet !== void 0 ? Math.min(1, Math.max(0, command2.comet)) : void 0;
1121
+ const windows = drawWindows(start, end, command2) ?? [{ id: node, start, end }];
1122
+ for (const w of windows) {
1123
+ if (comet !== void 0) {
1124
+ push({ node: w.id, property: "drawEnd", start: w.start, end: w.end, from: 0, to: 1 + comet, ease: e, ...where });
1125
+ push({ node: w.id, property: "drawStart", start: w.start, end: w.end, from: -comet, to: 1, ease: e, ...where });
1126
+ continue;
1127
+ }
1128
+ const drawn = tracks.has(motionTrackKey(w.id, "drawEnd"));
1129
+ push({ node: w.id, property: "drawEnd", start: w.start, end: w.end, from: command2.from ?? (drawn ? valueAt(w.id, "drawEnd", w.start) : 0), to: command2.to ?? 1, ease: e, ...where });
1130
+ }
996
1131
  break;
997
1132
  }
998
1133
  case "move": {
@@ -1437,7 +1572,11 @@ function visibleAt(timeline, id, time, node) {
1437
1572
  const circle = clip("clipCircle");
1438
1573
  if (Array.isArray(circle) && circle[2] <= 1e-4) return false;
1439
1574
  const hasFill = node.type === "path" ? !!node.fill && node.fill !== "none" : collectPathNodes(node).some((p) => p.fill && p.fill !== "none");
1440
- return num("drawEnd", 1) > 0.01 || hasFill && num("fillOpacity", 1) > 0.01;
1575
+ const drawn = num("drawEnd", 1) > 0.01 && (node.type === "path" || collectPathNodes(node).length === 0 || collectPathNodes(node).some((p) => {
1576
+ const v = trackValue(timeline.tracks.get(motionTrackKey(p.id, "drawEnd")), time);
1577
+ return typeof v !== "number" || v > 0.01;
1578
+ }));
1579
+ return drawn || hasFill && num("fillOpacity", 1) > 0.01;
1441
1580
  }
1442
1581
  function checkMotionScene(scene) {
1443
1582
  const issues = [];
@@ -1483,6 +1622,8 @@ function checkMotionScene(scene) {
1483
1622
  if (command2.type === "follow" && known(command2.path, "unknown-path", "path", where) && nodes.get(command2.path).node.type !== "path")
1484
1623
  issues.push(motionIssue("not-a-path", `"${command2.path}" is a group; follow a single path node.`, { ...where, ids: [command2.path] }));
1485
1624
  if (command2.type === "morph") known(command2.into, "unknown-shape", "shape", where);
1625
+ if (command2.type === "play" && !scene.clips?.[command2.clip])
1626
+ issues.push(motionIssue("unknown-clip", `There is no clip ${namedWithSuggestion(command2.clip, Object.keys(scene.clips ?? {}))}; define it with set_animation_clip.`, where));
1486
1627
  if (command2.type === "match" && known(command2.into, "unknown-shape", "group", where)) {
1487
1628
  const into = nodes.get(command2.into);
1488
1629
  for (const id of targets) {
@@ -1555,6 +1696,12 @@ function checkMotionScene(scene) {
1555
1696
  }
1556
1697
  });
1557
1698
  });
1699
+ for (const [name, clip] of Object.entries(scene.clips ?? {}))
1700
+ for (const command2 of clip.run)
1701
+ if (command2.type !== "wait") {
1702
+ for (const id of command2.targets)
1703
+ 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] }));
1704
+ }
1558
1705
  if (scene.trigger) {
1559
1706
  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.`, {}));
1560
1707
  if (scene.trigger === "click") {
@@ -1638,6 +1785,7 @@ var MOTION_EDIT_OPS = [
1638
1785
  "move_command",
1639
1786
  "set_artwork",
1640
1787
  "set_controls",
1788
+ "set_clip",
1641
1789
  "replace_scene"
1642
1790
  ];
1643
1791
  var EditError = class extends Error {
@@ -1694,6 +1842,23 @@ function applyOne(scene, edit) {
1694
1842
  else if (edit.target !== void 0) next.target = edit.target;
1695
1843
  return next;
1696
1844
  }
1845
+ case "set_clip": {
1846
+ if (typeof edit.name !== "string" || !edit.name) fail('Give the clip a "name".');
1847
+ const clips = { ...scene.clips ?? {} };
1848
+ if (edit.clip === null) {
1849
+ if (!clips[edit.name]) fail(`There is no clip ${namedWithSuggestion(edit.name, Object.keys(clips))} to delete.`);
1850
+ const playing = scene.shots.filter((s) => s.run.some((c) => c.type === "play" && c.clip === edit.name)).map((s) => `"${s.id}"`);
1851
+ if (playing.length) fail(`Clip "${edit.name}" is played in shot${playing.length > 1 ? "s" : ""} ${playing.join(", ")}; remove those play commands first.`);
1852
+ delete clips[edit.name];
1853
+ } else {
1854
+ const problems = clipsShapeProblems({ [edit.name]: edit.clip });
1855
+ if (problems.length) fail(problems.join(" "));
1856
+ clips[edit.name] = edit.clip;
1857
+ }
1858
+ const next = { ...scene, clips };
1859
+ if (!Object.keys(clips).length) delete next.clips;
1860
+ return next;
1861
+ }
1697
1862
  case "insert_shots": {
1698
1863
  if (!edit.shots.length) fail("Give at least one shot.");
1699
1864
  edit.shots.forEach((shot, i) => {
@@ -2582,11 +2747,13 @@ function toLottie(scene, options = {}) {
2582
2747
  const widthFrom = owner(path, "strokeWidth");
2583
2748
  const fadeFrom = owner(path, "fillOpacity");
2584
2749
  const drawFrom = owner(path, "drawEnd");
2585
- if (drawFrom) {
2586
- const draw = track(drawFrom, "drawEnd");
2587
- items.push({ ty: "tm", nm: "Draw on", s: { a: 0, k: 0 }, e: animate(draw, (t) => [Math.min(1, Math.max(0, Number(trackValue(draw, t) ?? 1))) * 100]), o: { a: 0, k: 0 }, m: 1 });
2750
+ const tailFrom = owner(path, "drawStart");
2751
+ if (drawFrom || tailFrom) {
2752
+ const pct = (track2, fallback) => (t) => [Math.min(1, Math.max(0, Number(trackValue(track2, t) ?? fallback))) * 100];
2753
+ const head = drawFrom ? track(drawFrom, "drawEnd") : [];
2754
+ const tail = tailFrom ? track(tailFrom, "drawStart") : [];
2755
+ items.push({ ty: "tm", nm: "Draw on", s: tail.length ? animate(tail, pct(tail, 0)) : { a: 0, k: 0 }, e: head.length ? animate(head, pct(head, 1)) : { a: 0, k: 100 }, o: { a: 0, k: 0 }, m: 1 });
2588
2756
  }
2589
- if (owner(path, "drawStart")) notes.push(`"${path.id}": drawing from a start other than 0 is not exported.`);
2590
2757
  const stroke = paintOf(path.stroke, "stroke", path.id);
2591
2758
  if (stroke || strokeFrom) {
2592
2759
  const colour = strokeFrom ? track(strokeFrom, "stroke") : [];
@@ -2972,11 +3139,19 @@ var BLEND = (fallback) => ({
2972
3139
  enum: [...MOTION_BLENDS],
2973
3140
  description: `"add" makes it a layer over the pose that stacks with other changes (a shake on a slide); "replace" makes it a change the next one takes over from (default ${fallback}).`
2974
3141
  });
3142
+ var DRAW_ORDER = {
3143
+ order: { type: "string", enum: ["together", "sequence"], description: "For drawing a group or several paths: together (default) or sequence (one stroke after another, each for a time in proportion to its length)." },
3144
+ pace: { type: "string", enum: ["time", "speed"], description: "With together: time (default, every stroke takes the whole time) or speed (all at one speed, so short strokes take less)." },
3145
+ align: { type: "string", enum: ["start", "end"], description: "With pace speed: start them together (default) or finish them together." },
3146
+ sortBy: { type: "string", enum: ["document", "distance"], description: "With sequence: stacking order (default) or distance from origin (a radial reveal)." },
3147
+ origin: object({ x: { type: "number", description: "Document units." }, y: { type: "number", description: "Document units." } }, ["x", "y"], "With sortBy distance: where strokes are drawn out from (default the target's centre).")
3148
+ };
2975
3149
  var command = (type, description, extra = {}, required = []) => object({ type: { const: type, description }, ...BASE, ...extra }, ["type", "targets", ...required], description);
2976
3150
  var MOTION_COMMAND_SCHEMA = {
2977
3151
  description: `A command (one of ${MOTION_COMMAND_TYPES.join(", ")}). Commands in a shot play one after another unless "with": "previous".`,
2978
3152
  oneOf: [
2979
3153
  command("enter", "Brings hidden targets in; they are hidden before this shot.", {
3154
+ ...DRAW_ORDER,
2980
3155
  style: {
2981
3156
  type: "string",
2982
3157
  enum: [...MOTION_ENTER_STYLES],
@@ -2989,11 +3164,12 @@ var MOTION_COMMAND_SCHEMA = {
2989
3164
  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." }
2990
3165
  }),
2991
3166
  command("exit", "Takes targets out.", {
3167
+ ...DRAW_ORDER,
2992
3168
  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)." },
2993
3169
  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)." },
2994
3170
  distance: { type: "number", description: "For slide: document units (default 40)." }
2995
3171
  }),
2996
- 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).") }),
3172
+ command("draw", "Draws strokes on (or off) to a fraction of their length.", { ...DRAW_ORDER, comet: FRACTION("A travelling stroke this fraction of the length long: it runs along the path and off the end (leave out from and to)."), to: FRACTION("Fraction drawn at the end (default 1)."), from: FRACTION("Fraction drawn at the start (default: what is drawn now).") }),
2997
3173
  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.") }),
2998
3174
  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." } }),
2999
3175
  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"]),
@@ -3003,6 +3179,20 @@ var MOTION_COMMAND_SCHEMA = {
3003
3179
  stroke: { type: "string", description: "CSS colour (hex, rgb(), rgba())." },
3004
3180
  strokeWidth: { type: "number", minimum: 0, description: "Document units." }
3005
3181
  }),
3182
+ object(
3183
+ {
3184
+ 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.' },
3185
+ targets: NODE_IDS,
3186
+ clip: { type: "string", minLength: 1, description: "The clip's name (get_animation lists them)." },
3187
+ speed: { type: "number", minimum: 0.05, maximum: 20, description: "Play it this many times as fast (default 1)." },
3188
+ delay: BASE.delay,
3189
+ stagger: BASE.stagger,
3190
+ with: BASE.with,
3191
+ note: BASE.note
3192
+ },
3193
+ ["type", "targets", "clip"],
3194
+ "Plays a clip on the targets."
3195
+ ),
3006
3196
  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"]),
3007
3197
  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"]),
3008
3198
  command("follow", "Moves targets along a path node, centre on the path.", {
@@ -3211,6 +3401,7 @@ function createAnimationEditorToolProvider(controller, options = {}) {
3211
3401
  ...s.target ? { target: s.target } : {},
3212
3402
  ...includeElements ? { elements: describeMotionElements(s) } : {},
3213
3403
  shots: describeMotionShots(s),
3404
+ ...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) }])) } : {},
3214
3405
  ...s.controls ? { controls: s.controls } : {},
3215
3406
  ...s.presets ? { presets: s.presets } : {},
3216
3407
  issues: issueSummary(controller.getState().issues),
@@ -3456,6 +3647,68 @@ function createAnimationEditorToolProvider(controller, options = {}) {
3456
3647
  return apply(edits, "Fit to length", () => report);
3457
3648
  }
3458
3649
  };
3650
+ const setClip = {
3651
+ name: "set_animation_clip",
3652
+ title: "Define a reusable clip",
3653
+ 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.`,
3654
+ inputSchema: object(
3655
+ {
3656
+ name: { type: "string", pattern: "^[A-Za-z_][\\w-]*$", description: 'Clip name, e.g. "pop-settle".' },
3657
+ clip: {
3658
+ 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" }],
3659
+ description: "The clip, or null to delete it (refused while a play command uses it)."
3660
+ }
3661
+ },
3662
+ ["name", "clip"]
3663
+ ),
3664
+ annotations: { title: "Define a reusable clip", ...WRITE_IDEMPOTENT },
3665
+ 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) })
3666
+ };
3667
+ const copyAnimation = {
3668
+ name: "copy_animation",
3669
+ title: "Copy one node's animation to others",
3670
+ 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.',
3671
+ inputSchema: object(
3672
+ {
3673
+ from: { type: "string", minLength: 1, description: "Element id whose commands to copy." },
3674
+ to: { type: "array", items: { type: "string", minLength: 1 }, minItems: 1, description: "Element ids to give the same motion." },
3675
+ shots: { type: "array", items: SHOT_ID, description: "Only these shots (default all)." },
3676
+ stagger: MS("Start each copy this much after the one before (default 0)")
3677
+ },
3678
+ ["from", "to"]
3679
+ ),
3680
+ annotations: { title: "Copy one node's animation to others", ...WRITE },
3681
+ handler: ({ from, to, shots, stagger }) => {
3682
+ const s = scene();
3683
+ const ids = describeMotionElements(s).map((e) => e.id);
3684
+ for (const id of [from, ...to]) if (!ids.includes(id)) throw new Error(`There is no element ${namedWithSuggestion(id, ids)}. Nothing was changed.`);
3685
+ if (to.includes(from)) throw new Error(`"to" includes "${from}" itself; give other elements. Nothing was changed.`);
3686
+ const only = shots ? new Set(shots) : null;
3687
+ if (only) {
3688
+ 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.`);
3689
+ }
3690
+ const copied = {};
3691
+ const edits = [];
3692
+ for (const shot of s.shots) {
3693
+ if (only && !only.has(shot.id)) continue;
3694
+ const run = [];
3695
+ let n = 0;
3696
+ for (const c of shot.run) {
3697
+ run.push(c);
3698
+ if (c.type === "wait" || !c.targets.includes(from)) continue;
3699
+ const { delay: _delay, ...rest } = c;
3700
+ run.push({ ...rest, targets: to, with: "previous", ...stagger ? { stagger } : {} });
3701
+ n++;
3702
+ }
3703
+ if (n) {
3704
+ copied[shot.id] = n;
3705
+ edits.push({ op: "update_shot", shot: shot.id, set: { run } });
3706
+ }
3707
+ }
3708
+ if (!edits.length) throw new Error(`No command animates "${from}"${only ? " in those shots" : ""}; nothing to copy.`);
3709
+ return apply(edits, "Copy animation", () => ({ copied }));
3710
+ }
3711
+ };
3459
3712
  const insertShots = {
3460
3713
  name: "insert_shots",
3461
3714
  title: "Add shots",
@@ -3632,7 +3885,7 @@ function createAnimationEditorToolProvider(controller, options = {}) {
3632
3885
  }
3633
3886
  };
3634
3887
  const reads = [getAnimation, getShot, findElements, getMotionPath, getTimingGrid, checkAnimation, renderFrame, exportFlairMotion, exportFlairEffect, exportLottie, seekAnimation];
3635
- const writes = [setArtworkSvg, fitAnimation, importFlairEffect, insertShots, updateShot, deleteShots, moveShot, addCommands, updateCommand, deleteCommands, moveCommand, setSettings, setControls, editAnimation, writeAnimation, undo, redo];
3888
+ const writes = [setArtworkSvg, setClip, copyAnimation, fitAnimation, importFlairEffect, insertShots, updateShot, deleteShots, moveShot, addCommands, updateCommand, deleteCommands, moveCommand, setSettings, setControls, editAnimation, writeAnimation, undo, redo];
3636
3889
  return {
3637
3890
  id: options.id ?? "animation",
3638
3891
  namespace: options.namespace,
@@ -3664,6 +3917,8 @@ Commands
3664
3917
  - enter (fade, pop, grow, slide, draw) and exit (fade, shrink, slide, undraw) bring things in and out.
3665
3918
  - 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.
3666
3919
  - 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.
3920
+ - Drawing a group of strokes: "order": "sequence" draws them one after another (a signature, a diagram), "sortBy": "distance" from "origin" reveals outward, "pace": "speed" keeps one speed; draw with "comet": 0.2 sends a short stroke along a path and off the end.
3921
+ - 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.
3667
3922
  - 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.
3668
3923
  - enter also has zoom (settles from larger) and rise (rises a short way, overshooting a touch); pop and zoom take a starting "scale".
3669
3924
  - 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).
@@ -3803,6 +4058,10 @@ function defaultMotionCommand(type, targets, scene) {
3803
4058
  const into = other((e) => e.node.type === "group" && e.node.visible === false) ?? other((e) => e.node.type === "group");
3804
4059
  return into ? { type, ...base, into } : { error: "Match needs another group to become; group the end state (it can stay hidden)." };
3805
4060
  }
4061
+ case "play": {
4062
+ const clip = Object.keys(scene.clips ?? {})[0];
4063
+ return clip ? { type, ...base, clip } : { error: 'Play needs a clip; define one first (set_animation_clip), with "$" as its target.' };
4064
+ }
3806
4065
  case "follow": {
3807
4066
  const path = other((e) => e.node.type === "path" && e.node.visible === false) ?? other((e) => e.node.type === "path");
3808
4067
  return path ? { type, ...base, path } : { error: "Follow needs a path to travel along; draw one (it can stay hidden)." };
@@ -3966,6 +4225,7 @@ export {
3966
4225
  MOTION_ADDITIVE_PROPERTIES,
3967
4226
  MOTION_BLENDS,
3968
4227
  MOTION_CAMERA_MS,
4228
+ MOTION_CLIP_TARGET,
3969
4229
  MOTION_COMMAND_SCHEMA,
3970
4230
  MOTION_COMMAND_TYPES,
3971
4231
  MOTION_DEFAULT_DURATIONS,
@@ -3992,6 +4252,7 @@ export {
3992
4252
  applyMotionControlValues,
3993
4253
  applyMotionEdits,
3994
4254
  checkMotionScene,
4255
+ clipsShapeProblems,
3995
4256
  commandShapeProblems,
3996
4257
  compileMotionScene,
3997
4258
  createAnimationEditorToolProvider,
@@ -4020,6 +4281,7 @@ export {
4020
4281
  motionBeatAt,
4021
4282
  motionBeatTime,
4022
4283
  motionClipAttrs,
4284
+ motionClipLayout,
4023
4285
  motionCommandDuration,
4024
4286
  motionCommandFields,
4025
4287
  motionCommandRepeat,