refactor: simplify MaskSegmentCanvas core logic and reduce bundle size

Streamline the main canvas component, type definitions, and paint
shader runtime. Removes unused code paths and redundant logic across
src/ and example/, resulting in a net reduction of ~460 lines.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
a1518 2026-06-30 23:56:13 -07:00
parent bf6b6cf502
commit 84b45ef24d
16 changed files with 400 additions and 859 deletions

View File

@ -114,7 +114,6 @@ function App(): React.JSX.Element {
maskUrl={testPaths.mask} maskUrl={testPaths.mask}
semanticColors={MASK_SEMANTIC_COLORS} semanticColors={MASK_SEMANTIC_COLORS}
regionOutlineColor="rgba(20, 120, 235, 0.58)" regionOutlineColor="rgba(20, 120, 235, 0.58)"
showDebugPickers
initialSession={sessionDraft ?? undefined} initialSession={sessionDraft ?? undefined}
onWatch={(state, durationMs, detail) => { onWatch={(state, durationMs, detail) => {
setWatchState(state); setWatchState(state);

View File

@ -1 +1 @@
{"version":3,"file":"MaskSegmentCanvas.d.ts","sourceRoot":"","sources":["../../src/components/MaskSegmentCanvas.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AAqDf,OAAO,KAAK,EAEV,sBAAsB,EACtB,oBAAoB,EAMrB,MAAM,2BAA2B,CAAC;AAcnC,YAAY,EACV,sBAAsB,EACtB,oBAAoB,EACpB,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,EACnB,QAAQ,EACR,iBAAiB,GAClB,MAAM,2BAA2B,CAAC;AA4ZnC,QAAA,MAAM,iBAAiB,qGA2qErB,CAAC;AA8HH,eAAe,iBAAiB,CAAC"} {"version":3,"file":"MaskSegmentCanvas.d.ts","sourceRoot":"","sources":["../../src/components/MaskSegmentCanvas.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AA8Cf,OAAO,KAAK,EAEV,sBAAsB,EACtB,oBAAoB,EAMrB,MAAM,2BAA2B,CAAC;AAcnC,YAAY,EACV,sBAAsB,EACtB,oBAAoB,EACpB,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,EACnB,QAAQ,EACR,iBAAiB,GAClB,MAAM,2BAA2B,CAAC;AA0cnC,QAAA,MAAM,iBAAiB,qGAo/DrB,CAAC;AAmBH,eAAe,iBAAiB,CAAC"}

View File

@ -1,9 +1,8 @@
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; 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 { useRef, useState, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle, } from 'react';
import { View, StyleSheet, Button, Dimensions, Text, TouchableOpacity, ScrollView, } from 'react-native'; import { View, StyleSheet, } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { runOnJS } from 'react-native-reanimated'; import { runOnJS } from 'react-native-reanimated';
import { launchImageLibrary } from 'react-native-image-picker';
import cv from '../utils/opencvAdapter'; import cv from '../utils/opencvAdapter';
import { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion, downsampleMaskDataForPaths, extractRegionsFromMaskBufferSync, isBaseboardMaskPixel, upscaleBinaryMask, } from '../utils/maskSegmentation'; import { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion, downsampleMaskDataForPaths, extractRegionsFromMaskBufferSync, isBaseboardMaskPixel, upscaleBinaryMask, } from '../utils/maskSegmentation';
import { splitWallRegionsByTexture } from '../utils/wallTextureSplit'; import { splitWallRegionsByTexture } from '../utils/wallTextureSplit';
@ -15,10 +14,6 @@ import { preparePaintResourcesFromWorkBuffer, releaseFreqLayerImages, } from '..
import { PaintShaderLayer, createPaintColorMapForPaint, } from '../utils/paintShaderRuntime'; import { PaintShaderLayer, createPaintColorMapForPaint, } from '../utils/paintShaderRuntime';
import { createRuntimeConfig, getMaskRuntimeRevision, getMaskSegmentRuntimeConfig, resolvePipelineConfig, setMaskSegmentRuntimeConfig, } from '../utils/maskSegmentRuntime'; 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'; 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) { function bgrColorEquals(a, b) {
return a.b === b.b && a.g === b.g && a.r === b.r; return a.b === b.b && a.g === b.g && a.r === b.r;
} }
@ -53,6 +48,45 @@ function canvasToNormalized(cx, cy, canvasW, canvasH, imgW, imgH) {
y: (cy - rect.y) / rect.h, y: (cy - rect.y) / rect.h,
}; };
} }
/** Skia matrix: pan → scale around viewport center. Matches screenToCanvasCoords. */
function buildZoomPanMatrix(panX, panY, scale, canvasW, canvasH) {
const cx = canvasW / 2;
const cy = canvasH / 2;
const m = Skia.Matrix();
m.translate(panX, panY);
m.translate(cx, cy);
m.scale(scale, scale);
m.translate(-cx, -cy);
return m;
}
/** Clamp pan so scaled containRect does not expose empty margins beyond the viewport. */
function clampPanOffset(pan, scale, canvasW, canvasH, containRect) {
if (!containRect || scale <= 1 || canvasW <= 0 || canvasH <= 0) {
return { x: 0, y: 0 };
}
const cx = canvasW / 2;
const cy = canvasH / 2;
const r = containRect;
const scaledMinX = cx + scale * (r.x - cx);
const scaledMaxX = cx + scale * (r.x + r.w - cx);
const scaledMinY = cy + scale * (r.y - cy);
const scaledMaxY = cy + scale * (r.y + r.h - cy);
let x = pan.x;
let y = pan.y;
if (scaledMaxX - scaledMinX > canvasW) {
x = Math.max(canvasW - scaledMaxX, Math.min(-scaledMinX, x));
}
else {
x = 0;
}
if (scaledMaxY - scaledMinY > canvasH) {
y = Math.max(canvasH - scaledMaxY, Math.min(-scaledMinY, y));
}
else {
y = 0;
}
return { x, y };
}
/** /**
* Inverse of the Skia Group transform applied during pinch-zoom. * Inverse of the Skia Group transform applied during pinch-zoom.
* Converts a raw touch point (screen pixels) back to the canvas coordinate * Converts a raw touch point (screen pixels) back to the canvas coordinate
@ -62,7 +96,6 @@ function canvasToNormalized(cx, cy, canvasW, canvasH, imgW, imgH) {
function screenToCanvasCoords(screenX, screenY, canvasW, canvasH, zoomScale, panOffset) { function screenToCanvasCoords(screenX, screenY, canvasW, canvasH, zoomScale, panOffset) {
if (zoomScale <= 1) if (zoomScale <= 1)
return { x: screenX, y: screenY }; return { x: screenX, y: screenY };
// Reverse: translate(-pan) → unscale around center → translate(+center)
return { return {
x: (screenX - panOffset.x - canvasW / 2) / zoomScale + canvasW / 2, x: (screenX - panOffset.x - canvasW / 2) / zoomScale + canvasW / 2,
y: (screenY - panOffset.y - canvasH / 2) / zoomScale + canvasH / 2, y: (screenY - panOffset.y - canvasH / 2) / zoomScale + canvasH / 2,
@ -234,10 +267,6 @@ function lookupRegionFromPickMap(normX, normY, pick, radiusPx = getMaskSegmentRu
} }
return null; return null;
} }
/** BGR → 屏幕 RGB */
function bgrToCss(b, g, r) {
return `rgb(${r},${g},${b})`;
}
function releasePaintResourceLayers(layers) { function releasePaintResourceLayers(layers) {
if (!layers) { if (!layers) {
return; return;
@ -275,7 +304,7 @@ function timeLog(tag) {
* 组件主体 * 组件主体
* ========================================================================== */ * ========================================================================== */
const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) { 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 { originUrl: originUrlProp, maskUrl: maskUrlProp, originImgPath: originImgPathLegacy, maskImgPath: maskImgPathLegacy, maskConfig, pipelinePreset, pipelineConfig, paintConfig, interactionConfig, semanticColors, regionOutlineColor, initialSession, initialPaintColor, initialPaintConfigJson, disabled = false, style, onWatch, onPaintCallback, onError, autoExportOnReady, onExported, } = props;
const originSource = originUrlProp ?? originImgPathLegacy ?? ''; const originSource = originUrlProp ?? originImgPathLegacy ?? '';
const maskSource = maskUrlProp ?? maskImgPathLegacy ?? ''; const maskSource = maskUrlProp ?? maskImgPathLegacy ?? '';
const resolvedMaskConfig = useMemo(() => semanticColors const resolvedMaskConfig = useMemo(() => semanticColors
@ -488,7 +517,6 @@ const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
const paintLayersPromiseRef = useRef(null); const paintLayersPromiseRef = useRef(null);
const loadPaintLayersRef = useRef(() => Promise.resolve()); const loadPaintLayersRef = useRef(() => Promise.resolve());
const [paintResourcesReady, setPaintResourcesReady] = useState(false); const [paintResourcesReady, setPaintResourcesReady] = useState(false);
const [layersLoading, setLayersLoading] = useState(false);
const [maskPathsReady, setMaskPathsReady] = useState(false); const [maskPathsReady, setMaskPathsReady] = useState(false);
const baseboardPickMaskRef = useRef(null); const baseboardPickMaskRef = useRef(null);
const kickRegionIdRef = useRef(null); const kickRegionIdRef = useRef(null);
@ -506,110 +534,52 @@ const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
// after the user (or initialSession seed) has painted at least one region. This keeps // after the user (or initialSession seed) has painted at least one region. This keeps
// idle segmentation / no-paint cases cheap. // idle segmentation / no-paint cases cheap.
const [highResSnapshotEnabled, setHighResSnapshotEnabled] = useState(false); const [highResSnapshotEnabled, setHighResSnapshotEnabled] = useState(false);
// Layout measurement for the root container of this component. Declared early so // Viewport measured from canvasWrap onLayout — single source of truth for Skia,
// the canvasW/canvasH memos (which decide the viewport rect for zoom centering, // gestures, containRect, and pan clamp (must match the actual touch target).
// containRect placement, clipping, and gesture coordinate mapping) can close over it. const [viewportSize, setViewportSize] = useState(null);
// 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 [segmentsReady, setSegmentsReady] = useState(false);
const segmentsReadyRef = useRef(false); const segmentsReadyRef = useRef(false);
const maskPathsReadyRef = useRef(false); const maskPathsReadyRef = useRef(false);
const [canvasInteractive, setCanvasInteractive] = useState(false); const [canvasInteractive, setCanvasInteractive] = useState(false);
const [segError, setSegError] = useState('');
const [compareMode, setCompareMode] = useState(false); const [compareMode, setCompareMode] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false); const resegmentInFlightRef = useRef(false);
const [originSkImg, setOriginSkImg] = useState(null); const [originSkImg, setOriginSkImg] = useState(null);
const originSkImgRef = useRef(null); const originSkImgRef = useRef(null);
const lowFreqSkImg = paintResourceLayers?.lowFreqImage ?? null; const lowFreqSkImg = paintResourceLayers?.lowFreqImage ?? null;
const highFreqSkImg = paintResourceLayers?.highFreqImage ?? null; const highFreqSkImg = paintResourceLayers?.highFreqImage ?? null;
const canvasBaseW = SCREEN_WIDTH - 20; const canvasW = viewportSize?.w ?? 1;
// The "viewport" size for this canvas component: the rect inside which we place const canvasH = viewportSize?.h ?? 1;
// the contained image, apply the zoom Group transform (centered), clip, and receive const canvasLayoutReady = viewportSize != null && viewportSize.w > 0 && viewportSize.h > 0;
// gestures (tap + two-finger pinch). When we have a real onLayout from the host // Refs synced to the latest viewport size so async callbacks read post-layout values.
// (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 canvasWRef = useRef(canvasW);
const canvasHRef = useRef(canvasH); const canvasHRef = useRef(canvasH);
// ── Pinch-zoom (two-finger only; single-finger drag/pan disabled) ───────── // ── Pinch-zoom (focal-point) + single-finger pan when zoomed ─────────────
const [zoomScale, setZoomScale] = useState(1); const [zoomScale, setZoomScale] = useState(1);
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
// Refs for gesture callbacks (closures don't capture fresh state mid-gesture) // Refs for gesture callbacks (closures don't capture fresh state mid-gesture)
const zoomScaleRef = useRef(1); const zoomScaleRef = useRef(1);
const panOffsetRef = useRef({ x: 0, y: 0 }); const panOffsetRef = useRef({ x: 0, y: 0 });
// Baseline value captured at gesture start to avoid jump on re-creation for pinch const pinchBaseScaleRef = useRef(1);
const zoomBaseRef = useRef(1); const pinchBasePanRef = useRef({ x: 0, y: 0 });
const pinchBaseFocalRef = useRef({ x: 0, y: 0 });
const panBaseRef = useRef({ x: 0, y: 0 });
// Ref to the latest containRect (the actual placed photo rect inside the viewport). // Ref to the latest containRect (the actual placed photo rect inside the viewport).
const containRectRef = useRef(null); const containRectRef = useRef(null);
useEffect(() => { zoomScaleRef.current = zoomScale; }, [zoomScale]); useEffect(() => { zoomScaleRef.current = zoomScale; }, [zoomScale]);
useEffect(() => { panOffsetRef.current = panOffset; }, [panOffset]); useEffect(() => { panOffsetRef.current = panOffset; }, [panOffset]);
const handleCanvasWrapLayout = useCallback((width, height) => {
if (width <= 0 || height <= 0) {
return;
}
canvasWRef.current = width;
canvasHRef.current = height;
setViewportSize(prev => {
if (prev?.w === width && prev?.h === height) {
return prev;
}
return { w: width, h: height };
});
}, []);
const resetZoom = useCallback(() => { const resetZoom = useCallback(() => {
setZoomScale(1); setZoomScale(1);
setPanOffset({ x: 0, y: 0 }); setPanOffset({ x: 0, y: 0 });
@ -708,7 +678,6 @@ const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
timeLog('▶ start segmentation'); timeLog('▶ start segmentation');
segmentsReadyRef.current = false; segmentsReadyRef.current = false;
setSegmentsReady(false); setSegmentsReady(false);
setSegError('');
setActiveBrushIndex(null); setActiveBrushIndex(null);
const clearedPainted = new Map(); const clearedPainted = new Map();
paintedRegionsRef.current = clearedPainted; paintedRegionsRef.current = clearedPainted;
@ -740,7 +709,6 @@ const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
setRegionOutlinePaths(new Map()); setRegionOutlinePaths(new Map());
setMaskPathsReady(false); setMaskPathsReady(false);
setPaintResourcesReady(false); setPaintResourcesReady(false);
setLayersLoading(false);
baseboardPickMaskRef.current = null; baseboardPickMaskRef.current = null;
kickRegionIdRef.current = null; kickRegionIdRef.current = null;
maskPathsContainRectRef.current = null; maskPathsContainRectRef.current = null;
@ -902,7 +870,6 @@ const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
if (!isCancelled()) { if (!isCancelled()) {
console.error('[SDK-SEGMENT] segmentation failed', e); console.error('[SDK-SEGMENT] segmentation failed', e);
setSegError(msg);
reportError(msg, e); reportError(msg, e);
} }
} }
@ -924,7 +891,6 @@ const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
const runId = segmentRunIdRef.current; const runId = segmentRunIdRef.current;
let promise; let promise;
promise = (async () => { promise = (async () => {
setLayersLoading(true);
timeLog('▶ start loading paint shader textures'); timeLog('▶ start loading paint shader textures');
try { try {
const result = await preparePaintResourcesFromWorkBuffer(work.buffer, work.cols, work.rows, layers => { const result = await preparePaintResourcesFromWorkBuffer(work.buffer, work.cols, work.rows, layers => {
@ -965,7 +931,6 @@ const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
} }
} }
finally { finally {
setLayersLoading(false);
if (paintLayersPromiseRef.current === promise) { if (paintLayersPromiseRef.current === promise) {
paintLayersPromiseRef.current = null; paintLayersPromiseRef.current = null;
} }
@ -975,29 +940,11 @@ const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
return promise; return promise;
}, [emitInteractiveIfReady]); }, [emitInteractiveIfReady]);
loadPaintLayersRef.current = loadPaintLayersIfNeeded; 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 () => { const clearCacheAndResegment = useCallback(async () => {
if (isRefreshing || !originImgPath || !maskImgPath) { if (resegmentInFlightRef.current || !originImgPath || !maskImgPath) {
return; return;
} }
setIsRefreshing(true); resegmentInFlightRef.current = true;
try { try {
resetZoom(); resetZoom();
const layers = paintResourceLayersRef.current; const layers = paintResourceLayersRef.current;
@ -1012,14 +959,12 @@ const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
} }
catch (e) { catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
setSegError(msg);
reportError(msg, e); reportError(msg, e);
} }
finally { finally {
setIsRefreshing(false); resegmentInFlightRef.current = false;
} }
}, [ }, [
isRefreshing,
originImgPath, originImgPath,
maskImgPath, maskImgPath,
segmentAndPrepareLayers, segmentAndPrepareLayers,
@ -1035,6 +980,7 @@ const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
segmentInFlightKeyRef.current === segmentKey) { segmentInFlightKeyRef.current === segmentKey) {
return; return;
} }
resetZoom();
segmentInFlightKeyRef.current = segmentKey; segmentInFlightKeyRef.current = segmentKey;
void segmentAndPrepareLayers(originImgPath, maskImgPath).finally(() => { void segmentAndPrepareLayers(originImgPath, maskImgPath).finally(() => {
if (segmentInFlightKeyRef.current === segmentKey) { if (segmentInFlightKeyRef.current === segmentKey) {
@ -1066,7 +1012,7 @@ const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
} }
setInitFlashRegionId(null); setInitFlashRegionId(null);
}; };
}, [originImgPath, maskImgPath]); }, [originImgPath, maskImgPath, resetZoom]);
const buildPaintedRecords = useCallback(() => { const buildPaintedRecords = useCallback(() => {
const records = []; const records = [];
// Prefer the live ref so getPaintedRegions() / session() used by host for // Prefer the live ref so getPaintedRegions() / session() used by host for
@ -1340,13 +1286,6 @@ const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
} }
return null; return null;
}, [segmentsReady, imageSize, canvasW, canvasH]); }, [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) => { const onCanvasTap = useCallback((x, y) => {
onUserInteraction(); onUserInteraction();
if (!segmentsReady || if (!segmentsReady ||
@ -1675,21 +1614,13 @@ const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
return null; 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}`)] })); 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 ───────────────────────────────── // ── Zoom matrix for the Skia Group (pan + scale around center) ──────────
// Note: single-finger pan/drag is disabled. Zoom is always centered around the const zoomMatrix = useMemo(() => {
// viewport center (no additional panOffset translation). The panOffset state if (zoomScale <= 1) {
// 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 undefined;
return [ }
{ translateX: 0, translateY: 0 }, return buildZoomPanMatrix(panOffset.x, panOffset.y, zoomScale, canvasW, canvasH);
{ translateX: canvasW / 2, translateY: canvasH / 2 }, }, [zoomScale, panOffset, canvasW, canvasH]);
{ scale: zoomScale },
{ translateX: -canvasW / 2, translateY: -canvasH / 2 },
];
}, [zoomScale, canvasW, canvasH]);
const renderDraw = () => { const renderDraw = () => {
const displayImg = originSkImg ?? lowFreqSkImg; const displayImg = originSkImg ?? lowFreqSkImg;
if (!displayImg || !containRect) { if (!displayImg || !containRect) {
@ -1708,7 +1639,7 @@ const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
// (shader leakage or edge sampling artifacts) from appearing outside the // (shader leakage or edge sampling artifacts) from appearing outside the
// photo rect when the content is scaled up. // photo rect when the content is scaled up.
const previewBg = '#F0F1F3'; 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 && 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, { matrix: zoomMatrix, 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 && initFlashRegionId != null &&
renderRegionMaskOverlay(initFlashRegionId, 'init-overlay'), showOverlay && renderRegionMaskOverlay(initFlashRegionId, 'init-overlay'), showOverlay &&
heldRegionId != null && heldRegionId != null &&
@ -1813,20 +1744,42 @@ const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
runOnJS(onFinalizeJS)(e.x, e.y); runOnJS(onFinalizeJS)(e.x, e.y);
}); });
}, []); }, []);
// ── Gesture: pinch-zoom (two-finger scale; max 5×) ───────────────────── // ── Gesture: pinch-zoom (focal-point scale + two-finger pan; max 5×) ────
const pinchGesture = useMemo(() => { const pinchGesture = useMemo(() => {
const onStartJS = () => { const onStartJS = (focalX, focalY) => {
zoomBaseRef.current = zoomScaleRef.current; pinchBaseScaleRef.current = zoomScaleRef.current;
pinchBasePanRef.current = { ...panOffsetRef.current };
pinchBaseFocalRef.current = { x: focalX, y: focalY };
}; };
const onUpdateJS = (scale) => { const onUpdateJS = (scale, focalX, focalY) => {
const newScale = Math.max(1, Math.min(zoomBaseRef.current * scale, 5)); const cw = canvasWRef.current;
const ch = canvasHRef.current;
if (cw <= 0 || ch <= 0) {
return;
}
const cx = cw / 2;
const cy = ch / 2;
const baseScale = pinchBaseScaleRef.current;
const basePan = pinchBasePanRef.current;
const baseFocal = pinchBaseFocalRef.current;
let newScale = Math.max(1, Math.min(baseScale * scale, 5));
const anchorX = (baseFocal.x - basePan.x - cx) / baseScale + cx;
const anchorY = (baseFocal.y - basePan.y - cy) / baseScale + cy;
let newPan = {
x: focalX - cx - newScale * (anchorX - cx),
y: focalY - cy - newScale * (anchorY - cy),
};
if (newScale <= 1) {
newScale = 1;
newPan = { x: 0, y: 0 };
}
else {
newPan = clampPanOffset(newPan, newScale, cw, ch, containRectRef.current);
}
setZoomScale(newScale); setZoomScale(newScale);
setPanOffset(newPan);
zoomScaleRef.current = newScale; zoomScaleRef.current = newScale;
// Lock pan offset to zero: only two-finger pinch zoom is supported. panOffsetRef.current = newPan;
// 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 = () => { const onEndJS = () => {
if (zoomScaleRef.current <= 1.01) { if (zoomScaleRef.current <= 1.01) {
@ -1834,63 +1787,63 @@ const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
} }
}; };
return Gesture.Pinch() return Gesture.Pinch()
.onStart(() => { .onStart((e) => {
'worklet'; 'worklet';
runOnJS(onStartJS)(); runOnJS(onStartJS)(e.focalX, e.focalY);
}) })
.onUpdate((e) => { .onUpdate((e) => {
'worklet'; 'worklet';
runOnJS(onUpdateJS)(e.scale); runOnJS(onUpdateJS)(e.scale, e.focalX, e.focalY);
}) })
.onEnd(() => { .onEnd(() => {
'worklet'; 'worklet';
runOnJS(onEndJS)(); runOnJS(onEndJS)();
}); });
}, []); }, []);
// ── Composed: Race ensures single-tap and pinch-zoom never conflict ───────── // ── Gesture: single-finger pan (active only when zoomed) ───────────────
// 依赖置空 + 内部的 tap/pinch 也用 [],保证整个手势描述符树只在 mount 时创建一次。 const panGesture = useMemo(() => {
// 避免初始化阶段反复创建导致 Reanimated / RNGH 内部节点重复分配。 const onStartJS = () => {
const composedGesture = useMemo(() => Gesture.Race(tapGesture, pinchGesture), []); panBaseRef.current = { ...panOffsetRef.current };
return (_jsxs(View, { style: [styles.container, style], onLayout: (e) => { };
const onUpdateJS = (translationX, translationY) => {
if (zoomScaleRef.current <= 1) {
return;
}
const newPan = clampPanOffset({
x: panBaseRef.current.x + translationX,
y: panBaseRef.current.y + translationY,
}, zoomScaleRef.current, canvasWRef.current, canvasHRef.current, containRectRef.current);
setPanOffset(newPan);
panOffsetRef.current = newPan;
};
const onEndJS = () => {
if (zoomScaleRef.current <= 1.01) {
resetZoomRef.current?.();
}
};
return Gesture.Pan()
.minPointers(1)
.maxPointers(1)
.minDistance(10)
.onStart(() => {
'worklet';
runOnJS(onStartJS)();
})
.onUpdate((e) => {
'worklet';
runOnJS(onUpdateJS)(e.translationX, e.translationY);
})
.onEnd(() => {
'worklet';
runOnJS(onEndJS)();
});
}, []);
// ── Composed: pinch + pan simultaneous; tap only when neither claims ───
const composedGesture = useMemo(() => Gesture.Exclusive(Gesture.Simultaneous(pinchGesture, panGesture), tapGesture), []);
return (_jsxs(View, { style: [styles.container, style], children: [_jsx(View, { style: styles.canvasWrap, onLayout: (e) => {
const { width, height } = e.nativeEvent.layout; const { width, height } = e.nativeEvent.layout;
setLayoutWidth(width); handleCanvasWrapLayout(width, height);
setLayoutHeight(height); }, children: canvasLayoutReady ? (_jsx(GestureDetector, { gesture: composedGesture, children: _jsx(View, { style: { width: canvasW, height: canvasH }, children: _jsx(Canvas, { style: { width: canvasW, height: canvasH }, pointerEvents: "none", children: renderDraw() }) }) })) : null }), highResSnapshotEnabled && exportCanvasSize ? (_jsx(View, { style: {
}, 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', position: 'absolute',
left: -exportCanvasSize.w - 200, left: -exportCanvasSize.w - 200,
top: 0, top: 0,
@ -1905,123 +1858,16 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
backgroundColor: '#fff', 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: { canvasWrap: {
flex: 1,
width: '100%',
alignSelf: 'stretch',
position: 'relative', position: 'relative',
backgroundColor: '#f5f5f5', backgroundColor: '#f5f5f5',
borderWidth: StyleSheet.hairlineWidth, borderWidth: StyleSheet.hairlineWidth,
borderColor: '#ddd', borderColor: '#ddd',
overflow: 'hidden', 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; export default MaskSegmentCanvas;
//# sourceMappingURL=MaskSegmentCanvas.js.map //# sourceMappingURL=MaskSegmentCanvas.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,4 @@
import type { ReactNode } from 'react'; import type { StyleProp, ViewStyle } from 'react-native';
import type { StyleProp, TextStyle, ViewStyle } from 'react-native';
import type { SegmentRegion } from '../utils/maskSegmentation'; import type { SegmentRegion } from '../utils/maskSegmentation';
import type { MaskSemanticColor } from '../utils/maskSemanticPalette'; import type { MaskSemanticColor } from '../utils/maskSemanticPalette';
export type BgrColor = { export type BgrColor = {
@ -118,11 +117,6 @@ export type PaintBrushRequiredPayload = {
regionName: string; regionName: string;
}; };
export type PaintCallbackPayload = PaintSuccessPayload | PaintBrushRequiredPayload; export type PaintCallbackPayload = PaintSuccessPayload | PaintBrushRequiredPayload;
export type OverlayButtonRenderProps = {
onPress: () => void;
disabled?: boolean;
text: string;
};
export type MaskSegmentCanvasRef = { export type MaskSegmentCanvasRef = {
reset: () => void; reset: () => void;
swap: (showOrigin?: boolean) => void; swap: (showOrigin?: boolean) => void;
@ -160,30 +154,14 @@ export type MaskSegmentCanvasProps = {
initialSession?: MaskSegmentSession; initialSession?: MaskSegmentSession;
initialPaintColor?: BgrColor; initialPaintColor?: BgrColor;
initialPaintConfigJson?: Record<string, unknown>; initialPaintConfigJson?: Record<string, unknown>;
showDebugPickers?: boolean;
showToolbar?: boolean;
showColorBar?: boolean;
showStatusRow?: boolean;
showOverlayButtons?: boolean;
disabled?: boolean; disabled?: boolean;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
canvasStyle?: StyleProp<ViewStyle>;
/** /**
* Max container height available for this component (px). When set, the SDK * Max container height available for this component (px). When set, the SDK
* computes canvas dimensions as a fit-contain within (screenWidth - 20, maxHeight) * computes canvas dimensions as a fit-contain within (screenWidth - 20, maxHeight)
* instead of using the full image aspect at full screen width. This prevents * instead of using the full image aspect at full screen width.
* internal ScrollView scrolling for tall images.
*/ */
maxHeight?: number; maxHeight?: number;
undoButtonStyle?: StyleProp<ViewStyle>;
compareButtonStyle?: StyleProp<ViewStyle>;
undoButtonTextStyle?: StyleProp<TextStyle>;
compareButtonTextStyle?: StyleProp<TextStyle>;
undoButtonText?: string;
compareButtonText?: string;
compareExitButtonText?: string;
renderUndoButton?: (props: OverlayButtonRenderProps) => ReactNode;
renderCompareButton?: (props: OverlayButtonRenderProps) => ReactNode;
onWatch?: (state: MaskSegmentWatchState, durationMs: number, detail?: MaskSegmentWatchDetail) => void; onWatch?: (state: MaskSegmentWatchState, durationMs: number, detail?: MaskSegmentWatchDetail) => void;
onPaintCallback?: (payload: PaintCallbackPayload) => void; onPaintCallback?: (payload: PaintCallbackPayload) => void;
onError?: (message: string, error?: unknown) => void; onError?: (message: string, error?: unknown) => void;

File diff suppressed because one or more lines are too long

2
dist/index.d.ts vendored
View File

@ -1,5 +1,5 @@
export { default } from './components/MaskSegmentCanvas'; export { default } from './components/MaskSegmentCanvas';
export type { BgrColor, InteractionConfig, MaskSegmentCanvasProps, MaskSegmentCanvasRef, MaskSegmentConfig, MaskSegmentSession, MaskSegmentWatchDetail, MaskSegmentWatchState, MaskSemanticColor, OverlayButtonRenderProps, PaintBrushRequiredPayload, PaintCallbackPayload, PaintSuccessPayload, PaintConfig, PaintedRegionRecord, PipelineConfig, PipelinePreset, SavePaintOptions, SavePaintResult, SegmentRegion, } from './components/MaskSegmentCanvas.types'; export type { BgrColor, InteractionConfig, MaskSegmentCanvasProps, MaskSegmentCanvasRef, MaskSegmentConfig, MaskSegmentSession, MaskSegmentWatchDetail, MaskSegmentWatchState, MaskSemanticColor, PaintBrushRequiredPayload, PaintCallbackPayload, PaintSuccessPayload, PaintConfig, PaintedRegionRecord, PipelineConfig, PipelinePreset, SavePaintOptions, SavePaintResult, SegmentRegion, } from './components/MaskSegmentCanvas.types';
export { BASEBOARD_SEMANTIC_NAME, MASK_SEMANTIC_COLORS, } from './utils/maskSemanticPalette'; export { BASEBOARD_SEMANTIC_NAME, MASK_SEMANTIC_COLORS, } from './utils/maskSemanticPalette';
export { createRuntimeConfig, DEFAULT_INTERACTION_CONFIG, DEFAULT_MASK_CONFIG, DEFAULT_PAINT_CONFIG, DEFAULT_PIPELINE_CONFIG, PIPELINE_HIGH, PIPELINE_LOW, PIPELINE_MEDIUM, PIPELINE_PRESETS, getMaskSegmentRuntimeConfig, resolvePipelineConfig, setMaskSegmentRuntimeConfig, } from './utils/maskSegmentRuntime'; export { createRuntimeConfig, DEFAULT_INTERACTION_CONFIG, DEFAULT_MASK_CONFIG, DEFAULT_PAINT_CONFIG, DEFAULT_PIPELINE_CONFIG, PIPELINE_HIGH, PIPELINE_LOW, PIPELINE_MEDIUM, PIPELINE_PRESETS, getMaskSegmentRuntimeConfig, resolvePipelineConfig, setMaskSegmentRuntimeConfig, } from './utils/maskSegmentRuntime';
export { prewarmPngBgrCache, prewarmPngBgrCacheAsync, } from './utils/pngImage'; export { prewarmPngBgrCache, prewarmPngBgrCacheAsync, } from './utils/pngImage';

2
dist/index.d.ts.map vendored
View File

@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AACzD,YAAY,EACV,QAAQ,EACR,iBAAiB,EACjB,sBAAsB,EACtB,oBAAoB,EACpB,iBAAiB,EACjB,kBAAkB,EAClB,sBAAsB,EACtB,qBAAqB,EACrB,iBAAiB,EACjB,wBAAwB,EACxB,yBAAyB,EACzB,oBAAoB,EACpB,mBAAmB,EACnB,WAAW,EACX,mBAAmB,EACnB,cAAc,EACd,cAAc,EACd,gBAAgB,EAChB,eAAe,EACf,aAAa,GACd,MAAM,sCAAsC,CAAC;AAC9C,OAAO,EACL,uBAAuB,EACvB,oBAAoB,GACrB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EACL,mBAAmB,EACnB,0BAA0B,EAC1B,mBAAmB,EACnB,oBAAoB,EACpB,uBAAuB,EACvB,aAAa,EACb,YAAY,EACZ,eAAe,EACf,gBAAgB,EAChB,2BAA2B,EAC3B,qBAAqB,EACrB,2BAA2B,GAC5B,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,kBAAkB,EAClB,uBAAuB,GACxB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC"} {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AACzD,YAAY,EACV,QAAQ,EACR,iBAAiB,EACjB,sBAAsB,EACtB,oBAAoB,EACpB,iBAAiB,EACjB,kBAAkB,EAClB,sBAAsB,EACtB,qBAAqB,EACrB,iBAAiB,EACjB,yBAAyB,EACzB,oBAAoB,EACpB,mBAAmB,EACnB,WAAW,EACX,mBAAmB,EACnB,cAAc,EACd,cAAc,EACd,gBAAgB,EAChB,eAAe,EACf,aAAa,GACd,MAAM,sCAAsC,CAAC;AAC9C,OAAO,EACL,uBAAuB,EACvB,oBAAoB,GACrB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EACL,mBAAmB,EACnB,0BAA0B,EAC1B,mBAAmB,EACnB,oBAAoB,EACpB,uBAAuB,EACvB,aAAa,EACb,YAAY,EACZ,eAAe,EACf,gBAAgB,EAChB,2BAA2B,EAC3B,qBAAqB,EACrB,2BAA2B,GAC5B,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,kBAAkB,EAClB,uBAAuB,GACxB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC"}

2
dist/index.js.map vendored
View File

@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAuBzD,OAAO,EACL,uBAAuB,EACvB,oBAAoB,GACrB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EACL,mBAAmB,EACnB,0BAA0B,EAC1B,mBAAmB,EACnB,oBAAoB,EACpB,uBAAuB,EACvB,aAAa,EACb,YAAY,EACZ,eAAe,EACf,gBAAgB,EAChB,2BAA2B,EAC3B,qBAAqB,EACrB,2BAA2B,GAC5B,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,kBAAkB,EAClB,uBAAuB,GACxB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC"} {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAsBzD,OAAO,EACL,uBAAuB,EACvB,oBAAoB,GACrB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EACL,mBAAmB,EACnB,0BAA0B,EAC1B,mBAAmB,EACnB,oBAAoB,EACpB,uBAAuB,EACvB,aAAa,EACb,YAAY,EACZ,eAAe,EACf,gBAAgB,EAChB,2BAA2B,EAC3B,qBAAqB,EACrB,2BAA2B,GAC5B,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,kBAAkB,EAClB,uBAAuB,GACxB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC"}

View File

@ -1,5 +1,5 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { Skia, Fill, Shader, ImageShader, Group, drawAsImage, } from '@shopify/react-native-skia'; import { Skia, Shader, ImageShader, Group, Rect, drawAsImage, } from '@shopify/react-native-skia';
import { REGION_PAINT_SKSL } from '../shaders/regionPaint.sksl'; import { REGION_PAINT_SKSL } from '../shaders/regionPaint.sksl';
import { getMaskSegmentRuntimeConfig } from './maskSegmentRuntime'; import { getMaskSegmentRuntimeConfig } from './maskSegmentRuntime';
import { buildPaintColorMapImage } from './paintColorMapTexture'; import { buildPaintColorMapImage } from './paintColorMapTexture';
@ -34,7 +34,7 @@ function createPaintShaderTree(props) {
ty: 'clamp', ty: 'clamp',
rect: { x, y, width, height }, rect: { x, y, width, height },
}; };
return (_jsx(Fill, { children: _jsxs(Shader, { source: effect, uniforms: uniforms, children: [_jsx(ImageShader, { image: originImage, ...imageShaderProps }), _jsx(ImageShader, { image: paintColorMap, ...imageShaderProps }), _jsx(ImageShader, { image: lowFreqImage, ...imageShaderProps }), _jsx(ImageShader, { image: highFreqImage, ...imageShaderProps })] }) })); return (_jsx(Rect, { x: x, y: y, width: width, height: height, children: _jsxs(Shader, { source: effect, uniforms: uniforms, children: [_jsx(ImageShader, { image: originImage, ...imageShaderProps }), _jsx(ImageShader, { image: paintColorMap, ...imageShaderProps }), _jsx(ImageShader, { image: lowFreqImage, ...imageShaderProps }), _jsx(ImageShader, { image: highFreqImage, ...imageShaderProps })] }) }));
} }
/** Canvas 内全屏上色 Shader 层 */ /** Canvas 内全屏上色 Shader 层 */
export function PaintShaderLayer(props) { export function PaintShaderLayer(props) {

View File

@ -1 +1 @@
{"version":3,"file":"paintShaderRuntime.js","sourceRoot":"","sources":["../../src/utils/paintShaderRuntime.tsx"],"names":[],"mappings":"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"} {"version":3,"file":"paintShaderRuntime.js","sourceRoot":"","sources":["../../src/utils/paintShaderRuntime.tsx"],"names":[],"mappings":"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"}

View File

@ -409,16 +409,8 @@ function App(): React.JSX.Element {
initRegionFlashMs: 1000, initRegionFlashMs: 1000,
enableInitRegionFlash: true, enableInitRegionFlash: true,
}} }}
showDebugPickers={false}
showToolbar={false}
showColorBar
showStatusRow={false}
showOverlayButtons
disabled={!isInteractive} disabled={!isInteractive}
initialSession={sessionDraft ?? undefined} initialSession={sessionDraft ?? undefined}
undoButtonText="撤销"
compareButtonText="对比原图"
compareExitButtonText="退出对比"
onWatch={handleWatch} onWatch={handleWatch}
onPaintCallback={handlePaintCallback} onPaintCallback={handlePaintCallback}
onError={handleError} onError={handleError}
@ -611,6 +603,7 @@ const styles = StyleSheet.create({
canvasHost: { canvasHost: {
flex: 1, flex: 1,
position: 'relative', position: 'relative',
height: 280,
}, },
canvas: { canvas: {
flex: 1, flex: 1,

View File

@ -10,16 +10,9 @@ import React, {
import { import {
View, View,
StyleSheet, StyleSheet,
Button,
Dimensions,
Text,
TouchableOpacity,
ScrollView,
type GestureResponderEvent,
} from 'react-native'; } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { runOnJS } from 'react-native-reanimated'; import { runOnJS } from 'react-native-reanimated';
import { launchImageLibrary } from 'react-native-image-picker';
import cv from '../utils/opencvAdapter'; import cv from '../utils/opencvAdapter';
import { import {
buildAllRegionOutlinePaths, buildAllRegionOutlinePaths,
@ -92,11 +85,6 @@ export type {
MaskSemanticColor, MaskSemanticColor,
} from './MaskSegmentCanvas.types'; } from './MaskSegmentCanvas.types';
/* ==========================================================================
* maskSegmentRuntime
* ========================================================================== */
const { width: SCREEN_WIDTH } = Dimensions.get('window');
/* ========================================================================== /* ==========================================================================
* *
* ========================================================================== */ * ========================================================================== */
@ -166,6 +154,63 @@ function canvasToNormalized(
}; };
} }
/** Skia matrix: pan → scale around viewport center. Matches screenToCanvasCoords. */
function buildZoomPanMatrix(
panX: number,
panY: number,
scale: number,
canvasW: number,
canvasH: number,
) {
const cx = canvasW / 2;
const cy = canvasH / 2;
const m = Skia.Matrix();
m.translate(panX, panY);
m.translate(cx, cy);
m.scale(scale, scale);
m.translate(-cx, -cy);
return m;
}
/** Clamp pan so scaled containRect does not expose empty margins beyond the viewport. */
function clampPanOffset(
pan: { x: number; y: number },
scale: number,
canvasW: number,
canvasH: number,
containRect: ContainRect | null,
): { x: number; y: number } {
if (!containRect || scale <= 1 || canvasW <= 0 || canvasH <= 0) {
return { x: 0, y: 0 };
}
const cx = canvasW / 2;
const cy = canvasH / 2;
const r = containRect;
const scaledMinX = cx + scale * (r.x - cx);
const scaledMaxX = cx + scale * (r.x + r.w - cx);
const scaledMinY = cy + scale * (r.y - cy);
const scaledMaxY = cy + scale * (r.y + r.h - cy);
let x = pan.x;
let y = pan.y;
if (scaledMaxX - scaledMinX > canvasW) {
x = Math.max(canvasW - scaledMaxX, Math.min(-scaledMinX, x));
} else {
x = 0;
}
if (scaledMaxY - scaledMinY > canvasH) {
y = Math.max(canvasH - scaledMaxY, Math.min(-scaledMinY, y));
} else {
y = 0;
}
return { x, y };
}
/** /**
* Inverse of the Skia Group transform applied during pinch-zoom. * Inverse of the Skia Group transform applied during pinch-zoom.
* Converts a raw touch point (screen pixels) back to the canvas coordinate * Converts a raw touch point (screen pixels) back to the canvas coordinate
@ -181,7 +226,6 @@ function screenToCanvasCoords(
panOffset: { x: number; y: number }, panOffset: { x: number; y: number },
): { x: number; y: number } { ): { x: number; y: number } {
if (zoomScale <= 1) return { x: screenX, y: screenY }; if (zoomScale <= 1) return { x: screenX, y: screenY };
// Reverse: translate(-pan) → unscale around center → translate(+center)
return { return {
x: (screenX - panOffset.x - canvasW / 2) / zoomScale + canvasW / 2, x: (screenX - panOffset.x - canvasW / 2) / zoomScale + canvasW / 2,
y: (screenY - panOffset.y - canvasH / 2) / zoomScale + canvasH / 2, y: (screenY - panOffset.y - canvasH / 2) / zoomScale + canvasH / 2,
@ -447,11 +491,6 @@ function lookupRegionFromPickMap(
return null; return null;
} }
/** BGR → 屏幕 RGB */
function bgrToCss(b: number, g: number, r: number): string {
return `rgb(${r},${g},${b})`;
}
function releasePaintResourceLayers(layers: PaintResourceLayers | null) { function releasePaintResourceLayers(layers: PaintResourceLayers | null) {
if (!layers) { if (!layers) {
return; return;
@ -519,24 +558,8 @@ const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProp
initialSession, initialSession,
initialPaintColor, initialPaintColor,
initialPaintConfigJson, initialPaintConfigJson,
showDebugPickers = true,
showToolbar = true,
showColorBar = true,
showStatusRow = true,
showOverlayButtons = true,
disabled = false, disabled = false,
style, style,
canvasStyle,
maxHeight,
undoButtonStyle,
compareButtonStyle,
undoButtonTextStyle,
compareButtonTextStyle,
undoButtonText = '撤销',
compareButtonText = '对比原图',
compareExitButtonText = '退出对比',
renderUndoButton,
renderCompareButton,
onWatch, onWatch,
onPaintCallback, onPaintCallback,
onError, onError,
@ -816,7 +839,6 @@ const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProp
const paintLayersPromiseRef = useRef<Promise<void> | null>(null); const paintLayersPromiseRef = useRef<Promise<void> | null>(null);
const loadPaintLayersRef = useRef<() => Promise<void>>(() => Promise.resolve()); const loadPaintLayersRef = useRef<() => Promise<void>>(() => Promise.resolve());
const [paintResourcesReady, setPaintResourcesReady] = useState(false); const [paintResourcesReady, setPaintResourcesReady] = useState(false);
const [layersLoading, setLayersLoading] = useState(false);
const [maskPathsReady, setMaskPathsReady] = useState(false); const [maskPathsReady, setMaskPathsReady] = useState(false);
const baseboardPickMaskRef = useRef<Uint8Array | null>(null); const baseboardPickMaskRef = useRef<Uint8Array | null>(null);
const kickRegionIdRef = useRef<number | null>(null); const kickRegionIdRef = useRef<number | null>(null);
@ -839,115 +861,62 @@ const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProp
// idle segmentation / no-paint cases cheap. // idle segmentation / no-paint cases cheap.
const [highResSnapshotEnabled, setHighResSnapshotEnabled] = useState(false); const [highResSnapshotEnabled, setHighResSnapshotEnabled] = useState(false);
// Layout measurement for the root container of this component. Declared early so // Viewport measured from canvasWrap onLayout — single source of truth for Skia,
// the canvasW/canvasH memos (which decide the viewport rect for zoom centering, // gestures, containRect, and pan clamp (must match the actual touch target).
// containRect placement, clipping, and gesture coordinate mapping) can close over it. const [viewportSize, setViewportSize] = useState<{ w: number; h: number } | null>(null);
// 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<number | null>(null);
const [layoutHeight, setLayoutHeight] = useState<number | null>(null);
const [segmentsReady, setSegmentsReady] = useState(false); const [segmentsReady, setSegmentsReady] = useState(false);
const segmentsReadyRef = useRef(false); const segmentsReadyRef = useRef(false);
const maskPathsReadyRef = useRef(false); const maskPathsReadyRef = useRef(false);
const [canvasInteractive, setCanvasInteractive] = useState(false); const [canvasInteractive, setCanvasInteractive] = useState(false);
const [segError, setSegError] = useState('');
const [compareMode, setCompareMode] = useState(false); const [compareMode, setCompareMode] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false); const resegmentInFlightRef = useRef(false);
const [originSkImg, setOriginSkImg] = useState<SkImage | null>(null); const [originSkImg, setOriginSkImg] = useState<SkImage | null>(null);
const originSkImgRef = useRef<SkImage | null>(null); const originSkImgRef = useRef<SkImage | null>(null);
const lowFreqSkImg = paintResourceLayers?.lowFreqImage ?? null; const lowFreqSkImg = paintResourceLayers?.lowFreqImage ?? null;
const highFreqSkImg = paintResourceLayers?.highFreqImage ?? null; const highFreqSkImg = paintResourceLayers?.highFreqImage ?? null;
const canvasBaseW = SCREEN_WIDTH - 20; const canvasW = viewportSize?.w ?? 1;
const canvasH = viewportSize?.h ?? 1;
const canvasLayoutReady =
viewportSize != null && viewportSize.w > 0 && viewportSize.h > 0;
// The "viewport" size for this canvas component: the rect inside which we place // Refs synced to the latest viewport size so async callbacks read post-layout values.
// 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 canvasWRef = useRef(canvasW);
const canvasHRef = useRef(canvasH); const canvasHRef = useRef(canvasH);
// ── Pinch-zoom (two-finger only; single-finger drag/pan disabled) ───────── // ── Pinch-zoom (focal-point) + single-finger pan when zoomed ─────────────
const [zoomScale, setZoomScale] = useState(1); const [zoomScale, setZoomScale] = useState(1);
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
// Refs for gesture callbacks (closures don't capture fresh state mid-gesture) // Refs for gesture callbacks (closures don't capture fresh state mid-gesture)
const zoomScaleRef = useRef(1); const zoomScaleRef = useRef(1);
const panOffsetRef = useRef({ x: 0, y: 0 }); const panOffsetRef = useRef({ x: 0, y: 0 });
// Baseline value captured at gesture start to avoid jump on re-creation for pinch const pinchBaseScaleRef = useRef(1);
const zoomBaseRef = useRef(1); const pinchBasePanRef = useRef({ x: 0, y: 0 });
const pinchBaseFocalRef = useRef({ x: 0, y: 0 });
const panBaseRef = useRef({ x: 0, y: 0 });
// Ref to the latest containRect (the actual placed photo rect inside the viewport). // Ref to the latest containRect (the actual placed photo rect inside the viewport).
const containRectRef = useRef<ContainRect | null>(null); const containRectRef = useRef<ContainRect | null>(null);
useEffect(() => { zoomScaleRef.current = zoomScale; }, [zoomScale]); useEffect(() => { zoomScaleRef.current = zoomScale; }, [zoomScale]);
useEffect(() => { panOffsetRef.current = panOffset; }, [panOffset]); useEffect(() => { panOffsetRef.current = panOffset; }, [panOffset]);
const handleCanvasWrapLayout = useCallback((width: number, height: number) => {
if (width <= 0 || height <= 0) {
return;
}
canvasWRef.current = width;
canvasHRef.current = height;
setViewportSize(prev => {
if (prev?.w === width && prev?.h === height) {
return prev;
}
return { w: width, h: height };
});
}, []);
const resetZoom = useCallback(() => { const resetZoom = useCallback(() => {
setZoomScale(1); setZoomScale(1);
setPanOffset({ x: 0, y: 0 }); setPanOffset({ x: 0, y: 0 });
@ -1067,7 +1036,6 @@ const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProp
timeLog('▶ start segmentation'); timeLog('▶ start segmentation');
segmentsReadyRef.current = false; segmentsReadyRef.current = false;
setSegmentsReady(false); setSegmentsReady(false);
setSegError('');
setActiveBrushIndex(null); setActiveBrushIndex(null);
const clearedPainted = new Map<number, BgrColor>(); const clearedPainted = new Map<number, BgrColor>();
paintedRegionsRef.current = clearedPainted; paintedRegionsRef.current = clearedPainted;
@ -1099,7 +1067,6 @@ const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProp
setRegionOutlinePaths(new Map()); setRegionOutlinePaths(new Map());
setMaskPathsReady(false); setMaskPathsReady(false);
setPaintResourcesReady(false); setPaintResourcesReady(false);
setLayersLoading(false);
baseboardPickMaskRef.current = null; baseboardPickMaskRef.current = null;
kickRegionIdRef.current = null; kickRegionIdRef.current = null;
maskPathsContainRectRef.current = null; maskPathsContainRectRef.current = null;
@ -1315,7 +1282,6 @@ const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProp
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
if (!isCancelled()) { if (!isCancelled()) {
console.error('[SDK-SEGMENT] segmentation failed', e); console.error('[SDK-SEGMENT] segmentation failed', e);
setSegError(msg);
reportError(msg, e); reportError(msg, e);
} }
} }
@ -1342,7 +1308,6 @@ const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProp
const runId = segmentRunIdRef.current; const runId = segmentRunIdRef.current;
let promise!: Promise<void>; let promise!: Promise<void>;
promise = (async () => { promise = (async () => {
setLayersLoading(true);
timeLog('▶ start loading paint shader textures'); timeLog('▶ start loading paint shader textures');
try { try {
const result = await preparePaintResourcesFromWorkBuffer( const result = await preparePaintResourcesFromWorkBuffer(
@ -1386,7 +1351,6 @@ const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProp
console.warn('[MaskSegment] failed to prepare paint shader textures', error); console.warn('[MaskSegment] failed to prepare paint shader textures', error);
} }
} finally { } finally {
setLayersLoading(false);
if (paintLayersPromiseRef.current === promise) { if (paintLayersPromiseRef.current === promise) {
paintLayersPromiseRef.current = null; paintLayersPromiseRef.current = null;
} }
@ -1398,32 +1362,12 @@ const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProp
loadPaintLayersRef.current = loadPaintLayersIfNeeded; 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 () => { const clearCacheAndResegment = useCallback(async () => {
if (isRefreshing || !originImgPath || !maskImgPath) { if (resegmentInFlightRef.current || !originImgPath || !maskImgPath) {
return; return;
} }
setIsRefreshing(true); resegmentInFlightRef.current = true;
try { try {
resetZoom(); resetZoom();
const layers = paintResourceLayersRef.current; const layers = paintResourceLayersRef.current;
@ -1438,13 +1382,11 @@ const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProp
await segmentAndPrepareLayers(originImgPath, maskImgPath); await segmentAndPrepareLayers(originImgPath, maskImgPath);
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
setSegError(msg);
reportError(msg, e); reportError(msg, e);
} finally { } finally {
setIsRefreshing(false); resegmentInFlightRef.current = false;
} }
}, [ }, [
isRefreshing,
originImgPath, originImgPath,
maskImgPath, maskImgPath,
segmentAndPrepareLayers, segmentAndPrepareLayers,
@ -1465,6 +1407,7 @@ const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProp
return; return;
} }
resetZoom();
segmentInFlightKeyRef.current = segmentKey; segmentInFlightKeyRef.current = segmentKey;
void segmentAndPrepareLayers(originImgPath, maskImgPath).finally(() => { void segmentAndPrepareLayers(originImgPath, maskImgPath).finally(() => {
if (segmentInFlightKeyRef.current === segmentKey) { if (segmentInFlightKeyRef.current === segmentKey) {
@ -1497,7 +1440,7 @@ const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProp
} }
setInitFlashRegionId(null); setInitFlashRegionId(null);
}; };
}, [originImgPath, maskImgPath]); }, [originImgPath, maskImgPath, resetZoom]);
const buildPaintedRecords = useCallback((): PaintedRegionRecord[] => { const buildPaintedRecords = useCallback((): PaintedRegionRecord[] => {
const records: PaintedRegionRecord[] = []; const records: PaintedRegionRecord[] = [];
@ -1837,17 +1780,6 @@ const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProp
[segmentsReady, imageSize, canvasW, canvasH], [segmentsReady, imageSize, canvasW, canvasH],
); );
const selectBrushColor = useCallback(
(brushIndex: number) => {
onUserInteraction();
setCustomPaintColor(null);
customPaintConfigJsonRef.current = undefined;
setActiveBrushIndex(brushIndex);
void loadPaintLayersIfNeeded();
},
[onUserInteraction, loadPaintLayersIfNeeded],
);
const onCanvasTap = useCallback( const onCanvasTap = useCallback(
(x: number, y: number) => { (x: number, y: number) => {
onUserInteraction(); onUserInteraction();
@ -2244,20 +2176,19 @@ const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProp
); );
}; };
// ── Zoom transform for the Skia Group ───────────────────────────────── // ── Zoom matrix for the Skia Group (pan + scale around center) ──────────
// Note: single-finger pan/drag is disabled. Zoom is always centered around the const zoomMatrix = useMemo(() => {
// viewport center (no additional panOffset translation). The panOffset state if (zoomScale <= 1) {
// is kept (and forced to 0 during pinch) for compatibility with screenToCanvasCoords return undefined;
// inverse mapping used by tap/paint logic, and for resetZoom. }
const zoomTransform = useMemo(() => { return buildZoomPanMatrix(
if (zoomScale <= 1) return undefined; panOffset.x,
return [ panOffset.y,
{ translateX: 0, translateY: 0 }, // panning disabled; content stays centered when zoomed zoomScale,
{ translateX: canvasW / 2, translateY: canvasH / 2 }, canvasW,
{ scale: zoomScale }, canvasH,
{ translateX: -canvasW / 2, translateY: -canvasH / 2 }, );
]; }, [zoomScale, panOffset, canvasW, canvasH]);
}, [zoomScale, canvasW, canvasH]);
const renderDraw = () => { const renderDraw = () => {
const displayImg = originSkImg ?? lowFreqSkImg; const displayImg = originSkImg ?? lowFreqSkImg;
@ -2293,7 +2224,7 @@ const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProp
The clip keeps drawing contained within the logical viewport and The clip keeps drawing contained within the logical viewport and
prevents shader content from leaking outside during zoom. */} prevents shader content from leaking outside during zoom. */}
<Group clip={Skia.XYWHRect(0, 0, canvasW, canvasH)}> <Group clip={Skia.XYWHRect(0, 0, canvasW, canvasH)}>
<Group transform={zoomTransform}> <Group matrix={zoomMatrix}>
{useShader && shaderOrigin ? ( {useShader && shaderOrigin ? (
<PaintShaderLayer <PaintShaderLayer
originImage={shaderOrigin} originImage={shaderOrigin}
@ -2465,21 +2396,54 @@ const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProp
[], // 仅创建一次手势对象;所有回调都通过 Ref 读取最新值,避免初始化期间重复创建导致 Reanimated 节点冲突 [], // 仅创建一次手势对象;所有回调都通过 Ref 读取最新值,避免初始化期间重复创建导致 Reanimated 节点冲突
); );
// ── Gesture: pinch-zoom (two-finger scale; max 5×) ───────────────────── // ── Gesture: pinch-zoom (focal-point scale + two-finger pan; max 5×) ────
const pinchGesture = useMemo( const pinchGesture = useMemo(
() => { () => {
const onStartJS = () => { const onStartJS = (focalX: number, focalY: number) => {
zoomBaseRef.current = zoomScaleRef.current; pinchBaseScaleRef.current = zoomScaleRef.current;
pinchBasePanRef.current = { ...panOffsetRef.current };
pinchBaseFocalRef.current = { x: focalX, y: focalY };
}; };
const onUpdateJS = (scale: number) => { const onUpdateJS = (scale: number, focalX: number, focalY: number) => {
const newScale = Math.max(1, Math.min(zoomBaseRef.current * scale, 5)); const cw = canvasWRef.current;
const ch = canvasHRef.current;
if (cw <= 0 || ch <= 0) {
return;
}
const cx = cw / 2;
const cy = ch / 2;
const baseScale = pinchBaseScaleRef.current;
const basePan = pinchBasePanRef.current;
const baseFocal = pinchBaseFocalRef.current;
let newScale = Math.max(1, Math.min(baseScale * scale, 5));
const anchorX = (baseFocal.x - basePan.x - cx) / baseScale + cx;
const anchorY = (baseFocal.y - basePan.y - cy) / baseScale + cy;
let newPan = {
x: focalX - cx - newScale * (anchorX - cx),
y: focalY - cy - newScale * (anchorY - cy),
};
if (newScale <= 1) {
newScale = 1;
newPan = { x: 0, y: 0 };
} else {
newPan = clampPanOffset(
newPan,
newScale,
cw,
ch,
containRectRef.current,
);
}
setZoomScale(newScale); setZoomScale(newScale);
setPanOffset(newPan);
zoomScaleRef.current = newScale; zoomScaleRef.current = newScale;
// Lock pan offset to zero: only two-finger pinch zoom is supported. panOffsetRef.current = newPan;
// 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 = () => { const onEndJS = () => {
if (zoomScaleRef.current <= 1.01) { if (zoomScaleRef.current <= 1.01) {
@ -2488,211 +2452,103 @@ const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProp
}; };
return Gesture.Pinch() return Gesture.Pinch()
.onStart(() => { .onStart((e) => {
'worklet'; 'worklet';
runOnJS(onStartJS)(); runOnJS(onStartJS)(e.focalX, e.focalY);
}) })
.onUpdate((e) => { .onUpdate((e) => {
'worklet'; 'worklet';
runOnJS(onUpdateJS)(e.scale); runOnJS(onUpdateJS)(e.scale, e.focalX, e.focalY);
}) })
.onEnd(() => { .onEnd(() => {
'worklet'; 'worklet';
runOnJS(onEndJS)(); runOnJS(onEndJS)();
}); });
}, },
[], // 仅创建一次;通过 Ref 访问 resetZoom [],
); );
// ── Composed: Race ensures single-tap and pinch-zoom never conflict ───────── // ── Gesture: single-finger pan (active only when zoomed) ───────────────
// 依赖置空 + 内部的 tap/pinch 也用 [],保证整个手势描述符树只在 mount 时创建一次。 const panGesture = useMemo(
// 避免初始化阶段反复创建导致 Reanimated / RNGH 内部节点重复分配。 () => {
const onStartJS = () => {
panBaseRef.current = { ...panOffsetRef.current };
};
const onUpdateJS = (translationX: number, translationY: number) => {
if (zoomScaleRef.current <= 1) {
return;
}
const newPan = clampPanOffset(
{
x: panBaseRef.current.x + translationX,
y: panBaseRef.current.y + translationY,
},
zoomScaleRef.current,
canvasWRef.current,
canvasHRef.current,
containRectRef.current,
);
setPanOffset(newPan);
panOffsetRef.current = newPan;
};
const onEndJS = () => {
if (zoomScaleRef.current <= 1.01) {
resetZoomRef.current?.();
}
};
return Gesture.Pan()
.minPointers(1)
.maxPointers(1)
.minDistance(10)
.onStart(() => {
'worklet';
runOnJS(onStartJS)();
})
.onUpdate((e) => {
'worklet';
runOnJS(onUpdateJS)(e.translationX, e.translationY);
})
.onEnd(() => {
'worklet';
runOnJS(onEndJS)();
});
},
[],
);
// ── Composed: pinch + pan simultaneous; tap only when neither claims ───
const composedGesture = useMemo( const composedGesture = useMemo(
() => Gesture.Race(tapGesture, pinchGesture), () =>
Gesture.Exclusive(
Gesture.Simultaneous(pinchGesture, panGesture),
tapGesture,
),
[], [],
); );
return ( return (
<View style={[styles.container, style]}>
<View <View
style={[styles.container, style]} style={styles.canvasWrap}
onLayout={(e) => { onLayout={(e) => {
const { width, height } = e.nativeEvent.layout; const { width, height } = e.nativeEvent.layout;
setLayoutWidth(width); handleCanvasWrapLayout(width, height);
setLayoutHeight(height);
}} }}
> >
{/* Only render the internal ScrollView (and its control bars) when any of {canvasLayoutReady ? (
the auxiliary UI rows are requested. In the main VisualizationScreen
(and scheme card live previews) all of showToolbar/showDebug/showStatus/showColorBar
are false, so we omit this entirely.
This conditional is still important for gesture integrity:
- An always-mounted vertical ScrollView can participate in the responder system
and steal touches (vertical moves, or even interfere with two-finger pinch).
- The GestureDetector (for tap/pinch) is a sibling after the ScrollView in the
tree when the ScrollView is rendered, so it may not receive the intended gestures.
- By omitting the ScrollView when unused, the canvas touch layer + GestureDetector
become direct children and reliably receive single-finger taps and two-finger pinches. */}
{(showToolbar || showDebugPickers || showStatusRow || showColorBar) ? (
<ScrollView
style={styles.scroll}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="always"
>
{showToolbar && (
<View style={styles.toolbarRow}>
<Button
title={isRefreshing ? 're-segmenting…' : 'clear cache and re-segment'}
onPress={clearCacheAndResegment}
disabled={isRefreshing || !originImgPath || !maskImgPath}
/>
</View>
)}
{showDebugPickers && (
<View style={styles.btnRow}>
<Button title="pick origin image" onPress={pickOriginImage} />
<View style={styles.btnGap} />
<Button title="pick mask image" onPress={pickMaskImage} />
</View>
)}
{showStatusRow && (
<View style={styles.statusRow}>
{segError ? (
<Text style={styles.err}> {segError}</Text>
) : !segmentsReady ? (
<Text style={styles.hint}> segmenting</Text>
) : layersLoading || !paintResourcesReady ? (
<Text style={styles.hint}>
{regionCount} regions · Shader textures preparing
</Text>
) : !(originSkImg ?? lowFreqSkImg) ? (
<Text style={styles.hint}> image loading</Text>
) : (
<Text style={styles.hint}>
{regionCount} regions · painted {paintedRegions.size} ·{' '}
{hasActiveBrush
? customPaintColor
? 'custom paint color'
: `paint color ${(activeBrushIndex ?? 0) + 1}`
: 'please select the bottom paint color first'}
{' · '}
{compareMode ? 'compare original image' : 'paint mode'}
</Text>
)}
</View>
)}
{showColorBar && (
<View style={styles.colorBar}>
<Text style={styles.colorBarLabel}>
paint color (tap to select, then tap canvas to paint)
</Text>
<View style={styles.colorSwatches}>
{paintPalette.map((color, index) => {
const isActive =
activeBrushIndex === index && customPaintColor == null;
const { b, g, r } = color;
return (
<TouchableOpacity
key={index}
style={[
styles.colorSwatch,
{ backgroundColor: bgrToCss(b, g, r) },
isActive && styles.colorSwatchSelected,
]}
activeOpacity={0.8}
disabled={!segmentsReady || disabled}
onPress={() => selectBrushColor(index)}
/>
);
})}
{!segmentsReady && (
<Text style={styles.colorBarEmpty}>loading</Text>
)}
</View>
{customPaintColor && (
<Text style={styles.hint}>
current custom paint color is set by ref.setPaintColor
</Text>
)}
</View>
)}
</ScrollView>
) : null}
<View
style={[styles.canvasOuter, canvasStyle]}
>
<View
style={[styles.canvasWrap, { width: canvasW, height: canvasH }]}
>
<GestureDetector gesture={composedGesture}> <GestureDetector gesture={composedGesture}>
<View style={[styles.canvasTouchLayer, { width: canvasW, height: canvasH }]}> <View style={{ width: canvasW, height: canvasH }}>
<Canvas style={{ width: canvasW, height: canvasH }} pointerEvents="none"> <Canvas
style={{ width: canvasW, height: canvasH }}
pointerEvents="none"
>
{renderDraw()} {renderDraw()}
</Canvas> </Canvas>
</View> </View>
</GestureDetector> </GestureDetector>
) : null}
{showOverlayButtons &&
(renderUndoButton ? (
renderUndoButton({
onPress: undoSelection,
disabled: paintHistory.length === 0,
text: undoButtonText,
})
) : (
<TouchableOpacity
style={[styles.overlayBtn, styles.btnBottomLeft, undoButtonStyle]}
activeOpacity={0.7}
disabled={paintHistory.length === 0 || disabled}
onPress={undoSelection}
>
<Text
style={[
styles.btnText,
undoButtonTextStyle,
{ opacity: paintHistory.length === 0 ? 0.4 : 1 },
]}
>
{undoButtonText}
</Text>
</TouchableOpacity>
))}
{showOverlayButtons &&
(renderCompareButton ? (
renderCompareButton({
onPress: () => {
onUserInteraction();
setCompareMode(v => !v);
},
text: compareMode ? compareExitButtonText : compareButtonText,
})
) : (
<TouchableOpacity
style={[
styles.overlayBtn,
styles.btnBottomRight,
compareButtonStyle,
]}
activeOpacity={0.7}
disabled={disabled}
onPress={() => {
onUserInteraction();
setCompareMode(v => !v);
}}
>
<Text style={[styles.btnText, compareButtonTextStyle]}>
{compareMode ? compareExitButtonText : compareButtonText}
</Text>
</TouchableOpacity>
))}
</View>
</View> </View>
{highResSnapshotEnabled && exportCanvasSize ? ( {highResSnapshotEnabled && exportCanvasSize ? (
@ -2728,123 +2584,16 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
backgroundColor: '#fff', 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: { canvasWrap: {
flex: 1,
width: '100%',
alignSelf: 'stretch',
position: 'relative', position: 'relative',
backgroundColor: '#f5f5f5', backgroundColor: '#f5f5f5',
borderWidth: StyleSheet.hairlineWidth, borderWidth: StyleSheet.hairlineWidth,
borderColor: '#ddd', borderColor: '#ddd',
overflow: 'hidden', 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; export default MaskSegmentCanvas;

View File

@ -1,5 +1,4 @@
import type { ReactNode } from 'react'; import type { StyleProp, ViewStyle } from 'react-native';
import type { StyleProp, TextStyle, ViewStyle } from 'react-native';
import type { SegmentRegion } from '../utils/maskSegmentation'; import type { SegmentRegion } from '../utils/maskSegmentation';
import type { MaskSemanticColor } from '../utils/maskSemanticPalette'; import type { MaskSemanticColor } from '../utils/maskSemanticPalette';
@ -139,12 +138,6 @@ export type PaintBrushRequiredPayload = {
export type PaintCallbackPayload = PaintSuccessPayload | PaintBrushRequiredPayload; export type PaintCallbackPayload = PaintSuccessPayload | PaintBrushRequiredPayload;
export type OverlayButtonRenderProps = {
onPress: () => void;
disabled?: boolean;
text: string;
};
export type MaskSegmentCanvasRef = { export type MaskSegmentCanvasRef = {
reset: () => void; reset: () => void;
swap: (showOrigin?: boolean) => void; swap: (showOrigin?: boolean) => void;
@ -183,30 +176,14 @@ export type MaskSegmentCanvasProps = {
initialSession?: MaskSegmentSession; initialSession?: MaskSegmentSession;
initialPaintColor?: BgrColor; initialPaintColor?: BgrColor;
initialPaintConfigJson?: Record<string, unknown>; initialPaintConfigJson?: Record<string, unknown>;
showDebugPickers?: boolean;
showToolbar?: boolean;
showColorBar?: boolean;
showStatusRow?: boolean;
showOverlayButtons?: boolean;
disabled?: boolean; disabled?: boolean;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
canvasStyle?: StyleProp<ViewStyle>;
/** /**
* Max container height available for this component (px). When set, the SDK * Max container height available for this component (px). When set, the SDK
* computes canvas dimensions as a fit-contain within (screenWidth - 20, maxHeight) * computes canvas dimensions as a fit-contain within (screenWidth - 20, maxHeight)
* instead of using the full image aspect at full screen width. This prevents * instead of using the full image aspect at full screen width.
* internal ScrollView scrolling for tall images.
*/ */
maxHeight?: number; maxHeight?: number;
undoButtonStyle?: StyleProp<ViewStyle>;
compareButtonStyle?: StyleProp<ViewStyle>;
undoButtonTextStyle?: StyleProp<TextStyle>;
compareButtonTextStyle?: StyleProp<TextStyle>;
undoButtonText?: string;
compareButtonText?: string;
compareExitButtonText?: string;
renderUndoButton?: (props: OverlayButtonRenderProps) => ReactNode;
renderCompareButton?: (props: OverlayButtonRenderProps) => ReactNode;
onWatch?: ( onWatch?: (
state: MaskSegmentWatchState, state: MaskSegmentWatchState,
durationMs: number, durationMs: number,

View File

@ -9,7 +9,6 @@ export type {
MaskSegmentWatchDetail, MaskSegmentWatchDetail,
MaskSegmentWatchState, MaskSegmentWatchState,
MaskSemanticColor, MaskSemanticColor,
OverlayButtonRenderProps,
PaintBrushRequiredPayload, PaintBrushRequiredPayload,
PaintCallbackPayload, PaintCallbackPayload,
PaintSuccessPayload, PaintSuccessPayload,

View File

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import { import {
Skia, Skia,
Fill,
Shader, Shader,
ImageShader, ImageShader,
Group, Group,
Rect,
drawAsImage, drawAsImage,
type SkImage, type SkImage,
type SkRuntimeEffect, type SkRuntimeEffect,
@ -75,14 +75,14 @@ function createPaintShaderTree(props: PaintShaderLayerProps) {
}; };
return ( return (
<Fill> <Rect x={x} y={y} width={width} height={height}>
<Shader source={effect} uniforms={uniforms}> <Shader source={effect} uniforms={uniforms}>
<ImageShader image={originImage} {...imageShaderProps} /> <ImageShader image={originImage} {...imageShaderProps} />
<ImageShader image={paintColorMap} {...imageShaderProps} /> <ImageShader image={paintColorMap} {...imageShaderProps} />
<ImageShader image={lowFreqImage} {...imageShaderProps} /> <ImageShader image={lowFreqImage} {...imageShaderProps} />
<ImageShader image={highFreqImage} {...imageShaderProps} /> <ImageShader image={highFreqImage} {...imageShaderProps} />
</Shader> </Shader>
</Fill> </Rect>
); );
} }