Implement Phase 5A resilient display renderer
Build the real /display Chromium signage client as a React route on the existing single-page build. The server remains authoritative; the display only consumes the verified Phase 3B snapshot and reports telemetry. The renderer owns two permanent scene layers that are never recreated. A transition prepares the hidden layer, decodes the incoming image on the element that will actually be composited, then raises that layer above the current one at opacity 0 and fades it in while the outgoing layer stays fully opaque underneath. This removes the v1 defect where two simultaneous fades let the black root through the composite. Every asynchronous load and transition carries a generation token, so work finishing after authoritative state moved on is discarded rather than shown. A true 16:9 slide fills the frame with no backdrop or shadow; anything else is contained, centred, and uncropped over a blurred, solid, or black background with a subtle shadow. Blur uses the global setting and is overscanned; boolean darkening applies to the background only. Static targets use the same renderer with the snapshot's global presentation defaults, and mode off is a topmost gate while the renderer keeps tracking underneath. Slide bytes live in an explicit Cache Storage cache with reference counted object URLs and conservative pruning, never in ordinary HTTP caching. A new manifest is staged before promotion, so one unfetchable asset marks only itself unavailable instead of rejecting a playlist. A small IndexedDB store with explicit version handling holds the persistent client ID, last known good manifest, timing anchor, and last structured error, and discards malformed records on read. A narrow service worker caches only the /display app shell, network-first for navigations, and explicitly bypasses media, API, and socket traffic. The display sends a real display:heartbeat every 12 seconds with its actual state, and reports structured errors as bounded JSON that the server's existing string validation already accepts; /api/displays parses them back into an object, so no migration was required. Verified against an isolated stack through Nginx with 20 Playwright Chromium tests plus 9 client unit tests, and 187/187 backend tests. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -361,6 +361,128 @@ become Home Assistant notifications. Structured display-side errors such as
|
||||
decode failures arrive with the Phase 5 renderer, which will report them through
|
||||
this same field and event.
|
||||
|
||||
## Signage display
|
||||
|
||||
`/display` is the signage output and `/` is reserved for the future management
|
||||
dashboard. Both are served by the same single-page build, so `/display` survives
|
||||
direct navigation and refresh through the existing Nginx SPA fallback. The
|
||||
display stays a Chromium kiosk pointed at a web application: v2 makes that
|
||||
application reliable rather than replacing it with a native client.
|
||||
|
||||
Chromium should open `/display` in kiosk mode against an origin the browser
|
||||
treats as secure — `http://localhost`, a private hostname over HTTPS, or the
|
||||
equivalent. Cache Storage, Service Workers, and therefore offline behaviour are
|
||||
unavailable on an insecure origin, and the display degrades to a network-only
|
||||
renderer without them.
|
||||
|
||||
The old v1 kiosk flags that disabled the browser cache are no longer required
|
||||
and should be removed at cutover. They existed because v1 reused mutable slide
|
||||
URLs; v2 media URLs are content-addressed, so a changed image always arrives
|
||||
under a new URL and stale bytes are impossible. Those flags now actively
|
||||
interfere with the Service Worker and Cache Storage the display depends on.
|
||||
|
||||
### Transition guarantee
|
||||
|
||||
The renderer owns two permanent full-screen scene layers. Each layer is a
|
||||
complete scene — background, optional darkening overlay, and foreground image —
|
||||
and neither layer is ever recreated; only its contents and opacity change.
|
||||
|
||||
A transition prepares the hidden layer completely, decodes the incoming image
|
||||
through `Image.decode()` on the element that will actually be composited, then
|
||||
raises that layer above the current one at opacity 0 and fades it in. The
|
||||
outgoing layer stays fully opaque underneath for the whole transition and is
|
||||
never faded out. This is the specific v1 defect that produced black frames: two
|
||||
simultaneous fades let the black root show through the composite.
|
||||
|
||||
If an incoming asset cannot be fetched or decoded, the last good frame simply
|
||||
stays on screen. The entry is marked locally unavailable, a structured error is
|
||||
recorded for telemetry, and rotation continues past it rather than retrying it
|
||||
in a loop. Every asynchronous load and transition carries a generation token, so
|
||||
work that completes after authoritative state has moved on is discarded instead
|
||||
of overwriting the newer target.
|
||||
|
||||
While connected, the display schedules the expected next transition locally from
|
||||
the authoritative timeline and the cached manifest, so transitions stay smooth
|
||||
between packets. The server always wins any disagreement. When an activation is
|
||||
pending for a playlist the display does not yet hold, local prediction is
|
||||
withheld entirely: holding the current frame a little longer is better than
|
||||
briefly showing the wrong slide.
|
||||
|
||||
### Presentation
|
||||
|
||||
A slide whose real pixel dimensions are 16:9 within a small tolerance fills the
|
||||
frame: no blurred duplicate, no pillarbox, and no foreground shadow. Anything
|
||||
else is shown whole and centred with `object-fit: contain`, never cropped, over
|
||||
its configured background and with a subtle drop shadow.
|
||||
|
||||
Backgrounds follow the server's already-resolved effective values. `blurred`
|
||||
reuses the same slide image, covered and centred, blurred by the global setting
|
||||
(20px by default) and overscanned so the blur cannot reveal an edge. `solid`
|
||||
fills with the configured colour and `black` fills with black. Background
|
||||
darkening is boolean, resolved by the server from the global default and the
|
||||
entry's inherit/off/on override, and applies a fixed overlay of about 0.25 to
|
||||
the background only — never to the readable foreground. Blur is applied at
|
||||
render time and is never baked into a stored derivative.
|
||||
|
||||
Static targets have no playlist entry, so they use the global presentation
|
||||
defaults the snapshot already carries, through the same renderer and the same
|
||||
transition path.
|
||||
|
||||
`mode=off` is a topmost black gate that covers the output immediately. The
|
||||
renderer keeps tracking the timeline, caching, and updating its current target
|
||||
underneath, so switching the mode back on reveals the synchronized scene without
|
||||
restarting the playlist.
|
||||
|
||||
### Explicit asset cache
|
||||
|
||||
Slide bytes are stored explicitly in Cache Storage under
|
||||
`announcements-display-assets-v1`, not left to ordinary HTTP caching. The asset
|
||||
manager checks the cache, fetches only on a miss, validates the response, stores
|
||||
a clone, and returns a blob that is decoded before it is ever presented. Object
|
||||
URLs are reference counted, and bytes are never revoked while a scene layer is
|
||||
still displaying them.
|
||||
|
||||
A new manifest never destroys the working one. Incoming assets are fetched and
|
||||
validated first and the manifest is promoted once it is usable; one unfetchable
|
||||
asset marks only itself unavailable rather than rejecting the whole playlist.
|
||||
After promotion, anything not referenced by the promoted manifest, the current
|
||||
static target, or a live scene layer is pruned. Quota pressure triggers a prune
|
||||
and one retry before a bounded error is reported.
|
||||
|
||||
### Persistence and the app shell
|
||||
|
||||
A small IndexedDB database (`announcements-display`, version 1, one object
|
||||
store, explicit upgrade handling) holds the persistent client ID, the
|
||||
last-known-good manifest with the revisions needed to interpret it, the last
|
||||
authoritative timing anchor, mode and transport state, and the most recent
|
||||
structured error. Malformed or superseded records are discarded on read so the
|
||||
display starts cleanly from the server instead of trusting an unreadable
|
||||
snapshot. The client ID is generated once and reused across refresh, reconnect,
|
||||
and Chromium restart; a socket ID is never used as display identity.
|
||||
|
||||
`display-sw.js` caches only the `/display` application shell. Navigations are
|
||||
network-first so an online kiosk always receives the newest application, with
|
||||
the cached shell as the offline fallback, and content-hashed Vite assets are
|
||||
cache-first. The worker explicitly bypasses `/media`, `/api`, `/socket.io`, and
|
||||
`/health`: slide bytes belong to the asset manager and live server state must
|
||||
never be answered from a cache. It calls `skipWaiting` and `clients.claim`, so a
|
||||
kiosk cannot be trapped on a stale version.
|
||||
|
||||
### Heartbeat and errors
|
||||
|
||||
The display emits `display:heartbeat` about every 12 seconds with its persistent
|
||||
client ID, the active playlist and revisions it is actually rendering, the
|
||||
current entry and asset, its true cached and locally playable asset counts, and
|
||||
its most recent error. Telemetry is read-only and never mutates playback.
|
||||
|
||||
Errors are structured internally as `slide_decode_failed`, `asset_fetch_failed`,
|
||||
`cache_write_failed`, or `manifest_invalid`, each with the relevant asset or
|
||||
entry, a bounded message, and a timestamp. They are sent as bounded JSON, which
|
||||
the server's existing string validation already accepts, so no schema migration
|
||||
was required; `/api/displays` parses them back into an object. An error clears
|
||||
when the failing asset renders successfully or leaves the manifest — never
|
||||
merely because time passed.
|
||||
|
||||
## Storage and health
|
||||
|
||||
The API uses a small path-style S3 adapter configured by `S3_ENDPOINT`,
|
||||
|
||||
Reference in New Issue
Block a user