commit cf0f8e404ac8363abf452e055e967f8ae49bc69a Author: InpaintRegionEditor Dev Date: Fri Feb 27 19:55:26 2026 +0800 Initial commit: Photopea integration for inpaint editing diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee81777 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# ComfyUI InpaintRegionEditor + +增强版遮罩编辑器,支持 Photopea 集成和可配置重绘区域。 + +## 功能特性 + +- 🎨 **Photopea 集成**:完整 Photoshop 功能(液化、仿制图章、滤镜等) +- 📐 **可配置选区**:预设尺寸(512×512 SD、1024×1024 SDXL 等)+ 自定义 +- 🎯 **自动定位**:选区自动居中于遮罩 +- ✅ **约束验证**:选区必须包含遮罩 + +## 概念说明 + +| 概念 | 目的 | 作用 | +|------|------|------| +| **遮罩 (Mask)** | 定义重绘区域 | 重绘完成后只在遮罩内合成新内容 | +| **选区 (Region)** | 定义参考区域 | 大模型重绘时参考这个区域内的原图内容 | + +**关键约束**:选区必须 ≥ 遮罩(选区必须完全包含遮罩) + +## 安装 + +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/yourname/ComfyUI-InpaintRegionEditor.git +``` + +## 使用方法 + +1. 添加 "Inpaint Region Editor" 节点 +2. 上传图像 +3. 右键点击节点 → "🎨 Open in Photopea" +4. 在 Photopea 中: + - 编辑图像(液化、仿制图章等)- 可选 + - 使用选区工具绘制遮罩(要重绘的区域) +5. 点击 "📐 从选区获取遮罩" → 点击 "💾 保存到节点" +6. 选择选区尺寸(预设或自定义) +7. 执行工作流 + +## 节点输出 + +| 输出 | 类型 | 说明 | +|------|------|------| +| `image` | IMAGE | 编辑后的图像 | +| `mask` | MASK | 遮罩(重绘区域) | +| `region_top` | INT | 选区上边距 | +| `region_left` | INT | 选区左边距 | +| `region_width` | INT | 选区宽度 | +| `region_height` | INT | 选区高度 | + +## 工作原理 + +``` +1. 大模型参考选区内的原图内容 +2. 重绘整个选区区域 +3. 根据遮罩合成:只在遮罩内放入新内容 + +结果:选区越大 → 参考信息越多 → 重绘越准确 +``` + +## 注意事项 + +- **网络依赖**:首次打开 Photopea 需要联网(约 10MB) +- **浏览器缓存**:缓存后可离线使用 +- **隐私**:图像处理在本地完成,不上传到服务器 + +## 图像编辑功能说明 + +本节点的图像编辑功能基于 [Photopea](https://www.photopea.com/),一个免费的在线图像编辑器。 + +- Photopea 是由 Ivan Kutskir 开发的免费软件 +- 图像处理在您的浏览器本地完成,不上传到任何服务器 +- 使用 Photopea API 是免费的,但会显示广告 +- 您编辑的所有图像归您所有 + +## 法律声明 + +- Photopea API 可免费用于任何目的(包括商业用途) +- 您的作品完全归您所有,无需分享收益 +- 无需标注使用了 Photopea(但建议标注) + +## 许可证 + +本项目采用 MIT 许可证。 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..6f90a78 --- /dev/null +++ b/__init__.py @@ -0,0 +1,18 @@ +""" +@author: InpaintRegionEditor +@title: Inpaint Region Editor +@description: Enhanced mask editor with Photopea integration and configurable inpaint region +""" + +from .nodes import InpaintRegionEditor + +NODE_CLASS_MAPPINGS = { + "InpaintRegionEditor": InpaintRegionEditor +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "InpaintRegionEditor": "Inpaint Region Editor" +} + +WEB_DIRECTORY = "./web" +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/__pycache__/__init__.cpython-313.pyc b/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..5b369a3 Binary files /dev/null and b/__pycache__/__init__.cpython-313.pyc differ diff --git a/__pycache__/nodes.cpython-313.pyc b/__pycache__/nodes.cpython-313.pyc new file mode 100644 index 0000000..3f5d3fe Binary files /dev/null and b/__pycache__/nodes.cpython-313.pyc differ diff --git a/docs/InpaintRegionEditor_Implementation_Plan.md b/docs/InpaintRegionEditor_Implementation_Plan.md new file mode 100644 index 0000000..b92c6ce --- /dev/null +++ b/docs/InpaintRegionEditor_Implementation_Plan.md @@ -0,0 +1,1190 @@ +# ComfyUI InpaintRegionEditor - 完整实现方案 + +> 基于深度调研制定的技术方案,供下一个会话实现 +> +> 版本:5.0 | 更新时间:2026-02-27 + +--- + +## 一、用户需求确认 + +### 1.1 业务需求来源 + +用户希望为 ComfyUI 的局部重绘(Inpaint)功能增加: + +1. **功能1:可拖动重绘区域框** + - 原有问题:局部重绘需要遮罩向外扩散,不够精确 + - 新需求:手工录入区域框尺寸(如 512×512),可拖动框住遮罩位置 + - 输出增加:`region_top`, `region_left`, `region_width`, `region_height` + +2. **功能2:PS 类功能** + - 液化、仿制图章等能力 + - 在重绘前可手工调整原图 + +### 1.2 技术选型决策 + +| 需求 | 选择 | 原因 | +|------|------|------| +| **前端架构** | 独立编辑器 | 不依赖原有 MaskEditor,实现更简单 | +| **区域框交互** | 自动计算 + 手动微调 | 最佳用户体验 | +| **PS 功能** | 嵌入 Photopea | 零开发成本,功能完整 | +| **约束** | 纯 custom node | 不修改 ComfyUI 核心代码 | + +--- + +## 二、Photopea 深度解析 + +### 2.1 Photopea 是什么? + +**一句话定义**:Photopea = 浏览器里的 Photoshop + +| 属性 | 详情 | +|------|------| +| **开发者** | Ivan Kutskir(1人,乌克兰/捷克)| +| **开发时间** | 2013年至今(10+ 年)| +| **代码量** | 138,541 行 JavaScript | +| **月活用户** | 1300 万+ | +| **年收入** | ~$100-120 万美元(广告+订阅)| +| **支持格式** | 40+ 种(PSD, AI, Sketch, Figma...)| +| **成本** | 免费(API 完全免费)| + +### 2.2 为什么能实现完整 PS 功能? + +``` +Photopea 的 138k 行代码包括: +├── PSD 解析器(逆向工程 Adobe 格式) +├── 图层系统(完整的图层树结构) +├── 渲染引擎(Canvas 2D + WebGL) +├── 滤镜系统(高斯模糊、液化、锐化...) +├── 选区算法(魔棒、套索、快速选择...) +├── 文字引擎(字体渲染、排版) +├── 脚本引擎(兼容 Adobe JSX) +└── 40+ 格式解析器 +``` + +**这是一个人用 10 年写出来的代码量,功能追平 Photoshop。** + +### 2.3 技术架构 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Photopea 技术栈 │ +├─────────────────────────────────────────────────────────────┤ +│ 渲染引擎: Canvas 2D + WebGL │ +│ 图像处理: JavaScript + WebAssembly │ +│ 文件格式: 自定义解析器(PSD, AI, SVG 等 40+ 格式) │ +│ UI: 自定义 HTML/CSS(非框架) │ +│ 存储: IndexedDB(本地缓存) │ +│ 性能优化: Web Workers(多线程) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.4 网络依赖问题 + +**重要**:Photopea 是「本地处理 + 联网加载代码」的混合架构。 + +| 功能 | 是否需要联网 | 说明 | +|------|-------------|------| +| **打开/编辑图像** | ❌ 不需要 | 纯本地处理 | +| **液化/滤镜** | ❌ 不需要 | 纯本地计算 | +| **保存文件** | ❌ 不需要 | 纯本地操作 | +| **首次打开** | ✅ 需要 | 下载核心代码(~10MB)| +| **字体选择** | ⚠️ 部分 | 字体预览缩略图需下载 | +| **广告显示** | ✅ 需要 | 广告从服务器加载 | + +**断网打不开的原因**: +1. 首次需要下载 ~10MB 的 JavaScript 代码 +2. 浏览器缓存被清理后需要重新下载 +3. 字体预览缩略图(7600+ 字体,2.75GB)需要从服务器获取 + +**解决方案**: +- 首次联网加载后,不要清理浏览器缓存 +- 如需完全离线,可购买 Self-Hosted 版本($500-$2000/月) + +### 2.5 嵌入原理 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 宿主页面 (ComfyUI) │ +│ │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ +
+
+ 💡 提示:在 Photopea 中绘制遮罩(要重绘的区域) +
+
+ + + +
+
+
+ 正在加载 Photopea... + +
+ + `; + + document.body.appendChild(this.modal); + + // 初始化 PhotopeaBridge + const iframe = document.getElementById('photopea-iframe'); + iframe.onload = () => { + PhotopeaBridge.init(iframe); + document.getElementById('status-text').textContent = 'Photopea 已就绪'; + }; + + this.setupEventListeners(); + } + + static setupEventListeners() { + // 从选区获取遮罩 + document.getElementById('btn-get-mask').addEventListener('click', async () => { + try { + const bounds = await PhotopeaBridge.getSelectionBounds(); + if (bounds) { + this.maskBounds = bounds; + document.getElementById('mask-info').style.display = 'inline'; + document.getElementById('mask-size').textContent = + `${bounds.width}×${bounds.height}`; + document.getElementById('status-text').textContent = + `✅ 已获取遮罩边界:(${bounds.x}, ${bounds.y}) ${bounds.width}×${bounds.height}`; + } else { + alert('请先在 Photopea 中创建选区!'); + } + } catch (e) { + alert('获取选区失败:' + e.message); + } + }); + + // 保存到节点 + document.getElementById('btn-save').addEventListener('click', async () => { + await this.saveToNode(); + }); + + // 取消 + document.getElementById('btn-cancel').addEventListener('click', () => { + this.close(); + }); + } + + static async loadImage(imagePath) { + try { + const response = await fetch(`/view?filename=${imagePath}&type=input`); + const blob = await response.blob(); + await PhotopeaBridge.openImage(blob); + document.getElementById('status-text').textContent = '图像已加载,请在 Photopea 中编辑并创建选区'; + } catch (e) { + document.getElementById('status-text').textContent = '加载图像失败:' + e.message; + } + } + + static async saveToNode() { + if (!this.maskBounds) { + // 尝试获取当前选区作为遮罩 + try { + const bounds = await PhotopeaBridge.getSelectionBounds(); + if (bounds) { + this.maskBounds = bounds; + } else { + alert('请先创建选区作为遮罩!'); + return; + } + } catch (e) { + alert('请先创建选区作为遮罩!'); + return; + } + } + + try { + document.getElementById('status-text').textContent = '正在导出图像和遮罩...'; + + // 导出图像和遮罩 + const { image, mask } = await PhotopeaBridge.exportImageAndMask(); + + // 上传到 ComfyUI + const imageResult = await this.uploadToComfyUI(image, 'edited_image.png'); + const maskResult = await this.uploadToComfyUI(mask, 'mask.png'); + + // 更新节点 + const imageWidget = this.node.widgets?.find(w => w.name === 'image'); + const maskBoundsWidget = this.node.widgets?.find(w => w.name === 'mask_bounds'); + + if (imageWidget) { + imageWidget.value = imageResult.name; + } + + if (maskBoundsWidget) { + maskBoundsWidget.value = JSON.stringify(this.maskBounds); + } + + this.node.setDirtyCanvas(true); + + document.getElementById('status-text').textContent = '✅ 已保存到节点'; + + setTimeout(() => this.close(), 500); + + } catch (e) { + alert('保存失败:' + 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; + this.maskBounds = null; + } +} +``` + +### 5.3 `web/photopea-bridge.js` + +```javascript +/** + * Photopea 通信桥接 + * 安全地处理与 Photopea iframe 的 postMessage 通信 + */ +export class PhotopeaBridge { + static iframe = null; + static photopeaWindow = null; + + // 安全:只接受来自 photopea.com 的消息 + static PHOTOPEA_ORIGIN = "https://www.photopea.com"; + + static init(iframe) { + this.iframe = iframe; + this.photopeaWindow = iframe.contentWindow; + } + + /** + * 发送消息到 Photopea 并等待响应 + * @param {string|ArrayBuffer} message - 要发送的消息 + * @returns {Promise} 响应数组 + */ + static async postMessage(message) { + return new Promise((resolve, reject) => { + if (!this.photopeaWindow) { + reject(new Error('Photopea not initialized')); + return; + } + + const responses = []; + + const handler = (event) => { + // ✅ 安全验证:只接受来自 photopea.com 的消息 + if (event.origin !== this.PHOTOPEA_ORIGIN) { + console.warn('[PhotopeaBridge] Ignored message from unknown origin:', event.origin); + return; + } + + responses.push(event.data); + + if (event.data === "done") { + window.removeEventListener("message", handler); + resolve(responses); + } + }; + + window.addEventListener("message", handler); + + // 发送消息("*" 用于发送是可接受的) + this.photopeaWindow.postMessage(message, "*"); + + // 超时处理 + setTimeout(() => { + window.removeEventListener("message", handler); + reject(new Error('Photopea timeout (60s)')); + }, 60000); + }); + } + + /** + * 打开图像到 Photopea + * @param {Blob} blob - 图像 Blob + */ + static async openImage(blob) { + const base64 = await this.blobToBase64(blob); + await this.postMessage(`app.open("${base64}", null, false);`); + await this.postMessage(`app.activeDocument.activeLayer.rasterize();`); + } + + /** + * 获取选区边界 + * @returns {Object|null} { x, y, width, height } 或 null(无选区) + */ + static async getSelectionBounds() { + try { + const result = await this.postMessage( + 'app.echoToOE(app.activeDocument.selection.bounds);' + ); + + if (result[0] && typeof result[0] === 'string') { + const [left, top, right, bottom] = result[0].split(',').map(Number); + return { + x: left, + y: top, + width: right - left, + height: bottom - top + }; + } + return null; + } catch (e) { + return null; + } + } + + /** + * 从选区创建遮罩并导出图像和遮罩 + * @returns {Object} { image: Blob, mask: Blob } + */ + static async exportImageAndMask() { + // 导出原图 + const imageResult = await this.postMessage('app.activeDocument.saveToOE("png");'); + const imageArrayBuffer = imageResult.find(r => r instanceof ArrayBuffer); + const imageBlob = new Blob([imageArrayBuffer], { type: 'image/png' }); + + // 创建遮罩 + const createMaskScript = ` + (function() { + var doc = app.activeDocument; + if (!doc.selection.bounds) return; + + // 创建遮罩图层 + var maskLayer = doc.artLayers.add(); + maskLayer.name = "TempMaskLayer"; + + // 反选填充黑色 + doc.selection.invert(); + var black = new SolidColor(); + black.rgb.red = 0; + black.rgb.green = 0; + black.rgb.blue = 0; + doc.selection.fill(black); + + // 恢复选区填充白色 + doc.selection.invert(); + var white = new SolidColor(); + white.rgb.red = 255; + white.rgb.green = 255; + white.rgb.blue = 255; + doc.selection.fill(white); + + // 导出 + doc.saveToOE("png"); + + // 删除临时图层 + maskLayer.remove(); + })(); + `; + + const maskResult = await this.postMessage(createMaskScript); + const maskArrayBuffer = maskResult.find(r => r instanceof ArrayBuffer); + const maskBlob = new Blob([maskArrayBuffer], { type: 'image/png' }); + + return { image: imageBlob, mask: maskBlob }; + } + + /** + * Blob 转 Base64 + */ + static blobToBase64(blob) { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.readAsDataURL(blob); + }); + } +} +``` + +### 5.4 `web/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: 10px 15px; + background: #2d2d2d; + border-top: 1px solid #444; +} + +.toolbar-left { + display: flex; + align-items: center; + gap: 10px; +} + +.toolbar-right { + display: flex; + align-items: center; + gap: 10px; +} + +.toolbar-hint { + color: #888; + font-size: 13px; +} + +.btn { + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: background 0.2s; +} + +.btn-primary { + background: #0078d4; + color: white; +} + +.btn-primary:hover { + background: #106ebe; +} + +.btn-success { + background: #107c10; + color: white; +} + +.btn-success:hover { + background: #0b5c0b; +} + +.btn-secondary { + background: #555; + color: white; +} + +.btn-secondary:hover { + background: #666; +} + +.photopea-statusbar { + display: flex; + justify-content: space-between; + padding: 8px 15px; + background: #1a1a1a; + color: #888; + font-size: 12px; +} +``` + +--- + +## 六、关键安全措施 + +### 6.1 postMessage 安全验证 + +```javascript +// ✅ 正确:验证消息来源 +const handler = (event) => { + if (event.origin !== "https://www.photopea.com") { + return; // 忽略非 Photopea 来源的消息 + } + // 处理消息... +}; +``` + +### 6.2 超时处理 + +```javascript +// 防止无限等待 +setTimeout(() => { + window.removeEventListener("message", handler); + reject(new Error('Photopea timeout')); +}, 60000); +``` + +### 6.3 错误处理 + +```javascript +try { + const result = await PhotopeaBridge.postMessage(message); + // 处理结果... +} catch (e) { + alert('操作失败:' + e.message); +} +``` + +--- + +## 七、约束验证 + +### 7.1 选区必须 >= 遮罩 + +```javascript +// 前端验证 +function validateRegionSize(maskBounds, regionWidth, regionHeight) { + if (regionWidth < maskBounds.width || regionHeight < maskBounds.height) { + return { + valid: false, + message: `选区太小!必须 ≥ 遮罩 (${maskBounds.width}×${maskBounds.height})` + }; + } + return { valid: true }; +} + +// 后端验证(Python) +if r_width < mask_w: + r_width = mask_w # 自动扩大 +if r_height < mask_h: + r_height = mask_h +``` + +--- + +## 八、实现检查清单 + +- [ ] `__init__.py` - 节点注册 +- [ ] `nodes.py` - 后端节点逻辑 +- [ ] `extension.js` - 右键菜单集成 +- [ ] `photopea-modal.js` - 全屏对话框 +- [ ] `photopea-bridge.js` - 通信桥接 +- [ ] `style.css` - 样式 +- [ ] 测试:图像上传 +- [ ] 测试:Photopea 打开 +- [ ] 测试:遮罩创建 +- [ ] 测试:选区验证 +- [ ] 测试:保存到节点 + +--- + +## 九、参考资源 + +- **Photopea API**: https://www.photopea.com/api +- **sd-webui-photopea-embed**: https://github.com/yankooliveira/sd-webui-photopea-embed +- **stable-diffusion-ps-pea** (推荐): https://github.com/huchenlei/stable-diffusion-ps-pea +- **ComfyUI Impact-Pack**: E:\ComfyUI\custom_nodes\ComfyUI-Impact-Pack\ + +--- + +*文档版本: 6.0 | 更新时间: 2026-02-27* +*基于 ULW 头脑风暴会话修订* diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..647a34f --- /dev/null +++ b/nodes.py @@ -0,0 +1,173 @@ +""" +InpaintRegionEditor Node + +增强版遮罩编辑器,支持 Photopea 集成和可配置选区 + +概念说明: +- 遮罩 (Mask): 定义重绘区域,重绘完成后只在遮罩内合成新内容 +- 选区 (Region): 定义参考区域,大模型重绘时参考这个区域内的原图内容 + +约束:选区必须 >= 遮罩 +""" + +import torch +import numpy as np +import folder_paths +from PIL import Image +import os +import json + + +class InpaintRegionEditor: + """ + 增强版遮罩编辑器,支持 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 = 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 + }), + }, + "hidden": { + # 遮罩边界信息(前端传递) + "mask_bounds": "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, region_size, region_width, region_height, + mask_bounds=None): + """ + 处理图像和遮罩,计算选区位置 + + Args: + image: 图像文件名 + region_size: 选区预设名称 + region_width: 自定义选区宽度 + region_height: 自定义选区高度 + mask_bounds: 遮罩边界 JSON 字符串 + """ + # 加载图像 + 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 遮罩约定 + else: + mask = np.zeros((img_height, img_width), dtype=np.float32) + + # 解析遮罩边界 + mask_bounds_dict = None + if mask_bounds: + try: + mask_bounds_dict = json.loads(mask_bounds) + 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)) + + # 转换为 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) + ) + + 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) + } diff --git a/web/extension.js b/web/extension.js new file mode 100644 index 0000000..526b7c6 --- /dev/null +++ b/web/extension.js @@ -0,0 +1,250 @@ +/** + * InpaintRegionEditor Extension + * + * 流程: + * 1. 打开:图像 + 蒙版 -> Photopea + * 2. 编辑:用户编辑图像和蒙版 + * 3. 保存:图像 + 蒙版 -> ComfyUI + */ + +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +const PhotopeaBridge = { + iframe: null, + photopeaWindow: null, + PHOTOPEA_ORIGIN: "https://www.photopea.com", + + init(iframe) { + this.iframe = iframe; + this.photopeaWindow = iframe.contentWindow; + console.log("[PhotopeaBridge] Initialized"); + }, + + async postMessage(message) { + return new Promise((resolve, reject) => { + if (!this.photopeaWindow) { + reject(new Error("Photopea 未初始化")); + return; + } + const responses = []; + let timeoutId = null; + const handler = (event) => { + if (event.origin !== this.PHOTOPEA_ORIGIN) return; + responses.push(event.data); + if (event.data === "done") { + if (timeoutId) clearTimeout(timeoutId); + window.removeEventListener("message", handler); + resolve(responses); + } + }; + window.addEventListener("message", handler); + this.photopeaWindow.postMessage(message, "*"); + timeoutId = setTimeout(() => { + window.removeEventListener("message", handler); + reject(new Error("超时")); + }, 120000); + }); + }, + + async openImageWithMask(imageBlob, maskBlob) { + const imageBase64 = await this.blobToBase64(imageBlob); + await this.postMessage('app.open("' + imageBase64 + '", null, false);'); + + if (maskBlob) { + const maskBase64 = await this.blobToBase64(maskBlob); + await this.postMessage('app.open("' + maskBase64 + '", null, true);'); + var script = '(function(){var d=app.activeDocument;if(d.layers.length<2)return;var m=d.activeLayer;var i=d.layers[d.layers.length-1];d.activeLayer=m;var b=m.bounds;if(b){d.selection.select([[b[0],b[1]],[b[2],b[1]],[b[2],b[3]],[b[0],b[3]]]);d.selection.copy();d.activeLayer=i;i.addMask();d.activeChannels=[d.channels.getByName("Mask")];d.selection.selectAll();d.paste();m.remove();d.activeChannels=d.componentChannels;}})();'; + await this.postMessage(script); + } + await this.postMessage('app.activeDocument.activeLayer.rasterize();'); + console.log("[PhotopeaBridge] Image opened"); + }, + + async exportImage() { + var result = await this.postMessage('app.activeDocument.saveToOE("png");'); + var arrayBuffer = result.find(r => r instanceof ArrayBuffer); + if (!arrayBuffer) throw new Error("导出图像失败"); + return new Blob([arrayBuffer], { type: "image/png" }); + }, + + async exportMask() { + var script = '(function(){var d=app.activeDocument;var l=d.activeLayer;if(!l.mask||!l.mask.enabled){app.echoToOE("NO_MASK");return;}var t=d.artLayers.add();t.name="__temp__";var mc=d.channels.getByName("Mask");d.activeChannels=[mc];d.selection.selectAll();d.selection.copy();d.activeChannels=d.componentChannels;d.activeLayer=t;d.selection.selectAll();d.paste();l.visible=false;d.saveToOE("png");t.remove();l.visible=true;d.activeLayer=l;})();'; + var result = await this.postMessage(script); + if (result.indexOf("NO_MASK") >= 0) return null; + var arrayBuffer = result.find(r => r instanceof ArrayBuffer); + return arrayBuffer ? new Blob([arrayBuffer], { type: "image/png" }) : null; + }, + + blobToBase64(blob) { + return new Promise((resolve, reject) => { + var reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } +}; + +const PhotopeaModal = { + modal: null, + node: null, + imagePath: null, + maskPath: null, + + show(node, imagePath, maskPath) { + this.node = node; + this.imagePath = imagePath; + this.maskPath = maskPath; + this.createUI(); + }, + + createUI() { + this.modal && this.modal.remove(); + this.modal = document.createElement("div"); + this.modal.className = "photopea-modal"; + this.modal.innerHTML = "
编辑图像和蒙版后,点击保存
正在加载 Photopea...
"; + this.injectStyles(); + document.body.appendChild(this.modal); + var iframe = document.getElementById("photopea-iframe"); + var self = this; + iframe.onload = async () => { + PhotopeaBridge.init(iframe); + document.getElementById("status-text").textContent = "正在加载图像..."; + self.setupEventListeners(); + await self.loadImageAndMask(); + }; + }, + + injectStyles() { + if (document.getElementById("photopea-modal-styles")) return; + var style = document.createElement("style"); + style.id = "photopea-modal-styles"; + style.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}.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;gap:10px}.toolbar-left{display:flex;align-items:center;gap:15px}.toolbar-right{display:flex;align-items:center;gap:10px}.toolbar-hint{color:#999;font-size:13px}.btn{padding:10px 18px;border:none;border-radius:6px;cursor:pointer;font-size:14px;font-weight:500}.btn:hover{transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,0,0,0.3)}.btn-success{background:linear-gradient(135deg,#107c10,#0b5c0b);color:#fff}.btn-secondary{background:#555;color:#fff}.photopea-statusbar{padding:8px 16px;background:#1a1a1a;color:#888;font-size:12px}"; + document.head.appendChild(style); + }, + + setupEventListeners() { + var self = this; + document.getElementById("btn-save").addEventListener("click", async () => { await self.saveAll(); }); + document.getElementById("btn-cancel").addEventListener("click", () => { self.close(); }); + this.escHandler = (e) => { if (e.key === "Escape") self.close(); }; + document.addEventListener("keydown", this.escHandler); + }, + + parsePath(path) { + var filename = path, subfolder = "", type = "input"; + var m = path.match(/^(.+?)\s*\[(\w+)\]$/); + if (m) { filename = m[1].trim(); type = m[2]; } + var lastSlash = filename.lastIndexOf("/"); + if (lastSlash !== -1) { subfolder = filename.substring(0, lastSlash); filename = filename.substring(lastSlash + 1); } + return { filename, subfolder, type }; + }, + + async loadImageAndMask() { + try { + var p = this.parsePath(this.imagePath); + var url = "/view?filename=" + encodeURIComponent(p.filename) + "&type=" + p.type; + if (p.subfolder) url += "&subfolder=" + encodeURIComponent(p.subfolder); + console.log("[InpaintRegionEditor] Loading image:", url); + var resp = await fetch(url); + if (!resp.ok) throw new Error("加载失败: " + resp.status); + var imageBlob = await resp.blob(); + + var maskBlob = null; + if (this.maskPath) { + try { + var mp = this.parsePath(this.maskPath); + var murl = "/view?filename=" + encodeURIComponent(mp.filename) + "&type=" + mp.type; + if (mp.subfolder) murl += "&subfolder=" + encodeURIComponent(mp.subfolder); + var mresp = await fetch(murl); + if (mresp.ok) maskBlob = await mresp.blob(); + } catch (e) { console.log("[InpaintRegionEditor] No mask"); } + } + + await PhotopeaBridge.openImageWithMask(imageBlob, maskBlob); + document.getElementById("status-text").textContent = maskBlob ? "图像和蒙版已加载" : "图像已加载"; + } catch (e) { + console.error("[InpaintRegionEditor] Load error:", e); + document.getElementById("status-text").textContent = "加载失败: " + e.message; + } + }, + + async saveAll() { + try { + document.getElementById("status-text").textContent = "正在导出..."; + var imageBlob = await PhotopeaBridge.exportImage(); + var maskBlob = null; + try { maskBlob = await PhotopeaBridge.exportMask(); } catch (e) {} + + document.getElementById("status-text").textContent = "正在上传..."; + var imageResult = await this.uploadToComfyUI(imageBlob, "edited.png"); + + var imageWidget = this.node.widgets && this.node.widgets.find(w => w.name === "image"); + if (imageWidget && imageResult.name) imageWidget.value = imageResult.name; + + if (maskBlob) { + var maskResult = await this.uploadToComfyUI(maskBlob, "mask.png"); + var maskPathWidget = this.node.widgets && this.node.widgets.find(w => w.name === "mask_path"); + if (maskPathWidget && maskResult.name) maskPathWidget.value = maskResult.name; + } + + this.node.setDirtyCanvas(true); + document.getElementById("status-text").textContent = "已保存"; + setTimeout(() => this.close(), 500); + } catch (e) { + console.error("[InpaintRegionEditor] Save error:", e); + alert("保存失败: " + e.message); + } + }, + + async uploadToComfyUI(blob, filename) { + var fd = new FormData(); + fd.append("image", blob, filename); + fd.append("type", "input"); + var resp = await api.fetchApi("/upload/image", { method: "POST", body: fd }); + return await resp.json(); + }, + + close() { + if (this.escHandler) { document.removeEventListener("keydown", this.escHandler); this.escHandler = null; } + this.modal && this.modal.remove(); + this.modal = null; + this.node = null; + } +}; + +app.registerExtension({ + name: "comfyui.inpaint_region_editor", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name !== "InpaintRegionEditor") return; + var getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function(canvas, options) { + getExtraMenuOptions && getExtraMenuOptions.apply(this, arguments); + var node = this; + options.push(null); + options.push({ + content: "Open in Photopea", + callback: () => { + var imageWidget = node.widgets && node.widgets.find(w => w.name === "image"); + var maskPathWidget = node.widgets && node.widgets.find(w => w.name === "mask_path"); + if (imageWidget && imageWidget.value) { + PhotopeaModal.show(node, imageWidget.value, maskPathWidget ? maskPathWidget.value : null); + } else { + alert("请先上传图像"); + } + } + }); + return options; + }; + var onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function() { + var result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; + this.addWidget("STRING", "mask_path", "", () => {}, { serialize: true }); + this.addWidget("STRING", "mask_bounds", "{}", () => {}, { serialize: true }); + return result; + }; + } +}); + +console.log("[InpaintRegionEditor] Extension loaded");