@idle-screens/schema 3.6.1 → 3.7.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 +261 -37
- package/dist/index.d.ts +274 -8
- package/dist/index.js +852 -86
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/saver-spec.schema.json +71 -4
package/FORMAT.md
CHANGED
|
@@ -36,7 +36,10 @@ sprites, `colorWeights`, `pulse.wave`, `layout` (grid), `life`,
|
|
|
36
36
|
`emit` (sparse events), `clock` (phase-lock), `motion.ease` (settle / buoyant)
|
|
37
37
|
(2026-09-05 — time structure); `polygon` / `stroke` sprites and `rect.feather`
|
|
38
38
|
(2026-09-05 — shape glyphs); `layout: list | table` and the `bar` sprite
|
|
39
|
-
(2026-09-05 — data layout).
|
|
39
|
+
(2026-09-05 — data layout); `textBlock.anchor` / `font` / `opacity`, the
|
|
40
|
+
`maxWidth` cap lifted to 2.0, `sync` and `bed` on the sequence envelope,
|
|
41
|
+
the `fade` transition, `role` on text sprites, and `text: 'crossfade'` on
|
|
42
|
+
`morph` (2026-09-08 — ambient presentations).
|
|
40
43
|
|
|
41
44
|
## Safety invariants
|
|
42
45
|
|
|
@@ -156,7 +159,7 @@ exceeds 2 %, or a `dense` one that would have tripped `sparse-scene`, gets a
|
|
|
156
159
|
| `life` | `{enter?, exit?, fade?}` ms | always on | act structure: fade the layer in at `enter`, out at `exit` |
|
|
157
160
|
| `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 |
|
|
158
161
|
| `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` |
|
|
159
|
-
| `key` | string | none | addressable name → `setParam("key.field", …)` |
|
|
162
|
+
| `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`) |
|
|
160
163
|
| `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` |
|
|
161
164
|
|
|
162
165
|
`links`: `{ k: 1..8, maxDist, color?, alpha?, width?, mode?, falloff?, closed? }`.
|
|
@@ -170,10 +173,12 @@ with distance, removing pop-in at the cutoff.
|
|
|
170
173
|
|
|
171
174
|
- `{ "kind": "emoji", "glyphs": ["🐟", "🐠"], "cycle": { "period": 800 } }` —
|
|
172
175
|
glyph picked per entity (seeded); optional `cycle` rotates variants over time
|
|
173
|
-
- `{ "kind": "text", "strings": ["HELLO"], "color": "#e6e8ef", "font": "bold monospace", "align": "center", "baseline": "middle", "maxWidth": 300, "cycle":
|
|
176
|
+
- `{ "kind": "text", "strings": ["HELLO"], "color": "#e6e8ef", "font": "bold monospace", "align": "center", "baseline": "middle", "maxWidth": 300, "cycle": ..., "role": "read" }`
|
|
174
177
|
— a `font` **with** a px size keeps that shorthand, its number rescaled to the
|
|
175
178
|
viewport (verbatim only under `units: "px"`); a family/weight only
|
|
176
|
-
(`"bold monospace"`) composes with the seeded per-entity `size`
|
|
179
|
+
(`"bold monospace"`) composes with the seeded per-entity `size`; `role` is
|
|
180
|
+
the legibility opt-in described under `textBlock` below (same field, same
|
|
181
|
+
meaning on both text sprites)
|
|
177
182
|
- `{ "kind": "circle", "radius": [0.001, 0.003], "color": "#ffffff", "soft": true }` —
|
|
178
183
|
`soft` renders a radial-falloff glow orb instead of a hard disc
|
|
179
184
|
- `{ "kind": "ring", "radius": [0.002, 0.005], "color": "#d8f6ff", "width": 0.001 }` —
|
|
@@ -188,8 +193,9 @@ with distance, removing pop-in at the cutoff.
|
|
|
188
193
|
- `{ "kind": "bar", "values": [82, 64, 91], "max": 100, "length": 0.5, "thickness": 0.02, "color": "#17e8c8", "direction": "right" }` —
|
|
189
194
|
a data bar: entity *i* draws `length × values[i] / max` (viewport units),
|
|
190
195
|
`thickness` thick, growing from its position toward `direction` (`right`
|
|
191
|
-
default, `left`, `up`, `down`). `values` are **paint**: `setParam("bars.values", […])`
|
|
192
|
-
glides every bar
|
|
196
|
+
default, `left`, `up`, `down`). `values` are **paint**: `setParam("bars.sprite.values", […])`
|
|
197
|
+
glides every bar (the field lives under `sprite`, so the path does too —
|
|
198
|
+
`bars.values` resolves nothing). With `layout: { type: "list" }` a chart is one layer and
|
|
193
199
|
its labels another — the dashboard genre stops needing one layer per number
|
|
194
200
|
- `{ "kind": "polygon", "radius": [0.02, 0.05], "sides": 3, "color": "#f2e8c9", "soft": false }` —
|
|
195
201
|
regular n-gon of the seeded circumradius, point up (`sides` 3..12, default
|
|
@@ -206,19 +212,79 @@ with distance, removing pop-in at the cutoff.
|
|
|
206
212
|
than a line); `orient: true` turns the path's +x along the entity's heading,
|
|
207
213
|
like `streak`. Rotates with `spin`. Van Gogh's stroke, Hokusai's contour,
|
|
208
214
|
O'Keeffe's petal, Basquiat's scrawl
|
|
209
|
-
- `{ "kind": "textBlock", "text": "Multi-line text with wrapping.", "maxWidth": 0.8, "fontSize": 0.04, "lineHeight": 1.4, "align": "left", "color": "#e6e8ef" }` —
|
|
215
|
+
- `{ "kind": "textBlock", "text": "Multi-line text with wrapping.", "maxWidth": 0.8, "fontSize": 0.04, "lineHeight": 1.4, "align": "left", "color": "#e6e8ef", "anchor": "top-left", "font": "system-ui, sans-serif", "opacity": 1 }` —
|
|
210
216
|
multi-line text block with deterministic line-breaking. All dimensions are
|
|
211
217
|
viewport fractions (of `min(w,h)`), not px — `units: "px"` specs reject
|
|
212
|
-
textBlock sprites at validation time. `position` is
|
|
213
|
-
**top-left corner
|
|
214
|
-
not the box itself — different from the
|
|
215
|
-
shift the meaning of `position`).
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
218
|
+
textBlock sprites at validation time. Without `anchor`, `position` is the
|
|
219
|
+
**top-left corner of the `maxWidth` layout box** regardless of `align`
|
|
220
|
+
(align moves text within the box, not the box itself — different from the
|
|
221
|
+
`text` sprite where `align`/`baseline` shift the meaning of `position`).
|
|
222
|
+
`maxWidth` may reach `2.0` — wider than the frame is legal (a wide caption
|
|
223
|
+
on a portrait screen); the `text-off-screen` advisory reports what runs
|
|
224
|
+
out. Line breaks are computed from a fixed character-class metrics table so
|
|
225
|
+
wrapping is identical across platforms; note that the table is approximate
|
|
226
|
+
— a painted line may slightly exceed `maxWidth` when the real font is wider
|
|
227
|
+
than the table estimates, so `maxWidth` is a layout target, not a hard clip.
|
|
220
228
|
Use with `count: 1`, `motion: { type: "static" }`, and `position`.
|
|
221
229
|
|
|
230
|
+
`anchor` (one of nine compass points: `top-left`, `top`, `top-right`,
|
|
231
|
+
`left`, `center`, `right`, `bottom-left`, `bottom`, `bottom-right`) says
|
|
232
|
+
which point of the **rendered text** `position` names — the widest line
|
|
233
|
+
wide, as `align` lays it inside `maxWidth`, and `lines × lineHeight` tall,
|
|
234
|
+
measured after line-breaking. With `anchor: "center"` and
|
|
235
|
+
`position: { "x": 0.5, "y": 0.5 }` the block is centred on **every** aspect
|
|
236
|
+
ratio — `position` is in viewport fractions while `maxWidth` is in
|
|
237
|
+
`min(w,h)`, so hand-computing a centred `x` for one screen misses on the
|
|
238
|
+
next; the anchor removes the arithmetic. Under an anchor `align` only
|
|
239
|
+
shapes the ragged edge (shorter lines sit left/centre/right of the widest);
|
|
240
|
+
it no longer moves the ink. Anchor is placement, so it is part of the
|
|
241
|
+
structural signature (a change rebuilds); rotation stays about the anchor
|
|
242
|
+
point. The perception boxes (`text-overlap`, `text-off-screen`) follow the
|
|
243
|
+
same rule. Absent ⇒ exactly the pre-anchor behaviour above. **Native
|
|
244
|
+
clients that do not read `anchor` render as absent (layout-box top-left).**
|
|
245
|
+
|
|
246
|
+
`font` is a CSS family and/or weight/style — `"bold monospace"`,
|
|
247
|
+
`"300 'Inter', sans-serif"` — composed with the scaled `fontSize` the way
|
|
248
|
+
the `text` sprite composes a size-less font; a **size inside it is
|
|
249
|
+
rejected** (`fontSize` owns size, so the block keeps scaling with the
|
|
250
|
+
viewport). A monospace family (`monospace`, `ui-monospace`, Menlo, Courier,
|
|
251
|
+
SF Mono, Fira Code, …) switches the line-breaker to a uniform 0.6 em
|
|
252
|
+
advance so mono wraps land where the real face puts them; every other
|
|
253
|
+
family uses the proportional table. Default `system-ui, sans-serif`.
|
|
254
|
+
**Native clients fall back to the system face** until a face table exists.
|
|
255
|
+
|
|
256
|
+
`opacity` (0–1, default 1) multiplies the block's paint alpha — the whole
|
|
257
|
+
block, on top of the layer's per-entity `alpha`. It is **paint**, not
|
|
258
|
+
carpentry: excluded from the structural signature, so
|
|
259
|
+
`setParam("h.sprite.opacity", 0, { dur: 1500 })` glides the block out
|
|
260
|
+
without a rebuild (and back in with `1`). Declare `"opacity": 1` on the
|
|
261
|
+
block to make the path steerable (a `setParam` cannot create a field that
|
|
262
|
+
is not there). Scales perceived ink in the luminance grid. **Native clients
|
|
263
|
+
without `opacity` render at 1.**
|
|
264
|
+
|
|
265
|
+
`role` (`"read"` | `"atmosphere"`, on `textBlock` **and** `text`) declares
|
|
266
|
+
what the words are *for*, and is the only thing that turns legibility
|
|
267
|
+
checking on. Absent, or `"atmosphere"`, the layer is texture — a haiku
|
|
268
|
+
fading in a corner, dim labels on a wall board, the `dev-dashboard`'s
|
|
269
|
+
2.6:1 telemetry — and `adviseSpec` says nothing about how readable it is,
|
|
270
|
+
exactly as before the field existed; keep atmospheric text undeclared, the
|
|
271
|
+
advisories would only nag. `"read"` says the words must be read from
|
|
272
|
+
across the room, and opts that layer into two advisories:
|
|
273
|
+
`text-legibility` fires when the text colour's WCAG ratio falls below
|
|
274
|
+
**4.5:1** against the background sampled at the box centre (gradient stops
|
|
275
|
+
interpolated at rest, `band` honoured) **or** against the brightest
|
|
276
|
+
additive layer (`blend: lighter` / `screen`) whose entities can reach the
|
|
277
|
+
box and are at least a glyph tall, taken at its peak alpha (base + pulse)
|
|
278
|
+
composited over that background — a glow parked under a caption is
|
|
279
|
+
measured, dust is not; both ratios appear in the message when an additive layer is beneath — a plate-less read reports only the background ratio. `text-safe-area`
|
|
280
|
+
fires when the box (a `list`'s whole extent) lies within **5 %** of any
|
|
281
|
+
viewport edge, where bezels and overscan hide it. Each is judged at the
|
|
282
|
+
viewport passed to `adviseSpec` — check the portrait one too. `role` is a
|
|
283
|
+
declaration only: it changes **no pixel anywhere, on any client**, is not
|
|
284
|
+
in the structural signature, and native players ignore it with nothing to
|
|
285
|
+
fall back to. The shipped `lobby-talk` example declares it on every text
|
|
286
|
+
layer and produces zero advisories, landscape and portrait.
|
|
287
|
+
|
|
222
288
|
textBlock also accepts `reveal` — animated typing/deleting:
|
|
223
289
|
`{ "reveal": { "progress": 1, "mode": "typewriter", "speed": 0, "caret": true } }`.
|
|
224
290
|
Layout always runs on the **full** text; reveal only masks which glyphs are
|
|
@@ -241,9 +307,13 @@ with distance, removing pop-in at the cutoff.
|
|
|
241
307
|
blink is in full cycles/sec, capped at 3 for flash safety, and is a square
|
|
242
308
|
wave of `t` like every other animation). With `align: "center"`/`"right"`
|
|
243
309
|
the revealing line stays anchored to its alignment as it types — only
|
|
244
|
-
left-aligned text reads as a classic typewriter.
|
|
245
|
-
|
|
246
|
-
|
|
310
|
+
left-aligned text reads as a classic typewriter. `text` itself is paint
|
|
311
|
+
that **steps**: a `setParam` swap or a sequence `morph` switches the string
|
|
312
|
+
whole on the first frame — nothing interpolates a string — so to "edit"
|
|
313
|
+
text live: glide `reveal.progress` to 0, swap `text` while nothing is
|
|
314
|
+
visible, glide back to 1 (or fade the block via its `color`). Inside a
|
|
315
|
+
sequence, a morph declared `text: 'crossfade'` cross-fades the old words
|
|
316
|
+
under the new ones instead (see **Transitions** below).
|
|
247
317
|
|
|
248
318
|
`circle`, `ring`, `streak`, `rect`, `bar`, `polygon` and `stroke` all accept
|
|
249
319
|
`colors: [...]` (seeded per-entity palette pick) and `colorWeights: [...]`
|
|
@@ -461,8 +531,10 @@ fixed-step warm-up (≤ 120 frames) from a full clear on any non-contiguous seek
|
|
|
461
531
|
## Steering
|
|
462
532
|
|
|
463
533
|
Compiled specs accept live parameter changes via dot-paths —
|
|
464
|
-
`layers.0.count`, `background.stops.0.color`, or
|
|
465
|
-
`cpu-gauge.
|
|
534
|
+
`layers.0.count`, `layers.0.sprite.color`, `background.stops.0.color`, or
|
|
535
|
+
`key`-based paths like `cpu-gauge.count` / `cpu-gauge.sprite.color` when
|
|
536
|
+
layers declare `key` (the key replaces `layers.N`; everything after it
|
|
537
|
+
mirrors the JSON, so a sprite field keeps its `sprite.` segment). Changes interpolate over a
|
|
466
538
|
control-track (`step` | `linear` | `smooth`). Placement/motion changes trigger
|
|
467
539
|
a deterministic rebuild (same seed → same stream). See `@idle-screens/core`
|
|
468
540
|
for `ControlTrack` and the idlescreens.com MCP `setParam` tool.
|
|
@@ -482,6 +554,13 @@ interpolation, and background drift is sampled at rest. These are documented
|
|
|
482
554
|
trade-offs for a zero-dependency, renderer-free analysis tool.
|
|
483
555
|
|
|
484
556
|
- `perceiveScene(spec, {t?, viewport?, seed?})` — one-call bundle: everything below.
|
|
557
|
+
- `perceiveSequenceFrame(seq, T, {viewport?, seed?, releasedBelow?})` — the
|
|
558
|
+
same bundle for one frame of a **sequence** at global time `T`: resolves the
|
|
559
|
+
segment (reported as `segment: {index, key, localT, held?}`) and, when the
|
|
560
|
+
sequence has a `bed`, composes the bed at `T` under the segment's ink at
|
|
561
|
+
`localT` with the segment's background dropped, as the renderer stacks
|
|
562
|
+
them — the grids add, dominance ranks both by raw weight with bed layers
|
|
563
|
+
keyed `bed:<key|index>`, and text/motion/form list bed layers first.
|
|
485
564
|
- `luminanceGrid(spec, opts)` — an 80×48 luminance image of the composed frame
|
|
486
565
|
(background gradient + entities + link lines, blend-aware), with coverage,
|
|
487
566
|
visual-mass centroid, and **row/column deviation profiles** (1D transects of
|
|
@@ -520,10 +599,16 @@ trade-offs for a zero-dependency, renderer-free analysis tool.
|
|
|
520
599
|
`perceiveScene().advisories`). Two of them are **spatial**, for static text:
|
|
521
600
|
`text-off-screen` (a text/textBlock box crosses the viewport edge by more
|
|
522
601
|
than 1%) and `text-overlap` (two static text layers share more than 10% of
|
|
523
|
-
the smaller box
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
602
|
+
the smaller box; it carries both boxes as `boxes: [{x, y, w, h}]` in
|
|
603
|
+
viewport fractions so the fix needs no re-derivation). Boxes come from the
|
|
604
|
+
same character-class width table the textBlock line-breaker uses, so they
|
|
605
|
+
are estimates of the renderer's own layout — a caption that fails these
|
|
606
|
+
will look wrong on the wall; one that passes may still sit a few px off.
|
|
607
|
+
Two more are **opt-in** through the text sprite's `role: "read"` (see the
|
|
608
|
+
`textBlock` sprite): `text-legibility` (WCAG ratio below 4.5:1 against the
|
|
609
|
+
background at the box centre or under the brightest additive layer that can
|
|
610
|
+
sit beneath the line) and `text-safe-area` (the box within 5 % of an edge,
|
|
611
|
+
with its box in `boxes`). Undeclared text is never measured for either.
|
|
527
612
|
`overlap-seams` catches the trap the other channels are blind to: a layer
|
|
528
613
|
whose entities bury each other (so it was drawn as one shape) but whose paint
|
|
529
614
|
settings make every overlap visible. It is gated to stay off deliberate
|
|
@@ -562,6 +647,8 @@ timeline. Discriminated from SaverSpec by `format: 'idle-sequence'`.
|
|
|
562
647
|
"label": "My Sequence",
|
|
563
648
|
"seed": 42, // optional; forwarded to children without their own seed
|
|
564
649
|
"loop": false,
|
|
650
|
+
"sync": "mount", // optional; 'mount' (default) or 'epoch' — see **Sync**
|
|
651
|
+
"bed": { /* SaverSpec */ }, // optional; the ground under every segment — see **Bed**
|
|
565
652
|
"segments": [
|
|
566
653
|
{ "key": "intro", "scene": { /* SaverSpec */ }, "duration": 5000 },
|
|
567
654
|
{ "key": "main", "scene": { /* SaverSpec */ }, "duration": 10000, "advance": "auto" },
|
|
@@ -582,13 +669,61 @@ the presenter never freezes. A durationless final segment holds regardless of
|
|
|
582
669
|
`advance`.
|
|
583
670
|
|
|
584
671
|
**Transitions:** `{ type: 'cut' }` (default) performs a hard switch.
|
|
585
|
-
`{ type: 'morph', dur: number }`
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
`
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
672
|
+
`{ type: 'morph', dur: number, text?: 'step' | 'crossfade' }` interpolates
|
|
673
|
+
**numbers and hex colours** (colour, alpha, pulse, `reveal.progress`, …) over
|
|
674
|
+
`dur` ms when crossing into the next segment; **every other value — strings,
|
|
675
|
+
and `textBlock.text` above all — switches on the first morph frame** under
|
|
676
|
+
the default `text: 'step'`. A caption change therefore does not cross-fade
|
|
677
|
+
under a plain morph: fade text via its colour (glide it into the background
|
|
678
|
+
and back), via `reveal.progress` — or declare **`text: 'crossfade'`**, which
|
|
679
|
+
draws each `text` / `textBlock` layer whose string(s) differ between the two
|
|
680
|
+
segments **twice** for the window: the outgoing words at alpha `1 − k` under
|
|
681
|
+
the incoming at `k`, where `k` is the morph's eased progress, both in the
|
|
682
|
+
lerped frame's paint (colour, `opacity` and layer alpha glide as usual). Only
|
|
683
|
+
the differing text layers cost a second draw, and only for `dur`; layers
|
|
684
|
+
whose words match, and every non-text layer, are untouched. It is an
|
|
685
|
+
internal paint pass, not a spec field — no `opacity` appears on the `text`
|
|
686
|
+
sprite. `text` is a morph option only (a `fade` already cross-fades whole
|
|
687
|
+
frames; a `cut` has no window) and the validator rejects it elsewhere.
|
|
688
|
+
**Default `step` is today's behaviour byte for byte** — the sequence
|
|
689
|
+
baseline pins it — and a flip to `crossfade` by default would be a major
|
|
690
|
+
change, with the baseline regenerated. **Native:** tvOS **steps** the words (it
|
|
691
|
+
ignores `text`) but already cross-fades whole frames on every segment
|
|
692
|
+
change using the transition's `dur`, so the result on the Apple TV is
|
|
693
|
+
close to the web's. Morph requires structurally
|
|
694
|
+
identical adjacent segments (same `structuralSignature`); if they differ, the
|
|
695
|
+
engine falls back to cut and the validator emits a `morph-structural-mismatch`
|
|
696
|
+
warning. When the segments are structural twins whose only differences are
|
|
697
|
+
values morph steps (a text-only change), the morph runs but every frame of it
|
|
698
|
+
shows the incoming segment — it reads as a cut — and the validator emits a
|
|
699
|
+
`morph-nothing-morphable` warning (never an error; stored sequences stay
|
|
700
|
+
valid) — unless the transition declared `text: 'crossfade'` and the words
|
|
701
|
+
differ, in which case the words are the thing that morphs and the warning is
|
|
702
|
+
withheld. During a morph, entity placement inherits the outgoing segment's
|
|
703
|
+
seed — the incoming segment's own seed is unused. `dur` must be between 200
|
|
704
|
+
and 5000 ms.
|
|
705
|
+
|
|
706
|
+
`{ type: 'fade', dur: number }` is the general cross-fade, for segments that
|
|
707
|
+
have nothing in common: the outgoing segment stays alive on a canvas of its
|
|
708
|
+
own for `dur` ms and is composited over the incoming one at
|
|
709
|
+
`1 − easeSmooth(localT / dur)`, so both keep animating through the window. The
|
|
710
|
+
outgoing segment renders at `duration + localT` — it continues rather than
|
|
711
|
+
freezing (and if it was an `advance: 'input'` hold, it resumes from its
|
|
712
|
+
`duration`, not from wherever the hold had reached). A fade is always from the
|
|
713
|
+
previous segment in the list; under `loop: true` the **last** segment's `fade`
|
|
714
|
+
is the wrap's transition into segment 0. It works through the clicker too — a
|
|
715
|
+
`sequence.segment` steer lands at `localT` 0 of the fade. Same `dur` bounds
|
|
716
|
+
as morph, no structural requirement, and a fade only smooths luminance, so the
|
|
717
|
+
flash gate is untouched; it is the remedy for a `boundary-luminance-jump`
|
|
718
|
+
advisory. **Tier gate:** two live segments for `dur` is over the budget of the
|
|
719
|
+
lowest tiers, so a host on the `basic` (canvas2d only) or `minimal` capability
|
|
720
|
+
tier — passed as `capabilityTier` on the `SequenceMountContext`, the tier
|
|
721
|
+
`computeTier` from `@idle-screens/capabilities` reports — renders `fade` as
|
|
722
|
+
`cut`; absent ⇒ fade enabled. `adviseSequence` says so once per sequence with
|
|
723
|
+
the informational `fade-degrades-on-low-tier`. **Native:** tvOS already
|
|
724
|
+
cross-fades on every segment change using the transition's `dur`, so `fade`
|
|
725
|
+
matches the native player rather than diverging from it; native reads
|
|
726
|
+
`fade.dur` where it reads the morph `dur` today.
|
|
592
727
|
|
|
593
728
|
**Time mapping:** global clock `T` maps to `(segmentIndex, localT)` via prefix
|
|
594
729
|
sums of durations. Half-open segments: `[start, start+duration)`. With
|
|
@@ -596,15 +731,102 @@ sums of durations. Half-open segments: `[start, start+duration)`. With
|
|
|
596
731
|
a durationless final segment). An unreleased `advance: 'input'` hold blocks the
|
|
597
732
|
wrap; after a wrap every hold is armed again.
|
|
598
733
|
|
|
734
|
+
**Sync:** `sync` names what the sequence clock is anchored to. `mount`
|
|
735
|
+
(the default, and today's behaviour for every stored sequence) starts `T` at
|
|
736
|
+
0 when the viewer mounts — every joiner sees segment 0, which is what makes a
|
|
737
|
+
pre-roll a pre-roll. `epoch` lets the host seed the clock: the viewer passes
|
|
738
|
+
`sequenceBaseT` (ms already elapsed on the shared clock — idlescreens.com
|
|
739
|
+
passes `Date.now() − scene.epoch`) on the mount context
|
|
740
|
+
(`SequenceMountContext`, a `SaverContext` plus that one field), and the
|
|
741
|
+
instance starts at that `T` instead of 0, so every screen in a room resolves
|
|
742
|
+
the same segment. The trade-off: a viewer joining an `epoch` sequence lands
|
|
743
|
+
**mid-loop**, wherever the room is; `mount` keeps pre-roll semantics. Holds
|
|
744
|
+
are the same under both — `advance: 'input'` is armed for every viewer, so a
|
|
745
|
+
late joiner whose seeded clock is already past an unreleased hold lands **on**
|
|
746
|
+
the held segment (still animating), not past it, and the clicker releases it
|
|
747
|
+
from there. `epoch` without a `sequenceBaseT` starts at 0 (a host that does
|
|
748
|
+
not pass the hint loses nothing). **Native clients that anchor at their own
|
|
749
|
+
mount behave as `mount`** until they read the field and seed from the channel
|
|
750
|
+
epoch. The default is never flipped: pre-roll depends on `mount`.
|
|
751
|
+
|
|
752
|
+
**Bed:** `bed` is one SaverSpec drawn **under every segment on the
|
|
753
|
+
sequence's global clock** — the ground that does not reset. Every segment still
|
|
754
|
+
starts at its own `localT` 0 (builds replay, `emit` phases and `reveal.speed`
|
|
755
|
+
key off segment time exactly as before), but the bed's `T` runs from mount (or
|
|
756
|
+
from `sequenceBaseT` under `sync: 'epoch'`) straight through every boundary,
|
|
757
|
+
and a `sequence.segment` steer displaces the *segments'* clock only — the
|
|
758
|
+
clicker rewinds a slide, never the bed. That is the fix for the boundary
|
|
759
|
+
rewind every ambient reviewer flagged: put the motion that must be continuous
|
|
760
|
+
(the drifting field, the runner-orb, the slow gradient) in the bed and the
|
|
761
|
+
slide content in the segments. Rules:
|
|
762
|
+
|
|
763
|
+
- **The bed owns the ground.** Segments render over it *transparently*: a
|
|
764
|
+
segment's `background` is never painted while a bed exists (the validator
|
|
765
|
+
warns `bed-hides-segment-background` on each segment that declares one), and
|
|
766
|
+
a segment's `ghosting` is ignored (a smear needs an opaque ground to decay
|
|
767
|
+
into; the bed may declare its own `ghosting`, and it works as usual). A
|
|
768
|
+
segment's ink composites over the bed with its own `blend`/`alpha`.
|
|
769
|
+
- **Clock:** bed at `T`, segment at `localT`, in the same frame. Under
|
|
770
|
+
`loop: true` the bed does not wrap with the segments — it keeps counting.
|
|
771
|
+
- **Steering:** `bed.<path>` routes to the bed with the prefix stripped
|
|
772
|
+
(`setParam("bed.field.sprite.color", …)`, `bed.ghosting`, …);
|
|
773
|
+
`sequenceSteerablePaths(seq)` lists them. Without a bed, `bed.*` reaches the
|
|
774
|
+
segments unchanged, so a layer keyed `bed` keeps working. Bed steers are
|
|
775
|
+
not part of the retained segment track — the bed is never re-created.
|
|
776
|
+
- **Seed:** the bed uses its own `seed`, else `seq.seed + 24` (past every
|
|
777
|
+
segment's `seq.seed + index`, so it never shares a stream with segment 0 and
|
|
778
|
+
adding a segment does not re-seat it).
|
|
779
|
+
- **Perf accounting:** the bed is live alongside whichever segment is up, so
|
|
780
|
+
its entities count **together with the largest segment's** toward the 800
|
|
781
|
+
cap (`validateSequence` errors on `bed` when the sum is over) and toward the
|
|
782
|
+
manifest's `costTier`. A `fade` over a bed puts both segments on canvases of
|
|
783
|
+
their own for `dur` (incoming at k, outgoing at 1 − k, both over the bed):
|
|
784
|
+
three live instances on the lowest tier, which is why fade is tier-gated.
|
|
785
|
+
- **Perception:** `perceiveSequenceFrame(seq, T)` composes bed + segment (see
|
|
786
|
+
the perception API above). Bed and segments are assumed to share `units` /
|
|
787
|
+
`referenceViewport`.
|
|
788
|
+
- **Native:** tvOS **ignores `bed` initially** and renders segments with their
|
|
789
|
+
own backgrounds, exactly as it does today — so a sequence authored with a bed
|
|
790
|
+
should still carry sensible segment backgrounds until the native player
|
|
791
|
+
draws the bed (a second compiled scene drawn first, which its layer model
|
|
792
|
+
already supports). Web viewers hide those backgrounds; native shows them.
|
|
793
|
+
- **Not a default.** The "cheap form" — rendering a morph chain's root child at
|
|
794
|
+
`T − segmentStart(chainRoot)` so twins keep one continuous clock — is **not**
|
|
795
|
+
implemented and never will be as a default: stored morph-chained sequences
|
|
796
|
+
pin their per-segment `t = 0` frames (`sequence-baseline.test.ts`). A bed is
|
|
797
|
+
the supported way to keep motion continuous across segments; if a per-segment
|
|
798
|
+
`timebase: 'sequence'` is ever wanted it will be opt-in, after this.
|
|
799
|
+
|
|
800
|
+
Absent `bed` ⇒ the code path is byte for byte what it was (the sequence
|
|
801
|
+
baseline proves it): every segment paints its own ground.
|
|
802
|
+
|
|
599
803
|
**Compilation:** `compileSequence()` returns an ordinary `SaverPlugin` — the
|
|
600
|
-
viewer needs zero changes
|
|
601
|
-
|
|
602
|
-
|
|
804
|
+
viewer needs zero changes (a host that wants `sync: 'epoch'` passes a
|
|
805
|
+
`SequenceMountContext`; a plain `SaverContext` still mounts). All children
|
|
806
|
+
share a single canvas; only the active segment's `SpecInstance` is alive at
|
|
807
|
+
any time — plus the `bed`'s, when one is declared, and for the `dur` of a
|
|
808
|
+
`fade` the outgoing segment on an offscreen canvas of its own. `workerReady`
|
|
809
|
+
is `false` (the worker compile-hook does not dispatch sequences).
|
|
603
810
|
|
|
604
811
|
**Steering:** segment switching uses the `sequence.segment` delta path via
|
|
605
812
|
`applyTrack` (`setParam("sequence.segment", n)` over MCP). The
|
|
606
|
-
`SequenceInstance` intercepts this path before delegation
|
|
607
|
-
|
|
813
|
+
`SequenceInstance` intercepts this path before delegation, and `bed.<path>`
|
|
814
|
+
deltas go to the bed (see **Bed**); every other delta
|
|
815
|
+
is forwarded to the active segment's `applyTrack` **and retained** (last wins
|
|
816
|
+
per path, merged across calls). Segment instances are created lazily and
|
|
817
|
+
disposed at each boundary, so the retained set is re-applied to every segment
|
|
818
|
+
as it comes up: **a steer persists across segment changes and lands on the
|
|
819
|
+
segment that owns the path.** `bars.sprite.values` steered while the title
|
|
820
|
+
slide is up takes effect the moment the chart slide appears (by timer or by
|
|
821
|
+
clicker), and stays if the show leaves and returns; on segments without a
|
|
822
|
+
`bars` key the delta is simply a no-op, as is any delta whose value does not
|
|
823
|
+
validate on that particular segment (the rest of the set still applies). A
|
|
824
|
+
morph's two lerp endpoints carry the retained set too, so a steered colour
|
|
825
|
+
rides through the glide instead of vanishing for `dur` — but a steer that
|
|
826
|
+
lands while the morph itself is in progress takes effect immediately rather
|
|
827
|
+
than gliding over its own `dur`, since the morph's cross-fade is already the
|
|
828
|
+
active transition on that child. Pinned by `sequence.test.ts` →
|
|
829
|
+
"SequenceInstance — retained track".
|
|
608
830
|
|
|
609
831
|
The steer **moves the clock, not the frame**: it displaces the timeline so the
|
|
610
832
|
target segment starts at its own `localT` 0 (its `life.enter` build replays)
|
|
@@ -633,5 +855,7 @@ showcases — `aurora` (wander + coherence + ghosting + pulse.wave),
|
|
|
633
855
|
`nostalghia-candle` and `haiku` (restraint and text); and the 2026-09 trio —
|
|
634
856
|
`pings` (emit + grow + ease: one event at a time), `facets` (polygon, stroke
|
|
635
857
|
and feathered rect) and `relay-board` (list layout + bar: a chart in five
|
|
636
|
-
layers)
|
|
858
|
+
layers); and `lobby-talk` (one screen of a quarter-in-review, every text
|
|
859
|
+
layer `role: "read"`, zero advisories — readable copy over additive
|
|
860
|
+
atmosphere). See [`src/examples/`](./src/examples/). The dashboard exercises the
|
|
637
861
|
static/HUD subset at scale (34 layers of keyed, positioned text).
|