fix(lab): stabilize recorded spatial playback
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user