Add files via upload

This commit is contained in:
Oratorian
2026-06-02 21:49:13 +02:00
committed by GitHub
parent ccde30b969
commit 3e8e59f20f
6 changed files with 671 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
# 🎛️ Switchboard - Advanced Group/Node Controllers for ComfyUI
Client-side nodes for enabling/disabling parts of your graph - by hand or from a
wired boolean. A more flexible take on rgthree's *Fast Group Bypasser*. Two
nodes, identical behaviour, different target:
| Node | Targets | Add list shows |
|------|---------|----------------|
| **Group Controller** | groups, by **title** | the group titles in your graph |
| **Node Controller** | individual nodes, by **id** | nodes as `id: Title` |
## Install
Copy this folder into `ComfyUI/custom_nodes/` (final path
`ComfyUI/custom_nodes/comfyui-switchboard/`) and restart ComfyUI, then hard-reload
the browser (Ctrl/Cmd+Shift+R). There are no Python dependencies - these are pure
front-end JavaScript nodes that never run on the server. In the Add-Node menu the
nodes live under the **🎛️ Switchboard** category.
---
## Group Controller
![Group Controller - a Boolean (true) wired into the "Test" group input keeps the Test group enabled; disable mode is mute](assets/GroupController.png)
Above: a **Boolean** node (`value = true`) is wired into the controller's **`Test`**
input. The controller drives the **`Test`** group on the right (a Save Image
node). Because the boolean is `true`, the group is **enabled**; if it were
`false`, the group would be **muted** (its `↳ disable as` setting).
## Node Controller
![Node Controller - ALL is disabled, but node "8: VAE Decode" stays enabled because a Boolean (true) is wired to it, overriding the master ALL toggle](assets/NodeController.png)
Above: the **`ALL`** master toggle is set to **disabled**, yet node **`8: VAE
Decode`** is still **enabled** - because a **Boolean** (`true`) is wired to it.
**A connected boolean overrides the `ALL` broadcast.** This is the key behaviour,
described in full below.
---
## Anatomy of the node
From top to bottom, a controller shows:
1. **Input sockets (top of the node).** One `BOOLEAN` socket per controlled
target, labelled with the target (the group title, or `id: Title` for nodes).
Wiring is **optional** - see [Boolean inputs](#boolean-inputs). *(Under Nodes
2.0 the sockets render at the very top of the node, slightly detached from
their matching toggle row - that's a renderer quirk, not a bug.)*
2. **`ALL`** - the **master broadcast** toggle. Flips every *manually-controlled*
target on/off at once (see precedence below).
3. **One row pair per target:**
- **`<target>` toggle** - `enabled` / `disabled` for that target. This is what
a wired boolean drives.
- **`↳ disable as`** - `bypass` or `mute`, i.e. *how* that target turns off.
This stays editable at all times, even while a boolean holds the target on.
4. **`add`** - the **➕ Add group…/node…** dropdown. Pick a target to start
controlling it. Lists only targets not already controlled.
5. **`remove`** - the **➖ Remove group…/node…** dropdown. Drops a target and
**re-enables** it (so removing the controller never strands something
disabled). The right-click menu also has **Remove controlled …** and
**Refresh … list**.
### What `bypass` vs `mute` do
- **`bypass`** - the target's nodes are bypassed; data routes *around* them and
the graph still runs.
- **`mute`** - the target's nodes (and everything downstream of them) are muted
and don't execute at all.
---
## Boolean inputs
Every controlled target has its own `BOOLEAN` input socket. Wiring one is
**optional**.
- **Not wired** → the target is controlled **manually**: its own toggle and the
`ALL` master toggle.
- **Wired** → the boolean **governs** that target. `True` = `enabled`,
`False` = `disabled` (turned off using that target's `↳ disable as` mode).
### Precedence - a connected boolean wins
Once a boolean is connected to a target, **it is authoritative for that target's
on/off state and overrides everything else:**
| Action | Target with **no** boolean wired | Target **with** a boolean wired |
|--------|----------------------------------|---------------------------------|
| Click the target's toggle | changes it | reverts to the boolean's value |
| Flip the `ALL` master toggle | changes it | **ignored** - boolean still governs |
| Change `↳ disable as` | takes effect | takes effect (controls *how* it disables) |
This is exactly the **Node Controller** screenshot above: `ALL` is `disabled`,
but `8: VAE Decode` stays `enabled` because its boolean is `true`. Flipping `ALL`
does not touch boolean-governed targets - they only follow their input.
The boolean updates the target **live** as you flip the upstream value, and is
re-read once more **at queue time**, so the prompt that actually runs always
matches the inputs.
### What the boolean reads (and its one limitation)
Enabling/disabling a target changes the **mode** of the underlying node(s) - a
graph operation that happens **before** a prompt is queued. So the controller
reads the upstream boolean's value **in the browser**, by following the wire to
the source node's widget.
- ✅ Works great for **constant** Boolean/primitive nodes (a `True`/`False` you
set in the UI).
- ❌ Does **not** track a boolean that is *computed at runtime* by another node -
that value doesn't exist until execution, by which point the target's on/off
state is already locked for that run. For runtime-computed gating you'd need a
backend execution-blocking node instead (open an issue if you want that).
---
## Notes
- **Node Controller targets by id.** Nodes are matched by their stable id, so
retitling a controlled node still works; the `id: Title` label is captured when
you add it. Controllers can't target other controllers.
- **Survives save/load.** All state lives in node properties and is re-applied at
queue time. Older saved Group Controllers are auto-migrated to the current
format.
## Nodes 2.0 compatibility
These are client-side (virtual) nodes built on the legacy LiteGraph API - the
same class of node as rgthree's group tools. ComfyUI's **Nodes 2.0** (Vue
renderer) is currently opt-in and keeps a compatibility layer, so these load and
function there (toggles, booleans and queue-time apply all work). Caveats:
- The **core action** (setting nodes to active/bypass/mute) is graph data, not
rendering - it works regardless of renderer.
- The **UI** uses legacy patterns (virtual node, dynamic widgets/inputs, context
menu) that 2.0 may render inconsistently - e.g. input sockets float to the top
of the node, and links to dynamically-added sockets may draw as straight lines
instead of curves. These are cosmetic. Canvas-draw hooks are deliberately
avoided (a timer replaces `onDrawForeground`) so live input tracking keeps
working.
A Vue/V3-native rewrite would be the longer-term fix once those APIs are
documented.
+16
View File
@@ -0,0 +1,16 @@
"""Switchboard - advanced group/node enable/disable controllers for ComfyUI.
The actual nodes (Group Controller, Node Controller) are client-side ("virtual")
LiteGraph nodes implemented in ``js/switchboard.js``. They never execute on the
server, so there are no Python node classes here -- we only need to expose the
web directory so ComfyUI serves the JavaScript extension.
"""
# No server-side nodes: everything happens in the browser graph.
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
# Tell ComfyUI where to find the front-end extension(s).
WEB_DIRECTORY = "./js"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+497
View File
@@ -0,0 +1,497 @@
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";
/** 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 ---------------------------------------------------------
function getGraphGroups() {
const graph = app.graph;
if (!graph) return [];
return graph._groups || graph.groups || [];
}
/** All group titles currently in the graph, de-duplicated, as {key,label}. */
function getGroupChoices() {
const seen = new Set();
const choices = [];
for (const group of getGraphGroups()) {
if (group.title && !seen.has(group.title)) {
seen.add(group.title);
choices.push({ key: group.title, label: group.title });
}
}
return choices;
}
/** Set the mode of every node inside groups whose title matches `key`. */
function applyModeToGroup(key, mode) {
for (const group of getGraphGroups()) {
if (group.title !== key) continue;
// Make sure the group knows which nodes are inside its bounds.
if (typeof group.recomputeInsideNodes === "function") {
group.recomputeInsideNodes();
}
const nodes = group._nodes || group.nodes || [];
for (const node of nodes) node.mode = mode;
}
}
// ---- node targets ----------------------------------------------------------
function getGraphNodes() {
const graph = app.graph;
if (!graph) return [];
return graph._nodes || graph.nodes || [];
}
/** Every targetable node as {key:id, label:"id: Title"}, excluding `self` and
* any of our own controller nodes (you can't control a controller). */
function getNodeChoices(self) {
const choices = [];
for (const node of getGraphNodes()) {
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 node whose id matches `key`. Handles both numeric ids
* (classic graphs) and string ids (e.g. subgraph nodes under Nodes 2.0). */
function applyModeToNode(key, mode) {
const graph = app.graph;
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;
}
/**
* 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}…`;
}
// ---- 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;
}
}
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) => {
for (const control of this.properties.controls) {
// A connected boolean governs its target -- the master broadcast (and
// manual toggling) is overridden by it, so skip those here.
if (this._hasBooleanInput(control)) continue;
control.enabled = value;
}
this._syncToggles();
this.applyAll();
},
{ on: "enabled", off: "disabled" },
);
// 2) Per target: on/off toggle (boolean-driven) + independent "disable as".
for (const control of this.properties.controls) {
this._addControlWidgets(control);
}
// 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();
this.setDirtyCanvas(true, true);
}
_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 },
);
}
// ---- 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--) {
if (!wanted.includes(this.inputs[i].name)) this.removeInput(i);
}
for (const label of wanted) {
if (this.findInputSlot(label) === -1) this.addInput(label, "BOOLEAN");
}
}
/** Read the boolean coming into `slot` from the connected upstream node.
* 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 link = app.graph.links[input.link];
if (!link) return null;
const origin = app.graph.getNodeById(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 || [];
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];
if (out && typeof out._data !== "undefined") return !!out._data;
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) {
const slot = this.findInputSlot(control.label);
if (slot === -1) return false;
const input = this.inputs?.[slot];
return !!(input && input.link != null);
}
/** Pull every connected boolean input into its control's enabled state. */
_refreshFromInputs() {
let changed = false;
for (const control of this.properties.controls) {
const slot = this.findInputSlot(control.label);
if (slot === -1) continue;
const value = this._readBooleanInput(slot);
if (value === null) continue; // not wired -> leave manual toggle alone
// The boolean drives 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() {
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" };
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(),
});
}
}
// ---- concrete controllers --------------------------------------------------
class GroupControllerNode extends BaseControllerNode {
static targetWord = "group";
static isSwitchboardController = true;
constructor(title = GroupControllerNode.nodeTitle) {
super(title);
}
_allChoices() {
return getGroupChoices();
}
_applyTo(key, mode) {
applyModeToGroup(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);
}
_applyTo(key, mode) {
applyModeToNode(key, mode);
}
}
NodeControllerNode.nodeTitle = "Node Controller";
app.registerExtension({
name: "Switchboard.Controllers",
registerCustomNodes() {
for (const Cls of [GroupControllerNode, NodeControllerNode]) {
Cls.title = Cls.nodeTitle;
Cls.collapsable = true;
LiteGraph.registerNodeType(Cls.nodeTitle, Cls);
Cls.category = CATEGORY;
}
},
});
+13
View File
@@ -0,0 +1,13 @@
[project]
name = "comfyui-switchboard"
description = "Switchboard - toggle ComfyUI groups or individual nodes on/off (bypass or mute), manually or from a wired boolean. A more flexible take on rgthree's Fast Group Bypasser."
version = "1.0.0"
license = { text = "MIT" }
[project.urls]
Repository = "https://github.com/oratorian/comfyui-switchboard"
[tool.comfy]
PublisherId = "Oratorian"
DisplayName = "Switchboard"
Icon = ""