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, 5Change brush color
SHIFT + 1Also changes brush color
SHIFT + A / SHIFT + DBrush size
SHIFT + W / SHIFT + SMask opacity
SHIFT + Z / SHIFT + XZoom (or use the mouse wheel)
SHIFT + CFit image to window
SHIFT + VGenerate automatic masks. Each press creates the next one
SPACE / SHIFT + SPACEImage scroll mode
ESCClose the window without saving
ENTERSave 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}