feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+360
View File
@@ -0,0 +1,360 @@
import type { SignageNativeScene } from "../shared/native-images.js";
import type { SignageSlideRendererInput } from "../shared/renderer-input.js";
/**
* The two-layer scene stage (ADR 0006).
*
* The legacy `v1` renderer rebuilt image elements per slide and faded the outgoing frame to
* transparent, which let the black page background show through the middle of every transition. That
* is the single most visible defect this file exists to prevent, and the rule that prevents it is
* one sentence: **the outgoing layer is never faded.**
*
* Two layers are created once and never rebuilt. For every transition the incoming content is
* prepared in the hidden layer and *actually awaited* — an image decode, or a contributed renderer's
* own `mount` promise — then raised above the outgoing layer at opacity zero and faded in. The
* outgoing layer stays fully opaque underneath until the fade completes.
*
* Everything the stage touches arrives as an option: the document, the clock, the timer, how an
* image is decoded, and how a contribution is mounted. That is what lets the transition itself be
* exercised rather than assumed, and what lets the management preview reuse this exact code without
* pretending to be a viewer.
*/
/** What a layer is asked to show. */
export type SceneRequest =
| {
readonly kind: "native-image";
/** Identity, so the stage can tell "the same slide, updated" from "a different slide". */
readonly key: string;
readonly url: string;
readonly alt: string;
readonly scene: SignageNativeScene;
}
| {
readonly kind: "contribution";
readonly key: string;
readonly contributionId: string;
readonly input: SignageSlideRendererInput;
};
/** What the stage needs of a mounted contribution. Structurally the platform's
* `MountedModuleContribution`, restated so this file imports no SDK and stays testable. */
export interface MountedRenderer {
update?(input: unknown): PromiseLike<void> | void;
unmount?(): PromiseLike<void> | void;
}
export interface SceneStageOptions {
readonly root: Element;
/** The starting crossfade. It is an operator setting that arrives with channel state, so it is
* also settable afterwards — a stage is created before the first state is fetched. */
readonly crossfadeMs: number;
/** Mounts one contributed renderer into a container the stage owns. */
readonly mountContribution?: (options: {
readonly container: Element;
readonly contributionId: string;
readonly input: SignageSlideRendererInput;
}) => Promise<MountedRenderer>;
/** How an image is awaited. A browser's `decode()` by default; a test supplies its own. */
readonly decodeImage?: (image: HTMLImageElement) => Promise<void>;
/** How the crossfade is waited out. A test supplies a synchronous one. */
readonly setTimer?: (handler: () => void, delayMs: number) => unknown;
}
export type SceneOutcome =
/** The scene is now on screen. */
| { readonly kind: "shown" }
/** The scene was already on screen; only its input changed, and the renderer was updated in place
* without a transition. This is what lets a cancelled event change a slide without restarting it. */
| { readonly kind: "updated" }
/** A newer transition won while this one was preparing. Nothing changed. */
| { readonly kind: "superseded" }
/** The content could not be prepared. **The last good frame is still on screen.** */
| { readonly kind: "failed"; readonly code: string };
export interface SceneStage {
show(request: SceneRequest): Promise<SceneOutcome>;
/** Covers the output with pure black. The caller's timeline keeps running underneath. */
setBlackout(on: boolean): void;
/** The built-in broadcast slate. Shown only when there is nothing renderable *and* no good frame. */
setSlate(on: boolean): void;
/** What is on screen now, or `undefined` if nothing ever was. */
currentKey(): string | undefined;
setCrossfade(milliseconds: number): void;
destroy(): Promise<void>;
}
interface Layer {
readonly element: HTMLElement;
readonly background: HTMLElement;
readonly backgroundImage: HTMLImageElement;
readonly darken: HTMLElement;
readonly foreground: HTMLImageElement;
readonly rendererHost: HTMLElement;
key: string | undefined;
renderer: MountedRenderer | undefined;
}
function createLayer(document: Document, index: number): Layer {
const element = document.createElement("div");
element.className = "signage-layer";
element.dataset.layer = String(index);
element.style.opacity = "0";
const background = document.createElement("div");
background.className = "signage-layer__background";
const backgroundImage = document.createElement("img");
backgroundImage.className = "signage-layer__background-image";
backgroundImage.alt = "";
const darken = document.createElement("div");
darken.className = "signage-layer__darken";
const foreground = document.createElement("img");
foreground.className = "signage-layer__foreground";
const rendererHost = document.createElement("div");
rendererHost.className = "signage-layer__renderer";
element.append(background, backgroundImage, darken, foreground, rendererHost);
return {
background,
backgroundImage,
darken,
element,
foreground,
key: undefined,
renderer: undefined,
rendererHost,
};
}
export function createSceneStage(options: SceneStageOptions): SceneStage {
const document = options.root.ownerDocument;
if (!document) {
throw new Error("A Signage scene stage needs a document.");
}
const decodeImage =
options.decodeImage ??
((image: HTMLImageElement) =>
typeof image.decode === "function" ? image.decode() : Promise.resolve());
const setTimer =
options.setTimer ?? ((handler: () => void, delayMs: number) => setTimeout(handler, delayMs));
const layers: [Layer, Layer] = [createLayer(document, 0), createLayer(document, 1)];
// The stage owns the two layers and nothing else. Blackout and the slate are **states of the
// root**, reported as attributes: whoever owns this root renders them, so there is exactly one
// slate implementation and this file never duplicates it.
const root = options.root as HTMLElement;
root.dataset.blackout = "false";
root.dataset.slate = "false";
root.prepend(layers[1].element);
root.prepend(layers[0].element);
let currentIndex = 0;
let generation = 0;
let destroyed = false;
let crossfadeMs = options.crossfadeMs;
async function releaseRenderer(layer: Layer): Promise<void> {
const renderer = layer.renderer;
layer.renderer = undefined;
if (!renderer?.unmount) {
return;
}
try {
await renderer.unmount();
} catch {
// A renderer whose cleanup throws must not stop the stage. The layer is reused either way,
// and the DOM it owned is cleared below.
}
}
async function clearLayer(layer: Layer): Promise<void> {
await releaseRenderer(layer);
layer.foreground.removeAttribute("src");
layer.backgroundImage.removeAttribute("src");
layer.rendererHost.replaceChildren();
layer.key = undefined;
}
/**
* Prepares the hidden layer, refusing to leave anything behind for a generation that has already
* been superseded.
*
* The token is not a nicety: two transitions can be in flight at once — one waiting on a slow
* decode while a newer one arrives — and both target the same hidden layer. Without checking the
* token *after* every await, a slow mount resolving late would overwrite the newer transition's
* renderer handle and leak the renderer it replaced.
*/
async function prepare(layer: Layer, request: SceneRequest, token: number): Promise<void> {
await clearLayer(layer);
layer.element.dataset.sceneKey = request.key;
if (request.kind === "native-image") {
const scene = request.scene;
layer.element.dataset.fit = scene.fit;
layer.background.style.backgroundColor =
scene.backgroundMode === "solid" ? scene.backgroundColor : "#000000";
if (scene.backgroundImage) {
layer.backgroundImage.style.display = "block";
layer.backgroundImage.style.filter = `blur(${scene.blurPixels}px)`;
layer.backgroundImage.setAttribute("src", request.url);
} else {
layer.backgroundImage.style.display = "none";
}
layer.darken.style.opacity = String(scene.darkenOpacity);
layer.foreground.style.display = "block";
layer.foreground.style.objectFit = scene.fit === "fill" ? "cover" : "contain";
layer.foreground.style.filter = scene.shadow
? "drop-shadow(0 0.6vh 1.6vh rgba(0, 0, 0, 0.45))"
: "none";
layer.foreground.alt = request.alt;
layer.foreground.setAttribute("src", request.url);
// The element that will actually be composited must be **decodable**, not merely present in
// the DOM. `onload` is not enough: a large image paints mid-fade.
await decodeImage(layer.foreground);
if (scene.backgroundImage) {
await decodeImage(layer.backgroundImage);
}
if (token !== generation) {
return;
}
layer.key = request.key;
return;
}
if (!options.mountContribution) {
throw new Error("This stage cannot mount contributed renderers.");
}
layer.foreground.removeAttribute("src");
layer.foreground.style.display = "none";
layer.backgroundImage.style.display = "none";
layer.darken.style.opacity = "0";
layer.background.style.backgroundColor = "#000000";
// Asynchronous by contract: the renderer resolves when it is genuinely ready to be seen, and the
// fade below does not begin until it does.
const mounted = await options.mountContribution({
container: layer.rendererHost,
contributionId: request.contributionId,
input: request.input,
});
if (token !== generation) {
// A newer transition owns this layer now. Release what was just mounted rather than storing a
// handle over the one that transition is using.
try {
await mounted.unmount?.();
} catch {
// Nothing to do: the layer belongs to someone else, and its own cleanup will run.
}
return;
}
layer.renderer = mounted;
layer.key = request.key;
}
function delay(ms: number): Promise<void> {
if (ms <= 0) {
return Promise.resolve();
}
// Nothing is cleared here: a timer that has fired needs no cancelling, and clearing one from
// inside its own handler is what breaks when a caller supplies a synchronous timer.
return new Promise((resolve) => {
setTimer(resolve, ms);
});
}
return {
currentKey: () => layers[currentIndex]?.key,
async destroy(): Promise<void> {
destroyed = true;
generation += 1;
await Promise.all([clearLayer(layers[0]), clearLayer(layers[1])]);
layers[0].element.remove();
layers[1].element.remove();
},
setBlackout(on: boolean): void {
// Pure black, over everything, and nothing else changes. The timeline underneath keeps its
// deadlines and keeps resolving, so ending a blackout lands where it would have.
root.dataset.blackout = on ? "true" : "false";
},
setCrossfade(milliseconds: number): void {
crossfadeMs = Math.max(0, milliseconds);
},
setSlate(on: boolean): void {
root.dataset.slate = on ? "true" : "false";
},
async show(request: SceneRequest): Promise<SceneOutcome> {
if (destroyed) {
return { kind: "superseded" };
}
const outgoing = layers[currentIndex] as Layer;
// Already on screen. If it is a contributed renderer, hand it the new input in place: the
// whole point of `update` is a long-running screen changing content without restarting
// animations, reloading media, or resetting its slot.
if (outgoing.key === request.key) {
if (request.kind === "contribution" && outgoing.renderer?.update) {
try {
await outgoing.renderer.update(request.input);
return { kind: "updated" };
} catch {
return { code: "RENDERER_UPDATE_FAILED", kind: "failed" };
}
}
return { kind: "updated" };
}
generation += 1;
const token = generation;
const incomingIndex = currentIndex === 0 ? 1 : 0;
const incoming = layers[incomingIndex] as Layer;
try {
await prepare(incoming, request, token);
} catch {
// The last good frame stays exactly where it is. A broken slide costs one slide.
await clearLayer(incoming);
return {
code: request.kind === "native-image" ? "SLIDE_DECODE_FAILED" : "RENDERER_MOUNT_FAILED",
kind: "failed",
};
}
// Authoritative state may have moved on while this was preparing. Completion from a stale
// generation is discarded rather than shown — and deliberately without clearing the layer,
// which by now belongs to the newer transition.
if (destroyed || token !== generation) {
return { kind: "superseded" };
}
incoming.element.style.transitionDuration = "0ms";
incoming.element.style.opacity = "0";
incoming.element.style.zIndex = "2";
outgoing.element.style.zIndex = "1";
// Guarantees the browser observes opacity 0 before the transition starts, so the incoming
// layer animates instead of snapping.
void incoming.element.offsetHeight;
incoming.element.style.transitionDuration = `${Math.max(0, crossfadeMs)}ms`;
incoming.element.style.opacity = "1";
incoming.element.dataset.visible = "true";
// The outgoing layer is **not** touched. Fading both is what let the black root show through.
await delay(crossfadeMs);
if (destroyed || token !== generation) {
// A newer transition won while this one was fading; it owns the layers now.
return { kind: "superseded" };
}
currentIndex = incomingIndex;
outgoing.element.dataset.visible = "false";
outgoing.element.style.transitionDuration = "0ms";
outgoing.element.style.opacity = "0";
await clearLayer(outgoing);
return { kind: "shown" };
},
};
}