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