import { advanceCursor, eligibleSlots, firstEligibilityBoundary, nextTimingBoundary, TIMING_TRANSITION_LIMIT, type TimingCursor, 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): boolean { if (unusable.has(selection.id) || selection.withdrawn) { return false; } return selection.availableUntilMs === null || atMs < selection.availableUntilMs; } /** * The answers one slot has already had, as the rotation they came in: one per item, oldest-played * first, so the item on screen most recently is last. * * A pinned slot has one item and so a rotation of one. A provider-source slot is answered with the * provider's *next* item on every play, so its history is the provider's own order — and replaying * only the newest answer would replay the image already on screen at every boundary. Each item's * newest answer is the one that counts: an item whose newest answer was withdrawn or failed here is * left out, never replaced by an older answer for it. */ function rotationForSlot( snapshot: OfflineSnapshot, slotId: string, atMs: number, unusable: ReadonlySet, ): readonly SceneSelection[] { const seen = new Set(); const newestFirst: SceneSelection[] = []; // History is newest-first, so the first answer seen for an item is its most recent one. for (const selection of snapshot.selections) { if (selection.slotId !== slotId) { continue; } const item = `${selection.providerId}\u0000${selection.itemId}`; if (seen.has(item)) { continue; } seen.add(item); if (isUsable(selection, atMs, unusable)) { newestFirst.push(selection); } } return newestFirst.reverse(); } /** The cached answer for the `play`-th play of a slot since the anchor (1 is the next one). */ function cachedForSlot( snapshot: OfflineSnapshot, slotId: string, play: number, atMs: number, unusable: ReadonlySet, ): SceneSelection | undefined { const rotation = rotationForSlot(snapshot, slotId, atMs, unusable); if (rotation.length === 0) { return undefined; } return rotation[(Math.max(1, play) - 1) % rotation.length]; } /** * How many times `slotId` has started since the anchor, up to and including a play starting at * `throughMs`. * * Arithmetic, like the cursor itself: wherever the eligible set cannot change, whole passes of the * pattern are counted by division — every eligible slot plays exactly once per pass — so a week * offline is one evaluation. It only chooses *which* cached answer a play shows; where a play * starts is still decided by `advanceCursor` alone. */ function playsSinceAnchor( timeline: TimingTimeline, anchor: TimingCursor, slotId: string, throughMs: number, ): number { let cursor = anchor; let plays = 0; for (let step = 0; step < TIMING_TRANSITION_LIMIT; step += 1) { const fromMs = cursor.deadlineAtMs; if (fromMs === null || fromMs > throughMs) { break; } const stable = eligibleSlots(timeline, fromMs); const passDuration = stable.reduce((total, slot) => total + slot.estimatedDurationMs, 0); const changeAt = firstEligibilityBoundary(timeline, fromMs, throughMs); const stableThrough = changeAt === null ? throughMs : changeAt - 1; if (passDuration > 0 && stable.some((slot) => slot.id === cursor.slotId)) { const passes = Math.floor((stableThrough - fromMs) / passDuration); if (passes > 0) { if (stable.some((slot) => slot.id === slotId)) { plays += passes; } cursor = { deadlineAtMs: fromMs + passes * passDuration, slotId: cursor.slotId, startedAtMs: (cursor.startedAtMs ?? fromMs) + passes * passDuration, }; continue; } } const next = advanceCursor(timeline, cursor, fromMs); if (next.kind !== "advance") { break; } if (next.slotId === slotId) { plays += 1; } cursor = { deadlineAtMs: next.deadlineAtMs, slotId: next.slotId, startedAtMs: next.startedAtMs, }; } return plays; } 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; }): OfflineEvaluation { const { snapshot, atMs } = input; const unusable = input.unusableSelectionIds ?? new Set(); 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); const anchor: TimingCursor = { deadlineAtMs: snapshot.playback.deadlineAtMs, slotId: snapshot.playback.currentSlotId, startedAtMs: snapshot.playback.startedAtMs, }; 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, playsSinceAnchor(timing, anchor, advance.slotId, advance.startedAtMs), 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; }