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:
Media
2026-08-17 19:38:50 -05:00
co-authored by Claude Opus 5
parent ae70bf1dd8
commit 147e375702
32 changed files with 3107 additions and 155 deletions
+122
View File
@@ -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`,