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
+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,
};
}