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,
rig,
corridor,
occupiedVoxelSizeM,
mode,
label,
}: {
@@ -103,6 +104,7 @@ export function LaboratoryMetricEvidenceScene({
obstacles: readonly LaboratoryMetricObstacleVisual[];
rig: LaboratoryMetricRigVisual;
corridor: LaboratoryMetricCorridorVisual;
occupiedVoxelSizeM: number;
mode: LaboratoryMetricSceneMode;
label: string;
}) {
@@ -205,10 +207,10 @@ export function LaboratoryMetricEvidenceScene({
contextGeometry,
new THREE.PointsMaterial({
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
size: 1.7,
size: 1.55,
sizeAttenuation: false,
transparent: true,
opacity: 0.34,
opacity: 0.58,
depthWrite: false,
}),
));
@@ -224,36 +226,62 @@ export function LaboratoryMetricEvidenceScene({
continue;
}
const color = decisionColor(host, obstacle.decision);
const cellsGeometry = new THREE.BufferGeometry();
cellsGeometry.setAttribute(
"position",
new THREE.BufferAttribute(positions(obstacle.cellCentersBodyXyzM), 3),
);
content.add(new THREE.Points(
cellsGeometry,
new THREE.PointsMaterial({
if (obstacle.state === "retained") {
const geometry = new THREE.BoxGeometry(
occupiedVoxelSizeM * 0.82,
occupiedVoxelSizeM * 0.82,
occupiedVoxelSizeM * 0.82,
);
const material = new THREE.MeshBasicMaterial({
color,
size: obstacle.state === "current" ? 4.8 : 5.2,
sizeAttenuation: false,
wireframe: true,
transparent: true,
opacity: obstacle.state === "current" ? 0.94 : 0.78,
opacity: 0.34,
depthWrite: false,
}),
));
const centroid = new THREE.Mesh(
new THREE.SphereGeometry(0.1, 16, 12),
new THREE.MeshBasicMaterial({
color,
wireframe: obstacle.state === "retained",
}),
);
centroid.position.fromArray(scenePoint(obstacle.centroidBodyXyzM));
centroid.userData.evidenceId = obstacle.id;
content.add(centroid);
});
const voxels = new THREE.InstancedMesh(
geometry,
material,
obstacle.cellCentersBodyXyzM.length,
);
const matrix = new THREE.Matrix4();
obstacle.cellCentersBodyXyzM.forEach((point, index) => {
matrix.makeTranslation(...scenePoint(point));
voxels.setMatrixAt(index, matrix);
});
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,
occupiedVoxelSizeM,
pointCloudBodyXyzM,
showCurrentIncrement,
showRollingMap,
@@ -156,6 +156,10 @@ export interface M4ThreatTimeline {
nominalRateHz: number;
maxChunkFrames: number;
pointSampleLimit: number;
maximumSourcePointsPerFrame: number;
pointDelivery: "exact-current-increment";
sourceRepresentationId: "registered-map-increment-v1";
occupiedVoxelSizeM: number;
rig: M4ThreatVisualFrame["rig"];
corridor: M4ThreatVisualFrame["corridor"];
}
@@ -513,6 +517,11 @@ export async function fetchM4ThreatTimeline(
"M4.6 recorded session",
);
exact(recorded.source_id, "RAVNOVES00", "M4.6 recorded source id");
exact(
recorded.representation_id,
"registered-map-increment-v1",
"M4.6 recorded representation",
);
exact(
recorded.synchronization,
"host-arrival-best-effort",
@@ -546,6 +555,20 @@ export async function fetchM4ThreatTimeline(
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"),
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: {
lengthM: number(rig.length_m, "M4.6 rig length"),
widthM: number(rig.width_m, "M4.6 rig width"),
@@ -12,6 +12,48 @@
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 {
position: absolute;
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 { ObservationTimeline } from "../../components/ObservationTimeline";
@@ -13,7 +13,10 @@ import {
type RecordedEvidenceBox,
} from "../../components/laboratory/RecordedEvidenceVideoScene";
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 { replayObservationSession } from "../../core/observation/sessionArchive";
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
@@ -62,6 +65,7 @@ function SpatialState({ message: text }: { message: string }) {
export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
const [mode, setMode] = useState<M4ThreatViewMode>("video");
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode>("3d");
const [expanded, setExpanded] = useState(false);
const metadata = useM4ThreatTimelineMetadata(resultId);
const playbackRange = useMemo(() => metadata.timeline ? ({
@@ -85,7 +89,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
useEffect(() => {
const timeline = metadata.timeline;
if (mode !== "video" || !timeline || videoSource) return;
if (!timeline || videoSource) return;
const controller = new AbortController();
setVideoLoading(true);
setVideoError(null);
@@ -122,9 +126,19 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
if (!controller.signal.aborted) setVideoLoading(false);
});
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 sceneObstacles = useMemo(() => frame?.metricObstacles.map((obstacle) => ({
id: obstacle.componentId,
@@ -147,9 +161,16 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
const seek = (seconds: number) => playbackController.seek(seconds);
const handleModeChange = (next: M4ThreatViewMode) => {
if (next === "camera") playbackController.setPlaying(false);
if (next === "3d" || next === "plan") setSpatialMode(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 = (
<div className="l3-visual-audit__actions">
<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>
<small>
+{(frame.sessionSeconds - metadata.timeline.timelineStartSeconds).toFixed(3)} с
· {playbackController.playback.playing ? "воспроизведение" : "пауза / seek"}
· {displayingBufferedFrame
? "держим последний кадр, следующий в буфере"
: playbackController.playback.playing ? "воспроизведение" : "пауза / seek"}
</small>
</div>
<div>
@@ -188,7 +211,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
</strong>
<small>
{frame.spatialAvailable
? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} LiDAR points`
? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} exact lio_pcl increment`
: "body frame / current increment unavailable"}
</small>
</div>
@@ -204,65 +227,97 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
</div>
) : undefined;
const timeline = metadata.timeline;
let content;
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>Открываем синхронное 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) {
} else if (!timeline) {
content = (
<div className="l3-visual-audit__state" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>Буферизуем bounded spatial chunk M4.6</span>
<span>Открываем recorded-realtime timeline M4.6</span>
</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 {
content = (
<LaboratoryMetricEvidenceScene
pointCloudBodyXyzM={frame.pointCloudBodyXyzM}
obstacles={sceneObstacles}
rig={metadata.timeline.rig}
corridor={metadata.timeline.corridor}
mode={mode}
label="M4.6 recorded-realtime current increment and rolling occupancy"
/>
<div className="m4-replay-threat-visual__deck">
<div
className="m4-replay-threat-visual__layer"
data-active={mode === "video" ? "true" : undefined}
aria-hidden={mode !== "video"}
>
{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 ? (
<ObservationTimeline
className="m4-replay-threat-visual__timeline"
@@ -9,13 +9,26 @@ import {
type M4ThreatTimelineFrame,
} from "../../core/laboratory/m4ReplayThreat";
const REQUESTED_CHUNK_FRAMES = 12;
const RETAINED_CHUNK_COUNT = 4;
const REQUESTED_CHUNK_FRAMES = 24;
const RETAINED_CHUNK_COUNT = 8;
const PREFETCH_CHUNKS_AHEAD = 2;
function errorMessage(error: unknown, fallback: string): string {
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) {
const [timeline, setTimeline] = useState<M4ThreatTimeline | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -52,14 +65,22 @@ export function useM4ThreatTimelineFrame({
() => new Map(),
);
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 activeChunkStartRef = useRef<number | null>(null);
chunksRef.current = chunks;
useEffect(() => {
setChunks(new Map());
setError(null);
for (const controller of inFlight.current.values()) controller.abort();
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]);
const activeSequence = useMemo(
@@ -75,18 +96,19 @@ export function useM4ThreatTimelineFrame({
const activeChunkStart = activeSequence === null
? null
: Math.floor(activeSequence / chunkSize) * chunkSize;
activeChunkStartRef.current = activeChunkStart;
useEffect(() => {
if (!timeline || activeChunkStart === null) return;
const starts = [activeChunkStart, activeChunkStart + chunkSize].filter(
(start) => start < timeline.frameCount,
const starts = m4ThreatChunkWindowStarts(
activeChunkStart,
chunkSize,
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);
inFlight.current.set(start, controller);
void fetchM4ThreatTimelineChunk(resultId, start, chunkSize, {
signal: controller.signal,
})
@@ -95,23 +117,27 @@ export function useM4ThreatTimelineFrame({
setChunks((current) => {
const next = new Map(current);
next.set(start, chunk);
const center = activeChunkStartRef.current ?? start;
const retained = [...next.keys()]
.sort((left, right) => (
Math.abs(left - activeChunkStart) - Math.abs(right - activeChunkStart)
Math.abs(left - center) - Math.abs(right - center)
))
.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) => {
if (!controller.signal.aborted && start === activeChunkStart) {
if (!controller.signal.aborted && start === activeChunkStartRef.current) {
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]);
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
@@ -12,6 +12,7 @@ let fetchM4ThreatTimelineChunk;
let selectM4ThreatTimelineFrame;
let selectM4ThreatTimelineSequence;
let advanceRecordedEvidencePlayback;
let m4ThreatChunkWindowStarts;
const resultId = `m4-threat-replay-${"a".repeat(64)}`;
@@ -32,6 +33,9 @@ before(async () => {
({ advanceRecordedEvidencePlayback } = await server.ssrLoadModule(
"/src/components/laboratory/useRecordedEvidencePlayback.ts",
));
({ m4ThreatChunkWindowStarts } = await server.ssrLoadModule(
"/src/workspaces/laboratory/useM4ThreatTimeline.ts",
));
});
after(async () => {
@@ -243,6 +247,7 @@ test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunk
recorded_source: {
session_id: "20260720T065719Z_viewer_live",
source_id: "RAVNOVES00",
representation_id: "registered-map-increment-v1",
synchronization: "host-arrival-best-effort",
},
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_rate_hz: 10,
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 },
corridor: {
forward_length_m: 8,
rear_margin_m: 0.5,
half_width_m: 0.5,
occupied_voxel_size_m: 0.45,
prediction_horizon_seconds: 5,
},
authority: "replay-simulated",
}), { status: 200 }),
});
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);
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);
});
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", () => {
const range = { startSeconds: 10, endSeconds: 20 };
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, /<RecordedEvidenceImageScene/);
assert.match(visual, /<LaboratoryMetricEvidenceScene/);
assert.match(visual, /m4-replay-threat-visual__deck/);
assert.match(visual, /lastFrameRef/);
assert.match(visual, /<ObservationTimeline/);
assert.match(visual, /useM4ThreatTimelineFrame/);
assert.match(visual, /label: "VIDEO"/);