diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc4d20f --- /dev/null +++ b/README.md @@ -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:** + - **`` 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. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..9489bad --- /dev/null +++ b/__init__.py @@ -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"] diff --git a/assets/GroupController.png b/assets/GroupController.png new file mode 100644 index 0000000..7fb3a8d Binary files /dev/null and b/assets/GroupController.png differ diff --git a/assets/NodeController.png b/assets/NodeController.png new file mode 100644 index 0000000..a04a825 Binary files /dev/null and b/assets/NodeController.png differ diff --git a/js/switchboard.js b/js/switchboard.js new file mode 100644 index 0000000..87bfbb9 --- /dev/null +++ b/js/switchboard.js @@ -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; + } + }, +}); diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d8c71e8 --- /dev/null +++ b/pyproject.toml @@ -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 = ""