diff --git a/apps/control-station/src/components/laboratory/LaboratoryEvidenceViewer.tsx b/apps/control-station/src/components/laboratory/LaboratoryEvidenceViewer.tsx index 6834a9f..559c95e 100644 --- a/apps/control-station/src/components/laboratory/LaboratoryEvidenceViewer.tsx +++ b/apps/control-station/src/components/laboratory/LaboratoryEvidenceViewer.tsx @@ -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(null); + const viewerRef = useRef(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 = ( -
@@ -86,7 +103,7 @@ export function LaboratoryEvidenceViewer< ) : null}
{actions} - {secondaryMode ? ( + {modeControlsVisible && secondaryMode ? ( ) : null} - + {modeControlsVisible ? ( + + ) : null} {trailingActions}
-
+ ); - return expanded ? createPortal(viewer, document.body) : viewer; + return viewer; } diff --git a/apps/control-station/src/components/laboratory/RecordedEvidenceSemanticMaskOverlay.tsx b/apps/control-station/src/components/laboratory/RecordedEvidenceSemanticMaskOverlay.tsx index 8859264..094df92 100644 --- a/apps/control-station/src/components/laboratory/RecordedEvidenceSemanticMaskOverlay.tsx +++ b/apps/control-station/src/components/laboratory/RecordedEvidenceSemanticMaskOverlay.tsx @@ -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; } -const MASK_CACHE_LIMIT = 48; +const MASK_CACHE_LIMIT = 20; +const MASK_PREFETCH_LIMIT = 12; const decodedMaskCache = new Map(); const pendingMaskCache = new Map(); @@ -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(null); + const rendererRef = useRef(undefined); + const [rendererMode, setRendererMode] = useState<"webgl" | "2d">("webgl"); const [mask, setMask] = useState(null); const [failure, setFailure] = useState(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(); - 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 ( <> {failure ? ( diff --git a/apps/control-station/src/styles/laboratory.css b/apps/control-station/src/styles/laboratory.css index 990d7a3..f0331fd 100644 --- a/apps/control-station/src/styles/laboratory.css +++ b/apps/control-station/src/styles/laboratory.css @@ -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; diff --git a/apps/control-station/src/styles/m4-replay-threat.css b/apps/control-station/src/styles/m4-replay-threat.css index 4e1f195..671164f 100644 --- a/apps/control-station/src/styles/m4-replay-threat.css +++ b/apps/control-station/src/styles/m4-replay-threat.css @@ -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; diff --git a/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx b/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx index b3dd723..a1cb012 100644 --- a/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx +++ b/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx @@ -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( "l34-right-yolox-truth-island-freeze", ); - const initialWorkSelectedRef = useRef(false); + const initialSelectionAppliedRef = useRef(false); const [e28Model, setE28Model] = useState(null); const [e29Result, setE29Result] = useState(null); const [e30Result, setE30Result] = useState(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); }; diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index c46ce0c..e6a726f 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -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("video"); - const [spatialMode, setSpatialMode] = useState("3d"); + const [mediaMode, setMediaMode] = useState("video"); + const [spatialMode, setSpatialMode] = useState(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(() => ( + typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches + ? "horizontal" + : "vertical" + )); const [expanded, setExpanded] = useState(false); const metricSceneRef = useRef(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( () => 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(() => { 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 = ( -
-
- seek(playbackController.playback.currentSeconds - 5)} + const splitView = mediaMode !== null && spatialMode !== null; + + const mediaModeControls = ( +
+ +
+ ); + + const spatialModeControls = ( +
+ +
+ ); + + const mediaLayerControls = semantic ? ( +
+ +
+ ) : null; + + const spatialLayerControls = ( +
+ + + + {semantic ? ( +
- {mode === "3d" || mode === "plan" || semantic ? ( -
- {mode === "3d" || mode === "plan" ? ( - <> - - - - - ) : null} - {semantic ? ( - - ) : null} -
+ SEMANTICS + ) : null}
); - const trailingActions = mode === "3d" || mode === "plan" ? ( + const resetSpatialView = ( metricSceneRef.current?.resetView()} > - ) : null; + ); + + const singlePaneControls = !splitView + ? spatialMode ? spatialLayerControls : mediaMode ? mediaLayerControls : null + : null; + + const actions = singlePaneControls ? ( +
+
+ {singlePaneControls} +
+
+ ) : undefined; + + const trailingActions = !splitView && spatialMode ? resetSpatialView : null; const overlay = metadata.timeline && frame ? (
@@ -365,18 +458,18 @@ export function M4ReplayThreatVisual({ {currentIncrementObstacles.length} current · {rollingMapObstacles.length} rolling - {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" : ""}
Virtual corridor - {frame.decisionCounts.threat} threat · nearest {nearest === null ? "—" : `${nearest.toFixed(2)} м`} + {spatialFrame?.decisionCounts.threat ?? 0} threat · nearest {nearest === null ? "—" : `${nearest.toFixed(2)} м`} {metadata.timeline.corridor.forwardLengthM} м · body {metadata.timeline.rig.lengthM}×{metadata.timeline.rig.widthM} м · REPLAY-SIMULATED @@ -397,74 +490,129 @@ export function M4ReplayThreatVisual({
); } else { - content = ( -
+ const mediaPane = ( + + ); + + const spatialPane = spatialMode ? ( +
+ {splitView ? ( +
+ {resetSpatialView} +
+ {spatialLayerControls} + {spatialModeControls} +
+
+ ) : null} + {spatialFrame ? ( + + ) : null} + {frame && !frame.spatialAvailable ? ( +
+ {spatialFrame + ? `На кадре ${frame.sequence + 1} нет body frame; держим spatial evidence кадра ${spatialFrame.sequence + 1}.` + : `На кадре ${frame.sequence + 1} нет body frame; ждём первый квалифицированный spatial evidence.`} +
+ ) : null} +
+ ) : null; + + content = ( +
+ } + 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 ? ( +
+ Выберите VIDEO/CAMERA или 3D/PLAN. Общий таймлайн останется на месте. +
+ ) : null} {timelineFrame.loading || displayingBufferedFrame ? (
) : null} - {frame && !frame.spatialAvailable && (mode === "3d" || mode === "plan") ? ( -
- На этом кадре нет квалифицированного body frame; сцена сохранена. -
- ) : null}
); } @@ -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} diff --git a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts index 177758f..26b24bf 100644 --- a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts +++ b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts @@ -46,6 +46,12 @@ export interface LaboratoryCatalogEntry { variantName: string; } +export interface LaboratorySelection { + profileId: LaboratoryProfileId; + experimentId: LaboratoryExperimentId; + workId: LaboratoryWorkId; +} + interface KnownWorkDefinition { profileId: Exclude; 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, + }; +} diff --git a/apps/control-station/test/advancedLaboratoryResults.test.mjs b/apps/control-station/test/advancedLaboratoryResults.test.mjs index 63d255b..9b2cd88 100644 --- a/apps/control-station/test/advancedLaboratoryResults.test.mjs +++ b/apps/control-station/test/advancedLaboratoryResults.test.mjs @@ -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", diff --git a/apps/control-station/test/laboratoryProductUi.test.mjs b/apps/control-station/test/laboratoryProductUi.test.mjs index 2d6f7ef..7f96729 100644 --- a/apps/control-station/test/laboratoryProductUi.test.mjs +++ b/apps/control-station/test/laboratoryProductUi.test.mjs @@ -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, diff --git a/apps/control-station/test/m4ReplayThreat.test.mjs b/apps/control-station/test/m4ReplayThreat.test.mjs index e66fc6b..8b62466 100644 --- a/apps/control-station/test/m4ReplayThreat.test.mjs +++ b/apps/control-station/test/m4ReplayThreat.test.mjs @@ -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, / \.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, / { + 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 \? \(/); +}); diff --git a/apps/control-station/test/observationSessions.test.mjs b/apps/control-station/test/observationSessions.test.mjs index 40c5167..7dc16c1 100644 --- a/apps/control-station/test/observationSessions.test.mjs +++ b/apps/control-station/test/observationSessions.test.mjs @@ -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/); diff --git a/apps/control-station/test/semanticEvidencePrimitives.test.mjs b/apps/control-station/test/semanticEvidencePrimitives.test.mjs index 89d44f7..9ffad32 100644 --- a/apps/control-station/test/semanticEvidencePrimitives.test.mjs +++ b/apps/control-station/test/semanticEvidencePrimitives.test.mjs @@ -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, /