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