Files
electricty00-ComfyUI-Toggle…/js/broadcast_toggle.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

1038 lines
46 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: ToggleUnifiedLoader —「全局广播」开关(自带,不依赖 cg-use-everywhere)
*
* 作用:节点上多一个「开 / 关」开关。
* 开 -> 运行(以及导出 API)时,把 MODEL / CLIP / VAE 自动接到图里**所有空着的同类型输入**上,
* 不用再一根根拉线;画布上不会留下这些线(提交时临时连、提交完立刻拆)。
* 同时画布上会用**紫色曲线**把「将会自动接上的线」画出来,方便核对。
* 关 -> 完全保持原样,自己拉线(默认)。
*
* 为什么不用 cg-use-everywhere 的 `properties.ue_convert`:
* 那是它的私有机制,装了才有、卸载就没了,而且只在它自己的 queuePrompt 钩子下生效。
* 这里自己实现一份最小广播:零依赖、导出 API 也照样带线。
*
* ⚠️ 序列化安全(这个节点最贵的教训,改动前务必读完):
* - 开关用 `addWidget("toggle", ...)` **追加在 widget 列表最末尾**,并且 `serialize:false`。
* `widgets_values` 是按位置存的;它既然不进 widgets_values,旧工作流的每一项位置都不动。
* —— 绝不能插在中间,也不能把一个已有参数标成 serialize:false(那样后面的值会整体前移)。
* - 开关的状态存在 `node.properties.tp_broadcast`(LiteGraph 原生字段,随工作流保存),
* 不占用 widgets_values。
* - 不动 Python 的 INPUT_TYPES,所以这个特性**不需要重启 ComfyUI**,Ctrl+F5 就行。
*
* ⚠️ 只在「前端」提交时生效:用 /prompt 接口直接 POST 原始 API 格式 JSON 时没有前端参与,
* 广播自然不会发生(想让它生效就先在界面里「导出 API」,导出的 JSON 已经带好线了)。
*/
import { app } from "../../scripts/app.js";
const EXT_NAME = "Toggle-Pass.broadcast";
const NODE_NAME = "ToggleUnifiedLoader";
const WIDGET_NAME = "全局广播";
const PROP = "tp_broadcast";
const BTN_ON = "开";
const BTN_OFF = "关";
/* 顶部自绘开关的几何常量 —— 必须与 multi_ref_encode.js 里「加载模式胶囊」那套保持一致,
* 否则开关会跟上面的 UNet/Checkpoint 胶囊对不齐(两套常量各改各的很容易漂)。 */
const ROW_SLOT_H = 20; // 兜底行高(真值优先取 LiteGraph.NODE_SLOT_HEIGHT)
const ROW_CAP_MAX = 147; // 胶囊可见宽上限(= multi_ref_encode.js 的 MODE_ROW_MAX)
const ROW_CAP_MIN = 120; // 节点极窄时的兜底宽
const ROW_GAP_RIGHT = 45; // 胶囊右端到节点右边的最小留白
const BC_MIN_W = 66; // 开关自身不窄于此(再窄「广播」+轨道就放不下了)
const BC_PAD_Y = 3; // 上下留白 —— 与 multi_ref_encode.js 的 TG_PAD_Y 同值,
// 这样开关高度 = 胶囊高度(都是 行高 - 3*2 = 14)
/* ------------------------------------------------------------------ *
* 一、节点上的开关
* ------------------------------------------------------------------ */
function isBroadcastOn(node) {
const w = (node.widgets || []).find((x) => x && x.name === WIDGET_NAME);
if (w) return !!w.value;
return !!(node.properties && node.properties[PROP]);
}
function numAt(v, i) {
const x = v?.[i];
return typeof x === "number" && isFinite(x) ? x : null;
}
function addBroadcastWidget(node) {
if ((node.widgets || []).some((x) => x && x.name === WIDGET_NAME)) return;
const on = !!(node.properties && node.properties[PROP]);
/* 原生 toggle:本机前端 1.53.10 的 widget 工厂支持 "toggle",
* {on, off} 决定两侧文案(ComfyUI 自己的 BOOLEAN 输入就是这么建的)。 */
let w = null;
try {
w = node.addWidget("toggle", WIDGET_NAME, on, (v) => {
try {
node.properties = node.properties || {};
/* 原生 toggle 会把新值当第一个参数传进来;拿不到才回落读 w.value
* (别只读 w.value:某些路径上 callback 先于 value 赋值,会写反) */
node.properties[PROP] = typeof v === "boolean" ? v : !!w.value;
} catch (e) {
console.warn("[Toggle-Pass] 广播开关状态写入 properties 失败", e);
}
app.graph?.setDirtyCanvas?.(true, true);
node.setDirtyCanvas?.(true, true);
}, { on: BTN_ON, off: BTN_OFF });
} catch (e) {
console.warn("[Toggle-Pass] 原生 toggle 控件不可用,跳过广播开关", e);
return;
}
if (!w) return;
/* 不进 widgets_values / 不进 prompt:旧工作流的位置表一项都不动 */
w.options = w.options || {};
w.options.serialize = false;
w.serialize = false;
w.tooltip =
"全局广播:开启后运行时把 MODEL / CLIP / VAE 自动接到图里所有空着的同类型输入上,不用拉线。" +
"开启时画布上会用紫色曲线画出「将自动接上的线」,方便核对(曲线只是预览,不是真实连线、不会被保存)。" +
"关闭则照旧手动连线。";
w.label = WIDGET_NAME;
/* ★ 2026-10-24:开关从「widget 列表末尾的一整行」搬到「顶部胶囊下面」。
* 做法:原生 widget 只当**状态容器**(值 / properties / 序列化全部原样不动),
* 用 hidden 让它不参与布局与绘制,再自己画一个小开关上去。
* 为什么 hidden 是安全的:widgets_values 是按「参与序列化的 widget」顺序存的,
* 这个 toggle 本来就是 serialize:false(不进 widgets_values),
* 藏起来既不增也不减参与序列化的项 ⇒ 旧工作流的位置表一项都不动。
* 顺带的好处:节点底部少了一整行,不再挤在「移除 LoRA」下面。 */
w.hidden = true;
node._tpBcW = w;
attachBroadcastRow(node);
}
/* ------------------------------------------------------------------ *
* 一之二、顶部自绘小开关(在 UNet / Checkpoint 胶囊的下一行、左半边)
*
* 为什么不用 widget 承载:widget 的 y 由前端布局算死(插槽区之下、按列表顺序往下排),
* 想放到「第一行插槽左侧的下方」这种位置,只能自己画。
* 这里复用的是「加载模式」开关同一套打法(见 multi_ref_encode.js 的 attachModeRowToggle):
* · 位置:第 1 行插槽的正下方(= 第 2 行插槽那一行的左半边,右侧 CLIP 标签不受影响)
* · 宽度:胶囊的一半(用户要求「一半位置就行」)
* · 绘制:node.onDrawForeground;命中:node.onMouseDown(返回真值吃掉事件,避免变成拖动节点)
* ⚠️ 这两个钩子是**实例属性**,必须存旧引用链式调用 —— 直接赋值会吞掉
* multi_ref_encode.js 挂的「加载模式」开关(两边都挂同一对钩子)。
* ------------------------------------------------------------------ */
/** 与 multi_ref_encode.js 的 tpWidgetMargin() 同源:BaseWidget.margin 默认 15 */
function bcMargin() {
const m = globalThis.BaseWidget?.margin;
return (typeof m === "number" && m >= 0 && m <= 40) ? m : 15;
}
function bcRoundRect(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();
}
/** 开关在节点本地坐标里的位置与尺寸(x/w 是我们自己这一块的范围,不含右侧留白) */
function bcRowGeom(node) {
const SH = Number(globalThis.LiteGraph?.NODE_SLOT_HEIGHT) || ROW_SLOT_H;
const nw = numAt(node?.size, 0) || 0;
const left = bcMargin();
/* 先按「加载模式胶囊」的宽度算(保证和上面的胶囊左右端点对得上) */
let capW = nw > 0 ? nw - left - ROW_GAP_RIGHT : ROW_CAP_MAX;
capW = Math.min(ROW_CAP_MAX, Math.max(0, capW));
if (capW < ROW_CAP_MIN) capW = nw > 0 ? Math.min(ROW_CAP_MIN, Math.max(0, nw - left)) : ROW_CAP_MIN;
/* 用户要求:占胶囊的一半(红框圈的就是胶囊前一半的宽度) */
const w = Math.max(BC_MIN_W, Math.round(capW / 2));
/* 第一行插槽的纵向位置(与加载模式胶囊同一套反推),再往下挪一行 = 胶囊正下方 */
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 + SH, w, h: SH };
}
/** 自绘:一个完整的小胶囊控件 —— 淡底色托底,左边 label、右边开关轨道。
* 为什么要有底色:节点里其它控件(下拉框、UNet/Checkpoint 胶囊)都有底色,
* 只画「裸文字 + 轨道」会显得浮在空白上,跟整体设计语言脱节。
* ★ 关键是「托底要淡、轨道要细」:
* 第一版给外框加了 1px 亮描边,结果和下边框围成**两层框**(外框 + 内轨道),
* 比上面那条单层胶囊笨重得多。现在外框只有一层很淡的填充、默认无描边,
* 视觉上退成「一块底色」而不是「一个框」,轨道才是主角。
* 配色对齐节点现有控件:下拉框底色 / 高亮蓝 #3a96f3。 */
function drawBroadcastRow(ctx, node, on, hover) {
const g = bcRowGeom(node);
const cy = g.y + g.h / 2;
/* 高度与上面的 UNet/Checkpoint 胶囊完全一致:行高 - BC_PAD_Y*2 = 14
* (胶囊那边是 TG_PAD_Y=3、净高 14 —— 两个常量必须同步改,否则一高一矮) */
const bh = g.h - BC_PAD_Y * 2;
const r = bh / 2;
const bx = g.x, bw = g.w;
try {
ctx.save();
/* ---- 托底:一层淡填充,只在开启/悬停时略提亮 ---- */
bcRoundRect(ctx, bx, g.y + BC_PAD_Y, bw, bh, r);
if (on) ctx.fillStyle = hover ? "rgba(58,150,243,0.24)" : "rgba(58,150,243,0.13)";
else ctx.fillStyle = hover ? "rgba(255,255,255,0.105)" : "rgba(255,255,255,0.06)";
ctx.fill();
/* 悬停加一道极淡描边:明确「这里能点」,又不会变成第二个框 */
if (hover) {
ctx.lineWidth = 1;
ctx.strokeStyle = on ? "rgba(58,150,243,0.45)" : "rgba(255,255,255,0.12)";
ctx.stroke();
}
/* ---- 内容整体居中:先量文字宽,再把「文字 + 间隙 + 轨道」作为一条内容居中摆放 ----
* 之前是「文字贴左 8px、轨道贴右 7px」,两边留白各算各的 ⇒ 一宽一窄,看着偏向一边。
* 现在统一算 padX,左右留白严格相等。 */
const label = "广播";
ctx.textAlign = "left";
ctx.font = "600 10px sans-serif"; // 用户要求:11px → 10px(小 10%)
let tw = 20; // 10px「广播」约 20px,量不到时兜底
try {
const lm = ctx.measureText(label);
if (lm && typeof lm.width === "number" && isFinite(lm.width) && lm.width > 0) tw = lm.width;
} catch (e) { /* 用兜底 */ }
const trackW = 24, GAP = 8;
const contentW = tw + GAP + trackW;
const padX = Math.max(5, (bw - contentW) / 2); // 左右留白相等 ⇒ 整条内容居中
const lx = bx + padX;
const tx = lx + tw + GAP;
/* ---- label:按视觉盒垂直居中 ----
* 不用 textBaseline="middle":Canvas 的 middle 按 (ascent+descent)/2 排,
* 对没有降部的中文会整体偏上。改成量出真实上沿、基线放到 cy + 上沿/2,
* 这样字形上下留白相等 —— 实测这样仍会低约半个像素,再补 0.5 的视觉修正。 */
ctx.fillStyle = on ? "rgba(230,238,250,1)" : "rgba(174,182,196,1)";
let asc = 7.3; // 10px 中文的兜底上沿(量不到时用)
try {
const lm = ctx.measureText(label);
const a = lm && lm.actualBoundingBoxAscent;
if (typeof a === "number" && isFinite(a) && a > 0) asc = a;
} catch (e) { /* 用兜底 */ }
ctx.textBaseline = "alphabetic";
ctx.fillText(label, lx, cy + asc / 2 - 0.5);
/* ---- 右侧开关:细轨道 + 小滑块(位置 tx 已在上面按居中算好) ---- */
const th = 10;
const ty = cy - th / 2;
bcRoundRect(ctx, tx, ty, trackW, th, th / 2);
if (on) {
const grad = ctx.createLinearGradient(tx, ty, tx, ty + th);
grad.addColorStop(0, "#4aa5ff");
grad.addColorStop(1, "#2f86e8");
ctx.fillStyle = grad;
} else {
ctx.fillStyle = "rgba(255,255,255,0.13)";
}
ctx.fill();
/* 关闭态补一道内沿,让轨道看起来是「凹槽」而不是一块平色 */
if (!on) {
ctx.lineWidth = 1;
ctx.strokeStyle = "rgba(0,0,0,0.18)";
ctx.stroke();
}
/* ---- 滑块:一点点投影,让它「浮」在轨道上 ---- */
const kr = 4;
const kx = on ? tx + trackW - 1 - kr : tx + 1 + kr;
ctx.save();
ctx.shadowColor = "rgba(0,0,0,0.30)";
ctx.shadowBlur = 2;
ctx.shadowOffsetY = 0.5;
ctx.beginPath();
ctx.arc(kx, cy, kr, 0, Math.PI * 2);
ctx.fillStyle = "#ffffff";
ctx.fill();
ctx.restore();
} finally {
/* ★ 必须 try/finally:漏 restore 会让整个画布的变换永久跑偏(踩过) */
try { ctx.restore(); } catch (e) { /* ignore */ }
}
}
function isBroadcastOnNode(node) {
const w = node?._tpBcW || (node?.widgets || []).find((x) => x && x.name === WIDGET_NAME);
if (w && typeof w.value === "boolean") return w.value;
return !!(node?.properties && node.properties[PROP]);
}
/** 切开关:真源写 properties,再同步给(隐藏的)原生 widget,前端才知道图被改了 */
function setBroadcast(node, on) {
const v = !!on;
try {
node.properties = node.properties || {};
node.properties[PROP] = v;
} catch (e) { /* ignore */ }
const w = node._tpBcW || (node.widgets || []).find((x) => x && x.name === WIDGET_NAME);
if (w) {
try {
if (typeof w.setValue === "function") w.setValue(v);
else w.value = v;
} catch (e) {
w.value = v;
}
}
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
app.graph?.change?.();
}
/** 把自绘开关挂到节点上(幂等) */
function attachBroadcastRow(node) {
if (node._tpBcRow) return;
/* 「现代节点设计」(Vue 节点)不走 canvas 绘制钩子 → 保持原生 widget 可见的回落 */
if (globalThis.LiteGraph?.vueNodesMode) {
const w = node._tpBcW;
if (w) w.hidden = false;
return;
}
node._tpBcRow = true;
const prevDraw = typeof node.onDrawForeground === "function" ? node.onDrawForeground : null;
const prevDown = typeof node.onMouseDown === "function" ? node.onMouseDown : null;
const prevMove = typeof node.onMouseMove === "function" ? node.onMouseMove : null;
node.onDrawForeground = function (ctx, ...rest) {
if (prevDraw) {
try { prevDraw.call(this, ctx, ...rest); } catch (e) { /* 旧钩子出错不影响开关 */ }
}
if (!this._tpBcW || this.flags?.collapsed) return;
try {
drawBroadcastRow(ctx, this, isBroadcastOnNode(this), !!this._tpBcHover);
} catch (e) {
/* 自绘失败就留空,绝不能抛出去(会中断整块画布绘制) */
}
};
/* 悬停高亮:只在「进入/离开」时刷新一次画布,不做逐像素重绘 */
node.onMouseMove = function (e, pos, canvas) {
if (this._tpBcW && !this.flags?.collapsed) {
const x = numAt(pos, 0), y = numAt(pos, 1);
let hv = false;
if (x !== null && y !== null) {
const g = bcRowGeom(this);
hv = x >= g.x && x <= g.x + g.w && y >= g.y && y <= g.y + g.h;
}
if (this._tpBcHover !== hv) {
this._tpBcHover = hv;
this.setDirtyCanvas?.(true, true);
}
}
return prevMove ? prevMove.call(this, e, pos, canvas) : false;
};
/* ⚠️ 光有 onMouseMove 不够:鼠标一移出节点,前端就不再给它派发 move,
* 高亮会**永久卡在亮着**的状态(实测:移开画布后 _tpBcHover 仍是 true)。
* 所以补一个离开钩子复位。 */
const prevLeave = typeof node.onMouseLeave === "function" ? node.onMouseLeave : null;
node.onMouseLeave = function (...args) {
if (this._tpBcHover) {
this._tpBcHover = false;
this.setDirtyCanvas?.(true, true);
}
return prevLeave ? prevLeave.apply(this, args) : undefined;
};
node.onMouseDown = function (e, pos, canvas) {
if (this._tpBcW && !this.flags?.collapsed) {
const x = numAt(pos, 0), y = numAt(pos, 1);
if (x !== null && y !== null) {
const g = bcRowGeom(this);
if (x >= g.x && x <= g.x + g.w && y >= g.y && y <= g.y + g.h) {
setBroadcast(this, !isBroadcastOnNode(this));
return true; // 吃掉点击,避免变成拖动节点
}
}
}
return prevDown ? (prevDown.call(this, e, pos, canvas) || false) : false;
};
}
/* 注:原 growToFit()(给追加的那行 widget 补节点高度)已随「开关搬到顶部」一起删除 ——
* 开关不再占用 widget 布局,节点高度不需要额外补。 */
function syncBroadcastWidget(node) {
const w = (node.widgets || []).find((x) => x && x.name === WIDGET_NAME);
if (!w) return;
const v = !!(node.properties && node.properties[PROP]);
if (!!w.value !== v) w.value = v;
/* 保险:极端情况下(前端没走 onNodeCreated 就直接 configure)自绘钩子可能还没挂 */
if (w.hidden) {
node._tpBcW = w;
attachBroadcastRow(node);
}
}
/* ------------------------------------------------------------------ *
* 二、提交时临时连线(最小广播实现)
* ------------------------------------------------------------------ */
/** 输入类型与输出类型是否匹配(兼容 "MODEL" / "A,B" / "*" 这几种写法) */
function typeMatch(inputType, outputType) {
const a = String(inputType ?? "");
const b = String(outputType ?? "");
if (!a || !b) return false;
if (a === b) return true;
if (a === "*" || b === "*") return true;
return a.split(",").some((s) => s.trim() === b);
}
/**
* 计算「广播计划」:哪些空着的输入会被接到哪个输出。
*
* ⚠️ 真接线(inflate)和画布上的曲线预览(computeShapes)**必须共用这一份**,
* 否则会出现「画的线」和「真接的线」不一致 —— 那种 bug 用户根本没法自查。
*/
function planBroadcast(graph) {
const pairs = [];
const ambiguous = new Set();
if (!graph || !Array.isArray(graph._nodes)) return { pairs, ambiguous };
/* 每个类型只认一个广播源:多个加载器都开广播时取第一个,并提示一次 */
const srcByType = new Map();
for (const n of graph._nodes) {
if (!n || n.type !== NODE_NAME || !isBroadcastOn(n)) continue;
const outs = n.outputs || [];
for (let i = 0; i < outs.length; i++) {
const t = outs[i] && outs[i].type;
if (!t || t === "*") continue;
if (srcByType.has(t)) {
ambiguous.add(t);
continue;
}
srcByType.set(t, { node: n, slot: i });
}
}
if (!srcByType.size) return { pairs, ambiguous };
for (const [t, src] of srcByType) {
for (const target of graph._nodes) {
/* 加载器自己不算,避免自己接自己 */
if (!target || target === src.node || target.type === NODE_NAME) continue;
const inputs = target.inputs || [];
for (let idx = 0; idx < inputs.length; idx++) {
const inp = inputs[idx];
/* 已经接了线的一律不动 —— 广播只补空位,绝不覆盖用户手连的线 */
if (!inp || inp.link != null) continue;
if (!typeMatch(inp.type, t)) continue;
pairs.push({ srcNode: src.node, srcSlot: src.slot, srcType: t, dstNode: target, dstSlot: idx });
}
}
}
return { pairs, ambiguous };
}
/**
* 把「开着广播」的加载器的三个输出,临时接到所有空着的同类型输入上。
* 返回一个还原函数;调用它会把临时连线全部拆掉。
*/
function inflate(appRef) {
const graph = appRef.rootGraph || appRef.graph;
const { pairs, ambiguous } = planBroadcast(graph);
if (ambiguous.size) {
console.warn(
"[Toggle-Pass] 有多个加载器开着「全局广播」,同类型输出只取第一个:",
Array.from(ambiguous).join(", "),
);
}
const undo = [];
for (const p of pairs) {
try {
p.srcNode.connect(p.srcSlot, p.dstNode, p.dstSlot);
} catch (e) {
continue;
}
/* connect 失败时不会抛,只表现为 link 仍为空 */
if (p.dstNode.inputs?.[p.dstSlot] && p.dstNode.inputs[p.dstSlot].link != null) {
const { dstNode, dstSlot } = p;
undo.push(() => {
try {
dstNode.disconnectInput(dstSlot);
} catch (e) {
/* 目标节点已被删掉之类,忽略 */
}
});
}
}
return () => {
for (let i = undo.length - 1; i >= 0; i--) undo[i]();
try {
appRef.graph?.setDirtyCanvas?.(true, true);
} catch (e) {
/* 无画布环境(导出脚本)忽略 */
}
};
}
/* ------------------------------------------------------------------ *
* 三、画布上的预览曲线(只画,不建真实 link)
* ------------------------------------------------------------------ */
/* 观感对齐 cg-use-everywhere:紫罗兰实线 + 一层柔光,比青色虚线耐看,
* 也不会被误当成「报错警告线」。 */
const PREVIEW_COLOR = "#a78bfa";
const PREVIEW_GLOW = "#7c5cff";
/**
* ⚠️⚠️ `boundingRect` 只在**画过**这个槽位之后才有效。
* 节点在可视区外 / 从未渲染时,槽位的 `boundingRect` 是 `[0,0,0,0]`,
* 而 `rectCenter` 对零尺寸会老老实实返回 `[0,0]` —— 于是端点被算成**图坐标原点**,
* 线就画到画布左上角那片空白里,看着像一根「悬空紫线」。
* 2026-11-06 用户截图里那根从节点往左伸出去就断掉的线就是这个:
* 目标是画面外的 `VAE解码`(pos.x=1352,槽位 boundingRect 全 0)。
* ⇒ 零尺寸一律视为「未布局」,退回 `getConnectionPos` 估算。
*/
function rectCenter(r) {
if (!r) return null;
if (Array.isArray(r) || typeof r[0] === "number") {
const x = numAt(r, 0);
const y = numAt(r, 1);
const w = numAt(r, 2);
const h = numAt(r, 3);
if (x == null || y == null) return null;
if (!(w > 0) || !(h > 0)) return null; /* 未布局 */
return [x + w / 2, y + h / 2];
}
const x = typeof r.x === "number" ? r.x : r.left;
const y = typeof r.y === "number" ? r.y : r.top;
if (typeof x !== "number" || typeof y !== "number") return null;
const w = r.width ?? r.w ?? 0;
const h = r.height ?? r.h ?? 0;
if (!(w > 0) || !(h > 0)) return null; /* 未布局 */
return [x + w / 2, y + h / 2];
}
/**
* 槽位中心(图坐标)。
*
* ⚠️ 优先 `boundingRect`,不要只信 `getConnectionPos`:
* 本机前端里槽位的 `pos` 字段**经常是 null**(只有走 widget 布局的那几个才被填),
* 而 `pos` 为 null 时 getConnectionPos 会退化成「按输入顺序估算」的公式
* (`node.pos + (index+0.7)*NODE_SLOT_HEIGHT`) —— 一旦节点里有 widget 夹在输入之间,
* 估出来的位置就和实际渲染对不上,线看起来就是「错位」。
* `boundingRect` 是渲染时算出来的真实盒子,节点一移动它就跟着更新。
*/
function slotPoint(node, isInput, idx) {
const list = isInput ? node?.inputs : node?.outputs;
const s = list && list[idx];
if (!s) return null;
const c = rectCenter(s.boundingRect);
if (c) return c;
try {
const p = node.getConnectionPos(isInput, idx);
if (p && isFinite(p[0]) && isFinite(p[1])) return [p[0], p[1]];
} catch (e) {
/* 节点被删/布局未完成 */
}
return null;
}
/**
* 把「运行时会自动接上的线」算成屏幕坐标下的贝塞尔。
* 不创建任何 link ⇒ 不进工作流、不影响保存,纯视觉提示。
*
* 坐标链路:槽位(图坐标)→ `ds.convertOffsetToCanvas` → 屏幕坐标,
* 然后 setTransform 到 dpr,直接按屏幕坐标画。
* ⚠️ 不要自己手搓 `(p + offset) * scale`:那是在赌前端内部变换的实现细节。
*/
function computeShapes(canvas) {
/* ⚠️ 必须和 inflate() 用**同一张图**(rootGraph 优先):
* 广播接哪张图,就画哪张图。两边取不同引用(canvas.graph / app.graph)
* 在切换工作流或进子图的瞬间会不一致,线就会指向图里已不存在的位置。 */
const g = (app && (app.rootGraph || app.graph)) || (canvas && canvas.graph);
const ds = canvas && canvas.ds;
if (!g || !ds) return null;
const { pairs } = planBroadcast(g);
if (!pairs.length) return null;
const scale = ds.scale || 1;
let dpr = canvas.dpr;
if (!dpr) {
const r = canvas.canvas?.getBoundingClientRect?.();
dpr = r && r.width > 0 ? canvas.canvas.width / r.width : 1;
}
const off = ds.offset || [0, 0];
const toScreen = (p) => {
try {
const r = ds.convertOffsetToCanvas?.([p[0], p[1]]);
if (r && isFinite(r[0]) && isFinite(r[1])) return [r[0], r[1]];
} catch (e) {
/* 兜底走下面的手算 */
}
return [(p[0] + (off[0] || 0)) * scale, (p[1] + (off[1] || 0)) * scale];
};
const items = [];
for (const p of pairs) {
const aG = slotPoint(p.srcNode, false, p.srcSlot);
const bG = slotPoint(p.dstNode, true, p.dstSlot);
if (!aG || !bG) continue;
const a = toScreen(aG);
const b = toScreen(bG);
if (!isFinite(a[0]) || !isFinite(b[0])) continue;
/* 控制点跟官方 Spline 连线同一套:取两点距离的 3 成(不小于 40 图单位),
* 起点往右甩、终点从左边进 —— 收口方向和真实连线一致,
* 一眼就能看出线是从哪个口出来的。 */
const dist = Math.hypot(bG[0] - aG[0], bG[1] - aG[1]);
const d = Math.max(40, dist * 0.3) * scale;
items.push({
aG,
bG,
a,
b,
c1: [a[0] + d, a[1]],
c2: [b[0] - d, b[1]],
/* 原始引用留着:点亮槽位时要拿槽位的 type(决定颜色)和归属节点(判遮挡) */
srcNode: p.srcNode,
srcSlot: p.srcSlot,
dstNode: p.dstNode,
dstSlot: p.dstSlot,
});
}
if (!items.length) return null;
return { items, scale, dpr };
}
/**
* 取「背景层」的 ctx。
*
* ⚠️⚠️ 这里是最容易白忙一场的地方:新版前端把背景画在**离屏** canvas 上
* (`this.bgcanvas`),画完之后才 `ctx.drawImage(bgcanvas, ...)` 贴到主画布,
* 再在主画布上画节点。所以往主 ctx 上画背景层的内容 —— 会立刻被那张贴图盖掉,
* 代码跑了、坐标也对,就是看不见(实测一个紫色像素都没有)。
* 必须画进 bgcanvas 自己的 ctx,贴图时才会把它一起带上来,节点再盖在上面。
* 老架构没有 bgcanvas(背景直接画在主 ctx 上),此时退回主 ctx。
*/
function bgCtx(canvas) {
const bg = canvas && canvas.bgcanvas;
if (bg && typeof bg.getContext === "function") {
if (!canvas.__tpBgCtx || canvas.__tpBgCtx.canvas !== bg) canvas.__tpBgCtx = bg.getContext("2d");
return canvas.__tpBgCtx;
}
return canvas && canvas.ctx;
}
/**
* 线本体 —— 画在**节点下层**(背景层 ctx,交付 `drawBackCanvas` 之后)。
* 所以线穿过节点时会被节点自然遮住,跟真实连线一个观感:
* 该露的缝隙里露出来,节点上干干净净,不挡标题也不挡参数。
*/
function drawBroadcastLines(ctx, canvas) {
const plan = computeShapes(canvas);
if (!plan || !ctx) return;
const { items, scale, dpr } = plan;
ctx.save();
/* ⚠️ save / restore 必须用 try/finally 包住:
* 中间任何一次 `it.a` 取值、bezier 计算出问题抛异常,若跳过 restore,
* 这个 ctx 就会**永久**留在 `dpr + translate + scale` 变换下 ——
* 之后所有绘制(包括官方的选择框、节点)都会整体偏移、放大,
* 而且看起来跟本插件毫无关系,极难排查。宁可少画一帧,也不能漏 restore。 */
try {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const it of items) {
const trace = () => {
ctx.beginPath();
ctx.moveTo(it.a[0], it.a[1]);
ctx.bezierCurveTo(it.c1[0], it.c1[1], it.c2[0], it.c2[1], it.b[0], it.b[1]);
};
/* 一层柔光底:深色画布上紫线才不会是「一根生硬的细线」 */
ctx.globalAlpha = 0.16;
ctx.strokeStyle = PREVIEW_GLOW;
ctx.lineWidth = Math.max(5, 7 * scale);
trace();
ctx.stroke();
/* 主线 */
ctx.globalAlpha = 0.95;
ctx.strokeStyle = PREVIEW_COLOR;
ctx.lineWidth = Math.max(1.8, 2.2 * scale);
trace();
ctx.stroke();
}
} finally {
ctx.restore();
}
if (typeof window !== "undefined") {
window.__tpLastDraw = items.map((it) => ({
a: [Math.round(it.aG[0]), Math.round(it.aG[1])],
b: [Math.round(it.bG[0]), Math.round(it.bG[1])],
aS: [Math.round(it.a[0]), Math.round(it.a[1])],
bS: [Math.round(it.b[0]), Math.round(it.b[1])],
bend: 0,
}));
}
}
/**
* 端点小圆点 —— 默认和线**同一层**(背景层,被节点盖住)。
*
* ⚠️ 为什么改了(2026-11-06 用户实测):
* 早先画在 `draw()` 末尾 = 全画布最上层,理由是「槽位压在节点边线上,
* 放下层会被盖掉、线头看着像断的」。但这个优先级会让圆点在**任何**节点
* 移到线上方时照样穿出来 —— 用户拿节点当遮罩盖内容时,一串紫点浮在
* 节点上面,看着像脏点。
* 现在和线同层:节点盖住线的同时把点一起盖住,观感统一(线从节点边缘
* 伸出来,正好是官方连线的样子)。
* 想回到旧行为:`localStorage.tp_dots_layer = "front"` 再刷新。
*/
function drawBroadcastDots(ctx, canvas) {
const plan = computeShapes(canvas);
if (!plan || !ctx) return;
const { items, scale, dpr } = plan;
ctx.save();
/* 同 drawBroadcastLines:restore 必须兜底,否则整个画布的变换会被带偏 */
try {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalAlpha = 0.95;
ctx.fillStyle = PREVIEW_COLOR;
const r = Math.max(2.2, 2.6 * scale);
for (const it of items) {
for (const pt of [it.a, it.b]) {
ctx.beginPath();
ctx.arc(pt[0], pt[1], r, 0, Math.PI * 2);
ctx.fill();
}
}
} finally {
ctx.restore();
}
}
/**
* 取某个类型(MODEL / CLIP / VAE…)的官方配色。
* 前端把颜色放在 `LGraphCanvas.link_type_colors`,没命中再看
* `canvas.default_connection_color_byType`,都没有就退回中灰。
*/
function slotTypeColor(canvas, type) {
const t = String(type || "").toUpperCase();
const tbl = (typeof window !== "undefined" && window.LGraphCanvas && window.LGraphCanvas.link_type_colors) || {};
let c = tbl[t];
if (!c && canvas && canvas.default_connection_color_byType) c = canvas.default_connection_color_byType[t];
return c || "#9A9";
}
/**
* 这个槽位会不会被**后绘制**的节点盖住?
* 画布按 `graph._nodes` 顺序绘制,下标大的在上面。只要有点落在
* 某个「更晚绘制」的节点矩形里,就说明它被挡住了 —— 此时不该点亮,
* 否则又是一颗浮在别的节点上的假点。
*/
function slotHiddenByOtherNode(pt, owner, nodes) {
if (!pt || !owner || !nodes) return false;
const i = nodes.indexOf(owner);
if (i < 0) return false;
for (let k = i + 1; k < nodes.length; k++) {
const n = nodes[k];
if (!n || n === owner || !n.pos || !n.size) continue;
const x0 = n.pos[0];
const y0 = n.pos[1] - 30;
if (pt[0] >= x0 && pt[0] <= x0 + n.size[0] && pt[1] >= y0 && pt[1] <= y0 + n.size[1] + 30) return true;
}
return false;
}
/**
* 把会被广播接上的槽位**点亮**(补一个和「已连接」一样的实心点)。
*
* ⚠️ 为什么必须先看 `slot.link`:
* 官方槽位是「有 link 填实心、没 link 只描边」。广播只是**预览**,提交前不会
* 真的建连线,所以 `slot.link` 一直是 null ⇒ 槽位永远显示成空心,线却已经
* 画过去了,看着就是「接上了但没点亮」。
* 这里只在「空着、且没被别的节点盖住、位置确定(boundingRect 有效)」时补实心点,
* 颜色取该槽位类型的官方配色,所以和手动连线的观感一致。
*
* 画在**节点之后**(`draw()` 末尾):槽位点在节点边框上,属于节点自身的一部分,
* 画在下层会被节点吃掉。
*/
function drawBroadcastSlotLit(ctx, canvas) {
const plan = computeShapes(canvas);
if (!plan || !ctx) return;
const g = (app && (app.rootGraph || app.graph)) || (canvas && canvas.graph);
const nodes = (g && g._nodes) || [];
const { items, scale, dpr } = plan;
ctx.save();
try {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const r = Math.max(2, 3 * scale);
for (const it of items) {
const ends = [
{ node: it.srcNode, idx: it.srcSlot, isInput: false, pt: it.a, ptG: it.aG },
{ node: it.dstNode, idx: it.dstSlot, isInput: true, pt: it.b, ptG: it.bG },
];
for (const q of ends) {
const list = q.isInput ? q.node && q.node.inputs : q.node && q.node.outputs;
const slot = list && list[q.idx];
if (!slot) continue;
if (slot.link != null) continue; /* 真连过了,官方自己就画实心 */
if (q.node.flags && q.node.flags.collapsed) continue;
const br = slot.boundingRect;
if (!br || !(numAt(br, 2) > 0)) continue; /* 位置不确定就别瞎点 */
if (slotHiddenByOtherNode(q.ptG, q.node, nodes)) continue;
ctx.beginPath();
ctx.fillStyle = slotTypeColor(canvas, slot.type);
ctx.arc(q.pt[0], q.pt[1], r, 0, Math.PI * 2);
ctx.fill();
}
}
} finally {
ctx.restore();
}
}
/**
* 端点圆点放哪一层:
* "back"(默认)→ 跟线一起画进 bgcanvas,节点能盖住
* "front" → 旧的 draw() 末尾,全画布最上层(能穿出节点)
* 用 localStorage 而不是 `app.extensionManager.setting`:后者会 POST 到
* 服务端 `comfy.settings.json`,污染用户真实环境。
*/
function dotsOnFront() {
try {
return window.localStorage && window.localStorage.getItem("tp_dots_layer") === "front";
} catch (e) {
return false;
}
}
/**
* 诊断工具:把当前每一根广播线的两端、归属节点、槽位实算位置全部列表打印。
* 只读,不改任何行为。控制台执行 `__tpAudit()` 即可。
*
* 用途:线画到空白处(端点悬空、没有归属节点)时,一眼看出是哪条 pair 算错、
* 目标槽位的 `boundingRect` 到底是什么。
*/
function installAudit() {
if (typeof window === "undefined" || window.__tpAudit) return;
window.__tpAudit = function (opts) {
const cv = app.canvas;
const g = (app && (app.rootGraph || app.graph)) || (cv && cv.graph);
if (!g) return "没有图";
const near = (p, n) => {
if (!p || !n) return "noPoint";
const pad = 14;
const x0 = n.pos[0] - pad;
const y0 = n.pos[1] - 30 - pad;
const x1 = n.pos[0] + n.size[0] + pad;
const y1 = n.pos[1] + n.size[1] + pad;
return p[0] >= x0 && p[0] <= x1 && p[1] >= y0 && p[1] <= y1;
};
const rows = [];
const { pairs } = planBroadcast(g);
(pairs || []).forEach((p, i) => {
const aG = slotPoint(p.srcNode, false, p.srcSlot);
const bG = slotPoint(p.dstNode, true, p.dstSlot);
const slot = p.dstNode?.inputs?.[p.dstSlot];
rows.push({
"#": i,
源: (p.srcNode?.title || p.srcNode?.type) + " [" + p.srcSlot + "]",
目标: (p.dstNode?.title || p.dstNode?.type) + " [" + p.dstSlot + "] " + (slot?.name || ""),
起点: aG ? aG.map(Math.round).join(",") : "-",
起点在节点内: near(aG, p.srcNode),
终点: bG ? bG.map(Math.round).join(",") : "-",
终点在节点内: near(bG, p.dstNode),
目标节点矩形: p.dstNode ? [p.dstNode.pos[0], p.dstNode.pos[1], p.dstNode.size[0], p.dstNode.size[1]].map(Math.round).join(",") : "-",
终点槽boundingRect: slot && slot.boundingRect ? Array.from(slot.boundingRect).map(Math.round).join(",") : String(slot && slot.boundingRect),
});
});
console.table(rows);
const bad = rows.filter((r) => r.起点在节点内 !== true || r.终点在节点内 !== true);
if (bad.length) console.warn(`[Toggle-Pass] ${bad.length} 根线的端点不在自己节点范围内(悬空):`, bad);
if (!opts || opts.raw !== false) return rows;
};
}
/**
* 装配两处钩子:
* ① `drawBackCanvas` **之后** → 线 + 端点圆点(画进 bgcanvas,节点下层)
* ② `draw()` 开头 → 兜底重包 `drawBackCanvas`(前端偶尔会换掉它)
*
* ⚠️ 为什么不用 `onDrawForeground`(踩过的坑):
* ComfyUI 前端用 `watch(() => app.canvas, e => e.onDrawForeground = chain(...))`
* 维护画布钩子,那个 watch 会在我们之后重新赋值 —— 包装被整个替换掉,
* 实测 hookLen 变成了别人的 88 字符函数,绘制一次都没执行。
*/
function installPreviewPainter(appRef) {
const canvas = appRef.canvas;
if (!canvas || typeof canvas.draw !== "function") return;
if (canvas.__tpDrawPatched) return;
canvas.__tpDrawPatched = true;
installAudit();
/* ⚠️ 为什么挂在 `drawBackCanvas` **之后**(踩过的坑):
* 一开始插在 `drawFrontCanvas` **之前**,结果一个像素都看不到 ——
* drawFrontCanvas 内部会把前景层重绘一遍,我们画的东西被它盖掉了。
* 画在 drawBackCanvas 之后:背景和真实连线都画完了,节点还没画 ⇒
* 线正好落在「节点下层」,节点一盖就自然遮住,节点之间的空白处保留。
*
* ⚠️ 只用 `this.ctx` / `this`,**不看入参**:实测 drawBackCanvas 的实参
* 既不是 ctx 也不是 canvas(另一个入口报过 `ctx.save is not a function`),
* 照它取坐标系会一条线都算不出来。参数原样透传,绝不改写。 */
const wrapBack = (cv) => {
const inner = cv.drawBackCanvas;
if (typeof inner !== "function" || inner.__tpPainter) return;
const wrapped = function (...args) {
const r = inner.apply(this, args);
try {
const bg = bgCtx(this);
drawBroadcastLines(bg, this);
if (!dotsOnFront()) drawBroadcastDots(bg, this);
} catch (e) {
if (typeof window !== "undefined") window.__tpDrawErr = "line:" + String((e && e.message) || e);
}
return r;
};
wrapped.__tpPainter = true;
cv.drawBackCanvas = wrapped;
};
wrapBack(canvas);
const origDraw = canvas.draw;
canvas.draw = function (...args) {
/* 前端某些路径会重新赋值 drawBackCanvas(拖拽中断时见过),
* 每帧开头兜一次,发现被换掉就重包 —— 成本是一次属性读。 */
if (!this.drawBackCanvas || !this.drawBackCanvas.__tpPainter) wrapBack(this);
let r;
try {
r = origDraw.apply(this, args);
} finally {
try {
drawBroadcastSlotLit(this.ctx, this);
if (dotsOnFront()) drawBroadcastDots(this.ctx, this);
} catch (e) {
if (typeof window !== "undefined") window.__tpDrawErr = "dot:" + String((e && e.message) || e);
}
}
return r;
};
}
function patchGraphToPrompt(appRef) {
if (appRef.__tpBroadcastPatched) return;
/* ⚠️ 必须**两层都包**,只包 graphToPrompt 会漏:
* cg-use-everywhere 在它自己的 setup() 里就把**当时的** app.graphToPrompt 存成变量,
* 点「运行」时它走 queuePrompt → 用存下来的原函数(可能绕过我们这一层)→ 广播静默失效。
* 包住 queuePrompt 就能兜住这条路径:不管里面谁调谁,临时线在我们这一层就已经接好了。
* 用 depth 计数保证「只广播一次」:外层已经接过,内层直接透传、不要重复接、更不要提前拆。 */
let depth = 0;
function around(fn) {
if (depth > 0) return fn();
depth += 1;
const restore = inflate(appRef);
const done = () => {
depth -= 1;
restore();
};
let ret;
try {
ret = fn();
} catch (e) {
done();
throw e;
}
if (ret && typeof ret.then === "function") {
return ret.then(
(v) => {
done();
return v;
},
(e) => {
done();
throw e;
},
);
}
done();
return ret;
}
const origPrompt = appRef.graphToPrompt;
if (typeof origPrompt === "function") {
appRef.graphToPrompt = function (...args) {
return around(() => origPrompt.apply(this, args));
};
}
const origQueue = appRef.queuePrompt;
if (typeof origQueue === "function") {
appRef.queuePrompt = function (...args) {
return around(() => origQueue.apply(this, args));
};
}
appRef.__tpBroadcastPatched = true;
/* 给自检脚本用:直接在「广播生效」上下文里跑一段同步/异步逻辑 */
appRef.__tpBroadcastAround = (fn) => around(fn);
/* 给自检脚本用:列出当前会广播哪些线(只算不接) */
appRef.__tpBroadcastPlan = () =>
planBroadcast(appRef.graph).pairs.map(
(p) => `${p.dstNode.id}:${p.dstNode.inputs?.[p.dstSlot]?.name}<-${p.srcNode.id}:${p.srcSlot}`,
);
}
/* ------------------------------------------------------------------ *
* 三、注册
* ------------------------------------------------------------------ */
app.registerExtension({
name: EXT_NAME,
async setup() {
patchGraphToPrompt(app);
installPreviewPainter(app);
},
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_NAME) return;
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = origCreated ? origCreated.apply(this, arguments) : undefined;
const node = this;
/* 加载器自己的 onNodeCreated 末尾会 setSize([270,220]),
* 所以这里必须排在它之后加控件、再补高度 */
addBroadcastWidget(node);
/* 前端重建过画布实例的话,钩子会挂在新实例上缺失——这里顺手兜一次 */
try {
installPreviewPainter(app);
} catch (e) {
/* 装不上不影响功能 */
}
return r;
};
/* 读档时 properties 已经恢复,把开关同步成存档里的状态 */
const origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const r = origConfigure ? origConfigure.apply(this, arguments) : undefined;
syncBroadcastWidget(this);
return r;
};
},
});