feat(perception): integrate calibrated operator pipeline

Add calibrated K1 projection, recorded and near-live perception qualification, unified Rerun operator layers, bounded replay admission, audited viewer controls, worker experiments, and lab evidence.
This commit is contained in:
DCCONSTRUCTIONS
2026-07-23 00:23:28 +03:00
parent ada2a55ee6
commit b53d6d5a45
221 changed files with 55923 additions and 1357 deletions
@@ -72,6 +72,7 @@ export function FloatingObservationWindow({
rect,
maximized,
active,
hidden = false,
onRectChange,
onMaximizedChange,
onActivate,
@@ -89,6 +90,7 @@ export function FloatingObservationWindow({
rect?: ObservationWindowRect;
maximized: boolean;
active: boolean;
hidden?: boolean;
onRectChange: (rect: ObservationWindowRect) => void;
onMaximizedChange: (maximized: boolean) => void;
onActivate: () => void;
@@ -153,7 +155,7 @@ export function FloatingObservationWindow({
resizable={source.capabilities.resizable}
active={active}
zIndex={maximized ? 15 : active ? 9 : 7}
className="floating-observation-window"
className={`floating-observation-window${hidden ? " floating-observation-window--hidden" : ""}`}
onPointerDownCapture={(event) => {
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
try {
@@ -52,18 +52,17 @@ const preparationLabel: Record<ObservationPreparationPhase, string> = {
requesting: "Запрашиваем подготовку",
queued: "В очереди",
validating: "Проверяем запись",
exporting: "Готовим облако точек",
exporting: "Готовим операторскую сцену",
finalizing: "Завершаем подготовку",
failed: "Подготовка не выполнена",
cancelled: "Подготовка отменена",
};
function progressCopy(
phase: ObservationPreparationPhase,
progress: number | null,
): string {
const label = preparationLabel[phase];
return progress === null ? label : `${label} · ${Math.round(progress * 100)}%`;
function progressCopy(phase: ObservationPreparationPhase): string {
// Backend progress values are phase markers and heartbeat revisions, not a
// measured fraction of bytes or frames. Presenting 0.5 as "50%" made a
// healthy long export look stalled, especially after a browser reload.
return preparationLabel[phase];
}
function formatStartedAt(value: string): string {
@@ -126,7 +125,7 @@ export function ObservationSessionSelect({
onReplaySettled,
});
const triggerCopy = sessions.replayProgress
? progressCopy(sessions.replayProgress.phase, sessions.replayProgress.progress)
? progressCopy(sessions.replayProgress.phase)
: sessions.state === "loading"
? "Загружаем сессии…"
: "Сохранённые сессии";
@@ -1,5 +1,3 @@
import { sha256 } from "@noble/hashes/sha2.js";
import { bytesToHex } from "@noble/hashes/utils.js";
import { useEffect, useMemo, useRef, useState } from "react";
import {
@@ -11,7 +9,6 @@ import {
type ObservationSessionFetch,
} from "../core/observation/sessionArchive";
import {
MAX_RECORDED_MEDIA_SOURCE_BYTES,
type RecordedAdmissionPhase,
type RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
@@ -22,32 +19,11 @@ export interface RecordedObservationPlayback {
playing: boolean;
}
export interface RecordedMediaLoadProgress {
loadedBytes: number;
totalBytes: number;
loadedParts: number;
totalParts: number;
}
interface VerifiedRecordedMediaEpoch {
descriptor: ObservationRecordedMediaEpoch;
readonly init: ArrayBuffer;
readonly segments: readonly ArrayBuffer[];
}
export interface VerifiedRecordedMediaArchive {
export interface RecordedMediaArchive {
manifest: ObservationRecordedMediaManifest;
epochs: readonly VerifiedRecordedMediaEpoch[];
byteLength: number;
}
interface RecordedMediaBinaryDescriptor {
url: string;
byteLength: number;
sha256: string;
accept: string;
}
export type RecordedMediaPresentationState = "loading" | "ready" | "waiting" | "error";
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
@@ -134,205 +110,33 @@ function sourceContract(source: ObservationSourceDescriptor): ObservationRecorde
};
}
function expectedPayloadEtag(digest: string): string {
return `"sha256:${digest}"`;
}
export async function fetchVerifiedRecordedMediaBytes(
descriptor: RecordedMediaBinaryDescriptor,
{
signal,
fetcher = globalThis.fetch,
}: { signal?: AbortSignal; fetcher?: ObservationSessionFetch } = {},
): Promise<ArrayBuffer> {
let response: Response;
try {
response = await fetcher(descriptor.url, {
method: "GET",
headers: {
Accept: descriptor.accept,
"If-Match": expectedPayloadEtag(descriptor.sha256),
},
signal,
});
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new ObservationSessionContractError(
"Не удалось загрузить канонический фрагмент записанного видео.",
);
}
if (!response.ok || response.status !== 200) {
throw new ObservationSessionContractError(
`Фрагмент записанного видео вернул HTTP ${response.status}.`,
);
}
if (response.headers.get("ETag") !== expectedPayloadEtag(descriptor.sha256)) {
throw new ObservationSessionContractError(
"Фрагмент записанного видео не соответствует immutable manifest.",
);
}
const contentLength = response.headers.get("Content-Length");
if (
contentLength === null ||
!/^[1-9][0-9]*$/.test(contentLength) ||
Number(contentLength) !== descriptor.byteLength
) {
throw new ObservationSessionContractError(
"Длина фрагмента записанного видео не соответствует immutable manifest.",
);
}
const payload = await response.arrayBuffer();
if (payload.byteLength !== descriptor.byteLength) {
throw new ObservationSessionContractError(
"Фрагмент записанного видео был усечён во время передачи.",
);
}
const digest = bytesToHex(sha256(new Uint8Array(payload)));
if (digest !== descriptor.sha256) {
throw new ObservationSessionContractError(
"SHA-256 фрагмента записанного видео не совпадает с immutable manifest.",
);
}
return payload;
}
function manifestIdentity(manifest: ObservationRecordedMediaManifest): string {
return JSON.stringify(manifest);
}
export async function fetchVerifiedRecordedMediaArchive(
export async function fetchRecordedMediaArchive(
source: ObservationRecordedMediaSource,
{
signal,
fetcher = globalThis.fetch,
onProgress,
}: {
signal?: AbortSignal;
fetcher?: ObservationSessionFetch;
onProgress?: (progress: RecordedMediaLoadProgress) => void;
} = {},
): Promise<VerifiedRecordedMediaArchive> {
): Promise<RecordedMediaArchive> {
const manifest = await fetchObservationRecordedMediaManifest(source, {
signal,
fetcher,
expectedGenerationSha256: source.manifestGenerationSha256,
});
const totalBytes = manifest.epochs.reduce(
(archiveTotal, epoch) => archiveTotal + epoch.initByteLength + epoch.segments.reduce(
(epochTotal, segment) => epochTotal + segment.byteLength,
0,
),
0,
);
const totalParts = manifest.epochs.reduce(
(total, epoch) => total + 1 + epoch.segments.length,
0,
);
const totalBytes = manifest.epochs.reduce((total, epoch) => total + epoch.byteLength, 0);
if (
!Number.isSafeInteger(totalBytes) ||
totalBytes < 1 ||
totalBytes > MAX_RECORDED_MEDIA_SOURCE_BYTES ||
totalBytes !== manifest.byteLength ||
totalBytes !== source.byteLength
) {
throw new ObservationSessionContractError(
"Размер записанного медиаканала выходит за безопасный лимит браузера.",
"Размеры потоков записанного медиаканала не совпадают с manifest.",
);
}
let loadedBytes = 0;
let loadedParts = 0;
const publishProgress = () => onProgress?.({
loadedBytes,
totalBytes,
loadedParts,
totalParts,
});
publishProgress();
const epochs: VerifiedRecordedMediaEpoch[] = [];
for (const epoch of manifest.epochs) {
const init = await fetchVerifiedRecordedMediaBytes({
url: epoch.initUrl,
byteLength: epoch.initByteLength,
sha256: epoch.initSha256,
accept: "video/mp4",
}, { signal, fetcher });
loadedBytes += init.byteLength;
loadedParts += 1;
publishProgress();
const segments: ArrayBuffer[] = [];
for (const segment of epoch.segments) {
const payload = await fetchVerifiedRecordedMediaBytes({
url: segment.url,
byteLength: segment.byteLength,
sha256: segment.sha256,
accept: "video/iso.segment",
}, { signal, fetcher });
segments.push(payload);
loadedBytes += payload.byteLength;
loadedParts += 1;
publishProgress();
}
epochs.push({ descriptor: epoch, init, segments });
}
// Bind the complete byte set to the same manifest generation at both ends
// of the transfer. A replacement during a long camera download fails closed.
const confirmed = await fetchObservationRecordedMediaManifest(source, {
signal,
fetcher,
expectedGenerationSha256: manifest.generationSha256,
});
if (manifestIdentity(confirmed) !== manifestIdentity(manifest)) {
throw new ObservationSessionContractError(
"Manifest записанного видео изменился во время полной загрузки.",
);
}
if (loadedBytes !== source.byteLength) {
throw new ObservationSessionContractError(
"Полная загрузка камеры не совпала с launch byte_length.",
);
}
return { manifest, epochs, byteLength: loadedBytes };
}
export function appendRecordedMediaBuffer(
sourceBuffer: SourceBuffer,
payload: ArrayBuffer,
signal: AbortSignal,
): Promise<void> {
return new Promise((resolve, reject) => {
const onUpdateEnd = () => {
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error("SourceBuffer rejected archived fMP4 data"));
};
const onAbort = () => {
cleanup();
reject(new DOMException("Aborted", "AbortError"));
};
const cleanup = () => {
sourceBuffer.removeEventListener("updateend", onUpdateEnd);
sourceBuffer.removeEventListener("error", onError);
signal.removeEventListener("abort", onAbort);
};
if (signal.aborted) {
onAbort();
return;
}
sourceBuffer.addEventListener("updateend", onUpdateEnd, { once: true });
sourceBuffer.addEventListener("error", onError, { once: true });
signal.addEventListener("abort", onAbort, { once: true });
try {
sourceBuffer.appendBuffer(payload);
} catch (error) {
cleanup();
reject(error);
}
});
return { manifest, byteLength: totalBytes };
}
function videoHasSeekableArchive(
@@ -358,17 +162,22 @@ function waitForSeekableArchive(
if (videoHasSeekableArchive(video, declaredDurationSeconds)) return Promise.resolve();
return new Promise((resolve, reject) => {
const events = ["loadedmetadata", "durationchange", "progress", "canplay"] as const;
const timeout = globalThis.setTimeout(() => {
cleanup();
reject(new Error("Archived camera did not become seekable"));
}, 20_000);
let stallTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
const armStallTimer = () => {
if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer);
stallTimer = globalThis.setTimeout(() => {
cleanup();
reject(new Error("Archived camera stream stalled"));
}, 45_000);
};
const cleanup = () => {
globalThis.clearTimeout(timeout);
if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer);
for (const event of events) video.removeEventListener(event, onProgress);
video.removeEventListener("error", onError);
signal.removeEventListener("abort", onAbort);
};
const onProgress = () => {
armStallTimer();
if (!videoHasSeekableArchive(video, declaredDurationSeconds)) return;
cleanup();
resolve();
@@ -384,43 +193,30 @@ function waitForSeekableArchive(
for (const event of events) video.addEventListener(event, onProgress);
video.addEventListener("error", onError, { once: true });
signal.addEventListener("abort", onAbort, { once: true });
armStallTimer();
});
}
async function mountVerifiedRecordedEpoch(
async function mountRecordedEpochStream(
video: HTMLVideoElement,
epoch: VerifiedRecordedMediaEpoch,
descriptor: ObservationRecordedMediaEpoch,
signal: AbortSignal,
): Promise<() => void> {
const descriptor = epoch.descriptor;
if (!descriptor.mediaType.startsWith("video/mp4;") || !video.canPlayType(descriptor.mediaType)) {
throw new Error("Archived camera codec is not supported");
}
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
// A sealed fragmented-MP4 generation is already complete and immutable.
// Present it as one Blob so Chromium can index and seek the whole archive
// without retaining the same 166+ MiB epoch in a quota-limited MSE
// SourceBuffer. Every constituent byte was fetched and SHA-256 verified
// before this boundary, and Blob preserves their canonical order.
const payload = new Blob([epoch.init, ...epoch.segments], {
type: descriptor.mediaType,
});
const expectedByteLength = epoch.init.byteLength + epoch.segments.reduce(
(total, segment) => total + segment.byteLength,
0,
);
if (payload.size !== expectedByteLength) {
throw new Error("Archived camera Blob is incomplete");
}
const objectUrl = URL.createObjectURL(payload);
// The generation token binds the native media request to the exact manifest.
// The browser range-streams the virtual init+fragment file from the server;
// no complete camera archive is copied into JavaScript memory.
const cleanup = () => {
video.pause();
video.removeAttribute("src");
video.load();
URL.revokeObjectURL(objectUrl);
};
video.src = objectUrl;
video.preload = "auto";
video.src = descriptor.streamUrl;
video.load();
try {
await waitForSeekableArchive(
@@ -483,22 +279,15 @@ export function RecordedFmp4Player({
recordedDelivery?.timelineEndSeconds,
],
);
const [archive, setArchive] = useState<VerifiedRecordedMediaArchive | null>(null);
const [archive, setArchive] = useState<RecordedMediaArchive | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
const [progress, setProgress] = useState<RecordedMediaLoadProgress | null>(null);
const [bufferRevision, setBufferRevision] = useState(0);
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
const epoch = useMemo(
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
[archive?.manifest.epochs, currentSeconds],
);
const verifiedEpoch = useMemo(
() => epoch
? archive?.epochs.find(({ descriptor }) => descriptor.ordinal === epoch.ordinal) ?? null
: null,
[archive?.epochs, epoch],
);
const waitingForEpoch = Boolean(archive && !epoch);
const selectedGeneration = contract && epoch
? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}`
@@ -514,7 +303,6 @@ export function RecordedFmp4Player({
useEffect(() => {
if (!contract) {
setArchive(null);
setProgress(null);
setReadyGeneration(null);
setState("error");
reportAdmission({
@@ -527,7 +315,6 @@ export function RecordedFmp4Player({
if (!prepare) return;
const abort = new AbortController();
setArchive(null);
setProgress(null);
setReadyGeneration(null);
setState("loading");
reportAdmission({
@@ -535,12 +322,7 @@ export function RecordedFmp4Player({
byteLength: contract.byteLength,
message: null,
});
void fetchVerifiedRecordedMediaArchive(contract, {
signal: abort.signal,
onProgress: (next) => {
if (!abort.signal.aborted) setProgress(next);
},
})
void fetchRecordedMediaArchive(contract, { signal: abort.signal })
.then((loaded) => {
if (abort.signal.aborted) return;
setArchive(loaded);
@@ -566,11 +348,11 @@ export function RecordedFmp4Player({
const abort = new AbortController();
let disposed = false;
void (async () => {
for (const candidate of archive.epochs) {
for (const candidate of archive.manifest.epochs) {
const probe = document.createElement("video");
probe.muted = true;
probe.playsInline = true;
const cleanup = await mountVerifiedRecordedEpoch(probe, candidate, abort.signal);
const cleanup = await mountRecordedEpochStream(probe, candidate, abort.signal);
cleanup();
if (disposed || abort.signal.aborted) return;
}
@@ -602,8 +384,8 @@ export function RecordedFmp4Player({
useEffect(() => {
const video = videoRef.current;
if (!video || !verifiedEpoch) return;
const epochDescriptor = verifiedEpoch.descriptor;
if (!video || !epoch) return;
const epochDescriptor = epoch;
const generation = contract
? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}`
: null;
@@ -615,7 +397,7 @@ export function RecordedFmp4Player({
const loadEpoch = async () => {
try {
cleanup = await mountVerifiedRecordedEpoch(video, verifiedEpoch, abort.signal);
cleanup = await mountRecordedEpochStream(video, epochDescriptor, abort.signal);
if (disposed || abort.signal.aborted) {
cleanup();
cleanup = null;
@@ -648,7 +430,7 @@ export function RecordedFmp4Player({
abort.abort();
cleanup?.();
};
}, [archive?.byteLength, contract, verifiedEpoch]);
}, [archive?.byteLength, contract, epoch]);
useEffect(() => {
const video = videoRef.current;
@@ -679,10 +461,6 @@ export function RecordedFmp4Player({
}
}, [archive?.byteLength, bufferRevision, currentSeconds, epoch, playback?.playing, visualState]);
const progressPercent = progress && progress.totalBytes > 0
? Math.min(100, Math.floor((progress.loadedBytes / progress.totalBytes) * 100))
: 0;
return (
<div
className="recorded-media-player"
@@ -707,8 +485,8 @@ export function RecordedFmp4Player({
: visualState === "error"
? "Записанное видео недоступно"
: archive
? "Проверяем полную готовность записанного видео…"
: `Загружаем и проверяем записанное видео · ${progressPercent}%`}
? "Проверяем seek и codec записанного видео…"
: "Читаем manifest записанного видео…"}
</div>
) : null}
</div>
@@ -4,7 +4,14 @@ 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 type RecordedRerunView = "spatial" | "perception" | "perception3d" | "metrics";
export interface RecordedPerceptionLayers {
enabled: boolean;
detections2d: boolean;
segmentation: boolean;
cuboids3d: boolean;
}
export interface RerunSelection {
entityPath: string;
@@ -57,6 +64,8 @@ export interface RerunViewportProps {
| "customColor"
>;
recordedView?: RecordedRerunView;
recordedViewResetGeneration?: 0 | 1;
recordedPerceptionLayers?: RecordedPerceptionLayers;
onPerceptionAvailabilityChange?: (available: boolean) => void;
}
@@ -438,11 +447,20 @@ export async function fetchRecordedBlueprintRrd(
origin,
signal,
activeView = "spatial",
viewResetGeneration = 0,
perceptionLayers = {
enabled: false,
detections2d: false,
segmentation: false,
cuboids3d: false,
},
fetcher = globalThis.fetch,
}: {
origin: string;
signal?: AbortSignal;
activeView?: RecordedRerunView;
viewResetGeneration?: 0 | 1;
perceptionLayers?: RecordedPerceptionLayers;
fetcher?: typeof globalThis.fetch;
},
): Promise<Uint8Array> {
@@ -461,7 +479,14 @@ 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) ||
!["spatial", "perception", "perception3d", "metrics"].includes(activeView) ||
![0, 1].includes(viewResetGeneration) ||
[
perceptionLayers.enabled,
perceptionLayers.detections2d,
perceptionLayers.segmentation,
perceptionLayers.cuboids3d,
].some((value) => typeof value !== "boolean") ||
identity.applicationId !== "nodedc_mission_core_recorded" ||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId)
) {
@@ -485,6 +510,11 @@ export async function fetchRecordedBlueprintRrd(
palette: settings.palette,
custom_color: settings.customColor,
active_view: activeView,
view_reset_generation: viewResetGeneration,
unified_perception: perceptionLayers.enabled,
show_detections_2d: perceptionLayers.detections2d,
show_segmentation: perceptionLayers.segmentation,
show_cuboids_3d: perceptionLayers.cuboids3d,
}),
signal,
});
@@ -588,6 +618,13 @@ export function RerunViewport({
onPlaybackControllerChange,
sceneSettings,
recordedView = "spatial",
recordedViewResetGeneration = 0,
recordedPerceptionLayers = {
enabled: false,
detections2d: false,
segmentation: false,
cuboids3d: false,
},
onPerceptionAvailabilityChange,
}: RerunViewportProps) {
const hostRef = useRef<HTMLDivElement>(null);
@@ -596,6 +633,7 @@ export function RerunViewport({
const [retryNonce, setRetryNonce] = useState(0);
const blueprintChannelRef = useRef<RerunBlueprintChannel | null>(null);
const perceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
const loadedPerceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
const recordedIdentityRef = useRef<RecordedRerunIdentity | null>(null);
const presentationGateRef = useRef(presentationGate);
presentationGateRef.current = presentationGate;
@@ -1123,11 +1161,11 @@ export function RerunViewport({
]);
useEffect(() => {
onPerceptionAvailabilityChange?.(false);
}, [onPerceptionAvailabilityChange, recordedPerceptionUrl]);
loadedPerceptionChannelRef.current = null;
}, [recordedPerceptionUrl]);
useEffect(() => {
if (!recordedPerceptionUrl) return;
if (!recordedPerceptionUrl || !recordedPerceptionLayers.enabled) return;
const active = perceptionChannelRef.current;
const identity = recordedIdentityRef.current;
if (
@@ -1136,6 +1174,10 @@ export function RerunViewport({
active.endpointUrl !== recordedPerceptionUrl ||
!active.channel.ready
) return;
if (loadedPerceptionChannelRef.current === active) {
onPerceptionAvailabilityChange?.(true);
return;
}
const abort = new AbortController();
void fetchRecordedPerceptionRrd(recordedPerceptionUrl, identity, {
origin: window.location.origin,
@@ -1154,6 +1196,7 @@ export function RerunViewport({
return;
}
active.channel.send_rrd(payload);
loadedPerceptionChannelRef.current = active;
onPerceptionAvailabilityChange?.(true);
}).catch(() => {
onPerceptionAvailabilityChange?.(false);
@@ -1163,6 +1206,7 @@ export function RerunViewport({
}, [
onPerceptionAvailabilityChange,
perceptionChannelRevision,
recordedPerceptionLayers.enabled,
recordedPerceptionUrl,
]);
@@ -1181,6 +1225,8 @@ export function RerunViewport({
origin: window.location.origin,
signal: abort.signal,
activeView: recordedView,
viewResetGeneration: recordedViewResetGeneration,
perceptionLayers: recordedPerceptionLayers,
}).then((payload) => {
if (
abort.signal.aborted ||
@@ -1200,6 +1246,11 @@ export function RerunViewport({
blueprintChannelRevision,
recordedBlueprintUrl,
recordedView,
recordedViewResetGeneration,
recordedPerceptionLayers.enabled,
recordedPerceptionLayers.detections2d,
recordedPerceptionLayers.segmentation,
recordedPerceptionLayers.cuboids3d,
sceneSettings?.accumulationSeconds,
sceneSettings?.customColor,
sceneSettings?.palette,