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" />
|
||||
|
||||
Reference in New Issue
Block a user