fablecut 1.12.1 → 1.14.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/motion.js ADDED
@@ -0,0 +1,803 @@
1
+ /* FableCut motion graphics — shape layers and layer transforms. Zero
2
+ dependencies. Loaded by the editor as a plain script (global
3
+ `FableCutMotion`) and required by edit-ops, the MCP server and the tests, so
4
+ the editor, an agent's `setShape` / `setParent` ops and the tests agree on
5
+ what a shape is and where a parented layer lands.
6
+
7
+ Shape layers: a clip of kind "shape" draws `props.shapes`, a list (first =
8
+ bottom), each item:
9
+ { type: rect | ellipse | polygon | star | line | path | group, name?, on?,
10
+ x, y position in layer px, from the layer's origin (y down),
11
+ rotation°, scale, opacity 0…1,
12
+ w, h size in px (rect / ellipse / polygon / star),
13
+ radius rect corner radius, px,
14
+ sides polygon / star points (3…64); inner: a star's inner radius (0.05…1 of the outer),
15
+ points line / path: [[x, y, inX?, inY?, outX?, outY?], …] px around x, y,
16
+ handles relative to their point; a line has exactly 2 points,
17
+ closed path: true (default) closes it; a line is always open,
18
+ fill "#rrggbb" | "#rrggbbaa" | "none"; stroke likewise,
19
+ strokeWidth px, lineCap butt | round | square, lineJoin miter | round | bevel,
20
+ dash [on, off, …] px,
21
+ trimStart, trimEnd 0…100 — the part of the outline that is drawn (a draw-on),
22
+ trimOffset % of the outline the drawn part is shifted round by,
23
+ items group: its own items, moved, turned, scaled, faded and trimmed together,
24
+ keys [{ t, ease?, x?, y?, rotation?, scale?, opacity?, w?, h?, radius?, inner?,
25
+ strokeWidth?, trimStart?, trimEnd?, trimOffset?, fill?, stroke?, points? }] }
26
+ t = seconds from the clip's start.
27
+ The layer's own props.trimStart / trimEnd / trimOffset trim every path in it.
28
+
29
+ Layer transforms (every visual clip):
30
+ props.anchorX, anchorY layer px from the picture's centre: the point that sits
31
+ at x / y and that rotation and scale turn around
32
+ clip.parent another clip's id: this clip moves, turns and scales
33
+ with it (its x / y are then in the parent's space).
34
+ Opacity, transitions and effects are not inherited. */
35
+ (function (root, factory) {
36
+ const api = factory();
37
+ if (typeof module === "object" && module.exports) module.exports = api;
38
+ else root.FableCutMotion = api;
39
+ })(typeof self !== "undefined" ? self : this, function () {
40
+ "use strict";
41
+
42
+ const TYPES = ["rect", "ellipse", "polygon", "star", "line", "path", "group"];
43
+ const MAX_ITEMS = 64; // every item in the layer, groups and their children included
44
+ const MAX_DEPTH = 4; // groups inside groups
45
+ const MAX_POINTS = 128;
46
+ const MAX_PARENT_DEPTH = 16;
47
+ const CAPS = ["butt", "round", "square"];
48
+ const JOINS = ["miter", "round", "bevel"];
49
+ const KAPPA = 0.5522847498;
50
+ const RANGE = {
51
+ x: [-20000, 20000], y: [-20000, 20000], rotation: [-36000, 36000], scale: [0.001, 100], opacity: [0, 1],
52
+ w: [0, 20000], h: [0, 20000], radius: [0, 10000], inner: [0.05, 1], strokeWidth: [0, 1000],
53
+ trimStart: [0, 100], trimEnd: [0, 100], trimOffset: [-10000, 10000],
54
+ };
55
+ const NUM_KEYED = Object.keys(RANGE);
56
+ const COLOR_KEYED = ["fill", "stroke"];
57
+ const KEYED = [...NUM_KEYED, ...COLOR_KEYED, "points"];
58
+ const TRANSFORM = ["x", "y", "rotation", "scale", "opacity"];
59
+ const TRIM = ["trimStart", "trimEnd", "trimOffset"];
60
+ /** Which keys each type takes beyond the transform, trim and name / on / keys. */
61
+ const OWN = {
62
+ rect: ["w", "h", "radius"], ellipse: ["w", "h"], polygon: ["w", "h", "sides"], star: ["w", "h", "sides", "inner"],
63
+ line: ["points"], path: ["points", "closed"], group: ["items"],
64
+ };
65
+ const PAINT = ["fill", "stroke", "strokeWidth", "lineCap", "lineJoin", "dash"];
66
+ const EASES = {
67
+ linear: (u) => u,
68
+ "ease-in": (u) => u * u,
69
+ "ease-out": (u) => 1 - (1 - u) * (1 - u),
70
+ "ease-in-out": (u) => (u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2),
71
+ };
72
+
73
+ const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
74
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v);
75
+ const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
76
+ const r3 = (v) => +v.toFixed(3);
77
+ const r4 = (v) => +v.toFixed(4);
78
+
79
+ /* ── Colours ── */
80
+ /** "#rrggbb" / "#rrggbbaa" (lower case), "none", or null when it is not a colour. */
81
+ function normColor(v) {
82
+ if (v === "none" || v === "transparent" || v === "") return "none";
83
+ if (typeof v !== "string") return null;
84
+ const s = v.trim().toLowerCase();
85
+ if (/^#[0-9a-f]{3,4}$/.test(s)) return "#" + [...s.slice(1)].map((c) => c + c).join("");
86
+ return /^#[0-9a-f]{6}([0-9a-f]{2})?$/.test(s) ? s : null;
87
+ }
88
+ const rgba = (c) => {
89
+ if (!c || c === "none") return [0, 0, 0, 0];
90
+ const n = (i) => parseInt(c.slice(i, i + 2), 16);
91
+ return [n(1), n(3), n(5), c.length > 7 ? n(7) : 255];
92
+ };
93
+ /** Colour between a and b ("none" fades through the other colour's transparent self). */
94
+ function lerpColor(a, b, u) {
95
+ if (a === b) return a;
96
+ let A = rgba(a), B = rgba(b);
97
+ if (a === "none") A = [B[0], B[1], B[2], 0];
98
+ if (b === "none") B = [A[0], A[1], A[2], 0];
99
+ const m = A.map((v, i) => Math.round(v + (B[i] - v) * u));
100
+ if (m[3] <= 0 && (a === "none" || b === "none") && u >= 1) return "none";
101
+ const hx = (v) => clamp(v, 0, 255).toString(16).padStart(2, "0");
102
+ return "#" + hx(m[0]) + hx(m[1]) + hx(m[2]) + (m[3] < 255 ? hx(m[3]) : "");
103
+ }
104
+
105
+ /* ── Validation ── */
106
+ const isClosed = (it) => it.type !== "line" && (it.type !== "path" || it.closed !== false);
107
+ function defaultsFor(type, closed = true) {
108
+ const open = type === "line" || (type === "path" && !closed);
109
+ const d = { x: 0, y: 0, rotation: 0, scale: 1, opacity: 1, trimStart: 0, trimEnd: 100, trimOffset: 0 };
110
+ if (type === "group") return d;
111
+ Object.assign(d, { fill: open ? "none" : "#ffffff", stroke: open ? "#ffffff" : "none", lineCap: "round", lineJoin: "round" });
112
+ if (type === "rect" || type === "ellipse" || type === "polygon" || type === "star") Object.assign(d, { w: 200, h: 200 });
113
+ if (type === "rect") d.radius = 0;
114
+ if (type === "polygon") d.sides = 6;
115
+ if (type === "star") Object.assign(d, { sides: 5, inner: 0.5 });
116
+ if (type === "line") d.points = [[-150, 0], [150, 0]];
117
+ return d;
118
+ }
119
+ function normalizePoints(pts, type, bad, label) {
120
+ const min = 2, max = type === "line" ? 2 : MAX_POINTS;
121
+ if (!Array.isArray(pts) || pts.length < min || pts.length > max ||
122
+ pts.some((p) => !Array.isArray(p) || (p.length !== 2 && p.length !== 6) || !p.every(isNum))) {
123
+ bad(`${label} must be ${type === "line" ? "exactly 2" : `2…${MAX_POINTS}`} points [x, y] or [x, y, inX, inY, outX, outY] (px around the item's x, y)`);
124
+ return null;
125
+ }
126
+ return pts.map((p) => {
127
+ const q = p.map((v) => r3(clamp(v, -20000, 20000)));
128
+ return q.length === 6 && q.slice(2).every((v) => v === 0) ? q.slice(0, 2) : q;
129
+ });
130
+ }
131
+ /** A layer's shape list, validated, with every default filled in. null when empty.
132
+ * strict: throw on bad input (agent); otherwise drop what is invalid. */
133
+ function normalizeShapes(list, strict = false) {
134
+ if (list == null) return null;
135
+ if (!Array.isArray(list)) { if (strict) throw new Error("shapes must be a list of shape items"); return null; }
136
+ const count = { n: 0 };
137
+ const out = list.map((it, i) => normalizeItem(it, strict, `shapes[${i}]`, 0, count)).filter(Boolean);
138
+ return out.length ? out : null;
139
+ }
140
+ /** One shape item, validated (groups with their items). */
141
+ function normalizeItem(it, strict = false, where = "shape", depth = 0, count = { n: 0 }) {
142
+ if (it == null) return null;
143
+ const bad = (msg) => { if (strict) throw new Error(`${where}${msg}`); return null; };
144
+ if (!isObj(it)) return bad(" must be an object like {type:'rect', w:300, h:120, fill:'#ffd166'}");
145
+ if (++count.n > MAX_ITEMS) return bad(`: a shape layer has at most ${MAX_ITEMS} items`);
146
+ const type = it.type == null ? "rect" : it.type;
147
+ if (!TYPES.includes(type)) return bad(`.type must be ${TYPES.join(" | ")}`);
148
+ if (type === "group" && depth >= MAX_DEPTH) return bad(`: groups nest at most ${MAX_DEPTH} deep`);
149
+ const known = ["type", "name", "on", "keys", ...TRANSFORM, ...TRIM, ...OWN[type], ...(type === "group" ? [] : PAINT)];
150
+ for (const k of Object.keys(it)) if (!known.includes(k)) bad(`.${k}: not a ${type} key (${known.join(", ")})`);
151
+ const closed = type === "path" ? it.closed !== false : type !== "line";
152
+ const d = defaultsFor(type, closed);
153
+ const out = { type };
154
+ if (typeof it.name === "string" && it.name.trim()) out.name = it.name.trim().slice(0, 40);
155
+ else if (it.name != null) bad(".name must be a string");
156
+ if (it.on === false) out.on = false;
157
+ for (const k of [...TRANSFORM, ...TRIM, ...OWN[type].filter((k) => RANGE[k])]) {
158
+ let v = it[k];
159
+ if (v == null) v = d[k];
160
+ else if (!isNum(v)) { bad(`.${k} must be a number`); v = d[k]; }
161
+ out[k] = r4(clamp(v, ...RANGE[k]));
162
+ }
163
+ if (OWN[type].includes("sides")) {
164
+ let v = it.sides == null ? d.sides : it.sides;
165
+ if (!Number.isInteger(v) || v < 3 || v > 64) { bad(".sides must be a whole number 3…64"); v = d.sides; }
166
+ out.sides = clamp(Math.round(+v || d.sides), 3, 64);
167
+ }
168
+ if (type === "line" || type === "path") {
169
+ const pts = it.points == null && type === "line" ? d.points : normalizePoints(it.points, type, bad, ".points");
170
+ if (!pts) return null;
171
+ out.points = pts;
172
+ if (type === "path" && it.closed === false) out.closed = false;
173
+ }
174
+ if (type === "group") {
175
+ if (it.items != null && !Array.isArray(it.items)) bad(".items must be a list of shape items");
176
+ out.items = (Array.isArray(it.items) ? it.items : []).map((c, i) => normalizeItem(c, strict, `${where}.items[${i}]`, depth + 1, count)).filter(Boolean);
177
+ } else {
178
+ for (const k of COLOR_KEYED) {
179
+ const v = it[k] == null ? d[k] : normColor(it[k]);
180
+ if (v == null) bad(`.${k} must be a colour "#rrggbb" / "#rrggbbaa" or "none"`);
181
+ out[k] = v || d[k];
182
+ }
183
+ let sw = it.strokeWidth;
184
+ if (sw != null && !isNum(sw)) { bad(".strokeWidth must be a number (px)"); sw = null; }
185
+ if (sw == null) sw = out.stroke === "none" ? 0 : closed ? 6 : 8;
186
+ if (sw || out.stroke !== "none") out.strokeWidth = r3(clamp(sw, ...RANGE.strokeWidth));
187
+ out.lineCap = CAPS.includes(it.lineCap) ? it.lineCap : (it.lineCap != null && bad(`.lineCap must be ${CAPS.join(" | ")}`), d.lineCap);
188
+ out.lineJoin = JOINS.includes(it.lineJoin) ? it.lineJoin : (it.lineJoin != null && bad(`.lineJoin must be ${JOINS.join(" | ")}`), d.lineJoin);
189
+ if (it.dash != null) {
190
+ if (!Array.isArray(it.dash) || it.dash.length > 8 || !it.dash.every((v) => isNum(v) && v >= 0) || !it.dash.some((v) => v > 0)) bad(".dash must be a list of up to 8 lengths in px, e.g. [20, 10]");
191
+ else out.dash = it.dash.map((v) => r3(Math.min(v, 10000)));
192
+ }
193
+ }
194
+ if (it.keys != null) {
195
+ const keys = normalizeKeys(it.keys, out, bad);
196
+ if (keys) out.keys = keys;
197
+ }
198
+ return out;
199
+ }
200
+ function normalizeKeys(list, it, bad) {
201
+ if (!Array.isArray(list)) { bad(".keys must be a list of {t, x?, y?, …}"); return null; }
202
+ const ok = it.type === "group" ? [...TRANSFORM, ...TRIM] : [...TRANSFORM, ...TRIM, ...OWN[it.type].filter((k) => RANGE[k]), ...COLOR_KEYED, ...(it.points ? ["points"] : [])];
203
+ const keys = [];
204
+ for (const kf of list) {
205
+ if (!isObj(kf) || !isNum(kf.t)) { bad(".keys entries need a time t (seconds from the clip's start)"); continue; }
206
+ const k = { t: r4(Math.max(0, kf.t)) };
207
+ for (const p of Object.keys(kf)) {
208
+ if (p === "t" || p === "ease") continue;
209
+ if (!ok.includes(p)) { bad(`.keys: ${p} is not keyable on a ${it.type} (${ok.join(", ")})`); continue; }
210
+ if (RANGE[p]) { if (isNum(kf[p])) k[p] = r4(clamp(kf[p], ...RANGE[p])); else bad(`.keys ${p} must be a number`); }
211
+ else if (p === "points") {
212
+ const pts = normalizePoints(kf.points, it.type, bad, ".keys points");
213
+ if (pts && pts.length !== it.points.length) bad(`.keys points need the shape's ${it.points.length} points (got ${pts.length})`);
214
+ else if (pts) k.points = pts;
215
+ } else {
216
+ const c = normColor(kf[p]);
217
+ if (c == null) bad(`.keys ${p} must be a colour or "none"`); else k[p] = c;
218
+ }
219
+ }
220
+ if (kf.ease && kf.ease !== "ease-in-out") {
221
+ if (!EASES[kf.ease]) { bad(`.keys ease must be ${Object.keys(EASES).join(" | ")}`); continue; }
222
+ k.ease = kf.ease;
223
+ }
224
+ if (Object.keys(k).some((p) => KEYED.includes(p))) keys.push(k);
225
+ }
226
+ keys.sort((a, b) => a.t - b.t);
227
+ const dedup = keys.filter((k, i) => i === keys.length - 1 || keys[i + 1].t !== k.t);
228
+ return dedup.length ? dedup : null;
229
+ }
230
+ /** Merge `set` into an item key by key (null resets a key); a new type starts fresh. */
231
+ function mergeItem(base, set, strict = false) {
232
+ if (set == null) return null;
233
+ if (!isObj(set)) { if (strict) throw new Error("shape must be an object"); return base || null; }
234
+ let next = { ...(base || {}) };
235
+ if (set.type && base && set.type !== base.type) {
236
+ next = {};
237
+ for (const k of ["name", "on", ...TRANSFORM, ...TRIM, ...PAINT]) if (k in base) next[k] = base[k];
238
+ }
239
+ for (const [k, v] of Object.entries(set)) {
240
+ if (v === null) delete next[k];
241
+ else next[k] = v;
242
+ }
243
+ if (next.type === "group") for (const k of PAINT) delete next[k];
244
+ return normalizeItem(next, strict);
245
+ }
246
+
247
+ /* ── Time ── */
248
+ function lerpPoints(a, b, u) {
249
+ return a.map((p, i) => {
250
+ const q = b[i], n = Math.max(p.length, q.length), out = [];
251
+ for (let j = 0; j < n; j++) out.push((p[j] || 0) + ((q[j] || 0) - (p[j] || 0)) * u);
252
+ return out;
253
+ });
254
+ }
255
+ /** An item as it is at clip-local time t (keys resolved and removed, groups too). */
256
+ function itemAt(it, t) {
257
+ if (!it) return it;
258
+ let out = it;
259
+ if (it.keys && it.keys.length) {
260
+ out = { ...it };
261
+ delete out.keys;
262
+ for (const p of KEYED) {
263
+ const ks = it.keys.filter((k) => k[p] != null);
264
+ if (!ks.length) continue;
265
+ if (t <= ks[0].t) { out[p] = ks[0][p]; continue; }
266
+ if (t >= ks[ks.length - 1].t) { out[p] = ks[ks.length - 1][p]; continue; }
267
+ let i = 0;
268
+ while (t > ks[i + 1].t) i++;
269
+ const a = ks[i], b = ks[i + 1];
270
+ const u = (EASES[b.ease || "ease-in-out"] || EASES.linear)((t - a.t) / Math.max(1e-9, b.t - a.t));
271
+ out[p] = p === "points" ? lerpPoints(a.points, b.points, u)
272
+ : COLOR_KEYED.includes(p) ? lerpColor(a[p], b[p], u) : a[p] + (b[p] - a[p]) * u;
273
+ }
274
+ if (out.stroke !== "none" && !out.strokeWidth && !it.keys.some((k) => k.strokeWidth != null)) out.strokeWidth = it.type === "line" || it.closed === false ? 8 : 6;
275
+ }
276
+ if (it.type === "group" && it.items && it.items.some((c) => c.keys || c.type === "group")) {
277
+ if (out === it) out = { ...it };
278
+ out.items = it.items.map((c) => itemAt(c, t));
279
+ }
280
+ return out;
281
+ }
282
+ /** The layer's items at clip-local time t (keys resolved), or null. */
283
+ function shapesAt(list, t) {
284
+ if (!Array.isArray(list) || !list.length) return null;
285
+ return list.map((it) => itemAt(it, t));
286
+ }
287
+ /** A key holding every keyed value of `now` (an item at some time), at time t. */
288
+ function snapshotKey(it, now, t) {
289
+ const k = { t: r4(Math.max(0, t)) };
290
+ for (const p of KEYED) {
291
+ if (now[p] == null) continue;
292
+ if (!(it.keys || []).some((x) => x[p] != null)) continue;
293
+ k[p] = p === "points" ? now.points.map((q) => q.map(r3)) : RANGE[p] ? r4(now[p]) : now[p];
294
+ }
295
+ return k;
296
+ }
297
+ /** Re-base item keys when the clip's head moves by `offset` s (a split's right half, a
298
+ * head trim): keys before the new start fold into one key at 0, so the animation plays on. */
299
+ function shiftKeys(list, offset) {
300
+ if (!Array.isArray(list) || !offset) return list;
301
+ const shift = (it) => {
302
+ if (!it || typeof it !== "object") return it;
303
+ let out = it;
304
+ if (it.keys && it.keys.length) {
305
+ const keys = it.keys.map((k) => ({ ...k, t: r4(k.t - offset) }));
306
+ const after = keys.filter((k) => k.t > 1e-4);
307
+ if (after.length === keys.length) out = { ...it, keys };
308
+ else {
309
+ const now = itemAt({ ...it, items: undefined }, offset);
310
+ out = { ...it, keys: [snapshotKey(it, now, 0), ...after] };
311
+ }
312
+ }
313
+ if (it.type === "group" && Array.isArray(it.items)) out = { ...out, items: it.items.map(shift) };
314
+ return out;
315
+ };
316
+ return list.map(shift);
317
+ }
318
+
319
+ /* ── 2D affine matrices [a, b, c, d, e, f] (the canvas / DOMMatrix order) ── */
320
+ const IDENT = [1, 0, 0, 1, 0, 0];
321
+ function mul(m, n) {
322
+ return [m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1], m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3],
323
+ m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5]];
324
+ }
325
+ function apply(m, x, y) { return [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]; }
326
+ function invert(m) {
327
+ const det = m[0] * m[3] - m[1] * m[2] || 1e-12;
328
+ return [m[3] / det, -m[1] / det, -m[2] / det, m[0] / det, (m[2] * m[5] - m[3] * m[4]) / det, (m[1] * m[4] - m[0] * m[5]) / det];
329
+ }
330
+ /** translate(x, y) · rotate(deg) · scale(s) · translate(−ax, −ay) */
331
+ function trs(x, y, deg, s, ax = 0, ay = 0) {
332
+ const th = (deg || 0) * Math.PI / 180, cs = Math.cos(th) * s, sn = Math.sin(th) * s;
333
+ return [cs, sn, -sn, cs, x - (cs * ax - sn * ay), y - (sn * ax + cs * ay)];
334
+ }
335
+
336
+ /* ── Geometry ── */
337
+ /** An item's outline in its own frame (before its x / y / rotation / scale):
338
+ * { A: [{x, y, ix, iy, ox, oy}, …] anchors with absolute handles, closed }. */
339
+ function outline(it) {
340
+ const hw = (it.w || 0) / 2, hh = (it.h || 0) / 2;
341
+ const P = (x, y, ix = 0, iy = 0, ox = 0, oy = 0) => ({ x, y, ix: x + ix, iy: y + iy, ox: x + ox, oy: y + oy });
342
+ if (it.type === "rect") {
343
+ const r = Math.min(it.radius || 0, hw, hh), k = r * KAPPA;
344
+ if (r <= 0) return { A: [P(-hw, -hh), P(hw, -hh), P(hw, hh), P(-hw, hh)], closed: true };
345
+ return { A: [
346
+ P(-hw + r, -hh, -k, 0), P(hw - r, -hh, 0, 0, k, 0), P(hw, -hh + r, 0, -k), P(hw, hh - r, 0, 0, 0, k),
347
+ P(hw - r, hh, k, 0), P(-hw + r, hh, 0, 0, -k, 0), P(-hw, hh - r, 0, k), P(-hw, -hh + r, 0, 0, 0, -k),
348
+ ], closed: true };
349
+ }
350
+ if (it.type === "ellipse") {
351
+ const kx = hw * KAPPA, ky = hh * KAPPA;
352
+ return { A: [P(0, -hh, -kx, 0, kx, 0), P(hw, 0, 0, -ky, 0, ky), P(0, hh, kx, 0, -kx, 0), P(-hw, 0, 0, ky, 0, -ky)], closed: true };
353
+ }
354
+ if (it.type === "polygon" || it.type === "star") {
355
+ const n = it.sides || 5, star = it.type === "star", m = star ? n * 2 : n, A = [];
356
+ for (let i = 0; i < m; i++) {
357
+ const th = -Math.PI / 2 + i * 2 * Math.PI / m, k = star && i % 2 ? it.inner : 1;
358
+ A.push(P(Math.cos(th) * hw * k, Math.sin(th) * hh * k));
359
+ }
360
+ return { A, closed: true };
361
+ }
362
+ if (it.type === "line" || it.type === "path") {
363
+ return { A: (it.points || []).map((p) => P(p[0], p[1], p[2] || 0, p[3] || 0, p[4] || 0, p[5] || 0)), closed: isClosed(it) };
364
+ }
365
+ return { A: [], closed: false };
366
+ }
367
+ /** Cubic segments of an outline: [[a, b], …] (closed: the last one wraps to the first). */
368
+ function segments(A, closed) {
369
+ const out = [];
370
+ for (let i = 0; i < A.length - (closed ? 0 : 1); i++) out.push([A[i], A[(i + 1) % A.length]]);
371
+ return out;
372
+ }
373
+ const cubic = (a, b, t) => {
374
+ const u = 1 - t;
375
+ return [u * u * u * a.x + 3 * u * u * t * a.ox + 3 * u * t * t * b.ix + t * t * t * b.x,
376
+ u * u * u * a.y + 3 * u * u * t * a.oy + 3 * u * t * t * b.iy + t * t * t * b.y];
377
+ };
378
+ const straight = (a, b) => a.ox === a.x && a.oy === a.y && b.ix === b.x && b.iy === b.y;
379
+ /** The outline as a polyline (each curve cut into n pieces), starting at the first point. */
380
+ function flatten(A, closed, n = 32) {
381
+ if (!A.length) return [];
382
+ const out = [[A[0].x, A[0].y]];
383
+ for (const [a, b] of segments(A, closed)) {
384
+ const steps = straight(a, b) ? 1 : n;
385
+ for (let j = 1; j <= steps; j++) out.push(cubic(a, b, j / steps));
386
+ }
387
+ return out;
388
+ }
389
+ /** Trace the full outline into a canvas-like path. */
390
+ function tracePath(ctx, A, closed) {
391
+ if (!A.length) return;
392
+ ctx.moveTo(A[0].x, A[0].y);
393
+ for (const [a, b] of segments(A, closed)) ctx.bezierCurveTo(a.ox, a.oy, b.ix, b.iy, b.x, b.y);
394
+ if (closed) ctx.closePath();
395
+ }
396
+ /** The drawn window of an outline as fractions of its length: [[from, to], …] (one or two runs). */
397
+ function trimRuns(trim, closed) {
398
+ let s = clamp(trim[0], 0, 1), e = clamp(trim[1], 0, 1);
399
+ if (e - s <= 1e-6) return [];
400
+ if (s <= 0 && e >= 1) return [[0, 1]];
401
+ const off = trim[2] || 0;
402
+ if (!closed) { s = clamp(s + off, 0, 1); e = clamp(e + off, 0, 1); return e - s > 1e-6 ? [[s, e]] : []; }
403
+ const span = e - s;
404
+ s = ((s + off) % 1 + 1) % 1;
405
+ return s + span <= 1 + 1e-9 ? [[s, s + span]] : [[s, 1], [0, s + span - 1]];
406
+ }
407
+ /** Trace the trimmed part of an outline (polyline). Closed outlines wrap round the seam. */
408
+ function traceTrim(ctx, A, closed, trim) {
409
+ const runs = trimRuns(trim, closed);
410
+ if (!runs.length) return false;
411
+ const P = flatten(A, closed), L = [0];
412
+ for (let i = 1; i < P.length; i++) L.push(L[i - 1] + Math.hypot(P[i][0] - P[i - 1][0], P[i][1] - P[i - 1][1]));
413
+ const total = L[L.length - 1];
414
+ if (total <= 0) return false;
415
+ const at = (d) => {
416
+ let i = 1;
417
+ while (i < L.length - 1 && L[i] < d) i++;
418
+ const u = (d - L[i - 1]) / Math.max(1e-12, L[i] - L[i - 1]);
419
+ return [P[i - 1][0] + (P[i][0] - P[i - 1][0]) * u, P[i - 1][1] + (P[i][1] - P[i - 1][1]) * u];
420
+ };
421
+ // a wrapped window on a closed outline is one continuous run: [s, 1] then [0, e]
422
+ const pts = [];
423
+ for (const [a, b] of runs) {
424
+ const da = a * total, db = b * total;
425
+ pts.push(at(da));
426
+ for (let i = 0; i < L.length; i++) if (L[i] > da && L[i] < db) pts.push(P[i]);
427
+ pts.push(at(db));
428
+ }
429
+ ctx.moveTo(pts[0][0], pts[0][1]);
430
+ for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
431
+ return true;
432
+ }
433
+ /** Compose a parent's trim window [s, e, offset] (fractions) with an item's own. */
434
+ function composeTrim(outer, it) {
435
+ const is = (it.trimStart ?? 0) / 100, ie = (it.trimEnd ?? 100) / 100, io = (it.trimOffset ?? 0) / 100;
436
+ const span = ie - is;
437
+ return [is + span * outer[0], is + span * outer[1], io + outer[2]];
438
+ }
439
+ const itemMatrix = (it) => trs(it.x || 0, it.y || 0, it.rotation || 0, it.scale == null ? 1 : it.scale);
440
+
441
+ /** Draw resolved items (shapesAt) onto a 2D context in layer px. trim: the layer's
442
+ * [trimStart, trimEnd, trimOffset] in % (default the whole outline). */
443
+ function draw(ctx, items, trim = [0, 100, 0]) {
444
+ if (!Array.isArray(items)) return;
445
+ const base = ctx.globalAlpha;
446
+ drawItems(ctx, items, [trim[0] / 100, trim[1] / 100, (trim[2] || 0) / 100], 1, base);
447
+ ctx.globalAlpha = base;
448
+ }
449
+ function drawItems(ctx, items, trim, alpha, base) {
450
+ for (const it of items) {
451
+ if (!it || it.on === false) continue;
452
+ const a = alpha * (it.opacity == null ? 1 : it.opacity);
453
+ if (a <= 0) continue;
454
+ ctx.save();
455
+ const m = itemMatrix(it);
456
+ ctx.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
457
+ const tr = composeTrim(trim, it);
458
+ if (it.type === "group") drawItems(ctx, it.items || [], tr, a, base);
459
+ else paint(ctx, it, tr, a * base);
460
+ ctx.restore();
461
+ }
462
+ }
463
+ function paint(ctx, it, trim, alpha) {
464
+ const { A, closed } = outline(it);
465
+ if (A.length < 2) return;
466
+ const full = trim[0] <= 1e-6 && trim[1] >= 1 - 1e-6;
467
+ ctx.beginPath();
468
+ if (full) tracePath(ctx, A, closed);
469
+ else if (!traceTrim(ctx, A, closed, trim)) return;
470
+ ctx.globalAlpha = alpha;
471
+ if (it.fill && it.fill !== "none") { ctx.fillStyle = it.fill; ctx.fill(); }
472
+ if (it.stroke && it.stroke !== "none" && it.strokeWidth > 0) {
473
+ ctx.lineWidth = it.strokeWidth;
474
+ ctx.lineCap = it.lineCap || "round";
475
+ ctx.lineJoin = it.lineJoin || "round";
476
+ if (ctx.setLineDash) ctx.setLineDash(it.dash || []);
477
+ ctx.strokeStyle = it.stroke;
478
+ ctx.stroke();
479
+ if (ctx.setLineDash) ctx.setLineDash([]);
480
+ }
481
+ }
482
+ /** Bounding box of resolved items in layer px (stroke included): {x0, y0, x1, y1} or null. */
483
+ function bounds(items, m = IDENT, acc = null) {
484
+ for (const it of items || []) {
485
+ if (!it || it.on === false) continue;
486
+ const M = mul(m, itemMatrix(it));
487
+ if (it.type === "group") { acc = bounds(it.items, M, acc); continue; }
488
+ const { A } = outline(it);
489
+ const k = Math.hypot(M[0], M[1]), pad = it.stroke && it.stroke !== "none" ? (it.strokeWidth || 0) / 2 * k : 0;
490
+ for (const a of A) for (const [x, y] of [[a.x, a.y], [a.ix, a.iy], [a.ox, a.oy]]) {
491
+ const [px, py] = apply(M, x, y);
492
+ acc = acc || { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
493
+ acc.x0 = Math.min(acc.x0, px - pad); acc.y0 = Math.min(acc.y0, py - pad);
494
+ acc.x1 = Math.max(acc.x1, px + pad); acc.y1 = Math.max(acc.y1, py + pad);
495
+ }
496
+ }
497
+ return acc;
498
+ }
499
+ /** Item paths through a list: [[i], [i, j], …] for every item, depth first (the UI's item picker). */
500
+ function itemPaths(items, pre = [], out = []) {
501
+ (items || []).forEach((it, i) => {
502
+ out.push([...pre, i]);
503
+ if (it.type === "group") itemPaths(it.items, [...pre, i], out);
504
+ });
505
+ return out;
506
+ }
507
+ function getItem(items, path) {
508
+ let list = items, it = null;
509
+ for (const i of path || []) { it = list && list[i]; if (!it) return null; list = it.items; }
510
+ return it;
511
+ }
512
+ /** The list with the item at `path` replaced by fn(item) (null removes it). Copies along the path only. */
513
+ function updateItem(items, path, fn) {
514
+ const [i, ...rest] = path, list = items.slice();
515
+ if (!list[i]) return items;
516
+ if (rest.length) list[i] = { ...list[i], items: updateItem(list[i].items || [], rest, fn) };
517
+ else { const v = fn(list[i]); if (v == null) list.splice(i, 1); else list[i] = v; }
518
+ return list;
519
+ }
520
+ /** Matrix from an item's own frame (at path) to layer px, items resolved. */
521
+ function itemFrame(items, path) {
522
+ let m = IDENT, list = items;
523
+ for (const i of path) { const it = list && list[i]; if (!it) return null; m = mul(m, itemMatrix(it)); list = it.items; }
524
+ return m;
525
+ }
526
+ function countItems(items) { return itemPaths(items).length; }
527
+
528
+ /** One line per layer for status readouts: "3 shapes: rect, star(5), group(2) …". */
529
+ function describe(list) {
530
+ if (!Array.isArray(list) || !list.length) return "";
531
+ const one = (it) => (it.name ? `"${it.name}" ` : "") + it.type +
532
+ (it.type === "group" ? `(${(it.items || []).map(one).join(", ")})` : it.type === "path" ? `(${(it.points || []).length}${it.closed === false ? " open" : ""})` :
533
+ it.sides && it.type !== "rect" ? `(${it.sides})` : "") +
534
+ (it.fill && it.fill !== "none" && it.type !== "group" ? ` ${it.fill}` : "") +
535
+ (it.stroke && it.stroke !== "none" ? ` stroke ${it.stroke}/${it.strokeWidth}` : "") +
536
+ ((it.trimStart || 0) > 0 || (it.trimEnd ?? 100) < 100 ? ` trim ${it.trimStart || 0}-${it.trimEnd ?? 100}` : "") +
537
+ (it.on === false ? " off" : "") + (it.keys ? ` ${it.keys.length} keys` : "");
538
+ const n = countItems(list);
539
+ return `${n} shape${n > 1 ? "s" : ""}: ` + list.map(one).join(", ");
540
+ }
541
+
542
+ /* ── Layer transforms: anchor point and parenting ── */
543
+ /** The scale a clip's picture is drawn at (a boxed title ignores scale). */
544
+ function contentScale(clip, p) {
545
+ if (clip && clip.kind === "text" && +p.boxW > 0 && +p.boxH > 0 && !p.textPath) return 1;
546
+ return +p.scale || 1;
547
+ }
548
+ /** A layer's own matrix: position · rotation · scale about its anchor point. */
549
+ function layerMatrix(clip, p) {
550
+ return trs(+p.x || 0, +p.y || 0, +p.rotation || 0, contentScale(clip, p), +p.anchorX || 0, +p.anchorY || 0);
551
+ }
552
+ /** A keyframed channel at clip-local time (the editor's own interpolation). */
553
+ function channelAt(clip, key, local, fallback) {
554
+ const kfs = clip.keyframes && clip.keyframes[key];
555
+ if (!Array.isArray(kfs) || !kfs.length) return fallback;
556
+ if (local <= kfs[0].t) return kfs[0].v;
557
+ if (local >= kfs[kfs.length - 1].t) return kfs[kfs.length - 1].v;
558
+ for (let i = 0; i < kfs.length - 1; i++) {
559
+ const a = kfs[i], b = kfs[i + 1];
560
+ if (local >= a.t && local <= b.t) {
561
+ const u = (local - a.t) / Math.max(1e-6, b.t - a.t);
562
+ return a.v + (b.v - a.v) * (EASES[b.ease || "ease-in-out"] || EASES.linear)(u);
563
+ }
564
+ }
565
+ return fallback;
566
+ }
567
+ const XFORM_KEYS = ["x", "y", "rotation", "scale", "anchorX", "anchorY", "boxW", "boxH"];
568
+ const XFORM_DEF = { x: 0, y: 0, rotation: 0, scale: 1, anchorX: 0, anchorY: 0, boxW: 0, boxH: 0 };
569
+ /** A clip's transform at timeline time t (its keyframes, held past its ends; no transitions). */
570
+ function transformAt(clip, t) {
571
+ const local = clamp(t - (clip.start || 0), 0, clip.duration || 0), p = clip.props || {}, out = {};
572
+ for (const k of XFORM_KEYS) out[k] = channelAt(clip, k, local, p[k] == null ? XFORM_DEF[k] : +p[k]);
573
+ const q = pathPositionAt(clip, local); // a curved motion path moves the children too
574
+ if (q) { out.x = q.x; out.y = q.y; }
575
+ if (p.autoOrient) { const a = orientAt(clip, local); if (a != null) out.rotation += a; }
576
+ return out;
577
+ }
578
+ /** The clips a clip is parented through: [parent, grandparent, …]; stops at a missing clip or a loop. */
579
+ function parentChain(clip, byId) {
580
+ const out = [], seen = new Set([clip.id]);
581
+ let q = clip;
582
+ while (q && q.parent && out.length < MAX_PARENT_DEPTH) {
583
+ const p = byId(q.parent);
584
+ if (!p || seen.has(p.id)) break;
585
+ seen.add(p.id); out.push(p); q = p;
586
+ }
587
+ return out;
588
+ }
589
+ /** The parents' combined matrix at timeline t (identity without a parent). */
590
+ function parentMatrix(clip, byId, t) {
591
+ const chain = parentChain(clip, byId);
592
+ let m = IDENT;
593
+ for (let i = chain.length - 1; i >= 0; i--) m = mul(m, layerMatrix(chain[i], transformAt(chain[i], t)));
594
+ return m;
595
+ }
596
+ /** Would parenting `childId` to `parentId` make a loop? */
597
+ function wouldCycle(childId, parentId, byId) {
598
+ let q = byId(parentId), n = 0;
599
+ while (q && n++ < 64) { if (q.id === childId) return true; q = q.parent ? byId(q.parent) : null; }
600
+ return false;
601
+ }
602
+ /** The x / y / rotation / scale that keep a clip where it is (at timeline t) when its
603
+ * parent changes from the current one to `parentId` (null = none). */
604
+ function reparentValues(clip, parentId, byId, t) {
605
+ const own = transformAt(clip, t);
606
+ const world = mul(parentMatrix(clip, byId, t), layerMatrix(clip, own));
607
+ const next = parentId ? parentMatrix({ id: clip.id, parent: parentId }, byId, t) : IDENT;
608
+ const L = mul(invert(next), world);
609
+ const boxed = clip.kind === "text" && own.boxW > 0 && own.boxH > 0;
610
+ const s = Math.hypot(L[0], L[1]);
611
+ const [x, y] = apply(L, own.anchorX, own.anchorY);
612
+ const out = { x: r3(x), y: r3(y), rotation: r3(Math.atan2(L[1], L[0]) * 180 / Math.PI) };
613
+ if (!boxed) out.scale = r4(s);
614
+ return out;
615
+ }
616
+ /** The x / y that keep a layer's picture still while its anchor moves to (ax, ay). */
617
+ function anchorMoveValues(clip, p, ax, ay) {
618
+ const [x, y] = apply(layerMatrix(clip, p), ax, ay); // where the new anchor point is now: the new position
619
+ return { x: r3(x), y: r3(y), anchorX: r3(ax), anchorY: r3(ay) };
620
+ }
621
+
622
+ /* ── Motion paths: position keys with spatial handles ──
623
+ A position key is a time where keyframes.x and keyframes.y both have a key.
624
+ Its curve handles sit on those keys: x key {pin, pout} and y key {pin, pout},
625
+ px relative to the key's position (in the clip's x / y space). Between two
626
+ position keys with no other x / y key in between, the clip travels along the
627
+ cubic bezier key → key+out → next+in → next, at the pace of the arriving key's
628
+ ease (measured along the curve). Without handles the channels run as usual. */
629
+ const KEY_EPS = 1e-3;
630
+ /** The clip's position keys: [{t, x, y, ix, iy, ox, oy, ease, xk, yk}] (xk / yk = the stored keys). */
631
+ function posKeys(clip) {
632
+ const kx = clip && clip.keyframes && clip.keyframes.x, ky = clip && clip.keyframes && clip.keyframes.y;
633
+ if (!Array.isArray(kx) || !Array.isArray(ky) || kx.length < 2 || ky.length < 2) return [];
634
+ const out = [];
635
+ for (const a of kx) {
636
+ const b = ky.find((k) => Math.abs(k.t - a.t) < KEY_EPS);
637
+ if (!b) continue;
638
+ out.push({ t: a.t, x: +a.v, y: +b.v, ix: +a.pin || 0, iy: +b.pin || 0, ox: +a.pout || 0, oy: +b.pout || 0,
639
+ ease: a.ease || "ease-in-out", xk: a, yk: b });
640
+ }
641
+ return out.sort((p, q) => p.t - q.t);
642
+ }
643
+ function hasPathHandles(clip) {
644
+ const k = clip && clip.keyframes;
645
+ return !!k && [k.x, k.y].some((a) => Array.isArray(a) && a.some((x) => x && (+x.pin || +x.pout)));
646
+ }
647
+ /** The curved segment that holds clip-local time t, or null (the channels apply there). */
648
+ function pathSegmentAt(clip, t) {
649
+ if (!hasPathHandles(clip)) return null;
650
+ const P = posKeys(clip);
651
+ for (let i = 0; i < P.length - 1; i++) {
652
+ const a = P[i], b = P[i + 1];
653
+ if (t < a.t - 1e-9 || t > b.t + 1e-9) continue;
654
+ if (!(a.ox || a.oy || b.ix || b.iy)) return null;
655
+ const between = (arr) => arr.some((k) => k.t > a.t + KEY_EPS && k.t < b.t - KEY_EPS);
656
+ if (between(clip.keyframes.x) || between(clip.keyframes.y)) return null;
657
+ return { a, b, A: { x: a.x, y: a.y, ix: a.x, iy: a.y, ox: a.x + a.ox, oy: a.y + a.oy }, B: { x: b.x, y: b.y, ix: b.x + b.ix, iy: b.y + b.iy, ox: b.x, oy: b.y } };
658
+ }
659
+ return null;
660
+ }
661
+ const segLUT = new Map(); // "x,y,…" → cumulative arc lengths of one cubic (64 steps)
662
+ function cubicLUT(A, B) {
663
+ const key = [A.x, A.y, A.ox, A.oy, B.ix, B.iy, B.x, B.y].join(",");
664
+ let L = segLUT.get(key);
665
+ if (!L) {
666
+ L = [0];
667
+ let prev = [A.x, A.y];
668
+ for (let j = 1; j <= 64; j++) { const q = cubic(A, B, j / 64); L.push(L[j - 1] + Math.hypot(q[0] - prev[0], q[1] - prev[1])); prev = q; }
669
+ if (segLUT.size > 512) segLUT.clear();
670
+ segLUT.set(key, L);
671
+ }
672
+ return L;
673
+ }
674
+ /** Curve parameter at arc-length fraction s of one cubic. */
675
+ function paramAtFraction(A, B, s) {
676
+ const L = cubicLUT(A, B), total = L[64];
677
+ if (total <= 1e-9) return s;
678
+ const d = clamp(s, 0, 1) * total;
679
+ let j = 1;
680
+ while (j < 64 && L[j] < d) j++;
681
+ return (j - 1 + (d - L[j - 1]) / Math.max(1e-12, L[j] - L[j - 1])) / 64;
682
+ }
683
+ /** {x, y} on the clip's motion path at clip-local t, or null where the channels apply. */
684
+ function pathPositionAt(clip, t) {
685
+ const seg = pathSegmentAt(clip, t);
686
+ if (!seg) return null;
687
+ const u = (t - seg.a.t) / Math.max(1e-9, seg.b.t - seg.a.t);
688
+ const s = (EASES[seg.b.ease] || EASES.linear)(clamp(u, 0, 1));
689
+ const [x, y] = cubic(seg.A, seg.B, paramAtFraction(seg.A, seg.B, s));
690
+ return { x, y };
691
+ }
692
+ /** x / y at clip-local t: the motion path where it curves, the keyframe channels elsewhere. */
693
+ function positionAt(clip, t, p = clip.props || {}) {
694
+ const q = pathPositionAt(clip, t);
695
+ if (q) return q;
696
+ return { x: channelAt(clip, "x", t, +p.x || 0), y: channelAt(clip, "y", t, +p.y || 0) };
697
+ }
698
+ /** Direction of travel at clip-local t in degrees (for auto-orient), or null when the clip never moves. */
699
+ function orientAt(clip, t) {
700
+ const kx = clip.keyframes && clip.keyframes.x, ky = clip.keyframes && clip.keyframes.y;
701
+ const ts = [...(kx || []), ...(ky || [])].map((k) => k.t);
702
+ if (ts.length < 2) return null;
703
+ const lo = Math.min(...ts), hi = Math.max(...ts);
704
+ for (const eps of [1 / 240, 1 / 60, 1 / 10]) {
705
+ const t0 = clamp(t - eps, lo, hi), t1 = clamp(t + eps, lo, hi);
706
+ if (t1 - t0 < 1e-9) continue;
707
+ const a = positionAt(clip, t0), b = positionAt(clip, t1);
708
+ if (Math.hypot(b.x - a.x, b.y - a.y) > 1e-6) return Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI;
709
+ }
710
+ return null;
711
+ }
712
+ /** Handles for every position key that make a smooth path through them (Catmull-Rom,
713
+ * a third of the way to the neighbours); the ends get one handle each. Returns the keyframes. */
714
+ function smoothPath(keyframes) {
715
+ const clip = { keyframes };
716
+ const P = posKeys(clip);
717
+ if (P.length < 2) return keyframes;
718
+ const out = { ...keyframes, x: keyframes.x.map((k) => ({ ...k })), y: keyframes.y.map((k) => ({ ...k })) };
719
+ const find = (arr, t) => arr.find((k) => Math.abs(k.t - t) < KEY_EPS);
720
+ P.forEach((k, i) => {
721
+ const prev = P[i - 1] || k, next = P[i + 1] || k;
722
+ const tx = (next.x - prev.x) / 6, ty = (next.y - prev.y) / 6;
723
+ const xk = find(out.x, k.t), yk = find(out.y, k.t);
724
+ const set = (key, prop, v) => { if (i === 0 && prop === "pin" || i === P.length - 1 && prop === "pout" || Math.abs(v) < 1e-3) delete key[prop]; else key[prop] = r3(v); };
725
+ set(xk, "pin", -tx); set(xk, "pout", tx); set(yk, "pin", -ty); set(yk, "pout", ty);
726
+ });
727
+ return out;
728
+ }
729
+ /** The keyframes with every spatial handle removed (a straight path). */
730
+ function straightPath(keyframes) {
731
+ const strip = (arr) => Array.isArray(arr) ? arr.map((k) => { const o = { ...k }; delete o.pin; delete o.pout; return o; }) : arr;
732
+ return { ...keyframes, x: strip(keyframes.x), y: strip(keyframes.y) };
733
+ }
734
+
735
+ /* ── Paths as tracks: arc-length sampling of an outline (text on a path) ── */
736
+ /** A sampler over an outline: { length, at(d) → {x, y, angle°} } with d in px along it (wraps when closed).
737
+ * offset: a parallel curve that far to the right of the direction of travel (below a left-to-right
738
+ * line), so a second line of text gets its own, shorter or longer, track. */
739
+ function pathSampler(A, closed, offset = 0) {
740
+ let P = flatten(A, closed, 48);
741
+ if (offset && P.length > 1) {
742
+ const n = P.length, seg = (i) => { const a = P[i], b = P[i + 1]; const l = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1; return [(b[0] - a[0]) / l, (b[1] - a[1]) / l]; };
743
+ P = P.map((q, i) => {
744
+ const t1 = i < n - 1 ? seg(i) : null, t0 = i > 0 ? seg(i - 1) : (closed && n > 2 ? seg(n - 2) : null);
745
+ let tx = (t0 ? t0[0] : 0) + (t1 ? t1[0] : 0), ty = (t0 ? t0[1] : 0) + (t1 ? t1[1] : 0);
746
+ const l = Math.hypot(tx, ty) || 1; tx /= l; ty /= l;
747
+ return [q[0] - ty * offset, q[1] + tx * offset];
748
+ });
749
+ }
750
+ const L = [0];
751
+ for (let i = 1; i < P.length; i++) L.push(L[i - 1] + Math.hypot(P[i][0] - P[i - 1][0], P[i][1] - P[i - 1][1]));
752
+ const length = L[L.length - 1];
753
+ const at = (d) => {
754
+ if (length <= 0) return { x: P[0] ? P[0][0] : 0, y: P[0] ? P[0][1] : 0, angle: 0 };
755
+ if (closed) d = ((d % length) + length) % length;
756
+ const outside = !closed && (d < 0 || d > length);
757
+ const dd = clamp(d, 0, length);
758
+ let i = 1;
759
+ while (i < L.length - 1 && L[i] < dd) i++;
760
+ const a = P[i - 1], b = P[i], u = (dd - L[i - 1]) / Math.max(1e-12, L[i] - L[i - 1]);
761
+ const angle = Math.atan2(b[1] - a[1], b[0] - a[0]);
762
+ let x = a[0] + (b[0] - a[0]) * u, y = a[1] + (b[1] - a[1]) * u;
763
+ if (outside) { const over = d < 0 ? d : d - length; x += Math.cos(angle) * over; y += Math.sin(angle) * over; } // runs on straight past the ends
764
+ return { x, y, angle: angle * 180 / Math.PI };
765
+ };
766
+ return { length, at, points: P };
767
+ }
768
+ const TEXT_PATH_TYPES = ["path", "line", "ellipse", "rect", "polygon", "star"];
769
+ /** Ready-made text paths (the editor's buttons and the agent's setTextPath names). */
770
+ const TEXT_PATH_PRESETS = {
771
+ line: { type: "line", points: [[-420, 0], [420, 0]] },
772
+ arc: { type: "path", closed: false, points: [[-420, 120], [0, -110, -240, 0, 240, 0], [420, 120]] },
773
+ circle: { type: "ellipse", w: 520, h: 520 },
774
+ wave: { type: "path", closed: false, points: [[-450, 0, 0, 0, 150, -160], [0, 0, -150, 160, 150, 160], [450, 0, -150, -160, 0, 0]] },
775
+ };
776
+ /** A text clip's props.textPath: one shape item (its outline is the baseline), validated. */
777
+ function normalizeTextPath(v, strict = false) {
778
+ if (v == null) return null;
779
+ const bad = (m) => { if (strict) throw new Error("textPath" + m); return null; };
780
+ if (!isObj(v)) return bad(" must be an object like {type:'path', points:[[-300, 0], [300, 0]], closed:false}");
781
+ const type = v.type == null ? "path" : v.type;
782
+ if (!TEXT_PATH_TYPES.includes(type)) return bad(`.type must be ${TEXT_PATH_TYPES.join(" | ")}`);
783
+ const src = { ...v, type };
784
+ for (const k of ["fill", "stroke", "strokeWidth", "lineCap", "lineJoin", "dash", "opacity", "trimStart", "trimEnd", "trimOffset", "name", "on"]) {
785
+ if (k in src) { if (strict && !["name", "on"].includes(k)) bad(`.${k}: a text path has no paint or trim (the text is what draws)`); delete src[k]; }
786
+ }
787
+ if (type === "path" && src.closed == null) src.closed = false;
788
+ const it = normalizeItem(src, strict, "textPath");
789
+ if (!it) return null;
790
+ for (const k of ["fill", "stroke", "strokeWidth", "lineCap", "lineJoin", "trimStart", "trimEnd", "trimOffset", "opacity"]) delete it[k];
791
+ return it;
792
+ }
793
+
794
+ return {
795
+ posKeys, hasPathHandles, pathSegmentAt, pathPositionAt, positionAt, orientAt, smoothPath, straightPath,
796
+ pathSampler, TEXT_PATH_TYPES, TEXT_PATH_PRESETS, normalizeTextPath,
797
+ TYPES, MAX_ITEMS, MAX_DEPTH, MAX_POINTS, CAPS, JOINS, KEYED, NUM_KEYED, COLOR_KEYED, RANGE, EASES, OWN,
798
+ normColor, lerpColor, defaultsFor, normalizeShapes, normalizeItem, mergeItem, itemAt, shapesAt, snapshotKey, shiftKeys,
799
+ IDENT, mul, apply, invert, trs, outline, segments, flatten, tracePath, trimRuns, traceTrim, composeTrim, itemMatrix,
800
+ draw, bounds, itemPaths, getItem, updateItem, itemFrame, countItems, describe, isClosed,
801
+ contentScale, layerMatrix, channelAt, transformAt, parentChain, parentMatrix, wouldCycle, reparentValues, anchorMoveValues,
802
+ };
803
+ });