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 顶部加版本标识
@@ -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")
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |