@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 >= (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 = 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 (grid.offset ?? 0) + beat * 6e4 / grid.bpm;
726
+ }
727
+ function motionBeatAt(grid, ms) {
728
+ const beat = (ms - (grid.offset ?? 0)) * grid.bpm / 6e4;
729
+ const meter = Math.max(1, Math.round(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 scene.narration?.words[cue.word]?.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 = 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 : shot.camera.ms ?? MOTION_CAMERA_MS;
788
+ const ms = index === 0 ? 0 : (shot.camera.ms ?? MOTION_CAMERA_MS) * f;
741
789
  cameraMoves.push({ shot: shot.id, start, end: start + ms, to: rect, ease: 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 : command2.delay ?? 0);
798
+ const begin = base + (command2.type === "wait" ? 0 : (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 : 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 : (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: durations[0] ?? 0 });
756
804
  prevStart = begin;
757
805
  runEnd = Math.max(runEnd, end2);
758
806
  });
759
- const end = runEnd + (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 + (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 * (command2.stagger ?? 0);
793
- const cycle = motionCycleDuration(command2, scene, target);
840
+ const f = motionShotTimeScale(shot);
841
+ const start = timing.start + k * (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
  }
@@ -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 ${scene.narration?.words.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++) {
@@ -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;
@@ -1590,6 +1658,34 @@ function createMotionScene(artwork, init = {}) {
1590
1658
  return { version: 1, name: init.name ?? "animation", ...init, artwork, shots: 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(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((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,
@@ -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 = s.narration?.words ?? [];
2795
+ const end = to ?? Math.max(timeline.duration, words.length ? words[words.length - 1].end ?? words[words.length - 1].start : 0);
2796
+ const shotAt = (ms) => timeline.shots.find((x) => ms >= x.start && ms < x.end)?.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: 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
  ),
@@ -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: 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.`;
@@ -2969,6 +3145,7 @@ export {
2969
3145
  MOTION_ISSUE_CODES,
2970
3146
  MOTION_MIN_CHANGE_MS,
2971
3147
  MOTION_REPEAT_MODES,
3148
+ MOTION_SPEED_RANGE,
2972
3149
  MOTION_SPLIT_ORIENTATIONS,
2973
3150
  MotionSceneFormatError,
2974
3151
  activeSegment,
@@ -2982,6 +3159,7 @@ export {
2982
3159
  describeMotionElements,
2983
3160
  describeMotionShots,
2984
3161
  fitMotionRect,
3162
+ fitMotionScene,
2985
3163
  flairAtLeast,
2986
3164
  flairCurveFor,
2987
3165
  flairEaseFor,
@@ -2994,9 +3172,12 @@ export {
2994
3172
  isMotionShot,
2995
3173
  matchMotionNodes,
2996
3174
  motionAddTrackKey,
3175
+ motionBeatAt,
3176
+ motionBeatTime,
2997
3177
  motionClipAttrs,
2998
3178
  motionCommandDuration,
2999
3179
  motionCommandRepeat,
3180
+ motionCueTime,
3000
3181
  motionCycleDuration,
3001
3182
  motionFlairPrimitive,
3002
3183
  motionFollowRoute,
@@ -3014,6 +3195,7 @@ export {
3014
3195
  motionSceneShapeProblems,
3015
3196
  motionSceneToJson,
3016
3197
  motionShotAt,
3198
+ motionShotTimeScale,
3017
3199
  motionTrackKey,
3018
3200
  namedWithSuggestion,
3019
3201
  parseMotionScene,