@pixerate/editor 0.60.0 → 0.62.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.
@@ -63,6 +63,7 @@ var MOTION_COMMAND_TYPES = [
63
63
  "fade",
64
64
  "style",
65
65
  "morph",
66
+ "match",
66
67
  "follow",
67
68
  "pulse",
68
69
  "shake",
@@ -89,6 +90,7 @@ var MOTION_DEFAULT_DURATIONS = {
89
90
  fade: 400,
90
91
  style: 500,
91
92
  morph: 900,
93
+ match: 900,
92
94
  follow: 1400,
93
95
  pulse: 600,
94
96
  shake: 500,
@@ -133,6 +135,8 @@ var MOTION_ISSUE_CODES = {
133
135
  "not-a-path": { severity: "error", wording: "A follow path must be a path node, not a group." },
134
136
  "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." },
135
137
  "unknown-shape": { severity: "error", wording: "A morph names a shape the artwork does not have." },
138
+ "match-self": { severity: "error", wording: "A match turns a group into itself or into a group it holds (or that holds it)." },
139
+ "match-unpaired": { severity: "note", wording: `Parts of a match's "into" group have no partner in the target, so they do not appear.` },
136
140
  "unknown-effect": { severity: "error", wording: "An effect command names an effect the node does not have." },
137
141
  "unknown-effect-param": { severity: "error", wording: "An effect command names a parameter that is not a number of that effect." },
138
142
  "unknown-frame": { severity: "error", wording: "A camera frames a node the artwork does not have." },
@@ -203,7 +207,7 @@ ${problems.map((p) => `- ${p}`).join("\n")}`);
203
207
  }
204
208
  };
205
209
  var SCENE_KEYS = ["version", "name", "description", "artwork", "shots", "loop", "fps", "steps", "target", "controls", "presets"];
206
- var SHOT_KEYS = ["id", "label", "camera", "run", "hold"];
210
+ var SHOT_KEYS = ["id", "label", "camera", "run", "hold", "speed"];
207
211
  var CAMERA_KEYS = ["x", "y", "width", "height", "frame", "padding", "ease", "ms", "geometric"];
208
212
  var BASE_KEYS = ["type", "targets", "duration", "delay", "stagger", "with", "ease", "note", "repeat", "repeatMode", "steps"];
209
213
  var COMMAND_KEYS = {
@@ -216,6 +220,7 @@ var COMMAND_KEYS = {
216
220
  fade: [...BASE_KEYS, "to"],
217
221
  style: [...BASE_KEYS, "fill", "stroke", "strokeWidth"],
218
222
  morph: [...BASE_KEYS, "into"],
223
+ match: [...BASE_KEYS, "into"],
219
224
  follow: [...BASE_KEYS, "path", "rotate", "from", "to"],
220
225
  pulse: [...BASE_KEYS, "amount", "count", "blend"],
221
226
  shake: [...BASE_KEYS, "amount", "count", "axis", "blend"],
@@ -311,7 +316,8 @@ function commandShapeProblems(command2, where) {
311
316
  if (command2.strokeWidth !== void 0 && !(isNum(command2.strokeWidth) && command2.strokeWidth >= 0)) p(`"strokeWidth" must be a number \u2265 0.`);
312
317
  break;
313
318
  case "morph":
314
- if (typeof command2.into !== "string" || !command2.into) p(`"into" must be the id of a node whose shape to take.`);
319
+ case "match":
320
+ 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"}.`);
315
321
  break;
316
322
  case "follow":
317
323
  if (typeof command2.path !== "string" || !command2.path) p(`"path" must be the id of a path node.`);
@@ -354,6 +360,7 @@ function shotShapeProblems(shot, where) {
354
360
  const name = typeof shot.id === "string" ? `Shot "${shot.id}"` : where;
355
361
  if (shot.label !== void 0 && typeof shot.label !== "string") problems.push(`${name}: "label" must be a string.`);
356
362
  if (shot.hold !== void 0 && !(isNum(shot.hold) && shot.hold >= 0)) problems.push(`${name}: "hold" must be a number of milliseconds \u2265 0.`);
363
+ if (shot.speed !== void 0 && !(isNum(shot.speed) && shot.speed >= 0.05 && shot.speed <= 20)) problems.push(`${name}: "speed" must be a number from 0.05 to 20 (2 plays it twice as fast).`);
357
364
  if (shot.camera !== void 0) {
358
365
  const c = shot.camera;
359
366
  if (!isObject(c)) problems.push(`${name}: "camera" must be an object.`);
@@ -420,6 +427,53 @@ function motionSceneToJson(scene, space = 2) {
420
427
  var isMotionShot = (shot) => shotShapeProblems(shot, "Shot").length === 0;
421
428
  var isMotionCommand = (command2) => commandShapeProblems(command2, "Command").length === 0;
422
429
 
430
+ // src/animation-editor/match.ts
431
+ var SEPARATOR = /[-_/.:]/;
432
+ function localId(id, groupId) {
433
+ if (groupId && id.includes(groupId)) return id.split(groupId).join("").replace(/^[-_/.:]+|[-_/.:]+$/g, "");
434
+ const parts = id.split(SEPARATOR);
435
+ return parts[parts.length - 1] ?? id;
436
+ }
437
+ function matchMotionNodes(from, into) {
438
+ const out = { pairs: [], unpairedFrom: [], unpairedInto: [] };
439
+ const visit = (a, b) => {
440
+ const aKids = a.type === "group" ? a.children : [];
441
+ const bKids = b.type === "group" ? b.children : [];
442
+ if (!aKids.length || !bKids.length) {
443
+ out.pairs.push([a.id, b.id]);
444
+ return;
445
+ }
446
+ const left = new Set(aKids);
447
+ const right = new Set(bKids);
448
+ const pair = (key) => {
449
+ for (const x of [...left]) {
450
+ const k = key(x, a);
451
+ if (!k) continue;
452
+ const y = [...right].find((n) => key(n, b) === k);
453
+ if (!y) continue;
454
+ left.delete(x);
455
+ right.delete(y);
456
+ if (x.type === "group" && y.type === "group") visit(x, y);
457
+ else out.pairs.push([x.id, y.id]);
458
+ }
459
+ };
460
+ pair((n) => n.name?.trim() || void 0);
461
+ pair((n, g) => localId(n.id, g.id) || void 0);
462
+ for (const x of [...left]) {
463
+ const y = [...right].find((n) => n.type === x.type);
464
+ if (!y) continue;
465
+ left.delete(x);
466
+ right.delete(y);
467
+ if (x.type === "group" && y.type === "group") visit(x, y);
468
+ else out.pairs.push([x.id, y.id]);
469
+ }
470
+ out.unpairedFrom.push(...[...left].map((n) => n.id));
471
+ out.unpairedInto.push(...[...right].map((n) => n.id));
472
+ };
473
+ visit(from, into);
474
+ return out;
475
+ }
476
+
423
477
  // src/animation-editor/compile.ts
424
478
  var indexCache = /* @__PURE__ */ new WeakMap();
425
479
  function indexMotionNodes(nodes) {
@@ -642,6 +696,9 @@ function motionPathLength(node, parentWorld = IDENTITY_MATRIX) {
642
696
  }
643
697
  return longest;
644
698
  }
699
+ function motionShotTimeScale(shot) {
700
+ return typeof shot.speed === "number" && shot.speed > 0 && Number.isFinite(shot.speed) ? 1 / shot.speed : 1;
701
+ }
645
702
  function motionCommandRepeat(command2) {
646
703
  if (command2.type === "wait" || command2.type === "enter" || command2.type === "exit") return 1;
647
704
  const n = command2.repeat ?? 1;
@@ -681,10 +738,11 @@ function compileMotionScene(scene) {
681
738
  scene.shots.forEach((shot, index) => {
682
739
  const start = t;
683
740
  let cursor = start;
741
+ const f = motionShotTimeScale(shot);
684
742
  if (shot.camera) {
685
743
  const rect = resolveMotionCamera(shot.camera, scene);
686
744
  if (rect && rect.width > 0 && rect.height > 0) {
687
- const ms = index === 0 ? 0 : shot.camera.ms ?? MOTION_CAMERA_MS;
745
+ const ms = index === 0 ? 0 : (shot.camera.ms ?? MOTION_CAMERA_MS) * f;
688
746
  cameraMoves.push({ shot: shot.id, start, end: start + ms, to: rect, ease: shot.camera.ease ?? MOTION_EASE_IN_OUT, geometric: shot.camera.geometric === true });
689
747
  cursor = start + ms;
690
748
  }
@@ -694,16 +752,16 @@ function compileMotionScene(scene) {
694
752
  let prevStart = cursor;
695
753
  shot.run.forEach((command2, i) => {
696
754
  const base = command2.with === "previous" && i > 0 ? prevStart : runEnd;
697
- const begin = base + (command2.type === "wait" ? 0 : command2.delay ?? 0);
755
+ const begin = base + (command2.type === "wait" ? 0 : (command2.delay ?? 0) * f);
698
756
  const targets = command2.type === "wait" ? [] : command2.targets;
699
- const durations = command2.type === "wait" ? [command2.duration] : targets.map((id) => motionCommandDuration(command2, scene, id));
700
- const stagger = command2.type === "wait" ? 0 : command2.stagger ?? 0;
757
+ const durations = command2.type === "wait" ? [command2.duration * f] : targets.map((id) => motionCommandDuration(command2, scene, id) * f);
758
+ const stagger = command2.type === "wait" ? 0 : (command2.stagger ?? 0) * f;
701
759
  const end2 = durations.reduce((m, d, k) => Math.max(m, begin + k * stagger + d), begin);
702
760
  commands.push({ shot: shot.id, shotIndex: index, command: i, type: command2.type, start: begin, end: end2, duration: durations[0] ?? 0 });
703
761
  prevStart = begin;
704
762
  runEnd = Math.max(runEnd, end2);
705
763
  });
706
- const end = runEnd + (shot.hold ?? MOTION_DEFAULT_HOLD);
764
+ const end = runEnd + (shot.hold ?? MOTION_DEFAULT_HOLD) * f;
707
765
  shots.push({ id: shot.id, index, ...shot.label ? { label: shot.label } : {}, start, commandsStart, commandsEnd: runEnd, end });
708
766
  t = end;
709
767
  });
@@ -736,8 +794,9 @@ function compileMotionScene(scene) {
736
794
  command2.targets.forEach((target, k) => {
737
795
  const entry = nodes.get(target);
738
796
  if (!entry) return;
739
- const start = timing.start + k * (command2.stagger ?? 0);
740
- const cycle = motionCycleDuration(command2, scene, target);
797
+ const f = motionShotTimeScale(shot);
798
+ const start = timing.start + k * (command2.stagger ?? 0) * f;
799
+ const cycle = motionCycleDuration(command2, scene, target) * f;
741
800
  jobs.push({ start, order: order++, run: () => compileRepeated(command2, target, entry, start, cycle, timing) });
742
801
  });
743
802
  }
@@ -886,6 +945,15 @@ function compileMotionScene(scene) {
886
945
  case "morph":
887
946
  seg("shape", command2.into, ease ?? MOTION_EASE_IN_OUT);
888
947
  break;
948
+ case "match": {
949
+ const into = nodes.get(command2.into);
950
+ if (!into || command2.into === node) break;
951
+ const match = matchMotionNodes(entry.node, into.node);
952
+ const e = ease ?? MOTION_EASE_IN_OUT;
953
+ for (const [a, b] of match.pairs) push({ node: a, property: "shape", start, end, from: valueAt(a, "shape", start), to: b, ease: e, ...where });
954
+ for (const a of match.unpairedFrom) push({ node: a, property: "opacity", start, end, from: valueAt(a, "opacity", start), to: 0, ease: e, ...where });
955
+ break;
956
+ }
889
957
  case "follow":
890
958
  seg("follow", command2.to ?? 1, ease ?? MOTION_EASE_IN_OUT, start, end, { from: command2.from ?? 0, path: command2.path, rotate: command2.rotate ?? false });
891
959
  break;
@@ -1226,6 +1294,19 @@ function checkMotionScene(scene) {
1226
1294
  if (command2.type === "follow" && known(command2.path, "unknown-path", "path", where) && nodes.get(command2.path).node.type !== "path")
1227
1295
  issues.push(motionIssue("not-a-path", `"${command2.path}" is a group; follow a single path node.`, { ...where, ids: [command2.path] }));
1228
1296
  if (command2.type === "morph") known(command2.into, "unknown-shape", "shape", where);
1297
+ if (command2.type === "match" && known(command2.into, "unknown-shape", "group", where)) {
1298
+ const into = nodes.get(command2.into);
1299
+ for (const id of targets) {
1300
+ const entry = nodes.get(id);
1301
+ if (id === command2.into || into.ancestors.includes(id) || entry.ancestors.includes(command2.into)) {
1302
+ issues.push(motionIssue("match-self", `"${id}" cannot match "${command2.into}": one is the other or holds it. Match two separate groups (the second usually hidden).`, { ...where, ids: [id, command2.into] }));
1303
+ continue;
1304
+ }
1305
+ const m = matchMotionNodes(entry.node, into.node);
1306
+ if (m.unpairedInto.length)
1307
+ issues.push(motionIssue("match-unpaired", `${m.unpairedInto.map((x) => `"${x}"`).join(", ")} in "${command2.into}" ${m.unpairedInto.length > 1 ? "have" : "has"} no partner in "${id}", so ${m.unpairedInto.length > 1 ? "they do" : "it does"} not appear; add a part with the same name or id to "${id}" (it can start hidden or tiny).`, { ...where, ids: m.unpairedInto }));
1308
+ }
1309
+ }
1229
1310
  const style = command2.type === "enter" || command2.type === "exit" ? command2.style : void 0;
1230
1311
  if (style === "wipe" || style === "iris" || style === "split") {
1231
1312
  for (const id of targets)
@@ -1329,8 +1410,8 @@ function checkMotionScene(scene) {
1329
1410
  if (scene.target && scene.shots.length) {
1330
1411
  const seconds = Math.round(timeline.duration / 100) / 10;
1331
1412
  const [min, max] = scene.target;
1332
- if (seconds > max) issues.push(motionIssue("scene-too-long", `The scene runs ${seconds} s, past its ${max} s target; shorten holds or durations.`, { measured: seconds, needed: max, unit: "s" }));
1333
- if (seconds < min) issues.push(motionIssue("scene-too-short", `The scene runs ${seconds} s, under its ${min} s target; hold longer or add shots.`, { measured: seconds, needed: min, unit: "s" }));
1413
+ if (seconds > max) issues.push(motionIssue("scene-too-long", `The scene runs ${seconds} s, past its ${max} s target; shorten holds or durations, or fit it (fit_animation).`, { measured: seconds, needed: max, unit: "s" }));
1414
+ if (seconds < min) issues.push(motionIssue("scene-too-short", `The scene runs ${seconds} s, under its ${min} s target; hold longer, add shots, or fit it (fit_animation).`, { measured: seconds, needed: min, unit: "s" }));
1334
1415
  }
1335
1416
  return issues;
1336
1417
  }
@@ -1515,6 +1596,34 @@ function createMotionScene(artwork, init = {}) {
1515
1596
  return { version: 1, name: init.name ?? "animation", ...init, artwork, shots: init.shots ?? [] };
1516
1597
  }
1517
1598
 
1599
+ // src/animation-editor/fit.ts
1600
+ var MOTION_SPEED_RANGE = [0.05, 20];
1601
+ function fitMotionScene(scene, ms, options = {}) {
1602
+ if (!(Number.isFinite(ms) && ms > 0)) throw new Error("Give the length to fit as a number of milliseconds above 0.");
1603
+ const ids = scene.shots.map((s) => s.id);
1604
+ const keep = new Set(options.keep ?? []);
1605
+ for (const id of keep) if (!ids.includes(id)) throw new Error(`There is no shot ${namedWithSuggestion(id, ids)} to keep.`);
1606
+ const timeline = compileMotionScene(scene);
1607
+ let kept = 0;
1608
+ let rest = 0;
1609
+ timeline.shots.forEach((t) => keep.has(t.id) ? kept += t.end - t.start : rest += t.end - t.start);
1610
+ if (rest <= 0) throw new Error(keep.size ? "Every shot is kept, so nothing can change pace; keep fewer shots." : "The scene has no length to fit; add shots first.");
1611
+ if (ms <= kept) throw new Error(`The kept shots alone last ${Math.round(kept)} ms, so the scene cannot fit ${Math.round(ms)} ms; keep fewer shots or give at least ${Math.ceil(kept) + 1} ms.`);
1612
+ const k = (ms - kept) / rest;
1613
+ const speeds = {};
1614
+ const shots = scene.shots.map((shot) => {
1615
+ if (keep.has(shot.id)) return shot;
1616
+ const speed = Math.round((shot.speed ?? 1) / k * 1e4) / 1e4;
1617
+ if (speed < MOTION_SPEED_RANGE[0] || speed > MOTION_SPEED_RANGE[1])
1618
+ throw new Error(`Fitting ${Math.round(ms)} ms would play shot "${shot.id}" at ${speed}\xD7 (speeds run ${MOTION_SPEED_RANGE[0]}\u2013${MOTION_SPEED_RANGE[1]}); aim nearer the current ${Math.round(timeline.duration)} ms.`);
1619
+ speeds[shot.id] = speed;
1620
+ const { speed: _old, ...rest2 } = shot;
1621
+ return speed === 1 ? rest2 : { ...rest2, speed };
1622
+ });
1623
+ const fitted = { ...scene, shots };
1624
+ return { scene: fitted, duration: compileMotionScene(fitted).duration, speeds };
1625
+ }
1626
+
1518
1627
  // src/animation-editor/flair.ts
1519
1628
  var FLAIR_EASES = {
1520
1629
  linear: (t) => t,
@@ -2331,6 +2440,7 @@ var MOTION_COMMAND_SCHEMA = {
2331
2440
  stroke: { type: "string", description: "CSS colour (hex, rgb(), rgba())." },
2332
2441
  strokeWidth: { type: "number", minimum: 0, description: "Document units." }
2333
2442
  }),
2443
+ 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"]),
2334
2444
  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"]),
2335
2445
  command("follow", "Moves targets along a path node, centre on the path.", {
2336
2446
  path: { type: "string", minLength: 1, description: "Id of a path node (it can be hidden)." },
@@ -2384,7 +2494,8 @@ var SHOT_FIELDS = {
2384
2494
  label: { type: "string", description: "What happens, in a few words." },
2385
2495
  camera: CAMERA,
2386
2496
  run: { type: "array", items: MOTION_COMMAND_SCHEMA, description: "Commands, in order." },
2387
- hold: MS("Hold after the commands (default 600)")
2497
+ hold: MS("Hold after the commands (default 600)"),
2498
+ speed: { type: "number", minimum: 0.05, maximum: 20, description: "Play the whole shot this many times as fast (default 1; 0.5 is half speed). fit_animation sets it." }
2388
2499
  };
2389
2500
  var SHOT = object({ id: { type: "string", pattern: "^[A-Za-z0-9_][\\w.-]*$", description: 'Unique shot id, e.g. "intro".' }, ...SHOT_FIELDS }, ["id", "run"], "A shot.");
2390
2501
  var CONTROL = object(
@@ -2411,7 +2522,7 @@ var EDIT_OP = {
2411
2522
  oneOf: [
2412
2523
  object({ op: { const: "set_settings", description: "Like set_animation_settings." }, ...SETTINGS }, ["op"]),
2413
2524
  object({ op: { const: "insert_shots", description: "Like insert_shots." }, shots: { type: "array", items: SHOT, minItems: 1, description: "Shots to add." }, at: { type: "integer", minimum: 0, description: "Position (default: the end)." } }, ["op", "shots"]),
2414
- object({ op: { const: "update_shot", description: "Like update_shot." }, shot: SHOT_ID, set: object({ id: SHOT.properties.id, ...SHOT_FIELDS }, [], "Fields to replace."), remove: { type: "array", items: { type: "string", enum: ["label", "camera", "hold"] }, description: "Fields to clear." } }, ["op", "shot"]),
2525
+ object({ op: { const: "update_shot", description: "Like update_shot." }, shot: SHOT_ID, set: object({ id: SHOT.properties.id, ...SHOT_FIELDS }, [], "Fields to replace."), remove: { type: "array", items: { type: "string", enum: ["label", "camera", "hold", "speed"] }, description: "Fields to clear." } }, ["op", "shot"]),
2415
2526
  object({ op: { const: "delete_shots", description: "Like delete_shots." }, shots: { type: "array", items: SHOT_ID, minItems: 1, description: "Shot ids." } }, ["op", "shots"]),
2416
2527
  object({ op: { const: "move_shot", description: "Like move_shot." }, shot: SHOT_ID, to: { type: "integer", minimum: 0, description: "New position (0 = first)." } }, ["op", "shot", "to"]),
2417
2528
  object({ op: { const: "add_commands", description: "Like add_commands." }, shot: SHOT_ID, commands: { type: "array", items: MOTION_COMMAND_SCHEMA, minItems: 1, description: "Commands to add." }, at: { type: "integer", minimum: 0, description: "Index in the shot's run (default: the end)." } }, ["op", "shot", "commands"]),
@@ -2658,6 +2769,27 @@ function createAnimationEditorToolProvider(controller, options = {}) {
2658
2769
  return outcome(applied, applied.ok ? { ...report, elements: describeMotionElements(result.scene) } : {});
2659
2770
  }
2660
2771
  };
2772
+ const fitAnimation = {
2773
+ name: "fit_animation",
2774
+ title: "Fit the animation to a length",
2775
+ description: `Makes the animation last a given time by setting its shots' speed (one undo step): shots in "keep" play as they are and the others stretch or squeeze together, keeping their proportions. Returns the new length, each changed shot's speed and the timing. With dry_run it only reports them.`,
2776
+ inputSchema: object(
2777
+ {
2778
+ seconds: { type: "number", exclusiveMinimum: 0, description: "The length to fit, in seconds." },
2779
+ keep: { type: "array", items: SHOT_ID, description: "Shots that keep their pace, e.g. a logo hold (default none)." },
2780
+ dry_run: { type: "boolean", description: "Report without changing anything (default false)." }
2781
+ },
2782
+ ["seconds"]
2783
+ ),
2784
+ annotations: { title: "Fit the animation to a length", ...WRITE_IDEMPOTENT },
2785
+ handler: ({ seconds, keep, dry_run = false }) => {
2786
+ const fit = fitMotionScene(controller.getState().scene, seconds * 1e3, { keep: keep ?? [] });
2787
+ const report = { duration: fit.duration, speeds: fit.speeds };
2788
+ if (dry_run) return { dry_run: true, ...report, hint: "Nothing was changed; call again without dry_run to fit." };
2789
+ const edits = Object.entries(fit.speeds).map(([shot, speed]) => speed === 1 ? { op: "update_shot", shot, remove: ["speed"] } : { op: "update_shot", shot, set: { speed } });
2790
+ return apply(edits, "Fit to length", () => report);
2791
+ }
2792
+ };
2661
2793
  const insertShots = {
2662
2794
  name: "insert_shots",
2663
2795
  title: "Add shots",
@@ -2674,7 +2806,7 @@ function createAnimationEditorToolProvider(controller, options = {}) {
2674
2806
  {
2675
2807
  shot: SHOT_ID,
2676
2808
  set: object({ id: SHOT.properties.id, ...SHOT_FIELDS }, [], "Fields to replace."),
2677
- remove: { type: "array", items: { type: "string", enum: ["label", "camera", "hold"] }, description: "Fields to clear." }
2809
+ remove: { type: "array", items: { type: "string", enum: ["label", "camera", "hold", "speed"] }, description: "Fields to clear." }
2678
2810
  },
2679
2811
  ["shot"]
2680
2812
  ),
@@ -2819,7 +2951,7 @@ function createAnimationEditorToolProvider(controller, options = {}) {
2819
2951
  }
2820
2952
  };
2821
2953
  const reads = [getAnimation, getShot, findElements, checkAnimation, renderFrame, exportFlairMotion, exportFlairEffect, seekAnimation];
2822
- const writes = [setArtworkSvg, importFlairEffect, insertShots, updateShot, deleteShots, moveShot, addCommands, updateCommand, deleteCommands, setSettings, setControls, editAnimation, writeAnimation, undo, redo];
2954
+ const writes = [setArtworkSvg, fitAnimation, importFlairEffect, insertShots, updateShot, deleteShots, moveShot, addCommands, updateCommand, deleteCommands, setSettings, setControls, editAnimation, writeAnimation, undo, redo];
2823
2955
  return {
2824
2956
  id: options.id ?? "animation",
2825
2957
  namespace: options.namespace,
@@ -2852,7 +2984,7 @@ Commands
2852
2984
  - 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.
2853
2985
  - 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.
2854
2986
  - enter also has zoom (settles from larger) and rise (rises a short way, overshooting a touch); pop and zoom take a starting "scale".
2855
- - 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).
2987
+ - 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).
2856
2988
  - 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).
2857
2989
  - Emphasis stacks on other motion: a shake during a slide shakes as it slides. Give move, rotate or scale "blend": "add" to stack them too (what they add stays); "blend": "replace" makes a change take over instead.
2858
2990
  - Any command but enter and exit can "repeat" (loop, mirror back and forth, or offset to carry on: rotate by 90, repeat 4 is a full turn); "duration" is one cycle.
@@ -2864,7 +2996,7 @@ Commands
2864
2996
  Working method
2865
2997
  1. get_animation to learn the element ids (set_animation_artwork_svg first if there is no artwork: give every element you will animate an id; or import_flair_effect to start from a Flair effect and edit it).
2866
2998
  2. Plan a few shots, each one idea. Add them with insert_shots, or edit_animation to make several edits as one step (dry_run first when unsure).
2867
- 3. check_animation and fix every error and warning; render_animation_frame (shot + "end") to look at the result.
2999
+ 3. To hit a length, fit_animation (keep the shots whose pace matters). check_animation and fix every error and warning; render_animation_frame (shot + "end") to look at the result.
2868
3000
  4. To make a reusable Flair effect, set_animation_controls (colour controls stand for every use of their default colour) and export_flair_effect.
2869
3001
 
2870
3002
  Keep motion purposeful: one thing moving at a time reads best, ease-out for arrivals, ease-in for exits, and hold long enough to see each result.`;
@@ -2893,6 +3025,7 @@ export {
2893
3025
  MOTION_ISSUE_CODES,
2894
3026
  MOTION_MIN_CHANGE_MS,
2895
3027
  MOTION_REPEAT_MODES,
3028
+ MOTION_SPEED_RANGE,
2896
3029
  MOTION_SPLIT_ORIENTATIONS,
2897
3030
  MotionSceneFormatError,
2898
3031
  activeSegment,
@@ -2906,6 +3039,7 @@ export {
2906
3039
  describeMotionElements,
2907
3040
  describeMotionShots,
2908
3041
  fitMotionRect,
3042
+ fitMotionScene,
2909
3043
  flairAtLeast,
2910
3044
  flairCurveFor,
2911
3045
  flairEaseFor,
@@ -2916,6 +3050,7 @@ export {
2916
3050
  interpolateMotionValue,
2917
3051
  isMotionCommand,
2918
3052
  isMotionShot,
3053
+ matchMotionNodes,
2919
3054
  motionAddTrackKey,
2920
3055
  motionClipAttrs,
2921
3056
  motionCommandDuration,
@@ -2937,6 +3072,7 @@ export {
2937
3072
  motionSceneShapeProblems,
2938
3073
  motionSceneToJson,
2939
3074
  motionShotAt,
3075
+ motionShotTimeScale,
2940
3076
  motionTrackKey,
2941
3077
  namedWithSuggestion,
2942
3078
  parseMotionScene,