fix(lab): стабилизировать нативный RAV004 replay

This commit is contained in:
DCCONSTRUCTIONS
2026-08-30 13:28:33 +03:00
parent f5ee42751d
commit 07453142c2
8 changed files with 418 additions and 17 deletions
@@ -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<number> {
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<RerunBlueprintChannel | null>(null);
const perceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
const perceptionReceiverRef = useRef<RerunNativeReceiver | null>(null);
const loadedPerceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
const loadedNativePerceptionSourceRef = useRef<LoadedNativePerceptionSource | null>(null);
const appliedPointColorKeyRef = useRef<string | null>(null);
const recordedIdentityRef = useRef<RecordedRerunIdentity | null>(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,
@@ -139,6 +139,7 @@ export function CanonicalRecordedLabReplay<
value={mediaMode}
items={[...mediaModes]}
label="Видео и камера"
size="dense"
onChange={onMediaModeChange}
/>
</div>
@@ -149,6 +150,7 @@ export function CanonicalRecordedLabReplay<
value={spatialMode}
items={[...spatialModes]}
label="3D и план"
size="dense"
onChange={onSpatialModeChange}
/>
</div>
@@ -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="Слои камеры и видео"
>
<Button
size="compact"
size="dense"
shape="pill"
variant={showSemantics ? "primary" : "secondary"}
aria-pressed={showSemantics}
@@ -146,6 +146,7 @@ export function CanonicalVegetationRerunReplay({
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
]}
label="Источник семантики"
size="dense"
onChange={(value) => {
setSemanticLayer(value);
setShowSemantics(true);
@@ -168,13 +169,14 @@ export function CanonicalVegetationRerunReplay({
{ value: "semantic", label: "SEMANTICS", disabled: true },
]}
label="Пространственные слои"
size="dense"
onChange={setSpatialLayer}
/>
</div>
);
const resetSpatialView = (
<Button
size="compact"
size="dense"
variant="ghost"
icon={<Icon name="refresh" size={14} />}
aria-label="Сбросить положение 3D камеры"