Стабилизация синхронного LAB-воспроизведения

This commit is contained in:
DCCONSTRUCTIONS
2026-08-23 18:56:06 +03:00
parent a3fc13ad63
commit 51bb1369eb
12 changed files with 1042 additions and 336 deletions
@@ -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;
}
@@ -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/);
});