feat: initial Signage 0.1.0
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user