Стабилизация синхронного LAB-воспроизведения
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import {
|
import {
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -31,6 +31,7 @@ export function LaboratoryEvidenceViewer<
|
|||||||
overlay,
|
overlay,
|
||||||
transport,
|
transport,
|
||||||
trailingActions,
|
trailingActions,
|
||||||
|
modeControlsVisible = true,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -50,9 +51,22 @@ export function LaboratoryEvidenceViewer<
|
|||||||
overlay?: ReactNode;
|
overlay?: ReactNode;
|
||||||
transport?: ReactNode;
|
transport?: ReactNode;
|
||||||
trailingActions?: ReactNode;
|
trailingActions?: ReactNode;
|
||||||
|
modeControlsVisible?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const expandButtonRef = useRef<HTMLButtonElement | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!expanded) return;
|
if (!expanded) return;
|
||||||
@@ -67,12 +81,15 @@ export function LaboratoryEvidenceViewer<
|
|||||||
}, [expanded, onExpandedChange]);
|
}, [expanded, onExpandedChange]);
|
||||||
|
|
||||||
const viewer = (
|
const viewer = (
|
||||||
<section
|
<dialog
|
||||||
|
ref={viewerRef}
|
||||||
|
role="region"
|
||||||
className={[
|
className={[
|
||||||
"laboratory-evidence-viewer",
|
"laboratory-evidence-viewer",
|
||||||
className,
|
className,
|
||||||
].filter(Boolean).join(" ")}
|
].filter(Boolean).join(" ")}
|
||||||
data-expanded={expanded ? "true" : undefined}
|
data-expanded={expanded ? "true" : undefined}
|
||||||
|
data-mode-controls={modeControlsVisible ? undefined : "content"}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
<div className="laboratory-evidence-viewer__stage">
|
<div className="laboratory-evidence-viewer__stage">
|
||||||
@@ -86,7 +103,7 @@ export function LaboratoryEvidenceViewer<
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="laboratory-evidence-viewer__controls">
|
<div className="laboratory-evidence-viewer__controls">
|
||||||
{actions}
|
{actions}
|
||||||
{secondaryMode ? (
|
{modeControlsVisible && secondaryMode ? (
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={secondaryMode.value}
|
value={secondaryMode.value}
|
||||||
items={[...secondaryMode.modes]}
|
items={[...secondaryMode.modes]}
|
||||||
@@ -94,12 +111,14 @@ export function LaboratoryEvidenceViewer<
|
|||||||
onChange={secondaryMode.onChange}
|
onChange={secondaryMode.onChange}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<SegmentedControl
|
{modeControlsVisible ? (
|
||||||
value={mode}
|
<SegmentedControl
|
||||||
items={[...modes]}
|
value={mode}
|
||||||
label={`${label}: режим представления`}
|
items={[...modes]}
|
||||||
onChange={onModeChange}
|
label={`${label}: режим представления`}
|
||||||
/>
|
onChange={onModeChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{trailingActions}
|
{trailingActions}
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={expandButtonRef}
|
ref={expandButtonRef}
|
||||||
@@ -109,8 +128,8 @@ export function LaboratoryEvidenceViewer<
|
|||||||
<Icon name={expanded ? "minimize" : "expand"} size={16} />
|
<Icon name={expanded ? "minimize" : "expand"} size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</dialog>
|
||||||
);
|
);
|
||||||
|
|
||||||
return expanded ? createPortal(viewer, document.body) : viewer;
|
return viewer;
|
||||||
}
|
}
|
||||||
|
|||||||
+335
-84
@@ -34,6 +34,7 @@ export interface RecordedEvidenceSemanticPaletteEntry {
|
|||||||
|
|
||||||
export interface RecordedEvidenceSemanticOverlay {
|
export interface RecordedEvidenceSemanticOverlay {
|
||||||
src: string;
|
src: string;
|
||||||
|
prefetchSrcs?: readonly string[];
|
||||||
classes: readonly RecordedEvidenceSemanticClass[];
|
classes: readonly RecordedEvidenceSemanticClass[];
|
||||||
palette: readonly RecordedEvidenceSemanticPaletteEntry[];
|
palette: readonly RecordedEvidenceSemanticPaletteEntry[];
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
@@ -44,7 +45,8 @@ interface DecodedSemanticMask {
|
|||||||
key: string;
|
key: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
classIds: Uint8Array;
|
source: ImageBitmap | HTMLImageElement;
|
||||||
|
release: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingSemanticMask {
|
interface PendingSemanticMask {
|
||||||
@@ -53,7 +55,8 @@ interface PendingSemanticMask {
|
|||||||
promise: Promise<DecodedSemanticMask>;
|
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 decodedMaskCache = new Map<string, DecodedSemanticMask>();
|
||||||
const pendingMaskCache = new Map<string, PendingSemanticMask>();
|
const pendingMaskCache = new Map<string, PendingSemanticMask>();
|
||||||
|
|
||||||
@@ -79,19 +82,23 @@ function semanticMaskKey(src: string, width: number, height: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rememberMask(mask: DecodedSemanticMask): void {
|
function rememberMask(mask: DecodedSemanticMask): void {
|
||||||
|
const previous = decodedMaskCache.get(mask.key);
|
||||||
|
if (previous && previous !== mask) previous.release();
|
||||||
decodedMaskCache.delete(mask.key);
|
decodedMaskCache.delete(mask.key);
|
||||||
decodedMaskCache.set(mask.key, mask);
|
decodedMaskCache.set(mask.key, mask);
|
||||||
while (decodedMaskCache.size > MASK_CACHE_LIMIT) {
|
while (decodedMaskCache.size > MASK_CACHE_LIMIT) {
|
||||||
const oldest = decodedMaskCache.keys().next().value;
|
const oldest = decodedMaskCache.keys().next().value;
|
||||||
if (typeof oldest !== "string") break;
|
if (typeof oldest !== "string") break;
|
||||||
|
const evicted = decodedMaskCache.get(oldest);
|
||||||
decodedMaskCache.delete(oldest);
|
decodedMaskCache.delete(oldest);
|
||||||
|
evicted?.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function imageSourceFromBlob(
|
async function imageSourceFromBlob(
|
||||||
blob: Blob,
|
blob: Blob,
|
||||||
signal: AbortSignal,
|
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") {
|
if (typeof createImageBitmap === "function") {
|
||||||
const bitmap = await createImageBitmap(blob, {
|
const bitmap = await createImageBitmap(blob, {
|
||||||
colorSpaceConversion: "none",
|
colorSpaceConversion: "none",
|
||||||
@@ -160,31 +167,19 @@ async function decodeSemanticMask(
|
|||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
const decoded = await imageSourceFromBlob(blob, signal);
|
const decoded = await imageSourceFromBlob(blob, signal);
|
||||||
try {
|
if (decoded.width !== expectedWidth || decoded.height !== expectedHeight) {
|
||||||
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 {
|
|
||||||
decoded.release();
|
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(
|
function subscribeToSemanticMask(
|
||||||
@@ -257,8 +252,252 @@ export function resolveRecordedEvidenceSemanticRgb(
|
|||||||
: TOKEN_FALLBACKS[color.token];
|
: 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({
|
export function RecordedEvidenceSemanticMaskOverlay({
|
||||||
src,
|
src,
|
||||||
|
prefetchSrcs = [],
|
||||||
imageWidth,
|
imageWidth,
|
||||||
imageHeight,
|
imageHeight,
|
||||||
classes,
|
classes,
|
||||||
@@ -270,9 +509,12 @@ export function RecordedEvidenceSemanticMaskOverlay({
|
|||||||
imageHeight: number;
|
imageHeight: number;
|
||||||
}) {
|
}) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
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 [mask, setMask] = useState<DecodedSemanticMask | null>(null);
|
||||||
const [failure, setFailure] = useState<string | null>(null);
|
const [failure, setFailure] = useState<string | null>(null);
|
||||||
const expectedKey = semanticMaskKey(src, imageWidth, imageHeight);
|
const expectedKey = semanticMaskKey(src, imageWidth, imageHeight);
|
||||||
|
const prefetchSignature = prefetchSrcs.join("\n");
|
||||||
const renderMask = failure
|
const renderMask = failure
|
||||||
? null
|
? null
|
||||||
: mask?.key === expectedKey
|
: mask?.key === expectedKey
|
||||||
@@ -285,14 +527,6 @@ export function RecordedEvidenceSemanticMaskOverlay({
|
|||||||
let current = true;
|
let current = true;
|
||||||
void subscription.promise.then((decoded) => {
|
void subscription.promise.then((decoded) => {
|
||||||
if (!current || decoded.key !== expectedKey) return;
|
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);
|
setMask(decoded);
|
||||||
}).catch((error: unknown) => {
|
}).catch((error: unknown) => {
|
||||||
if (!current || (error instanceof DOMException && error.name === "AbortError")) return;
|
if (!current || (error instanceof DOMException && error.name === "AbortError")) return;
|
||||||
@@ -302,83 +536,100 @@ export function RecordedEvidenceSemanticMaskOverlay({
|
|||||||
current = false;
|
current = false;
|
||||||
subscription.release();
|
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(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const host = canvas?.parentElement;
|
const host = canvas?.parentElement;
|
||||||
if (!canvas || !host) return;
|
if (!canvas || !host) return;
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) return;
|
|
||||||
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
const width = Math.max(host.clientWidth, 1);
|
const width = Math.max(host.clientWidth, 1);
|
||||||
const height = Math.max(host.clientHeight, 1);
|
const height = Math.max(host.clientHeight, 1);
|
||||||
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
||||||
canvas.width = Math.round(width * pixelRatio);
|
if (rendererMode === "webgl" && rendererRef.current === undefined) {
|
||||||
canvas.height = Math.round(height * pixelRatio);
|
rendererRef.current = createSemanticMaskRenderer(canvas);
|
||||||
canvas.style.width = `${width}px`;
|
if (!rendererRef.current) {
|
||||||
canvas.style.height = `${height}px`;
|
setRendererMode("2d");
|
||||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
return;
|
||||||
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),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
const renderer = rendererMode === "webgl" ? rendererRef.current : null;
|
||||||
const colorCanvas = document.createElement("canvas");
|
if (!renderMask) {
|
||||||
colorCanvas.width = renderMask.width;
|
if (renderer) {
|
||||||
colorCanvas.height = renderMask.height;
|
renderer.clear(width, height, pixelRatio);
|
||||||
const colorContext = colorCanvas.getContext("2d");
|
} else {
|
||||||
if (!colorContext) return;
|
canvas.width = Math.max(1, Math.round(width * pixelRatio));
|
||||||
const imageData = colorContext.createImageData(renderMask.width, renderMask.height);
|
canvas.height = Math.max(1, Math.round(height * pixelRatio));
|
||||||
for (let sourceOffset = 0, targetOffset = 0; sourceOffset < renderMask.classIds.length; sourceOffset += 1, targetOffset += 4) {
|
canvas.style.width = `${width}px`;
|
||||||
const color = resolvedPalette.get(renderMask.classIds[sourceOffset] ?? -1);
|
canvas.style.height = `${height}px`;
|
||||||
if (!color) continue;
|
}
|
||||||
imageData.data[targetOffset] = color.rgb[0];
|
return;
|
||||||
imageData.data[targetOffset + 1] = color.rgb[1];
|
}
|
||||||
imageData.data[targetOffset + 2] = color.rgb[2];
|
const resolvedPalette = semanticPalettePixels(host, classes, palette, opacity);
|
||||||
imageData.data[targetOffset + 3] = Math.round(color.alpha * 255);
|
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);
|
const observer = new ResizeObserver(render);
|
||||||
observer.observe(host);
|
observer.observe(host);
|
||||||
render();
|
render();
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [classes, expectedKey, imageHeight, imageWidth, opacity, palette, renderMask]);
|
}, [classes, expectedKey, imageHeight, imageWidth, opacity, palette, rendererMode, renderMask]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<canvas
|
<canvas
|
||||||
|
key={rendererMode}
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="recorded-evidence-semantic-mask-overlay"
|
className="recorded-evidence-semantic-mask-overlay"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-busy={!failure && !renderMask}
|
aria-busy={!failure && !renderMask}
|
||||||
data-state={failure ? "error" : renderMask ? "ready" : "loading"}
|
data-state={failure ? "error" : renderMask ? "ready" : "loading"}
|
||||||
|
data-renderer={rendererMode}
|
||||||
style={{ zIndex: 1 }}
|
style={{ zIndex: 1 }}
|
||||||
/>
|
/>
|
||||||
{failure ? (
|
{failure ? (
|
||||||
|
|||||||
@@ -431,13 +431,22 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border: 0;
|
||||||
border-radius: var(--nodedc-radius-option);
|
border-radius: var(--nodedc-radius-option);
|
||||||
background: var(--nodedc-canvas);
|
background: var(--nodedc-canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-viewer::backdrop {
|
||||||
|
background: var(--nodedc-canvas);
|
||||||
|
}
|
||||||
|
|
||||||
.laboratory-evidence-viewer__stage {
|
.laboratory-evidence-viewer__stage {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -12,29 +12,137 @@
|
|||||||
background: var(--nodedc-canvas);
|
background: var(--nodedc-canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m4-replay-threat-visual__deck,
|
.m4-replay-threat-visual__deck {
|
||||||
.m4-replay-threat-visual__layer {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
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%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m4-replay-threat-visual__layer {
|
.m4-replay-threat-visual__media-layer > * {
|
||||||
visibility: hidden;
|
width: 100%;
|
||||||
opacity: 0;
|
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;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m4-replay-threat-visual__layer[data-active="true"] {
|
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
|
||||||
z-index: 1;
|
justify-content: flex-end;
|
||||||
visibility: visible;
|
}
|
||||||
opacity: 1;
|
|
||||||
|
.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;
|
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 {
|
.m4-replay-threat-visual__buffering {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
@@ -59,16 +167,42 @@
|
|||||||
gap: 0.3rem;
|
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 {
|
.m4-replay-threat-evidence-viewer .l3-visual-audit__actions {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m4-replay-threat-visual__layer-controls {
|
.m4-replay-threat-visual__single-pane-controls {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m4-replay-threat-visual__layer-controls .nodedc-segmented__item {
|
.m4-replay-threat-visual__pane-layer-controls,
|
||||||
padding-inline: 0.3rem;
|
.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 {
|
.m4-replay-threat-evidence-viewer .laboratory-evidence-viewer__transport {
|
||||||
@@ -83,6 +217,21 @@
|
|||||||
grid-template-columns: auto auto auto minmax(0, 1fr) auto;
|
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 {
|
.laboratory-metric-evidence-scene__viewport {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
buildLaboratoryCatalog,
|
buildLaboratoryCatalog,
|
||||||
buildLaboratoryProfiles,
|
buildLaboratoryProfiles,
|
||||||
experimentOptionsForProfile,
|
experimentOptionsForProfile,
|
||||||
|
freshestLaboratorySelection,
|
||||||
workOptionsForExperiment,
|
workOptionsForExperiment,
|
||||||
} from "./laboratoryArchiveProfiles";
|
} from "./laboratoryArchiveProfiles";
|
||||||
import {
|
import {
|
||||||
@@ -481,7 +482,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
const [workId, setWorkId] = useState<LaboratoryWorkId>(
|
const [workId, setWorkId] = useState<LaboratoryWorkId>(
|
||||||
"l34-right-yolox-truth-island-freeze",
|
"l34-right-yolox-truth-island-freeze",
|
||||||
);
|
);
|
||||||
const initialWorkSelectedRef = useRef(false);
|
const initialSelectionAppliedRef = useRef(false);
|
||||||
const [e28Model, setE28Model] = useState<LidarLocalSurfaceModel | null>(null);
|
const [e28Model, setE28Model] = useState<LidarLocalSurfaceModel | null>(null);
|
||||||
const [e29Result, setE29Result] = useState<E29EvidenceResult | null>(null);
|
const [e29Result, setE29Result] = useState<E29EvidenceResult | null>(null);
|
||||||
const [e30Result, setE30Result] = useState<E30ReviewResult | null>(null);
|
const [e30Result, setE30Result] = useState<E30ReviewResult | null>(null);
|
||||||
@@ -656,6 +657,15 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
|| sessions.state === "idle"
|
|| sessions.state === "idle"
|
||||||
|| sessions.state === "loading"
|
|| sessions.state === "loading"
|
||||||
) return;
|
) 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)) {
|
if (!profiles.some((profile) => profile.id === profileId)) {
|
||||||
const firstProfile = profiles[0];
|
const firstProfile = profiles[0];
|
||||||
if (!firstProfile) return;
|
if (!firstProfile) return;
|
||||||
@@ -667,13 +677,6 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
if (firstExperiment) setExperimentId(firstExperiment.id);
|
if (firstExperiment) setExperimentId(firstExperiment.id);
|
||||||
return;
|
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)) {
|
if (!workOptions.some((work) => work.id === workId)) {
|
||||||
const firstWork = workOptions[0];
|
const firstWork = workOptions[0];
|
||||||
if (firstWork) setWorkId(firstWork.id);
|
if (firstWork) setWorkId(firstWork.id);
|
||||||
@@ -681,6 +684,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
}, [
|
}, [
|
||||||
evidenceLoading,
|
evidenceLoading,
|
||||||
advanced.indexLoading,
|
advanced.indexLoading,
|
||||||
|
catalog,
|
||||||
experimentId,
|
experimentId,
|
||||||
experimentOptions,
|
experimentOptions,
|
||||||
profileId,
|
profileId,
|
||||||
@@ -692,7 +696,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
|
|
||||||
const selectProfile = (next: LaboratoryProfileId) => {
|
const selectProfile = (next: LaboratoryProfileId) => {
|
||||||
setProfileId(next);
|
setProfileId(next);
|
||||||
initialWorkSelectedRef.current = true;
|
initialSelectionAppliedRef.current = true;
|
||||||
const firstExperiment = experimentOptionsForProfile(next, catalog)[0];
|
const firstExperiment = experimentOptionsForProfile(next, catalog)[0];
|
||||||
if (!firstExperiment) return;
|
if (!firstExperiment) return;
|
||||||
setExperimentId(firstExperiment.id);
|
setExperimentId(firstExperiment.id);
|
||||||
@@ -702,7 +706,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
|
|
||||||
const selectExperiment = (next: LaboratoryExperimentId) => {
|
const selectExperiment = (next: LaboratoryExperimentId) => {
|
||||||
setExperimentId(next);
|
setExperimentId(next);
|
||||||
initialWorkSelectedRef.current = true;
|
initialSelectionAppliedRef.current = true;
|
||||||
const firstWork = workOptionsForExperiment(profileId, next, catalog)[0];
|
const firstWork = workOptionsForExperiment(profileId, next, catalog)[0];
|
||||||
if (firstWork) selectWork(firstWork.id);
|
if (firstWork) selectWork(firstWork.id);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
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 { ObservationTimeline } from "../../components/ObservationTimeline";
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +29,7 @@ import { useRecordedEvidencePlayback } from "../../components/laboratory/useReco
|
|||||||
import {
|
import {
|
||||||
e47SemanticMaskUrl,
|
e47SemanticMaskUrl,
|
||||||
type E47SemanticClass,
|
type E47SemanticClass,
|
||||||
|
type E47SemanticTimelineFrame,
|
||||||
} from "../../core/laboratory/e47SemanticSlam";
|
} from "../../core/laboratory/e47SemanticSlam";
|
||||||
import type {
|
import type {
|
||||||
M4ThreatCameraProposal,
|
M4ThreatCameraProposal,
|
||||||
@@ -37,7 +45,9 @@ import {
|
|||||||
} from "./useM4ThreatTimeline";
|
} from "./useM4ThreatTimeline";
|
||||||
import { useE47SemanticTimelineFrame } from "./useE47SemanticTimeline";
|
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"] {
|
function toneForProposal(proposal: M4ThreatCameraProposal): RecordedEvidenceBox["tone"] {
|
||||||
if (proposal.threatDecision === "threat") return "danger";
|
if (proposal.threatDecision === "threat") return "danger";
|
||||||
@@ -87,12 +97,19 @@ export function M4ReplayThreatVisual({
|
|||||||
resultId: string;
|
resultId: string;
|
||||||
semantic?: M4ReplayThreatSemanticLayer;
|
semantic?: M4ReplayThreatSemanticLayer;
|
||||||
}) {
|
}) {
|
||||||
const [mode, setMode] = useState<M4ThreatViewMode>("video");
|
const [mediaMode, setMediaMode] = useState<M4ThreatMediaMode | null>("video");
|
||||||
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode>("3d");
|
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode | null>(null);
|
||||||
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
||||||
const [showLocalSurface, setShowLocalSurface] = useState(true);
|
const [showLocalSurface, setShowLocalSurface] = useState(true);
|
||||||
const [showRollingMap, setShowRollingMap] = 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 [expanded, setExpanded] = useState(false);
|
||||||
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
|
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
|
||||||
const metadata = useM4ThreatTimelineMetadata(resultId);
|
const metadata = useM4ThreatTimelineMetadata(resultId);
|
||||||
@@ -115,6 +132,14 @@ export function M4ReplayThreatVisual({
|
|||||||
setVideoError(null);
|
setVideoError(null);
|
||||||
}, [resultId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const timeline = metadata.timeline;
|
const timeline = metadata.timeline;
|
||||||
if (!timeline || videoSource) return;
|
if (!timeline || videoSource) return;
|
||||||
@@ -162,6 +187,18 @@ export function M4ReplayThreatVisual({
|
|||||||
}, [resultId]);
|
}, [resultId]);
|
||||||
if (timelineFrame.activeFrame) lastFrameRef.current = timelineFrame.activeFrame;
|
if (timelineFrame.activeFrame) lastFrameRef.current = timelineFrame.activeFrame;
|
||||||
const frame = timelineFrame.activeFrame ?? lastFrameRef.current;
|
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({
|
const semanticTimeline = useE47SemanticTimelineFrame({
|
||||||
resultId: semantic?.resultId ?? null,
|
resultId: semantic?.resultId ?? null,
|
||||||
activeSequence: frame?.sequence ?? timelineFrame.activeSequence,
|
activeSequence: frame?.sequence ?? timelineFrame.activeSequence,
|
||||||
@@ -174,33 +211,6 @@ export function M4ReplayThreatVisual({
|
|||||||
&& frame.sequence !== timelineFrame.activeSequence,
|
&& frame.sequence !== timelineFrame.activeSequence,
|
||||||
);
|
);
|
||||||
const activeBoxes = useMemo(() => boxes(frame?.cameraProposals ?? []), [frame]);
|
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[]>(
|
const semanticClasses = useMemo<readonly RecordedEvidenceSemanticClass[]>(
|
||||||
() => semantic?.taxonomy.map((item) => ({
|
() => semantic?.taxonomy.map((item) => ({
|
||||||
id: item.classId,
|
id: item.classId,
|
||||||
@@ -214,50 +224,95 @@ export function M4ReplayThreatVisual({
|
|||||||
color: item.disposition === "ambiguous"
|
color: item.disposition === "ambiguous"
|
||||||
? { kind: "token" as const, token: "--nodedc-warning-rgb" as const }
|
? { kind: "token" as const, token: "--nodedc-warning-rgb" as const }
|
||||||
: { kind: "diagnostic" as const, rgb: item.colorRgb },
|
: { 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],
|
[semantic?.taxonomy],
|
||||||
);
|
);
|
||||||
const semanticFrame = semanticTimeline.activeFrame?.sequence === frame?.sequence
|
const semanticFrame = semanticTimeline.activeFrame?.sequence === frame?.sequence
|
||||||
? semanticTimeline.activeFrame
|
? semanticTimeline.activeFrame
|
||||||
: null;
|
: null;
|
||||||
const semanticIntegrityError = semantic && frame?.spatialAvailable && semanticFrame && (
|
const lastSpatialSemanticFrameRef = useRef<{
|
||||||
semanticFrame.sourcePointCount !== frame.pointCloudSourceCount
|
resultId: string;
|
||||||
|| frame.pointCloudSampleCount !== frame.pointCloudSourceCount
|
frame: E47SemanticTimelineFrame;
|
||||||
|| frame.pointCloudBodyXyzM.length !== frame.pointCloudSourceCount
|
} | 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."
|
? "E47 semantic point index space не совпал с exact current increment M4.6."
|
||||||
: null;
|
: null;
|
||||||
const alignedSemanticPointIds = useMemo<readonly (number | null)[] | undefined>(() => {
|
const alignedSemanticPointIds = useMemo<readonly (number | null)[] | undefined>(() => {
|
||||||
if (
|
if (
|
||||||
!semantic
|
!semantic
|
||||||
|| !showSemantic
|
|| !showSpatialSemantic
|
||||||
|| !frame
|
|| !spatialFrame
|
||||||
|| !frame.spatialAvailable
|
|| !spatialSemanticFrame
|
||||||
|| !semanticFrame
|
|
||||||
|| semanticIntegrityError
|
|| semanticIntegrityError
|
||||||
) return undefined;
|
) return undefined;
|
||||||
return semanticFrame.classIds.map((classId, index) => {
|
return spatialSemanticFrame.classIds.map((classId, index) => {
|
||||||
const status = semanticFrame.statusCodes[index];
|
const status = spatialSemanticFrame.statusCodes[index];
|
||||||
return status === 2 || status === 3 ? classId : null;
|
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 =
|
const semanticOverlay: RecordedEvidenceSemanticOverlay | undefined =
|
||||||
semantic && showSemantic && frame
|
semantic && showMediaSemantic && frame
|
||||||
? {
|
? {
|
||||||
src: e47SemanticMaskUrl(semantic.resultId, frame.sequence),
|
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,
|
classes: semanticClasses,
|
||||||
palette: semanticPalette,
|
palette: semanticPalette,
|
||||||
opacity: 0.48,
|
opacity: 0.9,
|
||||||
ariaLabel: `E47 semantic mask frame ${frame.sequence + 1}`,
|
ariaLabel: `E47 semantic mask frame ${frame.sequence + 1}`,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const seek = (seconds: number) => playbackController.seek(seconds);
|
const handleMediaModeChange = (next: M4ThreatMediaSelection) => {
|
||||||
const handleModeChange = (next: M4ThreatViewMode) => {
|
if (next === "none") return;
|
||||||
if (next === "camera") playbackController.setPlaying(false);
|
setMediaMode((current) => current === next ? null : next);
|
||||||
if (next === "3d" || next === "plan") setSpatialMode(next);
|
};
|
||||||
setMode(next);
|
const handleSpatialModeChange = (next: M4ThreatSpatialSelection) => {
|
||||||
|
if (next === "none") return;
|
||||||
|
setSpatialMode((current) => current === next ? null : next);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -266,86 +321,124 @@ export function M4ReplayThreatVisual({
|
|||||||
image.src = frame.cameraUrl;
|
image.src = frame.cameraUrl;
|
||||||
}, [frame?.cameraUrl, playbackController.playback.playing]);
|
}, [frame?.cameraUrl, playbackController.playback.playing]);
|
||||||
|
|
||||||
const actions = (
|
const splitView = mediaMode !== null && spatialMode !== null;
|
||||||
<div className="l3-visual-audit__actions">
|
|
||||||
<div className="l3-visual-audit__pagination">
|
const mediaModeControls = (
|
||||||
<IconButton
|
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="media">
|
||||||
label="Назад на 5 секунд"
|
<SegmentedControl
|
||||||
disabled={!metadata.timeline}
|
value={mediaMode ?? "none"}
|
||||||
onClick={() => seek(playbackController.playback.currentSeconds - 5)}
|
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} />
|
SEMANTICS
|
||||||
</IconButton>
|
</Button>
|
||||||
<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>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const trailingActions = mode === "3d" || mode === "plan" ? (
|
const resetSpatialView = (
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Сбросить ракурс"
|
label="Сбросить ракурс"
|
||||||
onClick={() => metricSceneRef.current?.resetView()}
|
onClick={() => metricSceneRef.current?.resetView()}
|
||||||
>
|
>
|
||||||
<Icon name="refresh" size={16} />
|
<Icon name="refresh" size={16} />
|
||||||
</IconButton>
|
</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 ? (
|
const overlay = metadata.timeline && frame ? (
|
||||||
<div className="l3-visual-audit__overlay m4-replay-threat-visual__overlay">
|
<div className="l3-visual-audit__overlay m4-replay-threat-visual__overlay">
|
||||||
@@ -365,18 +458,18 @@ export function M4ReplayThreatVisual({
|
|||||||
{currentIncrementObstacles.length} current · {rollingMapObstacles.length} rolling
|
{currentIncrementObstacles.length} current · {rollingMapObstacles.length} rolling
|
||||||
</strong>
|
</strong>
|
||||||
<small>
|
<small>
|
||||||
{frame.spatialAvailable
|
{spatialFrame
|
||||||
? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} exact · ${localSurface.pointsBodyXyzM.length} local SLAM / ${localSurface.sourceFrameCount} frames`
|
? `${spatialFrame.pointCloudSampleCount}/${spatialFrame.pointCloudSourceCount} exact · ${localSurface.pointsBodyXyzM.length} local SLAM / ${localSurface.sourceFrameCount} frames`
|
||||||
: "body frame / current increment unavailable"}
|
: "квалифицированный spatial frame ещё не получен"}
|
||||||
{semantic && semanticFrame
|
{semantic && spatialSemanticFrame
|
||||||
? ` · semantic L ${semanticFrame.counts.labeled} · A ${semanticFrame.counts.ambiguous} · U ${semanticFrame.counts.unprojected} · Ø ${semanticFrame.counts.absent}`
|
? ` · semantic L ${spatialSemanticFrame.counts.labeled} · A ${spatialSemanticFrame.counts.ambiguous} · U ${spatialSemanticFrame.counts.unprojected} · Ø ${spatialSemanticFrame.counts.absent}`
|
||||||
: semantic ? " · semantic buffer" : ""}
|
: semantic ? " · semantic buffer" : ""}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Virtual corridor</span>
|
<span>Virtual corridor</span>
|
||||||
<strong>
|
<strong>
|
||||||
{frame.decisionCounts.threat} threat · nearest {nearest === null ? "—" : `${nearest.toFixed(2)} м`}
|
{spatialFrame?.decisionCounts.threat ?? 0} threat · nearest {nearest === null ? "—" : `${nearest.toFixed(2)} м`}
|
||||||
</strong>
|
</strong>
|
||||||
<small>
|
<small>
|
||||||
{metadata.timeline.corridor.forwardLengthM} м · body {metadata.timeline.rig.lengthM}×{metadata.timeline.rig.widthM} м · REPLAY-SIMULATED
|
{metadata.timeline.corridor.forwardLengthM} м · body {metadata.timeline.rig.lengthM}×{metadata.timeline.rig.widthM} м · REPLAY-SIMULATED
|
||||||
@@ -397,74 +490,129 @@ export function M4ReplayThreatVisual({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
content = (
|
const mediaPane = (
|
||||||
<div className="m4-replay-threat-visual__deck">
|
<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
|
<div
|
||||||
className="m4-replay-threat-visual__layer"
|
className="m4-replay-threat-visual__media-layer"
|
||||||
data-active={mode === "video" ? "true" : undefined}
|
data-media="video"
|
||||||
aria-hidden={mode !== "video"}
|
hidden={mediaMode !== "video"}
|
||||||
>
|
>
|
||||||
{videoSource ? (
|
{videoSource ? (
|
||||||
<RecordedEvidenceVideoScene
|
<RecordedEvidenceVideoScene
|
||||||
source={videoSource}
|
source={videoSource}
|
||||||
playback={playbackController.playback}
|
playback={playbackController.playback}
|
||||||
imageWidth={timeline.imageWidth}
|
imageWidth={timeline.imageWidth}
|
||||||
imageHeight={timeline.imageHeight}
|
imageHeight={timeline.imageHeight}
|
||||||
boxes={activeBoxes}
|
boxes={activeBoxes}
|
||||||
semanticOverlay={mode === "video" ? semanticOverlay : undefined}
|
semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined}
|
||||||
ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
||||||
interactive={false}
|
interactive={false}
|
||||||
/>
|
/>
|
||||||
) : videoError ? (
|
) : videoError ? (
|
||||||
<SpatialState message={videoError} />
|
<SpatialState message={videoError} />
|
||||||
) : (
|
) : (
|
||||||
<div className="l3-visual-audit__state" role="status">
|
<div className="l3-visual-audit__state" role="status">
|
||||||
<span className="busy-indicator" aria-hidden="true" />
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
<span>{videoLoading ? "Подготавливаем локальный видеобуфер" : "Открываем RIGHT-видео RAVNOVES00"}</span>
|
<span>{videoLoading ? "Подготавливаем локальный видеобуфер" : "Открываем RIGHT-видео RAVNOVES00"}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
{mediaMode === "camera" && frame ? (
|
||||||
className="m4-replay-threat-visual__layer"
|
<RecordedEvidenceImageScene
|
||||||
data-active={mode === "camera" ? "true" : undefined}
|
src={frame.cameraUrl}
|
||||||
aria-hidden={mode !== "camera"}
|
imageWidth={timeline.imageWidth}
|
||||||
>
|
imageHeight={timeline.imageHeight}
|
||||||
{mode === "camera" && frame ? (
|
boxes={activeBoxes}
|
||||||
<RecordedEvidenceImageScene
|
semanticOverlay={semanticOverlay}
|
||||||
src={frame.cameraUrl}
|
ariaLabel={`M4.6 exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
||||||
imageWidth={timeline.imageWidth}
|
/>
|
||||||
imageHeight={timeline.imageHeight}
|
) : null}
|
||||||
boxes={activeBoxes}
|
</section>
|
||||||
semanticOverlay={mode === "camera" ? semanticOverlay : undefined}
|
);
|
||||||
ariaLabel={`M4.6 exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
|
||||||
/>
|
const spatialPane = spatialMode ? (
|
||||||
) : null}
|
<section
|
||||||
</div>
|
className="m4-replay-threat-visual__pane"
|
||||||
<div
|
data-pane="spatial"
|
||||||
className="m4-replay-threat-visual__layer"
|
aria-label={spatialMode === "3d" ? "Трёхмерная сцена" : "Вид сверху"}
|
||||||
data-active={mode === "3d" || mode === "plan" ? "true" : undefined}
|
>
|
||||||
aria-hidden={mode !== "3d" && mode !== "plan"}
|
{splitView ? (
|
||||||
>
|
<div
|
||||||
{frame ? (
|
className="m4-replay-threat-visual__pane-toolbar"
|
||||||
<LaboratoryMetricEvidenceScene
|
data-pane-toolbar="spatial"
|
||||||
ref={metricSceneRef}
|
>
|
||||||
pointCloudBodyXyzM={frame.pointCloudBodyXyzM}
|
{resetSpatialView}
|
||||||
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
<div className="m4-replay-threat-visual__spatial-toolbar-end">
|
||||||
obstacles={sceneObstacles}
|
{spatialLayerControls}
|
||||||
rig={timeline.rig}
|
{spatialModeControls}
|
||||||
corridor={timeline.corridor}
|
</div>
|
||||||
occupiedVoxelSizeM={timeline.occupiedVoxelSizeM}
|
</div>
|
||||||
mode={spatialMode}
|
) : null}
|
||||||
label="M4.6 exact current increment, bounded local SLAM surface and rolling occupancy"
|
{spatialFrame ? (
|
||||||
showCurrentIncrement={showCurrentIncrement}
|
<LaboratoryMetricEvidenceScene
|
||||||
showLocalSurface={showLocalSurface}
|
ref={metricSceneRef}
|
||||||
showRollingMap={showRollingMap}
|
pointCloudBodyXyzM={spatialFrame.pointCloudBodyXyzM}
|
||||||
pointSemanticClassIds={alignedSemanticPointIds}
|
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
||||||
semanticClasses={semanticClasses}
|
obstacles={sceneObstacles}
|
||||||
semanticPalette={semanticPalette}
|
rig={timeline.rig}
|
||||||
/>
|
corridor={timeline.corridor}
|
||||||
) : null}
|
occupiedVoxelSizeM={timeline.occupiedVoxelSizeM}
|
||||||
</div>
|
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 ? (
|
{timelineFrame.loading || displayingBufferedFrame ? (
|
||||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||||
<span className="busy-indicator" aria-hidden="true" />
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
@@ -495,11 +643,6 @@ export function M4ReplayThreatVisual({
|
|||||||
<span>{semanticIntegrityError}</span>
|
<span>{semanticIntegrityError}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{frame && !frame.spatialAvailable && (mode === "3d" || mode === "plan") ? (
|
|
||||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
|
||||||
На этом кадре нет квалифицированного body frame; сцена сохранена.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -533,16 +676,24 @@ export function M4ReplayThreatVisual({
|
|||||||
? "E47 semantic + SLAM diagnostic replay"
|
? "E47 semantic + SLAM diagnostic replay"
|
||||||
: "M4.6 dual-evidence recorded-realtime replay"}
|
: "M4.6 dual-evidence recorded-realtime replay"}
|
||||||
className="m4-replay-threat-evidence-viewer"
|
className="m4-replay-threat-evidence-viewer"
|
||||||
mode={mode}
|
mode={mediaMode ?? "none"}
|
||||||
modes={[
|
modes={[
|
||||||
{ value: "video", label: "VIDEO" },
|
{ value: "video", label: "VIDEO" },
|
||||||
{ value: "camera", label: "CAMERA" },
|
{ 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}
|
expanded={expanded}
|
||||||
onModeChange={handleModeChange}
|
onModeChange={handleMediaModeChange}
|
||||||
onExpandedChange={setExpanded}
|
onExpandedChange={setExpanded}
|
||||||
|
modeControlsVisible={!splitView}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
overlay={overlay}
|
overlay={overlay}
|
||||||
transport={transport}
|
transport={transport}
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ export interface LaboratoryCatalogEntry {
|
|||||||
variantName: string;
|
variantName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LaboratorySelection {
|
||||||
|
profileId: LaboratoryProfileId;
|
||||||
|
experimentId: LaboratoryExperimentId;
|
||||||
|
workId: LaboratoryWorkId;
|
||||||
|
}
|
||||||
|
|
||||||
interface KnownWorkDefinition {
|
interface KnownWorkDefinition {
|
||||||
profileId: Exclude<LaboratoryProfileId, `published:${string}`>;
|
profileId: Exclude<LaboratoryProfileId, `published:${string}`>;
|
||||||
profileName: (rigLabel: string) => string;
|
profileName: (rigLabel: string) => string;
|
||||||
@@ -478,3 +484,19 @@ export function workOptionsForExperiment(
|
|||||||
label: `${laboratoryTimestamp(entry.createdAtUtc)} · ${entry.variantName}`,
|
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 buildLaboratoryCatalog;
|
||||||
let buildLaboratoryProfiles;
|
let buildLaboratoryProfiles;
|
||||||
let experimentOptionsForProfile;
|
let experimentOptionsForProfile;
|
||||||
|
let freshestLaboratorySelection;
|
||||||
let workOptionsForExperiment;
|
let workOptionsForExperiment;
|
||||||
let fetchL34RightYoloxTruthIsland;
|
let fetchL34RightYoloxTruthIsland;
|
||||||
let fetchL34RightYoloxTruthIslandFrame;
|
let fetchL34RightYoloxTruthIslandFrame;
|
||||||
@@ -853,6 +854,7 @@ before(async () => {
|
|||||||
buildLaboratoryCatalog,
|
buildLaboratoryCatalog,
|
||||||
buildLaboratoryProfiles,
|
buildLaboratoryProfiles,
|
||||||
experimentOptionsForProfile,
|
experimentOptionsForProfile,
|
||||||
|
freshestLaboratorySelection,
|
||||||
workOptionsForExperiment,
|
workOptionsForExperiment,
|
||||||
} = await server.ssrLoadModule(
|
} = await server.ssrLoadModule(
|
||||||
"/src/workspaces/laboratory/laboratoryArchiveProfiles.ts",
|
"/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", () => {
|
test("E46E is exposed as the newest independent NVIDIA pipeline", () => {
|
||||||
const catalog = buildLaboratoryCatalog({
|
const catalog = buildLaboratoryCatalog({
|
||||||
rigLabel: "K1",
|
rigLabel: "K1",
|
||||||
|
|||||||
@@ -224,7 +224,9 @@ test("E30 uses one reusable evidence viewer with camera, 3D and expand controls"
|
|||||||
assert.match(workspaceSource, /review: "Проверка"/);
|
assert.match(workspaceSource, /review: "Проверка"/);
|
||||||
assert.doesNotMatch(workspaceSource, /e30-review-evidence__facts/);
|
assert.doesNotMatch(workspaceSource, /e30-review-evidence__facts/);
|
||||||
assert.doesNotMatch(workspaceSource, /E30EngineeringAuditPanel/);
|
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(viewerSource, /name=\{expanded \? "minimize" : "expand"\}/);
|
||||||
assert.match(
|
assert.match(
|
||||||
laboratoryStyles,
|
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 () => {
|
test("M4.6 viewer keeps media and spatial panes on one playback clock", async () => {
|
||||||
const [visual, imageScene, videoScene, metricScene] = await Promise.all([
|
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/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/RecordedEvidenceImageScene.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../src/components/laboratory/RecordedEvidenceVideoScene.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"),
|
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, /<LaboratoryMetricEvidenceScene/);
|
||||||
assert.match(visual, /m4-replay-threat-visual__deck/);
|
assert.match(visual, /m4-replay-threat-visual__deck/);
|
||||||
assert.match(visual, /lastFrameRef/);
|
assert.match(visual, /lastFrameRef/);
|
||||||
|
assert.match(visual, /lastSpatialFrameRef/);
|
||||||
|
assert.match(visual, /const spatialFrame = frame\?\.spatialAvailable/);
|
||||||
assert.match(visual, /<ObservationTimeline/);
|
assert.match(visual, /<ObservationTimeline/);
|
||||||
assert.match(visual, /useM4ThreatTimelineFrame/);
|
assert.match(visual, /useM4ThreatTimelineFrame/);
|
||||||
assert.match(visual, /label: "VIDEO"/);
|
assert.match(visual, /label: "VIDEO"/);
|
||||||
assert.match(visual, /label: "CAMERA"/);
|
assert.match(visual, /label: "CAMERA"/);
|
||||||
assert.match(visual, /label: "3D"/);
|
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(videoScene, /<RecordedFmp4Player/);
|
||||||
assert.match(imageScene, /<RecordedEvidenceBoxOverlay/);
|
assert.match(imageScene, /<RecordedEvidenceBoxOverlay/);
|
||||||
assert.match(metricScene, /OrbitControls/);
|
assert.match(metricScene, /OrbitControls/);
|
||||||
assert.match(visual, /LOCAL SLAM/);
|
assert.match(visual, /LOCAL SLAM/);
|
||||||
assert.match(visual, /showLocalSurface/);
|
assert.match(visual, /showLocalSurface/);
|
||||||
|
assert.match(visual, /pointCloudBodyXyzM=\{spatialFrame\.pointCloudBodyXyzM\}/);
|
||||||
assert.match(metricScene, /Local SLAM surface/);
|
assert.match(metricScene, /Local SLAM surface/);
|
||||||
assert.match(visual, /showJumpToEnd=\{false\}/);
|
assert.match(visual, /showJumpToEnd=\{false\}/);
|
||||||
|
assert.doesNotMatch(visual, /Назад на 5 секунд/);
|
||||||
|
assert.doesNotMatch(visual, /Вперёд на 5 секунд/);
|
||||||
assert.doesNotMatch(metricScene, /ЛКМ · вращение/);
|
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, /experimentOptionsForProfile/);
|
||||||
assert.match(laboratorySource, /workOptionsForExperiment/);
|
assert.match(laboratorySource, /workOptionsForExperiment/);
|
||||||
assert.doesNotMatch(laboratorySource, /laboratoryWorkOrdinal/);
|
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, /pollingEnabled:\s*false/);
|
||||||
assert.match(laboratorySource, /useAdvancedLaboratoryCatalog/);
|
assert.match(laboratorySource, /useAdvancedLaboratoryCatalog/);
|
||||||
assert.doesNotMatch(laboratorySource, /fetchAdvancedLaboratoryResults/);
|
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);
|
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");
|
const source = await readFile(component("RecordedEvidenceSemanticMaskOverlay.tsx"), "utf8");
|
||||||
assert.match(source, /decodedMaskCache/);
|
assert.match(source, /decodedMaskCache/);
|
||||||
assert.match(source, /pendingMaskCache/);
|
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, /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, /getImageData/);
|
||||||
assert.match(source, /8-bit grayscale class-id PNG/);
|
|
||||||
assert.match(source, /ResizeObserver/);
|
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, /decoded\.key !== expectedKey/);
|
||||||
assert.match(source, /mask\?\.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, /recorded-evidence-semantic-mask-overlay__error/);
|
||||||
assert.match(source, /role="alert"/);
|
assert.match(source, /role="alert"/);
|
||||||
});
|
});
|
||||||
@@ -43,27 +55,40 @@ test("metric evidence keeps missing semantic assignments as context and exposes
|
|||||||
assert.match(source, /recordedEvidenceSemanticCssColor/);
|
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(
|
const source = await readFile(
|
||||||
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
assert.match(source, /semantic && frame\?\.spatialAvailable && semanticFrame/);
|
assert.match(source, /frame\?\.spatialAvailable && semanticFrame/);
|
||||||
assert.match(source, /\|\| !frame\.spatialAvailable\s*\|\| !semanticFrame/);
|
assert.match(source, /lastSpatialSemanticFrameRef/);
|
||||||
assert.match(source, /semanticFrame\.sourcePointCount !== frame\.pointCloudSourceCount/);
|
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(
|
const source = await readFile(
|
||||||
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(source, /showMediaSemantic/);
|
||||||
source,
|
assert.match(source, /showSpatialSemantic/);
|
||||||
/semanticOverlay=\{mode === "video" \? semanticOverlay : undefined\}/,
|
assert.match(source, /semantic && showMediaSemantic && frame/);
|
||||||
);
|
assert.match(source, /Array\.from\(\{ length: 12 \}, \(_, index\) => index \+ 1\)/);
|
||||||
assert.match(
|
assert.match(source, /\|\| !showSpatialSemantic/);
|
||||||
source,
|
assert.match(source, /aria-label="Слои камеры и видео"/);
|
||||||
/semanticOverlay=\{mode === "camera" \? semanticOverlay : undefined\}/,
|
assert.match(source, /aria-label="Слои 3D и плана"/);
|
||||||
);
|
assert.match(source, /data-pane-mode="media"/);
|
||||||
|
assert.match(source, /data-pane-mode="spatial"/);
|
||||||
|
assert.match(source, /modeControlsVisible=\{!splitView\}/);
|
||||||
|
assert.match(source, /semanticOverlay=\{mediaMode === "video" \? semanticOverlay : undefined\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("laboratory fullscreen preserves the mounted evidence subtree", async () => {
|
||||||
|
const source = await readFile(component("LaboratoryEvidenceViewer.tsx"), "utf8");
|
||||||
|
assert.match(source, /useLayoutEffect/);
|
||||||
|
assert.match(source, /viewer\.showModal\(\)/);
|
||||||
|
assert.match(source, /viewer\.show\(\)/);
|
||||||
|
assert.match(source, /<dialog/);
|
||||||
|
assert.doesNotMatch(source, /createPortal/);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user