diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c366864
--- /dev/null
+++ b/.gitignore
@@ -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
\ No newline at end of file
diff --git a/js/RGBYPMaskBridgeRedraw.js b/js/RGBYPMaskBridgeRedraw.js
new file mode 100644
index 0000000..d62e2a8
--- /dev/null
+++ b/js/RGBYPMaskBridgeRedraw.js
@@ -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;
+}
diff --git a/js/RGBYPMaskEditor.js b/js/RGBYPMaskEditor.js
index cfeb680..7d37f5b 100644
--- a/js/RGBYPMaskEditor.js
+++ b/js/RGBYPMaskEditor.js
@@ -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 = `
+
Hotkeys
+
+ | 1, 2, 3, 4, 5 | Change brush color |
+ | SHIFT + 1 | Also changes brush color |
+ | SHIFT + A / SHIFT + D | Brush size |
+ | SHIFT + W / SHIFT + S | Mask opacity |
+ | SHIFT + Z / SHIFT + X | Zoom (or use the mouse wheel) |
+ | SHIFT + C | Fit image to window |
+ | SHIFT + V | Generate automatic masks. Each press creates the next one |
+ | SPACE / SHIFT + SPACE | Image scroll mode |
+ | ESC | Close the window without saving |
+ | ENTER | Save and close the window |
+
+`;
+
+/**
+ * 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: `
RGBYP Mask Editor
`,
- // 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;
+ };
},
-
-});
\ No newline at end of file
+});
diff --git a/js/RGBYPMaskEditor_io.js b/js/RGBYPMaskEditor_io.js
index daa59be..f08b560 100644
--- a/js/RGBYPMaskEditor_io.js
+++ b/js/RGBYPMaskEditor_io.js
@@ -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 });
- }
\ No newline at end of file
+ } 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)
+ * и готовит файл с правильным именем:
+ * _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;
+}
diff --git a/js/RGBYPMaskEditor_keys.js b/js/RGBYPMaskEditor_keys.js
new file mode 100644
index 0000000..d959267
--- /dev/null
+++ b/js/RGBYPMaskEditor_keys.js
@@ -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 уйдут вместе с ним.
+}
+
diff --git a/js/RGBYPMaskEditor_ui.js b/js/RGBYPMaskEditor_ui.js
index 095c5a2..732afb2 100644
--- a/js/RGBYPMaskEditor_ui.js
+++ b/js/RGBYPMaskEditor_ui.js
@@ -1,1320 +1,470 @@
-import { app } from "../../scripts/app.js";
-import { api } from "../../scripts/api.js";
-import { dataURLtoFile } from "./RGBYPMaskEditor_io.js";
+import { registerKeyHandlers, unregisterKeyHandlers } from "./RGBYPMaskEditor_keys.js";
+import { initBaseImageAndCanvas } from "./RGBYPMaskEditor_io.js";
+import { GP } from "./RGBYPMaskEditor.js";
+import { getNodeState } from "./RGBYPMaskEditor.js";
+import { setNodeState } from "./RGBYPMaskEditor.js";
+import { HELP } from "./RGBYPMaskEditor.js";
-let lastBrushSize = 50;
-let lastBrushColor = [255, 0, 0];
-let lastMaskOpacity = 75;
+const extensionBaseUrl = "/extensions/ComfyUI-RGBYP-Mask-Editor/";
+export const colorListRGB = [
+ { name: "R", color: [255, 0, 0] },
+ { name: "G", color: [0, 255, 0] },
+ { name: "B", color: [0, 0, 255] },
+ { name: "Y", color: [255, 255, 0] },
+ { name: "P", color: [255, 0, 255] },
+];
+const colorListWEB = [
+ { name: "R", color: "#ff4d4d" }, // Red
+ { name: "G", color: "#4dff4d" }, // Green
+ { name: "B", color: "#4d4dff" }, // Blue
+ { name: "Y", color: "#ffff4d" }, // Yellow
+ { name: "P", color: "#ff4dff" }, // Pink/Magenta
+];
-let extensionBaseUrl = "";
-(function findExtensionBase() {
- const scripts = document.getElementsByTagName("script");
- for (const s of scripts) {
- if (!s.src) continue;
- if (s.src.includes("RGBYPMaskEditor.js")) {
- const url = new URL(s.src, window.location.origin);
- extensionBaseUrl = url.href.replace(/RGBYPMaskEditor\.js.*$/i, "");
- break;
+export function updateSelectedColorUI(selectedIndex) {
+ const state = getNodeState(GP.baseNode.id);
+
+ state.colorButtons.forEach((btn, idx) => {
+ if (idx === selectedIndex) {
+ btn.style.border = "2px solid #ffffff";
+ btn.style.opacity = "1";
+ } else {
+ btn.style.border = "none";
+ btn.style.opacity = "0.6";
}
- }
-})();
+ });
-function makeIconButton(iconName, altText) {
- const btn = document.createElement("button");
- btn.style.padding = "0px";
- btn.style.cursor = "pointer";
- btn.style.borderRadius = "4px";
- btn.style.border = "1px solid #555";
- btn.style.background = "#333";
- btn.style.color = "#fff";
- btn.style.fontSize = "12px";
- btn.style.display = "flex";
- btn.style.alignItems = "center";
- btn.style.justifyContent = "center";
- btn.style.width = "60px";
- btn.style.height = "60px";
- btn.title = altText;
-
- const img = document.createElement("img");
- img.src = extensionBaseUrl + iconName;
- img.alt = altText;
- img.style.maxWidth = "100%";
- img.style.maxHeight = "100%";
- img.style.objectFit = "contain";
-
- btn.appendChild(img);
- return btn;
+ state.drawCursor.style.outlineColor = colorListWEB[selectedIndex].color;
}
-export function openMaskEditor(node, imageWidget, imageInfo) {
- const originalFilename = imageWidget?.value || imageInfo?.filename || "rgbyp.png";
+export function updateToolButtonsHighlight(activeTool) {
+ const state = getNodeState(GP.baseNode.id);
+ if (!state) return;
- function getGraphImageKey() {
- const v = imageWidget?.value || imageInfo?.filename || "";
- return v == null ? "" : String(v);
- }
-
- function stripExt(name) {
- const s = (name == null) ? "" : String(name);
- const m = s.match(/^(.*)\.(png|jpg|jpeg|webp|bmp|gif)$/i);
- return m ? m[1] : s;
- /*
- const m = name.match(/^(.*)\.(png|jpg|jpeg|webp|bmp|gif)$/i);
- return m ? m[1] : name;
- */
- }
-
- let baseFilename;
- let compositeFilename;
- let maskFilename;
- let baseNameNoExt;
-
- const mRgb = originalFilename.match(/^(.*)__rgbyp\.png$/i);
- if (mRgb) {
- baseFilename = mRgb[1];
- baseNameNoExt = stripExt(baseFilename);
- compositeFilename = originalFilename;
- } else {
- baseFilename = originalFilename;
- baseNameNoExt = stripExt(baseFilename);
- compositeFilename = baseFilename + "__rgbyp.png";
- }
- maskFilename = baseNameNoExt + "__rgbyp_mask.png";
-
- const imgType = imageWidget?.image_type || imageInfo?.type || "input";
- const imgSubfolder = imageWidget?.dir || imageInfo?.subfolder || "";
-
- // Картинка из самой ноды (любая нода, своя или чужая)
- function getNodeInputImage() {
- const filename = imageWidget?.value || imageInfo?.filename || "";
- const type = imageWidget?.image_type || "input"; // ← так
- const subfolder = imageWidget?.dir || "";
-
- const params = new URLSearchParams({ filename, type, subfolder });
- params.append("_t", Date.now()); // анти-кэш
-
- return `/view?${params.toString()}`;
- }
-
- // Ключ для запоминания, какая картинка была при сохранении маски
- function getNodeImageKey() {
- const filename = imageWidget?.value || imageInfo?.filename || "";
- const type = imageWidget?.image_type || imageInfo?.type || "";
- const subfolder = imageWidget?.dir || imageInfo?.subfolder || "";
- return `${type}|${subfolder}|${filename}`;
- }
-
- const currentImageKey = getNodeImageKey();
- const lastImageKey = node.properties?.rgbyp_last_image_key || null;
-
- const uniqueId = node.properties?.["unique_id"] || node.id || Date.now().toString();
- const tempMaskFilename = `RGBYP_${uniqueId}_mask.png`;
- const metaFilename = `RGBYP_${uniqueId}_meta.json`;
-
- const maskSrc = `/view?filename=${encodeURIComponent(
- tempMaskFilename
- )}&type=temp&_rgbyp=${Date.now()}`;
- let dynamicMaskSrc = maskSrc;
+ const map = {
+ Brush: state.brushBtn,
+ Erase: state.eraseBtn,
+ Scroll: state.scrollBtn,
+ };
+ Object.entries(map).forEach(([key, btn]) => {
+ if (!btn) return;
+ if (key === activeTool) {
+ btn.style.backgroundColor = "#666";
+ } else {
+ btn.style.backgroundColor = "#333";
+ }
+ });
+}
+/**
+ * UI for RGBYP Mask Editor (layout only, no image I/O).
+ * - Keeps big dialog with left tools, central canvas, right controls.
+ * - Does NOT load or save any images and does not draw.
+ */
+export function openMaskEditor(node) {
+ // overlay
const overlay = document.createElement("div");
overlay.style.position = "fixed";
overlay.style.left = "0";
overlay.style.top = "0";
overlay.style.width = "100vw";
overlay.style.height = "100vh";
- overlay.style.background = "rgba(0,0,0,0.7)";
+ overlay.style.background = "rgba(0, 0, 0, 0.75)";
overlay.style.zIndex = "9999";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
- overlay.style.fontFamily = "sans-serif";
+ overlay.style.fontFamily = "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
+ setNodeState(GP.baseNode.id, { overlayDialog: overlay });
- const windowDiv = document.createElement("div");
- windowDiv.style.position = "relative";
- windowDiv.style.display = "flex";
- windowDiv.style.background = "#222";
- windowDiv.style.borderRadius = "8px";
- windowDiv.style.boxShadow = "0 0 20px rgba(0,0,0,0.8)";
- windowDiv.style.padding = "10px";
- windowDiv.style.gap = "10px";
- windowDiv.style.width = "90vw";
- windowDiv.style.height = "90vh";
- windowDiv.style.maxWidth = "90vw";
- windowDiv.style.maxHeight = "90vh";
- windowDiv.style.boxSizing = "border-box";
- windowDiv.style.overflow = "hidden";
+ // dialog
+ const dialog = document.createElement("div");
+ dialog.style.position = "relative";
+ dialog.style.display = "flex";
+ dialog.style.flexDirection = "column";
+ dialog.style.boxSizing = "border-box";
+ dialog.style.background = "#222";
+ dialog.style.borderRadius = "8px";
+ dialog.style.boxShadow = "0 0 25px rgba(0,0,0,0.8)";
+ dialog.style.padding = "10px 12px 12px 12px";
- let keyHandler = null;
- let keyUpHandler = null;
- const resizeHandler = () => resizeCursorCanvasToMask();
+ const margin = 40;
+ const vw = window.innerWidth || 1280;
+ const vh = window.innerHeight || 720;
+ dialog.style.width = "calc(100vw - " + (margin * 2) + "px)";
+ dialog.style.height = "calc(100vh - " + (margin * 2) + "px)";
+ // header
+ const header = document.createElement("div");
+ header.style.display = "flex";
+ header.style.alignItems = "center";
+ header.style.justifyContent = "space-between";
+ header.style.marginBottom = "6px";
- const closeEditor = () => {
- if (keyHandler) window.removeEventListener("keydown", keyHandler);
- if (keyUpHandler) window.removeEventListener("keyup", keyUpHandler);
- window.removeEventListener("resize", resizeHandler);
- if (overlay.parentNode) {
- document.body.removeChild(overlay);
- }
- };
+ const title = document.createElement("div");
+ title.textContent = "RGBYP Mask Editor";
+ title.style.color = "#f0f0f0";
+ title.style.fontSize = "15px";
+ title.style.lineHeight = "24px";
+ title.style.fontWeight = "600";
- const closeBtn = document.createElement("div");
- closeBtn.textContent = "✕";
- closeBtn.style.position = "absolute";
- closeBtn.style.top = "4px";
- closeBtn.style.right = "8px";
- closeBtn.style.cursor = "pointer";
- closeBtn.style.color = "#fff";
- closeBtn.style.fontSize = "18px";
- closeBtn.style.zIndex = "2";
- closeBtn.addEventListener("click", () => {
- closeEditor();
- });
+ const headerRight = document.createElement("div");
+ headerRight.style.display = "flex";
+ headerRight.style.alignItems = "center";
+ headerRight.style.gap = "8px";
+ const helpIcon = document.createElement("div");
+ helpIcon.textContent = "?";
+ helpIcon.style.width = "48px";
+ helpIcon.style.textAlign = "center";
+ helpIcon.style.cursor = "pointer";
+ helpIcon.style.fontSize = "24px";
+ helpIcon.style.fontWeight = "bold";
+ helpIcon.style.color = "#fff";
+ helpIcon.style.borderRight = "1px solid #444";
+ helpIcon.dataset.tool = "Help";
+ setNodeState(GP.baseNode.id, { helpIcon: helpIcon });
+
+
+ const closeIcon = document.createElement("div");
+ closeIcon.textContent = "✕";
+ closeIcon.style.width = "48px";
+ closeIcon.style.textAlign = "center";
+ closeIcon.style.cursor = "pointer";
+ closeIcon.style.fontSize = "24px";
+ closeIcon.style.fontWeight = "bold";
+ closeIcon.style.color = "#fff";
+ closeIcon.dataset.tool = "Close";
+
+
+ headerRight.appendChild(helpIcon);
+ headerRight.appendChild(closeIcon);
+
+ header.appendChild(title);
+ header.appendChild(headerRight);
+
+ // body layout
+ const body = document.createElement("div");
+ body.style.flex = "1 1 auto";
+ body.style.display = "flex";
+ body.style.gap = "10px";
+ body.style.minHeight = "0"; // allow flex children to shrink
+
+ // left tools column
const toolsPanel = document.createElement("div");
toolsPanel.style.display = "flex";
toolsPanel.style.flexDirection = "column";
- toolsPanel.style.gap = "8px";
- toolsPanel.style.padding = "5px 5px";
- toolsPanel.style.background = "#333";
- toolsPanel.style.borderRadius = "6px";
- toolsPanel.style.alignItems = "center";
- toolsPanel.style.minWidth = "70px";
- toolsPanel.style.width = "70px";
- toolsPanel.style.maxWidth = "70px";
- toolsPanel.style.flex = "0 0 70px";
+ toolsPanel.style.alignItems = "stretch";
+ toolsPanel.style.gap = "6px";
+ toolsPanel.style.padding = "4px 4px 4px 0";
+ toolsPanel.style.width = "72px";
+ toolsPanel.style.flex = "0 0 72px";
- const toolsTitle = document.createElement("div");
- toolsTitle.textContent = "Tools";
- toolsTitle.style.color = "#ddd";
- toolsTitle.style.fontSize = "12px";
- toolsTitle.style.textAlign = "center";
- toolsTitle.style.marginBottom = "4px";
+ function makeIconButton(iconName, label, iconSize) {
+ const btn = document.createElement("button");
+ btn.style.padding = "4px 4px";
+ btn.style.borderRadius = "4px";
+ btn.style.border = "1px solid #555";
+ btn.style.background = "#333";
+ btn.style.color = "#eee";
+ btn.style.cursor = "pointer";
+ btn.style.boxSizing = "border-box";
+ btn.dataset.tool = label;
- const toolsSpacer = document.createElement("div");
- toolsSpacer.style.height = "100%";
+ const img = document.createElement("img");
+ img.src = extensionBaseUrl + iconName;
+ img.alt = label;
+ img.style.width = iconSize;
+ img.style.height = iconSize;
+ img.style.objectFit = "contain";
- const brushBtn = makeIconButton("/extensions/ComfyUI-RGBYP-Mask-Editor/i_brush.png", "Brush");
- const eraserBtn = makeIconButton("/extensions/ComfyUI-RGBYP-Mask-Editor/i_erase.png", "Eraser");
- const scrollBtn = makeIconButton("/extensions/ComfyUI-RGBYP-Mask-Editor/i_scroll.png", "Scroll");
- const clearBtn = makeIconButton("/extensions/ComfyUI-RGBYP-Mask-Editor/i_clear.png", "Clear");
+ btn.appendChild(img);
- let currentTool = "brush";
- let currentColor = Array.isArray(lastBrushColor) ? [...lastBrushColor] : [255, 0, 0];
-
- function updateToolButtons() {
- brushBtn.style.background = currentTool === "brush" ? "#777" : "#333";
- eraserBtn.style.background = currentTool === "eraser" ? "#777" : "#333";
- scrollBtn.style.background = currentTool === "scroll" ? "#777" : "#333";
-
- if ((currentTool === "scroll" || panMode) && !isPanningDrag && isPointerOverCanvas) {
- maskCanvas.style.cursor = "grab";
- } else if (!isPanningDrag) {
- maskCanvas.style.cursor = "none";
- }
+ return btn;
}
- brushBtn.addEventListener("click", () => {
- currentTool = "brush";
- panMode = false;
- updateToolButtons();
- });
- eraserBtn.addEventListener("click", () => {
- currentTool = "eraser";
- panMode = false;
- updateToolButtons();
- });
+ const brushBtn = makeIconButton("i_brush.png", "Brush", "64px");
+ const eraserBtn = makeIconButton("i_erase.png", "Eraser", "64px");
+ const scrollBtn = makeIconButton("i_scroll.png", "Scroll", "64px");
+ const clearBtn = makeIconButton("i_clear.png", "Clear", "64px");
- scrollBtn.addEventListener("click", () => {
- currentTool = "scroll";
- panMode = true;
- updateToolButtons();
- });
-
- toolsPanel.appendChild(toolsTitle);
toolsPanel.appendChild(brushBtn);
toolsPanel.appendChild(eraserBtn);
toolsPanel.appendChild(scrollBtn);
- toolsPanel.appendChild(toolsSpacer);
+ toolsPanel.appendChild(Object.assign(document.createElement("div"), { style: "height:100%;" }));
toolsPanel.appendChild(clearBtn);
+ // center canvas area
+ const centralPanel = document.createElement("div");
+ centralPanel.style.display = "flex";
+
+ centralPanel.style.alignItems = "center";
+ centralPanel.style.textAlign = "center";
+ // centralPanel.style.justifyContent = "center";
+ centralPanel.style.background = "#111";
+ centralPanel.style.borderRadius = "6px";
+ centralPanel.style.overflow = "scroll"; // scrollbars always visible, no UI jump
+ centralPanel.style.position = "relative";
+ centralPanel.style.height = "100%";
+ centralPanel.style.width = "100%";
+ centralPanel.style.backgroundColor = "#9f9f9fff";
+ setNodeState(GP.baseNode.id, { centralPanel: centralPanel });
+
const canvasContainer = document.createElement("div");
- canvasContainer.style.flex = "1 1 auto";
canvasContainer.style.display = "block";
- canvasContainer.style.alignItems = "center";
- canvasContainer.style.textAlign = "center";
- canvasContainer.style.justifyContent = "center";
- canvasContainer.style.background = "#111";
- canvasContainer.style.borderRadius = "6px";
- canvasContainer.style.overflow = "scroll"; // scrollbars always visible, no UI jump
canvasContainer.style.position = "relative";
- canvasContainer.style.height = "100%";
- canvasContainer.style.maxWidth = "100%";
- canvasContainer.style.maxHeight = "100%";
-
+ canvasContainer.style.flex = "0 0 auto";
+ canvasContainer.style.maxWidth = "none";
+ canvasContainer.style.maxHeight = "none";
+ canvasContainer.style.margin = "auto";
+ setNodeState(GP.baseNode.id, { canvasContainer: canvasContainer });
+
+
+ const originalCanvas = document.createElement("canvas");
+ originalCanvas.style.cursor = "none";
+ originalCanvas.style.display = "block";
+ // originalCanvas.style.position = "relative";
+ originalCanvas.style.position = "absolute";
+ originalCanvas.style.width = "100%";
+ originalCanvas.style.height = "100%";
+ originalCanvas.style.top = "0";
+ originalCanvas.style.left = "0";
+ setNodeState(GP.baseNode.id, { originalCanvas: originalCanvas });
+
const maskCanvas = document.createElement("canvas");
- const maskCtx = maskCanvas.getContext("2d");
- maskCanvas.style.cursor = "none";
maskCanvas.style.display = "block";
maskCanvas.style.position = "absolute";
- maskCanvas.style.left = "50%";
- maskCanvas.style.top = "50%";
- maskCanvas.style.transform = "translate(-50%, -50%)";
-
- const cursorCanvas = document.createElement("canvas");
- const cursorCtx = cursorCanvas.getContext("2d");
- cursorCanvas.style.display = "block";
- cursorCanvas.style.position = "absolute";
- cursorCanvas.style.left = "50%";
- cursorCanvas.style.top = "50%";
- cursorCanvas.style.transform = "translate(-50%, -50%)";
- // cursorCanvas.style.verticalAlign = "middle";
- cursorCanvas.style.pointerEvents = "none";
+ maskCanvas.style.width = "100%";
+ maskCanvas.style.height = "100%";
+ maskCanvas.style.top = "0";
+ maskCanvas.style.left = "0";
+ maskCanvas.style.pointerEvents = "auto";
+ setNodeState(GP.baseNode.id, { maskCanvas: maskCanvas });
+ canvasContainer.appendChild(originalCanvas);
canvasContainer.appendChild(maskCanvas);
- canvasContainer.appendChild(cursorCanvas);
- let currentZoom = 1;
- // позволяем уменьшать до 10% исходного размера
- const MIN_ZOOM = 0.1;
- const MAX_ZOOM = 3;
+ const drawCursor = document.createElement("div");
+ drawCursor.style.position = "absolute";
+ drawCursor.style.pointerEvents = "none";
+ drawCursor.style.border = "1px solid #ffffff";
+ drawCursor.style.outline = "2px solid #ff0000ff";
- let isPointerOverCanvas = false;
- let panMode = false;
- let spacePanActive = false;
- let isPanningDrag = false;
- let panStartX = 0;
- let panStartY = 0;
- let panScrollLeft = 0;
- let panScrollTop = 0;
+ drawCursor.style.borderRadius = "50%";
+ drawCursor.style.boxSizing = "border-box";
+ drawCursor.style.transform = "translate(-50%, -50%)";
+ drawCursor.style.zIndex = "10";
+ drawCursor.style.display = "none";
+ setNodeState(GP.baseNode.id, { drawCursor: drawCursor });
- updateToolButtons();
-
- canvasContainer.addEventListener("mouseenter", () => {
- isPointerOverCanvas = true;
- if (panMode && !isPanningDrag) {
- maskCanvas.style.cursor = "grab";
- }
- });
-
- canvasContainer.addEventListener("mouseleave", () => {
- isPointerOverCanvas = false;
- if (!isPanningDrag && currentTool !== "scroll") {
- panMode = false;
- maskCanvas.style.cursor = "none";
- }
- });
-
- canvasContainer.addEventListener(
- "wheel",
- (e) => {
- e.preventDefault();
- const rect = canvasContainer.getBoundingClientRect();
- let clientX = e.clientX;
- let clientY = e.clientY;
-
- const targetIsCanvas = e.target === maskCanvas || e.target === cursorCanvas;
- if (!targetIsCanvas) {
- clientX = rect.left + rect.width / 2;
- clientY = rect.top + rect.height / 2;
- // console.log("Wheel event not on canvas, using center point");
- }
-
- handleZoom(e.deltaY, clientX, clientY);
- },
- { passive: false }
- );
+ canvasContainer.appendChild(drawCursor);
+ centralPanel.appendChild(canvasContainer);
+ // right side panel
const rightPanel = document.createElement("div");
rightPanel.style.display = "flex";
rightPanel.style.flexDirection = "column";
rightPanel.style.gap = "8px";
- rightPanel.style.padding = "5px 8px";
- rightPanel.style.background = "#333";
- rightPanel.style.borderRadius = "6px";
- rightPanel.style.width = "185px";
- // rightPanel.style.minWidth = "220px";
- // rightPanel.style.maxWidth = "220px";
- // rightPanel.style.flex = "0 0 220px";
+ rightPanel.style.padding = "4px 0 4px 4px";
+ rightPanel.style.width = "190px";
+ rightPanel.style.flex = "0 0 190px";
+ rightPanel.style.borderLeft = "1px solid #333";
- const rightTitle = document.createElement("div");
- rightTitle.textContent = "RGBYP Mask";
- rightTitle.style.color = "#ddd";
- rightTitle.style.fontSize = "12px";
- rightTitle.style.textAlign = "center";
- rightTitle.style.marginBottom = "4px";
+ function makeSliderRow(labelText, min, max, value, suffix) {
+ const row = document.createElement("div");
+ row.style.display = "flex";
+ row.style.flexDirection = "column";
+ row.style.gap = "4px";
+ const label = document.createElement("div");
+ label.textContent = labelText;
+ label.style.fontSize = "16px";
+ label.style.color = "#ccc";
+
+ const inner = document.createElement("div");
+ inner.style.display = "flex";
+ inner.style.alignItems = "center";
+ inner.style.gap = "6px";
+
+ const input = document.createElement("input");
+ input.type = "range";
+ input.min = String(min);
+ input.max = String(max);
+ input.value = String(value);
+ input.style.flex = "1 1 auto";
+
+ const span = document.createElement("div");
+ span.textContent = value + (suffix || "");
+ span.style.fontSize = "11px";
+ span.style.color = "#ddd";
+ span.style.minWidth = "40px";
+
+ input.addEventListener("input", () => {
+ span.textContent = input.value + (suffix || "");
+ // stub, no logic
+ });
+
+ inner.appendChild(input);
+ inner.appendChild(span);
+ row.appendChild(label);
+ row.appendChild(inner);
+ return row;
+ }
+
+ rightPanel.appendChild(makeSliderRow("Brush size", 1, 200, 50, " px"));
+ rightPanel.appendChild(makeSliderRow("Opacity", 0, 100, 75, " %"));
+
+
+ // color buttons
const colorsLabel = document.createElement("div");
colorsLabel.textContent = "Colors";
+ colorsLabel.style.fontSize = "16px";
colorsLabel.style.color = "#ccc";
- colorsLabel.style.fontSize = "12px";
const colorsRow = document.createElement("div");
colorsRow.style.display = "flex";
+ colorsRow.style.flexDirection = "row";
colorsRow.style.flexWrap = "wrap";
colorsRow.style.gap = "4px";
+ colorsRow.style.justifyContent = "space-between";
- const colorList = [
- { name: "R", color: [255, 0, 0] },
- { name: "G", color: [0, 255, 0] },
- { name: "B", color: [0, 0, 255] },
- { name: "Y", color: [255, 255, 0] },
- { name: "P", color: [255, 0, 255] },
- ];
+ function makeColorButton(cssColor) {
+ const btn = document.createElement("button");
+ btn.style.width = "32px";
+ btn.style.height = "32px";
+ btn.style.borderRadius = "4px";
+ btn.style.border = "1px solid #444";
+ btn.style.background = cssColor;
+ btn.style.color = "#000";
+ btn.style.fontWeight = "600";
+ btn.style.cursor = "pointer";
+ btn.style.fontSize = "11px";
+ btn.style.opacity = "0.6";
+ btn.dataset.tool = "ColorButton" + cssColor;
- function rgbToCss(c) {
- return `rgb(${c[0]}, ${c[1]}, ${c[2]})`;
+ return btn;
}
- function colorsEqual(a, b) {
- return (
- Array.isArray(a) &&
- Array.isArray(b) &&
- a[0] === b[0] &&
- a[1] === b[1] &&
- a[2] === b[2]
- );
- }
-
- const colorSwatches = [];
-
- colorList.forEach((c) => {
- const sw = document.createElement("div");
- sw.style.width = "30px";
- sw.style.height = "30px";
- sw.style.borderRadius = "4px";
- sw.style.background = rgbToCss(c.color);
- sw.style.cursor = "pointer";
- sw.style.border = colorsEqual(c.color, currentColor)
- ? "2px solid #000"
- : "2px solid transparent";
- sw.title = c.name;
-
- sw.addEventListener("click", () => {
- currentColor = c.color;
- lastBrushColor = c.color;
- colorSwatches.forEach((s) => (s.style.border = "2px solid transparent"));
- sw.style.border = "2px solid #000";
- });
-
- colorSwatches.push(sw);
- colorsRow.appendChild(sw);
+ colorListWEB.forEach((c) => {
+ colorsRow.appendChild(makeColorButton(c.color));
});
- const sizeLabel = document.createElement("div");
- sizeLabel.textContent = "Brush size";
- sizeLabel.style.color = "#ccc";
- sizeLabel.style.fontSize = "12px";
+ setNodeState(GP.baseNode.id, { colorButtons: [...colorsRow.children] });
+ updateSelectedColorUI(getNodeState(GP.baseNode.id).drawColor);
- const sizeRow = document.createElement("div");
- sizeRow.style.display = "flex";
- sizeRow.style.alignItems = "center";
- sizeRow.style.gap = "6px";
+ const autoMaskButtonsRow = document.createElement("div");
+ autoMaskButtonsRow.style.display = "flex";
+ autoMaskButtonsRow.style.flexDirection = "row";
+ autoMaskButtonsRow.style.gap = "4px";
+ autoMaskButtonsRow.style.marginTop = "4px";
+ autoMaskButtonsRow.style.justifyContent = "space-between";
- const sizeInput = document.createElement("input");
- sizeInput.type = "range";
- sizeInput.min = "1";
- sizeInput.max = "300";
- sizeInput.value = String(lastBrushSize || 50);
+ const autoHalfBtn = makeIconButton("i_d_11.png", "Half", "32px");
+ const auto1to2Btn = makeIconButton("i_d_12.png", "1 to 2", "32px");
+ const auto2to1Btn = makeIconButton("i_d_21.png", "2 to 1", "32px");
+ const autoThirdsBtn = makeIconButton("i_d_111.png", "Thirds", "32px");
- const sizeValue = document.createElement("div");
- sizeValue.textContent = sizeInput.value;
- sizeValue.style.color = "#ddd";
- sizeValue.style.fontSize = "12px";
- sizeValue.style.minWidth = "24px";
+ autoMaskButtonsRow.appendChild(autoHalfBtn);
+ autoMaskButtonsRow.appendChild(auto1to2Btn);
+ autoMaskButtonsRow.appendChild(auto2to1Btn);
+ autoMaskButtonsRow.appendChild(autoThirdsBtn);
- sizeInput.addEventListener("input", () => {
- sizeValue.textContent = sizeInput.value;
- lastBrushSize = parseFloat(sizeInput.value) || 50;
- });
-
- const opacityLabel = document.createElement("div");
- opacityLabel.textContent = "Opacity";
- opacityLabel.style.color = "#ccc";
- opacityLabel.style.fontSize = "12px";
-
- const opacityRow = document.createElement("div");
- opacityRow.style.display = "flex";
- opacityRow.style.alignItems = "center";
- opacityRow.style.gap = "6px";
-
- const opacityInput = document.createElement("input");
- opacityInput.type = "range";
- opacityInput.min = "1";
- opacityInput.max = "100";
- opacityInput.value = String(lastMaskOpacity || 100);
-
- const opacityValue = document.createElement("div");
- opacityValue.textContent = opacityInput.value;
- opacityValue.style.color = "#ddd";
- opacityValue.style.fontSize = "12px";
- opacityValue.style.minWidth = "24px";
-
- opacityInput.addEventListener("input", () => {
- opacityValue.textContent = opacityInput.value;
- lastMaskOpacity = parseFloat(opacityInput.value) || 100;
- redraw();
- });
-
- sizeRow.appendChild(sizeInput);
- sizeRow.appendChild(sizeValue);
-
- opacityRow.appendChild(opacityInput);
- opacityRow.appendChild(opacityValue);
-
- const autoButtonsRow = document.createElement("div");
- autoButtonsRow.style.display = "flex";
- autoButtonsRow.style.flexDirection = "row";
- autoButtonsRow.style.gap = "4px";
- autoButtonsRow.style.marginTop = "4px";
- autoButtonsRow.style.justifyContent = "space-between";
-
- const autoHalfBtn = makeIconButton(
- "/extensions/ComfyUI-RGBYP-Mask-Editor/i_d_11.png",
- "autoMaskSplitHalf"
- );
- const auto1to2Btn = makeIconButton(
- "/extensions/ComfyUI-RGBYP-Mask-Editor/i_d_12.png",
- "autoMaskSplit1to2"
- );
- const auto2to1Btn = makeIconButton(
- "/extensions/ComfyUI-RGBYP-Mask-Editor/i_d_21.png",
- "autoMaskSplit2to1"
- );
- const autoThirdsBtn = makeIconButton(
- "/extensions/ComfyUI-RGBYP-Mask-Editor/i_d_111.png",
- "autoMaskSplitThirds"
- );
-
- autoHalfBtn.style.width = "32px";
- autoHalfBtn.style.height = "32px";
- auto1to2Btn.style.width = "32px";
- auto1to2Btn.style.height = "32px";
- auto2to1Btn.style.width = "32px";
- auto2to1Btn.style.height = "32px";
- autoThirdsBtn.style.width = "32px";
- autoThirdsBtn.style.height = "32px";
-
- autoButtonsRow.appendChild(autoHalfBtn);
- autoButtonsRow.appendChild(auto1to2Btn);
- autoButtonsRow.appendChild(auto2to1Btn);
- autoButtonsRow.appendChild(autoThirdsBtn);
-
- const rightPanelSpacer = document.createElement("div");
- rightPanelSpacer.style.height = "100%";
-
- const saveBtn = document.createElement("button");
- saveBtn.textContent = "SAVE";
- saveBtn.style.marginTop = "8px";
- saveBtn.style.padding = "6px";
- saveBtn.style.cursor = "pointer";
- saveBtn.style.borderRadius = "4px";
- saveBtn.style.border = "1px solid #88ff88";
- saveBtn.style.background = "#2a4";
- saveBtn.style.color = "#fff";
- saveBtn.style.fontWeight = "bold";
- saveBtn.style.fontSize = "13px";
-
- const rightPanelSpacerSmall1 = document.createElement("div");
- rightPanelSpacerSmall1.style.height = "20px";
- const rightPanelSpacerSmall2 = document.createElement("div");
- rightPanelSpacerSmall2.style.height = "20px";
-
- rightPanel.appendChild(rightTitle);
+ rightPanel.appendChild(Object.assign(document.createElement("div"), { style: "height:16px;" }));
rightPanel.appendChild(colorsLabel);
rightPanel.appendChild(colorsRow);
- rightPanel.appendChild(rightPanelSpacerSmall1);
- rightPanel.appendChild(sizeLabel);
- rightPanel.appendChild(sizeRow);
- rightPanel.appendChild(opacityLabel);
- rightPanel.appendChild(opacityRow);
- rightPanel.appendChild(rightPanelSpacerSmall2);
- rightPanel.appendChild(autoButtonsRow);
- rightPanel.appendChild(rightPanelSpacer);
+ rightPanel.appendChild(Object.assign(document.createElement("div"), { style: "height:16px;" }));
+ rightPanel.appendChild(autoMaskButtonsRow);
+
+ function makeTextButton(label, primary) {
+ const btn = document.createElement("button");
+ btn.textContent = label;
+ btn.style.padding = "5px 14px";
+ btn.style.borderRadius = "4px";
+ btn.style.border = "1px solid " + (primary ? "#5a9" : "#555");
+ btn.style.background = primary ? "#3b7" : "#333";
+ btn.style.color = "#fff";
+ btn.style.cursor = "pointer";
+ btn.style.fontSize = "16px";
+ btn.style.fontWeight = "bold";
+ btn.dataset.tool = label;
+ return btn;
+ }
+
+ const saveBtn = makeTextButton("Save", true);
+
+ rightPanel.appendChild(Object.assign(document.createElement("div"), { style: "height:100%;" }));
rightPanel.appendChild(saveBtn);
- windowDiv.appendChild(closeBtn);
- windowDiv.appendChild(toolsPanel);
- windowDiv.appendChild(canvasContainer);
- windowDiv.appendChild(rightPanel);
+ const helpPanel = document.createElement("div");
+ helpPanel.style.position = "absolute";
+ helpPanel.style.left = "0";
+ helpPanel.style.right = "0";
+ helpPanel.style.bottom = "0";
+ helpPanel.style.zIndex = "10";
+ const headerHeight = header.getBoundingClientRect().height;
+ helpPanel.style.top = headerHeight + "px";
- overlay.appendChild(windowDiv);
+ // перекрываем только область под хедером
+ helpPanel.style.display = "none"; // по умолчанию скрыта
+ helpPanel.style.backgroundColor = "#333";
+ helpPanel.style.boxSizing = "border-box";
+ helpPanel.style.padding = "20px 40px";
+ helpPanel.style.overflow = "auto";
+ helpPanel.style.color = "#fff";
+ helpPanel.style.flexDirection = "column";
+
+ helpPanel.innerHTML = HELP;
+ setNodeState(GP.baseNode.id, { helpPanel: helpPanel });
+
+
+ // нижняя панель с кнопкой Close
+ const helpFooter = document.createElement("div");
+ helpFooter.style.display = "flex";
+ helpFooter.style.justifyContent = "flex-end";
+ helpFooter.style.marginTop = "16px";
+
+ const helpCloseBtn = makeTextButton("Close", true); // такой же зелёный, как Save
+ setNodeState(GP.baseNode.id, { helpCloseBtn: helpCloseBtn });
+
+ helpFooter.appendChild(helpCloseBtn);
+ helpPanel.appendChild(helpFooter);
+
+
+
+ // assemble
+ body.appendChild(toolsPanel);
+ body.appendChild(centralPanel);
+ body.appendChild(rightPanel);
+ // body.appendChild(helpPanel);
+
+ dialog.appendChild(header);
+ dialog.appendChild(body);
+ dialog.appendChild(helpPanel);
+
+ overlay.appendChild(dialog);
document.body.appendChild(overlay);
- const baseImg = new Image();
- baseImg.crossOrigin = "anonymous";
+ setNodeState(GP.baseNode.id, { baseImg: new Image() });
+ setNodeState(GP.baseNode.id, { maskImg: new Image() });
- const maskImg = new Image();
- maskImg.crossOrigin = "anonymous";
+ initBaseImageAndCanvas();
- let maskBuffer = document.createElement("canvas");
- let maskBufferCtx = maskBuffer.getContext("2d");
+ // hotkeys
+ registerKeyHandlers(dialog);
- function clearMaskBuffer(redrawAfter = true) {
- if (maskBufferCtx && maskBuffer) {
- maskBufferCtx.clearRect(0, 0, maskBuffer.width, maskBuffer.height);
- if (redrawAfter) redraw();
+ function closeEditor() {
+ unregisterKeyHandlers(dialog);
+ if (overlay.parentNode) {
+ overlay.parentNode.removeChild(overlay);
}
}
- function autoMaskSplitHalf() {
- if (!maskBuffer || !maskBufferCtx) return;
- const w = maskBuffer.width;
- const h = maskBuffer.height;
- if (!w || !h) return;
- clearMaskBuffer(false);
-
- const half = w / 2;
- maskBufferCtx.fillStyle = rgbToCss([255, 0, 0]);
- maskBufferCtx.fillRect(0, 0, half, h);
- maskBufferCtx.fillStyle = rgbToCss([0, 255, 0]);
- maskBufferCtx.fillRect(half, 0, w - half, h);
-
- redraw();
- }
-
- function autoMaskSplit1to2() {
- if (!maskBuffer || !maskBufferCtx) return;
- const w = maskBuffer.width;
- const h = maskBuffer.height;
- if (!w || !h) return;
-
- clearMaskBuffer(false);
-
- const third = w / 3;
-
- maskBufferCtx.fillStyle = rgbToCss([255, 0, 0]);
- maskBufferCtx.fillRect(0, 0, third, h);
- maskBufferCtx.fillStyle = rgbToCss([0, 255, 0]);
- maskBufferCtx.fillRect(third, 0, w - third, h);
-
- redraw();
- }
-
- function autoMaskSplit2to1() {
- if (!maskBuffer || !maskBufferCtx) return;
- const w = maskBuffer.width;
- const h = maskBuffer.height;
- if (!w || !h) return;
-
- clearMaskBuffer(false);
-
- const third = w / 3;
- const twoThirds = 2 * third;
-
- maskBufferCtx.fillStyle = rgbToCss([255, 0, 0]);
- maskBufferCtx.fillRect(0, 0, twoThirds, h);
- maskBufferCtx.fillStyle = rgbToCss([0, 255, 0]);
- maskBufferCtx.fillRect(twoThirds, 0, w - twoThirds, h);
-
- redraw();
- }
-
- function autoMaskSplitThirds() {
- if (!maskBuffer || !maskBufferCtx) return;
- const w = maskBuffer.width;
- const h = maskBuffer.height;
- if (!w || !h) return;
-
- clearMaskBuffer(false);
-
- const third = w / 3;
- const twoThirds = 2 * third;
-
- maskBufferCtx.fillStyle = rgbToCss([255, 0, 0]);
- maskBufferCtx.fillRect(0, 0, third, h);
- maskBufferCtx.fillStyle = rgbToCss([0, 255, 0]);
- maskBufferCtx.fillRect(third, 0, third, h);
- maskBufferCtx.fillStyle = rgbToCss([0, 0, 255]);
- maskBufferCtx.fillRect(twoThirds, 0, w - twoThirds, h);
-
- redraw();
- }
-
- autoHalfBtn.addEventListener("click", () => {
- autoMaskSplitHalf();
- });
- auto1to2Btn.addEventListener("click", () => {
- autoMaskSplit1to2();
- });
- auto2to1Btn.addEventListener("click", () => {
- autoMaskSplit2to1();
- });
- autoThirdsBtn.addEventListener("click", () => {
- autoMaskSplitThirds();
- });
-
- function resizeCursorCanvasToMask() {
- if (!maskCanvas || !cursorCanvas) return;
-
- // Берём реальный размер маски на экране
- const rect = maskCanvas.getBoundingClientRect();
-
- // Внутреннее разрешение курсор-канваса = видимому размеру
- cursorCanvas.width = rect.width;
- cursorCanvas.height = rect.height;
- cursorCanvas.style.width = rect.width + "px";
- cursorCanvas.style.height = rect.height + "px";
-
- // Позиция и transform такие же, как у maskCanvas
- cursorCanvas.style.left = maskCanvas.style.left;
- cursorCanvas.style.top = maskCanvas.style.top;
- cursorCanvas.style.transform = maskCanvas.style.transform;
- }
-
-function handleZoom(deltaY, clientX, clientY) {
- if (!baseImg.width || !baseImg.height) return;
-
- const factor = deltaY < 0 ? 1.1 : 0.9;
- let newZoom = currentZoom * factor;
-
- if (newZoom < MIN_ZOOM) newZoom = MIN_ZOOM;
- if (newZoom > MAX_ZOOM) newZoom = MAX_ZOOM;
- if (newZoom === currentZoom) return;
-
- const canvasRect = maskCanvas.getBoundingClientRect();
- const oldW = canvasRect.width;
- const oldH = canvasRect.height;
-
- if (!oldW || !oldH) return;
-
- // относительная позиция точки зума внутри канваса (0..1)
- const relX = (clientX - canvasRect.left) / oldW;
- const relY = (clientY - canvasRect.top) / oldH;
-
- const oldZoom = currentZoom;
- currentZoom = newZoom;
-
- const displayW = baseImg.width * currentZoom;
- const displayH = baseImg.height * currentZoom;
-
- // меняем размер канваса (зум)
- maskCanvas.style.width = displayW + "px";
- maskCanvas.style.height = displayH + "px";
-
- // разница размеров до/после зума
- const deltaW = displayW - oldW;
- const deltaH = displayH - oldH;
-
- // текущий скролл
- const oldScrollLeft = canvasContainer.scrollLeft;
- const oldScrollTop = canvasContainer.scrollTop;
-
- // компенсируем изменение размеров так,
- // чтобы точка с relX/relY осталась под той же экранной позицией
- let newScrollLeft = oldScrollLeft + deltaW * (relX - 0.5);
- let newScrollTop = oldScrollTop + deltaH * (relY - 0.5);
-
- const maxScrollX = canvasContainer.scrollWidth - canvasContainer.clientWidth;
- const maxScrollY = canvasContainer.scrollHeight - canvasContainer.clientHeight;
-
- if (!Number.isFinite(newScrollLeft)) newScrollLeft = 0;
- if (!Number.isFinite(newScrollTop)) newScrollTop = 0;
-
- canvasContainer.scrollLeft = Math.max(0, Math.min(maxScrollX, newScrollLeft));
- canvasContainer.scrollTop = Math.max(0, Math.min(maxScrollY, newScrollTop));
-
- resizeCursorCanvasToMask();
-}
-
- function redraw() {
- if (!maskCtx || !maskCanvas) return;
-
- maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
-
- if (baseImg && baseImg.width && baseImg.height) {
- maskCtx.drawImage(baseImg, 0, 0, maskCanvas.width, maskCanvas.height);
- }
-
- if (maskBuffer) {
- maskCtx.save();
- const alpha = (lastMaskOpacity || 100) / 100.0;
- maskCtx.globalAlpha = alpha;
- maskCtx.drawImage(maskBuffer, 0, 0, maskCanvas.width, maskCanvas.height);
- maskCtx.restore();
- }
- }
-
- baseImg.onload = () => {
- const w = baseImg.width;
- const h = baseImg.height;
-
- maskCanvas.width = w;
- maskCanvas.height = h;
- maskBuffer.width = w;
- maskBuffer.height = h;
- maskBufferCtx.clearRect(0, 0, w, h);
-
- // автофит картинки в видимую область контейнера
- const containerRect = canvasContainer.getBoundingClientRect();
- let fitZoom = 1;
-
- if (containerRect.width > 0 && containerRect.height > 0) {
- const scaleX = containerRect.width / w;
- const scaleY = containerRect.height / h;
- fitZoom = Math.min(scaleX, scaleY);
- }
-
- currentZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, fitZoom));
-
- const displayW = w * currentZoom;
- const displayH = h * currentZoom;
-
- maskCanvas.style.width = displayW + "px";
- maskCanvas.style.height = displayH + "px";
- maskCanvas.style.maxWidth = "none";
- maskCanvas.style.maxHeight = "none";
-
- const cw = containerRect.width;
- const ch = containerRect.height;
- canvasContainer.scrollLeft = Math.max(0, (displayW - cw) / 2);
- canvasContainer.scrollTop = Math.max(0, (displayH - ch) / 2);
-
- resizeCursorCanvasToMask();
- setTimeout(() => {
- redraw();
- }, 0);
-
- if (dynamicMaskSrc) { maskImg.src = dynamicMaskSrc; } else { clearMaskBuffer(true); }
- };
- baseImg.onerror = () => { };
-
- maskImg.onload = () => {
- try {
- maskBufferCtx.clearRect(0, 0, maskBuffer.width, maskBuffer.height);
- maskBufferCtx.drawImage(maskImg, 0, 0, maskBuffer.width, maskBuffer.height);
- redraw();
- } catch (e) { }
- };
-
- maskImg.onerror = () => { };
-
-async function resolveBaseSrcFromMeta() {
- try {
- // Запрос meta JSON из temp
- const url = `/view?filename=${encodeURIComponent(
- metaFilename
- )}&type=temp&_t=${Date.now()}`;
-
- const resp = await api.fetchApi(url, { method: "GET" });
-
- // Если meta ещё не существует → используем картинку из ноды
- if (!resp.ok) {
- dynamicMaskSrc = "";
- return getNodeInputImage();
- }
-
- const text = await resp.text();
- let meta;
-
- try {
- meta = JSON.parse(text);
- } catch (e) {
- // битый JSON → fallback
- dynamicMaskSrc = "";
- return getNodeInputImage();
- }
-
- //
- // 1️⃣ Проверка соответствия маски текущей картинке
- //
- const metaGraphImage = meta?.graph_image || "";
- const currentGraphImage = getGraphImageKey();
-
- if (
- metaGraphImage &&
- currentGraphImage &&
- metaGraphImage !== currentGraphImage
- ) {
- // Маска относится к другой картинке → игнорируем её
- dynamicMaskSrc = "";
- return getNodeInputImage();
- }
-
- //
- // 2️⃣ Загружаем ORIGINAL
- //
- const originalPath = meta?.original || "";
- if (originalPath) {
- const parts = originalPath.split(/[\\/]/);
- const originalName = parts[parts.length - 1];
-
- const params = new URLSearchParams({
- filename: originalName,
- type: "temp",
- subfolder: "",
- });
- params.append("_rgbyp", Date.now());
-
- return `/view?${params.toString()}`;
- }
-
- //
- // 3️⃣ Нет original → fallback к картинке из ноды
- //
- return getNodeInputImage();
-
- } catch (e) {
- // На любой ошибке — fallback
- dynamicMaskSrc = "";
- return getNodeInputImage();
- }
-}
-
- resolveBaseSrcFromMeta().then((src) => {
- baseImg.src = src;
- });
-
-
- clearBtn.addEventListener("click", () => {
- clearMaskBuffer(true);
- });
-
- let drawing = false;
- let lastX = 0;
- let lastY = 0;
- let tempTool = null;
-
- let cursorLastScreenX = null;
- let cursorLastScreenY = null;
-
- function getCanvasPos(evt) {
- const rect = maskCanvas.getBoundingClientRect();
- const x = ((evt.clientX - rect.left) / rect.width) * maskCanvas.width;
- const y = ((evt.clientY - rect.top) / rect.height) * maskCanvas.height;
- return { x, y };
- }
-
- function clearCursor() {
- cursorCtx.clearRect(0, 0, cursorCanvas.width, cursorCanvas.height);
- cursorLastScreenX = null;
- cursorLastScreenY = null;
- }
-
- function drawCursor(arg) {
- if (!maskCanvas || !cursorCanvas) return;
- const rect = maskCanvas.getBoundingClientRect();
- if (!rect.width || !rect.height) return;
-
- let sx, sy;
- if (arg && typeof arg.clientX === "number") {
- sx = arg.clientX - rect.left;
- sy = arg.clientY - rect.top;
- } else if (arg && typeof arg.x === "number") {
- sx = arg.x;
- sy = arg.y;
- } else {
- return;
- }
-
- cursorLastScreenX = sx;
- cursorLastScreenY = sy;
-
- const brushSize = parseFloat(sizeInput.value) || 10;
- const scale = rect.width / maskCanvas.width;
- const radius = (brushSize / 2) * scale;
-
- cursorCtx.clearRect(0, 0, cursorCanvas.width, cursorCanvas.height);
- cursorCtx.beginPath();
- cursorCtx.arc(sx, sy, radius, 0, Math.PI * 2);
- cursorCtx.strokeStyle = "#ffffff";
- cursorCtx.lineWidth = 1;
- cursorCtx.setLineDash([4, 4]);
- cursorCtx.stroke();
- }
-
- function startDraw(evt) {
- if (evt.button !== 0 && evt.button !== 2) {
- return;
- }
-
- if (panMode && evt.button === 0) {
- isPanningDrag = true;
- panStartX = evt.clientX;
- panStartY = evt.clientY;
- panScrollLeft = canvasContainer.scrollLeft;
- panScrollTop = canvasContainer.scrollTop;
- maskCanvas.style.cursor = "grabbing";
- return;
- }
-
- if (evt.button === 2) {
- evt.preventDefault();
- tempTool = currentTool;
- currentTool = "eraser";
- updateToolButtons();
- const restoreOnce = () => {
- if (tempTool !== null) {
- currentTool = tempTool;
- tempTool = null;
- updateToolButtons();
- }
- window.removeEventListener("mouseup", restoreOnce);
- };
- window.addEventListener("mouseup", restoreOnce);
- }
-
- drawing = true;
- const pos = getCanvasPos(evt);
- lastX = pos.x;
- lastY = pos.y;
- drawLine(pos.x, pos.y, true);
- drawCursor(evt);
- }
-
- function stopDraw() {
- if (isPanningDrag) {
- isPanningDrag = false;
- maskCanvas.style.cursor = panMode && isPointerOverCanvas ? "grab" : "none";
- return;
- }
- drawing = false;
- }
-
- function drawMove(evt) {
- drawCursor(evt);
-
- if (isPanningDrag) {
- const dx = evt.clientX - panStartX;
- const dy = evt.clientY - panStartY;
- canvasContainer.scrollLeft = panScrollLeft - dx;
- canvasContainer.scrollTop = panScrollTop - dy;
- resizeCursorCanvasToMask();
- return;
- }
-
- if (!drawing) return;
- const pos = getCanvasPos(evt);
- drawLine(pos.x, pos.y, false);
- lastX = pos.x;
- lastY = pos.y;
- }
-
- function drawLine(x, y, dotOnly) {
- const size = parseFloat(sizeInput.value) || 10;
-
- if (currentTool === "eraser") {
- maskBufferCtx.save();
- maskBufferCtx.globalCompositeOperation = "destination-out";
- maskBufferCtx.beginPath();
- if (dotOnly) {
- maskBufferCtx.arc(x, y, size / 2, 0, Math.PI * 2);
- maskBufferCtx.fill();
- } else {
- maskBufferCtx.lineCap = "round";
- maskBufferCtx.lineJoin = "round";
- maskBufferCtx.lineWidth = size;
- maskBufferCtx.moveTo(lastX, lastY);
- maskBufferCtx.lineTo(x, y);
- maskBufferCtx.stroke();
- }
- maskBufferCtx.restore();
- } else {
- maskBufferCtx.save();
- maskBufferCtx.globalCompositeOperation = "source-over";
- maskBufferCtx.strokeStyle = rgbToCss(currentColor);
- maskBufferCtx.fillStyle = rgbToCss(currentColor);
- if (dotOnly) {
- maskBufferCtx.beginPath();
- maskBufferCtx.arc(x, y, size / 2, 0, Math.PI * 2);
- maskBufferCtx.fill();
- } else {
- maskBufferCtx.beginPath();
- maskBufferCtx.lineCap = "round";
- maskBufferCtx.lineJoin = "round";
- maskBufferCtx.lineWidth = size;
- maskBufferCtx.moveTo(lastX, lastY);
- maskBufferCtx.lineTo(x, y);
- maskBufferCtx.stroke();
- }
- maskBufferCtx.restore();
- }
-
- redraw();
- }
-
- maskCanvas.addEventListener("mousedown", startDraw);
- maskCanvas.addEventListener("mousemove", drawMove);
- maskCanvas.addEventListener("mouseup", stopDraw);
- maskCanvas.addEventListener("mouseleave", () => {
- stopDraw();
- clearCursor();
- });
-
- maskCanvas.addEventListener("contextmenu", (e) => {
- e.preventDefault();
- });
-
- maskCanvas.addEventListener(
- "touchstart",
- (e) => {
- e.preventDefault();
- const t = e.touches[0];
- resizeCursorCanvasToMask();
- startDraw(t);
- },
- { passive: false }
- );
-
- maskCanvas.addEventListener(
- "touchmove",
- (e) => {
- e.preventDefault();
- const t = e.touches[0];
- drawMove(t);
- },
- { passive: false }
- );
-
- maskCanvas.addEventListener(
- "touchend",
- (e) => {
- e.preventDefault();
- stopDraw();
- clearCursor();
- },
- { passive: false }
- );
-
- keyHandler = (e) => {
- if (e.code === "Space") {
- if (isPointerOverCanvas) {
- spacePanActive = true;
- panMode = true;
- if (!isPanningDrag) {
- maskCanvas.style.cursor = "grab";
- }
- }
- e.preventDefault();
- return;
- }
- if (e.code === "KeyZ" && e.shiftKey) {
- const rect = canvasContainer.getBoundingClientRect();
- const cx = rect.left + rect.width / 2;
- const cy = rect.top + rect.height / 2;
- handleZoom(100, cx, cy);
- e.preventDefault();
- return;
- }
- if (e.code === "KeyX" && e.shiftKey) {
- const rect = canvasContainer.getBoundingClientRect();
- const cx = rect.left + rect.width / 2;
- const cy = rect.top + rect.height / 2;
- handleZoom(-100, cx, cy);
- e.preventDefault();
- return;
- }
-
- if (e.key >= "1" && e.key <= "5") {
- const idx = parseInt(e.key, 10) - 1;
- if (colorList[idx]) {
- currentColor = colorList[idx].color;
- lastBrushColor = colorList[idx].color;
- colorSwatches.forEach((s, i) => {
- s.style.border = i === idx ? "2px solid #000" : "2px solid transparent";
- });
- e.preventDefault();
- }
- return;
- }
-
- if (e.key === "Escape") {
+ closeIcon.addEventListener("click", closeEditor);
+ overlay.addEventListener("click", (e) => {
+ if (e.target === overlay) {
closeEditor();
- e.preventDefault();
- return;
}
-
- if (e.key === "Enter") {
- saveBtn.click();
- e.preventDefault();
- return;
- }
-
- if (e.code === "KeyN" && e.shiftKey) {
- clearMaskBuffer(true);
- e.preventDefault();
- return;
- }
-
- if (e.code === "KeyA" && e.shiftKey) {
- let v = parseFloat(sizeInput.value) || 1;
- let step;
- if (v <= 20) step = 1;
- else if (v <= 100) step = 3;
- else step = 5;
- v = Math.max(1, v - step);
- sizeInput.value = String(v);
- sizeValue.textContent = sizeInput.value;
- lastBrushSize = v;
-
- if (cursorLastScreenX !== null && cursorLastScreenY !== null) {
- drawCursor({ x: cursorLastScreenX, y: cursorLastScreenY });
- }
-
- e.preventDefault();
- return;
- }
-
- if (e.code === "KeyD" && e.shiftKey) {
- let v = parseFloat(sizeInput.value) || 1;
- let step;
- if (v <= 20) step = 1;
- else if (v <= 100) step = 3;
- else step = 5;
- v = Math.max(1, v + step);
- sizeInput.value = String(v);
- sizeValue.textContent = sizeInput.value;
- lastBrushSize = v;
-
- if (cursorLastScreenX !== null && cursorLastScreenY !== null) {
- drawCursor({ x: cursorLastScreenX, y: cursorLastScreenY });
- }
-
- e.preventDefault();
- return;
- }
-
- if (e.code === "KeyW" && e.shiftKey) {
- let v = parseFloat(opacityInput.value) || 1;
- v = Math.min(100, v + 5);
- opacityInput.value = String(v);
- opacityValue.textContent = opacityInput.value;
- lastMaskOpacity = v;
- redraw();
- e.preventDefault();
- return;
- }
-
- if (e.code === "KeyS" && e.shiftKey) {
- let v = parseFloat(opacityInput.value) || 1;
- v = Math.max(1, v - 5);
- opacityInput.value = String(v);
- opacityValue.textContent = opacityInput.value;
- lastMaskOpacity = v;
- redraw();
- e.preventDefault();
- return;
- }
- };
-
- keyUpHandler = (e) => {
- if (e.code === "Space") {
- spacePanActive = false;
- panMode = false;
-
- if (!isPanningDrag) {
- maskCanvas.style.cursor = "none";
- }
-
- updateToolButtons();
- }
- };
-
- window.addEventListener("keydown", keyHandler);
- window.addEventListener("keyup", keyUpHandler);
- window.addEventListener("resize", resizeHandler);
-
- saveBtn.addEventListener("click", async () => {
- if (!baseImg.width || !baseImg.height) {
- alert("Base image has not been loaded yet.");
- return;
- }
-
- const uniqueId = node.properties?.["unique_id"] || node.id || Date.now().toString();
- const prefix = `RGBYP_${uniqueId}_`;
- const tempType = "temp";
-
- const compositeFilename = `${prefix}composite.png`;
- const maskFilename2 = `${prefix}mask.png`;
-
- const finalCanvas = document.createElement("canvas");
- finalCanvas.width = baseImg.width;
- finalCanvas.height = baseImg.height;
- const finalCtx = finalCanvas.getContext("2d");
- finalCtx.drawImage(baseImg, 0, 0);
-
- const compositeAlpha = (lastMaskOpacity || 100) / 100.0;
- finalCtx.save();
- finalCtx.globalAlpha = compositeAlpha;
- finalCtx.drawImage(maskBuffer, 0, 0);
- finalCtx.restore();
- // finalCtx.drawImage(maskBuffer, 0, 0);
-
- const compositeDataUrl = finalCanvas.toDataURL("image/png");
- const maskDataUrl = maskBuffer.toDataURL("image/png");
-
- let uploadedCompositeName = compositeFilename;
-
- try {
- const compFile = dataURLtoFile(compositeDataUrl, compositeFilename);
- const compForm = new FormData();
- compForm.append("image", compFile);
- compForm.append("type", tempType);
- compForm.append("overwrite", "true");
-
- const compResp = await api.fetchApi("/upload/image", {
- method: "POST",
- body: compForm,
- });
-
- if (compResp.ok) {
- const cj = await compResp.json();
- uploadedCompositeName = cj.name || compositeFilename;
- }
- } catch (e) { }
-
- try {
- const maskFile = dataURLtoFile(maskDataUrl, maskFilename2);
- const maskForm = new FormData();
- maskForm.append("image", maskFile);
- maskForm.append("type", tempType);
- maskForm.append("overwrite", "true");
-
- await api.fetchApi("/upload/image", {
- method: "POST",
- body: maskForm,
- });
- } catch (e) { }
-
- const viewUrl = `/view?filename=${encodeURIComponent(
- uploadedCompositeName
- )}&type=temp&_t=${Date.now()}`;
-
- if (!node.imgs) node.imgs = [];
- if (node.imgs.length === 0) {
- node.imgs.push({ src: viewUrl });
- } else {
- node.imgs[0].src = viewUrl;
- }
-
- if (node.image instanceof Image) {
- node.image.src = viewUrl;
- }
- const randomValue = lastMaskOpacity + (Math.random() * 0.002) - 0.001;
-
- if (node.widgets && Array.isArray(node.widgets)) {
- const w = node.widgets.find(w => w.name === "updater");
- if (w) {
- w.value = randomValue;
-
- if (!node.properties) node.properties = {};
- node.properties["updater"] = randomValue;
-
- if (typeof node.onPropertyChanged === "function") {
- node.onPropertyChanged("updater", randomValue);
- }
- }
- }
-
- // запоминаем, с какой картинкой была сохранена маска
- if (!node.properties) node.properties = {};
- node.properties.rgbyp_last_image_key = getNodeImageKey();
-
- node.setDirtyCanvas?.(true, true);
- app.graph.setDirtyCanvas(true, true);
- node.graph?.change?.();
-
- closeEditor();
});
+
}
diff --git a/nodes/LoadImageWithFileData.py b/nodes/LoadImageWithFileData.py
index 62e076b..32767af 100644
--- a/nodes/LoadImageWithFileData.py
+++ b/nodes/LoadImageWithFileData.py
@@ -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__original.png
- - If an RGBYP color mask exists for this unique_id, saves a composite preview
- RGBYP__composite.png
- - Writes a JSON meta file RGBYP__meta.json with:
- {
- "graph_image": ,
- "original": "",
- "mask": "",
- "composite": "",
- "width": ,
- "height": ,
- }
+ Нам нужно восстановить базовое имя "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 ВСЕГДА называется:
+ <чистое_имя_БЕЗ_РАСШИРЕНИЯ>__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 __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) После этого, если имя оканчивается на '_' — тоже срезаем
+ (это тот 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. Убираем концеовой "_", если есть
+ 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:
+
+ __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__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__composite.png
- # ------------------------------------------------------------------
- def _save_composite_with_opacity(self, image_tensor, updater, unique_id, has_color_mask):
- """
- Save composite image RGBYP__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
diff --git a/nodes/RGBYPMaskBridge.py b/nodes/RGBYPMaskBridge.py
index 28c93bf..2d3cebd 100644
--- a/nodes/RGBYPMaskBridge.py
+++ b/nodes/RGBYPMaskBridge.py
@@ -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}