@pixerate/editor 0.64.0 → 0.66.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.
@@ -45,6 +45,7 @@ import {
45
45
  rgbaToHex
46
46
  } from "../chunk-TGD52FEZ.js";
47
47
  import {
48
+ ANIMATION_EASING_LABELS,
48
49
  ANIMATION_EASING_PRESETS,
49
50
  evaluateEasing,
50
51
  isAnimationEasing,
@@ -1258,6 +1259,88 @@ function withViewBox(svg, box) {
1258
1259
  return svg.replace(/viewBox="[^"]*"/, `viewBox="${f(box.x)} ${f(box.y)} ${f(box.width)} ${f(box.height)}"`);
1259
1260
  }
1260
1261
 
1262
+ // src/animation-editor/onion.ts
1263
+ function motionGhosts(scene, time, onion = {}) {
1264
+ const before = Math.max(0, Math.floor(onion.before ?? 2));
1265
+ const after = Math.max(0, Math.floor(onion.after ?? 0));
1266
+ const base = Math.min(1, Math.max(0, onion.opacity ?? 0.35));
1267
+ const timeline = compileMotionScene(scene);
1268
+ const ghosts = [];
1269
+ const push = (t, offset) => ghosts.push({ time: t, offset, opacity: Math.round(base / Math.abs(offset) * 1e3) / 1e3 });
1270
+ if (onion.spacing === "commands") {
1271
+ const marks = [...new Set(timeline.commands.flatMap((c) => [c.start, c.end]))].sort((a, b) => a - b);
1272
+ marks.filter((t) => t < time - 1e-6).reverse().slice(0, before).forEach((t, i) => push(t, -(i + 1)));
1273
+ marks.filter((t) => t > time + 1e-6).slice(0, after).forEach((t, i) => push(t, i + 1));
1274
+ return ghosts;
1275
+ }
1276
+ const spacing = typeof onion.spacing === "number" && onion.spacing > 0 ? onion.spacing : 1e3 / 12;
1277
+ for (let i = 1; i <= before && time - i * spacing >= 0; i++) push(time - i * spacing, -i);
1278
+ for (let i = 1; i <= after && time + i * spacing <= timeline.duration; i++) push(time + i * spacing, i);
1279
+ return ghosts;
1280
+ }
1281
+ var MOVING = ["x", "y", "rotation", "scaleX", "scaleY", "follow", "shape"];
1282
+ function motionMovingIds(scene) {
1283
+ const timeline = compileMotionScene(scene);
1284
+ const nodes = indexMotionNodes(scene.artwork.nodes);
1285
+ const moving = new Set(timeline.segments.filter((s) => s.node && MOVING.includes(s.property)).map((s) => s.node));
1286
+ return [...nodes.values()].filter((e) => e.node.visible !== false && (moving.has(e.node.id) || e.ancestors.some((a) => moving.has(a)))).map((e) => e.node.id);
1287
+ }
1288
+ function motionPath(scene, id, options = {}) {
1289
+ const nodes = indexMotionNodes(scene.artwork.nodes);
1290
+ const entry = nodes.get(id);
1291
+ if (!entry) throw new Error(`There is no element "${id}" in the artwork.`);
1292
+ const timeline = compileMotionScene(scene);
1293
+ const from = Math.max(0, options.from ?? 0);
1294
+ const to = Math.min(timeline.duration, options.to ?? timeline.duration);
1295
+ let step = options.step && options.step > 0 ? options.step : 1e3 / 30;
1296
+ if ((to - from) / step > 400) step = (to - from) / 400;
1297
+ const r = (n) => Math.round(n * 100) / 100;
1298
+ const centreAt = (t) => {
1299
+ const node = indexMotionNodes(motionFrameAtCache(scene, t)).get(id);
1300
+ const b = node?.bounds;
1301
+ return b ? { t: r(t), x: r(b.x + b.width / 2), y: r(b.y + b.height / 2) } : null;
1302
+ };
1303
+ const points = [];
1304
+ for (let t = from; t <= to + 1e-6; t += step) {
1305
+ const p = centreAt(Math.min(t, to));
1306
+ if (p) points.push(p);
1307
+ }
1308
+ const owners = /* @__PURE__ */ new Set([id, ...entry.ancestors]);
1309
+ const times = [...new Set(timeline.segments.filter((s) => owners.has(s.node) && MOVING.includes(s.property)).flatMap((s) => [s.start, s.end]))].filter((t) => t >= from && t <= to).sort((a, b) => a - b);
1310
+ const keys = times.map(centreAt).filter((p) => p !== null);
1311
+ return { id, points, keys };
1312
+ }
1313
+ function motionFrameAtCache(scene, t) {
1314
+ return motionFrameAt(scene, t).document.nodes;
1315
+ }
1316
+ function motionPathsSvg(paths, camera, color = "#f43f5e") {
1317
+ const w = Math.max(camera.width, camera.height) / 400;
1318
+ const f = (n) => String(Math.round(n * 100) / 100);
1319
+ return paths.filter((p) => p.points.length > 1).map(
1320
+ (p) => `<g data-motion-path="${p.id.replace(/"/g, "&quot;")}" fill="none" stroke="${color}" stroke-width="${f(w)}" stroke-linecap="round" stroke-linejoin="round" opacity="0.8"><polyline points="${p.points.map((q) => `${f(q.x)},${f(q.y)}`).join(" ")}" stroke-dasharray="${f(w * 3)} ${f(w * 2)}"/>${p.keys.map((k) => `<circle cx="${f(k.x)}" cy="${f(k.y)}" r="${f(w * 2.5)}" fill="${color}" stroke="none"/>`).join("")}</g>`
1321
+ ).join("");
1322
+ }
1323
+ var innerSvg = (svg) => svg.slice(svg.indexOf(">") + 1, svg.lastIndexOf("</svg>"));
1324
+ function motionStageSvg(scene, time, options = {}) {
1325
+ const { onionSkin, motionPaths, motionPathColor, ...svgOptions } = options;
1326
+ const current = motionFrameToSvg(scene, time, svgOptions);
1327
+ if (!onionSkin && !motionPaths) return current;
1328
+ const prefix = svgOptions.idPrefix ?? "vg-";
1329
+ const ghosts = onionSkin ? motionGhosts(scene, time, onionSkin).map((g, i) => {
1330
+ const own = `${prefix}ghost${i}-`;
1331
+ const inner = innerSvg(motionFrameToSvg(scene, g.time, { ...svgOptions, idPrefix: own, background: false })).replace(new RegExp(` id="(?!${own.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})[^"]*"`, "g"), "");
1332
+ return `<g data-onion="${g.offset < 0 ? "before" : "after"}" opacity="${g.opacity}">${inner}</g>`;
1333
+ }) : [];
1334
+ const camera = motionFrameAt(scene, time).camera;
1335
+ const ids = motionPaths === true ? motionMovingIds(scene) : motionPaths ? [...motionPaths] : [];
1336
+ const paths = ids.length ? motionPathsSvg(ids.map((id) => motionPath(scene, id)), camera, motionPathColor) : "";
1337
+ const open = current.indexOf(">") + 1;
1338
+ const close = current.lastIndexOf("</svg>");
1339
+ const body = current.slice(open, close);
1340
+ const bg = svgOptions.background !== false && scene.artwork.background ? /^\s*<rect[^>]*\/>/.exec(body)?.[0] ?? "" : "";
1341
+ return `${current.slice(0, open)}${bg}${ghosts.join("")}${body.slice(bg.length)}${paths}</svg>`;
1342
+ }
1343
+
1261
1344
  // src/animation-editor/check.ts
1262
1345
  var MOTION_MIN_CHANGE_MS = 120;
1263
1346
  var NUMERIC_PARAMS = (node, effectId) => {
@@ -1481,6 +1564,7 @@ var MOTION_EDIT_OPS = [
1481
1564
  "add_commands",
1482
1565
  "update_command",
1483
1566
  "delete_commands",
1567
+ "move_command",
1484
1568
  "set_artwork",
1485
1569
  "set_controls",
1486
1570
  "replace_scene"
@@ -1605,6 +1689,16 @@ function applyOne(scene, edit) {
1605
1689
  const drop = new Set(edit.commands);
1606
1690
  return replaceShot(scene, i, { ...scene.shots[i], run: run.filter((_, k) => !drop.has(k)) });
1607
1691
  }
1692
+ case "move_command": {
1693
+ const i = shotIndex(scene, edit.shot);
1694
+ const run = [...scene.shots[i].run];
1695
+ if (!Number.isInteger(edit.command) || edit.command < 0 || edit.command >= run.length)
1696
+ fail(`Shot "${edit.shot}" has ${run.length} command${run.length === 1 ? "" : "s"}; "command" must be an index from 0 to ${run.length - 1} (got ${edit.command}).`);
1697
+ const to = position(edit.to, run.length - 1, '"to"');
1698
+ const [moved] = run.splice(edit.command, 1);
1699
+ run.splice(to, 0, moved);
1700
+ return replaceShot(scene, i, { ...scene.shots[i], run });
1701
+ }
1608
1702
  case "set_artwork":
1609
1703
  return { ...scene, artwork: edit.artwork };
1610
1704
  case "set_controls": {
@@ -2173,11 +2267,17 @@ function fromFlairSvgMotion(input, options = {}) {
2173
2267
  ${problems.join("\n")}`);
2174
2268
  return { scene, notes: [...new Set(notes)], issues: checkMotionScene(scene) };
2175
2269
  }
2270
+ function presetColor(value) {
2271
+ if (typeof value === "string") return /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : value;
2272
+ if (Array.isArray(value) && value.length >= 3 && value.slice(0, 3).every((n) => typeof n === "number" && Number.isFinite(n)))
2273
+ return `#${value.slice(0, 3).map((n) => Math.round(Math.min(1, Math.max(0, n)) * 255).toString(16).padStart(2, "0")).join("")}`;
2274
+ return void 0;
2275
+ }
2176
2276
  function fromFlairVectorEffect(effect, options = {}) {
2177
2277
  if (!effect || effect.kind !== "vector" || !effect.motion) throw new Error('Give a Flair vector effect: { kind: "vector", id, name, motion: { svg, animate } }.');
2178
2278
  const result = fromFlairSvgMotion(effect.motion, { name: options.name ?? effect.id ?? effect.name, hold: options.hold ?? effect.hold });
2179
2279
  const names = new Set((result.scene.controls ?? []).map((c) => c.name));
2180
- const presets = (effect.presets ?? []).map((p) => ({ name: p.name, values: Object.fromEntries(Object.entries(p.values).filter(([k]) => names.has(k))) })).filter((p) => Object.keys(p.values).length);
2280
+ const presets = (effect.presets ?? []).map((p) => ({ name: p.name, values: Object.fromEntries(Object.entries(p.values).flatMap(([k, v]) => names.has(k) && presetColor(v) ? [[k, presetColor(v)]] : [])) })).filter((p) => Object.keys(p.values).length);
2181
2281
  const scene = { ...result.scene, ...effect.description ? { description: effect.description } : {}, ...presets.length ? { presets } : {} };
2182
2282
  const notes = [...result.notes];
2183
2283
  if (effect.trigger) notes.push(`The effect plays on ${effect.trigger}; scenes play through, so the trigger is not kept (pass it back to toFlairVectorEffect).`);
@@ -2701,7 +2801,7 @@ var AnimationEditorController = class {
2701
2801
  }
2702
2802
  /** The scene at `time` (default: the playhead) as SVG. */
2703
2803
  toSvg(time = this.player.currentTime, options = {}) {
2704
- return motionFrameToSvg(this.scene, time, options);
2804
+ return motionStageSvg(this.scene, time, options);
2705
2805
  }
2706
2806
  /** The scene as a Flair `SvgMotion`. */
2707
2807
  toFlairMotion(options = {}) {
@@ -2914,6 +3014,7 @@ var EDIT_OP = {
2914
3014
  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"]),
2915
3015
  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"]),
2916
3016
  object({ op: { const: "update_command", description: "Like update_command." }, shot: SHOT_ID, command: { type: "integer", minimum: 0, description: "Index in the shot's run." }, set: { type: "object", description: "Command fields to replace." }, remove: { type: "array", items: { type: "string" }, description: "Command fields to clear." } }, ["op", "shot", "command"]),
3017
+ object({ op: { const: "move_command", description: "Like move_command." }, shot: SHOT_ID, command: { type: "integer", minimum: 0, description: "Index in the shot's run." }, to: { type: "integer", minimum: 0, description: "New index in the run (0 = first)." } }, ["op", "shot", "command", "to"]),
2917
3018
  object({ op: { const: "delete_commands", description: "Like delete_commands." }, shot: SHOT_ID, commands: { type: "array", items: { type: "integer", minimum: 0 }, minItems: 1, description: "Indexes in the shot's run." } }, ["op", "shot", "commands"]),
2918
3019
  object({ op: { const: "set_controls", description: "Like set_controls." }, controls: { type: "array", items: CONTROL, description: "Controls (empty clears)." }, presets: { type: "array", items: PRESET, description: "Presets (empty clears)." } }, ["op", "controls"])
2919
3020
  ]
@@ -3061,10 +3162,20 @@ function createAnimationEditorToolProvider(controller, options = {}) {
3061
3162
  time: MS("Time from the start"),
3062
3163
  shot: { ...SHOT_ID, description: 'Render a moment of this shot instead of "time".' },
3063
3164
  at: { type: "string", enum: ["start", "middle", "end"], description: "With shot: which moment (default end: everything drawn, before the next shot)." },
3064
- format: { type: "string", enum: ["svg", "png"], description: "svg (default) or png (only when the host supports it)." }
3165
+ format: { type: "string", enum: ["svg", "png"], description: "svg (default) or png (only when the host supports it)." },
3166
+ onion_skin: object(
3167
+ {
3168
+ before: { type: "integer", minimum: 0, maximum: 12, description: "Ghost frames before the moment (default 2)." },
3169
+ after: { type: "integer", minimum: 0, maximum: 12, description: "Ghost frames after it (default 0)." },
3170
+ spacing: { anyOf: [{ type: "number", exclusiveMinimum: 0 }, { type: "string", enum: ["commands"] }], description: 'Ms between ghosts (default 83, every other frame at 24 fps), or "commands" for the moments commands start and end.' }
3171
+ },
3172
+ [],
3173
+ "Draw faded frames either side of the moment, to see motion in one picture."
3174
+ ),
3175
+ motion_paths: { anyOf: [{ type: "boolean" }, { type: "array", items: { type: "string", minLength: 1 } }], description: "Draw where nodes travel (dashed, with a dot where each change starts or ends): true for every node whose place changes, or a list of ids." }
3065
3176
  }),
3066
3177
  annotations: { title: "Render a frame", ...READ },
3067
- handler: async ({ time, shot, at = "end", format = "svg" }) => {
3178
+ handler: async ({ time, shot, at = "end", format = "svg", onion_skin, motion_paths }) => {
3068
3179
  const s = scene();
3069
3180
  let t = time ?? controller.getState().time;
3070
3181
  if (shot !== void 0) {
@@ -3072,7 +3183,11 @@ function createAnimationEditorToolProvider(controller, options = {}) {
3072
3183
  if (!timing) throw new Error(`There is no shot "${shot}"; the shots are ${s.shots.map((x) => `"${x.id}"`).join(", ") || "none yet"}.`);
3073
3184
  t = at === "start" ? timing.start : at === "middle" ? (timing.commandsStart + timing.commandsEnd) / 2 : Math.max(timing.start, timing.end - 1);
3074
3185
  }
3075
- const svg = controller.toSvg(t, { pretty: false });
3186
+ if (Array.isArray(motion_paths)) {
3187
+ const known = new Set(describeMotionElements(s).map((e) => e.id));
3188
+ for (const id of motion_paths) if (!known.has(id)) throw new Error(`There is no element ${namedWithSuggestion(id, known)} to draw a motion path for.`);
3189
+ }
3190
+ const svg = controller.toSvg(t, { pretty: false, ...onion_skin ? { onionSkin: onion_skin } : {}, ...motion_paths ? { motionPaths: motion_paths } : {} });
3076
3191
  if (format === "png") {
3077
3192
  if (!options.rasterize) throw new Error('This host cannot make PNG images; call render_animation_frame with format "svg".');
3078
3193
  const image = await options.rasterize(svg, { width: s.artwork.width, height: s.artwork.height });
@@ -3120,6 +3235,24 @@ function createAnimationEditorToolProvider(controller, options = {}) {
3120
3235
  };
3121
3236
  }
3122
3237
  };
3238
+ const getMotionPath = {
3239
+ name: "get_motion_path",
3240
+ title: "Read where a node travels",
3241
+ description: "Returns where a node's centre is over time, in document units (sampled), and where it is each time a change that moves it starts or ends. Use it to check a path, spacing or arc without reading pictures; render_animation_frame with motion_paths draws it.",
3242
+ inputSchema: object({
3243
+ node: { type: "string", minLength: 1, description: "Element id from get_animation." },
3244
+ from: { type: "number", minimum: 0, description: "From this time, ms (default 0)." },
3245
+ to: { type: "number", minimum: 0, description: "To this time, ms (default the end)." },
3246
+ step: { type: "number", exclusiveMinimum: 0, description: "Ms between samples (default 33; at most 400 samples)." }
3247
+ }, ["node"]),
3248
+ annotations: { title: "Read where a node travels", ...READ },
3249
+ handler: ({ node, from, to, step }) => {
3250
+ const s = scene();
3251
+ const ids = describeMotionElements(s).map((e) => e.id);
3252
+ if (!ids.includes(node)) throw new Error(`There is no element ${namedWithSuggestion(node, ids)}.`);
3253
+ return motionPath(s, node, { from, to, step });
3254
+ }
3255
+ };
3123
3256
  const exportFlairMotion = {
3124
3257
  name: "export_flair_motion",
3125
3258
  title: "Export as Flair vector motion",
@@ -3302,6 +3435,21 @@ function createAnimationEditorToolProvider(controller, options = {}) {
3302
3435
  annotations: { title: "Delete commands", ...DESTRUCTIVE },
3303
3436
  handler: ({ shot, commands }) => apply({ op: "delete_commands", shot, commands }, "Delete commands")
3304
3437
  };
3438
+ const moveCommand = {
3439
+ name: "move_command",
3440
+ title: "Reorder a command",
3441
+ description: `Moves one command to a new index in its shot's run (0 = first), as one undo step; the others close up around it. A command with "with": "previous" then starts with its new neighbour. Returns the command's new index, its old one and the shot's timing.`,
3442
+ inputSchema: object(
3443
+ {
3444
+ shot: SHOT_ID,
3445
+ command: { type: "integer", minimum: 0, description: "Index of the command in the shot's run (from get_animation)." },
3446
+ to: { type: "integer", minimum: 0, description: "Its new index, 0 = first (at most the last index)." }
3447
+ },
3448
+ ["shot", "command", "to"]
3449
+ ),
3450
+ annotations: { title: "Reorder a command", ...WRITE },
3451
+ handler: ({ shot, command: command2, to }) => apply({ op: "move_command", shot, command: command2, to }, "Move command", (s) => ({ shot, command: to, from: command2, commands: describeMotionShots(s).find((x) => x.id === shot).commands.map((c) => ({ index: c.index, type: c.type, start: c.start, end: c.end })) }))
3452
+ };
3305
3453
  const setSettings = {
3306
3454
  name: "set_animation_settings",
3307
3455
  title: "Change animation settings",
@@ -3383,8 +3531,8 @@ function createAnimationEditorToolProvider(controller, options = {}) {
3383
3531
  return { redone, ...redone ? { label } : {} };
3384
3532
  }
3385
3533
  };
3386
- const reads = [getAnimation, getShot, findElements, getTimingGrid, checkAnimation, renderFrame, exportFlairMotion, exportFlairEffect, exportLottie, seekAnimation];
3387
- const writes = [setArtworkSvg, fitAnimation, importFlairEffect, insertShots, updateShot, deleteShots, moveShot, addCommands, updateCommand, deleteCommands, setSettings, setControls, editAnimation, writeAnimation, undo, redo];
3534
+ const reads = [getAnimation, getShot, findElements, getMotionPath, getTimingGrid, checkAnimation, renderFrame, exportFlairMotion, exportFlairEffect, exportLottie, seekAnimation];
3535
+ const writes = [setArtworkSvg, fitAnimation, importFlairEffect, insertShots, updateShot, deleteShots, moveShot, addCommands, updateCommand, deleteCommands, moveCommand, setSettings, setControls, editAnimation, writeAnimation, undo, redo];
3388
3536
  return {
3389
3537
  id: options.id ?? "animation",
3390
3538
  namespace: options.namespace,
@@ -3429,11 +3577,285 @@ Commands
3429
3577
 
3430
3578
  Working method
3431
3579
  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).
3432
- 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).
3433
- 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.
3580
+ 2. Plan a few shots, each one idea. Add them with insert_shots (add_commands, update_command, move_command and delete_commands refine a shot), or edit_animation to make several edits as one step (dry_run first when unsure).
3581
+ 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; add onion_skin or motion_paths to see the motion in one picture, or get_motion_path for the numbers.
3434
3582
  4. To make a reusable Flair effect, set_animation_controls (colour controls stand for every use of their default colour) and export_flair_effect.
3435
3583
 
3436
3584
  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.`;
3585
+
3586
+ // src/animation-editor/form.ts
3587
+ var TIMING = /* @__PURE__ */ new Set(["duration", "delay", "stagger", "with", "ease", "repeat", "repeatMode", "steps", "note"]);
3588
+ var COLORS = /* @__PURE__ */ new Set(["fill", "stroke", "color"]);
3589
+ var NODES = /* @__PURE__ */ new Set(["into", "path"]);
3590
+ function branch(type) {
3591
+ return (MOTION_COMMAND_SCHEMA.oneOf ?? []).find((b) => b.properties?.type?.const === type);
3592
+ }
3593
+ function unitOf(type, name) {
3594
+ if (name === "duration" || name === "delay" || name === "stagger") return "ms";
3595
+ if (name === "steps") return "fps";
3596
+ if (name === "frequency") return "Hz";
3597
+ if (name === "repeat" || name === "count") return "\xD7";
3598
+ if (name === "distance" || name === "strokeWidth") return "units";
3599
+ if (name === "angle") return "\xB0";
3600
+ if (name === "amount") return type === "pulse" ? "\xD7" : "units";
3601
+ if (name === "rotate" && type === "wiggle") return "\xB0";
3602
+ if ((name === "by" || name === "to") && type === "rotate") return "\xB0";
3603
+ if (name === "scale" || name === "to" && type === "scale") return "\xD7";
3604
+ if ((name === "from" || name === "to") && (type === "draw" || type === "fade" || type === "follow")) return "0\u20131";
3605
+ return void 0;
3606
+ }
3607
+ function motionFieldLabel(name) {
3608
+ const words = name.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase();
3609
+ return words.charAt(0).toUpperCase() + words.slice(1);
3610
+ }
3611
+ function kindOf(name, schema) {
3612
+ if (name === "targets") return "targets";
3613
+ if (name === "ease") return "ease";
3614
+ if (schema.enum) return "enum";
3615
+ if (schema.anyOf) return "scale";
3616
+ if (schema.type === "object") return "point";
3617
+ if (schema.type === "boolean") return "boolean";
3618
+ if (schema.type === "integer") return "integer";
3619
+ if (schema.type === "number") return "number";
3620
+ if (COLORS.has(name)) return "color";
3621
+ if (NODES.has(name)) return "node";
3622
+ return "text";
3623
+ }
3624
+ function motionCommandFields(type) {
3625
+ const schema = branch(type);
3626
+ if (!schema?.properties) return [];
3627
+ const required = new Set(schema.required ?? []);
3628
+ const fields = Object.entries(schema.properties).filter(([name]) => name !== "type").map(([name, s]) => {
3629
+ const kind = kindOf(name, s);
3630
+ const min = typeof s.minimum === "number" ? s.minimum : typeof s.exclusiveMinimum === "number" ? s.exclusiveMinimum : void 0;
3631
+ return {
3632
+ name,
3633
+ kind,
3634
+ label: motionFieldLabel(name),
3635
+ description: s.description ?? "",
3636
+ required: required.has(name),
3637
+ ...s.enum ? { options: s.enum.map(String) } : {},
3638
+ ...min !== void 0 ? { min } : {},
3639
+ ...typeof s.maximum === "number" ? { max: s.maximum } : {},
3640
+ ...unitOf(type, name) ? { unit: unitOf(type, name) } : {},
3641
+ group: TIMING.has(name) ? "timing" : "main"
3642
+ };
3643
+ });
3644
+ return [...fields.filter((f) => f.group === "main"), ...fields.filter((f) => f.group === "timing")];
3645
+ }
3646
+ function parseMotionFieldInput(field, raw) {
3647
+ if (field.kind === "boolean") return raw === true ? { set: true } : { remove: true };
3648
+ if (field.kind === "targets") {
3649
+ const ids = (Array.isArray(raw) ? raw : String(raw).split(",")).map((s) => String(s).trim()).filter(Boolean);
3650
+ return ids.length ? { set: ids } : { error: "Pick at least one target." };
3651
+ }
3652
+ if (field.kind === "point" || field.kind === "scale" && typeof raw === "object" && !Array.isArray(raw)) {
3653
+ const p = raw;
3654
+ if (p.x.trim() === "" && p.y.trim() === "") return field.required ? { error: `${field.label} is required.` } : { remove: true };
3655
+ const x = Number(p.x);
3656
+ const y = Number(p.y);
3657
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return { error: `${field.label} needs a number for x and for y.` };
3658
+ return { set: { x, y } };
3659
+ }
3660
+ const text = String(raw).trim();
3661
+ if (text === "") return field.required ? { error: `${field.label} is required.` } : { remove: true };
3662
+ if (field.kind === "number" || field.kind === "integer" || field.kind === "scale") {
3663
+ const n = Number(text);
3664
+ if (!Number.isFinite(n)) return { error: `${field.label} must be a number${field.unit ? ` (${field.unit})` : ""}.` };
3665
+ if (field.kind === "integer" && !Number.isInteger(n)) return { error: `${field.label} must be a whole number.` };
3666
+ if (field.min !== void 0 && n < field.min) return { error: `${field.label} must be at least ${field.min}.` };
3667
+ if (field.max !== void 0 && n > field.max) return { error: `${field.label} must be at most ${field.max}.` };
3668
+ return { set: n };
3669
+ }
3670
+ if (field.kind === "enum" && field.options && !field.options.includes(text)) return { error: `${field.label} must be one of ${field.options.join(", ")}.` };
3671
+ return { set: text };
3672
+ }
3673
+ function motionFieldEdit(shot, command2, field, raw) {
3674
+ const input = parseMotionFieldInput(field, raw);
3675
+ if ("error" in input) return input;
3676
+ if ("remove" in input) return { op: "update_command", shot, command: command2, remove: [field.name] };
3677
+ return { op: "update_command", shot, command: command2, set: { [field.name]: input.set } };
3678
+ }
3679
+ function defaultMotionCommand(type, targets, scene) {
3680
+ if (type === "wait") return { type: "wait", duration: 500 };
3681
+ if (!targets.length) return { error: "Pick a target first: click an element on the stage." };
3682
+ const nodes = [...indexMotionNodes(scene.artwork.nodes).values()];
3683
+ const other = (pred) => nodes.find((e) => !targets.includes(e.node.id) && pred(e))?.node.id;
3684
+ const base = { targets: [...targets] };
3685
+ switch (type) {
3686
+ case "scale":
3687
+ return { type, ...base, to: 1.2 };
3688
+ case "fade":
3689
+ return { type, ...base, to: 0.5 };
3690
+ case "move":
3691
+ return { type, ...base, by: { x: 40, y: 0 } };
3692
+ case "rotate":
3693
+ return { type, ...base, by: 90 };
3694
+ case "style":
3695
+ return { type, ...base, fill: "#f59e0b" };
3696
+ case "morph": {
3697
+ const into = other((e) => e.node.type === "path" && e.node.visible === false) ?? other((e) => e.node.type === "path");
3698
+ return into ? { type, ...base, into } : { error: "Morph needs another shape to become; draw one (it can stay hidden)." };
3699
+ }
3700
+ case "match": {
3701
+ const into = other((e) => e.node.type === "group" && e.node.visible === false) ?? other((e) => e.node.type === "group");
3702
+ return into ? { type, ...base, into } : { error: "Match needs another group to become; group the end state (it can stay hidden)." };
3703
+ }
3704
+ case "follow": {
3705
+ const path = other((e) => e.node.type === "path" && e.node.visible === false) ?? other((e) => e.node.type === "path");
3706
+ return path ? { type, ...base, path } : { error: "Follow needs a path to travel along; draw one (it can stay hidden)." };
3707
+ }
3708
+ case "effect": {
3709
+ for (const id of targets) {
3710
+ const node = nodes.find((e) => e.node.id === id)?.node;
3711
+ for (const effect of node?.effects ?? []) {
3712
+ const param = Object.entries(effect.params).find(([, v]) => typeof v === "number");
3713
+ if (param) return { type, ...base, effect: effect.id, param: param[0], to: param[1] + 1 };
3714
+ }
3715
+ }
3716
+ return { error: "Effect needs a target with a live path effect (wiggle, twist, zig zag\u2026)." };
3717
+ }
3718
+ default:
3719
+ return { type, ...base };
3720
+ }
3721
+ }
3722
+ function motionCommandSummary(command2) {
3723
+ const c = command2;
3724
+ const what = [command2.type, typeof c.style === "string" ? c.style : ""].filter(Boolean).join(" ");
3725
+ if (command2.type === "wait") return `wait ${command2.duration} ms`;
3726
+ const extra = typeof c.into === "string" ? ` \u2192 ${c.into}` : typeof c.path === "string" ? ` along ${c.path}` : "";
3727
+ return `${what} \xB7 ${(command2.targets ?? []).join(", ")}${extra}`;
3728
+ }
3729
+ function motionCommandRows(scene, shotId) {
3730
+ const index = scene.shots.findIndex((s) => s.id === shotId);
3731
+ if (index < 0) return [];
3732
+ const timeline = compileMotionScene(scene);
3733
+ return scene.shots[index].run.map((command2, i) => {
3734
+ const t = timeline.commands.find((c) => c.shotIndex === index && c.command === i);
3735
+ const primitive = motionFlairPrimitive(command2);
3736
+ return { index: i, command: command2, summary: motionCommandSummary(command2), start: t?.start ?? 0, end: t?.end ?? 0, ...primitive ? { flairPrimitive: primitive } : {} };
3737
+ });
3738
+ }
3739
+ function duplicateMotionCommandEdit(scene, shot, command2) {
3740
+ const run = scene.shots.find((s) => s.id === shot)?.run ?? [];
3741
+ return { op: "add_commands", shot, commands: run[command2] ? [structuredClone(run[command2])] : [], at: command2 + 1 };
3742
+ }
3743
+ var MOTION_EASE_CHOICES = [
3744
+ { value: "", label: "Default" },
3745
+ ...ANIMATION_EASING_PRESETS.map((p) => ({ value: p, label: ANIMATION_EASING_LABELS[p] }))
3746
+ ];
3747
+ function motionEaseKey(ease) {
3748
+ if (ease === void 0 || ease === null) return "";
3749
+ return typeof ease === "string" ? ease : "custom";
3750
+ }
3751
+ function motionEasingCurve(ease, samples = 48, durationMs = 1e3) {
3752
+ const resolved = resolveEasing(ease);
3753
+ const ms = resolved !== "hold" && resolved.type === "spring" ? springSettleMs(resolved) : durationMs;
3754
+ const n = Math.max(2, Math.floor(samples));
3755
+ const points = [];
3756
+ for (let i = 0; i <= n; i++) {
3757
+ const u = i / n;
3758
+ points.push({ u, v: resolved === "hold" ? i === n ? 1 : 0 : evaluateEasing(ease, u, ms) });
3759
+ }
3760
+ const values = points.map((p) => p.v);
3761
+ return { points, min: Math.min(0, ...values), max: Math.max(1, ...values), durationMs: ms };
3762
+ }
3763
+ function motionEasingPath(curve, width, height, pad = 4) {
3764
+ const span = curve.max - curve.min || 1;
3765
+ const f = (n) => String(Math.round(n * 100) / 100);
3766
+ return curve.points.map((p, i) => `${i ? "L" : "M"}${f(pad + p.u * (width - 2 * pad))} ${f(pad + (1 - (p.v - curve.min) / span) * (height - 2 * pad))}`).join(" ");
3767
+ }
3768
+ function motionEasingGuides(curve, height, pad = 4) {
3769
+ const span = curve.max - curve.min || 1;
3770
+ const y = (v) => pad + (1 - (v - curve.min) / span) * (height - 2 * pad);
3771
+ return { zero: y(0), one: y(1) };
3772
+ }
3773
+ function motionNodeAtElement(scene, target, root) {
3774
+ const ids = indexMotionNodes(scene.artwork.nodes);
3775
+ for (let el = target; el && el !== root; el = el.parentElement) {
3776
+ const id = el.getAttribute("id");
3777
+ if (id && ids.has(id)) return id;
3778
+ }
3779
+ return null;
3780
+ }
3781
+ var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3782
+ function highlightMotionSvg(svg, ids, color = "#f43f5e") {
3783
+ if (!ids.length) return svg;
3784
+ let out = svg;
3785
+ for (const id of ids) {
3786
+ const escaped = id.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
3787
+ out = out.replace(new RegExp(` id="${escapeRegExp(escaped)}"`), ` id="${escaped}" data-picked=""`);
3788
+ }
3789
+ return out.replace(/(<svg\b[^>]*>)/, `$1<style>[data-picked]{filter:drop-shadow(0 0 1px #fff) drop-shadow(0 0 3px ${color}) drop-shadow(0 0 3px ${color})}</style>`);
3790
+ }
3791
+ function motionRulerTicks(duration, width, minGap = 56) {
3792
+ const span = Math.max(1, duration);
3793
+ const steps = [100, 250, 500, 1e3, 2e3, 5e3, 1e4, 3e4, 6e4];
3794
+ const step = steps.find((s) => s / span * Math.max(1, width) >= minGap) ?? steps[steps.length - 1];
3795
+ const ticks = [];
3796
+ for (let t = 0; t <= span + 1e-6; t += step / 2) {
3797
+ const major = Math.round(t / (step / 2)) % 2 === 0;
3798
+ ticks.push({ time: t, major, label: major ? `${step < 1e3 ? (t / 1e3).toFixed(step < 500 ? 2 : 1) : String(Math.round(t / 1e3))}s` : "" });
3799
+ }
3800
+ return ticks;
3801
+ }
3802
+ function fitMotionSceneEdits(scene, ms, options = {}) {
3803
+ const fit = fitMotionScene(scene, ms, options);
3804
+ return fit.scene.shots.flatMap((shot, i) => {
3805
+ const before = scene.shots[i].speed;
3806
+ if (before === shot.speed) return [];
3807
+ return shot.speed === void 0 ? [{ op: "update_shot", shot: shot.id, remove: ["speed"] }] : [{ op: "update_shot", shot: shot.id, set: { speed: shot.speed } }];
3808
+ });
3809
+ }
3810
+ function motionCueKey(cue) {
3811
+ if (!cue) return "";
3812
+ if (typeof cue.beat === "number") return `beat:${cue.beat}`;
3813
+ if (typeof cue.word === "number") return `word:${cue.word}`;
3814
+ if (typeof cue.ms === "number") return `ms:${cue.ms}`;
3815
+ return "";
3816
+ }
3817
+ function motionCueFromKey(key) {
3818
+ const m = /^(beat|word|ms):(-?\d+(?:\.\d+)?)$/.exec(key);
3819
+ if (!m) return void 0;
3820
+ return { [m[1]]: Number(m[2]) };
3821
+ }
3822
+ function motionCueChoices(scene, limit = 256) {
3823
+ const choices = [];
3824
+ const s = (ms) => `${(ms / 1e3).toFixed(2)} s`;
3825
+ const end = Math.max(compileMotionScene(scene).duration, ...scene.narration?.words.map((w) => w.end ?? w.start) ?? [0]) + 2e3;
3826
+ if (scene.beat && scene.beat.bpm > 0) {
3827
+ for (let beat = 0; choices.length < limit; beat++) {
3828
+ const time = motionBeatTime(scene.beat, beat);
3829
+ if (time > end) break;
3830
+ if (time < 0) continue;
3831
+ const at = motionBeatAt(scene.beat, time);
3832
+ choices.push({ value: `beat:${beat}`, label: `Beat ${beat} (bar ${at.bar}.${at.beatInBar}) \xB7 ${s(time)}`, time });
3833
+ }
3834
+ }
3835
+ scene.narration?.words.forEach((w, i) => {
3836
+ if (choices.length < limit * 2) choices.push({ value: `word:${i}`, label: `Word ${i} \u201C${w.text}\u201D \xB7 ${s(w.start)}`, time: w.start });
3837
+ });
3838
+ return choices;
3839
+ }
3840
+ function motionShotCueTime(scene, cue) {
3841
+ return cue ? motionCueTime(scene, cue) : void 0;
3842
+ }
3843
+ function formatMotionNarration(narration) {
3844
+ return (narration?.words ?? []).map((w) => `${w.start} ${w.text}`).join("\n");
3845
+ }
3846
+ function parseMotionNarration(text) {
3847
+ const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
3848
+ if (!lines.length) return { narration: null };
3849
+ const words = [];
3850
+ for (const [i, line] of lines.entries()) {
3851
+ const m = /^(\d+(?:\.\d+)?)\s+(.+)$/.exec(line);
3852
+ if (!m) return { error: `Line ${i + 1} ("${line}") should be a start time in ms and a word, e.g. "1200 hello".` };
3853
+ const start = Number(m[1]);
3854
+ if (words.length && start < words[words.length - 1].start) return { error: `Line ${i + 1} starts before the word above it; keep the words in time order.` };
3855
+ words.push({ text: m[2], start });
3856
+ }
3857
+ return { narration: { words } };
3858
+ }
3437
3859
  export {
3438
3860
  ANIMATION_EDITOR_AGENT_INSTRUCTIONS,
3439
3861
  AnimationEditorController,
@@ -3450,6 +3872,7 @@ export {
3450
3872
  MOTION_DRAW_EFFECT_ID,
3451
3873
  MOTION_DRAW_MS_PER_UNIT,
3452
3874
  MOTION_DRAW_RANGE,
3875
+ MOTION_EASE_CHOICES,
3453
3876
  MOTION_EASE_IN_OUT,
3454
3877
  MOTION_EASE_OUT,
3455
3878
  MOTION_EDIT_OPS,
@@ -3470,16 +3893,21 @@ export {
3470
3893
  compileMotionScene,
3471
3894
  createAnimationEditorToolProvider,
3472
3895
  createMotionScene,
3896
+ defaultMotionCommand,
3473
3897
  describeMotionElements,
3474
3898
  describeMotionShots,
3899
+ duplicateMotionCommandEdit,
3475
3900
  fitMotionRect,
3476
3901
  fitMotionScene,
3902
+ fitMotionSceneEdits,
3477
3903
  flairAtLeast,
3478
3904
  flairCurveFor,
3479
3905
  flairEaseFor,
3906
+ formatMotionNarration,
3480
3907
  freshShotId,
3481
3908
  fromFlairSvgMotion,
3482
3909
  fromFlairVectorEffect,
3910
+ highlightMotionSvg,
3483
3911
  indexMotionNodes,
3484
3912
  interpolateMotionValue,
3485
3913
  isMotionCommand,
@@ -3490,28 +3918,50 @@ export {
3490
3918
  motionBeatTime,
3491
3919
  motionClipAttrs,
3492
3920
  motionCommandDuration,
3921
+ motionCommandFields,
3493
3922
  motionCommandRepeat,
3923
+ motionCommandRows,
3924
+ motionCommandSummary,
3925
+ motionCueChoices,
3926
+ motionCueFromKey,
3927
+ motionCueKey,
3494
3928
  motionCueTime,
3495
3929
  motionCycleDuration,
3930
+ motionEaseKey,
3931
+ motionEasingCurve,
3932
+ motionEasingGuides,
3933
+ motionEasingPath,
3934
+ motionFieldEdit,
3935
+ motionFieldLabel,
3496
3936
  motionFlairPrimitive,
3497
3937
  motionFollowRoute,
3498
3938
  motionFrameAt,
3499
3939
  motionFrameToSvg,
3940
+ motionGhosts,
3500
3941
  motionIssue,
3501
3942
  motionIssueKey,
3502
3943
  motionIssueLine,
3944
+ motionMovingIds,
3945
+ motionNodeAtElement,
3503
3946
  motionNodeClips,
3504
3947
  motionNodeOffset,
3948
+ motionPath,
3505
3949
  motionPathLength,
3950
+ motionPathsSvg,
3506
3951
  motionRestValue,
3507
3952
  motionRevealClip,
3508
3953
  motionRevealPadding,
3954
+ motionRulerTicks,
3509
3955
  motionSceneShapeProblems,
3510
3956
  motionSceneToJson,
3511
3957
  motionShotAt,
3958
+ motionShotCueTime,
3512
3959
  motionShotTimeScale,
3960
+ motionStageSvg,
3513
3961
  motionTrackKey,
3514
3962
  namedWithSuggestion,
3963
+ parseMotionFieldInput,
3964
+ parseMotionNarration,
3515
3965
  parseMotionScene,
3516
3966
  resolveMotionCamera,
3517
3967
  segmentProgress,