From 67144ea9d28d080396354545199c3a74663c23c8 Mon Sep 17 00:00:00 2001 From: wjluoxiao <331030818@qq.com> Date: Fri, 30 Oct 2026 15:44:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E9=A2=84=E8=AE=BE?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 21 +- js/xb_compat.js | 71 +++ js/xb_gallery_picker.js | 742 +++++++++++++++++++++++++++++++ js/xb_image_prompt_preset.js | 111 ++++- js/xb_image_prompt_preset_pro.js | 7 +- js/xb_image_scale.js | 11 +- locales/en/nodeDefs.json | 59 +++ locales/zh/nodeDefs.json | 74 +++ nodes_image_preset.py | 62 +++ nodes_image_scale.py | 50 +++ xb_gallery.py | 487 ++++++++++++++++++++ 11 files changed, 1683 insertions(+), 12 deletions(-) create mode 100644 js/xb_gallery_picker.js create mode 100644 nodes_image_preset.py create mode 100644 xb_gallery.py diff --git a/__init__.py b/__init__.py index b12c27d..8ba04c3 100644 --- a/__init__.py +++ b/__init__.py @@ -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 - 🖼️ 批量图像", diff --git a/js/xb_compat.js b/js/xb_compat.js index 79e4dcf..ba6385a 100644 --- a/js/xb_compat.js +++ b/js/xb_compat.js @@ -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) diff --git a/js/xb_gallery_picker.js b/js/xb_gallery_picker.js new file mode 100644 index 0000000..4e0b647 --- /dev/null +++ b/js/xb_gallery_picker.js @@ -0,0 +1,742 @@ +/** + * XB-BOX 参考图库选择器(姿态 / 穿搭) + * ============================================================ + * 由「XB-BOX - 🖼️ 生图预设」的「🎬 姿态参考图选择 / 👚 穿搭参考图选择」两个按钮打开。 + * + * · 首次打开必须先**手动绑定**目录(使用确认 + 绝对路径),绑定信息只存本机 + * /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)); + } +} diff --git a/js/xb_image_prompt_preset.js b/js/xb_image_prompt_preset.js index ebd1118..384ac2d 100644 --- a/js/xb_image_prompt_preset.js +++ b/js/xb_image_prompt_preset.js @@ -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); diff --git a/js/xb_image_prompt_preset_pro.js b/js/xb_image_prompt_preset_pro.js index 689b569..7113e87 100644 --- a/js/xb_image_prompt_preset_pro.js +++ b/js/xb_image_prompt_preset_pro.js @@ -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 / 上下限 diff --git a/js/xb_image_scale.js b/js/xb_image_scale.js index 2a4e839..429c24f 100644 --- a/js/xb_image_scale.js +++ b/js/xb_image_scale.js @@ -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); diff --git a/locales/en/nodeDefs.json b/locales/en/nodeDefs.json index 831e4f8..caf5c25 100644 --- a/locales/en/nodeDefs.json +++ b/locales/en/nodeDefs.json @@ -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.", diff --git a/locales/zh/nodeDefs.json b/locales/zh/nodeDefs.json index 58f16a5..8d0cb22 100644 --- a/locales/zh/nodeDefs.json +++ b/locales/zh/nodeDefs.json @@ -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": { diff --git a/nodes_image_preset.py b/nodes_image_preset.py new file mode 100644 index 0000000..67581bc --- /dev/null +++ b/nodes_image_preset.py @@ -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 - 🖼️ 生图提示词预设(图)"} diff --git a/nodes_image_scale.py b/nodes_image_scale.py index c7b9b31..95c9aa3 100644 --- a/nodes_image_scale.py +++ b/nodes_image_scale.py @@ -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) diff --git a/xb_gallery.py b/xb_gallery.py new file mode 100644 index 0000000..d13e33c --- /dev/null +++ b/xb_gallery.py @@ -0,0 +1,487 @@ +"""XB-BOX 参考图库内核(姿态 / 穿搭)—— 绑定目录、素材清单读取、图片端点、节点取图 +============================================================================== +· 「姿态图库」「穿搭图库」两套库**分开绑定**:各一个目录,各自带「使用确认」标记; +· 支持两种目录形态(同一套代码自动识别,用户绑什么就用什么): + ① 带素材清单的图库:目录下有 ``data/*.json``(数组,每项含 ``id`` 与 + ``原图`` / ``大图`` 字段)→ 读取清单得到 体系 / 分类 / 角度 / 标签 等筛选维度, + 前端做筛选 + 分组 + 视角条界面; + ② 普通图片文件夹:没有清单 → 递归扫描图片,前端按文件名多宫格展示; +· 绑定信息只落机器本地 ``/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}")