提示词预设增强

This commit is contained in:
wjluoxiao
2026-10-30 15:44:54 +08:00
parent 21eb17f086
commit 67144ea9d2
11 changed files with 1683 additions and 12 deletions
+20 -1
View File
@@ -107,7 +107,7 @@ try:
from .nodes_fast_toggle import XB_DisableGroups, XB_DisableNodes, XB_AnySwitch
from .nodes_flux_encode import XB_FluxMultiImageEncode
from .nodes_qwen_encode import XB_TextEncodeQwenImageEdit, XB_TextEncodeQwenImageEditPlus
from .nodes_image_scale import XB_ImageScale
from .nodes_image_scale import XB_ImageScale, XB_ImageScalePixels
from .nodes_shenbi import XB_ShenBi
from .nodes_hailuo_video import XB_HailuoH3VideoParams
from .nodes_list_dispatcher import XB_ListDispatcher
@@ -208,6 +208,16 @@ try:
# ── 生图提示词预设(提示词 + 空latent 一体化)──
from .nodes_image_prompt_preset import XB_ImagePromptPreset
# ── 生图预设(生图提示词预设 + 姿态参考图 / 穿搭参考图)──
# 同时 import xb_gallery → 注册 /xb_toolbox/gallery/* 端点(图库绑定、素材清单、图片读取)
try:
from . import xb_gallery # noqa: F401
from .nodes_image_preset import XB_ImagePreset
_IMAGE_PRESET_AVAILABLE = True
except Exception as e:
print_warning(f"[XB-BOX] 生图预设节点 未加载:{e}")
_IMAGE_PRESET_AVAILABLE = False
# ── 播放声音(复刻 pysssss PlaySound,自带音频资源)──
from .nodes_play_sound import XB_PlaySound
@@ -294,6 +304,7 @@ try:
"XB_TextEncodeQwenImageEdit": XB_TextEncodeQwenImageEdit,
"XB_TextEncodeQwenImageEditPlus": XB_TextEncodeQwenImageEditPlus,
"XB_ImageScale": XB_ImageScale,
"XB_ImageScalePixels": XB_ImageScalePixels,
"XB_HailuoH3VideoParams": XB_HailuoH3VideoParams,
"XB_ListDispatcher": XB_ListDispatcher,
"XB_BatchImages": XB_BatchImages,
@@ -333,6 +344,12 @@ try:
"XB_CosyVoice3_SpeakerInstruct2": XB_CosyVoice3_SpeakerInstruct2,
}
# ── 🖼️ 生图预设(生图提示词预设 + 姿态/穿搭参考图)──
if _IMAGE_PRESET_AVAILABLE:
NODE_CLASS_MAPPINGS.update({
"XB_ImagePreset": XB_ImagePreset,
})
# ── 🎬 保存视频 ──
if _SAVE_VIDEO_AVAILABLE:
NODE_CLASS_MAPPINGS.update({
@@ -398,6 +415,7 @@ try:
"XB_ImageParamsMasterMini": "XB-BOX - Image Params Master Mini",
"XB_ShenBi": "XB-BOX - 🖌️ ShenBi Brush",
"XB_ImagePromptPreset": "XB-BOX - Image Prompt Preset",
"XB_ImagePreset": "XB-BOX - 🖼️ 生图提示词预设(图)",
"XB_PlaySound": "XB-BOX - 🔊 播放声音",
"XB_MasterParameter": "XB-BOX - Master Parameter",
"XB_VideoLoader": "XB-BOX - 🎬 视频加载器(修复预览BUG)",
@@ -453,6 +471,7 @@ try:
"XB_TextEncodeQwenImageEdit": "XB-BOX - 📝 Qwen图像编辑编码",
"XB_TextEncodeQwenImageEditPlus": "XB-BOX - 📝 Qwen图像编辑编码Plus",
"XB_ImageScale": "XB-BOX - 📐 批量缩放图像",
"XB_ImageScalePixels": "XB-BOX - 📐 批量缩放图像(像素)",
"XB_HailuoH3VideoParams": "XB-BOX - 🌊 海螺H3视频参数",
"XB_ListDispatcher": "XB-BOX - 📋 列表分发",
"XB_BatchImages": "XB-BOX - 🖼️ 批量图像",
+71
View File
@@ -125,6 +125,77 @@ export function lockWidgets(node, names, locked, opts = {}) {
return list.length;
}
/**
* 灰化(禁用)一组 widget:仍可见但不可操作
* Nodes 2.0:widget.disabled = true → Vue 组件拿到 options.disabled 后渲染成 disabled(实测生效)
* 经典模式:同步写 options.disabled(部分前端版本不绘制灰态,但不影响数值使用)
* @param {object} node 节点
* @param {string[]} names widget 名
* @param {boolean} disabled 是否禁用
* @returns {boolean} 是否有变化
*/
export function setWidgetsDisabled(node, names, disabled) {
const list = findWidgets(node, names);
let changed = false;
for (const w of list) {
const t = liveWidget(node, w);
if (!t) continue;
if (!!t.disabled === !!disabled) {
if (t.options && !!t.options.disabled !== !!disabled) t.options.disabled = !!disabled;
continue;
}
t.disabled = !!disabled;
try { if (t.options) t.options.disabled = !!disabled; } catch (_) { /* ignore */ }
changed = true;
}
if (changed) refreshWidgets(node);
return changed;
}
/** LATENT 输出端口是否已连线(空latent 一组参数是否生效的前提) */
export function latentOutputLinked(node) {
try {
const outs = node?.outputs || [];
const slot = outs.find((o) => String(o?.type || "").toUpperCase() === "LATENT")
|| outs.find((o) => o?.name === "空latent");
if (!slot) return false;
return Array.isArray(slot.links) ? slot.links.length > 0 : slot.link != null;
} catch (_) {
return false;
}
}
/**
* 「空latent 未连线 → 相关参数变灰不可操作」的守卫
* 生效时把 names 里的 widget 设为 disabled;接上「空latent」输出连线后自动恢复。
* @param {object} node 节点
* @param {string[]} [names] 默认 latent_kind / aspect_ratio / width / height / batch_size
* @returns {Function} 手动刷新函数(工作流加载后调用一次)
*/
export function installLatentGuard(node, names) {
if (!node) return () => {};
const list = names || ["latent_kind", "aspect_ratio", "width", "height", "batch_size"];
const apply = () => {
if (!node.widgets) return;
const locked = !latentOutputLinked(node);
setWidgetsDisabled(node, list, locked);
try { node.__xbLatentLocked = locked; } catch (_) { /* ignore */ }
};
node.__xbLatentGuardApply = apply;
if (!node.__xbLatentGuardHooked) {
node.__xbLatentGuardHooked = true;
const prev = node.onConnectionsChange;
node.onConnectionsChange = function (...args) {
const r = prev?.apply(this, args);
setTimeout(apply, 0);
return r;
};
}
setTimeout(apply, 0);
setTimeout(apply, 350);
return apply;
}
/**
* DOM widget 尺寸(Nodes 2.0 用 computeLayoutSize/options.getMinHeight,经典模式用 computeSize)
* @param {number} minH 期望高度(px)
+742
View File
@@ -0,0 +1,742 @@
/**
* XB-BOX 参考图库选择器(姿态 / 穿搭)
* ============================================================
* 由「XB-BOX - 🖼️ 生图预设」的「🎬 姿态参考图选择 / 👚 穿搭参考图选择」两个按钮打开。
*
* · 首次打开必须先**手动绑定**目录(使用确认 + 绝对路径),绑定信息只存本机
* <user_dir>/xb_toolbox/gallery.json,工作流里只存库内相对路径;
* · 带素材清单的图库(目录下有 data/*.json):体系 / 分类 / 视角 / 标签筛选 +
* 按姿势编号分组 + 视角条 + 收藏 / 最近使用 + 加载更多;
* · 普通图片文件夹(无清单):按文件名多宫格 + 搜索 + 排序 + 加载更多;
* · 单选(一个端口一张图)→ 回填节点内部字段 pose_ref / outfit_ref。
*
* 视觉沿用本包弹窗风格(#1c1c1e / 880px / 圆角 8px)。
*/
import { api } from "../../scripts/api.js";
const KIND_LABEL = { pose: "🎬 姿态参考图", costume: "👚 穿搭参考图" };
const ANGLE_ORDER = ["正面", "斜侧", "侧面", "俯视", "仰视"];
const PAGE = 48;
const LS = "xb_ref_gallery.v1.";
// ── 请求 / 图片地址 ──────────────────────────────────────────────
async function req(path, options = {}) {
const init = { ...options };
if (options.body) {
init.body = JSON.stringify(options.body);
init.headers = { ...(options.headers || {}), "Content-Type": "application/json" };
}
const resp = await api.fetchApi("/xb_toolbox/gallery" + path, init);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || "图库请求失败,请检查 ComfyUI 的运行状态。");
return data;
}
function imageUrl(kind, item, thumb) {
const query = new URLSearchParams({ kind, path: item.path, thumb: thumb ? "1" : "0" });
if (thumb && item.thumb) query.set("thumb_path", item.thumb);
return api.apiURL("/xb_toolbox/gallery/image?" + query.toString());
}
// ── 小工具 ──────────────────────────────────────────────────────
function el(tag, css = "", text = "") {
const node = document.createElement(tag);
if (css) node.style.cssText = css;
if (text) node.textContent = text;
return node;
}
function mkBtn(label, css, onClick) {
const btn = el("button", "font-family:inherit;cursor:pointer;border-radius:5px;" + css, label);
btn.type = "button";
btn.addEventListener("pointerdown", (e) => e.stopPropagation());
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
onClick?.(e);
});
return btn;
}
function readLocal(key, fallback) {
try {
const value = JSON.parse(localStorage.getItem(LS + key));
return Array.isArray(fallback) ? (Array.isArray(value) ? value : fallback) : (value ?? fallback);
} catch (_) {
return fallback;
}
}
function writeLocal(key, value) {
try { localStorage.setItem(LS + key, JSON.stringify(value)); } catch (_) { /* 隐私模式下忽略 */ }
}
const groupKey = (item) => JSON.stringify([item.system, item.group]);
const angleRank = (item) => {
const index = ANGLE_ORDER.indexOf(item.angle);
return index < 0 ? ANGLE_ORDER.length : index;
};
/** 筛选:空格分词 AND + 各维度精确匹配(收藏 / 最近 为集合过滤) */
function filterItems(items, state, favorites, recent) {
const words = String(state.query || "").trim().toLowerCase().split(/\s+/).filter(Boolean);
const favSet = new Set(favorites);
const recSet = new Set(recent);
const result = items.filter((item) =>
(!state.system || item.system === state.system) &&
(!state.category || item.category === state.category) &&
(!state.angle || item.angle === state.angle) &&
(!state.tag || (item.tags || []).includes(state.tag)) &&
(state.collection !== "favorites" || favSet.has(item.id)) &&
(state.collection !== "recent" || recSet.has(item.id)) &&
words.every((word) => String(item.search || "").includes(word)));
if (state.collection === "recent") {
const rank = new Map(recent.map((id, index) => [id, index]));
result.sort((a, b) => (rank.get(a.id) ?? 1e9) - (rank.get(b.id) ?? 1e9));
}
return result;
}
function buildGroups(items) {
const groups = new Map();
for (const item of items) {
const key = groupKey(item);
if (!groups.has(key)) groups.set(key, { key, items: [] });
groups.get(key).items.push(item);
}
for (const group of groups.values()) group.items.sort((a, b) => angleRank(a) - angleRank(b));
return groups;
}
// ── 弹窗外壳(本包统一风格:宽弹窗 + 字号 / 界面缩放,与本包其它 mod 面板同一套记忆) ──
// 字号 = 弹窗整体 font-size(px);界面 = 整体 zoom(%)。两项都存在 localStorage,
// 与「添加预设 / 编辑预设」等弹窗共用同一组键 → 在一个弹窗里调好,其它弹窗一致。
const MODAL_FONT_KEY = "xb_ipp_modal_font";
const MODAL_ZOOM_KEY = "xb_ipp_modal_zoom";
const FONT_MIN = 10, FONT_MAX = 30, FONT_DEF = 15;
const ZOOM_MIN = 0.6, ZOOM_MAX = 2.0, ZOOM_STEP = 0.05;
const DIALOG_WIDTH = 1280;
function fontGet() {
try {
const value = parseInt(localStorage.getItem(MODAL_FONT_KEY) || "", 10);
if (Number.isFinite(value)) return Math.max(FONT_MIN, Math.min(FONT_MAX, value));
} catch (_) {}
return FONT_DEF;
}
function fontSet(value) {
const next = Math.max(FONT_MIN, Math.min(FONT_MAX, Math.round(value)));
try { localStorage.setItem(MODAL_FONT_KEY, String(next)); } catch (_) {}
return next;
}
function zoomGet() {
try {
const value = parseFloat(localStorage.getItem(MODAL_ZOOM_KEY) || "");
if (Number.isFinite(value) && value > 0) return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, value));
} catch (_) {}
return 1;
}
function zoomSet(value) {
const stepped = Math.round(Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, value)) / ZOOM_STEP) * ZOOM_STEP;
const next = Math.round(stepped * 100) / 100;
try { localStorage.setItem(MODAL_ZOOM_KEY, String(next)); } catch (_) {}
return next;
}
/** 把字号 / 界面缩放套到弹窗上
* ⚠️ zoom 会等比放大整个盒子:宽度 / 高度上限必须先按 zoom 折回布局像素,
* 否则「界面 >100%」时卡片会顶出屏幕、出现横向挤压与字叠字。 */
function applyScale(card) {
if (!card) return;
const font = fontGet();
const zoom = zoomGet();
card.style.fontSize = font + "px";
card.style.zoom = String(zoom);
card.style.width = `min(${DIALOG_WIDTH}px, calc(96vw / ${zoom}))`;
card.style.maxHeight = `calc(90vh / ${zoom})`;
card.querySelectorAll("textarea, input[type=search], input[type=text]").forEach((node) => {
node.style.fontSize = "1em";
});
}
function shell(title) {
const mask = el("div", "position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,.78);"
+ "display:flex;overflow:auto;box-sizing:border-box;");
const card = el("div", "margin:auto;flex:0 0 auto;display:flex;flex-direction:column;"
+ "background:#1c1c1e;border:1px solid #333;border-radius:8px;color:#ddd;"
+ "font-family:system-ui,sans-serif;line-height:1.65;box-shadow:0 20px 40px rgba(0,0,0,.6);overflow:hidden;");
const head = el("div", "padding:14px 18px 12px;background:#232326;border-bottom:1px solid #3a3a3a;"
+ "display:flex;align-items:center;gap:12px;flex-wrap:wrap;");
head.append(el("span", "flex:1 1 320px;min-width:200px;font-size:1.2em;font-weight:700;color:#eee;", title));
// 顶部常驻条(放「禁用」勾选框等,切换视图时不会被清空)
const top = el("div", "padding:8px 18px;background:#202024;border-bottom:1px solid #333;"
+ "display:flex;align-items:center;gap:12px;flex-wrap:wrap;");
top.hidden = true;
const body = el("div", "padding:14px 18px 16px;display:flex;flex-direction:column;gap:10px;overflow:auto;min-height:0;");
const foot = el("div", "padding:10px 18px;border-top:1px solid #3a3a3a;background:#18181a;"
+ "display:flex;align-items:center;gap:10px;flex-wrap:wrap;");
// 顶部工具条:字号 A−/A+ | 界面 −/%/+ | ✕
const tools = el("span", "display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-weight:normal;");
const fontVal = el("span", "min-width:26px;text-align:center;color:#e8a87c;font-variant-numeric:tabular-nums;", String(fontGet()));
const zoomVal = el("span", "min-width:46px;text-align:center;color:#ffd98a;font-variant-numeric:tabular-nums;",
Math.round(zoomGet() * 100) + "%");
const refreshVals = () => {
fontVal.textContent = String(fontGet());
zoomVal.textContent = Math.round(zoomGet() * 100) + "%";
};
// 缩放变更后通知内容(如大图预览需要按新缩放重算可用高度)
const scaleNow = () => {
applyScale(card);
try { card.dispatchEvent(new Event("xb-scale")); } catch (_) {}
};
const fontMinus = mkBtn("A−", "border:1px solid #5b9bd5;background:#2a4a6a;color:#cfe3f7;height:1.9em;padding:0 .55em;", () => {
fontSet(fontGet() - 1);
scaleNow();
refreshVals();
});
const fontPlus = mkBtn("A+", "border:1px solid #5b9bd5;background:#2a4a6a;color:#cfe3f7;height:1.9em;padding:0 .55em;", () => {
fontSet(fontGet() + 1);
scaleNow();
refreshVals();
});
const zoomMinus = mkBtn("−", "border:1px solid #8a6d3b;background:#5a4a2a;color:#ffd98a;height:1.9em;padding:0 .6em;", () => {
zoomSet(zoomGet() - ZOOM_STEP);
scaleNow();
refreshVals();
});
const zoomPlus = mkBtn("+", "border:1px solid #8a6d3b;background:#5a4a2a;color:#ffd98a;height:1.9em;padding:0 .6em;", () => {
zoomSet(zoomGet() + ZOOM_STEP);
scaleNow();
refreshVals();
});
tools.append(
el("span", "color:#8a8a8a;font-size:.9em;", "字号"), fontMinus, fontVal, fontPlus,
el("span", "color:#5a5a5a;", "|"),
el("span", "color:#8a8a8a;font-size:.9em;", "界面"), zoomMinus, zoomVal, zoomPlus,
);
head.append(tools);
const close = () => {
try { mask.remove(); } catch (_) {}
document.removeEventListener("keydown", onKey);
mask.removeEventListener("wheel", onWheel);
window.app?.canvas?.setDirty?.(true, true);
};
const onKey = (event) => { if (event.key === "Escape") close(); };
// Ctrl/⌘ + 滚轮 = 调界面缩放(与本包元素面板一致)
const onWheel = (event) => {
if (!(event.ctrlKey || event.metaKey)) return;
event.preventDefault();
zoomSet(zoomGet() + (event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP));
scaleNow();
refreshVals();
};
tools.append(mkBtn("✕", "border:1px solid #555;background:#2b2b2b;color:#ddd;height:1.9em;padding:0 .7em;", close));
card.append(head, top, body, foot);
mask.append(card);
mask.addEventListener("pointerdown", (e) => { if (e.target === mask) close(); });
mask.addEventListener("wheel", onWheel, { passive: false });
document.addEventListener("keydown", onKey);
document.body.append(mask);
scaleNow();
return { mask, card, top, body, foot, head, close, applyScale: scaleNow };
}
/**
* 打开参考图选择器。
* @param {{kind:"pose"|"costume", current?:string, onPick?:(sel:{path:string,name:string,id:string})=>void}} options
*/
export async function openRefPicker({ kind, current = "", onPick } = {}) {
const label = KIND_LABEL[kind] || "参考图";
const ui = shell(label + " · 选择参考图");
const kindName = kind === "pose" ? "姿态" : "穿搭";
// ── 当前值(JSON:{path, name, disabled}) ─────────────────────
const parseCurrent = () => {
const text = String(current || "").trim();
if (!text) return { path: "", name: "", disabled: false };
if (text.startsWith("{")) {
try {
const data = JSON.parse(text) || {};
return { path: String(data.path || ""), name: String(data.name || ""), disabled: !!data.disabled };
} catch (_) {
return { path: "", name: "", disabled: false };
}
}
return { path: text, name: text.split("/").pop(), disabled: false };
};
const initial = parseCurrent();
let disabled = initial.disabled;
let currentPick = initial.path ? { path: initial.path, name: initial.name, id: "" } : null;
/** 写回节点:禁用状态与所选素材一起写;close=false 时只更新不关窗 */
const emit = (item, closeAfter) => {
const picked = item || currentPick || { path: "", name: "", id: "" };
try {
onPick?.({ path: picked.path || "", name: picked.name || "", id: picked.id || "", disabled });
} catch (err) {
console.error("[XB-参考图库]", err);
}
if (closeAfter) ui.close();
};
// ── 顶部「禁用」勾选框(姿态/穿搭各自独立,随工作流保存) ───────
const disableBox = el("input", "width:18px;height:18px;accent-color:#e07070;cursor:pointer;flex:0 0 auto;margin:0;");
disableBox.type = "checkbox";
disableBox.checked = disabled;
disableBox.id = `xb-ref-disable-${kind}`;
const disableText = el("span", "color:#ddd;cursor:pointer;user-select:none;", `🚫 禁用此参考图(不做任何输出)`);
const disableWrap = el("label", "display:flex;align-items:center;gap:8px;cursor:pointer;padding:2px 0;");
disableWrap.append(disableBox, disableText);
const HINT_OFF = "勾选后本参考图不做任何输出(等同 mute):接在它下游的节点会被直接跳过,不执行也不报错。";
const HINT_ON = `已禁用:${kindName}图不做任何输出,下游节点会被跳过。取消勾选即恢复。`;
const disableHint = el("span", "color:#8a8a8a;font-size:.88em;", HINT_OFF);
ui.top.append(disableWrap, disableHint);
ui.top.hidden = false;
disableBox.addEventListener("change", () => {
disabled = disableBox.checked;
disableText.style.color = disabled ? "#ffb74d" : "#ddd";
disableHint.style.color = disabled ? "#d9a441" : "#8a8a8a";
disableHint.textContent = disabled ? HINT_ON : HINT_OFF;
emit(null, false); // 立即写回节点(不关窗,方便接着选图)
});
disableHint.textContent = disabled ? HINT_ON : HINT_OFF;
disableText.style.color = disabled ? "#ffb74d" : "#ddd";
// ── ① 绑定界面(未绑定 / 未确认时强制先绑定) ──────────────────
async function showBinding(settings, message = "") {
ui.body.replaceChildren();
ui.foot.replaceChildren();
const cfg = (settings && settings[kind]) || {};
ui.body.append(el("div", "color:#ffb74d;line-height:1.75;white-space:pre-wrap;",
`⚠️ 首次使用需手动绑定${kind === "pose" ? "姿态" : "穿搭"}参考图库目录。\n`
+ "· 整套图库:选择包含 data / img / thumbs 的目录"
+ "(目录里有 data/*.json 素材清单即可自动识别)\n"
+ "· 普通文件夹:任意装图片的目录即可(按文件名多宫格显示)\n"
+ "· 本节点不内置、不分发任何图库素材;请自行确认素材的合法来源与使用授权。"));
const pathInput = el("input", "flex:1 1 320px;min-width:220px;height:2em;background:#1e1e1e;color:#ddd;"
+ "border:1px solid #444;border-radius:4px;padding:0 8px;font-size:1em;box-sizing:border-box;");
pathInput.type = "text";
pathInput.value = cfg.directory || "";
pathInput.placeholder = "填写 ComfyUI 所在电脑上的绝对路径";
const status = el("div", "color:#e07070;min-height:1.4em;white-space:pre-wrap;", message);
const browse = mkBtn("📂 浏览本机目录", "border:1px solid #555;background:#2b2b2b;color:#ddd;height:2.2em;padding:0 12px;", async () => {
try {
const resp = await api.fetchApi("/xb_toolbox/choose_folder", { method: "POST" });
const data = await resp.json();
if (data.path) {
pathInput.value = data.path;
status.style.color = "#bbb";
status.textContent = "已选择目录,勾选下方使用确认后点「检查并绑定」。";
} else if (data.error) {
status.style.color = "#e07070";
status.textContent = data.error;
}
} catch (err) {
status.style.color = "#e07070";
status.textContent = "调用系统目录选择框失败:" + (err?.message || err);
}
});
const row = el("div", "display:flex;gap:8px;align-items:center;flex-wrap:wrap;");
row.append(pathInput, browse);
const agreeRow = el("label", "display:flex;gap:8px;align-items:flex-start;cursor:pointer;color:#cfcfcf;line-height:1.6;");
const agree = el("input", "width:18px;height:18px;accent-color:#f59e0b;margin-top:3px;flex:0 0 auto;");
agree.type = "checkbox";
agree.checked = !!(cfg.accepted && cfg.directory);
agreeRow.append(agree, el("span", "",
"我已确认合法获得该图库的使用权,并自行承担素材使用与合规责任(本插件只读取本机文件,不上传、不分发)。"));
const save = mkBtn("✅ 检查并绑定", "background:#2d5a88;color:#fff;border:none;height:2.2em;padding:0 18px;font-weight:600;", async () => {
save.disabled = true;
status.style.color = "#bbb";
status.textContent = "正在检查目录…";
try {
const data = await req("/settings", {
method: "POST",
body: { kind, directory: pathInput.value, accepted: agree.checked },
});
const cat = await req(`/catalogue?kind=${kind}`);
status.style.color = "#7ed07e";
status.textContent = cat.mode === "library"
? `已绑定:整套图库(${cat.count} 条 · 清单 ${cat.manifest})`
: `已绑定:普通文件夹(${cat.count} 张图片)`;
await showGallery(data.settings);
} catch (err) {
status.style.color = "#e07070";
status.textContent = err?.message || String(err);
} finally {
save.disabled = false;
}
});
ui.body.append(row, agreeRow, status);
ui.foot.append(el("span", "flex:1 1 auto;color:#8a8a8a;", "绑定信息只保存在本机 ComfyUI 用户目录,不写进工作流。"), save);
}
// ── ② 图库界面 ────────────────────────────────────────────────
async function showGallery(settings, message = "") {
ui.body.replaceChildren(el("div", "color:#bbb;", "正在读取图库…"));
ui.foot.replaceChildren();
let cat;
try {
cat = await req(`/catalogue?kind=${kind}&t=${Date.now()}`);
} catch (err) {
return showBinding(settings, err?.message || String(err));
}
const isLibrary = cat.mode === "library";
// 只有素材带「姿势编号」时才能按姿势分组(穿搭库这类只有分类/标签的库 == 逐张浏览)
const canGroup = isLibrary && cat.items.some((item) => item.group);
const facets = cat.facets || {};
const state = { query: "", system: "", category: "", angle: "", tag: "", collection: "all",
grouped: true, shown: PAGE, sort: "name" };
let selected = null;
// ── 筛选栏 ──
const bar = el("div", "display:flex;gap:8px;align-items:center;flex-wrap:wrap;");
const search = el("input", "flex:1 1 240px;min-width:160px;height:2em;background:#1e1e1e;color:#ddd;"
+ "border:1px solid #444;border-radius:4px;padding:0 8px;font-size:1em;box-sizing:border-box;");
search.type = "search";
search.placeholder = kind === "pose" ? "搜索姿势、编号、体系、分类、角度…" : "搜索款式、颜色、关键词、编号…";
bar.append(search);
const makeSelect = (caption, values, allLabel, onChange) => {
const select = el("select", "height:2em;background:#1e1e1e;color:#ddd;border:1px solid #444;"
+ "border-radius:4px;padding:0 6px;font-size:1em;");
select.add(new Option(allLabel, ""));
for (const value of values) select.add(new Option(value, value));
select.addEventListener("change", () => onChange(select.value));
const wrap = el("label", "display:flex;gap:6px;align-items:center;color:#aaa;white-space:nowrap;");
wrap.append(el("span", "", caption), select);
return { wrap, select };
};
const categoriesFor = () => [...new Set(cat.items
.filter((item) => !state.system || item.system === state.system)
.map((item) => item.category)
.filter(Boolean))];
const sysSel = makeSelect("体系", facets.system || [], "全部体系", (value) => {
state.system = value;
state.category = "";
refillCategories();
render();
});
const catSel = makeSelect("分类", categoriesFor(), "全部分类", (value) => { state.category = value; render(); });
const angSel = makeSelect("视角", facets.angle || [], "全部视角", (value) => { state.angle = value; render(); });
const tagSel = makeSelect("标签", facets.tag || [], "全部标签", (value) => { state.tag = value; render(); });
const refillCategories = () => {
catSel.select.replaceChildren(new Option("全部分类", ""));
for (const value of categoriesFor()) catSel.select.add(new Option(value, value));
catSel.select.value = state.category;
};
if (isLibrary) {
if ((facets.system || []).length) bar.append(sysSel.wrap);
bar.append(catSel.wrap);
if ((facets.angle || []).length) bar.append(angSel.wrap);
if ((facets.tag || []).length) bar.append(tagSel.wrap);
} else {
bar.append(catSel.wrap);
const sortSel = el("select", "height:2em;background:#1e1e1e;color:#ddd;border:1px solid #444;"
+ "border-radius:4px;padding:0 6px;font-size:1em;");
sortSel.add(new Option("文件名 ↑", "name"));
sortSel.add(new Option("文件名 ↓", "name-desc"));
sortSel.addEventListener("change", () => { state.sort = sortSel.value; render(); });
bar.append(sortSel);
}
bar.append(mkBtn("重置筛选", "border:1px solid #555;background:transparent;color:#ccc;height:2em;padding:0 10px;", () => {
Object.assign(state, { query: "", system: "", category: "", angle: "", tag: "", collection: "all" });
search.value = "";
sysSel.select.value = "";
angSel.select.value = "";
tagSel.select.value = "";
refillCategories();
render();
}));
// ── 收藏 / 最近 ──
const tabs = el("div", "display:flex;gap:6px;flex-wrap:wrap;");
const tabButtons = [];
if (isLibrary) {
for (const [key, text] of [["all", "全部"], ["favorites", "♡ 收藏"], ["recent", "最近使用"]]) {
const btn = mkBtn(text, "border:1px solid #555;background:#242424;color:#ccc;height:2em;padding:0 10px;", () => {
state.collection = key;
render();
});
btn.dataset.collection = key;
tabButtons.push(btn);
tabs.append(btn);
}
if (canGroup) {
tabs.append(mkBtn("切换分组 / 逐张", "border:1px solid #555;background:transparent;color:#bbb;height:2em;padding:0 10px;", () => {
state.grouped = !state.grouped;
render();
}));
}
}
// 不能分组的库(没有姿势编号)不显示「切换分组」按钮,state.grouped 一并置 false
if (!canGroup) state.grouped = false;
const summary = el("div", "color:#9fc3e8;font-size:.95em;");
const notice = el("div", "color:#e07070;min-height:1.2em;");
notice.textContent = message || "";
const grid = el("div", "display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;align-content:start;");
const more = mkBtn("加载更多", "border:1px dashed #555;background:transparent;color:#bbb;height:2.2em;grid-column:1/-1;", () => {
state.shown += PAGE;
render(false);
});
if (isLibrary) ui.body.append(bar, tabs); else ui.body.append(bar);
ui.body.append(summary, notice, grid, more);
if (ui.body.firstElementChild && ui.body.firstElementChild.textContent === "正在读取图库…") {
ui.body.firstElementChild.remove();
}
search.addEventListener("input", () => { state.query = search.value; render(); });
const favorites = () => readLocal(`favorites.${kind}`, []);
const recent = () => readLocal(`recent.${kind}`, []);
// ── 卡片 ──
function makeCard(item, groupItems) {
const card = el("div", "position:relative;border:2px solid " + (selected?.id === item.id ? "#5b9bd5" : "#333")
+ ";border-radius:6px;overflow:hidden;background:#222;display:flex;flex-direction:column;");
// 缩略图用 contain:姿势/服装都要看全,绝不裁切;背景压深色让留白不刺眼
const img = el("img", "width:100%;height:9.6em;object-fit:contain;display:block;background:#101012;cursor:zoom-in;");
img.loading = "lazy";
img.decoding = "async";
img.alt = item.name;
img.src = imageUrl(kind, item, true);
img.addEventListener("error", () => { img.style.opacity = "0.25"; img.title = "缩略图读取失败,请检查图库目录"; });
img.addEventListener("click", () => preview(item, groupItems));
// 编号角标(如 P001):永远显示,不受下面文字省略号影响
const badgeText = item.group || (kind === "pose" ? "" : item.id);
if (badgeText) {
const badge = el("div", "position:absolute;left:6px;top:6px;max-width:calc(100% - 12px);"
+ "background:rgba(0,0,0,.66);color:#9fd8ff;border:1px solid rgba(91,155,213,.55);border-radius:4px;"
+ "padding:0 6px;font-size:.76em;line-height:1.7;letter-spacing:.02em;"
+ "white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;", badgeText);
badge.title = item.group ? `编号:${item.group}` : `ID:${item.id}`;
card.append(badge);
}
const meta = el("div", "padding:6px 8px;font-size:.86em;line-height:1.5;overflow:hidden;");
meta.append(el("div", "color:#8fd3ff;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;", item.name));
const sub = [item.system, item.category, item.angle].filter(Boolean).join(" · ");
if (sub) meta.append(el("div", "color:#8a8a8a;font-size:.92em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;", sub));
const actions = el("div", "display:flex;gap:5px;padding:0 8px 7px;");
const pick = mkBtn(selected?.id === item.id ? "✓ 已选" : "选择",
"flex:1 1 auto;min-width:0;border:1px solid #4a7a99;background:#24404f;color:#dff;height:1.9em;font-size:.8em;white-space:nowrap;overflow:hidden;", () => {
selected = item;
currentPick = item;
render(false);
});
const fav = mkBtn(favorites().includes(item.id) ? "♥" : "♡",
"flex:0 0 auto;border:1px solid #555;background:transparent;color:#e89;height:1.9em;font-size:.8em;padding:0 8px;", () => {
const list = favorites();
writeLocal(`favorites.${kind}`, list.includes(item.id)
? list.filter((id) => id !== item.id)
: [...list, item.id]);
render(false);
});
actions.append(pick, fav);
card.append(img, meta, actions);
if (isLibrary && state.grouped && Array.isArray(groupItems) && groupItems.length > 1) {
const strip = el("div", "display:flex;gap:4px;padding:0 8px 8px;");
for (const angle of ANGLE_ORDER) {
const target = groupItems.find((value) => value.angle === angle);
const isCurrent = !!(target && target.id === item.id);
// 视角条只用单字(正/斜/侧/俯/仰)+ 悬停全名:卡片变窄或字号放大时也绝不换行挤压
const angleBtn = mkBtn(angle.slice(0, 1), "flex:1 1 0;min-width:0;font-size:.78em;line-height:1.5;height:1.9em;padding:0;"
+ "white-space:nowrap;overflow:hidden;"
+ "border:1px solid " + (isCurrent ? "#5b9bd5" : "#444") + ";"
+ "background:" + (target ? (isCurrent ? "#2d5a88" : "#2a2a2a") : "transparent") + ";"
+ "color:" + (target ? "#ddd" : "#555") + ";", () => {
if (target) preview(target, groupItems);
});
angleBtn.title = target ? `${angle} · 点击打开大图` : `${angle} 暂无素材`;
strip.append(angleBtn);
}
card.append(strip);
}
return card;
}
// ── 大图预览 ──
function preview(item, groupItems) {
const view = shell(`预览 · ${item.name}`);
const stage = el("div", "display:flex;justify-content:center;align-items:center;background:#111;"
+ "border-radius:6px;overflow:hidden;padding:4px;box-sizing:border-box;");
// ⚠️ 只给 max-width / max-height + object-fit:contain:浏览器会按原图比例缩放,
// 永远显示完整画面、不会被拉伸变形;两处上限都用**布局像素**(视口高度 ÷ 界面缩放),
// 所以界面放大到 200% 时也不会顶出屏幕或把图片压扁。
const img = el("img", "display:block;width:auto;height:auto;object-fit:contain;");
const fit = () => {
const zoom = zoomGet();
img.style.maxWidth = "100%";
img.style.maxHeight = Math.max(140, Math.round((window.innerHeight * 0.64) / zoom)) + "px";
};
fit();
img.addEventListener("load", fit);
img.src = imageUrl(kind, item, false);
img.alt = item.name;
// 字号 / 界面变更 → 重新按新缩放算可用高度
view.card.addEventListener("xb-scale", fit);
const onResize = () => {
if (!document.body.contains(img)) { window.removeEventListener("resize", onResize); return; }
fit();
};
window.addEventListener("resize", onResize);
stage.append(img);
// 明细:名称 / 编号 / 体系·分类·视角 / 素材 ID(编号与 ID 单列一行,避免被截断)
const previewLines = (value) => [
value.name,
value.group ? `编号:${value.group}` : "",
[value.system, value.category, value.angle].filter(Boolean).join(" · "),
`ID:${value.id}`,
].filter(Boolean).join("\n");
const info = el("div", "color:#bbb;white-space:pre-wrap;font-size:.95em;line-height:1.6;");
info.textContent = previewLines(item);
let currentItem = item;
const angles = el("div", "display:flex;gap:5px;flex-wrap:wrap;");
const paintAngles = () => {
angles.replaceChildren();
if (!isLibrary || !Array.isArray(groupItems) || groupItems.length < 2) return;
for (const angle of ANGLE_ORDER) {
const target = groupItems.find((value) => value.angle === angle);
const btn = mkBtn(angle, "border:1px solid " + (target && target.id === currentItem.id ? "#5b9bd5" : "#444")
+ ";background:" + (target ? "#2a2a2a" : "transparent") + ";color:" + (target ? "#ddd" : "#555")
+ ";height:1.9em;padding:0 10px;white-space:nowrap;", () => {
if (!target) return;
currentItem = target;
img.src = imageUrl(kind, target, false);
info.textContent = previewLines(target);
paintAngles();
});
btn.disabled = !target;
angles.append(btn);
}
};
paintAngles();
view.body.append(stage, info, angles);
const use = mkBtn("使用此图", "background:#2d5a88;color:#fff;border:none;height:2.2em;padding:0 18px;font-weight:600;", () => {
selected = currentItem;
currentPick = currentItem;
view.close();
render(false);
});
view.foot.append(el("span", "flex:1 1 auto;color:#8a8a8a;", "Esc 关闭"), use);
}
// ── 渲染 ──
function render(reset = true) {
if (reset) state.shown = PAGE;
let items = filterItems(cat.items, state, favorites(), recent());
if (!isLibrary && state.sort === "name-desc") items = [...items].reverse();
const groups = isLibrary ? buildGroups(items) : new Map();
let entries;
if (isLibrary && state.grouped) {
const seen = new Set();
entries = [];
for (const item of items) {
const key = groupKey(item);
if (seen.has(key)) continue;
seen.add(key);
entries.push({ item, group: groups.get(key)?.items || [item] });
}
} else {
entries = items.map((item) => ({ item, group: null }));
}
grid.replaceChildren();
for (const entry of entries.slice(0, state.shown)) grid.append(makeCard(entry.item, entry.group));
if (!entries.length) {
grid.append(el("div", "grid-column:1/-1;color:#888;padding:18px;",
state.collection === "favorites" ? "还没有匹配的收藏。" :
state.collection === "recent" ? "最近使用的素材会显示在这里。" :
"没有匹配的素材,试试减少筛选条件。"));
}
summary.textContent = isLibrary && state.grouped
? `${entries.length} 组 · ${items.length} 张 / 共 ${cat.count}`
: `${items.length} 张 / 共 ${cat.count}`;
more.hidden = entries.length <= state.shown;
if (!more.hidden) more.textContent = `加载更多 · 还有 ${entries.length - state.shown} 组/张`;
for (const btn of tabButtons) {
const active = btn.dataset.collection === state.collection;
btn.style.background = active ? "#2d5a88" : "#242424";
btn.style.color = active ? "#fff" : "#ccc";
btn.style.borderColor = active ? "#5b9bd5" : "#555";
}
paintFooter();
}
// ── 底栏(当前选择 + 使用) ──
function paintFooter() {
ui.foot.replaceChildren();
if (selected) {
const thumb = el("img", "width:46px;height:46px;object-fit:contain;border-radius:4px;border:1px solid #444;background:#101012;flex:0 0 auto;");
thumb.src = imageUrl(kind, selected, true);
const label = (selected.group ? `[${selected.group}] ` : "") + selected.name;
const text = el("span", "flex:1 1 auto;color:#ddd;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;", label);
text.title = label;
ui.foot.append(thumb, text);
} else {
ui.foot.append(el("span", "flex:1 1 auto;color:#8a8a8a;", "点卡片上的「选择」或大图里的「使用此图」"));
}
const use = mkBtn(disabled ? "✅ 保存(当前已禁用)" : (selected ? "✅ 使用此图" : "请先选一张"),
"background:#2d5a88;color:#fff;border:none;height:2.2em;padding:0 18px;font-weight:600;", () => {
if (!selected) return;
const list = recent();
writeLocal(`recent.${kind}`, [selected.id, ...list.filter((id) => id !== selected.id)].slice(0, 80));
emit(selected, true);
});
use.disabled = !selected;
const rebind = mkBtn("🔧 更换图库目录", "border:1px solid #555;background:transparent;color:#bbb;height:2.2em;padding:0 10px;", () => {
req("/settings")
.then((data) => showBinding(data.settings))
.catch((err) => { notice.textContent = err?.message || String(err); });
});
ui.foot.append(rebind, use);
}
// 再次打开时高亮上次选择
const currentPath = initial.path;
if (currentPath) selected = cat.items.find((item) => item.path === currentPath) || null;
if (selected) currentPick = selected;
render();
}
// ── 入口 ──────────────────────────────────────────────────────
try {
const data = await req("/settings");
const cfg = (data.settings || {})[kind] || {};
if (cfg.accepted && cfg.directory) await showGallery(data.settings);
else await showBinding(data.settings);
} catch (err) {
await showBinding({ [kind]: {} }, err?.message || String(err));
}
}
+105 -6
View File
@@ -26,9 +26,17 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { bindNodes2NodeFill } from "./xb_compat.js";
import { bindNodes2NodeFill, installLatentGuard } from "./xb_compat.js";
import { openRefPicker } from "./xb_gallery_picker.js";
const NODE_TYPE = "XB_ImagePromptPreset";
// 「🖼️ 生图预设」= 本节点 + 姿态/穿搭参考图:UI 全复用,只在 8 个分类按钮**上面**多一行参考图按钮
const GALLERY_NODE_TYPE = "XB_ImagePreset";
const NODE_TYPES = new Set([NODE_TYPE, GALLERY_NODE_TYPE]);
const isGalleryNode = (node) => (node?.type || node?.comfyClass) === GALLERY_NODE_TYPE;
// 参考图按钮 ↔ 内部字段名(值 = JSON {"path": 库内相对路径, "name": 展示名})
const REF_FIELDS = { pose: "pose_ref", costume: "outfit_ref" };
const REF_LABELS = { pose: "🎬 姿态参考图", costume: "👚 穿搭参考图" };
// ── 枚举(必须与 nodes_image_prompt_preset.py 的常量逐字一致) ─────────
const LANG_ZH = "中文 [ZH]";
@@ -2774,11 +2782,18 @@ function setupNode(node) {
const hideInternal = () => {
hideWidget(findWidget(node, "internal_prompt"));
hideWidget(findWidget(node, "manager_settings"));
if (isGalleryNode(node)) {
hideWidget(findWidget(node, "pose_ref"));
hideWidget(findWidget(node, "outfit_ref"));
}
refreshNodes2View(node);
node.setDirtyCanvas?.(true, true);
};
hideInternal();
// ── 「空latent」输出没连线时:空latent类型 / 画幅比例 / 宽度 / 高度 / 生成数量 全部变灰不可操作
// (接了「空latent」输出连线后自动恢复;接不接都只是 UI 层面,后端取值不受影响)
const refreshLatentGuard = installLatentGuard(node, ["latent_kind", "aspect_ratio", "width", "height", "batch_size"]);
// ── 节点表面 DOM:结构照抄「短剧导演台」(js/asset_manager.js)──
// ① 容器 = 满高 flex 列(width/height:100% + overflow:hidden),高度完全交给前端 overlay 布局;
// 提示词框 flex:1 1 auto 吃掉剩余高度。全程零 JS 高度计算 → 不可能出现
@@ -2817,6 +2832,85 @@ function setupNode(node) {
});
btnRow.append(btn);
}
// ②' 参考图按钮行(仅「🖼️ 生图预设」):放在 8 个分类按钮**上面**,2 列
// · 点一下打开图库选择器(首次需手动绑定目录),选好后把库内相对路径写进隐藏字段
// · 按钮文案实时显示已选素材名;未选/已选的边框颜色不同,一眼可辨
const refButtons = {};
const shortName = (text) => {
const s = String(text || "");
return s.length > 14 ? s.slice(0, 14) + "…" : s;
};
const parseRefValue = (raw) => {
const text = String(raw ?? "").trim();
if (!text) return null;
if (text.startsWith("{")) {
try {
const data = JSON.parse(text);
// 兼容「只勾了禁用、没选图」:path 为空但有 disabled 也要认
return data && (data.path || data.disabled) ? data : null;
} catch (_) { return null; }
}
return { path: text, name: text.split("/").pop() };
};
const refreshRefButton = (kind) => {
const btn = refButtons[kind];
if (!btn) return;
const ref = parseRefValue(readWidgetValue(findWidget(node, REF_FIELDS[kind])));
const base = REF_LABELS[kind];
const off = !!(ref && ref.disabled);
if (off) {
btn.textContent = `${base}:已禁用`;
btn.style.borderColor = "#7a7a7a";
btn.style.color = "#9a9a9a";
btn.title = `已禁用:本参考图不做任何输出(等同 mute),下游节点会被跳过。\n点击可取消禁用或重新选图`;
return;
}
btn.textContent = ref && ref.path ? `${base}:${shortName(ref.name || ref.path)}` : `${base}选择`;
btn.style.borderColor = ref && ref.path ? "#7ed07e" : "#c58a3a";
btn.title = ref && ref.path
? `已选:${ref.name || ref.path}\n${ref.path}\n点击可重新选择`
: `尚未选择${kind === "pose" ? "姿态" : "穿搭"}参考图,点击打开图库${kind === "pose" ? "(输出端口:姿态图)" : "(输出端口:穿搭图)"}`;
btn.style.color = ref && ref.path ? "#d8ffd8" : "#eee";
};
if (isGalleryNode(node)) {
const refRow = el("div", "display:grid;grid-template-columns:repeat(2,1fr);grid-auto-rows:30px;gap:6px;flex:0 0 auto;");
for (const kind of ["pose", "costume"]) {
const btn = el("button", "width:100%;height:30px;box-sizing:border-box;display:flex;align-items:center;justify-content:center;"
+ "border-radius:6px;border:2px solid #c58a3a;background:#3a3a3a;color:#eee;font-size:13px;cursor:pointer;"
+ "white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:inherit;", REF_LABELS[kind] + "选择");
btn.addEventListener("mousedown", (e) => e.stopPropagation());
btn.addEventListener("mouseenter", () => { btn.style.background = "#4a4a4a"; });
btn.addEventListener("mouseleave", () => { btn.style.background = "#3a3a3a"; });
btn.addEventListener("click", (e) => {
e.stopPropagation();
const widgetRef = findWidget(node, REF_FIELDS[kind]);
if (!widgetRef) return;
openRefPicker({
kind,
current: String(readWidgetValue(widgetRef) ?? ""),
onPick: (sel) => {
setWidgetValue(widgetRef, JSON.stringify({
path: sel.path || "",
name: sel.name || "",
disabled: !!sel.disabled,
}));
refreshRefButton(kind);
node.setDirtyCanvas?.(true, true);
if (sel.disabled) notify(`${kind === "pose" ? "姿态" : "穿搭"}参考图已禁用(不做任何输出)`);
else if (sel.path) notify(`已选${kind === "pose" ? "姿态" : "穿搭"}参考图:${sel.name}`);
},
}).catch((err) => {
console.error("[XB-生图预设] 打开参考图库失败", err);
notify("打开参考图库失败:" + (err?.message || err), "error");
});
});
refButtons[kind] = btn;
refreshRefButton(kind);
refRow.append(btn);
}
container.appendChild(refRow);
}
container.appendChild(btnRow);
// ③ 提示词框(flex:1 1 auto 吃掉剩余高度;原来的「生成详情」状态行已按要求删除)
@@ -3388,7 +3482,8 @@ function setupNode(node) {
// ── 隐藏字段的幽灵端口清理(V1 没有 socketless,高级字段也可能冒出端口) ──
const killGhost = () => {
try {
for (const nm of ["internal_prompt", "manager_settings"]) {
const hiddenNames = ["internal_prompt", "manager_settings", "pose_ref", "outfit_ref"];
for (const nm of hiddenNames) {
const idx = (node.inputs || []).findIndex((i) => i.name === nm);
if (idx < 0) continue;
if (typeof node.removeInput === "function") node.removeInput(idx);
@@ -3422,9 +3517,11 @@ function setupNode(node) {
} catch (_) {}
};
// 高度常量(提前声明:widget 的布局回调可能在后面才被调用,避免 TDZ)
// 按钮区行数:8 个分类按钮 = 2 行;「🖼️ 生图预设」另有一行参考图按钮(30 + 6 间距 = 36)
const REF_ROW_H = isGalleryNode(node) ? 36 : 0;
const TEXT_MIN_H = 300; // 提示词框最小高度(节点再小也不低于它;节点变大则跟随)
const DOM_FIXED_H = 116; // 按钮区 2 行(66) + 标签(17) + 内边距/间距(≈33)
const DOM_MIN_H = 411; // DOM 区最小高度 = 按钮区 66 + 间距 12 + 标签 17 + 输入框 300 + 内边距 16
const DOM_FIXED_H = 116 + REF_ROW_H; // 按钮区(66) + 标签(17) + 内边距/间距(≈33)
const DOM_MIN_H = 411 + REF_ROW_H; // DOM 区最小高度 = 按钮区 66 + 间距 12 + 标签 17 + 输入框 300 + 内边距 16
if (widget) {
try { delete widget.computeSize; } catch (_) { widget.computeSize = undefined; }
widget.options = widget.options || {};
@@ -3590,6 +3687,8 @@ function setupNode(node) {
const boot = () => {
hideInternal(); // 工作流加载后 widget 可能被重建 → 重新藏
if (isGalleryNode(node)) { refreshRefButton("pose"); refreshRefButton("costume"); }
refreshLatentGuard(); // 「空latent」未连线 → 空latent类型/画幅/宽高/数量变灰
migrateLegacyOutput();
migrateLegacyPreset();
syncPromptBoxFromWidget();
@@ -3640,7 +3739,7 @@ function setupNode(node) {
app.registerExtension({
name: "XB.ImagePromptPreset",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_TYPE) return;
if (!NODE_TYPES.has(nodeData?.name)) return;
const orig = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = orig?.apply(this, arguments);
@@ -3662,7 +3761,7 @@ app.registerExtension({
try {
const nodes = (app.graph && (app.graph._nodes || app.graph.nodes)) || [];
for (const n of nodes) {
if (n && n.type === NODE_TYPE && typeof n.__ippSyncPromptBox === "function") n.__ippSyncPromptBox();
if (n && NODE_TYPES.has(n.type) && typeof n.__ippSyncPromptBox === "function") n.__ippSyncPromptBox();
}
} catch (_) {}
}, 1500);
+6 -1
View File
@@ -35,7 +35,7 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { bindNodes2NodeFill } from "./xb_compat.js";
import { bindNodes2NodeFill, installLatentGuard } from "./xb_compat.js";
const NODE_TYPE = "XB_ImagePromptPresetPro";
@@ -2653,6 +2653,10 @@ function setupNode(node) {
};
hideInternal();
// ── 「空latent」输出没连线时:空latent类型 / 画幅比例 / 宽度 / 高度 / 生成数量 全部变灰不可操作
// (接了「空latent」输出连线后自动恢复;后端取值不受影响)
const refreshLatentGuard = installLatentGuard(node, ["latent_kind", "aspect_ratio", "width", "height", "batch_size"]);
// ── 节点表面 DOM:结构照抄「短剧导演台」(js/asset_manager.js)──
// ① 容器 = 满高 flex 列(width/height:100% + overflow:hidden),高度完全交给前端 overlay 布局;
// 提示词框 flex:1 1 auto 吃掉剩余高度。全程零 JS 高度计算 → 不可能出现
@@ -3098,6 +3102,7 @@ function setupNode(node) {
// ── 载入 / 执行后的对齐 ──
const boot = () => {
hideInternal(); // 工作流加载后 widget 可能被重建 → 重新藏
refreshLatentGuard(); // 「空latent」未连线 → 空latent类型/画幅/宽高/数量变灰
migrateLegacyOutput();
syncPromptBoxFromWidget();
applyKindLimits(); // 按当前空latent类型同步 step / 上下限
+7 -4
View File
@@ -6,9 +6,12 @@
import { app } from "../../scripts/app.js";
const NODE_TYPE = "XB_ImageScale";
const NODE_TYPES = new Set(["XB_ImageScale", "XB_ImageScalePixels"]);
const MAX_IMAGES = 9;
const FIXED_INPUT_NAMES = new Set(["upscale_method", "width", "height", "crop", "image1"]);
const FIXED_INPUT_NAMES = new Set([
"upscale_method", "width", "height", "crop",
"megapixels", "resolution_step", "image1",
]);
function isImageInput(inp) {
return !FIXED_INPUT_NAMES.has(inp.name) && (inp.type === "IMAGE" || inp.type === "*");
@@ -18,7 +21,7 @@ app.registerExtension({
name: "XB_ToolBox.ImageScale",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_TYPE) return;
if (!NODE_TYPES.has(nodeData?.name)) return;
const orig = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
@@ -88,7 +91,7 @@ app.registerExtension({
},
async loadedGraphNode(node) {
if (node.type !== NODE_TYPE) return;
if (!NODE_TYPES.has(node.type)) return;
const hasDyn = (node.inputs || []).some(inp => isImageInput(inp));
if (!hasDyn) node.addInput("image2", "IMAGE");
manageOutputs(node);
+59
View File
@@ -114,6 +114,9 @@
"XB_ImageScale": {
"display_name": "XB-BOX - 📐 Batch Scale Image"
},
"XB_ImageScalePixels": {
"display_name": "XB-BOX - 📐 Batch Scale Image (Pixels)"
},
"XB_HailuoH3VideoParams": {
"display_name": "XB-BOX - 🌊 Hailuo H3 Video Params"
},
@@ -568,6 +571,62 @@
}
}
},
"XB_ImagePreset": {
"display_name": "XB-BOX - 🖼️ Image Prompt Preset (Image)",
"description": "Everything in Image Prompt Preset (latent type + aspect ratio / size / batch + 8-element prompt panels + preset files), plus two pickers: Pose Reference and Outfit Reference. Bind a catalogued gallery (filter by system / category / angle / tag, grouped by pose with an angle strip) or any plain image folder (grid by file name). Each reference can be disabled: a disabled output produces nothing (like muting it) and skips downstream nodes. Outputs: prompt / empty latent / pose image / outfit image.",
"inputs": {
"latent_kind": {
"name": "Empty Latent Type"
},
"output_lang": {
"name": "Output Language"
},
"preset_mode": {
"name": "Preset Mode"
},
"three_view_text": {
"name": "Preset Text (manage presets from the buttons above)"
},
"aspect_ratio": {
"name": "Aspect Ratio"
},
"width": {
"name": "Width"
},
"height": {
"name": "Height"
},
"batch_size": {
"name": "Batch Size"
},
"internal_prompt": {
"name": "Internal Prompt"
},
"manager_settings": {
"name": "Node Config"
},
"pose_ref": {
"name": "Pose Reference (internal)"
},
"outfit_ref": {
"name": "Outfit Reference (internal)"
}
},
"outputs": {
"0": {
"name": "Prompt"
},
"1": {
"name": "Empty Latent"
},
"2": {
"name": "Pose Image"
},
"3": {
"name": "Outfit Image"
}
}
},
"XB_YuE2StylePrompt": {
"display_name": "XB-BOX - 🎵 YuE2 Style Prompt",
"description": "Build the YuE2 style string from the official six parts (language / genre / vocal / tempo / instruments / musical characteristics), plus max_duration and seed. Structure, key and meter belong to the lyrics tags and the ABC score.",
+74
View File
@@ -4936,6 +4936,24 @@
},
"outputs": {}
},
"XB_ImageScalePixels": {
"display_name": "XB-BOX - 📐 批量缩放图像(像素)",
"inputs": {
"upscale_method": {
"name": "缩放方法"
},
"megapixels": {
"name": "像素数量"
},
"resolution_step": {
"name": "分辨率步长"
},
"image1": {
"name": "图片1"
}
},
"outputs": {}
},
"XB_HailuoH3VideoParams": {
"display_name": "XB-BOX - 🌊 海螺H3视频参数",
"inputs": {
@@ -5327,6 +5345,62 @@
}
}
},
"XB_ImagePreset": {
"display_name": "XB-BOX - 🖼️ 生图提示词预设(图)",
"description": "生图提示词预设的全部能力(空latent类型 + 画幅比例/宽高/数量 + 8 大类元素面板拼词 + 预设文件),外加「🎬 姿态参考图选择 / 👚 穿搭参考图选择」两个按钮:可绑定带素材清单的整套图库(体系/分类/视角/标签筛选 + 姿势分组 + 视角条)或任意普通图片文件夹(按文件名多宫格)。参考图可单独勾选「禁用」:禁用后该输出不做任何输出(等同 mute),下游节点会被跳过。输出:提示词 / 空latent / 姿态图 / 穿搭图。",
"inputs": {
"latent_kind": {
"name": "空latent类型"
},
"output_lang": {
"name": "输出语言"
},
"preset_mode": {
"name": "预设模式"
},
"three_view_text": {
"name": "预设句(右上角按钮管理预设)"
},
"aspect_ratio": {
"name": "画幅比例"
},
"width": {
"name": "宽度"
},
"height": {
"name": "高度"
},
"batch_size": {
"name": "生成数量"
},
"internal_prompt": {
"name": "节点内提示词"
},
"manager_settings": {
"name": "节点配置"
},
"pose_ref": {
"name": "姿态参考(内部)"
},
"outfit_ref": {
"name": "穿搭参考(内部)"
}
},
"outputs": {
"0": {
"name": "提示词"
},
"1": {
"name": "空latent"
},
"2": {
"name": "姿态图"
},
"3": {
"name": "穿搭图"
}
}
},
"XB_PlaySound": {
"display_name": "XB-BOX - 🔊 播放声音",
"inputs": {
+62
View File
@@ -0,0 +1,62 @@
"""XB-BOX - 🖼️ 生图提示词预设(图)—— 生图提示词预设 + 姿态参考图 + 穿搭参考图
=================================================================
完全**继承** nodes_image_prompt_preset.XB_ImagePromptPreset 的参数与拼词逻辑:
· 表面参数 / 8 大元素面板 / 预设文件 / 空 latent 规格 / 画幅联动,一行都不改;
· 只额外做两件事:
① 多两个内部字段 pose_ref / outfit_ref(前端隐藏,由「🎬 姿态参考图选择 /
👚 穿搭参考图选择」按钮写入,值 = JSON {"path": 库内相对路径, "name": 展示名},
随工作流保存);
② 多两个 IMAGE 输出「姿态图 / 穿搭图」,执行时从用户自行绑定的图库读图
(带素材清单的整套图库或普通图片文件夹都支持,见 xb_gallery.py)。
"""
from . import xb_gallery
from .nodes_image_prompt_preset import XB_ImagePromptPreset
_REF_TOOLTIP = ("内部字段(节点表面不显示):由「参考图选择」按钮写入的图库相对路径 JSON;"
"随工作流保存,换机器 / 换图库后需重新选择。")
class XB_ImagePreset(XB_ImagePromptPreset):
"""生图预设:生图提示词预设的全部能力 + 姿态 / 穿搭参考图输出。"""
@classmethod
def INPUT_TYPES(cls):
data = super().INPUT_TYPES()
# 追加在 manager_settings 之后 → 不影响老工作流 widgets_values 的顺序
data["required"]["pose_ref"] = ("STRING", {"default": "", "tooltip": _REF_TOOLTIP})
data["required"]["outfit_ref"] = ("STRING", {"default": "", "tooltip": _REF_TOOLTIP})
return data
RETURN_TYPES = ("STRING", "LATENT", "IMAGE", "IMAGE")
RETURN_NAMES = ("提示词", "空latent", "姿态图", "穿搭图")
FUNCTION = "generate"
CATEGORY = "XB_ToolBox/Image_Params"
DESCRIPTION = ("生图预设:在「生图提示词预设」的全部能力(空latent类型 + 画幅比例/宽高/数量 + 8 大类元素面板拼词 + "
"预设文件)之上,新增「🎬 姿态参考图选择 / 👚 穿搭参考图选择」两个按钮:可绑定带素材清单的整套图库"
"(按体系/分类/视角/标签筛选,按姿势编号分组并带视角条)或任意普通图片文件夹(按文件名多宫格)。"
"参考图可单独勾选「禁用」:禁用后该输出不做任何输出(等同 mute),下游节点会被跳过。"
"输出:①提示词 ②空latent ③姿态图 ④穿搭图。")
@classmethod
def IS_CHANGED(cls, manager_settings="", pose_ref="", outfit_ref="", **kwargs):
base = super().IS_CHANGED(manager_settings=manager_settings, **kwargs)
return (f"{base}|{pose_ref}|{outfit_ref}"
f"|{xb_gallery.fingerprint('pose', pose_ref)}"
f"|{xb_gallery.fingerprint('costume', outfit_ref)}")
def generate(self, output_lang, preset_mode, latent_kind, three_view_text, aspect_ratio,
width, height, batch_size, internal_prompt="", manager_settings="",
pose_ref="", outfit_ref=""):
prompt, latent = super().generate(
output_lang=output_lang, preset_mode=preset_mode, latent_kind=latent_kind,
three_view_text=three_view_text, aspect_ratio=aspect_ratio, width=width,
height=height, batch_size=batch_size, internal_prompt=internal_prompt,
manager_settings=manager_settings)
return (prompt, latent,
xb_gallery.load_reference("pose", pose_ref),
xb_gallery.load_reference("costume", outfit_ref))
NODE_CLASS_MAPPINGS = {"XB_ImagePreset": XB_ImagePreset}
NODE_DISPLAY_NAME_MAPPINGS = {"XB_ImagePreset": "XB-BOX - 🖼️ 生图提示词预设(图)"}
+50
View File
@@ -5,6 +5,7 @@ XB-ToolBox 批量缩放图像节点
N进N出,共享缩放参数,每张独立处理,互不干扰。
"""
import math
import comfy.utils
import nodes
from .nodes_reference_any import FlexibleOptionalInputType
@@ -64,3 +65,52 @@ class XB_ImageScale:
results.append(fallback)
return tuple(results)
class XB_ImageScalePixels:
"""批量缩放图像(像素)— 按总像素数缩放,动态输入/输出,N进N出。"""
upscale_methods = ["nearest-exact", "bilinear", "area", "bicubic", "lanczos"]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"upscale_method": (cls.upscale_methods,),
"megapixels": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 16.0, "step": 0.01}),
"resolution_step": ("INT", {"default": 1, "min": 1, "max": 1024, "step": 1,
"tooltip": "宽高对齐到该步长的整数倍"}),
"image1": ("IMAGE",),
},
"optional": FlexibleOptionalInputType("IMAGE"),
}
RETURN_TYPES = ("IMAGE",) * MAX_IMAGES
RETURN_NAMES = tuple(f"图像{i+1}" for i in range(MAX_IMAGES))
FUNCTION = "upscale"
CATEGORY = "XB_ToolBox/Utils"
def upscale(self, upscale_method, megapixels, resolution_step, image1, **kwargs):
images = [image1]
for key, value in kwargs.items():
if value is not None and hasattr(value, 'movedim'):
images.append(value)
total = int(megapixels * 1024 * 1024)
results = []
for img in images:
samples = img.movedim(-1, 1)
scale_by = math.sqrt(total / (samples.shape[3] * samples.shape[2]))
w = max(1, round(samples.shape[3] * scale_by))
h = max(1, round(samples.shape[2] * scale_by))
if resolution_step > 1:
w = max(resolution_step, int(round(w / resolution_step)) * resolution_step)
h = max(resolution_step, int(round(h / resolution_step)) * resolution_step)
s = comfy.utils.common_upscale(samples, w, h, upscale_method, "disabled")
results.append(s.movedim(1, -1))
fallback = results[0] if results else image1
while len(results) < MAX_IMAGES:
results.append(fallback)
return tuple(results)
+487
View File
@@ -0,0 +1,487 @@
"""XB-BOX 参考图库内核(姿态 / 穿搭)—— 绑定目录、素材清单读取、图片端点、节点取图
==============================================================================
· 「姿态图库」「穿搭图库」两套库**分开绑定**:各一个目录,各自带「使用确认」标记;
· 支持两种目录形态(同一套代码自动识别,用户绑什么就用什么):
① 带素材清单的图库:目录下有 ``data/*.json``(数组,每项含 ``id`` 与
``原图`` / ``大图`` 字段)→ 读取清单得到 体系 / 分类 / 角度 / 标签 等筛选维度,
前端做筛选 + 分组 + 视角条界面;
② 普通图片文件夹:没有清单 → 递归扫描图片,前端按文件名多宫格展示;
· 绑定信息只落机器本地 ``<user_dir>/xb_toolbox/gallery.json``,不进 Git、不进工作流;
工作流里只存「库内相对路径 + 展示名」,不外发、不携带本机磁盘路径。
节点侧只需要 ``load_reference(kind, ref)``:返回 [1,H,W,3] float32 0~1 的 IMAGE 张量。
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import threading
import time
from pathlib import Path
from urllib.parse import urlsplit
KINDS = {"pose": "姿态图库", "costume": "穿搭图库"}
# 「禁用」参考图时用它把该输出整条链路阻断(等同于 mute:下游节点直接不执行,也不报错)
try:
from comfy_execution.graph_utils import ExecutionBlocker
except Exception: # 极老版本没有该机制 → 退化为白色占位图(保证不崩)
ExecutionBlocker = None
IMAGE_EXT = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"}
# 显式声明 Content-Type:配合 nosniff 时,返回 octet-stream 会让浏览器拒绝渲染图片
MIME_BY_EXT = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".webp": "image/webp", ".bmp": "image/bmp", ".gif": "image/gif"}
ANGLE_ORDER = ("正面", "斜侧", "侧面", "俯视", "仰视")
THUMB_MAX = 384
FOLDER_TTL = 5.0 # 普通文件夹扫描缓存(秒)
PACKAGE_ROOT = Path(__file__).resolve().parent
_cache_lock = threading.Lock()
_manifest_cache: dict = {} # {(root, mtime, size): catalogue}
_folder_cache: dict = {} # {root: (ts, catalogue)}
class GalleryError(ValueError):
"""可直接展示给用户的中文错误。"""
# ── 绑定设置(机器本地,不进工作流) ────────────────────────────────────
def settings_path() -> Path:
try:
import folder_paths
base = Path(folder_paths.get_user_directory())
except Exception:
base = PACKAGE_ROOT / ".xb_user"
return base / "xb_toolbox" / "gallery.json"
def read_settings() -> dict:
path = settings_path()
raw = {}
if path.is_file():
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
raw = {}
out = {}
for kind in KINDS:
entry = raw.get(kind) if isinstance(raw, dict) else None
entry = entry if isinstance(entry, dict) else {}
out[kind] = {
"directory": str(entry.get("directory") or ""),
"accepted": bool(entry.get("accepted")),
}
return out
def save_settings(kind: str, directory: str, accepted: bool) -> dict:
"""绑定 / 更换图库目录(绑定前先完整校验一遍,坏目录不落盘)。"""
if kind not in KINDS:
raise GalleryError("未知图库类型。")
directory = str(directory or "").strip()
if directory:
root = Path(directory).expanduser()
if not root.is_absolute():
raise GalleryError("请填写 ComfyUI 所在电脑上的绝对路径(例如 D:\\XB图库\\pose)。")
root = root.resolve()
if not root.is_dir():
raise GalleryError(f"目录不存在或不可访问:{root}")
if not accepted:
raise GalleryError("绑定图库前请先勾选「我已确认合法获得该图库的使用权」。")
build_catalogue(root) # 校验清单 / 图片是否可用
directory = str(root)
data = read_settings()
data[kind] = {"directory": directory, "accepted": bool(accepted) and bool(directory)}
path = settings_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
return data
def bound_root(kind: str) -> Path:
cfg = read_settings().get(kind) or {}
label = KINDS.get(kind, "图库")
if not cfg.get("accepted"):
raise GalleryError(f"尚未绑定{label}:请先在图库窗口里选择目录并勾选使用确认。")
directory = cfg.get("directory") or ""
if not directory:
raise GalleryError(f"尚未绑定{label}目录。")
root = Path(directory)
if not root.is_dir():
raise GalleryError(f"{label}目录不存在或已移动:{directory}")
return root.resolve()
# ── 路径安全(只允许库内相对路径 + 图片后缀) ─────────────────────────
def safe_rel(value: str) -> str:
if not isinstance(value, str) or not value.strip():
raise GalleryError("素材路径不能为空。")
text = value.replace("\\", "/").lstrip("/")
if re.match(r"^[a-zA-Z]:", text) or ".." in text.split("/"):
raise GalleryError(f"素材须使用库内的相对路径:{value}")
if Path(text).suffix.lower() not in IMAGE_EXT:
raise GalleryError(f"不是支持的图片格式:{value}")
return text
def resolve_image(root: Path, rel: str) -> Path:
path = (root / safe_rel(rel)).resolve()
try:
path.relative_to(root)
except ValueError as exc:
raise GalleryError(f"素材路径越界:{rel}") from exc
if not path.is_file():
raise GalleryError(f"图片文件缺失:{rel}(请检查图库是否完整)")
return path
# ── 清单适配器(pose / costume 字段不同,统一归一成同一套 item) ────────
def _natural_key(text: str):
return [int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", text)]
def _find_manifest(root: Path):
data = root / "data"
if not data.is_dir():
return None
preferred = [data / "pose-zh.json", data / "costume-zh.json"]
candidates = [p for p in preferred if p.is_file()]
candidates += [p for p in sorted(data.glob("*.json")) if p not in candidates]
for path in candidates:
try:
rows = json.loads(path.read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
continue
if isinstance(rows, list) and rows and isinstance(rows[0], dict) and rows[0].get("id"):
return path, rows
return None
def _library_item(row: dict) -> dict | None:
item_id = str(row.get("id") or "").strip()
if not item_id:
return None
original = str(row.get("原图") or row.get("大图") or "").strip()
if not original:
return None
thumb = str(row.get("缩略图") or "").strip()
tags = row.get("标签") if isinstance(row.get("标签"), list) else []
keywords = " ".join(
str(x) for x in (row.get("关键词"), row.get("关键词中文"), " ".join(map(str, tags))) if x
)
search = str(row.get("检索") or "").strip().lower() or " ".join([
item_id,
str(row.get("姿势名称") or ""),
str(row.get("分类") or ""),
str(row.get("体系") or ""),
str(row.get("角度") or ""),
keywords,
]).lower()
return {
"id": item_id,
"name": str(row.get("姿势名称") or row.get("名称") or row.get("关键词中文") or item_id),
"category": str(row.get("分类") or ""),
"system": str(row.get("体系") or ""),
"angle": str(row.get("角度") or ""),
"group": str(row.get("姿势编号") or ""),
"tags": [str(t) for t in tags],
"path": safe_rel(original),
"thumb": safe_rel(thumb) if thumb else "",
"search": search,
}
def _folder_item(root: Path, path: Path) -> dict:
rel = path.relative_to(root).as_posix()
return {
"id": rel,
"name": path.stem,
"category": "" if path.parent == root else path.parent.name,
"system": "",
"angle": "",
"group": "",
"tags": [],
"path": rel,
"thumb": "",
"search": rel.lower(),
}
def build_catalogue(root: Path) -> dict:
"""扫描目录 → 统一结构 {mode, manifest, count, items, facets}。"""
found = _find_manifest(root)
if found:
manifest, rows = found
items = [it for it in (_library_item(row) for row in rows if isinstance(row, dict)) if it]
if not items:
raise GalleryError(f"清单 {manifest.name} 里没有可用条目(需含 id 与 原图/大图 字段)。")
angles = [a for a in ANGLE_ORDER if any(i["angle"] == a for i in items)]
angles += sorted({i["angle"] for i in items} - set(angles) - {""})
tag_rank: dict = {}
for item in items:
for tag in item["tags"]:
tag_rank[tag] = tag_rank.get(tag, 0) + 1
return {
"mode": "library",
"manifest": manifest.relative_to(root).as_posix(),
"count": len(items),
"items": items,
"facets": {
"system": sorted({i["system"] for i in items if i["system"]}),
"category": sorted({i["category"] for i in items if i["category"]}),
"angle": angles,
"tag": [t for t, _n in sorted(tag_rank.items(), key=lambda kv: -kv[1])[:48]],
},
}
files = []
for path in root.rglob("*"):
if not path.is_file() or path.suffix.lower() not in IMAGE_EXT:
continue
rel = path.relative_to(root).as_posix()
if any(part.startswith(".") for part in rel.split("/")):
continue
files.append(path)
if not files:
raise GalleryError(f"目录里没有图片:{root}(支持 png/jpg/webp/bmp/gif)")
files.sort(key=lambda p: _natural_key(p.relative_to(root).as_posix()))
items = [_folder_item(root, p) for p in files]
return {
"mode": "folder",
"manifest": "",
"count": len(items),
"items": items,
"facets": {
"system": [],
"category": sorted({i["category"] for i in items if i["category"]}),
"angle": [],
"tag": [],
},
}
def catalogue_for(kind: str) -> dict:
"""读当前绑定的图库(整套库按清单 mtime 缓存;普通文件夹按 5 秒 TTL 缓存)。"""
root = bound_root(kind)
found = _find_manifest(root)
if found:
manifest, _rows = found
stat = manifest.stat()
key = (str(root), stat.st_mtime_ns, stat.st_size)
with _cache_lock:
cached = _manifest_cache.get(key)
if cached is None:
cached = build_catalogue(root)
with _cache_lock:
_manifest_cache.clear()
_manifest_cache[key] = cached
return cached
with _cache_lock:
ts, cached = _folder_cache.get(str(root), (0.0, None))
if cached is not None and time.time() - ts < FOLDER_TTL:
return cached
cached = build_catalogue(root)
with _cache_lock:
_folder_cache[str(root)] = (time.time(), cached)
return cached
def invalidate() -> None:
"""手动清缓存(换绑目录 / 测试用)。"""
with _cache_lock:
_manifest_cache.clear()
_folder_cache.clear()
# ── 缩略图(普通文件夹没有 thumbs/ → 现生成并缓存到用户目录) ──────────
def _thumb_cache_path(path: Path) -> Path:
stat = path.stat()
key = hashlib.sha1(f"{path}|{stat.st_mtime_ns}|{stat.st_size}".encode("utf-8")).hexdigest()
return settings_path().parent / "thumbs" / f"{key}.jpg"
def thumbnail_of(root: Path, rel: str, thumb_rel: str = "") -> Path:
"""优先用清单里的缩略图;没有就现场切 384px JPEG 并缓存(只读素材目录)。"""
if thumb_rel:
try:
return resolve_image(root, thumb_rel)
except GalleryError:
pass
path = resolve_image(root, rel)
cached = _thumb_cache_path(path)
if cached.is_file():
return cached
try:
from PIL import Image, ImageOps
cached.parent.mkdir(parents=True, exist_ok=True)
with Image.open(path) as opened:
image = ImageOps.exif_transpose(opened).convert("RGB")
image.thumbnail((THUMB_MAX, THUMB_MAX), Image.Resampling.LANCZOS)
image.save(cached, "JPEG", quality=86, optimize=True)
return cached
except Exception:
# 缩略图失败不影响预览:直接回原图
return path
# ── 节点取图(IMAGE 张量规格:[1,H,W,3] float32,值域 0~1) ────────────
def _ref_meta(ref) -> dict:
"""内部字段解析:{"path","name","disabled"} JSON 或纯相对路径字符串。"""
text = str(ref or "").strip()
if not text:
return {"path": "", "name": "", "disabled": False}
if text.startswith("{"):
try:
data = json.loads(text)
except ValueError:
return {"path": "", "name": "", "disabled": False}
if not isinstance(data, dict):
return {"path": "", "name": "", "disabled": False}
return {
"path": str(data.get("path") or "").strip(),
"name": str(data.get("name") or "").strip(),
"disabled": bool(data.get("disabled")),
}
return {"path": text, "name": "", "disabled": False}
def _ref_path_of(ref) -> str:
return _ref_meta(ref)["path"]
def load_reference(kind: str, ref, placeholder: int = 64):
"""读取一张参考图 → IMAGE 张量。
· 已勾选「禁用」:返回 ``ExecutionBlocker(None)`` —— 等同于 mute:
本输出**不产出任何数据**,凡是接在这条输出上的下游节点会被 ComfyUI 直接跳过(不执行、不报错);
· 未选择:输出 placeholder×placeholder 白色占位图(下游不断流);
· 选了图但文件丢了:抛错并给出中文提示。
"""
import numpy as np
import torch
from PIL import Image, ImageOps
label = KINDS.get(kind, kind)
meta = _ref_meta(ref)
if meta["disabled"]:
if ExecutionBlocker is not None:
print(f"[XB-生图预设] {label} 已禁用 → 该输出不做任何输出(下游节点将被跳过)")
return ExecutionBlocker(None)
print(f"[XB-生图预设] {label} 已禁用(当前 ComfyUI 不支持阻断执行)→ 输出占位图")
arr = np.full((placeholder, placeholder, 3), 1.0, dtype=np.float32)
return torch.from_numpy(arr).unsqueeze(0)
rel = meta["path"]
if not rel:
arr = np.full((placeholder, placeholder, 3), 1.0, dtype=np.float32)
print(f"[XB-生图预设] {label} 未选择 → 输出 {placeholder}x{placeholder} 白色占位图")
return torch.from_numpy(arr).unsqueeze(0)
try:
root = bound_root(kind)
path = resolve_image(root, rel)
with Image.open(path) as opened:
image = ImageOps.exif_transpose(opened).convert("RGB")
arr = np.asarray(image, dtype=np.float32) / 255.0
except (GalleryError, OSError, ValueError) as exc:
raise RuntimeError(f"[XB-生图预设] {label}读取失败:{exc}") from exc
print(f"[XB-生图预设] {label}:{path.name} → {arr.shape[1]}x{arr.shape[0]}")
return torch.from_numpy(arr).unsqueeze(0)
def fingerprint(kind: str, ref) -> str:
"""IS_CHANGED 用:文件被替换/改动、或「禁用」开关变化 → 让节点重跑。"""
meta = _ref_meta(ref)
if meta["disabled"]:
return "disabled"
rel = meta["path"]
if not rel:
return ""
try:
stat = resolve_image(bound_root(kind), rel).stat()
return f"{rel}:{stat.st_mtime_ns}:{stat.st_size}"
except (GalleryError, OSError):
return f"{rel}:missing"
# ── HTTP 端点 ─────────────────────────────────────────────────────────
def _register_routes():
try:
import server
from aiohttp import web
except Exception:
return
routes = server.PromptServer.instance.routes
def same_origin(request) -> bool:
origin = request.headers.get("Origin")
return not origin or urlsplit(origin).netloc == request.host
@routes.get("/xb_toolbox/gallery/settings")
async def gallery_get_settings(request):
return web.json_response({"settings": read_settings()}, headers={"Cache-Control": "no-store"})
@routes.post("/xb_toolbox/gallery/settings")
async def gallery_set_settings(request):
# 目录变更必须来自本 UI,不能由其它网站触发(防跨站改本地路径)
if not same_origin(request):
return web.json_response({"error": "图库绑定仅接受同站请求。"}, status=403)
if request.content_type != "application/json":
return web.json_response({"error": "请使用 JSON 提交图库设置。"}, status=415)
try:
body = await request.json()
except Exception:
return web.json_response({"error": "JSON 解析失败。"}, status=400)
if not isinstance(body, dict):
return web.json_response({"error": "请求须为对象。"}, status=400)
try:
data = save_settings(str(body.get("kind") or ""), body.get("directory") or "",
bool(body.get("accepted")))
invalidate()
return web.json_response({"settings": data})
except GalleryError as exc:
return web.json_response({"error": str(exc)}, status=400)
except OSError:
return web.json_response({"error": "无法保存设置,请检查 ComfyUI 用户目录的写入权限。"}, status=500)
@routes.get("/xb_toolbox/gallery/catalogue")
async def gallery_catalogue(request):
import asyncio
kind = request.rel_url.query.get("kind", "pose")
try:
cat = await asyncio.to_thread(catalogue_for, kind)
return web.json_response({"kind": kind, **cat}, headers={"Cache-Control": "no-cache"})
except GalleryError as exc:
return web.json_response({"error": str(exc)}, status=400)
@routes.get("/xb_toolbox/gallery/image")
async def gallery_image(request):
import asyncio
kind = request.rel_url.query.get("kind", "pose")
rel = request.rel_url.query.get("path", "")
thumb_rel = request.rel_url.query.get("thumb_path", "")
thumb = request.rel_url.query.get("thumb") == "1"
try:
def resolve():
root = bound_root(kind)
return thumbnail_of(root, rel, thumb_rel) if thumb else resolve_image(root, rel)
path = await asyncio.to_thread(resolve)
mime = MIME_BY_EXT.get(path.suffix.lower(), "application/octet-stream")
return web.FileResponse(path, headers={"Cache-Control": "no-cache",
"Content-Type": mime,
"X-Content-Type-Options": "nosniff"})
except GalleryError as exc:
return web.json_response({"error": str(exc)}, status=404)
print("[XB-BOX] 参考图库端点已注册:/xb_toolbox/gallery/{settings,catalogue,image}")
try:
_register_routes()
except Exception as _exc: # 路由重复注册 / 非 ComfyUI 环境 → 只影响前端读取
print(f"[XB-BOX] 参考图库端点未注册:{_exc}")