/** * DaSiWa Node Status Switch - ComfyUI frontend extension * * Minimal JS responsibilities: * 1. Manage dynamic target_XX inputs (grow on connect, up to 20) * 2. At queue time, read the effective "enabled" value (from the * local widget OR from a connected upstream node) and set the * mode of every target node accordingly. * * No live updates. No widget callbacks. No polling. * The mode change happens right before the prompt is sent. * * ComfyUI mode values: * 0 = ALWAYS (active) * 2 = NEVER (mute) * 4 = bypass */ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; const MODE_ACTIVE = 0; const MODE_MUTE = 2; const MODE_BYPASS = 4; const NODE_TYPE = "DaSiWa_NodeStatusSwitch"; const DIRECTOR_TYPE = "MiniMaxH3Director"; const TARGET_PREFIX = "target_"; const MAX_TARGETS = 99; // Toggle in browser console: window.DASIWA_SWITCH_DEBUG = true function dlog(...args) { if (typeof window !== "undefined" && window.DASIWA_SWITCH_DEBUG) { console.log("[DaSiWa Switch]", ...args); } } // ── helpers ───────────────────────────────────────────────────────────── function getWidget(node, name) { return (node.widgets ?? []).find((w) => w.name === name); } function isTargetSlot(input) { return typeof input?.name === "string" && input.name.startsWith(TARGET_PREFIX); } function targetSlotName(n) { return `${TARGET_PREFIX}${String(n).padStart(2, "0")}`; } function countTargetSlots(node) { return (node.inputs ?? []).filter(isTargetSlot).length; } function isBoolWidget(w) { if (!w) return false; if (w.type === "toggle") return true; if (typeof w.type === "string" && w.type.toUpperCase().includes("BOOLEAN")) return true; if (typeof w.value === "boolean") return true; return false; } function findBoolWidget(node) { return node?.widgets?.find(isBoolWidget) ?? null; } function asBoolean(value) { if (typeof value === "boolean") return value; if (value === "true") return true; if (value === "false") return false; return null; } function nodesInGraphTree(graph, visited = new Set()) { if (!graph || visited.has(graph)) return []; visited.add(graph); const nodes = graph._nodes ?? graph.nodes ?? []; return nodes.flatMap((node) => [node, ...nodesInGraphTree(node.subgraph, visited)]); } /** * In the current frontend, a promoted widget is store-backed by the enclosing * subgraph host. The original widget inside the subgraph is no longer the * source of truth, so resolve its host widget before reading the local value. */ function readPromotedWidgetValue(node, widgetName) { const childGraph = node?.graph; const rootGraph = childGraph?.rootGraph ?? app.graph; if (!childGraph || !rootGraph || childGraph === rootGraph) return null; const widgetInput = (node.inputs ?? []).find( (input) => input?.widget?.name === widgetName || input?.name === widgetName ); for (const host of nodesInGraphTree(rootGraph)) { if (host?.subgraph !== childGraph) continue; for (const hostInput of host.inputs ?? []) { const slot = hostInput?._subgraphSlot; for (const linkId of slot?.linkIds ?? []) { const link = childGraph.getLink?.(linkId) ?? childGraph.links?.[linkId] ?? childGraph._links?.get?.(linkId); const resolved = link?.resolve?.(childGraph); if (resolved?.inputNode !== node) continue; if (widgetInput && resolved.input !== widgetInput) continue; // A promoted widget can itself be linked on the subgraph // host. In that case the outer connection, not the host's // store-backed widget value, is authoritative. const parentGraph = host.graph ?? rootGraph; if (hostInput.link != null && parentGraph) { const outerLink = parentGraph.getLink?.(hostInput.link) ?? parentGraph.links?.[hostInput.link] ?? parentGraph._links?.get?.(hostInput.link); const parentNodes = parentGraph._nodes ?? parentGraph.nodes ?? []; const source = resolveSourceNode( parentNodes.find((candidate) => candidate.id === outerLink?.origin_id), parentGraph ); const linkedValue = readBoolFromNode(source); if (linkedValue != null) return linkedValue; } const hostWidget = host.getWidgetFromSlot?.(hostInput) ?? hostInput.widget; const value = asBoolean(hostWidget?.value); if (value != null) return value; } } } return null; } /** * Read a boolean value from a node, regardless of how the node * stores it. Tries every plausible storage path. Returns the * boolean or null if nothing usable is found. */ function readBoolFromNode(node) { if (!node) return null; // ── Path 1: conventional widget ───────────────────────────────────── const w = findBoolWidget(node); const widgetValue = asBoolean(w?.value); if (widgetValue != null) return widgetValue; // ── Path 2: any widget whose value is boolean (broader than Path 1) ── if (node.widgets) { for (const aw of node.widgets) { const value = asBoolean(aw?.value); if (value != null) return value; } } // ── Path 3: input slots may carry a widget object with the value ─── // PrimitiveBoolean (and similar widget-input hybrids) put the widget // reference under inputs[i].widget; the value can live on the widget, // on the input slot itself, or in widgets_values keyed by widget name. if (node.inputs) { for (const inp of node.inputs) { const widgetValue = asBoolean(inp?.widget?.value); if (widgetValue != null) return widgetValue; const inputValue = asBoolean(inp?.value); if (inputValue != null) return inputValue; } } // ── Path 4: top-level widgets_values array ────────────────────────── if (Array.isArray(node.widgets_values)) { // First boolean entry for (const v of node.widgets_values) { if (typeof v === "boolean") return v; } // First entry coerced (some versions store "true"/"false" strings) const first = node.widgets_values[0]; if (first === "true" || first === true) return true; if (first === "false" || first === false) return false; } // ── Path 5: properties may hold the value ─────────────────────────── if (node.properties) { for (const v of Object.values(node.properties)) { if (typeof v === "boolean") return v; } } return null; } /** * Resolves the true source node by traversing through Reroute nodes. */ function resolveSourceNode(startNode, graph) { let current = startNode; const visited = new Set(); while (current?.type?.includes?.("Reroute") && !visited.has(current.id)) { visited.add(current.id); const linkId = current.inputs?.[0]?.link; const link = linkId != null ? (graph.links?.[linkId] ?? graph._links?.get?.(linkId)) : null; if (!link) break; const allNodes = graph._nodes ?? graph.nodes ?? []; current = allNodes.find((n) => n.id === link.origin_id); } return current; } // Modes that make the Director's fl2va_requested output true — mirrors // `mode in BASE_MODES or mode == "Image Inpaint"` in // nodes/nodes_minimax_h3_director.py. const FL2VA_FAMILY_MODES = ["T2VA", "I2VA", "FL2VA", "L2VA", "Image Inpaint"]; /** * Read the computed "request" boolean outputs of the MiniMax H3 Director. * * The Director's fl2va_requested / inpaint_requested outputs are computed * in the backend from the mode combo (build_guide); the frontend node never * stores them. A switch wired to one of these outputs therefore derives the * value from the Director's mode widget instead. * * Returns a boolean, or null if the slot is not a known request output. */ function readDirectorRequest(director, slotIndex) { if (!director) return null; const name = director.outputs?.[slotIndex]?.name; const mode = getWidget(director, "mode")?.value; if (mode == null) return null; switch (name) { case "inpaint_requested": return mode === "Image Inpaint"; case "fl2va_requested": return FL2VA_FAMILY_MODES.includes(mode); default: return null; } } /** * Read the effective "enabled" value at queue time. * If the "enabled" input is connected, follow the link to the source * node and read its boolean value. Otherwise fall back to the local * widget on the switch. * * Chaining: if the upstream node is another DaSiWa_NodeStatusSwitch, * recursively read its effective enabled value (which is what the * upstream switch outputs from its enabled_out pin). A `visited` * set guards against accidental cycles in user graphs. */ function readEnabled(switchNode, visited) { visited = visited ?? new Set(); if (visited.has(switchNode.id)) return null; // cycle guard visited.add(switchNode.id); const graph = switchNode.graph ?? app.graph; const enabledInput = (switchNode.inputs ?? []).find( (inp) => inp?.name === "enabled" ); if (enabledInput && enabledInput.link != null && graph) { const link = graph.links?.[enabledInput.link] ?? graph._links?.get?.(enabledInput.link); if (link) { const allNodes = graph._nodes ?? graph.nodes ?? []; const src = resolveSourceNode(allNodes.find((n) => n.id === link.origin_id), graph); if (src) { // If upstream is another switch, use its effective value, // not its raw widget — a chained switch's output is what // matters for downstream chaining. if (src.type === NODE_TYPE) { const v = readEnabled(src, visited); if (v != null) return v; } // The MiniMax H3 Director's request outputs (inpaint_requested, // fl2va_requested) are computed in the backend from its mode // combo; derive them from the mode widget so the switch follows // the mode pill live. if (src.type === DIRECTOR_TYPE) { const v = readDirectorRequest(src, link.origin_slot); if (v != null) return v; } const v = readBoolFromNode(src); if (v != null) return v; } } } const promotedValue = readPromotedWidgetValue(switchNode, "enabled"); if (promotedValue != null) return promotedValue; // Fallback: local widget on the switch itself const localW = getWidget(switchNode, "enabled"); return localW != null ? !!localW.value : true; } // ── target discovery ──────────────────────────────────────────────────── function getTargetNodeIds(switchNode) { const graph = switchNode.graph ?? app.graph; if (!graph) return []; const ids = []; for (const input of switchNode.inputs ?? []) { if (!isTargetSlot(input)) continue; if (input.link == null) continue; const link = graph.links?.[input.link] ?? graph._links?.get?.(input.link); if (link && link.origin_id != null) { const allNodes = graph._nodes ?? graph.nodes ?? []; const src = resolveSourceNode(allNodes.find((n) => n.id === link.origin_id), graph); if (src) ids.push(src.id); } } return ids; } function removeBackendSwitchNodes(prompt) { if (!prompt || typeof prompt !== "object") return; for (const [nodeId, nodeData] of Object.entries(prompt)) { if (nodeData?.class_type !== NODE_TYPE) continue; delete prompt[nodeId]; } } function removeBackendTargetInputsFromSwitchNodes(prompt) { if (!prompt || typeof prompt !== "object") return; for (const nodeData of Object.values(prompt)) { if (nodeData?.class_type !== NODE_TYPE) continue; const inputs = nodeData.inputs; if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) continue; for (const key of Object.keys(inputs)) { if (key.startsWith(TARGET_PREFIX)) { delete inputs[key]; } } } } function isPromptNodeKeyForTarget(key, targetId) { const id = String(targetId); return key === id || key.startsWith(`${id}:`) || key.endsWith(`:${id}`); } function removeInactiveTargetOutputs(output) { if (!output || typeof output !== "object") return; const allNodes = nodesInGraphTree(app.graph); if (!allNodes.length) return; const inactiveTargets = new Set(); for (const node of allNodes) { if (node.type !== NODE_TYPE) continue; for (const targetId of getTargetNodeIds(node)) { const target = allNodes.find((n) => String(n.id) === String(targetId)); if (target && target.mode !== MODE_ACTIVE) { inactiveTargets.add(target.id); } } } for (const key of Object.keys(output)) { for (const targetId of inactiveTargets) { if (isPromptNodeKeyForTarget(key, targetId)) { delete output[key]; break; } } } } function sanitizeQueuePrompt(queuePromptData) { if (!queuePromptData || typeof queuePromptData !== "object") return; applyAllSwitches(); removeInactiveTargetOutputs(queuePromptData.output); removeBackendTargetInputsFromSwitchNodes(queuePromptData.output); removeBackendTargetInputsFromSwitchNodes(queuePromptData.prompt); removeBackendSwitchNodes(queuePromptData.output); removeBackendSwitchNodes(queuePromptData.prompt); if (!queuePromptData.output && !queuePromptData.prompt) { removeBackendTargetInputsFromSwitchNodes(queuePromptData); removeBackendSwitchNodes(queuePromptData); } } function patchQueuePrompt() { if (api.__dasiwaNodeStatusSwitchQueuePatched) return; const originalQueuePrompt = api.queuePrompt; api.queuePrompt = async function (index, prompt, ...args) { sanitizeQueuePrompt(prompt); return originalQueuePrompt.apply(this, [index, prompt, ...args]); }; api.__dasiwaNodeStatusSwitchQueuePatched = true; } function applyAllSwitches() { const allNodes = nodesInGraphTree(app.graph); for (const n of allNodes) { if (n.type === NODE_TYPE) applySwitch(n); } app.graph?.setDirtyCanvas?.(true, true); } function patchGraphToPrompt() { if (app.__dasiwaNodeStatusSwitchGraphToPromptPatched) return; const originalGraphToPrompt = app.graphToPrompt; app.graphToPrompt = async function (...args) { applyAllSwitches(); const prompt = await originalGraphToPrompt.apply(this, args); sanitizeQueuePrompt(prompt); return prompt; }; app.__dasiwaNodeStatusSwitchGraphToPromptPatched = true; } patchQueuePrompt(); patchGraphToPrompt(); // ── dynamic input management ──────────────────────────────────────────── function syncTargetSlots(node) { const targetInputs = (node.inputs ?? []).filter(isTargetSlot); const connected = targetInputs.filter((inp) => inp.link != null).length; const desired = Math.min(connected + 1, MAX_TARGETS); while (countTargetSlots(node) < desired) { const nextNum = countTargetSlots(node) + 1; node.addInput(targetSlotName(nextNum), "*"); } while (countTargetSlots(node) > desired) { const allInputs = node.inputs ?? []; for (let i = allInputs.length - 1; i >= 0; i--) { if (isTargetSlot(allInputs[i]) && allInputs[i].link == null) { node.removeInput(i); break; } } } node.setSize(node.computeSize()); } // ── apply mode to targets ─────────────────────────────────────────────── /** * "true -> active" => targets ACTIVE when enabled=true * "false -> active" => targets ACTIVE when enabled=false */ function applySwitch(switchNode) { const triggerW = getWidget(switchNode, "trigger_on"); const actionW = getWidget(switchNode, "action"); if (!triggerW || !actionW) return; const enabled = readEnabled(switchNode); const triggerOn = triggerW.value ?? ""; const action = actionW.value ?? "bypass"; const activeWhenTrue = triggerOn.startsWith("true"); const targetsActive = activeWhenTrue ? enabled : !enabled; const actionMode = action === "mute" ? MODE_MUTE : MODE_BYPASS; const targetMode = targetsActive ? MODE_ACTIVE : actionMode; dlog("applySwitch", { nodeId: switchNode.id, enabled, triggerOn, action, targetsActive, targetMode, }); const graph = switchNode.graph ?? app.graph; if (!graph) return; const targetIds = getTargetNodeIds(switchNode); const allNodes = graph._nodes ?? graph.nodes ?? []; for (const id of targetIds) { const target = allNodes.find((n) => n.id === id); if (target) target.mode = targetMode; } } // ── propagate to downstream chained switches ──────────────────────────── // // When this switch's effective state changes, find every other switch // whose `enabled` input is wired (directly or transitively) from this // switch's `enabled_out` and re-apply them too. This is more reliable // than waiting for the polling mirror loop to notice. function findDownstreamSwitches(switchNode, visited) { visited = visited ?? new Set(); if (visited.has(switchNode.id)) return []; visited.add(switchNode.id); const graph = switchNode.graph ?? app.graph; if (!graph) return []; const allNodes = graph._nodes ?? graph.nodes ?? []; const linksMap = graph.links ?? graph._links; if (!linksMap) return []; // Find the output slot named "enabled_out" on this node. const outIdx = (switchNode.outputs ?? []).findIndex( (o) => o?.name === "enabled_out" ); if (outIdx < 0) return []; const outSlot = switchNode.outputs[outIdx]; const linkIds = outSlot?.links ?? []; const found = []; const traceVisited = new Set(); function traceDownstream(linkId) { if (traceVisited.has(linkId)) return; traceVisited.add(linkId); const link = linksMap?.[linkId] ?? linksMap?.get?.(linkId); const target = link ? allNodes.find((n) => n.id === link.target_id) : null; if (!target) return; if (target.type?.includes?.("Reroute")) { (target.outputs?.[0]?.links ?? []).forEach(traceDownstream); } else if (target.type === NODE_TYPE && target.inputs?.[link.target_slot]?.name === "enabled") { found.push(target, ...findDownstreamSwitches(target, visited)); } } linkIds.forEach(traceDownstream); return found; } function applySwitchAndDownstream(switchNode) { applySwitch(switchNode); const downstream = findDownstreamSwitches(switchNode); dlog("downstream of", switchNode.id, "=", downstream.map((d) => d.id)); for (const d of downstream) { // Mirror upstream's effective enabled into the downstream // switch's local widget so the UI reflects the chain. const upstreamEnabled = readEnabled(switchNode); const localW = getWidget(d, "enabled"); if (localW && localW.value !== upstreamEnabled) { localW.value = upstreamEnabled; } applySwitch(d); } const graph = switchNode.graph ?? app.graph; graph?.setDirtyCanvas?.(true, true); } // ── live mirror: external boolean -> local widget ─────────────────────── // // When the "enabled" input is connected to an external boolean source, // poll that source and mirror its value into the switch's own // "enabled" widget. Writing to the local widget shows the toggle // flipping on the switch's UI and triggers a live applySwitch. function syncExternalToLocal(switchNode) { const localW = getWidget(switchNode, "enabled"); if (!localW) return; const graph = switchNode.graph ?? app.graph; if (!graph) return; // The external value can arrive through this input directly, or through // the enclosing subgraph host after the widget has been promoted. const externalValue = readEnabled(switchNode); if (externalValue == null) return; if (localW.value === externalValue) return; // already in sync localW.value = externalValue; applySwitch(switchNode); graph.setDirtyCanvas?.(true, true); } let mirrorLoopRunning = false; function mirrorFrame() { if (!mirrorLoopRunning) return; try { const allNodes = nodesInGraphTree(app.graph); for (const n of allNodes) { if (n.type === NODE_TYPE) syncExternalToLocal(n); } } catch (_) {} requestAnimationFrame(mirrorFrame); } function startMirrorLoop() { if (mirrorLoopRunning) return; mirrorLoopRunning = true; requestAnimationFrame(mirrorFrame); } startMirrorLoop(); // ── extension registration ────────────────────────────────────────────── app.registerExtension({ name: "DaSiWa.NodeStatusSwitch", async beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name !== NODE_TYPE) return; const origCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { origCreated?.apply(this, arguments); const self = this; // Ensure the chaining output pin exists. ComfyUI normally // adds it from RETURN_TYPES, but be defensive in case the // node definition is reloaded without it. const hasOutput = (self.outputs ?? []).some( (o) => o?.name === "enabled_out" ); if (!hasOutput) { self.addOutput("enabled_out", "BOOLEAN"); } if (countTargetSlots(self) === 0) { self.addInput(targetSlotName(1), "*"); } // Local widget callbacks: live updates when the user // toggles directly on the switch node. Cascade through // any chained downstream switches. for (const w of self.widgets ?? []) { const origCb = w.callback; w.callback = function (...args) { origCb?.apply(this, args); requestAnimationFrame(() => applySwitchAndDownstream(self)); }; } self.setSize(self.computeSize()); }; const origConnChange = nodeType.prototype.onConnectionsChange; nodeType.prototype.onConnectionsChange = function ( side, slotIndex, connected, linkInfo ) { origConnChange?.apply(this, arguments); const self = this; if (side === 1) { requestAnimationFrame(() => { syncTargetSlots(self); applySwitchAndDownstream(self); }); } }; const origOnConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function (data) { origOnConfigure?.apply(this, arguments); const self = this; // Migration: older saved graphs had no outputs. Add the // chaining output if missing. Strip any *other* outputs // (defensive — should not occur). const outs = self.outputs ?? []; for (let i = outs.length - 1; i >= 0; i--) { if (outs[i]?.name !== "enabled_out") { self.removeOutput(i); } } const hasOutput = (self.outputs ?? []).some( (o) => o?.name === "enabled_out" ); if (!hasOutput) { self.addOutput("enabled_out", "BOOLEAN"); } requestAnimationFrame(() => syncTargetSlots(self)); }; }, async loadedGraphNode(node) { if (node.type !== NODE_TYPE) return; requestAnimationFrame(() => { const outs = node.outputs ?? []; for (let i = outs.length - 1; i >= 0; i--) { if (outs[i]?.name !== "enabled_out") { node.removeOutput(i); } } const hasOutput = (node.outputs ?? []).some( (o) => o?.name === "enabled_out" ); if (!hasOutput) { node.addOutput("enabled_out", "BOOLEAN"); } syncTargetSlots(node); }); }, // The only place mode is applied: right before the prompt is sent. async beforeQueued(...args) { for (const arg of args) { sanitizeQueuePrompt(arg); } applyAllSwitches(); }, });