Files
abc-lee-ComfyUI-InpaintRegi…/web/extension.js
T
InpaintRegionEditor Dev fec62d7758 feat: 选区约束 + MaskEditor 集成
- 选区约束:必须框住蒙版且不能超出图像边界
- 添加右键菜单 Open in MaskEditor 功能
- 使用 app.extensionManager.command.execute() 调用系统命令
- 统一 constrainRegion() 函数处理所有选区约束逻辑
- 移除红色蒙版区域显示(真实蒙版已在图像上显示)
2026-03-03 10:50:41 +08:00

583 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* InpaintRegionEditor Extension
*
* 完全自己控制图像渲染,不依赖 ComfyUI 的图像预览
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// ==================== Photopea Bridge ====================
const PhotopeaBridge = {
iframe: null,
photopeaWindow: null,
PHOTOPEA_ORIGIN: "https://www.photopea.com",
init(iframe) {
this.iframe = iframe;
this.photopeaWindow = iframe.contentWindow;
},
postMessage(message) {
const self = this;
return new Promise(function(resolve, reject) {
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") {
clearTimeout(timeoutId);
window.removeEventListener("message", handler);
resolve(responses);
}
};
window.addEventListener("message", handler);
self.photopeaWindow.postMessage(message, "*");
});
},
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);'); resolve(); }
catch (e) { reject(e); }
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
},
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("No image data");
}
};
// ==================== Photopea Modal ====================
let ppState = { modal: null, node: null, path: null, open: false };
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(); } };
}
function setStatus(t) { const 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); }
}
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");
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); }
}
function closePhotopeaModal() {
ppState.open = false;
if (ppState.modal) { ppState.modal.remove(); ppState.modal = null; }
ppState.node = null; ppState.path = null;
}
// ==================== 图像和选区数据 ====================
const nodeImageData = new Map(); // 存储每个节点的图像和选区数据
// 约束选区:框住蒙版且不超出图像边界
function constrainRegion(data, padding) {
if (!data.hasMask || !data.maskBounds) return;
const mask = data.maskBounds;
// 计算期望的选区大小
let regionWidth = mask.width + padding * 2;
let regionHeight = mask.height + padding * 2;
// 约束1:选区不能超过图像大小(自动缩小)
regionWidth = Math.min(regionWidth, data.imageWidth);
regionHeight = Math.min(regionHeight, data.imageHeight);
// 计算选区位置(优先框住蒙版,然后约束不超出边界)
let regionX = mask.x - padding;
let regionY = mask.y - padding;
// 约束2:选区不能超出图像边界
// 左边界
if (regionX < 0) regionX = 0;
// 右边界
if (regionX + regionWidth > data.imageWidth) {
regionX = data.imageWidth - regionWidth;
}
// 上边界
if (regionY < 0) regionY = 0;
// 下边界
if (regionY + regionHeight > data.imageHeight) {
regionY = data.imageHeight - regionHeight;
}
// 更新数据
data.regionX = regionX;
data.regionY = regionY;
data.regionWidth = regionWidth;
data.regionHeight = regionHeight;
}
// 加载图像并检测蒙版
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;
// 存储数据(包含原始URL供 MaskEditor 使用)
const data = {
image: img,
imageUrl: url, // 保存原始 ComfyUI URL
imageName: filename,
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 };
// 使用统一的约束函数计算选区
constrainRegion(data, padding);
}
nodeImageData.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 = nodeImageData.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([]);
// 选区标签
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);
}
}
// ==================== 拖动 ====================
let dragging = null;
function getImageDrawParams(node) {
const data = nodeImageData.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 = nodeImageData.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 = nodeImageData.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;
// 计算新位置
let newRegionX = dragging.origRegionX + dx;
let newRegionY = dragging.origRegionY + dy;
// 约束1:选区必须包含蒙版
const maskLeft = data.maskBounds.x;
const maskTop = data.maskBounds.y;
const maskRight = data.maskBounds.x + data.maskBounds.width;
const maskBottom = data.maskBounds.y + data.maskBounds.height;
// 约束2:选区不能超出图像边界
const imageRight = data.imageWidth;
const imageBottom = data.imageHeight;
// 综合约束
// X方向:选区左边 <= 蒙版左边,选区右边 >= 蒙版右边,且选区不能超出图像
let minX = maskRight - data.regionWidth; // 选区右边 >= 蒙版右边
let maxX = maskLeft; // 选区左边 <= 蒙版左边
// 加上图像边界约束
minX = Math.max(0, minX); // 选区左边 >= 0
maxX = Math.min(maxX, imageRight - data.regionWidth); // 选区右边 <= 图像右边
let minY = maskBottom - data.regionHeight;
let maxY = maskTop;
minY = Math.max(0, minY);
maxY = Math.min(maxY, imageBottom - data.regionHeight);
// 应用约束
data.regionX = Math.max(minX, Math.min(maxX, newRegionX));
data.regionY = Math.max(minY, Math.min(maxY, newRegionY));
// 同步到 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: "InpaintRegionEditor",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "InpaintRegionEditor") return;
// 右键菜单
const origGetMenu = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function(canvas, options) {
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: "打开 Photopea 编辑",
callback: function() { imgW?.value ? showPhotopeaModal(node, imgW.value) : alert("请先选择图像"); }
});
// Open in MaskEditor - 使用正确的命令调用
options.push({
content: "Open in MaskEditor",
callback: function() {
if (!imgW?.value) { alert("请先选择图像"); return; }
const data = nodeImageData.get(node.id);
if (!data?.imageUrl) { alert("图像尚未加载完成"); return; }
// 创建带有正确 URL 的图像对象供 MaskEditor 使用
const maskEditorImg = new Image();
maskEditorImg.src = data.imageUrl;
node.imgs = [maskEditorImg];
// 选中节点并执行命令
app.canvas.selectNode(node);
app.extensionManager.command.execute("Comfy.MaskEditor.OpenMaskEditor");
}
});
};
// 自己绘制图像和选区框
const origDrawBg = nodeType.prototype.onDrawBackground;
nodeType.prototype.onDrawBackground = function(ctx) {
const node = this;
// 检查是否有 MaskEditor 设置的 imgs(用户刚编辑完)
if (node.imgs && node.imgs.length > 0) {
const ourData = nodeImageData.get(node.id);
// 如果我们有数据,说明 MaskEditor 刚关闭,需要重新加载
if (ourData && ourData.imageUrl) {
const imgW = node.widgets?.find(w => w.name === "image");
if (imgW?.value) {
// 异步重新加载,避免阻塞渲染
setTimeout(() => loadImageAndDetectMask(node, imgW.value), 0);
}
}
// 用空数组替换,防止系统渲染(但不能用 null,会崩溃)
node.imgs = [];
}
if (origDrawBg) origDrawBg.apply(this, arguments);
drawNode(ctx, this);
};
// 节点创建
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
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 = nodeImageData.get(node.id);
if (data && data.hasMask && data.maskBounds) {
// 使用统一的约束函数重新计算选区
constrainRegion(data, v);
}
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;
};
}
});