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 顶部加版本标识
This commit is contained in:
GBrain
2026-11-07 21:29:09 +08:00
parent 60665017a9
commit af57d757eb
1545 changed files with 8140 additions and 2085 deletions
+2372 -937
View File
File diff suppressed because it is too large Load Diff
+42 -3
View File
@@ -2,9 +2,14 @@
ComfyUI Toggle Pass - Toggle nodes + Image IO + Multi-Ref Encode
Author: Clever
"""
# 版本号:与 pyproject.toml 的 [project].version 保持一致。
# ComfyUI-Manager / Comfy Registry 读 pyproject.toml;这里供运行时打印、方便看日志排查。
__version__ = "1.0.0"
from .multi_ref_encode import NODE_CLASS_MAPPINGS as MULTI_REF_NODES, NODE_DISPLAY_NAME_MAPPINGS as MULTI_REF_NAMES
from .unified_loader import NODE_CLASS_MAPPINGS as UNIFIED_NODES, NODE_DISPLAY_NAME_MAPPINGS as UNIFIED_NAMES
from .sampler import NODE_CLASS_MAPPINGS as SAMPLER_NODES, NODE_DISPLAY_NAME_MAPPINGS as SAMPLER_NAMES
from .pose_reference import NODE_CLASS_MAPPINGS as POSE_NODES, NODE_DISPLAY_NAME_MAPPINGS as POSE_NAMES
import os
import json
@@ -298,6 +303,25 @@ class SaveLoadImage:
class ToggleEmptyLatent:
"""开关空 Latent。
`latent类型` 决定通道数与空间压缩比,必须和底模的 latent_format 对齐:
· SD / SDXL 4 通道 8×
· SD3 / Flux / Krea 2 16 通道 8× (Krea2 走 Wan21 格式,latent_dimensions=3)
· Qwen-Image 2.1 64 通道 16×
· Flux2 128 通道 16×
通道给错不会立刻报错 —— K采样器里的 fix_empty_latent_channels 只会在
「latent 全零」时按模型补齐通道,属于隐式兜底;直接接别的 latent 处理节点就不会修。
"""
LATENT_PRESETS = {
"SD / SDXL(4 通道 · 8×)": (4, 8),
"SD3 / Flux / Krea 2(16 通道 · 8×)": (16, 8),
"Qwen-Image 2.1(64 通道 · 16×)": (64, 16),
"Flux2(128 通道 · 16×)": (128, 16),
}
DEFAULT_LATENT = "SD / SDXL(4 通道 · 8×)"
@classmethod
def INPUT_TYPES(cls):
return {
@@ -310,6 +334,12 @@ class ToggleEmptyLatent:
"optional": {
"fallback_width": ("INT", {"default": 1024, "min": 16, "max": 16384, "step": 8}),
"fallback_height": ("INT", {"default": 1024, "min": 16, "max": 16384, "step": 8}),
# ⚠️ 必须放在 fallback_height 之后 —— 前端控件顺序是 required 全部 + optional,
# 老存档的 widgets_values 是 [width,height,batch_size,enabled,fb_w,fb_h] 六项,
# 早先把它插在 enabled 后面(fb_w 之前),老图加载时 fb_w 的 1024 会灌进来,
# 点运行直接 value_not_in_list: latent类型 1024 ⇒ 400,看着就是「跑不了」。
# 放最后 ⇒ 老图前 6 位对得上,这一项拿 default(4 通道,即老行为)。
"latent类型": (list(cls.LATENT_PRESETS.keys()), {"default": cls.DEFAULT_LATENT}),
},
}
@@ -318,7 +348,8 @@ class ToggleEmptyLatent:
FUNCTION = "generate"
CATEGORY = "Toggle-Pass"
def generate(self, width, height, batch_size=1, enabled="ON", fallback_width=None, fallback_height=None):
def generate(self, width, height, batch_size=1, enabled="ON", latent类型=None,
fallback_width=None, fallback_height=None):
if fallback_width is None:
fallback_width = 1024
if fallback_height is None:
@@ -326,10 +357,14 @@ class ToggleEmptyLatent:
if enabled != "ON":
width = fallback_width
height = fallback_height
latent = torch.zeros([batch_size, 4, height // 8, width // 8],
channels, scale = self.LATENT_PRESETS.get(latent类型, self.LATENT_PRESETS[self.DEFAULT_LATENT])
# 宽高先向下对齐到压缩比的倍数,避免出现非整数网格
h = max(1, height // scale)
w = max(1, width // scale)
latent = torch.zeros([batch_size, channels, h, w],
device=comfy.model_management.intermediate_device(),
dtype=comfy.model_management.intermediate_dtype())
return ({"samples": latent, "downscale_ratio_spacial": 8},)
return ({"samples": latent, "downscale_ratio_spacial": scale},)
# ==================== Image Compare ====================
@@ -579,6 +614,7 @@ NODE_CLASS_MAPPINGS = {
**MULTI_REF_NODES,
**UNIFIED_NODES,
**SAMPLER_NODES,
**POSE_NODES,
"ToggleEmptyLatent": ToggleEmptyLatent,
"SaveLoadImage": SaveLoadImage,
"ToggleLoadImage": ToggleLoadImage,
@@ -590,9 +626,12 @@ NODE_DISPLAY_NAME_MAPPINGS = {
**MULTI_REF_NAMES,
**UNIFIED_NAMES,
**SAMPLER_NAMES,
**POSE_NAMES,
"ToggleEmptyLatent": "Toggle Empty Latent",
"SaveLoadImage": "Save/Load Image",
"ToggleLoadImage": "Toggle Load Image",
"TogglePreviewImage": "Toggle Preview Image",
"ToggleImageCompare": "图片对比",
}
print(f"[Toggle-Pass] v{__version__} loaded — {len(NODE_CLASS_MAPPINGS)} nodes")
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1037 -865
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -103,7 +103,7 @@ app.registerExtension({
try {
const nodes = collectTogglePassNodes();
if (!nodes.length) return [];
return [
const items = [
{
content: MENU_LABEL,
has_submenu: true,
@@ -115,6 +115,19 @@ app.registerExtension({
},
},
];
// 素材库是运行时可变的(本机目录里丢进新姿势就多出来),给一个手动重扫入口。
// 平时不用点:节点自己每 15 秒比对一次指纹,变了就重建下拉。
if (nodes.some((n) => n.type === "PoseReference")) {
items.push({
content: "🔄 刷新姿势素材库",
callback: () => {
if (typeof window.__tpRefreshPoses === "function") {
window.__tpRefreshPoses();
}
},
});
}
return items;
} catch (err) {
console.error("[Toggle-Pass] getCanvasMenuItems failed:", err);
return [];
+127
View File
@@ -89,7 +89,128 @@ function refreshNodeSize(node) {
});
}
/* ---- 模型类型改名迁移 ----
* 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;
@@ -137,6 +258,12 @@ app.registerExtension({
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)));
+637
View File
@@ -0,0 +1,637 @@
/**
* ComfyUI-Toggle-Pass: 参考姿势(PoseReference)— 三级联动 + 节点内缩略图
*
* Python 端把全部选项平铺给出(大类 4 / 姿势组 26 / 姿势 288 / 视角 5),
* 不联动也能用。这里做三件事:
* 1. 选了大类就把「姿势组」收窄到该类下的组,选了组再把「姿势」收窄到该组下的,
* 切完顺手把画布重绘一次,免得下拉还显示旧选项。
* 2. 任何下拉一变(大类/组/姿势/视角/线稿深度/翻转)就去 /toggle_pass/pose_image
* 取一张缩略图,直接画在节点下半部分 —— 不用执行工作流就能看到选的是哪张。
* 3. 图进来了按比例把节点撑高,保证图不会被挤成一条缝。
*
* 图片走自绘(onDrawForeground)而不是 node.imgs:后者需要 HTMLImage 对象、
* 还依赖前端内部绘制逻辑,跨版本不稳。自绘只依赖 LiteGraph 的公开钩子。
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const NODE_NAME = "PoseReference";
const TREE_API = "/toggle_pass/pose_tree";
const IMAGE_API = "/toggle_pass/pose_image";
const STAMP_API = "/toggle_pass/pose_stamp";
const PAD = 10;
const DEBOUNCE_MS = 120;
// 素材指纹轮询间隔:往本机素材目录里丢新姿势,不用刷新浏览器,最多等这么久自己就出现
const POLL_MS = 15000;
// 会改变「该显示哪张图」的六个下拉
const WATCHED = ["category", "group", "pose", "view", "source", "flip"];
// 1.57 前端给「有 imgs 的节点」自动挂的官方画布预览 widget
const PREVIEW_WIDGET = "$$canvas-image-preview";
let treePromise = null;
function fetchTree(force) {
if (force) treePromise = null; // 强制重扫:走 ?refresh=1 让后端丢掉缓存
if (!treePromise) {
treePromise = api
.fetchApi(TREE_API + (force ? "?refresh=1" : ""))
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
// 失败/空结果不能留在缓存里:否则后面所有节点都拿这个 null,
// 联动永远建不起来,只有刷新页面(清掉模块状态)才恢复。
if (!d || !d.tree) treePromise = null;
return d;
})
.catch(() => {
treePromise = null;
return null;
});
}
return treePromise;
}
function widget(node, name) {
return (node.widgets || []).find((w) => w.name === name);
}
function val(node, name, fallback) {
const w = widget(node, name);
return w && w.value !== undefined && w.value !== null ? w.value : fallback;
}
/**
* 换一套选项。keep=true 时保留当前值(加载工作流用,避免把用户存的姿势改掉)。
* 返回是否真的变了,用于决定要不要重绘。
*/
function setValues(w, values, keep) {
if (!w || !Array.isArray(values) || values.length === 0) return false;
w.options = w.options || {};
const old = w.options.values;
const same =
Array.isArray(old) &&
old.length === values.length &&
old.every((v, i) => v === values[i]);
if (same) return false;
w.options.values = values.slice();
if (!keep && !values.includes(w.value)) w.value = values[0];
return true;
}
function buildSync(node, tree) {
const wCat = widget(node, "category");
const wGroup = widget(node, "group");
const wPose = widget(node, "pose");
if (!wCat || !wGroup || !wPose) return null;
const groupsOf = (cat) => Object.keys((tree && tree[cat]) || {});
const posesOf = (cat, grp) => Object.keys(((tree && tree[cat]) || {})[grp] || {});
return function sync(keep) {
let dirty = false;
dirty = setValues(wGroup, groupsOf(wCat.value), keep) || dirty;
dirty = setValues(wPose, posesOf(wCat.value, wGroup.value), keep) || dirty;
if (dirty) {
node.setDirtyCanvas(true, true);
app.graph?.setDirtyCanvas?.(true, true);
}
return dirty;
};
}
// ---------------- 工作流内嵌 ----------------
// 选中的素材原文件(webp 约 5KB)以 data URI 存进 node.properties.tp_pose,
// 跟着工作流一起走:别人下载后既能看到图,素材库不在时后端也能拿它出图。
// 后端从 extra_pnginfo 里按 id 找 properties,所以 key 必须和 Python 的 embed_key 拼法一致。
function embedKey(node) {
return [
val(node, "category", ""),
val(node, "group", ""),
val(node, "pose", ""),
val(node, "view", ""),
val(node, "source", "线稿"),
]
.map((x) => String(x))
.join("|");
}
function flipOn(node) {
return val(node, "flip", "关") === "开";
}
function blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result));
fr.onerror = () => reject(fr.error);
fr.readAsDataURL(blob);
});
}
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("image decode failed"));
img.src = src;
});
}
function storeEmbed(node, dataUrl) {
const key = embedKey(node);
node.properties = node.properties || {};
const cur = node.properties.tp_pose;
if (cur && cur.key === key && cur.img === dataUrl) return;
node.properties.tp_pose = { key, img: dataUrl };
}
/** 兜底显示:素材库取不到时,先用工作流里带的那张。 */
function showEmbedded(node) {
const emb = node.properties && node.properties.tp_pose;
if (!emb || !emb.img) return false;
loadImage(emb.img)
.then((img) => {
if (node._tpPoseFromNet) return; // 真图已经到了,不自找麻烦
node._tpPoseImg = img;
node._tpPoseFlip = flipOn(node);
node._tpPoseErr = emb.key === embedKey(node) ? null : "素材库缺失,显示内嵌旧图";
fitNode(node, img);
node.setDirtyCanvas(true, true);
})
.catch(() => {});
return true;
}
// ---------------- 缩略图 ----------------
function previewUrl(node) {
// raw=1:直接返回素材原文件(webp 约 5KB),不缩放不翻转 —— 既当缩略图又当内嵌源
const q = new URLSearchParams({
category: String(val(node, "category", "")),
group: String(val(node, "group", "")),
pose: String(val(node, "pose", "")),
view: String(val(node, "view", "")),
source: String(val(node, "source", "线稿")),
raw: "1",
});
return `${IMAGE_API}?${q.toString()}`;
}
/** 所有 widget 画完后的底部 y(节点 body 坐标系,原点在标题栏下沿)。 */
function widgetsBottom(node) {
let bottom = 0;
const ws = node.widgets || [];
for (let i = 0; i < ws.length; i++) {
const w = ws[i];
let y = typeof w.last_y === "number" ? w.last_y : 4 + i * 28;
let h = 20;
if (typeof w.height === "number" && w.height > 0) {
h = w.height;
} else if (typeof w.computeSize === "function") {
try {
const s = w.computeSize(node.size[0]);
if (Array.isArray(s) && s.length > 1) h = s[1];
} catch (e) {
/* 忽略:拿不到就用默认行高 */
}
}
bottom = Math.max(bottom, y + h);
}
return bottom;
}
function fitNode(node, img) {
const top = widgetsBottom(node);
const availW = Math.max(80, node.size[0] - PAD * 2);
// 按图比例算一个「看得清又不占满屏」的高度,限制在 190~300
const wanted = Math.round(Math.min(300, Math.max(190, (availW * img.height) / img.width * 0.72)));
const need = Math.ceil(top + 6 + wanted + PAD);
if (node.size[1] < need) {
node.size[1] = need; // size 是类数组对象,整块赋值会被吞,逐下标写
node.setDirtyCanvas(true, true);
}
}
function refreshPreview(node) {
const url = previewUrl(node);
// flip 不参与 URL(拿的是未翻转的原文件),所以单独同步,不能跟着 URL 早退
const f = flipOn(node);
if (node._tpPoseFlip !== f) {
node._tpPoseFlip = f;
node.setDirtyCanvas(true, true);
}
// 已经有这张图就不重复取;但图对象丢了(出错被清、加载中新节点)还是要重取
if (node._tpPoseUrl === url && node._tpPoseImg) return;
node._tpPoseUrl = url;
const keyAtRequest = embedKey(node);
(async () => {
try {
const res = await api.fetchApi(url);
if (!res.ok) throw new Error("HTTP " + res.status);
const blob = await res.blob();
const dataUrl = await blobToDataUrl(blob);
if (node._tpPoseUrl !== url) return; // 期间又切了别的,丢弃
const img = await loadImage(dataUrl);
if (node._tpPoseUrl !== url) return;
node._tpPoseImg = img;
node._tpPoseFlip = flipOn(node);
node._tpPoseErr = null;
node._tpPoseFromNet = true;
// 只有参数没在旁边被改过才内嵌,避免把 A 的图写进 B 的设置
if (embedKey(node) === keyAtRequest) storeEmbed(node, dataUrl);
fitNode(node, img);
node.setDirtyCanvas(true, true);
// litegraph 0.4 里单个节点的 dirty 标记不一定能把画布叫醒,
// 图换了却停在旧画面,看着就是「点了没反应」。图是重点,再踹一脚整图。
app.graph?.setDirtyCanvas?.(true, true);
app.canvas?.setDirty?.(true, true);
} catch (e) {
if (node._tpPoseUrl !== url) return;
// 失败时解锁,否则这个参数组合会被永久判成「已请求过」,
// 后端恢复后切回来也不重试。
node._tpPoseUrl = null;
node._tpPoseFromNet = false;
const emb = node.properties && node.properties.tp_pose;
const curKey = embedKey(node);
// 只有内嵌的那张「正好就是当前选的」才能顶上去。
// 以前不管三七二十一都拿内嵌图兜底,结果切到别的姿势时画面还是老图,
// 用户看到的是「点了没反应」,还把真正的取图失败藏得干干净净。
if (emb && emb.img && emb.key === curKey) {
try {
const img = await loadImage(emb.img);
node._tpPoseImg = img;
node._tpPoseFlip = flipOn(node);
node._tpPoseErr = null;
fitNode(node, img);
node.setDirtyCanvas(true, true);
return;
} catch (e2) {
/* 解码也失败,走下面统一报错 */
}
}
node._tpPoseImg = null;
node._tpPoseErr = emb && emb.img ? "这张没取到(素材库缺失)" : "素材库缺失";
node.setDirtyCanvas(true, true);
app.graph?.setDirtyCanvas?.(true, true);
console.warn("[PoseReference] 取图失败:", url, e && e.message);
}
})();
}
function schedulePreview(node) {
if (node._tpPoseTimer) clearTimeout(node._tpPoseTimer);
node._tpPoseTimer = setTimeout(() => {
node._tpPoseTimer = null;
try {
refreshPreview(node);
} catch (e) {
console.warn("[PoseReference] 预览刷新失败", e);
}
}, DEBOUNCE_MS);
}
/**
* 参数自检:当前六个下拉的取值,和「上一次送去取图的那套参数」不一致就重取。
*
* 这个检查放在绘制里每帧跑,是刻意不依赖 widget.callback 的。实测(1.57 /
* litegraph 0.4)下拉选完只有 callback 一条通知路径,而 callback 可能被第三方
* 扩展重写、被旧版本闭包吃掉,或者干脆不触发 —— 一旦漏掉,节点就会永远停在
* 上一张图上,看起来像「切换不了,得重启」。绘制路径只要画布在动就一定跑,
* 拿它当兜底,任何漏通知的情况都能自己补上。
*/
function watchParams(node) {
const key = embedKey(node);
if (node._tpPoseKey === key) return;
node._tpPoseKey = key;
schedulePreview(node);
}
function drawPreview(node, ctx) {
watchParams(node); // 先自检:参数变了但没人通知,这里补一次
const top = widgetsBottom(node);
const x0 = PAD;
const y0 = top + 6;
const w0 = node.size[0] - PAD * 2;
const h0 = node.size[1] - y0 - PAD;
if (w0 < 40 || h0 < 30) return;
ctx.save();
try {
// 底衬:避免透明网格/深色背景上白线稿看不清边界
ctx.fillStyle = "#1b1b1b";
ctx.fillRect(x0, y0, w0, h0);
const img = node._tpPoseImg;
if (!img) {
ctx.fillStyle = "#7a7a7a";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(node._tpPoseErr || "加载中…", x0 + w0 / 2, y0 + h0 / 2);
return;
}
const scale = Math.min(w0 / img.width, h0 / img.height);
const dw = img.width * scale;
const dh = img.height * scale;
const dx = x0 + (w0 - dw) / 2;
const dy = y0 + (h0 - dh) / 2;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
if (node._tpPoseFlip) {
// 缩略图取的是素材原文件(未翻转),翻转在这里镜像
ctx.save();
try {
ctx.translate(dx + dw, dy);
ctx.scale(-1, 1);
ctx.drawImage(img, 0, 0, dw, dh);
} finally {
ctx.restore();
}
} else {
ctx.drawImage(img, dx, dy, dw, dh);
}
ctx.strokeStyle = "rgba(255,255,255,0.14)";
ctx.lineWidth = 1;
ctx.strokeRect(dx + 0.5, dy + 0.5, dw - 1, dh - 1);
} finally {
ctx.restore();
}
}
function hookCallback(w, node) {
if (!w || w._tpPoseHooked) return;
w._tpPoseHooked = true;
const orig = w.callback;
w.callback = function (...args) {
const ret = orig ? orig.apply(this, args) : undefined;
// 联动和取图分开 try:联动抛异常不能把取图一起带走,
// 否则下拉值变了、图却停在上一张。
try {
// 必须每次现取 node._tpPoseSync:素材库热更新会换上新树的闭包,
// 如果在这里闭包捕获旧 sync,下拉永远按旧树联动 —— 新增的类别/组
// 切不过去,只有重启(重新挂钩)才恢复。
if (typeof node._tpPoseSync === "function") node._tpPoseSync(false);
} catch (e) {
console.warn("[PoseReference] 联动失败", e);
}
try {
schedulePreview(node);
} catch (e) {
console.warn("[PoseReference] 预览排队失败", e);
}
return ret;
};
}
function attach(node, tree) {
const sync = buildSync(node, tree);
if (sync) node._tpPoseSync = sync;
// 六个下拉都要挂:前三个带联动,后三个只触发换图
WATCHED.forEach((name) => {
hookCallback(widget(node, name), node);
});
if (sync) sync(true);
showEmbedded(node);
schedulePreview(node);
}
// ---------------- 素材库热更新 ----------------
// 素材分两层:插件自带 pose_assets/(随插件分发、只放非敏感大类)+ 本机外挂
// (D:\pose 或 TP_POSE_ROOT,敏感/自建素材放这里)。往本机目录里丢新姿势后,
// 后端指纹会变,这里自动把下拉重建一遍 —— 不用刷新浏览器。
function poseNodes() {
return (app.graph?._nodes || []).filter((n) => n.type === NODE_NAME);
}
async function refreshAll(reason) {
const nodes = poseNodes();
if (!nodes.length) return 0;
const data = await fetchTree(true);
if (!data || !data.tree) return 0;
for (const n of nodes) {
try {
attach(n, data.tree); // 换上新的素材树
// 新树里可能没有当前选中的组/姿势了(素材被删或改名),这里校正一次,
// 否则下拉会显示一个不存在的值、点别的又对不上。
if (typeof n._tpPoseSync === "function") n._tpPoseSync(false);
// 素材可能被同名替换了内容,URL 却一模一样,不清掉就永远不会重取。
n._tpPoseUrl = null;
n._tpPoseFromNet = false;
schedulePreview(n);
} catch (e) {
console.warn("[PoseReference] 重建下拉失败", e);
}
}
console.log(`[PoseReference] 素材库已刷新(${reason}):${nodes.length} 个节点`);
return nodes.length;
}
let lastStamp = null;
let pollStarted = false;
function startStampPoll() {
if (pollStarted) return;
pollStarted = true;
const tick = async () => {
if (!poseNodes().length) return; // 画布上没有本节点就不打扰后端
try {
const r = await api.fetchApi(STAMP_API);
if (!r.ok) return;
const d = await r.json();
if (!d || !d.stamp) return;
if (lastStamp === null) {
lastStamp = d.stamp; // 首次只记基准,不触发重建
return;
}
if (d.stamp === lastStamp) return;
lastStamp = d.stamp;
await refreshAll("指纹变化");
} catch (e) {
/* 网络抖动忽略,下一轮再说 */
}
};
// 立刻记一次基准(和画布上有没有节点无关),不能等第一个 interval ——
// 否则「打开页面后 15 秒内放进去的素材」会被当成基准吞掉,指纹再也不变,永远不刷新。
(async () => {
try {
const r = await api.fetchApi(STAMP_API);
if (!r.ok) return;
const d = await r.json();
if (d && d.stamp) lastStamp = d.stamp;
} catch (e) {
/* 拿不到就退化成 tick 里首次记录基准 */
}
})();
setInterval(tick, POLL_MS);
}
/**
* 摘掉 ComfyUI 1.57 自动挂的官方画布预览 widget。
*
* 前端 `useNodeCanvasImagePreview` 的规则很简单:只要节点有 `node.imgs`,
* 就 `addCustomWidget("$$canvas-image-preview")`。这个 widget 会占掉节点底部
* 一行,并把执行输出的那张 PNG 画在节点下部 —— 正好压在我们的预览区上面。
*
* 于是「生图一开始」就等于给节点盖了一张定死的图:下拉照点、我们的图在背面
* 其实也换了,但画面上永远停着那张执行结果,看起来就是再也切不动。
* 本节点的图完全由 onDrawForeground 自己画,不需要官方这一份,直接摘掉;
* 同时清空 `node.imgs`,否则它下次判断又会把 widget 挂回来。
*/
function stripOfficialPreview(node) {
if (!node || !node.widgets) return false;
let hit = false;
for (let i = node.widgets.length - 1; i >= 0; i--) {
const w = node.widgets[i];
if (String((w && w.name) || "") !== PREVIEW_WIDGET) continue;
if (typeof w.onRemove === "function") {
try {
w.onRemove();
} catch (e) {
/* 清理回调失败也要把 widget 摘掉 */
}
}
node.widgets.splice(i, 1);
hit = true;
}
if (hit) {
node.imgs = null;
node.setDirtyCanvas(true, true);
}
return hit;
}
/**
* 参数巡检:300ms 扫一遍画布上的本节点,参数变了就补一次取图。
*
* 绘制里已经有一道同样的检查,但节点被折叠、画布静止不重绘时绘制不一定会跑。
* 这里只读六个 widget 的 value 拼个字符串,画布上没有本节点时直接返回,
* 开销可以忽略,换来的是「不管通知从哪条路径丢了,图都能自己跟上」。
*
* 顺便每轮摘一次官方预览 widget(见 stripOfficialPreview)。
*/
function startParamWatch() {
setInterval(() => {
for (const n of poseNodes()) {
try {
stripOfficialPreview(n);
watchParams(n);
} catch (e) {
/* 单个节点异常不能拖垮整轮巡检 */
}
}
}, 300);
}
// 画布右键菜单「刷新姿势素材库」通过这个全局调用(canvas_menu.js 里引用)
window.__tpRefreshPoses = () => refreshAll("手动");
/**
* 排查用:在浏览器控制台敲 __tpPoseDiag(),打印画布上每个本节点的
* 选项值 / 已加载图 / 上次请求的 URL / 错误信息。出问题时一眼看出卡在哪一步。
*/
window.__tpPoseDiag = () => {
const rows = poseNodes().map((n) => ({
选中: WATCHED.map((k) => val(n, k, "")).join(" / "),
上次请求: decodeURIComponent((n._tpPoseUrl || "").split("?")[1] || "(无)"),
已加载图: n._tpPoseImg ? `${n._tpPoseImg.naturalWidth}x${n._tpPoseImg.naturalHeight}` : "(无)",
来自网络: !!n._tpPoseFromNet,
错误: n._tpPoseErr || "",
}));
console.table(rows);
return rows;
};
app.registerExtension({
name: "TogglePass.PoseReference",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_NAME) return;
// ---- 节点内缩略图(自绘,链式调用宿主钩子)----
const origDraw = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
const ret = origDraw ? origDraw.apply(this, arguments) : undefined;
if (ctx) {
try {
drawPreview(this, ctx);
} catch (e) {
console.warn("[PoseReference] 绘制失败", e);
}
}
return ret;
};
// ---- 创建节点:拉目录树、挂联动、取首张预览 ----
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const ret = origCreated ? origCreated.apply(this, arguments) : undefined;
const node = this;
// size 是类数组对象,整块赋值会被忽略,必须逐下标写
if (node.size && node.size[0] < 320) {
node.size[0] = 340;
}
fetchTree().then((data) => {
if (!data || !data.tree) {
schedulePreview(node); // 树拉不到也先把图取上
return;
}
attach(node, data.tree);
});
return ret;
};
// ---- 加载工作流:选项可能和存的不一致,刷新一次但保留原值 ----
const origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const ret = origConfigure ? origConfigure.apply(this, arguments) : undefined;
const node = this;
// 换了工作流:上一张的 URL / 来源标记作废,先把内嵌图顶上去再拉本机的
node._tpPoseUrl = null;
node._tpPoseFromNet = false;
node._tpPoseKey = null; // 让绘制自检也认为「参数刚变过」
showEmbedded(node);
fetchTree().then((data) => {
if (!data || !data.tree) {
schedulePreview(node);
return;
}
attach(node, data.tree);
});
return ret;
};
// ---- 执行完:立刻摘掉官方预览 widget,别让它盖住预览区 ----
// 这是「生图一开始就再也切不动」的元凶:节点返回 ui.images 后前端会给它
// 挂一个固定显示执行结果的预览 widget,正好压在预览区上。
const origExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function () {
const ret = origExecuted ? origExecuted.apply(this, arguments) : undefined;
try {
this.imgs = null; // 官方预览靠它判断要不要挂
stripOfficialPreview(this);
schedulePreview(this); // 执行结束是「该重画」的信号,顺手对齐一次
} catch (e) {
console.warn("[PoseReference] 清理官方预览失败", e);
}
return ret;
};
},
setup() {
// 前端就绪后开始轮询素材指纹(每 POLL_MS 一次,画布没本节点时直接跳过)
startStampPoll();
// 参数巡检:回调漏通知时靠它把图补上
startParamWatch();
},
});
+290 -4
View File
@@ -13,14 +13,47 @@
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const NODE_NAME = "ToggleSampler";
const SCHEME_AUTO = "自动(按步数 + 调度器)";
const SCHEME_CUSTOM = "自定义表";
/* 新建节点时的初始宽度(高度用 computeSize 的默认值,不加高)。
仅在 onNodeCreated 生效,不影响已保存的工作流尺寸。 */
const INITIAL_WIDTH = 300;
/* 节点宽度。预设用的是完整模型名,最长一条实测 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 保持一致;只用于把「步数」显示得跟实际一致,
@@ -32,9 +65,197 @@ const PRESET_STEPS = {
};
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) {
@@ -172,6 +393,12 @@ app.registerExtension({
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 () {
@@ -181,14 +408,61 @@ app.registerExtension({
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;
applySchemeState(node);
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 算出的默认值。
@@ -206,9 +480,21 @@ app.registerExtension({
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);
}
+257 -31
View File
@@ -8,6 +8,7 @@ Version: 3.0 - Multiple node classes (3/5/10 images) + Custom output size for im
import math
import torch
import torch.nn.functional as F
import comfy.utils
import node_helpers
import comfy.model_management
@@ -154,6 +155,10 @@ KREA2_VISION_TOKEN = "<|vision_start|><|image_pad|><|vision_end|>"
def _krea2_ref_preprocess(samples, vae):
"""Krea2 参考图预处理,返回 (ref_latent, vl_image)。
⚠️ 2026-11-05 起三个编码节点的 Krea2 分支都改用 `_krea2_fit_ref`(参考图必须先
fit 到出图网格,否则 ref latent 与采样起点的 h/w 不同 ⇒ 线稿/边缘渗进画面),
本函数已无调用者,保留作参考(它编码的是「约 1 MP、保持原比例」的 latent)。
samples : [B, C, H, W](已 movedim(-1, 1))
· ref_latent —— VAE 编码,缩到约 1024x1024 并对齐 8 的倍数,只吃 RGB
(qwen_image_vae 输入 3 通道,Krea2 不吃 alpha)
@@ -187,6 +192,35 @@ def _krea2_ref_preprocess(samples, vae):
return ref_latent, vl_image
def _krea2_fit_ref(samples, out_w, out_h, mode="参考图保比例"):
"""把参考图 fit 到 Krea2 采样的**空间网格**(= krea2_edit LoRA 的训练几何)。
原生 krea2 forward 给每张参考图用它**自己的网格**造位置编码:
ref = (frame=i, h=0..h_ref-1, w=0..w_ref-1)
target = (frame=0, h=0..h_tgt-1, w=0..w_tgt-1)
ref 与 target 的空间尺寸不一致时,模型把它当成「另一块画布」——坐标对不上,
实测症状是参考图的线条被贴进成图(脸上出现轮廓线)。krea2_edit 训练时源图是
先 fit 到 target 网格再 VAE 编码的。这里照做:ref latent 与采样起点在 h/w 上
逐维相等,位置编码只差 frame 轴。
mode 沿用节点上的「尺寸适配」:
参考图保比例 → 中心裁到出图宽高比,再缩到出图像素尺寸(默认,几乎无损)
参考图裁剪 → 直接中心裁到出图尺寸
参考图拉伸 → 直接缩到出图尺寸(会变形)
"""
tw = max(8, int(round(out_w / 8.0)) * 8)
th = max(8, int(round(out_h / 8.0)) * 8)
if mode != "参考图拉伸":
samples = _center_crop(samples, tw / th)
if (samples.shape[3], samples.shape[2]) == (tw, th):
return samples
# ★★ antialias 不能省:缩小一张锐利线稿时,无 antialias 的双三次会产生振铃/摩尔纹,
# 黑白边界的潜空间特征被放大,模型会照着把线条画进成图(实测:脸上出现轮廓线)。
# krea2Edit 的像素路径同样是 bicubic + antialias。
return F.interpolate(samples.float(), size=(th, tw), mode="bicubic",
antialias=True).to(samples.dtype)
def _krea2_empty_latent(width, height):
"""Krea2 采样起点:全零 16 通道 / 8x latent。
@@ -205,6 +239,97 @@ def _krea2_align8(v):
return max(8, int(round(v / 8.0)) * 8)
def _empty_negative(clip):
"""真正的空文本负条件(不带参考图)。
⚠️ 不要用 `ConditioningZeroOut` 把正面条件清零当负面:
CFG == 1.0 时负面根本不参与计算,看着没问题;
一旦 CFG > 1,负面真的参与计算了,清零的张量会让整张图花屏(绿紫斑块)。
实测 Qwen 2.1 fp8 / int8 20 步 CFG 2.5 都崩,换成本函数这种空文本就正常。
"""
tokens = clip.tokenize("")
return clip.encode_from_tokens_scheduled(tokens)
# 模型类型改名兼容:老工作流里存的是旧字面量,后端收到旧值时归一化到新名字。
# 前端 js/multi_ref_encode.js 里有一份同样的表(加载时直接把 widget 值改掉),
# 这里再兜一层是为了「手工改 JSON / 第三方节点转存」也能走对分支。
_MODEL_TYPE_ALIASES = {
"SD / SDXL": "SD / SDXL / Z-Image",
"SD/SDXL": "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",
}
def _norm_model_type(v):
"""把旧/别名写法的模型类型归一成当前下拉里的正式值。"""
if not isinstance(v, str):
return v
return _MODEL_TYPE_ALIASES.get(v.strip(), v)
def _te_class_name(clip):
"""取文本编码器的实际类名(如 ZImageTEModel_)。取不到返回 None。
⚠️ 必须显式判 None:`type(None).__name__` 是字符串 "NoneType"(truthy),
直接拿去做 `in` 判断会误命中。
"""
try:
csm = getattr(clip, "cond_stage_model", None)
if csm is None:
return None
return type(csm).__name__
except Exception:
return None
# 底座没有 in-context 参考图通路的模型:一旦接了参考图,出图必然异常。
# 只登记**实测确认过**的,不按型号名字猜。
_NO_REF_CAPABILITY = {
"ZImage": (
"Z-Image(Lumina2 / NextDiT 系)是纯文生图模型,训练时没有参考图 token;"
"官方 5 个 z_image 模板里也没有任何「参考图编辑」接法"
"(唯一带图输入的是 fun_union_controlnet,走 ControlNet,机制完全不同)。"
"接上 image1 后参考图 token 进不了模型(缺配套的 reference_latents_text_embeds),"
"实测出图是满屏方块马赛克。\n"
" → 纯文生图:断开 image1 即可,模型类型选「SD / SDXL / Z-Image」"
"(空 latent 会自动补到 16 通道);\n"
" → 要「看图改图」:换 Qwen-Image 2.1 / Krea2 / Flux2 系;\n"
" → 要 Z-Image 的图生图:用 VAE Encode + 采样器 denoise < 1 的普通 img2img。"
),
}
_WARNED_NO_REF = set()
def _warn_no_ref_capability(clip, n_refs, tag="Ref Image Encode"):
"""接了参考图、但底座不支持时打一条明确警告。只打印,不改变任何行为。
同一个(节点, 编码器类)只打一次:`Ref Independent` 是按图循环调用的,
不去重会刷满日志。
"""
if n_refs <= 0:
return
te = _te_class_name(clip) or ""
for key, msg in _NO_REF_CAPABILITY.items():
if key in te:
if (tag, te) in _WARNED_NO_REF:
return
_WARNED_NO_REF.add((tag, te))
print(
f"\n[Toggle-Pass][{tag}] ⚠️ 文本编码器 = {te},已接入 {n_refs} 张参考图,"
f"但这个底座没有参考图通路。\n {msg}\n"
f" (本次仍按你的接线执行,以上仅为提示,不修改任何参数)\n"
)
return
class DynamicRefImageEncode:
"""
动态 Ref Image Encode 节点,合并原 III/V/X 三个节点。
@@ -212,6 +337,20 @@ class DynamicRefImageEncode:
所有参考图共享同一个 prompt,输出 1 个 conditioning + 1 个 latent。
"""
DESCRIPTION = (
"一个 prompt + 最多 10 张参考图,输出 CONDITIONING + LATENT + NEGATIVE。"
"第三个输出 negative 是空文本负条件,直接接采样器「负面条件」即可,"
"CFG 调多高都不会花屏(别再用 ConditioningZeroOut 清零正面当负面)。"
"MOSS-Image 与 FIBO 2.0 的 latent 与 Flux2 完全同族(128 通道 / 16x,都用 flux2-vae),"
"「模型类型」请直接选 Flux2。若需要 MOSS 的 edit_format(必须与编辑 LoRA 的训练格式配对)"
"或 image_strength、FIBO 的 negative_prompt,请改用官方编码器 "
"TextEncodeMOSSImageEdit / TextEncodeFibo2。"
"—— Krea 2:「模型类型」选 Krea2 就能做编辑(保身份 / 换姿势),不需要 krea2Edit 插件。"
"参考图会先 fit 到出图网格再编码,并以 reference_latents_method=index 注入。"
"⚠️ 顺序是硬约定:最后一个参考图 = 主体(姿势线稿放 image1、原照片放 image2),"
"反过来会渲成线稿人偶。"
)
@classmethod
def INPUT_TYPES(cls):
opt = {}
@@ -236,7 +375,7 @@ class DynamicRefImageEncode:
},
"optional": {
**opt,
"模型类型": (["Flux2", "Qwen Layered", "Qwen Image 2.1", "SD / SDXL", "Krea2"], {"default": "Flux2"}),
"模型类型": (["Flux2", "Qwen Layered", "Qwen Image 2.1", "SD / SDXL / Z-Image", "Krea2"], {"default": "Flux2"}),
"匹配原图尺寸": (["OFF", "ON"], {"default": "OFF"}),
"输出宽度": ("INT", {"default": 1024, "min": 256, "max": 4096, "step": 8}),
"输出高度": ("INT", {"default": 1024, "min": 256, "max": 4096, "step": 8}),
@@ -251,8 +390,14 @@ class DynamicRefImageEncode:
},
}
RETURN_TYPES = ("CONDITIONING", "LATENT")
RETURN_NAMES = ("conditioning", "latent")
# ↓ 第三个输出是后加的,追加在末尾 ⇒ 旧工作流的 0/1 号口不受影响
# 输出顺序:conditioning / negative / latent
# ⚠️ 2026-11-06 调整:`negative` 从末尾挪到 `latent` 前面(纯粹为了节点上好看)。
# ⇒ 索引变了(latent: 1 → 2,negative: 2 → 1),所有老工作流的连线索引都失效。
# 前端 js/multi_ref_encode.js 的 `migrateOutputOrder()` 会在加载时按
# outputs[i].name 判断布局并交换 1/2 号口的连线,两层都改才一致。
RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT")
RETURN_NAMES = ("conditioning", "negative", "latent")
FUNCTION = "encode"
CATEGORY = "Toggle-Pass"
@@ -303,9 +448,18 @@ class DynamicRefImageEncode:
device=comfy.model_management.intermediate_device(),
)
elif 模型类型 == "Flux2":
# Flux2 latent:128 通道 / 16x 空间压缩(flux2-vae)。
# MOSS-Image 与 FIBO 2.0 用的是同一套 latent,这两个模型请直接选 Flux2。
# ⚠️ 这里原来写的是 4 通道(SD 的 latent)。只有「一张参考图都不接、
# 纯把它当空 latent 源」时才会走到这个分支(接图时走 ref_latents[0]),
# 所以一直没暴露;接上 Flux2/moss/fibo 采样会直接通道不匹配。
channels = 128
h = height // 16
w = width // 16
latent = torch.zeros(1, 4, h, w)
latent = torch.zeros(
[1, channels, h, w],
device=comfy.model_management.intermediate_device(),
)
else:
h = height // 8
w = width // 8
@@ -320,6 +474,7 @@ class DynamicRefImageEncode:
n = max(1, min(10, int(num_images)))
llama_template = self._get_system_prompt(instruction)
模型类型 = _norm_model_type(模型类型) # 老存档兼容
is_krea2 = (模型类型 == "Krea2")
# 出图分辨率:2.1 用官方 ResolutionSelector 同款(宽高比 + 百万像素 + 倍数);
@@ -375,21 +530,18 @@ class DynamicRefImageEncode:
vl_images.append(rgb)
# 2.1 不加 "Picture N: " 文本前缀(Qwen3-VL 靠占位符,不靠文本)
elif is_krea2:
# ---- Krea2:照抄官方 TextEncodeQwenImageEditPlus ----
# 参考图 VAE 缩放到约 1024x1024、对齐 8 的倍数;VAE 只吃 RGB
# (qwen_image_vae 输入是 3 通道,Krea2 不吃 alpha)
total = 1024 * 1024
scale_by = math.sqrt(total / current_total)
kw = max(8, round(samples.shape[3] * scale_by / 8.0) * 8)
kh = max(8, round(samples.shape[2] * scale_by / 8.0) * 8)
if (kw, kh) == (samples.shape[3], samples.shape[2]):
s = samples
else:
s = comfy.utils.common_upscale(samples, kw, kh, "area", "disabled")
# ---- Krea2:参考图先 fit 到「出图网格」再 VAE 编码 ----
# 原生 krea2 forward 用每张参考图**自己的网格**造位置编码,尺寸与 target
# 不一致时模型把它当另一块画布 ⇒ 参考图的线条会被贴进成图(实测症状:
# 脸上出现轮廓线)。krea2_edit LoRA 训练时源图就是先 fit 到 target 网格
# 再编码的,这里对齐它。VAE 只吃 RGB(qwen_image_vae 3 通道)。
if first_tw is None:
first_tw, first_th = _krea2_align8(pixel_width), _krea2_align8(pixel_height)
s = _krea2_fit_ref(samples, first_tw, first_th, 尺寸适配)
if vae is not None:
ref_latents.append(vae.encode(s.movedim(1, -1)[:, :, :, :3]))
# 视觉塔:384x384 附近
# 视觉塔:384x384 附近(语义 grounding,与潜空间几何无关)
vl_total = 384 * 384
vl_scale = math.sqrt(vl_total / current_total)
s_vl = comfy.utils.common_upscale(
@@ -441,14 +593,23 @@ class DynamicRefImageEncode:
tokens = clip.tokenize(full_prompt, images=vl_images, llama_template=llama_template)
conditioning = clip.encode_from_tokens_scheduled(tokens)
_warn_no_ref_capability(clip, len(ref_latents), "Ref Image Encode")
if len(ref_latents) > 0:
ref_vals = {"reference_latents": ref_latents}
if is_krea2:
# ⚠️ Krea2 的 default_ref_method 实测是 None,而模型侧写的是
# if ref_method is not None and ref_latents is not None and len(...)
# ⇒ 不显式给方法时参考图会被**静默忽略**(不报错、只是没效果)。
# 官方风格参考模板用 FluxKontextMultiReferenceLatentMethod 设成 index_timestep_zero。
ref_vals["reference_latents_method"] = "index_timestep_zero"
#
# ★★ 方法必须用 "index",不能照官方模板用 "index_timestep_zero":
# "index_timestep_zero" 会让参考图 token 走 **t=0 的 adaLN 调制**
# (见 comfy/ldm/krea2/model.py SingleStreamBlock.forward 的
# timestep_zero_index 分支),target 走当前 t —— 两套调制。
# 而 krea2_edit LoRA(ai-toolkit predict_velocity_edit)训练时
# 全序列共用**同一个 tvec**,正好对应原生的 "index"(不分裂 tvec)。
# 实测:用 timestep_zero 时参考线稿会被"描"进成图(脸上出轮廓线)。
ref_vals["reference_latents_method"] = "index"
conditioning = node_helpers.conditioning_set_values(
conditioning,
ref_vals,
@@ -486,7 +647,11 @@ class DynamicRefImageEncode:
else:
latent_out = self._make_empty_latent(模型类型, final_w, final_h, 图层数)
return (conditioning, latent_out)
# 第二/三个输出:空文本负条件 + 采样起点 latent。
# 顺序见 RETURN_NAMES —— negative 在前、latent 在后。
# CFG=1.0 时负面不参与计算(等价于原来的 ConditioningZeroOut),
# CFG>1 时它是唯一正确的接法。
return (conditioning, _empty_negative(clip), latent_out)
NODE_CLASS_MAPPINGS = {
@@ -648,7 +813,7 @@ class OneImageMultiPrompt:
"with the original input where appropriate."
),
}),
"模型类型": (["Flux2", "Qwen Image 2.1", "SD / SDXL", "Krea2"], {"default": "Flux2"}),
"模型类型": (["Flux2", "Qwen Image 2.1", "SD / SDXL / Z-Image", "Krea2"], {"default": "Flux2"}),
"匹配原图尺寸": (["OFF", "ON"], {"default": "OFF"}),
"输出宽度": ("INT", {"default": 1024, "min": 256, "max": 4096, "step": 8}),
"输出高度": ("INT", {"default": 1024, "min": 256, "max": 4096, "step": 8}),
@@ -701,6 +866,7 @@ class OneImageMultiPrompt:
n = max(2, min(8, int(prompt数量)))
llama_template = self._get_system_prompt(instruction)
模型类型 = _norm_model_type(模型类型) # 老存档兼容
is_21 = (模型类型 == "Qwen Image 2.1")
is_krea2 = (模型类型 == "Krea2")
@@ -750,7 +916,19 @@ class OneImageMultiPrompt:
# ⚠️ 传了 llama_template 时 Qwen3VL tokenizer 不会自动插视觉占位符
# (只有用内置 llama_template_images 时才自动),必须自己写
# <|vision_start|><|image_pad|><|vision_end|>,否则参考图对模型完全不可见。
ref_latent, vl_image = _krea2_ref_preprocess(samples, vae)
# ★ 参考图先 fit 到出图网格(krea2_edit 训练几何),理由见 _krea2_fit_ref。
kw_, kh_ = (_krea2_align8(pixel_width), _krea2_align8(pixel_height))
s = _krea2_fit_ref(samples, kw_, kh_, 尺寸适配)
ref_latent = vae.encode(s.movedim(1, -1)[:, :, :, :3]) if vae is not None else None
vl_total = 384 * 384
vl_scale = math.sqrt(vl_total / current_total)
s_vl = comfy.utils.common_upscale(
samples,
max(8, round(samples.shape[3] * vl_scale)),
max(8, round(samples.shape[2] * vl_scale)),
"area", "disabled",
)
vl_image = s_vl.movedim(1, -1)
image_prompt = "Picture 1: " + KREA2_VISION_TOKEN
else:
# VAE 编码参考图(图生图用)
@@ -789,12 +967,17 @@ class OneImageMultiPrompt:
tokens = clip.tokenize(image_prompt + p, images=[vl_image], llama_template=llama_template)
cond = clip.encode_from_tokens_scheduled(tokens)
_warn_no_ref_capability(clip, 0 if ref_latent is None else 1, "一图多Prompt")
if ref_latent is not None:
ref_vals = {"reference_latents": [ref_latent]}
if is_krea2:
# Krea2 的 default_ref_method 是 None ⇒ 不显式给方法时参考图会被
# 静默忽略(不报错、只是没效果)。官方风格模板用 index_timestep_zero。
ref_vals["reference_latents_method"] = "index_timestep_zero"
# 静默忽略(不报错、只是没效果)。
# ★★ 必须用 "index" 而非 "index_timestep_zero":后者给参考图单独
# 一套 t=0 的 adaLN 调制,与 krea2_edit LoRA 的训练(全序列同一
# tvec)不符,实测会把参考线稿"描"进成图。详见 DynamicRefImageEncode。
ref_vals["reference_latents_method"] = "index"
cond = node_helpers.conditioning_set_values(
cond,
ref_vals,
@@ -829,11 +1012,19 @@ class OneImageMultiPrompt:
elif ref_latent is not None:
latent_out = {"samples": ref_latent}
else:
# 纯文生图
scale = 16 if 模型类型 == "Flux2" else 8
# 纯文生图:通道数必须跟模型一致。
# Flux2(以及同族的 MOSS-Image / FIBO 2.0)是 128 通道 / 16x,SD 是 4 通道 / 8x。
# 原来不分类型一律 4 通道,接 Flux2 采样会直接通道不匹配。
if 模型类型 == "Flux2":
scale, channels = 16, 128
else:
scale, channels = 8, 4
lw = 输出宽度 // scale
lh = 输出高度 // scale
latent_out = {"samples": torch.zeros(1, 4, lh, lw)}
latent_out = {"samples": torch.zeros(
[1, channels, lh, lw],
device=comfy.model_management.intermediate_device(),
)}
# 补齐到 8 个 conditioning(未使用的填空 conditioning)
while len(results) < 8:
@@ -892,7 +1083,7 @@ class DynamicRefIndependent:
},
"optional": {
**opt,
"模型类型": (["Flux2", "Qwen Layered", "Qwen Image 2.1", "SD / SDXL", "Krea2"], {"default": "Flux2"}),
"模型类型": (["Flux2", "Qwen Layered", "Qwen Image 2.1", "SD / SDXL / Z-Image", "Krea2"], {"default": "Flux2"}),
"匹配原图尺寸": (["OFF", "ON"], {"default": "OFF"}),
"输出宽度": ("INT", {"default": 1024, "min": 256, "max": 4096, "step": 8}),
"输出高度": ("INT", {"default": 1024, "min": 256, "max": 4096, "step": 8}),
@@ -944,8 +1135,17 @@ class DynamicRefIndependent:
尺寸适配="参考图保比例", **kwargs):
n = max(1, min(10, int(num_images)))
llama_template = self._get_system_prompt(instruction)
模型类型 = _norm_model_type(模型类型) # 老存档兼容
is_21 = (模型类型 == "Qwen Image 2.1")
is_krea2 = (模型类型 == "Krea2")
if is_krea2 and n > 1:
# 本节点把每张图编进各自独立的 conditioning(Qwen 2.1 的每图独立 index 平面那套设计)。
# Krea2 需要的是「一条连贯的参考序列」:合并多个独立 conditioning 后,
# reference_latents 的 frame 语义会乱 —— 实测线稿残留 + 姿势跑偏,而且不报错。
print("[Toggle-Pass] ⚠️ DynamicRefIndependent 在 Krea2 多图下不可靠:多图请改用 "
"Ref Image Encode(DynamicRefImageEncode),一个节点就能带上多张参考图。",
flush=True)
conds = []
latents = [] # 每张图自己的 latent
first_latent = None # 第一张非空图的 latent,用于共享 latent_out
@@ -1012,7 +1212,18 @@ class DynamicRefIndependent:
final_w = _krea2_align8(samples.shape[3])
final_h = _krea2_align8(samples.shape[2])
size_locked = True
img_latent, vl_image = _krea2_ref_preprocess(samples, vae)
# ★ 参考图先 fit 到出图网格(krea2_edit 训练几何),理由见 _krea2_fit_ref
s_k = _krea2_fit_ref(samples, final_w, final_h, 尺寸适配)
img_latent = vae.encode(s_k.movedim(1, -1)[:, :, :, :3]) if vae is not None else None
vl_total = 384 * 384
vl_scale = math.sqrt(vl_total / current_total)
s_vl = comfy.utils.common_upscale(
samples,
max(8, round(samples.shape[3] * vl_scale)),
max(8, round(samples.shape[2] * vl_scale)),
"area", "disabled",
)
vl_image = s_vl.movedim(1, -1)
if img_latent is not None and first_latent is None:
first_latent = img_latent
else:
@@ -1051,12 +1262,17 @@ class DynamicRefIndependent:
cond = clip.encode_from_tokens_scheduled(tokens)
# ---- 每张图的 conditioning 绑定自己的 latent ----
_warn_no_ref_capability(clip, 0 if img_latent is None else 1, "Ref Independent")
if img_latent is not None:
ref_vals = {"reference_latents": [img_latent]}
if is_krea2:
# Krea2 的 default_ref_method 是 None ⇒ 不显式给方法时参考图会被
# 静默忽略(不报错、只是没效果)。官方风格模板用 index_timestep_zero。
ref_vals["reference_latents_method"] = "index_timestep_zero"
# 静默忽略(不报错、只是没效果)。
# ★★ 必须用 "index" 而非 "index_timestep_zero":后者给参考图单独
# 一套 t=0 的 adaLN 调制,与 krea2_edit LoRA 的训练(全序列同一
# tvec)不符,实测会把参考线稿"描"进成图。
ref_vals["reference_latents_method"] = "index"
cond = node_helpers.conditioning_set_values(
cond,
ref_vals,
@@ -1094,7 +1310,17 @@ class DynamicRefIndependent:
elif is_krea2:
latent_out = _krea2_empty_latent(final_w, final_h)
elif first_latent is None:
latent_out = {"samples": torch.zeros(1, 4, 1, 1)}
# 一张图都没接:给一个与模型匹配的空 latent。
# 原来固定 4 通道 1×1 —— KSampler 的 fix_empty_latent_channels 会把它补成
# (1, 128, 1, 1) 并照常采样,最后解码出一张 16×16 的废图(不报错,很难查)。
if 模型类型 == "Flux2":
scale, channels = 16, 128
else:
scale, channels = 8, 4
latent_out = {"samples": torch.zeros(
[1, channels, max(1, 输出高度 // scale), max(1, 输出宽度 // scale)],
device=comfy.model_management.intermediate_device(),
)}
else:
latent_out = {"samples": first_latent}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Some files were not shown because too many files have changed in this diff Show More