Major Changes: - Add MultiAreaConditioningMask node for Flux/Chroma/SD3 support - Implement comprehensive error handling and input validation - Add bounds checking for MultiLatentComposite (fixes known issue) - Modernize JavaScript loading (js/ folder, no file copying) - Add tooltips and descriptions to all node inputs Fixes: - Remove debug console.log statements - Fix graph reference bug in background drawing (app.graph vs graph) - Fix typo in variable name (inputLenght -> inputLength) - Improve comment accuracy Enhancements: - Add detailed error messages with emoji indicators (⚠️ ℹ️ ❌) - Implement graceful fallback behavior when metadata missing - Add tensor shape and type validation - Add channel compatibility checking in MultiLatentComposite - Improve feathering with proper gradient calculation - Add bounds clipping with informative warnings Documentation: - Complete README rewrite with compatibility matrix - Add usage examples for Flux, Chroma, SD, SDXL - Document all node inputs/outputs with descriptions - Add troubleshooting section - Add changelog with version 3.0 details JavaScript: - Move from javascript/ to js/ for modern ComfyUI compatibility - Clean up all console.log debug statements - Fix graph._nodes bug (should be app.graph._nodes) - Add MultiAreaConditioningMask.js for mask-based node - Improve code comments and formatting Compatibility: - SD1.5, SD2.x, SDXL (area-based, existing functionality) - Flux, Chroma, SD3, SD3.5 (mask-based, NEW) - Experimental Qwen-Image support noted - WAN 2.2 marked as not supported (video architecture) All changes maintain backward compatibility with existing workflows while adding modern model support and significantly improving robustness and user experience.
398 lines
12 KiB
JavaScript
398 lines
12 KiB
JavaScript
// Multi Area Conditioning Mask - JavaScript UI
|
|
// Updated: 2025-11-22
|
|
// Visual interface for mask-based regional conditioning (Flux, Chroma, SD3+)
|
|
|
|
import { app } from "/scripts/app.js";
|
|
import {CUSTOM_INT, recursiveLinkUpstream, transformFunc, swapInputs, renameNodeInputs, removeNodeInputs, getDrawColor, computeCanvasSize} from "./utils.js"
|
|
|
|
function addMultiAreaConditioningMaskCanvas(node, app) {
|
|
|
|
const widget = {
|
|
type: "customCanvas",
|
|
name: "MultiAreaConditioningMask-Canvas",
|
|
get value() {
|
|
return this.canvas.value;
|
|
},
|
|
set value(x) {
|
|
this.canvas.value = x;
|
|
},
|
|
draw: function (ctx, node, widgetWidth, widgetY) {
|
|
|
|
// If we are initially offscreen when created we won't have received a resize event
|
|
// Calculate it here instead
|
|
if (!node.canvasHeight) {
|
|
computeCanvasSize(node, node.size)
|
|
}
|
|
|
|
const visible = true
|
|
const t = ctx.getTransform();
|
|
const margin = 10
|
|
const border = 2
|
|
|
|
const widgetHeight = node.canvasHeight
|
|
const values = node.properties["values"]
|
|
|
|
// Get canvas dimensions from properties (synced from width/height inputs)
|
|
const width = Math.round(node.properties["width"])
|
|
const height = Math.round(node.properties["height"])
|
|
|
|
const scale = Math.min((widgetWidth-margin*2)/width, (widgetHeight-margin*2)/height)
|
|
|
|
// Index widget is at position 2 (after width and height)
|
|
const index = Math.round(node.widgets[2].value)
|
|
|
|
Object.assign(this.canvas.style, {
|
|
left: `${t.e}px`,
|
|
top: `${t.f + (widgetY*t.d)}px`,
|
|
width: `${widgetWidth * t.a}px`,
|
|
height: `${widgetHeight * t.d}px`,
|
|
position: "absolute",
|
|
zIndex: 1,
|
|
fontSize: `${t.d * 10.0}px`,
|
|
pointerEvents: "none",
|
|
});
|
|
|
|
this.canvas.hidden = !visible;
|
|
|
|
let backgroundWidth = width * scale
|
|
let backgroundHeight = height * scale
|
|
|
|
let xOffset = margin
|
|
if (backgroundWidth < widgetWidth) {
|
|
xOffset += (widgetWidth-backgroundWidth)/2 - margin
|
|
}
|
|
let yOffset = margin
|
|
if (backgroundHeight < widgetHeight) {
|
|
yOffset += (widgetHeight-backgroundHeight)/2 - margin
|
|
}
|
|
|
|
let widgetX = xOffset
|
|
widgetY = widgetY + yOffset
|
|
|
|
ctx.fillStyle = "#000000"
|
|
ctx.fillRect(widgetX-border, widgetY-border, backgroundWidth+border*2, backgroundHeight+border*2)
|
|
|
|
ctx.fillStyle = globalThis.LiteGraph.NODE_DEFAULT_BGCOLOR
|
|
ctx.fillRect(widgetX, widgetY, backgroundWidth, backgroundHeight);
|
|
|
|
function getDrawArea(v) {
|
|
let x = v[0]*backgroundWidth/width
|
|
let y = v[1]*backgroundHeight/height
|
|
let w = v[2]*backgroundWidth/width
|
|
let h = v[3]*backgroundHeight/height
|
|
|
|
if (x > backgroundWidth) { x = backgroundWidth}
|
|
if (y > backgroundHeight) { y = backgroundHeight}
|
|
|
|
if (x+w > backgroundWidth) {
|
|
w = Math.max(0, backgroundWidth-x)
|
|
}
|
|
|
|
if (y+h > backgroundHeight) {
|
|
h = Math.max(0, backgroundHeight-y)
|
|
}
|
|
|
|
return [x, y, w, h]
|
|
}
|
|
|
|
// Draw all the conditioning zones
|
|
for (const [k, v] of values.entries()) {
|
|
|
|
if (k == index) {continue}
|
|
|
|
const [x, y, w, h] = getDrawArea(v)
|
|
|
|
ctx.fillStyle = getDrawColor(k/values.length, "80")
|
|
ctx.fillRect(widgetX+x, widgetY+y, w, h)
|
|
|
|
}
|
|
|
|
ctx.beginPath();
|
|
ctx.lineWidth = 1;
|
|
|
|
for (let x = 0; x <= width/64; x += 1) {
|
|
ctx.moveTo(widgetX+x*64*scale, widgetY);
|
|
ctx.lineTo(widgetX+x*64*scale, widgetY+backgroundHeight);
|
|
}
|
|
|
|
for (let y = 0; y <= height/64; y += 1) {
|
|
ctx.moveTo(widgetX, widgetY+y*64*scale);
|
|
ctx.lineTo(widgetX+backgroundWidth, widgetY+y*64*scale);
|
|
}
|
|
|
|
ctx.strokeStyle = "#00000050";
|
|
ctx.stroke();
|
|
ctx.closePath();
|
|
|
|
// Draw currently selected zone
|
|
let [x, y, w, h] = getDrawArea(values[index])
|
|
|
|
w = Math.max(32*scale, w)
|
|
h = Math.max(32*scale, h)
|
|
|
|
ctx.fillStyle = "#ffffff"
|
|
ctx.fillRect(widgetX+x, widgetY+y, w, h)
|
|
|
|
const selectedColor = getDrawColor(index/values.length, "FF")
|
|
ctx.fillStyle = selectedColor
|
|
ctx.fillRect(widgetX+x+border, widgetY+y+border, w-border*2, h-border*2)
|
|
|
|
// Display indicator
|
|
ctx.beginPath();
|
|
|
|
ctx.arc(LiteGraph.NODE_SLOT_HEIGHT*0.5, LiteGraph.NODE_SLOT_HEIGHT*(index + 0.5)+4, 4, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeStyle = "white";
|
|
ctx.stroke();
|
|
|
|
if (node.selected) {
|
|
const connectedNodes = recursiveLinkUpstream(node, node.inputs[index].type, 0, index)
|
|
|
|
if (connectedNodes.length !== 0) {
|
|
for (let [node_ID, depth] of connectedNodes) {
|
|
let connectedNode = node.graph._nodes_by_id[node_ID]
|
|
if (connectedNode.type != node.type) {
|
|
const [x, y] = connectedNode.pos
|
|
const [w, h] = connectedNode.size
|
|
const offset = 5
|
|
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT * (connectedNode.type === "Reroute" ? 0 : 1)
|
|
|
|
ctx.strokeStyle = selectedColor
|
|
ctx.lineWidth = 5;
|
|
ctx.strokeRect(x-offset-node.pos[0], y-offset-node.pos[1]-titleHeight, w+offset*2, h+offset*2+titleHeight)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ctx.lineWidth = 1;
|
|
ctx.closePath();
|
|
|
|
},
|
|
};
|
|
|
|
widget.canvas = document.createElement("canvas");
|
|
widget.canvas.className = "dave-custom-canvas";
|
|
|
|
widget.parent = node;
|
|
document.body.appendChild(widget.canvas);
|
|
|
|
node.addCustomWidget(widget);
|
|
|
|
app.canvas.onDrawBackground = function () {
|
|
// Draw node isn't fired once the node is off the screen
|
|
// if it goes off screen quickly, the input may not be removed
|
|
// this shifts it off screen so it can be moved back if the node is visible.
|
|
for (let n in app.graph._nodes) {
|
|
n = app.graph._nodes[n];
|
|
for (let w in n.widgets) {
|
|
let wid = n.widgets[w];
|
|
if (Object.hasOwn(wid, "canvas")) {
|
|
wid.canvas.style.left = -8000 + "px";
|
|
wid.canvas.style.position = "absolute";
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
node.onResize = function (size) {
|
|
computeCanvasSize(node, size);
|
|
}
|
|
|
|
return { minWidth: 200, minHeight: 200, widget }
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "Comfy.Davemane42.MultiAreaConditioningMask",
|
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
|
if (nodeData.name === "MultiAreaConditioningMask") {
|
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
|
|
|
this.setProperty("width", 1024)
|
|
this.setProperty("height", 1024)
|
|
this.setProperty("values", [[0, 0, 0, 0, 1.0], [0, 0, 0, 0, 1.0]])
|
|
|
|
this.selected = false
|
|
this.index = 2 // Index widget is now at position 2 (after width/height inputs)
|
|
|
|
this.serialize_widgets = true;
|
|
|
|
addMultiAreaConditioningMaskCanvas(this, app)
|
|
|
|
CUSTOM_INT(
|
|
this,
|
|
"index",
|
|
0,
|
|
function (v, _, node) {
|
|
|
|
let values = node.properties["values"]
|
|
|
|
// Widget indices are offset by 2 (width and height inputs come first)
|
|
node.widgets[3].value = values[v][0]
|
|
node.widgets[4].value = values[v][1]
|
|
node.widgets[5].value = values[v][2]
|
|
node.widgets[6].value = values[v][3]
|
|
if (!values[v][4]) {values[v][4] = 1.0}
|
|
node.widgets[7].value = values[v][4]
|
|
},
|
|
{ step: 10, max: 1 }
|
|
|
|
)
|
|
|
|
CUSTOM_INT(this, "x", 0, function (v, _, node) {transformFunc(this, v, node, 0)})
|
|
CUSTOM_INT(this, "y", 0, function (v, _, node) {transformFunc(this, v, node, 1)})
|
|
CUSTOM_INT(this, "width", 0, function (v, _, node) {transformFunc(this, v, node, 2)})
|
|
CUSTOM_INT(this, "height", 0, function (v, _, node) {transformFunc(this, v, node, 3)})
|
|
CUSTOM_INT(this, "strength", 1, function (v, _, node) {transformFunc(this, v, node, 4)}, {"min": 0.0, "max": 10.0, "step": 0.1, "precision": 2})
|
|
|
|
// Sync canvas dimensions from input widgets
|
|
const originalOnExecuted = this.onExecuted;
|
|
this.onExecuted = function(message) {
|
|
if (originalOnExecuted) {
|
|
originalOnExecuted.apply(this, arguments);
|
|
}
|
|
|
|
// Sync width/height from input widgets to properties for canvas rendering
|
|
if (this.widgets && this.widgets.length >= 2) {
|
|
// Width and height are first two widgets (inputs)
|
|
if (this.widgets[0].value !== undefined) {
|
|
this.properties["width"] = this.widgets[0].value;
|
|
}
|
|
if (this.widgets[1].value !== undefined) {
|
|
this.properties["height"] = this.widgets[1].value;
|
|
}
|
|
}
|
|
};
|
|
|
|
this.getExtraMenuOptions = function(_, options) {
|
|
options.unshift(
|
|
{
|
|
content: `insert input above ${this.widgets[this.index].value} /\\`,
|
|
callback: () => {
|
|
this.addInput("conditioning", "CONDITIONING")
|
|
|
|
const inputLength = this.inputs.length-1
|
|
const index = this.widgets[this.index].value
|
|
|
|
for (let i = inputLength; i > index; i--) {
|
|
swapInputs(this, i, i-1)
|
|
}
|
|
renameNodeInputs(this, "conditioning")
|
|
|
|
this.properties["values"].splice(index, 0, [0, 0, 0, 0, 1])
|
|
this.widgets[this.index].options.max = inputLength
|
|
|
|
this.setDirtyCanvas(true);
|
|
|
|
},
|
|
},
|
|
{
|
|
content: `insert input below ${this.widgets[this.index].value} \\/`,
|
|
callback: () => {
|
|
this.addInput("conditioning", "CONDITIONING")
|
|
|
|
const inputLength = this.inputs.length-1
|
|
const index = this.widgets[this.index].value
|
|
|
|
for (let i = inputLength; i > index+1; i--) {
|
|
swapInputs(this, i, i-1)
|
|
}
|
|
renameNodeInputs(this, "conditioning")
|
|
|
|
this.properties["values"].splice(index+1, 0, [0, 0, 0, 0, 1])
|
|
this.widgets[this.index].options.max = inputLength
|
|
|
|
this.setDirtyCanvas(true);
|
|
},
|
|
},
|
|
{
|
|
content: `swap with input above ${this.widgets[this.index].value} /\\`,
|
|
callback: () => {
|
|
const index = this.widgets[this.index].value
|
|
if (index !== 0) {
|
|
swapInputs(this, index, index-1)
|
|
|
|
renameNodeInputs(this, "conditioning")
|
|
|
|
this.properties["values"].splice(index-1,0,this.properties["values"].splice(index,1)[0]);
|
|
this.widgets[this.index].value = index-1
|
|
|
|
this.setDirtyCanvas(true);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
content: `swap with input below ${this.widgets[this.index].value} \\/`,
|
|
callback: () => {
|
|
const index = this.widgets[this.index].value
|
|
if (index !== this.inputs.length-1) {
|
|
swapInputs(this, index, index+1)
|
|
|
|
renameNodeInputs(this, "conditioning")
|
|
|
|
this.properties["values"].splice(index+1,0,this.properties["values"].splice(index,1)[0]);
|
|
this.widgets[this.index].value = index+1
|
|
|
|
this.setDirtyCanvas(true);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
content: `remove currently selected input ${this.widgets[this.index].value}`,
|
|
callback: () => {
|
|
const index = this.widgets[this.index].value
|
|
removeNodeInputs(this, [index])
|
|
renameNodeInputs(this, "conditioning")
|
|
},
|
|
},
|
|
{
|
|
content: "remove all unconnected inputs",
|
|
callback: () => {
|
|
let indexesToRemove = []
|
|
|
|
for (let i = 0; i < this.inputs.length; i++) {
|
|
if (!this.inputs[i].link) {
|
|
indexesToRemove.push(i)
|
|
}
|
|
}
|
|
|
|
if (indexesToRemove.length) {
|
|
removeNodeInputs(this, indexesToRemove, "conditioning")
|
|
}
|
|
renameNodeInputs(this, "conditioning")
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
this.onRemoved = function () {
|
|
// When removing this node we need to remove the input from the DOM
|
|
for (let y in this.widgets) {
|
|
if (this.widgets[y].canvas) {
|
|
this.widgets[y].canvas.remove();
|
|
}
|
|
}
|
|
};
|
|
|
|
this.onSelected = function () {
|
|
this.selected = true
|
|
}
|
|
this.onDeselected = function () {
|
|
this.selected = false
|
|
}
|
|
|
|
return r;
|
|
};
|
|
}
|
|
},
|
|
loadedGraphNode(node, _) {
|
|
if (node.type === "MultiAreaConditioningMask") {
|
|
node.widgets[node.index].options["max"] = node.properties["values"].length-1
|
|
}
|
|
},
|
|
|
|
});
|