diff --git a/apps/control-station/src/components/RerunViewport.tsx b/apps/control-station/src/components/RerunViewport.tsx index 5900a42..2b8a6a1 100644 --- a/apps/control-station/src/components/RerunViewport.tsx +++ b/apps/control-station/src/components/RerunViewport.tsx @@ -34,7 +34,9 @@ import { RECORDED_BASE_POINT_COLOR_KEY, canPublishRecordedPlaybackController, createRecordedAutoplayGate, + createRecordedInitialSeekGate, createRecordedOpenWatchdog, + shouldReapplyRecordedBlueprint, } from "../core/observation/recordedRerunLifecycle"; import type { RecordedPerceptionLayers, @@ -59,6 +61,7 @@ export { attemptRecordedAutoplay, canPublishRecordedPlaybackController, createRecordedAutoplayGate, + createRecordedInitialSeekGate, createRecordedOpenWatchdog, isRecordedPlaybackFullyBuffered, isRecordedPlaybackPresentationReady, @@ -69,6 +72,7 @@ export { recordedPlaybackRangeWhenReady, recordedPointColorKey, rerunPresentationStatus, + shouldReapplyRecordedBlueprint, type RecordedPlaybackBufferState, } from "../core/observation/recordedRerunLifecycle"; export { @@ -828,6 +832,7 @@ export function RerunViewport({ let publishPlaybackBufferState: (() => void) | null = null; let playbackState: RerunPlaybackState | null = null; const recordedAutoplay = createRecordedAutoplayGate(); + const recordedInitialSeek = createRecordedInitialSeekGate(); let blueprintChannel: RerunBlueprintChannel | null = null; let perceptionChannel: RerunBlueprintChannel | null = null; let perceptionReceiver: RerunNativeReceiver | null = null; @@ -1144,9 +1149,25 @@ export function RerunViewport({ }) => { if ( disposed || - recordingOpened || (isRecordedSource && event.application_id !== "nodedc_mission_core_recorded") ) return; + if (recordingOpened) { + const identity = recordedIdentityRef.current; + if (shouldReapplyRecordedBlueprint( + recordingOpened, + isRecordedSource, + recordedBlueprintUrl !== null, + identity, + event, + )) { + // A LAB sidecar is a second upstream receiver for the same + // recording store. Rerun re-opens that store only after the + // sidecar has been decoded, so reapply the canonical blueprint + // at this exact boundary instead of racing it during download. + setBlueprintChannelRevision((revision) => revision + 1); + } + return; + } recordingOpened = true; if (!isRecordedSource) { // Store discovery only establishes a candidate. Admission is @@ -1314,6 +1335,23 @@ export function RerunViewport({ playing = true; } } + if (!autoplayWhenReady && !followLive && readyToRender) { + recordedInitialSeek.attempt( + viewerStartResolved, + recordedBuffer.fullyBuffered, + presentationReady, + rangeNs, + (initialStartNs) => { + viewer.set_current_time(event.recording_id, timeline, initialStartNs); + currentNs = initialStartNs; + }, + initialPlaybackStartSeconds === undefined + ? expectedTimelineStartSeconds === undefined + ? undefined + : expectedTimelineStartSeconds * 1_000_000_000 + : initialPlaybackStartSeconds * 1_000_000_000, + ); + } emitPlayback({ rangeNs: followLive ? rangeNs diff --git a/apps/control-station/src/core/observation/recordedRerunLifecycle.ts b/apps/control-station/src/core/observation/recordedRerunLifecycle.ts index eb07dc4..3d79047 100644 --- a/apps/control-station/src/core/observation/recordedRerunLifecycle.ts +++ b/apps/control-station/src/core/observation/recordedRerunLifecycle.ts @@ -196,6 +196,20 @@ export function isRecordedPlaybackFullyBuffered( return recordedPlaybackBufferState(rangeNs, expectedTimelineEndSeconds).fullyBuffered; } +export function shouldReapplyRecordedBlueprint( + recordingOpened: boolean, + recordedSource: boolean, + hasBlueprint: boolean, + identity: { applicationId: string; recordingId: string } | null, + event: { application_id: string; recording_id: string }, +): boolean { + return recordingOpened + && recordedSource + && hasBlueprint + && identity?.applicationId === event.application_id + && identity.recordingId === event.recording_id; +} + export function attemptRecordedAutoplay( seekToStart: () => void, startPlaying: () => void, @@ -209,6 +223,49 @@ export function attemptRecordedAutoplay( } } +export function createRecordedInitialSeekGate(): { + attempt: ( + viewerStarted: boolean, + fullyBuffered: boolean, + presentationReady: boolean, + rangeNs: { min: number; max: number } | null, + seekToStart: (startNs: number) => void, + preferredStartNs?: number, + ) => boolean; + attempted: () => boolean; +} { + let consumed = false; + return { + attempt( + viewerStarted, + fullyBuffered, + presentationReady, + rangeNs, + seekToStart, + preferredStartNs, + ) { + if ( + consumed + || !viewerStarted + || !fullyBuffered + || !presentationReady + || !isUsableRecordedPlaybackRange(rangeNs) + ) return false; + consumed = true; + const startNs = Number.isFinite(preferredStartNs) + ? Math.min(Math.max(preferredStartNs as number, rangeNs.min), rangeNs.max) + : rangeNs.min; + try { + seekToStart(startNs); + return true; + } catch { + return false; + } + }, + attempted: () => consumed, + }; +} + export function createRecordedAutoplayGate(): { attempt: ( viewerStarted: boolean, diff --git a/apps/control-station/test/rerunViewportProgressivePlayback.test.mjs b/apps/control-station/test/rerunViewportProgressivePlayback.test.mjs index 5d361b7..74393b1 100644 --- a/apps/control-station/test/rerunViewportProgressivePlayback.test.mjs +++ b/apps/control-station/test/rerunViewportProgressivePlayback.test.mjs @@ -6,12 +6,14 @@ import { createServer } from "vite"; let server; let canPublishRecordedPlaybackController; let createRecordedAutoplayGate; +let createRecordedInitialSeekGate; let isRecordedPlaybackReady; let isRecordedPlaybackPresentationReady; let isUsableRecordedPlaybackRange; let recordedPlaybackBufferState; let recordedPlaybackRangeWhenReady; let rerunPresentationStatus; +let shouldReapplyRecordedBlueprint; before(async () => { server = await createServer({ @@ -22,12 +24,14 @@ before(async () => { ({ canPublishRecordedPlaybackController, createRecordedAutoplayGate, + createRecordedInitialSeekGate, isRecordedPlaybackReady, isRecordedPlaybackPresentationReady, isUsableRecordedPlaybackRange, recordedPlaybackBufferState, recordedPlaybackRangeWhenReady, rerunPresentationStatus, + shouldReapplyRecordedBlueprint, } = await server.ssrLoadModule("/src/components/RerunViewport.tsx")); }); @@ -159,6 +163,58 @@ test("recorded autoplay waits for the full range and then runs exactly once", () assert.equal(gate.attempted(), true); }); +test("paused recorded replay seeks once to its first presentable frame", () => { + const gate = createRecordedInitialSeekGate(); + const seeks = []; + const range = { min: 0, max: 535_717_620_042 }; + + assert.equal(gate.attempt( + true, + true, + true, + range, + (value) => seeks.push(value), + 39_215_263_458, + ), true); + assert.equal(gate.attempt( + true, + true, + true, + range, + (value) => seeks.push(value), + 50_000_000_000, + ), false); + assert.deepEqual(seeks, [39_215_263_458]); + assert.equal(gate.attempted(), true); +}); + +test("LAB sidecar store reopen reapplies only its matching recorded blueprint", () => { + const identity = { + applicationId: "nodedc_mission_core_recorded", + recordingId: "recording-001", + }; + const event = { + application_id: "nodedc_mission_core_recorded", + recording_id: "recording-001", + }; + + assert.equal( + shouldReapplyRecordedBlueprint(true, true, true, identity, event), + true, + ); + assert.equal( + shouldReapplyRecordedBlueprint(false, true, true, identity, event), + false, + ); + assert.equal( + shouldReapplyRecordedBlueprint(true, true, true, identity, { + ...event, + recording_id: "recording-002", + }), + false, + ); +}); + test("recorded autoplay starts at the first presentable camera frame without shrinking the range", () => { const gate = createRecordedAutoplayGate(); const seeks = [];