react-native-mask-segment-canvas 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +904 -0
  2. package/dist/components/MaskSegmentCanvas.d.ts +6 -0
  3. package/dist/components/MaskSegmentCanvas.d.ts.map +1 -0
  4. package/dist/components/MaskSegmentCanvas.js +2012 -0
  5. package/dist/components/MaskSegmentCanvas.js.map +1 -0
  6. package/dist/components/MaskSegmentCanvas.types.d.ts +189 -0
  7. package/dist/components/MaskSegmentCanvas.types.d.ts.map +1 -0
  8. package/dist/components/MaskSegmentCanvas.types.js +2 -0
  9. package/dist/components/MaskSegmentCanvas.types.js.map +1 -0
  10. package/dist/index.d.ts +6 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +5 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/shaders/regionPaint.sksl.d.ts +3 -0
  15. package/dist/shaders/regionPaint.sksl.d.ts.map +1 -0
  16. package/dist/shaders/regionPaint.sksl.js +72 -0
  17. package/dist/shaders/regionPaint.sksl.js.map +1 -0
  18. package/dist/utils/compositePaintedImage.d.ts +44 -0
  19. package/dist/utils/compositePaintedImage.d.ts.map +1 -0
  20. package/dist/utils/compositePaintedImage.js +146 -0
  21. package/dist/utils/compositePaintedImage.js.map +1 -0
  22. package/dist/utils/exportUtils.d.ts +20 -0
  23. package/dist/utils/exportUtils.d.ts.map +1 -0
  24. package/dist/utils/exportUtils.js +32 -0
  25. package/dist/utils/exportUtils.js.map +1 -0
  26. package/dist/utils/freqLayerPrep.d.ts +23 -0
  27. package/dist/utils/freqLayerPrep.d.ts.map +1 -0
  28. package/dist/utils/freqLayerPrep.js +168 -0
  29. package/dist/utils/freqLayerPrep.js.map +1 -0
  30. package/dist/utils/maskSegmentRuntime.d.ts +43 -0
  31. package/dist/utils/maskSegmentRuntime.d.ts.map +1 -0
  32. package/dist/utils/maskSegmentRuntime.js +181 -0
  33. package/dist/utils/maskSegmentRuntime.js.map +1 -0
  34. package/dist/utils/maskSegmentation.d.ts +133 -0
  35. package/dist/utils/maskSegmentation.d.ts.map +1 -0
  36. package/dist/utils/maskSegmentation.js +1600 -0
  37. package/dist/utils/maskSegmentation.js.map +1 -0
  38. package/dist/utils/maskSemanticPalette.d.ts +31 -0
  39. package/dist/utils/maskSemanticPalette.d.ts.map +1 -0
  40. package/dist/utils/maskSemanticPalette.js +125 -0
  41. package/dist/utils/maskSemanticPalette.js.map +1 -0
  42. package/dist/utils/opencvAdapter.d.ts +116 -0
  43. package/dist/utils/opencvAdapter.d.ts.map +1 -0
  44. package/dist/utils/opencvAdapter.js +353 -0
  45. package/dist/utils/opencvAdapter.js.map +1 -0
  46. package/dist/utils/paintColorMapTexture.d.ts +5 -0
  47. package/dist/utils/paintColorMapTexture.d.ts.map +1 -0
  48. package/dist/utils/paintColorMapTexture.js +203 -0
  49. package/dist/utils/paintColorMapTexture.js.map +1 -0
  50. package/dist/utils/paintShaderRuntime.d.ts +40 -0
  51. package/dist/utils/paintShaderRuntime.d.ts.map +1 -0
  52. package/dist/utils/paintShaderRuntime.js +76 -0
  53. package/dist/utils/paintShaderRuntime.js.map +1 -0
  54. package/dist/utils/pickMapTexture.d.ts +4 -0
  55. package/dist/utils/pickMapTexture.d.ts.map +1 -0
  56. package/dist/utils/pickMapTexture.js +24 -0
  57. package/dist/utils/pickMapTexture.js.map +1 -0
  58. package/dist/utils/pngImage.d.ts +49 -0
  59. package/dist/utils/pngImage.d.ts.map +1 -0
  60. package/dist/utils/pngImage.js +438 -0
  61. package/dist/utils/pngImage.js.map +1 -0
  62. package/dist/utils/resolveAssetPath.d.ts +3 -0
  63. package/dist/utils/resolveAssetPath.d.ts.map +1 -0
  64. package/dist/utils/resolveAssetPath.js +56 -0
  65. package/dist/utils/resolveAssetPath.js.map +1 -0
  66. package/dist/utils/resolveImageUrl.d.ts +3 -0
  67. package/dist/utils/resolveImageUrl.d.ts.map +1 -0
  68. package/dist/utils/resolveImageUrl.js +51 -0
  69. package/dist/utils/resolveImageUrl.js.map +1 -0
  70. package/dist/utils/skiaImage.d.ts +4 -0
  71. package/dist/utils/skiaImage.d.ts.map +1 -0
  72. package/dist/utils/skiaImage.js +12 -0
  73. package/dist/utils/skiaImage.js.map +1 -0
  74. package/package.json +100 -0
  75. package/patches/react-native-fast-opencv+0.4.8.patch +122 -0
  76. package/src/components/MaskSegmentCanvas.tsx +2832 -0
  77. package/src/components/MaskSegmentCanvas.types.ts +216 -0
  78. package/src/globals.d.ts +19 -0
  79. package/src/index.ts +45 -0
  80. package/src/shaders/regionPaint.sksl.ts +71 -0
  81. package/src/upng-js.d.ts +33 -0
  82. package/src/utils/compositePaintedImage.ts +201 -0
  83. package/src/utils/exportUtils.ts +40 -0
  84. package/src/utils/freqLayerPrep.ts +267 -0
  85. package/src/utils/maskSegmentRuntime.ts +257 -0
  86. package/src/utils/maskSegmentation.ts +2294 -0
  87. package/src/utils/maskSemanticPalette.ts +187 -0
  88. package/src/utils/opencvAdapter.ts +539 -0
  89. package/src/utils/paintColorMapTexture.ts +239 -0
  90. package/src/utils/paintShaderRuntime.tsx +150 -0
  91. package/src/utils/pickMapTexture.ts +37 -0
  92. package/src/utils/pngImage.ts +591 -0
  93. package/src/utils/resolveAssetPath.ts +64 -0
  94. package/src/utils/resolveImageUrl.ts +63 -0
  95. package/src/utils/skiaImage.ts +25 -0
@@ -0,0 +1,2012 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle, } from 'react';
3
+ import { View, StyleSheet, Button, Dimensions, Text, TouchableOpacity, ScrollView, } from 'react-native';
4
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
5
+ import { runOnJS } from 'react-native-reanimated';
6
+ import { launchImageLibrary } from 'react-native-image-picker';
7
+ import cv from '../utils/opencvAdapter';
8
+ import { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion, downsampleMaskDataForPaths, extractRegionsFromMaskBufferSync, isBaseboardMaskPixel, upscaleBinaryMask, } from '../utils/maskSegmentation';
9
+ import { clearDerivedImageCache, readPngBgrBuffer, prewarmPngBgrCache, resizeBgrBuffer, } from '../utils/pngImage';
10
+ import { resolveImageUrl } from '../utils/resolveImageUrl';
11
+ import { compositePaintedImage } from '../utils/compositePaintedImage';
12
+ import { paintedRegionsFingerprint, resolveExportResultForDestDir, } from '../utils/exportUtils';
13
+ import { preparePaintResourcesFromWorkBuffer, releaseFreqLayerImages, } from '../utils/freqLayerPrep';
14
+ import { PaintShaderLayer, createPaintColorMapForPaint, } from '../utils/paintShaderRuntime';
15
+ import { createRuntimeConfig, getMaskRuntimeRevision, getMaskSegmentRuntimeConfig, resolvePipelineConfig, setMaskSegmentRuntimeConfig, } from '../utils/maskSegmentRuntime';
16
+ import { Canvas, Image as SkiaImage, Path, Group, DashPathEffect, Rect, useCanvasRef, Skia, } from '@shopify/react-native-skia';
17
+ /* ==========================================================================
18
+ * 配置常量(屏幕相关;其余见 maskSegmentRuntime)
19
+ * ========================================================================== */
20
+ const { width: SCREEN_WIDTH } = Dimensions.get('window');
21
+ function bgrColorEquals(a, b) {
22
+ return a.b === b.b && a.g === b.g && a.r === b.r;
23
+ }
24
+ /* ==========================================================================
25
+ * 几何工具
26
+ * ========================================================================== */
27
+ function rectsEqual(a, b) {
28
+ return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
29
+ }
30
+ function getContainRect(canvasW, canvasH, imgW, imgH) {
31
+ const imgAspect = imgW / imgH;
32
+ const canvasAspect = canvasW / canvasH;
33
+ if (imgAspect > canvasAspect) {
34
+ const w = canvasW;
35
+ const h = canvasW / imgAspect;
36
+ return { x: 0, y: (canvasH - h) / 2, w, h };
37
+ }
38
+ const h = canvasH;
39
+ const w = canvasH * imgAspect;
40
+ return { x: (canvasW - w) / 2, y: 0, w, h };
41
+ }
42
+ function canvasToNormalized(cx, cy, canvasW, canvasH, imgW, imgH) {
43
+ const rect = getContainRect(canvasW, canvasH, imgW, imgH);
44
+ if (cx < rect.x ||
45
+ cx > rect.x + rect.w ||
46
+ cy < rect.y ||
47
+ cy > rect.y + rect.h) {
48
+ return null;
49
+ }
50
+ return {
51
+ x: (cx - rect.x) / rect.w,
52
+ y: (cy - rect.y) / rect.h,
53
+ };
54
+ }
55
+ /**
56
+ * Inverse of the Skia Group transform applied during pinch-zoom.
57
+ * Converts a raw touch point (screen pixels) back to the canvas coordinate
58
+ * space where the image and regions are positioned before any scale/pan.
59
+ * When zoomScale ≤ 1 (no zoom), returns the input unchanged.
60
+ */
61
+ function screenToCanvasCoords(screenX, screenY, canvasW, canvasH, zoomScale, panOffset) {
62
+ if (zoomScale <= 1)
63
+ return { x: screenX, y: screenY };
64
+ // Reverse: translate(-pan) → unscale around center → translate(+center)
65
+ return {
66
+ x: (screenX - panOffset.x - canvasW / 2) / zoomScale + canvasW / 2,
67
+ y: (screenY - panOffset.y - canvasH / 2) / zoomScale + canvasH / 2,
68
+ };
69
+ }
70
+ function pointInPolygon(x, y, points) {
71
+ let inside = false;
72
+ for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
73
+ const xi = points[i].x;
74
+ const yi = points[i].y;
75
+ const xj = points[j].x;
76
+ const yj = points[j].y;
77
+ const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
78
+ if (intersect)
79
+ inside = !inside;
80
+ }
81
+ return inside;
82
+ }
83
+ function pointInPolygonWithPadding(x, y, points, padding) {
84
+ if (points.length < 3) {
85
+ return false;
86
+ }
87
+ let minX = points[0].x;
88
+ let maxX = points[0].x;
89
+ let minY = points[0].y;
90
+ let maxY = points[0].y;
91
+ for (const point of points) {
92
+ minX = Math.min(minX, point.x);
93
+ maxX = Math.max(maxX, point.x);
94
+ minY = Math.min(minY, point.y);
95
+ maxY = Math.max(maxY, point.y);
96
+ }
97
+ if (x >= minX - padding &&
98
+ x <= maxX + padding &&
99
+ y >= minY - padding &&
100
+ y <= maxY + padding) {
101
+ if (maxY - minY < padding * 2.5 || maxX - minX < padding * 2.5) {
102
+ return true;
103
+ }
104
+ }
105
+ return pointInPolygon(x, y, points);
106
+ }
107
+ function getRegionHitPolygons(reg) {
108
+ return reg.hitPolygons && reg.hitPolygons.length > 0
109
+ ? reg.hitPolygons
110
+ : reg.polygons;
111
+ }
112
+ function pointHitsRegion(x, y, reg, options) {
113
+ const interaction = getMaskSegmentRuntimeConfig().interaction;
114
+ const thinPadding = options?.thinPadding ?? interaction.thinStripPadding;
115
+ const padding = reg.thinStrip ? thinPadding : interaction.regionPadding;
116
+ return getRegionHitPolygons(reg).some(poly => poly.length >= 3 && pointInPolygonWithPadding(x, y, poly, padding));
117
+ }
118
+ function pointStrictlyHitsRegion(x, y, reg) {
119
+ return getRegionHitPolygons(reg).some(poly => poly.length >= 3 && pointInPolygon(x, y, poly));
120
+ }
121
+ function resolveRegionHit(regions, x, y) {
122
+ const hits = [];
123
+ for (const reg of regions) {
124
+ const bboxPad = reg.thinStrip ? 0.005 : 0;
125
+ const b = reg.bbox;
126
+ if (x < b.x - bboxPad ||
127
+ x > b.x + b.w + bboxPad ||
128
+ y < b.y - bboxPad ||
129
+ y > b.y + b.h + bboxPad) {
130
+ continue;
131
+ }
132
+ if (pointHitsRegion(x, y, reg)) {
133
+ hits.push(reg);
134
+ }
135
+ }
136
+ if (hits.length === 0) {
137
+ return null;
138
+ }
139
+ if (hits.length === 1) {
140
+ return hits[0].id;
141
+ }
142
+ const strictNonThin = hits.filter(reg => !reg.thinStrip && pointStrictlyHitsRegion(x, y, reg));
143
+ if (strictNonThin.length > 0) {
144
+ strictNonThin.sort((a, b) => a.area - b.area);
145
+ return strictNonThin[0].id;
146
+ }
147
+ const strictThin = hits.filter(reg => reg.thinStrip && pointStrictlyHitsRegion(x, y, reg));
148
+ if (strictThin.length > 0) {
149
+ strictThin.sort((a, b) => a.area - b.area);
150
+ return strictThin[0].id;
151
+ }
152
+ const nonThin = hits.filter(reg => !reg.thinStrip);
153
+ if (nonThin.length > 0) {
154
+ nonThin.sort((a, b) => a.area - b.area);
155
+ return nonThin[0].id;
156
+ }
157
+ hits.sort((a, b) => a.area - b.area);
158
+ return hits[0].id;
159
+ }
160
+ function pickKickRegionFromMask(normX, normY, pick, kickRegionId, baseboardPickMask, strict = false) {
161
+ const cx = Math.floor(normX * pick.cols);
162
+ const cy = Math.floor(normY * pick.rows);
163
+ if (cx < 0 || cy < 0 || cx >= pick.cols || cy >= pick.rows) {
164
+ return null;
165
+ }
166
+ if (strict) {
167
+ if (baseboardPickMask) {
168
+ return baseboardPickMask[cy * pick.cols + cx] ? kickRegionId : null;
169
+ }
170
+ return isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, cx, cy)
171
+ ? kickRegionId
172
+ : null;
173
+ }
174
+ const interaction = getMaskSegmentRuntimeConfig().interaction;
175
+ const radius = Math.max(interaction.kickMaskPickRadiusPx, Math.floor(pick.cols * 0.022));
176
+ const radiusSq = radius * radius;
177
+ for (let dy = -radius; dy <= radius; dy++) {
178
+ for (let dx = -radius; dx <= radius; dx++) {
179
+ if (dx * dx + dy * dy > radiusSq) {
180
+ continue;
181
+ }
182
+ const x = cx + dx;
183
+ const y = cy + dy;
184
+ if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
185
+ continue;
186
+ }
187
+ if (baseboardPickMask) {
188
+ if (baseboardPickMask[y * pick.cols + x]) {
189
+ return kickRegionId;
190
+ }
191
+ continue;
192
+ }
193
+ if (isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, x, y)) {
194
+ return kickRegionId;
195
+ }
196
+ }
197
+ }
198
+ return null;
199
+ }
200
+ function pickKickNearStrip(normX, normY, kickReg) {
201
+ const polys = kickReg.hitPolygons ?? kickReg.polygons;
202
+ const pad = getMaskSegmentRuntimeConfig().interaction.thinStripPadding + 0.004;
203
+ return polys.some(poly => poly.length >= 3 && pointInPolygonWithPadding(normX, normY, poly, pad));
204
+ }
205
+ function lookupRegionFromPickMap(normX, normY, pick, radiusPx = getMaskSegmentRuntimeConfig().interaction.pickMapSearchRadiusPx) {
206
+ const cx = Math.min(pick.cols - 1, Math.max(0, Math.floor(normX * pick.cols)));
207
+ const cy = Math.min(pick.rows - 1, Math.max(0, Math.floor(normY * pick.rows)));
208
+ const readCode = (x, y) => pick.buffer[y * pick.cols + x];
209
+ const center = readCode(cx, cy);
210
+ if (center > 0) {
211
+ return center - 1;
212
+ }
213
+ if (radiusPx <= 0) {
214
+ return null;
215
+ }
216
+ const r = Math.max(4, radiusPx);
217
+ const rSq = r * r;
218
+ for (let dy = -r; dy <= r; dy++) {
219
+ for (let dx = -r; dx <= r; dx++) {
220
+ if (dx * dx + dy * dy > rSq) {
221
+ continue;
222
+ }
223
+ const x = cx + dx;
224
+ const y = cy + dy;
225
+ if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
226
+ continue;
227
+ }
228
+ const code = readCode(x, y);
229
+ if (code > 0) {
230
+ return code - 1;
231
+ }
232
+ }
233
+ }
234
+ return null;
235
+ }
236
+ /** BGR → 屏幕 RGB */
237
+ function bgrToCss(b, g, r) {
238
+ return `rgb(${r},${g},${b})`;
239
+ }
240
+ function releasePaintResourceLayers(layers) {
241
+ if (!layers) {
242
+ return;
243
+ }
244
+ layers.lowFreqImage.dispose();
245
+ layers.highFreqImage.dispose();
246
+ }
247
+ function releaseOriginSkImage(image) {
248
+ if (image) {
249
+ image.dispose();
250
+ }
251
+ }
252
+ async function prepareWorkScaledBgrBuffer(bgrBuffer, cols, rows, workScale) {
253
+ if (workScale >= 1) {
254
+ return { buffer: bgrBuffer, cols, rows };
255
+ }
256
+ const workCols = Math.floor(cols * workScale);
257
+ const workRows = Math.floor(rows * workScale);
258
+ const buffer = resizeBgrBuffer(bgrBuffer, cols, rows, workCols, workRows);
259
+ return { buffer, cols: workCols, rows: workRows };
260
+ }
261
+ /* ==========================================================================
262
+ * 分段计时工具(仅开发环境生效)
263
+ * ========================================================================== */
264
+ let _timeLogTs = 0;
265
+ function timeLog(tag) {
266
+ if (!__DEV__)
267
+ return;
268
+ const now = performance.now();
269
+ const dt = _timeLogTs ? now - _timeLogTs : 0;
270
+ console.log(`[⏱ ${tag}] ${dt.toFixed(2)} ms`);
271
+ _timeLogTs = now;
272
+ }
273
+ /* ==========================================================================
274
+ * 组件主体
275
+ * ========================================================================== */
276
+ const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
277
+ 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;
278
+ const originSource = originUrlProp ?? originImgPathLegacy ?? '';
279
+ const maskSource = maskUrlProp ?? maskImgPathLegacy ?? '';
280
+ const resolvedMaskConfig = useMemo(() => semanticColors
281
+ ? { ...maskConfig, semanticColors }
282
+ : maskConfig, [maskConfig, semanticColors]);
283
+ const resolvedPaintConfig = useMemo(() => regionOutlineColor
284
+ ? { ...paintConfig, regionOverlayFill: regionOutlineColor }
285
+ : paintConfig, [paintConfig, regionOutlineColor]);
286
+ const [resolvedOriginPath, setResolvedOriginPath] = useState('');
287
+ const [resolvedMaskPath, setResolvedMaskPath] = useState('');
288
+ const [originImgPath, setOriginImgPath] = useState(resolvedOriginPath);
289
+ const [maskImgPath, setMaskImgPath] = useState(resolvedMaskPath);
290
+ // Latest desired image paths (updated when the internal path states settle).
291
+ // Used by segmentAndPrepareLayers to decide whether a stale runId (from effect cleanup due to
292
+ // unrelated parent re-renders) should actually abort the current async read/segment work.
293
+ // If the image pair we are processing is still the one the caller ultimately wants, we continue.
294
+ const latestOriginPathRef = useRef('');
295
+ const latestMaskPathRef = useRef('');
296
+ const resolvedPipelineConfig = useMemo(() => resolvePipelineConfig(pipelinePreset, pipelineConfig), [pipelinePreset, pipelineConfig]);
297
+ const runtimeRef = useRef(createRuntimeConfig({
298
+ maskConfig: resolvedMaskConfig,
299
+ pipelineConfig: resolvedPipelineConfig,
300
+ paintConfig: resolvedPaintConfig,
301
+ interactionConfig,
302
+ }));
303
+ // Track last *values* we pushed for paintConfig. We only call the global setMaskSegmentRuntimeConfig
304
+ // (which bumps runtimeRevision) when the actual numbers change. This prevents repeated parent
305
+ // re-renders that pass a new object literal with identical values from causing:
306
+ // - global revision bump
307
+ // - paintColorMap useMemo invalidation (full-res Uint8Array + boxBlur + Skia image alloc)
308
+ // - extra main-thread work during the critical segmentation / freq-layers / outline paths window.
309
+ // The local runtimeRef is still kept in sync for any synchronous readers.
310
+ const lastAppliedPaintConfigRef = useRef(null);
311
+ const lastAppliedPipelineSignatureRef = useRef(null);
312
+ useEffect(() => {
313
+ const prevPaint = lastAppliedPaintConfigRef.current;
314
+ const currPaint = resolvedPaintConfig || {};
315
+ const paintKeys = [
316
+ 'colorBaseOpacity',
317
+ 'lLightOpacity',
318
+ 'textureOpacity',
319
+ 'lLowBlurKernel',
320
+ 'lLowContrast',
321
+ 'lLowBrightness',
322
+ 'lHighGain',
323
+ 'maskFeatherColor',
324
+ 'maskFeatherTexture',
325
+ 'regionOverlayFill',
326
+ ];
327
+ const paintChanged = !prevPaint || paintKeys.some((k) => currPaint[k] !== prevPaint[k]);
328
+ const pipelineSignature = JSON.stringify(resolvedPipelineConfig);
329
+ const pipelineChanged = lastAppliedPipelineSignatureRef.current !== pipelineSignature;
330
+ if (paintChanged || pipelineChanged) {
331
+ if (paintChanged) {
332
+ lastAppliedPaintConfigRef.current = { ...currPaint };
333
+ }
334
+ if (pipelineChanged) {
335
+ lastAppliedPipelineSignatureRef.current = pipelineSignature;
336
+ }
337
+ runtimeRef.current = setMaskSegmentRuntimeConfig({
338
+ maskConfig: resolvedMaskConfig,
339
+ pipelineConfig: resolvedPipelineConfig,
340
+ paintConfig: resolvedPaintConfig,
341
+ interactionConfig,
342
+ });
343
+ }
344
+ }, [
345
+ resolvedMaskConfig,
346
+ resolvedPipelineConfig,
347
+ resolvedPaintConfig,
348
+ interactionConfig,
349
+ ]);
350
+ const paintPalette = runtimeRef.current.paint.palette;
351
+ const paintRuntime = getMaskSegmentRuntimeConfig().paint;
352
+ const interactionRuntime = getMaskSegmentRuntimeConfig().interaction;
353
+ const onWatchRef = useRef(onWatch);
354
+ const onPaintCallbackRef = useRef(onPaintCallback);
355
+ const onErrorRef = useRef(onError);
356
+ const onExportedRef = useRef(onExported);
357
+ useEffect(() => {
358
+ onWatchRef.current = onWatch;
359
+ onPaintCallbackRef.current = onPaintCallback;
360
+ onErrorRef.current = onError;
361
+ onExportedRef.current = onExported;
362
+ }, [onWatch, onPaintCallback, onError, onExported]);
363
+ const watchStartRef = useRef(0);
364
+ const lastWatchStateRef = useRef(null);
365
+ const lastWatchSignatureRef = useRef(null);
366
+ const emitWatch = useCallback((state, detail) => {
367
+ const signature = [
368
+ state,
369
+ detail?.regionCount ?? '',
370
+ detail?.maskPathsReady ?? '',
371
+ detail?.freqLayersReady ?? '',
372
+ detail?.errorMessage ?? '',
373
+ ].join('|');
374
+ if (lastWatchSignatureRef.current === signature) {
375
+ return;
376
+ }
377
+ lastWatchSignatureRef.current = signature;
378
+ lastWatchStateRef.current = state;
379
+ const durationMs = watchStartRef.current
380
+ ? performance.now() - watchStartRef.current
381
+ : 0;
382
+ onWatchRef.current?.(state, durationMs, detail);
383
+ }, []);
384
+ const reportError = useCallback((message, error) => {
385
+ emitWatch('error', { errorMessage: message });
386
+ if (onErrorRef.current) {
387
+ onErrorRef.current(message, error);
388
+ }
389
+ else if (__DEV__) {
390
+ console.error('[MaskSegment]', message, error);
391
+ }
392
+ }, [emitWatch]);
393
+ const [customPaintColor, setCustomPaintColor] = useState(initialPaintColor ?? null);
394
+ const customPaintConfigJsonRef = useRef(initialPaintConfigJson);
395
+ const originUrlRef = useRef(originSource);
396
+ const maskUrlRef = useRef(maskSource);
397
+ useEffect(() => {
398
+ originUrlRef.current = originSource;
399
+ maskUrlRef.current = maskSource;
400
+ }, [originSource, maskSource]);
401
+ useEffect(() => {
402
+ let cancelled = false;
403
+ if (!originSource || !maskSource) {
404
+ setResolvedOriginPath('');
405
+ setResolvedMaskPath('');
406
+ return;
407
+ }
408
+ void (async () => {
409
+ try {
410
+ const [originPath, maskPath] = await Promise.all([
411
+ resolveImageUrl(originSource, 'origin.png'),
412
+ resolveImageUrl(maskSource, 'mask.png'),
413
+ ]);
414
+ if (!cancelled) {
415
+ setResolvedOriginPath(originPath);
416
+ setResolvedMaskPath(maskPath);
417
+ }
418
+ }
419
+ catch (e) {
420
+ if (!cancelled) {
421
+ const msg = e instanceof Error ? e.message : String(e);
422
+ reportError(msg, e);
423
+ }
424
+ }
425
+ })();
426
+ return () => {
427
+ cancelled = true;
428
+ };
429
+ }, [originSource, maskSource, reportError]);
430
+ useEffect(() => {
431
+ setOriginImgPath(resolvedOriginPath);
432
+ setMaskImgPath(resolvedMaskPath);
433
+ if (resolvedOriginPath && resolvedMaskPath) {
434
+ prewarmPngBgrCache([resolvedOriginPath, resolvedMaskPath]);
435
+ }
436
+ }, [resolvedOriginPath, resolvedMaskPath]);
437
+ // Keep latest desired paths for cancellation decisions inside the long async segment pipeline.
438
+ useEffect(() => {
439
+ latestOriginPathRef.current = originImgPath || '';
440
+ latestMaskPathRef.current = maskImgPath || '';
441
+ }, [originImgPath, maskImgPath]);
442
+ const [paintResourceLayers, setPaintResourceLayers] = useState(null);
443
+ const paintResourceLayersRef = useRef(null);
444
+ const paintColorMapSkImgRef = useRef(null);
445
+ const [activeBrushIndex, setActiveBrushIndex] = useState(null);
446
+ const [paintedRegions, setPaintedRegions] = useState(() => new Map());
447
+ const paintedRegionsRef = useRef(new Map());
448
+ const [paintHistory, setPaintHistory] = useState([]);
449
+ // Seed the ref with the initial empty map so early reads (before any paint effect)
450
+ // are consistent.
451
+ paintedRegionsRef.current = paintedRegions;
452
+ const [heldRegionId, setHeldRegionId] = useState(null);
453
+ const [heldRegionAnchor, setHeldRegionAnchor] = useState(null);
454
+ const [initFlashRegionId, setInitFlashRegionId] = useState(null);
455
+ const initFlashTimerRef = useRef(null);
456
+ const initFlashIndexRef = useRef(0);
457
+ const initFlashActiveRef = useRef(false);
458
+ // List of regions still eligible for the init dashed-outline flash (discovery aid).
459
+ // Computed at the start of the flash loop, excluding any that are already painted.
460
+ // This ensures that on continue-edit (or any partial seed), already-colored regions
461
+ // do not get the flashing dashed outline.
462
+ const initFlashListRef = useRef([]);
463
+ // Guard so that initialSession (seed from host bootstrap or scheme) is applied only once
464
+ // after segmentsReady. Prevents later prop identity changes (e.g. caused by host slot/brush
465
+ // selection re-renders) from calling restoreSession again, which would clobber live
466
+ // paintedRegions with a re-derived snapshot (often causing already-painted regions to
467
+ // "follow" the newly selected brush color).
468
+ const hasAppliedInitialSessionRef = useRef(false);
469
+ // Keep a ref to the absolute latest paintedRegions so that imperative save()
470
+ // (called from host performSaveProject for new schemes, or manually) and
471
+ // internal composites always see the most up-to-date painted state, even
472
+ // if the useImperativeHandle closure was created in a prior render.
473
+ // This fixes cases where the last user paint's colors were missing from
474
+ // the recolored After image captured at "save scheme" time.
475
+ useEffect(() => {
476
+ paintedRegionsRef.current = paintedRegions;
477
+ }, [paintedRegions]);
478
+ // Cached export from the most recent auto-export or save() — keyed by painted fingerprint.
479
+ const lastExportCacheRef = useRef(null);
480
+ const autoExportDebounceRef = useRef(null);
481
+ const exportInFlightRef = useRef(false);
482
+ const regionsRef = useRef([]);
483
+ const maskPickRef = useRef(null);
484
+ const regionPickRef = useRef(null);
485
+ const regionMaskDataRef = useRef(null);
486
+ const workBufferRef = useRef(null);
487
+ const paintLayersPromiseRef = useRef(null);
488
+ const loadPaintLayersRef = useRef(() => Promise.resolve());
489
+ const [paintResourcesReady, setPaintResourcesReady] = useState(false);
490
+ const [layersLoading, setLayersLoading] = useState(false);
491
+ const [maskPathsReady, setMaskPathsReady] = useState(false);
492
+ const baseboardPickMaskRef = useRef(null);
493
+ const kickRegionIdRef = useRef(null);
494
+ const [regionPalette, setRegionPalette] = useState([]);
495
+ const [regionCount, setRegionCount] = useState(0);
496
+ const [imageSize, setImageSize] = useState(null);
497
+ // High-resolution offscreen Canvas (sized to the work buffer resolution) whose content
498
+ // is the full shader composition (PaintShaderLayer at 0,0,workW,workH). On save() we
499
+ // call makeImageSnapshot() on it to get a "what you see in the editor, at source res"
500
+ // PNG bytes. This is the preferred "保存快照" path and avoids CPU recolor entirely
501
+ // for the exported After.
502
+ const highResExportCanvasRef = useCanvasRef();
503
+ const [exportCanvasSize, setExportCanvasSize] = useState(null);
504
+ // Gate the (potentially expensive) high-res snapshot canvas so it is only mounted
505
+ // after the user (or initialSession seed) has painted at least one region. This keeps
506
+ // idle segmentation / no-paint cases cheap.
507
+ const [highResSnapshotEnabled, setHighResSnapshotEnabled] = useState(false);
508
+ // Layout measurement for the root container of this component. Declared early so
509
+ // the canvasW/canvasH memos (which decide the viewport rect for zoom centering,
510
+ // containRect placement, clipping, and gesture coordinate mapping) can close over it.
511
+ // When the host passes a fitted frame (VisualizationScreen's canvasFrame with explicit
512
+ // w/h derived from safe area + aspect, or scheme cards), we size our internal Skia
513
+ // canvas + gesture layer + zoom transform to that exact allocated rect.
514
+ const [layoutWidth, setLayoutWidth] = useState(null);
515
+ const [layoutHeight, setLayoutHeight] = useState(null);
516
+ const [segmentsReady, setSegmentsReady] = useState(false);
517
+ const segmentsReadyRef = useRef(false);
518
+ const maskPathsReadyRef = useRef(false);
519
+ const [canvasInteractive, setCanvasInteractive] = useState(false);
520
+ const [segError, setSegError] = useState('');
521
+ const [compareMode, setCompareMode] = useState(false);
522
+ const [isRefreshing, setIsRefreshing] = useState(false);
523
+ const [originSkImg, setOriginSkImg] = useState(null);
524
+ const originSkImgRef = useRef(null);
525
+ const lowFreqSkImg = paintResourceLayers?.lowFreqImage ?? null;
526
+ const highFreqSkImg = paintResourceLayers?.highFreqImage ?? null;
527
+ const canvasBaseW = SCREEN_WIDTH - 20;
528
+ // The "viewport" size for this canvas component: the rect inside which we place
529
+ // the contained image, apply the zoom Group transform (centered), clip, and receive
530
+ // gestures (tap + two-finger pinch). When we have a real onLayout from the host
531
+ // (VisualizationScreen's aspect-fitted canvasFrame, or scheme card preview area),
532
+ // we use the *allocated pixel size* directly as our viewport.
533
+ //
534
+ // Accurate canvasW/H is still critical for:
535
+ // - Correct centering of the scaled content around the viewport center.
536
+ // - Proper containRect (letterbox/centering of the source photo inside the viewport).
537
+ // - Clip rect and gesture-to-canvas coordinate conversion used by painting.
538
+ //
539
+ // Previously the code fell back to aspect-derived sizes even after layout, which
540
+ // could cause the effective viewport to not match the host frame. Using the onLayout
541
+ // result (with maxHeight fallback only when no layout yet) keeps zoom, clip, and
542
+ // touch mapping consistent with what the user actually sees and touches.
543
+ const viewportW = useMemo(() => {
544
+ if (layoutWidth != null && layoutHeight != null) {
545
+ // Primary path for viz screen and scheme cards: the exact size the host
546
+ // decided for this component (after its own safe-area + aspect fit).
547
+ return layoutWidth;
548
+ }
549
+ if (!maxHeight || maxHeight <= 0) {
550
+ return canvasBaseW;
551
+ }
552
+ // Fallback (no layout yet, or other usages that pass maxHeight without a
553
+ // tightly sized parent frame). Replicate a contain-style budget.
554
+ const availableW = canvasBaseW;
555
+ let auxHeight = 0;
556
+ if (showToolbar)
557
+ auxHeight += 40;
558
+ if (showStatusRow)
559
+ auxHeight += 30;
560
+ if (showColorBar)
561
+ auxHeight += 70;
562
+ const availableH = Math.max(100, maxHeight - 20 - auxHeight);
563
+ const imgAspect = imageSize ? imageSize.w / imageSize.h : 1;
564
+ const containerAspect = availableW / availableH;
565
+ if (containerAspect > imgAspect) {
566
+ return Math.floor(availableH * imgAspect);
567
+ }
568
+ return availableW;
569
+ }, [layoutWidth, layoutHeight, maxHeight, showToolbar, showStatusRow, showColorBar, canvasBaseW, imageSize]);
570
+ const viewportH = useMemo(() => {
571
+ if (layoutWidth != null && layoutHeight != null) {
572
+ return layoutHeight;
573
+ }
574
+ if (!maxHeight || maxHeight <= 0) {
575
+ const imgAspect = imageSize ? imageSize.w / imageSize.h : 1;
576
+ return Math.floor(viewportW / imgAspect);
577
+ }
578
+ let auxHeight = 0;
579
+ if (showToolbar)
580
+ auxHeight += 40;
581
+ if (showStatusRow)
582
+ auxHeight += 30;
583
+ if (showColorBar)
584
+ auxHeight += 70;
585
+ return Math.max(100, maxHeight - 20 - auxHeight);
586
+ }, [layoutWidth, layoutHeight, maxHeight, showToolbar, showStatusRow, showColorBar, viewportW, imageSize]);
587
+ // For the rest of the component, "canvasW/H" means the viewport rect size.
588
+ // All zoom center, wrap size, touch layer, Canvas size, clip, containRect, and
589
+ // gesture coordinate mapping are based on this, so that two-finger zoom centering
590
+ // and single-finger tap painting stay consistent with the host-allocated area.
591
+ const canvasW = viewportW;
592
+ const canvasH = viewportH;
593
+ // Refs synced to the latest viewport size so that async callbacks
594
+ // (segmentAndPrepareLayers) always read post-layout values instead of
595
+ // stale closure captures. This fixes dashed-outline offset to the bottom
596
+ // when the initial pathMapRect was computed with fallback SCREEN_WIDTH.
597
+ // Declared before segmentAndPrepareLayers so the async body can reference them.
598
+ const canvasWRef = useRef(canvasW);
599
+ const canvasHRef = useRef(canvasH);
600
+ // ── Pinch-zoom (two-finger only; single-finger drag/pan disabled) ─────────
601
+ const [zoomScale, setZoomScale] = useState(1);
602
+ const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
603
+ // Refs for gesture callbacks (closures don't capture fresh state mid-gesture)
604
+ const zoomScaleRef = useRef(1);
605
+ const panOffsetRef = useRef({ x: 0, y: 0 });
606
+ // Baseline value captured at gesture start to avoid jump on re-creation for pinch
607
+ const zoomBaseRef = useRef(1);
608
+ // Ref to the latest containRect (the actual placed photo rect inside the viewport).
609
+ const containRectRef = useRef(null);
610
+ useEffect(() => { zoomScaleRef.current = zoomScale; }, [zoomScale]);
611
+ useEffect(() => { panOffsetRef.current = panOffset; }, [panOffset]);
612
+ const resetZoom = useCallback(() => {
613
+ setZoomScale(1);
614
+ setPanOffset({ x: 0, y: 0 });
615
+ zoomScaleRef.current = 1;
616
+ panOffsetRef.current = { x: 0, y: 0 };
617
+ }, []);
618
+ const containRect = useMemo(() => {
619
+ if (!imageSize)
620
+ return null;
621
+ return getContainRect(canvasW, canvasH, imageSize.w, imageSize.h);
622
+ }, [imageSize, canvasW, canvasH]);
623
+ // Keep a ref in sync so early-defined callbacks can read the
624
+ // latest containRect without TDZ or stale closure issues.
625
+ useEffect(() => {
626
+ containRectRef.current = containRect;
627
+ }, [containRect]);
628
+ const segmentRunIdRef = useRef(0);
629
+ const lastSegmentKeyRef = useRef('');
630
+ const segmentInFlightKeyRef = useRef('');
631
+ const maskPathsContainRectRef = useRef(null);
632
+ useEffect(() => {
633
+ paintResourceLayersRef.current = paintResourceLayers;
634
+ }, [paintResourceLayers]);
635
+ useEffect(() => {
636
+ segmentsReadyRef.current = segmentsReady;
637
+ }, [segmentsReady]);
638
+ useEffect(() => {
639
+ maskPathsReadyRef.current = maskPathsReady;
640
+ }, [maskPathsReady]);
641
+ const emitLayersReadyIfReady = useCallback(() => {
642
+ if (!segmentsReadyRef.current || !paintResourceLayersRef.current) {
643
+ return;
644
+ }
645
+ emitWatch('layers_ready', {
646
+ regionCount: regionsRef.current.length,
647
+ maskPathsReady: maskPathsReadyRef.current,
648
+ freqLayersReady: true,
649
+ });
650
+ }, [emitWatch]);
651
+ const emitMaskPathsReadyIfReady = useCallback(() => {
652
+ if (!segmentsReadyRef.current || !maskPathsReadyRef.current) {
653
+ return;
654
+ }
655
+ emitWatch('mask_paths_ready', {
656
+ regionCount: regionsRef.current.length,
657
+ maskPathsReady: true,
658
+ freqLayersReady: true,
659
+ });
660
+ }, [emitWatch]);
661
+ const emitInteractiveIfReady = useCallback(() => {
662
+ if (!segmentsReadyRef.current || !paintResourceLayersRef.current) {
663
+ return;
664
+ }
665
+ emitLayersReadyIfReady();
666
+ emitWatch('interactive', {
667
+ regionCount: regionsRef.current.length,
668
+ maskPathsReady: maskPathsReadyRef.current,
669
+ freqLayersReady: true,
670
+ });
671
+ setCanvasInteractive(true);
672
+ }, [emitWatch, emitLayersReadyIfReady]);
673
+ const paintColorMapSkImg = useMemo(() => {
674
+ const pick = regionPickRef.current;
675
+ paintColorMapSkImgRef.current?.dispose();
676
+ // Early out: no pick buffer yet, or no regions have been painted (initial load or fresh session).
677
+ // Avoids repeated full-resolution RGBA allocation + boxBlur (for maskFeather) + Skia.Image.MakeImage
678
+ // on every re-render during the hot init path. When initialSession restores paints, paintedRegions
679
+ // will update and legitimately trigger a single build of the (feathered) color map.
680
+ if (!pick || paintedRegions.size === 0) {
681
+ paintColorMapSkImgRef.current = null;
682
+ return null;
683
+ }
684
+ const map = createPaintColorMapForPaint(pick.buffer, pick.cols, pick.rows, paintedRegions);
685
+ paintColorMapSkImgRef.current = map;
686
+ return map;
687
+ }, [paintedRegions, paintResourcesReady, segmentsReady, getMaskRuntimeRevision()]);
688
+ const paintedRegionConfigRef = useRef(new Map());
689
+ const segmentAndPrepareLayers = useCallback(async (originPath, maskPath) => {
690
+ const runId = ++segmentRunIdRef.current;
691
+ // isCancelled: a runId bump alone is not enough to abort if the image pair we were asked to process
692
+ // is still the latest desired pair from the caller (protects against effect cleanups caused by
693
+ // unrelated parent re-renders / state updates that do not change the two images).
694
+ const isCancelled = () => {
695
+ if (runId === segmentRunIdRef.current)
696
+ return false;
697
+ const desiredOrigin = latestOriginPathRef.current || originPath;
698
+ const desiredMask = latestMaskPathRef.current || maskPath;
699
+ const stillWanted = desiredOrigin === originPath && desiredMask === maskPath;
700
+ return !stillWanted;
701
+ };
702
+ const pipeline = getMaskSegmentRuntimeConfig().pipeline;
703
+ watchStartRef.current = performance.now();
704
+ lastWatchStateRef.current = null;
705
+ lastWatchSignatureRef.current = null;
706
+ emitWatch('init');
707
+ timeLog('▶ start segmentation');
708
+ segmentsReadyRef.current = false;
709
+ setSegmentsReady(false);
710
+ setSegError('');
711
+ setActiveBrushIndex(null);
712
+ const clearedPainted = new Map();
713
+ paintedRegionsRef.current = clearedPainted;
714
+ setPaintedRegions(clearedPainted);
715
+ setPaintHistory([]);
716
+ setHeldRegionId(null);
717
+ setHeldRegionAnchor(null);
718
+ setInitFlashRegionId(null);
719
+ initFlashActiveRef.current = false;
720
+ initFlashIndexRef.current = 0;
721
+ initFlashListRef.current = [];
722
+ if (initFlashTimerRef.current) {
723
+ clearTimeout(initFlashTimerRef.current);
724
+ initFlashTimerRef.current = null;
725
+ }
726
+ hasAppliedInitialSessionRef.current = false;
727
+ lastExportCacheRef.current = null;
728
+ if (autoExportDebounceRef.current) {
729
+ clearTimeout(autoExportDebounceRef.current);
730
+ autoExportDebounceRef.current = null;
731
+ }
732
+ exportInFlightRef.current = false;
733
+ regionsRef.current = [];
734
+ maskPickRef.current = null;
735
+ regionPickRef.current = null;
736
+ regionMaskDataRef.current = null;
737
+ workBufferRef.current = null;
738
+ paintLayersPromiseRef.current = null;
739
+ setRegionOutlinePaths(new Map());
740
+ setMaskPathsReady(false);
741
+ setPaintResourcesReady(false);
742
+ setLayersLoading(false);
743
+ baseboardPickMaskRef.current = null;
744
+ kickRegionIdRef.current = null;
745
+ maskPathsContainRectRef.current = null;
746
+ setRegionPalette([]);
747
+ setRegionCount(0);
748
+ // Reset high-res snapshot state so a fresh segmentation starts clean (no stale size/ref).
749
+ setExportCanvasSize(null);
750
+ setHighResSnapshotEnabled(false);
751
+ const prevLayers = paintResourceLayersRef.current;
752
+ if (prevLayers) {
753
+ releasePaintResourceLayers(prevLayers);
754
+ paintResourceLayersRef.current = null;
755
+ setPaintResourceLayers(null);
756
+ }
757
+ releaseOriginSkImage(originSkImgRef.current);
758
+ originSkImgRef.current = null;
759
+ setOriginSkImg(null);
760
+ try {
761
+ timeLog('▶ reading origin PNG');
762
+ const originPromise = readPngBgrBuffer(originPath);
763
+ timeLog('▶ reading mask PNG');
764
+ const maskPromise = readPngBgrBuffer(maskPath);
765
+ const originDecoded = await originPromise;
766
+ const afterOriginCancelled = isCancelled();
767
+ if (afterOriginCancelled) {
768
+ return;
769
+ }
770
+ const imgW = originDecoded.cols;
771
+ const imgH = originDecoded.rows;
772
+ setImageSize({ w: imgW, h: imgH });
773
+ let scale = 1;
774
+ if (Math.max(imgW, imgH) > pipeline.maxImageLongSide) {
775
+ scale = pipeline.maxImageLongSide / Math.max(imgW, imgH);
776
+ }
777
+ const segW = Math.floor(imgW * scale);
778
+ const segH = Math.floor(imgH * scale);
779
+ const minArea = pipeline.minContourArea * scale * scale;
780
+ const workScaledTask = prepareWorkScaledBgrBuffer(originDecoded.buffer, imgW, imgH, scale).then(workScaled => {
781
+ if (isCancelled()) {
782
+ return workScaled;
783
+ }
784
+ workBufferRef.current = workScaled;
785
+ setExportCanvasSize({ w: workScaled.cols, h: workScaled.rows });
786
+ // Enable the offscreen high-res export canvas as soon as we know the work resolution.
787
+ // This gives the hidden <Canvas> time to mount and commit before autoExport/save()
788
+ // tries to snapshot it. The inner renderFullResPainted() will draw cheap origin until
789
+ // paints + shader textures are ready. This greatly increases the chance that the
790
+ // preferred makeImageSnapshot path succeeds for rich exports (instead of falling
791
+ // through to the drawAsImage reconstruction).
792
+ setHighResSnapshotEnabled(true);
793
+ void loadPaintLayersRef.current();
794
+ return workScaled;
795
+ });
796
+ const segmentTask = maskPromise.then(async (maskDecoded) => {
797
+ if (isCancelled()) {
798
+ throw new Error('cancelled');
799
+ }
800
+ timeLog('▶ PNG read completed');
801
+ emitWatch('images_loaded');
802
+ timeLog(`▶ image size: ${imgW}x${imgH}`);
803
+ const { buffer: maskBuffer, cols: maskW, rows: maskH } = maskDecoded;
804
+ const segMaskBuffer = resizeBgrBuffer(maskBuffer, maskW, maskH, segW, segH);
805
+ timeLog(`▶ mask scale: ${scale.toFixed(3)}`);
806
+ emitWatch('mask_aligned');
807
+ return extractRegionsFromMaskBufferSync(segMaskBuffer, segW, segH, {
808
+ minArea,
809
+ approxEpsilon: pipeline.contourApproxEpsilon,
810
+ });
811
+ });
812
+ void maskPromise.then(async (maskDecoded) => {
813
+ const { buffer: maskBuffer, cols: maskW, rows: maskH } = maskDecoded;
814
+ let pickBuffer;
815
+ if (maskW !== imgW || maskH !== imgH) {
816
+ pickBuffer = await cv.resizeBgrBuffer(maskBuffer, maskW, maskH, imgW, imgH);
817
+ }
818
+ else {
819
+ pickBuffer = new Uint8Array(maskBuffer);
820
+ }
821
+ if (runId !== segmentRunIdRef.current) {
822
+ return;
823
+ }
824
+ maskPickRef.current = {
825
+ buffer: pickBuffer,
826
+ cols: imgW,
827
+ rows: imgH,
828
+ };
829
+ });
830
+ const [segmentResult] = await Promise.all([
831
+ segmentTask,
832
+ workScaledTask,
833
+ ]);
834
+ if (isCancelled()) {
835
+ return;
836
+ }
837
+ const paintPromise = paintLayersPromiseRef.current ?? Promise.resolve();
838
+ emitWatch('mask_sampled', { regionCount: segmentResult.regions.length });
839
+ timeLog(`▶ segmentation completed, valid regions: ${segmentResult.regions.length}`);
840
+ const validRegions = segmentResult.regions;
841
+ if (__DEV__ && validRegions.length === 0) {
842
+ console.warn('[MaskSegment] not recognized any valid regions, please check if the mask is a pure color region image');
843
+ }
844
+ let finalRegions = validRegions;
845
+ if (finalRegions.length > pipeline.maxRegions) {
846
+ finalRegions = finalRegions.slice(0, pipeline.maxRegions);
847
+ }
848
+ regionsRef.current = finalRegions;
849
+ regionPickRef.current = segmentResult.pickMap;
850
+ regionMaskDataRef.current = {
851
+ labels: segmentResult.labels,
852
+ baseboardBinary: segmentResult.baseboardBinary,
853
+ cols: segmentResult.segCols,
854
+ rows: segmentResult.segRows,
855
+ };
856
+ baseboardPickMaskRef.current = null;
857
+ kickRegionIdRef.current =
858
+ finalRegions.find(reg => reg.thinStrip)?.id ?? null;
859
+ const pathMapRect = getContainRect(canvasWRef.current, canvasHRef.current, imgW, imgH);
860
+ maskPathsContainRectRef.current = pathMapRect;
861
+ setRegionOutlinePaths(new Map());
862
+ setMaskPathsReady(false);
863
+ setRegionPalette(finalRegions);
864
+ setRegionCount(finalRegions.length);
865
+ lastSegmentKeyRef.current = `${originPath}|${maskPath}`;
866
+ segmentsReadyRef.current = true;
867
+ setSegmentsReady(true);
868
+ emitWatch('regions_ready', { regionCount: finalRegions.length });
869
+ emitInteractiveIfReady();
870
+ void (async () => {
871
+ if (runId !== segmentRunIdRef.current) {
872
+ return;
873
+ }
874
+ const pathMaskData = downsampleMaskDataForPaths(regionMaskDataRef.current, pipeline.maskPathMaxLongSide);
875
+ const outlines = buildAllRegionOutlinePaths(finalRegions, pathMaskData, pathMapRect);
876
+ if (runId !== segmentRunIdRef.current) {
877
+ return;
878
+ }
879
+ setRegionOutlinePaths(outlines);
880
+ setMaskPathsReady(true);
881
+ maskPathsReadyRef.current = true;
882
+ emitMaskPathsReadyIfReady();
883
+ })();
884
+ void (async () => {
885
+ if (runId !== segmentRunIdRef.current) {
886
+ return;
887
+ }
888
+ await paintPromise;
889
+ if (runId !== segmentRunIdRef.current) {
890
+ return;
891
+ }
892
+ baseboardPickMaskRef.current = upscaleBinaryMask(segmentResult.baseboardBinary, segW, segH, imgW, imgH);
893
+ })();
894
+ }
895
+ catch (e) {
896
+ const msg = e instanceof Error ? e.message : String(e);
897
+ if (!isCancelled()) {
898
+ console.error('[SDK-SEGMENT] segmentation failed', e);
899
+ setSegError(msg);
900
+ reportError(msg, e);
901
+ }
902
+ }
903
+ }, [emitWatch, emitInteractiveIfReady, emitMaskPathsReadyIfReady, reportError]);
904
+ const loadPaintLayersIfNeeded = useCallback(() => {
905
+ if (paintResourcesReady) {
906
+ return Promise.resolve();
907
+ }
908
+ if (paintLayersPromiseRef.current) {
909
+ return paintLayersPromiseRef.current;
910
+ }
911
+ const work = workBufferRef.current;
912
+ if (!work) {
913
+ return Promise.resolve();
914
+ }
915
+ const runId = segmentRunIdRef.current;
916
+ let promise;
917
+ promise = (async () => {
918
+ setLayersLoading(true);
919
+ timeLog('▶ start loading paint shader textures');
920
+ try {
921
+ const result = await preparePaintResourcesFromWorkBuffer(work.buffer, work.cols, work.rows, layers => {
922
+ if (runId !== segmentRunIdRef.current) {
923
+ releaseFreqLayerImages(layers);
924
+ return;
925
+ }
926
+ setPaintResourceLayers(layers);
927
+ paintResourceLayersRef.current = layers;
928
+ setPaintResourcesReady(true);
929
+ timeLog('▶ paint shader textures ready');
930
+ emitInteractiveIfReady();
931
+ });
932
+ if (runId !== segmentRunIdRef.current) {
933
+ if (result) {
934
+ result.originImage.dispose();
935
+ releasePaintResourceLayers(result.layers);
936
+ }
937
+ return;
938
+ }
939
+ if (!result) {
940
+ return;
941
+ }
942
+ releaseOriginSkImage(originSkImgRef.current);
943
+ originSkImgRef.current = result.originImage;
944
+ setOriginSkImg(result.originImage);
945
+ timeLog('▶ origin Skia work resolution');
946
+ if (!paintResourceLayersRef.current) {
947
+ setPaintResourceLayers(result.layers);
948
+ paintResourceLayersRef.current = result.layers;
949
+ setPaintResourcesReady(true);
950
+ }
951
+ emitInteractiveIfReady();
952
+ }
953
+ catch (error) {
954
+ if (__DEV__) {
955
+ console.warn('[MaskSegment] failed to prepare paint shader textures', error);
956
+ }
957
+ }
958
+ finally {
959
+ setLayersLoading(false);
960
+ if (paintLayersPromiseRef.current === promise) {
961
+ paintLayersPromiseRef.current = null;
962
+ }
963
+ }
964
+ })();
965
+ paintLayersPromiseRef.current = promise;
966
+ return promise;
967
+ }, [emitInteractiveIfReady, paintResourcesReady]);
968
+ loadPaintLayersRef.current = loadPaintLayersIfNeeded;
969
+ const pickOriginImage = async () => {
970
+ const res = await launchImageLibrary({ mediaType: 'photo' });
971
+ const uri = res.assets?.[0]?.uri;
972
+ if (!uri) {
973
+ return;
974
+ }
975
+ const pngPath = await cv.ensurePngPath(uri, `picked_origin_${Date.now()}.png`);
976
+ setOriginImgPath(pngPath);
977
+ };
978
+ const pickMaskImage = async () => {
979
+ const res = await launchImageLibrary({ mediaType: 'photo' });
980
+ const uri = res.assets?.[0]?.uri;
981
+ if (!uri) {
982
+ return;
983
+ }
984
+ const pngPath = await cv.ensurePngPath(uri, `picked_mask_${Date.now()}.png`);
985
+ setMaskImgPath(pngPath);
986
+ };
987
+ const clearCacheAndResegment = useCallback(async () => {
988
+ if (isRefreshing || !originImgPath || !maskImgPath) {
989
+ return;
990
+ }
991
+ setIsRefreshing(true);
992
+ try {
993
+ resetZoom();
994
+ const layers = paintResourceLayersRef.current;
995
+ if (layers) {
996
+ releasePaintResourceLayers(layers);
997
+ paintResourceLayersRef.current = null;
998
+ setPaintResourceLayers(null);
999
+ }
1000
+ await clearDerivedImageCache();
1001
+ lastSegmentKeyRef.current = '';
1002
+ await segmentAndPrepareLayers(originImgPath, maskImgPath);
1003
+ }
1004
+ catch (e) {
1005
+ const msg = e instanceof Error ? e.message : String(e);
1006
+ setSegError(msg);
1007
+ reportError(msg, e);
1008
+ }
1009
+ finally {
1010
+ setIsRefreshing(false);
1011
+ }
1012
+ }, [
1013
+ isRefreshing,
1014
+ originImgPath,
1015
+ maskImgPath,
1016
+ segmentAndPrepareLayers,
1017
+ reportError,
1018
+ resetZoom,
1019
+ ]);
1020
+ useEffect(() => {
1021
+ if (!originImgPath || !maskImgPath) {
1022
+ return;
1023
+ }
1024
+ const segmentKey = `${originImgPath}|${maskImgPath}`;
1025
+ if (lastSegmentKeyRef.current === segmentKey ||
1026
+ segmentInFlightKeyRef.current === segmentKey) {
1027
+ return;
1028
+ }
1029
+ segmentInFlightKeyRef.current = segmentKey;
1030
+ void segmentAndPrepareLayers(originImgPath, maskImgPath).finally(() => {
1031
+ if (segmentInFlightKeyRef.current === segmentKey) {
1032
+ segmentInFlightKeyRef.current = '';
1033
+ }
1034
+ });
1035
+ return () => {
1036
+ segmentRunIdRef.current += 1;
1037
+ if (segmentInFlightKeyRef.current === segmentKey) {
1038
+ segmentInFlightKeyRef.current = '';
1039
+ }
1040
+ const layers = paintResourceLayersRef.current;
1041
+ if (layers) {
1042
+ releasePaintResourceLayers(layers);
1043
+ paintResourceLayersRef.current = null;
1044
+ }
1045
+ paintColorMapSkImgRef.current?.dispose();
1046
+ paintColorMapSkImgRef.current = null;
1047
+ regionsRef.current = [];
1048
+ // Also reset any pending init flash state (the full reset will also run at start of
1049
+ // the next segmentAndPrepareLayers, but this keeps things clean if the effect
1050
+ // re-triggers segmentation while a flash sequence was in flight).
1051
+ initFlashListRef.current = [];
1052
+ initFlashActiveRef.current = false;
1053
+ initFlashIndexRef.current = 0;
1054
+ if (initFlashTimerRef.current) {
1055
+ clearTimeout(initFlashTimerRef.current);
1056
+ initFlashTimerRef.current = null;
1057
+ }
1058
+ setInitFlashRegionId(null);
1059
+ };
1060
+ }, [originImgPath, maskImgPath]);
1061
+ const buildPaintedRecords = useCallback(() => {
1062
+ const records = [];
1063
+ // Prefer the live ref so getPaintedRegions() / session() used by host for
1064
+ // colorParams at save time sees the exact same data as the save() composite.
1065
+ const src = paintedRegionsRef.current && paintedRegionsRef.current.size > 0
1066
+ ? paintedRegionsRef.current
1067
+ : paintedRegions;
1068
+ for (const [regionId, color] of src) {
1069
+ const region = regionsRef.current.find(reg => reg.id === regionId);
1070
+ records.push({
1071
+ regionId,
1072
+ regionName: region?.name ?? String(regionId),
1073
+ color,
1074
+ configJson: paintedRegionConfigRef.current.get(regionId),
1075
+ });
1076
+ }
1077
+ return records;
1078
+ }, [paintedRegions]);
1079
+ const restoreSession = useCallback((session) => {
1080
+ const nextPainted = new Map();
1081
+ paintedRegionConfigRef.current = new Map();
1082
+ const currentRegions = regionsRef.current || [];
1083
+ const nameToRealId = new Map();
1084
+ for (const r of currentRegions) {
1085
+ if (r && r.name) {
1086
+ const key = String(r.name).trim().toLowerCase();
1087
+ if (key && !nameToRealId.has(key)) {
1088
+ nameToRealId.set(key, r.id);
1089
+ }
1090
+ }
1091
+ }
1092
+ // Build oldId -> regionName from the incoming seed (for paintHistory remapping).
1093
+ const oldIdToName = new Map();
1094
+ for (const item of session.painted || []) {
1095
+ if (item && typeof item.regionId === 'number' && item.regionName) {
1096
+ oldIdToName.set(item.regionId, String(item.regionName));
1097
+ }
1098
+ }
1099
+ // Resolve directly by name. Name is unique within a segmentation so there is no
1100
+ // need for id-based heuristics — the region name is the authoritative identity.
1101
+ for (const item of session.painted || []) {
1102
+ if (!item)
1103
+ continue;
1104
+ let targetId = item.regionId;
1105
+ if (item.regionName) {
1106
+ const key = String(item.regionName).trim().toLowerCase();
1107
+ const realId = nameToRealId.get(key);
1108
+ if (typeof realId === 'number') {
1109
+ targetId = realId;
1110
+ }
1111
+ }
1112
+ nextPainted.set(targetId, item.color);
1113
+ if (item.configJson) {
1114
+ paintedRegionConfigRef.current.set(targetId, item.configJson);
1115
+ }
1116
+ }
1117
+ // Remap paintHistory via names.
1118
+ const resolvedHistory = [];
1119
+ for (const oldId of session.paintHistory || []) {
1120
+ if (typeof oldId !== 'number')
1121
+ continue;
1122
+ const nm = oldIdToName.get(oldId);
1123
+ if (nm) {
1124
+ const real = nameToRealId.get(String(nm).trim().toLowerCase());
1125
+ if (typeof real === 'number') {
1126
+ resolvedHistory.push(real);
1127
+ continue;
1128
+ }
1129
+ }
1130
+ // Fallback: keep oldId if it happens to be valid in current segmentation.
1131
+ if (currentRegions.some((r) => r && r.id === oldId)) {
1132
+ resolvedHistory.push(oldId);
1133
+ }
1134
+ }
1135
+ paintedRegionsRef.current = nextPainted;
1136
+ setPaintedRegions(nextPainted);
1137
+ setPaintHistory(resolvedHistory.length ? resolvedHistory : [...(session.paintHistory || [])]);
1138
+ if (session.currentColor) {
1139
+ setCustomPaintColor(session.currentColor);
1140
+ customPaintConfigJsonRef.current = session.currentColorConfigJson;
1141
+ setActiveBrushIndex(null);
1142
+ }
1143
+ }, []);
1144
+ useEffect(() => {
1145
+ if (!initialSession || !segmentsReady) {
1146
+ return;
1147
+ }
1148
+ if (hasAppliedInitialSessionRef.current) {
1149
+ // Already seeded once for this canvas instance / segmentation. Do not re-apply on
1150
+ // subsequent initialSession prop changes (host may pass new object refs when its
1151
+ // vizSlots or selected brush changes). Re-applying would reset paintedRegions to
1152
+ // whatever the (often partial) seed snapshot contains.
1153
+ return;
1154
+ }
1155
+ hasAppliedInitialSessionRef.current = true;
1156
+ restoreSession(initialSession);
1157
+ }, [initialSession, segmentsReady, restoreSession]);
1158
+ useEffect(() => {
1159
+ return () => {
1160
+ if (initFlashTimerRef.current) {
1161
+ clearTimeout(initFlashTimerRef.current);
1162
+ }
1163
+ };
1164
+ }, []);
1165
+ const stopInitRegionFlash = useCallback(() => {
1166
+ initFlashActiveRef.current = false;
1167
+ if (initFlashTimerRef.current) {
1168
+ clearTimeout(initFlashTimerRef.current);
1169
+ initFlashTimerRef.current = null;
1170
+ }
1171
+ setInitFlashRegionId(null);
1172
+ }, []);
1173
+ const startInitRegionFlashLoop = useCallback(() => {
1174
+ const ir = getMaskSegmentRuntimeConfig().interaction;
1175
+ if (!ir.enableInitRegionFlash) {
1176
+ return;
1177
+ }
1178
+ if (initFlashActiveRef.current) {
1179
+ return;
1180
+ }
1181
+ const allRegions = regionsRef.current;
1182
+ if (allRegions.length === 0) {
1183
+ return;
1184
+ }
1185
+ // Filter out painted regions and tiny regions (noise / thin strips
1186
+ // that produce negligible overlays). Threshold: 0.2% of total image area.
1187
+ const imgSize = imageSizeRef2.current;
1188
+ const minFlashArea = imgSize
1189
+ ? Math.max(500, imgSize.w * imgSize.h * 0.002)
1190
+ : 500;
1191
+ initFlashListRef.current = allRegions.filter((r) => !paintedRegionsRef.current.has(r.id) && r.area >= minFlashArea);
1192
+ initFlashActiveRef.current = true;
1193
+ initFlashIndexRef.current = 0;
1194
+ const showNext = () => {
1195
+ if (!initFlashActiveRef.current || initFlashListRef.current.length === 0) {
1196
+ return;
1197
+ }
1198
+ const list = initFlashListRef.current;
1199
+ const idx = initFlashIndexRef.current;
1200
+ if (idx >= list.length) {
1201
+ // One full pass of dashed outline flashes (one per *unpainted* region) is enough.
1202
+ // Stop automatically; onUserInteraction can stop early.
1203
+ stopInitRegionFlash();
1204
+ return;
1205
+ }
1206
+ setInitFlashRegionId(list[idx].id);
1207
+ initFlashIndexRef.current += 1;
1208
+ initFlashTimerRef.current = setTimeout(showNext, ir.initRegionFlashMs);
1209
+ };
1210
+ showNext();
1211
+ }, []);
1212
+ const onUserInteraction = useCallback(() => {
1213
+ stopInitRegionFlash();
1214
+ }, [stopInitRegionFlash]);
1215
+ // Once any region has been painted, stop the entire discovery flash immediately.
1216
+ // No individual pruning — it's all or nothing: either 0 painted → cycle through
1217
+ // all regions, or ≥1 painted → stop flashing entirely.
1218
+ useEffect(() => {
1219
+ if (!initFlashActiveRef.current)
1220
+ return;
1221
+ if (paintedRegionsRef.current.size > 0) {
1222
+ stopInitRegionFlash();
1223
+ }
1224
+ }, [paintedRegions, stopInitRegionFlash]);
1225
+ useEffect(() => {
1226
+ if (segmentsReady && maskPathsReady && containRect && regionCount > 0 && canvasInteractive) {
1227
+ if (!initFlashActiveRef.current) {
1228
+ startInitRegionFlashLoop();
1229
+ }
1230
+ return;
1231
+ }
1232
+ if (!segmentsReady) {
1233
+ stopInitRegionFlash();
1234
+ setCanvasInteractive(false);
1235
+ }
1236
+ }, [
1237
+ segmentsReady,
1238
+ maskPathsReady,
1239
+ containRect,
1240
+ regionCount,
1241
+ canvasInteractive,
1242
+ startInitRegionFlashLoop,
1243
+ stopInitRegionFlash,
1244
+ ]);
1245
+ const getActiveBrushColor = useCallback(() => {
1246
+ if (customPaintColor) {
1247
+ return customPaintColor;
1248
+ }
1249
+ if (activeBrushIndex == null) {
1250
+ return null;
1251
+ }
1252
+ return paintPalette[activeBrushIndex] ?? null;
1253
+ }, [customPaintColor, activeBrushIndex, paintPalette]);
1254
+ const hasActiveBrush = customPaintColor != null || activeBrushIndex != null;
1255
+ const applyPaintToRegion = useCallback((targetRegionId, color) => {
1256
+ let applied = false;
1257
+ setPaintedRegions(prev => {
1258
+ const existing = prev.get(targetRegionId);
1259
+ if (existing && bgrColorEquals(existing, color)) {
1260
+ return prev;
1261
+ }
1262
+ applied = true;
1263
+ setPaintHistory(history => {
1264
+ const last = history[history.length - 1];
1265
+ if (last === targetRegionId) {
1266
+ return history;
1267
+ }
1268
+ return [...history.filter(id => id !== targetRegionId), targetRegionId];
1269
+ });
1270
+ const next = new Map(prev);
1271
+ next.set(targetRegionId, color);
1272
+ paintedRegionsRef.current = next;
1273
+ return next;
1274
+ });
1275
+ if (applied) {
1276
+ const configJson = customPaintConfigJsonRef.current ??
1277
+ paintedRegionConfigRef.current.get(targetRegionId);
1278
+ if (customPaintConfigJsonRef.current) {
1279
+ paintedRegionConfigRef.current.set(targetRegionId, customPaintConfigJsonRef.current);
1280
+ }
1281
+ const region = regionsRef.current.find(reg => reg.id === targetRegionId);
1282
+ onPaintCallbackRef.current?.({
1283
+ kind: 'painted',
1284
+ regionId: targetRegionId,
1285
+ regionName: region?.name ?? String(targetRegionId),
1286
+ color,
1287
+ configJson,
1288
+ });
1289
+ }
1290
+ }, []);
1291
+ const findRegionAtPoint = useCallback((x, y, strict = false) => {
1292
+ if (!segmentsReady || !imageSize || regionsRef.current.length === 0) {
1293
+ return null;
1294
+ }
1295
+ const norm = canvasToNormalized(x, y, canvasW, canvasH, imageSize.w, imageSize.h);
1296
+ if (!norm) {
1297
+ return null;
1298
+ }
1299
+ const regionPick = regionPickRef.current;
1300
+ if (regionPick) {
1301
+ const pickHit = lookupRegionFromPickMap(norm.x, norm.y, regionPick, strict
1302
+ ? 0
1303
+ : getMaskSegmentRuntimeConfig().interaction.pickMapSearchRadiusPx);
1304
+ if (pickHit != null) {
1305
+ return pickHit;
1306
+ }
1307
+ if (strict) {
1308
+ return null;
1309
+ }
1310
+ }
1311
+ const pick = maskPickRef.current;
1312
+ const kickId = kickRegionIdRef.current;
1313
+ if (!strict) {
1314
+ const polygonHit = resolveRegionHit(regionsRef.current, norm.x, norm.y);
1315
+ if (polygonHit != null) {
1316
+ return polygonHit;
1317
+ }
1318
+ }
1319
+ if (pick && kickId != null) {
1320
+ const pickMask = baseboardPickMaskRef.current;
1321
+ const kickHit = pickKickRegionFromMask(norm.x, norm.y, pick, kickId, pickMask, strict);
1322
+ if (kickHit != null) {
1323
+ return kickHit;
1324
+ }
1325
+ if (!strict) {
1326
+ const kickReg = regionsRef.current.find(reg => reg.id === kickId);
1327
+ if (kickReg && pickKickNearStrip(norm.x, norm.y, kickReg)) {
1328
+ return kickId;
1329
+ }
1330
+ }
1331
+ }
1332
+ return null;
1333
+ }, [segmentsReady, imageSize, canvasW, canvasH]);
1334
+ const selectBrushColor = useCallback((brushIndex) => {
1335
+ onUserInteraction();
1336
+ setCustomPaintColor(null);
1337
+ customPaintConfigJsonRef.current = undefined;
1338
+ setActiveBrushIndex(brushIndex);
1339
+ void loadPaintLayersIfNeeded();
1340
+ }, [onUserInteraction, loadPaintLayersIfNeeded]);
1341
+ const onCanvasTap = useCallback((x, y) => {
1342
+ onUserInteraction();
1343
+ if (!segmentsReady ||
1344
+ !imageSize ||
1345
+ regionsRef.current.length === 0 ||
1346
+ !hasActiveBrush ||
1347
+ !paintResourcesReady ||
1348
+ disabled) {
1349
+ return;
1350
+ }
1351
+ const regionId = findRegionAtPoint(x, y);
1352
+ if (regionId == null) {
1353
+ return;
1354
+ }
1355
+ const brushColor = getActiveBrushColor();
1356
+ if (!brushColor) {
1357
+ return;
1358
+ }
1359
+ applyPaintToRegion(regionId, brushColor);
1360
+ if (!paintResourcesReady && !paintLayersPromiseRef.current) {
1361
+ void loadPaintLayersIfNeeded();
1362
+ }
1363
+ }, [
1364
+ segmentsReady,
1365
+ imageSize,
1366
+ hasActiveBrush,
1367
+ paintResourcesReady,
1368
+ disabled,
1369
+ findRegionAtPoint,
1370
+ getActiveBrushColor,
1371
+ applyPaintToRegion,
1372
+ onUserInteraction,
1373
+ loadPaintLayersIfNeeded,
1374
+ ]);
1375
+ // ── Canvas-size & state refs for gesture callbacks ─────────────────────
1376
+ // Declared after their targets so useRef receives the current value.
1377
+ // canvasWRef / canvasHRef are declared earlier (before segmentAndPrepareLayers)
1378
+ // so the async body can read the latest layout size. Their sync useEffect is below.
1379
+ const hasActiveBrushRef = useRef(hasActiveBrush);
1380
+ const disabledRef = useRef(disabled);
1381
+ const segmentsReadyRef2 = useRef(segmentsReady);
1382
+ const imageSizeRef2 = useRef(imageSize);
1383
+ useEffect(() => { canvasWRef.current = canvasW; }, [canvasW]);
1384
+ useEffect(() => { canvasHRef.current = canvasH; }, [canvasH]);
1385
+ useEffect(() => { hasActiveBrushRef.current = hasActiveBrush; }, [hasActiveBrush]);
1386
+ useEffect(() => { disabledRef.current = disabled; }, [disabled]);
1387
+ useEffect(() => { segmentsReadyRef2.current = segmentsReady; }, [segmentsReady]);
1388
+ useEffect(() => { imageSizeRef2.current = imageSize; }, [imageSize]);
1389
+ // Stable refs for functions called inside gesture closures
1390
+ const findRegionAtPointRef = useRef(findRegionAtPoint);
1391
+ const onCanvasTapRef = useRef(onCanvasTap);
1392
+ useEffect(() => { findRegionAtPointRef.current = findRegionAtPoint; }, [findRegionAtPoint]);
1393
+ useEffect(() => { onCanvasTapRef.current = onCanvasTap; }, [onCanvasTap]);
1394
+ const undoSelection = useCallback(() => {
1395
+ onUserInteraction();
1396
+ setPaintHistory(history => {
1397
+ if (history.length === 0) {
1398
+ return history;
1399
+ }
1400
+ const lastId = history[history.length - 1];
1401
+ setPaintedRegions(prev => {
1402
+ const next = new Map(prev);
1403
+ next.delete(lastId);
1404
+ paintedRegionsRef.current = next; // sync for imperative readers
1405
+ return next;
1406
+ });
1407
+ paintedRegionConfigRef.current.delete(lastId);
1408
+ return history.slice(0, -1);
1409
+ });
1410
+ }, [onUserInteraction]);
1411
+ const clearAllPaint = useCallback(() => {
1412
+ onUserInteraction();
1413
+ const cleared = new Map();
1414
+ paintedRegionsRef.current = cleared;
1415
+ setPaintedRegions(cleared);
1416
+ setPaintHistory([]);
1417
+ paintedRegionConfigRef.current = new Map();
1418
+ lastExportCacheRef.current = null;
1419
+ resetZoom();
1420
+ }, [onUserInteraction, resetZoom]);
1421
+ const captureHighResExportPngBase64 = useCallback(async () => {
1422
+ try {
1423
+ const c = highResExportCanvasRef.current;
1424
+ const sz = exportCanvasSize;
1425
+ if (!c || !sz) {
1426
+ return undefined;
1427
+ }
1428
+ let snap = c.makeImageSnapshot?.();
1429
+ if (!snap) {
1430
+ await new Promise((r) => requestAnimationFrame(() => r()));
1431
+ snap = c.makeImageSnapshot?.();
1432
+ }
1433
+ if (!snap) {
1434
+ return undefined;
1435
+ }
1436
+ const enc = snap.encodeToBase64;
1437
+ if (typeof enc !== 'function') {
1438
+ return undefined;
1439
+ }
1440
+ const b64 = enc.call(snap) || '';
1441
+ return b64.length > 0 ? b64 : undefined;
1442
+ }
1443
+ catch (e) {
1444
+ console.warn('[VIZ-SAVE] highResExportCanvas makeImageSnapshot failed:', e);
1445
+ return undefined;
1446
+ }
1447
+ }, [exportCanvasSize]);
1448
+ const runExportPipeline = useCallback(async (livePainted, destDir) => {
1449
+ const work = workBufferRef.current;
1450
+ const pick = regionPickRef.current;
1451
+ if (!work || !pick) {
1452
+ throw new Error('image not ready, cannot save');
1453
+ }
1454
+ let snapshotPngBase64;
1455
+ if (livePainted.size > 0) {
1456
+ snapshotPngBase64 = await captureHighResExportPngBase64();
1457
+ }
1458
+ const layers = paintResourceLayersRef.current;
1459
+ const map = paintColorMapSkImgRef.current;
1460
+ const originForExport = originSkImgRef.current ?? layers?.lowFreqImage ?? null;
1461
+ const shaderTextures = !snapshotPngBase64 && originForExport && layers && map && paintResourcesReady
1462
+ ? {
1463
+ originImage: originForExport,
1464
+ paintColorMap: map,
1465
+ lowFreqImage: layers.lowFreqImage,
1466
+ highFreqImage: layers.highFreqImage,
1467
+ }
1468
+ : undefined;
1469
+ const result = await compositePaintedImage({
1470
+ originBuffer: work.buffer,
1471
+ cols: work.cols,
1472
+ rows: work.rows,
1473
+ pickBuffer: pick.buffer,
1474
+ paintedRegions: livePainted,
1475
+ destDir,
1476
+ ...(snapshotPngBase64 ? { exportPngBase64: snapshotPngBase64 } : {}),
1477
+ shaderTextures,
1478
+ renderWidth: work.cols,
1479
+ renderHeight: work.rows,
1480
+ });
1481
+ lastExportCacheRef.current = {
1482
+ fingerprint: paintedRegionsFingerprint(livePainted),
1483
+ result,
1484
+ };
1485
+ return result;
1486
+ }, [captureHighResExportPngBase64, paintResourcesReady]);
1487
+ // Debounced background export — pre-warms the save() cache after each paint change.
1488
+ useEffect(() => {
1489
+ if (!autoExportOnReady) {
1490
+ return;
1491
+ }
1492
+ if (!segmentsReady || !paintResourcesReady) {
1493
+ return;
1494
+ }
1495
+ if (initialSession && !hasAppliedInitialSessionRef.current) {
1496
+ return;
1497
+ }
1498
+ if (paintedRegions.size === 0) {
1499
+ return;
1500
+ }
1501
+ if (autoExportDebounceRef.current) {
1502
+ clearTimeout(autoExportDebounceRef.current);
1503
+ }
1504
+ autoExportDebounceRef.current = setTimeout(() => {
1505
+ autoExportDebounceRef.current = null;
1506
+ if (exportInFlightRef.current) {
1507
+ return;
1508
+ }
1509
+ const livePainted = paintedRegionsRef.current;
1510
+ if (!livePainted || livePainted.size === 0) {
1511
+ return;
1512
+ }
1513
+ exportInFlightRef.current = true;
1514
+ void (async () => {
1515
+ try {
1516
+ const result = await runExportPipeline(livePainted);
1517
+ onExportedRef.current?.(result);
1518
+ }
1519
+ catch (e) {
1520
+ console.log('[VIZ-SAVE] debounced autoExport threw (non-fatal):', e);
1521
+ }
1522
+ finally {
1523
+ exportInFlightRef.current = false;
1524
+ }
1525
+ })();
1526
+ }, 400);
1527
+ return () => {
1528
+ if (autoExportDebounceRef.current) {
1529
+ clearTimeout(autoExportDebounceRef.current);
1530
+ autoExportDebounceRef.current = null;
1531
+ }
1532
+ };
1533
+ }, [
1534
+ autoExportOnReady,
1535
+ segmentsReady,
1536
+ paintResourcesReady,
1537
+ initialSession,
1538
+ paintedRegions,
1539
+ runExportPipeline,
1540
+ ]);
1541
+ useImperativeHandle(ref, () => ({
1542
+ reset: undoSelection,
1543
+ swap: (showOrigin) => {
1544
+ onUserInteraction();
1545
+ if (showOrigin === undefined) {
1546
+ setCompareMode(v => !v);
1547
+ }
1548
+ else {
1549
+ setCompareMode(showOrigin);
1550
+ }
1551
+ },
1552
+ save: async (options) => {
1553
+ const livePainted = paintedRegionsRef.current && paintedRegionsRef.current.size > 0
1554
+ ? paintedRegionsRef.current
1555
+ : paintedRegions;
1556
+ const fp = paintedRegionsFingerprint(livePainted);
1557
+ const cached = lastExportCacheRef.current;
1558
+ if (cached?.fingerprint === fp && cached.result) {
1559
+ return resolveExportResultForDestDir(cached.result, options?.destDir);
1560
+ }
1561
+ try {
1562
+ return await runExportPipeline(livePainted, options?.destDir);
1563
+ }
1564
+ catch (e) {
1565
+ console.error('[VIZ-SAVE] SDK save() composite threw:', e);
1566
+ throw e;
1567
+ }
1568
+ },
1569
+ getLastExport: () => lastExportCacheRef.current?.result ?? null,
1570
+ session: () => ({
1571
+ version: 1,
1572
+ originUrl: originUrlRef.current,
1573
+ maskUrl: maskUrlRef.current,
1574
+ painted: buildPaintedRecords(),
1575
+ paintHistory: [...paintHistory],
1576
+ currentColor: customPaintColor ?? undefined,
1577
+ currentColorConfigJson: customPaintConfigJsonRef.current,
1578
+ savedAt: Date.now(),
1579
+ }),
1580
+ loadSession: restoreSession,
1581
+ setPaintColor: (color, configJson) => {
1582
+ setCustomPaintColor(color);
1583
+ customPaintConfigJsonRef.current = configJson;
1584
+ setActiveBrushIndex(null);
1585
+ },
1586
+ setMaskConfig: config => {
1587
+ setMaskSegmentRuntimeConfig({ maskConfig: config });
1588
+ runtimeRef.current = getMaskSegmentRuntimeConfig();
1589
+ if (originImgPath && maskImgPath) {
1590
+ lastSegmentKeyRef.current = '';
1591
+ void segmentAndPrepareLayers(originImgPath, maskImgPath);
1592
+ }
1593
+ },
1594
+ clearAllPaint,
1595
+ undoSelection,
1596
+ resegment: clearCacheAndResegment,
1597
+ getRegions: () => [...regionsRef.current],
1598
+ getPaintedRegions: () => buildPaintedRecords(),
1599
+ }), [
1600
+ undoSelection,
1601
+ onUserInteraction,
1602
+ paintedRegions,
1603
+ paintHistory,
1604
+ customPaintColor,
1605
+ buildPaintedRecords,
1606
+ restoreSession,
1607
+ clearAllPaint,
1608
+ clearCacheAndResegment,
1609
+ runExportPipeline,
1610
+ ]);
1611
+ const [regionOutlinePaths, setRegionOutlinePaths] = useState(new Map());
1612
+ useEffect(() => {
1613
+ if (!segmentsReady || !containRect || regionPalette.length === 0) {
1614
+ if (!segmentsReady) {
1615
+ setRegionOutlinePaths(new Map());
1616
+ setMaskPathsReady(false);
1617
+ maskPathsContainRectRef.current = null;
1618
+ }
1619
+ return;
1620
+ }
1621
+ const maskData = regionMaskDataRef.current;
1622
+ if (!maskData) {
1623
+ setRegionOutlinePaths(new Map());
1624
+ setMaskPathsReady(false);
1625
+ return;
1626
+ }
1627
+ if (maskPathsContainRectRef.current &&
1628
+ rectsEqual(maskPathsContainRectRef.current, containRect)) {
1629
+ return;
1630
+ }
1631
+ const pathStart = __DEV__ ? performance.now() : 0;
1632
+ const pathMaskData = downsampleMaskDataForPaths(maskData, getMaskSegmentRuntimeConfig().pipeline.maskPathMaxLongSide);
1633
+ const outlines = buildAllRegionOutlinePaths(regionPalette, pathMaskData, containRect);
1634
+ maskPathsContainRectRef.current = containRect;
1635
+ setRegionOutlinePaths(outlines);
1636
+ setMaskPathsReady(true);
1637
+ maskPathsReadyRef.current = true;
1638
+ emitMaskPathsReadyIfReady();
1639
+ }, [segmentsReady, containRect, regionPalette, emitMaskPathsReadyIfReady]);
1640
+ const heldOutlinePath = useMemo(() => {
1641
+ if (heldRegionId == null ||
1642
+ heldRegionAnchor == null ||
1643
+ !containRect ||
1644
+ regionPalette.length === 0) {
1645
+ return null;
1646
+ }
1647
+ const maskData = regionMaskDataRef.current;
1648
+ if (!maskData) {
1649
+ return null;
1650
+ }
1651
+ const pathMaskData = downsampleMaskDataForPaths(maskData, getMaskSegmentRuntimeConfig().pipeline.maskPathMaxLongSide);
1652
+ return buildRegionOutlinePathForRegion(heldRegionId, regionPalette, pathMaskData, containRect, heldRegionAnchor);
1653
+ }, [heldRegionId, heldRegionAnchor, containRect, regionPalette]);
1654
+ const renderImageLayer = (image, opacity = 1) => {
1655
+ if (!image || !containRect)
1656
+ return null;
1657
+ return (_jsx(SkiaImage, { image: image, x: containRect.x, y: containRect.y, width: containRect.w, height: containRect.h, opacity: opacity }));
1658
+ };
1659
+ const renderRegionMaskOverlay = (regionId, keyPrefix) => {
1660
+ const path = regionOutlinePaths.get(regionId);
1661
+ if (!path || !containRect)
1662
+ return null;
1663
+ 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}`)] }));
1664
+ };
1665
+ // ── Zoom transform for the Skia Group ─────────────────────────────────
1666
+ // Note: single-finger pan/drag is disabled. Zoom is always centered around the
1667
+ // viewport center (no additional panOffset translation). The panOffset state
1668
+ // is kept (and forced to 0 during pinch) for compatibility with screenToCanvasCoords
1669
+ // inverse mapping used by tap/paint logic, and for resetZoom.
1670
+ const zoomTransform = useMemo(() => {
1671
+ if (zoomScale <= 1)
1672
+ return undefined;
1673
+ return [
1674
+ { translateX: 0, translateY: 0 },
1675
+ { translateX: canvasW / 2, translateY: canvasH / 2 },
1676
+ { scale: zoomScale },
1677
+ { translateX: -canvasW / 2, translateY: -canvasH / 2 },
1678
+ ];
1679
+ }, [zoomScale, canvasW, canvasH]);
1680
+ const renderDraw = () => {
1681
+ const displayImg = originSkImg ?? lowFreqSkImg;
1682
+ if (!displayImg || !containRect) {
1683
+ return null;
1684
+ }
1685
+ const showOverlay = !compareMode && segmentsReady;
1686
+ const shaderReady = paintColorMapSkImg &&
1687
+ lowFreqSkImg &&
1688
+ highFreqSkImg &&
1689
+ paintResourcesReady;
1690
+ const useShader = !compareMode && paintedRegions.size > 0 && shaderReady;
1691
+ const shaderOrigin = originSkImg ?? lowFreqSkImg;
1692
+ // Background color for areas of the viewport that become visible around the
1693
+ // zoomed (centered) photo content. Using a light gray that matches common
1694
+ // preview container backgrounds prevents "mosaic-like colored blocks"
1695
+ // (shader leakage or edge sampling artifacts) from appearing outside the
1696
+ // photo rect when the content is scaled up.
1697
+ const previewBg = '#F0F1F3';
1698
+ 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 &&
1699
+ initFlashRegionId != null &&
1700
+ renderRegionMaskOverlay(initFlashRegionId, 'init-overlay'), showOverlay &&
1701
+ heldRegionId != null &&
1702
+ !paintedRegions.has(heldRegionId) &&
1703
+ renderRegionMaskOverlay(heldRegionId, 'hold-overlay')] }) })] }));
1704
+ };
1705
+ // Full-bleed (0,0 to work size) composition for the high-res export snapshot canvas.
1706
+ // No UI overlays (dashes, held, flash). When painted + shader ready we use the exact
1707
+ // same PaintShaderLayer the user sees in the editor, so makeImageSnapshot() gives
1708
+ const renderFullResPainted = () => {
1709
+ const sz = exportCanvasSize;
1710
+ if (!sz)
1711
+ return null;
1712
+ const ew = sz.w;
1713
+ const eh = sz.h;
1714
+ const shaderReady = paintColorMapSkImg &&
1715
+ lowFreqSkImg &&
1716
+ highFreqSkImg &&
1717
+ paintResourcesReady;
1718
+ const useShader = paintedRegions.size > 0 && shaderReady;
1719
+ const shaderOrigin = originSkImg ?? lowFreqSkImg;
1720
+ if (useShader && shaderOrigin) {
1721
+ return (_jsx(PaintShaderLayer, { originImage: shaderOrigin, paintColorMap: paintColorMapSkImg, lowFreqImage: lowFreqSkImg, highFreqImage: highFreqSkImg, x: 0, y: 0, width: ew, height: eh, showOrigin: false }));
1722
+ }
1723
+ // No paints yet or shader not ready — export the (scaled) origin as-is.
1724
+ const displayImg = originSkImg ?? lowFreqSkImg;
1725
+ if (displayImg) {
1726
+ return (_jsx(SkiaImage, { image: displayImg, x: 0, y: 0, width: ew, height: eh }));
1727
+ }
1728
+ return null;
1729
+ };
1730
+ // ── Gesture: tap (single-finger paint / highlight / brush_required) ────
1731
+ const tapGesture = useMemo(() => {
1732
+ const onBeginJS = (x, y) => {
1733
+ // Immediate hold highlight — fires on touch-down regardless of brush state.
1734
+ // When a brush is active, this lets the user preview which region they're
1735
+ // about to paint before lifting their finger.
1736
+ onUserInteraction();
1737
+ const coords = screenToCanvasCoords(x, y, canvasWRef.current, canvasHRef.current, zoomScaleRef.current, panOffsetRef.current);
1738
+ const regionId = findRegionAtPointRef.current(coords.x, coords.y, true);
1739
+ if (regionId == null || !imageSizeRef2.current) {
1740
+ setHeldRegionId(null);
1741
+ setHeldRegionAnchor(null);
1742
+ return;
1743
+ }
1744
+ if (paintedRegionsRef.current && paintedRegionsRef.current.has(regionId)) {
1745
+ setHeldRegionId(null);
1746
+ setHeldRegionAnchor(null);
1747
+ return;
1748
+ }
1749
+ const norm = canvasToNormalized(coords.x, coords.y, canvasWRef.current, canvasHRef.current, imageSizeRef2.current.w, imageSizeRef2.current.h);
1750
+ setHeldRegionId(regionId);
1751
+ setHeldRegionAnchor(norm);
1752
+ };
1753
+ const onEndJS = (x, y, success) => {
1754
+ if (!success)
1755
+ return;
1756
+ const coords = screenToCanvasCoords(x, y, canvasWRef.current, canvasHRef.current, zoomScaleRef.current, panOffsetRef.current);
1757
+ if (hasActiveBrushRef.current) {
1758
+ onCanvasTapRef.current(coords.x, coords.y);
1759
+ }
1760
+ else {
1761
+ // Brush_required
1762
+ onUserInteraction();
1763
+ if (disabledRef.current || !segmentsReadyRef2.current || !imageSizeRef2.current)
1764
+ return;
1765
+ const regionId = findRegionAtPointRef.current(coords.x, coords.y);
1766
+ if (regionId == null)
1767
+ return;
1768
+ const region = regionsRef.current.find(r => r.id === regionId);
1769
+ onPaintCallbackRef.current?.({
1770
+ kind: 'brush_required',
1771
+ hint: '请先选择笔刷颜色',
1772
+ regionId,
1773
+ regionName: region?.name ?? String(regionId),
1774
+ });
1775
+ }
1776
+ };
1777
+ const onFinalizeJS = (x, y) => {
1778
+ // Cleanup hold highlight (replaces onCanvasPressOut)
1779
+ if (hasActiveBrushRef.current) {
1780
+ setHeldRegionId(null);
1781
+ setHeldRegionAnchor(null);
1782
+ return;
1783
+ }
1784
+ const coords = screenToCanvasCoords(x, y, canvasWRef.current, canvasHRef.current, zoomScaleRef.current, panOffsetRef.current);
1785
+ onCanvasTapRef.current(coords.x, coords.y);
1786
+ setHeldRegionId(null);
1787
+ setHeldRegionAnchor(null);
1788
+ };
1789
+ return Gesture.Tap()
1790
+ .onBegin((e) => {
1791
+ 'worklet';
1792
+ runOnJS(onBeginJS)(e.x, e.y);
1793
+ })
1794
+ .onEnd((e, success) => {
1795
+ 'worklet';
1796
+ runOnJS(onEndJS)(e.x, e.y, success);
1797
+ })
1798
+ .onFinalize((e) => {
1799
+ 'worklet';
1800
+ runOnJS(onFinalizeJS)(e.x, e.y);
1801
+ });
1802
+ }, [onUserInteraction]);
1803
+ // ── Gesture: pinch-zoom (two-finger scale; max 5×) ─────────────────────
1804
+ const pinchGesture = useMemo(() => {
1805
+ const onStartJS = () => {
1806
+ zoomBaseRef.current = zoomScaleRef.current;
1807
+ };
1808
+ const onUpdateJS = (scale) => {
1809
+ const newScale = Math.max(1, Math.min(zoomBaseRef.current * scale, 5));
1810
+ setZoomScale(newScale);
1811
+ zoomScaleRef.current = newScale;
1812
+ // Lock pan offset to zero: only two-finger pinch zoom is supported.
1813
+ // Single-finger drag/pan after zoom is intentionally disabled per product decision.
1814
+ // Zoom is always centered; no additional translate from panOffset is applied in practice.
1815
+ setPanOffset({ x: 0, y: 0 });
1816
+ panOffsetRef.current = { x: 0, y: 0 };
1817
+ };
1818
+ const onEndJS = () => {
1819
+ if (zoomScaleRef.current <= 1.01) {
1820
+ resetZoom();
1821
+ }
1822
+ };
1823
+ return Gesture.Pinch()
1824
+ .onStart(() => {
1825
+ 'worklet';
1826
+ runOnJS(onStartJS)();
1827
+ })
1828
+ .onUpdate((e) => {
1829
+ 'worklet';
1830
+ runOnJS(onUpdateJS)(e.scale);
1831
+ })
1832
+ .onEnd(() => {
1833
+ 'worklet';
1834
+ runOnJS(onEndJS)();
1835
+ });
1836
+ }, [resetZoom]);
1837
+ // ── Composed: Race ensures single-tap and pinch-zoom never conflict ─────────
1838
+ const composedGesture = useMemo(() => Gesture.Race(tapGesture, pinchGesture), [tapGesture, pinchGesture]);
1839
+ return (_jsxs(View, { style: [styles.container, style], onLayout: (e) => {
1840
+ const { width, height } = e.nativeEvent.layout;
1841
+ setLayoutWidth(width);
1842
+ setLayoutHeight(height);
1843
+ }, 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
1844
+ ? customPaintColor
1845
+ ? 'custom paint color'
1846
+ : `paint color ${(activeBrushIndex ?? 0) + 1}`
1847
+ : '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) => {
1848
+ const isActive = activeBrushIndex === index && customPaintColor == null;
1849
+ const { b, g, r } = color;
1850
+ return (_jsx(TouchableOpacity, { style: [
1851
+ styles.colorSwatch,
1852
+ { backgroundColor: bgrToCss(b, g, r) },
1853
+ isActive && styles.colorSwatchSelected,
1854
+ ], activeOpacity: 0.8, disabled: !segmentsReady || disabled, onPress: () => selectBrushColor(index) }, index));
1855
+ }), !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 &&
1856
+ (renderUndoButton ? (renderUndoButton({
1857
+ onPress: undoSelection,
1858
+ disabled: paintHistory.length === 0,
1859
+ text: undoButtonText,
1860
+ })) : (_jsx(TouchableOpacity, { style: [styles.overlayBtn, styles.btnBottomLeft, undoButtonStyle], activeOpacity: 0.7, disabled: paintHistory.length === 0 || disabled, onPress: undoSelection, children: _jsx(Text, { style: [
1861
+ styles.btnText,
1862
+ undoButtonTextStyle,
1863
+ { opacity: paintHistory.length === 0 ? 0.4 : 1 },
1864
+ ], children: undoButtonText }) }))), showOverlayButtons &&
1865
+ (renderCompareButton ? (renderCompareButton({
1866
+ onPress: () => {
1867
+ onUserInteraction();
1868
+ setCompareMode(v => !v);
1869
+ },
1870
+ text: compareMode ? compareExitButtonText : compareButtonText,
1871
+ })) : (_jsx(TouchableOpacity, { style: [
1872
+ styles.overlayBtn,
1873
+ styles.btnBottomRight,
1874
+ compareButtonStyle,
1875
+ ], activeOpacity: 0.7, disabled: disabled, onPress: () => {
1876
+ onUserInteraction();
1877
+ setCompareMode(v => !v);
1878
+ }, children: _jsx(Text, { style: [styles.btnText, compareButtonTextStyle], children: compareMode ? compareExitButtonText : compareButtonText }) })))] }) }), highResSnapshotEnabled && exportCanvasSize ? (_jsx(View, { style: {
1879
+ position: 'absolute',
1880
+ left: -exportCanvasSize.w - 200,
1881
+ top: 0,
1882
+ width: exportCanvasSize.w,
1883
+ height: exportCanvasSize.h,
1884
+ opacity: 0,
1885
+ overflow: 'hidden',
1886
+ }, pointerEvents: "none", children: _jsx(Canvas, { ref: highResExportCanvasRef, style: { width: exportCanvasSize.w, height: exportCanvasSize.h }, pointerEvents: "none", children: _jsx(Group, { children: renderFullResPainted() }) }) })) : null] }));
1887
+ });
1888
+ const styles = StyleSheet.create({
1889
+ container: {
1890
+ flex: 1,
1891
+ backgroundColor: '#fff',
1892
+ },
1893
+ scroll: {
1894
+ flex: 1,
1895
+ },
1896
+ scrollContent: {
1897
+ padding: 10,
1898
+ paddingBottom: 28,
1899
+ },
1900
+ toolbarRow: {
1901
+ marginBottom: 8,
1902
+ },
1903
+ btnRow: {
1904
+ flexDirection: 'row',
1905
+ marginBottom: 8,
1906
+ },
1907
+ btnGap: {
1908
+ width: 10,
1909
+ },
1910
+ statusRow: {
1911
+ marginBottom: 8,
1912
+ },
1913
+ hint: {
1914
+ color: '#333',
1915
+ fontSize: 13,
1916
+ },
1917
+ err: {
1918
+ color: '#c33',
1919
+ fontSize: 13,
1920
+ },
1921
+ canvasWrap: {
1922
+ position: 'relative',
1923
+ backgroundColor: '#f5f5f5',
1924
+ borderWidth: StyleSheet.hairlineWidth,
1925
+ borderColor: '#ddd',
1926
+ overflow: 'hidden',
1927
+ },
1928
+ canvasOuter: {
1929
+ alignItems: 'center',
1930
+ justifyContent: 'center',
1931
+ // When the internal control ScrollView is omitted (the normal case for
1932
+ // VisualizationScreen and non-interactive scheme previews), this makes the
1933
+ // canvas area fill the bounds provided by the host (via style + maxHeight)
1934
+ // and centers the fixed-size image rect. This also ensures the
1935
+ // GestureDetector sits in the right place to receive taps and two-finger pinches.
1936
+ flex: 1,
1937
+ },
1938
+ canvasTouchLayer: {
1939
+ flex: 1,
1940
+ },
1941
+ canvas: {
1942
+ flex: 1,
1943
+ },
1944
+ overlayBtn: {
1945
+ position: 'absolute',
1946
+ bottom: 10,
1947
+ paddingHorizontal: 14,
1948
+ paddingVertical: 7,
1949
+ backgroundColor: 'rgba(0, 0, 0, 0.62)',
1950
+ borderRadius: 6,
1951
+ },
1952
+ btnBottomLeft: {
1953
+ left: 10,
1954
+ },
1955
+ btnBottomRight: {
1956
+ right: 10,
1957
+ },
1958
+ btnText: {
1959
+ color: '#fff',
1960
+ fontSize: 13,
1961
+ fontWeight: '600',
1962
+ },
1963
+ colorBar: {
1964
+ marginTop: 12,
1965
+ paddingVertical: 10,
1966
+ paddingHorizontal: 4,
1967
+ borderTopWidth: StyleSheet.hairlineWidth,
1968
+ borderTopColor: '#e0e0e0',
1969
+ },
1970
+ colorBarLabel: {
1971
+ fontSize: 12,
1972
+ color: '#666',
1973
+ marginBottom: 10,
1974
+ },
1975
+ colorSwatches: {
1976
+ flexDirection: 'row',
1977
+ flexWrap: 'wrap',
1978
+ alignItems: 'center',
1979
+ gap: 12,
1980
+ },
1981
+ colorSwatch: {
1982
+ width: 44,
1983
+ height: 44,
1984
+ borderRadius: 22,
1985
+ borderWidth: 2,
1986
+ borderColor: 'rgba(0,0,0,0.12)',
1987
+ alignItems: 'center',
1988
+ justifyContent: 'center',
1989
+ },
1990
+ colorSwatchSelected: {
1991
+ borderColor: '#1e96ff',
1992
+ borderWidth: 3,
1993
+ transform: [{ scale: 1.08 }],
1994
+ },
1995
+ colorSwatchPainted: {
1996
+ borderColor: 'rgba(255,255,255,0.9)',
1997
+ },
1998
+ colorSwatchDot: {
1999
+ width: 8,
2000
+ height: 8,
2001
+ borderRadius: 4,
2002
+ backgroundColor: 'rgba(255,255,255,0.92)',
2003
+ borderWidth: 1,
2004
+ borderColor: 'rgba(0,0,0,0.25)',
2005
+ },
2006
+ colorBarEmpty: {
2007
+ fontSize: 13,
2008
+ color: '#999',
2009
+ },
2010
+ });
2011
+ export default MaskSegmentCanvas;
2012
+ //# sourceMappingURL=MaskSegmentCanvas.js.map