Стабилизация синхронного LAB-воспроизведения
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
@@ -31,6 +31,7 @@ export function LaboratoryEvidenceViewer<
|
||||
overlay,
|
||||
transport,
|
||||
trailingActions,
|
||||
modeControlsVisible = true,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
@@ -50,9 +51,22 @@ export function LaboratoryEvidenceViewer<
|
||||
overlay?: ReactNode;
|
||||
transport?: ReactNode;
|
||||
trailingActions?: ReactNode;
|
||||
modeControlsVisible?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const expandButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const viewerRef = useRef<HTMLDialogElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const viewer = viewerRef.current;
|
||||
if (!viewer) return;
|
||||
if (viewer.open) viewer.close();
|
||||
if (expanded) {
|
||||
viewer.showModal();
|
||||
} else {
|
||||
viewer.show();
|
||||
}
|
||||
}, [expanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
@@ -67,12 +81,15 @@ export function LaboratoryEvidenceViewer<
|
||||
}, [expanded, onExpandedChange]);
|
||||
|
||||
const viewer = (
|
||||
<section
|
||||
<dialog
|
||||
ref={viewerRef}
|
||||
role="region"
|
||||
className={[
|
||||
"laboratory-evidence-viewer",
|
||||
className,
|
||||
].filter(Boolean).join(" ")}
|
||||
data-expanded={expanded ? "true" : undefined}
|
||||
data-mode-controls={modeControlsVisible ? undefined : "content"}
|
||||
aria-label={label}
|
||||
>
|
||||
<div className="laboratory-evidence-viewer__stage">
|
||||
@@ -86,7 +103,7 @@ export function LaboratoryEvidenceViewer<
|
||||
) : null}
|
||||
<div className="laboratory-evidence-viewer__controls">
|
||||
{actions}
|
||||
{secondaryMode ? (
|
||||
{modeControlsVisible && secondaryMode ? (
|
||||
<SegmentedControl
|
||||
value={secondaryMode.value}
|
||||
items={[...secondaryMode.modes]}
|
||||
@@ -94,12 +111,14 @@ export function LaboratoryEvidenceViewer<
|
||||
onChange={secondaryMode.onChange}
|
||||
/>
|
||||
) : null}
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
items={[...modes]}
|
||||
label={`${label}: режим представления`}
|
||||
onChange={onModeChange}
|
||||
/>
|
||||
{modeControlsVisible ? (
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
items={[...modes]}
|
||||
label={`${label}: режим представления`}
|
||||
onChange={onModeChange}
|
||||
/>
|
||||
) : null}
|
||||
{trailingActions}
|
||||
<IconButton
|
||||
ref={expandButtonRef}
|
||||
@@ -109,8 +128,8 @@ export function LaboratoryEvidenceViewer<
|
||||
<Icon name={expanded ? "minimize" : "expand"} size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
);
|
||||
|
||||
return expanded ? createPortal(viewer, document.body) : viewer;
|
||||
return viewer;
|
||||
}
|
||||
|
||||
+335
-84
@@ -34,6 +34,7 @@ export interface RecordedEvidenceSemanticPaletteEntry {
|
||||
|
||||
export interface RecordedEvidenceSemanticOverlay {
|
||||
src: string;
|
||||
prefetchSrcs?: readonly string[];
|
||||
classes: readonly RecordedEvidenceSemanticClass[];
|
||||
palette: readonly RecordedEvidenceSemanticPaletteEntry[];
|
||||
opacity?: number;
|
||||
@@ -44,7 +45,8 @@ interface DecodedSemanticMask {
|
||||
key: string;
|
||||
width: number;
|
||||
height: number;
|
||||
classIds: Uint8Array;
|
||||
source: ImageBitmap | HTMLImageElement;
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
interface PendingSemanticMask {
|
||||
@@ -53,7 +55,8 @@ interface PendingSemanticMask {
|
||||
promise: Promise<DecodedSemanticMask>;
|
||||
}
|
||||
|
||||
const MASK_CACHE_LIMIT = 48;
|
||||
const MASK_CACHE_LIMIT = 20;
|
||||
const MASK_PREFETCH_LIMIT = 12;
|
||||
const decodedMaskCache = new Map<string, DecodedSemanticMask>();
|
||||
const pendingMaskCache = new Map<string, PendingSemanticMask>();
|
||||
|
||||
@@ -79,19 +82,23 @@ function semanticMaskKey(src: string, width: number, height: number): string {
|
||||
}
|
||||
|
||||
function rememberMask(mask: DecodedSemanticMask): void {
|
||||
const previous = decodedMaskCache.get(mask.key);
|
||||
if (previous && previous !== mask) previous.release();
|
||||
decodedMaskCache.delete(mask.key);
|
||||
decodedMaskCache.set(mask.key, mask);
|
||||
while (decodedMaskCache.size > MASK_CACHE_LIMIT) {
|
||||
const oldest = decodedMaskCache.keys().next().value;
|
||||
if (typeof oldest !== "string") break;
|
||||
const evicted = decodedMaskCache.get(oldest);
|
||||
decodedMaskCache.delete(oldest);
|
||||
evicted?.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function imageSourceFromBlob(
|
||||
blob: Blob,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ source: CanvasImageSource; width: number; height: number; release: () => void }> {
|
||||
): Promise<{ source: ImageBitmap | HTMLImageElement; width: number; height: number; release: () => void }> {
|
||||
if (typeof createImageBitmap === "function") {
|
||||
const bitmap = await createImageBitmap(blob, {
|
||||
colorSpaceConversion: "none",
|
||||
@@ -160,31 +167,19 @@ async function decodeSemanticMask(
|
||||
const blob = await response.blob();
|
||||
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
const decoded = await imageSourceFromBlob(blob, signal);
|
||||
try {
|
||||
if (decoded.width !== expectedWidth || decoded.height !== expectedHeight) {
|
||||
throw new Error(
|
||||
`Semantic mask dimensions ${decoded.width}x${decoded.height} do not match ${expectedWidth}x${expectedHeight}`,
|
||||
);
|
||||
}
|
||||
const decodeCanvas = document.createElement("canvas");
|
||||
decodeCanvas.width = decoded.width;
|
||||
decodeCanvas.height = decoded.height;
|
||||
const context = decodeCanvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("Semantic mask canvas is unavailable");
|
||||
context.drawImage(decoded.source, 0, 0);
|
||||
const rgba = context.getImageData(0, 0, decoded.width, decoded.height).data;
|
||||
const classIds = new Uint8Array(decoded.width * decoded.height);
|
||||
for (let sourceOffset = 0, targetOffset = 0; targetOffset < classIds.length; sourceOffset += 4, targetOffset += 1) {
|
||||
const classId = rgba[sourceOffset] ?? 0;
|
||||
if (rgba[sourceOffset + 1] !== classId || rgba[sourceOffset + 2] !== classId) {
|
||||
throw new Error("Semantic mask must be an 8-bit grayscale class-id PNG");
|
||||
}
|
||||
classIds[targetOffset] = classId;
|
||||
}
|
||||
return { key, width: decoded.width, height: decoded.height, classIds };
|
||||
} finally {
|
||||
if (decoded.width !== expectedWidth || decoded.height !== expectedHeight) {
|
||||
decoded.release();
|
||||
throw new Error(
|
||||
`Semantic mask dimensions ${decoded.width}x${decoded.height} do not match ${expectedWidth}x${expectedHeight}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
key,
|
||||
width: decoded.width,
|
||||
height: decoded.height,
|
||||
source: decoded.source,
|
||||
release: decoded.release,
|
||||
};
|
||||
}
|
||||
|
||||
function subscribeToSemanticMask(
|
||||
@@ -257,8 +252,252 @@ export function resolveRecordedEvidenceSemanticRgb(
|
||||
: TOKEN_FALLBACKS[color.token];
|
||||
}
|
||||
|
||||
interface SemanticMaskRenderer {
|
||||
clear: (width: number, height: number, pixelRatio: number) => void;
|
||||
render: (
|
||||
mask: DecodedSemanticMask,
|
||||
palette: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
pixelRatio: number,
|
||||
) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
function compileSemanticShader(
|
||||
gl: WebGLRenderingContext,
|
||||
type: number,
|
||||
source: string,
|
||||
): WebGLShader | null {
|
||||
const shader = gl.createShader(type);
|
||||
if (!shader) return null;
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
|
||||
function createSemanticMaskRenderer(canvas: HTMLCanvasElement): SemanticMaskRenderer | null {
|
||||
const gl = canvas.getContext("webgl", {
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
depth: false,
|
||||
premultipliedAlpha: false,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
if (!gl) return null;
|
||||
const vertexShader = compileSemanticShader(gl, gl.VERTEX_SHADER, `
|
||||
attribute vec2 a_position;
|
||||
attribute vec2 a_uv;
|
||||
varying vec2 v_uv;
|
||||
void main() {
|
||||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||
v_uv = a_uv;
|
||||
}
|
||||
`);
|
||||
const fragmentShader = compileSemanticShader(gl, gl.FRAGMENT_SHADER, `
|
||||
precision mediump float;
|
||||
uniform sampler2D u_mask;
|
||||
uniform sampler2D u_palette;
|
||||
varying vec2 v_uv;
|
||||
void main() {
|
||||
// DOM image sources use a top-left origin while WebGL texture coordinates
|
||||
// use a bottom-left origin. UNPACK_FLIP_Y_WEBGL is ignored for
|
||||
// ImageBitmap uploads, so keep upload semantics stable and flip V here.
|
||||
vec2 mask_uv = vec2(v_uv.x, 1.0 - v_uv.y);
|
||||
float class_id = floor(texture2D(u_mask, mask_uv).r * 255.0 + 0.5);
|
||||
gl_FragColor = texture2D(u_palette, vec2((class_id + 0.5) / 256.0, 0.5));
|
||||
}
|
||||
`);
|
||||
if (!vertexShader || !fragmentShader) {
|
||||
if (vertexShader) gl.deleteShader(vertexShader);
|
||||
if (fragmentShader) gl.deleteShader(fragmentShader);
|
||||
return null;
|
||||
}
|
||||
const program = gl.createProgram();
|
||||
if (!program) {
|
||||
gl.deleteShader(vertexShader);
|
||||
gl.deleteShader(fragmentShader);
|
||||
return null;
|
||||
}
|
||||
gl.attachShader(program, vertexShader);
|
||||
gl.attachShader(program, fragmentShader);
|
||||
gl.linkProgram(program);
|
||||
gl.deleteShader(vertexShader);
|
||||
gl.deleteShader(fragmentShader);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
gl.deleteProgram(program);
|
||||
return null;
|
||||
}
|
||||
const buffer = gl.createBuffer();
|
||||
const maskTexture = gl.createTexture();
|
||||
const paletteTexture = gl.createTexture();
|
||||
if (!buffer || !maskTexture || !paletteTexture) {
|
||||
if (buffer) gl.deleteBuffer(buffer);
|
||||
if (maskTexture) gl.deleteTexture(maskTexture);
|
||||
if (paletteTexture) gl.deleteTexture(paletteTexture);
|
||||
gl.deleteProgram(program);
|
||||
return null;
|
||||
}
|
||||
const positionLocation = gl.getAttribLocation(program, "a_position");
|
||||
const uvLocation = gl.getAttribLocation(program, "a_uv");
|
||||
const maskLocation = gl.getUniformLocation(program, "u_mask");
|
||||
const paletteLocation = gl.getUniformLocation(program, "u_palette");
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||||
-1, -1, 0, 0,
|
||||
1, -1, 1, 0,
|
||||
-1, 1, 0, 1,
|
||||
1, 1, 1, 1,
|
||||
]), gl.STATIC_DRAW);
|
||||
|
||||
const resize = (width: number, height: number, pixelRatio: number) => {
|
||||
const pixelWidth = Math.max(1, Math.round(width * pixelRatio));
|
||||
const pixelHeight = Math.max(1, Math.round(height * pixelRatio));
|
||||
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
|
||||
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
};
|
||||
|
||||
const clear = (width: number, height: number, pixelRatio: number) => {
|
||||
resize(width, height, pixelRatio);
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
};
|
||||
|
||||
return {
|
||||
clear,
|
||||
render: (mask, palette, width, height, pixelRatio) => {
|
||||
if (gl.isContextLost()) throw new Error("Semantic WebGL context lost");
|
||||
clear(width, height, pixelRatio);
|
||||
gl.useProgram(program);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.enableVertexAttribArray(positionLocation);
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 16, 0);
|
||||
gl.enableVertexAttribArray(uvLocation);
|
||||
gl.vertexAttribPointer(uvLocation, 2, gl.FLOAT, false, 16, 8);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, maskTexture);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, mask.source);
|
||||
gl.uniform1i(maskLocation, 0);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, paletteTexture);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, palette);
|
||||
gl.uniform1i(paletteLocation, 1);
|
||||
|
||||
const scale = Math.min(width / mask.width, height / mask.height);
|
||||
const drawWidth = mask.width * scale;
|
||||
const drawHeight = mask.height * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
gl.viewport(
|
||||
Math.round(offsetX * pixelRatio),
|
||||
Math.round(offsetY * pixelRatio),
|
||||
Math.max(1, Math.round(drawWidth * pixelRatio)),
|
||||
Math.max(1, Math.round(drawHeight * pixelRatio)),
|
||||
);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
},
|
||||
dispose: () => {
|
||||
gl.deleteTexture(maskTexture);
|
||||
gl.deleteTexture(paletteTexture);
|
||||
gl.deleteBuffer(buffer);
|
||||
gl.deleteProgram(program);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function semanticPalettePixels(
|
||||
host: HTMLElement,
|
||||
classes: readonly RecordedEvidenceSemanticClass[],
|
||||
palette: readonly RecordedEvidenceSemanticPaletteEntry[],
|
||||
opacity: number,
|
||||
): Uint8Array {
|
||||
const pixels = new Uint8Array(256 * 4);
|
||||
const declaredClassIds = new Set(
|
||||
classes
|
||||
.map((item) => item.id)
|
||||
.filter((classId) => Number.isInteger(classId) && classId >= 0 && classId <= 255),
|
||||
);
|
||||
for (const entry of palette) {
|
||||
if (!declaredClassIds.has(entry.classId)) continue;
|
||||
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
|
||||
if (!rgb) continue;
|
||||
const offset = entry.classId * 4;
|
||||
pixels[offset] = rgb[0];
|
||||
pixels[offset + 1] = rgb[1];
|
||||
pixels[offset + 2] = rgb[2];
|
||||
pixels[offset + 3] = Math.round(
|
||||
clampOpacity(entry.opacity, 1) * clampOpacity(opacity, 0.46) * 255,
|
||||
);
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
function renderSemanticMask2d(
|
||||
canvas: HTMLCanvasElement,
|
||||
mask: DecodedSemanticMask,
|
||||
palette: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
pixelRatio: number,
|
||||
) {
|
||||
canvas.width = Math.max(1, Math.round(width * pixelRatio));
|
||||
canvas.height = Math.max(1, Math.round(height * pixelRatio));
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
const colorCanvas = document.createElement("canvas");
|
||||
colorCanvas.width = mask.width;
|
||||
colorCanvas.height = mask.height;
|
||||
const colorContext = colorCanvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!colorContext) return;
|
||||
colorContext.drawImage(mask.source, 0, 0);
|
||||
const imageData = colorContext.getImageData(0, 0, mask.width, mask.height);
|
||||
for (let offset = 0; offset < imageData.data.length; offset += 4) {
|
||||
const paletteOffset = (imageData.data[offset] ?? 0) * 4;
|
||||
imageData.data[offset] = palette[paletteOffset] ?? 0;
|
||||
imageData.data[offset + 1] = palette[paletteOffset + 1] ?? 0;
|
||||
imageData.data[offset + 2] = palette[paletteOffset + 2] ?? 0;
|
||||
imageData.data[offset + 3] = palette[paletteOffset + 3] ?? 0;
|
||||
}
|
||||
colorContext.putImageData(imageData, 0, 0);
|
||||
const scale = Math.min(width / mask.width, height / mask.height);
|
||||
const drawWidth = mask.width * scale;
|
||||
const drawHeight = mask.height * scale;
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.drawImage(
|
||||
colorCanvas,
|
||||
(width - drawWidth) / 2,
|
||||
(height - drawHeight) / 2,
|
||||
drawWidth,
|
||||
drawHeight,
|
||||
);
|
||||
}
|
||||
|
||||
export function RecordedEvidenceSemanticMaskOverlay({
|
||||
src,
|
||||
prefetchSrcs = [],
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
classes,
|
||||
@@ -270,9 +509,12 @@ export function RecordedEvidenceSemanticMaskOverlay({
|
||||
imageHeight: number;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const rendererRef = useRef<SemanticMaskRenderer | null | undefined>(undefined);
|
||||
const [rendererMode, setRendererMode] = useState<"webgl" | "2d">("webgl");
|
||||
const [mask, setMask] = useState<DecodedSemanticMask | null>(null);
|
||||
const [failure, setFailure] = useState<string | null>(null);
|
||||
const expectedKey = semanticMaskKey(src, imageWidth, imageHeight);
|
||||
const prefetchSignature = prefetchSrcs.join("\n");
|
||||
const renderMask = failure
|
||||
? null
|
||||
: mask?.key === expectedKey
|
||||
@@ -285,14 +527,6 @@ export function RecordedEvidenceSemanticMaskOverlay({
|
||||
let current = true;
|
||||
void subscription.promise.then((decoded) => {
|
||||
if (!current || decoded.key !== expectedKey) return;
|
||||
const declaredClassIds = new Set(classes.map((item) => item.id));
|
||||
const undeclaredClassId = decoded.classIds.find(
|
||||
(classId) => !declaredClassIds.has(classId),
|
||||
);
|
||||
if (undeclaredClassId !== undefined) {
|
||||
setFailure(`Semantic mask содержит необъявленный class ID ${undeclaredClassId}.`);
|
||||
return;
|
||||
}
|
||||
setMask(decoded);
|
||||
}).catch((error: unknown) => {
|
||||
if (!current || (error instanceof DOMException && error.name === "AbortError")) return;
|
||||
@@ -302,83 +536,100 @@ export function RecordedEvidenceSemanticMaskOverlay({
|
||||
current = false;
|
||||
subscription.release();
|
||||
};
|
||||
}, [classes, expectedKey, imageHeight, imageWidth, src]);
|
||||
}, [expectedKey, imageHeight, imageWidth, src]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscriptions = prefetchSignature
|
||||
.split("\n")
|
||||
.filter((candidate) => candidate && candidate !== src)
|
||||
.slice(0, MASK_PREFETCH_LIMIT)
|
||||
.map((candidate) => subscribeToSemanticMask(candidate, imageWidth, imageHeight));
|
||||
for (const subscription of subscriptions) {
|
||||
void subscription.promise
|
||||
.catch(() => undefined)
|
||||
.finally(subscription.release);
|
||||
}
|
||||
}, [imageHeight, imageWidth, prefetchSignature, src]);
|
||||
|
||||
useEffect(() => () => {
|
||||
rendererRef.current?.dispose();
|
||||
rendererRef.current = undefined;
|
||||
}, [rendererMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || rendererMode !== "webgl") return;
|
||||
const onContextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
setRendererMode("2d");
|
||||
};
|
||||
canvas.addEventListener("webglcontextlost", onContextLost);
|
||||
return () => canvas.removeEventListener("webglcontextlost", onContextLost);
|
||||
}, [rendererMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const host = canvas?.parentElement;
|
||||
if (!canvas || !host) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * pixelRatio);
|
||||
canvas.height = Math.round(height * pixelRatio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
if (!renderMask) return;
|
||||
|
||||
const declaredClassIds = new Set(
|
||||
classes
|
||||
.map((item) => item.id)
|
||||
.filter((classId) => Number.isInteger(classId) && classId >= 0 && classId <= 255),
|
||||
);
|
||||
const resolvedPalette = new Map<number, { rgb: readonly [number, number, number]; alpha: number }>();
|
||||
for (const entry of palette) {
|
||||
if (!declaredClassIds.has(entry.classId)) continue;
|
||||
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
|
||||
if (!rgb) continue;
|
||||
resolvedPalette.set(entry.classId, {
|
||||
rgb,
|
||||
alpha: clampOpacity(entry.opacity, 1) * clampOpacity(opacity, 0.46),
|
||||
});
|
||||
if (rendererMode === "webgl" && rendererRef.current === undefined) {
|
||||
rendererRef.current = createSemanticMaskRenderer(canvas);
|
||||
if (!rendererRef.current) {
|
||||
setRendererMode("2d");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const colorCanvas = document.createElement("canvas");
|
||||
colorCanvas.width = renderMask.width;
|
||||
colorCanvas.height = renderMask.height;
|
||||
const colorContext = colorCanvas.getContext("2d");
|
||||
if (!colorContext) return;
|
||||
const imageData = colorContext.createImageData(renderMask.width, renderMask.height);
|
||||
for (let sourceOffset = 0, targetOffset = 0; sourceOffset < renderMask.classIds.length; sourceOffset += 1, targetOffset += 4) {
|
||||
const color = resolvedPalette.get(renderMask.classIds[sourceOffset] ?? -1);
|
||||
if (!color) continue;
|
||||
imageData.data[targetOffset] = color.rgb[0];
|
||||
imageData.data[targetOffset + 1] = color.rgb[1];
|
||||
imageData.data[targetOffset + 2] = color.rgb[2];
|
||||
imageData.data[targetOffset + 3] = Math.round(color.alpha * 255);
|
||||
const renderer = rendererMode === "webgl" ? rendererRef.current : null;
|
||||
if (!renderMask) {
|
||||
if (renderer) {
|
||||
renderer.clear(width, height, pixelRatio);
|
||||
} else {
|
||||
canvas.width = Math.max(1, Math.round(width * pixelRatio));
|
||||
canvas.height = Math.max(1, Math.round(height * pixelRatio));
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const resolvedPalette = semanticPalettePixels(host, classes, palette, opacity);
|
||||
if (renderer) {
|
||||
try {
|
||||
renderer.render(renderMask, resolvedPalette, width, height, pixelRatio);
|
||||
} catch {
|
||||
setRendererMode("2d");
|
||||
}
|
||||
} else {
|
||||
renderSemanticMask2d(
|
||||
canvas,
|
||||
renderMask,
|
||||
resolvedPalette,
|
||||
width,
|
||||
height,
|
||||
pixelRatio,
|
||||
);
|
||||
}
|
||||
colorContext.putImageData(imageData, 0, 0);
|
||||
|
||||
const scale = Math.min(width / imageWidth, height / imageHeight);
|
||||
const drawWidth = imageWidth * scale;
|
||||
const drawHeight = imageHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.drawImage(colorCanvas, offsetX, offsetY, drawWidth, drawHeight);
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [classes, expectedKey, imageHeight, imageWidth, opacity, palette, renderMask]);
|
||||
}, [classes, expectedKey, imageHeight, imageWidth, opacity, palette, rendererMode, renderMask]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas
|
||||
key={rendererMode}
|
||||
ref={canvasRef}
|
||||
className="recorded-evidence-semantic-mask-overlay"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={!failure && !renderMask}
|
||||
data-state={failure ? "error" : renderMask ? "ready" : "loading"}
|
||||
data-renderer={rendererMode}
|
||||
style={{ zIndex: 1 }}
|
||||
/>
|
||||
{failure ? (
|
||||
|
||||
@@ -431,13 +431,22 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: var(--nodedc-radius-option);
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer::backdrop {
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer__stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -12,29 +12,137 @@
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck,
|
||||
.m4-replay-threat-visual__layer {
|
||||
.m4-replay-threat-visual__deck {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck[data-split="true"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck[data-empty="true"] > .l3-visual-audit__state {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck > .nodedc-split-pane,
|
||||
.m4-replay-threat-visual__deck > .nodedc-split-pane > .nodedc-split-pane__panel,
|
||||
.m4-replay-threat-visual__deck > .nodedc-split-pane > .nodedc-split-pane__panel > .m4-replay-threat-visual__pane,
|
||||
.m4-replay-threat-visual__media-layer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
.m4-replay-threat-visual__media-layer > * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane > .recorded-evidence-video-scene,
|
||||
.m4-replay-threat-visual__pane > .recorded-evidence-image-scene,
|
||||
.m4-replay-threat-visual__pane > .laboratory-metric-evidence-scene,
|
||||
.m4-replay-threat-visual__pane > .l3-visual-audit__state {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0.6rem;
|
||||
right: 0.6rem;
|
||||
left: 0.6rem;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer[data-active="true"] {
|
||||
z-index: 1;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"] {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar > *,
|
||||
.m4-replay-threat-visual__spatial-toolbar-end > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__spatial-toolbar-end {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__spatial-toolbar-end::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-mode-controls {
|
||||
flex: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-status {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 4.2rem;
|
||||
right: 0.6rem;
|
||||
left: calc(33.333333% + 1.2rem);
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-floating-surface);
|
||||
padding: 0.42rem 0.58rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.54rem;
|
||||
text-align: left;
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer
|
||||
.m4-replay-threat-visual__pane[data-pane="spatial"]
|
||||
.laboratory-metric-evidence-scene__legend {
|
||||
box-sizing: border-box;
|
||||
top: 4.2rem;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
left: 0.6rem;
|
||||
width: 33.333333%;
|
||||
max-height: calc(100% - 8.4rem);
|
||||
flex-flow: column nowrap;
|
||||
align-items: flex-start;
|
||||
gap: 0.3rem;
|
||||
overflow-y: auto;
|
||||
background: color-mix(in srgb, var(--nodedc-floating-surface) 80%, transparent);
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__buffering {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
@@ -59,16 +167,42 @@
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"] .laboratory-evidence-viewer__controls,
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"] .l3-visual-audit__actions {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"] .laboratory-evidence-viewer__controls > .nodedc-icon-button {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer .l3-visual-audit__actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer-controls {
|
||||
.m4-replay-threat-visual__single-pane-controls {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer-controls .nodedc-segmented__item {
|
||||
padding-inline: 0.3rem;
|
||||
.m4-replay-threat-visual__pane-layer-controls,
|
||||
.m4-replay-threat-visual__single-pane-controls {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__single-pane-controls,
|
||||
.m4-replay-threat-visual__single-pane-controls > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-layer-controls::-webkit-scrollbar,
|
||||
.m4-replay-threat-visual__single-pane-controls::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer .laboratory-evidence-viewer__transport {
|
||||
@@ -83,6 +217,21 @@
|
||||
grid-template-columns: auto auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__overlay {
|
||||
align-items: stretch;
|
||||
gap: 0.35rem;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__overlay > div {
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: color-mix(in srgb, var(--nodedc-floating-surface) 80%, transparent);
|
||||
padding: 0.55rem 0.65rem;
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
buildLaboratoryCatalog,
|
||||
buildLaboratoryProfiles,
|
||||
experimentOptionsForProfile,
|
||||
freshestLaboratorySelection,
|
||||
workOptionsForExperiment,
|
||||
} from "./laboratoryArchiveProfiles";
|
||||
import {
|
||||
@@ -481,7 +482,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
const [workId, setWorkId] = useState<LaboratoryWorkId>(
|
||||
"l34-right-yolox-truth-island-freeze",
|
||||
);
|
||||
const initialWorkSelectedRef = useRef(false);
|
||||
const initialSelectionAppliedRef = useRef(false);
|
||||
const [e28Model, setE28Model] = useState<LidarLocalSurfaceModel | null>(null);
|
||||
const [e29Result, setE29Result] = useState<E29EvidenceResult | null>(null);
|
||||
const [e30Result, setE30Result] = useState<E30ReviewResult | null>(null);
|
||||
@@ -656,6 +657,15 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
|| sessions.state === "idle"
|
||||
|| sessions.state === "loading"
|
||||
) return;
|
||||
if (!initialSelectionAppliedRef.current) {
|
||||
const freshest = freshestLaboratorySelection(catalog);
|
||||
if (!freshest) return;
|
||||
initialSelectionAppliedRef.current = true;
|
||||
setProfileId(freshest.profileId);
|
||||
setExperimentId(freshest.experimentId);
|
||||
setWorkId(freshest.workId);
|
||||
return;
|
||||
}
|
||||
if (!profiles.some((profile) => profile.id === profileId)) {
|
||||
const firstProfile = profiles[0];
|
||||
if (!firstProfile) return;
|
||||
@@ -667,13 +677,6 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
if (firstExperiment) setExperimentId(firstExperiment.id);
|
||||
return;
|
||||
}
|
||||
if (!initialWorkSelectedRef.current) {
|
||||
const freshestWork = workOptions[0];
|
||||
if (!freshestWork) return;
|
||||
setWorkId(freshestWork.id);
|
||||
initialWorkSelectedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (!workOptions.some((work) => work.id === workId)) {
|
||||
const firstWork = workOptions[0];
|
||||
if (firstWork) setWorkId(firstWork.id);
|
||||
@@ -681,6 +684,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
}, [
|
||||
evidenceLoading,
|
||||
advanced.indexLoading,
|
||||
catalog,
|
||||
experimentId,
|
||||
experimentOptions,
|
||||
profileId,
|
||||
@@ -692,7 +696,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
|
||||
const selectProfile = (next: LaboratoryProfileId) => {
|
||||
setProfileId(next);
|
||||
initialWorkSelectedRef.current = true;
|
||||
initialSelectionAppliedRef.current = true;
|
||||
const firstExperiment = experimentOptionsForProfile(next, catalog)[0];
|
||||
if (!firstExperiment) return;
|
||||
setExperimentId(firstExperiment.id);
|
||||
@@ -702,7 +706,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
|
||||
const selectExperiment = (next: LaboratoryExperimentId) => {
|
||||
setExperimentId(next);
|
||||
initialWorkSelectedRef.current = true;
|
||||
initialSelectionAppliedRef.current = true;
|
||||
const firstWork = workOptionsForExperiment(profileId, next, catalog)[0];
|
||||
if (firstWork) selectWork(firstWork.id);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon, IconButton } from "@nodedc/ui-react";
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
IconButton,
|
||||
SegmentedControl,
|
||||
SplitPane,
|
||||
type SplitPaneOrientation,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { ObservationTimeline } from "../../components/ObservationTimeline";
|
||||
import {
|
||||
@@ -22,6 +29,7 @@ import { useRecordedEvidencePlayback } from "../../components/laboratory/useReco
|
||||
import {
|
||||
e47SemanticMaskUrl,
|
||||
type E47SemanticClass,
|
||||
type E47SemanticTimelineFrame,
|
||||
} from "../../core/laboratory/e47SemanticSlam";
|
||||
import type {
|
||||
M4ThreatCameraProposal,
|
||||
@@ -37,7 +45,9 @@ import {
|
||||
} from "./useM4ThreatTimeline";
|
||||
import { useE47SemanticTimelineFrame } from "./useE47SemanticTimeline";
|
||||
|
||||
type M4ThreatViewMode = "video" | "camera" | LaboratoryMetricSceneMode;
|
||||
type M4ThreatMediaMode = "video" | "camera";
|
||||
type M4ThreatMediaSelection = M4ThreatMediaMode | "none";
|
||||
type M4ThreatSpatialSelection = LaboratoryMetricSceneMode | "none";
|
||||
|
||||
function toneForProposal(proposal: M4ThreatCameraProposal): RecordedEvidenceBox["tone"] {
|
||||
if (proposal.threatDecision === "threat") return "danger";
|
||||
@@ -87,12 +97,19 @@ export function M4ReplayThreatVisual({
|
||||
resultId: string;
|
||||
semantic?: M4ReplayThreatSemanticLayer;
|
||||
}) {
|
||||
const [mode, setMode] = useState<M4ThreatViewMode>("video");
|
||||
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode>("3d");
|
||||
const [mediaMode, setMediaMode] = useState<M4ThreatMediaMode | null>("video");
|
||||
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode | null>(null);
|
||||
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
||||
const [showLocalSurface, setShowLocalSurface] = useState(true);
|
||||
const [showRollingMap, setShowRollingMap] = useState(true);
|
||||
const [showSemantic, setShowSemantic] = useState(true);
|
||||
const [showMediaSemantic, setShowMediaSemantic] = useState(true);
|
||||
const [showSpatialSemantic, setShowSpatialSemantic] = useState(true);
|
||||
const [splitPrimarySize, setSplitPrimarySize] = useState(50);
|
||||
const [splitOrientation, setSplitOrientation] = useState<SplitPaneOrientation>(() => (
|
||||
typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches
|
||||
? "horizontal"
|
||||
: "vertical"
|
||||
));
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
|
||||
const metadata = useM4ThreatTimelineMetadata(resultId);
|
||||
@@ -115,6 +132,14 @@ export function M4ReplayThreatVisual({
|
||||
setVideoError(null);
|
||||
}, [resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(max-width: 900px)");
|
||||
const update = () => setSplitOrientation(query.matches ? "horizontal" : "vertical");
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timeline = metadata.timeline;
|
||||
if (!timeline || videoSource) return;
|
||||
@@ -162,6 +187,18 @@ export function M4ReplayThreatVisual({
|
||||
}, [resultId]);
|
||||
if (timelineFrame.activeFrame) lastFrameRef.current = timelineFrame.activeFrame;
|
||||
const frame = timelineFrame.activeFrame ?? lastFrameRef.current;
|
||||
const lastSpatialFrameRef = useRef<{
|
||||
resultId: string;
|
||||
frame: M4ThreatTimelineFrame;
|
||||
} | null>(null);
|
||||
if (frame?.spatialAvailable) {
|
||||
lastSpatialFrameRef.current = { resultId, frame };
|
||||
}
|
||||
const spatialFrame = frame?.spatialAvailable
|
||||
? frame
|
||||
: lastSpatialFrameRef.current?.resultId === resultId
|
||||
? lastSpatialFrameRef.current.frame
|
||||
: null;
|
||||
const semanticTimeline = useE47SemanticTimelineFrame({
|
||||
resultId: semantic?.resultId ?? null,
|
||||
activeSequence: frame?.sequence ?? timelineFrame.activeSequence,
|
||||
@@ -174,33 +211,6 @@ export function M4ReplayThreatVisual({
|
||||
&& frame.sequence !== timelineFrame.activeSequence,
|
||||
);
|
||||
const activeBoxes = useMemo(() => boxes(frame?.cameraProposals ?? []), [frame]);
|
||||
const sceneObstacles = useMemo(() => frame?.metricObstacles.map((obstacle) => ({
|
||||
id: obstacle.componentId,
|
||||
decision: obstacle.assessment.decision,
|
||||
state: obstacle.state,
|
||||
centroidBodyXyzM: obstacle.centroidBodyXyzM,
|
||||
cellCentersBodyXyzM: obstacle.cellCentersBodyXyzM,
|
||||
})) ?? [], [frame]);
|
||||
const currentIncrementObstacles = frame?.metricObstacles.filter(
|
||||
(item) => item.state === "current",
|
||||
) ?? [];
|
||||
const rollingMapObstacles = frame?.metricObstacles.filter(
|
||||
(item) => item.state === "retained",
|
||||
) ?? [];
|
||||
const nearest = frame?.metricObstacles
|
||||
.map((item) => item.assessment.closestApproachM)
|
||||
.filter((value): value is number => value !== null)
|
||||
.sort((left, right) => left - right)[0] ?? null;
|
||||
const localSurface = useMemo(() => buildM4LocalSurface(
|
||||
timelineFrame.availableFrames,
|
||||
frame,
|
||||
metadata.timeline?.localSurfaceVisualization ?? {
|
||||
windowSeconds: 2,
|
||||
voxelSizeM: 0.1,
|
||||
radiusM: 12,
|
||||
pointLimit: 20_000,
|
||||
},
|
||||
), [frame, metadata.timeline, timelineFrame.availableFrames]);
|
||||
const semanticClasses = useMemo<readonly RecordedEvidenceSemanticClass[]>(
|
||||
() => semantic?.taxonomy.map((item) => ({
|
||||
id: item.classId,
|
||||
@@ -214,50 +224,95 @@ export function M4ReplayThreatVisual({
|
||||
color: item.disposition === "ambiguous"
|
||||
? { kind: "token" as const, token: "--nodedc-warning-rgb" as const }
|
||||
: { kind: "diagnostic" as const, rgb: item.colorRgb },
|
||||
opacity: item.disposition === "ambiguous" ? 0.22 : 0.56,
|
||||
opacity: item.disposition === "ambiguous" ? 0.52 : 0.92,
|
||||
})) ?? [],
|
||||
[semantic?.taxonomy],
|
||||
);
|
||||
const semanticFrame = semanticTimeline.activeFrame?.sequence === frame?.sequence
|
||||
? semanticTimeline.activeFrame
|
||||
: null;
|
||||
const semanticIntegrityError = semantic && frame?.spatialAvailable && semanticFrame && (
|
||||
semanticFrame.sourcePointCount !== frame.pointCloudSourceCount
|
||||
|| frame.pointCloudSampleCount !== frame.pointCloudSourceCount
|
||||
|| frame.pointCloudBodyXyzM.length !== frame.pointCloudSourceCount
|
||||
const lastSpatialSemanticFrameRef = useRef<{
|
||||
resultId: string;
|
||||
frame: E47SemanticTimelineFrame;
|
||||
} | null>(null);
|
||||
if (frame?.spatialAvailable && semanticFrame) {
|
||||
lastSpatialSemanticFrameRef.current = { resultId, frame: semanticFrame };
|
||||
}
|
||||
const spatialSemanticFrame = semanticFrame?.sequence === spatialFrame?.sequence
|
||||
? semanticFrame
|
||||
: lastSpatialSemanticFrameRef.current?.resultId === resultId
|
||||
&& lastSpatialSemanticFrameRef.current.frame.sequence === spatialFrame?.sequence
|
||||
? lastSpatialSemanticFrameRef.current.frame
|
||||
: null;
|
||||
const semanticIntegrityError = semantic && spatialFrame && spatialSemanticFrame && (
|
||||
spatialSemanticFrame.sourcePointCount !== spatialFrame.pointCloudSourceCount
|
||||
|| spatialFrame.pointCloudSampleCount !== spatialFrame.pointCloudSourceCount
|
||||
|| spatialFrame.pointCloudBodyXyzM.length !== spatialFrame.pointCloudSourceCount
|
||||
)
|
||||
? "E47 semantic point index space не совпал с exact current increment M4.6."
|
||||
: null;
|
||||
const alignedSemanticPointIds = useMemo<readonly (number | null)[] | undefined>(() => {
|
||||
if (
|
||||
!semantic
|
||||
|| !showSemantic
|
||||
|| !frame
|
||||
|| !frame.spatialAvailable
|
||||
|| !semanticFrame
|
||||
|| !showSpatialSemantic
|
||||
|| !spatialFrame
|
||||
|| !spatialSemanticFrame
|
||||
|| semanticIntegrityError
|
||||
) return undefined;
|
||||
return semanticFrame.classIds.map((classId, index) => {
|
||||
const status = semanticFrame.statusCodes[index];
|
||||
return spatialSemanticFrame.classIds.map((classId, index) => {
|
||||
const status = spatialSemanticFrame.statusCodes[index];
|
||||
return status === 2 || status === 3 ? classId : null;
|
||||
});
|
||||
}, [frame, semantic, semanticFrame, semanticIntegrityError, showSemantic]);
|
||||
}, [semantic, semanticIntegrityError, showSpatialSemantic, spatialFrame, spatialSemanticFrame]);
|
||||
const sceneObstacles = useMemo(() => spatialFrame?.metricObstacles.map((obstacle) => ({
|
||||
id: obstacle.componentId,
|
||||
decision: obstacle.assessment.decision,
|
||||
state: obstacle.state,
|
||||
centroidBodyXyzM: obstacle.centroidBodyXyzM,
|
||||
cellCentersBodyXyzM: obstacle.cellCentersBodyXyzM,
|
||||
})) ?? [], [spatialFrame]);
|
||||
const currentIncrementObstacles = spatialFrame?.metricObstacles.filter(
|
||||
(item) => item.state === "current",
|
||||
) ?? [];
|
||||
const rollingMapObstacles = spatialFrame?.metricObstacles.filter(
|
||||
(item) => item.state === "retained",
|
||||
) ?? [];
|
||||
const nearest = spatialFrame?.metricObstacles
|
||||
.map((item) => item.assessment.closestApproachM)
|
||||
.filter((value): value is number => value !== null)
|
||||
.sort((left, right) => left - right)[0] ?? null;
|
||||
const localSurface = useMemo(() => buildM4LocalSurface(
|
||||
timelineFrame.availableFrames,
|
||||
spatialFrame,
|
||||
metadata.timeline?.localSurfaceVisualization ?? {
|
||||
windowSeconds: 2,
|
||||
voxelSizeM: 0.1,
|
||||
radiusM: 12,
|
||||
pointLimit: 20_000,
|
||||
},
|
||||
), [metadata.timeline, spatialFrame, timelineFrame.availableFrames]);
|
||||
const semanticOverlay: RecordedEvidenceSemanticOverlay | undefined =
|
||||
semantic && showSemantic && frame
|
||||
semantic && showMediaSemantic && frame
|
||||
? {
|
||||
src: e47SemanticMaskUrl(semantic.resultId, frame.sequence),
|
||||
prefetchSrcs: Array.from({ length: 12 }, (_, index) => index + 1)
|
||||
.map((offset) => frame.sequence + offset)
|
||||
.filter((sequence) => sequence < (metadata.timeline?.frameCount ?? 0))
|
||||
.map((sequence) => e47SemanticMaskUrl(semantic.resultId, sequence)),
|
||||
classes: semanticClasses,
|
||||
palette: semanticPalette,
|
||||
opacity: 0.48,
|
||||
opacity: 0.9,
|
||||
ariaLabel: `E47 semantic mask frame ${frame.sequence + 1}`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const seek = (seconds: number) => playbackController.seek(seconds);
|
||||
const handleModeChange = (next: M4ThreatViewMode) => {
|
||||
if (next === "camera") playbackController.setPlaying(false);
|
||||
if (next === "3d" || next === "plan") setSpatialMode(next);
|
||||
setMode(next);
|
||||
const handleMediaModeChange = (next: M4ThreatMediaSelection) => {
|
||||
if (next === "none") return;
|
||||
setMediaMode((current) => current === next ? null : next);
|
||||
};
|
||||
const handleSpatialModeChange = (next: M4ThreatSpatialSelection) => {
|
||||
if (next === "none") return;
|
||||
setSpatialMode((current) => current === next ? null : next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -266,86 +321,124 @@ export function M4ReplayThreatVisual({
|
||||
image.src = frame.cameraUrl;
|
||||
}, [frame?.cameraUrl, playbackController.playback.playing]);
|
||||
|
||||
const actions = (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton
|
||||
label="Назад на 5 секунд"
|
||||
disabled={!metadata.timeline}
|
||||
onClick={() => seek(playbackController.playback.currentSeconds - 5)}
|
||||
const splitView = mediaMode !== null && spatialMode !== null;
|
||||
|
||||
const mediaModeControls = (
|
||||
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="media">
|
||||
<SegmentedControl
|
||||
value={mediaMode ?? "none"}
|
||||
items={[
|
||||
{ value: "video", label: "VIDEO" },
|
||||
{ value: "camera", label: "CAMERA" },
|
||||
]}
|
||||
label="Видео и камера"
|
||||
onChange={handleMediaModeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const spatialModeControls = (
|
||||
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="spatial">
|
||||
<SegmentedControl
|
||||
value={spatialMode ?? "none"}
|
||||
items={[
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "PLAN" },
|
||||
]}
|
||||
label="3D и план"
|
||||
onChange={handleSpatialModeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const mediaLayerControls = semantic ? (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Слои камеры и видео"
|
||||
>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showMediaSemantic ? "primary" : "secondary"}
|
||||
aria-pressed={showMediaSemantic}
|
||||
onClick={() => setShowMediaSemantic((visible) => !visible)}
|
||||
>
|
||||
SEMANTICS
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const spatialLayerControls = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Слои 3D и плана"
|
||||
>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showCurrentIncrement ? "primary" : "secondary"}
|
||||
aria-pressed={showCurrentIncrement}
|
||||
onClick={() => setShowCurrentIncrement((visible) => !visible)}
|
||||
>
|
||||
CURRENT
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showLocalSurface ? "primary" : "secondary"}
|
||||
aria-pressed={showLocalSurface}
|
||||
title="Bounded local SLAM surface · visual-derived"
|
||||
onClick={() => setShowLocalSurface((visible) => !visible)}
|
||||
>
|
||||
LOCAL SLAM
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showRollingMap ? "primary" : "secondary"}
|
||||
aria-pressed={showRollingMap}
|
||||
onClick={() => setShowRollingMap((visible) => !visible)}
|
||||
>
|
||||
ROLLING
|
||||
</Button>
|
||||
{semantic ? (
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showSpatialSemantic ? "primary" : "secondary"}
|
||||
aria-pressed={showSpatialSemantic}
|
||||
onClick={() => setShowSpatialSemantic((visible) => !visible)}
|
||||
>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Вперёд на 5 секунд"
|
||||
disabled={!metadata.timeline}
|
||||
onClick={() => seek(playbackController.playback.currentSeconds + 5)}
|
||||
>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{mode === "3d" || mode === "plan" || semantic ? (
|
||||
<div
|
||||
className="nodedc-segmented m4-replay-threat-visual__layer-controls"
|
||||
role="group"
|
||||
aria-label="Слои пространственного evidence"
|
||||
>
|
||||
{mode === "3d" || mode === "plan" ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-segmented__item"
|
||||
data-active={showCurrentIncrement ? "true" : undefined}
|
||||
aria-pressed={showCurrentIncrement}
|
||||
onClick={() => setShowCurrentIncrement((visible) => !visible)}
|
||||
>
|
||||
CURRENT
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-segmented__item"
|
||||
data-active={showLocalSurface ? "true" : undefined}
|
||||
aria-pressed={showLocalSurface}
|
||||
title="Bounded local SLAM surface · visual-derived"
|
||||
onClick={() => setShowLocalSurface((visible) => !visible)}
|
||||
>
|
||||
LOCAL SLAM
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-segmented__item"
|
||||
data-active={showRollingMap ? "true" : undefined}
|
||||
aria-pressed={showRollingMap}
|
||||
onClick={() => setShowRollingMap((visible) => !visible)}
|
||||
>
|
||||
ROLLING
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{semantic ? (
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-segmented__item"
|
||||
data-active={showSemantic ? "true" : undefined}
|
||||
aria-pressed={showSemantic}
|
||||
onClick={() => setShowSemantic((visible) => !visible)}
|
||||
>
|
||||
SEMANTICS
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
SEMANTICS
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const trailingActions = mode === "3d" || mode === "plan" ? (
|
||||
const resetSpatialView = (
|
||||
<IconButton
|
||||
label="Сбросить ракурс"
|
||||
onClick={() => metricSceneRef.current?.resetView()}
|
||||
>
|
||||
<Icon name="refresh" size={16} />
|
||||
</IconButton>
|
||||
) : null;
|
||||
);
|
||||
|
||||
const singlePaneControls = !splitView
|
||||
? spatialMode ? spatialLayerControls : mediaMode ? mediaLayerControls : null
|
||||
: null;
|
||||
|
||||
const actions = singlePaneControls ? (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="m4-replay-threat-visual__single-pane-controls">
|
||||
{singlePaneControls}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const trailingActions = !splitView && spatialMode ? resetSpatialView : null;
|
||||
|
||||
const overlay = metadata.timeline && frame ? (
|
||||
<div className="l3-visual-audit__overlay m4-replay-threat-visual__overlay">
|
||||
@@ -365,18 +458,18 @@ export function M4ReplayThreatVisual({
|
||||
{currentIncrementObstacles.length} current · {rollingMapObstacles.length} rolling
|
||||
</strong>
|
||||
<small>
|
||||
{frame.spatialAvailable
|
||||
? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} exact · ${localSurface.pointsBodyXyzM.length} local SLAM / ${localSurface.sourceFrameCount} frames`
|
||||
: "body frame / current increment unavailable"}
|
||||
{semantic && semanticFrame
|
||||
? ` · semantic L ${semanticFrame.counts.labeled} · A ${semanticFrame.counts.ambiguous} · U ${semanticFrame.counts.unprojected} · Ø ${semanticFrame.counts.absent}`
|
||||
{spatialFrame
|
||||
? `${spatialFrame.pointCloudSampleCount}/${spatialFrame.pointCloudSourceCount} exact · ${localSurface.pointsBodyXyzM.length} local SLAM / ${localSurface.sourceFrameCount} frames`
|
||||
: "квалифицированный spatial frame ещё не получен"}
|
||||
{semantic && spatialSemanticFrame
|
||||
? ` · semantic L ${spatialSemanticFrame.counts.labeled} · A ${spatialSemanticFrame.counts.ambiguous} · U ${spatialSemanticFrame.counts.unprojected} · Ø ${spatialSemanticFrame.counts.absent}`
|
||||
: semantic ? " · semantic buffer" : ""}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Virtual corridor</span>
|
||||
<strong>
|
||||
{frame.decisionCounts.threat} threat · nearest {nearest === null ? "—" : `${nearest.toFixed(2)} м`}
|
||||
{spatialFrame?.decisionCounts.threat ?? 0} threat · nearest {nearest === null ? "—" : `${nearest.toFixed(2)} м`}
|
||||
</strong>
|
||||
<small>
|
||||
{metadata.timeline.corridor.forwardLengthM} м · body {metadata.timeline.rig.lengthM}×{metadata.timeline.rig.widthM} м · REPLAY-SIMULATED
|
||||
@@ -397,74 +490,129 @@ export function M4ReplayThreatVisual({
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<div className="m4-replay-threat-visual__deck">
|
||||
const mediaPane = (
|
||||
<section
|
||||
className="m4-replay-threat-visual__pane"
|
||||
data-pane="media"
|
||||
aria-label={mediaMode === "camera" ? "Камера" : "Видео"}
|
||||
hidden={!mediaMode}
|
||||
>
|
||||
{splitView ? (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-toolbar"
|
||||
data-pane-toolbar="media"
|
||||
>
|
||||
{mediaLayerControls}
|
||||
{mediaModeControls}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="m4-replay-threat-visual__layer"
|
||||
data-active={mode === "video" ? "true" : undefined}
|
||||
aria-hidden={mode !== "video"}
|
||||
className="m4-replay-threat-visual__media-layer"
|
||||
data-media="video"
|
||||
hidden={mediaMode !== "video"}
|
||||
>
|
||||
{videoSource ? (
|
||||
<RecordedEvidenceVideoScene
|
||||
source={videoSource}
|
||||
playback={playbackController.playback}
|
||||
imageWidth={timeline.imageWidth}
|
||||
imageHeight={timeline.imageHeight}
|
||||
boxes={activeBoxes}
|
||||
semanticOverlay={mode === "video" ? semanticOverlay : undefined}
|
||||
ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
||||
interactive={false}
|
||||
/>
|
||||
) : videoError ? (
|
||||
<SpatialState message={videoError} />
|
||||
) : (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>{videoLoading ? "Подготавливаем локальный видеобуфер" : "Открываем RIGHT-видео RAVNOVES00"}</span>
|
||||
</div>
|
||||
<RecordedEvidenceVideoScene
|
||||
source={videoSource}
|
||||
playback={playbackController.playback}
|
||||
imageWidth={timeline.imageWidth}
|
||||
imageHeight={timeline.imageHeight}
|
||||
boxes={activeBoxes}
|
||||
semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined}
|
||||
ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
||||
interactive={false}
|
||||
/>
|
||||
) : videoError ? (
|
||||
<SpatialState message={videoError} />
|
||||
) : (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>{videoLoading ? "Подготавливаем локальный видеобуфер" : "Открываем RIGHT-видео RAVNOVES00"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="m4-replay-threat-visual__layer"
|
||||
data-active={mode === "camera" ? "true" : undefined}
|
||||
aria-hidden={mode !== "camera"}
|
||||
>
|
||||
{mode === "camera" && frame ? (
|
||||
<RecordedEvidenceImageScene
|
||||
src={frame.cameraUrl}
|
||||
imageWidth={timeline.imageWidth}
|
||||
imageHeight={timeline.imageHeight}
|
||||
boxes={activeBoxes}
|
||||
semanticOverlay={mode === "camera" ? semanticOverlay : undefined}
|
||||
ariaLabel={`M4.6 exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="m4-replay-threat-visual__layer"
|
||||
data-active={mode === "3d" || mode === "plan" ? "true" : undefined}
|
||||
aria-hidden={mode !== "3d" && mode !== "plan"}
|
||||
>
|
||||
{frame ? (
|
||||
<LaboratoryMetricEvidenceScene
|
||||
ref={metricSceneRef}
|
||||
pointCloudBodyXyzM={frame.pointCloudBodyXyzM}
|
||||
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
||||
obstacles={sceneObstacles}
|
||||
rig={timeline.rig}
|
||||
corridor={timeline.corridor}
|
||||
occupiedVoxelSizeM={timeline.occupiedVoxelSizeM}
|
||||
mode={spatialMode}
|
||||
label="M4.6 exact current increment, bounded local SLAM surface and rolling occupancy"
|
||||
showCurrentIncrement={showCurrentIncrement}
|
||||
showLocalSurface={showLocalSurface}
|
||||
showRollingMap={showRollingMap}
|
||||
pointSemanticClassIds={alignedSemanticPointIds}
|
||||
semanticClasses={semanticClasses}
|
||||
semanticPalette={semanticPalette}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{mediaMode === "camera" && frame ? (
|
||||
<RecordedEvidenceImageScene
|
||||
src={frame.cameraUrl}
|
||||
imageWidth={timeline.imageWidth}
|
||||
imageHeight={timeline.imageHeight}
|
||||
boxes={activeBoxes}
|
||||
semanticOverlay={semanticOverlay}
|
||||
ariaLabel={`M4.6 exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
|
||||
const spatialPane = spatialMode ? (
|
||||
<section
|
||||
className="m4-replay-threat-visual__pane"
|
||||
data-pane="spatial"
|
||||
aria-label={spatialMode === "3d" ? "Трёхмерная сцена" : "Вид сверху"}
|
||||
>
|
||||
{splitView ? (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-toolbar"
|
||||
data-pane-toolbar="spatial"
|
||||
>
|
||||
{resetSpatialView}
|
||||
<div className="m4-replay-threat-visual__spatial-toolbar-end">
|
||||
{spatialLayerControls}
|
||||
{spatialModeControls}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{spatialFrame ? (
|
||||
<LaboratoryMetricEvidenceScene
|
||||
ref={metricSceneRef}
|
||||
pointCloudBodyXyzM={spatialFrame.pointCloudBodyXyzM}
|
||||
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
||||
obstacles={sceneObstacles}
|
||||
rig={timeline.rig}
|
||||
corridor={timeline.corridor}
|
||||
occupiedVoxelSizeM={timeline.occupiedVoxelSizeM}
|
||||
mode={spatialMode}
|
||||
label="M4.6 exact current increment, bounded local SLAM surface and rolling occupancy"
|
||||
showCurrentIncrement={showCurrentIncrement}
|
||||
showLocalSurface={showLocalSurface}
|
||||
showRollingMap={showRollingMap}
|
||||
pointSemanticClassIds={alignedSemanticPointIds}
|
||||
semanticClasses={semanticClasses}
|
||||
semanticPalette={semanticPalette}
|
||||
/>
|
||||
) : null}
|
||||
{frame && !frame.spatialAvailable ? (
|
||||
<div className="m4-replay-threat-visual__pane-status" role="status">
|
||||
{spatialFrame
|
||||
? `На кадре ${frame.sequence + 1} нет body frame; держим spatial evidence кадра ${spatialFrame.sequence + 1}.`
|
||||
: `На кадре ${frame.sequence + 1} нет body frame; ждём первый квалифицированный spatial evidence.`}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null;
|
||||
|
||||
content = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__deck"
|
||||
data-split={splitView ? "true" : undefined}
|
||||
data-empty={!mediaMode && !spatialMode ? "true" : undefined}
|
||||
>
|
||||
<SplitPane
|
||||
primary={mediaPane}
|
||||
secondary={spatialPane ?? <div />}
|
||||
primarySize={splitView ? splitPrimarySize : mediaMode ? 100 : 0}
|
||||
onPrimarySizeChange={setSplitPrimarySize}
|
||||
orientation={splitOrientation}
|
||||
minPrimarySize={splitView ? 24 : 0}
|
||||
minSecondarySize={splitView ? 24 : 0}
|
||||
resizable={splitView}
|
||||
separatorLabel="Изменить размер VIDEO/CAMERA и 3D/PLAN"
|
||||
/>
|
||||
{!mediaMode && !spatialMode ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
Выберите VIDEO/CAMERA или 3D/PLAN. Общий таймлайн останется на месте.
|
||||
</div>
|
||||
) : null}
|
||||
{timelineFrame.loading || displayingBufferedFrame ? (
|
||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
@@ -495,11 +643,6 @@ export function M4ReplayThreatVisual({
|
||||
<span>{semanticIntegrityError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{frame && !frame.spatialAvailable && (mode === "3d" || mode === "plan") ? (
|
||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||
На этом кадре нет квалифицированного body frame; сцена сохранена.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -533,16 +676,24 @@ export function M4ReplayThreatVisual({
|
||||
? "E47 semantic + SLAM diagnostic replay"
|
||||
: "M4.6 dual-evidence recorded-realtime replay"}
|
||||
className="m4-replay-threat-evidence-viewer"
|
||||
mode={mode}
|
||||
mode={mediaMode ?? "none"}
|
||||
modes={[
|
||||
{ value: "video", label: "VIDEO" },
|
||||
{ value: "camera", label: "CAMERA" },
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "PLAN" },
|
||||
]}
|
||||
secondaryMode={{
|
||||
value: spatialMode ?? "none",
|
||||
modes: [
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "PLAN" },
|
||||
],
|
||||
label: "3D и план",
|
||||
onChange: handleSpatialModeChange,
|
||||
}}
|
||||
expanded={expanded}
|
||||
onModeChange={handleModeChange}
|
||||
onModeChange={handleMediaModeChange}
|
||||
onExpandedChange={setExpanded}
|
||||
modeControlsVisible={!splitView}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
transport={transport}
|
||||
|
||||
@@ -46,6 +46,12 @@ export interface LaboratoryCatalogEntry {
|
||||
variantName: string;
|
||||
}
|
||||
|
||||
export interface LaboratorySelection {
|
||||
profileId: LaboratoryProfileId;
|
||||
experimentId: LaboratoryExperimentId;
|
||||
workId: LaboratoryWorkId;
|
||||
}
|
||||
|
||||
interface KnownWorkDefinition {
|
||||
profileId: Exclude<LaboratoryProfileId, `published:${string}`>;
|
||||
profileName: (rigLabel: string) => string;
|
||||
@@ -478,3 +484,19 @@ export function workOptionsForExperiment(
|
||||
label: `${laboratoryTimestamp(entry.createdAtUtc)} · ${entry.variantName}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function freshestLaboratorySelection(
|
||||
catalog: readonly LaboratoryCatalogEntry[],
|
||||
): LaboratorySelection | null {
|
||||
const profile = buildLaboratoryProfiles(catalog)[0];
|
||||
if (!profile) return null;
|
||||
const experiment = experimentOptionsForProfile(profile.id, catalog)[0];
|
||||
if (!experiment) return null;
|
||||
const work = workOptionsForExperiment(profile.id, experiment.id, catalog)[0];
|
||||
if (!work) return null;
|
||||
return {
|
||||
profileId: profile.id,
|
||||
experimentId: experiment.id,
|
||||
workId: work.id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ let AdvancedLaboratoryContractError;
|
||||
let buildLaboratoryCatalog;
|
||||
let buildLaboratoryProfiles;
|
||||
let experimentOptionsForProfile;
|
||||
let freshestLaboratorySelection;
|
||||
let workOptionsForExperiment;
|
||||
let fetchL34RightYoloxTruthIsland;
|
||||
let fetchL34RightYoloxTruthIslandFrame;
|
||||
@@ -853,6 +854,7 @@ before(async () => {
|
||||
buildLaboratoryCatalog,
|
||||
buildLaboratoryProfiles,
|
||||
experimentOptionsForProfile,
|
||||
freshestLaboratorySelection,
|
||||
workOptionsForExperiment,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/workspaces/laboratory/laboratoryArchiveProfiles.ts",
|
||||
@@ -930,6 +932,37 @@ test("LAB catalog is pipeline-scoped and ordered by real run time", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("LAB entry defaults atomically to the freshest pipeline, experiment and run", () => {
|
||||
const catalog = buildLaboratoryCatalog({
|
||||
rigLabel: "K1",
|
||||
knownWorks: [],
|
||||
advancedIndex: [
|
||||
{
|
||||
workId: "e46d-temporal-failure-audit",
|
||||
resultId: `e46d-temporal-failure-audit-${"d".repeat(64)}`,
|
||||
createdAtUtc: "2026-08-04T07:49:43.801Z",
|
||||
},
|
||||
{
|
||||
workId: "m4-replay-threat",
|
||||
resultId: `m4-threat-replay-${"4".repeat(64)}`,
|
||||
createdAtUtc: "2026-08-05T18:00:00.000Z",
|
||||
},
|
||||
{
|
||||
workId: "e47-semantic-slam-shadow",
|
||||
resultId: `e47-semantic-slam-${"7".repeat(64)}`,
|
||||
createdAtUtc: "2026-08-06T08:13:48.601Z",
|
||||
},
|
||||
],
|
||||
publishedWorks: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(freshestLaboratorySelection(catalog), {
|
||||
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||
experimentId: "ravnoves00-semantic-slam-shadow-r1",
|
||||
workId: "e47-semantic-slam-shadow",
|
||||
});
|
||||
});
|
||||
|
||||
test("E46E is exposed as the newest independent NVIDIA pipeline", () => {
|
||||
const catalog = buildLaboratoryCatalog({
|
||||
rigLabel: "K1",
|
||||
|
||||
@@ -224,7 +224,9 @@ test("E30 uses one reusable evidence viewer with camera, 3D and expand controls"
|
||||
assert.match(workspaceSource, /review: "Проверка"/);
|
||||
assert.doesNotMatch(workspaceSource, /e30-review-evidence__facts/);
|
||||
assert.doesNotMatch(workspaceSource, /E30EngineeringAuditPanel/);
|
||||
assert.match(viewerSource, /createPortal/);
|
||||
assert.match(viewerSource, /viewer\.showModal\(\)/);
|
||||
assert.match(viewerSource, /viewer\.show\(\)/);
|
||||
assert.doesNotMatch(viewerSource, /createPortal/);
|
||||
assert.match(viewerSource, /name=\{expanded \? "minimize" : "expand"\}/);
|
||||
assert.match(
|
||||
laboratoryStyles,
|
||||
|
||||
@@ -391,9 +391,10 @@ test("recorded evidence clock advances by selected rate and stops at the sealed
|
||||
);
|
||||
});
|
||||
|
||||
test("M4.6 viewer reuses shared camera, video and metric evidence renderers", async () => {
|
||||
const [visual, imageScene, videoScene, metricScene] = await Promise.all([
|
||||
test("M4.6 viewer keeps media and spatial panes on one playback clock", async () => {
|
||||
const [visual, visualCss, imageScene, videoScene, metricScene] = await Promise.all([
|
||||
readFile(new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/styles/m4-replay-threat.css", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/components/laboratory/RecordedEvidenceImageScene.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/components/laboratory/RecordedEvidenceVideoScene.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/components/laboratory/LaboratoryMetricEvidenceScene.tsx", import.meta.url), "utf8"),
|
||||
@@ -403,17 +404,56 @@ test("M4.6 viewer reuses shared camera, video and metric evidence renderers", as
|
||||
assert.match(visual, /<LaboratoryMetricEvidenceScene/);
|
||||
assert.match(visual, /m4-replay-threat-visual__deck/);
|
||||
assert.match(visual, /lastFrameRef/);
|
||||
assert.match(visual, /lastSpatialFrameRef/);
|
||||
assert.match(visual, /const spatialFrame = frame\?\.spatialAvailable/);
|
||||
assert.match(visual, /<ObservationTimeline/);
|
||||
assert.match(visual, /useM4ThreatTimelineFrame/);
|
||||
assert.match(visual, /label: "VIDEO"/);
|
||||
assert.match(visual, /label: "CAMERA"/);
|
||||
assert.match(visual, /label: "3D"/);
|
||||
assert.match(visual, /label: "PLAN"/);
|
||||
assert.match(visual, /mediaMode/);
|
||||
assert.match(visual, /spatialMode/);
|
||||
assert.match(visual, /current === next \? null : next/);
|
||||
assert.match(visual, /data-split=\{splitView \? "true" : undefined\}/);
|
||||
assert.match(visual, /<SplitPane/);
|
||||
assert.match(visual, /primarySize=\{splitView \? splitPrimarySize : mediaMode \? 100 : 0\}/);
|
||||
assert.match(visual, /resizable=\{splitView\}/);
|
||||
assert.match(visual, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
|
||||
assert.match(visual, /secondaryMode=\{\{/);
|
||||
assert.match(visual, /playback=\{playbackController\.playback\}/);
|
||||
assert.match(visual, /currentSeconds: playbackController\.playback\.currentSeconds/);
|
||||
assert.doesNotMatch(visual, /setPlaying\(false\)/);
|
||||
assert.match(visualCss, /m4-replay-threat-visual__deck > \.nodedc-split-pane/);
|
||||
assert.match(visualCss, /m4-replay-threat-visual__pane-toolbar\[data-pane-toolbar="media"\]/);
|
||||
assert.match(visualCss, /m4-replay-threat-visual__pane-toolbar\[data-pane-toolbar="spatial"\]/);
|
||||
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, /laboratory-metric-evidence-scene__legend/);
|
||||
assert.match(visualCss, /bottom: auto/);
|
||||
assert.match(videoScene, /<RecordedFmp4Player/);
|
||||
assert.match(imageScene, /<RecordedEvidenceBoxOverlay/);
|
||||
assert.match(metricScene, /OrbitControls/);
|
||||
assert.match(visual, /LOCAL SLAM/);
|
||||
assert.match(visual, /showLocalSurface/);
|
||||
assert.match(visual, /pointCloudBodyXyzM=\{spatialFrame\.pointCloudBodyXyzM\}/);
|
||||
assert.match(metricScene, /Local SLAM surface/);
|
||||
assert.match(visual, /showJumpToEnd=\{false\}/);
|
||||
assert.doesNotMatch(visual, /Назад на 5 секунд/);
|
||||
assert.doesNotMatch(visual, /Вперёд на 5 секунд/);
|
||||
assert.doesNotMatch(metricScene, /ЛКМ · вращение/);
|
||||
});
|
||||
|
||||
test("M4.6 keeps the recorded VIDEO player mounted across media mode toggles", async () => {
|
||||
const visual = await readFile(
|
||||
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(visual, /const mediaPane = \(/);
|
||||
assert.match(visual, /hidden=\{!mediaMode\}/);
|
||||
assert.match(visual, /data-media="video"/);
|
||||
assert.match(visual, /hidden=\{mediaMode !== "video"\}/);
|
||||
assert.match(visual, /\{videoSource \? \(/);
|
||||
assert.doesNotMatch(visual, /\{mediaMode === "video" \? videoSource \? \(/);
|
||||
});
|
||||
|
||||
@@ -209,7 +209,8 @@ test("data recordings keep the compact session dropdown and laboratory results s
|
||||
assert.match(laboratorySource, /experimentOptionsForProfile/);
|
||||
assert.match(laboratorySource, /workOptionsForExperiment/);
|
||||
assert.doesNotMatch(laboratorySource, /laboratoryWorkOrdinal/);
|
||||
assert.match(laboratorySource, /initialWorkSelectedRef/);
|
||||
assert.match(laboratorySource, /freshestLaboratorySelection\(catalog\)/);
|
||||
assert.match(laboratorySource, /initialSelectionAppliedRef/);
|
||||
assert.match(laboratorySource, /pollingEnabled:\s*false/);
|
||||
assert.match(laboratorySource, /useAdvancedLaboratoryCatalog/);
|
||||
assert.doesNotMatch(laboratorySource, /fetchAdvancedLaboratoryResults/);
|
||||
|
||||
@@ -4,18 +4,30 @@ import { test } from "node:test";
|
||||
|
||||
const component = (name) => new URL(`../src/components/laboratory/${name}`, import.meta.url);
|
||||
|
||||
test("semantic evidence mask is decoded, cached, cancelled and object-contained client-side", async () => {
|
||||
test("semantic evidence mask is GPU-colored, bounded, cancelled and object-contained client-side", async () => {
|
||||
const source = await readFile(component("RecordedEvidenceSemanticMaskOverlay.tsx"), "utf8");
|
||||
assert.match(source, /decodedMaskCache/);
|
||||
assert.match(source, /pendingMaskCache/);
|
||||
assert.match(source, /MASK_CACHE_LIMIT = 20/);
|
||||
assert.match(source, /MASK_PREFETCH_LIMIT = 12/);
|
||||
assert.match(source, /prefetchSrcs\?: readonly string\[\]/);
|
||||
assert.match(source, /\.slice\(0, MASK_PREFETCH_LIMIT\)/);
|
||||
assert.match(source, /AbortController/);
|
||||
assert.match(source, /createSemanticMaskRenderer/);
|
||||
assert.match(source, /getContext\("webgl"/);
|
||||
assert.match(source, /preserveDrawingBuffer: true/);
|
||||
assert.match(source, /vec2\(v_uv\.x, 1\.0 - v_uv\.y\)/);
|
||||
assert.match(source, /UNPACK_FLIP_Y_WEBGL, 0/);
|
||||
assert.match(source, /gl\.isContextLost\(\)/);
|
||||
assert.match(source, /webglcontextlost/);
|
||||
assert.match(source, /setRendererMode\("2d"\)/);
|
||||
assert.match(source, /gl\.texImage2D/);
|
||||
assert.match(source, /u_palette/);
|
||||
assert.match(source, /getImageData/);
|
||||
assert.match(source, /8-bit grayscale class-id PNG/);
|
||||
assert.match(source, /ResizeObserver/);
|
||||
assert.match(source, /Math\.min\(width \/ imageWidth, height \/ imageHeight\)/);
|
||||
assert.match(source, /Math\.min\(width \/ mask\.width, height \/ mask\.height\)/);
|
||||
assert.match(source, /decoded\.key !== expectedKey/);
|
||||
assert.match(source, /mask\?\.key === expectedKey/);
|
||||
assert.match(source, /необъявленный class ID/);
|
||||
assert.match(source, /recorded-evidence-semantic-mask-overlay__error/);
|
||||
assert.match(source, /role="alert"/);
|
||||
});
|
||||
@@ -43,27 +55,40 @@ test("metric evidence keeps missing semantic assignments as context and exposes
|
||||
assert.match(source, /recordedEvidenceSemanticCssColor/);
|
||||
});
|
||||
|
||||
test("semantic point alignment is enforced only when M4 exposes the exact spatial increment", async () => {
|
||||
test("semantic point alignment follows the last qualified spatial increment", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /semantic && frame\?\.spatialAvailable && semanticFrame/);
|
||||
assert.match(source, /\|\| !frame\.spatialAvailable\s*\|\| !semanticFrame/);
|
||||
assert.match(source, /semanticFrame\.sourcePointCount !== frame\.pointCloudSourceCount/);
|
||||
assert.match(source, /frame\?\.spatialAvailable && semanticFrame/);
|
||||
assert.match(source, /lastSpatialSemanticFrameRef/);
|
||||
assert.match(source, /\|\| !spatialFrame\s*\|\| !spatialSemanticFrame/);
|
||||
assert.match(source, /spatialSemanticFrame\.sourcePointCount !== spatialFrame\.pointCloudSourceCount/);
|
||||
});
|
||||
|
||||
test("M4 mounts semantic mask overlays only for the active VIDEO or CAMERA layer", async () => {
|
||||
test("M4 keeps independent semantic controls in media and spatial panes", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/semanticOverlay=\{mode === "video" \? semanticOverlay : undefined\}/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/semanticOverlay=\{mode === "camera" \? semanticOverlay : undefined\}/,
|
||||
);
|
||||
assert.match(source, /showMediaSemantic/);
|
||||
assert.match(source, /showSpatialSemantic/);
|
||||
assert.match(source, /semantic && showMediaSemantic && frame/);
|
||||
assert.match(source, /Array\.from\(\{ length: 12 \}, \(_, index\) => index \+ 1\)/);
|
||||
assert.match(source, /\|\| !showSpatialSemantic/);
|
||||
assert.match(source, /aria-label="Слои камеры и видео"/);
|
||||
assert.match(source, /aria-label="Слои 3D и плана"/);
|
||||
assert.match(source, /data-pane-mode="media"/);
|
||||
assert.match(source, /data-pane-mode="spatial"/);
|
||||
assert.match(source, /modeControlsVisible=\{!splitView\}/);
|
||||
assert.match(source, /semanticOverlay=\{mediaMode === "video" \? semanticOverlay : undefined\}/);
|
||||
});
|
||||
|
||||
test("laboratory fullscreen preserves the mounted evidence subtree", async () => {
|
||||
const source = await readFile(component("LaboratoryEvidenceViewer.tsx"), "utf8");
|
||||
assert.match(source, /useLayoutEffect/);
|
||||
assert.match(source, /viewer\.showModal\(\)/);
|
||||
assert.match(source, /viewer\.show\(\)/);
|
||||
assert.match(source, /<dialog/);
|
||||
assert.doesNotMatch(source, /createPortal/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user