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" />