import type { ModuleStorage } from "@ekklesia/module-sdk"; import type { DisplayChannelState as DisplayChannelStateWire, DisplaySelection, DisplayTimeline, } from "../../shared/display-protocol.js"; 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 type { DisplayMedia, DisplaySelection, DisplayTimeline, DisplayTimelineSlot, } from "../../shared/display-protocol.js"; /** * The channel state the server builds: the wire shape a display reads (`src/shared`), plus the * channel's name and the group's whole playback snapshot. Declared as an extension so that a field * the display relies on cannot be renamed or retyped here without failing to compile. */ export interface DisplayChannelState extends DisplayChannelStateWire { readonly channel: DisplayChannelStateWire["channel"] & { readonly name: string }; readonly playback: PlaybackSnapshot; } /** 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 { 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 { 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 { 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 { 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, }; }