@glissade/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1331 @@
1
+ //#region src/signal.ts
2
+ let activeConsumer = null;
3
+ let readPhaseDepth = 0;
4
+ var WriteDuringEvaluationError = class extends Error {
5
+ constructor() {
6
+ super("signal.set() during evaluation: the read phase is pure (DESIGN.md §2.1). Drivers write the playhead before evaluation begins; stateful animation belongs in bake().");
7
+ this.name = "WriteDuringEvaluationError";
8
+ }
9
+ };
10
+ var CircularDependencyError = class extends Error {
11
+ constructor() {
12
+ super("circular signal dependency detected");
13
+ this.name = "CircularDependencyError";
14
+ }
15
+ };
16
+ /** Begin the pure read phase; any signal write until {@link endReadPhase} throws. */
17
+ function beginReadPhase() {
18
+ readPhaseDepth++;
19
+ }
20
+ function endReadPhase() {
21
+ if (readPhaseDepth === 0) throw new Error("endReadPhase() without matching beginReadPhase()");
22
+ readPhaseDepth--;
23
+ }
24
+ function inReadPhase() {
25
+ return readPhaseDepth > 0;
26
+ }
27
+ var SignalNode = class {
28
+ value;
29
+ /** Compute function; null for plain writable sources. Binding installs one. */
30
+ fn;
31
+ equals;
32
+ version = 0;
33
+ state;
34
+ computing = false;
35
+ deps = [];
36
+ depVersions = [];
37
+ dependents = /* @__PURE__ */ new Set();
38
+ subscribers = /* @__PURE__ */ new Set();
39
+ constructor(init) {
40
+ this.fn = init.fn ?? null;
41
+ this.value = init.value;
42
+ this.equals = init.equals ?? Object.is;
43
+ this.state = this.fn ? 2 : 0;
44
+ }
45
+ get() {
46
+ if (activeConsumer) activeConsumer.addDep(this);
47
+ this.updateIfNecessary();
48
+ return this.value;
49
+ }
50
+ peek() {
51
+ this.updateIfNecessary();
52
+ return this.value;
53
+ }
54
+ set(next) {
55
+ if (readPhaseDepth > 0) throw new WriteDuringEvaluationError();
56
+ if (this.fn) this.detachFn();
57
+ this.writeValue(next);
58
+ }
59
+ /**
60
+ * Sanctioned entry write (the playhead at evaluate() entry, DESIGN.md §2.5).
61
+ * Identical to set() but exempt from the phase guard; not part of the public
62
+ * signal surface.
63
+ */
64
+ forceSet(next) {
65
+ if (this.fn) this.detachFn();
66
+ this.writeValue(next);
67
+ }
68
+ /** Rewire this signal's source to a computation (timeline binding, §2.4). */
69
+ bindSource(fn) {
70
+ if (readPhaseDepth > 0) throw new WriteDuringEvaluationError();
71
+ this.fn = fn;
72
+ this.state = 2;
73
+ this.invalidateDependents(2);
74
+ }
75
+ /** Remove a bound source, freezing the signal at its last value. */
76
+ unbindSource() {
77
+ if (readPhaseDepth > 0) throw new WriteDuringEvaluationError();
78
+ if (!this.fn) return;
79
+ this.updateIfNecessary();
80
+ this.detachFn();
81
+ }
82
+ get isBound() {
83
+ return this.fn !== null;
84
+ }
85
+ subscribe(cb) {
86
+ this.subscribers.add(cb);
87
+ return () => this.subscribers.delete(cb);
88
+ }
89
+ addDep(dep) {
90
+ if (this.deps[this.deps.length - 1] !== dep) {
91
+ this.deps.push(dep);
92
+ this.depVersions.push(-1);
93
+ dep.dependents.add(this);
94
+ }
95
+ }
96
+ detachFn() {
97
+ this.fn = null;
98
+ this.clearDeps();
99
+ this.state = 0;
100
+ }
101
+ writeValue(next) {
102
+ if (this.version > 0 || this.value !== void 0) {
103
+ if (this.equals(this.value, next)) return;
104
+ }
105
+ this.value = next;
106
+ this.version++;
107
+ this.invalidateDependents(2);
108
+ }
109
+ invalidateDependents(level) {
110
+ for (const d of [...this.dependents]) d.markStale(level);
111
+ if (this.subscribers.size > 0) for (const cb of [...this.subscribers]) cb();
112
+ }
113
+ markStale(level) {
114
+ if (this.state >= level) return;
115
+ const wasClean = this.state === 0;
116
+ this.state = level;
117
+ if (wasClean) this.invalidateDependents(1);
118
+ }
119
+ updateIfNecessary() {
120
+ if (this.fn === null || this.state === 0) return;
121
+ if (this.computing) throw new CircularDependencyError();
122
+ if (this.state === 1) {
123
+ this.state = 0;
124
+ for (let i = 0; i < this.deps.length; i++) {
125
+ const dep = this.deps[i];
126
+ dep.updateIfNecessary();
127
+ if (dep.version !== this.depVersions[i]) {
128
+ this.state = 2;
129
+ break;
130
+ }
131
+ }
132
+ }
133
+ if (this.state === 2) this.recompute();
134
+ this.state = 0;
135
+ }
136
+ recompute() {
137
+ this.clearDeps();
138
+ const prevConsumer = activeConsumer;
139
+ activeConsumer = this;
140
+ this.computing = true;
141
+ let next;
142
+ try {
143
+ next = this.fn();
144
+ } finally {
145
+ this.computing = false;
146
+ activeConsumer = prevConsumer;
147
+ }
148
+ for (let i = 0; i < this.deps.length; i++) this.depVersions[i] = this.deps[i].version;
149
+ if (!(this.version > 0 || this.value !== void 0) || !this.equals(this.value, next)) {
150
+ this.value = next;
151
+ this.version++;
152
+ }
153
+ }
154
+ clearDeps() {
155
+ for (const dep of this.deps) dep.dependents.delete(this);
156
+ this.deps = [];
157
+ this.depVersions = [];
158
+ }
159
+ };
160
+ function makeCallable(node, extra) {
161
+ const sig = (() => node.get());
162
+ sig["peek"] = () => node.peek();
163
+ sig["subscribe"] = (cb) => node.subscribe(cb);
164
+ extra(sig);
165
+ return sig;
166
+ }
167
+ function signal(initial, options) {
168
+ const node = new SignalNode({
169
+ value: initial,
170
+ equals: options?.equals
171
+ });
172
+ return makeCallable(node, (sig) => {
173
+ sig["set"] = (v) => node.set(v);
174
+ sig["forceSet"] = (v) => node.forceSet(v);
175
+ sig["bindSource"] = (fn) => node.bindSource(fn);
176
+ sig["unbindSource"] = () => node.unbindSource();
177
+ Object.defineProperty(sig, "isBound", { get: () => node.isBound });
178
+ });
179
+ }
180
+ function computed(fn, options) {
181
+ return makeCallable(new SignalNode({
182
+ fn,
183
+ equals: options?.equals
184
+ }), () => {});
185
+ }
186
+ /** Run `fn` without registering dependencies on the active consumer. */
187
+ function untracked(fn) {
188
+ const prev = activeConsumer;
189
+ activeConsumer = null;
190
+ try {
191
+ return fn();
192
+ } finally {
193
+ activeConsumer = prev;
194
+ }
195
+ }
196
+ //#endregion
197
+ //#region src/color.ts
198
+ const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
199
+ const RGB_RE = /^rgba?\(\s*([\d.]+)\s*[, ]\s*([\d.]+)\s*[, ]\s*([\d.]+)\s*(?:[,/]\s*([\d.]+%?)\s*)?\)$/i;
200
+ var ColorParseError = class extends Error {
201
+ constructor(input) {
202
+ super(`cannot parse color '${input}' (supported: #rgb[a], #rrggbb[aa], rgb()/rgba())`);
203
+ this.name = "ColorParseError";
204
+ }
205
+ };
206
+ function parseColor(input) {
207
+ const hex = HEX_RE.exec(input);
208
+ if (hex) {
209
+ let h = hex[1];
210
+ if (h.length <= 4) h = [...h].map((c) => c + c).join("");
211
+ const n = parseInt(h, 16);
212
+ if (h.length === 6) return {
213
+ r: n >> 16,
214
+ g: n >> 8 & 255,
215
+ b: n & 255,
216
+ a: 1
217
+ };
218
+ return {
219
+ r: n >>> 24 & 255,
220
+ g: n >>> 16 & 255,
221
+ b: n >>> 8 & 255,
222
+ a: (n & 255) / 255
223
+ };
224
+ }
225
+ const rgb = RGB_RE.exec(input);
226
+ if (rgb) {
227
+ const alphaRaw = rgb[4];
228
+ const a = alphaRaw === void 0 ? 1 : alphaRaw.endsWith("%") ? parseFloat(alphaRaw) / 100 : parseFloat(alphaRaw);
229
+ return {
230
+ r: parseFloat(rgb[1]),
231
+ g: parseFloat(rgb[2]),
232
+ b: parseFloat(rgb[3]),
233
+ a
234
+ };
235
+ }
236
+ throw new ColorParseError(input);
237
+ }
238
+ function formatColor(c) {
239
+ const clampByte = (v) => Math.min(255, Math.max(0, Math.round(v)));
240
+ const hex = (v) => clampByte(v).toString(16).padStart(2, "0");
241
+ const base = `#${hex(c.r)}${hex(c.g)}${hex(c.b)}`;
242
+ const a = Math.min(1, Math.max(0, c.a));
243
+ return a >= 1 ? base : `${base}${hex(a * 255)}`;
244
+ }
245
+ function srgbToLinear(c) {
246
+ const v = c / 255;
247
+ return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
248
+ }
249
+ function linearToSrgb(v) {
250
+ return (v <= .0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - .055) * 255;
251
+ }
252
+ function rgbaToOklab(c) {
253
+ const r = srgbToLinear(c.r);
254
+ const g = srgbToLinear(c.g);
255
+ const b = srgbToLinear(c.b);
256
+ const l = Math.cbrt(.4122214708 * r + .5363325363 * g + .0514459929 * b);
257
+ const m = Math.cbrt(.2119034982 * r + .6806995451 * g + .1073969566 * b);
258
+ const s = Math.cbrt(.0883024619 * r + .2817188376 * g + .6299787005 * b);
259
+ return {
260
+ L: .2104542553 * l + .793617785 * m - .0040720468 * s,
261
+ a: 1.9779984951 * l - 2.428592205 * m + .4505937099 * s,
262
+ b: .0259040371 * l + .7827717662 * m - .808675766 * s,
263
+ alpha: c.a
264
+ };
265
+ }
266
+ function oklabToRgba(c) {
267
+ const l = (c.L + .3963377774 * c.a + .2158037573 * c.b) ** 3;
268
+ const m = (c.L - .1055613458 * c.a - .0638541728 * c.b) ** 3;
269
+ const s = (c.L - .0894841775 * c.a - 1.291485548 * c.b) ** 3;
270
+ return {
271
+ r: linearToSrgb(4.0767416621 * l - 3.3077115913 * m + .2309699292 * s),
272
+ g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - .3413193965 * s),
273
+ b: linearToSrgb(-.0041960863 * l - .7034186147 * m + 1.707614701 * s),
274
+ a: c.alpha
275
+ };
276
+ }
277
+ /** Interpolate two CSS color strings in OKLab; t may extrapolate. */
278
+ function lerpColor(from, to, t) {
279
+ const a = rgbaToOklab(parseColor(from));
280
+ const b = rgbaToOklab(parseColor(to));
281
+ const mix = (x, y) => x + (y - x) * t;
282
+ return formatColor(oklabToRgba({
283
+ L: mix(a.L, b.L),
284
+ a: mix(a.a, b.a),
285
+ b: mix(a.b, b.b),
286
+ alpha: mix(a.alpha, b.alpha)
287
+ }));
288
+ }
289
+ //#endregion
290
+ //#region src/valueTypes.ts
291
+ /**
292
+ * Value-type registry with pluggable per-type interpolation (DESIGN.md §2.2).
293
+ * `extrapolates` declares whether a type's lerp accepts easedT outside [0,1]
294
+ * (spring overshoot); non-extrapolating types clamp.
295
+ */
296
+ const registry = /* @__PURE__ */ new Map();
297
+ function registerValueType(vt) {
298
+ registry.set(vt.id, vt);
299
+ }
300
+ var UnknownValueTypeError = class extends Error {
301
+ constructor(id) {
302
+ super(`unknown value type '${id}'; register it via registerValueType()`);
303
+ this.name = "UnknownValueTypeError";
304
+ }
305
+ };
306
+ function getValueType(id) {
307
+ const vt = registry.get(id);
308
+ if (!vt) throw new UnknownValueTypeError(id);
309
+ return vt;
310
+ }
311
+ const numberType = {
312
+ id: "number",
313
+ lerp: (a, b, t) => a + (b - a) * t,
314
+ extrapolates: true,
315
+ equals: Object.is
316
+ };
317
+ const vec2Equals = (a, b) => a[0] === b[0] && a[1] === b[1];
318
+ const vec2Type = {
319
+ id: "vec2",
320
+ lerp: (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t],
321
+ extrapolates: true,
322
+ equals: vec2Equals
323
+ };
324
+ const colorType = {
325
+ id: "color",
326
+ lerp: lerpColor,
327
+ extrapolates: true,
328
+ equals: (a, b) => a === b
329
+ };
330
+ /** Discrete types: hold-only by construction (§2.2); lerp snaps at t=1. */
331
+ function discrete(id) {
332
+ return {
333
+ id,
334
+ lerp: (a, b, t) => t >= 1 ? b : a,
335
+ extrapolates: false,
336
+ equals: (a, b) => Object.is(a, b)
337
+ };
338
+ }
339
+ const stringType = discrete("string");
340
+ const booleanType = discrete("boolean");
341
+ var ValueTypeInferenceError = class extends Error {
342
+ constructor(value) {
343
+ super(`cannot infer a value type for ${JSON.stringify(value)}; register a custom type`);
344
+ this.name = "ValueTypeInferenceError";
345
+ }
346
+ };
347
+ /** Infer a registered type id from a sample value (builder + bake authoring surfaces). */
348
+ function inferValueType(value) {
349
+ if (typeof value === "number") return "number";
350
+ if (typeof value === "boolean") return "boolean";
351
+ if (Array.isArray(value) && value.length === 2 && value.every((v) => typeof v === "number")) return "vec2";
352
+ if (typeof value === "string") try {
353
+ parseColor(value);
354
+ return "color";
355
+ } catch {
356
+ return "string";
357
+ }
358
+ throw new ValueTypeInferenceError(value);
359
+ }
360
+ registerValueType(numberType);
361
+ registerValueType(vec2Type);
362
+ registerValueType(colorType);
363
+ registerValueType(stringType);
364
+ registerValueType(booleanType);
365
+ //#endregion
366
+ //#region src/vec2Signal.ts
367
+ /**
368
+ * Compound Vec2 signal (DESIGN.md §2.1): sub-signals are real signals, the
369
+ * parent read derives from them, and tracks may target either level — a
370
+ * sub-signal binding takes precedence for its component (§2.2).
371
+ */
372
+ function vec2Signal(initial) {
373
+ const [ix, iy] = Array.isArray(initial) ? initial : [initial.x, initial.y];
374
+ const x = signal(ix);
375
+ const y = signal(iy);
376
+ const compound = signal(null, { equals: (a, b) => a === null || b === null ? a === b : a[0] === b[0] && a[1] === b[1] });
377
+ const baseX = signal(ix);
378
+ const baseY = signal(iy);
379
+ x.bindSource(() => {
380
+ const c = compound();
381
+ return c === null ? baseX() : c[0];
382
+ });
383
+ y.bindSource(() => {
384
+ const c = compound();
385
+ return c === null ? baseY() : c[1];
386
+ });
387
+ const value = computed(() => [x(), y()], { equals: (a, b) => a[0] === b[0] && a[1] === b[1] });
388
+ const sig = (() => value());
389
+ sig["peek"] = () => value.peek();
390
+ sig["subscribe"] = (cb) => value.subscribe(cb);
391
+ sig["set"] = (v) => {
392
+ compound.set(null);
393
+ baseX.set(v[0]);
394
+ baseY.set(v[1]);
395
+ };
396
+ sig["bindSource"] = (fn) => compound.bindSource(() => fn());
397
+ sig["unbindSource"] = () => {
398
+ compound.unbindSource();
399
+ compound.set(null);
400
+ };
401
+ Object.defineProperty(sig, "x", {
402
+ value: x,
403
+ enumerable: true
404
+ });
405
+ Object.defineProperty(sig, "y", {
406
+ value: y,
407
+ enumerable: true
408
+ });
409
+ return sig;
410
+ }
411
+ //#endregion
412
+ //#region src/easing.ts
413
+ const c1 = 1.70158;
414
+ const c2 = c1 * 1.525;
415
+ const c3 = 2.70158;
416
+ const c4 = 2 * Math.PI / 3;
417
+ const c5 = 2 * Math.PI / 4.5;
418
+ function bounceOut(t) {
419
+ const n1 = 7.5625;
420
+ const d1 = 2.75;
421
+ if (t < 1 / d1) return n1 * t * t;
422
+ if (t < 2 / d1) return n1 * (t -= 1.5 / d1) * t + .75;
423
+ if (t < 2.5 / d1) return n1 * (t -= 2.25 / d1) * t + .9375;
424
+ return n1 * (t -= 2.625 / d1) * t + .984375;
425
+ }
426
+ const easings = {
427
+ linear: (t) => t,
428
+ easeInQuad: (t) => t * t,
429
+ easeOutQuad: (t) => 1 - (1 - t) * (1 - t),
430
+ easeInOutQuad: (t) => t < .5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2,
431
+ easeInCubic: (t) => t ** 3,
432
+ easeOutCubic: (t) => 1 - (1 - t) ** 3,
433
+ easeInOutCubic: (t) => t < .5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2,
434
+ easeInQuart: (t) => t ** 4,
435
+ easeOutQuart: (t) => 1 - (1 - t) ** 4,
436
+ easeInOutQuart: (t) => t < .5 ? 8 * t ** 4 : 1 - (-2 * t + 2) ** 4 / 2,
437
+ easeInQuint: (t) => t ** 5,
438
+ easeOutQuint: (t) => 1 - (1 - t) ** 5,
439
+ easeInOutQuint: (t) => t < .5 ? 16 * t ** 5 : 1 - (-2 * t + 2) ** 5 / 2,
440
+ easeInSine: (t) => 1 - Math.cos(t * Math.PI / 2),
441
+ easeOutSine: (t) => Math.sin(t * Math.PI / 2),
442
+ easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
443
+ easeInExpo: (t) => t === 0 ? 0 : 2 ** (10 * t - 10),
444
+ easeOutExpo: (t) => t === 1 ? 1 : 1 - 2 ** (-10 * t),
445
+ easeInOutExpo: (t) => t === 0 ? 0 : t === 1 ? 1 : t < .5 ? 2 ** (20 * t - 10) / 2 : (2 - 2 ** (-20 * t + 10)) / 2,
446
+ easeInCirc: (t) => 1 - Math.sqrt(1 - t * t),
447
+ easeOutCirc: (t) => Math.sqrt(1 - (t - 1) * (t - 1)),
448
+ easeInOutCirc: (t) => t < .5 ? (1 - Math.sqrt(1 - (2 * t) ** 2)) / 2 : (Math.sqrt(1 - (-2 * t + 2) ** 2) + 1) / 2,
449
+ easeInBack: (t) => c3 * t ** 3 - c1 * t * t,
450
+ easeOutBack: (t) => 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2,
451
+ 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,
452
+ easeInElastic: (t) => t === 0 ? 0 : t === 1 ? 1 : -(2 ** (10 * t - 10)) * Math.sin((t * 10 - 10.75) * c4),
453
+ easeOutElastic: (t) => t === 0 ? 0 : t === 1 ? 1 : 2 ** (-10 * t) * Math.sin((t * 10 - .75) * c4) + 1,
454
+ 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,
455
+ easeInBounce: (t) => 1 - bounceOut(1 - t),
456
+ easeOutBounce: bounceOut,
457
+ easeInOutBounce: (t) => t < .5 ? (1 - bounceOut(1 - 2 * t)) / 2 : (1 + bounceOut(2 * t - 1)) / 2
458
+ };
459
+ /** Default property-tween ease (Motion Canvas precedent). */
460
+ const DEFAULT_EASE = "easeInOutCubic";
461
+ /**
462
+ * CSS-style cubic bézier where x is time and y is progress. Newton's method
463
+ * with a bisection fallback for the flat-derivative regions.
464
+ */
465
+ function cubicBezier(p1x, p1y, p2x, p2y) {
466
+ const ax = 3 * p1x - 3 * p2x + 1;
467
+ const bx = 3 * p2x - 6 * p1x;
468
+ const cx = 3 * p1x;
469
+ const ay = 3 * p1y - 3 * p2y + 1;
470
+ const by = 3 * p2y - 6 * p1y;
471
+ const cy = 3 * p1y;
472
+ const sampleX = (u) => ((ax * u + bx) * u + cx) * u;
473
+ const sampleY = (u) => ((ay * u + by) * u + cy) * u;
474
+ const sampleDX = (u) => (3 * ax * u + 2 * bx) * u + cx;
475
+ const solveU = (x) => {
476
+ let u = x;
477
+ for (let i = 0; i < 8; i++) {
478
+ const err = sampleX(u) - x;
479
+ if (Math.abs(err) < 1e-7) return u;
480
+ const d = sampleDX(u);
481
+ if (Math.abs(d) < 1e-6) break;
482
+ u -= err / d;
483
+ }
484
+ let lo = 0;
485
+ let hi = 1;
486
+ u = x;
487
+ while (hi - lo > 1e-7) {
488
+ if (sampleX(u) < x) lo = u;
489
+ else hi = u;
490
+ u = (lo + hi) / 2;
491
+ }
492
+ return u;
493
+ };
494
+ return (t) => {
495
+ if (t <= 0) return 0;
496
+ if (t >= 1) return 1;
497
+ return sampleY(solveU(t));
498
+ };
499
+ }
500
+ var UnknownEasingError = class extends Error {
501
+ constructor(name) {
502
+ super(`unknown easing '${name}'; register it via easings or use cubicBezier/spring`);
503
+ this.name = "UnknownEasingError";
504
+ }
505
+ };
506
+ function namedEasing(name) {
507
+ const fn = easings[name];
508
+ if (!fn) throw new UnknownEasingError(name);
509
+ return fn;
510
+ }
511
+ //#endregion
512
+ //#region src/spring.ts
513
+ const DEFAULT_SETTLE_TOLERANCE = .005;
514
+ function params(cfg) {
515
+ const mass = cfg.mass ?? 1;
516
+ if (!(cfg.stiffness > 0) || !(cfg.damping > 0) || !(mass > 0)) throw new RangeError("spring stiffness, damping, and mass must all be > 0");
517
+ return {
518
+ w0: Math.sqrt(cfg.stiffness / mass),
519
+ zeta: cfg.damping / (2 * Math.sqrt(cfg.stiffness * mass))
520
+ };
521
+ }
522
+ /** Raw closed-form spring position at time t (seconds). Approaches 1, may overshoot. */
523
+ function rawValue(cfg, t) {
524
+ if (t <= 0) return 0;
525
+ const { w0, zeta } = params(cfg);
526
+ if (Math.abs(zeta - 1) < 1e-9) return 1 - Math.exp(-w0 * t) * (1 + w0 * t);
527
+ if (zeta < 1) {
528
+ const wd = w0 * Math.sqrt(1 - zeta * zeta);
529
+ return 1 - Math.exp(-zeta * w0 * t) * (Math.cos(wd * t) + zeta * w0 / wd * Math.sin(wd * t));
530
+ }
531
+ const s = Math.sqrt(zeta * zeta - 1);
532
+ const r1 = -w0 * (zeta - s);
533
+ const r2 = -w0 * (zeta + s);
534
+ return 1 + (r2 * Math.exp(r1 * t) - r1 * Math.exp(r2 * t)) / (r1 - r2);
535
+ }
536
+ /**
537
+ * Settle duration: the earliest time after which |x - 1| stays within
538
+ * settleTolerance. Closed-form via the decay envelope for the underdamped
539
+ * case; bisection on the monotone tail otherwise. Deterministic.
540
+ */
541
+ function duration(cfg, opts) {
542
+ const tol = opts?.settleTolerance ?? DEFAULT_SETTLE_TOLERANCE;
543
+ const { w0, zeta } = params(cfg);
544
+ if (zeta < 1) {
545
+ const wd = w0 * Math.sqrt(1 - zeta * zeta);
546
+ const amp = Math.sqrt(1 + (zeta * w0 / wd) ** 2);
547
+ return Math.log(amp / tol) / (zeta * w0);
548
+ }
549
+ let hi = 1 / w0;
550
+ while (1 - rawValue(cfg, hi) > tol) hi *= 2;
551
+ let lo = 0;
552
+ for (let i = 0; i < 64 && hi - lo > 1e-9; i++) {
553
+ const mid = (lo + hi) / 2;
554
+ if (1 - rawValue(cfg, mid) > tol) lo = mid;
555
+ else hi = mid;
556
+ }
557
+ return hi;
558
+ }
559
+ /**
560
+ * Spring progress at local time t, affinely rescaled so value(duration) = 1
561
+ * exactly — the raw form only approaches 1, and an unscaled curve would snap
562
+ * at the key (§2.7 "endpoint continuity"). May exceed 1 (overshoot).
563
+ */
564
+ function value(cfg, t, opts) {
565
+ const d = duration(cfg, opts);
566
+ return rawValue(cfg, Math.min(t, d)) / rawValue(cfg, d);
567
+ }
568
+ const spring = Object.assign((cfg) => {
569
+ params(cfg);
570
+ return {
571
+ kind: "spring",
572
+ stiffness: cfg.stiffness,
573
+ damping: cfg.damping,
574
+ mass: cfg.mass ?? 1
575
+ };
576
+ }, {
577
+ duration,
578
+ value
579
+ });
580
+ /**
581
+ * The spring as a normalized easing over a segment whose length must equal
582
+ * spring.duration(cfg) (validated at the document layer, §2.7).
583
+ */
584
+ function springEasing(cfg) {
585
+ const d = duration(cfg);
586
+ return (p) => value(cfg, p * d);
587
+ }
588
+ //#endregion
589
+ //#region src/track.ts
590
+ /**
591
+ * Track & keyframe model (DESIGN.md §2.2) and sampling (§2.4): binary search
592
+ * per track with a last-segment cursor — sanctioned memoization, semantics
593
+ * identical to a cold search (property-tested).
594
+ */
595
+ var TrackValidationError = class extends Error {
596
+ constructor(target, message) {
597
+ super(`track '${target}': ${message}`);
598
+ this.name = "TrackValidationError";
599
+ }
600
+ };
601
+ function validateTrack(track) {
602
+ getValueType(track.type);
603
+ if (track.keys.length === 0) throw new TrackValidationError(track.target, "must have at least one key");
604
+ for (let i = 1; i < track.keys.length; i++) {
605
+ const prev = track.keys[i - 1];
606
+ const cur = track.keys[i];
607
+ 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})`);
608
+ }
609
+ if (!/^[^/]+\/.+$/.test(track.target)) throw new TrackValidationError(track.target, "target must be '<nodeId>/<prop.path>' (e.g. 'circle/opacity')");
610
+ }
611
+ function key(t, value, easeOrOpts) {
612
+ if (easeOrOpts === void 0) return {
613
+ t,
614
+ value
615
+ };
616
+ if (typeof easeOrOpts === "string" || "kind" in easeOrOpts) return {
617
+ t,
618
+ value,
619
+ ease: easeOrOpts
620
+ };
621
+ const opts = easeOrOpts;
622
+ const k = {
623
+ t,
624
+ value
625
+ };
626
+ if (opts.ease !== void 0) k.ease = opts.ease;
627
+ if (opts.interp !== void 0) k.interp = opts.interp;
628
+ if (opts.id !== void 0) k.id = opts.id;
629
+ if (opts.derived !== void 0) k.derived = opts.derived;
630
+ return k;
631
+ }
632
+ function track(target, type, keys, opts) {
633
+ const tr = {
634
+ target,
635
+ type,
636
+ keys
637
+ };
638
+ if (opts?.editable !== void 0) tr.editable = opts.editable;
639
+ validateTrack(tr);
640
+ return tr;
641
+ }
642
+ function resolveEase(spec) {
643
+ if (spec === void 0) return namedEasing("linear");
644
+ if (typeof spec === "string") return namedEasing(spec);
645
+ if (spec.kind === "cubicBezier") return cubicBezier(...spec.pts);
646
+ return springEasing(spec);
647
+ }
648
+ const samplerStates = /* @__PURE__ */ new WeakMap();
649
+ function state(tr) {
650
+ let s = samplerStates.get(tr);
651
+ if (!s) {
652
+ s = {
653
+ cursor: 1,
654
+ easeCache: new Array(tr.keys.length)
655
+ };
656
+ samplerStates.set(tr, s);
657
+ }
658
+ return s;
659
+ }
660
+ function easeFor(tr, s, i) {
661
+ let fn = s.easeCache[i];
662
+ if (!fn) {
663
+ fn = resolveEase(tr.keys[i].ease);
664
+ s.easeCache[i] = fn;
665
+ }
666
+ return fn;
667
+ }
668
+ /**
669
+ * Find i such that keys[i-1].t <= t < keys[i].t, i.e. the index of the
670
+ * arrival key of the segment containing t. Returns 0 if t < first key,
671
+ * keys.length if t >= last key.
672
+ */
673
+ function findSegment(keys, t, hint) {
674
+ const n = keys.length;
675
+ 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;
676
+ if (t < keys[0].t) return 0;
677
+ if (t >= keys[n - 1].t) return n;
678
+ let lo = 1;
679
+ let hi = n - 1;
680
+ while (lo < hi) {
681
+ const mid = lo + hi >> 1;
682
+ if (keys[mid].t <= t) lo = mid + 1;
683
+ else hi = mid;
684
+ }
685
+ return lo;
686
+ }
687
+ /** Pure sample of a track at time t (§2.4). */
688
+ function sampleTrack(tr, t) {
689
+ const keys = tr.keys;
690
+ const n = keys.length;
691
+ const s = state(tr);
692
+ const i = findSegment(keys, t, s.cursor);
693
+ if (i === 0) return keys[0].value;
694
+ if (i >= n) return keys[n - 1].value;
695
+ s.cursor = i;
696
+ const arrival = keys[i];
697
+ const prev = keys[i - 1];
698
+ if (arrival.interp === "hold") return prev.value;
699
+ const vt = getValueType(tr.type);
700
+ const p = (t - prev.t) / (arrival.t - prev.t);
701
+ let easedT = easeFor(tr, s, i)(p);
702
+ if (!vt.extrapolates) easedT = Math.min(1, Math.max(0, easedT));
703
+ return vt.lerp(prev.value, arrival.value, easedT);
704
+ }
705
+ //#endregion
706
+ //#region src/timeline.ts
707
+ /**
708
+ * The Timeline document (DESIGN.md §2.3) — the serializable animation source
709
+ * of truth — and its compiler: child flattening (add/sync), same-target
710
+ * coalescing (last-insertion-wins, §2.2), duration computation, validation.
711
+ */
712
+ function timeline$1(init) {
713
+ const doc = {
714
+ version: 1,
715
+ tracks: init.tracks ?? []
716
+ };
717
+ if (init.duration !== void 0) doc.duration = init.duration;
718
+ if (init.fps !== void 0) doc.fps = init.fps;
719
+ if (init.posterTime !== void 0) doc.posterTime = init.posterTime;
720
+ if (init.labels !== void 0) doc.labels = init.labels;
721
+ if (init.markers !== void 0) doc.markers = init.markers;
722
+ if (init.children !== void 0) doc.children = init.children;
723
+ if (init.audio !== void 0) doc.audio = init.audio;
724
+ if (init.assets !== void 0) doc.assets = init.assets;
725
+ return doc;
726
+ }
727
+ var TimelineValidationError = class extends Error {
728
+ constructor(message) {
729
+ super(message);
730
+ this.name = "TimelineValidationError";
731
+ }
732
+ };
733
+ /** Spring key rule (§2.7): a spring-eased key's t must equal prev.t + spring.duration(cfg). */
734
+ function validateSpringKeys(tr) {
735
+ for (let i = 1; i < tr.keys.length; i++) {
736
+ const k = tr.keys[i];
737
+ if (k.ease && typeof k.ease === "object" && k.ease.kind === "spring") {
738
+ const expected = tr.keys[i - 1].t + spring.duration(k.ease);
739
+ 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`);
740
+ }
741
+ }
742
+ }
743
+ let devWarn = (msg) => {
744
+ globalThis.console?.warn(`[glissade] ${msg}`);
745
+ };
746
+ function setDevWarning(fn) {
747
+ devWarn = fn;
748
+ }
749
+ /** Internal: emit through the configurable dev-warning channel. */
750
+ function emitDevWarning(message) {
751
+ devWarn(message);
752
+ }
753
+ function rebaseKeys(keys, at, timeScale) {
754
+ return keys.map((k) => ({
755
+ ...k,
756
+ t: at + k.t / timeScale
757
+ }));
758
+ }
759
+ function flatten(doc, at, timeScale, opaque, out) {
760
+ for (const tr of doc.tracks) {
761
+ validateTrack(tr);
762
+ validateSpringKeys(tr);
763
+ out.push({
764
+ track: {
765
+ ...tr,
766
+ keys: rebaseKeys(tr.keys, at, timeScale)
767
+ },
768
+ opaque
769
+ });
770
+ }
771
+ for (const child of doc.children ?? []) {
772
+ const scale = child.mode === "sync" ? child.timeScale ?? 1 : 1;
773
+ if (child.mode === "add" && child.timeScale !== void 0) throw new TimelineValidationError("timeScale is only valid on mode:'sync' children (§2.3)");
774
+ if (scale <= 0) throw new TimelineValidationError("sync timeScale must be > 0");
775
+ flatten(child.timeline, at + child.at / timeScale, timeScale * scale, opaque || child.mode === "sync", out);
776
+ }
777
+ }
778
+ /**
779
+ * Coalesce same-target tracks: later insertion wins where key ranges overlap
780
+ * (§2.2/§2.6 decided rule), with a dev warning. Earlier keys inside the later
781
+ * track's [first.t, last.t] span are dropped.
782
+ */
783
+ function coalesce(entries) {
784
+ const byTarget = /* @__PURE__ */ new Map();
785
+ for (const { track: tr } of entries) {
786
+ const existing = byTarget.get(tr.target);
787
+ if (!existing) {
788
+ byTarget.set(tr.target, {
789
+ ...tr,
790
+ keys: [...tr.keys]
791
+ });
792
+ continue;
793
+ }
794
+ if (existing.type !== tr.type) throw new TimelineValidationError(`target '${tr.target}' has conflicting value types '${existing.type}' and '${tr.type}'`);
795
+ const start = tr.keys[0].t;
796
+ const end = tr.keys[tr.keys.length - 1].t;
797
+ const existingStart = existing.keys[0].t;
798
+ const existingEnd = existing.keys[existing.keys.length - 1].t;
799
+ const kept = existing.keys.filter((k) => k.t < start || k.t > end);
800
+ if (existingStart <= end && start <= existingEnd) devWarn(`overlapping tracks for '${tr.target}' in [${start}, ${end}]: later insertion wins (${existing.keys.length - kept.length} earlier key(s) dropped)`);
801
+ existing.keys = [...kept, ...tr.keys].sort((a, b) => a.t - b.t);
802
+ }
803
+ return byTarget;
804
+ }
805
+ function childExtent(child) {
806
+ const scale = child.mode === "sync" ? child.timeScale ?? 1 : 1;
807
+ return child.at + computeDuration(child.timeline) / scale;
808
+ }
809
+ function computeDuration(doc) {
810
+ if (doc.duration !== void 0) return doc.duration;
811
+ let max = 0;
812
+ for (const tr of doc.tracks) {
813
+ const last = tr.keys[tr.keys.length - 1];
814
+ if (last) max = Math.max(max, last.t);
815
+ }
816
+ for (const m of doc.markers ?? []) max = Math.max(max, m.t);
817
+ for (const child of doc.children ?? []) max = Math.max(max, childExtent(child));
818
+ return max;
819
+ }
820
+ function compileTimeline(doc) {
821
+ if (doc.version !== 1) throw new TimelineValidationError(`unsupported timeline document version ${String(doc.version)}`);
822
+ const flat = [];
823
+ flatten(doc, 0, 1, false, flat);
824
+ const tracks = coalesce(flat);
825
+ const labels = { ...doc.labels };
826
+ const markers = [...doc.markers ?? []];
827
+ const audio = [...doc.audio ?? []];
828
+ const visitChildren = (children, at, scale) => {
829
+ for (const child of children ?? []) {
830
+ const base = at + child.at / scale;
831
+ const childScale = scale * (child.mode === "sync" ? child.timeScale ?? 1 : 1);
832
+ for (const [name, t] of Object.entries(child.timeline.labels ?? {})) if (!(name in labels)) labels[name] = base + t / childScale;
833
+ for (const m of child.timeline.markers ?? []) markers.push({
834
+ ...m,
835
+ t: base + m.t / childScale
836
+ });
837
+ for (const clip of child.timeline.audio ?? []) audio.push({
838
+ ...clip,
839
+ at: base + clip.at / childScale,
840
+ ...childScale !== 1 ? { playbackRate: (clip.playbackRate ?? 1) * childScale } : {}
841
+ });
842
+ visitChildren(child.timeline.children, base, childScale);
843
+ }
844
+ };
845
+ visitChildren(doc.children, 0, 1);
846
+ markers.sort((a, b) => a.t - b.t);
847
+ audio.sort((a, b) => a.at - b.at);
848
+ return {
849
+ duration: computeDuration(doc),
850
+ labels,
851
+ markers,
852
+ tracks,
853
+ audio
854
+ };
855
+ }
856
+ //#endregion
857
+ //#region src/targetRef.ts
858
+ /**
859
+ * Target references (DESIGN.md §2.6): the builder accepts either a canonical
860
+ * target string ('circle/opacity') or a property signal that carries its own
861
+ * path — attached by the scene package at node construction via TARGET_PATH.
862
+ */
863
+ const TARGET_PATH = Symbol.for("glissade.targetPath");
864
+ var UnresolvableTargetError = class extends Error {
865
+ constructor() {
866
+ super("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)");
867
+ this.name = "UnresolvableTargetError";
868
+ }
869
+ };
870
+ function resolveTweenTarget(target) {
871
+ if (typeof target === "string") return target;
872
+ const path = target[TARGET_PATH];
873
+ if (typeof path !== "string") throw new UnresolvableTargetError();
874
+ return path;
875
+ }
876
+ //#endregion
877
+ //#region src/builder.ts
878
+ /**
879
+ * The fluent timeline builder (DESIGN.md §2.6): imperative-LOOKING chains that
880
+ * compile to the declarative Timeline document. Nothing executes at play time.
881
+ *
882
+ * Position grammar (GSAP-proven): absolute `1.5`; `'+=0.5'`/`'-=0.2'` from the
883
+ * previous insertion's END; `'<'`/`'>'` previous START/END; `'label'`,
884
+ * `'label+=0.3'`. Implicit from-values resolve in a finalize pass in t-order
885
+ * against the complete document — never sampled live (the GSAP invalidate()
886
+ * bug class, §2.6), with the build-time signal value as the t-order base.
887
+ */
888
+ var PositionError = class extends Error {
889
+ constructor(pos, detail) {
890
+ super(`invalid position '${pos}': ${detail}`);
891
+ this.name = "PositionError";
892
+ }
893
+ };
894
+ function peekBase(target) {
895
+ return typeof target !== "string" && typeof target.peek === "function" ? target.peek() : void 0;
896
+ }
897
+ /** Callbacks registered via .call(), keyed by the produced document. */
898
+ const timelineCallbacks = /* @__PURE__ */ new WeakMap();
899
+ function getTimelineCallbacks(doc) {
900
+ return timelineCallbacks.get(doc) ?? /* @__PURE__ */ new Map();
901
+ }
902
+ function buildTimeline(build, init = {}) {
903
+ const insertions = [];
904
+ const labels = { ...init.labels };
905
+ const children = [];
906
+ const markers = [];
907
+ const callbacks = /* @__PURE__ */ new Map();
908
+ let prevStart = 0;
909
+ let prevEnd = 0;
910
+ let callCount = 0;
911
+ function resolvePosition(at) {
912
+ if (at === void 0) return prevEnd;
913
+ if (typeof at === "number") return at;
914
+ if (at === "<") return prevStart;
915
+ if (at === ">") return prevEnd;
916
+ const rel = /^([+-])=([\d.]+)$/.exec(at);
917
+ if (rel) return prevEnd + (rel[1] === "+" ? 1 : -1) * parseFloat(rel[2]);
918
+ const labelRel = /^([^+\-=]+?)(?:([+-])=([\d.]+))?$/.exec(at);
919
+ if (labelRel) {
920
+ const name = labelRel[1];
921
+ if (!(name in labels)) throw new PositionError(at, `unknown label '${name}' (labels must be declared before use)`);
922
+ const offset = labelRel[2] ? (labelRel[2] === "+" ? 1 : -1) * parseFloat(labelRel[3]) : 0;
923
+ return labels[name] + offset;
924
+ }
925
+ throw new PositionError(at, "expected a number, '<', '>', '+=x', '-=x', or 'label[+=x]'");
926
+ }
927
+ const builder = {
928
+ to(target, value, opts = {}) {
929
+ const ease = opts.ease ?? "easeInOutCubic";
930
+ const isSpring = typeof ease === "object" && ease.kind === "spring";
931
+ const duration = isSpring ? spring.duration(ease) : opts.duration ?? 1;
932
+ if (isSpring && opts.duration !== void 0) throw new TimelineValidationError("a spring ease determines its duration (spring.duration(cfg)); do not pass duration with a spring");
933
+ const start = resolvePosition(opts.at);
934
+ const ins = {
935
+ kind: "tween",
936
+ target: resolveTweenTarget(target),
937
+ value,
938
+ duration,
939
+ ease,
940
+ at: opts.at,
941
+ baseValue: peekBase(target),
942
+ editable: false,
943
+ start
944
+ };
945
+ if (opts.from !== void 0) ins.explicitFrom = opts.from;
946
+ insertions.push(ins);
947
+ prevStart = start;
948
+ prevEnd = start + duration;
949
+ return builder;
950
+ },
951
+ fromTo(target, from, to, opts = {}) {
952
+ builder.to(target, to, opts);
953
+ insertions[insertions.length - 1].explicitFrom = from;
954
+ return builder;
955
+ },
956
+ set(target, value, opts = {}) {
957
+ const start = resolvePosition(opts.at);
958
+ insertions.push({
959
+ kind: "set",
960
+ target: resolveTweenTarget(target),
961
+ value,
962
+ duration: 0,
963
+ ease: "linear",
964
+ at: opts.at,
965
+ baseValue: peekBase(target),
966
+ editable: false,
967
+ start
968
+ });
969
+ prevStart = start;
970
+ prevEnd = start;
971
+ return builder;
972
+ },
973
+ label(name, at) {
974
+ labels[name] = at === void 0 ? prevEnd : resolvePosition(at);
975
+ return builder;
976
+ },
977
+ add(child, at, opts = {}) {
978
+ const start = at === void 0 ? prevEnd : resolvePosition(at);
979
+ const mode = opts.mode ?? "add";
980
+ const entry = {
981
+ timeline: child,
982
+ at: start,
983
+ mode,
984
+ _pos: at
985
+ };
986
+ if (opts.timeScale !== void 0) entry.timeScale = opts.timeScale;
987
+ children.push(entry);
988
+ const scale = mode === "sync" ? opts.timeScale ?? 1 : 1;
989
+ prevStart = start;
990
+ prevEnd = start + compileTimeline(child).duration / scale;
991
+ return builder;
992
+ },
993
+ call(fn, at) {
994
+ const t = at === void 0 ? prevEnd : resolvePosition(at);
995
+ const name = `call:${callCount++}`;
996
+ markers.push({
997
+ t,
998
+ name
999
+ });
1000
+ callbacks.set(name, fn);
1001
+ return builder;
1002
+ },
1003
+ editable() {
1004
+ const last = insertions[insertions.length - 1];
1005
+ if (!last) throw new TimelineValidationError(".editable() requires a preceding insertion");
1006
+ last.editable = true;
1007
+ return builder;
1008
+ }
1009
+ };
1010
+ build(builder);
1011
+ const byTarget = /* @__PURE__ */ new Map();
1012
+ for (const ins of insertions) {
1013
+ let list = byTarget.get(ins.target);
1014
+ if (!list) {
1015
+ list = [];
1016
+ byTarget.set(ins.target, list);
1017
+ }
1018
+ list.push(ins);
1019
+ }
1020
+ const tracks = [];
1021
+ for (const [target, list] of byTarget) {
1022
+ list.sort((a, b) => a.start - b.start);
1023
+ const keys = [];
1024
+ const editable = list.some((i) => i.editable);
1025
+ let prevValue = list.find((i) => i.baseValue !== void 0)?.baseValue;
1026
+ const first = list[0];
1027
+ if (first.kind === "tween" && first.explicitFrom === void 0 && prevValue === void 0) emitDevWarning(`'${target}': first tween has no resolvable from-value (string targets have no base) — the track sits at its end state before the tween. Anchor it with { from }, fromTo(), or set(..., { at: 0 }).`);
1028
+ for (const ins of list) {
1029
+ if (ins.kind === "set") {
1030
+ keys.push({
1031
+ t: ins.start,
1032
+ value: ins.value,
1033
+ interp: "hold"
1034
+ });
1035
+ prevValue = ins.value;
1036
+ continue;
1037
+ }
1038
+ const from = ins.explicitFrom !== void 0 ? ins.explicitFrom : prevValue;
1039
+ const lastKey = keys[keys.length - 1];
1040
+ if (from !== void 0 && (!lastKey || lastKey.t < ins.start)) {
1041
+ const fromKey = {
1042
+ t: ins.start,
1043
+ value: from
1044
+ };
1045
+ if (ins.explicitFrom === void 0) fromKey.derived = true;
1046
+ if (!lastKey || ins.start > lastKey.t) keys.push(fromKey);
1047
+ }
1048
+ keys.push({
1049
+ t: ins.start + ins.duration,
1050
+ value: ins.value,
1051
+ ease: ins.ease
1052
+ });
1053
+ prevValue = ins.value;
1054
+ }
1055
+ const deduped = [];
1056
+ for (const k of keys.sort((a, b) => a.t - b.t)) {
1057
+ const last = deduped[deduped.length - 1];
1058
+ if (last && last.t === k.t) deduped[deduped.length - 1] = k;
1059
+ else deduped.push(k);
1060
+ }
1061
+ const tr = {
1062
+ target,
1063
+ type: inferValueType(list[0].value),
1064
+ keys: deduped
1065
+ };
1066
+ if (editable) tr.editable = true;
1067
+ tracks.push(tr);
1068
+ }
1069
+ const doc = timeline$1({
1070
+ ...init,
1071
+ tracks,
1072
+ labels,
1073
+ ...markers.length ? { markers } : {},
1074
+ ...children.length ? { children: children.map(({ _pos, ...c }) => c) } : {}
1075
+ });
1076
+ if (callbacks.size) timelineCallbacks.set(doc, callbacks);
1077
+ return doc;
1078
+ }
1079
+ function timeline(arg, init) {
1080
+ return typeof arg === "function" ? buildTimeline(arg, init) : timeline$1(arg);
1081
+ }
1082
+ //#endregion
1083
+ //#region src/binding.ts
1084
+ /**
1085
+ * Playhead + timeline binding (DESIGN.md §2.4): animated properties are not
1086
+ * written each frame — binding rewires each targeted signal's source to
1087
+ * `() => sampleTrack(track, playhead())`, so evaluation stays pull-only and
1088
+ * unchanged samples don't propagate dirtiness.
1089
+ */
1090
+ function createPlayhead(initial = 0) {
1091
+ return signal(initial);
1092
+ }
1093
+ var UnboundTargetError = class extends Error {
1094
+ constructor(target) {
1095
+ super(`timeline targets '${target}' but no property signal resolves to it`);
1096
+ this.name = "UnboundTargetError";
1097
+ }
1098
+ };
1099
+ /**
1100
+ * Bind a compiled timeline's tracks to property signals. `resolve` returns
1101
+ * the signal for a target path, or undefined — which is a compile-time-style
1102
+ * error (§2.2: unbound tracks are build errors, not silent no-ops).
1103
+ */
1104
+ function bindTimeline(compiled, resolve, playhead = createPlayhead()) {
1105
+ const bound = [];
1106
+ for (const [target, tr] of compiled.tracks) {
1107
+ const sig = resolve(target);
1108
+ if (!sig) throw new UnboundTargetError(target);
1109
+ sig.bindSource(() => sampleTrack(tr, playhead()));
1110
+ bound.push(sig);
1111
+ }
1112
+ return {
1113
+ playhead,
1114
+ unbind: () => {
1115
+ for (const sig of bound) sig.unbindSource();
1116
+ }
1117
+ };
1118
+ }
1119
+ /**
1120
+ * The evaluation entry discipline (§2.5): the playhead write is the sanctioned
1121
+ * entry mutation, then the read phase begins and `read` must be pure.
1122
+ */
1123
+ function evaluateAt(playhead, t, read) {
1124
+ playhead.forceSet(t);
1125
+ beginReadPhase();
1126
+ try {
1127
+ return read();
1128
+ } finally {
1129
+ endReadPhase();
1130
+ }
1131
+ }
1132
+ //#endregion
1133
+ //#region src/rng.ts
1134
+ /** Returns a deterministic [0,1) generator for the given integer seed. */
1135
+ function random(seed) {
1136
+ let a = seed >>> 0;
1137
+ return () => {
1138
+ a = a + 2654435769 | 0;
1139
+ let t = a ^ a >>> 16;
1140
+ t = Math.imul(t, 569420461);
1141
+ t = t ^ t >>> 15;
1142
+ t = Math.imul(t, 1935289751);
1143
+ t = t ^ t >>> 15;
1144
+ return (t >>> 0) / 4294967296;
1145
+ };
1146
+ }
1147
+ //#endregion
1148
+ //#region src/bake.ts
1149
+ /**
1150
+ * bake() (DESIGN.md §2.8): stateful simulation as a compilation step. Run the
1151
+ * stepper ONCE — fixed dt, seeded RNG — and emit ordinary frame-indexed
1152
+ * Tracks; rendering stays a pure lookup and the §2.5 contract survives.
1153
+ * The checkpointed variant trades memory for bounded re-simulation.
1154
+ */
1155
+ var BakeError = class extends Error {
1156
+ constructor(detail) {
1157
+ super(`bake(): ${detail}`);
1158
+ this.name = "BakeError";
1159
+ }
1160
+ };
1161
+ function emit(acc, sampled, t) {
1162
+ for (const [path, value] of Object.entries(sampled)) {
1163
+ let a = acc.get(path);
1164
+ if (!a) {
1165
+ a = {
1166
+ keys: [],
1167
+ type: inferValueType(value)
1168
+ };
1169
+ acc.set(path, a);
1170
+ }
1171
+ a.keys.push({
1172
+ t,
1173
+ value
1174
+ });
1175
+ }
1176
+ }
1177
+ function toTracks(acc) {
1178
+ return [...acc.entries()].map(([target, a]) => ({
1179
+ target,
1180
+ type: a.type,
1181
+ keys: a.keys
1182
+ }));
1183
+ }
1184
+ function bake(cfg) {
1185
+ if (!(cfg.fps > 0) || !(cfg.duration >= 0)) throw new BakeError("fps must be > 0 and duration >= 0");
1186
+ const rng = random(cfg.seed ?? 0);
1187
+ const dt = 1 / cfg.fps;
1188
+ const frames = Math.round(cfg.duration * cfg.fps);
1189
+ let world = cfg.setup(rng);
1190
+ const acc = /* @__PURE__ */ new Map();
1191
+ emit(acc, cfg.sample(world), 0);
1192
+ for (let f = 1; f <= frames; f++) {
1193
+ world = cfg.step(world, dt, rng) ?? world;
1194
+ emit(acc, cfg.sample(world), f * dt);
1195
+ }
1196
+ return toTracks(acc);
1197
+ }
1198
+ function bakeCheckpointed(cfg) {
1199
+ if (!(cfg.every >= 1)) throw new BakeError("checkpoint interval `every` must be >= 1 frame");
1200
+ const dt = 1 / cfg.fps;
1201
+ const totalFrames = Math.round(cfg.duration * cfg.fps);
1202
+ let draws = 0;
1203
+ const baseRng = random(cfg.seed ?? 0);
1204
+ const countingRng = () => {
1205
+ draws++;
1206
+ return baseRng();
1207
+ };
1208
+ const rngAt = (targetDraws) => {
1209
+ const r = random(cfg.seed ?? 0);
1210
+ for (let i = 0; i < targetDraws; i++) r();
1211
+ return r;
1212
+ };
1213
+ let world = cfg.setup(countingRng);
1214
+ const checkpoints = [{
1215
+ snap: cfg.snapshot(world),
1216
+ rngDraws: draws
1217
+ }];
1218
+ let simulatedTo = 0;
1219
+ /** Advance the master simulation, recording a checkpoint every K frames. */
1220
+ function ensureSimulatedTo(frame) {
1221
+ while (simulatedTo < Math.min(frame, totalFrames)) {
1222
+ world = cfg.step(world, dt, countingRng) ?? world;
1223
+ simulatedTo++;
1224
+ if (simulatedTo % cfg.every === 0) checkpoints.push({
1225
+ snap: cfg.snapshot(world),
1226
+ rngDraws: draws
1227
+ });
1228
+ }
1229
+ }
1230
+ return {
1231
+ frames: totalFrames,
1232
+ bakeRange(fromFrame, toFrame) {
1233
+ if (fromFrame < 0 || toFrame > totalFrames || fromFrame > toFrame) throw new BakeError(`range ${fromFrame}..${toFrame} outside 0..${totalFrames}`);
1234
+ const cpFrame = Math.floor(fromFrame / cfg.every) * cfg.every;
1235
+ ensureSimulatedTo(cpFrame);
1236
+ const cp = checkpoints[cpFrame / cfg.every];
1237
+ let w = cfg.restore(cp.snap);
1238
+ const rng = rngAt(cp.rngDraws);
1239
+ let frame = cpFrame;
1240
+ const acc = /* @__PURE__ */ new Map();
1241
+ if (frame >= fromFrame) emit(acc, cfg.sample(w), frame * dt);
1242
+ while (frame < toFrame) {
1243
+ w = cfg.step(w, dt, rng) ?? w;
1244
+ frame++;
1245
+ if (frame >= fromFrame) emit(acc, cfg.sample(w), frame * dt);
1246
+ }
1247
+ return toTracks(acc);
1248
+ }
1249
+ };
1250
+ }
1251
+ //#endregion
1252
+ //#region src/sidecar.ts
1253
+ /**
1254
+ * The editor sidecar (DESIGN.md §6.2): code declares scene structure and
1255
+ * programmatic tracks; the studio owns keyframe data persisted as a sidecar
1256
+ * document next to the scene module, merged at track granularity. Versioned
1257
+ * independently of the API (§7.4) — breaking it orphans users' files.
1258
+ */
1259
+ var SidecarVersionError = class extends Error {
1260
+ constructor(version) {
1261
+ super(`unsupported sidecar version ${String(version)}; this build reads sidecarVersion 1`);
1262
+ this.name = "SidecarVersionError";
1263
+ }
1264
+ };
1265
+ function emptySidecar() {
1266
+ return {
1267
+ sidecarVersion: 1,
1268
+ tracks: []
1269
+ };
1270
+ }
1271
+ /**
1272
+ * Editor-edit normalization (§2.7 invariant): a spring-eased key's t is
1273
+ * intrinsic — prev.t + spring.duration(cfg) — so after any retime, sort and
1274
+ * re-pin spring keys to their predecessors. Dragging a spring key itself
1275
+ * therefore snaps back; retiming its predecessor carries it along. Returns a
1276
+ * new array. Colliding keys are NUDGED apart (+1ms), never deleted — an
1277
+ * editor must not silently destroy keyframe data on an exact-t collision.
1278
+ */
1279
+ function normalizeEditedKeys(keys) {
1280
+ const out = keys.map((k) => ({ ...k })).sort((a, b) => a.t - b.t);
1281
+ for (let pass = 0; pass < 2; pass++) {
1282
+ for (let i = 1; i < out.length; i++) {
1283
+ const ease = out[i].ease;
1284
+ if (ease && typeof ease === "object" && ease.kind === "spring") out[i].t = out[i - 1].t + spring.duration(ease);
1285
+ }
1286
+ out.sort((a, b) => a.t - b.t);
1287
+ }
1288
+ for (let i = 1; i < out.length; i++) if (out[i].t <= out[i - 1].t) out[i].t = out[i - 1].t + .001;
1289
+ return out;
1290
+ }
1291
+ /**
1292
+ * Merge rules (§6.2, schema-drift resolution):
1293
+ * - a sidecar track whose target exists in code REPLACES that track's keys
1294
+ * (the editor owns it; `editable` is preserved on the result);
1295
+ * - a sidecar track with no code counterpart is ADDED (editor-created track);
1296
+ * - sidecar tracks targeting nothing the scene can bind fail later at
1297
+ * bindTimeline with UnboundTargetError — surfaced, never silently dropped;
1298
+ * - code tracks without sidecar counterparts pass through untouched.
1299
+ * The input documents are not mutated.
1300
+ */
1301
+ function mergeSidecar(code, sidecar) {
1302
+ if (!sidecar) return code;
1303
+ if (sidecar.sidecarVersion !== 1) throw new SidecarVersionError(sidecar.sidecarVersion);
1304
+ const overlay = new Map(sidecar.tracks.map((t) => [t.target, t]));
1305
+ const tracks = code.tracks.map((t) => {
1306
+ const replacement = overlay.get(t.target);
1307
+ if (!replacement) return t;
1308
+ overlay.delete(t.target);
1309
+ return {
1310
+ ...t,
1311
+ keys: replacement.keys.map((k) => ({ ...k })),
1312
+ editable: true
1313
+ };
1314
+ });
1315
+ for (const added of overlay.values()) tracks.push({
1316
+ ...added,
1317
+ keys: added.keys.map((k) => ({ ...k })),
1318
+ editable: true
1319
+ });
1320
+ const merged = {
1321
+ ...code,
1322
+ tracks
1323
+ };
1324
+ if (sidecar.labels && Object.keys(sidecar.labels).length > 0) merged.labels = {
1325
+ ...code.labels,
1326
+ ...sidecar.labels
1327
+ };
1328
+ return merged;
1329
+ }
1330
+ //#endregion
1331
+ export { BakeError, CircularDependencyError, ColorParseError, DEFAULT_EASE, PositionError, SidecarVersionError, TARGET_PATH, TimelineValidationError, TrackValidationError, UnboundTargetError, UnknownEasingError, UnknownValueTypeError, UnresolvableTargetError, ValueTypeInferenceError, WriteDuringEvaluationError, bake, bakeCheckpointed, beginReadPhase, bindTimeline, booleanType, buildTimeline, colorType, compileTimeline, computed, createPlayhead, cubicBezier, easings, emptySidecar, endReadPhase, evaluateAt, formatColor, getTimelineCallbacks, getValueType, inReadPhase, inferValueType, key, lerpColor, mergeSidecar, namedEasing, normalizeEditedKeys, numberType, oklabToRgba, parseColor, random, registerValueType, resolveEase, resolveTweenTarget, rgbaToOklab, sampleTrack, setDevWarning, signal, spring, springEasing, stringType, timeline, track, untracked, validateTrack, vec2Equals, vec2Signal, vec2Type };