From 0559e16cf0815cca0e1592beb75bf30214461f6a Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 5 Mar 2026 03:28:04 +0100 Subject: [PATCH] Add Ordered Passthrough node with auto-wired execution order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS extension auto-creates LiteGraph links between consecutive passthrough nodes based on their order widget (1→2→3→4), hiding the wait_for input and drawing visual chain lines between nodes. Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 2 +- string_utils.py | 26 +++ web/dependency_passthrough.js | 295 ++++++++++++++++++++++++++++++++++ 3 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 web/dependency_passthrough.js diff --git a/pyproject.toml b/pyproject.toml index a784ba8..0c3e15a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "comfyui-json-dynamic" -version = "1.2.2" +version = "1.2.3" description = "ComfyUI nodes for dynamic JSON loading and string/path utility operations" license = { file = "LICENSE" } requires-python = ">=3.10" diff --git a/string_utils.py b/string_utils.py index bb3fa67..d14c360 100644 --- a/string_utils.py +++ b/string_utils.py @@ -155,11 +155,36 @@ class JDL_StringSwitch: return (on_false if on_false is not None else default_false,) +class JDL_DependencyPassthrough: + """Passes data through unchanged. Set 'order' in the JS widget to auto-wire + execution order between passthrough nodes (1 → 2 → 3 → 4).""" + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "data": (any_type,), + }, + "optional": { + "wait_for": (any_type,), + }, + } + + RETURN_TYPES = (any_type,) + RETURN_NAMES = ("data",) + FUNCTION = "passthrough" + CATEGORY = "utils/flow" + + def passthrough(self, data, wait_for=None): + return (data,) + + NODE_CLASS_MAPPINGS = { "JDL_PathJoin": JDL_PathJoin, "JDL_StringFormat": JDL_StringFormat, "JDL_StringExtract": JDL_StringExtract, "JDL_StringSwitch": JDL_StringSwitch, + "JDL_DependencyPassthrough": JDL_DependencyPassthrough, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -167,4 +192,5 @@ NODE_DISPLAY_NAME_MAPPINGS = { "JDL_StringFormat": "String Format", "JDL_StringExtract": "String Extract", "JDL_StringSwitch": "String Switch", + "JDL_DependencyPassthrough": "Ordered Passthrough", } diff --git a/web/dependency_passthrough.js b/web/dependency_passthrough.js new file mode 100644 index 0000000..95ec7df --- /dev/null +++ b/web/dependency_passthrough.js @@ -0,0 +1,295 @@ +import { app } from "../../scripts/app.js"; + +const NODE_TYPE = "JDL_DependencyPassthrough"; +const WAIT_FOR_INPUT = "wait_for"; +const CHAIN_COLOR = "#4ea6f7"; +const CHAIN_COLOR_DIM = "#2a5a8a"; + +/** Find the input slot index for wait_for on a node. */ +function getWaitForSlot(node) { + if (!node.inputs) return -1; + for (let i = 0; i < node.inputs.length; i++) { + if (node.inputs[i].name === WAIT_FOR_INPUT) return i; + } + return -1; +} + +/** Get all JDL_DependencyPassthrough nodes in the graph sorted by order. */ +function getSortedPassthroughNodes(graph) { + const nodes = []; + for (const node of graph._nodes || []) { + if (node.type === NODE_TYPE) { + const orderW = node.widgets?.find(w => w.name === "order"); + nodes.push({ node, order: orderW ? orderW.value : 0 }); + } + } + nodes.sort((a, b) => a.order - b.order || a.node.id - b.node.id); + return nodes; +} + +/** Remove all links connected to the wait_for input of a node. */ +function clearWaitForLinks(node, graph) { + const slot = getWaitForSlot(node); + if (slot < 0) return; + const input = node.inputs[slot]; + if (input && input.link != null) { + graph.removeLink(input.link); + } +} + +/** + * Rewire all passthrough nodes: connect each node's data output (slot 0) + * to the next-order node's wait_for input. + */ +function rewireAll(graph) { + if (!graph || graph._rewiring) return; + graph._rewiring = true; + graph._prevNodeMap = null; // invalidate rendering cache + try { + const sorted = getSortedPassthroughNodes(graph); + + // Clear all wait_for links first + for (const { node } of sorted) { + clearWaitForLinks(node, graph); + } + + // Connect consecutive pairs + for (let i = 0; i < sorted.length - 1; i++) { + const srcNode = sorted[i].node; + const dstNode = sorted[i + 1].node; + const dstSlot = getWaitForSlot(dstNode); + if (dstSlot < 0) continue; + // output slot 0 = data output + srcNode.connect(0, dstNode, dstSlot); + } + + graph.setDirtyCanvas(true, true); + } finally { + graph._rewiring = false; + } +} + +/** + * Build a map from node id to its predecessor in the sorted chain. + * Cached per graph and invalidated when rewireAll runs. + */ +function getPrevNodeMap(graph) { + if (graph._prevNodeMap) return graph._prevNodeMap; + const sorted = getSortedPassthroughNodes(graph); + const map = new Map(); + for (let i = 1; i < sorted.length; i++) { + map.set(sorted[i].node.id, sorted[i - 1]); + } + graph._prevNodeMap = map; + return map; +} + +app.registerExtension({ + name: "jdl.dependency.passthrough", + + async beforeRegisterNodeDef(nodeType, nodeData, appInstance) { + if (nodeData.name !== NODE_TYPE) return; + + // --- onNodeCreated: add order widget, hide wait_for --- + const origOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + origOnNodeCreated?.apply(this, arguments); + + // Add order widget + this.addWidget("number", "order", 1, (v) => { + // Clamp to 1-99 + const clamped = Math.max(1, Math.min(99, Math.round(v))); + const orderW = this.widgets?.find(w => w.name === "order"); + if (orderW && orderW.value !== clamped) orderW.value = clamped; + // Rewire after order changes + if (this.graph) rewireAll(this.graph); + }, { min: 1, max: 99, step: 1, precision: 0 }); + + // Hide the wait_for input visually + this._hideWaitFor(); + + this.setSize(this.computeSize()); + }; + + // --- Helper to hide wait_for slot --- + nodeType.prototype._hideWaitFor = function () { + const slot = getWaitForSlot(this); + if (slot >= 0 && this.inputs[slot]) { + this.inputs[slot].hidden = true; + } + }; + + // --- onConfigure: restore order widget from saved data --- + const origOnConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (info) { + origOnConfigure?.apply(this, arguments); + + // Restore order widget value from serialized widget values + const orderW = this.widgets?.find(w => w.name === "order"); + if (orderW && info.widgets_values) { + const orderIdx = this.widgets.indexOf(orderW); + if (orderIdx >= 0 && orderIdx < info.widgets_values.length) { + const v = info.widgets_values[orderIdx]; + if (typeof v === "number") { + orderW.value = Math.max(1, Math.min(99, Math.round(v))); + } + } + } + + this._hideWaitFor(); + }; + + // --- onAdded: rewire when node is placed on graph --- + const origOnAdded = nodeType.prototype.onAdded; + nodeType.prototype.onAdded = function (graph) { + origOnAdded?.apply(this, arguments); + this._hideWaitFor(); + // Defer to let graph settle + queueMicrotask(() => { + if (this.graph) rewireAll(this.graph); + }); + }; + + // --- onRemoved: rewire remaining nodes --- + const origOnRemoved = nodeType.prototype.onRemoved; + nodeType.prototype.onRemoved = function () { + const graph = this.graph; + origOnRemoved?.apply(this, arguments); + if (graph) { + queueMicrotask(() => rewireAll(graph)); + } + }; + + // --- onConnectionsChange: prevent manual wait_for edits --- + const origOnConnectionsChange = nodeType.prototype.onConnectionsChange; + nodeType.prototype.onConnectionsChange = function (type, slotIndex, isConnected, linkInfo, ioSlot) { + origOnConnectionsChange?.apply(this, arguments); + if (this.graph?._rewiring) return; + + // If user manually disconnected/connected the wait_for slot, re-enforce auto wiring + if (type === LiteGraph.INPUT) { + const waitSlot = getWaitForSlot(this); + if (slotIndex === waitSlot) { + queueMicrotask(() => { + if (this.graph) rewireAll(this.graph); + }); + } + } + }; + + // --- onDrawForeground: draw chain visualization --- + const origOnDrawForeground = nodeType.prototype.onDrawForeground; + nodeType.prototype.onDrawForeground = function (ctx) { + origOnDrawForeground?.apply(this, arguments); + if (!this.graph || this.flags?.collapsed) return; + + const orderW = this.widgets?.find(w => w.name === "order"); + if (!orderW) return; + + // Draw order badge in top-right corner + const orderNum = orderW.value; + ctx.save(); + const badgeX = this.size[0] - 28; + const badgeY = -LiteGraph.NODE_TITLE_HEIGHT + 4; + const badgeW = 24; + const badgeH = LiteGraph.NODE_TITLE_HEIGHT - 8; + const radius = 4; + + // Rounded rect background + ctx.fillStyle = CHAIN_COLOR; + ctx.beginPath(); + ctx.roundRect(badgeX, badgeY, badgeW, badgeH, radius); + ctx.fill(); + + // Order number text + ctx.fillStyle = "#fff"; + ctx.font = "bold 11px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(orderNum, badgeX + badgeW / 2, badgeY + badgeH / 2); + ctx.restore(); + + // Draw dashed chain line from previous-order node + const prevMap = getPrevNodeMap(this.graph); + const prev = prevMap.get(this.id); + if (!prev) return; + + const srcNode = prev.node; + // Calculate connection points in graph space, then convert to local + const srcX = srcNode.pos[0] + srcNode.size[0] / 2; + const srcY = srcNode.pos[1] + srcNode.size[1]; + const dstX = this.pos[0] + this.size[0] / 2; + const dstY = this.pos[1] + 10; + + // Convert to local coords (this node's space) + const localSrcX = srcX - this.pos[0]; + const localSrcY = srcY - this.pos[1]; + const localDstX = dstX - this.pos[0]; + const localDstY = dstY - this.pos[1]; + + ctx.save(); + ctx.strokeStyle = CHAIN_COLOR_DIM; + ctx.lineWidth = 2; + ctx.setLineDash([6, 4]); + ctx.globalAlpha = 0.6; + ctx.beginPath(); + ctx.moveTo(localSrcX, localSrcY); + + // Curved line for better visual + const midY = (localSrcY + localDstY) / 2; + ctx.bezierCurveTo( + localSrcX, midY, + localDstX, midY, + localDstX, localDstY + ); + ctx.stroke(); + + // Draw a small arrow at the destination + ctx.setLineDash([]); + ctx.globalAlpha = 0.8; + ctx.fillStyle = CHAIN_COLOR_DIM; + ctx.beginPath(); + ctx.moveTo(localDstX, localDstY); + ctx.lineTo(localDstX - 5, localDstY - 8); + ctx.lineTo(localDstX + 5, localDstY - 8); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + }; + }, + + /** After the full graph is configured (workflow load), rewire everything. */ + async setup(appInstance) { + function hookGraph(graph) { + if (!graph || graph._passthroughHooked) return; + graph._passthroughHooked = true; + const origAfterConfigure = graph.onAfterConfigure; + graph.onAfterConfigure = function () { + origAfterConfigure?.apply(this, arguments); + queueMicrotask(() => { + for (const node of graph._nodes || []) { + if (node.type === NODE_TYPE) { + node._hideWaitFor?.(); + } + } + rewireAll(graph); + }); + }; + } + + // Hook now if graph exists, otherwise poll briefly until it does + if (app.graph) { + hookGraph(app.graph); + } else { + let attempts = 0; + const interval = setInterval(() => { + if (app.graph) { + clearInterval(interval); + hookGraph(app.graph); + } else if (++attempts > 50) { + clearInterval(interval); + } + }, 100); + } + }, +});