The display controller and the viewer routes each described the heartbeat their own way and disagreed about one thing: the display sent null for "nothing to report", and the route accepted only a missing field. Every heartbeat from a screen showing a native image was refused with a 400. The roster therefore marked working screens Offline 90 s after they opened, and never recorded offlineReady or cachedAssetCount. src/shared/display-protocol.ts now defines every message between a display and the server: - viewer session request and response; - heartbeat request and response; - channel state; - the realtime display payload. Both halves import it. Its rules: a nullable field accepts null explicitly, an absent field means "not reported", and an unknown field is ignored, so a newer display is never refused by an older server. - The routes parse with the shared parsers, and a refusal names the field with Signage's sanitized code. - The server's channel state extends the wire type, so a field the display relies on cannot drift without a compile error. - The display builds its heartbeat as the shared type, and reads its registration with the shared parser instead of casting it. - Test stubs that described server responses more loosely than the server sends them (no viewerName, no stale, partial media) now match the real responses and are typed as the protocol. Co-Authored-By: Claude Opus 5.5 <[email protected]>
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
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<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,
|
|
};
|
|
}
|