diff --git a/apps/control-station/src/components/RerunViewport.tsx b/apps/control-station/src/components/RerunViewport.tsx index cdca0a9..66df3ba 100644 --- a/apps/control-station/src/components/RerunViewport.tsx +++ b/apps/control-station/src/components/RerunViewport.tsx @@ -143,6 +143,19 @@ interface RerunBlueprintChannel { }; } +interface RerunNativeReceiver { + endpointUrl: string; + ready: () => boolean; + open: (sourceUrl: string) => void; + close: (sourceUrl: string) => void; +} + +interface LoadedNativePerceptionSource { + receiver: RerunNativeReceiver; + sourceUrl: string; + byteLength: number; +} + interface RecordedRerunIdentity { applicationId: "nodedc_mission_core_recorded"; recordingId: string; @@ -557,6 +570,115 @@ export async function fetchRecordedPerceptionRrd( return payload; } +/** Bind a LAB sidecar to the exact immutable base recording generation. */ +export function resolveRecordedPerceptionViewerSourceUrl( + endpointUrl: string, + identity: RecordedRerunIdentity, + baseGenerationSha256: string, + origin: string, +): string { + const base = new URL(origin); + const endpoint = new URL(endpointUrl, base.origin); + if ( + endpoint.origin !== base.origin || + endpoint.search || + endpoint.hash || + !LAB_RECORDED_PERCEPTION_PATH.test(endpoint.pathname) || + identity.applicationId !== "nodedc_mission_core_recorded" || + !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId) || + !/^[a-f0-9]{64}$/.test(baseGenerationSha256) + ) { + throw new Error("Unsafe recorded perception viewer source"); + } + endpoint.searchParams.set("application_id", identity.applicationId); + endpoint.searchParams.set("recording_id", identity.recordingId); + endpoint.searchParams.set("generation", baseGenerationSha256); + return endpoint.href; +} + +/** Verify the immutable URL with a four-byte range request before Rerun opens it. */ +export async function probeRecordedPerceptionViewerSource( + sourceUrl: string, + { + origin, + signal, + fetcher = globalThis.fetch, + }: { + origin: string; + signal?: AbortSignal; + fetcher?: typeof globalThis.fetch; + }, +): Promise { + const base = new URL(origin); + const endpoint = new URL(sourceUrl, base.origin); + const allowedParameters = ["application_id", "generation", "recording_id"]; + if ( + endpoint.origin !== base.origin || + endpoint.hash || + !LAB_RECORDED_PERCEPTION_PATH.test(endpoint.pathname) || + [...endpoint.searchParams.keys()].sort().join("\0") !== allowedParameters.join("\0") || + endpoint.searchParams.get("application_id") !== "nodedc_mission_core_recorded" || + !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test( + endpoint.searchParams.get("recording_id") ?? "", + ) || + !/^[a-f0-9]{64}$/.test(endpoint.searchParams.get("generation") ?? "") + ) { + throw new Error("Unsafe recorded perception viewer source"); + } + const response = await fetcher(endpoint.href, { + method: "GET", + credentials: "same-origin", + headers: { + Accept: "application/vnd.rerun.rrd", + Range: "bytes=0-3", + }, + signal, + }); + const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim(); + const contentRange = response.headers.get("Content-Range"); + const rangedLength = contentRange?.match(/^bytes 0-3\/(\d+)$/)?.[1]; + const declaredLength = Number( + rangedLength ?? response.headers.get("Content-Length"), + ); + if ( + ![200, 206].includes(response.status) || + contentType !== "application/vnd.rerun.rrd" || + !Number.isSafeInteger(declaredLength) || + declaredLength < 4 || + declaredLength > MAX_PERCEPTION_BYTES + ) { + throw new Error("Invalid recorded perception viewer response"); + } + const prefix = new Uint8Array(4); + let receivedBytes = 0; + const reader = response.body?.getReader(); + if (reader) { + while (receivedBytes < prefix.byteLength) { + const { done, value } = await reader.read(); + if (done) break; + const count = Math.min(value.byteLength, prefix.byteLength - receivedBytes); + prefix.set(value.subarray(0, count), receivedBytes); + receivedBytes += count; + } + await reader.cancel(); + } else { + const payload = new Uint8Array(await response.arrayBuffer()); + const count = Math.min(payload.byteLength, prefix.byteLength); + prefix.set(payload.subarray(0, count)); + receivedBytes = count; + } + if ( + receivedBytes !== prefix.byteLength || + prefix[0] !== 0x52 || + prefix[1] !== 0x52 || + prefix[2] !== 0x46 || + prefix[3] !== 0x32 + ) { + throw new Error("Invalid recorded perception viewer RRD"); + } + return declaredLength; +} + export function RerunViewport({ profile, onStatusChange, @@ -617,7 +739,9 @@ export function RerunViewport({ const uiBuildStaleRef = useRef(false); const blueprintChannelRef = useRef(null); const perceptionChannelRef = useRef(null); + const perceptionReceiverRef = useRef(null); const loadedPerceptionChannelRef = useRef(null); + const loadedNativePerceptionSourceRef = useRef(null); const appliedPointColorKeyRef = useRef(null); const recordedIdentityRef = useRef(null); const blueprintSessionIdRef = useRef(crypto.randomUUID().replaceAll("-", "")); @@ -733,6 +857,7 @@ export function RerunViewport({ const recordedAutoplay = createRecordedAutoplayGate(); let blueprintChannel: RerunBlueprintChannel | null = null; let perceptionChannel: RerunBlueprintChannel | null = null; + let perceptionReceiver: RerunNativeReceiver | null = null; const unsubscribers: Array<() => void> = []; const unsubscribeAll = () => { while (unsubscribers.length > 0) { @@ -989,6 +1114,9 @@ export function RerunViewport({ if (perceptionChannelRef.current === perceptionChannel) { perceptionChannelRef.current = null; } + if (perceptionReceiverRef.current === perceptionReceiver) { + perceptionReceiverRef.current = null; + } recordedIdentityRef.current = null; try { blueprintChannel?.channel.close(); @@ -1000,6 +1128,15 @@ export function RerunViewport({ } catch { // The viewer may already have closed all auxiliary channels. } + const loadedNativeSource = loadedNativePerceptionSourceRef.current; + if (loadedNativeSource?.receiver === perceptionReceiver) { + loadedNativePerceptionSourceRef.current = null; + try { + perceptionReceiver?.close(loadedNativeSource.sourceUrl); + } catch { + // The viewer may already have closed all native receivers. + } + } }, () => { try { if (viewer.ready) viewer.close(resolvedSource); @@ -1363,9 +1500,19 @@ export function RerunViewport({ setBlueprintChannelRevision((revision) => revision + 1); } if (recordedPerceptionUrl) { - const channel = viewer.open_channel("missioncore/recorded-perception"); - perceptionChannel = { endpointUrl: recordedPerceptionUrl, channel }; - perceptionChannelRef.current = perceptionChannel; + if (LAB_RECORDED_PERCEPTION_PATH.test(new URL(recordedPerceptionUrl).pathname)) { + perceptionReceiver = { + endpointUrl: recordedPerceptionUrl, + ready: () => viewer.ready, + open: (source) => viewer.open(source), + close: (source) => viewer.close(source), + }; + perceptionReceiverRef.current = perceptionReceiver; + } else { + const channel = viewer.open_channel("missioncore/recorded-perception"); + perceptionChannel = { endpointUrl: recordedPerceptionUrl, channel }; + perceptionChannelRef.current = perceptionChannel; + } setPerceptionChannelRevision((revision) => revision + 1); } @@ -1440,6 +1587,7 @@ export function RerunViewport({ useEffect(() => { loadedPerceptionChannelRef.current = null; + loadedNativePerceptionSourceRef.current = null; }, [recordedPerceptionUrl]); useEffect(() => { @@ -1459,11 +1607,91 @@ export function RerunViewport({ } return; } - const active = perceptionChannelRef.current; const identity = recordedIdentityRef.current; + if (!identity) return; + if (LAB_RECORDED_PERCEPTION_PATH.test(new URL(recordedPerceptionUrl).pathname)) { + const receiver = perceptionReceiverRef.current; + if ( + !receiver || + receiver.endpointUrl !== recordedPerceptionUrl || + !receiver.ready() || + !recordedArtifact + ) return; + let sourceUrl: string; + try { + sourceUrl = resolveRecordedPerceptionViewerSourceUrl( + recordedPerceptionUrl, + identity, + recordedArtifact.sha256, + window.location.origin, + ); + } catch { + onPerceptionLoadChange?.({ + phase: "error", + receivedBytes: 0, + totalBytes: null, + progress: null, + message: "AI-слои не привязаны к поколению записи.", + }); + return; + } + const loaded = loadedNativePerceptionSourceRef.current; + if (loaded?.receiver === receiver && loaded.sourceUrl === sourceUrl) { + onPerceptionLoadChange?.({ + phase: "ready", + receivedBytes: loaded.byteLength, + totalBytes: loaded.byteLength, + progress: 1, + message: "AI-слои подключены к Rerun.", + }); + return; + } + const abort = new AbortController(); + onPerceptionLoadChange?.({ + phase: "loading", + receivedBytes: 0, + totalBytes: null, + progress: null, + message: "Подключаем AI-слои к Rerun.", + }); + void probeRecordedPerceptionViewerSource(sourceUrl, { + origin: window.location.origin, + signal: abort.signal, + }).then((byteLength) => { + if ( + abort.signal.aborted || + perceptionReceiverRef.current !== receiver || + recordedIdentityRef.current !== identity || + !receiver.ready() + ) return; + receiver.open(sourceUrl); + loadedNativePerceptionSourceRef.current = { + receiver, + sourceUrl, + byteLength, + }; + onPerceptionLoadChange?.({ + phase: "ready", + receivedBytes: byteLength, + totalBytes: byteLength, + progress: 1, + message: "AI-слои подключены к Rerun.", + }); + }).catch(() => { + if (abort.signal.aborted) return; + onPerceptionLoadChange?.({ + phase: "error", + receivedBytes: 0, + totalBytes: null, + progress: null, + message: "AI-слои не загрузились. Можно повторить.", + }); + }); + return () => abort.abort(); + } + const active = perceptionChannelRef.current; if ( !active || - !identity || active.endpointUrl !== recordedPerceptionUrl || !active.channel.ready ) return; @@ -1604,6 +1832,7 @@ export function RerunViewport({ }, [ onPerceptionLoadChange, perceptionChannelRevision, + recordedArtifact?.sha256, recordedPerceptionLayers.enabled, recordedPerceptionRetryGeneration, recordedPerceptionUrl, diff --git a/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx b/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx index 1cda027..3ec90b0 100644 --- a/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx +++ b/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx @@ -139,6 +139,7 @@ export function CanonicalRecordedLabReplay< value={mediaMode} items={[...mediaModes]} label="Видео и камера" + size="dense" onChange={onMediaModeChange} /> @@ -149,6 +150,7 @@ export function CanonicalRecordedLabReplay< value={spatialMode} items={[...spatialModes]} label="3D и план" + size="dense" onChange={onSpatialModeChange} /> diff --git a/apps/control-station/src/workspaces/laboratory/CanonicalVegetationRerunReplay.tsx b/apps/control-station/src/workspaces/laboratory/CanonicalVegetationRerunReplay.tsx index 041b93d..19589e4 100644 --- a/apps/control-station/src/workspaces/laboratory/CanonicalVegetationRerunReplay.tsx +++ b/apps/control-station/src/workspaces/laboratory/CanonicalVegetationRerunReplay.tsx @@ -90,7 +90,7 @@ export function CanonicalVegetationRerunReplay({ showPoints: spatialMode !== null, showTrajectory: spatialMode !== null, showGrid: spatialMode !== null, - pointSize: 2.2, + pointSize: 3.8, }), [spatialLayer, spatialMode]); const profile = launch ? recordedSessionRerunProfile({ sourceUrl: launch.sourceUrl, @@ -107,7 +107,7 @@ export function CanonicalVegetationRerunReplay({ initialPlaybackStartSeconds: review.timelineStartSeconds, view: mediaMode !== null ? "perception" : "spatial", viewResetGeneration, - followTrajectory: false, + followTrajectory: true, perceptionSourceUrl: `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}` + "/canonical-overlay.rrd", @@ -131,7 +131,7 @@ export function CanonicalVegetationRerunReplay({ aria-label="Слои камеры и видео" >