@glissade/core 0.8.1 → 0.9.0-pre.1

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.
@@ -0,0 +1,1269 @@
1
+ //#region src/color.ts
2
+ const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
3
+ const RGB_RE = /^rgba?\(\s*([\d.]+)\s*[, ]\s*([\d.]+)\s*[, ]\s*([\d.]+)\s*(?:[,/]\s*([\d.]+%?)\s*)?\)$/i;
4
+ var ColorParseError = class extends Error {
5
+ constructor(input) {
6
+ super(`cannot parse color '${input}' (supported: #rgb[a], #rrggbb[aa], rgb()/rgba())`);
7
+ this.name = "ColorParseError";
8
+ }
9
+ };
10
+ function parseColor(input) {
11
+ const hex = HEX_RE.exec(input);
12
+ if (hex) {
13
+ let h = hex[1];
14
+ if (h.length <= 4) h = [...h].map((c) => c + c).join("");
15
+ const n = parseInt(h, 16);
16
+ if (h.length === 6) return {
17
+ r: n >> 16,
18
+ g: n >> 8 & 255,
19
+ b: n & 255,
20
+ a: 1
21
+ };
22
+ return {
23
+ r: n >>> 24 & 255,
24
+ g: n >>> 16 & 255,
25
+ b: n >>> 8 & 255,
26
+ a: (n & 255) / 255
27
+ };
28
+ }
29
+ const rgb = RGB_RE.exec(input);
30
+ if (rgb) {
31
+ const alphaRaw = rgb[4];
32
+ const a = alphaRaw === void 0 ? 1 : alphaRaw.endsWith("%") ? parseFloat(alphaRaw) / 100 : parseFloat(alphaRaw);
33
+ return {
34
+ r: parseFloat(rgb[1]),
35
+ g: parseFloat(rgb[2]),
36
+ b: parseFloat(rgb[3]),
37
+ a
38
+ };
39
+ }
40
+ throw new ColorParseError(input);
41
+ }
42
+ function formatColor(c) {
43
+ const clampByte = (v) => Math.min(255, Math.max(0, Math.round(v)));
44
+ const hex = (v) => clampByte(v).toString(16).padStart(2, "0");
45
+ const base = `#${hex(c.r)}${hex(c.g)}${hex(c.b)}`;
46
+ const a = Math.min(1, Math.max(0, c.a));
47
+ return a >= 1 ? base : `${base}${hex(a * 255)}`;
48
+ }
49
+ function srgbToLinear(c) {
50
+ const v = c / 255;
51
+ return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
52
+ }
53
+ function linearToSrgb(v) {
54
+ return (v <= .0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - .055) * 255;
55
+ }
56
+ function rgbaToOklab(c) {
57
+ const r = srgbToLinear(c.r);
58
+ const g = srgbToLinear(c.g);
59
+ const b = srgbToLinear(c.b);
60
+ const l = Math.cbrt(.4122214708 * r + .5363325363 * g + .0514459929 * b);
61
+ const m = Math.cbrt(.2119034982 * r + .6806995451 * g + .1073969566 * b);
62
+ const s = Math.cbrt(.0883024619 * r + .2817188376 * g + .6299787005 * b);
63
+ return {
64
+ L: .2104542553 * l + .793617785 * m - .0040720468 * s,
65
+ a: 1.9779984951 * l - 2.428592205 * m + .4505937099 * s,
66
+ b: .0259040371 * l + .7827717662 * m - .808675766 * s,
67
+ alpha: c.a
68
+ };
69
+ }
70
+ function oklabToRgba(c) {
71
+ const l = (c.L + .3963377774 * c.a + .2158037573 * c.b) ** 3;
72
+ const m = (c.L - .1055613458 * c.a - .0638541728 * c.b) ** 3;
73
+ const s = (c.L - .0894841775 * c.a - 1.291485548 * c.b) ** 3;
74
+ return {
75
+ r: linearToSrgb(4.0767416621 * l - 3.3077115913 * m + .2309699292 * s),
76
+ g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - .3413193965 * s),
77
+ b: linearToSrgb(-.0041960863 * l - .7034186147 * m + 1.707614701 * s),
78
+ a: c.alpha
79
+ };
80
+ }
81
+ /** Interpolate two CSS color strings in OKLab; t may extrapolate. */
82
+ function lerpColor(from, to, t) {
83
+ const a = rgbaToOklab(parseColor(from));
84
+ const b = rgbaToOklab(parseColor(to));
85
+ const mix = (x, y) => x + (y - x) * t;
86
+ return formatColor(oklabToRgba({
87
+ L: mix(a.L, b.L),
88
+ a: mix(a.a, b.a),
89
+ b: mix(a.b, b.b),
90
+ alpha: mix(a.alpha, b.alpha)
91
+ }));
92
+ }
93
+ //#endregion
94
+ //#region src/devWarning.ts
95
+ let devWarn = (msg) => {
96
+ globalThis.console?.warn(`[glissade] ${msg}`);
97
+ };
98
+ function setDevWarning(fn) {
99
+ devWarn = fn;
100
+ }
101
+ /** Internal: emit through the configurable channel. */
102
+ function emitDevWarning(message) {
103
+ devWarn(message);
104
+ }
105
+ //#endregion
106
+ //#region src/valueTypes.ts
107
+ /**
108
+ * Value-type registry with pluggable per-type interpolation (DESIGN.md §2.2).
109
+ * `extrapolates` declares whether a type's lerp accepts easedT outside [0,1]
110
+ * (spring overshoot); non-extrapolating types clamp.
111
+ */
112
+ const registry = /* @__PURE__ */ new Map();
113
+ function registerValueType(vt) {
114
+ registry.set(vt.id, vt);
115
+ }
116
+ var UnknownValueTypeError = class extends Error {
117
+ constructor(id) {
118
+ super(`unknown value type '${id}'; register it via registerValueType()`);
119
+ this.name = "UnknownValueTypeError";
120
+ }
121
+ };
122
+ function getValueType(id) {
123
+ const vt = registry.get(id);
124
+ if (!vt) throw new UnknownValueTypeError(id);
125
+ return vt;
126
+ }
127
+ const numberType = {
128
+ id: "number",
129
+ lerp: (a, b, t) => a + (b - a) * t,
130
+ extrapolates: true,
131
+ equals: Object.is,
132
+ add: (a, b) => a + b,
133
+ sub: (a, b) => a - b,
134
+ scale: (a, k) => a * k,
135
+ defaultHandoff: "spring"
136
+ };
137
+ const vec2Equals = (a, b) => a[0] === b[0] && a[1] === b[1];
138
+ const vec2Type = {
139
+ id: "vec2",
140
+ lerp: (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t],
141
+ extrapolates: true,
142
+ equals: vec2Equals,
143
+ add: (a, b) => [a[0] + b[0], a[1] + b[1]],
144
+ sub: (a, b) => [a[0] - b[0], a[1] - b[1]],
145
+ scale: (a, k) => [a[0] * k, a[1] * k],
146
+ defaultHandoff: "spring"
147
+ };
148
+ /** vec2 swept along a circular arc: polar lerp of radius + shortest-path angle (§2.2). */
149
+ const vec2ArcType = {
150
+ id: "vec2-arc",
151
+ lerp: (a, b, t) => {
152
+ const ra = Math.hypot(a[0], a[1]);
153
+ const rb = Math.hypot(b[0], b[1]);
154
+ const angA = Math.atan2(a[1], a[0]);
155
+ let dAng = Math.atan2(b[1], b[0]) - angA;
156
+ while (dAng > Math.PI) dAng -= 2 * Math.PI;
157
+ while (dAng < -Math.PI) dAng += 2 * Math.PI;
158
+ const r = ra + (rb - ra) * t;
159
+ const ang = angA + dAng * t;
160
+ return [r * Math.cos(ang), r * Math.sin(ang)];
161
+ },
162
+ extrapolates: true,
163
+ equals: vec2Equals,
164
+ defaultHandoff: "blend-from-frozen"
165
+ };
166
+ const colorType = {
167
+ id: "color",
168
+ lerp: lerpColor,
169
+ extrapolates: true,
170
+ equals: (a, b) => a === b,
171
+ defaultHandoff: "blend-from-frozen"
172
+ };
173
+ /** Discrete types: hold-only by construction (§2.2); lerp snaps at t=1. */
174
+ function discrete(id) {
175
+ return {
176
+ id,
177
+ lerp: (a, b, t) => t >= 1 ? b : a,
178
+ extrapolates: false,
179
+ equals: (a, b) => Object.is(a, b),
180
+ defaultHandoff: "cut"
181
+ };
182
+ }
183
+ const stringType = discrete("string");
184
+ const booleanType = discrete("boolean");
185
+ const lerpV = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
186
+ /** Topology must match for a morph (contour count, closed flags, vertex counts). */
187
+ function pathTopologyMatches(a, b) {
188
+ if (a.length !== b.length) return false;
189
+ for (let i = 0; i < a.length; i++) {
190
+ const ca = a[i];
191
+ const cb = b[i];
192
+ if (ca.closed !== cb.closed || ca.v.length !== cb.v.length) return false;
193
+ }
194
+ return true;
195
+ }
196
+ let warnedPathTopology = false;
197
+ /**
198
+ * Path morphing (§2.2): pairwise lerp of anchors and tangents — exactly how
199
+ * lottie-web morphs, so imported animations are pixel-faithful. Mismatched
200
+ * topology snaps (hold a, then b at t ≥ 1) with a one-time dev warning; the
201
+ * de Casteljau normalization fallback for arbitrary native morphs is tracked
202
+ * future work. Lerp-only: offsets are not well-defined under mismatched
203
+ * topology, so no add/sub/scale — handoffs blend from the frozen value.
204
+ */
205
+ const pathType = {
206
+ id: "path",
207
+ lerp: (a, b, t) => {
208
+ if (!pathTopologyMatches(a, b)) {
209
+ if (!warnedPathTopology) {
210
+ warnedPathTopology = true;
211
+ emitDevWarning("path lerp with mismatched topology (contour/vertex counts or closed flags differ): snapping instead of morphing — supply matched vertex counts (§2.2)");
212
+ }
213
+ return t >= 1 ? b : a;
214
+ }
215
+ return a.map((ca, i) => {
216
+ const cb = b[i];
217
+ return {
218
+ closed: ca.closed,
219
+ v: ca.v.map((p, j) => lerpV(p, cb.v[j], t)),
220
+ in: ca.in.map((p, j) => lerpV(p, cb.in[j], t)),
221
+ out: ca.out.map((p, j) => lerpV(p, cb.out[j], t))
222
+ };
223
+ });
224
+ },
225
+ extrapolates: false,
226
+ equals: (a, b) => {
227
+ if (a === b) return true;
228
+ if (!pathTopologyMatches(a, b)) return false;
229
+ const eq = (x, y) => x[0] === y[0] && x[1] === y[1];
230
+ return a.every((ca, i) => {
231
+ const cb = b[i];
232
+ return ca.v.every((p, j) => eq(p, cb.v[j])) && ca.in.every((p, j) => eq(p, cb.in[j])) && ca.out.every((p, j) => eq(p, cb.out[j]));
233
+ });
234
+ },
235
+ defaultHandoff: "blend-from-frozen"
236
+ };
237
+ var ValueTypeInferenceError = class extends Error {
238
+ constructor(value) {
239
+ super(`cannot infer a value type for ${JSON.stringify(value)}; register a custom type`);
240
+ this.name = "ValueTypeInferenceError";
241
+ }
242
+ };
243
+ const isContour = (c) => typeof c === "object" && c !== null && typeof c.closed === "boolean" && Array.isArray(c.v) && Array.isArray(c.in) && Array.isArray(c.out);
244
+ /** Infer a registered type id from a sample value (builder + bake authoring surfaces). */
245
+ function inferValueType(value) {
246
+ if (typeof value === "number") return "number";
247
+ if (typeof value === "boolean") return "boolean";
248
+ if (Array.isArray(value) && value.length === 2 && value.every((v) => typeof v === "number")) return "vec2";
249
+ if (Array.isArray(value) && value.length > 0 && value.every(isContour)) return "path";
250
+ if (typeof value === "string") try {
251
+ parseColor(value);
252
+ return "color";
253
+ } catch {
254
+ return "string";
255
+ }
256
+ throw new ValueTypeInferenceError(value);
257
+ }
258
+ registerValueType(numberType);
259
+ registerValueType(vec2Type);
260
+ registerValueType(vec2ArcType);
261
+ registerValueType(colorType);
262
+ registerValueType(stringType);
263
+ registerValueType(booleanType);
264
+ registerValueType(pathType);
265
+ //#endregion
266
+ //#region src/easing.ts
267
+ const c1 = 1.70158;
268
+ const c2 = c1 * 1.525;
269
+ const c3 = 2.70158;
270
+ const c4 = 2 * Math.PI / 3;
271
+ const c5 = 2 * Math.PI / 4.5;
272
+ function bounceOut(t) {
273
+ const n1 = 7.5625;
274
+ const d1 = 2.75;
275
+ if (t < 1 / d1) return n1 * t * t;
276
+ if (t < 2 / d1) return n1 * (t -= 1.5 / d1) * t + .75;
277
+ if (t < 2.5 / d1) return n1 * (t -= 2.25 / d1) * t + .9375;
278
+ return n1 * (t -= 2.625 / d1) * t + .984375;
279
+ }
280
+ const easings = {
281
+ linear: (t) => t,
282
+ easeInQuad: (t) => t * t,
283
+ easeOutQuad: (t) => 1 - (1 - t) * (1 - t),
284
+ easeInOutQuad: (t) => t < .5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2,
285
+ easeInCubic: (t) => t ** 3,
286
+ easeOutCubic: (t) => 1 - (1 - t) ** 3,
287
+ easeInOutCubic: (t) => t < .5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2,
288
+ easeInQuart: (t) => t ** 4,
289
+ easeOutQuart: (t) => 1 - (1 - t) ** 4,
290
+ easeInOutQuart: (t) => t < .5 ? 8 * t ** 4 : 1 - (-2 * t + 2) ** 4 / 2,
291
+ easeInQuint: (t) => t ** 5,
292
+ easeOutQuint: (t) => 1 - (1 - t) ** 5,
293
+ easeInOutQuint: (t) => t < .5 ? 16 * t ** 5 : 1 - (-2 * t + 2) ** 5 / 2,
294
+ easeInSine: (t) => 1 - Math.cos(t * Math.PI / 2),
295
+ easeOutSine: (t) => Math.sin(t * Math.PI / 2),
296
+ easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
297
+ easeInExpo: (t) => t === 0 ? 0 : 2 ** (10 * t - 10),
298
+ easeOutExpo: (t) => t === 1 ? 1 : 1 - 2 ** (-10 * t),
299
+ easeInOutExpo: (t) => t === 0 ? 0 : t === 1 ? 1 : t < .5 ? 2 ** (20 * t - 10) / 2 : (2 - 2 ** (-20 * t + 10)) / 2,
300
+ easeInCirc: (t) => 1 - Math.sqrt(1 - t * t),
301
+ easeOutCirc: (t) => Math.sqrt(1 - (t - 1) * (t - 1)),
302
+ easeInOutCirc: (t) => t < .5 ? (1 - Math.sqrt(1 - (2 * t) ** 2)) / 2 : (Math.sqrt(1 - (-2 * t + 2) ** 2) + 1) / 2,
303
+ easeInBack: (t) => c3 * t ** 3 - c1 * t * t,
304
+ easeOutBack: (t) => 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2,
305
+ easeInOutBack: (t) => t < .5 ? (2 * t) ** 2 * (3.5949095 * 2 * t - c2) / 2 : ((2 * t - 2) ** 2 * (3.5949095 * (t * 2 - 2) + c2) + 2) / 2,
306
+ easeInElastic: (t) => t === 0 ? 0 : t === 1 ? 1 : -(2 ** (10 * t - 10)) * Math.sin((t * 10 - 10.75) * c4),
307
+ easeOutElastic: (t) => t === 0 ? 0 : t === 1 ? 1 : 2 ** (-10 * t) * Math.sin((t * 10 - .75) * c4) + 1,
308
+ easeInOutElastic: (t) => t === 0 ? 0 : t === 1 ? 1 : t < .5 ? -(2 ** (20 * t - 10) * Math.sin((20 * t - 11.125) * c5)) / 2 : 2 ** (-20 * t + 10) * Math.sin((20 * t - 11.125) * c5) / 2 + 1,
309
+ easeInBounce: (t) => 1 - bounceOut(1 - t),
310
+ easeOutBounce: bounceOut,
311
+ easeInOutBounce: (t) => t < .5 ? (1 - bounceOut(1 - 2 * t)) / 2 : (1 + bounceOut(2 * t - 1)) / 2
312
+ };
313
+ function bounceOutD(t) {
314
+ const n1 = 7.5625;
315
+ const d1 = 2.75;
316
+ if (t < 1 / d1) return 2 * n1 * t;
317
+ if (t < 2 / d1) return 2 * n1 * (t - 1.5 / d1);
318
+ if (t < 2.5 / d1) return 2 * n1 * (t - 2.25 / d1);
319
+ return 2 * n1 * (t - 2.625 / d1);
320
+ }
321
+ const LN2 = Math.LN2;
322
+ /**
323
+ * Analytic derivatives d(u) of every named ease (§B.6) — closed-form, used
324
+ * for reading velocity off in-flight curves at interruption time. Property-
325
+ * tested against central differences at interior points.
326
+ */
327
+ const easingDerivatives = {
328
+ linear: () => 1,
329
+ easeInQuad: (t) => 2 * t,
330
+ easeOutQuad: (t) => 2 * (1 - t),
331
+ easeInOutQuad: (t) => t < .5 ? 4 * t : 4 * (1 - t),
332
+ easeInCubic: (t) => 3 * t ** 2,
333
+ easeOutCubic: (t) => 3 * (1 - t) ** 2,
334
+ easeInOutCubic: (t) => t < .5 ? 12 * t ** 2 : 12 * (1 - t) ** 2,
335
+ easeInQuart: (t) => 4 * t ** 3,
336
+ easeOutQuart: (t) => 4 * (1 - t) ** 3,
337
+ easeInOutQuart: (t) => t < .5 ? 32 * t ** 3 : 32 * (1 - t) ** 3,
338
+ easeInQuint: (t) => 5 * t ** 4,
339
+ easeOutQuint: (t) => 5 * (1 - t) ** 4,
340
+ easeInOutQuint: (t) => t < .5 ? 80 * t ** 4 : 80 * (1 - t) ** 4,
341
+ easeInSine: (t) => Math.PI / 2 * Math.sin(t * Math.PI / 2),
342
+ easeOutSine: (t) => Math.PI / 2 * Math.cos(t * Math.PI / 2),
343
+ easeInOutSine: (t) => Math.PI / 2 * Math.sin(Math.PI * t),
344
+ easeInExpo: (t) => t === 0 ? 0 : 10 * LN2 * 2 ** (10 * t - 10),
345
+ easeOutExpo: (t) => t === 1 ? 0 : 10 * LN2 * 2 ** (-10 * t),
346
+ easeInOutExpo: (t) => t === 0 || t === 1 ? 0 : t < .5 ? 10 * LN2 * 2 ** (20 * t - 10) : 10 * LN2 * 2 ** (-20 * t + 10),
347
+ easeInCirc: (t) => t / Math.sqrt(1 - t * t),
348
+ easeOutCirc: (t) => (1 - t) / Math.sqrt(1 - (t - 1) * (t - 1)),
349
+ easeInOutCirc: (t) => t < .5 ? 2 * t / Math.sqrt(1 - 4 * t * t) : (2 - 2 * t) / Math.sqrt(1 - (-2 * t + 2) ** 2),
350
+ easeInBack: (t) => 3 * c3 * t ** 2 - 2 * c1 * t,
351
+ easeOutBack: (t) => 3 * c3 * (t - 1) ** 2 + 2 * c1 * (t - 1),
352
+ easeInOutBack: (t) => t < .5 ? 12 * 3.5949095 * t ** 2 - 4 * c2 * t : 3 * 3.5949095 * (2 * t - 2) ** 2 + 2 * c2 * (2 * t - 2),
353
+ easeInElastic: (t) => {
354
+ if (t === 0 || t === 1) return 0;
355
+ const theta = (t * 10 - 10.75) * c4;
356
+ const amp = 2 ** (10 * t - 10);
357
+ return -(10 * LN2 * amp * Math.sin(theta) + amp * 10 * c4 * Math.cos(theta));
358
+ },
359
+ easeOutElastic: (t) => {
360
+ if (t === 0 || t === 1) return 0;
361
+ const phi = (t * 10 - .75) * c4;
362
+ const amp = 2 ** (-10 * t);
363
+ return -10 * LN2 * amp * Math.sin(phi) + amp * 10 * c4 * Math.cos(phi);
364
+ },
365
+ easeInOutElastic: (t) => {
366
+ if (t === 0 || t === 1) return 0;
367
+ const psi = (20 * t - 11.125) * c5;
368
+ if (t < .5) {
369
+ const amp = 2 ** (20 * t - 10);
370
+ return -(20 * LN2 * amp * Math.sin(psi) + amp * 20 * c5 * Math.cos(psi)) / 2;
371
+ }
372
+ const amp = 2 ** (-20 * t + 10);
373
+ return (-20 * LN2 * amp * Math.sin(psi) + amp * 20 * c5 * Math.cos(psi)) / 2;
374
+ },
375
+ easeInBounce: (t) => bounceOutD(1 - t),
376
+ easeOutBounce: bounceOutD,
377
+ easeInOutBounce: (t) => t < .5 ? bounceOutD(1 - 2 * t) : bounceOutD(2 * t - 1)
378
+ };
379
+ /** Default property-tween ease (Motion Canvas precedent). */
380
+ const DEFAULT_EASE = "easeInOutCubic";
381
+ function bezierKernel(p1x, p1y, p2x, p2y) {
382
+ const ax = 3 * p1x - 3 * p2x + 1;
383
+ const bx = 3 * p2x - 6 * p1x;
384
+ const cx = 3 * p1x;
385
+ const ay = 3 * p1y - 3 * p2y + 1;
386
+ const by = 3 * p2y - 6 * p1y;
387
+ const cy = 3 * p1y;
388
+ const sampleX = (u) => ((ax * u + bx) * u + cx) * u;
389
+ const sampleY = (u) => ((ay * u + by) * u + cy) * u;
390
+ const sampleDX = (u) => (3 * ax * u + 2 * bx) * u + cx;
391
+ const sampleDY = (u) => (3 * ay * u + 2 * by) * u + cy;
392
+ const solveU = (x) => {
393
+ let u = x;
394
+ for (let i = 0; i < 8; i++) {
395
+ const err = sampleX(u) - x;
396
+ if (Math.abs(err) < 1e-7) return u;
397
+ const d = sampleDX(u);
398
+ if (Math.abs(d) < 1e-6) break;
399
+ u -= err / d;
400
+ }
401
+ let lo = 0;
402
+ let hi = 1;
403
+ u = x;
404
+ while (hi - lo > 1e-7) {
405
+ if (sampleX(u) < x) lo = u;
406
+ else hi = u;
407
+ u = (lo + hi) / 2;
408
+ }
409
+ return u;
410
+ };
411
+ return {
412
+ sampleY,
413
+ sampleDX,
414
+ sampleDY,
415
+ solveU
416
+ };
417
+ }
418
+ /**
419
+ * CSS-style cubic bézier where x is time and y is progress. Newton's method
420
+ * with a bisection fallback for the flat-derivative regions.
421
+ */
422
+ function cubicBezier(p1x, p1y, p2x, p2y) {
423
+ const k = bezierKernel(p1x, p1y, p2x, p2y);
424
+ return (t) => {
425
+ if (t <= 0) return 0;
426
+ if (t >= 1) return 1;
427
+ return k.sampleY(k.solveU(t));
428
+ };
429
+ }
430
+ /** Analytic dy/dx of a cubic bézier ease: y'(s)/x'(s) at the solved parameter (§B.6). */
431
+ function cubicBezierDerivative(p1x, p1y, p2x, p2y) {
432
+ const k = bezierKernel(p1x, p1y, p2x, p2y);
433
+ return (t) => {
434
+ const u = k.solveU(Math.min(1, Math.max(0, t)));
435
+ const dx = k.sampleDX(u);
436
+ if (Math.abs(dx) < 1e-9) return 0;
437
+ return k.sampleDY(u) / dx;
438
+ };
439
+ }
440
+ var UnknownEasingError = class extends Error {
441
+ constructor(name) {
442
+ super(`unknown easing '${name}'; register it via easings or use cubicBezier/spring`);
443
+ this.name = "UnknownEasingError";
444
+ }
445
+ };
446
+ function namedEasing(name) {
447
+ const fn = easings[name];
448
+ if (!fn) throw new UnknownEasingError(name);
449
+ return fn;
450
+ }
451
+ //#endregion
452
+ //#region src/spring.ts
453
+ const DEFAULT_SETTLE_TOLERANCE = .005;
454
+ function params(cfg) {
455
+ const mass = cfg.mass ?? 1;
456
+ if (!(cfg.stiffness > 0) || !(cfg.damping > 0) || !(mass > 0)) throw new RangeError("spring stiffness, damping, and mass must all be > 0");
457
+ return {
458
+ w0: Math.sqrt(cfg.stiffness / mass),
459
+ zeta: cfg.damping / (2 * Math.sqrt(cfg.stiffness * mass))
460
+ };
461
+ }
462
+ /** Raw closed-form spring velocity at time t — d/dt of rawValue's three branches. */
463
+ function rawDerivative(cfg, t) {
464
+ if (t <= 0) return 0;
465
+ const { w0, zeta } = params(cfg);
466
+ if (Math.abs(zeta - 1) < 1e-9) return w0 * w0 * t * Math.exp(-w0 * t);
467
+ if (zeta < 1) {
468
+ const wd = w0 * Math.sqrt(1 - zeta * zeta);
469
+ return Math.exp(-zeta * w0 * t) * (w0 * w0 / wd) * Math.sin(wd * t);
470
+ }
471
+ const s = Math.sqrt(zeta * zeta - 1);
472
+ const r1 = -w0 * (zeta - s);
473
+ const r2 = -w0 * (zeta + s);
474
+ return r1 * r2 * (Math.exp(r1 * t) - Math.exp(r2 * t)) / (r1 - r2);
475
+ }
476
+ /** Raw closed-form spring position at time t (seconds). Approaches 1, may overshoot. */
477
+ function rawValue(cfg, t) {
478
+ if (t <= 0) return 0;
479
+ const { w0, zeta } = params(cfg);
480
+ if (Math.abs(zeta - 1) < 1e-9) return 1 - Math.exp(-w0 * t) * (1 + w0 * t);
481
+ if (zeta < 1) {
482
+ const wd = w0 * Math.sqrt(1 - zeta * zeta);
483
+ return 1 - Math.exp(-zeta * w0 * t) * (Math.cos(wd * t) + zeta * w0 / wd * Math.sin(wd * t));
484
+ }
485
+ const s = Math.sqrt(zeta * zeta - 1);
486
+ const r1 = -w0 * (zeta - s);
487
+ const r2 = -w0 * (zeta + s);
488
+ return 1 + (r2 * Math.exp(r1 * t) - r1 * Math.exp(r2 * t)) / (r1 - r2);
489
+ }
490
+ /**
491
+ * Settle duration: the earliest time after which |x - 1| stays within
492
+ * settleTolerance. Closed-form via the decay envelope for the underdamped
493
+ * case; bisection on the monotone tail otherwise. Deterministic.
494
+ */
495
+ function duration(cfg, opts) {
496
+ const tol = opts?.settleTolerance ?? DEFAULT_SETTLE_TOLERANCE;
497
+ const { w0, zeta } = params(cfg);
498
+ if (zeta < 1) {
499
+ const wd = w0 * Math.sqrt(1 - zeta * zeta);
500
+ const amp = Math.sqrt(1 + (zeta * w0 / wd) ** 2);
501
+ return Math.log(amp / tol) / (zeta * w0);
502
+ }
503
+ let hi = 1 / w0;
504
+ while (1 - rawValue(cfg, hi) > tol) hi *= 2;
505
+ let lo = 0;
506
+ for (let i = 0; i < 64 && hi - lo > 1e-9; i++) {
507
+ const mid = (lo + hi) / 2;
508
+ if (1 - rawValue(cfg, mid) > tol) lo = mid;
509
+ else hi = mid;
510
+ }
511
+ return hi;
512
+ }
513
+ /**
514
+ * Spring progress at local time t, affinely rescaled so value(duration) = 1
515
+ * exactly — the raw form only approaches 1, and an unscaled curve would snap
516
+ * at the key (§2.7 "endpoint continuity"). May exceed 1 (overshoot).
517
+ */
518
+ function value(cfg, t, opts) {
519
+ const d = duration(cfg, opts);
520
+ return rawValue(cfg, Math.min(t, d)) / rawValue(cfg, d);
521
+ }
522
+ function retarget(cfg, x0, v0) {
523
+ const { w0, zeta } = params(cfg);
524
+ let value0;
525
+ let velocity0;
526
+ let envelope;
527
+ if (Math.abs(zeta - 1) < 1e-9) {
528
+ const b = v0 + w0 * x0;
529
+ value0 = (tau) => tau <= 0 ? x0 : Math.exp(-w0 * tau) * (x0 + b * tau);
530
+ velocity0 = (tau) => tau <= 0 ? v0 : Math.exp(-w0 * tau) * (v0 - w0 * b * tau);
531
+ envelope = (tau) => Math.exp(-w0 * tau) * (Math.abs(x0) + Math.abs(b) * tau);
532
+ } else if (zeta < 1) {
533
+ const wd = w0 * Math.sqrt(1 - zeta * zeta);
534
+ const c2v = (v0 + zeta * w0 * x0) / wd;
535
+ const amp = Math.hypot(x0, c2v);
536
+ value0 = (tau) => tau <= 0 ? x0 : Math.exp(-zeta * w0 * tau) * (x0 * Math.cos(wd * tau) + c2v * Math.sin(wd * tau));
537
+ velocity0 = (tau) => tau <= 0 ? v0 : Math.exp(-zeta * w0 * tau) * (v0 * Math.cos(wd * tau) - (w0 * w0 * x0 + zeta * w0 * v0) / wd * Math.sin(wd * tau));
538
+ envelope = (tau) => Math.exp(-zeta * w0 * tau) * amp;
539
+ } else {
540
+ const s = Math.sqrt(zeta * zeta - 1);
541
+ const rp = w0 * (-zeta + s);
542
+ const rm = w0 * (-zeta - s);
543
+ const cp = (v0 - rm * x0) / (rp - rm);
544
+ const cm = (rp * x0 - v0) / (rp - rm);
545
+ value0 = (tau) => tau <= 0 ? x0 : cp * Math.exp(rp * tau) + cm * Math.exp(rm * tau);
546
+ velocity0 = (tau) => tau <= 0 ? v0 : cp * rp * Math.exp(rp * tau) + cm * rm * Math.exp(rm * tau);
547
+ envelope = (tau) => Math.abs(cp) * Math.exp(rp * tau) + Math.abs(cm) * Math.exp(rm * tau);
548
+ }
549
+ const settleTime = (tol) => {
550
+ const eps = tol ?? Math.abs(x0) * .005 + 1e-6;
551
+ if (envelope(0) <= eps && Math.abs(v0) < 1e-12) return 0;
552
+ let hi = 1 / w0;
553
+ let guard = 0;
554
+ while (envelope(hi) > eps && guard++ < 64) hi *= 2;
555
+ let lo = 0;
556
+ for (let i = 0; i < 64 && hi - lo > 1e-9; i++) {
557
+ const mid = (lo + hi) / 2;
558
+ if (Math.max(envelope(mid), envelope(mid * 1.05 + 1e-6)) > eps) lo = mid;
559
+ else hi = mid;
560
+ }
561
+ return hi;
562
+ };
563
+ return {
564
+ value: value0,
565
+ velocity: velocity0,
566
+ settleTime
567
+ };
568
+ }
569
+ const spring = Object.assign((cfg) => {
570
+ params(cfg);
571
+ return {
572
+ kind: "spring",
573
+ stiffness: cfg.stiffness,
574
+ damping: cfg.damping,
575
+ mass: cfg.mass ?? 1
576
+ };
577
+ }, {
578
+ duration,
579
+ value,
580
+ retarget
581
+ });
582
+ /**
583
+ * Named spring feels (react-spring conventions, mass 1) — a vocabulary instead
584
+ * of hand-tuned stiffness/damping. Use `spring(springPresets.wobbly)` or
585
+ * `springTo(t, a, b, springPresets.gentle)`.
586
+ */
587
+ const springPresets = {
588
+ default: {
589
+ stiffness: 170,
590
+ damping: 26
591
+ },
592
+ gentle: {
593
+ stiffness: 120,
594
+ damping: 14
595
+ },
596
+ wobbly: {
597
+ stiffness: 180,
598
+ damping: 12
599
+ },
600
+ stiff: {
601
+ stiffness: 210,
602
+ damping: 20
603
+ },
604
+ slow: {
605
+ stiffness: 280,
606
+ damping: 60
607
+ },
608
+ molasses: {
609
+ stiffness: 280,
610
+ damping: 120
611
+ }
612
+ };
613
+ /**
614
+ * The spring as a normalized easing over a segment whose length must equal
615
+ * spring.duration(cfg) (validated at the document layer, §2.7).
616
+ */
617
+ function springEasing(cfg) {
618
+ const d = duration(cfg);
619
+ return (p) => value(cfg, p * d);
620
+ }
621
+ /**
622
+ * Analytic d/dp of springEasing (§B.6): oscillator derivative × the affine
623
+ * rescale factor × duration (chain rule p → t = p·D). Flat past p=1,
624
+ * matching value()'s clamp (right-derivative convention).
625
+ */
626
+ function springEasingDerivative(cfg) {
627
+ const d = duration(cfg);
628
+ const scale = d / rawValue(cfg, d);
629
+ return (p) => p >= 1 ? 0 : rawDerivative(cfg, p * d) * scale;
630
+ }
631
+ //#endregion
632
+ //#region src/track.ts
633
+ /**
634
+ * Track & keyframe model (DESIGN.md §2.2) and sampling (§2.4): binary search
635
+ * per track with a last-segment cursor — sanctioned memoization, semantics
636
+ * identical to a cold search (property-tested).
637
+ */
638
+ var TrackValidationError = class extends Error {
639
+ constructor(target, message) {
640
+ super(`track '${target}': ${message}`);
641
+ this.name = "TrackValidationError";
642
+ }
643
+ };
644
+ function validateTrack(track) {
645
+ const vt = getValueType(track.type);
646
+ if (track.keys.length === 0) throw new TrackValidationError(track.target, "must have at least one key");
647
+ if (vt.defaultHandoff === "cut") {
648
+ for (const k of track.keys) if (k.interp !== "hold") k.interp = "hold";
649
+ }
650
+ for (let i = 1; i < track.keys.length; i++) {
651
+ const prev = track.keys[i - 1];
652
+ const cur = track.keys[i];
653
+ if (cur.t <= prev.t) throw new TrackValidationError(track.target, `keys must be strictly increasing in t (key[${i}] at t=${cur.t} after t=${prev.t})`);
654
+ }
655
+ if (!/^[^/]+\/.+$/.test(track.target)) throw new TrackValidationError(track.target, "target must be '<nodeId>/<prop.path>' (e.g. 'circle/opacity')");
656
+ }
657
+ function key(t, value, easeOrOpts) {
658
+ if (easeOrOpts === void 0) return {
659
+ t,
660
+ value
661
+ };
662
+ if (typeof easeOrOpts === "string" || "kind" in easeOrOpts) return {
663
+ t,
664
+ value,
665
+ ease: easeOrOpts
666
+ };
667
+ const opts = easeOrOpts;
668
+ const k = {
669
+ t,
670
+ value
671
+ };
672
+ if (opts.ease !== void 0) k.ease = opts.ease;
673
+ if (opts.interp !== void 0) k.interp = opts.interp;
674
+ if (opts.id !== void 0) k.id = opts.id;
675
+ if (opts.derived !== void 0) k.derived = opts.derived;
676
+ return k;
677
+ }
678
+ /**
679
+ * The settle-ON-the-beat helper: a spring key must sit at prev.t +
680
+ * spring.duration(cfg) (§2.7), so beat-anchored authoring otherwise means
681
+ * hand-computing the launch time. springTo returns the [launch, settle] key
682
+ * pair with the arithmetic done — spread it into a raw track():
683
+ * track('x/width', 'number', [...springTo(beats.start('drop'), 0, 320, cfg)])
684
+ */
685
+ function springTo(endT, from, to, cfg) {
686
+ const d = spring.duration(cfg);
687
+ if (endT - d < 0) throw new TrackValidationError("springTo", `this spring needs ${d.toFixed(3)}s to settle — endT must be ≥ its duration (got ${endT})`);
688
+ return [key(endT - d, from), key(endT, to, spring(cfg))];
689
+ }
690
+ /**
691
+ * Cascade a set of tracks by shifting each one's key times — the classic
692
+ * stagger for animating a list of nodes with a delay between them. `delay` is
693
+ * the per-index gap in seconds, or a function of the index for non-linear
694
+ * cascades. Pure: returns new tracks, leaving the inputs untouched.
695
+ *
696
+ * stagger(items.map((it, i) => track(`${it.id}/opacity`, 'number',
697
+ * [key(0, 0), key(0.3, 1, 'easeOutCubic')])), 0.08)
698
+ */
699
+ function stagger(tracks, delay) {
700
+ const at = typeof delay === "function" ? delay : (i) => i * delay;
701
+ return tracks.map((tr, i) => {
702
+ const d = at(i);
703
+ return {
704
+ ...tr,
705
+ keys: tr.keys.map((k) => ({
706
+ ...k,
707
+ t: k.t + d
708
+ }))
709
+ };
710
+ });
711
+ }
712
+ function track(target, type, keys, opts) {
713
+ const tr = {
714
+ target,
715
+ type,
716
+ keys
717
+ };
718
+ if (opts?.editable !== void 0) tr.editable = opts.editable;
719
+ validateTrack(tr);
720
+ return tr;
721
+ }
722
+ function resolveEase(spec) {
723
+ if (spec === void 0) return namedEasing("linear");
724
+ if (typeof spec === "string") return namedEasing(spec);
725
+ if (spec.kind === "cubicBezier") return cubicBezier(...spec.pts);
726
+ return springEasing(spec);
727
+ }
728
+ const warnedNumericDerivative = /* @__PURE__ */ new Set();
729
+ /**
730
+ * Analytic d(u) for an ease spec (§B.6). Custom-registered eases without a
731
+ * derivative fall back to a symmetric difference with a one-time dev warning.
732
+ */
733
+ function resolveEaseDerivative(spec) {
734
+ if (spec === void 0) return easingDerivatives["linear"];
735
+ if (typeof spec === "string") {
736
+ const d = easingDerivatives[spec];
737
+ if (d) return d;
738
+ const fn = namedEasing(spec);
739
+ if (!warnedNumericDerivative.has(spec)) {
740
+ warnedNumericDerivative.add(spec);
741
+ emitDevWarning(`easing '${spec}' has no registered derivative; velocity uses a numeric fallback — register one in easingDerivatives for exact interruption handoff`);
742
+ }
743
+ const h = 1 / 1024;
744
+ return (u) => (fn(Math.min(1, u + h)) - fn(Math.max(0, u - h))) / (Math.min(1, u + h) - Math.max(0, u - h));
745
+ }
746
+ if (spec.kind === "cubicBezier") return cubicBezierDerivative(...spec.pts);
747
+ return springEasingDerivative(spec);
748
+ }
749
+ const samplerStates = /* @__PURE__ */ new WeakMap();
750
+ function state(tr) {
751
+ let s = samplerStates.get(tr);
752
+ if (!s) {
753
+ s = {
754
+ cursor: 1,
755
+ easeCache: new Array(tr.keys.length)
756
+ };
757
+ samplerStates.set(tr, s);
758
+ }
759
+ return s;
760
+ }
761
+ function easeFor(tr, s, i) {
762
+ let fn = s.easeCache[i];
763
+ if (!fn) {
764
+ fn = resolveEase(tr.keys[i].ease);
765
+ s.easeCache[i] = fn;
766
+ }
767
+ return fn;
768
+ }
769
+ /**
770
+ * Find i such that keys[i-1].t <= t < keys[i].t, i.e. the index of the
771
+ * arrival key of the segment containing t. Returns 0 if t < first key,
772
+ * keys.length if t >= last key.
773
+ */
774
+ function findSegment(keys, t, hint) {
775
+ const n = keys.length;
776
+ for (let i = Math.max(1, hint - 1); i <= Math.min(n - 1, hint + 1); i++) if (keys[i - 1].t <= t && t < keys[i].t) return i;
777
+ if (t < keys[0].t) return 0;
778
+ if (t >= keys[n - 1].t) return n;
779
+ let lo = 1;
780
+ let hi = n - 1;
781
+ while (lo < hi) {
782
+ const mid = lo + hi >> 1;
783
+ if (keys[mid].t <= t) lo = mid + 1;
784
+ else hi = mid;
785
+ }
786
+ return lo;
787
+ }
788
+ /**
789
+ * Analytic track derivative at time t, in value-units per second of local
790
+ * track time (v2 addendum §B.3/§B.6 conventions, pinned):
791
+ * (a) at a key boundary, velocity is the RIGHT derivative;
792
+ * (b) hold segments and the clamped regions outside the keys have v = 0;
793
+ * (c) types without sub/scale operators return null (no kinetic velocity).
794
+ */
795
+ function velocityAt(tr, t) {
796
+ const vt = getValueType(tr.type);
797
+ if (!vt.sub || !vt.scale) return null;
798
+ const keys = tr.keys;
799
+ const n = keys.length;
800
+ const s = state(tr);
801
+ const i = findSegment(keys, t, s.cursor);
802
+ const zero = vt.scale(vt.sub(keys[0].value, keys[0].value), 0);
803
+ if (i === 0 || i >= n) return zero;
804
+ const arrival = keys[i];
805
+ if (arrival.interp === "hold") return zero;
806
+ const prev = keys[i - 1];
807
+ const segDur = arrival.t - prev.t;
808
+ const p = (t - prev.t) / segDur;
809
+ if (!vt.extrapolates) {
810
+ const eased = easeFor(tr, s, i)(p);
811
+ if (eased < 0 || eased > 1) return zero;
812
+ }
813
+ const d = resolveEaseDerivative(arrival.ease)(p);
814
+ return vt.scale(vt.sub(arrival.value, prev.value), d / segDur);
815
+ }
816
+ /** Pure sample of a track at time t (§2.4). */
817
+ function sampleTrack(tr, t) {
818
+ const keys = tr.keys;
819
+ const n = keys.length;
820
+ const s = state(tr);
821
+ const i = findSegment(keys, t, s.cursor);
822
+ if (i === 0) return keys[0].value;
823
+ if (i >= n) return keys[n - 1].value;
824
+ s.cursor = i;
825
+ const arrival = keys[i];
826
+ const prev = keys[i - 1];
827
+ if (arrival.interp === "hold") return prev.value;
828
+ const vt = getValueType(tr.type);
829
+ const p = (t - prev.t) / (arrival.t - prev.t);
830
+ let easedT = easeFor(tr, s, i)(p);
831
+ if (!vt.extrapolates && (easedT < 0 || easedT > 1)) {
832
+ if (!s.warnedClamp) {
833
+ s.warnedClamp = true;
834
+ emitDevWarning(`track '${tr.target}' (type '${tr.type}') clamped an out-of-range eased value — non-extrapolating types can't overshoot, so a spring/overshooting ease on this track is flattened`);
835
+ }
836
+ easedT = Math.min(1, Math.max(0, easedT));
837
+ }
838
+ return vt.lerp(prev.value, arrival.value, easedT);
839
+ }
840
+ //#endregion
841
+ //#region src/timeline.ts
842
+ /**
843
+ * The Timeline document (DESIGN.md §2.3) — the serializable animation source
844
+ * of truth — and its compiler: child flattening (add/sync), same-target
845
+ * coalescing (last-insertion-wins, §2.2), duration computation, validation.
846
+ */
847
+ function timeline(init) {
848
+ const doc = {
849
+ version: 1,
850
+ tracks: init.tracks ?? []
851
+ };
852
+ if (init.duration !== void 0) doc.duration = init.duration;
853
+ if (init.editableDuration !== void 0) doc.editableDuration = init.editableDuration;
854
+ if (init.fps !== void 0) doc.fps = init.fps;
855
+ if (init.posterTime !== void 0) doc.posterTime = init.posterTime;
856
+ if (init.labels !== void 0) doc.labels = init.labels;
857
+ if (init.markers !== void 0) doc.markers = init.markers;
858
+ if (init.children !== void 0) doc.children = init.children;
859
+ if (init.audio !== void 0) doc.audio = init.audio;
860
+ if (init.assets !== void 0) doc.assets = init.assets;
861
+ return doc;
862
+ }
863
+ var TimelineValidationError = class extends Error {
864
+ constructor(message) {
865
+ super(message);
866
+ this.name = "TimelineValidationError";
867
+ }
868
+ };
869
+ /** Spring key rule (§2.7): a spring-eased key's t must equal prev.t + spring.duration(cfg). */
870
+ function validateSpringKeys(tr) {
871
+ for (let i = 1; i < tr.keys.length; i++) {
872
+ const k = tr.keys[i];
873
+ if (k.ease && typeof k.ease === "object" && k.ease.kind === "spring") {
874
+ const expected = tr.keys[i - 1].t + spring.duration(k.ease);
875
+ if (Math.abs(k.t - expected) > 1e-6) throw new TimelineValidationError(`track '${tr.target}': spring-eased key at t=${k.t} must sit at prev.t + spring.duration (${expected.toFixed(6)}); the builder computes this automatically`);
876
+ }
877
+ }
878
+ }
879
+ /**
880
+ * Sample-indexed clip offset — the single A/V-sync source of truth (§5.3),
881
+ * `round(at * sampleRate)`. Both the CLI (FFmpeg) and the browser
882
+ * (OfflineAudioContext) mixers derive their delay from this, so A/V offsets
883
+ * agree across paths by construction rather than one rounding to milliseconds
884
+ * and the other to raw float seconds. Default rate is the canonical mix grid.
885
+ */
886
+ function audioOffsetSamples(at, sampleRate = 48e3) {
887
+ return Math.round(at * sampleRate);
888
+ }
889
+ function rebaseKeys(keys, at, timeScale) {
890
+ return keys.map((k) => ({
891
+ ...k,
892
+ t: at + k.t / timeScale
893
+ }));
894
+ }
895
+ function flatten(doc, at, timeScale, unit, out, counter) {
896
+ for (const tr of doc.tracks) {
897
+ validateTrack(tr);
898
+ validateSpringKeys(tr);
899
+ out.push({
900
+ track: {
901
+ ...tr,
902
+ keys: rebaseKeys(tr.keys, at, timeScale)
903
+ },
904
+ unit
905
+ });
906
+ }
907
+ for (const child of doc.children ?? []) {
908
+ const scale = child.mode === "sync" ? child.timeScale ?? 1 : 1;
909
+ if (child.mode === "add" && child.timeScale !== void 0) throw new TimelineValidationError("timeScale is only valid on mode:'sync' children (§2.3)");
910
+ if (scale <= 0) throw new TimelineValidationError("sync timeScale must be > 0");
911
+ const childUnit = child.mode === "sync" ? ++counter.n : unit;
912
+ flatten(child.timeline, at + child.at / timeScale, timeScale * scale, childUnit, out, counter);
913
+ }
914
+ }
915
+ /**
916
+ * Coalesce same-target tracks: later insertion wins where key ranges overlap
917
+ * (§2.2/§2.6 decided rule), with a dev warning. Earlier keys inside the later
918
+ * track's [first.t, last.t] span are dropped.
919
+ */
920
+ function coalesce(entries) {
921
+ const byTarget = /* @__PURE__ */ new Map();
922
+ for (const { track: tr, unit } of entries) {
923
+ const existing = byTarget.get(tr.target);
924
+ if (!existing) {
925
+ byTarget.set(tr.target, {
926
+ track: {
927
+ ...tr,
928
+ keys: [...tr.keys]
929
+ },
930
+ unit
931
+ });
932
+ continue;
933
+ }
934
+ if (existing.unit !== unit) throw new TimelineValidationError(`target '${tr.target}' is animated by a sync (opaque) child and another timeline unit; sync children must own disjoint targets (no last-writer-wins across the sync boundary, §2.3)`);
935
+ if (existing.track.type !== tr.type) throw new TimelineValidationError(`target '${tr.target}' has conflicting value types '${existing.track.type}' and '${tr.type}'`);
936
+ const start = tr.keys[0].t;
937
+ const end = tr.keys[tr.keys.length - 1].t;
938
+ const existingStart = existing.track.keys[0].t;
939
+ const existingEnd = existing.track.keys[existing.track.keys.length - 1].t;
940
+ const kept = existing.track.keys.filter((k) => k.t < start || k.t > end);
941
+ if (existingStart <= end && start <= existingEnd) emitDevWarning(`overlapping tracks for '${tr.target}' in [${start}, ${end}]: later insertion wins (${existing.track.keys.length - kept.length} earlier key(s) dropped)`);
942
+ existing.track.keys = [...kept, ...tr.keys].sort((a, b) => a.t - b.t);
943
+ }
944
+ const result = /* @__PURE__ */ new Map();
945
+ for (const [target, { track }] of byTarget) result.set(target, track);
946
+ return result;
947
+ }
948
+ /**
949
+ * Studio reader (§6.2 rule 4): is this timeline's duration opted into editor
950
+ * editing? Code-owned and read-only by default; `editableDuration()` flips it.
951
+ */
952
+ function isDurationEditable(doc) {
953
+ return doc.editableDuration === true;
954
+ }
955
+ function childExtent(child) {
956
+ const scale = child.mode === "sync" ? child.timeScale ?? 1 : 1;
957
+ return child.at + computeDuration(child.timeline) / scale;
958
+ }
959
+ function computeDuration(doc) {
960
+ if (doc.duration !== void 0) return doc.duration;
961
+ let max = 0;
962
+ for (const tr of doc.tracks) {
963
+ const last = tr.keys[tr.keys.length - 1];
964
+ if (last) max = Math.max(max, last.t);
965
+ }
966
+ for (const m of doc.markers ?? []) max = Math.max(max, m.t);
967
+ for (const child of doc.children ?? []) max = Math.max(max, childExtent(child));
968
+ return max;
969
+ }
970
+ function compileTimeline(doc) {
971
+ if (doc.version !== 1) throw new TimelineValidationError(`unsupported timeline document version ${String(doc.version)}`);
972
+ const flat = [];
973
+ flatten(doc, 0, 1, 0, flat, { n: 0 });
974
+ const tracks = coalesce(flat);
975
+ const labels = { ...doc.labels };
976
+ const markers = [...doc.markers ?? []];
977
+ const audio = [...doc.audio ?? []];
978
+ const visitChildren = (children, at, scale) => {
979
+ for (const child of children ?? []) {
980
+ const base = at + child.at / scale;
981
+ const childScale = scale * (child.mode === "sync" ? child.timeScale ?? 1 : 1);
982
+ for (const [name, t] of Object.entries(child.timeline.labels ?? {})) if (!(name in labels)) labels[name] = base + t / childScale;
983
+ for (const m of child.timeline.markers ?? []) markers.push({
984
+ ...m,
985
+ t: base + m.t / childScale
986
+ });
987
+ for (const clip of child.timeline.audio ?? []) audio.push({
988
+ ...clip,
989
+ at: base + clip.at / childScale,
990
+ ...childScale !== 1 ? { playbackRate: (clip.playbackRate ?? 1) * childScale } : {}
991
+ });
992
+ visitChildren(child.timeline.children, base, childScale);
993
+ }
994
+ };
995
+ visitChildren(doc.children, 0, 1);
996
+ markers.sort((a, b) => a.t - b.t);
997
+ audio.sort((a, b) => a.at - b.at);
998
+ return {
999
+ duration: computeDuration(doc),
1000
+ labels,
1001
+ markers,
1002
+ tracks,
1003
+ audio
1004
+ };
1005
+ }
1006
+ //#endregion
1007
+ //#region src/targetRef.ts
1008
+ /**
1009
+ * Target references (DESIGN.md §2.6): the builder accepts either a canonical
1010
+ * target string ('circle/opacity') or a property signal that carries its own
1011
+ * path — attached by the scene package at node construction via TARGET_PATH.
1012
+ */
1013
+ const TARGET_PATH = Symbol.for("glissade.targetPath");
1014
+ var UnresolvableTargetError = class extends Error {
1015
+ constructor(message) {
1016
+ super(message ?? "tween target is not addressable: pass a target string (\"node/prop\") or a property signal of a node that has an explicit id (§3.1 — anonymous nodes cannot be track targets)");
1017
+ this.name = "UnresolvableTargetError";
1018
+ }
1019
+ };
1020
+ /** The node-id portion of a canonical `nodeId/prop.path` target. */
1021
+ function targetNodeId(target) {
1022
+ const slash = target.indexOf("/");
1023
+ return slash < 0 ? target : target.slice(0, slash);
1024
+ }
1025
+ /**
1026
+ * The single editable-host rule (§6.4 sub-decision, the 0.9 locked predicate):
1027
+ * only a node with an EXPLICIT, non-structural id can host an editable or
1028
+ * editor-created track. Structural fallback ids (`~Type.ordinal`, §6.5) are
1029
+ * inspection-only and reorder-fragile, so they are never editable nor valid
1030
+ * track targets. Lives here (the addressing module) so the builder guard, the
1031
+ * scene, and the studio host all share ONE definition.
1032
+ */
1033
+ function isEditableNodeId(id) {
1034
+ return typeof id === "string" && id.length > 0 && id !== "__root" && !id.startsWith("~");
1035
+ }
1036
+ function resolveTweenTarget(target) {
1037
+ const path = typeof target === "string" ? target : target[TARGET_PATH];
1038
+ if (typeof path !== "string") throw new UnresolvableTargetError();
1039
+ if (targetNodeId(path).startsWith("~")) throw new UnresolvableTargetError(`'${path}': structural ids (~Type.ordinal) are inspection-only and cannot be track targets (§6.5) — give the node an explicit id`);
1040
+ return path;
1041
+ }
1042
+ //#endregion
1043
+ //#region src/sidecar.ts
1044
+ /**
1045
+ * The editor sidecar (DESIGN.md §6.2): code declares scene structure and
1046
+ * programmatic tracks; the studio owns keyframe data persisted next to the
1047
+ * scene module and merged at track granularity. Versioned independently of the
1048
+ * API (§7.4) — breaking it orphans users' files, so v1 documents migrate.
1049
+ *
1050
+ * v2 namespaces edits by timeline id ('main' for the linear timeline; v2
1051
+ * machines add more), keys tracks by canonical target, records the code
1052
+ * baseline hash for drift detection, parks drifted tracks as `orphans`, and
1053
+ * gives keys stable `k<N>` ids.
1054
+ */
1055
+ const MAIN = "main";
1056
+ var SidecarVersionError = class extends Error {
1057
+ constructor(version) {
1058
+ super(`unsupported sidecar version ${String(version)}; this build reads sidecarVersion 1 or 2`);
1059
+ this.name = "SidecarVersionError";
1060
+ }
1061
+ };
1062
+ function emptySidecar() {
1063
+ return {
1064
+ sidecarVersion: 2,
1065
+ timelines: { [MAIN]: { tracks: {} } }
1066
+ };
1067
+ }
1068
+ /** Lift a v1 (or already-v2) document to the v2 shape. Throws on unknown versions. */
1069
+ function migrateSidecar(doc) {
1070
+ if (!doc) return null;
1071
+ if (doc.sidecarVersion === 2) return doc;
1072
+ if (doc.sidecarVersion === 1) {
1073
+ const tracks = {};
1074
+ for (const t of doc.tracks) tracks[t.target] = {
1075
+ type: t.type,
1076
+ baseHash: null,
1077
+ keys: t.keys
1078
+ };
1079
+ const main = { tracks };
1080
+ if (doc.labels) main.labels = doc.labels;
1081
+ return {
1082
+ sidecarVersion: 2,
1083
+ timelines: { [MAIN]: main }
1084
+ };
1085
+ }
1086
+ throw new SidecarVersionError(doc.sidecarVersion);
1087
+ }
1088
+ /** Stable hash of a code track's keys — the baseline an editor branch records. */
1089
+ function hashKeys(keys) {
1090
+ const s = JSON.stringify(keys.map((k) => [
1091
+ k.t,
1092
+ k.value,
1093
+ k.ease ?? null,
1094
+ k.interp ?? null
1095
+ ]));
1096
+ let h = 2166136261;
1097
+ for (let i = 0; i < s.length; i++) {
1098
+ h ^= s.charCodeAt(i);
1099
+ h = Math.imul(h, 16777619);
1100
+ }
1101
+ return (h >>> 0).toString(16);
1102
+ }
1103
+ /** Assign stable monotonic `k<N>` ids to keys lacking one; existing ids preserved. */
1104
+ function assignKeyIds(keys) {
1105
+ let max = -1;
1106
+ for (const k of keys) {
1107
+ const m = k.id ? /^k(\d+)$/.exec(k.id) : null;
1108
+ if (m) max = Math.max(max, Number(m[1]));
1109
+ }
1110
+ return keys.map((k) => k.id ? { ...k } : {
1111
+ ...k,
1112
+ id: `k${++max}`
1113
+ });
1114
+ }
1115
+ /**
1116
+ * Set one editable track's keys in the sidecar (§6.2) — the studio write path.
1117
+ * `codeBaselineKeys` is the code track this branches from (null = editor-created
1118
+ * with no baseline). Keys get stable `k<N>` ids. Returns a new document.
1119
+ */
1120
+ function setSidecarTrack(doc, timelineId, target, type, keys, codeBaselineKeys) {
1121
+ if (!isEditableNodeId(targetNodeId(target))) throw new TrackValidationError(target, "structural/un-id'd nodes cannot host editor tracks (§6.5) — only nodes with an explicit id");
1122
+ const tl = doc.timelines[timelineId] ?? { tracks: {} };
1123
+ const entry = {
1124
+ type,
1125
+ baseHash: codeBaselineKeys ? hashKeys(codeBaselineKeys) : null,
1126
+ keys: assignKeyIds(keys)
1127
+ };
1128
+ return {
1129
+ ...doc,
1130
+ timelines: {
1131
+ ...doc.timelines,
1132
+ [timelineId]: {
1133
+ ...tl,
1134
+ tracks: {
1135
+ ...tl.tracks,
1136
+ [target]: entry
1137
+ }
1138
+ }
1139
+ }
1140
+ };
1141
+ }
1142
+ /**
1143
+ * Remove one editor-owned track from the sidecar (§6.2 rule 7 write-back): the
1144
+ * "extract edits to code" affordance deletes the sidecar entry after copying its
1145
+ * `key(...)` source to the clipboard. Source is never mutated — the user pastes
1146
+ * the generated calls themselves. A missing entry is a no-op (returns the input
1147
+ * unchanged); the document is never mutated in place.
1148
+ */
1149
+ function deleteSidecarTrack(doc, timelineId, target) {
1150
+ const tl = doc.timelines[timelineId];
1151
+ if (!tl || !(target in tl.tracks)) return doc;
1152
+ const { [target]: _removed, ...rest } = tl.tracks;
1153
+ return {
1154
+ ...doc,
1155
+ timelines: {
1156
+ ...doc.timelines,
1157
+ [timelineId]: {
1158
+ ...tl,
1159
+ tracks: rest
1160
+ }
1161
+ }
1162
+ };
1163
+ }
1164
+ /**
1165
+ * Re-resolve `derived:true` leading keys against the merged track (§2.6): a
1166
+ * derived from-key duplicates the preceding key's held value, so an upstream
1167
+ * edit must flow into it or the segment pops at its start. Build-time derived
1168
+ * keys are already correct; this fixes the ones an edit moved beneath.
1169
+ */
1170
+ function reresolveDerived(keys) {
1171
+ if (!keys.some((k) => k.derived)) return keys;
1172
+ return keys.map((k, i) => k.derived && i > 0 ? {
1173
+ ...k,
1174
+ value: keys[i - 1].value
1175
+ } : k);
1176
+ }
1177
+ /**
1178
+ * Merge with the full §6.2 report: the bindable Timeline, the list of targets
1179
+ * whose code baseline drifted, and the orphaned tracks (type-changed, or whose
1180
+ * code track vanished). Orphans are NEVER merged, so a drifted edit can't make
1181
+ * the whole overlay fail to bind — the good edits survive. Inputs unmutated.
1182
+ */
1183
+ function mergeSidecarDetailed(code, sidecar) {
1184
+ const doc = migrateSidecar(sidecar);
1185
+ if (!doc) return {
1186
+ timeline: code,
1187
+ drift: [],
1188
+ orphans: {}
1189
+ };
1190
+ const main = doc.timelines[MAIN] ?? { tracks: {} };
1191
+ const overlay = new Map(Object.entries(main.tracks));
1192
+ const drift = [];
1193
+ const orphans = { ...main.orphans ?? {} };
1194
+ const tracks = code.tracks.map((t) => {
1195
+ const entry = overlay.get(t.target);
1196
+ if (!entry) return t;
1197
+ overlay.delete(t.target);
1198
+ if (entry.type !== t.type) {
1199
+ orphans[t.target] = {
1200
+ type: entry.type,
1201
+ keys: entry.keys,
1202
+ reason: "type-changed"
1203
+ };
1204
+ return t;
1205
+ }
1206
+ if (entry.baseHash !== null && entry.baseHash !== hashKeys(t.keys)) drift.push(t.target);
1207
+ return {
1208
+ ...t,
1209
+ keys: reresolveDerived(entry.keys.map((k) => ({ ...k }))),
1210
+ editable: true
1211
+ };
1212
+ });
1213
+ for (const [target, entry] of overlay) if (entry.baseHash !== null) orphans[target] = {
1214
+ type: entry.type,
1215
+ keys: entry.keys,
1216
+ reason: "prop-missing"
1217
+ };
1218
+ else tracks.push({
1219
+ target,
1220
+ type: entry.type,
1221
+ keys: reresolveDerived(entry.keys.map((k) => ({ ...k }))),
1222
+ editable: true
1223
+ });
1224
+ const merged = {
1225
+ ...code,
1226
+ tracks
1227
+ };
1228
+ if (main.labels && Object.keys(main.labels).length > 0) {
1229
+ const codeLabels = code.labels ?? {};
1230
+ const shadowed = Object.keys(main.labels).filter((n) => n in codeLabels);
1231
+ if (shadowed.length) emitDevWarning(`sidecar label(s) ${shadowed.join(", ")} collide with code labels; code wins (§6.2)`);
1232
+ merged.labels = {
1233
+ ...main.labels,
1234
+ ...codeLabels
1235
+ };
1236
+ }
1237
+ if (drift.length) emitDevWarning(`sidecar: code baseline changed beneath edits on ${drift.join(", ")} (§6.2)`);
1238
+ return {
1239
+ timeline: merged,
1240
+ drift,
1241
+ orphans
1242
+ };
1243
+ }
1244
+ /** Merge the sidecar overlay onto the code baseline → a bindable Timeline (§6.2). */
1245
+ function mergeSidecar(code, sidecar) {
1246
+ return mergeSidecarDetailed(code, sidecar).timeline;
1247
+ }
1248
+ /**
1249
+ * Editor-edit normalization (§2.7 invariant): a spring-eased key's t is
1250
+ * intrinsic — prev.t + spring.duration(cfg) — so after any retime, sort and
1251
+ * re-pin spring keys to their predecessors. Dragging a spring key itself
1252
+ * therefore snaps back; retiming its predecessor carries it along. Returns a
1253
+ * new array. Colliding keys are NUDGED apart (+1ms), never deleted — an editor
1254
+ * must not silently destroy keyframe data on an exact-t collision.
1255
+ */
1256
+ function normalizeEditedKeys(keys) {
1257
+ const out = keys.map((k) => ({ ...k })).sort((a, b) => a.t - b.t);
1258
+ for (let pass = 0; pass < 2; pass++) {
1259
+ for (let i = 1; i < out.length; i++) {
1260
+ const ease = out[i].ease;
1261
+ if (ease && typeof ease === "object" && ease.kind === "spring") out[i].t = out[i - 1].t + spring.duration(ease);
1262
+ }
1263
+ out.sort((a, b) => a.t - b.t);
1264
+ }
1265
+ for (let i = 1; i < out.length; i++) if (out[i].t <= out[i - 1].t) out[i].t = out[i - 1].t + .001;
1266
+ return out;
1267
+ }
1268
+ //#endregion
1269
+ export { vec2Equals as $, velocityAt as A, easings as B, resolveEase as C, stagger as D, springTo as E, DEFAULT_EASE as F, colorType as G, UnknownValueTypeError as H, UnknownEasingError as I, numberType as J, getValueType as K, cubicBezier as L, springEasing as M, springEasingDerivative as N, track as O, springPresets as P, vec2ArcType as Q, cubicBezierDerivative as R, key as S, sampleTrack as T, ValueTypeInferenceError as U, namedEasing as V, booleanType as W, registerValueType as X, pathType as Y, stringType as Z, audioOffsetSamples as _, hashKeys as a, lerpColor as at, timeline as b, migrateSidecar as c, rgbaToOklab as ct, TARGET_PATH as d, vec2Type as et, UnresolvableTargetError as f, TimelineValidationError as g, targetNodeId as h, emptySidecar as i, formatColor as it, spring as j, validateTrack as k, normalizeEditedKeys as l, resolveTweenTarget as m, assignKeyIds as n, setDevWarning as nt, mergeSidecar as o, oklabToRgba as ot, isEditableNodeId as p, inferValueType as q, deleteSidecarTrack as r, ColorParseError as rt, mergeSidecarDetailed as s, parseColor as st, SidecarVersionError as t, emitDevWarning as tt, setSidecarTrack as u, compileTimeline as v, resolveEaseDerivative as w, TrackValidationError as x, isDurationEditable as y, easingDerivatives as z };