# react-native-mask-segment-canvas 基于 React Native **0.79** 的掩码分区交互库,核心导出 `MaskSegmentCanvas` 组件,可通过 **npm 包** 或 **npm link** 接入其它 RN 工程。 - **OpenCV**(`react-native-fast-opencv`):掩码语义布局、踢脚线修补、分区提取 - **Skia RuntimeEffect(SkSL)**:原图 + LAB 高低频纹理叠色(单次全屏 Shader) - **Skia Path**:分区虚线轮廓高亮 - **交互**:底部笔刷选色(可选初始化)→ 点击分区上色;未选笔刷时点击分区会通过 `onPaintCallback` 提示;未选笔刷时长按预览分区虚线轮廓 本仓库同时作为 **库源码**(`src/index.ts`)与 **自测 Demo**(根目录 `App.tsx`)维护。 **推荐的集成演示请查看 `example/` 目录**:它只使用公开 API,完整模拟业务项目接入方式(含 `package.json`、Metro 配置、完整可参考的 `App.tsx`)。 --- ## 作为 npm 包接入其它工程 ### 安装依赖(宿主工程) ```bash npm install react-native-mask-segment-canvas # 或本地联调 npm link ../MaskSegmentApp # 在库目录先执行 npm link npm link react-native-mask-segment-canvas ``` 宿主工程还需安装 **peerDependencies**(版本需与宿主 RN 对齐): ```bash npm install @shopify/react-native-skia react-native-reanimated react-native-fast-opencv react-native-fs buffer upng-js # 若使用 showDebugPickers 相册选图 npm install react-native-image-picker ``` ### 宿主工程 postinstall(必需) 本库依赖 `patch-package` 修补 `react-native-fast-opencv`,宿主 `package.json` 需配置: ```json { "scripts": { "postinstall": "patch-package" }, "devDependencies": { "patch-package": "^8.0.1" } } ``` 安装本库后,`node_modules/react-native-mask-segment-canvas/patches/` 中的补丁会在宿主 `postinstall` 时自动应用。 ### iOS / Android 原生依赖 ```bash cd ios && pod install && cd .. ``` 确保宿主已按各原生库文档完成 Skia、Reanimated、OpenCV 等配置。 ### Metro 配置(npm link / monorepo / file: 依赖时) 联调时若出现模块解析问题,在宿主 `metro.config.js` 中把本库加入 `watchFolders`,并使用下面推荐的完整配置(同时包含 extraNodeModules + blockList)。这是防止所有「类似重复模块问题」(SkiaPictureView undefined、Reanimated Animated node already exists 等)的可靠做法: ```js const path = require('path'); module.exports = { watchFolders: [path.resolve(__dirname, '../MaskSegmentApp')], resolver: { nodeModulesPaths: [path.resolve(__dirname, 'node_modules')], // 推荐同时使用 extraNodeModules + blockList extraNodeModules: { 'react-native-reanimated': path.resolve(__dirname, 'node_modules/react-native-reanimated'), '@shopify/react-native-skia': path.resolve(__dirname, 'node_modules/@shopify/react-native-skia'), 'react-native-gesture-handler': path.resolve(__dirname, 'node_modules/react-native-gesture-handler'), 'react-native-fast-opencv': path.resolve(__dirname, 'node_modules/react-native-fast-opencv'), 'react-native-safe-area-context': path.resolve(__dirname, 'node_modules/react-native-safe-area-context'), 'react-native-fs': path.resolve(__dirname, 'node_modules/react-native-fs'), }, blockList: [ /\/MaskSegmentApp\/node_modules\/@shopify\/react-native-skia\//, /\/MaskSegmentApp\/node_modules\/react-native-reanimated\//, /\/MaskSegmentApp\/node_modules\/react-native-fast-opencv\//, /\/MaskSegmentApp\/node_modules\/react-native-gesture-handler\//, /\/MaskSegmentApp\/node_modules\/react-native-safe-area-context\//, /\/MaskSegmentApp\/node_modules\/react-native-fs\//, ], }, }; ``` **强烈建议**在宿主 `index.js` 最顶部加入(在任何业务代码之前): ```js import '@shopify/react-native-skia'; ``` (完整推荐配置见下文「故障排查」以及 `example/metro.config.js` + `example/index.js`,那里有覆盖全部 peer 的 singletons 列表。) ### 故障排查:各种重复模块导致的运行时错误 常见症状(同类问题): - `SkiaPictureView must be a function (received 'undefined')` - `createAnimatedNode: Animated node[...] already exists` **几乎总是**因为 Metro 同时解析到多份 reanimated / skia / gesture-handler / fast-opencv / safe-area 等包。 **最佳实践**: - 直接复制 `example/metro.config.js` 里的 `singletonPackages` + extraNodeModules + blockList 写法 - 在你的 `index.js` 最顶部加入 gesture-handler → reanimated → skia 三个 import - 重启 Metro (`--reset-cache`) + 重装 app 详细清单和模板见 `example/README.md` 的「运行时出现类似错误」一节。 ### 业务侧引入 ```tsx import MaskSegmentCanvas, { type MaskSegmentCanvasRef, type MaskSegmentSession, type MaskSegmentWatchState, type MaskSegmentWatchDetail, type BgrColor, type MaskSemanticColor, type PaintCallbackPayload, type PaintedRegionRecord, type PipelineConfig, type MaskSegmentConfig, type PaintConfig, type InteractionConfig, type SavePaintResult, MASK_SEMANTIC_COLORS, BASEBOARD_SEMANTIC_NAME, prewarmPngBgrCacheAsync, DEFAULT_PIPELINE_CONFIG, DEFAULT_MASK_CONFIG, DEFAULT_PAINT_CONFIG, DEFAULT_INTERACTION_CONFIG, } from 'react-native-mask-segment-canvas'; ``` 主要导出一览: | 类别 | 名称 | | ---- | ---- | | 组件 | `MaskSegmentCanvas`(default) | | Ref / Props 类型 | `MaskSegmentCanvasRef`、`MaskSegmentCanvasProps` | | 会话 / 回调类型 | `MaskSegmentSession`、`PaintCallbackPayload`、`PaintedRegionRecord`、`SavePaintResult` | | Watch 类型 | `MaskSegmentWatchState`、`MaskSegmentWatchDetail` | | 配置类型 | `PipelineConfig`、`MaskSegmentConfig`、`PaintConfig`、`InteractionConfig` | | 语义色 | `MASK_SEMANTIC_COLORS`、`BASEBOARD_SEMANTIC_NAME` | | 工具 | `prewarmPngBgrCacheAsync`、`prewarmPngBgrCache` | | 运行时 | `DEFAULT_*_CONFIG`、`getMaskSegmentRuntimeConfig`、`setMaskSegmentRuntimeConfig` | --- ## 推荐:通过 example/ 目录学习集成 `example/` 是**专门为业务侧集成准备的演示文件夹**,它: - 只通过 `import ... from 'react-native-mask-segment-canvas'` 使用公开 API(不碰内部 src) - 提供了独立的 `package.json`(含 peer deps + 本地 file 依赖) - 包含针对本地联调的 `metro.config.js` - `App.tsx` 是一个可直接参考的完整页面,涵盖预热、状态管理、ref 操作、回调处理等 建议: 1. 直接阅读 `example/App.tsx` 获取最新可运行的集成写法。 2. 按 `example/README.md` 的步骤在本机跑起来,验证安装、patch、Metro 配置是否正确。 3. 把 `example/App.tsx` 中的核心逻辑复制到你自己的页面/组件中即可。 这样可以确保你接入的是「库的公开契约」,而不是内部实现细节。 --- ## 环境要求 - Node.js >= 18(推荐 20+) - Xcode 15+(iOS) - Android Studio + JDK 17(Android) - CocoaPods(iOS) ## 快速开始(本仓库 Demo) 根目录 `App.tsx` 是库作者自测用的完整 Demo,内部直接 import `./src`。 ```bash cd MaskSegmentApp npm install cd ios && bundle exec pod install && cd .. npm start # 另开终端 npm run ios # 或 npm run android ``` **想看「纯业务项目如何集成」**:请进入 `example/` 目录,按其 `README.md` 操作。它使用 `import from 'react-native-mask-segment-canvas'` + 标准的 `package.json` + Metro 配置,完全模拟消费者环境。 Demo 入口 `App.tsx` 通过 `./src`(即包入口 `src/index.ts`)引用组件,与业务侧 `import from 'react-native-mask-segment-canvas'` 等价。 --- ## MaskSegmentCanvas 组件 ### 引入 ```tsx import React, { useRef } from 'react'; import MaskSegmentCanvas, { type MaskSegmentCanvasRef, type MaskSegmentSession, type MaskSegmentWatchState, type MaskSegmentWatchDetail, type BgrColor, type MaskSemanticColor, type PaintCallbackPayload, MASK_SEMANTIC_COLORS, prewarmPngBgrCacheAsync, } from 'react-native-mask-segment-canvas'; ``` 也可按需导入运行时默认值(与组件 Props 合并使用): ```tsx import { DEFAULT_PIPELINE_CONFIG, DEFAULT_MASK_CONFIG, DEFAULT_PAINT_CONFIG, DEFAULT_INTERACTION_CONFIG, } from 'react-native-mask-segment-canvas'; ``` ### 最小示例 下面是一个可直接放进业务页面的完整示例,涵盖 **PNG 预热**、**state**、**配置**、**加载态**、**onWatch** 与 **ref** 常用操作。 ```tsx import React, { useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Text, View } from 'react-native'; import MaskSegmentCanvas, { type MaskSegmentCanvasRef, type MaskSegmentSession, type MaskSegmentWatchState, type BgrColor, MASK_SEMANTIC_COLORS, prewarmPngBgrCacheAsync, } from 'react-native-mask-segment-canvas'; /** 业务侧准备好的图片地址(本地 file:// 或 http(s)://) */ type ImagePaths = { origin: string; mask: string; }; const INTERACTIVE_STATES: MaskSegmentWatchState[] = [ 'interactive', 'mask_paths_ready', ]; export function PaintScreen() { const canvasRef = useRef(null); const [imagePaths, setImagePaths] = useState(null); const [pathsError, setPathsError] = useState(''); const [watchState, setWatchState] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const [sessionDraft] = useState(null); const isInteractive = INTERACTIVE_STATES.includes( watchState as MaskSegmentWatchState, ); const isOutlineReady = watchState === 'mask_paths_ready'; const isCanvasLoading = imagePaths != null && watchState !== '' && !INTERACTIVE_STATES.includes(watchState as MaskSegmentWatchState) && watchState !== 'error'; // 示例:接口下载完成后写入路径,并预热 PNG 解码缓存 useEffect(() => { let cancelled = false; (async () => { try { const origin = 'file:///path/to/origin.png'; const mask = 'file:///path/to/mask.png'; await prewarmPngBgrCacheAsync([origin, mask]); if (!cancelled) { setImagePaths({ origin, mask }); } } catch (e) { if (!cancelled) { setPathsError(e instanceof Error ? e.message : String(e)); } } })(); return () => { cancelled = true; }; }, []); const handleSave = async () => { if (!isInteractive) return; const result = await canvasRef.current?.save({ destDir: undefined }); console.log('saved', result?.filePath, result?.paintedCount); }; if (pathsError) { return {pathsError}; } if (!imagePaths) { return ( 等待原图与掩码… ); } return ( {isCanvasLoading ? 加载中:{watchState} : null} {watchState === 'interactive' ? ( 可上色(轮廓加载中…) ) : null} {isOutlineReady ? 就绪 : null} {errorMessage ? {errorMessage} : null} { setWatchState(state); // detail: regionCount, maskPathsReady, freqLayersReady, errorMessage console.log('[onWatch]', state, durationMs, detail); }} onPaintCallback={payload => { if (payload.kind === 'brush_required') { toast(payload.hint); return; } console.log('painted', payload.regionId, payload.regionName, payload.color, payload.configJson); }} onError={message => { setErrorMessage(message); setWatchState('error'); }} /> {/* ref 示例:disabled={!isInteractive} 时可按需绑定 */} {/*