From 38b520ac458a4635f24d3445d485b4eba407eafc Mon Sep 17 00:00:00 2001 From: Oratorian Date: Wed, 3 Jun 2026 07:12:37 +0200 Subject: [PATCH] fix: resolve boolean inputs across subgraph boundaries. Graph.links is a Map in current litegraph; read via linkById(). Resolve subgraph I/O proxy nodes via nodeById() and hop from a subgraph input proxy out to the parent's input slot, so an in-subgraph controller can be driven by a boolean passed through a subgraph input. Nested subgraphs supported. --- README.md | 8 ++- js/switchboard.js | 170 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 167 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index fd80baa..cd3460c 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,13 @@ branch is evaluated, so the unused input's entire upstream chain is **skipped** format. - **Subgraph-aware.** A controller operates on the graph it sits in, so a controller placed inside a subgraph targets that subgraph's own groups/nodes - (and its `Add…` list shows those, not the outer graph's). + (and its `Add…` list shows those, not the outer graph's). To drive an + in-subgraph controller from outside, expose a `BOOLEAN` as a **subgraph input** + and wire your boolean to it - the controller traces that input back across the + subgraph boundary to read the value (so only the boolean crosses; the + controller keeps holding the inner node/group id). Nested subgraphs work too. + To switch a **whole** subgraph instead, target the subgraph node itself with a + Node Controller from the parent graph. ## Nodes 2.0 compatibility diff --git a/js/switchboard.js b/js/switchboard.js index c067087..63e01cf 100644 --- a/js/switchboard.js +++ b/js/switchboard.js @@ -102,6 +102,31 @@ function applyModeToNode(graph, key, mode) { if (node) node.mode = mode; } +/** Look up a link by id, tolerating both the classic object/array `links` and + * the newer `Map` used by the Comfy-Org litegraph (incl. Subgraph). */ +function linkById(graph, id) { + const links = graph && graph.links; + if (!links || id == null) return null; + if (typeof links.get === "function") return links.get(id) || null; // Map + return links[id] || null; // object / array +} + +/** Resolve a node by id, including subgraph I/O proxy nodes that aren't always + * registered in getNodeById. */ +function nodeById(graph, id) { + if (!graph) return null; + let n = graph.getNodeById ? graph.getNodeById(id) : null; + if (!n && /^\d+$/.test(String(id)) && graph.getNodeById) n = graph.getNodeById(Number(id)); + if (n) return n; + const io = []; + if (graph.inputNode) io.push(graph.inputNode); + if (graph.outputNode) io.push(graph.outputNode); + if (Array.isArray(graph._input_nodes)) io.push(...graph._input_nodes); + if (Array.isArray(graph._output_nodes)) io.push(...graph._output_nodes); + for (const c of io) if (c && (c.id === id || c.id === Number(id))) return c; + return null; +} + /** * Shared base. Subclasses provide three things via static/overridable members: * - static targetWord ("group" | "node") for placeholder text @@ -302,28 +327,84 @@ class BaseControllerNode extends LGraphNode { } } - /** Read the boolean coming into `slot` from the connected upstream node. - * Returns true/false, or null if nothing usable is connected. */ + /** Read the boolean coming into `slot`. Follows the wire to its source, and + * if the source is a subgraph input proxy, hops out across the boundary to + * read the real constant in the parent graph (recursively). Returns + * true/false, or null if nothing usable is connected. */ _readBooleanInput(slot) { const input = this.inputs?.[slot]; if (!input || input.link == null) return null; - const graph = this._graph(); - const link = graph.links[input.link]; + return this._resolveBoolean(this._graph(), input.link, 0); + } + + _resolveBoolean(graph, linkId, depth) { + if (!graph || linkId == null || depth > 10) return null; + const link = linkById(graph, linkId); if (!link) return null; - const origin = graph.getNodeById(link.origin_id); + const origin = nodeById(graph, link.origin_id); if (!origin) return null; - // Primitive/Boolean nodes hold their value in a widget (a constant set in - // the UI), which we can read directly on the front-end. - const widgets = origin.widgets || []; + + // 1) A constant boolean we can read directly on the front-end. + const direct = this._readBoolWidget(origin, link.origin_slot); + if (direct !== null) return direct; + + // 2) The source is a subgraph INPUT proxy -> hop out to the parent graph's + // matching input slot and keep resolving. Only the boolean crosses; the + // controller (and the node id it holds) stays inside the subgraph. + const hop = this._crossSubgraphInput(graph, origin, link.origin_slot); + if (hop) return this._resolveBoolean(hop.graph, hop.linkId, depth + 1); + + return null; + } + + /** Read a boolean constant off a node, or its cached output value. */ + _readBoolWidget(node, originSlot) { + const widgets = node.widgets || []; let w = widgets.find((x) => typeof x.value === "boolean"); if (!w) w = widgets.find((x) => /^(value|boolean|bool)$/i.test(x.name || "")); if (w) return !!w.value; - // Fall back to a cached output value if the node exposes one. - const out = origin.outputs?.[link.origin_slot]; + const out = node.outputs?.[originSlot]; if (out && typeof out._data !== "undefined") return !!out._data; return null; } + /** If `origin` is `graph`'s subgraph-input proxy node, return the parent + * graph + the link feeding the matching input slot on the subgraph node. + * Best-effort across litegraph versions; returns null (no crossing) if the + * structure isn't recognised, so there's never a regression. */ + _crossSubgraphInput(graph, origin, originSlot) { + try { + const inputProxy = graph.inputNode || graph._inputNode || graph.input_node; + if (!inputProxy || origin !== inputProxy) return null; + + // Find the subgraph node (in the parent graph) that hosts this subgraph. + let host = graph._subgraph_node || graph.subgraphNode || graph._node || null; + let parentGraph = host ? host.graph : null; + if (!host) { + const root = app.graph; + const stack = root ? [root] : []; + while (stack.length) { + const g = stack.pop(); + for (const n of g._nodes || g.nodes || []) { + if (n.subgraph === graph) { host = n; parentGraph = g; break; } + if (n.subgraph) stack.push(n.subgraph); + } + if (host) break; + } + } + if (!host || !parentGraph) { + console.debug("[Switchboard] subgraph boundary: parent node not found", { graph, origin }); + return null; + } + const parentInput = host.inputs?.[originSlot]; + if (!parentInput || parentInput.link == null) return null; + return { graph: parentGraph, linkId: parentInput.link }; + } catch (err) { + console.debug("[Switchboard] subgraph boundary error", err); + return null; + } + } + /** True when this control has a BOOLEAN wired in -- i.e. it's governed by the * input and should ignore the master broadcast / manual toggling. */ _hasBooleanInput(control) { @@ -452,6 +533,75 @@ class BaseControllerNode extends LGraphNode { content: `Refresh ${word} list`, callback: () => this._buildWidgets(), }); + options.push({ + content: "Log Switchboard diagnostics (console)", + callback: () => this._logDiagnostics(), + }); + } + + /** Dump everything needed to diagnose targeting / boolean-crossing issues. + * Open the browser console (F12) and copy the printed object. */ + _logDiagnostics() { + const graph = this._graph(); + const isNode = this.constructor.targetWord === "node"; + const info = { + node: this.constructor.nodeTitle, + inRootGraph: graph === app.graph, + graphCtor: graph && graph.constructor ? graph.constructor.name : null, + graphKeys: graph ? Object.keys(graph) : [], + subgraphInputNodeProp: + (graph && graph.inputNode && "inputNode") || + (graph && graph._inputNode && "_inputNode") || + (graph && graph.input_node && "input_node") || + null, + controls: [], + }; + for (const c of this.properties.controls) { + const slot = this.findInputSlot(c.label); + const input = slot >= 0 ? this.inputs?.[slot] : null; + const linkId = input ? input.link : null; + let origin = null; + const link = linkId != null ? linkById(graph, linkId) : null; + if (link) { + const o = nodeById(graph, link.origin_id); + if (o) { + origin = { + id: o.id, + type: o.type, + ctor: o.constructor ? o.constructor.name : null, + title: o.title, + isVirtualNode: o.isVirtualNode, + keys: Object.keys(o), + widgets: (o.widgets || []).map((w) => ({ name: w.name, value: w.value })), + isGraphInputNode: !!( + graph && + (o === graph.inputNode || o === graph._inputNode || o === graph.input_node) + ), + }; + } + } + let targetFound; + if (isNode) { + targetFound = !!( + graph?.getNodeById?.(c.key) || + (/^\d+$/.test(c.key) ? graph?.getNodeById?.(Number(c.key)) : null) + ); + } else { + targetFound = getGraphGroups(graph).some((g) => g.title === c.key); + } + info.controls.push({ + key: c.key, + label: c.label, + enabled: c.enabled, + targetFoundInThisGraph: targetFound, + inputSlot: slot, + hasLink: linkId != null, + booleanResolved: linkId != null ? this._readBooleanInput(slot) : null, + origin, + }); + } + console.log("[Switchboard] DIAGNOSTICS — copy this:", info); + return info; } }