fix(lab): stabilize recorded spatial playback

This commit is contained in:
DCCONSTRUCTIONS
2026-08-06 08:51:10 +03:00
parent 9df9f58ab8
commit e38f2fb1da
11 changed files with 316 additions and 99 deletions
@@ -96,6 +96,7 @@ export function LaboratoryMetricEvidenceScene({
obstacles, obstacles,
rig, rig,
corridor, corridor,
occupiedVoxelSizeM,
mode, mode,
label, label,
}: { }: {
@@ -103,6 +104,7 @@ export function LaboratoryMetricEvidenceScene({
obstacles: readonly LaboratoryMetricObstacleVisual[]; obstacles: readonly LaboratoryMetricObstacleVisual[];
rig: LaboratoryMetricRigVisual; rig: LaboratoryMetricRigVisual;
corridor: LaboratoryMetricCorridorVisual; corridor: LaboratoryMetricCorridorVisual;
occupiedVoxelSizeM: number;
mode: LaboratoryMetricSceneMode; mode: LaboratoryMetricSceneMode;
label: string; label: string;
}) { }) {
@@ -205,10 +207,10 @@ export function LaboratoryMetricEvidenceScene({
contextGeometry, contextGeometry,
new THREE.PointsMaterial({ new THREE.PointsMaterial({
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]), color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
size: 1.7, size: 1.55,
sizeAttenuation: false, sizeAttenuation: false,
transparent: true, transparent: true,
opacity: 0.34, opacity: 0.58,
depthWrite: false, depthWrite: false,
}), }),
)); ));
@@ -224,36 +226,62 @@ export function LaboratoryMetricEvidenceScene({
continue; continue;
} }
const color = decisionColor(host, obstacle.decision); const color = decisionColor(host, obstacle.decision);
const cellsGeometry = new THREE.BufferGeometry(); if (obstacle.state === "retained") {
cellsGeometry.setAttribute( const geometry = new THREE.BoxGeometry(
"position", occupiedVoxelSizeM * 0.82,
new THREE.BufferAttribute(positions(obstacle.cellCentersBodyXyzM), 3), occupiedVoxelSizeM * 0.82,
); occupiedVoxelSizeM * 0.82,
content.add(new THREE.Points( );
cellsGeometry, const material = new THREE.MeshBasicMaterial({
new THREE.PointsMaterial({
color, color,
size: obstacle.state === "current" ? 4.8 : 5.2, wireframe: true,
sizeAttenuation: false,
transparent: true, transparent: true,
opacity: obstacle.state === "current" ? 0.94 : 0.78, opacity: 0.34,
depthWrite: false, depthWrite: false,
}), });
)); const voxels = new THREE.InstancedMesh(
const centroid = new THREE.Mesh( geometry,
new THREE.SphereGeometry(0.1, 16, 12), material,
new THREE.MeshBasicMaterial({ obstacle.cellCentersBodyXyzM.length,
color, );
wireframe: obstacle.state === "retained", const matrix = new THREE.Matrix4();
}), obstacle.cellCentersBodyXyzM.forEach((point, index) => {
); matrix.makeTranslation(...scenePoint(point));
centroid.position.fromArray(scenePoint(obstacle.centroidBodyXyzM)); voxels.setMatrixAt(index, matrix);
centroid.userData.evidenceId = obstacle.id; });
content.add(centroid); voxels.instanceMatrix.needsUpdate = true;
voxels.userData.evidenceId = obstacle.id;
content.add(voxels);
} else {
const cellsGeometry = new THREE.BufferGeometry();
cellsGeometry.setAttribute(
"position",
new THREE.BufferAttribute(positions(obstacle.cellCentersBodyXyzM), 3),
);
content.add(new THREE.Points(
cellsGeometry,
new THREE.PointsMaterial({
color,
size: 4.4,
sizeAttenuation: false,
transparent: true,
opacity: 0.96,
depthWrite: false,
}),
));
const centroid = new THREE.Mesh(
new THREE.SphereGeometry(0.065, 12, 8),
new THREE.MeshBasicMaterial({ color }),
);
centroid.position.fromArray(scenePoint(obstacle.centroidBodyXyzM));
centroid.userData.evidenceId = obstacle.id;
content.add(centroid);
}
} }
}, [ }, [
obstacles, obstacles,
occupiedVoxelSizeM,
pointCloudBodyXyzM, pointCloudBodyXyzM,
showCurrentIncrement, showCurrentIncrement,
showRollingMap, showRollingMap,
@@ -156,6 +156,10 @@ export interface M4ThreatTimeline {
nominalRateHz: number; nominalRateHz: number;
maxChunkFrames: number; maxChunkFrames: number;
pointSampleLimit: number; pointSampleLimit: number;
maximumSourcePointsPerFrame: number;
pointDelivery: "exact-current-increment";
sourceRepresentationId: "registered-map-increment-v1";
occupiedVoxelSizeM: number;
rig: M4ThreatVisualFrame["rig"]; rig: M4ThreatVisualFrame["rig"];
corridor: M4ThreatVisualFrame["corridor"]; corridor: M4ThreatVisualFrame["corridor"];
} }
@@ -513,6 +517,11 @@ export async function fetchM4ThreatTimeline(
"M4.6 recorded session", "M4.6 recorded session",
); );
exact(recorded.source_id, "RAVNOVES00", "M4.6 recorded source id"); exact(recorded.source_id, "RAVNOVES00", "M4.6 recorded source id");
exact(
recorded.representation_id,
"registered-map-increment-v1",
"M4.6 recorded representation",
);
exact( exact(
recorded.synchronization, recorded.synchronization,
"host-arrival-best-effort", "host-arrival-best-effort",
@@ -546,6 +555,20 @@ export async function fetchM4ThreatTimeline(
nominalRateHz: number(payload.nominal_rate_hz, "M4.6 timeline rate"), nominalRateHz: number(payload.nominal_rate_hz, "M4.6 timeline rate"),
maxChunkFrames: integer(payload.max_chunk_frames, "M4.6 max chunk"), maxChunkFrames: integer(payload.max_chunk_frames, "M4.6 max chunk"),
pointSampleLimit: integer(payload.point_sample_limit, "M4.6 point limit"), pointSampleLimit: integer(payload.point_sample_limit, "M4.6 point limit"),
maximumSourcePointsPerFrame: integer(
payload.maximum_source_points_per_frame,
"M4.6 maximum source points",
),
pointDelivery: exact(
payload.point_delivery,
"exact-current-increment",
"M4.6 point delivery",
),
sourceRepresentationId: "registered-map-increment-v1",
occupiedVoxelSizeM: number(
corridor.occupied_voxel_size_m,
"M4.6 occupied voxel size",
),
rig: { rig: {
lengthM: number(rig.length_m, "M4.6 rig length"), lengthM: number(rig.length_m, "M4.6 rig length"),
widthM: number(rig.width_m, "M4.6 rig width"), widthM: number(rig.width_m, "M4.6 rig width"),
@@ -12,6 +12,48 @@
background: var(--nodedc-canvas); background: var(--nodedc-canvas);
} }
.m4-replay-threat-visual__deck,
.m4-replay-threat-visual__layer {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.m4-replay-threat-visual__layer {
visibility: hidden;
opacity: 0;
pointer-events: none;
}
.m4-replay-threat-visual__layer[data-active="true"] {
z-index: 1;
visibility: visible;
opacity: 1;
pointer-events: auto;
}
.m4-replay-threat-visual__buffering {
position: absolute;
z-index: 5;
top: 4.9rem;
left: 50%;
display: flex;
align-items: center;
gap: 0.4rem;
border: 1px solid var(--nodedc-glass-outline);
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-floating-surface);
padding: 0.42rem 0.58rem;
color: var(--nodedc-text-secondary);
font-size: 0.54rem;
backdrop-filter: blur(var(--nodedc-blur-control));
pointer-events: none;
transform: translateX(-50%);
}
.laboratory-metric-evidence-scene__viewport { .laboratory-metric-evidence-scene__viewport {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Icon, IconButton } from "@nodedc/ui-react"; import { Icon, IconButton } from "@nodedc/ui-react";
import { ObservationTimeline } from "../../components/ObservationTimeline"; import { ObservationTimeline } from "../../components/ObservationTimeline";
@@ -13,7 +13,10 @@ import {
type RecordedEvidenceBox, type RecordedEvidenceBox,
} from "../../components/laboratory/RecordedEvidenceVideoScene"; } from "../../components/laboratory/RecordedEvidenceVideoScene";
import { useRecordedEvidencePlayback } from "../../components/laboratory/useRecordedEvidencePlayback"; import { useRecordedEvidencePlayback } from "../../components/laboratory/useRecordedEvidencePlayback";
import type { M4ThreatCameraProposal } from "../../core/laboratory/m4ReplayThreat"; import type {
M4ThreatCameraProposal,
M4ThreatTimelineFrame,
} 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";
@@ -62,6 +65,7 @@ function SpatialState({ message: text }: { message: string }) {
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 [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode>("3d");
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const metadata = useM4ThreatTimelineMetadata(resultId); const metadata = useM4ThreatTimelineMetadata(resultId);
const playbackRange = useMemo(() => metadata.timeline ? ({ const playbackRange = useMemo(() => metadata.timeline ? ({
@@ -85,7 +89,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
useEffect(() => { useEffect(() => {
const timeline = metadata.timeline; const timeline = metadata.timeline;
if (mode !== "video" || !timeline || videoSource) return; if (!timeline || videoSource) return;
const controller = new AbortController(); const controller = new AbortController();
setVideoLoading(true); setVideoLoading(true);
setVideoError(null); setVideoError(null);
@@ -122,9 +126,19 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
if (!controller.signal.aborted) setVideoLoading(false); if (!controller.signal.aborted) setVideoLoading(false);
}); });
return () => controller.abort(); return () => controller.abort();
}, [metadata.timeline, mode, videoSource]); }, [metadata.timeline, videoSource]);
const frame = timelineFrame.activeFrame; const lastFrameRef = useRef<M4ThreatTimelineFrame | null>(null);
useEffect(() => {
lastFrameRef.current = null;
}, [resultId]);
if (timelineFrame.activeFrame) lastFrameRef.current = timelineFrame.activeFrame;
const frame = timelineFrame.activeFrame ?? lastFrameRef.current;
const displayingBufferedFrame = Boolean(
frame
&& timelineFrame.activeSequence !== null
&& frame.sequence !== timelineFrame.activeSequence,
);
const activeBoxes = useMemo(() => boxes(frame?.cameraProposals ?? []), [frame]); const activeBoxes = useMemo(() => boxes(frame?.cameraProposals ?? []), [frame]);
const sceneObstacles = useMemo(() => frame?.metricObstacles.map((obstacle) => ({ const sceneObstacles = useMemo(() => frame?.metricObstacles.map((obstacle) => ({
id: obstacle.componentId, id: obstacle.componentId,
@@ -147,9 +161,16 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
const seek = (seconds: number) => playbackController.seek(seconds); const seek = (seconds: number) => playbackController.seek(seconds);
const handleModeChange = (next: M4ThreatViewMode) => { const handleModeChange = (next: M4ThreatViewMode) => {
if (next === "camera") playbackController.setPlaying(false); if (next === "camera") playbackController.setPlaying(false);
if (next === "3d" || next === "plan") setSpatialMode(next);
setMode(next); setMode(next);
}; };
useEffect(() => {
if (playbackController.playback.playing || !frame) return;
const image = new Image();
image.src = frame.cameraUrl;
}, [frame?.cameraUrl, playbackController.playback.playing]);
const actions = ( 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">
@@ -178,7 +199,9 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
<strong>frame {frame.sequence + 1}/{metadata.timeline.frameCount}</strong> <strong>frame {frame.sequence + 1}/{metadata.timeline.frameCount}</strong>
<small> <small>
+{(frame.sessionSeconds - metadata.timeline.timelineStartSeconds).toFixed(3)} с +{(frame.sessionSeconds - metadata.timeline.timelineStartSeconds).toFixed(3)} с
· {playbackController.playback.playing ? "воспроизведение" : "пауза / seek"} · {displayingBufferedFrame
? "держим последний кадр, следующий в буфере"
: playbackController.playback.playing ? "воспроизведение" : "пауза / seek"}
</small> </small>
</div> </div>
<div> <div>
@@ -188,7 +211,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
</strong> </strong>
<small> <small>
{frame.spatialAvailable {frame.spatialAvailable
? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} LiDAR points` ? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} exact lio_pcl increment`
: "body frame / current increment unavailable"} : "body frame / current increment unavailable"}
</small> </small>
</div> </div>
@@ -204,65 +227,97 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
</div> </div>
) : undefined; ) : undefined;
const timeline = metadata.timeline;
let content; let content;
if (metadata.error) { if (metadata.error) {
content = <SpatialState message={metadata.error} />; content = <SpatialState message={metadata.error} />;
} else if (mode === "video") { } else if (!timeline) {
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>Открываем синхронное RIGHT-видео RAVNOVES00</span>
</div>
) : videoError || !metadata.timeline || !videoSource ? (
<SpatialState message={videoError ?? "Видео-доказательство M4.6 недоступно."} />
) : (
<RecordedEvidenceVideoScene
source={videoSource}
playback={playbackController.playback}
imageWidth={metadata.timeline.imageWidth}
imageHeight={metadata.timeline.imageHeight}
boxes={activeBoxes}
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 = ( content = (
<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>Буферизуем bounded spatial chunk M4.6</span> <span>Открываем recorded-realtime timeline M4.6</span>
</div> </div>
); );
} else if (mode === "camera") {
content = (
<RecordedEvidenceImageScene
src={frame.cameraUrl}
imageWidth={metadata.timeline.imageWidth}
imageHeight={metadata.timeline.imageHeight}
boxes={activeBoxes}
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 = ( content = (
<LaboratoryMetricEvidenceScene <div className="m4-replay-threat-visual__deck">
pointCloudBodyXyzM={frame.pointCloudBodyXyzM} <div
obstacles={sceneObstacles} className="m4-replay-threat-visual__layer"
rig={metadata.timeline.rig} data-active={mode === "video" ? "true" : undefined}
corridor={metadata.timeline.corridor} aria-hidden={mode !== "video"}
mode={mode} >
label="M4.6 recorded-realtime current increment and rolling occupancy" {videoSource ? (
/> <RecordedEvidenceVideoScene
source={videoSource}
playback={playbackController.playback}
imageWidth={timeline.imageWidth}
imageHeight={timeline.imageHeight}
boxes={activeBoxes}
ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
interactive={false}
/>
) : videoError ? (
<SpatialState message={videoError} />
) : (
<div className="l3-visual-audit__state" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>{videoLoading ? "Подготавливаем локальный видеобуфер" : "Открываем RIGHT-видео RAVNOVES00"}</span>
</div>
)}
</div>
<div
className="m4-replay-threat-visual__layer"
data-active={mode === "camera" ? "true" : undefined}
aria-hidden={mode !== "camera"}
>
{mode === "camera" && frame ? (
<RecordedEvidenceImageScene
src={frame.cameraUrl}
imageWidth={timeline.imageWidth}
imageHeight={timeline.imageHeight}
boxes={activeBoxes}
ariaLabel={`M4.6 exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
/>
) : null}
</div>
<div
className="m4-replay-threat-visual__layer"
data-active={mode === "3d" || mode === "plan" ? "true" : undefined}
aria-hidden={mode !== "3d" && mode !== "plan"}
>
{frame ? (
<LaboratoryMetricEvidenceScene
pointCloudBodyXyzM={frame.pointCloudBodyXyzM}
obstacles={sceneObstacles}
rig={timeline.rig}
corridor={timeline.corridor}
occupiedVoxelSizeM={timeline.occupiedVoxelSizeM}
mode={spatialMode}
label="M4.6 recorded-realtime current increment and rolling occupancy"
/>
) : null}
</div>
{timelineFrame.loading || displayingBufferedFrame ? (
<div className="m4-replay-threat-visual__buffering" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>Догружаем следующий spatial-буфер без сброса сцены</span>
</div>
) : null}
{timelineFrame.error ? (
<div className="m4-replay-threat-visual__buffering" role="alert">
<Icon name="alert" size={16} />
<span>{timelineFrame.error}</span>
</div>
) : null}
{frame && !frame.spatialAvailable && (mode === "3d" || mode === "plan") ? (
<div className="m4-replay-threat-visual__buffering" role="status">
На этом кадре нет квалифицированного body frame; сцена сохранена.
</div>
) : null}
</div>
); );
} }
const timeline = metadata.timeline;
const transport = timeline ? ( const transport = timeline ? (
<ObservationTimeline <ObservationTimeline
className="m4-replay-threat-visual__timeline" className="m4-replay-threat-visual__timeline"
@@ -9,13 +9,26 @@ import {
type M4ThreatTimelineFrame, type M4ThreatTimelineFrame,
} from "../../core/laboratory/m4ReplayThreat"; } from "../../core/laboratory/m4ReplayThreat";
const REQUESTED_CHUNK_FRAMES = 12; const REQUESTED_CHUNK_FRAMES = 24;
const RETAINED_CHUNK_COUNT = 4; const RETAINED_CHUNK_COUNT = 8;
const PREFETCH_CHUNKS_AHEAD = 2;
function errorMessage(error: unknown, fallback: string): string { function errorMessage(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;
} }
export function m4ThreatChunkWindowStarts(
activeChunkStart: number,
chunkSize: number,
frameCount: number,
): readonly number[] {
if (chunkSize < 1 || frameCount < 1) return [];
return Array.from(
{ length: PREFETCH_CHUNKS_AHEAD + 2 },
(_, index) => activeChunkStart + (index - 1) * chunkSize,
).filter((start) => start >= 0 && start < frameCount);
}
export function useM4ThreatTimelineMetadata(resultId: string) { export function useM4ThreatTimelineMetadata(resultId: string) {
const [timeline, setTimeline] = useState<M4ThreatTimeline | null>(null); const [timeline, setTimeline] = useState<M4ThreatTimeline | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -52,14 +65,22 @@ export function useM4ThreatTimelineFrame({
() => new Map(), () => new Map(),
); );
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const inFlight = useRef(new Set<number>()); const inFlight = useRef(new Map<number, AbortController>());
const chunksRef = useRef(chunks); const chunksRef = useRef(chunks);
const activeChunkStartRef = useRef<number | null>(null);
chunksRef.current = chunks; chunksRef.current = chunks;
useEffect(() => { useEffect(() => {
setChunks(new Map()); for (const controller of inFlight.current.values()) controller.abort();
setError(null);
inFlight.current.clear(); inFlight.current.clear();
const empty = new Map<number, M4ThreatTimelineChunk>();
chunksRef.current = empty;
setChunks(empty);
setError(null);
return () => {
for (const controller of inFlight.current.values()) controller.abort();
inFlight.current.clear();
};
}, [resultId, timeline]); }, [resultId, timeline]);
const activeSequence = useMemo( const activeSequence = useMemo(
@@ -75,18 +96,19 @@ export function useM4ThreatTimelineFrame({
const activeChunkStart = activeSequence === null const activeChunkStart = activeSequence === null
? null ? null
: Math.floor(activeSequence / chunkSize) * chunkSize; : Math.floor(activeSequence / chunkSize) * chunkSize;
activeChunkStartRef.current = activeChunkStart;
useEffect(() => { useEffect(() => {
if (!timeline || activeChunkStart === null) return; if (!timeline || activeChunkStart === null) return;
const starts = [activeChunkStart, activeChunkStart + chunkSize].filter( const starts = m4ThreatChunkWindowStarts(
(start) => start < timeline.frameCount, activeChunkStart,
chunkSize,
timeline.frameCount,
); );
const controllers: AbortController[] = [];
for (const start of starts) { for (const start of starts) {
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue; if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
const controller = new AbortController(); const controller = new AbortController();
controllers.push(controller); inFlight.current.set(start, controller);
inFlight.current.add(start);
void fetchM4ThreatTimelineChunk(resultId, start, chunkSize, { void fetchM4ThreatTimelineChunk(resultId, start, chunkSize, {
signal: controller.signal, signal: controller.signal,
}) })
@@ -95,23 +117,27 @@ export function useM4ThreatTimelineFrame({
setChunks((current) => { setChunks((current) => {
const next = new Map(current); const next = new Map(current);
next.set(start, chunk); next.set(start, chunk);
const center = activeChunkStartRef.current ?? start;
const retained = [...next.keys()] const retained = [...next.keys()]
.sort((left, right) => ( .sort((left, right) => (
Math.abs(left - activeChunkStart) - Math.abs(right - activeChunkStart) Math.abs(left - center) - Math.abs(right - center)
)) ))
.slice(0, RETAINED_CHUNK_COUNT); .slice(0, RETAINED_CHUNK_COUNT);
return new Map(retained.map((key) => [key, next.get(key)!])); const bounded = new Map(retained.map((key) => [key, next.get(key)!]));
chunksRef.current = bounded;
return bounded;
}); });
if (start === activeChunkStart) setError(null); if (start === activeChunkStartRef.current) setError(null);
}) })
.catch((caught: unknown) => { .catch((caught: unknown) => {
if (!controller.signal.aborted && start === activeChunkStart) { if (!controller.signal.aborted && start === activeChunkStartRef.current) {
setError(errorMessage(caught, "3D chunk M4.6 недоступен.")); setError(errorMessage(caught, "3D chunk M4.6 недоступен."));
} }
}) })
.finally(() => inFlight.current.delete(start)); .finally(() => {
if (inFlight.current.get(start) === controller) inFlight.current.delete(start);
});
} }
return () => controllers.forEach((controller) => controller.abort());
}, [activeChunkStart, chunkSize, resultId, timeline]); }, [activeChunkStart, chunkSize, resultId, timeline]);
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => { const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
@@ -12,6 +12,7 @@ let fetchM4ThreatTimelineChunk;
let selectM4ThreatTimelineFrame; let selectM4ThreatTimelineFrame;
let selectM4ThreatTimelineSequence; let selectM4ThreatTimelineSequence;
let advanceRecordedEvidencePlayback; let advanceRecordedEvidencePlayback;
let m4ThreatChunkWindowStarts;
const resultId = `m4-threat-replay-${"a".repeat(64)}`; const resultId = `m4-threat-replay-${"a".repeat(64)}`;
@@ -32,6 +33,9 @@ before(async () => {
({ advanceRecordedEvidencePlayback } = await server.ssrLoadModule( ({ advanceRecordedEvidencePlayback } = await server.ssrLoadModule(
"/src/components/laboratory/useRecordedEvidencePlayback.ts", "/src/components/laboratory/useRecordedEvidencePlayback.ts",
)); ));
({ m4ThreatChunkWindowStarts } = await server.ssrLoadModule(
"/src/workspaces/laboratory/useM4ThreatTimeline.ts",
));
}); });
after(async () => { after(async () => {
@@ -243,6 +247,7 @@ test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunk
recorded_source: { recorded_source: {
session_id: "20260720T065719Z_viewer_live", session_id: "20260720T065719Z_viewer_live",
source_id: "RAVNOVES00", source_id: "RAVNOVES00",
representation_id: "registered-map-increment-v1",
synchronization: "host-arrival-best-effort", synchronization: "host-arrival-best-effort",
}, },
image_width: 800, image_width: 800,
@@ -254,18 +259,24 @@ test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunk
nominal_frame_interval_seconds: 0.1, nominal_frame_interval_seconds: 0.1,
nominal_rate_hz: 10, nominal_rate_hz: 10,
max_chunk_frames: 24, max_chunk_frames: 24,
point_sample_limit: 2000, point_sample_limit: 4096,
maximum_source_points_per_frame: 3092,
point_delivery: "exact-current-increment",
rig: { length_m: 1, width_m: 0.6, nominal_sensor_height_m: 1.25 }, rig: { length_m: 1, width_m: 0.6, nominal_sensor_height_m: 1.25 },
corridor: { corridor: {
forward_length_m: 8, forward_length_m: 8,
rear_margin_m: 0.5, rear_margin_m: 0.5,
half_width_m: 0.5, half_width_m: 0.5,
occupied_voxel_size_m: 0.45,
prediction_horizon_seconds: 5, prediction_horizon_seconds: 5,
}, },
authority: "replay-simulated", authority: "replay-simulated",
}), { status: 200 }), }), { status: 200 }),
}); });
assert.equal(timeline.frameTimesNs.length, 4489); assert.equal(timeline.frameTimesNs.length, 4489);
assert.equal(timeline.pointDelivery, "exact-current-increment");
assert.equal(timeline.maximumSourcePointsPerFrame, 3092);
assert.equal(timeline.occupiedVoxelSizeM, 0.45);
assert.equal(selectM4ThreatTimelineSequence(timeline.frameTimesNs, 35.50), 1); assert.equal(selectM4ThreatTimelineSequence(timeline.frameTimesNs, 35.50), 1);
const chunk = await fetchM4ThreatTimelineChunk(resultId, 0, 2, { const chunk = await fetchM4ThreatTimelineChunk(resultId, 0, 2, {
@@ -289,6 +300,11 @@ test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunk
assert.equal(selectM4ThreatTimelineFrame(chunk.frames, 35.50).sequence, 1); assert.equal(selectM4ThreatTimelineFrame(chunk.frames, 35.50).sequence, 1);
}); });
test("M4.6 spatial buffering keeps previous, active and two future chunks", () => {
assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [24, 48, 72, 96]);
assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24, 48]);
});
test("recorded evidence clock advances by selected rate and stops at the sealed end", () => { test("recorded evidence clock advances by selected rate and stops at the sealed end", () => {
const range = { startSeconds: 10, endSeconds: 20 }; const range = { startSeconds: 10, endSeconds: 20 };
assert.deepEqual( assert.deepEqual(
@@ -319,6 +335,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, /m4-replay-threat-visual__deck/);
assert.match(visual, /lastFrameRef/);
assert.match(visual, /<ObservationTimeline/); assert.match(visual, /<ObservationTimeline/);
assert.match(visual, /useM4ThreatTimelineFrame/); assert.match(visual, /useM4ThreatTimelineFrame/);
assert.match(visual, /label: "VIDEO"/); assert.match(visual, /label: "VIDEO"/);
+1 -1
View File
@@ -25,7 +25,7 @@ WHEEL_NAME = "nodedc_mission_core-0.1.0-py3-none-any.whl"
RUNNER_NAME = RUNNER.name RUNNER_NAME = RUNNER.name
PATCH_ID = re.compile(r"^[A-Za-z0-9._-]{1,96}$") PATCH_ID = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
EXPECTED_BASELINE_SHA256 = "ea10359339e6cce31b5780a2710299771cab7cc0c1c2a2b56a1621f786b31fa8" EXPECTED_BASELINE_SHA256 = "ea10359339e6cce31b5780a2710299771cab7cc0c1c2a2b56a1621f786b31fa8"
EXPECTED_WHEEL_SHA256 = "215411719c6af4b042d0019f7f73b1885e2c2db47f87eb3617c653d7e751f19e" EXPECTED_WHEEL_SHA256 = "9d203362cebb383fd5394e60dbcaa4cea6d332de9c0aa411cbc23825a4e53868"
PAYLOAD_FILES = ( PAYLOAD_FILES = (
RUNNER_NAME, RUNNER_NAME,
WHEEL_NAME, WHEEL_NAME,
+11 -1
View File
@@ -276,6 +276,13 @@ class RecordedGeometryStore:
points.setflags(write=False) points.setflags(write=False)
return points return points
@property
def maximum_current_point_count(self) -> int:
"""Return the immutable source-pack upper bound for one recorded increment."""
counts = np.diff(np.asarray(self._source["cloud_offsets"], dtype=np.int64))
return int(counts.max(initial=0))
def pose_values_for_frame( def pose_values_for_frame(
self, self,
frame_id: str, frame_id: str,
@@ -946,7 +953,10 @@ def _camera_unavailable_observation(
) )
def _load_npz(path: Path, label: str) -> dict[str, npt.NDArray[np.generic]]: def _load_npz(
path: Path,
label: str,
) -> dict[str, npt.NDArray[np.generic]]:
try: try:
with np.load(path, allow_pickle=False) as archive: with np.load(path, allow_pickle=False) as archive:
return {name: np.asarray(archive[name]) for name in archive.files} return {name: np.asarray(archive[name]) for name in archive.files}
+10 -1
View File
@@ -14,6 +14,7 @@ from threading import RLock
from typing import Final from typing import Final
from .geometry import RecordedGeometryStore from .geometry import RecordedGeometryStore
from .recorded_source import RECORDED_REPRESENTATION_ID
from .spatial_evidence import ( from .spatial_evidence import (
project_metric_obstacles_to_body, project_metric_obstacles_to_body,
sample_points_in_body_frame, sample_points_in_body_frame,
@@ -32,7 +33,7 @@ from .threat_replay import (
RECORDED_SPATIAL_TIMELINE_SCHEMA: Final = "missioncore.recorded-spatial-evidence-timeline/v1" RECORDED_SPATIAL_TIMELINE_SCHEMA: Final = "missioncore.recorded-spatial-evidence-timeline/v1"
RECORDED_SPATIAL_CHUNK_SCHEMA: Final = "missioncore.recorded-spatial-evidence-chunk/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_FRAME_SCHEMA: Final = "missioncore.recorded-spatial-evidence-frame/v1"
RECORDED_SPATIAL_POINT_LIMIT: Final = 2_000 RECORDED_SPATIAL_POINT_LIMIT: Final = 4_096
RECORDED_SPATIAL_MAX_CHUNK_FRAMES: Final = 24 RECORDED_SPATIAL_MAX_CHUNK_FRAMES: Final = 24
_EXPECTED_FRAME_COUNT: Final = 4_489 _EXPECTED_FRAME_COUNT: Final = 4_489
_SOURCE_TIME = re.compile(rb'"source_time_ns":([0-9]+)') _SOURCE_TIME = re.compile(rb'"source_time_ns":([0-9]+)')
@@ -80,6 +81,10 @@ class RecordedThreatTimeline:
or self.store.profile.frame_count != _EXPECTED_FRAME_COUNT or self.store.profile.frame_count != _EXPECTED_FRAME_COUNT
): ):
raise RecordedThreatTimelineError("recorded timeline geometry identity changed") raise RecordedThreatTimelineError("recorded timeline geometry identity changed")
if self.store.maximum_current_point_count > RECORDED_SPATIAL_POINT_LIMIT:
raise RecordedThreatTimelineError(
"recorded timeline exact point delivery exceeds its declared bound"
)
self.body_frames = RecordedReplayBodyFrameResolver( self.body_frames = RecordedReplayBodyFrameResolver(
self.store, self.store,
profile=self.profile.body_frame, profile=self.profile.body_frame,
@@ -99,6 +104,7 @@ class RecordedThreatTimeline:
"recorded_source": { "recorded_source": {
"session_id": self.profile.session_id, "session_id": self.profile.session_id,
"source_id": self.profile.source_id, "source_id": self.profile.source_id,
"representation_id": RECORDED_REPRESENTATION_ID,
"synchronization": "host-arrival-best-effort", "synchronization": "host-arrival-best-effort",
}, },
"frame_count": len(times), "frame_count": len(times),
@@ -109,6 +115,8 @@ class RecordedThreatTimeline:
"nominal_rate_hz": 1 / nominal_interval, "nominal_rate_hz": 1 / nominal_interval,
"max_chunk_frames": RECORDED_SPATIAL_MAX_CHUNK_FRAMES, "max_chunk_frames": RECORDED_SPATIAL_MAX_CHUNK_FRAMES,
"point_sample_limit": RECORDED_SPATIAL_POINT_LIMIT, "point_sample_limit": RECORDED_SPATIAL_POINT_LIMIT,
"point_delivery": "exact-current-increment",
"maximum_source_points_per_frame": self.store.maximum_current_point_count,
"image_width": 800, "image_width": 800,
"image_height": 600, "image_height": 600,
"rig": { "rig": {
@@ -119,6 +127,7 @@ class RecordedThreatTimeline:
"corridor": { "corridor": {
"forward_length_m": self.profile.corridor.forward_length_m, "forward_length_m": self.profile.corridor.forward_length_m,
"rear_margin_m": self.profile.corridor.rear_margin_m, "rear_margin_m": self.profile.corridor.rear_margin_m,
"occupied_voxel_size_m": self.profile.corridor.occupied_voxel_size_m,
"half_width_m": ( "half_width_m": (
self.profile.rig.body_width_m / 2 + self.profile.corridor.lateral_clearance_m self.profile.rig.body_width_m / 2 + self.profile.corridor.lateral_clearance_m
), ),
+2
View File
@@ -13,6 +13,7 @@ from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import ValidationError from pydantic import ValidationError
from starlette.middleware.gzip import GZipMiddleware
from k1link import __version__ from k1link import __version__
from k1link.artifact_gateway import configured_artifact_gateway from k1link.artifact_gateway import configured_artifact_gateway
@@ -414,6 +415,7 @@ app = FastAPI(
openapi_url="/api/openapi.json", openapi_url="/api/openapi.json",
lifespan=app_lifespan, lifespan=app_lifespan,
) )
app.add_middleware(GZipMiddleware, minimum_size=1_024, compresslevel=5)
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)
+5 -1
View File
@@ -130,6 +130,9 @@ def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None:
assert timeline["frame_count"] == 4489 assert timeline["frame_count"] == 4489
assert len(timeline["frame_times_ns"]) == 4489 assert len(timeline["frame_times_ns"]) == 4489
assert timeline["recorded_source"]["session_id"] == "20260720T065719Z_viewer_live" assert timeline["recorded_source"]["session_id"] == "20260720T065719Z_viewer_live"
assert timeline["recorded_source"]["representation_id"] == "registered-map-increment-v1"
assert timeline["point_delivery"] == "exact-current-increment"
assert timeline["maximum_source_points_per_frame"] == 3092
assert "frames" not in timeline assert "frames" not in timeline
chunk = get_chunk(RESULT_ID, start=1880, count=12) chunk = get_chunk(RESULT_ID, start=1880, count=12)
@@ -141,7 +144,8 @@ def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None:
assert first["schema_version"] == "missioncore.recorded-spatial-evidence-frame/v1" assert first["schema_version"] == "missioncore.recorded-spatial-evidence-frame/v1"
assert first["spatial_available"] is True assert first["spatial_available"] is True
assert first["point_cloud_layer"] == "current-increment" assert first["point_cloud_layer"] == "current-increment"
assert 0 < first["point_cloud_sample_count"] <= 2000 assert first["point_cloud_sample_count"] == first["point_cloud_source_count"]
assert 0 < first["point_cloud_sample_count"] <= 4096
assert first["camera_url"].endswith(f"/{RESULT_ID}/timeline/frames/1880/camera") assert first["camera_url"].endswith(f"/{RESULT_ID}/timeline/frames/1880/camera")