fix(lab): стабилизировать нативный RAV004 replay
This commit is contained in:
@@ -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 камеры"
|
||||
|
||||
Reference in New Issue
Block a user