feat(viewer): add recorded point colors and trajectory follow

This commit is contained in:
DCCONSTRUCTIONS
2026-07-23 13:33:08 +03:00
parent 9f712bf353
commit ecd95a22f0
16 changed files with 1218 additions and 36 deletions
@@ -20,6 +20,11 @@ export interface RecordedPerceptionLoadState {
message: string;
}
export type RecordedPointColorLoadState = Pick<
RecordedPerceptionLoadState,
"phase" | "receivedBytes" | "totalBytes" | "progress" | "message"
>;
export interface RecordedPerceptionLayers {
enabled: boolean;
detections2d: boolean;
@@ -75,15 +80,18 @@ export interface RerunViewportProps {
| "showPoints"
| "showTrajectory"
| "pointSize"
| "colorMode"
| "palette"
| "customColor"
>;
recordedView?: RecordedRerunView;
recordedViewResetGeneration?: 0 | 1;
recordedFollowTrajectory?: boolean;
recordedPerceptionLayers?: RecordedPerceptionLayers;
recordedPerceptionRetryGeneration?: number;
lockPerceptionCameraInteraction?: boolean;
onPerceptionLoadChange?: (state: RecordedPerceptionLoadState) => void;
onPointColorLoadChange?: (state: RecordedPointColorLoadState) => void;
}
export interface RecordedRrdArtifactDescriptor {
@@ -110,6 +118,7 @@ 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 RECORDED_POINT_COLORS_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/point-colors\.rrd$/;
const MAX_BLUEPRINT_BYTES = 1_048_576;
const MAX_PERCEPTION_BYTES = 512 * 1024 * 1024;
const BUFFER_END_TOLERANCE_NS = 1_000_000;
@@ -117,6 +126,16 @@ const RECORDED_OPEN_MIN_TIMEOUT_MS = 120_000;
const RECORDED_OPEN_MAX_TIMEOUT_MS = 1_800_000;
const RECORDED_OPEN_GRACE_MS = 30_000;
const RECORDED_OPEN_MIN_BYTES_PER_SECOND = 2 * 1024 * 1024;
const RECORDED_BASE_POINT_COLOR_KEY = "intensity|turbo|-";
export function recordedPointColorKey(
settings: Pick<SceneSettings, "colorMode" | "palette" | "customColor">,
): string {
const custom = settings.palette === "custom" || settings.colorMode === "class"
? settings.customColor.toLowerCase()
: "-";
return `${settings.colorMode}|${settings.palette}|${custom}`;
}
export function recordedOpenWatchdogTimeoutMs(byteLength: number): number {
if (!Number.isSafeInteger(byteLength) || byteLength < 4) {
@@ -459,6 +478,84 @@ export function resolveRecordedPerceptionUrl(sourceUrl: string, origin: string):
return endpoint.origin === base.origin ? endpoint.href : null;
}
export function resolveRecordedPointColorsUrl(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$/, "/point-colors.rrd"),
`${base.origin}/`,
);
return endpoint.origin === base.origin ? endpoint.href : null;
}
export async function fetchRecordedPointColorsRrd(
endpointUrl: string,
settings: Pick<SceneSettings, "colorMode" | "palette" | "customColor">,
identity: RecordedRerunIdentity,
{
origin,
signal,
fetcher = globalThis.fetch,
}: {
origin: string;
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
},
): Promise<Uint8Array> {
const base = new URL(origin);
const endpoint = new URL(endpointUrl, base.origin);
if (
endpoint.origin !== base.origin ||
endpoint.search ||
endpoint.hash ||
!RECORDED_POINT_COLORS_PATH.test(endpoint.pathname) ||
!["intensity", "height", "distance", "rgb", "class"].includes(settings.colorMode) ||
!["turbo", "viridis", "plasma", "grayscale", "custom"].includes(settings.palette) ||
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
identity.applicationId !== "nodedc_mission_core_recorded" ||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId)
) {
throw new Error("Unsafe recorded point-color 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,
color_mode: settings.colorMode,
palette: settings.palette,
custom_color: settings.customColor,
}),
signal,
});
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.isFinite(declaredLength) && declaredLength < 4)
) {
throw new Error("Invalid recorded point-color response");
}
const payload = new Uint8Array(await response.arrayBuffer());
if (
payload.byteLength < 4 ||
payload[0] !== 0x52 ||
payload[1] !== 0x52 ||
payload[2] !== 0x46 ||
payload[3] !== 0x32
) {
throw new Error("Invalid recorded point-color RRD");
}
return payload;
}
export async function fetchRecordedBlueprintRrd(
endpointUrl: string,
settings: Pick<
@@ -468,6 +565,7 @@ export async function fetchRecordedBlueprintRrd(
| "showPoints"
| "showTrajectory"
| "pointSize"
| "colorMode"
| "palette"
| "customColor"
>,
@@ -478,6 +576,7 @@ export async function fetchRecordedBlueprintRrd(
signal,
activeView = "spatial",
viewResetGeneration = 0,
followTrajectory = false,
perceptionLayers = {
enabled: false,
detections2d: false,
@@ -491,6 +590,7 @@ export async function fetchRecordedBlueprintRrd(
signal?: AbortSignal;
activeView?: RecordedRerunView;
viewResetGeneration?: 0 | 1;
followTrajectory?: boolean;
perceptionLayers?: RecordedPerceptionLayers;
fetcher?: typeof globalThis.fetch;
},
@@ -508,6 +608,7 @@ export async function fetchRecordedBlueprintRrd(
!Number.isFinite(settings.pointSize) ||
settings.pointSize < 0.1 ||
settings.pointSize > 32 ||
!["intensity", "height", "distance", "rgb", "class"].includes(settings.colorMode) ||
!["turbo", "viridis", "plasma", "grayscale", "custom"].includes(settings.palette) ||
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
!["spatial", "perception", "perception3d", "metrics"].includes(activeView) ||
@@ -540,10 +641,12 @@ export async function fetchRecordedBlueprintRrd(
show_points: settings.showPoints,
show_trajectory: settings.showTrajectory,
point_size: settings.pointSize,
color_mode: settings.colorMode,
palette: settings.palette,
custom_color: settings.customColor,
active_view: activeView,
view_reset_generation: viewResetGeneration,
follow_trajectory: followTrajectory,
unified_perception:
perceptionLayers.detections2d || perceptionLayers.segmentation,
show_detections_2d: perceptionLayers.detections2d,
@@ -679,6 +782,7 @@ export function RerunViewport({
sceneSettings,
recordedView = "spatial",
recordedViewResetGeneration = 0,
recordedFollowTrajectory = false,
recordedPerceptionLayers = {
enabled: false,
detections2d: false,
@@ -688,6 +792,7 @@ export function RerunViewport({
recordedPerceptionRetryGeneration = 0,
lockPerceptionCameraInteraction = false,
onPerceptionLoadChange,
onPointColorLoadChange,
}: RerunViewportProps) {
const hostRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
@@ -696,6 +801,7 @@ export function RerunViewport({
const blueprintChannelRef = useRef<RerunBlueprintChannel | null>(null);
const perceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
const loadedPerceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
const appliedPointColorKeyRef = useRef<string | null>(null);
const recordedIdentityRef = useRef<RecordedRerunIdentity | null>(null);
const blueprintSessionIdRef = useRef(crypto.randomUUID().replaceAll("-", ""));
const presentationGateRef = useRef(presentationGate);
@@ -708,6 +814,9 @@ export function RerunViewport({
const recordedPerceptionUrl = sourceUrl
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
: null;
const recordedPointColorsUrl = sourceUrl
? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin)
: null;
const presentationStatus = rerunPresentationStatus(
status,
presentationGate,
@@ -822,6 +931,7 @@ export function RerunViewport({
};
host.replaceChildren();
appliedPointColorKeyRef.current = null;
setStatus("loading");
setRecordingBufferProgress(null);
onStatusChange?.("loading");
@@ -1213,7 +1323,7 @@ export function RerunViewport({
autoplayWhenReady,
expectedTimelineEndSeconds,
expectedTimelineStartSeconds,
followLive,
followLive,
initialPlaybackStartSeconds,
onPlaybackChange,
onPlaybackControllerChange,
@@ -1231,6 +1341,10 @@ export function RerunViewport({
loadedPerceptionChannelRef.current = null;
}, [recordedPerceptionUrl]);
useEffect(() => {
appliedPointColorKeyRef.current = null;
}, [recordedPointColorsUrl]);
useEffect(() => {
if (!recordedPerceptionUrl) return;
const active = perceptionChannelRef.current;
@@ -1327,6 +1441,89 @@ export function RerunViewport({
recordedPerceptionUrl,
]);
useEffect(() => {
if (!recordedPointColorsUrl || !sceneSettings) return;
const active = blueprintChannelRef.current;
const identity = recordedIdentityRef.current;
if (
!active ||
!identity ||
!active.channel.ready
) return;
const desiredKey = recordedPointColorKey(sceneSettings);
if (
appliedPointColorKeyRef.current === null &&
desiredKey === RECORDED_BASE_POINT_COLOR_KEY
) {
appliedPointColorKeyRef.current = desiredKey;
onPointColorLoadChange?.({
phase: "ready",
receivedBytes: 0,
totalBytes: 0,
progress: 1,
message: "Цвет записи готов.",
});
return;
}
if (appliedPointColorKeyRef.current === desiredKey) {
onPointColorLoadChange?.({
phase: "ready",
receivedBytes: 0,
totalBytes: null,
progress: 1,
message: "Окраска точек готова.",
});
return;
}
const abort = new AbortController();
onPointColorLoadChange?.({
phase: "loading",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "Готовим архивную окраску точек.",
});
void fetchRecordedPointColorsRrd(recordedPointColorsUrl, sceneSettings, identity, {
origin: window.location.origin,
signal: abort.signal,
}).then((payload) => {
if (
abort.signal.aborted ||
blueprintChannelRef.current !== active ||
recordedIdentityRef.current !== identity ||
!active.channel.ready
) {
return;
}
active.channel.send_rrd(payload);
appliedPointColorKeyRef.current = desiredKey;
onPointColorLoadChange?.({
phase: "ready",
receivedBytes: payload.byteLength,
totalBytes: payload.byteLength,
progress: 1,
message: "Окраска точек готова.",
});
}).catch(() => {
if (abort.signal.aborted) return;
onPointColorLoadChange?.({
phase: "error",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "Окраска точек не загрузилась.",
});
});
return () => abort.abort();
}, [
blueprintChannelRevision,
onPointColorLoadChange,
recordedPointColorsUrl,
sceneSettings?.colorMode,
sceneSettings?.customColor,
sceneSettings?.palette,
]);
useEffect(() => {
if (!recordedBlueprintUrl || !sceneSettings) return;
const active = blueprintChannelRef.current;
@@ -1344,6 +1541,7 @@ export function RerunViewport({
signal: abort.signal,
activeView: recordedView,
viewResetGeneration: recordedViewResetGeneration,
followTrajectory: recordedFollowTrajectory,
perceptionLayers: recordedPerceptionLayers,
}).then((payload) => {
if (
@@ -1365,12 +1563,14 @@ export function RerunViewport({
recordedBlueprintUrl,
recordedView,
recordedViewResetGeneration,
recordedFollowTrajectory,
recordedPerceptionLayers.enabled,
recordedPerceptionLayers.detections2d,
recordedPerceptionLayers.segmentation,
recordedPerceptionLayers.cuboids3d,
sceneSettings?.accumulationSeconds,
sceneSettings?.customColor,
sceneSettings?.colorMode,
sceneSettings?.palette,
sceneSettings?.pointSize,
sceneSettings?.showPoints,