@glissade/core 0.1.0 → 0.3.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/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @glissade/core
2
+
3
+ The engine-agnostic heart: pull-based signals, the serializable keyframe **Timeline document**, the fluent builder that compiles to it, easing + closed-form springs, OKLab color, `bake()` for stateful simulation under seeking, and the v2 analytic layer (ease derivatives, `velocityAt`, `spring.retarget`). Zero DOM or Node dependencies; ≤ 11 kB gz.
4
+
5
+ ```sh
6
+ npm i @glissade/core
7
+ ```
8
+
9
+ ```ts
10
+ import { timeline, spring } from '@glissade/core';
11
+
12
+ const doc = timeline((tl) => {
13
+ tl.to('dot/opacity', 1, { duration: 0.5 })
14
+ .to('dot/position.x', 520, { ease: spring({ stiffness: 170, damping: 14 }) })
15
+ .label('arrived')
16
+ .to('dot/fill', '#7c4dff', { duration: 0.6, at: 'arrived' });
17
+ });
18
+ // `doc` is plain JSON: nothing executes at play time, so any t samples in O(log keys)
19
+ ```
20
+
21
+ One contract underneath everything: evaluation is a **pure function of time** — same inputs, same output, in any order.
22
+
23
+ ## Part of glissade
24
+
25
+ *(glide & slide)* — programmatic motion graphics for TypeScript: realtime-first in any web page, deterministic headless video export from the same code, a visual studio over the same document. No generator functions.
26
+
27
+ - [Repository & full README](https://github.com/tyevco/glissade)
28
+ - [Getting started](https://github.com/tyevco/glissade/blob/main/docs/getting-started.md) · [Concepts](https://github.com/tyevco/glissade/blob/main/docs/concepts.md) · [Interactivity](https://github.com/tyevco/glissade/blob/main/docs/interactivity.md)
29
+
30
+ Apache-2.0.
package/dist/index.d.ts CHANGED
@@ -49,12 +49,29 @@ declare function untracked<T>(fn: () => T): T;
49
49
  * (spring overshoot); non-extrapolating types clamp.
50
50
  */
51
51
  type Vec2 = readonly [number, number];
52
+ /** One bezier contour in Lottie's vertex form: anchor points + RELATIVE in/out tangents. */
53
+ interface PathContour {
54
+ closed: boolean;
55
+ v: Vec2[];
56
+ in: Vec2[];
57
+ out: Vec2[];
58
+ }
59
+ /** The 'path' document value (§2.2): plain JSON, serializes with no new hooks. */
60
+ type PathValue = PathContour[];
61
+ /** Transition handoff policies (v2 addendum §A.4/§B.1); 'crossfade' reserved. */
62
+ type HandoffKind = 'cut' | 'decay' | 'spring' | 'blend-from-frozen';
52
63
  interface ValueType<T> {
53
64
  id: string;
54
65
  lerp(a: T, b: T, t: number): T;
55
66
  /** Accepts easedT outside [0,1] (spring overshoot)? Otherwise clamped. */
56
67
  extrapolates: boolean;
57
68
  equals(a: T, b: T): boolean;
69
+ /** Optional linear-space operators (offset decay + reserved additive blending, §B.6). */
70
+ add?(a: T, b: T): T;
71
+ sub?(a: T, b: T): T;
72
+ scale?(a: T, k: number): T;
73
+ /** Type-class handoff default (§B.1): spring for kinetic, cut for hold-only. */
74
+ defaultHandoff?: HandoffKind;
58
75
  }
59
76
  type ValueTypeId = 'number' | 'vec2' | 'color' | 'string' | 'boolean' | (string & {});
60
77
  declare function registerValueType<T>(vt: ValueType<T>): void;
@@ -68,6 +85,15 @@ declare const vec2Type: ValueType<Vec2>;
68
85
  declare const colorType: ValueType<string>;
69
86
  declare const stringType: ValueType<string>;
70
87
  declare const booleanType: ValueType<boolean>;
88
+ /**
89
+ * Path morphing (§2.2): pairwise lerp of anchors and tangents — exactly how
90
+ * lottie-web morphs, so imported animations are pixel-faithful. Mismatched
91
+ * topology snaps (hold a, then b at t ≥ 1) with a one-time dev warning; the
92
+ * de Casteljau normalization fallback for arbitrary native morphs is tracked
93
+ * future work. Lerp-only: offsets are not well-defined under mismatched
94
+ * topology, so no add/sub/scale — handoffs blend from the frozen value.
95
+ */
96
+ declare const pathType: ValueType<PathValue>;
71
97
  declare class ValueTypeInferenceError extends Error {
72
98
  constructor(value: unknown);
73
99
  }
@@ -104,6 +130,12 @@ type EaseSpec = string | {
104
130
  mass: number;
105
131
  };
106
132
  declare const easings: Record<string, EasingFn>;
133
+ /**
134
+ * Analytic derivatives d(u) of every named ease (§B.6) — closed-form, used
135
+ * for reading velocity off in-flight curves at interruption time. Property-
136
+ * tested against central differences at interior points.
137
+ */
138
+ declare const easingDerivatives: Record<string, EasingFn>;
107
139
  /** Default property-tween ease (Motion Canvas precedent). */
108
140
  declare const DEFAULT_EASE = "easeInOutCubic";
109
141
  /**
@@ -111,6 +143,8 @@ declare const DEFAULT_EASE = "easeInOutCubic";
111
143
  * with a bisection fallback for the flat-derivative regions.
112
144
  */
113
145
  declare function cubicBezier(p1x: number, p1y: number, p2x: number, p2y: number): EasingFn;
146
+ /** Analytic dy/dx of a cubic bézier ease: y'(s)/x'(s) at the solved parameter (§B.6). */
147
+ declare function cubicBezierDerivative(p1x: number, p1y: number, p2x: number, p2y: number): EasingFn;
114
148
  declare class UnknownEasingError extends Error {
115
149
  constructor(name: string);
116
150
  }
@@ -144,10 +178,24 @@ declare function duration(cfg: SpringConfig, opts?: {
144
178
  declare function value(cfg: SpringConfig, t: number, opts?: {
145
179
  settleTolerance?: number;
146
180
  }): number;
181
+ /**
182
+ * Velocity-matched retarget oscillator (v2 addendum §B.3): the same damped
183
+ * harmonic oscillator on an OFFSET in value units with nonzero initial
184
+ * velocity — y(0)=x0, y'(0)=v0, decaying to 0. No affine rescale (the target
185
+ * is exactly 0). Pure closed forms; seek-safe at any τ.
186
+ */
187
+ interface RetargetSpring {
188
+ value(tau: number): number;
189
+ velocity(tau: number): number;
190
+ /** Earliest τ after which |value| stays within tol (default 0.005·|x0|+1e-6 floor). */
191
+ settleTime(tol?: number): number;
192
+ }
193
+ declare function retarget(cfg: SpringConfig, x0: number, v0: number): RetargetSpring;
147
194
  interface SpringFactory {
148
195
  (cfg: SpringConfig): SpringEase;
149
196
  duration: typeof duration;
150
197
  value: typeof value;
198
+ retarget: typeof retarget;
151
199
  }
152
200
  declare const spring: SpringFactory;
153
201
  /**
@@ -155,6 +203,12 @@ declare const spring: SpringFactory;
155
203
  * spring.duration(cfg) (validated at the document layer, §2.7).
156
204
  */
157
205
  declare function springEasing(cfg: SpringConfig): EasingFn;
206
+ /**
207
+ * Analytic d/dp of springEasing (§B.6): oscillator derivative × the affine
208
+ * rescale factor × duration (chain rule p → t = p·D). Flat past p=1,
209
+ * matching value()'s clamp (right-derivative convention).
210
+ */
211
+ declare function springEasingDerivative(cfg: SpringConfig): EasingFn;
158
212
  //#endregion
159
213
  //#region src/color.d.ts
160
214
  /**
@@ -215,9 +269,29 @@ declare function track<T>(target: string, type: ValueTypeId, keys: Key<T>[], opt
215
269
  editable?: boolean;
216
270
  }): Track<T>;
217
271
  declare function resolveEase(spec: EaseSpec | undefined): EasingFn;
272
+ /**
273
+ * Analytic d(u) for an ease spec (§B.6). Custom-registered eases without a
274
+ * derivative fall back to a symmetric difference with a one-time dev warning.
275
+ */
276
+ declare function resolveEaseDerivative(spec: EaseSpec | undefined): EasingFn;
277
+ /**
278
+ * Analytic track derivative at time t, in value-units per second of local
279
+ * track time (v2 addendum §B.3/§B.6 conventions, pinned):
280
+ * (a) at a key boundary, velocity is the RIGHT derivative;
281
+ * (b) hold segments and the clamped regions outside the keys have v = 0;
282
+ * (c) types without sub/scale operators return null (no kinetic velocity).
283
+ */
284
+ declare function velocityAt<T>(tr: Track<T>, t: number): T | null;
218
285
  /** Pure sample of a track at time t (§2.4). */
219
286
  declare function sampleTrack<T>(tr: Track<T>, t: number): T;
220
287
  //#endregion
288
+ //#region src/devWarning.d.ts
289
+ /** The configurable dev-warning channel (no DOM lib in core; console may not exist). */
290
+ type DevWarning = (message: string) => void;
291
+ declare function setDevWarning(fn: DevWarning): void;
292
+ /** Internal: emit through the configurable channel. */
293
+ declare function emitDevWarning(message: string): void;
294
+ //#endregion
221
295
  //#region src/timeline.d.ts
222
296
  type Json = null | boolean | number | string | Json[] | {
223
297
  [k: string]: Json;
@@ -293,10 +367,6 @@ interface CompiledTimeline {
293
367
  /** Audio clips rebased to the root time axis (§5.3); sync timeScale scales playbackRate. */
294
368
  audio: AudioClip[];
295
369
  }
296
- type DevWarning = (message: string) => void;
297
- declare function setDevWarning(fn: DevWarning): void;
298
- /** Internal: emit through the configurable dev-warning channel. */
299
-
300
370
  declare function compileTimeline(doc: Timeline): CompiledTimeline;
301
371
  //#endregion
302
372
  //#region src/targetRef.d.ts
@@ -365,8 +435,18 @@ interface BindTarget {
365
435
  bindSource(fn: () => unknown): void;
366
436
  unbindSource(): void;
367
437
  }
438
+ /** Analytic value/velocity access to one bound target (v2 addendum §B.6). */
439
+ interface CurveSampler {
440
+ readonly track: Track;
441
+ /** Pure sample at local timeline time t. */
442
+ value(t: number): unknown;
443
+ /** Analytic derivative per §B.3 conventions; null for types without operators. */
444
+ velocity(t: number): unknown | null;
445
+ }
368
446
  interface BoundTimeline {
369
447
  playhead: Playhead;
448
+ /** Per-target analytic samplers (additive, v2 §B.6); machines read these. */
449
+ samplers: ReadonlyMap<string, CurveSampler>;
370
450
  /** Detach every track binding, freezing signals at their last values. */
371
451
  unbind(): void;
372
452
  }
@@ -459,4 +539,4 @@ declare function normalizeEditedKeys(keys: Key[]): Key[];
459
539
  */
460
540
  declare function mergeSidecar(code: Timeline, sidecar: SidecarDoc | null | undefined): Timeline;
461
541
  //#endregion
462
- export { type AssetRef, type AudioClip, type BakeConfig, BakeError, type BindTarget, type BindableSignal, type BoundTimeline, type CheckpointedBakeConfig, type CheckpointedSim, type ChildEntry, CircularDependencyError, ColorParseError, type CompiledTimeline, DEFAULT_EASE, type DevWarning, type EaseSpec, type EasingFn, type Equals, type Json, type Key, type KeyOpts, type Marker, type OkLab, type Playhead, type Position, PositionError, type ReadonlySignal, type Rgba, type Rng, type SidecarDoc, SidecarVersionError, type Signal, type SignalOptions, type SpringConfig, type SpringEase, TARGET_PATH, type TargetCarrier, type Timeline, type TimelineBuilder, type TimelineInit, TimelineValidationError, type Track, TrackValidationError, type TweenOpts, type TweenTarget, UnboundTargetError, UnknownEasingError, UnknownValueTypeError, UnresolvableTargetError, type ValueType, type ValueTypeId, ValueTypeInferenceError, type Vec2, type Vec2Signal, 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 };
542
+ export { type AssetRef, type AudioClip, type BakeConfig, BakeError, type BindTarget, type BindableSignal, type BoundTimeline, type CheckpointedBakeConfig, type CheckpointedSim, type ChildEntry, CircularDependencyError, ColorParseError, type CompiledTimeline, type CurveSampler, DEFAULT_EASE, type DevWarning, type EaseSpec, type EasingFn, type Equals, type HandoffKind, type Json, type Key, type KeyOpts, type Marker, type OkLab, type PathContour, type PathValue, type Playhead, type Position, PositionError, type ReadonlySignal, type RetargetSpring, type Rgba, type Rng, type SidecarDoc, SidecarVersionError, type Signal, type SignalOptions, type SpringConfig, type SpringEase, TARGET_PATH, type TargetCarrier, type Timeline, type TimelineBuilder, type TimelineInit, TimelineValidationError, type Track, TrackValidationError, type TweenOpts, type TweenTarget, UnboundTargetError, UnknownEasingError, UnknownValueTypeError, UnresolvableTargetError, type ValueType, type ValueTypeId, ValueTypeInferenceError, type Vec2, type Vec2Signal, WriteDuringEvaluationError, bake, bakeCheckpointed, beginReadPhase, bindTimeline, booleanType, buildTimeline, colorType, compileTimeline, computed, createPlayhead, cubicBezier, cubicBezierDerivative, easingDerivatives, easings, emitDevWarning, emptySidecar, endReadPhase, evaluateAt, formatColor, getTimelineCallbacks, getValueType, inReadPhase, inferValueType, key, lerpColor, mergeSidecar, namedEasing, normalizeEditedKeys, numberType, oklabToRgba, parseColor, pathType, random, registerValueType, resolveEase, resolveEaseDerivative, resolveTweenTarget, rgbaToOklab, sampleTrack, setDevWarning, signal, spring, springEasing, springEasingDerivative, stringType, timeline, track, untracked, validateTrack, vec2Equals, vec2Signal, vec2Type, velocityAt };
package/dist/index.js CHANGED
@@ -287,6 +287,18 @@ function lerpColor(from, to, t) {
287
287
  }));
288
288
  }
289
289
  //#endregion
290
+ //#region src/devWarning.ts
291
+ let devWarn = (msg) => {
292
+ globalThis.console?.warn(`[glissade] ${msg}`);
293
+ };
294
+ function setDevWarning(fn) {
295
+ devWarn = fn;
296
+ }
297
+ /** Internal: emit through the configurable channel. */
298
+ function emitDevWarning(message) {
299
+ devWarn(message);
300
+ }
301
+ //#endregion
290
302
  //#region src/valueTypes.ts
291
303
  /**
292
304
  * Value-type registry with pluggable per-type interpolation (DESIGN.md §2.2).
@@ -312,20 +324,29 @@ const numberType = {
312
324
  id: "number",
313
325
  lerp: (a, b, t) => a + (b - a) * t,
314
326
  extrapolates: true,
315
- equals: Object.is
327
+ equals: Object.is,
328
+ add: (a, b) => a + b,
329
+ sub: (a, b) => a - b,
330
+ scale: (a, k) => a * k,
331
+ defaultHandoff: "spring"
316
332
  };
317
333
  const vec2Equals = (a, b) => a[0] === b[0] && a[1] === b[1];
318
334
  const vec2Type = {
319
335
  id: "vec2",
320
336
  lerp: (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t],
321
337
  extrapolates: true,
322
- equals: vec2Equals
338
+ equals: vec2Equals,
339
+ add: (a, b) => [a[0] + b[0], a[1] + b[1]],
340
+ sub: (a, b) => [a[0] - b[0], a[1] - b[1]],
341
+ scale: (a, k) => [a[0] * k, a[1] * k],
342
+ defaultHandoff: "spring"
323
343
  };
324
344
  const colorType = {
325
345
  id: "color",
326
346
  lerp: lerpColor,
327
347
  extrapolates: true,
328
- equals: (a, b) => a === b
348
+ equals: (a, b) => a === b,
349
+ defaultHandoff: "blend-from-frozen"
329
350
  };
330
351
  /** Discrete types: hold-only by construction (§2.2); lerp snaps at t=1. */
331
352
  function discrete(id) {
@@ -333,22 +354,77 @@ function discrete(id) {
333
354
  id,
334
355
  lerp: (a, b, t) => t >= 1 ? b : a,
335
356
  extrapolates: false,
336
- equals: (a, b) => Object.is(a, b)
357
+ equals: (a, b) => Object.is(a, b),
358
+ defaultHandoff: "cut"
337
359
  };
338
360
  }
339
361
  const stringType = discrete("string");
340
362
  const booleanType = discrete("boolean");
363
+ const lerpV = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
364
+ /** Topology must match for a morph (contour count, closed flags, vertex counts). */
365
+ function pathTopologyMatches(a, b) {
366
+ if (a.length !== b.length) return false;
367
+ for (let i = 0; i < a.length; i++) {
368
+ const ca = a[i];
369
+ const cb = b[i];
370
+ if (ca.closed !== cb.closed || ca.v.length !== cb.v.length) return false;
371
+ }
372
+ return true;
373
+ }
374
+ let warnedPathTopology = false;
375
+ /**
376
+ * Path morphing (§2.2): pairwise lerp of anchors and tangents — exactly how
377
+ * lottie-web morphs, so imported animations are pixel-faithful. Mismatched
378
+ * topology snaps (hold a, then b at t ≥ 1) with a one-time dev warning; the
379
+ * de Casteljau normalization fallback for arbitrary native morphs is tracked
380
+ * future work. Lerp-only: offsets are not well-defined under mismatched
381
+ * topology, so no add/sub/scale — handoffs blend from the frozen value.
382
+ */
383
+ const pathType = {
384
+ id: "path",
385
+ lerp: (a, b, t) => {
386
+ if (!pathTopologyMatches(a, b)) {
387
+ if (!warnedPathTopology) {
388
+ warnedPathTopology = true;
389
+ emitDevWarning("path lerp with mismatched topology (contour/vertex counts or closed flags differ): snapping instead of morphing — supply matched vertex counts (§2.2)");
390
+ }
391
+ return t >= 1 ? b : a;
392
+ }
393
+ return a.map((ca, i) => {
394
+ const cb = b[i];
395
+ return {
396
+ closed: ca.closed,
397
+ v: ca.v.map((p, j) => lerpV(p, cb.v[j], t)),
398
+ in: ca.in.map((p, j) => lerpV(p, cb.in[j], t)),
399
+ out: ca.out.map((p, j) => lerpV(p, cb.out[j], t))
400
+ };
401
+ });
402
+ },
403
+ extrapolates: false,
404
+ equals: (a, b) => {
405
+ if (a === b) return true;
406
+ if (!pathTopologyMatches(a, b)) return false;
407
+ const eq = (x, y) => x[0] === y[0] && x[1] === y[1];
408
+ return a.every((ca, i) => {
409
+ const cb = b[i];
410
+ 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]));
411
+ });
412
+ },
413
+ defaultHandoff: "blend-from-frozen"
414
+ };
341
415
  var ValueTypeInferenceError = class extends Error {
342
416
  constructor(value) {
343
417
  super(`cannot infer a value type for ${JSON.stringify(value)}; register a custom type`);
344
418
  this.name = "ValueTypeInferenceError";
345
419
  }
346
420
  };
421
+ const isContour = (c) => typeof c === "object" && c !== null && typeof c.closed === "boolean" && Array.isArray(c.v) && Array.isArray(c.in) && Array.isArray(c.out);
347
422
  /** Infer a registered type id from a sample value (builder + bake authoring surfaces). */
348
423
  function inferValueType(value) {
349
424
  if (typeof value === "number") return "number";
350
425
  if (typeof value === "boolean") return "boolean";
351
426
  if (Array.isArray(value) && value.length === 2 && value.every((v) => typeof v === "number")) return "vec2";
427
+ if (Array.isArray(value) && value.length > 0 && value.every(isContour)) return "path";
352
428
  if (typeof value === "string") try {
353
429
  parseColor(value);
354
430
  return "color";
@@ -362,6 +438,7 @@ registerValueType(vec2Type);
362
438
  registerValueType(colorType);
363
439
  registerValueType(stringType);
364
440
  registerValueType(booleanType);
441
+ registerValueType(pathType);
365
442
  //#endregion
366
443
  //#region src/vec2Signal.ts
367
444
  /**
@@ -456,13 +533,75 @@ const easings = {
456
533
  easeOutBounce: bounceOut,
457
534
  easeInOutBounce: (t) => t < .5 ? (1 - bounceOut(1 - 2 * t)) / 2 : (1 + bounceOut(2 * t - 1)) / 2
458
535
  };
459
- /** Default property-tween ease (Motion Canvas precedent). */
460
- const DEFAULT_EASE = "easeInOutCubic";
536
+ function bounceOutD(t) {
537
+ const n1 = 7.5625;
538
+ const d1 = 2.75;
539
+ if (t < 1 / d1) return 2 * n1 * t;
540
+ if (t < 2 / d1) return 2 * n1 * (t - 1.5 / d1);
541
+ if (t < 2.5 / d1) return 2 * n1 * (t - 2.25 / d1);
542
+ return 2 * n1 * (t - 2.625 / d1);
543
+ }
544
+ const LN2 = Math.LN2;
461
545
  /**
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.
546
+ * Analytic derivatives d(u) of every named ease (§B.6) closed-form, used
547
+ * for reading velocity off in-flight curves at interruption time. Property-
548
+ * tested against central differences at interior points.
464
549
  */
465
- function cubicBezier(p1x, p1y, p2x, p2y) {
550
+ const easingDerivatives = {
551
+ linear: () => 1,
552
+ easeInQuad: (t) => 2 * t,
553
+ easeOutQuad: (t) => 2 * (1 - t),
554
+ easeInOutQuad: (t) => t < .5 ? 4 * t : 4 * (1 - t),
555
+ easeInCubic: (t) => 3 * t ** 2,
556
+ easeOutCubic: (t) => 3 * (1 - t) ** 2,
557
+ easeInOutCubic: (t) => t < .5 ? 12 * t ** 2 : 12 * (1 - t) ** 2,
558
+ easeInQuart: (t) => 4 * t ** 3,
559
+ easeOutQuart: (t) => 4 * (1 - t) ** 3,
560
+ easeInOutQuart: (t) => t < .5 ? 32 * t ** 3 : 32 * (1 - t) ** 3,
561
+ easeInQuint: (t) => 5 * t ** 4,
562
+ easeOutQuint: (t) => 5 * (1 - t) ** 4,
563
+ easeInOutQuint: (t) => t < .5 ? 80 * t ** 4 : 80 * (1 - t) ** 4,
564
+ easeInSine: (t) => Math.PI / 2 * Math.sin(t * Math.PI / 2),
565
+ easeOutSine: (t) => Math.PI / 2 * Math.cos(t * Math.PI / 2),
566
+ easeInOutSine: (t) => Math.PI / 2 * Math.sin(Math.PI * t),
567
+ easeInExpo: (t) => t === 0 ? 0 : 10 * LN2 * 2 ** (10 * t - 10),
568
+ easeOutExpo: (t) => t === 1 ? 0 : 10 * LN2 * 2 ** (-10 * t),
569
+ easeInOutExpo: (t) => t === 0 || t === 1 ? 0 : t < .5 ? 10 * LN2 * 2 ** (20 * t - 10) : 10 * LN2 * 2 ** (-20 * t + 10),
570
+ easeInCirc: (t) => t / Math.sqrt(1 - t * t),
571
+ easeOutCirc: (t) => (1 - t) / Math.sqrt(1 - (t - 1) * (t - 1)),
572
+ easeInOutCirc: (t) => t < .5 ? 2 * t / Math.sqrt(1 - 4 * t * t) : (2 - 2 * t) / Math.sqrt(1 - (-2 * t + 2) ** 2),
573
+ easeInBack: (t) => 3 * c3 * t ** 2 - 2 * c1 * t,
574
+ easeOutBack: (t) => 3 * c3 * (t - 1) ** 2 + 2 * c1 * (t - 1),
575
+ easeInOutBack: (t) => t < .5 ? 12 * 3.5949095 * t ** 2 - 4 * c2 * t : 3 * 3.5949095 * (2 * t - 2) ** 2 + 2 * c2 * (2 * t - 2),
576
+ easeInElastic: (t) => {
577
+ if (t === 0 || t === 1) return 0;
578
+ const theta = (t * 10 - 10.75) * c4;
579
+ const amp = 2 ** (10 * t - 10);
580
+ return -(10 * LN2 * amp * Math.sin(theta) + amp * 10 * c4 * Math.cos(theta));
581
+ },
582
+ easeOutElastic: (t) => {
583
+ if (t === 0 || t === 1) return 0;
584
+ const phi = (t * 10 - .75) * c4;
585
+ const amp = 2 ** (-10 * t);
586
+ return -10 * LN2 * amp * Math.sin(phi) + amp * 10 * c4 * Math.cos(phi);
587
+ },
588
+ easeInOutElastic: (t) => {
589
+ if (t === 0 || t === 1) return 0;
590
+ const psi = (20 * t - 11.125) * c5;
591
+ if (t < .5) {
592
+ const amp = 2 ** (20 * t - 10);
593
+ return -(20 * LN2 * amp * Math.sin(psi) + amp * 20 * c5 * Math.cos(psi)) / 2;
594
+ }
595
+ const amp = 2 ** (-20 * t + 10);
596
+ return (-20 * LN2 * amp * Math.sin(psi) + amp * 20 * c5 * Math.cos(psi)) / 2;
597
+ },
598
+ easeInBounce: (t) => bounceOutD(1 - t),
599
+ easeOutBounce: bounceOutD,
600
+ easeInOutBounce: (t) => t < .5 ? bounceOutD(1 - 2 * t) : bounceOutD(2 * t - 1)
601
+ };
602
+ /** Default property-tween ease (Motion Canvas precedent). */
603
+ const DEFAULT_EASE = "easeInOutCubic";
604
+ function bezierKernel(p1x, p1y, p2x, p2y) {
466
605
  const ax = 3 * p1x - 3 * p2x + 1;
467
606
  const bx = 3 * p2x - 6 * p1x;
468
607
  const cx = 3 * p1x;
@@ -472,6 +611,7 @@ function cubicBezier(p1x, p1y, p2x, p2y) {
472
611
  const sampleX = (u) => ((ax * u + bx) * u + cx) * u;
473
612
  const sampleY = (u) => ((ay * u + by) * u + cy) * u;
474
613
  const sampleDX = (u) => (3 * ax * u + 2 * bx) * u + cx;
614
+ const sampleDY = (u) => (3 * ay * u + 2 * by) * u + cy;
475
615
  const solveU = (x) => {
476
616
  let u = x;
477
617
  for (let i = 0; i < 8; i++) {
@@ -491,10 +631,33 @@ function cubicBezier(p1x, p1y, p2x, p2y) {
491
631
  }
492
632
  return u;
493
633
  };
634
+ return {
635
+ sampleY,
636
+ sampleDX,
637
+ sampleDY,
638
+ solveU
639
+ };
640
+ }
641
+ /**
642
+ * CSS-style cubic bézier where x is time and y is progress. Newton's method
643
+ * with a bisection fallback for the flat-derivative regions.
644
+ */
645
+ function cubicBezier(p1x, p1y, p2x, p2y) {
646
+ const k = bezierKernel(p1x, p1y, p2x, p2y);
494
647
  return (t) => {
495
648
  if (t <= 0) return 0;
496
649
  if (t >= 1) return 1;
497
- return sampleY(solveU(t));
650
+ return k.sampleY(k.solveU(t));
651
+ };
652
+ }
653
+ /** Analytic dy/dx of a cubic bézier ease: y'(s)/x'(s) at the solved parameter (§B.6). */
654
+ function cubicBezierDerivative(p1x, p1y, p2x, p2y) {
655
+ const k = bezierKernel(p1x, p1y, p2x, p2y);
656
+ return (t) => {
657
+ const u = k.solveU(Math.min(1, Math.max(0, t)));
658
+ const dx = k.sampleDX(u);
659
+ if (Math.abs(dx) < 1e-9) return 0;
660
+ return k.sampleDY(u) / dx;
498
661
  };
499
662
  }
500
663
  var UnknownEasingError = class extends Error {
@@ -519,6 +682,20 @@ function params(cfg) {
519
682
  zeta: cfg.damping / (2 * Math.sqrt(cfg.stiffness * mass))
520
683
  };
521
684
  }
685
+ /** Raw closed-form spring velocity at time t — d/dt of rawValue's three branches. */
686
+ function rawDerivative(cfg, t) {
687
+ if (t <= 0) return 0;
688
+ const { w0, zeta } = params(cfg);
689
+ if (Math.abs(zeta - 1) < 1e-9) return w0 * w0 * t * Math.exp(-w0 * t);
690
+ if (zeta < 1) {
691
+ const wd = w0 * Math.sqrt(1 - zeta * zeta);
692
+ return Math.exp(-zeta * w0 * t) * (w0 * w0 / wd) * Math.sin(wd * t);
693
+ }
694
+ const s = Math.sqrt(zeta * zeta - 1);
695
+ const r1 = -w0 * (zeta - s);
696
+ const r2 = -w0 * (zeta + s);
697
+ return r1 * r2 * (Math.exp(r1 * t) - Math.exp(r2 * t)) / (r1 - r2);
698
+ }
522
699
  /** Raw closed-form spring position at time t (seconds). Approaches 1, may overshoot. */
523
700
  function rawValue(cfg, t) {
524
701
  if (t <= 0) return 0;
@@ -565,6 +742,53 @@ function value(cfg, t, opts) {
565
742
  const d = duration(cfg, opts);
566
743
  return rawValue(cfg, Math.min(t, d)) / rawValue(cfg, d);
567
744
  }
745
+ function retarget(cfg, x0, v0) {
746
+ const { w0, zeta } = params(cfg);
747
+ let value0;
748
+ let velocity0;
749
+ let envelope;
750
+ if (Math.abs(zeta - 1) < 1e-9) {
751
+ const b = v0 + w0 * x0;
752
+ value0 = (tau) => tau <= 0 ? x0 : Math.exp(-w0 * tau) * (x0 + b * tau);
753
+ velocity0 = (tau) => tau <= 0 ? v0 : Math.exp(-w0 * tau) * (v0 - w0 * b * tau);
754
+ envelope = (tau) => Math.exp(-w0 * tau) * (Math.abs(x0) + Math.abs(b) * tau);
755
+ } else if (zeta < 1) {
756
+ const wd = w0 * Math.sqrt(1 - zeta * zeta);
757
+ const c2v = (v0 + zeta * w0 * x0) / wd;
758
+ const amp = Math.hypot(x0, c2v);
759
+ value0 = (tau) => tau <= 0 ? x0 : Math.exp(-zeta * w0 * tau) * (x0 * Math.cos(wd * tau) + c2v * Math.sin(wd * tau));
760
+ 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));
761
+ envelope = (tau) => Math.exp(-zeta * w0 * tau) * amp;
762
+ } else {
763
+ const s = Math.sqrt(zeta * zeta - 1);
764
+ const rp = w0 * (-zeta + s);
765
+ const rm = w0 * (-zeta - s);
766
+ const cp = (v0 - rm * x0) / (rp - rm);
767
+ const cm = (rp * x0 - v0) / (rp - rm);
768
+ value0 = (tau) => tau <= 0 ? x0 : cp * Math.exp(rp * tau) + cm * Math.exp(rm * tau);
769
+ velocity0 = (tau) => tau <= 0 ? v0 : cp * rp * Math.exp(rp * tau) + cm * rm * Math.exp(rm * tau);
770
+ envelope = (tau) => Math.abs(cp) * Math.exp(rp * tau) + Math.abs(cm) * Math.exp(rm * tau);
771
+ }
772
+ const settleTime = (tol) => {
773
+ const eps = tol ?? Math.abs(x0) * .005 + 1e-6;
774
+ if (envelope(0) <= eps && Math.abs(v0) < 1e-12) return 0;
775
+ let hi = 1 / w0;
776
+ let guard = 0;
777
+ while (envelope(hi) > eps && guard++ < 64) hi *= 2;
778
+ let lo = 0;
779
+ for (let i = 0; i < 64 && hi - lo > 1e-9; i++) {
780
+ const mid = (lo + hi) / 2;
781
+ if (Math.max(envelope(mid), envelope(mid * 1.05 + 1e-6)) > eps) lo = mid;
782
+ else hi = mid;
783
+ }
784
+ return hi;
785
+ };
786
+ return {
787
+ value: value0,
788
+ velocity: velocity0,
789
+ settleTime
790
+ };
791
+ }
568
792
  const spring = Object.assign((cfg) => {
569
793
  params(cfg);
570
794
  return {
@@ -575,7 +799,8 @@ const spring = Object.assign((cfg) => {
575
799
  };
576
800
  }, {
577
801
  duration,
578
- value
802
+ value,
803
+ retarget
579
804
  });
580
805
  /**
581
806
  * The spring as a normalized easing over a segment whose length must equal
@@ -585,6 +810,16 @@ function springEasing(cfg) {
585
810
  const d = duration(cfg);
586
811
  return (p) => value(cfg, p * d);
587
812
  }
813
+ /**
814
+ * Analytic d/dp of springEasing (§B.6): oscillator derivative × the affine
815
+ * rescale factor × duration (chain rule p → t = p·D). Flat past p=1,
816
+ * matching value()'s clamp (right-derivative convention).
817
+ */
818
+ function springEasingDerivative(cfg) {
819
+ const d = duration(cfg);
820
+ const scale = d / rawValue(cfg, d);
821
+ return (p) => p >= 1 ? 0 : rawDerivative(cfg, p * d) * scale;
822
+ }
588
823
  //#endregion
589
824
  //#region src/track.ts
590
825
  /**
@@ -645,6 +880,27 @@ function resolveEase(spec) {
645
880
  if (spec.kind === "cubicBezier") return cubicBezier(...spec.pts);
646
881
  return springEasing(spec);
647
882
  }
883
+ const warnedNumericDerivative = /* @__PURE__ */ new Set();
884
+ /**
885
+ * Analytic d(u) for an ease spec (§B.6). Custom-registered eases without a
886
+ * derivative fall back to a symmetric difference with a one-time dev warning.
887
+ */
888
+ function resolveEaseDerivative(spec) {
889
+ if (spec === void 0) return easingDerivatives["linear"];
890
+ if (typeof spec === "string") {
891
+ const d = easingDerivatives[spec];
892
+ if (d) return d;
893
+ const fn = namedEasing(spec);
894
+ if (!warnedNumericDerivative.has(spec)) {
895
+ warnedNumericDerivative.add(spec);
896
+ emitDevWarning(`easing '${spec}' has no registered derivative; velocity uses a numeric fallback — register one in easingDerivatives for exact interruption handoff`);
897
+ }
898
+ const h = 1e-5;
899
+ return (u) => (fn(Math.min(1, u + h)) - fn(Math.max(0, u - h))) / (Math.min(1, u + h) - Math.max(0, u - h));
900
+ }
901
+ if (spec.kind === "cubicBezier") return cubicBezierDerivative(...spec.pts);
902
+ return springEasingDerivative(spec);
903
+ }
648
904
  const samplerStates = /* @__PURE__ */ new WeakMap();
649
905
  function state(tr) {
650
906
  let s = samplerStates.get(tr);
@@ -684,6 +940,34 @@ function findSegment(keys, t, hint) {
684
940
  }
685
941
  return lo;
686
942
  }
943
+ /**
944
+ * Analytic track derivative at time t, in value-units per second of local
945
+ * track time (v2 addendum §B.3/§B.6 conventions, pinned):
946
+ * (a) at a key boundary, velocity is the RIGHT derivative;
947
+ * (b) hold segments and the clamped regions outside the keys have v = 0;
948
+ * (c) types without sub/scale operators return null (no kinetic velocity).
949
+ */
950
+ function velocityAt(tr, t) {
951
+ const vt = getValueType(tr.type);
952
+ if (!vt.sub || !vt.scale) return null;
953
+ const keys = tr.keys;
954
+ const n = keys.length;
955
+ const s = state(tr);
956
+ const i = findSegment(keys, t, s.cursor);
957
+ const zero = vt.scale(vt.sub(keys[0].value, keys[0].value), 0);
958
+ if (i === 0 || i >= n) return zero;
959
+ const arrival = keys[i];
960
+ if (arrival.interp === "hold") return zero;
961
+ const prev = keys[i - 1];
962
+ const segDur = arrival.t - prev.t;
963
+ const p = (t - prev.t) / segDur;
964
+ if (!vt.extrapolates) {
965
+ const eased = easeFor(tr, s, i)(p);
966
+ if (eased < 0 || eased > 1) return zero;
967
+ }
968
+ const d = resolveEaseDerivative(arrival.ease)(p);
969
+ return vt.scale(vt.sub(arrival.value, prev.value), d / segDur);
970
+ }
687
971
  /** Pure sample of a track at time t (§2.4). */
688
972
  function sampleTrack(tr, t) {
689
973
  const keys = tr.keys;
@@ -740,16 +1024,6 @@ function validateSpringKeys(tr) {
740
1024
  }
741
1025
  }
742
1026
  }
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
1027
  function rebaseKeys(keys, at, timeScale) {
754
1028
  return keys.map((k) => ({
755
1029
  ...k,
@@ -797,7 +1071,7 @@ function coalesce(entries) {
797
1071
  const existingStart = existing.keys[0].t;
798
1072
  const existingEnd = existing.keys[existing.keys.length - 1].t;
799
1073
  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)`);
1074
+ if (existingStart <= end && start <= existingEnd) emitDevWarning(`overlapping tracks for '${tr.target}' in [${start}, ${end}]: later insertion wins (${existing.keys.length - kept.length} earlier key(s) dropped)`);
801
1075
  existing.keys = [...kept, ...tr.keys].sort((a, b) => a.t - b.t);
802
1076
  }
803
1077
  return byTarget;
@@ -1103,14 +1377,21 @@ var UnboundTargetError = class extends Error {
1103
1377
  */
1104
1378
  function bindTimeline(compiled, resolve, playhead = createPlayhead()) {
1105
1379
  const bound = [];
1380
+ const samplers = /* @__PURE__ */ new Map();
1106
1381
  for (const [target, tr] of compiled.tracks) {
1107
1382
  const sig = resolve(target);
1108
1383
  if (!sig) throw new UnboundTargetError(target);
1109
1384
  sig.bindSource(() => sampleTrack(tr, playhead()));
1110
1385
  bound.push(sig);
1386
+ samplers.set(target, {
1387
+ track: tr,
1388
+ value: (t) => sampleTrack(tr, t),
1389
+ velocity: (t) => velocityAt(tr, t)
1390
+ });
1111
1391
  }
1112
1392
  return {
1113
1393
  playhead,
1394
+ samplers,
1114
1395
  unbind: () => {
1115
1396
  for (const sig of bound) sig.unbindSource();
1116
1397
  }
@@ -1328,4 +1609,4 @@ function mergeSidecar(code, sidecar) {
1328
1609
  return merged;
1329
1610
  }
1330
1611
  //#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 };
1612
+ 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, cubicBezierDerivative, easingDerivatives, easings, emitDevWarning, emptySidecar, endReadPhase, evaluateAt, formatColor, getTimelineCallbacks, getValueType, inReadPhase, inferValueType, key, lerpColor, mergeSidecar, namedEasing, normalizeEditedKeys, numberType, oklabToRgba, parseColor, pathType, random, registerValueType, resolveEase, resolveEaseDerivative, resolveTweenTarget, rgbaToOklab, sampleTrack, setDevWarning, signal, spring, springEasing, springEasingDerivative, stringType, timeline, track, untracked, validateTrack, vec2Equals, vec2Signal, vec2Type, velocityAt };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/core",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "glissade core: signals, tracks, timeline document, evaluation, easing, springs, seeded RNG. Zero DOM/Node dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",