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 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