@glissade/core 0.7.0-pre.0 → 0.8.0-pre.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.d.ts CHANGED
@@ -72,6 +72,9 @@ interface ValueType<T> {
72
72
  scale?(a: T, k: number): T;
73
73
  /** Type-class handoff default (§B.1): spring for kinetic, cut for hold-only. */
74
74
  defaultHandoff?: HandoffKind;
75
+ /** Document (de)serialization; default identity for JSON-native types (§2.2). */
76
+ serialize?(value: T): unknown;
77
+ deserialize?(raw: unknown): T;
75
78
  }
76
79
  type ValueTypeId = 'number' | 'vec2' | 'color' | 'string' | 'boolean' | (string & {});
77
80
  declare function registerValueType<T>(vt: ValueType<T>): void;
@@ -82,6 +85,8 @@ declare function getValueType<T = unknown>(id: ValueTypeId): ValueType<T>;
82
85
  declare const numberType: ValueType<number>;
83
86
  declare const vec2Equals: (a: Vec2, b: Vec2) => boolean;
84
87
  declare const vec2Type: ValueType<Vec2>;
88
+ /** vec2 swept along a circular arc: polar lerp of radius + shortest-path angle (§2.2). */
89
+ declare const vec2ArcType: ValueType<Vec2>;
85
90
  declare const colorType: ValueType<string>;
86
91
  declare const stringType: ValueType<string>;
87
92
  declare const booleanType: ValueType<boolean>;
@@ -280,6 +285,8 @@ interface Key<T = unknown> {
280
285
  id?: string;
281
286
  /** Builder-resolved implicit from-value; re-resolved on merge (§2.6, §6.2). */
282
287
  derived?: boolean;
288
+ /** Reserved (§4.7): a v2 synthesized-transition leading key reads the live value. v1 accepts but ignores it. */
289
+ from?: 'live';
283
290
  }
284
291
  interface Track<T = unknown> {
285
292
  /** Canonical path: '<nodeId>/<prop.path>', e.g. 'circle/position.x'. */
@@ -289,6 +296,8 @@ interface Track<T = unknown> {
289
296
  keys: Key<T>[];
290
297
  /** Studio may own this track's keys via sidecar (§6.2). */
291
298
  editable?: boolean;
299
+ /** Reserved (§2.2/§B.6): v2 additive blending. v1 accepts but ignores it (coalesce stays last-wins). */
300
+ additive?: boolean;
292
301
  }
293
302
  declare class TrackValidationError extends Error {
294
303
  constructor(target: string, message: string);
@@ -477,6 +486,13 @@ interface TimelineBuilder {
477
486
  }): TimelineBuilder;
478
487
  /** Compiles to a marker; the callback is Player-registered, never serialized (§4.2). */
479
488
  call(fn: () => void, at?: Position): TimelineBuilder;
489
+ /** A named cue marker (serialized, fired on crossing) — the composer-signal substrate. */
490
+ cue(at: Position, name: string, data?: Json): TimelineBuilder;
491
+ /** An ad-break cue: a marker with `data.kind: 'ad-break'` + optional duration (§ad-break). */
492
+ adBreak(at: Position, opts?: {
493
+ id?: string;
494
+ duration?: number;
495
+ }): TimelineBuilder;
480
496
  /** Mark the preceding track editable for the studio (§6.2). */
481
497
  editable(): TimelineBuilder;
482
498
  }
@@ -571,36 +587,78 @@ interface CheckpointedSim {
571
587
  declare function bakeCheckpointed<W, S = W>(cfg: CheckpointedBakeConfig<W, S>): CheckpointedSim;
572
588
  //#endregion
573
589
  //#region src/sidecar.d.ts
590
+ type OrphanReason = 'node-missing' | 'prop-missing' | 'type-changed';
591
+ interface SidecarTrackEntry {
592
+ /** value type, so an editor-created track binds without a code baseline. */
593
+ type: ValueTypeId;
594
+ /** hash of the code baseline this track branched from; null = editor-created. */
595
+ baseHash: string | null;
596
+ keys: Key[];
597
+ }
598
+ interface SidecarOrphan {
599
+ type: ValueTypeId;
600
+ keys: Key[];
601
+ reason: OrphanReason;
602
+ }
603
+ interface SidecarTimelineEntry {
604
+ /** editor-owned tracks, keyed by canonical target. */
605
+ tracks: Record<string, SidecarTrackEntry>;
606
+ /** editor-created labels. */
607
+ labels?: Record<string, number>;
608
+ /** tracks parked off the merge because their target drifted (§6.2 rule 3). */
609
+ orphans?: Record<string, SidecarOrphan>;
610
+ }
574
611
  interface SidecarDoc {
612
+ sidecarVersion: 2;
613
+ /** edits namespaced by timeline id; the linear timeline is 'main'. */
614
+ timelines: Record<string, SidecarTimelineEntry>;
615
+ }
616
+ /** The flat v1 shape, accepted on load and migrated forward. */
617
+ interface SidecarDocV1 {
575
618
  sidecarVersion: 1;
576
- /** Editor-owned tracks, replacing same-target code tracks wholesale. */
577
619
  tracks: Track[];
578
- /** Editor-created labels; code labels are authoritative and win on a name collision (§6.2). */
579
620
  labels?: Record<string, number>;
580
621
  }
581
622
  declare class SidecarVersionError extends Error {
582
623
  constructor(version: unknown);
583
624
  }
584
625
  declare function emptySidecar(): SidecarDoc;
626
+ /** Lift a v1 (or already-v2) document to the v2 shape. Throws on unknown versions. */
627
+ declare function migrateSidecar(doc: SidecarDoc | SidecarDocV1 | null | undefined): SidecarDoc | null;
628
+ /** Stable hash of a code track's keys — the baseline an editor branch records. */
629
+ declare function hashKeys(keys: readonly Key[]): string;
630
+ /** Assign stable monotonic `k<N>` ids to keys lacking one; existing ids preserved. */
631
+ declare function assignKeyIds(keys: readonly Key[]): Key[];
632
+ /**
633
+ * Set one editable track's keys in the sidecar (§6.2) — the studio write path.
634
+ * `codeBaselineKeys` is the code track this branches from (null = editor-created
635
+ * with no baseline). Keys get stable `k<N>` ids. Returns a new document.
636
+ */
637
+ declare function setSidecarTrack(doc: SidecarDoc, timelineId: string, target: string, type: ValueTypeId, keys: Key[], codeBaselineKeys: readonly Key[] | null): SidecarDoc;
638
+ interface MergeResult {
639
+ timeline: Timeline;
640
+ /** targets whose code baseline changed beneath the editor's keys (§6.2 rule 2). */
641
+ drift: string[];
642
+ /** sidecar tracks parked off the merge (§6.2 rule 3). */
643
+ orphans: Record<string, SidecarOrphan>;
644
+ }
645
+ /**
646
+ * Merge with the full §6.2 report: the bindable Timeline, the list of targets
647
+ * whose code baseline drifted, and the orphaned tracks (type-changed, or whose
648
+ * code track vanished). Orphans are NEVER merged, so a drifted edit can't make
649
+ * the whole overlay fail to bind — the good edits survive. Inputs unmutated.
650
+ */
651
+ declare function mergeSidecarDetailed(code: Timeline, sidecar: SidecarDoc | SidecarDocV1 | null | undefined): MergeResult;
652
+ /** Merge the sidecar overlay onto the code baseline → a bindable Timeline (§6.2). */
653
+ declare function mergeSidecar(code: Timeline, sidecar: SidecarDoc | SidecarDocV1 | null | undefined): Timeline;
585
654
  /**
586
655
  * Editor-edit normalization (§2.7 invariant): a spring-eased key's t is
587
656
  * intrinsic — prev.t + spring.duration(cfg) — so after any retime, sort and
588
657
  * re-pin spring keys to their predecessors. Dragging a spring key itself
589
658
  * therefore snaps back; retiming its predecessor carries it along. Returns a
590
- * new array. Colliding keys are NUDGED apart (+1ms), never deleted — an
591
- * editor must not silently destroy keyframe data on an exact-t collision.
659
+ * new array. Colliding keys are NUDGED apart (+1ms), never deleted — an editor
660
+ * must not silently destroy keyframe data on an exact-t collision.
592
661
  */
593
662
  declare function normalizeEditedKeys(keys: Key[]): Key[];
594
- /**
595
- * Merge rules (§6.2, schema-drift resolution):
596
- * - a sidecar track whose target exists in code REPLACES that track's keys
597
- * (the editor owns it; `editable` is preserved on the result);
598
- * - a sidecar track with no code counterpart is ADDED (editor-created track);
599
- * - sidecar tracks targeting nothing the scene can bind fail later at
600
- * bindTimeline with UnboundTargetError — surfaced, never silently dropped;
601
- * - code tracks without sidecar counterparts pass through untouched.
602
- * The input documents are not mutated.
603
- */
604
- declare function mergeSidecar(code: Timeline, sidecar: SidecarDoc | null | undefined): Timeline;
605
663
  //#endregion
606
- 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 GainEnvelope, 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, audioOffsetSamples, 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, springPresets, springTo, stagger, stringType, timeline, track, untracked, validateTrack, vec2Equals, vec2Signal, vec2Type, velocityAt };
664
+ 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 GainEnvelope, type HandoffKind, type Json, type Key, type KeyOpts, type Marker, type MergeResult, type OkLab, type OrphanReason, type PathContour, type PathValue, type Playhead, type Position, PositionError, type ReadonlySignal, type RetargetSpring, type Rgba, type Rng, type SidecarDoc, type SidecarDocV1, type SidecarOrphan, type SidecarTimelineEntry, type SidecarTrackEntry, 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, assignKeyIds, audioOffsetSamples, bake, bakeCheckpointed, beginReadPhase, bindTimeline, booleanType, buildTimeline, colorType, compileTimeline, computed, createPlayhead, cubicBezier, cubicBezierDerivative, easingDerivatives, easings, emitDevWarning, emptySidecar, endReadPhase, evaluateAt, formatColor, getTimelineCallbacks, getValueType, hashKeys, inReadPhase, inferValueType, key, lerpColor, mergeSidecar, mergeSidecarDetailed, migrateSidecar, namedEasing, normalizeEditedKeys, numberType, oklabToRgba, parseColor, pathType, random, registerValueType, resolveEase, resolveEaseDerivative, resolveTweenTarget, rgbaToOklab, sampleTrack, setDevWarning, setSidecarTrack, signal, spring, springEasing, springEasingDerivative, springPresets, springTo, stagger, stringType, timeline, track, untracked, validateTrack, vec2ArcType, vec2Equals, vec2Signal, vec2Type, velocityAt };
package/dist/index.js CHANGED
@@ -341,6 +341,24 @@ const vec2Type = {
341
341
  scale: (a, k) => [a[0] * k, a[1] * k],
342
342
  defaultHandoff: "spring"
343
343
  };
344
+ /** vec2 swept along a circular arc: polar lerp of radius + shortest-path angle (§2.2). */
345
+ const vec2ArcType = {
346
+ id: "vec2-arc",
347
+ lerp: (a, b, t) => {
348
+ const ra = Math.hypot(a[0], a[1]);
349
+ const rb = Math.hypot(b[0], b[1]);
350
+ const angA = Math.atan2(a[1], a[0]);
351
+ let dAng = Math.atan2(b[1], b[0]) - angA;
352
+ while (dAng > Math.PI) dAng -= 2 * Math.PI;
353
+ while (dAng < -Math.PI) dAng += 2 * Math.PI;
354
+ const r = ra + (rb - ra) * t;
355
+ const ang = angA + dAng * t;
356
+ return [r * Math.cos(ang), r * Math.sin(ang)];
357
+ },
358
+ extrapolates: true,
359
+ equals: vec2Equals,
360
+ defaultHandoff: "blend-from-frozen"
361
+ };
344
362
  const colorType = {
345
363
  id: "color",
346
364
  lerp: lerpColor,
@@ -435,6 +453,7 @@ function inferValueType(value) {
435
453
  }
436
454
  registerValueType(numberType);
437
455
  registerValueType(vec2Type);
456
+ registerValueType(vec2ArcType);
438
457
  registerValueType(colorType);
439
458
  registerValueType(stringType);
440
459
  registerValueType(booleanType);
@@ -1365,6 +1384,29 @@ function buildTimeline(build, init = {}) {
1365
1384
  callbacks.set(name, fn);
1366
1385
  return builder;
1367
1386
  },
1387
+ cue(at, name, data) {
1388
+ markers.push(data !== void 0 ? {
1389
+ t: resolvePosition(at),
1390
+ name,
1391
+ data
1392
+ } : {
1393
+ t: resolvePosition(at),
1394
+ name
1395
+ });
1396
+ return builder;
1397
+ },
1398
+ adBreak(at, opts = {}) {
1399
+ const data = {
1400
+ kind: "ad-break",
1401
+ ...opts.duration !== void 0 ? { duration: opts.duration } : {}
1402
+ };
1403
+ markers.push({
1404
+ t: resolvePosition(at),
1405
+ name: opts.id ?? "ad-break",
1406
+ data
1407
+ });
1408
+ return builder;
1409
+ },
1368
1410
  editable() {
1369
1411
  const last = insertions[insertions.length - 1];
1370
1412
  if (!last) throw new TimelineValidationError(".editable() requires a preceding insertion");
@@ -1624,85 +1666,204 @@ function bakeCheckpointed(cfg) {
1624
1666
  //#region src/sidecar.ts
1625
1667
  /**
1626
1668
  * The editor sidecar (DESIGN.md §6.2): code declares scene structure and
1627
- * programmatic tracks; the studio owns keyframe data persisted as a sidecar
1628
- * document next to the scene module, merged at track granularity. Versioned
1629
- * independently of the API (§7.4) — breaking it orphans users' files.
1669
+ * programmatic tracks; the studio owns keyframe data persisted next to the
1670
+ * scene module and merged at track granularity. Versioned independently of the
1671
+ * API (§7.4) — breaking it orphans users' files, so v1 documents migrate.
1672
+ *
1673
+ * v2 namespaces edits by timeline id ('main' for the linear timeline; v2
1674
+ * machines add more), keys tracks by canonical target, records the code
1675
+ * baseline hash for drift detection, parks drifted tracks as `orphans`, and
1676
+ * gives keys stable `k<N>` ids.
1630
1677
  */
1678
+ const MAIN = "main";
1631
1679
  var SidecarVersionError = class extends Error {
1632
1680
  constructor(version) {
1633
- super(`unsupported sidecar version ${String(version)}; this build reads sidecarVersion 1`);
1681
+ super(`unsupported sidecar version ${String(version)}; this build reads sidecarVersion 1 or 2`);
1634
1682
  this.name = "SidecarVersionError";
1635
1683
  }
1636
1684
  };
1637
1685
  function emptySidecar() {
1638
1686
  return {
1639
- sidecarVersion: 1,
1640
- tracks: []
1687
+ sidecarVersion: 2,
1688
+ timelines: { [MAIN]: { tracks: {} } }
1641
1689
  };
1642
1690
  }
1691
+ /** Lift a v1 (or already-v2) document to the v2 shape. Throws on unknown versions. */
1692
+ function migrateSidecar(doc) {
1693
+ if (!doc) return null;
1694
+ if (doc.sidecarVersion === 2) return doc;
1695
+ if (doc.sidecarVersion === 1) {
1696
+ const tracks = {};
1697
+ for (const t of doc.tracks) tracks[t.target] = {
1698
+ type: t.type,
1699
+ baseHash: null,
1700
+ keys: t.keys
1701
+ };
1702
+ const main = { tracks };
1703
+ if (doc.labels) main.labels = doc.labels;
1704
+ return {
1705
+ sidecarVersion: 2,
1706
+ timelines: { [MAIN]: main }
1707
+ };
1708
+ }
1709
+ throw new SidecarVersionError(doc.sidecarVersion);
1710
+ }
1711
+ /** Stable hash of a code track's keys — the baseline an editor branch records. */
1712
+ function hashKeys(keys) {
1713
+ const s = JSON.stringify(keys.map((k) => [
1714
+ k.t,
1715
+ k.value,
1716
+ k.ease ?? null,
1717
+ k.interp ?? null
1718
+ ]));
1719
+ let h = 2166136261;
1720
+ for (let i = 0; i < s.length; i++) {
1721
+ h ^= s.charCodeAt(i);
1722
+ h = Math.imul(h, 16777619);
1723
+ }
1724
+ return (h >>> 0).toString(16);
1725
+ }
1726
+ /** Assign stable monotonic `k<N>` ids to keys lacking one; existing ids preserved. */
1727
+ function assignKeyIds(keys) {
1728
+ let max = -1;
1729
+ for (const k of keys) {
1730
+ const m = k.id ? /^k(\d+)$/.exec(k.id) : null;
1731
+ if (m) max = Math.max(max, Number(m[1]));
1732
+ }
1733
+ return keys.map((k) => k.id ? { ...k } : {
1734
+ ...k,
1735
+ id: `k${++max}`
1736
+ });
1737
+ }
1643
1738
  /**
1644
- * Editor-edit normalization (§2.7 invariant): a spring-eased key's t is
1645
- * intrinsic prev.t + spring.duration(cfg) so after any retime, sort and
1646
- * re-pin spring keys to their predecessors. Dragging a spring key itself
1647
- * therefore snaps back; retiming its predecessor carries it along. Returns a
1648
- * new array. Colliding keys are NUDGED apart (+1ms), never deleted — an
1649
- * editor must not silently destroy keyframe data on an exact-t collision.
1739
+ * Set one editable track's keys in the sidecar 6.2) the studio write path.
1740
+ * `codeBaselineKeys` is the code track this branches from (null = editor-created
1741
+ * with no baseline). Keys get stable `k<N>` ids. Returns a new document.
1650
1742
  */
1651
- function normalizeEditedKeys(keys) {
1652
- const out = keys.map((k) => ({ ...k })).sort((a, b) => a.t - b.t);
1653
- for (let pass = 0; pass < 2; pass++) {
1654
- for (let i = 1; i < out.length; i++) {
1655
- const ease = out[i].ease;
1656
- if (ease && typeof ease === "object" && ease.kind === "spring") out[i].t = out[i - 1].t + spring.duration(ease);
1743
+ function setSidecarTrack(doc, timelineId, target, type, keys, codeBaselineKeys) {
1744
+ const tl = doc.timelines[timelineId] ?? { tracks: {} };
1745
+ const entry = {
1746
+ type,
1747
+ baseHash: codeBaselineKeys ? hashKeys(codeBaselineKeys) : null,
1748
+ keys: assignKeyIds(keys)
1749
+ };
1750
+ return {
1751
+ ...doc,
1752
+ timelines: {
1753
+ ...doc.timelines,
1754
+ [timelineId]: {
1755
+ ...tl,
1756
+ tracks: {
1757
+ ...tl.tracks,
1758
+ [target]: entry
1759
+ }
1760
+ }
1657
1761
  }
1658
- out.sort((a, b) => a.t - b.t);
1659
- }
1660
- for (let i = 1; i < out.length; i++) if (out[i].t <= out[i - 1].t) out[i].t = out[i - 1].t + .001;
1661
- return out;
1762
+ };
1662
1763
  }
1663
1764
  /**
1664
- * Merge rules (§6.2, schema-drift resolution):
1665
- * - a sidecar track whose target exists in code REPLACES that track's keys
1666
- * (the editor owns it; `editable` is preserved on the result);
1667
- * - a sidecar track with no code counterpart is ADDED (editor-created track);
1668
- * - sidecar tracks targeting nothing the scene can bind fail later at
1669
- * bindTimeline with UnboundTargetError — surfaced, never silently dropped;
1670
- * - code tracks without sidecar counterparts pass through untouched.
1671
- * The input documents are not mutated.
1765
+ * Re-resolve `derived:true` leading keys against the merged track 2.6): a
1766
+ * derived from-key duplicates the preceding key's held value, so an upstream
1767
+ * edit must flow into it or the segment pops at its start. Build-time derived
1768
+ * keys are already correct; this fixes the ones an edit moved beneath.
1672
1769
  */
1673
- function mergeSidecar(code, sidecar) {
1674
- if (!sidecar) return code;
1675
- if (sidecar.sidecarVersion !== 1) throw new SidecarVersionError(sidecar.sidecarVersion);
1676
- const overlay = new Map(sidecar.tracks.map((t) => [t.target, t]));
1770
+ function reresolveDerived(keys) {
1771
+ if (!keys.some((k) => k.derived)) return keys;
1772
+ return keys.map((k, i) => k.derived && i > 0 ? {
1773
+ ...k,
1774
+ value: keys[i - 1].value
1775
+ } : k);
1776
+ }
1777
+ /**
1778
+ * Merge with the full §6.2 report: the bindable Timeline, the list of targets
1779
+ * whose code baseline drifted, and the orphaned tracks (type-changed, or whose
1780
+ * code track vanished). Orphans are NEVER merged, so a drifted edit can't make
1781
+ * the whole overlay fail to bind — the good edits survive. Inputs unmutated.
1782
+ */
1783
+ function mergeSidecarDetailed(code, sidecar) {
1784
+ const doc = migrateSidecar(sidecar);
1785
+ if (!doc) return {
1786
+ timeline: code,
1787
+ drift: [],
1788
+ orphans: {}
1789
+ };
1790
+ const main = doc.timelines[MAIN] ?? { tracks: {} };
1791
+ const overlay = new Map(Object.entries(main.tracks));
1792
+ const drift = [];
1793
+ const orphans = { ...main.orphans ?? {} };
1677
1794
  const tracks = code.tracks.map((t) => {
1678
- const replacement = overlay.get(t.target);
1679
- if (!replacement) return t;
1795
+ const entry = overlay.get(t.target);
1796
+ if (!entry) return t;
1680
1797
  overlay.delete(t.target);
1798
+ if (entry.type !== t.type) {
1799
+ orphans[t.target] = {
1800
+ type: entry.type,
1801
+ keys: entry.keys,
1802
+ reason: "type-changed"
1803
+ };
1804
+ return t;
1805
+ }
1806
+ if (entry.baseHash !== null && entry.baseHash !== hashKeys(t.keys)) drift.push(t.target);
1681
1807
  return {
1682
1808
  ...t,
1683
- keys: replacement.keys.map((k) => ({ ...k })),
1809
+ keys: reresolveDerived(entry.keys.map((k) => ({ ...k }))),
1684
1810
  editable: true
1685
1811
  };
1686
1812
  });
1687
- for (const added of overlay.values()) tracks.push({
1688
- ...added,
1689
- keys: added.keys.map((k) => ({ ...k })),
1813
+ for (const [target, entry] of overlay) if (entry.baseHash !== null) orphans[target] = {
1814
+ type: entry.type,
1815
+ keys: entry.keys,
1816
+ reason: "prop-missing"
1817
+ };
1818
+ else tracks.push({
1819
+ target,
1820
+ type: entry.type,
1821
+ keys: reresolveDerived(entry.keys.map((k) => ({ ...k }))),
1690
1822
  editable: true
1691
1823
  });
1692
1824
  const merged = {
1693
1825
  ...code,
1694
1826
  tracks
1695
1827
  };
1696
- if (sidecar.labels && Object.keys(sidecar.labels).length > 0) {
1828
+ if (main.labels && Object.keys(main.labels).length > 0) {
1697
1829
  const codeLabels = code.labels ?? {};
1698
- const shadowed = Object.keys(sidecar.labels).filter((n) => n in codeLabels);
1830
+ const shadowed = Object.keys(main.labels).filter((n) => n in codeLabels);
1699
1831
  if (shadowed.length) emitDevWarning(`sidecar label(s) ${shadowed.join(", ")} collide with code labels; code wins (§6.2)`);
1700
1832
  merged.labels = {
1701
- ...sidecar.labels,
1833
+ ...main.labels,
1702
1834
  ...codeLabels
1703
1835
  };
1704
1836
  }
1705
- return merged;
1837
+ if (drift.length) emitDevWarning(`sidecar: code baseline changed beneath edits on ${drift.join(", ")} (§6.2)`);
1838
+ return {
1839
+ timeline: merged,
1840
+ drift,
1841
+ orphans
1842
+ };
1843
+ }
1844
+ /** Merge the sidecar overlay onto the code baseline → a bindable Timeline (§6.2). */
1845
+ function mergeSidecar(code, sidecar) {
1846
+ return mergeSidecarDetailed(code, sidecar).timeline;
1847
+ }
1848
+ /**
1849
+ * Editor-edit normalization (§2.7 invariant): a spring-eased key's t is
1850
+ * intrinsic — prev.t + spring.duration(cfg) — so after any retime, sort and
1851
+ * re-pin spring keys to their predecessors. Dragging a spring key itself
1852
+ * therefore snaps back; retiming its predecessor carries it along. Returns a
1853
+ * new array. Colliding keys are NUDGED apart (+1ms), never deleted — an editor
1854
+ * must not silently destroy keyframe data on an exact-t collision.
1855
+ */
1856
+ function normalizeEditedKeys(keys) {
1857
+ const out = keys.map((k) => ({ ...k })).sort((a, b) => a.t - b.t);
1858
+ for (let pass = 0; pass < 2; pass++) {
1859
+ for (let i = 1; i < out.length; i++) {
1860
+ const ease = out[i].ease;
1861
+ if (ease && typeof ease === "object" && ease.kind === "spring") out[i].t = out[i - 1].t + spring.duration(ease);
1862
+ }
1863
+ out.sort((a, b) => a.t - b.t);
1864
+ }
1865
+ for (let i = 1; i < out.length; i++) if (out[i].t <= out[i - 1].t) out[i].t = out[i - 1].t + .001;
1866
+ return out;
1706
1867
  }
1707
1868
  //#endregion
1708
- export { BakeError, CircularDependencyError, ColorParseError, DEFAULT_EASE, PositionError, SidecarVersionError, TARGET_PATH, TimelineValidationError, TrackValidationError, UnboundTargetError, UnknownEasingError, UnknownValueTypeError, UnresolvableTargetError, ValueTypeInferenceError, WriteDuringEvaluationError, audioOffsetSamples, 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, springPresets, springTo, stagger, stringType, timeline, track, untracked, validateTrack, vec2Equals, vec2Signal, vec2Type, velocityAt };
1869
+ export { BakeError, CircularDependencyError, ColorParseError, DEFAULT_EASE, PositionError, SidecarVersionError, TARGET_PATH, TimelineValidationError, TrackValidationError, UnboundTargetError, UnknownEasingError, UnknownValueTypeError, UnresolvableTargetError, ValueTypeInferenceError, WriteDuringEvaluationError, assignKeyIds, audioOffsetSamples, bake, bakeCheckpointed, beginReadPhase, bindTimeline, booleanType, buildTimeline, colorType, compileTimeline, computed, createPlayhead, cubicBezier, cubicBezierDerivative, easingDerivatives, easings, emitDevWarning, emptySidecar, endReadPhase, evaluateAt, formatColor, getTimelineCallbacks, getValueType, hashKeys, inReadPhase, inferValueType, key, lerpColor, mergeSidecar, mergeSidecarDetailed, migrateSidecar, namedEasing, normalizeEditedKeys, numberType, oklabToRgba, parseColor, pathType, random, registerValueType, resolveEase, resolveEaseDerivative, resolveTweenTarget, rgbaToOklab, sampleTrack, setDevWarning, setSidecarTrack, signal, spring, springEasing, springEasingDerivative, springPresets, springTo, stagger, stringType, timeline, track, untracked, validateTrack, vec2ArcType, vec2Equals, vec2Signal, vec2Type, velocityAt };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/core",
3
- "version": "0.7.0-pre.0",
3
+ "version": "0.8.0-pre.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",