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
+177
View File
@@ -0,0 +1,177 @@
import {
SIGNAGE_SLIDE_RENDERER_CONTRACT,
SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
type SignageSlideRendererInput,
signageRendererGeneration,
} from "../shared/renderer-input.js";
import type { SceneRequest } from "./scene.js";
/**
* Turning one resolved selection into something the stage can show.
*
* The one decision here worth naming is the **key**, and it is narrower than it first looks: a slide's
* identity is the selection and the audience, and *nothing else*. A payload revision, a pause, a
* re-anchored clock, and a fresh delivery signature are all things that happen to a slide already on
* screen — they become an `update`, which is the whole reason a cancelled event can change a
* mounted React tree instead of restarting its slot and every animation in it.
*
* The renderer still learns that something changed: `generation` inside its input *does* carry the
* revision and the paused flag, so a renderer keying internal state on it sees the change without
* the stage remounting anything.
*/
/** The subset of a channel state's selection this needs. Structural on purpose: the display and the
* management preview build the same request from the same shape. */
export interface SceneSelection {
readonly id: string;
readonly revision: number;
/** Which slot this occurrence came from. `null` for a static hold, and for history whose slot an
* operator has since deleted. */
readonly slotId: string | null;
/** When this selection stops being valid. `null` is permanent; local age never invents one. */
readonly availableUntilMs: number | null;
readonly withdrawn: boolean;
readonly providerId: string;
readonly itemId: string;
readonly itemRevision: string;
readonly durationMs: number;
readonly startedAtMs: number | null;
readonly deadlineAtMs: number | null;
readonly payload: unknown;
readonly presentation: unknown;
readonly renderer:
| { readonly kind: "native-image" }
| {
readonly kind: "contribution";
readonly contributionId: string;
readonly contributionVersion: string;
}
| { readonly kind: "unavailable" };
readonly media?: {
readonly display: { readonly url: string };
readonly thumbnail: { readonly url: string };
};
readonly scene?: {
readonly fit: "fill" | "contain";
readonly backgroundMode: "blurred" | "solid" | "black" | "none";
readonly backgroundColor: string;
readonly backgroundImage: boolean;
readonly blurPixels: number;
readonly darkenOpacity: number;
readonly shadow: boolean;
};
}
export interface SceneContext {
readonly audienceKey: string;
readonly paused: boolean;
readonly remainingMs: number | null;
/** The server's clock at the moment this state was received, and the device's at the same instant.
* A television's own clock is routinely wrong; this pair is what makes a renderer's countdown
* correct anyway. */
readonly serverNowMs: number;
readonly localNowMs: number;
}
export function sceneKeyFor(selection: SceneSelection, context: SceneContext): string {
return `${selection.id}:${context.audienceKey}`;
}
export function rendererInputFor(
selection: SceneSelection,
context: SceneContext,
): SignageSlideRendererInput {
return {
audienceKey: context.audienceKey,
clock: { localNowMs: context.localNowMs, serverNowMs: context.serverNowMs },
contract: SIGNAGE_SLIDE_RENDERER_CONTRACT,
generation: signageRendererGeneration({
audienceKey: context.audienceKey,
paused: context.paused,
revision: selection.revision,
selectionId: selection.id,
}),
itemId: selection.itemId,
itemRevision: selection.itemRevision,
payload: selection.payload,
presentation: selection.presentation,
providerId: selection.providerId,
timing: {
deadlineAtMs: selection.deadlineAtMs ?? context.serverNowMs + selection.durationMs,
durationMs: selection.durationMs,
paused: context.paused,
remainingMs: context.paused ? context.remainingMs : null,
startedAtMs: selection.startedAtMs ?? context.serverNowMs,
},
version: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
};
}
/**
* Builds a native-image scene from bytes this display already has.
*
* Separate from {@link sceneRequestFor} because offline is a different question: a persisted
* selection deliberately carries **no** delivery URL — one is a bearer capability with an expiry —
* so the only thing that can paint it is the local copy, and if there is none there is nothing to
* show.
*/
export function nativeSceneRequestFor(
selection: SceneSelection,
context: SceneContext,
localUrl: string,
): SceneRequest | undefined {
if (selection.renderer.kind !== "native-image" || selection.scene === undefined) {
return undefined;
}
return {
alt: "",
key: sceneKeyFor(selection, context),
kind: "native-image",
scene: selection.scene,
url: localUrl,
};
}
/**
* Builds a scene, or returns `undefined` when this selection cannot be shown here.
*
* `undefined` is not a failure: a native image whose delivery URL is missing, or a contributed
* renderer the platform did not publish, is content this display genuinely cannot render — and the
* caller keeps its last good frame rather than blanking the screen.
*/
export function sceneRequestFor(
selection: SceneSelection,
context: SceneContext,
options: { readonly canMountContribution: (contributionId: string) => boolean },
): SceneRequest | undefined {
const key = sceneKeyFor(selection, context);
if (selection.renderer.kind === "native-image") {
const url = selection.media?.display.url;
if (url === undefined || selection.scene === undefined) {
return undefined;
}
return {
alt: "",
key,
kind: "native-image",
scene: selection.scene,
url,
};
}
if (selection.renderer.kind !== "contribution") {
return undefined;
}
if (!options.canMountContribution(selection.renderer.contributionId)) {
// The contributing module is absent, disabled, or outside Signage's compatible range. That is
// ordinary rather than exceptional (ADR 0041) and the display simply cannot draw this item.
return undefined;
}
return {
contributionId: selection.renderer.contributionId,
input: rendererInputFor(selection, context),
key,
kind: "contribution",
};
}