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