@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.
- package/dist/animation-editor/index.cjs +285 -73
- package/dist/animation-editor/index.cjs.map +1 -1
- package/dist/animation-editor/index.d.cts +88 -2
- package/dist/animation-editor/index.d.ts +88 -2
- package/dist/animation-editor/index.js +233 -21
- package/dist/animation-editor/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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, """)}" 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
|
-
|
|
2218
|
-
|
|
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
|
|
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,
|