44 KiB
44 KiB
ComfyUI InpaintRegionEditor - 完整实现方案
基于深度调研制定的技术方案,供下一个会话实现
版本:5.0 | 更新时间:2026-02-27
一、用户需求确认
1.1 业务需求来源
用户希望为 ComfyUI 的局部重绘(Inpaint)功能增加:
-
功能1:可拖动重绘区域框
- 原有问题:局部重绘需要遮罩向外扩散,不够精确
- 新需求:手工录入区域框尺寸(如 512×512),可拖动框住遮罩位置
- 输出增加:
region_top,region_left,region_width,region_height
-
功能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) |
| 字体选择 | ⚠️ 部分 | 字体预览缩略图需下载 |
| 广告显示 | ✅ 需要 | 广告从服务器加载 |
断网打不开的原因:
- 首次需要下载 ~10MB 的 JavaScript 代码
- 浏览器缓存被清理后需要重新下载
- 字体预览缩略图(7600+ 字体,2.75GB)需要从服务器获取
解决方案:
- 首次联网加载后,不要清理浏览器缓存
- 如需完全离线,可购买 Self-Hosted 版本($500-$2000/月)
2.5 嵌入原理
┌─────────────────────────────────────────────────────────────┐
│ 宿主页面 (ComfyUI) │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ <iframe src="https://www.photopea.com/"> │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ Photopea 完整应用 │ │ │
│ │ │ - 所有 PS 功能已实现 │ │ │
│ │ │ - 138k+ 行代码运行在 iframe 内 │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
│ ↕ │
│ postMessage API │
│ ↕ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ 宿主脚本 │ │
│ │ - 发送图像到 Photopea │ │
│ │ - 接收编辑后的图像 │ │
│ │ - 只需要 ~100 行代码 │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
三、法律约束与使用条款
3.1 Photopea 官方条款(原文)
Terms Of Use:
"The Photopea editor can be used by anyone for any purpose, for free."
"All your work belongs to you. You can sell the work, which you made in Photopea, without giving us any share."
"You don't have to mention that your work was made using Photopea."
API Policy:
"Photopea API is completely free."
3.2 法律分析
| 行为 | 是否允许 | 条款依据 |
|---|---|---|
| 嵌入 iframe 到产品 | ✅ 允许 | API 完全免费 |
| 商业用途 | ✅ 允许 | "anyone for any purpose, for free" |
| 出售你的作品 | ✅ 允许 | "All your work belongs to you" |
| 不标注使用了 Photopea | ✅ 允许 | "You don't have to mention" |
| 作为 custom node 发布 | ✅ 允许 | API 免费使用 |
| 移除广告 | ⚠️ 需付费 | 购买 Distributor 账户 |
3.3 需要注意的风险
| 风险 | 说明 | 缓解措施 |
|---|---|---|
| 广告显示 | 免费版会显示广告 | 购买白标或提示用户 |
| 服务依赖 | 依赖 photopea.com | 可购买自托管版本 |
| 隐私问题 | iframe 加载时会连接服务器 | 在隐私政策中说明 |
3.4 白标模式价格(可选)
| 方案 | 价格 | 功能 |
|---|---|---|
| 免费 | $0 | 完整功能,有广告 |
| Distributor | 按月流量计费 | 隐藏广告、隐藏品牌按钮 |
| Self-Hosted | $500-$2000/月 | 完全自托管,可离线使用 |
结论:可以免费公开发布,用户会看到广告(影响体验但不违法)。
四、技术架构
4.1 整体架构图
┌─────────────────────────────────────────────────────────────────────────────┐
│ ComfyUI InpaintRegionEditor │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 右键菜单集成 │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ Open in Region │ │ Open in │ │ │
│ │ │ Editor │ │ Photopea │ │ │
│ │ │ (区域框编辑) │ │ (完整PS功能) │ │ │
│ │ └────────┬────────┘ └────────┬────────┘ │ │
│ │ │ │ │ │
│ └───────────┼────────────────────┼─────────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────┐ ┌─────────────────────────────────────────────┐ │
│ │ RegionBoxEditor │ │ Photopea iframe │ │
│ │ (自定义 Canvas) │ │ ┌─────────────────────────────────────┐ │ │
│ │ │ │ │ https://www.photopea.com/ │ │ │
│ │ - 遮罩绘制 │ │ │ │ │ │
│ │ - 区域框拖拽 │ │ │ 完整 Photoshop 功能: │ │ │
│ │ - 自动计算 │ │ │ - 图层管理 │ │ │
│ │ │ │ │ - 液化变形 │ │ │
│ └──────────┬──────────┘ │ │ - 仿制图章 │ │ │
│ │ │ │ - 滤镜效果 │ │ │
│ │ │ │ - 选区/蒙版 │ │ │
│ │ │ └─────────────────────────────────────┘ │ │
│ │ └─────────────────────────────────────────────┘ │
│ │ │ │
│ │ postMessage API │
│ │ │ │
│ └───────────┬───────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 数据处理层 │ │
│ │ - 图像上传: POST /upload/image │ │
│ │ - 遮罩上传: POST /upload/mask │ │
│ │ - 数据同步: widget.value ↔ node.properties │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 后端节点 (Python) │ │
│ │ INPUT: image, region_width, region_height, auto_calculate │ │
│ │ OUTPUT: image, mask, region_top, region_left, region_width/height │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4.2 文件结构
ComfyUI-InpaintRegionEditor/
├── __init__.py # 节点注册
├── nodes.py # 后端节点定义
├── requirements.txt # Python 依赖(无额外依赖)
│
├── WEB_DIRECTORY = "web"
│
└── web/
├── extension.js # 扩展入口
├── photopea-embed.js # Photopea 嵌入模块
├── region-editor.js # 区域框编辑器(RegionBoxEditor)
└── utils.js # 工具函数
五、后端实现
5.1 __init__.py
"""
@author: Your Name
@title: Inpaint Region Editor
@description: Enhanced mask editor with Photopea integration and draggable inpaint region selection
"""
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"]
5.2 nodes.py
import torch
import numpy as np
import folder_paths
from PIL import Image
import os
class InpaintRegionEditor:
"""
增强版遮罩编辑器,支持可拖动重绘区域框
输出:
- image: 原始图像
- mask: 用户绘制的遮罩
- region_top, region_left: 区域框左上角坐标
- region_width, region_height: 区域框尺寸
"""
@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" 才能启用 MaskEditor)
"image": (sorted(files), {"image_upload": True}),
# 区域框默认尺寸
"region_width": ("INT", {
"default": 512,
"min": 64,
"max": 4096,
"step": 64
}),
"region_height": ("INT", {
"default": 512,
"min": 64,
"max": 4096,
"step": 64
}),
# 自动计算区域框
"auto_calculate": ("BOOLEAN", {"default": True}),
# 区域框边距(自动计算时使用)
"padding": ("INT", {
"default": 0,
"min": 0,
"max": 256
}),
},
"hidden": {
# 前端传递的区域框坐标(用户手动拖动时更新)
"region_x": "INT",
"region_y": "INT",
}
}
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_width, region_height, auto_calculate, padding,
region_x=None, region_y=None):
"""
处理图像和遮罩,计算区域框
Args:
image: 图像文件名
region_width: 区域框宽度
region_height: 区域框高度
auto_calculate: 是否自动计算区域框位置
padding: 自动计算时的边距
region_x: 手动指定的 X 坐标(前端传递)
region_y: 手动指定的 Y 坐标(前端传递)
"""
# 加载图像
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)
# 计算区域框位置
if auto_calculate or region_x is None or region_y is None:
# 自动计算:找到遮罩中心
region_x, region_y = self._calculate_region_center(
mask, region_width, region_height, padding
)
# 确保区域框在图像范围内
region_x = max(0, min(region_x, img_width - region_width))
region_y = max(0, min(region_y, img_height - region_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,
region_y, # top
region_x, # left
region_width,
region_height
)
def _calculate_region_center(self, mask, region_width, region_height, padding):
"""根据遮罩中心计算区域框位置"""
# 找到遮罩非零区域的边界框
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 (mask.shape[1] - region_width) // 2, (mask.shape[0] - region_height) // 2
# 遮罩边界
rmin, rmax = np.where(rows)[0][[0, -1]]
cmin, cmax = np.where(cols)[0][[0, -1]]
# 计算中心
center_y = (rmin + rmax) // 2
center_x = (cmin + cmax) // 2
# 区域框位置(居中于遮罩中心)
region_x = center_x - region_width // 2 - padding
region_y = center_y - region_height // 2 - padding
return region_x, region_y
六、前端实现
6.1 Photopea 嵌入模块 web/photopea-embed.js
/**
* Photopea 嵌入模块
* 基于 sd-webui-photopea-embed 改编用于 ComfyUI
*/
let photopeaWindow = null;
let photopeaIframe = null;
/**
* 初始化 Photopea iframe
*/
function initPhotopea() {
if (photopeaIframe) return;
photopeaIframe = document.createElement('iframe');
photopeaIframe.id = 'comfyui-photopea-iframe';
photopeaIframe.src = 'https://www.photopea.com/';
photopeaIframe.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 99999;
display: none;
border: none;
`;
document.body.appendChild(photopeaIframe);
photopeaIframe.onload = () => {
photopeaWindow = photopeaIframe.contentWindow;
console.log('[Photopea] iframe loaded');
};
}
/**
* 发送消息到 Photopea 并等待响应
*/
async function postMessageToPhotopea(message) {
return new Promise((resolve, reject) => {
if (!photopeaWindow) {
reject(new Error('Photopea not initialized'));
return;
}
const responses = [];
const handler = (response) => {
responses.push(response.data);
// Photopea 先返回数据,再返回 "done"
if (response.data === "done") {
window.removeEventListener("message", handler);
resolve(responses);
}
};
window.addEventListener("message", handler);
photopeaWindow.postMessage(message, "*");
// 超时处理
setTimeout(() => {
window.removeEventListener("message", handler);
reject(new Error('Photopea timeout'));
}, 60000);
});
}
/**
* 打开图像到 Photopea
*/
async function openImageInPhotopea(imagePath) {
// 从 ComfyUI 获取图像
const response = await fetch(`/view?filename=${imagePath}&type=input`);
const blob = await response.blob();
const base64 = await blobToBase64(blob);
// 发送到 Photopea
await postMessageToPhotopea(`app.open("${base64}", null, false);`);
await postMessageToPhotopea(`app.activeDocument.activeLayer.rasterize();`);
}
/**
* 从选区创建遮罩并导出
*/
async function exportSelectionAsMask() {
// 检查是否有选区
const checkResult = await postMessageToPhotopea(
'app.echoToOE(app.activeDocument.selection.bounds != null);'
);
const hasSelection = checkResult[0] === true;
if (!hasSelection) {
throw new Error('No selection in active document');
}
// 创建遮罩
const createMaskScript = `
var newLayer = app.activeDocument.artLayers.add();
newLayer.name = "TempMaskLayer";
app.activeDocument.selection.invert();
var black = new SolidColor();
black.rgb.red = 0; black.rgb.green = 0; black.rgb.blue = 0;
app.activeDocument.selection.fill(black);
app.activeDocument.selection.invert();
var white = new SolidColor();
white.rgb.red = 255; white.rgb.green = 255; white.rgb.blue = 255;
app.activeDocument.selection.fill(white);
`;
await postMessageToPhotopea(createMaskScript);
// 导出遮罩
const maskResult = await postMessageToPhotopea('app.activeDocument.saveToOE("png");');
const maskArrayBuffer = maskResult.find(r => r instanceof ArrayBuffer);
const maskBlob = new Blob([maskArrayBuffer], { type: 'image/png' });
// 删除临时图层
await postMessageToPhotopea('app.activeDocument.activeLayer.remove();');
// 导出原图
const imageBlob = await exportImageFromPhotopea(false);
return { image: imageBlob, mask: maskBlob };
}
/**
* 从 Photopea 导出图像
*/
async function exportImageFromPhotopea(activeLayerOnly = false) {
const script = activeLayerOnly
? getExportSelectedLayerScript()
: 'app.activeDocument.saveToOE("png");';
const result = await postMessageToPhotopea(script);
const arrayBuffer = result.find(r => r instanceof ArrayBuffer);
if (!arrayBuffer) {
throw new Error('No image data received from Photopea');
}
return new Blob([arrayBuffer], { type: 'image/png' });
}
// ============== 工具函数 ==============
function blobToBase64(blob) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
}
function getExportSelectedLayerScript() {
return `
function exportSelectedLayerOnly() {
function getAllArtLayers(document, layerCollection) {
for (var i = 0; i < document.layers.length; i++) {
var currentLayer = document.layers[i];
if (currentLayer.typename === "ArtLayer") {
layerCollection.push(currentLayer);
} else {
getAllArtLayers(currentLayer, layerCollection);
}
}
return layerCollection;
}
var allLayers = getAllArtLayers(app.activeDocument, []);
var layerStates = [];
for (var i = 0; i < allLayers.length; i++) {
layerStates.push(allLayers[i].visible);
allLayers[i].visible = allLayers[i] === app.activeDocument.activeLayer;
}
app.activeDocument.saveToOE("png");
for (var i = 0; i < allLayers.length; i++) {
allLayers[i].visible = layerStates[i];
}
}
exportSelectedLayerOnly();
`;
}
// 导出模块
window.PhotopeaEmbed = {
init: initPhotopea,
show: () => { if (photopeaIframe) photopeaIframe.style.display = 'block'; },
hide: () => { if (photopeaIframe) photopeaIframe.style.display = 'none'; },
openImage: openImageInPhotopea,
exportImage: exportImageFromPhotopea,
exportSelectionAsMask: exportSelectionAsMask,
postMessage: postMessageToPhotopea
};
6.2 区域框编辑器 web/region-editor.js
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// ============== 配置 ==============
const CONFIG = {
REGION_BOX_COLOR: "rgba(255, 165, 0, 0.5)",
REGION_BOX_BORDER: "rgb(255, 165, 0)",
REGION_BOX_BORDER_WIDTH: 2,
REGION_BOX_DASH: [5, 5],
HANDLE_SIZE: 10,
HANDLE_COLOR: "rgb(255, 255, 255)",
};
// ============== 区域框编辑器类 ==============
class RegionBoxEditor {
constructor(node, canvas) {
this.node = node;
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
// 区域框状态
this.regionBox = {
x: 0,
y: 0,
width: 512,
height: 512
};
// 交互状态
this.isDragging = false;
this.isResizing = false;
this.dragStart = { x: 0, y: 0 };
this.activeHandle = null;
// 图像
this.maskImage = null;
this.backgroundImage = null;
this.setupEventListeners();
}
setupEventListeners() {
this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this));
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this));
// 触摸事件
this.canvas.addEventListener('touchstart', this.onTouchStart.bind(this));
this.canvas.addEventListener('touchmove', this.onTouchMove.bind(this));
this.canvas.addEventListener('touchend', this.onTouchEnd.bind(this));
}
onMouseDown(e) {
const rect = this.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
this.activeHandle = this.getHandleAtPoint(x, y);
if (this.activeHandle) {
this.isResizing = true;
} else if (this.isPointInRegionBox(x, y)) {
this.isDragging = true;
}
this.dragStart = { x, y };
this.canvas.style.cursor = this.activeHandle ? 'nwse-resize' : 'move';
}
onMouseMove(e) {
const rect = this.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (this.isDragging) {
const dx = x - this.dragStart.x;
const dy = y - this.dragStart.y;
this.regionBox.x += dx;
this.regionBox.y += dy;
this.dragStart = { x, y };
this.render();
this.syncToNode();
} else if (this.isResizing && this.activeHandle) {
this.resizeRegionBox(x, y);
this.render();
this.syncToNode();
} else {
const handle = this.getHandleAtPoint(x, y);
if (handle) {
this.canvas.style.cursor = this.getCursorForHandle(handle);
} else if (this.isPointInRegionBox(x, y)) {
this.canvas.style.cursor = 'move';
} else {
this.canvas.style.cursor = 'crosshair';
}
}
}
onMouseUp(e) {
this.isDragging = false;
this.isResizing = false;
this.activeHandle = null;
this.canvas.style.cursor = 'crosshair';
}
onTouchStart(e) {
e.preventDefault();
const touch = e.touches[0];
this.onMouseDown({ clientX: touch.clientX, clientY: touch.clientY });
}
onTouchMove(e) {
e.preventDefault();
const touch = e.touches[0];
this.onMouseMove({ clientX: touch.clientX, clientY: touch.clientY });
}
onTouchEnd(e) {
this.onMouseUp(e);
}
// ============== 渲染 ==============
render() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
if (this.backgroundImage) {
this.ctx.drawImage(this.backgroundImage, 0, 0);
}
if (this.maskImage) {
this.ctx.globalAlpha = 0.5;
this.ctx.drawImage(this.maskImage, 0, 0);
this.ctx.globalAlpha = 1.0;
}
this.drawRegionBox();
this.drawHandles();
}
drawRegionBox() {
const { x, y, width, height } = this.regionBox;
this.ctx.fillStyle = CONFIG.REGION_BOX_COLOR;
this.ctx.fillRect(x, y, width, height);
this.ctx.strokeStyle = CONFIG.REGION_BOX_BORDER;
this.ctx.lineWidth = CONFIG.REGION_BOX_BORDER_WIDTH;
this.ctx.setLineDash(CONFIG.REGION_BOX_DASH);
this.ctx.strokeRect(x, y, width, height);
this.ctx.setLineDash([]);
this.ctx.fillStyle = 'white';
this.ctx.font = '14px sans-serif';
this.ctx.fillText(`${width} × ${height}`, x + 5, y + 20);
}
drawHandles() {
const handles = this.getHandlePositions();
Object.values(handles).forEach(handle => {
this.ctx.fillStyle = CONFIG.HANDLE_COLOR;
this.ctx.fillRect(
handle.x - CONFIG.HANDLE_SIZE / 2,
handle.y - CONFIG.HANDLE_SIZE / 2,
CONFIG.HANDLE_SIZE,
CONFIG.HANDLE_SIZE
);
});
}
// ============== 辅助方法 ==============
isPointInRegionBox(x, y) {
const { x: rx, y: ry, width, height } = this.regionBox;
return x >= rx && x <= rx + width && y >= ry && y <= ry + height;
}
getHandleAtPoint(x, y) {
const handles = this.getHandlePositions();
for (const [name, handle] of Object.entries(handles)) {
if (Math.abs(x - handle.x) <= CONFIG.HANDLE_SIZE &&
Math.abs(y - handle.y) <= CONFIG.HANDLE_SIZE) {
return name;
}
}
return null;
}
getHandlePositions() {
const { x, y, width, height } = this.regionBox;
return {
'nw': { x: x, y: y },
'ne': { x: x + width, y: y },
'sw': { x: x, y: y + height },
'se': { x: x + width, y: y + height },
'n': { x: x + width / 2, y: y },
's': { x: x + width / 2, y: y + height },
'w': { x: x, y: y + height / 2 },
'e': { x: x + width, y: y + height / 2 }
};
}
getCursorForHandle(handle) {
const cursorMap = {
'nw': 'nwse-resize', 'se': 'nwse-resize',
'ne': 'nesw-resize', 'sw': 'nesw-resize',
'n': 'ns-resize', 's': 'ns-resize',
'w': 'ew-resize', 'e': 'ew-resize'
};
return cursorMap[handle] || 'default';
}
resizeRegionBox(x, y) {
const minSize = 64;
switch (this.activeHandle) {
case 'se':
this.regionBox.width = Math.max(minSize, x - this.regionBox.x);
this.regionBox.height = Math.max(minSize, y - this.regionBox.y);
break;
case 'nw':
const newWidth = this.regionBox.x + this.regionBox.width - x;
const newHeight = this.regionBox.y + this.regionBox.height - y;
if (newWidth >= minSize) {
this.regionBox.x = x;
this.regionBox.width = newWidth;
}
if (newHeight >= minSize) {
this.regionBox.y = y;
this.regionBox.height = newHeight;
}
break;
}
}
syncToNode() {
const xWidget = this.node.widgets?.find(w => w.name === 'region_x');
const yWidget = this.node.widgets?.find(w => w.name === 'region_y');
const widthWidget = this.node.widgets?.find(w => w.name === 'region_width');
const heightWidget = this.node.widgets?.find(w => w.name === 'region_height');
if (xWidget) xWidget.value = Math.round(this.regionBox.x);
if (yWidget) yWidget.value = Math.round(this.regionBox.y);
if (widthWidget) widthWidget.value = Math.round(this.regionBox.width);
if (heightWidget) heightWidget.value = Math.round(this.regionBox.height);
}
}
window.RegionBoxEditor = RegionBoxEditor;
6.3 扩展入口 web/extension.js
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import "./photopea-embed.js";
import "./region-editor.js";
app.registerExtension({
name: "comfyui.inpaint_region_editor",
async setup() {
PhotopeaEmbed.init();
console.log("[InpaintRegionEditor] Extension loaded");
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "InpaintRegionEditor") return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const result = onNodeCreated?.apply(this, arguments);
// 添加隐藏的坐标 widgets
this.addWidget("INT", "region_x", 0, () => {}, { serialize: true });
this.addWidget("INT", "region_y", 0, () => {}, { serialize: true });
return result;
};
// 添加右键菜单
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function(canvas, options) {
getExtraMenuOptions?.apply(this, arguments);
const node = this;
options.push(null); // 分隔线
options.push({
content: "🦜 Open in Photopea",
callback: () => {
PhotopeaEmbed.show();
const imageWidget = node.widgets?.find(w => w.name === 'image');
if (imageWidget?.value) {
PhotopeaEmbed.openImage(imageWidget.value);
}
}
});
options.push({
content: "📤 Export Selection as Mask",
callback: async () => {
try {
const { image, mask } = await PhotopeaEmbed.exportSelectionAsMask();
const imageData = await uploadToComfyUI(image, 'selection_image.png');
const imageWidget = node.widgets?.find(w => w.name === 'image');
if (imageWidget) {
imageWidget.value = imageData.name;
}
node.setDirtyCanvas(true);
} catch (e) {
alert('No selection found in Photopea!');
}
}
});
return options;
};
}
});
async function 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();
}
七、数据流详解
7.1 Photopea 编辑流程
1. 用户右键点击节点 → 选择 "Open in Photopea"
↓
2. 显示 Photopea iframe(全屏覆盖)
↓
3. 发送节点图像到 Photopea
postMessageToPhotopea(`app.open("${base64Image}")`)
↓
4. 用户在 Photopea 中编辑
- 使用液化、仿制图章等工具
- 创建选区作为遮罩
↓
5. 用户点击 "Close & Save"
↓
6. 从 Photopea 导出图像和遮罩
exportSelectionAsMask()
↓
7. 上传到 ComfyUI: POST /upload/image
↓
8. 更新节点 widget → 隐藏 Photopea iframe
7.2 数据传递机制
┌──────────────────────────────────────────────────────────────┐
│ 数据流向 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 1. 用户上传图像 │
│ └─▶ POST /upload/image │
│ └─▶ 返回 {filename, subfolder, type} │
│ │
│ 2. 用户绘制遮罩 │
│ └─▶ POST /upload/mask │
│ └─▶ 遮罩写入原图 Alpha 通道 │
│ │
│ 3. 用户拖动区域框 │
│ └─▶ Widget 更新: region_x, region_y │
│ └─▶ 自动同步到后端 │
│ │
│ 4. 执行节点 │
│ └─▶ Python process() 接收所有参数 │
│ └─▶ 返回 image, mask, region_* │
│ │
└──────────────────────────────────────────────────────────────┘
八、关键技术参考
8.1 现有实现参考
| 项目 | 文件 | 参考价值 |
|---|---|---|
| Impact-Pack | js/mask-rect-area.js |
Canvas 预览 + widget 同步模式 |
| Impact-Pack | js/impact-sam-editor.js |
独立编辑器对话框实现 |
| OpenPose-Editor | js/openpose.js |
Fabric.js 集成 + 图像上传 |
| sd-webui-photopea-embed | javascript/photopea-bindings.js |
Photopea 嵌入核心代码 |
8.2 ComfyUI 扩展 API
// app.registerExtension() 支持的钩子
app.registerExtension({
name: "extension_name",
// 节点创建前
async beforeRegisterNodeDef(nodeType, nodeData, app) {},
// 节点创建后
async nodeCreated(node) {},
// 设置
async setup(app) {},
// 自定义菜单
getCustomMenu(node) {},
});
// Widget 操作
node.addWidget(type, name, value, callback, options);
node.properties[key] = value; // 持久化存储
// API 调用
api.fetchApi('/upload/image', { method: 'POST', body: formData });
api.fetchApi('/upload/mask', { method: 'POST', body: formData });
九、实现路线图
| 阶段 | 任务 | 工作量 |
|---|---|---|
| Phase 1 | Photopea 集成 | 2-3 天 |
| Phase 2 | 区域框编辑器 | 2-3 天 |
| Phase 3 | 集成测试 | 1-2 天 |
| Phase 4 | 文档发布 | 1 天 |
预计总工作量:5-8 天
十、注意事项
10.1 必须遵守的约束
- 参数名必须是
image:使用image_upload: True时,输入参数名必须是image - 遮罩存储在 Alpha 通道:通过
/upload/mask端点上传时,遮罩会写入原图的 Alpha 通道 - Widget 数据同步:使用
widget.value更新数据,或使用node.properties持久化
10.2 性能优化建议
// 使用 requestAnimationFrame 节流渲染
let rafId = null;
function throttledRender() {
if (rafId) return;
rafId = requestAnimationFrame(() => {
this.render();
rafId = null;
});
}
// 使用 OffscreenCanvas 后台处理
const offscreen = new OffscreenCanvas(width, height);
十一、README 模板
# ComfyUI InpaintRegionEditor
增强版遮罩编辑器,支持 Photopea 集成和可拖动重绘区域框。
## 功能
- ✅ 完整的 Photoshop 功能(液化、仿制图章、图层等)
- ✅ 可拖动重绘区域框
- ✅ 自动计算最佳区域框位置
- ✅ 输出区域框坐标用于精确重绘
## 安装
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/yourname/ComfyUI-InpaintRegionEditor.git
使用方法
- 添加 "Inpaint Region Editor" 节点
- 右键点击节点 → "Open in Photopea"
- 在 Photopea 中编辑图像
- 创建选区作为遮罩
- 点击关闭按钮,自动保存到节点
图像编辑功能说明
本节点的图像编辑功能基于 Photopea, 一个免费的在线图像编辑器。
- Photopea 是由 Ivan Kutskir 开发的免费软件
- 图像处理在您的浏览器本地完成,不上传到任何服务器
- 使用 Photopea API 是免费的,但会显示广告
- 您编辑的所有图像归您所有
法律声明
- Photopea API 可免费用于任何目的(包括商业用途)
- 您的作品完全归您所有,无需分享收益
- 无需标注使用了 Photopea(但建议标注)
许可证
本项目采用 MIT 许可证。
---
## 十二、总结
| 需求 | 方案 | 可行性 | 工作量 |
|------|------|--------|--------|
| **功能1**: 区域框 | 自定义 Canvas | ✅ 100% | 2-3 天 |
| **功能2**: PS 功能 | 嵌入 Photopea | ✅ 100% | 2-3 天 |
| **约束**: 不改核心代码 | 纯 custom node | ✅ 满足 | - |
| **法律风险** | 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
---
*文档版本: 5.0 | 更新时间: 2026-02-27*
*基于深度调研 + sd-webui-photopea-embed 源码分析 + Photopea 官方条款调研*