This commit is contained in:
akawana
2025-12-04 20:21:39 +08:00
parent ed1e432fc8
commit 8ca2f11cd7
8 changed files with 3008 additions and 1896 deletions
+18
View File
@@ -0,0 +1,18 @@
# Python bytecode
__pycache__/
*.pyc
*.pyo
*.pyd
# Virtual environment
.venv/
venv/
env/
# Editor/IDE specific files
.idea/
.vscode/
# Operating System files
.DS_Store
Thumbs.db
+197
View File
@@ -0,0 +1,197 @@
import { app } from "/scripts/app.js";
app.registerExtension({
name: "RGBYPMaskBridgeRedraw",
init(appInstance) {
console.log("[RGBYPMaskBridgeRedraw] INIT extension loaded");
const origQueuePrompt = appInstance.queuePrompt?.bind(appInstance);
if (!origQueuePrompt) {
console.warn("[RGBYPMaskBridgeRedraw] ERROR: app.queuePrompt not found");
return;
}
appInstance.queuePrompt = async function (...args) {
console.log("[RGBYPMaskBridgeRedraw] queuePrompt → START");
const result = await origQueuePrompt(...args);
console.log("[RGBYPMaskBridgeRedraw] queuePrompt → FINISHED, now checking RGBYPMaskBridge nodes…");
try {
await redrawRGBYPMaskBridgeNodes(appInstance);
} catch (e) {
console.error("[RGBYPMaskBridgeRedraw] EXCEPTION during redraw:", e);
}
console.log("[RGBYPMaskBridgeRedraw] queuePrompt → END");
return result;
};
},
});
async function redrawRGBYPMaskBridgeNodes(appInstance) {
console.log("—————— [RGBYPMaskBridgeRedraw] redraw start ——————");
const graph = appInstance?.graph;
if (!graph) {
console.warn("[RGBYPMaskBridgeRedraw] WARNING: graph not found");
return;
}
if (!graph._nodes) {
console.warn("[RGBYPMaskBridgeRedraw] WARNING: graph has no nodes");
return;
}
const nodes = graph._nodes;
console.log(`[RGBYPMaskBridgeRedraw] total nodes in graph: ${nodes.length}`);
const rgbypNodes = nodes.filter((node) => node?.type === "RGBYPMaskBridge");
console.log(`[RGBYPMaskBridgeRedraw] total RGBYPMaskBridge nodes found: ${rgbypNodes.length}`);
if (rgbypNodes.length === 0) {
console.log("[RGBYPMaskBridgeRedraw] no RGBYPMaskBridge nodes → nothing to redraw");
console.log("—————— [RGBYPMaskBridgeRedraw] redraw end ——————");
return;
}
let updatedCount = 0;
for (const node of rgbypNodes) {
try {
const updated = await tryUpdateCompositePreviewForNode(node);
if (updated) {
updatedCount++;
}
} catch (e) {
console.error(`[RGBYPMaskBridgeRedraw] error updating node id=${node.id}:`, e);
}
}
if (updatedCount > 0 && graph.setDirtyCanvas) {
console.log(
`[RGBYPMaskBridgeRedraw] forcing canvas redraw, previews updated for ${updatedCount} node(s)`
);
graph.setDirtyCanvas(true, true);
} else {
console.log("[RGBYPMaskBridgeRedraw] no previews updated → canvas redraw skipped");
}
console.log("—————— [RGBYPMaskBridgeRedraw] redraw end ——————");
}
async function tryUpdateCompositePreviewForNode(node) {
if (!node) {
console.warn("[RGBYPMaskBridgeRedraw] tryUpdateCompositePreviewForNode: node is null/undefined");
return false;
}
// Берём текущую картинку из node.img или node.imgs[0]
let currentImg = node.img;
if (!currentImg && Array.isArray(node.imgs) && node.imgs.length > 0) {
currentImg = node.imgs[0];
}
if (!currentImg || !currentImg.src) {
console.log(`[RGBYPMaskBridgeRedraw] node id=${node.id}: no current img/src → skip`);
return false;
}
const src = currentImg.src;
console.log(`[RGBYPMaskBridgeRedraw] node id=${node.id}: current src='${src}'`);
// Пытаемся вытащить filename из query-параметров (view?filename=...)
let filename = null;
try {
const url = new URL(src, window.location.origin);
const qFilename = url.searchParams.get("filename");
if (qFilename) {
filename = decodeURIComponent(qFilename);
} else {
// запасной вариант: берём всё после последнего "/"
const parts = url.pathname.split("/");
filename = parts[parts.length - 1] || null;
}
} catch (e) {
console.warn(
`[RGBYPMaskBridgeRedraw] node id=${node.id}: failed to parse URL, fallback to raw src parsing`
);
const m = src.match(/filename=([^&]+)/);
if (m) {
filename = decodeURIComponent(m[1]);
}
}
if (!filename) {
console.warn(`[RGBYPMaskBridgeRedraw] node id=${node.id}: cannot resolve filename from src`);
return false;
}
console.log(`[RGBYPMaskBridgeRedraw] node id=${node.id}: resolved filename='${filename}'`);
// Если файл уже композит, просто используем его же
let compositeFilename;
if (filename.toLowerCase().endsWith("_rgbyp_composite.png")) {
compositeFilename = filename;
} else {
// убираем расширение и добавляем постфикс _rgbyp_composite.png
const dot = filename.lastIndexOf(".");
const baseName = dot >= 0 ? filename.slice(0, dot) : filename;
compositeFilename = `${baseName}_rgbyp_composite.png`;
}
console.log(
`[RGBYPMaskBridgeRedraw] node id=${node.id}: composite filename candidate='${compositeFilename}'`
);
// URL для проверки и загрузки composite из input/rgbyp
const compositeUrl = `/view?filename=${encodeURIComponent(
compositeFilename
)}&type=input&subfolder=rgbyp&_t=${Date.now()}`;
console.log(
`[RGBYPMaskBridgeRedraw] node id=${node.id}: checking composite URL='${compositeUrl}'`
);
// Пробуем запросить картинку; если 404 — значит ещё нет композита
let resp;
try {
resp = await fetch(compositeUrl, { method: "GET" });
} catch (e) {
console.error(
`[RGBYPMaskBridgeRedraw] node id=${node.id}: error fetching composite:`,
e
);
return false;
}
if (!resp.ok) {
console.log(
`[RGBYPMaskBridgeRedraw] node id=${node.id}: composite not found (status ${resp.status}) → skip`
);
return false;
}
console.log(
`[RGBYPMaskBridgeRedraw] node id=${node.id}: composite EXISTS → updating node preview`
);
// Мы не будем использовать resp.body, просто создаём Image с тем же URL
const img = new Image();
img.src = compositeUrl;
node.img = img;
if (Array.isArray(node.imgs)) {
node.imgs[0] = img;
} else {
node.imgs = [img];
}
console.log(
`[RGBYPMaskBridgeRedraw] node id=${node.id}: node.img/node.imgs updated to composite`
);
return true;
}
+109 -216
View File
@@ -1,235 +1,128 @@
import { app } from "../../scripts/app.js";
import { openMaskEditor } from "./RGBYPMaskEditor_ui.js";
// export const GP = {
// baseNode: null,
// baseImg: null,
// maskImg: null,
// };
export const GP = {
nodes: new Map(),
baseNode: null,
};
export function setNodeState(nodeId, partial) {
const state = getNodeState(nodeId);
Object.assign(state, partial); // обновляет только указанные поля
GP.nodes.set(nodeId, state);
};
export function getNodeState(nodeId) {
if (!GP.nodes.has(nodeId)) {
GP.nodes.set(nodeId, {
overlayDialog: null,
dialogElement: null,
helpPanel: null,
helpIcon: null,
helpCloseBtn: null,
baseImg: null,
maskImg: null,
originalCanvas: null,
maskCanvas: null,
centralPanel: null,
canvasContainer: null,
zoom: 1,
zoomPrevWidth: null,
zoomPrevHeight: null,
drawCursor: null,
isDrawing: false,
drawLastX: 0,
drawLastY: 0,
drawBrushSize: 40,
drawColor: 0,
colorButtons: [],
lastCursorClientX: 0,
lastCursorClientY: 0,
maskOpacity: 0.75,
currentTool: 'Brush',
prevTool: 'Brush',
brushBtn: null,
eraserBtn: null,
scrollBtn: null,
clearBtn: null,
drawMode: 'draw',
tempOriginal: null,
tempMask: null,
tempComposite: null,
});
}
return GP.nodes.get(nodeId);
};
export const HELP = `
<h2>Hotkeys</h2>
<table style="border-spacing: 5px; border-collapse:separate;">
<tr><td><b>1, 2, 3, 4, 5</b></td><td>Change brush color</td></tr>
<tr><td><b>SHIFT + 1</b></td><td>Also changes brush color</td></tr>
<tr><td><b>SHIFT + A / SHIFT + D</b></td><td>Brush size</td></tr>
<tr><td><b>SHIFT + W / SHIFT + S</b></td><td>Mask opacity</td></tr>
<tr><td><b>SHIFT + Z / SHIFT + X</b></td><td>Zoom (or use the mouse wheel)</td></tr>
<tr><td><b>SHIFT + C</b></td><td>Fit image to window</td></tr>
<tr><td><b>SHIFT + V</b></td><td>Generate automatic masks. Each press creates the next one</td></tr>
<tr><td><b>SPACE / SHIFT + SPACE</b></td><td>Image scroll mode</td></tr>
<tr><td><b>ESC</b></td><td>Close the window without saving</td></tr>
<tr><td><b>ENTER</b></td><td>Save and close the window</td></tr>
</table>
`;
/**
* Minimal RGBYP Mask Editor extension.
* - Registers only a context-menu entry.
* - Does NOT load or process any images.
*/
app.registerExtension({
name: "RGBYP Mask Editor",
name: "RGBYPMaskEditor",
async setup(appInstance) {
function resolveImageSource(node) {
const widgets = node.widgets || [];
const LGraphCanvas = window.LGraphCanvas;
if (!LGraphCanvas) return;
for (const w of widgets) {
if (typeof w.value === "string" && /\.(png|jpg|jpeg|webp|bmp|gif)$/i.test(w.value)) {
const filename = w.value;
const subfolder = w.dir || "";
const imageType = w.image_type || "input";
return {
filename,
subfolder,
type: imageType,
widget: w,
};
}
const proto = LGraphCanvas.prototype;
const origGetNodeMenuOptions = proto.getNodeMenuOptions;
if (w.value && typeof w.value === "object" && w.value.filename) {
const filename = w.value.filename;
const subfolder = w.value.subfolder || w.value.sub_dir || "";
const imageType = w.value.type || w.value.image_type || "input";
return {
filename,
subfolder,
type: imageType,
widget: w,
};
}
proto.getNodeMenuOptions = function (node) {
GP.baseNode = node
// setNodeState(node.id, { baseNode: node });
let options = [];
try {
options = origGetNodeMenuOptions
? origGetNodeMenuOptions.call(this, node) || []
: [];
} catch (e) {
options = options || [];
}
if (Array.isArray(node.imgs) && node.imgs.length > 0) {
for (const imgInfo of node.imgs) {
if (imgInfo && typeof imgInfo === "object" && imgInfo.filename) {
const filename = imgInfo.filename;
const subfolder = imgInfo.subfolder || imgInfo.sub_dir || "";
const imageType = imgInfo.type || imgInfo.image_type || "input";
return {
filename,
subfolder,
type: imageType,
widget: null,
};
}
// Visual separator
options.unshift(null);
if (imgInfo && typeof imgInfo.src === "string" && imgInfo.src.includes("filename=")) {
try {
const url = new URL(imgInfo.src, window.location.origin);
const filename = url.searchParams.get("filename");
if (!filename) continue;
const type = url.searchParams.get("type") || "input";
const subfolder = url.searchParams.get("subfolder") || "";
return {
filename,
subfolder,
type,
widget: null,
};
} catch (e) { }
}
}
}
if (node.image instanceof Image && typeof node.image.src === "string" && node.image.src.includes("filename=")) {
try {
const url = new URL(node.image.src, window.location.origin);
const filename = url.searchParams.get("filename");
if (filename) {
const type = url.searchParams.get("type") || "input";
const subfolder = url.searchParams.get("subfolder") || "";
return {
filename,
subfolder,
type,
widget: null,
};
}
} catch (e) { }
}
const nodeIdStr = String(node.id);
const selectors = [
`div[data-id="${nodeIdStr}"]`,
`div[node_id="${nodeIdStr}"]`,
`.litegraphnode[data-id="${nodeIdStr}"]`,
];
let container = null;
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) {
container = el;
break;
}
}
let imgs;
if (container) {
imgs = container.querySelectorAll("img");
} else {
imgs = document.querySelectorAll("img");
}
for (const imgEl of imgs) {
if (!imgEl.src) continue;
if (!imgEl.src.includes("filename=")) continue;
try {
const url = new URL(imgEl.src, window.location.origin);
const filename = url.searchParams.get("filename");
if (!filename) continue;
const type = url.searchParams.get("type") || "input";
const subfolder = url.searchParams.get("subfolder") || "";
return {
filename,
subfolder,
type,
widget: null,
};
} catch (e) { }
}
return null;
}
function addMenuEntry() {
const LGraphCanvas = window.LGraphCanvas;
if (!LGraphCanvas) return;
const proto = LGraphCanvas.prototype;
const origGetNodeMenuOptions = proto.getNodeMenuOptions;
proto.getNodeMenuOptions = function (node) {
let options = [];
try {
options = origGetNodeMenuOptions
? origGetNodeMenuOptions.call(this, node) || []
: [];
} catch (e) {
options = options || [];
}
options.unshift(null);
options.unshift({
content: `
// Simple menu item that only opens the stub dialog
options.unshift({
content: `
<span style="display:flex;align-items:center;gap:6px;">
<img src="/extensions/ComfyUI-RGBYP-Mask-Editor/i_menu.png"
style="width:16px;height:16px;image-rendering:pixelated;">
<span>RGBYP Mask Editor</span>
</span>
`,
// content: "RGBYP Mask Editor",
callback: () => {
const info = resolveImageSource(node);
if (!info) {
alert(
"RGBYP Mask Editor: Could not find an image file in this node.\n" +
"For Load Image, please select an image first."
);
return;
}
callback: () => {
openMaskEditor(node);
},
});
openMaskEditor(node, info.widget, info);
},
});
return options;
};
}
addMenuEntry();
function applyRGBYPCompositePreviews() {
if (!app.graph || !app.graph._nodes) {
return;
}
for (const node of app.graph._nodes) {
const props = node.properties || {};
const compositeName = props["rgbyp_composite"];
if (!compositeName) continue;
const viewUrl = `/view?filename=${encodeURIComponent(
compositeName
)}&type=temp&_t=${Date.now()}`;
const testImg = new Image();
testImg.onload = () => {
let img = node.imgs && node.imgs[0];
if (!(img instanceof Image)) {
img = new Image();
node.imgs = [img];
}
img.src = viewUrl;
node.setDirtyCanvas?.(true, true);
app.graph.setDirtyCanvas(true, true);
};
testImg.onerror = () => { };
testImg.src = viewUrl;
node.setDirtyCanvas?.(true, true);
}
app.graph.setDirtyCanvas(true, true);
}
/*
const origLoadGraphData = app.loadGraphData;
app.loadGraphData = async function (data) {
const result = await origLoadGraphData.call(this, data);
const applySafe = () => {
try {
applyRGBYPCompositePreviews();
} catch (e) {}
};
setTimeout(applySafe, 200);
return result;
};
*/
return options;
};
},
});
});
+630 -11
View File
@@ -1,12 +1,631 @@
export function dataURLtoFile(dataUrl, filename) {
const arr = dataUrl.split(",");
const mimeMatch = arr[0].match(/:(.*?);/);
const mime = mimeMatch ? mimeMatch[1] : "image/png";
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
// Changes in RGBYPMaskEditor_io.js
// Add this import if not already present (it's already there)
import { api } from "../../scripts/api.js";
import { app } from "../../scripts/app.js";
import { GP } from "./RGBYPMaskEditor.js";
import { getNodeState } from "./RGBYPMaskEditor.js";
import { setNodeState } from "./RGBYPMaskEditor.js";
// Existing dataURLtoFile remains unchanged
/* async function computeSHA1FromImage(img) {
return new Promise((resolve) => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
canvas.toBlob(async (blob) => {
const arrayBuffer = await blob.arrayBuffer();
const hashBuffer = await crypto.subtle.digest("SHA-1", arrayBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
resolve(hashHex);
}, "image/png");
});
}
*/
function loadImageFromUrl(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (e) => {
console.error("[RGBYP] loadImageFromUrl failed:", url, e);
reject(e);
};
img.src = url;
});
}
export function initBaseImageAndCanvas() {
const node = GP.baseNode;
const state = getNodeState(node.id);
if (!node || !state || !state.originalCanvas || !state.maskCanvas || !state.canvasContainer) {
console.warn("[RGBYP] initBaseImageAndCanvas: no node or canvases");
return;
}
// src из ноды (fallback, если json не подойдёт)
let fallbackSrc = null;
if (node.imgs && Array.isArray(node.imgs) && node.imgs.length > 0 && node.imgs[0]?.src) {
fallbackSrc = node.imgs[0].src;
} else if (node.image instanceof Image && node.image.src) {
fallbackSrc = node.image.src;
}
if (!fallbackSrc) {
console.warn("[RGBYP] initBaseImageAndCanvas: no image src on node");
return;
}
(async () => {
const metaFilename = `rgbyp_${node.id}.json`;
let meta = null;
// --- 1. Пробуем прочитать meta json из temp ---
try {
const metaUrl = `/view?filename=${encodeURIComponent(metaFilename)}&type=temp&_t=${Date.now()}`;
const resp = await api.fetchApi(metaUrl, { method: "GET" });
if (resp.ok) {
const text = await resp.text();
try {
meta = JSON.parse(text);
console.log("[RGBYP] initBaseImageAndCanvas: loaded meta", meta);
} catch (e) {
console.warn("[RGBYP] initBaseImageAndCanvas: cannot parse meta json", e);
meta = null;
}
} else {
console.log("[RGBYP] initBaseImageAndCanvas: meta not found, fallback to node image", resp.status);
}
return new File([u8arr], filename, { type: mime });
}
} catch (e) {
console.warn("[RGBYP] initBaseImageAndCanvas: error loading meta", e);
}
// --- 2. Если meta есть — проверяем, что она относится к текущей картинке ---
if (meta && typeof meta.original === "string") {
const currentFilename = getNodeImageFilename(node) || "";
const originalFilename = meta.original || "";
// вырезаем постфиксы
const normalizedCurrent = currentFilename
? currentFilename.replace(/_rgbyp_composite.*?(?=\.)/, "")
: "";
const normalizedOriginal = originalFilename
? originalFilename.replace(/_rgbyp_original.*?(?=\.)/, "")
: "";
if (!normalizedCurrent || !normalizedOriginal || normalizedCurrent !== normalizedOriginal) {
console.log(
"[RGBYP] initBaseImageAndCanvas: meta.original does not match current node image -> ignore meta",
{ normalizedCurrent, normalizedOriginal }
);
meta = null;
}
} else {
// meta нет или нет original — считаем, что работать по json нельзя
meta = null;
}
let baseImg = null;
let maskImg = null;
// --- 3. Если meta валидна и принадлежит этой картинке — берём original/mask из temp ---
if (meta && meta.original) {
try {
const originalUrl = `/view?filename=${encodeURIComponent(meta.original)}&type=temp&_t=${Date.now()}`;
baseImg = await loadImageFromUrl(originalUrl);
} catch (e) {
console.warn("[RGBYP] Failed to load original from meta, will fallback to node src", e);
baseImg = null;
}
// mask может быть пустой строкой → в этом случае делаем чистую маску
const maskFile = (typeof meta.mask === "string" ? meta.mask.trim() : "");
if (maskFile) {
try {
const maskUrl = `/view?filename=${encodeURIComponent(maskFile)}&type=temp&_t=${Date.now()}`;
maskImg = await loadImageFromUrl(maskUrl);
} catch (e) {
console.warn("[RGBYP] Failed to load mask from meta, will start with empty mask", e);
maskImg = null;
}
} else {
console.log("[RGBYP] initBaseImageAndCanvas: meta.mask empty -> start with clean mask");
maskImg = null;
}
}
// --- 4. Если baseImg так и не получили — грузим из ноды, как раньше ---
if (!baseImg) {
try {
baseImg = await loadImageFromUrl(fallbackSrc);
} catch (e) {
console.error("[RGBYP] Failed to load image from node src", fallbackSrc, e);
return;
}
}
// --- 5. Запоминаем в state ---
state.baseImg = baseImg;
if (maskImg) {
state.maskImg = maskImg;
}
const imgW = baseImg.naturalWidth || baseImg.width;
const imgH = baseImg.naturalHeight || baseImg.height;
console.log("[RGBYP] Loaded base image size:", imgW, imgH);
const containerDiv = state.canvasContainer;
const prevDisplayW = containerDiv.clientWidth || containerDiv.width || imgW;
const prevDisplayH = containerDiv.clientHeight || containerDiv.height || imgH;
console.log("[RGBYP] Previous container size:", prevDisplayW, prevDisplayH);
// базовый размер для зума
state.zoomPrevWidth = prevDisplayW;
state.zoomPrevHeight = prevDisplayH;
state.zoom = 1;
// внутреннее разрешение канвасов = размеру картинки
containerDiv.style.width = imgW + "px";
containerDiv.style.height = imgH + "px";
state.originalCanvas.width = imgW;
state.originalCanvas.height = imgH;
state.maskCanvas.width = imgW;
state.maskCanvas.height = imgH;
// --- 6. Рисуем оригинал ---
const octx = state.originalCanvas.getContext("2d");
octx.clearRect(0, 0, imgW, imgH);
octx.drawImage(baseImg, 0, 0);
// --- 7. Рисуем маску, если она есть; иначе маска остаётся чистой ---
const mctx = state.maskCanvas.getContext("2d");
mctx.clearRect(0, 0, imgW, imgH);
if (maskImg) {
mctx.drawImage(maskImg, 0, 0);
}
// --- 8. Fit по "contain" в centralPanel (как и раньше) ---
const outerContainer = state.centralPanel || containerDiv.parentElement;
const boxW = outerContainer?.clientWidth || prevDisplayW;
const boxH = outerContainer?.clientHeight || prevDisplayH;
console.log("[RGBYP] Container size:", boxW, boxH);
if (boxW && boxH) {
const scale = Math.min(boxW / imgW, boxH / imgH);
console.log("[RGBYP] Calculated scale:", scale);
const cssW = imgW * scale;
const cssH = imgH * scale;
containerDiv.style.width = cssW + "px";
containerDiv.style.height = cssH + "px";
}
console.log("[RGBYP] baseImg + mask (if any) loaded, canvases resized and zoomed out");
})().catch((e) => {
console.error("[RGBYP] initBaseImageAndCanvas async error:", e);
});
}
function getNodeImageFilename(node) {
// Пытаемся вытащить имя файла из src
let src = null;
if (node.imgs && Array.isArray(node.imgs) && node.imgs.length > 0 && node.imgs[0]?.src) {
src = node.imgs[0].src;
} else if (node.image instanceof Image && node.image.src) {
src = node.image.src;
}
if (!src) return null;
try {
// src обычно вида /view?filename=xxx.png&type=...
const url = new URL(src, window.location.origin);
const fromParam = url.searchParams.get("filename");
if (fromParam) return fromParam.replace(/_rgbyp_composite.*?(?=\.)/, "").replace(/_rgbyp_original.*?(?=\.)/, "");
const pathParts = url.pathname.split("/");
return pathParts[pathParts.length - 1] || null;
} catch (e) {
console.warn("[RGBYP] getNodeImageFilename: failed to parse src", src, e);
// на крайний случай — грубый парсинг
const idx = src.indexOf("filename=");
if (idx >= 0) {
const rest = src.slice(idx + "filename=".length);
const amp = rest.indexOf("&");
return amp >= 0 ? rest.slice(0, amp) : rest;
}
return null;
}
}
async function uploadComfyFile(file, type = "temp", subfolder) {
const form = new FormData();
form.append("image", file);
form.append("type", type); // <-- ВАЖНО: type в FORM, не в URL
if (subfolder)
form.append("subfolder", subfolder);
form.append("overwrite", "true"); // чтобы перезаписывать файлы с тем же именем
try {
const resp = await api.fetchApi("/upload/image", {
method: "POST",
body: form,
});
const text = await resp.text();
let info = null;
try {
info = JSON.parse(text);
} catch {
info = text;
}
if (!resp.ok) {
console.warn("[RGBYP] uploadComfyFile FAILED", file.name, resp.status, info);
return null;
}
console.log("[RGBYP] uploadComfyFile OK:", file.name, "->", info);
// info обычно вида { name, subfolder, type: 'temp' }
return info;
} catch (err) {
console.error("[RGBYP] uploadComfyFile error:", err);
return null;
}
}
export async function saveMask() {
const node = GP.baseNode;
if (!node) {
console.warn("[RGBYP] saveMask: no GP.baseNode");
return;
}
const state = getNodeState(node.id);
if (!state) {
console.warn("[RGBYP] saveMask: no state for node", node.id);
return;
}
const originalCanvas = state.originalCanvas;
const maskCanvas = state.maskCanvas;
const baseImg = state.baseImg;
if (!originalCanvas || !maskCanvas || !baseImg) {
console.warn("[RGBYP] saveMask: missing canvases or baseImg");
return;
}
// ---------- 1. Определяем имя исходной картинки из ноды ----------
const graphImageFilename = getNodeImageFilename(node);
if (!graphImageFilename) {
console.warn("[RGBYP] saveMask: cannot determine graph image filename");
return;
}
const dot = graphImageFilename.lastIndexOf(".");
const baseName = dot >= 0 ? graphImageFilename.slice(0, dot) : graphImageFilename;
const ext = ".png";
// Имена файлов по умолчанию (для нового случая)
const desiredOriginalName = `${baseName}_rgbyp_original${ext}`;
const desiredMaskName = `${baseName}_rgbyp_mask${ext}`;
const desiredCompositeName = `${baseName}_rgbyp_composite${ext}`;
// Имя JSON по id ноды
const metaFilename = `rgbyp_${node.id}.json`;
console.log("[****] saveMask: determined filenames:", { metaFilename, desiredOriginalName, desiredMaskName, desiredCompositeName });
// ---------- 2. Пробуем прочитать существующий meta JSON ----------
let meta = null;
let reuseExistingNames = false;
try {
const url = `/view?filename=${encodeURIComponent(metaFilename)}&type=temp&_t=${Date.now()}`;
// SHA не трогаем — загрузка meta нам не нужна для sha
} catch (e) {
console.warn("[RGBYP] saveMask: error loading meta", e);
}
// ---------- 3. Решаем: обновление или новый набор файлов ----------
let originalName = desiredOriginalName;
let maskName = desiredMaskName;
let compositeName = desiredCompositeName;
if (meta && meta.mask) {
const expectedMaskForCurrent = desiredMaskName;
if (meta.mask === expectedMaskForCurrent) {
reuseExistingNames = true;
originalName = meta.original || desiredOriginalName;
maskName = meta.mask;
compositeName = meta.composite || desiredCompositeName;
console.log("[RGBYP] saveMask: reuse existing meta, only overwrite mask & composite");
}
}
// ---------- 4. Сохранение original (только если НОВЫЙ набор) ----------
if (!reuseExistingNames) {
const tmpCanvas = document.createElement("canvas");
tmpCanvas.width = baseImg.naturalWidth || baseImg.width;
tmpCanvas.height = baseImg.naturalHeight || baseImg.height;
const tctx = tmpCanvas.getContext("2d");
tctx.drawImage(baseImg, 0, 0);
const originalDataUrl = tmpCanvas.toDataURL("image/png");
const originalFile = dataURLtoFile(originalDataUrl, originalName);
await uploadComfyFile(originalFile, "temp");
// ❌ УДАЛЕНО: вычисление SHA
// const sha = await computeSHA1FromImage(baseImg);
console.log("[RGBYP] saveMask: original saved", originalName);
}
// ---------- 5. Сохранение mask ----------
const maskDataUrl = maskCanvas.toDataURL("image/png");
const maskFile = dataURLtoFile(maskDataUrl, maskName);
await uploadComfyFile(maskFile, "temp");
console.log("[RGBYP] saveMask: mask saved", maskName);
// ---------- 6. Сохранение composite ----------
const compCanvas = document.createElement("canvas");
const w = originalCanvas.width;
const h = originalCanvas.height;
compCanvas.width = w;
compCanvas.height = h;
const cctx = compCanvas.getContext("2d");
cctx.clearRect(0, 0, w, h);
cctx.drawImage(baseImg, 0, 0, w, h);
const alpha = typeof state.maskOpacity === "number"
? Math.max(0, Math.min(1, state.maskOpacity))
: 1;
cctx.globalAlpha = alpha;
cctx.drawImage(maskCanvas, 0, 0, w, h);
cctx.globalAlpha = 1;
const compositeDataUrl = compCanvas.toDataURL("image/png");
const compositeFile = dataURLtoFile(compositeDataUrl, compositeName);
await uploadComfyFile(compositeFile, "temp");
await uploadComfyFile(compositeFile, "input", "rgbyp");
console.log("[RGBYP] saveMask: composite saved", compositeName, "opacity =", state.maskOpacity);
// ---------- 7. Сохранение / обновление meta JSON ----------
if (!reuseExistingNames) {
const imgW = baseImg.naturalWidth || baseImg.width || originalCanvas.width;
const imgH = baseImg.naturalHeight || baseImg.height || originalCanvas.height;
const metaObj = {
// ❌ SHA УДАЛЁН
original: originalName,
mask: maskName,
composite: compositeName,
width: imgW,
height: imgH,
};
const metaBlob = new Blob([JSON.stringify(metaObj, null, 2)], {
type: "application/json",
});
const metaFile = new File([metaBlob], metaFilename, {
type: "application/json",
});
await uploadComfyFile(metaFile, "temp");
console.log("[RGBYP] saveMask: meta json written", metaFilename, metaObj);
setNodeState(node.id, {
tempOriginal: originalName,
tempMask: maskName,
tempComposite: compositeName,
});
console.log("[NODE STATE] saveMask: temp paths:", getNodeState(node.id).tempComposite);
} else {
console.log("[RGBYP] saveMask: meta json left unchanged", metaFilename);
}
// ---------- 8. Пишем пути в state для updatePreview ----------
}
export function dataURLtoFile(dataUrl, filename) {
const arr = dataUrl.split(",");
const mimeMatch = arr[0].match(/:(.*?);/);
const mime = mimeMatch ? mimeMatch[1] : "image/png";
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], filename, { type: mime });
}
/**
* Делает запечённую картинку (оригинал + маска с учётом maskOpacity)
* и готовит файл с правильным именем:
* <original>_rgbyp_composite.png
* или, если уже есть постфикс, то оставляет как есть.
*
* Плюс здесь же можно (и логично) обновить превью в питон-ноде.
*
* Возвращает объект { file, filename } на случай, если захочешь
* дальше использовать в saveMask или ещё где-то.
*/
export async function updatePreview() {
const node = GP.baseNode;
if (!node) {
console.warn("[RGBYP] updatePreview: no GP.baseNode");
return;
}
const state = getNodeState(node.id);
if (!state) {
console.warn("[RGBYP] updatePreview: no state for node", node.id);
return;
}
console.log("[updatePreview] updatePreview: start");
const compositeName = state.tempComposite;
if (!compositeName) {
console.warn("[RGBYP] updatePreview: no composite in state");
return;
}
// Определяем, наша ли это нода
const nodeType =
(node.type || node.comfyClass || (node.constructor && node.constructor.name) || "") + "";
const isOurNode =
nodeType === "RGBYPMaskBridge" ||
nodeType === "LoadImageWithFileData";
// URL для превью (как было раньше)
const viewUrl =
"/view?filename=" +
compositeName +
"&type=temp" +
"&_t=" +
Date.now();
const img = new Image();
console.log("[updatePreview] updatePreview: loading composite from", viewUrl);
img.onload = () => {
// ✅ СТАРАЯ ЛОГИКА — обновляем превью ноды
node.img = img;
if (Array.isArray(node.imgs)) {
node.imgs[0] = img;
} else {
node.imgs = [img];
}
if (app && app.graph) {
app.graph.setDirtyCanvas(true, true);
}
console.log("[updatePreview] updatePreview: preview updated successfully", viewUrl);
// ✅ ДОП. ЛОГИКА ТОЛЬКО ДЛЯ ЧУЖИХ НОД
if (!isOurNode) {
// Для простых нод типа Load Image:
// кладём запечённую картинку в widget "image",
// чтобы на выход шла уже composite-картинка из temp.
const annotatedPath = `rgbyp/${compositeName}`;
if (Array.isArray(node.widgets)) {
const imageWidget = node.widgets.find(
(w) =>
w &&
(w.name === "image" ||
w.type === "image" ||
w.widgetType === "image")
);
if (imageWidget) {
console.log(
"[updatePreview] updatePreview: updating image widget to",
annotatedPath
);
imageWidget.value = annotatedPath;
// Если у виджета есть callback — даём ему шанс отреагировать
try {
if (typeof imageWidget.callback === "function") {
// Сигнатуру у разных виджетов чуть-чуть гуляет, но
// большинство спокойно переварит такой вызов.
imageWidget.callback(imageWidget.value, app, node, imageWidget);
}
} catch (e) {
console.warn(
"[updatePreview] image widget callback error",
e
);
}
if (app && app.graph) {
app.graph.setDirtyCanvas(true, true);
}
} else {
console.log(
"[updatePreview] updatePreview: no image widget found on foreign node",
nodeType
);
}
}
}
// ------------------------------------------------------------
// 🔧 ДОП. ЛОГИКА: если у ноды есть FLOAT-виджет "updater",
// то обновляем его значением opacity + случайное число
// ------------------------------------------------------------
if (Array.isArray(node.widgets)) {
const updaterWidget = node.widgets.find(
(w) =>
w &&
(w.name === "updater" || w.label === "updater") &&
(w.type === "FLOAT" || w.widgetType === "FLOAT" || typeof w.value === "number")
);
if (updaterWidget) {
const oldVal =
typeof updaterWidget.value === "number"
? updaterWidget.value
: parseFloat(updaterWidget.value) || 0;
let rnd = 0;
let newVal = oldVal;
let attempts = 0;
// крутим рандом пока новое значение совпадает со старым
// (на всякий случай ограничиваемся 10 попытками)
while (newVal === oldVal && attempts < 100) {
rnd = (Math.random() * 0.02) - 0.01; // от -0.001 до +0.001
newVal = state.maskOpacity + rnd;
attempts++;
}
updaterWidget.value = newVal;
console.log(
`[updatePreview] updater widget set to ${newVal.toFixed(6)} (old=${oldVal.toFixed(6)}, opacity=${state.maskOpacity}, rnd=${rnd.toFixed(6)}, attempts=${attempts})`
);
try {
if (typeof updaterWidget.callback === "function") {
updaterWidget.callback(updaterWidget.value, app, node, updaterWidget);
}
} catch (e) {
console.warn("[updatePreview] updater widget callback error:", e);
}
if (app && app.graph) {
app.graph.setDirtyCanvas(true, true);
}
}
}
};
img.onerror = (e) => {
console.error("[updatePreview] updatePreview: failed to load composite", viewUrl, e);
};
img.src = viewUrl;
}
+892
View File
@@ -0,0 +1,892 @@
import { GP, getNodeState } from "./RGBYPMaskEditor.js"
import { colorListRGB, updateToolButtonsHighlight, updateSelectedColorUI } from "./RGBYPMaskEditor_ui.js";
import { saveMask, updatePreview } from "./RGBYPMaskEditor_io.js";
/**
* Centralized hotkey stubs for RGBYP Mask Editor.
* All handlers are intentionally empty.
*/
async function computeSHA1FromImage(img) {
return new Promise((resolve) => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
canvas.toBlob(async (blob) => {
const arrayBuffer = await blob.arrayBuffer();
const hashBuffer = await crypto.subtle.digest("SHA-1", arrayBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
resolve(hashHex);
}, "image/png");
});
}
const closeEditor = () => {
const state = getNodeState(GP.baseNode.id);
const dialog = state.dialogElement;
const overlay = state.overlayDialog;
// снимаем все глобальные хоткеи
unregisterKeyHandlers(dialog);
// убираем оверлей из DOM
if (overlay && overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
};
async function onKeyDownStub(e) {
const state = getNodeState(GP.baseNode.id);
if (state) {
let idx = null;
switch (e.code) {
case "Digit1": idx = 0; break; // R
case "Digit2": idx = 1; break; // G
case "Digit3": idx = 2; break; // B
case "Digit4": idx = 3; break; // Y
case "Digit5": idx = 4; break; // P
}
if (idx !== null && !e.altKey && !e.metaKey) {
state.drawColor = idx;
updateSelectedColorUI(idx);
console.log("[RGBYP] Color hotkey -> index", idx);
e.preventDefault();
return;
}
}
// Space : temporary pan
// Space / Shift+Space : временный Scroll (hand)
if (e.code === "Space") {
if (state && !state.spaceScrollActive) {
state.spaceScrollActive = true;
state.prevTool = state.currentTool || "Brush";
state.currentTool = "Scroll";
updateToolButtonsHighlight("Scroll");
}
e.preventDefault();
return;
}
// --- SHIFT+Z (zoom out) ---
if (e.code === "KeyZ" && e.shiftKey) {
if (!state) return;
const panel = state.centralPanel || state.canvasContainer;
if (!panel) return;
const rect = panel.getBoundingClientRect();
// зум от центра панели
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
// deltaY > 0 => отдалить (как колесо вниз)
applyZoomAt(state, cx, cy, +100);
e.preventDefault();
return;
}
// --- SHIFT+X (zoom in) ---
if (e.code === "KeyX" && e.shiftKey) {
if (!state) return;
const panel = state.centralPanel || state.canvasContainer;
if (!panel) return;
const rect = panel.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
// deltaY < 0 => приблизить (как колесо вверх)
applyZoomAt(state, cx, cy, -100);
e.preventDefault();
return;
}
// --- SHIFT+V : циклические авто-маски ---
if (e.code === "KeyV" && e.shiftKey) {
if (!state) return;
const total = 4; // 0..3
const prevIndex = (state.currentAutoMaskIndex ?? -1);
const nextIndex = (prevIndex + 1 + total) % total;
state.currentAutoMaskIndex = nextIndex;
applyAutoMask(state, nextIndex);
e.preventDefault();
return;
}
// --- ESC: закрыть диалог ---
if (e.key === "Escape") {
console.log("[RGBYP] ESC pressed: closing dialog without saving");
closeEditor();
e.preventDefault();
return;
}
// Enter : save + закрыть
if (e.key === "Enter") {
try {
// если saveMask async — промис просто улетит, нам не критично
await saveMask();
updatePreview();
} catch (err) {
console.error("[RGBYP] saveMask error on Enter:", err);
}
closeEditor();
e.preventDefault();
return;
}
// Shift+C : reset zoom to initial
if (e.code === "KeyC" && e.shiftKey) {
resetZoom();
e.preventDefault();
return;
}
// Shift+N : clear mask
if (e.code === "KeyN" && e.shiftKey) {
if (state) clearMask(state);
e.preventDefault();
return;
}
// Shift+A / Shift+D : brush size - / +
if (e.code === "KeyA" && e.shiftKey) {
adjustBrushSizeByStep(-1);
e.preventDefault();
return;
}
// Shift+D — увеличить кисть
if (e.code === "KeyD" && e.shiftKey) {
adjustBrushSizeByStep(1);
e.preventDefault();
return;
}
// Shift+W / Shift+S : opacity + / -
if (e.code === "KeyW" && e.shiftKey) {
adjustMaskOpacityByStep(+1);
e.preventDefault();
return;
}
if (e.code === "KeyS" && e.shiftKey) {
adjustMaskOpacityByStep(-1);
e.preventDefault();
return;
}
}
function onKeyUpStub(e) {
if (e.code === "Space") {
const state = getNodeState(GP.baseNode.id);
if (state && state.spaceScrollActive) {
state.spaceScrollActive = false;
state.currentTool = state.prevTool || "Brush";
updateToolButtonsHighlight(state.currentTool || "Brush");
}
e.preventDefault();
}
}
function applyZoomAt(state, centerClientX, centerClientY, deltaY, cursorEvent) {
const container = state.canvasContainer;
const panel = state.centralPanel;
if (!container || !panel) return;
const rect = container.getBoundingClientRect();
const prevCssW = parseFloat(container.style.width) || rect.width;
const prevCssH = parseFloat(container.style.height) || rect.height;
if (!prevCssW || !prevCssH) return;
// координаты точки зума внутри контейнера
const xOnCanvas = centerClientX - rect.left;
const yOnCanvas = centerClientY - rect.top;
const relX = xOnCanvas / prevCssW;
const relY = yOnCanvas / prevCssH;
// базовый размер для вычисления масштаба
if (!state.zoomPrevWidth || !state.zoomPrevHeight) {
state.zoomPrevWidth = prevCssW;
state.zoomPrevHeight = prevCssH;
}
// колесо вверх (deltaY < 0) — приблизить, вниз — отдалить
const factor = deltaY < 0 ? 1.1 : 1 / 1.1;
const oldZoom = state.zoom || 1;
let newZoom = oldZoom * factor;
const MIN_ZOOM = 0.2;
const MAX_ZOOM = 6.0;
if (newZoom < MIN_ZOOM) newZoom = MIN_ZOOM;
if (newZoom > MAX_ZOOM) newZoom = MAX_ZOOM;
state.zoom = newZoom;
const cssW = state.zoomPrevWidth * newZoom;
const cssH = state.zoomPrevHeight * newZoom;
container.style.width = cssW + "px";
container.style.height = cssH + "px";
const deltaW = cssW - prevCssW;
const deltaH = cssH - prevCssH;
const dx = deltaW * relX;
const dy = deltaH * relY;
panel.scrollLeft += dx;
panel.scrollTop += dy;
// после изменения размеров и скролла контейнера
// пересчитываем положение кругового курсора под тем же clientX/clientY
if (cursorEvent) {
updateBrushCursor(cursorEvent);
}
}
function onWheelZoom(e) {
const state = getNodeState(GP.baseNode.id);
if (!state) return;
// чтобы страница не скроллилась
e.preventDefault();
applyZoomAt(state, e.clientX, e.clientY, e.deltaY, e);
}
function resetZoom() {
const state = getNodeState(GP.baseNode.id);
if (!state || !state.canvasContainer || !state.centralPanel) return;
if (!state.zoomPrevWidth || !state.zoomPrevHeight) return;
state.zoom = 1;
state.canvasContainer.style.width = state.zoomPrevWidth + "px";
state.canvasContainer.style.height = state.zoomPrevHeight + "px";
state.centralPanel.scrollLeft = 0;
state.centralPanel.scrollTop = 0;
}
function getCanvasCoords(e, canvas) {
const rect = canvas.getBoundingClientRect();
const xRel = (e.clientX - rect.left) / rect.width;
const yRel = (e.clientY - rect.top) / rect.height;
const x = xRel * canvas.width;
const y = yRel * canvas.height;
return { x, y };
}
// Нажатие кнопки мыши — начало рисования
function onMaskMouseDown(e) {
const state = getNodeState(GP.baseNode.id);
if (state.currentTool === "Scroll") {
onPanMouseDown(e);
return;
}
// запоминаем режим
// запоминаем режим
if (state.currentTool === "Erase") {
state.drawMode = "Erase";
} else {
// по умолчанию brush, плюс можно оставить ПКМ как erase
state.drawMode = (e.button === 2) ? "Erase" : "Paint";
}
const canvas = state.maskCanvas;
if (!canvas) return;
// ЛКМ = рисование, ПКМ = стирание, остальные игнорируем
if (e.button !== 0 && e.button !== 2) return;
e.preventDefault();
const { x, y } = getCanvasCoords(e, canvas);
state.isDrawing = true;
state.drawLastX = x;
state.drawLastY = y;
const ctx = canvas.getContext("2d");
// настраиваем режим в зависимости от кисти / ластика
if (state.drawMode === "Erase") {
ctx.globalCompositeOperation = "destination-out";
ctx.strokeStyle = "rgba(0,0,0,1)";
} else {
ctx.globalCompositeOperation = "source-over";
const idx = state.drawColor ?? 0;
const rgb = colorListRGB[idx]?.color || [255, 0, 0];
const strokeColor = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 1)`;
console.log("[RGBYP] onMaskMouseDown strokeColor:", idx);
ctx.strokeStyle = strokeColor;
}
ctx.lineWidth = state.brushSize || 40;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(x, y);
onMaskDraw(e);
}
// Движение мыши — собственно рисование
function onMaskDraw(e) {
const state = getNodeState(GP.baseNode.id);
const canvas = state.maskCanvas;
if (!canvas || !state.isDrawing) return;
if (!(e.buttons & 1) && !(e.buttons & 2)) {
const ctx = canvas.getContext("2d");
ctx.closePath();
ctx.globalCompositeOperation = "source-over";
state.isDrawing = false;
state.drawMode = null;
return;
}
e.preventDefault();
const { x, y } = getCanvasCoords(e, canvas);
const ctx = canvas.getContext("2d");
const mode = state.drawMode || "Paint";
// выбор цвета
if (mode === "Erase") {
ctx.globalCompositeOperation = "destination-out";
ctx.strokeStyle = "rgba(0,0,0,1)";
} else {
ctx.globalCompositeOperation = "source-over";
const idx = state.drawColor ?? 0;
const rgb = colorListRGB[idx]?.color || [255, 0, 0];
const strokeColor = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 1)`;
ctx.strokeStyle = strokeColor;
}
ctx.lineWidth = state.brushSize || 40;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.lineTo(x, y);
ctx.stroke();
state.drawLastX = x;
state.drawLastY = y;
}
// Отпускание кнопки / уход мыши — конец рисования
function onMaskMouseUp(e) {
const state = getNodeState(GP.baseNode.id);
const canvas = state.maskCanvas;
if (!canvas) {
state.isDrawing = false;
return;
}
if (state.isDrawing) {
const ctx = canvas.getContext("2d");
ctx.closePath();
// возвращаем стандартный режим
ctx.globalCompositeOperation = "source-over";
}
state.isDrawing = false;
state.drawMode = null;
}
function onMaskContextMenu(e) {
// отключаем стандартное меню браузера по ПКМ
e.preventDefault();
}
// ----------------- OPACITY -----------------
function clampMaskOpacity(a) {
return Math.max(0, Math.min(a, 1));
}
function applyMaskOpacity(state) {
const alpha = clampMaskOpacity(state.maskOpacity ?? 1);
state.maskOpacity = alpha;
if (state.maskCanvas) {
state.maskCanvas.style.opacity = String(alpha);
}
}
function adjustMaskOpacityByStep(direction) {
const state = getNodeState(GP.baseNode.id);
if (!state) return;
let alpha = state.maskOpacity;
if (alpha == null || isNaN(alpha)) alpha = 1;
const step = 0.05; // шаг по хоткеям
alpha += direction * step;
alpha = clampMaskOpacity(alpha);
state.maskOpacity = alpha;
applyMaskOpacity(state);
if (state.opacitySlider) {
const v = Math.round(alpha * 100);
state.opacitySlider.value = String(v);
// дёргаем input, чтобы UI (подпись) тоже обновился
state.opacitySlider.dispatchEvent(new Event("input", { bubbles: true }));
}
}
// ----------------- КУРСОР-КИСТЬ -----------------
function clampBrushSize(size) {
return Math.max(1, Math.min(size, 300));
}
function adjustBrushSizeByStep(direction) {
const state = getNodeState(GP.baseNode.id);
let size = state.brushSize ?? 50;
let step = 1;
if (size < 20) step = 1;
else if (size < 100) step = 3;
else step = 5;
size += direction * step;
size = clampBrushSize(size);
state.brushSize = size;
// обновление ползунка
if (state.brushSlider) {
state.brushSlider.value = String(size);
state.brushSlider.dispatchEvent(new Event("input", { bubbles: true }));
}
// обновление курсора, если он есть
if (state.lastCursorClientX != null) {
updateBrushCursor({
clientX: state.lastCursorClientX,
clientY: state.lastCursorClientY,
});
}
}
function getBrushSizePx(state) {
const brushSize = clampBrushSize(state.brushSize ?? 50);
const canvas = state.maskCanvas || state.originalCanvas;
const rect = canvas.getBoundingClientRect();
const scale = rect.width / canvas.width;
return brushSize * scale;
}
function updateBrushCursor(e) {
const state = getNodeState(GP.baseNode.id);
const container = state.canvasContainer;
if (!container) return;
const cursorEl = state.drawCursor;
if (!cursorEl) return;
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 🔹 Запоминаем последние координаты мыши
state.lastCursorClientX = e.clientX;
state.lastCursorClientY = e.clientY;
const sizePx = getBrushSizePx(state);
console.log(`[RGBYP] Brush cursor sizePx: ${sizePx}`);
cursorEl.style.width = sizePx + "px";
cursorEl.style.height = sizePx + "px";
cursorEl.style.left = x + "px";
cursorEl.style.top = y + "px";
cursorEl.style.display = "block";
}
function onBrushCursorMove(e) {
updateBrushCursor(e);
}
function onBrushCursorEnter(e) {
const state = getNodeState(GP.baseNode.id);
const cursorEl = state.drawCursor;
if (cursorEl) {
cursorEl.style.display = "block";
}
updateBrushCursor(e);
}
function onBrushCursorLeave(e) {
const state = getNodeState(GP.baseNode.id);
if (state.drawCursor) {
state.drawCursor.style.display = "none";
}
}
// Tools
function clearMask(state) {
const canvas = state.maskCanvas;
if (!canvas) return;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
function onToolButtonClick(e) {
const state = getNodeState(GP.baseNode.id);
if (!state) return;
const btn = e.currentTarget;
const tool = btn.dataset.tool;
if (tool === "Brush") {
state.currentTool = "Brush";
updateToolButtonsHighlight("Brush");
} else if (tool === "Erase" || tool === "Eraser") {
state.currentTool = "Erase";
updateToolButtonsHighlight("Erase");
} else if (tool === "Scroll") {
state.currentTool = "Scroll";
updateToolButtonsHighlight("Scroll");
} else if (tool === "Clear") {
clearMask(state);
}
}
function onPanMouseDown(e) {
const state = getNodeState(GP.baseNode.id);
if (!state || !state.centralPanel) return;
// пан только в режиме Scroll (кнопка или Space)
if (state.currentTool !== "Scroll") return;
if (e.button !== 0) return;
e.preventDefault();
state.isPanning = true;
state.panStartX = e.clientX;
state.panStartY = e.clientY;
state.panScrollLeft = state.centralPanel.scrollLeft;
state.panScrollTop = state.centralPanel.scrollTop;
}
function onPanMouseMove(e) {
const state = getNodeState(GP.baseNode.id);
if (!state || !state.centralPanel || !state.isPanning) return;
e.preventDefault();
const dx = e.clientX - state.panStartX;
const dy = e.clientY - state.panStartY;
state.centralPanel.scrollLeft = state.panScrollLeft - dx;
state.centralPanel.scrollTop = state.panScrollTop - dy;
}
function onPanMouseUp(e) {
const state = getNodeState(GP.baseNode.id);
if (!state) return;
state.isPanning = false;
}
//Automask
function applyAutoMask(state, modeIndex) {
const canvas = state.maskCanvas;
if (!canvas) return;
const ctx = canvas.getContext("2d");
const w = canvas.width;
const h = canvas.height;
if (!w || !h) return;
// очистить текущую маску
ctx.clearRect(0, 0, w, h);
ctx.globalCompositeOperation = "source-over";
// цвета маски
const RED = "rgba(255,0,0,1)";
const GREEN = "rgba(0,255,0,1)";
const BLUE = "rgba(0,0,255,1)";
if (modeIndex === 0) {
// Half: слева R, справа G
const mid = w / 2;
ctx.fillStyle = RED;
ctx.fillRect(0, 0, mid, h);
ctx.fillStyle = GREEN;
ctx.fillRect(mid, 0, w - mid, h);
} else if (modeIndex === 1) {
// 1 : 2 — слева 1/3 R, справа 2/3 G
const x1 = w / 3;
ctx.fillStyle = RED;
ctx.fillRect(0, 0, x1, h);
ctx.fillStyle = GREEN;
ctx.fillRect(x1, 0, w - x1, h);
} else if (modeIndex === 2) {
// 2 : 1 — слева 2/3 R, справа 1/3 G
const x2 = (w * 2) / 3;
ctx.fillStyle = RED;
ctx.fillRect(0, 0, x2, h);
ctx.fillStyle = GREEN;
ctx.fillRect(x2, 0, w - x2, h);
} else if (modeIndex === 3) {
// Thirds — три равные части: R | G | B
const step = w / 3;
ctx.fillStyle = RED;
ctx.fillRect(0, 0, step, h);
ctx.fillStyle = GREEN;
ctx.fillRect(step, 0, step, h);
ctx.fillStyle = BLUE;
ctx.fillRect(step * 2, 0, w - step * 2, h);
}
}
/**
* Регистрация обработчиков.
* scopeElement — это dialog, который создаётся в _ui.
*/
export function registerKeyHandlers(scopeElement) {
const state = getNodeState(GP.baseNode.id);
if (!state || !scopeElement) return;
// --- ГЛОБАЛЬНЫЕ КЛАВИШИ ---
window.addEventListener("keydown", onKeyDownStub);
window.addEventListener("keyup", onKeyUpStub);
// --- ZOOM / PAN по центральной панели ---
const panel = state.centralPanel;
if (panel) {
panel.onwheel = (e) => onWheelZoom(e); // zoom
panel.onmousedown = onPanMouseDown; // hand-pan
panel.onmousemove = onPanMouseMove;
panel.onmouseup = onPanMouseUp;
panel.onmouseleave = onPanMouseUp;
}
// --- РИСОВАНИЕ / СТИРАНИЕ НА МАСКЕ ---
const mask = state.maskCanvas;
if (mask) {
mask.onmousedown = onMaskMouseDown;
mask.onmousemove = onMaskDraw;
mask.onmouseup = onMaskMouseUp;
// mask.onmouseleave = onMaskMouseUp;
mask.onmouseleave = null;
mask.oncontextmenu = onMaskContextMenu;
}
// --- КРУГЛЫЙ КУРСОР-КИСТЬ ---
const cont = state.canvasContainer;
if (cont) {
cont.onmousemove = onBrushCursorMove;
cont.onmouseenter = onBrushCursorEnter;
cont.onmouseleave = onBrushCursorLeave;
cont.style.cursor = "none";
}
// --- СЛАЙДЕРЫ (0 — brush size, 1 — opacity) ---
const sliders = scopeElement.querySelectorAll('input[type="range"]');
// Brush size
if (sliders[0]) {
const brushSlider = sliders[0];
state.brushSlider = brushSlider;
// инициализация из state или из дефолта
if (state.brushSize == null) {
state.brushSize = clampBrushSize(parseInt(brushSlider.value) || 50);
} else {
brushSlider.value = String(clampBrushSize(state.brushSize));
brushSlider.dispatchEvent(new Event("input", { bubbles: true }));
}
brushSlider.oninput = (ev) => {
const s = getNodeState(GP.baseNode.id);
if (!s) return;
let v = parseInt(ev.target.value) || 1;
v = clampBrushSize(v);
s.brushSize = v;
if (s.lastCursorClientX != null && s.lastCursorClientY != null) {
updateBrushCursor({
clientX: s.lastCursorClientX,
clientY: s.lastCursorClientY,
});
}
};
}
// Opacity
if (sliders[1]) {
const opacitySlider = sliders[1];
state.opacitySlider = opacitySlider;
if (state.maskOpacity != null && !isNaN(state.maskOpacity)) {
const v = Math.round(clampMaskOpacity(state.maskOpacity) * 100);
opacitySlider.value = String(v);
applyMaskOpacity(state);
} else {
let initVal = parseInt(opacitySlider.value);
if (isNaN(initVal) || initVal <= 0) initVal = 100;
state.maskOpacity = clampMaskOpacity(initVal / 100);
applyMaskOpacity(state);
}
opacitySlider.oninput = (ev) => {
const s = getNodeState(GP.baseNode.id);
if (!s) return;
let v = parseInt(ev.target.value);
if (isNaN(v)) v = 100;
v = Math.max(0, Math.min(v, 100));
s.maskOpacity = clampMaskOpacity(v / 100);
applyMaskOpacity(s);
};
}
// --- HELP PANEL (кнопка ? и кнопка Close) ---
if (state.helpIcon && state.helpPanel) {
state.helpIcon.onclick = () => {
state.helpPanel.style.display = "flex";
};
}
if (state.helpCloseBtn && state.helpPanel) {
state.helpCloseBtn.onclick = () => {
state.helpPanel.style.display = "none";
};
}
// --- TOOL BUTTONS (Brush / Eraser / Scroll / Clear) ---
const brushBtn = scopeElement.querySelector('button[data-tool="Brush"]');
const eraserBtn = scopeElement.querySelector('button[data-tool="Eraser"]');
const scrollBtn = scopeElement.querySelector('button[data-tool="Scroll"]');
const clearBtn = scopeElement.querySelector('button[data-tool="Clear"]');
state.brushBtn = brushBtn || null;
state.eraseBtn = eraserBtn || null;
state.scrollBtn = scrollBtn || null;
state.clearBtn = clearBtn || null;
if (brushBtn) brushBtn.onclick = onToolButtonClick;
if (eraserBtn) eraserBtn.onclick = onToolButtonClick;
if (scrollBtn) scrollBtn.onclick = onToolButtonClick;
if (clearBtn) clearBtn.onclick = onToolButtonClick;
// дефолтный режим
if (!state.currentTool) {
state.currentTool = "Brush";
}
updateToolButtonsHighlight(state.currentTool);
// --- COLOR BUTTONS (клики по цветам) ---
if (state.colorButtons && Array.isArray(state.colorButtons)) {
state.colorButtons.forEach((btn, idx) => {
btn.onclick = () => {
const s = getNodeState(GP.baseNode.id);
if (!s) return;
s.drawColor = idx;
updateSelectedColorUI(idx);
console.log("[RGBYP] Color selected by click:", idx);
};
});
}
// --- AUTO MASK BUTTONS (Half / 1:2 / 2:1 / Thirds) ---
const autoHalfBtn = scopeElement.querySelector('button[data-tool="Half"]');
const auto1to2Btn = scopeElement.querySelector('button[data-tool="1 to 2"]');
const auto2to1Btn = scopeElement.querySelector('button[data-tool="2 to 1"]');
const autoThirdsBtn = scopeElement.querySelector('button[data-tool="Thirds"]');
if (autoHalfBtn) {
autoHalfBtn.onclick = () => {
const s = getNodeState(GP.baseNode.id);
if (!s) return;
s.currentAutoMaskIndex = 0;
applyAutoMask(s, 0);
};
}
if (auto1to2Btn) {
auto1to2Btn.onclick = () => {
const s = getNodeState(GP.baseNode.id);
if (!s) return;
s.currentAutoMaskIndex = 1;
applyAutoMask(s, 1);
};
}
if (auto2to1Btn) {
auto2to1Btn.onclick = () => {
const s = getNodeState(GP.baseNode.id);
if (!s) return;
s.currentAutoMaskIndex = 2;
applyAutoMask(s, 2);
};
}
if (autoThirdsBtn) {
autoThirdsBtn.onclick = () => {
const s = getNodeState(GP.baseNode.id);
if (!s) return;
s.currentAutoMaskIndex = 3;
applyAutoMask(s, 3);
};
}
const saveBtn = scopeElement.querySelector('button[data-tool="Save"]');
if (saveBtn) {
state.saveBtn = saveBtn;
saveBtn.onclick = async(e) => {
e.preventDefault();
e.stopPropagation();
try {
await saveMask();
updatePreview();
} catch (err) {
console.error("[RGBYP] saveMask error on button click:", err);
}
closeEditor();
};
}
}
/**
* Снятие обработчиков при закрытии редактора.
*/
export function unregisterKeyHandlers(scopeElement) {
window.removeEventListener("keydown", onKeyDownStub);
window.removeEventListener("keyup", onKeyUpStub);
// остальное можно не чистить — overlay удалится из DOM,
// и все onmousemove/onmousedown/oninput уйдут вместе с ним.
}
+377 -1227
View File
File diff suppressed because it is too large Load Diff
+334 -302
View File
@@ -10,365 +10,397 @@ import folder_paths
class LoadImageWithFileData:
"""
Extended version of the built‑in Load Image node.
Расширенная версия стандартной Load Image ноды, работающая в связке с RGBYP-редактором.
Outputs:
- image (IMAGE)
- rgbyp_mask (IMAGE) — color RGBYP mask (if exists), otherwise a small black image
- mask (MASK)
- dir_path (STRING)
- file_path (STRING)
- file_name (STRING)
- file_name_ext (STRING)
ЛОГИКА:
Extra behaviour:
1. JS-нода для превью может портить имя файла, добавляя хвост:
"_<какой-то_id>__rgbyp"
например:
исходный файл: iii_2.png
превью-файл: iii_2_108__rgbyp.png
- Saves a copy of the current image to ComfyUI/temp as
RGBYP_<unique_id>_original.png
- If an RGBYP color mask exists for this unique_id, saves a composite preview
RGBYP_<unique_id>_composite.png
- Writes a JSON meta file RGBYP_<unique_id>_meta.json with:
{
"graph_image": <key that identifies which graph image this mask belongs to>,
"original": "<path>",
"mask": "<path or empty>",
"composite": "<path or empty>",
"width": <int>,
"height": <int>,
}
Нам нужно восстановить базовое имя "iii_2" и игнорировать этот хвост.
The same attributes are attached to the output IMAGE tensor:
image.dir_path
image.file_path
image.file_name
image.file_name_ext
image.graph_image (string key used to detect stale masks)
2. meta.json ВСЕГДА называется:
<чистое_имя_БЕЗ_РАСШИРЕНИЯ>_<unique_id>_meta.json
Пример:
базовое имя: iii_2
unique_id: 108
meta.json: iii_2_108_meta.json (лежат в temp)
3. Внутри meta.json ожидаются поля:
{
"original": "<путь или имя файла с оригиналом>",
"mask": "<путь или имя файла с маской>",
"composite": "<путь или имя файла с запечённой маской>",
...
}
Значения могут быть:
- абсолютными путями
- или именами файлов в temp (относительными к temp)
4. Выходы ноды:
- image (IMAGE):
берётся из meta["original"] (картинка в temp).
Если meta.json нет или original не найден/не грузится —
используется стандартный результат LoadImage (исходная картинка).
- rgbyp_mask (IMAGE):
берётся из meta["mask"] (картинка в temp).
Если meta.json нет или mask не найден/пустой/не грузится —
возвращается чёрная картинка 64×64.
- mask (MASK):
стандартная маска из LoadImage.
- file_path (STRING):
абсолютный путь к исходной картинке (как у стандартной LoadImage).
- file_name (STRING):
имя исходной картинки без расширения (сырое, до очистки).
"""
@classmethod
def INPUT_TYPES(cls):
# Start from the standard LoadImage INPUT_TYPES so behaviour stays consistent.
base = nodes.LoadImage.INPUT_TYPES()
# Visible FLOAT widget used as a recompute trigger (updated from JS)
required = dict(base.get("required", {}))
required["updater"] = (
"FLOAT",
{
"default": 100.0,
"min": 0.0,
"max": 1000000.0,
"step": 0.1,
"default": 0.001,
"min": 0.001,
"max": 100,
"step": 0.001,
},
)
base["required"] = required
# Hidden unique id, shared with the RGBYP editor/bridge.
hidden = dict(base.get("hidden", {}))
hidden["unique_id"] = "UNIQUE_ID"
base["hidden"] = hidden
return base
DESCRIPTION = "Loads an image, stores helper file info for the RGBYP editor, and outputs the file path and file name."
DESCRIPTION = "Loads an image, reads baked original/mask from temp via <base_name>_<unique_id>_meta.json, and outputs helper file info."
CATEGORY = getattr(nodes.LoadImage, "CATEGORY", "image")
RETURN_TYPES = ("IMAGE", "IMAGE", "MASK", "STRING", "STRING", "STRING", "STRING")
RETURN_TYPES = ("IMAGE", "IMAGE", "MASK", "STRING", "STRING")
RETURN_NAMES = (
"image",
"rgbyp_mask",
"mask",
"dir_path",
"file_path",
"file_name",
"file_name_ext",
)
OUTPUT_NODE = True
FUNCTION = "load_image"
# ------------------------------------------------------------------
# Helpers
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
# ------------------------------------------------------------------
@staticmethod
def _get_temp_paths(unique_id: str):
def _normalize_base_name(raw_name: str) -> str:
"""
Build all temp paths used by this node for a given unique_id.
Превращает имя вроде 'iii_2_108__rgbyp' обратно в базовое 'iii_2'.
Правила:
1) Если есть хвост '__rgbyp' — срезаем его.
2) После этого, если имя оканчивается на '_<digits>' — тоже срезаем
(это тот ID, который навешивает JS для превью).
Примеры:
'iii_2_108__rgbyp' -> 'iii_2'
'iii_2_999__rgbyp' -> 'iii_2'
'iii_2' -> 'iii_2'
"""
if not raw_name:
return raw_name
base = raw_name
# 1. Убираем хвост "__rgbyp"
preview_suffix = "__rgbyp"
if base.endswith(preview_suffix):
base = base[: -len(preview_suffix)]
# 2. Убираем концеовой "_<digits>", если есть
idx = base.rfind("_")
if idx != -1:
tail = base[idx + 1 :]
if tail.isdigit():
base = base[:idx]
return base
def _read_meta_paths(self, base_name, unique_id):
"""
Ищет meta.json в temp:
<base_name>_<unique_id>_meta.json
где base_name — уже очищенное базовое имя картинки (без .png и без хвостов от JS).
Возвращает:
(temp_dir, meta_path, original_path, mask_path, composite_path)
- *_path могут быть None, если их нет или meta.json отсутствует.
"""
temp_dir = folder_paths.get_temp_directory()
os.makedirs(temp_dir, exist_ok=True)
original_name = f"RGBYP_{unique_id}_original.png"
composite_name = f"RGBYP_{unique_id}_composite.png"
mask_name = f"RGBYP_{unique_id}_mask.png"
meta_name = f"RGBYP_{unique_id}_meta.json"
return {
"temp_dir": temp_dir,
"original": os.path.join(temp_dir, original_name),
"composite": os.path.join(temp_dir, composite_name),
"mask": os.path.join(temp_dir, mask_name),
"meta": os.path.join(temp_dir, meta_name),
}
# ------------------------------------------------------------------
# Load color mask RGBYP_<unique_id>_mask.png from temp directory
# ------------------------------------------------------------------
def _load_rgbyp_mask(self, base_image, unique_id):
"""
Load RGBYP mask from temp.
Returns:
(mask_tensor, has_color_mask)
- mask_tensor: IMAGE tensor
- has_color_mask: bool, True if mask file exists and has non-zero pixels
"""
device = base_image.device
b, h, w, c = base_image.shape
# Original empty full-size mask
empty = torch.zeros_like(base_image, device=device)
# Fallback 64×64 black IMAGE (small placeholder)
fallback_64 = torch.zeros((1, 64, 64, 3), device=device, dtype=base_image.dtype)
if not unique_id:
# No editor / no id – behave as "no mask yet".
return fallback_64, False
paths = self._get_temp_paths(unique_id)
mask_path = paths["mask"]
meta_path = paths["meta"]
# If we have meta with a different graph_image key, treat the mask as stale
try:
current_graph_image = getattr(base_image, "graph_image", None)
if current_graph_image is not None and os.path.isfile(meta_path):
with open(meta_path, "r", encoding="utf-8") as f:
meta = json.load(f)
stored_graph_image = meta.get("graph_image")
if stored_graph_image and str(stored_graph_image) != str(current_graph_image):
# Mask was drawn for another image – ignore it (and optionally delete old file).
try:
if os.path.isfile(mask_path):
os.remove(mask_path)
except OSError:
pass
return fallback_64, False
except Exception as e:
print(f"[LoadImageWithFileData] warning reading meta.json: {e}")
# If file does not exist, there is no color mask yet.
if not os.path.isfile(mask_path):
return fallback_64, False
try:
m = Image.open(mask_path).convert("RGBA")
if m.size != (w, h):
m = m.resize((w, h), Image.LANCZOS)
m_rgb = m.convert("RGB")
arr = np.array(m_rgb).astype(np.float32) / 255.0
mask_tensor = torch.from_numpy(arr)[None, ...].to(device)
has_color_mask = False
try:
if mask_tensor.sum().item() != 0.0:
has_color_mask = True
except Exception:
has_color_mask = False
return mask_tensor, has_color_mask
except Exception as e:
print(f"[LoadImageWithFileData] error loading rgbyp_mask: {e}")
return empty, False
# ------------------------------------------------------------------
# Save composite RGBYP_<unique_id>_composite.png
# ------------------------------------------------------------------
def _save_composite_with_opacity(self, image_tensor, updater, unique_id, has_color_mask):
"""
Save composite image RGBYP_<unique_id>_composite.png in temp directory,
blending the RGBYP mask with the base image using updater as opacity (0–100).
Returns:
(composite_path, composite_exists)
"""
if not unique_id:
return "", False
paths = self._get_temp_paths(unique_id)
composite_path = paths["composite"]
mask_path = paths["mask"]
# If there is no color mask, remove old composite (if any) and exit.
if (not has_color_mask) or (not os.path.isfile(mask_path)):
try:
if os.path.isfile(composite_path):
os.remove(composite_path)
except OSError:
pass
return "", False
# Convert base image tensor to PIL
try:
arr_img = (image_tensor[0].detach().cpu().numpy().clip(0, 1) * 255).astype(
np.uint8
)
base_pil = Image.fromarray(arr_img, "RGB")
except Exception as e:
print(f"[LoadImageWithFileData] error converting base image to PIL: {e}")
return "", False
try:
mask_pil = Image.open(mask_path).convert("RGBA")
if mask_pil.size != base_pil.size:
mask_pil = mask_pil.resize(base_pil.size, Image.LANCZOS)
rgba_base = base_pil.convert("RGBA")
# updater is expected in 0–100 range
try:
opacity_value = float(updater)
except Exception:
opacity_value = 100.0
alpha_factor = max(0.0, min(opacity_value / 100.0, 1.0))
if alpha_factor <= 0.0:
# Fully transparent – treat as "no composite"
try:
if os.path.isfile(composite_path):
os.remove(composite_path)
except OSError:
pass
return "", False
r, g, b, a = mask_pil.split()
a = a.point(lambda v: int(v * alpha_factor))
mask_pil = Image.merge("RGBA", (r, g, b, a))
rgba_base.alpha_composite(mask_pil)
rgba_base.convert("RGB").save(composite_path)
return composite_path, True
except Exception as e:
print(f"[LoadImageWithFileData] error saving composite: {e}")
try:
if os.path.isfile(composite_path):
os.remove(composite_path)
except OSError:
pass
return "", False
# ------------------------------------------------------------------
# Main execution
# ------------------------------------------------------------------
def load_image(self, image, updater=0.0, unique_id=None):
# Use original LoadImage behaviour
base_loader = nodes.LoadImage()
output_image, output_mask = base_loader.load_image(image)
# Resolve absolute path of the loaded image
abs_path = folder_paths.get_annotated_filepath(image)
dir_path, file_name_ext = os.path.split(abs_path)
file_name, _ = os.path.splitext(file_name_ext)
file_path = abs_path
# Attach metadata attributes to IMAGE tensor
try:
setattr(output_image, "dir_path", dir_path)
setattr(output_image, "file_path", file_path)
setattr(output_image, "file_name", file_name)
setattr(output_image, "file_name_ext", file_name_ext)
# Key that identifies which graph image this is (used to detect stale masks)
setattr(output_image, "graph_image", str(image))
except Exception:
pass
# Load RGBYP mask (or fallback) for this unique id
rgbyp_mask, has_color_mask = self._load_rgbyp_mask(output_image, unique_id)
# Prepare temp paths and ensure original image copy
paths = self._get_temp_paths(unique_id) if unique_id else None
original_path = ""
composite_path = ""
composite_exists = False
if paths is not None:
original_path = paths["original"]
composite_path = paths["composite"]
mask_path = paths["mask"]
meta_path = paths["meta"]
# Save original image copy to temp
try:
arr_img = (
output_image[0].detach().cpu().numpy().clip(0, 1) * 255
).astype(np.uint8)
base_pil = Image.fromarray(arr_img, "RGB")
base_pil.save(original_path)
except Exception as e:
print(f"[LoadImageWithFileData] ERROR saving original image: {e}")
# Save composite preview with opacity from updater (only if mask has color)
composite_path, composite_exists = self._save_composite_with_opacity(
output_image, updater, unique_id, has_color_mask
)
# Save / update meta.json
try:
_, h, w, _ = output_image.shape
except Exception:
h = 0
w = 0
meta = {
"graph_image": str(image),
"original": original_path,
"mask": mask_path if has_color_mask and os.path.isfile(mask_path) else "",
"composite": composite_path if composite_exists else "",
"width": int(w),
"height": int(h),
}
try:
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False)
except Exception as e:
print(f"[LoadImageWithFileData] ERROR saving meta.json: {e}")
return (
output_image,
rgbyp_mask,
output_mask,
dir_path,
file_path,
file_name,
file_name_ext,
print(
f"[LoadImageWithFileData] _read_meta_paths: "
f"base_name='{base_name}', unique_id='{unique_id}', temp_dir='{temp_dir}'"
)
if not base_name or not unique_id:
print("[LoadImageWithFileData] _read_meta_paths: base_name or unique_id is empty -> no meta.json")
return temp_dir, None, None, None, None
# Правильное имя meta-файла:
meta_filename = f"{base_name}_{unique_id}_meta.json"
meta_path = os.path.join(temp_dir, meta_filename)
print(
f"[LoadImageWithFileData] _read_meta_paths: looking for meta json file "
f"'{meta_filename}' at '{meta_path}'"
)
if not os.path.isfile(meta_path):
print(f"[LoadImageWithFileData] _read_meta_paths: meta json NOT FOUND at '{meta_path}'")
return temp_dir, meta_path, None, None, None
try:
with open(meta_path, "r", encoding="utf-8") as f:
meta = json.load(f)
except Exception as e:
print(f"[LoadImageWithFileData] error reading meta json '{meta_path}': {e}")
return temp_dir, meta_path, None, None, None
def resolve(key):
val = str(meta.get(key) or "").strip()
if not val:
return None
return val if os.path.isabs(val) else os.path.join(temp_dir, val)
original_path = resolve("original")
mask_path = resolve("mask")
composite_path = resolve("composite")
print(
"[LoadImageWithFileData] _read_meta_paths: "
f"original='{original_path}', mask='{mask_path}', composite='{composite_path}'"
)
return temp_dir, meta_path, original_path, mask_path, composite_path
def _load_image_from_path(self, path, ref_tensor=None, label=""):
"""
Загружает картинку из файла как IMAGE-тензор (1,H,W,C) в диапазоне [0,1].
Сейчас используем RGBA, чтобы сохранялась альфа (прозрачный фон маски).
Если PNG без альфы, будет обычный RGB.
Если передан ref_tensor, под него подгоняется device/dtype,
а размер по возможности тоже синхронизируется.
label — строка для логов (например 'original' или 'mask').
"""
if not path or not os.path.isfile(path):
print(
f"[LoadImageWithFileData] _load_image_from_path: {label} path is missing or not a file: '{path}'"
)
return None
print(f"[LoadImageWithFileData] _load_image_from_path: loading {label} from '{path}'")
try:
img = Image.open(path).convert("RGBA")
# Если есть ref_tensor, можем подогнать размер
if ref_tensor is not None:
try:
_, h, w, _ = ref_tensor.shape
if img.size != (w, h):
print(
f"[LoadImageWithFileData] _load_image_from_path: "
f"resizing {label} from {img.size} to ({w}, {h})"
)
img = img.resize((w, h), resample=Image.LANCZOS)
except Exception as e:
print(
"[LoadImageWithFileData] _load_image_from_path: "
f"could not auto-resize {label} to ref_tensor shape: {e}"
)
arr = np.array(img).astype(np.float32) / 255.0
tensor = torch.from_numpy(arr)[None, ...] # (1,H,W,C)
if ref_tensor is not None:
tensor = tensor.to(device=ref_tensor.device, dtype=ref_tensor.dtype)
return tensor
except Exception as e:
print(f"[LoadImageWithFileData] error loading {label} image from '{path}': {e}")
return None
def _make_black_64(self, ref_tensor):
"""
Делает чёрную картинку 64×64 (IMAGE) на том же device/dtype,
что и ref_tensor.
"""
device = getattr(ref_tensor, "device", "cpu")
dtype = getattr(ref_tensor, "dtype", torch.float32)
print(
f"[LoadImageWithFileData] _make_black_64: creating black 64x64 image "
f"on device={device}, dtype={dtype}"
)
return torch.zeros((1, 64, 64, 3), device=device, dtype=dtype)
# ------------------------------------------------------------------
# Change detection / validation
# ОСНОВНАЯ ЛОГИКА НОДЫ
# ------------------------------------------------------------------
def load_image(self, image, updater=0.0, unique_id=None):
print(
f"[LoadImageWithFileData] load_image: image='{image}', "
f"updater={updater}, unique_id='{unique_id}'"
)
# 1. Загружаем картинку стандартной LoadImage
base_loader = nodes.LoadImage()
base_image, base_mask = base_loader.load_image(image)
# 1. Берем имя входной картинки и сохраняем имя картинки в переменной imageOriginalName
abs_path = folder_paths.get_annotated_filepath(image)
dir_path, file_name_ext = os.path.split(abs_path)
imageOriginalName, _ = os.path.splitext(file_name_ext)
print(
"[LoadImageWithFileData] load_image: "
f"abs_path='{abs_path}', dir_path='{dir_path}', "
f"file_name_ext='{file_name_ext}', imageOriginalName='{imageOriginalName}'"
)
# 1.1 Делать переменную outputMask = None
outputMask = None
# 1.2 Составляем имя json как rgbyp_idНоды и записываем в переменную jsonFileName
temp_dir = folder_paths.get_temp_directory()
os.makedirs(temp_dir, exist_ok=True)
if unique_id is not None:
jsonFileName = f"rgbyp_{unique_id}.json"
json_path = os.path.join(temp_dir, jsonFileName)
else:
jsonFileName = None
json_path = None
print(
"[LoadImageWithFileData] load_image: "
f"temp_dir='{temp_dir}', jsonFileName='{jsonFileName}', json_path='{json_path}'"
)
# 1.3 Сохраняем полный путь к выбранной картинке в переменной filePath
filePath = abs_path
# 1.4 Сохраняем имя файла картинки без расширения в переменной fileName
fileName = imageOriginalName
# 2. Проверяем, есть ли в папке temp json с именем jsonFileName
if json_path is not None and os.path.isfile(json_path):
print(f"[LoadImageWithFileData] load_image: json exists at '{json_path}'")
try:
with open(json_path, "r", encoding="utf-8") as f:
meta = json.load(f)
except Exception as e:
print(
f"[LoadImageWithFileData] load_image: error reading json '{json_path}': {e}"
)
meta = {}
mask_rel = str(meta.get("mask") or "").strip()
print(
"[LoadImageWithFileData] load_image: "
f"json mask field='{mask_rel}'"
)
# ЕСЛИ ПОЛЕ mask НЕ ПУСТОЕ
if mask_rel:
# mask_rel может быть абсолютным путём или именем файла в temp
mask_path = (
mask_rel if os.path.isabs(mask_rel) else os.path.join(temp_dir, mask_rel)
)
print(
"[LoadImageWithFileData] load_image: "
f"resolved mask_path='{mask_path}'"
)
if os.path.isfile(mask_path):
# Загружаем маску из temp и присваиваем её переменной outputMask
outputMask = self._load_image_from_path(
mask_path, ref_tensor=base_image, label="rgbyp_mask"
)
if outputMask is None:
print(
"[LoadImageWithFileData] load_image: failed to load mask image, "
"will fallback to black 64x64"
)
else:
print(
"[LoadImageWithFileData] load_image: mask file does not exist, "
"will fallback to black 64x64"
)
else:
# ЕСЛИ ПОЛЕ ПУСТОЕ
print(
"[LoadImageWithFileData] load_image: json mask field is empty, "
"will use black 64x64 mask"
)
else:
# ЕСЛИ JSON НЕТ
if json_path is not None:
print(
"[LoadImageWithFileData] load_image: json not found at "
f"'{json_path}', will use black 64x64 mask"
)
else:
print(
"[LoadImageWithFileData] load_image: unique_id is None, "
"skipping json lookup and using black 64x64 mask"
)
# Если на этом этапе outputMask всё ещё None — создаём черную картинку 64x64
if outputMask is None:
outputMask = self._make_black_64(base_image)
print(
"[LoadImageWithFileData] load_image: done, returning base_image, outputMask, "
"base_mask, filePath, fileName"
)
return (
base_image,
outputMask,
base_mask,
filePath,
fileName,
)
# ------------------------------------------------------------------
# CHANGE DETECTION / VALIDATION
# ------------------------------------------------------------------
@classmethod
def IS_CHANGED(cls, image, updater=0.0, **kwargs):
"""
Node is treated as changed when:
- the underlying image changes (LoadImage.IS_CHANGED),
- or the updater value changes (from JS / UI).
"""
base_changed = nodes.LoadImage.IS_CHANGED(image)
try:
# If base loader already indicates change, respect that
if base_changed is not None and not np.isnan(float(base_changed)):
return base_changed
except Exception:
pass
# Otherwise, tie change to updater value
return float(updater or 0.0)
@classmethod
+451 -140
View File
@@ -15,7 +15,7 @@ class RGBYPMaskBridge:
"clear_on_size_change": (
"BOOLEAN",
{
"default": True,
"default": False,
"label_on": "Clear",
"label_off": "Keep",
},
@@ -23,10 +23,19 @@ class RGBYPMaskBridge:
"updater": (
"FLOAT",
{
"default": 100.0,
"min": 0.0,
"max": 100000.0,
"step": 0.1,
"default": 0.75,
"min": 0.001,
"max": 1.0,
"step": 0.001,
},
),
},
"optional": {
"file_path": (
"STRING",
{
"default": "",
"forceInput": True,
},
),
},
@@ -42,168 +51,470 @@ class RGBYPMaskBridge:
OUTPUT_NODE = True
FUNCTION = "execute"
# Extract filename/path from IMAGE tensor metadata
# ---------- helpers ----------
def _get_original_filename_from_tensor(self, image):
"""
Пытаемся вытащить исходный путь к файлу из тензора IMAGE.
"""
try:
for attr in ("image_path", "filename", "name"):
if hasattr(image, attr):
val = getattr(image, attr)
meta = getattr(image, "metadata", None)
if isinstance(meta, dict):
for key in ("original_path", "filename", "path"):
val = meta.get(key)
if isinstance(val, str) and val:
return os.path.abspath(val)
except Exception as e:
print(f"[RGBYPMaskBridge] Error reading filename from tensor: {e}")
return None
def _save_tensor_as_png(self, image_tensor, path):
"""
Сохраняем IMAGE (B,H,W,C в [0,1]) как PNG по указанному пути.
Берём только первый батч.
Поддерживаем как RGB, так и RGBA (если C>=4, сохраняем с альфой).
"""
try:
img0 = image_tensor[0].detach().cpu().clamp(0.0, 1.0)
# гарантируем минимум 3 канала
if img0.shape[-1] < 3:
pad = 3 - img0.shape[-1]
img0 = torch.cat([img0, img0[..., :1].repeat(1, 1, pad)], dim=-1)
if img0.shape[-1] >= 4:
rgba = img0[..., :4].numpy()
rgba_u8 = (rgba * 255.0).round().astype(np.uint8)
Image.fromarray(rgba_u8, mode="RGBA").save(path, format="PNG")
else:
rgb = img0[..., :3].numpy()
rgb_u8 = (rgb * 255.0).round().astype(np.uint8)
Image.fromarray(rgb_u8, mode="RGB").save(path, format="PNG")
print(f"[RGBYPMaskBridge] saved PNG: '{path}'")
except Exception as e:
print(f"[RGBYPMaskBridge] ERROR saving PNG '{path}': {e}")
def _load_mask_tensor(self, mask_path, target_hw, device):
"""
Загружаем картинку маски из PNG и приводим к размеру target_hw (H,W).
Возвращаем torch.Tensor формы (1,H,W,4) в [0,1] с прозрачным фоном:
- пиксели, где маска чёрная (RGB≈0), получают alpha=0;
- цветные пиксели маски получают alpha=1.
"""
try:
m = Image.open(mask_path).convert("RGBA")
w_t, h_t = target_hw[1], target_hw[0]
if m.size != (w_t, h_t):
m = m.resize((w_t, h_t), resample=Image.NEAREST)
arr = np.array(m).astype(np.float32) / 255.0
if arr.ndim == 2:
# серый → дублируем в RGB, alpha затем пересчитаем
arr = np.stack([arr, arr, arr, np.ones_like(arr)], axis=-1)
# гарантируем минимум 3 канала
if arr.shape[-1] < 3:
pad = 3 - arr.shape[-1]
pad_arr = np.repeat(arr[..., :1], pad, axis=-1)
arr = np.concatenate([arr, pad_arr], axis=-1)
rgb = arr[..., :3]
# alpha: 0 там, где RGB почти чёрный, иначе 1
mag = np.max(rgb, axis=-1)
alpha = (mag > 1e-3).astype(np.float32)
rgba = np.concatenate([rgb, alpha[..., None]], axis=-1)
t = torch.from_numpy(rgba).to(device=device, dtype=torch.float32).unsqueeze(0)
print(
f"[RGBYPMaskBridge] loaded mask tensor from '{mask_path}', shape={tuple(t.shape)}"
)
return t
except Exception as e:
print(f"[RGBYPMaskBridge] ERROR loading mask '{mask_path}': {e}")
return None
def _bake_composite(self, base_tensor, mask_path_or_none, updater, out_path, device):
"""
Делает запечённую картинку:
- base_tensor: IMAGE (B,H,W,C) в [0,1]
- mask_path_or_none: путь к PNG маски либо None
- updater: FLOAT (0..100000) → opacity фактор (0..1)
- out_path: полный путь PNG
Возвращает True/False.
"""
try:
img0 = base_tensor[0].detach().cpu().clamp(0.0, 1.0)
base_arr = img0[..., :3].numpy() # (H,W,3)
h, w, _ = base_arr.shape
if mask_path_or_none and os.path.isfile(mask_path_or_none):
mask_img = Image.open(mask_path_or_none).convert("RGB")
if mask_img.size != (w, h):
mask_img = mask_img.resize((w, h), resample=Image.NEAREST)
mask_arr = np.array(mask_img).astype(np.float32) / 255.0
if mask_arr.ndim == 2:
mask_arr = np.stack([mask_arr, mask_arr, mask_arr], axis=-1)
if mask_arr.shape[-1] != 3:
mask_arr = mask_arr[..., :3]
print(
f"[RGBYPMaskBridge] baking with REAL mask '{mask_path_or_none}', size={mask_img.size}"
)
else:
# пустая прозрачная "маска 1 пиксель" → по факту нулевая маска
mask_arr = np.zeros((1, 1, 3), dtype=np.float32)
print("[RGBYPMaskBridge] baking with EMPTY mask (no mask file)")
# приводим маску к размеру base через ресайз
if mask_arr.shape[0] != h or mask_arr.shape[1] != w:
mask_img = Image.fromarray(
(np.clip(mask_arr, 0.0, 1.0) * 255.0).astype(np.uint8), mode="RGB"
)
mask_img = mask_img.resize((w, h), resample=Image.NEAREST)
mask_arr = np.array(mask_img).astype(np.float32) / 255.0
# alpha_factor = float(max(0.0, min(1.0, updater / 100.0)))
alpha_factor = float(max(0.0, min(1.0, updater)))
alpha = np.max(mask_arr, axis=2, keepdims=True) * alpha_factor # (H,W,1)
comp = base_arr * (1.0 - alpha) + mask_arr * alpha
comp = np.clip(comp, 0.0, 1.0)
comp_u8 = (comp * 255.0).round().astype(np.uint8)
comp_img = Image.fromarray(comp_u8, mode="RGB")
comp_img.save(out_path, format="PNG")
print(f"[RGBYPMaskBridge] baked composite to '{out_path}'")
return True
except Exception as e:
print(f"[RGBYPMaskBridge] ERROR baking composite: {e}")
return False
# ---------- main ----------
def execute(self, image, clear_on_size_change=True, updater=100.0, unique_id=None):
device = image.device
b, h, w, c = image.shape
if unique_id is None:
unique_id = "0"
print(f"[RGBYPMaskBridge] execute(): start, unique_id={unique_id}, clear_on_size_change={clear_on_size_change}, updater={updater}, image_shape={tuple(image.shape)}, device={device}")
# TEMP paths
temp_dir = folder_paths.get_temp_directory()
original_name = f"RGBYP_{unique_id}_original.png"
composite_name = f"RGBYP_{unique_id}_composite.png"
mask_name = f"RGBYP_{unique_id}_mask.png"
meta_name = f"RGBYP_{unique_id}_meta.json"
original_path = os.path.join(temp_dir, original_name)
composite_path = os.path.join(temp_dir, composite_name)
mask_path = os.path.join(temp_dir, mask_name)
meta_path = os.path.join(temp_dir, meta_name)
input_dir = folder_paths.get_input_directory()
rgbyp_input_dir = os.path.join(input_dir, "rgbyp")
os.makedirs(temp_dir, exist_ok=True)
os.makedirs(rgbyp_input_dir, exist_ok=True)
# Load previous meta (optional, no longer used for paths)
if os.path.isfile(meta_path):
try:
with open(meta_path, "r", encoding="utf-8") as f:
json.load(f)
except Exception as e:
print(f"[RGBYPMaskBridge] ERROR reading meta.json: {e}")
# Mask loading
mask_tensor = torch.zeros_like(image)
mask_pil = None
if os.path.isfile(mask_path):
try:
m = Image.open(mask_path).convert("RGBA")
original_mask_size = m.size
if original_mask_size != (w, h):
if clear_on_size_change:
try:
os.remove(mask_path)
except OSError:
pass
mask_pil = None
else:
m = m.resize((w, h), Image.LANCZOS)
m.save(mask_path)
mask_pil = m
else:
mask_pil = m
if mask_pil is not None:
m_rgb = mask_pil.convert("RGB")
arr = np.array(m_rgb).astype(np.float32) / 255.0
mask_tensor = torch.from_numpy(arr)[None, ...].to(device)
except Exception as e:
print(f"[RGBYPMaskBridge] ERROR loading mask: {e}")
mask_pil = None
# 1. Имя входной картинки
input_abs_path = self._get_original_filename_from_tensor(image)
if input_abs_path:
base_filename = os.path.basename(input_abs_path)
else:
mask_pil = None
base_filename = f"RGBYP_{unique_id}.png"
has_color_mask = False
if mask_pil is not None:
try:
if mask_tensor.sum().item() != 0.0:
has_color_mask = True
except Exception:
has_color_mask = False
if not has_color_mask:
mask_tensor = torch.zeros((1, 64, 64, 3), device=device, dtype=torch.float32)
# Generate base image PIL
arr_img = (image[0].detach().cpu().numpy().clip(0, 1) * 255).astype(np.uint8)
base_pil = Image.fromarray(arr_img, "RGB")
# Save original image copy to temp
try:
base_pil.save(original_path)
except Exception as e:
print(f"[RGBYPMaskBridge] ERROR saving original image: {e}")
# Generate composite image only if mask has color
composite_exists = False
if has_color_mask and mask_pil is not None:
rgba_base = base_pil.convert("RGBA")
if updater is None:
updater = 100.0
try:
opacity_value = float(updater)
except Exception:
opacity_value = 100.0
alpha_factor = opacity_value / 100.0
if alpha_factor < 0.0:
alpha_factor = 0.0
if alpha_factor > 1.0:
alpha_factor = 1.0
r, g, b, a = mask_pil.split()
a = a.point(lambda v: int(v * alpha_factor))
mask_for_merge = Image.merge("RGBA", (r, g, b, a))
merged = Image.alpha_composite(rgba_base, mask_for_merge)
final_pil = merged.convert("RGB")
try:
final_pil.save(composite_path)
composite_exists = True
except Exception as e:
print(f"[RGBYPMaskBridge] ERROR saving composite: {e}")
base_no_ext, _ = os.path.splitext(base_filename)
suffix = "_rgbyp_composite"
if base_no_ext.endswith(suffix):
imageOriginalName = base_no_ext[: -len(suffix)]
else:
try:
if os.path.isfile(composite_path):
os.remove(composite_path)
except OSError:
pass
imageOriginalName = base_no_ext
# Save meta.json with paths inside temp
meta = {
"graph_image": "",
"original": original_path,
"mask": mask_path if has_color_mask and os.path.isfile(mask_path) else "",
"composite": composite_path if composite_exists else "",
if not imageOriginalName:
imageOriginalName = f"RGBYP_{unique_id}"
print(f"[RGBYPMaskBridge] input file='{base_filename}', imageOriginalName='{imageOriginalName}'")
# 1.1 jsonFileName
jsonFileName = f"rgbyp_{unique_id}.json"
json_path = os.path.join(temp_dir, jsonFileName)
# 1.2 jsonTemp in memory
jsonTemp = {
"original": f"{imageOriginalName}_rgbyp_original.png",
"mask": "",
"composite": "",
"width": int(w),
"height": int(h),
}
try:
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False)
except Exception as e:
print(f"[RGBYPMaskBridge] ERROR saving meta.json: {e}")
# UI preview: show composite if it exists, otherwise original
ui_filename = composite_name if composite_exists else original_name
ui = {
"images": [{
"filename": ui_filename,
"subfolder": "",
"type": "temp",
}]
}
# 1.3 outputImage = входной image
outputImage = image
return {"result": (image, mask_tensor), "ui": ui}
# 1.4 outputMask = None
outputMask = None
# 2. Проверяем существование json
json_exists = os.path.isfile(json_path)
print(f"[RGBYPMaskBridge] json_exists={json_exists}, json_path='{json_path}'")
# Для превью
preview_filename = None
preview_type = None
preview_subfolder = ""
if not json_exists:
# --- JSON НЕТ ---
print("[RGBYPMaskBridge] JSON does not exist → create new")
# сохраняем jsonTemp
try:
with open(json_path, "w", encoding="utf-8") as f:
json.dump(jsonTemp, f, ensure_ascii=False, indent=2)
print(f"[RGBYPMaskBridge] wrote new json '{json_path}'")
except Exception as e:
print(f"[RGBYPMaskBridge] ERROR writing new json: {e}")
# сохраняем входную картинку как original в temp
original_temp_path = os.path.join(temp_dir, jsonTemp["original"])
self._save_tensor_as_png(outputImage, original_temp_path)
# превью — эта же original-картинка из temp
preview_filename = jsonTemp["original"]
preview_type = "temp"
preview_subfolder = ""
else:
# --- JSON ЕСТЬ ---
try:
with open(json_path, "r", encoding="utf-8") as f:
existing = json.load(f)
except Exception as e:
print(f"[RGBYPMaskBridge] ERROR reading existing json: {e}")
existing = {}
# читаем старые ширину/высоту и mask
old_w = int(existing.get("width", 0) or 0)
old_h = int(existing.get("height", 0) or 0)
mask_name = (existing.get("mask") or "").strip()
isJsonMask = bool(mask_name)
print(
f"[RGBYPMaskBridge] existing width={old_w}, height={old_h}, mask='{mask_name}', isJsonMask={isJsonMask}"
)
sizes_match = (old_w == int(w)) and (old_h == int(h))
print(f"[RGBYPMaskBridge] sizes_match={sizes_match}, clear_on_size_change={clear_on_size_change}")
# имя composite
composite_name = f"{imageOriginalName}_rgbyp_composite.png"
composite_temp_path = os.path.join(temp_dir, composite_name)
composite_input_path = os.path.join(rgbyp_input_dir, composite_name)
# начнём с jsonTemp как базового (обновлённые width/height/original)
jsonData = dict(jsonTemp)
# но скопируем sha/mask/composite из existing, если нужно
jsonData["mask"] = mask_name # оставляем старую маску, если была
jsonData["composite"] = existing.get("composite", "")
if sizes_match:
# ---------- размеры совпадают ----------
if isJsonMask:
# есть маска → печём composite с ней
mask_path = os.path.join(temp_dir, mask_name)
print(
f"[RGBYPMaskBridge] sizes match & mask exists → bake composite with mask '{mask_path}'"
)
baked = self._bake_composite(
outputImage, mask_path, updater, composite_temp_path, device
)
if baked:
# Скопировать composite и в input/rgbyp
try:
Image.open(composite_temp_path).save(
composite_input_path, format="PNG"
)
print(
f"[RGBYPMaskBridge] copied composite to input/rgbyp: '{composite_input_path}'"
)
except Exception as e:
print(
f"[RGBYPMaskBridge] ERROR copying composite to input/rgbyp: {e}"
)
jsonData["composite"] = composite_name
# outputMask = сама маска
outputMask = self._load_mask_tensor(
mask_path, (int(h), int(w)), device
)
# превью из input/rgbyp
preview_filename = composite_name
preview_type = "input"
preview_subfolder = "rgbyp"
else:
print("[RGBYPMaskBridge] bake failed, fallback preview to original")
# сохраняем original и используем как превью
original_temp_path = os.path.join(temp_dir, jsonTemp["original"])
self._save_tensor_as_png(outputImage, original_temp_path)
preview_filename = jsonTemp["original"]
preview_type = "temp"
preview_subfolder = ""
else:
# маски нет → печём composite с пустой маской
print(
"[RGBYPMaskBridge] sizes match & mask is empty → bake composite with empty mask"
)
baked = self._bake_composite(
outputImage, None, updater, composite_temp_path, device
)
if baked:
try:
Image.open(composite_temp_path).save(
composite_input_path, format="PNG"
)
print(
f"[RGBYPMaskBridge] copied composite to input/rgbyp: '{composite_input_path}'"
)
except Exception as e:
print(
f"[RGBYPMaskBridge] ERROR copying composite to input/rgbyp: {e}"
)
jsonData["mask"] = "" # по ТЗ поле mask остаётся пустым
jsonData["composite"] = composite_name
preview_filename = composite_name
preview_type = "input"
preview_subfolder = "rgbyp"
else:
print("[RGBYPMaskBridge] bake failed, fallback preview to original")
original_temp_path = os.path.join(temp_dir, jsonTemp["original"])
self._save_tensor_as_png(outputImage, original_temp_path)
preview_filename = jsonTemp["original"]
preview_type = "temp"
preview_subfolder = ""
else:
# ---------- размеры НЕ совпадают ----------
if clear_on_size_change:
print(
"[RGBYPMaskBridge] size mismatch & clear_on_size_change=True → reset jsonTemp & preview=input image"
)
# jsonTemp уже содержит актуальные width/height/original
jsonData = dict(jsonTemp)
# сохраняем jsonTemp
try:
with open(json_path, "w", encoding="utf-8") as f:
json.dump(jsonData, f, ensure_ascii=False, indent=2)
print(f"[RGBYPMaskBridge] wrote reset json '{json_path}'")
except Exception as e:
print(
f"[RGBYPMaskBridge] ERROR writing reset json (size mismatch, clear=True): {e}"
)
# сохраняем original в temp
original_temp_path = os.path.join(temp_dir, jsonTemp["original"])
self._save_tensor_as_png(outputImage, original_temp_path)
preview_filename = jsonTemp["original"]
preview_type = "temp"
preview_subfolder = ""
else:
# clear_on_size_change == False
print(
"[RGBYPMaskBridge] size mismatch & clear_on_size_change=False → keep mask, bake with it if exists"
)
mask_path = os.path.join(temp_dir, mask_name) if mask_name else None
# Сохраняем входную картинку как original в temp с новым именем
original_temp_path = os.path.join(temp_dir, jsonTemp["original"])
self._save_tensor_as_png(outputImage, original_temp_path)
baked = self._bake_composite(
outputImage, mask_path, updater, composite_temp_path, device
)
if baked:
try:
Image.open(composite_temp_path).save(
composite_input_path, format="PNG"
)
print(
f"[RGBYPMaskBridge] copied composite to input/rgbyp: '{composite_input_path}'"
)
except Exception as e:
print(
f"[RGBYPMaskBridge] ERROR copying composite to input/rgbyp: {e}"
)
# сохраняем имя composite и обновлённые размеры
jsonData["composite"] = composite_name
jsonData["width"] = int(w)
jsonData["height"] = int(h)
# outputMask = сама маска, если есть
if mask_path and os.path.isfile(mask_path):
outputMask = self._load_mask_tensor(
mask_path, (int(h), int(w)), device
)
if outputMask is not None:
# сохраняем маску в temp с именем imageOriginalName + _rgbyp_mask
mask_output_name = f"{imageOriginalName}_rgbyp_mask.png"
mask_output_path = os.path.join(temp_dir, mask_output_name)
self._save_tensor_as_png(outputMask, mask_output_path)
jsonData["mask"] = mask_output_name
else:
print(
"[RGBYPMaskBridge] WARNING: could not load mask tensor, skip saving resized mask file"
)
else:
# маски нет на диске — оставляем поле mask пустым
jsonData["mask"] = ""
# превью — запечённая картинка из input/rgbyp
preview_filename = composite_name
preview_type = "input"
preview_subfolder = "rgbyp"
else:
print(
"[RGBYPMaskBridge] bake failed (size mismatch, clear=False), fallback preview to original"
)
# на всякий случай сохраняем original (если не сохранился)
original_temp_path = os.path.join(temp_dir, jsonTemp["original"])
self._save_tensor_as_png(outputImage, original_temp_path)
preview_filename = jsonTemp["original"]
preview_type = "temp"
preview_subfolder = ""
# в любом случае после всех веток – сохраняем jsonData
try:
with open(json_path, "w", encoding="utf-8") as f:
json.dump(jsonData, f, ensure_ascii=False, indent=2)
print(f"[RGBYPMaskBridge] wrote final json '{json_path}'")
except Exception as e:
print(f"[RGBYPMaskBridge] ERROR writing final json: {e}")
# 3. Выходы ноды
# 3.1 rgbyp_mask: если outputMask=None → чёрная 64x64
if outputMask is None:
print(
"[RGBYPMaskBridge] outputMask is None → using default black 64x64 mask"
)
mask_arr = np.zeros((64, 64, 3), dtype=np.float32)
outputMask = (
torch.from_numpy(mask_arr)
.to(device=device, dtype=torch.float32)
.unsqueeze(0)
)
# Превью
ui = None
if preview_filename is not None and preview_type is not None:
ui = {
"images": [
{
"filename": preview_filename,
"subfolder": preview_subfolder,
"type": preview_type,
}
]
}
print(
f"[RGBYPMaskBridge] preview: type={preview_type}, subfolder='{preview_subfolder}', filename='{preview_filename}'"
)
else:
print("[RGBYPMaskBridge] no preview image resolved")
if ui is not None:
print("[RGBYPMaskBridge] execute(): returning result with UI preview")
return {"result": (outputImage, outputMask), "ui": ui}
else:
print("[RGBYPMaskBridge] execute(): returning result without UI preview")
return {"result": (outputImage, outputMask)}
NODE_CLASS_MAPPINGS = {"RGBYPMaskBridge": RGBYPMaskBridge}