From a4f8f78a0eadd4e932d43f1c40cf17f4d240a12b Mon Sep 17 00:00:00 2001 From: InpaintRegionEditor Dev Date: Tue, 3 Mar 2026 15:16:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Photopea=20=E8=92=99=E7=89=88=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=8A=9F=E8=83=BD=20+=20=E4=BF=AE=E5=A4=8D=20RGB=20?= =?UTF-8?q?=E8=A2=AB=E6=8E=8F=E7=AA=9F=E7=AA=BF=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Photopea 蒙版编辑模式(双图层:参考图像 + 蒙版层) - 使用 channel=rgb 和 channel=a 分离加载,绑过 Canvas premultiplied alpha 问题 - UPNG.js 集成,保存时保留 Alpha=0 的 RGB 数据 - 支持羽化效果(半透明蒙版) - 修复蒙版边界框检测:alpha < 255 都计入边界 - 右键菜单分离:编辑图像 / 编辑蒙版 --- web/extension.js | 500 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 491 insertions(+), 9 deletions(-) diff --git a/web/extension.js b/web/extension.js index b4c39bd..bf262b0 100644 --- a/web/extension.js +++ b/web/extension.js @@ -7,6 +7,32 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; +// 动态加载 UPNG.js 和 pako(用于保留 Alpha=0 时的 RGB 数据) +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 = { @@ -127,6 +153,455 @@ function closePhotopeaModal() { 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 = `
用黑色画笔绘制遮罩区域,灰色可产生羽化边缘
加载中...
`; + 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(不会被掏窟窿) + setMaskStatus("加载 RGB 数据..."); + 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("加载 Alpha 通道..."); + let alphaUrl = baseUrl + "&channel=a"; + const alphaResp = await fetch(alphaUrl); + let alphaBlob = null; + if (alphaResp.ok) { + alphaBlob = await alphaResp.blob(); + } + + // 3. 从 Alpha 通道创建蒙版层 + setMaskStatus("准备蒙版层..."); + const maskBlob = await createMaskFromAlpha(alphaBlob); + + // 4. 转换为 base64 + setMaskStatus("转换图像格式..."); + 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("加载图像层..."); + await PhotopeaBridge.postMessage('app.open("' + rgbBase64 + '", null, false);'); + + // 6. 等待一下确保文档加载完成 + await new Promise(r => setTimeout(r, 500)); + + // 7. 将蒙版作为新图层粘贴到当前文档 + setMaskStatus("加载蒙版层..."); + 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 = "蒙版层(编辑此层)"; + doc.layers[doc.layers.length - 1].name = "参考图像"; + } + })(); + `); + + setMaskStatus("就绪 - 请在蒙版层上绘制"); + } catch (e) { + setMaskStatus("错误: " + 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("准备编码器..."); + try { + await loadUPNG(); + } catch (e) { + console.error("加载 UPNG.js 失败:", e); + } + + setMaskStatus("导出蒙版..."); + + // 先检查图层信息 + 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("未获取到蒙版数据"); + + setMaskStatus("处理蒙版..."); + + // 获取原始图像 + const originalImageBlob = await getOriginalImageBlob(ppMaskState.path); + + // 合并蒙版到原图 + const finalBlob = await mergeMaskToImage(originalImageBlob, maskBlob); + + setMaskStatus("上传中..."); + 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("蒙版已保存"); + setTimeout(closePhotopeaMaskModal, 500); + } + } catch (e) { + setMaskStatus("错误: " + 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 数据(不被掏窟窿) + 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(); // 存储每个节点的图像和选区数据 @@ -174,6 +649,8 @@ function constrainRegion(data, padding) { async function loadImageAndDetectMask(node, imageName) { if (!imageName) return; + console.log("loadImageAndDetectMask 开始:", imageName); + try { // 解析文件名 let filename = imageName, type = "input", subfolder = ""; @@ -182,13 +659,16 @@ async function loadImageAndDetectMask(node, imageName) { 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(); @@ -212,12 +692,10 @@ async function loadImageAndDetectMask(node, imageName) { 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); - } + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); } } } @@ -227,7 +705,7 @@ async function loadImageAndDetectMask(node, imageName) { // 存储数据(包含原始URL供 MaskEditor 使用) const data = { image: img, - imageUrl: url, // 保存原始 ComfyUI URL + imageUrl: url, // 保存原始 URL imageName: filename, imageWidth: img.width, imageHeight: img.height, @@ -714,9 +1192,13 @@ app.registerExtension({ const imgW = node.widgets?.find(w => w.name === "image"); options.push(null); options.push({ - content: "打开 Photopea 编辑", + content: "编辑图像(Photopea)", callback: function() { imgW?.value ? showPhotopeaModal(node, imgW.value) : alert("请先选择图像"); } }); + options.push({ + content: "编辑蒙版(Photopea)", + callback: function() { imgW?.value ? showPhotopeaMaskModal(node, imgW.value) : alert("请先选择图像"); } + }); // Open in MaskEditor - 使用正确的命令调用 options.push({ content: "Open in MaskEditor",