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

505 lines
21 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: 采样器(一体)— 参数联动
*
* 「采样方案」决定哪些参数真正生效,把不生效的灰掉并锁住,避免
* 「步数写着 20、其实按 8 步的表在跑」这种误导。
*
* 自动(按步数 + 调度器)-> 步数 / 调度器 可用;自定义表灰
* 预置表(turbo 等) -> 步数 / 调度器 / 自定义表 全灰,步数显示成表真实步数
* 自定义表 -> 自定义表可用;步数 / 调度器 灰
*
* 只动 widget.disabled + options 的 min/max/values + DOM readOnly,
* **不碰 serialize**,所以 widgets_values 的位置与顺序完全不变,老工作流不受影响。
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const NODE_NAME = "ToggleSampler";
const SCHEME_AUTO = "自动(按步数 + 调度器)";
const SCHEME_CUSTOM = "自定义表";
/* 节点宽度。预设用的是完整模型名,最长一条实测 240px("Qwen-Image Layered(9步·CFG 2.5)"),
加上左侧 47px 的「⚡预设」和两侧留白,340 就会开始重叠 ⇒ 取 350。
⚠️ 宽度不够时 combo 的值是右对齐画的,会把左边的标题整个盖住,
看起来就成了「一个没有名字的下拉框」—— 这正是之前被误认成"重复控件"的原因。
⇒ 老工作流里存的是 300/340,加载时若不够就补到 PRESET_FIT_WIDTH(只加不缩)。 */
const INITIAL_WIDTH = 350;
const PRESET_FIT_WIDTH = 350;
/* node.size 是"类数组对象"(Array.isArray 恒为 false),取值必须逐下标。 */
function sizeAt(node, i) {
const s = node && node.size;
if (!s) return 0;
const v = typeof s[i] === "number" ? s[i] : s[String(i)];
return typeof v === "number" ? v : 0;
}
/** 节点太窄就把标题挤没了,补到能放下最长预设名的宽度。只加宽,不缩窄。 */
function ensureWidthForPreset(node) {
const w = sizeAt(node, 0);
if (!w || w >= PRESET_FIT_WIDTH) return false;
try {
node.size[0] = PRESET_FIT_WIDTH; // 逐下标赋值,整体赋值会被类数组吞掉
node.setDirtyCanvas?.(true, true);
app.canvas?.setDirty?.(true, true);
return true;
} catch (e) {
return false;
}
}
/* 控件上显示的名字。**内部 name 一律不改** —— widgets_values_named 和 /prompt 的
inputs 都按 name 走,改 name 等于让所有已存工作流失联。改 label 才是安全的。 */
const LABEL_SCHEME = "Sigma 曲线"; // 原名「采样方案」:它只管噪声曲线,容易和「预设」混
const LABEL_PRESET = "⚡预设"; // 原名「模型预设」:四个字太长,窄节点上会没地方显示
/* 各预置表对应的真实步数(= 表点数 - 1)。
与 sampler.py 的 SIGMA_PRESETS 保持一致;只用于把「步数」显示得跟实际一致,
不参与任何计算 —— 后端永远按表跑。 */
const PRESET_STEPS = {
"Qwen-Image-2.1 turbo 8步": 8,
"LTX-2 视频 8步(蒸馏)": 8,
"LTX-2 视频 3步(低噪)": 3,
};
const W_SCHEME = "采样方案";
const W_SAMPLER = "采样器";
const W_SCHEDULER = "调度器";
const W_STEPS = "步数";
const W_CFG = "CFG";
const W_CUSTOM = "自定义Sigma表";
const W_MODEL_PRESET = "模型预设";
/* ==================== 模型预设 ====================
*
* 预设表只存在后端(sampler.py 的 MODEL_PRESETS),这里通过
* /toggle_pass/sampler_presets 拉回来,**不在 JS 里再抄一份**,
* 免得以后加模型时两边对不上。
*
* 语义是「一键填充」而不是「持续锁定」:选完预设后,只要用户手动改了
* 采样方案/采样器/调度器/步数/CFG 中的任何一个,下拉就自己退回「手动」——
* 这样控件显示的永远是真实状态,不会出现「写着 Qwen 预设、其实在跑 40 步」。
*/
const PRESET_NONE = "手动(不套用)";
const PRESET_API = "/toggle_pass/sampler_presets";
// 预设会写到的五个参数(降噪和随机种不归预设管,改它们不该影响预设显示)
const PRESET_KEYS = [W_SCHEME, W_SAMPLER, W_SCHEDULER, W_STEPS, W_CFG];
/* 「模型预设」挪到最前面之前的控件顺序(与 sampler.py 的 LEGACY_WIDGET_ORDER 同步)。
1.57 的存档会额外写一份 widgets_values_named(按名字恢复,位置无关),但更早的
纯位置存档只能按顺序对号;把这张表交给 nodeData.fallbackWidgetsValuesNames,
老存档就会按名字重新落到正确控件上,而不是整体错一位。 */
const LEGACY_WIDGET_ORDER = [
W_SCHEME, W_SAMPLER, W_SCHEDULER, W_STEPS, W_CFG, "降噪",
"噪波随机种", "control_after_generate", W_CUSTOM, W_MODEL_PRESET,
];
/* 旧预设名 → 当前预设名。权威副本在 sampler.py 的 PRESET_ALIASES,由
/toggle_pass/sampler_presets 下发;这里初始只放一份兜底,fetch 成功后会被覆盖。
为什么需要兜底:加载工作流(onConfigure)可能发生在接口返回之前,那一刻
如果表是空的,旧短名就翻译不出来,下拉会停在 `Layered · 9步` 这种已不存在的值上。
fetch 完成后 retroFixPresets() 会对画布上已有节点再补一次。 */
const PRESET_ALIASES = {
"Qwen 2.1 · 25步": "Qwen-Image 2.1(25步)",
"Qwen 2.1 turbo · 8步表": "Qwen-Image 2.1 turbo(8步·固定表)",
"Krea2 turbo · 8步": "Krea 2 turbo(8步)",
"Krea2 细节 · 12步": "Krea 2 turbo・细节增强(12步)",
"FLUX2 klein · 4步": "FLUX.2 klein(4步·beta)",
"FLUX2 高清 · 4步": "FLUX.2 klein・高清修复(4步)",
"Z-Image turbo · 8步": "Z-Image turbo(8步)",
"Layered · 9步": "Qwen-Image Layered(9步·CFG2.5)",
"2511 AIO · 4步": "Qwen-Image Edit 2511 AIO(4步)",
};
let presetPromise = null;
/** 别名到齐后,把画布上已经加载完的节点再修一遍(加载早于接口返回的情况)。 */
function retroFixPresets() {
const nodes = (app.graph && app.graph._nodes) || [];
for (const n of nodes) {
if (!n || n.type !== NODE_NAME) continue;
try {
if (fixPresetValue(n)) n.setDirtyCanvas?.(true, true);
} catch (e) {
/* 单个节点异常不影响其余 */
}
}
}
function fetchPresets() {
if (!presetPromise) {
presetPromise = api
.fetchApi(PRESET_API)
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
// 失败不要缓存 null,否则这个页面里永远拿不到预设
if (!d || !d.presets) {
presetPromise = null;
return d;
}
if (d.aliases) {
Object.assign(PRESET_ALIASES, d.aliases);
retroFixPresets();
}
return d;
})
.catch(() => {
presetPromise = null;
return null;
});
}
return presetPromise;
}
const findW = (node, name) => node.widgets && node.widgets.find((w) => w.name === name);
/** 只在值真的不同、且该值确实在下拉可选项里时才写 —— 免得把 widget 写成一个非法值。 */
function setValue(w, v) {
if (!w || v === undefined || v === null) return false;
const vals = w.options && w.options.values;
if (Array.isArray(vals) && !vals.includes(v)) {
console.warn("[Toggle-Pass] 预设值不在可选项里,跳过:", w.name, v);
return false;
}
if (w.value === v) return false;
w.value = v; // 直接赋值不触发 callback,正是我们要的
return true;
}
/**
* 把某个预设套到节点上。
*
* 顺序很关键:先换「采样方案」并立刻跑一次 applySchemeState —— 因为调度器/步数
* 会随方案被解锁或锁死(锁死时 options.min=max=当前值,这时再写就写不进去了)。
* 解锁之后再填其余四项,最后再跑一次让锁定状态和步数显示对齐。
*/
async function applyPreset(node, name) {
if (!name || name === PRESET_NONE) return;
if (!node.__tpPresets) {
const d = await fetchPresets();
if (d && d.presets) node.__tpPresets = d.presets;
}
const conf = node.__tpPresets && node.__tpPresets[name];
if (!conf) {
console.warn("[Toggle-Pass] 找不到预设:", name);
return;
}
if (node.__tpApplying) return;
node.__tpApplying = true;
try {
setValue(findW(node, W_SCHEME), conf.scheme);
applySchemeState(node);
setValue(findW(node, W_SAMPLER), conf.sampler);
setValue(findW(node, W_SCHEDULER), conf.scheduler);
setValue(findW(node, W_STEPS), conf.steps);
setValue(findW(node, W_CFG), conf.cfg);
} finally {
node.__tpApplying = false;
}
applySchemeState(node);
node.setDirtyCanvas(true, true);
if (node.graph && node.graph.setDirtyCanvas) node.graph.setDirtyCanvas(true, false);
}
/** 用户手改了预设管辖的参数 —— 把下拉退回「手动」,别让它说谎。 */
function markManual(node) {
if (node.__tpApplying) return; // 预设自己填的值不算
const w = findW(node, W_MODEL_PRESET);
if (!w || w.value === PRESET_NONE) return;
w.value = PRESET_NONE;
node.setDirtyCanvas(true, true);
}
/**
* 这个值是不是「采样方案」的取值?
*
* 用来识别一种错位:老工作流没有 widgets_values_named,只能按位置恢复,而
* 「模型预设」被挪到了第一位 —— 于是它拿到的其实是老位置 0 的采样方案值。
* 采样方案和模型预设的取值集合完全不重叠,所以这里可以可靠地区分。
*/
function looksLikeScheme(v) {
if (typeof v !== "string") return false;
return v === SCHEME_AUTO || v === SCHEME_CUSTOM
|| Object.prototype.hasOwnProperty.call(PRESET_STEPS, v);
}
/**
* 把「模型预设」的值修成合法的:
* · 旧长名 -> 新短名(改过名的老存档)
* · 拿到采样方案的值 -> 退回「手动」(纯位置存档的错位)
* 返回是否改动过。
*/
function fixPresetValue(node) {
const w = findW(node, W_MODEL_PRESET);
if (!w || typeof w.value !== "string") return false;
if (Object.prototype.hasOwnProperty.call(PRESET_ALIASES, w.value)) {
w.value = PRESET_ALIASES[w.value];
return true;
}
if (looksLikeScheme(w.value)) {
// 位置错位:这一格拿到的是「采样方案」的值,退回手动
w.value = PRESET_NONE;
return true;
}
// 剩下的可能是个已废弃的旧名:别名表没覆盖时留在控件上会显示成一个不存在的值,
// 退回「手动」更诚实 —— 真正的采样参数还在,只是不再声称套着某个预设。
const vals = w.options && w.options.values;
if (Array.isArray(vals) && vals.length && !vals.includes(w.value)) {
console.warn("[Toggle-Pass] 未知预设名,已退回手动:", w.value);
w.value = PRESET_NONE;
return true;
}
return false;
}
/** 从手填的 sigma 表里数步数(N 个点 = N-1 步;解析不出来就返回 0) */
function countSigmaSteps(text) {
if (!text) return 0;
const nums = String(text)
.replace(/[\n;\t]/g, ",")
.split(",")
.map((s) => s.trim())
.filter((s) => s && Number.isFinite(Number(s)));
return nums.length >= 2 ? nums.length - 1 : 0;
}
/**
* 让灰掉的控件仍然显示数值。
*
* ⚠️ 前端 BaseWidget 里写死了:
* get _displayValue() { return this.computedDisabled ? '' : String(this.value) }
* 也就是 disabled 时数值会被画成空 —— 光灰化会让「步数」变成一条空白,
* 反而看不出实际在跑几步。这里在实例上覆盖该 getter 把值保住。
* 属于私有 API,若某天前端改掉,最坏结果只是退回「空白显示」,不会报错。
*/
function keepValueVisible(widget) {
if (widget.__tpKeepValue) return;
widget.__tpKeepValue = true;
try {
Object.defineProperty(widget, "_displayValue", {
configurable: true,
get() { return String(this.value); },
});
} catch (e) { /* 忽略 */ }
}
/**
* 灰掉 + 锁住一个控件。
*
* disabled 本身只管渲染(前端源码注释:Disabled widgets are rendered at half opacity),
* 所以还要从数据层断掉交互:
* · 数字控件 -> 把 min/max 压成当前值,怎么拖都变不了
* · 下拉控件 -> values 只留当前项
* · 多行文本框 -> DOM readOnly
* 原值都记在 widget.__tp* 上,恢复时还原。
*/
function setEnabled(widget, enabled) {
if (!widget) return;
const lock = !enabled;
if (widget.disabled === lock) return;
widget.disabled = lock;
if (lock) keepValueVisible(widget);
const opts = widget.options || {};
// 数字控件:压扁区间
if (typeof opts.min === "number" && typeof opts.max === "number") {
if (widget.__tpMin === undefined) {
widget.__tpMin = opts.min;
widget.__tpMax = opts.max;
}
if (lock) {
opts.min = widget.value;
opts.max = widget.value;
} else {
opts.min = widget.__tpMin;
opts.max = widget.__tpMax;
}
}
// 下拉控件:只留当前值(恢复时必须还原成同一个数组引用)
if (Array.isArray(opts.values) && !Array.isArray(widget.__tpValues)) {
widget.__tpValues = opts.values;
if (lock) opts.values = [widget.value];
} else if (Array.isArray(widget.__tpValues)) {
opts.values = lock ? [widget.value] : widget.__tpValues;
}
// 多行文本框是真 DOM(textarea),widget.disabled 不会让它变灰
const el = widget.element || widget.inputEl;
if (el) {
try {
el.readOnly = lock;
el.style.opacity = lock ? "0.45" : "";
} catch (e) { /* 忽略 */ }
}
}
function applySchemeState(node) {
const find = (name) => node.widgets && node.widgets.find((w) => w.name === name);
const wScheme = find(W_SCHEME);
if (!wScheme) return;
const scheme = wScheme.value;
const isAuto = scheme === SCHEME_AUTO;
const isCustom = scheme === SCHEME_CUSTOM;
const wSteps = find(W_STEPS);
const prev = node.__tpPrevScheme;
// 1) 离开「自动」档之前,先把用户自己的步数记下来(必须在改值之前!)
if (prev === SCHEME_AUTO && !isAuto && wSteps) {
node.__tpAutoSteps = wSteps.value;
}
// 2) 回到「自动」档时还给他
if (isAuto && prev !== undefined && prev !== SCHEME_AUTO && node.__tpAutoSteps != null && wSteps) {
wSteps.value = node.__tpAutoSteps;
node.__tpAutoSteps = undefined;
}
// 3) 选预置表时把「步数」显示成实际步数(上锁时 min/max 取的是当前 value)
const presetSteps = PRESET_STEPS[scheme];
if (presetSteps && wSteps && wSteps.value !== presetSteps) {
wSteps.value = presetSteps; // 直接赋值不触发 callback
}
// 3b) 自定义表档:按表里的点数算步数(N 点 = N-1 步),免得「20 步」跟表对不上
if (isCustom && wSteps) {
const wCustom = find(W_CUSTOM);
const n = countSigmaSteps(wCustom && wCustom.value);
if (n && wSteps.value !== n) wSteps.value = n;
}
node.__tpPrevScheme = scheme;
// 4) 上锁
setEnabled(wSteps, isAuto);
setEnabled(find(W_SCHEDULER), isAuto);
setEnabled(find(W_CUSTOM), isCustom);
if (node.graph && node.graph.setDirtyCanvas) node.graph.setDirtyCanvas(true, false);
app.canvas?.setDirty?.(true, true);
}
app.registerExtension({
name: "ComfyUI-Toggle-Pass.sampler-widgets",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_NAME) return;
/* 老存档兜底:1.57 之前的存档只有按位置的 widgets_values。
「模型预设」从末尾挪到了第一位,纯位置恢复会整体错一位 —— 把老顺序
交给前端,它就会按 widget 名字重新对号。(新版存档有 widgets_values_named,
本来就不受位置影响,这里只是补上更早的那批。) */
nodeData.fallbackWidgetsValuesNames = LEGACY_WIDGET_ORDER;
// ---- 新建节点 ----
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
const node = this;
const wScheme = node.widgets && node.widgets.find((w) => w.name === W_SCHEME);
if (!wScheme) return r;
wScheme.label = LABEL_SCHEME;
const wPresetInit = node.widgets.find((w) => w.name === W_MODEL_PRESET);
if (wPresetInit) wPresetInit.label = LABEL_PRESET;
// 拦截方案切换(用户点下拉时走 callback)
const origCb = wScheme.callback;
wScheme.callback = function () {
const out = origCb ? origCb.apply(this, arguments) : undefined;
try {
applySchemeState(node);
markManual(node);
} catch (e) {
console.warn("[Toggle-Pass] 采样方案联动失败:", e);
}
return out;
};
// 预设表提前拉好,等用户点下拉时基本已经到位
fetchPresets().then((d) => {
if (d && d.presets) node.__tpPresets = d.presets;
});
// 「模型预设」:选中就把五项参数一次性填好
const wPreset = node.widgets.find((w) => w.name === W_MODEL_PRESET);
if (wPreset) {
const origPresetCb = wPreset.callback;
wPreset.callback = function () {
const out = origPresetCb ? origPresetCb.apply(this, arguments) : undefined;
try {
applyPreset(node, wPreset.value);
} catch (e) {
console.warn("[Toggle-Pass] 套用模型预设失败:", e);
}
return out;
};
}
// 其余四个参数:用户一动就说明偏离预设了,把下拉退回「手动」
PRESET_KEYS.forEach((name) => {
if (name === W_SCHEME) return; // 方案上面已经包过
const w = node.widgets.find((x) => x.name === name);
if (!w || w.__tpManualHooked) return;
w.__tpManualHooked = true;
const orig = w.callback;
w.callback = function () {
const out = orig ? orig.apply(this, arguments) : undefined;
try {
markManual(node);
} catch (e) {
/* 标记失败不影响参数本身 */
}
return out;
};
});
applySchemeState(node);
/* 初始尺寸:只加宽,高度沿用 computeSize 算出的默认值。
⚠️ 只在这里设 —— onConfigure 之后会恢复用户自己存的 size,
所以老工作流的尺寸不会被这条改掉。 */
try {
node.setSize([INITIAL_WIDTH, node.computeSize()[1]]);
} catch (e) { /* 忽略 */ }
return r;
};
// ---- 加载工作流:configure 会覆盖 widget 值,之后必须再套一次 ----
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const r = onConfigure ? onConfigure.apply(this, arguments) : undefined;
try {
// label 不是存档内容,但某些路径会重建 widget,这里补一遍最稳
const ws = findW(this, W_SCHEME);
if (ws) ws.label = LABEL_SCHEME;
const wp = findW(this, W_MODEL_PRESET);
if (wp) wp.label = LABEL_PRESET;
// 先把预设值修成合法的(旧长名 / 位置错位),再对方案做联动
fixPresetValue(this);
this.__tpPrevScheme = undefined; // 加载时不还原历史步数,按存档来
applySchemeState(this);
this.__tpPrevScheme = this.widgets?.find((w) => w.name === W_SCHEME)?.value;
// 老存档里节点可能只有 300 宽,全名会把「⚡预设」压在下面看不见
ensureWidthForPreset(this);
} catch (e) {
console.warn("[Toggle-Pass] 采样器参数联动失败:", e);
}
return r;
};
},
});