feat(perception): project recorded results into Rerun
This commit is contained in:
@@ -4,6 +4,7 @@ import type { SceneSettings } from "../sceneSettings";
|
||||
import type { RecordedAdmissionPhase } from "../core/observation/recordedSessionAdmission";
|
||||
|
||||
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
|
||||
export type RecordedRerunView = "spatial" | "perception" | "metrics";
|
||||
|
||||
export interface RerunSelection {
|
||||
entityPath: string;
|
||||
@@ -55,6 +56,8 @@ export interface RerunViewportProps {
|
||||
| "palette"
|
||||
| "customColor"
|
||||
>;
|
||||
recordedView?: RecordedRerunView;
|
||||
onPerceptionAvailabilityChange?: (available: boolean) => void;
|
||||
}
|
||||
|
||||
export interface RecordedRrdArtifactDescriptor {
|
||||
@@ -80,7 +83,9 @@ interface RecordedRerunIdentity {
|
||||
|
||||
const RECORDED_RRD_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/recording\.rrd$/;
|
||||
const RECORDED_BLUEPRINT_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/blueprint\.rrd$/;
|
||||
const RECORDED_PERCEPTION_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/perception\.rrd$/;
|
||||
const MAX_BLUEPRINT_BYTES = 1_048_576;
|
||||
const MAX_PERCEPTION_BYTES = 512 * 1024 * 1024;
|
||||
const BUFFER_END_TOLERANCE_NS = 1_000_000;
|
||||
const RECORDED_OPEN_MIN_TIMEOUT_MS = 120_000;
|
||||
const RECORDED_OPEN_MAX_TIMEOUT_MS = 1_800_000;
|
||||
@@ -405,6 +410,17 @@ export function resolveRecordedBlueprintUrl(sourceUrl: string, origin: string):
|
||||
return endpoint.origin === base.origin ? endpoint.href : null;
|
||||
}
|
||||
|
||||
export function resolveRecordedPerceptionUrl(sourceUrl: string, origin: string): string | null {
|
||||
const normalized = sourceUrl.trim();
|
||||
if (!RECORDED_RRD_PATH.test(normalized)) return null;
|
||||
const base = new URL(origin);
|
||||
const endpoint = new URL(
|
||||
normalized.replace(/\/recording\.rrd$/, "/perception.rrd"),
|
||||
`${base.origin}/`,
|
||||
);
|
||||
return endpoint.origin === base.origin ? endpoint.href : null;
|
||||
}
|
||||
|
||||
export async function fetchRecordedBlueprintRrd(
|
||||
endpointUrl: string,
|
||||
settings: Pick<
|
||||
@@ -421,10 +437,12 @@ export async function fetchRecordedBlueprintRrd(
|
||||
{
|
||||
origin,
|
||||
signal,
|
||||
activeView = "spatial",
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
origin: string;
|
||||
signal?: AbortSignal;
|
||||
activeView?: RecordedRerunView;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
},
|
||||
): Promise<Uint8Array> {
|
||||
@@ -443,6 +461,7 @@ export async function fetchRecordedBlueprintRrd(
|
||||
settings.pointSize > 32 ||
|
||||
!["turbo", "viridis", "plasma", "grayscale", "custom"].includes(settings.palette) ||
|
||||
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
|
||||
!["spatial", "perception", "metrics"].includes(activeView) ||
|
||||
identity.applicationId !== "nodedc_mission_core_recorded" ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId)
|
||||
) {
|
||||
@@ -465,6 +484,7 @@ export async function fetchRecordedBlueprintRrd(
|
||||
point_size: settings.pointSize,
|
||||
palette: settings.palette,
|
||||
custom_color: settings.customColor,
|
||||
active_view: activeView,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
@@ -491,6 +511,69 @@ export async function fetchRecordedBlueprintRrd(
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchRecordedPerceptionRrd(
|
||||
endpointUrl: string,
|
||||
identity: RecordedRerunIdentity,
|
||||
{
|
||||
origin,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
origin: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
},
|
||||
): Promise<Uint8Array | null> {
|
||||
const base = new URL(origin);
|
||||
const endpoint = new URL(endpointUrl, base.origin);
|
||||
if (
|
||||
endpoint.origin !== base.origin ||
|
||||
endpoint.search ||
|
||||
endpoint.hash ||
|
||||
!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)
|
||||
) {
|
||||
throw new Error("Unsafe recorded perception request");
|
||||
}
|
||||
const response = await fetcher(endpoint.href, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Accept: "application/vnd.rerun.rrd",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
application_id: identity.applicationId,
|
||||
recording_id: identity.recordingId,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
if (response.status === 204) return null;
|
||||
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
|
||||
const declaredLength = Number(response.headers.get("Content-Length"));
|
||||
if (
|
||||
!response.ok ||
|
||||
contentType !== "application/vnd.rerun.rrd" ||
|
||||
!Number.isSafeInteger(declaredLength) ||
|
||||
declaredLength < 4 ||
|
||||
declaredLength > MAX_PERCEPTION_BYTES
|
||||
) {
|
||||
throw new Error("Invalid recorded perception response");
|
||||
}
|
||||
const payload = new Uint8Array(await response.arrayBuffer());
|
||||
if (
|
||||
payload.byteLength !== declaredLength ||
|
||||
payload[0] !== 0x52 ||
|
||||
payload[1] !== 0x52 ||
|
||||
payload[2] !== 0x46 ||
|
||||
payload[3] !== 0x32
|
||||
) {
|
||||
throw new Error("Invalid recorded perception RRD");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function RerunViewport({
|
||||
sourceUrl,
|
||||
recordedArtifact = null,
|
||||
@@ -504,19 +587,26 @@ export function RerunViewport({
|
||||
onPlaybackChange,
|
||||
onPlaybackControllerChange,
|
||||
sceneSettings,
|
||||
recordedView = "spatial",
|
||||
onPerceptionAvailabilityChange,
|
||||
}: RerunViewportProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [recordingBufferProgress, setRecordingBufferProgress] = useState<number | null>(null);
|
||||
const [retryNonce, setRetryNonce] = useState(0);
|
||||
const blueprintChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const perceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const recordedIdentityRef = useRef<RecordedRerunIdentity | null>(null);
|
||||
const presentationGateRef = useRef(presentationGate);
|
||||
presentationGateRef.current = presentationGate;
|
||||
const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0);
|
||||
const [perceptionChannelRevision, setPerceptionChannelRevision] = useState(0);
|
||||
const recordedBlueprintUrl = sourceUrl
|
||||
? resolveRecordedBlueprintUrl(sourceUrl, window.location.origin)
|
||||
: null;
|
||||
const recordedPerceptionUrl = sourceUrl
|
||||
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
|
||||
: null;
|
||||
const presentationStatus = rerunPresentationStatus(
|
||||
status,
|
||||
presentationGate,
|
||||
@@ -580,6 +670,7 @@ export function RerunViewport({
|
||||
let playbackState: RerunPlaybackState | null = null;
|
||||
const recordedAutoplay = createRecordedAutoplayGate();
|
||||
let blueprintChannel: RerunBlueprintChannel | null = null;
|
||||
let perceptionChannel: RerunBlueprintChannel | null = null;
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
const unsubscribeAll = () => {
|
||||
while (unsubscribers.length > 0) {
|
||||
@@ -665,12 +756,20 @@ export function RerunViewport({
|
||||
if (blueprintChannelRef.current === blueprintChannel) {
|
||||
blueprintChannelRef.current = null;
|
||||
}
|
||||
if (perceptionChannelRef.current === perceptionChannel) {
|
||||
perceptionChannelRef.current = null;
|
||||
}
|
||||
recordedIdentityRef.current = null;
|
||||
try {
|
||||
blueprintChannel?.channel.close();
|
||||
} catch {
|
||||
// The viewer may already have closed all auxiliary channels.
|
||||
}
|
||||
try {
|
||||
perceptionChannel?.channel.close();
|
||||
} catch {
|
||||
// The viewer may already have closed all auxiliary channels.
|
||||
}
|
||||
try {
|
||||
if (viewer.ready) viewer.close(resolvedSource);
|
||||
} catch {
|
||||
@@ -700,7 +799,11 @@ export function RerunViewport({
|
||||
|
||||
unsubscribers.push(
|
||||
viewer.on("recording_open", (event) => {
|
||||
if (disposed || recordingOpened) return;
|
||||
if (
|
||||
disposed ||
|
||||
recordingOpened ||
|
||||
(isRecordedSource && event.application_id !== "nodedc_mission_core_recorded")
|
||||
) return;
|
||||
recordingOpened = true;
|
||||
if (
|
||||
recordedBlueprintUrl &&
|
||||
@@ -717,6 +820,7 @@ export function RerunViewport({
|
||||
recordingId: event.recording_id,
|
||||
};
|
||||
setBlueprintChannelRevision((revision) => revision + 1);
|
||||
setPerceptionChannelRevision((revision) => revision + 1);
|
||||
}
|
||||
}
|
||||
if (!isRecordedSource) clearLiveRecordingOpenTimer();
|
||||
@@ -961,6 +1065,12 @@ export function RerunViewport({
|
||||
blueprintChannelRef.current = blueprintChannel;
|
||||
setBlueprintChannelRevision((revision) => revision + 1);
|
||||
}
|
||||
if (recordedPerceptionUrl) {
|
||||
const channel = viewer.open_channel("missioncore/recorded-perception");
|
||||
perceptionChannel = { endpointUrl: recordedPerceptionUrl, channel };
|
||||
perceptionChannelRef.current = perceptionChannel;
|
||||
setPerceptionChannelRevision((revision) => revision + 1);
|
||||
}
|
||||
|
||||
if (!recordingOpened && !isRecordedSource) {
|
||||
recordingOpenTimer = window.setTimeout(() => {
|
||||
@@ -1012,6 +1122,50 @@ export function RerunViewport({
|
||||
sourceUrl,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onPerceptionAvailabilityChange?.(false);
|
||||
}, [onPerceptionAvailabilityChange, recordedPerceptionUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordedPerceptionUrl) return;
|
||||
const active = perceptionChannelRef.current;
|
||||
const identity = recordedIdentityRef.current;
|
||||
if (
|
||||
!active ||
|
||||
!identity ||
|
||||
active.endpointUrl !== recordedPerceptionUrl ||
|
||||
!active.channel.ready
|
||||
) return;
|
||||
const abort = new AbortController();
|
||||
void fetchRecordedPerceptionRrd(recordedPerceptionUrl, identity, {
|
||||
origin: window.location.origin,
|
||||
signal: abort.signal,
|
||||
}).then((payload) => {
|
||||
if (payload === null) {
|
||||
onPerceptionAvailabilityChange?.(false);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
abort.signal.aborted ||
|
||||
perceptionChannelRef.current !== active ||
|
||||
recordedIdentityRef.current !== identity ||
|
||||
!active.channel.ready
|
||||
) {
|
||||
return;
|
||||
}
|
||||
active.channel.send_rrd(payload);
|
||||
onPerceptionAvailabilityChange?.(true);
|
||||
}).catch(() => {
|
||||
onPerceptionAvailabilityChange?.(false);
|
||||
// The base recording remains available when no admitted perception layer exists.
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [
|
||||
onPerceptionAvailabilityChange,
|
||||
perceptionChannelRevision,
|
||||
recordedPerceptionUrl,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordedBlueprintUrl || !sceneSettings) return;
|
||||
const active = blueprintChannelRef.current;
|
||||
@@ -1026,6 +1180,7 @@ export function RerunViewport({
|
||||
void fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, {
|
||||
origin: window.location.origin,
|
||||
signal: abort.signal,
|
||||
activeView: recordedView,
|
||||
}).then((payload) => {
|
||||
if (
|
||||
abort.signal.aborted ||
|
||||
@@ -1044,6 +1199,7 @@ export function RerunViewport({
|
||||
}, [
|
||||
blueprintChannelRevision,
|
||||
recordedBlueprintUrl,
|
||||
recordedView,
|
||||
sceneSettings?.accumulationSeconds,
|
||||
sceneSettings?.customColor,
|
||||
sceneSettings?.palette,
|
||||
|
||||
Reference in New Issue
Block a user