diff --git a/apps/control-station/src/components/RecordedFmp4Player.tsx b/apps/control-station/src/components/RecordedFmp4Player.tsx index 33f4d33..d4ef085 100644 --- a/apps/control-station/src/components/RecordedFmp4Player.tsx +++ b/apps/control-station/src/components/RecordedFmp4Player.tsx @@ -29,7 +29,9 @@ export type RecordedMediaPresentationState = "loading" | "ready" | "waiting" | " export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1; const RECORDED_MEDIA_SOURCE_OPEN_TIMEOUT_MS = 10_000; -const RECORDED_MEDIA_TARGET_TIMEOUT_MS = 10_000; +// A valid local fragment becomes decoder-ready well below one second. Keeping +// a damaged GOP on screen for ten seconds only delays the keyframe recovery. +const RECORDED_MEDIA_TARGET_TIMEOUT_MS = 2_500; const RECORDED_MEDIA_FRAGMENT_TIMEOUT_MS = 15_000; const RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS = 12; const RECORDED_MEDIA_SEGMENTS_AHEAD = 36; @@ -72,6 +74,39 @@ export function recordedMediaDecodeStartSequence( return selected; } +export function nextRecordedMediaRandomAccessSequence( + randomAccessSequences: readonly number[], + failedSequence: number, +): number | null { + if (!Number.isInteger(failedSequence) || failedSequence < 1) return null; + for (const sequence of randomAccessSequences) { + if (!Number.isInteger(sequence) || sequence < 1) return null; + if (sequence > failedSequence) return sequence; + } + return null; +} + +export function recordedMediaRecoveryTargetSequence( + requestedSequence: number | null, + failedSequence: number | null, + recoverySequence: number | null, +): number | null { + if (requestedSequence === null) return null; + if ( + !Number.isInteger(requestedSequence) + || requestedSequence < 1 + || failedSequence === null + || recoverySequence === null + || !Number.isInteger(failedSequence) + || !Number.isInteger(recoverySequence) + || failedSequence < 1 + || recoverySequence <= failedSequence + ) return requestedSequence; + return requestedSequence >= failedSequence && requestedSequence < recoverySequence + ? recoverySequence + : requestedSequence; +} + export function recordedMediaSegmentAppendOrder( appended: ReadonlySet, decodeStartSequence: number, @@ -90,6 +125,32 @@ export function recordedMediaSegmentAppendOrder( return missing; } +/** Resolve a source-clock timestamp to the first fMP4 fragment covering it. */ +export function recordedMediaSegmentSequenceAtTime( + segmentEndTimesSeconds: readonly number[], + epochStartSeconds: number, + currentSeconds: number, +): number | null { + if ( + !segmentEndTimesSeconds.length || + !Number.isFinite(epochStartSeconds) || + !Number.isFinite(currentSeconds) + ) return null; + const localSeconds = Math.max(0, currentSeconds - epochStartSeconds); + let left = 0; + let right = segmentEndTimesSeconds.length - 1; + while (left < right) { + const middle = Math.floor((left + right) / 2); + const endSeconds = segmentEndTimesSeconds[middle]; + if (!Number.isFinite(endSeconds) || endSeconds <= 0) return null; + if (endSeconds + 0.001 >= localSeconds) right = middle; + else left = middle + 1; + } + const finalEndSeconds = segmentEndTimesSeconds[left]; + if (!Number.isFinite(finalEndSeconds) || finalEndSeconds + 0.001 < localSeconds) return null; + return left + 1; +} + export function recordedMediaCanRollTarget( previousSequence: number, nextSequence: number, @@ -151,6 +212,24 @@ export function selectRecordedMediaEpoch( return selected && currentSeconds <= selected.timelineEndSeconds ? selected : null; } +/** + * Keep admission bounded even when the shared Rerun clock is currently before, + * between, or after camera epochs. Presentation still reports `waiting`; this + * selector only chooses the nearest epoch whose first/last fragment can prove + * that the camera transport is usable without downloading the whole MP4. + */ +export function selectRecordedMediaPreparationEpoch( + epochs: readonly ObservationRecordedMediaEpoch[], + currentSeconds: number, +): ObservationRecordedMediaEpoch | null { + if (!epochs.length || !Number.isFinite(currentSeconds)) return null; + const active = selectRecordedMediaEpoch(epochs, currentSeconds); + if (active) return active; + return epochs.find((epoch) => epoch.timelineStartSeconds > currentSeconds) + ?? epochs.at(-1) + ?? null; +} + export function recordedMediaSeekableCoverage( durationSeconds: number, seekableEndSeconds: number, @@ -231,29 +310,14 @@ export async function fetchRecordedMediaArchive( return { manifest, byteLength: totalBytes }; } -function videoHasSeekableArchive( +function waitForRecordedVideoInitialFrame( video: HTMLVideoElement, - declaredDurationSeconds: number, -): boolean { - if (video.readyState < 1 || video.seekable.length < 1) return false; - return recordedMediaSeekableCoverage( - video.duration, - video.seekable.end(video.seekable.length - 1), - declaredDurationSeconds, - RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS, - video.seekable.start(0), - ); -} - -function waitForSeekableArchive( - video: HTMLVideoElement, - declaredDurationSeconds: number, signal: AbortSignal, ): Promise { if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")); - if (videoHasSeekableArchive(video, declaredDurationSeconds)) return Promise.resolve(); + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return Promise.resolve(); return new Promise((resolve, reject) => { - const events = ["loadedmetadata", "durationchange", "progress", "canplay"] as const; + const events = ["loadeddata", "canplay", "progress"] as const; let stallTimer: ReturnType | undefined; const armStallTimer = () => { if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer); @@ -270,7 +334,7 @@ function waitForSeekableArchive( }; const onProgress = () => { armStallTimer(); - if (!videoHasSeekableArchive(video, declaredDurationSeconds)) return; + if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return; cleanup(); resolve(); }; @@ -313,11 +377,7 @@ async function mountRecordedEpochStream( }; video.load(); try { - await waitForSeekableArchive( - video, - descriptor.timelineEndSeconds - descriptor.timelineStartSeconds, - signal, - ); + await waitForRecordedVideoInitialFrame(video, signal); return cleanup; } catch (error) { cleanup(); @@ -336,6 +396,11 @@ interface RecordedSegmentTarget { resetAttempts: number; } +interface RecordedSegmentRecovery { + readonly failedSequence: number; + readonly recoverySequence: number; +} + interface RecordedSegmentStreamRuntime { readonly generation: string; readonly mediaSource: MediaSource; @@ -768,12 +833,16 @@ export function RecordedFmp4Player({ ); const [archive, setArchive] = useState(null); const [state, setState] = useState<"loading" | "ready" | "error">("loading"); + const [errorMessage, setErrorMessage] = useState(null); const [readyGeneration, setReadyGeneration] = useState(null); const [bufferRevision, setBufferRevision] = useState(0); + const [segmentRecoveryGeneration, setSegmentRecoveryGeneration] = useState(0); + const [segmentRecovery, setSegmentRecovery] = useState(null); const segmentedRuntimeRef = useRef(null); const [segmentedRuntimeGeneration, setSegmentedRuntimeGeneration] = useState(null); const targetRevisionRef = useRef(0); const targetReadyAbortRef = useRef(null); + const lastSegmentRecoveryRef = useRef(null); const playAttemptRevisionRef = useRef(0); const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0; const playbackPlayingRef = useRef(Boolean(playback?.playing)); @@ -781,25 +850,60 @@ export function RecordedFmp4Player({ const playbackRate = playback?.rate && Number.isFinite(playback.rate) ? Math.min(4, Math.max(0.25, playback.rate)) : 1; - const epoch = useMemo( + const presentationEpoch = useMemo( () => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds), [archive?.manifest.epochs, currentSeconds], ); + const epoch = useMemo( + () => selectRecordedMediaPreparationEpoch( + archive?.manifest.epochs ?? [], + currentSeconds, + ), + [archive?.manifest.epochs, currentSeconds], + ); + const segmentClockSeconds = epoch + ? Math.min( + Math.max(currentSeconds, epoch.timelineStartSeconds), + epoch.timelineEndSeconds, + ) + : currentSeconds; + const effectiveSegmentCount = segmentCount ?? epoch?.segmentCount ?? null; + const requestedSegmentSequence = segmentSequence ?? (epoch + ? recordedMediaSegmentSequenceAtTime( + epoch.segmentEndTimesSeconds, + epoch.timelineStartSeconds, + segmentClockSeconds, + ) + : null); + const effectiveSegmentSequence = recordedMediaRecoveryTargetSequence( + requestedSegmentSequence, + segmentRecovery?.failedSequence ?? null, + segmentRecovery?.recoverySequence ?? null, + ); + const holdingForSegmentRecovery = Boolean( + segmentRecovery + && requestedSegmentSequence !== null + && effectiveSegmentSequence !== requestedSegmentSequence, + ); const segmented = Boolean( - segmentCount !== null - && Number.isInteger(segmentCount) - && segmentCount >= 1 + requestedSegmentSequence !== null + && Number.isInteger(requestedSegmentSequence) + && requestedSegmentSequence >= 1 + && + effectiveSegmentCount !== null + && Number.isInteger(effectiveSegmentCount) + && effectiveSegmentCount >= 1 && typeof MediaSource !== "undefined" && epoch - && epoch.segmentCount === segmentCount + && epoch.segmentCount === effectiveSegmentCount && epoch.randomAccessSequences.length > 0 - && epoch.segmentEndTimesSeconds.length === segmentCount + && epoch.segmentEndTimesSeconds.length === effectiveSegmentCount && MediaSource.isTypeSupported(epoch.mediaType), ); const directPlaybackSeconds = segmented ? null : currentSeconds; - const waitingForEpoch = Boolean(archive && !epoch); - const selectedGeneration = contract && epoch - ? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}` + const waitingForEpoch = Boolean(archive && !presentationEpoch); + const selectedGeneration = contract && presentationEpoch + ? `${contract.manifestGenerationSha256}:${presentationEpoch.ordinal}:${presentationEpoch.timelineStartSeconds}:${presentationEpoch.timelineEndSeconds}` : null; const visualState = recordedMediaPresentationState( state, @@ -813,6 +917,7 @@ export function RecordedFmp4Player({ if (!contract) { setArchive(null); setReadyGeneration(null); + setErrorMessage("Некорректный descriptor записанной камеры."); setState("error"); reportAdmission({ phase: "error", @@ -825,6 +930,9 @@ export function RecordedFmp4Player({ const abort = new AbortController(); setArchive(null); setReadyGeneration(null); + setSegmentRecovery(null); + lastSegmentRecoveryRef.current = null; + setErrorMessage(null); setState("loading"); reportAdmission({ phase: "loading", @@ -842,6 +950,7 @@ export function RecordedFmp4Player({ } setArchive(null); setReadyGeneration(null); + setErrorMessage("Архив записанной камеры не прошёл проверку."); setState("error"); reportAdmission({ phase: "error", @@ -853,43 +962,14 @@ export function RecordedFmp4Player({ }, [admissionKey, contract, prepare]); useEffect(() => { - if (!archive || !contract || !prepare || segmented) return; - const abort = new AbortController(); - let disposed = false; - void (async () => { - for (const candidate of archive.manifest.epochs) { - const probe = document.createElement("video"); - probe.muted = true; - probe.playsInline = true; - const cleanup = await mountRecordedEpochStream(probe, candidate, abort.signal); - cleanup(); - if (disposed || abort.signal.aborted) return; - } - if (disposed || abort.signal.aborted) return; - reportAdmission({ - phase: "ready", - byteLength: archive.byteLength, - message: null, - }); - })().catch((error: unknown) => { - if ( - disposed || - abort.signal.aborted || - (error instanceof DOMException && error.name === "AbortError") - ) return; - setReadyGeneration(null); - setState("error"); - reportAdmission({ - phase: "error", - byteLength: archive.byteLength, - message: "Не все codec epoch записанной камеры декодируются и доступны для seek.", - }); - }); - return () => { - disposed = true; - abort.abort(); - }; - }, [admissionKey, archive, contract, prepare, segmented]); + if (!segmentRecovery || requestedSegmentSequence === null) return; + if ( + requestedSegmentSequence >= segmentRecovery.failedSequence + && requestedSegmentSequence < segmentRecovery.recoverySequence + ) return; + lastSegmentRecoveryRef.current = null; + setSegmentRecovery(null); + }, [requestedSegmentSequence, segmentRecovery]); useEffect(() => { const video = videoRef.current; @@ -910,6 +990,7 @@ export function RecordedFmp4Player({ setReadyGeneration(null); setSegmentedRuntimeGeneration(null); + setErrorMessage(null); setState("loading"); video.pause(); const sourceOpened = waitForMediaSourceOpen(mediaSource, abort.signal); @@ -957,6 +1038,7 @@ export function RecordedFmp4Player({ return; } setReadyGeneration(null); + setErrorMessage("Покадровый буфер записанной камеры не открылся."); setState("error"); reportAdmission({ phase: "error", @@ -982,7 +1064,14 @@ export function RecordedFmp4Player({ } URL.revokeObjectURL(objectUrl); }; - }, [archive, contract, epoch, segmentCount, segmented]); + }, [ + archive, + contract, + effectiveSegmentCount, + epoch, + segmented, + segmentRecoveryGeneration, + ]); useEffect(() => { const runtime = segmentedRuntimeRef.current; @@ -993,18 +1082,19 @@ export function RecordedFmp4Player({ || !archive || !segmented || segmentedRuntimeGeneration !== runtime.generation - || segmentSequence === null - || !Number.isInteger(segmentSequence) - || segmentSequence < 1 - || segmentSequence > runtime.segmentCount + || effectiveSegmentSequence === null + || !Number.isInteger(effectiveSegmentSequence) + || effectiveSegmentSequence < 1 + || effectiveSegmentSequence > runtime.segmentCount ) return; const archiveByteLength = archive.byteLength; const decodeStart = recordedMediaDecodeStartSequence( runtime.randomAccessSequences, - segmentSequence, + effectiveSegmentSequence, ); if (decodeStart === null) { setReadyGeneration(null); + setErrorMessage("Для кадра записанной камеры нет random-access фрагмента."); setState("error"); reportAdmission({ phase: "error", @@ -1015,10 +1105,11 @@ export function RecordedFmp4Player({ } const targetSeconds = recordedSegmentStartSeconds( runtime.segmentEndTimesSeconds, - segmentSequence, + effectiveSegmentSequence, ); if (targetSeconds === null) { setReadyGeneration(null); + setErrorMessage("Для кадра записанной камеры нет точной media timestamp."); setState("error"); reportAdmission({ phase: "error", @@ -1030,15 +1121,15 @@ export function RecordedFmp4Player({ const previousTarget = runtime.target; const readyEnd = Math.min( runtime.segmentCount, - segmentSequence + RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS, + effectiveSegmentSequence + RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS, ); const desiredEnd = Math.min( runtime.segmentCount, - segmentSequence + RECORDED_MEDIA_SEGMENTS_AHEAD, + effectiveSegmentSequence + RECORDED_MEDIA_SEGMENTS_AHEAD, ); const candidateTarget: RecordedSegmentTarget = { revision: previousTarget?.revision ?? 0, - sequence: segmentSequence, + sequence: effectiveSegmentSequence, decodeStart, readyEnd, desiredEnd, @@ -1046,6 +1137,24 @@ export function RecordedFmp4Player({ forceReset: false, resetAttempts: 0, }; + const recoverFromSegmentFailure = (failedSequence: number): boolean => { + const recoverySequence = nextRecordedMediaRandomAccessSequence( + runtime.randomAccessSequences, + failedSequence, + ); + if (recoverySequence === null) return false; + const recoveryToken = `${runtime.generation}:${failedSequence}:${recoverySequence}`; + if (lastSegmentRecoveryRef.current === recoveryToken) return true; + lastSegmentRecoveryRef.current = recoveryToken; + setReadyGeneration(null); + setSegmentRecovery({ failedSequence, recoverySequence }); + setErrorMessage( + `Восстанавливаем камеру с ключевого кадра ${recoverySequence}.`, + ); + setState("loading"); + setSegmentRecoveryGeneration((generation) => generation + 1); + return true; + }; const rollingTarget = Boolean(previousTarget && recordedMediaCanRollTarget( previousTarget.sequence, candidateTarget.sequence, @@ -1059,12 +1168,19 @@ export function RecordedFmp4Player({ || runtime.abort.signal.aborted || (error instanceof DOMException && error.name === "AbortError") ) return; + const failedSequence = runtime.target?.sequence ?? effectiveSegmentSequence; + if (failedSequence !== null && recoverFromSegmentFailure(failedSequence)) return; + const detail = error instanceof Error && error.message + ? `: ${error.message}` + : "."; + const message = `Покадровый фрагмент записанной камеры недоступен${detail}`; setReadyGeneration(null); + setErrorMessage(message); setState("error"); reportAdmission({ phase: "error", byteLength: archiveByteLength, - message: "Покадровый фрагмент записанной камеры недоступен.", + message, }); }; if (rollingTarget && previousTarget) { @@ -1128,6 +1244,7 @@ export function RecordedFmp4Player({ setBufferRevision((revision) => revision + 1); runtime.hasPresentedFrame = true; setReadyGeneration(runtime.generation); + setErrorMessage(null); setState("ready"); reportAdmission({ phase: "ready", @@ -1139,7 +1256,9 @@ export function RecordedFmp4Player({ targetReadyAbort.signal.aborted || (error instanceof DOMException && error.name === "AbortError") ) return; + if (recoverFromSegmentFailure(bufferedTarget.sequence)) return; setReadyGeneration(null); + setErrorMessage("Кадр записанной камеры не стал decoder-ready."); setState("error"); reportAdmission({ phase: "error", @@ -1160,7 +1279,7 @@ export function RecordedFmp4Player({ if (targetReadyAbortRef.current === targetReadyAbort) targetReadyAbortRef.current = null; if (runtime.onTargetBuffered === markBuffered) runtime.onTargetBuffered = null; }; - }, [archive?.byteLength, segmentSequence, segmented, segmentedRuntimeGeneration]); + }, [archive?.byteLength, effectiveSegmentSequence, segmented, segmentedRuntimeGeneration]); useEffect(() => { const video = videoRef.current; @@ -1170,6 +1289,7 @@ export function RecordedFmp4Player({ ? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}` : null; setReadyGeneration(null); + setErrorMessage(null); setState("loading"); const abort = new AbortController(); let disposed = false; @@ -1185,7 +1305,13 @@ export function RecordedFmp4Player({ } setBufferRevision((revision) => revision + 1); setReadyGeneration(generation); + setErrorMessage(null); setState("ready"); + reportAdmission({ + phase: "ready", + byteLength: archive?.byteLength ?? null, + message: null, + }); } catch (error) { if ( disposed || @@ -1195,11 +1321,12 @@ export function RecordedFmp4Player({ return; } setReadyGeneration(null); + setErrorMessage("Записанная камера не открыла первый декодируемый кадр."); setState("error"); reportAdmission({ phase: "error", byteLength: archive?.byteLength ?? null, - message: "Записанная камера не стала seekable.", + message: "Записанная камера не открыла первый декодируемый кадр.", }); } }; @@ -1234,6 +1361,7 @@ export function RecordedFmp4Player({ video.currentTime = target; } catch { setReadyGeneration(null); + setErrorMessage("Seek записанной камеры завершился ошибкой."); setState("error"); reportAdmission({ phase: "error", @@ -1244,11 +1372,12 @@ export function RecordedFmp4Player({ } } video.playbackRate = playbackRate; - if (playback?.playing) { + if (playback?.playing && !holdingForSegmentRecovery) { void video.play().catch(() => { if (playAttemptRevisionRef.current !== playAttemptRevision) return; onPlayingRejectedRef.current?.(); setReadyGeneration(null); + setErrorMessage("Запуск записанной камеры отклонён браузером."); setState("error"); reportAdmission({ phase: "error", @@ -1264,6 +1393,7 @@ export function RecordedFmp4Player({ bufferRevision, directPlaybackSeconds, epoch, + holdingForSegmentRecovery, playback?.playing, playbackRate, segmented, @@ -1327,7 +1457,9 @@ export function RecordedFmp4Player({ {visualState === "waiting" ? "Камера на этой позиции ещё не записывалась" : visualState === "error" - ? "Записанное видео недоступно" + ? errorMessage ?? "Записанное видео недоступно" + : errorMessage + ? errorMessage : archive ? "Проверяем seek и codec записанного видео…" : "Читаем manifest записанного видео…"} diff --git a/apps/control-station/src/components/RerunViewport.tsx b/apps/control-station/src/components/RerunViewport.tsx index 4c2a898..ac7dba4 100644 --- a/apps/control-station/src/components/RerunViewport.tsx +++ b/apps/control-station/src/components/RerunViewport.tsx @@ -11,10 +11,18 @@ import { LIVE_RECEIVER_OPEN_CHECK_INTERVAL_MS, requestLiveReceiverRecovery, } from "../core/observation/liveReceiverWatchdog"; +import { + claimExclusiveLiveViewer, + createReentrantViewerDisposer, + isLiveRerunPresentationReady, + liveRerunReceiverBindingIdentity, + liveTimelineNeedsSynchronization, +} from "../core/observation/liveRerunLifecycle"; import { createLiveViewerDiagnosticLifecycle, createLiveViewerInstanceId, createLiveViewerLineage, + reloadRecordedViewerAfterStaleModuleFailure, subscribeToLiveViewerBuildFence, type LiveViewerFailureStage, } from "../core/observation/liveViewerDiagnostics"; @@ -22,10 +30,63 @@ import { fetchPerceptionPreparationStatus, perceptionPreparationMessage, } from "../core/observation/perceptionPreparation"; -import type { RecordedAdmissionPhase } from "../core/observation/recordedSessionAdmission"; +import { + RECORDED_BASE_POINT_COLOR_KEY, + canPublishRecordedPlaybackController, + createRecordedAutoplayGate, + createRecordedOpenWatchdog, +} from "../core/observation/recordedRerunLifecycle"; +import type { + RecordedPerceptionLayers, + RecordedRerunView, + RecordedRrdArtifactDescriptor, + RerunPlaybackController, + RerunPlaybackState, + RerunViewerProfile, + RerunViewportStatus, +} from "../core/observation/viewerProfile"; + +export type { + RecordedPerceptionLayers, + RecordedRerunView, + RecordedRrdArtifactDescriptor, + RerunPlaybackController, + RerunPlaybackState, + RerunViewerProfile, + RerunViewportStatus, +} from "../core/observation/viewerProfile"; +export { + attemptRecordedAutoplay, + canPublishRecordedPlaybackController, + createRecordedAutoplayGate, + createRecordedOpenWatchdog, + isRecordedPlaybackFullyBuffered, + isRecordedPlaybackPresentationReady, + isRecordedPlaybackReady, + isUsableRecordedPlaybackRange, + recordedOpenWatchdogTimeoutMs, + recordedPlaybackBufferState, + recordedPlaybackRangeWhenReady, + recordedPointColorKey, + rerunPresentationStatus, + type RecordedPlaybackBufferState, +} from "../core/observation/recordedRerunLifecycle"; +export { + claimExclusiveLiveViewer, + createReentrantViewerDisposer, + isLiveRerunPresentationReady, + liveRerunReceiverBindingIdentity, + liveTimelineNeedsSynchronization, +} from "../core/observation/liveRerunLifecycle"; + +import { + isRecordedPlaybackReady, + recordedPlaybackBufferState, + recordedPlaybackRangeWhenReady, + recordedPointColorKey, + rerunPresentationStatus, +} from "../core/observation/recordedRerunLifecycle"; -export type RerunViewportStatus = "idle" | "loading" | "ready" | "error"; -export type RecordedRerunView = "spatial" | "perception" | "perception3d" | "metrics"; export type RecordedPerceptionLoadPhase = | "idle" | "loading" @@ -46,55 +107,14 @@ export type RecordedPointColorLoadState = Pick< "phase" | "receivedBytes" | "totalBytes" | "progress" | "message" >; -export interface RecordedPerceptionLayers { - enabled: boolean; - detections2d: boolean; - segmentation: boolean; - cuboids3d: boolean; -} - export interface RerunSelection { entityPath: string; viewName?: string; position?: [number, number, number]; } -export interface RerunPlaybackState { - recordingId: string; - timeline: string; - rangeNs: { min: number; max: number } | null; - currentNs: number; - playing: boolean; - /** Latest session-time value currently available to the browser receiver. */ - bufferedEndNs: number | null; - /** Declared first session-time value, when the archive descriptor provides it. */ - expectedStartNs: number | null; - /** Declared final session-time value, when the archive descriptor provides it. */ - expectedEndNs: number | null; - /** Download progress in the closed interval 0..1, or null without a valid expectation. */ - bufferProgress: number | null; - /** True only when the buffered range has reached the declared archive end. */ - fullyBuffered: boolean; -} - -export interface RerunPlaybackController { - seek: (timeNs: number) => void; - setPlaying: (playing: boolean) => void; - jumpToEnd: () => void; -} - export interface RerunViewportProps { - sourceUrl: string; - recordedArtifact?: RecordedRrdArtifactDescriptor | null; - followLive?: boolean; - liveActivitySequence?: number | null; - liveStreamId?: string | null; - liveRecoveryAuthorityIdentity?: string | null; - autoplayWhenReady?: boolean; - presentationGate?: RecordedAdmissionPhase; - expectedTimelineStartSeconds?: number; - expectedTimelineEndSeconds?: number; - initialPlaybackStartSeconds?: number; + profile: RerunViewerProfile; onStatusChange?: (status: RerunViewportStatus, message?: string) => void; onSelectionChange?: (selection: RerunSelection | null) => void; onPlaybackChange?: (state: RerunPlaybackState | null) => void; @@ -110,23 +130,10 @@ export interface RerunViewportProps { | "palette" | "customColor" >; - recordedView?: RecordedRerunView; - recordedViewResetGeneration?: 0 | 1; - recordedFollowTrajectory?: boolean; - recordedPerceptionLayers?: RecordedPerceptionLayers; - recordedPerceptionRetryGeneration?: number; - lockPerceptionCameraInteraction?: boolean; onPerceptionLoadChange?: (state: RecordedPerceptionLoadState) => void; onPointColorLoadChange?: (state: RecordedPointColorLoadState) => void; } -export interface RecordedRrdArtifactDescriptor { - sourceUrl: string; - viewerSourceUrl: string; - byteLength: number; - sha256: string; -} - interface RerunBlueprintChannel { endpointUrl: string; channel: { @@ -147,375 +154,6 @@ const RECORDED_PERCEPTION_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][ const RECORDED_POINT_COLORS_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/point-colors\.rrd$/; const MAX_BLUEPRINT_BYTES = 1_048_576; const MAX_PERCEPTION_BYTES = 512 * 1024 * 1024; -const BUFFER_END_TOLERANCE_NS = 1_000_000; -const RECORDED_OPEN_MIN_TIMEOUT_MS = 120_000; -const RECORDED_OPEN_MAX_TIMEOUT_MS = 1_800_000; -const RECORDED_OPEN_GRACE_MS = 30_000; -const RECORDED_OPEN_MIN_BYTES_PER_SECOND = 2 * 1024 * 1024; -const RECORDED_BASE_POINT_COLOR_KEY = "intensity|turbo|-"; - -export function recordedPointColorKey( - settings: Pick, -): string { - const custom = settings.palette === "custom" || settings.colorMode === "class" - ? settings.customColor.toLowerCase() - : "-"; - return `${settings.colorMode}|${settings.palette}|${custom}`; -} - -export function recordedOpenWatchdogTimeoutMs(byteLength: number): number { - if (!Number.isSafeInteger(byteLength) || byteLength < 4) { - throw new Error("Unsafe recorded RRD byte length"); - } - const transferBudgetMs = Math.ceil( - (byteLength / RECORDED_OPEN_MIN_BYTES_PER_SECOND) * 1_000, - ); - return Math.min( - RECORDED_OPEN_MAX_TIMEOUT_MS, - Math.max(RECORDED_OPEN_MIN_TIMEOUT_MS, transferBudgetMs + RECORDED_OPEN_GRACE_MS), - ); -} - -export function createRecordedOpenWatchdog({ - byteLength, - schedule, - cancel, - onTimeout, -}: { - byteLength: number; - schedule: (callback: () => void, timeoutMs: number) => T; - cancel: (handle: T) => void; - onTimeout: () => void; -}): { arm: () => void; clear: () => void; pending: () => boolean } { - const timeoutMs = recordedOpenWatchdogTimeoutMs(byteLength); - let handle: T | null = null; - return { - arm() { - if (handle !== null) return; - handle = schedule(() => { - handle = null; - onTimeout(); - }, timeoutMs); - }, - clear() { - if (handle === null) return; - cancel(handle); - handle = null; - }, - pending: () => handle !== null, - }; -} - -export function createReentrantViewerDisposer( - cleanupOnce: () => void, - releaseNativeViewer: () => void, -): () => void { - let cleanupComplete = false; - return () => { - try { - if (!cleanupComplete) { - cleanupComplete = true; - cleanupOnce(); - } - } finally { - // `viewer.start()` can resolve after an earlier pre-ready stop. Reapply - // native release on every disposal boundary so that a stale viewer can - // never reopen after React and diagnostics have already unmounted it. - releaseNativeViewer(); - } - }; -} - -interface ActiveLiveViewerOwner { - release: () => void; -} - -let activeLiveViewerOwner: ActiveLiveViewerOwner | null = null; - -/** - * Own exactly one native live receiver per application document. - * - * React route/StrictMode transitions can overlap two mounted workspaces for a - * render turn. Rerun keeps each native gRPC receiver alive independently, so - * the overlap used to consume the bounded live replay slots and leave the - * operator's visible canvas black. Claiming the next owner synchronously - * retires the previous native receiver before the next one starts. - */ -export function claimExclusiveLiveViewer(release: () => void): () => void { - const owner = { release }; - const previous = activeLiveViewerOwner; - activeLiveViewerOwner = owner; - previous?.release(); - return () => { - if (activeLiveViewerOwner === owner) activeLiveViewerOwner = null; - }; -} - -export interface RecordedPlaybackBufferState { - bufferedEndNs: number | null; - expectedStartNs: number | null; - expectedEndNs: number | null; - bufferProgress: number | null; - fullyBuffered: boolean; -} - -/** - * A Rerun time range is usable as soon as it contains one finite timestamp. - * A first frame commonly has min === max; waiting for positive duration would - * needlessly keep that visible frame behind the loading screen. - */ -export function isUsableRecordedPlaybackRange( - rangeNs: { min: number; max: number } | null, -): rangeNs is { min: number; max: number } { - return Boolean( - rangeNs && - Number.isFinite(rangeNs.min) && - Number.isFinite(rangeNs.max) && - rangeNs.max >= rangeNs.min, - ); -} - -/** - * A live receiver is presentable only after the exact browser store exposes - * real timeline data that the backend has also confirmed publishing. - * `WebViewer.start()` and a non-null active recording id are transport setup, - * not evidence that the spatial scene can render. - */ -export function isLiveRerunPresentationReady( - viewerStarted: boolean, - rangeNs: { min: number; max: number } | null, - backendActivitySequence: number | null, -): boolean { - return viewerStarted && - Number.isSafeInteger(backendActivitySequence) && - (backendActivitySequence ?? 0) > 0 && - isUsableRecordedPlaybackRange(rangeNs); -} - -/** - * `recording_open` can arrive before Rerun has registered the live timeline. - * Selecting it at that point is a silent no-op, so keep retrying only until - * the exact live timeline is both available and active. - */ -export function liveTimelineNeedsSynchronization( - followLive: boolean, - activeTimeline: string | null | undefined, - rangeNs: { min: number; max: number } | null, -): boolean { - return followLive && - isUsableRecordedPlaybackRange(rangeNs) && - activeTimeline !== "stream_time"; -} - -/** - * Key the native receiver to its data-plane binding. Recovery authority is a - * retry fence projected from changing supervisor snapshots; it must not tear - * down a healthy WebViewer while this acquisition and URL remain unchanged. - */ -export function liveRerunReceiverBindingIdentity( - sourceUrl: string, - liveStreamId: string | null, - followLive: boolean, -): string { - return JSON.stringify([ - followLive ? "live" : "recorded", - sourceUrl.trim(), - followLive ? liveStreamId?.trim() ?? "" : "", - ]); -} - -/** Describe progressive archive availability without gating first rendering. */ -export function recordedPlaybackBufferState( - rangeNs: { min: number; max: number } | null, - expectedTimelineEndSeconds?: number, - expectedTimelineStartSeconds?: number, -): RecordedPlaybackBufferState { - const usableRange = isUsableRecordedPlaybackRange(rangeNs) ? rangeNs : null; - const bufferedEndNs = usableRange?.max ?? null; - if (expectedTimelineEndSeconds === undefined) { - return { - bufferedEndNs, - expectedStartNs: null, - expectedEndNs: null, - bufferProgress: null, - fullyBuffered: usableRange !== null, - }; - } - if (!Number.isFinite(expectedTimelineEndSeconds) || expectedTimelineEndSeconds < 0) { - return { - bufferedEndNs, - expectedStartNs: null, - expectedEndNs: null, - bufferProgress: null, - fullyBuffered: false, - }; - } - - const expectedEndNs = expectedTimelineEndSeconds * 1_000_000_000; - const expectedStartNs = expectedTimelineStartSeconds === undefined - ? 0 - : expectedTimelineStartSeconds * 1_000_000_000; - if ( - !Number.isFinite(expectedEndNs) || - !Number.isFinite(expectedStartNs) || - expectedStartNs < 0 || - expectedEndNs < expectedStartNs - ) { - return { - bufferedEndNs, - expectedStartNs: null, - expectedEndNs: null, - bufferProgress: null, - fullyBuffered: false, - }; - } - // Rerun split preserves whole boundary chunks so that all generated splits - // still sum exactly to the source recording. A verified bounded artifact can - // therefore begin slightly before its declared operator window. Admission - // requires coverage of the declared window, not byte-chunk boundary equality. - const fullyBuffered = usableRange !== null && - usableRange.min <= expectedStartNs + BUFFER_END_TOLERANCE_NS && - usableRange.max >= expectedEndNs - BUFFER_END_TOLERANCE_NS; - const expectedDurationNs = expectedEndNs - expectedStartNs; - const bufferProgress = bufferedEndNs === null - ? 0 - : expectedDurationNs <= 0 - ? (fullyBuffered ? 1 : 0) - : Math.min(1, Math.max(0, (bufferedEndNs - expectedStartNs) / expectedDurationNs)); - return { - bufferedEndNs, - expectedStartNs, - expectedEndNs, - bufferProgress, - fullyBuffered, - }; -} - -/** A recorded viewport is publishable only after its declared range is complete. */ -export function isRecordedPlaybackReady( - viewerStarted: boolean, - artifactVerified: boolean, - buffer: RecordedPlaybackBufferState, -): boolean { - return viewerStarted && artifactVerified && buffer.fullyBuffered; -} - -/** Keep the host scrubber and its controls disconnected from a partial archive. */ -export function recordedPlaybackRangeWhenReady( - rangeNs: { min: number; max: number } | null, - buffer: RecordedPlaybackBufferState, - artifactVerified: boolean, -): { min: number; max: number } | null { - if ( - !artifactVerified || - !buffer.fullyBuffered || - !isUsableRecordedPlaybackRange(rangeNs) - ) return null; - return { - min: buffer.expectedStartNs === null - ? rangeNs.min - : Math.max(rangeNs.min, buffer.expectedStartNs), - max: buffer.expectedEndNs === null - ? rangeNs.max - : Math.min(rangeNs.max, buffer.expectedEndNs), - }; -} - -/** Do not mount host timeline controls while a recorded generation is partial. */ -export function isRecordedPlaybackPresentationReady( - status: RerunViewportStatus, - playback: RerunPlaybackState | null, -): boolean { - return status === "ready" && - playback?.fullyBuffered === true && - isUsableRecordedPlaybackRange(playback.rangeNs); -} - -export function rerunPresentationStatus( - status: RerunViewportStatus, - gate: RecordedAdmissionPhase, - recorded: boolean, -): RerunViewportStatus { - if (!recorded) return status; - if (status === "error" || gate === "error") return "error"; - if (gate !== "ready") return "loading"; - return status; -} - -export function isRecordedPlaybackFullyBuffered( - rangeNs: { min: number; max: number } | null, - expectedTimelineEndSeconds?: number, -): boolean { - return recordedPlaybackBufferState(rangeNs, expectedTimelineEndSeconds).fullyBuffered; -} - -export function attemptRecordedAutoplay( - seekToStart: () => void, - startPlaying: () => void, -): boolean { - try { - seekToStart(); - startPlaying(); - return true; - } catch { - return false; - } -} - -/** - * Admit autoplay once, after the viewer and the complete recorded range are - * ready. The attempt is consumed even if a vendor call throws: subsequent - * polling must never seek the operator back to the beginning a second time. - */ -export function createRecordedAutoplayGate(): { - attempt: ( - viewerStarted: boolean, - fullyBuffered: boolean, - presentationReady: boolean, - rangeNs: { min: number; max: number } | null, - seekToStart: (startNs: number) => void, - startPlaying: () => void, - preferredStartNs?: number, - ) => boolean; - attempted: () => boolean; -} { - let consumed = false; - return { - attempt( - viewerStarted, - fullyBuffered, - presentationReady, - rangeNs, - seekToStart, - startPlaying, - 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; - return attemptRecordedAutoplay( - () => seekToStart(startNs), - startPlaying, - ); - }, - attempted: () => consumed, - }; -} - -export function canPublishRecordedPlaybackController( - readyToRender: boolean, - presentationGate: RecordedAdmissionPhase, -): boolean { - return readyToRender && presentationGate === "ready"; -} export function createLatestAnimationFrameEmitter({ emit, @@ -917,36 +555,43 @@ export async function fetchRecordedPerceptionRrd( } export function RerunViewport({ - sourceUrl, - recordedArtifact = null, - followLive = false, - liveActivitySequence = null, - liveStreamId = null, - liveRecoveryAuthorityIdentity = null, - autoplayWhenReady = false, - presentationGate = "ready", - expectedTimelineStartSeconds, - expectedTimelineEndSeconds, - initialPlaybackStartSeconds, + profile, onStatusChange, onSelectionChange, onPlaybackChange, onPlaybackControllerChange, sceneSettings, - recordedView = "spatial", - recordedViewResetGeneration = 0, - recordedFollowTrajectory = false, - recordedPerceptionLayers = { + onPerceptionLoadChange, + onPointColorLoadChange, +}: RerunViewportProps) { + const recordedProfile = profile.kind === "recorded-session" ? profile : null; + const liveProfile = profile.kind === "live-acquisition" ? profile : null; + const sourceUrl = profile.sourceUrl; + const recordedArtifact = recordedProfile?.artifact ?? null; + const followLive = liveProfile !== null; + const liveActivitySequence = liveProfile?.liveActivitySequence ?? null; + const liveStreamId = liveProfile?.liveStreamId ?? null; + const liveRecoveryAuthorityIdentity = + liveProfile?.liveRecoveryAuthorityIdentity ?? null; + const autoplayWhenReady = recordedProfile?.autoplayWhenReady ?? false; + const presentationGate = recordedProfile?.presentationGate ?? "ready"; + const expectedTimelineStartSeconds = + recordedProfile?.expectedTimelineStartSeconds; + const expectedTimelineEndSeconds = recordedProfile?.expectedTimelineEndSeconds; + const initialPlaybackStartSeconds = recordedProfile?.initialPlaybackStartSeconds; + const recordedView = recordedProfile?.view ?? "spatial"; + const recordedViewResetGeneration = recordedProfile?.viewResetGeneration ?? 0; + const recordedFollowTrajectory = recordedProfile?.followTrajectory ?? false; + const recordedPerceptionLayers = recordedProfile?.perceptionLayers ?? { enabled: false, detections2d: false, segmentation: false, cuboids3d: false, - }, - recordedPerceptionRetryGeneration = 0, - lockPerceptionCameraInteraction = false, - onPerceptionLoadChange, - onPointColorLoadChange, -}: RerunViewportProps) { + }; + const recordedPerceptionRetryGeneration = + recordedProfile?.perceptionRetryGeneration ?? 0; + const lockPerceptionCameraInteraction = + recordedProfile?.lockPerceptionCameraInteraction ?? false; const hostRef = useRef(null); const [status, setStatus] = useState(sourceUrl ? "loading" : "idle"); const [recordingBufferProgress, setRecordingBufferProgress] = useState(null); @@ -1588,7 +1233,7 @@ export function RerunViewport({ liveRecoveryRef.current = initialLiveReceiverRecoveryState(); } if (!followLive) clearRecordedAdmissionWatchdog(); - if (!followLive) setRecordingBufferProgress(1); + if (!followLive) setRecordingBufferProgress(recordedBuffer.bufferProgress); recordedSceneAdmitted = true; if (!followLive) onPlaybackChange?.(playbackState); setStatus("ready"); @@ -1597,7 +1242,10 @@ export function RerunViewport({ if ( !followLive && !playbackControllerPublished && - canPublishRecordedPlaybackController(readyToRender, presentationGateRef.current) + canPublishRecordedPlaybackController( + recordedBuffer.fullyBuffered, + presentationGateRef.current, + ) ) { playbackControllerPublished = true; onPlaybackControllerChange?.(playbackController); @@ -1740,9 +1388,16 @@ export function RerunViewport({ } } }) - .catch(() => { + .catch(async () => { if (disposed) return; disposeViewer?.(); + if ( + isRecordedSource && + await reloadRecordedViewerAfterStaleModuleFailure({ + loadedUiBuildId: diagnosticLifecycle.lineage.uiBuildId, + signal: diagnosticLifecycle.signal, + }) + ) return; if (requestLiveRecovery("module-load")) return; reportError("Не удалось загрузить модуль визуализатора."); }); @@ -1786,7 +1441,18 @@ export function RerunViewport({ }, [recordedPointColorsUrl]); useEffect(() => { - if (!recordedPerceptionUrl) return; + if (!recordedPerceptionUrl || !recordedPerceptionLayers.enabled) { + if (!recordedPerceptionLayers.enabled) { + onPerceptionLoadChange?.({ + phase: "idle", + receivedBytes: 0, + totalBytes: null, + progress: null, + message: "", + }); + } + return; + } const active = perceptionChannelRef.current; const identity = recordedIdentityRef.current; if ( @@ -1931,6 +1597,7 @@ export function RerunViewport({ }, [ onPerceptionLoadChange, perceptionChannelRevision, + recordedPerceptionLayers.enabled, recordedPerceptionRetryGeneration, recordedPerceptionUrl, ]); diff --git a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts index 14f980d..284ba5d 100644 --- a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts +++ b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts @@ -849,12 +849,14 @@ export async function fetchM4ThreatTimelineChunk( endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT, cameraObstacleProjectionDelivery = null, playbackPointPack, + includePoints = true, }: { fetcher?: LaboratoryFetch; signal?: AbortSignal; endpointRoot?: string; cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"]; playbackPointPack?: M4ThreatPlaybackPointPack; + includePoints?: boolean; } = {}, ): Promise { const params = new URLSearchParams({ @@ -864,7 +866,7 @@ export async function fetchM4ThreatTimelineChunk( if (cameraObstacleProjectionDelivery !== null) { params.set("obstacle_projection", cameraObstacleProjectionDelivery); } - if (playbackPointPack) params.set("include_points", "false"); + if (playbackPointPack || !includePoints) params.set("include_points", "false"); const response = await fetcher( `${endpointRoot}/${result}/timeline/chunk?${params}`, { headers: { Accept: "application/json" }, signal }, diff --git a/apps/control-station/src/core/laboratory/recordedEvidenceProfile.ts b/apps/control-station/src/core/laboratory/recordedEvidenceProfile.ts new file mode 100644 index 0000000..177308a --- /dev/null +++ b/apps/control-station/src/core/laboratory/recordedEvidenceProfile.ts @@ -0,0 +1,57 @@ +import { + LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE, + type LaboratoryRecordedEvidenceViewerProfile, +} from "../observation/viewerProfile"; + +export type LaboratoryRecordedMediaMode = "video" | "camera" | null; +export type LaboratoryRecordedSpatialMode = "3d" | "plan" | null; +export type LaboratoryClassifiedSpatialMode = "none" | "overlay" | "replace-source"; + +export interface LaboratoryRecordedEvidenceVisibility { + mediaMode: LaboratoryRecordedMediaMode; + spatialMode: LaboratoryRecordedSpatialMode; + showMediaSemantic: boolean; + showSpatialSemantic: boolean; + showMediaPoints: boolean; + classifiedSpatialMode: LaboratoryClassifiedSpatialMode; +} + +export interface LaboratoryRecordedEvidenceDemand { + sourceTimelineMetadata: true; + recordedVideo: boolean; + exactCameraFrame: boolean; + sourceSpatialPoints: boolean; + cameraPointOverlay: boolean; + selectedSemanticMask: boolean; + selectedSemanticPoints: boolean; + classifiedSpatial: boolean; +} + +/** + * Translate the already-visible M4 evidence composition into explicit data + * demand. The profile never starts a hidden point, semantic or camera channel + * merely because the selected LAB happens to publish that artifact. + */ +export function laboratoryRecordedEvidenceDemand( + visibility: LaboratoryRecordedEvidenceVisibility, + profile: LaboratoryRecordedEvidenceViewerProfile = + LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE, +): LaboratoryRecordedEvidenceDemand { + if (profile.loadPolicy !== "visible-evidence-only") { + throw new Error("Unsupported LAB recorded evidence load policy"); + } + const mediaVisible = visibility.mediaMode !== null; + const spatialVisible = visibility.spatialMode !== null; + return { + sourceTimelineMetadata: true, + recordedVideo: visibility.mediaMode === "video", + exactCameraFrame: visibility.mediaMode === "camera", + sourceSpatialPoints: + spatialVisible && visibility.classifiedSpatialMode !== "replace-source", + cameraPointOverlay: mediaVisible && visibility.showMediaPoints, + selectedSemanticMask: mediaVisible && visibility.showMediaSemantic, + selectedSemanticPoints: spatialVisible && visibility.showSpatialSemantic, + classifiedSpatial: + spatialVisible && visibility.classifiedSpatialMode !== "none", + }; +} diff --git a/apps/control-station/src/core/observation/liveRerunLifecycle.ts b/apps/control-station/src/core/observation/liveRerunLifecycle.ts new file mode 100644 index 0000000..9cbe76c --- /dev/null +++ b/apps/control-station/src/core/observation/liveRerunLifecycle.ts @@ -0,0 +1,70 @@ +import { isUsableRecordedPlaybackRange } from "./recordedRerunLifecycle"; + +export function createReentrantViewerDisposer( + cleanupOnce: () => void, + releaseNativeViewer: () => void, +): () => void { + let cleanupComplete = false; + return () => { + try { + if (!cleanupComplete) { + cleanupComplete = true; + cleanupOnce(); + } + } finally { + // A deferred native start can resolve after a pre-ready stop. Reapply + // release at every boundary so it cannot reopen after React unmount. + releaseNativeViewer(); + } + }; +} + +interface ActiveLiveViewerOwner { + release: () => void; +} + +let activeLiveViewerOwner: ActiveLiveViewerOwner | null = null; + +/** Own exactly one native live receiver per application document. */ +export function claimExclusiveLiveViewer(release: () => void): () => void { + const owner = { release }; + const previous = activeLiveViewerOwner; + activeLiveViewerOwner = owner; + previous?.release(); + return () => { + if (activeLiveViewerOwner === owner) activeLiveViewerOwner = null; + }; +} + +export function isLiveRerunPresentationReady( + viewerStarted: boolean, + rangeNs: { min: number; max: number } | null, + backendActivitySequence: number | null, +): boolean { + return viewerStarted + && Number.isSafeInteger(backendActivitySequence) + && (backendActivitySequence ?? 0) > 0 + && isUsableRecordedPlaybackRange(rangeNs); +} + +export function liveTimelineNeedsSynchronization( + followLive: boolean, + activeTimeline: string | null | undefined, + rangeNs: { min: number; max: number } | null, +): boolean { + return followLive + && isUsableRecordedPlaybackRange(rangeNs) + && activeTimeline !== "stream_time"; +} + +export function liveRerunReceiverBindingIdentity( + sourceUrl: string, + liveStreamId: string | null, + followLive: boolean, +): string { + return JSON.stringify([ + followLive ? "live" : "recorded", + sourceUrl.trim(), + followLive ? liveStreamId?.trim() ?? "" : "", + ]); +} diff --git a/apps/control-station/src/core/observation/liveViewerDiagnostics.ts b/apps/control-station/src/core/observation/liveViewerDiagnostics.ts index 46b865e..1df8702 100644 --- a/apps/control-station/src/core/observation/liveViewerDiagnostics.ts +++ b/apps/control-station/src/core/observation/liveViewerDiagnostics.ts @@ -327,6 +327,53 @@ export function verifyLiveViewerClientBuild( }); } +/** + * Recover a recorded viewer whose lazy module disappeared during a frontend + * deployment. Live acquisition deliberately never reloads automatically, but + * a saved recording has no device-side authority to preserve and can safely + * move to the current immutable application build. + */ +export async function reloadRecordedViewerAfterStaleModuleFailure({ + loadedUiBuildId, + signal, + fetcher = globalThis.fetch, + reload = () => window.location.reload(), +}: { + loadedUiBuildId: string; + signal?: AbortSignal; + fetcher?: typeof globalThis.fetch; + reload?: () => void; +}): Promise { + if ( + signal?.aborted || + loadedUiBuildId === DEVELOPMENT_UI_BUILD_ID || + !HASHED_UI_BUILD_ID.test(loadedUiBuildId) + ) return false; + try { + const response = await fetcher("/api/v1/viewer/client-contract", { + method: "GET", + headers: { + Accept: "application/json", + "X-MissionCore-UI-Build": loadedUiBuildId, + }, + cache: "no-store", + signal, + }); + if (signal?.aborted || !response.ok) return false; + const expectedUiBuildId = response.headers.get(UI_BUILD_HEADER); + if ( + !expectedUiBuildId || + expectedUiBuildId === loadedUiBuildId || + !HASHED_UI_BUILD_ID.test(expectedUiBuildId) + ) return false; + browserUiBuildCoordinator().report({ loadedUiBuildId, expectedUiBuildId }); + reload(); + return true; + } catch { + return false; + } +} + function startBuildMonitor(): void { if (buildMonitorAbort || typeof window === "undefined") return; const lineage = createLiveViewerLineage(createLiveViewerInstanceId(), 1); diff --git a/apps/control-station/src/core/observation/recordedRerunLifecycle.ts b/apps/control-station/src/core/observation/recordedRerunLifecycle.ts new file mode 100644 index 0000000..eb07dc4 --- /dev/null +++ b/apps/control-station/src/core/observation/recordedRerunLifecycle.ts @@ -0,0 +1,260 @@ +import type { SceneSettings } from "../../sceneSettings"; +import type { RecordedAdmissionPhase } from "./recordedSessionAdmission"; +import type { RerunPlaybackState, RerunViewportStatus } from "./viewerProfile"; + +const BUFFER_END_TOLERANCE_NS = 1_000_000; +const RECORDED_OPEN_MIN_TIMEOUT_MS = 120_000; +const RECORDED_OPEN_MAX_TIMEOUT_MS = 1_800_000; +const RECORDED_OPEN_GRACE_MS = 30_000; +const RECORDED_OPEN_MIN_BYTES_PER_SECOND = 2 * 1024 * 1024; + +export const RECORDED_BASE_POINT_COLOR_KEY = "intensity|turbo|-"; + +export interface RecordedPlaybackBufferState { + bufferedEndNs: number | null; + expectedStartNs: number | null; + expectedEndNs: number | null; + bufferProgress: number | null; + fullyBuffered: boolean; +} + +export function recordedPointColorKey( + settings: Pick, +): string { + const custom = settings.palette === "custom" || settings.colorMode === "class" + ? settings.customColor.toLowerCase() + : "-"; + return `${settings.colorMode}|${settings.palette}|${custom}`; +} + +export function recordedOpenWatchdogTimeoutMs(byteLength: number): number { + if (!Number.isSafeInteger(byteLength) || byteLength < 4) { + throw new Error("Unsafe recorded RRD byte length"); + } + const transferBudgetMs = Math.ceil( + (byteLength / RECORDED_OPEN_MIN_BYTES_PER_SECOND) * 1_000, + ); + return Math.min( + RECORDED_OPEN_MAX_TIMEOUT_MS, + Math.max(RECORDED_OPEN_MIN_TIMEOUT_MS, transferBudgetMs + RECORDED_OPEN_GRACE_MS), + ); +} + +export function createRecordedOpenWatchdog({ + byteLength, + schedule, + cancel, + onTimeout, +}: { + byteLength: number; + schedule: (callback: () => void, timeoutMs: number) => T; + cancel: (handle: T) => void; + onTimeout: () => void; +}): { arm: () => void; clear: () => void; pending: () => boolean } { + const timeoutMs = recordedOpenWatchdogTimeoutMs(byteLength); + let handle: T | null = null; + return { + arm() { + if (handle !== null) return; + handle = schedule(() => { + handle = null; + onTimeout(); + }, timeoutMs); + }, + clear() { + if (handle === null) return; + cancel(handle); + handle = null; + }, + pending: () => handle !== null, + }; +} + +export function isUsableRecordedPlaybackRange( + rangeNs: { min: number; max: number } | null, +): rangeNs is { min: number; max: number } { + return Boolean( + rangeNs + && Number.isFinite(rangeNs.min) + && Number.isFinite(rangeNs.max) + && rangeNs.max >= rangeNs.min, + ); +} + +export function recordedPlaybackBufferState( + rangeNs: { min: number; max: number } | null, + expectedTimelineEndSeconds?: number, + expectedTimelineStartSeconds?: number, +): RecordedPlaybackBufferState { + const usableRange = isUsableRecordedPlaybackRange(rangeNs) ? rangeNs : null; + const bufferedEndNs = usableRange?.max ?? null; + if (expectedTimelineEndSeconds === undefined) { + return { + bufferedEndNs, + expectedStartNs: null, + expectedEndNs: null, + bufferProgress: null, + fullyBuffered: usableRange !== null, + }; + } + if (!Number.isFinite(expectedTimelineEndSeconds) || expectedTimelineEndSeconds < 0) { + return { + bufferedEndNs, + expectedStartNs: null, + expectedEndNs: null, + bufferProgress: null, + fullyBuffered: false, + }; + } + + const expectedEndNs = expectedTimelineEndSeconds * 1_000_000_000; + const expectedStartNs = expectedTimelineStartSeconds === undefined + ? 0 + : expectedTimelineStartSeconds * 1_000_000_000; + if ( + !Number.isFinite(expectedEndNs) + || !Number.isFinite(expectedStartNs) + || expectedStartNs < 0 + || expectedEndNs < expectedStartNs + ) { + return { + bufferedEndNs, + expectedStartNs: null, + expectedEndNs: null, + bufferProgress: null, + fullyBuffered: false, + }; + } + const fullyBuffered = usableRange !== null + && usableRange.min <= expectedStartNs + BUFFER_END_TOLERANCE_NS + && usableRange.max >= expectedEndNs - BUFFER_END_TOLERANCE_NS; + const expectedDurationNs = expectedEndNs - expectedStartNs; + const bufferProgress = bufferedEndNs === null + ? 0 + : expectedDurationNs <= 0 + ? (fullyBuffered ? 1 : 0) + : Math.min(1, Math.max(0, (bufferedEndNs - expectedStartNs) / expectedDurationNs)); + return { + bufferedEndNs, + expectedStartNs, + expectedEndNs, + bufferProgress, + fullyBuffered, + }; +} + +export function isRecordedPlaybackReady( + viewerStarted: boolean, + artifactVerified: boolean, + buffer: RecordedPlaybackBufferState, +): boolean { + return viewerStarted && artifactVerified && buffer.bufferedEndNs !== null; +} + +export function recordedPlaybackRangeWhenReady( + rangeNs: { min: number; max: number } | null, + buffer: RecordedPlaybackBufferState, + artifactVerified: boolean, +): { min: number; max: number } | null { + if ( + !artifactVerified + || !buffer.fullyBuffered + || !isUsableRecordedPlaybackRange(rangeNs) + ) return null; + return { + min: buffer.expectedStartNs === null + ? rangeNs.min + : Math.max(rangeNs.min, buffer.expectedStartNs), + max: buffer.expectedEndNs === null + ? rangeNs.max + : Math.min(rangeNs.max, buffer.expectedEndNs), + }; +} + +export function isRecordedPlaybackPresentationReady( + status: RerunViewportStatus, + playback: RerunPlaybackState | null, +): boolean { + return status === "ready" + && playback?.fullyBuffered === true + && isUsableRecordedPlaybackRange(playback.rangeNs); +} + +export function rerunPresentationStatus( + status: RerunViewportStatus, + _gate: RecordedAdmissionPhase, + recorded: boolean, +): RerunViewportStatus { + if (!recorded) return status; + return status === "error" ? "error" : status; +} + +export function isRecordedPlaybackFullyBuffered( + rangeNs: { min: number; max: number } | null, + expectedTimelineEndSeconds?: number, +): boolean { + return recordedPlaybackBufferState(rangeNs, expectedTimelineEndSeconds).fullyBuffered; +} + +export function attemptRecordedAutoplay( + seekToStart: () => void, + startPlaying: () => void, +): boolean { + try { + seekToStart(); + startPlaying(); + return true; + } catch { + return false; + } +} + +export function createRecordedAutoplayGate(): { + attempt: ( + viewerStarted: boolean, + fullyBuffered: boolean, + presentationReady: boolean, + rangeNs: { min: number; max: number } | null, + seekToStart: (startNs: number) => void, + startPlaying: () => void, + preferredStartNs?: number, + ) => boolean; + attempted: () => boolean; +} { + let consumed = false; + return { + attempt( + viewerStarted, + fullyBuffered, + presentationReady, + rangeNs, + seekToStart, + startPlaying, + 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; + return attemptRecordedAutoplay( + () => seekToStart(startNs), + startPlaying, + ); + }, + attempted: () => consumed, + }; +} + +export function canPublishRecordedPlaybackController( + readyToRender: boolean, + presentationGate: RecordedAdmissionPhase, +): boolean { + return readyToRender && presentationGate === "ready"; +} diff --git a/apps/control-station/src/core/observation/viewerProfile.ts b/apps/control-station/src/core/observation/viewerProfile.ts new file mode 100644 index 0000000..5ae98b5 --- /dev/null +++ b/apps/control-station/src/core/observation/viewerProfile.ts @@ -0,0 +1,114 @@ +import type { RecordedAdmissionPhase } from "./recordedSessionAdmission"; + +export type RerunViewportStatus = "idle" | "loading" | "ready" | "error"; +export type RecordedRerunView = "spatial" | "perception" | "perception3d" | "metrics"; + +export interface RerunPlaybackState { + recordingId: string; + timeline: string; + rangeNs: { min: number; max: number } | null; + currentNs: number; + playing: boolean; + bufferedEndNs: number | null; + expectedStartNs: number | null; + expectedEndNs: number | null; + bufferProgress: number | null; + fullyBuffered: boolean; +} + +export interface RerunPlaybackController { + seek: (timeNs: number) => void; + setPlaying: (playing: boolean) => void; + jumpToEnd: () => void; +} + +export interface RecordedPerceptionLayers { + enabled: boolean; + detections2d: boolean; + segmentation: boolean; + cuboids3d: boolean; +} + +export interface RecordedRrdArtifactDescriptor { + sourceUrl: string; + viewerSourceUrl: string; + byteLength: number; + sha256: string; +} + +/** + * The native live receiver owns one acquisition lineage and never exposes + * recorded transport or autoplay policy. + */ +export interface LiveAcquisitionRerunProfile { + kind: "live-acquisition"; + clock: "stream_time"; + sourceUrl: string; + liveActivitySequence: number | null; + liveStreamId: string | null; + liveRecoveryAuthorityIdentity: string | null; +} + +/** + * A sealed session is progressively presentable, while its shared playback + * controls remain fenced by the aggregate session admission gate. + */ +export interface RecordedSessionRerunProfile { + kind: "recorded-session"; + clock: "session_time"; + sourceUrl: string; + artifact: RecordedRrdArtifactDescriptor | null; + autoplayWhenReady: boolean; + presentationGate: RecordedAdmissionPhase; + expectedTimelineStartSeconds?: number; + expectedTimelineEndSeconds?: number; + initialPlaybackStartSeconds?: number; + view: RecordedRerunView; + viewResetGeneration: 0 | 1; + followTrajectory: boolean; + perceptionLayers: RecordedPerceptionLayers; + perceptionRetryGeneration: number; + lockPerceptionCameraInteraction: boolean; +} + +/** + * LAB recorded evidence is not a native Rerun mode. It owns a source-sequence + * clock and composes the existing sealed fMP4 and retained spatial primitives. + */ +export interface LaboratoryRecordedEvidenceViewerProfile { + kind: "lab-recorded-evidence"; + clock: "source-sequence"; + cameraTransport: "generation-bound-fmp4"; + spatialTransport: "bounded-sealed-artifacts"; + loadPolicy: "visible-evidence-only"; + workerRequired: false; +} + +export type RerunViewerProfile = + | LiveAcquisitionRerunProfile + | RecordedSessionRerunProfile; + +export type ObservationViewerProfile = + | RerunViewerProfile + | LaboratoryRecordedEvidenceViewerProfile; + +export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({ + kind: "lab-recorded-evidence", + clock: "source-sequence", + cameraTransport: "generation-bound-fmp4", + spatialTransport: "bounded-sealed-artifacts", + loadPolicy: "visible-evidence-only", + workerRequired: false, +} satisfies LaboratoryRecordedEvidenceViewerProfile); + +export function liveAcquisitionRerunProfile( + input: Omit, +): LiveAcquisitionRerunProfile { + return { kind: "live-acquisition", clock: "stream_time", ...input }; +} + +export function recordedSessionRerunProfile( + input: Omit, +): RecordedSessionRerunProfile { + return { kind: "recorded-session", clock: "session_time", ...input }; +} diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index 468f781..552b7d3 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -12,6 +12,7 @@ import type { RecordedCameraAdmissionState, } from "../core/observation/recordedSessionAdmission"; import { liveRerunRecoveryAuthorityIdentity } from "../core/observation/liveReceiverWatchdog"; +import { liveAcquisitionRerunProfile, recordedSessionRerunProfile } from "../core/observation/viewerProfile"; import type { ObservationSourceDescriptor } from "../core/runtime/contracts"; import { RerunViewport, @@ -46,7 +47,6 @@ function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" if (status === "contract") return "warning"; return "neutral"; } - function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) { return (
@@ -76,7 +76,6 @@ function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
); } - function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) { return (
@@ -89,7 +88,6 @@ function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition;
); } - function EmptySpatialStage({ settings }: { settings: SceneSettings }) { return (
@@ -181,7 +179,7 @@ function SpatialWorkspace({ ); const recordedPerceptionSupported = recordedSource && perceptionLoad.phase !== "unavailable"; - const recordedPerceptionReady = recordedSource && perceptionLoad.phase === "ready"; + const recordedPerceptionLoading = recordedSource && perceptionLoad.phase === "loading"; const recordedPerceptionEnabled = showDetections2d || showSegmentation || showCuboids3d; // The native recorded camera remains the authoritative original. Only 2D @@ -261,7 +259,7 @@ function SpatialWorkspace({ const shouldPrepareRecordedSource = useCallback((sourceId: string) => { if (!recordedSessionAdmission) return false; return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) || - recordedSessionAdmission.cameras[sourceId]?.phase === "ready"; + ["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading"); }, [recordedSessionAdmission]); const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []); const onPlaybackChange = useCallback( @@ -381,6 +379,35 @@ function SpatialWorkspace({ : presentedViewerStatus === "error" ? "danger" : "neutral"; + const rerunViewerProfile = recordedSource + ? recordedSessionRerunProfile({ + sourceUrl, + artifact: recordedReplay, + autoplayWhenReady: true, + presentationGate: recordedSessionGate, + expectedTimelineStartSeconds: state?.observationTimeline?.range?.startSeconds, + expectedTimelineEndSeconds: state?.observationTimeline?.range?.endSeconds, + initialPlaybackStartSeconds: initialRecordedPlaybackStartSeconds, + view: "spatial", + viewResetGeneration: recordedViewResetGeneration, + followTrajectory: followRecordedTrajectory, + perceptionLayers: { + enabled: recordedPerceptionSupported && recordedPerceptionEnabled, + detections2d: showDetections2d, + segmentation: showSegmentation, + cuboids3d: showCuboids3d, + }, + perceptionRetryGeneration, + lockPerceptionCameraInteraction: unifiedPerception, + }) + : liveAcquisitionRerunProfile({ + sourceUrl, + liveActivitySequence: livePresentationActivitySequence, + liveStreamId: state?.spatialSource?.id ?? null, + liveRecoveryAuthorityIdentity: streamActive + ? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource) + : null, + }); return (
} aria-pressed={detections2dActive} - disabled={recordedSource && !recordedPerceptionReady} + disabled={recordedPerceptionLoading} onClick={() => recordedSource ? setShowDetections2d((current) => !current) : onLivePerceptionLayersChange({ @@ -420,7 +447,7 @@ function SpatialWorkspace({ variant={segmentationActive ? "primary" : "secondary"} icon={} aria-pressed={segmentationActive} - disabled={recordedSource && !recordedPerceptionReady} + disabled={recordedPerceptionLoading} onClick={() => recordedSource ? setShowSegmentation((current) => !current) : onLivePerceptionLayersChange({ @@ -435,7 +462,7 @@ function SpatialWorkspace({ variant={cuboids3dActive ? "primary" : "secondary"} icon={} aria-pressed={cuboids3dActive} - disabled={recordedSource && !recordedPerceptionReady} + disabled={recordedPerceptionLoading} onClick={() => recordedSource ? setShowCuboids3d((current) => !current) : onLivePerceptionLayersChange({ @@ -489,35 +516,8 @@ function SpatialWorkspace({ > {sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? ( diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index 63ca0f4..acabddf 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -35,6 +35,7 @@ import { type E47SemanticClass, type E47SemanticTimelineFrame, } from "../../core/laboratory/e47SemanticSlam"; +import { laboratoryRecordedEvidenceDemand } from "../../core/laboratory/recordedEvidenceProfile"; import type { M4ThreatCameraProposal, M4ThreatTimelineFrame, @@ -234,6 +235,26 @@ export function M4ReplayThreatVisual({ const activeSemantic = availableSemanticLayers.find( (layer, index) => (layer.id ?? `${layer.resultId}:${index}`) === selectedSemanticLayerId, ) ?? availableSemanticLayers[0]; + const evidenceDemand = useMemo(() => laboratoryRecordedEvidenceDemand({ + mediaMode, + spatialMode, + showMediaSemantic: Boolean(activeSemantic) && showMediaSemantic, + showSpatialSemantic: Boolean(activeSemantic) && showSpatialSemantic, + showMediaPoints, + classifiedSpatialMode: !classifiedSpatialLayer + ? "none" + : classifiedSpatialLayer.replacePointCloud + ? "replace-source" + : "overlay", + }), [ + activeSemantic, + classifiedSpatialLayer, + mediaMode, + showMediaPoints, + showMediaSemantic, + showSpatialSemantic, + spatialMode, + ]); const metricSceneRef = useRef(null); const metadata = useM4ThreatTimelineMetadata(resultId, timelineEndpointRoot); const playbackRange = useMemo(() => metadata.timeline ? ({ @@ -249,6 +270,7 @@ export function M4ReplayThreatVisual({ resultId, timeline: metadata.timeline, currentSeconds: playbackController.playback.currentSeconds, + includeSpatialPoints: evidenceDemand.sourceSpatialPoints, endpointRoot: timelineEndpointRoot, }); const [videoSource, setVideoSource] = useState(null); @@ -270,6 +292,10 @@ export function M4ReplayThreatVisual({ useEffect(() => { const timeline = metadata.timeline; + if (!evidenceDemand.recordedVideo) { + setVideoLoading(false); + return; + } if (!timeline || videoSource) return; const controller = new AbortController(); setVideoLoading(true); @@ -304,7 +330,7 @@ export function M4ReplayThreatVisual({ if (!controller.signal.aborted) setVideoLoading(false); }); return () => controller.abort(); - }, [metadata.timeline, videoSource]); + }, [evidenceDemand.recordedVideo, metadata.timeline, videoSource]); const lastFrameRef = useRef(null); useEffect(() => { @@ -319,6 +345,9 @@ export function M4ReplayThreatVisual({ resultId: string; frame: M4ThreatTimelineFrame; } | null>(null); + useEffect(() => { + lastSpatialFrameRef.current = null; + }, [evidenceDemand.sourceSpatialPoints, resultId]); if (frame?.spatialAvailable) { lastSpatialFrameRef.current = { resultId, frame }; } @@ -328,7 +357,7 @@ export function M4ReplayThreatVisual({ ? lastSpatialFrameRef.current.frame : null; const cameraPointOverlay = useM4ThreatCameraPointOverlay({ - enabled: showReferenceMediaLayers && showMediaPoints, + enabled: showReferenceMediaLayers && evidenceDemand.cameraPointOverlay, resultId, sequence: frame?.sequence ?? null, endpointRoot: timelineEndpointRoot, @@ -354,6 +383,7 @@ export function M4ReplayThreatVisual({ activeSequence: frame?.sequence ?? timelineFrame.activeSequence, frameCount: metadata.timeline?.frameCount ?? 0, taxonomy: spatialSemanticTaxonomy, + enabled: evidenceDemand.selectedSemanticPoints, }); const displayingBufferedFrame = Boolean( frame @@ -647,7 +677,7 @@ export function M4ReplayThreatVisual({ }, ), [metadata.timeline, spatialFrame, timelineFrame.availableFrames]); const semanticOverlay: RecordedEvidenceSemanticOverlay | undefined = - activeSemantic && showMediaSemantic && frame + activeSemantic && evidenceDemand.selectedSemanticMask && frame ? { src: activeSemantic.maskUrl?.(frame.sequence) ?? e47SemanticMaskUrl(activeSemantic.resultId, frame.sequence), @@ -694,10 +724,14 @@ export function M4ReplayThreatVisual({ }; useEffect(() => { - if (playbackController.playback.playing || !frame) return; + if ( + playbackController.playback.playing + || !evidenceDemand.exactCameraFrame + || !frame + ) return; const image = new Image(); image.src = frame.cameraUrl; - }, [frame?.cameraUrl, playbackController.playback.playing]); + }, [evidenceDemand.exactCameraFrame, frame?.cameraUrl, playbackController.playback.playing]); const splitView = mediaMode !== null && spatialMode !== null; diff --git a/apps/control-station/src/workspaces/laboratory/useE47SemanticTimeline.ts b/apps/control-station/src/workspaces/laboratory/useE47SemanticTimeline.ts index 50336e0..a6f2d34 100644 --- a/apps/control-station/src/workspaces/laboratory/useE47SemanticTimeline.ts +++ b/apps/control-station/src/workspaces/laboratory/useE47SemanticTimeline.ts @@ -11,11 +11,30 @@ const CHUNK_SIZE = 24; const RETAINED_CHUNK_COUNT = 8; const PREFETCH_CHUNKS_AHEAD = 2; -function chunkWindowStarts(activeStart: number, frameCount: number): readonly number[] { - return Array.from( - { length: PREFETCH_CHUNKS_AHEAD + 2 }, - (_, index) => activeStart + (index - 1) * CHUNK_SIZE, - ).filter((start) => start >= 0 && start < frameCount); +export function e47SemanticChunkWindowStarts( + activeStart: number, + frameCount: number, +): readonly number[] { + return [ + activeStart, + ...Array.from( + { length: PREFETCH_CHUNKS_AHEAD }, + (_, index) => activeStart + (index + 1) * CHUNK_SIZE, + ), + activeStart - CHUNK_SIZE, + ].filter((start) => start >= 0 && start < frameCount); +} + +export function cancelE47SemanticRequestsOutsideWindow( + inFlight: Map, + desiredStarts: readonly number[], +): void { + const desired = new Set(desiredStarts); + for (const [start, controller] of inFlight) { + if (desired.has(start)) continue; + controller.abort(); + inFlight.delete(start); + } } function errorMessage(error: unknown): string { @@ -29,11 +48,13 @@ export function useE47SemanticTimelineFrame({ activeSequence, frameCount, taxonomy, + enabled = true, }: { resultId: string | null; activeSequence: number | null; frameCount: number; taxonomy: readonly E47SemanticClass[]; + enabled?: boolean; }) { const [chunks, setChunks] = useState>( () => new Map(), @@ -55,16 +76,21 @@ export function useE47SemanticTimelineFrame({ for (const controller of inFlight.current.values()) controller.abort(); inFlight.current.clear(); }; - }, [resultId]); + }, [enabled, resultId]); - const activeStart = activeSequence === null + const activeStart = !enabled || activeSequence === null ? null : Math.floor(activeSequence / CHUNK_SIZE) * CHUNK_SIZE; activeStartRef.current = activeStart; useEffect(() => { - if (!resultId || activeStart === null || frameCount < 1) return; - for (const start of chunkWindowStarts(activeStart, frameCount)) { + if (!enabled || !resultId || activeStart === null || frameCount < 1) { + cancelE47SemanticRequestsOutsideWindow(inFlight.current, []); + return; + } + const starts = e47SemanticChunkWindowStarts(activeStart, frameCount); + cancelE47SemanticRequestsOutsideWindow(inFlight.current, starts); + for (const start of starts) { if (chunksRef.current.has(start) || inFlight.current.has(start)) continue; const controller = new AbortController(); inFlight.current.set(start, controller); @@ -95,8 +121,11 @@ export function useE47SemanticTimelineFrame({ .finally(() => { if (inFlight.current.get(start) === controller) inFlight.current.delete(start); }); + // Semantic point arrays are large JSON payloads. Admit the active chunk + // first, then advance the bounded prefetch window one request per render. + break; } - }, [activeStart, frameCount, resultId, taxonomy]); + }, [activeStart, chunks, enabled, frameCount, resultId, taxonomy]); const activeFrame: E47SemanticTimelineFrame | null = useMemo(() => { if (activeSequence === null || activeStart === null) return null; @@ -107,7 +136,7 @@ export function useE47SemanticTimelineFrame({ return { activeFrame, - loading: Boolean(resultId) && activeSequence !== null && !activeFrame && !error, + loading: enabled && Boolean(resultId) && activeSequence !== null && !activeFrame && !error, error, }; } diff --git a/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts b/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts index a9b7b38..bcb686f 100644 --- a/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts +++ b/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts @@ -76,11 +76,13 @@ export function useM4ThreatTimelineFrame({ resultId, timeline, currentSeconds, + includeSpatialPoints = true, endpointRoot, }: { resultId: string; timeline: M4ThreatTimeline | null; currentSeconds: number; + includeSpatialPoints?: boolean; endpointRoot?: string; }) { const [chunks, setChunks] = useState>( @@ -107,7 +109,7 @@ export function useM4ThreatTimelineFrame({ setPlaybackError(null); setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 }); if (!timeline) return () => controller.abort(); - if (!binaryPlayback) { + if (!binaryPlayback || !includeSpatialPoints) { setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 }); return () => controller.abort(); } @@ -127,7 +129,7 @@ export function useM4ThreatTimelineFrame({ } }); return () => controller.abort(); - }, [binaryPlayback, endpointRoot, resultId, timeline]); + }, [binaryPlayback, endpointRoot, includeSpatialPoints, resultId, timeline]); useEffect(() => { for (const controller of inFlight.current.values()) controller.abort(); @@ -140,7 +142,7 @@ export function useM4ThreatTimelineFrame({ for (const controller of inFlight.current.values()) controller.abort(); inFlight.current.clear(); }; - }, [resultId, timeline]); + }, [includeSpatialPoints, resultId, timeline]); const activeSequence = useMemo( () => timeline @@ -158,7 +160,11 @@ export function useM4ThreatTimelineFrame({ activeChunkStartRef.current = activeChunkStart; useEffect(() => { - if (!timeline || activeChunkStart === null || (binaryPlayback && !playbackManifest)) return; + if ( + !timeline + || activeChunkStart === null + || (binaryPlayback && includeSpatialPoints && !playbackManifest) + ) return; const starts = m4ThreatChunkWindowStarts( activeChunkStart, chunkSize, @@ -170,7 +176,7 @@ export function useM4ThreatTimelineFrame({ const controller = new AbortController(); inFlight.current.set(start, controller); void (async () => { - const playbackPointPack = binaryPlayback && playbackManifest + const playbackPointPack = binaryPlayback && includeSpatialPoints && playbackManifest ? await fetchM4ThreatPlaybackPointChunk( playbackManifest, Math.floor(start / playbackManifest.chunkFrameCount), @@ -189,6 +195,7 @@ export function useM4ThreatTimelineFrame({ endpointRoot, cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery, playbackPointPack, + includePoints: includeSpatialPoints, }); })() .then((chunk) => { @@ -220,7 +227,7 @@ export function useM4ThreatTimelineFrame({ // loaded first, then the next chunk is prefetched on the following render. break; } - }, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, playbackManifest, resultId, timeline]); + }, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, includeSpatialPoints, playbackManifest, resultId, timeline]); const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => { if (activeSequence === null || activeChunkStart === null) return null; diff --git a/apps/control-station/test/laboratoryRecordedEvidenceProfile.test.mjs b/apps/control-station/test/laboratoryRecordedEvidenceProfile.test.mjs new file mode 100644 index 0000000..e7dc1bb --- /dev/null +++ b/apps/control-station/test/laboratoryRecordedEvidenceProfile.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { createServer } from "vite"; + +let server; +let laboratoryRecordedEvidenceDemand; +let e47SemanticChunkWindowStarts; +let cancelE47SemanticRequestsOutsideWindow; + +before(async () => { + server = await createServer({ + appType: "custom", + logLevel: "silent", + server: { middlewareMode: true }, + }); + ({ laboratoryRecordedEvidenceDemand } = await server.ssrLoadModule( + "/src/core/laboratory/recordedEvidenceProfile.ts", + )); + ({ + e47SemanticChunkWindowStarts, + cancelE47SemanticRequestsOutsideWindow, + } = await server.ssrLoadModule( + "/src/workspaces/laboratory/useE47SemanticTimeline.ts", + )); +}); + +after(async () => { + await server?.close(); +}); + +test("camera-only LAB presentation does not acquire hidden spatial or semantic payloads", () => { + assert.deepEqual(laboratoryRecordedEvidenceDemand({ + mediaMode: "video", + spatialMode: null, + showMediaSemantic: false, + showSpatialSemantic: true, + showMediaPoints: false, + classifiedSpatialMode: "overlay", + }), { + sourceTimelineMetadata: true, + recordedVideo: true, + exactCameraFrame: false, + sourceSpatialPoints: false, + cameraPointOverlay: false, + selectedSemanticMask: false, + selectedSemanticPoints: false, + classifiedSpatial: false, + }); +}); + +test("visible M4 layers acquire only their selected camera and spatial evidence", () => { + assert.deepEqual(laboratoryRecordedEvidenceDemand({ + mediaMode: "camera", + spatialMode: "plan", + showMediaSemantic: true, + showSpatialSemantic: true, + showMediaPoints: true, + classifiedSpatialMode: "overlay", + }), { + sourceTimelineMetadata: true, + recordedVideo: false, + exactCameraFrame: true, + sourceSpatialPoints: true, + cameraPointOverlay: true, + selectedSemanticMask: true, + selectedSemanticPoints: true, + classifiedSpatial: true, + }); +}); + +test("a classified replacement does not also load the hidden source point track", () => { + const demand = laboratoryRecordedEvidenceDemand({ + mediaMode: null, + spatialMode: "3d", + showMediaSemantic: false, + showSpatialSemantic: false, + showMediaPoints: false, + classifiedSpatialMode: "replace-source", + }); + assert.equal(demand.sourceSpatialPoints, false); + assert.equal(demand.classifiedSpatial, true); +}); + +test("semantic point chunks are ordered active-first and stale requests are cancelled", () => { + assert.deepEqual(e47SemanticChunkWindowStarts(48, 4_489), [48, 72, 96, 24]); + assert.deepEqual(e47SemanticChunkWindowStarts(0, 4_489), [0, 24, 48]); + + const cancelled = []; + const requests = new Map([ + [0, { abort: () => cancelled.push(0) }], + [24, { abort: () => cancelled.push(24) }], + [240, { abort: () => cancelled.push(240) }], + ]); + cancelE47SemanticRequestsOutsideWindow(requests, [240, 264]); + assert.deepEqual(cancelled, [0, 24]); + assert.deepEqual([...requests.keys()], [240]); +}); diff --git a/apps/control-station/test/liveViewerDiagnostics.test.mjs b/apps/control-station/test/liveViewerDiagnostics.test.mjs index 326525d..7c56a2a 100644 --- a/apps/control-station/test/liveViewerDiagnostics.test.mjs +++ b/apps/control-station/test/liveViewerDiagnostics.test.mjs @@ -7,6 +7,7 @@ let createLiveViewerDiagnosticLifecycle; let createAbortFencedBuildVerifier; let createUiBuildStaleCoordinator; let liveViewerDiagnosticBody; +let reloadRecordedViewerAfterStaleModuleFailure; let server; let uiBuildIdFromModuleScripts; let verifyLiveViewerClientBuild; @@ -22,6 +23,7 @@ before(async () => { createLiveViewerDiagnosticLifecycle, createUiBuildStaleCoordinator, liveViewerDiagnosticBody, + reloadRecordedViewerAfterStaleModuleFailure, uiBuildIdFromModuleScripts, verifyLiveViewerClientBuild, } = await server.ssrLoadModule("/src/core/observation/liveViewerDiagnostics.ts")); @@ -204,6 +206,36 @@ test("build drift is reported once with the exact loaded build", async () => { ); }); +test("a recorded viewer reloads only when its failed lazy module belongs to a stale build", async () => { + const reloads = []; + const loadedUiBuildId = "/assets/index-abcdefgh.js"; + const currentUiBuildId = "/assets/index-ijklmnop.js"; + const fetcher = async (_url, options) => { + assert.equal(options.headers["X-MissionCore-UI-Build"], loadedUiBuildId); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "X-MissionCore-UI-Build": currentUiBuildId }, + }); + }; + + assert.equal(await reloadRecordedViewerAfterStaleModuleFailure({ + loadedUiBuildId, + fetcher, + reload: () => reloads.push("reload"), + }), true); + assert.deepEqual(reloads, ["reload"]); + + assert.equal(await reloadRecordedViewerAfterStaleModuleFailure({ + loadedUiBuildId: currentUiBuildId, + fetcher: async () => new Response(JSON.stringify({}), { + status: 200, + headers: { "X-MissionCore-UI-Build": currentUiBuildId }, + }), + reload: () => reloads.push("unexpected"), + }), false); + assert.deepEqual(reloads, ["reload"]); +}); + test("last unsubscribe fences an already queued build verification callback", () => { const controller = new AbortController(); const observedSignals = []; diff --git a/apps/control-station/test/m4ReplayThreat.test.mjs b/apps/control-station/test/m4ReplayThreat.test.mjs index 4d6edb7..164d40c 100644 --- a/apps/control-station/test/m4ReplayThreat.test.mjs +++ b/apps/control-station/test/m4ReplayThreat.test.mjs @@ -932,7 +932,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async () assert.match(visual, /timelineFrame\.availableFrames\.find/); assert.match(visual, /localSurfaceBodyXyzM=\{localSurface\.pointsBodyXyzM\}/); assert.match(visual, /showLocalSurface=\{showLocalSurface\}/); - assert.match(visual, /\{semantic \? \([\s\S]*>\s*SEMANTICS\s*<\/Button>/); + assert.match(visual, /\{activeSemantic \? \([\s\S]*>\s*SEMANTICS\s*<\/Button>/); assert.match(visual, /current safety — все \{classifiedCellCount\.toLocaleString\("ru-RU"\)\} TGS-ячейки UNOBSERVED/); assert.match( visual, diff --git a/apps/control-station/test/recordedCameraBuffering.test.mjs b/apps/control-station/test/recordedCameraBuffering.test.mjs index 2c5de07..1376e99 100644 --- a/apps/control-station/test/recordedCameraBuffering.test.mjs +++ b/apps/control-station/test/recordedCameraBuffering.test.mjs @@ -11,7 +11,11 @@ let recordedMediaSeekableCoverage; let recordedMediaFragmentUrl; let recordedMediaDecodeStartSequence; let recordedMediaSegmentAppendOrder; +let recordedMediaSegmentSequenceAtTime; let recordedMediaCanRollTarget; +let nextRecordedMediaRandomAccessSequence; +let recordedMediaRecoveryTargetSequence; +let selectRecordedMediaPreparationEpoch; before(async () => { server = await createServer({ @@ -26,7 +30,11 @@ before(async () => { recordedMediaFragmentUrl, recordedMediaDecodeStartSequence, recordedMediaSegmentAppendOrder, + recordedMediaSegmentSequenceAtTime, recordedMediaCanRollTarget, + nextRecordedMediaRandomAccessSequence, + recordedMediaRecoveryTargetSequence, + selectRecordedMediaPreparationEpoch, } = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx")); }); @@ -161,7 +169,7 @@ test("decoded duration and seekable range cover the complete declared epoch", () assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false); }); -test("recorded player keeps full-archive range fallback and uses bounded generation-bound fragments", async () => { +test("production replay derives bounded fragments and retains native range fallback", async () => { const source = await readFile( new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url), "utf8", @@ -176,6 +184,10 @@ test("recorded player keeps full-archive range fallback and uses bounded generat assert.match(source, /hasPresentedFrame/); assert.match(source, /retainForwardFrame/); assert.match(source, /candidateTarget\.sequence >= previousTarget\.sequence/); + assert.match(source, /effectiveSegmentCount = segmentCount \?\? epoch\?\.segmentCount \?\? null/); + assert.match(source, /requestedSegmentSequence = segmentSequence \?\?/); + assert.match(source, /waitForRecordedVideoInitialFrame/); + assert.match(source, /setSegmentRecoveryGeneration/); assert.equal(recordedMediaDecodeStartSequence([1, 1491, 1501], 1500), 1491); assert.deepEqual( @@ -209,6 +221,44 @@ test("recorded player keeps full-archive range fallback and uses bounded generat ); }); +test("production replay derives its bounded fragment directly from the source clock", () => { + const ends = [0.101, 0.185, 0.286, 0.401]; + const epochStart = 39.215263458; + assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart), 1); + assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 0.101), 1); + assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 0.103), 2); + assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 0.4), 4); + assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 1), null); +}); + +test("a corrupt fragment advances recovery to the next random-access frame", () => { + assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 1), 11); + assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 20), 21); + assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 49), null); + assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 0), null); +}); + +test("paused seek displays the recovery keyframe until the source clock leaves the corrupt GOP", () => { + assert.equal(recordedMediaRecoveryTargetSequence(120, 120, 149), 149); + assert.equal(recordedMediaRecoveryTargetSequence(130, 120, 149), 149); + assert.equal(recordedMediaRecoveryTargetSequence(119, 120, 149), 119); + assert.equal(recordedMediaRecoveryTargetSequence(149, 120, 149), 149); + assert.equal(recordedMediaRecoveryTargetSequence(151, 120, 149), 151); + assert.equal(recordedMediaRecoveryTargetSequence(null, 120, 149), null); + assert.equal(recordedMediaRecoveryTargetSequence(120, null, null), 120); +}); + +test("camera admission selects one bounded epoch while the shared clock is outside video", () => { + const epochs = [ + { ordinal: 1, timelineStartSeconds: 39, timelineEndSeconds: 100 }, + { ordinal: 2, timelineStartSeconds: 120, timelineEndSeconds: 180 }, + ]; + assert.equal(selectRecordedMediaPreparationEpoch(epochs, 0).ordinal, 1); + assert.equal(selectRecordedMediaPreparationEpoch(epochs, 70).ordinal, 1); + assert.equal(selectRecordedMediaPreparationEpoch(epochs, 110).ordinal, 2); + assert.equal(selectRecordedMediaPreparationEpoch(epochs, 200).ordinal, 2); +}); + test("recorded player preserves forward rolling playback but seeks backward clip loops", () => { assert.equal(recordedMediaCanRollTarget(20, 21, true, true), true); assert.equal(recordedMediaCanRollTarget(20, 20, true, true), true); diff --git a/apps/control-station/test/recordedSessionAdmission.test.mjs b/apps/control-station/test/recordedSessionAdmission.test.mjs index d2de4f6..66dafbc 100644 --- a/apps/control-station/test/recordedSessionAdmission.test.mjs +++ b/apps/control-station/test/recordedSessionAdmission.test.mjs @@ -34,7 +34,7 @@ function camera(phase, byteLength = 1_024) { return { phase, byteLength, message: null }; } -test("recorded session admits RRD and every declared camera as one atomic generation", () => { +test("recorded session keeps camera and timeline atomic without hiding a ready spatial frame", () => { const ids = ["camera.left", "camera.right"]; const oneCamera = { "camera.left": camera("ready"), @@ -42,7 +42,7 @@ test("recorded session admits RRD and every declared camera as one atomic genera }; const partialGate = admission.recordedSessionAdmissionPhase("ready", ids, oneCamera); assert.equal(partialGate, "loading"); - assert.equal(rerunPresentationStatus("ready", partialGate, true), "loading"); + assert.equal(rerunPresentationStatus("ready", partialGate, true), "ready"); assert.equal( recordedMediaPresentationState("ready", "generation", "generation", false, partialGate), "loading", @@ -71,7 +71,7 @@ test("any RRD or camera failure closes the complete recorded session", () => { ...cameras, "camera.right": camera("ready"), }), "error"); - assert.equal(rerunPresentationStatus("ready", "error", true), "error"); + assert.equal(rerunPresentationStatus("ready", "error", true), "ready"); assert.equal( recordedMediaPresentationState("ready", "generation", "generation", false, "error"), "error", diff --git a/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs b/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs index f939861..eb15ae8 100644 --- a/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs +++ b/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs @@ -257,6 +257,14 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn source, /if \(readyToRender && !readyPublished\)[\s\S]*clearRecordedAdmissionWatchdog\(\);/, ); + assert.match( + source, + /reloadRecordedViewerAfterStaleModuleFailure\(\{[\s\S]*loadedUiBuildId: diagnosticLifecycle\.lineage\.uiBuildId/, + ); + assert.match( + source, + /!recordedPerceptionUrl \|\| !recordedPerceptionLayers\.enabled/, + ); }); test("one live document owns one native Rerun receiver", async () => { @@ -287,8 +295,12 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan" source, /const livePresentationActivitySequence = metrics\?\.publishedFrameCount \?\?[\s\S]*liveRerunSource && !streamActive \? 1 : null/, ); - assert.match(source, /followLive=\{liveRerunSource\}/); - assert.match(source, /liveActivitySequence=\{livePresentationActivitySequence\}/); + assert.match( + source, + /liveAcquisitionRerunProfile\(\{[\s\S]*liveActivitySequence: livePresentationActivitySequence/, + ); + assert.match(source, / { server = await createServer({ @@ -26,6 +27,7 @@ before(async () => { isUsableRecordedPlaybackRange, recordedPlaybackBufferState, recordedPlaybackRangeWhenReady, + rerunPresentationStatus, } = await server.ssrLoadModule("/src/components/RerunViewport.tsx")); }); @@ -33,7 +35,7 @@ after(async () => { await server?.close(); }); -test("a first frame reports buffer telemetry but is not ready for presentation", () => { +test("a verified first frame is presentable while full-range controls stay closed", () => { assert.equal(isUsableRecordedPlaybackRange(null), false); assert.equal(isUsableRecordedPlaybackRange({ min: Number.NaN, max: 0 }), false); assert.equal(isUsableRecordedPlaybackRange({ min: 2, max: 1 }), false); @@ -47,8 +49,11 @@ test("a first frame reports buffer telemetry but is not ready for presentation", bufferProgress: 0, fullyBuffered: false, }); - assert.equal(isRecordedPlaybackReady(true, true, firstFrame), false); + assert.equal(isRecordedPlaybackReady(true, true, firstFrame), true); assert.equal(recordedPlaybackRangeWhenReady({ min: 0, max: 0 }, firstFrame, true), null); + assert.equal(rerunPresentationStatus("ready", "loading", true), "ready"); + assert.equal(rerunPresentationStatus("loading", "ready", true), "loading"); + assert.equal(rerunPresentationStatus("ready", "error", true), "ready"); }); test("host timeline remains unmounted until the verified recording is fully ready", () => { @@ -107,7 +112,7 @@ test("buffer progress grows independently and preserves the full-buffer toleranc ); assert.equal(missingDeclaredStart.bufferProgress, 1); assert.equal(missingDeclaredStart.fullyBuffered, false); - assert.equal(isRecordedPlaybackReady(true, true, missingDeclaredStart), false); + assert.equal(isRecordedPlaybackReady(true, true, missingDeclaredStart), true); }); test("a verified split boundary spill covers and clamps the declared LAB window", () => { diff --git a/apps/control-station/test/semanticEvidencePrimitives.test.mjs b/apps/control-station/test/semanticEvidencePrimitives.test.mjs index 3bec288..5bbfc2c 100644 --- a/apps/control-station/test/semanticEvidencePrimitives.test.mjs +++ b/apps/control-station/test/semanticEvidencePrimitives.test.mjs @@ -81,7 +81,7 @@ test("M4 keeps independent semantic controls in media and spatial panes", async ); assert.match(source, /showMediaSemantic/); assert.match(source, /showSpatialSemantic/); - assert.match(source, /semantic && showMediaSemantic && frame/); + assert.match(source, /activeSemantic && evidenceDemand\.selectedSemanticMask && frame/); assert.match(source, /Array\.from\(\{ length: 12 \}, \(_, index\) => index \+ 1\)/); assert.match(source, /\|\| !showSpatialSemantic/); assert.match(source, /aria-label="Слои камеры и видео"/); diff --git a/apps/control-station/test/viewerProfile.test.mjs b/apps/control-station/test/viewerProfile.test.mjs new file mode 100644 index 0000000..df5deb5 --- /dev/null +++ b/apps/control-station/test/viewerProfile.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { createServer } from "vite"; + +let server; +let LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE; +let liveAcquisitionRerunProfile; +let recordedSessionRerunProfile; + +before(async () => { + server = await createServer({ + appType: "custom", + logLevel: "silent", + server: { middlewareMode: true }, + }); + ({ + LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE, + liveAcquisitionRerunProfile, + recordedSessionRerunProfile, + } = await server.ssrLoadModule("/src/core/observation/viewerProfile.ts")); +}); + +after(async () => { + await server?.close(); +}); + +test("live acquisition profile cannot acquire recorded playback policy", () => { + assert.deepEqual(liveAcquisitionRerunProfile({ + sourceUrl: "rerun+http://127.0.0.1:9877/proxy", + liveActivitySequence: 12, + liveStreamId: "acquisition-1", + liveRecoveryAuthorityIdentity: "authority-1", + }), { + kind: "live-acquisition", + clock: "stream_time", + sourceUrl: "rerun+http://127.0.0.1:9877/proxy", + liveActivitySequence: 12, + liveStreamId: "acquisition-1", + liveRecoveryAuthorityIdentity: "authority-1", + }); +}); + +test("recorded session profile owns progressive admission and on-demand layers", () => { + const artifact = { + sourceUrl: "/api/v1/observation-sessions/session-1/recording.rrd", + viewerSourceUrl: "/api/v1/observation-sessions/session-1/recording.rrd?generation=abc", + byteLength: 42, + sha256: "a".repeat(64), + }; + const profile = recordedSessionRerunProfile({ + sourceUrl: artifact.sourceUrl, + artifact, + autoplayWhenReady: true, + presentationGate: "loading", + expectedTimelineStartSeconds: 0, + expectedTimelineEndSeconds: 20, + initialPlaybackStartSeconds: 1, + view: "spatial", + viewResetGeneration: 0, + followTrajectory: false, + perceptionLayers: { + enabled: false, + detections2d: false, + segmentation: false, + cuboids3d: false, + }, + perceptionRetryGeneration: 0, + lockPerceptionCameraInteraction: false, + }); + + assert.equal(profile.kind, "recorded-session"); + assert.equal(profile.clock, "session_time"); + assert.equal(profile.artifact, artifact); + assert.equal(profile.perceptionLayers.enabled, false); +}); + +test("LAB recorded evidence remains outside native Rerun lifecycle", () => { + assert.deepEqual(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE, { + kind: "lab-recorded-evidence", + clock: "source-sequence", + cameraTransport: "generation-bound-fmp4", + spatialTransport: "bounded-sealed-artifacts", + loadPolicy: "visible-evidence-only", + workerRequired: false, + }); + assert.equal(Object.isFrozen(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE), true); +});