@idle-screens/schema 3.13.0 → 3.15.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 +183 -22
- package/dist/index.d.ts +362 -18
- package/dist/index.js +1419 -174
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
- package/saver-spec.schema.json +69 -5
package/FORMAT.md
CHANGED
|
@@ -48,7 +48,11 @@ scene's own clock), paint-level layer `opacity` and `transform`,
|
|
|
48
48
|
`text: 'dip'` on `morph` (2026-09-25 — timed scenes); layer `groups` and
|
|
49
49
|
`transform.origin` (2026-09-26 — groups and origin); path drawings on
|
|
50
50
|
`polygon` — `path`, `fillRule`, `fill`, `gradient`, `stroke`, `parts`,
|
|
51
|
-
`span`, `origin` (2026-10-17 — vector drawings)
|
|
51
|
+
`span`, `origin` (2026-10-17 — vector drawings); `draw` on `stroke` sprites
|
|
52
|
+
and path strokes, the `in` / `out` / `spring` glide eases (timeline keys and
|
|
53
|
+
live steers), `rock`, `life.stagger`, `reveal.rise`, `textBlock.number`, the
|
|
54
|
+
`wipe` transition, `scenePeriod` and the `loop-seam` advisory
|
|
55
|
+
(2026-10-25 — motion vocabulary).
|
|
52
56
|
|
|
53
57
|
## Safety invariants
|
|
54
58
|
|
|
@@ -90,6 +94,12 @@ Two validators exist with deliberately different strictness:
|
|
|
90
94
|
|
|
91
95
|
Author against the JSON Schema; ship through the runtime validator.
|
|
92
96
|
|
|
97
|
+
An unknown field that is another motion tool's vocabulary gets a pointer in
|
|
98
|
+
its `unknown-property` warning: `delay` on a layer names `life.enter`,
|
|
99
|
+
`duration` on a timeline key names `dur`, `fps` or `bpm` at the top level
|
|
100
|
+
says a scene is a pure function of time in ms. The code and path are the
|
|
101
|
+
same as any unknown field's; only the message says what to write instead.
|
|
102
|
+
|
|
93
103
|
Rules the JSON Schema cannot fully express (the runtime enforces them):
|
|
94
104
|
total entities across all layers ≤ 800; every `[min, max]` range must satisfy
|
|
95
105
|
`min ≤ max`; `colorWeights` length must match `colors`; `emit.life ≤
|
|
@@ -287,16 +297,17 @@ own `finish` is not steerable; steer a segment's, or republish.
|
|
|
287
297
|
| `spin` | number \| `[min,max]` ±360 deg/sec | none | per-entity rotation (seeded start angle — drawn from the scene's one shared seeded stream, so it is reproducible for a given spec, seed **and viewport** but not a fixed per-entity value: density scaling changes how many entities draw before it, and declaring a `rotate` range adds a draw per entity, so either one changes the angles; use `rotate` for an angle you need to pin); a range gives each entity a seeded speed (confetti, tumbling debris). `[0, 0]` does **not** hold the seeded angle — a zero speed renders at 0; use `rotate` |
|
|
288
298
|
| `rotate` | number \| `[min,max]` ±360 deg | none | **static** per-entity rotation: a scalar turns the whole layer, a range gives each entity a seeded angle (thrown blades, scattered glyphs, a tilted grid). Adds to `spin`'s start angle. Structural. **Native:** tvOS reads `spin` only ⇒ 0 |
|
|
289
299
|
| `grow` | `{amp ≤ 0.8, period ≥ 500}` | none | size breathing (seeded phase) |
|
|
300
|
+
| `rock` | `{amp ≤ 45, period ≥ 1000}` (degrees, ms) | none | a **swing** about each entity's resting angle (`rotate` plus any `spin`): `amp` degrees either side, once per `period` there and back, on a seeded per-entity phase — a boat on a swell, a hanging sign, a lantern. A `clock` replaces the seeded phase (the layer swings in step) and then needs `period / rate ≥ 1000`. Structural, like `pulse`; its phases are drawn in a pass after the layer's entities, only when declared, so declaring it re-seats nothing else in the layer. Rotation is not in the luminance grid, so perception does not see it. **Native:** ignored ⇒ no swing |
|
|
290
301
|
| `trail` | `{length ≤ 5000, fade?}` | none | analytic afterglow trail; `length` is **milliseconds** of history (max 5000), `fade` a number 0..1 (not a boolean — `links.falloff` in the next row is the boolean). Echoes of a shaped sprite (`polygon`, `stroke`, `rect`, `ring`, `streak`, `bar`) are the sprite's own shape as it was then, shrinking toward the tail; `circle`, `emoji` and text trail discs. A hard `circle`'s trail is one continuous ribbon tapering behind it, so a quick path draws a line rather than a string of dots (a layer whose ribbons would cover more than a tenth of the screen draws its echo discs instead, to bound the cost); a `soft` circle's echoes are filled in where they would part, within a small per-layer budget |
|
|
291
302
|
| `links` | see below | none | inter-entity lines |
|
|
292
|
-
| `layout` | `{type: "grid", columns?, jitter?}` \| `{type: "list", gap?}` \| `{type: "table", columns, gap?}` | scatter | `grid`: cells fill `region`, `jitter` scalar or `{x?, y?}` 0..1 per axis. **`list` / `table`: data layouts** — entities in reading order from `position` (any count) or centred in `region`, `gap` apart (viewport units of `min(w,h)`, default 0.06; a number or `{x?, y?}`). **`gap` is the pitch between cell anchors, not the space between cells** — a label wider than the column pitch runs into its neighbour (the `layout-text-overlap` advisory names the gap that clears it), so size `gap.x` to the widest label. Text/emoji take `strings[i]` / `glyphs[i]` **in order**, so N labels are one layer; a shaped sprite's palette (`colors[i]` on `bar`, `rect`, `circle`…) is read in order too. **Text and emoji have no `colors`**: a `text` layer paints every label in its one `color` (a `colors` on it is an unknown property, ignored with a warning) — use one layer per label colour |
|
|
293
|
-
| `life` | `{enter?, exit?, fade?}` ms | always on | act structure: fade the layer in at `enter`, out at `exit` |
|
|
303
|
+
| `layout` | `{type: "grid", columns?, jitter?}` \| `{type: "list", gap?}` \| `{type: "table", columns, gap?}` (`gap`: a number or `{x?, y?}`) | scatter | `grid`: cells fill `region`, `jitter` scalar or `{x?, y?}` 0..1 per axis. **`list` / `table`: data layouts** — entities in reading order from `position` (any count) or centred in `region`, `gap` apart (viewport units of `min(w,h)`, default 0.06; a number or `{x?, y?}` — a list is one column, so only its `y`, the row pitch, applies). **`gap` is the pitch between cell anchors, not the space between cells** — a label wider than the column pitch runs into its neighbour (the `layout-text-overlap` advisory names the gap that clears it), so size `gap.x` to the widest label. Text/emoji take `strings[i]` / `glyphs[i]` **in order**, so N labels are one layer; a shaped sprite's palette (`colors[i]` on `bar`, `rect`, `circle`…) is read in order too. **Text and emoji have no `colors`**: a `text` layer paints every label in its one `color` (a `colors` on it is an unknown property, ignored with a warning) — use one layer per label colour |
|
|
304
|
+
| `life` | `{enter?, exit?, fade?, stagger?}` ms | always on | act structure: fade the layer in at `enter`, out at `exit`. `stagger` shifts entity *i*'s whole envelope (enter and exit) by *i* × `stagger` in entity order (reading order for `list` / `table` / `grid`), so a row of labels builds one after another; paint, no RNG draws. Density scaling changes the count, so the build takes (count − 1) × `stagger`. **Native:** ignored ⇒ all together |
|
|
294
305
|
| `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 |
|
|
295
306
|
| `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` |
|
|
296
307
|
| `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`) |
|
|
297
308
|
| `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 |
|
|
298
309
|
| `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 |
|
|
299
|
-
| `transform` | `{x?, y?, scale?, scaleX?, rotate?, origin?}` | identity | **paint-level** transform of the whole layer, about the viewport centre — or, with `origin: "anchor"`, about the layer's own anchor (its `position` point with `dx`/`dy`; a `list`/`table` block's anchor; without `position`, the centre of its `region`), so a word scales **in place** on every aspect (`origin` is an enum: not a steer or key target, and it steps across a morph — keep one origin per layer): 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 |
|
|
310
|
+
| `transform` | `{x?, y?, scale?, scaleX?, rotate?, origin?}` | identity | **paint-level** transform of the whole layer, about the viewport centre — or, with `origin: "anchor"`, about the layer's own anchor (its `position` point with `dx`/`dy`; a `list`/`table` block's anchor; without `position`, the centre of its `region`), so a word scales **in place** on every aspect (`origin` is an enum: not a steer or key target, and it steps across a morph — keep one origin per layer): 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). **Order:** `scale`/`scaleX` (along the layer's own horizontal) and `rotate` apply about the origin first, then `x`/`y` translate in **screen space** — an offset is never turned or stretched by the layer's own `rotate`/`scale`, so `{ "rotate": 90, "x": 0.2 }` moves the layer right, not down. A group's transform wraps the result (*group(layer(p))*), so a group's `scale` and `rotate` do act on its members' offsets. 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 |
|
|
300
311
|
| `group` | string | none | the name of a `groups` entry this layer paints through — see **Groups**. **Native:** ignored |
|
|
301
312
|
|
|
302
313
|
`links`: `{ k: 1..8, maxDist, color?, alpha?, width?, mode?, falloff?, closed? }`.
|
|
@@ -364,7 +375,9 @@ for `"nearest"` and `"random"`; a `"chain"` may omit both, except that
|
|
|
364
375
|
`parts`).
|
|
365
376
|
- **Holes and pieces.** Several subpaths in one `path` are one drawing.
|
|
366
377
|
`fillRule: "auto"` (default) decides by containment: a subpath lying
|
|
367
|
-
entirely inside another is a hole
|
|
378
|
+
entirely inside another is a hole — touching its container's edge counts
|
|
379
|
+
as inside, so a notch cut from the rim or a mouth meeting the face's
|
|
380
|
+
outline is a hole too (a vertex within 0.001 of the edge is on it); one inside a hole is solid again (a
|
|
368
381
|
pupil in an eye-spot ring); pieces that merely overlap merge (branches,
|
|
369
382
|
veins, leaflets on a stem), and a hole cuts through every overlapping
|
|
370
383
|
piece of the layer (a window across two walls). `"nonzero"` /
|
|
@@ -383,6 +396,12 @@ for `"nearest"` and `"random"`; a `"chain"` may omit both, except that
|
|
|
383
396
|
— an outline along every subpath **as drawn** (holes included; open
|
|
384
397
|
subpaths too). `width` is in unit-box units (0.01 = 1% of the drawing's
|
|
385
398
|
half-size). Overlapping pieces show their inner edges, as in SVG.
|
|
399
|
+
`draw: {start?, end?}` on a stroke paints only that stretch of it, the
|
|
400
|
+
part's subpaths counted as one pen run in the order the path draws them
|
|
401
|
+
(a closed ring includes its closing edge, and comes back open when cut) —
|
|
402
|
+
`fill: false` line art plus a glide of `stroke.draw.end` is a drawing
|
|
403
|
+
that draws itself. Paint; `…stroke.draw.end` is a key target like the
|
|
404
|
+
stroke's width. **Native:** ignored ⇒ the whole outline.
|
|
386
405
|
- **`fill: false`** draws the stroke only — line art. Then open paths
|
|
387
406
|
(no `Z`) are fine: veins, hatching, antennae, rigging.
|
|
388
407
|
- **`parts`** — a multi-colour drawing in one layer:
|
|
@@ -411,18 +430,26 @@ for `"nearest"` and `"random"`; a `"chain"` may omit both, except that
|
|
|
411
430
|
- **Steering and morphs.** Part colours, gradient stops, stroke widths
|
|
412
431
|
and opacities glide like any paint. Two paths with the **same commands**
|
|
413
432
|
(same letters, same count of numbers — draw both from one template)
|
|
414
|
-
morph number by number; any other path change steps.
|
|
433
|
+
morph number by number; any other path change steps. The full list of
|
|
434
|
+
what a `setParam` or `timeline` key can address is under **Timeline**
|
|
435
|
+
("Path drawings: what a key can address").
|
|
415
436
|
- Drawings are rendered once and reused while they look the same (moving
|
|
416
437
|
copies are resampled), so a detailed still drawing costs about what a
|
|
417
438
|
flat one does per frame.
|
|
418
439
|
- `{ "kind": "stroke", "length": [0.08, 0.18], "points": [[-1, 0.3], [-0.5, -0.6], [0.3, -0.7], [1, 0.1]], "width": 0.004, "taper": true, "color": "#f2e8c9" }` —
|
|
419
440
|
a freehand mark: a path through 2..24 unit-coordinate points, scaled so the
|
|
420
441
|
unit box spans the seeded `length`, stroked `width` wide with round joins.
|
|
421
|
-
`curve: "smooth"` (default) is a Catmull-Rom spline, `"linear"` a polyline
|
|
442
|
+
`curve: "smooth"` (default) is a Catmull-Rom spline, `"linear"` a polyline
|
|
443
|
+
through every point (its corners stay sharp);
|
|
422
444
|
`taper` thins the mark to almost nothing at both ends (a brush stroke rather
|
|
423
445
|
than a line; it paints as one filled mark, so a translucent stroke has no
|
|
424
446
|
overlap beads); `orient: true` turns the path's +x along the entity's heading,
|
|
425
|
-
like `streak`. Rotates with `spin`.
|
|
447
|
+
like `streak`. Rotates with `spin`. **`draw: {start?, end?}`** paints only
|
|
448
|
+
the stretch of the mark between those fractions of its length (defaults 0
|
|
449
|
+
and 1) — glide `end` 0 → 1 and the mark draws itself on; glide `start` after
|
|
450
|
+
it and it erases from the tail. Paint (it glides, it is outside the
|
|
451
|
+
structural signature), and perception scales the ink with it.
|
|
452
|
+
**Native:** ignored ⇒ the whole mark. Van Gogh's stroke, Hokusai's contour,
|
|
426
453
|
O'Keeffe's petal, Basquiat's scrawl
|
|
427
454
|
- `{ "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 }` —
|
|
428
455
|
multi-line text block with deterministic line-breaking. All dimensions are
|
|
@@ -437,6 +464,9 @@ for `"nearest"` and `"random"`; a `"chain"` may omit both, except that
|
|
|
437
464
|
wrapping is identical across platforms; note that the table is approximate
|
|
438
465
|
— a painted line may slightly exceed `maxWidth` when the real font is wider
|
|
439
466
|
than the table estimates, so `maxWidth` is a layout target, not a hard clip.
|
|
467
|
+
Full-width glyphs (CJK ideographs, kana, Hangul, fullwidth forms) count a
|
|
468
|
+
whole em in every table; lines break only at whitespace, so an unspaced CJK
|
|
469
|
+
run stays on one line — break it with spaces or `\n`.
|
|
440
470
|
Use with `count: 1`, `motion: { type: "static" }`, and `position`.
|
|
441
471
|
|
|
442
472
|
`anchor` (one of nine compass points: `top-left`, `top`, `top-right`,
|
|
@@ -461,7 +491,7 @@ for `"nearest"` and `"random"`; a `"chain"` may omit both, except that
|
|
|
461
491
|
rejected** (`fontSize` owns size, so the block keeps scaling with the
|
|
462
492
|
viewport). A monospace family (`monospace`, `ui-monospace`, Menlo, Courier,
|
|
463
493
|
SF Mono, Fira Code, …) switches the line-breaker to a uniform 0.6 em
|
|
464
|
-
advance so mono wraps land where the real face puts them; every other
|
|
494
|
+
advance (1 em for a full-width glyph) so mono wraps land where the real face puts them; every other
|
|
465
495
|
family uses the proportional table. Default `system-ui, sans-serif`.
|
|
466
496
|
**Native clients fall back to the system face** until a face table exists.
|
|
467
497
|
`font` names faces **installed on the viewing device** — nothing is
|
|
@@ -490,7 +520,9 @@ for `"nearest"` and `"random"`; a `"chain"` may omit both, except that
|
|
|
490
520
|
across the room, and opts that layer into two advisories:
|
|
491
521
|
`text-legibility` fires when the text colour's WCAG ratio falls below
|
|
492
522
|
**4.5:1** against the background sampled at the box centre (gradient stops
|
|
493
|
-
interpolated at rest, `band` honoured)
|
|
523
|
+
interpolated at rest, `band` honoured) — or, where an earlier static,
|
|
524
|
+
hard-edged, unblended layer covers the whole box (a backing chip), against
|
|
525
|
+
that layer composited over the page — **or** against the brightest
|
|
494
526
|
additive layer (`blend: lighter` / `screen`) whose entities can reach the
|
|
495
527
|
box and are at least a glyph tall, taken at its peak alpha (base + pulse)
|
|
496
528
|
composited over that background — a glow parked under a caption is
|
|
@@ -520,7 +552,11 @@ for `"nearest"` and `"random"`; a `"chain"` may omit both, except that
|
|
|
520
552
|
progress — small reads as a soft typewriter, large as a wave of
|
|
521
553
|
overlapping fades. Still a pure function of `progress`, so it glides and
|
|
522
554
|
deletes exactly like the other modes. Native clients render `glyphFade`
|
|
523
|
-
as `typewriter` until they gain per-glyph alpha. `
|
|
555
|
+
as `typewriter` until they gain per-glyph alpha. With `glyphFade`, `rise`
|
|
556
|
+
(0..1 em, default 0) lifts each glyph into its line as it fades in, from
|
|
557
|
+
`rise` em below — still a pure function of progress, and layout never
|
|
558
|
+
moves (it warns and is ignored on the other modes; native ignores it).
|
|
559
|
+
`caret` draws a
|
|
524
560
|
blinking caret at the frontier (`true`, or `{ "blink": 1.2, "color": "#fff" }`;
|
|
525
561
|
blink is in full cycles/sec, capped at 3 for flash safety, and is a square
|
|
526
562
|
wave of `t` like every other animation). With `align: "center"`/`"right"`
|
|
@@ -533,6 +569,20 @@ for `"nearest"` and `"random"`; a `"chain"` may omit both, except that
|
|
|
533
569
|
sequence, a morph declared `text: 'crossfade'` cross-fades the old words
|
|
534
570
|
under the new ones instead (see **Transitions** below).
|
|
535
571
|
|
|
572
|
+
textBlock also accepts **`number`** — a counter:
|
|
573
|
+
`{ "number": { "value": 1200, "decimals": 0, "prefix": "", "suffix": " visitors", "grouping": true } }`.
|
|
574
|
+
The block paints `prefix + value + suffix` instead of `text`, `value`
|
|
575
|
+
rounded to `decimals` (0..6, default 0) with its thousands grouped by
|
|
576
|
+
commas unless `grouping: false` — formatted by the engine, never by the
|
|
577
|
+
viewer's locale, so every screen shows the same digits. `value` is
|
|
578
|
+
**paint**: `setParam("stats.sprite.number.value", 48210, { dur: 4000 })`
|
|
579
|
+
rolls the digits up over the glide, and a timeline key does the same.
|
|
580
|
+
Line-breaking runs on the formatted string, so a counter that gains a
|
|
581
|
+
digit can re-wrap: give it width to spare and an `anchor` on the edge that
|
|
582
|
+
should stay put. Perception and the text advisories read the formatted
|
|
583
|
+
number. `text` is what clients that do not read `number` show — author it
|
|
584
|
+
as the counter's rest value. **Native:** ignored ⇒ `text`.
|
|
585
|
+
|
|
536
586
|
`circle`, `ring`, `streak`, `rect`, `bar`, `polygon` and `stroke` all accept
|
|
537
587
|
`colors: [...]` (seeded per-entity palette pick) and `colorWeights: [...]`
|
|
538
588
|
(relative weights, same length — "mostly cool tones, occasional ember").
|
|
@@ -550,10 +600,29 @@ and configured the second.
|
|
|
550
600
|
|
|
551
601
|
### `motion` (one of)
|
|
552
602
|
|
|
603
|
+
Every field each motion type takes — **bold** is required; anything else
|
|
604
|
+
on a motion is an unknown property, ignored with a warning. Lengths are
|
|
605
|
+
fractions of `min(w, h)` like every size (px under `units: "px"`).
|
|
606
|
+
|
|
607
|
+
| `type` | fields | units and ranges |
|
|
608
|
+
| --- | --- | --- |
|
|
609
|
+
| `drift` | **`speed`**, `angle`, `bidirectional`, `bob`, `ease` | `speed` [min,max] per second; `angle` degrees (0 = right, 90 = down); `bob` vertical wobble amplitude (typ. 0.002–0.02) |
|
|
610
|
+
| `rise` | **`speed`**, `sway`, `ease` | `speed` [min,max] per second, upward; `sway` horizontal amplitude (typ. 0.002–0.02) |
|
|
611
|
+
| `bounce` | **`speed`** | `speed` [min,max] per second, diagonal |
|
|
612
|
+
| `static` | — | none |
|
|
613
|
+
| `orbit` | **`speed`**, **`radius`**, `center` | `speed` [min,max] **degrees** per second (negative turns the other way, ≤ 180); `radius` [min,max]; `center` `{x, y}` 0..1 or `{layer}` |
|
|
614
|
+
| `wander` | **`speed`**, `angle`, `meander`, `coherence`, `ease` | `speed` [min,max] per second; `angle` degrees (omit for a seeded heading); `meander` amplitude ≤ 0.463; `coherence` 0..1 |
|
|
615
|
+
| `warp` | **`speed`**, `center` | `speed` [min,max] **depth-units** per second (≤ 1.5); `center` `{x, y}` 0..1 |
|
|
616
|
+
| `path` | **`points`**, **`duration`**, `curve`, `closed`, `scatter` | `points` 2..24 `{x, y}` 0..1; `duration` ms per lap (≥ 2000); `curve` `"smooth"` \| `"linear"`; `closed` boolean; `scatter` > 0 |
|
|
617
|
+
|
|
618
|
+
`ease` is `{ "type": "settle" | "buoyant", "tau": ms }` (below). `bob` and
|
|
619
|
+
`sway` warn `motion-amplitude` above 0.25: `bob: 4` swings the layer four
|
|
620
|
+
screen-heights each way, a px value written into a viewport-unit spec.
|
|
621
|
+
|
|
553
622
|
- `{ "type": "drift", "speed": [0.02, 0.06], "angle": 0, "bidirectional": true, "bob": 0.004 }`
|
|
554
623
|
— heading in degrees (0 = right, 90 = down); `bidirectional` flips horizontal
|
|
555
|
-
direction per entity (fish); `bob` adds vertical wobble
|
|
556
|
-
- `{ "type": "rise", "speed": [0.01, 0.03], "sway": 0.006 }` — upward, with horizontal sway (bubbles)
|
|
624
|
+
direction per entity (fish); `bob` adds vertical wobble (0.004 ≈ 4 px at 1080p)
|
|
625
|
+
- `{ "type": "rise", "speed": [0.01, 0.03], "sway": 0.006 }` — upward, with horizontal sway (bubbles; 0.006 ≈ 6 px at 1080p)
|
|
557
626
|
- `{ "type": "bounce", "speed": [0.12, 0.24] }` — diagonal, reflecting off edges
|
|
558
627
|
- `{ "type": "static" }` — stays exactly where placed (use with `position` for HUD text)
|
|
559
628
|
- `{ "type": "orbit", "speed": [10, 40], "radius": [0.05, 0.2], "center": { "x": 0.5, "y": 0.5 } }`
|
|
@@ -773,6 +842,12 @@ Paths that are **paint** (colours, `alpha` inside a sprite's paint, layer
|
|
|
773
842
|
pop. Prefer `opacity` over `alpha` and `transform` over `position` for
|
|
774
843
|
anything that should move smoothly.
|
|
775
844
|
|
|
845
|
+
A live steer glides `smooth` unless a delta names `ease: "in"`, `"out"` or
|
|
846
|
+
`"spring"` (the longest delta's ease shapes the glide). `step`, `linear` and
|
|
847
|
+
`smooth` on a steer still mean smooth: tracks carried those names before
|
|
848
|
+
steers read an ease at all, and honouring them now would change how every
|
|
849
|
+
stored track replays.
|
|
850
|
+
|
|
776
851
|
## Timeline — keyframes on the scene's clock
|
|
777
852
|
|
|
778
853
|
A SaverSpec can carry authored change on its **own** clock — the same clock
|
|
@@ -794,7 +869,12 @@ its drift, orbit and pulse run on (from mount; a sequence segment's `localT`):
|
|
|
794
869
|
(default 1000, the live-steer default; `0` is a cut; at most 30000 — a
|
|
795
870
|
longer `dur` fails validation for the whole spec, so split a slower glide
|
|
796
871
|
into consecutive keys of ≤ 30 s each), eased `smooth`
|
|
797
|
-
(default), `linear
|
|
872
|
+
(default), `linear`, `step` (holds, then switches at the glide's end),
|
|
873
|
+
`in` (accelerates from rest — a departure), `out` (leaves at speed and
|
|
874
|
+
decelerates into place — an arrival) or `spring` (a critically damped
|
|
875
|
+
spring: a quick start that settles with no overshoot). Every ease is
|
|
876
|
+
monotone, so a glide never leaves the range its two values bound.
|
|
877
|
+
**Native:** an ease it does not know reads as `smooth`.
|
|
798
878
|
Keys address the same dot-paths as `setParam` — index or `key` form — and a
|
|
799
879
|
path must already exist on the spec (a key cannot add a field). The spec must
|
|
800
880
|
still validate with each key's value applied.
|
|
@@ -809,7 +889,38 @@ its drift, orbit and pulse run on (from mount; a sequence segment's `localT`):
|
|
|
809
889
|
Keys are also checked **together**: two keys that are each valid can
|
|
810
890
|
combine past a floor (a `clock.rate` and a `pulse.period` share one), so
|
|
811
891
|
the validator resolves the scene at every key's start, end and glide
|
|
812
|
-
quarter-points and rejects it there.
|
|
892
|
+
quarter-points and rejects it there. Paint keys on different paths can
|
|
893
|
+
also combine into a faster flicker than any one path allows: a top
|
|
894
|
+
layer's `opacity` switching between its own colour and the colour
|
|
895
|
+
beneath, each keyed at the floor and out of step, flickers the whole
|
|
896
|
+
frame 5 times a second. So where any one second holds 7 or more key
|
|
897
|
+
boundaries on paint paths (opacity, alpha, colours, stops, gradients,
|
|
898
|
+
strokes), publishing resolves the frame there and refuses more than 3
|
|
899
|
+
flashes a second over a quarter of the screen (WCAG 2.3.1,
|
|
900
|
+
`timelineFlashReport`). Keep big brightness changes at least ~170 ms
|
|
901
|
+
apart across the whole frame, not just per path. The count is also taken
|
|
902
|
+
per cell of the check's 16 × 9 grid, from the keys on the layers that can
|
|
903
|
+
paint that cell: a staggered wave of small static dots is busy across the
|
|
904
|
+
frame but sparse in every cell, so it passes without a frame resolved. A
|
|
905
|
+
layer that moves, emits, links or has its geometry keyed counts as
|
|
906
|
+
reaching every cell. Past 512 frames to resolve, publishing refuses and
|
|
907
|
+
says the timeline is too dense to verify: put fewer paint keys in that
|
|
908
|
+
second, or align them to shared times.
|
|
909
|
+
- **Path drawings: what a key can address.** Every field of a path
|
|
910
|
+
polygon is paint, so all of these glide (`N` is a part's index):
|
|
911
|
+
`sprite.path` and `sprite.parts.N.path` (a morph number by number when the
|
|
912
|
+
commands match, else a step); `sprite.color` / `sprite.parts.N.color`;
|
|
913
|
+
`sprite.parts.N.opacity`; `…stroke.width`, `…stroke.color`,
|
|
914
|
+
`…stroke.opacity`; `…gradient.stops.M.color` / `.opacity` / `.at`; and a
|
|
915
|
+
gradient's `from` / `to` / `center` / `radius` / `focus`. A key can only
|
|
916
|
+
address a field the spec **declares** — key `parts.N.opacity` or a
|
|
917
|
+
stroke's `opacity` only after writing it on the part (`"opacity": 1`).
|
|
918
|
+
A **gradient paints over the flat colour**: a `color` key on a part (or
|
|
919
|
+
path sprite) that has a `gradient` changes nothing, and the validator
|
|
920
|
+
warns `timeline-key-no-effect` — key its stops instead. A keyed path may
|
|
921
|
+
**collapse to nothing** (an eye blinking shut, a mouth closing to a line):
|
|
922
|
+
at those moments that part draws nothing, rather than the scene being
|
|
923
|
+
refused. The base spec's own paths must still draw something.
|
|
813
924
|
- A key on a **structural** path rebuilds the scene at that key — a pop, and
|
|
814
925
|
the validator warns `timeline-structural-key`. Animate paint: colours,
|
|
815
926
|
`opacity`, `transform`, polygon `points` (a whole equal-length array
|
|
@@ -840,9 +951,15 @@ Each steer is applied once per server stamp, so a host re-sending the whole
|
|
|
840
951
|
track (idle-server does, on every steer) never re-arms a hold the timeline has
|
|
841
952
|
taken back. A viewer that mounts later replays the stored track at its own
|
|
842
953
|
mount: a steer on an animated path shows there until that path's next key
|
|
843
|
-
after the mount, while screens that saw it live have already moved on.
|
|
844
|
-
|
|
845
|
-
|
|
954
|
+
after the mount, while screens that saw it live have already moved on. Inside
|
|
955
|
+
a `sync: 'epoch'` sequence every viewer shares the channel's clock (the
|
|
956
|
+
idlescreens.com viewer passes `sequenceBaseT` from idle-server PR 247 on), so
|
|
957
|
+
playback lines up. That aligns playback, not steers: a steer is still armed at
|
|
958
|
+
the receiving screen's current time rather than the server stamp, so a stored
|
|
959
|
+
track replayed at a later mount can still hold a path until a different key
|
|
960
|
+
than a screen that saw it live. A timed scene on its own, or a `mount`
|
|
961
|
+
sequence, runs on each screen's own clock, so screens mounted at different
|
|
962
|
+
times can briefly disagree on such a path.
|
|
846
963
|
|
|
847
964
|
**One format for both use cases.** An ambient scene is a SaverSpec without a
|
|
848
965
|
timeline — nothing about it changes. A timed piece (an ident, a film, a
|
|
@@ -962,14 +1079,40 @@ moves and scales its positions and sizes (sprite rotation is not modelled).
|
|
|
962
1079
|
will look wrong on the wall; one that passes may still sit a few px off.
|
|
963
1080
|
Two more are **opt-in** through the text sprite's `role: "read"` (see the
|
|
964
1081
|
`textBlock` sprite): `text-legibility` (WCAG ratio below 4.5:1 against the
|
|
965
|
-
background at the box centre or
|
|
966
|
-
sit beneath the line) and `text-safe-area` (the box within 5 % of an edge,
|
|
1082
|
+
background at the box centre, or the solid chip painted under the whole box,
|
|
1083
|
+
or under the brightest additive layer that can sit beneath the line) and `text-safe-area` (the box within 5 % of an edge,
|
|
967
1084
|
with its box in `boxes`). Undeclared text is never measured for either.
|
|
968
1085
|
`overlap-seams` catches the trap the other channels are blind to: a layer
|
|
969
1086
|
whose entities bury each other (so it was drawn as one shape) but whose paint
|
|
970
1087
|
settings make every overlap visible. It is gated to stay off deliberate
|
|
971
1088
|
washes — it needs at least 6 entities and a mean base alpha of 0.45, and
|
|
972
|
-
never fires under `blend: lighter` / `screen`.
|
|
1089
|
+
never fires under `blend: lighter` / `screen`. Nor does it fire when the
|
|
1090
|
+
only seam is a multi-colour palette and the shapes bury their neighbours'
|
|
1091
|
+
outlines but under 15 % of their area — rows or tiles butted edge to edge
|
|
1092
|
+
(overlapping by a hairline to hide the gap) are banding, not a pile. No
|
|
1093
|
+
shipped example trips it.
|
|
1094
|
+
`motion-intensity-mismatch` fires when a scene declares
|
|
1095
|
+
`motionIntensity: "calm"` but a layer of shapes, glyphs or text spins
|
|
1096
|
+
faster than 90°/s on average (shipped calm spins stay at or under 32°/s).
|
|
1097
|
+
`flash-near-limit` fires when a timeline's luminance keys, resolved over
|
|
1098
|
+
their densest one-second window, flicker at least 2 times a second over a
|
|
1099
|
+
quarter of the frame — a full-frame step at the 200 ms key floor is 2.5, which
|
|
1100
|
+
the publish-time WCAG gate (more than 3) lets through.
|
|
1101
|
+
`loop-seam` fires when a scene declares a looping timeline and the rest
|
|
1102
|
+
of the scene repeats too, but on a period the lap does not divide — so
|
|
1103
|
+
one lap can never come round to its own first frame (it names the period
|
|
1104
|
+
to make the lap a multiple of). A scene whose other motion never repeats
|
|
1105
|
+
(drifting stars, wandering motes) is not judged: that is ambient by
|
|
1106
|
+
design. `scenePeriod(spec)` is the analysis behind it — the least common
|
|
1107
|
+
multiple of every periodic part (pulse, grow, rock, spin, cycle, orbit,
|
|
1108
|
+
path laps, emit, caret, background drift, a looping timeline), `0` for a
|
|
1109
|
+
static scene, or `null` with the reasons when something never repeats
|
|
1110
|
+
(seeded speed ranges, wrapping travel, a `life` build, a non-looping
|
|
1111
|
+
timeline, a self-typing reveal, animated grain) — and likewise when a part
|
|
1112
|
+
repeats between whole milliseconds (`spin: 7` laps every 51428.57 ms, which
|
|
1113
|
+
no whole-ms period reproduces) or a timeline key moves a period, rate or
|
|
1114
|
+
speed (the cycle itself then varies). An exporter reads it to cut an exact
|
|
1115
|
+
loop.
|
|
973
1116
|
The two density advisories read the spec's declared `density` first:
|
|
974
1117
|
`sparse-scene` is withheld under `density: "sparse"` and `dense-scene`
|
|
975
1118
|
under `"dense"`, and `density-mismatch` fires instead when the measured
|
|
@@ -1107,6 +1250,20 @@ cross-fades on every segment change using the transition's `dur`, so `fade`
|
|
|
1107
1250
|
matches the native player rather than diverging from it; native reads
|
|
1108
1251
|
`fade.dur` where it reads the morph `dur` today.
|
|
1109
1252
|
|
|
1253
|
+
`{ type: 'wipe', dur, shape?, origin?, angle?, feather? }` reveals the
|
|
1254
|
+
incoming segment through a moving edge instead of a dissolve: `shape:
|
|
1255
|
+
'circle'` (default) is an iris opening from `origin` (`{x, y}` 0..1,
|
|
1256
|
+
default the centre) until it clears the farthest corner; `'line'` sweeps a
|
|
1257
|
+
straight edge along `angle` degrees (0 = left to right, 90 = top to
|
|
1258
|
+
bottom). `feather` (0..1, default 0.08) is the edge's softness, as a
|
|
1259
|
+
fraction of the distance it travels. It runs on `fade`'s machinery — both
|
|
1260
|
+
segments live on canvases of their own for `dur`, each composited through
|
|
1261
|
+
the edge's mask — with the same `dur` bounds, the same tier gate (`basic` /
|
|
1262
|
+
`minimal` hosts cut, and `fade-degrades-on-low-tier` counts wipes), and the
|
|
1263
|
+
same answer to `boundary-luminance-jump`: every pixel changes once, a
|
|
1264
|
+
spatial ramp, never a flash. `text` is a morph option and is rejected on a
|
|
1265
|
+
wipe. **Native:** tvOS cross-fades over `dur`.
|
|
1266
|
+
|
|
1110
1267
|
**Wrap morph:** under `loop: true` a morph declared on the **last** segment
|
|
1111
1268
|
is ignored at the wrap — the lap cuts back to segment 0, and every stored
|
|
1112
1269
|
sequence expects that. `wrapMorph: true` on the envelope honours it: the lap
|
|
@@ -1287,5 +1444,9 @@ layers); and `lobby-talk` (one screen of a quarter-in-review, every text
|
|
|
1287
1444
|
layer `role: "read"`, zero advisories — readable copy over additive
|
|
1288
1445
|
atmosphere); and `thermal-field` (a `field` background in six riso inks,
|
|
1289
1446
|
`quantize: 6`, warped and drifting, under a handful of screened motes, with
|
|
1290
|
-
a grain-and-dither `finish` — the ground is the piece and the paper shows)
|
|
1447
|
+
a grain-and-dither `finish` — the ground is the piece and the paper shows); and
|
|
1448
|
+
`ink-tide` (the motion vocabulary in one scene: a pen line that draws itself
|
|
1449
|
+
on with `stroke.draw` eased `out` and erases eased `in`, a boat that `rock`s on
|
|
1450
|
+
a clock, and a caption that lifts in with `reveal.rise` — its rock period
|
|
1451
|
+
divides the 30 s lap, so `loop-seam` stays quiet). See [`src/examples/`](./src/examples/). The dashboard exercises the
|
|
1291
1452
|
static/HUD subset at scale (34 layers of keyed, positioned text).
|