fix(viewer): gate recorded perception readiness
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user