fix(lab): enforce canonical replay runtime
This commit is contained in:
@@ -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<HTMLVideoElement>(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;
|
||||
|
||||
@@ -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,
|
||||
cameraRef,
|
||||
cameraOverlay,
|
||||
cameraControls,
|
||||
alternativeScene,
|
||||
spatialControls,
|
||||
onSequenceChange,
|
||||
onPlayingChange,
|
||||
onPlaybackRateChange,
|
||||
@@ -99,9 +97,7 @@ export function LaboratoryRecordedClipPlayer({
|
||||
sourceCount: number;
|
||||
cameraRef?: RefObject<HTMLDivElement | null>;
|
||||
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 ? (
|
||||
<div
|
||||
className="laboratory-recorded-clip-player__pane-controls"
|
||||
data-pane="spatial"
|
||||
>
|
||||
{spatialControls}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
const cameraPane = (
|
||||
@@ -207,14 +195,6 @@ export function LaboratoryRecordedClipPlayer({
|
||||
/>
|
||||
) : null}
|
||||
{cameraPresentation !== "hidden" ? cameraOverlay : null}
|
||||
{cameraPresentation !== "hidden" && cameraControls ? (
|
||||
<div
|
||||
className="laboratory-recorded-clip-player__pane-controls"
|
||||
data-pane="camera"
|
||||
>
|
||||
{cameraControls}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
|
||||
@@ -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 (
|
||||
<div className="recorded-evidence-video-scene">
|
||||
@@ -59,6 +63,8 @@ export function RecordedEvidenceVideoScene({
|
||||
segmentCount={segmentCount}
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
onPlayingRejected={onPlayingRejected}
|
||||
playbackAuthority={playbackAuthority}
|
||||
playbackTransport={playbackTransport}
|
||||
/>
|
||||
{semanticOverlay ? (
|
||||
<RecordedEvidenceSemanticMaskOverlay
|
||||
|
||||
@@ -130,8 +130,12 @@ export function useRecordedEvidencePlayback(
|
||||
|
||||
const synchronize = useCallback((next: RecordedObservationPlayback) => {
|
||||
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,
|
||||
|
||||
@@ -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<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(
|
||||
resultId: string,
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<M4ThreatMediaMode | null>("video");
|
||||
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode | null>(
|
||||
const {
|
||||
mediaMode,
|
||||
spatialMode,
|
||||
splitView,
|
||||
splitPrimarySize,
|
||||
splitOrientation,
|
||||
expanded,
|
||||
onMediaModeChange: handleMediaModeChange,
|
||||
onSpatialModeChange: handleSpatialModeChange,
|
||||
onSplitPrimarySizeChange: setSplitPrimarySize,
|
||||
onExpandedChange: setExpanded,
|
||||
} = useCanonicalRecordedLabReplayState<M4ThreatMediaMode, LaboratoryMetricSceneMode>({
|
||||
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<SplitPaneOrientation>(() => (
|
||||
typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches
|
||||
? "horizontal"
|
||||
: "vertical"
|
||||
));
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedReviewAnchorIndex, setSelectedReviewAnchorIndex] = useState(0);
|
||||
const availableSemanticLayers = useMemo<readonly M4ReplayThreatSemanticLayer[]>(
|
||||
() => 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 = (
|
||||
<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
|
||||
|| (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 = <SpatialState message={metadata.error} />;
|
||||
} else if (!timeline) {
|
||||
@@ -1121,23 +1091,8 @@ export function M4ReplayThreatVisual({
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const mediaPane = (
|
||||
<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}
|
||||
const mediaContent = (
|
||||
<>
|
||||
<div
|
||||
className="m4-replay-threat-visual__media-layer"
|
||||
data-media="video"
|
||||
@@ -1161,7 +1116,8 @@ export function M4ReplayThreatVisual({
|
||||
}
|
||||
segmentCount={timeline.frameCount}
|
||||
onPlaybackChange={playbackController.synchronize}
|
||||
onPlayingRejected={() => playbackController.setPlaying(false)}
|
||||
playbackAuthority="host"
|
||||
playbackTransport="epoch-stream"
|
||||
/>
|
||||
) : videoError ? (
|
||||
<SpatialState message={videoError} />
|
||||
@@ -1183,27 +1139,11 @@ export function M4ReplayThreatVisual({
|
||||
ariaLabel={`${evidenceLabel} exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
const spatialPane = 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}
|
||||
const spatialContent = spatialMode ? (
|
||||
<>
|
||||
<LaboratoryMetricEvidenceScene
|
||||
ref={metricSceneRef}
|
||||
pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||
@@ -1265,31 +1205,11 @@ export function M4ReplayThreatVisual({
|
||||
: `На кадре ${frame.sequence + 1} нет body frame; ждём первый квалифицированный spatial evidence.`}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
content = (
|
||||
<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}
|
||||
const deckOverlays = (
|
||||
<>
|
||||
{timelineFrame.loading || displayingBufferedFrame ? (
|
||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
@@ -1320,8 +1240,9 @@ export function M4ReplayThreatVisual({
|
||||
<span>{semanticIntegrityError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
canonicalContent = { mediaContent, spatialContent, deckOverlays };
|
||||
}
|
||||
|
||||
const transport = timeline ? (
|
||||
@@ -1346,38 +1267,59 @@ export function M4ReplayThreatVisual({
|
||||
/>
|
||||
) : 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 (
|
||||
<div className="l3-visual-audit m4-replay-threat-visual">
|
||||
<LaboratoryEvidenceViewer
|
||||
label={activeSemantic
|
||||
? activeSemantic.label ?? "Semantic diagnostic replay"
|
||||
: `${evidenceLabel} recorded-realtime replay`}
|
||||
className="m4-replay-threat-evidence-viewer"
|
||||
mode={mediaMode ?? "none"}
|
||||
modes={[
|
||||
{ value: "video", label: "VIDEO" },
|
||||
{ value: "camera", label: "CAMERA" },
|
||||
]}
|
||||
secondaryMode={{
|
||||
value: spatialMode ?? "none",
|
||||
modes: [
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "PLAN" },
|
||||
],
|
||||
label: "3D и план",
|
||||
onChange: handleSpatialModeChange,
|
||||
}}
|
||||
expanded={expanded}
|
||||
onModeChange={handleMediaModeChange}
|
||||
onExpandedChange={setExpanded}
|
||||
modeControlsVisible={!splitView}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
transport={transport}
|
||||
trailingActions={trailingActions}
|
||||
>
|
||||
{content}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
<CanonicalRecordedLabReplay
|
||||
label={activeSemantic
|
||||
? activeSemantic.label ?? "Semantic diagnostic replay"
|
||||
: `${evidenceLabel} recorded-realtime replay`}
|
||||
mediaMode={mediaMode ?? "none"}
|
||||
mediaModes={[
|
||||
{ value: "video", label: "VIDEO" },
|
||||
{ value: "camera", label: "CAMERA" },
|
||||
]}
|
||||
spatialMode={spatialMode ?? "none"}
|
||||
spatialModes={[
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "PLAN" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
splitPrimarySize={splitPrimarySize}
|
||||
splitOrientation={splitOrientation}
|
||||
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео"}
|
||||
spatialAriaLabel={spatialMode === "3d" ? "Трёхмерная сцена" : "Вид сверху"}
|
||||
mediaLayerControls={mediaLayerControls}
|
||||
spatialLayerControls={spatialLayerControls}
|
||||
spatialLeadingControl={resetSpatialView}
|
||||
mediaMultiLayer={availableSemanticLayers.length > 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<VegetationMixedRouteCase | null>((nearest, candidate) => (
|
||||
!nearest
|
||||
|| Math.abs(candidate.sourceSequence - sequence)
|
||||
< Math.abs(nearest.sourceSequence - sequence)
|
||||
if (!cases.length) return null;
|
||||
return cases.reduce<VegetationMixedRouteCase | null>((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<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({
|
||||
@@ -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<FullRouteMediaMode, FullRouteSpatialMode>({
|
||||
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<M48BlindEvidenceMode>("3d");
|
||||
const [cameraVisible, setCameraVisible] = useState(true);
|
||||
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(() => ({
|
||||
...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<ObservationSourceDescriptor | null>(null);
|
||||
const [replayLaunch, setReplayLaunch] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||
const [videoError, setVideoError] = useState<string | null>(null);
|
||||
const [linkedReview, setLinkedReview] = useState<VegetationMixedRouteReview | null>(null);
|
||||
const [linkedReviewError, setLinkedReviewError] = useState<string | null>(null);
|
||||
const spatialControllerRef = useRef<RerunPlaybackController | null>(null);
|
||||
const frames = useMemo(
|
||||
() => review.frameSourceTimesNs.map((sourceTimeNs, index) => ({
|
||||
sequence: index + 1,
|
||||
sourceTimeNs,
|
||||
})),
|
||||
[review.frameSourceTimesNs],
|
||||
const [tgsAnchor, setTgsAnchor] = useState<VegetationRouteTgsAnchor | null>(null);
|
||||
const [tgsAnchorLoading, setTgsAnchorLoading] = useState(false);
|
||||
const [tgsAnchorError, setTgsAnchorError] = useState<string | null>(null);
|
||||
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(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 ? (
|
||||
<div className="recorded-evidence-image-scene">
|
||||
<img
|
||||
src={selectedTgsCase.assets.tgs}
|
||||
alt={`TGS costmap · sequence ${selectedTgsCase.sourceSequence}`}
|
||||
draggable={false}
|
||||
const controller = new AbortController();
|
||||
setTgsAnchorLoading(true);
|
||||
setTgsAnchorError(null);
|
||||
void fetchVegetationRouteTgsAnchor(
|
||||
review.linkedRouteReviewResultId,
|
||||
selectedTgsCase.sourceSequence,
|
||||
{ signal: controller.signal },
|
||||
).then((anchor) => {
|
||||
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<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 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>
|
||||
);
|
||||
|
||||
return (
|
||||
<LaboratoryEvidenceViewer
|
||||
label="RAVNOVES004TREE full recorded review"
|
||||
className="m48-atlas-visual"
|
||||
mode={semanticLayer}
|
||||
modes={FULL_ROUTE_SEMANTIC_MODES}
|
||||
expanded={expanded}
|
||||
onModeChange={setSemanticLayer}
|
||||
onExpandedChange={setExpanded}
|
||||
modeControlsVisible={false}
|
||||
chromeLayout="stacked"
|
||||
const spatialContent = spatialMode ? (
|
||||
<>
|
||||
{spatialEvidence.frame ? (
|
||||
<LaboratoryMetricEvidenceScene
|
||||
ref={metricSceneRef}
|
||||
pointCloudBodyXyzM={showSourcePoints
|
||||
? spatialEvidence.frame.sourcePointsBodyXyzM
|
||||
: []}
|
||||
localSurfaceBodyXyzM={showLocalSlam
|
||||
? spatialEvidence.frame.localSlamBodyXyzM
|
||||
: []}
|
||||
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">
|
||||
{videoSource ? (
|
||||
<LaboratoryRecordedClipPlayer
|
||||
source={videoSource}
|
||||
segmentCount={review.frameCount}
|
||||
frames={frames}
|
||||
sequence={sequence}
|
||||
playing={playing}
|
||||
playbackRate={playbackRate}
|
||||
cameraPresentation={cameraPresentation}
|
||||
continuousPlayback
|
||||
sourceCount={4}
|
||||
onSequenceChange={setSequence}
|
||||
onPlayingChange={setPlaying}
|
||||
onPlaybackRateChange={setPlaybackRate}
|
||||
alternativeScene={spatialScene}
|
||||
cameraControls={(
|
||||
<>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showCameraSemantic ? "primary" : "secondary"}
|
||||
aria-pressed={showCameraSemantic}
|
||||
onClick={() => setShowCameraSemantic((visible) => !visible)}
|
||||
>
|
||||
SEMANTICS
|
||||
</Button>
|
||||
<SegmentedControl
|
||||
value={semanticLayer}
|
||||
items={[...FULL_ROUTE_SEMANTIC_MODES]}
|
||||
label="Источник семантики"
|
||||
onChange={(value) => {
|
||||
setSemanticLayer(value);
|
||||
setShowCameraSemantic(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
spatialControls={(
|
||||
<>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={sceneSettings.showPoints ? "primary" : "secondary"}
|
||||
aria-pressed={sceneSettings.showPoints}
|
||||
onClick={() => {
|
||||
setEvidenceMode("3d");
|
||||
setSceneSettings((settings) => ({
|
||||
...settings,
|
||||
showPoints: !settings.showPoints,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
SOURCE POINTS
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={sceneSettings.showTrajectory ? "primary" : "secondary"}
|
||||
aria-pressed={sceneSettings.showTrajectory}
|
||||
onClick={() => {
|
||||
setEvidenceMode("3d");
|
||||
setSceneSettings((settings) => ({
|
||||
...settings,
|
||||
showTrajectory: !settings.showTrajectory,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
LOCAL SLAM
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={evidenceMode === "plan" ? "primary" : "secondary"}
|
||||
aria-pressed={evidenceMode === "plan"}
|
||||
disabled={!linkedReview}
|
||||
title={linkedReviewError ?? "10 sealed causal TGS anchors; continuous TGS отсутствует"}
|
||||
onClick={selectTgsPlan}
|
||||
>
|
||||
TGS COSTMAP
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant="secondary"
|
||||
disabled
|
||||
title="Для RAV004 не опубликован point-aligned 3D semantic archive"
|
||||
>
|
||||
SEMANTICS
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
cameraOverlay={(
|
||||
<>
|
||||
<div className="m48-clip-player__pane-label" data-pane="camera">
|
||||
{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}
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showCameraSemantic ? "primary" : "secondary"}
|
||||
aria-pressed={showCameraSemantic}
|
||||
onClick={() => setShowCameraSemantic((visible) => !visible)}
|
||||
>
|
||||
SEMANTICS
|
||||
</Button>
|
||||
<SegmentedControl
|
||||
value={semanticLayer}
|
||||
items={[...FULL_ROUTE_SEMANTIC_MODES]}
|
||||
label="Источник семантики"
|
||||
onChange={(value) => {
|
||||
setSemanticLayer(value);
|
||||
setShowCameraSemantic(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const spatialLayerControls = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Слои 3D и плана"
|
||||
>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showSourcePoints ? "primary" : "secondary"}
|
||||
aria-pressed={showSourcePoints}
|
||||
onClick={() => setShowSourcePoints((visible) => !visible)}
|
||||
>
|
||||
SOURCE POINTS
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showLocalSlam ? "primary" : "secondary"}
|
||||
aria-pressed={showLocalSlam}
|
||||
onClick={() => setShowLocalSlam((visible) => !visible)}
|
||||
>
|
||||
LOCAL SLAM
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showTgs ? "primary" : "secondary"}
|
||||
aria-pressed={showTgs}
|
||||
disabled={!linkedReview}
|
||||
title={linkedReviewError ?? "10 sealed causal TGS anchors; continuous TGS отсутствует"}
|
||||
onClick={() => setShowTgs((visible) => !visible)}
|
||||
>
|
||||
TGS COSTMAP
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showCameraSemantic ? "primary" : "secondary"}
|
||||
aria-pressed={showCameraSemantic}
|
||||
title="Recorded semantic layer; camera-aligned prediction, без выдуманной 3D-проекции"
|
||||
onClick={() => setShowCameraSemantic((visible) => !visible)}
|
||||
>
|
||||
SEMANTICS
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const resetSpatialView = (
|
||||
<IconButton
|
||||
label="Сбросить ракурс"
|
||||
onClick={() => metricSceneRef.current?.resetView()}
|
||||
>
|
||||
<Icon name="refresh" size={16} />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
const overlayPanePercent = splitView && splitOrientation === "vertical"
|
||||
? splitPrimarySize
|
||||
: 100;
|
||||
const overlay = (
|
||||
<div
|
||||
className="l3-visual-audit__overlay m4-replay-threat-visual__overlay"
|
||||
style={{
|
||||
"--m4-replay-threat-overlay-pane-width": `${overlayPanePercent}%`,
|
||||
} as CSSProperties}
|
||||
>
|
||||
<div>
|
||||
<span>RAVNOVES004TREE · recorded realtime</span>
|
||||
<strong>frame {sequence}/{review.frameCount}</strong>
|
||||
<small>
|
||||
+{(playbackController.playback.currentSeconds - review.timelineStartSeconds).toFixed(3)} с
|
||||
· {playbackController.playback.playing ? "воспроизведение" : "пауза / seek"}
|
||||
</small>
|
||||
</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 () => {
|
||||
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, /<RecordedEvidenceVideoScene/);
|
||||
assert.match(visual, /<RecordedEvidenceImageScene/);
|
||||
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, /lastSpatialFrameRef/);
|
||||
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, /mediaMode/);
|
||||
assert.match(visual, /spatialMode/);
|
||||
assert.match(visual, /current === next \? null : next/);
|
||||
assert.match(visual, /data-split=\{splitView \? "true" : undefined\}/);
|
||||
assert.match(visual, /<SplitPane/);
|
||||
assert.match(visual, /primarySize=\{splitView \? splitPrimarySize : mediaMode \? 100 : 0\}/);
|
||||
assert.match(visual, /resizable=\{splitView\}/);
|
||||
assert.match(visual, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
|
||||
assert.match(visual, /secondaryMode=\{\{/);
|
||||
assert.match(canonical, /current === next \? null : next/);
|
||||
assert.match(canonical, /data-split=\{splitView \? "true" : undefined\}/);
|
||||
assert.match(canonical, /<SplitPane/);
|
||||
assert.match(canonical, /primary=\{mediaPane\}/);
|
||||
assert.match(canonical, /secondary=\{spatialPane/);
|
||||
assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/);
|
||||
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, /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, /segmentCount=\{timeline\.frameCount\}/);
|
||||
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(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 \? \(/);
|
||||
|
||||
@@ -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\}/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(/<LaboratoryEvidence\b/g)?.length, 2);
|
||||
assert.doesNotMatch(resultSource, /RAVNOVES004TREE mixed route review/);
|
||||
assert.match(resultSource, /RAVNOVES004TREE full recorded review/);
|
||||
assert.match(resultSource, /LaboratoryRecordedClipPlayer/);
|
||||
assert.match(resultSource, /RerunViewport/);
|
||||
assert.match(resultSource, /M48EvidenceModeRail/);
|
||||
assert.match(resultSource, /CanonicalRecordedLabReplay/);
|
||||
assert.match(resultSource, /RecordedEvidenceVideoScene/);
|
||||
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, /LOCAL SLAM/);
|
||||
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, /linkedTgsResultId/);
|
||||
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(),
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user