perf(lab): retain spatial scenes during replay

This commit is contained in:
DCCONSTRUCTIONS
2026-08-27 15:19:00 +03:00
parent bf76304e00
commit 7aeea4ed81
6 changed files with 372 additions and 214 deletions
@@ -169,6 +169,47 @@ function positions(points: readonly LaboratoryMetricPoint3[]): Float32Array {
return result;
}
function updatePointPositions(
geometry: THREE.BufferGeometry,
points: readonly LaboratoryMetricPoint3[],
): void {
const requiredValues = points.length * 3;
let attribute = geometry.getAttribute("position") as THREE.BufferAttribute | undefined;
if (!attribute || attribute.array.length < requiredValues) {
let capacity = 3;
while (capacity < requiredValues) capacity *= 2;
attribute = new THREE.BufferAttribute(new Float32Array(capacity), 3);
attribute.setUsage(THREE.DynamicDrawUsage);
geometry.setAttribute("position", attribute);
}
const values = attribute.array as Float32Array;
points.forEach((point, index) => {
const [x, y, z] = scenePoint(point);
const offset = index * 3;
values[offset] = x;
values[offset + 1] = y;
values[offset + 2] = z;
});
attribute.needsUpdate = true;
geometry.setDrawRange(0, points.length);
}
function ensurePointColors(
geometry: THREE.BufferGeometry,
pointCount: number,
): THREE.BufferAttribute {
const requiredValues = pointCount * 3;
let attribute = geometry.getAttribute("color") as THREE.BufferAttribute | undefined;
if (!attribute || attribute.array.length < requiredValues) {
let capacity = 3;
while (capacity < requiredValues) capacity *= 2;
attribute = new THREE.BufferAttribute(new Float32Array(capacity), 3);
attribute.setUsage(THREE.DynamicDrawUsage);
geometry.setAttribute("color", attribute);
}
return attribute;
}
function decisionColor(
host: HTMLElement,
decision: LaboratoryMetricDecision,
@@ -244,6 +285,12 @@ LaboratoryMetricEvidenceSceneHandle,
const controlsRef = useRef<OrbitControls | null>(null);
const staticContentRef = useRef<THREE.Group | null>(null);
const dynamicContentRef = useRef<THREE.Group | null>(null);
const classifiedContentRef = useRef<THREE.Group | null>(null);
const localSurfacePointsRef = useRef<THREE.Points | null>(null);
const currentIncrementPointsRef = useRef<THREE.Points | null>(null);
const classifiedMeshesRef = useRef<ReadonlyMap<LaboratoryMetricCellState, THREE.InstancedMesh>>(
new Map(),
);
const [renderError, setRenderError] = useState<string | null>(null);
useEffect(() => {
@@ -278,12 +325,48 @@ LaboratoryMetricEvidenceSceneHandle,
controls.maxDistance = 80;
const staticContent = new THREE.Group();
const dynamicContent = new THREE.Group();
scene.add(staticContent, dynamicContent);
const classifiedContent = new THREE.Group();
const localSurfacePoints = new THREE.Points(
new THREE.BufferGeometry(),
new THREE.PointsMaterial({
color: tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
size: 1.3,
sizeAttenuation: false,
transparent: true,
opacity: 0.42,
depthWrite: false,
}),
);
const currentIncrementPoints = new THREE.Points(
new THREE.BufferGeometry(),
new THREE.PointsMaterial({
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
size: 1.55,
sizeAttenuation: false,
transparent: true,
opacity: 0.58,
depthWrite: false,
}),
);
localSurfacePoints.visible = false;
localSurfacePoints.frustumCulled = false;
currentIncrementPoints.visible = false;
currentIncrementPoints.frustumCulled = false;
scene.add(
staticContent,
localSurfacePoints,
currentIncrementPoints,
dynamicContent,
classifiedContent,
);
sceneRef.current = scene;
cameraRef.current = camera;
controlsRef.current = controls;
staticContentRef.current = staticContent;
dynamicContentRef.current = dynamicContent;
classifiedContentRef.current = classifiedContent;
localSurfacePointsRef.current = localSurfacePoints;
currentIncrementPointsRef.current = currentIncrementPoints;
const resize = () => {
const width = Math.max(host.clientWidth, 1);
@@ -315,6 +398,10 @@ LaboratoryMetricEvidenceSceneHandle,
controlsRef.current = null;
staticContentRef.current = null;
dynamicContentRef.current = null;
classifiedContentRef.current = null;
localSurfacePointsRef.current = null;
currentIncrementPointsRef.current = null;
classifiedMeshesRef.current = new Map();
};
}, []);
@@ -323,137 +410,71 @@ LaboratoryMetricEvidenceSceneHandle,
if (canvas) canvas.setAttribute("aria-label", label);
}, [label]);
useEffect(() => {
const points = localSurfacePointsRef.current;
if (!points) return;
points.visible = showLocalSurface && localSurfaceBodyXyzM.length > 0;
if (points.visible) updatePointPositions(points.geometry, localSurfaceBodyXyzM);
}, [localSurfaceBodyXyzM, showLocalSurface]);
useEffect(() => {
const host = hostRef.current;
const points = currentIncrementPointsRef.current;
if (!host || !points) return;
points.visible = showCurrentIncrement && pointCloudBodyXyzM.length > 0;
if (!points.visible) return;
updatePointPositions(points.geometry, pointCloudBodyXyzM);
const material = points.material as THREE.PointsMaterial;
const hasAlignedSemanticClasses =
pointSemanticClassIds !== undefined
&& pointSemanticClassIds.length === pointCloudBodyXyzM.length
&& semanticClasses !== undefined
&& semanticPalette !== undefined;
if (!hasAlignedSemanticClasses) {
if (material.vertexColors) {
material.vertexColors = false;
material.needsUpdate = true;
}
material.color.copy(tokenColor(host, "--nodedc-text-muted", [147, 151, 159]));
return;
}
const declaredIds = new Set(semanticClasses.map((item) => item.id));
const colorsByClassId = new Map<number, readonly [number, number, number]>();
for (const entry of semanticPalette) {
if (!declaredIds.has(entry.classId)) continue;
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
if (rgb) colorsByClassId.set(entry.classId, rgb);
}
const context = tokenColor(host, "--nodedc-text-muted", [147, 151, 159]);
const colorAttribute = ensurePointColors(points.geometry, pointCloudBodyXyzM.length);
const pointColors = colorAttribute.array as Float32Array;
pointSemanticClassIds.forEach((classId, index) => {
const rgb = classId === null ? undefined : colorsByClassId.get(classId);
const offset = index * 3;
pointColors[offset] = rgb ? rgb[0] / 255 : context.r;
pointColors[offset + 1] = rgb ? rgb[1] / 255 : context.g;
pointColors[offset + 2] = rgb ? rgb[2] / 255 : context.b;
});
colorAttribute.needsUpdate = true;
if (!material.vertexColors) {
material.vertexColors = true;
material.needsUpdate = true;
}
material.color.setRGB(1, 1, 1);
}, [
pointCloudBodyXyzM,
pointSemanticClassIds,
semanticClasses,
semanticPalette,
showCurrentIncrement,
]);
useEffect(() => {
const host = hostRef.current;
const content = dynamicContentRef.current;
if (!host || !content) return;
clearGroup(content);
if (showLocalSurface) {
const localSurfaceGeometry = new THREE.BufferGeometry();
localSurfaceGeometry.setAttribute(
"position",
new THREE.BufferAttribute(positions(localSurfaceBodyXyzM), 3),
);
content.add(new THREE.Points(
localSurfaceGeometry,
new THREE.PointsMaterial({
color: tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
size: 1.3,
sizeAttenuation: false,
transparent: true,
opacity: 0.42,
depthWrite: false,
}),
));
}
if (showCurrentIncrement) {
const contextGeometry = new THREE.BufferGeometry();
contextGeometry.setAttribute(
"position",
new THREE.BufferAttribute(positions(pointCloudBodyXyzM), 3),
);
const hasAlignedSemanticClasses =
pointSemanticClassIds !== undefined
&& pointSemanticClassIds.length === pointCloudBodyXyzM.length
&& semanticClasses !== undefined
&& semanticPalette !== undefined;
if (hasAlignedSemanticClasses) {
const declaredIds = new Set(semanticClasses.map((item) => item.id));
const colorsByClassId = new Map<number, readonly [number, number, number]>();
for (const entry of semanticPalette) {
if (!declaredIds.has(entry.classId)) continue;
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
if (rgb) colorsByClassId.set(entry.classId, rgb);
}
const context = tokenColor(host, "--nodedc-text-muted", [147, 151, 159]);
const pointColors = new Float32Array(pointCloudBodyXyzM.length * 3);
pointSemanticClassIds.forEach((classId, index) => {
const rgb = classId === null ? undefined : colorsByClassId.get(classId);
const offset = index * 3;
pointColors[offset] = rgb ? rgb[0] / 255 : context.r;
pointColors[offset + 1] = rgb ? rgb[1] / 255 : context.g;
pointColors[offset + 2] = rgb ? rgb[2] / 255 : context.b;
});
contextGeometry.setAttribute(
"color",
new THREE.BufferAttribute(pointColors, 3),
);
}
content.add(new THREE.Points(
contextGeometry,
new THREE.PointsMaterial({
color: hasAlignedSemanticClasses
? new THREE.Color(1, 1, 1)
: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
vertexColors: hasAlignedSemanticClasses,
size: 1.55,
sizeAttenuation: false,
transparent: true,
opacity: 0.58,
depthWrite: false,
}),
));
}
if (showClassifiedCells && classifiedCells.length) {
const cellsByState = new Map<LaboratoryMetricCellState, LaboratoryMetricCellEvidence[]>();
for (const cell of classifiedCells) {
const cells = cellsByState.get(cell.state) ?? [];
cells.push(cell);
cellsByState.set(cell.state, cells);
}
for (const [state, cells] of cellsByState) {
const color = state === "ground-support"
? tokenColor(host, "--nodedc-success-rgb", [181, 255, 90])
: state === "nonground-occupied"
? tokenColor(host, "--nodedc-danger-rgb", [255, 104, 112])
: state === "unknown-rejected"
? tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92])
: tokenColor(host, "--nodedc-text-muted", [96, 99, 106]);
const geometry = new THREE.BoxGeometry(
classifiedCellSizeM * 0.92,
1,
classifiedCellSizeM * 0.92,
);
const material = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: state === "unobserved" ? 0.035 : state === "ground-support" ? 0.12 : 0.24,
depthWrite: false,
});
const mesh = new THREE.InstancedMesh(geometry, material, cells.length);
const matrix = new THREE.Matrix4();
const scale = new THREE.Vector3(1, 1, 1);
const rotation = new THREE.Quaternion();
cells.forEach((cell, index) => {
const minimum = cell.zBoundsM[0];
const maximum = cell.zBoundsM[1];
const height = minimum === null || maximum === null
? 0.018
: Math.max(0.018, maximum - minimum);
const centerZ = minimum === null || maximum === null
? -0.012
: (minimum + maximum) / 2;
const [sceneX, sceneY, sceneZ] = scenePoint([
cell.centerBodyXyM[0],
cell.centerBodyXyM[1],
centerZ,
]);
scale.set(1, height, 1);
matrix.compose(
new THREE.Vector3(sceneX, sceneY, sceneZ),
rotation,
scale,
);
mesh.setMatrixAt(index, matrix);
});
mesh.instanceMatrix.needsUpdate = true;
content.add(mesh);
}
}
for (const obstacle of obstacles) {
if (
(
@@ -526,20 +547,97 @@ LaboratoryMetricEvidenceSceneHandle,
}, [
obstacles,
occupiedVoxelSizeM,
localSurfaceBodyXyzM,
pointCloudBodyXyzM,
pointSemanticClassIds,
semanticClasses,
semanticPalette,
classifiedCells,
classifiedCellSizeM,
showCurrentIncrement,
showClassifiedCells,
showLocalSurface,
showRollingMap,
showLowStep,
]);
useEffect(() => {
const host = hostRef.current;
const content = classifiedContentRef.current;
if (!host || !content) return;
content.visible = showClassifiedCells && classifiedCells.length > 0;
if (!content.visible) return;
const requiredCapacity = classifiedCells.length;
const currentMeshes = classifiedMeshesRef.current;
const firstMesh = currentMeshes.values().next().value as THREE.InstancedMesh | undefined;
const needsAllocation = !firstMesh
|| Number(firstMesh.userData.capacity ?? 0) < requiredCapacity
|| Number(firstMesh.userData.cellSizeM ?? 0) !== classifiedCellSizeM;
if (needsAllocation) {
clearGroup(content);
const meshes = new Map<LaboratoryMetricCellState, THREE.InstancedMesh>();
const states: readonly LaboratoryMetricCellState[] = [
"unobserved",
"ground-support",
"nonground-occupied",
"unknown-rejected",
];
for (const state of states) {
const color = state === "ground-support"
? tokenColor(host, "--nodedc-success-rgb", [181, 255, 90])
: state === "nonground-occupied"
? tokenColor(host, "--nodedc-danger-rgb", [255, 104, 112])
: state === "unknown-rejected"
? tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92])
: tokenColor(host, "--nodedc-text-muted", [96, 99, 106]);
const mesh = new THREE.InstancedMesh(
new THREE.BoxGeometry(classifiedCellSizeM * 0.92, 1, classifiedCellSizeM * 0.92),
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: state === "unobserved" ? 0.035 : state === "ground-support" ? 0.12 : 0.24,
depthWrite: false,
}),
requiredCapacity,
);
mesh.count = 0;
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.frustumCulled = false;
mesh.userData.capacity = requiredCapacity;
mesh.userData.cellSizeM = classifiedCellSizeM;
meshes.set(state, mesh);
content.add(mesh);
}
classifiedMeshesRef.current = meshes;
}
const meshes = classifiedMeshesRef.current;
const counts = new Map<LaboratoryMetricCellState, number>();
const matrix = new THREE.Matrix4();
const position = new THREE.Vector3();
const scale = new THREE.Vector3(1, 1, 1);
const rotation = new THREE.Quaternion();
for (const cell of classifiedCells) {
const mesh = meshes.get(cell.state);
if (!mesh) continue;
const index = counts.get(cell.state) ?? 0;
const minimum = cell.zBoundsM[0];
const maximum = cell.zBoundsM[1];
const height = minimum === null || maximum === null
? 0.018
: Math.max(0.018, maximum - minimum);
const centerZ = minimum === null || maximum === null
? -0.012
: (minimum + maximum) / 2;
const [sceneX, sceneY, sceneZ] = scenePoint([
cell.centerBodyXyM[0],
cell.centerBodyXyM[1],
centerZ,
]);
position.set(sceneX, sceneY, sceneZ);
scale.set(1, height, 1);
matrix.compose(position, rotation, scale);
mesh.setMatrixAt(index, matrix);
counts.set(cell.state, index + 1);
}
for (const [state, mesh] of meshes) {
mesh.count = counts.get(state) ?? 0;
mesh.instanceMatrix.needsUpdate = true;
}
}, [classifiedCellSizeM, classifiedCells, showClassifiedCells]);
useEffect(() => {
const host = hostRef.current;
const content = staticContentRef.current;
@@ -510,16 +510,21 @@ export function RecordedEvidenceSemanticMaskOverlay({
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const rendererRef = useRef<SemanticMaskRenderer | null | undefined>(undefined);
const lastReadyMaskRef = useRef<{ series: string; mask: DecodedSemanticMask } | null>(null);
const [rendererMode, setRendererMode] = useState<"webgl" | "2d">("webgl");
const [mask, setMask] = useState<DecodedSemanticMask | null>(null);
const [failure, setFailure] = useState<string | null>(null);
const expectedKey = semanticMaskKey(src, imageWidth, imageHeight);
const prefetchSignature = prefetchSrcs.join("\n");
const renderMask = failure
const series = src.slice(0, Math.max(src.lastIndexOf("/"), 0));
if (lastReadyMaskRef.current?.series !== series) lastReadyMaskRef.current = null;
const exactMask = failure
? null
: mask?.key === expectedKey
? mask
: decodedMaskCache.get(expectedKey) ?? null;
if (exactMask) lastReadyMaskRef.current = { series, mask: exactMask };
const renderMask = exactMask ?? lastReadyMaskRef.current?.mask ?? null;
useEffect(() => {
setFailure(null);
@@ -627,8 +632,8 @@ export function RecordedEvidenceSemanticMaskOverlay({
className="recorded-evidence-semantic-mask-overlay"
role="img"
aria-label={ariaLabel}
aria-busy={!failure && !renderMask}
data-state={failure ? "error" : renderMask ? "ready" : "loading"}
aria-busy={!failure && !exactMask}
data-state={failure ? "error" : exactMask ? "ready" : renderMask ? "stale" : "loading"}
data-renderer={rendererMode}
style={{ zIndex: 1 }}
/>
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type {
RecordedEvidenceSemanticClass,
@@ -9,10 +9,11 @@ import {
type E47SemanticSlamResult,
} from "../../core/laboratory/e47SemanticSlam";
import {
fetchM49TgsFullShadowSpatialChunk,
fetchM49TgsFullShadowPlaybackPack,
type M49TgsFullShadowResult,
type M49TgsFullShadowPlaybackPack,
type M49TgsFullShadowPlaybackProgress,
type M49TgsFullShadowSpatial,
type M49TgsFullShadowSpatialChunk,
type M49TgsFullShadowStateCode,
} from "../../core/laboratory/m49TgsFullShadow";
import {
@@ -32,9 +33,6 @@ const PALETTE: readonly RecordedEvidenceSemanticPaletteEntry[] = [
{ classId: 3, color: { kind: "token", token: "--nodedc-warning-rgb" } },
];
const CHUNK_FRAMES = 24;
const RETAINED_CHUNKS = 4;
function cellState(code: M49TgsFullShadowStateCode): M4ReplayClassifiedSpatialFrame["cellsMapGravityLocal"][number]["state"] {
if (code === 1) return "ground-support";
if (code === 2) return "nonground-occupied";
@@ -52,16 +50,14 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
const [activeSequence, setActiveSequence] = useState<number | null>(null);
const [semantic, setSemantic] = useState<E47SemanticSlamResult | null>(null);
const [semanticError, setSemanticError] = useState<string | null>(null);
const [chunks, setChunks] = useState<ReadonlyMap<number, M49TgsFullShadowSpatialChunk>>(
() => new Map(),
);
const [playbackPack, setPlaybackPack] = useState<M49TgsFullShadowPlaybackPack | null>(null);
const [playbackProgress, setPlaybackProgress] = useState<M49TgsFullShadowPlaybackProgress>({
phase: "manifest",
trackId: null,
loadedBytes: 0,
totalBytes: 0,
});
const [error, setError] = useState<string | null>(null);
const inFlightRef = useRef(new Map<number, AbortController>());
const activeChunkStart = activeSequence === null
? null
: Math.floor(activeSequence / CHUNK_FRAMES) * CHUNK_FRAMES;
const activeChunkStartRef = useRef(activeChunkStart);
activeChunkStartRef.current = activeChunkStart;
useEffect(() => {
const controller = new AbortController();
@@ -85,65 +81,64 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
}, [result.source.linkedSemanticResultId, result.source.linkedVisualResultId]);
useEffect(() => {
for (const controller of inFlightRef.current.values()) controller.abort();
inFlightRef.current.clear();
setChunks(new Map());
const controller = new AbortController();
setPlaybackPack(null);
setPlaybackProgress({ phase: "manifest", trackId: null, loadedBytes: 0, totalBytes: 0 });
setError(null);
return () => {
for (const controller of inFlightRef.current.values()) controller.abort();
inFlightRef.current.clear();
};
void fetchM49TgsFullShadowPlaybackPack(result.resultId, {
signal: controller.signal,
onProgress: setPlaybackProgress,
}).then((pack) => {
if (!controller.signal.aborted) setPlaybackPack(pack);
}).catch((caught: unknown) => {
if (!controller.signal.aborted) setError(message(caught));
});
return () => controller.abort();
}, [result.resultId]);
useEffect(() => {
if (activeChunkStart === null) return;
const desiredStarts = [activeChunkStart, activeChunkStart + CHUNK_FRAMES]
.filter((start) => start < result.timeline.frameCount);
const desired = new Set(desiredStarts);
for (const [start, controller] of inFlightRef.current) {
if (desired.has(start)) continue;
controller.abort();
inFlightRef.current.delete(start);
}
for (const start of desiredStarts) {
if (chunks.has(start) || inFlightRef.current.has(start)) continue;
const controller = new AbortController();
inFlightRef.current.set(start, controller);
void fetchM49TgsFullShadowSpatialChunk(result.resultId, start, CHUNK_FRAMES, {
signal: controller.signal,
})
.then((chunk) => {
if (controller.signal.aborted) return;
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 - center) - Math.abs(right - center))
.slice(0, RETAINED_CHUNKS);
return new Map(retained.map((key) => [key, next.get(key)!]));
});
if (start === activeChunkStartRef.current) setError(null);
})
.catch((caught: unknown) => {
if (!controller.signal.aborted && start === activeChunkStartRef.current) {
setError(message(caught));
}
})
.finally(() => {
if (inFlightRef.current.get(start) === controller) inFlightRef.current.delete(start);
});
break;
}
}, [activeChunkStart, chunks, result.resultId, result.timeline.frameCount]);
const spatial = useMemo<M49TgsFullShadowSpatial | null>(() => {
if (activeSequence === null || activeChunkStart === null) return null;
return chunks.get(activeChunkStart)?.frames.find(
(frame) => frame.sourceSequence === activeSequence,
) ?? null;
}, [activeChunkStart, activeSequence, chunks]);
if (activeSequence === null || !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,
centersXyM: playbackPack.centersXyM,
states,
zBoundsM,
},
metrics: frame.metrics,
};
}, [activeSequence, playbackPack, result.configuration.cellSizeM, result.configuration.radiusM]);
const loading = activeSequence !== null && !spatial && !error;
const progressPercent = playbackProgress.totalBytes > 0
? Math.min(100, Math.round((playbackProgress.loadedBytes / playbackProgress.totalBytes) * 100))
: 0;
const loadingLabel = 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;
@@ -188,6 +183,7 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
expectedAtSequence: true,
frame: classifiedFrame,
loading,
loadingLabel,
error,
replacePointCloud: false,
}}
@@ -130,6 +130,7 @@ export interface M4ReplayClassifiedSpatialLayer {
expectedAtSequence: boolean;
frame: M4ReplayClassifiedSpatialFrame | null;
loading: boolean;
loadingLabel?: string;
error: string | null;
replacePointCloud?: boolean;
}
@@ -850,7 +851,9 @@ export function M4ReplayThreatVisual({
: activeSpatialFrame
? "map-gravity-local · all eligible points accounted · causal rolling 1 s"
: "TGS рассчитан · linked source cloud недоступен для этого кадра"
: classifiedSpatialLayer.error ?? `Открываем ${classifiedSpatialLayer.label}`
: classifiedSpatialLayer.error
?? classifiedSpatialLayer.loadingLabel
?? `Открываем ${classifiedSpatialLayer.label}`
: (
<>
{spatialFrame
@@ -1022,7 +1025,7 @@ export function M4ReplayThreatVisual({
: <Icon name="alert" size={18} />}
<span>{classifiedSpatialLayer.error
?? (classifiedSpatialLayer.loading || displayingBufferedFrame
? `Открываем ${classifiedSpatialLayer.label}`
? classifiedSpatialLayer.loadingLabel ?? `Открываем ${classifiedSpatialLayer.label}`
: classifiedSpatialLayer.expectedAtSequence
? `Открываем ${classifiedSpatialLayer.label}`
: `${classifiedSpatialLayer.label} рассчитан только на 10 контрольных кадров.`)}</span>
@@ -1071,7 +1074,7 @@ export function M4ReplayThreatVisual({
{timelineFrame.loading || displayingBufferedFrame ? (
<div className="m4-replay-threat-visual__buffering" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>Догружаем следующий spatial-буфер без сброса сцены</span>
<span>{timelineFrame.loadingLabel ?? "Догружаем следующий spatial-буфер без сброса сцены"}</span>
</div>
) : null}
{timelineFrame.error ? (
@@ -2,10 +2,14 @@ import { useEffect, useMemo, useRef, useState } from "react";
import {
fetchM4ThreatCameraPointOverlay,
fetchM4ThreatPlaybackPointPack,
fetchM4ThreatTimeline,
fetchM4ThreatTimelineChunk,
M4_THREAT_TIMELINE_ENDPOINT_ROOT,
selectM4ThreatTimelineSequence,
type M4ThreatCameraPointOverlay,
type M4ThreatPlaybackPointPack,
type M4ThreatPlaybackProgress,
type M4ThreatTimeline,
type M4ThreatTimelineChunk,
type M4ThreatTimelineFrame,
@@ -82,11 +86,48 @@ export function useM4ThreatTimelineFrame({
() => new Map(),
);
const [error, setError] = useState<string | null>(null);
const [pointPack, setPointPack] = useState<M4ThreatPlaybackPointPack | null>(null);
const [playbackProgress, setPlaybackProgress] = useState<M4ThreatPlaybackProgress>({
phase: "manifest",
loadedBytes: 0,
totalBytes: 0,
});
const [playbackError, setPlaybackError] = useState<string | null>(null);
const binaryPlayback = endpointRoot === undefined
|| endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT;
const inFlight = useRef(new Map<number, AbortController>());
const chunksRef = useRef(chunks);
const activeChunkStartRef = useRef<number | null>(null);
chunksRef.current = chunks;
useEffect(() => {
const controller = new AbortController();
setPointPack(null);
setPlaybackError(null);
setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
if (!timeline) return () => controller.abort();
if (!binaryPlayback) {
setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 });
return () => controller.abort();
}
void fetchM4ThreatPlaybackPointPack(resultId, {
signal: controller.signal,
endpointRoot,
onProgress: (progress) => {
if (!controller.signal.aborted) setPlaybackProgress(progress);
},
})
.then((pack) => {
if (!controller.signal.aborted) setPointPack(pack);
})
.catch((caught: unknown) => {
if (!controller.signal.aborted) {
setPlaybackError(errorMessage(caught, "Бинарный spatial playback M4.6 недоступен."));
}
});
return () => controller.abort();
}, [binaryPlayback, endpointRoot, resultId, timeline]);
useEffect(() => {
for (const controller of inFlight.current.values()) controller.abort();
inFlight.current.clear();
@@ -116,7 +157,7 @@ export function useM4ThreatTimelineFrame({
activeChunkStartRef.current = activeChunkStart;
useEffect(() => {
if (!timeline || activeChunkStart === null) return;
if (!timeline || activeChunkStart === null || (binaryPlayback && !pointPack)) return;
const starts = m4ThreatChunkWindowStarts(
activeChunkStart,
chunkSize,
@@ -131,6 +172,7 @@ export function useM4ThreatTimelineFrame({
signal: controller.signal,
endpointRoot,
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
playbackPointPack: binaryPlayback ? pointPack ?? undefined : undefined,
})
.then((chunk) => {
if (controller.signal.aborted) return;
@@ -161,7 +203,7 @@ export function useM4ThreatTimelineFrame({
// loaded first, then the next chunk is prefetched on the following render.
break;
}
}, [activeChunkStart, chunkSize, chunks, endpointRoot, resultId, timeline]);
}, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, pointPack, resultId, timeline]);
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
if (activeSequence === null || activeChunkStart === null) return null;
@@ -181,8 +223,17 @@ export function useM4ThreatTimelineFrame({
activeSequence,
activeFrame,
availableFrames,
loading: error === null && Boolean(timeline) && !activeFrame,
error,
loading: error === null && playbackError === null && Boolean(timeline) && !activeFrame,
loadingLabel: !binaryPlayback
? "Догружаем следующий spatial-буфер без сброса сцены"
: playbackProgress.phase === "manifest"
? "Проверяем 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)} МБ`
: "0%"}`,
error: playbackError ?? error,
};
}
@@ -28,6 +28,8 @@ test("semantic evidence mask is GPU-colored, bounded, cancelled and object-conta
assert.match(source, /Math\.min\(width \/ mask\.width, height \/ mask\.height\)/);
assert.match(source, /decoded\.key !== expectedKey/);
assert.match(source, /mask\?\.key === expectedKey/);
assert.match(source, /lastReadyMaskRef/);
assert.match(source, /data-state=\{failure \? "error" : exactMask \? "ready" : renderMask \? "stale" : "loading"\}/);
assert.match(source, /recorded-evidence-semantic-mask-overlay__error/);
assert.match(source, /role="alert"/);
});
@@ -53,6 +55,9 @@ test("metric evidence keeps missing semantic assignments as context and exposes
assert.match(source, /classId === null \? undefined : colorsByClassId\.get\(classId\)/);
assert.match(source, /data-decision="semantic"/);
assert.match(source, /recordedEvidenceSemanticCssColor/);
assert.match(source, /DynamicDrawUsage/);
assert.match(source, /classifiedMeshesRef/);
assert.match(source, /updatePointPositions/);
});
test("semantic point alignment follows the last qualified spatial increment", async () => {