Add files via upload
This commit is contained in:
@@ -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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
@@ -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 |
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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 = ""
|
||||
Reference in New Issue
Block a user