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
@@ -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,
};
}