@pixerate/editor 0.71.0 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -56,6 +56,7 @@ import {
56
56
 
57
57
  // src/animation-editor/types.ts
58
58
  var MOTION_TRIGGERS = ["click", "hover", "press", "scroll"];
59
+ var MOTION_DRIVEN_PROPERTIES = ["x", "y", "rotation", "scaleX", "scaleY", "opacity"];
59
60
  var MOTION_CLIP_TARGET = "$";
60
61
  var MOTION_COMMAND_TYPES = [
61
62
  "enter",
@@ -141,6 +142,7 @@ var MOTION_ISSUE_CODES = {
141
142
  "unknown-path": { severity: "error", wording: "A follow command names a path the artwork does not have." },
142
143
  "not-a-path": { severity: "error", wording: "A follow path must be a path node, not a group." },
143
144
  "reveal-no-box": { severity: "warning", wording: "A wipe, iris or split cannot clip a node with no width or height (a lone straight line), so it fades instead." },
145
+ "driver-cycle": { severity: "error", wording: "Drivers follow each other in a loop, so none of them has a value." },
144
146
  "unknown-clip": { severity: "error", wording: "A play command names a clip the scene does not have." },
145
147
  "unknown-shape": { severity: "error", wording: "A morph names a shape the artwork does not have." },
146
148
  "match-self": { severity: "error", wording: "A match turns a group into itself or into a group it holds (or that holds it)." },
@@ -219,7 +221,7 @@ ${problems.map((p) => `- ${p}`).join("\n")}`);
219
221
  this.problems = problems;
220
222
  }
221
223
  };
222
- var SCENE_KEYS = ["version", "name", "description", "artwork", "shots", "clips", "loop", "trigger", "fps", "steps", "beat", "narration", "target", "controls", "presets"];
224
+ var SCENE_KEYS = ["version", "name", "description", "artwork", "shots", "clips", "drivers", "motionBlur", "loop", "trigger", "fps", "steps", "beat", "narration", "target", "controls", "presets"];
223
225
  var SHOT_KEYS = ["id", "label", "camera", "at", "run", "hold", "speed"];
224
226
  var CAMERA_KEYS = ["x", "y", "width", "height", "frame", "padding", "ease", "ms", "geometric"];
225
227
  var BASE_KEYS = ["type", "targets", "duration", "delay", "stagger", "with", "ease", "note", "repeat", "repeatMode", "steps"];
@@ -403,6 +405,23 @@ function clipsShapeProblems(clips) {
403
405
  }
404
406
  return problems;
405
407
  }
408
+ function driversShapeProblems(drivers) {
409
+ if (!Array.isArray(drivers)) return ['Scene: "drivers" must be a list of { target, property, source: { node, property }, factor?, offset?, clamp? }.'];
410
+ const problems = [];
411
+ const props = MOTION_DRIVEN_PROPERTIES;
412
+ drivers.forEach((d, i) => {
413
+ const where = `Driver ${i + 1}`;
414
+ if (!isObject(d)) return void problems.push(`${where} must be an object.`);
415
+ unknownKeys(d, ["target", "property", "source", "factor", "offset", "clamp"], where, problems);
416
+ if (typeof d.target !== "string" || !d.target) problems.push(`${where}: "target" must be the id of the node that follows.`);
417
+ if (!props.includes(d.property)) problems.push(`${where}: "property" must be one of ${MOTION_DRIVEN_PROPERTIES.join(", ")}.`);
418
+ if (!isObject(d.source) || typeof d.source.node !== "string" || !d.source.node || !props.includes(d.source.property)) problems.push(`${where}: "source" must be { "node": id, "property": one of ${MOTION_DRIVEN_PROPERTIES.join(", ")} }.`);
419
+ if (d.factor !== void 0 && !isNum(d.factor)) problems.push(`${where}: "factor" must be a number.`);
420
+ if (d.offset !== void 0 && !isNum(d.offset)) problems.push(`${where}: "offset" must be a number.`);
421
+ if (d.clamp !== void 0 && !(Array.isArray(d.clamp) && d.clamp.length === 2 && d.clamp.every(isNum) && d.clamp[0] <= d.clamp[1])) problems.push(`${where}: "clamp" must be [min, max].`);
422
+ });
423
+ return problems;
424
+ }
406
425
  function shotShapeProblems(shot, where) {
407
426
  if (!isObject(shot)) return [`${where} must be an object.`];
408
427
  const problems = [];
@@ -451,6 +470,12 @@ function motionSceneShapeProblems(input) {
451
470
  if (!isObject(art) || !isNum(art.width) || !isNum(art.height) || !(art.width > 0) || !(art.height > 0) || !Array.isArray(art.nodes))
452
471
  problems.push('Scene: "artwork" must be a vector document { width, height, nodes }.');
453
472
  if (input.clips !== void 0) problems.push(...clipsShapeProblems(input.clips));
473
+ if (input.drivers !== void 0) problems.push(...driversShapeProblems(input.drivers));
474
+ if (input.motionBlur !== void 0) {
475
+ const b = input.motionBlur;
476
+ if (!isObject(b) || Object.keys(b).some((k) => k !== "shutter" && k !== "samples") || b.shutter !== void 0 && !(isNum(b.shutter) && b.shutter > 0 && b.shutter <= 720) || b.samples !== void 0 && !(Number.isInteger(b.samples) && b.samples >= 2 && b.samples <= 32))
477
+ problems.push('Scene: "motionBlur" must be { "shutter"?: degrees above 0 up to 720 (default 180), "samples"?: 2 to 32 (default 8) }.');
478
+ }
454
479
  if (input.trigger !== void 0 && !MOTION_TRIGGERS.includes(input.trigger)) problems.push(`Scene: "trigger" must be one of ${MOTION_TRIGGERS.join(", ")}.`);
455
480
  if (input.loop !== void 0 && !["none", "loop", "ping-pong"].includes(input.loop)) problems.push('Scene: "loop" must be "none", "loop" or "ping-pong".');
456
481
  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.');
@@ -826,6 +851,41 @@ function motionCueTime(scene, cue) {
826
851
  if (typeof cue.word === "number") return scene.narration?.words[cue.word]?.start;
827
852
  return void 0;
828
853
  }
854
+ var SCALE_LIKE = /* @__PURE__ */ new Set(["scaleX", "scaleY", "opacity"]);
855
+ function motionPropertyValue(timeline, node, property, time, raw = false, depth = 0) {
856
+ const rest = property === "opacity" ? timeline.restOpacity?.get(node) ?? 1 : SCALE_LIKE.has(property) ? 1 : 0;
857
+ const v = trackValue(timeline.tracks.get(motionTrackKey(node, property)), time, raw);
858
+ const base = property === "opacity" ? typeof v === "number" ? v : rest : withMotionLayers(timeline, node, property, typeof v === "number" ? v : rest, time, raw);
859
+ return applyMotionDrivers(timeline, node, property, base, time, raw, depth);
860
+ }
861
+ function applyMotionDrivers(timeline, node, property, value, time, raw = false, depth = 0) {
862
+ if (!timeline.drivers || depth > 8) return value;
863
+ let v = value;
864
+ for (const d of timeline.drivers) {
865
+ if (d.target !== node || d.property !== property) continue;
866
+ const src = motionPropertyValue(timeline, d.source.node, d.source.property, time, raw, depth + 1);
867
+ const srcRest = d.source.property === "opacity" ? timeline.restOpacity?.get(d.source.node) ?? 1 : SCALE_LIKE.has(d.source.property) ? 1 : 0;
868
+ let change = (src - srcRest) * (d.factor ?? 1) + (d.offset ?? 0);
869
+ if (d.clamp) change = Math.min(d.clamp[1], Math.max(d.clamp[0], change));
870
+ v = SCALE_LIKE.has(property) ? v * (1 + change) : v + change;
871
+ }
872
+ return property === "opacity" ? Math.min(1, Math.max(0, v)) : v;
873
+ }
874
+ function motionDrivenNodes(timeline) {
875
+ return new Set((timeline.drivers ?? []).map((d) => d.target));
876
+ }
877
+ function motionDriverSources(timeline, node, properties, seen = /* @__PURE__ */ new Set()) {
878
+ const out = [];
879
+ for (const d of timeline.drivers ?? []) {
880
+ if (d.target !== node || !properties.includes(d.property)) continue;
881
+ const key = `${d.source.node}\0${d.source.property}`;
882
+ if (seen.has(key)) continue;
883
+ seen.add(key);
884
+ out.push(...timeline.tracks.get(motionTrackKey(d.source.node, d.source.property)) ?? [], ...timeline.tracks.get(`${motionTrackKey(d.source.node, d.source.property)}\0add`) ?? []);
885
+ out.push(...motionDriverSources(timeline, d.source.node, [d.source.property], seen));
886
+ }
887
+ return out;
888
+ }
829
889
  function motionCommandRepeat(command2) {
830
890
  if (command2.type === "wait" || command2.type === "enter" || command2.type === "exit") return 1;
831
891
  const n = command2.repeat ?? 1;
@@ -1238,7 +1298,7 @@ function compileMotionScene(scene) {
1238
1298
  for (const track of tracks.values()) track.sort((a, b) => a.start - b.start);
1239
1299
  segments.sort((a, b) => a.start - b.start);
1240
1300
  const duration = shots.length ? shots[shots.length - 1].end : 0;
1241
- const timeline = { duration, shots, commands, segments, tracks };
1301
+ const timeline = { duration, shots, commands, segments, tracks, ...scene.drivers?.length ? { drivers: scene.drivers, restOpacity: new Map([...nodes].map(([id, e]) => [id, e.node.opacity ?? 1])) } : {} };
1242
1302
  timelineCache.set(scene, timeline);
1243
1303
  return timeline;
1244
1304
  }
@@ -1336,9 +1396,11 @@ function followAt(entry, timeline, nodes, time) {
1336
1396
  function motionNodeOffset(entry, timeline, time, nodes = indexMotionNodes([entry.node]), raw = false, follow = true) {
1337
1397
  const read = (p, rest) => {
1338
1398
  const v = trackValue(timeline.tracks.get(motionTrackKey(entry.node.id, p)), time, raw);
1339
- return withMotionLayers(timeline, entry.node.id, p, typeof v === "number" ? v : rest, time, raw);
1399
+ const layered = withMotionLayers(timeline, entry.node.id, p, typeof v === "number" ? v : rest, time, raw);
1400
+ return p === "follow" ? layered : applyMotionDrivers(timeline, entry.node.id, p, layered, time, raw);
1340
1401
  };
1341
- if (!TRANSFORM_PROPS.some((p) => (follow || p !== "follow") && (timeline.tracks.has(motionTrackKey(entry.node.id, p)) || timeline.tracks.has(motionAddTrackKey(entry.node.id, p))))) return null;
1402
+ const driven = timeline.drivers?.some((d) => d.target === entry.node.id && d.property !== "opacity");
1403
+ if (!driven && !TRANSFORM_PROPS.some((p) => (follow || p !== "follow") && (timeline.tracks.has(motionTrackKey(entry.node.id, p)) || timeline.tracks.has(motionAddTrackKey(entry.node.id, p))))) return null;
1342
1404
  const dx = read("x", 0);
1343
1405
  const dy = read("y", 0);
1344
1406
  const rotation = read("rotation", 0);
@@ -1364,7 +1426,7 @@ function motionFrameAt(scene, time, options = {}) {
1364
1426
  const nodes = indexMotionNodes(s.artwork.nodes);
1365
1427
  const drawOn = options.draw !== false;
1366
1428
  const value = (id, p) => trackValue(timeline.tracks.get(motionTrackKey(id, p)), time);
1367
- const animated = new Set(timeline.segments.map((seg) => seg.node));
1429
+ const animated = /* @__PURE__ */ new Set([...timeline.segments.map((seg) => seg.node), ...motionDrivenNodes(timeline)]);
1368
1430
  const clips = {};
1369
1431
  const visit = (node) => {
1370
1432
  let out = node.type === "group" ? { ...node, children: node.children.map(visit) } : node;
@@ -1421,7 +1483,7 @@ function motionFrameAt(scene, time, options = {}) {
1421
1483
  return p;
1422
1484
  });
1423
1485
  }
1424
- const opacity = value(id, "opacity");
1486
+ const opacity = timeline.drivers?.some((d) => d.target === id && d.property === "opacity") ? motionPropertyValue(timeline, id, "opacity", time) : value(id, "opacity");
1425
1487
  if (typeof opacity === "number") {
1426
1488
  const o = Math.min(1, Math.max(0, tidy(opacity)));
1427
1489
  out = { ...out, opacity: o };
@@ -1517,12 +1579,49 @@ function motionPathsSvg(paths, camera, color = "#f43f5e") {
1517
1579
  (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>`
1518
1580
  ).join("");
1519
1581
  }
1582
+ function motionBlurTimes(scene, time, blur = {}) {
1583
+ const shutter = Math.min(720, Math.max(0, blur.shutter ?? 180));
1584
+ const most = Math.max(1, Math.min(32, Math.floor(blur.samples ?? 8)));
1585
+ const span = 1e3 / (scene.fps ?? 30) * shutter / 360;
1586
+ if (span <= 0 || most <= 1) return [time];
1587
+ const ids = motionMovingIds(scene);
1588
+ if (!ids.length) return [time];
1589
+ const a = indexMotionNodes(motionFrameAt(scene, time - span / 2).document.nodes);
1590
+ const b = indexMotionNodes(motionFrameAt(scene, time + span / 2).document.nodes);
1591
+ let travel = 0;
1592
+ for (const id of ids) {
1593
+ const p = a.get(id)?.bounds;
1594
+ const q = b.get(id)?.bounds;
1595
+ if (p && q) travel = Math.max(travel, Math.abs(p.x - q.x), Math.abs(p.y - q.y), Math.abs(p.x + p.width - q.x - q.width), Math.abs(p.y + p.height - q.y - q.height));
1596
+ }
1597
+ const n = Math.min(most, Math.ceil(travel / 1.5));
1598
+ if (n <= 1) return [time];
1599
+ return Array.from({ length: n }, (_, k) => time - span / 2 + span * (k + 0.5) / n);
1600
+ }
1601
+ function averagedFrames(scene, times, options, prefix) {
1602
+ const n = times.length;
1603
+ return `<g data-motion-blur="${n}" style="isolation:isolate">${times.map((t, k) => {
1604
+ const own = `${prefix}blur${k}-`;
1605
+ const frame = motionFrameToSvg(scene, t, { ...options, idPrefix: own, background: false });
1606
+ const view = /viewBox="([^"]*)"/.exec(frame)?.[1] ?? "";
1607
+ const inner = k === n - 1 ? innerSvg(frame) : innerSvg(frame).replace(new RegExp(` id="(?!${own.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})[^"]*"`, "g"), "");
1608
+ return `<svg viewBox="${view}" preserveAspectRatio="none" style="mix-blend-mode:plus-lighter" opacity="${Math.round(1e4 / n) / 1e4}">${inner}</svg>`;
1609
+ }).join("")}</g>`;
1610
+ }
1520
1611
  var innerSvg = (svg) => svg.slice(svg.indexOf(">") + 1, svg.lastIndexOf("</svg>"));
1521
1612
  function motionStageSvg(scene, time, options = {}) {
1522
- const { onionSkin, motionPaths, motionPathColor, ...svgOptions } = options;
1523
- const current = motionFrameToSvg(scene, time, svgOptions);
1524
- if (!onionSkin && !motionPaths) return current;
1613
+ const { onionSkin, motionPaths, motionPathColor, motionBlur, ...svgOptions } = options;
1525
1614
  const prefix = svgOptions.idPrefix ?? "vg-";
1615
+ let current = motionFrameToSvg(scene, time, svgOptions);
1616
+ const blur = motionBlur === false ? void 0 : motionBlur ?? scene.motionBlur;
1617
+ const blurTimes = blur ? motionBlurTimes(scene, time, blur) : [time];
1618
+ if (blurTimes.length > 1) {
1619
+ const open2 = current.indexOf(">") + 1;
1620
+ const body2 = current.slice(open2, current.lastIndexOf("</svg>"));
1621
+ const bg2 = svgOptions.background !== false && scene.artwork.background ? /^\s*<rect[^>]*\/>/.exec(body2)?.[0] ?? "" : "";
1622
+ current = `${current.slice(0, open2)}${bg2}${averagedFrames(scene, blurTimes, svgOptions, prefix)}</svg>`;
1623
+ }
1624
+ if (!onionSkin && !motionPaths) return current;
1526
1625
  const ghosts = onionSkin ? motionGhosts(scene, time, onionSkin).map((g, i) => {
1527
1626
  const own = `${prefix}ghost${i}-`;
1528
1627
  const inner = innerSvg(motionFrameToSvg(scene, g.time, { ...svgOptions, idPrefix: own, background: false })).replace(new RegExp(` id="(?!${own.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})[^"]*"`, "g"), "");
@@ -1720,6 +1819,28 @@ function checkMotionScene(scene) {
1720
1819
  }
1721
1820
  });
1722
1821
  });
1822
+ const drivers = scene.drivers ?? [];
1823
+ drivers.forEach((d, i) => {
1824
+ for (const id of [d.target, d.source.node])
1825
+ if (!nodes.has(id)) issues.push(motionIssue("unknown-target", `Driver ${i + 1} names ${namedWithSuggestion(id, [...nodes.keys()])}, which the artwork does not have.`, { ids: [id] }));
1826
+ });
1827
+ {
1828
+ const key = (n, p) => `${n}.${p}`;
1829
+ const edges = /* @__PURE__ */ new Map();
1830
+ for (const d of drivers) edges.set(key(d.target, d.property), [...edges.get(key(d.target, d.property)) ?? [], key(d.source.node, d.source.property)]);
1831
+ const state = /* @__PURE__ */ new Map();
1832
+ const loop = [];
1833
+ const visit = (k, trail) => {
1834
+ if (state.get(k) === 2) return false;
1835
+ if (state.get(k) === 1) return loop.push(...trail.slice(trail.indexOf(k)), k), true;
1836
+ state.set(k, 1);
1837
+ for (const n of edges.get(k) ?? []) if (visit(n, [...trail, k])) return true;
1838
+ state.set(k, 2);
1839
+ return false;
1840
+ };
1841
+ for (const k of edges.keys()) if (visit(k, [])) break;
1842
+ if (loop.length) issues.push(motionIssue("driver-cycle", `Drivers follow each other round in a loop (${loop.join(" \u2190 ")}); remove one of them.`, { ids: [...new Set(loop.map((k) => k.split(".")[0]))] }));
1843
+ }
1723
1844
  for (const [name, clip] of Object.entries(scene.clips ?? {}))
1724
1845
  for (const command2 of clip.run)
1725
1846
  if (command2.type !== "wait") {
@@ -1810,6 +1931,7 @@ var MOTION_EDIT_OPS = [
1810
1931
  "set_artwork",
1811
1932
  "set_controls",
1812
1933
  "set_clip",
1934
+ "set_drivers",
1813
1935
  "replace_scene"
1814
1936
  ];
1815
1937
  var EditError = class extends Error {
@@ -1854,18 +1976,25 @@ function applyOne(scene, edit) {
1854
1976
  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.');
1855
1977
  next.steps = edit.steps;
1856
1978
  }
1857
- for (const key of ["beat", "narration"]) {
1979
+ for (const key of ["beat", "narration", "motionBlur"]) {
1858
1980
  if (edit[key] === null) delete next[key];
1859
1981
  else if (edit[key] !== void 0) next[key] = edit[key];
1860
1982
  }
1861
1983
  {
1862
- const problems = motionSceneShapeProblems(next).filter((p) => /"beat"|"narration"/.test(p));
1984
+ const problems = motionSceneShapeProblems(next).filter((p) => /"beat"|"narration"|"motionBlur"/.test(p));
1863
1985
  if (problems.length) fail(problems.join(" "));
1864
1986
  }
1865
1987
  if (edit.target === null) delete next.target;
1866
1988
  else if (edit.target !== void 0) next.target = edit.target;
1867
1989
  return next;
1868
1990
  }
1991
+ case "set_drivers": {
1992
+ const problems = driversShapeProblems(edit.drivers);
1993
+ if (problems.length) fail(problems.join(" "));
1994
+ const next = { ...scene, drivers: edit.drivers };
1995
+ if (!edit.drivers.length) delete next.drivers;
1996
+ return next;
1997
+ }
1869
1998
  case "set_clip": {
1870
1999
  if (typeof edit.name !== "string" || !edit.name) fail('Give the clip a "name".');
1871
2000
  const clips = { ...scene.clips ?? {} };
@@ -2167,7 +2296,13 @@ function toFlairSvgMotion(scene, options = {}) {
2167
2296
  if (!entry) return void 0;
2168
2297
  return [...entry.ancestors, path.id].find((id) => timeline.tracks.has(motionTrackKey(id, p)));
2169
2298
  };
2170
- const animatedNodes = new Set(timeline.segments.map((s) => s.node).filter(Boolean));
2299
+ const animatedNodes = /* @__PURE__ */ new Set([...timeline.segments.map((s) => s.node).filter(Boolean), ...motionDrivenNodes(timeline)]);
2300
+ const drivenBy = /* @__PURE__ */ new WeakSet();
2301
+ const sourcesOf = (id, props) => motionDriverSources(timeline, id, props).map((s) => {
2302
+ const copy = { ...s };
2303
+ drivenBy.add(copy);
2304
+ return copy;
2305
+ });
2171
2306
  const pathsSeen = /* @__PURE__ */ new Set();
2172
2307
  const wrap = {};
2173
2308
  const routes = [];
@@ -2214,10 +2349,11 @@ function toFlairSvgMotion(scene, options = {}) {
2214
2349
  follows.push({ id, wrapper, routes: ids, segments: followSegments.filter((seg) => seg.path && ids.has(seg.path)) });
2215
2350
  }
2216
2351
  }
2217
- if (timeline.tracks.has(motionTrackKey(id, "opacity")))
2218
- channels.push({ id, kind: "attr", attr: "opacity", segments: [...track(id, "opacity")], exact: plays, value: (t) => round2(Math.min(1, Math.max(0, Number(trackValue(track(id, "opacity"), t))))) });
2352
+ const opacitySources = sourcesOf(id, ["opacity"]);
2353
+ if (timeline.tracks.has(motionTrackKey(id, "opacity")) || opacitySources.length)
2354
+ channels.push({ id, kind: "attr", attr: "opacity", segments: [...track(id, "opacity"), ...opacitySources], exact: (s) => plays(s) && !drivenBy.has(s), value: (t) => round2(motionPropertyValue(timeline, id, "opacity", t)) });
2219
2355
  const followed = follows.some((x) => x.id === id);
2220
- const transformSegments = TRANSFORM_PROPS2.flatMap((p) => followed && p === "follow" ? [] : [...track(id, p), ...timeline.tracks.get(motionAddTrackKey(id, p)) ?? []]);
2356
+ const transformSegments = [...TRANSFORM_PROPS2.flatMap((p) => followed && p === "follow" ? [] : [...track(id, p), ...timeline.tracks.get(motionAddTrackKey(id, p)) ?? []]), ...sourcesOf(id, ["x", "y", "rotation", "scaleX", "scaleY"])];
2221
2357
  if (transformSegments.length) {
2222
2358
  const base = node.transform ? ` matrix(${node.transform.map(f).join(" ")})` : "";
2223
2359
  const c = entry.center;
@@ -2230,7 +2366,7 @@ function toFlairSvgMotion(scene, options = {}) {
2230
2366
  kind: "attr",
2231
2367
  attr: "transform",
2232
2368
  segments: transformSegments,
2233
- exact: (s) => LINEAR_TRANSFORM.has(s.property) && plays(s),
2369
+ exact: (s) => LINEAR_TRANSFORM.has(s.property) && plays(s) && !drivenBy.has(s),
2234
2370
  overshoot: true,
2235
2371
  // x, y and the angle are the transform's numbers 0, 1 and 2 (`MotionSegment.index`).
2236
2372
  value: (t) => transformAt(t, false),
@@ -2375,6 +2511,7 @@ function toFlairSvgMotion(scene, options = {}) {
2375
2511
  }
2376
2512
  animate.sort((x, y) => (x.step ?? 0) - (y.step ?? 0) || (x.delay ?? 0) - (y.delay ?? 0));
2377
2513
  }
2514
+ if (scene.motionBlur) notes.push("Motion blur is drawn on the stage only; Flair plays the scene sharp.");
2378
2515
  if (trigger && (scene.loop ?? "none") !== "none") notes.push(`The scene plays on ${trigger}, so its loop does not apply in Flair.`);
2379
2516
  let svg = vectorDocumentToSvg({ ...scene.artwork, nodes: expandVectorAppearances(scene.artwork.nodes) }, { pretty: false, precision, wrap });
2380
2517
  if (routes.length) svg = svg.replace(/^(<svg[^>]*>)/, `$1<defs>${routes.join("")}</defs>`);
@@ -2691,6 +2828,7 @@ function toLottie(scene, options = {}) {
2691
2828
  const timeline = compileMotionScene(scene);
2692
2829
  const nodes = indexMotionNodes(scene.artwork.nodes);
2693
2830
  const notes = [];
2831
+ if (scene.motionBlur) notes.push("Motion blur is drawn on the stage only; the Lottie plays sharp.");
2694
2832
  if (scene.trigger) notes.push(`The scene plays on ${scene.trigger}; Lottie has no trigger, so it plays through (markers show where each shot starts, for a player to stop at).`);
2695
2833
  let sampled = 0;
2696
2834
  const track = (id, p) => timeline.tracks.get(motionTrackKey(id, p)) ?? [];
@@ -2762,16 +2900,22 @@ function toLottie(scene, options = {}) {
2762
2900
  const tr = STATIC_TR();
2763
2901
  const c = [entry.center.x, entry.center.y].map(round2);
2764
2902
  const own = (p) => track(id, p);
2765
- const layered = ["x", "y", "rotation", "scaleX", "scaleY"].some((p) => layers(id, p).length);
2903
+ const sources = motionDriverSources(timeline, id, ["x", "y", "rotation", "scaleX", "scaleY"]);
2904
+ const layered = ["x", "y", "rotation", "scaleX", "scaleY"].some((p) => layers(id, p).length) || sources.length > 0;
2766
2905
  const moving = ["x", "y", "rotation", "scaleX", "scaleY", "follow"].some((p) => own(p).length) || layered;
2767
2906
  if (moving) {
2768
2907
  const mixed = own("follow").length > 0 || layered;
2769
2908
  tr.a = { a: 0, k: c };
2770
- tr.p = animate(mixed ? [...own("x"), ...own("y"), ...own("follow"), ...layers(id, "x"), ...layers(id, "y")] : [...own("x"), ...own("y")], (t) => poseAt(entry, t).p, !mixed);
2771
- tr.r = animate(mixed ? [...own("rotation"), ...own("follow"), ...layers(id, "rotation")] : own("rotation"), (t) => [poseAt(entry, t).r], !mixed);
2772
- tr.s = animate(mixed ? [...own("scaleX"), ...own("scaleY"), ...layers(id, "scaleX"), ...layers(id, "scaleY")] : [...own("scaleX"), ...own("scaleY")], (t) => poseAt(entry, t).s, !mixed);
2909
+ tr.p = animate(mixed ? [...own("x"), ...own("y"), ...own("follow"), ...layers(id, "x"), ...layers(id, "y"), ...sources] : [...own("x"), ...own("y")], (t) => poseAt(entry, t).p, !mixed);
2910
+ tr.r = animate(mixed ? [...own("rotation"), ...own("follow"), ...layers(id, "rotation"), ...sources] : own("rotation"), (t) => [poseAt(entry, t).r], !mixed);
2911
+ tr.s = animate(mixed ? [...own("scaleX"), ...own("scaleY"), ...layers(id, "scaleX"), ...layers(id, "scaleY"), ...sources] : [...own("scaleX"), ...own("scaleY")], (t) => poseAt(entry, t).s, !mixed);
2773
2912
  if (tr.p.a === 0) tr.p = { a: 0, k: poseAt(entry, 0).p.map(round2) };
2774
2913
  }
2914
+ const opacitySources = motionDriverSources(timeline, id, ["opacity"]);
2915
+ if (opacitySources.length) {
2916
+ tr.o = animate([...own("opacity"), ...opacitySources], (t) => [motionPropertyValue(timeline, id, "opacity", t) * 100], false);
2917
+ return tr;
2918
+ }
2775
2919
  const opacity = own("opacity");
2776
2920
  const rest = entry.node.opacity ?? 1;
2777
2921
  tr.o = opacity.length ? animate(opacity, (t) => [Math.min(1, Math.max(0, Number(trackValue(opacity, t) ?? rest))) * 100]) : { a: 0, k: round2(rest * 100) };
@@ -3351,6 +3495,10 @@ var SETTINGS = {
3351
3495
  },
3352
3496
  fps: { type: "number", minimum: 1, maximum: 120, description: "Sampling rate for exports (default 30)." },
3353
3497
  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).' },
3498
+ motionBlur: {
3499
+ anyOf: [object({ shutter: { type: "number", exclusiveMinimum: 0, maximum: 720, description: "Shutter angle in degrees of a frame (default 180; 360 is a whole frame, more blur)." }, samples: { type: "integer", minimum: 2, maximum: 32, description: "Most moments a frame averages (default 8)." } }), { type: "null" }],
3500
+ description: "Motion blur on the stage and in rendered frames (not exported to Flair or Lottie); null turns it off."
3501
+ },
3354
3502
  beat: {
3355
3503
  anyOf: [
3356
3504
  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"]),
@@ -3476,6 +3624,8 @@ function createAnimationEditorToolProvider(controller, options = {}) {
3476
3624
  ...includeElements ? { elements: describeMotionElements(s) } : {},
3477
3625
  shots: describeMotionShots(s),
3478
3626
  ...s.clips ? { clips: Object.fromEntries(Object.entries(s.clips).map(([name, c]) => [name, { ...c.description ? { description: c.description } : {}, commands: c.run.length, length: Math.round(motionClipLayout(s, name, "$").length) }])) } : {},
3627
+ ...s.drivers ? { drivers: s.drivers } : {},
3628
+ ...s.motionBlur ? { motionBlur: s.motionBlur } : {},
3479
3629
  ...s.controls ? { controls: s.controls } : {},
3480
3630
  ...s.presets ? { presets: s.presets } : {},
3481
3631
  issues: issueSummary(controller.getState().issues),
@@ -3734,6 +3884,36 @@ function createAnimationEditorToolProvider(controller, options = {}) {
3734
3884
  return apply(edits, "Fit to length", () => report);
3735
3885
  }
3736
3886
  };
3887
+ const PROP = { type: "string", enum: ["x", "y", "rotation", "scaleX", "scaleY", "opacity"], description: "x / y (offset, document units), rotation (degrees), scaleX / scaleY or opacity (1 = rest)." };
3888
+ const setDrivers = {
3889
+ name: "set_animation_drivers",
3890
+ title: "Make properties follow others",
3891
+ description: "Replaces the drivers (empty clears them): each makes a target's property follow a source node's property, as a change from rest \u2014 target change = source change \xB7 factor + offset, clamped \u2014 added to offsets and turns, multiplied into scales and opacity, on top of the target's own commands. For parallax (x follows x \xB7 0.5), a wheel turning as it rolls (rotation follows x \xB7 360 / (2\u03C0\xB7radius)), a shadow fading as a ball rises (opacity follows y \xB7 0.005). One undo step; loops between drivers are refused by check_animation. Returns the timing and issues.",
3892
+ inputSchema: object(
3893
+ {
3894
+ drivers: {
3895
+ type: "array",
3896
+ maxItems: 64,
3897
+ items: object(
3898
+ {
3899
+ target: { type: "string", minLength: 1, description: "Element id that follows." },
3900
+ property: PROP,
3901
+ source: object({ node: { type: "string", minLength: 1, description: "Element id it follows." }, property: PROP }, ["node", "property"], "What it follows."),
3902
+ factor: { type: "number", description: "Multiplies the source's change (default 1)." },
3903
+ offset: { type: "number", description: "Added to the change (default 0)." },
3904
+ clamp: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2, description: "Limits the change to [min, max]." }
3905
+ },
3906
+ ["target", "property", "source"],
3907
+ "One property following another."
3908
+ ),
3909
+ description: "Every driver (the list replaces the current one)."
3910
+ }
3911
+ },
3912
+ ["drivers"]
3913
+ ),
3914
+ annotations: { title: "Make properties follow others", ...WRITE_IDEMPOTENT },
3915
+ handler: ({ drivers }) => apply({ op: "set_drivers", drivers }, "Set drivers")
3916
+ };
3737
3917
  const setClip = {
3738
3918
  name: "set_animation_clip",
3739
3919
  title: "Define a reusable clip",
@@ -3974,7 +4154,7 @@ function createAnimationEditorToolProvider(controller, options = {}) {
3974
4154
  }
3975
4155
  };
3976
4156
  const reads = [getAnimation, getShot, findElements, getMotionPath, getTimingGrid, checkAnimation, renderFrame, exportFlairMotion, exportFlairEffect, exportLottie, seekAnimation];
3977
- const writes = [setArtworkSvg, setClip, copyAnimation, fitAnimation, importFlairEffect, insertShots, updateShot, deleteShots, moveShot, addCommands, updateCommand, deleteCommands, moveCommand, setSettings, setControls, editAnimation, writeAnimation, undo, redo];
4157
+ const writes = [setArtworkSvg, setDrivers, setClip, copyAnimation, fitAnimation, importFlairEffect, insertShots, updateShot, deleteShots, moveShot, addCommands, updateCommand, deleteCommands, moveCommand, setSettings, setControls, editAnimation, writeAnimation, undo, redo];
3978
4158
  return {
3979
4159
  id: options.id ?? "animation",
3980
4160
  namespace: options.namespace,
@@ -4007,6 +4187,8 @@ Commands
4007
4187
  - enter also reveals through a clip over the target's box: wipe (behind an edge travelling "direction", default right), iris (from the centre; "shape": circle or box) and split (from a line through the middle; "orientation": horizontal or vertical). exit wipe hides the same way. Reveals need a width and a height: use draw / undraw for a straight line.
4008
4188
  - draw draws strokes on to a fraction; move, rotate, scale and fade change the pose (about the centre); style blends solid colours and stroke width.
4009
4189
  - Drawing a group of strokes: "order": "sequence" draws them one after another (a signature, a diagram), "sortBy": "distance" from "origin" reveals outward, "pace": "speed" keeps one speed; draw with "comet": 0.2 sends a short stroke along a path and off the end.
4190
+ - Fast motion reads better with blur: set_animation_settings { "motionBlur": { "shutter": 180 } } (stage and rendered frames only).
4191
+ - Tie properties together with set_animation_drivers: parallax layers, a wheel that turns as it rolls, a shadow that fades as something rises. Drivers add to the target's own commands.
4010
4192
  - Reuse motion: set_animation_clip defines a clip (commands targeting "$"), and { "type": "play", "clip": name, "targets": [...] } plays it on any nodes (with stagger and speed). copy_animation gives other nodes one node's motion.
4011
4193
  - move takes "via" waypoints for a curved, smooth path that passes exactly through each point (steady speed by default); use it instead of chaining moves, which stop at each point.
4012
4194
  - enter also has zoom (settles from larger) and rise (rises a short way, overshooting a touch); pop and zoom take a starting "scale".
@@ -4307,6 +4489,27 @@ function parseMotionNarration(text) {
4307
4489
  }
4308
4490
  return { narration: { words } };
4309
4491
  }
4492
+ function formatMotionDrivers(drivers) {
4493
+ return (drivers ?? []).map((d) => `${d.target}.${d.property} <- ${d.source.node}.${d.source.property}${d.factor !== void 0 && d.factor !== 1 ? ` * ${d.factor}` : ""}${d.offset ? ` ${d.offset < 0 ? "-" : "+"} ${Math.abs(d.offset)}` : ""}${d.clamp ? ` [${d.clamp[0]}, ${d.clamp[1]}]` : ""}`).join("\n");
4494
+ }
4495
+ function parseMotionDrivers(text) {
4496
+ const props = MOTION_DRIVEN_PROPERTIES;
4497
+ const drivers = [];
4498
+ const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
4499
+ for (const [i, line] of lines.entries()) {
4500
+ const m = /^([\w.-]+)\.(\w+)\s*<-\s*([\w.-]+)\.(\w+)\s*(?:\*\s*(-?[\d.]+(?:e-?\d+)?))?\s*(?:([+-])\s*([\d.]+(?:e-?\d+)?))?\s*(?:\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\])?$/.exec(line);
4501
+ if (!m) return { error: `Line ${i + 1} ("${line}") should read "target.property <- source.property", optionally "* factor", "+ offset" and "[min, max]", e.g. "dot.x <- box.x * 0.5".` };
4502
+ const [, target, property, node, sourceProperty, factor, sign, offset, min, max] = m;
4503
+ for (const p of [property, sourceProperty]) if (!props.includes(p)) return { error: `Line ${i + 1}: "${p}" is not a property drivers use; use ${MOTION_DRIVEN_PROPERTIES.join(", ")}.` };
4504
+ const d = { target, property, source: { node, property: sourceProperty } };
4505
+ if (factor !== void 0) d.factor = Number(factor);
4506
+ if (offset !== void 0) d.offset = (sign === "-" ? -1 : 1) * Number(offset);
4507
+ if (min !== void 0) d.clamp = [Number(min), Number(max)];
4508
+ if ([d.factor, d.offset, ...d.clamp ?? []].some((n) => n !== void 0 && !Number.isFinite(n))) return { error: `Line ${i + 1} has a number that cannot be read.` };
4509
+ drivers.push(d);
4510
+ }
4511
+ return { drivers };
4512
+ }
4310
4513
  export {
4311
4514
  ANIMATION_EDITOR_AGENT_INSTRUCTIONS,
4312
4515
  AnimationEditorController,
@@ -4324,6 +4527,7 @@ export {
4324
4527
  MOTION_DRAW_EFFECT_ID,
4325
4528
  MOTION_DRAW_MS_PER_UNIT,
4326
4529
  MOTION_DRAW_RANGE,
4530
+ MOTION_DRIVEN_PROPERTIES,
4327
4531
  MOTION_EASE_CHOICES,
4328
4532
  MOTION_EASE_IN_OUT,
4329
4533
  MOTION_EASE_OUT,
@@ -4340,6 +4544,7 @@ export {
4340
4544
  MotionSceneFormatError,
4341
4545
  activeSegment,
4342
4546
  applyMotionControlValues,
4547
+ applyMotionDrivers,
4343
4548
  applyMotionEdits,
4344
4549
  checkMotionScene,
4345
4550
  clipsShapeProblems,
@@ -4350,6 +4555,7 @@ export {
4350
4555
  defaultMotionCommand,
4351
4556
  describeMotionElements,
4352
4557
  describeMotionShots,
4558
+ driversShapeProblems,
4353
4559
  duplicateMotionCommandEdit,
4354
4560
  fitMotionRect,
4355
4561
  fitMotionScene,
@@ -4357,6 +4563,7 @@ export {
4357
4563
  flairAtLeast,
4358
4564
  flairCurveFor,
4359
4565
  flairEaseFor,
4566
+ formatMotionDrivers,
4360
4567
  formatMotionNarration,
4361
4568
  freshShotId,
4362
4569
  fromFlairSvgMotion,
@@ -4370,6 +4577,7 @@ export {
4370
4577
  motionAddTrackKey,
4371
4578
  motionBeatAt,
4372
4579
  motionBeatTime,
4580
+ motionBlurTimes,
4373
4581
  motionClipAttrs,
4374
4582
  motionClipLayout,
4375
4583
  motionCommandDuration,
@@ -4383,6 +4591,8 @@ export {
4383
4591
  motionCueKey,
4384
4592
  motionCueTime,
4385
4593
  motionCycleDuration,
4594
+ motionDrivenNodes,
4595
+ motionDriverSources,
4386
4596
  motionEaseKey,
4387
4597
  motionEasingCurve,
4388
4598
  motionEasingGuides,
@@ -4404,6 +4614,7 @@ export {
4404
4614
  motionPath,
4405
4615
  motionPathLength,
4406
4616
  motionPathsSvg,
4617
+ motionPropertyValue,
4407
4618
  motionRestValue,
4408
4619
  motionRevealClip,
4409
4620
  motionRevealPadding,
@@ -4416,6 +4627,7 @@ export {
4416
4627
  motionStageSvg,
4417
4628
  motionTrackKey,
4418
4629
  namedWithSuggestion,
4630
+ parseMotionDrivers,
4419
4631
  parseMotionFieldInput,
4420
4632
  parseMotionNarration,
4421
4633
  parseMotionScene,