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

This commit is contained in:
DCCONSTRUCTIONS
2026-08-23 18:56:06 +03:00
parent a3fc13ad63
commit 51bb1369eb
12 changed files with 1042 additions and 336 deletions
@@ -1,9 +1,9 @@
import {
useEffect,
useLayoutEffect,
useRef,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import {
Icon,
IconButton,
@@ -31,6 +31,7 @@ export function LaboratoryEvidenceViewer<
overlay,
transport,
trailingActions,
modeControlsVisible = true,
children,
}: {
label: string;
@@ -50,9 +51,22 @@ export function LaboratoryEvidenceViewer<
overlay?: ReactNode;
transport?: ReactNode;
trailingActions?: ReactNode;
modeControlsVisible?: boolean;
children: ReactNode;
}) {
const expandButtonRef = useRef<HTMLButtonElement | null>(null);
const viewerRef = useRef<HTMLDialogElement | null>(null);
useLayoutEffect(() => {
const viewer = viewerRef.current;
if (!viewer) return;
if (viewer.open) viewer.close();
if (expanded) {
viewer.showModal();
} else {
viewer.show();
}
}, [expanded]);
useEffect(() => {
if (!expanded) return;
@@ -67,12 +81,15 @@ export function LaboratoryEvidenceViewer<
}, [expanded, onExpandedChange]);
const viewer = (
<section
<dialog
ref={viewerRef}
role="region"
className={[
"laboratory-evidence-viewer",
className,
].filter(Boolean).join(" ")}
data-expanded={expanded ? "true" : undefined}
data-mode-controls={modeControlsVisible ? undefined : "content"}
aria-label={label}
>
<div className="laboratory-evidence-viewer__stage">
@@ -86,7 +103,7 @@ export function LaboratoryEvidenceViewer<
) : null}
<div className="laboratory-evidence-viewer__controls">
{actions}
{secondaryMode ? (
{modeControlsVisible && secondaryMode ? (
<SegmentedControl
value={secondaryMode.value}
items={[...secondaryMode.modes]}
@@ -94,12 +111,14 @@ export function LaboratoryEvidenceViewer<
onChange={secondaryMode.onChange}
/>
) : null}
<SegmentedControl
value={mode}
items={[...modes]}
label={`${label}: режим представления`}
onChange={onModeChange}
/>
{modeControlsVisible ? (
<SegmentedControl
value={mode}
items={[...modes]}
label={`${label}: режим представления`}
onChange={onModeChange}
/>
) : null}
{trailingActions}
<IconButton
ref={expandButtonRef}
@@ -109,8 +128,8 @@ export function LaboratoryEvidenceViewer<
<Icon name={expanded ? "minimize" : "expand"} size={16} />
</IconButton>
</div>
</section>
</dialog>
);
return expanded ? createPortal(viewer, document.body) : viewer;
return viewer;
}
@@ -34,6 +34,7 @@ export interface RecordedEvidenceSemanticPaletteEntry {
export interface RecordedEvidenceSemanticOverlay {
src: string;
prefetchSrcs?: readonly string[];
classes: readonly RecordedEvidenceSemanticClass[];
palette: readonly RecordedEvidenceSemanticPaletteEntry[];
opacity?: number;
@@ -44,7 +45,8 @@ interface DecodedSemanticMask {
key: string;
width: number;
height: number;
classIds: Uint8Array;
source: ImageBitmap | HTMLImageElement;
release: () => void;
}
interface PendingSemanticMask {
@@ -53,7 +55,8 @@ interface PendingSemanticMask {
promise: Promise<DecodedSemanticMask>;
}
const MASK_CACHE_LIMIT = 48;
const MASK_CACHE_LIMIT = 20;
const MASK_PREFETCH_LIMIT = 12;
const decodedMaskCache = new Map<string, DecodedSemanticMask>();
const pendingMaskCache = new Map<string, PendingSemanticMask>();
@@ -79,19 +82,23 @@ function semanticMaskKey(src: string, width: number, height: number): string {
}
function rememberMask(mask: DecodedSemanticMask): void {
const previous = decodedMaskCache.get(mask.key);
if (previous && previous !== mask) previous.release();
decodedMaskCache.delete(mask.key);
decodedMaskCache.set(mask.key, mask);
while (decodedMaskCache.size > MASK_CACHE_LIMIT) {
const oldest = decodedMaskCache.keys().next().value;
if (typeof oldest !== "string") break;
const evicted = decodedMaskCache.get(oldest);
decodedMaskCache.delete(oldest);
evicted?.release();
}
}
async function imageSourceFromBlob(
blob: Blob,
signal: AbortSignal,
): Promise<{ source: CanvasImageSource; width: number; height: number; release: () => void }> {
): Promise<{ source: ImageBitmap | HTMLImageElement; width: number; height: number; release: () => void }> {
if (typeof createImageBitmap === "function") {
const bitmap = await createImageBitmap(blob, {
colorSpaceConversion: "none",
@@ -160,31 +167,19 @@ async function decodeSemanticMask(
const blob = await response.blob();
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
const decoded = await imageSourceFromBlob(blob, signal);
try {
if (decoded.width !== expectedWidth || decoded.height !== expectedHeight) {
throw new Error(
`Semantic mask dimensions ${decoded.width}x${decoded.height} do not match ${expectedWidth}x${expectedHeight}`,
);
}
const decodeCanvas = document.createElement("canvas");
decodeCanvas.width = decoded.width;
decodeCanvas.height = decoded.height;
const context = decodeCanvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("Semantic mask canvas is unavailable");
context.drawImage(decoded.source, 0, 0);
const rgba = context.getImageData(0, 0, decoded.width, decoded.height).data;
const classIds = new Uint8Array(decoded.width * decoded.height);
for (let sourceOffset = 0, targetOffset = 0; targetOffset < classIds.length; sourceOffset += 4, targetOffset += 1) {
const classId = rgba[sourceOffset] ?? 0;
if (rgba[sourceOffset + 1] !== classId || rgba[sourceOffset + 2] !== classId) {
throw new Error("Semantic mask must be an 8-bit grayscale class-id PNG");
}
classIds[targetOffset] = classId;
}
return { key, width: decoded.width, height: decoded.height, classIds };
} finally {
if (decoded.width !== expectedWidth || decoded.height !== expectedHeight) {
decoded.release();
throw new Error(
`Semantic mask dimensions ${decoded.width}x${decoded.height} do not match ${expectedWidth}x${expectedHeight}`,
);
}
return {
key,
width: decoded.width,
height: decoded.height,
source: decoded.source,
release: decoded.release,
};
}
function subscribeToSemanticMask(
@@ -257,8 +252,252 @@ export function resolveRecordedEvidenceSemanticRgb(
: TOKEN_FALLBACKS[color.token];
}
interface SemanticMaskRenderer {
clear: (width: number, height: number, pixelRatio: number) => void;
render: (
mask: DecodedSemanticMask,
palette: Uint8Array,
width: number,
height: number,
pixelRatio: number,
) => void;
dispose: () => void;
}
function compileSemanticShader(
gl: WebGLRenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
gl.deleteShader(shader);
return null;
}
function createSemanticMaskRenderer(canvas: HTMLCanvasElement): SemanticMaskRenderer | null {
const gl = canvas.getContext("webgl", {
alpha: true,
antialias: false,
depth: false,
premultipliedAlpha: false,
preserveDrawingBuffer: true,
});
if (!gl) return null;
const vertexShader = compileSemanticShader(gl, gl.VERTEX_SHADER, `
attribute vec2 a_position;
attribute vec2 a_uv;
varying vec2 v_uv;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_uv = a_uv;
}
`);
const fragmentShader = compileSemanticShader(gl, gl.FRAGMENT_SHADER, `
precision mediump float;
uniform sampler2D u_mask;
uniform sampler2D u_palette;
varying vec2 v_uv;
void main() {
// DOM image sources use a top-left origin while WebGL texture coordinates
// use a bottom-left origin. UNPACK_FLIP_Y_WEBGL is ignored for
// ImageBitmap uploads, so keep upload semantics stable and flip V here.
vec2 mask_uv = vec2(v_uv.x, 1.0 - v_uv.y);
float class_id = floor(texture2D(u_mask, mask_uv).r * 255.0 + 0.5);
gl_FragColor = texture2D(u_palette, vec2((class_id + 0.5) / 256.0, 0.5));
}
`);
if (!vertexShader || !fragmentShader) {
if (vertexShader) gl.deleteShader(vertexShader);
if (fragmentShader) gl.deleteShader(fragmentShader);
return null;
}
const program = gl.createProgram();
if (!program) {
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
return null;
}
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
gl.deleteProgram(program);
return null;
}
const buffer = gl.createBuffer();
const maskTexture = gl.createTexture();
const paletteTexture = gl.createTexture();
if (!buffer || !maskTexture || !paletteTexture) {
if (buffer) gl.deleteBuffer(buffer);
if (maskTexture) gl.deleteTexture(maskTexture);
if (paletteTexture) gl.deleteTexture(paletteTexture);
gl.deleteProgram(program);
return null;
}
const positionLocation = gl.getAttribLocation(program, "a_position");
const uvLocation = gl.getAttribLocation(program, "a_uv");
const maskLocation = gl.getUniformLocation(program, "u_mask");
const paletteLocation = gl.getUniformLocation(program, "u_palette");
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 0, 0,
1, -1, 1, 0,
-1, 1, 0, 1,
1, 1, 1, 1,
]), gl.STATIC_DRAW);
const resize = (width: number, height: number, pixelRatio: number) => {
const pixelWidth = Math.max(1, Math.round(width * pixelRatio));
const pixelHeight = Math.max(1, Math.round(height * pixelRatio));
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
};
const clear = (width: number, height: number, pixelRatio: number) => {
resize(width, height, pixelRatio);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
};
return {
clear,
render: (mask, palette, width, height, pixelRatio) => {
if (gl.isContextLost()) throw new Error("Semantic WebGL context lost");
clear(width, height, pixelRatio);
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 16, 0);
gl.enableVertexAttribArray(uvLocation);
gl.vertexAttribPointer(uvLocation, 2, gl.FLOAT, false, 16, 8);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, maskTexture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, mask.source);
gl.uniform1i(maskLocation, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, paletteTexture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, palette);
gl.uniform1i(paletteLocation, 1);
const scale = Math.min(width / mask.width, height / mask.height);
const drawWidth = mask.width * scale;
const drawHeight = mask.height * scale;
const offsetX = (width - drawWidth) / 2;
const offsetY = (height - drawHeight) / 2;
gl.viewport(
Math.round(offsetX * pixelRatio),
Math.round(offsetY * pixelRatio),
Math.max(1, Math.round(drawWidth * pixelRatio)),
Math.max(1, Math.round(drawHeight * pixelRatio)),
);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
},
dispose: () => {
gl.deleteTexture(maskTexture);
gl.deleteTexture(paletteTexture);
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
},
};
}
function semanticPalettePixels(
host: HTMLElement,
classes: readonly RecordedEvidenceSemanticClass[],
palette: readonly RecordedEvidenceSemanticPaletteEntry[],
opacity: number,
): Uint8Array {
const pixels = new Uint8Array(256 * 4);
const declaredClassIds = new Set(
classes
.map((item) => item.id)
.filter((classId) => Number.isInteger(classId) && classId >= 0 && classId <= 255),
);
for (const entry of palette) {
if (!declaredClassIds.has(entry.classId)) continue;
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
if (!rgb) continue;
const offset = entry.classId * 4;
pixels[offset] = rgb[0];
pixels[offset + 1] = rgb[1];
pixels[offset + 2] = rgb[2];
pixels[offset + 3] = Math.round(
clampOpacity(entry.opacity, 1) * clampOpacity(opacity, 0.46) * 255,
);
}
return pixels;
}
function renderSemanticMask2d(
canvas: HTMLCanvasElement,
mask: DecodedSemanticMask,
palette: Uint8Array,
width: number,
height: number,
pixelRatio: number,
) {
canvas.width = Math.max(1, Math.round(width * pixelRatio));
canvas.height = Math.max(1, Math.round(height * pixelRatio));
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const context = canvas.getContext("2d");
if (!context) return;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, width, height);
const colorCanvas = document.createElement("canvas");
colorCanvas.width = mask.width;
colorCanvas.height = mask.height;
const colorContext = colorCanvas.getContext("2d", { willReadFrequently: true });
if (!colorContext) return;
colorContext.drawImage(mask.source, 0, 0);
const imageData = colorContext.getImageData(0, 0, mask.width, mask.height);
for (let offset = 0; offset < imageData.data.length; offset += 4) {
const paletteOffset = (imageData.data[offset] ?? 0) * 4;
imageData.data[offset] = palette[paletteOffset] ?? 0;
imageData.data[offset + 1] = palette[paletteOffset + 1] ?? 0;
imageData.data[offset + 2] = palette[paletteOffset + 2] ?? 0;
imageData.data[offset + 3] = palette[paletteOffset + 3] ?? 0;
}
colorContext.putImageData(imageData, 0, 0);
const scale = Math.min(width / mask.width, height / mask.height);
const drawWidth = mask.width * scale;
const drawHeight = mask.height * scale;
context.imageSmoothingEnabled = false;
context.drawImage(
colorCanvas,
(width - drawWidth) / 2,
(height - drawHeight) / 2,
drawWidth,
drawHeight,
);
}
export function RecordedEvidenceSemanticMaskOverlay({
src,
prefetchSrcs = [],
imageWidth,
imageHeight,
classes,
@@ -270,9 +509,12 @@ export function RecordedEvidenceSemanticMaskOverlay({
imageHeight: number;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const rendererRef = useRef<SemanticMaskRenderer | null | undefined>(undefined);
const [rendererMode, setRendererMode] = useState<"webgl" | "2d">("webgl");
const [mask, setMask] = useState<DecodedSemanticMask | null>(null);
const [failure, setFailure] = useState<string | null>(null);
const expectedKey = semanticMaskKey(src, imageWidth, imageHeight);
const prefetchSignature = prefetchSrcs.join("\n");
const renderMask = failure
? null
: mask?.key === expectedKey
@@ -285,14 +527,6 @@ export function RecordedEvidenceSemanticMaskOverlay({
let current = true;
void subscription.promise.then((decoded) => {
if (!current || decoded.key !== expectedKey) return;
const declaredClassIds = new Set(classes.map((item) => item.id));
const undeclaredClassId = decoded.classIds.find(
(classId) => !declaredClassIds.has(classId),
);
if (undeclaredClassId !== undefined) {
setFailure(`Semantic mask содержит необъявленный class ID ${undeclaredClassId}.`);
return;
}
setMask(decoded);
}).catch((error: unknown) => {
if (!current || (error instanceof DOMException && error.name === "AbortError")) return;
@@ -302,83 +536,100 @@ export function RecordedEvidenceSemanticMaskOverlay({
current = false;
subscription.release();
};
}, [classes, expectedKey, imageHeight, imageWidth, src]);
}, [expectedKey, imageHeight, imageWidth, src]);
useEffect(() => {
const subscriptions = prefetchSignature
.split("\n")
.filter((candidate) => candidate && candidate !== src)
.slice(0, MASK_PREFETCH_LIMIT)
.map((candidate) => subscribeToSemanticMask(candidate, imageWidth, imageHeight));
for (const subscription of subscriptions) {
void subscription.promise
.catch(() => undefined)
.finally(subscription.release);
}
}, [imageHeight, imageWidth, prefetchSignature, src]);
useEffect(() => () => {
rendererRef.current?.dispose();
rendererRef.current = undefined;
}, [rendererMode]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || rendererMode !== "webgl") return;
const onContextLost = (event: Event) => {
event.preventDefault();
setRendererMode("2d");
};
canvas.addEventListener("webglcontextlost", onContextLost);
return () => canvas.removeEventListener("webglcontextlost", onContextLost);
}, [rendererMode]);
useEffect(() => {
const canvas = canvasRef.current;
const host = canvas?.parentElement;
if (!canvas || !host) return;
const context = canvas.getContext("2d");
if (!context) return;
const render = () => {
const width = Math.max(host.clientWidth, 1);
const height = Math.max(host.clientHeight, 1);
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
canvas.width = Math.round(width * pixelRatio);
canvas.height = Math.round(height * pixelRatio);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, width, height);
if (!renderMask) return;
const declaredClassIds = new Set(
classes
.map((item) => item.id)
.filter((classId) => Number.isInteger(classId) && classId >= 0 && classId <= 255),
);
const resolvedPalette = new Map<number, { rgb: readonly [number, number, number]; alpha: number }>();
for (const entry of palette) {
if (!declaredClassIds.has(entry.classId)) continue;
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
if (!rgb) continue;
resolvedPalette.set(entry.classId, {
rgb,
alpha: clampOpacity(entry.opacity, 1) * clampOpacity(opacity, 0.46),
});
if (rendererMode === "webgl" && rendererRef.current === undefined) {
rendererRef.current = createSemanticMaskRenderer(canvas);
if (!rendererRef.current) {
setRendererMode("2d");
return;
}
}
const colorCanvas = document.createElement("canvas");
colorCanvas.width = renderMask.width;
colorCanvas.height = renderMask.height;
const colorContext = colorCanvas.getContext("2d");
if (!colorContext) return;
const imageData = colorContext.createImageData(renderMask.width, renderMask.height);
for (let sourceOffset = 0, targetOffset = 0; sourceOffset < renderMask.classIds.length; sourceOffset += 1, targetOffset += 4) {
const color = resolvedPalette.get(renderMask.classIds[sourceOffset] ?? -1);
if (!color) continue;
imageData.data[targetOffset] = color.rgb[0];
imageData.data[targetOffset + 1] = color.rgb[1];
imageData.data[targetOffset + 2] = color.rgb[2];
imageData.data[targetOffset + 3] = Math.round(color.alpha * 255);
const renderer = rendererMode === "webgl" ? rendererRef.current : null;
if (!renderMask) {
if (renderer) {
renderer.clear(width, height, pixelRatio);
} else {
canvas.width = Math.max(1, Math.round(width * pixelRatio));
canvas.height = Math.max(1, Math.round(height * pixelRatio));
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
return;
}
const resolvedPalette = semanticPalettePixels(host, classes, palette, opacity);
if (renderer) {
try {
renderer.render(renderMask, resolvedPalette, width, height, pixelRatio);
} catch {
setRendererMode("2d");
}
} else {
renderSemanticMask2d(
canvas,
renderMask,
resolvedPalette,
width,
height,
pixelRatio,
);
}
colorContext.putImageData(imageData, 0, 0);
const scale = Math.min(width / imageWidth, height / imageHeight);
const drawWidth = imageWidth * scale;
const drawHeight = imageHeight * scale;
const offsetX = (width - drawWidth) / 2;
const offsetY = (height - drawHeight) / 2;
context.imageSmoothingEnabled = false;
context.drawImage(colorCanvas, offsetX, offsetY, drawWidth, drawHeight);
};
const observer = new ResizeObserver(render);
observer.observe(host);
render();
return () => observer.disconnect();
}, [classes, expectedKey, imageHeight, imageWidth, opacity, palette, renderMask]);
}, [classes, expectedKey, imageHeight, imageWidth, opacity, palette, rendererMode, renderMask]);
return (
<>
<canvas
key={rendererMode}
ref={canvasRef}
className="recorded-evidence-semantic-mask-overlay"
role="img"
aria-label={ariaLabel}
aria-busy={!failure && !renderMask}
data-state={failure ? "error" : renderMask ? "ready" : "loading"}
data-renderer={rendererMode}
style={{ zIndex: 1 }}
/>
{failure ? (