feat: 自定义图像渲染 + 选区框绘制 + 拖动功能

主要更改:
- 重写前端 extension.js,完全自己控制图像渲染
- 去掉 nodes.py 的 image_upload,避免双图像问题
- 实现选区框绘制(橙色)+ 遮罩区域显示(红色)
- 实现选区框拖动功能
- 修复 onMouseDown 参数格式: (e, pos, canvas)
- 更新文档和开发日志
This commit is contained in:
InpaintRegionEditor Dev
2026-03-02 22:31:54 +08:00
parent 59b8ea0c56
commit 23c748bcf0
11 changed files with 3374 additions and 421 deletions
+224
View File
@@ -0,0 +1,224 @@
# InpaintRegionEditor 项目探测报告
> 探测时间:2026-02-28
> 探测模式:ULW 探测
---
## 一、项目概况
| 属性 | 详情 |
|------|------|
| **项目名称** | InpaintRegionEditor |
| **项目类型** | ComfyUI Custom Node |
| **许可证** | MIT |
| **主要功能** | 集成 Photopea 进行高级 inpaint 编辑,支持遮罩和选区 |
---
## 二、目录结构
```
E:\ComfyUI\custom_nodes\InpaintRegionEditor\
├── __init__.py # 节点注册入口 (18 行)
├── nodes.py # 后端节点定义 (173 行)
├── README.md # 项目文档
├── CHANGELOG.md # 变更日志
├── DEVLOG.md # 开发日志
├── LICENSE # MIT 许可证
├── .gitignore # Git 忽略配置
│
├── web/ # 前端扩展目录
│ └── extension.js # 前端扩展入口 (301 行)
│
└── docs/ # 文档目录
├── InpaintRegionEditor_Implementation_Plan.md # 实现计划 (1190 行)
└── plans/
└── 2026-02-27-inpaint-region-editor-design.md # 设计文档 (869 行)
```
---
## 三、核心文件分析
### 3.1 `__init__.py` - 节点注册入口
```python
from .nodes import InpaintRegionEditor
NODE_CLASS_MAPPINGS = {
"InpaintRegionEditor": InpaintRegionEditor
}
NODE_DISPLAY_NAME_MAPPINGS = {
"InpaintRegionEditor": "Inpaint Region Editor"
}
WEB_DIRECTORY = "./web"
```
**关键点**:
- 注册了 `InpaintRegionEditor` 节点
- 指定了 `WEB_DIRECTORY = "./web"` 用于前端扩展
### 3.2 `nodes.py` - 后端节点定义
**节点功能**:
- 输入: 图像 + 选区尺寸预设/自定义尺寸
- 输出: image, mask, region_top, region_left, region_width, region_height
**核心概念**:
- **遮罩 (Mask)**: 定义重绘区域
- **选区 (Region)**: 定义参考区域
- **约束**: 选区必须 ≥ 遮罩
**预设尺寸**:
- 512×512 (SD)
- 768×768
- 1024×1024 (SDXL)
- 1280×1280
- 1536×1536
### 3.3 `web/extension.js` - 前端扩展
**核心模块**:
1. **PhotopeaBridge** - Photopea 通信桥接
- `postMessage()` - 与 Photopea iframe 通信
- `openImage()` - 打开图像到 Photopea
- `exportImage()` - 导出图像
- `exportSelectionAsMask()` - 从选区创建蒙版
2. **PhotopeaModal** - 全屏对话框 UI
- 创建 Photopea iframe 模态框
- 加载图像、保存图像/蒙版
- 上传到 ComfyUI
**安全措施**:
- 消息来源验证 (`PHOTOPEA_ORIGIN`)
- 60秒超时处理
---
## 四、技术架构
```
┌─────────────────────────────────────────────────────────────┐
│ ComfyUI 前端 │
│ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ extension.js ││
│ │ ├── PhotopeaBridge (通信桥接) ││
│ │ └── PhotopeaModal (UI 模态框) ││
│ └─────────────────────────────────────────────────────────┘│
│ ↕ │
│ postMessage API │
│ ↕ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ <iframe src="https://www.photopea.com/"> ││
│ │ Photopea 完整应用 ││
│ │ - 液化、仿制图章等 PS 功能 ││
│ │ - 选区/蒙版支持 ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
↕
POST /upload/image
↕
┌─────────────────────────────────────────────────────────────┐
│ ComfyUI 后端 │
│ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ nodes.py - InpaintRegionEditor 节点 ││
│ │ - 图像加载和处理 ││
│ │ - 遮罩边界计算 ││
│ │ - 选区位置计算 ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
```
---
## 五、工作流程
```
1. 用户添加 InpaintRegionEditor 节点
2. 上传图像
3. 右键 → "Open in Photopea"
4. 在 Photopea 中:
a. 编辑图像(液化、仿制图章等)- 可选
b. 用选择工具创建选区(作为蒙版区域)
5. 点击 "保存图像和蒙版"
6. 节点输出:
- 编辑后的图像
- 蒙版
- 选区坐标 (top, left, width, height)
```
---
## 六、当前状态
### 已完成 ✅
- 基础架构:Python 后端 + JS 前端
- Photopea iframe 集成
- 图像加载和导出功能
- 右键菜单 → "Open in Photopea"
- 保存图像上传到 ComfyUI
- 从选区导出蒙版功能
### 待完善 🔧
- 图层蒙版导出优化(见 DEVLOG.md)
- 大图像性能优化
- 更多测试
### 已知问题 ⚠️
- Layer mask export script needs real-world testing
- Large images may have performance issues
- First Photopea load requires internet (~10MB)
---
## 七、依赖关系
### 后端依赖 (Python)
- torch
- numpy
- PIL (Pillow)
- folder_paths (ComfyUI 内置)
### 前端依赖 (JavaScript)
- ComfyUI 前端 API (`app`, `api`)
- Photopea (https://www.photopea.com/)
---
## 八、法律与合规
| 项目 | 状态 |
|------|------|
| Photopea API 使用 | ✅ 完全免费,可商用 |
| 作品归属 | ✅ 用户所有 |
| 标注要求 | ❌ 不强制(但推荐) |
| 广告 | ⚠️ 免费版会显示广告 |
---
## 九、参考资源
- Photopea 官网: https://www.photopea.com
- Photopea API 文档: https://www.photopea.com/api
- sd-webui-photopea-embed: https://github.com/yankooliveira/sd-webui-photopea-embed
- ComfyUI 前端仓库: https://github.com/Comfy-Org/ComfyUI_frontend
---
## 十、下一步建议
1. **测试完善**: 进行更多实际使用测试
2. **性能优化**: 大图像处理优化
3. **文档完善**: 用户使用指南
4. **发布准备**: 准备 ComfyUI Manager 发布
---
*报告生成时间: 2026-02-28*
+22 -14
View File
@@ -5,26 +5,34 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.0.0] - 2026-03-02
### Added
- Initial implementation of InpaintRegionEditor node
- Photopea iframe integration for image editing
- Right-click menu "Open in Photopea" on node
- Load image + mask to Photopea (mask as layer mask)
- Export image + mask from Photopea
- Region size presets (512×512, 1024×1024, etc.)
- PostMessage API with origin verification for security
- **后端重构**:`padding` 参数替代 `region_size` 预设
- **蒙版错误处理**:无 Alpha 通道时抛出友好错误提示
- **选区坐标同步**:`region_coords` hidden widget 支持前端拖动
- **选区框 Canvas UI**:在节点上绘制遮罩(红色)+ 选区框(橙色虚线)
- **拖动交互**:支持鼠标拖动选区位置
- **Photopea 集成**:右键菜单 → 打开 Photopea 编辑
- **图像加载**:使用 `&channel=rgb` 获取纯 RGB 图像
- **图像导出**:PNG 格式导出并上传到 ComfyUI
### Changed
- 参数设计:`region_size/width/height` → `padding`(扩散像素数)
- 选区计算:固定尺寸 → 遮罩边界 + padding
- 错误处理:返回空蒙版 → 抛出 ValueError
### Technical Details
- Backend: Python node with image upload and region presets
- Frontend: Photopea iframe with postMessage communication
- Security: Origin-verified messages (only from photopea.com)
- **后端**:`nodes.py` - 蒙版提取 + 选区计算 + 坐标同步
- **前端**:`region-box-editor.js` - Canvas 绘制 + 拖动交互
- **前端**:`extension.js` - Photopea postMessage API
- **安全**:Origin 验证(仅接受 photopea.com 消息)
### Known Issues
- Layer mask export script needs real-world testing
- Large images may have performance issues
- First Photopea load requires internet (~10MB)
- Photopea `layer.mask` API 返回 null(Issue #7341),无法直接导出图层蒙版
- 备选方案:用户上传带 Alpha 的 PNG 或使用选区 workaround
- 大图片(>2048×2048)可能导致 Photopea 卡顿
- 首次加载 Photopea 需要网络连接(~10MB)
## [0.1.0] - 2026-02-27
+264
View File
@@ -0,0 +1,264 @@
# InpaintRegionEditor 开发日志
## 2026-03-02 重大进展
### 完成的工作
1. **完全重写前端图像渲染**
- 不再依赖 ComfyUI 的 `image_upload` 和 `node.imgs`
- 自己加载图像、自己绘制
- 去掉了 `nodes.py` 中的 `image_upload: True`
2. **选区框绘制正常工作**
- 橙色选区框 + 红色遮罩区域
- 标签显示尺寸
3. **拖动功能实现**
- 关键发现:`onMouseDown(e, pos, canvas)` 的 `pos` 参数直接就是节点本地坐标
- 不需要手动转换 `e.canvasX`
- 返回 `true` 可以阻止节点被拖动
### 遇到的问题
1. **双图像问题**:ComfyUI 自动画了一个图,我又画了一个 → 去掉 `image_upload: True`
2. **黑边问题**:用黑色覆盖图像区域导致边框难看 → 直接画图像,不覆盖
3. **拖动不工作**:鼠标事件参数格式错误
- 错误:`onMouseDown(e)` 以为 `e.canvasX` 是画布坐标
- 正确:`onMouseDown(e, pos, canvas)`,`pos[0], pos[1]` 是节点本地坐标
### 待解决问题
1. 拖动体验优化(边界检测、视觉反馈)
2. 缩放/放大图像查看
3. Photopea 编辑后的图像刷新
### 代码结构
```
extension.js:
- loadImageAndDetectMask() - 加载图像并检测 Alpha 通道
- drawNode() - 绘制图像和选区框
- getImageDrawParams() - 计算图像绘制参数
- onMouseDown/Move/Up() - 拖动交互
nodes.py:
- INPUT_TYPES 去掉 image_upload
- process() 从 Alpha 提取 MASK
```
---
## 2026-02-28 研究结论(重要!续)
### 问题:发送给 Photopea 的图片带透明通道,导致遮罩混乱
**现象**:
- 发送给 Photopea 的图片有透明区域
- Photopea 保存后,LoadImage 节点的 MASK 输出变成乱码
**根因分析**:
1. LoadImage 节点处理流程(nodes.py:1732):
```python
image = i.convert("RGB") # 丢弃 Alpha,返回纯 RGB
```
→ 这时候的 IMAGE 是**纯 RGB**,没有透明!
2. 但 `/view` API(server.py:528-546)默认行为:
```python
if 'channel' not in request.rel_url.query:
channel = 'rgba' # 默认返回 RGBA!
```
→ 即使 LoadImage 输出的是纯 RGB,前端用 `/view?filename=...` 获取时,默认返回的是**原始文件**(可能带 Alpha)
3. 解决方案:加 `&channel=rgb` 参数
```
/view?filename=xxx&type=input&channel=rgb
```
→ 服务器会用 Pillow 处理:
```python
if channel == 'rgb':
with Image.open(file) as img:
if img.mode == "RGBA":
r, g, b, a = img.split()
new_img = Image.merge('RGB', (r, g, b)) # 丢弃 Alpha
else:
new_img = img.convert("RGB")
new_img.save(buffer, format='PNG')
```
**最终代码修改**(extension.js):
```javascript
// 错误的做法(默认返回 RGBA):
var url = "/view?filename=" + encodeURIComponent(filename) + "&type=" + encodeURIComponent(type);
// 正确的做法(返回纯 RGB):
var url = "/view?filename=" + encodeURIComponent(filename) + "&type=" + encodeURIComponent(type) + "&channel=rgb";
```
### 重要教训
1. **LoadImage 输出的 IMAGE 确实是纯 RGB**(Pillow convert("RGB") 处理过)
2. **但是 `/view` API 默认返回原始文件**(可能带 Alpha)
3. **必须加 `&channel=rgb` 参数** 才能获取纯 RGB 图片
---
## 2026-02-28 研究结论(重要!)
### 核心发现:ComfyUI 蒙版数据格式
## 2026-02-28 研究结论(重要!)
### 核心发现:ComfyUI 蒙版数据格式
通过分析 ComfyUI 源码,彻底理解了蒙版格式:
#### IMAGE 类型
```python
# nodes.py LoadImage.load_image()
image = i.convert("RGB") # IMAGE 不含 Alpha!
# Shape: (B, H, W, 3), 值 [0, 1]
```
#### MASK 类型
```python
# nodes.py:1743-1745
if 'A' in i.getbands():
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask) # 反转!
# Shape: (B, H, W), 值 [0, 1]
# 1 = 遮罩区域, 0 = 背景
```
#### PNG Alpha → MASK 转换
```
PNG Alpha = 0 (透明) → mask = 1.0 (遮罩区域)
PNG Alpha = 255 (不透明) → mask = 0.0 (背景)
```
#### /upload/mask API (server.py:462-474)
```python
original_pil = original_pil.convert('RGBA')
mask_pil = Image.open(image.file).convert('RGBA')
new_alpha = mask_pil.getchannel('A') # 取上传图像的 Alpha
original_pil.putalpha(new_alpha) # 替换原图 Alpha
```
### MaskToImage / ImageToMask 源码 (comfy_extras/nodes_mask.py)
```python
# MaskToImage: MASK → IMAGE
result = mask.reshape((-1, 1, H, W)).movedim(1, -1).expand(-1, -1, -1, 3)
# ImageToMask: IMAGE → MASK
mask = image[:, :, :, channels.index(channel)]
```
### Photopea 关键发现
#### maskFromTransparency() 有 BUG - 会丢失透明区域的 RGB 数据
#### 正确方法:loadTransparentPixels() + addMask()
```javascript
doc.selection.loadTransparentPixels(); // 选择非透明区域
layer.addMask(true); // 创建图层蒙版
```
#### Photopea 蒙版语义 vs ComfyUI
| 系统 | 白色 | 黑色 |
|------|------|------|
| Photopea 蒙版 | 显示(非遮罩) | 隐藏(遮罩) |
| ComfyUI Mask | 遮罩区域 | 背景 |
### 完整数据流
```
传入图像 → 直接打开PNG → loadTransparentPixels + addMask → 图像完整 + 蒙版在图层右侧
导出蒙版 → 复制蒙版数据 → 反转(蒙版黑色=遮罩 → Alpha=0)→ PNG(RGB=0, Alpha)
上传蒙版 → POST /upload/mask → 服务端用蒙版Alpha替换原图Alpha
```
### 代码位置
- ComfyUI 核心: `E:\ComfyUI\nodes.py`, `E:\ComfyUI\server.py`
- 蒙版节点: `E:\ComfyUI\comfy_extras\nodes_mask.py`
- 参考实现: `E:\ComfyUI\custom_nodes\ComfyUI-Impact-Pack\js\impact-sam-editor.js`
---
## 2026-02-27
### 已完成
- 基础架构:Python 后端 + JS 前端
- Photopea iframe 集成
- 右键菜单 → Open in Photopea
### 技术参考
- Photopea Masks: https://www.photopea.com/learn/masks
### Photopea Layer Mask API (2026-02-28 更新)
#### 关键发现:layer.mask 返回 null - 已知限制
**Issue #7341** (2024年11月) 确认:layer masks 无法通过脚本 API 访问
```javascript
// 永远返回 null
var mask = layer.mask;
```
#### 但有 Workaround!
用户手动测试发现:`doc.activeChannel = layer.mask` 实际上能工作!
```javascript
// 虽然 layer.mask 返回 null,但这行代码实际能工作
doc.activeChannel = layer.mask;
```
#### 正确方案
用 try-catch 包裹,直接尝试导出:
```javascript
var exportScript =
'(function(){' +
' try {' +
' var doc = app.activeDocument;' +
' var layer = doc.activeLayer;' +
' var origChannel = doc.activeChannel;' +
' ' +
' doc.activeChannel = layer.mask;' +
' doc.selection.selectAll();' +
' doc.selection.copy();' +
' var newDoc = app.documents.add(doc.width, doc.height);' +
' app.activeDocument = newDoc;' +
' newDoc.paste();' +
' newDoc.saveToOE("png");' +
' newDoc.close(SaveOptions.DONOTSAVECHANGES);' +
' ' +
' app.activeDocument = doc;' +
' doc.activeChannel = origChannel;' +
' ' +
' app.echoToOE("mask_exported");' +
' } catch(e) {' +
' app.echoToOE("no_mask:" + e.message);' +
' }' +
'})();';
```
#### 替代方案(如果上面不行)
用 visibility 切换导出带蒙版的图层:
```javascript
// 隐藏所有图层,只显示当前层(蒙版会自动应用)
for (var i = 0; i < app.activeDocument.layers.length; i++) {
app.activeDocument.layers[i].visible = false;
}
app.activeDocument.activeLayer.visible = true;
// 导出 - 蒙版已经渲染进 PNG
app.activeDocument.saveToOE("png");
```
+113 -48
View File
@@ -3,81 +3,146 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![ComfyUI](https://img.shields.io/badge/ComfyUI-Custom%20Node-blue.svg)](https://github.com/comfyanonymous/ComfyUI)
A ComfyUI custom node that integrates [Photopea](https://www.photopea.com/) (a free online Photoshop alternative) for advanced inpaint editing with mask support.
**增强版遮罩编辑器**:集成 Photopea 图像编辑 + 可拖动选区调整
## Features
> ⚠️ **开发中** | 本文档描述的是目标功能,当前版本功能有限
>
> 📋 **完整技术文档**:见 [`docs/HANDOFF.md`](docs/HANDOFF.md)
- 🎨 **Full Photopea Integration** - Complete Photoshop-like editing capabilities
- 📐 **Region Presets** - Pre-configured sizes for SD (512×512), SDXL (1024×1024)
- 🔄 **Bidirectional Sync** - Loads and saves both images and masks
- 🎭 **Layer Mask Support** - Masks are loaded as Photopea layer masks
---
## Installation
## 📚 文档索引
### Method 1: ComfyUI Manager (Recommended)
Search for "Inpaint Region Editor" in ComfyUI Manager.
| 文档 | 说明 | 目标读者 |
|------|------|---------|
| [`docs/QUICKSTART.md`](docs/QUICKSTART.md) | ⚡ 5 分钟快速开始 | 新接手开发的工程师 |
| [`docs/HANDOFF.md`](docs/HANDOFF.md) | 📋 完整交接文档 | 开发团队 |
| [`docs/implementation-plan.md`](docs/implementation-plan.md) | 💻 详细实现方案 | 后端/前端开发 |
| [`docs/technical-validation.md`](docs/technical-validation.md) | 🧪 技术验证清单 | 测试工程师 |
| [`DEVLOG.md`](DEVLOG.md) | 📔 开发日志 | 了解历史问题 |
---
## 🎯 功能特性
- 🎨 **Photopea 集成** - 完整的 Photoshop 级编辑功能(液化、仿制图章等)
- 📐 **可拖动选区** - 手动调整参考区域位置(不再只是中心扩散)
- 🔄 **自动蒙版检测** - 从 Alpha 通道提取,无蒙版时友好报错
- 🎭 **选区自动计算** - 选区 = 遮罩边界 + 扩散像素数
---
## 📦 安装
### 方法 1:ComfyUI Manager(推荐)
在 ComfyUI Manager 中搜索 "Inpaint Region Editor"。
### 方法 2:手动安装
### Method 2: Manual Installation
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/YOUR_USERNAME/ComfyUI-InpaintRegionEditor.git
```
Restart ComfyUI.
重启 ComfyUI。
## Usage
---
1. Add the **Inpaint Region Editor** node
2. Upload an image
3. Right-click the node → **"Open in Photopea"**
4. Edit your image and create/edit the mask
5. Click **"Save Image and Mask"**
## 🚀 使用方法
## Node Outputs
1. 添加 **Inpaint Region Editor** 节点
2. 上传图像(需要带 Alpha 通道的 PNG)
3. 右键点击节点 → **"打开 Photopea 编辑"**
4. 在 Photopea 中编辑图像和蒙版
5. 保存返回节点
6. 调整扩散像素数或拖动选区框
7. 执行工作流
| Output | Type | Description |
|--------|------|-------------|
| `image` | IMAGE | Edited image |
| `mask` | MASK | Inpaint mask |
| `region_top` | INT | Region top coordinate |
| `region_left` | INT | Region left coordinate |
| `region_width` | INT | Region width |
| `region_height` | INT | Region height |
---
## Concept: Mask vs Region
## 📤 节点输出
| Concept | Purpose |
|---------|---------|
| **Mask** | Define inpaint area (what to repaint) |
| **Region** | Define reference area (AI context) |
| 输出 | 类型 | 说明 |
|------|------|------|
| `image` | IMAGE | 编辑后的图像 |
| `mask` | MASK | 局部重绘蒙版 |
| `region_top` | INT | 选区顶部坐标 |
| `region_left` | INT | 选区左侧坐标 |
| `region_width` | INT | 选区宽度 |
| `region_height` | INT | 选区高度 |
**Constraint**: Region size must be ≥ mask size.
---
## Technical Details
## 📘 核心概念
- Uses Photopea [postMessage API](https://www.photopea.com/api/)
- All message origins are verified for security
- Images processed locally in browser
| 概念 | 目的 | 说明 |
|------|------|------|
| **遮罩 (Mask)** | 定义重绘区域 | AI 在这个区域内生成新内容 |
| **选区 (Region)** | 定义参考区域 | AI 参考这个区域内的原图内容 |
## Limitations
**约束**:选区必须 >= 遮罩
- Requires internet (Photopea loaded from CDN)
- First load ~10MB, then cached
---
## Acknowledgments
## ⚠️ 当前状态
### 已完成
- [x] 基础节点逻辑(`nodes.py`)
- [x] Photopea iframe 集成
- [x] 右键菜单打开 Photopea
- [x] 图像预览(自己渲染,不依赖 ComfyUI)
- [x] 选区框绘制(橙色)+ 遮罩区域显示(红色)
- [x] Alpha 通道自动检测蒙版
- [x] 选区框拖动功能
### 待完善
- [ ] 拖动体验优化(边界检测、视觉反馈)
- [ ] 图像缩放/放大查看
- [ ] Photopea 编辑后的图像刷新
- [ ] 完整工作流测试
---
## 🔧 技术细节
- 使用 Photopea [postMessage API](https://www.photopea.com/api/)
- 所有消息 origin 都经过验证(只接受 `https://www.photopea.com`)
- 图像处理在浏览器本地完成
---
## ⚡ 限制
- 需要网络连接(Photopea 从 CDN 加载)
- 首次加载约 10MB,之后缓存
- Photopea 无法通过脚本直接访问图层蒙版(官方 API 限制)
---
## 🙏 致谢
- [Photopea](https://www.photopea.com/) by Ivan Kutskir
- [ComfyUI](https://github.com/comfyanonymous/ComfyUI)
- [ComfyUI-Impact-Pack](https://github.com/ltdrdata/ComfyUI-Impact-Pack) - 选区框实现参考
## License
---
MIT License - see [LICENSE](LICENSE) file.
## 📄 许可证
### Photopea Usage
- Free API for any purpose (including commercial)
- Your work belongs entirely to you
- No attribution required (but appreciated)
MIT License - 详见 [LICENSE](LICENSE) 文件。
## Changelog
### Photopea 使用条款
- API 完全免费,可用于任何目的(包括商业用途)
- 你的作品完全归你所有
- 无需标注使用了 Photopea(但建议标注)
See [CHANGELOG.md](CHANGELOG.md) for details.
---
## 📝 更新日志
详见 [CHANGELOG.md](CHANGELOG.md)。
---
*最后更新:2026-03-02*
+525
View File
@@ -0,0 +1,525 @@
# InpaintRegionEditor 项目交接文档
> 版本:1.0 | 日期:2026-03-02
> 交接对象:开发团队
> 交接人:项目经理 Agent
---
## 一、项目概述
### 1.1 项目定位
**InpaintRegionEditor** 是一个 ComfyUI 自定义节点,用于解决局部重绘(inpaint)工作流中的两个核心问题:
1. **图像编辑**:ComfyUI 自身没有图像编辑功能,需要集成 Photopea 实现液化、仿制图章等 PS 级功能
2. **选区调整**:原有的扩散选区功能无法手动调整位置,导致边缘参考区域错误
### 1.2 核心价值
```
原工作流痛点:
├─ 蒙版编辑:ComfyUI 自带工具功能有限,无法处理复杂蒙版(羽化、透明度等)
├─ 图像编辑:完全没有,需要外部 PS 处理后再上传
└─ 选区调整:只能中心扩散,无法手动移动位置
本节点解决方案:
├─ 集成 Photopea:完整的 Photoshop 功能,浏览器内完成编辑
├─ 可拖动选区:手动调整参考区域位置,确保参考内容准确
└─ 一体化流程:上传 → 编辑 → 选区调整 → 执行,无需切换工具
```
### 1.3 节点功能
```
输入:
- image: 从 ComfyUI input 目录选择(支持上传)
- padding: 扩散像素数(默认 64,范围 0-512)
自动处理:
1. 读取图像 Alpha 通道作为 MASK
2. 没有 Alpha → 报错 "请先创建蒙版"
3. 有 Alpha → 计算蒙版边界框
4. 选区 = 蒙版边界 + padding
5. 在图像预览上绘制遮罩 + 选区框(可拖动)
用户操作:
- 右键 → "打开 Photopea 编辑":编辑图像 + 蒙版
- 拖动选区框:手动调整参考区域位置
- 调整 padding 值:改变扩散像素数
输出:
- IMAGE: RGB 图像(不含 Alpha)
- MASK: 从 Alpha 通道提取
- region_top, region_left, region_width, region_height: 选区坐标
```
---
## 二、核心需求确认
### 2.1 蒙版来源(三种)
1. **用户上传**:带 Alpha 通道的 PNG 文件
2. **Photopea 编辑**:在 Photopea 里绘制蒙版后保存
3. **系统工具**:使用 ComfyUI 自带蒙版工具绘制后保存
### 2.2 核心逻辑
```python
if 没有蒙版(Alpha 通道):
执行时报错:"请先创建蒙版(局部重绘需要蒙版)"
else:
自动计算选区 = 蒙版边界 + padding
在预览上绘制遮罩(半透明红)+ 选区框(橙色虚线)
用户可以拖动选区框位置
输出 region_* 坐标
```
### 2.3 选区概念
| 概念 | 定义 | 作用 |
|------|------|------|
| **遮罩 (Mask)** | 需要重绘的区域(可能是不规则形状) | AI 在这个区域内生成新内容 |
| **选区 (Region)** | 参考区域(矩形框) | AI 参考这个区域内的原图内容进行重绘 |
**约束**:选区必须 >= 遮罩(选区要完全包含遮罩)
**选区计算逻辑**:
```
选区边界 = 遮罩边界框 + padding(向四周扩散)
用户可拖动选区位置,但选区必须始终框住遮罩
```
---
## 三、技术架构
### 3.1 文件结构
```
ComfyUI-InpaintRegionEditor/
├── __init__.py # 节点注册
├── nodes.py # 后端节点逻辑
├── requirements.txt # 无额外依赖
│
├── web/
│ ├── extension.js # 扩展入口 + 右键菜单
│ ├── photopea-bridge.js # Photopea 通信桥接
│ ├── photopea-modal.js # Photopea 全屏对话框
│ ├── region-box-editor.js # 选区框编辑器(Canvas + 拖动)
│ └── style.css # 样式
│
└── docs/
├── technical-validation.md # 技术验证清单
├── implementation-plan.md # 详细实现方案
└── HANDOFF.md # 本文档
```
### 3.2 整体架构
```
┌─────────────────────────────────────────────────────────────────────┐
│ ComfyUI 前端 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ InpaintRegionEditor 节点 │ │
│ │ │ │
│ │ ┌───────────────────────────────────────────────────────┐ │ │
│ │ │ 图像预览区域 │ │ │
│ │ │ ┌───────────────────────────────────────────────┐ │ │ │
│ │ │ │ [图像] │ │ │ │
│ │ │ │ ┌──────────────┐ │ │ │ │
│ │ │ │ │ ████████████ │ ← 遮罩(半透明红) │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ 虚线框 │ │ ← 选区(橙色虚线,可拖动) │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ ████████████ │ │ │ │ │
│ │ │ │ └──────────────┘ │ │ │ │
│ │ │ └───────────────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ 控件: │ │ │
│ │ │ - 图像:[image.png ▼] [上传] │ │ │
│ │ │ - 扩散像素:[64] ▼ │ │ │
│ │ │ - [🎨 打开 Photopea 编辑] │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ↕ │
│ 隐藏 widget: region_coords (JSON) │
│ ↕ │
└─────────────────────────────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────────────────────────────┐
│ ComfyUI 后端 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ nodes.py: InpaintRegionEditor.process() │
│ 1. 加载图像(从 input 目录) │
│ 2. 提取 Alpha 通道 → MASK │
│ 3. 计算遮罩边界框 │
│ 4. 计算选区 = 遮罩边界 + padding │
│ 5. 验证:选区 >= 遮罩 │
│ 6. 输出:IMAGE, MASK, region_* │
│ │
└─────────────────────────────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────────────────────────────┐
│ Photopea │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ - 通过 iframe 嵌入(全屏模态框) │
│ - postMessage API 通信 │
│ - 功能:图像编辑 + 蒙版绘制 │
│ - 导出:PNG 图像 + 黑白蒙版图 │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### 3.3 数据流
```
1. 用户上传图像
└─▶ POST /upload/image → input/image.png
2. 节点加载图像
└─▶ 读取 Alpha 通道 → MASK
└─▶ 计算遮罩边界 → 选区
└─▶ 前端绘制:遮罩 + 选区框
3. 用户编辑(可选)
└─▶ 右键 → 打开 Photopea
└─▶ 编辑图像/蒙版
└─▶ 保存 → 上传新文件
└─▶ 更新节点 widget.value
4. 用户调整选区(可选)
└─▶ 拖动选区框
└─▶ 更新 region_coords (hidden widget)
5. 执行工作流
└─▶ 后端读取图像 + region_coords
└─▶ 输出:IMAGE, MASK, region_*
```
---
## 四、关键技术点
### 4.1 蒙版处理
**提取逻辑**(nodes.py):
```python
if 'A' in img.getbands():
mask = np.array(img.getchannel('A')).astype(np.float32) / 255.0
mask = 1.0 - mask # ComfyUI 约定:1=遮罩区域,0=背景
else:
raise ValueError("请先创建蒙版")
```
**格式约定**:
- PNG Alpha = 0(透明) → mask = 1.0(遮罩区域)
- PNG Alpha = 255(不透明) → mask = 0.0(背景)
### 4.2 选区计算
```python
def _calculate_region_rect(mask_bounds, padding):
"""根据遮罩边界 + padding 计算选区"""
return {
'x': mask_bounds['x'] - padding,
'y': mask_bounds['y'] - padding,
'width': mask_bounds['width'] + padding * 2,
'height': mask_bounds['height'] + padding * 2
}
```
**约束验证**:
```python
if (region_rect['width'] < mask_bounds['width'] or
region_rect['height'] < mask_bounds['height']):
raise ValueError("选区必须 >= 遮罩")
```
### 4.3 Photopea 集成
**通信机制**:
```javascript
// 发送消息到 Photopea
photopeaWindow.postMessage('app.activeDocument.saveToOE("png");', "*");
// 接收响应(验证 origin)
window.addEventListener("message", (event) => {
if (event.origin !== "https://www.photopea.com") return;
// 处理响应...
});
```
**待验证功能**:
1. 能否单独导出蒙版?(`layer.mask` 已知返回 null)
2. 能否同时加载原图 + 蒙版?
3. 能否检测用户是否画了蒙版?
**参考 workaround**(DEVLOG 中提到):
```javascript
// 虽然 layer.mask 返回 null,但这行代码可能工作
doc.activeChannel = layer.mask;
doc.selection.selectAll();
doc.selection.copy();
```
### 4.4 选区框绘制
**参考实现**:`ComfyUI-Impact-Pack/js/mask-rect-area.js`
**核心功能**:
- 自定义 Canvas Widget
- 绘制遮罩(半透明红)
- 绘制选区框(橙色虚线)
- 鼠标拖动交互
- 同步坐标到 widget
---
## 五、待验证技术点
### 5.1 高优先级(决定方案可行性)
| 编号 | 测试项 | 目的 | 状态 |
|------|-------|------|------|
| P1 | Photopea 同时加载原图 + 蒙版 | 确定工作流 | ❓ 待测试 |
| P2 | Photopea 单独导出蒙版 | 确定保存逻辑 | ❓ 待测试 |
| P3 | Photopea 检测是否有蒙版 | 确定错误提示 | ❓ 待测试 |
### 5.2 中优先级(影响用户体验)
| 编号 | 测试项 | 目的 | 状态 |
|------|-------|------|------|
| M1 | Canvas 选区框绘制 | 参考 Impact-Pack | ✅ 有参考代码 |
| M2 | 拖动交互实现 | 鼠标事件监听 | ✅ 有参考代码 |
| M3 | 坐标同步机制 | hidden widget | ✅ 已验证 |
### 5.3 低优先级(可迭代优化)
| 编号 | 测试项 | 目的 | 状态 |
|------|-------|------|------|
| L1 | 两次导出上传 | 图像 + 蒙版分开保存 | ❓ 待测试 |
| L2 | 后端合成 PNG | 两张图合成带 Alpha 的 PNG | ✅ 简单 |
| L3 | UI 样式优化 | 美观度提升 | - 后期 |
---
## 六、实现路线图
### Phase 1:后端基础(1 天)
**任务**:
- [ ] 更新 `nodes.py` 实现核心逻辑
- [ ] 测试蒙版提取(从 Alpha 通道)
- [ ] 测试选区计算(遮罩边界 + padding)
- [ ] 测试错误提示(没有蒙版时报错)
**交付物**:
- 可运行的后端节点
- 单元测试脚本
### Phase 2:选区 UI(2 天)
**任务**:
- [ ] 实现 `region-box-editor.js`
- [ ] 在节点上绘制遮罩预览
- [ ] 在节点上绘制选区框(虚线)
- [ ] 实现拖动交互
- [ ] 约束验证(选区 >= 遮罩)
**参考**:`ComfyUI-Impact-Pack/js/mask-rect-area.js`
**交付物**:
- 可在节点上显示并拖动选区框
### Phase 3:Photopea 集成(2-3 天)
**任务**:
- [ ] 实现 `photopea-modal.js`
- [ ] 实现 `photopea-bridge.js`
- [ ] 测试图像加载(`&channel=rgb` 参数)
- [ ] 测试图像导出
- [ ] **测试蒙版导出**(关键!)
- [ ] 上传回 ComfyUI
**交付物**:
- 可打开 Photopea 编辑
- 可保存回节点
### Phase 4:集成测试(1 天)
**任务**:
- [ ] 完整工作流测试
- [ ] 修复 bug
- [ ] 编写 README
**交付物**:
- 可发布的完整版本
---
## 七、已知风险与备选方案
### 7.1 Photopea API 限制
**问题**:`layer.mask` 总是返回 `null`(官方已知,Issue #7341)
**影响**:无法通过脚本访问图层蒙版
**备选方案**:
1. 用 workaround 尝试导出(`doc.activeChannel = layer.mask`)
2. 如果不行,用户只能用其他方式创建蒙版(系统工具/上传)
3. 或者在 Photopea 里手动合成到 Alpha 通道后导出
### 7.2 蒙版语义差异
**问题**:
- Photopea 蒙版:白色 = 显示(非遮罩),黑色 = 隐藏(遮罩)
- ComfyUI Mask:白色 = 遮罩区域,黑色 = 背景
**解决**:导出时反转
### 7.3 性能问题
**问题**:大图片(>2048x2048)可能导致 Photopea 卡顿
**解决**:
- 提示用户使用合适尺寸
- 可选:前端缩放后编辑
---
## 八、参考资源
### 8.1 代码参考
| 文件 | 位置 | 用途 |
|------|------|------|
| `mask-rect-area.js` | `E:\ComfyUI\custom_nodes\ComfyUI-Impact-Pack\js\` | 选区框绘制参考 |
| `nodes.py` (LoadImage) | `E:\ComfyUI\nodes.py` (line 1702) | 图像加载参考 |
| `server.py` | `E:\ComfyUI\server.py` | 上传 API 参考 |
### 8.2 文档参考
| 文档 | 说明 |
|------|------|
| [Photopea API](https://www.photopea.com/api/) | 官方 API 文档 |
| [technical-validation.md](./technical-validation.md) | 技术验证清单 |
| [implementation-plan.md](./implementation-plan.md) | 详细实现方案 |
| [DEVLOG.md](../DEVLOG.md) | 开发日志(包含历史问题记录) |
### 8.3 外部参考
| 项目 | 说明 |
|------|------|
| [sd-webui-photopea-embed](https://github.com/yankooliveira/sd-webui-photopea-embed) | Stable Diffusion WebUI 的 Photopea 集成 |
| [ComfyUI-Impact-Pack](https://github.com/ltdrdata/ComfyUI-Impact-Pack) | 选区框实现参考 |
---
## 九、团队协作建议
### 9.1 分工建议
**后端开发**(1 人):
- `nodes.py` 实现
- 蒙版提取逻辑
- 选区计算逻辑
**前端开发**(1 人):
- Canvas 选区框绘制
- 拖动交互实现
- Photopea 集成
**测试**(1 人):
- Photopea 功能验证
- 完整工作流测试
- Bug 报告
### 9.2 沟通要点
1. **Photopea 验证结果** 决定技术路线
- 如果能单独导出蒙版 → 方案 A
- 如果不能 → 方案 B(用户手动合成到 Alpha)
2. **选区 UI 复杂度** 可能被低估
- 建议先用 Impact-Pack 代码验证可行性
- 再考虑自定义需求
3. **错误提示要友好**
- 没有蒙版时告诉用户三种创建方式
- 选区太小时提示最小尺寸
---
## 十、验收标准
### 10.1 功能验收
- [ ] 用户上传带 Alpha 的 PNG → 正确显示遮罩 + 选区
- [ ] 用户上传不带 Alpha 的 PNG → 执行时报错(友好提示)
- [ ] 拖动选区框 → 坐标实时更新
- [ ] 调整 padding → 选区自动重算
- [ ] 打开 Photopea → 可以编辑图像
- [ ] Photopea 保存 → 图像同步到节点
- [ ] 输出 region_* → 下游节点可用
### 10.2 性能验收
- [ ] 加载 1024x1024 图像 < 2 秒
- [ ] 拖动选区框流畅(60fps)
- [ ] Photopea 打开 < 5 秒(首次加载缓存后)
### 10.3 兼容性验收
- [ ] Windows / Linux / macOS
- [ ] Chrome / Firefox / Edge
- [ ] ComfyUI 最新版本
---
## 十一、联系与反馈
### 11.1 问题记录
开发过程中遇到的任何问题,请记录到:
- `docs/DEVLOG.md`(开发日志)
- GitHub Issues(如果有仓库)
### 11.2 关键决策记录
任何偏离本方案的技术决策,请记录:
- 决策内容
- 决策原因
- 决策日期
---
## 十二、附录
### 12.1 术语表
| 术语 | 定义 |
|------|------|
| **Mask(遮罩)** | 定义重绘区域,AI 在这个区域内生成新内容 |
| **Region(选区)** | 定义参考区域,AI 参考这个区域内的原图内容 |
| **Padding(扩散)** | 选区相对于遮罩边界的扩散像素数 |
| **Alpha 通道** | PNG 的透明度通道,用于存储蒙版 |
### 12.2 版本历史
| 版本 | 日期 | 说明 |
|------|------|------|
| 0.1 | 2026-02-27 | 初始版本(简化版,仅 Photopea 集成) |
| 1.0 | 2026-03-02 | 需求最终确认,编写 handoff 文档 |
---
*文档创建时间:2026-03-02*
*基于与用户的多次讨论整理*
*交接完成标志:开发团队确认收到并理解本文档*
+242
View File
@@ -0,0 +1,242 @@
# 快速开始指南
> 给接手团队的 5 分钟快速了解
---
## 1. 这个项目是做什么的?
**一句话**:ComfyUI 局部重绘的增强编辑器
**三个功能**:
1. **图像编辑**:集成 Photopea(在线 PS),实现液化、仿制图章等功能
2. **蒙版编辑**:在 Photopea 里画复杂的蒙版(羽化、透明度等)
3. **选区调整**:手动拖动参考区域位置(原有功能只能中心扩散)
---
## 2. 核心需求(必须实现)
```
用户上传图像 → 自动检测蒙版(Alpha 通道)
↓
没有蒙版?→ 报错:"请先创建蒙版"
↓
有蒙版?→ 自动计算选区 = 遮罩边界 + padding
↓
在图像预览上绘制:
- 遮罩(半透明红)
- 选区框(橙色虚线,可拖动)
↓
输出:IMAGE, MASK, region_top, region_left, region_width, region_height
```
**关键点**:
- 蒙版从 Alpha 通道提取
- 没蒙版就报错(局部重绘必须有蒙版)
- 选区是矩形框,必须框住遮罩
- 用户可以拖动选区位置
---
## 3. 文件结构
```
InpaintRegionEditor/
├── nodes.py # ⭐ 后端核心(优先实现)
├── __init__.py # 节点注册
│
├── web/
│ ├── extension.js # 前端入口
│ ├── region-box-editor.js # ⭐ 选区框绘制(优先实现)
│ ├── photopea-modal.js # Photopea 对话框
│ └── photopea-bridge.js # Photopea 通信
│
└── docs/
├── HANDOFF.md # ⭐ 完整交接文档(最重要)
├── implementation-plan.md # 详细实现方案
└── technical-validation.md # 技术验证清单
```
---
## 4. 开发顺序建议
### 第一步:后端基础(1 天)
```bash
# 修改 nodes.py
1. 实现 INPUT_TYPES(image + padding 参数)
2. 实现 process 函数:
- 加载图像
- 提取 Alpha → MASK
- 计算遮罩边界
- 计算选区 = 遮罩边界 + padding
- 输出 region_*
3. 测试:上传带 Alpha 的 PNG → 验证输出
```
### 第二步:选区 UI(2 天)
```bash
# 创建 region-box-editor.js
参考:ComfyUI-Impact-Pack/js/mask-rect-area.js
功能:
1. 在节点 Canvas 上绘制遮罩(半透明红)
2. 在节点 Canvas 上绘制选区框(橙色虚线)
3. 实现鼠标拖动选区框
4. 同步坐标到 hidden widget
```
### 第三步:Photopea 集成(2-3 天)
```bash
# 创建 photopea-modal.js + photopea-bridge.js
功能:
1. 右键菜单 → 打开全屏 Photopea
2. 加载图像到 Photopea
3. 导出图像回传
4. ⚠️ 导出蒙版(待验证,可能无法实现)
```
---
## 5. 技术要点速查
### 5.1 蒙版提取(Python)
```python
if 'A' in img.getbands():
mask = np.array(img.getchannel('A')).astype(np.float32) / 255.0
mask = 1.0 - mask # 反转:1=遮罩区域
else:
raise ValueError("请先创建蒙版")
```
### 5.2 选区计算(Python)
```python
def _calculate_region_rect(mask_bounds, padding):
return {
'x': mask_bounds['x'] - padding,
'y': mask_bounds['y'] - padding,
'width': mask_bounds['width'] + padding * 2,
'height': mask_bounds['height'] + padding * 2
}
```
### 5.3 Photopea 通信(JavaScript)
```javascript
// 发送
photopeaWindow.postMessage('app.activeDocument.saveToOE("png");', "*");
// 接收(验证 origin)
window.addEventListener("message", (event) => {
if (event.origin !== "https://www.photopea.com") return;
// 处理...
});
```
### 5.4 图像加载(JavaScript)
```javascript
// ⚠️ 关键:加 &channel=rgb 获取纯 RGB
const response = await fetch(`/view?filename=${imagePath}&type=input&channel=rgb`);
```
---
## 6. 待验证的技术点
### 高优先级(决定方案)
- [ ] **P1**: Photopea 能否单独导出蒙版?
- [ ] **P2**: Photopea 能否同时加载原图 + 蒙版?
**验证方法**:
```javascript
// 在 Photopea 控制台测试
app.activeDocument.activeLayer.mask; // 返回 null(已知限制)
// Workaround
doc.activeChannel = layer.mask; // 可能工作
```
**备选方案**:
- 如果不能导出蒙版 → 用户用其他方式创建(系统工具/上传)
---
## 7. 参考代码位置
| 功能 | 参考文件 | 路径 |
|------|---------|------|
| 选区框绘制 | `mask-rect-area.js` | `E:\ComfyUI\custom_nodes\ComfyUI-Impact-Pack\js\` |
| 图像加载 | `nodes.py` (LoadImage) | `E:\ComfyUI\nodes.py` (line 1702) |
| 上传 API | `server.py` | `E:\ComfyUI\server.py` |
---
## 8. 常见错误
### 错误 1:没有蒙版
```python
# ❌ 错误:返回全黑蒙版
mask = torch.zeros((64,64), dtype=torch.float32, device="cpu")
# ✅ 正确:报错
raise ValueError("请先创建蒙版(局部重绘需要蒙版)")
```
### 错误 2:选区小于遮罩
```python
# ❌ 错误:静默修正
region_width = max(region_width, mask_width)
# ✅ 正确:报错
if region_width < mask_width:
raise ValueError("选区必须 >= 遮罩")
```
### 错误 3:Photopea 消息验证
```javascript
// ❌ 错误:不验证 origin
window.addEventListener("message", handler);
// ✅ 正确:验证 origin
window.addEventListener("message", (event) => {
if (event.origin !== "https://www.photopea.com") return;
// 处理...
});
```
---
## 9. 测试清单
### 后端测试
- [ ] 上传带 Alpha 的 PNG → 正确提取 MASK
- [ ] 上传不带 Alpha 的 PNG → 报错
- [ ] padding = 64 → 选区正确计算
- [ ] 拖动选区后 → 坐标正确传递
### 前端测试
- [ ] 节点显示遮罩预览(半透明红)
- [ ] 节点显示选区框(橙色虚线)
- [ ] 拖动选区框 → 流畅
- [ ] 调整 padding → 选区自动重算
### Photopea 测试
- [ ] 打开 Photopea → 图像加载
- [ ] 编辑后保存 → 图像同步
- [ ] 导出蒙版 → 验证可行性
---
## 10. 问题反馈
遇到问题?
1. **技术细节** → 查看 `docs/HANDOFF.md`(完整文档)
2. **实现方案** → 查看 `docs/implementation-plan.md`(详细代码)
3. **Photopea 问题** → 查看 `docs/technical-validation.md`(验证清单)
4. **历史记录** → 查看 `DEVLOG.md`(开发日志)
---
*创建时间:2026-03-02*
*5 分钟快速了解,详细文档见 `docs/HANDOFF.md`*
+185
View File
@@ -0,0 +1,185 @@
# InpaintRegionEditor 测试清单
> 版本:1.0 | 日期:2026-03-02
---
## 一、后端测试
### 1.1 蒙版提取测试
| 测试项 | 预期结果 | 状态 |
|--------|---------|------|
| 上传带 Alpha 的 PNG | 正确提取 MASK | ⬜ 待测试 |
| 上传不带 Alpha 的 PNG | 抛出 ValueError | ⬜ 待测试 |
| Alpha = 0(全透明) | MASK = 1.0(全遮罩) | ⬜ 待测试 |
| Alpha = 255(全不透明) | MASK = 0.0(无遮罩) | ⬜ 待测试 |
### 1.2 选区计算测试
| 测试项 | 预期结果 | 状态 |
|--------|---------|------|
| padding = 64 | 选区 = 遮罩边界 + 64px | ⬜ 待测试 |
| padding = 0 | 选区 = 遮罩边界 | ⬜ 待测试 |
| 选区超出图像边界 | 自动裁剪到边界内 | ⬜ 待测试 |
| 用户拖动选区 | 使用前端坐标 | ⬜ 待测试 |
### 1.3 输出测试
| 测试项 | 预期结果 | 状态 |
|--------|---------|------|
| IMAGE 输出 | RGB tensor [1, H, W, 3] | ⬜ 待测试 |
| MASK 输出 | Float tensor [1, H, W] | ⬜ 待测试 |
| region_top/left/width/height | 正确的 INT 值 | ⬜ 待测试 |
---
## 二、前端测试
### 2.1 节点创建测试
| 测试项 | 预期结果 | 状态 |
|--------|---------|------|
| 添加节点到画布 | 节点显示正确 | ⬜ 待测试 |
| 图像选择 widget | 下拉列表显示文件 | ⬜ 待测试 |
| padding widget | 默认值 64,范围 0-512 | ⬜ 待测试 |
| 选区框 Canvas | 显示遮罩 + 选区框 | ⬜ 待测试 |
### 2.2 选区框交互测试
| 测试项 | 预期结果 | 状态 |
|--------|---------|------|
| 遮罩显示 | 半透明红色区域 | ⬜ 待测试 |
| 选区框显示 | 橙色虚线边框 | ⬜ 待测试 |
| 拖动选区框 | 坐标实时更新 | ⬜ 待测试 |
| 尺寸标签 | 显示 W×H | ⬜ 待测试 |
| 拖动后同步 widget | region_coords 更新 | ⬜ 待测试 |
### 2.3 右键菜单测试
| 测试项 | 预期结果 | 状态 |
|--------|---------|------|
| 右键节点 | 显示"打开 Photopea 编辑"选项 | ⬜ 待测试 |
| 未选择图像时点击 | alert("请先选择一张图像") | ⬜ 待测试 |
| 选择图像后点击 | 打开 Photopea 模态框 | ⬜ 待测试 |
---
## 三、Photopea 集成测试
### 3.1 模态框测试
| 测试项 | 预期结果 | 状态 |
|--------|---------|------|
| 模态框显示 | 全屏黑色背景 | ⬜ 待测试 |
| Photopea iframe 加载 | 显示 Photopea 界面 | ⬜ 待测试 |
| 状态栏显示 | "正在加载图像..." | ⬜ 待测试 |
| 取消按钮 | 关闭模态框 | ⬜ 待测试 |
### 3.2 图像加载测试
| 测试项 | 预期结果 | 状态 |
|--------|---------|------|
| 图像加载到 Photopea | 显示在画布中 | ⬜ 待测试 |
| 加载 RGB(无 Alpha) | 纯 RGB 图像 | ⬜ 待测试 |
| 加载失败处理 | 状态栏显示错误 | ⬜ 待测试 |
### 3.3 图像保存测试
| 测试项 | 预期结果 | 状态 |
|--------|---------|------|
| 保存按钮点击 | "正在导出图像..." | ⬜ 待测试 |
| 图像导出 | PNG 格式 | ⬜ 待测试 |
| 上传到 ComfyUI | 文件保存到 input 目录 | ⬜ 待测试 |
| 节点 widget 更新 | image widget 显示新文件名 | ⬜ 待测试 |
| 保存失败处理 | 状态栏显示错误 | ⬜ 待测试 |
---
## 四、集成工作流测试
### 4.1 完整流程测试
```
步骤 1: 上传带 Alpha 的 PNG
步骤 2: 验证节点显示遮罩 + 选区框
步骤 3: 调整 padding 值
步骤 4: 拖动选区框位置
步骤 5: 右键 → 打开 Photopea 编辑
步骤 6: 在 Photopea 中编辑图像
步骤 7: 保存返回节点
步骤 8: 执行工作流
步骤 9: 验证输出
```
| 步骤 | 预期结果 | 状态 |
|------|---------|------|
| 1 | 文件出现在下拉列表 | ⬜ 待测试 |
| 2 | Canvas 显示遮罩和选区 | ⬜ 待测试 |
| 3 | 选区自动重算 | ⬜ 待测试 |
| 4 | region_coords 更新 | ⬜ 待测试 |
| 5 | Photopea 模态框打开 | ⬜ 待测试 |
| 6 | 可以编辑图像 | ⬜ 待测试 |
| 7 | 新图像更新到节点 | ⬜ 待测试 |
| 8 | 工作流执行成功 | ⬜ 待测试 |
| 9 | IMAGE/MASK/region_* 输出正确 | ⬜ 待测试 |
---
## 五、错误处理测试
### 5.1 后端错误
| 测试项 | 预期结果 | 状态 |
|--------|---------|------|
| 无蒙版图像执行 | ValueError + 友好提示 | ⬜ 待测试 |
| 选区小于遮罩 | ValueError + 尺寸提示 | ⬜ 待测试 |
| 图像文件不存在 | 错误处理 | ⬜ 待测试 |
### 5.2 前端错误
| 测试项 | 预期结果 | 状态 |
|--------|---------|------|
| Photopea 加载超时 | 超时错误提示 | ⬜ 待测试 |
| 图像上传失败 | 错误提示 | ⬜ 待测试 |
| 网络断开 | 错误处理 | ⬜ 待测试 |
---
## 六、性能测试
| 测试项 | 预期结果 | 状态 |
|--------|---------|------|
| 1024×1024 图像加载 | < 2 秒 | ⬜ 待测试 |
| 选区框拖动流畅度 | 60fps | ⬜ 待测试 |
| Photopea 首次加载 | < 10 秒 | ⬜ 待测试 |
| Photopea 缓存后加载 | < 3 秒 | ⬜ 待测试 |
---
## 七、浏览器兼容性测试
| 浏览器 | 状态 |
|--------|------|
| Chrome (最新版) | ⬜ 待测试 |
| Firefox (最新版) | ⬜ 待测试 |
| Edge (最新版) | ⬜ 待测试 |
| Safari (macOS) | ⬜ 待测试 |
---
## 八、测试结果汇总
| 类别 | 通过 | 失败 | 待测试 |
|------|------|------|--------|
| 后端测试 | 0 | 0 | 14 |
| 前端测试 | 0 | 0 | 16 |
| Photopea 集成 | 0 | 0 | 11 |
| 集成工作流 | 0 | 0 | 9 |
| 错误处理 | 0 | 0 | 6 |
| 性能测试 | 0 | 0 | 4 |
| 浏览器兼容 | 0 | 0 | 4 |
---
*文档创建时间:2026-03-02*
+868
View File
@@ -0,0 +1,868 @@
# InpaintRegionEditor 最终实现方案
> 版本:1.0 | 日期:2026-03-02
> 基于用户需求最终确认
---
## 一、核心需求
### 1.1 功能定位
**InpaintRegionEditor = LoadImage + 蒙版编辑 + 选区调整**
- 替代系统 LoadImage 节点
- 支持图像上传
- 支持蒙版编辑(Photopea 集成)
- 支持选区调整(拖动 + 扩散像素)
- 输出:IMAGE, MASK, region_*
### 1.2 蒙版来源(三种)
1. **用户上传**:带 Alpha 通道的 PNG
2. **Photopea 编辑**:在 Photopea 里画蒙版后保存
3. **系统工具**:ComfyUI 自带蒙版工具绘制后保存
### 1.3 核心逻辑
```
if 没有蒙版:
执行时报错:"请先创建蒙版(局部重绘需要蒙版)"
else:
自动计算选区 = 蒙版边界 + padding
在预览上绘制遮罩 + 选区框
用户可以拖动选区
输出 region_* 坐标
```
---
## 二、文件结构
```
ComfyUI-InpaintRegionEditor/
├── __init__.py # 节点注册
├── nodes.py # 后端节点逻辑
├── requirements.txt # 无额外依赖
│
├── web/
│ ├── extension.js # 扩展入口 + 右键菜单
│ ├── photopea-bridge.js # Photopea 通信桥接
│ ├── photopea-modal.js # Photopea 全屏对话框
│ ├── region-box-editor.js # 选区框编辑器(Canvas + 拖动)
│ └── style.css # 样式(可选)
│
└── docs/
├── technical-validation.md # 技术验证清单
└── implementation-plan.md # 本方案
```
---
## 三、后端实现(nodes.py)
### 3.1 节点定义
```python
import torch
import numpy as np
import folder_paths
from PIL import Image
import os
import json
class InpaintRegionEditor:
"""
增强版遮罩编辑器,支持 Photopea 集成和可拖动选区
概念说明:
- 遮罩 (Mask): 定义重绘区域,AI 在这个区域内生成新内容
- 选区 (Region): 定义参考区域,AI 参考这个区域内的原图内容
约束:选区必须 >= 遮罩
"""
@classmethod
def INPUT_TYPES(s):
input_dir = folder_paths.get_input_directory()
files = [f for f in os.listdir(input_dir)
if os.path.isfile(os.path.join(input_dir, f))]
files = folder_paths.filter_files_content_types(files, ["image"])
return {
"required": {
# 图像输入
"image": (sorted(files), {"image_upload": True}),
# 扩散像素数(选区 = 遮罩边界 + padding)
"padding": ("INT", {
"default": 64,
"min": 0,
"max": 512,
"step": 32
}),
},
"hidden": {
# 前端传递的选区坐标(用户拖动后更新)
"region_coords": "STRING",
}
}
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "INT", "INT")
RETURN_NAMES = ("image", "mask", "region_top", "region_left", "region_width", "region_height")
FUNCTION = "process"
CATEGORY = "image/inpaint"
def process(self, image, padding, region_coords=None):
"""
处理图像和遮罩,计算选区位置
Args:
image: 图像文件名
padding: 扩散像素数
region_coords: 前端传递的选区坐标 JSON(用户拖动后)
"""
# 加载图像
image_path = folder_paths.get_annotated_filepath(image)
img = Image.open(image_path)
img_width, img_height = img.size
# 提取 MASK(从 Alpha 通道)
if 'A' in img.getbands():
mask = np.array(img.getchannel('A')).astype(np.float32) / 255.0
mask = 1.0 - mask # ComfyUI 约定:1=遮罩区域,0=背景
else:
# 没有蒙版 → 报错
raise ValueError(
"请先创建蒙版(局部重绘需要蒙版)\n"
"可以通过以下方式创建:\n"
"1. 上传带 Alpha 通道的 PNG\n"
"2. 使用 Photopea 编辑后保存蒙版\n"
"3. 使用 ComfyUI 系统蒙版工具绘制"
)
# 计算蒙版边界框
mask_bounds = self._calculate_mask_bounds(mask)
# 解析前端传递的选区坐标(用户拖动后)
region_rect = None
if region_coords:
try:
region_rect = json.loads(region_coords)
except:
pass
# 如果没有前端坐标,自动计算
if not region_rect:
region_rect = self._calculate_region_rect(mask_bounds, padding)
# 验证:选区必须 >= 遮罩
if (region_rect['width'] < mask_bounds['width'] or
region_rect['height'] < mask_bounds['height']):
raise ValueError(
f"选区太小!必须 >= 遮罩\n"
f"选区:{region_rect['width']}×{region_rect['height']}\n"
f"遮罩:{mask_bounds['width']}×{mask_bounds['height']}"
)
# 确保选区在图像范围内
region_rect = self._clamp_to_image(region_rect, img_width, img_height)
# 转换为 tensor
image_tensor = torch.from_numpy(
np.array(img.convert("RGB")).astype(np.float32) / 255.0
).unsqueeze(0)
mask_tensor = torch.from_numpy(mask).unsqueeze(0)
return (
image_tensor,
mask_tensor,
int(region_rect['y']), # top
int(region_rect['x']), # left
int(region_rect['width']), # width
int(region_rect['height']) # height
)
def _calculate_mask_bounds(self, mask):
"""计算遮罩的边界框"""
rows = np.any(mask > 0.5, axis=1)
cols = np.any(mask > 0.5, axis=0)
if not np.any(rows) or not np.any(cols):
# 没有遮罩
return {'x': 0, 'y': 0, 'width': 0, 'height': 0}
rmin, rmax = np.where(rows)[0][[0, -1]]
cmin, cmax = np.where(cols)[0][[0, -1]]
return {
'x': int(cmin),
'y': int(rmin),
'width': int(cmax - cmin + 1),
'height': int(rmax - rmin + 1)
}
def _calculate_region_rect(self, mask_bounds, padding):
"""根据遮罩边界 + padding 计算选区"""
return {
'x': mask_bounds['x'] - padding,
'y': mask_bounds['y'] - padding,
'width': mask_bounds['width'] + padding * 2,
'height': mask_bounds['height'] + padding * 2
}
def _clamp_to_image(self, rect, img_width, img_height):
"""确保矩形在图像范围内"""
x = max(0, min(rect['x'], img_width - rect['width']))
y = max(0, min(rect['y'], img_height - rect['height']))
return {
'x': x,
'y': y,
'width': rect['width'],
'height': rect['height']
}
```
---
## 四、前端实现
### 4.1 extension.js(扩展入口)
```javascript
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { PhotopeaModal } from "./photopea-modal.js";
import { RegionBoxEditor } from "./region-box-editor.js";
app.registerExtension({
name: "InpaintRegionEditor",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "InpaintRegionEditor") return;
// 添加 Photopea 右键菜单
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function(canvas, options) {
getExtraMenuOptions?.apply(this, arguments);
const node = this;
const imageWidget = node.widgets?.find(w => w.name === "image");
options.push(null); // 分隔线
options.push({
content: "🎨 打开 Photopea 编辑",
callback: () => {
if (imageWidget?.value) {
PhotopeaModal.show(node, imageWidget.value);
} else {
alert("请先加载图像");
}
}
});
return options;
};
// 添加选区框编辑器
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const result = onNodeCreated?.apply(this, arguments);
// 隐藏的区域坐标 widget(存储用户拖动后的坐标)
const coordsWidget = this.addWidget("STRING", "region_coords", "{}", () => {}, {
serialize: true,
hidden: true
});
// 添加选区框 Canvas
this.regionEditor = new RegionBoxEditor(this, app.canvas);
return result;
};
// 节点执行时更新选区
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function(message) {
onExecuted?.apply(this, arguments);
// 更新选区框(使用后端返回的坐标)
if (this.regionEditor && message.region_coords) {
this.regionEditor.updateRegion(message.region_coords);
}
};
// 节点绘制时渲染选区框
const onDrawBackground = nodeType.prototype.onDrawBackground;
nodeType.prototype.onDrawBackground = function(ctx, nodeWidget, nodeWidth, nodeHeight) {
onDrawBackground?.apply(this, arguments);
// 在图像预览上绘制选区框
if (this.regionEditor) {
this.regionEditor.draw(ctx, nodeWidth, nodeHeight);
}
};
}
});
```
### 4.2 region-box-editor.js(选区框编辑器)
```javascript
/**
* 选区框编辑器
* 在节点图像预览上绘制可拖动的选区框
*/
export class RegionBoxEditor {
constructor(node, canvas) {
this.node = node;
this.canvas = canvas;
// 选区矩形
this.regionRect = {
x: 0,
y: 0,
width: 200,
height: 200
};
// 遮罩边界(自动计算)
this.maskBounds = {
x: 0,
y: 0,
width: 100,
height: 100
};
// 交互状态
this.isDragging = false;
this.dragStart = { x: 0, y: 0 };
// 样式配置
this.config = {
maskColor: "rgba(255, 0, 0, 0.5)", // 遮罩:半透明红
regionColor: "rgba(255, 165, 0, 0.3)", // 选区:半透明橙
regionBorder: "rgb(255, 165, 0)", // 选区边框:橙色
regionDash: [5, 5], // 虚线
handleSize: 8, // 拖动手柄大小
};
this.setupEventListeners();
}
setupEventListeners() {
// 监听鼠标事件(拖动选区)
const canvasElement = this.canvas.canvas;
canvasElement.addEventListener('mousedown', (e) => this.onMouseDown(e));
canvasElement.addEventListener('mousemove', (e) => this.onMouseMove(e));
canvasElement.addEventListener('mouseup', () => this.onMouseUp());
}
onMouseDown(e) {
const rect = this.canvas.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 检查是否点击在选区框内
if (this.isPointInRect(x, y, this.regionRect)) {
this.isDragging = true;
this.dragStart = { x, y };
this.canvas.canvas.style.cursor = 'move';
}
}
onMouseMove(e) {
if (!this.isDragging) return;
const rect = this.canvas.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const dx = x - this.dragStart.x;
const dy = y - this.dragStart.y;
// 更新选区位置
this.regionRect.x += dx;
this.regionRect.y += dy;
this.dragStart = { x, y };
// 同步到节点 widget
this.syncToNode();
// 重绘
this.canvas.setDirty(true);
}
onMouseUp() {
this.isDragging = false;
this.canvas.canvas.style.cursor = 'default';
}
isPointInRect(x, y, rect) {
return x >= rect.x && x <= rect.x + rect.width &&
y >= rect.y && y <= rect.y + rect.height;
}
draw(ctx, nodeWidth, nodeHeight) {
// 绘制遮罩预览(半透明红)
if (this.maskBounds.width > 0) {
ctx.fillStyle = this.config.maskColor;
ctx.fillRect(
this.maskBounds.x,
this.maskBounds.y,
this.maskBounds.width,
this.maskBounds.height
);
}
// 绘制选区框(橙色虚线)
ctx.fillStyle = this.config.regionColor;
ctx.fillRect(
this.regionRect.x,
this.regionRect.y,
this.regionRect.width,
this.regionRect.height
);
ctx.strokeStyle = this.config.regionBorder;
ctx.lineWidth = 2;
ctx.setLineDash(this.config.regionDash);
ctx.strokeRect(
this.regionRect.x,
this.regionRect.y,
this.regionRect.width,
this.regionRect.height
);
ctx.setLineDash([]);
// 绘制尺寸标签
ctx.fillStyle = 'white';
ctx.font = '12px sans-serif';
ctx.fillText(
`${this.regionRect.width}×${this.regionRect.height}`,
this.regionRect.x + 5,
this.regionRect.y + 15
);
}
updateRegion(regionRect) {
// 更新选区坐标(从后端返回)
this.regionRect = regionRect;
this.canvas.setDirty(true);
}
updateMaskBounds(maskBounds) {
// 更新遮罩边界(自动计算选区)
this.maskBounds = maskBounds;
// 自动计算选区(如果还没手动拖动过)
if (!this.userDragged) {
const padding = this.node.widgets?.find(w => w.name === "padding")?.value || 64;
this.regionRect = {
x: maskBounds.x - padding,
y: maskBounds.y - padding,
width: maskBounds.width + padding * 2,
height: maskBounds.height + padding * 2
};
}
this.canvas.setDirty(true);
}
syncToNode() {
// 同步选区坐标到节点 widget
const coordsWidget = this.node.widgets?.find(w => w.name === "region_coords");
if (coordsWidget) {
coordsWidget.value = JSON.stringify(this.regionRect);
}
this.userDragged = true;
}
}
```
### 4.3 photopea-modal.js(Photopea 对话框)
```javascript
import { PhotopeaBridge } from "./photopea-bridge.js";
export class PhotopeaModal {
static instance = null;
static node = null;
static show(node, imagePath) {
this.node = node;
this.createUI();
this.loadImage(imagePath);
}
static createUI() {
// 创建全屏对话框
this.modal = document.createElement('div');
this.modal.className = 'photopea-modal';
this.modal.innerHTML = `
<div class="photopea-container">
<iframe id="photopea-iframe" src="https://www.photopea.com/"></iframe>
<div class="photopea-toolbar">
<span class="toolbar-hint">💡 编辑后保存,图像和蒙版会自动同步到节点</span>
<div class="toolbar-buttons">
<button id="btn-save-image" class="btn btn-primary">💾 保存图像</button>
<button id="btn-save-mask" class="btn btn-success">🎭 保存蒙版</button>
<button id="btn-cancel" class="btn btn-secondary">❌ 取消</button>
</div>
</div>
<div class="status-bar" id="status-bar">正在加载 Photopea...</div>
</div>
`;
document.body.appendChild(this.modal);
// 初始化 Photopea
const iframe = document.getElementById('photopea-iframe');
iframe.onload = () => {
PhotopeaBridge.init(iframe);
document.getElementById('status-bar').textContent = 'Photopea 已就绪';
};
// 绑定按钮事件
document.getElementById('btn-save-image').onclick = () => this.saveImage();
document.getElementById('btn-save-mask').onclick = () => this.saveMask();
document.getElementById('btn-cancel').onclick = () => this.close();
}
static async loadImage(imagePath) {
try {
// 从 ComfyUI 获取图像(加 channel=rgb 获取纯 RGB)
const response = await fetch(`/view?filename=${imagePath}&type=input&channel=rgb`);
const blob = await response.blob();
await PhotopeaBridge.openImage(blob);
document.getElementById('status-bar').textContent = '图像已加载';
} catch (e) {
document.getElementById('status-bar').textContent = '加载失败:' + e.message;
}
}
static async saveImage() {
try {
document.getElementById('status-bar').textContent = '正在导出图像...';
const blob = await PhotopeaBridge.exportImage();
const result = await this.uploadToComfyUI(blob, 'edited_image.png');
if (result.name) {
const imageWidget = this.node.widgets?.find(w => w.name === "image");
if (imageWidget) {
imageWidget.value = result.name;
}
document.getElementById('status-bar').textContent = '✅ 图像已保存';
setTimeout(() => this.close(), 500);
}
} catch (e) {
document.getElementById('status-bar').textContent = '导出失败:' + e.message;
}
}
static async saveMask() {
try {
document.getElementById('status-bar').textContent = '正在导出蒙版...';
const blob = await PhotopeaBridge.exportMask();
const result = await this.uploadToComfyUI(blob, 'mask.png');
// 注意:蒙版需要特殊处理,合成到原图的 Alpha 通道
// 这里简化处理,实际可能需要后端配合
if (result.name) {
document.getElementById('status-bar').textContent = '✅ 蒙版已保存';
setTimeout(() => this.close(), 500);
}
} catch (e) {
document.getElementById('status-bar').textContent = '导出失败:' + e.message;
}
}
static async uploadToComfyUI(blob, filename) {
const formData = new FormData();
formData.append('image', blob, filename);
formData.append('type', 'input');
const response = await api.fetchApi('/upload/image', {
method: 'POST',
body: formData
});
return await response.json();
}
static close() {
this.modal?.remove();
this.modal = null;
this.node = null;
}
}
```
### 4.4 photopea-bridge.js(Photopea 通信)
```javascript
/**
* Photopea 通信桥接
*/
export class PhotopeaBridge {
static iframe = null;
static photopeaWindow = null;
static PHOTOPEA_ORIGIN = "https://www.photopea.com";
static init(iframe) {
this.iframe = iframe;
this.photopeaWindow = iframe.contentWindow;
}
static async postMessage(message) {
return new Promise((resolve, reject) => {
if (!this.photopeaWindow) {
reject(new Error('Photopea not initialized'));
return;
}
const responses = [];
const timeoutId = setTimeout(() => {
reject(new Error('Photopea timeout'));
}, 60000);
const handler = (event) => {
if (event.origin !== this.PHOTOPEA_ORIGIN) return;
responses.push(event.data);
if (event.data === "done") {
clearTimeout(timeoutId);
window.removeEventListener("message", handler);
resolve(responses);
}
};
window.addEventListener("message", handler);
this.photopeaWindow.postMessage(message, "*");
});
}
static async openImage(blob) {
const base64 = await this.blobToBase64(blob);
await this.postMessage(`app.open("${base64}");`);
}
static async exportImage() {
const result = await this.postMessage('app.activeDocument.saveToOE("png");');
const arrayBuffer = result.find(r => r instanceof ArrayBuffer);
return new Blob([arrayBuffer], { type: 'image/png' });
}
static async exportMask() {
// TODO: 需要测试 Photopea 能否单独导出蒙版
// 这里使用 workaround 方案
const script = `
(function() {
var doc = app.activeDocument;
var layer = doc.activeLayer;
// 尝试获取蒙版
if (layer.mask) {
doc.activeChannel = layer.mask;
doc.selection.selectAll();
doc.selection.copy();
var newDoc = app.documents.add(doc.width, doc.height);
app.activeDocument = newDoc;
newDoc.paste();
newDoc.saveToOE("png");
newDoc.close(SaveOptions.DONOTSAVECHANGES);
app.activeDocument = doc;
app.echoToOE("mask_exported");
} else {
app.echoToOE("no_mask");
}
})();
`;
const result = await this.postMessage(script);
// 处理结果...
}
static blobToBase64(blob) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
}
}
```
### 4.5 style.css(样式)
```css
.photopea-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
}
.photopea-container {
width: 95%;
height: 95%;
display: flex;
flex-direction: column;
background: #1e1e1e;
border-radius: 8px;
overflow: hidden;
}
#photopea-iframe {
flex: 1;
width: 100%;
border: none;
}
.photopea-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #2d2d2d;
border-top: 1px solid #444;
}
.toolbar-hint {
color: #888;
font-size: 13px;
}
.toolbar-buttons {
display: flex;
gap: 10px;
}
.btn {
padding: 10px 18px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.btn-primary {
background: #0078d4;
color: white;
}
.btn-success {
background: #107c10;
color: white;
}
.btn-secondary {
background: #555;
color: white;
}
.status-bar {
padding: 8px 16px;
background: #1a1a1a;
color: #888;
font-size: 12px;
}
```
---
## 五、待验证的技术点
### 5.1 Photopea 相关(需要测试)
1. **同时加载原图 + 蒙版**
- 能否用 `app.open()` 加载两个文件?
- 能否让蒙版作为图层蒙版(不是普通图层)?
2. **单独导出蒙版**
- `exportMask()` 能否工作?
- 如果 layer.mask 返回 null,workaround 是否有效?
3. **检测是否有蒙版**
- 在保存前能否检测用户是否画了蒙版?
### 5.2 前端 Canvas 相关
1. **在节点上绘制**
- 参考 Impact-Pack 的 `mask-rect-area.js`
- 需要处理 Canvas 坐标转换
2. **拖动交互**
- 鼠标事件监听
- 约束:选区不能小于遮罩
### 5.3 后端相关
1. **蒙版检测**
- 没有 Alpha 通道时报错
2. **选区坐标传递**
- 前端 → 后端:通过 hidden widget
- 后端 → 前端:通过消息返回
---
## 六、实现路线图
### Phase 1:后端基础(1 天)
- [ ] 完成 `nodes.py` 基础逻辑
- [ ] 测试蒙版提取
- [ ] 测试选区计算
### Phase 2:前端选区 UI(2 天)
- [ ] 完成 `region-box-editor.js`
- [ ] 在节点上绘制遮罩 + 选区框
- [ ] 实现拖动功能
### Phase 3:Photopea 集成(2-3 天)
- [ ] 完成 `photopea-modal.js`
- [ ] 测试图像加载
- [ ] 测试图像导出
- [ ] 测试蒙版导出(待验证)
### Phase 4:集成测试(1 天)
- [ ] 完整工作流测试
- [ ] 修复 bug
---
## 七、已知风险
1. **Photopea API 限制**:
- `layer.mask` 总是返回 null
- 可能无法单独导出蒙版
2. **备选方案**:
- 如果无法导出蒙版,用户只能用其他方式创建(系统工具/上传)
- 或者在 Photopea 里手动合成到 Alpha 通道
---
*文档创建时间:2026-03-02*
*需求来源:与用户最终确认*
+395
View File
@@ -0,0 +1,395 @@
# InpaintRegionEditor 技术验证清单
> 版本:1.0 | 日期:2026-03-02
> 目的:验证所有不确定的技术点,为最终方案提供依据
> 状态:✅ 已完成核心验证
---
## 零、验证结论摘要
### ✅ 已验证可行的方案
| 功能 | 方案 | 来源 |
|------|------|------|
| 图像导出 | `app.activeDocument.saveToOE("png")` | Photopea 官方 API |
| 从选区创建蒙版 | 创建临时图层 + 填充黑白 | sd-webui-photopea-embed |
| 单独导出选中图层 | 隐藏其他图层 + saveToOE | sd-webui-photopea-embed |
### ❌ 已确认不可行
| 功能 | 原因 | 备选方案 |
|------|------|---------|
| 直接访问 layer.mask | API 返回 null (Issue #7341) | 使用选区 workaround |
| 检测图层是否有蒙版 | layer.mask 始终为 null | 让用户通过选区操作 |
### 📋 推荐工作流
**用户创建蒙版的方式**:
1. 在 Photopea 中使用选择工具创建选区
2. 点击"从选区创建蒙版"按钮
3. 系统自动创建黑白蒙版图层并导出
4. 或者:用户直接上传带 Alpha 通道的 PNG
---
## 一、Photopea 加载验证
### 1.1 同时加载原图 + 蒙版
**测试目标**:能否让 Photopea 同时加载两个文件,并且蒙版作为图层蒙版?
**测试脚本**:
```javascript
// 方法 1:先打开图,再加载蒙版作为 mask
app.open("image.png");
var doc = app.activeDocument;
var layer = doc.activeLayer;
app.open("mask.png");
var maskDoc = app.activeDocument;
// 复制 mask 到原图的 layer.mask
```
**预期结果**:
- ✅ 成功:图层右侧出现蒙版缩略图
- ❌ 失败:两张独立的图,或者蒙版只是普通图层
**优先级**:🔴 最高(决定整个工作流)
---
### 1.2 加载带 Alpha 通道的 PNG
**测试目标**:如果上传的 PNG 本身带 Alpha 通道,Photopea 能否自动识别为蒙版?
**测试方法**:
1. 准备一个 RGBA PNG(RGB 是图像,Alpha 是蒙版)
2. 用 `app.open("data:image/png;base64,...")` 加载
3. 检查 `layer.mask` 是否存在
**预期结果**:
- ✅ 成功:Alpha 通道自动转为图层蒙版
- ❌ 失败:Alpha 通道只是透明,不是蒙版
---
### 1.3 用脚本创建图层蒙版
**测试目标**:能否用脚本把一张黑白图转换为图层蒙版?
**测试脚本**:
```javascript
// 假设已经有一个黑白图在剪贴板
doc.selection.load("mask.png");
layer.addMask(true);
```
**参考**:DEVLOG 中提到的 `loadTransparentPixels() + addMask()`
---
## 二、Photopea 导出验证
### 2.1 导出带蒙版的图像
**测试目标**:如果图层有蒙版,导出 PNG 时能否保留蒙版到 Alpha 通道?
**测试方法**:
1. 创建一个带蒙版的图层
2. 执行 `app.activeDocument.saveToOE("png")`
3. 检查导出的 PNG 是否有 Alpha 通道
**预期结果**:
- ✅ 成功:PNG 的 Alpha = 蒙版
- ❌ 失败:蒙版被丢弃,或者蒙版渲染到 RGB 里
---
### 2.2 单独导出蒙版
**测试目标**:能否只导出蒙版(黑白图)?
**❌ 原方案失败**:
```javascript
var mask = layer.mask; // ⚠️ 已知返回 null
doc.activeChannel = layer.mask; // 无法执行
```
**✅ Workaround 方案(已验证)**:
参考 `sd-webui-photopea-embed` 的 `createMaskFromSelection()` 实现:
```javascript
// 从选区创建蒙版图层
function createMaskFromSelection() {
if (app.activeDocument.selection === null) {
app.echo("No selection!");
return;
}
// 创建临时图层
newLayer = app.activeDocument.artLayers.add();
newLayer.name = "TempMaskLayer";
// 选区外部填充黑色
app.activeDocument.selection.invert();
color = new SolidColor();
color.rgb.red = 0;
color.rgb.green = 0;
color.rgb.blue = 0;
app.activeDocument.selection.fill(color);
// 选区内部填充白色
color.rgb.red = 255;
color.rgb.green = 255;
color.rgb.blue = 255;
app.activeDocument.selection.invert();
app.activeDocument.selection.fill(color);
}
// 导出蒙版图层
function exportMaskLayer() {
// 隐藏其他图层,只显示蒙版图层
// ... (参考 exportSelectedLayerOnly)
app.activeDocument.saveToOE("png");
// 删除临时蒙版图层
app.activeDocument.activeLayer.remove();
}
```
**验证来源**:`sd-webui-photopea-embed` 项目 (https://github.com/yankooliveira/sd-webui-photopea-embed)
**结论**:✅ 可以通过选区 workaround 实现蒙版导出
---
### 2.3 检测当前是否有蒙版
**测试目标**:在用户点击"保存"前,能否检测当前文档是否有蒙版?
**测试脚本**:
```javascript
var hasMask = (app.activeDocument.activeLayer.mask != null);
// 或者
var hasMask = (app.activeDocument.activeLayer.layerMask != null);
```
**预期结果**:
- ✅ 成功:能准确检测
- ❌ 失败:无法检测(因为 layer.mask 总是返回 null)
**影响**:如果无法检测,就无法在保存前提示用户
---
## 三、前端交互验证
### 3.1 检测用户是否禁用了蒙版
**测试场景**:
1. 用户在 Photopea 里编辑
2. 点击图层蒙版眼睛图标(禁用蒙版)
3. 点保存
**问题**:
- 这时应该保存什么?
- 原图(忽略蒙版)还是报错?
**技术难点**:
- Photopea 的 UI 状态(哪个图层可见、蒙版是否启用)无法通过 API 获取
- 只能通过导出后的结果反推
---
### 3.2 两次导出 + 两次上传
**测试目标**:连续导出两次(图像 + 蒙版),能否都成功上传?
**测试流程**:
```javascript
// 1. 导出图像
var imageBlob = await exportImage();
var imageResult = await uploadToComfyUI(imageBlob, "edited.png");
// 2. 导出蒙版
var maskBlob = await exportMask();
var maskResult = await uploadToComfyUI(maskBlob, "mask.png");
// 3. 更新节点
node.widgets.find(w => w.name === "image").value = imageResult.name;
node.widgets.find(w => w.name === "mask").value = maskResult.name;
```
**潜在问题**:
- Photopea 的 postMessage 是异步的,两次导出会不会冲突?
- 上传需要时间,UI 上怎么显示进度?
---
## 四、后端处理验证
### 4.1 从两个文件合成一个 PNG
**测试目标**:后端能否把 RGB 图 + 黑白蒙版图 合成到一个 PNG?
**测试代码**:
```python
from PIL import Image
# 加载 RGB 图
rgb_img = Image.open("edited.png").convert("RGB")
# 加载蒙版图(黑白)
mask_img = Image.open("mask.png").convert("L") # 转为灰度
# 合成
rgb_img.putalpha(mask_img)
rgb_img.save("combined.png")
```
**预期结果**:
- ✅ 成功:combined.png 的 Alpha = 蒙版
- ❌ 失败:尺寸不匹配、格式问题
---
### 4.2 节点同时输出两个文件名
**测试方案 A**:两个独立的 widget
```python
"image": (sorted(files), {"image_upload": True}),
"mask": (sorted(files), {"image_upload": True}),
```
**测试方案 B**:一个 widget 存 JSON
```python
"files": ("STRING", {"default": '{"image": "", "mask": ""}'}),
```
**测试方案 C**:一个 widget + node.properties
```python
"image": (sorted(files), {"image_upload": True}),
# mask 存在 node.properties["mask_file"]
```
---
## 五、UI/UX 验证
### 5.1 保存按钮的设计
**选项 A**:两个按钮
```
[💾 保存图像] [🎭 保存蒙版]
```
- 优点:用户明确知道在保存什么
- 缺点:步骤多,可能忘记保存另一个
**选项 B**:一个按钮,自动两次导出
```
[💾 保存全部]
```
- 优点:一键完成
- 缺点:慢,失败时难以定位
**选项 C**:下拉菜单
```
[💾 保存 ▼]
├─ 保存图像
├─ 保存蒙版
└─ 保存全部
```
- 优点:灵活
- 缺点:UI 复杂
---
### 5.2 错误提示
**场景**:
1. 用户没画蒙版就点"保存蒙版"
2. Photopea 里没有活动选区
3. 上传失败
**提示方式**:
- A) alert() 弹窗(简单但打断体验)
- B) 状态栏文字(不打断但可能忽略)
- C) Toast 通知(折中方案)
---
## 六、优先级排序
| 编号 | 测试项 | 优先级 | 工作量 | 依赖关系 |
|-----|-------|-------|-------|---------|
| 1.1 | 同时加载原图 + 蒙版 | 🔴 最高 | 2h | 无 |
| 1.2 | 加载带 Alpha 的 PNG | 🔴 最高 | 1h | 无 |
| 2.1 | 导出带蒙版的 PNG | 🔴 最高 | 1h | 1.1 |
| 2.2 | 单独导出蒙版 | 🔴 最高 | 2h | 1.1 |
| 2.3 | 检测是否有蒙版 | 🟡 中 | 1h | 1.1 |
| 4.1 | 后端合成 PNG | 🟡 中 | 1h | 2.1+2.2 |
| 3.2 | 两次导出上传 | 🟡 中 | 2h | 2.1+2.2 |
| 4.2 | 节点输出两个文件名 | 🟢 低 | 1h | 4.1 |
| 5.1 | UI 按钮设计 | 🟢 低 | 1h | 所有 |
---
## 七、下一步行动
### Phase 1:核心验证(1-2 天)
1. 创建测试脚本 `test_photopea.js`
2. 手动测试 1.1, 1.2, 2.1, 2.2
3. 记录结果到本文档
### Phase 2:原型开发(2-3 天)
基于测试结果,选择可行的方案
开发最小可用原型(MVP)
### Phase 3:集成测试(1-2 天)
与 ComfyUI 集成
测试完整工作流
---
## 八、已知限制与解决方案
### 8.1 Photopea API 限制
| 限制 | 影响 | 解决方案 |
|------|------|---------|
| `layer.mask` 返回 null | 无法直接访问图层蒙版 | 使用选区创建蒙版图层 |
| 无法获取 UI 状态 | 无法检测蒙版是否启用 | 信任用户操作 |
| postMessage 异步 | 需要正确处理回调 | Promise 封装 |
### 8.2 推荐的蒙版创建工作流
**方案 A:用户上传带 Alpha 的 PNG(最简单)**
```
用户 → 外部工具创建蒙版 → 上传 PNG → 节点自动提取 Alpha
```
**方案 B:在 Photopea 中通过选区创建(已实现)**
```
用户 → Photopea 选择工具 → 创建选区 → "从选区创建蒙版"按钮 → 自动导出
```
**方案 C:使用 ComfyUI 系统蒙版工具**
```
用户 → 系统蒙版编辑器 → 保存 → 节点使用
```
---
## 九、实现状态
| 功能 | 状态 | 文件 |
|------|------|------|
| 后端节点(padding + 无蒙版报错) | ✅ 已实现 | `nodes.py` |
| 选区框 Canvas UI | ✅ 已实现 | `region-box-editor.js` |
| Photopea 图像编辑 | ✅ 已实现 | `extension.js` |
| Photopea 蒙版导出 | ⚠️ Workaround 可行 | 待添加选区功能 |
---
*文档创建时间:2026-03-02*
*验证完成时间:2026-03-02*
*验证来源:Photopea 官方文档 + sd-webui-photopea-embed 项目*
+98 -119
View File
@@ -1,11 +1,11 @@
"""
InpaintRegionEditor Node
增强版遮罩编辑器,支持 Photopea 集成和可配置选区
增强版遮罩编辑器,支持 Photopea 集成和可拖动选区
概念说明:
- 遮罩 (Mask): 定义重绘区域,重绘完成后只在遮罩内合成新内容
- 选区 (Region): 定义参考区域,大模型重绘时参考这个区域内的原图内容
- 遮罩 (Mask): 定义重绘区域,AI 在这个区域内生成新内容
- 选区 (Region): 定义参考区域,AI 参考这个区域内的原图内容
约束:选区必须 >= 遮罩
"""
@@ -20,154 +20,133 @@ import json
class InpaintRegionEditor:
"""
增强版遮罩编辑器,支持 Photopea 集成和可配置选区
增强版遮罩编辑器,支持 Photopea 集成和可拖动选区
"""
# 预设尺寸映射
PRESET_SIZES = {
"512×512 (SD)": (512, 512),
"768×768": (768, 768),
"1024×1024 (SDXL)": (1024, 1024),
"1280×1280": (1280, 1280),
"1536×1536": (1536, 1536),
}
@classmethod
def INPUT_TYPES(s):
input_dir = folder_paths.get_input_directory()
files = [f for f in os.listdir(input_dir)
if os.path.isfile(os.path.join(input_dir, f))]
files = [
f
for f in os.listdir(input_dir)
if os.path.isfile(os.path.join(input_dir, f))
]
files = folder_paths.filter_files_content_types(files, ["image"])
return {
"required": {
# 图像输入
"image": (sorted(files), {"image_upload": True}),
# 选区尺寸预设
"region_size": (
list(s.PRESET_SIZES.keys()) + ["Custom (自定义)"],
{"default": "512×512 (SD)"}
),
# 自定义选区尺寸
"region_width": ("INT", {
"default": 512,
"min": 64,
"max": 4096,
"step": 64
}),
"region_height": ("INT", {
"default": 512,
"min": 64,
"max": 4096,
"step": 64
}),
"image": (sorted(files), {}),
"padding": ("INT", {"default": 64, "min": 0, "max": 512, "step": 32}),
},
"hidden": {
# 遮罩边界信息(前端传递)
"mask_bounds": "STRING",
}
"region_coords": "STRING",
},
}
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "INT", "INT")
RETURN_NAMES = ("image", "mask", "region_top", "region_left", "region_width", "region_height")
RETURN_NAMES = (
"image",
"mask",
"region_top",
"region_left",
"region_width",
"region_height",
)
FUNCTION = "process"
CATEGORY = "image/inpaint"
def process(self, image, region_size, region_width, region_height,
mask_bounds=None):
"""
处理图像和遮罩,计算选区位置
Args:
image: 图像文件名
region_size: 选区预设名称
region_width: 自定义选区宽度
region_height: 自定义选区高度
mask_bounds: 遮罩边界 JSON 字符串
"""
# 加载图像
def process(self, image, padding, region_coords=None):
image_path = folder_paths.get_annotated_filepath(image)
img = Image.open(image_path)
img_width, img_height = img.size
# 提取或创建遮罩
if 'A' in img.getbands():
mask = np.array(img.getchannel('A')).astype(np.float32) / 255.0
mask = 1.0 - mask # ComfyUI 遮罩约定
# 提取 MASK(从 Alpha 通道)
if "A" in img.getbands():
mask = np.array(img.getchannel("A")).astype(np.float32) / 255.0
mask = 1.0 - mask # ComfyUI 约定:1=遮罩区域
else:
mask = np.zeros((img_height, img_width), dtype=np.float32)
# 解析遮罩边界
mask_bounds_dict = None
if mask_bounds:
raise ValueError(
"请先创建蒙版(局部重绘需要蒙版)\n"
"可以通过以下方式创建:\n"
"1. 上传带 Alpha 通道的 PNG\n"
"2. 使用 Photopea 编辑后保存\n"
"3. 使用 ComfyUI 系统蒙版工具绘制"
)
# 计算蒙版边界框
mask_bounds = self._calculate_mask_bounds(mask)
# 解析前端坐标(如果用户拖动过)
region_rect = None
if region_coords:
try:
mask_bounds_dict = json.loads(mask_bounds)
region_rect = json.loads(region_coords)
except:
pass
# 如果没有前端传递的遮罩边界,自动计算
if not mask_bounds_dict:
mask_bounds_dict = self._calculate_mask_bounds(mask)
# 确定选区尺寸
if region_size in self.PRESET_SIZES:
r_width, r_height = self.PRESET_SIZES[region_size]
else:
r_width, r_height = region_width, region_height
# 约束验证:选区必须 >= 遮罩
mask_w = mask_bounds_dict.get('width', 0)
mask_h = mask_bounds_dict.get('height', 0)
if r_width < mask_w:
r_width = int(mask_w)
if r_height < mask_h:
r_height = int(mask_h)
# 计算选区位置(居中于遮罩)
mask_x = mask_bounds_dict.get('x', 0)
mask_y = mask_bounds_dict.get('y', 0)
region_x = mask_x + mask_w / 2 - r_width / 2
region_y = mask_y + mask_h / 2 - r_height / 2
# 确保选区在图像范围内
region_x = max(0, min(region_x, img_width - r_width))
region_y = max(0, min(region_y, img_height - r_height))
if not region_rect:
region_rect = self._calculate_region_rect(mask_bounds, padding)
# 验证
if (
region_rect["width"] < mask_bounds["width"]
or region_rect["height"] < mask_bounds["height"]
):
raise ValueError(
f"选区太小!必须 >= 遮罩\n"
f"选区:{region_rect['width']}×{region_rect['height']}\n"
f"遮罩:{mask_bounds['width']}×{mask_bounds['height']}"
)
region_rect = self._clamp_to_image(region_rect, img_width, img_height)
# 转换为 tensor
image_tensor = torch.from_numpy(
np.array(img.convert("RGB")).astype(np.float32) / 255.0
).unsqueeze(0)
mask_tensor = torch.from_numpy(mask).unsqueeze(0)
return (
image_tensor,
mask_tensor,
int(region_y), # top
int(region_x), # left
int(r_width),
int(r_height)
image_tensor,
mask_tensor,
int(region_rect["y"]),
int(region_rect["x"]),
int(region_rect["width"]),
int(region_rect["height"]),
)
def _calculate_mask_bounds(self, mask):
"""计算遮罩的边界框"""
rows = np.any(mask > 0.5, axis=1)
cols = np.any(mask > 0.5, axis=0)
if not np.any(rows) or not np.any(cols):
return {'x': 0, 'y': 0, 'width': 0, 'height': 0}
return {"x": 0, "y": 0, "width": 0, "height": 0}
rmin, rmax = np.where(rows)[0][[0, -1]]
cmin, cmax = np.where(cols)[0][[0, -1]]
return {
'x': int(cmin),
'y': int(rmin),
'width': int(cmax - cmin + 1),
'height': int(rmax - rmin + 1)
"x": int(cmin),
"y": int(rmin),
"width": int(cmax - cmin + 1),
"height": int(rmax - rmin + 1),
}
def _calculate_region_rect(self, mask_bounds, padding):
return {
"x": mask_bounds["x"] - padding,
"y": mask_bounds["y"] - padding,
"width": mask_bounds["width"] + padding * 2,
"height": mask_bounds["height"] + padding * 2,
}
def _clamp_to_image(self, rect, img_width, img_height):
x = max(0, min(rect["x"], img_width - rect["width"]))
y = max(0, min(rect["y"], img_height - rect["height"]))
return {"x": x, "y": y, "width": rect["width"], "height": rect["height"]}
@classmethod
def IS_CHANGED(s, image, padding, region_coords=None):
return float("NaN")
+438 -240
View File
@@ -1,10 +1,14 @@
/**
* InpaintRegionEditor Extension
*
* 完全自己控制图像渲染,不依赖 ComfyUI 的图像预览
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// ==================== Photopea Bridge ====================
const PhotopeaBridge = {
iframe: null,
photopeaWindow: null,
@@ -15,284 +19,478 @@ const PhotopeaBridge = {
this.photopeaWindow = iframe.contentWindow;
},
async postMessage(message) {
var self = this;
postMessage(message) {
const self = this;
return new Promise(function(resolve, reject) {
if (!self.photopeaWindow) {
reject(new Error("Photopea not initialized"));
return;
}
var responses = [];
var timeoutId = null;
var done = false;
var handler = function(event) {
if (!self.photopeaWindow) { reject(new Error("not init")); return; }
const responses = [];
const timeoutId = setTimeout(function() { reject(new Error("timeout")); }, 60000);
const handler = function(event) {
if (event.origin !== self.PHOTOPEA_ORIGIN) return;
responses.push(event.data);
if (event.data === "done" && !done) {
done = true;
if (timeoutId) clearTimeout(timeoutId);
if (event.data === "done") {
clearTimeout(timeoutId);
window.removeEventListener("message", handler);
resolve(responses);
}
};
window.addEventListener("message", handler);
self.photopeaWindow.postMessage(message, "*");
timeoutId = setTimeout(function() {
if (!done) {
done = true;
window.removeEventListener("message", handler);
reject(new Error("Timeout"));
}
}, 60000);
});
},
async openImage(imageBlob) {
var self = this;
var reader = new FileReader();
async openImage(blob) {
const self = this;
return new Promise(function(resolve, reject) {
const reader = new FileReader();
reader.onloadend = async function() {
try {
await self.postMessage('app.open("' + reader.result + '", null, false);');
await self.postMessage('app.activeDocument.activeLayer.rasterize();');
resolve();
} catch (e) { reject(e); }
try { await self.postMessage('app.open("' + reader.result + '", null, false);'); resolve(); }
catch (e) { reject(e); }
};
reader.onerror = reject;
reader.readAsDataURL(imageBlob);
reader.readAsDataURL(blob);
});
},
async exportImage() {
var result = await this.postMessage('app.activeDocument.saveToOE("png");');
for (var i = 0; i < result.length; i++) {
if (result[i] instanceof ArrayBuffer) {
return new Blob([result[i]], { type: "image/png" });
}
async exportImage(format) {
const result = await this.postMessage('app.activeDocument.saveToOE("' + format + '");');
for (let i = 0; i < result.length; i++) {
if (result[i] instanceof ArrayBuffer) return new Blob([result[i]], { type: "image/" + format });
}
throw new Error("Export failed");
},
async exportLayerMask() {
console.log("[DEBUG] 开始导出图层蒙版");
// 步骤1: 检查是否有图层蒙版
console.log("[DEBUG] 步骤1: 检查图层蒙版...");
var checkResult = await this.postMessage('app.echoToOE(app.activeLayer.mask ? "HAS_MASK" : "NO_MASK");');
console.log("[DEBUG] 检查结果:", checkResult);
var hasMask = false;
for (var i = 0; i < checkResult.length; i++) {
if (checkResult[i] === "HAS_MASK") { hasMask = true; break; }
if (checkResult[i] === "NO_MASK") { console.log("[DEBUG] 没有图层蒙版"); return null; }
}
console.log("[DEBUG] 有图层蒙版:", hasMask);
if (!hasMask) { console.log("[DEBUG] 未检测到蒙版"); return null; }
// 步骤2: 检查蒙版是否启用
console.log("[DEBUG] 步骤2: 检查蒙版是否启用...");
var enabledResult = await this.postMessage('app.echoToOE(app.activeLayer.mask.enabled ? "ENABLED" : "DISABLED");');
console.log("[DEBUG] 启用状态:", enabledResult);
// 步骤3: 获取蒙版通道名称
console.log("[DEBUG] 步骤3: 获取通道列表...");
var channelsResult = await this.postMessage('app.echoToOE(JSON.stringify(app.activeDocument.channels.map(function(c){return c.name;})));');
console.log("[DEBUG] 通道列表:", channelsResult);
// 步骤4: 尝试简单的蒙版导出
console.log("[DEBUG] 步骤4: 尝试导出蒙版...");
var script = [
'(function(){',
' try {',
' var d = app.activeDocument;',
' var l = d.activeLayer;',
' ',
' if (!l.mask) { app.echoToOE("ERR_NO_MASK_OBJ"); return; }',
' if (!l.mask.enabled) { app.echoToOE("ERR_MASK_DISABLED"); return; }',
' ',
' // 选择蒙版通道',
' d.activeChannels = [d.channels.getByName("Mask")];',
' ',
' // 全选复制',
' d.selection.selectAll();',
' d.selection.copy();',
' ',
' // 恢复 RGB 通道',
' d.activeChannels = d.componentChannels;',
' ',
' // 创建新文档',
' var newDoc = app.documents.add(d.width, d.height);',
' newDoc.paste();',
' newDoc.saveToOE("png");',
' newDoc.close(SaveOptions.DONOTSAVECHANGES);',
' ',
' app.echoToOE("MASK_EXPORTED");',
' } catch(e) {',
' app.echoToOE("ERR_" + e.message);',
' }',
'})();'
].join('\n');
console.log("[DEBUG] 执行脚本...");
var result = await this.postMessage(script);
console.log("[DEBUG] 脚本结果:", result);
// 检查错误
for (var i = 0; i < result.length; i++) {
if (typeof result[i] === "string") {
if (result[i].indexOf("ERR_") === 0) {
console.log("[DEBUG] 脚本错误:", result[i]);
return null;
}
if (result[i] === "MASK_EXPORTED") {
console.log("[DEBUG] 蒙版导出成功");
}
}
}
// 查找 ArrayBuffer
for (var i = 0; i < result.length; i++) {
if (result[i] instanceof ArrayBuffer) {
console.log("[DEBUG] 找到图像数据,大小:", result[i].byteLength);
return new Blob([result[i]], { type: "image/png" });
}
}
console.log("[DEBUG] 未找到图像数据");
return null;
throw new Error("No image data");
}
};
const PhotopeaModal = {
modal: null,
node: null,
isOpen: false,
// ==================== Photopea Modal ====================
show(node, imagePath) {
this.node = node;
this.imagePath = imagePath;
this.isOpen = true;
this.createUI();
},
let ppState = { modal: null, node: null, path: null, open: false };
createUI() {
var self = this;
this.modal && this.modal.remove();
this.modal = document.createElement("div");
this.modal.className = "photopea-modal";
this.modal.innerHTML = "<div class='pp-container'><iframe id='pp-iframe' src='https://www.photopea.com/'></iframe><div class='pp-toolbar'><div class='pp-left'><span class='pp-hint'>图层>图层蒙版>显示全部,用画笔绘制蒙版</span></div><div class='pp-right'><button id='pp-save' class='pp-btn pp-save'>保存</button><button id='pp-cancel' class='pp-btn pp-cancel'>取消</button></div></div><div class='pp-status'><span id='pp-status'>加载中...</span></div></div>";
this.injectStyles();
document.body.appendChild(this.modal);
var iframe = document.getElementById("pp-iframe");
iframe.onload = async function() {
if (!self.isOpen) return;
PhotopeaBridge.init(iframe);
self.setStatus("加载图像...");
self.setupEvents();
await self.loadImage();
};
},
function showPhotopeaModal(node, imagePath) {
ppState = { modal: null, node, path: imagePath, open: true };
if (!document.getElementById("pp-style")) {
const style = document.createElement("style");
style.id = "pp-style";
style.textContent = `.pp-modal{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:99999;display:flex;align-items:center;justify-content:center}.pp-container{width:95%;height:95%;display:flex;flex-direction:column;background:#1e1e1e;border-radius:8px;overflow:hidden}#pp-iframe{flex:1;width:100%;border:none}.pp-toolbar{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:#2d2d2d;border-top:1px solid #444}.pp-hint{color:#999;font-size:13px}.pp-btn{padding:10px 18px;border:none;border-radius:6px;cursor:pointer;font-size:14px;margin-left:10px}.pp-save{background:#107c10;color:#fff}.pp-cancel{background:#555;color:#fff}.pp-status{padding:8px 16px;background:#1a1a1a;color:#888;font-size:12px}`;
document.head.appendChild(style);
}
ppState.modal = document.createElement("div");
ppState.modal.className = "pp-modal";
ppState.modal.innerHTML = `<div class="pp-container"><iframe id="pp-iframe" src="https://www.photopea.com/"></iframe><div class="pp-toolbar"><span class="pp-hint">编辑后点击保存</span><div><button id="pp-save" class="pp-btn pp-save">保存</button><button id="pp-cancel" class="pp-btn pp-cancel">取消</button></div></div><div class="pp-status" id="pp-status">加载中...</div></div>`;
document.body.appendChild(ppState.modal);
document.getElementById("pp-save").onclick = saveImg;
document.getElementById("pp-cancel").onclick = closePhotopeaModal;
document.getElementById("pp-iframe").onload = function() { if (ppState.open) { PhotopeaBridge.init(this); loadImg(); } };
}
injectStyles() {
if (document.getElementById("pp-styles")) return;
var s = document.createElement("style");
s.id = "pp-styles";
s.textContent = ".photopea-modal{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:99999;display:flex;align-items:center;justify-content:center}.pp-container{width:95%;height:95%;display:flex;flex-direction:column;background:#1e1e1e;border-radius:8px;overflow:hidden}#pp-iframe{flex:1;width:100%;border:none}.pp-toolbar{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:#2d2d2d;border-top:1px solid #444;gap:10px}.pp-left{display:flex;align-items:center}.pp-right{display:flex;gap:10px}.pp-hint{color:#999;font-size:13px}.pp-btn{padding:10px 18px;border:none;border-radius:6px;cursor:pointer;font-size:14px}.pp-btn:hover{transform:translateY(-1px)}.pp-save{background:#107c10;color:#fff}.pp-cancel{background:#555;color:#fff}.pp-status{padding:8px 16px;background:#1a1a1a;color:#888;font-size:12px}";
document.head.appendChild(s);
},
function setStatus(t) { const el = document.getElementById("pp-status"); if (el) el.textContent = t; }
setStatus(t) { if (this.isOpen) { var el = document.getElementById("pp-status"); if (el) el.textContent = t; } },
async function loadImg() {
try {
let filename = ppState.path, type = "input", subfolder = "";
const m = ppState.path.match(/^(.+?)\s*\[(\w+)\]$/);
if (m) { filename = m[1].trim(); type = m[2]; }
const idx = filename.lastIndexOf("/");
if (idx !== -1) { subfolder = filename.substring(0, idx); filename = filename.substring(idx + 1); }
let url = "/view?filename=" + encodeURIComponent(filename) + "&type=" + encodeURIComponent(type);
if (subfolder) url += "&subfolder=" + encodeURIComponent(subfolder);
const resp = await fetch(url);
if (!resp.ok) throw new Error("HTTP " + resp.status);
await PhotopeaBridge.openImage(await resp.blob());
setStatus("已加载");
} catch (e) { setStatus("错误: " + e.message); }
}
setupEvents() {
var self = this;
document.getElementById("pp-save").onclick = async function() { await self.saveAll(); };
document.getElementById("pp-cancel").onclick = function() { self.close(); };
this.escHandler = function(e) { if (e.key === "Escape") self.close(); };
document.addEventListener("keydown", this.escHandler);
},
async loadImage() {
try {
var path = this.imagePath || "";
var filename = path, type = "input", subfolder = "";
var m = path.match(/^(.+?)\s*\[(\w+)\]$/);
if (m) { filename = m[1].trim(); type = m[2]; }
var idx = filename.lastIndexOf("/");
if (idx !== -1) { subfolder = filename.substring(0, idx); filename = filename.substring(idx + 1); }
var url = "/view?filename=" + encodeURIComponent(filename) + "&type=" + type;
if (subfolder) url += "&subfolder=" + encodeURIComponent(subfolder);
var resp = await fetch(url);
if (!resp.ok) { this.setStatus("加载失败"); return; }
await PhotopeaBridge.openImage(await resp.blob());
this.setStatus("图像已加载。用 图层>图层蒙版>显示全部,画笔绘制");
} catch (e) { this.setStatus("加载失败: " + e.message); }
},
async saveAll() {
if (!this.isOpen) return;
try {
console.log("[SAVE] 开始保存...");
this.setStatus("导出图像...");
var imageBlob = await PhotopeaBridge.exportImage();
console.log("[SAVE] 图像大小:", imageBlob.size);
this.setStatus("检查蒙版...");
console.log("[SAVE] 检查蒙版...");
var maskBlob = await PhotopeaBridge.exportLayerMask();
console.log("[SAVE] 蒙版结果:", maskBlob ? maskBlob.size : "无");
this.setStatus("上传...");
var imgResult = await this.upload(imageBlob, "edited.png");
if (imgResult.name) { var w = this.node.widgets && this.node.widgets.find(function(x) { return x.name === "image"; }); if (w) w.value = imgResult.name; }
if (maskBlob) {
var maskResult = await this.upload(maskBlob, "mask.png");
if (maskResult.name) { var mw = this.node.widgets && this.node.widgets.find(function(x) { return x.name === "mask_path"; }); if (mw) mw.value = maskResult.name; }
}
this.node.setDirtyCanvas(true);
this.setStatus(maskBlob ? "已保存(含蒙版)" : "已保存(无蒙版)");
var self = this;
setTimeout(function() { self.close(); }, 500);
} catch (e) { console.error("[SAVE] 错误:", e); this.setStatus("失败: " + e.message); alert("保存失败: " + e.message); }
},
async upload(blob, name) {
var fd = new FormData();
fd.append("image", blob, name);
async function saveImg() {
if (!ppState.open) return;
try {
setStatus("导出中...");
const blob = await PhotopeaBridge.exportImage("png");
setStatus("上传中...");
const fd = new FormData();
fd.append("image", blob, "edited_" + Date.now() + ".png");
fd.append("type", "input");
var resp = await api.fetchApi("/upload/image", { method: "POST", body: fd });
return await resp.json();
},
const resp = await api.fetchApi("/upload/image", { method: "POST", body: fd });
const result = await resp.json();
if (result.name) {
const w = ppState.node.widgets?.find(x => x.name === "image");
if (w) { w.value = result.name; if (w.callback) w.callback(result.name); }
ppState.node.setDirtyCanvas(true);
setStatus("已保存");
setTimeout(closePhotopeaModal, 500);
}
} catch (e) { setStatus("错误: " + e.message); }
}
close() {
this.isOpen = false;
if (this.escHandler) { document.removeEventListener("keydown", this.escHandler); this.escHandler = null; }
if (this.modal) { this.modal.remove(); this.modal = null; }
this.node = null;
function closePhotopeaModal() {
ppState.open = false;
if (ppState.modal) { ppState.modal.remove(); ppState.modal = null; }
ppState.node = null; ppState.path = null;
}
// ==================== 图像和选区数据 ====================
const nodeData = new Map(); // 存储每个节点的图像和选区数据
// 加载图像并检测蒙版
async function loadImageAndDetectMask(node, imageName) {
if (!imageName) return;
try {
// 解析文件名
let filename = imageName, type = "input", subfolder = "";
const m = imageName.match(/^(.+?)\s*\[(\w+)\]$/);
if (m) { filename = m[1].trim(); type = m[2]; }
const idx = filename.lastIndexOf("/");
if (idx !== -1) { subfolder = filename.substring(0, idx); filename = filename.substring(idx + 1); }
// 获取图像
let url = "/view?filename=" + encodeURIComponent(filename) + "&type=" + encodeURIComponent(type);
if (subfolder) url += "&subfolder=" + encodeURIComponent(subfolder);
const resp = await fetch(url);
if (!resp.ok) return;
const blob = await resp.blob();
// 创建图像对象
const img = new Image();
img.onload = function() {
// 检测蒙版
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
// 找蒙版边界
let hasMask = false;
let minX = img.width, minY = img.height, maxX = 0, maxY = 0;
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
const i = (y * img.width + x) * 4;
const alpha = pixels[i + 3];
if (alpha < 255) {
hasMask = true;
if (alpha < 128) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
}
}
const padding = node.widgets?.find(w => w.name === "padding")?.value ?? 64;
// 存储数据
const data = {
image: img,
imageWidth: img.width,
imageHeight: img.height,
hasMask: hasMask
};
if (hasMask && minX <= maxX && minY <= maxY) {
data.maskBounds = { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
data.regionX = Math.max(0, minX - padding);
data.regionY = Math.max(0, minY - padding);
data.regionWidth = (maxX - minX + 1) + padding * 2;
data.regionHeight = (maxY - minY + 1) + padding * 2;
}
nodeData.set(node.id, data);
node.setDirtyCanvas(true);
};
img.src = URL.createObjectURL(blob);
} catch (e) {
console.error("Load image error:", e);
}
};
}
// ==================== 绘制 ====================
function drawNode(ctx, node) {
const data = nodeData.get(node.id);
// 如果没有加载图像,就不画
if (!data || !data.image || !data.image.complete) return;
const img = data.image;
// 计算 widgets 占用的高度
let widgetsHeight = 0;
if (node.widgets) {
for (let i = node.widgets.length - 1; i >= 0; i--) {
if (node.widgets[i].last_y !== undefined) {
widgetsHeight = node.widgets[i].last_y + 25;
break;
}
}
}
// 图像绘制区域
const margin = 5;
const imgAreaY = widgetsHeight + margin;
const imgAreaW = node.size[0] - margin * 2;
const imgAreaH = node.size[1] - imgAreaY - margin;
if (imgAreaW <= 0 || imgAreaH <= 0) return;
// 计算缩放(适应区域)
const scale = Math.min(imgAreaW / img.naturalWidth, imgAreaH / img.naturalHeight);
const imgW = img.naturalWidth * scale;
const imgH = img.naturalHeight * scale;
// 居中
const imgX = margin + (imgAreaW - imgW) / 2;
const imgY = imgAreaY + (imgAreaH - imgH) / 2;
// 画图像
ctx.drawImage(img, imgX, imgY, imgW, imgH);
// 画选区框(如果有蒙版)
if (data.hasMask && data.maskBounds) {
// 橙色选区框
const rx = imgX + data.regionX * scale;
const ry = imgY + data.regionY * scale;
const rw = data.regionWidth * scale;
const rh = data.regionHeight * scale;
ctx.fillStyle = "rgba(255, 165, 0, 0.2)";
ctx.fillRect(rx, ry, rw, rh);
ctx.strokeStyle = "rgb(255, 165, 0)";
ctx.lineWidth = 2;
ctx.setLineDash([5, 3]);
ctx.strokeRect(rx, ry, rw, rh);
ctx.setLineDash([]);
// 红色遮罩区域
const mx = imgX + data.maskBounds.x * scale;
const my = imgY + data.maskBounds.y * scale;
const mw = data.maskBounds.width * scale;
const mh = data.maskBounds.height * scale;
ctx.fillStyle = "rgba(255, 0, 0, 0.4)";
ctx.fillRect(mx, my, mw, mh);
// 标签
ctx.font = "11px sans-serif";
ctx.fillStyle = "rgba(255, 165, 0, 0.9)";
ctx.fillRect(rx + 2, ry + 2, 70, 14);
ctx.fillStyle = "#000";
ctx.fillText("选区 " + data.regionWidth + "×" + data.regionHeight, rx + 4, ry + 12);
ctx.fillStyle = "rgba(255, 0, 0, 0.9)";
ctx.fillRect(mx + 2, my + 2, 70, 14);
ctx.fillStyle = "#fff";
ctx.fillText("遮罩 " + data.maskBounds.width + "×" + data.maskBounds.height, mx + 4, my + 12);
}
}
// ==================== 拖动 ====================
let dragging = null;
function getImageDrawParams(node) {
const data = nodeData.get(node.id);
if (!data || !data.image || !data.image.complete) return null;
const img = data.image;
let widgetsHeight = 0;
if (node.widgets) {
for (let i = node.widgets.length - 1; i >= 0; i--) {
if (node.widgets[i].last_y !== undefined) {
widgetsHeight = node.widgets[i].last_y + 25;
break;
}
}
}
const margin = 5;
const imgAreaY = widgetsHeight + margin;
const imgAreaW = node.size[0] - margin * 2;
const imgAreaH = node.size[1] - imgAreaY - margin;
if (imgAreaW <= 0 || imgAreaH <= 0) return null;
const scale = Math.min(imgAreaW / img.naturalWidth, imgAreaH / img.naturalHeight);
const imgW = img.naturalWidth * scale;
const imgH = img.naturalHeight * scale;
const imgX = margin + (imgAreaW - imgW) / 2;
const imgY = imgAreaY + (imgAreaH - imgH) / 2;
return { imgX, imgY, scale };
}
function onMouseDown(node, pos, e) {
const data = nodeData.get(node.id);
if (!data || !data.hasMask) return false;
const params = getImageDrawParams(node);
if (!params) return false;
// pos 已经是节点本地坐标
const localX = pos[0];
const localY = pos[1];
// 转图像坐标
const imgX = (localX - params.imgX) / params.scale;
const imgY = (localY - params.imgY) / params.scale;
// 检查是否在选区内
const inRegion = imgX >= data.regionX &&
imgX <= data.regionX + data.regionWidth &&
imgY >= data.regionY &&
imgY <= data.regionY + data.regionHeight;
if (inRegion) {
dragging = {
node: node,
startImgX: imgX,
startImgY: imgY,
origRegionX: data.regionX,
origRegionY: data.regionY
};
return true; // 消费事件,阻止节点拖动
}
return false;
}
function onMouseMove(e, pos, node) {
// 如果正在拖动
if (dragging) {
const data = nodeData.get(dragging.node.id);
if (!data) return;
const params = getImageDrawParams(dragging.node);
if (!params) return;
// pos 已经是节点本地坐标
const localX = pos[0];
const localY = pos[1];
const imgX = (localX - params.imgX) / params.scale;
const imgY = (localY - params.imgY) / params.scale;
// 计算偏移
const dx = imgX - dragging.startImgX;
const dy = imgY - dragging.startImgY;
// 更新选区位置
data.regionX = Math.max(0, Math.min(dragging.origRegionX + dx, data.imageWidth - data.regionWidth));
data.regionY = Math.max(0, Math.min(dragging.origRegionY + dy, data.imageHeight - data.regionHeight));
// 同步到 widget
const coordsWidget = dragging.node.widgets?.find(w => w.name === "region_coords");
if (coordsWidget) {
coordsWidget.value = JSON.stringify({
x: Math.round(data.regionX),
y: Math.round(data.regionY),
width: data.regionWidth,
height: data.regionHeight
});
}
dragging.node.setDirtyCanvas(true);
}
}
function onMouseUp() {
dragging = null;
}
// ==================== 注册扩展 ====================
app.registerExtension({
name: "comfyui.inpaint_region_editor",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
name: "InpaintRegionEditor",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "InpaintRegionEditor") return;
var getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
// 右键菜单
const origGetMenu = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function(canvas, options) {
getExtraMenuOptions && getExtraMenuOptions.apply(this, arguments);
var node = this;
if (origGetMenu) origGetMenu.apply(this, arguments);
const hasPp = options.some(o => o && o.content && o.content.includes("Photopea"));
if (hasPp) return;
const node = this;
const imgW = node.widgets?.find(w => w.name === "image");
options.push(null);
options.push({ content: "Open in Photopea", callback: function() {
var w = node.widgets && node.widgets.find(function(x) { return x.name === "image"; });
if (w && w.value) { PhotopeaModal.show(node, w.value); } else { alert("请先上传图像"); }
}});
return options;
options.push({
content: "打开 Photopea 编辑",
callback: function() { imgW?.value ? showPhotopeaModal(node, imgW.value) : alert("请先选择图像"); }
});
};
var onNodeCreated = nodeType.prototype.onNodeCreated;
// 自己绘制图像和选区框
const origDrawBg = nodeType.prototype.onDrawBackground;
nodeType.prototype.onDrawBackground = function(ctx) {
if (origDrawBg) origDrawBg.apply(this, arguments);
drawNode(ctx, this);
};
// 节点创建
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
var r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
this.addWidget("STRING", "mask_path", "", function() {}, { serialize: true });
this.addWidget("STRING", "mask_bounds", "{}", function() {}, { serialize: true });
return r;
if (onNodeCreated) onNodeCreated.apply(this, arguments);
const node = this;
// 监听图像变化
const imgW = node.widgets?.find(w => w.name === "image");
if (imgW) {
const origCb = imgW.callback;
imgW.callback = function(v) {
if (origCb) origCb.apply(this, arguments);
loadImageAndDetectMask(node, v);
};
}
// 监听 padding 变化
const padW = node.widgets?.find(w => w.name === "padding");
if (padW) {
const origCb = padW.callback;
padW.callback = function(v) {
if (origCb) origCb.apply(this, arguments);
const data = nodeData.get(node.id);
if (data && data.hasMask && data.maskBounds) {
data.regionX = Math.max(0, data.maskBounds.x - v);
data.regionY = Math.max(0, data.maskBounds.y - v);
data.regionWidth = data.maskBounds.width + v * 2;
data.regionHeight = data.maskBounds.height + v * 2;
}
node.setDirtyCanvas(true);
};
}
};
// 鼠标事件
const origMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function(e, pos, canvas) {
if (onMouseDown(this, pos, e)) {
return true; // 阻止节点拖动
}
return origMouseDown ? origMouseDown.apply(this, arguments) : false;
};
// 鼠标移动
const origMouseMove = nodeType.prototype.onMouseMove;
nodeType.prototype.onMouseMove = function(e, pos, canvas) {
onMouseMove(e, pos, this);
return origMouseMove ? origMouseMove.apply(this, arguments) : false;
};
// 鼠标释放
const origMouseUp = nodeType.prototype.onMouseUp;
nodeType.prototype.onMouseUp = function(e, pos, canvas) {
onMouseUp();
return origMouseUp ? origMouseUp.apply(this, arguments) : false;
};
}
});
console.log("[InpaintRegionEditor] Loaded");