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
+185
View File
@@ -0,0 +1,185 @@
import { expect, test } from "@playwright/test";
import { ensureFixtures } from "./fixtures.js";
import {
activatePlaylist,
api,
displayDiagnostics,
resetPlayback,
visibleScene,
waitForFirstSlide,
} from "./helpers.js";
test.beforeEach(async ({ request }) => {
await ensureFixtures(request);
await resetPlayback(request);
});
// Primary acceptance test. It fails if anyone restores the v1 architecture that
// faded the outgoing frame to transparent over a black root.
test("the outgoing slide stays fully opaque until the incoming slide can be drawn", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "No black", [
{ assetId: library["wide-a"].id, durationOverrideMs: 4_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
]);
const slowUrl = `**/media/assets/${library["wide-b"].id}/**`;
let released = false;
await page.route(slowUrl, async (route) => {
// Hold the incoming slide's bytes well past its nominal transition instant.
while (!released) await new Promise((resolve) => setTimeout(resolve, 100));
await route.continue();
});
await page.goto("/display");
await waitForFirstSlide(page);
const firstScene = await visibleScene(page);
expect(firstScene.sceneKey).toMatch(/^normal:/);
const before = await displayDiagnostics(page);
expect(before.renderedAssetId).toBe(library["wide-a"].id);
// Sample continuously across the boundary while B is deliberately stuck.
const samples = [];
const deadline = Date.now() + 9_000;
while (Date.now() < deadline) {
samples.push(
await page.evaluate(() => ({
at: Date.now(),
gate: document.querySelector(".output-gate").dataset.active,
layers: [...document.querySelectorAll(".scene")].map((layer) => ({
key: layer.dataset.sceneKey || "",
opacity: Number(getComputedStyle(layer).opacity),
})),
rendered: window.__display?.diagnostics()?.renderedAssetId ?? null,
})),
);
await page.waitForTimeout(120);
}
for (const sample of samples) {
const opaque = sample.layers.filter((layer) => layer.opacity > 0.99);
expect(
opaque.length,
`black root was exposed at ${sample.at}: ${JSON.stringify(sample.layers)}`,
).toBeGreaterThanOrEqual(1);
expect(sample.gate).toBe("false");
// The outgoing scene must never begin fading away.
expect(sample.rendered).toBe(library["wide-a"].id);
}
released = true;
await expect
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 30_000 })
.toBe(library["wide-b"].id);
const afterScene = await visibleScene(page);
expect(afterScene.opacity).toBe(1);
// Exactly one layer is composited afterwards; the other is free for reuse.
const layerCount = await page.evaluate(
() => document.querySelectorAll(".scene").length,
);
expect(layerCount, "scene layers are persistent, never recreated").toBe(2);
});
test("a late asset load never replaces a newer authoritative target", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Stale", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
{ assetId: library["wide-c"].id, durationOverrideMs: 60_000 },
]);
let releaseB = false;
await page.route(`**/media/assets/${library["wide-b"].id}/**`, async (route) => {
while (!releaseB) await new Promise((resolve) => setTimeout(resolve, 100));
await route.continue();
});
await page.goto("/display");
await waitForFirstSlide(page);
expect((await displayDiagnostics(page)).renderedAssetId).toBe(library["wide-a"].id);
// Advance to B, whose bytes are stuck, then advance again to C.
await api(request, "/api/playback/next", { method: "POST" });
await page.waitForTimeout(1_500);
await api(request, "/api/playback/next", { method: "POST" });
await expect
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 30_000 })
.toBe(library["wide-c"].id);
// B finally arrives long after it stopped being authoritative.
releaseB = true;
await page.waitForTimeout(4_000);
const diagnostics = await displayDiagnostics(page);
expect(
diagnostics.renderedAssetId,
"a stale load must never overwrite the newer target",
).toBe(library["wide-c"].id);
});
test("one unusable asset never blanks the screen or blocks the playlist", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Bad asset", [
{ assetId: library["wide-a"].id, durationOverrideMs: 3_000 },
{ assetId: library["wide-d"].id, durationOverrideMs: 3_000 },
{ assetId: library["wide-c"].id, durationOverrideMs: 3_000 },
]);
let attempts = 0;
await page.route(`**/media/assets/${library["wide-d"].id}/**`, async (route) => {
attempts += 1;
await route.fulfill({ status: 500, body: "broken" });
});
await page.goto("/display");
await waitForFirstSlide(page);
// Rotation continues across the broken slide without ever losing a frame.
const seen = new Set();
const deadline = Date.now() + 16_000;
while (Date.now() < deadline) {
const state = await page.evaluate(() => ({
rendered: window.__display?.diagnostics()?.renderedAssetId ?? null,
opaque: [...document.querySelectorAll(".scene")].filter(
(layer) => Number(getComputedStyle(layer).opacity) > 0.99,
).length,
}));
if (state.rendered) seen.add(state.rendered);
expect(state.opaque, "a good frame is always on screen").toBeGreaterThanOrEqual(1);
await page.waitForTimeout(200);
}
expect(seen.has(library["wide-a"].id)).toBe(true);
expect(seen.has(library["wide-c"].id)).toBe(true);
expect(seen.has(library["wide-d"].id), "the broken slide never displays").toBe(false);
expect(attempts, "a bad asset must not be retried in a tight loop").toBeLessThan(25);
const diagnostics = await displayDiagnostics(page);
expect(diagnostics.unavailableEntryIds.length).toBeGreaterThanOrEqual(1);
expect(diagnostics.mostRecentError).not.toBeNull();
expect(["asset_fetch_failed", "slide_decode_failed"]).toContain(
diagnostics.mostRecentError.kind,
);
expect(diagnostics.mostRecentError.assetId).toBe(library["wide-d"].id);
// The structured error reaches the server through the real heartbeat.
const clientId = await page.evaluate(() => window.__display.clientId);
await page.evaluate(() => window.__display.heartbeat());
await expect
.poll(async () => {
const displays = await api(request, "/api/displays");
const mine = displays.displays.find((display) => display.clientId === clientId);
return mine?.mostRecentError?.kind ?? null;
}, { timeout: 30_000 })
.toBe(diagnostics.mostRecentError.kind);
});