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 {
|
interface RecordedRerunIdentity {
|
||||||
applicationId: "nodedc_mission_core_recorded";
|
applicationId: "nodedc_mission_core_recorded";
|
||||||
recordingId: string;
|
recordingId: string;
|
||||||
@@ -557,6 +570,115 @@ export async function fetchRecordedPerceptionRrd(
|
|||||||
return payload;
|
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({
|
export function RerunViewport({
|
||||||
profile,
|
profile,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
@@ -617,7 +739,9 @@ export function RerunViewport({
|
|||||||
const uiBuildStaleRef = useRef(false);
|
const uiBuildStaleRef = useRef(false);
|
||||||
const blueprintChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
const blueprintChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||||
const perceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
const perceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||||
|
const perceptionReceiverRef = useRef<RerunNativeReceiver | null>(null);
|
||||||
const loadedPerceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
const loadedPerceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||||
|
const loadedNativePerceptionSourceRef = useRef<LoadedNativePerceptionSource | null>(null);
|
||||||
const appliedPointColorKeyRef = useRef<string | null>(null);
|
const appliedPointColorKeyRef = useRef<string | null>(null);
|
||||||
const recordedIdentityRef = useRef<RecordedRerunIdentity | null>(null);
|
const recordedIdentityRef = useRef<RecordedRerunIdentity | null>(null);
|
||||||
const blueprintSessionIdRef = useRef(crypto.randomUUID().replaceAll("-", ""));
|
const blueprintSessionIdRef = useRef(crypto.randomUUID().replaceAll("-", ""));
|
||||||
@@ -733,6 +857,7 @@ export function RerunViewport({
|
|||||||
const recordedAutoplay = createRecordedAutoplayGate();
|
const recordedAutoplay = createRecordedAutoplayGate();
|
||||||
let blueprintChannel: RerunBlueprintChannel | null = null;
|
let blueprintChannel: RerunBlueprintChannel | null = null;
|
||||||
let perceptionChannel: RerunBlueprintChannel | null = null;
|
let perceptionChannel: RerunBlueprintChannel | null = null;
|
||||||
|
let perceptionReceiver: RerunNativeReceiver | null = null;
|
||||||
const unsubscribers: Array<() => void> = [];
|
const unsubscribers: Array<() => void> = [];
|
||||||
const unsubscribeAll = () => {
|
const unsubscribeAll = () => {
|
||||||
while (unsubscribers.length > 0) {
|
while (unsubscribers.length > 0) {
|
||||||
@@ -989,6 +1114,9 @@ export function RerunViewport({
|
|||||||
if (perceptionChannelRef.current === perceptionChannel) {
|
if (perceptionChannelRef.current === perceptionChannel) {
|
||||||
perceptionChannelRef.current = null;
|
perceptionChannelRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (perceptionReceiverRef.current === perceptionReceiver) {
|
||||||
|
perceptionReceiverRef.current = null;
|
||||||
|
}
|
||||||
recordedIdentityRef.current = null;
|
recordedIdentityRef.current = null;
|
||||||
try {
|
try {
|
||||||
blueprintChannel?.channel.close();
|
blueprintChannel?.channel.close();
|
||||||
@@ -1000,6 +1128,15 @@ export function RerunViewport({
|
|||||||
} catch {
|
} catch {
|
||||||
// The viewer may already have closed all auxiliary channels.
|
// 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 {
|
try {
|
||||||
if (viewer.ready) viewer.close(resolvedSource);
|
if (viewer.ready) viewer.close(resolvedSource);
|
||||||
@@ -1363,9 +1500,19 @@ export function RerunViewport({
|
|||||||
setBlueprintChannelRevision((revision) => revision + 1);
|
setBlueprintChannelRevision((revision) => revision + 1);
|
||||||
}
|
}
|
||||||
if (recordedPerceptionUrl) {
|
if (recordedPerceptionUrl) {
|
||||||
|
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");
|
const channel = viewer.open_channel("missioncore/recorded-perception");
|
||||||
perceptionChannel = { endpointUrl: recordedPerceptionUrl, channel };
|
perceptionChannel = { endpointUrl: recordedPerceptionUrl, channel };
|
||||||
perceptionChannelRef.current = perceptionChannel;
|
perceptionChannelRef.current = perceptionChannel;
|
||||||
|
}
|
||||||
setPerceptionChannelRevision((revision) => revision + 1);
|
setPerceptionChannelRevision((revision) => revision + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1440,6 +1587,7 @@ export function RerunViewport({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadedPerceptionChannelRef.current = null;
|
loadedPerceptionChannelRef.current = null;
|
||||||
|
loadedNativePerceptionSourceRef.current = null;
|
||||||
}, [recordedPerceptionUrl]);
|
}, [recordedPerceptionUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1459,11 +1607,91 @@ export function RerunViewport({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const active = perceptionChannelRef.current;
|
|
||||||
const identity = recordedIdentityRef.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 (
|
if (
|
||||||
!active ||
|
!active ||
|
||||||
!identity ||
|
|
||||||
active.endpointUrl !== recordedPerceptionUrl ||
|
active.endpointUrl !== recordedPerceptionUrl ||
|
||||||
!active.channel.ready
|
!active.channel.ready
|
||||||
) return;
|
) return;
|
||||||
@@ -1604,6 +1832,7 @@ export function RerunViewport({
|
|||||||
}, [
|
}, [
|
||||||
onPerceptionLoadChange,
|
onPerceptionLoadChange,
|
||||||
perceptionChannelRevision,
|
perceptionChannelRevision,
|
||||||
|
recordedArtifact?.sha256,
|
||||||
recordedPerceptionLayers.enabled,
|
recordedPerceptionLayers.enabled,
|
||||||
recordedPerceptionRetryGeneration,
|
recordedPerceptionRetryGeneration,
|
||||||
recordedPerceptionUrl,
|
recordedPerceptionUrl,
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export function CanonicalRecordedLabReplay<
|
|||||||
value={mediaMode}
|
value={mediaMode}
|
||||||
items={[...mediaModes]}
|
items={[...mediaModes]}
|
||||||
label="Видео и камера"
|
label="Видео и камера"
|
||||||
|
size="dense"
|
||||||
onChange={onMediaModeChange}
|
onChange={onMediaModeChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,6 +150,7 @@ export function CanonicalRecordedLabReplay<
|
|||||||
value={spatialMode}
|
value={spatialMode}
|
||||||
items={[...spatialModes]}
|
items={[...spatialModes]}
|
||||||
label="3D и план"
|
label="3D и план"
|
||||||
|
size="dense"
|
||||||
onChange={onSpatialModeChange}
|
onChange={onSpatialModeChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function CanonicalVegetationRerunReplay({
|
|||||||
showPoints: spatialMode !== null,
|
showPoints: spatialMode !== null,
|
||||||
showTrajectory: spatialMode !== null,
|
showTrajectory: spatialMode !== null,
|
||||||
showGrid: spatialMode !== null,
|
showGrid: spatialMode !== null,
|
||||||
pointSize: 2.2,
|
pointSize: 3.8,
|
||||||
}), [spatialLayer, spatialMode]);
|
}), [spatialLayer, spatialMode]);
|
||||||
const profile = launch ? recordedSessionRerunProfile({
|
const profile = launch ? recordedSessionRerunProfile({
|
||||||
sourceUrl: launch.sourceUrl,
|
sourceUrl: launch.sourceUrl,
|
||||||
@@ -107,7 +107,7 @@ export function CanonicalVegetationRerunReplay({
|
|||||||
initialPlaybackStartSeconds: review.timelineStartSeconds,
|
initialPlaybackStartSeconds: review.timelineStartSeconds,
|
||||||
view: mediaMode !== null ? "perception" : "spatial",
|
view: mediaMode !== null ? "perception" : "spatial",
|
||||||
viewResetGeneration,
|
viewResetGeneration,
|
||||||
followTrajectory: false,
|
followTrajectory: true,
|
||||||
perceptionSourceUrl:
|
perceptionSourceUrl:
|
||||||
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}` +
|
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}` +
|
||||||
"/canonical-overlay.rrd",
|
"/canonical-overlay.rrd",
|
||||||
@@ -131,7 +131,7 @@ export function CanonicalVegetationRerunReplay({
|
|||||||
aria-label="Слои камеры и видео"
|
aria-label="Слои камеры и видео"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
size="compact"
|
size="dense"
|
||||||
shape="pill"
|
shape="pill"
|
||||||
variant={showSemantics ? "primary" : "secondary"}
|
variant={showSemantics ? "primary" : "secondary"}
|
||||||
aria-pressed={showSemantics}
|
aria-pressed={showSemantics}
|
||||||
@@ -146,6 +146,7 @@ export function CanonicalVegetationRerunReplay({
|
|||||||
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
|
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
|
||||||
]}
|
]}
|
||||||
label="Источник семантики"
|
label="Источник семантики"
|
||||||
|
size="dense"
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setSemanticLayer(value);
|
setSemanticLayer(value);
|
||||||
setShowSemantics(true);
|
setShowSemantics(true);
|
||||||
@@ -168,13 +169,14 @@ export function CanonicalVegetationRerunReplay({
|
|||||||
{ value: "semantic", label: "SEMANTICS", disabled: true },
|
{ value: "semantic", label: "SEMANTICS", disabled: true },
|
||||||
]}
|
]}
|
||||||
label="Пространственные слои"
|
label="Пространственные слои"
|
||||||
|
size="dense"
|
||||||
onChange={setSpatialLayer}
|
onChange={setSpatialLayer}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const resetSpatialView = (
|
const resetSpatialView = (
|
||||||
<Button
|
<Button
|
||||||
size="compact"
|
size="dense"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
icon={<Icon name="refresh" size={14} />}
|
icon={<Icon name="refresh" size={14} />}
|
||||||
aria-label="Сбросить положение 3D камеры"
|
aria-label="Сбросить положение 3D камеры"
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ let resolveRecordedBlueprintUrl;
|
|||||||
let fetchRecordedBlueprintRrd;
|
let fetchRecordedBlueprintRrd;
|
||||||
let resolveRecordedPerceptionUrl;
|
let resolveRecordedPerceptionUrl;
|
||||||
let fetchRecordedPerceptionRrd;
|
let fetchRecordedPerceptionRrd;
|
||||||
|
let resolveRecordedPerceptionViewerSourceUrl;
|
||||||
|
let probeRecordedPerceptionViewerSource;
|
||||||
let resolveRecordedPointColorsUrl;
|
let resolveRecordedPointColorsUrl;
|
||||||
let fetchRecordedPointColorsRrd;
|
let fetchRecordedPointColorsRrd;
|
||||||
let recordedPointColorKey;
|
let recordedPointColorKey;
|
||||||
@@ -110,6 +112,8 @@ before(async () => {
|
|||||||
fetchRecordedBlueprintRrd,
|
fetchRecordedBlueprintRrd,
|
||||||
resolveRecordedPerceptionUrl,
|
resolveRecordedPerceptionUrl,
|
||||||
fetchRecordedPerceptionRrd,
|
fetchRecordedPerceptionRrd,
|
||||||
|
resolveRecordedPerceptionViewerSourceUrl,
|
||||||
|
probeRecordedPerceptionViewerSource,
|
||||||
resolveRecordedPointColorsUrl,
|
resolveRecordedPointColorsUrl,
|
||||||
fetchRecordedPointColorsRrd,
|
fetchRecordedPointColorsRrd,
|
||||||
recordedPointColorKey,
|
recordedPointColorKey,
|
||||||
@@ -666,6 +670,41 @@ test("recorded perception fetch admits one complete same-origin RRD or no layer"
|
|||||||
assert.equal(absent, null);
|
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", () => {
|
test("recorded replay creates an isolated source catalog without live device bindings", () => {
|
||||||
const sources = recordedObservationSources({
|
const sources = recordedObservationSources({
|
||||||
kind: "rerun-recording",
|
kind: "rerun-recording",
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ class _RecordedBlueprintStream:
|
|||||||
eye_contract = (follow_trajectory, plan_view)
|
eye_contract = (follow_trajectory, plan_view)
|
||||||
update_eye_controls = self._eye_contract != eye_contract
|
update_eye_controls = self._eye_contract != eye_contract
|
||||||
blueprint = blueprint_factory(update_eye_controls)
|
blueprint = blueprint_factory(update_eye_controls)
|
||||||
|
make_active = self._sequence == 0
|
||||||
self._blueprint_recording.set_time(
|
self._blueprint_recording.set_time(
|
||||||
"blueprint",
|
"blueprint",
|
||||||
sequence=self._sequence,
|
sequence=self._sequence,
|
||||||
@@ -102,7 +103,7 @@ class _RecordedBlueprintStream:
|
|||||||
self._blueprint_recording.flush(timeout_sec=5.0)
|
self._blueprint_recording.flush(timeout_sec=5.0)
|
||||||
bindings.send_blueprint(
|
bindings.send_blueprint(
|
||||||
self._blueprint_memory.storage,
|
self._blueprint_memory.storage,
|
||||||
True,
|
make_active,
|
||||||
False,
|
False,
|
||||||
self._transport_recording.to_native(),
|
self._transport_recording.to_native(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import zipfile
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import Any, Final, Literal
|
from typing import Annotated, Any, Final, Literal
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -298,10 +298,11 @@ def _build_vegetation_lab_router(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/{result_id}/canonical-overlay.rrd")
|
async def canonical_rerun_overlay_response(
|
||||||
async def get_canonical_rerun_overlay(
|
|
||||||
result_id: str,
|
result_id: str,
|
||||||
request: CanonicalLabRerunRequest,
|
request: CanonicalLabRerunRequest,
|
||||||
|
*,
|
||||||
|
expected_base_generation_sha256: str | None = None,
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
"""Project LAB-only evidence into the base recording's native clock."""
|
"""Project LAB-only evidence into the base recording's native clock."""
|
||||||
|
|
||||||
@@ -319,6 +320,11 @@ def _build_vegetation_lab_router(
|
|||||||
if recording is None:
|
if recording is None:
|
||||||
raise HTTPException(status_code=409, detail="Canonical spatial recording is not ready")
|
raise HTTPException(status_code=409, detail="Canonical spatial recording is not ready")
|
||||||
recording_path, generation_sha256 = recording
|
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:
|
try:
|
||||||
expected_recording_id = await run_in_threadpool(
|
expected_recording_id = await run_in_threadpool(
|
||||||
canonical_recording_id,
|
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")
|
@router.get("/{result_id}/timeline")
|
||||||
def get_canonical_route_timeline(result_id: str) -> dict[str, object]:
|
def get_canonical_route_timeline(result_id: str) -> dict[str, object]:
|
||||||
candidate = _resolve_candidate(root_provider, definition, result_id)
|
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||||
|
|||||||
@@ -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 eye_control_updates == [True, False, True, False, True]
|
||||||
assert [activation[1:] for activation in activations] == [
|
assert [activation[1:] for activation in activations] == [
|
||||||
(True, False),
|
(True, False),
|
||||||
(True, False),
|
(False, False),
|
||||||
(True, False),
|
(False, False),
|
||||||
(True, False),
|
(False, False),
|
||||||
(True, False),
|
(True, False),
|
||||||
]
|
]
|
||||||
assert activations[0][0] is activations[1][0]
|
assert activations[0][0] is activations[1][0]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from PIL import Image
|
|||||||
import k1link.laboratory.vegetation_policy_review as policy_review_module
|
import k1link.laboratory.vegetation_policy_review as policy_review_module
|
||||||
import k1link.laboratory.vegetation_policy_video as policy_video_module
|
import k1link.laboratory.vegetation_policy_video as policy_video_module
|
||||||
import k1link.laboratory.vegetation_shadow_lab as vegetation_lab_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 import LaboratoryEvidenceRegistry
|
||||||
from k1link.laboratory.canonical_rerun_overlay import (
|
from k1link.laboratory.canonical_rerun_overlay import (
|
||||||
CanonicalLabOverlayArtifact,
|
CanonicalLabOverlayArtifact,
|
||||||
@@ -89,6 +90,90 @@ def test_canonical_overlay_memory_cache_rejects_same_size_tampering(
|
|||||||
assert not _artifact_is_regular(artifact)
|
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:
|
def test_route_playback_chunk_descriptor_seals_only_requested_binary_window() -> None:
|
||||||
points = np.arange(18, dtype="<f4").reshape(6, 3)
|
points = np.arange(18, dtype="<f4").reshape(6, 3)
|
||||||
descriptor = _canonical_route_playback_chunk_descriptor(
|
descriptor = _canonical_route_playback_chunk_descriptor(
|
||||||
|
|||||||
Reference in New Issue
Block a user