1431 lines
53 KiB
JavaScript
1431 lines
53 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
||
|
||
/**
|
||
* Switchboard - Group / Node Controllers
|
||
* ---------------------------------------
|
||
* Client-side ("virtual") nodes, in the spirit of rgthree's Fast Group Bypasser
|
||
* but more flexible. Two node types share one base:
|
||
*
|
||
* - Group Controller -- targets are GROUPS (by title).
|
||
* - Node Controller -- targets are individual NODES (by id).
|
||
*
|
||
* For each target you get an on/off toggle (drivable by a wired BOOLEAN input)
|
||
* plus an independent "disable as" bypass/mute selector, a master broadcast
|
||
* toggle, and add/remove dropdowns.
|
||
*
|
||
* Enabling/disabling a target just changes the *mode* of the underlying node(s)
|
||
* -- a pure graph operation, applied immediately on change and again at queue
|
||
* time via applyToGraph() so the prompt that runs matches what you see.
|
||
*/
|
||
|
||
// LiteGraph node modes.
|
||
const MODE_ALWAYS = 0; // active / enabled
|
||
const MODE_NEVER = 2; // muted
|
||
const MODE_BYPASS = 4; // bypassed (route around)
|
||
|
||
const CATEGORY = "🎛️ Switchboard";
|
||
const DISABLE_MODES = ["bypass", "mute"];
|
||
// Recognised values from an older single tri-state shape, kept for migration.
|
||
const GROUP_STATES = ["enabled", "bypass", "mute"];
|
||
const MODE_WIDGET_LABEL = "↳ disable as";
|
||
const INVERT_WIDGET_LABEL = "⇄ input";
|
||
|
||
// Wired Controller patch. A custom socket type so a Wired Controller's output
|
||
// only connects to a Controller's patch input, plus the input slot label it
|
||
// lands on. ("Patch" as in patching a connection through a switchboard.)
|
||
const BUS_TYPE = "SWITCHBOARD_PATCH";
|
||
const BUS_INPUT_NAME = "↦ patch";
|
||
|
||
/** Resolve a control's LiteGraph node mode from its enabled flag + disable mode. */
|
||
function controlToMode(control) {
|
||
if (control.enabled) return MODE_ALWAYS;
|
||
return control.disableMode === "mute" ? MODE_NEVER : MODE_BYPASS;
|
||
}
|
||
|
||
// ---- group targets ---------------------------------------------------------
|
||
|
||
// NOTE: every helper takes the *node's own graph* (`this.graph`), not the root
|
||
// `app.graph`. A controller placed inside a subgraph must see that subgraph's
|
||
// groups/nodes/links -- using the root graph is what made subgraphs only list
|
||
// the outer graph's contents.
|
||
|
||
function getGraphGroups(graph) {
|
||
if (!graph) return [];
|
||
return graph._groups || graph.groups || [];
|
||
}
|
||
|
||
/** All group titles in `graph`, de-duplicated, as {key,label}. */
|
||
function getGroupChoices(graph) {
|
||
const seen = new Set();
|
||
const choices = [];
|
||
for (const group of getGraphGroups(graph)) {
|
||
if (group.title && !seen.has(group.title)) {
|
||
seen.add(group.title);
|
||
choices.push({ key: group.title, label: group.title });
|
||
}
|
||
}
|
||
return choices;
|
||
}
|
||
|
||
/** A node's rect as [x, y, w, h], title bar included. `boundingRect` is only
|
||
* filled in when a node is *rendered*, so it reads [0,0,0,0] for every node in
|
||
* a graph that isn't currently on screen; pos/size are always valid, so derive
|
||
* the rect from those when the cached one is degenerate. */
|
||
function nodeRect(node) {
|
||
const cached = node.boundingRect;
|
||
if (cached && (cached[2] || cached[3])) return cached;
|
||
const titleH = (typeof LiteGraph !== "undefined" && LiteGraph.NODE_TITLE_HEIGHT) || 30;
|
||
const width = (node.size && node.size[0]) || 0;
|
||
const height = node.flags && node.flags.collapsed ? 0 : (node.size && node.size[1]) || 0;
|
||
return [node.pos[0], node.pos[1] - titleH, width, height + titleH];
|
||
}
|
||
|
||
/** litegraph's own containment rule: a node is in a group when the centre of
|
||
* its rect falls inside the group's bounding box. */
|
||
function groupContains(bounding, node) {
|
||
const [x, y, w, h] = nodeRect(node);
|
||
const cx = x + w / 2;
|
||
const cy = y + h / 2;
|
||
return (
|
||
cx >= bounding[0] &&
|
||
cx <= bounding[0] + bounding[2] &&
|
||
cy >= bounding[1] &&
|
||
cy <= bounding[1] + bounding[3]
|
||
);
|
||
}
|
||
|
||
/** The nodes inside `group`, worked out ourselves rather than by asking
|
||
* litegraph. `group.recomputeInsideNodes()` matches on render-time bounding
|
||
* rects, so it returns an EMPTY set for any graph the user hasn't opened --
|
||
* e.g. a subgraph holding a Controller, which would then silently control
|
||
* nothing, queue time included. */
|
||
function nodesInGroup(graph, group) {
|
||
const bounding = group._bounding || group.bounding;
|
||
if (!bounding) return [];
|
||
return getGraphNodes(graph).filter((node) => groupContains(bounding, node));
|
||
}
|
||
|
||
/** Set the mode of every node inside `graph` groups whose title matches `key`. */
|
||
function applyModeToGroup(graph, key, mode) {
|
||
for (const group of getGraphGroups(graph)) {
|
||
if (group.title !== key) continue;
|
||
for (const node of nodesInGroup(graph, group)) node.mode = mode;
|
||
}
|
||
}
|
||
|
||
// ---- node targets ----------------------------------------------------------
|
||
|
||
function getGraphNodes(graph) {
|
||
if (!graph) return [];
|
||
return graph._nodes || graph.nodes || [];
|
||
}
|
||
|
||
/** Every targetable node in `graph` as {key:id, label:"id: Title"}, excluding
|
||
* `self` and any of our own controller nodes (you can't control a controller). */
|
||
function getNodeChoices(graph, self) {
|
||
const choices = [];
|
||
for (const node of getGraphNodes(graph)) {
|
||
if (node === self) continue;
|
||
if (node.constructor && node.constructor.isSwitchboardController) continue;
|
||
const name = node.title || node.type || "node";
|
||
choices.push({ key: String(node.id), label: `${node.id}: ${name}` });
|
||
}
|
||
return choices;
|
||
}
|
||
|
||
/** Set the mode of the `graph` node whose id matches `key`. Handles both numeric
|
||
* ids (classic graphs) and string ids (e.g. subgraph nodes under Nodes 2.0). */
|
||
function applyModeToNode(graph, key, mode) {
|
||
if (!graph || !graph.getNodeById) return;
|
||
let node = graph.getNodeById(key);
|
||
if (!node && /^\d+$/.test(key)) node = graph.getNodeById(Number(key));
|
||
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;
|
||
}
|
||
|
||
// ---- boolean resolution (shared) -------------------------------------------
|
||
//
|
||
// These are module functions (not methods) because both the Controllers and the
|
||
// Wired Controller resolve booleans the same way: follow a wire to its source,
|
||
// reading a constant on the front-end, and hopping across a subgraph boundary
|
||
// when the source is a subgraph input proxy. The reliable operation is reading
|
||
// in the node's OWN graph; cross-boundary hops are best-effort.
|
||
|
||
/** The widget on `node` most likely to hold a boolean constant. */
|
||
function boolWidgetOf(node) {
|
||
const widgets = (node && node.widgets) || [];
|
||
let w = widgets.find((x) => typeof x.value === "boolean");
|
||
if (!w) w = widgets.find((x) => /^(value|boolean|bool)$/i.test(x.name || ""));
|
||
return w || null;
|
||
}
|
||
|
||
/** The input slot backing `widget` on `node` -- the slot a widget-input link
|
||
* lands on when the widget is driven by a wire instead of typed in. */
|
||
function widgetInputSlot(node, widget) {
|
||
if (!node || !widget) return null;
|
||
for (const input of node.inputs || []) {
|
||
if (input && input.widget && input.widget.name === widget.name) return input;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** If `node`'s boolean widget is fed by a wire, the id of that link. Since
|
||
* ComfyUI's subgraph *widget promotion*, a driven widget's own `value` is
|
||
* never written back -- the live value sits upstream (on the promoting node),
|
||
* so the wire has to be followed rather than the stale local value read. */
|
||
function widgetInputLink(node) {
|
||
const input = widgetInputSlot(node, boolWidgetOf(node));
|
||
return input && input.link != null ? input.link : null;
|
||
}
|
||
|
||
/** Read a boolean constant off a node, or its cached output value. Returns null
|
||
* for a widget that's driven by a wire -- see `widgetInputLink`. */
|
||
function readBoolWidget(node, originSlot) {
|
||
const w = boolWidgetOf(node);
|
||
if (w) return widgetInputLink(node) != null ? null : !!w.value;
|
||
const out = node.outputs?.[originSlot];
|
||
if (out && typeof out._data !== "undefined") return !!out._data;
|
||
return null;
|
||
}
|
||
|
||
/** Resolve the inner graph hosted by a subgraph-instance node, tolerating the
|
||
* several shapes litegraph versions use (direct ref, registry by uuid). */
|
||
function subgraphOf(node) {
|
||
if (!node) return null;
|
||
if (node.subgraph) return node.subgraph;
|
||
if (node._subgraph) return node._subgraph;
|
||
const root = (node.graph && node.graph._rootGraph) || app.graph;
|
||
const reg = root && (root._subgraphs || root.subgraphs);
|
||
if (reg && typeof reg.get === "function") {
|
||
const id = node.subgraphId || node.type || node.properties?.subgraph;
|
||
const sub = id != null ? reg.get(id) : null;
|
||
if (sub) return sub._graph || sub.graph || sub;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** The id of the subgraph a node instantiates (its uuid), tolerating shapes. */
|
||
function subgraphIdOf(node) {
|
||
if (!node) return null;
|
||
if (node.subgraph && node.subgraph.id != null) return node.subgraph.id;
|
||
return node.subgraphId || node.properties?.subgraph || node.type || null;
|
||
}
|
||
|
||
/** Find the subgraph-instance node (in some parent graph) that hosts `graph`,
|
||
* i.e. the node you'd see in the parent whose inner graph IS `graph`. Matches
|
||
* by object identity AND by subgraph id (more robust across litegraph versions),
|
||
* searching from the root and any known root reference. */
|
||
function findSubgraphHost(graph) {
|
||
if (!graph) return { host: null, parentGraph: null };
|
||
const direct = graph._subgraph_node || graph.subgraphNode || graph._node || null;
|
||
if (direct) return { host: direct, parentGraph: direct.graph };
|
||
|
||
const wantId = graph.id;
|
||
const roots = [];
|
||
if (app.graph) roots.push(app.graph);
|
||
if (graph._rootGraph && !roots.includes(graph._rootGraph)) roots.push(graph._rootGraph);
|
||
const stack = [...roots];
|
||
const seen = new Set();
|
||
while (stack.length) {
|
||
const g = stack.pop();
|
||
if (!g || seen.has(g)) continue;
|
||
seen.add(g);
|
||
for (const n of g._nodes || g.nodes || []) {
|
||
const sub = subgraphOf(n);
|
||
if (sub === graph || (wantId != null && subgraphIdOf(n) === wantId)) {
|
||
return { host: n, parentGraph: g };
|
||
}
|
||
if (sub && !seen.has(sub)) stack.push(sub);
|
||
}
|
||
}
|
||
return { host: null, parentGraph: null };
|
||
}
|
||
|
||
/** Every link id leaving `node`'s output `slot`. Gathered robustly: the slot's
|
||
* own `links`/`_floatingLinks`, plus a scan of the graph's link collection
|
||
* (covers versions where the slot array isn't maintained the way we expect). */
|
||
function outgoingLinkIds(graph, node, slot) {
|
||
const ids = new Set();
|
||
const out = node.outputs?.[slot];
|
||
if (out) {
|
||
if (Array.isArray(out.links)) for (const id of out.links) if (id != null) ids.add(id);
|
||
if (Array.isArray(out._floatingLinks)) {
|
||
for (const fl of out._floatingLinks) {
|
||
const id = fl && (fl.id != null ? fl.id : fl);
|
||
if (id != null) ids.add(id);
|
||
}
|
||
}
|
||
}
|
||
const links = graph && graph.links;
|
||
if (links) {
|
||
const all = typeof links.values === "function" ? links.values() : Object.values(links);
|
||
for (const link of all) {
|
||
if (link && link.origin_id === node.id && link.origin_slot === slot && link.id != null) {
|
||
ids.add(link.id);
|
||
}
|
||
}
|
||
}
|
||
return [...ids];
|
||
}
|
||
|
||
/** Which of `graph`'s subgraph outputs does `node`/`slot` feed? Returns the
|
||
* output indices, read from the output proxy's input links and the subgraph's
|
||
* own `outputs[]` link records -- whichever the running litegraph populates. */
|
||
function subgraphOutputsFedBy(graph, node, slot) {
|
||
const res = new Set();
|
||
const proxy = graph.outputNode || graph._outputNode || graph.output_node;
|
||
if (proxy && Array.isArray(proxy.inputs)) {
|
||
for (let k = 0; k < proxy.inputs.length; k++) {
|
||
const linkId = proxy.inputs[k]?.link;
|
||
if (linkId == null) continue;
|
||
const link = linkById(graph, linkId);
|
||
if (link && link.origin_id === node.id && link.origin_slot === slot) res.add(k);
|
||
}
|
||
}
|
||
if (Array.isArray(graph.outputs)) {
|
||
for (let k = 0; k < graph.outputs.length; k++) {
|
||
const o = graph.outputs[k];
|
||
if (!o) continue;
|
||
const ids = [];
|
||
if (o.link != null) ids.push(o.link);
|
||
if (Array.isArray(o.linkIds)) ids.push(...o.linkIds);
|
||
if (Array.isArray(o._floatingLinks)) {
|
||
for (const fl of o._floatingLinks) {
|
||
const id = fl && (fl.id != null ? fl.id : fl);
|
||
if (id != null) ids.push(id);
|
||
}
|
||
}
|
||
for (const id of ids) {
|
||
const link = linkById(graph, id);
|
||
if (link && link.origin_id === node.id && link.origin_slot === slot) res.add(k);
|
||
}
|
||
}
|
||
}
|
||
return [...res];
|
||
}
|
||
|
||
/** If `origin` is `graph`'s subgraph-input proxy node, hop out to the parent.
|
||
* Returns `{graph, linkId}` when the matching input on the subgraph instance
|
||
* node is wired, or `{value}` when that input is instead exposed as a promoted
|
||
* widget on the instance (which holds the value itself -- no link to follow). */
|
||
function crossSubgraphInput(graph, origin, originSlot) {
|
||
try {
|
||
const inputProxy = graph.inputNode || graph._inputNode || graph.input_node;
|
||
if (!inputProxy || origin !== inputProxy) return null;
|
||
const { host, parentGraph } = findSubgraphHost(graph);
|
||
if (!host || !parentGraph) return null;
|
||
const parentInput = host.inputs?.[originSlot];
|
||
if (!parentInput) return null;
|
||
if (parentInput.link != null) return { graph: parentGraph, linkId: parentInput.link };
|
||
const promoted = parentInput.widget
|
||
? (host.widgets || []).find((w) => w.name === parentInput.widget.name)
|
||
: null;
|
||
if (promoted && typeof promoted.value === "boolean") return { value: !!promoted.value };
|
||
return null;
|
||
} catch (err) {
|
||
console.debug("[Switchboard] subgraph input crossing error", err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Walking an OUTPUT outward: if `target` is `graph`'s subgraph-output proxy
|
||
* node, return the parent graph + the host node + the matching output slot, so
|
||
* a walk can continue following the host's external output links. This is the
|
||
* child->parent direction, which (unlike descending into a child) is reliable. */
|
||
function crossOutputToParent(graph, target, targetSlot) {
|
||
try {
|
||
const outputProxy = graph.outputNode || graph._outputNode || graph.output_node;
|
||
if (!outputProxy || target !== outputProxy) return null;
|
||
const { host, parentGraph } = findSubgraphHost(graph);
|
||
if (!host || !parentGraph) return null;
|
||
return { graph: parentGraph, node: host, slot: targetSlot };
|
||
} catch (err) {
|
||
console.debug("[Switchboard] subgraph output crossing error", err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Walking an OUTPUT into a child: if `target` is a subgraph INSTANCE node and a
|
||
* link lands on its input `targetSlot`, descend into the inner graph and return
|
||
* its input proxy + matching output slot, so the walk continues toward whatever
|
||
* that subgraph input drives inside. (Reaching a sibling subgraph's contents.) */
|
||
function crossInputToChild(target, targetSlot) {
|
||
try {
|
||
const sub = subgraphOf(target);
|
||
if (!sub) return null;
|
||
const inputProxy = sub.inputNode || sub._inputNode || sub.input_node;
|
||
if (!inputProxy) return null;
|
||
return { graph: sub, node: inputProxy, slot: targetSlot };
|
||
} catch (err) {
|
||
console.debug("[Switchboard] subgraph input descend error", err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** A Group/Node Controller that can receive bus signals (not a Wired Controller). */
|
||
function isReceivingController(node) {
|
||
const c = node && node.constructor;
|
||
return !!(c && c.isSwitchboardController && !c.isWiredController);
|
||
}
|
||
|
||
/** If `origin` is a subgraph INSTANCE node and we tapped its output `originSlot`,
|
||
* descend into the inner graph and return the internal link feeding that output
|
||
* (which leads to whatever drives it inside the subgraph). */
|
||
function crossSubgraphOutput(origin, originSlot) {
|
||
try {
|
||
const sub = subgraphOf(origin);
|
||
if (!sub) return null;
|
||
const out = (sub.outputs && sub.outputs[originSlot]) || null;
|
||
if (!out) return null;
|
||
let linkId = out.link;
|
||
if (linkId == null && Array.isArray(out.linkIds)) linkId = out.linkIds[0];
|
||
if (linkId == null && Array.isArray(out._floatingLinks) && out._floatingLinks[0]) {
|
||
linkId = out._floatingLinks[0].id;
|
||
}
|
||
if (linkId == null) return null;
|
||
return { graph: sub, linkId };
|
||
} catch (err) {
|
||
console.debug("[Switchboard] subgraph output crossing error", err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Follow `linkId` in `graph` to a usable boolean. Returns true/false or null. */
|
||
function resolveBoolean(graph, linkId, depth) {
|
||
if (!graph || linkId == null || depth > 10) return null;
|
||
const link = linkById(graph, linkId);
|
||
if (!link) return null;
|
||
const origin = nodeById(graph, link.origin_id);
|
||
if (!origin) return null;
|
||
|
||
// 1) Source is a subgraph INSTANCE node -> descend to whatever drives its
|
||
// output. Tried first because such a node's widgets are *promoted* inner
|
||
// widgets that have nothing to do with the output slot we tapped, so
|
||
// reading one off it would pick an unrelated boolean.
|
||
const down = crossSubgraphOutput(origin, link.origin_slot);
|
||
if (down) {
|
||
const inner = resolveBoolean(down.graph, down.linkId, depth + 1);
|
||
if (inner !== null) return inner;
|
||
}
|
||
|
||
// 2) A constant boolean we can read directly on the front-end.
|
||
const direct = readBoolWidget(origin, link.origin_slot);
|
||
if (direct !== null) return direct;
|
||
|
||
// 3) The constant's widget is driven by a wire (a promoted or converted
|
||
// widget input) -> follow that wire; the local value is stale.
|
||
const viaWidget = widgetInputLink(origin);
|
||
if (viaWidget != null) return resolveBoolean(graph, viaWidget, depth + 1);
|
||
|
||
// 4) Source is a subgraph INPUT proxy -> hop out to the parent and keep going,
|
||
// or read the promoted widget standing in for an unwired input.
|
||
const up = crossSubgraphInput(graph, origin, link.origin_slot);
|
||
if (up) {
|
||
if (typeof up.value === "boolean") return up.value;
|
||
if (up.linkId != null) return resolveBoolean(up.graph, up.linkId, depth + 1);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ---- Wired Controller bus --------------------------------------------------
|
||
//
|
||
// Pub/sub registry keyed by channel name. This is the robust transport that
|
||
// needs NO cross-boundary graph traversal: a Wired Controller reads its booleans
|
||
// in its OWN graph (always reliable) and writes the resolved {label:bool} map
|
||
// here under a string key; receivers read by the same key. The wire (a
|
||
// SWITCHBOARD_BUS socket) is the convenient binding when link-traversal works;
|
||
// the channel name is the always-works fallback when it doesn't.
|
||
|
||
const SwitchboardBus = {
|
||
channels: new Map(), // channel name -> { label: bool } (Wired Controller -> Controllers)
|
||
demands: new Map(), // channel name -> Map(sourceKey -> { labels:[], ts }) (Controllers -> Wired Controller)
|
||
publish(channel, signals) {
|
||
if (channel) this.channels.set(channel, signals);
|
||
},
|
||
read(channel) {
|
||
return channel ? this.channels.get(channel) || null : null;
|
||
},
|
||
// A Controller advertises the target labels it wants driven, by channel. The
|
||
// Wired Controller reads the union to auto-create matching signals. Entries are
|
||
// timestamped so a Controller that disconnects (or changes channel) ages out.
|
||
demand(channel, sourceKey, labels, now) {
|
||
if (!channel) return;
|
||
let m = this.demands.get(channel);
|
||
if (!m) {
|
||
m = new Map();
|
||
this.demands.set(channel, m);
|
||
}
|
||
m.set(sourceKey, { labels: labels.slice(), ts: now });
|
||
},
|
||
demandedLabels(channel, now) {
|
||
const m = channel ? this.demands.get(channel) : null;
|
||
if (!m) return [];
|
||
const out = new Set();
|
||
for (const [key, entry] of [...m]) {
|
||
if (now - entry.ts > 1000) {
|
||
m.delete(key); // stale: source stopped advertising on this channel
|
||
continue;
|
||
}
|
||
for (const label of entry.labels) out.add(label);
|
||
}
|
||
return [...out];
|
||
},
|
||
};
|
||
|
||
/** Look up a signal in a {label:bool} map, exact first then case/space-tolerant. */
|
||
function busValueFor(signals, label) {
|
||
if (!signals || label == null) return null;
|
||
if (Object.prototype.hasOwnProperty.call(signals, label)) return !!signals[label];
|
||
const norm = (s) => String(s).trim().toLowerCase();
|
||
const want = norm(label);
|
||
for (const key of Object.keys(signals)) {
|
||
if (norm(key) === want) return !!signals[key];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Shared base. Subclasses provide three things via static/overridable members:
|
||
* - static targetWord ("group" | "node") for placeholder text
|
||
* - _allChoices() -> [{key,label}] of everything targetable right now
|
||
* - _applyTo(key, mode) applies a LiteGraph mode to that target
|
||
*/
|
||
class BaseControllerNode extends LGraphNode {
|
||
constructor(title) {
|
||
super(title);
|
||
|
||
// Virtual: never sent to the server as an executable node.
|
||
this.isVirtualNode = true;
|
||
// All state lives in `this.properties` (serialized automatically). Widgets
|
||
// are rebuilt from properties on load, so don't also persist widget values.
|
||
this.serialize_widgets = false;
|
||
|
||
if (!this.properties) this.properties = {};
|
||
// controls: array of { key, label, enabled: bool, disableMode: bypass|mute }
|
||
if (!this.properties.controls) this.properties.controls = [];
|
||
this._normalizeControls();
|
||
|
||
this.size = [260, 120];
|
||
this._buildWidgets();
|
||
}
|
||
|
||
get _addPlaceholder() {
|
||
return `➕ Add ${this.constructor.targetWord}…`;
|
||
}
|
||
|
||
get _removePlaceholder() {
|
||
return `➖ Remove ${this.constructor.targetWord}…`;
|
||
}
|
||
|
||
/** The graph this controller lives in -- the subgraph when nested, otherwise
|
||
* the root graph. Everything operates on this, never the root `app.graph`. */
|
||
_graph() {
|
||
return this.graph || app.graph;
|
||
}
|
||
|
||
// ---- overridable target hooks (defaults are inert) -----------------------
|
||
|
||
_allChoices() {
|
||
return [];
|
||
}
|
||
|
||
_applyTo(_key, _mode) {}
|
||
|
||
// ---- migration / setup ---------------------------------------------------
|
||
|
||
/** Backfill defaults and migrate older shapes so saved workflows keep working.
|
||
* Target shape: { key, label, enabled, disableMode }. */
|
||
_normalizeControls() {
|
||
for (const c of this.properties.controls) {
|
||
// Old Group Controller shape used `title` for both key and label.
|
||
if (c.key == null && c.title != null) c.key = c.title;
|
||
if (c.label == null) c.label = c.title != null ? c.title : c.key;
|
||
if (!DISABLE_MODES.includes(c.disableMode)) c.disableMode = "bypass";
|
||
if (typeof c.enabled !== "boolean") {
|
||
// Migrate from the single tri-state { state } shape.
|
||
if (GROUP_STATES.includes(c.state)) {
|
||
c.enabled = c.state === "enabled";
|
||
if (c.state !== "enabled") c.disableMode = c.state;
|
||
} else {
|
||
c.enabled = true;
|
||
}
|
||
}
|
||
if (typeof c.invert !== "boolean") c.invert = false;
|
||
delete c.state;
|
||
delete c.title;
|
||
}
|
||
}
|
||
|
||
_availableChoices() {
|
||
const used = new Set(this.properties.controls.map((c) => c.key));
|
||
return this._allChoices().filter((choice) => !used.has(choice.key));
|
||
}
|
||
|
||
// ---- widget construction -------------------------------------------------
|
||
|
||
_buildWidgets() {
|
||
this.widgets = [];
|
||
|
||
// 1) Master broadcast: clicking it forces every target on/off. It does NOT
|
||
// track individual or boolean-driven changes -- it's an action switch.
|
||
// When disabling, each target uses its own bypass/mute mode.
|
||
this.addWidget(
|
||
"toggle",
|
||
"ALL",
|
||
this._allEnabled(),
|
||
(value) => {
|
||
const signals = this._resolveBusSignals();
|
||
for (const control of this.properties.controls) {
|
||
// A connected input (direct boolean or Wired Controller signal) governs
|
||
// its target -- the master broadcast / manual toggling is overridden by
|
||
// it, so skip those here.
|
||
if (this._isGoverned(control, signals)) continue;
|
||
control.enabled = value;
|
||
}
|
||
this._syncToggles();
|
||
this.applyAll();
|
||
},
|
||
{ on: "enabled", off: "disabled" },
|
||
);
|
||
|
||
// 1b) Optional Wired Controller channel. Leave blank to bind only by wire
|
||
// (or not at all); set it to the channel a Wired Controller publishes on
|
||
// to follow it by name -- works across any subgraph nesting.
|
||
this.addWidget(
|
||
"text",
|
||
"channel",
|
||
this.properties.busChannel || "",
|
||
(value) => {
|
||
this.properties.busChannel = (value || "").trim();
|
||
this._pollInputs();
|
||
},
|
||
{},
|
||
);
|
||
|
||
// 2) Per target: a divider line, then on/off toggle (boolean-driven) +
|
||
// "disable as" + invert. The divider keeps each target's three rows from
|
||
// blurring into the next group's (and separates them from the header).
|
||
for (const control of this.properties.controls) {
|
||
this._addDivider();
|
||
this._addControlWidgets(control);
|
||
}
|
||
// Close the last group off so the add/remove controls read as their own row.
|
||
if (this.properties.controls.length) this._addDivider();
|
||
|
||
// 3) "Add" combo -- lists targets not yet controlled.
|
||
this.addWidget(
|
||
"combo",
|
||
"add",
|
||
this._addPlaceholder,
|
||
(value) => {
|
||
const choice = this._availableChoices().find((c) => c.label === value);
|
||
if (choice) this.addTarget(choice.key, choice.label);
|
||
},
|
||
{ values: () => [this._addPlaceholder, ...this._availableChoices().map((c) => c.label)] },
|
||
);
|
||
|
||
// 4) "Remove" combo -- only when there's something to remove.
|
||
if (this.properties.controls.length) {
|
||
this.addWidget(
|
||
"combo",
|
||
"remove",
|
||
this._removePlaceholder,
|
||
(value) => {
|
||
const control = this.properties.controls.find((c) => c.label === value);
|
||
if (control) this.removeTarget(control.key);
|
||
},
|
||
{ values: () => [this._removePlaceholder, ...this.properties.controls.map((c) => c.label)] },
|
||
);
|
||
}
|
||
|
||
this._refreshAddWidget();
|
||
this._syncInputs();
|
||
// Grow to fit the widgets (e.g. the per-target invert row on saved nodes).
|
||
if (this.properties.controls.length) {
|
||
this.size[1] = Math.max(this.size[1], this.computeSize()[1]);
|
||
}
|
||
this.setDirtyCanvas(true, true);
|
||
}
|
||
|
||
/** A thin horizontal separator widget pushed between target groups. It's a
|
||
* custom draw-only widget: it renders a line on the legacy canvas and is
|
||
* inert/non-interactive. (Under the Nodes 2.0 / Vue renderer custom draws
|
||
* aren't shown, so it simply collapses to a small gap there -- harmless.) */
|
||
_addDivider() {
|
||
const widget = {
|
||
type: "switchboard_divider",
|
||
name: "",
|
||
value: "",
|
||
// Slim row; LiteGraph uses this to allocate height.
|
||
computeSize(width) {
|
||
return [width, 8];
|
||
},
|
||
draw(ctx, node, width, y, H) {
|
||
const margin = 10;
|
||
const lineY = Math.round(y + H * 0.5) + 0.5;
|
||
ctx.save();
|
||
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR || "rgba(255,255,255,0.2)";
|
||
ctx.globalAlpha = 0.5;
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(margin, lineY);
|
||
ctx.lineTo(width - margin, lineY);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
},
|
||
};
|
||
this.widgets.push(widget);
|
||
return widget;
|
||
}
|
||
|
||
_addControlWidgets(control) {
|
||
// On/off toggle -- this is what a wired BOOLEAN input drives.
|
||
const toggle = this.addWidget(
|
||
"toggle",
|
||
control.label,
|
||
control.enabled,
|
||
(value) => {
|
||
control.enabled = value;
|
||
this.applyControl(control);
|
||
},
|
||
{ on: "enabled", off: "disabled" },
|
||
);
|
||
toggle._controlKey = control.key;
|
||
|
||
// Independent "disable as" selector -- stays editable even while a boolean
|
||
// holds the target enabled, so you can pre-set bypass vs mute per target.
|
||
this.addWidget(
|
||
"combo",
|
||
MODE_WIDGET_LABEL,
|
||
control.disableMode,
|
||
(value) => {
|
||
control.disableMode = value;
|
||
this.applyControl(control); // re-applies immediately if currently disabled
|
||
},
|
||
{ values: DISABLE_MODES },
|
||
);
|
||
|
||
// Per-target input inversion -- flips the value coming from a wired boolean
|
||
// or a Wired Controller patch signal, so one input can drive two targets to
|
||
// OPPOSITE states (e.g. group A on while group B off). No effect on a
|
||
// manually-toggled target (there's no input to invert). NOTE: no _controlKey,
|
||
// so _syncToggles() leaves this user setting alone.
|
||
this.addWidget(
|
||
"toggle",
|
||
INVERT_WIDGET_LABEL,
|
||
!!control.invert,
|
||
(value) => {
|
||
control.invert = value;
|
||
this._pollInputs(); // re-read inputs so the (inverted) value applies now
|
||
},
|
||
{ on: "inverted", off: "normal" },
|
||
);
|
||
}
|
||
|
||
// ---- helpers -------------------------------------------------------------
|
||
|
||
_allEnabled() {
|
||
const controls = this.properties.controls;
|
||
return controls.length > 0 && controls.every((c) => c.enabled);
|
||
}
|
||
|
||
_syncToggles() {
|
||
// Only the on/off toggles mirror state; the "disable as" combos are
|
||
// user-owned and never driven by inputs, so they're left alone.
|
||
for (const control of this.properties.controls) {
|
||
const widget = this.widgets?.find((w) => w._controlKey === control.key);
|
||
if (widget) widget.value = control.enabled;
|
||
}
|
||
}
|
||
|
||
_refreshAddWidget() {
|
||
const addWidget = this.widgets?.find((w) => w.name === "add");
|
||
if (addWidget) addWidget.value = this._addPlaceholder;
|
||
}
|
||
|
||
// ---- boolean inputs ------------------------------------------------------
|
||
|
||
/** Ensure exactly one BOOLEAN input per control, matched by label. Reconciles
|
||
* by name so existing slots (and their links) survive reload. */
|
||
_syncInputs() {
|
||
const wanted = this.properties.controls.map((c) => c.label);
|
||
for (let i = (this.inputs?.length || 0) - 1; i >= 0; i--) {
|
||
const name = this.inputs[i].name;
|
||
if (name === BUS_INPUT_NAME) continue; // keep the Wired Controller bus input
|
||
if (!wanted.includes(name)) this.removeInput(i);
|
||
}
|
||
// One bus input so a Wired Controller can be wired straight in.
|
||
if (this.findInputSlot(BUS_INPUT_NAME) === -1) this.addInput(BUS_INPUT_NAME, BUS_TYPE);
|
||
for (const label of wanted) {
|
||
if (this.findInputSlot(label) === -1) this.addInput(label, "BOOLEAN");
|
||
}
|
||
}
|
||
|
||
/** Read the boolean coming into `slot`. Follows the wire to its source,
|
||
* hopping across subgraph boundaries as needed. true/false, or null if
|
||
* nothing usable is connected. */
|
||
_readBooleanInput(slot) {
|
||
const input = this.inputs?.[slot];
|
||
if (!input || input.link == null) return null;
|
||
return resolveBoolean(this._graph(), input.link, 0);
|
||
}
|
||
|
||
/** A bound Wired Controller pushes its resolved signals onto us (over the wire,
|
||
* reaching across subgraph boundaries in the reliable child->parent direction).
|
||
* Stored per source so several Wired Controllers can fan in. */
|
||
_receivePushedSignals(sourceKey, signals) {
|
||
if (!this._pushed) this._pushed = new Map();
|
||
this._pushed.set(sourceKey, { signals, ts: Date.now() });
|
||
}
|
||
|
||
/** Resolve the {label:bool} signal map driving this controller: the union of
|
||
* every fresh pushed map (by wire) plus the subscribed channel. null when
|
||
* nothing is connected. Pushed (wire) values win over the channel on conflict. */
|
||
_resolveBusSignals() {
|
||
const now = Date.now();
|
||
let merged = null;
|
||
const channel = SwitchboardBus.read(this.properties.busChannel);
|
||
if (channel) merged = Object.assign({}, channel);
|
||
if (this._pushed) {
|
||
for (const [key, entry] of [...this._pushed]) {
|
||
if (now - entry.ts > 1000) {
|
||
this._pushed.delete(key); // source stopped pushing -> drop it
|
||
continue;
|
||
}
|
||
merged = Object.assign(merged || {}, entry.signals);
|
||
}
|
||
}
|
||
return merged && Object.keys(merged).length ? merged : null;
|
||
}
|
||
|
||
/** Stable per-session key identifying this controller as a demand source. */
|
||
_busKey() {
|
||
if (!this.__busKey) this.__busKey = `${this.id}:${Math.floor(Math.random() * 1e9)}`;
|
||
return this.__busKey;
|
||
}
|
||
|
||
/** Advertise our target labels on the channel so a Wired Controller bound only
|
||
* by name (no resolvable wire) can still auto-create matching signals. Wire
|
||
* binding needs nothing here -- the Wired Controller pulls our labels directly. */
|
||
_advertiseToBus() {
|
||
if (!this.properties.busChannel) return;
|
||
const labels = this.properties.controls.map((c) => c.label);
|
||
SwitchboardBus.demand(this.properties.busChannel, this._busKey(), labels, Date.now());
|
||
}
|
||
|
||
/** True when a Wired Controller bus signal covers this control's label. */
|
||
_busGoverns(control, signals) {
|
||
const sig = signals !== undefined ? signals : this._resolveBusSignals();
|
||
return busValueFor(sig, control.label) !== null;
|
||
}
|
||
|
||
/** True when this control has a direct BOOLEAN wired in. */
|
||
_hasBooleanInput(control) {
|
||
const slot = this.findInputSlot(control.label);
|
||
if (slot === -1) return false;
|
||
const input = this.inputs?.[slot];
|
||
return !!(input && input.link != null);
|
||
}
|
||
|
||
/** True when the target is driven by an input (direct boolean or bus signal)
|
||
* and so should ignore the master broadcast / manual toggling. */
|
||
_isGoverned(control, signals) {
|
||
return this._hasBooleanInput(control) || this._busGoverns(control, signals);
|
||
}
|
||
|
||
/** Pull every connected input into its control's enabled state. A bus signal
|
||
* (by label) drives the target; a direct per-target boolean overrides it. */
|
||
_refreshFromInputs() {
|
||
let changed = false;
|
||
const bus = this._resolveBusSignals();
|
||
for (const control of this.properties.controls) {
|
||
let value = null;
|
||
// 1) Wired Controller signal, matched by label.
|
||
const fromBus = busValueFor(bus, control.label);
|
||
if (fromBus !== null) value = fromBus;
|
||
// 2) A direct boolean on the target's own socket wins over the bus.
|
||
const slot = this.findInputSlot(control.label);
|
||
if (slot !== -1) {
|
||
const direct = this._readBooleanInput(slot);
|
||
if (direct !== null) value = direct;
|
||
}
|
||
if (value === null) continue; // unwired -> leave manual toggle alone
|
||
if (control.invert) value = !value; // flip this target relative to the input
|
||
// Inputs drive only on/off; the target keeps its own disable mode.
|
||
if (control.enabled !== value) {
|
||
control.enabled = value;
|
||
changed = true;
|
||
}
|
||
}
|
||
if (changed) this._syncToggles();
|
||
return changed;
|
||
}
|
||
|
||
_pollInputs() {
|
||
this._advertiseToBus();
|
||
if (this._refreshFromInputs()) {
|
||
this.applyAll();
|
||
this.setDirtyCanvas(true, true);
|
||
}
|
||
}
|
||
|
||
// ---- public actions ------------------------------------------------------
|
||
|
||
addTarget(key, label) {
|
||
if (this.properties.controls.some((c) => c.key === key)) return;
|
||
const control = { key, label, enabled: true, disableMode: "bypass", invert: false };
|
||
this.properties.controls.push(control);
|
||
// Rebuild so the toggle + disable-as combo, BOOLEAN input and "remove" combo
|
||
// all appear at once. _buildWidgets() -> _syncInputs() adds the input slot.
|
||
this._buildWidgets();
|
||
this.applyControl(control);
|
||
this.size[1] = Math.max(this.size[1], this.computeSize()[1]);
|
||
this.setDirtyCanvas(true, true);
|
||
}
|
||
|
||
removeTarget(key) {
|
||
const control = this.properties.controls.find((c) => c.key === key);
|
||
this.properties.controls = this.properties.controls.filter((c) => c.key !== key);
|
||
// Re-enable the target so removing the controller doesn't strand it disabled.
|
||
if (control) this._applyTo(key, MODE_ALWAYS);
|
||
this._buildWidgets();
|
||
}
|
||
|
||
applyControl(control) {
|
||
this._applyTo(control.key, controlToMode(control));
|
||
this.setDirtyCanvas(true, true);
|
||
}
|
||
|
||
applyAll() {
|
||
for (const control of this.properties.controls) this.applyControl(control);
|
||
}
|
||
|
||
// ---- ComfyUI / LiteGraph hooks ------------------------------------------
|
||
|
||
/** Called by ComfyUI during graphToPrompt (queue time). Re-reads any wired
|
||
* boolean inputs, then ensures the running graph matches the toggles. */
|
||
applyToGraph() {
|
||
this._refreshFromInputs();
|
||
this.applyAll();
|
||
}
|
||
|
||
onAdded() {
|
||
this.applyAll();
|
||
// Live-track wired boolean inputs with a lightweight timer instead of a
|
||
// canvas draw hook. Draw-based hooks (onDrawForeground) don't fire under the
|
||
// Nodes 2.0 / Vue renderer; a timer works in both the legacy canvas and Vue.
|
||
if (!this._pollTimer) {
|
||
this._pollTimer = setInterval(() => this._pollInputs(), 200);
|
||
}
|
||
}
|
||
|
||
onRemoved() {
|
||
if (this._pollTimer) {
|
||
clearInterval(this._pollTimer);
|
||
this._pollTimer = null;
|
||
}
|
||
}
|
||
|
||
onConfigure() {
|
||
if (!this.properties.controls) this.properties.controls = [];
|
||
this._normalizeControls();
|
||
this._buildWidgets();
|
||
// Defer applying until the rest of the graph (groups/nodes) exists.
|
||
setTimeout(() => {
|
||
this._refreshFromInputs();
|
||
this.applyAll();
|
||
}, 0);
|
||
}
|
||
|
||
/** Connecting/disconnecting a boolean input takes effect immediately. */
|
||
onConnectionsChange(type) {
|
||
if (type === LiteGraph.INPUT) this._pollInputs();
|
||
}
|
||
|
||
getExtraMenuOptions(_, options) {
|
||
// IMPORTANT: push into `options` and DO NOT return it. ComfyUI's LiteGraph
|
||
// does `options = extra.concat(options)` when this returns truthy -- returning
|
||
// the mutated array would duplicate the ENTIRE context menu.
|
||
const word = this.constructor.targetWord;
|
||
if (this.properties.controls.length) {
|
||
options.push({
|
||
content: `Remove controlled ${word}`,
|
||
has_submenu: true,
|
||
submenu: {
|
||
options: this.properties.controls.map((c) => ({
|
||
content: c.label,
|
||
callback: () => this.removeTarget(c.key),
|
||
})),
|
||
},
|
||
});
|
||
}
|
||
options.push({
|
||
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,
|
||
busChannel: this.properties.busChannel || "",
|
||
busSignals: this._resolveBusSignals(),
|
||
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,
|
||
busValue: busValueFor(info.busSignals, c.label),
|
||
origin,
|
||
});
|
||
}
|
||
console.log("[Switchboard] DIAGNOSTICS - copy this:", info);
|
||
return info;
|
||
}
|
||
}
|
||
|
||
// ---- concrete controllers --------------------------------------------------
|
||
|
||
class GroupControllerNode extends BaseControllerNode {
|
||
static targetWord = "group";
|
||
static isSwitchboardController = true;
|
||
|
||
constructor(title = GroupControllerNode.nodeTitle) {
|
||
super(title);
|
||
}
|
||
|
||
_allChoices() {
|
||
return getGroupChoices(this._graph());
|
||
}
|
||
|
||
_applyTo(key, mode) {
|
||
applyModeToGroup(this._graph(), key, mode);
|
||
}
|
||
}
|
||
GroupControllerNode.nodeTitle = "Group Controller";
|
||
|
||
class NodeControllerNode extends BaseControllerNode {
|
||
static targetWord = "node";
|
||
static isSwitchboardController = true;
|
||
|
||
constructor(title = NodeControllerNode.nodeTitle) {
|
||
super(title);
|
||
}
|
||
|
||
_allChoices() {
|
||
return getNodeChoices(this._graph(), this);
|
||
}
|
||
|
||
_applyTo(key, mode) {
|
||
applyModeToNode(this._graph(), key, mode);
|
||
}
|
||
}
|
||
NodeControllerNode.nodeTitle = "Node Controller";
|
||
|
||
// ---- Wired Controller (source) ---------------------------------------------
|
||
|
||
/**
|
||
* Wired Controller -- a *source* that lives next to your booleans (typically
|
||
* inside a subgraph) and relays them to one or more Group/Node Controllers.
|
||
*
|
||
* It reads each of its boolean inputs in its OWN graph (the reliable operation),
|
||
* caches the resolved {label:bool} map, and makes it available two ways:
|
||
* - on its `patch` output, so you can wire it across subgraph IO into a
|
||
* Controller's bus input (the wire is followed back to this node's cache);
|
||
* - on a named `channel`, published to a global registry a Controller can
|
||
* subscribe to by name -- no cross-boundary wiring needed at all.
|
||
*
|
||
* A receiving Controller applies each signal to the target whose label matches.
|
||
* One Wired Controller can fan out to many Controllers. This keeps a subgraph
|
||
* tidy: just the booleans + one Wired Controller, with the actual targets held
|
||
* by Controllers out in the parent graph.
|
||
*/
|
||
class WiredControllerNode extends LGraphNode {
|
||
static nodeTitle = "Wired Controller";
|
||
static isSwitchboardController = true; // Controllers won't target our own nodes
|
||
static isWiredController = true;
|
||
|
||
constructor(title = WiredControllerNode.nodeTitle) {
|
||
super(title);
|
||
this.isVirtualNode = true;
|
||
this.serialize_widgets = false;
|
||
|
||
if (!this.properties) this.properties = {};
|
||
if (typeof this.properties.channel !== "string") this.properties.channel = "";
|
||
// signals: array of { label } -- one boolean input each, matched on receivers.
|
||
if (!Array.isArray(this.properties.signals)) this.properties.signals = [];
|
||
|
||
this._signals = {}; // resolved cache {label:bool}, kept fresh by the timer
|
||
this.size = [240, 110];
|
||
|
||
this._ensurePatchOutput();
|
||
this._buildWidgets();
|
||
}
|
||
|
||
_graph() {
|
||
return this.graph || app.graph;
|
||
}
|
||
|
||
/** Ensure the single output is the patch bus, normalising nodes saved before
|
||
* the rename (output was "signals" / type "SWITCHBOARD_BUS"). */
|
||
_ensurePatchOutput() {
|
||
if (!this.outputs || !this.outputs.length) {
|
||
this.addOutput("patch", BUS_TYPE);
|
||
return;
|
||
}
|
||
const out = this.outputs[0];
|
||
out.name = "patch";
|
||
out.type = BUS_TYPE;
|
||
delete out.label; // drop any stale display label so `name` shows
|
||
}
|
||
|
||
_buildWidgets() {
|
||
this.widgets = [];
|
||
|
||
// The channel this node publishes on (optional -- wiring works without it).
|
||
this.addWidget(
|
||
"text",
|
||
"channel",
|
||
this.properties.channel,
|
||
(value) => {
|
||
this.properties.channel = (value || "").trim();
|
||
},
|
||
{},
|
||
);
|
||
|
||
// One read-only-ish readout per signal: reflects the resolved boolean. It's
|
||
// input-driven, so clicking just reverts on the next poll. Signals are added
|
||
// and removed automatically to mirror the Controllers this node feeds.
|
||
for (const signal of this.properties.signals) {
|
||
const w = this.addWidget(
|
||
"toggle",
|
||
signal.label,
|
||
!!this._signals[signal.label],
|
||
() => this._poll(),
|
||
{ on: "true", off: "false" },
|
||
);
|
||
w._signalLabel = signal.label;
|
||
}
|
||
|
||
this._syncInputs();
|
||
this.setDirtyCanvas(true, true);
|
||
}
|
||
|
||
/** One BOOLEAN input per signal, reconciled by name so links survive reload. */
|
||
_syncInputs() {
|
||
const wanted = this.properties.signals.map((s) => s.label);
|
||
for (let i = (this.inputs?.length || 0) - 1; i >= 0; i--) {
|
||
if (!wanted.includes(this.inputs[i].name)) this.removeInput(i);
|
||
}
|
||
for (const label of wanted) {
|
||
if (this.findInputSlot(label) === -1) this.addInput(label, "BOOLEAN");
|
||
}
|
||
}
|
||
|
||
/** Find every Controller this node feeds, by walking its `patch` output
|
||
* outward and crossing subgraph-output boundaries (child->parent, the reliable
|
||
* direction). Same-graph receivers are found directly. */
|
||
_collectBusTargets() {
|
||
const acc = new Set();
|
||
this._walkBusOutput(this._graph(), this, 0, 0, acc, new Set());
|
||
return [...acc];
|
||
}
|
||
|
||
_walkBusOutput(graph, node, slot, depth, acc, visited) {
|
||
if (!graph || !node || depth > 10) return;
|
||
const key = `${graph.id != null ? graph.id : "root"}:${node.id}:${slot}`;
|
||
if (visited.has(key)) return;
|
||
visited.add(key);
|
||
|
||
// a) Direct links out of this slot, in this graph.
|
||
for (const linkId of outgoingLinkIds(graph, node, slot)) {
|
||
const link = linkById(graph, linkId);
|
||
if (!link) continue;
|
||
const target = nodeById(graph, link.target_id);
|
||
if (!target) continue;
|
||
if (isReceivingController(target)) {
|
||
acc.add(target);
|
||
continue;
|
||
}
|
||
// Hit the subgraph's output proxy -> hop to the host's output in the parent.
|
||
const up = crossOutputToParent(graph, target, link.target_slot);
|
||
if (up) {
|
||
this._walkBusOutput(up.graph, up.node, up.slot, depth + 1, acc, visited);
|
||
continue;
|
||
}
|
||
// Landed on a subgraph instance's input -> descend and keep walking inside,
|
||
// so a controller living in a sibling/nested subgraph is still reached.
|
||
const into = crossInputToChild(target, link.target_slot);
|
||
if (into) this._walkBusOutput(into.graph, into.node, into.slot, depth + 1, acc, visited);
|
||
}
|
||
|
||
// b) Boundary crossing by reverse lookup: whichever of this graph's outputs
|
||
// this slot feeds, continue from the host's matching output in the parent.
|
||
// (Covers connections to the subgraph output not captured by the link scan.)
|
||
for (const k of subgraphOutputsFedBy(graph, node, slot)) {
|
||
const { host, parentGraph } = findSubgraphHost(graph);
|
||
if (host && parentGraph) this._walkBusOutput(parentGraph, host, k, depth + 1, acc, visited);
|
||
}
|
||
}
|
||
|
||
/** Mirror the signal list to the set of labels the bound Controllers demand:
|
||
* add a signal per new label, drop one no longer demanded UNLESS a boolean is
|
||
* still wired into it (don't strand the user's wiring). Returns true on change. */
|
||
_reconcileAutoSignals(demanded) {
|
||
let changed = false;
|
||
for (const label of demanded) {
|
||
if (!this.properties.signals.some((s) => s.label === label)) {
|
||
this.properties.signals.push({ label });
|
||
changed = true;
|
||
}
|
||
}
|
||
for (let i = this.properties.signals.length - 1; i >= 0; i--) {
|
||
const signal = this.properties.signals[i];
|
||
if (demanded.has(signal.label)) continue;
|
||
const slot = this.findInputSlot(signal.label);
|
||
const wired = slot !== -1 && this.inputs?.[slot]?.link != null;
|
||
if (wired) continue; // keep: a boolean is wired in, don't strand it
|
||
this.properties.signals.splice(i, 1);
|
||
changed = true;
|
||
}
|
||
return changed;
|
||
}
|
||
|
||
/** Stable per-session key identifying this Wired Controller as a signal source. */
|
||
_busKey() {
|
||
if (!this.__busKey) this.__busKey = `${this.id}:${Math.floor(Math.random() * 1e9)}`;
|
||
return this.__busKey;
|
||
}
|
||
|
||
/** Drive everything: discover the Controllers we feed, mirror their targets as
|
||
* inputs, resolve our booleans locally, then deliver the result. */
|
||
_poll() {
|
||
const graph = this._graph();
|
||
|
||
// 1) Find the Controllers we feed and pull their target labels as demand.
|
||
const targets = this._collectBusTargets();
|
||
const demanded = new Set();
|
||
for (const target of targets) {
|
||
for (const control of target.properties.controls || []) demanded.add(control.label);
|
||
}
|
||
for (const label of SwitchboardBus.demandedLabels(this.properties.channel, Date.now())) {
|
||
demanded.add(label);
|
||
}
|
||
|
||
// 2) Mirror the input list to that demand (auto add/prune).
|
||
if (this._reconcileAutoSignals(demanded)) {
|
||
this._buildWidgets();
|
||
this.size[1] = Math.max(this.size[1], this.computeSize()[1]);
|
||
}
|
||
|
||
// 3) Resolve every wired boolean locally -- always reliable, it's our graph.
|
||
const signals = {};
|
||
for (const signal of this.properties.signals) {
|
||
const slot = this.findInputSlot(signal.label);
|
||
if (slot === -1) continue;
|
||
const input = this.inputs?.[slot];
|
||
if (!input || input.link == null) continue; // unwired -> omit, leave target manual
|
||
const value = resolveBoolean(graph, input.link, 0);
|
||
if (value !== null) signals[signal.label] = value;
|
||
}
|
||
this._signals = signals;
|
||
if (this.outputs?.[0]) this.outputs[0]._data = signals;
|
||
|
||
// 4) Deliver: push straight onto each bound Controller (wire) + publish (channel).
|
||
for (const target of targets) {
|
||
if (typeof target._receivePushedSignals === "function") {
|
||
target._receivePushedSignals(this._busKey(), signals);
|
||
}
|
||
}
|
||
SwitchboardBus.publish(this.properties.channel, signals);
|
||
|
||
// Mirror onto the readout toggles for visual feedback.
|
||
let dirty = false;
|
||
for (const w of this.widgets || []) {
|
||
if (!w._signalLabel) continue;
|
||
const v = !!signals[w._signalLabel];
|
||
if (w.value !== v) {
|
||
w.value = v;
|
||
dirty = true;
|
||
}
|
||
}
|
||
if (dirty) this.setDirtyCanvas(true, true);
|
||
}
|
||
|
||
getExtraMenuOptions(_, options) {
|
||
options.push({
|
||
content: "Log Wired Controller diagnostics (console)",
|
||
callback: () => this._logDiagnostics(),
|
||
});
|
||
}
|
||
|
||
/** Dump the output-walk so a failing wire binding can be diagnosed. Open the
|
||
* browser console (F12), right-click the node, run this, and copy the object. */
|
||
_logDiagnostics() {
|
||
const graph = this._graph();
|
||
const out0 = this.outputs?.[0] || {};
|
||
const host = findSubgraphHost(graph);
|
||
const targets = this._collectBusTargets();
|
||
const describe = (n) =>
|
||
n ? { id: n.id, type: n.type, ctor: n.constructor?.name, title: n.title } : null;
|
||
const info = {
|
||
node: WiredControllerNode.nodeTitle,
|
||
channel: this.properties.channel || "",
|
||
inRootGraph: graph === app.graph,
|
||
graphCtor: graph?.constructor?.name || null,
|
||
graphId: graph?.id ?? null,
|
||
output: {
|
||
type: out0.type,
|
||
rawLinks: out0.links || null,
|
||
floatingLinks: out0._floatingLinks ? out0._floatingLinks.length : 0,
|
||
},
|
||
outgoingLinkIds: outgoingLinkIds(graph, this, 0),
|
||
subgraphOutputsFedBy: subgraphOutputsFedBy(graph, this, 0),
|
||
hasOutputProxy: !!(graph?.outputNode || graph?._outputNode || graph?.output_node),
|
||
foundHost: describe(host.host),
|
||
hostParentGraphCtor: host.parentGraph?.constructor?.name || null,
|
||
hostOutputs: host.host ? (host.host.outputs || []).map((o) => ({ name: o.name, links: o.links || null })) : null,
|
||
hostOutputResolved:
|
||
host.host && host.parentGraph
|
||
? (host.host.outputs || []).map((o, slot) => ({
|
||
slot,
|
||
name: o.name,
|
||
targets: outgoingLinkIds(host.parentGraph, host.host, slot).map((id) => {
|
||
const l = linkById(host.parentGraph, id);
|
||
const t = l ? nodeById(host.parentGraph, l.target_id) : null;
|
||
return t
|
||
? {
|
||
...describe(t),
|
||
slot: l.target_slot,
|
||
isController: isReceivingController(t),
|
||
isSubgraphInstance: !!subgraphOf(t),
|
||
}
|
||
: { unresolvedLink: id };
|
||
}),
|
||
}))
|
||
: null,
|
||
collectedTargets: targets.map(describe),
|
||
signals: this._signals,
|
||
};
|
||
console.log("[Switchboard] WIRED DIAGNOSTICS - copy this:", info);
|
||
return info;
|
||
}
|
||
|
||
onAdded() {
|
||
this._poll();
|
||
if (!this._pollTimer) this._pollTimer = setInterval(() => this._poll(), 200);
|
||
}
|
||
|
||
onRemoved() {
|
||
if (this._pollTimer) {
|
||
clearInterval(this._pollTimer);
|
||
this._pollTimer = null;
|
||
}
|
||
}
|
||
|
||
onConfigure() {
|
||
if (!Array.isArray(this.properties.signals)) this.properties.signals = [];
|
||
this._ensurePatchOutput(); // rename the output on older saved nodes
|
||
this._buildWidgets();
|
||
setTimeout(() => this._poll(), 0);
|
||
}
|
||
|
||
// Re-poll on any wiring change: an INPUT change is a new boolean to read; an
|
||
// OUTPUT change is a Controller wired in/out, so discovery must re-run.
|
||
onConnectionsChange() {
|
||
this._poll();
|
||
}
|
||
}
|
||
WiredControllerNode.nodeTitle = "Wired Controller";
|
||
|
||
app.registerExtension({
|
||
name: "Switchboard.Controllers",
|
||
registerCustomNodes() {
|
||
for (const Cls of [GroupControllerNode, NodeControllerNode, WiredControllerNode]) {
|
||
Cls.title = Cls.nodeTitle;
|
||
Cls.collapsable = true;
|
||
LiteGraph.registerNodeType(Cls.nodeTitle, Cls);
|
||
Cls.category = CATEGORY;
|
||
}
|
||
},
|
||
});
|