/** * ComfyUI-Toggle-Pass 前端扩展 * 支持动态输入/输出槽: * - DynamicRefLatentInput: 动态 LATENT 输入槽 * - ImageBatcher: 动态 IMAGE 输入槽 * - DynamicRefImageEncode: 动态 IMAGE 输入槽 * - DynamicRefIndependent: 四项同步增删(IMAGE + prompt + cond + latent) * * 架构说明: * 使用 beforeRegisterNodeDef + onConfigure/onNodeCreated 模式, * 而非 nodeCreated/nodeAdded + setTimeout。 * 原因:nodeCreated/nodeAdded 触发时 widget 值可能尚未从 JSON 恢复, * setTimeout 无法可靠保证时序,且切页面时可能与 onConfigure 打架。 * onConfigure 在 widget 值恢复之后触发,可安全读取 w.value。 */ import { app } from "../../scripts/app.js"; const EXT_NAME = "ComfyUI-Toggle-Pass"; const FIXED_WIDTH = 300; /* ============ 通用节点(DynamicRefLatentInput / ImageBatcher / DynamicRefImageEncode)============ */ const DYNAMIC_NODES = { "DynamicRefLatentInput": { widget: "num_latents", inputType: "LATENT", max: 10, prefix: "latent" }, "ImageBatcher": { widget: "num_images", inputType: "IMAGE", max: 10, prefix: "image" }, "DynamicRefImageEncode": { widget: "num_images", inputType: "IMAGE", max: 10, prefix: "image" }, }; /** * 强制固定节点宽度为 FIXED_WIDTH * 方法:劫持 setSize,以及用 setTimeout 多轮覆盖 */ function enforceWidth(node) { // 劫持 setSize const origSetSize = node.setSize; node.setSize = function (size) { if (Array.isArray(size)) { origSetSize.call(this, [FIXED_WIDTH, size[1]]); } else { origSetSize.call(this, size); } }; // 立即设置一次 node.setSize([FIXED_WIDTH, node.size[1]]); // 多轮 setTimeout 覆盖 Vue 渲染后的修改 for (let i = 0; i < 10; i++) { setTimeout(() => { if (node.size[0] !== FIXED_WIDTH) { node.size[0] = FIXED_WIDTH; app.graph.change(); } }, i * 100); } } app.registerExtension({ name: EXT_NAME + "-generic", beforeRegisterNodeDef(nodeType, nodeData, app) { const cfg = DYNAMIC_NODES[nodeData.name]; if (!cfg) return; // 劫持 setSize,强制固定宽度 const origSetSize = nodeType.prototype.setSize; nodeType.prototype.setSize = function (size) { if (Array.isArray(size)) { size = [FIXED_WIDTH, size[1]]; } return origSetSize.call(this, size); }; // computeSize 也返回固定宽度 const origComputeSize = nodeType.prototype.computeSize; nodeType.prototype.computeSize = function () { const s = origComputeSize ? origComputeSize.call(this) : [FIXED_WIDTH, 100]; return [FIXED_WIDTH, Array.isArray(s) ? s[1] : s]; }; // onConfigure:从 JSON 恢复后触发,widget 值已正确 const origConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { origConfigure?.apply(this, arguments); requestAnimationFrame(() => { const w = this.widgets?.find(w => w.name === cfg.widget); if (w) syncInputs(this, cfg, Math.max(1, Math.min(cfg.max, w.value || 1))); enforceWidth(this); }); }; // onNodeCreated:新拖入的节点(无 JSON 恢复),用默认值初始化 const origCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { origCreated?.apply(this, arguments); if (this._tpInit) return; this._tpInit = true; this.setSize([FIXED_WIDTH, this.computeSize()[1]]); enforceWidth(this); const w = this.widgets?.find(w => w.name === cfg.widget); if (!w) return; syncInputs(this, cfg, Math.max(1, Math.min(cfg.max, w.value || 1))); const prev = w.callback; w.callback = function (v) { if (prev) prev.call(this, v); syncInputs(this._node || this, cfg, Math.max(1, Math.min(cfg.max, v || 1))); enforceWidth(this._node || this); }; }; }, }); function syncInputs(node, cfg, target) { const inputs = node.inputs || []; let count = 0; for (const inp of inputs) if (inp.type === cfg.inputType) count++; if (count === target) { enforceWidth(node); return; } if (count < target) { for (let i = count + 1; i <= target; i++) node.addInput(`${cfg.prefix}${i}`, cfg.inputType); } else { const idx = []; for (let i = 0; i < inputs.length; i++) if (inputs[i].type === cfg.inputType) idx.push(i); for (let i = 0; i < count - target; i++) node.removeInput(idx[idx.length - 1 - i]); } requestAnimationFrame(() => { requestAnimationFrame(() => { node.setSize([FIXED_WIDTH, node.computeSize()[1]]); enforceWidth(node); app.graph.change(); }); }); } /* ============ Ref Independent(四项同步增删)============ */ app.registerExtension({ name: EXT_NAME + "-refind", beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeData.name !== "DynamicRefIndependent") return; // 劫持 setSize,强制固定宽度 const origSetSize = nodeType.prototype.setSize; nodeType.prototype.setSize = function (size) { if (Array.isArray(size)) { size = [FIXED_WIDTH, size[1]]; } return origSetSize.call(this, size); }; // computeSize 也返回固定宽度 const origComputeSize = nodeType.prototype.computeSize; nodeType.prototype.computeSize = function () { const s = origComputeSize ? origComputeSize.call(this) : [FIXED_WIDTH, 100]; return [FIXED_WIDTH, Array.isArray(s) ? s[1] : s]; }; // onConfigure:从 JSON 恢复后触发 const origConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { origConfigure?.apply(this, arguments); requestAnimationFrame(() => { const w = this.widgets?.find(w => w.name === "num_images"); if (!w) return; const n = Math.max(1, Math.min(10, w.value || 1)); applyRefInd(this, n); enforceWidth(this); }); }; // onNodeCreated:新拖入的节点(首次创建,无 JSON 恢复) const origCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { origCreated?.apply(this, arguments); if (this._tpRefInd) return; this._tpRefInd = true; this.setSize([FIXED_WIDTH, this.computeSize()[1]]); enforceWidth(this); const w = this.widgets?.find(w => w.name === "num_images"); if (!w) return; const n = Math.max(1, Math.min(10, w.value || 1)); applyRefInd(this, n); const prev = w.callback; w.callback = function (v) { if (prev) prev.call(this, v); applyRefInd(this._node || this, Math.max(1, Math.min(10, v || 1))); enforceWidth(this._node || this); }; }; }, }); /** * Ref Independent:同步增删 * 1. IMAGE 输入槽:image1 ~ imageN(增删) * 2. prompt 控件:prompt1 ~ promptN(hidden 显示/隐藏) * 3. 输出槽:cond_1, latent1, cond_2, latent2, ..., cond_N, latentN(增删,交替排列) */ function applyRefInd(node, n) { // ---- 1. 增删 IMAGE 输入槽 ---- const inputs = node.inputs || []; let imgCount = 0; for (const inp of inputs) if (inp.type === "IMAGE") imgCount++; if (imgCount < n) { for (let i = imgCount + 1; i <= n; i++) node.addInput(`image${i}`, "IMAGE"); } else if (imgCount > n) { const idx = []; for (let i = 0; i < inputs.length; i++) if (inputs[i].type === "IMAGE") idx.push(i); for (let i = 0; i < imgCount - n; i++) node.removeInput(idx[idx.length - 1 - i]); } // ---- 2. 隐藏/显示 prompt 控件 ---- for (const w of (node.widgets || [])) { if (w.name && w.name.startsWith("prompt")) { const num = parseInt(w.name.replace("prompt", ""), 10); w.hidden = num > n; } } // ---- 3. 调整输出槽 ---- const expectedCount = n * 2; while (node.outputs && node.outputs.length > expectedCount) { node.removeOutput(node.outputs.length - 1); } while (node.outputs && node.outputs.length < expectedCount) { const i = Math.floor(node.outputs.length / 2) + 1; if (node.outputs.length % 2 === 0) { node.addOutput(`cond_${i}`, "CONDITIONING"); } else { node.addOutput(`latent${i}`, "LATENT"); } } requestAnimationFrame(() => { requestAnimationFrame(() => { node.setSize([FIXED_WIDTH, node.computeSize()[1]]); enforceWidth(node); app.graph.change(); }); }); }