feat(lab): add recorded realtime spatial playback
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Button, Icon } from "@nodedc/ui-react";
|
import { Button, Icon, Select } from "@nodedc/ui-react";
|
||||||
|
|
||||||
import type { ObservationTimelineMode } from "../core/runtime/contracts";
|
import type { ObservationTimelineMode } from "../core/runtime/contracts";
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ export function ObservationTimeline({
|
|||||||
onSeek,
|
onSeek,
|
||||||
onPlayingChange,
|
onPlayingChange,
|
||||||
onJumpToEnd,
|
onJumpToEnd,
|
||||||
|
playbackRate,
|
||||||
|
onPlaybackRateChange,
|
||||||
accumulationSeconds,
|
accumulationSeconds,
|
||||||
onAccumulationChange,
|
onAccumulationChange,
|
||||||
onAccumulationCommit,
|
onAccumulationCommit,
|
||||||
@@ -30,6 +32,8 @@ export function ObservationTimeline({
|
|||||||
onSeek?: (timeNs: number) => void;
|
onSeek?: (timeNs: number) => void;
|
||||||
onPlayingChange?: (playing: boolean) => void;
|
onPlayingChange?: (playing: boolean) => void;
|
||||||
onJumpToEnd?: () => void;
|
onJumpToEnd?: () => void;
|
||||||
|
playbackRate?: number;
|
||||||
|
onPlaybackRateChange?: (rate: number) => void;
|
||||||
accumulationSeconds?: number;
|
accumulationSeconds?: number;
|
||||||
onAccumulationChange?: (value: number) => void;
|
onAccumulationChange?: (value: number) => void;
|
||||||
onAccumulationCommit?: () => void;
|
onAccumulationCommit?: () => void;
|
||||||
@@ -102,6 +106,20 @@ export function ObservationTimeline({
|
|||||||
>
|
>
|
||||||
{buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
{buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
|
||||||
|
<Select
|
||||||
|
label="Скорость воспроизведения"
|
||||||
|
value={String(playbackRate)}
|
||||||
|
options={[
|
||||||
|
{ value: "0.5", label: "0,5×" },
|
||||||
|
{ value: "1", label: "1×" },
|
||||||
|
{ value: "2", label: "2×" },
|
||||||
|
]}
|
||||||
|
variant="split"
|
||||||
|
menuWidth="anchor"
|
||||||
|
onChange={(value) => onPlaybackRateChange(Number(value))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<input
|
<input
|
||||||
className="observation-timeline__track"
|
className="observation-timeline__track"
|
||||||
type="range"
|
type="range"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
|||||||
export interface RecordedObservationPlayback {
|
export interface RecordedObservationPlayback {
|
||||||
currentSeconds: number;
|
currentSeconds: number;
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
|
rate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecordedMediaArchive {
|
export interface RecordedMediaArchive {
|
||||||
@@ -290,6 +291,9 @@ export function RecordedFmp4Player({
|
|||||||
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
|
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
|
||||||
const [bufferRevision, setBufferRevision] = useState(0);
|
const [bufferRevision, setBufferRevision] = useState(0);
|
||||||
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
|
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
|
||||||
|
const playbackRate = playback?.rate && Number.isFinite(playback.rate)
|
||||||
|
? Math.min(4, Math.max(0.25, playback.rate))
|
||||||
|
: 1;
|
||||||
const epoch = useMemo(
|
const epoch = useMemo(
|
||||||
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
|
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
|
||||||
[archive?.manifest.epochs, currentSeconds],
|
[archive?.manifest.epochs, currentSeconds],
|
||||||
@@ -460,12 +464,21 @@ export function RecordedFmp4Player({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
video.playbackRate = playbackRate;
|
||||||
if (playback?.playing) {
|
if (playback?.playing) {
|
||||||
void video.play().catch(() => undefined);
|
void video.play().catch(() => undefined);
|
||||||
} else {
|
} else {
|
||||||
video.pause();
|
video.pause();
|
||||||
}
|
}
|
||||||
}, [archive?.byteLength, bufferRevision, currentSeconds, epoch, playback?.playing, visualState]);
|
}, [
|
||||||
|
archive?.byteLength,
|
||||||
|
bufferRevision,
|
||||||
|
currentSeconds,
|
||||||
|
epoch,
|
||||||
|
playback?.playing,
|
||||||
|
playbackRate,
|
||||||
|
visualState,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@@ -475,6 +488,7 @@ export function RecordedFmp4Player({
|
|||||||
onPlaybackChangeRef.current?.({
|
onPlaybackChangeRef.current?.({
|
||||||
currentSeconds: epoch.timelineStartSeconds + video.currentTime,
|
currentSeconds: epoch.timelineStartSeconds + video.currentTime,
|
||||||
playing: !video.paused && !video.ended,
|
playing: !video.paused && !video.ended,
|
||||||
|
rate: video.playbackRate,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const scheduleVideoFrame = () => {
|
const scheduleVideoFrame = () => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function LaboratoryEvidenceViewer<
|
|||||||
actions,
|
actions,
|
||||||
secondaryMode,
|
secondaryMode,
|
||||||
overlay,
|
overlay,
|
||||||
|
transport,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -46,6 +47,7 @@ export function LaboratoryEvidenceViewer<
|
|||||||
onChange: (mode: U) => void;
|
onChange: (mode: U) => void;
|
||||||
};
|
};
|
||||||
overlay?: ReactNode;
|
overlay?: ReactNode;
|
||||||
|
transport?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const expandButtonRef = useRef<HTMLButtonElement | null>(null);
|
const expandButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
@@ -75,6 +77,11 @@ export function LaboratoryEvidenceViewer<
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{overlay}
|
{overlay}
|
||||||
|
{transport ? (
|
||||||
|
<div className="laboratory-evidence-viewer__transport">
|
||||||
|
{transport}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="laboratory-evidence-viewer__controls">
|
<div className="laboratory-evidence-viewer__controls">
|
||||||
{actions}
|
{actions}
|
||||||
{secondaryMode ? (
|
{secondaryMode ? (
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ function disposeRenderable(object: THREE.Object3D): void {
|
|||||||
materials.forEach((material) => material.dispose());
|
materials.forEach((material) => material.dispose());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearGroup(group: THREE.Group): void {
|
||||||
|
while (group.children.length) {
|
||||||
|
const child = group.children[0];
|
||||||
|
if (!child) break;
|
||||||
|
group.remove(child);
|
||||||
|
child.traverse(disposeRenderable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scenePoint(point: LaboratoryMetricPoint3): LaboratoryMetricPoint3 {
|
function scenePoint(point: LaboratoryMetricPoint3): LaboratoryMetricPoint3 {
|
||||||
return [point[0], point[2], -point[1]];
|
return [point[0], point[2], -point[1]];
|
||||||
}
|
}
|
||||||
@@ -101,7 +110,8 @@ export function LaboratoryMetricEvidenceScene({
|
|||||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||||
const controlsRef = useRef<OrbitControls | null>(null);
|
const controlsRef = useRef<OrbitControls | null>(null);
|
||||||
const contentRef = useRef<THREE.Group | null>(null);
|
const staticContentRef = useRef<THREE.Group | null>(null);
|
||||||
|
const dynamicContentRef = useRef<THREE.Group | null>(null);
|
||||||
const [renderError, setRenderError] = useState<string | null>(null);
|
const [renderError, setRenderError] = useState<string | null>(null);
|
||||||
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
||||||
const [showRollingMap, setShowRollingMap] = useState(true);
|
const [showRollingMap, setShowRollingMap] = useState(true);
|
||||||
@@ -137,12 +147,14 @@ export function LaboratoryMetricEvidenceScene({
|
|||||||
controls.screenSpacePanning = true;
|
controls.screenSpacePanning = true;
|
||||||
controls.minDistance = 0.4;
|
controls.minDistance = 0.4;
|
||||||
controls.maxDistance = 80;
|
controls.maxDistance = 80;
|
||||||
const content = new THREE.Group();
|
const staticContent = new THREE.Group();
|
||||||
scene.add(content);
|
const dynamicContent = new THREE.Group();
|
||||||
|
scene.add(staticContent, dynamicContent);
|
||||||
sceneRef.current = scene;
|
sceneRef.current = scene;
|
||||||
cameraRef.current = camera;
|
cameraRef.current = camera;
|
||||||
controlsRef.current = controls;
|
controlsRef.current = controls;
|
||||||
contentRef.current = content;
|
staticContentRef.current = staticContent;
|
||||||
|
dynamicContentRef.current = dynamicContent;
|
||||||
|
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
const width = Math.max(host.clientWidth, 1);
|
const width = Math.max(host.clientWidth, 1);
|
||||||
@@ -172,20 +184,16 @@ export function LaboratoryMetricEvidenceScene({
|
|||||||
sceneRef.current = null;
|
sceneRef.current = null;
|
||||||
cameraRef.current = null;
|
cameraRef.current = null;
|
||||||
controlsRef.current = null;
|
controlsRef.current = null;
|
||||||
contentRef.current = null;
|
staticContentRef.current = null;
|
||||||
|
dynamicContentRef.current = null;
|
||||||
};
|
};
|
||||||
}, [label]);
|
}, [label]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
const content = contentRef.current;
|
const content = dynamicContentRef.current;
|
||||||
if (!host || !content) return;
|
if (!host || !content) return;
|
||||||
while (content.children.length) {
|
clearGroup(content);
|
||||||
const child = content.children[0];
|
|
||||||
if (!child) break;
|
|
||||||
content.remove(child);
|
|
||||||
child.traverse(disposeRenderable);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showCurrentIncrement) {
|
if (showCurrentIncrement) {
|
||||||
const contextGeometry = new THREE.BufferGeometry();
|
const contextGeometry = new THREE.BufferGeometry();
|
||||||
@@ -244,6 +252,19 @@ export function LaboratoryMetricEvidenceScene({
|
|||||||
content.add(centroid);
|
content.add(centroid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}, [
|
||||||
|
obstacles,
|
||||||
|
pointCloudBodyXyzM,
|
||||||
|
showCurrentIncrement,
|
||||||
|
showRollingMap,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
const content = staticContentRef.current;
|
||||||
|
if (!host || !content) return;
|
||||||
|
clearGroup(content);
|
||||||
|
|
||||||
const corridorLength = rig.lengthM / 2 + corridor.forwardLengthM + corridor.rearMarginM;
|
const corridorLength = rig.lengthM / 2 + corridor.forwardLengthM + corridor.rearMarginM;
|
||||||
const corridorCenterX = (rig.lengthM / 2 + corridor.forwardLengthM - corridor.rearMarginM) / 2;
|
const corridorCenterX = (rig.lengthM / 2 + corridor.forwardLengthM - corridor.rearMarginM) / 2;
|
||||||
const corridorMesh = new THREE.Mesh(
|
const corridorMesh = new THREE.Mesh(
|
||||||
@@ -302,14 +323,7 @@ export function LaboratoryMetricEvidenceScene({
|
|||||||
material.depthWrite = false;
|
material.depthWrite = false;
|
||||||
});
|
});
|
||||||
content.add(grid);
|
content.add(grid);
|
||||||
}, [
|
}, [corridor, rig]);
|
||||||
corridor,
|
|
||||||
obstacles,
|
|
||||||
pointCloudBodyXyzM,
|
|
||||||
rig,
|
|
||||||
showCurrentIncrement,
|
|
||||||
showRollingMap,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const resetView = () => {
|
const resetView = () => {
|
||||||
const camera = cameraRef.current;
|
const camera = cameraRef.current;
|
||||||
@@ -327,7 +341,7 @@ export function LaboratoryMetricEvidenceScene({
|
|||||||
controls.update();
|
controls.update();
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(resetView, [corridor.forwardLengthM, mode, obstacles]);
|
useEffect(resetView, [corridor.forwardLengthM, mode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="laboratory-metric-evidence-scene">
|
<div className="laboratory-metric-evidence-scene">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function RecordedEvidenceVideoScene({
|
|||||||
imageHeight,
|
imageHeight,
|
||||||
boxes,
|
boxes,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
|
interactive = true,
|
||||||
onPlaybackChange,
|
onPlaybackChange,
|
||||||
}: {
|
}: {
|
||||||
source: ObservationSourceDescriptor;
|
source: ObservationSourceDescriptor;
|
||||||
@@ -26,14 +27,15 @@ export function RecordedEvidenceVideoScene({
|
|||||||
imageHeight: number;
|
imageHeight: number;
|
||||||
boxes: readonly RecordedEvidenceBox[];
|
boxes: readonly RecordedEvidenceBox[];
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
onPlaybackChange: (playback: RecordedObservationPlayback) => void;
|
interactive?: boolean;
|
||||||
|
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="recorded-evidence-video-scene">
|
<div className="recorded-evidence-video-scene">
|
||||||
<RecordedFmp4Player
|
<RecordedFmp4Player
|
||||||
source={source}
|
source={source}
|
||||||
playback={playback}
|
playback={playback}
|
||||||
interactive
|
interactive={interactive}
|
||||||
prepare
|
prepare
|
||||||
onPlaybackChange={onPlaybackChange}
|
onPlaybackChange={onPlaybackChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import type { RecordedObservationPlayback } from "../RecordedFmp4Player";
|
||||||
|
|
||||||
|
export interface RecordedEvidencePlaybackRange {
|
||||||
|
startSeconds: number;
|
||||||
|
endSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validRange(
|
||||||
|
range: RecordedEvidencePlaybackRange | null,
|
||||||
|
): range is RecordedEvidencePlaybackRange {
|
||||||
|
return Boolean(
|
||||||
|
range
|
||||||
|
&& Number.isFinite(range.startSeconds)
|
||||||
|
&& Number.isFinite(range.endSeconds)
|
||||||
|
&& range.endSeconds > range.startSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampRecordedEvidenceSeconds(
|
||||||
|
seconds: number,
|
||||||
|
range: RecordedEvidencePlaybackRange,
|
||||||
|
): number {
|
||||||
|
return Math.min(range.endSeconds, Math.max(range.startSeconds, seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function advanceRecordedEvidencePlayback(
|
||||||
|
playback: RecordedObservationPlayback,
|
||||||
|
elapsedSeconds: number,
|
||||||
|
range: RecordedEvidencePlaybackRange,
|
||||||
|
): RecordedObservationPlayback {
|
||||||
|
if (!playback.playing || !Number.isFinite(elapsedSeconds) || elapsedSeconds <= 0) {
|
||||||
|
return playback;
|
||||||
|
}
|
||||||
|
const next = playback.currentSeconds + elapsedSeconds * (playback.rate ?? 1);
|
||||||
|
if (next >= range.endSeconds) {
|
||||||
|
return { ...playback, currentSeconds: range.endSeconds, playing: false };
|
||||||
|
}
|
||||||
|
return { ...playback, currentSeconds: clampRecordedEvidenceSeconds(next, range) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRecordedEvidencePlayback(
|
||||||
|
range: RecordedEvidencePlaybackRange | null,
|
||||||
|
) {
|
||||||
|
const [playback, setPlayback] = useState<RecordedObservationPlayback>({
|
||||||
|
currentSeconds: 0,
|
||||||
|
playing: false,
|
||||||
|
rate: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!validRange(range)) return;
|
||||||
|
setPlayback((current) => ({
|
||||||
|
...current,
|
||||||
|
currentSeconds: current.currentSeconds === 0
|
||||||
|
? range.startSeconds
|
||||||
|
: clampRecordedEvidenceSeconds(current.currentSeconds, range),
|
||||||
|
}));
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!validRange(range) || !playback.playing) return;
|
||||||
|
let animationFrame = 0;
|
||||||
|
let previous = performance.now();
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const elapsed = Math.max(0, now - previous);
|
||||||
|
if (elapsed >= 32) {
|
||||||
|
previous = now;
|
||||||
|
setPlayback((current) => {
|
||||||
|
if (!current.playing) return current;
|
||||||
|
return advanceRecordedEvidencePlayback(current, elapsed / 1_000, range);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
animationFrame = window.requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
animationFrame = window.requestAnimationFrame(tick);
|
||||||
|
return () => window.cancelAnimationFrame(animationFrame);
|
||||||
|
}, [playback.playing, range]);
|
||||||
|
|
||||||
|
const seek = useCallback((seconds: number, pause = true) => {
|
||||||
|
if (!validRange(range)) return;
|
||||||
|
setPlayback((current) => ({
|
||||||
|
...current,
|
||||||
|
currentSeconds: clampRecordedEvidenceSeconds(seconds, range),
|
||||||
|
playing: pause ? false : current.playing,
|
||||||
|
}));
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
const setPlaying = useCallback((playing: boolean) => {
|
||||||
|
if (!validRange(range)) return;
|
||||||
|
setPlayback((current) => ({
|
||||||
|
...current,
|
||||||
|
currentSeconds: playing && current.currentSeconds >= range.endSeconds
|
||||||
|
? range.startSeconds
|
||||||
|
: current.currentSeconds,
|
||||||
|
playing,
|
||||||
|
}));
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
const setRate = useCallback((rate: number) => {
|
||||||
|
if (![0.5, 1, 2].includes(rate)) return;
|
||||||
|
setPlayback((current) => ({ ...current, rate }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return useMemo(() => ({
|
||||||
|
playback,
|
||||||
|
seek,
|
||||||
|
setPlaying,
|
||||||
|
setRate,
|
||||||
|
}), [playback, seek, setPlaying, setRate]);
|
||||||
|
}
|
||||||
@@ -125,22 +125,47 @@ export interface M4ThreatVisualIndexItem {
|
|||||||
pointCloudSampleCount: number;
|
pointCloudSampleCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface M4ThreatVideoFrame {
|
export interface M4ThreatTimelineFrame {
|
||||||
frameIndex: number;
|
sequence: number;
|
||||||
|
frameId: string;
|
||||||
|
sourceTimeNs: number;
|
||||||
sessionSeconds: number;
|
sessionSeconds: number;
|
||||||
sourceAvailable: boolean;
|
sourceAvailable: boolean;
|
||||||
|
spatialAvailable: boolean;
|
||||||
|
pointCloudBodyXyzM: readonly M4Point3[];
|
||||||
|
pointCloudSourceCount: number;
|
||||||
|
pointCloudSampleCount: number;
|
||||||
|
pointCloudLayer: "current-increment";
|
||||||
|
rollingMapComponentCount: number;
|
||||||
|
metricObstacles: readonly M4ThreatMetricVisual[];
|
||||||
cameraProposals: readonly M4ThreatCameraProposal[];
|
cameraProposals: readonly M4ThreatCameraProposal[];
|
||||||
decisionCounts: Record<M4ThreatDecision, number>;
|
decisionCounts: Record<M4ThreatDecision, number>;
|
||||||
|
cameraUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface M4ThreatVideoOverlay {
|
export interface M4ThreatTimeline {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
recordedSourceSessionId: "20260720T065719Z_viewer_live";
|
recordedSourceSessionId: "20260720T065719Z_viewer_live";
|
||||||
imageWidth: 800;
|
imageWidth: 800;
|
||||||
imageHeight: 600;
|
imageHeight: 600;
|
||||||
|
frameCount: 4489;
|
||||||
|
frameTimesNs: readonly number[];
|
||||||
timelineStartSeconds: number;
|
timelineStartSeconds: number;
|
||||||
timelineEndSeconds: number;
|
timelineEndSeconds: number;
|
||||||
frames: readonly M4ThreatVideoFrame[];
|
nominalFrameIntervalSeconds: number;
|
||||||
|
nominalRateHz: number;
|
||||||
|
maxChunkFrames: number;
|
||||||
|
pointSampleLimit: number;
|
||||||
|
rig: M4ThreatVisualFrame["rig"];
|
||||||
|
corridor: M4ThreatVisualFrame["corridor"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M4ThreatTimelineChunk {
|
||||||
|
resultId: string;
|
||||||
|
startSequence: number;
|
||||||
|
frameCount: number;
|
||||||
|
nextSequence: number | null;
|
||||||
|
frames: readonly M4ThreatTimelineFrame[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
@@ -245,6 +270,29 @@ function parseCameraProposal(value: unknown): M4ThreatCameraProposal {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMetricVisual(value: unknown): M4ThreatMetricVisual {
|
||||||
|
const item = object(value, "M4.6 metric visual");
|
||||||
|
const state = text(item.state, "M4.6 temporal state");
|
||||||
|
if (
|
||||||
|
state !== "current"
|
||||||
|
&& state !== "retained"
|
||||||
|
&& state !== "held"
|
||||||
|
&& state !== "expired"
|
||||||
|
) {
|
||||||
|
throw new M4ThreatContractError("M4.6 temporal state: неизвестное состояние.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
componentId: text(item.component_id, "M4.6 visual component"),
|
||||||
|
state,
|
||||||
|
motion: motion(item.motion),
|
||||||
|
centroidBodyXyzM: vector(item.centroid_body_xyz_m, 3, "M4.6 centroid") as [number, number, number],
|
||||||
|
cellCentersBodyXyzM: array(item.cell_centers_body_xyz_m, "M4.6 cells").map(
|
||||||
|
(point) => vector(point, 3, "M4.6 cell") as [number, number, number],
|
||||||
|
),
|
||||||
|
assessment: parseAssessment(item.assessment),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchM4ThreatReplayResult({
|
export async function fetchM4ThreatReplayResult({
|
||||||
fetcher = fetch,
|
fetcher = fetch,
|
||||||
signal,
|
signal,
|
||||||
@@ -425,28 +473,7 @@ export async function fetchM4ThreatVisual(
|
|||||||
rollingMapComponentCount: rollingMapV2
|
rollingMapComponentCount: rollingMapV2
|
||||||
? integer(item.rolling_map_component_count, "M4.6 rolling components")
|
? integer(item.rolling_map_component_count, "M4.6 rolling components")
|
||||||
: 0,
|
: 0,
|
||||||
metricObstacles: array(item.metric_obstacles, "M4.6 metric visuals").map((raw) => {
|
metricObstacles: array(item.metric_obstacles, "M4.6 metric visuals").map(parseMetricVisual),
|
||||||
const value = object(raw, "M4.6 metric visual");
|
|
||||||
const state = text(value.state, "M4.6 temporal state");
|
|
||||||
if (
|
|
||||||
state !== "current"
|
|
||||||
&& state !== "retained"
|
|
||||||
&& state !== "held"
|
|
||||||
&& state !== "expired"
|
|
||||||
) {
|
|
||||||
throw new M4ThreatContractError("M4.6 temporal state: неизвестное состояние.");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
componentId: text(value.component_id, "M4.6 visual component"),
|
|
||||||
state,
|
|
||||||
motion: motion(value.motion),
|
|
||||||
centroidBodyXyzM: vector(value.centroid_body_xyz_m, 3, "M4.6 centroid") as [number, number, number],
|
|
||||||
cellCentersBodyXyzM: array(value.cell_centers_body_xyz_m, "M4.6 cells").map(
|
|
||||||
(point) => vector(point, 3, "M4.6 cell") as [number, number, number],
|
|
||||||
),
|
|
||||||
assessment: parseAssessment(value.assessment),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
cameraProposals: array(item.camera_proposals, "M4.6 camera proposals").map(parseCameraProposal),
|
cameraProposals: array(item.camera_proposals, "M4.6 camera proposals").map(parseCameraProposal),
|
||||||
rig: {
|
rig: {
|
||||||
lengthM: number(rig.length_m, "M4.6 rig length"),
|
lengthM: number(rig.length_m, "M4.6 rig length"),
|
||||||
@@ -462,71 +489,211 @@ export async function fetchM4ThreatVisual(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchM4ThreatVideoOverlay(
|
export async function fetchM4ThreatTimeline(
|
||||||
result: string,
|
result: string,
|
||||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||||
): Promise<M4ThreatVideoOverlay> {
|
): Promise<M4ThreatTimeline> {
|
||||||
const response = await fetcher(
|
const response = await fetcher(
|
||||||
`/api/v1/laboratory/m4-threat/results/${result}/video-overlay`,
|
`/api/v1/laboratory/m4-threat/results/${result}/timeline`,
|
||||||
{ headers: { Accept: "application/json" }, signal },
|
{ headers: { Accept: "application/json" }, signal },
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 video overlay: HTTP ${response.status}.`);
|
if (!response.ok) throw new M4ThreatContractError(`M4.6 timeline: HTTP ${response.status}.`);
|
||||||
const payload = object(await response.json(), "M4.6 video overlay");
|
const payload = object(await response.json(), "M4.6 timeline");
|
||||||
exact(payload.schema_version, "missioncore.m4-threat-video-overlay/v1", "M4.6 video schema");
|
exact(
|
||||||
exact(payload.result_id, result, "M4.6 video result");
|
payload.schema_version,
|
||||||
exact(payload.authority, "replay-simulated", "M4.6 video authority");
|
"missioncore.recorded-spatial-evidence-timeline/v1",
|
||||||
|
"M4.6 timeline schema",
|
||||||
|
);
|
||||||
|
exact(payload.result_id, result, "M4.6 timeline result");
|
||||||
|
exact(payload.authority, "replay-simulated", "M4.6 timeline authority");
|
||||||
const recorded = object(payload.recorded_source, "M4.6 recorded source");
|
const recorded = object(payload.recorded_source, "M4.6 recorded source");
|
||||||
exact(
|
exact(
|
||||||
recorded.session_id,
|
recorded.session_id,
|
||||||
"20260720T065719Z_viewer_live",
|
"20260720T065719Z_viewer_live",
|
||||||
"M4.6 recorded session",
|
"M4.6 recorded session",
|
||||||
);
|
);
|
||||||
const frames = array(payload.frames, "M4.6 video frames").map((raw, expectedIndex) => {
|
exact(recorded.source_id, "RAVNOVES00", "M4.6 recorded source id");
|
||||||
const item = object(raw, "M4.6 video frame");
|
exact(
|
||||||
const frameIndex = integer(item.frame_index, "M4.6 video index");
|
recorded.synchronization,
|
||||||
if (frameIndex !== expectedIndex) throw new M4ThreatContractError("M4.6 video order.");
|
"host-arrival-best-effort",
|
||||||
const counts = object(item.decision_counts, "M4.6 video decisions");
|
"M4.6 recorded synchronization",
|
||||||
return {
|
);
|
||||||
frameIndex,
|
const frameCount = exact(payload.frame_count, 4489, "M4.6 timeline frame count");
|
||||||
sessionSeconds: number(item.session_seconds, "M4.6 video time"),
|
const frameTimesNs = array(payload.frame_times_ns, "M4.6 timeline index").map(
|
||||||
sourceAvailable: typeof item.source_available === "boolean" ? item.source_available : false,
|
(value) => integer(value, "M4.6 timeline time"),
|
||||||
cameraProposals: array(item.camera_proposals, "M4.6 video proposals").map(parseCameraProposal),
|
);
|
||||||
decisionCounts: {
|
if (
|
||||||
threat: integer(counts.threat, "M4.6 video threat"),
|
frameTimesNs.length !== frameCount
|
||||||
"not-threat": integer(counts["not-threat"], "M4.6 video clear"),
|
|| frameTimesNs.some((value, index) => index > 0 && value <= (frameTimesNs[index - 1] ?? value))
|
||||||
unknown: integer(counts.unknown, "M4.6 video unknown"),
|
) {
|
||||||
},
|
throw new M4ThreatContractError("M4.6 timeline index: нарушен порядок.");
|
||||||
};
|
}
|
||||||
});
|
const rig = object(payload.rig, "M4.6 timeline rig");
|
||||||
exact(payload.frame_count, 4489, "M4.6 video frame count");
|
const corridor = object(payload.corridor, "M4.6 timeline corridor");
|
||||||
return {
|
return {
|
||||||
resultId: result,
|
resultId: result,
|
||||||
recordedSourceSessionId: "20260720T065719Z_viewer_live",
|
recordedSourceSessionId: "20260720T065719Z_viewer_live",
|
||||||
imageWidth: exact(payload.image_width, 800, "M4.6 image width"),
|
imageWidth: exact(payload.image_width, 800, "M4.6 image width"),
|
||||||
imageHeight: exact(payload.image_height, 600, "M4.6 image height"),
|
imageHeight: exact(payload.image_height, 600, "M4.6 image height"),
|
||||||
timelineStartSeconds: number(payload.timeline_start_seconds, "M4.6 video start"),
|
frameCount,
|
||||||
timelineEndSeconds: number(payload.timeline_end_seconds, "M4.6 video end"),
|
frameTimesNs,
|
||||||
|
timelineStartSeconds: number(payload.timeline_start_seconds, "M4.6 timeline start"),
|
||||||
|
timelineEndSeconds: number(payload.timeline_end_seconds, "M4.6 timeline end"),
|
||||||
|
nominalFrameIntervalSeconds: number(
|
||||||
|
payload.nominal_frame_interval_seconds,
|
||||||
|
"M4.6 timeline interval",
|
||||||
|
),
|
||||||
|
nominalRateHz: number(payload.nominal_rate_hz, "M4.6 timeline rate"),
|
||||||
|
maxChunkFrames: integer(payload.max_chunk_frames, "M4.6 max chunk"),
|
||||||
|
pointSampleLimit: integer(payload.point_sample_limit, "M4.6 point limit"),
|
||||||
|
rig: {
|
||||||
|
lengthM: number(rig.length_m, "M4.6 rig length"),
|
||||||
|
widthM: number(rig.width_m, "M4.6 rig width"),
|
||||||
|
nominalSensorHeightM: number(rig.nominal_sensor_height_m, "M4.6 sensor height"),
|
||||||
|
},
|
||||||
|
corridor: {
|
||||||
|
forwardLengthM: number(corridor.forward_length_m, "M4.6 forward corridor"),
|
||||||
|
rearMarginM: number(corridor.rear_margin_m, "M4.6 rear corridor"),
|
||||||
|
halfWidthM: number(corridor.half_width_m, "M4.6 half width"),
|
||||||
|
predictionHorizonSeconds: number(
|
||||||
|
corridor.prediction_horizon_seconds,
|
||||||
|
"M4.6 prediction horizon",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchM4ThreatTimelineChunk(
|
||||||
|
result: string,
|
||||||
|
startSequence: number,
|
||||||
|
frameCount: number,
|
||||||
|
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||||
|
): Promise<M4ThreatTimelineChunk> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
start: String(startSequence),
|
||||||
|
count: String(frameCount),
|
||||||
|
});
|
||||||
|
const response = await fetcher(
|
||||||
|
`/api/v1/laboratory/m4-threat/results/${result}/timeline/chunk?${params}`,
|
||||||
|
{ headers: { Accept: "application/json" }, signal },
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new M4ThreatContractError(`M4.6 timeline chunk: HTTP ${response.status}.`);
|
||||||
|
const payload = object(await response.json(), "M4.6 timeline chunk");
|
||||||
|
exact(
|
||||||
|
payload.schema_version,
|
||||||
|
"missioncore.recorded-spatial-evidence-chunk/v1",
|
||||||
|
"M4.6 timeline chunk schema",
|
||||||
|
);
|
||||||
|
exact(payload.result_id, result, "M4.6 timeline chunk result");
|
||||||
|
exact(payload.authority, "replay-simulated", "M4.6 timeline chunk authority");
|
||||||
|
const parsedStart = integer(payload.start_sequence, "M4.6 timeline chunk start");
|
||||||
|
if (parsedStart !== startSequence) {
|
||||||
|
throw new M4ThreatContractError("M4.6 timeline chunk start: нарушен контракт.");
|
||||||
|
}
|
||||||
|
const frames = array(payload.frames, "M4.6 timeline frames").map((raw, offset) =>
|
||||||
|
parseTimelineFrame(raw, result, parsedStart + offset));
|
||||||
|
const parsedCount = integer(payload.frame_count, "M4.6 timeline chunk count");
|
||||||
|
if (parsedCount !== frames.length || parsedCount > frameCount) {
|
||||||
|
throw new M4ThreatContractError("M4.6 timeline chunk count: нарушен контракт.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
resultId: result,
|
||||||
|
startSequence: parsedStart,
|
||||||
|
frameCount: parsedCount,
|
||||||
|
nextSequence: payload.next_sequence === null
|
||||||
|
? null
|
||||||
|
: integer(payload.next_sequence, "M4.6 timeline next sequence"),
|
||||||
frames,
|
frames,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectM4ThreatVideoFrame(
|
function parseTimelineFrame(
|
||||||
frames: readonly M4ThreatVideoFrame[],
|
value: unknown,
|
||||||
|
result: string,
|
||||||
|
expectedSequence: number,
|
||||||
|
): M4ThreatTimelineFrame {
|
||||||
|
const item = object(value, "M4.6 timeline frame");
|
||||||
|
exact(
|
||||||
|
item.schema_version,
|
||||||
|
"missioncore.recorded-spatial-evidence-frame/v1",
|
||||||
|
"M4.6 timeline frame schema",
|
||||||
|
);
|
||||||
|
exact(item.authority, "replay-simulated", "M4.6 timeline frame authority");
|
||||||
|
const sequence = integer(item.sequence, "M4.6 timeline sequence");
|
||||||
|
if (sequence !== expectedSequence) {
|
||||||
|
throw new M4ThreatContractError("M4.6 timeline frame order: нарушен контракт.");
|
||||||
|
}
|
||||||
|
const counts = object(item.decision_counts, "M4.6 timeline decisions");
|
||||||
|
const cameraUrl = text(item.camera_url, "M4.6 timeline camera URL");
|
||||||
|
if (!cameraUrl.includes(`/results/${result}/timeline/frames/${sequence}/camera`)) {
|
||||||
|
throw new M4ThreatContractError("M4.6 timeline camera URL: нарушена идентичность.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sequence,
|
||||||
|
frameId: text(item.frame_id, "M4.6 timeline frame id"),
|
||||||
|
sourceTimeNs: integer(item.source_time_ns, "M4.6 timeline source time"),
|
||||||
|
sessionSeconds: number(item.session_seconds, "M4.6 timeline time"),
|
||||||
|
sourceAvailable: typeof item.source_available === "boolean" && item.source_available,
|
||||||
|
spatialAvailable: typeof item.spatial_available === "boolean" && item.spatial_available,
|
||||||
|
pointCloudBodyXyzM: array(item.point_cloud_body_xyz_m, "M4.6 timeline points").map(
|
||||||
|
(point) => vector(point, 3, "M4.6 timeline point") as [number, number, number],
|
||||||
|
),
|
||||||
|
pointCloudSourceCount: integer(item.point_cloud_source_count, "M4.6 source points"),
|
||||||
|
pointCloudSampleCount: integer(item.point_cloud_sample_count, "M4.6 sampled points"),
|
||||||
|
pointCloudLayer: exact(
|
||||||
|
item.point_cloud_layer,
|
||||||
|
"current-increment",
|
||||||
|
"M4.6 timeline point layer",
|
||||||
|
),
|
||||||
|
rollingMapComponentCount: integer(
|
||||||
|
item.rolling_map_component_count,
|
||||||
|
"M4.6 rolling components",
|
||||||
|
),
|
||||||
|
metricObstacles: array(item.metric_obstacles, "M4.6 timeline obstacles").map(
|
||||||
|
parseMetricVisual,
|
||||||
|
),
|
||||||
|
cameraProposals: array(item.camera_proposals, "M4.6 timeline proposals").map(
|
||||||
|
parseCameraProposal,
|
||||||
|
),
|
||||||
|
decisionCounts: {
|
||||||
|
threat: integer(counts.threat, "M4.6 timeline threat"),
|
||||||
|
"not-threat": integer(counts["not-threat"], "M4.6 timeline clear"),
|
||||||
|
unknown: integer(counts.unknown, "M4.6 timeline unknown"),
|
||||||
|
},
|
||||||
|
cameraUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectM4ThreatTimelineSequence(
|
||||||
|
frameTimesNs: readonly number[],
|
||||||
seconds: number,
|
seconds: number,
|
||||||
): M4ThreatVideoFrame | null {
|
): number | null {
|
||||||
if (!frames.length) return null;
|
if (!frameTimesNs.length || !Number.isFinite(seconds)) return null;
|
||||||
|
const targetNs = seconds * 1_000_000_000;
|
||||||
let low = 0;
|
let low = 0;
|
||||||
let high = frames.length - 1;
|
let high = frameTimesNs.length - 1;
|
||||||
while (low < high) {
|
while (low < high) {
|
||||||
const middle = Math.floor((low + high) / 2);
|
const middle = Math.floor((low + high) / 2);
|
||||||
const current = frames[middle];
|
const current = frameTimesNs[middle];
|
||||||
if (!current || current.sessionSeconds < seconds) low = middle + 1;
|
if (current === undefined || current < targetNs) low = middle + 1;
|
||||||
else high = middle;
|
else high = middle;
|
||||||
}
|
}
|
||||||
const current = frames[low] ?? frames[frames.length - 1] ?? null;
|
const current = frameTimesNs[low];
|
||||||
const previous = frames[Math.max(0, low - 1)] ?? null;
|
const previousIndex = Math.max(0, low - 1);
|
||||||
if (!current || !previous) return current;
|
const previous = frameTimesNs[previousIndex];
|
||||||
return Math.abs(previous.sessionSeconds - seconds) <= Math.abs(current.sessionSeconds - seconds)
|
if (current === undefined) return frameTimesNs.length - 1;
|
||||||
? previous
|
if (previous === undefined) return low;
|
||||||
: current;
|
return Math.abs(previous - targetNs) <= Math.abs(current - targetNs) ? previousIndex : low;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectM4ThreatTimelineFrame(
|
||||||
|
frames: readonly M4ThreatTimelineFrame[],
|
||||||
|
seconds: number,
|
||||||
|
): M4ThreatTimelineFrame | null {
|
||||||
|
if (!frames.length) return null;
|
||||||
|
const local = selectM4ThreatTimelineSequence(
|
||||||
|
frames.map((frame) => frame.sourceTimeNs),
|
||||||
|
seconds,
|
||||||
|
);
|
||||||
|
return local === null ? null : frames[local] ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,15 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.m4-replay-threat-evidence-viewer .laboratory-metric-evidence-scene__legend,
|
||||||
|
.m4-replay-threat-evidence-viewer .m4-replay-threat-visual__overlay {
|
||||||
|
bottom: 5.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m4-replay-threat-visual__timeline {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.e46e-ready-stack-video {
|
.e46e-ready-stack-video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -460,6 +460,14 @@
|
|||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-viewer__transport {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 4;
|
||||||
|
right: 0.6rem;
|
||||||
|
bottom: 0.6rem;
|
||||||
|
left: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.laboratory-evidence-viewer[data-expanded="true"] {
|
.laboratory-evidence-viewer[data-expanded="true"] {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: var(--nodedc-layer-overlay);
|
z-index: var(--nodedc-layer-overlay);
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.laboratory-metric-evidence-scene__toolbar .nodedc-button,
|
.laboratory-metric-evidence-scene__toolbar .nodedc-button:not([data-variant="primary"]),
|
||||||
.laboratory-metric-evidence-scene__toolbar > span,
|
.laboratory-metric-evidence-scene__toolbar > span,
|
||||||
.laboratory-metric-evidence-scene__legend {
|
.laboratory-metric-evidence-scene__legend {
|
||||||
background: var(--nodedc-floating-surface);
|
background: var(--nodedc-floating-surface);
|
||||||
|
|||||||
@@ -399,6 +399,11 @@ i[data-availability="error"] {
|
|||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.observation-timeline__playback > .nodedc-select-anchor {
|
||||||
|
width: 7.5rem;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
.observation-timeline__accumulation {
|
.observation-timeline__accumulation {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function M4ReplayThreatResultView({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Визуал",
|
label: "Визуал",
|
||||||
value: "4489-frame VIDEO · 32 exact CAMERA/3D/PLAN samples",
|
value: "4489-frame VIDEO/CAMERA/3D/PLAN · единый recorded clock",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
brief={{
|
brief={{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Icon, IconButton, Select } from "@nodedc/ui-react";
|
import { Icon, IconButton } from "@nodedc/ui-react";
|
||||||
|
|
||||||
import type { RecordedObservationPlayback } from "../../components/RecordedFmp4Player";
|
import { ObservationTimeline } from "../../components/ObservationTimeline";
|
||||||
import {
|
import {
|
||||||
LaboratoryMetricEvidenceScene,
|
LaboratoryMetricEvidenceScene,
|
||||||
type LaboratoryMetricSceneMode,
|
type LaboratoryMetricSceneMode,
|
||||||
@@ -12,19 +12,15 @@ import {
|
|||||||
RecordedEvidenceVideoScene,
|
RecordedEvidenceVideoScene,
|
||||||
type RecordedEvidenceBox,
|
type RecordedEvidenceBox,
|
||||||
} from "../../components/laboratory/RecordedEvidenceVideoScene";
|
} from "../../components/laboratory/RecordedEvidenceVideoScene";
|
||||||
import {
|
import { useRecordedEvidencePlayback } from "../../components/laboratory/useRecordedEvidencePlayback";
|
||||||
fetchM4ThreatVideoOverlay,
|
import type { M4ThreatCameraProposal } from "../../core/laboratory/m4ReplayThreat";
|
||||||
fetchM4ThreatVisual,
|
|
||||||
fetchM4ThreatVisualIndex,
|
|
||||||
selectM4ThreatVideoFrame,
|
|
||||||
type M4ThreatCameraProposal,
|
|
||||||
type M4ThreatVideoOverlay,
|
|
||||||
type M4ThreatVisualFrame,
|
|
||||||
type M4ThreatVisualIndexItem,
|
|
||||||
} from "../../core/laboratory/m4ReplayThreat";
|
|
||||||
import { recordedObservationSources } from "../../core/observation/recordedObservationSources";
|
import { recordedObservationSources } from "../../core/observation/recordedObservationSources";
|
||||||
import { replayObservationSession } from "../../core/observation/sessionArchive";
|
import { replayObservationSession } from "../../core/observation/sessionArchive";
|
||||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||||
|
import {
|
||||||
|
useM4ThreatTimelineFrame,
|
||||||
|
useM4ThreatTimelineMetadata,
|
||||||
|
} from "./useM4ThreatTimeline";
|
||||||
|
|
||||||
type M4ThreatViewMode = "video" | "camera" | LaboratoryMetricSceneMode;
|
type M4ThreatViewMode = "video" | "camera" | LaboratoryMetricSceneMode;
|
||||||
|
|
||||||
@@ -55,98 +51,68 @@ function message(error: unknown, fallback: string): string {
|
|||||||
return error instanceof Error && error.message.trim() ? error.message : fallback;
|
return error instanceof Error && error.message.trim() ? error.message : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SpatialState({ message: text }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="l3-visual-audit__state" role="status">
|
||||||
|
<Icon name="alert" size={18} />
|
||||||
|
<span>{text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
||||||
const [mode, setMode] = useState<M4ThreatViewMode>("video");
|
const [mode, setMode] = useState<M4ThreatViewMode>("video");
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [index, setIndex] = useState<readonly M4ThreatVisualIndexItem[]>([]);
|
const metadata = useM4ThreatTimelineMetadata(resultId);
|
||||||
const [ordinal, setOrdinal] = useState(1);
|
const playbackRange = useMemo(() => metadata.timeline ? ({
|
||||||
const [frame, setFrame] = useState<M4ThreatVisualFrame | null>(null);
|
startSeconds: metadata.timeline.timelineStartSeconds,
|
||||||
const [sampleLoading, setSampleLoading] = useState(true);
|
endSeconds: metadata.timeline.timelineEndSeconds,
|
||||||
const [sampleError, setSampleError] = useState<string | null>(null);
|
}) : null, [metadata.timeline]);
|
||||||
const [videoOverlay, setVideoOverlay] = useState<M4ThreatVideoOverlay | null>(null);
|
const playbackController = useRecordedEvidencePlayback(playbackRange);
|
||||||
|
const timelineFrame = useM4ThreatTimelineFrame({
|
||||||
|
resultId,
|
||||||
|
timeline: metadata.timeline,
|
||||||
|
currentSeconds: playbackController.playback.currentSeconds,
|
||||||
|
});
|
||||||
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
|
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
|
||||||
const [videoLoading, setVideoLoading] = useState(false);
|
const [videoLoading, setVideoLoading] = useState(false);
|
||||||
const [videoError, setVideoError] = useState<string | null>(null);
|
const [videoError, setVideoError] = useState<string | null>(null);
|
||||||
const [videoPlayback, setVideoPlayback] = useState<RecordedObservationPlayback>({
|
|
||||||
currentSeconds: 0,
|
|
||||||
playing: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
setVideoSource(null);
|
||||||
setSampleLoading(true);
|
setVideoError(null);
|
||||||
setSampleError(null);
|
|
||||||
void fetchM4ThreatVisualIndex(resultId, { signal: controller.signal })
|
|
||||||
.then((items) => {
|
|
||||||
if (!controller.signal.aborted) setIndex(items);
|
|
||||||
})
|
|
||||||
.catch((caught: unknown) => {
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
setSampleError(message(caught, "Индекс визуальных кадров M4.6 недоступен."));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [resultId]);
|
}, [resultId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const timeline = metadata.timeline;
|
||||||
setSampleLoading(true);
|
if (mode !== "video" || !timeline || videoSource) return;
|
||||||
setSampleError(null);
|
|
||||||
setFrame(null);
|
|
||||||
void fetchM4ThreatVisual(resultId, ordinal, { signal: controller.signal })
|
|
||||||
.then((next) => {
|
|
||||||
if (!controller.signal.aborted) setFrame(next);
|
|
||||||
})
|
|
||||||
.catch((caught: unknown) => {
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
setSampleError(message(caught, "Метрический visual M4.6 недоступен."));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!controller.signal.aborted) setSampleLoading(false);
|
|
||||||
});
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [ordinal, resultId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== "video" || (videoOverlay && videoSource)) return;
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setVideoLoading(true);
|
setVideoLoading(true);
|
||||||
setVideoError(null);
|
setVideoError(null);
|
||||||
void (async () => {
|
void replayObservationSession(timeline.recordedSourceSessionId, {
|
||||||
const overlay = await fetchM4ThreatVideoOverlay(resultId, {
|
signal: controller.signal,
|
||||||
signal: controller.signal,
|
})
|
||||||
});
|
.then((replay) => {
|
||||||
const replay = await replayObservationSession(overlay.recordedSourceSessionId, {
|
if (replay.kind !== "ready") {
|
||||||
signal: controller.signal,
|
throw new Error("RIGHT-видео RAVNOVES00 ещё готовится к воспроизведению.");
|
||||||
});
|
}
|
||||||
if (replay.kind !== "ready") {
|
const source = recordedObservationSources(replay.launch).find(
|
||||||
throw new Error("RIGHT-видео RAVNOVES00 ещё готовится к воспроизведению.");
|
(candidate) => candidate.modality === "video"
|
||||||
}
|
&& candidate.semanticChannelId === "camera.video.recorded",
|
||||||
const source = recordedObservationSources(replay.launch).find(
|
);
|
||||||
(candidate) =>
|
const delivery = source?.delivery?.kind === "recorded-fmp4-manifest"
|
||||||
candidate.modality === "video" &&
|
? source.delivery
|
||||||
candidate.semanticChannelId === "camera.video.recorded",
|
: null;
|
||||||
);
|
if (
|
||||||
const delivery = source?.delivery?.kind === "recorded-fmp4-manifest"
|
!source
|
||||||
? source.delivery
|
|| !delivery
|
||||||
: null;
|
|| delivery.timelineStartSeconds !== timeline.timelineStartSeconds
|
||||||
if (
|
|| delivery.timelineEndSeconds < timeline.timelineEndSeconds
|
||||||
!source ||
|
) {
|
||||||
!delivery ||
|
throw new Error("RIGHT-видео не совпало с recorded-realtime timeline M4.6.");
|
||||||
delivery.timelineStartSeconds !== overlay.timelineStartSeconds ||
|
}
|
||||||
delivery.timelineEndSeconds < overlay.timelineEndSeconds
|
if (!controller.signal.aborted) setVideoSource(source);
|
||||||
) {
|
})
|
||||||
throw new Error("RIGHT-видео не совпало с временным контрактом M4.6.");
|
|
||||||
}
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
setVideoOverlay(overlay);
|
|
||||||
setVideoSource(source);
|
|
||||||
setVideoPlayback({
|
|
||||||
currentSeconds: overlay.timelineStartSeconds,
|
|
||||||
playing: false,
|
|
||||||
});
|
|
||||||
})()
|
|
||||||
.catch((caught: unknown) => {
|
.catch((caught: unknown) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
setVideoError(message(caught, "Видео-доказательство M4.6 недоступно."));
|
setVideoError(message(caught, "Видео-доказательство M4.6 недоступно."));
|
||||||
@@ -156,22 +122,17 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
if (!controller.signal.aborted) setVideoLoading(false);
|
if (!controller.signal.aborted) setVideoLoading(false);
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [mode, resultId, videoOverlay, videoSource]);
|
}, [metadata.timeline, mode, videoSource]);
|
||||||
|
|
||||||
const activeVideoFrame = useMemo(
|
const frame = timelineFrame.activeFrame;
|
||||||
() => videoOverlay
|
const activeBoxes = useMemo(() => boxes(frame?.cameraProposals ?? []), [frame]);
|
||||||
? selectM4ThreatVideoFrame(videoOverlay.frames, videoPlayback.currentSeconds)
|
const sceneObstacles = useMemo(() => frame?.metricObstacles.map((obstacle) => ({
|
||||||
: null,
|
id: obstacle.componentId,
|
||||||
[videoOverlay, videoPlayback.currentSeconds],
|
decision: obstacle.assessment.decision,
|
||||||
);
|
state: obstacle.state,
|
||||||
const activeProposals = mode === "camera"
|
centroidBodyXyzM: obstacle.centroidBodyXyzM,
|
||||||
? frame?.cameraProposals ?? []
|
cellCentersBodyXyzM: obstacle.cellCentersBodyXyzM,
|
||||||
: activeVideoFrame?.cameraProposals ?? [];
|
})) ?? [], [frame]);
|
||||||
const activeBoxes = useMemo(() => boxes(activeProposals), [activeProposals]);
|
|
||||||
const selectedItem = index.find((item) => item.ordinal === ordinal) ?? null;
|
|
||||||
const threatObstacles = frame?.metricObstacles.filter(
|
|
||||||
(item) => item.assessment.decision === "threat",
|
|
||||||
) ?? [];
|
|
||||||
const currentIncrementObstacles = frame?.metricObstacles.filter(
|
const currentIncrementObstacles = frame?.metricObstacles.filter(
|
||||||
(item) => item.state === "current",
|
(item) => item.state === "current",
|
||||||
) ?? [];
|
) ?? [];
|
||||||
@@ -183,208 +144,151 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
.filter((value): value is number => value !== null)
|
.filter((value): value is number => value !== null)
|
||||||
.sort((left, right) => left - right)[0] ?? null;
|
.sort((left, right) => left - right)[0] ?? null;
|
||||||
|
|
||||||
const seekVideo = (seconds: number) => {
|
const seek = (seconds: number) => playbackController.seek(seconds);
|
||||||
if (!videoOverlay) return;
|
const handleModeChange = (next: M4ThreatViewMode) => {
|
||||||
setVideoPlayback({
|
if (next === "camera") playbackController.setPlaying(false);
|
||||||
currentSeconds: Math.min(
|
setMode(next);
|
||||||
videoOverlay.timelineEndSeconds,
|
|
||||||
Math.max(videoOverlay.timelineStartSeconds, seconds),
|
|
||||||
),
|
|
||||||
playing: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const navigate = (offset: -1 | 1) => {
|
|
||||||
const count = Math.max(index.length, 32);
|
|
||||||
setOrdinal((current) => ((current - 1 + offset + count) % count) + 1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const actions = mode === "video" ? (
|
const actions = (
|
||||||
<div className="l3-visual-audit__actions">
|
<div className="l3-visual-audit__actions">
|
||||||
<div className="l3-visual-audit__pagination">
|
<div className="l3-visual-audit__pagination">
|
||||||
<IconButton label="Назад на 5 секунд" onClick={() => seekVideo(videoPlayback.currentSeconds - 5)}>
|
<IconButton
|
||||||
|
label="Назад на 5 секунд"
|
||||||
|
disabled={!metadata.timeline}
|
||||||
|
onClick={() => seek(playbackController.playback.currentSeconds - 5)}
|
||||||
|
>
|
||||||
<Icon name="chevron-left" size={16} />
|
<Icon name="chevron-left" size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton label="Вперёд на 5 секунд" onClick={() => seekVideo(videoPlayback.currentSeconds + 5)}>
|
<IconButton
|
||||||
|
label="Вперёд на 5 секунд"
|
||||||
|
disabled={!metadata.timeline}
|
||||||
|
onClick={() => seek(playbackController.playback.currentSeconds + 5)}
|
||||||
|
>
|
||||||
<Icon name="chevron-right" size={16} />
|
<Icon name="chevron-right" size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
|
||||||
label="Перейти к метрическому sample M4.6"
|
|
||||||
value={String(ordinal)}
|
|
||||||
options={(index.length ? index : Array.from({ length: 32 }, (_, position) => ({
|
|
||||||
ordinal: position + 1,
|
|
||||||
sequence: position,
|
|
||||||
frameId: "",
|
|
||||||
sourceTimeNs: 0,
|
|
||||||
metricObstacleCount: 0,
|
|
||||||
cameraProposalCount: 0,
|
|
||||||
pointCloudSampleCount: 0,
|
|
||||||
}))).map((item) => ({
|
|
||||||
value: String(item.ordinal),
|
|
||||||
label: `${item.ordinal}/32 · frame ${item.sequence} · ${item.metricObstacleCount} metric objects`,
|
|
||||||
}))}
|
|
||||||
variant="split"
|
|
||||||
menuWidth="anchor"
|
|
||||||
searchable
|
|
||||||
searchPlaceholder="Найти sample"
|
|
||||||
onChange={(value) => {
|
|
||||||
const nextOrdinal = Number(value);
|
|
||||||
const target = index.find((item) => item.ordinal === nextOrdinal);
|
|
||||||
setOrdinal(nextOrdinal);
|
|
||||||
if (target) seekVideo(target.sourceTimeNs / 1_000_000_000);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="l3-visual-audit__actions">
|
|
||||||
<div className="l3-visual-audit__pagination">
|
|
||||||
<IconButton label="Предыдущий sample M4.6" onClick={() => navigate(-1)}>
|
|
||||||
<Icon name="chevron-left" size={16} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton label="Следующий sample M4.6" onClick={() => navigate(1)}>
|
|
||||||
<Icon name="chevron-right" size={16} />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
label="Выбрать sample M4.6"
|
|
||||||
value={String(ordinal)}
|
|
||||||
options={index.map((item) => ({
|
|
||||||
value: String(item.ordinal),
|
|
||||||
label: `${item.ordinal}/32 · frame ${item.sequence} · ${item.metricObstacleCount} metric · ${item.cameraProposalCount} camera`,
|
|
||||||
}))}
|
|
||||||
variant="split"
|
|
||||||
menuWidth="anchor"
|
|
||||||
searchable
|
|
||||||
searchPlaceholder="Найти sample"
|
|
||||||
onChange={(value) => setOrdinal(Number(value))}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const overlay = mode === "video" && videoOverlay ? (
|
const overlay = metadata.timeline && frame ? (
|
||||||
<div className="l3-visual-audit__overlay l3-visual-audit__overlay--video">
|
<div className="l3-visual-audit__overlay m4-replay-threat-visual__overlay">
|
||||||
<div>
|
<div>
|
||||||
<span>RAVNOVES00 · recorded RIGHT</span>
|
<span>RAVNOVES00 · recorded realtime</span>
|
||||||
<strong>
|
<strong>frame {frame.sequence + 1}/{metadata.timeline.frameCount}</strong>
|
||||||
+{(videoPlayback.currentSeconds - videoOverlay.timelineStartSeconds).toFixed(1)} с
|
<small>
|
||||||
{activeVideoFrame ? ` · frame ${activeVideoFrame.frameIndex}` : ""}
|
+{(frame.sessionSeconds - metadata.timeline.timelineStartSeconds).toFixed(3)} с
|
||||||
</strong>
|
· {playbackController.playback.playing ? "воспроизведение" : "пауза / seek"}
|
||||||
<small>{videoPlayback.playing ? "воспроизведение" : "пауза / seek"}</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Camera evidence</span>
|
<span>Spatial evidence</span>
|
||||||
<strong>{activeVideoFrame?.cameraProposals.length ?? 0} рамок · distance при LiDAR support</strong>
|
|
||||||
<small>пунктир = camera-only · всегда unknown</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Replay decision</span>
|
|
||||||
<strong>
|
|
||||||
{activeVideoFrame?.decisionCounts.threat ?? 0} threat · {activeVideoFrame?.decisionCounts["not-threat"] ?? 0} clear · {activeVideoFrame?.decisionCounts.unknown ?? 0} unknown
|
|
||||||
</strong>
|
|
||||||
<small>REPLAY-SIMULATED · не live и не safety authority</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : frame ? (
|
|
||||||
<div className="l3-visual-audit__overlay">
|
|
||||||
<div>
|
|
||||||
<span>RAVNOVES00 · exact replay sample</span>
|
|
||||||
<strong>frame {frame.sequence} · sample {frame.ordinal}/32</strong>
|
|
||||||
<small>{(frame.sourceTimeNs / 1_000_000_000).toFixed(3)} с · {selectedItem?.frameId}</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Representation layers</span>
|
|
||||||
<strong>
|
<strong>
|
||||||
{currentIncrementObstacles.length} current · {rollingMapObstacles.length} rolling
|
{currentIncrementObstacles.length} current · {rollingMapObstacles.length} rolling
|
||||||
</strong>
|
</strong>
|
||||||
<small>
|
<small>
|
||||||
CURRENT INCREMENT {frame.pointCloudSampleCount}/{frame.pointCloudSourceCount} points
|
{frame.spatialAvailable
|
||||||
· ROLLING MAP {frame.rollingMapComponentCount} components
|
? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} LiDAR points`
|
||||||
|
: "body frame / current increment unavailable"}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Virtual corridor</span>
|
<span>Virtual corridor</span>
|
||||||
<strong>{threatObstacles.length} threat · nearest {nearest === null ? "—" : `${nearest.toFixed(2)} м`}</strong>
|
<strong>
|
||||||
<small>{frame.corridor.forwardLengthM} м · body {frame.rig.lengthM}×{frame.rig.widthM} м · REPLAY-SIMULATED</small>
|
{frame.decisionCounts.threat} threat · nearest {nearest === null ? "—" : `${nearest.toFixed(2)} м`}
|
||||||
|
</strong>
|
||||||
|
<small>
|
||||||
|
{metadata.timeline.corridor.forwardLengthM} м · body {metadata.timeline.rig.lengthM}×{metadata.timeline.rig.widthM} м · REPLAY-SIMULATED
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
let content;
|
let content;
|
||||||
if (mode === "video") {
|
if (metadata.error) {
|
||||||
content = videoLoading ? (
|
content = <SpatialState message={metadata.error} />;
|
||||||
|
} else if (mode === "video") {
|
||||||
|
content = videoLoading
|
||||||
|
|| metadata.loading
|
||||||
|
|| Boolean(metadata.timeline && !videoSource && !videoError) ? (
|
||||||
<div className="l3-visual-audit__state" role="status">
|
<div className="l3-visual-audit__state" role="status">
|
||||||
<span className="busy-indicator" aria-hidden="true" />
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
<span>Связываем 4489 решений M4.6 с RIGHT-видео</span>
|
<span>Открываем синхронное RIGHT-видео RAVNOVES00</span>
|
||||||
</div>
|
|
||||||
) : videoError || !videoOverlay || !videoSource ? (
|
|
||||||
<div className="l3-visual-audit__state" role="status">
|
|
||||||
<Icon name="alert" size={18} />
|
|
||||||
<span>{videoError ?? "Видео-доказательство M4.6 недоступно."}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : videoError || !metadata.timeline || !videoSource ? (
|
||||||
|
<SpatialState message={videoError ?? "Видео-доказательство M4.6 недоступно."} />
|
||||||
) : (
|
) : (
|
||||||
<RecordedEvidenceVideoScene
|
<RecordedEvidenceVideoScene
|
||||||
source={videoSource}
|
source={videoSource}
|
||||||
playback={videoPlayback}
|
playback={playbackController.playback}
|
||||||
imageWidth={videoOverlay.imageWidth}
|
imageWidth={metadata.timeline.imageWidth}
|
||||||
imageHeight={videoOverlay.imageHeight}
|
imageHeight={metadata.timeline.imageHeight}
|
||||||
boxes={activeBoxes}
|
boxes={activeBoxes}
|
||||||
ariaLabel={`M4.6 full video frame ${activeVideoFrame?.frameIndex ?? 0}: ${activeBoxes.length} proposals`}
|
ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
||||||
onPlaybackChange={setVideoPlayback}
|
interactive={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
} else if (timelineFrame.error) {
|
||||||
|
content = <SpatialState message={timelineFrame.error} />;
|
||||||
|
} else if (timelineFrame.loading || !metadata.timeline || !frame) {
|
||||||
|
content = (
|
||||||
|
<div className="l3-visual-audit__state" role="status">
|
||||||
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
|
<span>Буферизуем bounded spatial chunk M4.6</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
} else if (mode === "camera") {
|
} else if (mode === "camera") {
|
||||||
content = sampleLoading ? (
|
content = (
|
||||||
<div className="l3-visual-audit__state" role="status">
|
|
||||||
<span className="busy-indicator" aria-hidden="true" />
|
|
||||||
<span>Открываем точный CAMERA-кадр M4.6</span>
|
|
||||||
</div>
|
|
||||||
) : sampleError || !frame ? (
|
|
||||||
<div className="l3-visual-audit__state" role="status">
|
|
||||||
<Icon name="alert" size={18} />
|
|
||||||
<span>{sampleError ?? "CAMERA-кадр M4.6 недоступен."}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<RecordedEvidenceImageScene
|
<RecordedEvidenceImageScene
|
||||||
src={frame.cameraUrl}
|
src={frame.cameraUrl}
|
||||||
imageWidth={800}
|
imageWidth={metadata.timeline.imageWidth}
|
||||||
imageHeight={600}
|
imageHeight={metadata.timeline.imageHeight}
|
||||||
boxes={activeBoxes}
|
boxes={activeBoxes}
|
||||||
ariaLabel={`M4.6 exact camera sample ${ordinal}: ${activeBoxes.length} proposals`}
|
ariaLabel={`M4.6 exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
} else if (!frame.spatialAvailable) {
|
||||||
|
content = <SpatialState message="На этом recorded-кадре нет квалифицированного body frame и current LiDAR increment." />;
|
||||||
} else {
|
} else {
|
||||||
content = sampleLoading ? (
|
content = (
|
||||||
<div className="l3-visual-audit__state" role="status">
|
|
||||||
<span className="busy-indicator" aria-hidden="true" />
|
|
||||||
<span>Открываем синхронное облако точек M4.6</span>
|
|
||||||
</div>
|
|
||||||
) : sampleError || !frame ? (
|
|
||||||
<div className="l3-visual-audit__state" role="status">
|
|
||||||
<Icon name="alert" size={18} />
|
|
||||||
<span>{sampleError ?? "Метрический visual M4.6 недоступен."}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<LaboratoryMetricEvidenceScene
|
<LaboratoryMetricEvidenceScene
|
||||||
pointCloudBodyXyzM={frame.pointCloudBodyXyzM}
|
pointCloudBodyXyzM={frame.pointCloudBodyXyzM}
|
||||||
obstacles={frame.metricObstacles.map((obstacle) => ({
|
obstacles={sceneObstacles}
|
||||||
id: obstacle.componentId,
|
rig={metadata.timeline.rig}
|
||||||
decision: obstacle.assessment.decision,
|
corridor={metadata.timeline.corridor}
|
||||||
state: obstacle.state,
|
|
||||||
centroidBodyXyzM: obstacle.centroidBodyXyzM,
|
|
||||||
cellCentersBodyXyzM: obstacle.cellCentersBodyXyzM,
|
|
||||||
}))}
|
|
||||||
rig={frame.rig}
|
|
||||||
corridor={frame.corridor}
|
|
||||||
mode={mode}
|
mode={mode}
|
||||||
label={`M4.6 current increment and rolling map, frame ${frame.sequence}`}
|
label="M4.6 recorded-realtime current increment and rolling occupancy"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timeline = metadata.timeline;
|
||||||
|
const transport = timeline ? (
|
||||||
|
<ObservationTimeline
|
||||||
|
className="m4-replay-threat-visual__timeline"
|
||||||
|
active
|
||||||
|
sourceCount={3}
|
||||||
|
mode="recorded"
|
||||||
|
seekable
|
||||||
|
synchronization="host-arrival-best-effort"
|
||||||
|
rangeNs={{
|
||||||
|
min: Math.round(timeline.timelineStartSeconds * 1_000_000_000),
|
||||||
|
max: Math.round(timeline.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}
|
||||||
|
onJumpToEnd={() => playbackController.seek(timeline.timelineEndSeconds)}
|
||||||
|
/>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="l3-visual-audit m4-replay-threat-visual">
|
<div className="l3-visual-audit m4-replay-threat-visual">
|
||||||
<LaboratoryEvidenceViewer
|
<LaboratoryEvidenceViewer
|
||||||
label="M4.6 dual-evidence replay: video, camera and metric 3D"
|
label="M4.6 dual-evidence recorded-realtime replay"
|
||||||
className="m4-replay-threat-evidence-viewer"
|
className="m4-replay-threat-evidence-viewer"
|
||||||
mode={mode}
|
mode={mode}
|
||||||
modes={[
|
modes={[
|
||||||
@@ -394,10 +298,11 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
{ value: "plan", label: "PLAN" },
|
{ value: "plan", label: "PLAN" },
|
||||||
]}
|
]}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onModeChange={setMode}
|
onModeChange={handleModeChange}
|
||||||
onExpandedChange={setExpanded}
|
onExpandedChange={setExpanded}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
overlay={overlay}
|
overlay={overlay}
|
||||||
|
transport={transport}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</LaboratoryEvidenceViewer>
|
</LaboratoryEvidenceViewer>
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchM4ThreatTimeline,
|
||||||
|
fetchM4ThreatTimelineChunk,
|
||||||
|
selectM4ThreatTimelineSequence,
|
||||||
|
type M4ThreatTimeline,
|
||||||
|
type M4ThreatTimelineChunk,
|
||||||
|
type M4ThreatTimelineFrame,
|
||||||
|
} from "../../core/laboratory/m4ReplayThreat";
|
||||||
|
|
||||||
|
const REQUESTED_CHUNK_FRAMES = 12;
|
||||||
|
const RETAINED_CHUNK_COUNT = 4;
|
||||||
|
|
||||||
|
function errorMessage(error: unknown, fallback: string): string {
|
||||||
|
return error instanceof Error && error.message.trim() ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useM4ThreatTimelineMetadata(resultId: string) {
|
||||||
|
const [timeline, setTimeline] = useState<M4ThreatTimeline | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setTimeline(null);
|
||||||
|
setError(null);
|
||||||
|
void fetchM4ThreatTimeline(resultId, { signal: controller.signal })
|
||||||
|
.then((next) => {
|
||||||
|
if (!controller.signal.aborted) setTimeline(next);
|
||||||
|
})
|
||||||
|
.catch((caught: unknown) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setError(errorMessage(caught, "Recorded-realtime timeline M4.6 недоступен."));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [resultId]);
|
||||||
|
|
||||||
|
return { timeline, loading: !timeline && !error, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useM4ThreatTimelineFrame({
|
||||||
|
resultId,
|
||||||
|
timeline,
|
||||||
|
currentSeconds,
|
||||||
|
}: {
|
||||||
|
resultId: string;
|
||||||
|
timeline: M4ThreatTimeline | null;
|
||||||
|
currentSeconds: number;
|
||||||
|
}) {
|
||||||
|
const [chunks, setChunks] = useState<ReadonlyMap<number, M4ThreatTimelineChunk>>(
|
||||||
|
() => new Map(),
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const inFlight = useRef(new Set<number>());
|
||||||
|
const chunksRef = useRef(chunks);
|
||||||
|
chunksRef.current = chunks;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setChunks(new Map());
|
||||||
|
setError(null);
|
||||||
|
inFlight.current.clear();
|
||||||
|
}, [resultId, timeline]);
|
||||||
|
|
||||||
|
const activeSequence = useMemo(
|
||||||
|
() => timeline
|
||||||
|
? selectM4ThreatTimelineSequence(timeline.frameTimesNs, currentSeconds)
|
||||||
|
: null,
|
||||||
|
[currentSeconds, timeline],
|
||||||
|
);
|
||||||
|
const chunkSize = Math.min(
|
||||||
|
REQUESTED_CHUNK_FRAMES,
|
||||||
|
timeline?.maxChunkFrames ?? REQUESTED_CHUNK_FRAMES,
|
||||||
|
);
|
||||||
|
const activeChunkStart = activeSequence === null
|
||||||
|
? null
|
||||||
|
: Math.floor(activeSequence / chunkSize) * chunkSize;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!timeline || activeChunkStart === null) return;
|
||||||
|
const starts = [activeChunkStart, activeChunkStart + chunkSize].filter(
|
||||||
|
(start) => start < timeline.frameCount,
|
||||||
|
);
|
||||||
|
const controllers: AbortController[] = [];
|
||||||
|
for (const start of starts) {
|
||||||
|
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
|
||||||
|
const controller = new AbortController();
|
||||||
|
controllers.push(controller);
|
||||||
|
inFlight.current.add(start);
|
||||||
|
void fetchM4ThreatTimelineChunk(resultId, start, chunkSize, {
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then((chunk) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setChunks((current) => {
|
||||||
|
const next = new Map(current);
|
||||||
|
next.set(start, chunk);
|
||||||
|
const retained = [...next.keys()]
|
||||||
|
.sort((left, right) => (
|
||||||
|
Math.abs(left - activeChunkStart) - Math.abs(right - activeChunkStart)
|
||||||
|
))
|
||||||
|
.slice(0, RETAINED_CHUNK_COUNT);
|
||||||
|
return new Map(retained.map((key) => [key, next.get(key)!]));
|
||||||
|
});
|
||||||
|
if (start === activeChunkStart) setError(null);
|
||||||
|
})
|
||||||
|
.catch((caught: unknown) => {
|
||||||
|
if (!controller.signal.aborted && start === activeChunkStart) {
|
||||||
|
setError(errorMessage(caught, "3D chunk M4.6 недоступен."));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => inFlight.current.delete(start));
|
||||||
|
}
|
||||||
|
return () => controllers.forEach((controller) => controller.abort());
|
||||||
|
}, [activeChunkStart, chunkSize, resultId, timeline]);
|
||||||
|
|
||||||
|
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
|
||||||
|
if (activeSequence === null || activeChunkStart === null) return null;
|
||||||
|
return chunks.get(activeChunkStart)?.frames.find(
|
||||||
|
(frame) => frame.sequence === activeSequence,
|
||||||
|
) ?? null;
|
||||||
|
}, [activeChunkStart, activeSequence, chunks]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeSequence,
|
||||||
|
activeFrame,
|
||||||
|
loading: error === null && Boolean(timeline) && !activeFrame,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,8 +7,11 @@ import { createServer } from "vite";
|
|||||||
let server;
|
let server;
|
||||||
let fetchM4ThreatReplayResult;
|
let fetchM4ThreatReplayResult;
|
||||||
let fetchM4ThreatVisual;
|
let fetchM4ThreatVisual;
|
||||||
let fetchM4ThreatVideoOverlay;
|
let fetchM4ThreatTimeline;
|
||||||
let selectM4ThreatVideoFrame;
|
let fetchM4ThreatTimelineChunk;
|
||||||
|
let selectM4ThreatTimelineFrame;
|
||||||
|
let selectM4ThreatTimelineSequence;
|
||||||
|
let advanceRecordedEvidencePlayback;
|
||||||
|
|
||||||
const resultId = `m4-threat-replay-${"a".repeat(64)}`;
|
const resultId = `m4-threat-replay-${"a".repeat(64)}`;
|
||||||
|
|
||||||
@@ -21,9 +24,14 @@ before(async () => {
|
|||||||
({
|
({
|
||||||
fetchM4ThreatReplayResult,
|
fetchM4ThreatReplayResult,
|
||||||
fetchM4ThreatVisual,
|
fetchM4ThreatVisual,
|
||||||
fetchM4ThreatVideoOverlay,
|
fetchM4ThreatTimeline,
|
||||||
selectM4ThreatVideoFrame,
|
fetchM4ThreatTimelineChunk,
|
||||||
|
selectM4ThreatTimelineFrame,
|
||||||
|
selectM4ThreatTimelineSequence,
|
||||||
} = await server.ssrLoadModule("/src/core/laboratory/m4ReplayThreat.ts"));
|
} = await server.ssrLoadModule("/src/core/laboratory/m4ReplayThreat.ts"));
|
||||||
|
({ advanceRecordedEvidencePlayback } = await server.ssrLoadModule(
|
||||||
|
"/src/components/laboratory/useRecordedEvidencePlayback.ts",
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
@@ -44,6 +52,29 @@ function proposal(overrides = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timelineFrame(sequence, sessionSeconds, overrides = {}) {
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.recorded-spatial-evidence-frame/v1",
|
||||||
|
sequence,
|
||||||
|
frame_id: `frame-${String(sequence).padStart(6, "0")}`,
|
||||||
|
source_time_ns: Math.round(sessionSeconds * 1_000_000_000),
|
||||||
|
session_seconds: sessionSeconds,
|
||||||
|
source_available: true,
|
||||||
|
spatial_available: true,
|
||||||
|
point_cloud_body_xyz_m: [[1, 0, 0.1]],
|
||||||
|
point_cloud_source_count: 1,
|
||||||
|
point_cloud_sample_count: 1,
|
||||||
|
point_cloud_layer: "current-increment",
|
||||||
|
rolling_map_component_count: 0,
|
||||||
|
metric_obstacles: [],
|
||||||
|
camera_proposals: [],
|
||||||
|
decision_counts: { threat: 0, "not-threat": 0, unknown: 0 },
|
||||||
|
camera_url: `/api/v1/laboratory/m4-threat/results/${resultId}/timeline/frames/${sequence}/camera`,
|
||||||
|
authority: "replay-simulated",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test("M4.6 decodes accepted dual-evidence result without physical authority", async () => {
|
test("M4.6 decodes accepted dual-evidence result without physical authority", async () => {
|
||||||
const result = await fetchM4ThreatReplayResult({
|
const result = await fetchM4ThreatReplayResult({
|
||||||
fetcher: async () => new Response(JSON.stringify({
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
@@ -200,38 +231,82 @@ test("M4.6 v2 keeps CURRENT INCREMENT separate from ROLLING MAP", async () => {
|
|||||||
assert.equal(frame.metricObstacles[0].assessment.decision, "threat");
|
assert.equal(frame.metricObstacles[0].assessment.decision, "threat");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("M4.6 full video preserves camera-only unknown and nearest-frame selection", async () => {
|
test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunks", async () => {
|
||||||
const overlay = await fetchM4ThreatVideoOverlay(resultId, {
|
const frameTimesNs = Array.from(
|
||||||
|
{ length: 4489 },
|
||||||
|
(_, index) => 35_421_857_292 + index * 100_000_000,
|
||||||
|
);
|
||||||
|
const timeline = await fetchM4ThreatTimeline(resultId, {
|
||||||
fetcher: async () => new Response(JSON.stringify({
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
schema_version: "missioncore.m4-threat-video-overlay/v1",
|
schema_version: "missioncore.recorded-spatial-evidence-timeline/v1",
|
||||||
result_id: resultId,
|
result_id: resultId,
|
||||||
recorded_source: { session_id: "20260720T065719Z_viewer_live" },
|
recorded_source: {
|
||||||
|
session_id: "20260720T065719Z_viewer_live",
|
||||||
|
source_id: "RAVNOVES00",
|
||||||
|
synchronization: "host-arrival-best-effort",
|
||||||
|
},
|
||||||
image_width: 800,
|
image_width: 800,
|
||||||
image_height: 600,
|
image_height: 600,
|
||||||
timeline_start_seconds: 35.421857292,
|
|
||||||
timeline_end_seconds: 484.044857292,
|
|
||||||
frame_count: 4489,
|
frame_count: 4489,
|
||||||
|
frame_times_ns: frameTimesNs,
|
||||||
|
timeline_start_seconds: 35.421857292,
|
||||||
|
timeline_end_seconds: 484.221857292,
|
||||||
|
nominal_frame_interval_seconds: 0.1,
|
||||||
|
nominal_rate_hz: 10,
|
||||||
|
max_chunk_frames: 24,
|
||||||
|
point_sample_limit: 2000,
|
||||||
|
rig: { length_m: 1, width_m: 0.6, nominal_sensor_height_m: 1.25 },
|
||||||
|
corridor: {
|
||||||
|
forward_length_m: 8,
|
||||||
|
rear_margin_m: 0.5,
|
||||||
|
half_width_m: 0.5,
|
||||||
|
prediction_horizon_seconds: 5,
|
||||||
|
},
|
||||||
|
authority: "replay-simulated",
|
||||||
|
}), { status: 200 }),
|
||||||
|
});
|
||||||
|
assert.equal(timeline.frameTimesNs.length, 4489);
|
||||||
|
assert.equal(selectM4ThreatTimelineSequence(timeline.frameTimesNs, 35.50), 1);
|
||||||
|
|
||||||
|
const chunk = await fetchM4ThreatTimelineChunk(resultId, 0, 2, {
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.recorded-spatial-evidence-chunk/v1",
|
||||||
|
result_id: resultId,
|
||||||
|
start_sequence: 0,
|
||||||
|
frame_count: 2,
|
||||||
|
next_sequence: 2,
|
||||||
frames: [
|
frames: [
|
||||||
{
|
timelineFrame(0, 35.421857292),
|
||||||
frame_index: 0,
|
timelineFrame(1, 35.521857292, {
|
||||||
session_seconds: 35.421857292,
|
|
||||||
source_available: true,
|
|
||||||
camera_proposals: [],
|
|
||||||
decision_counts: { threat: 0, "not-threat": 0, unknown: 0 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
frame_index: 1,
|
|
||||||
session_seconds: 35.521857292,
|
|
||||||
source_available: true,
|
|
||||||
camera_proposals: [proposal()],
|
camera_proposals: [proposal()],
|
||||||
decision_counts: { threat: 0, "not-threat": 0, unknown: 1 },
|
decision_counts: { threat: 0, "not-threat": 0, unknown: 1 },
|
||||||
},
|
}),
|
||||||
],
|
],
|
||||||
authority: "replay-simulated",
|
authority: "replay-simulated",
|
||||||
}), { status: 200 }),
|
}), { status: 200 }),
|
||||||
});
|
});
|
||||||
assert.equal(overlay.frames[1].cameraProposals[0].rangeM, null);
|
assert.equal(chunk.frames[1].cameraProposals[0].rangeM, null);
|
||||||
assert.equal(selectM4ThreatVideoFrame(overlay.frames, 35.50).frameIndex, 1);
|
assert.equal(selectM4ThreatTimelineFrame(chunk.frames, 35.50).sequence, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recorded evidence clock advances by selected rate and stops at the sealed end", () => {
|
||||||
|
const range = { startSeconds: 10, endSeconds: 20 };
|
||||||
|
assert.deepEqual(
|
||||||
|
advanceRecordedEvidencePlayback(
|
||||||
|
{ currentSeconds: 12, playing: true, rate: 2 },
|
||||||
|
1.5,
|
||||||
|
range,
|
||||||
|
),
|
||||||
|
{ currentSeconds: 15, playing: true, rate: 2 },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
advanceRecordedEvidencePlayback(
|
||||||
|
{ currentSeconds: 19.5, playing: true, rate: 1 },
|
||||||
|
1,
|
||||||
|
range,
|
||||||
|
),
|
||||||
|
{ currentSeconds: 20, playing: false, rate: 1 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("M4.6 viewer reuses shared camera, video and metric evidence renderers", async () => {
|
test("M4.6 viewer reuses shared camera, video and metric evidence renderers", async () => {
|
||||||
@@ -244,6 +319,8 @@ test("M4.6 viewer reuses shared camera, video and metric evidence renderers", as
|
|||||||
assert.match(visual, /<RecordedEvidenceVideoScene/);
|
assert.match(visual, /<RecordedEvidenceVideoScene/);
|
||||||
assert.match(visual, /<RecordedEvidenceImageScene/);
|
assert.match(visual, /<RecordedEvidenceImageScene/);
|
||||||
assert.match(visual, /<LaboratoryMetricEvidenceScene/);
|
assert.match(visual, /<LaboratoryMetricEvidenceScene/);
|
||||||
|
assert.match(visual, /<ObservationTimeline/);
|
||||||
|
assert.match(visual, /useM4ThreatTimelineFrame/);
|
||||||
assert.match(visual, /label: "VIDEO"/);
|
assert.match(visual, /label: "VIDEO"/);
|
||||||
assert.match(visual, /label: "CAMERA"/);
|
assert.match(visual, /label: "CAMERA"/);
|
||||||
assert.match(visual, /label: "3D"/);
|
assert.match(visual, /label: "3D"/);
|
||||||
|
|||||||
@@ -1058,9 +1058,37 @@ The accepted corrected M4.6 result is
|
|||||||
The common viewer keeps VIDEO/CAMERA/3D/PLAN synchronized and independently
|
The common viewer keeps VIDEO/CAMERA/3D/PLAN synchronized and independently
|
||||||
toggles `CURRENT INCREMENT` and `ROLLING MAP`. CAMERA requests one exact JPEG
|
toggles `CURRENT INCREMENT` and `ROLLING MAP`. CAMERA requests one exact JPEG
|
||||||
decoded from the bounded fMP4 GOP instead of preparing the complete video.
|
decoded from the bounded fMP4 GOP instead of preparing the complete video.
|
||||||
Regression frames are now `138`, `274`, `1880` and `2584`. M4.7 may proceed only
|
Regression frames are now `138`, `274`, `1880` and `2584`.
|
||||||
from the corrected identities above; physical-live, collision, navigation and
|
|
||||||
actuation authority remain false.
|
### 2026-08-05 — M4.6 recorded-realtime visual timeline
|
||||||
|
|
||||||
|
The 32 sealed visual samples remain immutable regression checkpoints, but they
|
||||||
|
are no longer the playback mechanism. A product-owned recorded spatial evidence
|
||||||
|
timeline now exposes every one of the `4,489` source frames without loading or
|
||||||
|
copying the complete `345 MB` frame ledger into the browser:
|
||||||
|
|
||||||
|
- timeline metadata contains only the exact ordered source timestamps and the
|
||||||
|
frozen source, rig, corridor and authority identities;
|
||||||
|
- spatial evidence is read on demand from indexed ledger offsets in bounded
|
||||||
|
chunks of at most `24` frames; the GUI requests `12` and retains at most four
|
||||||
|
chunks;
|
||||||
|
- each frame carries the bounded current point sample, metric/rolling obstacles,
|
||||||
|
camera proposals, decision counts and one exact CAMERA URL;
|
||||||
|
- VIDEO, 3D and PLAN run from one source-time clock at `0.5×`, `1×` or `2×`;
|
||||||
|
CAMERA pauses that clock and decodes exactly the selected sequence;
|
||||||
|
- the Three.js scene separates immutable rig/corridor/grid objects from dynamic
|
||||||
|
point and obstacle layers, so a frame update does not rebuild the scene or
|
||||||
|
reset the operator view;
|
||||||
|
- playback never changes the sealed M4.6 result, its visual ledger or its
|
||||||
|
`replay-simulated`/no-actuation authority.
|
||||||
|
|
||||||
|
Browser acceptance verified live frame advance and changing current/rolling
|
||||||
|
geometry in both 3D and PLAN, exact CAMERA sequence binding, VIDEO clock drift
|
||||||
|
below `0.1 s` after initial decode, `2×` pacing, fullscreen layout and an empty
|
||||||
|
browser error log. The first cold timeline index took approximately `3.5 s`;
|
||||||
|
after indexing, a 12-frame spatial chunk was served in approximately `33 ms` and
|
||||||
|
was about `1.27 MB`. M4.7 may proceed only from the corrected identities above;
|
||||||
|
physical-live, collision, navigation and actuation authority remain false.
|
||||||
|
|
||||||
## Implementation order
|
## Implementation order
|
||||||
|
|
||||||
|
|||||||
@@ -257,6 +257,25 @@ class RecordedGeometryStore:
|
|||||||
points.setflags(write=False)
|
points.setflags(write=False)
|
||||||
return points
|
return points
|
||||||
|
|
||||||
|
def current_points_for_frame(self, frame_index: int) -> FloatArray | None:
|
||||||
|
"""Expose one verified increment to a read-only recorded-evidence projector."""
|
||||||
|
|
||||||
|
if not isinstance(frame_index, int) or isinstance(frame_index, bool):
|
||||||
|
raise GeometryProviderError("replay evidence frame index is invalid")
|
||||||
|
if not 0 <= frame_index < self.profile.frame_count:
|
||||||
|
raise GeometryProviderError("replay evidence frame is outside the source profile")
|
||||||
|
if int(self._source["frame_indices"][frame_index]) != frame_index:
|
||||||
|
raise GeometryProviderError("source pack frame sequence changed")
|
||||||
|
if not bool(self._source["sample_available"][frame_index]) or not bool(
|
||||||
|
self._surface["frame_valid"][frame_index]
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
offsets = self._source["cloud_offsets"]
|
||||||
|
start, end = int(offsets[frame_index]), int(offsets[frame_index + 1])
|
||||||
|
points = np.asarray(self._source["cloud_points_map"][start:end], dtype=np.float64)
|
||||||
|
points.setflags(write=False)
|
||||||
|
return points
|
||||||
|
|
||||||
def pose_values_for_frame(
|
def pose_values_for_frame(
|
||||||
self,
|
self,
|
||||||
frame_id: str,
|
frame_id: str,
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Deterministic projections shared by recorded spatial evidence producers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import numpy.typing as npt
|
||||||
|
|
||||||
|
from .threat import ReplayBodyFrame
|
||||||
|
|
||||||
|
FloatArray = npt.NDArray[np.float64]
|
||||||
|
|
||||||
|
|
||||||
|
class SpatialEvidenceProjectionError(RuntimeError):
|
||||||
|
"""Recorded spatial evidence cannot be projected without changing meaning."""
|
||||||
|
|
||||||
|
|
||||||
|
def sample_points_in_body_frame(
|
||||||
|
points_map: FloatArray,
|
||||||
|
body_frame: ReplayBodyFrame,
|
||||||
|
*,
|
||||||
|
point_limit: int,
|
||||||
|
) -> tuple[list[list[float]], int]:
|
||||||
|
"""Project one immutable map-frame increment into the current body frame."""
|
||||||
|
|
||||||
|
if point_limit < 1:
|
||||||
|
raise SpatialEvidenceProjectionError("spatial evidence point limit must be positive")
|
||||||
|
points = np.asarray(points_map, dtype=np.float64)
|
||||||
|
if points.ndim != 2 or points.shape[1] != 3 or not np.isfinite(points).all():
|
||||||
|
raise SpatialEvidenceProjectionError("spatial evidence point array is invalid")
|
||||||
|
basis = np.asarray(body_frame.basis_map_from_body, dtype=np.float64)
|
||||||
|
origin = np.asarray(body_frame.origin_map_xyz_m, dtype=np.float64)
|
||||||
|
if basis.shape != (3, 3) or origin.shape != (3,):
|
||||||
|
raise SpatialEvidenceProjectionError("spatial evidence body frame is invalid")
|
||||||
|
points_body = (points - origin) @ basis
|
||||||
|
stride = max(1, math.ceil(points_body.shape[0] / point_limit))
|
||||||
|
sampled = points_body[::stride][:point_limit]
|
||||||
|
return np.round(sampled, 6).tolist(), int(points.shape[0])
|
||||||
|
|
||||||
|
|
||||||
|
def project_metric_obstacles_to_body(
|
||||||
|
metric_rows: Sequence[Mapping[str, object]],
|
||||||
|
body_frame: ReplayBodyFrame,
|
||||||
|
*,
|
||||||
|
occupied_voxel_size_m: float,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
"""Project ledger-owned metric components without recomputing their decision."""
|
||||||
|
|
||||||
|
if not math.isfinite(occupied_voxel_size_m) or occupied_voxel_size_m <= 0:
|
||||||
|
raise SpatialEvidenceProjectionError("occupied voxel size is invalid")
|
||||||
|
visuals: list[dict[str, object]] = []
|
||||||
|
for row in metric_rows:
|
||||||
|
centroid = row.get("centroid_map_xyz_m")
|
||||||
|
cells = row.get("cells")
|
||||||
|
if not isinstance(centroid, list) or len(centroid) != 3 or not isinstance(cells, list):
|
||||||
|
continue
|
||||||
|
centroid_map = _finite_vector3(centroid, "metric centroid")
|
||||||
|
centroid_body = body_frame.map_point_to_body(centroid_map)
|
||||||
|
cell_centers: list[list[float]] = []
|
||||||
|
for raw_cell in cells:
|
||||||
|
if not isinstance(raw_cell, dict):
|
||||||
|
raise SpatialEvidenceProjectionError("occupied cell is invalid")
|
||||||
|
indices = (
|
||||||
|
_signed_integer(raw_cell.get("x"), "cell x"),
|
||||||
|
_signed_integer(raw_cell.get("y"), "cell y"),
|
||||||
|
_signed_integer(raw_cell.get("z"), "cell z"),
|
||||||
|
)
|
||||||
|
point_map = (
|
||||||
|
(indices[0] + 0.5) * occupied_voxel_size_m,
|
||||||
|
(indices[1] + 0.5) * occupied_voxel_size_m,
|
||||||
|
(indices[2] + 0.5) * occupied_voxel_size_m,
|
||||||
|
)
|
||||||
|
cell_centers.append(list(body_frame.map_point_to_body(point_map)))
|
||||||
|
visuals.append(
|
||||||
|
{
|
||||||
|
"component_id": row.get("component_id"),
|
||||||
|
"state": row.get("state"),
|
||||||
|
"motion": row.get("motion"),
|
||||||
|
"centroid_body_xyz_m": list(centroid_body),
|
||||||
|
"cell_centers_body_xyz_m": cell_centers,
|
||||||
|
"assessment": row.get("assessment"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return visuals
|
||||||
|
|
||||||
|
|
||||||
|
def _finite_vector3(value: Sequence[object], label: str) -> tuple[float, float, float]:
|
||||||
|
if len(value) != 3:
|
||||||
|
raise SpatialEvidenceProjectionError(f"{label} is invalid")
|
||||||
|
return (
|
||||||
|
_finite_float(value[0], label),
|
||||||
|
_finite_float(value[1], label),
|
||||||
|
_finite_float(value[2], label),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _finite_float(value: object, label: str) -> float:
|
||||||
|
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||||
|
raise SpatialEvidenceProjectionError(f"{label} is invalid")
|
||||||
|
parsed = float(value)
|
||||||
|
if not math.isfinite(parsed):
|
||||||
|
raise SpatialEvidenceProjectionError(f"{label} is invalid")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _signed_integer(value: object, label: str) -> int:
|
||||||
|
if not isinstance(value, int) or isinstance(value, bool):
|
||||||
|
raise SpatialEvidenceProjectionError(f"{label} is invalid")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SpatialEvidenceProjectionError",
|
||||||
|
"project_metric_obstacles_to_body",
|
||||||
|
"sample_points_in_body_frame",
|
||||||
|
]
|
||||||
@@ -38,6 +38,10 @@ from .geometry import RecordedGeometryStore
|
|||||||
from .geometry_replay import GeometryReplayResult, read_geometry_replay_result
|
from .geometry_replay import GeometryReplayResult, read_geometry_replay_result
|
||||||
from .providers import SourcePacket
|
from .providers import SourcePacket
|
||||||
from .recorded_source import RecordedRavnoves00Source, ReplayPacing
|
from .recorded_source import RecordedRavnoves00Source, ReplayPacing
|
||||||
|
from .spatial_evidence import (
|
||||||
|
project_metric_obstacles_to_body,
|
||||||
|
sample_points_in_body_frame,
|
||||||
|
)
|
||||||
from .temporal_replay import TemporalReplayResult, read_temporal_replay_result
|
from .temporal_replay import TemporalReplayResult, read_temporal_replay_result
|
||||||
from .threat import (
|
from .threat import (
|
||||||
DEFAULT_REPLAY_THREAT_PROFILE_PATH,
|
DEFAULT_REPLAY_THREAT_PROFILE_PATH,
|
||||||
@@ -654,49 +658,24 @@ def _visual_frame(
|
|||||||
points = store.current_points(packet)
|
points = store.current_points(packet)
|
||||||
if points is None:
|
if points is None:
|
||||||
raise ThreatReplayError("visual frame has no current point cloud")
|
raise ThreatReplayError("visual frame has no current point cloud")
|
||||||
basis = np.asarray(body_frame.basis_map_from_body, dtype=np.float64)
|
sampled, source_count = sample_points_in_body_frame(
|
||||||
origin = np.asarray(body_frame.origin_map_xyz_m, dtype=np.float64)
|
points,
|
||||||
points_body = (points - origin) @ basis
|
body_frame,
|
||||||
stride = max(1, math.ceil(points_body.shape[0] / VISUAL_POINT_LIMIT))
|
point_limit=VISUAL_POINT_LIMIT,
|
||||||
sampled = points_body[::stride][:VISUAL_POINT_LIMIT]
|
)
|
||||||
metric_visuals = []
|
metric_visuals = project_metric_obstacles_to_body(
|
||||||
for row in metric_rows:
|
metric_rows,
|
||||||
centroid = row.get("centroid_map_xyz_m")
|
body_frame,
|
||||||
cells = row.get("cells")
|
occupied_voxel_size_m=profile.corridor.occupied_voxel_size_m,
|
||||||
if not isinstance(centroid, list) or not isinstance(cells, list):
|
)
|
||||||
continue
|
|
||||||
centroid_body = body_frame.map_point_to_body(
|
|
||||||
(float(centroid[0]), float(centroid[1]), float(centroid[2]))
|
|
||||||
)
|
|
||||||
cell_centers = []
|
|
||||||
for raw_cell in cells:
|
|
||||||
cell = _object(raw_cell, "visual occupied cell")
|
|
||||||
point_map = tuple(
|
|
||||||
(_signed_integer(cell.get(key), f"cell {key}") + 0.5)
|
|
||||||
* profile.corridor.occupied_voxel_size_m
|
|
||||||
for key in ("x", "y", "z")
|
|
||||||
)
|
|
||||||
cell_centers.append(
|
|
||||||
list(body_frame.map_point_to_body((point_map[0], point_map[1], point_map[2])))
|
|
||||||
)
|
|
||||||
metric_visuals.append(
|
|
||||||
{
|
|
||||||
"component_id": row["component_id"],
|
|
||||||
"state": row["state"],
|
|
||||||
"motion": row["motion"],
|
|
||||||
"centroid_body_xyz_m": list(centroid_body),
|
|
||||||
"cell_centers_body_xyz_m": cell_centers,
|
|
||||||
"assessment": row["assessment"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"schema_version": THREAT_REPLAY_VISUAL_SCHEMA_V2,
|
"schema_version": THREAT_REPLAY_VISUAL_SCHEMA_V2,
|
||||||
"sequence": packet.envelope.sequence,
|
"sequence": packet.envelope.sequence,
|
||||||
"frame_id": packet.envelope.frame_id,
|
"frame_id": packet.envelope.frame_id,
|
||||||
"source_time_ns": packet.envelope.timestamps.source_ns,
|
"source_time_ns": packet.envelope.timestamps.source_ns,
|
||||||
"point_cloud_body_xyz_m": np.round(sampled, 6).tolist(),
|
"point_cloud_body_xyz_m": sampled,
|
||||||
"point_cloud_source_count": int(points.shape[0]),
|
"point_cloud_source_count": source_count,
|
||||||
"point_cloud_sample_count": int(sampled.shape[0]),
|
"point_cloud_sample_count": len(sampled),
|
||||||
"point_cloud_layer": "current-increment",
|
"point_cloud_layer": "current-increment",
|
||||||
"rolling_map_component_count": sum(
|
"rolling_map_component_count": sum(
|
||||||
row.get("state") == TemporalState.RETAINED.value for row in metric_rows
|
row.get("state") == TemporalState.RETAINED.value for row in metric_rows
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""Bounded recorded-realtime projection of a sealed replay threat ledger."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import statistics
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from itertools import pairwise
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import RLock
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from .geometry import RecordedGeometryStore
|
||||||
|
from .spatial_evidence import (
|
||||||
|
project_metric_obstacles_to_body,
|
||||||
|
sample_points_in_body_frame,
|
||||||
|
)
|
||||||
|
from .threat import (
|
||||||
|
DEFAULT_REPLAY_THREAT_PROFILE_PATH,
|
||||||
|
RecordedReplayBodyFrameResolver,
|
||||||
|
load_replay_threat_profile,
|
||||||
|
)
|
||||||
|
from .threat_replay import (
|
||||||
|
THREAT_REPLAY_FRAME_SCHEMA,
|
||||||
|
THREAT_REPLAY_FRAME_SCHEMA_V2,
|
||||||
|
ThreatReplayResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
RECORDED_SPATIAL_TIMELINE_SCHEMA: Final = "missioncore.recorded-spatial-evidence-timeline/v1"
|
||||||
|
RECORDED_SPATIAL_CHUNK_SCHEMA: Final = "missioncore.recorded-spatial-evidence-chunk/v1"
|
||||||
|
RECORDED_SPATIAL_FRAME_SCHEMA: Final = "missioncore.recorded-spatial-evidence-frame/v1"
|
||||||
|
RECORDED_SPATIAL_POINT_LIMIT: Final = 2_000
|
||||||
|
RECORDED_SPATIAL_MAX_CHUNK_FRAMES: Final = 24
|
||||||
|
_EXPECTED_FRAME_COUNT: Final = 4_489
|
||||||
|
_SOURCE_TIME = re.compile(rb'"source_time_ns":([0-9]+)')
|
||||||
|
|
||||||
|
|
||||||
|
class RecordedThreatTimelineError(RuntimeError):
|
||||||
|
"""A bounded timeline projection escaped its sealed result or source."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RecordedThreatTimelineIndex:
|
||||||
|
offsets: tuple[int, ...]
|
||||||
|
source_times_ns: tuple[int, ...]
|
||||||
|
|
||||||
|
|
||||||
|
class RecordedThreatTimeline:
|
||||||
|
"""Read bounded spatial chunks without materializing the full ledger in memory."""
|
||||||
|
|
||||||
|
def __init__(self, *, repository_root: Path, result: ThreatReplayResult) -> None:
|
||||||
|
self.repository_root = repository_root.resolve(strict=True)
|
||||||
|
self.result = result
|
||||||
|
self.frames_path = (result.result_root / "frames.jsonl").resolve(strict=True)
|
||||||
|
if self.frames_path.is_symlink() or self.frames_path.parent != result.result_root:
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline frame ledger is invalid")
|
||||||
|
self.profile = load_replay_threat_profile(
|
||||||
|
self.repository_root / DEFAULT_REPLAY_THREAT_PROFILE_PATH
|
||||||
|
)
|
||||||
|
identity = result.manifest.get("identity")
|
||||||
|
if not isinstance(identity, dict):
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline identity is missing")
|
||||||
|
expected_identity = {
|
||||||
|
"profile_id": self.profile.profile_id,
|
||||||
|
"profile_sha256": self.profile.profile_sha256,
|
||||||
|
"source_id": self.profile.source_id,
|
||||||
|
"source_session_id": self.profile.session_id,
|
||||||
|
"source_pack_id": self.profile.source_pack_id,
|
||||||
|
"source_pack_sha256": self.profile.source_pack_sha256,
|
||||||
|
}
|
||||||
|
if any(identity.get(key) != value for key, value in expected_identity.items()):
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline escaped the threat profile")
|
||||||
|
self.store = RecordedGeometryStore.from_repository(self.repository_root)
|
||||||
|
if (
|
||||||
|
self.store.profile.source_pack_id != self.profile.source_pack_id
|
||||||
|
or self.store.profile.source_pack_sha256 != self.profile.source_pack_sha256
|
||||||
|
or self.store.profile.frame_count != _EXPECTED_FRAME_COUNT
|
||||||
|
):
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline geometry identity changed")
|
||||||
|
self.body_frames = RecordedReplayBodyFrameResolver(
|
||||||
|
self.store,
|
||||||
|
profile=self.profile.body_frame,
|
||||||
|
)
|
||||||
|
self.index = _index_frame_ledger(self.frames_path)
|
||||||
|
self._lock = RLock()
|
||||||
|
|
||||||
|
def metadata(self) -> dict[str, object]:
|
||||||
|
times = self.index.source_times_ns
|
||||||
|
intervals = [(current - previous) / 1_000_000_000 for previous, current in pairwise(times)]
|
||||||
|
nominal_interval = statistics.median(intervals)
|
||||||
|
if not math.isfinite(nominal_interval) or nominal_interval <= 0:
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline cadence is invalid")
|
||||||
|
return {
|
||||||
|
"schema_version": RECORDED_SPATIAL_TIMELINE_SCHEMA,
|
||||||
|
"result_id": self.result.result_id,
|
||||||
|
"recorded_source": {
|
||||||
|
"session_id": self.profile.session_id,
|
||||||
|
"source_id": self.profile.source_id,
|
||||||
|
"synchronization": "host-arrival-best-effort",
|
||||||
|
},
|
||||||
|
"frame_count": len(times),
|
||||||
|
"frame_times_ns": list(times),
|
||||||
|
"timeline_start_seconds": times[0] / 1_000_000_000,
|
||||||
|
"timeline_end_seconds": times[-1] / 1_000_000_000,
|
||||||
|
"nominal_frame_interval_seconds": nominal_interval,
|
||||||
|
"nominal_rate_hz": 1 / nominal_interval,
|
||||||
|
"max_chunk_frames": RECORDED_SPATIAL_MAX_CHUNK_FRAMES,
|
||||||
|
"point_sample_limit": RECORDED_SPATIAL_POINT_LIMIT,
|
||||||
|
"image_width": 800,
|
||||||
|
"image_height": 600,
|
||||||
|
"rig": {
|
||||||
|
"length_m": self.profile.rig.body_length_m,
|
||||||
|
"width_m": self.profile.rig.body_width_m,
|
||||||
|
"nominal_sensor_height_m": self.profile.rig.nominal_sensor_height_m,
|
||||||
|
},
|
||||||
|
"corridor": {
|
||||||
|
"forward_length_m": self.profile.corridor.forward_length_m,
|
||||||
|
"rear_margin_m": self.profile.corridor.rear_margin_m,
|
||||||
|
"half_width_m": (
|
||||||
|
self.profile.rig.body_width_m / 2 + self.profile.corridor.lateral_clearance_m
|
||||||
|
),
|
||||||
|
"prediction_horizon_seconds": (self.profile.corridor.prediction_horizon_seconds),
|
||||||
|
},
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": "replay-simulated",
|
||||||
|
"access": "read-only-bounded-recorded-replay",
|
||||||
|
}
|
||||||
|
|
||||||
|
def chunk(self, *, start_sequence: int, frame_count: int) -> dict[str, object]:
|
||||||
|
if not 0 <= start_sequence < len(self.index.offsets):
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline chunk start is invalid")
|
||||||
|
if not 1 <= frame_count <= RECORDED_SPATIAL_MAX_CHUNK_FRAMES:
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline chunk size is invalid")
|
||||||
|
stop = min(len(self.index.offsets), start_sequence + frame_count)
|
||||||
|
with self._lock:
|
||||||
|
frames = [self._project_frame(sequence) for sequence in range(start_sequence, stop)]
|
||||||
|
return {
|
||||||
|
"schema_version": RECORDED_SPATIAL_CHUNK_SCHEMA,
|
||||||
|
"result_id": self.result.result_id,
|
||||||
|
"start_sequence": start_sequence,
|
||||||
|
"frame_count": len(frames),
|
||||||
|
"next_sequence": stop if stop < len(self.index.offsets) else None,
|
||||||
|
"frames": frames,
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": "replay-simulated",
|
||||||
|
"access": "read-only-bounded-recorded-replay",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _project_frame(self, sequence: int) -> dict[str, object]:
|
||||||
|
row = _read_frame_at(self.frames_path, self.index, sequence)
|
||||||
|
frame_id = row.get("frame_id")
|
||||||
|
if not isinstance(frame_id, str) or not frame_id:
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline frame identity is invalid")
|
||||||
|
body_frame = self.body_frames.body_frame_for_frame(frame_id)
|
||||||
|
body_frame_declared = row.get("body_frame_available")
|
||||||
|
if not isinstance(body_frame_declared, bool) or body_frame_declared is not (
|
||||||
|
body_frame is not None
|
||||||
|
):
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline body-frame binding changed")
|
||||||
|
source_available = row.get("source_available")
|
||||||
|
if not isinstance(source_available, bool):
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline source state is invalid")
|
||||||
|
point_cloud: list[list[float]] = []
|
||||||
|
point_source_count = 0
|
||||||
|
metric_visuals: list[dict[str, object]] = []
|
||||||
|
if body_frame is not None:
|
||||||
|
points = self.store.current_points_for_frame(sequence)
|
||||||
|
if points is None or not source_available:
|
||||||
|
raise RecordedThreatTimelineError(
|
||||||
|
"recorded timeline current increment binding changed"
|
||||||
|
)
|
||||||
|
point_cloud, point_source_count = sample_points_in_body_frame(
|
||||||
|
points,
|
||||||
|
body_frame,
|
||||||
|
point_limit=RECORDED_SPATIAL_POINT_LIMIT,
|
||||||
|
)
|
||||||
|
metric_visuals = project_metric_obstacles_to_body(
|
||||||
|
_mapping_array(row.get("metric_obstacles"), "metric obstacles"),
|
||||||
|
body_frame,
|
||||||
|
occupied_voxel_size_m=self.profile.corridor.occupied_voxel_size_m,
|
||||||
|
)
|
||||||
|
assessments = _mapping_array(row.get("assessments"), "threat assessments")
|
||||||
|
camera_proposals = row.get("camera_proposals")
|
||||||
|
if not isinstance(camera_proposals, list):
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline camera proposals are invalid")
|
||||||
|
return {
|
||||||
|
"schema_version": RECORDED_SPATIAL_FRAME_SCHEMA,
|
||||||
|
"sequence": sequence,
|
||||||
|
"frame_id": frame_id,
|
||||||
|
"source_time_ns": self.index.source_times_ns[sequence],
|
||||||
|
"session_seconds": self.index.source_times_ns[sequence] / 1_000_000_000,
|
||||||
|
"source_available": source_available,
|
||||||
|
"spatial_available": body_frame is not None,
|
||||||
|
"point_cloud_body_xyz_m": point_cloud,
|
||||||
|
"point_cloud_source_count": point_source_count,
|
||||||
|
"point_cloud_sample_count": len(point_cloud),
|
||||||
|
"point_cloud_layer": "current-increment",
|
||||||
|
"rolling_map_component_count": sum(
|
||||||
|
item.get("state") == "retained" for item in metric_visuals
|
||||||
|
),
|
||||||
|
"metric_obstacles": metric_visuals,
|
||||||
|
"camera_proposals": copy.deepcopy(camera_proposals),
|
||||||
|
"decision_counts": _decision_counts(assessments),
|
||||||
|
"camera_url": (
|
||||||
|
f"/api/v1/laboratory/m4-threat/results/{self.result.result_id}"
|
||||||
|
f"/timeline/frames/{sequence}/camera"
|
||||||
|
),
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": "replay-simulated",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _index_frame_ledger(path: Path) -> RecordedThreatTimelineIndex:
|
||||||
|
offsets: list[int] = []
|
||||||
|
source_times: list[int] = []
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
while True:
|
||||||
|
offset = handle.tell()
|
||||||
|
line = handle.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
match = _SOURCE_TIME.search(line)
|
||||||
|
if match is None:
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline source time is missing")
|
||||||
|
offsets.append(offset)
|
||||||
|
source_times.append(int(match.group(1)))
|
||||||
|
if len(offsets) != _EXPECTED_FRAME_COUNT:
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline frame count changed")
|
||||||
|
if any(current <= previous for previous, current in pairwise(source_times)):
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline source time is not monotonic")
|
||||||
|
return RecordedThreatTimelineIndex(tuple(offsets), tuple(source_times))
|
||||||
|
|
||||||
|
|
||||||
|
def _read_frame_at(
|
||||||
|
path: Path,
|
||||||
|
index: RecordedThreatTimelineIndex,
|
||||||
|
sequence: int,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
handle.seek(index.offsets[sequence])
|
||||||
|
line = handle.readline()
|
||||||
|
try:
|
||||||
|
row = json.loads(line)
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline frame JSON is invalid") from error
|
||||||
|
if (
|
||||||
|
not isinstance(row, dict)
|
||||||
|
or row.get("schema_version")
|
||||||
|
not in {THREAT_REPLAY_FRAME_SCHEMA, THREAT_REPLAY_FRAME_SCHEMA_V2}
|
||||||
|
or row.get("sequence") != sequence
|
||||||
|
or row.get("source_time_ns") != index.source_times_ns[sequence]
|
||||||
|
):
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline frame binding changed")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping_array(value: object, label: str) -> list[dict[str, object]]:
|
||||||
|
if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
|
||||||
|
raise RecordedThreatTimelineError(f"recorded timeline {label} are invalid")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _decision_counts(assessments: list[dict[str, object]]) -> dict[str, int]:
|
||||||
|
result = {"threat": 0, "not-threat": 0, "unknown": 0}
|
||||||
|
for item in assessments:
|
||||||
|
decision = item.get("decision")
|
||||||
|
if not isinstance(decision, str) or decision not in result:
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline decision is invalid")
|
||||||
|
result[decision] += 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RECORDED_SPATIAL_CHUNK_SCHEMA",
|
||||||
|
"RECORDED_SPATIAL_FRAME_SCHEMA",
|
||||||
|
"RECORDED_SPATIAL_MAX_CHUNK_FRAMES",
|
||||||
|
"RECORDED_SPATIAL_POINT_LIMIT",
|
||||||
|
"RECORDED_SPATIAL_TIMELINE_SCHEMA",
|
||||||
|
"RecordedThreatTimeline",
|
||||||
|
"RecordedThreatTimelineError",
|
||||||
|
]
|
||||||
@@ -767,6 +767,7 @@ app.include_router(
|
|||||||
root_provider=lambda: (
|
root_provider=lambda: (
|
||||||
REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "m4" / "replay-threat"
|
REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "m4" / "replay-threat"
|
||||||
),
|
),
|
||||||
|
repository_root_provider=lambda: REPOSITORY_ROOT,
|
||||||
camera_frame_provider=(
|
camera_frame_provider=(
|
||||||
session_recorded_camera_frame_service.extract
|
session_recorded_camera_frame_service.extract
|
||||||
if session_recorded_camera_frame_service is not None
|
if session_recorded_camera_frame_service is not None
|
||||||
|
|||||||
@@ -22,11 +22,15 @@ from k1link.perception.threat_replay import (
|
|||||||
ThreatReplayResult,
|
ThreatReplayResult,
|
||||||
read_threat_replay_result,
|
read_threat_replay_result,
|
||||||
)
|
)
|
||||||
|
from k1link.perception.threat_timeline import (
|
||||||
|
RECORDED_SPATIAL_MAX_CHUNK_FRAMES,
|
||||||
|
RecordedThreatTimeline,
|
||||||
|
RecordedThreatTimelineError,
|
||||||
|
)
|
||||||
from k1link.sessions import RecordedCameraFrame, SessionIntegrityError
|
from k1link.sessions import RecordedCameraFrame, SessionIntegrityError
|
||||||
|
|
||||||
M4_THREAT_CATALOG_SCHEMA: Final = "missioncore.m4-threat-replay-catalog/v1"
|
M4_THREAT_CATALOG_SCHEMA: Final = "missioncore.m4-threat-replay-catalog/v1"
|
||||||
M4_THREAT_VIEW_SCHEMA: Final = "missioncore.m4-threat-replay-view/v1"
|
M4_THREAT_VIEW_SCHEMA: Final = "missioncore.m4-threat-replay-view/v1"
|
||||||
M4_THREAT_VIDEO_SCHEMA: Final = "missioncore.m4-threat-video-overlay/v1"
|
|
||||||
M4_THREAT_VISUAL_CATALOG_SCHEMA: Final = "missioncore.m4-threat-visual-catalog/v1"
|
M4_THREAT_VISUAL_CATALOG_SCHEMA: Final = "missioncore.m4-threat-visual-catalog/v1"
|
||||||
_RESULT_ID = re.compile(rf"^{THREAT_REPLAY_RESULT_PREFIX}[a-f0-9]{{64}}$")
|
_RESULT_ID = re.compile(rf"^{THREAT_REPLAY_RESULT_PREFIX}[a-f0-9]{{64}}$")
|
||||||
RootProvider = Callable[[], Path | None]
|
RootProvider = Callable[[], Path | None]
|
||||||
@@ -36,6 +40,7 @@ CameraFrameProvider = Callable[[str, int], RecordedCameraFrame]
|
|||||||
def build_m4_threat_replay_router(
|
def build_m4_threat_replay_router(
|
||||||
*,
|
*,
|
||||||
root_provider: RootProvider = lambda: None,
|
root_provider: RootProvider = lambda: None,
|
||||||
|
repository_root_provider: RootProvider = lambda: None,
|
||||||
camera_frame_provider: CameraFrameProvider | None = None,
|
camera_frame_provider: CameraFrameProvider | None = None,
|
||||||
) -> APIRouter:
|
) -> APIRouter:
|
||||||
router = APIRouter(prefix="/api/v1/laboratory/m4-threat", tags=["laboratory"])
|
router = APIRouter(prefix="/api/v1/laboratory/m4-threat", tags=["laboratory"])
|
||||||
@@ -54,6 +59,23 @@ def build_m4_threat_replay_router(
|
|||||||
except (ThreatReplayError, OSError, ValueError):
|
except (ThreatReplayError, OSError, ValueError):
|
||||||
raise HTTPException(status_code=404, detail="M4.6 result не найден") from None
|
raise HTTPException(status_code=404, detail="M4.6 result не найден") from None
|
||||||
|
|
||||||
|
def timeline(result_id: str) -> RecordedThreatTimeline:
|
||||||
|
frozen = result(result_id)
|
||||||
|
repository_root = _configured_root(repository_root_provider)
|
||||||
|
if repository_root is None:
|
||||||
|
raise HTTPException(status_code=503, detail="M4.6 timeline source недоступен")
|
||||||
|
try:
|
||||||
|
return _read_threat_timeline_cached(
|
||||||
|
str(repository_root),
|
||||||
|
str(frozen.result_root),
|
||||||
|
_result_signature(frozen.result_root),
|
||||||
|
)
|
||||||
|
except (OSError, ThreatReplayError, RecordedThreatTimelineError, ValueError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="M4.6 bounded timeline не прошёл проверку",
|
||||||
|
) from None
|
||||||
|
|
||||||
@router.get("/results")
|
@router.get("/results")
|
||||||
def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]:
|
def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]:
|
||||||
candidates = _candidates(root_provider)
|
candidates = _candidates(root_provider)
|
||||||
@@ -134,41 +156,38 @@ def build_m4_threat_replay_router(
|
|||||||
sequence = frames[ordinal - 1].get("sequence")
|
sequence = frames[ordinal - 1].get("sequence")
|
||||||
if not isinstance(session_id, str) or not isinstance(sequence, int):
|
if not isinstance(session_id, str) or not isinstance(sequence, int):
|
||||||
raise HTTPException(status_code=404, detail="M4.6 camera identity не найдена")
|
raise HTTPException(status_code=404, detail="M4.6 camera identity не найдена")
|
||||||
try:
|
return _camera_response(camera_frame_provider, session_id, sequence)
|
||||||
camera = camera_frame_provider(session_id, sequence)
|
|
||||||
except (OSError, SessionIntegrityError, ValueError):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="M4.6 exact camera frame недоступен",
|
|
||||||
) from None
|
|
||||||
if camera.width != 800 or camera.height != 600:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="M4.6 camera frame нарушил размерный контракт",
|
|
||||||
)
|
|
||||||
return Response(
|
|
||||||
content=camera.payload,
|
|
||||||
media_type=camera.media_type,
|
|
||||||
headers={
|
|
||||||
"Cache-Control": "private, max-age=31536000, immutable",
|
|
||||||
"ETag": f'"{camera.sha256}"',
|
|
||||||
"X-Content-Type-Options": "nosniff",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get("/results/{result_id}/video-overlay")
|
@router.get("/results/{result_id}/timeline")
|
||||||
def get_video_overlay(result_id: str) -> dict[str, object]:
|
def get_timeline(result_id: str) -> dict[str, object]:
|
||||||
frozen = result(result_id)
|
return copy.deepcopy(timeline(result_id).metadata())
|
||||||
identity = frozen.manifest["identity"]
|
|
||||||
assert isinstance(identity, dict)
|
@router.get("/results/{result_id}/timeline/chunk")
|
||||||
return copy.deepcopy(
|
def get_timeline_chunk(
|
||||||
_cached_video_overlay(
|
result_id: str,
|
||||||
result_id,
|
start: int = Query(default=0, ge=0),
|
||||||
str(frozen.result_root),
|
count: int = Query(
|
||||||
str(identity["frames_sha256"]),
|
default=12,
|
||||||
str(identity["source_session_id"]),
|
ge=1,
|
||||||
)
|
le=RECORDED_SPATIAL_MAX_CHUNK_FRAMES,
|
||||||
)
|
),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
return timeline(result_id).chunk(start_sequence=start, frame_count=count)
|
||||||
|
except RecordedThreatTimelineError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="M4.6 timeline chunk не найден",
|
||||||
|
) from None
|
||||||
|
|
||||||
|
@router.get("/results/{result_id}/timeline/frames/{sequence}/camera")
|
||||||
|
def get_timeline_camera(result_id: str, sequence: int) -> Response:
|
||||||
|
if camera_frame_provider is None:
|
||||||
|
raise HTTPException(status_code=503, detail="M4.6 camera decoder недоступен")
|
||||||
|
projected = timeline(result_id)
|
||||||
|
if not 0 <= sequence < len(projected.index.source_times_ns):
|
||||||
|
raise HTTPException(status_code=404, detail="M4.6 timeline frame не найден")
|
||||||
|
return _camera_response(camera_frame_provider, projected.profile.session_id, sequence)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|
||||||
@@ -183,53 +202,16 @@ def _read_threat_result_cached(
|
|||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=4)
|
@lru_cache(maxsize=4)
|
||||||
def _cached_video_overlay(
|
def _read_threat_timeline_cached(
|
||||||
result_id: str,
|
repository_root_value: str,
|
||||||
root_value: str,
|
root_value: str,
|
||||||
frames_sha256: str,
|
signature: tuple[int, ...],
|
||||||
source_session_id: str,
|
) -> RecordedThreatTimeline:
|
||||||
) -> dict[str, object]:
|
result = _read_threat_result_cached(root_value, signature)
|
||||||
root = Path(root_value).resolve(strict=True)
|
return RecordedThreatTimeline(
|
||||||
if root.is_symlink() or not root.is_dir() or len(frames_sha256) != 64:
|
repository_root=Path(repository_root_value),
|
||||||
raise ValueError("M4.6 video evidence identity changed")
|
result=result,
|
||||||
frames = []
|
)
|
||||||
for expected_sequence, row in enumerate(_iter_jsonl(root / "frames.jsonl")):
|
|
||||||
if (
|
|
||||||
row.get("schema_version")
|
|
||||||
not in {THREAT_REPLAY_FRAME_SCHEMA, THREAT_REPLAY_FRAME_SCHEMA_V2}
|
|
||||||
or row.get("sequence") != expected_sequence
|
|
||||||
):
|
|
||||||
raise ValueError("M4.6 video frame order changed")
|
|
||||||
frames.append(
|
|
||||||
{
|
|
||||||
"frame_index": expected_sequence,
|
|
||||||
"session_seconds": _nonnegative_int(row.get("source_time_ns"), "source time")
|
|
||||||
/ 1_000_000_000,
|
|
||||||
"source_available": row["source_available"],
|
|
||||||
"camera_proposals": copy.deepcopy(row["camera_proposals"]),
|
|
||||||
"decision_counts": _decision_counts(_array(row.get("assessments"))),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if len(frames) != 4489:
|
|
||||||
raise ValueError("M4.6 video frame coverage changed")
|
|
||||||
return {
|
|
||||||
"schema_version": M4_THREAT_VIDEO_SCHEMA,
|
|
||||||
"result_id": result_id,
|
|
||||||
"recorded_source": {
|
|
||||||
"session_id": source_session_id,
|
|
||||||
"source_id": "sensor.camera.right",
|
|
||||||
"synchronization": "host-arrival-best-effort",
|
|
||||||
},
|
|
||||||
"image_width": 800,
|
|
||||||
"image_height": 600,
|
|
||||||
"timeline_start_seconds": frames[0]["session_seconds"],
|
|
||||||
"timeline_end_seconds": frames[-1]["session_seconds"],
|
|
||||||
"frame_count": len(frames),
|
|
||||||
"frames": frames,
|
|
||||||
"ground_truth": False,
|
|
||||||
"authority": "replay-simulated",
|
|
||||||
"access": "read-only-replay-simulated-video",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _project_result(result: ThreatReplayResult) -> dict[str, object]:
|
def _project_result(result: ThreatReplayResult) -> dict[str, object]:
|
||||||
@@ -261,14 +243,32 @@ def _project_result(result: ThreatReplayResult) -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _decision_counts(raw: list[object]) -> dict[str, int]:
|
def _camera_response(
|
||||||
result = {"threat": 0, "not-threat": 0, "unknown": 0}
|
provider: CameraFrameProvider,
|
||||||
for item in raw:
|
session_id: str,
|
||||||
assessment = item if isinstance(item, dict) else {}
|
sequence: int,
|
||||||
decision = assessment.get("decision")
|
) -> Response:
|
||||||
if isinstance(decision, str) and decision in result:
|
try:
|
||||||
result[decision] += 1
|
camera = provider(session_id, sequence)
|
||||||
return result
|
except (OSError, SessionIntegrityError, ValueError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="M4.6 exact camera frame недоступен",
|
||||||
|
) from None
|
||||||
|
if camera.width != 800 or camera.height != 600:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="M4.6 camera frame нарушил размерный контракт",
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
content=camera.payload,
|
||||||
|
media_type=camera.media_type,
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
"ETag": f'"{camera.sha256}"',
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _configured_root(provider: RootProvider) -> Path | None:
|
def _configured_root(provider: RootProvider) -> Path | None:
|
||||||
@@ -343,15 +343,8 @@ def _array(value: object) -> list[object]:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _nonnegative_int(value: object, label: str) -> int:
|
|
||||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
||||||
raise ValueError(f"M4.6 {label} is invalid")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"M4_THREAT_CATALOG_SCHEMA",
|
"M4_THREAT_CATALOG_SCHEMA",
|
||||||
"M4_THREAT_VIDEO_SCHEMA",
|
|
||||||
"M4_THREAT_VIEW_SCHEMA",
|
"M4_THREAT_VIEW_SCHEMA",
|
||||||
"M4_THREAT_VISUAL_CATALOG_SCHEMA",
|
"M4_THREAT_VISUAL_CATALOG_SCHEMA",
|
||||||
"build_m4_threat_replay_router",
|
"build_m4_threat_replay_router",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ RESULTS_ROOT = REPOSITORY_ROOT / ".runtime/compute-experiments/m4/replay-threat"
|
|||||||
def _endpoint(path: str, *, camera_frame_provider=None):
|
def _endpoint(path: str, *, camera_frame_provider=None):
|
||||||
router = build_m4_threat_replay_router(
|
router = build_m4_threat_replay_router(
|
||||||
root_provider=lambda: RESULTS_ROOT,
|
root_provider=lambda: RESULTS_ROOT,
|
||||||
|
repository_root_provider=lambda: REPOSITORY_ROOT,
|
||||||
camera_frame_provider=camera_frame_provider,
|
camera_frame_provider=camera_frame_provider,
|
||||||
)
|
)
|
||||||
return next(
|
return next(
|
||||||
@@ -120,16 +121,28 @@ def test_m4_6_lab_api_projects_report_and_exact_visual_frame() -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_m4_6_video_overlay_covers_the_exact_recorded_camera_timeline() -> None:
|
def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None:
|
||||||
get_overlay = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/video-overlay")
|
get_timeline = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline")
|
||||||
|
get_chunk = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline/chunk")
|
||||||
|
|
||||||
overlay = get_overlay(RESULT_ID)
|
timeline = get_timeline(RESULT_ID)
|
||||||
|
assert timeline["schema_version"] == "missioncore.recorded-spatial-evidence-timeline/v1"
|
||||||
|
assert timeline["frame_count"] == 4489
|
||||||
|
assert len(timeline["frame_times_ns"]) == 4489
|
||||||
|
assert timeline["recorded_source"]["session_id"] == "20260720T065719Z_viewer_live"
|
||||||
|
assert "frames" not in timeline
|
||||||
|
|
||||||
assert overlay["frame_count"] == 4489
|
chunk = get_chunk(RESULT_ID, start=1880, count=12)
|
||||||
assert overlay["recorded_source"]["session_id"] == ("20260720T065719Z_viewer_live")
|
assert chunk["schema_version"] == "missioncore.recorded-spatial-evidence-chunk/v1"
|
||||||
assert overlay["frames"][0]["frame_index"] == 0
|
assert chunk["start_sequence"] == 1880
|
||||||
assert overlay["frames"][-1]["frame_index"] == 4488
|
assert chunk["frame_count"] == 12
|
||||||
assert overlay["authority"] == "replay-simulated"
|
assert [frame["sequence"] for frame in chunk["frames"]] == list(range(1880, 1892))
|
||||||
|
first = chunk["frames"][0]
|
||||||
|
assert first["schema_version"] == "missioncore.recorded-spatial-evidence-frame/v1"
|
||||||
|
assert first["spatial_available"] is True
|
||||||
|
assert first["point_cloud_layer"] == "current-increment"
|
||||||
|
assert 0 < first["point_cloud_sample_count"] <= 2000
|
||||||
|
assert first["camera_url"].endswith(f"/{RESULT_ID}/timeline/frames/1880/camera")
|
||||||
|
|
||||||
|
|
||||||
def test_m4_6_exact_camera_endpoint_is_bound_to_selected_visual_sequence() -> None:
|
def test_m4_6_exact_camera_endpoint_is_bound_to_selected_visual_sequence() -> None:
|
||||||
@@ -155,3 +168,26 @@ def test_m4_6_exact_camera_endpoint_is_bound_to_selected_visual_sequence() -> No
|
|||||||
assert response.media_type == "image/jpeg"
|
assert response.media_type == "image/jpeg"
|
||||||
assert response.headers["etag"] == f'"{"a" * 64}"'
|
assert response.headers["etag"] == f'"{"a" * 64}"'
|
||||||
assert calls == [("20260720T065719Z_viewer_live", 2584)]
|
assert calls == [("20260720T065719Z_viewer_live", 2584)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_m4_6_timeline_camera_endpoint_is_bound_to_exact_sequence() -> None:
|
||||||
|
calls: list[tuple[str, int]] = []
|
||||||
|
|
||||||
|
def provide(session_id: str, frame_index: int) -> RecordedCameraFrame:
|
||||||
|
calls.append((session_id, frame_index))
|
||||||
|
return RecordedCameraFrame(
|
||||||
|
payload=b"timeline-jpeg",
|
||||||
|
media_type="image/jpeg",
|
||||||
|
width=800,
|
||||||
|
height=600,
|
||||||
|
sha256="b" * 64,
|
||||||
|
)
|
||||||
|
|
||||||
|
endpoint = _endpoint(
|
||||||
|
"/api/v1/laboratory/m4-threat/results/{result_id}/timeline/frames/{sequence}/camera",
|
||||||
|
camera_frame_provider=provide,
|
||||||
|
)
|
||||||
|
response = endpoint(RESULT_ID, 2584)
|
||||||
|
|
||||||
|
assert response.body == b"timeline-jpeg"
|
||||||
|
assert calls == [("20260720T065719Z_viewer_live", 2584)]
|
||||||
|
|||||||
Reference in New Issue
Block a user