@pixerate/editor 0.61.0 → 0.63.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.
@@ -149,6 +149,8 @@ var MOTION_ISSUE_CODES = {
149
149
  "out-of-frame": { severity: "warning", wording: "A node a command animates ends up outside the camera." },
150
150
  "too-fast": { severity: "warning", wording: "A change is too quick to follow." },
151
151
  "spring-cut-short": { severity: "warning", wording: "A spring is given a duration shorter than it takes to settle, so it jumps to its end." },
152
+ "unknown-cue": { severity: "error", wording: "A shot is cued to a beat with no beat grid, or to a word the narration does not have." },
153
+ "cue-missed": { severity: "warning", wording: "A shot starts after its cue, because the shot before runs past it." },
152
154
  "scene-too-long": { severity: "warning", wording: "The scene runs past its target length." },
153
155
  "scene-too-short": { severity: "warning", wording: "The scene ends before its target length." },
154
156
  "unknown-control-color": { severity: "warning", wording: "A control stands for a colour the scene never uses." },
@@ -206,8 +208,8 @@ ${problems.map((p) => `- ${p}`).join("\n")}`);
206
208
  this.problems = problems;
207
209
  }
208
210
  };
209
- var SCENE_KEYS = ["version", "name", "description", "artwork", "shots", "loop", "fps", "steps", "target", "controls", "presets"];
210
- var SHOT_KEYS = ["id", "label", "camera", "run", "hold"];
211
+ var SCENE_KEYS = ["version", "name", "description", "artwork", "shots", "loop", "fps", "steps", "beat", "narration", "target", "controls", "presets"];
212
+ var SHOT_KEYS = ["id", "label", "camera", "at", "run", "hold", "speed"];
211
213
  var CAMERA_KEYS = ["x", "y", "width", "height", "frame", "padding", "ease", "ms", "geometric"];
212
214
  var BASE_KEYS = ["type", "targets", "duration", "delay", "stagger", "with", "ease", "note", "repeat", "repeatMode", "steps"];
213
215
  var COMMAND_KEYS = {
@@ -360,6 +362,17 @@ function shotShapeProblems(shot, where) {
360
362
  const name = typeof shot.id === "string" ? `Shot "${shot.id}"` : where;
361
363
  if (shot.label !== void 0 && typeof shot.label !== "string") problems.push(`${name}: "label" must be a string.`);
362
364
  if (shot.hold !== void 0 && !(isNum(shot.hold) && shot.hold >= 0)) problems.push(`${name}: "hold" must be a number of milliseconds \u2265 0.`);
365
+ 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).`);
366
+ if (shot.at !== void 0) {
367
+ const at = shot.at;
368
+ const given = isObject(at) ? ["beat", "word", "ms"].filter((k) => at[k] !== void 0) : [];
369
+ if (!isObject(at) || given.length !== 1) problems.push(`${name}: "at" must give one of { "beat": number }, { "word": index } or { "ms": number }.`);
370
+ else {
371
+ unknownKeys(at, ["beat", "word", "ms"], `${name} at`, problems);
372
+ const k = given[0];
373
+ if (!isNum(at[k]) || at[k] < 0 || k === "word" && !Number.isInteger(at[k])) problems.push(`${name}: "at.${k}" must be ${k === "word" ? "a word index \u2265 0" : "a number \u2265 0"}.`);
374
+ }
375
+ }
363
376
  if (shot.camera !== void 0) {
364
377
  const c = shot.camera;
365
378
  if (!isObject(c)) problems.push(`${name}: "camera" must be an object.`);
@@ -390,6 +403,16 @@ function motionSceneShapeProblems(input) {
390
403
  problems.push('Scene: "artwork" must be a vector document { width, height, nodes }.');
391
404
  if (input.loop !== void 0 && !["none", "loop", "ping-pong"].includes(input.loop)) problems.push('Scene: "loop" must be "none", "loop" or "ping-pong".');
392
405
  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.');
406
+ if (input.beat !== void 0) {
407
+ const b = input.beat;
408
+ if (!isObject(b) || !(isNum(b.bpm) && b.bpm >= 1 && b.bpm <= 1e3) || b.offset !== void 0 && !isNum(b.offset) || b.meter !== void 0 && !(Number.isInteger(b.meter) && b.meter >= 1))
409
+ problems.push('Scene: "beat" must be { "bpm": 1\u20131000, "offset"?: ms, "meter"?: beats a bar }.');
410
+ }
411
+ if (input.narration !== void 0) {
412
+ const n = input.narration;
413
+ if (!isObject(n) || !Array.isArray(n.words) || !n.words.every((w, i, all) => isObject(w) && typeof w.text === "string" && isNum(w.start) && w.start >= 0 && (w.end === void 0 || isNum(w.end) && w.end >= w.start) && (i === 0 || w.start >= (_nullishCoalesce(all[i - 1].start, () => ( 0))))))
414
+ problems.push('Scene: "narration" must be { "words": [{ "text", "start": ms, "end"?: ms }, \u2026] } in time order.');
415
+ }
393
416
  if (input.steps !== void 0 && !(isNum(input.steps) && input.steps > 0 && input.steps <= 120)) problems.push('Scene: "steps" must be frames per second from above 0 to 120.');
394
417
  if (input.target !== void 0 && !(Array.isArray(input.target) && input.target.length === 2 && input.target.every(isNum) && input.target[0] <= input.target[1]))
395
418
  problems.push('Scene: "target" must be [min, max] seconds.');
@@ -695,6 +718,24 @@ function motionPathLength(node, parentWorld = _chunkYJLCBA7Vcjs.IDENTITY_MATRIX)
695
718
  }
696
719
  return longest;
697
720
  }
721
+ function motionShotTimeScale(shot) {
722
+ return typeof shot.speed === "number" && shot.speed > 0 && Number.isFinite(shot.speed) ? 1 / shot.speed : 1;
723
+ }
724
+ function motionBeatTime(grid, beat) {
725
+ return (_nullishCoalesce(grid.offset, () => ( 0))) + beat * 6e4 / grid.bpm;
726
+ }
727
+ function motionBeatAt(grid, ms) {
728
+ const beat = (ms - (_nullishCoalesce(grid.offset, () => ( 0)))) * grid.bpm / 6e4;
729
+ const meter = Math.max(1, Math.round(_nullishCoalesce(grid.meter, () => ( 4))));
730
+ const whole = Math.floor(beat + 1e-9);
731
+ return { beat, bar: Math.floor(whole / meter) + 1, beatInBar: (whole % meter + meter) % meter + 1 };
732
+ }
733
+ function motionCueTime(scene, cue) {
734
+ if (typeof cue.ms === "number") return Math.max(0, cue.ms);
735
+ if (typeof cue.beat === "number") return scene.beat && scene.beat.bpm > 0 ? Math.max(0, motionBeatTime(scene.beat, cue.beat)) : void 0;
736
+ if (typeof cue.word === "number") return _optionalChain([scene, 'access', _18 => _18.narration, 'optionalAccess', _19 => _19.words, 'access', _20 => _20[cue.word], 'optionalAccess', _21 => _21.start]);
737
+ return void 0;
738
+ }
698
739
  function motionCommandRepeat(command2) {
699
740
  if (command2.type === "wait" || command2.type === "enter" || command2.type === "exit") return 1;
700
741
  const n = _nullishCoalesce(command2.repeat, () => ( 1));
@@ -732,12 +773,19 @@ function compileMotionScene(scene) {
732
773
  const cameraMoves = [];
733
774
  let t = 0;
734
775
  scene.shots.forEach((shot, index) => {
776
+ const cue = shot.at ? motionCueTime(scene, shot.at) : void 0;
777
+ if (cue !== void 0 && cue > t) {
778
+ if (shots.length) shots[shots.length - 1].end = cue;
779
+ t = cue;
780
+ }
781
+ const missedBy = cue !== void 0 && t - cue > 0.5 ? t - cue : void 0;
735
782
  const start = t;
736
783
  let cursor = start;
784
+ const f = motionShotTimeScale(shot);
737
785
  if (shot.camera) {
738
786
  const rect = resolveMotionCamera(shot.camera, scene);
739
787
  if (rect && rect.width > 0 && rect.height > 0) {
740
- const ms = index === 0 ? 0 : _nullishCoalesce(shot.camera.ms, () => ( MOTION_CAMERA_MS));
788
+ const ms = index === 0 ? 0 : (_nullishCoalesce(shot.camera.ms, () => ( MOTION_CAMERA_MS))) * f;
741
789
  cameraMoves.push({ shot: shot.id, start, end: start + ms, to: rect, ease: _nullishCoalesce(shot.camera.ease, () => ( MOTION_EASE_IN_OUT)), geometric: shot.camera.geometric === true });
742
790
  cursor = start + ms;
743
791
  }
@@ -747,17 +795,17 @@ function compileMotionScene(scene) {
747
795
  let prevStart = cursor;
748
796
  shot.run.forEach((command2, i) => {
749
797
  const base = command2.with === "previous" && i > 0 ? prevStart : runEnd;
750
- const begin = base + (command2.type === "wait" ? 0 : _nullishCoalesce(command2.delay, () => ( 0)));
798
+ const begin = base + (command2.type === "wait" ? 0 : (_nullishCoalesce(command2.delay, () => ( 0))) * f);
751
799
  const targets = command2.type === "wait" ? [] : command2.targets;
752
- const durations = command2.type === "wait" ? [command2.duration] : targets.map((id) => motionCommandDuration(command2, scene, id));
753
- const stagger = command2.type === "wait" ? 0 : _nullishCoalesce(command2.stagger, () => ( 0));
800
+ const durations = command2.type === "wait" ? [command2.duration * f] : targets.map((id) => motionCommandDuration(command2, scene, id) * f);
801
+ const stagger = command2.type === "wait" ? 0 : (_nullishCoalesce(command2.stagger, () => ( 0))) * f;
754
802
  const end2 = durations.reduce((m, d, k) => Math.max(m, begin + k * stagger + d), begin);
755
803
  commands.push({ shot: shot.id, shotIndex: index, command: i, type: command2.type, start: begin, end: end2, duration: _nullishCoalesce(durations[0], () => ( 0)) });
756
804
  prevStart = begin;
757
805
  runEnd = Math.max(runEnd, end2);
758
806
  });
759
- const end = runEnd + (_nullishCoalesce(shot.hold, () => ( MOTION_DEFAULT_HOLD)));
760
- shots.push({ id: shot.id, index, ...shot.label ? { label: shot.label } : {}, start, commandsStart, commandsEnd: runEnd, end });
807
+ const end = runEnd + (_nullishCoalesce(shot.hold, () => ( MOTION_DEFAULT_HOLD))) * f;
808
+ shots.push({ id: shot.id, index, ...shot.label ? { label: shot.label } : {}, start, commandsStart, commandsEnd: runEnd, end, ...cue !== void 0 ? { cue } : {}, ...missedBy !== void 0 ? { missedBy } : {} });
761
809
  t = end;
762
810
  });
763
811
  const tracks = /* @__PURE__ */ new Map();
@@ -789,8 +837,9 @@ function compileMotionScene(scene) {
789
837
  command2.targets.forEach((target, k) => {
790
838
  const entry = nodes.get(target);
791
839
  if (!entry) return;
792
- const start = timing.start + k * (_nullishCoalesce(command2.stagger, () => ( 0)));
793
- const cycle = motionCycleDuration(command2, scene, target);
840
+ const f = motionShotTimeScale(shot);
841
+ const start = timing.start + k * (_nullishCoalesce(command2.stagger, () => ( 0))) * f;
842
+ const cycle = motionCycleDuration(command2, scene, target) * f;
794
843
  jobs.push({ start, order: order++, run: () => compileRepeated(command2, target, entry, start, cycle, timing) });
795
844
  });
796
845
  }
@@ -836,7 +885,7 @@ function compileMotionScene(scene) {
836
885
  const style = _nullishCoalesce(command2.style, () => ( "fade"));
837
886
  const opacity = rest("opacity");
838
887
  const reveal = style === "wipe" ? motionRevealClip(entry, { style, direction: command2.direction }) : style === "iris" ? motionRevealClip(entry, { style, shape: command2.shape }) : style === "split" ? motionRevealClip(entry, { style, orientation: command2.orientation }) : null;
839
- for (const p of ["clipRect", "clipCircle"]) if (p !== _optionalChain([reveal, 'optionalAccess', _18 => _18.property]) && tracks.has(motionTrackKey(node, p))) seg(p, rest(p), "linear", start, start);
888
+ for (const p of ["clipRect", "clipCircle"]) if (p !== _optionalChain([reveal, 'optionalAccess', _22 => _22.property]) && tracks.has(motionTrackKey(node, p))) seg(p, rest(p), "linear", start, start);
840
889
  if (reveal) {
841
890
  seg("opacity", opacity, "linear", start, start);
842
891
  seg(reveal.property, reveal.to, _nullishCoalesce(ease, () => ( MOTION_EASE_OUT)), start, end, { from: reveal.from });
@@ -898,8 +947,8 @@ function compileMotionScene(scene) {
898
947
  }
899
948
  case "move": {
900
949
  const e = _nullishCoalesce(ease, () => ( MOTION_EASE_IN_OUT));
901
- let dx = _nullishCoalesce(_optionalChain([command2, 'access', _19 => _19.by, 'optionalAccess', _20 => _20.x]), () => ( 0));
902
- let dy = _nullishCoalesce(_optionalChain([command2, 'access', _21 => _21.by, 'optionalAccess', _22 => _22.y]), () => ( 0));
950
+ let dx = _nullishCoalesce(_optionalChain([command2, 'access', _23 => _23.by, 'optionalAccess', _24 => _24.x]), () => ( 0));
951
+ let dy = _nullishCoalesce(_optionalChain([command2, 'access', _25 => _25.by, 'optionalAccess', _26 => _26.y]), () => ( 0));
903
952
  if (command2.to) {
904
953
  const inv = _chunkYJLCBA7Vcjs.invertMatrix.call(void 0, entry.parentWorld);
905
954
  const target = _chunkYJLCBA7Vcjs.applyMatrix.call(void 0, inv, command2.to);
@@ -1003,7 +1052,7 @@ var MOTION_DRAW_EFFECT_ID = "~draw";
1003
1052
  var TRANSFORM_PROPS = ["x", "y", "rotation", "scaleX", "scaleY", "follow"];
1004
1053
  var tidy = (v) => Math.round(v * 1e6) / 1e6 || 0;
1005
1054
  function applyMotionControlValues(scene, values) {
1006
- if (!values || !_optionalChain([scene, 'access', _23 => _23.controls, 'optionalAccess', _24 => _24.length])) return scene;
1055
+ if (!values || !_optionalChain([scene, 'access', _27 => _27.controls, 'optionalAccess', _28 => _28.length])) return scene;
1007
1056
  const map = /* @__PURE__ */ new Map();
1008
1057
  for (const control of scene.controls) {
1009
1058
  const value = values[control.name];
@@ -1049,7 +1098,7 @@ function shapeIn(entry, shape) {
1049
1098
  }
1050
1099
  function shapeAt(entry, timeline, nodes, time) {
1051
1100
  const track = timeline.tracks.get(motionTrackKey(entry.node.id, "shape"));
1052
- if (!_optionalChain([track, 'optionalAccess', _25 => _25.length])) return null;
1101
+ if (!_optionalChain([track, 'optionalAccess', _29 => _29.length])) return null;
1053
1102
  const s = activeSegment(track, time);
1054
1103
  const fromId = String(s ? s.from : track[0].from);
1055
1104
  if (!s) return fromId === entry.node.id ? null : nodes.get(fromId) ? shapeIn(entry, nodes.get(fromId)) : null;
@@ -1072,8 +1121,8 @@ function motionFollowRoute(entry, path) {
1072
1121
  }
1073
1122
  function followAt(entry, timeline, nodes, time) {
1074
1123
  const track = timeline.tracks.get(motionTrackKey(entry.node.id, "follow"));
1075
- const s = _nullishCoalesce(activeSegment(track, time), () => ( _optionalChain([track, 'optionalAccess', _26 => _26[0]])));
1076
- if (!_optionalChain([s, 'optionalAccess', _27 => _27.path])) return null;
1124
+ const s = _nullishCoalesce(activeSegment(track, time), () => ( _optionalChain([track, 'optionalAccess', _30 => _30[0]])));
1125
+ if (!_optionalChain([s, 'optionalAccess', _31 => _31.path])) return null;
1077
1126
  const route = motionFollowRoute(entry, nodes.get(s.path));
1078
1127
  const progress = Number(trackValue(track, time));
1079
1128
  if (!route || !(progress >= 0)) return null;
@@ -1133,7 +1182,7 @@ function motionFrameAt(scene, time, options = {}) {
1133
1182
  if (out.visible === void 0) delete out.visible;
1134
1183
  }
1135
1184
  const effectTracks = [...timeline.tracks.keys()].filter((k) => k.startsWith(`${id}\0effect:`));
1136
- if (effectTracks.length && _optionalChain([out, 'access', _28 => _28.effects, 'optionalAccess', _29 => _29.length])) {
1185
+ if (effectTracks.length && _optionalChain([out, 'access', _32 => _32.effects, 'optionalAccess', _33 => _33.length])) {
1137
1186
  out = {
1138
1187
  ...out,
1139
1188
  effects: out.effects.map((effect) => {
@@ -1211,7 +1260,7 @@ function withViewBox(svg, box) {
1211
1260
  // src/animation-editor/check.ts
1212
1261
  var MOTION_MIN_CHANGE_MS = 120;
1213
1262
  var NUMERIC_PARAMS = (node, effectId) => {
1214
- const effect = _optionalChain([node, 'access', _30 => _30.effects, 'optionalAccess', _31 => _31.find, 'call', _32 => _32((e) => e.id === effectId)]);
1263
+ const effect = _optionalChain([node, 'access', _34 => _34.effects, 'optionalAccess', _35 => _35.find, 'call', _36 => _36((e) => e.id === effectId)]);
1215
1264
  if (!effect) return null;
1216
1265
  return Object.entries(effect.params).filter(([, v]) => typeof v === "number").map(([k]) => k);
1217
1266
  };
@@ -1360,6 +1409,17 @@ function checkMotionScene(scene) {
1360
1409
  }
1361
1410
  });
1362
1411
  });
1412
+ scene.shots.forEach((shot, i) => {
1413
+ if (!shot.at) return;
1414
+ const timing = timeline.shots[i];
1415
+ if (timing.cue === void 0) {
1416
+ const what = shot.at.beat !== void 0 ? `beat ${shot.at.beat}, but the scene has no beat grid; set one with set_animation_settings ({ "beat": { "bpm": \u2026 } })` : `word ${shot.at.word}, but the narration has ${_nullishCoalesce(_optionalChain([scene, 'access', _37 => _37.narration, 'optionalAccess', _38 => _38.words, 'access', _39 => _39.length]), () => ( 0))} words (indexes from 0)`;
1417
+ issues.push(motionIssue("unknown-cue", `Shot "${shot.id}" is cued to ${what}.`, { shot: shot.id }));
1418
+ } else if (timing.missedBy !== void 0) {
1419
+ const late = Math.round(timing.missedBy);
1420
+ issues.push(motionIssue("cue-missed", `Shot "${shot.id}" is cued at ${Math.round(timing.cue)} ms but starts ${late} ms late, because the shot before runs until ${Math.round(timing.start)} ms; shorten that shot (its hold, durations or speed) or cue this one later.`, { shot: shot.id, measured: late, needed: 0, unit: "ms" }));
1421
+ }
1422
+ });
1363
1423
  for (const [key, track] of timeline.tracks) {
1364
1424
  if (key.endsWith("\0add")) continue;
1365
1425
  for (let i = 1; i < track.length; i++) {
@@ -1386,7 +1446,7 @@ function checkMotionScene(scene) {
1386
1446
  const cam = frame.camera;
1387
1447
  const docIndex = indexMotionNodes(frame.document.nodes);
1388
1448
  for (const id of command2.targets) {
1389
- const b = _optionalChain([docIndex, 'access', _33 => _33.get, 'call', _34 => _34(id), 'optionalAccess', _35 => _35.bounds]);
1449
+ const b = _optionalChain([docIndex, 'access', _40 => _40.get, 'call', _41 => _41(id), 'optionalAccess', _42 => _42.bounds]);
1390
1450
  if (!b || reported.has(id) || !visibleAt(timeline, id, timing.end, nodes.get(id).node)) continue;
1391
1451
  const outside = b.x + b.width < cam.x || b.y + b.height < cam.y || b.x > cam.x + cam.width || b.y > cam.y + cam.height;
1392
1452
  if (outside) {
@@ -1404,8 +1464,8 @@ function checkMotionScene(scene) {
1404
1464
  if (scene.target && scene.shots.length) {
1405
1465
  const seconds = Math.round(timeline.duration / 100) / 10;
1406
1466
  const [min, max] = scene.target;
1407
- 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" }));
1408
- 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" }));
1467
+ 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" }));
1468
+ 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" }));
1409
1469
  }
1410
1470
  return issues;
1411
1471
  }
@@ -1461,6 +1521,14 @@ function applyOne(scene, edit) {
1461
1521
  if (!(typeof edit.steps === "number" && edit.steps > 0 && edit.steps <= 120)) fail('"steps" must be frames per second from above 0 to 120 (12 is "on twos" at 24 fps), or null for smooth.');
1462
1522
  next.steps = edit.steps;
1463
1523
  }
1524
+ for (const key of ["beat", "narration"]) {
1525
+ if (edit[key] === null) delete next[key];
1526
+ else if (edit[key] !== void 0) next[key] = edit[key];
1527
+ }
1528
+ {
1529
+ const problems = motionSceneShapeProblems(next).filter((p) => /"beat"|"narration"/.test(p));
1530
+ if (problems.length) fail(problems.join(" "));
1531
+ }
1464
1532
  if (edit.target === null) delete next.target;
1465
1533
  else if (edit.target !== void 0) next.target = edit.target;
1466
1534
  return next;
@@ -1568,9 +1636,9 @@ function applyMotionEdits(scene, edits) {
1568
1636
  let next = scene;
1569
1637
  for (let i = 0; i < list.length; i++) {
1570
1638
  const edit = list[i];
1571
- const prefix = list.length > 1 ? `op ${i + 1} (${_optionalChain([edit, 'optionalAccess', _36 => _36.op])}): ` : "";
1639
+ const prefix = list.length > 1 ? `op ${i + 1} (${_optionalChain([edit, 'optionalAccess', _43 => _43.op])}): ` : "";
1572
1640
  if (!edit || !MOTION_EDIT_OPS.includes(edit.op))
1573
- return { ok: false, error: `${prefix}Unknown op ${namedWithSuggestion(String(_optionalChain([edit, 'optionalAccess', _37 => _37.op])), MOTION_EDIT_OPS)}. Nothing was changed.` };
1641
+ return { ok: false, error: `${prefix}Unknown op ${namedWithSuggestion(String(_optionalChain([edit, 'optionalAccess', _44 => _44.op])), MOTION_EDIT_OPS)}. Nothing was changed.` };
1574
1642
  try {
1575
1643
  next = applyOne(next, edit);
1576
1644
  } catch (error) {
@@ -1590,6 +1658,34 @@ function createMotionScene(artwork, init = {}) {
1590
1658
  return { version: 1, name: _nullishCoalesce(init.name, () => ( "animation")), ...init, artwork, shots: _nullishCoalesce(init.shots, () => ( [])) };
1591
1659
  }
1592
1660
 
1661
+ // src/animation-editor/fit.ts
1662
+ var MOTION_SPEED_RANGE = [0.05, 20];
1663
+ function fitMotionScene(scene, ms, options = {}) {
1664
+ if (!(Number.isFinite(ms) && ms > 0)) throw new Error("Give the length to fit as a number of milliseconds above 0.");
1665
+ const ids = scene.shots.map((s) => s.id);
1666
+ const keep = new Set(_nullishCoalesce(options.keep, () => ( [])));
1667
+ for (const id of keep) if (!ids.includes(id)) throw new Error(`There is no shot ${namedWithSuggestion(id, ids)} to keep.`);
1668
+ const timeline = compileMotionScene(scene);
1669
+ let kept = 0;
1670
+ let rest = 0;
1671
+ timeline.shots.forEach((t) => keep.has(t.id) ? kept += t.end - t.start : rest += t.end - t.start);
1672
+ 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.");
1673
+ 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.`);
1674
+ const k = (ms - kept) / rest;
1675
+ const speeds = {};
1676
+ const shots = scene.shots.map((shot) => {
1677
+ if (keep.has(shot.id)) return shot;
1678
+ const speed = Math.round((_nullishCoalesce(shot.speed, () => ( 1))) / k * 1e4) / 1e4;
1679
+ if (speed < MOTION_SPEED_RANGE[0] || speed > MOTION_SPEED_RANGE[1])
1680
+ 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.`);
1681
+ speeds[shot.id] = speed;
1682
+ const { speed: _old, ...rest2 } = shot;
1683
+ return speed === 1 ? rest2 : { ...rest2, speed };
1684
+ });
1685
+ const fitted = { ...scene, shots };
1686
+ return { scene: fitted, duration: compileMotionScene(fitted).duration, speeds };
1687
+ }
1688
+
1593
1689
  // src/animation-editor/flair.ts
1594
1690
  var FLAIR_EASES = {
1595
1691
  linear: (t) => t,
@@ -1702,7 +1798,7 @@ function toFlairSvgMotion(scene, options = {}) {
1702
1798
  const wrapper = freshId(`${id}--follow`);
1703
1799
  const centre = freshId(`${id}--follow-centre`);
1704
1800
  const c = entry.center;
1705
- const at = _optionalChain([motionNodeOffset, 'call', _38 => _38(entry, timeline, 0, nodes), 'optionalAccess', _39 => _39.follow]);
1801
+ const at = _optionalChain([motionNodeOffset, 'call', _45 => _45(entry, timeline, 0, nodes), 'optionalAccess', _46 => _46.follow]);
1706
1802
  const placed = at ? `translate(${f(at.x)} ${f(at.y)})${at.angle ? ` rotate(${f(at.angle)})` : ""}` : void 0;
1707
1803
  (wrap[id] ??= []).push({ id: wrapper, ...placed ? { transform: placed } : {} }, { id: centre, transform: `translate(${f(-c.x)} ${f(-c.y)})` });
1708
1804
  follows.push({ id, wrapper, routes: ids, segments: followSegments.filter((seg) => seg.path && ids.has(seg.path)) });
@@ -1762,7 +1858,7 @@ function toFlairSvgMotion(scene, options = {}) {
1762
1858
  exact: () => false,
1763
1859
  value: (t) => {
1764
1860
  const frame = motionFrameAt(scene, t, { draw: false, offsets: false });
1765
- const now = _chunkYJLCBA7Vcjs.expandVectorAppearance.call(void 0, _optionalChain([indexMotionNodes, 'call', _40 => _40(frame.document.nodes), 'access', _41 => _41.get, 'call', _42 => _42(id), 'optionalAccess', _43 => _43.node]));
1861
+ const now = _chunkYJLCBA7Vcjs.expandVectorAppearance.call(void 0, _optionalChain([indexMotionNodes, 'call', _47 => _47(frame.document.nodes), 'access', _48 => _48.get, 'call', _49 => _49(id), 'optionalAccess', _50 => _50.node]));
1766
1862
  if (!now || now.type !== "path") return "";
1767
1863
  const bezier = _chunkO2UH7LZEcjs.transformBezierPath.call(void 0, _chunkYJLCBA7Vcjs.vectorPathWorldBezier.call(void 0, now, _chunkYJLCBA7Vcjs.IDENTITY_MATRIX, { effects: false }), inverse);
1768
1864
  return _chunkO2UH7LZEcjs.bezierPathToSvg.call(void 0, bezier, { precision: Math.min(precision, 2) });
@@ -1783,8 +1879,8 @@ function toFlairSvgMotion(scene, options = {}) {
1783
1879
  ...delay > 0 ? { delay: round2(delay) } : {},
1784
1880
  ms: round2(ms),
1785
1881
  ease: written,
1786
- ..._optionalChain([segment, 'optionalAccess', _44 => _44.behavior]) ? { behavior: segment.behavior } : {},
1787
- ..._optionalChain([segment, 'optionalAccess', _45 => _45.steps]) ? { strobe: segment.steps } : {}
1882
+ ..._optionalChain([segment, 'optionalAccess', _51 => _51.behavior]) ? { behavior: segment.behavior } : {},
1883
+ ..._optionalChain([segment, 'optionalAccess', _52 => _52.steps]) ? { strobe: segment.steps } : {}
1788
1884
  };
1789
1885
  if (channel.kind === "view") animate.push({ ...base, view: to });
1790
1886
  else if (channel.kind === "draw") animate.push({ ...base, draw: [from, to] });
@@ -1857,7 +1953,7 @@ function toFlairSvgMotion(scene, options = {}) {
1857
1953
  if (routes.length) svg = svg.replace(/^(<svg[^>]*>)/, `$1<defs>${routes.join("")}</defs>`);
1858
1954
  const lastChange = animate.reduce((m, a) => Math.max(m, (_nullishCoalesce(a.delay, () => ( 0))) + (_nullishCoalesce(a.ms, () => ( 0)))), 0);
1859
1955
  let motion = { svg, animate };
1860
- if (_optionalChain([scene, 'access', _46 => _46.controls, 'optionalAccess', _47 => _47.length])) {
1956
+ if (_optionalChain([scene, 'access', _53 => _53.controls, 'optionalAccess', _54 => _54.length])) {
1861
1957
  const declarations = scene.controls.map((c) => `{{${c.name}: color default=${c.default.toUpperCase()}${c.label ? ` label='${c.label.replace(/'/g, "\u2019")}'` : ""}}}`).join(" ");
1862
1958
  const swap = (text) => scene.controls.reduce((s, c) => s.replace(new RegExp(`${c.default}(?![0-9a-fA-F])`, "gi"), `{{${c.name}}}`), text);
1863
1959
  svg = svg.replace(/^(<svg[^>]*>)/, `$1<!-- ${declarations} -->`);
@@ -1879,7 +1975,7 @@ function toFlairVectorEffect(scene, options = {}) {
1879
1975
  tags: _nullishCoalesce(options.tags, () => ( [])),
1880
1976
  motion: out.motion,
1881
1977
  hold: out.hold,
1882
- ..._optionalChain([scene, 'access', _48 => _48.presets, 'optionalAccess', _49 => _49.length]) ? { presets: scene.presets } : {},
1978
+ ..._optionalChain([scene, 'access', _55 => _55.presets, 'optionalAccess', _56 => _56.length]) ? { presets: scene.presets } : {},
1883
1979
  ...options.trigger ? { trigger: options.trigger } : {}
1884
1980
  };
1885
1981
  }
@@ -1922,7 +2018,7 @@ function resolvePlaceholders(motion, notes) {
1922
2018
  const filled = text.replace(PLACEHOLDER, (_, name) => {
1923
2019
  const d = declared.get(name);
1924
2020
  if (!d) unknown.add(name);
1925
- return (_nullishCoalesce(_optionalChain([d, 'optionalAccess', _50 => _50.value]), () => ( ""))).replace(/"/g, '\\"');
2021
+ return (_nullishCoalesce(_optionalChain([d, 'optionalAccess', _57 => _57.value]), () => ( ""))).replace(/"/g, '\\"');
1926
2022
  });
1927
2023
  for (const name of unknown) notes.push(`Placeholder "{{${name}}}" has no declaration, so it was left empty.`);
1928
2024
  return { motion: JSON.parse(filled), controls };
@@ -1976,7 +2072,7 @@ function commandsFor(anim, nodes, entered, notes) {
1976
2072
  if (anim.along) {
1977
2073
  const path = nodes.get(anim.along);
1978
2074
  if (!path || path.node.type !== "path") notes.push(`${where} follows "${anim.along}", which is not a path in the artwork; the follow was skipped.`);
1979
- else out.push({ type: "follow", targets, path: anim.along, from: _nullishCoalesce(_optionalChain([anim, 'access', _51 => _51.at, 'optionalAccess', _52 => _52[0]]), () => ( 0)), to: _nullishCoalesce(_optionalChain([anim, 'access', _53 => _53.at, 'optionalAccess', _54 => _54[1]]), () => ( 1)), ...anim.orient ? { rotate: true } : {}, ...timing, ...ease(MOTION_EASE_IN_OUT) });
2075
+ else out.push({ type: "follow", targets, path: anim.along, from: _nullishCoalesce(_optionalChain([anim, 'access', _58 => _58.at, 'optionalAccess', _59 => _59[0]]), () => ( 0)), to: _nullishCoalesce(_optionalChain([anim, 'access', _60 => _60.at, 'optionalAccess', _61 => _61[1]]), () => ( 1)), ...anim.orient ? { rotate: true } : {}, ...timing, ...ease(MOTION_EASE_IN_OUT) });
1980
2076
  }
1981
2077
  for (const [attr, [a, b]] of Object.entries(_nullishCoalesce(anim.attrs, () => ( {})))) {
1982
2078
  if (attr === "opacity") {
@@ -1987,7 +2083,7 @@ function commandsFor(anim, nodes, entered, notes) {
1987
2083
  out.push({ type: "enter", targets, style: "fade", ...timing, ...ease(MOTION_EASE_OUT) });
1988
2084
  } else out.push({ type: "fade", targets, to: Math.min(1, Math.max(0, to)), ...timing, ...ease(MOTION_EASE_IN_OUT) });
1989
2085
  } else if (attr === "fill" || attr === "stroke") {
1990
- if (_optionalChain([behavior, 'optionalAccess', _55 => _55.type]) === "pulse") out.push({ type: "color-pulse", targets, color: String(b), paint: attr, count: _nullishCoalesce(behavior.count, () => ( 1)), ...timing, ...ease("linear") });
2086
+ if (_optionalChain([behavior, 'optionalAccess', _62 => _62.type]) === "pulse") out.push({ type: "color-pulse", targets, color: String(b), paint: attr, count: _nullishCoalesce(behavior.count, () => ( 1)), ...timing, ...ease("linear") });
1991
2087
  else {
1992
2088
  if (behavior) notes.push(`${where}: a ${attr} ${behavior.type} became a plain colour change.`);
1993
2089
  out.push({ type: "style", targets, [attr]: String(b), ...timing, ...ease(MOTION_EASE_IN_OUT) });
@@ -2154,7 +2250,7 @@ var AnimationEditorController = (_class = class {
2154
2250
  time: p.time,
2155
2251
  playing: p.playing,
2156
2252
  loop: p.loop,
2157
- currentShot: _nullishCoalesce(_optionalChain([motionShotAt, 'call', _56 => _56(timeline, p.time), 'optionalAccess', _57 => _57.id]), () => ( null)),
2253
+ currentShot: _nullishCoalesce(_optionalChain([motionShotAt, 'call', _63 => _63(timeline, p.time), 'optionalAccess', _64 => _64.id]), () => ( null)),
2158
2254
  selection: this.selection,
2159
2255
  readOnly: this.readOnly,
2160
2256
  error: this.error,
@@ -2193,7 +2289,7 @@ var AnimationEditorController = (_class = class {
2193
2289
  this.applying = false;
2194
2290
  }
2195
2291
  this.notify();
2196
- _optionalChain([this, 'access', _58 => _58.onChange, 'optionalCall', _59 => _59(scene)]);
2292
+ _optionalChain([this, 'access', _65 => _65.onChange, 'optionalCall', _66 => _66(scene)]);
2197
2293
  }
2198
2294
  commit(label, next) {
2199
2295
  const before = this.scene;
@@ -2460,7 +2556,13 @@ var SHOT_FIELDS = {
2460
2556
  label: { type: "string", description: "What happens, in a few words." },
2461
2557
  camera: CAMERA,
2462
2558
  run: { type: "array", items: MOTION_COMMAND_SCHEMA, description: "Commands, in order." },
2463
- hold: MS("Hold after the commands (default 600)")
2559
+ hold: MS("Hold after the commands (default 600)"),
2560
+ 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." },
2561
+ at: object(
2562
+ { beat: { type: "number", minimum: 0, description: `Beat number on the scene's beat grid, from 0 (2.5 is the "and" of beat 3).` }, word: { type: "integer", minimum: 0, description: "Index of a narration word, from 0." }, ms: { type: "number", minimum: 0, description: "A time in ms." } },
2563
+ [],
2564
+ "Start this shot on a cue (give one of beat, word or ms); the shot before holds until then. get_timing_grid lists beats and words."
2565
+ )
2464
2566
  };
2465
2567
  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.");
2466
2568
  var CONTROL = object(
@@ -2480,6 +2582,20 @@ var SETTINGS = {
2480
2582
  loop: { type: "string", enum: ["none", "loop", "ping-pong"], description: "Playback past the end (default none)." },
2481
2583
  fps: { type: "number", minimum: 1, maximum: 120, description: "Sampling rate for exports (default 30)." },
2482
2584
  steps: { anyOf: [{ type: "number", exclusiveMinimum: 0, maximum: 120 }, { type: "null" }], description: 'Stepped time for every command without its own: changes hold on a grid of this many frames per second (12 = "on twos" at 24 fps) for a hand-drawn look; null makes it smooth again (default).' },
2585
+ beat: {
2586
+ anyOf: [
2587
+ object({ bpm: { type: "number", minimum: 1, maximum: 1e3, description: "Beats a minute." }, offset: { type: "number", description: "When beat 0 falls, ms (default 0)." }, meter: { type: "integer", minimum: 1, description: "Beats a bar (default 4)." } }, ["bpm"]),
2588
+ { type: "null" }
2589
+ ],
2590
+ description: `A beat grid (music or a voiceover's rhythm) that shots can be cued to with "at": { "beat": n }; null clears.`
2591
+ },
2592
+ narration: {
2593
+ anyOf: [
2594
+ object({ words: { type: "array", items: object({ text: { type: "string", description: "The word." }, start: { type: "number", minimum: 0, description: "When it is said, ms." }, end: { type: "number", minimum: 0, description: "When it ends, ms." } }, ["text", "start"]), description: "Words in time order, from a TTS or speech-to-text word timing." } }, ["words"]),
2595
+ { type: "null" }
2596
+ ],
2597
+ description: `A voiceover's word timings that shots can be cued to with "at": { "word": index }; null clears.`
2598
+ },
2483
2599
  target: { anyOf: [{ type: "array", items: { type: "number", minimum: 0 }, minItems: 2, maxItems: 2 }, { type: "null" }], description: "Wanted length [min, max] in seconds; null clears." }
2484
2600
  };
2485
2601
  var EDIT_OP = {
@@ -2487,7 +2603,7 @@ var EDIT_OP = {
2487
2603
  oneOf: [
2488
2604
  object({ op: { const: "set_settings", description: "Like set_animation_settings." }, ...SETTINGS }, ["op"]),
2489
2605
  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"]),
2490
- 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"]),
2606
+ 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", "at"] }, description: "Fields to clear." } }, ["op", "shot"]),
2491
2607
  object({ op: { const: "delete_shots", description: "Like delete_shots." }, shots: { type: "array", items: SHOT_ID, minItems: 1, description: "Shot ids." } }, ["op", "shots"]),
2492
2608
  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"]),
2493
2609
  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"]),
@@ -2532,6 +2648,8 @@ function describeMotionShots(scene) {
2532
2648
  end: t.end,
2533
2649
  ...shot.camera ? { camera: shot.camera } : {},
2534
2650
  ...shot.hold !== void 0 ? { hold: shot.hold } : {},
2651
+ ...t.cue !== void 0 ? { cue: Math.round(t.cue) } : {},
2652
+ ...t.missedBy !== void 0 ? { missedBy: Math.round(t.missedBy) } : {},
2535
2653
  commands: shot.run.map((c, index) => {
2536
2654
  const ct = timeline.commands.find((x) => x.shotIndex === i && x.command === index);
2537
2655
  const primitive = motionFlairPrimitive(c);
@@ -2660,6 +2778,42 @@ function createAnimationEditorToolProvider(controller, options = {}) {
2660
2778
  const CURVES_DESCRIPTION = "Write cubic-bezier easings (ease, ease-in, ease-out, ease-in-out, back eases, custom curves) as one change with ease: [x1, y1, x2, y2] (default true). Needs Flair 0.36 or later; false samples them into linear steps for older Flair.";
2661
2779
  const FLAIR_DESCRIPTION = "The Flair version to export for (default 0.39): springs and stepped time need 0.39, behaviours 0.38, cubic-bezier curves 0.36; older versions get those sampled into linear steps.";
2662
2780
  const BEHAVIORS_DESCRIPTION = "Write pulses, shakes, teeters, waves, wiggles and colour pulses as one change with a Flair behavior, so they play exactly (default true; needs Flair 0.38 or later). False samples them into linear steps for older Flair.";
2781
+ const getTimingGrid = {
2782
+ name: "get_timing_grid",
2783
+ title: "Read the beat grid and narration",
2784
+ description: 'Returns the beats (number, time in ms, bar and beat in the bar) and narration words (index, text, start, end) between two times, to cue shots to with "at". Also says which shot plays at each. Without a beat grid or narration, that part is empty and says how to add one.',
2785
+ inputSchema: object({
2786
+ from: { type: "number", minimum: 0, description: "From this time, ms (default 0)." },
2787
+ to: { type: "number", minimum: 0, description: "Up to this time, ms (default: the end of the animation, or of the narration if later)." },
2788
+ limit: { type: "integer", minimum: 1, maximum: 1e3, description: "At most this many beats and words each (default 200)." }
2789
+ }),
2790
+ annotations: { title: "Read the beat grid and narration", ...READ },
2791
+ handler: ({ from = 0, to, limit = 200 }) => {
2792
+ const s = scene();
2793
+ const timeline = compileMotionScene(s);
2794
+ const words = _nullishCoalesce(_optionalChain([s, 'access', _67 => _67.narration, 'optionalAccess', _68 => _68.words]), () => ( []));
2795
+ const end = _nullishCoalesce(to, () => ( Math.max(timeline.duration, words.length ? _nullishCoalesce(words[words.length - 1].end, () => ( words[words.length - 1].start)) : 0)));
2796
+ const shotAt = (ms) => _optionalChain([timeline, 'access', _69 => _69.shots, 'access', _70 => _70.find, 'call', _71 => _71((x) => ms >= x.start && ms < x.end), 'optionalAccess', _72 => _72.id]);
2797
+ const beats = [];
2798
+ if (s.beat && s.beat.bpm > 0) {
2799
+ const first = Math.max(0, Math.ceil(motionBeatAt(s.beat, from).beat - 1e-9));
2800
+ for (let b = first; beats.length < limit; b++) {
2801
+ const ms = motionBeatTime(s.beat, b);
2802
+ if (ms > end) break;
2803
+ const at = motionBeatAt(s.beat, ms);
2804
+ const shot = shotAt(ms);
2805
+ beats.push({ beat: b, ms: Math.round(ms), bar: at.bar, beatInBar: at.beatInBar, ...shot ? { shot } : {} });
2806
+ }
2807
+ }
2808
+ const listed = words.map((w, index) => ({ index, text: w.text, start: w.start, ...w.end !== void 0 ? { end: w.end } : {}, ...shotAt(w.start) ? { shot: shotAt(w.start) } : {} })).filter((w) => w.start >= from && w.start <= end).slice(0, limit);
2809
+ return {
2810
+ from,
2811
+ to: Math.round(end),
2812
+ ...s.beat ? { grid: s.beat, beats } : { beats: [], hint: 'No beat grid: set_animation_settings with { "beat": { "bpm": 120 } }.' },
2813
+ ...words.length ? { words: listed } : { words: [], narrationHint: 'No narration: set_animation_settings with { "narration": { "words": [{ "text", "start" }, \u2026] } }.' }
2814
+ };
2815
+ }
2816
+ };
2663
2817
  const exportFlairMotion = {
2664
2818
  name: "export_flair_motion",
2665
2819
  title: "Export as Flair vector motion",
@@ -2734,6 +2888,27 @@ function createAnimationEditorToolProvider(controller, options = {}) {
2734
2888
  return outcome(applied, applied.ok ? { ...report, elements: describeMotionElements(result.scene) } : {});
2735
2889
  }
2736
2890
  };
2891
+ const fitAnimation = {
2892
+ name: "fit_animation",
2893
+ title: "Fit the animation to a length",
2894
+ 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.`,
2895
+ inputSchema: object(
2896
+ {
2897
+ seconds: { type: "number", exclusiveMinimum: 0, description: "The length to fit, in seconds." },
2898
+ keep: { type: "array", items: SHOT_ID, description: "Shots that keep their pace, e.g. a logo hold (default none)." },
2899
+ dry_run: { type: "boolean", description: "Report without changing anything (default false)." }
2900
+ },
2901
+ ["seconds"]
2902
+ ),
2903
+ annotations: { title: "Fit the animation to a length", ...WRITE_IDEMPOTENT },
2904
+ handler: ({ seconds, keep, dry_run = false }) => {
2905
+ const fit = fitMotionScene(controller.getState().scene, seconds * 1e3, { keep: _nullishCoalesce(keep, () => ( [])) });
2906
+ const report = { duration: fit.duration, speeds: fit.speeds };
2907
+ if (dry_run) return { dry_run: true, ...report, hint: "Nothing was changed; call again without dry_run to fit." };
2908
+ const edits = Object.entries(fit.speeds).map(([shot, speed]) => speed === 1 ? { op: "update_shot", shot, remove: ["speed"] } : { op: "update_shot", shot, set: { speed } });
2909
+ return apply(edits, "Fit to length", () => report);
2910
+ }
2911
+ };
2737
2912
  const insertShots = {
2738
2913
  name: "insert_shots",
2739
2914
  title: "Add shots",
@@ -2750,7 +2925,7 @@ function createAnimationEditorToolProvider(controller, options = {}) {
2750
2925
  {
2751
2926
  shot: SHOT_ID,
2752
2927
  set: object({ id: SHOT.properties.id, ...SHOT_FIELDS }, [], "Fields to replace."),
2753
- remove: { type: "array", items: { type: "string", enum: ["label", "camera", "hold"] }, description: "Fields to clear." }
2928
+ remove: { type: "array", items: { type: "string", enum: ["label", "camera", "hold", "speed", "at"] }, description: "Fields to clear." }
2754
2929
  },
2755
2930
  ["shot"]
2756
2931
  ),
@@ -2780,7 +2955,7 @@ function createAnimationEditorToolProvider(controller, options = {}) {
2780
2955
  inputSchema: object({ 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 run (default: the end)." } }, ["shot", "commands"]),
2781
2956
  annotations: { title: "Add commands to a shot", ...WRITE },
2782
2957
  handler: ({ shot, commands, at }) => {
2783
- const before = _nullishCoalesce(_optionalChain([scene, 'call', _60 => _60(), 'access', _61 => _61.shots, 'access', _62 => _62.find, 'call', _63 => _63((s) => s.id === shot), 'optionalAccess', _64 => _64.run, 'access', _65 => _65.length]), () => ( 0));
2958
+ const before = _nullishCoalesce(_optionalChain([scene, 'call', _73 => _73(), 'access', _74 => _74.shots, 'access', _75 => _75.find, 'call', _76 => _76((s) => s.id === shot), 'optionalAccess', _77 => _77.run, 'access', _78 => _78.length]), () => ( 0));
2784
2959
  const first = _nullishCoalesce(at, () => ( before));
2785
2960
  return apply({ op: "add_commands", shot, commands, at }, "Add commands", (s) => {
2786
2961
  const shots = describeMotionShots(s);
@@ -2816,7 +2991,7 @@ function createAnimationEditorToolProvider(controller, options = {}) {
2816
2991
  const setSettings = {
2817
2992
  name: "set_animation_settings",
2818
2993
  title: "Change animation settings",
2819
- description: "Sets the name, description, loop mode, export frame rate, stepped time or target length. Returns the timing.",
2994
+ description: "Sets the name, description, loop mode, export frame rate, stepped time, beat grid, narration word timings or target length. Returns the timing.",
2820
2995
  inputSchema: object(SETTINGS),
2821
2996
  annotations: { title: "Change animation settings", ...WRITE_IDEMPOTENT },
2822
2997
  handler: (args) => apply({ op: "set_settings", ...args }, "Change settings")
@@ -2894,8 +3069,8 @@ function createAnimationEditorToolProvider(controller, options = {}) {
2894
3069
  return { redone, ...redone ? { label } : {} };
2895
3070
  }
2896
3071
  };
2897
- const reads = [getAnimation, getShot, findElements, checkAnimation, renderFrame, exportFlairMotion, exportFlairEffect, seekAnimation];
2898
- const writes = [setArtworkSvg, importFlairEffect, insertShots, updateShot, deleteShots, moveShot, addCommands, updateCommand, deleteCommands, setSettings, setControls, editAnimation, writeAnimation, undo, redo];
3072
+ const reads = [getAnimation, getShot, findElements, getTimingGrid, checkAnimation, renderFrame, exportFlairMotion, exportFlairEffect, seekAnimation];
3073
+ const writes = [setArtworkSvg, fitAnimation, importFlairEffect, insertShots, updateShot, deleteShots, moveShot, addCommands, updateCommand, deleteCommands, setSettings, setControls, editAnimation, writeAnimation, undo, redo];
2899
3074
  return {
2900
3075
  id: _nullishCoalesce(options.id, () => ( "animation")),
2901
3076
  namespace: options.namespace,
@@ -2933,6 +3108,7 @@ Commands
2933
3108
  - 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.
2934
3109
  - 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.
2935
3110
  - Springs ("spring-gentle", "spring-quick", "spring-bouncy", "spring-slow" or { "type": "spring", \u2026 }) feel physical; leave "duration" out and the spring sets it.
3111
+ - To time shots to music or a voiceover: set a "beat" grid or "narration" word timings with set_animation_settings, read them with get_timing_grid, and give a shot "at": { "beat": n } or { "word": i }. The shot before holds until the cue; check_animation warns when a cue is missed.
2936
3112
  - "steps": 12 (on a command, or for the whole scene with set_animation_settings) holds poses "on twos" for a hand-drawn look. "geometric": true on scale or a camera zooms at an even rate.
2937
3113
  - Commands share their names and defaults with Flair's motion primitives (get_animation shows each one's "flairPrimitive"), and emphasis, reveals and follows export to Flair exactly.
2938
3114
  - effect tweens a numeric parameter of a live path effect already on the node (twist angle, wiggle time, zig zag size).
@@ -2940,7 +3116,7 @@ Commands
2940
3116
  Working method
2941
3117
  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).
2942
3118
  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).
2943
- 3. check_animation and fix every error and warning; render_animation_frame (shot + "end") to look at the result.
3119
+ 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.
2944
3120
  4. To make a reusable Flair effect, set_animation_controls (colour controls stand for every use of their default colour) and export_flair_effect.
2945
3121
 
2946
3122
  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.`;
@@ -3028,5 +3204,11 @@ Keep motion purposeful: one thing moving at a time reads best, ease-out for arri
3028
3204
 
3029
3205
 
3030
3206
 
3031
- exports.ANIMATION_EDITOR_AGENT_INSTRUCTIONS = ANIMATION_EDITOR_AGENT_INSTRUCTIONS; exports.AnimationEditorController = AnimationEditorController; exports.FLAIR_EASES = FLAIR_EASES; exports.FLAIR_EXPORT_VERSION = FLAIR_EXPORT_VERSION; exports.MOTION_ADDITIVE_PROPERTIES = MOTION_ADDITIVE_PROPERTIES; exports.MOTION_BLENDS = MOTION_BLENDS; exports.MOTION_CAMERA_MS = MOTION_CAMERA_MS; exports.MOTION_COMMAND_SCHEMA = MOTION_COMMAND_SCHEMA; exports.MOTION_COMMAND_TYPES = MOTION_COMMAND_TYPES; exports.MOTION_DEFAULT_DURATIONS = MOTION_DEFAULT_DURATIONS; exports.MOTION_DEFAULT_HOLD = MOTION_DEFAULT_HOLD; exports.MOTION_DIRECTIONS = MOTION_DIRECTIONS; exports.MOTION_DRAW_EFFECT_ID = MOTION_DRAW_EFFECT_ID; exports.MOTION_DRAW_MS_PER_UNIT = MOTION_DRAW_MS_PER_UNIT; exports.MOTION_DRAW_RANGE = MOTION_DRAW_RANGE; exports.MOTION_EASE_IN_OUT = MOTION_EASE_IN_OUT; exports.MOTION_EASE_OUT = MOTION_EASE_OUT; exports.MOTION_EDIT_OPS = MOTION_EDIT_OPS; exports.MOTION_ENTER_STYLES = MOTION_ENTER_STYLES; exports.MOTION_EXIT_STYLES = MOTION_EXIT_STYLES; exports.MOTION_IRIS_SHAPES = MOTION_IRIS_SHAPES; exports.MOTION_ISSUE_CODES = MOTION_ISSUE_CODES; exports.MOTION_MIN_CHANGE_MS = MOTION_MIN_CHANGE_MS; exports.MOTION_REPEAT_MODES = MOTION_REPEAT_MODES; exports.MOTION_SPLIT_ORIENTATIONS = MOTION_SPLIT_ORIENTATIONS; exports.MotionSceneFormatError = MotionSceneFormatError; exports.activeSegment = activeSegment; exports.applyMotionControlValues = applyMotionControlValues; exports.applyMotionEdits = applyMotionEdits; exports.checkMotionScene = checkMotionScene; exports.commandShapeProblems = commandShapeProblems; exports.compileMotionScene = compileMotionScene; exports.createAnimationEditorToolProvider = createAnimationEditorToolProvider; exports.createMotionScene = createMotionScene; exports.describeMotionElements = describeMotionElements; exports.describeMotionShots = describeMotionShots; exports.fitMotionRect = fitMotionRect; exports.flairAtLeast = flairAtLeast; exports.flairCurveFor = flairCurveFor; exports.flairEaseFor = flairEaseFor; exports.freshShotId = freshShotId; exports.fromFlairSvgMotion = fromFlairSvgMotion; exports.fromFlairVectorEffect = fromFlairVectorEffect; exports.indexMotionNodes = indexMotionNodes; exports.interpolateMotionValue = interpolateMotionValue; exports.isMotionCommand = isMotionCommand; exports.isMotionShot = isMotionShot; exports.matchMotionNodes = matchMotionNodes; exports.motionAddTrackKey = motionAddTrackKey; exports.motionClipAttrs = motionClipAttrs; exports.motionCommandDuration = motionCommandDuration; exports.motionCommandRepeat = motionCommandRepeat; exports.motionCycleDuration = motionCycleDuration; exports.motionFlairPrimitive = motionFlairPrimitive; exports.motionFollowRoute = motionFollowRoute; exports.motionFrameAt = motionFrameAt; exports.motionFrameToSvg = motionFrameToSvg; exports.motionIssue = motionIssue; exports.motionIssueKey = motionIssueKey; exports.motionIssueLine = motionIssueLine; exports.motionNodeClips = motionNodeClips; exports.motionNodeOffset = motionNodeOffset; exports.motionPathLength = motionPathLength; exports.motionRestValue = motionRestValue; exports.motionRevealClip = motionRevealClip; exports.motionRevealPadding = motionRevealPadding; exports.motionSceneShapeProblems = motionSceneShapeProblems; exports.motionSceneToJson = motionSceneToJson; exports.motionShotAt = motionShotAt; exports.motionTrackKey = motionTrackKey; exports.namedWithSuggestion = namedWithSuggestion; exports.parseMotionScene = parseMotionScene; exports.resolveMotionCamera = resolveMotionCamera; exports.segmentProgress = segmentProgress; exports.segmentValue = segmentValue; exports.shotShapeProblems = shotShapeProblems; exports.suggestName = suggestName; exports.toFlairEffectDocument = toFlairEffectDocument; exports.toFlairSvgMotion = toFlairSvgMotion; exports.toFlairVectorEffect = toFlairVectorEffect; exports.trackValue = trackValue; exports.withMotionLayers = withMotionLayers; exports.withViewBox = withViewBox;
3207
+
3208
+
3209
+
3210
+
3211
+
3212
+
3213
+ exports.ANIMATION_EDITOR_AGENT_INSTRUCTIONS = ANIMATION_EDITOR_AGENT_INSTRUCTIONS; exports.AnimationEditorController = AnimationEditorController; exports.FLAIR_EASES = FLAIR_EASES; exports.FLAIR_EXPORT_VERSION = FLAIR_EXPORT_VERSION; exports.MOTION_ADDITIVE_PROPERTIES = MOTION_ADDITIVE_PROPERTIES; exports.MOTION_BLENDS = MOTION_BLENDS; exports.MOTION_CAMERA_MS = MOTION_CAMERA_MS; exports.MOTION_COMMAND_SCHEMA = MOTION_COMMAND_SCHEMA; exports.MOTION_COMMAND_TYPES = MOTION_COMMAND_TYPES; exports.MOTION_DEFAULT_DURATIONS = MOTION_DEFAULT_DURATIONS; exports.MOTION_DEFAULT_HOLD = MOTION_DEFAULT_HOLD; exports.MOTION_DIRECTIONS = MOTION_DIRECTIONS; exports.MOTION_DRAW_EFFECT_ID = MOTION_DRAW_EFFECT_ID; exports.MOTION_DRAW_MS_PER_UNIT = MOTION_DRAW_MS_PER_UNIT; exports.MOTION_DRAW_RANGE = MOTION_DRAW_RANGE; exports.MOTION_EASE_IN_OUT = MOTION_EASE_IN_OUT; exports.MOTION_EASE_OUT = MOTION_EASE_OUT; exports.MOTION_EDIT_OPS = MOTION_EDIT_OPS; exports.MOTION_ENTER_STYLES = MOTION_ENTER_STYLES; exports.MOTION_EXIT_STYLES = MOTION_EXIT_STYLES; exports.MOTION_IRIS_SHAPES = MOTION_IRIS_SHAPES; exports.MOTION_ISSUE_CODES = MOTION_ISSUE_CODES; exports.MOTION_MIN_CHANGE_MS = MOTION_MIN_CHANGE_MS; exports.MOTION_REPEAT_MODES = MOTION_REPEAT_MODES; exports.MOTION_SPEED_RANGE = MOTION_SPEED_RANGE; exports.MOTION_SPLIT_ORIENTATIONS = MOTION_SPLIT_ORIENTATIONS; exports.MotionSceneFormatError = MotionSceneFormatError; exports.activeSegment = activeSegment; exports.applyMotionControlValues = applyMotionControlValues; exports.applyMotionEdits = applyMotionEdits; exports.checkMotionScene = checkMotionScene; exports.commandShapeProblems = commandShapeProblems; exports.compileMotionScene = compileMotionScene; exports.createAnimationEditorToolProvider = createAnimationEditorToolProvider; exports.createMotionScene = createMotionScene; exports.describeMotionElements = describeMotionElements; exports.describeMotionShots = describeMotionShots; exports.fitMotionRect = fitMotionRect; exports.fitMotionScene = fitMotionScene; exports.flairAtLeast = flairAtLeast; exports.flairCurveFor = flairCurveFor; exports.flairEaseFor = flairEaseFor; exports.freshShotId = freshShotId; exports.fromFlairSvgMotion = fromFlairSvgMotion; exports.fromFlairVectorEffect = fromFlairVectorEffect; exports.indexMotionNodes = indexMotionNodes; exports.interpolateMotionValue = interpolateMotionValue; exports.isMotionCommand = isMotionCommand; exports.isMotionShot = isMotionShot; exports.matchMotionNodes = matchMotionNodes; exports.motionAddTrackKey = motionAddTrackKey; exports.motionBeatAt = motionBeatAt; exports.motionBeatTime = motionBeatTime; exports.motionClipAttrs = motionClipAttrs; exports.motionCommandDuration = motionCommandDuration; exports.motionCommandRepeat = motionCommandRepeat; exports.motionCueTime = motionCueTime; exports.motionCycleDuration = motionCycleDuration; exports.motionFlairPrimitive = motionFlairPrimitive; exports.motionFollowRoute = motionFollowRoute; exports.motionFrameAt = motionFrameAt; exports.motionFrameToSvg = motionFrameToSvg; exports.motionIssue = motionIssue; exports.motionIssueKey = motionIssueKey; exports.motionIssueLine = motionIssueLine; exports.motionNodeClips = motionNodeClips; exports.motionNodeOffset = motionNodeOffset; exports.motionPathLength = motionPathLength; exports.motionRestValue = motionRestValue; exports.motionRevealClip = motionRevealClip; exports.motionRevealPadding = motionRevealPadding; exports.motionSceneShapeProblems = motionSceneShapeProblems; exports.motionSceneToJson = motionSceneToJson; exports.motionShotAt = motionShotAt; exports.motionShotTimeScale = motionShotTimeScale; exports.motionTrackKey = motionTrackKey; exports.namedWithSuggestion = namedWithSuggestion; exports.parseMotionScene = parseMotionScene; exports.resolveMotionCamera = resolveMotionCamera; exports.segmentProgress = segmentProgress; exports.segmentValue = segmentValue; exports.shotShapeProblems = shotShapeProblems; exports.suggestName = suggestName; exports.toFlairEffectDocument = toFlairEffectDocument; exports.toFlairSvgMotion = toFlairSvgMotion; exports.toFlairVectorEffect = toFlairVectorEffect; exports.trackValue = trackValue; exports.withMotionLayers = withMotionLayers; exports.withViewBox = withViewBox;
3032
3214
  //# sourceMappingURL=index.cjs.map