feat(lab): stabilize autonomous TGS playback

This commit is contained in:
DCCONSTRUCTIONS
2026-08-27 20:34:20 +03:00
parent c8593207d3
commit 95a1ef5057
26 changed files with 3326 additions and 253 deletions
@@ -8,13 +8,16 @@ import {
fetchE47SemanticSlamResult,
type E47SemanticSlamResult,
} from "../../core/laboratory/e47SemanticSlam";
import {
fetchM49PhysicalSafetyPlaybackIdForSource,
M49PhysicalSafetyPlaybackBuffer,
type M49PhysicalSafetyPlaybackProgress,
} from "../../core/laboratory/m49PhysicalSafetyPlayback";
import {
fetchM49TgsFullShadowPlaybackPack,
type M49TgsFullShadowResult,
type M49TgsFullShadowPlaybackPack,
type M49TgsFullShadowPlaybackProgress,
type M49TgsFullShadowSpatial,
type M49TgsFullShadowStateCode,
} from "../../core/laboratory/m49TgsFullShadow";
import {
M4ReplayThreatVisual,
@@ -33,13 +36,6 @@ const PALETTE: readonly RecordedEvidenceSemanticPaletteEntry[] = [
{ classId: 3, color: { kind: "token", token: "--nodedc-warning-rgb" } },
];
function cellState(code: M49TgsFullShadowStateCode): M4ReplayClassifiedSpatialFrame["cellsMapGravityLocal"][number]["state"] {
if (code === 1) return "ground-support";
if (code === 2) return "nonground-occupied";
if (code === 3) return "unknown-rejected";
return "unobserved";
}
function message(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
@@ -51,6 +47,9 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
const [semantic, setSemantic] = useState<E47SemanticSlamResult | null>(null);
const [semanticError, setSemanticError] = useState<string | null>(null);
const [playbackPack, setPlaybackPack] = useState<M49TgsFullShadowPlaybackPack | null>(null);
const [physicalPlayback, setPhysicalPlayback] = useState<M49PhysicalSafetyPlaybackBuffer | null>(null);
const [physicalProgress, setPhysicalProgress] = useState<M49PhysicalSafetyPlaybackProgress | null>(null);
const [physicalRevision, setPhysicalRevision] = useState(0);
const [playbackProgress, setPlaybackProgress] = useState<M49TgsFullShadowPlaybackProgress>({
phase: "manifest",
trackId: null,
@@ -83,12 +82,27 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
useEffect(() => {
const controller = new AbortController();
setPlaybackPack(null);
setPhysicalPlayback(null);
setPhysicalProgress(null);
setPhysicalRevision(0);
setPlaybackProgress({ phase: "manifest", trackId: null, loadedBytes: 0, totalBytes: 0 });
setError(null);
void fetchM49TgsFullShadowPlaybackPack(result.resultId, {
void fetchM49PhysicalSafetyPlaybackIdForSource(result.resultId, {
signal: controller.signal,
onProgress: setPlaybackProgress,
}).then((pack) => {
}).then(async (physicalResultId) => {
if (controller.signal.aborted) return;
if (physicalResultId) {
const playback = await M49PhysicalSafetyPlaybackBuffer.open(physicalResultId, {
signal: controller.signal,
onProgress: setPhysicalProgress,
});
if (!controller.signal.aborted) setPhysicalPlayback(playback);
return;
}
const pack = await fetchM49TgsFullShadowPlaybackPack(result.resultId, {
signal: controller.signal,
onProgress: setPlaybackProgress,
});
if (!controller.signal.aborted) setPlaybackPack(pack);
}).catch((caught: unknown) => {
if (!controller.signal.aborted) setError(message(caught));
@@ -96,69 +110,84 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
return () => controller.abort();
}, [result.resultId]);
const spatial = useMemo<M49TgsFullShadowSpatial | null>(() => {
if (activeSequence === null || !playbackPack) return null;
useEffect(() => {
if (activeSequence === null || !physicalPlayback) return;
let active = true;
const resident = physicalPlayback.frameIfResident(activeSequence);
void physicalPlayback.prepare(activeSequence).then(() => {
if (active && !resident) setPhysicalRevision((value) => value + 1);
}).catch((caught: unknown) => {
if (active) setError(message(caught));
});
return () => {
active = false;
};
}, [activeSequence, physicalPlayback]);
const classifiedFrame = useMemo<M4ReplayClassifiedSpatialFrame | null>(() => {
if (activeSequence === null) return null;
if (physicalPlayback) {
const frame = physicalPlayback.frameIfResident(activeSequence);
if (!frame) return null;
return {
sourceSequence: activeSequence,
sampleAvailable: frame.metadata.sampleAvailable,
sourcePointCount: frame.metadata.metrics.eligible_point_count ?? 0,
pointsMapGravityLocalXyzM: [],
pointClassIds: [],
cellsMapGravityLocal: [],
packedCellsMapGravityLocal: {
centersXyM: frame.centersXyM,
stateCodes: frame.states,
zBoundsM: frame.zBoundsM,
},
cellSizeM: frame.cellSizeM,
classes: CLASSES,
palette: PALETTE,
};
}
if (!playbackPack) return null;
const frame = playbackPack.frames[activeSequence];
if (!frame) return null;
const cellOffset = activeSequence * playbackPack.cellCount;
const zOffset = cellOffset * 2;
const states = Array.from(
playbackPack.states.subarray(cellOffset, cellOffset + playbackPack.cellCount),
(value) => value as M49TgsFullShadowStateCode,
);
const zBoundsM = Array.from({ length: playbackPack.cellCount }, (_, index) => {
const minimum = playbackPack.zBoundsM[zOffset + index * 2]!;
const maximum = playbackPack.zBoundsM[zOffset + index * 2 + 1]!;
return [
Number.isFinite(minimum) ? minimum : null,
Number.isFinite(maximum) ? maximum : null,
] as const;
});
return {
resultId: playbackPack.resultId,
sourceSequence: activeSequence,
sourceFrameIndex: frame.sourceFrameIndex,
sessionSeconds: frame.sessionSeconds,
sampleAvailable: frame.sampleAvailable,
costmap: {
cellSizeM: result.configuration.cellSizeM,
radiusM: result.configuration.radiusM,
sourcePointCount: frame.metrics.eligiblePointCount,
pointsMapGravityLocalXyzM: [],
pointClassIds: [],
cellsMapGravityLocal: [],
packedCellsMapGravityLocal: {
centersXyM: playbackPack.centersXyM,
states,
zBoundsM,
stateCodes: playbackPack.states.subarray(
cellOffset,
cellOffset + playbackPack.cellCount,
),
zBoundsM: playbackPack.zBoundsM.subarray(
zOffset,
zOffset + playbackPack.cellCount * 2,
),
},
metrics: frame.metrics,
cellSizeM: result.configuration.cellSizeM,
classes: CLASSES,
palette: PALETTE,
};
}, [activeSequence, playbackPack, result.configuration.cellSizeM, result.configuration.radiusM]);
const loading = activeSequence !== null && !spatial && !error;
}, [activeSequence, physicalPlayback, physicalRevision, playbackPack, result.configuration.cellSizeM]);
const loading = activeSequence !== null && !classifiedFrame && !error;
const progressPercent = playbackProgress.totalBytes > 0
? Math.min(100, Math.round((playbackProgress.loadedBytes / playbackProgress.totalBytes) * 100))
: 0;
const loadingLabel = playbackProgress.phase === "manifest"
const loadingLabel = physicalProgress
? physicalProgress.phase === "ready"
? "Sealed TGS playback готов"
: `Буферизуем sealed TGS · ${physicalProgress.residentChunkIndexes.length}/3 chunks · ${(physicalProgress.loadedBytes / 1_048_576).toFixed(1)} МБ`
: playbackProgress.phase === "manifest"
? "Проверяем playback manifest"
: playbackProgress.phase === "ready"
? "TGS playback готов"
: `Подготавливаем TGS playback · ${progressPercent}% · ${(playbackProgress.loadedBytes / 1_048_576).toFixed(1)}/${(playbackProgress.totalBytes / 1_048_576).toFixed(1)} МБ`;
const classifiedFrame = useMemo<M4ReplayClassifiedSpatialFrame | null>(() => {
if (!spatial) return null;
return {
sourceSequence: spatial.sourceSequence,
sampleAvailable: spatial.sampleAvailable,
sourcePointCount: spatial.metrics.eligiblePointCount,
pointsMapGravityLocalXyzM: [],
pointClassIds: [],
cellsMapGravityLocal: spatial.costmap.centersXyM.map((center, index) => ({
centerXyM: center,
zBoundsM: spatial.costmap.zBoundsM[index]!,
state: cellState(spatial.costmap.states[index]!),
})),
cellSizeM: spatial.costmap.cellSizeM,
classes: CLASSES,
palette: PALETTE,
};
}, [spatial]);
const handleSequenceChange = useCallback((sequence: number | null) => {
setActiveSequence(sequence);
}, []);
@@ -14,6 +14,7 @@ import {
LaboratoryMetricEvidenceScene,
type LaboratoryMetricCellEvidence,
type LaboratoryMetricEvidenceSceneHandle,
type LaboratoryMetricPackedCellEvidence,
type LaboratoryMetricSceneMode,
} from "../../components/laboratory/LaboratoryMetricEvidenceScene";
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
@@ -118,6 +119,11 @@ export interface M4ReplayClassifiedSpatialFrame {
zBoundsM: readonly [number | null, number | null];
state: LaboratoryMetricCellEvidence["state"];
}[];
packedCellsMapGravityLocal?: {
centersXyM: Float32Array;
zBoundsM: Float32Array;
stateCodes: Uint8Array;
};
cellSizeM: number;
classes: readonly RecordedEvidenceSemanticClass[];
palette: readonly RecordedEvidenceSemanticPaletteEntry[];
@@ -414,19 +420,41 @@ export function M4ReplayThreatVisual({
resultId: string;
frame: M4ReplayClassifiedSpatialFrame;
} | null>(null);
if (classifiedSpatialLayer?.frame) {
lastClassifiedSpatialFrameRef.current = { resultId, frame: classifiedSpatialLayer.frame };
const incomingClassifiedSpatialFrame = classifiedSpatialLayer?.frame ?? null;
if (incomingClassifiedSpatialFrame && incomingClassifiedSpatialFrame.sampleAvailable !== false) {
lastClassifiedSpatialFrameRef.current = { resultId, frame: incomingClassifiedSpatialFrame };
}
const lastAvailableClassifiedSpatialFrame = lastClassifiedSpatialFrameRef.current?.resultId === resultId
? lastClassifiedSpatialFrameRef.current.frame
: null;
// `undefined !== false` is true, so the optional-chain form here used to
// select `null` during every short chunk miss. That unmounted the WebGL
// scene, flashed the alert state and recreated OrbitControls at the default
// view on the next frame. Keep the last complete volume until the exact
// classified frame is resident again.
const displayedClassifiedSpatialFrame = classifiedSpatialFrame
?? (lastClassifiedSpatialFrameRef.current?.resultId === resultId
? lastClassifiedSpatialFrameRef.current.frame
: null);
&& classifiedSpatialFrame.sampleAvailable !== false
? classifiedSpatialFrame
: lastAvailableClassifiedSpatialFrame ?? classifiedSpatialFrame;
const displayedClassifiedFrameHeld = Boolean(
classifiedSpatialFrame
&& displayedClassifiedSpatialFrame
&& classifiedSpatialFrame.sourceSequence !== displayedClassifiedSpatialFrame.sourceSequence,
);
const classifiedContextSpatialFrame = displayedClassifiedSpatialFrame
? timelineFrame.availableFrames.find(
(candidate) => candidate.spatialAvailable
&& candidate.sequence === displayedClassifiedSpatialFrame.sourceSequence,
) ?? (spatialFrame?.sequence === displayedClassifiedSpatialFrame.sourceSequence
? spatialFrame
: null)
: null;
const replaceClassifiedPointCloud = classifiedSpatialLayer?.replacePointCloud ?? true;
const nominalSensorHeightM = metadata.timeline?.rig.nominalSensorHeightM ?? 0;
const mapGravityLocalSensorToBodyGround = useCallback((
point: readonly [number, number, number],
): readonly [number, number, number] => {
const basis = activeSpatialFrame?.bodyFrame?.basisMapFromBody;
const basis = classifiedContextSpatialFrame?.bodyFrame?.basisMapFromBody;
const rotated: readonly [number, number, number] = basis ? [
basis[0][0] * point[0] + basis[1][0] * point[1] + basis[2][0] * point[2],
basis[0][1] * point[0] + basis[1][1] * point[1] + basis[2][1] * point[2],
@@ -435,7 +463,7 @@ export function M4ReplayThreatVisual({
// TGS evidence is translation-only map-gravity-local with the current LiDAR
// as its origin. The metric scene uses the body ground projection as z=0.
return [rotated[0], rotated[1], rotated[2] + nominalSensorHeightM];
}, [activeSpatialFrame?.bodyFrame?.basisMapFromBody, nominalSensorHeightM]);
}, [classifiedContextSpatialFrame?.bodyFrame?.basisMapFromBody, nominalSensorHeightM]);
const classifiedPointsBody = useMemo(
() => displayedClassifiedSpatialFrame?.pointsMapGravityLocalXyzM.map(
mapGravityLocalSensorToBodyGround,
@@ -465,20 +493,58 @@ export function M4ReplayThreatVisual({
}) ?? [],
[displayedClassifiedSpatialFrame, mapGravityLocalSensorToBodyGround, nominalSensorHeightM],
);
const classifiedCellCounts = useMemo(() => ({
ground: classifiedSpatialFrame?.cellsMapGravityLocal.filter(
(cell) => cell.state === "ground-support",
).length ?? 0,
occupied: classifiedSpatialFrame?.cellsMapGravityLocal.filter(
(cell) => cell.state === "nonground-occupied",
).length ?? 0,
rejected: classifiedSpatialFrame?.cellsMapGravityLocal.filter(
(cell) => cell.state === "unknown-rejected",
).length ?? 0,
unobserved: classifiedSpatialFrame?.cellsMapGravityLocal.filter(
(cell) => cell.state === "unobserved",
).length ?? 0,
}), [classifiedSpatialFrame]);
const classifiedPackedCellsBody = useMemo<LaboratoryMetricPackedCellEvidence | undefined>(() => {
const packed = displayedClassifiedSpatialFrame?.packedCellsMapGravityLocal;
if (!packed) return undefined;
const cellCount = packed.stateCodes.length;
if (packed.centersXyM.length !== cellCount * 2 || packed.zBoundsM.length !== cellCount * 2) {
return undefined;
}
const centersBodyXyM = new Float32Array(cellCount * 2);
const zBoundsM = new Float32Array(cellCount * 2);
for (let index = 0; index < cellCount; index += 1) {
const body = mapGravityLocalSensorToBodyGround([
packed.centersXyM[index * 2]!,
packed.centersXyM[index * 2 + 1]!,
0,
]);
centersBodyXyM[index * 2] = body[0];
centersBodyXyM[index * 2 + 1] = body[1];
const minimum = packed.zBoundsM[index * 2]!;
const maximum = packed.zBoundsM[index * 2 + 1]!;
zBoundsM[index * 2] = Number.isFinite(minimum)
? minimum + nominalSensorHeightM
: Number.NaN;
zBoundsM[index * 2 + 1] = Number.isFinite(maximum)
? maximum + nominalSensorHeightM
: Number.NaN;
}
return { centersBodyXyM, zBoundsM, stateCodes: packed.stateCodes };
}, [displayedClassifiedSpatialFrame, mapGravityLocalSensorToBodyGround, nominalSensorHeightM]);
const classifiedCellCounts = useMemo(() => {
const counts = { ground: 0, occupied: 0, rejected: 0, unobserved: 0 };
const packed = classifiedSpatialFrame?.packedCellsMapGravityLocal;
if (packed) {
for (const code of packed.stateCodes) {
if (code === 1) counts.ground += 1;
else if (code === 2) counts.occupied += 1;
else if (code === 3) counts.rejected += 1;
else if (code === 0) counts.unobserved += 1;
else counts.rejected += 1;
}
return counts;
}
for (const cell of classifiedSpatialFrame?.cellsMapGravityLocal ?? []) {
if (cell.state === "ground-support") counts.ground += 1;
else if (cell.state === "nonground-occupied") counts.occupied += 1;
else if (cell.state === "unknown-rejected") counts.rejected += 1;
else counts.unobserved += 1;
}
return counts;
}, [classifiedSpatialFrame]);
const classifiedCellCount = classifiedSpatialFrame?.packedCellsMapGravityLocal?.stateCodes.length
?? classifiedSpatialFrame?.cellsMapGravityLocal.length
?? 0;
const sceneObstacles = useMemo(() => spatialFrame?.metricObstacles.map((obstacle) => ({
id: obstacle.componentId,
decision: obstacle.assessment.decision,
@@ -840,14 +906,16 @@ export function M4ReplayThreatVisual({
<strong>{classifiedSpatialLayer
? classifiedSpatialFrame
? replaceClassifiedPointCloud
? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedSpatialFrame.cellsMapGravityLocal.length.toLocaleString("ru-RU")} cells`
: `${(activeSpatialFrame?.pointCloudSourceCount ?? classifiedSpatialFrame.sourcePointCount ?? 0).toLocaleString("ru-RU")} source points · ${classifiedSpatialFrame.cellsMapGravityLocal.length.toLocaleString("ru-RU")} TGS cells`
? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedCellCount.toLocaleString("ru-RU")} cells`
: `${(activeSpatialFrame?.pointCloudSourceCount ?? classifiedSpatialFrame.sourcePointCount ?? 0).toLocaleString("ru-RU")} source points · ${classifiedCellCount.toLocaleString("ru-RU")} TGS cells`
: "TGS spatial buffer"
: `${currentIncrementObstacles.length} current · ${rollingMapObstacles.length} rolling${metadata.timeline.occupancyProvenanceDelivery ? ` · ${lowStepObstacles.length} low-step` : ""}`}</strong>
<small>{classifiedSpatialLayer
? classifiedSpatialFrame
? classifiedSpatialFrame.sampleAvailable === false
? "LiDAR отсутствует · все ячейки принудительно UNOBSERVED · causal rolling 1 s"
? displayedClassifiedFrameHeld && displayedClassifiedSpatialFrame
? `LiDAR отсутствует · current UNOBSERVED · объёмный контекст удержан с frame ${displayedClassifiedSpatialFrame.sourceSequence + 1}`
: "LiDAR отсутствует · все ячейки принудительно UNOBSERVED · causal rolling 1 s"
: activeSpatialFrame
? "map-gravity-local · all eligible points accounted · causal rolling 1 s"
: "TGS рассчитан · linked source cloud недоступен для этого кадра"
@@ -987,37 +1055,36 @@ export function M4ReplayThreatVisual({
</div>
</div>
) : null}
{(!classifiedSpatialLayer ? spatialFrame : displayedClassifiedSpatialFrame) ? (
<LaboratoryMetricEvidenceScene
ref={metricSceneRef}
pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
? classifiedPointsBody
: activeSpatialFrame?.pointCloudBodyXyzM ?? []}
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
obstacles={displayedClassifiedSpatialFrame ? [] : sceneObstacles}
rig={timeline.rig}
corridor={timeline.corridor}
occupiedVoxelSizeM={displayedClassifiedSpatialFrame?.cellSizeM ?? timeline.occupiedVoxelSizeM}
mode={spatialMode}
label={`${evidenceLabel} exact current increment, bounded local SLAM surface and rolling occupancy`}
showCurrentIncrement={showCurrentIncrement}
showLocalSurface={showLocalSurface}
showRollingMap={showRollingMap}
showLowStep={displayedClassifiedSpatialFrame ? false : showLowStep}
pointSemanticClassIds={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
? displayedClassifiedSpatialFrame.pointClassIds
: alignedSemanticPointIds}
semanticClasses={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
? displayedClassifiedSpatialFrame.classes
: semanticClasses}
semanticPalette={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
? displayedClassifiedSpatialFrame.palette
: semanticPalette}
classifiedCells={classifiedCellsBody}
classifiedCellSizeM={displayedClassifiedSpatialFrame?.cellSizeM}
showClassifiedCells={showRollingMap}
/>
) : null}
<LaboratoryMetricEvidenceScene
ref={metricSceneRef}
pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
? classifiedPointsBody
: classifiedContextSpatialFrame?.pointCloudBodyXyzM ?? []}
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
obstacles={classifiedSpatialLayer ? [] : sceneObstacles}
rig={timeline.rig}
corridor={timeline.corridor}
occupiedVoxelSizeM={displayedClassifiedSpatialFrame?.cellSizeM ?? timeline.occupiedVoxelSizeM}
mode={spatialMode}
label={`${evidenceLabel} exact current increment, bounded local SLAM surface and rolling occupancy`}
showCurrentIncrement={showCurrentIncrement}
showLocalSurface={showLocalSurface}
showRollingMap={showRollingMap}
showLowStep={classifiedSpatialLayer ? false : showLowStep}
pointSemanticClassIds={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
? displayedClassifiedSpatialFrame.pointClassIds
: alignedSemanticPointIds}
semanticClasses={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
? displayedClassifiedSpatialFrame.classes
: semanticClasses}
semanticPalette={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
? displayedClassifiedSpatialFrame.palette
: semanticPalette}
classifiedCells={classifiedCellsBody}
classifiedPackedCells={classifiedPackedCellsBody}
classifiedCellSizeM={displayedClassifiedSpatialFrame?.cellSizeM}
showClassifiedCells={showRollingMap}
/>
{classifiedSpatialLayer && !displayedClassifiedSpatialFrame ? (
<div className="l3-visual-audit__state" role={classifiedSpatialLayer.error ? "alert" : "status"}>
{classifiedSpatialLayer.loading || displayingBufferedFrame
@@ -1033,7 +1100,10 @@ export function M4ReplayThreatVisual({
) : null}
{classifiedSpatialFrame?.sampleAvailable === false ? (
<div className="m4-replay-threat-visual__pane-status" role="status">
Кадр {classifiedSpatialFrame.sourceSequence + 1}: LiDAR отсутствует; все 2 244 TGS-ячейки явно UNOBSERVED.
Кадр {classifiedSpatialFrame.sourceSequence + 1}: LiDAR отсутствует; current safety — все {classifiedCellCount.toLocaleString("ru-RU")} TGS-ячейки UNOBSERVED
{displayedClassifiedFrameHeld && displayedClassifiedSpatialFrame
? `; для ориентации удержан последний объёмный контекст кадра ${displayedClassifiedSpatialFrame.sourceSequence + 1}.`
: "."}
</div>
) : classifiedSpatialFrame && !activeSpatialFrame ? (
<div className="m4-replay-threat-visual__pane-status" role="status">
@@ -2,13 +2,14 @@ import { useEffect, useMemo, useRef, useState } from "react";
import {
fetchM4ThreatCameraPointOverlay,
fetchM4ThreatPlaybackPointPack,
fetchM4ThreatPlaybackManifest,
fetchM4ThreatPlaybackPointChunk,
fetchM4ThreatTimeline,
fetchM4ThreatTimelineChunk,
M4_THREAT_TIMELINE_ENDPOINT_ROOT,
selectM4ThreatTimelineSequence,
type M4ThreatCameraPointOverlay,
type M4ThreatPlaybackPointPack,
type M4ThreatPlaybackManifest,
type M4ThreatPlaybackProgress,
type M4ThreatTimeline,
type M4ThreatTimelineChunk,
@@ -86,7 +87,7 @@ export function useM4ThreatTimelineFrame({
() => new Map(),
);
const [error, setError] = useState<string | null>(null);
const [pointPack, setPointPack] = useState<M4ThreatPlaybackPointPack | null>(null);
const [playbackManifest, setPlaybackManifest] = useState<M4ThreatPlaybackManifest | null>(null);
const [playbackProgress, setPlaybackProgress] = useState<M4ThreatPlaybackProgress>({
phase: "manifest",
loadedBytes: 0,
@@ -102,7 +103,7 @@ export function useM4ThreatTimelineFrame({
useEffect(() => {
const controller = new AbortController();
setPointPack(null);
setPlaybackManifest(null);
setPlaybackError(null);
setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
if (!timeline) return () => controller.abort();
@@ -110,15 +111,15 @@ export function useM4ThreatTimelineFrame({
setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 });
return () => controller.abort();
}
void fetchM4ThreatPlaybackPointPack(resultId, {
void fetchM4ThreatPlaybackManifest(resultId, {
signal: controller.signal,
endpointRoot,
onProgress: (progress) => {
if (!controller.signal.aborted) setPlaybackProgress(progress);
},
})
.then((pack) => {
if (!controller.signal.aborted) setPointPack(pack);
.then((manifest) => {
if (!controller.signal.aborted) {
setPlaybackManifest(manifest);
setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 });
}
})
.catch((caught: unknown) => {
if (!controller.signal.aborted) {
@@ -157,7 +158,7 @@ export function useM4ThreatTimelineFrame({
activeChunkStartRef.current = activeChunkStart;
useEffect(() => {
if (!timeline || activeChunkStart === null || (binaryPlayback && !pointPack)) return;
if (!timeline || activeChunkStart === null || (binaryPlayback && !playbackManifest)) return;
const starts = m4ThreatChunkWindowStarts(
activeChunkStart,
chunkSize,
@@ -168,12 +169,28 @@ export function useM4ThreatTimelineFrame({
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
const controller = new AbortController();
inFlight.current.set(start, controller);
void fetchM4ThreatTimelineChunk(resultId, start, chunkSize, {
signal: controller.signal,
endpointRoot,
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
playbackPointPack: binaryPlayback ? pointPack ?? undefined : undefined,
})
void (async () => {
const playbackPointPack = binaryPlayback && playbackManifest
? await fetchM4ThreatPlaybackPointChunk(
playbackManifest,
Math.floor(start / playbackManifest.chunkFrameCount),
{
signal: controller.signal,
onProgress: (progress) => {
if (!controller.signal.aborted && start === activeChunkStartRef.current) {
setPlaybackProgress(progress);
}
},
},
)
: undefined;
return fetchM4ThreatTimelineChunk(resultId, start, chunkSize, {
signal: controller.signal,
endpointRoot,
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
playbackPointPack,
});
})()
.then((chunk) => {
if (controller.signal.aborted) return;
setChunks((current) => {
@@ -203,7 +220,7 @@ export function useM4ThreatTimelineFrame({
// loaded first, then the next chunk is prefetched on the following render.
break;
}
}, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, pointPack, resultId, timeline]);
}, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, playbackManifest, resultId, timeline]);
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
if (activeSequence === null || activeChunkStart === null) return null;
@@ -230,8 +247,8 @@ export function useM4ThreatTimelineFrame({
? "Проверяем M4 playback manifest"
: playbackProgress.phase === "ready"
? "Подготавливаем текущий spatial-кадр"
: `${playbackProgress.phase === "verify" ? "Проверяем" : "Загружаем"} M4 spatial playback · ${playbackProgress.totalBytes > 0
? `${Math.min(100, Math.round(playbackProgress.loadedBytes / playbackProgress.totalBytes * 100))}% · ${(playbackProgress.loadedBytes / 1_048_576).toFixed(1)}/${(playbackProgress.totalBytes / 1_048_576).toFixed(1)} МБ`
: `${playbackProgress.phase === "verify" ? "Проверяем" : "Буферизуем"} текущий spatial chunk · ${playbackProgress.totalBytes > 0
? `${Math.min(100, Math.round(playbackProgress.loadedBytes / playbackProgress.totalBytes * 100))}% · ${(playbackProgress.loadedBytes / 1_048_576).toFixed(1)} МБ`
: "0%"}`,
error: playbackError ?? error,
};