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