diff --git a/apps/control-station/src/components/RerunViewport.tsx b/apps/control-station/src/components/RerunViewport.tsx index e6b104c..4ef957d 100644 --- a/apps/control-station/src/components/RerunViewport.tsx +++ b/apps/control-station/src/components/RerunViewport.tsx @@ -5,6 +5,20 @@ import type { RecordedAdmissionPhase } from "../core/observation/recordedSession export type RerunViewportStatus = "idle" | "loading" | "ready" | "error"; export type RecordedRerunView = "spatial" | "perception" | "perception3d" | "metrics"; +export type RecordedPerceptionLoadPhase = + | "idle" + | "loading" + | "ready" + | "unavailable" + | "error"; + +export interface RecordedPerceptionLoadState { + phase: RecordedPerceptionLoadPhase; + receivedBytes: number; + totalBytes: number | null; + progress: number | null; + message: string; +} export interface RecordedPerceptionLayers { enabled: boolean; @@ -67,7 +81,9 @@ export interface RerunViewportProps { recordedView?: RecordedRerunView; recordedViewResetGeneration?: 0 | 1; recordedPerceptionLayers?: RecordedPerceptionLayers; - onPerceptionAvailabilityChange?: (available: boolean) => void; + recordedPerceptionRetryGeneration?: number; + lockPerceptionCameraInteraction?: boolean; + onPerceptionLoadChange?: (state: RecordedPerceptionLoadState) => void; } export interface RecordedRrdArtifactDescriptor { @@ -566,10 +582,12 @@ export async function fetchRecordedPerceptionRrd( origin, signal, fetcher = globalThis.fetch, + onProgress, }: { origin: string; signal?: AbortSignal; fetcher?: typeof globalThis.fetch; + onProgress?: (receivedBytes: number, totalBytes: number) => void; }, ): Promise { const base = new URL(origin); @@ -609,9 +627,32 @@ export async function fetchRecordedPerceptionRrd( ) { throw new Error("Invalid recorded perception response"); } - const payload = new Uint8Array(await response.arrayBuffer()); + const payload = new Uint8Array(declaredLength); + let receivedBytes = 0; + onProgress?.(receivedBytes, declaredLength); + if (response.body) { + const reader = response.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (receivedBytes + value.byteLength > declaredLength) { + await reader.cancel(); + throw new Error("Recorded perception response exceeds its declared length"); + } + payload.set(value, receivedBytes); + receivedBytes += value.byteLength; + onProgress?.(receivedBytes, declaredLength); + } + } else { + const fallback = new Uint8Array(await response.arrayBuffer()); + if (fallback.byteLength <= declaredLength) { + payload.set(fallback); + receivedBytes = fallback.byteLength; + onProgress?.(receivedBytes, declaredLength); + } + } if ( - payload.byteLength !== declaredLength || + receivedBytes !== declaredLength || payload[0] !== 0x52 || payload[1] !== 0x52 || payload[2] !== 0x46 || @@ -644,7 +685,9 @@ export function RerunViewport({ segmentation: false, cuboids3d: false, }, - onPerceptionAvailabilityChange, + recordedPerceptionRetryGeneration = 0, + lockPerceptionCameraInteraction = false, + onPerceptionLoadChange, }: RerunViewportProps) { const hostRef = useRef(null); const [status, setStatus] = useState(sourceUrl ? "loading" : "idle"); @@ -1189,7 +1232,7 @@ export function RerunViewport({ }, [recordedPerceptionUrl]); useEffect(() => { - if (!recordedPerceptionUrl || !recordedPerceptionLayers.enabled) return; + if (!recordedPerceptionUrl) return; const active = perceptionChannelRef.current; const identity = recordedIdentityRef.current; if ( @@ -1199,16 +1242,51 @@ export function RerunViewport({ !active.channel.ready ) return; if (loadedPerceptionChannelRef.current === active) { - onPerceptionAvailabilityChange?.(true); + onPerceptionLoadChange?.({ + phase: "ready", + receivedBytes: 0, + totalBytes: null, + progress: 1, + message: "AI-слои готовы.", + }); return; } const abort = new AbortController(); + let lastReportedPercent = -1; + onPerceptionLoadChange?.({ + phase: "loading", + receivedBytes: 0, + totalBytes: null, + progress: null, + message: "Сервер готовит AI-слои.", + }); void fetchRecordedPerceptionRrd(recordedPerceptionUrl, identity, { origin: window.location.origin, signal: abort.signal, + onProgress: (receivedBytes, totalBytes) => { + const progress = totalBytes > 0 ? receivedBytes / totalBytes : null; + const percent = progress === null ? -1 : Math.floor(progress * 100); + if (percent === lastReportedPercent && receivedBytes !== totalBytes) return; + lastReportedPercent = percent; + onPerceptionLoadChange?.({ + phase: "loading", + receivedBytes, + totalBytes, + progress, + message: progress === null + ? "Загружаем AI-слои." + : `Загружаем AI-слои · ${Math.round(progress * 100)}%`, + }); + }, }).then((payload) => { if (payload === null) { - onPerceptionAvailabilityChange?.(false); + onPerceptionLoadChange?.({ + phase: "unavailable", + receivedBytes: 0, + totalBytes: null, + progress: null, + message: "Для этой сессии AI-слои не опубликованы.", + }); return; } if ( @@ -1221,16 +1299,31 @@ export function RerunViewport({ } active.channel.send_rrd(payload); loadedPerceptionChannelRef.current = active; - onPerceptionAvailabilityChange?.(true); - }).catch(() => { - onPerceptionAvailabilityChange?.(false); + onPerceptionLoadChange?.({ + phase: "ready", + receivedBytes: payload.byteLength, + totalBytes: payload.byteLength, + progress: 1, + message: "AI-слои готовы.", + }); + }).catch((error: unknown) => { + if (abort.signal.aborted) return; + onPerceptionLoadChange?.({ + phase: "error", + receivedBytes: 0, + totalBytes: null, + progress: null, + message: error instanceof Error + ? "AI-слои не загрузились. Можно повторить." + : "AI-слои недоступны.", + }); // The base recording remains available when no admitted perception layer exists. }); return () => abort.abort(); }, [ - onPerceptionAvailabilityChange, + onPerceptionLoadChange, perceptionChannelRevision, - recordedPerceptionLayers.enabled, + recordedPerceptionRetryGeneration, recordedPerceptionUrl, ]); @@ -1292,6 +1385,13 @@ export function RerunViewport({ className="rerun-viewport__canvas" data-presented={recordedArtifact === null || presentationStatus === "ready" ? "true" : "false"} /> + {lockPerceptionCameraInteraction && presentationStatus === "ready" ? ( +