fix(lab): enforce canonical replay runtime

This commit is contained in:
DCCONSTRUCTIONS
2026-08-29 22:42:30 +03:00
parent 525ab74168
commit bd2892140f
17 changed files with 1765 additions and 507 deletions
@@ -781,6 +781,8 @@ export function RecordedFmp4Player({
onAdmissionChange, onAdmissionChange,
onPlaybackChange, onPlaybackChange,
onPlayingRejected, onPlayingRejected,
playbackAuthority = "media",
playbackTransport = "segmented",
}: { }: {
source: ObservationSourceDescriptor; source: ObservationSourceDescriptor;
playback?: RecordedObservationPlayback | null; playback?: RecordedObservationPlayback | null;
@@ -793,6 +795,8 @@ export function RecordedFmp4Player({
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void; onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
onPlaybackChange?: (playback: RecordedObservationPlayback) => void; onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
onPlayingRejected?: () => void; onPlayingRejected?: () => void;
playbackAuthority?: "media" | "host";
playbackTransport?: "segmented" | "epoch-stream";
}) { }) {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const onAdmissionChangeRef = useRef(onAdmissionChange); const onAdmissionChangeRef = useRef(onAdmissionChange);
@@ -888,7 +892,8 @@ export function RecordedFmp4Player({
&& effectiveSegmentSequence !== requestedSegmentSequence, && effectiveSegmentSequence !== requestedSegmentSequence,
); );
const segmented = Boolean( const segmented = Boolean(
requestedSegmentSequence !== null playbackTransport === "segmented"
&& requestedSegmentSequence !== null
&& Number.isInteger(requestedSegmentSequence) && Number.isInteger(requestedSegmentSequence)
&& requestedSegmentSequence >= 1 && requestedSegmentSequence >= 1
&& &&
@@ -1201,6 +1206,11 @@ export function RecordedFmp4Player({
|| segmentedRuntimeRef.current !== runtime || segmentedRuntimeRef.current !== runtime
|| runtime.target?.revision !== revision || runtime.target?.revision !== revision
) return; ) return;
// Canonical recorded LABs run one host-owned clock for camera and
// spatial evidence. A transient MSE play() rejection (commonly a
// pause/reset race while the next fragment is admitted) must stay a
// decoder concern: the rolling target will retry and catch up.
if (playbackAuthority === "host") return;
onPlayingRejectedRef.current?.(); onPlayingRejectedRef.current?.();
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Запуск записанной камеры отклонён браузером."); setErrorMessage("Запуск записанной камеры отклонён браузером.");
@@ -1311,7 +1321,13 @@ export function RecordedFmp4Player({
if (targetReadyAbortRef.current === targetReadyAbort) targetReadyAbortRef.current = null; if (targetReadyAbortRef.current === targetReadyAbort) targetReadyAbortRef.current = null;
if (runtime.onTargetBuffered === markBuffered) runtime.onTargetBuffered = null; if (runtime.onTargetBuffered === markBuffered) runtime.onTargetBuffered = null;
}; };
}, [archive?.byteLength, effectiveSegmentSequence, segmented, segmentedRuntimeGeneration]); }, [
archive?.byteLength,
effectiveSegmentSequence,
playbackAuthority,
segmented,
segmentedRuntimeGeneration,
]);
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
@@ -1407,6 +1423,7 @@ export function RecordedFmp4Player({
if (playback?.playing && !holdingForSegmentRecovery) { if (playback?.playing && !holdingForSegmentRecovery) {
void video.play().catch(() => { void video.play().catch(() => {
if (playAttemptRevisionRef.current !== playAttemptRevision) return; if (playAttemptRevisionRef.current !== playAttemptRevision) return;
if (playbackAuthority === "host") return;
onPlayingRejectedRef.current?.(); onPlayingRejectedRef.current?.();
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Запуск записанной камеры отклонён браузером."); setErrorMessage("Запуск записанной камеры отклонён браузером.");
@@ -1427,6 +1444,7 @@ export function RecordedFmp4Player({
epoch, epoch,
holdingForSegmentRecovery, holdingForSegmentRecovery,
playback?.playing, playback?.playing,
playbackAuthority,
playbackRate, playbackRate,
segmented, segmented,
visualState, visualState,
@@ -1454,6 +1472,7 @@ export function RecordedFmp4Player({
video.playbackRate = playbackRateRef.current; video.playbackRate = playbackRateRef.current;
void video.play().catch(() => { void video.play().catch(() => {
if (cancelled || playAttemptRevisionRef.current !== playAttemptRevision) return; if (cancelled || playAttemptRevisionRef.current !== playAttemptRevision) return;
if (playbackAuthority === "host") return;
onPlayingRejectedRef.current?.(); onPlayingRejectedRef.current?.();
}); });
}; };
@@ -1465,7 +1484,7 @@ export function RecordedFmp4Player({
cancelled = true; cancelled = true;
for (const event of events) video.removeEventListener(event, queueResume); for (const event of events) video.removeEventListener(event, queueResume);
}; };
}, [bufferRevision, playback?.playing, segmented, visualState]); }, [bufferRevision, playback?.playing, playbackAuthority, segmented, visualState]);
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
@@ -0,0 +1,245 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { SegmentedControl, SplitPane, type SplitPaneOrientation } from "@nodedc/ui-react";
import { LaboratoryEvidenceViewer } from "./LaboratoryEvidenceViewer";
export const CANONICAL_RECORDED_LAB_REPLAY_CONTRACT =
"missioncore.canonical-recorded-lab-replay/v1";
export interface CanonicalRecordedLabMode<T extends string> {
value: T;
label: string;
}
/**
* The interaction contract from the accepted recorded-LAB instrument.
*
* Keeping pane selection, collapse semantics, responsive split orientation,
* expansion and splitter state here prevents individual experiments from
* quietly growing their own replay behaviour. Experiments provide evidence
* layers; they do not reimplement the laboratory shell.
*/
export function useCanonicalRecordedLabReplayState<
TMedia extends string,
TSpatial extends string,
>({
initialMediaMode,
initialSpatialMode,
}: {
initialMediaMode: TMedia;
initialSpatialMode: TSpatial | null;
}) {
const [mediaMode, setMediaMode] = useState<TMedia | null>(initialMediaMode);
const [spatialMode, setSpatialMode] = useState<TSpatial | null>(initialSpatialMode);
const [splitPrimarySize, setSplitPrimarySize] = useState(50);
const [splitOrientation, setSplitOrientation] = useState<SplitPaneOrientation>(() => (
typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches
? "horizontal"
: "vertical"
));
const [expanded, setExpanded] = useState(false);
useEffect(() => {
const query = window.matchMedia("(max-width: 900px)");
const update = () => setSplitOrientation(query.matches ? "horizontal" : "vertical");
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
const onMediaModeChange = useCallback((next: TMedia | "none") => {
if (next === "none") return;
setMediaMode((current) => current === next ? null : next);
}, []);
const onSpatialModeChange = useCallback((next: TSpatial | "none") => {
if (next === "none") return;
setSpatialMode((current) => current === next ? null : next);
}, []);
return {
mediaMode,
spatialMode,
splitView: mediaMode !== null && spatialMode !== null,
splitPrimarySize,
splitOrientation,
expanded,
onMediaModeChange,
onSpatialModeChange,
onSplitPrimarySizeChange: setSplitPrimarySize,
onExpandedChange: setExpanded,
};
}
export function CanonicalRecordedLabReplay<
TMedia extends string,
TSpatial extends string,
>({
label,
mediaMode,
mediaModes,
spatialMode,
spatialModes,
expanded,
splitPrimarySize,
splitOrientation,
mediaAriaLabel,
spatialAriaLabel,
mediaLayerControls,
spatialLayerControls,
spatialLeadingControl,
mediaMultiLayer = false,
mediaContent,
spatialContent,
emptyMessage,
deckOverlays,
actions,
overlay,
transport,
trailingActions,
onMediaModeChange,
onSpatialModeChange,
onExpandedChange,
onSplitPrimarySizeChange,
}: {
label: string;
mediaMode: TMedia;
mediaModes: readonly CanonicalRecordedLabMode<TMedia>[];
spatialMode: TSpatial;
spatialModes: readonly CanonicalRecordedLabMode<TSpatial>[];
expanded: boolean;
splitPrimarySize: number;
splitOrientation: SplitPaneOrientation;
mediaAriaLabel: string;
spatialAriaLabel: string;
mediaLayerControls?: ReactNode;
spatialLayerControls?: ReactNode;
spatialLeadingControl?: ReactNode;
mediaMultiLayer?: boolean;
mediaContent: ReactNode;
spatialContent: ReactNode;
emptyMessage: string;
deckOverlays?: ReactNode;
actions?: ReactNode;
overlay?: ReactNode;
transport?: ReactNode;
trailingActions?: ReactNode;
onMediaModeChange: (mode: TMedia) => void;
onSpatialModeChange: (mode: TSpatial) => void;
onExpandedChange: (expanded: boolean) => void;
onSplitPrimarySizeChange: (size: number) => void;
}) {
const splitView = mediaMode !== "none" && spatialMode !== "none";
const mediaModeControls = (
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="media">
<SegmentedControl
value={mediaMode}
items={[...mediaModes]}
label="Видео и камера"
onChange={onMediaModeChange}
/>
</div>
);
const spatialModeControls = (
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="spatial">
<SegmentedControl
value={spatialMode}
items={[...spatialModes]}
label="3D и план"
onChange={onSpatialModeChange}
/>
</div>
);
const mediaPane = (
<section
className="m4-replay-threat-visual__pane"
data-pane="media"
aria-label={mediaAriaLabel}
hidden={mediaMode === "none"}
>
{splitView ? (
<div
className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="media"
data-multi-semantic={mediaMultiLayer ? "true" : undefined}
>
{mediaLayerControls}
{mediaModeControls}
</div>
) : null}
{mediaContent}
</section>
);
const spatialPane = spatialMode !== "none" ? (
<section
className="m4-replay-threat-visual__pane"
data-pane="spatial"
aria-label={spatialAriaLabel}
>
{splitView ? (
<div
className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="spatial"
>
{spatialLeadingControl}
<div className="m4-replay-threat-visual__spatial-toolbar-end">
{spatialLayerControls}
{spatialModeControls}
</div>
</div>
) : null}
{spatialContent}
</section>
) : null;
return (
<div
className="l3-visual-audit m4-replay-threat-visual"
data-contract={CANONICAL_RECORDED_LAB_REPLAY_CONTRACT}
>
<LaboratoryEvidenceViewer
label={label}
className="m4-replay-threat-evidence-viewer"
mode={mediaMode}
modes={[...mediaModes]}
secondaryMode={{
value: spatialMode,
modes: [...spatialModes],
label: "3D и план",
onChange: onSpatialModeChange,
}}
expanded={expanded}
onModeChange={onMediaModeChange}
onExpandedChange={onExpandedChange}
modeControlsVisible={!splitView}
actions={actions}
overlay={overlay}
transport={transport}
trailingActions={trailingActions}
>
<div
className="m4-replay-threat-visual__deck"
data-split={splitView ? "true" : undefined}
data-empty={mediaMode === "none" && spatialMode === "none" ? "true" : undefined}
>
<SplitPane
primary={mediaPane}
secondary={spatialPane ?? <div />}
primarySize={splitView ? splitPrimarySize : mediaMode !== "none" ? 100 : 0}
onPrimarySizeChange={onSplitPrimarySizeChange}
orientation={splitOrientation}
minPrimarySize={splitView ? 24 : 0}
minSecondarySize={splitView ? 24 : 0}
resizable={splitView}
separatorLabel="Изменить размер VIDEO/CAMERA и 3D/PLAN"
/>
{mediaMode === "none" && spatialMode === "none" ? (
<div className="l3-visual-audit__state" role="status">
{emptyMessage}
</div>
) : null}
{deckOverlays}
</div>
</LaboratoryEvidenceViewer>
</div>
);
}
@@ -81,9 +81,7 @@ export function LaboratoryRecordedClipPlayer({
sourceCount, sourceCount,
cameraRef, cameraRef,
cameraOverlay, cameraOverlay,
cameraControls,
alternativeScene, alternativeScene,
spatialControls,
onSequenceChange, onSequenceChange,
onPlayingChange, onPlayingChange,
onPlaybackRateChange, onPlaybackRateChange,
@@ -99,9 +97,7 @@ export function LaboratoryRecordedClipPlayer({
sourceCount: number; sourceCount: number;
cameraRef?: RefObject<HTMLDivElement | null>; cameraRef?: RefObject<HTMLDivElement | null>;
cameraOverlay?: ReactNode; cameraOverlay?: ReactNode;
cameraControls?: ReactNode;
alternativeScene?: ReactNode; alternativeScene?: ReactNode;
spatialControls?: ReactNode;
onSequenceChange: (sequence: number) => void; onSequenceChange: (sequence: number) => void;
onPlayingChange: (playing: boolean) => void; onPlayingChange: (playing: boolean) => void;
onPlaybackRateChange: (rate: number) => void; onPlaybackRateChange: (rate: number) => void;
@@ -177,14 +173,6 @@ export function LaboratoryRecordedClipPlayer({
aria-hidden={cameraPresentation === "primary"} aria-hidden={cameraPresentation === "primary"}
> >
{alternativeScene} {alternativeScene}
{spatialControls ? (
<div
className="laboratory-recorded-clip-player__pane-controls"
data-pane="spatial"
>
{spatialControls}
</div>
) : null}
</div> </div>
); );
const cameraPane = ( const cameraPane = (
@@ -207,14 +195,6 @@ export function LaboratoryRecordedClipPlayer({
/> />
) : null} ) : null}
{cameraPresentation !== "hidden" ? cameraOverlay : null} {cameraPresentation !== "hidden" ? cameraOverlay : null}
{cameraPresentation !== "hidden" && cameraControls ? (
<div
className="laboratory-recorded-clip-player__pane-controls"
data-pane="camera"
>
{cameraControls}
</div>
) : null}
</div> </div>
); );
return ( return (
@@ -33,6 +33,8 @@ export function RecordedEvidenceVideoScene({
segmentCount, segmentCount,
onPlaybackChange, onPlaybackChange,
onPlayingRejected, onPlayingRejected,
playbackAuthority = "media",
playbackTransport = "segmented",
}: { }: {
source: ObservationSourceDescriptor; source: ObservationSourceDescriptor;
playback: RecordedObservationPlayback; playback: RecordedObservationPlayback;
@@ -47,6 +49,8 @@ export function RecordedEvidenceVideoScene({
segmentCount?: number; segmentCount?: number;
onPlaybackChange?: (playback: RecordedObservationPlayback) => void; onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
onPlayingRejected?: () => void; onPlayingRejected?: () => void;
playbackAuthority?: "media" | "host";
playbackTransport?: "segmented" | "epoch-stream";
}) { }) {
return ( return (
<div className="recorded-evidence-video-scene"> <div className="recorded-evidence-video-scene">
@@ -59,6 +63,8 @@ export function RecordedEvidenceVideoScene({
segmentCount={segmentCount} segmentCount={segmentCount}
onPlaybackChange={onPlaybackChange} onPlaybackChange={onPlaybackChange}
onPlayingRejected={onPlayingRejected} onPlayingRejected={onPlayingRejected}
playbackAuthority={playbackAuthority}
playbackTransport={playbackTransport}
/> />
{semanticOverlay ? ( {semanticOverlay ? (
<RecordedEvidenceSemanticMaskOverlay <RecordedEvidenceSemanticMaskOverlay
@@ -130,8 +130,12 @@ export function useRecordedEvidencePlayback(
const synchronize = useCallback((next: RecordedObservationPlayback) => { const synchronize = useCallback((next: RecordedObservationPlayback) => {
if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return; if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return;
// The canonical LAB host clock is authoritative. Native media callbacks
// are observational only in this mode: a stalled decoder must never stop
// the common timeline or let an independently playing spatial view drift.
if (clock === "animation") return;
setPlayback((current) => synchronizeRecordedEvidencePlayback(current, next, range)); setPlayback((current) => synchronizeRecordedEvidencePlayback(current, next, range));
}, [range]); }, [clock, range]);
return useMemo(() => ({ return useMemo(() => ({
playback, playback,
@@ -106,6 +106,36 @@ export interface VegetationMixedRouteReview {
cases: readonly VegetationMixedRouteCase[]; cases: readonly VegetationMixedRouteCase[];
} }
export interface VegetationRouteTgsAnchor {
sourceSequence: number;
slot: number;
currentPointsXyzM: readonly (readonly [number, number, number])[];
costmap: {
cellSizeM: 0.45;
centersXyM: readonly (readonly [number, number])[];
stateCodes: readonly number[];
zBoundsM: readonly (readonly [number | null, number | null])[];
};
}
export interface CanonicalRecordedLabSpatialFrame {
targetTimeNs: number;
sourceTimeNs: number;
poseTimeNs: number;
trajectoryTimeNs: number;
sourcePointCount: number;
bodyFrame: {
originMapXyzM: readonly [number, number, number];
basisMapFromBody: readonly [
readonly [number, number, number],
readonly [number, number, number],
readonly [number, number, number],
];
};
sourcePointsBodyXyzM: readonly (readonly [number, number, number])[];
localSlamBodyXyzM: readonly (readonly [number, number, number])[];
}
export interface VegetationFullRouteLayer { export interface VegetationFullRouteLayer {
name: string; name: string;
resultId: string; resultId: string;
@@ -942,6 +972,172 @@ export function vegetationFullRouteMaskUrl(
return `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/route-masks/${layer}/${sequence}`; return `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/route-masks/${layer}/${sequence}`;
} }
export async function fetchVegetationRouteTgsAnchor(
resultId: string,
sourceSequence: number,
{
fetcher = fetch,
signal,
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
): Promise<VegetationRouteTgsAnchor> {
if (!RESULT_ID.test(resultId) || !Number.isInteger(sourceSequence) || sourceSequence < 1) {
throw new VegetationShadowContractError("Vegetation TGS anchor identity недопустима.");
}
const response = await fetcher(
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}`
+ `/route-tgs-anchor/${sourceSequence}`,
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
if (!response.ok) {
throw new VegetationShadowContractError(`Vegetation TGS anchor недоступен: HTTP ${response.status}.`);
}
const payload = objectValue(await response.json(), "vegetation.route_tgs_anchor");
exact(
payload.schema_version,
"missioncore.lab-v1-route-tgs-anchor/v1",
"vegetation.route_tgs_anchor.schema_version",
);
exact(payload.source_sequence, sourceSequence, "vegetation.route_tgs_anchor.source_sequence");
const pointValue = (value: unknown, label: string): readonly number[] => {
const point = arrayValue(value, label).map((item, index) => numberValue(item, `${label}[${index}]`));
if (point.length !== 2 && point.length !== 3) {
throw new VegetationShadowContractError(`${label}: размер изменён.`);
}
return point;
};
const points = arrayValue(payload.current_points_xyz_m, "vegetation.route_tgs_anchor.points")
.map((value, index) => pointValue(value, `vegetation.route_tgs_anchor.points[${index}]`));
const costmap = objectValue(payload.costmap, "vegetation.route_tgs_anchor.costmap");
exact(costmap.cell_size_m, 0.45, "vegetation.route_tgs_anchor.costmap.cell_size_m");
const centers = arrayValue(costmap.centers_xy_m, "vegetation.route_tgs_anchor.costmap.centers")
.map((value, index) => pointValue(value, `vegetation.route_tgs_anchor.costmap.centers[${index}]`));
const stateCodes = arrayValue(costmap.state_codes, "vegetation.route_tgs_anchor.costmap.states")
.map((value, index) => integerValue(value, `vegetation.route_tgs_anchor.costmap.states[${index}]`));
const zBounds = arrayValue(costmap.z_bounds_m, "vegetation.route_tgs_anchor.costmap.z_bounds")
.map((value, index) => {
const row = arrayValue(value, `vegetation.route_tgs_anchor.costmap.z_bounds[${index}]`);
if (row.length !== 2 || row.some((item) => item !== null && (typeof item !== "number" || !Number.isFinite(item)))) {
throw new VegetationShadowContractError("vegetation.route_tgs_anchor.costmap.z_bounds: контракт изменён.");
}
return row as readonly [number | null, number | null];
});
if (
centers.length !== 2244
|| stateCodes.length !== 2244
|| zBounds.length !== 2244
|| stateCodes.some((value) => value > 3)
|| points.some((point) => point.length !== 3)
|| centers.some((point) => point.length !== 2)
) {
throw new VegetationShadowContractError("Vegetation TGS anchor shape изменён.");
}
return {
sourceSequence,
slot: integerValue(payload.slot, "vegetation.route_tgs_anchor.slot"),
currentPointsXyzM: points.map((point) => [point[0]!, point[1]!, point[2]!] as const),
costmap: {
cellSizeM: 0.45,
centersXyM: centers.map((point) => [point[0]!, point[1]!] as const),
stateCodes,
zBoundsM: zBounds,
},
};
}
export async function fetchCanonicalRecordedLabSpatialFrame(
sessionId: string,
generationSha256: string,
targetTimeNs: number,
{
fetcher = fetch,
signal,
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
): Promise<CanonicalRecordedLabSpatialFrame> {
if (
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(sessionId)
|| !SHA256.test(generationSha256)
|| !Number.isSafeInteger(targetTimeNs)
|| targetTimeNs < 0
) {
throw new VegetationShadowContractError("Canonical LAB spatial identity недопустима.");
}
const query = new URLSearchParams({
generation: generationSha256,
time_ns: String(targetTimeNs),
});
const response = await fetcher(
`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}`
+ `/canonical-lab/spatial-frame?${query.toString()}`,
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
if (!response.ok) {
throw new VegetationShadowContractError(
`Canonical LAB spatial frame недоступен: HTTP ${response.status}.`,
);
}
const payload = objectValue(await response.json(), "canonical_lab.spatial_frame");
exact(
payload.schema_version,
"missioncore.canonical-recorded-lab-spatial-frame/v1",
"canonical_lab.spatial_frame.schema_version",
);
exact(payload.target_time_ns, targetTimeNs, "canonical_lab.spatial_frame.target_time_ns");
const pointList = (value: unknown, label: string) => arrayValue(value, label).map(
(entry, index) => {
const point = arrayValue(entry, `${label}[${index}]`).map(
(channel, channelIndex) => numberValue(channel, `${label}[${index}][${channelIndex}]`),
);
if (point.length !== 3) {
throw new VegetationShadowContractError(`${label}[${index}]: размер изменён.`);
}
return [point[0]!, point[1]!, point[2]!] as const;
},
);
const sourcePoints = pointList(
payload.source_points_body_xyz_m,
"canonical_lab.spatial_frame.source_points",
);
const localSlam = pointList(
payload.local_slam_body_xyz_m,
"canonical_lab.spatial_frame.local_slam",
);
const sourcePointCount = integerValue(
payload.source_point_count,
"canonical_lab.spatial_frame.source_point_count",
);
if (sourcePointCount !== sourcePoints.length || sourcePointCount > 100_000 || localSlam.length > 10_000) {
throw new VegetationShadowContractError("Canonical LAB spatial accounting изменён.");
}
const bodyFrame = objectValue(payload.body_frame, "canonical_lab.spatial_frame.body_frame");
const origin = pointList(
[bodyFrame.origin_map_xyz_m],
"canonical_lab.spatial_frame.body_frame.origin",
)[0]!;
const basisRows = pointList(
bodyFrame.basis_map_from_body,
"canonical_lab.spatial_frame.body_frame.basis",
);
if (basisRows.length !== 3) {
throw new VegetationShadowContractError("Canonical LAB spatial basis изменён.");
}
return {
targetTimeNs,
sourceTimeNs: integerValue(payload.source_time_ns, "canonical_lab.spatial_frame.source_time_ns"),
poseTimeNs: integerValue(payload.pose_time_ns, "canonical_lab.spatial_frame.pose_time_ns"),
trajectoryTimeNs: integerValue(
payload.trajectory_time_ns,
"canonical_lab.spatial_frame.trajectory_time_ns",
),
sourcePointCount,
bodyFrame: {
originMapXyzM: origin,
basisMapFromBody: [basisRows[0]!, basisRows[1]!, basisRows[2]!],
},
sourcePointsBodyXyzM: sourcePoints,
localSlamBodyXyzM: localSlam,
};
}
export async function fetchVegetationShadowResult( export async function fetchVegetationShadowResult(
resultId: string, resultId: string,
{ {
@@ -33,29 +33,6 @@
pointer-events: auto; pointer-events: auto;
} }
.laboratory-recorded-clip-player__pane-controls {
position: absolute;
z-index: 6;
top: 0.6rem;
display: flex;
max-width: calc(100% - 1.2rem);
flex-wrap: wrap;
align-items: center;
gap: 0.38rem;
border-radius: var(--nodedc-radius-control-pill);
background: var(--nodedc-floating-surface);
padding: 0.28rem;
backdrop-filter: blur(var(--nodedc-blur-control));
}
.laboratory-recorded-clip-player__pane-controls[data-pane="spatial"] {
right: 0.6rem;
}
.laboratory-recorded-clip-player__pane-controls[data-pane="camera"] {
left: 0.6rem;
}
.laboratory-recorded-clip-player__split .laboratory-recorded-clip-player__split
> .nodedc-split-pane__separator::before { > .nodedc-split-pane__separator::before {
background: transparent; background: transparent;
@@ -1,15 +1,25 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { import {
Button, Button,
Icon, Icon,
IconButton, IconButton,
Select, Select,
SegmentedControl, SegmentedControl,
SplitPane,
type SplitPaneOrientation,
} from "@nodedc/ui-react"; } from "@nodedc/ui-react";
import { ObservationTimeline } from "../../components/ObservationTimeline"; import { ObservationTimeline } from "../../components/ObservationTimeline";
import {
CanonicalRecordedLabReplay,
useCanonicalRecordedLabReplayState,
} from "../../components/laboratory/CanonicalRecordedLabReplay";
import { import {
LaboratoryMetricEvidenceScene, LaboratoryMetricEvidenceScene,
type LaboratoryMetricCellEvidence, type LaboratoryMetricCellEvidence,
@@ -53,8 +63,6 @@ import { useE47SemanticTimelineFrame } from "./useE47SemanticTimeline";
import { buildM4StaticObstacleBoxes } from "./m4StaticObstacleBoxes"; import { buildM4StaticObstacleBoxes } from "./m4StaticObstacleBoxes";
type M4ThreatMediaMode = "video" | "camera"; type M4ThreatMediaMode = "video" | "camera";
type M4ThreatMediaSelection = M4ThreatMediaMode | "none";
type M4ThreatSpatialSelection = LaboratoryMetricSceneMode | "none";
function toneForProposal(proposal: M4ThreatCameraProposal): RecordedEvidenceBox["tone"] { function toneForProposal(proposal: M4ThreatCameraProposal): RecordedEvidenceBox["tone"] {
if (proposal.threatDecision === "threat") return "danger"; if (proposal.threatDecision === "threat") return "danger";
@@ -188,10 +196,21 @@ export function M4ReplayThreatVisual({
showSpatialOverlaySummary?: boolean; showSpatialOverlaySummary?: boolean;
onActiveSequenceChange?: (sequence: number | null) => void; onActiveSequenceChange?: (sequence: number | null) => void;
}) { }) {
const [mediaMode, setMediaMode] = useState<M4ThreatMediaMode | null>("video"); const {
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode | null>( mediaMode,
spatialMode,
splitView,
splitPrimarySize,
splitOrientation,
expanded,
onMediaModeChange: handleMediaModeChange,
onSpatialModeChange: handleSpatialModeChange,
onSplitPrimarySizeChange: setSplitPrimarySize,
onExpandedChange: setExpanded,
} = useCanonicalRecordedLabReplayState<M4ThreatMediaMode, LaboratoryMetricSceneMode>({
initialMediaMode: "video",
initialSpatialMode, initialSpatialMode,
); });
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true); const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
const [showLocalSurface, setShowLocalSurface] = useState(true); const [showLocalSurface, setShowLocalSurface] = useState(true);
const [showRollingMap, setShowRollingMap] = useState(true); const [showRollingMap, setShowRollingMap] = useState(true);
@@ -200,13 +219,6 @@ export function M4ReplayThreatVisual({
const [showSpatialSemantic, setShowSpatialSemantic] = useState(true); const [showSpatialSemantic, setShowSpatialSemantic] = useState(true);
const [showMediaPoints, setShowMediaPoints] = useState(false); const [showMediaPoints, setShowMediaPoints] = useState(false);
const [showStaticObstacles, setShowStaticObstacles] = useState(true); const [showStaticObstacles, setShowStaticObstacles] = useState(true);
const [splitPrimarySize, setSplitPrimarySize] = useState(50);
const [splitOrientation, setSplitOrientation] = useState<SplitPaneOrientation>(() => (
typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches
? "horizontal"
: "vertical"
));
const [expanded, setExpanded] = useState(false);
const [selectedReviewAnchorIndex, setSelectedReviewAnchorIndex] = useState(0); const [selectedReviewAnchorIndex, setSelectedReviewAnchorIndex] = useState(0);
const availableSemanticLayers = useMemo<readonly M4ReplayThreatSemanticLayer[]>( const availableSemanticLayers = useMemo<readonly M4ReplayThreatSemanticLayer[]>(
() => semanticLayers?.length ? semanticLayers : semantic ? [semantic] : [], () => semanticLayers?.length ? semanticLayers : semantic ? [semantic] : [],
@@ -266,7 +278,7 @@ export function M4ReplayThreatVisual({
endSeconds: metadata.timeline.timelineEndSeconds, endSeconds: metadata.timeline.timelineEndSeconds,
}) : null, [metadata.timeline]); }) : null, [metadata.timeline]);
const playbackController = useRecordedEvidencePlayback(playbackRange, { const playbackController = useRecordedEvidencePlayback(playbackRange, {
clock: mediaMode === "video" ? "external" : "animation", clock: "animation",
}); });
const seekPlayback = playbackController.seek; const seekPlayback = playbackController.seek;
const setPlaybackPlaying = playbackController.setPlaying; const setPlaybackPlaying = playbackController.setPlaying;
@@ -286,14 +298,6 @@ export function M4ReplayThreatVisual({
setVideoError(null); setVideoError(null);
}, [resultId]); }, [resultId]);
useEffect(() => {
const query = window.matchMedia("(max-width: 900px)");
const update = () => setSplitOrientation(query.matches ? "horizontal" : "vertical");
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
useEffect(() => { useEffect(() => {
const timeline = metadata.timeline; const timeline = metadata.timeline;
if (!evidenceDemand.recordedVideo) { if (!evidenceDemand.recordedVideo) {
@@ -739,15 +743,6 @@ export function M4ReplayThreatVisual({
} }
: undefined; : undefined;
const handleMediaModeChange = (next: M4ThreatMediaSelection) => {
if (next === "none") return;
setMediaMode((current) => current === next ? null : next);
};
const handleSpatialModeChange = (next: M4ThreatSpatialSelection) => {
if (next === "none") return;
setSpatialMode((current) => current === next ? null : next);
};
useEffect(() => { useEffect(() => {
if ( if (
playbackController.playback.playing playbackController.playback.playing
@@ -758,36 +753,6 @@ export function M4ReplayThreatVisual({
image.src = frame.cameraUrl; image.src = frame.cameraUrl;
}, [evidenceDemand.exactCameraFrame, frame?.cameraUrl, playbackController.playback.playing]); }, [evidenceDemand.exactCameraFrame, frame?.cameraUrl, playbackController.playback.playing]);
const splitView = mediaMode !== null && spatialMode !== null;
const mediaModeControls = (
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="media">
<SegmentedControl
value={mediaMode ?? "none"}
items={[
{ value: "video", label: "VIDEO" },
{ value: "camera", label: "CAMERA" },
]}
label="Видео и камера"
onChange={handleMediaModeChange}
/>
</div>
);
const spatialModeControls = (
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="spatial">
<SegmentedControl
value={spatialMode ?? "none"}
items={[
{ value: "3d", label: "3D" },
{ value: "plan", label: "PLAN" },
]}
label="3D и план"
onChange={handleSpatialModeChange}
/>
</div>
);
const mediaLayerControls = activeSemantic const mediaLayerControls = activeSemantic
|| (showReferenceMediaLayers && metadata.timeline?.cameraPointDelivery) || (showReferenceMediaLayers && metadata.timeline?.cameraPointDelivery)
|| (showReferenceMediaLayers && metadata.timeline?.cameraObstacleProjectionDelivery) ? ( || (showReferenceMediaLayers && metadata.timeline?.cameraObstacleProjectionDelivery) ? (
@@ -1110,7 +1075,12 @@ export function M4ReplayThreatVisual({
) : undefined; ) : undefined;
const timeline = metadata.timeline; const timeline = metadata.timeline;
let content; let content: ReactNode = null;
let canonicalContent: {
mediaContent: ReactNode;
spatialContent: ReactNode;
deckOverlays: ReactNode;
} | null = null;
if (metadata.error) { if (metadata.error) {
content = <SpatialState message={metadata.error} />; content = <SpatialState message={metadata.error} />;
} else if (!timeline) { } else if (!timeline) {
@@ -1121,23 +1091,8 @@ export function M4ReplayThreatVisual({
</div> </div>
); );
} else { } else {
const mediaPane = ( const mediaContent = (
<section <>
className="m4-replay-threat-visual__pane"
data-pane="media"
aria-label={mediaMode === "camera" ? "Камера" : "Видео"}
hidden={!mediaMode}
>
{splitView ? (
<div
className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="media"
data-multi-semantic={availableSemanticLayers.length > 1 ? "true" : undefined}
>
{mediaLayerControls}
{mediaModeControls}
</div>
) : null}
<div <div
className="m4-replay-threat-visual__media-layer" className="m4-replay-threat-visual__media-layer"
data-media="video" data-media="video"
@@ -1161,7 +1116,8 @@ export function M4ReplayThreatVisual({
} }
segmentCount={timeline.frameCount} segmentCount={timeline.frameCount}
onPlaybackChange={playbackController.synchronize} onPlaybackChange={playbackController.synchronize}
onPlayingRejected={() => playbackController.setPlaying(false)} playbackAuthority="host"
playbackTransport="epoch-stream"
/> />
) : videoError ? ( ) : videoError ? (
<SpatialState message={videoError} /> <SpatialState message={videoError} />
@@ -1183,27 +1139,11 @@ export function M4ReplayThreatVisual({
ariaLabel={`${evidenceLabel} exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`} ariaLabel={`${evidenceLabel} exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
/> />
) : null} ) : null}
</section> </>
); );
const spatialPane = spatialMode ? ( const spatialContent = spatialMode ? (
<section <>
className="m4-replay-threat-visual__pane"
data-pane="spatial"
aria-label={spatialMode === "3d" ? "Трёхмерная сцена" : "Вид сверху"}
>
{splitView ? (
<div
className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="spatial"
>
{resetSpatialView}
<div className="m4-replay-threat-visual__spatial-toolbar-end">
{spatialLayerControls}
{spatialModeControls}
</div>
</div>
) : null}
<LaboratoryMetricEvidenceScene <LaboratoryMetricEvidenceScene
ref={metricSceneRef} ref={metricSceneRef}
pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
@@ -1265,31 +1205,11 @@ export function M4ReplayThreatVisual({
: `На кадре ${frame.sequence + 1} нет body frame; ждём первый квалифицированный spatial evidence.`} : `На кадре ${frame.sequence + 1} нет body frame; ждём первый квалифицированный spatial evidence.`}
</div> </div>
) : null} ) : null}
</section> </>
) : null; ) : null;
content = ( const deckOverlays = (
<div <>
className="m4-replay-threat-visual__deck"
data-split={splitView ? "true" : undefined}
data-empty={!mediaMode && !spatialMode ? "true" : undefined}
>
<SplitPane
primary={mediaPane}
secondary={spatialPane ?? <div />}
primarySize={splitView ? splitPrimarySize : mediaMode ? 100 : 0}
onPrimarySizeChange={setSplitPrimarySize}
orientation={splitOrientation}
minPrimarySize={splitView ? 24 : 0}
minSecondarySize={splitView ? 24 : 0}
resizable={splitView}
separatorLabel="Изменить размер VIDEO/CAMERA и 3D/PLAN"
/>
{!mediaMode && !spatialMode ? (
<div className="l3-visual-audit__state" role="status">
Выберите VIDEO/CAMERA или 3D/PLAN. Общий таймлайн останется на месте.
</div>
) : null}
{timelineFrame.loading || displayingBufferedFrame ? ( {timelineFrame.loading || displayingBufferedFrame ? (
<div className="m4-replay-threat-visual__buffering" role="status"> <div className="m4-replay-threat-visual__buffering" role="status">
<span className="busy-indicator" aria-hidden="true" /> <span className="busy-indicator" aria-hidden="true" />
@@ -1320,8 +1240,9 @@ export function M4ReplayThreatVisual({
<span>{semanticIntegrityError}</span> <span>{semanticIntegrityError}</span>
</div> </div>
) : null} ) : null}
</div> </>
); );
canonicalContent = { mediaContent, spatialContent, deckOverlays };
} }
const transport = timeline ? ( const transport = timeline ? (
@@ -1346,38 +1267,59 @@ export function M4ReplayThreatVisual({
/> />
) : undefined; ) : undefined;
if (!timeline || !canonicalContent) {
return (
<div className="l3-visual-audit m4-replay-threat-visual">
<LaboratoryEvidenceViewer
label={`${evidenceLabel} recorded-realtime replay`}
className="m4-replay-threat-evidence-viewer"
mode="video"
modes={[{ value: "video", label: "VIDEO" }]}
expanded={expanded}
onModeChange={() => undefined}
onExpandedChange={setExpanded}
>
{content}
</LaboratoryEvidenceViewer>
</div>
);
}
return ( return (
<div className="l3-visual-audit m4-replay-threat-visual"> <CanonicalRecordedLabReplay
<LaboratoryEvidenceViewer label={activeSemantic
label={activeSemantic ? activeSemantic.label ?? "Semantic diagnostic replay"
? activeSemantic.label ?? "Semantic diagnostic replay" : `${evidenceLabel} recorded-realtime replay`}
: `${evidenceLabel} recorded-realtime replay`} mediaMode={mediaMode ?? "none"}
className="m4-replay-threat-evidence-viewer" mediaModes={[
mode={mediaMode ?? "none"} { value: "video", label: "VIDEO" },
modes={[ { value: "camera", label: "CAMERA" },
{ value: "video", label: "VIDEO" }, ]}
{ value: "camera", label: "CAMERA" }, spatialMode={spatialMode ?? "none"}
]} spatialModes={[
secondaryMode={{ { value: "3d", label: "3D" },
value: spatialMode ?? "none", { value: "plan", label: "PLAN" },
modes: [ ]}
{ value: "3d", label: "3D" }, expanded={expanded}
{ value: "plan", label: "PLAN" }, splitPrimarySize={splitPrimarySize}
], splitOrientation={splitOrientation}
label: "3D и план", mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео"}
onChange: handleSpatialModeChange, spatialAriaLabel={spatialMode === "3d" ? "Трёхмерная сцена" : "Вид сверху"}
}} mediaLayerControls={mediaLayerControls}
expanded={expanded} spatialLayerControls={spatialLayerControls}
onModeChange={handleMediaModeChange} spatialLeadingControl={resetSpatialView}
onExpandedChange={setExpanded} mediaMultiLayer={availableSemanticLayers.length > 1}
modeControlsVisible={!splitView} mediaContent={canonicalContent.mediaContent}
actions={actions} spatialContent={canonicalContent.spatialContent}
overlay={overlay} emptyMessage="Выберите VIDEO/CAMERA или 3D/PLAN. Общий таймлайн останется на месте."
transport={transport} deckOverlays={canonicalContent.deckOverlays}
trailingActions={trailingActions} actions={actions}
> overlay={overlay}
{content} transport={transport}
</LaboratoryEvidenceViewer> trailingActions={trailingActions}
</div> onMediaModeChange={handleMediaModeChange}
onSpatialModeChange={handleSpatialModeChange}
onExpandedChange={setExpanded}
onSplitPrimarySizeChange={setSplitPrimarySize}
/>
); );
} }
@@ -1,9 +1,29 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
import { Button, SegmentedControl } from "@nodedc/ui-react"; useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import {
Button,
Icon,
IconButton,
SegmentedControl,
} from "@nodedc/ui-react";
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer"; import { ObservationTimeline } from "../../components/ObservationTimeline";
import { LaboratoryRecordedClipPlayer } from "../../components/laboratory/LaboratoryRecordedClipPlayer"; import {
import { RerunViewport } from "../../components/RerunViewport"; CanonicalRecordedLabReplay,
useCanonicalRecordedLabReplayState,
} from "../../components/laboratory/CanonicalRecordedLabReplay";
import {
LaboratoryMetricEvidenceScene,
type LaboratoryMetricEvidenceSceneHandle,
type LaboratoryMetricPackedCellEvidence,
} from "../../components/laboratory/LaboratoryMetricEvidenceScene";
import { RecordedEvidenceVideoScene } from "../../components/laboratory/RecordedEvidenceVideoScene";
import { useRecordedEvidencePlayback } from "../../components/laboratory/useRecordedEvidencePlayback";
import { import {
LaboratoryEvidence, LaboratoryEvidence,
LaboratoryResultSummary, LaboratoryResultSummary,
@@ -11,18 +31,21 @@ import {
LaboratoryWorkTemplate, LaboratoryWorkTemplate,
} from "../../components/laboratory/LaboratoryPresentation"; } from "../../components/laboratory/LaboratoryPresentation";
import { import {
RecordedEvidenceSemanticMaskOverlay,
type RecordedEvidenceSemanticClass, type RecordedEvidenceSemanticClass,
type RecordedEvidenceSemanticPaletteEntry, type RecordedEvidenceSemanticPaletteEntry,
} from "../../components/laboratory/RecordedEvidenceSemanticMaskOverlay"; } from "../../components/laboratory/RecordedEvidenceSemanticMaskOverlay";
import { import {
fetchCanonicalRecordedLabSpatialFrame,
fetchVegetationShadowResult, fetchVegetationShadowResult,
fetchVegetationRouteTgsAnchor,
vegetationFullRouteMaskUrl, vegetationFullRouteMaskUrl,
vegetationVideoMaskUrl, vegetationVideoMaskUrl,
type CanonicalRecordedLabSpatialFrame,
type VegetationFullRouteLayer, type VegetationFullRouteLayer,
type VegetationFullRouteReview, type VegetationFullRouteReview,
type VegetationMixedRouteCase, type VegetationMixedRouteCase,
type VegetationMixedRouteReview, type VegetationMixedRouteReview,
type VegetationRouteTgsAnchor,
type VegetationShadowResult, type VegetationShadowResult,
} from "../../core/laboratory/vegetationShadow"; } from "../../core/laboratory/vegetationShadow";
import { import {
@@ -33,16 +56,7 @@ import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence";
import { recordedObservationSources } from "../../core/observation/recordedObservationSources"; import { recordedObservationSources } from "../../core/observation/recordedObservationSources";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive"; import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions"; import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
import {
recordedSessionRerunProfile,
type RerunPlaybackController,
} from "../../core/observation/viewerProfile";
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts"; import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
import { defaultSceneSettings, type SceneSettings } from "../../sceneSettings";
import {
M48EvidenceModeRail,
type M48BlindEvidenceMode,
} from "./annotation/M48EvidenceModeControls";
function decimal(value: number, digits = 1): string { function decimal(value: number, digits = 1): string {
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits }); return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
@@ -53,6 +67,19 @@ const FULL_ROUTE_SEMANTIC_MODES = [
{ value: "vegetation", label: "ПРИРОДА · DDRNet" }, { value: "vegetation", label: "ПРИРОДА · DDRNet" },
] as const; ] as const;
type FullRouteMediaMode = "video" | "camera";
type FullRouteSpatialMode = "3d" | "plan";
const FULL_ROUTE_MEDIA_MODES = [
{ value: "video", label: "VIDEO" },
{ value: "camera", label: "CAMERA" },
] as const;
const FULL_ROUTE_SPATIAL_MODES = [
{ value: "3d", label: "3D" },
{ value: "plan", label: "PLAN" },
] as const;
function semanticPresentation(layer: VegetationFullRouteLayer): { function semanticPresentation(layer: VegetationFullRouteLayer): {
classes: readonly RecordedEvidenceSemanticClass[]; classes: readonly RecordedEvidenceSemanticClass[];
palette: readonly RecordedEvidenceSemanticPaletteEntry[]; palette: readonly RecordedEvidenceSemanticPaletteEntry[];
@@ -68,17 +95,125 @@ function semanticPresentation(layer: VegetationFullRouteLayer): {
}; };
} }
function nearestTgsCase( function causalTgsCase(
cases: readonly VegetationMixedRouteCase[], cases: readonly VegetationMixedRouteCase[],
sequence: number, sequence: number,
): VegetationMixedRouteCase | null { ): VegetationMixedRouteCase | null {
return cases.reduce<VegetationMixedRouteCase | null>((nearest, candidate) => ( if (!cases.length) return null;
!nearest return cases.reduce<VegetationMixedRouteCase | null>((latest, candidate) => (
|| Math.abs(candidate.sourceSequence - sequence) candidate.sourceSequence <= sequence
< Math.abs(nearest.sourceSequence - sequence) && (!latest || candidate.sourceSequence > latest.sourceSequence)
? candidate ? candidate
: nearest : latest
), null); ), null) ?? cases.reduce((first, candidate) => (
candidate.sourceSequence < first.sourceSequence ? candidate : first
));
}
function nearestFullRouteFrameIndex(
frameSourceTimesNs: readonly number[],
sourceTimeNs: number,
): number {
if (!frameSourceTimesNs.length) return 0;
let low = 0;
let high = frameSourceTimesNs.length - 1;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if ((frameSourceTimesNs[middle] ?? 0) < sourceTimeNs) low = middle + 1;
else high = middle;
}
if (low === 0) return 0;
const previous = frameSourceTimesNs[low - 1] ?? frameSourceTimesNs[0] ?? 0;
const current = frameSourceTimesNs[low] ?? previous;
return Math.abs(sourceTimeNs - previous) <= Math.abs(current - sourceTimeNs)
? low - 1
: low;
}
function useCanonicalRavSpatialFrame(
review: VegetationFullRouteReview,
replayLaunch: ObservationSessionReplayLaunch | null,
targetTimeNs: number,
) {
const [frame, setFrame] = useState<CanonicalRecordedLabSpatialFrame | null>(null);
const [error, setError] = useState<string | null>(null);
const desiredRef = useRef<number | null>(null);
const runningRef = useRef(false);
const mountedRef = useRef(true);
const cacheRef = useRef(new Map<number, CanonicalRecordedLabSpatialFrame>());
const pumpRef = useRef<() => void>(() => undefined);
pumpRef.current = () => {
if (runningRef.current || desiredRef.current === null || !replayLaunch) return;
runningRef.current = true;
let settledTimeNs: number | null = null;
void (async () => {
while (mountedRef.current && desiredRef.current !== null) {
const requestedTimeNs = desiredRef.current;
const cached = cacheRef.current.get(requestedTimeNs);
try {
const next = cached ?? await fetchCanonicalRecordedLabSpatialFrame(
review.sessionId,
replayLaunch.sha256,
requestedTimeNs,
);
if (!cached) {
cacheRef.current.set(requestedTimeNs, next);
while (cacheRef.current.size > 12) {
const oldest = cacheRef.current.keys().next().value as number | undefined;
if (oldest === undefined) break;
cacheRef.current.delete(oldest);
}
}
if (!mountedRef.current) break;
setFrame(next);
setError(null);
} catch (caught: unknown) {
if (!mountedRef.current) break;
setError(caught instanceof Error ? caught.message : "Spatial-слои RAV004 недоступны.");
}
settledTimeNs = requestedTimeNs;
if (desiredRef.current === requestedTimeNs) break;
}
})().finally(() => {
runningRef.current = false;
if (
mountedRef.current
&& desiredRef.current !== null
&& desiredRef.current !== settledTimeNs
) {
pumpRef.current();
}
});
};
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
desiredRef.current = null;
};
}, []);
useEffect(() => {
cacheRef.current.clear();
setFrame(null);
setError(null);
}, [replayLaunch?.sha256, review.sessionId]);
useEffect(() => {
if (!replayLaunch) return;
desiredRef.current = targetTimeNs;
const cached = cacheRef.current.get(targetTimeNs);
if (cached) {
setFrame(cached);
setError(null);
return;
}
pumpRef.current();
}, [replayLaunch, targetTimeNs]);
return { frame, error, loading: Boolean(replayLaunch) && !frame && !error };
} }
function FullRouteReviewEvidence({ function FullRouteReviewEvidence({
@@ -88,41 +223,57 @@ function FullRouteReviewEvidence({
resultId: string; resultId: string;
review: VegetationFullRouteReview; review: VegetationFullRouteReview;
}) { }) {
const [sequence, setSequence] = useState(1); const {
const [playing, setPlaying] = useState(false); mediaMode,
const [playbackRate, setPlaybackRate] = useState(1); spatialMode,
splitView,
splitPrimarySize,
splitOrientation,
expanded,
onMediaModeChange: handleMediaModeChange,
onSpatialModeChange: handleSpatialModeChange,
onSplitPrimarySizeChange: setSplitPrimarySize,
onExpandedChange: setExpanded,
} = useCanonicalRecordedLabReplayState<FullRouteMediaMode, FullRouteSpatialMode>({
initialMediaMode: "video",
initialSpatialMode: "3d",
});
const [semanticLayer, setSemanticLayer] = useState<"city" | "vegetation">("vegetation"); const [semanticLayer, setSemanticLayer] = useState<"city" | "vegetation">("vegetation");
const [showCameraSemantic, setShowCameraSemantic] = useState(true); const [showCameraSemantic, setShowCameraSemantic] = useState(true);
const [expanded, setExpanded] = useState(false); const [showSourcePoints, setShowSourcePoints] = useState(true);
const [evidenceMode, setEvidenceMode] = useState<M48BlindEvidenceMode>("3d"); const [showLocalSlam, setShowLocalSlam] = useState(true);
const [cameraVisible, setCameraVisible] = useState(true); const [showTgs, setShowTgs] = useState(true);
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(() => ({
...defaultSceneSettings,
accumulationSeconds: 12,
showPoints: true,
showTrajectory: true,
}));
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null); const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
const [replayLaunch, setReplayLaunch] = useState<ObservationSessionReplayLaunch | null>(null); const [replayLaunch, setReplayLaunch] = useState<ObservationSessionReplayLaunch | null>(null);
const [videoError, setVideoError] = useState<string | null>(null); const [videoError, setVideoError] = useState<string | null>(null);
const [linkedReview, setLinkedReview] = useState<VegetationMixedRouteReview | null>(null); const [linkedReview, setLinkedReview] = useState<VegetationMixedRouteReview | null>(null);
const [linkedReviewError, setLinkedReviewError] = useState<string | null>(null); const [linkedReviewError, setLinkedReviewError] = useState<string | null>(null);
const spatialControllerRef = useRef<RerunPlaybackController | null>(null); const [tgsAnchor, setTgsAnchor] = useState<VegetationRouteTgsAnchor | null>(null);
const frames = useMemo( const [tgsAnchorLoading, setTgsAnchorLoading] = useState(false);
() => review.frameSourceTimesNs.map((sourceTimeNs, index) => ({ const [tgsAnchorError, setTgsAnchorError] = useState<string | null>(null);
sequence: index + 1, const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
sourceTimeNs, const playbackRange = useMemo(() => ({
})), startSeconds: review.timelineStartSeconds,
[review.frameSourceTimesNs], endSeconds: review.timelineEndSeconds,
}), [review.timelineEndSeconds, review.timelineStartSeconds]);
const playbackController = useRecordedEvidencePlayback(playbackRange, { clock: "animation" });
const sequenceIndex = nearestFullRouteFrameIndex(
review.frameSourceTimesNs,
Math.round(playbackController.playback.currentSeconds * 1_000_000_000),
); );
const sequence = sequenceIndex + 1;
const spatialRequestIndex = Math.floor(sequenceIndex / 5) * 5;
const spatialRequestTimeNs = review.frameSourceTimesNs[spatialRequestIndex]
?? review.frameSourceTimesNs[sequenceIndex]
?? Math.round(playbackController.playback.currentSeconds * 1_000_000_000);
const spatialEvidence = useCanonicalRavSpatialFrame(review, replayLaunch, spatialRequestTimeNs);
const layer = review[semanticLayer]; const layer = review[semanticLayer];
const semantic = useMemo(() => semanticPresentation(layer), [layer]); const semantic = useMemo(() => semanticPresentation(layer), [layer]);
const maskSequence = sequence - 1;
const prefetchSrcs = useMemo(() => showCameraSemantic const prefetchSrcs = useMemo(() => showCameraSemantic
? Array.from({ length: 8 }, (_, offset) => maskSequence + offset + 1) ? Array.from({ length: 8 }, (_, offset) => sequenceIndex + offset + 1)
.filter((candidate) => candidate < review.frameCount) .filter((candidate) => candidate < review.frameCount)
.map((candidate) => vegetationFullRouteMaskUrl(resultId, semanticLayer, candidate)) .map((candidate) => vegetationFullRouteMaskUrl(resultId, semanticLayer, candidate))
: [], [maskSequence, resultId, review.frameCount, semanticLayer, showCameraSemantic]); : [], [resultId, review.frameCount, semanticLayer, sequenceIndex, showCameraSemantic]);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@@ -185,246 +336,326 @@ function FullRouteReviewEvidence({
return () => controller.abort(); return () => controller.abort();
}, [review.linkedRouteReviewResultId, review.sessionId, review.sourceId]); }, [review.linkedRouteReviewResultId, review.sessionId, review.sourceId]);
const spatialProfile = useMemo(() => replayLaunch ? recordedSessionRerunProfile({ const selectedTgsCase = linkedReview
sourceUrl: replayLaunch.viewerSourceUrl, ? causalTgsCase(linkedReview.cases, sequence)
artifact: { : null;
sourceUrl: replayLaunch.sourceUrl, const selectedTgsTimeNs = selectedTgsCase
viewerSourceUrl: replayLaunch.viewerSourceUrl, ? review.frameSourceTimesNs[selectedTgsCase.sourceSequence - 1]
byteLength: replayLaunch.byteLength, ?? Math.round(selectedTgsCase.sessionSeconds * 1_000_000_000)
sha256: replayLaunch.sha256, : spatialRequestTimeNs;
}, const tgsReferenceEvidence = useCanonicalRavSpatialFrame(
autoplayWhenReady: false, review,
presentationGate: "ready", replayLaunch,
expectedTimelineStartSeconds: replayLaunch.timelineStartSeconds, selectedTgsTimeNs,
expectedTimelineEndSeconds: replayLaunch.timelineEndSeconds, );
initialPlaybackStartSeconds: review.timelineStartSeconds,
view: "spatial",
viewResetGeneration: 0,
followTrajectory: false,
perceptionLayers: {
enabled: false,
detections2d: false,
segmentation: false,
cuboids3d: false,
},
perceptionRetryGeneration: 0,
lockPerceptionCameraInteraction: false,
}) : null, [replayLaunch, review.timelineStartSeconds]);
const activeFrame = frames.find((candidate) => candidate.sequence === sequence)
?? frames[0]
?? null;
const activeSourceTimeNsRef = useRef(activeFrame?.sourceTimeNs ?? null);
activeSourceTimeNsRef.current = activeFrame?.sourceTimeNs ?? null;
const handleSpatialControllerChange = useCallback((controller: RerunPlaybackController | null) => {
spatialControllerRef.current = controller;
const sourceTimeNs = activeSourceTimeNsRef.current;
if (!controller || sourceTimeNs === null) return;
controller.setPlaying(false);
controller.seek(sourceTimeNs);
}, []);
useEffect(() => { useEffect(() => {
const controller = spatialControllerRef.current; if (!showTgs || !selectedTgsCase) {
if (!controller || !activeFrame) return; setTgsAnchor(null);
controller.setPlaying(false); setTgsAnchorLoading(false);
controller.seek(activeFrame.sourceTimeNs); setTgsAnchorError(null);
}, [activeFrame]);
const selectedTgsCase = linkedReview
? nearestTgsCase(linkedReview.cases, sequence)
: null;
const selectTgsPlan = useCallback(() => {
if (!linkedReview) return;
const item = nearestTgsCase(linkedReview.cases, sequence);
if (!item) return;
setPlaying(false);
setSequence(item.sourceSequence);
setEvidenceMode("plan");
}, [linkedReview, sequence]);
const handleEvidenceModeChange = useCallback((nextMode: M48BlindEvidenceMode) => {
if (nextMode === "plan") {
selectTgsPlan();
return; return;
} }
setEvidenceMode(nextMode); const controller = new AbortController();
}, [selectTgsPlan]); setTgsAnchorLoading(true);
const cameraPresentation = evidenceMode === "camera" setTgsAnchorError(null);
? "primary" void fetchVegetationRouteTgsAnchor(
: cameraVisible ? "companion" : "hidden"; review.linkedRouteReviewResultId,
const spatialScene = evidenceMode === "plan" ? ( selectedTgsCase.sourceSequence,
selectedTgsCase ? ( { signal: controller.signal },
<div className="recorded-evidence-image-scene"> ).then((anchor) => {
<img if (!controller.signal.aborted) setTgsAnchor(anchor);
src={selectedTgsCase.assets.tgs} }).catch((caught: unknown) => {
alt={`TGS costmap · sequence ${selectedTgsCase.sourceSequence}`} if (!controller.signal.aborted) {
draggable={false} setTgsAnchorError(caught instanceof Error ? caught.message : "TGS anchor недоступен.");
}
}).finally(() => {
if (!controller.signal.aborted) setTgsAnchorLoading(false);
});
return () => controller.abort();
}, [review.linkedRouteReviewResultId, selectedTgsCase?.sourceSequence, showTgs]);
const packedTgsCells = useMemo<LaboratoryMetricPackedCellEvidence | undefined>(() => {
if (!tgsAnchor) return undefined;
const currentBody = spatialEvidence.frame?.bodyFrame;
const anchorBody = tgsReferenceEvidence.frame?.bodyFrame;
const transformPoint = (point: readonly [number, number, number]) => {
if (!currentBody || !anchorBody) return point;
const map = [0, 1, 2].map((row) => (
anchorBody.originMapXyzM[row]!
+ anchorBody.basisMapFromBody[row]!.reduce(
(sum, coefficient, column) => sum + coefficient * point[column]!,
0,
)
));
const delta = map.map((value, index) => value - currentBody.originMapXyzM[index]!);
return [0, 1, 2].map((column) => (
currentBody.basisMapFromBody.reduce(
(sum, row, rowIndex) => sum + row[column]! * delta[rowIndex]!,
0,
)
)) as [number, number, number];
};
const centers: number[] = [];
const zBounds: number[] = [];
tgsAnchor.costmap.centersXyM.forEach(([x, y], index) => {
const bounds = tgsAnchor.costmap.zBoundsM[index] ?? [null, null];
const center = transformPoint([x, y, 0]);
centers.push(center[0], center[1]);
if (bounds[0] === null || bounds[1] === null) {
zBounds.push(Number.NaN, Number.NaN);
} else {
const bottom = transformPoint([x, y, bounds[0]]);
const top = transformPoint([x, y, bounds[1]]);
zBounds.push(Math.min(bottom[2], top[2]), Math.max(bottom[2], top[2]));
}
});
return {
centersBodyXyM: Float32Array.from(centers),
zBoundsM: Float32Array.from(zBounds),
stateCodes: Uint8Array.from(tgsAnchor.costmap.stateCodes),
};
}, [spatialEvidence.frame?.bodyFrame, tgsAnchor, tgsReferenceEvidence.frame?.bodyFrame]);
const semanticOverlay = showCameraSemantic ? {
src: vegetationFullRouteMaskUrl(resultId, semanticLayer, sequenceIndex),
prefetchSrcs,
classes: semantic.classes,
palette: semantic.palette,
opacity: 0.76,
ariaLabel: `${layer.name} semantic prediction frame ${sequence}`,
} : undefined;
const mediaContent = (
<div className="m4-replay-threat-visual__media-layer" data-media={mediaMode ?? "none"}>
{videoSource ? (
<RecordedEvidenceVideoScene
source={videoSource}
playback={playbackController.playback}
imageWidth={review.width}
imageHeight={review.height}
boxes={[]}
semanticOverlay={semanticOverlay}
ariaLabel={`RAVNOVES004TREE recorded frame ${sequence}`}
interactive={false}
segmentSequence={sequence}
segmentCount={review.frameCount}
onPlaybackChange={playbackController.synchronize}
playbackAuthority="host"
playbackTransport="epoch-stream"
/> />
<div className="m48-clip-player__pane-label" data-pane="spatial"> ) : (
TGS COSTMAP · ЯКОРЬ {selectedTgsCase.sourceSequence} · {selectedTgsCase.tgs.occupiedCells} OCCUPIED <div className="l3-visual-audit__state" role={videoError ? "alert" : "status"}>
{videoError ?? "Открываем автономный RAVNOVES004TREE source…"}
</div> </div>
</div> )}
) : (
<div className="m4-replay-threat-visual__pane-status" role="status">
{linkedReviewError ?? "Открываем sealed TGS anchors…"}
</div>
)
) : spatialProfile ? (
<RerunViewport
profile={spatialProfile}
sceneSettings={sceneSettings}
onPlaybackControllerChange={handleSpatialControllerChange}
/>
) : (
<div className="m4-replay-threat-visual__pane-status" role="status">
Открываем sealed RRD, source points и SLAM trajectory
</div> </div>
); );
return ( const spatialContent = spatialMode ? (
<LaboratoryEvidenceViewer <>
label="RAVNOVES004TREE full recorded review" {spatialEvidence.frame ? (
className="m48-atlas-visual" <LaboratoryMetricEvidenceScene
mode={semanticLayer} ref={metricSceneRef}
modes={FULL_ROUTE_SEMANTIC_MODES} pointCloudBodyXyzM={showSourcePoints
expanded={expanded} ? spatialEvidence.frame.sourcePointsBodyXyzM
onModeChange={setSemanticLayer} : []}
onExpandedChange={setExpanded} localSurfaceBodyXyzM={showLocalSlam
modeControlsVisible={false} ? spatialEvidence.frame.localSlamBodyXyzM
chromeLayout="stacked" : []}
obstacles={[]}
rig={{ lengthM: 1, widthM: 0.8, nominalSensorHeightM: 0.4 }}
corridor={{ forwardLengthM: 12, rearMarginM: 1, halfWidthM: 0.4 }}
occupiedVoxelSizeM={tgsAnchor?.costmap.cellSizeM ?? 0.45}
mode={spatialMode}
label="RAV004 canonical source points, local SLAM and TGS costmap"
showCurrentIncrement={showSourcePoints}
showLocalSurface={showLocalSlam}
showRollingMap={showTgs}
showLowStep={false}
classifiedPackedCells={packedTgsCells}
classifiedCellSizeM={tgsAnchor?.costmap.cellSizeM}
showClassifiedCells={showTgs && Boolean(packedTgsCells)}
/>
) : (
<div className="l3-visual-audit__state" role={spatialEvidence.error ? "alert" : "status"}>
{spatialEvidence.loading ? <span className="busy-indicator" aria-hidden="true" /> : null}
<span>{spatialEvidence.error ?? "Открываем source points и Local SLAM из sealed RRD…"}</span>
</div>
)}
{showTgs && selectedTgsCase ? (
<div className="m4-replay-threat-visual__pane-status" role="status">
{tgsAnchorError ?? linkedReviewError ?? (tgsAnchorLoading
? `Открываем sealed TGS anchor ${selectedTgsCase.sourceSequence}; source/SLAM и общий clock продолжаются.`
: `TGS anchor ${selectedTgsCase.sourceSequence} из 10; source/SLAM и общий clock продолжаются.`)}
</div>
) : null}
</>
) : null;
const mediaLayerControls = (
<div
className="m4-replay-threat-visual__pane-layer-controls"
role="group"
aria-label="Слои камеры и видео"
> >
<div className="m48-evidence-stage"> <Button
{videoSource ? ( size="compact"
<LaboratoryRecordedClipPlayer shape="pill"
source={videoSource} variant={showCameraSemantic ? "primary" : "secondary"}
segmentCount={review.frameCount} aria-pressed={showCameraSemantic}
frames={frames} onClick={() => setShowCameraSemantic((visible) => !visible)}
sequence={sequence} >
playing={playing} SEMANTICS
playbackRate={playbackRate} </Button>
cameraPresentation={cameraPresentation} <SegmentedControl
continuousPlayback value={semanticLayer}
sourceCount={4} items={[...FULL_ROUTE_SEMANTIC_MODES]}
onSequenceChange={setSequence} label="Источник семантики"
onPlayingChange={setPlaying} onChange={(value) => {
onPlaybackRateChange={setPlaybackRate} setSemanticLayer(value);
alternativeScene={spatialScene} setShowCameraSemantic(true);
cameraControls={( }}
<> />
<Button </div>
size="compact" );
shape="pill"
variant={showCameraSemantic ? "primary" : "secondary"} const spatialLayerControls = (
aria-pressed={showCameraSemantic} <div
onClick={() => setShowCameraSemantic((visible) => !visible)} className="m4-replay-threat-visual__pane-layer-controls"
> role="group"
SEMANTICS aria-label="Слои 3D и плана"
</Button> >
<SegmentedControl <Button
value={semanticLayer} size="compact"
items={[...FULL_ROUTE_SEMANTIC_MODES]} shape="pill"
label="Источник семантики" variant={showSourcePoints ? "primary" : "secondary"}
onChange={(value) => { aria-pressed={showSourcePoints}
setSemanticLayer(value); onClick={() => setShowSourcePoints((visible) => !visible)}
setShowCameraSemantic(true); >
}} SOURCE POINTS
/> </Button>
</> <Button
)} size="compact"
spatialControls={( shape="pill"
<> variant={showLocalSlam ? "primary" : "secondary"}
<Button aria-pressed={showLocalSlam}
size="compact" onClick={() => setShowLocalSlam((visible) => !visible)}
shape="pill" >
variant={sceneSettings.showPoints ? "primary" : "secondary"} LOCAL SLAM
aria-pressed={sceneSettings.showPoints} </Button>
onClick={() => { <Button
setEvidenceMode("3d"); size="compact"
setSceneSettings((settings) => ({ shape="pill"
...settings, variant={showTgs ? "primary" : "secondary"}
showPoints: !settings.showPoints, aria-pressed={showTgs}
})); disabled={!linkedReview}
}} title={linkedReviewError ?? "10 sealed causal TGS anchors; continuous TGS отсутствует"}
> onClick={() => setShowTgs((visible) => !visible)}
SOURCE POINTS >
</Button> TGS COSTMAP
<Button </Button>
size="compact" <Button
shape="pill" size="compact"
variant={sceneSettings.showTrajectory ? "primary" : "secondary"} shape="pill"
aria-pressed={sceneSettings.showTrajectory} variant={showCameraSemantic ? "primary" : "secondary"}
onClick={() => { aria-pressed={showCameraSemantic}
setEvidenceMode("3d"); title="Recorded semantic layer; camera-aligned prediction, без выдуманной 3D-проекции"
setSceneSettings((settings) => ({ onClick={() => setShowCameraSemantic((visible) => !visible)}
...settings, >
showTrajectory: !settings.showTrajectory, SEMANTICS
})); </Button>
}} </div>
> );
LOCAL SLAM
</Button> const resetSpatialView = (
<Button <IconButton
size="compact" label="Сбросить ракурс"
shape="pill" onClick={() => metricSceneRef.current?.resetView()}
variant={evidenceMode === "plan" ? "primary" : "secondary"} >
aria-pressed={evidenceMode === "plan"} <Icon name="refresh" size={16} />
disabled={!linkedReview} </IconButton>
title={linkedReviewError ?? "10 sealed causal TGS anchors; continuous TGS отсутствует"} );
onClick={selectTgsPlan}
> const overlayPanePercent = splitView && splitOrientation === "vertical"
TGS COSTMAP ? splitPrimarySize
</Button> : 100;
<Button const overlay = (
size="compact" <div
shape="pill" className="l3-visual-audit__overlay m4-replay-threat-visual__overlay"
variant="secondary" style={{
disabled "--m4-replay-threat-overlay-pane-width": `${overlayPanePercent}%`,
title="Для RAV004 не опубликован point-aligned 3D semantic archive" } as CSSProperties}
> >
SEMANTICS <div>
</Button> <span>RAVNOVES004TREE · recorded realtime</span>
</> <strong>frame {sequence}/{review.frameCount}</strong>
)} <small>
cameraOverlay={( +{(playbackController.playback.currentSeconds - review.timelineStartSeconds).toFixed(3)} с
<> · {playbackController.playback.playing ? "воспроизведение" : "пауза / seek"}
<div className="m48-clip-player__pane-label" data-pane="camera"> </small>
{showCameraSemantic
? `${semanticLayer === "city" ? "EoMT CITY" : "DDRNet NATURE"} · КАДР ${sequence}/${review.frameCount}`
: `SOURCE · КАДР ${sequence}/${review.frameCount}`}
</div>
{showCameraSemantic ? (
<div className="m48-clip-player__overlay">
<RecordedEvidenceSemanticMaskOverlay
src={vegetationFullRouteMaskUrl(resultId, semanticLayer, maskSequence)}
prefetchSrcs={prefetchSrcs}
imageWidth={review.width}
imageHeight={review.height}
classes={semantic.classes}
palette={semantic.palette}
opacity={0.76}
ariaLabel={`${layer.name} semantic prediction`}
/>
</div>
) : null}
</>
)}
/>
) : (
<div className="m4-replay-threat-visual__pane-status" role={videoError ? "alert" : "status"}>
{videoError ?? "Открываем автономный RAVNOVES004TREE source…"}
</div>
)}
{videoSource ? (
<M48EvidenceModeRail
mode={evidenceMode}
cameraVisible={cameraVisible}
spatialAvailable={Boolean(spatialProfile)}
planAvailable={Boolean(linkedReview)}
onModeChange={handleEvidenceModeChange}
onCameraVisibleChange={setCameraVisible}
/>
) : null}
</div> </div>
</LaboratoryEvidenceViewer> <div>
<span>Spatial evidence</span>
<strong>{showTgs && selectedTgsCase
? `TGS anchor ${selectedTgsCase.sourceSequence} · ${selectedTgsCase.tgs.occupiedCells} occupied`
: "source RRD · points + SLAM"}</strong>
<small>{showTgs
? "latest causal of 10 sealed anchors · continuous playback retained"
: "causal 1 s view · grayscale intensity · recorded source identity"}</small>
</div>
</div>
);
const transport = (
<ObservationTimeline
className="m4-replay-threat-visual__timeline"
active
sourceCount={4}
mode="recorded"
seekable
synchronization="host-arrival-best-effort"
rangeNs={{
min: Math.round(review.timelineStartSeconds * 1_000_000_000),
max: Math.round(review.timelineEndSeconds * 1_000_000_000),
}}
currentNs={Math.round(playbackController.playback.currentSeconds * 1_000_000_000)}
playing={playbackController.playback.playing}
playbackRate={playbackController.playback.rate ?? 1}
onSeek={(timeNs) => playbackController.seek(timeNs / 1_000_000_000)}
onPlayingChange={playbackController.setPlaying}
onPlaybackRateChange={playbackController.setRate}
showJumpToEnd={false}
/>
);
return (
<CanonicalRecordedLabReplay
label="RAVNOVES004TREE full recorded review"
mediaMode={mediaMode ?? "none"}
mediaModes={FULL_ROUTE_MEDIA_MODES}
spatialMode={spatialMode ?? "none"}
spatialModes={FULL_ROUTE_SPATIAL_MODES}
expanded={expanded}
splitPrimarySize={splitPrimarySize}
splitOrientation={splitOrientation}
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео"}
spatialAriaLabel={spatialMode === "3d" ? "Трёхмерная сцена" : "Вид сверху"}
mediaLayerControls={mediaLayerControls}
spatialLayerControls={spatialLayerControls}
spatialLeadingControl={resetSpatialView}
mediaMultiLayer
mediaContent={mediaContent}
spatialContent={spatialContent}
emptyMessage="Выберите VIDEO/CAMERA или 3D/PLAN. Общий таймлайн останется на месте."
overlay={overlay}
transport={transport}
trailingActions={!splitView && spatialMode ? resetSpatialView : null}
onMediaModeChange={handleMediaModeChange}
onSpatialModeChange={handleSpatialModeChange}
onExpandedChange={setExpanded}
onSplitPrimarySizeChange={setSplitPrimarySize}
/>
); );
} }
@@ -847,8 +847,9 @@ test("recorded VIDEO clock cannot reverse an explicit operator pause", () => {
}); });
test("M4.6 viewer keeps media and spatial panes on one playback clock", async () => { test("M4.6 viewer keeps media and spatial panes on one playback clock", async () => {
const [visual, visualCss, imageScene, videoScene, pointOverlay, metricScene] = await Promise.all([ const [visual, canonical, visualCss, imageScene, videoScene, pointOverlay, metricScene] = await Promise.all([
readFile(new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), "utf8"), readFile(new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/styles/m4-replay-threat.css", import.meta.url), "utf8"), readFile(new URL("../src/styles/m4-replay-threat.css", import.meta.url), "utf8"),
readFile(new URL("../src/components/laboratory/RecordedEvidenceImageScene.tsx", import.meta.url), "utf8"), readFile(new URL("../src/components/laboratory/RecordedEvidenceImageScene.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/components/laboratory/RecordedEvidenceVideoScene.tsx", import.meta.url), "utf8"), readFile(new URL("../src/components/laboratory/RecordedEvidenceVideoScene.tsx", import.meta.url), "utf8"),
@@ -858,7 +859,8 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
assert.match(visual, /<RecordedEvidenceVideoScene/); assert.match(visual, /<RecordedEvidenceVideoScene/);
assert.match(visual, /<RecordedEvidenceImageScene/); assert.match(visual, /<RecordedEvidenceImageScene/);
assert.match(visual, /<LaboratoryMetricEvidenceScene/); assert.match(visual, /<LaboratoryMetricEvidenceScene/);
assert.match(visual, /m4-replay-threat-visual__deck/); assert.match(visual, /<CanonicalRecordedLabReplay/);
assert.match(canonical, /m4-replay-threat-visual__deck/);
assert.match(visual, /lastFrameRef/); assert.match(visual, /lastFrameRef/);
assert.match(visual, /lastSpatialFrameRef/); assert.match(visual, /lastSpatialFrameRef/);
assert.match(visual, /const spatialFrame = frame\?\.spatialAvailable/); assert.match(visual, /const spatialFrame = frame\?\.spatialAvailable/);
@@ -876,19 +878,32 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
assert.match(visual, /pointCloudOverlay=/); assert.match(visual, /pointCloudOverlay=/);
assert.match(visual, /mediaMode/); assert.match(visual, /mediaMode/);
assert.match(visual, /spatialMode/); assert.match(visual, /spatialMode/);
assert.match(visual, /current === next \? null : next/); assert.match(canonical, /current === next \? null : next/);
assert.match(visual, /data-split=\{splitView \? "true" : undefined\}/); assert.match(canonical, /data-split=\{splitView \? "true" : undefined\}/);
assert.match(visual, /<SplitPane/); assert.match(canonical, /<SplitPane/);
assert.match(visual, /primarySize=\{splitView \? splitPrimarySize : mediaMode \? 100 : 0\}/); assert.match(canonical, /primary=\{mediaPane\}/);
assert.match(visual, /resizable=\{splitView\}/); assert.match(canonical, /secondary=\{spatialPane/);
assert.match(visual, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/); assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/);
assert.match(visual, /secondaryMode=\{\{/); assert.match(canonical, /resizable=\{splitView\}/);
assert.match(canonical, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
assert.match(canonical, /secondaryMode=\{\{/);
assert.match(visual, /playback=\{playbackController\.playback\}/); assert.match(visual, /playback=\{playbackController\.playback\}/);
assert.match(visual, /clock: mediaMode === "video" \? "external" : "animation"/); assert.match(visual, /clock: "animation"/);
assert.match(visual, /useCanonicalRecordedLabReplayState/);
assert.match(canonical, /current === next \? null : next/);
assert.match(visual, /timelineFrame\.activeSequence \+ 1/); assert.match(visual, /timelineFrame\.activeSequence \+ 1/);
assert.match(visual, /segmentCount=\{timeline\.frameCount\}/); assert.match(visual, /segmentCount=\{timeline\.frameCount\}/);
assert.match(visual, /onPlaybackChange=\{playbackController\.synchronize\}/); assert.match(visual, /onPlaybackChange=\{playbackController\.synchronize\}/);
assert.match(visual, /onPlayingRejected=\{\(\) => playbackController\.setPlaying\(false\)\}/); assert.match(
await readFile(new URL("../src/components/laboratory/useRecordedEvidencePlayback.ts", import.meta.url), "utf8"),
/if \(clock === "animation"\) return;/,
);
assert.match(visual, /playbackAuthority="host"/);
assert.match(visual, /playbackTransport="epoch-stream"/);
assert.match(
await readFile(new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url), "utf8"),
/if \(playbackAuthority === "host"\) return;/,
);
assert.match(visual, /currentSeconds: playbackController\.playback\.currentSeconds/); assert.match(visual, /currentSeconds: playbackController\.playback\.currentSeconds/);
assert.match(visualCss, /m4-replay-threat-visual__deck > \.nodedc-split-pane/); assert.match(visualCss, /m4-replay-threat-visual__deck > \.nodedc-split-pane/);
assert.match(visualCss, /m4-replay-threat-visual__pane-toolbar\[data-pane-toolbar="media"\]/); assert.match(visualCss, /m4-replay-threat-visual__pane-toolbar\[data-pane-toolbar="media"\]/);
@@ -951,12 +966,18 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
}); });
test("M4.6 keeps the recorded VIDEO player mounted across media mode toggles", async () => { test("M4.6 keeps the recorded VIDEO player mounted across media mode toggles", async () => {
const visual = await readFile( const [visual, canonical] = await Promise.all([
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), readFile(
"utf8", new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
); "utf8",
assert.match(visual, /const mediaPane = \(/); ),
assert.match(visual, /hidden=\{!mediaMode\}/); readFile(
new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url),
"utf8",
),
]);
assert.match(canonical, /const mediaPane = \(/);
assert.match(canonical, /hidden=\{mediaMode === "none"\}/);
assert.match(visual, /data-media="video"/); assert.match(visual, /data-media="video"/);
assert.match(visual, /hidden=\{mediaMode !== "video"\}/); assert.match(visual, /hidden=\{mediaMode !== "video"\}/);
assert.match(visual, /\{videoSource \? \(/); assert.match(visual, /\{videoSource \? \(/);
@@ -75,10 +75,16 @@ test("semantic point alignment follows the last qualified spatial increment", as
}); });
test("M4 keeps independent semantic controls in media and spatial panes", async () => { test("M4 keeps independent semantic controls in media and spatial panes", async () => {
const source = await readFile( const [source, canonical] = await Promise.all([
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), readFile(
"utf8", new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
); "utf8",
),
readFile(
new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url),
"utf8",
),
]);
assert.match(source, /showMediaSemantic/); assert.match(source, /showMediaSemantic/);
assert.match(source, /showSpatialSemantic/); assert.match(source, /showSpatialSemantic/);
assert.match(source, /activeSpatialSemantic = spatialSemantic \?\? activeSemantic/); assert.match(source, /activeSpatialSemantic = spatialSemantic \?\? activeSemantic/);
@@ -89,9 +95,9 @@ test("M4 keeps independent semantic controls in media and spatial panes", async
assert.match(source, /\|\| !showSpatialSemantic/); assert.match(source, /\|\| !showSpatialSemantic/);
assert.match(source, /aria-label="Слои камеры и видео"/); assert.match(source, /aria-label="Слои камеры и видео"/);
assert.match(source, /aria-label="Слои 3D и плана"/); assert.match(source, /aria-label="Слои 3D и плана"/);
assert.match(source, /data-pane-mode="media"/); assert.match(canonical, /data-pane-mode="media"/);
assert.match(source, /data-pane-mode="spatial"/); assert.match(canonical, /data-pane-mode="spatial"/);
assert.match(source, /modeControlsVisible=\{!splitView\}/); assert.match(canonical, /modeControlsVisible=\{!splitView\}/);
assert.match(source, /semanticOverlay=\{mediaMode === "video" \? semanticOverlay : undefined\}/); assert.match(source, /semanticOverlay=\{mediaMode === "video" \? semanticOverlay : undefined\}/);
}); });
@@ -6,7 +6,9 @@ import { createServer } from "vite";
let server; let server;
let fetchVegetationBenchmarkResult; let fetchVegetationBenchmarkResult;
let fetchCanonicalRecordedLabSpatialFrame;
let fetchVegetationShadowResult; let fetchVegetationShadowResult;
let fetchVegetationRouteTgsAnchor;
let vegetationFullRouteMaskUrl; let vegetationFullRouteMaskUrl;
before(async () => { before(async () => {
@@ -17,7 +19,9 @@ before(async () => {
}); });
({ ({
fetchVegetationBenchmarkResult, fetchVegetationBenchmarkResult,
fetchCanonicalRecordedLabSpatialFrame,
fetchVegetationShadowResult, fetchVegetationShadowResult,
fetchVegetationRouteTgsAnchor,
vegetationFullRouteMaskUrl, vegetationFullRouteMaskUrl,
} = await server.ssrLoadModule( } = await server.ssrLoadModule(
"/src/core/laboratory/vegetationShadow.ts", "/src/core/laboratory/vegetationShadow.ts",
@@ -365,8 +369,68 @@ test("vegetation GOOSE benchmark opens through its separate archival endpoint",
assert.equal(result.validationCases.length, 12); assert.equal(result.validationCases.length, 12);
}); });
test("vegetation route TGS anchor keeps exact sealed metric shapes", async () => {
let requestedUrl = "";
const anchor = await fetchVegetationRouteTgsAnchor(resultId, 409, {
fetcher: async (url) => {
requestedUrl = String(url);
return new Response(JSON.stringify({
schema_version: "missioncore.lab-v1-route-tgs-anchor/v1",
source_sequence: 409,
slot: 1,
current_points_xyz_m: [[1, 2, 3], [4, 5, 6]],
costmap: {
cell_size_m: 0.45,
centers_xy_m: Array.from({ length: 2244 }, (_, index) => [index, -index]),
state_codes: Array.from({ length: 2244 }, (_, index) => index % 4),
z_bounds_m: Array.from({ length: 2244 }, () => [null, null]),
},
}), { status: 200, headers: { "Content-Type": "application/json" } });
},
});
assert.equal(
requestedUrl,
`/api/v1/laboratory/vegetation-shadow/${resultId}/route-tgs-anchor/409`,
);
assert.equal(anchor.sourceSequence, 409);
assert.equal(anchor.currentPointsXyzM.length, 2);
assert.equal(anchor.costmap.centersXyM.length, 2244);
assert.deepEqual(new Set(anchor.costmap.stateCodes), new Set([0, 1, 2, 3]));
});
test("canonical recorded LAB spatial frame keeps source, SLAM and body identity on one clock", async () => {
const generation = "e".repeat(64);
let requestedUrl = "";
const frame = await fetchCanonicalRecordedLabSpatialFrame("session-004", generation, 82_770_000_000, {
fetcher: async (url) => {
requestedUrl = String(url);
return new Response(JSON.stringify({
schema_version: "missioncore.canonical-recorded-lab-spatial-frame/v1",
target_time_ns: 82_770_000_000,
source_time_ns: 82_769_535_708,
pose_time_ns: 82_769_535_708,
trajectory_time_ns: 82_700_000_000,
source_point_count: 2,
source_points_body_xyz_m: [[1, 2, 3], [4, 5, 6]],
local_slam_body_xyz_m: [[0, 0, 0], [1, 0, 0]],
body_frame: {
origin_map_xyz_m: [33, 4, 1],
basis_map_from_body: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
},
}), { status: 200, headers: { "Content-Type": "application/json" } });
},
});
assert.equal(
requestedUrl,
`/api/v1/observation-sessions/session-004/canonical-lab/spatial-frame?generation=${generation}&time_ns=82770000000`,
);
assert.equal(frame.sourcePointCount, 2);
assert.equal(frame.localSlamBodyXyzM.length, 2);
assert.deepEqual(frame.bodyFrame.originMapXyzM, [33, 4, 1]);
});
test("vegetation realtime LAB and archival benchmark use separate admitted instruments", async () => { test("vegetation realtime LAB and archival benchmark use separate admitted instruments", async () => {
const [resultSource, benchmarkSource, m49Source] = await Promise.all([ const [resultSource, benchmarkSource, m49Source, canonicalSource] = await Promise.all([
readFile( readFile(
new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url), new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url),
"utf8", "utf8",
@@ -379,6 +443,10 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr
new URL("../src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx", import.meta.url), new URL("../src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx", import.meta.url),
"utf8", "utf8",
), ),
readFile(
new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url),
"utf8",
),
]); ]);
assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/); assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/);
assert.match(resultSource, /M49TgsFullShadowEvidence/); assert.match(resultSource, /M49TgsFullShadowEvidence/);
@@ -389,12 +457,28 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr
assert.equal(resultSource.match(/<LaboratoryEvidence\b/g)?.length, 2); assert.equal(resultSource.match(/<LaboratoryEvidence\b/g)?.length, 2);
assert.doesNotMatch(resultSource, /RAVNOVES004TREE mixed route review/); assert.doesNotMatch(resultSource, /RAVNOVES004TREE mixed route review/);
assert.match(resultSource, /RAVNOVES004TREE full recorded review/); assert.match(resultSource, /RAVNOVES004TREE full recorded review/);
assert.match(resultSource, /LaboratoryRecordedClipPlayer/); assert.match(resultSource, /CanonicalRecordedLabReplay/);
assert.match(resultSource, /RerunViewport/); assert.match(resultSource, /RecordedEvidenceVideoScene/);
assert.match(resultSource, /M48EvidenceModeRail/); assert.match(resultSource, /LaboratoryMetricEvidenceScene/);
assert.doesNotMatch(resultSource, /RerunViewport/);
assert.match(resultSource, /fetchCanonicalRecordedLabSpatialFrame/);
assert.match(resultSource, /useCanonicalRecordedLabReplayState/);
assert.match(resultSource, /playbackTransport="epoch-stream"/);
assert.match(resultSource, /causalTgsCase/);
assert.match(resultSource, /latest causal of 10 sealed anchors/);
assert.doesNotMatch(resultSource, /LaboratoryRecordedClipPlayer|M48EvidenceModeRail/);
assert.doesNotMatch(resultSource, /assets\.tgs|<img/);
assert.match(resultSource, /SOURCE POINTS/); assert.match(resultSource, /SOURCE POINTS/);
assert.match(resultSource, /LOCAL SLAM/); assert.match(resultSource, /LOCAL SLAM/);
assert.match(resultSource, /TGS COSTMAP/); assert.match(resultSource, /TGS COSTMAP/);
assert.match(resultSource, /onClick=\{\(\) => setShowTgs\(\(visible\) => !visible\)\}/);
assert.match(resultSource, /showClassifiedCells=\{showTgs && Boolean\(packedTgsCells\)\}/);
assert.doesNotMatch(resultSource, /setShowTgs\(false\)/);
assert.doesNotMatch(resultSource, /setPlaying\(false\);[\s\S]{0,160}setShowTgs/);
assert.match(canonicalSource, /primary=\{mediaPane\}/);
assert.match(canonicalSource, /secondary=\{spatialPane/);
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
assert.match(canonicalSource, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
assert.match(resultSource, /linked canonical M4\.9 TGS evidence/); assert.match(resultSource, /linked canonical M4\.9 TGS evidence/);
assert.match(resultSource, /linkedTgsResultId/); assert.match(resultSource, /linkedTgsResultId/);
assert.match(benchmarkSource, /M48MaskComparisonVisual/); assert.match(benchmarkSource, /M48MaskComparisonVisual/);
@@ -0,0 +1,259 @@
"""Canonical recorded-LAB spatial adapter for sealed Rerun recordings.
The LAB viewer must not run an independent Rerun transport beside the camera
transport. This adapter reads the immutable recording once, indexes the
recorded source cloud, sensor pose and SLAM trajectory, and returns the latest
source-paced spatial sample in the current body frame. Camera, spatial layers
and the common timeline can therefore be driven by one host clock.
"""
from __future__ import annotations
from bisect import bisect_right
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from threading import Lock
from typing import Any, Final
import numpy as np
import rerun_bindings as rr_bindings
_POINT_ENTITY: Final = "/world/points"
_POSE_ENTITY: Final = "/world/sensor_pose"
_TRAJECTORY_ENTITY: Final = "/world/trajectory"
_POINT_COMPONENT: Final = "Points3D:positions"
_POSE_TRANSLATION_COMPONENT: Final = "Transform3D:translation"
_POSE_QUATERNION_COMPONENT: Final = "Transform3D:quaternion"
_TRAJECTORY_COMPONENT: Final = "LineStrips3D:strips"
_INDEX_LOCK: Final = Lock()
@dataclass(frozen=True)
class _TimedPoints:
times_ns: tuple[int, ...]
values: tuple[np.ndarray, ...]
@dataclass(frozen=True)
class _TimedPoses:
times_ns: tuple[int, ...]
translations: tuple[np.ndarray, ...]
quaternions_xyzw: tuple[np.ndarray, ...]
@dataclass(frozen=True)
class _CanonicalSpatialIndex:
points: _TimedPoints
poses: _TimedPoses
trajectories: _TimedPoints
def _session_times(batch: Any) -> Any | None:
if "session_time" not in batch.schema.names:
return None
return batch.column("session_time")
def _point_rows(chunks: list[Any], entity: str, component: str, *, nested: bool = False) -> _TimedPoints:
rows: list[tuple[int, np.ndarray]] = []
for chunk in chunks:
if chunk.entity_path != entity:
continue
batch = chunk.to_record_batch()
times = _session_times(batch)
if times is None or component not in batch.schema.names:
continue
column = batch.column(component)
for row_index in range(batch.num_rows):
timestamp = int(times[row_index].value)
payload = column[row_index].as_py()
if nested:
payload = payload[0] if payload else []
values = np.asarray(payload, dtype=np.float32)
if values.ndim != 2 or values.shape[1] != 3 or not np.isfinite(values).all():
continue
values.setflags(write=False)
rows.append((timestamp, values))
rows.sort(key=lambda item: item[0])
return _TimedPoints(
times_ns=tuple(timestamp for timestamp, _ in rows),
values=tuple(values for _, values in rows),
)
def _pose_rows(chunks: list[Any]) -> _TimedPoses:
rows: list[tuple[int, np.ndarray, np.ndarray]] = []
for chunk in chunks:
if chunk.entity_path != _POSE_ENTITY:
continue
batch = chunk.to_record_batch()
times = _session_times(batch)
if (
times is None
or _POSE_TRANSLATION_COMPONENT not in batch.schema.names
or _POSE_QUATERNION_COMPONENT not in batch.schema.names
):
continue
translations = batch.column(_POSE_TRANSLATION_COMPONENT)
quaternions = batch.column(_POSE_QUATERNION_COMPONENT)
for row_index in range(batch.num_rows):
translation_values = translations[row_index].as_py()
quaternion_values = quaternions[row_index].as_py()
if len(translation_values) != 1 or len(quaternion_values) != 1:
continue
translation = np.asarray(translation_values[0], dtype=np.float64)
quaternion = np.asarray(quaternion_values[0], dtype=np.float64)
if (
translation.shape != (3,)
or quaternion.shape != (4,)
or not np.isfinite(translation).all()
or not np.isfinite(quaternion).all()
):
continue
norm = float(np.linalg.norm(quaternion))
if norm <= 1e-9:
continue
translation.setflags(write=False)
normalized = quaternion / norm
normalized.setflags(write=False)
rows.append((int(times[row_index].value), translation, normalized))
rows.sort(key=lambda item: item[0])
return _TimedPoses(
times_ns=tuple(timestamp for timestamp, _, _ in rows),
translations=tuple(translation for _, translation, _ in rows),
quaternions_xyzw=tuple(quaternion for _, _, quaternion in rows),
)
@lru_cache(maxsize=4)
def _load_index_cached(
path_text: str,
byte_length: int,
modified_ns: int,
generation_sha256: str,
) -> _CanonicalSpatialIndex:
path = Path(path_text)
stat = path.stat()
if stat.st_size != byte_length or stat.st_mtime_ns != modified_ns:
raise ValueError("Recorded LAB source changed during spatial indexing")
if len(generation_sha256) != 64:
raise ValueError("Recorded LAB generation is invalid")
# Decode only the three canonical entities in one pass. Building a lazy
# store first decodes the complete RRD (including unrelated payloads), and
# then scanning that store once per layer made first-open take more than a
# minute on RAVNOVES004TREE.
chunks = (
rr_bindings.RrdReaderInternal(str(path))
.stream()
.filter(content=[_POINT_ENTITY, _POSE_ENTITY, _TRAJECTORY_ENTITY])
.to_chunks()
)
points = _point_rows(chunks, _POINT_ENTITY, _POINT_COMPONENT)
poses = _pose_rows(chunks)
trajectories = _point_rows(
chunks,
_TRAJECTORY_ENTITY,
_TRAJECTORY_COMPONENT,
nested=True,
)
if not points.times_ns or not poses.times_ns or not trajectories.times_ns:
raise ValueError("Recorded LAB source has no canonical spatial layers")
return _CanonicalSpatialIndex(points=points, poses=poses, trajectories=trajectories)
def _load_index(
path_text: str,
byte_length: int,
modified_ns: int,
generation_sha256: str,
) -> _CanonicalSpatialIndex:
# functools.lru_cache is coherent but intentionally releases its lock
# during a miss. Serialize cold RRD indexing so simultaneous camera/TGS
# admission cannot parse the same 80 MiB recording twice.
with _INDEX_LOCK:
return _load_index_cached(
path_text,
byte_length,
modified_ns,
generation_sha256,
)
def _latest_index(times_ns: tuple[int, ...], target_ns: int) -> int:
return max(0, min(len(times_ns) - 1, bisect_right(times_ns, target_ns) - 1))
def _rotation_map_from_body(quaternion_xyzw: np.ndarray) -> np.ndarray:
x, y, z, w = (float(value) for value in quaternion_xyzw)
return np.asarray(
[
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
],
dtype=np.float64,
)
def _map_points_to_body(
points_map: np.ndarray,
translation_map: np.ndarray,
quaternion_xyzw: np.ndarray,
) -> np.ndarray:
rotation = _rotation_map_from_body(quaternion_xyzw)
# Row vectors: inverse(map_from_body) == right-multiply by map_from_body.
body = (points_map.astype(np.float64) - translation_map) @ rotation
return body.astype(np.float32)
def canonical_lab_spatial_frame(
recording_path: Path,
generation_sha256: str,
target_time_ns: int,
) -> dict[str, object]:
"""Return the latest sealed source cloud and SLAM route on one host time."""
if target_time_ns < 0:
raise ValueError("Recorded LAB target time is invalid")
stat = recording_path.stat()
index = _load_index(
str(recording_path),
stat.st_size,
stat.st_mtime_ns,
generation_sha256,
)
point_index = _latest_index(index.points.times_ns, target_time_ns)
pose_index = _latest_index(index.poses.times_ns, index.points.times_ns[point_index])
trajectory_index = _latest_index(index.trajectories.times_ns, target_time_ns)
translation = index.poses.translations[pose_index]
quaternion = index.poses.quaternions_xyzw[pose_index]
basis_map_from_body = _rotation_map_from_body(quaternion)
points_body = _map_points_to_body(index.points.values[point_index], translation, quaternion)
trajectory_body = _map_points_to_body(
index.trajectories.values[trajectory_index],
translation,
quaternion,
)
# The canonical local-SLAM layer is bounded around the vehicle. It must
# never turn into the full world-route "blob" seen in the raw Rerun view.
local_mask = (
(np.abs(trajectory_body[:, 0]) <= 30.0)
& (np.abs(trajectory_body[:, 1]) <= 30.0)
& (np.abs(trajectory_body[:, 2]) <= 6.0)
)
local_trajectory = trajectory_body[local_mask]
return {
"schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v1",
"target_time_ns": target_time_ns,
"source_time_ns": index.points.times_ns[point_index],
"pose_time_ns": index.poses.times_ns[pose_index],
"trajectory_time_ns": index.trajectories.times_ns[trajectory_index],
"body_frame": {
"origin_map_xyz_m": translation.tolist(),
"basis_map_from_body": basis_map_from_body.tolist(),
},
"source_point_count": int(points_body.shape[0]),
"source_points_body_xyz_m": points_body.tolist(),
"local_slam_body_xyz_m": local_trajectory.tolist(),
}
+71
View File
@@ -37,6 +37,7 @@ from k1link.sessions import (
SessionStore, SessionStore,
validate_recorded_media_timeline, validate_recorded_media_timeline,
) )
from k1link.sessions.canonical_lab_spatial import canonical_lab_spatial_frame
from k1link.sessions.plugin_contract import RecordedPointColorRenderer from k1link.sessions.plugin_contract import RecordedPointColorRenderer
from k1link.viewer.recorded import ( from k1link.viewer.recorded import (
APPLICATION_ID as RECORDED_APPLICATION_ID, APPLICATION_ID as RECORDED_APPLICATION_ID,
@@ -824,6 +825,76 @@ def build_session_router(
**response_kwargs, **response_kwargs,
) )
@router.get(
"/api/v1/observation-sessions/{session_id}/canonical-lab/spatial-frame"
)
async def get_observation_session_canonical_lab_spatial_frame(
session_id: str,
generation: Annotated[str, Query(min_length=64, max_length=64)],
time_ns: Annotated[int, Query(ge=0, le=MAX_SAFE_INTEGER)],
) -> JSONResponse:
"""Serve one body-frame sample for the canonical recorded-LAB clock.
The camera timeline owns playback. Spatial evidence is sampled from
the same immutable recording instead of starting a second Rerun clock.
"""
if SAFE_SHA256.fullmatch(generation) is None:
raise HTTPException(
status_code=412,
detail="Поколение spatial-записи не совпадает.",
)
if recording_preparation_manager is None:
raise HTTPException(
status_code=503,
detail="Сервис canonical LAB spatial playback не настроен.",
)
snapshot = recording_preparation_manager.status(session_id)
if snapshot is None or snapshot.state != "ready" or snapshot.recording is None:
raise HTTPException(
status_code=409,
detail="Запись canonical LAB ещё не подготовлена.",
)
_require_matching_recording_generation(snapshot.recording.sha256, generation)
pinned = recording_preparation_manager.pin_ready(
session_id,
preparation_id=snapshot.preparation_id,
)
if pinned is None:
raise HTTPException(
status_code=412,
detail="Подготовленная spatial-запись была заменена.",
)
pinned_snapshot, release_recording = pinned
try:
recording = pinned_snapshot.recording
if recording is None:
raise HTTPException(
status_code=500,
detail="Подготовленная spatial-запись недоступна.",
)
payload = await run_in_threadpool(
canonical_lab_spatial_frame,
recording.path,
generation,
time_ns,
)
except (OSError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail="Canonical LAB spatial frame не прошёл проверку.",
) from exc
finally:
release_recording()
return JSONResponse(
payload,
headers={
"Cache-Control": "private, max-age=31536000, immutable",
"ETag": f'"{generation}:{payload["source_time_ns"]}"',
"X-Content-Type-Options": "nosniff",
},
)
@router.post("/api/v1/observation-sessions/{session_id}/blueprint.rrd") @router.post("/api/v1/observation-sessions/{session_id}/blueprint.rrd")
async def get_observation_session_blueprint( async def get_observation_session_blueprint(
session_id: str, session_id: str,
+106 -1
View File
@@ -11,8 +11,9 @@ from functools import lru_cache
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import Any, Final from typing import Any, Final
import numpy as np
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse, JSONResponse, Response
from k1link.laboratory.evidence_registry import LaboratoryEvidenceDefinition from k1link.laboratory.evidence_registry import LaboratoryEvidenceDefinition
from k1link.laboratory.evidence_report import ( from k1link.laboratory.evidence_report import (
@@ -262,9 +263,113 @@ def _build_vegetation_lab_router(
}, },
) )
@router.get("/{result_id}/route-tgs-anchor/{source_sequence}")
def get_route_tgs_anchor(result_id: str, source_sequence: int) -> JSONResponse:
candidate = _resolve_candidate(root_provider, definition, result_id)
manifest = _read_verified(candidate, definition)
review = manifest.get("route_review")
cases = review.get("cases") if isinstance(review, dict) else None
if (
not isinstance(cases, list)
or review.get("source_id") != "RAVNOVES004TREE"
or review.get("session_id") != "20260828T130511Z_viewer_live"
or not any(
isinstance(item, dict) and item.get("source_sequence") == source_sequence
for item in cases
)
):
raise HTTPException(status_code=404, detail="Route TGS anchor not found")
artifacts = manifest.get("artifacts")
descriptor = next(
(
item
for item in artifacts if isinstance(item, dict)
and item.get("role") == "mixed-route-tgs-evidence"
and item.get("path") == "proofs/tgs-evidence.npz"
and item.get("media_type") == "application/x-npz"
),
None,
) if isinstance(artifacts, list) else None
if descriptor is None:
raise HTTPException(status_code=404, detail="Route TGS anchor not found")
try:
payload = _route_tgs_anchor_payload(
candidate / "proofs" / "tgs-evidence.npz",
source_sequence,
)
except (KeyError, OSError, ValueError):
raise HTTPException(
status_code=503,
detail="Route TGS anchor failed verification",
) from None
return JSONResponse(
payload,
headers={
"Cache-Control": "private, max-age=31536000, immutable",
"ETag": f'"{descriptor.get("sha256", "")}"',
"X-Content-Type-Options": "nosniff",
},
)
return router return router
def _route_tgs_anchor_payload(path: Path, source_sequence: int) -> dict[str, object]:
before = path.stat()
with np.load(path, allow_pickle=False) as archive:
source_indices = archive["source_frame_indices"]
offsets = archive["current_increment_point_offsets"]
points = archive["current_increment_points_xyz_m"]
centers = archive["costmap_cell_centers_xy_m"]
states = archive["causal_rolling_1s_costmap_states"]
z_bounds = archive["causal_rolling_1s_costmap_z_bounds_m"]
if (
source_indices.shape != (10,)
or offsets.shape != (11,)
or points.ndim != 2
or points.shape[1] != 3
or centers.shape != (2244, 2)
or states.shape != (10, 2244)
or z_bounds.shape != (10, 2244, 2)
):
raise ValueError("Route TGS evidence shape changed")
matches = np.flatnonzero(source_indices == source_sequence - 1)
if matches.shape != (1,):
raise ValueError("Route TGS source sequence changed")
slot = int(matches[0])
start = int(offsets[slot])
end = int(offsets[slot + 1])
if not 0 <= start <= end <= points.shape[0]:
raise ValueError("Route TGS point offsets changed")
selected_points = np.ascontiguousarray(points[start:end], dtype=np.float32)
selected_states = np.ascontiguousarray(states[slot], dtype=np.uint8)
selected_z_bounds = np.ascontiguousarray(z_bounds[slot], dtype=np.float32)
if not np.isfinite(selected_points).all() or not np.isin(selected_states, [0, 1, 2, 3]).all():
raise ValueError("Route TGS payload changed")
result = {
"schema_version": "missioncore.lab-v1-route-tgs-anchor/v1",
"source_sequence": source_sequence,
"slot": slot,
"current_points_xyz_m": selected_points.astype(float).tolist(),
"costmap": {
"cell_size_m": 0.45,
"centers_xy_m": centers.astype(float).tolist(),
"state_codes": selected_states.astype(int).tolist(),
"z_bounds_m": [
[
float(row[0]) if np.isfinite(row[0]) else None,
float(row[1]) if np.isfinite(row[1]) else None,
]
for row in selected_z_bounds
],
},
}
after = path.stat()
if before.st_size != after.st_size or before.st_mtime_ns != after.st_mtime_ns:
raise ValueError("Route TGS evidence changed during read")
return result
def _zip_mask_response(archive_path: Path, sequence: int) -> Response: def _zip_mask_response(archive_path: Path, sequence: int) -> Response:
member = f"masks/frame-{sequence + 1:06d}.png" member = f"masks/frame-{sequence + 1:06d}.png"
try: try:
+75
View File
@@ -17,6 +17,7 @@ from fastapi.responses import FileResponse
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
import k1link.sessions.media as recorded_media_module import k1link.sessions.media as recorded_media_module
import k1link.web.session_api as session_api_module
from k1link.compute import RecordedPerceptionOverlayArtifact, RecordedPerceptionVideo from k1link.compute import RecordedPerceptionOverlayArtifact, RecordedPerceptionVideo
from k1link.device_plugins.xgrids_k1 import xgrids_k1_archive_source from k1link.device_plugins.xgrids_k1 import xgrids_k1_archive_source
from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC
@@ -458,6 +459,80 @@ def test_completed_recording_get_does_not_hold_delete_for_launch_lease(
manager.close() manager.close()
def test_canonical_lab_spatial_frame_uses_ready_immutable_recording(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
store = SessionStore(repository, data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
payload = b"sealed-spatial-recording"
def export_recording(source: Path, destination: Path) -> dict[str, object]:
destination.write_bytes(payload)
return {
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
"rrd_bytes": len(payload),
"timeline": "session_time",
"timeline_start_ns": 0,
"timeline_end_ns": 1_000_000_000,
}
materializer = SessionRecordingMaterializer(store.data_dir, exporter=export_recording)
command = store.prepare_replay(session.name)
recording = materializer.materialize(command)
manager = SessionRecordingPreparationManager(materializer)
resolved = manager.resolve_cached(command)
assert resolved is not None and resolved.recording is not None
generation = hashlib.sha256(payload).hexdigest()
expected = {
"schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v1",
"target_time_ns": 500_000_000,
"source_time_ns": 499_000_000,
"pose_time_ns": 499_000_000,
"trajectory_time_ns": 490_000_000,
"body_frame": {
"origin_map_xyz_m": [0.0, 0.0, 0.0],
"basis_map_from_body": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
},
"source_point_count": 1,
"source_points_body_xyz_m": [[1.0, 2.0, 3.0]],
"local_slam_body_xyz_m": [[0.0, 0.0, 0.0]],
}
def spatial_frame(path: Path, sha256: str, time_ns: int) -> dict[str, object]:
assert path == recording.path
assert sha256 == generation
assert time_ns == 500_000_000
return expected
monkeypatch.setattr(session_api_module, "canonical_lab_spatial_frame", spatial_frame)
router = build_session_router(
store,
recording_materializer=materializer,
recording_preparation_manager=manager,
)
spatial_route = endpoint(
router,
"/api/v1/observation-sessions/{session_id}/canonical-lab/spatial-frame",
"GET",
)
try:
response = asyncio.run(spatial_route(
session_id=session.name,
generation=generation,
time_ns=500_000_000,
))
assert json.loads(response.body) == expected
assert response.headers["etag"] == f'"{generation}:499000000"'
assert response.headers["cache-control"].endswith("immutable")
finally:
manager.close()
def test_session_router_returns_seekable_recording_and_serves_byte_ranges( def test_session_router_returns_seekable_recording_and_serves_byte_ranges(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
+38 -1
View File
@@ -21,11 +21,48 @@ from k1link.laboratory import LaboratoryEvidenceRegistry
from k1link.laboratory.evidence_report import verify_laboratory_evidence_result from k1link.laboratory.evidence_report import verify_laboratory_evidence_result
from k1link.laboratory.vegetation_policy_review import seal_vegetation_policy_review from k1link.laboratory.vegetation_policy_review import seal_vegetation_policy_review
from k1link.laboratory.vegetation_shadow_lab import seal_vegetation_shadow_lab from k1link.laboratory.vegetation_shadow_lab import seal_vegetation_shadow_lab
from k1link.web.vegetation_shadow_lab_api import build_vegetation_shadow_lab_router from k1link.web.vegetation_shadow_lab_api import (
_route_tgs_anchor_payload,
build_vegetation_shadow_lab_router,
)
REPOSITORY_ROOT = Path(__file__).resolve().parents[1] REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
def test_route_tgs_anchor_payload_preserves_metric_evidence(tmp_path: Path) -> None:
path = tmp_path / "tgs-evidence.npz"
point_counts = np.arange(1, 11, dtype=np.int64)
offsets = np.concatenate(([0], np.cumsum(point_counts)))
points = np.arange(int(offsets[-1]) * 3, dtype=np.float32).reshape(-1, 3)
centers = np.arange(2244 * 2, dtype=np.float32).reshape(2244, 2) * 0.45
states = np.tile(np.arange(2244, dtype=np.uint16) % 4, (10, 1)).astype(np.uint8)
z_bounds = np.zeros((10, 2244, 2), dtype=np.float32)
z_bounds[..., 0] = np.nan
z_bounds[..., 1] = 1.25
np.savez(
path,
source_frame_indices=np.array(
[20, 408, 789, 1189, 1609, 1992, 2380, 3190, 4810, 6381],
dtype=np.int64,
),
current_increment_point_offsets=offsets,
current_increment_points_xyz_m=points,
costmap_cell_centers_xy_m=centers,
causal_rolling_1s_costmap_states=states,
causal_rolling_1s_costmap_z_bounds_m=z_bounds,
)
payload = _route_tgs_anchor_payload(path, 409)
assert payload["schema_version"] == "missioncore.lab-v1-route-tgs-anchor/v1"
assert payload["source_sequence"] == 409
assert payload["slot"] == 1
assert len(payload["current_points_xyz_m"]) == 2
assert len(payload["costmap"]["centers_xy_m"]) == 2244
assert set(payload["costmap"]["state_codes"]) == {0, 1, 2, 3}
assert payload["costmap"]["z_bounds_m"][0] == [None, 1.25]
def test_coarse_policy_masks_mark_every_outside_fov_pixel_undefined( def test_coarse_policy_masks_mark_every_outside_fov_pixel_undefined(
tmp_path: Path, tmp_path: Path,
monkeypatch, monkeypatch,