CI / typecheck (pull_request) Successful in 24s
CI / lint (pull_request) Successful in 26s
CI / build (pull_request) Successful in 28s
CI / test (pull_request) Successful in 29s
CI / package (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 8m49s
Offline continuation replayed each slot's newest cached answer. A provider-source slot is answered with the provider's next item on every play, so its newest answer is the image already on screen: on a timeline of one source slot, every offline boundary "advanced" to the same picture. Stage 0's real timeline is exactly that, which is why the wall froze whether the network or only the API went away, and resumed the moment the server answered. Each slot's cached answers are now its observed rotation (one per item, oldest-played first), and the k-th play of the slot since the anchor shows the k-th entry. The play count is arithmetic, skipping whole passes wherever the eligible set is stable, so a week offline is still one evaluation. Where a play starts is unchanged and still decided by advanceCursor alone; a pinned slot is a rotation of one and behaves as before. Nothing is invented: only answers the server already resolved are replayed, and an item whose newest answer is unusable is skipped. Regression tests: the resolver on a one-slot rotation of two and three images, across a week, and with an unusable image; and the controller on the real shape for six slide intervals with the network down and with only the API down. Co-Authored-By: Claude Opus 5.5 <[email protected]>
397 lines
14 KiB
TypeScript
397 lines
14 KiB
TypeScript
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<string>): 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<string>,
|
|
): readonly SceneSelection[] {
|
|
const seen = new Set<string>();
|
|
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<string>,
|
|
): 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<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);
|
|
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;
|
|
}
|