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.
This commit is contained in:
Oratorian
2026-06-03 07:12:37 +02:00
parent d64badea85
commit 38b520ac45
2 changed files with 167 additions and 11 deletions
+7 -1
View File
@@ -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
+160 -10
View File
@@ -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;
}
}