feat(lab): add recorded realtime spatial playback

This commit is contained in:
DCCONSTRUCTIONS
2026-08-05 23:17:48 +03:00
parent aacc6dc43b
commit 9df9f58ab8
23 changed files with 1443 additions and 517 deletions
@@ -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";
@@ -14,6 +14,8 @@ export function ObservationTimeline({
onSeek,
onPlayingChange,
onJumpToEnd,
playbackRate,
onPlaybackRateChange,
accumulationSeconds,
onAccumulationChange,
onAccumulationCommit,
@@ -30,6 +32,8 @@ export function ObservationTimeline({
onSeek?: (timeNs: number) => void;
onPlayingChange?: (playing: boolean) => void;
onJumpToEnd?: () => void;
playbackRate?: number;
onPlaybackRateChange?: (rate: number) => void;
accumulationSeconds?: number;
onAccumulationChange?: (value: number) => void;
onAccumulationCommit?: () => void;
@@ -102,6 +106,20 @@ export function ObservationTimeline({
>
{buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
</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
className="observation-timeline__track"
type="range"
@@ -17,6 +17,7 @@ import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
export interface RecordedObservationPlayback {
currentSeconds: number;
playing: boolean;
rate?: number;
}
export interface RecordedMediaArchive {
@@ -290,6 +291,9 @@ export function RecordedFmp4Player({
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
const [bufferRevision, setBufferRevision] = useState(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(
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
[archive?.manifest.epochs, currentSeconds],
@@ -460,12 +464,21 @@ export function RecordedFmp4Player({
return;
}
}
video.playbackRate = playbackRate;
if (playback?.playing) {
void video.play().catch(() => undefined);
} else {
video.pause();
}
}, [archive?.byteLength, bufferRevision, currentSeconds, epoch, playback?.playing, visualState]);
}, [
archive?.byteLength,
bufferRevision,
currentSeconds,
epoch,
playback?.playing,
playbackRate,
visualState,
]);
useEffect(() => {
const video = videoRef.current;
@@ -475,6 +488,7 @@ export function RecordedFmp4Player({
onPlaybackChangeRef.current?.({
currentSeconds: epoch.timelineStartSeconds + video.currentTime,
playing: !video.paused && !video.ended,
rate: video.playbackRate,
});
};
const scheduleVideoFrame = () => {
@@ -29,6 +29,7 @@ export function LaboratoryEvidenceViewer<
actions,
secondaryMode,
overlay,
transport,
children,
}: {
label: string;
@@ -46,6 +47,7 @@ export function LaboratoryEvidenceViewer<
onChange: (mode: U) => void;
};
overlay?: ReactNode;
transport?: ReactNode;
children: ReactNode;
}) {
const expandButtonRef = useRef<HTMLButtonElement | null>(null);
@@ -75,6 +77,11 @@ export function LaboratoryEvidenceViewer<
{children}
</div>
{overlay}
{transport ? (
<div className="laboratory-evidence-viewer__transport">
{transport}
</div>
) : null}
<div className="laboratory-evidence-viewer__controls">
{actions}
{secondaryMode ? (
@@ -53,6 +53,15 @@ function disposeRenderable(object: THREE.Object3D): void {
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 {
return [point[0], point[2], -point[1]];
}
@@ -101,7 +110,8 @@ export function LaboratoryMetricEvidenceScene({
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | 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 [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
const [showRollingMap, setShowRollingMap] = useState(true);
@@ -137,12 +147,14 @@ export function LaboratoryMetricEvidenceScene({
controls.screenSpacePanning = true;
controls.minDistance = 0.4;
controls.maxDistance = 80;
const content = new THREE.Group();
scene.add(content);
const staticContent = new THREE.Group();
const dynamicContent = new THREE.Group();
scene.add(staticContent, dynamicContent);
sceneRef.current = scene;
cameraRef.current = camera;
controlsRef.current = controls;
contentRef.current = content;
staticContentRef.current = staticContent;
dynamicContentRef.current = dynamicContent;
const resize = () => {
const width = Math.max(host.clientWidth, 1);
@@ -172,20 +184,16 @@ export function LaboratoryMetricEvidenceScene({
sceneRef.current = null;
cameraRef.current = null;
controlsRef.current = null;
contentRef.current = null;
staticContentRef.current = null;
dynamicContentRef.current = null;
};
}, [label]);
useEffect(() => {
const host = hostRef.current;
const content = contentRef.current;
const content = dynamicContentRef.current;
if (!host || !content) return;
while (content.children.length) {
const child = content.children[0];
if (!child) break;
content.remove(child);
child.traverse(disposeRenderable);
}
clearGroup(content);
if (showCurrentIncrement) {
const contextGeometry = new THREE.BufferGeometry();
@@ -244,6 +252,19 @@ export function LaboratoryMetricEvidenceScene({
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 corridorCenterX = (rig.lengthM / 2 + corridor.forwardLengthM - corridor.rearMarginM) / 2;
const corridorMesh = new THREE.Mesh(
@@ -302,14 +323,7 @@ export function LaboratoryMetricEvidenceScene({
material.depthWrite = false;
});
content.add(grid);
}, [
corridor,
obstacles,
pointCloudBodyXyzM,
rig,
showCurrentIncrement,
showRollingMap,
]);
}, [corridor, rig]);
const resetView = () => {
const camera = cameraRef.current;
@@ -327,7 +341,7 @@ export function LaboratoryMetricEvidenceScene({
controls.update();
};
useEffect(resetView, [corridor.forwardLengthM, mode, obstacles]);
useEffect(resetView, [corridor.forwardLengthM, mode]);
return (
<div className="laboratory-metric-evidence-scene">
@@ -18,6 +18,7 @@ export function RecordedEvidenceVideoScene({
imageHeight,
boxes,
ariaLabel,
interactive = true,
onPlaybackChange,
}: {
source: ObservationSourceDescriptor;
@@ -26,14 +27,15 @@ export function RecordedEvidenceVideoScene({
imageHeight: number;
boxes: readonly RecordedEvidenceBox[];
ariaLabel: string;
onPlaybackChange: (playback: RecordedObservationPlayback) => void;
interactive?: boolean;
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
}) {
return (
<div className="recorded-evidence-video-scene">
<RecordedFmp4Player
source={source}
playback={playback}
interactive
interactive={interactive}
prepare
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;
}
export interface M4ThreatVideoFrame {
frameIndex: number;
export interface M4ThreatTimelineFrame {
sequence: number;
frameId: string;
sourceTimeNs: number;
sessionSeconds: number;
sourceAvailable: boolean;
spatialAvailable: boolean;
pointCloudBodyXyzM: readonly M4Point3[];
pointCloudSourceCount: number;
pointCloudSampleCount: number;
pointCloudLayer: "current-increment";
rollingMapComponentCount: number;
metricObstacles: readonly M4ThreatMetricVisual[];
cameraProposals: readonly M4ThreatCameraProposal[];
decisionCounts: Record<M4ThreatDecision, number>;
cameraUrl: string;
}
export interface M4ThreatVideoOverlay {
export interface M4ThreatTimeline {
resultId: string;
recordedSourceSessionId: "20260720T065719Z_viewer_live";
imageWidth: 800;
imageHeight: 600;
frameCount: 4489;
frameTimesNs: readonly number[];
timelineStartSeconds: 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>;
@@ -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({
fetcher = fetch,
signal,
@@ -425,28 +473,7 @@ export async function fetchM4ThreatVisual(
rollingMapComponentCount: rollingMapV2
? integer(item.rolling_map_component_count, "M4.6 rolling components")
: 0,
metricObstacles: array(item.metric_obstacles, "M4.6 metric visuals").map((raw) => {
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),
};
}),
metricObstacles: array(item.metric_obstacles, "M4.6 metric visuals").map(parseMetricVisual),
cameraProposals: array(item.camera_proposals, "M4.6 camera proposals").map(parseCameraProposal),
rig: {
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,
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
): Promise<M4ThreatVideoOverlay> {
): Promise<M4ThreatTimeline> {
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 },
);
if (!response.ok) throw new M4ThreatContractError(`M4.6 video overlay: HTTP ${response.status}.`);
const payload = object(await response.json(), "M4.6 video overlay");
exact(payload.schema_version, "missioncore.m4-threat-video-overlay/v1", "M4.6 video schema");
exact(payload.result_id, result, "M4.6 video result");
exact(payload.authority, "replay-simulated", "M4.6 video authority");
if (!response.ok) throw new M4ThreatContractError(`M4.6 timeline: HTTP ${response.status}.`);
const payload = object(await response.json(), "M4.6 timeline");
exact(
payload.schema_version,
"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");
exact(
recorded.session_id,
"20260720T065719Z_viewer_live",
"M4.6 recorded session",
);
const frames = array(payload.frames, "M4.6 video frames").map((raw, expectedIndex) => {
const item = object(raw, "M4.6 video frame");
const frameIndex = integer(item.frame_index, "M4.6 video index");
if (frameIndex !== expectedIndex) throw new M4ThreatContractError("M4.6 video order.");
const counts = object(item.decision_counts, "M4.6 video decisions");
return {
frameIndex,
sessionSeconds: number(item.session_seconds, "M4.6 video time"),
sourceAvailable: typeof item.source_available === "boolean" ? item.source_available : false,
cameraProposals: array(item.camera_proposals, "M4.6 video proposals").map(parseCameraProposal),
decisionCounts: {
threat: integer(counts.threat, "M4.6 video threat"),
"not-threat": integer(counts["not-threat"], "M4.6 video clear"),
unknown: integer(counts.unknown, "M4.6 video unknown"),
},
};
});
exact(payload.frame_count, 4489, "M4.6 video frame count");
exact(recorded.source_id, "RAVNOVES00", "M4.6 recorded source id");
exact(
recorded.synchronization,
"host-arrival-best-effort",
"M4.6 recorded synchronization",
);
const frameCount = exact(payload.frame_count, 4489, "M4.6 timeline frame count");
const frameTimesNs = array(payload.frame_times_ns, "M4.6 timeline index").map(
(value) => integer(value, "M4.6 timeline time"),
);
if (
frameTimesNs.length !== frameCount
|| frameTimesNs.some((value, index) => index > 0 && value <= (frameTimesNs[index - 1] ?? value))
) {
throw new M4ThreatContractError("M4.6 timeline index: нарушен порядок.");
}
const rig = object(payload.rig, "M4.6 timeline rig");
const corridor = object(payload.corridor, "M4.6 timeline corridor");
return {
resultId: result,
recordedSourceSessionId: "20260720T065719Z_viewer_live",
imageWidth: exact(payload.image_width, 800, "M4.6 image width"),
imageHeight: exact(payload.image_height, 600, "M4.6 image height"),
timelineStartSeconds: number(payload.timeline_start_seconds, "M4.6 video start"),
timelineEndSeconds: number(payload.timeline_end_seconds, "M4.6 video end"),
frameCount,
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,
};
}
export function selectM4ThreatVideoFrame(
frames: readonly M4ThreatVideoFrame[],
function parseTimelineFrame(
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,
): M4ThreatVideoFrame | null {
if (!frames.length) return null;
): number | null {
if (!frameTimesNs.length || !Number.isFinite(seconds)) return null;
const targetNs = seconds * 1_000_000_000;
let low = 0;
let high = frames.length - 1;
let high = frameTimesNs.length - 1;
while (low < high) {
const middle = Math.floor((low + high) / 2);
const current = frames[middle];
if (!current || current.sessionSeconds < seconds) low = middle + 1;
const current = frameTimesNs[middle];
if (current === undefined || current < targetNs) low = middle + 1;
else high = middle;
}
const current = frames[low] ?? frames[frames.length - 1] ?? null;
const previous = frames[Math.max(0, low - 1)] ?? null;
if (!current || !previous) return current;
return Math.abs(previous.sessionSeconds - seconds) <= Math.abs(current.sessionSeconds - seconds)
? previous
: current;
const current = frameTimesNs[low];
const previousIndex = Math.max(0, low - 1);
const previous = frameTimesNs[previousIndex];
if (current === undefined) return frameTimesNs.length - 1;
if (previous === undefined) return low;
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;
}
.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 {
width: 100%;
height: 100%;
@@ -460,6 +460,14 @@
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"] {
position: fixed;
z-index: var(--nodedc-layer-overlay);
@@ -49,7 +49,7 @@
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__legend {
background: var(--nodedc-floating-surface);
@@ -399,6 +399,11 @@ i[data-availability="error"] {
gap: 0.6rem;
}
.observation-timeline__playback > .nodedc-select-anchor {
width: 7.5rem;
flex: none;
}
.observation-timeline__accumulation {
display: grid;
min-width: 0;
@@ -47,7 +47,7 @@ export function M4ReplayThreatResultView({
},
{
label: "Визуал",
value: "4489-frame VIDEO · 32 exact CAMERA/3D/PLAN samples",
value: "4489-frame VIDEO/CAMERA/3D/PLAN · единый recorded clock",
},
]}
brief={{
@@ -1,7 +1,7 @@
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 {
LaboratoryMetricEvidenceScene,
type LaboratoryMetricSceneMode,
@@ -12,19 +12,15 @@ import {
RecordedEvidenceVideoScene,
type RecordedEvidenceBox,
} from "../../components/laboratory/RecordedEvidenceVideoScene";
import {
fetchM4ThreatVideoOverlay,
fetchM4ThreatVisual,
fetchM4ThreatVisualIndex,
selectM4ThreatVideoFrame,
type M4ThreatCameraProposal,
type M4ThreatVideoOverlay,
type M4ThreatVisualFrame,
type M4ThreatVisualIndexItem,
} from "../../core/laboratory/m4ReplayThreat";
import { useRecordedEvidencePlayback } from "../../components/laboratory/useRecordedEvidencePlayback";
import type { M4ThreatCameraProposal } from "../../core/laboratory/m4ReplayThreat";
import { recordedObservationSources } from "../../core/observation/recordedObservationSources";
import { replayObservationSession } from "../../core/observation/sessionArchive";
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
import {
useM4ThreatTimelineFrame,
useM4ThreatTimelineMetadata,
} from "./useM4ThreatTimeline";
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;
}
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 }) {
const [mode, setMode] = useState<M4ThreatViewMode>("video");
const [expanded, setExpanded] = useState(false);
const [index, setIndex] = useState<readonly M4ThreatVisualIndexItem[]>([]);
const [ordinal, setOrdinal] = useState(1);
const [frame, setFrame] = useState<M4ThreatVisualFrame | null>(null);
const [sampleLoading, setSampleLoading] = useState(true);
const [sampleError, setSampleError] = useState<string | null>(null);
const [videoOverlay, setVideoOverlay] = useState<M4ThreatVideoOverlay | null>(null);
const metadata = useM4ThreatTimelineMetadata(resultId);
const playbackRange = useMemo(() => metadata.timeline ? ({
startSeconds: metadata.timeline.timelineStartSeconds,
endSeconds: metadata.timeline.timelineEndSeconds,
}) : null, [metadata.timeline]);
const playbackController = useRecordedEvidencePlayback(playbackRange);
const timelineFrame = useM4ThreatTimelineFrame({
resultId,
timeline: metadata.timeline,
currentSeconds: playbackController.playback.currentSeconds,
});
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
const [videoLoading, setVideoLoading] = useState(false);
const [videoError, setVideoError] = useState<string | null>(null);
const [videoPlayback, setVideoPlayback] = useState<RecordedObservationPlayback>({
currentSeconds: 0,
playing: false,
});
useEffect(() => {
const controller = new AbortController();
setSampleLoading(true);
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();
setVideoSource(null);
setVideoError(null);
}, [resultId]);
useEffect(() => {
const controller = new AbortController();
setSampleLoading(true);
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 timeline = metadata.timeline;
if (mode !== "video" || !timeline || videoSource) return;
const controller = new AbortController();
setVideoLoading(true);
setVideoError(null);
void (async () => {
const overlay = await fetchM4ThreatVideoOverlay(resultId, {
signal: controller.signal,
});
const replay = await replayObservationSession(overlay.recordedSourceSessionId, {
signal: controller.signal,
});
if (replay.kind !== "ready") {
throw new Error("RIGHT-видео RAVNOVES00 ещё готовится к воспроизведению.");
}
const source = recordedObservationSources(replay.launch).find(
(candidate) =>
candidate.modality === "video" &&
candidate.semanticChannelId === "camera.video.recorded",
);
const delivery = source?.delivery?.kind === "recorded-fmp4-manifest"
? source.delivery
: null;
if (
!source ||
!delivery ||
delivery.timelineStartSeconds !== overlay.timelineStartSeconds ||
delivery.timelineEndSeconds < overlay.timelineEndSeconds
) {
throw new Error("RIGHT-видео не совпало с временным контрактом M4.6.");
}
if (controller.signal.aborted) return;
setVideoOverlay(overlay);
setVideoSource(source);
setVideoPlayback({
currentSeconds: overlay.timelineStartSeconds,
playing: false,
});
})()
void replayObservationSession(timeline.recordedSourceSessionId, {
signal: controller.signal,
})
.then((replay) => {
if (replay.kind !== "ready") {
throw new Error("RIGHT-видео RAVNOVES00 ещё готовится к воспроизведению.");
}
const source = recordedObservationSources(replay.launch).find(
(candidate) => candidate.modality === "video"
&& candidate.semanticChannelId === "camera.video.recorded",
);
const delivery = source?.delivery?.kind === "recorded-fmp4-manifest"
? source.delivery
: null;
if (
!source
|| !delivery
|| delivery.timelineStartSeconds !== timeline.timelineStartSeconds
|| delivery.timelineEndSeconds < timeline.timelineEndSeconds
) {
throw new Error("RIGHT-видео не совпало с recorded-realtime timeline M4.6.");
}
if (!controller.signal.aborted) setVideoSource(source);
})
.catch((caught: unknown) => {
if (!controller.signal.aborted) {
setVideoError(message(caught, "Видео-доказательство M4.6 недоступно."));
@@ -156,22 +122,17 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
if (!controller.signal.aborted) setVideoLoading(false);
});
return () => controller.abort();
}, [mode, resultId, videoOverlay, videoSource]);
}, [metadata.timeline, mode, videoSource]);
const activeVideoFrame = useMemo(
() => videoOverlay
? selectM4ThreatVideoFrame(videoOverlay.frames, videoPlayback.currentSeconds)
: null,
[videoOverlay, videoPlayback.currentSeconds],
);
const activeProposals = mode === "camera"
? frame?.cameraProposals ?? []
: activeVideoFrame?.cameraProposals ?? [];
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 frame = timelineFrame.activeFrame;
const activeBoxes = useMemo(() => boxes(frame?.cameraProposals ?? []), [frame]);
const sceneObstacles = useMemo(() => frame?.metricObstacles.map((obstacle) => ({
id: obstacle.componentId,
decision: obstacle.assessment.decision,
state: obstacle.state,
centroidBodyXyzM: obstacle.centroidBodyXyzM,
cellCentersBodyXyzM: obstacle.cellCentersBodyXyzM,
})) ?? [], [frame]);
const currentIncrementObstacles = frame?.metricObstacles.filter(
(item) => item.state === "current",
) ?? [];
@@ -183,208 +144,151 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
.filter((value): value is number => value !== null)
.sort((left, right) => left - right)[0] ?? null;
const seekVideo = (seconds: number) => {
if (!videoOverlay) return;
setVideoPlayback({
currentSeconds: Math.min(
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 seek = (seconds: number) => playbackController.seek(seconds);
const handleModeChange = (next: M4ThreatViewMode) => {
if (next === "camera") playbackController.setPlaying(false);
setMode(next);
};
const actions = mode === "video" ? (
const actions = (
<div className="l3-visual-audit__actions">
<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} />
</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} />
</IconButton>
</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>
);
const overlay = mode === "video" && videoOverlay ? (
<div className="l3-visual-audit__overlay l3-visual-audit__overlay--video">
const overlay = metadata.timeline && frame ? (
<div className="l3-visual-audit__overlay m4-replay-threat-visual__overlay">
<div>
<span>RAVNOVES00 · recorded RIGHT</span>
<strong>
+{(videoPlayback.currentSeconds - videoOverlay.timelineStartSeconds).toFixed(1)} с
{activeVideoFrame ? ` · frame ${activeVideoFrame.frameIndex}` : ""}
</strong>
<small>{videoPlayback.playing ? "воспроизведение" : "пауза / seek"}</small>
<span>RAVNOVES00 · recorded realtime</span>
<strong>frame {frame.sequence + 1}/{metadata.timeline.frameCount}</strong>
<small>
+{(frame.sessionSeconds - metadata.timeline.timelineStartSeconds).toFixed(3)} с
· {playbackController.playback.playing ? "воспроизведение" : "пауза / seek"}
</small>
</div>
<div>
<span>Camera 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>
<span>Spatial evidence</span>
<strong>
{currentIncrementObstacles.length} current · {rollingMapObstacles.length} rolling
</strong>
<small>
CURRENT INCREMENT {frame.pointCloudSampleCount}/{frame.pointCloudSourceCount} points
· ROLLING MAP {frame.rollingMapComponentCount} components
{frame.spatialAvailable
? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} LiDAR points`
: "body frame / current increment unavailable"}
</small>
</div>
<div>
<span>Virtual corridor</span>
<strong>{threatObstacles.length} threat · nearest {nearest === null ? "—" : `${nearest.toFixed(2)} м`}</strong>
<small>{frame.corridor.forwardLengthM} м · body {frame.rig.lengthM}×{frame.rig.widthM} м · REPLAY-SIMULATED</small>
<strong>
{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>
) : undefined;
let content;
if (mode === "video") {
content = videoLoading ? (
if (metadata.error) {
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">
<span className="busy-indicator" aria-hidden="true" />
<span>Связываем 4489 решений M4.6 с RIGHT-видео</span>
</div>
) : videoError || !videoOverlay || !videoSource ? (
<div className="l3-visual-audit__state" role="status">
<Icon name="alert" size={18} />
<span>{videoError ?? "Видео-доказательство M4.6 недоступно."}</span>
<span>Открываем синхронное RIGHT-видео RAVNOVES00</span>
</div>
) : videoError || !metadata.timeline || !videoSource ? (
<SpatialState message={videoError ?? "Видео-доказательство M4.6 недоступно."} />
) : (
<RecordedEvidenceVideoScene
source={videoSource}
playback={videoPlayback}
imageWidth={videoOverlay.imageWidth}
imageHeight={videoOverlay.imageHeight}
playback={playbackController.playback}
imageWidth={metadata.timeline.imageWidth}
imageHeight={metadata.timeline.imageHeight}
boxes={activeBoxes}
ariaLabel={`M4.6 full video frame ${activeVideoFrame?.frameIndex ?? 0}: ${activeBoxes.length} proposals`}
onPlaybackChange={setVideoPlayback}
ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
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") {
content = sampleLoading ? (
<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>
) : (
content = (
<RecordedEvidenceImageScene
src={frame.cameraUrl}
imageWidth={800}
imageHeight={600}
imageWidth={metadata.timeline.imageWidth}
imageHeight={metadata.timeline.imageHeight}
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 {
content = sampleLoading ? (
<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>
) : (
content = (
<LaboratoryMetricEvidenceScene
pointCloudBodyXyzM={frame.pointCloudBodyXyzM}
obstacles={frame.metricObstacles.map((obstacle) => ({
id: obstacle.componentId,
decision: obstacle.assessment.decision,
state: obstacle.state,
centroidBodyXyzM: obstacle.centroidBodyXyzM,
cellCentersBodyXyzM: obstacle.cellCentersBodyXyzM,
}))}
rig={frame.rig}
corridor={frame.corridor}
obstacles={sceneObstacles}
rig={metadata.timeline.rig}
corridor={metadata.timeline.corridor}
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 (
<div className="l3-visual-audit m4-replay-threat-visual">
<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"
mode={mode}
modes={[
@@ -394,10 +298,11 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
{ value: "plan", label: "PLAN" },
]}
expanded={expanded}
onModeChange={setMode}
onModeChange={handleModeChange}
onExpandedChange={setExpanded}
actions={actions}
overlay={overlay}
transport={transport}
>
{content}
</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,
};
}
+101 -24
View File
@@ -7,8 +7,11 @@ import { createServer } from "vite";
let server;
let fetchM4ThreatReplayResult;
let fetchM4ThreatVisual;
let fetchM4ThreatVideoOverlay;
let selectM4ThreatVideoFrame;
let fetchM4ThreatTimeline;
let fetchM4ThreatTimelineChunk;
let selectM4ThreatTimelineFrame;
let selectM4ThreatTimelineSequence;
let advanceRecordedEvidencePlayback;
const resultId = `m4-threat-replay-${"a".repeat(64)}`;
@@ -21,9 +24,14 @@ before(async () => {
({
fetchM4ThreatReplayResult,
fetchM4ThreatVisual,
fetchM4ThreatVideoOverlay,
selectM4ThreatVideoFrame,
fetchM4ThreatTimeline,
fetchM4ThreatTimelineChunk,
selectM4ThreatTimelineFrame,
selectM4ThreatTimelineSequence,
} = await server.ssrLoadModule("/src/core/laboratory/m4ReplayThreat.ts"));
({ advanceRecordedEvidencePlayback } = await server.ssrLoadModule(
"/src/components/laboratory/useRecordedEvidencePlayback.ts",
));
});
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 () => {
const result = await fetchM4ThreatReplayResult({
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");
});
test("M4.6 full video preserves camera-only unknown and nearest-frame selection", async () => {
const overlay = await fetchM4ThreatVideoOverlay(resultId, {
test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunks", async () => {
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({
schema_version: "missioncore.m4-threat-video-overlay/v1",
schema_version: "missioncore.recorded-spatial-evidence-timeline/v1",
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_height: 600,
timeline_start_seconds: 35.421857292,
timeline_end_seconds: 484.044857292,
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: [
{
frame_index: 0,
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,
timelineFrame(0, 35.421857292),
timelineFrame(1, 35.521857292, {
camera_proposals: [proposal()],
decision_counts: { threat: 0, "not-threat": 0, unknown: 1 },
},
}),
],
authority: "replay-simulated",
}), { status: 200 }),
});
assert.equal(overlay.frames[1].cameraProposals[0].rangeM, null);
assert.equal(selectM4ThreatVideoFrame(overlay.frames, 35.50).frameIndex, 1);
assert.equal(chunk.frames[1].cameraProposals[0].rangeM, null);
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 () => {
@@ -244,6 +319,8 @@ test("M4.6 viewer reuses shared camera, video and metric evidence renderers", as
assert.match(visual, /<RecordedEvidenceVideoScene/);
assert.match(visual, /<RecordedEvidenceImageScene/);
assert.match(visual, /<LaboratoryMetricEvidenceScene/);
assert.match(visual, /<ObservationTimeline/);
assert.match(visual, /useM4ThreatTimelineFrame/);
assert.match(visual, /label: "VIDEO"/);
assert.match(visual, /label: "CAMERA"/);
assert.match(visual, /label: "3D"/);