From bd2892140fe3bf1a325ebd433a569da9b819b228 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 29 Aug 2026 22:42:30 +0300 Subject: [PATCH] fix(lab): enforce canonical replay runtime --- .../src/components/RecordedFmp4Player.tsx | 25 +- .../laboratory/CanonicalRecordedLabReplay.tsx | 245 ++++++ .../LaboratoryRecordedClipPlayer.tsx | 20 - .../laboratory/RecordedEvidenceVideoScene.tsx | 6 + .../laboratory/useRecordedEvidencePlayback.ts | 6 +- .../src/core/laboratory/vegetationShadow.ts | 196 +++++ .../laboratory-recorded-clip-player.css | 23 - .../laboratory/M4ReplayThreatVisual.tsx | 256 +++--- .../laboratory/VegetationShadowResult.tsx | 777 ++++++++++++------ .../test/m4ReplayThreat.test.mjs | 55 +- .../test/semanticEvidencePrimitives.test.mjs | 20 +- .../test/vegetationShadow.test.mjs | 92 ++- src/k1link/sessions/canonical_lab_spatial.py | 259 ++++++ src/k1link/web/session_api.py | 71 ++ src/k1link/web/vegetation_shadow_lab_api.py | 107 ++- tests/test_session_api.py | 75 ++ tests/test_vegetation_shadow_lab.py | 39 +- 17 files changed, 1765 insertions(+), 507 deletions(-) create mode 100644 apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx create mode 100644 src/k1link/sessions/canonical_lab_spatial.py diff --git a/apps/control-station/src/components/RecordedFmp4Player.tsx b/apps/control-station/src/components/RecordedFmp4Player.tsx index 34bb56a..5ba5c07 100644 --- a/apps/control-station/src/components/RecordedFmp4Player.tsx +++ b/apps/control-station/src/components/RecordedFmp4Player.tsx @@ -781,6 +781,8 @@ export function RecordedFmp4Player({ onAdmissionChange, onPlaybackChange, onPlayingRejected, + playbackAuthority = "media", + playbackTransport = "segmented", }: { source: ObservationSourceDescriptor; playback?: RecordedObservationPlayback | null; @@ -793,6 +795,8 @@ export function RecordedFmp4Player({ onAdmissionChange?: (state: RecordedCameraAdmissionState) => void; onPlaybackChange?: (playback: RecordedObservationPlayback) => void; onPlayingRejected?: () => void; + playbackAuthority?: "media" | "host"; + playbackTransport?: "segmented" | "epoch-stream"; }) { const videoRef = useRef(null); const onAdmissionChangeRef = useRef(onAdmissionChange); @@ -888,7 +892,8 @@ export function RecordedFmp4Player({ && effectiveSegmentSequence !== requestedSegmentSequence, ); const segmented = Boolean( - requestedSegmentSequence !== null + playbackTransport === "segmented" + && requestedSegmentSequence !== null && Number.isInteger(requestedSegmentSequence) && requestedSegmentSequence >= 1 && @@ -1201,6 +1206,11 @@ export function RecordedFmp4Player({ || segmentedRuntimeRef.current !== runtime || runtime.target?.revision !== revision ) 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?.(); setReadyGeneration(null); setErrorMessage("Запуск записанной камеры отклонён браузером."); @@ -1311,7 +1321,13 @@ export function RecordedFmp4Player({ if (targetReadyAbortRef.current === targetReadyAbort) targetReadyAbortRef.current = null; if (runtime.onTargetBuffered === markBuffered) runtime.onTargetBuffered = null; }; - }, [archive?.byteLength, effectiveSegmentSequence, segmented, segmentedRuntimeGeneration]); + }, [ + archive?.byteLength, + effectiveSegmentSequence, + playbackAuthority, + segmented, + segmentedRuntimeGeneration, + ]); useEffect(() => { const video = videoRef.current; @@ -1407,6 +1423,7 @@ export function RecordedFmp4Player({ if (playback?.playing && !holdingForSegmentRecovery) { void video.play().catch(() => { if (playAttemptRevisionRef.current !== playAttemptRevision) return; + if (playbackAuthority === "host") return; onPlayingRejectedRef.current?.(); setReadyGeneration(null); setErrorMessage("Запуск записанной камеры отклонён браузером."); @@ -1427,6 +1444,7 @@ export function RecordedFmp4Player({ epoch, holdingForSegmentRecovery, playback?.playing, + playbackAuthority, playbackRate, segmented, visualState, @@ -1454,6 +1472,7 @@ export function RecordedFmp4Player({ video.playbackRate = playbackRateRef.current; void video.play().catch(() => { if (cancelled || playAttemptRevisionRef.current !== playAttemptRevision) return; + if (playbackAuthority === "host") return; onPlayingRejectedRef.current?.(); }); }; @@ -1465,7 +1484,7 @@ export function RecordedFmp4Player({ cancelled = true; for (const event of events) video.removeEventListener(event, queueResume); }; - }, [bufferRevision, playback?.playing, segmented, visualState]); + }, [bufferRevision, playback?.playing, playbackAuthority, segmented, visualState]); useEffect(() => { const video = videoRef.current; diff --git a/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx b/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx new file mode 100644 index 0000000..239a5ad --- /dev/null +++ b/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx @@ -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 { + 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(initialMediaMode); + const [spatialMode, setSpatialMode] = useState(initialSpatialMode); + const [splitPrimarySize, setSplitPrimarySize] = useState(50); + const [splitOrientation, setSplitOrientation] = useState(() => ( + 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[]; + spatialMode: TSpatial; + spatialModes: readonly CanonicalRecordedLabMode[]; + 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 = ( +
+ +
+ ); + const spatialModeControls = ( +
+ +
+ ); + const mediaPane = ( + + ); + const spatialPane = spatialMode !== "none" ? ( +
+ {splitView ? ( +
+ {spatialLeadingControl} +
+ {spatialLayerControls} + {spatialModeControls} +
+
+ ) : null} + {spatialContent} +
+ ) : null; + + return ( +
+ +
+ } + 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" ? ( +
+ {emptyMessage} +
+ ) : null} + {deckOverlays} +
+
+
+ ); +} diff --git a/apps/control-station/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx b/apps/control-station/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx index 0fd9b59..50cc27b 100644 --- a/apps/control-station/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx +++ b/apps/control-station/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx @@ -81,9 +81,7 @@ export function LaboratoryRecordedClipPlayer({ sourceCount, cameraRef, cameraOverlay, - cameraControls, alternativeScene, - spatialControls, onSequenceChange, onPlayingChange, onPlaybackRateChange, @@ -99,9 +97,7 @@ export function LaboratoryRecordedClipPlayer({ sourceCount: number; cameraRef?: RefObject; cameraOverlay?: ReactNode; - cameraControls?: ReactNode; alternativeScene?: ReactNode; - spatialControls?: ReactNode; onSequenceChange: (sequence: number) => void; onPlayingChange: (playing: boolean) => void; onPlaybackRateChange: (rate: number) => void; @@ -177,14 +173,6 @@ export function LaboratoryRecordedClipPlayer({ aria-hidden={cameraPresentation === "primary"} > {alternativeScene} - {spatialControls ? ( -
- {spatialControls} -
- ) : null} ); const cameraPane = ( @@ -207,14 +195,6 @@ export function LaboratoryRecordedClipPlayer({ /> ) : null} {cameraPresentation !== "hidden" ? cameraOverlay : null} - {cameraPresentation !== "hidden" && cameraControls ? ( -
- {cameraControls} -
- ) : null} ); return ( diff --git a/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx b/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx index d96f046..9df67df 100644 --- a/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx +++ b/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx @@ -33,6 +33,8 @@ export function RecordedEvidenceVideoScene({ segmentCount, onPlaybackChange, onPlayingRejected, + playbackAuthority = "media", + playbackTransport = "segmented", }: { source: ObservationSourceDescriptor; playback: RecordedObservationPlayback; @@ -47,6 +49,8 @@ export function RecordedEvidenceVideoScene({ segmentCount?: number; onPlaybackChange?: (playback: RecordedObservationPlayback) => void; onPlayingRejected?: () => void; + playbackAuthority?: "media" | "host"; + playbackTransport?: "segmented" | "epoch-stream"; }) { return (
@@ -59,6 +63,8 @@ export function RecordedEvidenceVideoScene({ segmentCount={segmentCount} onPlaybackChange={onPlaybackChange} onPlayingRejected={onPlayingRejected} + playbackAuthority={playbackAuthority} + playbackTransport={playbackTransport} /> {semanticOverlay ? ( { 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)); - }, [range]); + }, [clock, range]); return useMemo(() => ({ playback, diff --git a/apps/control-station/src/core/laboratory/vegetationShadow.ts b/apps/control-station/src/core/laboratory/vegetationShadow.ts index 42e6bf8..8b35f6d 100644 --- a/apps/control-station/src/core/laboratory/vegetationShadow.ts +++ b/apps/control-station/src/core/laboratory/vegetationShadow.ts @@ -106,6 +106,36 @@ export interface VegetationMixedRouteReview { 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 { name: string; resultId: string; @@ -942,6 +972,172 @@ export function vegetationFullRouteMaskUrl( 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 { + 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 { + 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( resultId: string, { diff --git a/apps/control-station/src/styles/laboratory-recorded-clip-player.css b/apps/control-station/src/styles/laboratory-recorded-clip-player.css index e899b46..9b9c764 100644 --- a/apps/control-station/src/styles/laboratory-recorded-clip-player.css +++ b/apps/control-station/src/styles/laboratory-recorded-clip-player.css @@ -33,29 +33,6 @@ 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 > .nodedc-split-pane__separator::before { background: transparent; diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index 63ddc7f..b41ee6f 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -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 { Button, Icon, IconButton, Select, SegmentedControl, - SplitPane, - type SplitPaneOrientation, } from "@nodedc/ui-react"; import { ObservationTimeline } from "../../components/ObservationTimeline"; +import { + CanonicalRecordedLabReplay, + useCanonicalRecordedLabReplayState, +} from "../../components/laboratory/CanonicalRecordedLabReplay"; import { LaboratoryMetricEvidenceScene, type LaboratoryMetricCellEvidence, @@ -53,8 +63,6 @@ import { useE47SemanticTimelineFrame } from "./useE47SemanticTimeline"; import { buildM4StaticObstacleBoxes } from "./m4StaticObstacleBoxes"; type M4ThreatMediaMode = "video" | "camera"; -type M4ThreatMediaSelection = M4ThreatMediaMode | "none"; -type M4ThreatSpatialSelection = LaboratoryMetricSceneMode | "none"; function toneForProposal(proposal: M4ThreatCameraProposal): RecordedEvidenceBox["tone"] { if (proposal.threatDecision === "threat") return "danger"; @@ -188,10 +196,21 @@ export function M4ReplayThreatVisual({ showSpatialOverlaySummary?: boolean; onActiveSequenceChange?: (sequence: number | null) => void; }) { - const [mediaMode, setMediaMode] = useState("video"); - const [spatialMode, setSpatialMode] = useState( + const { + mediaMode, + spatialMode, + splitView, + splitPrimarySize, + splitOrientation, + expanded, + onMediaModeChange: handleMediaModeChange, + onSpatialModeChange: handleSpatialModeChange, + onSplitPrimarySizeChange: setSplitPrimarySize, + onExpandedChange: setExpanded, + } = useCanonicalRecordedLabReplayState({ + initialMediaMode: "video", initialSpatialMode, - ); + }); const [showCurrentIncrement, setShowCurrentIncrement] = useState(true); const [showLocalSurface, setShowLocalSurface] = useState(true); const [showRollingMap, setShowRollingMap] = useState(true); @@ -200,13 +219,6 @@ export function M4ReplayThreatVisual({ const [showSpatialSemantic, setShowSpatialSemantic] = useState(true); const [showMediaPoints, setShowMediaPoints] = useState(false); const [showStaticObstacles, setShowStaticObstacles] = useState(true); - const [splitPrimarySize, setSplitPrimarySize] = useState(50); - const [splitOrientation, setSplitOrientation] = useState(() => ( - typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches - ? "horizontal" - : "vertical" - )); - const [expanded, setExpanded] = useState(false); const [selectedReviewAnchorIndex, setSelectedReviewAnchorIndex] = useState(0); const availableSemanticLayers = useMemo( () => semanticLayers?.length ? semanticLayers : semantic ? [semantic] : [], @@ -266,7 +278,7 @@ export function M4ReplayThreatVisual({ endSeconds: metadata.timeline.timelineEndSeconds, }) : null, [metadata.timeline]); const playbackController = useRecordedEvidencePlayback(playbackRange, { - clock: mediaMode === "video" ? "external" : "animation", + clock: "animation", }); const seekPlayback = playbackController.seek; const setPlaybackPlaying = playbackController.setPlaying; @@ -286,14 +298,6 @@ export function M4ReplayThreatVisual({ setVideoError(null); }, [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(() => { const timeline = metadata.timeline; if (!evidenceDemand.recordedVideo) { @@ -739,15 +743,6 @@ export function M4ReplayThreatVisual({ } : 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(() => { if ( playbackController.playback.playing @@ -758,36 +753,6 @@ export function M4ReplayThreatVisual({ image.src = frame.cameraUrl; }, [evidenceDemand.exactCameraFrame, frame?.cameraUrl, playbackController.playback.playing]); - const splitView = mediaMode !== null && spatialMode !== null; - - const mediaModeControls = ( -
- -
- ); - - const spatialModeControls = ( -
- -
- ); - const mediaLayerControls = activeSemantic || (showReferenceMediaLayers && metadata.timeline?.cameraPointDelivery) || (showReferenceMediaLayers && metadata.timeline?.cameraObstacleProjectionDelivery) ? ( @@ -1110,7 +1075,12 @@ export function M4ReplayThreatVisual({ ) : undefined; const timeline = metadata.timeline; - let content; + let content: ReactNode = null; + let canonicalContent: { + mediaContent: ReactNode; + spatialContent: ReactNode; + deckOverlays: ReactNode; + } | null = null; if (metadata.error) { content = ; } else if (!timeline) { @@ -1121,23 +1091,8 @@ export function M4ReplayThreatVisual({
); } else { - const mediaPane = ( - + ); - const spatialPane = spatialMode ? ( -
- {splitView ? ( -
- {resetSpatialView} -
- {spatialLayerControls} - {spatialModeControls} -
-
- ) : null} + const spatialContent = spatialMode ? ( + <> ) : null} -
+ ) : null; - content = ( -
- } - 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 ? ( -
- Выберите VIDEO/CAMERA или 3D/PLAN. Общий таймлайн останется на месте. -
- ) : null} + const deckOverlays = ( + <> {timelineFrame.loading || displayingBufferedFrame ? (
) : null} -
+ ); + canonicalContent = { mediaContent, spatialContent, deckOverlays }; } const transport = timeline ? ( @@ -1346,38 +1267,59 @@ export function M4ReplayThreatVisual({ /> ) : undefined; + if (!timeline || !canonicalContent) { + return ( +
+ undefined} + onExpandedChange={setExpanded} + > + {content} + +
+ ); + } return ( -
- - {content} - -
+ 1} + mediaContent={canonicalContent.mediaContent} + spatialContent={canonicalContent.spatialContent} + emptyMessage="Выберите VIDEO/CAMERA или 3D/PLAN. Общий таймлайн останется на месте." + deckOverlays={canonicalContent.deckOverlays} + actions={actions} + overlay={overlay} + transport={transport} + trailingActions={trailingActions} + onMediaModeChange={handleMediaModeChange} + onSpatialModeChange={handleSpatialModeChange} + onExpandedChange={setExpanded} + onSplitPrimarySizeChange={setSplitPrimarySize} + /> ); } diff --git a/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx b/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx index ef14d3e..4b37f56 100644 --- a/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx @@ -1,9 +1,29 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Button, SegmentedControl } from "@nodedc/ui-react"; +import { + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, +} from "react"; +import { + Button, + Icon, + IconButton, + SegmentedControl, +} from "@nodedc/ui-react"; -import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer"; -import { LaboratoryRecordedClipPlayer } from "../../components/laboratory/LaboratoryRecordedClipPlayer"; -import { RerunViewport } from "../../components/RerunViewport"; +import { ObservationTimeline } from "../../components/ObservationTimeline"; +import { + 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 { LaboratoryEvidence, LaboratoryResultSummary, @@ -11,18 +31,21 @@ import { LaboratoryWorkTemplate, } from "../../components/laboratory/LaboratoryPresentation"; import { - RecordedEvidenceSemanticMaskOverlay, type RecordedEvidenceSemanticClass, type RecordedEvidenceSemanticPaletteEntry, } from "../../components/laboratory/RecordedEvidenceSemanticMaskOverlay"; import { + fetchCanonicalRecordedLabSpatialFrame, fetchVegetationShadowResult, + fetchVegetationRouteTgsAnchor, vegetationFullRouteMaskUrl, vegetationVideoMaskUrl, + type CanonicalRecordedLabSpatialFrame, type VegetationFullRouteLayer, type VegetationFullRouteReview, type VegetationMixedRouteCase, type VegetationMixedRouteReview, + type VegetationRouteTgsAnchor, type VegetationShadowResult, } from "../../core/laboratory/vegetationShadow"; import { @@ -33,16 +56,7 @@ import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence"; import { recordedObservationSources } from "../../core/observation/recordedObservationSources"; import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive"; import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions"; -import { - recordedSessionRerunProfile, - type RerunPlaybackController, -} from "../../core/observation/viewerProfile"; 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 { return value.toLocaleString("ru-RU", { maximumFractionDigits: digits }); @@ -53,6 +67,19 @@ const FULL_ROUTE_SEMANTIC_MODES = [ { value: "vegetation", label: "ПРИРОДА · DDRNet" }, ] 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): { classes: readonly RecordedEvidenceSemanticClass[]; palette: readonly RecordedEvidenceSemanticPaletteEntry[]; @@ -68,17 +95,125 @@ function semanticPresentation(layer: VegetationFullRouteLayer): { }; } -function nearestTgsCase( +function causalTgsCase( cases: readonly VegetationMixedRouteCase[], sequence: number, ): VegetationMixedRouteCase | null { - return cases.reduce((nearest, candidate) => ( - !nearest - || Math.abs(candidate.sourceSequence - sequence) - < Math.abs(nearest.sourceSequence - sequence) + if (!cases.length) return null; + return cases.reduce((latest, candidate) => ( + candidate.sourceSequence <= sequence + && (!latest || candidate.sourceSequence > latest.sourceSequence) ? candidate - : nearest - ), null); + : latest + ), 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(null); + const [error, setError] = useState(null); + const desiredRef = useRef(null); + const runningRef = useRef(false); + const mountedRef = useRef(true); + const cacheRef = useRef(new Map()); + 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({ @@ -88,41 +223,57 @@ function FullRouteReviewEvidence({ resultId: string; review: VegetationFullRouteReview; }) { - const [sequence, setSequence] = useState(1); - const [playing, setPlaying] = useState(false); - const [playbackRate, setPlaybackRate] = useState(1); + const { + mediaMode, + spatialMode, + splitView, + splitPrimarySize, + splitOrientation, + expanded, + onMediaModeChange: handleMediaModeChange, + onSpatialModeChange: handleSpatialModeChange, + onSplitPrimarySizeChange: setSplitPrimarySize, + onExpandedChange: setExpanded, + } = useCanonicalRecordedLabReplayState({ + initialMediaMode: "video", + initialSpatialMode: "3d", + }); const [semanticLayer, setSemanticLayer] = useState<"city" | "vegetation">("vegetation"); const [showCameraSemantic, setShowCameraSemantic] = useState(true); - const [expanded, setExpanded] = useState(false); - const [evidenceMode, setEvidenceMode] = useState("3d"); - const [cameraVisible, setCameraVisible] = useState(true); - const [sceneSettings, setSceneSettings] = useState(() => ({ - ...defaultSceneSettings, - accumulationSeconds: 12, - showPoints: true, - showTrajectory: true, - })); + const [showSourcePoints, setShowSourcePoints] = useState(true); + const [showLocalSlam, setShowLocalSlam] = useState(true); + const [showTgs, setShowTgs] = useState(true); const [videoSource, setVideoSource] = useState(null); const [replayLaunch, setReplayLaunch] = useState(null); const [videoError, setVideoError] = useState(null); const [linkedReview, setLinkedReview] = useState(null); const [linkedReviewError, setLinkedReviewError] = useState(null); - const spatialControllerRef = useRef(null); - const frames = useMemo( - () => review.frameSourceTimesNs.map((sourceTimeNs, index) => ({ - sequence: index + 1, - sourceTimeNs, - })), - [review.frameSourceTimesNs], + const [tgsAnchor, setTgsAnchor] = useState(null); + const [tgsAnchorLoading, setTgsAnchorLoading] = useState(false); + const [tgsAnchorError, setTgsAnchorError] = useState(null); + const metricSceneRef = useRef(null); + const playbackRange = useMemo(() => ({ + startSeconds: review.timelineStartSeconds, + 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 semantic = useMemo(() => semanticPresentation(layer), [layer]); - const maskSequence = sequence - 1; 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) .map((candidate) => vegetationFullRouteMaskUrl(resultId, semanticLayer, candidate)) - : [], [maskSequence, resultId, review.frameCount, semanticLayer, showCameraSemantic]); + : [], [resultId, review.frameCount, semanticLayer, sequenceIndex, showCameraSemantic]); useEffect(() => { const controller = new AbortController(); @@ -185,246 +336,326 @@ function FullRouteReviewEvidence({ return () => controller.abort(); }, [review.linkedRouteReviewResultId, review.sessionId, review.sourceId]); - const spatialProfile = useMemo(() => replayLaunch ? recordedSessionRerunProfile({ - sourceUrl: replayLaunch.viewerSourceUrl, - artifact: { - sourceUrl: replayLaunch.sourceUrl, - viewerSourceUrl: replayLaunch.viewerSourceUrl, - byteLength: replayLaunch.byteLength, - sha256: replayLaunch.sha256, - }, - autoplayWhenReady: false, - presentationGate: "ready", - expectedTimelineStartSeconds: replayLaunch.timelineStartSeconds, - 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); - }, []); + const selectedTgsCase = linkedReview + ? causalTgsCase(linkedReview.cases, sequence) + : null; + const selectedTgsTimeNs = selectedTgsCase + ? review.frameSourceTimesNs[selectedTgsCase.sourceSequence - 1] + ?? Math.round(selectedTgsCase.sessionSeconds * 1_000_000_000) + : spatialRequestTimeNs; + const tgsReferenceEvidence = useCanonicalRavSpatialFrame( + review, + replayLaunch, + selectedTgsTimeNs, + ); useEffect(() => { - const controller = spatialControllerRef.current; - if (!controller || !activeFrame) return; - controller.setPlaying(false); - controller.seek(activeFrame.sourceTimeNs); - }, [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(); + if (!showTgs || !selectedTgsCase) { + setTgsAnchor(null); + setTgsAnchorLoading(false); + setTgsAnchorError(null); return; } - setEvidenceMode(nextMode); - }, [selectTgsPlan]); - const cameraPresentation = evidenceMode === "camera" - ? "primary" - : cameraVisible ? "companion" : "hidden"; - const spatialScene = evidenceMode === "plan" ? ( - selectedTgsCase ? ( -
- {`TGS { + if (!controller.signal.aborted) setTgsAnchor(anchor); + }).catch((caught: unknown) => { + if (!controller.signal.aborted) { + 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(() => { + 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 = ( +
+ {videoSource ? ( + -
- TGS COSTMAP · ЯКОРЬ {selectedTgsCase.sourceSequence} · {selectedTgsCase.tgs.occupiedCells} OCCUPIED + ) : ( +
+ {videoError ?? "Открываем автономный RAVNOVES004TREE source…"}
-
- ) : ( -
- {linkedReviewError ?? "Открываем sealed TGS anchors…"} -
- ) - ) : spatialProfile ? ( - - ) : ( -
- Открываем sealed RRD, source points и SLAM trajectory… + )}
); - return ( - + {spatialEvidence.frame ? ( + + ) : ( +
+ {spatialEvidence.loading ?
+ )} + {showTgs && selectedTgsCase ? ( +
+ {tgsAnchorError ?? linkedReviewError ?? (tgsAnchorLoading + ? `Открываем sealed TGS anchor ${selectedTgsCase.sourceSequence}; source/SLAM и общий clock продолжаются.` + : `TGS anchor ${selectedTgsCase.sourceSequence} из 10; source/SLAM и общий clock продолжаются.`)} +
+ ) : null} + + ) : null; + + const mediaLayerControls = ( +
-
- {videoSource ? ( - - - { - setSemanticLayer(value); - setShowCameraSemantic(true); - }} - /> - - )} - spatialControls={( - <> - - - - - - )} - cameraOverlay={( - <> -
- {showCameraSemantic - ? `${semanticLayer === "city" ? "EoMT CITY" : "DDRNet NATURE"} · КАДР ${sequence}/${review.frameCount}` - : `SOURCE · КАДР ${sequence}/${review.frameCount}`} -
- {showCameraSemantic ? ( -
- -
- ) : null} - - )} - /> - ) : ( -
- {videoError ?? "Открываем автономный RAVNOVES004TREE source…"} -
- )} - {videoSource ? ( - - ) : null} + + { + setSemanticLayer(value); + setShowCameraSemantic(true); + }} + /> +
+ ); + + const spatialLayerControls = ( +
+ + + + +
+ ); + + const resetSpatialView = ( + metricSceneRef.current?.resetView()} + > + + + ); + + const overlayPanePercent = splitView && splitOrientation === "vertical" + ? splitPrimarySize + : 100; + const overlay = ( +
+
+ RAVNOVES004TREE · recorded realtime + frame {sequence}/{review.frameCount} + + +{(playbackController.playback.currentSeconds - review.timelineStartSeconds).toFixed(3)} с + · {playbackController.playback.playing ? "воспроизведение" : "пауза / seek"} +
- +
+ Spatial evidence + {showTgs && selectedTgsCase + ? `TGS anchor ${selectedTgsCase.sourceSequence} · ${selectedTgsCase.tgs.occupiedCells} occupied` + : "source RRD · points + SLAM"} + {showTgs + ? "latest causal of 10 sealed anchors · continuous playback retained" + : "causal 1 s view · grayscale intensity · recorded source identity"} +
+
+ ); + + const transport = ( + playbackController.seek(timeNs / 1_000_000_000)} + onPlayingChange={playbackController.setPlaying} + onPlaybackRateChange={playbackController.setRate} + showJumpToEnd={false} + /> + ); + + return ( + ); } diff --git a/apps/control-station/test/m4ReplayThreat.test.mjs b/apps/control-station/test/m4ReplayThreat.test.mjs index 164d40c..21f932d 100644 --- a/apps/control-station/test/m4ReplayThreat.test.mjs +++ b/apps/control-station/test/m4ReplayThreat.test.mjs @@ -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 () => { - 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/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/components/laboratory/RecordedEvidenceImageScene.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, / 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(visualCss, /m4-replay-threat-visual__deck > \.nodedc-split-pane/); 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 () => { - const visual = await readFile( - new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), - "utf8", - ); - assert.match(visual, /const mediaPane = \(/); - assert.match(visual, /hidden=\{!mediaMode\}/); + const [visual, canonical] = await Promise.all([ + readFile( + 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(canonical, /const mediaPane = \(/); + assert.match(canonical, /hidden=\{mediaMode === "none"\}/); assert.match(visual, /data-media="video"/); assert.match(visual, /hidden=\{mediaMode !== "video"\}/); assert.match(visual, /\{videoSource \? \(/); diff --git a/apps/control-station/test/semanticEvidencePrimitives.test.mjs b/apps/control-station/test/semanticEvidencePrimitives.test.mjs index 9d55791..fe9fc8a 100644 --- a/apps/control-station/test/semanticEvidencePrimitives.test.mjs +++ b/apps/control-station/test/semanticEvidencePrimitives.test.mjs @@ -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 () => { - const source = await readFile( - new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), - "utf8", - ); + const [source, canonical] = await Promise.all([ + readFile( + 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, /showSpatialSemantic/); 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, /aria-label="Слои камеры и видео"/); assert.match(source, /aria-label="Слои 3D и плана"/); - assert.match(source, /data-pane-mode="media"/); - assert.match(source, /data-pane-mode="spatial"/); - assert.match(source, /modeControlsVisible=\{!splitView\}/); + assert.match(canonical, /data-pane-mode="media"/); + assert.match(canonical, /data-pane-mode="spatial"/); + assert.match(canonical, /modeControlsVisible=\{!splitView\}/); assert.match(source, /semanticOverlay=\{mediaMode === "video" \? semanticOverlay : undefined\}/); }); diff --git a/apps/control-station/test/vegetationShadow.test.mjs b/apps/control-station/test/vegetationShadow.test.mjs index 8e9791f..ccde03a 100644 --- a/apps/control-station/test/vegetationShadow.test.mjs +++ b/apps/control-station/test/vegetationShadow.test.mjs @@ -6,7 +6,9 @@ import { createServer } from "vite"; let server; let fetchVegetationBenchmarkResult; +let fetchCanonicalRecordedLabSpatialFrame; let fetchVegetationShadowResult; +let fetchVegetationRouteTgsAnchor; let vegetationFullRouteMaskUrl; before(async () => { @@ -17,7 +19,9 @@ before(async () => { }); ({ fetchVegetationBenchmarkResult, + fetchCanonicalRecordedLabSpatialFrame, fetchVegetationShadowResult, + fetchVegetationRouteTgsAnchor, vegetationFullRouteMaskUrl, } = await server.ssrLoadModule( "/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); }); +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 () => { - const [resultSource, benchmarkSource, m49Source] = await Promise.all([ + const [resultSource, benchmarkSource, m49Source, canonicalSource] = await Promise.all([ readFile( new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url), "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), "utf8", ), + readFile( + new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url), + "utf8", + ), ]); assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/); assert.match(resultSource, /M49TgsFullShadowEvidence/); @@ -389,12 +457,28 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr assert.equal(resultSource.match(/ 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, /linkedTgsResultId/); assert.match(benchmarkSource, /M48MaskComparisonVisual/); diff --git a/src/k1link/sessions/canonical_lab_spatial.py b/src/k1link/sessions/canonical_lab_spatial.py new file mode 100644 index 0000000..a7a19cb --- /dev/null +++ b/src/k1link/sessions/canonical_lab_spatial.py @@ -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(), + } diff --git a/src/k1link/web/session_api.py b/src/k1link/web/session_api.py index 76b2f87..e6837da 100644 --- a/src/k1link/web/session_api.py +++ b/src/k1link/web/session_api.py @@ -37,6 +37,7 @@ from k1link.sessions import ( SessionStore, validate_recorded_media_timeline, ) +from k1link.sessions.canonical_lab_spatial import canonical_lab_spatial_frame from k1link.sessions.plugin_contract import RecordedPointColorRenderer from k1link.viewer.recorded import ( APPLICATION_ID as RECORDED_APPLICATION_ID, @@ -824,6 +825,76 @@ def build_session_router( **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") async def get_observation_session_blueprint( session_id: str, diff --git a/src/k1link/web/vegetation_shadow_lab_api.py b/src/k1link/web/vegetation_shadow_lab_api.py index 1ae9bde..bda2885 100644 --- a/src/k1link/web/vegetation_shadow_lab_api.py +++ b/src/k1link/web/vegetation_shadow_lab_api.py @@ -11,8 +11,9 @@ from functools import lru_cache from pathlib import Path, PurePosixPath from typing import Any, Final +import numpy as np 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_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 +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: member = f"masks/frame-{sequence + 1:06d}.png" try: diff --git a/tests/test_session_api.py b/tests/test_session_api.py index b48f7e3..740dc42 100644 --- a/tests/test_session_api.py +++ b/tests/test_session_api.py @@ -17,6 +17,7 @@ from fastapi.responses import FileResponse from fastapi.routing import APIRoute 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.device_plugins.xgrids_k1 import xgrids_k1_archive_source 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() +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( tmp_path: Path, ) -> None: diff --git a/tests/test_vegetation_shadow_lab.py b/tests/test_vegetation_shadow_lab.py index 703064f..0d077e3 100644 --- a/tests/test_vegetation_shadow_lab.py +++ b/tests/test_vegetation_shadow_lab.py @@ -21,11 +21,48 @@ from k1link.laboratory import LaboratoryEvidenceRegistry 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_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] +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( tmp_path: Path, monkeypatch,