1425 lines
56 KiB
JavaScript
1425 lines
56 KiB
JavaScript
/**
|
||
* InpaintRegionEditor Extension
|
||
*
|
||
* 完全自己控制图像渲染,不依赖 ComfyUI 的图像预览
|
||
*/
|
||
|
||
import { app } from "../../scripts/app.js";
|
||
import { api } from "../../scripts/api.js";
|
||
|
||
// ==================== i18n 多语言支持 ====================
|
||
|
||
const i18n = {
|
||
data: null,
|
||
loaded: false,
|
||
|
||
// 获取当前语言(使用 ComfyUI API)
|
||
getLocale() {
|
||
try {
|
||
const locale = app.ui?.settings?.getSettingValue?.('Comfy.Locale');
|
||
if (locale) return locale;
|
||
} catch (e) {}
|
||
// 回退到浏览器语言
|
||
return navigator.language || 'en';
|
||
},
|
||
|
||
// 加载语言文件
|
||
async load() {
|
||
if (this.loaded) return;
|
||
const locale = this.getLocale();
|
||
const lang = locale.startsWith('zh') ? 'zh' : 'en';
|
||
try {
|
||
const resp = await fetch(`/extensions/InpaintRegionEditor/locales/${lang}/main.json`);
|
||
if (resp.ok) {
|
||
this.data = await resp.json();
|
||
console.log('i18n loaded:', lang, this.data);
|
||
} else {
|
||
console.warn('i18n response not ok:', resp.status);
|
||
}
|
||
} catch (e) {
|
||
console.warn('Failed to load locale:', e);
|
||
}
|
||
this.loaded = true;
|
||
},
|
||
|
||
// 获取翻译
|
||
t(key) {
|
||
if (!this.data) return key;
|
||
const keys = key.split('.');
|
||
let value = this.data;
|
||
for (const k of keys) {
|
||
if (value && typeof value === 'object' && k in value) {
|
||
value = value[k];
|
||
} else {
|
||
return key; // 找不到返回 key
|
||
}
|
||
}
|
||
return typeof value === 'string' ? value : key;
|
||
}
|
||
};
|
||
|
||
// 快捷函数
|
||
function t(key) {
|
||
return i18n.t(key);
|
||
}
|
||
|
||
// ==================== UPNG.js 动态加载 ====================
|
||
let UPNG = null;
|
||
async function loadUPNG() {
|
||
if (UPNG) return UPNG;
|
||
|
||
// 先加载 pako(UPNG 依赖)
|
||
if (!window.pako) {
|
||
await new Promise((resolve, reject) => {
|
||
const script = document.createElement('script');
|
||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js';
|
||
script.onload = resolve;
|
||
script.onerror = reject;
|
||
document.head.appendChild(script);
|
||
});
|
||
}
|
||
|
||
// 再加载 UPNG
|
||
return new Promise((resolve, reject) => {
|
||
const script = document.createElement('script');
|
||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/upng-js/2.1.0/UPNG.min.js';
|
||
script.onload = () => { UPNG = window.UPNG; resolve(UPNG); };
|
||
script.onerror = reject;
|
||
document.head.appendChild(script);
|
||
});
|
||
}
|
||
|
||
// ==================== 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">${t('photopea.editHint')}</span><div><button id="pp-save" class="pp-btn pp-save">${t('photopea.save')}</button><button id="pp-cancel" class="pp-btn pp-cancel">${t('photopea.cancel')}</button></div></div><div class="pp-status" id="pp-status">${t('photopea.loading')}</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); }
|
||
|
||
// 使用 channel=rgb 获取完整 RGB(避免透明区域 RGB 数据丢失)
|
||
let url = "/view?filename=" + encodeURIComponent(filename) + "&type=" + encodeURIComponent(type) + "&channel=rgb";
|
||
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(t('photopea.loaded'));
|
||
} catch (e) { setStatus(t('photopea.error') + e.message); }
|
||
}
|
||
|
||
async function saveImg() {
|
||
if (!ppState.open) return;
|
||
try {
|
||
setStatus(t('photopea.exporting'));
|
||
const blob = await PhotopeaBridge.exportImage("png");
|
||
setStatus(t('photopea.uploading'));
|
||
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(t('photopea.saved'));
|
||
setTimeout(closePhotopeaModal, 500);
|
||
}
|
||
} catch (e) { setStatus(t('photopea.error') + e.message); }
|
||
}
|
||
|
||
function closePhotopeaModal() {
|
||
ppState.open = false;
|
||
if (ppState.modal) { ppState.modal.remove(); ppState.modal = null; }
|
||
ppState.node = null; ppState.path = null;
|
||
}
|
||
|
||
// ==================== Photopea 蒙版编辑模式 ====================
|
||
|
||
let ppMaskState = { modal: null, node: null, path: null, open: false, mode: "mask" };
|
||
|
||
function showPhotopeaMaskModal(node, imagePath) {
|
||
ppMaskState = { modal: null, node, path: imagePath, open: true, mode: "mask" };
|
||
|
||
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);
|
||
}
|
||
|
||
ppMaskState.modal = document.createElement("div");
|
||
ppMaskState.modal.className = "pp-modal";
|
||
ppMaskState.modal.innerHTML = `<div class="pp-container"><iframe id="pp-iframe" src="https://www.photopea.com/"></iframe><div class="pp-toolbar"><span class="pp-hint">${t('maskEditor.hint')}</span><div><button id="pp-save" class="pp-btn pp-save">${t('maskEditor.saveMask')}</button><button id="pp-cancel" class="pp-btn pp-cancel">${t('photopea.cancel')}</button></div></div><div class="pp-status" id="pp-status">${t('photopea.loading')}</div></div>`;
|
||
document.body.appendChild(ppMaskState.modal);
|
||
|
||
document.getElementById("pp-save").onclick = saveMaskImg;
|
||
document.getElementById("pp-cancel").onclick = closePhotopeaMaskModal;
|
||
document.getElementById("pp-iframe").onload = function() {
|
||
if (ppMaskState.open) {
|
||
PhotopeaBridge.init(this);
|
||
loadImgForMask();
|
||
}
|
||
};
|
||
}
|
||
|
||
function setMaskStatus(t) { const el = document.getElementById("pp-status"); if (el) el.textContent = t; }
|
||
|
||
async function loadImgForMask() {
|
||
try {
|
||
// 解析文件路径
|
||
let filename = ppMaskState.path, type = "input", subfolder = "";
|
||
const m = ppMaskState.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 baseUrl = "/view?filename=" + encodeURIComponent(filename) + "&type=" + encodeURIComponent(type);
|
||
if (subfolder) baseUrl += "&subfolder=" + encodeURIComponent(subfolder);
|
||
|
||
// 关键修复:分别获取 RGB 和 Alpha,绑过 Canvas 的 premultiplied alpha 问题
|
||
// 1. 获取完整 RGB(避免透明区域 RGB 数据丢失)
|
||
setMaskStatus(t('maskEditor.loadRgb'));
|
||
let rgbUrl = baseUrl + "&channel=rgb";
|
||
const rgbResp = await fetch(rgbUrl);
|
||
if (!rgbResp.ok) throw new Error("HTTP " + rgbResp.status);
|
||
const rgbBlob = await rgbResp.blob();
|
||
|
||
// 2. 获取 Alpha 通道(用于创建蒙版层)
|
||
setMaskStatus(t('maskEditor.loadAlpha'));
|
||
let alphaUrl = baseUrl + "&channel=a";
|
||
const alphaResp = await fetch(alphaUrl);
|
||
let alphaBlob = null;
|
||
if (alphaResp.ok) {
|
||
alphaBlob = await alphaResp.blob();
|
||
}
|
||
|
||
// 3. 从 Alpha 通道创建蒙版层
|
||
setMaskStatus(t('maskEditor.prepareMask'));
|
||
const maskBlob = await createMaskFromAlpha(alphaBlob);
|
||
|
||
// 4. 转换为 base64
|
||
setMaskStatus(t('maskEditor.converting'));
|
||
const rgbBase64 = await blobToBase64(rgbBlob);
|
||
const maskBase64 = await blobToBase64(maskBlob);
|
||
|
||
console.log("RGB blob 大小:", rgbBlob.size);
|
||
console.log("Alpha blob 大小:", alphaBlob?.size);
|
||
console.log("蒙版 blob 大小:", maskBlob.size);
|
||
|
||
// 5. 先打开 RGB 图像(完整 RGB,没窟窿)
|
||
setMaskStatus(t('maskEditor.loadImageLayer'));
|
||
await PhotopeaBridge.postMessage('app.open("' + rgbBase64 + '", null, false);');
|
||
|
||
// 6. 等待一下确保文档加载完成
|
||
await new Promise(r => setTimeout(r, 500));
|
||
|
||
// 7. 将蒙版作为新图层粘贴到当前文档
|
||
setMaskStatus(t('maskEditor.loadMaskLayer'));
|
||
await PhotopeaBridge.postMessage('app.open("' + maskBase64 + '", null, true);');
|
||
|
||
// 8. 设置图层名称
|
||
await new Promise(r => setTimeout(r, 300));
|
||
await PhotopeaBridge.postMessage(`
|
||
(function() {
|
||
var doc = app.activeDocument;
|
||
if (doc.layers.length >= 2) {
|
||
doc.layers[0].name = "${t('maskEditor.maskLayerName')}";
|
||
doc.layers[doc.layers.length - 1].name = "${t('maskEditor.referenceImage')}";
|
||
}
|
||
})();
|
||
`);
|
||
|
||
setMaskStatus(t('maskEditor.ready'));
|
||
} catch (e) {
|
||
setMaskStatus(t('photopea.error') + e.message);
|
||
console.error(e);
|
||
}
|
||
}
|
||
|
||
// 从 Alpha 通道创建蒙版层
|
||
async function createMaskFromAlpha(alphaBlob) {
|
||
return new Promise((resolve) => {
|
||
if (!alphaBlob) {
|
||
// 没有 Alpha 数据,创建透明蒙版
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = 512;
|
||
canvas.height = 512;
|
||
canvas.toBlob((blob) => resolve(blob), "image/png");
|
||
return;
|
||
}
|
||
|
||
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");
|
||
|
||
// channel=a 返回的是 RGBA 图像,Alpha 值在 Alpha 通道
|
||
ctx.drawImage(img, 0, 0);
|
||
const imageData = ctx.getImageData(0, 0, img.width, img.height);
|
||
const pixels = imageData.data;
|
||
|
||
// 检查是否有遮罩(Alpha < 255 表示有遮罩)
|
||
let hasMask = false;
|
||
for (let i = 0; i < pixels.length; i += 4) {
|
||
if (pixels[i + 3] < 255) { // Alpha 通道
|
||
hasMask = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (hasMask) {
|
||
// 从 Alpha 创建蒙版层
|
||
// channel=a 返回的 Alpha:255=不透明,0=透明
|
||
// 我们需要:透明区域(Alpha=0) → 黑色不透明蒙版
|
||
ctx.clearRect(0, 0, img.width, img.height);
|
||
|
||
const maskData = ctx.createImageData(img.width, img.height);
|
||
for (let i = 0; i < pixels.length; i += 4) {
|
||
const alphaValue = pixels[i + 3]; // Alpha 通道
|
||
if (alphaValue < 255) {
|
||
const maskAlpha = 255 - alphaValue; // 反转
|
||
maskData.data[i] = 0; // 黑色
|
||
maskData.data[i + 1] = 0;
|
||
maskData.data[i + 2] = 0;
|
||
maskData.data[i + 3] = maskAlpha;
|
||
}
|
||
}
|
||
ctx.putImageData(maskData, 0, 0);
|
||
console.log("已有蒙版,已转换为蒙版层");
|
||
} else {
|
||
ctx.clearRect(0, 0, img.width, img.height);
|
||
console.log("创建透明蒙版,尺寸:", img.width, "x", img.height);
|
||
}
|
||
|
||
canvas.toBlob((blob) => resolve(blob), "image/png");
|
||
};
|
||
img.onerror = () => {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = 512;
|
||
canvas.height = 512;
|
||
canvas.toBlob((blob) => resolve(blob), "image/png");
|
||
};
|
||
img.src = URL.createObjectURL(alphaBlob);
|
||
});
|
||
}
|
||
|
||
async function getOrCreateMaskBlob(node, imgBlob) {
|
||
// 检查图像是否有 Alpha 通道(已有蒙版)
|
||
return new Promise((resolve) => {
|
||
const img = new Image();
|
||
img.onload = async 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;
|
||
for (let i = 3; i < pixels.length; i += 4) {
|
||
if (pixels[i] < 255) {
|
||
hasMask = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (hasMask) {
|
||
// 从 Alpha 通道创建蒙版层
|
||
// 转换逻辑(与保存时相反):
|
||
// - 原图 Alpha = 0(遮罩)→ 蒙版层黑色不透明
|
||
// - 原图 Alpha = 255(无遮罩)→ 蒙版层透明
|
||
// - 原图 Alpha = 中间值(羽化)→ 蒙版层黑色半透明
|
||
|
||
// 先清除 canvas
|
||
ctx.clearRect(0, 0, img.width, img.height);
|
||
|
||
const maskData = ctx.createImageData(img.width, img.height);
|
||
for (let i = 0; i < pixels.length; i += 4) {
|
||
const alpha = pixels[i + 3]; // 原图的 Alpha
|
||
if (alpha < 255) {
|
||
// 有遮罩的区域:画黑色
|
||
// alpha 越小 = 越遮罩 = 蒙版层越不透明
|
||
const maskAlpha = 255 - alpha; // 反转
|
||
maskData.data[i] = 0; // 黑色
|
||
maskData.data[i + 1] = 0;
|
||
maskData.data[i + 2] = 0;
|
||
maskData.data[i + 3] = maskAlpha;
|
||
}
|
||
// alpha = 255 的像素保持透明(maskData.data[i+3] 默认是 0)
|
||
}
|
||
ctx.putImageData(maskData, 0, 0);
|
||
console.log("已有蒙版,已转换为蒙版层");
|
||
} else {
|
||
// 创建透明蒙版(用户在上面画黑色 = 遮罩)
|
||
// 清除 canvas,保持完全透明
|
||
ctx.clearRect(0, 0, img.width, img.height);
|
||
console.log("创建透明蒙版,尺寸:", img.width, "x", img.height);
|
||
}
|
||
|
||
// 导出为 PNG,确保保留透明通道
|
||
canvas.toBlob((blob) => {
|
||
console.log("蒙版 blob 大小:", blob.size, "type:", blob.type);
|
||
resolve(blob);
|
||
}, "image/png");
|
||
};
|
||
img.onerror = () => {
|
||
// 出错时返回白色蒙版
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = 512;
|
||
canvas.height = 512;
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.fillStyle = "white";
|
||
ctx.fillRect(0, 0, 512, 512);
|
||
canvas.toBlob((blob) => resolve(blob), "image/png");
|
||
};
|
||
img.src = URL.createObjectURL(imgBlob);
|
||
});
|
||
}
|
||
|
||
function blobToBase64(blob) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onloadend = () => resolve(reader.result);
|
||
reader.onerror = reject;
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
}
|
||
|
||
async function saveMaskImg() {
|
||
if (!ppMaskState.open) return;
|
||
try {
|
||
// 提前加载 UPNG.js
|
||
setMaskStatus(t('maskEditor.prepareEncoder'));
|
||
try {
|
||
await loadUPNG();
|
||
} catch (e) {
|
||
console.error("加载 UPNG.js 失败:", e);
|
||
}
|
||
|
||
setMaskStatus(t('maskEditor.exportMask'));
|
||
|
||
// 先检查图层信息
|
||
const checkScript = `
|
||
(function() {
|
||
var doc = app.activeDocument;
|
||
var info = "图层: " + doc.layers.length + "\\n";
|
||
for (var i = 0; i < doc.layers.length; i++) {
|
||
info += i + ": " + doc.layers[i].name + " visible=" + doc.layers[i].visible + "\\n";
|
||
}
|
||
app.echoToOE(info);
|
||
})();
|
||
`;
|
||
const checkResult = await PhotopeaBridge.postMessage(checkScript);
|
||
console.log("图层信息:", checkResult);
|
||
|
||
// 导出脚本:隐藏图像层,只导出蒙版层
|
||
const exportScript = `
|
||
(function() {
|
||
var doc = app.activeDocument;
|
||
// 隐藏所有图层
|
||
for (var i = 0; i < doc.layers.length; i++) {
|
||
doc.layers[i].visible = false;
|
||
}
|
||
// 只显示最上层(蒙版层,索引0)
|
||
doc.layers[0].visible = true;
|
||
// 导出
|
||
doc.saveToOE("png");
|
||
})();
|
||
`;
|
||
|
||
const result = await PhotopeaBridge.postMessage(exportScript);
|
||
|
||
// 提取 ArrayBuffer
|
||
let maskBlob = null;
|
||
for (let i = 0; i < result.length; i++) {
|
||
if (result[i] instanceof ArrayBuffer) {
|
||
maskBlob = new Blob([result[i]], { type: "image/png" });
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!maskBlob) throw new Error(t('maskEditor.noMaskData'));
|
||
|
||
setMaskStatus(t('maskEditor.processMask'));
|
||
|
||
// 获取原始图像
|
||
const originalImageBlob = await getOriginalImageBlob(ppMaskState.path);
|
||
|
||
// 合并蒙版到原图
|
||
const finalBlob = await mergeMaskToImage(originalImageBlob, maskBlob);
|
||
|
||
setMaskStatus(t('photopea.uploading'));
|
||
const fd = new FormData();
|
||
fd.append("image", finalBlob, "mask_" + Date.now() + ".png");
|
||
fd.append("type", "input");
|
||
|
||
const resp = await api.fetchApi("/upload/image", { method: "POST", body: fd });
|
||
const uploadResult = await resp.json();
|
||
|
||
if (uploadResult.name) {
|
||
const w = ppMaskState.node.widgets?.find(x => x.name === "image");
|
||
if (w) {
|
||
w.value = uploadResult.name;
|
||
if (w.callback) w.callback(uploadResult.name);
|
||
}
|
||
ppMaskState.node.setDirtyCanvas(true);
|
||
setMaskStatus(t('maskEditor.maskSaved'));
|
||
setTimeout(closePhotopeaMaskModal, 500);
|
||
}
|
||
} catch (e) {
|
||
setMaskStatus(t('photopea.error') + e.message);
|
||
console.error(e);
|
||
}
|
||
}
|
||
|
||
async function getOriginalImageBlob(imagePath) {
|
||
let filename = imagePath, type = "input", subfolder = "";
|
||
const m = imagePath.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); }
|
||
|
||
// 关键:添加 channel=rgb 参数,确保获取完整 RGB 数据(避免透明区域 RGB 数据丢失)
|
||
let url = "/view?filename=" + encodeURIComponent(filename) + "&type=" + encodeURIComponent(type) + "&channel=rgb";
|
||
if (subfolder) url += "&subfolder=" + encodeURIComponent(subfolder);
|
||
|
||
console.log("获取原图 URL:", url);
|
||
const resp = await fetch(url);
|
||
return await resp.blob();
|
||
}
|
||
|
||
async function mergeMaskToImage(imageBlob, maskBlob) {
|
||
// 加载 UPNG.js(用于绑过 Canvas 的 premultiplied alpha 问题)
|
||
try {
|
||
await loadUPNG();
|
||
} catch (e) {
|
||
console.error("加载 UPNG.js 失败:", e);
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
const img = new Image();
|
||
img.onload = async function() {
|
||
const maskImg = new Image();
|
||
maskImg.onload = async function() {
|
||
const w = img.width;
|
||
const h = img.height;
|
||
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = w;
|
||
canvas.height = h;
|
||
const ctx = canvas.getContext("2d");
|
||
|
||
// 绘制原图
|
||
ctx.drawImage(img, 0, 0);
|
||
|
||
// 获取图像数据
|
||
const imageData = ctx.getImageData(0, 0, w, h);
|
||
const pixels = imageData.data;
|
||
|
||
// 创建临时 canvas 读取蒙版灰度
|
||
const maskCanvas = document.createElement("canvas");
|
||
maskCanvas.width = w;
|
||
maskCanvas.height = h;
|
||
const maskCtx = maskCanvas.getContext("2d");
|
||
maskCtx.drawImage(maskImg, 0, 0, w, h);
|
||
const maskData = maskCtx.getImageData(0, 0, w, h);
|
||
const maskPixels = maskData.data;
|
||
|
||
// 合并蒙版到 Alpha 通道
|
||
// 蒙版层逻辑(用户画黑色):
|
||
// - 黑色不透明(maskAlpha=255)→ 遮罩 → Alpha = 0
|
||
// - 黑色半透明(maskAlpha=128)→ 羽化 → Alpha = 128
|
||
// - 完全透明(maskAlpha=0)→ 不遮罩 → Alpha = 255
|
||
// 公式:最终 Alpha = 255 - maskAlpha
|
||
|
||
let alphaSamples = [];
|
||
for (let i = 0; i < pixels.length; i += 4) {
|
||
const maskAlpha = maskPixels[i + 3]; // 蒙版层的透明度
|
||
const finalAlpha = 255 - maskAlpha; // 反转:用户画的 = 遮罩
|
||
pixels[i + 3] = finalAlpha;
|
||
|
||
// 收集羽化样本
|
||
if (alphaSamples.length < 10 && maskAlpha > 0 && maskAlpha < 255) {
|
||
alphaSamples.push({ maskAlpha, finalAlpha });
|
||
}
|
||
}
|
||
|
||
if (alphaSamples.length > 0) {
|
||
console.log("羽化样本 (maskAlpha → finalAlpha):", alphaSamples);
|
||
}
|
||
|
||
console.log("合并完成,尺寸:", w, "x", h);
|
||
|
||
// 使用 UPNG.js 编码(保留 Alpha=0 时的 RGB 数据)
|
||
if (UPNG) {
|
||
console.log("使用 UPNG.js 编码 PNG");
|
||
const rgbaBuffer = imageData.data.buffer;
|
||
const pngBuffer = UPNG.encode([rgbaBuffer], w, h, 0);
|
||
const blob = new Blob([pngBuffer], { type: "image/png" });
|
||
console.log("UPNG 编码完成,blob 大小:", blob.size);
|
||
resolve(blob);
|
||
} else {
|
||
// 回退到 Canvas(会丢失 Alpha=0 的 RGB)
|
||
console.log("UPNG 不可用,使用 Canvas 编码");
|
||
ctx.putImageData(imageData, 0, 0);
|
||
canvas.toBlob((blob) => resolve(blob), "image/png");
|
||
}
|
||
};
|
||
maskImg.src = URL.createObjectURL(maskBlob);
|
||
};
|
||
img.src = URL.createObjectURL(imageBlob);
|
||
});
|
||
}
|
||
|
||
function closePhotopeaMaskModal() {
|
||
ppMaskState.open = false;
|
||
if (ppMaskState.modal) { ppMaskState.modal.remove(); ppMaskState.modal = null; }
|
||
ppMaskState.node = null; ppMaskState.path = null;
|
||
}
|
||
|
||
// ==================== 图像和选区数据 ====================
|
||
|
||
const nodeImageData = new Map(); // 存储每个节点的图像和选区数据
|
||
|
||
// 检查选区是否包含蒙版边界框
|
||
function regionContainsMask(region, mask) {
|
||
return region.x <= mask.x &&
|
||
region.y <= mask.y &&
|
||
region.x + region.width >= mask.x + mask.width &&
|
||
region.y + region.height >= mask.y + mask.height;
|
||
}
|
||
|
||
// 约束选区:框住蒙版且不超出图像边界
|
||
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;
|
||
|
||
console.log("loadImageAndDetectMask 开始:", imageName);
|
||
|
||
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); }
|
||
|
||
// 加载完整图像(带 Alpha,用于显示蒙版效果)
|
||
let url = "/view?filename=" + encodeURIComponent(filename) + "&type=" + encodeURIComponent(type);
|
||
if (subfolder) url += "&subfolder=" + encodeURIComponent(subfolder);
|
||
|
||
console.log("图像 URL:", url);
|
||
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) return;
|
||
const blob = await resp.blob();
|
||
console.log("图像 blob 大小:", blob.size);
|
||
|
||
// 创建图像对象
|
||
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;
|
||
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, // 保存原始 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 };
|
||
|
||
// 优先恢复用户手动调整过的选区(如果仍然包含当前蒙版)
|
||
// 避免图像重新加载、关闭编辑器窗口等操作把手动调整重置回 padding 计算值
|
||
const coordsWidget = node.widgets?.find(w => w.name === "region_coords");
|
||
let restored = false;
|
||
if (coordsWidget && coordsWidget.value && coordsWidget.value !== "{}") {
|
||
try {
|
||
const saved = JSON.parse(coordsWidget.value);
|
||
if (saved && saved.width > 0 && saved.height > 0 &&
|
||
regionContainsMask(saved, data.maskBounds)) {
|
||
data.regionX = saved.x;
|
||
data.regionY = saved.y;
|
||
data.regionWidth = saved.width;
|
||
data.regionHeight = saved.height;
|
||
restored = true;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
if (!restored) {
|
||
// 使用统一的约束函数计算选区
|
||
constrainRegion(data, padding);
|
||
}
|
||
|
||
// 同步到 region_coords widget
|
||
if (coordsWidget) {
|
||
coordsWidget.value = JSON.stringify({
|
||
x: Math.round(data.regionX),
|
||
y: Math.round(data.regionY),
|
||
width: Math.round(data.regionWidth),
|
||
height: Math.round(data.regionHeight)
|
||
});
|
||
}
|
||
}
|
||
|
||
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);
|
||
|
||
// 显示原图尺寸(在图像左下角内部)
|
||
const sizeText = img.naturalWidth + "×" + img.naturalHeight;
|
||
ctx.font = "10px sans-serif";
|
||
const textWidth = ctx.measureText(sizeText).width;
|
||
ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
|
||
ctx.fillRect(imgX, imgY + imgH - 14, textWidth + 6, 14);
|
||
ctx.fillStyle = "#fff";
|
||
ctx.fillText(sizeText, imgX + 3, imgY + imgH - 4);
|
||
|
||
// 画选区框(如果有蒙版)
|
||
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 = "8px sans-serif";
|
||
ctx.fillStyle = "rgba(255, 165, 0, 0.9)";
|
||
const labelText = t('node.referenceArea') + " " + Math.round(data.regionWidth) + "×" + Math.round(data.regionHeight);
|
||
const labelWidth = ctx.measureText(labelText).width + 6;
|
||
ctx.fillRect(rx + 2, ry + 2, labelWidth, 10);
|
||
ctx.fillStyle = "#000";
|
||
ctx.fillText(labelText, rx + 3, ry + 9);
|
||
}
|
||
}
|
||
|
||
// ==================== 拖动和调整大小 ====================
|
||
|
||
let dragging = null;
|
||
const EDGE_THRESHOLD = 8; // 边缘检测阈值(屏幕像素)
|
||
|
||
// 检测鼠标在选区的哪个区域
|
||
function getResizeHandle(imgX, imgY, data, scale) {
|
||
const rx = data.regionX;
|
||
const ry = data.regionY;
|
||
const rw = data.regionWidth;
|
||
const rh = data.regionHeight;
|
||
|
||
// 边缘检测阈值,不超过选区尺寸的 15%
|
||
const baseThreshold = EDGE_THRESHOLD / scale;
|
||
const thresholdX = Math.min(baseThreshold, rw * 0.15);
|
||
const thresholdY = Math.min(baseThreshold, rh * 0.15);
|
||
|
||
// 在选区内部
|
||
const insideX = imgX >= rx && imgX <= rx + rw;
|
||
const insideY = imgY >= ry && imgY <= ry + rh;
|
||
|
||
if (!insideX || !insideY) return null;
|
||
|
||
// 检测是否在边缘
|
||
const nearLeft = imgX - rx < thresholdX;
|
||
const nearRight = (rx + rw) - imgX < thresholdX;
|
||
const nearTop = imgY - ry < thresholdY;
|
||
const nearBottom = (ry + rh) - imgY < thresholdY;
|
||
|
||
// 角落优先
|
||
if (nearLeft && nearTop) return 'tl';
|
||
if (nearRight && nearTop) return 'tr';
|
||
if (nearLeft && nearBottom) return 'bl';
|
||
if (nearRight && nearBottom) return 'br';
|
||
|
||
// 边缘
|
||
if (nearLeft) return 'l';
|
||
if (nearRight) return 'r';
|
||
if (nearTop) return 't';
|
||
if (nearBottom) return 'b';
|
||
|
||
// 中间
|
||
return 'move';
|
||
}
|
||
|
||
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 handle = getResizeHandle(imgX, imgY, data, params.scale);
|
||
|
||
if (handle) {
|
||
dragging = {
|
||
node: node,
|
||
handle: handle,
|
||
startImgX: imgX,
|
||
startImgY: imgY,
|
||
origRegionX: data.regionX,
|
||
origRegionY: data.regionY,
|
||
origRegionWidth: data.regionWidth,
|
||
origRegionHeight: data.regionHeight
|
||
};
|
||
// 拖动时改变光标
|
||
requestAnimationFrame(() => {
|
||
const canvasEl = app.canvas.canvas;
|
||
if (canvasEl) {
|
||
canvasEl.style.cursor = handle === 'move' ? 'grabbing' :
|
||
({'tl': 'nwse-resize', 'tr': 'nesw-resize', 'bl': 'nesw-resize', 'br': 'nwse-resize',
|
||
'l': 'ew-resize', 'r': 'ew-resize', 't': 'ns-resize', 'b': 'ns-resize'}[handle] || 'crosshair');
|
||
}
|
||
});
|
||
return true; // 消费事件,阻止节点拖动
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function onMouseMove(e, pos, node) {
|
||
const data = nodeImageData.get(node.id);
|
||
if (!data || !data.hasMask) return;
|
||
|
||
const params = getImageDrawParams(node);
|
||
if (!params) return;
|
||
|
||
// 转图像坐标
|
||
const localX = pos[0];
|
||
const localY = pos[1];
|
||
const imgX = (localX - params.imgX) / params.scale;
|
||
const imgY = (localY - params.imgY) / params.scale;
|
||
|
||
// 检测手柄
|
||
const handle = getResizeHandle(imgX, imgY, data, params.scale);
|
||
|
||
// 如果正在拖动
|
||
if (dragging) {
|
||
// 计算偏移
|
||
const dx = imgX - dragging.startImgX;
|
||
const dy = imgY - dragging.startImgY;
|
||
|
||
const mask = data.maskBounds;
|
||
|
||
if (dragging.handle === 'move') {
|
||
// 拖动位置
|
||
let newRegionX = dragging.origRegionX + dx;
|
||
let newRegionY = dragging.origRegionY + dy;
|
||
|
||
const mask = data.maskBounds;
|
||
|
||
// 约束1:选区必须框住蒙版
|
||
// 选区左边 <= 蒙版左边,选区右边 >= 蒙版右边
|
||
const maskRight = mask.x + mask.width;
|
||
const maskBottom = mask.y + mask.height;
|
||
|
||
let minX = maskRight - data.regionWidth; // 选区右边 >= 蒙版右边
|
||
let maxX = mask.x; // 选区左边 <= 蒙版左边
|
||
|
||
let minY = maskBottom - data.regionHeight;
|
||
let maxY = mask.y;
|
||
|
||
// 约束2:选区不能超出图像边界
|
||
minX = Math.max(0, minX);
|
||
maxX = Math.min(maxX, data.imageWidth - data.regionWidth);
|
||
minY = Math.max(0, minY);
|
||
maxY = Math.min(maxY, data.imageHeight - data.regionHeight);
|
||
|
||
data.regionX = Math.max(minX, Math.min(maxX, newRegionX));
|
||
data.regionY = Math.max(minY, Math.min(maxY, newRegionY));
|
||
|
||
} else {
|
||
// 调整大小
|
||
let newWidth = dragging.origRegionWidth;
|
||
let newHeight = dragging.origRegionHeight;
|
||
let newX = dragging.origRegionX;
|
||
let newY = dragging.origRegionY;
|
||
|
||
const handle = dragging.handle;
|
||
|
||
// 根据手柄类型调整
|
||
if (handle.includes('r')) {
|
||
// 右边
|
||
newWidth = dragging.origRegionWidth + dx;
|
||
}
|
||
if (handle.includes('l')) {
|
||
// 左边
|
||
newWidth = dragging.origRegionWidth - dx;
|
||
newX = dragging.origRegionX + dx;
|
||
}
|
||
if (handle.includes('b')) {
|
||
// 下边
|
||
newHeight = dragging.origRegionHeight + dy;
|
||
}
|
||
if (handle.includes('t')) {
|
||
// 上边
|
||
newHeight = dragging.origRegionHeight - dy;
|
||
newY = dragging.origRegionY + dy;
|
||
}
|
||
|
||
// 约束1:最小尺寸 = 蒙版边界框
|
||
const minW = mask.width;
|
||
const minH = mask.height;
|
||
newWidth = Math.max(minW, newWidth);
|
||
newHeight = Math.max(minH, newHeight);
|
||
|
||
// 约束2:最大尺寸 = 图像边界
|
||
newWidth = Math.min(newWidth, data.imageWidth);
|
||
newHeight = Math.min(newHeight, data.imageHeight);
|
||
|
||
// 约束3:位置不能超出边界
|
||
if (newX < 0) {
|
||
newWidth += newX; // 减小宽度
|
||
newX = 0;
|
||
}
|
||
if (newY < 0) {
|
||
newHeight += newY;
|
||
newY = 0;
|
||
}
|
||
if (newX + newWidth > data.imageWidth) {
|
||
newWidth = data.imageWidth - newX;
|
||
}
|
||
if (newY + newHeight > data.imageHeight) {
|
||
newHeight = data.imageHeight - newY;
|
||
}
|
||
|
||
// 约束4:选区必须框住蒙版
|
||
const maskRight = mask.x + mask.width;
|
||
const maskBottom = mask.y + mask.height;
|
||
|
||
// 如果左边 > 蒙版左边,调整
|
||
if (newX > mask.x) {
|
||
const diff = newX - mask.x;
|
||
newX = mask.x;
|
||
newWidth += diff;
|
||
}
|
||
// 如果右边 < 蒙版右边,调整
|
||
if (newX + newWidth < maskRight) {
|
||
newWidth = maskRight - newX;
|
||
}
|
||
// 如果上边 > 蒙版上边,调整
|
||
if (newY > mask.y) {
|
||
const diff = newY - mask.y;
|
||
newY = mask.y;
|
||
newHeight += diff;
|
||
}
|
||
// 如果下边 < 蒙版下边,调整
|
||
if (newY + newHeight < maskBottom) {
|
||
newHeight = maskBottom - newY;
|
||
}
|
||
|
||
// 再次检查边界
|
||
if (newX < 0) { newX = 0; }
|
||
if (newY < 0) { newY = 0; }
|
||
if (newX + newWidth > data.imageWidth) { newWidth = data.imageWidth - newX; }
|
||
if (newY + newHeight > data.imageHeight) { newHeight = data.imageHeight - newY; }
|
||
|
||
data.regionX = newX;
|
||
data.regionY = newY;
|
||
data.regionWidth = newWidth;
|
||
data.regionHeight = newHeight;
|
||
}
|
||
|
||
// 同步到 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: Math.round(data.regionWidth),
|
||
height: Math.round(data.regionHeight)
|
||
});
|
||
}
|
||
|
||
dragging.node.setDirtyCanvas(true);
|
||
} else {
|
||
// 非拖动状态:检测光标位置
|
||
const handle = getResizeHandle(imgX, imgY, data, params.scale);
|
||
|
||
if (handle) {
|
||
// 根据手柄类型设置光标
|
||
const cursorMap = {
|
||
'move': 'grab',
|
||
'tl': 'nwse-resize',
|
||
'tr': 'nesw-resize',
|
||
'bl': 'nesw-resize',
|
||
'br': 'nwse-resize',
|
||
'l': 'ew-resize',
|
||
'r': 'ew-resize',
|
||
't': 'ns-resize',
|
||
'b': 'ns-resize'
|
||
};
|
||
// 延迟设置,确保在原生代码之后执行
|
||
const cursor = cursorMap[handle] || 'crosshair';
|
||
requestAnimationFrame(() => {
|
||
const canvasEl = app.canvas.canvas;
|
||
if (canvasEl) {
|
||
canvasEl.style.cursor = cursor;
|
||
}
|
||
});
|
||
} else {
|
||
const canvasEl = app.canvas.canvas;
|
||
if (canvasEl) {
|
||
canvasEl.style.cursor = 'crosshair';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function onMouseUp() {
|
||
dragging = null;
|
||
requestAnimationFrame(() => {
|
||
const canvasEl = app.canvas.canvas;
|
||
if (canvasEl) {
|
||
canvasEl.style.cursor = 'crosshair';
|
||
}
|
||
});
|
||
}
|
||
|
||
// ==================== 注册扩展 ====================
|
||
|
||
app.registerExtension({
|
||
name: "InpaintRegionEditor",
|
||
|
||
async init() {
|
||
// 初始化多语言
|
||
await i18n.load();
|
||
|
||
// 全局粘贴事件监听(捕获阶段,优先于系统处理)
|
||
document.addEventListener("paste", async (e) => {
|
||
// 检查是否有选中的节点
|
||
const selectedNodes = app.canvas.selected_nodes;
|
||
if (!selectedNodes || Object.keys(selectedNodes).length !== 1) return;
|
||
|
||
const node = Object.values(selectedNodes)[0];
|
||
// 只处理我们的节点
|
||
if (node.type !== "InpaintRegionEditor") return;
|
||
|
||
// 检查剪贴板是否有图片
|
||
const items = e.clipboardData?.items;
|
||
if (!items) return;
|
||
|
||
for (const item of items) {
|
||
if (item.type.startsWith("image/")) {
|
||
// 阻止系统处理
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
e.stopImmediatePropagation();
|
||
|
||
const file = item.getAsFile();
|
||
if (!file) continue;
|
||
|
||
try {
|
||
// 上传图片
|
||
const fd = new FormData();
|
||
fd.append("image", file, file.name || "pasted.png");
|
||
fd.append("type", "input");
|
||
|
||
const resp = await api.fetchApi("/upload/image", { method: "POST", body: fd });
|
||
const result = await resp.json();
|
||
|
||
if (result.name) {
|
||
// 更新 widget
|
||
const imgW = node.widgets?.find(w => w.name === "image");
|
||
if (imgW) {
|
||
imgW.value = result.name;
|
||
if (imgW.callback) imgW.callback(result.name);
|
||
}
|
||
node.setDirtyCanvas(true);
|
||
}
|
||
} catch (err) {
|
||
console.error("Paste image error:", err);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}, true); // 捕获阶段
|
||
},
|
||
|
||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||
// 确保语言文件加载完成
|
||
await i18n.load();
|
||
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: t('menu.editImage'),
|
||
callback: function() { imgW?.value ? showPhotopeaModal(node, imgW.value) : alert(t('menu.selectImageFirst')); }
|
||
});
|
||
options.push({
|
||
content: t('menu.editMask'),
|
||
callback: function() { imgW?.value ? showPhotopeaMaskModal(node, imgW.value) : alert(t('menu.selectImageFirst')); }
|
||
});
|
||
};
|
||
|
||
// 自己绘制图像和选区框
|
||
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;
|
||
|
||
// 确保 region_coords hidden widget 存在
|
||
let coordsWidget = node.widgets?.find(w => w.name === "region_coords");
|
||
if (!coordsWidget) {
|
||
// ComfyUI hidden widget 需要手动创建
|
||
coordsWidget = node.addWidget("STRING", "region_coords", "{}", () => {}, {
|
||
serialize: true,
|
||
hidden: true
|
||
});
|
||
}
|
||
|
||
// 监听图像变化
|
||
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);
|
||
// 同步到 region_coords widget,保证显示与后端使用一致
|
||
const coordsWidget = 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: Math.round(data.regionWidth),
|
||
height: Math.round(data.regionHeight)
|
||
});
|
||
}
|
||
}
|
||
node.setDirtyCanvas(true);
|
||
};
|
||
}
|
||
};
|
||
|
||
// 从工作流加载时确保 widget 存在
|
||
const origConfigure = nodeType.prototype.configure;
|
||
nodeType.prototype.configure = function(info) {
|
||
if (origConfigure) origConfigure.apply(this, arguments);
|
||
|
||
const node = this;
|
||
// 确保 region_coords widget 存在
|
||
let coordsWidget = node.widgets?.find(w => w.name === "region_coords");
|
||
if (!coordsWidget) {
|
||
coordsWidget = node.addWidget("STRING", "region_coords", "{}", () => {}, {
|
||
serialize: 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;
|
||
};
|
||
}
|
||
});
|