fix(viewer): gate recorded perception readiness

This commit is contained in:
DCCONSTRUCTIONS
2026-07-23 12:27:48 +03:00
parent ee15160862
commit 9f712bf353
6 changed files with 347 additions and 40 deletions
@@ -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<Uint8Array | null> {
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<HTMLDivElement>(null);
const [status, setStatus] = useState<RerunViewportStatus>(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" ? (
<div
className="rerun-viewport__camera-lock"
aria-hidden="true"
title="Операторское видео зафиксировано"
/>
) : null}
{presentationStatus === "loading" ? (
<div className="rerun-viewport__notice" role="status">
<span className="busy-indicator" aria-hidden="true" />
@@ -39,6 +39,39 @@
border-radius: 999px;
}
.spatial-toolbar__perception-status,
.spatial-toolbar__perception-retry {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 0.4rem;
border: 1px solid rgb(255 255 255 / 0.07);
border-radius: 999px;
background: rgb(255 255 255 / 0.035);
padding: 0.42rem 0.62rem;
color: var(--nodedc-text-muted);
font: inherit;
font-size: 0.58rem;
white-space: nowrap;
}
.spatial-toolbar__perception-status .busy-indicator {
width: 0.72rem;
height: 0.72rem;
flex-basis: 0.72rem;
}
.spatial-toolbar__perception-retry {
color: var(--nodedc-text-primary);
cursor: pointer;
}
.spatial-toolbar__perception-retry:hover,
.spatial-toolbar__perception-retry:focus-visible {
background: rgb(255 255 255 / 0.09);
outline: none;
}
.spatial-viewport-shell {
position: relative;
min-width: 0;
@@ -95,6 +128,17 @@
height: 100% !important;
}
.rerun-viewport__camera-lock {
position: absolute;
z-index: 2;
top: 0;
bottom: 0;
left: 0;
width: calc(46% - 2px);
cursor: default;
touch-action: none;
}
.rerun-viewport:is([data-status="loading"], [data-status="error"])
.rerun-viewport__canvas {
visibility: hidden;
@@ -45,6 +45,7 @@ import {
type RerunPlaybackState,
type RerunSelection,
type RerunViewportStatus,
type RecordedPerceptionLoadState,
} from "../components/RerunViewport";
import {
capabilityStatusLabel,
@@ -307,9 +308,14 @@ function SpatialWorkspace({
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
const [recordedViewResetGeneration, setRecordedViewResetGeneration] = useState<0 | 1>(0);
const [perceptionAvailability, setPerceptionAvailability] = useState<
"unknown" | "available" | "unavailable"
>("unknown");
const [perceptionLoad, setPerceptionLoad] = useState<RecordedPerceptionLoadState>({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
const [perceptionRetryGeneration, setPerceptionRetryGeneration] = useState(0);
const [showDetections2d, setShowDetections2d] = useState(false);
const [showSegmentation, setShowSegmentation] = useState(false);
const [showCuboids3d, setShowCuboids3d] = useState(false);
@@ -335,7 +341,9 @@ function SpatialWorkspace({
const mediaSources = observationSources.filter(
(source) => source.capabilities.overlay && source.modality !== "point-cloud",
);
const recordedPerceptionSupported = recordedSource && perceptionAvailability !== "unavailable";
const recordedPerceptionSupported =
recordedSource && perceptionLoad.phase !== "unavailable";
const recordedPerceptionReady = recordedSource && perceptionLoad.phase === "ready";
const recordedPerceptionEnabled =
showDetections2d || showSegmentation || showCuboids3d;
// The native recorded camera remains the authoritative original. Only 2D
@@ -421,9 +429,9 @@ function SpatialWorkspace({
(next: RerunPlaybackController | null) => setPlaybackController(next),
[],
);
const onPerceptionAvailabilityChange = useCallback((available: boolean) => {
setPerceptionAvailability(available ? "available" : "unavailable");
if (!available) {
const onPerceptionLoadChange = useCallback((next: RecordedPerceptionLoadState) => {
setPerceptionLoad(next);
if (next.phase === "unavailable" || next.phase === "error") {
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
@@ -431,12 +439,19 @@ function SpatialWorkspace({
}, []);
useEffect(() => {
setPerceptionAvailability("unknown");
setPerceptionLoad({
phase: recordedSource ? "loading" : "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: recordedSource ? "Ожидаем канал AI-слоёв." : "",
});
setPerceptionRetryGeneration(0);
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
setRecordedViewResetGeneration(0);
}, [sourceUrl]);
}, [recordedSource, sourceUrl]);
useEffect(() => {
if (pointCloudVisible && sourceUrl.trim()) return;
@@ -445,7 +460,13 @@ function SpatialWorkspace({
setSelection(null);
setPlaybackState(null);
setPlaybackController(null);
setPerceptionAvailability("unknown");
setPerceptionLoad({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
@@ -507,6 +528,7 @@ function SpatialWorkspace({
variant={detections2dActive ? "primary" : "secondary"}
icon={<Icon name="target" />}
aria-pressed={detections2dActive}
disabled={recordedSource && !recordedPerceptionReady}
onClick={() => recordedSource
? setShowDetections2d((current) => !current)
: onLivePerceptionLayersChange({
@@ -521,6 +543,7 @@ function SpatialWorkspace({
variant={segmentationActive ? "primary" : "secondary"}
icon={<Icon name="image" />}
aria-pressed={segmentationActive}
disabled={recordedSource && !recordedPerceptionReady}
onClick={() => recordedSource
? setShowSegmentation((current) => !current)
: onLivePerceptionLayersChange({
@@ -535,6 +558,7 @@ function SpatialWorkspace({
variant={cuboids3dActive ? "primary" : "secondary"}
icon={<Icon name="apps" />}
aria-pressed={cuboids3dActive}
disabled={recordedSource && !recordedPerceptionReady}
onClick={() => recordedSource
? setShowCuboids3d((current) => !current)
: onLivePerceptionLayersChange({
@@ -546,6 +570,22 @@ function SpatialWorkspace({
</Button>
</div>
) : null}
{recordedSource && perceptionLoad.phase === "loading" ? (
<div className="spatial-toolbar__perception-status" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>{perceptionLoad.message || "Готовим AI-слои."}</span>
</div>
) : null}
{recordedSource && perceptionLoad.phase === "error" ? (
<button
type="button"
className="spatial-toolbar__perception-retry"
onClick={() => setPerceptionRetryGeneration((generation) => generation + 1)}
>
<Icon name="refresh" size={14} />
Повторить AI
</button>
) : null}
{recordedSource && presentedViewerStatus === "ready" ? (
<Button
size="compact"
@@ -591,12 +631,17 @@ function SpatialWorkspace({
sceneSettings={sceneSettings}
recordedViewResetGeneration={recordedViewResetGeneration}
recordedPerceptionLayers={{
enabled: recordedPerceptionSupported && recordedPerceptionEnabled,
enabled:
recordedPerceptionSupported &&
recordedPerceptionReady &&
recordedPerceptionEnabled,
detections2d: showDetections2d,
segmentation: showSegmentation,
cuboids3d: showCuboids3d,
}}
onPerceptionAvailabilityChange={onPerceptionAvailabilityChange}
recordedPerceptionRetryGeneration={perceptionRetryGeneration}
lockPerceptionCameraInteraction={unifiedPerception}
onPerceptionLoadChange={onPerceptionLoadChange}
onStatusChange={onStatusChange}
onSelectionChange={onSelectionChange}
onPlaybackChange={onPlaybackChange}
@@ -375,11 +375,15 @@ test("recorded perception fetch admits one complete same-origin RRD or no layer"
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/perception.rrd",
);
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
const progress = [];
const result = await fetchRecordedPerceptionRrd(
endpoint,
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
{
origin: "http://127.0.0.1:5174",
onProgress: (receivedBytes, totalBytes) => {
progress.push([receivedBytes, totalBytes]);
},
fetcher: async (_input, init) => {
assert.deepEqual(JSON.parse(String(init.body)), {
application_id: "nodedc_mission_core_recorded",
@@ -396,6 +400,8 @@ test("recorded perception fetch admits one complete same-origin RRD or no layer"
},
);
assert.deepEqual([...result], [...payload]);
assert.deepEqual(progress[0], [0, payload.byteLength]);
assert.deepEqual(progress.at(-1), [payload.byteLength, payload.byteLength]);
const absent = await fetchRecordedPerceptionRrd(
endpoint,