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
+31
View File
@@ -0,0 +1,31 @@
import type { ReactElement, ReactNode } from "react";
/**
* The narrowest viewport the Signage management UI is designed for.
*
* Signage's dashboard is a timeline editor, a slot table, a live preview, and a display roster shown
* together. Compressing that into a phone would not make it usable, it would make it a different
* product — the mobile application this release deliberately does not build. So below this width the
* operator is told plainly where to go instead of being handed a broken layout.
*
* The switch is a CSS media query rather than a measured width in JavaScript: both branches are
* always in the DOM and always in the accessibility tree, so a screen reader and a test find the
* same content regardless of how the environment reports its viewport.
*/
export const SIGNAGE_MIN_DESKTOP_WIDTH_PX = 1024;
export function DesktopOnly({ children }: { readonly children: ReactNode }): ReactElement {
return (
<>
<div className="signage-narrow-notice" data-testid="signage-narrow-notice" role="note">
<h2 className="signage-narrow-notice__title">Signage needs a desktop screen</h2>
<p className="signage-narrow-notice__body">
{`Open the Ekklesia Dashboard on a display at least ${SIGNAGE_MIN_DESKTOP_WIDTH_PX} pixels wide to manage timelines, images, channels, and playback.`}
</p>
</div>
<div className="signage-desktop" data-testid="signage-desktop">
{children}
</div>
</>
);
}
+288
View File
@@ -0,0 +1,288 @@
import {
advanceCursor,
nextTimingBoundary,
type TimingSlot,
type TimingTimeline,
} from "../shared/playback-timing.js";
import type { SceneSelection } from "./scene-request.js";
/**
* Deterministic local continuation of the last authoritative state (ADR 0006).
*
* This is **resilience, never a second authority**. It reproduces the server's own documented
* semantics using the *same* arithmetic module the server reconciles with, so a screen that has been
* disconnected for an hour is showing what the server would have said, not what a second
* implementation guessed.
*
* Three rules bound what it may do:
*
* - **It never invents provider data.** It replays selections the server already resolved and this
* display already cached. A slot it has no cached answer for holds the last good frame rather than
* fabricating one.
* - **It never reports its own cursor back as authority.** Nothing here is sent anywhere.
* - **It catches up arithmetically.** A long outage costs one evaluation, not one per missed slide.
*/
export interface OfflineTimeline {
readonly id: string;
readonly revision: number;
readonly eligibleFromMs: number | null;
readonly eligibleUntilMs: number | null;
readonly slots: readonly {
readonly id: string;
readonly position: number;
readonly enabled: boolean;
readonly eligibleFromMs: number | null;
readonly eligibleUntilMs: number | null;
readonly estimatedDurationMs: number;
}[];
}
/** Everything a display persists so it can keep going. Structured, JSON-safe, and bounded. */
export interface OfflineSnapshot {
readonly version: 1;
readonly channel: { readonly id: string; readonly key: string; readonly groupId: string };
readonly crossfadeMs: number;
readonly timeline: OfflineTimeline | null;
readonly playback: {
readonly revision: number;
readonly transport: string;
readonly blackout: boolean;
readonly remainingMs: number | null;
readonly currentSelectionId: string | null;
readonly currentSlotId: string | null;
readonly startedAtMs: number | null;
readonly deadlineAtMs: number | null;
readonly pendingTimelineId: string | null;
};
readonly selections: readonly SceneSelection[];
readonly staticHold: SceneSelection | null;
/** The two clocks at the moment this was captured. A television's own clock is routinely wrong, so
* every later evaluation is done on the *server's* timeline, reconstructed from this pair. */
readonly anchor: { readonly serverNowMs: number; readonly localNowMs: number };
}
export type OfflineResolution =
/** Show this selection, with this window. */
| {
readonly kind: "selection";
readonly selection: SceneSelection;
readonly startedAtMs: number;
readonly deadlineAtMs: number;
}
/** The held static image. */
| { readonly kind: "static"; readonly selection: SceneSelection }
/** Frozen: whatever is on screen stays, with its remainder preserved. */
| { readonly kind: "paused"; readonly selection: SceneSelection | undefined }
/** Keep the last good frame. Something is genuinely unknown — a queued activation whose timeline
* this display never received, or a slot with no cached answer. Holding is the honest response. */
| { readonly kind: "hold"; readonly reason: string }
/** The cached timeline really has nothing eligible. Black is correct. */
| { readonly kind: "empty" };
/** The server's clock, reconstructed from the anchor. Correct however far off the device's is. */
export function serverNowFrom(snapshot: OfflineSnapshot, localNowMs: number): number {
return snapshot.anchor.serverNowMs + (localNowMs - snapshot.anchor.localNowMs);
}
function timingTimelineOf(timeline: OfflineTimeline): TimingTimeline {
const slots: TimingSlot[] = timeline.slots.map((slot) => ({
eligibleFromMs: slot.eligibleFromMs,
eligibleUntilMs: slot.eligibleUntilMs,
enabled: slot.enabled,
estimatedDurationMs: slot.estimatedDurationMs,
id: slot.id,
position: slot.position,
}));
return {
eligibleFromMs: timeline.eligibleFromMs,
eligibleUntilMs: timeline.eligibleUntilMs,
id: timeline.id,
slots,
};
}
/** Whether a cached selection may still be shown. Expired and explicitly withdrawn content is
* skipped; a null expiry is permanent, and local age never invents one. */
function isUsable(selection: SceneSelection, atMs: number, unusable: ReadonlySet<string>): boolean {
if (unusable.has(selection.id) || selection.withdrawn) {
return false;
}
return selection.availableUntilMs === null || atMs < selection.availableUntilMs;
}
/** The most recent cached answer for one slot. History is newest-first, so the first match is the
* one that most recently played there. */
function cachedForSlot(
snapshot: OfflineSnapshot,
slotId: string,
atMs: number,
unusable: ReadonlySet<string>,
): SceneSelection | undefined {
return snapshot.selections.find(
(selection) => selection.slotId === slotId && isUsable(selection, atMs, unusable),
);
}
export interface OfflineEvaluation {
readonly resolution: OfflineResolution;
/** When to look again, or `null` when nothing is scheduled to change. */
readonly nextBoundaryMs: number | null;
}
/**
* Resolves what a disconnected display should be showing at `atMs`, on the server's clock.
*
* Pure and deterministic: the same snapshot and the same instant always give the same answer, which
* is what lets two screens that lost the same network stay on the same slide.
*/
export function resolveOffline(input: {
readonly snapshot: OfflineSnapshot;
readonly atMs: number;
readonly unusableSelectionIds?: ReadonlySet<string>;
}): OfflineEvaluation {
const { snapshot, atMs } = input;
const unusable = input.unusableSelectionIds ?? new Set<string>();
if (snapshot.staticHold !== null) {
// A hold is a deliberate operator decision, and local age never releases one. Only an explicit
// expiry on the held image does.
if (isUsable(snapshot.staticHold, atMs, unusable)) {
return {
nextBoundaryMs: null,
resolution: { kind: "static", selection: snapshot.staticHold },
};
}
}
if (snapshot.playback.transport === "paused") {
const current = snapshot.selections.find(
(selection) => selection.id === snapshot.playback.currentSelectionId,
);
// Frozen means frozen: the remainder is preserved and nothing advances.
return { nextBoundaryMs: null, resolution: { kind: "paused", selection: current } };
}
if (snapshot.playback.pendingTimelineId !== null) {
const deadline = snapshot.playback.deadlineAtMs;
if (deadline !== null && atMs >= deadline) {
// The next boundary belongs to a timeline this display never received. Going further into the
// old one would show known-wrong content, so the last good frame stays until the server can
// speak again.
return { nextBoundaryMs: null, resolution: { kind: "hold", reason: "pending-activation" } };
}
}
const timeline = snapshot.timeline;
if (!timeline || timeline.slots.length === 0) {
return { nextBoundaryMs: null, resolution: { kind: "empty" } };
}
const timing = timingTimelineOf(timeline);
let cursor = {
deadlineAtMs: snapshot.playback.deadlineAtMs,
slotId: snapshot.playback.currentSlotId,
startedAtMs: snapshot.playback.startedAtMs,
};
// Bounded by the number of slots: a slot with no cached answer is skipped exactly as the server
// skips a provider with nothing available, and one pass proves there is nothing to show.
const attempts = Math.max(1, timeline.slots.length);
for (let attempt = 0; attempt <= attempts; attempt += 1) {
const advance = advanceCursor(timing, cursor, atMs);
if (advance.kind === "empty") {
return {
nextBoundaryMs: nextTimingBoundary(timing, cursor, atMs),
resolution: { kind: "empty" },
};
}
if (advance.kind === "hold") {
const current = snapshot.selections.find(
(selection) => selection.id === snapshot.playback.currentSelectionId,
);
if (
current &&
cursor.startedAtMs !== null &&
cursor.deadlineAtMs !== null &&
isUsable(current, atMs, unusable)
) {
return {
nextBoundaryMs: nextTimingBoundary(timing, cursor, atMs),
resolution: {
deadlineAtMs: cursor.deadlineAtMs,
kind: "selection",
selection: current,
startedAtMs: cursor.startedAtMs,
},
};
}
return {
nextBoundaryMs: nextTimingBoundary(timing, cursor, atMs),
resolution: { kind: "hold", reason: "no-cached-selection" },
};
}
const cached = cachedForSlot(snapshot, advance.slotId, atMs, unusable);
if (cached) {
const nextCursor = {
deadlineAtMs: advance.deadlineAtMs,
slotId: advance.slotId,
startedAtMs: advance.startedAtMs,
};
return {
nextBoundaryMs: nextTimingBoundary(timing, nextCursor, atMs),
resolution: {
deadlineAtMs: advance.deadlineAtMs,
kind: "selection",
selection: cached,
startedAtMs: advance.startedAtMs,
},
};
}
// No cached answer for this slot. Skip it exactly as the server skips an empty provider slot,
// and keep walking — inventing content would be worse than moving on.
cursor = {
deadlineAtMs: advance.startedAtMs,
slotId: advance.slotId,
startedAtMs: advance.startedAtMs,
};
}
return {
nextBoundaryMs: nextTimingBoundary(timing, cursor, atMs),
resolution: { kind: "hold", reason: "no-cached-selection" },
};
}
/** Builds the snapshot a display persists. Bounded and JSON-safe by construction. */
export function snapshotFrom(input: {
readonly channel: OfflineSnapshot["channel"];
readonly crossfadeMs: number;
readonly timeline: OfflineTimeline | null;
readonly playback: OfflineSnapshot["playback"];
readonly selections: readonly SceneSelection[];
readonly staticHold: SceneSelection | null;
readonly serverNowMs: number;
readonly localNowMs: number;
}): OfflineSnapshot {
return {
anchor: { localNowMs: input.localNowMs, serverNowMs: input.serverNowMs },
channel: input.channel,
crossfadeMs: input.crossfadeMs,
playback: input.playback,
// A delivery URL is a bearer capability with an expiry in it. Persisting one would store a
// credential that stops working; the bytes live in the media cache under a stable key instead.
selections: input.selections.map(withoutDeliveryUrls),
staticHold: input.staticHold === null ? null : withoutDeliveryUrls(input.staticHold),
timeline: input.timeline,
version: 1,
};
}
function withoutDeliveryUrls(selection: SceneSelection): SceneSelection {
const { media: _media, ...rest } = selection as SceneSelection & { media?: unknown };
return rest as SceneSelection;
}
+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",
};
}
+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" };
},
};
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactElement } from "react";
/**
* The built-in slate a Signage display shows when it has nothing it can honestly render: no timeline
* content, and no cached good frame to keep.
*
* It is deliberately unmistakable and deliberately uninformative. Restrained broadcast-style bars
* and four words tell anyone in the room that the screen is working and the content is not — while
* showing no exception, URL, provider payload, credential, database detail, or internal identifier.
* Full error detail belongs only in Signage's sanitized management telemetry, where an operator who
* is already authorized can read it.
*
* Nothing here animates. A lobby television showing a flashing diagnostic for an hour is worse than
* one showing a still frame, and an unattended screen is exactly the case this exists for.
*
* A deliberate blackout is **not** this: that renders pure black, and never reaches here.
*/
export function SignageSlate(): ReactElement {
return (
<section aria-live="polite" className="signage-slate" data-testid="signage-slate">
<div aria-hidden="true" className="signage-slate__bars">
<span className="signage-slate__bar signage-slate__bar--one" />
<span className="signage-slate__bar signage-slate__bar--two" />
<span className="signage-slate__bar signage-slate__bar--three" />
<span className="signage-slate__bar signage-slate__bar--four" />
<span className="signage-slate__bar signage-slate__bar--five" />
<span className="signage-slate__bar signage-slate__bar--six" />
</div>
<p className="signage-slate__message">Content unavailable</p>
</section>
);
}
+251
View File
@@ -0,0 +1,251 @@
/**
* A display's own local storage: **IndexedDB for structured state, Cache Storage for image bytes**
* (ADR 0006).
*
* Ordinary HTTP cache is deliberately not used as the asset manager. It offers no eviction control,
* no way to prune a superseded rendition on purpose, and no way to answer the one question a display
* has to answer honestly — *what can I actually render if the network is gone?*
*
* Everything here is behind a small interface with an in-memory implementation, because a display's
* offline behaviour is the part that must be tested and IndexedDB is the part a test runner cannot
* reliably host. Both implementations satisfy the same contract; the browser one is thin.
*/
/** One key-value store of structured, JSON-serializable state. */
export interface SignageLocalStore {
read<Value>(key: string): Promise<Value | undefined>;
write(key: string, value: unknown): Promise<void>;
delete(key: string): Promise<void>;
}
/**
* Image bytes a display can still paint with no network.
*
* Everything is addressed by a **stable content key**, never by the delivery URL. A delivery URL is
* signed and short-lived: it changes on every refresh of the same bytes, so a cache keyed on one
* would store the same image over and over and find none of it after a reconnect. The key is derived
* from identity that does not change — the provider, the item, and the item's revision — so the
* bytes a display cached an hour ago are the bytes it can still paint now.
*/
export interface SignageMediaCache {
/** Fetches from `url` and stores under `key`, returning a local URL to render from. */
store(key: string, url: string): Promise<string | undefined>;
/** A local URL for previously stored bytes, or `undefined` if they were never stored. */
find(key: string): Promise<string | undefined>;
/** Removes every key not in the keep set. Called only after a new snapshot is usable, so a prune
* can never leave a screen with less than it had. */
prune(keep: readonly string[]): Promise<void>;
count(): Promise<number>;
}
/** The stable key a native image's bytes are cached under. Deliberately built from identity, never
* from a URL. */
export function signageMediaKey(input: {
readonly providerId: string;
readonly itemId: string;
readonly itemRevision: string;
readonly rendition: string;
}): string {
return `signage-media/${encodeURIComponent(input.providerId)}/${encodeURIComponent(input.itemId)}/${encodeURIComponent(input.itemRevision)}/${input.rendition}`;
}
export const SIGNAGE_LOCAL_DATABASE = "ekklesia-signage";
export const SIGNAGE_LOCAL_STORE = "state";
export const SIGNAGE_MEDIA_CACHE = "ekklesia-signage-media-v1";
export function createMemoryStore(): SignageLocalStore {
const values = new Map<string, unknown>();
return {
delete(key: string): Promise<void> {
values.delete(key);
return Promise.resolve();
},
read<Value>(key: string): Promise<Value | undefined> {
return Promise.resolve(values.get(key) as Value | undefined);
},
write(key: string, value: unknown): Promise<void> {
// Structured state only. Round-tripping through JSON here is what keeps a `Date`, a function,
// or a cycle from being written now and failing to come back later.
values.set(key, JSON.parse(JSON.stringify(value)) as unknown);
return Promise.resolve();
},
};
}
export function createMemoryMediaCache(): SignageMediaCache {
const stored = new Map<string, string>();
return {
count: () => Promise.resolve(stored.size),
find: (key: string) => Promise.resolve(stored.get(key)),
prune(keep: readonly string[]): Promise<void> {
const retained = new Set(keep);
for (const key of [...stored.keys()]) {
if (!retained.has(key)) {
stored.delete(key);
}
}
return Promise.resolve();
},
store(key: string, url: string): Promise<string | undefined> {
stored.set(key, url);
return Promise.resolve(url);
},
};
}
/** The IndexedDB-backed store a real display uses. Falls back to memory where IndexedDB is absent —
* a private window, a policy that blocks storage — because a display that cannot persist must still
* render, online. */
export function createBrowserStore(scope: typeof globalThis = globalThis): SignageLocalStore {
const maybeIndexedDb = (scope as { indexedDB?: IDBFactory }).indexedDB;
if (!maybeIndexedDb) {
return createMemoryStore();
}
const indexedDb: IDBFactory = maybeIndexedDb;
function open(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDb.open(SIGNAGE_LOCAL_DATABASE, 1);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(SIGNAGE_LOCAL_STORE)) {
database.createObjectStore(SIGNAGE_LOCAL_STORE);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(new Error("Signage could not open its local store."));
});
}
async function transact<Result>(
mode: IDBTransactionMode,
run: (store: IDBObjectStore) => IDBRequest,
): Promise<Result | undefined> {
let database: IDBDatabase;
try {
database = await open();
} catch {
return undefined;
}
return new Promise<Result | undefined>((resolve) => {
try {
const transaction = database.transaction(SIGNAGE_LOCAL_STORE, mode);
const request = run(transaction.objectStore(SIGNAGE_LOCAL_STORE));
request.onsuccess = () => resolve(request.result as Result | undefined);
request.onerror = () => resolve(undefined);
transaction.oncomplete = () => database.close();
} catch {
resolve(undefined);
}
});
}
return {
async delete(key: string): Promise<void> {
await transact("readwrite", (store) => store.delete(key));
},
read<Value>(key: string): Promise<Value | undefined> {
return transact<Value>("readonly", (store) => store.get(key));
},
async write(key: string, value: unknown): Promise<void> {
await transact("readwrite", (store) => store.put(value, key));
},
};
}
/** The Cache Storage-backed media cache a real display uses. Absent Cache Storage — an insecure
* origin in development, a policy that refuses — a display works exactly as it did before: online. */
export function createBrowserMediaCache(scope: typeof globalThis = globalThis): SignageMediaCache {
const maybeCaches = (scope as { caches?: CacheStorage }).caches;
const fetchFn = (scope as { fetch?: typeof fetch }).fetch;
if (!maybeCaches || !fetchFn) {
return createMemoryMediaCache();
}
const caches: CacheStorage = maybeCaches;
async function open(): Promise<Cache | undefined> {
try {
return await caches.open(SIGNAGE_MEDIA_CACHE);
} catch {
return undefined;
}
}
/** Cache Storage keys on a request URL, so a stable key becomes a stable absolute URL on this
* origin. It is never fetched; it exists only to be a key. */
function cacheRequest(key: string): string {
const origin =
(scope as { location?: { origin?: string } }).location?.origin ?? "https://signage.invalid";
return `${origin}/${key}`;
}
async function localUrl(cache: Cache, url: string): Promise<string | undefined> {
const response = await cache.match(url);
if (!response) {
return undefined;
}
try {
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch {
return undefined;
}
}
return {
async count(): Promise<number> {
const cache = await open();
if (!cache) {
return 0;
}
try {
return (await cache.keys()).length;
} catch {
return 0;
}
},
async find(key: string): Promise<string | undefined> {
const cache = await open();
return cache ? localUrl(cache, cacheRequest(key)) : undefined;
},
async prune(keep: readonly string[]): Promise<void> {
const cache = await open();
if (!cache) {
return;
}
const retained = new Set(keep.map((key) => cacheRequest(key)));
try {
for (const request of await cache.keys()) {
if (!retained.has(request.url)) {
await cache.delete(request);
}
}
} catch {
// A prune that fails leaves a screen with more than it needs, which is the safe direction.
}
},
async store(key: string, url: string): Promise<string | undefined> {
const cache = await open();
if (!cache) {
return undefined;
}
try {
const response = await fetchFn(url);
// Only a complete, successful response is stored. A `404` or a redirect replayed later as a
// slide would be worse than having nothing cached.
if (!response.ok || response.status !== 200) {
return undefined;
}
// Stored under the *stable* key, so the next refresh's freshly signed URL finds these
// bytes instead of downloading them again.
await cache.put(cacheRequest(key), response.clone());
return localUrl(cache, cacheRequest(key));
} catch {
return undefined;
}
},
};
}
+192
View File
@@ -0,0 +1,192 @@
/**
* The upload queue behind the image library.
*
* The shape of it is the whole point: **one image, one request**, several at a time but never all at
* once. Twenty slides an operator dropped on the page become twenty individually bounded requests,
* each of which the server can refuse cheaply, rather than one enormous one whose failure wastes
* every byte already sent (ADR 0007). And because only a few are in flight, the dashboard stays
* responsive and the API's memory stays bounded while a batch runs.
*
* Pure and injectable: the queue takes a function that uploads one file and knows nothing about HTTP,
* so its ordering, concurrency, and failure behaviour are exercised directly.
*/
export type UploadStatus = "queued" | "uploading" | "done" | "failed" | "rejected";
export interface UploadItem {
readonly id: string;
readonly name: string;
readonly size: number;
readonly status: UploadStatus;
/** A sanitized code from the server, or one the queue itself decided before sending. */
readonly code?: string;
readonly message?: string;
}
export interface UploadFile {
readonly name: string;
readonly size: number;
readonly type: string;
slice?(start?: number, end?: number): Blob;
}
export interface UploadQueueOptions {
readonly concurrency: number;
readonly maxBytes: number;
readonly maxFiles: number;
/** Uploads exactly one file. Rejecting is a failure for that file and nothing else. */
readonly upload: (file: UploadFile) => Promise<void>;
readonly onChange: (items: readonly UploadItem[]) => void;
readonly newId?: () => string;
}
export interface UploadQueue {
/** Adds files and starts working. Returns immediately; progress arrives through `onChange`. */
enqueue(files: readonly UploadFile[]): void;
items(): readonly UploadItem[];
/** Resolves when nothing is left in flight. */
drain(): Promise<void>;
clearFinished(): void;
}
/** Formats the two rejections the queue itself makes, so an operator is told before a byte is sent
* rather than after the server refuses. */
export const UPLOAD_REJECTIONS = Object.freeze({
SIGNAGE_IMAGE_TOO_LARGE: "This file is larger than Signage accepts.",
SIGNAGE_UPLOAD_BATCH_TOO_LARGE: "Too many files at once. Add them in smaller batches.",
SIGNAGE_UPLOAD_UNSUPPORTED_TYPE: "Signage accepts PNG, JPEG, and WebP images.",
});
const ACCEPTED_TYPES: ReadonlySet<string> = new Set(["image/jpeg", "image/png", "image/webp"]);
export function createUploadQueue(options: UploadQueueOptions): UploadQueue {
const newId = options.newId ?? (() => Math.random().toString(36).slice(2));
let items: UploadItem[] = [];
const pending: { item: UploadItem; file: UploadFile }[] = [];
let active = 0;
let idle: Promise<void> = Promise.resolve();
let settleIdle: (() => void) | undefined;
function publish(): void {
options.onChange([...items]);
}
function replace(id: string, changes: Partial<UploadItem>): void {
items = items.map((item) => (item.id === id ? { ...item, ...changes } : item));
publish();
}
function pump(): void {
while (active < options.concurrency && pending.length > 0) {
const next = pending.shift();
if (!next) {
break;
}
active += 1;
replace(next.item.id, { status: "uploading" });
void options
.upload(next.file)
.then(() => replace(next.item.id, { status: "done" }))
.catch((error: unknown) => {
// One bad file fails alone. The rest of the batch keeps going, which is the entire reason
// a batch is many requests rather than one.
const failure = error as { failure?: { code?: string; message?: string } };
replace(next.item.id, {
code: failure.failure?.code ?? "SIGNAGE_REQUEST_FAILED",
message: failure.failure?.message ?? "Signage could not be reached.",
status: "failed",
});
})
.finally(() => {
active -= 1;
if (pending.length > 0) {
pump();
} else if (active === 0) {
settleIdle?.();
settleIdle = undefined;
}
});
}
}
return {
clearFinished(): void {
items = items.filter((item) => item.status === "queued" || item.status === "uploading");
publish();
},
drain(): Promise<void> {
if (active === 0 && pending.length === 0) {
return Promise.resolve();
}
idle = new Promise<void>((resolve) => {
settleIdle = resolve;
});
return idle;
},
enqueue(files: readonly UploadFile[]): void {
const accepted = files.slice(0, options.maxFiles);
const overflow = files.slice(options.maxFiles);
for (const file of accepted) {
const item: UploadItem = {
id: newId(),
name: file.name,
size: file.size,
status: "queued",
};
items = [...items, item];
// Two rejections happen here, before anything is sent: a file too large for Signage, and a
// type it does not accept. The server checks both again — it must, because a browser is not
// a security boundary — but telling an operator now costs them nothing.
if (file.size > options.maxBytes) {
items = items.map((candidate) =>
candidate.id === item.id
? {
...candidate,
code: "SIGNAGE_IMAGE_TOO_LARGE",
message: UPLOAD_REJECTIONS.SIGNAGE_IMAGE_TOO_LARGE,
status: "rejected",
}
: candidate,
);
continue;
}
if (file.type !== "" && !ACCEPTED_TYPES.has(file.type)) {
items = items.map((candidate) =>
candidate.id === item.id
? {
...candidate,
code: "SIGNAGE_UPLOAD_UNSUPPORTED_TYPE",
message: UPLOAD_REJECTIONS.SIGNAGE_UPLOAD_UNSUPPORTED_TYPE,
status: "rejected",
}
: candidate,
);
continue;
}
pending.push({ file, item });
}
for (const file of overflow) {
items = [
...items,
{
code: "SIGNAGE_UPLOAD_BATCH_TOO_LARGE",
id: newId(),
message: UPLOAD_REJECTIONS.SIGNAGE_UPLOAD_BATCH_TOO_LARGE,
name: file.name,
size: file.size,
status: "rejected",
},
];
}
publish();
pump();
},
items: () => [...items],
};
}
+676
View File
@@ -0,0 +1,676 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import {
type OfflineSnapshot,
resolveOffline,
serverNowFrom,
snapshotFrom,
} from "../browser/offline.js";
import { createSceneStage, type SceneStage } from "../browser/scene.js";
import {
nativeSceneRequestFor,
type SceneSelection,
sceneRequestFor,
} from "../browser/scene-request.js";
import {
createBrowserMediaCache,
createBrowserStore,
type SignageLocalStore,
type SignageMediaCache,
signageMediaKey,
} from "../browser/storage.js";
/**
* The display: what it fetches, what it shows, and what it reports.
*
* The order of operations is the product. Server state arrives, the scene is prepared in the hidden
* layer and *awaited*, and only then does the incoming layer fade in over an outgoing layer that was
* never touched. When content cannot be prepared, the last good frame stays exactly where it is and
* the failure becomes a bounded, sanitized code in the next heartbeat — never anything on screen.
*
* Everything the platform provides arrives through the host context: the API client, realtime, and
* the renderers other modules contributed. Nothing here builds a URL, opens a socket, or reaches for
* a credential.
*/
const VIEWER_KEY_STORAGE = "viewer-key";
const SNAPSHOT_STORAGE = "last-known-good";
/** A safety-net refetch. Realtime drives every transition, but a display that missed one message
* must not sit on a stale frame until the next; and a delivery URL has a finite life. */
const REFRESH_INTERVAL_MS = 4 * 60 * 1000;
export interface DisplayChannelStateView {
readonly serverNowMs: number;
readonly channel: { readonly id: string; readonly key: string; readonly groupId: string };
readonly crossfadeMs: number;
readonly playback: {
readonly revision: number;
readonly transport: string;
readonly blackout: boolean;
readonly remainingMs: number | null;
readonly selectionRevision: number | null;
readonly currentSelectionId: string | null;
readonly currentSlotId: string | null;
readonly activeTimelineId: string | null;
readonly pendingTimelineId: string | null;
readonly startedAtMs: number | null;
readonly deadlineAtMs: number | null;
};
readonly timeline: OfflineSnapshot["timeline"];
readonly current: SceneSelection | null;
readonly staticHold: SceneSelection | null;
readonly selections: readonly SceneSelection[];
}
export interface DisplaySessionView {
readonly viewerId: string;
readonly channelId: string;
readonly channelKey: string;
readonly groupId: string;
readonly heartbeatIntervalMs: number;
}
/** What an operator can see about this screen, and what a test asserts on. Sanitized by
* construction: identity, counts, and a code. */
export interface DisplayDiagnostics {
readonly viewerId: string | undefined;
readonly channelId: string | undefined;
readonly renderedKey: string | undefined;
readonly renderedSelectionId: string | undefined;
readonly slate: boolean;
readonly blackout: boolean;
readonly online: boolean;
/** Whether this display is currently continuing from its own persisted state rather than from the
* server. Reported so an operator can see it; never sent back as authority. */
readonly offline: boolean;
readonly offlineReady: boolean;
readonly cachedAssetCount: number;
readonly unusableSelectionIds: readonly string[];
readonly lastError: { readonly code: string; readonly detail: string | null } | undefined;
}
export interface DisplayControllerOptions {
readonly context: ModuleUiHostContext;
readonly root: Element;
readonly now?: () => number;
readonly setTimer?: (handler: () => void, delayMs: number) => unknown;
readonly clearTimer?: (handle: unknown) => void;
readonly store?: SignageLocalStore;
readonly media?: SignageMediaCache;
readonly decodeImage?: (image: HTMLImageElement) => Promise<void>;
readonly newViewerKey?: () => string;
readonly onDiagnostics?: (diagnostics: DisplayDiagnostics) => void;
/** Suppresses viewer registration and heartbeats. The management preview uses this: it renders
* exactly what a wall renders, and must not appear in the display roster while doing so. */
readonly telemetry?: boolean;
}
export interface DisplayController {
start(): Promise<void>;
stop(): Promise<void>;
/** Applies one channel state. Exposed so a test — and the preview — can drive it directly. */
apply(state: DisplayChannelStateView): Promise<void>;
diagnostics(): DisplayDiagnostics;
}
export function createDisplayController(options: DisplayControllerOptions): DisplayController {
const now = options.now ?? (() => Date.now());
const setTimer =
options.setTimer ??
((handler: () => void, delayMs: number) => {
return setTimeout(handler, delayMs);
});
const clearTimer = options.clearTimer ?? ((handle: unknown) => clearTimeout(handle as never));
const store = options.store ?? createBrowserStore();
const media = options.media ?? createBrowserMediaCache();
const telemetry = options.telemetry !== false;
let stage: SceneStage | undefined;
let session: DisplaySessionView | undefined;
let subscription: { close(): void } | undefined;
let heartbeatHandle: unknown;
let refreshHandle: unknown;
let stopped = false;
let online = false;
let renderedSelectionId: string | undefined;
let cachedAssetCount = 0;
let lastError: { code: string; detail: string | null } | undefined;
let appliedRevision = -1;
let appliedSelectionRevision: number | null = null;
let lastState: DisplayChannelStateView | undefined;
let snapshot: OfflineSnapshot | undefined;
let offlineActive = false;
let offlineHandle: unknown;
/** Selections this display has tried and failed to render. A broken item is skipped rather than
* retried in a tight loop, which is what keeps one bad slide from becoming a flicker. */
const unusable = new Set<string>();
function report(): void {
// A quiet, honest state on the root, which the display renders as a small corner indicator. A
// screen whose switch was rebooted must not blank a lobby wall to say so.
(options.root as HTMLElement).dataset.offline = offlineActive ? "true" : "false";
options.onDiagnostics?.(diagnostics());
}
function diagnostics(): DisplayDiagnostics {
return {
blackout: lastState?.playback.blackout ?? snapshot?.playback.blackout ?? false,
cachedAssetCount,
channelId: session?.channelId,
lastError,
offline: offlineActive,
offlineReady:
snapshot !== undefined && cachedAssetCount >= 0 && snapshot.selections.length > 0,
online,
renderedKey: stage?.currentKey(),
renderedSelectionId,
slate: stage?.currentKey() === undefined,
unusableSelectionIds: [...unusable],
viewerId: session?.viewerId,
};
}
function recordError(code: string, detail: string | null = null): void {
lastError = { code, detail };
report();
}
async function viewerKey(): Promise<string> {
const existing = await store.read<string>(VIEWER_KEY_STORAGE);
if (typeof existing === "string" && existing.length >= 8) {
return existing;
}
// A display's identity is its own and persists across reboots, so a television that restarts is
// recognized as the same screen rather than filling the roster with ghosts.
const created =
options.newViewerKey?.() ??
`viewer-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
await store.write(VIEWER_KEY_STORAGE, created);
return created;
}
/** Caches a native image's bytes under a **stable** key, and prefers the cached copy when it
* exists. A delivery URL is signed and changes on every refresh; the bytes do not. */
async function localUrlFor(selection: SceneSelection): Promise<string | undefined> {
if (selection.renderer.kind !== "native-image") {
return undefined;
}
const key = signageMediaKey({
itemId: selection.itemId,
itemRevision: selection.itemRevision,
providerId: selection.providerId,
rendition: "display",
});
const cached = await media.find(key);
if (cached !== undefined) {
return cached;
}
const remote = selection.media?.display.url;
if (remote === undefined) {
return undefined;
}
const stored = await media.store(key, remote);
cachedAssetCount = await media.count();
return stored ?? remote;
}
async function renderSelection(
selection: SceneSelection,
state: DisplayChannelStateView,
): Promise<void> {
if (!stage) {
return;
}
if (unusable.has(selection.id)) {
// Already known broken here. Keep what is on screen rather than trying it again immediately.
return;
}
const context = {
audienceKey: state.channel.key,
localNowMs: now(),
paused: state.playback.transport === "paused",
remainingMs: state.playback.remainingMs,
serverNowMs: state.serverNowMs,
};
const request = sceneRequestFor(selection, context, {
canMountContribution: (contributionId) =>
options.context.contributions
.list("signage.slide-renderer")
.some((candidate) => candidate.id === contributionId),
});
if (!request) {
// Not renderable here — a missing delivery URL, or a renderer the platform did not publish.
// That is a fact about this display, not a failure of playback.
unusable.add(selection.id);
recordError("SLIDE_UNRENDERABLE");
return;
}
if (request.kind === "native-image") {
const url = await localUrlFor(selection);
if (url !== undefined) {
Object.assign(request, { url });
}
}
const outcome = await stage.show(request);
if (outcome.kind === "failed") {
unusable.add(selection.id);
recordError(outcome.code);
return;
}
if (outcome.kind === "superseded") {
return;
}
renderedSelectionId = selection.id;
if (lastError !== undefined) {
lastError = undefined;
}
report();
}
async function apply(state: DisplayChannelStateView): Promise<void> {
if (stopped || !stage) {
return;
}
// The server is authoritative the instant it speaks. Any local cursor is abandoned rather than
// reconciled, and it was never reported anywhere to begin with.
online = true;
exitOffline();
lastState = state;
// The crossfade is an operator setting and arrives with the state, so the stage is told rather
// than assuming one.
stage.setCrossfade(state.crossfadeMs);
appliedRevision = state.playback.revision;
appliedSelectionRevision = state.playback.selectionRevision;
// Blackout covers the output; the timeline underneath keeps running and keeps being prepared, so
// ending it lands on whatever the server says is current rather than on a stale frame.
stage.setBlackout(state.playback.blackout);
const target = state.staticHold ?? state.current;
if (target === null) {
// Nothing renderable from the server. If something good is already on screen it stays; only a
// display with nothing at all shows the slate.
if (stage.currentKey() === undefined) {
stage.setSlate(true);
}
report();
return;
}
stage.setSlate(false);
await renderSelection(target, state);
// Everything else the server sent is staged for later: a reconnect should have nothing left to
// download, and an outage should have something to continue from. The snapshot is written only
// once the frame is up, so what a display resumes from is what it actually showed — and the
// media prune runs after it, so nothing is discarded until its replacement is usable.
void prefetch(state);
await persist(state);
scheduleRefresh(state);
}
/**
* Persists the last known good state.
*
* Written **after** the frame is on screen, so what a display resumes from is what it actually
* showed. Delivery URLs are stripped on the way in: one is a bearer capability with an expiry in
* it, and the bytes live in the media cache under a stable key instead.
*/
async function persist(state: DisplayChannelStateView): Promise<void> {
snapshot = snapshotFrom({
channel: state.channel,
crossfadeMs: state.crossfadeMs,
localNowMs: now(),
playback: {
blackout: state.playback.blackout,
currentSelectionId: state.playback.currentSelectionId,
currentSlotId: state.playback.currentSlotId,
deadlineAtMs: state.playback.deadlineAtMs,
pendingTimelineId: state.playback.pendingTimelineId,
remainingMs: state.playback.remainingMs,
revision: state.playback.revision,
startedAtMs: state.playback.startedAtMs,
transport: state.playback.transport,
},
selections: state.selections,
serverNowMs: state.serverNowMs,
staticHold: state.staticHold,
timeline: state.timeline,
});
try {
await store.write(SNAPSHOT_STORAGE, snapshot);
} catch {
// Persistence is best effort and never blocks rendering. A display that cannot write still
// works — online.
}
}
/**
* Continues from the last authoritative anchor, with no network.
*
* Deterministic, arithmetic, and bounded: it replays selections the server already resolved and
* this display already cached, and it never invents a provider's answer. Its cursor is never
* reported anywhere — the moment the server speaks again, the server wins.
*/
async function runOffline(): Promise<void> {
if (stopped || online || !stage || !snapshot) {
return;
}
offlineActive = true;
// The crossfade is part of the persisted state too, so a screen that resumes offline fades the
// way its operator configured rather than the way this file happened to default.
stage.setCrossfade(snapshot.crossfadeMs);
const atMs = serverNowFrom(snapshot, now());
const evaluation = resolveOffline({ atMs, snapshot, unusableSelectionIds: unusable });
const resolution = evaluation.resolution;
if (resolution.kind === "empty") {
// The cached timeline genuinely has nothing eligible. Black is correct, and only when nothing
// good is already up.
if (stage.currentKey() === undefined) {
stage.setSlate(true);
}
} else if (resolution.kind === "static" || resolution.kind === "selection") {
await renderOfflineSelection(resolution.selection, atMs);
} else if (resolution.kind === "paused" && resolution.selection) {
await renderOfflineSelection(resolution.selection, atMs);
}
// `hold` is deliberate: keep the last good frame and say nothing.
report();
scheduleOffline(evaluation.nextBoundaryMs, atMs);
}
async function renderOfflineSelection(selection: SceneSelection, atMs: number): Promise<void> {
if (!stage || !snapshot) {
return;
}
const context = {
audienceKey: snapshot.channel.key,
localNowMs: now(),
paused: snapshot.playback.transport === "paused",
remainingMs: snapshot.playback.remainingMs,
serverNowMs: atMs,
};
let request: ReturnType<typeof sceneRequestFor>;
if (selection.renderer.kind === "native-image") {
// Offline, only what is genuinely cached can be painted: a persisted selection carries no
// delivery URL, deliberately, so the local copy is the only source there is.
const cached = await media.find(
signageMediaKey({
itemId: selection.itemId,
itemRevision: selection.itemRevision,
providerId: selection.providerId,
rendition: "display",
}),
);
request =
cached === undefined ? undefined : nativeSceneRequestFor(selection, context, cached);
} else {
request = sceneRequestFor(selection, context, {
canMountContribution: (contributionId) =>
options.context.contributions
.list("signage.slide-renderer")
.some((candidate) => candidate.id === contributionId),
});
}
if (!request) {
// Nothing local can draw this. The slot is skipped and the last good frame stays.
unusable.add(selection.id);
return;
}
const outcome = await stage.show(request);
if (outcome.kind === "failed") {
unusable.add(selection.id);
return;
}
if (outcome.kind !== "superseded") {
renderedSelectionId = selection.id;
}
}
function scheduleOffline(boundaryMs: number | null, atMs: number): void {
if (offlineHandle !== undefined) {
clearTimer(offlineHandle);
offlineHandle = undefined;
}
if (stopped || online || boundaryMs === null) {
return;
}
offlineHandle = setTimer(
() => {
offlineHandle = undefined;
void runOffline();
},
Math.max(250, boundaryMs - atMs),
);
}
function exitOffline(): void {
offlineActive = false;
if (offlineHandle !== undefined) {
clearTimer(offlineHandle);
offlineHandle = undefined;
}
}
async function prefetch(state: DisplayChannelStateView): Promise<void> {
for (const selection of state.selections) {
if (selection.renderer.kind !== "native-image") {
continue;
}
try {
await localUrlFor(selection);
} catch {
// A slide that cannot be pre-cached is simply not pre-cached. It will be tried again when it
// is actually needed, and the frame on screen is unaffected.
}
}
// Superseded bytes are removed **only now**, after everything this state needs is present: a
// prune that ran first could leave a screen with less than it had.
await media.prune(
state.selections
.filter((selection) => selection.renderer.kind === "native-image")
.map((selection) =>
signageMediaKey({
itemId: selection.itemId,
itemRevision: selection.itemRevision,
providerId: selection.providerId,
rendition: "display",
}),
),
);
cachedAssetCount = await media.count();
report();
}
function scheduleRefresh(state: DisplayChannelStateView): void {
if (refreshHandle !== undefined) {
clearTimer(refreshHandle);
refreshHandle = undefined;
}
if (stopped) {
return;
}
// Just after the current slot's deadline, so a missed realtime message costs one late
// transition rather than a stuck screen; and never longer than the safety-net interval.
const untilDeadline =
state.playback.deadlineAtMs === null
? REFRESH_INTERVAL_MS
: Math.max(250, state.playback.deadlineAtMs - state.serverNowMs + 250);
refreshHandle = setTimer(
() => {
refreshHandle = undefined;
void refresh();
},
Math.min(REFRESH_INTERVAL_MS, untilDeadline),
);
}
async function refresh(): Promise<void> {
if (stopped || !session) {
return;
}
try {
const response = await options.context.api.request(
`channels/${session.channelId}/display-state`,
);
if (!response.ok) {
online = false;
await runOffline();
return;
}
await apply((await response.json()) as DisplayChannelStateView);
} catch {
// The server is unreachable. The frame stays and local continuation takes over; nothing about
// a network failure belongs on a lobby wall.
online = false;
await runOffline();
}
}
async function heartbeat(): Promise<void> {
if (stopped || !session || !telemetry) {
return;
}
try {
await options.context.api.request(`viewers/${session.viewerId}/heartbeat`, {
body: JSON.stringify({
cachedAssetCount,
error:
lastError === undefined ? null : { code: lastError.code, detail: lastError.detail },
offlineReady: snapshot !== undefined && snapshot.selections.length > 0,
reportedContributionId:
lastState?.current?.renderer.kind === "contribution"
? lastState.current.renderer.contributionId
: null,
reportedPlaybackRevision: appliedRevision >= 0 ? appliedRevision : null,
reportedProviderId: lastState?.current?.providerId ?? null,
reportedSelectionId: renderedSelectionId ?? null,
reportedTimelineId: lastState?.playback.activeTimelineId ?? null,
}),
headers: { "content-type": "application/json" },
method: "POST",
});
} catch {
// Telemetry is best effort and never affects what is on screen.
}
}
return {
apply,
diagnostics,
async start(): Promise<void> {
stopped = false;
stage = createSceneStage({
crossfadeMs: 1000,
...(options.decodeImage ? { decodeImage: options.decodeImage } : {}),
mountContribution: async ({ container, contributionId, input }) => {
// The platform loads, verifies, and mounts the renderer. Signage hands it identity and its
// own opaque input, and receives a handle — never a URL, and never the module's code.
const mounted = await options.context.contributions.mount({
container,
contributionId,
input: input as never,
});
return {
unmount: () => mounted.unmount(),
update: (next: unknown) => mounted.update(next as never),
};
},
root: options.root,
setTimer,
});
// A frame from the last known good state goes up before the first request returns, so a
// reload during a service does not stare at black while a fetch completes.
try {
const restored = await store.read<OfflineSnapshot>(SNAPSHOT_STORAGE);
if (restored?.version === 1) {
snapshot = restored;
await runOffline();
}
} catch {
// Nothing persisted, or nothing readable. The display simply starts from the server.
}
if (telemetry) {
try {
const response = await options.context.api.request("viewers/session", {
body: JSON.stringify({ viewerKey: await viewerKey() }),
headers: { "content-type": "application/json" },
method: "POST",
});
if (response.ok) {
session = (await response.json()) as DisplaySessionView;
online = true;
}
} catch {
online = false;
}
}
if (session) {
subscription = options.context.realtime.subscribe("display-state", {
onSnapshot: (state) => onRealtime(state),
onStatus: (status) => {
online = status === "connected";
report();
},
onUpdate: (state) => onRealtime(state),
});
heartbeatHandle = setInterval(
() => void heartbeat(),
session.heartbeatIntervalMs,
) as unknown;
await refresh();
void heartbeat();
}
report();
},
async stop(): Promise<void> {
stopped = true;
subscription?.close();
subscription = undefined;
if (heartbeatHandle !== undefined) {
clearInterval(heartbeatHandle as never);
heartbeatHandle = undefined;
}
if (refreshHandle !== undefined) {
clearTimer(refreshHandle);
refreshHandle = undefined;
}
exitOffline();
await stage?.destroy();
stage = undefined;
},
};
/**
* A realtime frame carries compact authoritative state and revisions, never content. A revision
* this display has not applied means "fetch the state you are authorized for", which is what
* closes the GET-then-subscribe race without a second transport (ADR 0005).
*/
function onRealtime(payload: unknown): void {
if (stopped || !session) {
return;
}
const groups = (payload as { groups?: readonly Record<string, unknown>[] } | null)?.groups;
if (!Array.isArray(groups)) {
return;
}
const mine = groups.find((group) => group.groupId === session?.groupId);
if (!mine) {
return;
}
online = true;
const revision = typeof mine.revision === "number" ? mine.revision : -1;
const selectionRevision =
typeof mine.selectionRevision === "number" ? mine.selectionRevision : null;
if (revision === appliedRevision && selectionRevision === appliedSelectionRevision) {
return;
}
void refresh();
}
}
+69
View File
@@ -0,0 +1,69 @@
import { defineModuleUi, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { type ReactElement, useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
import { SignageSlate } from "../browser/slate.js";
import { createDisplayController } from "./controller.js";
import displayStyles from "./styles.css";
/**
* Signage's full-viewport display.
*
* React renders the fixed furniture — the stage's root, the blackout cover, and the slate — and the
* controller owns everything that moves. That split is deliberate: the two scene layers are created
* once and never rebuilt, and a React tree that re-created them on every state change would be the
* `v1` renderer again.
*
* The artifact performs no permission check of its own. The host resolved an authorization-bearing
* descriptor before these bytes were ever fetched, and a television has nobody standing at it to act
* on a denial — telling a lobby wall "you may not see this" would only report something about the
* installation's authorization to whoever walks past.
*/
function SignageDisplay({ context }: { readonly context: ModuleUiHostContext }): ReactElement {
const stageRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = stageRef.current;
if (!root) {
return;
}
const controller = createDisplayController({ context, root });
void controller.start();
return () => {
void controller.stop();
};
}, [context]);
return (
<main className="signage-display" data-testid="signage-display">
<div className="signage-stage" data-testid="signage-stage" ref={stageRef}>
{/* Shown only when the stage says there is nothing renderable *and* no good frame. */}
<div className="signage-slate-host">
<SignageSlate />
</div>
{/* A deliberate blackout: pure black over everything, while the hidden timeline runs on. */}
<div aria-hidden="true" className="signage-blackout" data-testid="signage-blackout" />
{/* Continuing from this screen's own last authoritative state. Small and in a corner: a
congregation is looking at this wall, and a network is not their problem. */}
<p className="signage-offline-note" data-testid="signage-offline-note">
Offline — continuing from the last update
</p>
</div>
</main>
);
}
/**
* The compiled full-viewport display artifact. It uses the same role-neutral browser ABI as the
* management UI; its separate manifest entrypoint — not a mode flag — is what tells a host which
* artifact it is mounting.
*/
export default defineModuleUi({
id: "signage",
version: "0.1.0",
styles: displayStyles,
mount(container, context) {
const root = createRoot(container);
root.render(<SignageDisplay context={context} />);
return () => root.unmount();
},
});
+227
View File
@@ -0,0 +1,227 @@
/*
* The Signage display artifact's own stylesheet, isolated from the management UI's exactly as it is
* from the Dashboard's: two artifacts of one module share no styles unless they share a source file
* (ADR 0040).
*
* `:host { display: block }` overrides the host's transparent default deliberately — a full-viewport
* display needs a real box, and the host installs its base rules before this stylesheet precisely so
* an artifact can override them by ordinary cascade order.
*
* Almost every value here is a literal rather than a token, and that is the one deliberate exception
* in this module. A television is not a themed surface: its ground is black because anything else
* would show as a frame around a church's own artwork, and the slate must stay unmistakable whatever
* a deployment's theme becomes. The Dashboard's theme never recolours a provider's output or an
* uploaded image.
*/
/*
* The box model for the tree Signage owns. An isolated root inherits nothing from the page around
* it, so a full-viewport layout that assumes `border-box` has to say so.
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
:host {
display: block;
--signage-slate-bar-one: #6f6f6f;
--signage-slate-bar-two: #6f6f2f;
--signage-slate-bar-three: #2f6f6f;
--signage-slate-bar-four: #2f6f2f;
--signage-slate-bar-five: #6f2f6f;
--signage-slate-bar-six: #6f2f2f;
}
.signage-display {
background: #000000;
color: #ffffff;
font-family: var(--ekklesia-typography-family-sans);
height: 100%;
inset: 0;
margin: 0;
min-height: 100vh;
overflow: hidden;
position: relative;
width: 100%;
}
.signage-stage {
height: 100%;
inset: 0;
min-height: 100vh;
position: absolute;
width: 100%;
}
/* ---------------------------------------------------------------------------------------------
* The two permanent scene layers.
*
* Created once by the stage and never rebuilt. Only their contents, stacking, and opacity change —
* and an *outgoing* layer is never faded, which is what keeps the black root from showing through
* the middle of a transition.
* ------------------------------------------------------------------------------------------- */
.signage-layer {
height: 100%;
inset: 0;
opacity: 0;
position: absolute;
transition-duration: 0ms;
transition-property: opacity;
transition-timing-function: linear;
width: 100%;
}
.signage-layer__background {
background: #000000;
inset: 0;
position: absolute;
}
.signage-layer__background-image {
display: none;
height: 100%;
inset: 0;
object-fit: cover;
position: absolute;
transform: scale(1.08);
width: 100%;
}
.signage-layer__darken {
background: #000000;
inset: 0;
opacity: 0;
position: absolute;
}
.signage-layer__foreground {
height: 100%;
inset: 0;
object-fit: contain;
position: absolute;
width: 100%;
}
.signage-layer__renderer {
inset: 0;
position: absolute;
}
/* ---------------------------------------------------------------------------------------------
* Blackout and the slate, both states of the stage root.
* ------------------------------------------------------------------------------------------- */
.signage-blackout {
background: #000000;
inset: 0;
opacity: 0;
pointer-events: none;
position: absolute;
transition: opacity 200ms linear;
z-index: 20;
}
[data-blackout="true"] > .signage-blackout {
opacity: 1;
}
.signage-slate-host {
inset: 0;
opacity: 0;
pointer-events: none;
position: absolute;
z-index: 10;
}
[data-slate="true"] > .signage-slate-host {
opacity: 1;
}
.signage-slate {
align-items: stretch;
display: flex;
flex-direction: column;
height: 100%;
inset: 0;
position: absolute;
width: 100%;
}
.signage-slate__bars {
display: grid;
flex: 1 1 auto;
grid-template-columns: repeat(6, 1fr);
}
.signage-slate__bar {
display: block;
height: 100%;
}
.signage-slate__bar--one {
background: var(--signage-slate-bar-one);
}
.signage-slate__bar--two {
background: var(--signage-slate-bar-two);
}
.signage-slate__bar--three {
background: var(--signage-slate-bar-three);
}
.signage-slate__bar--four {
background: var(--signage-slate-bar-four);
}
.signage-slate__bar--five {
background: var(--signage-slate-bar-five);
}
.signage-slate__bar--six {
background: var(--signage-slate-bar-six);
}
.signage-slate__message {
background: #000000;
flex: 0 0 auto;
font-size: 4vh;
letter-spacing: 0.08em;
margin: 0;
padding: 3vh 0;
text-align: center;
text-transform: uppercase;
}
/* ---------------------------------------------------------------------------------------------
* The offline indicator.
*
* Deliberately small, deliberately in a corner, and deliberately not an error. A screen continuing
* from its own last authoritative state is working; a full-page failure message on a lobby wall
* would be the actual failure.
* ------------------------------------------------------------------------------------------- */
.signage-offline-note {
background: rgba(0, 0, 0, 0.55);
border-radius: 0.6vh;
bottom: 2vh;
color: rgba(255, 255, 255, 0.75);
font-size: 1.6vh;
letter-spacing: 0.04em;
margin: 0;
opacity: 0;
padding: 0.8vh 1.4vh;
pointer-events: none;
position: absolute;
right: 2vh;
transition: opacity 400ms linear;
z-index: 30;
}
[data-offline="true"] > .signage-offline-note {
opacity: 1;
}
+17
View File
@@ -0,0 +1,17 @@
/**
* What `@ekklesia/module-ui-build` turns a local stylesheet import into: that stylesheet's compiled
* CSS **text**, as a string (ADR 0040).
*
* ```ts
* import styles from "./styles.css";
* export default defineModuleUi({ id, version, styles, mount });
* ```
*
* There is no sidecar file and no URL — the text is compiled into the declared JavaScript artifact,
* and the host installs it into the artifact's isolated root at mount. Copy this file into your own
* module and include it in your UI TypeScript project.
*/
declare module "*.css" {
const stylesheet: string;
export default stylesheet;
}
+50
View File
@@ -0,0 +1,50 @@
import { defineModuleSchema } from "@ekklesia/module-sdk";
import {
channelGroups,
channels,
imageAssets,
imageRenditions,
playbackStates,
providerSources,
selections,
selectionVariants,
timelineSlots,
timelines,
viewers,
} from "./tables.js";
export {
channelGroups,
channels,
imageAssets,
imageRenditions,
playbackStates,
providerSources,
selections,
selectionVariants,
timelineSlots,
timelines,
viewers,
} from "./tables.js";
/**
* Signage's compiled schema entrypoint: the same table objects `tables.ts` declares, wrapped with the
* ownership marker Core validates during discovery. Side-effect free by contract — importing this
* connects to nothing and reads nothing.
*/
export default defineModuleSchema({
moduleId: "signage",
tables: {
channelGroups,
channels,
imageAssets,
imageRenditions,
playbackStates,
providerSources,
selections,
selectionVariants,
timelines,
timelineSlots,
viewers,
},
});
+485
View File
@@ -0,0 +1,485 @@
import { sql } from "drizzle-orm";
import {
bigint,
boolean,
check,
index,
integer,
jsonb,
pgTableCreator,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
/**
* Signage's own PostgreSQL tables.
*
* Kept to **pure Drizzle declarations** so the stable Drizzle Kit generator can load this file
* without executing the ESM Module SDK wrapper; `./index.ts` wraps these same objects with the
* ownership marker Core validates. The required `signage_` namespace is applied once here, and Core
* validates it independently of this helper.
*
* Two rules hold everywhere below, and both are architecture rather than style:
*
* - **No foreign key leaves this module.** A provider's id, a source id, an item id, and a Core
* device id are all stored as opaque text. Signage cannot join to another module's data and must
* never be able to (ADR 0007 of the platform, ADR 0001 here).
* - **No signed delivery URL is ever stored.** Objects are addressed by their logical key, and a
* read URL is requested fresh when something is about to be delivered — a stored URL is a
* credential with an expiry date in it.
*/
const moduleTable = pgTableCreator((localName) => `signage_${localName}`);
/** Timestamps are absolute instants. The installation's IANA zone is an operator setting used to
* write and read them for a person, never to interpret one at run time. */
const instant = (name: string) => timestamp(name, { mode: "date", withTimezone: true });
// -------------------------------------------------------------------------------------------------
// Native images.
// -------------------------------------------------------------------------------------------------
/**
* One native image, identified by the SHA-256 of the bytes an operator uploaded (ADR 0007).
*
* `content_sha256` is unique, so uploading the same file twice returns the same asset instead of
* storing a second copy, and re-running a failed ingest is idempotent.
*/
export const imageAssets = moduleTable(
"image_assets",
{
id: uuid("id").primaryKey(),
contentSha256: text("content_sha256").notNull(),
displayName: text("display_name").notNull(),
originalFilename: text("original_filename").notNull(),
/** The archived original, byte for byte. Signage is not entitled to rewrite a church's file. */
originalObjectKey: text("original_object_key").notNull(),
originalContentType: text("original_content_type").notNull(),
originalByteSize: bigint("original_byte_size", { mode: "number" }).notNull(),
/** How the original should appear once its own orientation is applied. */
width: integer("width").notNull(),
height: integer("height").notNull(),
/** What is actually encoded in the file, kept for diagnostics without implying the archived
* bytes were rewritten. */
encodedWidth: integer("encoded_width").notNull(),
encodedHeight: integer("encoded_height").notNull(),
orientation: integer("orientation"),
hasAlpha: boolean("has_alpha").notNull().default(false),
/** `pending` while ingest is in flight, `ready` once every rendition is committed, `failed` when
* ingest gave up. Only `ready` is ever selectable by playback. */
status: text("status").notNull().default("pending"),
/** A stable sanitized code, never a caught exception, a path, or a provider message. */
failureCode: text("failure_code"),
/** Null means permanent. Local age never invents an expiration. */
expiresAt: instant("expires_at"),
createdAt: instant("created_at").notNull().defaultNow(),
updatedAt: instant("updated_at").notNull().defaultNow(),
},
(table) => [
uniqueIndex("signage_image_assets_content_sha256_key").on(table.contentSha256),
index("signage_image_assets_status_idx").on(table.status),
check(
"signage_image_assets_status_check",
sql`${table.status} in ('pending', 'ready', 'failed')`,
),
check(
"signage_image_assets_dimensions_check",
sql`${table.width} > 0 and ${table.height} > 0 and ${table.encodedWidth} > 0 and ${table.encodedHeight} > 0`,
),
],
);
/**
* A generated rendition of one asset. Immutable: different bytes are a different asset, so a
* rendition is never rewritten in place.
*/
export const imageRenditions = moduleTable(
"image_renditions",
{
id: uuid("id").primaryKey(),
assetId: uuid("asset_id")
.notNull()
.references(() => imageAssets.id, { onDelete: "cascade" }),
kind: text("kind").notNull(),
objectKey: text("object_key").notNull(),
contentType: text("content_type").notNull(),
contentSha256: text("content_sha256").notNull(),
byteSize: bigint("byte_size", { mode: "number" }).notNull(),
width: integer("width").notNull(),
height: integer("height").notNull(),
createdAt: instant("created_at").notNull().defaultNow(),
},
(table) => [
uniqueIndex("signage_image_renditions_asset_kind_key").on(table.assetId, table.kind),
check("signage_image_renditions_kind_check", sql`${table.kind} in ('display', 'thumbnail')`),
],
);
// -------------------------------------------------------------------------------------------------
// Timelines, sources, and slots.
// -------------------------------------------------------------------------------------------------
/** A saved, ordered, repeating pattern. It holds no cursor and no clock: running one is what a
* channel group does, and the same timeline may run on two groups without interfering (ADR 0003). */
export const timelines = moduleTable(
"timelines",
{
id: uuid("id").primaryKey(),
name: text("name").notNull(),
description: text("description"),
/** Timeline-level eligibility, half-open `[eligibleFrom, eligibleUntil)`. */
eligibleFrom: instant("eligible_from"),
eligibleUntil: instant("eligible_until"),
/** Increments only on a semantic change, so a client can compare and refetch. */
revision: integer("revision").notNull().default(1),
createdAt: instant("created_at").notNull().defaultNow(),
updatedAt: instant("updated_at").notNull().defaultNow(),
},
(table) => [
index("signage_timelines_name_idx").on(table.name),
check(
"signage_timelines_eligibility_check",
sql`${table.eligibleFrom} is null or ${table.eligibleUntil} is null or ${table.eligibleFrom} < ${table.eligibleUntil}`,
),
],
);
/**
* One **selection scope**: a provider's source as used by a timeline (ADR 0003).
*
* Every slot referencing this row passes the same `selection_scope` to `resolveNext`, so five
* Announcement slots walk one rotation rather than five. Two rows for the same provider source are
* two deliberately independent rotations.
*
* `provider_id` and `source_id` are opaque text. There is no foreign key here and there never can be:
* the provider is another module.
*/
export const providerSources = moduleTable(
"provider_sources",
{
id: uuid("id").primaryKey(),
timelineId: uuid("timeline_id")
.notNull()
.references(() => timelines.id, { onDelete: "cascade" }),
providerId: text("provider_id").notNull(),
sourceId: text("source_id").notNull(),
/** What an operator sees. Captured from provider discovery when the source was added, so the
* editor still reads sensibly when that provider is temporarily not running. */
label: text("label").notNull(),
/** Stable, opaque, and deliberately not derived from the timeline or slot id — either would
* silently reset a provider's rotation when an operator renamed or reordered something. */
selectionScope: text("selection_scope").notNull(),
createdAt: instant("created_at").notNull().defaultNow(),
updatedAt: instant("updated_at").notNull().defaultNow(),
},
(table) => [
uniqueIndex("signage_provider_sources_selection_scope_key").on(table.selectionScope),
index("signage_provider_sources_timeline_idx").on(table.timelineId),
index("signage_provider_sources_provider_idx").on(table.providerId),
],
);
/**
* One position in a timeline's pattern.
*
* `position` is unique per timeline and kept contiguous from zero inside the transaction that
* changes it — a gap would make "the next slot" ambiguous exactly when playback needs it least. The
* slot's `id` is stable across every reorder, which is what lets history and a display's cursor
* survive an operator dragging a row.
*/
export const timelineSlots = moduleTable(
"timeline_slots",
{
id: uuid("id").primaryKey(),
timelineId: uuid("timeline_id")
.notNull()
.references(() => timelines.id, { onDelete: "cascade" }),
position: integer("position").notNull(),
/** `provider-source` asks for the next item; `pinned-item` shows exactly one. A native image is
* a pinned item of Signage's own built-in image provider (ADR 0001). */
kind: text("kind").notNull(),
sourceId: uuid("source_id").references(() => providerSources.id, { onDelete: "cascade" }),
/** Set for a pinned item only. Opaque: another module's identity for its own content. */
providerId: text("provider_id"),
itemId: text("item_id"),
enabled: boolean("enabled").notNull().default(true),
durationOverrideMs: integer("duration_override_ms"),
/** Per-slot eligibility, half-open `[eligibleFrom, eligibleUntil)`. */
eligibleFrom: instant("eligible_from"),
eligibleUntil: instant("eligible_until"),
/** Native-image presentation overrides. Null means "take the installation default". These apply
* to native images only; an external provider owns its rendering completely. */
backgroundMode: text("background_mode"),
backgroundColor: text("background_color"),
backgroundDarkened: boolean("background_darkened"),
blurPixels: integer("blur_pixels"),
foregroundShadow: boolean("foreground_shadow"),
createdAt: instant("created_at").notNull().defaultNow(),
updatedAt: instant("updated_at").notNull().defaultNow(),
},
(table) => [
uniqueIndex("signage_timeline_slots_position_key").on(table.timelineId, table.position),
index("signage_timeline_slots_source_idx").on(table.sourceId),
check("signage_timeline_slots_position_check", sql`${table.position} >= 0`),
check(
"signage_timeline_slots_kind_check",
sql`${table.kind} in ('provider-source', 'pinned-item')`,
),
// Exactly one of the two shapes is populated, so a slot can never be both or neither.
check(
"signage_timeline_slots_reference_check",
sql`(${table.kind} = 'provider-source' and ${table.sourceId} is not null and ${table.providerId} is null and ${table.itemId} is null)
or (${table.kind} = 'pinned-item' and ${table.sourceId} is null and ${table.providerId} is not null and ${table.itemId} is not null)`,
),
check(
"signage_timeline_slots_duration_check",
sql`${table.durationOverrideMs} is null or ${table.durationOverrideMs} > 0`,
),
check(
"signage_timeline_slots_eligibility_check",
sql`${table.eligibleFrom} is null or ${table.eligibleUntil} is null or ${table.eligibleFrom} < ${table.eligibleUntil}`,
),
check(
"signage_timeline_slots_background_check",
sql`${table.backgroundMode} is null or ${table.backgroundMode} in ('blurred', 'solid', 'black')`,
),
],
);
// -------------------------------------------------------------------------------------------------
// Channel groups, channels, and authoritative playback.
// -------------------------------------------------------------------------------------------------
/** One synchronized group: one authoritative timeline clock, one active timeline, one cursor
* (ADR 0004). A genuinely independent clock is another group. */
export const channelGroups = moduleTable(
"channel_groups",
{
id: uuid("id").primaryKey(),
name: text("name").notNull(),
/** Increments when the group's channel membership changes, so a client knows to refetch. */
revision: integer("revision").notNull().default(1),
createdAt: instant("created_at").notNull().defaultNow(),
updatedAt: instant("updated_at").notNull().defaultNow(),
},
(table) => [index("signage_channel_groups_name_idx").on(table.name)],
);
/** An audience within a group. Channels differ in exactly one way: which variant of a resolved
* selection they render. Exactly one channel per group is the default. */
export const channels = moduleTable(
"channels",
{
id: uuid("id").primaryKey(),
groupId: uuid("group_id")
.notNull()
.references(() => channelGroups.id, { onDelete: "cascade" }),
/** The audience key handed to a provider. `default` is the base channel's. */
key: text("key").notNull(),
name: text("name").notNull(),
isDefault: boolean("is_default").notNull().default(false),
createdAt: instant("created_at").notNull().defaultNow(),
updatedAt: instant("updated_at").notNull().defaultNow(),
},
(table) => [
uniqueIndex("signage_channels_group_key_key").on(table.groupId, table.key),
// Exactly one default per group, enforced by the database rather than by convention.
uniqueIndex("signage_channels_group_default_key")
.on(table.groupId)
.where(sql`${table.isDefault}`),
check("signage_channels_key_check", sql`${table.key} ~ '^[a-z][a-z0-9]*(-[a-z0-9]+)*$'`),
],
);
/**
* One row per channel group: the authoritative clock (ADR 0005).
*
* `slot_started_at` and `slot_deadline_at` are **absolute instants**, never a remaining count. That
* is what lets two viewers who connected minutes apart agree, and what lets a restart resume the
* item it was already showing instead of choosing a new one.
*/
export const playbackStates = moduleTable(
"playback_states",
{
groupId: uuid("group_id")
.primaryKey()
.references(() => channelGroups.id, { onDelete: "cascade" }),
activeTimelineId: uuid("active_timeline_id").references(() => timelines.id, {
onDelete: "set null",
}),
/** Queued activation, applied at the next safe slot boundary. */
pendingTimelineId: uuid("pending_timeline_id").references(() => timelines.id, {
onDelete: "set null",
}),
pendingRequestedAt: instant("pending_requested_at"),
/** Which resolved selection is on screen. Nulled rather than cascaded when history is pruned. */
currentSelectionId: uuid("current_selection_id"),
/** Where in the pattern the cursor is. Kept beside the selection so an empty or skipped slot
* still advances deterministically. */
currentSlotId: uuid("current_slot_id"),
slotStartedAt: instant("slot_started_at"),
slotDeadlineAt: instant("slot_deadline_at"),
/** Preserved across pause and static hold, so releasing either continues the same slide. */
pausedRemainingMs: integer("paused_remaining_ms"),
paused: boolean("paused").notNull().default(false),
/** Covers the output with black while the hidden timeline keeps running. */
blackout: boolean("blackout").notNull().default(false),
/** A native image held on screen while the normal cursor and remainder stay preserved. */
staticAssetId: uuid("static_asset_id").references(() => imageAssets.id, {
onDelete: "set null",
}),
/** An activation an operator asked for in advance. One per group: "activate this timeline at
* this instant" is a single intention, and a queue of them would be a scheduling product
* Signage does not have. */
scheduledTimelineId: uuid("scheduled_timeline_id").references(() => timelines.id, {
onDelete: "set null",
}),
scheduledActivationAt: instant("scheduled_activation_at"),
/** Monotonic. Increments only on a semantic change; an exact no-op commits nothing. */
revision: integer("revision").notNull().default(1),
/** The next sequence number a resolved selection for this group will take. */
nextSequence: bigint("next_sequence", { mode: "number" }).notNull().default(1),
updatedAt: instant("updated_at").notNull().defaultNow(),
},
(table) => [
check(
"signage_playback_states_remaining_check",
sql`${table.pausedRemainingMs} is null or ${table.pausedRemainingMs} >= 0`,
),
check(
"signage_playback_states_window_check",
sql`${table.slotStartedAt} is null or ${table.slotDeadlineAt} is null or ${table.slotStartedAt} <= ${table.slotDeadlineAt}`,
),
],
);
// -------------------------------------------------------------------------------------------------
// Resolved selections and bounded history.
// -------------------------------------------------------------------------------------------------
/**
* One logical selection, resolved once for the whole channel group (ADR 0004).
*
* Persisting this is what makes two viewers that joined at different times render the same slot,
* what lets `previous` walk actual playback rather than the slot order, what makes restart recovery
* show the same item, and what gives offline continuation something real to continue from.
*
* `slot_id` is nulled rather than cascaded when a slot is deleted: history is a record of what
* happened, and an operator editing a timeline must not erase it.
*/
export const selections = moduleTable(
"selections",
{
id: uuid("id").primaryKey(),
groupId: uuid("group_id")
.notNull()
.references(() => channelGroups.id, { onDelete: "cascade" }),
/** Monotonic per group. The ordering `previous` walks. */
sequence: bigint("sequence", { mode: "number" }).notNull(),
timelineId: uuid("timeline_id"),
slotId: uuid("slot_id").references(() => timelineSlots.id, { onDelete: "set null" }),
/** The selection scope this item came from, copied at resolution time. Kept even after the
* source row is gone, so history stays readable and a provider's rotation can be re-seeded from
* what actually played rather than from the beginning. Null for a pinned item. */
selectionScope: text("selection_scope"),
/** Opaque provider identity, and the provider's own identity for its content. */
providerId: text("provider_id").notNull(),
itemId: text("item_id").notNull(),
/** Changes whenever the payload changes and the item stays the same thing. */
itemRevision: text("item_revision").notNull(),
/** Which renderer draws this. Signage never imports it; the display asks the host to mount it. */
rendererContributionId: text("renderer_contribution_id"),
rendererContributionVersion: text("renderer_contribution_version"),
/** Opaque presentation values the provider itself supplied to its own renderer. */
presentation: jsonb("presentation"),
durationMs: integer("duration_ms").notNull(),
resolvedAt: instant("resolved_at").notNull().defaultNow(),
startedAt: instant("started_at"),
deadlineAt: instant("deadline_at"),
/** When this selection stops being valid, as supplied by the provider. */
availableUntil: instant("available_until"),
/** The provider said this item is explicitly gone. Removed at the next safe boundary. */
withdrawn: boolean("withdrawn").notNull().default(false),
/** A provider call failed temporarily and this is the last known good answer. */
stale: boolean("stale").notNull().default(false),
/** Increments when a refresh changes payloads without changing which item this is — which is
* what updates a mounted renderer in place without restarting its slot. */
revision: integer("revision").notNull().default(1),
},
(table) => [
uniqueIndex("signage_selections_group_sequence_key").on(table.groupId, table.sequence),
index("signage_selections_group_resolved_idx").on(table.groupId, table.resolvedAt),
index("signage_selections_provider_item_idx").on(table.providerId, table.itemId),
check("signage_selections_duration_check", sql`${table.durationMs} > 0`),
],
);
/** One audience's payload for one selection. The default variant is always present; an
* audience-specific one exists only when the provider offered it. */
export const selectionVariants = moduleTable(
"selection_variants",
{
id: uuid("id").primaryKey(),
selectionId: uuid("selection_id")
.notNull()
.references(() => selections.id, { onDelete: "cascade" }),
/** The audience key this payload is for. The default variant uses the literal `default` key. */
audienceKey: text("audience_key").notNull(),
isDefault: boolean("is_default").notNull().default(false),
/** Opaque JSON. Signage validates that it *is* finite JSON and never what it means. */
payload: jsonb("payload").notNull(),
},
(table) => [
uniqueIndex("signage_selection_variants_selection_audience_key").on(
table.selectionId,
table.audienceKey,
),
],
);
// -------------------------------------------------------------------------------------------------
// Viewers.
// -------------------------------------------------------------------------------------------------
/**
* One display Signage has seen.
*
* `viewer_key` is the persistent identity a display keeps in its own IndexedDB, so a reconnect is
* recognized as the same screen rather than a new one. `device_id` is the **safe** Core device
* identifier a paired display's principal carries — an opaque correlation value. Signage never sees,
* stores, or handles a device credential, and pairing remains entirely Core's.
*/
export const viewers = moduleTable(
"viewers",
{
id: uuid("id").primaryKey(),
viewerKey: text("viewer_key").notNull(),
name: text("name").notNull(),
deviceId: text("device_id"),
channelId: uuid("channel_id").references(() => channels.id, { onDelete: "set null" }),
lastSeenAt: instant("last_seen_at"),
/** What the viewer last reported it was actually showing. Telemetry: a display never mutates
* authoritative playback, and a local cursor is never accepted as authority. */
reportedTimelineId: uuid("reported_timeline_id"),
reportedSelectionId: uuid("reported_selection_id"),
reportedProviderId: text("reported_provider_id"),
reportedContributionId: text("reported_contribution_id"),
reportedPlaybackRevision: integer("reported_playback_revision"),
/** Whether the viewer says it has enough cached to keep rendering through an outage. */
offlineReady: boolean("offline_ready").notNull().default(false),
cachedAssetCount: integer("cached_asset_count").notNull().default(0),
/** A stable sanitized code and a bounded operator-facing detail. Never an exception, a URL, a
* payload, or a stack. */
lastErrorCode: text("last_error_code"),
lastErrorDetail: text("last_error_detail"),
lastErrorAt: instant("last_error_at"),
createdAt: instant("created_at").notNull().defaultNow(),
updatedAt: instant("updated_at").notNull().defaultNow(),
},
(table) => [
uniqueIndex("signage_viewers_viewer_key_key").on(table.viewerKey),
index("signage_viewers_channel_idx").on(table.channelId),
index("signage_viewers_last_seen_idx").on(table.lastSeenAt),
],
);
+370
View File
@@ -0,0 +1,370 @@
import type { ModuleStorage } from "@ekklesia/module-sdk";
import {
resolveNativeScene,
SIGNAGE_NATIVE_PROVIDER_ID,
SIGNAGE_NATIVE_RENDERER,
type SignageNativePresentation,
} from "../../shared/native-images.js";
import type { SignageSettings } from "../../shared/settings.js";
import { createImageDeliveryUrls } from "../images/service.js";
import type { PlaybackSnapshot } from "../playback/service.js";
import type { SignageProviderRegistry } from "../providers/registry.js";
import type {
SignageStore,
StoredChannel,
StoredSelection,
StoredSlot,
StoredTimelineDetail,
} from "../store/types.js";
/**
* Everything one channel's display needs to render, and to keep rendering when its network drops.
*
* Three groups of things travel together here, and each earns its place:
*
* - **What is on screen now**, resolved for this channel's audience, with fresh delivery URLs for a
* native image. This is the frame.
* - **The timeline's shape** — slot identities, eligibility windows, and estimated durations. This
* is what lets a disconnected display advance its own cursor with the same arithmetic the server
* reconciles with, rather than guessing (ADR 0006).
* - **Recent resolved selections**, again for this audience. This is what a disconnected display
* *renders* when its cursor lands on a slot it has already seen. It never resolves new provider
* content offline, because inventing a provider's answer is worse than keeping the last frame.
*
* A delivery URL is minted here and never stored: it is a bearer capability with an expiry in it,
* and a display refreshes state long before one lapses.
*/
export interface DisplayMedia {
readonly url: string;
readonly expiresAtMs: number;
readonly width: number;
readonly height: number;
}
export interface DisplaySelection {
readonly id: string;
readonly revision: number;
readonly slotId: string | null;
readonly providerId: string;
readonly itemId: string;
readonly itemRevision: string;
readonly startedAtMs: number | null;
readonly deadlineAtMs: number | null;
readonly durationMs: number;
readonly availableUntilMs: number | null;
readonly withdrawn: boolean;
readonly stale: boolean;
/** `native-image` is drawn by Signage's own display code; `contribution` is mounted through the
* platform's contribution ABI by id. A display never receives a URL for either. */
readonly renderer:
| { readonly kind: "native-image" }
| {
readonly kind: "contribution";
readonly contributionId: string;
readonly contributionVersion: string;
}
| { readonly kind: "unavailable" };
/** The provider's own payload for this channel's audience, or its default. */
readonly payload: unknown;
readonly presentation: unknown;
/** Native images only: fresh delivery URLs, and the already-resolved scene. */
readonly media?: { readonly display: DisplayMedia; readonly thumbnail: DisplayMedia };
readonly scene?: ReturnType<typeof resolveNativeScene>;
}
export interface DisplayTimelineSlot {
readonly id: string;
readonly position: number;
readonly enabled: boolean;
readonly eligibleFromMs: number | null;
readonly eligibleUntilMs: number | null;
readonly estimatedDurationMs: number;
}
export interface DisplayTimeline {
readonly id: string;
readonly revision: number;
readonly eligibleFromMs: number | null;
readonly eligibleUntilMs: number | null;
readonly slots: readonly DisplayTimelineSlot[];
}
export interface DisplayChannelState {
readonly serverNowMs: number;
readonly channel: {
readonly id: string;
readonly key: string;
readonly name: string;
readonly groupId: string;
};
readonly crossfadeMs: number;
readonly playback: PlaybackSnapshot;
readonly timeline: DisplayTimeline | null;
readonly current: DisplaySelection | null;
/** The held image, already resolved as a scene. Separate from `current` because a static hold sits
* *over* a preserved cursor rather than replacing it. */
readonly staticHold: DisplaySelection | null;
/** Bounded, newest first. Material for offline continuation, never a substitute for authority. */
readonly selections: readonly DisplaySelection[];
}
/** How many resolved selections travel to a display. Enough to cover a pattern and then some, small
* enough that the response stays a response. */
export const SIGNAGE_DISPLAY_SELECTION_LIMIT = 24;
function slotPresentationFor(
slot: StoredSlot | undefined,
settings: SignageSettings,
): SignageNativePresentation {
const defaults = settings.nativePresentation;
return {
backgroundColor: slot?.presentation.backgroundColor ?? defaults.backgroundColor,
backgroundDarkened: slot?.presentation.backgroundDarkened ?? defaults.backgroundDarkened,
backgroundMode: slot?.presentation.backgroundMode ?? defaults.backgroundMode,
blurPixels: slot?.presentation.blurPixels ?? defaults.blurPixels,
foregroundShadow: slot?.presentation.foregroundShadow ?? true,
};
}
function payloadFor(selection: StoredSelection, audienceKey: string): unknown {
const exact = selection.variants.find((variant) => variant.audienceKey === audienceKey);
if (exact) {
return exact.payload;
}
// The fallback *is* the contract: a provider with nothing audience-specific returns one variant,
// and every channel renders it (ADR 0002).
return selection.variants.find((variant) => variant.isDefault)?.payload ?? null;
}
interface NativePayloadShape {
readonly imageId?: unknown;
readonly width?: unknown;
readonly height?: unknown;
readonly renditionKeys?: { readonly display?: unknown; readonly thumbnail?: unknown };
}
export interface BuildDisplayStateInput {
readonly store: SignageStore;
readonly storage: ModuleStorage;
readonly registry: SignageProviderRegistry;
readonly settings: SignageSettings;
readonly channel: StoredChannel;
readonly playback: PlaybackSnapshot;
readonly atMs: number;
}
export async function buildDisplayChannelState(
input: BuildDisplayStateInput,
): Promise<DisplayChannelState> {
const group = await input.store.findChannelGroup(input.channel.groupId);
const channelName = group?.channels.find((c) => c.id === input.channel.id)?.name ?? "Display";
const timelineDetail =
input.playback.activeTimelineId === null
? undefined
: await input.store.findTimeline(input.playback.activeTimelineId);
const history = await input.store.listSelections(
input.channel.groupId,
SIGNAGE_DISPLAY_SELECTION_LIMIT,
);
const rendered = await Promise.all(
history.map((selection) =>
renderSelection(input, selection, timelineDetail, input.channel.key),
),
);
const current =
rendered.find((selection) => selection.id === input.playback.currentSelectionId) ?? null;
return {
channel: {
groupId: input.channel.groupId,
id: input.channel.id,
key: input.channel.key,
name: channelName,
},
crossfadeMs: input.settings.crossfadeMs,
current,
playback: input.playback,
selections: rendered,
serverNowMs: input.atMs,
staticHold:
input.playback.staticAssetId === null
? null
: await renderStaticHold(input, input.playback.staticAssetId),
timeline: timelineDetail ? describeTimeline(input, timelineDetail) : null,
};
}
function describeTimeline(
input: BuildDisplayStateInput,
detail: StoredTimelineDetail,
): DisplayTimeline {
return {
eligibleFromMs: detail.timeline.eligibleFrom?.getTime() ?? null,
eligibleUntilMs: detail.timeline.eligibleUntil?.getTime() ?? null,
id: detail.timeline.id,
revision: detail.timeline.revision,
slots: detail.slots.map((slot) => {
const providerId =
slot.kind === "pinned-item"
? slot.providerId
: (detail.sources.find((source) => source.id === slot.sourceId)?.providerId ?? null);
return {
eligibleFromMs: slot.eligibleFrom?.getTime() ?? null,
eligibleUntilMs: slot.eligibleUntil?.getTime() ?? null,
enabled: slot.enabled,
// The same estimate the server uses to skip whole passes, so a disconnected display's
// arithmetic and the server's agree.
estimatedDurationMs:
slot.durationOverrideMs ??
(providerId === null ? undefined : input.registry.defaultDurationMs(providerId)) ??
input.settings.nativeImageDurationMs,
id: slot.id,
position: slot.position,
};
}),
};
}
async function renderSelection(
input: BuildDisplayStateInput,
selection: StoredSelection,
timeline: StoredTimelineDetail | undefined,
audienceKey: string,
): Promise<DisplaySelection> {
const slot =
selection.slotId === null
? undefined
: timeline?.slots.find((candidate) => candidate.id === selection.slotId);
const payload = payloadFor(selection, audienceKey);
const base: DisplaySelection = {
availableUntilMs: selection.availableUntil?.getTime() ?? null,
deadlineAtMs: selection.deadlineAt?.getTime() ?? null,
durationMs: selection.durationMs,
id: selection.id,
itemId: selection.itemId,
itemRevision: selection.itemRevision,
payload,
presentation: selection.presentation,
providerId: selection.providerId,
renderer:
selection.providerId === SIGNAGE_NATIVE_PROVIDER_ID ||
selection.rendererContributionId === SIGNAGE_NATIVE_RENDERER
? { kind: "native-image" }
: selection.rendererContributionId === null ||
selection.rendererContributionVersion === null
? { kind: "unavailable" }
: {
contributionId: selection.rendererContributionId,
contributionVersion: selection.rendererContributionVersion,
kind: "contribution",
},
revision: selection.revision,
slotId: selection.slotId,
stale: selection.stale,
startedAtMs: selection.startedAt?.getTime() ?? null,
withdrawn: selection.withdrawn,
};
if (base.renderer.kind !== "native-image") {
return base;
}
const media = await nativeMediaFor(input, payload);
const shape = payload as NativePayloadShape | null;
const width = typeof shape?.width === "number" ? shape.width : 16;
const height = typeof shape?.height === "number" ? shape.height : 9;
return {
...base,
...(media ? { media } : {}),
scene: resolveNativeScene({ height, width }, slotPresentationFor(slot, input.settings)),
};
}
async function nativeMediaFor(
input: BuildDisplayStateInput,
payload: unknown,
): Promise<DisplaySelection["media"]> {
const shape = payload as NativePayloadShape | null;
const displayKey = shape?.renditionKeys?.display;
const thumbnailKey = shape?.renditionKeys?.thumbnail;
if (typeof displayKey !== "string" || typeof thumbnailKey !== "string") {
return undefined;
}
const width = typeof shape?.width === "number" ? shape.width : 0;
const height = typeof shape?.height === "number" ? shape.height : 0;
try {
const urls = await createImageDeliveryUrls(input.storage, [
{ height, kind: "display", objectKey: displayKey, width },
{ height, kind: "thumbnail", objectKey: thumbnailKey, width },
]);
const display = urls.find((url) => url.kind === "display");
const thumbnail = urls.find((url) => url.kind === "thumbnail");
if (!display || !thumbnail) {
return undefined;
}
return {
display: {
expiresAtMs: Date.parse(display.expiresAt),
height,
url: display.url,
width,
},
thumbnail: {
expiresAtMs: Date.parse(thumbnail.expiresAt),
height,
url: thumbnail.url,
width,
},
};
} catch {
// Storage being briefly unreachable costs this frame's URLs, not the whole state: a display
// that already has the bytes cached keeps rendering, and the next refresh carries fresh ones.
return undefined;
}
}
/** The held image, shaped exactly like a selection so a display's renderer path is the same one. */
async function renderStaticHold(
input: BuildDisplayStateInput,
imageId: string,
): Promise<DisplaySelection | null> {
const image = await input.store.findImage(imageId);
if (image?.status !== "ready") {
return null;
}
const display = image.renditions.find((rendition) => rendition.kind === "display");
const thumbnail = image.renditions.find((rendition) => rendition.kind === "thumbnail");
const payload = {
displayName: image.displayName,
height: image.height,
imageId: image.id,
kind: "native-image",
renditionKeys: {
display: display?.objectKey ?? image.originalObjectKey,
thumbnail: thumbnail?.objectKey ?? image.originalObjectKey,
},
width: image.width,
};
const media = await nativeMediaFor(input, payload);
return {
availableUntilMs: image.expiresAt?.getTime() ?? null,
deadlineAtMs: null,
durationMs: 0,
id: `static:${image.id}`,
itemId: image.id,
itemRevision: image.contentSha256.slice(0, 32),
...(media ? { media } : {}),
payload,
presentation: null,
providerId: SIGNAGE_NATIVE_PROVIDER_ID,
renderer: { kind: "native-image" },
revision: 1,
scene: resolveNativeScene(image, slotPresentationFor(undefined, input.settings)),
slotId: null,
stale: false,
startedAtMs: null,
withdrawn: false,
};
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Signage's one sanitized failure vocabulary.
*
* Every error that can reach a browser, a log, or an operator carries a **stable code and a fixed
* message**, and deliberately nothing else: no caught exception, no stack, no cause, no SQL, no
* object key, no storage endpoint, no provider text, no machine path, and no payload. This follows
* the same discipline as the platform's own `ModuleStorageError` and migration-runner errors.
*
* A `detail` is allowed only when it is a value **Signage itself supplied or the caller itself
* sent** — an asset id the caller named, a limit Signage declared. It is never something the host,
* the database, or another module handed back.
*/
export const SIGNAGE_ERROR_CODES = [
/** The request body, query, or path parameter is not something this route accepts. */
"SIGNAGE_INVALID_REQUEST",
/** The named resource does not exist, or is not one this caller may address. */
"SIGNAGE_NOT_FOUND",
/** The request is well formed but conflicts with current state. */
"SIGNAGE_CONFLICT",
/** The asset is referenced by a timeline slot, a pinned item, or a live static hold. */
"SIGNAGE_ASSET_IN_USE",
/** The uploaded bytes are not a still PNG, JPEG, or WebP. */
"SIGNAGE_IMAGE_UNSUPPORTED_FORMAT",
/** The uploaded bytes could not be decoded as an image at all. */
"SIGNAGE_IMAGE_MALFORMED",
/** Animation is not supported; a signage slide is a still frame. */
"SIGNAGE_IMAGE_ANIMATED",
/** Multipage input is not supported. */
"SIGNAGE_IMAGE_MULTIPAGE",
/** The upload exceeded Signage's per-file byte limit. */
"SIGNAGE_IMAGE_TOO_LARGE",
/** The image decodes to more pixels than Signage will process. */
"SIGNAGE_IMAGE_PIXEL_LIMIT",
/** Object storage could not be reached, or refused the operation. */
"SIGNAGE_STORAGE_UNAVAILABLE",
/** Signage could not complete the operation, and has said everything it safely can. */
"SIGNAGE_OPERATION_FAILED",
] as const;
export type SignageErrorCode = (typeof SIGNAGE_ERROR_CODES)[number];
const messages: Readonly<Record<SignageErrorCode, string>> = Object.freeze({
SIGNAGE_ASSET_IN_USE: "This image is still referenced and cannot be deleted.",
SIGNAGE_CONFLICT: "The request conflicts with the current state.",
SIGNAGE_IMAGE_ANIMATED: "Animated images are not supported.",
SIGNAGE_IMAGE_MALFORMED: "The uploaded file is not a decodable image.",
SIGNAGE_IMAGE_MULTIPAGE: "Multipage images are not supported.",
SIGNAGE_IMAGE_PIXEL_LIMIT: "The image exceeds the decoded-pixel limit.",
SIGNAGE_IMAGE_TOO_LARGE: "The image exceeds the maximum upload size.",
SIGNAGE_IMAGE_UNSUPPORTED_FORMAT: "Only still PNG, JPEG, and WebP images are supported.",
SIGNAGE_INVALID_REQUEST: "The request is not valid.",
SIGNAGE_NOT_FOUND: "The requested resource was not found.",
SIGNAGE_OPERATION_FAILED: "The operation could not be completed.",
SIGNAGE_STORAGE_UNAVAILABLE: "Object storage is not available.",
});
const statuses: Readonly<Record<SignageErrorCode, number>> = Object.freeze({
SIGNAGE_ASSET_IN_USE: 409,
SIGNAGE_CONFLICT: 409,
SIGNAGE_IMAGE_ANIMATED: 415,
SIGNAGE_IMAGE_MALFORMED: 422,
SIGNAGE_IMAGE_MULTIPAGE: 415,
SIGNAGE_IMAGE_PIXEL_LIMIT: 413,
SIGNAGE_IMAGE_TOO_LARGE: 413,
SIGNAGE_IMAGE_UNSUPPORTED_FORMAT: 415,
SIGNAGE_INVALID_REQUEST: 400,
SIGNAGE_NOT_FOUND: 404,
SIGNAGE_OPERATION_FAILED: 500,
SIGNAGE_STORAGE_UNAVAILABLE: 503,
});
/** What a caller may safely be told alongside a code: values the caller sent, or limits Signage
* declared. Never a host value, a driver message, or another module's text. */
export type SignageErrorDetail = Readonly<Record<string, number | string | null>>;
export class SignageError extends Error {
readonly code: SignageErrorCode;
readonly status: number;
readonly detail?: SignageErrorDetail;
constructor(code: SignageErrorCode, detail?: SignageErrorDetail) {
super(messages[code]);
this.name = "SignageError";
this.code = code;
this.status = statuses[code];
if (detail !== undefined) {
this.detail = Object.freeze({ ...detail });
}
this.stack = `${this.name}: ${this.message}`;
}
}
export function signageErrorResponse(error: SignageError): Response {
return Response.json(
{ code: error.code, message: error.message, ...(error.detail ? { detail: error.detail } : {}) },
{ status: error.status },
);
}
/**
* The one boundary every Signage route handler runs inside.
*
* A `SignageError` becomes its own sanitized response. **Anything else becomes
* `SIGNAGE_OPERATION_FAILED` with nothing of the caught value in it** — a database error, a storage
* failure, a bug, and a provider's own exception all look identical from outside, which is the
* point. The platform would sanitize an uncaught throw too; catching it here is how Signage keeps a
* useful code instead of a generic one.
*/
export async function signageBoundary(run: () => Promise<Response>): Promise<Response> {
try {
return await run();
} catch (error) {
if (error instanceof SignageError) {
return signageErrorResponse(error);
}
return signageErrorResponse(new SignageError("SIGNAGE_OPERATION_FAILED"));
}
}
+126
View File
@@ -0,0 +1,126 @@
import type { ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
import { SignageError } from "./errors.js";
/**
* Request-shaping helpers, and — more importantly — parameter validation.
*
* `context.params` holds the router's **decoded** path values. Decoding is not validation, and the
* platform deliberately supplies no generic parameter grammar: these are Signage's domain types, so
* validating them is Signage's job, before any database, storage, or identifier use.
*/
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
export function requireUuidParam(context: ModuleRouteHandlerContext, name: string): string {
const value = context.params[name];
if (value === undefined || !UUID.test(value)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { parameter: name });
}
return value;
}
export function isUuid(value: unknown): value is string {
return typeof value === "string" && UUID.test(value);
}
/** Reads a JSON request body a route declared a buffered allowance for. A body that is absent,
* oversized for its own declared shape, or not a JSON object is refused with Signage's own code
* rather than a parser's message. */
export async function readJsonObject(request: Request): Promise<Record<string, unknown>> {
let parsed: unknown;
try {
parsed = await request.json();
} catch {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" });
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" });
}
return parsed as Record<string, unknown>;
}
export function optionalString(
body: Record<string, unknown>,
field: string,
options: { readonly maxLength: number },
): string | undefined {
const value = body[field];
if (value === undefined) {
return undefined;
}
if (typeof value !== "string" || value.trim().length === 0 || value.length > options.maxLength) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
}
return value.trim();
}
export function requiredString(
body: Record<string, unknown>,
field: string,
options: { readonly maxLength: number },
): string {
const value = optionalString(body, field, options);
if (value === undefined) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
}
return value;
}
/** An absolute instant, or `null` to clear one. Signage never accepts an ambiguous local timestamp:
* a value must parse as an absolute instant or be refused. */
export function optionalInstant(
body: Record<string, unknown>,
field: string,
): Date | null | undefined {
const value = body[field];
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
if (typeof value !== "string") {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime()) || !/[Zz]|[+-]\d{2}:?\d{2}$/u.test(value)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
}
return parsed;
}
export function optionalInteger(
body: Record<string, unknown>,
field: string,
options: { readonly minimum: number; readonly maximum: number },
): number | null | undefined {
const value = body[field];
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
if (
typeof value !== "number" ||
!Number.isSafeInteger(value) ||
value < options.minimum ||
value > options.maximum
) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
}
return value;
}
export function optionalBoolean(
body: Record<string, unknown>,
field: string,
): boolean | null | undefined {
const value = body[field];
if (value === undefined || value === null) {
return value as null | undefined;
}
if (typeof value !== "boolean") {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
}
return value;
}
+46
View File
@@ -0,0 +1,46 @@
/**
* The bounds Signage's native image ingest enforces, in one place.
*
* These are Signage's own product limits, deliberately inside the platform's. The platform's
* absolute request-body ceiling is 32 MiB; Signage declares 25 MiB and refuses anything larger
* itself rather than relying on the platform's refusal. Two limits that agree are two chances to
* catch a mistake, and a module that only had the platform's would silently change its own product
* behaviour if the platform's ever moved.
*
* The upload route carries **raw image bytes**, not a multipart envelope, so the declared allowance
* is exactly the image limit. One request is one file; nothing in Signage can express an aggregate
* upload, and the filename and display name travel in the query string.
*/
/** The largest image file an operator may upload. */
export const SIGNAGE_IMAGE_MAX_BYTES = 25 * 1024 * 1024;
/**
* The decoded-pixel budget.
*
* File size does not bound decoding: a few hundred kilobytes of PNG can decode to gigabytes of
* pixels, which is the actual shape of a decompression bomb. 40 million pixels is roughly a 7700 ×
* 5200 photograph — far beyond any signage slide, and far below anything that threatens the process.
*/
export const SIGNAGE_IMAGE_MAX_PIXELS = 40_000_000;
/** How many files the dashboard offers to queue at once. One request each, always. */
export const SIGNAGE_UPLOAD_BATCH_MAX_FILES = 20;
/** How many of those requests the dashboard runs concurrently. Bounded so a batch cannot pin the
* API's memory with several simultaneous full-size images, and so the dashboard stays responsive. */
export const SIGNAGE_UPLOAD_CONCURRENCY = 3;
export const SIGNAGE_IMAGE_DISPLAY_RENDITION = Object.freeze({
kind: "display" as const,
maxWidth: 1920,
maxHeight: 1080,
quality: 82,
});
export const SIGNAGE_IMAGE_THUMBNAIL_RENDITION = Object.freeze({
kind: "thumbnail" as const,
maxWidth: 480,
maxHeight: 270,
quality: 72,
});
+199
View File
@@ -0,0 +1,199 @@
import { createHash } from "node:crypto";
import sharp, { type Metadata, type OutputInfo } from "sharp";
import { SignageError } from "../errors.js";
import {
SIGNAGE_IMAGE_DISPLAY_RENDITION,
SIGNAGE_IMAGE_MAX_PIXELS,
SIGNAGE_IMAGE_THUMBNAIL_RENDITION,
} from "./limits.js";
/**
* Signage's native image pipeline.
*
* Three properties matter more than the code:
*
* 1. **Format comes from the bytes.** A filename and a declared content type are attacker-controlled
* and routinely wrong even when they are not; the decoder decides what this is.
* 2. **The original is never rewritten.** It is a church's file, archived exactly as uploaded. Only
* the two renditions Signage generates are transformed.
* 3. **Nothing about presentation is baked in.** Background, blur, darkening, and shadow are
* resolved at render time from a slot's own settings, so changing them is a database update
* rather than a re-encode (ADR 0007).
*/
const SUPPORTED_FORMATS: ReadonlySet<string> = new Set(["jpeg", "png", "webp"]);
const CONTENT_TYPES: Readonly<Record<string, string>> = Object.freeze({
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
});
const FILE_EXTENSIONS: Readonly<Record<string, string>> = Object.freeze({
jpeg: "jpg",
png: "png",
webp: "webp",
});
export interface SignageImageRendition {
readonly kind: "display" | "thumbnail";
readonly bytes: Uint8Array;
readonly contentType: string;
readonly contentSha256: string;
readonly byteSize: number;
readonly width: number;
readonly height: number;
}
export interface SignageProcessedImage {
readonly contentSha256: string;
readonly byteSize: number;
readonly format: string;
readonly contentType: string;
readonly fileExtension: string;
/** How the original should appear once its own orientation is applied. */
readonly width: number;
readonly height: number;
/** What is actually encoded in the archived bytes. */
readonly encodedWidth: number;
readonly encodedHeight: number;
readonly orientation: number | null;
readonly hasAlpha: boolean;
readonly renditions: readonly SignageImageRendition[];
}
export function sha256Hex(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
/** Sharp reports a pixel-limit breach and a decode failure through the same exception type. The
* message is inspected here and then **discarded** — nothing from the decoder reaches a caller. */
function decodeFailure(error: unknown): SignageError {
const message = error instanceof Error ? error.message : "";
if (/pixel limit/iu.test(message)) {
return new SignageError("SIGNAGE_IMAGE_PIXEL_LIMIT", { maxPixels: SIGNAGE_IMAGE_MAX_PIXELS });
}
return new SignageError("SIGNAGE_IMAGE_MALFORMED");
}
async function readMetadata(bytes: Uint8Array): Promise<Metadata> {
try {
return await sharp(bytes, {
animated: true,
failOn: "error",
limitInputPixels: SIGNAGE_IMAGE_MAX_PIXELS,
}).metadata();
} catch (error) {
throw decodeFailure(error);
}
}
function assertSupported(metadata: Metadata): void {
const pages = metadata.pages ?? 1;
if (pages > 1) {
const animated =
metadata.loop !== undefined || (Array.isArray(metadata.delay) && metadata.delay.length > 0);
throw new SignageError(animated ? "SIGNAGE_IMAGE_ANIMATED" : "SIGNAGE_IMAGE_MULTIPAGE", {
pages,
});
}
if (metadata.format === undefined || !SUPPORTED_FORMATS.has(metadata.format)) {
// The detected format is echoed because the caller supplied the bytes it came from. Nothing
// about the host, the decoder, or the process is in this response.
throw new SignageError("SIGNAGE_IMAGE_UNSUPPORTED_FORMAT", {
detectedFormat: metadata.format ?? null,
});
}
if (
!Number.isSafeInteger(metadata.width) ||
(metadata.width ?? 0) < 1 ||
!Number.isSafeInteger(metadata.height) ||
(metadata.height ?? 0) < 1
) {
throw new SignageError("SIGNAGE_IMAGE_MALFORMED");
}
if ((metadata.width ?? 0) * (metadata.height ?? 0) > SIGNAGE_IMAGE_MAX_PIXELS) {
throw new SignageError("SIGNAGE_IMAGE_PIXEL_LIMIT", { maxPixels: SIGNAGE_IMAGE_MAX_PIXELS });
}
}
async function renderRendition(
bytes: Uint8Array,
target: {
readonly kind: "display" | "thumbnail";
maxWidth: number;
maxHeight: number;
quality: number;
},
): Promise<SignageImageRendition> {
let output: { data: Buffer; info: OutputInfo };
try {
output = await sharp(bytes, { failOn: "error", limitInputPixels: SIGNAGE_IMAGE_MAX_PIXELS })
// Honour the original's own orientation, so a phone photograph is not shown on its side.
.autoOrient()
// Transparency becomes black rather than white: a signage display's ground is black, and a
// white flash behind a logo is the artefact this avoids.
.flatten({ background: { b: 0, g: 0, r: 0 } })
// `inside` never crops, and `withoutEnlargement` never upscales — a small image stays small
// rather than being blown up into softness.
.resize({
fit: "inside",
height: target.maxHeight,
width: target.maxWidth,
withoutEnlargement: true,
})
.toColourspace("srgb")
// No `withMetadata()`: EXIF, GPS coordinates, and camera serial numbers are stripped from
// anything Signage publishes. The original keeps them; the rendition a browser fetches
// does not.
.webp({ effort: 4, quality: target.quality })
.toBuffer({ resolveWithObject: true });
} catch (error) {
throw decodeFailure(error);
}
const data = new Uint8Array(output.data);
return {
byteSize: data.byteLength,
bytes: data,
contentSha256: sha256Hex(data),
contentType: CONTENT_TYPES.webp as string,
height: output.info.height,
kind: target.kind,
width: output.info.width,
};
}
/**
* Validates the uploaded bytes and produces both renditions, or throws one of Signage's own
* sanitized codes. Deterministic: the same input always yields the same output bytes, which is what
* makes a retry idempotent.
*/
export async function processSignageImage(bytes: Uint8Array): Promise<SignageProcessedImage> {
const metadata = await readMetadata(bytes);
assertSupported(metadata);
const format = metadata.format as string;
const oriented = metadata.autoOrient ?? {
height: metadata.height as number,
width: metadata.width as number,
};
const [display, thumbnail] = await Promise.all([
renderRendition(bytes, SIGNAGE_IMAGE_DISPLAY_RENDITION),
renderRendition(bytes, SIGNAGE_IMAGE_THUMBNAIL_RENDITION),
]);
return {
byteSize: bytes.byteLength,
contentSha256: sha256Hex(bytes),
contentType: CONTENT_TYPES[format] as string,
encodedHeight: metadata.height as number,
encodedWidth: metadata.width as number,
fileExtension: FILE_EXTENSIONS[format] as string,
format,
hasAlpha: Boolean(metadata.hasAlpha),
height: oriented.height,
orientation: metadata.orientation ?? null,
renditions: [display, thumbnail],
width: oriented.width,
};
}
+278
View File
@@ -0,0 +1,278 @@
import { randomUUID } from "node:crypto";
import {
type ModuleStorage,
type ModuleStorageBucket,
ModuleStorageError,
} from "@ekklesia/module-sdk";
import { SignageError } from "../errors.js";
import { type SignageStore, SignageStoreConflict, type StoredImage } from "../store/types.js";
import { SIGNAGE_IMAGE_MAX_BYTES } from "./limits.js";
import { processSignageImage, type SignageProcessedImage } from "./processing.js";
/** The one logical bucket Signage declares. It names no service, endpoint, region, or credential —
* which is what lets the same code run under Ekklesia and under a future standalone host. */
export const SIGNAGE_IMAGE_BUCKET = "native-images";
export function nativeImageBucket(storage: ModuleStorage): ModuleStorageBucket {
return storage.bucket(SIGNAGE_IMAGE_BUCKET);
}
/**
* Object keys are scoped to the asset they belong to.
*
* Until the asset row commits these are **temporary**: nothing references them, nothing can select
* them, and a failed ingest deletes every one it wrote. Scoping them to the asset rather than to a
* throwaway upload id is what makes a successful upload cost exactly one write of each object
* instead of two — the commit is the database row, not a second copy of 25 MiB.
*/
export function originalObjectKey(assetId: string, extension: string): string {
return `assets/${assetId}/original.${extension}`;
}
export function renditionObjectKey(assetId: string, kind: string): string {
return `assets/${assetId}/${kind}.webp`;
}
/**
* Every storage failure becomes one of Signage's own codes.
*
* `ModuleStorageError` already carries no endpoint, credential, or provider body; collapsing it
* further keeps even *which* storage operation failed out of Signage's responses, because that is
* not a caller's business.
*/
function storageFailure(error: unknown): SignageError {
if (error instanceof ModuleStorageError && error.code === "STORAGE_OBJECT_NOT_FOUND") {
return new SignageError("SIGNAGE_NOT_FOUND");
}
return new SignageError("SIGNAGE_STORAGE_UNAVAILABLE");
}
export interface IngestNativeImageInput {
readonly store: SignageStore;
readonly storage: ModuleStorage;
readonly bytes: Uint8Array;
readonly filename: string;
readonly displayName: string;
readonly expiresAt: Date | null;
/** Supplied so a test can pin identity. Production passes nothing and gets a fresh UUID. */
readonly newId?: () => string;
}
export interface IngestNativeImageResult {
readonly image: StoredImage;
/** True when identical bytes were already stored, so nothing new was written anywhere. */
readonly deduplicated: boolean;
}
/**
* Ingests one native image.
*
* The order **is** the contract (ADR 0007):
*
* 1. The caller has already read the whole request body under Signage's own bound.
* 2. Decode and validate. An invalid image has cost zero storage writes and zero rows.
* 3. Deduplicate by content hash. Identical bytes return the existing asset — again, zero writes.
* 4. Write the original and both renditions to asset-scoped keys. Temporary: nothing refers to them.
* 5. Commit the asset and its renditions in one transaction. That row is what makes the asset exist,
* so a partial write can never be promoted into something playback could select.
* 6. On any failure after step 4, delete every object this attempt wrote.
*/
export async function ingestNativeImage(
input: IngestNativeImageInput,
): Promise<IngestNativeImageResult> {
if (input.bytes.byteLength > SIGNAGE_IMAGE_MAX_BYTES) {
throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes: SIGNAGE_IMAGE_MAX_BYTES });
}
const processed: SignageProcessedImage = await processSignageImage(input.bytes);
const existing = await input.store.findReadyImageByContentHash(processed.contentSha256);
if (existing) {
return { deduplicated: true, image: existing };
}
const bucket = nativeImageBucket(input.storage);
const assetId = (input.newId ?? randomUUID)();
const original = originalObjectKey(assetId, processed.fileExtension);
const written: string[] = [];
try {
await bucket.put(original, input.bytes, { contentType: processed.contentType });
written.push(original);
const renditions = [];
for (const rendition of processed.renditions) {
const key = renditionObjectKey(assetId, rendition.kind);
await bucket.put(key, rendition.bytes, { contentType: rendition.contentType });
written.push(key);
renditions.push({
byteSize: rendition.byteSize,
contentSha256: rendition.contentSha256,
contentType: rendition.contentType,
height: rendition.height,
kind: rendition.kind,
objectKey: key,
width: rendition.width,
});
}
await input.store.insertImage({
contentSha256: processed.contentSha256,
displayName: input.displayName,
encodedHeight: processed.encodedHeight,
encodedWidth: processed.encodedWidth,
expiresAt: input.expiresAt,
hasAlpha: processed.hasAlpha,
height: processed.height,
id: assetId,
orientation: processed.orientation,
originalByteSize: processed.byteSize,
originalContentType: processed.contentType,
originalFilename: input.filename,
originalObjectKey: original,
renditions,
status: "ready",
width: processed.width,
});
} catch (error) {
// Nothing this attempt wrote is referenced by anything, so removing all of it is always safe.
await Promise.all(written.map((key) => bucket.delete(key).catch(() => undefined)));
if (error instanceof SignageStoreConflict) {
// Another request committed the identical bytes while this one was writing. For the caller
// that is a success, not a failure: the image they uploaded is stored.
const raced = await input.store.findReadyImageByContentHash(processed.contentSha256);
if (raced) {
return { deduplicated: true, image: raced };
}
throw new SignageError("SIGNAGE_CONFLICT");
}
if (error instanceof SignageError) {
throw error;
}
if (error instanceof ModuleStorageError) {
throw storageFailure(error);
}
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
const image = await input.store.findImage(assetId);
if (!image) {
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
return { deduplicated: false, image };
}
export interface SignageImageDelivery {
readonly kind: string;
readonly url: string;
readonly expiresAt: string;
readonly width: number;
readonly height: number;
}
/**
* Fresh, short-lived delivery URLs for one image's renditions.
*
* Requested when something is about to be delivered, and then forgotten. Signage persists the
* logical key and never the URL: a URL is a bearer capability with an expiry in it, and storing one
* in a table would be storing a credential that stops working.
*/
export async function createImageDeliveryUrls(
storage: ModuleStorage,
renditions: readonly {
readonly kind: string;
readonly objectKey: string;
readonly width: number;
readonly height: number;
}[],
): Promise<readonly SignageImageDelivery[]> {
const bucket = nativeImageBucket(storage);
try {
return await Promise.all(
renditions.map(async (rendition) => {
const url = await bucket.createReadUrl(rendition.objectKey);
return {
expiresAt: url.expiresAt.toISOString(),
height: rendition.height,
kind: rendition.kind,
url: url.url,
width: rendition.width,
};
}),
);
} catch (error) {
throw storageFailure(error);
}
}
export async function updateNativeImage(input: {
readonly store: SignageStore;
readonly assetId: string;
readonly displayName?: string | undefined;
readonly expiresAt?: Date | null | undefined;
}): Promise<StoredImage> {
const existing = await input.store.findImage(input.assetId);
if (!existing) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
const changes: { displayName?: string; expiresAt?: Date | null } = {};
if (input.displayName !== undefined && input.displayName !== existing.displayName) {
changes.displayName = input.displayName;
}
if (input.expiresAt !== undefined) {
const current = existing.expiresAt?.getTime() ?? null;
const next = input.expiresAt?.getTime() ?? null;
if (current !== next) {
changes.expiresAt = input.expiresAt;
}
}
// An exact no-op writes nothing at all.
if (Object.keys(changes).length === 0) {
return existing;
}
await input.store.updateImage(input.assetId, changes);
const updated = await input.store.findImage(input.assetId);
if (!updated) {
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
return updated;
}
/**
* Deletes an image, and refuses while something still refers to it.
*
* A slot pinned to a deleted image would resolve to nothing at the moment its turn came, mid-service.
* Refusing with a count of what refers to it is something an operator can act on; a silent cascade
* is not.
*/
export async function deleteNativeImage(input: {
readonly store: SignageStore;
readonly storage: ModuleStorage;
readonly assetId: string;
}): Promise<void> {
const existing = await input.store.findImage(input.assetId);
if (!existing) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
const references = await input.store.countImageReferences(input.assetId);
if (references.slots > 0 || references.staticHolds > 0) {
throw new SignageError("SIGNAGE_ASSET_IN_USE", {
heldByChannelGroups: references.staticHolds,
referencedBySlots: references.slots,
});
}
// The row goes first: an asset nothing can select is the state that matters, and an object left
// behind by a storage outage is inert rather than dangerous.
await input.store.deleteImage(input.assetId);
const bucket = nativeImageBucket(input.storage);
const keys = [
existing.originalObjectKey,
...existing.renditions.map((rendition) => rendition.objectKey),
];
await Promise.all(keys.map((key) => bucket.delete(key).catch(() => undefined)));
}
+76
View File
@@ -0,0 +1,76 @@
import { SignageError } from "../errors.js";
/**
* Reads a streamed request body under a hard byte bound.
*
* The upload route runs in the platform's `streamed` body mode, which means the platform counts
* bytes **as they are consumed** and never materializes the body itself. Signage counts them too,
* and stops at its own smaller limit — so an oversized upload is refused by Signage's own code with
* Signage's own message, rather than only by the platform's later `413`.
*
* The bytes are accumulated because an image cannot be validated, hashed, or resized from a partial
* stream: a decoder needs the whole file, and the decoded-pixel budget can only be checked once the
* header is present. The bound is what makes that safe — the peak is one image, never a request of
* unknown size, and never more than {@link maxBytes}.
*
* The source is cancelled as soon as the bound is passed, so a client sending more stops being read
* rather than being read and discarded.
*/
export async function readBoundedBody(request: Request, maxBytes: number): Promise<Uint8Array> {
const declared = request.headers.get("content-length");
if (declared !== null) {
const length = Number(declared);
// A declared length over the limit is refused before a byte is read. A missing or lying one
// bounds nothing, which is exactly why the running total below is the real check.
if (Number.isFinite(length) && length > maxBytes) {
throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes });
}
}
const body = request.body;
if (!body) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" });
}
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value) {
continue;
}
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel().catch(() => undefined);
throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes });
}
chunks.push(value);
}
} catch (error) {
if (error instanceof SignageError) {
throw error;
}
// The platform errors the stream when *its* limit is passed, and cancels the source. Nothing
// about that error reaches a caller: it becomes Signage's own bounded code.
throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes });
} finally {
reader.releaseLock();
}
if (total === 0) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" });
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}
+11
View File
@@ -0,0 +1,11 @@
import { createSignageModuleRuntime } from "./runtime.js";
/**
* Signage's compiled server entrypoint.
*
* `id` and `version` are build-artifact identity markers and must match `ekklesia.module.json`
* exactly; Core verifies both before this module is allowed to register anything (ADR 0006). The
* runtime itself is built by a factory (`./runtime.ts`) so a test can construct a fresh one rather
* than exercising a shared singleton's leftovers.
*/
export default createSignageModuleRuntime().definition;
+68
View File
@@ -0,0 +1,68 @@
import type { PlaybackSnapshot, SignagePlaybackService } from "./service.js";
/**
* `signage.playback` — sanitized playback state and commands for another module.
*
* The intended consumer is something like an Ekklesia Companion: a module that gives a volunteer at
* the back of a room a way to hold a slide or black out a screen. What it gets is exactly the
* operations an operator has in the dashboard, over the same authoritative service, with the same
* serialization.
*
* What it deliberately is **not**: a transport. There is no topic, no broker, no webhook, no
* integration vocabulary of any kind in this contract — a consumer that wanted to bridge these
* commands onto something else would do that in its own module, where that decision belongs.
*
* What it deliberately does not carry: a Core credential, a session, a device identity, a provider
* payload, or any private host internal. State here is identity, timing, and revisions.
*/
export const SIGNAGE_PLAYBACK_CAPABILITY_VERSION = "1.0.0";
/** One channel group's state, exactly as a display sees it. */
export type SignagePlaybackState = PlaybackSnapshot;
export interface SignagePlaybackCapability {
readonly contractVersion: string;
/** Every channel group and its current authoritative state. */
listGroups(): Promise<readonly SignagePlaybackState[]>;
readGroup(groupId: string): Promise<SignagePlaybackState>;
/** Immediately, or queued for the next safe slot boundary. */
activate(
groupId: string,
timelineId: string,
when: "now" | "next-boundary",
): Promise<SignagePlaybackState>;
pause(groupId: string): Promise<SignagePlaybackState>;
resume(groupId: string): Promise<SignagePlaybackState>;
next(groupId: string): Promise<SignagePlaybackState>;
previous(groupId: string): Promise<SignagePlaybackState>;
setBlackout(groupId: string, on: boolean): Promise<SignagePlaybackState>;
/** `null` clears the hold and continues the slide underneath from its preserved remainder. */
setStaticHold(groupId: string, imageId: string | null): Promise<SignagePlaybackState>;
/** Re-resolves provider availability and the current selection without disturbing the frame. */
reload(groupId: string): Promise<SignagePlaybackState>;
}
export function createPlaybackCapability(
playback: () => SignagePlaybackService,
listGroupIds: () => Promise<readonly string[]>,
): SignagePlaybackCapability {
const capability: SignagePlaybackCapability = {
activate: (groupId: string, timelineId: string, when: "now" | "next-boundary") =>
playback().activate(groupId, timelineId, when),
contractVersion: SIGNAGE_PLAYBACK_CAPABILITY_VERSION,
async listGroups(): Promise<readonly SignagePlaybackState[]> {
const ids = await listGroupIds();
return Promise.all(ids.map((groupId) => playback().snapshot(groupId)));
},
next: (groupId: string) => playback().next(groupId),
pause: (groupId: string) => playback().pause(groupId),
previous: (groupId: string) => playback().previous(groupId),
readGroup: (groupId: string) => playback().snapshot(groupId),
reload: (groupId: string) => playback().reload(groupId),
resume: (groupId: string) => playback().resume(groupId),
setBlackout: (groupId: string, on: boolean) => playback().setBlackout(groupId, on),
setStaticHold: (groupId: string, imageId: string | null) =>
playback().setStaticHold(groupId, imageId),
};
return Object.freeze(capability);
}
File diff suppressed because it is too large Load Diff
+201
View File
@@ -0,0 +1,201 @@
/**
* The `signage.content-providers` contract, version 1 (ADR 0002).
*
* This is the seam every future content module reaches Signage through: Announcements, Scripture,
* Trivia, and anything else a church installs. It is the one contract in this repository with
* implementations Signage's authors will never see, so it is written to be implementable from the
* documentation alone.
*
* ## A provider does not import this file
*
* Modules are independently installed peers, and `pnpm boundaries` refuses a module that imports
* another module. A provider therefore resolves the capability, receives an `unknown`
* implementation, and casts it to **its own local copy** of these declarations — which is what keeps
* the two modules independently versioned and separately releasable. `docs/provider-contract.md`
* publishes the shapes for exactly that purpose.
*
* ## Signage validates what it receives, always
*
* Everything crossing this boundary is validated before it is believed: registrations, resolutions,
* payloads, and durations. A provider is trusted code in the same process, so this is not a sandbox
* — it is the difference between one module's bug staying one module's bug and a malformed value
* reaching a television.
*/
/** The contract version Signage implements, and the only one it accepts a registration for. */
export const SIGNAGE_PROVIDER_CONTRACT_VERSION = "1.0.0";
/** Major-version compatibility: a provider written against `1.x` works, `2.x` does not. Stated as a
* predicate rather than a range string so no semantic-version parser has to cross this boundary. */
export function isSupportedProviderContractVersion(version: string): boolean {
return /^1\.\d+\.\d+$/u.test(version);
}
export type SignageJsonValue =
| boolean
| number
| string
| null
| readonly SignageJsonValue[]
| { readonly [key: string]: SignageJsonValue };
/** The audience key of the base channel every group has. A provider that offers nothing
* audience-specific returns exactly one variant, under this key. */
export const SIGNAGE_DEFAULT_AUDIENCE_KEY = "default";
/** One of a provider's own sources, as an operator picks it in the timeline editor. */
export interface SignageProviderSourceDescriptor {
/** The provider's own identity for this source. Opaque to Signage. */
readonly id: string;
/** What an operator sees. Signage stores a copy so the editor still reads sensibly when the
* provider is temporarily not running. */
readonly label: string;
readonly description?: string;
}
/** One specific item, so an operator can pin it into a slot. */
export interface SignageProviderItemDescriptor {
readonly id: string;
readonly label: string;
readonly description?: string;
/** When this item stops being available, as an absolute ISO 8601 instant. */
readonly availableUntil?: string | null;
}
/** One audience's rendering of one logical selection. */
export interface SignageProviderVariant {
/** `null` — or {@link SIGNAGE_DEFAULT_AUDIENCE_KEY} — is the default every channel falls back to. */
readonly audienceKey: string | null;
/** Structured, finite JSON. Signage validates that it *is* JSON and never what it means. */
readonly payload: SignageJsonValue;
}
/**
* What a provider hands back for one slot occurrence: **one logical item**, never one per channel.
*
* Resolving once and deriving variants is what keeps every channel in a group on the same slide with
* the same deadline, and what makes an audience a rendering rather than a second program (ADR 0004).
*/
export interface SignageProviderResolution {
/** The provider's own stable identity for this content. */
readonly itemId: string;
/** Changes whenever the payload changes and the item stays the same thing. */
readonly itemRevision: string;
/** Overrides this provider's default duration for this one item. */
readonly durationMs?: number;
/** When this selection stops being valid, as an absolute ISO 8601 instant. */
readonly availableUntil?: string | null;
/** This item is explicitly gone. Signage removes it at the next safe reconciliation boundary
* rather than immediately, so a screen does not cut mid-slide. */
readonly withdrawn?: boolean;
/** Presentation values the provider supplies to its **own** renderer. Opaque to Signage, which
* never interprets, merges, or overrides them. */
readonly presentation?: SignageJsonValue;
/** At least the default variant. */
readonly variants: readonly SignageProviderVariant[];
}
/** What Signage tells a provider when it asks for the next item. Deliberately nothing more: a
* provider is already running in this process, and what it needs from Signage is *when*. */
export interface SignageProviderResolveRequest {
/** Stable and opaque. Every slot referencing one source instance passes the same value, which is
* what makes five Announcement slots one rotation rather than five (ADR 0003). */
readonly selectionScopeId: string;
/** Which of the provider's own sources is being asked. */
readonly sourceId: string;
readonly timelineId: string;
readonly channelGroupId: string;
/** Every audience channel in the group, in a stable order. */
readonly audienceKeys: readonly string[];
/** The instant being resolved for, as an absolute ISO 8601 instant. */
readonly at: string;
/** The installation's IANA zone, for a provider whose availability is calendar-shaped. */
readonly timezone: string;
}
/** What Signage tells a provider when it wants one already-known item resolved again — a pinned
* slot's item, or a live refresh of what is currently on screen. */
export interface SignageProviderRefreshRequest
extends Omit<SignageProviderResolveRequest, "sourceId" | "selectionScopeId"> {
readonly itemId: string;
/** Present when the item came from a source; absent for a pinned item. */
readonly sourceId?: string;
readonly selectionScopeId?: string;
}
export interface SignageProviderDiscoveryRequest {
readonly at: string;
readonly timezone: string;
}
/** The renderer a provider's content is drawn by: a browser contribution the platform publishes
* against Signage's `signage.slide-renderer` capability (ADR 0041). Signage never imports it; its
* display asks the host to mount it by id. */
export interface SignageProviderRenderer {
readonly contributionId: string;
readonly contributionVersion: string;
}
/**
* What a provider module passes to `register`.
*
* Every function may reject or throw. Signage bounds each call with a deadline, catches everything,
* and converts the outcome into one of three answers — nothing available, explicitly withdrawn, or
* temporarily failed — which is the distinction the whole failure model rests on (ADR 0002).
*/
export interface SignageProviderRegistration {
readonly providerId: string;
readonly providerVersion: string;
/** Which version of this contract the provider was written against. */
readonly contractVersion: string;
readonly renderer: SignageProviderRenderer;
/** How long this provider's content is shown when no slot overrides it. */
readonly defaultDurationMs: number;
listSources(
request: SignageProviderDiscoveryRequest,
):
| Promise<readonly SignageProviderSourceDescriptor[]>
| readonly SignageProviderSourceDescriptor[];
/** Optional. Without it, an operator cannot pin one of this provider's items. */
listItems?(
request: SignageProviderDiscoveryRequest & { readonly sourceId?: string },
): Promise<readonly SignageProviderItemDescriptor[]> | readonly SignageProviderItemDescriptor[];
/** **The only call that advances anything.** `null` means nothing is available for this source
* right now, and Signage skips the slot immediately with no error and no slate. */
resolveNext(
request: SignageProviderResolveRequest,
): Promise<SignageProviderResolution | null> | SignageProviderResolution | null;
/** Re-resolves one already-known item without advancing. */
refresh(
request: SignageProviderRefreshRequest,
): Promise<SignageProviderResolution | null> | SignageProviderResolution | null;
}
/** What a provider may tell Signage has changed. A **notification**, never a command: Signage
* decides what to refresh and when, and nothing here can skip a slot or move a deadline. */
export interface SignageProviderInvalidation {
/** Limits the notification to one of the provider's sources. */
readonly sourceId?: string;
/** Limits it to one item — a cancelled event, a changed countdown. */
readonly itemId?: string;
}
/** What `register` returns. Held by the provider module and released in its own `stop` hook. */
export interface SignageProviderHandle {
/** Idempotent. Unregistering an already-unregistered provider is a no-op, which is what makes a
* rollback after a partially completed `start` safe. */
unregister(): void;
/** Notifies Signage that something it already resolved may have changed. Never throws for a
* provider that has been unregistered — a late notification from a module being torn down is
* ordinary, not an error. */
invalidate(change?: SignageProviderInvalidation): void;
}
/** The capability implementation Signage provides as `signage.content-providers`. */
export interface SignageContentProviderCapability {
readonly contractVersion: string;
/** Takes `unknown` deliberately: the caller is another module with its own copy of these types,
* and Signage validates the value rather than trusting a structural type it cannot enforce across
* a module boundary. */
register(registration: unknown): SignageProviderHandle;
}
+210
View File
@@ -0,0 +1,210 @@
import { SIGNAGE_NATIVE_PROVIDER_ID, SIGNAGE_NATIVE_RENDERER } from "../../shared/native-images.js";
import type { SignageStore, StoredImage } from "../store/types.js";
import type {
SignageProviderDiscoveryRequest,
SignageProviderItemDescriptor,
SignageProviderRefreshRequest,
SignageProviderRegistration,
SignageProviderResolution,
SignageProviderResolveRequest,
SignageProviderSourceDescriptor,
} from "./contract.js";
import { SIGNAGE_PROVIDER_CONTRACT_VERSION } from "./contract.js";
/**
* Signage's own content provider.
*
* It implements the identical registration shape an external provider implements, registers through
* the identical capability, and resolves through the identical code path (ADR 0001). That is the
* whole point: there is **one** selection pipeline, so history, `previous`, restart recovery,
* audience variants, and offline continuation are written once rather than twice and drifting.
*
* Two things are special about it, and only two:
*
* - Its renderer is `signage.native-image`, which is not a browser contribution at all. A display
* that sees that identity paints an image element itself instead of asking the host to mount
* anything, because the content is Signage's own.
* - Its payload shape is Signage's own, so the display-state route may read it — to mint fresh
* delivery URLs from the stored object keys. Every *other* provider's payload stays opaque.
*/
/** The one source Signage's own provider offers: every ready, currently-available image, in the
* order they were added, cycling forever. A pinned slot does not use this at all. */
export const SIGNAGE_NATIVE_LIBRARY_SOURCE = "library";
/** What a native-image selection carries. The renditions are **object keys**, not URLs: a URL is a
* bearer capability with an expiry, minted when something is about to be delivered and never
* stored (ADR 0007). */
// A `type` rather than an `interface` on purpose: TypeScript grants a type alias an implicit index
// signature, so this shape is assignable to `SignageJsonValue` without a cast — which is the
// compiler proving that everything Signage's own provider emits really is finite JSON.
export type SignageNativeImagePayload = {
readonly kind: "native-image";
readonly imageId: string;
readonly displayName: string;
readonly width: number;
readonly height: number;
readonly renditionKeys: { readonly display: string; readonly thumbnail: string };
};
function isAvailable(image: StoredImage, at: Date): boolean {
if (image.status !== "ready") {
return false;
}
// Half-open: available up to but not including the expiration instant.
return image.expiresAt === null || at < image.expiresAt;
}
function payloadFor(image: StoredImage): SignageNativeImagePayload {
const display = image.renditions.find((rendition) => rendition.kind === "display");
const thumbnail = image.renditions.find((rendition) => rendition.kind === "thumbnail");
return {
displayName: image.displayName,
height: image.height,
imageId: image.id,
kind: "native-image",
renditionKeys: {
display: display?.objectKey ?? image.originalObjectKey,
thumbnail: thumbnail?.objectKey ?? image.originalObjectKey,
},
width: image.width,
};
}
function resolutionFor(image: StoredImage): SignageProviderResolution {
return {
availableUntil: image.expiresAt?.toISOString() ?? null,
// The image's own content hash is its revision: different bytes are a different asset, so a
// rendition can never change underneath a revision that stayed the same.
itemId: image.id,
itemRevision: image.contentSha256.slice(0, 32),
// One variant, under the default key. Native images have no audience-specific rendering: a
// church's slide is the same slide in the room and online.
variants: [{ audienceKey: null, payload: payloadFor(image) }],
};
}
export interface NativeImageProviderOptions {
readonly store: SignageStore;
readonly defaultDurationMs: number;
readonly now?: () => Date;
}
/**
* Builds the registration for Signage's built-in image provider.
*
* The library source's rotation cursor is held **in memory, per selection scope**. That is a
* deliberate simplification with a stated cost: a restart resumes the library rotation from the
* image that last played in that scope when Signage can tell it — which it can, because it persists
* every resolved selection — and from the beginning when it cannot. The current slide itself is
* unaffected either way, because that is persisted authoritative state rather than a cursor.
*/
export interface NativeImageProvider {
readonly registration: SignageProviderRegistration;
/** Re-seeds a scope's rotation from what actually played last, so a restart continues the library
* where it left off rather than at the beginning. */
seedCursor(selectionScopeId: string, itemId: string): void;
}
export function createNativeImageProvider(
options: NativeImageProviderOptions,
): NativeImageProvider {
const now = options.now ?? (() => new Date());
const cursors = new Map<string, string>();
async function available(at: Date): Promise<readonly StoredImage[]> {
const images = await options.store.listImages();
return (
images
.filter((image) => isAvailable(image, at))
// Oldest first, so a rotation an operator watched yesterday runs in the same order today.
.slice()
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime())
);
}
const registration: SignageProviderRegistration = {
contractVersion: SIGNAGE_PROVIDER_CONTRACT_VERSION,
defaultDurationMs: options.defaultDurationMs,
async listItems(
_request: SignageProviderDiscoveryRequest,
): Promise<readonly SignageProviderItemDescriptor[]> {
const at = now();
const images = await available(at);
return images.map((image) => ({
availableUntil: image.expiresAt?.toISOString() ?? null,
description: `${image.width}×${image.height}`,
id: image.id,
label: image.displayName,
}));
},
listSources(
_request: SignageProviderDiscoveryRequest,
): readonly SignageProviderSourceDescriptor[] {
return [
{
description:
"Every Signage image that is ready and not expired, in the order it was added.",
id: SIGNAGE_NATIVE_LIBRARY_SOURCE,
label: "All Signage images",
},
];
},
providerId: SIGNAGE_NATIVE_PROVIDER_ID,
providerVersion: "1.0.0",
async refresh(
request: SignageProviderRefreshRequest,
): Promise<SignageProviderResolution | null> {
const image = await options.store.findImage(request.itemId);
if (!image) {
// The operator deleted it. That is a withdrawal, not a failure: the item is genuinely gone,
// and the slot should stop showing it at the next safe boundary rather than keeping a stale
// frame forever.
return {
itemId: request.itemId,
itemRevision: "deleted",
variants: [{ audienceKey: null, payload: { kind: "withdrawn" } }],
withdrawn: true,
};
}
if (!isAvailable(image, new Date(request.at))) {
// Expired or not yet ready: nothing to show right now, which is a skip rather than an error.
return null;
}
return resolutionFor(image);
},
renderer: { contributionId: SIGNAGE_NATIVE_RENDERER, contributionVersion: "1.0.0" },
async resolveNext(
request: SignageProviderResolveRequest,
): Promise<SignageProviderResolution | null> {
if (request.sourceId !== SIGNAGE_NATIVE_LIBRARY_SOURCE) {
return null;
}
const images = await available(new Date(request.at));
if (images.length === 0) {
return null;
}
const previous = cursors.get(request.selectionScopeId);
const index =
previous === undefined ? -1 : images.findIndex((image) => image.id === previous);
// Round robin. An image added or removed since the last turn simply shifts the ring; nothing
// about the cursor is an index into a list that may have changed underneath it.
const next = images[(index + 1) % images.length] as StoredImage;
cursors.set(request.selectionScopeId, next.id);
return resolutionFor(next);
},
};
return {
registration,
seedCursor(selectionScopeId: string, itemId: string): void {
cursors.set(selectionScopeId, itemId);
},
};
}
+362
View File
@@ -0,0 +1,362 @@
import type {
SignageContentProviderCapability,
SignageJsonValue,
SignageProviderDiscoveryRequest,
SignageProviderHandle,
SignageProviderInvalidation,
SignageProviderItemDescriptor,
SignageProviderRefreshRequest,
SignageProviderRegistration,
SignageProviderResolveRequest,
SignageProviderSourceDescriptor,
} from "./contract.js";
import { SIGNAGE_PROVIDER_CONTRACT_VERSION } from "./contract.js";
import {
type ParsedProviderResolution,
parseItemDescriptors,
parseProviderRegistration,
parseProviderResolution,
parseSourceDescriptors,
SignageProviderError,
type SignageProviderErrorCode,
} from "./validation.js";
/**
* The live registry of content providers.
*
* **In memory only, deliberately.** A registration is a running fact, not a stored one: a provider
* that is uninstalled or disabled leaves no row behind claiming it exists, and Signage never
* enumerates provider *modules* — only whatever registered itself while running.
*
* Every call out to a provider goes through {@link callProvider}: a deadline, a `catch`, and
* validation of what came back. A provider cannot crash the Signage runtime, hang playback, corrupt
* a selection, or put its own words on a screen or in a log.
*/
/** What management sees about one provider. Everything here is either the provider's own declared
* identity or a count Signage itself kept — never a provider message or a caught exception. */
export interface SignageProviderHealth {
readonly providerId: string;
readonly providerVersion: string;
readonly contractVersion: string;
readonly renderer: { readonly contributionId: string; readonly contributionVersion: string };
readonly defaultDurationMs: number;
readonly registeredAt: string;
readonly consecutiveFailures: number;
readonly lastFailureCode: SignageProviderErrorCode | null;
readonly lastFailureAt: string | null;
readonly lastSuccessAt: string | null;
}
/** What Signage does when a provider says something it already resolved may have changed. The
* registry only *reports* the notification; deciding what to refresh, and when, belongs to
* playback, which is the only thing that knows what is on screen. */
export type SignageInvalidationListener = (notice: {
readonly providerId: string;
readonly sourceId?: string;
readonly itemId?: string;
}) => void;
export interface SignageProviderRegistryOptions {
/** How long one provider call may take. Signage's own operator setting. */
readonly timeoutMs: number;
readonly now?: () => Date;
readonly onInvalidate?: SignageInvalidationListener;
}
interface RegisteredProvider {
readonly registration: SignageProviderRegistration;
readonly registeredAt: Date;
consecutiveFailures: number;
lastFailureCode: SignageProviderErrorCode | null;
lastFailureAt: Date | null;
lastSuccessAt: Date | null;
active: boolean;
}
export interface SignageProviderRegistry {
/** The capability implementation handed to `capabilities.provide`. */
readonly capability: SignageContentProviderCapability;
/** Every currently registered provider, in a stable order. */
health(): readonly SignageProviderHealth[];
has(providerId: string): boolean;
rendererFor(providerId: string): SignageProviderHealth["renderer"] | undefined;
defaultDurationMs(providerId: string): number | undefined;
listSources(
providerId: string,
request: SignageProviderDiscoveryRequest,
): Promise<readonly SignageProviderSourceDescriptor[]>;
listItems(
providerId: string,
request: SignageProviderDiscoveryRequest & { readonly sourceId?: string },
): Promise<readonly SignageProviderItemDescriptor[]>;
resolveNext(
providerId: string,
request: SignageProviderResolveRequest,
): Promise<ProviderCallOutcome>;
refresh(providerId: string, request: SignageProviderRefreshRequest): Promise<ProviderCallOutcome>;
/** Releases every registration. Used when Signage itself stops. */
clear(): void;
}
/**
* The three answers a resolution call can produce, kept distinct all the way up (ADR 0002).
*
* Collapsing them would be this design's biggest mistake: an empty pool would show an error, and a
* provider restart would clear the screen.
*/
export type ProviderCallOutcome =
/** The provider answered. `resolution` is `null` when nothing is available right now. */
| { readonly kind: "resolved"; readonly resolution: ParsedProviderResolution | null }
/** The provider is not registered at all — uninstalled, disabled, or still starting. */
| { readonly kind: "unregistered" }
/** The provider threw, rejected, timed out, or returned something unusable. Temporary. */
| { readonly kind: "failed"; readonly code: SignageProviderErrorCode };
export function createProviderRegistry(
options: SignageProviderRegistryOptions,
): SignageProviderRegistry {
const now = options.now ?? (() => new Date());
const providers = new Map<string, RegisteredProvider>();
function recordSuccess(entry: RegisteredProvider): void {
entry.consecutiveFailures = 0;
entry.lastFailureCode = null;
entry.lastSuccessAt = now();
}
function recordFailure(entry: RegisteredProvider, code: SignageProviderErrorCode): void {
entry.consecutiveFailures += 1;
entry.lastFailureCode = code;
entry.lastFailureAt = now();
}
/**
* One bounded, sanitized call into a provider.
*
* The deadline is what stops a provider that hangs from stopping a channel group: playback is
* serialized per group, so a call that never settles would freeze the wall. The timer never
* rejects the provider's own promise — a late answer is simply ignored, because cancelling
* another module's work is not something this contract can express.
*/
async function callProvider<Result>(
entry: RegisteredProvider,
invoke: () => Promise<Result> | Result,
): Promise<{ ok: true; value: Result } | { ok: false; code: SignageProviderErrorCode }> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const value = await Promise.race([
Promise.resolve().then(invoke),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new SignageProviderError("SIGNAGE_PROVIDER_UNAVAILABLE")),
options.timeoutMs,
);
timer.unref?.();
}),
]);
recordSuccess(entry);
return { ok: true, value };
} catch (error) {
// Whatever the provider threw is discarded here. Its message, stack, and cause never reach a
// log, a response, or a screen.
const code =
error instanceof SignageProviderError && error.code === "SIGNAGE_PROVIDER_RESULT_INVALID"
? "SIGNAGE_PROVIDER_RESULT_INVALID"
: "SIGNAGE_PROVIDER_UNAVAILABLE";
recordFailure(entry, code);
return { code, ok: false };
} finally {
if (timer !== undefined) {
clearTimeout(timer);
}
}
}
function active(providerId: string): RegisteredProvider | undefined {
const entry = providers.get(providerId);
return entry?.active === true ? entry : undefined;
}
const capability: SignageContentProviderCapability = Object.freeze({
contractVersion: SIGNAGE_PROVIDER_CONTRACT_VERSION,
register(value: unknown): SignageProviderHandle {
const registration = parseProviderRegistration(value);
const existing = providers.get(registration.providerId);
if (existing?.active === true) {
throw new SignageProviderError("SIGNAGE_PROVIDER_DUPLICATE", registration.providerId);
}
const entry: RegisteredProvider = {
active: true,
consecutiveFailures: 0,
lastFailureAt: null,
lastFailureCode: null,
lastSuccessAt: null,
registeredAt: now(),
registration,
};
providers.set(registration.providerId, entry);
return Object.freeze({
invalidate(change?: SignageProviderInvalidation): void {
// A late notification from a module being torn down is ordinary, not an error.
if (!entry.active) {
return;
}
options.onInvalidate?.({
providerId: registration.providerId,
...(typeof change?.sourceId === "string" ? { sourceId: change.sourceId } : {}),
...(typeof change?.itemId === "string" ? { itemId: change.itemId } : {}),
});
},
unregister(): void {
// Idempotent, which is what makes a rollback after a partially completed `start` safe:
// the hook that failed runs its own cleanup, and reverse-order shutdown runs it again.
if (!entry.active) {
return;
}
entry.active = false;
providers.delete(registration.providerId);
},
});
},
});
return {
capability,
clear(): void {
for (const entry of providers.values()) {
entry.active = false;
}
providers.clear();
},
defaultDurationMs(providerId: string): number | undefined {
return active(providerId)?.registration.defaultDurationMs;
},
has(providerId: string): boolean {
return active(providerId) !== undefined;
},
health(): readonly SignageProviderHealth[] {
return [...providers.values()]
.filter((entry) => entry.active)
.map((entry) => ({
consecutiveFailures: entry.consecutiveFailures,
contractVersion: entry.registration.contractVersion,
defaultDurationMs: entry.registration.defaultDurationMs,
lastFailureAt: entry.lastFailureAt?.toISOString() ?? null,
lastFailureCode: entry.lastFailureCode,
lastSuccessAt: entry.lastSuccessAt?.toISOString() ?? null,
providerId: entry.registration.providerId,
providerVersion: entry.registration.providerVersion,
registeredAt: entry.registeredAt.toISOString(),
renderer: {
contributionId: entry.registration.renderer.contributionId,
contributionVersion: entry.registration.renderer.contributionVersion,
},
}))
.sort((left, right) => left.providerId.localeCompare(right.providerId));
},
async listItems(
providerId: string,
request: SignageProviderDiscoveryRequest & { readonly sourceId?: string },
): Promise<readonly SignageProviderItemDescriptor[]> {
const entry = active(providerId);
if (!entry?.registration.listItems) {
return [];
}
const listItems = entry.registration.listItems.bind(entry.registration);
const result = await callProvider(entry, () => listItems(request));
if (!result.ok) {
return [];
}
try {
return parseItemDescriptors(result.value);
} catch {
recordFailure(entry, "SIGNAGE_PROVIDER_RESULT_INVALID");
return [];
}
},
async listSources(
providerId: string,
request: SignageProviderDiscoveryRequest,
): Promise<readonly SignageProviderSourceDescriptor[]> {
const entry = active(providerId);
if (!entry) {
return [];
}
const listSources = entry.registration.listSources.bind(entry.registration);
const result = await callProvider(entry, () => listSources(request));
if (!result.ok) {
return [];
}
try {
return parseSourceDescriptors(result.value);
} catch {
recordFailure(entry, "SIGNAGE_PROVIDER_RESULT_INVALID");
return [];
}
},
async refresh(
providerId: string,
request: SignageProviderRefreshRequest,
): Promise<ProviderCallOutcome> {
const entry = active(providerId);
if (!entry) {
return { kind: "unregistered" };
}
const refresh = entry.registration.refresh.bind(entry.registration);
const result = await callProvider(entry, () => refresh(request));
if (!result.ok) {
return { code: result.code, kind: "failed" };
}
try {
return { kind: "resolved", resolution: parseProviderResolution(result.value) };
} catch {
recordFailure(entry, "SIGNAGE_PROVIDER_RESULT_INVALID");
return { code: "SIGNAGE_PROVIDER_RESULT_INVALID", kind: "failed" };
}
},
rendererFor(providerId: string): SignageProviderHealth["renderer"] | undefined {
const entry = active(providerId);
return entry
? {
contributionId: entry.registration.renderer.contributionId,
contributionVersion: entry.registration.renderer.contributionVersion,
}
: undefined;
},
async resolveNext(
providerId: string,
request: SignageProviderResolveRequest,
): Promise<ProviderCallOutcome> {
const entry = active(providerId);
if (!entry) {
return { kind: "unregistered" };
}
const resolveNext = entry.registration.resolveNext.bind(entry.registration);
const result = await callProvider(entry, () => resolveNext(request));
if (!result.ok) {
return { code: result.code, kind: "failed" };
}
try {
return { kind: "resolved", resolution: parseProviderResolution(result.value) };
} catch {
recordFailure(entry, "SIGNAGE_PROVIDER_RESULT_INVALID");
return { code: "SIGNAGE_PROVIDER_RESULT_INVALID", kind: "failed" };
}
},
};
}
export type { SignageJsonValue };
+366
View File
@@ -0,0 +1,366 @@
import type {
SignageJsonValue,
SignageProviderItemDescriptor,
SignageProviderRegistration,
SignageProviderSourceDescriptor,
SignageProviderVariant,
} from "./contract.js";
import { isSupportedProviderContractVersion, SIGNAGE_DEFAULT_AUDIENCE_KEY } from "./contract.js";
/**
* Everything crossing the provider boundary is validated here, before it is believed.
*
* A provider is trusted code in the same process, so this is not a sandbox and never claims to be.
* It is the difference between one module's bug staying one module's bug and a malformed value
* reaching a television — a `NaN` duration that makes a slide last forever, a cyclic payload that
* cannot be stored, an unbounded string that fills a realtime frame.
*
* Every rejection is a stable code carrying **nothing of the rejected value**.
*/
export const SIGNAGE_PROVIDER_ERROR_CODES = [
/** The registration is not the shape this contract accepts. */
"SIGNAGE_PROVIDER_REGISTRATION_INVALID",
/** The provider was written against a contract version Signage does not implement. */
"SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED",
/** A provider with this id is already registered. */
"SIGNAGE_PROVIDER_DUPLICATE",
/** The provider returned something this contract does not accept. */
"SIGNAGE_PROVIDER_RESULT_INVALID",
/** The provider threw, rejected, or exceeded its deadline. Temporary by definition. */
"SIGNAGE_PROVIDER_UNAVAILABLE",
] as const;
export type SignageProviderErrorCode = (typeof SIGNAGE_PROVIDER_ERROR_CODES)[number];
const providerErrorMessages: Readonly<Record<SignageProviderErrorCode, string>> = Object.freeze({
SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED:
"This content provider was written against an unsupported contract version.",
SIGNAGE_PROVIDER_DUPLICATE: "A content provider with this identifier is already registered.",
SIGNAGE_PROVIDER_REGISTRATION_INVALID: "The content provider registration is not valid.",
SIGNAGE_PROVIDER_RESULT_INVALID: "The content provider returned an unusable result.",
SIGNAGE_PROVIDER_UNAVAILABLE: "The content provider could not be reached.",
});
/** Carries a code and, at most, a value the **provider itself supplied as its own identity** — never
* its payload, its exception, or anything of the host's. */
export class SignageProviderError extends Error {
readonly code: SignageProviderErrorCode;
readonly providerId?: string;
constructor(code: SignageProviderErrorCode, providerId?: string) {
super(providerErrorMessages[code]);
this.name = "SignageProviderError";
this.code = code;
if (providerId !== undefined) {
this.providerId = providerId;
}
this.stack = `${this.name}: ${this.message}`;
}
}
/** Signage's own bounds on what a provider may hand back. They exist so one provider cannot make a
* realtime frame, a database row, or a slide duration unusable for everything else. */
export const SIGNAGE_PROVIDER_LIMITS = Object.freeze({
/** A slide shorter than a second is a flicker; one longer than an hour is a static hold. */
maxDurationMs: 3_600_000,
/** One selection's payloads, serialized. Well inside the platform's 64 KiB realtime ceiling even
* before Signage decides to deliver large state over HTTP instead. */
maxPayloadBytes: 32 * 1024,
/** Discovery is a picker, not a catalogue. */
maxDiscoveryResults: 500,
maxIdentifierLength: 200,
maxLabelLength: 200,
maxVariants: 16,
minDurationMs: 1000,
});
const IDENTIFIER = /^[\w][\w.:-]*$/u;
const SEMANTIC_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?$/u;
function isBoundedString(value: unknown, maxLength: number): value is string {
return typeof value === "string" && value.length > 0 && value.length <= maxLength;
}
function isIdentifier(value: unknown): value is string {
return (
isBoundedString(value, SIGNAGE_PROVIDER_LIMITS.maxIdentifierLength) && IDENTIFIER.test(value)
);
}
function isFunction(value: unknown): boolean {
return typeof value === "function";
}
/**
* Whether a value is genuinely JSON-serializable, checked structurally rather than by attempting
* `JSON.stringify` and hoping.
*
* `undefined`, a function, a `Symbol`, a `Date`, a class instance, a cycle, `NaN`, and `Infinity`
* all either vanish or throw on the way to storage or a browser, and the failure would surface far
* from the provider that caused it. The walk is bounded, so a cycle is caught rather than followed.
*/
export function isFiniteJson(value: unknown, seen = new Set<object>()): value is SignageJsonValue {
if (value === null || typeof value === "boolean" || typeof value === "string") {
return true;
}
if (typeof value === "number") {
return Number.isFinite(value);
}
if (typeof value !== "object") {
return false;
}
if (seen.has(value)) {
return false;
}
seen.add(value);
const isArray = Array.isArray(value);
if (!isArray && Object.getPrototypeOf(value) !== Object.prototype) {
return false;
}
const entries = isArray ? value : Object.values(value);
const valid = entries.every((entry) => isFiniteJson(entry, seen));
seen.delete(value);
return valid;
}
function jsonByteLength(value: SignageJsonValue): number {
return new TextEncoder().encode(JSON.stringify(value) ?? "").length;
}
function isInstantOrNull(value: unknown): value is string | null | undefined {
if (value === undefined || value === null) {
return true;
}
if (typeof value !== "string") {
return false;
}
// Absolute instants only. An ambiguous local timestamp is refused rather than guessed at.
return !Number.isNaN(Date.parse(value)) && /[Zz]|[+-]\d{2}:?\d{2}$/u.test(value);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Validates a registration and returns it in its canonical form.
*
* Throws {@link SignageProviderError} — never a `TypeError`, never anything carrying the offending
* value — because the caller is another module's `start` hook, and what it does with a rejection is
* its own business.
*/
export function parseProviderRegistration(value: unknown): SignageProviderRegistration {
if (!isRecord(value)) {
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID");
}
const providerId = value.providerId;
if (!isIdentifier(providerId)) {
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID");
}
if (typeof value.contractVersion !== "string") {
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId);
}
if (!isSupportedProviderContractVersion(value.contractVersion)) {
// Reported separately from a malformed registration, because an operator can act on it: the
// provider module needs an update, and nothing about it is broken.
throw new SignageProviderError("SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED", providerId);
}
if (typeof value.providerVersion !== "string" || !SEMANTIC_VERSION.test(value.providerVersion)) {
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId);
}
const renderer = value.renderer;
if (
!isRecord(renderer) ||
!isIdentifier(renderer.contributionId) ||
typeof renderer.contributionVersion !== "string" ||
!SEMANTIC_VERSION.test(renderer.contributionVersion)
) {
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId);
}
const defaultDurationMs = value.defaultDurationMs;
if (
typeof defaultDurationMs !== "number" ||
!Number.isSafeInteger(defaultDurationMs) ||
defaultDurationMs < SIGNAGE_PROVIDER_LIMITS.minDurationMs ||
defaultDurationMs > SIGNAGE_PROVIDER_LIMITS.maxDurationMs
) {
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId);
}
if (
!isFunction(value.listSources) ||
!isFunction(value.resolveNext) ||
!isFunction(value.refresh) ||
(value.listItems !== undefined && !isFunction(value.listItems))
) {
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId);
}
return value as unknown as SignageProviderRegistration;
}
export function parseSourceDescriptors(value: unknown): readonly SignageProviderSourceDescriptor[] {
if (!Array.isArray(value) || value.length > SIGNAGE_PROVIDER_LIMITS.maxDiscoveryResults) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
return value.map((entry) => {
if (
!isRecord(entry) ||
!isIdentifier(entry.id) ||
!isBoundedString(entry.label, SIGNAGE_PROVIDER_LIMITS.maxLabelLength) ||
(entry.description !== undefined &&
!isBoundedString(entry.description, SIGNAGE_PROVIDER_LIMITS.maxLabelLength))
) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
return {
id: entry.id,
label: entry.label,
...(entry.description === undefined ? {} : { description: entry.description as string }),
};
});
}
export function parseItemDescriptors(value: unknown): readonly SignageProviderItemDescriptor[] {
if (!Array.isArray(value) || value.length > SIGNAGE_PROVIDER_LIMITS.maxDiscoveryResults) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
return value.map((entry) => {
if (
!isRecord(entry) ||
!isIdentifier(entry.id) ||
!isBoundedString(entry.label, SIGNAGE_PROVIDER_LIMITS.maxLabelLength) ||
!isInstantOrNull(entry.availableUntil) ||
(entry.description !== undefined &&
!isBoundedString(entry.description, SIGNAGE_PROVIDER_LIMITS.maxLabelLength))
) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
return {
id: entry.id,
label: entry.label,
...(entry.description === undefined ? {} : { description: entry.description as string }),
...(entry.availableUntil === undefined
? {}
: { availableUntil: entry.availableUntil as string | null }),
};
});
}
/** The canonical form of a resolution: variants keyed and deduplicated, the default guaranteed
* present, and every bound already applied. */
export interface ParsedProviderResolution {
readonly itemId: string;
readonly itemRevision: string;
readonly durationMs: number | undefined;
readonly availableUntil: Date | null;
readonly withdrawn: boolean;
readonly presentation: SignageJsonValue | undefined;
/** Always contains {@link SIGNAGE_DEFAULT_AUDIENCE_KEY}. */
readonly variants: ReadonlyMap<string, SignageJsonValue>;
}
/**
* Validates one resolution.
*
* `null` passes straight through: "nothing available right now" is an answer, not a failure, and
* conflating the two is how an empty Trivia pool becomes an error on a lobby wall.
*/
export function parseProviderResolution(value: unknown): ParsedProviderResolution | null {
if (value === null || value === undefined) {
return null;
}
if (!isRecord(value)) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
if (!isIdentifier(value.itemId) || !isBoundedString(value.itemRevision, 128)) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
if (
value.durationMs !== undefined &&
(typeof value.durationMs !== "number" ||
!Number.isSafeInteger(value.durationMs) ||
value.durationMs < SIGNAGE_PROVIDER_LIMITS.minDurationMs ||
value.durationMs > SIGNAGE_PROVIDER_LIMITS.maxDurationMs)
) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
if (!isInstantOrNull(value.availableUntil)) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
if (value.withdrawn !== undefined && typeof value.withdrawn !== "boolean") {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
if (value.presentation !== undefined && !isFiniteJson(value.presentation)) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
const rawVariants = value.variants;
if (
!Array.isArray(rawVariants) ||
rawVariants.length === 0 ||
rawVariants.length > SIGNAGE_PROVIDER_LIMITS.maxVariants
) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
const variants = new Map<string, SignageJsonValue>();
let payloadBytes = 0;
for (const entry of rawVariants as readonly unknown[]) {
if (!isRecord(entry)) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
const key =
entry.audienceKey === null || entry.audienceKey === undefined
? SIGNAGE_DEFAULT_AUDIENCE_KEY
: entry.audienceKey;
if (!isBoundedString(key, 64)) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
if (variants.has(key)) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
if (!isFiniteJson(entry.payload)) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
payloadBytes += jsonByteLength(entry.payload);
if (payloadBytes > SIGNAGE_PROVIDER_LIMITS.maxPayloadBytes) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
variants.set(key, entry.payload);
}
// Every channel must have something to render, and a provider with no audience-specific content
// returns exactly one variant. Requiring the default is what makes the fallback rule real.
if (!variants.has(SIGNAGE_DEFAULT_AUDIENCE_KEY)) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
const withdrawn = value.withdrawn === true;
const availableUntil =
typeof value.availableUntil === "string" ? new Date(value.availableUntil) : null;
return {
availableUntil,
durationMs: typeof value.durationMs === "number" ? value.durationMs : undefined,
itemId: value.itemId,
itemRevision: value.itemRevision,
presentation:
value.presentation === undefined ? undefined : (value.presentation as SignageJsonValue),
variants,
withdrawn,
};
}
export function parseProviderVariants(
variants: readonly SignageProviderVariant[],
): ReadonlyMap<string, SignageJsonValue> {
const parsed = parseProviderResolution({
itemId: "x",
itemRevision: "1",
variants,
});
if (!parsed) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
return parsed.variants;
}
+209
View File
@@ -0,0 +1,209 @@
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
import { SignageError, signageBoundary } from "../errors.js";
import { optionalInstant, optionalString, readJsonObject, requireUuidParam } from "../http.js";
import { SIGNAGE_IMAGE_MAX_BYTES } from "../images/limits.js";
import {
createImageDeliveryUrls,
deleteNativeImage,
ingestNativeImage,
updateNativeImage,
} from "../images/service.js";
import { readBoundedBody } from "../images/streaming.js";
import { createSignageStore } from "../store/drizzle.js";
import type { StoredImage } from "../store/types.js";
const FILENAME_MAX_LENGTH = 200;
/**
* A filename is a label an operator sees, never a path and never an object key.
*
* Path separators and control characters are refused rather than escaped, because a display name has
* no business containing either — and the value is echoed back to a browser, where a control
* character is a rendering hazard and a separator invites something to read it as a location.
*/
function isSafeLabel(value: string): boolean {
if (value.length === 0 || value.length > FILENAME_MAX_LENGTH) {
return false;
}
for (const character of value) {
const codePoint = character.codePointAt(0) ?? 0;
if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f)) {
return false;
}
if (character === "/" || character === "\\") {
return false;
}
}
return true;
}
function queryValue(request: Request, name: string): string | undefined {
const value = new URL(request.url).searchParams.get(name);
return value === null ? undefined : value;
}
function requireLabel(request: Request, field: string, fallback?: string): string {
const value = queryValue(request, field)?.trim();
if (value === undefined || value.length === 0) {
if (fallback !== undefined) {
return fallback;
}
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
}
if (!isSafeLabel(value)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
}
return value;
}
function readExpiresAt(request: Request): Date | null {
const raw = queryValue(request, "expiresAt");
if (raw === undefined || raw === "") {
// Null is permanent. Local age never invents an expiration for a church's own slide.
return null;
}
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime()) || !/[Zz]|[+-]\d{2}:?\d{2}$/u.test(raw)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "expiresAt" });
}
return parsed;
}
/** What a browser is given: the record plus fresh, short-lived delivery URLs. The logical object key
* never leaves the server — a browser has no use for it, and it is the one durable thing Signage
* stores about where bytes live. */
async function describe(
context: ModuleRouteHandlerContext,
image: StoredImage,
): Promise<Record<string, unknown>> {
const delivery = await createImageDeliveryUrls(context.storage, image.renditions);
return {
byteSize: image.originalByteSize,
contentSha256: image.contentSha256,
contentType: image.originalContentType,
createdAt: image.createdAt.toISOString(),
displayName: image.displayName,
expiresAt: image.expiresAt?.toISOString() ?? null,
hasAlpha: image.hasAlpha,
height: image.height,
id: image.id,
originalFilename: image.originalFilename,
renditions: delivery,
status: image.status,
width: image.width,
};
}
export function registerImageRoutes(http: ModuleHttpRouteRegistrar): void {
http.route({
method: "GET",
path: "/images",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const images = await createSignageStore(context.services.database).listImages();
return Response.json({
images: await Promise.all(images.map((image) => describe(context, image))),
});
});
},
});
http.route({
method: "POST",
path: "/images",
permission: "signage.assets.manage",
// One image, streamed. The platform counts bytes as the handler consumes them and never
// materializes the body; Signage counts them too and refuses at its own smaller limit first.
// The filename and display name travel in the query string, so the body is exactly the image.
maxBodyBytes: SIGNAGE_IMAGE_MAX_BYTES,
bodyMode: "streamed",
handler(request, context) {
return signageBoundary(async () => {
const filename = requireLabel(request, "filename");
const displayName = requireLabel(request, "displayName", filename);
const expiresAt = readExpiresAt(request);
// The whole body is read, and only then validated and committed. A `413` can arrive after a
// streamed handler has started, which is exactly why nothing is written until this returns.
const bytes = await readBoundedBody(request, SIGNAGE_IMAGE_MAX_BYTES);
const result = await ingestNativeImage({
bytes,
displayName,
expiresAt,
filename,
storage: context.storage,
store: createSignageStore(context.services.database),
});
return Response.json(
{ deduplicated: result.deduplicated, image: await describe(context, result.image) },
{ status: result.deduplicated ? 200 : 201 },
);
});
},
});
http.route({
method: "GET",
path: "/images/:imageId",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const imageId = requireUuidParam(context, "imageId");
const image = await createSignageStore(context.services.database).findImage(imageId);
if (!image) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
return Response.json({ image: await describe(context, image) });
});
},
});
http.route({
method: "PATCH",
path: "/images/:imageId",
permission: "signage.assets.manage",
// A rename and an expiration date. Nothing about this route needs a large body.
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const imageId = requireUuidParam(context, "imageId");
const body = await readJsonObject(request);
const displayName = optionalString(body, "displayName", {
maxLength: FILENAME_MAX_LENGTH,
});
if (displayName !== undefined && !isSafeLabel(displayName)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "displayName" });
}
const image = await updateNativeImage({
assetId: imageId,
displayName,
expiresAt: optionalInstant(body, "expiresAt"),
store: createSignageStore(context.services.database),
});
return Response.json({ image: await describe(context, image) });
});
},
});
http.route({
method: "DELETE",
path: "/images/:imageId",
permission: "signage.assets.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const imageId = requireUuidParam(context, "imageId");
await deleteNativeImage({
assetId: imageId,
storage: context.storage,
store: createSignageStore(context.services.database),
});
return new Response(null, { status: 204 });
});
},
});
}
+291
View File
@@ -0,0 +1,291 @@
import { randomUUID } from "node:crypto";
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
import { SignageError, signageBoundary } from "../errors.js";
import {
isUuid,
optionalInstant,
readJsonObject,
requiredString,
requireUuidParam,
} from "../http.js";
import type { SignagePlaybackService } from "../playback/service.js";
import { createSignageStore } from "../store/drizzle.js";
import { SignageStoreConflict, type StoredChannelGroupDetail } from "../store/types.js";
/** An audience key is what a provider receives, so it uses the same flat lowercase grammar the rest
* of the platform uses for a local identifier. */
const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
function describeGroup(detail: StoredChannelGroupDetail): Record<string, unknown> {
return {
channels: detail.channels.map((channel) => ({
id: channel.id,
isDefault: channel.isDefault,
key: channel.key,
name: channel.name,
})),
id: detail.group.id,
name: detail.group.name,
revision: detail.group.revision,
};
}
export function registerPlaybackRoutes(
http: ModuleHttpRouteRegistrar,
playback: () => SignagePlaybackService,
): void {
http.route({
method: "GET",
path: "/channel-groups",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const groups = await createSignageStore(context.services.database).listChannelGroups();
return Response.json({
groups: await Promise.all(
groups.map(async (group) => ({
...describeGroup(group),
playback: await playback().snapshot(group.group.id),
})),
),
});
});
},
});
http.route({
method: "POST",
path: "/channel-groups",
permission: "signage.displays.manage",
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const body = await readJsonObject(request);
const store = createSignageStore(context.services.database);
const groupId = randomUUID();
// A group is created with its default channel and its playback row together: a group
// without a default channel has nowhere for a viewer to land.
await store.createChannelGroup({
channelId: randomUUID(),
channelName: "Default",
groupId,
name: requiredString(body, "name", { maxLength: 120 }),
});
const created = await store.findChannelGroup(groupId);
if (!created) {
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
return Response.json(describeGroup(created), { status: 201 });
});
},
});
http.route({
method: "DELETE",
path: "/channel-groups/:groupId",
permission: "signage.displays.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const groupId = requireUuidParam(context, "groupId");
const store = createSignageStore(context.services.database);
const groups = await store.listChannelGroups();
if (groups.length <= 1) {
// An installation always has somewhere for a viewer to land.
throw new SignageError("SIGNAGE_CONFLICT", { reason: "last-group" });
}
await store.deleteChannelGroup(groupId);
return new Response(null, { status: 204 });
});
},
});
http.route({
method: "POST",
path: "/channel-groups/:groupId/channels",
permission: "signage.displays.manage",
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const groupId = requireUuidParam(context, "groupId");
const body = await readJsonObject(request);
const key = requiredString(body, "key", { maxLength: 64 });
if (!AUDIENCE_KEY.test(key) || key === "default") {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "key" });
}
const store = createSignageStore(context.services.database);
const group = await store.findChannelGroup(groupId);
if (!group) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
if (group.channels.some((channel) => channel.key === key)) {
throw new SignageError("SIGNAGE_CONFLICT", { field: "key" });
}
await store.addChannel({
groupId,
id: randomUUID(),
isDefault: false,
key,
name: requiredString(body, "name", { maxLength: 120 }),
});
const updated = await store.findChannelGroup(groupId);
if (!updated) {
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
return Response.json(describeGroup(updated), { status: 201 });
});
},
});
http.route({
method: "DELETE",
path: "/channels/:channelId",
permission: "signage.displays.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const channelId = requireUuidParam(context, "channelId");
try {
await createSignageStore(context.services.database).removeChannel(channelId);
} catch (error) {
if (error instanceof SignageStoreConflict) {
// A group's default channel is the one a viewer lands on. It cannot be removed.
throw new SignageError("SIGNAGE_CONFLICT", { reason: "default-channel" });
}
throw error;
}
return new Response(null, { status: 204 });
});
},
});
http.route({
method: "GET",
path: "/playback/:groupId",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const groupId = requireUuidParam(context, "groupId");
const store = createSignageStore(context.services.database);
const snapshot = await playback().snapshot(groupId);
const selection =
snapshot.currentSelectionId === null
? undefined
: await store.findSelection(snapshot.currentSelectionId);
const history = await store.listSelections(groupId, 20);
return Response.json({
current:
selection === undefined
? null
: {
deadlineAtMs: selection.deadlineAt?.getTime() ?? null,
durationMs: selection.durationMs,
id: selection.id,
itemId: selection.itemId,
itemRevision: selection.itemRevision,
providerId: selection.providerId,
rendererContributionId: selection.rendererContributionId,
revision: selection.revision,
stale: selection.stale,
startedAtMs: selection.startedAt?.getTime() ?? null,
withdrawn: selection.withdrawn,
},
// Identity and timing only. A payload belongs to a display, which asks for the channel
// state it is authorized for.
history: history.map((entry) => ({
id: entry.id,
itemId: entry.itemId,
providerId: entry.providerId,
sequence: entry.sequence,
startedAtMs: entry.startedAt?.getTime() ?? null,
})),
snapshot,
});
});
},
});
function command(
path: string,
run: (
service: SignagePlaybackService,
groupId: string,
body: Record<string, unknown>,
context: ModuleRouteHandlerContext,
) => Promise<unknown>,
options: { readonly maxBodyBytes: number },
): void {
http.route({
method: "POST",
path,
permission: "signage.playback.control",
maxBodyBytes: options.maxBodyBytes,
handler(request, context) {
return signageBoundary(async () => {
const groupId = requireUuidParam(context, "groupId");
const body = options.maxBodyBytes === 0 ? {} : await readJsonObject(request);
return Response.json(await run(playback(), groupId, body, context));
});
},
});
}
command(
"/playback/:groupId/activate",
async (service, groupId, body) => {
const timelineId = body.timelineId;
if (!isUuid(timelineId)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "timelineId" });
}
const at = optionalInstant(body, "at");
if (at) {
return service.activate(groupId, timelineId, { at });
}
const when = body.when === "next-boundary" ? "next-boundary" : "now";
return service.activate(groupId, timelineId, when);
},
{ maxBodyBytes: 2048 },
);
command("/playback/:groupId/pause", (service, groupId) => service.pause(groupId), {
maxBodyBytes: 0,
});
command("/playback/:groupId/resume", (service, groupId) => service.resume(groupId), {
maxBodyBytes: 0,
});
command("/playback/:groupId/next", (service, groupId) => service.next(groupId), {
maxBodyBytes: 0,
});
command("/playback/:groupId/previous", (service, groupId) => service.previous(groupId), {
maxBodyBytes: 0,
});
command("/playback/:groupId/reload", (service, groupId) => service.reload(groupId), {
maxBodyBytes: 0,
});
command(
"/playback/:groupId/blackout",
(service, groupId, body) => {
if (typeof body.on !== "boolean") {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "on" });
}
return service.setBlackout(groupId, body.on);
},
{ maxBodyBytes: 1024 },
);
command(
"/playback/:groupId/static",
(service, groupId, body) => {
const assetId = body.assetId;
if (assetId === null) {
return service.setStaticHold(groupId, null);
}
if (!isUuid(assetId)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "assetId" });
}
return service.setStaticHold(groupId, assetId);
},
{ maxBodyBytes: 1024 },
);
}
+90
View File
@@ -0,0 +1,90 @@
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
import { SignageError, signageBoundary } from "../errors.js";
import type { SignageProviderRegistry } from "../providers/registry.js";
import { readSignageSettings } from "../settings.js";
/** A provider id is another module's opaque identifier, and arrives here as a decoded path value —
* which is untrusted input whatever the declared path grammar was. Validated against Signage's own
* bound before any lookup. */
const PROVIDER_ID = /^[\w][\w.:-]{0,199}$/u;
function requireProviderId(context: ModuleRouteHandlerContext): string {
const value = context.params.providerId;
if (value === undefined || !PROVIDER_ID.test(value)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { parameter: "providerId" });
}
return value;
}
/**
* Provider health and discovery.
*
* Everything these routes return is either a provider's own declared identity or a count Signage
* itself kept. A provider's exception, message, stack, or payload never appears — which is what
* makes "an incompatible provider is clearly reported in management" true without also making a
* provider's internals an operator-visible surface.
*/
export function registerProviderRoutes(
http: ModuleHttpRouteRegistrar,
registry: () => SignageProviderRegistry,
): void {
http.route({
method: "GET",
path: "/providers",
permission: "signage.view",
maxBodyBytes: 0,
handler() {
return signageBoundary(() =>
Promise.resolve(
Response.json({
// Only what is actually registered right now. A registration is a running fact, not a
// stored one, so a provider that was uninstalled leaves nothing behind claiming it
// exists.
providers: registry().health(),
}),
),
);
},
});
http.route({
method: "GET",
path: "/providers/:providerId/sources",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const providerId = requireProviderId(context);
const settings = readSignageSettings(context.config);
// A provider that is not registered, is failing, or answers with something unusable yields
// an empty list rather than an error: the editor should say "no sources right now", not
// break, and provider health already reports why.
const sources = await registry().listSources(providerId, {
at: new Date().toISOString(),
timezone: settings.timezone,
});
return Response.json({ providerId, sources });
});
},
});
http.route({
method: "GET",
path: "/providers/:providerId/items",
permission: "signage.view",
maxBodyBytes: 0,
handler(request, context) {
return signageBoundary(async () => {
const providerId = requireProviderId(context);
const settings = readSignageSettings(context.config);
const sourceId = new URL(request.url).searchParams.get("sourceId");
const items = await registry().listItems(providerId, {
at: new Date().toISOString(),
timezone: settings.timezone,
...(sourceId === null ? {} : { sourceId }),
});
return Response.json({ items, providerId });
});
},
});
}
+420
View File
@@ -0,0 +1,420 @@
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
import { SignageError, signageBoundary } from "../errors.js";
import {
isUuid,
optionalBoolean,
optionalInstant,
optionalInteger,
optionalString,
readJsonObject,
requiredString,
requireUuidParam,
} from "../http.js";
import { createSignageStore } from "../store/drizzle.js";
import type { SignageStore, StoredSlotPresentation, StoredTimelineDetail } from "../store/types.js";
import {
addProviderSource,
addSlot,
createTimeline,
deleteTimeline,
duplicateTimeline,
removeProviderSource,
removeSlot,
reorderSlots,
requireTimeline,
SIGNAGE_TIMELINE_LIMITS,
updateSlot,
updateTimeline,
} from "../timelines/service.js";
const PROVIDER_ID = /^[\w][\w.:-]{0,199}$/u;
function store(context: ModuleRouteHandlerContext): SignageStore {
return createSignageStore(context.services.database);
}
/** The wire shape of a timeline. Instants are ISO 8601 strings, which is what a browser should
* receive — never an invented epoch or a locale-formatted representation. */
function describe(detail: StoredTimelineDetail): Record<string, unknown> {
return {
slots: detail.slots.map((slot) => ({
durationOverrideMs: slot.durationOverrideMs,
eligibleFrom: slot.eligibleFrom?.toISOString() ?? null,
eligibleUntil: slot.eligibleUntil?.toISOString() ?? null,
enabled: slot.enabled,
id: slot.id,
itemId: slot.itemId,
kind: slot.kind,
position: slot.position,
presentation: slot.presentation,
providerId: slot.providerId,
sourceId: slot.sourceId,
})),
sources: detail.sources.map((source) => ({
id: source.id,
label: source.label,
providerId: source.providerId,
sourceId: source.sourceId,
// The selection scope is deliberately **not** exposed. It is an internal stable identity a
// provider receives; a dashboard that showed it would invite someone to depend on its shape.
})),
timeline: {
description: detail.timeline.description,
eligibleFrom: detail.timeline.eligibleFrom?.toISOString() ?? null,
eligibleUntil: detail.timeline.eligibleUntil?.toISOString() ?? null,
id: detail.timeline.id,
name: detail.timeline.name,
revision: detail.timeline.revision,
updatedAt: detail.timeline.updatedAt.toISOString(),
},
};
}
function readPresentation(
body: Record<string, unknown>,
): Partial<StoredSlotPresentation> | undefined {
const raw = body.presentation;
if (raw === undefined) {
return undefined;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "presentation" });
}
const value = raw as Record<string, unknown>;
const mode = value.backgroundMode;
if (
mode !== undefined &&
mode !== null &&
mode !== "blurred" &&
mode !== "solid" &&
mode !== "black"
) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "backgroundMode" });
}
return {
...(mode === undefined
? {}
: { backgroundMode: mode as StoredSlotPresentation["backgroundMode"] }),
...(value.backgroundColor === undefined
? {}
: { backgroundColor: optionalString(value, "backgroundColor", { maxLength: 7 }) ?? null }),
...(value.backgroundDarkened === undefined
? {}
: { backgroundDarkened: optionalBoolean(value, "backgroundDarkened") ?? null }),
...(value.blurPixels === undefined
? {}
: { blurPixels: optionalInteger(value, "blurPixels", { maximum: 120, minimum: 0 }) ?? null }),
...(value.foregroundShadow === undefined
? {}
: { foregroundShadow: optionalBoolean(value, "foregroundShadow") ?? null }),
};
}
function requireOpaqueId(body: Record<string, unknown>, field: string): string {
const value = body[field];
if (typeof value !== "string" || !PROVIDER_ID.test(value)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
}
return value;
}
export function registerTimelineRoutes(http: ModuleHttpRouteRegistrar): void {
http.route({
method: "GET",
path: "/timelines",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const timelines = await store(context).listTimelines();
return Response.json({
timelines: timelines.map((timeline) => ({
description: timeline.description,
eligibleFrom: timeline.eligibleFrom?.toISOString() ?? null,
eligibleUntil: timeline.eligibleUntil?.toISOString() ?? null,
id: timeline.id,
name: timeline.name,
revision: timeline.revision,
updatedAt: timeline.updatedAt.toISOString(),
})),
});
});
},
});
http.route({
method: "POST",
path: "/timelines",
permission: "signage.timelines.manage",
maxBodyBytes: 8192,
handler(request, context) {
return signageBoundary(async () => {
const body = await readJsonObject(request);
const detail = await createTimeline({
description: optionalString(body, "description", { maxLength: 500 }) ?? null,
eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null,
eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null,
name: requiredString(body, "name", {
maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength,
}),
store: store(context),
});
return Response.json(describe(detail), { status: 201 });
});
},
});
http.route({
method: "GET",
path: "/timelines/:timelineId",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
return Response.json(describe(await requireTimeline(store(context), timelineId)));
});
},
});
http.route({
method: "PATCH",
path: "/timelines/:timelineId",
permission: "signage.timelines.manage",
maxBodyBytes: 8192,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const body = await readJsonObject(request);
const detail = await updateTimeline({
changes: {
...(body.name === undefined
? {}
: {
name: requiredString(body, "name", {
maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength,
}),
}),
...(body.description === undefined
? {}
: { description: optionalString(body, "description", { maxLength: 500 }) ?? null }),
...(body.eligibleFrom === undefined
? {}
: { eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null }),
...(body.eligibleUntil === undefined
? {}
: { eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null }),
},
store: store(context),
timelineId,
});
return Response.json(describe(detail));
});
},
});
http.route({
method: "DELETE",
path: "/timelines/:timelineId",
permission: "signage.timelines.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
await deleteTimeline({ store: store(context), timelineId });
return new Response(null, { status: 204 });
});
},
});
http.route({
method: "POST",
path: "/timelines/:timelineId/duplicate",
permission: "signage.timelines.manage",
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const body = await readJsonObject(request);
const detail = await duplicateTimeline({
name: requiredString(body, "name", { maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength }),
store: store(context),
timelineId,
});
return Response.json(describe(detail), { status: 201 });
});
},
});
http.route({
method: "POST",
path: "/timelines/:timelineId/sources",
permission: "signage.timelines.manage",
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const body = await readJsonObject(request);
const detail = await addProviderSource({
label: requiredString(body, "label", { maxLength: 200 }),
providerId: requireOpaqueId(body, "providerId"),
sourceId: requireOpaqueId(body, "sourceId"),
store: store(context),
timelineId,
});
return Response.json(describe(detail), { status: 201 });
});
},
});
http.route({
method: "DELETE",
path: "/timelines/:timelineId/sources/:sourceId",
permission: "signage.timelines.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const sourceId = requireUuidParam(context, "sourceId");
return Response.json(
describe(await removeProviderSource({ sourceId, store: store(context), timelineId })),
);
});
},
});
http.route({
method: "POST",
path: "/timelines/:timelineId/slots",
permission: "signage.timelines.manage",
maxBodyBytes: 8192,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const body = await readJsonObject(request);
const sourceId = body.sourceId;
const detail = await addSlot({
durationOverrideMs: optionalInteger(body, "durationOverrideMs", {
maximum: SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs,
minimum: SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs,
}),
eligibleFrom: optionalInstant(body, "eligibleFrom"),
eligibleUntil: optionalInstant(body, "eligibleUntil"),
enabled: optionalBoolean(body, "enabled") ?? true,
...(sourceId === undefined
? {}
: {
source: {
sourceId: isUuid(sourceId)
? sourceId
: (() => {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "sourceId" });
})(),
},
}),
...(body.itemId === undefined
? {}
: {
pinned: {
itemId: requireOpaqueId(body, "itemId"),
providerId: requireOpaqueId(body, "providerId"),
},
}),
position: optionalInteger(body, "position", { maximum: 10_000, minimum: 0 }) ?? undefined,
presentation: readPresentation(body),
store: store(context),
timelineId,
});
return Response.json(describe(detail), { status: 201 });
});
},
});
http.route({
method: "PATCH",
path: "/timelines/:timelineId/slots/:slotId",
permission: "signage.timelines.manage",
maxBodyBytes: 8192,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const slotId = requireUuidParam(context, "slotId");
const body = await readJsonObject(request);
const presentation = readPresentation(body);
const detail = await updateSlot({
changes: {
...(body.enabled === undefined
? {}
: { enabled: optionalBoolean(body, "enabled") ?? true }),
...(body.durationOverrideMs === undefined
? {}
: {
durationOverrideMs: optionalInteger(body, "durationOverrideMs", {
maximum: SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs,
minimum: SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs,
}) as number | null,
}),
...(body.eligibleFrom === undefined
? {}
: { eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null }),
...(body.eligibleUntil === undefined
? {}
: { eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null }),
...(body.itemId === undefined ? {} : { itemId: requireOpaqueId(body, "itemId") }),
...(presentation === undefined ? {} : { presentation }),
},
slotId,
store: store(context),
timelineId,
});
return Response.json(describe(detail));
});
},
});
http.route({
method: "DELETE",
path: "/timelines/:timelineId/slots/:slotId",
permission: "signage.timelines.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const slotId = requireUuidParam(context, "slotId");
return Response.json(
describe(await removeSlot({ slotId, store: store(context), timelineId })),
);
});
},
});
http.route({
method: "PUT",
path: "/timelines/:timelineId/slot-order",
permission: "signage.timelines.manage",
maxBodyBytes: 16_384,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const body = await readJsonObject(request);
const ids = body.slotIds;
if (
!Array.isArray(ids) ||
ids.length > SIGNAGE_TIMELINE_LIMITS.maxSlotsPerTimeline ||
!ids.every((id) => isUuid(id))
) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "slotIds" });
}
return Response.json(
describe(
await reorderSlots({
orderedSlotIds: ids as string[],
store: store(context),
timelineId,
}),
),
);
});
},
});
}
+259
View File
@@ -0,0 +1,259 @@
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
import { buildDisplayChannelState } from "../display/state.js";
import { SignageError, signageBoundary } from "../errors.js";
import {
optionalBoolean,
optionalInteger,
optionalString,
readJsonObject,
requireUuidParam,
} from "../http.js";
import type { SignagePlaybackService } from "../playback/service.js";
import type { SignageProviderRegistry } from "../providers/registry.js";
import { readSignageSettings } from "../settings.js";
import { createSignageStore } from "../store/drizzle.js";
import type { SignageStore, StoredChannel } from "../store/types.js";
import {
describeViewerHealth,
openViewerSession,
recordViewerHeartbeat,
SIGNAGE_VIEWER_NAME_MAX,
updateViewerAssignment,
} from "../viewers/service.js";
const OPAQUE_ID = /^[\w][\w.:-]{0,199}$/u;
function store(context: ModuleRouteHandlerContext): SignageStore {
return createSignageStore(context.services.database);
}
/**
* The safe Core device identifier, when the caller is a paired display.
*
* A device principal is deliberately **not** an authenticated person (ADR 0036), and Signage treats
* it as exactly what it is: an opaque identifier to correlate its own roster with Core's. Pairing,
* revocation, and every credential remain entirely Core's — nothing in Signage reads, stores, or
* forwards one.
*/
function deviceIdOf(context: ModuleRouteHandlerContext): string | null {
return context.principal.kind === "device" ? context.principal.deviceId : null;
}
async function requireChannel(
signageStore: SignageStore,
channelId: string,
): Promise<StoredChannel> {
const groups = await signageStore.listChannelGroups();
for (const group of groups) {
const channel = group.channels.find((candidate) => candidate.id === channelId);
if (channel) {
return channel;
}
}
throw new SignageError("SIGNAGE_NOT_FOUND");
}
export function registerViewerRoutes(
http: ModuleHttpRouteRegistrar,
playback: () => SignagePlaybackService,
registry: () => SignageProviderRegistry,
): void {
http.route({
method: "POST",
path: "/viewers/session",
permission: "signage.display.view",
maxBodyBytes: 2048,
handler(request, context) {
return signageBoundary(async () => {
const body = await readJsonObject(request);
const viewerKey = body.viewerKey;
if (typeof viewerKey !== "string") {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "viewerKey" });
}
// A display registers with the identity it keeps in its own IndexedDB and lands on the
// default channel. It never chooses one: a public screen that could would let anyone who
// reaches the page decide what a wall shows (ADR 0004).
const session = await openViewerSession({
at: new Date(),
deviceId: deviceIdOf(context),
store: store(context),
viewerKey,
});
return Response.json({
channelId: session.channel.id,
channelKey: session.channel.key,
groupId: session.groupId,
heartbeatIntervalMs: 12_000,
viewerId: session.viewer.id,
viewerName: session.viewer.name,
});
});
},
});
http.route({
method: "POST",
path: "/viewers/:viewerId/heartbeat",
permission: "signage.display.view",
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const viewerId = requireUuidParam(context, "viewerId");
const body = await readJsonObject(request);
const settings = readSignageSettings(context.config);
const rawError = body.error;
// Telemetry only. Nothing a display reports here changes authoritative playback, and a
// local cursor is never accepted as truth.
const result = await recordViewerHeartbeat({
at: new Date(),
heartbeat: {
cachedAssetCount:
optionalInteger(body, "cachedAssetCount", {
maximum: 100_000,
minimum: 0,
}) ?? undefined,
offlineReady: optionalBoolean(body, "offlineReady") ?? undefined,
reportedContributionId:
optionalString(body, "reportedContributionId", { maxLength: 200 }) ?? null,
reportedPlaybackRevision:
optionalInteger(body, "reportedPlaybackRevision", {
maximum: Number.MAX_SAFE_INTEGER,
minimum: 0,
}) ?? null,
reportedProviderId:
optionalString(body, "reportedProviderId", { maxLength: 200 }) ?? null,
reportedSelectionId:
optionalString(body, "reportedSelectionId", { maxLength: 64 }) ?? null,
reportedTimelineId:
optionalString(body, "reportedTimelineId", { maxLength: 64 }) ?? null,
...(rawError === undefined
? {}
: {
error:
rawError === null
? null
: {
code: readErrorCode(rawError),
detail: readErrorDetail(rawError),
},
}),
},
minimumWriteIntervalMs: settings.viewerHeartbeatWriteMs,
store: store(context),
viewerId,
});
// The response says what a display needs to keep itself honest, and nothing about other
// displays or the installation.
return Response.json({ acknowledged: true, recorded: result.written });
});
},
});
http.route({
method: "GET",
path: "/channels/:channelId/display-state",
permission: "signage.display.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const channelId = requireUuidParam(context, "channelId");
const signageStore = store(context);
const channel = await requireChannel(signageStore, channelId);
const snapshot = await playback().snapshot(channel.groupId);
return Response.json(
await buildDisplayChannelState({
atMs: Date.now(),
channel,
playback: snapshot,
registry: registry(),
settings: readSignageSettings(context.config),
storage: context.storage,
store: signageStore,
}),
);
});
},
});
http.route({
method: "GET",
path: "/viewers",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const atMs = Date.now();
const viewers = await store(context).listViewers();
return Response.json({
viewers: viewers.map((viewer) => describeViewerHealth(viewer, atMs)),
});
});
},
});
http.route({
method: "PATCH",
path: "/viewers/:viewerId",
permission: "signage.displays.manage",
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const viewerId = requireUuidParam(context, "viewerId");
const body = await readJsonObject(request);
const channelId = body.channelId;
if (channelId !== undefined && typeof channelId !== "string") {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "channelId" });
}
const viewer = await updateViewerAssignment({
...(channelId === undefined ? {} : { channelId }),
name: optionalString(body, "name", { maxLength: SIGNAGE_VIEWER_NAME_MAX }),
store: store(context),
viewerId,
});
return Response.json({ viewer: describeViewerHealth(viewer, Date.now()) });
});
},
});
http.route({
method: "DELETE",
path: "/viewers/:viewerId",
permission: "signage.displays.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const viewerId = requireUuidParam(context, "viewerId");
const signageStore = store(context);
if (!(await signageStore.findViewer(viewerId))) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
// Removing a screen from the roster is a record-keeping action. A display that checks in
// again is simply registered afresh, because its own identity is the one it keeps.
await signageStore.deleteViewer(viewerId);
return new Response(null, { status: 204 });
});
},
});
}
function readErrorCode(value: unknown): string {
if (typeof value !== "object" || value === null) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error" });
}
const code = (value as { code?: unknown }).code;
if (typeof code !== "string" || !OPAQUE_ID.test(code)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.code" });
}
return code;
}
function readErrorDetail(value: unknown): string | null {
const detail = (value as { detail?: unknown }).detail;
if (detail === undefined || detail === null) {
return null;
}
if (typeof detail !== "string") {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.detail" });
}
return detail;
}
+267
View File
@@ -0,0 +1,267 @@
import {
defineModuleRuntime,
type ModuleRuntimeContext,
type ModuleRuntimeDefinition,
} from "@ekklesia/module-sdk";
import { SIGNAGE_SLIDE_RENDERER_INPUT_VERSION } from "../shared/renderer-input.js";
import {
SIGNAGE_IMAGE_MAX_BYTES,
SIGNAGE_IMAGE_MAX_PIXELS,
SIGNAGE_UPLOAD_BATCH_MAX_FILES,
SIGNAGE_UPLOAD_CONCURRENCY,
} from "./images/limits.js";
import { createPlaybackCapability } from "./playback/capability.js";
import {
createPlaybackService,
type PlaybackSnapshot,
type SignagePlaybackService,
} from "./playback/service.js";
import type { SignageProviderHandle } from "./providers/contract.js";
import {
createNativeImageProvider,
type NativeImageProvider,
} from "./providers/native-provider.js";
import { createProviderRegistry, type SignageProviderRegistry } from "./providers/registry.js";
import { registerImageRoutes } from "./routes/images.js";
import { registerPlaybackRoutes } from "./routes/playback.js";
import { registerProviderRoutes } from "./routes/providers.js";
import { registerTimelineRoutes } from "./routes/timelines.js";
import { registerViewerRoutes } from "./routes/viewers.js";
import { readSignageSettings } from "./settings.js";
import { createSignageStore } from "./store/drizzle.js";
import { createDisplaysCapability } from "./viewers/capability.js";
/**
* Signage's server runtime, as a factory rather than a module-level singleton.
*
* A factory because the runtime owns real state — the live provider registry, the built-in image
* provider's registration handle, the playback service and its timers — and a test that exercised
* registration twice against one shared instance would be testing leftovers. `index.ts` calls this
* once; a test calls it per case.
*
* Registration stays declarative and I/O-free: nothing here connects to PostgreSQL, contacts object
* storage, reads the environment, opens a socket, or applies a migration. Live services reach
* Signage only through the contexts the platform hands `start`, `stop`, and route handlers.
*/
export interface SignageRuntimeOptions {
readonly now?: () => Date;
}
export interface SignageModuleRuntime {
readonly definition: ModuleRuntimeDefinition;
/** The live registry, exposed for tests and for the routes that report provider health. */
readonly registry: SignageProviderRegistry;
}
/** Realtime carries compact authoritative state and revisions, never content (ADR 0005). Large
* state — a timeline, a history, a renderer payload — is fetched over authorized HTTP keyed by
* revision, which closes the GET-then-subscribe race without a second transport. */
export const SIGNAGE_DISPLAY_CHANNEL = "display-state";
export const SIGNAGE_MANAGEMENT_CHANNEL = "management-state";
export function createSignageModuleRuntime(
options: SignageRuntimeOptions = {},
): SignageModuleRuntime {
const now = options.now ?? (() => new Date());
let registry: SignageProviderRegistry | undefined;
let nativeProvider: NativeImageProvider | undefined;
let nativeHandle: SignageProviderHandle | undefined;
let playback: SignagePlaybackService | undefined;
let store: ReturnType<typeof createSignageStore> | undefined;
/** The latest snapshot per group, so a publication never has to read the database from inside the
* very lock that produced it. */
const snapshots = new Map<string, PlaybackSnapshot>();
function requireRegistry(): SignageProviderRegistry {
if (!registry) {
throw new Error("Signage's provider registry is only available after registration.");
}
return registry;
}
function requirePlayback(): SignagePlaybackService {
if (!playback) {
throw new Error("Signage's playback service is only available after start.");
}
return playback;
}
function requireStore(): ReturnType<typeof createSignageStore> {
if (!store) {
throw new Error("Signage's database facade is only available after start.");
}
return store;
}
function publish(context: ModuleRuntimeContext): void {
const groups = [...snapshots.values()].sort((left, right) =>
left.groupId.localeCompare(right.groupId),
);
try {
context.realtime.publish(SIGNAGE_DISPLAY_CHANNEL, {
groups,
serverNowMs: now().getTime(),
});
context.realtime.publish(SIGNAGE_MANAGEMENT_CHANNEL, {
// Revisions only. The dashboard refetches what changed over authorized HTTP.
groups: groups.map((snapshot) => ({
groupId: snapshot.groupId,
revision: snapshot.revision,
selectionRevision: snapshot.selectionRevision,
transport: snapshot.transport,
})),
providers: requireRegistry()
.health()
.map((provider) => ({
consecutiveFailures: provider.consecutiveFailures,
lastFailureCode: provider.lastFailureCode,
providerId: provider.providerId,
})),
serverNowMs: now().getTime(),
});
} catch {
// Realtime is best effort and never blocks or changes committed playback state.
}
}
const definition = defineModuleRuntime({
id: "signage",
version: "0.1.0",
register({ capabilities, config, http, realtime }) {
const settings = readSignageSettings(config);
registry = createProviderRegistry({
now,
onInvalidate: (notice) => {
// A notification, never a command: playback decides what to refresh and when, because it
// is the only thing that knows what is on screen.
void playback?.invalidate(notice).catch(() => undefined);
},
timeoutMs: settings.providerTimeoutMs,
});
// The extension seam every future content module reaches Signage through (ADR 0002).
capabilities.provide("signage.content-providers", registry.capability);
// The browser half of the same seam. Nothing registers against it server-side: a contribution
// is declared in a *contributing* module's manifest and published by the platform, and this
// declaration is what makes `capabilityRange` mean something. Resolving it tells a module
// which version of Signage's renderer input contract this installation speaks.
capabilities.provide(
"signage.slide-renderer",
Object.freeze({ inputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION }),
);
// Sanitized playback state and commands for a future consumer such as an Ekklesia Companion.
// Exactly what an operator has in the dashboard, over the same serialized service — and
// deliberately no transport, topic, broker, or integration vocabulary of any kind.
capabilities.provide(
"signage.playback",
createPlaybackCapability(requirePlayback, async () => {
const groups = await requireStore().listChannelGroups();
return groups.map((group) => group.group.id);
}),
);
// Sanitized viewer and channel health, and assignment, for another module. Names, online
// state, and what a screen last reported — never a credential, a pairing token, or a host
// internal.
capabilities.provide("signage.displays", createDisplaysCapability(requireStore, now));
realtime.channel({ channel: SIGNAGE_DISPLAY_CHANNEL, permission: "signage.display.view" });
realtime.channel({ channel: SIGNAGE_MANAGEMENT_CHANNEL, permission: "signage.view" });
http.route({
method: "GET",
path: "/settings",
permission: "signage.view",
// A GET route accepts no request body at all.
maxBodyBytes: 0,
handler(_request, context) {
const current = readSignageSettings(context.config);
// Operator settings and Signage's own declared limits. Signage declares no `secret`
// configuration key, and this route would still never serialize one: what an operator may
// see of a module's configuration is the module's deliberate decision, value by value.
//
// The limits travel with the settings so the dashboard never hardcodes a number the
// server enforces — a UI believing a stale limit would offer an upload the server refuses.
return Response.json({
crossfadeMs: current.crossfadeMs,
moduleVersion: "0.1.0",
nativeImageDurationMs: current.nativeImageDurationMs,
nativePresentation: current.nativePresentation,
rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
timezone: current.timezone,
uploads: {
batchMaxFiles: SIGNAGE_UPLOAD_BATCH_MAX_FILES,
concurrency: SIGNAGE_UPLOAD_CONCURRENCY,
maxBytes: SIGNAGE_IMAGE_MAX_BYTES,
maxPixels: SIGNAGE_IMAGE_MAX_PIXELS,
},
});
},
});
registerImageRoutes(http);
registerProviderRoutes(http, requireRegistry);
registerTimelineRoutes(http);
registerPlaybackRoutes(http, requirePlayback);
registerViewerRoutes(http, requirePlayback, requireRegistry);
},
async start(context: ModuleRuntimeContext) {
const settings = readSignageSettings(context.config);
store = createSignageStore(context.services.database);
// Signage's own content provider registers through the identical capability an external
// provider uses. There is one selection pipeline, not a native special case (ADR 0001).
nativeProvider = createNativeImageProvider({
defaultDurationMs: settings.nativeImageDurationMs,
now,
store,
});
nativeHandle = requireRegistry().capability.register(nativeProvider.registration);
playback = createPlaybackService({
now: () => now().getTime(),
onSnapshot: (snapshot) => {
snapshots.set(snapshot.groupId, snapshot);
publish(context);
},
registry: requireRegistry(),
settings,
store,
});
// One default channel group with one default channel, and nothing else. `in-person` and
// `online` are an administrator's decision, not an install-time one (ADR 0004).
await playback.ensureDefaultGroup();
await playback.start();
// Every declared channel must receive an initial publication before `start` completes, so a
// subscriber that connects immediately is given state rather than silence.
publish(context);
},
stop() {
// Idempotent, and safe after a `start` that failed partway: `stop` runs before rollback, and
// may run again during reverse-order shutdown.
playback?.stop();
playback = undefined;
store = undefined;
snapshots.clear();
nativeHandle?.unregister();
nativeHandle = undefined;
nativeProvider = undefined;
registry?.clear();
},
});
return {
definition,
get registry(): SignageProviderRegistry {
return requireRegistry();
},
};
}
+52
View File
@@ -0,0 +1,52 @@
import type { ModuleConfiguration } from "@ekklesia/module-sdk";
import {
isSignageBackgroundMode,
SIGNAGE_DEFAULT_SETTINGS,
SIGNAGE_HEX_COLOR,
type SignageSettings,
} from "../shared/settings.js";
/**
* Reads Signage's own declared configuration through the one platform contract that offers it.
*
* Signage never reads `process.env`, never queries a platform table for a setting, and has no way to
* see another module's configuration — which is what keeps it runnable under a future standalone
* host that stores configuration somewhere else entirely.
*
* The manifest declares a default for every key, so in a real deployment each accessor is guaranteed
* a value before any Signage code runs. The fallbacks exist because the accessors are typed
* `T | undefined` for keys a manifest does not declare, and because a module-layer test may
* construct a `ModuleConfiguration` by hand.
*/
export function readSignageSettings(config: ModuleConfiguration): SignageSettings {
const defaults = SIGNAGE_DEFAULT_SETTINGS;
const backgroundColor = config.getString("defaultBackgroundColor");
const backgroundMode = config.getEnum("defaultBackgroundMode");
const durationSeconds = config.getInteger("nativeImageDurationSeconds");
const heartbeatSeconds = config.getInteger("viewerHeartbeatWriteSeconds");
return Object.freeze({
crossfadeMs: config.getInteger("crossfadeMilliseconds") ?? defaults.crossfadeMs,
nativeImageDurationMs:
durationSeconds === undefined ? defaults.nativeImageDurationMs : durationSeconds * 1000,
nativePresentation: Object.freeze({
backgroundColor:
backgroundColor !== undefined && SIGNAGE_HEX_COLOR.test(backgroundColor)
? backgroundColor.toLowerCase()
: defaults.nativePresentation.backgroundColor,
backgroundDarkened:
config.getBoolean("defaultBackgroundDarkened") ??
defaults.nativePresentation.backgroundDarkened,
backgroundMode: isSignageBackgroundMode(backgroundMode)
? backgroundMode
: defaults.nativePresentation.backgroundMode,
blurPixels: config.getInteger("defaultBlurPixels") ?? defaults.nativePresentation.blurPixels,
}),
playbackHistoryLimit:
config.getInteger("playbackHistoryLimit") ?? defaults.playbackHistoryLimit,
providerTimeoutMs:
config.getInteger("providerTimeoutMilliseconds") ?? defaults.providerTimeoutMs,
timezone: config.getString("timezone") ?? defaults.timezone,
viewerHeartbeatWriteMs:
heartbeatSeconds === undefined ? defaults.viewerHeartbeatWriteMs : heartbeatSeconds * 1000,
});
}
+371
View File
@@ -0,0 +1,371 @@
import { randomUUID } from "node:crypto";
import type { ModuleDatabase } from "@ekklesia/module-sdk";
import { and, asc, desc, eq, inArray, lt, sql } from "drizzle-orm";
import {
channelGroups,
channels,
playbackStates,
selections,
selectionVariants,
} from "../../schema/tables.js";
import {
type NewStoredSelection,
type PlaybackChanges,
type SelectionChanges,
type SignagePlaybackStore,
SignageStoreConflict,
type StoredChannel,
type StoredChannelGroupDetail,
type StoredPlaybackState,
type StoredSelection,
type StoredSelectionVariant,
} from "./types.js";
/**
* The playback half of Signage's production store.
*
* Two things here are transactional because they cannot be safe otherwise: creating a group together
* with its playback row and its default channel, and taking the next selection sequence. A group
* without a default channel has nowhere for a viewer to land, and two selections that took the same
* sequence would make history ambiguous exactly where `previous` reads it.
*/
type SelectionRow = typeof selections.$inferSelect;
function toPlayback(row: typeof playbackStates.$inferSelect): StoredPlaybackState {
return {
activeTimelineId: row.activeTimelineId,
blackout: row.blackout,
currentSelectionId: row.currentSelectionId,
currentSlotId: row.currentSlotId,
groupId: row.groupId,
nextSequence: row.nextSequence,
paused: row.paused,
pausedRemainingMs: row.pausedRemainingMs,
pendingRequestedAt: row.pendingRequestedAt,
pendingTimelineId: row.pendingTimelineId,
revision: row.revision,
scheduledActivationAt: row.scheduledActivationAt,
scheduledTimelineId: row.scheduledTimelineId,
slotDeadlineAt: row.slotDeadlineAt,
slotStartedAt: row.slotStartedAt,
staticAssetId: row.staticAssetId,
updatedAt: row.updatedAt,
};
}
function toChannel(row: typeof channels.$inferSelect): StoredChannel {
return {
groupId: row.groupId,
id: row.id,
isDefault: row.isDefault,
key: row.key,
name: row.name,
};
}
function toSelection(
row: SelectionRow,
variants: readonly StoredSelectionVariant[],
): StoredSelection {
return {
availableUntil: row.availableUntil,
deadlineAt: row.deadlineAt,
durationMs: row.durationMs,
groupId: row.groupId,
id: row.id,
itemId: row.itemId,
itemRevision: row.itemRevision,
presentation: row.presentation,
providerId: row.providerId,
rendererContributionId: row.rendererContributionId,
rendererContributionVersion: row.rendererContributionVersion,
resolvedAt: row.resolvedAt,
revision: row.revision,
selectionScope: row.selectionScope,
sequence: row.sequence,
slotId: row.slotId,
stale: row.stale,
startedAt: row.startedAt,
timelineId: row.timelineId,
variants,
withdrawn: row.withdrawn,
};
}
export function createPlaybackStore(database: ModuleDatabase): SignagePlaybackStore {
async function variantsFor(
selectionIds: readonly string[],
): Promise<Map<string, StoredSelectionVariant[]>> {
const grouped = new Map<string, StoredSelectionVariant[]>();
if (selectionIds.length === 0) {
return grouped;
}
const rows = await database
.select()
.from(selectionVariants)
.where(inArray(selectionVariants.selectionId, [...selectionIds]));
for (const row of rows) {
const list = grouped.get(row.selectionId) ?? [];
list.push({ audienceKey: row.audienceKey, isDefault: row.isDefault, payload: row.payload });
grouped.set(row.selectionId, list);
}
return grouped;
}
async function hydrateSelections(rows: readonly SelectionRow[]): Promise<StoredSelection[]> {
const variants = await variantsFor(rows.map((row) => row.id));
return rows.map((row) => toSelection(row, variants.get(row.id) ?? []));
}
async function groupDetail(
groupRow: typeof channelGroups.$inferSelect,
): Promise<StoredChannelGroupDetail | undefined> {
const [channelRows, playbackRows] = await Promise.all([
database
.select()
.from(channels)
.where(eq(channels.groupId, groupRow.id))
.orderBy(desc(channels.isDefault), asc(channels.key)),
database.select().from(playbackStates).where(eq(playbackStates.groupId, groupRow.id)),
]);
const playback = playbackRows[0];
if (!playback) {
return undefined;
}
return {
channels: channelRows.map(toChannel),
group: { id: groupRow.id, name: groupRow.name, revision: groupRow.revision },
playback: toPlayback(playback),
};
}
return {
async addChannel(channel: StoredChannel): Promise<void> {
await database.transaction(async (transaction) => {
await transaction.insert(channels).values({
groupId: channel.groupId,
id: channel.id,
isDefault: channel.isDefault,
key: channel.key,
name: channel.name,
});
await transaction
.update(channelGroups)
.set({ revision: sql`${channelGroups.revision} + 1`, updatedAt: new Date() })
.where(eq(channelGroups.id, channel.groupId));
});
},
async createChannelGroup(input: {
readonly groupId: string;
readonly name: string;
readonly channelId: string;
readonly channelName: string;
}): Promise<void> {
await database.transaction(async (transaction) => {
await transaction.insert(channelGroups).values({ id: input.groupId, name: input.name });
await transaction.insert(channels).values({
groupId: input.groupId,
id: input.channelId,
isDefault: true,
key: "default",
name: input.channelName,
});
await transaction.insert(playbackStates).values({ groupId: input.groupId });
});
},
async deleteChannelGroup(groupId: string): Promise<void> {
await database.delete(channelGroups).where(eq(channelGroups.id, groupId));
},
async findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined> {
const rows = await database.select().from(channelGroups).where(eq(channelGroups.id, groupId));
const row = rows[0];
return row ? groupDetail(row) : undefined;
},
async findLatestSelectionForScope(
groupId: string,
selectionScope: string,
): Promise<StoredSelection | undefined> {
const rows = await database
.select()
.from(selections)
.where(and(eq(selections.groupId, groupId), eq(selections.selectionScope, selectionScope)))
.orderBy(desc(selections.sequence))
.limit(1);
return (await hydrateSelections(rows))[0];
},
async findSelection(selectionId: string): Promise<StoredSelection | undefined> {
const rows = await database.select().from(selections).where(eq(selections.id, selectionId));
return (await hydrateSelections(rows))[0];
},
async insertSelection(selection: NewStoredSelection): Promise<void> {
await database.transaction(async (transaction) => {
await transaction.insert(selections).values({
availableUntil: selection.availableUntil,
deadlineAt: selection.deadlineAt,
durationMs: selection.durationMs,
groupId: selection.groupId,
id: selection.id,
itemId: selection.itemId,
itemRevision: selection.itemRevision,
presentation: selection.presentation,
providerId: selection.providerId,
rendererContributionId: selection.rendererContributionId,
rendererContributionVersion: selection.rendererContributionVersion,
selectionScope: selection.selectionScope,
sequence: selection.sequence,
slotId: selection.slotId,
stale: selection.stale,
startedAt: selection.startedAt,
timelineId: selection.timelineId,
withdrawn: selection.withdrawn,
});
if (selection.variants.length > 0) {
await transaction.insert(selectionVariants).values(
selection.variants.map((variant) => ({
audienceKey: variant.audienceKey,
id: randomUUID(),
isDefault: variant.isDefault,
payload: variant.payload,
selectionId: selection.id,
})),
);
}
});
},
async listChannelGroups(): Promise<readonly StoredChannelGroupDetail[]> {
const rows = await database.select().from(channelGroups).orderBy(asc(channelGroups.name));
const details = await Promise.all(rows.map((row) => groupDetail(row)));
return details.filter((detail): detail is StoredChannelGroupDetail => detail !== undefined);
},
async listSelections(groupId: string, limit: number): Promise<readonly StoredSelection[]> {
const rows = await database
.select()
.from(selections)
.where(eq(selections.groupId, groupId))
.orderBy(desc(selections.sequence))
.limit(limit);
return hydrateSelections(rows);
},
async pruneSelections(groupId: string, limit: number): Promise<void> {
const keep = await database
.select({ sequence: selections.sequence })
.from(selections)
.where(eq(selections.groupId, groupId))
.orderBy(desc(selections.sequence))
.limit(limit);
const oldest = keep.at(-1)?.sequence;
if (oldest === undefined) {
return;
}
await database
.delete(selections)
.where(and(eq(selections.groupId, groupId), lt(selections.sequence, oldest)));
},
async removeChannel(channelId: string): Promise<void> {
await database.transaction(async (transaction) => {
const rows = await transaction.select().from(channels).where(eq(channels.id, channelId));
const channel = rows[0];
if (!channel) {
return;
}
if (channel.isDefault) {
// Something must remain for a viewer to land on.
throw new SignageStoreConflict("channel.default");
}
await transaction.delete(channels).where(eq(channels.id, channelId));
await transaction
.update(channelGroups)
.set({ revision: sql`${channelGroups.revision} + 1`, updatedAt: new Date() })
.where(eq(channelGroups.id, channel.groupId));
});
},
async takeSelectionSequence(groupId: string): Promise<number> {
const rows = await database
.update(playbackStates)
.set({ nextSequence: sql`${playbackStates.nextSequence} + 1` })
.where(eq(playbackStates.groupId, groupId))
.returning({ nextSequence: playbackStates.nextSequence });
const next = rows[0]?.nextSequence;
if (next === undefined) {
throw new SignageStoreConflict("playback.sequence");
}
// `returning` gives the incremented value, so the sequence this call owns is one below it.
return next - 1;
},
async updatePlayback(
groupId: string,
changes: PlaybackChanges,
options?: { readonly bumpRevision?: boolean },
): Promise<void> {
const values: Record<string, unknown> = { updatedAt: new Date() };
for (const [key, value] of Object.entries(changes)) {
if (value !== undefined) {
values[key] = value;
} else if (Object.hasOwn(changes, key)) {
values[key] = null;
}
}
if (options?.bumpRevision !== false) {
values.revision = sql`${playbackStates.revision} + 1`;
}
await database.update(playbackStates).set(values).where(eq(playbackStates.groupId, groupId));
},
async updateSelection(selectionId: string, changes: SelectionChanges): Promise<void> {
await database.transaction(async (transaction) => {
const values: Record<string, unknown> = {};
for (const key of [
"availableUntil",
"deadlineAt",
"durationMs",
"itemRevision",
"presentation",
"stale",
"startedAt",
"withdrawn",
] as const) {
if (changes[key] !== undefined) {
values[key] = changes[key];
}
}
if (changes.bumpRevision === true) {
values.revision = sql`${selections.revision} + 1`;
}
if (Object.keys(values).length > 0) {
await transaction.update(selections).set(values).where(eq(selections.id, selectionId));
}
if (changes.variants) {
// Replaced wholesale: a partial update would leave one audience rendering a payload from
// a revision that no longer exists.
await transaction
.delete(selectionVariants)
.where(eq(selectionVariants.selectionId, selectionId));
if (changes.variants.length > 0) {
await transaction.insert(selectionVariants).values(
changes.variants.map((variant) => ({
audienceKey: variant.audienceKey,
id: randomUUID(),
isDefault: variant.isDefault,
payload: variant.payload,
selectionId,
})),
);
}
}
});
},
};
}
+399
View File
@@ -0,0 +1,399 @@
import type { ModuleDatabase } from "@ekklesia/module-sdk";
import { asc, eq, or, sql } from "drizzle-orm";
import { playbackStates, providerSources, timelineSlots, timelines } from "../../schema/tables.js";
import {
type NewStoredProviderSource,
type NewStoredSlot,
type NewStoredTimeline,
SignageStoreConflict,
type SignageTimelineStore,
type SlotChanges,
type StoredProviderSource,
type StoredSlot,
type StoredTimeline,
type StoredTimelineDetail,
type StoredTimelineUsage,
type TimelineChanges,
} from "./types.js";
/**
* The timeline half of Signage's production store.
*
* Two invariants live here rather than in the services above, because they are only safe if they are
* transactional:
*
* - **Slot positions stay unique and contiguous from zero.** Every operation that can change the
* order rewrites the whole order inside one transaction, so a gap can never be observed — not even
* briefly, by a reconciliation running at the same moment.
* - **A timeline's revision increments exactly once per semantic change**, in the same transaction
* as the change, so a client that compares revisions can never see a changed timeline at an
* unchanged revision.
*/
type TimelineRow = typeof timelines.$inferSelect;
type SlotRow = typeof timelineSlots.$inferSelect;
type SourceRow = typeof providerSources.$inferSelect;
function toTimeline(row: TimelineRow): StoredTimeline {
return {
createdAt: row.createdAt,
description: row.description,
eligibleFrom: row.eligibleFrom,
eligibleUntil: row.eligibleUntil,
id: row.id,
name: row.name,
revision: row.revision,
updatedAt: row.updatedAt,
};
}
function toSource(row: SourceRow): StoredProviderSource {
return {
createdAt: row.createdAt,
id: row.id,
label: row.label,
providerId: row.providerId,
selectionScope: row.selectionScope,
sourceId: row.sourceId,
timelineId: row.timelineId,
};
}
function toSlot(row: SlotRow): StoredSlot {
return {
durationOverrideMs: row.durationOverrideMs,
eligibleFrom: row.eligibleFrom,
eligibleUntil: row.eligibleUntil,
enabled: row.enabled,
id: row.id,
itemId: row.itemId,
kind: row.kind === "pinned-item" ? "pinned-item" : "provider-source",
position: row.position,
presentation: {
backgroundColor: row.backgroundColor,
backgroundDarkened: row.backgroundDarkened,
backgroundMode:
row.backgroundMode === "blurred" ||
row.backgroundMode === "solid" ||
row.backgroundMode === "black"
? row.backgroundMode
: null,
blurPixels: row.blurPixels,
foregroundShadow: row.foregroundShadow,
},
providerId: row.providerId,
sourceId: row.sourceId,
timelineId: row.timelineId,
};
}
function slotValues(slot: NewStoredSlot, position: number) {
return {
backgroundColor: slot.presentation.backgroundColor,
backgroundDarkened: slot.presentation.backgroundDarkened,
backgroundMode: slot.presentation.backgroundMode,
blurPixels: slot.presentation.blurPixels,
durationOverrideMs: slot.durationOverrideMs,
eligibleFrom: slot.eligibleFrom,
eligibleUntil: slot.eligibleUntil,
enabled: slot.enabled,
foregroundShadow: slot.presentation.foregroundShadow,
id: slot.id,
itemId: slot.itemId,
kind: slot.kind,
position,
providerId: slot.providerId,
sourceId: slot.sourceId,
timelineId: slot.timelineId,
};
}
export function createTimelineStore(database: ModuleDatabase): SignageTimelineStore {
/**
* Rewrites `position` for one timeline from an explicit order.
*
* Positions are written in two passes, through a temporary negative range, because the unique
* `(timeline_id, position)` index is checked per statement: moving a slot from 3 to 1 while
* another still holds 1 would collide. Negative positions are outside every real value, so the
* intermediate state cannot be mistaken for an order.
*/
async function renumber(
transaction: ModuleDatabase,
timelineId: string,
orderedSlotIds: readonly string[],
): Promise<void> {
for (const [index, slotId] of orderedSlotIds.entries()) {
await transaction
.update(timelineSlots)
.set({ position: -1 - index })
.where(eq(timelineSlots.id, slotId));
}
for (const [index, slotId] of orderedSlotIds.entries()) {
await transaction
.update(timelineSlots)
.set({ position: index, updatedAt: new Date() })
.where(eq(timelineSlots.id, slotId));
}
await bumpRevision(transaction, timelineId);
}
async function bumpRevision(transaction: ModuleDatabase, timelineId: string): Promise<void> {
await transaction
.update(timelines)
.set({ revision: sql`${timelines.revision} + 1`, updatedAt: new Date() })
.where(eq(timelines.id, timelineId));
}
async function orderedSlotIdsFor(
transaction: ModuleDatabase,
timelineId: string,
): Promise<string[]> {
const rows = await transaction
.select({ id: timelineSlots.id, position: timelineSlots.position })
.from(timelineSlots)
.where(eq(timelineSlots.timelineId, timelineId))
.orderBy(asc(timelineSlots.position));
return rows.map((row) => row.id);
}
async function timelineIdForSlot(
transaction: ModuleDatabase,
slotId: string,
): Promise<string | undefined> {
const rows = await transaction
.select({ timelineId: timelineSlots.timelineId })
.from(timelineSlots)
.where(eq(timelineSlots.id, slotId));
return rows[0]?.timelineId;
}
return {
async addSlot(slot: NewStoredSlot, position?: number): Promise<void> {
await database.transaction(async (transaction) => {
const existing = await orderedSlotIdsFor(transaction, slot.timelineId);
const index =
position === undefined
? existing.length
: Math.max(0, Math.min(position, existing.length));
await transaction.insert(timelineSlots).values(slotValues(slot, -1_000_000));
const next = [...existing.slice(0, index), slot.id, ...existing.slice(index)];
await renumber(transaction, slot.timelineId, next);
});
},
async addSource(source: NewStoredProviderSource): Promise<void> {
await database.insert(providerSources).values({
id: source.id,
label: source.label,
providerId: source.providerId,
selectionScope: source.selectionScope,
sourceId: source.sourceId,
timelineId: source.timelineId,
});
},
async createTimeline(input: {
readonly timeline: NewStoredTimeline;
readonly sources: readonly NewStoredProviderSource[];
readonly slots: readonly NewStoredSlot[];
}): Promise<void> {
await database.transaction(async (transaction) => {
await transaction.insert(timelines).values({
description: input.timeline.description,
eligibleFrom: input.timeline.eligibleFrom,
eligibleUntil: input.timeline.eligibleUntil,
id: input.timeline.id,
name: input.timeline.name,
});
if (input.sources.length > 0) {
await transaction.insert(providerSources).values(
input.sources.map((source) => ({
id: source.id,
label: source.label,
providerId: source.providerId,
selectionScope: source.selectionScope,
sourceId: source.sourceId,
timelineId: source.timelineId,
})),
);
}
if (input.slots.length > 0) {
await transaction
.insert(timelineSlots)
.values(input.slots.map((slot, index) => slotValues(slot, index)));
}
});
},
async deleteTimeline(timelineId: string): Promise<void> {
// Sources and slots cascade with the timeline; resolved selections do not, because history is
// a record of what a screen actually showed.
await database.delete(timelines).where(eq(timelines.id, timelineId));
},
async findTimeline(timelineId: string): Promise<StoredTimelineDetail | undefined> {
const timelineRows = await database
.select()
.from(timelines)
.where(eq(timelines.id, timelineId));
const row = timelineRows[0];
if (!row) {
return undefined;
}
const [sourceRows, slotRows] = await Promise.all([
database
.select()
.from(providerSources)
.where(eq(providerSources.timelineId, timelineId))
.orderBy(asc(providerSources.createdAt)),
database
.select()
.from(timelineSlots)
.where(eq(timelineSlots.timelineId, timelineId))
.orderBy(asc(timelineSlots.position)),
]);
return {
slots: slotRows.map(toSlot),
sources: sourceRows.map(toSource),
timeline: toTimeline(row),
};
},
async listTimelines(): Promise<readonly StoredTimeline[]> {
const rows = await database.select().from(timelines).orderBy(asc(timelines.name));
return rows.map(toTimeline);
},
async removeSlot(slotId: string): Promise<void> {
await database.transaction(async (transaction) => {
const timelineId = await timelineIdForSlot(transaction, slotId);
if (timelineId === undefined) {
return;
}
await transaction.delete(timelineSlots).where(eq(timelineSlots.id, slotId));
await renumber(transaction, timelineId, await orderedSlotIdsFor(transaction, timelineId));
});
},
async removeSource(sourceId: string): Promise<void> {
await database.transaction(async (transaction) => {
const rows = await transaction
.select({ timelineId: providerSources.timelineId })
.from(providerSources)
.where(eq(providerSources.id, sourceId));
const timelineId = rows[0]?.timelineId;
if (timelineId === undefined) {
return;
}
// Slots referencing the source cascade with it; the remainder is renumbered so the pattern
// an operator reads never has a hole in it.
await transaction.delete(providerSources).where(eq(providerSources.id, sourceId));
await renumber(transaction, timelineId, await orderedSlotIdsFor(transaction, timelineId));
});
},
async reorderSlots(timelineId: string, orderedSlotIds: readonly string[]): Promise<void> {
await database.transaction(async (transaction) => {
const current = await orderedSlotIdsFor(transaction, timelineId);
const given = new Set(orderedSlotIds);
// Exhaustive and exact: anything else could lose a slot, duplicate one, or leave a gap.
if (given.size !== orderedSlotIds.length || given.size !== current.length) {
throw new SignageStoreConflict("timeline.slotOrder");
}
for (const slotId of current) {
if (!given.has(slotId)) {
throw new SignageStoreConflict("timeline.slotOrder");
}
}
await renumber(transaction, timelineId, orderedSlotIds);
});
},
async timelineUsage(timelineId: string): Promise<StoredTimelineUsage> {
const rows = await database
.select({
activeTimelineId: playbackStates.activeTimelineId,
pendingTimelineId: playbackStates.pendingTimelineId,
scheduledTimelineId: playbackStates.scheduledTimelineId,
})
.from(playbackStates)
.where(
or(
eq(playbackStates.activeTimelineId, timelineId),
eq(playbackStates.pendingTimelineId, timelineId),
eq(playbackStates.scheduledTimelineId, timelineId),
),
);
return {
activeGroups: rows.filter((row) => row.activeTimelineId === timelineId).length,
pendingGroups: rows.filter((row) => row.pendingTimelineId === timelineId).length,
scheduledGroups: rows.filter((row) => row.scheduledTimelineId === timelineId).length,
};
},
async updateSlot(slotId: string, changes: SlotChanges): Promise<void> {
await database.transaction(async (transaction) => {
const timelineId = await timelineIdForSlot(transaction, slotId);
if (timelineId === undefined) {
return;
}
const values: Record<string, unknown> = { updatedAt: new Date() };
if (changes.enabled !== undefined) {
values.enabled = changes.enabled;
}
if (changes.durationOverrideMs !== undefined) {
values.durationOverrideMs = changes.durationOverrideMs;
}
if (changes.eligibleFrom !== undefined) {
values.eligibleFrom = changes.eligibleFrom;
}
if (changes.eligibleUntil !== undefined) {
values.eligibleUntil = changes.eligibleUntil;
}
if (changes.itemId !== undefined) {
values.itemId = changes.itemId;
}
const presentation = changes.presentation;
if (presentation) {
if (presentation.backgroundMode !== undefined) {
values.backgroundMode = presentation.backgroundMode;
}
if (presentation.backgroundColor !== undefined) {
values.backgroundColor = presentation.backgroundColor;
}
if (presentation.backgroundDarkened !== undefined) {
values.backgroundDarkened = presentation.backgroundDarkened;
}
if (presentation.blurPixels !== undefined) {
values.blurPixels = presentation.blurPixels;
}
if (presentation.foregroundShadow !== undefined) {
values.foregroundShadow = presentation.foregroundShadow;
}
}
await transaction.update(timelineSlots).set(values).where(eq(timelineSlots.id, slotId));
await bumpRevision(transaction, timelineId);
});
},
async updateTimeline(timelineId: string, changes: TimelineChanges): Promise<void> {
const values: Record<string, unknown> = {
revision: sql`${timelines.revision} + 1`,
updatedAt: new Date(),
};
if (changes.name !== undefined) {
values.name = changes.name;
}
if (changes.description !== undefined) {
values.description = changes.description;
}
if (changes.eligibleFrom !== undefined) {
values.eligibleFrom = changes.eligibleFrom;
}
if (changes.eligibleUntil !== undefined) {
values.eligibleUntil = changes.eligibleUntil;
}
await database.update(timelines).set(values).where(eq(timelines.id, timelineId));
},
};
}
+99
View File
@@ -0,0 +1,99 @@
import type { ModuleDatabase } from "@ekklesia/module-sdk";
import { asc, eq } from "drizzle-orm";
import { viewers } from "../../schema/tables.js";
import type { NewStoredViewer, SignageViewerStore, StoredViewer, ViewerChanges } from "./types.js";
/** The viewer half of Signage's production store. Deliberately dull: heartbeat coalescing, health
* derivation, and every other decision live above it. */
function toViewer(row: typeof viewers.$inferSelect): StoredViewer {
return {
cachedAssetCount: row.cachedAssetCount,
channelId: row.channelId,
createdAt: row.createdAt,
deviceId: row.deviceId,
id: row.id,
lastError:
row.lastErrorCode === null || row.lastErrorAt === null
? null
: { at: row.lastErrorAt, code: row.lastErrorCode, detail: row.lastErrorDetail },
lastSeenAt: row.lastSeenAt,
name: row.name,
offlineReady: row.offlineReady,
reportedContributionId: row.reportedContributionId,
reportedPlaybackRevision: row.reportedPlaybackRevision,
reportedProviderId: row.reportedProviderId,
reportedSelectionId: row.reportedSelectionId,
reportedTimelineId: row.reportedTimelineId,
viewerKey: row.viewerKey,
};
}
export function createViewerStore(database: ModuleDatabase): SignageViewerStore {
return {
async deleteViewer(viewerId: string): Promise<void> {
await database.delete(viewers).where(eq(viewers.id, viewerId));
},
async findViewer(viewerId: string): Promise<StoredViewer | undefined> {
const rows = await database.select().from(viewers).where(eq(viewers.id, viewerId));
const row = rows[0];
return row ? toViewer(row) : undefined;
},
async findViewerByKey(viewerKey: string): Promise<StoredViewer | undefined> {
const rows = await database.select().from(viewers).where(eq(viewers.viewerKey, viewerKey));
const row = rows[0];
return row ? toViewer(row) : undefined;
},
async insertViewer(viewer: NewStoredViewer): Promise<void> {
await database.insert(viewers).values({
cachedAssetCount: viewer.cachedAssetCount,
channelId: viewer.channelId,
deviceId: viewer.deviceId,
id: viewer.id,
name: viewer.name,
offlineReady: viewer.offlineReady,
reportedContributionId: viewer.reportedContributionId,
reportedPlaybackRevision: viewer.reportedPlaybackRevision,
reportedProviderId: viewer.reportedProviderId,
reportedSelectionId: viewer.reportedSelectionId,
reportedTimelineId: viewer.reportedTimelineId,
viewerKey: viewer.viewerKey,
});
},
async listViewers(): Promise<readonly StoredViewer[]> {
const rows = await database.select().from(viewers).orderBy(asc(viewers.name));
return rows.map(toViewer);
},
async updateViewer(viewerId: string, changes: ViewerChanges): Promise<void> {
const values: Record<string, unknown> = { updatedAt: new Date() };
for (const key of [
"cachedAssetCount",
"channelId",
"deviceId",
"lastSeenAt",
"name",
"offlineReady",
"reportedContributionId",
"reportedPlaybackRevision",
"reportedProviderId",
"reportedSelectionId",
"reportedTimelineId",
] as const) {
if (changes[key] !== undefined) {
values[key] = changes[key];
}
}
if (changes.lastError !== undefined) {
values.lastErrorAt = changes.lastError?.at ?? null;
values.lastErrorCode = changes.lastError?.code ?? null;
values.lastErrorDetail = changes.lastError?.detail ?? null;
}
await database.update(viewers).set(values).where(eq(viewers.id, viewerId));
},
};
}
+269
View File
@@ -0,0 +1,269 @@
import { randomUUID } from "node:crypto";
import type { ModuleDatabase } from "@ekklesia/module-sdk";
import { and, desc, eq, inArray } from "drizzle-orm";
import {
imageAssets,
imageRenditions,
playbackStates,
timelineSlots,
} from "../../schema/tables.js";
import { SIGNAGE_NATIVE_PROVIDER_ID } from "../../shared/native-images.js";
import { createPlaybackStore } from "./drizzle-playback.js";
import { createTimelineStore } from "./drizzle-timelines.js";
import { createViewerStore } from "./drizzle-viewers.js";
import {
type NewStoredImage,
type SignageImageStore,
type SignageStore,
SignageStoreConflict,
type StoredImage,
type StoredImageReferences,
type StoredImageRendition,
} from "./types.js";
/**
* The production implementation of Signage's data port, over the platform's `ModuleDatabase`.
*
* This file is deliberately dull: it maps rows and nothing else. Every decision — what to write,
* when a revision changes, what a no-op is — lives in the services above it, so that behaviour can
* be proven without PostgreSQL and this mapping can be proven against a live one.
*
* The facade is the only way Signage reaches PostgreSQL. Nothing here constructs a client, reads a
* connection string, or touches another module's table.
*/
const assetColumns = {
contentSha256: imageAssets.contentSha256,
createdAt: imageAssets.createdAt,
displayName: imageAssets.displayName,
encodedHeight: imageAssets.encodedHeight,
encodedWidth: imageAssets.encodedWidth,
expiresAt: imageAssets.expiresAt,
hasAlpha: imageAssets.hasAlpha,
height: imageAssets.height,
id: imageAssets.id,
orientation: imageAssets.orientation,
originalByteSize: imageAssets.originalByteSize,
originalContentType: imageAssets.originalContentType,
originalFilename: imageAssets.originalFilename,
originalObjectKey: imageAssets.originalObjectKey,
status: imageAssets.status,
width: imageAssets.width,
};
type AssetRow = {
[Key in keyof typeof assetColumns]: (typeof imageAssets.$inferSelect)[Key];
};
const renditionColumns = {
assetId: imageRenditions.assetId,
byteSize: imageRenditions.byteSize,
contentSha256: imageRenditions.contentSha256,
contentType: imageRenditions.contentType,
height: imageRenditions.height,
kind: imageRenditions.kind,
objectKey: imageRenditions.objectKey,
width: imageRenditions.width,
};
/** PostgreSQL reports a uniqueness violation as SQLSTATE `23505`. The code is all that is read; the
* driver's message, detail, and constraint text are never surfaced. */
function isUniqueViolation(error: unknown): boolean {
return (
typeof error === "object" && error !== null && (error as { code?: unknown }).code === "23505"
);
}
function toRendition(row: {
byteSize: number;
contentSha256: string;
contentType: string;
height: number;
kind: string;
objectKey: string;
width: number;
}): StoredImageRendition {
return {
byteSize: row.byteSize,
contentSha256: row.contentSha256,
contentType: row.contentType,
height: row.height,
kind: row.kind === "thumbnail" ? "thumbnail" : "display",
objectKey: row.objectKey,
width: row.width,
};
}
function toImage(row: AssetRow, renditions: readonly StoredImageRendition[]): StoredImage {
return {
contentSha256: row.contentSha256,
createdAt: row.createdAt,
displayName: row.displayName,
encodedHeight: row.encodedHeight,
encodedWidth: row.encodedWidth,
expiresAt: row.expiresAt,
hasAlpha: row.hasAlpha,
height: row.height,
id: row.id,
orientation: row.orientation,
originalByteSize: row.originalByteSize,
originalContentType: row.originalContentType,
originalFilename: row.originalFilename,
originalObjectKey: row.originalObjectKey,
renditions,
status: row.status === "ready" ? "ready" : row.status === "failed" ? "failed" : "pending",
width: row.width,
};
}
export function createImageStore(database: ModuleDatabase): SignageImageStore {
async function renditionsFor(
assetIds: readonly string[],
): Promise<Map<string, StoredImageRendition[]>> {
const grouped = new Map<string, StoredImageRendition[]>();
if (assetIds.length === 0) {
return grouped;
}
const rows = await database
.select(renditionColumns)
.from(imageRenditions)
.where(inArray(imageRenditions.assetId, [...assetIds]));
for (const row of rows) {
const list = grouped.get(row.assetId) ?? [];
list.push(toRendition(row));
grouped.set(row.assetId, list);
}
for (const list of grouped.values()) {
list.sort((left, right) => left.kind.localeCompare(right.kind));
}
return grouped;
}
async function hydrate(rows: readonly AssetRow[]): Promise<StoredImage[]> {
const renditions = await renditionsFor(rows.map((row) => row.id));
return rows.map((row) => toImage(row, renditions.get(row.id) ?? []));
}
return {
async countImageReferences(imageId: string): Promise<StoredImageReferences> {
const [slots, holds] = await Promise.all([
database
.select({ id: timelineSlots.id })
.from(timelineSlots)
.where(
and(
eq(timelineSlots.providerId, SIGNAGE_NATIVE_PROVIDER_ID),
eq(timelineSlots.itemId, imageId),
),
),
database
.select({ groupId: playbackStates.groupId })
.from(playbackStates)
.where(eq(playbackStates.staticAssetId, imageId)),
]);
return { slots: slots.length, staticHolds: holds.length };
},
async deleteImage(imageId: string): Promise<void> {
// Renditions cascade with the asset, so one delete is the whole removal.
await database.delete(imageAssets).where(eq(imageAssets.id, imageId));
},
async findImage(imageId: string): Promise<StoredImage | undefined> {
const rows = (await database
.select(assetColumns)
.from(imageAssets)
.where(eq(imageAssets.id, imageId))) as AssetRow[];
return (await hydrate(rows))[0];
},
async findReadyImageByContentHash(contentSha256: string): Promise<StoredImage | undefined> {
const rows = (await database
.select(assetColumns)
.from(imageAssets)
.where(
and(eq(imageAssets.contentSha256, contentSha256), eq(imageAssets.status, "ready")),
)) as AssetRow[];
return (await hydrate(rows))[0];
},
async insertImage(image: NewStoredImage): Promise<void> {
try {
await database.transaction(async (transaction) => {
await transaction.insert(imageAssets).values({
contentSha256: image.contentSha256,
displayName: image.displayName,
encodedHeight: image.encodedHeight,
encodedWidth: image.encodedWidth,
expiresAt: image.expiresAt,
hasAlpha: image.hasAlpha,
height: image.height,
id: image.id,
orientation: image.orientation,
originalByteSize: image.originalByteSize,
originalContentType: image.originalContentType,
originalFilename: image.originalFilename,
originalObjectKey: image.originalObjectKey,
status: image.status,
width: image.width,
});
await transaction.insert(imageRenditions).values(
image.renditions.map((rendition) => ({
assetId: image.id,
byteSize: rendition.byteSize,
contentSha256: rendition.contentSha256,
contentType: rendition.contentType,
height: rendition.height,
id: randomUUID(),
kind: rendition.kind,
objectKey: rendition.objectKey,
width: rendition.width,
})),
);
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new SignageStoreConflict("image.contentSha256");
}
throw error;
}
},
async listImages(): Promise<readonly StoredImage[]> {
const rows = (await database
.select(assetColumns)
.from(imageAssets)
.orderBy(desc(imageAssets.createdAt))) as AssetRow[];
return hydrate(rows);
},
async updateImage(
imageId: string,
changes: { readonly displayName?: string; readonly expiresAt?: Date | null },
): Promise<void> {
const values: Record<string, unknown> = { updatedAt: new Date() };
if (changes.displayName !== undefined) {
values.displayName = changes.displayName;
}
if (changes.expiresAt !== undefined) {
values.expiresAt = changes.expiresAt;
}
await database.update(imageAssets).set(values).where(eq(imageAssets.id, imageId));
},
};
}
/**
* Signage's complete production store: the image half above, plus the timeline half beside it.
*
* Composed rather than written as one object because the two halves share nothing but the database
* handle, and keeping them separate is what lets each be read on its own.
*/
export function createSignageStore(database: ModuleDatabase): SignageStore {
return {
...createImageStore(database),
...createTimelineStore(database),
...createPlaybackStore(database),
...createViewerStore(database),
};
}
+447
View File
@@ -0,0 +1,447 @@
/**
* Signage's narrow data port.
*
* Every piece of Signage's behaviour — ingest, timeline editing, provider resolution, authoritative
* playback, viewer health — is written against this interface rather than against Drizzle directly.
* There are two implementations: the production one over the platform's `ModuleDatabase`
* (`./drizzle.ts`), and an in-memory one used by tests.
*
* This is the same discipline Core's own migration runner follows, and it exists for the same
* reason: the behaviour worth proving is *what Signage decides*, and proving that against a real
* PostgreSQL for every case would make the interesting tests slow, flaky, and rare. The Drizzle
* implementation is exercised separately, against a live disposable database, where what is being
* proven is the mapping rather than the decision.
*
* The port is deliberately **narrow and domain-shaped**. It exposes the operations Signage performs,
* not a query language: there is no way to express an arbitrary join, and no way for a caller to
* reach a table this file does not name.
*/
export interface StoredImageRendition {
readonly kind: "display" | "thumbnail";
readonly objectKey: string;
readonly contentType: string;
readonly contentSha256: string;
readonly byteSize: number;
readonly width: number;
readonly height: number;
}
export interface StoredImage {
readonly id: string;
readonly contentSha256: string;
readonly displayName: string;
readonly originalFilename: string;
readonly originalObjectKey: string;
readonly originalContentType: string;
readonly originalByteSize: number;
/** How the original should appear once its own orientation is applied. */
readonly width: number;
readonly height: number;
readonly encodedWidth: number;
readonly encodedHeight: number;
readonly orientation: number | null;
readonly hasAlpha: boolean;
readonly status: "pending" | "ready" | "failed";
/** Null is permanent. */
readonly expiresAt: Date | null;
readonly createdAt: Date;
readonly renditions: readonly StoredImageRendition[];
}
export interface NewStoredImage extends Omit<StoredImage, "createdAt" | "status"> {
readonly status: "ready";
}
export interface StoredImageReferences {
/** Timeline slots pinned to this image. */
readonly slots: number;
/** Channel groups currently holding this image as a static hold. */
readonly staticHolds: number;
}
export interface SignageImageStore {
listImages(): Promise<readonly StoredImage[]>;
findImage(imageId: string): Promise<StoredImage | undefined>;
/** Content-addressed lookup, restricted to `ready` assets: an asset whose ingest failed must not
* make a retry of the same bytes a no-op. */
findReadyImageByContentHash(contentSha256: string): Promise<StoredImage | undefined>;
/**
* Inserts the asset and every rendition **atomically**. A partially inserted asset would be
* selectable by playback with renditions that do not exist.
*
* Rejects with {@link SignageStoreConflict} when an asset with the same content hash already
* exists, so a race between two identical uploads resolves to one asset rather than to a
* corrupted pair.
*/
insertImage(image: NewStoredImage): Promise<void>;
/** Applies only the fields present. An exact no-op is the caller's to detect, not the store's. */
updateImage(
imageId: string,
changes: { readonly displayName?: string; readonly expiresAt?: Date | null },
): Promise<void>;
deleteImage(imageId: string): Promise<void>;
countImageReferences(imageId: string): Promise<StoredImageReferences>;
}
/** Thrown by a store implementation when a uniqueness guarantee the database owns was violated.
* Carries no driver message, no SQL, and no constraint name — only which guarantee it was. */
export class SignageStoreConflict extends Error {
readonly guarantee: string;
constructor(guarantee: string) {
super("A Signage uniqueness guarantee was violated.");
this.name = "SignageStoreConflict";
this.guarantee = guarantee;
this.stack = `${this.name}: ${this.message}`;
}
}
// -------------------------------------------------------------------------------------------------
// Timelines, provider sources, and slots.
// -------------------------------------------------------------------------------------------------
export interface StoredTimeline {
readonly id: string;
readonly name: string;
readonly description: string | null;
/** Half-open `[eligibleFrom, eligibleUntil)`. */
readonly eligibleFrom: Date | null;
readonly eligibleUntil: Date | null;
/** Increments only on a semantic change, so a client can compare and refetch. */
readonly revision: number;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface StoredProviderSource {
readonly id: string;
readonly timelineId: string;
/** Another module's opaque identity. Never a foreign key. */
readonly providerId: string;
readonly sourceId: string;
readonly label: string;
/** Stable and opaque. Every slot referencing this row passes it to `resolveNext`, which is what
* makes repeated slots one rotation rather than several (ADR 0003). */
readonly selectionScope: string;
readonly createdAt: Date;
}
export type StoredSlotKind = "provider-source" | "pinned-item";
/** Native-image presentation overrides. Every field is `null` for "take the installation default",
* and the whole group is meaningless for a slot whose content comes from another module — an
* external provider owns its rendering completely. */
export interface StoredSlotPresentation {
readonly backgroundMode: "blurred" | "solid" | "black" | null;
readonly backgroundColor: string | null;
readonly backgroundDarkened: boolean | null;
readonly blurPixels: number | null;
readonly foregroundShadow: boolean | null;
}
export interface StoredSlot {
readonly id: string;
readonly timelineId: string;
/** Unique within the timeline and contiguous from zero. */
readonly position: number;
readonly kind: StoredSlotKind;
/** Set for a `provider-source` slot. */
readonly sourceId: string | null;
/** Set for a `pinned-item` slot. Both opaque. */
readonly providerId: string | null;
readonly itemId: string | null;
readonly enabled: boolean;
readonly durationOverrideMs: number | null;
readonly eligibleFrom: Date | null;
readonly eligibleUntil: Date | null;
readonly presentation: StoredSlotPresentation;
}
/** A timeline and everything in it, in one read. Slots are ordered by position. */
export interface StoredTimelineDetail {
readonly timeline: StoredTimeline;
readonly sources: readonly StoredProviderSource[];
readonly slots: readonly StoredSlot[];
}
export type NewStoredTimeline = Omit<StoredTimeline, "createdAt" | "revision" | "updatedAt">;
export type NewStoredProviderSource = Omit<StoredProviderSource, "createdAt">;
/** Position is assigned by the store, which is what keeps positions contiguous without every caller
* having to know the current length. */
export type NewStoredSlot = Omit<StoredSlot, "position">;
export interface TimelineChanges {
readonly name?: string;
readonly description?: string | null;
readonly eligibleFrom?: Date | null;
readonly eligibleUntil?: Date | null;
}
export interface SlotChanges {
readonly enabled?: boolean;
readonly durationOverrideMs?: number | null;
readonly eligibleFrom?: Date | null;
readonly eligibleUntil?: Date | null;
readonly presentation?: Partial<StoredSlotPresentation>;
readonly itemId?: string;
}
/** Where a timeline is currently referenced by live playback. Reported so a delete can refuse with
* something an operator can act on rather than cascading silently. */
export interface StoredTimelineUsage {
readonly activeGroups: number;
readonly pendingGroups: number;
readonly scheduledGroups: number;
}
export interface SignageTimelineStore {
listTimelines(): Promise<readonly StoredTimeline[]>;
findTimeline(timelineId: string): Promise<StoredTimelineDetail | undefined>;
/**
* Creates a timeline together with every source and slot it starts with, **atomically**.
*
* One operation rather than three, because that is what makes duplication safe: a copy that
* committed its timeline and then failed on its slots would be a half-copy an operator has to
* find and clean up. Slot positions are assigned from the order given.
*/
createTimeline(input: {
readonly timeline: NewStoredTimeline;
readonly sources: readonly NewStoredProviderSource[];
readonly slots: readonly NewStoredSlot[];
}): Promise<void>;
/** Applies only the fields present, and bumps the revision. An exact no-op is the caller's to
* detect: the store does not guess what "changed" means. */
updateTimeline(timelineId: string, changes: TimelineChanges): Promise<void>;
deleteTimeline(timelineId: string): Promise<void>;
timelineUsage(timelineId: string): Promise<StoredTimelineUsage>;
addSource(source: NewStoredProviderSource): Promise<void>;
/** Removes the source and every slot that referenced it, then renumbers what remains. */
removeSource(sourceId: string): Promise<void>;
/** Appends a slot at the end, or inserts it at `position` and shifts the rest. */
addSlot(slot: NewStoredSlot, position?: number): Promise<void>;
updateSlot(slotId: string, changes: SlotChanges): Promise<void>;
/** Removes the slot and renumbers the remainder so positions stay contiguous. */
removeSlot(slotId: string): Promise<void>;
/**
* Rewrites the whole order.
*
* All-or-nothing and exhaustive: the given ids must be exactly the timeline's current slots, so a
* reorder can never lose a slot, duplicate one, or leave a gap. Rejects with
* {@link SignageStoreConflict} otherwise.
*/
reorderSlots(timelineId: string, orderedSlotIds: readonly string[]): Promise<void>;
}
/** The complete port. It grows one section at a time as Signage's behaviour does. */
// -------------------------------------------------------------------------------------------------
// Channel groups, channels, playback, and resolved selections.
// -------------------------------------------------------------------------------------------------
export interface StoredChannel {
readonly id: string;
readonly groupId: string;
/** The audience key handed to a provider. The base channel's is `default`. */
readonly key: string;
readonly name: string;
readonly isDefault: boolean;
}
export interface StoredChannelGroup {
readonly id: string;
readonly name: string;
readonly revision: number;
}
export type StoredTransport = "idle" | "playing" | "paused" | "static";
/** One channel group's authoritative clock. Every instant is absolute (ADR 0005). */
export interface StoredPlaybackState {
readonly groupId: string;
readonly activeTimelineId: string | null;
readonly pendingTimelineId: string | null;
readonly pendingRequestedAt: Date | null;
readonly scheduledTimelineId: string | null;
readonly scheduledActivationAt: Date | null;
readonly currentSelectionId: string | null;
readonly currentSlotId: string | null;
readonly slotStartedAt: Date | null;
readonly slotDeadlineAt: Date | null;
readonly pausedRemainingMs: number | null;
readonly paused: boolean;
readonly blackout: boolean;
readonly staticAssetId: string | null;
readonly revision: number;
readonly nextSequence: number;
readonly updatedAt: Date;
}
export interface StoredChannelGroupDetail {
readonly group: StoredChannelGroup;
readonly channels: readonly StoredChannel[];
readonly playback: StoredPlaybackState;
}
export type PlaybackChanges = Partial<
Omit<StoredPlaybackState, "groupId" | "revision" | "updatedAt">
>;
export interface StoredSelectionVariant {
readonly audienceKey: string;
readonly isDefault: boolean;
readonly payload: unknown;
}
/** One logical selection, resolved once for a whole channel group. */
export interface StoredSelection {
readonly id: string;
readonly groupId: string;
readonly sequence: number;
readonly timelineId: string | null;
readonly slotId: string | null;
readonly selectionScope: string | null;
readonly providerId: string;
readonly itemId: string;
readonly itemRevision: string;
readonly rendererContributionId: string | null;
readonly rendererContributionVersion: string | null;
readonly presentation: unknown;
readonly durationMs: number;
readonly resolvedAt: Date;
readonly startedAt: Date | null;
readonly deadlineAt: Date | null;
readonly availableUntil: Date | null;
readonly withdrawn: boolean;
readonly stale: boolean;
/** Increments when a refresh changes payloads without changing which item this is — the change
* that updates a mounted renderer in place rather than restarting its slot. */
readonly revision: number;
readonly variants: readonly StoredSelectionVariant[];
}
export type NewStoredSelection = Omit<StoredSelection, "resolvedAt" | "revision">;
export interface SelectionChanges {
readonly startedAt?: Date | null;
readonly deadlineAt?: Date | null;
readonly availableUntil?: Date | null;
readonly withdrawn?: boolean;
readonly stale?: boolean;
readonly durationMs?: number;
readonly itemRevision?: string;
readonly presentation?: unknown;
/** Replaces the whole variant set. A partial update would leave an audience rendering a payload
* from a revision that no longer exists. */
readonly variants?: readonly StoredSelectionVariant[];
/** Increments the selection's revision, which is what tells a mounted renderer to update. */
readonly bumpRevision?: boolean;
}
export interface SignagePlaybackStore {
listChannelGroups(): Promise<readonly StoredChannelGroupDetail[]>;
findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined>;
/** Creates a group, its playback row, and its one default channel, atomically. A group without a
* default channel would have nowhere for a viewer to land. */
createChannelGroup(input: {
readonly groupId: string;
readonly name: string;
readonly channelId: string;
readonly channelName: string;
}): Promise<void>;
deleteChannelGroup(groupId: string): Promise<void>;
addChannel(channel: StoredChannel): Promise<void>;
/** Refuses to remove a group's default channel: something must remain for a viewer to join. */
removeChannel(channelId: string): Promise<void>;
/** Applies only the fields present, and bumps the group's playback revision. */
updatePlayback(
groupId: string,
changes: PlaybackChanges,
options?: { readonly bumpRevision?: boolean },
): Promise<void>;
/** Takes the next sequence number for this group and increments it, atomically. */
takeSelectionSequence(groupId: string): Promise<number>;
insertSelection(selection: NewStoredSelection): Promise<void>;
findSelection(selectionId: string): Promise<StoredSelection | undefined>;
updateSelection(selectionId: string, changes: SelectionChanges): Promise<void>;
/** Most recent first, bounded. This is the history `previous` walks. */
listSelections(groupId: string, limit: number): Promise<readonly StoredSelection[]>;
/** The most recent selection for one selection scope, so a provider's rotation can be re-seeded
* from what actually played rather than from the beginning. */
findLatestSelectionForScope(
groupId: string,
selectionScope: string,
): Promise<StoredSelection | undefined>;
/** Keeps the newest `limit` selections for a group and removes the rest. */
pruneSelections(groupId: string, limit: number): Promise<void>;
}
/** The complete port. It grows one section at a time as Signage's behaviour does. */
// -------------------------------------------------------------------------------------------------
// Viewers.
// -------------------------------------------------------------------------------------------------
/** A sanitized report from a display. Never an exception, a stack, a URL, or a payload. */
export interface StoredViewerError {
readonly code: string;
readonly detail: string | null;
readonly at: Date;
}
export interface StoredViewer {
readonly id: string;
/** The persistent identity a display keeps in its own IndexedDB, so a reconnect is recognized as
* the same screen rather than a new one. */
readonly viewerKey: string;
readonly name: string;
/** The **safe** Core device identifier a paired display's principal carries — an opaque
* correlation value. Signage never sees, stores, or handles a device credential. */
readonly deviceId: string | null;
readonly channelId: string | null;
readonly lastSeenAt: Date | null;
readonly reportedTimelineId: string | null;
readonly reportedSelectionId: string | null;
readonly reportedProviderId: string | null;
readonly reportedContributionId: string | null;
readonly reportedPlaybackRevision: number | null;
readonly offlineReady: boolean;
readonly cachedAssetCount: number;
readonly lastError: StoredViewerError | null;
readonly createdAt: Date;
}
export type NewStoredViewer = Omit<StoredViewer, "createdAt" | "lastSeenAt" | "lastError">;
export interface ViewerChanges {
readonly name?: string;
readonly channelId?: string | null;
readonly deviceId?: string | null;
readonly lastSeenAt?: Date;
readonly reportedTimelineId?: string | null;
readonly reportedSelectionId?: string | null;
readonly reportedProviderId?: string | null;
readonly reportedContributionId?: string | null;
readonly reportedPlaybackRevision?: number | null;
readonly offlineReady?: boolean;
readonly cachedAssetCount?: number;
readonly lastError?: StoredViewerError | null;
}
export interface SignageViewerStore {
listViewers(): Promise<readonly StoredViewer[]>;
findViewer(viewerId: string): Promise<StoredViewer | undefined>;
findViewerByKey(viewerKey: string): Promise<StoredViewer | undefined>;
insertViewer(viewer: NewStoredViewer): Promise<void>;
updateViewer(viewerId: string, changes: ViewerChanges): Promise<void>;
deleteViewer(viewerId: string): Promise<void>;
}
/** The complete port. It grows one section at a time as Signage's behaviour does. */
export type SignageStore = SignageImageStore &
SignagePlaybackStore &
SignageTimelineStore &
SignageViewerStore;
+429
View File
@@ -0,0 +1,429 @@
import { randomUUID } from "node:crypto";
import { SIGNAGE_HEX_COLOR } from "../../shared/settings.js";
import { SignageError } from "../errors.js";
import {
type NewStoredProviderSource,
type NewStoredSlot,
type SignageStore,
SignageStoreConflict,
type SlotChanges,
type StoredSlotPresentation,
type StoredTimelineDetail,
type TimelineChanges,
} from "../store/types.js";
/**
* Timeline, source, and slot management.
*
* The rules that matter are here rather than in the routes, because they are decisions rather than
* transport: what a duplicate copies and what it deliberately does not, when an edit is a no-op,
* and what a delete refuses.
*/
/** Bounds an operator can actually hit. Generous for a real church, small enough that a timeline
* stays something a person can read. */
export const SIGNAGE_TIMELINE_LIMITS = Object.freeze({
maxNameLength: 120,
maxSlotDurationMs: 3_600_000,
maxSlotsPerTimeline: 200,
maxSourcesPerTimeline: 50,
minSlotDurationMs: 1000,
});
export interface TimelineIdentitySource {
/** Overridable so a test can pin identity. Production takes fresh UUIDs. */
readonly newId?: () => string;
}
function nextId(input: TimelineIdentitySource): string {
return (input.newId ?? randomUUID)();
}
function requireInterval(from: Date | null | undefined, until: Date | null | undefined): void {
if (from && until && from.getTime() >= until.getTime()) {
// Half-open `[from, until)`: an empty or inverted window is a mistake an operator wants to hear
// about now rather than discover as a slot that never appears.
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "eligibility" });
}
}
function normalizePresentation(
input: Partial<StoredSlotPresentation> | undefined,
): StoredSlotPresentation {
const presentation: StoredSlotPresentation = {
backgroundColor: input?.backgroundColor ?? null,
backgroundDarkened: input?.backgroundDarkened ?? null,
backgroundMode: input?.backgroundMode ?? null,
blurPixels: input?.blurPixels ?? null,
foregroundShadow: input?.foregroundShadow ?? null,
};
if (
presentation.backgroundColor !== null &&
!SIGNAGE_HEX_COLOR.test(presentation.backgroundColor)
) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "backgroundColor" });
}
if (
presentation.blurPixels !== null &&
(!Number.isSafeInteger(presentation.blurPixels) ||
presentation.blurPixels < 0 ||
presentation.blurPixels > 120)
) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "blurPixels" });
}
return presentation;
}
export interface CreateTimelineInput extends TimelineIdentitySource {
readonly store: SignageStore;
readonly name: string;
readonly description?: string | null;
readonly eligibleFrom?: Date | null;
readonly eligibleUntil?: Date | null;
}
export async function createTimeline(input: CreateTimelineInput): Promise<StoredTimelineDetail> {
requireInterval(input.eligibleFrom, input.eligibleUntil);
const id = nextId(input);
await input.store.createTimeline({
slots: [],
sources: [],
timeline: {
description: input.description ?? null,
eligibleFrom: input.eligibleFrom ?? null,
eligibleUntil: input.eligibleUntil ?? null,
id,
name: input.name,
},
});
return requireTimeline(input.store, id);
}
export async function requireTimeline(
store: SignageStore,
timelineId: string,
): Promise<StoredTimelineDetail> {
const detail = await store.findTimeline(timelineId);
if (!detail) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
return detail;
}
/**
* Duplicates a timeline, and gives every copied source a **new selection scope**.
*
* That is the one non-obvious decision here, and it is deliberate. A duplicate is a separate
* program: an operator copying "Sunday Morning" to make "Sunday Evening" expects the two to rotate
* independently, and a shared scope would make the evening loop continue wherever the morning one
* happened to be. Slot ids are new for the same reason — history belongs to the timeline that
* actually played.
*/
export async function duplicateTimeline(input: {
readonly store: SignageStore;
readonly timelineId: string;
readonly name: string;
readonly newId?: () => string;
}): Promise<StoredTimelineDetail> {
const original = await requireTimeline(input.store, input.timelineId);
const newId = input.newId ?? randomUUID;
const timelineId = newId();
const sourceIds = new Map<string, string>();
const sources: NewStoredProviderSource[] = original.sources.map((source) => {
const id = newId();
sourceIds.set(source.id, id);
return {
id,
label: source.label,
providerId: source.providerId,
// A new rotation, not a continuation of the original's.
selectionScope: newId(),
sourceId: source.sourceId,
timelineId,
};
});
const slots: NewStoredSlot[] = original.slots.map((slot) => ({
durationOverrideMs: slot.durationOverrideMs,
eligibleFrom: slot.eligibleFrom,
eligibleUntil: slot.eligibleUntil,
enabled: slot.enabled,
id: newId(),
itemId: slot.itemId,
kind: slot.kind,
presentation: slot.presentation,
providerId: slot.providerId,
sourceId: slot.sourceId === null ? null : (sourceIds.get(slot.sourceId) ?? null),
timelineId,
}));
await input.store.createTimeline({
slots,
sources,
timeline: {
description: original.timeline.description,
eligibleFrom: original.timeline.eligibleFrom,
eligibleUntil: original.timeline.eligibleUntil,
id: timelineId,
name: input.name,
},
});
return requireTimeline(input.store, timelineId);
}
export async function updateTimeline(input: {
readonly store: SignageStore;
readonly timelineId: string;
readonly changes: TimelineChanges;
}): Promise<StoredTimelineDetail> {
const current = await requireTimeline(input.store, input.timelineId);
const changes: TimelineChanges = {};
const timeline = current.timeline;
if (input.changes.name !== undefined && input.changes.name !== timeline.name) {
Object.assign(changes, { name: input.changes.name });
}
if (
input.changes.description !== undefined &&
input.changes.description !== timeline.description
) {
Object.assign(changes, { description: input.changes.description });
}
for (const field of ["eligibleFrom", "eligibleUntil"] as const) {
const next = input.changes[field];
if (next !== undefined && (next?.getTime() ?? null) !== (timeline[field]?.getTime() ?? null)) {
Object.assign(changes, { [field]: next });
}
}
// An exact no-op writes nothing and — crucially — does not bump the revision, so a client polling
// on revision is not woken by an operator saving a form they did not change.
if (Object.keys(changes).length === 0) {
return current;
}
requireInterval(
changes.eligibleFrom === undefined ? timeline.eligibleFrom : changes.eligibleFrom,
changes.eligibleUntil === undefined ? timeline.eligibleUntil : changes.eligibleUntil,
);
await input.store.updateTimeline(input.timelineId, changes);
return requireTimeline(input.store, input.timelineId);
}
/**
* Deletes a timeline, and refuses while playback still points at it.
*
* A timeline deleted out from under a running channel group would blank a wall mid-service. Refusing
* with counts is something an operator can act on: activate something else first.
*/
export async function deleteTimeline(input: {
readonly store: SignageStore;
readonly timelineId: string;
}): Promise<void> {
await requireTimeline(input.store, input.timelineId);
const usage = await input.store.timelineUsage(input.timelineId);
if (usage.activeGroups > 0 || usage.pendingGroups > 0 || usage.scheduledGroups > 0) {
throw new SignageError("SIGNAGE_CONFLICT", {
activeGroups: usage.activeGroups,
pendingGroups: usage.pendingGroups,
scheduledGroups: usage.scheduledGroups,
});
}
await input.store.deleteTimeline(input.timelineId);
}
export async function addProviderSource(input: {
readonly store: SignageStore;
readonly timelineId: string;
readonly providerId: string;
readonly sourceId: string;
readonly label: string;
readonly newId?: () => string;
}): Promise<StoredTimelineDetail> {
const detail = await requireTimeline(input.store, input.timelineId);
if (detail.sources.length >= SIGNAGE_TIMELINE_LIMITS.maxSourcesPerTimeline) {
throw new SignageError("SIGNAGE_CONFLICT", {
maxSources: SIGNAGE_TIMELINE_LIMITS.maxSourcesPerTimeline,
});
}
const newId = input.newId ?? randomUUID;
await input.store.addSource({
id: newId(),
label: input.label,
providerId: input.providerId,
// Generated here, stable forever, and deliberately not derived from the timeline or slot id:
// either would silently reset a provider's rotation when an operator renamed or reordered
// something (ADR 0003).
selectionScope: newId(),
sourceId: input.sourceId,
timelineId: input.timelineId,
});
return requireTimeline(input.store, input.timelineId);
}
export async function removeProviderSource(input: {
readonly store: SignageStore;
readonly timelineId: string;
readonly sourceId: string;
}): Promise<StoredTimelineDetail> {
const detail = await requireTimeline(input.store, input.timelineId);
if (!detail.sources.some((source) => source.id === input.sourceId)) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
await input.store.removeSource(input.sourceId);
return requireTimeline(input.store, input.timelineId);
}
export interface AddSlotInput extends TimelineIdentitySource {
readonly store: SignageStore;
readonly timelineId: string;
readonly position?: number | undefined;
readonly enabled?: boolean | undefined;
readonly durationOverrideMs?: number | null | undefined;
readonly eligibleFrom?: Date | null | undefined;
readonly eligibleUntil?: Date | null | undefined;
readonly presentation?: Partial<StoredSlotPresentation> | undefined;
/** Exactly one of these two shapes. */
readonly source?: { readonly sourceId: string } | undefined;
readonly pinned?: { readonly providerId: string; readonly itemId: string } | undefined;
}
export async function addSlot(input: AddSlotInput): Promise<StoredTimelineDetail> {
const detail = await requireTimeline(input.store, input.timelineId);
if (detail.slots.length >= SIGNAGE_TIMELINE_LIMITS.maxSlotsPerTimeline) {
throw new SignageError("SIGNAGE_CONFLICT", {
maxSlots: SIGNAGE_TIMELINE_LIMITS.maxSlotsPerTimeline,
});
}
if ((input.source === undefined) === (input.pinned === undefined)) {
// A slot is a provider source or a pinned item. Both or neither is not a slot.
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "kind" });
}
if (input.source && !detail.sources.some((source) => source.id === input.source?.sourceId)) {
// A slot may only reference a source configured on its own timeline.
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "sourceId" });
}
if (
input.durationOverrideMs !== undefined &&
input.durationOverrideMs !== null &&
(!Number.isSafeInteger(input.durationOverrideMs) ||
input.durationOverrideMs < SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs ||
input.durationOverrideMs > SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs)
) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "durationOverrideMs" });
}
requireInterval(input.eligibleFrom, input.eligibleUntil);
await input.store.addSlot(
{
durationOverrideMs: input.durationOverrideMs ?? null,
eligibleFrom: input.eligibleFrom ?? null,
eligibleUntil: input.eligibleUntil ?? null,
enabled: input.enabled ?? true,
id: nextId(input),
itemId: input.pinned?.itemId ?? null,
kind: input.source ? "provider-source" : "pinned-item",
presentation: normalizePresentation(input.presentation),
providerId: input.pinned?.providerId ?? null,
sourceId: input.source?.sourceId ?? null,
timelineId: input.timelineId,
},
input.position,
);
return requireTimeline(input.store, input.timelineId);
}
export async function updateSlot(input: {
readonly store: SignageStore;
readonly timelineId: string;
readonly slotId: string;
readonly changes: SlotChanges;
}): Promise<StoredTimelineDetail> {
const detail = await requireTimeline(input.store, input.timelineId);
const slot = detail.slots.find((candidate) => candidate.id === input.slotId);
if (!slot) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
const changes: SlotChanges = {};
if (input.changes.enabled !== undefined && input.changes.enabled !== slot.enabled) {
Object.assign(changes, { enabled: input.changes.enabled });
}
if (
input.changes.durationOverrideMs !== undefined &&
input.changes.durationOverrideMs !== slot.durationOverrideMs
) {
const value = input.changes.durationOverrideMs;
if (
value !== null &&
(!Number.isSafeInteger(value) ||
value < SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs ||
value > SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs)
) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "durationOverrideMs" });
}
Object.assign(changes, { durationOverrideMs: value });
}
for (const field of ["eligibleFrom", "eligibleUntil"] as const) {
const next = input.changes[field];
if (next !== undefined && (next?.getTime() ?? null) !== (slot[field]?.getTime() ?? null)) {
Object.assign(changes, { [field]: next });
}
}
if (input.changes.itemId !== undefined && input.changes.itemId !== slot.itemId) {
if (slot.kind !== "pinned-item") {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "itemId" });
}
Object.assign(changes, { itemId: input.changes.itemId });
}
if (input.changes.presentation) {
const merged = normalizePresentation({ ...slot.presentation, ...input.changes.presentation });
const changed = (Object.keys(merged) as (keyof StoredSlotPresentation)[]).some(
(key) => merged[key] !== slot.presentation[key],
);
if (changed) {
Object.assign(changes, { presentation: merged });
}
}
if (Object.keys(changes).length === 0) {
return detail;
}
requireInterval(
changes.eligibleFrom === undefined ? slot.eligibleFrom : changes.eligibleFrom,
changes.eligibleUntil === undefined ? slot.eligibleUntil : changes.eligibleUntil,
);
await input.store.updateSlot(input.slotId, changes);
return requireTimeline(input.store, input.timelineId);
}
export async function removeSlot(input: {
readonly store: SignageStore;
readonly timelineId: string;
readonly slotId: string;
}): Promise<StoredTimelineDetail> {
const detail = await requireTimeline(input.store, input.timelineId);
if (!detail.slots.some((slot) => slot.id === input.slotId)) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
await input.store.removeSlot(input.slotId);
return requireTimeline(input.store, input.timelineId);
}
export async function reorderSlots(input: {
readonly store: SignageStore;
readonly timelineId: string;
readonly orderedSlotIds: readonly string[];
}): Promise<StoredTimelineDetail> {
await requireTimeline(input.store, input.timelineId);
try {
await input.store.reorderSlots(input.timelineId, input.orderedSlotIds);
} catch (error) {
if (error instanceof SignageStoreConflict) {
// The order was not exactly this timeline's current slots. Refusing is what makes a reorder
// unable to lose a slot, duplicate one, or leave a hole in the pattern.
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "slotIds" });
}
throw error;
}
return requireTimeline(input.store, input.timelineId);
}
+86
View File
@@ -0,0 +1,86 @@
import { SignageError } from "../errors.js";
import type { SignageStore } from "../store/types.js";
import { describeViewerHealth, updateViewerAssignment, type ViewerHealth } from "./service.js";
/**
* `signage.displays` — sanitized viewer and channel health, and assignment, for another module.
*
* What a consuming module gets is exactly the roster an operator sees in the dashboard: names,
* online state, assignment, what each screen last reported, and its most recent safe error code.
*
* What it deliberately does not get: a device credential, a pairing token, a session, a provider
* payload, an object key, a delivery URL, or any private host internal. `deviceId` is the same
* opaque correlation value Core exposes on a device principal — it identifies a screen, and it
* unlocks nothing.
*/
export const SIGNAGE_DISPLAYS_CAPABILITY_VERSION = "1.0.0";
export interface SignageChannelSummary {
readonly id: string;
readonly groupId: string;
readonly groupName: string;
readonly key: string;
readonly name: string;
readonly isDefault: boolean;
}
export interface SignageDisplaysCapability {
readonly contractVersion: string;
listChannels(): Promise<readonly SignageChannelSummary[]>;
listViewers(): Promise<readonly ViewerHealth[]>;
/** Moves a screen to another channel. Refuses a channel that does not exist rather than leaving a
* display pointed at nothing. */
assignViewer(viewerId: string, channelId: string): Promise<ViewerHealth>;
renameViewer(viewerId: string, name: string): Promise<ViewerHealth>;
}
export function createDisplaysCapability(
store: () => SignageStore,
now: () => Date,
): SignageDisplaysCapability {
async function health(
viewerId: string,
name?: string,
channelId?: string,
): Promise<ViewerHealth> {
const viewer = await updateViewerAssignment({
...(channelId === undefined ? {} : { channelId }),
...(name === undefined ? {} : { name }),
store: store(),
viewerId,
});
return describeViewerHealth(viewer, now().getTime());
}
const capability: SignageDisplaysCapability = {
assignViewer(viewerId: string, channelId: string): Promise<ViewerHealth> {
if (typeof channelId !== "string" || channelId.length === 0) {
return Promise.reject(new SignageError("SIGNAGE_INVALID_REQUEST", { field: "channelId" }));
}
return health(viewerId, undefined, channelId);
},
contractVersion: SIGNAGE_DISPLAYS_CAPABILITY_VERSION,
async listChannels(): Promise<readonly SignageChannelSummary[]> {
const groups = await store().listChannelGroups();
return groups.flatMap((group) =>
group.channels.map((channel) => ({
groupId: group.group.id,
groupName: group.group.name,
id: channel.id,
isDefault: channel.isDefault,
key: channel.key,
name: channel.name,
})),
);
},
async listViewers(): Promise<readonly ViewerHealth[]> {
const atMs = now().getTime();
const viewers = await store().listViewers();
return viewers.map((viewer) => describeViewerHealth(viewer, atMs));
},
renameViewer(viewerId: string, name: string): Promise<ViewerHealth> {
return health(viewerId, name, undefined);
},
};
return Object.freeze(capability);
}
+324
View File
@@ -0,0 +1,324 @@
import { randomUUID } from "node:crypto";
import { SignageError } from "../errors.js";
import type {
SignageStore,
StoredChannel,
StoredViewer,
StoredViewerError,
ViewerChanges,
} from "../store/types.js";
/**
* Viewer registration, assignment, heartbeat, and health (ADR 0004).
*
* Two rules shape everything here.
*
* **A viewer never chooses its channel.** A display registers with the identity it keeps in its own
* IndexedDB and lands on the default channel of the default group. An administrator moves it from
* the dashboard. A public display that could select its own channel would let anyone who reaches the
* page decide what a screen shows.
*
* **A heartbeat is telemetry, never authority.** What a viewer reports about what it is showing is
* recorded so an operator can see it; it never changes playback, and a local cursor is never
* accepted as truth.
*/
/** The longest a viewer's own reported error detail may be. Bounded because it is operator-facing
* text a display supplied, and because an unbounded one is a way to fill a table. */
export const SIGNAGE_VIEWER_ERROR_DETAIL_MAX = 200;
export const SIGNAGE_VIEWER_NAME_MAX = 120;
/** A display is considered offline when nothing has been heard from it for this long. Comfortably
* more than a few missed heartbeats, so a slow network does not make a working screen look dead. */
export const SIGNAGE_VIEWER_OFFLINE_AFTER_MS = 90_000;
export interface ViewerSessionInput {
readonly store: SignageStore;
readonly viewerKey: string;
/** The safe Core device identifier, when the principal is a paired device. Never a credential. */
readonly deviceId: string | null;
readonly at: Date;
readonly newId?: () => string;
}
export interface ViewerSession {
readonly viewer: StoredViewer;
readonly channel: StoredChannel;
readonly groupId: string;
}
const VIEWER_KEY = /^[\w-]{8,128}$/u;
export function isViewerKey(value: unknown): value is string {
return typeof value === "string" && VIEWER_KEY.test(value);
}
/**
* Registers a display, or recognizes one that has been here before.
*
* A returning viewer keeps its assignment, its name, and its history. Nothing about a reconnect
* looks like a new screen, which is what keeps a display roster from filling with ghosts every time
* a television reboots.
*/
export async function openViewerSession(input: ViewerSessionInput): Promise<ViewerSession> {
if (!isViewerKey(input.viewerKey)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "viewerKey" });
}
const newId = input.newId ?? randomUUID;
const groups = await input.store.listChannelGroups();
const group = groups[0];
if (!group) {
throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-channel-group" });
}
const existing = await input.store.findViewerByKey(input.viewerKey);
if (existing) {
const changes: ViewerChanges = { lastSeenAt: input.at };
if (input.deviceId !== null && input.deviceId !== existing.deviceId) {
// A screen that was paired since it last checked in. The identifier is opaque and is stored
// only so an operator can correlate the two rosters.
Object.assign(changes, { deviceId: input.deviceId });
}
if (existing.channelId === null) {
// Its channel was removed. It lands back on a default rather than rendering nothing.
Object.assign(changes, { channelId: defaultChannelOf(group.channels).id });
}
await input.store.updateViewer(existing.id, changes);
const refreshed = await input.store.findViewer(existing.id);
if (!refreshed) {
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
return resolveSession(input.store, refreshed);
}
const channel = defaultChannelOf(group.channels);
const viewer: StoredViewer = {
cachedAssetCount: 0,
channelId: channel.id,
createdAt: input.at,
deviceId: input.deviceId,
id: newId(),
lastError: null,
lastSeenAt: input.at,
name: "New display",
offlineReady: false,
reportedContributionId: null,
reportedPlaybackRevision: null,
reportedProviderId: null,
reportedSelectionId: null,
reportedTimelineId: null,
viewerKey: input.viewerKey,
};
await input.store.insertViewer(viewer);
await input.store.updateViewer(viewer.id, { lastSeenAt: input.at });
return { channel, groupId: group.group.id, viewer };
}
function defaultChannelOf(channels: readonly StoredChannel[]): StoredChannel {
const fallback = channels.find((channel) => channel.isDefault) ?? channels[0];
if (!fallback) {
throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-channel" });
}
return fallback;
}
async function resolveSession(store: SignageStore, viewer: StoredViewer): Promise<ViewerSession> {
const groups = await store.listChannelGroups();
for (const group of groups) {
const channel = group.channels.find((candidate) => candidate.id === viewer.channelId);
if (channel) {
return { channel, groupId: group.group.id, viewer };
}
}
const first = groups[0];
if (!first) {
throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-channel-group" });
}
return { channel: defaultChannelOf(first.channels), groupId: first.group.id, viewer };
}
export interface ViewerHeartbeat {
readonly reportedTimelineId?: string | null | undefined;
readonly reportedSelectionId?: string | null | undefined;
readonly reportedProviderId?: string | null | undefined;
readonly reportedContributionId?: string | null | undefined;
readonly reportedPlaybackRevision?: number | null | undefined;
readonly offlineReady?: boolean | undefined;
readonly cachedAssetCount?: number | undefined;
readonly error?: { readonly code: string; readonly detail?: string | null } | null | undefined;
}
/**
* Records a heartbeat, and **coalesces** it.
*
* A steady display checks in every few seconds. Writing a row every time would turn a wall of
* televisions into a constant stream of PostgreSQL writes that says nothing new. So a write happens
* only when something an operator would notice changed, or when enough time has passed that "last
* seen" would otherwise go stale.
*
* Returns whether a write actually happened, so this is measurable rather than asserted.
*/
export async function recordViewerHeartbeat(input: {
readonly store: SignageStore;
readonly viewerId: string;
readonly heartbeat: ViewerHeartbeat;
readonly at: Date;
readonly minimumWriteIntervalMs: number;
}): Promise<{ readonly written: boolean; readonly viewer: StoredViewer }> {
const viewer = await input.store.findViewer(input.viewerId);
if (!viewer) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
const error = normalizeViewerError(input.heartbeat.error, input.at);
const changed =
fieldChanged(viewer.reportedTimelineId, input.heartbeat.reportedTimelineId) ||
fieldChanged(viewer.reportedSelectionId, input.heartbeat.reportedSelectionId) ||
fieldChanged(viewer.reportedProviderId, input.heartbeat.reportedProviderId) ||
fieldChanged(viewer.reportedContributionId, input.heartbeat.reportedContributionId) ||
fieldChanged(viewer.reportedPlaybackRevision, input.heartbeat.reportedPlaybackRevision) ||
fieldChanged(viewer.offlineReady, input.heartbeat.offlineReady) ||
fieldChanged(viewer.cachedAssetCount, input.heartbeat.cachedAssetCount) ||
(viewer.lastError?.code ?? null) !== (error?.code ?? null);
const staleMs = input.at.getTime() - (viewer.lastSeenAt?.getTime() ?? 0);
if (!changed && staleMs < input.minimumWriteIntervalMs) {
// Nothing new, and "last seen" is recent enough to still be true. Say nothing to the database.
return { viewer, written: false };
}
const changes: ViewerChanges = { lastSeenAt: input.at };
for (const key of [
"cachedAssetCount",
"offlineReady",
"reportedContributionId",
"reportedPlaybackRevision",
"reportedProviderId",
"reportedSelectionId",
"reportedTimelineId",
] as const) {
const value = input.heartbeat[key];
if (value !== undefined) {
Object.assign(changes, { [key]: value });
}
}
if (input.heartbeat.error !== undefined) {
Object.assign(changes, { lastError: error });
}
await input.store.updateViewer(input.viewerId, changes);
const updated = await input.store.findViewer(input.viewerId);
if (!updated) {
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
return { viewer: updated, written: true };
}
function fieldChanged<Value>(current: Value, next: Value | undefined): boolean {
return next !== undefined && next !== current;
}
/** A display's own reported error, bounded and stripped. A code and a short detail — never a stack,
* a URL, a payload, or anything else a browser might have had to hand. */
function normalizeViewerError(error: ViewerHeartbeat["error"], at: Date): StoredViewerError | null {
if (error === undefined || error === null) {
return null;
}
if (typeof error.code !== "string" || error.code.length === 0 || error.code.length > 64) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.code" });
}
const detail = error.detail ?? null;
if (
detail !== null &&
(typeof detail !== "string" || detail.length > SIGNAGE_VIEWER_ERROR_DETAIL_MAX)
) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.detail" });
}
return { at, code: error.code, detail };
}
export interface ViewerHealth {
readonly id: string;
readonly name: string;
readonly online: boolean;
readonly channelId: string | null;
readonly deviceId: string | null;
readonly lastSeenAtMs: number | null;
readonly reportedTimelineId: string | null;
readonly reportedSelectionId: string | null;
readonly reportedProviderId: string | null;
readonly reportedContributionId: string | null;
readonly offlineReady: boolean;
readonly cachedAssetCount: number;
readonly lastError: {
readonly code: string;
readonly detail: string | null;
readonly atMs: number;
} | null;
}
/** What management sees. Everything is either Signage's own record or a value the display itself
* reported through the bounded heartbeat contract — never a credential and never a host internal. */
export function describeViewerHealth(viewer: StoredViewer, atMs: number): ViewerHealth {
const lastSeenAtMs = viewer.lastSeenAt?.getTime() ?? null;
return {
cachedAssetCount: viewer.cachedAssetCount,
channelId: viewer.channelId,
deviceId: viewer.deviceId,
id: viewer.id,
lastError:
viewer.lastError === null
? null
: {
atMs: viewer.lastError.at.getTime(),
code: viewer.lastError.code,
detail: viewer.lastError.detail,
},
lastSeenAtMs,
name: viewer.name,
offlineReady: viewer.offlineReady,
online: lastSeenAtMs !== null && atMs - lastSeenAtMs < SIGNAGE_VIEWER_OFFLINE_AFTER_MS,
reportedContributionId: viewer.reportedContributionId,
reportedProviderId: viewer.reportedProviderId,
reportedSelectionId: viewer.reportedSelectionId,
reportedTimelineId: viewer.reportedTimelineId,
};
}
/** Assigns a viewer to a channel, or renames it. Refuses a channel that does not exist rather than
* silently leaving a screen unassigned. */
export async function updateViewerAssignment(input: {
readonly store: SignageStore;
readonly viewerId: string;
readonly name?: string | undefined;
readonly channelId?: string | undefined;
}): Promise<StoredViewer> {
const viewer = await input.store.findViewer(input.viewerId);
if (!viewer) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
const changes: ViewerChanges = {};
if (input.name !== undefined && input.name !== viewer.name) {
if (input.name.length === 0 || input.name.length > SIGNAGE_VIEWER_NAME_MAX) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "name" });
}
Object.assign(changes, { name: input.name });
}
if (input.channelId !== undefined && input.channelId !== viewer.channelId) {
const groups = await input.store.listChannelGroups();
const exists = groups.some((group) =>
group.channels.some((channel) => channel.id === input.channelId),
);
if (!exists) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
Object.assign(changes, { channelId: input.channelId });
}
if (Object.keys(changes).length === 0) {
return viewer;
}
await input.store.updateViewer(input.viewerId, changes);
const updated = await input.store.findViewer(input.viewerId);
if (!updated) {
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
return updated;
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Everything about a native Signage image that both halves of the module must agree on.
*
* Import-free on purpose: this file is compiled into the Node server project *and* into the browser
* artifacts, so the server's stored presentation and the display's painted presentation are resolved
* by the same function rather than by two implementations that drift.
*/
/** The identity Signage's own built-in content provider registers under. It is an ordinary provider
* from every other part of the system's point of view (ADR 0001), which is why there is exactly one
* selection pipeline instead of a native special case. */
export const SIGNAGE_NATIVE_PROVIDER_ID = "signage.native-images";
/** The renderer identity a native image resolves to. Native images are drawn by Signage's own
* display code rather than by a contributed renderer, so this is deliberately not a contribution
* id — a display that sees it renders an image element instead of asking the host to mount
* anything. */
export const SIGNAGE_NATIVE_RENDERER = "signage.native-image";
export const SIGNAGE_ASPECT_16_9 = 16 / 9;
/**
* How far from exactly 16:9 an image may be and still be treated as filling the frame.
*
* 0.02 accepts the ratios real export pipelines actually produce — 1920×1080 exactly, 1918×1080 from
* a cropped canvas, 1280×720 — and rejects anything a viewer would see letterboxed. It is a
* documented tolerance rather than an exact comparison because an exact one would put a 1919-pixel
* export on a blurred background for no reason a person could see.
*/
export const SIGNAGE_ASPECT_TOLERANCE = 0.02;
export function isSixteenByNine(width: number, height: number): boolean {
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return false;
}
return Math.abs(width / height - SIGNAGE_ASPECT_16_9) <= SIGNAGE_ASPECT_TOLERANCE;
}
export type SignageImageFit = "fill" | "contain";
/** What a scene layer needs to paint one native image. Every value is already resolved: the layer
* makes no decisions of its own. */
export interface SignageNativeScene {
readonly fit: SignageImageFit;
readonly backgroundMode: "blurred" | "solid" | "black" | "none";
readonly backgroundColor: string;
readonly backgroundImage: boolean;
readonly blurPixels: number;
readonly darkenOpacity: number;
readonly shadow: boolean;
}
/** The presentation values a slot may override, already merged with the installation defaults by
* the server. */
export interface SignageNativePresentation {
readonly backgroundMode: "blurred" | "solid" | "black";
readonly backgroundColor: string;
readonly backgroundDarkened: boolean;
readonly blurPixels: number;
readonly foregroundShadow: boolean;
}
/** How much a darkened background is dimmed. One value rather than a setting: an operator asking for
* "darker" wants the text readable, not a dimmer they have to tune. */
export const SIGNAGE_DARKEN_OPACITY = 0.25;
/**
* Resolves one native image plus its slot's presentation into everything a scene layer needs.
*
* A true 16:9 image covers the whole frame on its own — no blurred duplicate, no pillarbox, and no
* floating-card shadow, all of which would be artefacts on an image that already fits. Anything else
* is shown **whole** and backed, because cropping a church's slide to fill a screen cuts words off it.
*/
export function resolveNativeScene(
image: { readonly width: number; readonly height: number },
presentation: SignageNativePresentation,
): SignageNativeScene {
if (isSixteenByNine(image.width, image.height)) {
return {
backgroundColor: "#000000",
backgroundImage: false,
backgroundMode: "none",
blurPixels: 0,
darkenOpacity: 0,
fit: "fill",
shadow: false,
};
}
return {
backgroundColor: presentation.backgroundColor,
backgroundImage: presentation.backgroundMode === "blurred",
backgroundMode: presentation.backgroundMode,
blurPixels: presentation.backgroundMode === "blurred" ? presentation.blurPixels : 0,
darkenOpacity: presentation.backgroundDarkened ? SIGNAGE_DARKEN_OPACITY : 0,
fit: "contain",
shadow: true,
};
}
+306
View File
@@ -0,0 +1,306 @@
/**
* The arithmetic of authoritative playback (ADR 0005).
*
* Import-free, and compiled into **both** halves of the module on purpose: the server reconciles a
* channel group's clock with these functions, and a disconnected display continues from its last
* authoritative anchor with the *same* functions. Two implementations of this would drift, and the
* drift would only ever be visible on a wall, hours into an outage, with nobody watching.
*
* Three properties are what this file exists to guarantee, and each is the one a naive
* implementation loses:
*
* 1. **Absolute instants, never a countdown.** A slot has a start and a deadline; a timer is only a
* hint about when to look again.
* 2. **Reconcile forward from the previous authoritative deadline**, never from "now". A callback
* that fired 40 ms late costs 40 ms once, not 40 ms on every slide for the rest of the day.
* 3. **Catch up by arithmetic.** A screen that was off for a week must not step through a week of
* slides — and must not consume a provider's rotation doing it.
*/
/** One slot, reduced to exactly what timing needs. */
export interface TimingSlot {
readonly id: string;
readonly position: number;
readonly enabled: boolean;
/** Half-open `[from, until)`, in absolute epoch milliseconds. */
readonly eligibleFromMs: number | null;
readonly eligibleUntilMs: number | null;
/**
* What this slot is *expected* to last, from its own override, its provider's registered default,
* or the native-image default.
*
* An estimate, deliberately: the real duration of a provider slot is only known once the provider
* has resolved, and resolving thousands of times to catch up is exactly what this file avoids. The
* estimate decides how far to skip; the resolution decides the deadline of the slot actually
* landed on.
*/
readonly estimatedDurationMs: number;
}
export interface TimingTimeline {
readonly id: string;
readonly eligibleFromMs: number | null;
readonly eligibleUntilMs: number | null;
/** In position order. */
readonly slots: readonly TimingSlot[];
}
/** Where a channel group's clock currently is. */
export interface TimingCursor {
readonly slotId: string | null;
readonly startedAtMs: number | null;
readonly deadlineAtMs: number | null;
}
export type TimingAdvance =
/** The current slot is still running. Nothing to do until its deadline. */
| { readonly kind: "hold" }
/** Move to this slot, starting exactly here. */
| {
readonly kind: "advance";
readonly slotId: string;
readonly startedAtMs: number;
readonly deadlineAtMs: number;
/** How many slot boundaries were crossed to get here. Zero for an ordinary transition; large
* after an outage, and reported so a caller can tell "the next slide" from "catching up". */
readonly skipped: number;
}
/** Nothing in this timeline is eligible right now. */
| { readonly kind: "empty" };
/** A safety limit on any walk. Reaching it means a bug, not a legitimate schedule, and stopping is
* better than spinning on a wall. */
export const TIMING_TRANSITION_LIMIT = 10_000;
export function isTimelineEligible(timeline: TimingTimeline, atMs: number): boolean {
if (timeline.eligibleFromMs !== null && atMs < timeline.eligibleFromMs) {
return false;
}
return timeline.eligibleUntilMs === null || atMs < timeline.eligibleUntilMs;
}
/** Half-open `[from, until)`: eligible exactly at `from`, ineligible exactly at `until`. It is the
* only convention under which two adjacent windows neither overlap nor leave a gap. */
export function isSlotEligible(slot: TimingSlot, atMs: number): boolean {
if (!slot.enabled) {
return false;
}
if (slot.eligibleFromMs !== null && atMs < slot.eligibleFromMs) {
return false;
}
return slot.eligibleUntilMs === null || atMs < slot.eligibleUntilMs;
}
export function eligibleSlots(timeline: TimingTimeline, atMs: number): readonly TimingSlot[] {
if (!isTimelineEligible(timeline, atMs)) {
return [];
}
return timeline.slots.filter((slot) => isSlotEligible(slot, atMs));
}
/**
* The first instant in `(after, through]` at which any slot's eligibility can change.
*
* Between two such instants the eligible set is constant, which is exactly what makes skipping whole
* passes of the pattern safe. Without this, a catch-up would happily skip past a slot that was
* scheduled to appear during the outage.
*/
export function firstEligibilityBoundary(
timeline: TimingTimeline,
afterMs: number,
throughMs: number,
): number | null {
let best: number | null = null;
const candidates: (number | null)[] = [timeline.eligibleFromMs, timeline.eligibleUntilMs];
for (const slot of timeline.slots) {
candidates.push(slot.eligibleFromMs, slot.eligibleUntilMs);
}
for (const value of candidates) {
if (value === null || value <= afterMs || value > throughMs) {
continue;
}
if (best === null || value < best) {
best = value;
}
}
return best;
}
/** A currently showing slot can be cut short by **its own** window, never by another slot's. */
function ownInvalidBoundary(
slot: TimingSlot | undefined,
startedAtMs: number,
throughMs: number,
): number | null {
if (!slot) {
return null;
}
const value = slot.eligibleUntilMs;
if (value === null || value <= startedAtMs || value > throughMs) {
return null;
}
return value;
}
function nextEligibleAfter(
timeline: TimingTimeline,
currentSlotId: string | null,
atMs: number,
): TimingSlot | null {
const eligible = eligibleSlots(timeline, atMs);
if (eligible.length === 0) {
return null;
}
if (currentSlotId === null) {
return eligible[0] as TimingSlot;
}
const current = timeline.slots.find((slot) => slot.id === currentSlotId);
if (!current) {
return eligible[0] as TimingSlot;
}
// The pattern repeats, so "after" wraps. Position — not eligibility order — is what an operator
// arranged, so the successor is the next *position* that happens to be eligible.
const after = eligible.find((slot) => slot.position > current.position);
return after ?? (eligible[0] as TimingSlot);
}
/**
* The next instant a display or a channel group must look again.
*
* The current slot's deadline, or the next eligibility boundary when nothing is running. `null`
* means there is nothing scheduled to change — an empty timeline with no future windows — and a
* caller should wait to be told rather than poll.
*/
export function nextTimingBoundary(
timeline: TimingTimeline,
cursor: TimingCursor,
atMs: number,
): number | null {
const candidates: number[] = [];
if (cursor.deadlineAtMs !== null && cursor.deadlineAtMs > atMs) {
candidates.push(cursor.deadlineAtMs);
}
const boundaries: (number | null)[] = [timeline.eligibleFromMs, timeline.eligibleUntilMs];
for (const slot of timeline.slots) {
boundaries.push(slot.eligibleFromMs, slot.eligibleUntilMs);
}
for (const value of boundaries) {
if (value !== null && value > atMs) {
candidates.push(value);
}
}
return candidates.length === 0 ? null : Math.min(...candidates);
}
/**
* Walks a cursor forward to `atMs`.
*
* The heart of it is the boundary choice: when a slot's deadline has passed, the next slot starts at
* **that deadline**, not at `atMs`. That single decision is what keeps a loop from stretching by the
* accumulated lateness of every timer that ever fired.
*
* Long gaps are crossed with arithmetic. Within one stretch where the eligible set cannot change,
* whole passes of the pattern are skipped by division rather than by stepping — so a week offline
* costs one reconciliation and one provider call, not ten thousand of each.
*/
export function advanceCursor(
timeline: TimingTimeline,
cursor: TimingCursor,
atMs: number,
): TimingAdvance {
const current =
cursor.slotId === null ? undefined : timeline.slots.find((s) => s.id === cursor.slotId);
// Still running, and still allowed to be: nothing to decide.
if (
current &&
cursor.deadlineAtMs !== null &&
cursor.deadlineAtMs > atMs &&
isSlotEligible(current, atMs) &&
isTimelineEligible(timeline, atMs)
) {
return { kind: "hold" };
}
// Where the next slot legitimately begins.
let boundary = atMs;
if (cursor.deadlineAtMs !== null && cursor.deadlineAtMs <= atMs) {
boundary = cursor.deadlineAtMs;
}
const invalidAt = ownInvalidBoundary(current, cursor.startedAtMs ?? boundary, atMs);
if (invalidAt !== null) {
boundary = Math.min(boundary, invalidAt);
}
let slotId = cursor.slotId;
let startedAtMs = boundary;
let skipped = 0;
for (let step = 0; step < TIMING_TRANSITION_LIMIT; step += 1) {
const next = nextEligibleAfter(timeline, slotId, startedAtMs);
if (!next) {
// Nothing eligible at this instant. If something becomes eligible later in the gap, jump
// straight to that instant rather than declaring the timeline empty for the whole outage.
const boundaryAhead = firstEligibilityBoundary(timeline, startedAtMs, atMs);
if (boundaryAhead === null) {
return { kind: "empty" };
}
startedAtMs = boundaryAhead;
slotId = null;
continue;
}
const deadlineAtMs = startedAtMs + next.estimatedDurationMs;
if (deadlineAtMs > atMs) {
return { deadlineAtMs, kind: "advance", skipped, slotId: next.id, startedAtMs };
}
// The landed-on slot is already over. Skip whole passes of the pattern with a division, but only
// as far as the millisecond before the next instant the eligible set could change.
const changeAt = firstEligibilityBoundary(timeline, startedAtMs, atMs);
const through = changeAt === null ? atMs : changeAt - 1;
const stable = eligibleSlots(timeline, startedAtMs);
const passDuration = stable.reduce((total, slot) => total + slot.estimatedDurationMs, 0);
if (passDuration > 0 && deadlineAtMs <= through) {
const passes = Math.floor((through - startedAtMs) / passDuration);
if (passes > 0) {
startedAtMs += passes * passDuration;
skipped += passes * stable.length;
continue;
}
}
startedAtMs = deadlineAtMs;
slotId = next.id;
skipped += 1;
}
// Unreachable for any legitimate schedule. Holding is safer on a wall than spinning.
return { kind: "hold" };
}
/** How much of a running slot is left. Never negative, and `null` when nothing is running. */
export function remainingMs(cursor: TimingCursor, atMs: number): number | null {
if (cursor.deadlineAtMs === null) {
return null;
}
return Math.max(0, cursor.deadlineAtMs - atMs);
}
/**
* Where a resumed slot's window sits.
*
* The deadline is `now + remainder`, and the start is reconstructed **backwards** from it so the
* whole-slot window stays consistent — a renderer computing progress from `start` and `deadline`
* gets the same answer it would have got had the slot never been paused. A remainder captured under
* a longer duration is clamped, so the reconstructed start can never land after the resume instant.
*/
export function resumeWindow(
atMs: number,
remainderMs: number,
fullDurationMs: number,
): { readonly startedAtMs: number; readonly deadlineAtMs: number } {
const remaining = Math.max(0, Math.min(remainderMs, fullDurationMs));
const deadlineAtMs = atMs + remaining;
return { deadlineAtMs, startedAtMs: deadlineAtMs - fullDurationMs };
}
+100
View File
@@ -0,0 +1,100 @@
/**
* The versioned input Signage hands a provider's browser renderer (ADR 0002, ADR 0006).
*
* This is the domain contract behind Signage's `signage.slide-renderer` capability. The platform
* carries it as opaque JSON and validates only that it *is* JSON (ADR 0041); what it means is
* entirely between Signage and the contributing module, and it is versioned by Signage's own
* capability version so a renderer written against v1 keeps working when v2 exists.
*
* Import-free, because it is compiled into both the Node server that builds it and the browser
* artifacts that consume it.
*
* ## What is deliberately absent
*
* No API client, no realtime subscription, no principal, no permissions, no configuration, no
* database, no storage, no credential, and no URL that is not already inside `payload`. Mounting a
* renderer grants its module nothing: everything it needs arrives here, because Signage is the party
* that was authorized to obtain it. A renderer that needed to reach the network would need a later
* contract version, and this one does not weaken to accommodate it.
*/
export const SIGNAGE_SLIDE_RENDERER_CONTRACT = "signage.slide-renderer";
/** Bumped when the shape below changes in a way a renderer can observe. */
export const SIGNAGE_SLIDE_RENDERER_INPUT_VERSION = "1.0.0";
/** Both clocks at the moment this input was built, so a renderer can compute the authoritative time
* without trusting the local one.
*
* A television's clock is routinely wrong — sometimes by hours. `serverNowMs` is what the server
* said the time was; `localNowMs` is what `Date.now()` said at the same instant on this device. A
* renderer that wants the authoritative now computes `serverNowMs + (Date.now() - localNowMs)`,
* which is correct regardless of how far off the device's clock is, and stays correct across a
* pause because both anchors move together. */
export interface SignageRendererClock {
readonly serverNowMs: number;
readonly localNowMs: number;
}
/** The authoritative slot window. A renderer may do anything it likes inside it and may not extend
* it: the deadline was set by Signage before the renderer mounted. */
export interface SignageRendererTiming {
/** Absolute epoch milliseconds, on the server's clock. */
readonly startedAtMs: number;
readonly deadlineAtMs: number;
readonly durationMs: number;
readonly paused: boolean;
/** How much of the slot is left when paused. `null` while running — the deadline is the answer. */
readonly remainingMs: number | null;
}
export interface SignageSlideRendererInput {
readonly contract: typeof SIGNAGE_SLIDE_RENDERER_CONTRACT;
readonly version: string;
/** Which provider's content this is. Opaque; a renderer already knows its own. */
readonly providerId: string;
/** The provider's own identity for this content, and the revision of it being rendered. */
readonly itemId: string;
readonly itemRevision: string;
/** The audience this channel renders. A renderer that has nothing audience-specific ignores it. */
readonly audienceKey: string;
/** The provider's own payload, exactly as it returned it. */
readonly payload: unknown;
/** Presentation values the provider supplied to its own renderer, or `null`. Signage never
* interprets, merges, or overrides them, and never restyles a provider's output. */
readonly presentation: unknown;
readonly timing: SignageRendererTiming;
readonly clock: SignageRendererClock;
/**
* Changes whenever anything in this input changed.
*
* A renderer that keeps internal state keyed on it can tell "the same slide, updated" from "a
* different slide" without comparing the whole input — which is exactly the distinction that lets
* a cancelled event update a mounted React tree instead of restarting its slot.
*/
readonly generation: string;
}
/** Builds the generation token. Deterministic, so two displays that received the same state compute
* the same token and neither remounts when the other does not. */
export function signageRendererGeneration(input: {
readonly selectionId: string;
readonly revision: number;
readonly audienceKey: string;
readonly paused: boolean;
}): string {
return `${input.selectionId}:${input.revision}:${input.audienceKey}:${input.paused ? "p" : "r"}`;
}
/** Whether two inputs describe the same logical slide, so a consumer can update in place rather than
* remounting. Identity — not payload equality — is what decides. */
export function isSameSignageSlide(
left: SignageSlideRendererInput,
right: SignageSlideRendererInput,
): boolean {
return (
left.providerId === right.providerId &&
left.itemId === right.itemId &&
left.audienceKey === right.audienceKey
);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Signage's own operator settings, as one plain, JSON-safe shape.
*
* Deliberately **import-free**. This file is compiled into both halves of the module: the Node
* server project and the browser project, whose TypeScript configuration declares `"types": []` so
* that `process.env` fails to compile. Importing `@ekklesia/module-sdk` here would pull the backend
* contracts barrel — and with it Drizzle's types and Node's globals — into every browser artifact,
* which is exactly the boundary ADR 0024 closed. Reading these values from the platform's
* configuration contract is therefore the server's job (`src/server/settings.ts`).
*
* Every value here is an **operator setting**: it survives restarts and is immutable for the life of
* the process (ADR 0029). Domain state — which timeline is active, which slot is on screen, whether
* playback is paused — lives in Signage's own tables instead, because those must change without one.
*/
/** How a native image that is not 16:9 is backed. External providers own their own rendering
* completely and are never subject to these values. */
export const SIGNAGE_BACKGROUND_MODES = ["blurred", "solid", "black"] as const;
export type SignageBackgroundMode = (typeof SIGNAGE_BACKGROUND_MODES)[number];
export function isSignageBackgroundMode(value: unknown): value is SignageBackgroundMode {
return (
typeof value === "string" && (SIGNAGE_BACKGROUND_MODES as readonly string[]).includes(value)
);
}
/** The presentation a native image falls back to when a slot overrides nothing. */
export interface SignageNativePresentationDefaults {
readonly backgroundMode: SignageBackgroundMode;
readonly backgroundColor: string;
readonly backgroundDarkened: boolean;
readonly blurPixels: number;
}
export interface SignageSettings {
/** IANA zone every schedule in this installation is written and displayed in. Signage never
* interprets an ambiguous local timestamp: stored instants are absolute, and this exists so a
* *person* reading or writing one sees the zone the church actually keeps. */
readonly timezone: string;
readonly nativeImageDurationMs: number;
readonly crossfadeMs: number;
readonly nativePresentation: SignageNativePresentationDefaults;
/** How long one content-provider call may take before Signage treats it as a temporary failure. */
readonly providerTimeoutMs: number;
/** How many resolved selections per channel group are retained as playback history. */
readonly playbackHistoryLimit: number;
/** Shortest interval between two persisted heartbeat writes for one steady viewer. */
readonly viewerHeartbeatWriteMs: number;
}
export const SIGNAGE_HEX_COLOR = /^#[0-9a-f]{6}$/iu;
/** What Signage falls back to when a host supplies no configuration at all — the same values the
* manifest declares as defaults, so the two can never disagree about what "unset" means. */
export const SIGNAGE_DEFAULT_SETTINGS: SignageSettings = Object.freeze({
crossfadeMs: 1000,
nativeImageDurationMs: 7000,
nativePresentation: Object.freeze({
backgroundColor: "#000000",
backgroundDarkened: false,
backgroundMode: "blurred",
blurPixels: 20,
}),
playbackHistoryLimit: 50,
providerTimeoutMs: 2000,
timezone: "America/Chicago",
viewerHeartbeatWriteMs: 30_000,
});
+142
View File
@@ -0,0 +1,142 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { EkklesiaUiStyles, EmptyState, Tabs } from "@ekklesia/ui";
import { type ReactElement, useMemo, useState } from "react";
import { DesktopOnly } from "../browser/desktop-gate.js";
import { createSignageApi } from "./api.js";
import { useResource } from "./hooks.js";
import { DisplaysPanel } from "./panels/DisplaysPanel.js";
import { ImagesPanel } from "./panels/ImagesPanel.js";
import { PlaybackPanel } from "./panels/PlaybackPanel.js";
import { ProvidersPanel } from "./panels/ProvidersPanel.js";
import { renderLoadable } from "./panels/shared.js";
import { TimelinesPanel } from "./panels/TimelinesPanel.js";
import type { ChannelGroupView, SettingsResponse } from "./types.js";
/**
* The Signage management screen.
*
* Host-neutral by construction: every value comes from the host context, and nothing here hardcodes
* a URL, an origin, or a global. Presentation is entirely the platform's shared elements plus
* Signage's own stylesheet, so this looks like the rest of Ekklesia without copying a colour.
*
* Permission checks here decide what an operator is *offered*. The server decides what happens: a
* hidden button is a courtesy, and every route is enforced regardless of what this believes.
*/
export function SignageDashboard({
context,
}: {
readonly context: ModuleUiHostContext;
}): ReactElement {
const api = useMemo(() => createSignageApi(context.api), [context.api]);
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
// A hidden screen is a usability choice; the server is what actually refuses, and making a
// request it will certainly refuse is noise in a log rather than security.
const mayView = context.permissions.has("signage.view");
const settings = useResource(
() => (mayView ? api.get<SettingsResponse>("settings") : Promise.resolve(undefined)),
[mayView],
);
const groups = useResource(
() =>
mayView
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
: Promise.resolve(undefined),
[mayView],
);
const [tab, setTab] = useState("playback");
const [groupId, setGroupId] = useState<string | undefined>(undefined);
const mayManageAssets = context.permissions.has("signage.assets.manage");
const mayManageTimelines = context.permissions.has("signage.timelines.manage");
const mayControl = context.permissions.has("signage.playback.control");
const mayManageDisplays = context.permissions.has("signage.displays.manage");
if (!mayView) {
return (
<EmptyState
description="Ask an administrator for the signage.view permission."
title="No access to Signage"
/>
);
}
const selectedGroupId =
groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined);
return (
<>
<EkklesiaUiStyles />
<DesktopOnly>
{renderLoadable(
settings.state,
(loaded) =>
loaded === undefined ? undefined : (
<Tabs
label="Signage sections"
onSelect={setTab}
selectedId={tab}
tabs={[
{
content: (
<PlaybackPanel
api={api}
canControl={mayControl}
context={context}
groupId={selectedGroupId}
onGroupChange={setGroupId}
settings={loaded}
/>
),
id: "playback",
label: "Playback",
},
{
content: (
<TimelinesPanel
api={api}
canManage={mayManageTimelines}
onActivate={
mayControl && selectedGroupId !== undefined
? (timelineId) => {
void api
.send("POST", `playback/${selectedGroupId}/activate`, {
timelineId,
when: "now",
})
.then(() => setTab("playback"))
.catch(() => setTab("playback"));
}
: undefined
}
settings={loaded}
/>
),
id: "timelines",
label: "Timelines",
},
{
content: (
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} />
),
id: "images",
label: "Images",
},
{
content: <DisplaysPanel api={api} canManage={mayManageDisplays} />,
id: "displays",
label: "Displays",
},
{
content: <ProvidersPanel api={api} context={context} settings={loaded} />,
id: "providers",
label: "Providers",
},
]}
/>
),
{ label: "Loading Signage…", onRetry: settings.reload },
)}
</DesktopOnly>
</>
);
}
+107
View File
@@ -0,0 +1,107 @@
import type { ModuleUiApiClient } from "@ekklesia/module-ui-sdk";
/**
* Signage's dashboard talking to Signage's own backend.
*
* Every path here is **relative to this module's own API root**, and the host resolves it. Nothing
* in this file builds `/api/v1/modules/signage/...`: that prefix is Ekklesia's private deployment
* detail, and a UI that wrote it would be welded to Ekklesia and unhostable anywhere else.
*
* Failures become one small, typed shape. The server already sanitizes what it says; this keeps a
* network error and a refusal indistinguishable in the places where they should be — a screen that
* cannot load — and distinguishable where they matter, in a form that can show *why* a save was
* refused.
*/
export interface SignageApiFailure {
readonly code: string;
readonly message: string;
readonly status: number;
readonly detail?: Readonly<Record<string, number | string | null>> | undefined;
}
export class SignageRequestError extends Error {
readonly failure: SignageApiFailure;
constructor(failure: SignageApiFailure) {
super(failure.message);
this.name = "SignageRequestError";
this.failure = failure;
}
}
const GENERIC: SignageApiFailure = {
code: "SIGNAGE_REQUEST_FAILED",
message: "Signage could not be reached.",
status: 0,
};
async function readFailure(response: Response): Promise<SignageApiFailure> {
try {
const body = (await response.json()) as {
code?: unknown;
message?: unknown;
detail?: unknown;
};
if (typeof body.code === "string" && typeof body.message === "string") {
return {
code: body.code,
...(typeof body.detail === "object" && body.detail !== null
? { detail: body.detail as SignageApiFailure["detail"] }
: {}),
message: body.message,
status: response.status,
};
}
} catch {
// A response that is not Signage's own error shape tells the operator nothing useful, and
// guessing at it would put someone else's words on the screen.
}
return { ...GENERIC, status: response.status };
}
export interface SignageApi {
get<Result>(path: string): Promise<Result>;
send<Result>(
method: "POST" | "PATCH" | "PUT" | "DELETE",
path: string,
body?: unknown,
): Promise<Result>;
/** One image, one request. The queue that drives this lives in `browser/uploads.ts`. */
upload<Result>(path: string, bytes: Blob, contentType: string): Promise<Result>;
}
export function createSignageApi(client: ModuleUiApiClient): SignageApi {
async function request<Result>(path: string, init: RequestInit | undefined): Promise<Result> {
let response: Response;
try {
response = await client.request(path, init);
} catch {
throw new SignageRequestError(GENERIC);
}
if (!response.ok) {
throw new SignageRequestError(await readFailure(response));
}
if (response.status === 204) {
return undefined as Result;
}
try {
return (await response.json()) as Result;
} catch {
throw new SignageRequestError({ ...GENERIC, status: response.status });
}
}
return {
get: (path) => request(path, undefined),
send: (method, path, body) =>
request(path, {
...(body === undefined
? {}
: { body: JSON.stringify(body), headers: { "content-type": "application/json" } }),
method,
}),
upload: (path, bytes, contentType) =>
request(path, { body: bytes, headers: { "content-type": contentType }, method: "POST" }),
};
}
+122
View File
@@ -0,0 +1,122 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { type SignageApiFailure, SignageRequestError } from "./api.js";
/**
* One loading state, used everywhere, so every panel in the dashboard fails the same way.
*
* The states are the ones an operator can actually act on: still loading, loaded, or failed with a
* reason Signage itself gave. There is no fourth state for "loaded but empty" — a panel decides what
* empty means for its own data, because "no timelines yet" and "no displays yet" are different
* sentences.
*/
export type Loadable<Value> =
| { readonly status: "loading" }
| { readonly status: "ready"; readonly value: Value }
| { readonly status: "failed"; readonly failure: SignageApiFailure };
export interface Resource<Value> {
readonly state: Loadable<Value>;
/** Re-reads without flashing the panel back to a spinner: the previous value stays on screen
* until the new one arrives, which is what makes a five-second refresh bearable to look at. */
reload(): void;
}
export function useResource<Value>(
load: () => Promise<Value>,
dependencies: readonly unknown[],
): Resource<Value> {
const [state, setState] = useState<Loadable<Value>>({ status: "loading" });
const generation = useRef(0);
const loadRef = useRef(load);
loadRef.current = load;
const run = useCallback(() => {
generation.current += 1;
const token = generation.current;
void loadRef
.current()
.then((value) => {
// A response from a request the panel has already moved on from must never overwrite a
// newer one.
if (token === generation.current) {
setState({ status: "ready", value });
}
})
.catch((error: unknown) => {
if (token !== generation.current) {
return;
}
setState({
failure:
error instanceof SignageRequestError
? error.failure
: {
code: "SIGNAGE_REQUEST_FAILED",
message: "Signage could not be reached.",
status: 0,
},
status: "failed",
});
});
}, []);
// The caller's dependency list is spread deliberately: a resource reloads when *its* inputs
// change, which is the whole reason this takes one.
useEffect(run, [run, ...dependencies]);
return { reload: run, state };
}
/** A mutation with a pending flag and a sanitized failure, so a button can disable itself and a form
* can say why a save was refused. */
export interface Action {
readonly pending: boolean;
readonly failure: SignageApiFailure | undefined;
run(work: () => Promise<unknown>): void;
clearFailure(): void;
}
export function useAction(onSettled?: () => void): Action {
const [pending, setPending] = useState(false);
const [failure, setFailure] = useState<SignageApiFailure | undefined>(undefined);
const settled = useRef(onSettled);
settled.current = onSettled;
const run = useCallback((work: () => Promise<unknown>) => {
setPending(true);
setFailure(undefined);
void work()
.then(() => {
setPending(false);
settled.current?.();
})
.catch((error: unknown) => {
setPending(false);
setFailure(
error instanceof SignageRequestError
? error.failure
: {
code: "SIGNAGE_REQUEST_FAILED",
message: "Signage could not be reached.",
status: 0,
},
);
});
}, []);
return { clearFailure: () => setFailure(undefined), failure, pending, run };
}
/** Re-renders on an interval, so a countdown or a "last seen" reads correctly without every panel
* inventing its own timer. Paused when the interval is zero. */
export function useTick(intervalMs: number): number {
const [tick, setTick] = useState(0);
useEffect(() => {
if (intervalMs <= 0) {
return;
}
const handle = setInterval(() => setTick((value) => value + 1), intervalMs);
return () => clearInterval(handle);
}, [intervalMs]);
return tick;
}
+25
View File
@@ -0,0 +1,25 @@
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
import { EKKLESIA_UI_STYLESHEET } from "@ekklesia/ui";
import { createRoot } from "react-dom/client";
import { SignageDashboard } from "./SignageDashboard.js";
import ownStyles from "./styles.css";
/**
* Signage's compiled management-UI artifact. `pnpm build` bundles this file and everything it
* imports into the single browser artifact `entrypoints.ui` declares.
*
* `styles` is this artifact's complete stylesheet — the shared elements' rules plus Signage's own —
* compiled in as text and installed by the host into this artifact's isolated root at mount
* (ADR 0040). The design tokens both stylesheets reference are inherited from the host's own root
* rather than bundled (ADR 0039).
*/
export default defineModuleUi({
id: "signage",
version: "0.1.0",
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
mount(container, context) {
const root = createRoot(container);
root.render(<SignageDashboard context={context} />);
return () => root.unmount();
},
});
+288
View File
@@ -0,0 +1,288 @@
import {
Badge,
Button,
EmptyState,
Field,
Panel,
Select,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextInput,
} from "@ekklesia/ui";
import { type ReactElement, useState } from "react";
import type { SignageApi } from "../api.js";
import { useAction, useResource, useTick } from "../hooks.js";
import type { ChannelGroupView, ViewerHealthView } from "../types.js";
import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js";
/**
* Channel groups, audience channels, and the display roster.
*
* A church starts with one group and one default channel. `in-person` and `online` are created here,
* when someone actually needs them — not shipped as rows nobody asked for and everybody has to
* understand and ignore (ADR 0004).
*/
export function DisplaysPanel({
api,
canManage,
}: {
readonly api: SignageApi;
readonly canManage: boolean;
}): ReactElement {
const groups = useResource(
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
[],
);
const viewers = useResource(
() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"),
[],
);
const edit = useAction(() => {
groups.reload();
viewers.reload();
});
const [groupName, setGroupName] = useState("");
const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({
groupId: "",
key: "",
name: "",
});
// "Last seen" is a moving number: without a tick it would freeze at whatever it said when the
// roster loaded, and an operator would read a screen that went dark ten minutes ago as current.
useTick(5000);
const nowMs = Date.now();
const channels =
groups.state.status === "ready"
? groups.state.value.groups.flatMap((group) =>
group.channels.map((channel) => ({ ...channel, groupName: group.name })),
)
: [];
return (
<div className="signage-columns">
<Panel
actions={
canManage ? (
<div className="signage-inline-form">
<Field label="New channel group">
{(control) => (
<TextInput
{...control}
onChange={(event) => setGroupName(event.target.value)}
placeholder="Nursery hallway"
value={groupName}
/>
)}
</Field>
<Button
disabled={edit.pending || groupName.trim().length === 0}
onClick={() =>
edit.run(async () => {
await api.send("POST", "channel-groups", { name: groupName.trim() });
setGroupName("");
})
}
variant="primary"
>
Create
</Button>
</div>
) : undefined
}
description="A group is one clock. An audience channel is one rendering of it."
title="Channel groups"
>
{renderLoadable(
groups.state,
(value) => (
<div className="signage-groups" data-testid="signage-groups">
{value.groups.map((group) => (
<div className="signage-group" key={group.id}>
<div className="signage-group__header">
<h4 className="signage-editor__subtitle">{group.name}</h4>
<Badge tone="info">{group.playback.transport}</Badge>
</div>
<ul className="ek-list">
{group.channels.map((channel) => (
<li className="ek-list__item signage-row" key={channel.id}>
<span className="signage-row__name">{channel.name}</span>
<Badge tone={channel.isDefault ? "success" : "neutral"}>
{channel.key}
</Badge>
{canManage && !channel.isDefault ? (
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() => api.send("DELETE", `channels/${channel.id}`))
}
variant="danger"
>
Remove
</Button>
) : undefined}
</li>
))}
</ul>
{canManage ? (
<div className="signage-inline-form" data-testid="signage-add-channel">
<Field hint="Lowercase, e.g. online" label="Audience key">
{(control) => (
<TextInput
{...control}
onChange={(event) =>
setChannelDraft({
groupId: group.id,
key: event.target.value,
name: channelDraft.name,
})
}
value={channelDraft.groupId === group.id ? channelDraft.key : ""}
/>
)}
</Field>
<Field label="Channel name">
{(control) => (
<TextInput
{...control}
onChange={(event) =>
setChannelDraft({
groupId: group.id,
key: channelDraft.key,
name: event.target.value,
})
}
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
/>
)}
</Field>
<Button
disabled={
edit.pending ||
channelDraft.groupId !== group.id ||
channelDraft.key.trim() === "" ||
channelDraft.name.trim() === ""
}
onClick={() =>
edit.run(async () => {
await api.send("POST", `channel-groups/${group.id}/channels`, {
key: channelDraft.key.trim(),
name: channelDraft.name.trim(),
});
setChannelDraft({ groupId: "", key: "", name: "" });
})
}
>
Add audience channel
</Button>
</div>
) : undefined}
</div>
))}
</div>
),
{ label: "Loading channel groups…", onRetry: groups.reload },
)}
</Panel>
<Panel
description="Every screen Signage has seen, and what it last reported."
title="Displays"
>
{renderLoadable(
viewers.state,
(value) =>
value.viewers.length === 0 ? (
<EmptyState
description="Open a Signage display on a screen and it will register itself here."
title="No displays yet"
/>
) : (
<Table label="Display health">
<TableHead>
<TableRow>
<TableCell header>Name</TableCell>
<TableCell header>State</TableCell>
<TableCell header>Channel</TableCell>
<TableCell header>Last seen</TableCell>
<TableCell header>Offline ready</TableCell>
<TableCell header>Most recent error</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value.viewers.map((viewer) => (
<TableRow key={viewer.id}>
<TableCell>
{canManage ? (
<TextInput
aria-label={`Name for ${viewer.name}`}
defaultValue={viewer.name}
onBlur={(event) =>
event.target.value !== viewer.name &&
edit.run(() =>
api.send("PATCH", `viewers/${viewer.id}`, {
name: event.target.value,
}),
)
}
/>
) : (
viewer.name
)}
</TableCell>
<TableCell>
<OnlineBadge online={viewer.online} />
</TableCell>
<TableCell>
{canManage ? (
<Select
aria-label={`Channel for ${viewer.name}`}
onChange={(event) =>
edit.run(() =>
api.send("PATCH", `viewers/${viewer.id}`, {
channelId: event.target.value,
}),
)
}
value={viewer.channelId ?? ""}
>
{channels.map((channel) => (
<option key={channel.id} value={channel.id}>
{`${channel.groupName} · ${channel.name}`}
</option>
))}
</Select>
) : (
(channels.find((channel) => channel.id === viewer.channelId)?.name ?? "—")
)}
</TableCell>
<TableCell>{formatSince(viewer.lastSeenAtMs, nowMs)}</TableCell>
<TableCell>
<Badge tone={viewer.offlineReady ? "success" : "neutral"}>
{viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not ready"}
</Badge>
</TableCell>
<TableCell>
{viewer.lastError === null ? (
"—"
) : (
<span title={viewer.lastError.detail ?? undefined}>
{viewer.lastError.code}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
{ label: "Loading displays…", onRetry: viewers.reload },
)}
<FailureNote failure={edit.failure} />
</Panel>
</div>
);
}
+226
View File
@@ -0,0 +1,226 @@
import { Badge, Button, EmptyState, Field, Panel, TextInput } from "@ekklesia/ui";
import { type ChangeEvent, type ReactElement, useCallback, useMemo, useRef, useState } from "react";
import {
createUploadQueue,
type UploadFile,
type UploadItem,
type UploadQueue,
} from "../../browser/uploads.js";
import type { SignageApi } from "../api.js";
import { useAction, useResource } from "../hooks.js";
import type { ImageRecord, SettingsResponse } from "../types.js";
import { FailureNote, formatInstant, renderLoadable } from "./shared.js";
/**
* The native image library and its upload queue.
*
* The upload behaviour is the part worth reading: an operator drops twenty slides and gets twenty
* individually bounded requests, a few at a time. The page stays responsive because only a few are
* in flight; one bad file fails alone; and the operator learns about a file that is too large or of
* the wrong type before a byte is sent, while the server still checks both again.
*/
export function ImagesPanel({
api,
settings,
canManage,
}: {
readonly api: SignageApi;
readonly settings: SettingsResponse;
readonly canManage: boolean;
}): ReactElement {
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []);
const [uploads, setUploads] = useState<readonly UploadItem[]>([]);
const queueRef = useRef<UploadQueue | undefined>(undefined);
const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined);
const rename = useAction(() => {
setRenaming(undefined);
images.reload();
});
const remove = useAction(() => images.reload());
const queue = useMemo(() => {
const created = createUploadQueue({
concurrency: settings.uploads.concurrency,
maxBytes: settings.uploads.maxBytes,
maxFiles: settings.uploads.batchMaxFiles,
onChange: (items) => setUploads(items),
upload: async (file) => {
// One image per request, with the label in the query string so the body is exactly the
// bytes. The server streams and counts them; nothing here buffers a second copy.
const query = new URLSearchParams({ displayName: file.name, filename: file.name });
await api.upload(`images?${query.toString()}`, file as unknown as Blob, file.type);
},
});
queueRef.current = created;
return created;
}, [
api,
settings.uploads.batchMaxFiles,
settings.uploads.concurrency,
settings.uploads.maxBytes,
]);
const onFiles = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []) as unknown as UploadFile[];
if (files.length === 0) {
return;
}
queue.enqueue(files);
void queue.drain().then(() => images.reload());
event.target.value = "";
},
[images, queue],
);
const finished = uploads.filter(
(item) => item.status !== "queued" && item.status !== "uploading",
);
return (
<Panel
actions={
canManage ? (
<label className="signage-upload-trigger">
<span className="ek-button ek-button--primary">Add images</span>
<input
accept="image/png,image/jpeg,image/webp"
className="signage-upload-input"
data-testid="signage-upload-input"
multiple
onChange={onFiles}
type="file"
/>
</label>
) : undefined
}
description={`PNG, JPEG, and WebP. Up to ${Math.round(settings.uploads.maxBytes / (1024 * 1024))} MiB each, ${settings.uploads.batchMaxFiles} at a time.`}
title="Images"
>
{uploads.length === 0 ? undefined : (
<div className="signage-uploads" data-testid="signage-uploads">
<ul className="ek-list">
{uploads.map((item) => (
<li className="ek-list__item signage-row" key={item.id}>
<span className="signage-row__name">{item.name}</span>
<Badge
tone={
item.status === "done"
? "success"
: item.status === "failed" || item.status === "rejected"
? "danger"
: "info"
}
>
{item.status}
</Badge>
{item.message === undefined ? undefined : (
<span className="signage-uploads__message">{item.message}</span>
)}
</li>
))}
</ul>
{finished.length === 0 ? undefined : (
<Button onClick={() => queue.clearFinished()}>Clear finished</Button>
)}
</div>
)}
{renderLoadable(
images.state,
(value) =>
value.images.length === 0 ? (
<EmptyState
description="Add a PNG, JPEG, or WebP and it will be available to every timeline."
title="No images yet"
/>
) : (
<ul className="signage-image-grid" data-testid="signage-image-grid">
{value.images.map((image) => {
const thumbnail = image.renditions.find(
(rendition) => rendition.kind === "thumbnail",
);
return (
<li className="signage-image" key={image.id}>
{thumbnail === undefined ? (
<div className="signage-image__missing">No preview</div>
) : (
<img
alt={image.displayName}
className="signage-image__thumbnail"
height={thumbnail.height}
src={thumbnail.url}
width={thumbnail.width}
/>
)}
<div className="signage-image__meta">
{renaming?.id === image.id ? (
<Field label="Name">
{(control) => (
<TextInput
{...control}
onChange={(event) =>
setRenaming({ id: image.id, value: event.target.value })
}
value={renaming.value}
/>
)}
</Field>
) : (
<p className="signage-image__name">{image.displayName}</p>
)}
<p className="signage-image__detail">
{`${image.width}×${image.height} · ${formatInstant(image.createdAt, settings.timezone)}`}
</p>
{image.expiresAt === null ? undefined : (
<Badge className="signage-image__expiry" tone="warning">
{`Expires ${formatInstant(image.expiresAt, settings.timezone)}`}
</Badge>
)}
</div>
{canManage ? (
<div className="signage-image__actions">
{renaming?.id === image.id ? (
<>
<Button
disabled={rename.pending}
onClick={() =>
rename.run(() =>
api.send("PATCH", `images/${image.id}`, {
displayName: renaming.value,
}),
)
}
variant="primary"
>
Save
</Button>
<Button onClick={() => setRenaming(undefined)}>Cancel</Button>
</>
) : (
<Button
onClick={() => setRenaming({ id: image.id, value: image.displayName })}
>
Rename
</Button>
)}
<Button
disabled={remove.pending}
onClick={() => remove.run(() => api.send("DELETE", `images/${image.id}`))}
variant="danger"
>
Delete
</Button>
</div>
) : undefined}
</li>
);
})}
</ul>
),
{ label: "Loading images…", onRetry: images.reload },
)}
<FailureNote failure={rename.failure ?? remove.failure} />
</Panel>
);
}
+274
View File
@@ -0,0 +1,274 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui";
import { type ReactElement, useState } from "react";
import type { SignageApi } from "../api.js";
import { useAction, useResource, useTick } from "../hooks.js";
import type {
ChannelGroupView,
PlaybackDetailView,
SettingsResponse,
TimelineSummary,
} from "../types.js";
import { PreviewPanel } from "./PreviewPanel.js";
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
/**
* Live transport for one channel group.
*
* Every control here is the same operation the `signage.playback` capability exposes, over the same
* serialized service — a volunteer with a phone and an operator at this screen are pressing the same
* button, and neither can produce a state the other did not ask for.
*/
export function PlaybackPanel({
api,
context,
settings,
canControl,
groupId,
onGroupChange,
}: {
readonly api: SignageApi;
readonly context: ModuleUiHostContext;
readonly settings: SettingsResponse;
readonly canControl: boolean;
readonly groupId: string | undefined;
readonly onGroupChange: (groupId: string) => void;
}): ReactElement {
const groups = useResource(
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
[],
);
const timelines = useResource(
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
[],
);
const detail = useResource(
() =>
groupId === undefined
? Promise.resolve(undefined)
: api.get<PlaybackDetailView>(`playback/${groupId}`),
[groupId],
);
const command = useAction(() => {
detail.reload();
groups.reload();
});
const [activateId, setActivateId] = useState("");
const previewChannelId =
groups.state.status === "ready"
? (groups.state.value.groups
.find((group) => group.id === groupId)
?.channels.find((channel) => channel.isDefault)?.id ?? undefined)
: undefined;
// A one-second tick so the remaining time counts down rather than sitting still between refreshes.
useTick(1000);
function run(path: string, body?: unknown): void {
if (groupId === undefined) {
return;
}
command.run(() => api.send("POST", `playback/${groupId}/${path}`, body));
}
return (
<div className="signage-columns">
<Panel
actions={renderLoadable(
groups.state,
(value) => (
<Field label="Channel group">
{(control) => (
<Select
{...control}
onChange={(event) => onGroupChange(event.target.value)}
value={groupId ?? ""}
>
{value.groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</Select>
)}
</Field>
),
{ label: "Loading groups…" },
)}
description="One clock for every channel in the group."
title="Playback"
>
{groupId === undefined ? (
<EmptyState title="No channel group selected" />
) : (
renderLoadable(
detail.state,
(value) =>
value === undefined ? (
<EmptyState title="No channel group selected" />
) : (
<div className="signage-playback" data-testid="signage-playback">
<div className="signage-playback__status">
<Badge
tone={
value.snapshot.transport === "playing"
? "success"
: value.snapshot.transport === "idle"
? "warning"
: "info"
}
>
{value.snapshot.transport}
</Badge>
{value.snapshot.blackout ? <Badge tone="danger">Blackout</Badge> : undefined}
{value.current?.stale === true ? (
<Badge tone="warning">Stale</Badge>
) : undefined}
{value.current?.withdrawn === true ? (
<Badge tone="warning">Withdrawn</Badge>
) : undefined}
{value.snapshot.pendingTimelineId === null ? undefined : (
<Badge tone="info">Activation queued</Badge>
)}
</div>
{value.current === null ? (
<EmptyState
description="Nothing is currently resolvable. A display shows its safe slate."
title="Nothing on screen"
/>
) : (
<dl className="signage-playback__current" data-testid="signage-current">
<dt>Provider</dt>
<dd>{value.current.providerId}</dd>
<dt>Item</dt>
<dd>{value.current.itemId}</dd>
<dt>Renderer</dt>
<dd>{value.current.rendererContributionId ?? "—"}</dd>
<dt>Duration</dt>
<dd>{formatDuration(value.current.durationMs)}</dd>
<dt>Remaining</dt>
<dd data-testid="signage-remaining">
{value.snapshot.remainingMs === null
? "—"
: formatDuration(value.snapshot.remainingMs)}
</dd>
<dt>Started</dt>
<dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd>
</dl>
)}
{canControl ? (
<div className="signage-playback__controls" data-testid="signage-controls">
<Button disabled={command.pending} onClick={() => run("previous")}>
Previous
</Button>
{value.snapshot.transport === "paused" ? (
<Button
disabled={command.pending}
onClick={() => run("resume")}
variant="primary"
>
Resume
</Button>
) : (
<Button disabled={command.pending} onClick={() => run("pause")}>
Pause
</Button>
)}
<Button disabled={command.pending} onClick={() => run("next")}>
Next
</Button>
<Button
disabled={command.pending}
onClick={() => run("blackout", { on: !value.snapshot.blackout })}
variant={value.snapshot.blackout ? "primary" : "secondary"}
>
{value.snapshot.blackout ? "End blackout" : "Blackout"}
</Button>
{value.snapshot.staticAssetId === null ? undefined : (
<Button
disabled={command.pending}
onClick={() => run("static", { assetId: null })}
>
Release hold
</Button>
)}
<Button disabled={command.pending} onClick={() => run("reload")}>
Reload content
</Button>
</div>
) : undefined}
{canControl ? (
<div className="signage-playback__activate">
{renderLoadable(
timelines.state,
(loaded) => (
<>
<Field label="Activate timeline">
{(control) => (
<Select
{...control}
onChange={(event) => setActivateId(event.target.value)}
value={activateId}
>
<option value="">Choose a timeline…</option>
{loaded.timelines.map((timeline) => (
<option key={timeline.id} value={timeline.id}>
{timeline.name}
</option>
))}
</Select>
)}
</Field>
<Button
disabled={command.pending || activateId === ""}
onClick={() =>
run("activate", { timelineId: activateId, when: "now" })
}
variant="primary"
>
Now
</Button>
<Button
disabled={command.pending || activateId === ""}
onClick={() =>
run("activate", { timelineId: activateId, when: "next-boundary" })
}
>
At next slot
</Button>
</>
),
{ label: "Loading timelines…" },
)}
</div>
) : undefined}
{value.history.length === 0 ? undefined : (
<div className="signage-playback__history">
<h4 className="signage-editor__subtitle">Recently played</h4>
<ul className="ek-list">
{value.history.slice(0, 8).map((entry) => (
<li className="ek-list__item signage-row" key={entry.id}>
<span className="signage-row__name">{entry.itemId}</span>
<span className="signage-playback__history-meta">
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
</span>
</li>
))}
</ul>
</div>
)}
</div>
),
{ label: "Loading playback…", onRetry: detail.reload },
)
)}
<FailureNote failure={command.failure} />
</Panel>
<PreviewPanel api={api} channelId={previewChannelId} context={context} />
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { EmptyState, Panel } from "@ekklesia/ui";
import { type ReactElement, useEffect, useRef } from "react";
import { SignageSlate } from "../../browser/slate.js";
import { createDisplayController, type DisplayChannelStateView } from "../../display/controller.js";
import type { SignageApi } from "../api.js";
/**
* The live preview.
*
* It mounts **the display controller itself** — the same scene stage, the same native-image
* presentation resolver, the same transition, the same renderer host — against the same channel
* state a wall reads. A preview that reimplemented any of that would drift from what the room
* actually sees, which makes it worse than no preview at all.
*
* It differs from a display in exactly two ways, both of them `telemetry: false`: it registers no
* viewer, so it never appears in the display roster, and it sends no heartbeat, so it never reports
* health for a screen that does not exist. It drives its own refresh instead of subscribing.
*/
export function PreviewPanel({
api,
channelId,
context,
refreshMs = 2000,
}: {
readonly api: SignageApi;
readonly channelId: string | undefined;
readonly context: ModuleUiHostContext;
readonly refreshMs?: number;
}): ReactElement {
const stageRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = stageRef.current;
if (!root || channelId === undefined) {
return;
}
let stopped = false;
const controller = createDisplayController({ context, root, telemetry: false });
let handle: ReturnType<typeof setTimeout> | undefined;
async function pump(): Promise<void> {
if (stopped) {
return;
}
try {
const state = await api.get<DisplayChannelStateView>(`channels/${channelId}/display-state`);
if (!stopped) {
await controller.apply(state);
}
} catch {
// A preview that cannot reach the server simply keeps its last frame, exactly as a wall
// does. Nothing about a failed poll belongs in the operator's face.
}
if (!stopped) {
handle = setTimeout(() => void pump(), refreshMs);
}
}
void controller.start().then(() => pump());
return () => {
stopped = true;
if (handle !== undefined) {
clearTimeout(handle);
}
void controller.stop();
};
}, [api, channelId, context, refreshMs]);
return (
<Panel
description="Exactly what a display on this channel is showing, rendered by the display's own code."
title="Live preview"
>
{channelId === undefined ? (
<EmptyState title="No channel to preview" />
) : (
<div className="signage-preview">
<div className="signage-preview__frame">
<div className="signage-stage" data-testid="signage-preview-stage" ref={stageRef}>
<div className="signage-slate-host">
<SignageSlate />
</div>
<div aria-hidden="true" className="signage-blackout" />
</div>
</div>
</div>
)}
</Panel>
);
}
+116
View File
@@ -0,0 +1,116 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import {
Badge,
EmptyState,
Panel,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@ekklesia/ui";
import type { ReactElement } from "react";
import type { SignageApi } from "../api.js";
import { useResource } from "../hooks.js";
import type { ProviderHealth, SettingsResponse } from "../types.js";
import { formatInstant, renderLoadable } from "./shared.js";
const SLIDE_RENDERER_CAPABILITY = "signage.slide-renderer";
/**
* Provider health, and the one thing only a browser can tell an operator.
*
* A registration is a **running fact**: a provider that was uninstalled or disabled simply is not
* here, and there is no stored row claiming otherwise. Everything shown is either the provider's own
* declared identity or a count Signage kept — never a provider's exception, message, or payload.
*
* Renderer availability is checked here rather than on the server because the server genuinely
* cannot know it: whether the platform published a module's contribution is a fact the *host* holds,
* and `context.contributions` is where it is legible. A provider whose renderer was not published —
* because the module is missing, disabled, or outside Signage's compatible range — is reported as
* exactly that, which is the difference between "this is broken" and "this needs an update".
*/
export function ProvidersPanel({
api,
context,
settings,
}: {
readonly api: SignageApi;
readonly context: ModuleUiHostContext;
readonly settings: SettingsResponse;
}): ReactElement {
const providers = useResource(
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
[],
);
const published = new Set(
context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id),
);
return (
<Panel
description={`Renderer input contract ${settings.rendererInputVersion}. Only providers that are actually running appear here.`}
title="Content providers"
>
{renderLoadable(
providers.state,
(value) =>
value.providers.length === 0 ? (
<EmptyState
description="Signage works on its own with native images. Install a content module to add more."
title="No content providers registered"
/>
) : (
<Table label="Provider health">
<TableHead>
<TableRow>
<TableCell header>Provider</TableCell>
<TableCell header>Contract</TableCell>
<TableCell header>Renderer</TableCell>
<TableCell header>Health</TableCell>
<TableCell header>Last success</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value.providers.map((provider) => {
const rendererPublished = published.has(provider.renderer.contributionId);
const isNative = provider.renderer.contributionId === "signage.native-image";
return (
<TableRow key={provider.providerId}>
<TableCell>
<span className="signage-provider__id">{provider.providerId}</span>
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
</TableCell>
<TableCell>{provider.contractVersion}</TableCell>
<TableCell>
{isNative ? (
<Badge tone="success">Signage renders this</Badge>
) : rendererPublished ? (
<Badge tone="success">{provider.renderer.contributionId}</Badge>
) : (
<Badge tone="warning">Renderer not published</Badge>
)}
</TableCell>
<TableCell>
{provider.consecutiveFailures === 0 ? (
<Badge tone="success">Healthy</Badge>
) : (
<Badge tone="danger">
{`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`}
</Badge>
)}
</TableCell>
<TableCell>
{formatInstant(provider.lastSuccessAt, settings.timezone)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
),
{ label: "Loading providers…", onRetry: providers.reload },
)}
</Panel>
);
}
+476
View File
@@ -0,0 +1,476 @@
import {
Badge,
Button,
Checkbox,
EmptyState,
Field,
Panel,
Select,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextInput,
} from "@ekklesia/ui";
import { type ReactElement, useState } from "react";
import type { SignageApi } from "../api.js";
import { useAction, useResource } from "../hooks.js";
import type {
ImageRecord,
ProviderHealth,
ProviderItemDescriptor,
ProviderSourceDescriptor,
SettingsResponse,
TimelineDetail,
TimelineSummary,
} from "../types.js";
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
const NATIVE_PROVIDER_ID = "signage.native-images";
/**
* The timeline editor.
*
* The pattern an operator arranges here is the product: an ordered list of slots, each either a
* provider source that asks for the next item or one pinned item. Repeating a source is how a loop
* gets three announcements in a row — the five occurrences share one rotation, and this screen makes
* that visible by naming the source on every row rather than hiding it.
*/
export function TimelinesPanel({
api,
settings,
canManage,
onActivate,
}: {
readonly api: SignageApi;
readonly settings: SettingsResponse;
readonly canManage: boolean;
readonly onActivate: ((timelineId: string) => void) | undefined;
}): ReactElement {
const timelines = useResource(
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
[],
);
const providers = useResource(
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
[],
);
const [selectedId, setSelectedId] = useState<string | undefined>(undefined);
const [newName, setNewName] = useState("");
const create = useAction(() => {
setNewName("");
timelines.reload();
});
return (
<div className="signage-columns">
<Panel
actions={
canManage ? (
<div className="signage-inline-form">
<Field label="New timeline">
{(control) => (
<TextInput
{...control}
onChange={(event) => setNewName(event.target.value)}
placeholder="Sunday morning"
value={newName}
/>
)}
</Field>
<Button
disabled={create.pending || newName.trim().length === 0}
onClick={() =>
create.run(() => api.send("POST", "timelines", { name: newName.trim() }))
}
variant="primary"
>
Create
</Button>
</div>
) : undefined
}
description="Saved patterns an operator builds on demand. Signage ships none."
title="Timelines"
>
{renderLoadable(
timelines.state,
(value) =>
value.timelines.length === 0 ? (
<EmptyState
description="Create one and add slots to it. There are no presets, on purpose."
title="No timelines yet"
/>
) : (
<Table label="Saved timelines">
<TableHead>
<TableRow>
<TableCell header>Name</TableCell>
<TableCell header>Eligible</TableCell>
<TableCell header>Revision</TableCell>
<TableCell header>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value.timelines.map((timeline) => (
<TableRow key={timeline.id} selected={timeline.id === selectedId}>
<TableCell>
<Button onClick={() => setSelectedId(timeline.id)}>{timeline.name}</Button>
</TableCell>
<TableCell>
{timeline.eligibleFrom === null && timeline.eligibleUntil === null
? "Always"
: `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`}
</TableCell>
<TableCell>{timeline.revision}</TableCell>
<TableCell>
{onActivate === undefined ? undefined : (
<Button onClick={() => onActivate(timeline.id)} variant="primary">
Activate
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
{ label: "Loading timelines…", onRetry: timelines.reload },
)}
<FailureNote failure={create.failure} />
</Panel>
{selectedId === undefined ? undefined : (
<TimelineEditor
api={api}
canManage={canManage}
key={selectedId}
onChanged={timelines.reload}
onClose={() => setSelectedId(undefined)}
providers={providers.state.status === "ready" ? providers.state.value.providers : []}
settings={settings}
timelineId={selectedId}
/>
)}
</div>
);
}
function TimelineEditor({
api,
canManage,
onChanged,
onClose,
providers,
settings,
timelineId,
}: {
readonly api: SignageApi;
readonly canManage: boolean;
readonly onChanged: () => void;
readonly onClose: () => void;
readonly providers: readonly ProviderHealth[];
readonly settings: SettingsResponse;
readonly timelineId: string;
}): ReactElement {
const detail = useResource(
() => api.get<TimelineDetail>(`timelines/${timelineId}`),
[timelineId],
);
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []);
const edit = useAction(() => {
detail.reload();
onChanged();
});
const [providerId, setProviderId] = useState("");
const [sources, setSources] = useState<readonly ProviderSourceDescriptor[]>([]);
const [items, setItems] = useState<readonly ProviderItemDescriptor[]>([]);
function loadProvider(nextProviderId: string): void {
setProviderId(nextProviderId);
setSources([]);
setItems([]);
if (nextProviderId === "") {
return;
}
void api
.get<{ sources: readonly ProviderSourceDescriptor[] }>(
`providers/${encodeURIComponent(nextProviderId)}/sources`,
)
.then((response) => setSources(response.sources))
.catch(() => setSources([]));
void api
.get<{ items: readonly ProviderItemDescriptor[] }>(
`providers/${encodeURIComponent(nextProviderId)}/items`,
)
.then((response) => setItems(response.items))
.catch(() => setItems([]));
}
return (
<Panel
actions={<Button onClick={onClose}>Close</Button>}
description="Slots run in order and repeat. Repeating one source is one rotation, not several."
title="Timeline pattern"
>
{renderLoadable(
detail.state,
(value) => (
<div className="signage-editor" data-testid="signage-timeline-editor">
<h3 className="signage-editor__title">{value.timeline.name}</h3>
{canManage ? (
<div className="signage-editor__add" data-testid="signage-add-source">
<Field
hint="A provider's own source. Repeat its slot to give it more turns in one rotation."
label="Content provider"
>
{(control) => (
<Select
{...control}
onChange={(event) => loadProvider(event.target.value)}
value={providerId}
>
<option value="">Choose a provider…</option>
{providers.map((provider) => (
<option key={provider.providerId} value={provider.providerId}>
{provider.providerId}
</option>
))}
</Select>
)}
</Field>
{sources.map((source) => (
<Button
disabled={edit.pending}
key={source.id}
onClick={() =>
edit.run(() =>
api.send("POST", `timelines/${timelineId}/sources`, {
label: source.label,
providerId,
sourceId: source.id,
}),
)
}
>
{`Add source: ${source.label}`}
</Button>
))}
{items.slice(0, 20).map((item) => (
<Button
disabled={edit.pending}
key={item.id}
onClick={() =>
edit.run(() =>
api.send("POST", `timelines/${timelineId}/slots`, {
itemId: item.id,
providerId,
}),
)
}
>
{`Pin item: ${item.label}`}
</Button>
))}
</div>
) : undefined}
{value.sources.length === 0 ? undefined : (
<div className="signage-editor__sources">
<h4 className="signage-editor__subtitle">Configured sources</h4>
{value.sources.map((source) => (
<div className="signage-editor__source" key={source.id}>
<span>{source.label}</span>
<Badge tone="info">{source.providerId}</Badge>
{canManage ? (
<>
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send("POST", `timelines/${timelineId}/slots`, {
sourceId: source.id,
}),
)
}
>
Add slot
</Button>
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send("DELETE", `timelines/${timelineId}/sources/${source.id}`),
)
}
variant="danger"
>
Remove
</Button>
</>
) : undefined}
</div>
))}
</div>
)}
{value.slots.length === 0 ? (
<EmptyState
description="Add a provider source or pin an image, then repeat it as often as the loop needs."
title="This timeline has no slots"
/>
) : (
<Table label="Timeline slots">
<TableHead>
<TableRow>
<TableCell header>#</TableCell>
<TableCell header>Content</TableCell>
<TableCell header>Duration</TableCell>
<TableCell header>Enabled</TableCell>
<TableCell header>Order</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value.slots.map((slot, index) => {
const source = value.sources.find(
(candidate) => candidate.id === slot.sourceId,
);
const image =
slot.providerId === NATIVE_PROVIDER_ID && images.state.status === "ready"
? images.state.value.images.find(
(candidate) => candidate.id === slot.itemId,
)
: undefined;
return (
<TableRow key={slot.id}>
<TableCell>{slot.position + 1}</TableCell>
<TableCell>
{slot.kind === "provider-source"
? (source?.label ?? "Removed source")
: (image?.displayName ?? `${slot.providerId} · ${slot.itemId}`)}
</TableCell>
<TableCell>
{slot.durationOverrideMs === null
? `Provider default (${formatDuration(settings.nativeImageDurationMs)} for images)`
: formatDuration(slot.durationOverrideMs)}
</TableCell>
<TableCell>
<Checkbox
checked={slot.enabled}
disabled={!canManage || edit.pending}
label={slot.enabled ? "On" : "Off"}
onChange={(event) =>
edit.run(() =>
api.send("PATCH", `timelines/${timelineId}/slots/${slot.id}`, {
enabled: event.target.checked,
}),
)
}
/>
</TableCell>
<TableCell>
{canManage ? (
<div className="signage-editor__order">
<Button
disabled={index === 0 || edit.pending}
onClick={() =>
edit.run(() =>
api.send("PUT", `timelines/${timelineId}/slot-order`, {
slotIds: swap(
value.slots.map((candidate) => candidate.id),
index,
index - 1,
),
}),
)
}
>
Up
</Button>
<Button
disabled={index === value.slots.length - 1 || edit.pending}
onClick={() =>
edit.run(() =>
api.send("PUT", `timelines/${timelineId}/slot-order`, {
slotIds: swap(
value.slots.map((candidate) => candidate.id),
index,
index + 1,
),
}),
)
}
>
Down
</Button>
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send("DELETE", `timelines/${timelineId}/slots/${slot.id}`),
)
}
variant="danger"
>
Remove
</Button>
</div>
) : undefined}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
{canManage ? (
<div className="signage-editor__footer">
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send("POST", `timelines/${timelineId}/duplicate`, {
name: `${value.timeline.name} (copy)`,
}),
)
}
>
Duplicate
</Button>
<Button
disabled={edit.pending}
onClick={() =>
edit.run(async () => {
await api.send("DELETE", `timelines/${timelineId}`);
onClose();
})
}
variant="danger"
>
Delete timeline
</Button>
</div>
) : undefined}
</div>
),
{ label: "Loading timeline…", onRetry: detail.reload },
)}
<FailureNote failure={edit.failure} />
</Panel>
);
}
function swap(ids: readonly string[], from: number, to: number): string[] {
const next = [...ids];
const moved = next[from];
const target = next[to];
if (moved === undefined || target === undefined) {
return next;
}
next[from] = target;
next[to] = moved;
return next;
}
+91
View File
@@ -0,0 +1,91 @@
import { Badge, ErrorState, LoadingState } from "@ekklesia/ui";
import type { ReactElement, ReactNode } from "react";
import type { SignageApiFailure } from "../api.js";
import type { Loadable } from "../hooks.js";
/**
* The one place a panel's loading, failure, and ready states are turned into elements.
*
* Every panel uses it, so a screen that cannot load looks the same wherever it happens — and the
* failure text is always Signage's own sanitized message, never a caught exception.
*/
export function renderLoadable<Value>(
state: Loadable<Value>,
render: (value: Value) => ReactNode,
options: { readonly label: string; readonly onRetry?: () => void },
): ReactNode {
if (state.status === "loading") {
return <LoadingState label={options.label} />;
}
if (state.status === "failed") {
return (
<ErrorState
action={
options.onRetry === undefined ? undefined : (
<button
className="ek-button ek-button--secondary"
onClick={options.onRetry}
type="button"
>
Try again
</button>
)
}
description={state.failure.message}
title="Signage could not load this"
/>
);
}
return render(state.value);
}
/** A refused save, shown where the operator was working. The server's own sanitized message and
* nothing else: no status line, no URL, no request detail. */
export function FailureNote({ failure }: { readonly failure: SignageApiFailure | undefined }) {
if (!failure) {
return undefined;
}
return (
<p className="signage-failure" role="alert">
{failure.message}
</p>
);
}
export function formatDuration(milliseconds: number): string {
const seconds = milliseconds / 1000;
return seconds % 1 === 0 ? `${seconds}s` : `${seconds.toFixed(1)}s`;
}
export function formatInstant(value: string | number | null, timezone: string): string {
if (value === null) {
return "—";
}
const date = typeof value === "number" ? new Date(value) : new Date(value);
if (Number.isNaN(date.getTime())) {
return "—";
}
// Always in the installation's own zone, and always labelled with it. A church reading "9:00"
// needs to know which nine o'clock, and a browser's local zone is not the answer.
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
timeZone: timezone,
}).format(date);
}
export function formatSince(atMs: number | null, nowMs: number): string {
if (atMs === null) {
return "never";
}
const seconds = Math.max(0, Math.round((nowMs - atMs) / 1000));
if (seconds < 60) {
return `${seconds}s ago`;
}
const minutes = Math.round(seconds / 60);
return minutes < 60 ? `${minutes}m ago` : `${Math.round(minutes / 60)}h ago`;
}
export function OnlineBadge({ online }: { readonly online: boolean }): ReactElement {
return <Badge tone={online ? "success" : "danger"}>{online ? "Online" : "Offline"}</Badge>;
}
+522
View File
@@ -0,0 +1,522 @@
/*
* The Signage management UI's own stylesheet.
*
* Compiled into the declared browser artifact as text and installed by the host into this artifact's
* isolated root at mount (ADR 0040), so these selectors reach nothing outside Signage and nothing
* outside Signage reaches in.
*
* Every value is either a registered Ekklesia token or a private `--signage-*` alias derived from
* one. Signage declares no `--ekklesia-*` property: those belong to the platform's global theme, and
* the build rejects a stylesheet that invents one (ADR 0039). Nothing here recolours a provider's
* output or an uploaded image — the Dashboard's theme is the Dashboard's, and a television's content
* belongs to whoever made it.
*/
/*
* The box model for the tree Signage owns.
*
* An isolated root inherits nothing from the page around it, and the platform's stylesheet declares
* no reset of its own — so under the initial `content-box` a `.ek-control`, which is `width: 100%`,
* is laid out wider than the field containing it by its own padding and border, and slides under
* whatever sits beside it. Setting the box model once here fixes that everywhere, rather than
* subtracting padding by hand in each panel.
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
:host {
--signage-gap: var(--ekklesia-spacing-lg);
--signage-gap-tight: var(--ekklesia-spacing-sm);
--signage-panel-radius: var(--ekklesia-radius-lg);
--signage-thumbnail-width: 220px;
}
.signage-desktop {
display: none;
}
.signage-narrow-notice {
background: var(--ekklesia-background-surface);
border: var(--ekklesia-border-width) solid var(--ekklesia-border-base);
border-radius: var(--signage-panel-radius);
color: var(--ekklesia-text-primary);
display: grid;
font-family: var(--ekklesia-typography-family-sans);
gap: var(--signage-gap-tight);
padding: var(--ekklesia-spacing-xl);
}
.signage-narrow-notice__title {
font-size: var(--ekklesia-typography-size-heading-3);
font-weight: var(--ekklesia-typography-weight-bold);
line-height: var(--ekklesia-typography-line-height-tight);
margin: 0;
}
.signage-narrow-notice__body {
color: var(--ekklesia-text-secondary);
line-height: var(--ekklesia-typography-line-height-normal);
margin: 0;
}
@media (min-width: 1024px) {
.signage-narrow-notice {
display: none;
}
.signage-desktop {
display: grid;
gap: var(--signage-gap);
}
}
.signage-columns {
display: grid;
gap: var(--signage-gap);
}
.signage-inline-form {
align-items: end;
display: flex;
flex-wrap: wrap;
gap: var(--signage-gap-tight);
}
/*
* A labelled control beside the button that submits it. The field takes the width its control asks
* for and is allowed to shrink below it, so a narrow header squeezes the control rather than sliding
* the button on top of it.
*/
.signage-inline-form > .ek-field {
min-width: 0;
position: relative;
}
.signage-inline-form .ek-control {
min-width: 0;
}
/*
* On one line, a field's hint hangs below the row rather than making that one field taller. The row
* is bottom-aligned so the controls share a line with the button, and a hint left in flow would push
* its own control above the others.
*/
.signage-inline-form > .ek-field > .ek-field__hint {
inset-block-start: 100%;
inset-inline-start: 0;
padding-block-start: var(--ekklesia-spacing-2xs);
position: absolute;
}
.signage-inline-form:has(.ek-field__hint) {
padding-block-end: var(--ekklesia-spacing-lg);
}
.signage-failure {
color: var(--ekklesia-text-danger);
font-size: var(--ekklesia-typography-size-sm);
margin: var(--signage-gap-tight) 0 0;
}
/*
* A list row that carries a name beside its metadata. The platform's list item is a plain block, so
* without this the two run together into one unreadable word — and the row's own children already
* assume a flex line.
*/
.signage-row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--ekklesia-spacing-sm);
}
.signage-row__name {
flex: 1 1 auto;
}
/* ---------------------------------------------------------------------------------------------
* Images.
* ------------------------------------------------------------------------------------------- */
.signage-upload-trigger {
cursor: pointer;
display: inline-flex;
}
.signage-upload-input {
height: 1px;
opacity: 0;
position: absolute;
width: 1px;
}
.signage-uploads {
display: grid;
gap: var(--signage-gap-tight);
margin-bottom: var(--signage-gap);
}
.signage-uploads__message {
color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm);
}
.signage-image-grid {
display: grid;
gap: var(--signage-gap);
grid-template-columns: repeat(auto-fill, minmax(var(--signage-thumbnail-width), 1fr));
list-style: none;
margin: 0;
padding: 0;
}
.signage-image {
/* Rows sized to their content, with the slack from a taller neighbour left at the bottom rather
than spread between the thumbnail and the name. */
align-content: start;
background: var(--ekklesia-background-canvas);
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-radius: var(--ekklesia-radius-md);
display: grid;
gap: var(--ekklesia-spacing-xs);
overflow: hidden;
padding: var(--ekklesia-spacing-sm);
}
/* One box, whatever shape the image is: a library of mixed aspect ratios still reads as a grid, and
a tall image is letterboxed here rather than reflowing the card it sits in. */
.signage-image__thumbnail {
aspect-ratio: 16 / 9;
background: var(--ekklesia-background-inset);
border-radius: var(--ekklesia-radius-sm);
display: block;
height: auto;
max-width: 100%;
object-fit: contain;
width: 100%;
}
.signage-image__missing {
align-items: center;
aspect-ratio: 16 / 9;
background: var(--ekklesia-background-inset);
border-radius: var(--ekklesia-radius-sm);
color: var(--ekklesia-text-muted);
display: flex;
font-size: var(--ekklesia-typography-size-sm);
justify-content: center;
}
.signage-image__meta {
display: grid;
gap: var(--ekklesia-spacing-2xs);
}
/* A badge is sized by what it says, not by the column it lands in. */
.signage-image__expiry {
justify-self: start;
}
.signage-image__name {
font-weight: var(--ekklesia-typography-weight-medium);
margin: 0;
overflow-wrap: anywhere;
}
.signage-image__detail {
color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm);
margin: 0;
}
.signage-image__actions {
display: flex;
flex-wrap: wrap;
gap: var(--ekklesia-spacing-2xs);
}
/* ---------------------------------------------------------------------------------------------
* Timelines.
* ------------------------------------------------------------------------------------------- */
.signage-editor {
display: grid;
gap: var(--signage-gap);
}
.signage-editor__title {
font-size: var(--ekklesia-typography-size-heading-3);
font-weight: var(--ekklesia-typography-weight-bold);
margin: 0;
}
.signage-editor__subtitle {
color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm);
font-weight: var(--ekklesia-typography-weight-medium);
margin: 0;
text-transform: uppercase;
}
.signage-editor__add,
.signage-editor__sources,
.signage-editor__footer {
display: flex;
flex-wrap: wrap;
gap: var(--signage-gap-tight);
}
.signage-editor__sources {
display: grid;
gap: var(--ekklesia-spacing-xs);
}
.signage-editor__source {
align-items: center;
display: flex;
gap: var(--signage-gap-tight);
}
.signage-editor__order {
display: flex;
gap: var(--ekklesia-spacing-2xs);
}
/* ---------------------------------------------------------------------------------------------
* Playback.
* ------------------------------------------------------------------------------------------- */
.signage-playback {
display: grid;
gap: var(--signage-gap);
}
.signage-playback__status,
.signage-playback__controls,
.signage-playback__activate {
align-items: end;
display: flex;
flex-wrap: wrap;
gap: var(--signage-gap-tight);
}
.signage-playback__current {
column-gap: var(--signage-gap);
display: grid;
grid-template-columns: max-content 1fr;
margin: 0;
row-gap: var(--ekklesia-spacing-2xs);
}
.signage-playback__current dt {
color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm);
}
.signage-playback__current dd {
font-variant-numeric: tabular-nums;
margin: 0;
}
.signage-playback__history-meta {
color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm);
}
/* ---------------------------------------------------------------------------------------------
* Displays and providers.
* ------------------------------------------------------------------------------------------- */
.signage-groups {
display: grid;
gap: var(--signage-gap);
}
.signage-group {
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-radius: var(--ekklesia-radius-md);
display: grid;
gap: var(--signage-gap-tight);
padding: var(--ekklesia-spacing-md);
}
.signage-group__header {
align-items: center;
display: flex;
gap: var(--signage-gap-tight);
}
.signage-provider__id {
font-weight: var(--ekklesia-typography-weight-medium);
}
.signage-provider__version {
color: var(--ekklesia-text-muted);
font-size: var(--ekklesia-typography-size-sm);
/* Far enough that the version reads as a second word, not the tail of the identifier. */
margin-left: var(--ekklesia-spacing-sm);
}
/* ---------------------------------------------------------------------------------------------
* Live preview.
*
* The frame is a 16:9 box with a black ground, because that is what a television is. Inside it the
* display's own scene layers render exactly as they do on a wall — this panel supplies a size and
* nothing else.
* ------------------------------------------------------------------------------------------- */
.signage-preview {
display: grid;
gap: var(--signage-gap-tight);
}
.signage-preview__frame {
aspect-ratio: 16 / 9;
background: #000000;
border: var(--ekklesia-border-width) solid var(--ekklesia-border-strong);
border-radius: var(--ekklesia-radius-md);
overflow: hidden;
position: relative;
width: 100%;
}
.signage-preview .signage-stage {
height: 100%;
inset: 0;
min-height: 0;
position: absolute;
width: 100%;
}
.signage-preview .signage-layer {
height: 100%;
inset: 0;
opacity: 0;
position: absolute;
transition-duration: 0ms;
transition-property: opacity;
transition-timing-function: linear;
width: 100%;
}
.signage-preview .signage-layer__background {
background: #000000;
inset: 0;
position: absolute;
}
.signage-preview .signage-layer__background-image {
display: none;
height: 100%;
inset: 0;
object-fit: cover;
position: absolute;
transform: scale(1.08);
width: 100%;
}
.signage-preview .signage-layer__darken {
background: #000000;
inset: 0;
opacity: 0;
position: absolute;
}
.signage-preview .signage-layer__foreground {
height: 100%;
inset: 0;
object-fit: contain;
position: absolute;
width: 100%;
}
.signage-preview .signage-layer__renderer {
inset: 0;
position: absolute;
}
.signage-preview .signage-blackout {
background: #000000;
inset: 0;
opacity: 0;
pointer-events: none;
position: absolute;
transition: opacity 200ms linear;
z-index: 20;
}
.signage-preview [data-blackout="true"] > .signage-blackout {
opacity: 1;
}
.signage-preview .signage-slate-host {
inset: 0;
opacity: 0;
pointer-events: none;
position: absolute;
z-index: 10;
}
.signage-preview [data-slate="true"] > .signage-slate-host {
opacity: 1;
}
.signage-preview .signage-slate {
align-items: stretch;
display: flex;
flex-direction: column;
height: 100%;
inset: 0;
position: absolute;
width: 100%;
}
.signage-preview .signage-slate__bars {
display: grid;
flex: 1 1 auto;
grid-template-columns: repeat(6, 1fr);
}
.signage-preview .signage-slate__bar {
display: block;
height: 100%;
}
.signage-preview .signage-slate__bar--one {
background: #6f6f6f;
}
.signage-preview .signage-slate__bar--two {
background: #6f6f2f;
}
.signage-preview .signage-slate__bar--three {
background: #2f6f6f;
}
.signage-preview .signage-slate__bar--four {
background: #2f6f2f;
}
.signage-preview .signage-slate__bar--five {
background: #6f2f6f;
}
.signage-preview .signage-slate__bar--six {
background: #6f2f2f;
}
.signage-preview .signage-slate__message {
background: #000000;
color: #ffffff;
flex: 0 0 auto;
font-size: 2vh;
letter-spacing: 0.08em;
margin: 0;
padding: 1.5vh 0;
text-align: center;
text-transform: uppercase;
}
+198
View File
@@ -0,0 +1,198 @@
/**
* The wire shapes Signage's dashboard reads.
*
* Signage owns both ends of every one of these, which is exactly why they are written down: the
* server's route and this file are one contract, and a drift between them is a bug an operator sees
* rather than a compiler does.
*/
export interface SettingsResponse {
readonly crossfadeMs: number;
readonly moduleVersion: string;
readonly nativeImageDurationMs: number;
readonly nativePresentation: {
readonly backgroundMode: string;
readonly backgroundColor: string;
readonly backgroundDarkened: boolean;
readonly blurPixels: number;
};
readonly rendererInputVersion: string;
readonly timezone: string;
readonly uploads: {
readonly batchMaxFiles: number;
readonly concurrency: number;
readonly maxBytes: number;
readonly maxPixels: number;
};
}
export interface ImageRendition {
readonly kind: string;
readonly url: string;
readonly expiresAt: string;
readonly width: number;
readonly height: number;
}
export interface ImageRecord {
readonly id: string;
readonly displayName: string;
readonly originalFilename: string;
readonly contentType: string;
readonly byteSize: number;
readonly width: number;
readonly height: number;
readonly status: string;
readonly expiresAt: string | null;
readonly createdAt: string;
readonly renditions: readonly ImageRendition[];
}
export interface TimelineSummary {
readonly id: string;
readonly name: string;
readonly description: string | null;
readonly eligibleFrom: string | null;
readonly eligibleUntil: string | null;
readonly revision: number;
readonly updatedAt: string;
}
export interface TimelineSource {
readonly id: string;
readonly label: string;
readonly providerId: string;
readonly sourceId: string;
}
export interface TimelineSlot {
readonly id: string;
readonly position: number;
readonly kind: "provider-source" | "pinned-item";
readonly sourceId: string | null;
readonly providerId: string | null;
readonly itemId: string | null;
readonly enabled: boolean;
readonly durationOverrideMs: number | null;
readonly eligibleFrom: string | null;
readonly eligibleUntil: string | null;
readonly presentation: {
readonly backgroundMode: string | null;
readonly backgroundColor: string | null;
readonly backgroundDarkened: boolean | null;
readonly blurPixels: number | null;
readonly foregroundShadow: boolean | null;
};
}
export interface TimelineDetail {
readonly timeline: TimelineSummary;
readonly sources: readonly TimelineSource[];
readonly slots: readonly TimelineSlot[];
}
export interface ProviderHealth {
readonly providerId: string;
readonly providerVersion: string;
readonly contractVersion: string;
readonly renderer: { readonly contributionId: string; readonly contributionVersion: string };
readonly defaultDurationMs: number;
readonly registeredAt: string;
readonly consecutiveFailures: number;
readonly lastFailureCode: string | null;
readonly lastFailureAt: string | null;
readonly lastSuccessAt: string | null;
}
export interface ProviderSourceDescriptor {
readonly id: string;
readonly label: string;
readonly description?: string;
}
export interface ProviderItemDescriptor {
readonly id: string;
readonly label: string;
readonly description?: string;
readonly availableUntil?: string | null;
}
export type TransportState = "idle" | "playing" | "paused" | "static";
export interface PlaybackSnapshotView {
readonly groupId: string;
readonly revision: number;
readonly serverNowMs: number;
readonly transport: TransportState;
readonly blackout: boolean;
readonly activeTimelineId: string | null;
readonly pendingTimelineId: string | null;
readonly scheduledTimelineId: string | null;
readonly scheduledActivationAtMs: number | null;
readonly currentSelectionId: string | null;
readonly currentSlotId: string | null;
readonly startedAtMs: number | null;
readonly deadlineAtMs: number | null;
readonly remainingMs: number | null;
readonly selectionRevision: number | null;
readonly staticAssetId: string | null;
}
export interface ChannelView {
readonly id: string;
readonly key: string;
readonly name: string;
readonly isDefault: boolean;
}
export interface ChannelGroupView {
readonly id: string;
readonly name: string;
readonly revision: number;
readonly channels: readonly ChannelView[];
readonly playback: PlaybackSnapshotView;
}
export interface PlaybackDetailView {
readonly snapshot: PlaybackSnapshotView;
readonly current: {
readonly id: string;
readonly itemId: string;
readonly itemRevision: string;
readonly providerId: string;
readonly rendererContributionId: string | null;
readonly revision: number;
readonly stale: boolean;
readonly withdrawn: boolean;
readonly durationMs: number;
readonly startedAtMs: number | null;
readonly deadlineAtMs: number | null;
} | null;
readonly history: readonly {
readonly id: string;
readonly itemId: string;
readonly providerId: string;
readonly sequence: number;
readonly startedAtMs: number | null;
}[];
}
export interface ViewerHealthView {
readonly id: string;
readonly name: string;
readonly online: boolean;
readonly channelId: string | null;
readonly deviceId: string | null;
readonly lastSeenAtMs: number | null;
readonly reportedTimelineId: string | null;
readonly reportedSelectionId: string | null;
readonly reportedProviderId: string | null;
readonly reportedContributionId: string | null;
readonly offlineReady: boolean;
readonly cachedAssetCount: number;
readonly lastError: {
readonly code: string;
readonly detail: string | null;
readonly atMs: number;
} | null;
}