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 камеры"
@@ -42,6 +42,8 @@ let resolveRecordedBlueprintUrl;
let fetchRecordedBlueprintRrd;
let resolveRecordedPerceptionUrl;
let fetchRecordedPerceptionRrd;
let resolveRecordedPerceptionViewerSourceUrl;
let probeRecordedPerceptionViewerSource;
let resolveRecordedPointColorsUrl;
let fetchRecordedPointColorsRrd;
let recordedPointColorKey;
@@ -110,6 +112,8 @@ before(async () => {
fetchRecordedBlueprintRrd,
resolveRecordedPerceptionUrl,
fetchRecordedPerceptionRrd,
resolveRecordedPerceptionViewerSourceUrl,
probeRecordedPerceptionViewerSource,
resolveRecordedPointColorsUrl,
fetchRecordedPointColorsRrd,
recordedPointColorKey,
@@ -666,6 +670,41 @@ test("recorded perception fetch admits one complete same-origin RRD or no layer"
assert.equal(absent, null);
});
test("LAB perception sidecar is streamed by native Rerun from one generation-bound URL", async () => {
const endpoint =
`http://127.0.0.1:5174/api/v1/laboratory/vegetation-shadow/` +
`lab-v1-vegetation-shadow-${"a".repeat(64)}/canonical-overlay.rrd`;
const sourceUrl = resolveRecordedPerceptionViewerSourceUrl(
endpoint,
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
"b".repeat(64),
"http://127.0.0.1:5174",
);
assert.equal(
sourceUrl,
`${endpoint}?application_id=nodedc_mission_core_recorded` +
`&recording_id=recording-001&generation=${"b".repeat(64)}`,
);
const byteLength = await probeRecordedPerceptionViewerSource(sourceUrl, {
origin: "http://127.0.0.1:5174",
fetcher: async (input, init) => {
assert.equal(String(input), sourceUrl);
assert.equal(init.method, "GET");
assert.equal(new Headers(init.headers).get("Range"), "bytes=0-3");
return new Response(Uint8Array.from([0x52, 0x52, 0x46, 0x32]), {
status: 206,
headers: {
"Content-Type": "application/vnd.rerun.rrd",
"Content-Length": "4",
"Content-Range": "bytes 0-3/393203594",
},
});
},
});
assert.equal(byteLength, 393_203_594);
});
test("recorded replay creates an isolated source catalog without live device bindings", () => {
const sources = recordedObservationSources({
kind: "rerun-recording",
+2 -1
View File
@@ -93,6 +93,7 @@ class _RecordedBlueprintStream:
eye_contract = (follow_trajectory, plan_view)
update_eye_controls = self._eye_contract != eye_contract
blueprint = blueprint_factory(update_eye_controls)
make_active = self._sequence == 0
self._blueprint_recording.set_time(
"blueprint",
sequence=self._sequence,
@@ -102,7 +103,7 @@ class _RecordedBlueprintStream:
self._blueprint_recording.flush(timeout_sec=5.0)
bindings.send_blueprint(
self._blueprint_memory.storage,
True,
make_active,
False,
self._transport_recording.to_native(),
)
+47 -4
View File
@@ -12,10 +12,10 @@ import zipfile
from collections.abc import Callable
from functools import lru_cache
from pathlib import Path, PurePosixPath
from typing import Any, Final, Literal
from typing import Annotated, Any, Final, Literal
import numpy as np
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse, JSONResponse, Response
from PIL import Image
@@ -298,10 +298,11 @@ def _build_vegetation_lab_router(
},
)
@router.post("/{result_id}/canonical-overlay.rrd")
async def get_canonical_rerun_overlay(
async def canonical_rerun_overlay_response(
result_id: str,
request: CanonicalLabRerunRequest,
*,
expected_base_generation_sha256: str | None = None,
) -> FileResponse:
"""Project LAB-only evidence into the base recording's native clock."""
@@ -319,6 +320,11 @@ def _build_vegetation_lab_router(
if recording is None:
raise HTTPException(status_code=409, detail="Canonical spatial recording is not ready")
recording_path, generation_sha256 = recording
if (
expected_base_generation_sha256 is not None
and expected_base_generation_sha256 != generation_sha256
):
raise HTTPException(status_code=412, detail="Canonical recording generation changed")
try:
expected_recording_id = await run_in_threadpool(
canonical_recording_id,
@@ -353,6 +359,43 @@ def _build_vegetation_lab_router(
},
)
@router.post("/{result_id}/canonical-overlay.rrd")
async def get_canonical_rerun_overlay(
result_id: str,
request: CanonicalLabRerunRequest,
) -> FileResponse:
"""Resolve the sealed sidecar for bounded non-viewer consumers."""
return await canonical_rerun_overlay_response(result_id, request)
@router.get("/{result_id}/canonical-overlay.rrd")
async def stream_canonical_rerun_overlay(
result_id: str,
application_id: Annotated[
Literal["nodedc_mission_core_recorded"],
Query(),
],
recording_id: Annotated[
str,
Query(
min_length=1,
max_length=128,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$",
),
],
generation: Annotated[str, Query(pattern=r"^[a-f0-9]{64}$")],
) -> FileResponse:
"""Stream one immutable LAB sidecar through Rerun's native HTTP receiver."""
return await canonical_rerun_overlay_response(
result_id,
CanonicalLabRerunRequest(
application_id=application_id,
recording_id=recording_id,
),
expected_base_generation_sha256=generation,
)
@router.get("/{result_id}/timeline")
def get_canonical_route_timeline(result_id: str) -> dict[str, object]:
candidate = _resolve_candidate(root_provider, definition, result_id)
+3 -3
View File
@@ -594,9 +594,9 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
assert eye_control_updates == [True, False, True, False, True]
assert [activation[1:] for activation in activations] == [
(True, False),
(True, False),
(True, False),
(True, False),
(False, False),
(False, False),
(False, False),
(True, False),
]
assert activations[0][0] is activations[1][0]
+85
View File
@@ -17,6 +17,7 @@ from PIL import Image
import k1link.laboratory.vegetation_policy_review as policy_review_module
import k1link.laboratory.vegetation_policy_video as policy_video_module
import k1link.laboratory.vegetation_shadow_lab as vegetation_lab_module
import k1link.web.vegetation_shadow_lab_api as vegetation_api_module
from k1link.laboratory import LaboratoryEvidenceRegistry
from k1link.laboratory.canonical_rerun_overlay import (
CanonicalLabOverlayArtifact,
@@ -89,6 +90,90 @@ def test_canonical_overlay_memory_cache_rejects_same_size_tampering(
assert not _artifact_is_regular(artifact)
def test_canonical_overlay_get_is_generation_bound_and_range_streamable(
tmp_path: Path,
monkeypatch,
) -> None:
result_id = f"lab-v1-vegetation-shadow-{'a' * 64}"
result_root = tmp_path / result_id
result_root.mkdir()
base = tmp_path / "base.rrd"
base.write_bytes(b"RRF2-base")
overlay = tmp_path / "overlay.rrd"
overlay.write_bytes(b"RRF2-overlay")
generation = "b" * 64
recording_id = "recording-001"
artifact = CanonicalLabOverlayArtifact(
path=overlay,
byte_length=overlay.stat().st_size,
sha256=_sha256(overlay),
)
monkeypatch.setattr(
vegetation_api_module,
"_resolve_candidate",
lambda *_args, **_kwargs: result_root,
)
monkeypatch.setattr(
vegetation_api_module,
"_read_verified",
lambda *_args, **_kwargs: {},
)
monkeypatch.setattr(
vegetation_api_module,
"_full_route_context",
lambda *_args, **_kwargs: ({"session_id": "session-001"}, ()),
)
monkeypatch.setattr(
vegetation_api_module,
"canonical_recording_id",
lambda _path: recording_id,
)
monkeypatch.setattr(
vegetation_api_module,
"canonical_lab_overlay",
lambda *_args, **_kwargs: artifact,
)
ffmpeg = tmp_path / "ffmpeg"
ffmpeg.write_bytes(b"fixture")
ffmpeg.chmod(0o700)
app = FastAPI()
app.include_router(
build_vegetation_shadow_lab_router(
root_provider=lambda: tmp_path,
canonical_recording_provider=lambda _session_id: (base, generation),
jobs_root=tmp_path,
rerun_overlay_cache_root=tmp_path / "cache",
ffmpeg_path=ffmpeg,
)
)
client = TestClient(app)
endpoint = f"/api/v1/laboratory/vegetation-shadow/{result_id}/canonical-overlay.rrd"
response = client.get(
endpoint,
params={
"application_id": "nodedc_mission_core_recorded",
"recording_id": recording_id,
"generation": generation,
},
headers={"Range": "bytes=0-3"},
)
assert response.status_code == 206
assert response.content == b"RRF2"
assert response.headers["content-range"] == f"bytes 0-3/{artifact.byte_length}"
assert response.headers["etag"] == f'"{artifact.sha256}"'
assert response.headers["cache-control"].endswith("immutable")
stale = client.get(
endpoint,
params={
"application_id": "nodedc_mission_core_recorded",
"recording_id": recording_id,
"generation": "c" * 64,
},
)
assert stale.status_code == 412
def test_route_playback_chunk_descriptor_seals_only_requested_binary_window() -> None:
points = np.arange(18, dtype="<f4").reshape(6, 3)
descriptor = _canonical_route_playback_chunk_descriptor(