Files
electricty00-ComfyUI-Toggle…/js/multi_ref_encode.js
T
GBrain af57d757eb feat: PoseReference node, sampler presets, model-type rename, auto clip_type rework, output reorder
新增 / New
- PoseReference 节点:姿势素材库(线稿 / 深度两套)+ 节点内缩略图,选中素材以 data URI 内嵌进工作流
- 采样器「⚡预设」下拉:一键填 采样器/调度器/步数/CFG/Sigma曲线;预设表只有一份在 sampler.py,
  前端走 /toggle_pass/sampler_presets 拉取;手改任一参数自动退回「手动(不套用)」
- ToggleEmptyLatent 新增 latent类型(4 档:SD 4ch/8x、SD3-Flux-Krea2 16ch/8x、Qwen2.1 64ch/16x、Flux2 128ch/16x)
- DynamicRefImageEncode 新增专门的 negative 输出(内部编空文本),替代 ConditioningZeroOut

变更 / Changed
- 模型类型:SD / SDXL → SD / SDXL / Z-Image(前后端双层别名迁移,旧工作流自动改写)
- DynamicRefImageEncode 输出口顺序 → conditioning / negative / latent,老工作流按 link 类型自动迁移连线
- clip_type 从 14 项补到 40 项;auto 重做为「精确表 → MRO 继承链 → 前缀规则」三层
- CLIP 校验加固:手工表未登记的模型改问 model_config.clip_target(),覆盖 106/108 个模型类
- 统一加载器支持 FIBO 2.0 / MOSS-Image;采样器预设新增 FIBO 2.0 turbo(4步)

修复 / Fixed
- Krea2 的 reference_latents_method: index_timestep_zero → index(前者会把参考线稿「描」进画面)
- Krea2 参考图 latent fit 到出图网格
- 修正三处 Flux2 空 latent 的通道 / 尺寸(Ref Image Encode / OneImageMultiPrompt / Ref Independent)
- 广播:槽位小点点亮、端点圆点改到节点下层、修「紫线伸向空白处断掉」(零尺寸 boundingRect 误判)
- 输出口顺序迁移只在 onConfigure 跑一次太早(graph.links 要几百毫秒才建好)⇒ 改为阶梯重试,
  否则会出现「槽位改名了、连线没搬」

其他 / Misc
- 新增诊断命令 __tpAudit():打印每条广播线两端坐标 / 归属节点 / 是否悬空
- 画布菜单新增「🔄 刷新姿势素材库」
- 移除示例 Krea2 Pose Editing (Ostris).json(硬依赖外部节点包且会换人)

版本 / Version
- 新增 pyproject.toml(ComfyUI-Manager / Registry 元数据入口),版本号 v1.0.0
- __init__.py 增加 __version__,启动打印 [Toggle-Pass] v1.0.0 loaded — 13 nodes
- README 顶部加版本标识
2026-11-07 21:29:09 +08:00

1569 lines
77 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ComfyUI-Toggle-Pass 前端扩展
* 支持动态输入/输出槽:
* - DynamicRefLatentInput: 动态 LATENT 输入槽
* - ImageBatcher: 动态 IMAGE 输入槽
* - DynamicRefImageEncode: 动态 IMAGE 输入槽
* - DynamicRefIndependent: 四项同步增删(IMAGE + prompt + cond + latent)
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const EXT_NAME = "ComfyUI-Toggle-Pass";
/* ============ 通用节点 ============ */
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" },
};
/* ---- 按「模型类型」显示/隐藏相关参数 ---- */
// 只有 Qwen Image 2.1 用得到的一组分辨率参数;选其他模型时全部藏起来
const RES21_WIDGETS = ["宽高比", "百万像素", "倍数", "参考图分辨率", "尺寸适配"];
const MANUAL_RATIO = "自定义(用输出宽度/高度)";
/* ---- 参数自愈:旧工作流按位置回填导致错位时自动归位 ----
* 成因:ComfyUI 的 widgets_values 是「按位置」存的。给节点新增/调整参数后,
* 旧工作流的值会被回填到错误的位置,于是出现「尺寸适配 = 32」「倍数 = NaN」
* 「百万像素 = 1024.0」这类脏值(枚举值不在选项内 / 数值 NaN 或越界)。
* 这里在节点创建与加载时逐项校验,非法即恢复为安全默认值。
*/
const SIZE_PARAM_DEFAULTS = {
"输出宽度": 1024,
"输出高度": 1024,
"尺寸适配": "参考图保比例",
"图层数": 3,
"宽高比": "1:1 (Square)",
"百万像素": 1.0,
"倍数": 32,
"参考图分辨率": 1024,
};
function isSaneValue(name, v, w) {
const opts = w?.options || {};
let values = opts.values;
if (typeof values === "function") {
try { values = values(w); } catch (e) { values = null; }
}
if (Array.isArray(values) && values.length) {
// 枚举型:值必须真的出现在下拉选项里
const flat = values.map(o => (o && typeof o === "object") ? o.value : o);
return flat.includes(v);
}
// 数值型:必须有限、且在 min/max 内
const n = Number(v);
if (!isFinite(n)) return false;
if (opts.min != null && n < opts.min) return false;
if (opts.max != null && n > opts.max) return false;
// 参考图分辨率:0 = 保持原尺寸,其余必须 >= 256(低于此只可能是错位残留)
if (name === "参考图分辨率") return n === 0 || n >= 256;
return true;
}
function sanitizeSizeParams(node) {
let changed = false;
for (const [name, def] of Object.entries(SIZE_PARAM_DEFAULTS)) {
const w = (node.widgets || []).find(x => x.name === name);
if (!w) continue;
if (!isSaneValue(name, w.value, w)) {
w.value = def;
changed = true;
}
}
return changed;
}
function refreshNodeSize(node) {
const s = node.computeSize();
const min = node._tpMinSize || [0, 0];
node.setSize([Math.max(s[0], min[0]), Math.max(s[1], min[1])]);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const s2 = node.computeSize();
node.setSize([Math.max(s2[0], min[0]), Math.max(s2[1], min[1])]);
app.graph.change();
});
});
}
/* ---- 模型类型改名迁移 ----
* 2026-11-06:`SD / SDXL` 改名为 `SD / SDXL / Z-Image`(两者 latent 行为完全一样,
* Z-Image 就是「不接参考图的通用档」,合进一个名字里省得再开一项)。
* 旧工作流的 widgets_values 里存的是旧字面量,不在下拉选项里 → 前端会显示空白。
* 这里在加载时直接改值,用户看不到中间态。
* ⚠️ 后端 multi_ref_encode.py 有一份同样的表,两层都兜(第三方节点转存 / 手工改 JSON)。
*/
const MODEL_TYPE_MIGRATIONS = {
"SD / SDXL": "SD / SDXL / Z-Image",
"SD/SDXL": "SD / SDXL / Z-Image",
"SD / SDXL / z-image": "SD / SDXL / Z-Image",
"SD/SDXL/z-image": "SD / SDXL / Z-Image",
"SDXL": "SD / SDXL / Z-Image",
"SD 1.5": "SD / SDXL / Z-Image",
"Z-Image": "SD / SDXL / Z-Image",
"z-image": "SD / SDXL / Z-Image",
"ZImage": "SD / SDXL / Z-Image",
};
function migrateModelType(node) {
const w = (node.widgets || []).find(x => x.name === "模型类型");
if (!w || typeof w.value !== "string") return false;
const to = MODEL_TYPE_MIGRATIONS[w.value.trim()];
if (!to || to === w.value) return false;
console.log(`[Toggle-Pass] 模型类型 已迁移: "${w.value}" -> "${to}"`);
w.value = to;
return true;
}
/* ---- Ref Image Encode 输出顺序迁移 ----
* 2026-11-06:`negative` 从末尾挪到 `latent` 前面(纯排版,node 上好看)。
* 老布局 outputs = [conditioning, latent, negative]
* 新布局 outputs = [conditioning, negative, latent]
* ⚠️ 索引 1/2 的含义互换了,老工作流存在 JSON 里的 link.origin_slot 会指向错的输出口
* (负面被当 latent、latent 被当负面,类型对不上直接断线)。
* 这里按 outputs[i].name 判断布局(不依赖存盘标记,幂等):
* - 检测到老布局 ⇒ 交换 1/2 号口的整个 output 元素(name/type/links 一起走)
* - 每次都把 graph.links 的 origin_slot 对齐到当前 outputs 位置
*/
const OUT_ORDER_TAG = "negative_before_latent";
// ① 只给槽位改名(幂等:新布局下不做任何事)。同步执行,抢在渲染连线之前。
function renameOutputSlots(node) {
const outs = node?.outputs;
if (!outs || outs.length < 3) return false;
if (outs[1]?.name !== "latent" || outs[2]?.name !== "negative") return false;
// ⚠️ 不能交换 outputs 数组元素本身 —— ComfyUI 加载完会按 link.origin_slot
// 重新往 outputs[i].links 里填,换掉的数组元素会被"按位置"填回旧连线。
// 只换 name/type,位置不变,等价于「给槽位改名」。
const nm = outs[1].name, tp = outs[1].type;
outs[1].name = outs[2].name; outs[1].type = outs[2].type;
outs[2].name = nm; outs[2].type = tp;
node.properties = node.properties || {};
node.properties.tp_outorder = OUT_ORDER_TAG;
console.log("[Toggle-Pass] Ref Image Encode 输出顺序已迁移: conditioning / negative / latent");
return true;
}
// ② 把连线按「类型语义」搬回正确槽位。幂等,可无限次重跑。
// LATENT → 2 号口(latent),CONDITIONING → 1 号口(negative);0 号口不参与。
// 返回值:-1 = graph.links 还没建好(要继续重试);>=0 = 本次改动的连线条数。
function fixLinkSlots(node) {
if (!node) return 0;
const graph = node.graph || app?.graph;
const links = graph?.links;
if (!links) return -1;
// ★ 加载早期 graph.links 是空表(实测 200ms 时仍是空的,几百毫秒后才填满)
// ⇒ 空表一律当「未就绪」,否则会在什么都没做的状态下提前收敛。
const empty = Array.isArray(links) ? links.length === 0 : Object.keys(links).length === 0;
if (empty) return -1;
let changed = 0;
const fix = (getSlot, setSlot, type, originId) => {
if (String(originId) !== String(node.id)) return;
const s = getSlot();
if (s !== 1 && s !== 2) return; // 0 号口(conditioning)不参与
const want = type === "LATENT" ? 2 : 1;
if (s !== want) { setSlot(want); changed++; }
};
if (Array.isArray(links)) {
// 老式:[[id, origin_id, origin_slot, target_id, target_slot, type], ...]
for (const l of links) {
if (!l) continue;
fix(() => l[2], (v) => { l[2] = v; }, l[5], l[1]);
}
} else {
// 新版:{[id]: LLink},origin_slot 是 getter/setter,直接赋值有效
for (const lk of Object.values(links)) {
if (!lk) continue;
fix(() => lk.origin_slot, (v) => { lk.origin_slot = v; }, lk.type, lk.origin_id);
}
}
return changed;
}
// ③ 阶梯重试直到收敛。graph.links 的就绪时机在不同机器/图规模下差别很大,
// 单靠一帧 rAF 兜底会漏(这一版就栽在这:name 换了、slot 没换 ⇒ 用户看到「连错了」)。
const OUT_ORDER_RETRY_MS = [0, 50, 120, 260, 550, 1000, 1800, 3000];
function scheduleOutputOrderFix(node) {
let i = 0;
const step = () => {
if (!node || !node.graph) return; // 节点已被移除/图已换
const r = fixLinkSlots(node);
if (r === 0) return; // 已收敛,收手,不再干扰用户手动接线
i++;
if (i < OUT_ORDER_RETRY_MS.length) setTimeout(step, OUT_ORDER_RETRY_MS[i]);
};
setTimeout(step, OUT_ORDER_RETRY_MS[0]);
}
// 兼容旧调用点
function migrateOutputOrder(node) {
renameOutputSlots(node);
return fixLinkSlots(node);
}
function applyModelVisibility(node) {
migrateModelType(node); // 先把旧名字换成新名字,再决定显隐
const get = n => (node.widgets || []).find(w => w.name === n);
const mw = get("模型类型");
const model = mw ? mw.value : null;
const is21 = model === "Qwen Image 2.1";
const isLayered = model === "Qwen Layered";
// 2.1 专属参数:只在选 Qwen Image 2.1 时露出
for (const n of RES21_WIDGETS) {
const w = get(n);
if (w) w.hidden = !is21;
}
// 图层数:只在 Qwen Layered 时露出
const layer = get("图层数");
if (layer) layer.hidden = !isLayered;
// 输出宽度/高度:2.1 下只有在「自定义」宽高比时才需要,其他模型一直显示
const ar = get("宽高比");
const needManual = is21 && ar && ar.value === MANUAL_RATIO;
for (const n of ["输出宽度", "输出高度"]) {
const w = get(n);
if (w) w.hidden = is21 && !needManual;
}
refreshNodeSize(node);
}
function bindModelVisibility(node) {
for (const n of ["模型类型", "宽高比"]) {
const w = (node.widgets || []).find(x => x.name === n);
if (w && !w._tpVisBound) {
w._tpVisBound = true;
const prev = w.callback;
w.callback = function (v) {
if (prev) prev.call(this, v);
applyModelVisibility(node);
};
}
}
}
app.registerExtension({
name: EXT_NAME + "-generic",
beforeRegisterNodeDef(nodeType, nodeData, app) {
const cfg = DYNAMIC_NODES[nodeData.name];
if (!cfg) return;
const origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
origConfigure?.apply(this, arguments);
// 输出口改名必须抢在画布渲染连线之前(同步执行);
// 连线归位要等 graph.links 建好,交给阶梯重试(只等一帧太早,实测那时 links 还是空的)。
if (nodeData.name === "DynamicRefImageEncode") {
renameOutputSlots(this);
scheduleOutputOrderFix(this);
}
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)));
// 恢复「按模型类型显隐」状态(加载旧工作流后也要正确)
if (nodeData.name === "DynamicRefImageEncode") {
// 先修脏值(旧工作流参数错位会留下 NaN / 越界 / 非法枚举),再定显隐
sanitizeSizeParams(this);
applyModelVisibility(this);
bindModelVisibility(this);
}
});
};
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
origCreated?.apply(this, arguments);
if (this._tpInit) return;
this._tpInit = true;
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)));
};
// 为 DynamicRefImageEncode 设置最小尺寸 + 模型类型联动
if (nodeData.name === "DynamicRefImageEncode") {
this._tpMinSize = [270, 310];
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.setSize([270, 310]);
app.graph.change();
});
});
// 按「模型类型」控制参数显隐(2.1 才显示分辨率那组,Layered 才显示图层数)
sanitizeSizeParams(this);
applyModelVisibility(this);
bindModelVisibility(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) 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(() => {
// 用最小尺寸约束,而非固定尺寸
const s = node.computeSize();
const min = node._tpMinSize || [0, 0];
node.setSize([Math.max(s[0], min[0]), Math.max(s[1], min[1])]);
app.graph.change();
});
});
}
/* ============ Ref Independent ============ */
app.registerExtension({
name: EXT_NAME + "-refind",
beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "DynamicRefIndependent") return;
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;
applyRefInd(this, Math.max(1, Math.min(10, w.value || 1)));
sanitizeSizeParams(this);
applyModelVisibility(this);
bindModelVisibility(this);
});
};
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
origCreated?.apply(this, arguments);
if (this._tpRefInd) return;
this._tpRefInd = true;
const w = this.widgets?.find(w => w.name === "num_images");
if (!w) return;
applyRefInd(this, Math.max(1, Math.min(10, w.value || 1)));
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)));
};
// 为 DynamicRefIndependent 设置最小尺寸(宽270, 高310)
this._tpMinSize = [270, 310];
// 按「模型类型」控制参数显隐
sanitizeSizeParams(this);
applyModelVisibility(this);
bindModelVisibility(this);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.setSize([270, 310]);
app.graph.change();
});
});
};
},
});
function applyRefInd(node, n) {
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]);
}
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;
}
}
const expected = n * 2;
while (node.outputs && node.outputs.length > expected) {
node.removeOutput(node.outputs.length - 1);
}
while (node.outputs && node.outputs.length < expected) {
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");
}
}
// 用最小尺寸约束,而非固定尺寸
const min = node._tpMinSize || [0, 0];
const s = node.computeSize();
node.setSize([Math.max(s[0], min[0]), Math.max(s[1], min[1])]);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const s2 = node.computeSize();
node.setSize([Math.max(s2[0], min[0]), Math.max(s2[1], min[1])]);
app.graph.change();
});
});
}
/* ============ One Image Multi Prompt(一图多视角)============ */
app.registerExtension({
name: EXT_NAME + "-oneimg",
beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "OneImageMultiPrompt") return;
const origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
origConfigure?.apply(this, arguments);
requestAnimationFrame(() => {
const w = this.widgets?.find(x => x.name === "prompt数量");
if (w) applyOneImgPrompts(this, Math.max(2, Math.min(8, w.value || 2)));
sanitizeSizeParams(this); // 旧工作流错位留下的脏值先归位
applyModelVisibility(this); // 再按模型类型定显隐
bindModelVisibility(this);
bindPromptCount(this);
});
};
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
origCreated?.apply(this, arguments);
if (this._tpOneImg) return;
this._tpOneImg = true;
const w = this.widgets?.find(x => x.name === "prompt数量");
if (!w) return;
applyOneImgPrompts(this, Math.max(2, Math.min(8, w.value || 2)));
this._tpMinSize = [300, 310];
sanitizeSizeParams(this);
applyModelVisibility(this);
bindModelVisibility(this);
bindPromptCount(this);
};
},
});
function applyOneImgPrompts(node, n) {
for (const w of (node.widgets || [])) {
// 只匹配 prompt1~prompt8,不匹配「prompt数量」
if (w.name && /^prompt\d+$/.test(w.name)) {
const num = parseInt(w.name.replace("prompt", ""), 10);
w.hidden = num > n;
}
}
}
function bindPromptCount(node) {
const w = (node.widgets || []).find(x => x.name === "prompt数量");
if (!w || w._tpCountBound) return;
w._tpCountBound = true;
const prev = w.callback;
w.callback = function (v) {
if (prev) prev.call(this, v);
const n = this._node || this;
applyOneImgPrompts(n, Math.max(2, Math.min(8, v || 2)));
refreshNodeSize(n);
};
}
/* ============ Unified Loader(按钮逐个添加 LoRA)============ */
app.registerExtension({
name: EXT_NAME + "-unified",
beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "ToggleUnifiedLoader") return;
const MAX = 10;
/* ---- 加载模式:一体包 vs 分离组件,按模式只显示用得上的参数 ---- */
const MODE_SPLIT = "分离组件 (UNet+CLIP+VAE)";
const MODE_CKPT = "Checkpoint 一体包";
const SPLIT_WIDGETS = ["unet_name", "clip_name", "vae_name", "clip_type"];
const CKPT_WIDGETS = ["ckpt_name"];
/* ---- 加载模式:画成一个 UNet / Checkpoint 分段开关 ----
* 为什么是「改造原 combo」而不是「再新增一个开关控件」:
* widgets_values 是按位置存的,多一个 widget 会让旧工作流整体串位;前端也不允许用
* serialize:false 来"删参数"(同样会让值整体前移)。所以这里 widget 对象、名字、
* 值域、在列表里的位置全都不动,只把它的 draw / mouse 换成自绘开关 ——
* 序列化与旧工作流兼容性零影响,自绘失败还会回落成原生下拉。
* 点击哪一段就把 combo 的值改成那一段对应的枚举字符串(不硬编码文案)。 */
const MODE_TEXT = ["UNet", "Checkpoint"];
/* 量不到字体宽度时的估算用:最长标签的字符数(配合平均字宽 0.545em) */
const MODE_MAX_CHARS = MODE_TEXT.reduce((a, t) => Math.max(a, String(t).length), 0);
const MODE_TIP = "加载模式:UNet = 分离组件(UNet + CLIP + VAE 各一个文件)/ Checkpoint = 一体包";
const TG_PAD_Y = 3; // 胶囊在插槽行内的上下留白(左右对齐交给 tpWidgetMargin())
/* 滑块相对轨道的内缩量。轨道净高 = 行高 - TG_PAD_Y*2 = 14(行高 20 时);
* 滑块高 = 14 - 内缩*2 = 12,半高 6 —— 得装得下「上沿/2 + 下沿」:
* 文字按视觉盒居中(基线 = 胶囊中心 + 上沿/2,见 tpTextBaselineY),降部落到基线下方一个「下沿」。
* ⚠️ 这个约束必须用**真机主画布 ctx**(Arial 口径)量:11px "Checkpoint" 上沿 8 / 下沿 2
* ⇒ 需要 4 + 2 = 6,滑块半高 6 —— 正好贴边、不溢出。
* 若拿游离 canvas 的偏大口径(9 / 3 ⇒ 7.5)算,就会被误判成放不下而**把胶囊撑高**
* (2026-10-10.5 正是这么误加的:轨道 16 / 滑块 14,用户反馈「胶囊变粗了」)。
* ⇒ 以后要动这两个常量,先用 probe_main_ctx_metrics.mjs 量准,别凭感觉。 */
const MODE_KNOB_INSET = 1;
/* 文字相对「理想视觉居中位」的垂直微调(负 = 往上,正 = 往下)。
* 理想位(0):基线 = 胶囊中心 + 上沿/2 —— 此时「字母顶沿到滑块顶」与「基线到滑块底」相等。
* 用户实测微调旋钮:-1 = 整体上移 1px(降部跟着上移,上边留白更少)。 */
const MODE_TEXT_DY = -1;
const TG_TRACK = "rgba(126,138,156,0.30)";
const TG_LINE = "rgba(126,138,156,0.55)";
const TG_KNOB = "rgba(58,150,243,0.95)";
const TG_HOVER = "rgba(126,138,156,0.22)";
const TG_TEXT_ON = "#ffffff";
const TG_TEXT_OFF = "rgba(155,163,175,1)";
const CKPT_RE = /checkpoint|一体/i;
/* 构建标记:改造生效时会在控制台打一行,方便一眼确认前端加载的是不是新版(没这行 = 没刷新) */
const TP_MODE_BUILD = "2026-10-10.8";
/* ⚠️ 踩过的真坑(本机前端 1.53.10 实测):新版 node.size / node.pos 是**类数组对象**,
* JSON 出来是 {"0":270,"1":220},`Array.isArray(node.size)` 恒为 false。
* 拿它算宽度会得到 0 → 开关/命中判定直接罢工(症状就是「点了没反应」)。
* 所以一律按下标取数:typeof v[0] === "number" 才算有效。 */
function numAt(v, i) {
const x = v?.[i];
return (typeof x === "number" && isFinite(x)) ? x : null;
}
/** combo 的真实选项列表(备选项可能是 {value,text} 对象) */
function modeRawValues(w) {
let v = w?.options?.values;
if (typeof v === "function") { try { v = v(w); } catch (e) { v = null; } }
if (!Array.isArray(v)) return [];
return v.map(o => (o && typeof o === "object") ? o.value : o)
.filter(x => typeof x === "string");
}
/** 0 = UNet(分离组件)/ 1 = Checkpoint(一体包) */
function modeIndexOf(value) { return CKPT_RE.test(String(value ?? "")) ? 1 : 0; }
/** 取第 idx 档在 Python 枚举里的真实字符串(文案改了也不会崩) */
function modeValueFor(w, idx) {
const vals = modeRawValues(w);
const ck = vals.findIndex(v => CKPT_RE.test(v));
if (ck >= 0 && vals.length >= 2) {
return idx === 1 ? vals[ck] : vals[ck === 0 ? 1 : 0];
}
return idx === 1 ? MODE_CKPT : MODE_SPLIT;
}
function modeWidget(node) { return (node.widgets || []).find(w => w.name === "加载模式"); }
function getModeIndex(node) { return modeIndexOf(modeWidget(node)?.value); }
/** 切档:改值 + 走原有回调链(bindLoaderMode 的包装在里面 → 自动重排显隐) */
function setModeIndex(node, idx, opts) {
const w = modeWidget(node);
if (!w) return;
const v = modeValueFor(w, idx);
const o = opts || { e: null, node, canvas: app.canvas };
if (w.value === v) {
node.setDirtyCanvas?.(true, true);
return;
}
try {
/* 新版前端:走官方 setValue(内部会调 callback、并通知图已变更);
* 老版没有 setValue,就自己改值 + 手动跑回调。 */
if (typeof w.setValue === "function") {
w.setValue(v, o);
} else {
w.value = v;
w.callback?.(v, app.canvas, node, [0, 0], null);
}
} catch (e) {
console.warn("[Toggle-Pass] 加载模式切换失败,直接改值兜底", e);
w.value = v;
try { w.callback?.(v, app.canvas, node, [0, 0], null); } catch (e2) { /* 兜底也失败就只改值 */ }
}
app.graph?.change?.();
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
}
function tpRoundRect(ctx, x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, h / 2, w / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
ctx.lineTo(x + w, y + h - rr);
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
ctx.lineTo(x + rr, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
ctx.lineTo(x, y + rr);
ctx.quadraticCurveTo(x, y, x + rr, y);
ctx.closePath();
}
/* 与原生 widget 的左边缘对齐。
* 前端 widgets/BaseWidget.ts 里 `static margin = 15`(注释:From node edge to widget edge),
* widget 外框画在 `roundRect(margin, y, width - margin*2, height)` —— 所以下面所有下拉框
* 的左边缘都在节点内 x=15。我们的开关要跟它们齐平,就取这个值。
* 运行时 BaseWidget 未必挂在全局(实测本机 window.BaseWidget 为 null),拿不到就按 15 兜底。 */
function tpWidgetMargin() {
const m = globalThis.BaseWidget?.margin;
return (typeof m === "number" && m >= 0 && m <= 40) ? m : 15;
}
/* 量「两段标签的并集字形盒」(相对字母基线):{ w 最大宽, up 上沿, dn 下沿 }。
* 两段取并集是为了共用一条基线 —— 否则 "UNet"(无降部)会比 "Checkpoint" 浮高一截。
* ⚠️ actualBoundingBox* 的语义**跟随 textBaseline**(实测确认),所以量之前必须切回 alphabetic。
* ⚠️⚠️ 必须用**真正绘制的那个 ctx** 量:真机实测同一个 "600 11px sans-serif",
* app.canvas.ctx(挂在 DOM 上的画布)→ Arial 口径(上沿 8 / 下沿 2 / 宽 59.89),
* 而 document.createElement('canvas')(游离画布)→ 另一套(上沿 9 / 下沿 3 / 宽 62.64)。
* 拿游离 canvas 量会偏大一号 ⇒ 字号被误判成放不下而白缩一级。
* 量不到(老环境 / 被 mock)就按 Arial 口径兜底:0.73 / 0.20 倍字号、平均字宽 0.545 倍。 */
function tpGlyphBox(ctx, fs) {
let w = 0, up = 0, dn = 0, got = false;
try {
if (typeof ctx?.measureText === "function") {
const prev = ctx.textBaseline;
ctx.textBaseline = "alphabetic";
ctx.font = `600 ${fs}px sans-serif`;
for (let i = 0; i < MODE_TEXT.length; i++) {
const m = ctx.measureText(MODE_TEXT[i]);
if (!m) continue;
if (typeof m.width === "number" && isFinite(m.width)) w = Math.max(w, m.width);
const u = m.actualBoundingBoxAscent, d = m.actualBoundingBoxDescent;
if (typeof u === "number" && isFinite(u)) { up = Math.max(up, u); got = true; }
if (typeof d === "number" && isFinite(d)) dn = Math.max(dn, d);
}
ctx.textBaseline = prev;
}
} catch (e) { /* 落到比例兜底 */ }
if (!got) { up = fs * 0.73; dn = fs * 0.20; }
if (!(w > 0)) w = fs * 0.545 * MODE_MAX_CHARS; // 连宽度都量不到 → 按平均字宽估
return { w, up, dn };
}
/* 文字基线:让文字**看着居中**于胶囊 —— 按「视觉盒」居中(大写字母顶沿与基线对称),
* 而不是按「含降部的字形盒」居中。
* 为什么:按字形盒居中时,"Checkpoint" 的 p 降部把整个盒子往下拽,为了让它不掉出去,
* 大写字母只好整体上移 —— 肉眼就是「上面空得少、下面空得多」(用户报的就是这个)。
* 用户要的是字母居中 ⇒ 基线 = cy + 上沿/2(上沿取两段并集,共用一条基线)。
* 前提:滑块得装得下「上沿/2 + 下沿」,由调用方按这个约束挑字号(见 drawModeToggle)。
* 最后再叠加 MODE_TEXT_DY(用户实测微调,负 = 往上)。 */
function tpTextBaselineY(ctx, cy, fs) {
return cy + tpGlyphBox(ctx, fs).up / 2 + MODE_TEXT_DY;
}
/* 自绘:一条胶囊轨道 + 当前档的高亮滑块 + 两段文字(不画 label,整行都是开关)
* 参数:bw = 胶囊宽(不是整行宽);x0 = 胶囊左端(节点内坐标,取 tpWidgetMargin() 与上面下拉框对齐) */
function drawModeToggle(ctx, w, bw, y, H, x0) {
const h = H || 22;
const left = (typeof x0 === "number" && x0 >= 0) ? x0 : tpWidgetMargin();
const y0 = y + TG_PAD_Y;
const bh = h - TG_PAD_Y * 2;
if (bw < 24 || bh < 8) return;
const idx = modeIndexOf(w.value);
const seg = bw / 2;
ctx.save();
tpRoundRect(ctx, left, y0, bw, bh, bh / 2);
ctx.fillStyle = TG_TRACK;
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = TG_LINE;
ctx.stroke();
// 悬停的那一段(非当前档)给一点反馈
const hv = w._tpHover;
if ((hv === 0 || hv === 1) && hv !== idx) {
tpRoundRect(ctx, left + hv * seg + MODE_KNOB_INSET, y0 + MODE_KNOB_INSET,
seg - MODE_KNOB_INSET * 2, bh - MODE_KNOB_INSET * 2, (bh - MODE_KNOB_INSET * 2) / 2);
ctx.fillStyle = TG_HOVER;
ctx.fill();
}
// 当前档的滑块。
// ⚠️ 内缩量决定滑块高度,滑块得装得下「基线以上的字母 + 基线以下的降部」:
// 文字按视觉盒居中后,降部落在基线下方整一个「下沿」。
// 真机 Arial 口径 11px "Checkpoint":上沿 8 / 下沿 2 ⇒ 需 4 + 2 = 6;
// 轨道净高 bh=14、内缩 1 → 滑块 12(半高 6)⇒ 正好贴边,降部不戳出。
// (轨道/滑块尺寸维持原样,不加高 —— 用户明确不要「更粗的胶囊」。)
tpRoundRect(ctx, left + idx * seg + MODE_KNOB_INSET, y0 + MODE_KNOB_INSET,
seg - MODE_KNOB_INSET * 2, bh - MODE_KNOB_INSET * 2, (bh - MODE_KNOB_INSET * 2) / 2);
ctx.fillStyle = TG_KNOB;
ctx.fill();
// 两段文字:字号要同时满足「宽度放得下最长那段」和「高度装得下含降部的字形盒」
// · 宽度:实测 600 11px 下 "Checkpoint" = 59.9px,胶囊一缩水就不够,硬画会截成 "Checkp…"
// · 高度:基线改成「按视觉盒居中」后,降部要从基线往下吃掉半个滑块
ctx.textAlign = "center";
const knobH = bh - MODE_KNOB_INSET * 2;
const limitW = seg - 10; // 每段两侧各留 5px
const fits = (size) => {
const g = tpGlyphBox(ctx, size);
return g.w <= limitW && (g.up / 2 + g.dn) <= knobH / 2;
};
let fs = Math.min(13, Math.max(9, Math.round(bh * 0.85)));
while (fs > 9 && !fits(fs)) fs -= 1;
ctx.font = `600 ${fs}px sans-serif`;
// 基线 = 胶囊中心 + 上沿/2 ⇒「大写字母顶沿到滑块顶」= 「基线到滑块底」= 看着居中
ctx.textBaseline = "alphabetic";
const ty = tpTextBaselineY(ctx, y0 + bh / 2, fs);
for (let i = 0; i < 2; i++) {
ctx.fillStyle = i === idx ? TG_TEXT_ON : TG_TEXT_OFF;
ctx.fillText(MODE_TEXT[i], left + seg * i + seg / 2, ty);
}
ctx.restore();
}
/* 回落方案(widget 内自绘)里「胶囊」的真实范围:
* widget 占整行,但胶囊只在 `左边距 .. (行宽 - 左边距)` 之内、且宽度封顶 MODE_ROW_MAX。
* ★ 命中判定必须和绘制共用这一套几何,否则分界会跟眼睛看到的不一致 ——
* 例:节点宽 270 → 胶囊只有 147(15..162),左右分界在 88.5;
* 若按「整行宽的一半」(135) 判档,点在 88.5~135 之间就会切错档。
* (宽度来源:绘制传进来的行宽;命中判定时退到 node.size[0]) */
function widgetCapsule(node, width) {
const ww = (typeof width === "number" && width > 0) ? width : (numAt(node?.size, 0) || 0);
const m = tpWidgetMargin();
return { x: m, w: Math.min(MODE_ROW_MAX, Math.max(0, ww - m * 2)) };
}
/* 点击位置换算成节点内坐标。
* 不同前端版本传给 widget.mouse 的 pos 有的是节点内坐标、有的是画布坐标,
* 这里按「值明显超出节点宽度」来判别并兜底换算。 */
function toggleLocalX(node, pos, event) {
const ew = numAt(node?.size, 0) || 0;
const x = numAt(pos, 0);
if (x !== null && (ew <= 0 || (x >= 0 && x <= ew + 2))) return x;
if (typeof event?.canvasX === "number") return event.canvasX - (node?.pos?.[0] || 0);
return x === null ? 0 : x;
}
/* ★ 首选点击入口(onPointerDown)的坐标换算:
* 前端 processWidgetClick() 里对自定义 widget 的第一优先分支就是
* if (typeof widget.onPointerDown === 'function') { if (widget.onPointerDown(pointer, node, canvas)) return }
* —— 比 onClick / mouse 都早,返回真值即吃掉本次点击,原生下拉不会弹。
* 该回调拿到的是 CanvasPointer(本身没有 canvasX 字段),所以坐标优先取 canvas.graph_mouse
* (前端自己算点击落点时用的也是它),兜底再从 pointer.eDown / eUp / eMove 取 canvasX。
* 解析不出来返回 null:宁可不响应,也不要乱跳档(跳错档会让整条加载链错位)。 */
function modeLocalXFromPointer(pointer, node, canvas) {
const px = node?.pos?.[0] || 0;
const gx = numAt(canvas?.graph_mouse, 0);
if (gx !== null) return gx - px;
const ev = pointer?.eDown || pointer?.eUp || pointer?.eMove || pointer?.e;
if (ev && typeof ev.canvasX === "number") return ev.canvasX - px;
return null;
}
/* 从两种回调签名里解析「点到哪一档」
* 新版:onClick({ e, node, canvas }) —— 只有 e.canvasX(画布坐标),自己减 node.pos
* 老版:mouse(e, [x, y], node) / onClick(e, pos, node) —— 直接给节点内坐标
* 解析不出来返回 null:宁可不响应,也不要乱跳档(跳错档会让整条加载链错位)。 */
function resolveToggleHit(node, w, a, b, c) {
let n = node, e = null, pos = null;
if (a && typeof a === "object" && !Array.isArray(a) && (a.node || a.canvas)) {
n = a.node || node;
e = a.e || null;
} else if (numAt(b, 0) !== null) {
e = a; pos = b; n = c || node;
} else {
e = a; n = c || node;
}
const cap = widgetCapsule(n, w?.width);
if (!(cap.w > 0)) return null;
let x = NaN;
if (pos) x = toggleLocalX(n, pos, e);
else if (e && typeof e.canvasX === "number") x = e.canvasX - (n?.pos?.[0] || 0);
if (!isFinite(x)) return null;
/* 点在胶囊外 → 不算命中:既不吞事件(原生下拉/节点拖动照旧),也不误切档。
* 分界用胶囊自己的中心(与绘制一致),不是整行中心。 */
if (x < cap.x || x > cap.x + cap.w) return null;
return { node: n, e, idx: x < cap.x + cap.w / 2 ? 0 : 1 };
}
/* ---- 开关摆放位置:节点顶部第一行(与输出插槽「模型 / MODEL」同行)----
* 为什么不用 widget 承载:widget 的 y 由前端布局算(本地 y = 插槽区之下),
* 而用户要的位置是「插槽行左侧那块空白」→ 只能自己画。
* 做法:
* 1) 把「加载模式」widget 置 hidden —— 前端 isWidgetVisible()/getLayoutWidgets()
* 会跳过它(不绘制、不参与尺寸计算),但它**仍然在 node.widgets 数组里**,
* 序列化 widgets_values / 提交 / API 参数完全不受影响;
* 2) 改由节点级钩子接管:
* · node.onDrawForeground(ctx, canvas, canvasEl) → 自绘。
* 它在 drawNode 里位于「插槽绘制之前」(LGraphCanvas.ts:5843),不会被插槽盖住;
* · node.onMouseDown(e, pos, canvas) → 命中即切档并 return true 吃掉事件
* (不返回真值会被当成「点节点空白」→ 触发拖动)。
* Vue 节点模式(前端「现代节点设计」)不走这两个钩子 → 回落到 widget 自绘方案。 */
/* 开关尺寸与摆放(x/w 指「胶囊本身」的范围,左右端点都在节点内坐标)
* · 左端 = tpWidgetMargin(),与下面所有下拉框的左边缘严格对齐;
* · MODE_ROW_MAX = 147 —— 旧版胶囊可见宽 184px 的 80%(用户要求「再缩小 20%」);
* 固定值而非百分比:两档宽度完全一致,切档时开关不会再跳;
* · MODE_ROW_GAP = 45 —— 胶囊右端到节点右边至少留这么多。插槽标签「模型」是
* textAlign=right 画在 nw-10、实测 14px 字体下宽 28px(NodeSlot.draw: pos[0]-10),
* 即标签左边界 ≈ nw-38,再给圆点留一点,取 45 是安全线;
* · MODE_ROW_MIN = 120 —— 节点被手动拉得极窄时的兜底。 */
const MODE_ROW_GAP = 45;
const MODE_ROW_MIN = 120;
const MODE_ROW_MAX = 147;
/** 开关在节点本地坐标里的位置与尺寸(x/w 都是「胶囊本身」的范围) */
function modeRowGeom(node) {
const SH = Number(globalThis.LiteGraph?.NODE_SLOT_HEIGHT) || 20;
const nw = numAt(node?.size, 0) || 0;
const left = tpWidgetMargin(); // 与下方下拉框左边缘对齐(前端 BaseWidget.margin = 15)
/* 宽度固定 MODE_ROW_MAX:不随节点变宽而拉长(两档一致,切档不跳),
* 只在节点被拉得比「左边距 + 胶囊 + 右侧安全距离」还窄时才收缩。 */
let w = nw > 0 ? nw - left - MODE_ROW_GAP : MODE_ROW_MAX;
w = Math.min(MODE_ROW_MAX, Math.max(0, w));
if (w < MODE_ROW_MIN) {
// 节点极窄:兜底到 MIN,但绝不超出节点自身(否则胶囊会画到节点外)
w = nw > 0 ? Math.min(MODE_ROW_MIN, Math.max(0, nw - left)) : MODE_ROW_MIN;
}
/* 第一行插槽的纵向范围:优先按 getOutputPos(0) 反推(不同主题/版本行高不同),
* 拿不到再用 NODE_SLOT_HEIGHT 的默认值兜底。 */
let top = Math.round(SH * 0.2);
try {
const cy = numAt(node?.getOutputPos?.(0), 1);
const py = numAt(node?.pos, 1);
if (cy !== null && py !== null) {
top = Math.max(0, Math.round(cy - py - SH / 2));
}
} catch (e) { /* 用兜底值 */ }
return { x: left, y: top, w, h: SH };
}
/** 把开关搬到节点顶部第一行;成功接管返回 true(Vue 节点模式返回 false → 回落 widget 方案) */
function attachModeRowToggle(node) {
const w = modeWidget(node);
if (!w) return false;
if (w._tpModeRow) return true;
// 「现代节点设计」(Vue 节点)不经过 canvas 绘制,这些钩子不生效 → 保持 widget 方案
if (globalThis.LiteGraph?.vueNodesMode) return false;
w._tpModeRow = true;
w.hidden = true; // 只影响绘制/布局,不参与序列化过滤
node._tpModeW = w;
/* ⚠️ 这两个钩子是**实例属性**,直接赋值会顶掉基类(或别的插件)已挂的同名钩子。
* 实测:LiteGraph 给节点原型挂了 onDrawForeground(ComfyUI 用它做节点级装饰),
* 无条件覆盖等于把它吞掉。所以先存旧引用、链式调用(旧钩子抛错也不影响自绘)。 */
const prevDraw = typeof node.onDrawForeground === "function" ? node.onDrawForeground : null;
const prevDown = typeof node.onMouseDown === "function" ? node.onMouseDown : null;
node.onDrawForeground = function (ctx, ...rest) {
if (prevDraw) {
try { prevDraw.call(this, ctx, ...rest); } catch (e) { /* 旧钩子出错不影响开关 */ }
}
const mw = this._tpModeW;
if (!mw || this.flags?.collapsed) return;
const g = modeRowGeom(this);
try {
drawModeToggle(ctx, mw, g.w, g.y, g.h, g.x);
} catch (e) {
/* 自绘失败就留空:绝不能抛出去,否则整块画布的绘制会中断 */
}
};
node.onMouseDown = function (e, pos, canvas) {
const mw = this._tpModeW;
if (mw && !this.flags?.collapsed) {
const x = numAt(pos, 0), y = numAt(pos, 1);
if (x !== null && y !== null) {
const g = modeRowGeom(this);
if (x >= g.x && x <= g.x + g.w && y >= g.y && y <= g.y + g.h) {
setModeIndex(this, x < g.x + g.w / 2 ? 0 : 1,
{ e, node: this, canvas: canvas || app.canvas });
return true; // 吃掉本次点击,避免变成「拖动节点」
}
}
}
// 没点中开关 → 交还给原来的钩子(没有就返回 falsy)
return prevDown ? (prevDown.call(this, e, pos, canvas) || false) : false;
};
/* 只打一次:控制台有这行 = 前端确实加载了新版 js;没有 = 页面还没刷新(缓存) */
if (globalThis.__tpModeToggleBuild !== TP_MODE_BUILD) {
globalThis.__tpModeToggleBuild = TP_MODE_BUILD;
console.log(`[Toggle-Pass] 加载模式 → 顶部开关已接管 (build ${TP_MODE_BUILD})`);
}
refreshSize(node);
return true;
}
/** 把「加载模式」的 combo 换成开关(幂等,重复调用只生效一次)
* 首选搬到节点顶部第一行;不支持的场景回落成 widget 自绘。 */
function styleModeToggle(node) {
if (attachModeRowToggle(node)) return;
styleModeToggleInWidget(node);
}
/** 回落方案:原地替换 combo 的绘制与点击入口(widget 仍占一行) */
function styleModeToggleInWidget(node) {
const w = modeWidget(node);
if (!w || w._tpModeStyled) return;
w._tpModeStyled = true;
/* 只打一次:控制台有这行 = 前端确实加载了新版 js;没有 = 页面还没刷新(缓存) */
if (globalThis.__tpModeToggleBuild !== TP_MODE_BUILD) {
globalThis.__tpModeToggleBuild = TP_MODE_BUILD;
console.log(`[Toggle-Pass] 加载模式 → 分段开关已接管(回落) (build ${TP_MODE_BUILD})`);
}
w._tpOrigDraw = w.draw; // 原生绘制(前端逻辑:widget.draw 是函数就用它)
w._tpHover = -1;
w.label = ""; // 开关文字已说明含义,不再另画 label
w.tooltip = MODE_TIP;
w.draw = function (ctx, nodeArg, width, y, H) {
try {
/* 回落方案里 widget 占整行,原生 widget 外框在 margin..width-margin,
* 所以左端取同一个 margin、宽度也封到 MODE_ROW_MAX —— 与主方案视觉一致。
* ★ 几何统一由 widgetCapsule() 给,命中判定用的是同一套(分界才不会错位)。 */
const cap = widgetCapsule(nodeArg, width);
drawModeToggle(ctx, this, cap.w, y, H, cap.x);
} catch (e) {
// 自绘失败就退回原生下拉:宁可丑,也别留一个白框
if (typeof this._tpOrigDraw === "function") {
this._tpOrigDraw.call(this, ctx, nodeArg, width, y, H);
}
}
};
/* ⚠️ 点击必须接上「三个」入口,少接一个就会「点了没反应」:
* 1) onPointerDown(pointer, node, canvas) —— ★ 前端 processWidgetClick 的**第一优先**分支,
* 返回真值就直接吃掉本次点击。只接 onClick 不够:widget 一旦被前端提升成
* ComboWidget 实例,挂在它身上的 onClick(普通函数属性)会在 adoptConcreteWidget 里
* 被同名的原型方法顶掉,点击落到 ComboWidget.onClick(弹原生下拉 / 边缘加减档),
* 开关自然没反应。BaseWidget 原型上没有 onPointerDown,所以这条最稳。
* 2) onClick({ e, node, canvas }) —— 稍旧的新版前端走这条,e 是 CanvasPointerEvent(有 canvasX);
* 3) mouse(e, [x, y], node) —— 更老的版本。
* 三条都接上,原生下拉就此被左右两档取代。 */
w.onPointerDown = function (pointer, nodeArg, canvas) {
const n = nodeArg || node;
const x = modeLocalXFromPointer(pointer, n, canvas);
if (x === null) return false; // 拿不到坐标 → 不吞事件,留给后面的分支兜底
const cap = widgetCapsule(n, this.width);
if (!(cap.w > 0)) return false;
if (x < cap.x || x > cap.x + cap.w) return false; // 点在胶囊外 → 不吞事件
setModeIndex(n, x < cap.x + cap.w / 2 ? 0 : 1,
{ e: pointer?.eDown || pointer?.e, node: n, canvas: canvas || app.canvas });
return true; // 已处理 → 不再注册 onClick,原生下拉不会弹
};
w.onClick = function (a, b, c) {
const hit = resolveToggleHit(node, w, a, b, c);
if (hit) setModeIndex(hit.node, hit.idx, { e: hit.e, node: hit.node, canvas: app.canvas });
};
w.mouse = function (event, pos, nodeArg) {
const t = String(event?.type || "");
const n = nodeArg || node;
if (t === "pointerdown" || t === "mousedown") {
const hit = resolveToggleHit(node, this, event, pos, n);
if (hit) setModeIndex(hit.node, hit.idx, { e: event, node: hit.node, canvas: app.canvas });
return true;
}
if (t === "pointermove" || t === "mousemove") {
const hit = resolveToggleHit(node, this, event, pos, n);
const hIdx = hit ? hit.idx : -1; // 移出胶囊 → -1,把悬停高亮清掉
if (this._tpHover !== hIdx) {
this._tpHover = hIdx;
n?.setDirtyCanvas?.(true);
}
}
};
}
/* ---- 参数显示顺序:模型放最前 ----
* Python 侧 INPUT_TYPES 的顺序**故意保持不动**(它是 widgets_values 的位置基准),
* 这里只改画面上 widget 的先后 —— 而显示顺序 = node.widgets 顺序 = 序列化顺序,
* 所以调顺序必须配一次「旧文件 → 新顺序」的值迁移,见 migrateWidgetValues()。
* 一体包模式下 unet/clip/vae/clip_type 是隐藏的,所以第一眼看到的就是模型行。 */
const WIDGET_ORDER = [
"ckpt_name", "unet_name", // 模型(一体包 / 分离组件)
"clip_name", "vae_name", "clip_type", // 分离组件的另外三项
"加载模式",
];
const LORA_SUFFIX = ["name", "strength_model", "strength_clip"];
const LORA_RE = /^lora_(name|strength_model|strength_clip)_(\d+)$/;
/** 排序权重:模型参数 → 加载模式 → LoRA 组(组内 name/强度 次序不变)→ 按钮 */
function widgetRank(name) {
const i = WIDGET_ORDER.indexOf(name);
if (i >= 0) return i;
const m = LORA_RE.exec(String(name ?? ""));
if (m) return 100 + (parseInt(m[2], 10) - 1) * 3 + LORA_SUFFIX.indexOf(m[1]);
return 9999; // 按钮等:排最后,保持原有相对次序
}
/** 原地调整 node.widgets 的顺序。用原下标兜底,保证未知项的相对次序稳定 */
function reorderWidgets(node) {
const ws = node.widgets;
if (!Array.isArray(ws) || ws.length < 2) return;
const old = new Map(ws.map((w, i) => [w, i]));
ws.sort((a, b) => {
const d = widgetRank(a && a.name) - widgetRank(b && b.name);
return d !== 0 ? d : (old.get(a) - old.get(b));
});
}
/* ---- 旧工作流的参数迁移 ----
* 旧文件的 widgets_values 是「按位置」存的,位置表是改动前的:
* 0-3 unet_name / clip_name / vae_name / clip_type
* 4-33 10 组 LoRA(name / strength_model / strength_clip)
* 34-35 加载模式 / ckpt_name ← 只有「已经带加载模式」的那批文件才有这两项
* 36-37 两个按钮(值恒为 null,跳过)
* 改了顺序后前端会按新位置回填 → 参数整体串位,所以这里按名字对回去。
* 若文件里有 widgets_values_named(新版前端会写,键=参数名)就直接按名取,与顺序无关;
* 新存的工作流不会走错分支(named 与位置表都已同步,结果一致)。 */
const LEGACY_HEAD = ["unet_name", "clip_name", "vae_name", "clip_type"];
const LEGACY_TAIL = ["加载模式", "ckpt_name"];
const LEGACY_LORA_COUNT = 10;
const LEGACY_LORA_END = LEGACY_HEAD.length + LEGACY_LORA_COUNT * 3;
const LEGACY_MIN_LEN = LEGACY_LORA_END + LEGACY_TAIL.length + 2; // 含两个按钮
function legacyNameAt(i) {
if (i < LEGACY_HEAD.length) return LEGACY_HEAD[i];
if (i < LEGACY_LORA_END) {
const k = Math.floor((i - LEGACY_HEAD.length) / 3);
return `lora_${LORA_SUFFIX[(i - LEGACY_HEAD.length) % 3]}_${k + 1}`;
}
if (i < LEGACY_LORA_END + LEGACY_TAIL.length) return LEGACY_TAIL[i - LEGACY_LORA_END];
return null;
}
function migrateWidgetValues(node, info) {
if (!info || !Array.isArray(node.widgets)) return;
const pos = Array.isArray(info.widgets_values) ? info.widgets_values : null;
const named = (info.widgets_values_named && typeof info.widgets_values_named === "object")
? info.widgets_values_named : null;
if (!pos && !named) return;
const byName = {};
if (named) {
Object.assign(byName, named);
} else {
for (let i = 0; i < pos.length; i++) {
const n = legacyNameAt(i);
/* 老文件没有「加载模式 / ckpt_name」两项时,34/35 号位置放的是按钮的 null,
* 不判长度会把 null 写进加载模式 —— 必须挡一下 */
if (!n || (i >= LEGACY_LORA_END && pos.length < LEGACY_MIN_LEN)) continue;
byName[n] = pos[i];
}
}
let fixed = 0;
for (const [name, v] of Object.entries(byName)) {
if (v === null || v === undefined) continue;
const w = node.widgets.find(x => x && x.name === name);
if (!w || w.value === v) continue;
w.value = v;
fixed++;
}
if (fixed) {
console.log(`[Toggle-Pass] 顺序迁移:按参数名对回 ${fixed} 项(旧工作流的位置表已变更)`);
}
}
/* ---- LoRA 强度:界面上只保留一个滑块 ----
* 底层其实有两个参数:lora_strength_model_i / lora_strength_clip_i。
* 参数一个都不能删(删了会让 widgets_values 按位置回填时整体串位),
* 所以做法是:clip 滑块隐藏、值跟随 model,model 滑块换个显示名。
* 只改显示文本(widget.label),widget.name 不动 → 提交与序列化完全不受影响。 */
const LORA_STRENGTH_LABEL = "强度";
/** 把 model 强度同步给隐藏的 clip 强度,保证「看到的强度 = 实际生效强度」 */
function syncClip(g) {
if (!g.model || !g.clip) return;
const v = parseFloat(g.model.value);
g.clip.value = isFinite(v) ? v : 1.0;
}
/** 拖动强度滑块时同步 clip 并刷新按钮提示 */
function bindStrengthSync(node, g) {
if (!g.model || g.model._tpStrengthBound) return;
g.model._tpStrengthBound = true;
const prev = g.model.callback;
g.model.callback = function (v) {
if (prev) prev.call(this, v);
if (g.clip) {
const nv = parseFloat(v);
g.clip.value = isFinite(nv) ? nv : 1.0;
}
updateLabels(node);
};
}
function applyLoaderMode(node) {
const isCkpt = getModeIndex(node) === 1;
for (const n of SPLIT_WIDGETS) {
const w = (node.widgets || []).find(x => x.name === n);
if (w) w.hidden = isCkpt; // 一体包模式下不需要单独指定
}
for (const n of CKPT_WIDGETS) {
const w = (node.widgets || []).find(x => x.name === n);
if (w) w.hidden = !isCkpt; // 只有一体包模式才显示
}
refreshSize(node);
}
function bindLoaderMode(node) {
const w = (node.widgets || []).find(x => x.name === "加载模式");
if (!w || w._tpModeBound) return;
w._tpModeBound = true;
const prev = w.callback;
w.callback = function (v) {
if (prev) prev.call(this, v);
applyLoaderMode(node);
};
}
function getGroup(node, i) {
const s = String(i);
return {
name: node.widgets.find(w => w.name === `lora_name_${s}`),
model: node.widgets.find(w => w.name === `lora_strength_model_${s}`),
clip: node.widgets.find(w => w.name === `lora_strength_clip_${s}`),
};
}
/* 刷新节点尺寸。
* 顺序很讲究:setSize(computeSize()) 只按当前可见的 widget 算高度,
* 之后再 fitButtonWidth 把长文案撑开,否则按钮文字会画到节点外面。
* 连做两帧是因为 widget 显隐后前端有一帧的延迟,单帧会算到旧高度。 */
function refreshSize(node) {
const apply = () => {
node.setSize(node.computeSize());
fitButtonWidth(node);
};
apply();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
apply();
app.graph.change();
});
});
}
/* 按钮文案比节点还宽时会溢出到节点外面(computeSize 的宽度不含按钮文字)。
* 这里按实测文本宽度把节点撑宽一点,只涨不缩,且设上限避免被超长文案拉爆。 */
function fitButtonWidth(node) {
const add = node.widgets?.find(w => w.name === "_tp_add_lora_btn");
const text = add?.label;
if (!text || typeof node.setSize !== "function" || numAt(node.size, 0) === null) return;
let w = 0;
try {
const LG = globalThis.LiteGraph;
const measure = LG && LG.LGraphCanvas && LG.LGraphCanvas._measureText;
if (typeof measure === "function") w = measure(text) || 0;
if (!w) {
const ctx = app?.canvas?.ctx;
if (ctx && ctx.measureText) {
ctx.save();
ctx.font = "14px sans-serif";
w = ctx.measureText(text).width;
ctx.restore();
}
}
} catch (e) { /* 量不到就按字符数估 */ }
if (!w) w = text.length * 9;
const need = Math.min(Math.round(w) + 46, 430);
if (numAt(node.size, 0) < need) node.setSize([need, numAt(node.size, 1) || 0]);
}
function showGroup(node, i) {
const g = getGroup(node, i);
if (!g.name) return;
g.name.hidden = false;
const active = g.name.value !== "none";
if (g.model) {
g.model.hidden = !active;
g.model.label = LORA_STRENGTH_LABEL; // 只改显示名,参数名保持 lora_strength_model_i
bindStrengthSync(node, g);
}
if (g.clip) g.clip.hidden = true; // clip 强度滑块不再单独展示
syncClip(g); // 取值跟随 model,避免残留旧值
if (!g.name._tpBound) {
g.name._tpBound = true;
const prev = g.name.callback;
g.name.callback = function (v) {
if (prev) prev.call(this, v);
const a = v !== "none";
if (g.model) g.model.hidden = !a;
if (g.clip) g.clip.hidden = true;
syncClip(g);
node._tpLoraStatus = null; // 换了 LoRA,上一次的 patch 统计已过期
updateLabels(node);
refreshSize(node);
};
}
refreshSize(node);
}
/* ⚠️ 关键:隐藏 widget 后必须重算节点高度。
* computeSize() 会跳过隐藏的 widget,Node.setSize 又只按传入值赋值,
* 所以「只隐藏不 setSize」会留下一大片空白(把 10 组 LoRA 移除后就变成长条空框)。
* 批量隐藏时传 refresh=false,循环结束后统一 refreshSize 一次即可。 */
function hideGroup(node, i, refresh = true) {
const g = getGroup(node, i);
if (!g.name) return;
g.name.hidden = true;
if (g.model) g.model.hidden = true;
if (g.clip) g.clip.hidden = true;
if (refresh) refreshSize(node);
}
function countVisible(node) {
let c = 0;
for (let i = 1; i <= MAX; i++) {
const g = getGroup(node, i);
if (g.name && !g.name.hidden) c++;
}
return c;
}
/** 真正会被提交上去的 LoRA 数量(name 不是 none) */
function countEnabled(node) {
let c = 0;
for (let i = 1; i <= MAX; i++) {
const g = getGroup(node, i);
if (g.name && g.name.value && g.name.value !== "none") c++;
}
return c;
}
/** 已选 LoRA 中强度为 0 / 非法 的数量 —— 这种 LoRA 等于没生效 */
function countZeroStrength(node) {
let c = 0;
for (let i = 1; i <= MAX; i++) {
const g = getGroup(node, i);
if (!g.name || !g.name.value || g.name.value === "none") continue;
const v = parseFloat(g.model ? g.model.value : 1.0);
if (!isFinite(v) || v === 0) c++;
}
return c;
}
/* ---- LoRA 生效状态回传 ----
* 后端把「每个 LoRA 实际写入了多少个 patch」发过来,这里直接写进按钮文案。
* 再也不用猜「到底生效了没」:patch=0 → 没写进去;有数字 → Python 侧确实加了。
* 注意:这只反映「patch 有没有写进模型」,不代表画面上一定看得出来
* (量化底座、参考图主导等情况会让效果变弱)。 */
const LORA_STATUS_EVENT = "toggle-pass-lora-status";
try {
api.addEventListener(LORA_STATUS_EVENT, (ev) => {
try {
const d = ev?.detail || {};
const id = parseInt(d.node, 10);
const n = (app.graph && (app.graph.getNodeById?.(id) || app.graph._nodes_by_id?.[id]))
|| (app.graph?.nodes || []).find(x => String(x.id) === String(d.node));
if (!n) return;
n._tpLoraStatus = d;
if (typeof updateLabels === "function") updateLabels(n);
n.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
} catch (e) {
console.warn("[Toggle-Pass] LoRA 状态回传处理失败", e);
}
});
} catch (e) {
console.warn("[Toggle-Pass] 无法监听 LoRA 状态事件", e);
}
/** 后端回传的状态 -> 按钮文案尾巴(尽量短,长文案会把窄节点撑爆) */
function statusSuffix(node) {
const st = node?._tpLoraStatus;
if (!st) return "";
const items = Array.isArray(st.items) ? st.items : [];
// 两种失败要分开说:文件没找到(跳过) vs 键名一个都没匹配上(写入 0 个)
const skipped = items.filter(it => it.error).length;
const noMatch = items.filter(it => !it.error && (it.patches | 0) <= 0).length;
const total = st.total_patches | 0;
/* 覆盖率:LoRA 一共多少模块 / 实际落下多少 patch。
* 一体化包被 repack 过时会有个别层挂不上(如 img_mlp.gate_layer 被融进 gate_up),
* 这时显示 `192/224p`,一眼能看出「不是没加载,是底座少了几层」。 */
const cov = st.coverage || null;
const modTotal = cov ? (cov.modules | 0) : 0;
let s;
if (total > 0 && modTotal > total) s = ` · ${total}/${modTotal}p`;
else if (total > 0) s = ` · ${total}p`;
else s = " · ⚠️未写入";
if (noMatch > 0) s += ` · ⚠️${noMatch}不匹配`;
if (skipped > 0) s += ` · ⚠️${skipped}跳过`;
if (st.clip && st.clip.ok === false) s += " · ⚠️CLIP不符";
return s;
}
/** 完整信息挂在 tooltip 上,鼠标悬停可见(按钮上只放短标签) */
function statusTooltip(node) {
const st = node?._tpLoraStatus;
if (!st) return undefined;
const items = Array.isArray(st.items) ? st.items : [];
const lines = [`上次运行:合计写入 ${st.total_patches | 0} 个 patch`];
/* 模块覆盖率(分母 = LoRA 自己的模块数),用来解释「为什么只挂了一部分」 */
const cov = st.coverage || null;
if (cov && (cov.modules | 0) > 0) {
const mt = cov.modules | 0, mp = cov.patches | 0;
lines.push(mt > mp
? `模块覆盖 ${mp}/${mt}:有 ${mt - mp} 个模块没落到层上(多为一体化包 repack 融合,属底座差异,非节点问题)`
: `模块覆盖 ${mp}/${mt}:全部挂上`);
}
/* CLIP 校验:分体模式选错 CLIP 文件时会建出错误的文本编码器,跑得通但提示词全废 */
if (st.clip && st.clip.ok === false) {
lines.push(`⚠️ CLIP 不符:文本编码器 = ${st.clip.te},预期 ${st.clip.expect}`);
lines.push(" → 请更换 CLIP 文件,或检查「CLIP 类型」是否选对");
} else if (st.clip && st.clip.te) {
lines.push(`CLIP 校验通过:${st.clip.te}`);
}
if (st.quantized) lines.push("底座是量化模型(INT8/FP8)——LoRA 仍会写入,只是效果更『糊』");
for (const it of items) {
const nm = String(it.name || "").split(/[\\/]/).pop();
const mods = it.modules ? `/${it.modules}` : "";
lines.push(it.error
? `#${it.slot} ${nm}:跳过(${it.error})`
: `#${it.slot} ${nm}:${it.patches | 0}${mods} 个 patch`);
}
return lines.join("\n");
}
/** 把「已启用几个 / 有几个强度为 0」写进按钮文字,一眼能看出 LoRA 是否真的生效 */
function updateLabels(n) {
const add = n.widgets?.find(w => w.name === "_tp_add_lora_btn");
const rm = n.widgets?.find(w => w.name === "_tp_rm_lora_btn");
const visible = countVisible(n);
const enabled = countEnabled(n);
const zero = countZeroStrength(n);
const warn = zero > 0 ? ` ⚠️${zero}个强度0` : "";
const st = statusSuffix(n);
if (add) {
add.label = visible >= MAX
? `(已到上限 10)· 已启用 ${enabled}${warn}${st}`
: `+ 添加 LoRA · 已启用 ${enabled}${warn}${st}`;
add.tooltip = statusTooltip(n);
fitButtonWidth(n);
}
if (rm) rm.label = visible <= 0 ? "(没有可移除的)" : "- 移除 LoRA";
}
const origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
origConfigure?.apply(this, arguments);
/* ⚠️ 必须同步做、且在下面 rAF 里那套「按值显隐」之前:
* 旧工作流的位置表与现在不同,先把值按参数名对回去,
* 否则下面的显隐、强度同步、按钮统计全部跟着错。 */
migrateWidgetValues(this, arguments[0]);
styleModeToggle(this); // 加载模式 → 分段开关(幂等)
requestAnimationFrame(() => {
// 恢复加载模式的显隐(加载旧工作流后也要正确)
applyLoaderMode(this);
bindLoaderMode(this);
// 隐藏全部(批量操作,循环内不逐个刷新尺寸)
for (let i = 1; i <= MAX; i++) hideGroup(this, i, false);
// 恢复:maxActive 之前的所有组都显示
let maxActive = 0;
for (let i = 1; i <= MAX; i++) {
const g = getGroup(this, i);
if (g.name && g.name.value !== "none") maxActive = i;
}
for (let i = 1; i <= maxActive; i++) showGroup(this, i);
// 上一次运行的状态不跨工作流沿用
this._tpLoraStatus = null;
updateLabels(this);
// 无条件收一次尺寸:maxActive 为 0(一个 LoRA 都没用)时,
// 旧工作流里存的高尺寸否则会原样保留 → 一大片空白
refreshSize(this);
});
};
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
origCreated?.apply(this, arguments);
if (this._tpUnified) return;
this._tpUnified = true;
const node = this; // 闭包捕获,确保回调里 this 正确
// 初始按加载模式显隐(默认分离组件)
applyLoaderMode(node);
bindLoaderMode(node);
styleModeToggle(node);
// 初始隐藏全部 LoRA 组(批量隐藏后统一收尺寸,别逐个 setSize)
for (let i = 1; i <= MAX; i++) hideGroup(node, i, false);
// 添加按钮 widget
const btn = node.addWidget("button", "+ 添加 LoRA", null, () => {
for (let i = 1; i <= MAX; i++) {
const g = getGroup(node, i);
if (g.name && g.name.hidden) {
showGroup(node, i);
break;
}
}
updateLabels(node);
});
btn.name = "_tp_add_lora_btn";
btn.label = "+ 添加 LoRA";
// 移除按钮 widget
const rmBtn = node.addWidget("button", "- 移除 LoRA", null, () => {
// 从最后一组开始移除
let hit = false;
for (let i = MAX; i >= 1; i--) {
const g = getGroup(node, i);
if (g.name && !g.name.hidden) {
hideGroup(node, i, false); // 循环内先不刷新,最后统一收
g.name.value = "none"; // 重置选择
// ⚠️ 强度必须回到默认 1.0,不能设成 0:
// 设 0 后再「+ 添加 LoRA」复用的还是这一组,滑块停在 0
// → LoRA 加载了但强度为 0,表现就是「完全没效果」
if (g.model) g.model.value = 1.0;
if (g.clip) g.clip.value = 1.0;
hit = true;
break;
}
}
if (hit) node._tpLoraStatus = null; // 配置变了,旧状态不再代表本次出图
updateLabels(node);
// ⚠️ 关键:隐藏后必须收一次高度,否则节点留下一大片空白
refreshSize(node);
});
rmBtn.name = "_tp_rm_lora_btn";
rmBtn.label = "- 移除 LoRA";
// 参数显示顺序:模型放最前(按钮已建好,一起参与排序)
reorderWidgets(node);
updateLabels(node);
// 设置初始尺寸 宽270,高220
node.setSize([270, 220]);
app.graph.change();
};
},
});
/* ============ Image Compare(图片对比)============ */
/* 两组动态输入:source1..source10(原图)+ result1..result10(结果)。
* 两个组各自用 num_sources / num_results 控制数量;槽位顺序固定为「原图在前、结果在后」,
* 所以重建时会**按名字保存已连的线,重建后再接回去**,避免中间插入把连线弄乱。
*/
const COMPARE_SLOT_RE = /^(source|result)(\d+)$/;
const COMPARE_MAX = 10;
function clampInt(v, lo, hi) {
const n = Math.round(Number(v));
if (!Number.isFinite(n)) return lo;
return Math.max(lo, Math.min(hi, n));
}
// 显示名一律中文;内部 name(source1… / result1…)保持英文,
// 否则会连累「按名字存线 / 重建后接回」的逻辑与已保存工作流。
const COMPARE_SLOT_PREFIX = { source: "原图", result: "结果" };
function compareSlotLabel(name) {
const m = /^(source|result)(\d+)$/.exec(name);
return m ? COMPARE_SLOT_PREFIX[m[1]] + m[2] : name;
}
function applyCompareLabels(node) {
for (const inp of (node.inputs || [])) {
if (!COMPARE_SLOT_RE.test(inp.name)) continue;
const lb = compareSlotLabel(inp.name);
if (inp.label !== lb) inp.label = lb;
}
}
function syncCompareInputs(node) {
const ws = node.widgets?.find((w) => w.name === "num_sources");
const wr = node.widgets?.find((w) => w.name === "num_results");
if (ws && ws.label !== "原图数") ws.label = "原图数";
if (wr && wr.label !== "结果数") wr.label = "结果数";
const ns = clampInt(ws ? ws.value : 2, 1, COMPARE_MAX);
const nr = clampInt(wr ? wr.value : 2, 1, COMPARE_MAX);
const want = [];
for (let i = 1; i <= ns; i++) want.push(`source${i}`);
for (let i = 1; i <= nr; i++) want.push(`result${i}`);
const cur = (node.inputs || []).map((i) => i.name);
if (cur.length === want.length && want.every((n, i) => cur[i] === n)) {
applyCompareLabels(node);
return;
}
// 1) 记下现有连线(按槽名)
const graph = node.graph || app.graph;
const saved = {};
for (const inp of (node.inputs || [])) {
if (inp.link == null || !COMPARE_SLOT_RE.test(inp.name)) continue;
const l = graph?.links?.[inp.link];
if (l) saved[inp.name] = { origin_id: l.origin_id, origin_slot: l.origin_slot };
}
// 2) 清掉旧的动态槽(从后往前,避免索引错位)
for (let i = (node.inputs || []).length - 1; i >= 0; i--) {
if (COMPARE_SLOT_RE.test(node.inputs[i].name)) node.removeInput(i);
}
// 3) 按「原图在前、结果在后」重建
for (const nm of want) node.addInput(nm, "IMAGE");
applyCompareLabels(node);
// 4) 把连线接回去
for (let i = 0; i < (node.inputs || []).length; i++) {
const s = saved[node.inputs[i].name];
if (!s) continue;
const origin = graph?.getNodeById?.(s.origin_id);
if (origin) {
try { origin.connect(s.origin_slot, node, i); } catch (e) { /* 接不回就算了 */ }
}
}
// 5) 尺寸
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const sz = node.computeSize();
node.setSize([Math.max(sz[0], 260), Math.max(sz[1], 120)]);
app.graph.change();
});
});
}
app.registerExtension({
name: EXT_NAME + "-compare",
beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "ToggleImageCompare") return;
const origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
origConfigure?.apply(this, arguments);
requestAnimationFrame(() => syncCompareInputs(this));
};
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
origCreated?.apply(this, arguments);
if (this._tpCmpInit) return;
this._tpCmpInit = true;
syncCompareInputs(this);
for (const nm of ["num_sources", "num_results"]) {
const w = this.widgets?.find((x) => x.name === nm);
if (!w) continue;
const prev = w.callback;
w.callback = function (v) {
if (prev) prev.call(this, v);
syncCompareInputs(this._node || this);
};
}
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const sz = this.computeSize();
this.setSize([Math.max(sz[0], 260), Math.max(sz[1], 120)]);
app.graph.change();
});
});
};
},
});