@pixerate/editor 0.63.0 → 0.65.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.
@@ -23,6 +23,7 @@ import {
23
23
  bezierPathLength,
24
24
  bezierPathPointAtLength,
25
25
  bezierPathToSvg,
26
+ toBezierPath,
26
27
  transformBezierPath
27
28
  } from "../chunk-GTMJ4Y3S.js";
28
29
  import {
@@ -1257,6 +1258,88 @@ function withViewBox(svg, box) {
1257
1258
  return svg.replace(/viewBox="[^"]*"/, `viewBox="${f(box.x)} ${f(box.y)} ${f(box.width)} ${f(box.height)}"`);
1258
1259
  }
1259
1260
 
1261
+ // src/animation-editor/onion.ts
1262
+ function motionGhosts(scene, time, onion = {}) {
1263
+ const before = Math.max(0, Math.floor(onion.before ?? 2));
1264
+ const after = Math.max(0, Math.floor(onion.after ?? 0));
1265
+ const base = Math.min(1, Math.max(0, onion.opacity ?? 0.35));
1266
+ const timeline = compileMotionScene(scene);
1267
+ const ghosts = [];
1268
+ const push = (t, offset) => ghosts.push({ time: t, offset, opacity: Math.round(base / Math.abs(offset) * 1e3) / 1e3 });
1269
+ if (onion.spacing === "commands") {
1270
+ const marks = [...new Set(timeline.commands.flatMap((c) => [c.start, c.end]))].sort((a, b) => a - b);
1271
+ marks.filter((t) => t < time - 1e-6).reverse().slice(0, before).forEach((t, i) => push(t, -(i + 1)));
1272
+ marks.filter((t) => t > time + 1e-6).slice(0, after).forEach((t, i) => push(t, i + 1));
1273
+ return ghosts;
1274
+ }
1275
+ const spacing = typeof onion.spacing === "number" && onion.spacing > 0 ? onion.spacing : 1e3 / 12;
1276
+ for (let i = 1; i <= before && time - i * spacing >= 0; i++) push(time - i * spacing, -i);
1277
+ for (let i = 1; i <= after && time + i * spacing <= timeline.duration; i++) push(time + i * spacing, i);
1278
+ return ghosts;
1279
+ }
1280
+ var MOVING = ["x", "y", "rotation", "scaleX", "scaleY", "follow", "shape"];
1281
+ function motionMovingIds(scene) {
1282
+ const timeline = compileMotionScene(scene);
1283
+ const nodes = indexMotionNodes(scene.artwork.nodes);
1284
+ const moving = new Set(timeline.segments.filter((s) => s.node && MOVING.includes(s.property)).map((s) => s.node));
1285
+ 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);
1286
+ }
1287
+ function motionPath(scene, id, options = {}) {
1288
+ const nodes = indexMotionNodes(scene.artwork.nodes);
1289
+ const entry = nodes.get(id);
1290
+ if (!entry) throw new Error(`There is no element "${id}" in the artwork.`);
1291
+ const timeline = compileMotionScene(scene);
1292
+ const from = Math.max(0, options.from ?? 0);
1293
+ const to = Math.min(timeline.duration, options.to ?? timeline.duration);
1294
+ let step = options.step && options.step > 0 ? options.step : 1e3 / 30;
1295
+ if ((to - from) / step > 400) step = (to - from) / 400;
1296
+ const r = (n) => Math.round(n * 100) / 100;
1297
+ const centreAt = (t) => {
1298
+ const node = indexMotionNodes(motionFrameAtCache(scene, t)).get(id);
1299
+ const b = node?.bounds;
1300
+ return b ? { t: r(t), x: r(b.x + b.width / 2), y: r(b.y + b.height / 2) } : null;
1301
+ };
1302
+ const points = [];
1303
+ for (let t = from; t <= to + 1e-6; t += step) {
1304
+ const p = centreAt(Math.min(t, to));
1305
+ if (p) points.push(p);
1306
+ }
1307
+ const owners = /* @__PURE__ */ new Set([id, ...entry.ancestors]);
1308
+ 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);
1309
+ const keys = times.map(centreAt).filter((p) => p !== null);
1310
+ return { id, points, keys };
1311
+ }
1312
+ function motionFrameAtCache(scene, t) {
1313
+ return motionFrameAt(scene, t).document.nodes;
1314
+ }
1315
+ function motionPathsSvg(paths, camera, color = "#f43f5e") {
1316
+ const w = Math.max(camera.width, camera.height) / 400;
1317
+ const f = (n) => String(Math.round(n * 100) / 100);
1318
+ return paths.filter((p) => p.points.length > 1).map(
1319
+ (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>`
1320
+ ).join("");
1321
+ }
1322
+ var innerSvg = (svg) => svg.slice(svg.indexOf(">") + 1, svg.lastIndexOf("</svg>"));
1323
+ function motionStageSvg(scene, time, options = {}) {
1324
+ const { onionSkin, motionPaths, motionPathColor, ...svgOptions } = options;
1325
+ const current = motionFrameToSvg(scene, time, svgOptions);
1326
+ if (!onionSkin && !motionPaths) return current;
1327
+ const prefix = svgOptions.idPrefix ?? "vg-";
1328
+ const ghosts = onionSkin ? motionGhosts(scene, time, onionSkin).map((g, i) => {
1329
+ const own = `${prefix}ghost${i}-`;
1330
+ const inner = innerSvg(motionFrameToSvg(scene, g.time, { ...svgOptions, idPrefix: own, background: false })).replace(new RegExp(` id="(?!${own.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})[^"]*"`, "g"), "");
1331
+ return `<g data-onion="${g.offset < 0 ? "before" : "after"}" opacity="${g.opacity}">${inner}</g>`;
1332
+ }) : [];
1333
+ const camera = motionFrameAt(scene, time).camera;
1334
+ const ids = motionPaths === true ? motionMovingIds(scene) : motionPaths ? [...motionPaths] : [];
1335
+ const paths = ids.length ? motionPathsSvg(ids.map((id) => motionPath(scene, id)), camera, motionPathColor) : "";
1336
+ const open = current.indexOf(">") + 1;
1337
+ const close = current.lastIndexOf("</svg>");
1338
+ const body = current.slice(open, close);
1339
+ const bg = svgOptions.background !== false && scene.artwork.background ? /^\s*<rect[^>]*\/>/.exec(body)?.[0] ?? "" : "";
1340
+ return `${current.slice(0, open)}${bg}${ghosts.join("")}${body.slice(bg.length)}${paths}</svg>`;
1341
+ }
1342
+
1260
1343
  // src/animation-editor/check.ts
1261
1344
  var MOTION_MIN_CHANGE_MS = 120;
1262
1345
  var NUMERIC_PARAMS = (node, effectId) => {
@@ -2183,6 +2266,311 @@ function fromFlairVectorEffect(effect, options = {}) {
2183
2266
  return { scene, notes, issues: checkMotionScene(scene) };
2184
2267
  }
2185
2268
 
2269
+ // src/animation-editor/lottie.ts
2270
+ var STATIC_TR = () => ({
2271
+ ty: "tr",
2272
+ p: { a: 0, k: [0, 0] },
2273
+ a: { a: 0, k: [0, 0] },
2274
+ s: { a: 0, k: [100, 100] },
2275
+ r: { a: 0, k: 0 },
2276
+ o: { a: 0, k: 100 },
2277
+ sk: { a: 0, k: 0 },
2278
+ sa: { a: 0, k: 0 }
2279
+ });
2280
+ function rgba(color) {
2281
+ const hex = typeof color === "string" ? normalizeTokenColor(color) : null;
2282
+ if (!hex) return null;
2283
+ const n = (i) => parseInt(hex.slice(i, i + 2), 16) / 255;
2284
+ return [n(1), n(3), n(5), hex.length >= 9 ? parseInt(hex.slice(7, 9), 16) / 255 : 1];
2285
+ }
2286
+ function lottieContours(path, round2) {
2287
+ const out = [];
2288
+ let cur = null;
2289
+ let px = 0;
2290
+ let py = 0;
2291
+ const add = (x, y, inX, inY) => {
2292
+ cur.v.push([x, y]);
2293
+ cur.i.push([inX - x, inY - y]);
2294
+ cur.o.push([0, 0]);
2295
+ px = x;
2296
+ py = y;
2297
+ };
2298
+ for (const cmd of path) {
2299
+ if (cmd.type === "M") {
2300
+ if (cur) out.push(cur);
2301
+ cur = { c: false, v: [], i: [], o: [] };
2302
+ add(cmd.x, cmd.y, cmd.x, cmd.y);
2303
+ } else if (!cur) continue;
2304
+ else if (cmd.type === "L") add(cmd.x, cmd.y, cmd.x, cmd.y);
2305
+ else if (cmd.type === "Q") {
2306
+ const c1 = [px + 2 / 3 * (cmd.x1 - px), py + 2 / 3 * (cmd.y1 - py)];
2307
+ const c2 = [cmd.x + 2 / 3 * (cmd.x1 - cmd.x), cmd.y + 2 / 3 * (cmd.y1 - cmd.y)];
2308
+ cur.o[cur.o.length - 1] = [c1[0] - px, c1[1] - py];
2309
+ add(cmd.x, cmd.y, c2[0], c2[1]);
2310
+ } else if (cmd.type === "C") {
2311
+ cur.o[cur.o.length - 1] = [cmd.x1 - px, cmd.y1 - py];
2312
+ add(cmd.x, cmd.y, cmd.x2, cmd.y2);
2313
+ } else if (cmd.type === "Z") {
2314
+ cur.c = true;
2315
+ const n = cur.v.length;
2316
+ if (n > 1 && Math.abs(cur.v[n - 1][0] - cur.v[0][0]) < 1e-9 && Math.abs(cur.v[n - 1][1] - cur.v[0][1]) < 1e-9) {
2317
+ cur.i[0] = cur.i[n - 1];
2318
+ cur.v.pop();
2319
+ cur.i.pop();
2320
+ cur.o.pop();
2321
+ }
2322
+ out.push(cur);
2323
+ cur = null;
2324
+ }
2325
+ }
2326
+ if (cur) out.push(cur);
2327
+ return out.map((c) => ({ c: c.c, v: c.v.map((p) => p.map(round2)), i: c.i.map((p) => p.map(round2)), o: c.o.map((p) => p.map(round2)) }));
2328
+ }
2329
+ function staticParts(m) {
2330
+ const sx = Math.hypot(m[0], m[1]);
2331
+ if (!(sx > 1e-12)) return null;
2332
+ const sy = (m[0] * m[3] - m[1] * m[2]) / sx;
2333
+ if (Math.abs(m[2] + m[1] / sx * sy) > 1e-6 || Math.abs(m[3] - m[0] / sx * sy) > 1e-6) return null;
2334
+ return { p: [m[4], m[5]], s: [sx * 100, sy * 100], r: Math.atan2(m[1], m[0]) * 180 / Math.PI };
2335
+ }
2336
+ function toLottie(scene, options = {}) {
2337
+ const fps = options.fps ?? scene.fps ?? 30;
2338
+ const precision = options.precision ?? 3;
2339
+ const round2 = (n) => Math.round(n * 10 ** precision) / 10 ** precision;
2340
+ const frame = (ms) => round2(ms * fps / 1e3);
2341
+ const timeline = compileMotionScene(scene);
2342
+ const nodes = indexMotionNodes(scene.artwork.nodes);
2343
+ const notes = [];
2344
+ let sampled = 0;
2345
+ const track = (id, p) => timeline.tracks.get(motionTrackKey(id, p)) ?? [];
2346
+ const layers = (id, p) => timeline.tracks.get(`${motionTrackKey(id, p)}\0add`) ?? [];
2347
+ const end = timeline.duration;
2348
+ const curveOf = (s) => {
2349
+ if (s.behavior || s.steps || s.geometric) return void 0;
2350
+ const e = s.reverse ? reverseCubicBezier(s.ease) : resolveEasing(s.ease);
2351
+ if (!e || e === "hold" || e.type !== "cubic-bezier") return void 0;
2352
+ return [e.x1, e.y1, e.x2, e.y2];
2353
+ };
2354
+ const animate = (segments, value, exact = true) => {
2355
+ const rest = value(0).map(round2);
2356
+ if (!segments.length) return { a: 0, k: rest.length === 1 ? rest[0] : rest };
2357
+ const keys = [];
2358
+ const key = (t, v, ease) => {
2359
+ const k = { t: frame(t), s: v.map(round2) };
2360
+ if (ease === "hold") k.h = 1;
2361
+ else {
2362
+ const [x1, y1, x2, y2] = ease === "linear" || !ease ? [0, 0, 1, 1] : ease;
2363
+ k.o = { x: [round2(x1)], y: [round2(y1)] };
2364
+ k.i = { x: [round2(x2)], y: [round2(y2)] };
2365
+ }
2366
+ if (keys.length && keys[keys.length - 1].t === k.t) keys[keys.length - 1] = k;
2367
+ else keys.push(k);
2368
+ };
2369
+ const times = [.../* @__PURE__ */ new Set([0, ...segments.flatMap((s) => [s.start, s.end]), end])].filter((t) => t >= 0 && t <= end).sort((a, b) => a - b);
2370
+ for (let n = 0; n + 1 < times.length; n++) {
2371
+ const a = times[n];
2372
+ const b = times[n + 1];
2373
+ const active = segments.filter((s) => s.start < b && s.end > a);
2374
+ if (!active.length) {
2375
+ key(a, value(a), "hold");
2376
+ continue;
2377
+ }
2378
+ const first = active[0];
2379
+ const curve = exact && active.every((s) => s.start === a && s.end === b && JSON.stringify(curveOf(s)) === JSON.stringify(curveOf(first))) ? curveOf(first) : void 0;
2380
+ if (curve) {
2381
+ key(a, value(a), curve);
2382
+ continue;
2383
+ }
2384
+ const count = Math.max(1, Math.ceil((b - a) * fps / 1e3));
2385
+ for (let k = 0; k < count; k++) {
2386
+ const t = a + (b - a) * k / count;
2387
+ key(t, value(t), "linear");
2388
+ sampled++;
2389
+ }
2390
+ }
2391
+ for (const s of segments) if (s.end <= s.start) key(s.start, value(s.start), "hold");
2392
+ keys.sort((x, y) => x.t - y.t);
2393
+ key(end, value(end), "hold");
2394
+ const last = keys[keys.length - 1];
2395
+ delete last.o;
2396
+ delete last.i;
2397
+ delete last.h;
2398
+ return { a: 1, k: keys };
2399
+ };
2400
+ const poseAt = (entry, t) => {
2401
+ const o = motionNodeOffset(entry, timeline, t, nodes);
2402
+ if (!o) return { p: [entry.center.x, entry.center.y], s: [100, 100], r: 0 };
2403
+ const m = o.matrix;
2404
+ const sx = Math.hypot(m[0], m[1]);
2405
+ const sy = sx > 1e-12 ? (m[0] * m[3] - m[1] * m[2]) / sx : 0;
2406
+ const c = entry.center;
2407
+ return { p: [m[0] * c.x + m[2] * c.y + m[4], m[1] * c.x + m[3] * c.y + m[5]], s: [sx * 100, sy * 100], r: Math.atan2(m[1], m[0]) * 180 / Math.PI };
2408
+ };
2409
+ const poseTransform = (entry) => {
2410
+ const id = entry.node.id;
2411
+ const tr = STATIC_TR();
2412
+ const c = [entry.center.x, entry.center.y].map(round2);
2413
+ const own = (p) => track(id, p);
2414
+ const layered = ["x", "y", "rotation", "scaleX", "scaleY"].some((p) => layers(id, p).length);
2415
+ const moving = ["x", "y", "rotation", "scaleX", "scaleY", "follow"].some((p) => own(p).length) || layered;
2416
+ if (moving) {
2417
+ const mixed = own("follow").length > 0 || layered;
2418
+ tr.a = { a: 0, k: c };
2419
+ 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);
2420
+ tr.r = animate(mixed ? [...own("rotation"), ...own("follow"), ...layers(id, "rotation")] : own("rotation"), (t) => [poseAt(entry, t).r], !mixed);
2421
+ tr.s = animate(mixed ? [...own("scaleX"), ...own("scaleY"), ...layers(id, "scaleX"), ...layers(id, "scaleY")] : [...own("scaleX"), ...own("scaleY")], (t) => poseAt(entry, t).s, !mixed);
2422
+ if (tr.p.a === 0) tr.p = { a: 0, k: poseAt(entry, 0).p.map(round2) };
2423
+ }
2424
+ const opacity = own("opacity");
2425
+ const rest = entry.node.opacity ?? 1;
2426
+ 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) };
2427
+ return tr;
2428
+ };
2429
+ const owner = (path, p) => {
2430
+ const entry = nodes.get(path.id);
2431
+ if (!entry) return void 0;
2432
+ return [...entry.ancestors, path.id].find((id) => timeline.tracks.has(motionTrackKey(id, p)));
2433
+ };
2434
+ const paintOf = (paint, what, id) => {
2435
+ if (paint === void 0 || paint === null || paint === "none") return null;
2436
+ const c = rgba(paint);
2437
+ if (!c) notes.push(`"${id}": its ${what} is a gradient, which is not exported; it is left unpainted.`);
2438
+ return c;
2439
+ };
2440
+ const pathShapes = (path) => {
2441
+ const items = [];
2442
+ const shapeSegments = [...timeline.tracks.entries()].filter(([k]) => k.startsWith(`${path.id}\0`) && (k.endsWith("\0shape") || k.includes("\0effect:"))).flatMap(([, v]) => v);
2443
+ const geometryAt = (t) => {
2444
+ if (!shapeSegments.length) return vectorPathWorldBezier(path);
2445
+ const now = indexMotionNodes(motionFrameAt(scene, t, { draw: false, offsets: false }).document.nodes).get(path.id)?.node;
2446
+ return now && now.type === "path" ? vectorPathWorldBezier(expandVectorAppearances([now])[0]) : vectorPathWorldBezier(path);
2447
+ };
2448
+ const rest = lottieContours(toBezierPath(geometryAt(0)), round2);
2449
+ rest.forEach((contour, n) => {
2450
+ let ks = { a: 0, k: contour };
2451
+ if (shapeSegments.length) {
2452
+ const keys = [];
2453
+ let fits = true;
2454
+ const count = Math.max(1, Math.ceil(end * fps / 1e3));
2455
+ for (let k = 0; k <= count && fits; k++) {
2456
+ const t = end * k / count;
2457
+ const c = lottieContours(toBezierPath(geometryAt(t)), round2)[n];
2458
+ if (!c || c.v.length !== contour.v.length || c.c !== contour.c) fits = false;
2459
+ else keys.push({ t: frame(t), s: [c], ...k < count ? { o: { x: [0], y: [0] }, i: { x: [1], y: [1] } } : {} });
2460
+ }
2461
+ if (fits) {
2462
+ ks = { a: 1, k: keys };
2463
+ sampled += keys.length;
2464
+ } else notes.push(`"${path.id}": its morph changes how many points the outline has, which Lottie cannot tween; it shows its starting shape.`);
2465
+ }
2466
+ items.push({ ty: "sh", nm: `${path.id} ${n + 1}`, ks });
2467
+ });
2468
+ const fillFrom = owner(path, "fill");
2469
+ const strokeFrom = owner(path, "stroke");
2470
+ const widthFrom = owner(path, "strokeWidth");
2471
+ const fadeFrom = owner(path, "fillOpacity");
2472
+ const drawFrom = owner(path, "drawEnd");
2473
+ if (drawFrom) {
2474
+ const draw = track(drawFrom, "drawEnd");
2475
+ items.push({ ty: "tm", nm: "Draw on", s: { a: 0, k: 0 }, e: animate(draw, (t) => [Math.min(1, Math.max(0, Number(trackValue(draw, t) ?? 1))) * 100]), o: { a: 0, k: 0 }, m: 1 });
2476
+ }
2477
+ if (owner(path, "drawStart")) notes.push(`"${path.id}": drawing from a start other than 0 is not exported.`);
2478
+ const stroke = paintOf(path.stroke, "stroke", path.id);
2479
+ if (stroke || strokeFrom) {
2480
+ const colour = strokeFrom ? track(strokeFrom, "stroke") : [];
2481
+ items.push({
2482
+ ty: "st",
2483
+ nm: "Stroke",
2484
+ c: colour.length ? animate(colour, (t) => rgba(trackValue(colour, t)) ?? stroke ?? [0, 0, 0, 1]) : { a: 0, k: (stroke ?? [0, 0, 0, 1]).map(round2) },
2485
+ o: { a: 0, k: 100 },
2486
+ w: widthFrom ? animate(track(widthFrom, "strokeWidth"), (t) => [Math.max(0, Number(trackValue(track(widthFrom, "strokeWidth"), t) ?? path.strokeWidth ?? 1))]) : { a: 0, k: round2(path.strokeWidth ?? 1) },
2487
+ lc: path.strokeLinecap === "round" ? 2 : path.strokeLinecap === "square" ? 3 : 1,
2488
+ lj: path.strokeLinejoin === "round" ? 2 : path.strokeLinejoin === "bevel" ? 3 : 1,
2489
+ ml: 4
2490
+ });
2491
+ if (path.strokeDasharray?.length) notes.push(`"${path.id}": its dashes are not exported; the stroke is solid.`);
2492
+ if (path.strokeAlign && path.strokeAlign !== "center") notes.push(`"${path.id}": its stroke is drawn centred (Lottie has no inside or outside strokes).`);
2493
+ }
2494
+ const fill = paintOf(path.fill, "fill", path.id);
2495
+ if (fill || fillFrom) {
2496
+ const colour = fillFrom ? track(fillFrom, "fill") : [];
2497
+ const fade = fadeFrom ? track(fadeFrom, "fillOpacity") : [];
2498
+ items.push({
2499
+ ty: "fl",
2500
+ nm: "Fill",
2501
+ c: colour.length ? animate(colour, (t) => rgba(trackValue(colour, t)) ?? fill ?? [0, 0, 0, 1]) : { a: 0, k: (fill ?? [0, 0, 0, 1]).map(round2) },
2502
+ o: fade.length ? animate(fade, (t) => [Math.min(1, Math.max(0, Number(trackValue(fade, t) ?? 1))) * 100]) : { a: 0, k: 100 },
2503
+ r: path.fillRule === "evenodd" ? 2 : 1
2504
+ });
2505
+ }
2506
+ return items;
2507
+ };
2508
+ const nodeGroup = (node) => {
2509
+ const entry = nodes.get(node.id);
2510
+ if (timeline.tracks.has(motionTrackKey(node.id, "clipRect")) || timeline.tracks.has(motionTrackKey(node.id, "clipCircle")))
2511
+ notes.push(`"${node.id}": its wipe, iris or split reveal is not exported (Lottie masks belong to layers); it fades with its opacity only.`);
2512
+ let inner;
2513
+ const innerTr = STATIC_TR();
2514
+ if (node.type === "path") inner = [...pathShapes(node)];
2515
+ else {
2516
+ inner = [...node.children].reverse().map(nodeGroup);
2517
+ const m = nodeMatrix(node);
2518
+ if (m !== IDENTITY_MATRIX) {
2519
+ const parts = staticParts(m);
2520
+ if (!parts) notes.push(`"${node.id}": its transform skews, which is not exported; it is drawn without the skew.`);
2521
+ else {
2522
+ innerTr.p = { a: 0, k: parts.p.map(round2) };
2523
+ innerTr.s = { a: 0, k: parts.s.map(round2) };
2524
+ innerTr.r = { a: 0, k: round2(parts.r) };
2525
+ }
2526
+ }
2527
+ }
2528
+ const body = { ty: "gr", nm: `${node.id} (own)`, it: [...inner, innerTr] };
2529
+ return { ty: "gr", nm: node.name ?? node.id, ...node.visible === false ? { hd: true } : {}, it: [body, poseTransform(entry)] };
2530
+ };
2531
+ const artwork = { ...scene.artwork, nodes: expandVectorAppearances(scene.artwork.nodes) };
2532
+ const content = [...artwork.nodes].reverse().map(nodeGroup);
2533
+ if (artwork.background && rgba(artwork.background))
2534
+ content.push({ ty: "gr", nm: "Background", it: [{ ty: "rc", nm: "Background", p: { a: 0, k: [artwork.width / 2, artwork.height / 2] }, s: { a: 0, k: [artwork.width, artwork.height] }, r: { a: 0, k: 0 } }, { ty: "fl", c: { a: 0, k: rgba(artwork.background).map(round2) }, o: { a: 0, k: 100 }, r: 1 }, STATIC_TR()] });
2535
+ const camera = track("", "camera");
2536
+ const root = STATIC_TR();
2537
+ if (camera.length) {
2538
+ const box = (t) => trackValue(camera, t) ?? [0, 0, artwork.width, artwork.height];
2539
+ root.a = animate(camera, (t) => box(t).slice(0, 2), false);
2540
+ root.s = animate(camera, (t) => [artwork.width / box(t)[2] * 100, artwork.height / box(t)[3] * 100], false);
2541
+ }
2542
+ const op = Math.max(1, Math.ceil(end * fps / 1e3));
2543
+ const lottie = {
2544
+ v: "5.7.4",
2545
+ fr: fps,
2546
+ ip: 0,
2547
+ op,
2548
+ w: artwork.width,
2549
+ h: artwork.height,
2550
+ nm: scene.name,
2551
+ ddd: 0,
2552
+ assets: [],
2553
+ layers: [
2554
+ {
2555
+ ddd: 0,
2556
+ ind: 1,
2557
+ ty: 4,
2558
+ nm: scene.name,
2559
+ sr: 1,
2560
+ ks: { o: { a: 0, k: 100 }, r: { a: 0, k: 0 }, p: { a: 0, k: [0, 0, 0] }, a: { a: 0, k: [0, 0, 0] }, s: { a: 0, k: [100, 100, 100] } },
2561
+ ao: 0,
2562
+ shapes: [{ ty: "gr", nm: "Camera", it: [...content, root] }],
2563
+ ip: 0,
2564
+ op,
2565
+ st: 0,
2566
+ bm: 0
2567
+ }
2568
+ ],
2569
+ markers: timeline.shots.map((s) => ({ tm: frame(s.start), cm: s.label ?? s.id, dr: frame(s.end - s.start) }))
2570
+ };
2571
+ return { lottie, sampled, notes: [...new Set(notes)] };
2572
+ }
2573
+
2186
2574
  // src/animation-editor/controller.ts
2187
2575
  var BLANK = { width: 800, height: 450, background: "#ffffff", nodes: [] };
2188
2576
  var AnimationEditorController = class {
@@ -2395,7 +2783,7 @@ var AnimationEditorController = class {
2395
2783
  }
2396
2784
  /** The scene at `time` (default: the playhead) as SVG. */
2397
2785
  toSvg(time = this.player.currentTime, options = {}) {
2398
- return motionFrameToSvg(this.scene, time, options);
2786
+ return motionStageSvg(this.scene, time, options);
2399
2787
  }
2400
2788
  /** The scene as a Flair `SvgMotion`. */
2401
2789
  toFlairMotion(options = {}) {
@@ -2755,10 +3143,20 @@ function createAnimationEditorToolProvider(controller, options = {}) {
2755
3143
  time: MS("Time from the start"),
2756
3144
  shot: { ...SHOT_ID, description: 'Render a moment of this shot instead of "time".' },
2757
3145
  at: { type: "string", enum: ["start", "middle", "end"], description: "With shot: which moment (default end: everything drawn, before the next shot)." },
2758
- format: { type: "string", enum: ["svg", "png"], description: "svg (default) or png (only when the host supports it)." }
3146
+ format: { type: "string", enum: ["svg", "png"], description: "svg (default) or png (only when the host supports it)." },
3147
+ onion_skin: object(
3148
+ {
3149
+ before: { type: "integer", minimum: 0, maximum: 12, description: "Ghost frames before the moment (default 2)." },
3150
+ after: { type: "integer", minimum: 0, maximum: 12, description: "Ghost frames after it (default 0)." },
3151
+ 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.' }
3152
+ },
3153
+ [],
3154
+ "Draw faded frames either side of the moment, to see motion in one picture."
3155
+ ),
3156
+ 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." }
2759
3157
  }),
2760
3158
  annotations: { title: "Render a frame", ...READ },
2761
- handler: async ({ time, shot, at = "end", format = "svg" }) => {
3159
+ handler: async ({ time, shot, at = "end", format = "svg", onion_skin, motion_paths }) => {
2762
3160
  const s = scene();
2763
3161
  let t = time ?? controller.getState().time;
2764
3162
  if (shot !== void 0) {
@@ -2766,7 +3164,11 @@ function createAnimationEditorToolProvider(controller, options = {}) {
2766
3164
  if (!timing) throw new Error(`There is no shot "${shot}"; the shots are ${s.shots.map((x) => `"${x.id}"`).join(", ") || "none yet"}.`);
2767
3165
  t = at === "start" ? timing.start : at === "middle" ? (timing.commandsStart + timing.commandsEnd) / 2 : Math.max(timing.start, timing.end - 1);
2768
3166
  }
2769
- const svg = controller.toSvg(t, { pretty: false });
3167
+ if (Array.isArray(motion_paths)) {
3168
+ const known = new Set(describeMotionElements(s).map((e) => e.id));
3169
+ 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.`);
3170
+ }
3171
+ const svg = controller.toSvg(t, { pretty: false, ...onion_skin ? { onionSkin: onion_skin } : {}, ...motion_paths ? { motionPaths: motion_paths } : {} });
2770
3172
  if (format === "png") {
2771
3173
  if (!options.rasterize) throw new Error('This host cannot make PNG images; call render_animation_frame with format "svg".');
2772
3174
  const image = await options.rasterize(svg, { width: s.artwork.width, height: s.artwork.height });
@@ -2814,6 +3216,24 @@ function createAnimationEditorToolProvider(controller, options = {}) {
2814
3216
  };
2815
3217
  }
2816
3218
  };
3219
+ const getMotionPath = {
3220
+ name: "get_motion_path",
3221
+ title: "Read where a node travels",
3222
+ 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.",
3223
+ inputSchema: object({
3224
+ node: { type: "string", minLength: 1, description: "Element id from get_animation." },
3225
+ from: { type: "number", minimum: 0, description: "From this time, ms (default 0)." },
3226
+ to: { type: "number", minimum: 0, description: "To this time, ms (default the end)." },
3227
+ step: { type: "number", exclusiveMinimum: 0, description: "Ms between samples (default 33; at most 400 samples)." }
3228
+ }, ["node"]),
3229
+ annotations: { title: "Read where a node travels", ...READ },
3230
+ handler: ({ node, from, to, step }) => {
3231
+ const s = scene();
3232
+ const ids = describeMotionElements(s).map((e) => e.id);
3233
+ if (!ids.includes(node)) throw new Error(`There is no element ${namedWithSuggestion(node, ids)}.`);
3234
+ return motionPath(s, node, { from, to, step });
3235
+ }
3236
+ };
2817
3237
  const exportFlairMotion = {
2818
3238
  name: "export_flair_motion",
2819
3239
  title: "Export as Flair vector motion",
@@ -2827,6 +3247,14 @@ function createAnimationEditorToolProvider(controller, options = {}) {
2827
3247
  annotations: { title: "Export as Flair vector motion", ...READ },
2828
3248
  handler: ({ fps, curves, behaviors, flair }) => controller.toFlairMotion({ ...fps ? { fps } : {}, ...curves === void 0 ? {} : { curves }, ...behaviors === void 0 ? {} : { behaviors }, ...flair ? { flair } : {} })
2829
3249
  };
3250
+ const exportLottie = {
3251
+ name: "export_lottie",
3252
+ title: "Export as Lottie",
3253
+ description: "Returns the animation as Lottie (Bodymovin JSON, v5) for lottie-web and the mobile Lottie players: one shape layer whose groups mirror the artwork, a marker per shot, eased changes as keyframes with bezier easing and the rest sampled. Also returns how many keyframes were sampled and notes on anything not carried over (gradients, dashes, reveal clips, morphs that change point counts).",
3254
+ inputSchema: object({ fps: { type: "number", minimum: 1, maximum: 120, description: "Frame rate (default: the scene's fps, else 30)." } }),
3255
+ annotations: { title: "Export as Lottie", ...READ },
3256
+ handler: ({ fps }) => toLottie(scene(), fps ? { fps } : {})
3257
+ };
2830
3258
  const exportFlairEffect = {
2831
3259
  name: "export_flair_effect",
2832
3260
  title: "Export as a Flair effect",
@@ -3069,7 +3497,7 @@ function createAnimationEditorToolProvider(controller, options = {}) {
3069
3497
  return { redone, ...redone ? { label } : {} };
3070
3498
  }
3071
3499
  };
3072
- const reads = [getAnimation, getShot, findElements, getTimingGrid, checkAnimation, renderFrame, exportFlairMotion, exportFlairEffect, seekAnimation];
3500
+ const reads = [getAnimation, getShot, findElements, getMotionPath, getTimingGrid, checkAnimation, renderFrame, exportFlairMotion, exportFlairEffect, exportLottie, seekAnimation];
3073
3501
  const writes = [setArtworkSvg, fitAnimation, importFlairEffect, insertShots, updateShot, deleteShots, moveShot, addCommands, updateCommand, deleteCommands, setSettings, setControls, editAnimation, writeAnimation, undo, redo];
3074
3502
  return {
3075
3503
  id: options.id ?? "animation",
@@ -3116,7 +3544,7 @@ Commands
3116
3544
  Working method
3117
3545
  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).
3118
3546
  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).
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.
3547
+ 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.
3120
3548
  4. To make a reusable Flair effect, set_animation_controls (colour controls stand for every use of their default colour) and export_flair_effect.
3121
3549
 
3122
3550
  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.`;
@@ -3183,12 +3611,16 @@ export {
3183
3611
  motionFollowRoute,
3184
3612
  motionFrameAt,
3185
3613
  motionFrameToSvg,
3614
+ motionGhosts,
3186
3615
  motionIssue,
3187
3616
  motionIssueKey,
3188
3617
  motionIssueLine,
3618
+ motionMovingIds,
3189
3619
  motionNodeClips,
3190
3620
  motionNodeOffset,
3621
+ motionPath,
3191
3622
  motionPathLength,
3623
+ motionPathsSvg,
3192
3624
  motionRestValue,
3193
3625
  motionRevealClip,
3194
3626
  motionRevealPadding,
@@ -3196,6 +3628,7 @@ export {
3196
3628
  motionSceneToJson,
3197
3629
  motionShotAt,
3198
3630
  motionShotTimeScale,
3631
+ motionStageSvg,
3199
3632
  motionTrackKey,
3200
3633
  namedWithSuggestion,
3201
3634
  parseMotionScene,
@@ -3207,6 +3640,7 @@ export {
3207
3640
  toFlairEffectDocument,
3208
3641
  toFlairSvgMotion,
3209
3642
  toFlairVectorEffect,
3643
+ toLottie,
3210
3644
  trackValue,
3211
3645
  withMotionLayers,
3212
3646
  withViewBox