fix(display): continue a source slot's rotation while offline
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
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5f52334664
commit
ab5b02960a
+115
-7
@@ -1,6 +1,10 @@
|
||||
import {
|
||||
advanceCursor,
|
||||
eligibleSlots,
|
||||
firstEligibilityBoundary,
|
||||
nextTimingBoundary,
|
||||
TIMING_TRANSITION_LIMIT,
|
||||
type TimingCursor,
|
||||
type TimingSlot,
|
||||
type TimingTimeline,
|
||||
} from "../shared/playback-timing.js";
|
||||
@@ -111,17 +115,110 @@ function isUsable(selection: SceneSelection, atMs: number, unusable: ReadonlySet
|
||||
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(
|
||||
/**
|
||||
* 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 {
|
||||
return snapshot.selections.find(
|
||||
(selection) => selection.slotId === slotId && isUsable(selection, atMs, unusable),
|
||||
);
|
||||
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 {
|
||||
@@ -179,6 +276,11 @@ export function resolveOffline(input: {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -224,7 +326,13 @@ export function resolveOffline(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const cached = cachedForSlot(snapshot, advance.slotId, atMs, unusable);
|
||||
const cached = cachedForSlot(
|
||||
snapshot,
|
||||
advance.slotId,
|
||||
playsSinceAnchor(timing, anchor, advance.slotId, advance.startedAtMs),
|
||||
atMs,
|
||||
unusable,
|
||||
);
|
||||
if (cached) {
|
||||
const nextCursor = {
|
||||
deadlineAtMs: advance.deadlineAtMs,
|
||||
|
||||
Reference in New Issue
Block a user