@idle-screens/schema 3.9.1 → 3.10.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/FORMAT.md CHANGED
@@ -42,7 +42,10 @@ the `fade` transition, `role` on text sprites, and `text: 'crossfade'` on
42
42
  `morph` (2026-09-08 — ambient presentations); `background: { type: 'field' }`
43
43
  (seeded, warped noise quantised into bands), the static per-entity
44
44
  `rotate`, and the print `finish` (grain + dither screen, spec- and
45
- sequence-level) (2026-09-10 — the print look).
45
+ sequence-level) (2026-09-10 — the print look); `timeline` (keyframes on the
46
+ scene's own clock), paint-level layer `opacity` and `transform`,
47
+ `position.dx` / `dy`, `wrapMorph` on the sequence envelope and
48
+ `text: 'dip'` on `morph` (2026-09-25 — timed scenes).
46
49
 
47
50
  ## Safety invariants
48
51
 
@@ -107,7 +110,8 @@ must exist, have `count: 1`, and not themselves orbit a layer.
107
110
  "ghosting": 0.9, // optional 0..0.95; frame-persistence smear
108
111
  "finish": { "grain": 0.5, "dither": 0.3 }, // optional; print screen over the finished frame
109
112
  "background": { ... }, // optional; defaults to black
110
- "layers": [ { ... }, ... ] // 1..36, rendered back-to-front
113
+ "layers": [ { ... }, ... ], // 1..36, rendered back-to-front
114
+ "timeline": { ... } // optional; authored keyframes — see **Timeline**
111
115
  }
112
116
  ```
113
117
 
@@ -283,7 +287,9 @@ own `finish` is not steerable; steer a segment's, or republish.
283
287
  | `emit` | `{every ≥ 1000, life ≥ 500, jitter?, grow?}` (`life ≤ every`) | always lit | **sparse events**: each entity is dark except a `life`-ms window every `every` ms, fading in fast and out slow; `jitter` 0 staggers entities evenly (one event at a time while `life ≤ every / count`), 1 scatters the offsets (a fixed sequence, not seeded — declaring `emit` disturbs no other draw); `grow: [from, to]` scales size across the window — expansion rather than travel |
284
288
  | `clock` | `{phase?, rate?}` | seeded phases | **phase-lock**: `pulse`, `grow` and `cycle` share one phase (turns, 0..1) and run at `rate` × time; two layers with the same clock breathe in step. Clocked periods must satisfy `period / rate ≥ 1000` |
285
289
  | `key` | string | none | addressable name → `setParam("key.count", …)` for a layer field, `setParam("key.sprite.color", …)` for a sprite field (the path mirrors the JSON: sprite fields sit under `sprite`) |
286
- | `position` | `{x, y}` 0..1 | none | exact placement; **requires `count: 1`** — except with a `list`/`table` layout, where it anchors the block's top-left; overrides `region` |
290
+ | `position` | `{x, y, dx?, dy?}` — `x`/`y` 0..1, `dx`/`dy` ±2 (±17280 px under `units: "px"`) | none | exact placement; **requires `count: 1`** — except with a `list`/`table` layout, where it anchors the block's top-left; overrides `region`. `dx`/`dy` offset the point in **`min(w, h)` units** (px under `units: "px"`): `x`/`y` are fractions of width and height while every size is a fraction of `min(w, h)`, so parts placed at computed fractions only register at the aspect they were computed for — anchor a compound form's parts on one shared fraction (the centre is the only aspect-safe one) and offset each with `dx`/`dy`. Placement (structural). **Native:** tvOS ignores the offset |
291
+ | `opacity` | number 0..1 | `1` | **paint-level** layer opacity: multiplies every entity's `alpha` and the `life` envelope at draw time. Unlike `alpha` (a range baked into each entity), it is outside the structural signature, so `setParam`, a `timeline` key or a `morph` **glides** it instead of re-seeding the layer. `0` skips the layer. **Native:** ignored ⇒ 1 |
292
+ | `transform` | `{x?, y?, scale?, scaleX?, rotate?}` | identity | **paint-level** transform of the whole layer about the viewport centre: translate `x`/`y` (±2 in `min(w, h)` units; px under `units: "px"`, ±17280), `rotate` (degrees, ±3600), `scale` (0..8) and `scaleX` (±8, × `scale` horizontally — toward 0 reads as a turn about the vertical axis, negative mirrors). Outside the structural signature: a camera for a timed piece, a slow drift for an ambient one, and it glides under steering. Sprites are not re-rasterised, so a large `scale` enlarges pixels of soft sprites, not detail. **Native:** ignored ⇒ identity |
287
293
 
288
294
  `links`: `{ k: 1..8, maxDist, color?, alpha?, width?, mode?, falloff?, closed? }`.
289
295
  `mode: "nearest"` (default) wires each entity to its k nearest neighbors within
@@ -663,6 +669,91 @@ control-track (`step` | `linear` | `smooth`). Placement/motion changes trigger
663
669
  a deterministic rebuild (same seed → same stream). See `@idle-screens/core`
664
670
  for `ControlTrack` and the idlescreens.com MCP `setParam` tool.
665
671
 
672
+ Paths that are **paint** (colours, `alpha` inside a sprite's paint, layer
673
+ `opacity` / `transform`, `textBlock.opacity`, `reveal.progress`, polygon
674
+ `points`) glide; paths that are **structure** (`count`, `position`, `motion`,
675
+ `size`, `layer.alpha`, …) rebuild — a deterministic re-seed, which reads as a
676
+ pop. Prefer `opacity` over `alpha` and `transform` over `position` for
677
+ anything that should move smoothly.
678
+
679
+ ## Timeline — keyframes on the scene's clock
680
+
681
+ A SaverSpec can carry authored change on its **own** clock — the same clock
682
+ its drift, orbit and pulse run on (from mount; a sequence segment's `localT`):
683
+
684
+ ```jsonc
685
+ "timeline": {
686
+ "loop": true, // optional; repeat every `duration` ms
687
+ "duration": 16000, // required with loop; > the latest key's t
688
+ "keys": [ // 1..256
689
+ { "t": 400, "path": "sun.transform.y", "value": 0, "dur": 5200 },
690
+ { "t": 6000, "path": "title.opacity", "value": 1, "dur": 1400, "ease": "linear" },
691
+ { "t": 11000, "path": "title.opacity", "value": 0, "dur": 1200 }
692
+ ]
693
+ }
694
+ ```
695
+
696
+ - A key glides `path` from wherever it is at `t` to `value` over `dur` ms
697
+ (default 1000, the live-steer default; `0` is a cut), eased `smooth`
698
+ (default), `linear` or `step` (holds, then switches at the glide's end).
699
+ Keys address the same dot-paths as `setParam` — index or `key` form — and a
700
+ path must already exist on the spec (a key cannot add a field). The spec must
701
+ still validate with each key's value applied.
702
+ - Overlapping keys on one path chain: the later glide starts from the earlier
703
+ one's in-flight value.
704
+ - Without `loop`, a path holds the base spec's value before its first key and
705
+ its last key's value after it. With `loop`, the value depends only on
706
+ `t mod duration`, and **before a path's first key it has its last key's
707
+ value** — each lap is one closed cycle, lap 7 looks exactly like lap 1.
708
+ - **Flash safety:** distinct key times on one path sit ≥ 200 ms apart
709
+ (across the loop wrap too). Keys on different paths may share a beat.
710
+ Keys are also checked **together**: two keys that are each valid can
711
+ combine past a floor (a `clock.rate` and a `pulse.period` share one), so
712
+ the validator resolves the scene at every key's start, end and glide
713
+ quarter-points and rejects it there.
714
+ - A key on a **structural** path rebuilds the scene at that key — a pop, and
715
+ the validator warns `timeline-structural-key`. Animate paint: colours,
716
+ `opacity`, `transform`, polygon `points` (a whole equal-length array
717
+ lerps point by point), `reveal.progress`. A glide that runs past a loop's
718
+ lap warns `timeline-glide-overruns-lap`.
719
+ - A timeline is **frame-addressable**: the scene at `t` is a pure function of
720
+ `(spec, t)`, so seeks, ghosting warm-ups and perception all resolve it at
721
+ their own time. `resolveTimelineAt(spec, t)` returns the plain spec at `t`
722
+ (the same object when there is no timeline).
723
+
724
+ **Steering a scene with a timeline** — live steering layers on top, and the
725
+ rule falls out of the scene itself, no mode flag:
726
+
727
+ - a steer on a path **no key touches** is a sticky override — exactly today's
728
+ behaviour on an ambient scene;
729
+ - a steer on a path the **timeline animates** holds until that path's next
730
+ key, then glides back to the timeline over that key's `dur` — from
731
+ wherever the steer's own glide had reached, if the key arrives mid-glide.
732
+
733
+ A steer is rejected, as on any scene, when the scene it makes is invalid —
734
+ judged with every live steer applied, now and at each key still ahead (a
735
+ sticky steer outlives the keys).
736
+
737
+ A channel's control track is not a timeline: its `t` is a wall-clock stamp the
738
+ server writes, so it is never read as a schedule. Authored time lives here.
739
+
740
+ Each steer is applied once per server stamp, so a host re-sending the whole
741
+ track (idle-server does, on every steer) never re-arms a hold the timeline has
742
+ taken back. A viewer that mounts later replays the stored track at its own
743
+ mount: a steer on an animated path shows there until that path's next key
744
+ after the mount, while screens that saw it live have already moved on. Until
745
+ viewers share a clock (`sync: 'epoch'`, still unwired on the site), screens
746
+ mounted at different times can briefly disagree on such a path.
747
+
748
+ **One format for both use cases.** An ambient scene is a SaverSpec without a
749
+ timeline — nothing about it changes. A timed piece (an ident, a film, a
750
+ pre-roll) is a scene whose evolution is a timeline, and a sequence only where
751
+ it genuinely cuts to a different set of layers.
752
+
753
+ **Native:** tvOS ignores `timeline` and shows the base spec — so **author the
754
+ base as the piece's rest state** (its end card), and a timed piece degrades
755
+ to a valid ambient scene.
756
+
666
757
  ## Seeing without eyes — the perception API
667
758
 
668
759
  `src/perceive.ts` translates a spec into modalities a **non-vision agent** can
@@ -677,6 +768,11 @@ simplified (`screen` ≈ `lighter`), wrapped link segments use straight
677
768
  interpolation, and background drift is sampled at rest. These are documented
678
769
  trade-offs for a zero-dependency, renderer-free analysis tool.
679
770
 
771
+ A `timeline` is resolved at the sample time `t` before anything is measured,
772
+ so every output below describes the scene as it is at that moment of the
773
+ piece. Layer `opacity` scales a layer's ink and weight; a layer `transform`
774
+ moves and scales its positions and sizes (sprite rotation is not modelled).
775
+
680
776
  - `perceiveScene(spec, {t?, viewport?, seed?})` — one-call bundle: everything below.
681
777
  **`t` is in milliseconds** (the MCP `previewScene` tool takes seconds and
682
778
  converts; passing seconds here reads a self-typing block as one that never
@@ -797,7 +893,7 @@ the presenter never freezes. A durationless final segment holds regardless of
797
893
  `advance`.
798
894
 
799
895
  **Transitions:** `{ type: 'cut' }` (default) performs a hard switch.
800
- `{ type: 'morph', dur: number, text?: 'step' | 'crossfade' }` interpolates
896
+ `{ type: 'morph', dur: number, text?: 'step' | 'crossfade' | 'dip' }` interpolates
801
897
  **numbers and hex colours** (colour, alpha, pulse, `reveal.progress`, …) over
802
898
  `dur` ms when crossing into the next segment; **every other value — strings,
803
899
  and `textBlock.text` above all — switches on the first morph frame** under
@@ -811,7 +907,11 @@ lerped frame's paint (colour, `opacity` and layer alpha glide as usual). Only
811
907
  the differing text layers cost a second draw, and only for `dur`; layers
812
908
  whose words match, and every non-text layer, are untouched. It is an
813
909
  internal paint pass, not a spec field — no `opacity` appears on the `text`
814
- sprite. `text` is a morph option only (a `fade` already cross-fades whole
910
+ sprite. **`text: 'dip'`** draws the same two passes one after the other
911
+ instead: the outgoing words fade out over the first half of the morph
912
+ (alpha `1 − 2k`) and the incoming fade in over the second (`2k − 1`), so two
913
+ captions never overlap — the right choice when the words sit in the same
914
+ place. `text` is a morph option only (a `fade` already cross-fades whole
815
915
  frames; a `cut` has no window) and the validator rejects it elsewhere.
816
916
  **Default `step` is today's behaviour byte for byte** — the sequence
817
917
  baseline pins it — and a flip to `crossfade` by default would be a major
@@ -825,7 +925,7 @@ warning. When the segments are structural twins whose only differences are
825
925
  values morph steps (a text-only change), the morph runs but every frame of it
826
926
  shows the incoming segment — it reads as a cut — and the validator emits a
827
927
  `morph-nothing-morphable` warning (never an error; stored sequences stay
828
- valid) — unless the transition declared `text: 'crossfade'` and the words
928
+ valid) — unless the transition declared `text: 'crossfade'` or `'dip'` and the words
829
929
  differ, in which case the words are the thing that morphs and the warning is
830
930
  withheld. During a morph, entity placement inherits the outgoing segment's
831
931
  seed — the incoming segment's own seed is unused. `dur` must be between 200
@@ -853,6 +953,28 @@ cross-fades on every segment change using the transition's `dur`, so `fade`
853
953
  matches the native player rather than diverging from it; native reads
854
954
  `fade.dur` where it reads the morph `dur` today.
855
955
 
956
+ **Wrap morph:** under `loop: true` a morph declared on the **last** segment
957
+ is ignored at the wrap — the lap cuts back to segment 0, and every stored
958
+ sequence expects that. `wrapMorph: true` on the envelope honours it: the lap
959
+ morphs back into segment 0 (from the second lap on; the first mount at
960
+ `T = 0` is not a wrap). It takes effect only when the whole lap is **one
961
+ morph chain** — every segment morphs into the next and the last is a
962
+ structural twin of the first — so every lap keeps the chain root's seed and
963
+ entity placement; otherwise it is still a cut and the validator warns
964
+ `wrap-morph-inactive` with the reason. With it, a looping ident is a closed
965
+ cycle of poses. A clicker jump to segment 0 (a `sequence.segment` steer)
966
+ still cuts — it arrives from wherever the presenter was, not from the last
967
+ segment. **Native:** tvOS cross-fades the wrap like any segment change.
968
+
969
+ **Timelines in segments:** a segment's scene may carry a `timeline`; it runs
970
+ on the segment's `localT`. A morph resolves each side on its own clock — the
971
+ outgoing segment at `duration + localT`, the incoming at `localT` — and lerps
972
+ the results, so a looping timeline keeps cycling through the morph. The
973
+ retained steers follow the steering rule on both ends (a steer the outgoing
974
+ timeline has already taken back stays taken back), and a layer `opacity` or
975
+ `transform` present on only one end glides from its identity (opacity 1,
976
+ no transform) instead of stepping.
977
+
856
978
  **Time mapping:** global clock `T` maps to `(segmentIndex, localT)` via prefix
857
979
  sums of durations. Half-open segments: `[start, start+duration)`. With
858
980
  `loop: true`, `T` wraps at the sum of all durations (loop is incompatible with
package/dist/index.d.ts CHANGED
@@ -46,6 +46,12 @@ interface SaverSpec {
46
46
  * Absent ⇒ the frame is presented exactly as drawn.
47
47
  */
48
48
  finish?: FinishSpec;
49
+ /**
50
+ * Authored keyframes on the scene's own clock — see `Timeline`. Absent ⇒
51
+ * the scene is exactly what it was before the field existed. **Native:**
52
+ * ignored ⇒ the base spec (author it as the piece's rest state).
53
+ */
54
+ timeline?: Timeline;
49
55
  /** Dimensional unit system. 'viewport' (default) = all sizes/speeds/distances are fractions of min(w,h). */
50
56
  units?: 'viewport' | 'px';
51
57
  /**
@@ -139,6 +145,51 @@ interface BackgroundDrift {
139
145
  /** How far stops shift (fraction of the 0..1 range). Default 0.15, max 0.3. */
140
146
  amount?: number;
141
147
  }
148
+ /**
149
+ * A paint-level transform applied to a whole layer at draw time, about the
150
+ * viewport centre: translate by (`x`, `y`) in `min(w, h)` units, rotate by
151
+ * `rotate` degrees, scale by `scale` (× `scaleX` horizontally — `scaleX`
152
+ * toward 0 reads as a turn about the vertical axis). Kept out of the
153
+ * structural signature, so it glides under steering, a `timeline` key or a
154
+ * `morph` — a camera for timed pieces, a slow drift for ambient ones. Every
155
+ * field optional; an empty transform is the identity. **Native:** ignored ⇒
156
+ * identity.
157
+ */
158
+ interface LayerTransform {
159
+ x?: number;
160
+ y?: number;
161
+ scale?: number;
162
+ scaleX?: number;
163
+ rotate?: number;
164
+ }
165
+ /** One authored key of a `Timeline`: at scene time `t` (ms), glide `path` to `value` over `dur` ms. */
166
+ interface TimelineKey {
167
+ /** Scene time in ms (the instance's own clock — a sequence segment's `localT`). */
168
+ t: number;
169
+ /** A steering path (`layers.0.sprite.color`, `key.sprite.color`, `background.stops.1.color`, …). It must already exist on the spec, as for `setParam`. */
170
+ path: string;
171
+ /** The target value; the key must still validate when applied. */
172
+ value: unknown;
173
+ /** Default `smooth`. */
174
+ ease?: 'step' | 'linear' | 'smooth';
175
+ /** Glide length in ms, starting at `t`. Default 1000 (as live steering); 0 = a cut. */
176
+ dur?: number;
177
+ }
178
+ /**
179
+ * Authored change on the scene's own clock — keyframes. A scene without a
180
+ * timeline is unchanged (ambient, today's behaviour); with one, each key
181
+ * glides its path at its time, and the scene can loop. Distinct from the
182
+ * channel's live `track`, whose `t` is a wall-clock stamp: live steering still
183
+ * layers on top, and a steer on a path the timeline animates holds until that
184
+ * path's next key.
185
+ */
186
+ interface Timeline {
187
+ /** Repeat every `duration` ms. A looping path's value before its first key is its last key's value (the loop wraps). */
188
+ loop?: boolean;
189
+ /** Loop length in ms; required with `loop`, and at least the latest key's `t`. */
190
+ duration?: number;
191
+ keys: TimelineKey[];
192
+ }
142
193
  interface LayerSpec {
143
194
  count: number;
144
195
  sprite: SpriteSpec;
@@ -227,7 +278,28 @@ interface LayerSpec {
227
278
  position?: {
228
279
  x: number;
229
280
  y: number;
281
+ /**
282
+ * An offset from `x`/`y` in `min(w, h)` units (px under `units: 'px'`).
283
+ * `x`/`y` are fractions of width and height while every size is a fraction
284
+ * of `min(w, h)`, so parts placed at computed fractions only register at
285
+ * the aspect they were computed for; anchor on a shared fraction (the
286
+ * centre is the only aspect-safe one) and offset in size units instead.
287
+ * Placement, like `x`/`y` (structural). Absent ⇒ 0. **Native:** tvOS
288
+ * ignores the offset until it reads the field.
289
+ */
290
+ dx?: number;
291
+ dy?: number;
230
292
  };
293
+ /**
294
+ * Paint-level layer opacity, 0..1 (default 1): multiplies the per-entity
295
+ * `alpha` and the `life` envelope at draw time. Unlike `alpha` (a range baked
296
+ * into every entity at build), it is **paint** — kept out of the structural
297
+ * signature — so `setParam`, a `timeline` key or a sequence `morph` glides it
298
+ * instead of re-seeding the layer. **Native:** ignored ⇒ 1.
299
+ */
300
+ opacity?: number;
301
+ /** Paint-level transform of the whole layer — see `LayerTransform`. */
302
+ transform?: LayerTransform;
231
303
  /**
232
304
  * Inter-entity links. `mode` picks the wiring:
233
305
  * - 'nearest' (default): each entity's k nearest neighbors within maxDist.
@@ -755,6 +827,14 @@ declare const LIMITS: {
755
827
  readonly maxTransitionDur: 5000;
756
828
  readonly minTransitionDur: 200;
757
829
  readonly minEmitEvery: 1000;
830
+ readonly maxTimelineKeys: 256;
831
+ readonly minTimelineKeyInterval: 200;
832
+ readonly maxTimelineKeyDur: 30000;
833
+ readonly maxTimelineDuration: 86400000;
834
+ readonly maxLayerTransformOffset: 2;
835
+ readonly maxLayerTransformOffsetPx: 17280;
836
+ readonly maxLayerTransformScale: 8;
837
+ readonly maxLayerTransformRotate: 3600;
758
838
  readonly maxEmitEvery: 600000;
759
839
  readonly minEmitLife: 500;
760
840
  readonly maxEmitGrow: 8;
@@ -777,13 +857,16 @@ type SequenceTransition = {
777
857
  * identical) segment over `dur` ms. Strings step on the first frame —
778
858
  * unless `text: 'crossfade'`, which draws each `text` / `textBlock` layer
779
859
  * whose string(s) differ twice for the window: the outgoing string at
780
- * `1 − k`, the incoming at `k` (k = the morph's eased progress). Default
781
- * `'step'`, today's behaviour, byte for byte.
860
+ * `1 − k`, the incoming at `k` (k = the morph's eased progress). `'dip'`
861
+ * draws the same two passes one after the other instead — the outgoing
862
+ * words fade out over the first half, the incoming fade in over the second —
863
+ * so the two strings never overlap. Default `'step'`, today's behaviour,
864
+ * byte for byte.
782
865
  */
783
866
  | {
784
867
  type: 'morph';
785
868
  dur: number;
786
- text?: 'step' | 'crossfade';
869
+ text?: 'step' | 'crossfade' | 'dip';
787
870
  }
788
871
  /**
789
872
  * Cross-fade into the next segment over `dur` ms: the outgoing segment
@@ -821,6 +904,16 @@ interface IdleSequence {
821
904
  * lands mid-loop. `advance: 'input'` holds stay armed under either mode.
822
905
  */
823
906
  sync?: 'mount' | 'epoch';
907
+ /**
908
+ * Honour a `morph` declared on the last segment at the loop wrap (the lap
909
+ * morphs back into segment 0 instead of cutting). Opt-in: without it a
910
+ * last-segment morph stays a cut at the wrap, as every stored sequence
911
+ * expects. Takes effect only when the whole loop is one morph chain (every
912
+ * segment morphs into the next, and the last is a structural twin of the
913
+ * first) — so every lap reuses the same child and seed; otherwise it is a
914
+ * cut and the validator says why. Needs `loop: true`.
915
+ */
916
+ wrapMorph?: boolean;
824
917
  /**
825
918
  * A scene drawn under every segment on the sequence's **global** clock —
826
919
  * the ground that does not reset at a boundary or under a `sequence.segment`
@@ -850,6 +943,18 @@ declare function validateSpec(spec: unknown): ValidationResult;
850
943
  * `validateSpec` it never normalises colours into the caller's spec.
851
944
  */
852
945
  declare function ignoredPropertyPaths(spec: unknown): string[];
946
+ /**
947
+ * Validity of only the parts of a spec that `paths` (index-form dot-paths,
948
+ * as `canonicalSpecPath` spells them) live in: each touched layer, the
949
+ * background, the finish — plus the cross-layer entity cap. A layer's rules
950
+ * (the `clock`/`pulse`/`grow` flash floors, `emit`, orbit parents) are all
951
+ * judged inside that layer, so a change confined to layer N can only break
952
+ * layer N or the total. Any other top-level path falls back to the full
953
+ * `validateSpec`. For the timeline's composed-state checks, which run at every
954
+ * key boundary and on every live steer: the whole-spec check costs a
955
+ * full validation per sample, this one a layer or two.
956
+ */
957
+ declare function validateSpecPaths(spec: SaverSpec, paths: Iterable<string>): boolean;
853
958
  /** Narrowing helper: validate + cast. Throws with a joined message when invalid. */
854
959
  declare function assertValidSpec(spec: unknown): SaverSpec;
855
960
  /**
@@ -1206,6 +1311,15 @@ interface PathTarget {
1206
1311
  }
1207
1312
  /** Resolve a dot-path (key-aware) to its parent + final key; null if absent. */
1208
1313
  declare function resolveSpecPath(spec: unknown, path: string): PathTarget | null;
1314
+ /**
1315
+ * The index form of a key-aware dot-path (`fireflies.sprite.color` →
1316
+ * `layers.3.sprite.color`), or null when it does not resolve. Two paths that
1317
+ * address the same field compare equal after this — how a live steer finds
1318
+ * out whether a `timeline` key animates the field it touches.
1319
+ */
1320
+ declare function canonicalSpecPath(spec: unknown, path: string): string | null;
1321
+ /** Read the value at a dot-path, or undefined when it does not resolve. */
1322
+ declare function readSpecPath(spec: unknown, path: string): unknown;
1209
1323
  /** A steering delta as carried on a channel control-track. */
1210
1324
  interface SteerDelta {
1211
1325
  t: number;
@@ -1222,6 +1336,14 @@ declare function applyDeltasToSpec(spec: SaverSpec, deltas: SteerDelta[]): Saver
1222
1336
  * target immediately (k > 0). Layer counts round to integers.
1223
1337
  */
1224
1338
  declare function lerpSpec(from: SaverSpec, to: SaverSpec, k: number): SaverSpec;
1339
+ /**
1340
+ * The walk behind `lerpSpec`, for any value: numbers lerp, hex colours lerp
1341
+ * per channel, equal-length arrays and objects recurse, everything else steps
1342
+ * to the target at k > 0 (`count` rounds). At a spec's root a `timeline` steps
1343
+ * too — two timelines' key times must never blend. No existing spec carries a
1344
+ * timeline, so every stored morph and glide is unchanged.
1345
+ */
1346
+ declare function lerpValue(from: unknown, to: unknown, k: number, specRoot?: boolean, rootKey?: string): unknown;
1225
1347
  /**
1226
1348
  * The string(s) a text layer paints — `strings` of a `text` sprite, `text`
1227
1349
  * of a `textBlock` — or null for every other sprite.
@@ -1472,10 +1594,11 @@ interface LuminanceGridOptions extends PerceiveOptions {
1472
1594
  * of (seed, t), `ghosting` is a decayed sum of past-frame splats (weight g^m
1473
1595
  * for ink m frames old, mirroring the renderer's warm-up replay) and `trail`
1474
1596
  * re-uses drawTrail's sampling — past positions with decaying alpha and
1475
- * shrinking radius. Remaining approximations (documented, deliberate): soft
1476
- * circles use a linear falloff, background drift is sampled at its rest
1477
- * position. Good enough to perceive composition, focus, balance — and now
1478
- * smear.
1597
+ * shrinking radius. Polygons larger than POLYGON_OUTLINE_CELLS are scanned by
1598
+ * their real (rotated) outline; smaller ones splat as a fill-weighted disc.
1599
+ * Remaining approximations (documented, deliberate): soft circles use a linear
1600
+ * falloff, background drift is sampled at its rest position. Good enough to
1601
+ * perceive composition, focus, balance — and now smear.
1479
1602
  */
1480
1603
  declare function luminanceGrid(spec: SaverSpec, opts?: LuminanceGridOptions): LuminanceGrid;
1481
1604
  /**
@@ -1549,7 +1672,15 @@ interface LayerMotionStats {
1549
1672
  maxSpeed: number;
1550
1673
  moving: boolean;
1551
1674
  }
1552
- /** Per-layer displacement between t and t+dt — choreography as numbers. */
1675
+ /**
1676
+ * Per-layer displacement between t and t+dt — choreography as numbers.
1677
+ *
1678
+ * Samples the scene at both ends of the window separately (not just each
1679
+ * entity's own position within one scene) so a `timeline`-driven layer
1680
+ * `transform` — a camera pan or zoom with every entity otherwise static —
1681
+ * registers as motion instead of vanishing between two reads of the same
1682
+ * frozen transform.
1683
+ */
1553
1684
  declare function motionStats(spec: SaverSpec, opts?: PerceiveOptions & {
1554
1685
  dt?: number;
1555
1686
  }): LayerMotionStats[];
@@ -1890,6 +2021,15 @@ declare function canMorph(seq: IdleSequence, from: number): boolean;
1890
2021
  * never its own index's.
1891
2022
  */
1892
2023
  declare function morphChainRoot(seq: IdleSequence, index: number): number;
2024
+ /**
2025
+ * Whether a looping sequence's wrap (last segment → segment 0) plays the last
2026
+ * segment's `morph`. Opt-in via `wrapMorph: true`, and only when the whole lap
2027
+ * is one morph chain — every boundary morphs and the last segment is a
2028
+ * structural twin of segment 0 — so the wrap keeps the chain root's seed and
2029
+ * entity placement and nothing pops. Otherwise the wrap is the hard cut it has
2030
+ * always been (the validator warns when `wrapMorph` asks for one it can't do).
2031
+ */
2032
+ declare function canWrapMorph(seq: IdleSequence): boolean;
1893
2033
  /** `SpecInstance`'s seed normalization: 0 is falsy, so a valid `seed: 0` lands on 1. */
1894
2034
  declare function normalizeSeed(seed: number): number;
1895
2035
  /**
@@ -1959,6 +2099,73 @@ declare function sequenceWantsFinish(seq: IdleSequence): boolean;
1959
2099
  */
1960
2100
  declare function sequenceSwapCompatible(prev: IdleSequence, next: IdleSequence): boolean;
1961
2101
 
2102
+ /**
2103
+ * `timeline` — authored keyframes on a scene's own clock.
2104
+ *
2105
+ * Everything here is a pure function of `(spec, t)`: resolving a scene at a
2106
+ * time never depends on what was rendered before, so a timeline scene is as
2107
+ * seekable and frame-addressable as any other (ghosting warm-up replays call
2108
+ * `resolveTimelineAt` at each replay frame's own time). A spec without a
2109
+ * `timeline` is returned untouched, which is what keeps every existing scene
2110
+ * byte-identical.
2111
+ *
2112
+ * Semantics:
2113
+ * - Keys address the same dot-paths as live steering; a path must already
2114
+ * exist on the spec (a key cannot create a field).
2115
+ * - A key glides from wherever its path is at `t` to `value` over `dur` ms
2116
+ * (default 1000, the live-steering default; 0 is a cut), eased `smooth`
2117
+ * unless it says otherwise. Overlapping glides are fine: the later one
2118
+ * starts from the earlier one's in-flight value.
2119
+ * - Without `loop`, a path holds the base spec's value before its first key.
2120
+ * - With `loop`, time wraps every `duration` ms and a path's value before its
2121
+ * first key is its LAST key's value — the lap is one closed cycle, and lap 1
2122
+ * looks exactly like lap 7 (the value depends only on `t mod duration`).
2123
+ */
2124
+
2125
+ /** Default glide length for a key without `dur` — the same as a live steer's. */
2126
+ declare const DEFAULT_KEY_DUR = 1000;
2127
+ type Tracks = Map<string, TimelineKey[]>;
2128
+ /** The spec without its `timeline` (a shallow copy — the layers are shared, never mutated). */
2129
+ declare function withoutTimeline(spec: SaverSpec): SaverSpec;
2130
+ /**
2131
+ * The spec's keys grouped by the field they animate (index-form path), each
2132
+ * group sorted by time (stable, so equal times keep authored order and the
2133
+ * later one wins). Keys whose path does not resolve are dropped — the
2134
+ * validator rejects them, and the runtime stays lenient like steering.
2135
+ */
2136
+ declare function timelineTracks(spec: SaverSpec): Tracks;
2137
+ /** Scene time folded into the timeline's own clock: `t mod duration` under `loop`, else `t`. */
2138
+ declare function timelineLocalTime(tl: Timeline, t: number): number;
2139
+ /**
2140
+ * The scene at time `t` with every key applied — and without its `timeline`,
2141
+ * so the result can be rendered, perceived, lerped or steered like any plain
2142
+ * spec. A spec without a timeline comes back as the same object.
2143
+ */
2144
+ declare function resolveTimelineAt(spec: SaverSpec, t: number): SaverSpec;
2145
+ /**
2146
+ * When the timeline next takes a path back: the scene time of that path's
2147
+ * next key after `t`, and that key's glide length — or null when no key will
2148
+ * ever touch it again (no key on the path, or a non-looping timeline past its
2149
+ * last key). A live steer on the path holds until `at`, then glides back to
2150
+ * the timeline over `dur`.
2151
+ */
2152
+ declare function nextKeyAfter(spec: SaverSpec, path: string, t: number): {
2153
+ at: number;
2154
+ dur: number;
2155
+ } | null;
2156
+ /**
2157
+ * Scene times (timeline-local; within one lap under `loop`) where the keys'
2158
+ * combined state is worth checking: time 0, and every key's start, end and
2159
+ * glide quarter-points. Two keys can each be valid alone and invalid together
2160
+ * (a `clock.rate` and a `pulse.period` the flash floor bounds as a ratio), so
2161
+ * validation samples the composed scene here. `dense: false` keeps only the
2162
+ * starts and ends, for the per-steer check a live viewer runs. Capped (evenly
2163
+ * subsampled) so a 256-key timeline stays cheap to check.
2164
+ */
2165
+ declare function timelineSampleTimes(spec: SaverSpec, dense?: boolean, cap?: number, only?: (path: string) => boolean): number[];
2166
+ /** The absolute scene times at or after `now` where `timelineSampleTimes` fall — the next lap's occurrences under `loop`. */
2167
+ declare function timelineSampleTimesAfter(spec: SaverSpec, now: number, dense?: boolean, only?: (path: string) => boolean): number[];
2168
+
1962
2169
  /**
1963
2170
  * Reproduces the hand-coded Fish Aquarium saver as pure DATA — the discriminating test
1964
2171
  * that the schema is actually expressive: two layers (fish drifting both ways at varied
@@ -2334,4 +2541,4 @@ declare const EXAMPLE_SPECS: SaverSpec[];
2334
2541
  /** Lookup by saver id. */
2335
2542
  declare const EXAMPLE_BY_ID: Readonly<Record<string, SaverSpec>>;
2336
2543
 
2337
- export { AQUARIUM_SPEC, AURORA_SPEC, type BackgroundDrift, type BackgroundSpec, type BandSpec, COHESION_T, COMETS_SPEC, CONSTELLATION_SPEC, type CapabilityTier, type CohesionRead, type CycleSpec, DASHBOARD_SPEC, DITHER_ALPHA, DITHER_TILE, type DominanceEntry, EXAMPLE_BY_ID, EXAMPLE_SEQUENCES, EXAMPLE_SPECS, type Entity, FACETS_SPEC, FIELD_BUCKET_MS, FIELD_DRIFT_DEFAULT_AMOUNT, FIELD_RASTER_SHORT_SIDE, FIELD_RASTER_SHORT_SIDE_LOW, FINISH_ANIMATE_HZ, type FieldBackground, type FieldDrift, type FinishPass, type FinishSpec, GRAIN_ALPHA, GRAIN_TILE, type GradientStop, HAIKU_SPEC, type IdleSequence, LANTERNS_SPEC, LIMITS, LOBBY_TALK_SPEC, type LayerCohesion, type LayerMotionStats, type LayerSnapshot, type LayerSpec, type LinkEdge, type LuminanceGrid, type LuminanceGridOptions, MATRIX_RAIN_SPEC, MURMUR_SPEC, type MotionEase, type MotionSpec, NOSTALGHIA_CANDLE_SPEC, ORRERY_SPEC, PHASE_DUET_SPEC, PINGS_SPEC, POLYGONS_SPEC, PROCESSION_SPEC, type PerceiveOptions, type PerceiveSequenceOptions, type Placed, RAIN_SPEC, RELAY_BOARD_SPEC, type ResolveOptions, type ResolvedSegment, type RevealState, SAKURA_SPEC, SCHEMA_EXAMPLES, SCHEMA_VERSION, SHARD_FALL_SPEC, SIGNAL_BOARD_SPEC, SNOWFALL_SPEC, SPARSE_NIGHT_SPEC, type SaverSpec, type SceneDescription, type SceneDiff, type ScenePerception, type SceneSnapshot, type SchemaExample, type ScreenOp, type SequenceExample, type SequenceFramePerception, type SequenceMountContext, type SequenceSaverInstance, type SequenceSaverPlugin, type SequenceSegment, type SequenceTransition, type SpecError, type SpecMountContext, type SpecWarning, type SpriteSpec, type SteerDelta, THERMAL_FIELD_SPEC, THREE_MOVEMENTS_SEQUENCE, TYPED_CUE_SPEC, type TextBlockAnchor, type TextBlockLine, type TextMetricsClass, type TextRevealInput, type TextRevealSpec, type TextRole, type TextSpriteInfo, type ValidationResult, WARP_MAX_SCALE, WARP_NEAR, WARP_TUNNEL_SPEC, WEB_WORK_SPEC, type WanderOsc, type WarningBox, adviseSequence, adviseSpec, alphaAt, applyDeltasToSpec, assertValidSequence, assertValidSpec, bayerMatrix, bayerTilePixels, bedRenderSeed, breakTextBlock, buildEntities, canMorph, cohesionOf, compileSaver, compileSequence, createFinishPass, createSceneCanvas, describeScene, diffScenes, dominanceRanking, easeSmooth, emitEnvelope, emitWindow, fieldAt, fieldOctaves, fieldRgb, fieldRgbAt, fieldSampleTime, finishStrength, grainOffset, grainTilePixels, graphemeClusters, hasHotSwapSequence, headingAt, hexToRgb255, ignoredPropertyPaths, latticeHash, layerCohesion, lerpSpec, lifeAlphaAt, linkEdges, linkPairs, luminanceGrid, manifestFor, morphChainRoot, morphNothingMorphable, motionStats, normalizeSeed, perceiveScene, perceiveSequenceFrame, pickScreenOp, positionAt, presentWithFinish, renderBrailleMap, renderDensityMap, resolveSegment, resolveSpecPath, revealState, rgb255Luma, rotationAt, seamCauseOf, seamsWorthWarning, segmentRenderSeed, segmentStart, sequenceSteerablePaths, sequenceSwapCompatible, sequenceWantsFinish, sizeAt, spriteIndexAt, spriteVariants, steerablePaths, structuralSignature, textBlockAnchorOffset, textMetricsClassFor, textSprites, textStringsDiffer, textStringsOf, textWidthEm, validateSequence, validateSpec };
2544
+ export { AQUARIUM_SPEC, AURORA_SPEC, type BackgroundDrift, type BackgroundSpec, type BandSpec, COHESION_T, COMETS_SPEC, CONSTELLATION_SPEC, type CapabilityTier, type CohesionRead, type CycleSpec, DASHBOARD_SPEC, DEFAULT_KEY_DUR, DITHER_ALPHA, DITHER_TILE, type DominanceEntry, EXAMPLE_BY_ID, EXAMPLE_SEQUENCES, EXAMPLE_SPECS, type Entity, FACETS_SPEC, FIELD_BUCKET_MS, FIELD_DRIFT_DEFAULT_AMOUNT, FIELD_RASTER_SHORT_SIDE, FIELD_RASTER_SHORT_SIDE_LOW, FINISH_ANIMATE_HZ, type FieldBackground, type FieldDrift, type FinishPass, type FinishSpec, GRAIN_ALPHA, GRAIN_TILE, type GradientStop, HAIKU_SPEC, type IdleSequence, LANTERNS_SPEC, LIMITS, LOBBY_TALK_SPEC, type LayerCohesion, type LayerMotionStats, type LayerSnapshot, type LayerSpec, type LayerTransform, type LinkEdge, type LuminanceGrid, type LuminanceGridOptions, MATRIX_RAIN_SPEC, MURMUR_SPEC, type MotionEase, type MotionSpec, NOSTALGHIA_CANDLE_SPEC, ORRERY_SPEC, PHASE_DUET_SPEC, PINGS_SPEC, POLYGONS_SPEC, PROCESSION_SPEC, type PerceiveOptions, type PerceiveSequenceOptions, type Placed, RAIN_SPEC, RELAY_BOARD_SPEC, type ResolveOptions, type ResolvedSegment, type RevealState, SAKURA_SPEC, SCHEMA_EXAMPLES, SCHEMA_VERSION, SHARD_FALL_SPEC, SIGNAL_BOARD_SPEC, SNOWFALL_SPEC, SPARSE_NIGHT_SPEC, type SaverSpec, type SceneDescription, type SceneDiff, type ScenePerception, type SceneSnapshot, type SchemaExample, type ScreenOp, type SequenceExample, type SequenceFramePerception, type SequenceMountContext, type SequenceSaverInstance, type SequenceSaverPlugin, type SequenceSegment, type SequenceTransition, type SpecError, type SpecMountContext, type SpecWarning, type SpriteSpec, type SteerDelta, THERMAL_FIELD_SPEC, THREE_MOVEMENTS_SEQUENCE, TYPED_CUE_SPEC, type TextBlockAnchor, type TextBlockLine, type TextMetricsClass, type TextRevealInput, type TextRevealSpec, type TextRole, type TextSpriteInfo, type Timeline, type TimelineKey, type ValidationResult, WARP_MAX_SCALE, WARP_NEAR, WARP_TUNNEL_SPEC, WEB_WORK_SPEC, type WanderOsc, type WarningBox, adviseSequence, adviseSpec, alphaAt, applyDeltasToSpec, assertValidSequence, assertValidSpec, bayerMatrix, bayerTilePixels, bedRenderSeed, breakTextBlock, buildEntities, canMorph, canWrapMorph, canonicalSpecPath, cohesionOf, compileSaver, compileSequence, createFinishPass, createSceneCanvas, describeScene, diffScenes, dominanceRanking, easeSmooth, emitEnvelope, emitWindow, fieldAt, fieldOctaves, fieldRgb, fieldRgbAt, fieldSampleTime, finishStrength, grainOffset, grainTilePixels, graphemeClusters, hasHotSwapSequence, headingAt, hexToRgb255, ignoredPropertyPaths, latticeHash, layerCohesion, lerpSpec, lerpValue, lifeAlphaAt, linkEdges, linkPairs, luminanceGrid, manifestFor, morphChainRoot, morphNothingMorphable, motionStats, nextKeyAfter, normalizeSeed, perceiveScene, perceiveSequenceFrame, pickScreenOp, positionAt, presentWithFinish, readSpecPath, renderBrailleMap, renderDensityMap, resolveSegment, resolveSpecPath, resolveTimelineAt, revealState, rgb255Luma, rotationAt, seamCauseOf, seamsWorthWarning, segmentRenderSeed, segmentStart, sequenceSteerablePaths, sequenceSwapCompatible, sequenceWantsFinish, sizeAt, spriteIndexAt, spriteVariants, steerablePaths, structuralSignature, textBlockAnchorOffset, textMetricsClassFor, textSprites, textStringsDiffer, textStringsOf, textWidthEm, timelineLocalTime, timelineSampleTimes, timelineSampleTimesAfter, timelineTracks, validateSequence, validateSpec, validateSpecPaths, withoutTimeline };