Files
2025-10-23 18:35:50 +02:00

473 lines
20 KiB
JavaScript

/* A simple image loop for your workflow. MIT License. version 0.2
https://github.com/Hullabalo/ComfyUI-Loop/
Thanks to rgthree, chrisgoringe, pythongosssss and many, many many others for their contributions, how-to's, code snippets etc.
*/
import { app } from "../../../scripts/app.js";
const NODES_LIST = ["LoopAny", "SaveAny"];
const FIXED_INPUTS = ["mask"];
const FIXED_OUTPUTS = ["path", "width", "height", "mask"];
app.registerExtension({
name: "loop.LoopAny.InputOutput",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (!NODES_LIST.includes(nodeData.name)) return;
// ============================================
// 'any' input and output helper methods
// ============================================
nodeType.prototype.getDefaultLabelForType = type => type === "*" ? "any" : type;
nodeType.prototype.updateInputTypeAndLabel = function(input, type, label) {
input.type = type;
input.label = label ?? this.getDefaultLabelForType(type);
if (app.canvas?.default_connection_color_byType) {
input.color_on = app.canvas.default_connection_color_byType[type];
}
};
nodeType.prototype.updateOutputTypeAndLabel = function(output, type, label) {
output.type = type;
output.label = label ?? this.getDefaultLabelForType(output.type);
};
// Check if two types are incompatible (neither is wildcard and they differ)
nodeType.prototype.areTypesIncompatible = (type1, type2) =>
type1 !== type2 && type1 !== "*" && type2 !== "*";
// Disconnect a target node's input if types are incompatible
nodeType.prototype.disconnectIfIncompatible = function(targetNode, targetSlot, newType) {
if (!targetNode?.inputs?.[targetSlot]) return false;
const targetInput = targetNode.inputs[targetSlot];
if (this.areTypesIncompatible(targetInput.type, newType)) {
targetNode.disconnectInput(targetSlot);
return true;
}
return false;
};
// ============================================
// Widget Management (Proper disable/hide)
// ============================================
nodeType.prototype.manageLoopMaskWidget = function() {
// Only manage widgets for LoopAny node
if (this.type !== "LoopAny") return;
const anyInput = this.inputs?.find(i => i.name === "input");
if (!anyInput || !this.widgets) return;
const hasConnection = anyInput.link != null;
const shouldShowLoopMask = hasConnection && anyInput.label?.toUpperCase() === "IMAGE";
const loopMaskWidget = this.widgets?.find(w => w.name === "loop_mask");
const loopMaskInput = this.inputs?.find(i => i.name === "loop_mask");
if (loopMaskWidget) {
loopMaskWidget.hidden = !shouldShowLoopMask;
// Handle the input connection point
if (!shouldShowLoopMask && loopMaskInput) {
const index = this.inputs.findIndex(i => i.name === "loop_mask");
if (index !== -1) {
this.removeInput(index);
}
}
const size = this.computeSize();
size[1] += 17;
this.setSize(size);
}
};
// ============================================
// Dynamic Input/Output Management
// ============================================
nodeType.prototype.removeByName = function(collection, names, removeFn) {
names.forEach(name => {
const index = collection.findIndex(o => o.name === name);
if (index !== -1) {
removeFn.call(this, index);
}
});
};
nodeType.prototype.manageInputOutputVisibility = function() {
const anyInput = this.inputs?.find(i => i.name === "input");
if (!anyInput) return;
const hasConnection = anyInput.link != null;
const hasOutputs = this.outputs?.length > 0;
const hasMaskInput = this.inputs?.some(o => o.name === "mask");
const shouldShowMask = hasConnection && anyInput.label?.toUpperCase() === "IMAGE";
// Add/remove mask input according to 'guessed' type
if (shouldShowMask && !hasMaskInput) {
this.addInput("mask", "MASK");
} else if (!shouldShowMask && hasMaskInput) {
this.removeByName(this.inputs, ["mask"], this.removeInput);
}
// Only manage outputs if the node has them
if (hasOutputs) {
const hasMaskOutput = this.outputs?.some(o => o.name === "mask");
const hasWidthOutput = this.outputs?.some(o => o.name === "width");
const hasHeightOutput = this.outputs?.some(o => o.name === "height");
const shouldShowDimensions = hasConnection &&
["IMAGE", "MASK", "LATENT"].includes(anyInput.label?.toUpperCase());
// Mask output (only for nodes with outputs)
if (shouldShowMask && !hasMaskOutput) {
this.addOutput("mask", "MASK");
} else if (!shouldShowMask && hasMaskOutput) {
this.removeByName(this.outputs, ["mask"], this.removeOutput);
}
// Width and height outputs
if (shouldShowDimensions && !hasWidthOutput && !hasHeightOutput) {
if (!hasWidthOutput) this.addOutput("width", "INT");
if (!hasHeightOutput) this.addOutput("height", "INT");
} else if (!shouldShowDimensions && (hasWidthOutput || hasHeightOutput)) {
this.removeByName(this.outputs, ["width", "height"], this.removeOutput);
}
// Force output order
const orderMap = new Map(
["output", ...FIXED_OUTPUTS].map((name, i) => [name, i])
);
this.outputs.sort((a, b) =>
(orderMap.get(a.name) ?? 999) - (orderMap.get(b.name) ?? 999)
);
}
this.setSize?.(this.computeSize());
this.manageLoopMaskWidget?.();
// update visual connections
if (app.canvas) {
app.canvas.setDirty(true);
app.canvas.draw(true, true);
}
};
// ============================================
// Reroute Node Handling (pysssss, rgthree and native reroute)
// ============================================
nodeType.prototype.updateRerouteNode = function(rerouteNode, slot, type, label) {
const input = rerouteNode.inputs?.[slot];
const output = rerouteNode.outputs?.[slot];
if (!input || !output) return;
// Update input and output types
if (input.type !== type) {
this.updateInputTypeAndLabel(input, type, label);
}
if (output.type !== type) {
this.updateOutputTypeAndLabel(output, type, label);
// Disconnect incompatible outputs
const outputLinks = output.links ?? [];
for (const outputLinkId of [...outputLinks]) {
const outputLink = app.graph.links?.[outputLinkId];
if (!outputLink) continue;
const targetNode = app.graph.getNodeById(outputLink.target_id);
this.disconnectIfIncompatible(targetNode, outputLink.target_slot, type);
}
}
rerouteNode.setDirtyCanvas(true, true);
};
// ============================================
// Type Propagation
// ============================================
nodeType.prototype.propagateTypeToConnectedNode = function(linkId, type, label, visited, depth, maxDepth) {
try {
const graph = app.graph;
if (!graph?.links) return;
const link = graph.links[linkId];
if (!link) return;
const node = graph.getNodeById(link.target_id);
if (!node) return;
const targetSlot = link.target_slot;
// Handle Reroute nodes
if (node.type.includes("Reroute")) {
this.updateRerouteNode(node, targetSlot, type, label);
return;
}
// Handle nodes with updateChainType capability
if (node.updateChainType) {
const targetInput = node.inputs?.[targetSlot];
if (targetInput) {
const typeChanged = targetInput.type !== type;
this.updateInputTypeAndLabel(targetInput, type, label);
if (typeChanged && node.outputs?.[targetSlot]) {
node.updateChainType(targetSlot, type, label, visited, depth + 1, maxDepth);
}
node.setDirtyCanvas(true, true);
// Trigger input/output visibility management for the downstream node
setTimeout(() => {
node.manageInputOutputVisibility?.();
}, 10);
}
} else {
// Handle standard nodes --> disconnect if incompatible
this.disconnectIfIncompatible(node, targetSlot, type);
}
} catch (linkError) {
console.warn('Error processing link in propagateTypeToConnectedNode:', linkError);
}
};
nodeType.prototype.updateChainType = function(slot, type, label, visited = new Set(), depth = 0, maxDepth = 50) {
try {
if (depth > maxDepth) {
console.warn('Recursion depth exceeded in updateChainType');
return;
}
if (!this?.outputs?.length) return;
if (visited.has(this.id)) return;
visited.add(this.id);
const output = this.outputs[slot];
if (!output) return;
// Do not relabel the fixed output if exists
if (["mask", "width", "height"].includes(output.name)) return;
// Update output type and label
this.updateOutputTypeAndLabel(output, type, label);
const graph = app?.graph;
if (!graph?.links) return;
const links = output.links ?? [];
for (const linkId of [...links]) {
const link = graph.links?.[linkId];
if (!link) continue;
const targetNode = graph.getNodeById(link.target_id);
// Disconnect if incompatible, otherwise propagate
if (!this.disconnectIfIncompatible(targetNode, link.target_slot, type)) {
this.propagateTypeToConnectedNode(linkId, type, label, visited, depth, maxDepth);
}
}
} catch (e) {
console.warn('Error in updateChainType:', e);
}
};
// ============================================
// Lifecycle Methods
// ============================================
const Configure = nodeType.prototype.configure ?? LGraphNode.prototype.configure;
nodeType.prototype.configure = function() {
this.configuring = true;
const r = Configure?.apply(this, arguments);
this.configuring = false;
setTimeout(() => {
this.inputs?.forEach((input, slot) => {
// Ignore fixed inputs
if (this.fixedInputs?.includes(input.name)) return;
// If input is connected, simulate a connection change
if (input.link != null) {
const graph = app?.graph;
if (!graph?.links) return;
const link = graph.links[input.link];
if (!link) return;
const originNode = graph._nodes_by_id?.[link.origin_id];
const connectedType = originNode?.outputs?.[link.origin_slot]?.type;
if (connectedType) {
const label = this.getDefaultLabelForType(connectedType);
// input update (we don't change the type)
input.label = label;
if (app.canvas?.default_connection_color_byType) {
input.color_on = app.canvas.default_connection_color_byType[connectedType];
}
// propagate the type to the node connected forward
if (this.outputs?.length && this.outputs[slot]) {
this.updateChainType(slot, connectedType, label, new Set(), 0, 50);
}
}
}
});
this.manageInputOutputVisibility?.();
}, 10);
return r;
};
// Init types from existing connections
nodeType.prototype.initializeTypesFromConnections = function() {
this.inputs?.forEach((input, inputSlot) => {
// Ignore fixed inputs
if (this.fixedInputs?.includes(input.name)) return;
// if input is connected
if (input.link != null) {
const graph = app?.graph;
if (!graph?.links) return;
const link = graph.links[input.link];
if (!link) return;
const originNode = graph._nodes_by_id?.[link.origin_id];
const connectedType = originNode?.outputs?.[link.origin_slot]?.type;
if (connectedType) {
const label = this.getDefaultLabelForType(connectedType);
// ONLY update the 'any' type in init (type === "*")
// allow further reconnections with different types
if (input.type === "*") {
input.type = connectedType;
}
// ALWAYS update label and color
input.label = label;
if (app.canvas?.default_connection_color_byType) {
input.color_on = app.canvas.default_connection_color_byType[connectedType];
}
// Propagate the type on node forward
if (this.outputs?.length && this.outputs[inputSlot]) {
this.updateChainType(inputSlot, connectedType, label, new Set(), 0, 50);
}
}
}
});
};
const OnConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function(side, slot, connect, link_info) {
if (this.configuring) return;
// Don't do anything to the fixed inputs
if (FIXED_INPUTS.includes(this.inputs[slot]?.name)) {
return OnConnectionsChange?.apply(this, arguments);
}
// Change only the 'any' input
if (side === 1 && this.inputs) {
const input = this.inputs[slot];
if (connect && link_info) {
const connectedType = this.getConnectedOutputType(link_info);
if (connectedType) {
// CRITICAL: we don't change input.type to allow reconnection with different node input types
// updating label and color only
input.label = this.getDefaultLabelForType(connectedType);
if (app.canvas?.default_connection_color_byType) {
input.color_on = app.canvas.default_connection_color_byType[connectedType];
}
const label = this.getDefaultLabelForType(connectedType);
// Only call updateChainType if the node has outputs
if (this.outputs?.length && this.outputs[slot]) {
this.updateChainType(slot, connectedType, label, new Set(), 0, 50);
}
}
} else {
// Reset if disconnected
input.type = "*";
input.label = this.getDefaultLabelForType("*");
input.color_on = undefined;
const label = this.getDefaultLabelForType("*");
// Only call updateChainType if the node has outputs
if (this.outputs?.length && this.outputs[slot]) {
this.updateChainType(slot, "*", label, new Set(), 0, 50);
}
}
this.setDirtyCanvas(true, true);
}
const result = OnConnectionsChange?.apply(this, arguments);
// inputs/outputs visibility after connection change
if (side === 1) { // INPUT change
setTimeout(() => {
this.manageInputOutputVisibility?.();
}, 10);
}
return result;
};
// Helper to get the connected output type
nodeType.prototype.getConnectedOutputType = link_info => {
const graph = app?.graph;
if (!graph?._nodes_by_id || !link_info.origin_id) return null;
return graph._nodes_by_id[link_info.origin_id]?.outputs?.[link_info.origin_slot]?.type ?? null;
};
const OnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = OnNodeCreated ? OnNodeCreated.apply(this, arguments) : undefined;
this.configuring = false;
this.fixedInputs = this.fixedInputs ?? FIXED_INPUTS;
this.fixedOutputs = this.fixedOutputs ?? FIXED_OUTPUTS;
// Initialize inputs
this.inputs?.forEach(input => {
if (input.type === "*" && !input.label) {
input.label = this.getDefaultLabelForType(input.type);
}
});
// Initialize outputs
this.outputs?.forEach(output => {
if (output.type === "*" && !output.label) {
output.label = this.getDefaultLabelForType(output.type);
}
});
// Init inputs/outputs visibility
setTimeout(() => {
this.manageInputOutputVisibility?.();
}, 10);
return r;
};
const OnRemoved = nodeType.prototype.onRemoved ?? LGraphNode.prototype.onRemoved;
nodeType.prototype.onRemoved = function() {
delete this.configuring;
return OnRemoved?.apply(this, arguments);
};
}
});