feat(lab): stabilize autonomous TGS playback
This commit is contained in:
@@ -352,6 +352,7 @@ interface RecordedSegmentStreamRuntime {
|
||||
target: RecordedSegmentTarget | null;
|
||||
fetchAbort: AbortController | null;
|
||||
notifiedRevision: number;
|
||||
hasPresentedFrame: boolean;
|
||||
pumping: boolean;
|
||||
disposed: boolean;
|
||||
onTargetBuffered: ((target: RecordedSegmentTarget) => void) | null;
|
||||
@@ -944,6 +945,7 @@ export function RecordedFmp4Player({
|
||||
target: null,
|
||||
fetchAbort: null,
|
||||
notifiedRevision: 0,
|
||||
hasPresentedFrame: false,
|
||||
pumping: false,
|
||||
disposed: false,
|
||||
onTargetBuffered: null,
|
||||
@@ -1090,8 +1092,13 @@ export function RecordedFmp4Player({
|
||||
runtime.fetchAbort?.abort();
|
||||
runtime.target = target;
|
||||
const alreadyBuffered = recordedSegmentTargetBuffered(runtime, target);
|
||||
const retainForwardFrame = Boolean(
|
||||
runtime.hasPresentedFrame
|
||||
&& previousTarget
|
||||
&& candidateTarget.sequence >= previousTarget.sequence,
|
||||
);
|
||||
video.pause();
|
||||
if (!alreadyBuffered) {
|
||||
if (!alreadyBuffered && !retainForwardFrame) {
|
||||
setReadyGeneration(null);
|
||||
setState("loading");
|
||||
}
|
||||
@@ -1119,6 +1126,7 @@ export function RecordedFmp4Player({
|
||||
|| runtime.target?.revision !== bufferedTarget.revision
|
||||
) return;
|
||||
setBufferRevision((revision) => revision + 1);
|
||||
runtime.hasPresentedFrame = true;
|
||||
setReadyGeneration(runtime.generation);
|
||||
setState("ready");
|
||||
reportAdmission({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
forwardRef,
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
@@ -53,6 +54,12 @@ export interface LaboratoryMetricCellEvidence {
|
||||
state: LaboratoryMetricCellState;
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricPackedCellEvidence {
|
||||
centersBodyXyM: Float32Array;
|
||||
zBoundsM: Float32Array;
|
||||
stateCodes: Uint8Array;
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricLegendEntry {
|
||||
id: LaboratoryMetricDecision
|
||||
| LaboratoryMetricCellState
|
||||
@@ -63,6 +70,13 @@ export interface LaboratoryMetricLegendEntry {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface LaboratoryMetricRenderStats {
|
||||
frameMs: number;
|
||||
drawCalls: number;
|
||||
triangles: number;
|
||||
pixelRatio: number;
|
||||
}
|
||||
|
||||
export function laboratoryMetricLegendEntries({
|
||||
pointCloudCount,
|
||||
localSurfaceCount,
|
||||
@@ -256,6 +270,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
semanticClasses?: readonly RecordedEvidenceSemanticClass[];
|
||||
semanticPalette?: readonly RecordedEvidenceSemanticPaletteEntry[];
|
||||
classifiedCells?: readonly LaboratoryMetricCellEvidence[];
|
||||
classifiedPackedCells?: LaboratoryMetricPackedCellEvidence;
|
||||
classifiedCellSizeM?: number;
|
||||
showClassifiedCells?: boolean;
|
||||
}
|
||||
@@ -276,6 +291,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
semanticClasses,
|
||||
semanticPalette,
|
||||
classifiedCells = [],
|
||||
classifiedPackedCells,
|
||||
classifiedCellSizeM = 0.45,
|
||||
showClassifiedCells = true,
|
||||
}, ref) {
|
||||
@@ -288,10 +304,16 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
const classifiedContentRef = useRef<THREE.Group | null>(null);
|
||||
const localSurfacePointsRef = useRef<THREE.Points | null>(null);
|
||||
const currentIncrementPointsRef = useRef<THREE.Points | null>(null);
|
||||
const requestRenderRef = useRef<() => void>(() => undefined);
|
||||
const corridorForwardLengthRef = useRef(corridor.forwardLengthM);
|
||||
const modeRef = useRef(mode);
|
||||
const classifiedMeshesRef = useRef<ReadonlyMap<LaboratoryMetricCellState, THREE.InstancedMesh>>(
|
||||
new Map(),
|
||||
);
|
||||
const [renderError, setRenderError] = useState<string | null>(null);
|
||||
const [renderStats, setRenderStats] = useState<LaboratoryMetricRenderStats | null>(null);
|
||||
corridorForwardLengthRef.current = corridor.forwardLengthM;
|
||||
modeRef.current = mode;
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
@@ -368,27 +390,51 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
localSurfacePointsRef.current = localSurfacePoints;
|
||||
currentIncrementPointsRef.current = currentIncrementPoints;
|
||||
|
||||
let animationFrame: number | null = null;
|
||||
let lastStatsAt = Number.NEGATIVE_INFINITY;
|
||||
const render = () => {
|
||||
animationFrame = null;
|
||||
// OrbitControls emits another change while damping is still settling, so
|
||||
// this remains smooth during interaction without rendering forever while
|
||||
// the evidence scene is idle.
|
||||
controls.update();
|
||||
const startedAt = performance.now();
|
||||
renderer.render(scene, camera);
|
||||
const completedAt = performance.now();
|
||||
if (completedAt - lastStatsAt >= 1_000) {
|
||||
lastStatsAt = completedAt;
|
||||
setRenderStats({
|
||||
frameMs: completedAt - startedAt,
|
||||
drawCalls: renderer.info.render.calls,
|
||||
triangles: renderer.info.render.triangles,
|
||||
pixelRatio: renderer.getPixelRatio(),
|
||||
});
|
||||
}
|
||||
};
|
||||
const requestRender = () => {
|
||||
if (animationFrame !== null) return;
|
||||
animationFrame = window.requestAnimationFrame(render);
|
||||
};
|
||||
requestRenderRef.current = requestRender;
|
||||
controls.addEventListener("change", requestRender);
|
||||
|
||||
const resize = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height, false);
|
||||
requestRender();
|
||||
};
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(host);
|
||||
resize();
|
||||
|
||||
let animationFrame = 0;
|
||||
const render = () => {
|
||||
animationFrame = window.requestAnimationFrame(render);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
render();
|
||||
requestRender();
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
if (animationFrame !== null) window.cancelAnimationFrame(animationFrame);
|
||||
observer.disconnect();
|
||||
controls.removeEventListener("change", requestRender);
|
||||
controls.dispose();
|
||||
scene.traverse(disposeRenderable);
|
||||
renderer.dispose();
|
||||
@@ -401,6 +447,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
classifiedContentRef.current = null;
|
||||
localSurfacePointsRef.current = null;
|
||||
currentIncrementPointsRef.current = null;
|
||||
requestRenderRef.current = () => undefined;
|
||||
classifiedMeshesRef.current = new Map();
|
||||
};
|
||||
}, []);
|
||||
@@ -415,6 +462,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
if (!points) return;
|
||||
points.visible = showLocalSurface && localSurfaceBodyXyzM.length > 0;
|
||||
if (points.visible) updatePointPositions(points.geometry, localSurfaceBodyXyzM);
|
||||
requestRenderRef.current();
|
||||
}, [localSurfaceBodyXyzM, showLocalSurface]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -422,7 +470,10 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
const points = currentIncrementPointsRef.current;
|
||||
if (!host || !points) return;
|
||||
points.visible = showCurrentIncrement && pointCloudBodyXyzM.length > 0;
|
||||
if (!points.visible) return;
|
||||
if (!points.visible) {
|
||||
requestRenderRef.current();
|
||||
return;
|
||||
}
|
||||
updatePointPositions(points.geometry, pointCloudBodyXyzM);
|
||||
const material = points.material as THREE.PointsMaterial;
|
||||
const hasAlignedSemanticClasses =
|
||||
@@ -436,6 +487,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
material.color.copy(tokenColor(host, "--nodedc-text-muted", [147, 151, 159]));
|
||||
requestRenderRef.current();
|
||||
return;
|
||||
}
|
||||
const declaredIds = new Set(semanticClasses.map((item) => item.id));
|
||||
@@ -461,6 +513,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
material.color.setRGB(1, 1, 1);
|
||||
requestRenderRef.current();
|
||||
}, [
|
||||
pointCloudBodyXyzM,
|
||||
pointSemanticClassIds,
|
||||
@@ -543,7 +596,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
content.add(centroid);
|
||||
}
|
||||
}
|
||||
|
||||
requestRenderRef.current();
|
||||
}, [
|
||||
obstacles,
|
||||
occupiedVoxelSizeM,
|
||||
@@ -556,10 +609,21 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
const host = hostRef.current;
|
||||
const content = classifiedContentRef.current;
|
||||
if (!host || !content) return;
|
||||
content.visible = showClassifiedCells && classifiedCells.length > 0;
|
||||
if (!content.visible) return;
|
||||
const packedCellCount = classifiedPackedCells?.stateCodes.length ?? 0;
|
||||
const packedCellsValid = !classifiedPackedCells || (
|
||||
classifiedPackedCells.centersBodyXyM.length === packedCellCount * 2
|
||||
&& classifiedPackedCells.zBoundsM.length === packedCellCount * 2
|
||||
);
|
||||
const cellCount = packedCellsValid && classifiedPackedCells
|
||||
? packedCellCount
|
||||
: classifiedCells.length;
|
||||
content.visible = showClassifiedCells && cellCount > 0;
|
||||
if (!content.visible) {
|
||||
requestRenderRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredCapacity = classifiedCells.length;
|
||||
const requiredCapacity = cellCount;
|
||||
const currentMeshes = classifiedMeshesRef.current;
|
||||
const firstMesh = currentMeshes.values().next().value as THREE.InstancedMesh | undefined;
|
||||
const needsAllocation = !firstMesh
|
||||
@@ -609,12 +673,16 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
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 setCell = (
|
||||
state: LaboratoryMetricCellState,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
minimum: number | null,
|
||||
maximum: number | null,
|
||||
) => {
|
||||
const mesh = meshes.get(state);
|
||||
if (!mesh) return;
|
||||
const index = counts.get(state) ?? 0;
|
||||
const height = minimum === null || maximum === null
|
||||
? 0.018
|
||||
: Math.max(0.018, maximum - minimum);
|
||||
@@ -622,21 +690,55 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
? -0.012
|
||||
: (minimum + maximum) / 2;
|
||||
const [sceneX, sceneY, sceneZ] = scenePoint([
|
||||
cell.centerBodyXyM[0],
|
||||
cell.centerBodyXyM[1],
|
||||
centerX,
|
||||
centerY,
|
||||
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);
|
||||
counts.set(state, index + 1);
|
||||
};
|
||||
if (packedCellsValid && classifiedPackedCells) {
|
||||
for (let cellIndex = 0; cellIndex < packedCellCount; cellIndex += 1) {
|
||||
const code = classifiedPackedCells.stateCodes[cellIndex];
|
||||
const state: LaboratoryMetricCellState = code === 1
|
||||
? "ground-support"
|
||||
: code === 2
|
||||
? "nonground-occupied"
|
||||
: code === 3
|
||||
? "unknown-rejected"
|
||||
: code === 0
|
||||
? "unobserved"
|
||||
: "unknown-rejected";
|
||||
const minimum = classifiedPackedCells.zBoundsM[cellIndex * 2]!;
|
||||
const maximum = classifiedPackedCells.zBoundsM[cellIndex * 2 + 1]!;
|
||||
setCell(
|
||||
state,
|
||||
classifiedPackedCells.centersBodyXyM[cellIndex * 2]!,
|
||||
classifiedPackedCells.centersBodyXyM[cellIndex * 2 + 1]!,
|
||||
Number.isFinite(minimum) ? minimum : null,
|
||||
Number.isFinite(maximum) ? maximum : null,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (const cell of classifiedCells) {
|
||||
setCell(
|
||||
cell.state,
|
||||
cell.centerBodyXyM[0],
|
||||
cell.centerBodyXyM[1],
|
||||
cell.zBoundsM[0],
|
||||
cell.zBoundsM[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [state, mesh] of meshes) {
|
||||
mesh.count = counts.get(state) ?? 0;
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
}, [classifiedCellSizeM, classifiedCells, showClassifiedCells]);
|
||||
requestRenderRef.current();
|
||||
}, [classifiedCellSizeM, classifiedCells, classifiedPackedCells, showClassifiedCells]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
@@ -702,15 +804,24 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
material.depthWrite = false;
|
||||
});
|
||||
content.add(grid);
|
||||
}, [corridor, rig]);
|
||||
requestRenderRef.current();
|
||||
}, [
|
||||
corridor.forwardLengthM,
|
||||
corridor.halfWidthM,
|
||||
corridor.rearMarginM,
|
||||
rig.lengthM,
|
||||
rig.nominalSensorHeightM,
|
||||
rig.widthM,
|
||||
]);
|
||||
|
||||
const resetView = () => {
|
||||
const resetView = useCallback(() => {
|
||||
const camera = cameraRef.current;
|
||||
const controls = controlsRef.current;
|
||||
if (!camera || !controls) return;
|
||||
controls.target.set(corridor.forwardLengthM * 0.35, 0.6, 0);
|
||||
if (mode === "plan") {
|
||||
camera.position.set(corridor.forwardLengthM * 0.35, 15, 0.001);
|
||||
const forwardLengthM = corridorForwardLengthRef.current;
|
||||
controls.target.set(forwardLengthM * 0.35, 0.6, 0);
|
||||
if (modeRef.current === "plan") {
|
||||
camera.position.set(forwardLengthM * 0.35, 15, 0.001);
|
||||
camera.up.set(0, 0, -1);
|
||||
} else {
|
||||
camera.position.set(-4.5, 4.8, 8.5);
|
||||
@@ -718,10 +829,13 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
}
|
||||
camera.updateProjectionMatrix();
|
||||
controls.update();
|
||||
};
|
||||
requestRenderRef.current();
|
||||
}, []);
|
||||
|
||||
useEffect(resetView, [corridor.forwardLengthM, mode]);
|
||||
useImperativeHandle(ref, () => ({ resetView }));
|
||||
// Playback data and rig object identities must never reset an operator's
|
||||
// orbit. Only a deliberate 3D/PLAN mode transition chooses a default view.
|
||||
useEffect(() => resetView(), [mode, resetView]);
|
||||
useImperativeHandle(ref, () => ({ resetView }), [resetView]);
|
||||
|
||||
const semanticLegendEntries = (() => {
|
||||
if (
|
||||
@@ -755,8 +869,23 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
showLowStep,
|
||||
});
|
||||
const classifiedLegendEntries = (() => {
|
||||
if (!showClassifiedCells || !classifiedCells.length) return [];
|
||||
const states = new Set(classifiedCells.map((cell) => cell.state));
|
||||
const states = new Set<LaboratoryMetricCellState>();
|
||||
if (classifiedPackedCells) {
|
||||
for (const code of classifiedPackedCells.stateCodes) {
|
||||
states.add(code === 1
|
||||
? "ground-support"
|
||||
: code === 2
|
||||
? "nonground-occupied"
|
||||
: code === 3
|
||||
? "unknown-rejected"
|
||||
: code === 0
|
||||
? "unobserved"
|
||||
: "unknown-rejected");
|
||||
}
|
||||
} else {
|
||||
classifiedCells.forEach((cell) => states.add(cell.state));
|
||||
}
|
||||
if (!showClassifiedCells || !states.size) return [];
|
||||
return [
|
||||
states.has("ground-support")
|
||||
? { id: "ground-support" as const, label: "Ground support" }
|
||||
@@ -783,6 +912,16 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
{renderError ? <p>{renderError}</p> : null}
|
||||
</div>
|
||||
<div className="laboratory-metric-evidence-scene__legend">
|
||||
{renderStats ? (
|
||||
<span
|
||||
data-decision="performance"
|
||||
title="CPU-время отправки последнего WebGL render pass; это не GPU timer. В покое сцена не перерисовывается."
|
||||
>
|
||||
3D {renderStats.frameMs.toFixed(1)} ms · {renderStats.drawCalls} draw · {renderStats.triangles.toLocaleString("ru-RU")} tri · {(
|
||||
classifiedPackedCells?.stateCodes.length ?? classifiedCells.length
|
||||
).toLocaleString("ru-RU")} cells · {renderStats.pixelRatio.toFixed(1)}×
|
||||
</span>
|
||||
) : null}
|
||||
{metricLegendEntries.map((entry) => (
|
||||
<span key={entry.id} data-decision={entry.id}>{entry.label}</span>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
import type { LaboratoryFetch } from "./advancedResults";
|
||||
|
||||
const RESULT_ID = /^m49-physical-safety-playback-[a-f0-9]{64}$/;
|
||||
const SOURCE_RESULT_ID = /^m49-tgs-full-shadow-[a-f0-9]{64}$/;
|
||||
const SCHEMA = "missioncore.m49-physical-safety-playback/v1";
|
||||
const CHUNK_MAGIC = "MCPSCH01";
|
||||
const CHUNK_HEADER_BYTES = 28;
|
||||
const ENDPOINT_ROOT = "/api/v1/laboratory/m49/physical-safety-playback";
|
||||
|
||||
export interface M49PhysicalSafetyPlaybackFrameMetadata {
|
||||
sourceSequence: number;
|
||||
sourceFrameIndex: number;
|
||||
sessionSeconds: number;
|
||||
sampleAvailable: boolean;
|
||||
metrics: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
interface ArtifactDescriptor {
|
||||
url: string;
|
||||
mediaType: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
interface ChunkDescriptor extends ArtifactDescriptor {
|
||||
index: number;
|
||||
start: number;
|
||||
count: number;
|
||||
headerBytes: 28;
|
||||
format: "mcpsch01-states-u8-aligned-z-bounds-f32le";
|
||||
}
|
||||
|
||||
export interface M49PhysicalSafetyPlaybackManifest {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceResultId: string;
|
||||
frameCount: number;
|
||||
cellCount: number;
|
||||
cellSizeM: number;
|
||||
radiusM: number;
|
||||
sourcePaceHz: number;
|
||||
stateCodes: Readonly<Record<string, number>>;
|
||||
chunkFrameCount: 32;
|
||||
startupPrebufferChunkCount: 2;
|
||||
residentChunkCountMax: 3;
|
||||
forwardPrefetchChunkCount: 1;
|
||||
centers: ArtifactDescriptor;
|
||||
frames: ArtifactDescriptor;
|
||||
chunks: readonly ChunkDescriptor[];
|
||||
}
|
||||
|
||||
interface ResidentChunk {
|
||||
descriptor: ChunkDescriptor;
|
||||
states: Uint8Array;
|
||||
zBoundsM: Float32Array;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
export interface M49PhysicalSafetyPlaybackFrame {
|
||||
metadata: M49PhysicalSafetyPlaybackFrameMetadata;
|
||||
centersXyM: Float32Array;
|
||||
states: Uint8Array;
|
||||
zBoundsM: Float32Array;
|
||||
cellSizeM: number;
|
||||
radiusM: number;
|
||||
}
|
||||
|
||||
export interface M49PhysicalSafetyPlaybackProgress {
|
||||
phase: "manifest" | "static" | "prebuffer" | "ready" | "seek";
|
||||
residentChunkIndexes: readonly number[];
|
||||
loadedBytes: number;
|
||||
}
|
||||
|
||||
export class M49PhysicalSafetyPlaybackContractError extends Error {}
|
||||
|
||||
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = finiteNumber(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T extends string | number | boolean>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function sha256(value: unknown, label: string): string {
|
||||
const parsed = text(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(`${label}: SHA-256 недопустим.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function localUrl(value: unknown, expected: string, label: string): string {
|
||||
const parsed = text(value, label);
|
||||
if (parsed !== expected || parsed.includes("worker")) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(`${label}: разрешён только sealed local API.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function descriptor(
|
||||
value: unknown,
|
||||
expectedUrl: string,
|
||||
label: string,
|
||||
): ArtifactDescriptor {
|
||||
const row = objectValue(value, label);
|
||||
return {
|
||||
url: localUrl(row.url, expectedUrl, `${label}.url`),
|
||||
mediaType: text(row.media_type, `${label}.media_type`),
|
||||
byteLength: integer(row.byte_length, `${label}.byte_length`),
|
||||
sha256: sha256(row.sha256, `${label}.sha256`),
|
||||
};
|
||||
}
|
||||
|
||||
async function sha256Hex(buffer: ArrayBuffer): Promise<string> {
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(
|
||||
"Браузер не поддерживает проверку playback SHA-256.",
|
||||
);
|
||||
}
|
||||
const digest = await globalThis.crypto.subtle.digest("SHA-256", buffer);
|
||||
return [...new Uint8Array(digest)]
|
||||
.map((value) => value.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function fetchVerified(
|
||||
artifact: ArtifactDescriptor,
|
||||
fetcher: LaboratoryFetch,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ArrayBuffer> {
|
||||
const response = await fetcher(artifact.url, {
|
||||
method: "GET",
|
||||
headers: { Accept: artifact.mediaType },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(
|
||||
`Sealed local playback недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength !== artifact.byteLength) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(
|
||||
"Sealed local playback: получен неверный размер.",
|
||||
);
|
||||
}
|
||||
if (await sha256Hex(buffer) !== artifact.sha256) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(
|
||||
"Sealed local playback: SHA-256 не совпал.",
|
||||
);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function parseStateCodes(value: unknown): Readonly<Record<string, number>> {
|
||||
const row = objectValue(value, "M49 physical-safety state codes");
|
||||
const parsed: Record<string, number> = {};
|
||||
for (const [name, code] of Object.entries(row)) {
|
||||
const stateCode = integer(code, `M49 physical-safety state code ${name}`);
|
||||
if (stateCode > 255) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 state code превышает uint8.");
|
||||
}
|
||||
parsed[name] = stateCode;
|
||||
}
|
||||
if (parsed.UNOBSERVED !== 0 || !Object.keys(parsed).length) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 UNOBSERVED state изменился.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function fetchManifest(
|
||||
resultId: string,
|
||||
fetcher: LaboratoryFetch,
|
||||
signal?: AbortSignal,
|
||||
): Promise<M49PhysicalSafetyPlaybackManifest> {
|
||||
if (!RESULT_ID.test(resultId)) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 physical-safety identity недопустима.");
|
||||
}
|
||||
const base = `${ENDPOINT_ROOT}/${encodeURIComponent(resultId)}`;
|
||||
const response = await fetcher(`${base}/manifest`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(
|
||||
`M49 physical-safety manifest недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const payload = objectValue(await response.json(), "M49 physical-safety manifest");
|
||||
exact(payload.schema_version, SCHEMA, "M49 physical-safety schema");
|
||||
exact(payload.result_id, resultId, "M49 physical-safety result");
|
||||
exact(payload.access, "read-only-sealed-local", "M49 physical-safety access");
|
||||
const identity = objectValue(payload.identity, "M49 physical-safety identity");
|
||||
const execution = objectValue(payload.execution, "M49 physical-safety execution");
|
||||
exact(execution.execution_class, "local-sequential-offline", "M49 execution class");
|
||||
exact(execution.worker_role, "realtime-only", "M49 worker role");
|
||||
exact(execution.worker_runtime_dependency, false, "M49 worker dependency");
|
||||
exact(execution.worker_requests_required, 0, "M49 worker requests");
|
||||
const authority = objectValue(payload.authority, "M49 physical-safety authority");
|
||||
exact(authority.commands_enabled, false, "M49 commands");
|
||||
exact(authority.navigation_or_safety_accepted, false, "M49 safety authority");
|
||||
exact(authority.actuation_accepted, false, "M49 actuation authority");
|
||||
const playback = objectValue(payload.playback, "M49 physical-safety playback");
|
||||
exact(playback.coordinate_frame, "map-gravity-local", "M49 coordinate frame");
|
||||
const frameCount = integer(playback.frame_count, "M49 frame count");
|
||||
const cellCount = integer(playback.cell_count, "M49 cell count");
|
||||
if (!frameCount || !cellCount) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 playback не содержит evidence.");
|
||||
}
|
||||
const chunkFrameCount = exact(
|
||||
integer(playback.chunk_frame_count, "M49 chunk frame count"),
|
||||
32,
|
||||
"M49 chunk frame count",
|
||||
);
|
||||
const startupPrebufferChunkCount = exact(
|
||||
integer(playback.startup_prebuffer_chunk_count, "M49 startup prebuffer"),
|
||||
2,
|
||||
"M49 startup prebuffer",
|
||||
);
|
||||
const residentChunkCountMax = exact(
|
||||
integer(playback.resident_chunk_count_max, "M49 resident chunks"),
|
||||
3,
|
||||
"M49 resident chunks",
|
||||
);
|
||||
const forwardPrefetchChunkCount = exact(
|
||||
integer(playback.forward_prefetch_chunk_count, "M49 forward prefetch"),
|
||||
1,
|
||||
"M49 forward prefetch",
|
||||
);
|
||||
const centers = descriptor(playback.centers, `${base}/tracks/centers`, "M49 centers");
|
||||
const centerRow = objectValue(playback.centers, "M49 centers");
|
||||
exact(centerRow.dtype, "<f4", "M49 centers dtype");
|
||||
if (!Array.isArray(centerRow.shape)
|
||||
|| centerRow.shape.length !== 2
|
||||
|| centerRow.shape[0] !== cellCount
|
||||
|| centerRow.shape[1] !== 2
|
||||
|| centers.byteLength !== cellCount * 2 * 4) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 centers shape изменилась.");
|
||||
}
|
||||
const frames = descriptor(playback.frames, `${base}/tracks/frames`, "M49 frames");
|
||||
const frameRow = objectValue(playback.frames, "M49 frames");
|
||||
exact(frameRow.dtype, "ndjson", "M49 frames dtype");
|
||||
if (!Array.isArray(frameRow.shape)
|
||||
|| frameRow.shape.length !== 1
|
||||
|| frameRow.shape[0] !== frameCount) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 frames shape изменилась.");
|
||||
}
|
||||
if (!Array.isArray(playback.chunks)) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk catalog отсутствует.");
|
||||
}
|
||||
const expectedChunkCount = Math.ceil(frameCount / chunkFrameCount);
|
||||
if (playback.chunks.length !== expectedChunkCount) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk accounting нарушен.");
|
||||
}
|
||||
const chunks = playback.chunks.map((value, index): ChunkDescriptor => {
|
||||
const row = objectValue(value, `M49 chunk ${index}`);
|
||||
const common = descriptor(value, `${base}/chunks/${index}`, `M49 chunk ${index}`);
|
||||
const start = index * chunkFrameCount;
|
||||
const count = Math.min(chunkFrameCount, frameCount - start);
|
||||
exact(integer(row.index, `M49 chunk ${index}.index`), index, `M49 chunk ${index}.index`);
|
||||
exact(integer(row.start, `M49 chunk ${index}.start`), start, `M49 chunk ${index}.start`);
|
||||
exact(integer(row.count, `M49 chunk ${index}.count`), count, `M49 chunk ${index}.count`);
|
||||
return {
|
||||
...common,
|
||||
index,
|
||||
start,
|
||||
count,
|
||||
headerBytes: exact(
|
||||
integer(row.header_bytes, `M49 chunk ${index}.header`),
|
||||
CHUNK_HEADER_BYTES,
|
||||
`M49 chunk ${index}.header`,
|
||||
),
|
||||
format: exact(
|
||||
row.format,
|
||||
"mcpsch01-states-u8-aligned-z-bounds-f32le",
|
||||
`M49 chunk ${index}.format`,
|
||||
),
|
||||
};
|
||||
});
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: text(payload.created_at_utc, "M49 physical-safety created"),
|
||||
sourceResultId: text(identity.source_result_id, "M49 physical-safety source"),
|
||||
frameCount,
|
||||
cellCount,
|
||||
cellSizeM: finiteNumber(playback.cell_size_m, "M49 cell size"),
|
||||
radiusM: finiteNumber(playback.radius_m, "M49 radius"),
|
||||
sourcePaceHz: finiteNumber(playback.source_pace_hz, "M49 source pace"),
|
||||
stateCodes: parseStateCodes(playback.state_codes),
|
||||
chunkFrameCount,
|
||||
startupPrebufferChunkCount,
|
||||
residentChunkCountMax,
|
||||
forwardPrefetchChunkCount,
|
||||
centers,
|
||||
frames,
|
||||
chunks,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM49PhysicalSafetyPlaybackIdForSource(
|
||||
sourceResultId: string,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<string | null> {
|
||||
if (!SOURCE_RESULT_ID.test(sourceResultId)) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 source identity недопустима.");
|
||||
}
|
||||
const response = await fetcher(
|
||||
`${ENDPOINT_ROOT}/results?limit=2&source_result_id=${encodeURIComponent(sourceResultId)}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(
|
||||
`M49 physical-safety catalog недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const payload = objectValue(await response.json(), "M49 physical-safety catalog");
|
||||
exact(
|
||||
payload.schema_version,
|
||||
"missioncore.m49-physical-safety-playback-catalog/v1",
|
||||
"M49 physical-safety catalog schema",
|
||||
);
|
||||
exact(payload.worker_runtime_dependency, false, "M49 physical-safety catalog worker");
|
||||
exact(payload.access, "read-only-sealed-local", "M49 physical-safety catalog access");
|
||||
if (!Array.isArray(payload.items)) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 physical-safety catalog изменился.");
|
||||
}
|
||||
if (!payload.items.length) return null;
|
||||
if (payload.items.length !== 1) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(
|
||||
"M49 physical-safety source имеет несколько активных playback artifacts.",
|
||||
);
|
||||
}
|
||||
const item = objectValue(payload.items[0], "M49 physical-safety catalog item");
|
||||
exact(item.source_result_id, sourceResultId, "M49 physical-safety catalog source");
|
||||
exact(item.worker_runtime_dependency, false, "M49 physical-safety item worker");
|
||||
exact(item.navigation_or_safety_accepted, false, "M49 physical-safety item authority");
|
||||
const resultId = text(item.result_id, "M49 physical-safety catalog result");
|
||||
if (!RESULT_ID.test(resultId)) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(
|
||||
"M49 physical-safety catalog result недопустим.",
|
||||
);
|
||||
}
|
||||
return resultId;
|
||||
}
|
||||
|
||||
function parseFrames(
|
||||
buffer: ArrayBuffer,
|
||||
frameCount: number,
|
||||
): readonly M49PhysicalSafetyPlaybackFrameMetadata[] {
|
||||
const lines = new TextDecoder().decode(buffer).trim().split("\n");
|
||||
if (lines.length !== frameCount) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 frame catalog изменил размер.");
|
||||
}
|
||||
return lines.map((line, sourceSequence) => {
|
||||
const row = objectValue(JSON.parse(line), `M49 frame ${sourceSequence}`);
|
||||
const metrics: Record<string, number> = {};
|
||||
for (const [name, value] of Object.entries(row)) {
|
||||
if (name.endsWith("_count") && typeof value === "number" && Number.isFinite(value)) {
|
||||
metrics[name] = value;
|
||||
}
|
||||
}
|
||||
return {
|
||||
sourceSequence,
|
||||
sourceFrameIndex: integer(row.source_frame_index, `M49 frame ${sourceSequence}.source`),
|
||||
sessionSeconds: finiteNumber(row.session_seconds, `M49 frame ${sourceSequence}.time`),
|
||||
sampleAvailable: booleanValue(
|
||||
row.sample_available,
|
||||
`M49 frame ${sourceSequence}.available`,
|
||||
),
|
||||
metrics,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseChunk(
|
||||
buffer: ArrayBuffer,
|
||||
descriptorValue: ChunkDescriptor,
|
||||
cellCount: number,
|
||||
frames: readonly M49PhysicalSafetyPlaybackFrameMetadata[],
|
||||
admittedStateCodes: ReadonlySet<number>,
|
||||
): ResidentChunk {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
if (bytes.byteLength < CHUNK_HEADER_BYTES
|
||||
|| new TextDecoder("latin1").decode(bytes.subarray(0, 8)) !== CHUNK_MAGIC) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk signature изменилась.");
|
||||
}
|
||||
const view = new DataView(buffer);
|
||||
const start = view.getUint32(8, true);
|
||||
const count = view.getUint32(12, true);
|
||||
const cells = view.getUint32(16, true);
|
||||
const stateBytes = view.getUint32(20, true);
|
||||
const zBytes = view.getUint32(24, true);
|
||||
const expectedStateBytes = count * cellCount;
|
||||
const expectedZBytes = count * cellCount * 2 * 4;
|
||||
const zOffset = CHUNK_HEADER_BYTES + stateBytes
|
||||
+ ((4 - ((CHUNK_HEADER_BYTES + stateBytes) % 4)) % 4);
|
||||
if (start !== descriptorValue.start
|
||||
|| count !== descriptorValue.count
|
||||
|| cells !== cellCount
|
||||
|| stateBytes !== expectedStateBytes
|
||||
|| zBytes !== expectedZBytes
|
||||
|| zOffset + zBytes !== bytes.byteLength) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk layout изменился.");
|
||||
}
|
||||
const states = new Uint8Array(buffer, CHUNK_HEADER_BYTES, stateBytes);
|
||||
const zBoundsM = new Float32Array(buffer, zOffset, count * cellCount * 2);
|
||||
for (let localFrame = 0; localFrame < count; localFrame += 1) {
|
||||
const stateStart = localFrame * cellCount;
|
||||
const zStart = localFrame * cellCount * 2;
|
||||
for (let cell = 0; cell < cellCount; cell += 1) {
|
||||
const state = states[stateStart + cell];
|
||||
if (!admittedStateCodes.has(state)) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk содержит неизвестный state.");
|
||||
}
|
||||
if (!frames[start + localFrame].sampleAvailable
|
||||
&& (state !== 0
|
||||
|| Number.isFinite(zBoundsM[zStart + cell * 2])
|
||||
|| Number.isFinite(zBoundsM[zStart + cell * 2 + 1]))) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(
|
||||
"M49 missing sample перестал быть fail-closed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { descriptor: descriptorValue, states, zBoundsM, byteLength: buffer.byteLength };
|
||||
}
|
||||
|
||||
export class M49PhysicalSafetyPlaybackBuffer {
|
||||
readonly manifest: M49PhysicalSafetyPlaybackManifest;
|
||||
readonly centersXyM: Float32Array;
|
||||
readonly frames: readonly M49PhysicalSafetyPlaybackFrameMetadata[];
|
||||
|
||||
private readonly fetcher: LaboratoryFetch;
|
||||
private readonly signal: AbortSignal | undefined;
|
||||
private readonly onProgress: ((value: M49PhysicalSafetyPlaybackProgress) => void) | undefined;
|
||||
private readonly chunks = new Map<number, ResidentChunk>();
|
||||
private readonly pending = new Map<number, Promise<ResidentChunk>>();
|
||||
private readonly admittedStateCodes: ReadonlySet<number>;
|
||||
private staticBytes: number;
|
||||
private preparedWindowKey: string | null = null;
|
||||
private pendingPrepare: { key: string; promise: Promise<void> } | null = null;
|
||||
|
||||
private constructor(
|
||||
manifest: M49PhysicalSafetyPlaybackManifest,
|
||||
centersXyM: Float32Array,
|
||||
frames: readonly M49PhysicalSafetyPlaybackFrameMetadata[],
|
||||
fetcher: LaboratoryFetch,
|
||||
signal: AbortSignal | undefined,
|
||||
onProgress: ((value: M49PhysicalSafetyPlaybackProgress) => void) | undefined,
|
||||
) {
|
||||
this.manifest = manifest;
|
||||
this.centersXyM = centersXyM;
|
||||
this.frames = frames;
|
||||
this.fetcher = fetcher;
|
||||
this.signal = signal;
|
||||
this.onProgress = onProgress;
|
||||
this.admittedStateCodes = new Set(Object.values(manifest.stateCodes));
|
||||
this.staticBytes = centersXyM.byteLength + manifest.frames.byteLength;
|
||||
}
|
||||
|
||||
static async open(
|
||||
resultId: string,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
onProgress,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (value: M49PhysicalSafetyPlaybackProgress) => void;
|
||||
} = {},
|
||||
): Promise<M49PhysicalSafetyPlaybackBuffer> {
|
||||
onProgress?.({ phase: "manifest", residentChunkIndexes: [], loadedBytes: 0 });
|
||||
const manifest = await fetchManifest(resultId, fetcher, signal);
|
||||
const [centersBuffer, framesBuffer] = await Promise.all([
|
||||
fetchVerified(manifest.centers, fetcher, signal),
|
||||
fetchVerified(manifest.frames, fetcher, signal),
|
||||
]);
|
||||
onProgress?.({
|
||||
phase: "static",
|
||||
residentChunkIndexes: [],
|
||||
loadedBytes: centersBuffer.byteLength + framesBuffer.byteLength,
|
||||
});
|
||||
const centersXyM = new Float32Array(centersBuffer);
|
||||
const frames = parseFrames(framesBuffer, manifest.frameCount);
|
||||
const playback = new M49PhysicalSafetyPlaybackBuffer(
|
||||
manifest,
|
||||
centersXyM,
|
||||
frames,
|
||||
fetcher,
|
||||
signal,
|
||||
onProgress,
|
||||
);
|
||||
const startupCount = Math.min(
|
||||
manifest.startupPrebufferChunkCount,
|
||||
manifest.chunks.length,
|
||||
);
|
||||
await Promise.all(Array.from(
|
||||
{ length: startupCount },
|
||||
(_, index) => playback.loadChunk(index, "prebuffer"),
|
||||
));
|
||||
playback.emit("ready");
|
||||
return playback;
|
||||
}
|
||||
|
||||
get residentChunkIndexes(): readonly number[] {
|
||||
return [...this.chunks.keys()].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
get residentByteLength(): number {
|
||||
return this.staticBytes
|
||||
+ [...this.chunks.values()].reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
||||
}
|
||||
|
||||
async prepare(sourceSequence: number): Promise<void> {
|
||||
const chunkIndex = this.chunkIndex(sourceSequence);
|
||||
const nextIndex = chunkIndex + this.manifest.forwardPrefetchChunkCount;
|
||||
const indexes = [chunkIndex, nextIndex].filter(
|
||||
(index) => index < this.manifest.chunks.length,
|
||||
);
|
||||
const key = indexes.join(":");
|
||||
if (this.preparedWindowKey === key && indexes.every((index) => this.chunks.has(index))) {
|
||||
return;
|
||||
}
|
||||
if (this.pendingPrepare?.key === key) return this.pendingPrepare.promise;
|
||||
const promise = Promise.all(indexes.map((index) => this.loadChunk(index, "seek")))
|
||||
.then(() => {
|
||||
this.trim(new Set(indexes));
|
||||
this.preparedWindowKey = key;
|
||||
this.emit("seek");
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.pendingPrepare?.key === key) this.pendingPrepare = null;
|
||||
});
|
||||
this.pendingPrepare = { key, promise };
|
||||
return promise;
|
||||
}
|
||||
|
||||
async frame(sourceSequence: number): Promise<M49PhysicalSafetyPlaybackFrame> {
|
||||
const chunkIndex = this.chunkIndex(sourceSequence);
|
||||
const chunk = await this.loadChunk(chunkIndex, "seek");
|
||||
const frame = this.frameFromChunk(sourceSequence, chunk);
|
||||
const nextIndex = chunkIndex + this.manifest.forwardPrefetchChunkCount;
|
||||
if (nextIndex < this.manifest.chunks.length && !this.chunks.has(nextIndex)) {
|
||||
void this.loadChunk(nextIndex, "seek").catch(() => undefined);
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
frameIfResident(sourceSequence: number): M49PhysicalSafetyPlaybackFrame | null {
|
||||
const chunkIndex = this.chunkIndex(sourceSequence);
|
||||
const chunk = this.chunks.get(chunkIndex);
|
||||
return chunk ? this.frameFromChunk(sourceSequence, chunk) : null;
|
||||
}
|
||||
|
||||
private frameFromChunk(
|
||||
sourceSequence: number,
|
||||
chunk: ResidentChunk,
|
||||
): M49PhysicalSafetyPlaybackFrame {
|
||||
const localFrame = sourceSequence - chunk.descriptor.start;
|
||||
const stateOffset = localFrame * this.manifest.cellCount;
|
||||
const zOffset = stateOffset * 2;
|
||||
return {
|
||||
metadata: this.frames[sourceSequence],
|
||||
centersXyM: this.centersXyM,
|
||||
states: chunk.states.subarray(stateOffset, stateOffset + this.manifest.cellCount),
|
||||
zBoundsM: chunk.zBoundsM.subarray(zOffset, zOffset + this.manifest.cellCount * 2),
|
||||
cellSizeM: this.manifest.cellSizeM,
|
||||
radiusM: this.manifest.radiusM,
|
||||
};
|
||||
}
|
||||
|
||||
private chunkIndex(sourceSequence: number): number {
|
||||
if (!Number.isSafeInteger(sourceSequence)
|
||||
|| sourceSequence < 0
|
||||
|| sourceSequence >= this.manifest.frameCount) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 source sequence недопустима.");
|
||||
}
|
||||
return Math.floor(sourceSequence / this.manifest.chunkFrameCount);
|
||||
}
|
||||
|
||||
private async loadChunk(
|
||||
index: number,
|
||||
phase: "prebuffer" | "seek",
|
||||
): Promise<ResidentChunk> {
|
||||
const existing = this.chunks.get(index);
|
||||
if (existing) {
|
||||
this.chunks.delete(index);
|
||||
this.chunks.set(index, existing);
|
||||
return existing;
|
||||
}
|
||||
const inflight = this.pending.get(index);
|
||||
if (inflight) return inflight;
|
||||
const descriptorValue = this.manifest.chunks[index];
|
||||
if (!descriptorValue) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk отсутствует.");
|
||||
}
|
||||
const promise = fetchVerified(descriptorValue, this.fetcher, this.signal)
|
||||
.then((buffer) => parseChunk(
|
||||
buffer,
|
||||
descriptorValue,
|
||||
this.manifest.cellCount,
|
||||
this.frames,
|
||||
this.admittedStateCodes,
|
||||
))
|
||||
.then((chunk) => {
|
||||
this.chunks.set(index, chunk);
|
||||
this.trim(new Set([index]));
|
||||
this.emit(phase);
|
||||
return chunk;
|
||||
})
|
||||
.finally(() => this.pending.delete(index));
|
||||
this.pending.set(index, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private trim(protectedIndexes: ReadonlySet<number>): void {
|
||||
while (this.chunks.size > this.manifest.residentChunkCountMax) {
|
||||
const removable = [...this.chunks.keys()].find((index) => !protectedIndexes.has(index));
|
||||
if (removable === undefined) break;
|
||||
this.chunks.delete(removable);
|
||||
}
|
||||
}
|
||||
|
||||
private emit(phase: M49PhysicalSafetyPlaybackProgress["phase"]): void {
|
||||
this.onProgress?.({
|
||||
phase,
|
||||
residentChunkIndexes: this.residentChunkIndexes,
|
||||
loadedBytes: this.residentByteLength,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export interface M49TgsFullShadowPlaybackPack {
|
||||
frameCount: 4489;
|
||||
cellCount: 2244;
|
||||
totalByteLength: number;
|
||||
centersXyM: readonly (readonly [number, number])[];
|
||||
centersXyM: Float32Array;
|
||||
states: Uint8Array;
|
||||
zBoundsM: Float32Array;
|
||||
frames: readonly M49TgsFullShadowPlaybackFrame[];
|
||||
@@ -628,9 +628,6 @@ export async function fetchM49TgsFullShadowPlaybackPack(
|
||||
"<f4",
|
||||
centersTrack.shape,
|
||||
) as Float32Array;
|
||||
const centersXyM = Array.from({ length: manifest.cellCount }, (_, index) => (
|
||||
[centersRaw[index * 2]!, centersRaw[index * 2 + 1]!] as const
|
||||
));
|
||||
const states = parseM49TgsNpyTrack(
|
||||
buffers.get("states")!,
|
||||
"|u1",
|
||||
@@ -653,7 +650,7 @@ export async function fetchM49TgsFullShadowPlaybackPack(
|
||||
frameCount: manifest.frameCount,
|
||||
cellCount: manifest.cellCount,
|
||||
totalByteLength: manifest.totalByteLength,
|
||||
centersXyM,
|
||||
centersXyM: centersRaw,
|
||||
states,
|
||||
zBoundsM,
|
||||
frames,
|
||||
|
||||
@@ -238,6 +238,37 @@ export interface M4ThreatPlaybackPointPack {
|
||||
pointCount: number;
|
||||
pointOffsets: Uint32Array;
|
||||
pointsMapXyzM: Float32Array;
|
||||
pointStart?: number;
|
||||
startSequence?: number;
|
||||
sequenceCount?: number;
|
||||
}
|
||||
|
||||
export interface M4ThreatPlaybackChunkDescriptor {
|
||||
index: number;
|
||||
start: number;
|
||||
count: number;
|
||||
pointStart: number;
|
||||
pointCount: number;
|
||||
url: string;
|
||||
mediaType: "application/octet-stream";
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface M4ThreatPlaybackManifest {
|
||||
resultId: string;
|
||||
frameCount: 4489;
|
||||
pointCount: number;
|
||||
pointOffsets: Uint32Array;
|
||||
chunkFrameCount: 24;
|
||||
residentChunkCountMax: 4;
|
||||
forwardPrefetchChunkCount: 1;
|
||||
chunks: readonly M4ThreatPlaybackChunkDescriptor[];
|
||||
track: {
|
||||
url: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
};
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
@@ -889,7 +920,14 @@ export function hydrateM4ThreatTimelineFrame(
|
||||
frame: M4ThreatTimelineFrame,
|
||||
pack: M4ThreatPlaybackPointPack,
|
||||
): M4ThreatTimelineFrame {
|
||||
if (frame.sequence >= pack.frameCount || pack.resultId === "") {
|
||||
if (
|
||||
frame.sequence >= pack.frameCount
|
||||
|| pack.resultId === ""
|
||||
|| (pack.startSequence !== undefined && (
|
||||
frame.sequence < pack.startSequence
|
||||
|| frame.sequence >= pack.startSequence + (pack.sequenceCount ?? 0)
|
||||
))
|
||||
) {
|
||||
throw new M4ThreatContractError("M4.6 binary playback frame: нарушена идентичность.");
|
||||
}
|
||||
if (!frame.sourceAvailable || !frame.spatialAvailable || !frame.bodyFrame) {
|
||||
@@ -900,7 +938,15 @@ export function hydrateM4ThreatTimelineFrame(
|
||||
}
|
||||
const start = pack.pointOffsets[frame.sequence];
|
||||
const stop = pack.pointOffsets[frame.sequence + 1];
|
||||
if (start === undefined || stop === undefined || stop < start || stop > pack.pointCount) {
|
||||
const pointStart = pack.pointStart ?? 0;
|
||||
const pointStop = pointStart + pack.pointCount;
|
||||
if (
|
||||
start === undefined
|
||||
|| stop === undefined
|
||||
|| stop < start
|
||||
|| start < pointStart
|
||||
|| stop > pointStop
|
||||
) {
|
||||
throw new M4ThreatContractError("M4.6 binary playback offsets: нарушена размерность.");
|
||||
}
|
||||
const count = stop - start;
|
||||
@@ -911,7 +957,7 @@ export function hydrateM4ThreatTimelineFrame(
|
||||
const basis = frame.bodyFrame.basisMapFromBody;
|
||||
const points = new Array<M4Point3>(count);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const sourceOffset = (start + index) * 3;
|
||||
const sourceOffset = (start - pointStart + index) * 3;
|
||||
const dx = pack.pointsMapXyzM[sourceOffset]! - origin[0];
|
||||
const dy = pack.pointsMapXyzM[sourceOffset + 1]! - origin[1];
|
||||
const dz = pack.pointsMapXyzM[sourceOffset + 2]! - origin[2];
|
||||
@@ -924,22 +970,19 @@ export function hydrateM4ThreatTimelineFrame(
|
||||
return { ...frame, pointCloudBodyXyzM: points, pointCloudSampleCount: count };
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatPlaybackPointPack(
|
||||
export async function fetchM4ThreatPlaybackManifest(
|
||||
result: string,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
||||
onProgress,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
endpointRoot?: string;
|
||||
onProgress?: (progress: M4ThreatPlaybackProgress) => void;
|
||||
} = {},
|
||||
): Promise<M4ThreatPlaybackPointPack> {
|
||||
): Promise<M4ThreatPlaybackManifest> {
|
||||
resultId(result);
|
||||
onProgress?.({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
|
||||
const manifestResponse = await fetcher(`${endpointRoot}/${result}/timeline/playback`, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
@@ -982,46 +1025,180 @@ export async function fetchM4ThreatPlaybackPointPack(
|
||||
if (!/^[a-f0-9]{64}$/.test(sha256)) {
|
||||
throw new M4ThreatContractError("M4.6 playback SHA-256: нарушен контракт.");
|
||||
}
|
||||
const response = await fetcher(text(track.url, "M4.6 playback URL"), {
|
||||
headers: { Accept: "application/octet-stream" },
|
||||
signal,
|
||||
const trackUrl = text(track.url, "M4.6 playback URL");
|
||||
const chunkFrameCount = exact(
|
||||
integer(manifest.chunk_frame_count, "M4.6 playback chunk frames"),
|
||||
24,
|
||||
"M4.6 playback chunk frames",
|
||||
);
|
||||
const residentChunkCountMax = exact(
|
||||
integer(manifest.resident_chunk_count_max, "M4.6 playback resident chunks"),
|
||||
4,
|
||||
"M4.6 playback resident chunks",
|
||||
);
|
||||
const forwardPrefetchChunkCount = exact(
|
||||
integer(manifest.forward_prefetch_chunk_count, "M4.6 playback prefetch chunks"),
|
||||
1,
|
||||
"M4.6 playback prefetch chunks",
|
||||
);
|
||||
const chunkRows = array(manifest.chunks, "M4.6 playback chunks");
|
||||
const expectedChunkCount = Math.ceil(frameCount / chunkFrameCount);
|
||||
if (chunkRows.length !== expectedChunkCount) {
|
||||
throw new M4ThreatContractError("M4.6 playback chunk catalog: неверный размер.");
|
||||
}
|
||||
const chunks = chunkRows.map((value, index): M4ThreatPlaybackChunkDescriptor => {
|
||||
const row = object(value, `M4.6 playback chunk ${index}`);
|
||||
const start = index * chunkFrameCount;
|
||||
const count = Math.min(chunkFrameCount, frameCount - start);
|
||||
const pointStart = pointOffsets[start]!;
|
||||
const pointStop = pointOffsets[start + count]!;
|
||||
const pointChunkCount = pointStop - pointStart;
|
||||
exact(integer(row.index, `M4.6 playback chunk ${index}.index`), index, `M4.6 playback chunk ${index}.index`);
|
||||
exact(integer(row.start, `M4.6 playback chunk ${index}.start`), start, `M4.6 playback chunk ${index}.start`);
|
||||
exact(integer(row.count, `M4.6 playback chunk ${index}.count`), count, `M4.6 playback chunk ${index}.count`);
|
||||
exact(integer(row.point_start, `M4.6 playback chunk ${index}.point start`), pointStart, `M4.6 playback chunk ${index}.point start`);
|
||||
exact(integer(row.point_count, `M4.6 playback chunk ${index}.point count`), pointChunkCount, `M4.6 playback chunk ${index}.point count`);
|
||||
exact(row.media_type, "application/octet-stream", `M4.6 playback chunk ${index}.media type`);
|
||||
exact(row.dtype, "<f4", `M4.6 playback chunk ${index}.dtype`);
|
||||
const shapeValue = array(row.shape, `M4.6 playback chunk ${index}.shape`)
|
||||
.map((item) => integer(item, `M4.6 playback chunk ${index}.shape`));
|
||||
const chunkBytes = integer(row.bytes, `M4.6 playback chunk ${index}.bytes`);
|
||||
if (
|
||||
shapeValue.length !== 2
|
||||
|| shapeValue[0] !== pointChunkCount
|
||||
|| shapeValue[1] !== 3
|
||||
|| chunkBytes !== pointChunkCount * 3 * Float32Array.BYTES_PER_ELEMENT
|
||||
) {
|
||||
throw new M4ThreatContractError(`M4.6 playback chunk ${index}: нарушена размерность.`);
|
||||
}
|
||||
const chunkSha256 = text(row.sha256, `M4.6 playback chunk ${index}.SHA-256`);
|
||||
if (!/^[a-f0-9]{64}$/.test(chunkSha256)) {
|
||||
throw new M4ThreatContractError(`M4.6 playback chunk ${index}: SHA-256 недопустим.`);
|
||||
}
|
||||
const expectedUrl = `${endpointRoot}/${result}/timeline/playback/chunks/${index}`;
|
||||
const url = text(row.url, `M4.6 playback chunk ${index}.URL`);
|
||||
if (url !== expectedUrl || url.includes("worker")) {
|
||||
throw new M4ThreatContractError(`M4.6 playback chunk ${index}: разрешён только sealed local API.`);
|
||||
}
|
||||
return {
|
||||
index,
|
||||
start,
|
||||
count,
|
||||
pointStart,
|
||||
pointCount: pointChunkCount,
|
||||
url,
|
||||
mediaType: "application/octet-stream",
|
||||
byteLength: chunkBytes,
|
||||
sha256: chunkSha256,
|
||||
};
|
||||
});
|
||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 playback points: HTTP ${response.status}.`);
|
||||
const bytes = new Uint8Array(byteLength);
|
||||
if (response.body) {
|
||||
const reader = response.body.getReader();
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (offset + value.byteLength > bytes.byteLength) {
|
||||
throw new M4ThreatContractError("M4.6 playback points превысил объявленный размер.");
|
||||
}
|
||||
bytes.set(value, offset);
|
||||
offset += value.byteLength;
|
||||
onProgress?.({ phase: "download", loadedBytes: offset, totalBytes: byteLength });
|
||||
}
|
||||
if (offset !== byteLength) {
|
||||
throw new M4ThreatContractError("M4.6 playback points получен не полностью.");
|
||||
}
|
||||
} else {
|
||||
const fallback = new Uint8Array(await response.arrayBuffer());
|
||||
if (fallback.byteLength !== byteLength) {
|
||||
throw new M4ThreatContractError("M4.6 playback points: неверный размер.");
|
||||
}
|
||||
bytes.set(fallback);
|
||||
}
|
||||
onProgress?.({ phase: "verify", loadedBytes: byteLength, totalBytes: byteLength });
|
||||
if (await playbackSha256Hex(bytes.buffer) !== sha256) {
|
||||
throw new M4ThreatContractError("M4.6 playback points: SHA-256 не совпал.");
|
||||
}
|
||||
onProgress?.({ phase: "ready", loadedBytes: byteLength, totalBytes: byteLength });
|
||||
return {
|
||||
resultId: result,
|
||||
frameCount,
|
||||
pointCount,
|
||||
pointOffsets,
|
||||
pointsMapXyzM: new Float32Array(bytes.buffer),
|
||||
chunkFrameCount,
|
||||
residentChunkCountMax,
|
||||
forwardPrefetchChunkCount,
|
||||
chunks,
|
||||
track: { url: trackUrl, byteLength, sha256 },
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatPlaybackPointChunk(
|
||||
manifest: M4ThreatPlaybackManifest,
|
||||
chunkIndex: number,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
onProgress,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: M4ThreatPlaybackProgress) => void;
|
||||
} = {},
|
||||
): Promise<M4ThreatPlaybackPointPack> {
|
||||
const descriptor = manifest.chunks[chunkIndex];
|
||||
if (!descriptor || descriptor.index !== chunkIndex) {
|
||||
throw new M4ThreatContractError("M4.6 playback chunk отсутствует.");
|
||||
}
|
||||
onProgress?.({ phase: "download", loadedBytes: 0, totalBytes: descriptor.byteLength });
|
||||
const response = await fetcher(descriptor.url, {
|
||||
headers: { Accept: "application/octet-stream" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new M4ThreatContractError(`M4.6 playback chunk: HTTP ${response.status}.`);
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength !== descriptor.byteLength) {
|
||||
throw new M4ThreatContractError("M4.6 playback chunk: неверный размер.");
|
||||
}
|
||||
onProgress?.({
|
||||
phase: "verify",
|
||||
loadedBytes: descriptor.byteLength,
|
||||
totalBytes: descriptor.byteLength,
|
||||
});
|
||||
if (await playbackSha256Hex(buffer) !== descriptor.sha256) {
|
||||
throw new M4ThreatContractError("M4.6 playback chunk: SHA-256 не совпал.");
|
||||
}
|
||||
onProgress?.({
|
||||
phase: "ready",
|
||||
loadedBytes: descriptor.byteLength,
|
||||
totalBytes: descriptor.byteLength,
|
||||
});
|
||||
return {
|
||||
resultId: manifest.resultId,
|
||||
frameCount: manifest.frameCount,
|
||||
pointCount: descriptor.pointCount,
|
||||
pointOffsets: manifest.pointOffsets,
|
||||
pointsMapXyzM: new Float32Array(buffer),
|
||||
pointStart: descriptor.pointStart,
|
||||
startSequence: descriptor.start,
|
||||
sequenceCount: descriptor.count,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatPlaybackPointPack(
|
||||
result: string,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
||||
onProgress,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
endpointRoot?: string;
|
||||
onProgress?: (progress: M4ThreatPlaybackProgress) => void;
|
||||
} = {},
|
||||
): Promise<M4ThreatPlaybackPointPack> {
|
||||
onProgress?.({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
|
||||
const manifest = await fetchM4ThreatPlaybackManifest(result, {
|
||||
fetcher,
|
||||
signal,
|
||||
endpointRoot,
|
||||
});
|
||||
const response = await fetcher(manifest.track.url, {
|
||||
headers: { Accept: "application/octet-stream" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 playback points: HTTP ${response.status}.`);
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength !== manifest.track.byteLength) {
|
||||
throw new M4ThreatContractError("M4.6 playback points: неверный размер.");
|
||||
}
|
||||
onProgress?.({ phase: "verify", loadedBytes: buffer.byteLength, totalBytes: buffer.byteLength });
|
||||
if (await playbackSha256Hex(buffer) !== manifest.track.sha256) {
|
||||
throw new M4ThreatContractError("M4.6 playback points: SHA-256 не совпал.");
|
||||
}
|
||||
onProgress?.({ phase: "ready", loadedBytes: buffer.byteLength, totalBytes: buffer.byteLength });
|
||||
return {
|
||||
resultId: manifest.resultId,
|
||||
frameCount: manifest.frameCount,
|
||||
pointCount: manifest.pointCount,
|
||||
pointOffsets: manifest.pointOffsets,
|
||||
pointsMapXyzM: new Float32Array(buffer),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -236,9 +236,10 @@
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
width: max-content;
|
||||
max-width: calc(var(--m4-replay-threat-overlay-pane-width, 100%) - 1.2rem);
|
||||
grid-template-columns: minmax(0, max-content);
|
||||
width: min(17.5rem, calc(var(--m4-replay-threat-overlay-pane-width, 100%) - 1.2rem));
|
||||
max-width: none;
|
||||
grid-auto-rows: 3.8rem;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.3rem;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
@@ -247,6 +248,9 @@
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__overlay > div {
|
||||
box-sizing: border-box;
|
||||
height: 3.8rem;
|
||||
grid-template-rows: auto auto minmax(1.32rem, 1fr);
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: color-mix(in srgb, var(--nodedc-floating-surface) 80%, transparent);
|
||||
padding: 0.55rem 0.65rem;
|
||||
@@ -254,15 +258,24 @@
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__overlay span,
|
||||
.m4-replay-threat-visual__overlay strong,
|
||||
.m4-replay-threat-visual__overlay small {
|
||||
.m4-replay-threat-visual__overlay strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__overlay small {
|
||||
display: -webkit-box;
|
||||
min-height: 1.32rem;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.m4-replay-threat-visual__overlay {
|
||||
width: min(17.5rem, calc(100% - 1.2rem));
|
||||
max-width: calc(100% - 1.2rem);
|
||||
}
|
||||
}
|
||||
@@ -372,3 +385,13 @@
|
||||
background: var(--nodedc-text-muted);
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend span[data-decision="performance"] {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend span[data-decision="performance"]::before {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgb(var(--nodedc-accent-rgb));
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -691,3 +691,18 @@ test("metric evidence keeps one WebGL renderer while frame labels advance", () =
|
||||
/querySelector\("canvas"\)[\s\S]*?setAttribute\("aria-label", label\)[\s\S]*?\}, \[label\]\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("metric evidence renders on demand while preserving damped camera interaction", () => {
|
||||
const source = readFileSync(
|
||||
new URL("../src/components/laboratory/LaboratoryMetricEvidenceScene.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(source, /const requestRenderRef = useRef/);
|
||||
assert.match(source, /controls\.addEventListener\("change", requestRender\)/);
|
||||
assert.match(source, /requestRenderRef\.current\(\)/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/const render = \(\) => \{\s*animationFrame = window\.requestAnimationFrame\(render\)/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let M49PhysicalSafetyPlaybackBuffer;
|
||||
let fetchM49PhysicalSafetyPlaybackIdForSource;
|
||||
|
||||
const resultId = `m49-physical-safety-playback-${"a".repeat(64)}`;
|
||||
const endpoint = `/api/v1/laboratory/m49/physical-safety-playback/${resultId}`;
|
||||
const frameCount = 160;
|
||||
const cellCount = 4;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
M49PhysicalSafetyPlaybackBuffer,
|
||||
fetchM49PhysicalSafetyPlaybackIdForSource,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/core/laboratory/m49PhysicalSafetyPlayback.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function sha256(buffer) {
|
||||
return createHash("sha256").update(Buffer.from(buffer)).digest("hex");
|
||||
}
|
||||
|
||||
function chunk(index) {
|
||||
const start = index * 32;
|
||||
const count = Math.min(32, frameCount - start);
|
||||
const stateBytes = count * cellCount;
|
||||
const zBytes = count * cellCount * 2 * 4;
|
||||
const headerBytes = 28;
|
||||
const padding = (4 - ((headerBytes + stateBytes) % 4)) % 4;
|
||||
const buffer = new ArrayBuffer(headerBytes + stateBytes + padding + zBytes);
|
||||
const bytes = new Uint8Array(buffer);
|
||||
bytes.set(new TextEncoder().encode("MCPSCH01"));
|
||||
const view = new DataView(buffer);
|
||||
view.setUint32(8, start, true);
|
||||
view.setUint32(12, count, true);
|
||||
view.setUint32(16, cellCount, true);
|
||||
view.setUint32(20, stateBytes, true);
|
||||
view.setUint32(24, zBytes, true);
|
||||
const states = new Uint8Array(buffer, headerBytes, stateBytes);
|
||||
states.fill(1);
|
||||
const z = new Float32Array(buffer, headerBytes + stateBytes + padding, count * cellCount * 2);
|
||||
for (let local = 0; local < count; local += 1) {
|
||||
const sequence = start + local;
|
||||
for (let cell = 0; cell < cellCount; cell += 1) {
|
||||
z[(local * cellCount + cell) * 2] = 0;
|
||||
z[(local * cellCount + cell) * 2 + 1] = 0.12;
|
||||
}
|
||||
if (sequence === 17) {
|
||||
states.fill(0, local * cellCount, (local + 1) * cellCount);
|
||||
z.fill(Number.NaN, local * cellCount * 2, (local + 1) * cellCount * 2);
|
||||
}
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const centers = new Float32Array([0, 0, 0.15, 0, 0.3, 0, 0.45, 0]).buffer;
|
||||
const frames = new TextEncoder().encode(Array.from({ length: frameCount }, (_, sequence) => (
|
||||
JSON.stringify({
|
||||
source_frame_index: sequence,
|
||||
session_seconds: sequence / 10,
|
||||
sample_available: sequence !== 17,
|
||||
eligible_point_count: sequence === 17 ? 0 : cellCount,
|
||||
})
|
||||
)).join("\n") + "\n").buffer;
|
||||
const chunks = Array.from({ length: Math.ceil(frameCount / 32) }, (_, index) => chunk(index));
|
||||
const manifest = {
|
||||
schema_version: "missioncore.m49-physical-safety-playback/v1",
|
||||
result_id: resultId,
|
||||
created_at_utc: "2026-08-27T12:00:00Z",
|
||||
access: "read-only-sealed-local",
|
||||
identity: { source_result_id: `m49-tgs-full-shadow-${"b".repeat(64)}` },
|
||||
execution: {
|
||||
execution_class: "local-sequential-offline",
|
||||
worker_role: "realtime-only",
|
||||
worker_runtime_dependency: false,
|
||||
worker_requests_required: 0,
|
||||
},
|
||||
authority: {
|
||||
commands_enabled: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
actuation_accepted: false,
|
||||
},
|
||||
playback: {
|
||||
coordinate_frame: "map-gravity-local",
|
||||
source_pace_hz: 10,
|
||||
frame_count: frameCount,
|
||||
cell_count: cellCount,
|
||||
cell_size_m: 0.15,
|
||||
radius_m: 12,
|
||||
state_codes: {
|
||||
UNOBSERVED: 0,
|
||||
GROUND_SUPPORT: 1,
|
||||
NONGROUND_OCCUPIED: 2,
|
||||
UNKNOWN_REJECTED: 3,
|
||||
},
|
||||
chunk_frame_count: 32,
|
||||
startup_prebuffer_chunk_count: 2,
|
||||
resident_chunk_count_max: 3,
|
||||
forward_prefetch_chunk_count: 1,
|
||||
centers: {
|
||||
url: `${endpoint}/tracks/centers`,
|
||||
media_type: "application/octet-stream",
|
||||
byte_length: centers.byteLength,
|
||||
sha256: sha256(centers),
|
||||
dtype: "<f4",
|
||||
shape: [cellCount, 2],
|
||||
},
|
||||
frames: {
|
||||
url: `${endpoint}/tracks/frames`,
|
||||
media_type: "application/x-ndjson",
|
||||
byte_length: frames.byteLength,
|
||||
sha256: sha256(frames),
|
||||
dtype: "ndjson",
|
||||
shape: [frameCount],
|
||||
},
|
||||
chunks: chunks.map((buffer, index) => ({
|
||||
index,
|
||||
start: index * 32,
|
||||
count: Math.min(32, frameCount - index * 32),
|
||||
url: `${endpoint}/chunks/${index}`,
|
||||
media_type: "application/octet-stream",
|
||||
byte_length: buffer.byteLength,
|
||||
sha256: sha256(buffer),
|
||||
header_bytes: 28,
|
||||
format: "mcpsch01-states-u8-aligned-z-bounds-f32le",
|
||||
})),
|
||||
},
|
||||
};
|
||||
const responses = new Map([
|
||||
[`${endpoint}/tracks/centers`, centers],
|
||||
[`${endpoint}/tracks/frames`, frames],
|
||||
...chunks.map((buffer, index) => [`${endpoint}/chunks/${index}`, buffer]),
|
||||
]);
|
||||
return { manifest, responses };
|
||||
}
|
||||
|
||||
test("physical-safety playback opens and seeks with bounded local chunks only", async () => {
|
||||
const { manifest, responses } = fixture();
|
||||
const requested = [];
|
||||
const progress = [];
|
||||
const fetcher = async (input) => {
|
||||
const url = String(input);
|
||||
requested.push(url);
|
||||
if (url === `${endpoint.replace(`/${resultId}`, "")}/results?limit=2&source_result_id=${manifest.identity.source_result_id}`) {
|
||||
return Response.json({
|
||||
schema_version: "missioncore.m49-physical-safety-playback-catalog/v1",
|
||||
worker_runtime_dependency: false,
|
||||
access: "read-only-sealed-local",
|
||||
items: [{
|
||||
result_id: resultId,
|
||||
source_result_id: manifest.identity.source_result_id,
|
||||
worker_runtime_dependency: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
}],
|
||||
});
|
||||
}
|
||||
if (url === `${endpoint}/manifest`) {
|
||||
return Response.json(manifest);
|
||||
}
|
||||
const payload = responses.get(url);
|
||||
return payload
|
||||
? new Response(payload, { headers: { "Content-Type": "application/octet-stream" } })
|
||||
: new Response(null, { status: 404 });
|
||||
};
|
||||
|
||||
const resolvedResultId = await fetchM49PhysicalSafetyPlaybackIdForSource(
|
||||
manifest.identity.source_result_id,
|
||||
{ fetcher },
|
||||
);
|
||||
assert.equal(resolvedResultId, resultId);
|
||||
const playback = await M49PhysicalSafetyPlaybackBuffer.open(resolvedResultId, {
|
||||
fetcher,
|
||||
onProgress: (value) => progress.push(value),
|
||||
});
|
||||
|
||||
assert.deepEqual(playback.residentChunkIndexes, [0, 1]);
|
||||
assert.equal(progress.at(-1).phase, "ready");
|
||||
assert.equal(requested.some((url) => url.includes("worker")), false);
|
||||
const startupRequestCount = requested.length;
|
||||
await playback.prepare(0);
|
||||
await playback.prepare(0);
|
||||
assert.equal(requested.length, startupRequestCount);
|
||||
const missing = await playback.frame(17);
|
||||
assert.equal(missing.metadata.sampleAvailable, false);
|
||||
assert.deepEqual(new Set(missing.states), new Set([0]));
|
||||
assert.equal([...missing.zBoundsM].every((value) => Number.isNaN(value)), true);
|
||||
|
||||
await playback.prepare(100);
|
||||
assert.equal(playback.residentChunkIndexes.length <= 3, true);
|
||||
assert.equal(playback.residentChunkIndexes.includes(3), true);
|
||||
assert.equal(playback.residentChunkIndexes.includes(4), true);
|
||||
const selected = await playback.frame(100);
|
||||
assert.deepEqual([...selected.states], [1, 1, 1, 1]);
|
||||
const maximumResidentBytes = manifest.playback.centers.byte_length
|
||||
+ manifest.playback.frames.byte_length
|
||||
+ 3 * Math.max(...manifest.playback.chunks.map((value) => value.byte_length));
|
||||
assert.equal(playback.residentByteLength <= maximumResidentBytes, true);
|
||||
|
||||
await playback.prepare(0);
|
||||
assert.equal(playback.residentChunkIndexes.length <= 3, true);
|
||||
assert.equal(playback.residentChunkIndexes.includes(0), true);
|
||||
assert.equal(playback.residentChunkIndexes.includes(1), true);
|
||||
assert.equal(
|
||||
requested.every((url) => url.startsWith("/api/v1/laboratory/m49/physical-safety-playback")),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("physical-safety playback rejects a manifest that points at Worker", async () => {
|
||||
const { manifest } = fixture();
|
||||
manifest.playback.centers.url = "http://worker-006/centers";
|
||||
await assert.rejects(
|
||||
M49PhysicalSafetyPlaybackBuffer.open(resultId, {
|
||||
fetcher: async () => Response.json(manifest),
|
||||
}),
|
||||
/sealed local API/,
|
||||
);
|
||||
});
|
||||
@@ -74,24 +74,53 @@ test("M4.9T5 chunk keeps every missing-LiDAR cell explicitly UNOBSERVED", async
|
||||
assert.deepEqual(new Set(chunk.frames[0].costmap.states), new Set([0]));
|
||||
});
|
||||
|
||||
test("M4.9T5 viewer preloads one immutable binary playback pack", async () => {
|
||||
const [source, contract] = await Promise.all([
|
||||
test("M4.9T5 viewer prefers autonomous chunks and keeps a sealed legacy fallback", async () => {
|
||||
const [source, visual, scene, contract] = await Promise.all([
|
||||
readFile(
|
||||
new URL("../src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
readFile(
|
||||
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
readFile(
|
||||
new URL("../src/components/laboratory/LaboratoryMetricEvidenceScene.tsx", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
readFile(
|
||||
new URL("../src/core/laboratory/m49TgsFullShadow.ts", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
]);
|
||||
assert.match(source, /fetchM49PhysicalSafetyPlaybackIdForSource/);
|
||||
assert.match(source, /M49PhysicalSafetyPlaybackBuffer\.open/);
|
||||
assert.match(source, /physicalPlayback\.frameIfResident/);
|
||||
assert.match(source, /physicalPlayback\.prepare/);
|
||||
assert.match(source, /fetchM49TgsFullShadowPlaybackPack/);
|
||||
assert.match(source, /playbackProgress/);
|
||||
assert.doesNotMatch(source, /fetchM49TgsFullShadowSpatialChunk/);
|
||||
assert.match(source, /sampleAvailable: spatial\.sampleAvailable/);
|
||||
assert.match(source, /sampleAvailable: frame\.sampleAvailable/);
|
||||
assert.match(source, /packedCellsMapGravityLocal/);
|
||||
assert.match(source, /playbackPack\.states\.subarray/);
|
||||
assert.match(source, /playbackPack\.zBoundsM\.subarray/);
|
||||
assert.doesNotMatch(source, /Array\.from\(\s*playbackPack\.states/);
|
||||
assert.doesNotMatch(source, /centersXyM\.map\(/);
|
||||
assert.match(source, /fetchE47SemanticSlamResult/);
|
||||
assert.match(source, /next\.baseM4ResultId !== result\.source\.linkedVisualResultId/);
|
||||
assert.match(source, /semantic=\{semantic \? \{/);
|
||||
assert.match(
|
||||
visual,
|
||||
/classifiedSpatialFrame\s*&&\s*classifiedSpatialFrame\.sampleAvailable !== false/,
|
||||
);
|
||||
assert.doesNotMatch(visual, /classifiedSpatialFrame\?\.sampleAvailable !== false/);
|
||||
assert.match(visual, /timelineFrame\.availableFrames\.find/);
|
||||
assert.match(visual, /<LaboratoryMetricEvidenceScene/);
|
||||
assert.doesNotMatch(
|
||||
visual,
|
||||
/\{\(!classifiedSpatialLayer \? spatialFrame : displayedClassifiedSpatialFrame\) \? \(/,
|
||||
);
|
||||
assert.match(scene, /useEffect\(\(\) => resetView\(\), \[mode, resetView\]\)/);
|
||||
assert.match(contract, /linked_semantic_result_id/);
|
||||
assert.match(contract, /missioncore\.m49-tgs-full-shadow-playback\/v1/);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
@@ -9,6 +10,8 @@ let fetchM4ThreatReplayResult;
|
||||
let fetchM4ThreatVisual;
|
||||
let fetchM4ThreatTimeline;
|
||||
let fetchM4ThreatTimelineChunk;
|
||||
let fetchM4ThreatPlaybackManifest;
|
||||
let fetchM4ThreatPlaybackPointChunk;
|
||||
let fetchM4ThreatCameraPointOverlay;
|
||||
let hydrateM4ThreatTimelineFrame;
|
||||
let selectM4ThreatTimelineFrame;
|
||||
@@ -33,6 +36,8 @@ before(async () => {
|
||||
fetchM4ThreatVisual,
|
||||
fetchM4ThreatTimeline,
|
||||
fetchM4ThreatTimelineChunk,
|
||||
fetchM4ThreatPlaybackManifest,
|
||||
fetchM4ThreatPlaybackPointChunk,
|
||||
fetchM4ThreatCameraPointOverlay,
|
||||
hydrateM4ThreatTimelineFrame,
|
||||
selectM4ThreatTimelineFrame,
|
||||
@@ -373,6 +378,76 @@ test("M4.6 hydrates a lightweight timeline frame from one retained binary point
|
||||
assert.equal(typeof hydrateM4ThreatTimelineFrame, "function");
|
||||
});
|
||||
|
||||
test("M4.6 source cloud opens from one verified bounded chunk instead of the 105 MiB track", async () => {
|
||||
const pointOffsets = [0, ...Array(4489).fill(2)];
|
||||
const points = new Float32Array([11, 20, 30.25, 12, 19.5, 30]);
|
||||
const pointBytes = points.buffer;
|
||||
const pointSha256 = createHash("sha256").update(Buffer.from(pointBytes)).digest("hex");
|
||||
const endpointRoot = "/api/v1/laboratory/m4-threat/results";
|
||||
const chunks = Array.from({ length: 188 }, (_, index) => {
|
||||
const start = index * 24;
|
||||
const count = Math.min(24, 4489 - start);
|
||||
const pointStart = pointOffsets[start];
|
||||
const pointStop = pointOffsets[start + count];
|
||||
const pointCount = pointStop - pointStart;
|
||||
return {
|
||||
index,
|
||||
start,
|
||||
count,
|
||||
point_start: pointStart,
|
||||
point_count: pointCount,
|
||||
url: `${endpointRoot}/${resultId}/timeline/playback/chunks/${index}`,
|
||||
media_type: "application/octet-stream",
|
||||
dtype: "<f4",
|
||||
shape: [pointCount, 3],
|
||||
bytes: pointCount * 3 * 4,
|
||||
sha256: index === 0 ? pointSha256 : "0".repeat(64),
|
||||
};
|
||||
});
|
||||
const manifestPayload = {
|
||||
schema_version: "missioncore.recorded-spatial-playback/v1",
|
||||
result_id: resultId,
|
||||
frame_count: 4489,
|
||||
point_count: 2,
|
||||
point_offsets: pointOffsets,
|
||||
chunk_frame_count: 24,
|
||||
resident_chunk_count_max: 4,
|
||||
forward_prefetch_chunk_count: 1,
|
||||
chunks,
|
||||
track: {
|
||||
id: "points-map-f32",
|
||||
url: `${endpointRoot}/${resultId}/timeline/playback/tracks/points-map-f32`,
|
||||
media_type: "application/octet-stream",
|
||||
dtype: "<f4",
|
||||
shape: [2, 3],
|
||||
bytes: pointBytes.byteLength,
|
||||
sha256: pointSha256,
|
||||
},
|
||||
coordinate_frame: "map",
|
||||
access: "read-only-sealed-binary-playback",
|
||||
};
|
||||
const requested = [];
|
||||
const fetcher = async (input) => {
|
||||
const url = String(input);
|
||||
requested.push(url);
|
||||
if (url.endsWith("/timeline/playback")) return Response.json(manifestPayload);
|
||||
if (url.endsWith("/timeline/playback/chunks/0")) return new Response(pointBytes.slice(0));
|
||||
return new Response(null, { status: 404 });
|
||||
};
|
||||
const manifest = await fetchM4ThreatPlaybackManifest(resultId, { fetcher });
|
||||
const chunk = await fetchM4ThreatPlaybackPointChunk(manifest, 0, { fetcher });
|
||||
|
||||
assert.deepEqual(requested, [
|
||||
`${endpointRoot}/${resultId}/timeline/playback`,
|
||||
`${endpointRoot}/${resultId}/timeline/playback/chunks/0`,
|
||||
]);
|
||||
assert.equal(chunk.pointCount, 2);
|
||||
assert.equal(chunk.pointStart, 0);
|
||||
assert.equal(chunk.sequenceCount, 24);
|
||||
assert.equal(chunk.pointsMapXyzM.byteLength, 24);
|
||||
assert.equal(requested.some((url) => url.endsWith("points-map-f32")), false);
|
||||
});
|
||||
|
||||
test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint", async () => {
|
||||
const replayResultId = `m48s-fixed-class-detector-lab-${"b".repeat(64)}`;
|
||||
const endpointRoot = "/api/v1/laboratory/m48s/fixed-class-detector";
|
||||
@@ -825,10 +900,15 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
||||
assert.match(visualCss, /width: 33\.333333%/);
|
||||
assert.match(visualCss, /flex-flow: column nowrap/);
|
||||
assert.match(visualCss, /m4-replay-threat-visual__overlay > div/);
|
||||
assert.match(visualCss, /grid-template-columns: minmax\(0, max-content\)/);
|
||||
assert.match(visualCss, /width: max-content/);
|
||||
assert.match(visualCss, /grid-template-columns: minmax\(0, 1fr\)/);
|
||||
assert.match(visualCss, /width: min\(17\.5rem/);
|
||||
assert.match(visualCss, /grid-auto-rows: 3\.8rem/);
|
||||
assert.match(visualCss, /--m4-replay-threat-overlay-pane-width/);
|
||||
assert.match(visualCss, /white-space: nowrap/);
|
||||
assert.match(visualCss, /-webkit-line-clamp: 2/);
|
||||
assert.match(visual, /classifiedCellCount/);
|
||||
assert.match(visual, /lastAvailableClassifiedSpatialFrame/);
|
||||
assert.match(visual, /current UNOBSERVED/);
|
||||
assert.match(visualCss, /laboratory-metric-evidence-scene__legend/);
|
||||
assert.match(visualCss, /bottom: auto/);
|
||||
assert.match(videoScene, /<RecordedFmp4Player/);
|
||||
@@ -842,16 +922,18 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
||||
assert.match(visual, /showLocalSurface/);
|
||||
assert.match(
|
||||
visual,
|
||||
/pointCloudBodyXyzM=\{displayedClassifiedSpatialFrame && replaceClassifiedPointCloud[\s\S]*\? classifiedPointsBody[\s\S]*: activeSpatialFrame\?\.pointCloudBodyXyzM \?\? \[\]\}/,
|
||||
/pointCloudBodyXyzM=\{displayedClassifiedSpatialFrame && replaceClassifiedPointCloud[\s\S]*\? classifiedPointsBody[\s\S]*: classifiedContextSpatialFrame\?\.pointCloudBodyXyzM \?\? \[\]\}/,
|
||||
);
|
||||
assert.match(
|
||||
visual,
|
||||
/const classifiedSpatialFrame = classifiedSpatialLayer\?\.frame\?\.sourceSequence === timelineFrame\.activeSequence[\s\S]*const displayedClassifiedSpatialFrame = classifiedSpatialFrame[\s\S]*lastClassifiedSpatialFrameRef/,
|
||||
/const classifiedSpatialFrame = classifiedSpatialLayer\?\.frame\?\.sourceSequence === timelineFrame\.activeSequence[\s\S]*lastClassifiedSpatialFrameRef[\s\S]*const displayedClassifiedSpatialFrame = classifiedSpatialFrame\s*&&\s*classifiedSpatialFrame\.sampleAvailable !== false/,
|
||||
);
|
||||
assert.doesNotMatch(visual, /classifiedSpatialFrame\?\.sampleAvailable !== false/);
|
||||
assert.match(visual, /timelineFrame\.availableFrames\.find/);
|
||||
assert.match(visual, /localSurfaceBodyXyzM=\{localSurface\.pointsBodyXyzM\}/);
|
||||
assert.match(visual, /showLocalSurface=\{showLocalSurface\}/);
|
||||
assert.match(visual, /\{semantic \? \([\s\S]*>\s*SEMANTICS\s*<\/Button>/);
|
||||
assert.match(visual, /все 2 244 TGS-ячейки явно UNOBSERVED/);
|
||||
assert.match(visual, /current safety — все \{classifiedCellCount\.toLocaleString\("ru-RU"\)\} TGS-ячейки UNOBSERVED/);
|
||||
assert.match(
|
||||
visual,
|
||||
/mapGravityLocalSensorToBodyGround[\s\S]*rotated\[2\] \+ nominalSensorHeightM/,
|
||||
|
||||
@@ -173,6 +173,9 @@ test("recorded player keeps full-archive range fallback and uses bounded generat
|
||||
assert.match(source, /pumpRecordedSegmentWindow/);
|
||||
assert.match(source, /waitForRecordedVideoTarget/);
|
||||
assert.match(source, /removeRecordedMediaRange/);
|
||||
assert.match(source, /hasPresentedFrame/);
|
||||
assert.match(source, /retainForwardFrame/);
|
||||
assert.match(source, /candidateTarget\.sequence >= previousTarget\.sequence/);
|
||||
|
||||
assert.equal(recordedMediaDecodeStartSequence([1, 1491, 1501], 1500), 1491);
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -57,7 +57,10 @@ test("metric evidence keeps missing semantic assignments as context and exposes
|
||||
assert.match(source, /recordedEvidenceSemanticCssColor/);
|
||||
assert.match(source, /DynamicDrawUsage/);
|
||||
assert.match(source, /classifiedMeshesRef/);
|
||||
assert.match(source, /classifiedPackedCells/);
|
||||
assert.match(source, /updatePointPositions/);
|
||||
assert.match(source, /renderer\.info\.render\.calls/);
|
||||
assert.match(source, /data-decision="performance"/);
|
||||
});
|
||||
|
||||
test("semantic point alignment follows the last qualified spatial increment", async () => {
|
||||
|
||||
Reference in New Issue
Block a user