react-native-mask-segment-c.../dist/components/MaskSegmentCanvas.js

2012 lines
90 KiB
JavaScript
Raw Normal View History

import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
import { useRef, useState, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle, } from 'react';
import { View, StyleSheet, Button, Dimensions, Text, TouchableOpacity, ScrollView, } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { runOnJS } from 'react-native-reanimated';
import { launchImageLibrary } from 'react-native-image-picker';
import cv from '../utils/opencvAdapter';
import { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion, downsampleMaskDataForPaths, extractRegionsFromMaskBufferSync, isBaseboardMaskPixel, upscaleBinaryMask, } from '../utils/maskSegmentation';
import { clearDerivedImageCache, readPngBgrBuffer, prewarmPngBgrCache, resizeBgrBuffer, } from '../utils/pngImage';
import { resolveImageUrl } from '../utils/resolveImageUrl';
import { compositePaintedImage } from '../utils/compositePaintedImage';
import { paintedRegionsFingerprint, resolveExportResultForDestDir, } from '../utils/exportUtils';
import { preparePaintResourcesFromWorkBuffer, releaseFreqLayerImages, } from '../utils/freqLayerPrep';
import { PaintShaderLayer, createPaintColorMapForPaint, } from '../utils/paintShaderRuntime';
import { createRuntimeConfig, getMaskRuntimeRevision, getMaskSegmentRuntimeConfig, resolvePipelineConfig, setMaskSegmentRuntimeConfig, } from '../utils/maskSegmentRuntime';
import { Canvas, Image as SkiaImage, Path, Group, DashPathEffect, Rect, useCanvasRef, Skia, } from '@shopify/react-native-skia';
/* ==========================================================================
* 配置常量屏幕相关其余见 maskSegmentRuntime
* ========================================================================== */
const { width: SCREEN_WIDTH } = Dimensions.get('window');
function bgrColorEquals(a, b) {
return a.b === b.b && a.g === b.g && a.r === b.r;
}
/* ==========================================================================
* 几何工具
* ========================================================================== */
function rectsEqual(a, b) {
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
}
function getContainRect(canvasW, canvasH, imgW, imgH) {
const imgAspect = imgW / imgH;
const canvasAspect = canvasW / canvasH;
if (imgAspect > canvasAspect) {
const w = canvasW;
const h = canvasW / imgAspect;
return { x: 0, y: (canvasH - h) / 2, w, h };
}
const h = canvasH;
const w = canvasH * imgAspect;
return { x: (canvasW - w) / 2, y: 0, w, h };
}
function canvasToNormalized(cx, cy, canvasW, canvasH, imgW, imgH) {
const rect = getContainRect(canvasW, canvasH, imgW, imgH);
if (cx < rect.x ||
cx > rect.x + rect.w ||
cy < rect.y ||
cy > rect.y + rect.h) {
return null;
}
return {
x: (cx - rect.x) / rect.w,
y: (cy - rect.y) / rect.h,
};
}
/**
* Inverse of the Skia Group transform applied during pinch-zoom.
* Converts a raw touch point (screen pixels) back to the canvas coordinate
* space where the image and regions are positioned before any scale/pan.
* When zoomScale 1 (no zoom), returns the input unchanged.
*/
function screenToCanvasCoords(screenX, screenY, canvasW, canvasH, zoomScale, panOffset) {
if (zoomScale <= 1)
return { x: screenX, y: screenY };
// Reverse: translate(-pan) → unscale around center → translate(+center)
return {
x: (screenX - panOffset.x - canvasW / 2) / zoomScale + canvasW / 2,
y: (screenY - panOffset.y - canvasH / 2) / zoomScale + canvasH / 2,
};
}
function pointInPolygon(x, y, points) {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const xi = points[i].x;
const yi = points[i].y;
const xj = points[j].x;
const yj = points[j].y;
const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect)
inside = !inside;
}
return inside;
}
function pointInPolygonWithPadding(x, y, points, padding) {
if (points.length < 3) {
return false;
}
let minX = points[0].x;
let maxX = points[0].x;
let minY = points[0].y;
let maxY = points[0].y;
for (const point of points) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
if (x >= minX - padding &&
x <= maxX + padding &&
y >= minY - padding &&
y <= maxY + padding) {
if (maxY - minY < padding * 2.5 || maxX - minX < padding * 2.5) {
return true;
}
}
return pointInPolygon(x, y, points);
}
function getRegionHitPolygons(reg) {
return reg.hitPolygons && reg.hitPolygons.length > 0
? reg.hitPolygons
: reg.polygons;
}
function pointHitsRegion(x, y, reg, options) {
const interaction = getMaskSegmentRuntimeConfig().interaction;
const thinPadding = options?.thinPadding ?? interaction.thinStripPadding;
const padding = reg.thinStrip ? thinPadding : interaction.regionPadding;
return getRegionHitPolygons(reg).some(poly => poly.length >= 3 && pointInPolygonWithPadding(x, y, poly, padding));
}
function pointStrictlyHitsRegion(x, y, reg) {
return getRegionHitPolygons(reg).some(poly => poly.length >= 3 && pointInPolygon(x, y, poly));
}
function resolveRegionHit(regions, x, y) {
const hits = [];
for (const reg of regions) {
const bboxPad = reg.thinStrip ? 0.005 : 0;
const b = reg.bbox;
if (x < b.x - bboxPad ||
x > b.x + b.w + bboxPad ||
y < b.y - bboxPad ||
y > b.y + b.h + bboxPad) {
continue;
}
if (pointHitsRegion(x, y, reg)) {
hits.push(reg);
}
}
if (hits.length === 0) {
return null;
}
if (hits.length === 1) {
return hits[0].id;
}
const strictNonThin = hits.filter(reg => !reg.thinStrip && pointStrictlyHitsRegion(x, y, reg));
if (strictNonThin.length > 0) {
strictNonThin.sort((a, b) => a.area - b.area);
return strictNonThin[0].id;
}
const strictThin = hits.filter(reg => reg.thinStrip && pointStrictlyHitsRegion(x, y, reg));
if (strictThin.length > 0) {
strictThin.sort((a, b) => a.area - b.area);
return strictThin[0].id;
}
const nonThin = hits.filter(reg => !reg.thinStrip);
if (nonThin.length > 0) {
nonThin.sort((a, b) => a.area - b.area);
return nonThin[0].id;
}
hits.sort((a, b) => a.area - b.area);
return hits[0].id;
}
function pickKickRegionFromMask(normX, normY, pick, kickRegionId, baseboardPickMask, strict = false) {
const cx = Math.floor(normX * pick.cols);
const cy = Math.floor(normY * pick.rows);
if (cx < 0 || cy < 0 || cx >= pick.cols || cy >= pick.rows) {
return null;
}
if (strict) {
if (baseboardPickMask) {
return baseboardPickMask[cy * pick.cols + cx] ? kickRegionId : null;
}
return isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, cx, cy)
? kickRegionId
: null;
}
const interaction = getMaskSegmentRuntimeConfig().interaction;
const radius = Math.max(interaction.kickMaskPickRadiusPx, Math.floor(pick.cols * 0.022));
const radiusSq = radius * radius;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (dx * dx + dy * dy > radiusSq) {
continue;
}
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
continue;
}
if (baseboardPickMask) {
if (baseboardPickMask[y * pick.cols + x]) {
return kickRegionId;
}
continue;
}
if (isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, x, y)) {
return kickRegionId;
}
}
}
return null;
}
function pickKickNearStrip(normX, normY, kickReg) {
const polys = kickReg.hitPolygons ?? kickReg.polygons;
const pad = getMaskSegmentRuntimeConfig().interaction.thinStripPadding + 0.004;
return polys.some(poly => poly.length >= 3 && pointInPolygonWithPadding(normX, normY, poly, pad));
}
function lookupRegionFromPickMap(normX, normY, pick, radiusPx = getMaskSegmentRuntimeConfig().interaction.pickMapSearchRadiusPx) {
const cx = Math.min(pick.cols - 1, Math.max(0, Math.floor(normX * pick.cols)));
const cy = Math.min(pick.rows - 1, Math.max(0, Math.floor(normY * pick.rows)));
const readCode = (x, y) => pick.buffer[y * pick.cols + x];
const center = readCode(cx, cy);
if (center > 0) {
return center - 1;
}
if (radiusPx <= 0) {
return null;
}
const r = Math.max(4, radiusPx);
const rSq = r * r;
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
if (dx * dx + dy * dy > rSq) {
continue;
}
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
continue;
}
const code = readCode(x, y);
if (code > 0) {
return code - 1;
}
}
}
return null;
}
/** BGR → 屏幕 RGB */
function bgrToCss(b, g, r) {
return `rgb(${r},${g},${b})`;
}
function releasePaintResourceLayers(layers) {
if (!layers) {
return;
}
layers.lowFreqImage.dispose();
layers.highFreqImage.dispose();
}
function releaseOriginSkImage(image) {
if (image) {
image.dispose();
}
}
async function prepareWorkScaledBgrBuffer(bgrBuffer, cols, rows, workScale) {
if (workScale >= 1) {
return { buffer: bgrBuffer, cols, rows };
}
const workCols = Math.floor(cols * workScale);
const workRows = Math.floor(rows * workScale);
const buffer = resizeBgrBuffer(bgrBuffer, cols, rows, workCols, workRows);
return { buffer, cols: workCols, rows: workRows };
}
/* ==========================================================================
* 分段计时工具仅开发环境生效
* ========================================================================== */
let _timeLogTs = 0;
function timeLog(tag) {
if (!__DEV__)
return;
const now = performance.now();
const dt = _timeLogTs ? now - _timeLogTs : 0;
console.log(`[⏱ ${tag}] ${dt.toFixed(2)} ms`);
_timeLogTs = now;
}
/* ==========================================================================
* 组件主体
* ========================================================================== */
const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
const { originUrl: originUrlProp, maskUrl: maskUrlProp, originImgPath: originImgPathLegacy, maskImgPath: maskImgPathLegacy, maskConfig, pipelinePreset, pipelineConfig, paintConfig, interactionConfig, semanticColors, regionOutlineColor, initialSession, initialPaintColor, initialPaintConfigJson, showDebugPickers = true, showToolbar = true, showColorBar = true, showStatusRow = true, showOverlayButtons = true, disabled = false, style, canvasStyle, maxHeight, undoButtonStyle, compareButtonStyle, undoButtonTextStyle, compareButtonTextStyle, undoButtonText = '撤销', compareButtonText = '对比原图', compareExitButtonText = '退出对比', renderUndoButton, renderCompareButton, onWatch, onPaintCallback, onError, autoExportOnReady, onExported, } = props;
const originSource = originUrlProp ?? originImgPathLegacy ?? '';
const maskSource = maskUrlProp ?? maskImgPathLegacy ?? '';
const resolvedMaskConfig = useMemo(() => semanticColors
? { ...maskConfig, semanticColors }
: maskConfig, [maskConfig, semanticColors]);
const resolvedPaintConfig = useMemo(() => regionOutlineColor
? { ...paintConfig, regionOverlayFill: regionOutlineColor }
: paintConfig, [paintConfig, regionOutlineColor]);
const [resolvedOriginPath, setResolvedOriginPath] = useState('');
const [resolvedMaskPath, setResolvedMaskPath] = useState('');
const [originImgPath, setOriginImgPath] = useState(resolvedOriginPath);
const [maskImgPath, setMaskImgPath] = useState(resolvedMaskPath);
// Latest desired image paths (updated when the internal path states settle).
// Used by segmentAndPrepareLayers to decide whether a stale runId (from effect cleanup due to
// unrelated parent re-renders) should actually abort the current async read/segment work.
// If the image pair we are processing is still the one the caller ultimately wants, we continue.
const latestOriginPathRef = useRef('');
const latestMaskPathRef = useRef('');
const resolvedPipelineConfig = useMemo(() => resolvePipelineConfig(pipelinePreset, pipelineConfig), [pipelinePreset, pipelineConfig]);
const runtimeRef = useRef(createRuntimeConfig({
maskConfig: resolvedMaskConfig,
pipelineConfig: resolvedPipelineConfig,
paintConfig: resolvedPaintConfig,
interactionConfig,
}));
// Track last *values* we pushed for paintConfig. We only call the global setMaskSegmentRuntimeConfig
// (which bumps runtimeRevision) when the actual numbers change. This prevents repeated parent
// re-renders that pass a new object literal with identical values from causing:
// - global revision bump
// - paintColorMap useMemo invalidation (full-res Uint8Array + boxBlur + Skia image alloc)
// - extra main-thread work during the critical segmentation / freq-layers / outline paths window.
// The local runtimeRef is still kept in sync for any synchronous readers.
const lastAppliedPaintConfigRef = useRef(null);
const lastAppliedPipelineSignatureRef = useRef(null);
useEffect(() => {
const prevPaint = lastAppliedPaintConfigRef.current;
const currPaint = resolvedPaintConfig || {};
const paintKeys = [
'colorBaseOpacity',
'lLightOpacity',
'textureOpacity',
'lLowBlurKernel',
'lLowContrast',
'lLowBrightness',
'lHighGain',
'maskFeatherColor',
'maskFeatherTexture',
'regionOverlayFill',
];
const paintChanged = !prevPaint || paintKeys.some((k) => currPaint[k] !== prevPaint[k]);
const pipelineSignature = JSON.stringify(resolvedPipelineConfig);
const pipelineChanged = lastAppliedPipelineSignatureRef.current !== pipelineSignature;
if (paintChanged || pipelineChanged) {
if (paintChanged) {
lastAppliedPaintConfigRef.current = { ...currPaint };
}
if (pipelineChanged) {
lastAppliedPipelineSignatureRef.current = pipelineSignature;
}
runtimeRef.current = setMaskSegmentRuntimeConfig({
maskConfig: resolvedMaskConfig,
pipelineConfig: resolvedPipelineConfig,
paintConfig: resolvedPaintConfig,
interactionConfig,
});
}
}, [
resolvedMaskConfig,
resolvedPipelineConfig,
resolvedPaintConfig,
interactionConfig,
]);
const paintPalette = runtimeRef.current.paint.palette;
const paintRuntime = getMaskSegmentRuntimeConfig().paint;
const interactionRuntime = getMaskSegmentRuntimeConfig().interaction;
const onWatchRef = useRef(onWatch);
const onPaintCallbackRef = useRef(onPaintCallback);
const onErrorRef = useRef(onError);
const onExportedRef = useRef(onExported);
useEffect(() => {
onWatchRef.current = onWatch;
onPaintCallbackRef.current = onPaintCallback;
onErrorRef.current = onError;
onExportedRef.current = onExported;
}, [onWatch, onPaintCallback, onError, onExported]);
const watchStartRef = useRef(0);
const lastWatchStateRef = useRef(null);
const lastWatchSignatureRef = useRef(null);
const emitWatch = useCallback((state, detail) => {
const signature = [
state,
detail?.regionCount ?? '',
detail?.maskPathsReady ?? '',
detail?.freqLayersReady ?? '',
detail?.errorMessage ?? '',
].join('|');
if (lastWatchSignatureRef.current === signature) {
return;
}
lastWatchSignatureRef.current = signature;
lastWatchStateRef.current = state;
const durationMs = watchStartRef.current
? performance.now() - watchStartRef.current
: 0;
onWatchRef.current?.(state, durationMs, detail);
}, []);
const reportError = useCallback((message, error) => {
emitWatch('error', { errorMessage: message });
if (onErrorRef.current) {
onErrorRef.current(message, error);
}
else if (__DEV__) {
console.error('[MaskSegment]', message, error);
}
}, [emitWatch]);
const [customPaintColor, setCustomPaintColor] = useState(initialPaintColor ?? null);
const customPaintConfigJsonRef = useRef(initialPaintConfigJson);
const originUrlRef = useRef(originSource);
const maskUrlRef = useRef(maskSource);
useEffect(() => {
originUrlRef.current = originSource;
maskUrlRef.current = maskSource;
}, [originSource, maskSource]);
useEffect(() => {
let cancelled = false;
if (!originSource || !maskSource) {
setResolvedOriginPath('');
setResolvedMaskPath('');
return;
}
void (async () => {
try {
const [originPath, maskPath] = await Promise.all([
resolveImageUrl(originSource, 'origin.png'),
resolveImageUrl(maskSource, 'mask.png'),
]);
if (!cancelled) {
setResolvedOriginPath(originPath);
setResolvedMaskPath(maskPath);
}
}
catch (e) {
if (!cancelled) {
const msg = e instanceof Error ? e.message : String(e);
reportError(msg, e);
}
}
})();
return () => {
cancelled = true;
};
}, [originSource, maskSource, reportError]);
useEffect(() => {
setOriginImgPath(resolvedOriginPath);
setMaskImgPath(resolvedMaskPath);
if (resolvedOriginPath && resolvedMaskPath) {
prewarmPngBgrCache([resolvedOriginPath, resolvedMaskPath]);
}
}, [resolvedOriginPath, resolvedMaskPath]);
// Keep latest desired paths for cancellation decisions inside the long async segment pipeline.
useEffect(() => {
latestOriginPathRef.current = originImgPath || '';
latestMaskPathRef.current = maskImgPath || '';
}, [originImgPath, maskImgPath]);
const [paintResourceLayers, setPaintResourceLayers] = useState(null);
const paintResourceLayersRef = useRef(null);
const paintColorMapSkImgRef = useRef(null);
const [activeBrushIndex, setActiveBrushIndex] = useState(null);
const [paintedRegions, setPaintedRegions] = useState(() => new Map());
const paintedRegionsRef = useRef(new Map());
const [paintHistory, setPaintHistory] = useState([]);
// Seed the ref with the initial empty map so early reads (before any paint effect)
// are consistent.
paintedRegionsRef.current = paintedRegions;
const [heldRegionId, setHeldRegionId] = useState(null);
const [heldRegionAnchor, setHeldRegionAnchor] = useState(null);
const [initFlashRegionId, setInitFlashRegionId] = useState(null);
const initFlashTimerRef = useRef(null);
const initFlashIndexRef = useRef(0);
const initFlashActiveRef = useRef(false);
// List of regions still eligible for the init dashed-outline flash (discovery aid).
// Computed at the start of the flash loop, excluding any that are already painted.
// This ensures that on continue-edit (or any partial seed), already-colored regions
// do not get the flashing dashed outline.
const initFlashListRef = useRef([]);
// Guard so that initialSession (seed from host bootstrap or scheme) is applied only once
// after segmentsReady. Prevents later prop identity changes (e.g. caused by host slot/brush
// selection re-renders) from calling restoreSession again, which would clobber live
// paintedRegions with a re-derived snapshot (often causing already-painted regions to
// "follow" the newly selected brush color).
const hasAppliedInitialSessionRef = useRef(false);
// Keep a ref to the absolute latest paintedRegions so that imperative save()
// (called from host performSaveProject for new schemes, or manually) and
// internal composites always see the most up-to-date painted state, even
// if the useImperativeHandle closure was created in a prior render.
// This fixes cases where the last user paint's colors were missing from
// the recolored After image captured at "save scheme" time.
useEffect(() => {
paintedRegionsRef.current = paintedRegions;
}, [paintedRegions]);
// Cached export from the most recent auto-export or save() — keyed by painted fingerprint.
const lastExportCacheRef = useRef(null);
const autoExportDebounceRef = useRef(null);
const exportInFlightRef = useRef(false);
const regionsRef = useRef([]);
const maskPickRef = useRef(null);
const regionPickRef = useRef(null);
const regionMaskDataRef = useRef(null);
const workBufferRef = useRef(null);
const paintLayersPromiseRef = useRef(null);
const loadPaintLayersRef = useRef(() => Promise.resolve());
const [paintResourcesReady, setPaintResourcesReady] = useState(false);
const [layersLoading, setLayersLoading] = useState(false);
const [maskPathsReady, setMaskPathsReady] = useState(false);
const baseboardPickMaskRef = useRef(null);
const kickRegionIdRef = useRef(null);
const [regionPalette, setRegionPalette] = useState([]);
const [regionCount, setRegionCount] = useState(0);
const [imageSize, setImageSize] = useState(null);
// High-resolution offscreen Canvas (sized to the work buffer resolution) whose content
// is the full shader composition (PaintShaderLayer at 0,0,workW,workH). On save() we
// call makeImageSnapshot() on it to get a "what you see in the editor, at source res"
// PNG bytes. This is the preferred "保存快照" path and avoids CPU recolor entirely
// for the exported After.
const highResExportCanvasRef = useCanvasRef();
const [exportCanvasSize, setExportCanvasSize] = useState(null);
// Gate the (potentially expensive) high-res snapshot canvas so it is only mounted
// after the user (or initialSession seed) has painted at least one region. This keeps
// idle segmentation / no-paint cases cheap.
const [highResSnapshotEnabled, setHighResSnapshotEnabled] = useState(false);
// Layout measurement for the root container of this component. Declared early so
// the canvasW/canvasH memos (which decide the viewport rect for zoom centering,
// containRect placement, clipping, and gesture coordinate mapping) can close over it.
// When the host passes a fitted frame (VisualizationScreen's canvasFrame with explicit
// w/h derived from safe area + aspect, or scheme cards), we size our internal Skia
// canvas + gesture layer + zoom transform to that exact allocated rect.
const [layoutWidth, setLayoutWidth] = useState(null);
const [layoutHeight, setLayoutHeight] = useState(null);
const [segmentsReady, setSegmentsReady] = useState(false);
const segmentsReadyRef = useRef(false);
const maskPathsReadyRef = useRef(false);
const [canvasInteractive, setCanvasInteractive] = useState(false);
const [segError, setSegError] = useState('');
const [compareMode, setCompareMode] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [originSkImg, setOriginSkImg] = useState(null);
const originSkImgRef = useRef(null);
const lowFreqSkImg = paintResourceLayers?.lowFreqImage ?? null;
const highFreqSkImg = paintResourceLayers?.highFreqImage ?? null;
const canvasBaseW = SCREEN_WIDTH - 20;
// The "viewport" size for this canvas component: the rect inside which we place
// the contained image, apply the zoom Group transform (centered), clip, and receive
// gestures (tap + two-finger pinch). When we have a real onLayout from the host
// (VisualizationScreen's aspect-fitted canvasFrame, or scheme card preview area),
// we use the *allocated pixel size* directly as our viewport.
//
// Accurate canvasW/H is still critical for:
// - Correct centering of the scaled content around the viewport center.
// - Proper containRect (letterbox/centering of the source photo inside the viewport).
// - Clip rect and gesture-to-canvas coordinate conversion used by painting.
//
// Previously the code fell back to aspect-derived sizes even after layout, which
// could cause the effective viewport to not match the host frame. Using the onLayout
// result (with maxHeight fallback only when no layout yet) keeps zoom, clip, and
// touch mapping consistent with what the user actually sees and touches.
const viewportW = useMemo(() => {
if (layoutWidth != null && layoutHeight != null) {
// Primary path for viz screen and scheme cards: the exact size the host
// decided for this component (after its own safe-area + aspect fit).
return layoutWidth;
}
if (!maxHeight || maxHeight <= 0) {
return canvasBaseW;
}
// Fallback (no layout yet, or other usages that pass maxHeight without a
// tightly sized parent frame). Replicate a contain-style budget.
const availableW = canvasBaseW;
let auxHeight = 0;
if (showToolbar)
auxHeight += 40;
if (showStatusRow)
auxHeight += 30;
if (showColorBar)
auxHeight += 70;
const availableH = Math.max(100, maxHeight - 20 - auxHeight);
const imgAspect = imageSize ? imageSize.w / imageSize.h : 1;
const containerAspect = availableW / availableH;
if (containerAspect > imgAspect) {
return Math.floor(availableH * imgAspect);
}
return availableW;
}, [layoutWidth, layoutHeight, maxHeight, showToolbar, showStatusRow, showColorBar, canvasBaseW, imageSize]);
const viewportH = useMemo(() => {
if (layoutWidth != null && layoutHeight != null) {
return layoutHeight;
}
if (!maxHeight || maxHeight <= 0) {
const imgAspect = imageSize ? imageSize.w / imageSize.h : 1;
return Math.floor(viewportW / imgAspect);
}
let auxHeight = 0;
if (showToolbar)
auxHeight += 40;
if (showStatusRow)
auxHeight += 30;
if (showColorBar)
auxHeight += 70;
return Math.max(100, maxHeight - 20 - auxHeight);
}, [layoutWidth, layoutHeight, maxHeight, showToolbar, showStatusRow, showColorBar, viewportW, imageSize]);
// For the rest of the component, "canvasW/H" means the viewport rect size.
// All zoom center, wrap size, touch layer, Canvas size, clip, containRect, and
// gesture coordinate mapping are based on this, so that two-finger zoom centering
// and single-finger tap painting stay consistent with the host-allocated area.
const canvasW = viewportW;
const canvasH = viewportH;
// Refs synced to the latest viewport size so that async callbacks
// (segmentAndPrepareLayers) always read post-layout values instead of
// stale closure captures. This fixes dashed-outline offset to the bottom
// when the initial pathMapRect was computed with fallback SCREEN_WIDTH.
// Declared before segmentAndPrepareLayers so the async body can reference them.
const canvasWRef = useRef(canvasW);
const canvasHRef = useRef(canvasH);
// ── Pinch-zoom (two-finger only; single-finger drag/pan disabled) ─────────
const [zoomScale, setZoomScale] = useState(1);
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
// Refs for gesture callbacks (closures don't capture fresh state mid-gesture)
const zoomScaleRef = useRef(1);
const panOffsetRef = useRef({ x: 0, y: 0 });
// Baseline value captured at gesture start to avoid jump on re-creation for pinch
const zoomBaseRef = useRef(1);
// Ref to the latest containRect (the actual placed photo rect inside the viewport).
const containRectRef = useRef(null);
useEffect(() => { zoomScaleRef.current = zoomScale; }, [zoomScale]);
useEffect(() => { panOffsetRef.current = panOffset; }, [panOffset]);
const resetZoom = useCallback(() => {
setZoomScale(1);
setPanOffset({ x: 0, y: 0 });
zoomScaleRef.current = 1;
panOffsetRef.current = { x: 0, y: 0 };
}, []);
const containRect = useMemo(() => {
if (!imageSize)
return null;
return getContainRect(canvasW, canvasH, imageSize.w, imageSize.h);
}, [imageSize, canvasW, canvasH]);
// Keep a ref in sync so early-defined callbacks can read the
// latest containRect without TDZ or stale closure issues.
useEffect(() => {
containRectRef.current = containRect;
}, [containRect]);
const segmentRunIdRef = useRef(0);
const lastSegmentKeyRef = useRef('');
const segmentInFlightKeyRef = useRef('');
const maskPathsContainRectRef = useRef(null);
useEffect(() => {
paintResourceLayersRef.current = paintResourceLayers;
}, [paintResourceLayers]);
useEffect(() => {
segmentsReadyRef.current = segmentsReady;
}, [segmentsReady]);
useEffect(() => {
maskPathsReadyRef.current = maskPathsReady;
}, [maskPathsReady]);
const emitLayersReadyIfReady = useCallback(() => {
if (!segmentsReadyRef.current || !paintResourceLayersRef.current) {
return;
}
emitWatch('layers_ready', {
regionCount: regionsRef.current.length,
maskPathsReady: maskPathsReadyRef.current,
freqLayersReady: true,
});
}, [emitWatch]);
const emitMaskPathsReadyIfReady = useCallback(() => {
if (!segmentsReadyRef.current || !maskPathsReadyRef.current) {
return;
}
emitWatch('mask_paths_ready', {
regionCount: regionsRef.current.length,
maskPathsReady: true,
freqLayersReady: true,
});
}, [emitWatch]);
const emitInteractiveIfReady = useCallback(() => {
if (!segmentsReadyRef.current || !paintResourceLayersRef.current) {
return;
}
emitLayersReadyIfReady();
emitWatch('interactive', {
regionCount: regionsRef.current.length,
maskPathsReady: maskPathsReadyRef.current,
freqLayersReady: true,
});
setCanvasInteractive(true);
}, [emitWatch, emitLayersReadyIfReady]);
const paintColorMapSkImg = useMemo(() => {
const pick = regionPickRef.current;
paintColorMapSkImgRef.current?.dispose();
// Early out: no pick buffer yet, or no regions have been painted (initial load or fresh session).
// Avoids repeated full-resolution RGBA allocation + boxBlur (for maskFeather) + Skia.Image.MakeImage
// on every re-render during the hot init path. When initialSession restores paints, paintedRegions
// will update and legitimately trigger a single build of the (feathered) color map.
if (!pick || paintedRegions.size === 0) {
paintColorMapSkImgRef.current = null;
return null;
}
const map = createPaintColorMapForPaint(pick.buffer, pick.cols, pick.rows, paintedRegions);
paintColorMapSkImgRef.current = map;
return map;
}, [paintedRegions, paintResourcesReady, segmentsReady, getMaskRuntimeRevision()]);
const paintedRegionConfigRef = useRef(new Map());
const segmentAndPrepareLayers = useCallback(async (originPath, maskPath) => {
const runId = ++segmentRunIdRef.current;
// isCancelled: a runId bump alone is not enough to abort if the image pair we were asked to process
// is still the latest desired pair from the caller (protects against effect cleanups caused by
// unrelated parent re-renders / state updates that do not change the two images).
const isCancelled = () => {
if (runId === segmentRunIdRef.current)
return false;
const desiredOrigin = latestOriginPathRef.current || originPath;
const desiredMask = latestMaskPathRef.current || maskPath;
const stillWanted = desiredOrigin === originPath && desiredMask === maskPath;
return !stillWanted;
};
const pipeline = getMaskSegmentRuntimeConfig().pipeline;
watchStartRef.current = performance.now();
lastWatchStateRef.current = null;
lastWatchSignatureRef.current = null;
emitWatch('init');
timeLog('▶ start segmentation');
segmentsReadyRef.current = false;
setSegmentsReady(false);
setSegError('');
setActiveBrushIndex(null);
const clearedPainted = new Map();
paintedRegionsRef.current = clearedPainted;
setPaintedRegions(clearedPainted);
setPaintHistory([]);
setHeldRegionId(null);
setHeldRegionAnchor(null);
setInitFlashRegionId(null);
initFlashActiveRef.current = false;
initFlashIndexRef.current = 0;
initFlashListRef.current = [];
if (initFlashTimerRef.current) {
clearTimeout(initFlashTimerRef.current);
initFlashTimerRef.current = null;
}
hasAppliedInitialSessionRef.current = false;
lastExportCacheRef.current = null;
if (autoExportDebounceRef.current) {
clearTimeout(autoExportDebounceRef.current);
autoExportDebounceRef.current = null;
}
exportInFlightRef.current = false;
regionsRef.current = [];
maskPickRef.current = null;
regionPickRef.current = null;
regionMaskDataRef.current = null;
workBufferRef.current = null;
paintLayersPromiseRef.current = null;
setRegionOutlinePaths(new Map());
setMaskPathsReady(false);
setPaintResourcesReady(false);
setLayersLoading(false);
baseboardPickMaskRef.current = null;
kickRegionIdRef.current = null;
maskPathsContainRectRef.current = null;
setRegionPalette([]);
setRegionCount(0);
// Reset high-res snapshot state so a fresh segmentation starts clean (no stale size/ref).
setExportCanvasSize(null);
setHighResSnapshotEnabled(false);
const prevLayers = paintResourceLayersRef.current;
if (prevLayers) {
releasePaintResourceLayers(prevLayers);
paintResourceLayersRef.current = null;
setPaintResourceLayers(null);
}
releaseOriginSkImage(originSkImgRef.current);
originSkImgRef.current = null;
setOriginSkImg(null);
try {
timeLog('▶ reading origin PNG');
const originPromise = readPngBgrBuffer(originPath);
timeLog('▶ reading mask PNG');
const maskPromise = readPngBgrBuffer(maskPath);
const originDecoded = await originPromise;
const afterOriginCancelled = isCancelled();
if (afterOriginCancelled) {
return;
}
const imgW = originDecoded.cols;
const imgH = originDecoded.rows;
setImageSize({ w: imgW, h: imgH });
let scale = 1;
if (Math.max(imgW, imgH) > pipeline.maxImageLongSide) {
scale = pipeline.maxImageLongSide / Math.max(imgW, imgH);
}
const segW = Math.floor(imgW * scale);
const segH = Math.floor(imgH * scale);
const minArea = pipeline.minContourArea * scale * scale;
const workScaledTask = prepareWorkScaledBgrBuffer(originDecoded.buffer, imgW, imgH, scale).then(workScaled => {
if (isCancelled()) {
return workScaled;
}
workBufferRef.current = workScaled;
setExportCanvasSize({ w: workScaled.cols, h: workScaled.rows });
// Enable the offscreen high-res export canvas as soon as we know the work resolution.
// This gives the hidden <Canvas> time to mount and commit before autoExport/save()
// tries to snapshot it. The inner renderFullResPainted() will draw cheap origin until
// paints + shader textures are ready. This greatly increases the chance that the
// preferred makeImageSnapshot path succeeds for rich exports (instead of falling
// through to the drawAsImage reconstruction).
setHighResSnapshotEnabled(true);
void loadPaintLayersRef.current();
return workScaled;
});
const segmentTask = maskPromise.then(async (maskDecoded) => {
if (isCancelled()) {
throw new Error('cancelled');
}
timeLog('▶ PNG read completed');
emitWatch('images_loaded');
timeLog(`▶ image size: ${imgW}x${imgH}`);
const { buffer: maskBuffer, cols: maskW, rows: maskH } = maskDecoded;
const segMaskBuffer = resizeBgrBuffer(maskBuffer, maskW, maskH, segW, segH);
timeLog(`▶ mask scale: ${scale.toFixed(3)}`);
emitWatch('mask_aligned');
return extractRegionsFromMaskBufferSync(segMaskBuffer, segW, segH, {
minArea,
approxEpsilon: pipeline.contourApproxEpsilon,
});
});
void maskPromise.then(async (maskDecoded) => {
const { buffer: maskBuffer, cols: maskW, rows: maskH } = maskDecoded;
let pickBuffer;
if (maskW !== imgW || maskH !== imgH) {
pickBuffer = await cv.resizeBgrBuffer(maskBuffer, maskW, maskH, imgW, imgH);
}
else {
pickBuffer = new Uint8Array(maskBuffer);
}
if (runId !== segmentRunIdRef.current) {
return;
}
maskPickRef.current = {
buffer: pickBuffer,
cols: imgW,
rows: imgH,
};
});
const [segmentResult] = await Promise.all([
segmentTask,
workScaledTask,
]);
if (isCancelled()) {
return;
}
const paintPromise = paintLayersPromiseRef.current ?? Promise.resolve();
emitWatch('mask_sampled', { regionCount: segmentResult.regions.length });
timeLog(`▶ segmentation completed, valid regions: ${segmentResult.regions.length}`);
const validRegions = segmentResult.regions;
if (__DEV__ && validRegions.length === 0) {
console.warn('[MaskSegment] not recognized any valid regions, please check if the mask is a pure color region image');
}
let finalRegions = validRegions;
if (finalRegions.length > pipeline.maxRegions) {
finalRegions = finalRegions.slice(0, pipeline.maxRegions);
}
regionsRef.current = finalRegions;
regionPickRef.current = segmentResult.pickMap;
regionMaskDataRef.current = {
labels: segmentResult.labels,
baseboardBinary: segmentResult.baseboardBinary,
cols: segmentResult.segCols,
rows: segmentResult.segRows,
};
baseboardPickMaskRef.current = null;
kickRegionIdRef.current =
finalRegions.find(reg => reg.thinStrip)?.id ?? null;
const pathMapRect = getContainRect(canvasWRef.current, canvasHRef.current, imgW, imgH);
maskPathsContainRectRef.current = pathMapRect;
setRegionOutlinePaths(new Map());
setMaskPathsReady(false);
setRegionPalette(finalRegions);
setRegionCount(finalRegions.length);
lastSegmentKeyRef.current = `${originPath}|${maskPath}`;
segmentsReadyRef.current = true;
setSegmentsReady(true);
emitWatch('regions_ready', { regionCount: finalRegions.length });
emitInteractiveIfReady();
void (async () => {
if (runId !== segmentRunIdRef.current) {
return;
}
const pathMaskData = downsampleMaskDataForPaths(regionMaskDataRef.current, pipeline.maskPathMaxLongSide);
const outlines = buildAllRegionOutlinePaths(finalRegions, pathMaskData, pathMapRect);
if (runId !== segmentRunIdRef.current) {
return;
}
setRegionOutlinePaths(outlines);
setMaskPathsReady(true);
maskPathsReadyRef.current = true;
emitMaskPathsReadyIfReady();
})();
void (async () => {
if (runId !== segmentRunIdRef.current) {
return;
}
await paintPromise;
if (runId !== segmentRunIdRef.current) {
return;
}
baseboardPickMaskRef.current = upscaleBinaryMask(segmentResult.baseboardBinary, segW, segH, imgW, imgH);
})();
}
catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!isCancelled()) {
console.error('[SDK-SEGMENT] segmentation failed', e);
setSegError(msg);
reportError(msg, e);
}
}
}, [emitWatch, emitInteractiveIfReady, emitMaskPathsReadyIfReady, reportError]);
const loadPaintLayersIfNeeded = useCallback(() => {
if (paintResourcesReady) {
return Promise.resolve();
}
if (paintLayersPromiseRef.current) {
return paintLayersPromiseRef.current;
}
const work = workBufferRef.current;
if (!work) {
return Promise.resolve();
}
const runId = segmentRunIdRef.current;
let promise;
promise = (async () => {
setLayersLoading(true);
timeLog('▶ start loading paint shader textures');
try {
const result = await preparePaintResourcesFromWorkBuffer(work.buffer, work.cols, work.rows, layers => {
if (runId !== segmentRunIdRef.current) {
releaseFreqLayerImages(layers);
return;
}
setPaintResourceLayers(layers);
paintResourceLayersRef.current = layers;
setPaintResourcesReady(true);
timeLog('▶ paint shader textures ready');
emitInteractiveIfReady();
});
if (runId !== segmentRunIdRef.current) {
if (result) {
result.originImage.dispose();
releasePaintResourceLayers(result.layers);
}
return;
}
if (!result) {
return;
}
releaseOriginSkImage(originSkImgRef.current);
originSkImgRef.current = result.originImage;
setOriginSkImg(result.originImage);
timeLog('▶ origin Skia work resolution');
if (!paintResourceLayersRef.current) {
setPaintResourceLayers(result.layers);
paintResourceLayersRef.current = result.layers;
setPaintResourcesReady(true);
}
emitInteractiveIfReady();
}
catch (error) {
if (__DEV__) {
console.warn('[MaskSegment] failed to prepare paint shader textures', error);
}
}
finally {
setLayersLoading(false);
if (paintLayersPromiseRef.current === promise) {
paintLayersPromiseRef.current = null;
}
}
})();
paintLayersPromiseRef.current = promise;
return promise;
}, [emitInteractiveIfReady, paintResourcesReady]);
loadPaintLayersRef.current = loadPaintLayersIfNeeded;
const pickOriginImage = async () => {
const res = await launchImageLibrary({ mediaType: 'photo' });
const uri = res.assets?.[0]?.uri;
if (!uri) {
return;
}
const pngPath = await cv.ensurePngPath(uri, `picked_origin_${Date.now()}.png`);
setOriginImgPath(pngPath);
};
const pickMaskImage = async () => {
const res = await launchImageLibrary({ mediaType: 'photo' });
const uri = res.assets?.[0]?.uri;
if (!uri) {
return;
}
const pngPath = await cv.ensurePngPath(uri, `picked_mask_${Date.now()}.png`);
setMaskImgPath(pngPath);
};
const clearCacheAndResegment = useCallback(async () => {
if (isRefreshing || !originImgPath || !maskImgPath) {
return;
}
setIsRefreshing(true);
try {
resetZoom();
const layers = paintResourceLayersRef.current;
if (layers) {
releasePaintResourceLayers(layers);
paintResourceLayersRef.current = null;
setPaintResourceLayers(null);
}
await clearDerivedImageCache();
lastSegmentKeyRef.current = '';
await segmentAndPrepareLayers(originImgPath, maskImgPath);
}
catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setSegError(msg);
reportError(msg, e);
}
finally {
setIsRefreshing(false);
}
}, [
isRefreshing,
originImgPath,
maskImgPath,
segmentAndPrepareLayers,
reportError,
resetZoom,
]);
useEffect(() => {
if (!originImgPath || !maskImgPath) {
return;
}
const segmentKey = `${originImgPath}|${maskImgPath}`;
if (lastSegmentKeyRef.current === segmentKey ||
segmentInFlightKeyRef.current === segmentKey) {
return;
}
segmentInFlightKeyRef.current = segmentKey;
void segmentAndPrepareLayers(originImgPath, maskImgPath).finally(() => {
if (segmentInFlightKeyRef.current === segmentKey) {
segmentInFlightKeyRef.current = '';
}
});
return () => {
segmentRunIdRef.current += 1;
if (segmentInFlightKeyRef.current === segmentKey) {
segmentInFlightKeyRef.current = '';
}
const layers = paintResourceLayersRef.current;
if (layers) {
releasePaintResourceLayers(layers);
paintResourceLayersRef.current = null;
}
paintColorMapSkImgRef.current?.dispose();
paintColorMapSkImgRef.current = null;
regionsRef.current = [];
// Also reset any pending init flash state (the full reset will also run at start of
// the next segmentAndPrepareLayers, but this keeps things clean if the effect
// re-triggers segmentation while a flash sequence was in flight).
initFlashListRef.current = [];
initFlashActiveRef.current = false;
initFlashIndexRef.current = 0;
if (initFlashTimerRef.current) {
clearTimeout(initFlashTimerRef.current);
initFlashTimerRef.current = null;
}
setInitFlashRegionId(null);
};
}, [originImgPath, maskImgPath]);
const buildPaintedRecords = useCallback(() => {
const records = [];
// Prefer the live ref so getPaintedRegions() / session() used by host for
// colorParams at save time sees the exact same data as the save() composite.
const src = paintedRegionsRef.current && paintedRegionsRef.current.size > 0
? paintedRegionsRef.current
: paintedRegions;
for (const [regionId, color] of src) {
const region = regionsRef.current.find(reg => reg.id === regionId);
records.push({
regionId,
regionName: region?.name ?? String(regionId),
color,
configJson: paintedRegionConfigRef.current.get(regionId),
});
}
return records;
}, [paintedRegions]);
const restoreSession = useCallback((session) => {
const nextPainted = new Map();
paintedRegionConfigRef.current = new Map();
const currentRegions = regionsRef.current || [];
const nameToRealId = new Map();
for (const r of currentRegions) {
if (r && r.name) {
const key = String(r.name).trim().toLowerCase();
if (key && !nameToRealId.has(key)) {
nameToRealId.set(key, r.id);
}
}
}
// Build oldId -> regionName from the incoming seed (for paintHistory remapping).
const oldIdToName = new Map();
for (const item of session.painted || []) {
if (item && typeof item.regionId === 'number' && item.regionName) {
oldIdToName.set(item.regionId, String(item.regionName));
}
}
// Resolve directly by name. Name is unique within a segmentation so there is no
// need for id-based heuristics — the region name is the authoritative identity.
for (const item of session.painted || []) {
if (!item)
continue;
let targetId = item.regionId;
if (item.regionName) {
const key = String(item.regionName).trim().toLowerCase();
const realId = nameToRealId.get(key);
if (typeof realId === 'number') {
targetId = realId;
}
}
nextPainted.set(targetId, item.color);
if (item.configJson) {
paintedRegionConfigRef.current.set(targetId, item.configJson);
}
}
// Remap paintHistory via names.
const resolvedHistory = [];
for (const oldId of session.paintHistory || []) {
if (typeof oldId !== 'number')
continue;
const nm = oldIdToName.get(oldId);
if (nm) {
const real = nameToRealId.get(String(nm).trim().toLowerCase());
if (typeof real === 'number') {
resolvedHistory.push(real);
continue;
}
}
// Fallback: keep oldId if it happens to be valid in current segmentation.
if (currentRegions.some((r) => r && r.id === oldId)) {
resolvedHistory.push(oldId);
}
}
paintedRegionsRef.current = nextPainted;
setPaintedRegions(nextPainted);
setPaintHistory(resolvedHistory.length ? resolvedHistory : [...(session.paintHistory || [])]);
if (session.currentColor) {
setCustomPaintColor(session.currentColor);
customPaintConfigJsonRef.current = session.currentColorConfigJson;
setActiveBrushIndex(null);
}
}, []);
useEffect(() => {
if (!initialSession || !segmentsReady) {
return;
}
if (hasAppliedInitialSessionRef.current) {
// Already seeded once for this canvas instance / segmentation. Do not re-apply on
// subsequent initialSession prop changes (host may pass new object refs when its
// vizSlots or selected brush changes). Re-applying would reset paintedRegions to
// whatever the (often partial) seed snapshot contains.
return;
}
hasAppliedInitialSessionRef.current = true;
restoreSession(initialSession);
}, [initialSession, segmentsReady, restoreSession]);
useEffect(() => {
return () => {
if (initFlashTimerRef.current) {
clearTimeout(initFlashTimerRef.current);
}
};
}, []);
const stopInitRegionFlash = useCallback(() => {
initFlashActiveRef.current = false;
if (initFlashTimerRef.current) {
clearTimeout(initFlashTimerRef.current);
initFlashTimerRef.current = null;
}
setInitFlashRegionId(null);
}, []);
const startInitRegionFlashLoop = useCallback(() => {
const ir = getMaskSegmentRuntimeConfig().interaction;
if (!ir.enableInitRegionFlash) {
return;
}
if (initFlashActiveRef.current) {
return;
}
const allRegions = regionsRef.current;
if (allRegions.length === 0) {
return;
}
// Filter out painted regions and tiny regions (noise / thin strips
// that produce negligible overlays). Threshold: 0.2% of total image area.
const imgSize = imageSizeRef2.current;
const minFlashArea = imgSize
? Math.max(500, imgSize.w * imgSize.h * 0.002)
: 500;
initFlashListRef.current = allRegions.filter((r) => !paintedRegionsRef.current.has(r.id) && r.area >= minFlashArea);
initFlashActiveRef.current = true;
initFlashIndexRef.current = 0;
const showNext = () => {
if (!initFlashActiveRef.current || initFlashListRef.current.length === 0) {
return;
}
const list = initFlashListRef.current;
const idx = initFlashIndexRef.current;
if (idx >= list.length) {
// One full pass of dashed outline flashes (one per *unpainted* region) is enough.
// Stop automatically; onUserInteraction can stop early.
stopInitRegionFlash();
return;
}
setInitFlashRegionId(list[idx].id);
initFlashIndexRef.current += 1;
initFlashTimerRef.current = setTimeout(showNext, ir.initRegionFlashMs);
};
showNext();
}, []);
const onUserInteraction = useCallback(() => {
stopInitRegionFlash();
}, [stopInitRegionFlash]);
// Once any region has been painted, stop the entire discovery flash immediately.
// No individual pruning — it's all or nothing: either 0 painted → cycle through
// all regions, or ≥1 painted → stop flashing entirely.
useEffect(() => {
if (!initFlashActiveRef.current)
return;
if (paintedRegionsRef.current.size > 0) {
stopInitRegionFlash();
}
}, [paintedRegions, stopInitRegionFlash]);
useEffect(() => {
if (segmentsReady && maskPathsReady && containRect && regionCount > 0 && canvasInteractive) {
if (!initFlashActiveRef.current) {
startInitRegionFlashLoop();
}
return;
}
if (!segmentsReady) {
stopInitRegionFlash();
setCanvasInteractive(false);
}
}, [
segmentsReady,
maskPathsReady,
containRect,
regionCount,
canvasInteractive,
startInitRegionFlashLoop,
stopInitRegionFlash,
]);
const getActiveBrushColor = useCallback(() => {
if (customPaintColor) {
return customPaintColor;
}
if (activeBrushIndex == null) {
return null;
}
return paintPalette[activeBrushIndex] ?? null;
}, [customPaintColor, activeBrushIndex, paintPalette]);
const hasActiveBrush = customPaintColor != null || activeBrushIndex != null;
const applyPaintToRegion = useCallback((targetRegionId, color) => {
let applied = false;
setPaintedRegions(prev => {
const existing = prev.get(targetRegionId);
if (existing && bgrColorEquals(existing, color)) {
return prev;
}
applied = true;
setPaintHistory(history => {
const last = history[history.length - 1];
if (last === targetRegionId) {
return history;
}
return [...history.filter(id => id !== targetRegionId), targetRegionId];
});
const next = new Map(prev);
next.set(targetRegionId, color);
paintedRegionsRef.current = next;
return next;
});
if (applied) {
const configJson = customPaintConfigJsonRef.current ??
paintedRegionConfigRef.current.get(targetRegionId);
if (customPaintConfigJsonRef.current) {
paintedRegionConfigRef.current.set(targetRegionId, customPaintConfigJsonRef.current);
}
const region = regionsRef.current.find(reg => reg.id === targetRegionId);
onPaintCallbackRef.current?.({
kind: 'painted',
regionId: targetRegionId,
regionName: region?.name ?? String(targetRegionId),
color,
configJson,
});
}
}, []);
const findRegionAtPoint = useCallback((x, y, strict = false) => {
if (!segmentsReady || !imageSize || regionsRef.current.length === 0) {
return null;
}
const norm = canvasToNormalized(x, y, canvasW, canvasH, imageSize.w, imageSize.h);
if (!norm) {
return null;
}
const regionPick = regionPickRef.current;
if (regionPick) {
const pickHit = lookupRegionFromPickMap(norm.x, norm.y, regionPick, strict
? 0
: getMaskSegmentRuntimeConfig().interaction.pickMapSearchRadiusPx);
if (pickHit != null) {
return pickHit;
}
if (strict) {
return null;
}
}
const pick = maskPickRef.current;
const kickId = kickRegionIdRef.current;
if (!strict) {
const polygonHit = resolveRegionHit(regionsRef.current, norm.x, norm.y);
if (polygonHit != null) {
return polygonHit;
}
}
if (pick && kickId != null) {
const pickMask = baseboardPickMaskRef.current;
const kickHit = pickKickRegionFromMask(norm.x, norm.y, pick, kickId, pickMask, strict);
if (kickHit != null) {
return kickHit;
}
if (!strict) {
const kickReg = regionsRef.current.find(reg => reg.id === kickId);
if (kickReg && pickKickNearStrip(norm.x, norm.y, kickReg)) {
return kickId;
}
}
}
return null;
}, [segmentsReady, imageSize, canvasW, canvasH]);
const selectBrushColor = useCallback((brushIndex) => {
onUserInteraction();
setCustomPaintColor(null);
customPaintConfigJsonRef.current = undefined;
setActiveBrushIndex(brushIndex);
void loadPaintLayersIfNeeded();
}, [onUserInteraction, loadPaintLayersIfNeeded]);
const onCanvasTap = useCallback((x, y) => {
onUserInteraction();
if (!segmentsReady ||
!imageSize ||
regionsRef.current.length === 0 ||
!hasActiveBrush ||
!paintResourcesReady ||
disabled) {
return;
}
const regionId = findRegionAtPoint(x, y);
if (regionId == null) {
return;
}
const brushColor = getActiveBrushColor();
if (!brushColor) {
return;
}
applyPaintToRegion(regionId, brushColor);
if (!paintResourcesReady && !paintLayersPromiseRef.current) {
void loadPaintLayersIfNeeded();
}
}, [
segmentsReady,
imageSize,
hasActiveBrush,
paintResourcesReady,
disabled,
findRegionAtPoint,
getActiveBrushColor,
applyPaintToRegion,
onUserInteraction,
loadPaintLayersIfNeeded,
]);
// ── Canvas-size & state refs for gesture callbacks ─────────────────────
// Declared after their targets so useRef receives the current value.
// canvasWRef / canvasHRef are declared earlier (before segmentAndPrepareLayers)
// so the async body can read the latest layout size. Their sync useEffect is below.
const hasActiveBrushRef = useRef(hasActiveBrush);
const disabledRef = useRef(disabled);
const segmentsReadyRef2 = useRef(segmentsReady);
const imageSizeRef2 = useRef(imageSize);
useEffect(() => { canvasWRef.current = canvasW; }, [canvasW]);
useEffect(() => { canvasHRef.current = canvasH; }, [canvasH]);
useEffect(() => { hasActiveBrushRef.current = hasActiveBrush; }, [hasActiveBrush]);
useEffect(() => { disabledRef.current = disabled; }, [disabled]);
useEffect(() => { segmentsReadyRef2.current = segmentsReady; }, [segmentsReady]);
useEffect(() => { imageSizeRef2.current = imageSize; }, [imageSize]);
// Stable refs for functions called inside gesture closures
const findRegionAtPointRef = useRef(findRegionAtPoint);
const onCanvasTapRef = useRef(onCanvasTap);
useEffect(() => { findRegionAtPointRef.current = findRegionAtPoint; }, [findRegionAtPoint]);
useEffect(() => { onCanvasTapRef.current = onCanvasTap; }, [onCanvasTap]);
const undoSelection = useCallback(() => {
onUserInteraction();
setPaintHistory(history => {
if (history.length === 0) {
return history;
}
const lastId = history[history.length - 1];
setPaintedRegions(prev => {
const next = new Map(prev);
next.delete(lastId);
paintedRegionsRef.current = next; // sync for imperative readers
return next;
});
paintedRegionConfigRef.current.delete(lastId);
return history.slice(0, -1);
});
}, [onUserInteraction]);
const clearAllPaint = useCallback(() => {
onUserInteraction();
const cleared = new Map();
paintedRegionsRef.current = cleared;
setPaintedRegions(cleared);
setPaintHistory([]);
paintedRegionConfigRef.current = new Map();
lastExportCacheRef.current = null;
resetZoom();
}, [onUserInteraction, resetZoom]);
const captureHighResExportPngBase64 = useCallback(async () => {
try {
const c = highResExportCanvasRef.current;
const sz = exportCanvasSize;
if (!c || !sz) {
return undefined;
}
let snap = c.makeImageSnapshot?.();
if (!snap) {
await new Promise((r) => requestAnimationFrame(() => r()));
snap = c.makeImageSnapshot?.();
}
if (!snap) {
return undefined;
}
const enc = snap.encodeToBase64;
if (typeof enc !== 'function') {
return undefined;
}
const b64 = enc.call(snap) || '';
return b64.length > 0 ? b64 : undefined;
}
catch (e) {
console.warn('[VIZ-SAVE] highResExportCanvas makeImageSnapshot failed:', e);
return undefined;
}
}, [exportCanvasSize]);
const runExportPipeline = useCallback(async (livePainted, destDir) => {
const work = workBufferRef.current;
const pick = regionPickRef.current;
if (!work || !pick) {
throw new Error('image not ready, cannot save');
}
let snapshotPngBase64;
if (livePainted.size > 0) {
snapshotPngBase64 = await captureHighResExportPngBase64();
}
const layers = paintResourceLayersRef.current;
const map = paintColorMapSkImgRef.current;
const originForExport = originSkImgRef.current ?? layers?.lowFreqImage ?? null;
const shaderTextures = !snapshotPngBase64 && originForExport && layers && map && paintResourcesReady
? {
originImage: originForExport,
paintColorMap: map,
lowFreqImage: layers.lowFreqImage,
highFreqImage: layers.highFreqImage,
}
: undefined;
const result = await compositePaintedImage({
originBuffer: work.buffer,
cols: work.cols,
rows: work.rows,
pickBuffer: pick.buffer,
paintedRegions: livePainted,
destDir,
...(snapshotPngBase64 ? { exportPngBase64: snapshotPngBase64 } : {}),
shaderTextures,
renderWidth: work.cols,
renderHeight: work.rows,
});
lastExportCacheRef.current = {
fingerprint: paintedRegionsFingerprint(livePainted),
result,
};
return result;
}, [captureHighResExportPngBase64, paintResourcesReady]);
// Debounced background export — pre-warms the save() cache after each paint change.
useEffect(() => {
if (!autoExportOnReady) {
return;
}
if (!segmentsReady || !paintResourcesReady) {
return;
}
if (initialSession && !hasAppliedInitialSessionRef.current) {
return;
}
if (paintedRegions.size === 0) {
return;
}
if (autoExportDebounceRef.current) {
clearTimeout(autoExportDebounceRef.current);
}
autoExportDebounceRef.current = setTimeout(() => {
autoExportDebounceRef.current = null;
if (exportInFlightRef.current) {
return;
}
const livePainted = paintedRegionsRef.current;
if (!livePainted || livePainted.size === 0) {
return;
}
exportInFlightRef.current = true;
void (async () => {
try {
const result = await runExportPipeline(livePainted);
onExportedRef.current?.(result);
}
catch (e) {
console.log('[VIZ-SAVE] debounced autoExport threw (non-fatal):', e);
}
finally {
exportInFlightRef.current = false;
}
})();
}, 400);
return () => {
if (autoExportDebounceRef.current) {
clearTimeout(autoExportDebounceRef.current);
autoExportDebounceRef.current = null;
}
};
}, [
autoExportOnReady,
segmentsReady,
paintResourcesReady,
initialSession,
paintedRegions,
runExportPipeline,
]);
useImperativeHandle(ref, () => ({
reset: undoSelection,
swap: (showOrigin) => {
onUserInteraction();
if (showOrigin === undefined) {
setCompareMode(v => !v);
}
else {
setCompareMode(showOrigin);
}
},
save: async (options) => {
const livePainted = paintedRegionsRef.current && paintedRegionsRef.current.size > 0
? paintedRegionsRef.current
: paintedRegions;
const fp = paintedRegionsFingerprint(livePainted);
const cached = lastExportCacheRef.current;
if (cached?.fingerprint === fp && cached.result) {
return resolveExportResultForDestDir(cached.result, options?.destDir);
}
try {
return await runExportPipeline(livePainted, options?.destDir);
}
catch (e) {
console.error('[VIZ-SAVE] SDK save() composite threw:', e);
throw e;
}
},
getLastExport: () => lastExportCacheRef.current?.result ?? null,
session: () => ({
version: 1,
originUrl: originUrlRef.current,
maskUrl: maskUrlRef.current,
painted: buildPaintedRecords(),
paintHistory: [...paintHistory],
currentColor: customPaintColor ?? undefined,
currentColorConfigJson: customPaintConfigJsonRef.current,
savedAt: Date.now(),
}),
loadSession: restoreSession,
setPaintColor: (color, configJson) => {
setCustomPaintColor(color);
customPaintConfigJsonRef.current = configJson;
setActiveBrushIndex(null);
},
setMaskConfig: config => {
setMaskSegmentRuntimeConfig({ maskConfig: config });
runtimeRef.current = getMaskSegmentRuntimeConfig();
if (originImgPath && maskImgPath) {
lastSegmentKeyRef.current = '';
void segmentAndPrepareLayers(originImgPath, maskImgPath);
}
},
clearAllPaint,
undoSelection,
resegment: clearCacheAndResegment,
getRegions: () => [...regionsRef.current],
getPaintedRegions: () => buildPaintedRecords(),
}), [
undoSelection,
onUserInteraction,
paintedRegions,
paintHistory,
customPaintColor,
buildPaintedRecords,
restoreSession,
clearAllPaint,
clearCacheAndResegment,
runExportPipeline,
]);
const [regionOutlinePaths, setRegionOutlinePaths] = useState(new Map());
useEffect(() => {
if (!segmentsReady || !containRect || regionPalette.length === 0) {
if (!segmentsReady) {
setRegionOutlinePaths(new Map());
setMaskPathsReady(false);
maskPathsContainRectRef.current = null;
}
return;
}
const maskData = regionMaskDataRef.current;
if (!maskData) {
setRegionOutlinePaths(new Map());
setMaskPathsReady(false);
return;
}
if (maskPathsContainRectRef.current &&
rectsEqual(maskPathsContainRectRef.current, containRect)) {
return;
}
const pathStart = __DEV__ ? performance.now() : 0;
const pathMaskData = downsampleMaskDataForPaths(maskData, getMaskSegmentRuntimeConfig().pipeline.maskPathMaxLongSide);
const outlines = buildAllRegionOutlinePaths(regionPalette, pathMaskData, containRect);
maskPathsContainRectRef.current = containRect;
setRegionOutlinePaths(outlines);
setMaskPathsReady(true);
maskPathsReadyRef.current = true;
emitMaskPathsReadyIfReady();
}, [segmentsReady, containRect, regionPalette, emitMaskPathsReadyIfReady]);
const heldOutlinePath = useMemo(() => {
if (heldRegionId == null ||
heldRegionAnchor == null ||
!containRect ||
regionPalette.length === 0) {
return null;
}
const maskData = regionMaskDataRef.current;
if (!maskData) {
return null;
}
const pathMaskData = downsampleMaskDataForPaths(maskData, getMaskSegmentRuntimeConfig().pipeline.maskPathMaxLongSide);
return buildRegionOutlinePathForRegion(heldRegionId, regionPalette, pathMaskData, containRect, heldRegionAnchor);
}, [heldRegionId, heldRegionAnchor, containRect, regionPalette]);
const renderImageLayer = (image, opacity = 1) => {
if (!image || !containRect)
return null;
return (_jsx(SkiaImage, { image: image, x: containRect.x, y: containRect.y, width: containRect.w, height: containRect.h, opacity: opacity }));
};
const renderRegionMaskOverlay = (regionId, keyPrefix) => {
const path = regionOutlinePaths.get(regionId);
if (!path || !containRect)
return null;
return (_jsxs(_Fragment, { children: [_jsx(Path, { path: path, color: paintRuntime.regionOverlayFill, style: "fill", opacity: 0.05 }, `${keyPrefix}-fill-${regionId}`), _jsx(Path, { path: path, color: paintRuntime.regionOverlayFill, style: "stroke", strokeWidth: 3, strokeJoin: "round", antiAlias: true, children: _jsx(DashPathEffect, { intervals: [8, 6] }) }, `${keyPrefix}-stroke-${regionId}`)] }));
};
// ── Zoom transform for the Skia Group ─────────────────────────────────
// Note: single-finger pan/drag is disabled. Zoom is always centered around the
// viewport center (no additional panOffset translation). The panOffset state
// is kept (and forced to 0 during pinch) for compatibility with screenToCanvasCoords
// inverse mapping used by tap/paint logic, and for resetZoom.
const zoomTransform = useMemo(() => {
if (zoomScale <= 1)
return undefined;
return [
{ translateX: 0, translateY: 0 },
{ translateX: canvasW / 2, translateY: canvasH / 2 },
{ scale: zoomScale },
{ translateX: -canvasW / 2, translateY: -canvasH / 2 },
];
}, [zoomScale, canvasW, canvasH]);
const renderDraw = () => {
const displayImg = originSkImg ?? lowFreqSkImg;
if (!displayImg || !containRect) {
return null;
}
const showOverlay = !compareMode && segmentsReady;
const shaderReady = paintColorMapSkImg &&
lowFreqSkImg &&
highFreqSkImg &&
paintResourcesReady;
const useShader = !compareMode && paintedRegions.size > 0 && shaderReady;
const shaderOrigin = originSkImg ?? lowFreqSkImg;
// Background color for areas of the viewport that become visible around the
// zoomed (centered) photo content. Using a light gray that matches common
// preview container backgrounds prevents "mosaic-like colored blocks"
// (shader leakage or edge sampling artifacts) from appearing outside the
// photo rect when the content is scaled up.
const previewBg = '#F0F1F3';
return (_jsxs(_Fragment, { children: [_jsx(Rect, { x: 0, y: 0, width: canvasW, height: canvasH, color: previewBg }), _jsx(Group, { clip: Skia.XYWHRect(0, 0, canvasW, canvasH), children: _jsxs(Group, { transform: zoomTransform, children: [useShader && shaderOrigin ? (_jsx(PaintShaderLayer, { originImage: shaderOrigin, paintColorMap: paintColorMapSkImg, lowFreqImage: lowFreqSkImg, highFreqImage: highFreqSkImg, x: containRect.x, y: containRect.y, width: containRect.w, height: containRect.h, showOrigin: false })) : (renderImageLayer(displayImg)), showOverlay &&
initFlashRegionId != null &&
renderRegionMaskOverlay(initFlashRegionId, 'init-overlay'), showOverlay &&
heldRegionId != null &&
!paintedRegions.has(heldRegionId) &&
renderRegionMaskOverlay(heldRegionId, 'hold-overlay')] }) })] }));
};
// Full-bleed (0,0 to work size) composition for the high-res export snapshot canvas.
// No UI overlays (dashes, held, flash). When painted + shader ready we use the exact
// same PaintShaderLayer the user sees in the editor, so makeImageSnapshot() gives
const renderFullResPainted = () => {
const sz = exportCanvasSize;
if (!sz)
return null;
const ew = sz.w;
const eh = sz.h;
const shaderReady = paintColorMapSkImg &&
lowFreqSkImg &&
highFreqSkImg &&
paintResourcesReady;
const useShader = paintedRegions.size > 0 && shaderReady;
const shaderOrigin = originSkImg ?? lowFreqSkImg;
if (useShader && shaderOrigin) {
return (_jsx(PaintShaderLayer, { originImage: shaderOrigin, paintColorMap: paintColorMapSkImg, lowFreqImage: lowFreqSkImg, highFreqImage: highFreqSkImg, x: 0, y: 0, width: ew, height: eh, showOrigin: false }));
}
// No paints yet or shader not ready — export the (scaled) origin as-is.
const displayImg = originSkImg ?? lowFreqSkImg;
if (displayImg) {
return (_jsx(SkiaImage, { image: displayImg, x: 0, y: 0, width: ew, height: eh }));
}
return null;
};
// ── Gesture: tap (single-finger paint / highlight / brush_required) ────
const tapGesture = useMemo(() => {
const onBeginJS = (x, y) => {
// Immediate hold highlight — fires on touch-down regardless of brush state.
// When a brush is active, this lets the user preview which region they're
// about to paint before lifting their finger.
onUserInteraction();
const coords = screenToCanvasCoords(x, y, canvasWRef.current, canvasHRef.current, zoomScaleRef.current, panOffsetRef.current);
const regionId = findRegionAtPointRef.current(coords.x, coords.y, true);
if (regionId == null || !imageSizeRef2.current) {
setHeldRegionId(null);
setHeldRegionAnchor(null);
return;
}
if (paintedRegionsRef.current && paintedRegionsRef.current.has(regionId)) {
setHeldRegionId(null);
setHeldRegionAnchor(null);
return;
}
const norm = canvasToNormalized(coords.x, coords.y, canvasWRef.current, canvasHRef.current, imageSizeRef2.current.w, imageSizeRef2.current.h);
setHeldRegionId(regionId);
setHeldRegionAnchor(norm);
};
const onEndJS = (x, y, success) => {
if (!success)
return;
const coords = screenToCanvasCoords(x, y, canvasWRef.current, canvasHRef.current, zoomScaleRef.current, panOffsetRef.current);
if (hasActiveBrushRef.current) {
onCanvasTapRef.current(coords.x, coords.y);
}
else {
// Brush_required
onUserInteraction();
if (disabledRef.current || !segmentsReadyRef2.current || !imageSizeRef2.current)
return;
const regionId = findRegionAtPointRef.current(coords.x, coords.y);
if (regionId == null)
return;
const region = regionsRef.current.find(r => r.id === regionId);
onPaintCallbackRef.current?.({
kind: 'brush_required',
hint: '请先选择笔刷颜色',
regionId,
regionName: region?.name ?? String(regionId),
});
}
};
const onFinalizeJS = (x, y) => {
// Cleanup hold highlight (replaces onCanvasPressOut)
if (hasActiveBrushRef.current) {
setHeldRegionId(null);
setHeldRegionAnchor(null);
return;
}
const coords = screenToCanvasCoords(x, y, canvasWRef.current, canvasHRef.current, zoomScaleRef.current, panOffsetRef.current);
onCanvasTapRef.current(coords.x, coords.y);
setHeldRegionId(null);
setHeldRegionAnchor(null);
};
return Gesture.Tap()
.onBegin((e) => {
'worklet';
runOnJS(onBeginJS)(e.x, e.y);
})
.onEnd((e, success) => {
'worklet';
runOnJS(onEndJS)(e.x, e.y, success);
})
.onFinalize((e) => {
'worklet';
runOnJS(onFinalizeJS)(e.x, e.y);
});
}, [onUserInteraction]);
// ── Gesture: pinch-zoom (two-finger scale; max 5×) ─────────────────────
const pinchGesture = useMemo(() => {
const onStartJS = () => {
zoomBaseRef.current = zoomScaleRef.current;
};
const onUpdateJS = (scale) => {
const newScale = Math.max(1, Math.min(zoomBaseRef.current * scale, 5));
setZoomScale(newScale);
zoomScaleRef.current = newScale;
// Lock pan offset to zero: only two-finger pinch zoom is supported.
// Single-finger drag/pan after zoom is intentionally disabled per product decision.
// Zoom is always centered; no additional translate from panOffset is applied in practice.
setPanOffset({ x: 0, y: 0 });
panOffsetRef.current = { x: 0, y: 0 };
};
const onEndJS = () => {
if (zoomScaleRef.current <= 1.01) {
resetZoom();
}
};
return Gesture.Pinch()
.onStart(() => {
'worklet';
runOnJS(onStartJS)();
})
.onUpdate((e) => {
'worklet';
runOnJS(onUpdateJS)(e.scale);
})
.onEnd(() => {
'worklet';
runOnJS(onEndJS)();
});
}, [resetZoom]);
// ── Composed: Race ensures single-tap and pinch-zoom never conflict ─────────
const composedGesture = useMemo(() => Gesture.Race(tapGesture, pinchGesture), [tapGesture, pinchGesture]);
return (_jsxs(View, { style: [styles.container, style], onLayout: (e) => {
const { width, height } = e.nativeEvent.layout;
setLayoutWidth(width);
setLayoutHeight(height);
}, children: [(showToolbar || showDebugPickers || showStatusRow || showColorBar) ? (_jsxs(ScrollView, { style: styles.scroll, showsVerticalScrollIndicator: false, contentContainerStyle: styles.scrollContent, keyboardShouldPersistTaps: "always", children: [showToolbar && (_jsx(View, { style: styles.toolbarRow, children: _jsx(Button, { title: isRefreshing ? 're-segmenting…' : 'clear cache and re-segment', onPress: clearCacheAndResegment, disabled: isRefreshing || !originImgPath || !maskImgPath }) })), showDebugPickers && (_jsxs(View, { style: styles.btnRow, children: [_jsx(Button, { title: "pick origin image", onPress: pickOriginImage }), _jsx(View, { style: styles.btnGap }), _jsx(Button, { title: "pick mask image", onPress: pickMaskImage })] })), showStatusRow && (_jsx(View, { style: styles.statusRow, children: segError ? (_jsxs(Text, { style: styles.err, children: ["\u274C ", segError] })) : !segmentsReady ? (_jsx(Text, { style: styles.hint, children: "\u23F3 segmenting\u2026" })) : layersLoading || !paintResourcesReady ? (_jsxs(Text, { style: styles.hint, children: ["\u2705 ", regionCount, " regions \u00B7 Shader textures preparing\u2026"] })) : !(originSkImg ?? lowFreqSkImg) ? (_jsx(Text, { style: styles.hint, children: "\u23F3 image loading\u2026" })) : (_jsxs(Text, { style: styles.hint, children: ["\u2705 ", regionCount, " regions \u00B7 painted ", paintedRegions.size, " \u00B7", ' ', hasActiveBrush
? customPaintColor
? 'custom paint color'
: `paint color ${(activeBrushIndex ?? 0) + 1}`
: 'please select the bottom paint color first', ' · ', compareMode ? 'compare original image' : 'paint mode'] })) })), showColorBar && (_jsxs(View, { style: styles.colorBar, children: [_jsx(Text, { style: styles.colorBarLabel, children: "paint color (tap to select, then tap canvas to paint)" }), _jsxs(View, { style: styles.colorSwatches, children: [paintPalette.map((color, index) => {
const isActive = activeBrushIndex === index && customPaintColor == null;
const { b, g, r } = color;
return (_jsx(TouchableOpacity, { style: [
styles.colorSwatch,
{ backgroundColor: bgrToCss(b, g, r) },
isActive && styles.colorSwatchSelected,
], activeOpacity: 0.8, disabled: !segmentsReady || disabled, onPress: () => selectBrushColor(index) }, index));
}), !segmentsReady && (_jsx(Text, { style: styles.colorBarEmpty, children: "loading\u2026" }))] }), customPaintColor && (_jsx(Text, { style: styles.hint, children: "current custom paint color is set by ref.setPaintColor" }))] }))] })) : null, _jsx(View, { style: [styles.canvasOuter, canvasStyle], children: _jsxs(View, { style: [styles.canvasWrap, { width: canvasW, height: canvasH }], children: [_jsx(GestureDetector, { gesture: composedGesture, children: _jsx(View, { style: [styles.canvasTouchLayer, { width: canvasW, height: canvasH }], children: _jsx(Canvas, { style: { width: canvasW, height: canvasH }, pointerEvents: "none", children: renderDraw() }) }) }), showOverlayButtons &&
(renderUndoButton ? (renderUndoButton({
onPress: undoSelection,
disabled: paintHistory.length === 0,
text: undoButtonText,
})) : (_jsx(TouchableOpacity, { style: [styles.overlayBtn, styles.btnBottomLeft, undoButtonStyle], activeOpacity: 0.7, disabled: paintHistory.length === 0 || disabled, onPress: undoSelection, children: _jsx(Text, { style: [
styles.btnText,
undoButtonTextStyle,
{ opacity: paintHistory.length === 0 ? 0.4 : 1 },
], children: undoButtonText }) }))), showOverlayButtons &&
(renderCompareButton ? (renderCompareButton({
onPress: () => {
onUserInteraction();
setCompareMode(v => !v);
},
text: compareMode ? compareExitButtonText : compareButtonText,
})) : (_jsx(TouchableOpacity, { style: [
styles.overlayBtn,
styles.btnBottomRight,
compareButtonStyle,
], activeOpacity: 0.7, disabled: disabled, onPress: () => {
onUserInteraction();
setCompareMode(v => !v);
}, children: _jsx(Text, { style: [styles.btnText, compareButtonTextStyle], children: compareMode ? compareExitButtonText : compareButtonText }) })))] }) }), highResSnapshotEnabled && exportCanvasSize ? (_jsx(View, { style: {
position: 'absolute',
left: -exportCanvasSize.w - 200,
top: 0,
width: exportCanvasSize.w,
height: exportCanvasSize.h,
opacity: 0,
overflow: 'hidden',
}, pointerEvents: "none", children: _jsx(Canvas, { ref: highResExportCanvasRef, style: { width: exportCanvasSize.w, height: exportCanvasSize.h }, pointerEvents: "none", children: _jsx(Group, { children: renderFullResPainted() }) }) })) : null] }));
});
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
scroll: {
flex: 1,
},
scrollContent: {
padding: 10,
paddingBottom: 28,
},
toolbarRow: {
marginBottom: 8,
},
btnRow: {
flexDirection: 'row',
marginBottom: 8,
},
btnGap: {
width: 10,
},
statusRow: {
marginBottom: 8,
},
hint: {
color: '#333',
fontSize: 13,
},
err: {
color: '#c33',
fontSize: 13,
},
canvasWrap: {
position: 'relative',
backgroundColor: '#f5f5f5',
borderWidth: StyleSheet.hairlineWidth,
borderColor: '#ddd',
overflow: 'hidden',
},
canvasOuter: {
alignItems: 'center',
justifyContent: 'center',
// When the internal control ScrollView is omitted (the normal case for
// VisualizationScreen and non-interactive scheme previews), this makes the
// canvas area fill the bounds provided by the host (via style + maxHeight)
// and centers the fixed-size image rect. This also ensures the
// GestureDetector sits in the right place to receive taps and two-finger pinches.
flex: 1,
},
canvasTouchLayer: {
flex: 1,
},
canvas: {
flex: 1,
},
overlayBtn: {
position: 'absolute',
bottom: 10,
paddingHorizontal: 14,
paddingVertical: 7,
backgroundColor: 'rgba(0, 0, 0, 0.62)',
borderRadius: 6,
},
btnBottomLeft: {
left: 10,
},
btnBottomRight: {
right: 10,
},
btnText: {
color: '#fff',
fontSize: 13,
fontWeight: '600',
},
colorBar: {
marginTop: 12,
paddingVertical: 10,
paddingHorizontal: 4,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#e0e0e0',
},
colorBarLabel: {
fontSize: 12,
color: '#666',
marginBottom: 10,
},
colorSwatches: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
gap: 12,
},
colorSwatch: {
width: 44,
height: 44,
borderRadius: 22,
borderWidth: 2,
borderColor: 'rgba(0,0,0,0.12)',
alignItems: 'center',
justifyContent: 'center',
},
colorSwatchSelected: {
borderColor: '#1e96ff',
borderWidth: 3,
transform: [{ scale: 1.08 }],
},
colorSwatchPainted: {
borderColor: 'rgba(255,255,255,0.9)',
},
colorSwatchDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: 'rgba(255,255,255,0.92)',
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.25)',
},
colorBarEmpty: {
fontSize: 13,
color: '#999',
},
});
export default MaskSegmentCanvas;
//# sourceMappingURL=MaskSegmentCanvas.js.map