diff --git a/__init__.py b/__init__.py index 9346287..d4a28d6 100644 --- a/__init__.py +++ b/__init__.py @@ -70,6 +70,16 @@ def print_error(msg): def print_warning(msg): print(f"\033[93m{msg}\033[0m") +# 启动 banner 上的 JZL 像素字 +XB_LOGO = "\n".join([ + " ██╗███████╗██╗", + " ██║╚══███╔╝██║", + " ██║ ███╔╝ ██║", + "██ ██║ ███╔╝ ██║", + "╚█████╔╝███████╗███████╗", + " ╚════╝ ╚══════╝╚══════╝", +]) + NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} WEB_DIRECTORY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "js") @@ -94,6 +104,7 @@ try: from .nodes_digital_human import XB_DigitalHumanParams_Single, XB_DigitalHumanParams_Dual from .nodes_model_loader_gguf import XB_ModelLoaderV1_GGUF, XB_ModelLoaderV2_GGUF, XB_ModelLoaderV3_GGUF from .nodes_status_switch import XB_NodeStatusSwitch + 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 @@ -285,6 +296,9 @@ try: "XB_WanInfiniteTalk_RelayNode_AllInOne": XB_WanInfiniteTalk_RelayNode_AllInOne, "XB_LTX23_InfiniteStreamer": XB_LTX23_InfiniteStreamer, "XB_NodeStatusSwitch": XB_NodeStatusSwitch, + "XB_DisableGroups": XB_DisableGroups, + "XB_DisableNodes": XB_DisableNodes, + "XB_AnySwitch": XB_AnySwitch, "XB_FluxMultiImageEncode": XB_FluxMultiImageEncode, "XB_TextEncodeQwenImageEdit": XB_TextEncodeQwenImageEdit, "XB_TextEncodeQwenImageEditPlus": XB_TextEncodeQwenImageEditPlus, @@ -447,6 +461,9 @@ try: "XB_WanInfiniteTalk_RelayNode_AllInOne": "XB-BOX - 🆕 InfiniteTalk 无限接力点 (全能)", "XB_LTX23_InfiniteStreamer": "XB-BOX - 🔄 LTX2.3 数字人无限时长对口型", "XB_NodeStatusSwitch": "XB-BOX - ⚡ 节点状态开关", + "XB_DisableGroups": "XB-BOX - 🚫 禁用分组", + "XB_DisableNodes": "XB-BOX - 🚫 禁用节点", + "XB_AnySwitch": "XB-BOX - 🔀 切换任意", "XB_FluxMultiImageEncode": "XB-BOX - 🖼️ FLUX多图编码", "XB_TextEncodeQwenImageEdit": "XB-BOX - 📝 Qwen图像编辑编码", "XB_TextEncodeQwenImageEditPlus": "XB-BOX - 📝 Qwen图像编辑编码Plus", @@ -551,7 +568,8 @@ try: "XB_YuE2Transcription": "XB-BOX - 🎵 YuE2 转谱导入", }) - print_success("\n" + "="*50) + print_success("\n" + XB_LOGO) + print_success("="*50) print_success("🚀 [XB-BOX] XB_ToolBox Core Modules Loaded Successfully!") print_success("="*50 + "\n") diff --git a/cosyvoice3/__init__.py b/cosyvoice3/__init__.py index 793b175..c4c4fbc 100644 --- a/cosyvoice3/__init__.py +++ b/cosyvoice3/__init__.py @@ -51,8 +51,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { # ASCII art banner ascii_art = """ -⡎⠑ ⢀⡀ ⢀⣀ ⡀⢀ ⡇⢸ ⢀⡀ ⠄ ⢀⣀ ⢀⡀ ⢉⡹ -⠣⠔ ⠣⠜ ⠭⠕ ⣑⡺ ⠸⠃ ⠣⠜ ⠇ ⠣⠤ ⠣⠭ ⠤⠜ + """ print(f"\033[35m{ascii_art}\033[0m") print("XB CosyVoice3 Custom Nodes Loaded - Version 1.2.1") diff --git a/js/xb_fast_toggle.js b/js/xb_fast_toggle.js new file mode 100644 index 0000000..8a6bda3 --- /dev/null +++ b/js/xb_fast_toggle.js @@ -0,0 +1,1139 @@ +/** + * XB-BOX 快速开关三件套(参考 rgthree-comfy 的 Fast Muter / Fast Groups Muter / Any Switch) + * ===================================================================================== + * XB_DisableGroups(禁用分组)· XB_DisableNodes(禁用节点) + * —— 不接线:面板/右键菜单里勾选目标,点一行就能「静音 / 绕过 / 恢复」。 + * 状态直接读写画布上节点自己的 mode(随工作流保存,和其他插件互通)。 + * XB_AnySwitch(切换任意) + * —— 动态任意类型输入(默认 any_01 / any_02,连满自动追加);没有选择控件, + * 与 rgthree 一致:永远输出第一个非空的输入。 + * + * 两个「禁用」节点是**纯前端控制节点**(Python 端无输入无输出,不参与执行): + * 面板用 DOM widget 画(经典 + Nodes 2.0 都能点),选择结果存 node.properties。 + * Nodes 2.0 下容器高度用 bindNodes2NodeFill 跟随节点,列表在容器内滚动。 + * 分组 / 节点都可以是子工作流(子图)里的,跨图定位后直接改它们的 mode。 + */ + +import { app } from "../../scripts/app.js"; +import { bindNodes2NodeFill, isNodes2 } from "./xb_compat.js"; + +const TYPE_GROUPS = "XB_DisableGroups"; +const TYPE_NODES = "XB_DisableNodes"; +const TYPE_SWITCH = "XB_AnySwitch"; + +const MODE_ALWAYS = 0; // 正常 +const MODE_NEVER = 2; // 静音(mute) +const MODE_BYPASS = 4; // 绕过(bypass) + +const PANEL_MIN_H = 34; // 单行时的最小面板高度 +const ROW_GAP = 3; +const PANEL_PAD = 12; // 容器上下 padding 6px + 6px +const MIN_W = 260; + +const PROP_GROUPS = "xb_groups"; // ["分组标题", …] +const PROP_NODES = "xb_nodes"; // [{ id, title }, …] +const PROP_MODE = "xb_off_mode"; // "mute" | "bypass" +const PROP_RESTRICT = "xb_toggle_restrict"; // "multi"(多选可全关全开)| "one"(永远保持一个开启) + +/* ── 小工具 ───────────────────────────────────────────────────────────── */ +const graphOf = (node) => node?.graph || app.graph; + +/** 控件所在图的顶层根图(子图节点内部的 LGraph 有 rootGraph 指回去) */ +function rootGraphOf(node) { + const g = graphOf(node); + return (g && (g.rootGraph || g)) || app.graph; +} + +/** + * 画布树:根图 + 所有子图(含嵌套)。 + * sg = 子图节点的 id 路径(根图为空串),用于跨子图唯一定位分组 / 节点。 + */ +function graphTree(root) { + const out = []; + const walk = (g, sg, depth) => { + if (!g || depth > 8) return; + out.push({ graph: g, sg }); + for (const n of (g._nodes || [])) { + if (n && n.subgraph) walk(n.subgraph, sg ? `${sg}/${n.id}` : String(n.id), depth + 1); + } + }; + walk(root || app.graph, "", 0); + return out; +} + +/** 子图路径 → 可读名字,如 "New Subgraph › 子子图" */ +function sgTitleOf(root, sg) { + if (!sg) return ""; + const names = []; + let g = root || app.graph; + for (const part of String(sg).split("/")) { + const n = g ? (g._nodes || []).find((x) => String(x.id) === part) : null; + if (!n) { names.push(`#${part}`); break; } + names.push(n.title || n.type || `#${part}`); + g = n.subgraph; + } + return names.join(" › "); +} + +/** 分组条目 key:子图路径 + 标题 */ +const groupKey = (sg, title) => `${sg || ""}\u0001${title}`; +/** 节点条目 key:子图路径 + 节点 id */ +const nodeKey = (sg, id) => `${sg || ""}\u0001${id}`; + +function allGroupsExt(root) { + const out = []; + for (const { graph, sg } of graphTree(root)) { + for (const g of allGroups(graph)) out.push({ graph, sg, group: g, title: g.title }); + } + return out; +} + +function findGroupExt(root, sg, title) { + const all = allGroupsExt(root); + return all.find((x) => x.sg === (sg || "") && x.title === title) + || all.find((x) => x.title === title) // 子图改名 / 结构变化后的兜底 + || null; +} + +function allNodesExt(root) { + const out = []; + for (const { graph, sg } of graphTree(root)) { + for (const n of (graph?._nodes || [])) if (n) out.push({ graph, sg, node: n }); + } + return out; +} + +function findNodeExt(root, sg, id) { + const all = allNodesExt(root); + return all.find((x) => x.sg === (sg || "") && String(x.node.id) === String(id)) + || all.find((x) => String(x.node.id) === String(id)) + || null; +} + +function ensureProps(node) { + if (!node.properties || typeof node.properties !== "object") node.properties = {}; + return node.properties; +} + +function markDirty(graph) { + try { graph?.setDirtyCanvas?.(true, true); } catch (_) { /* ignore */ } + try { app.canvas?.setDirty?.(true, true); } catch (_) { /* ignore */ } +} + +function mkBtn(text, tip, onClick, css) { + const b = document.createElement("button"); + b.type = "button"; + b.textContent = text; + if (tip) b.title = tip; + b.style.cssText = "font-size:11px;line-height:1.2;height:22px;padding:0 8px;border-radius:5px;" + + "border:1px solid #555;background:#2b2b2b;color:#ddd;cursor:pointer;white-space:nowrap;font-family:inherit;" + + (css || ""); + b.addEventListener("pointerdown", (e) => e.stopPropagation()); + b.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); onClick?.(e); }); + b.addEventListener("mouseenter", () => { b.style.background = "#3a3a3a"; }); + b.addEventListener("mouseleave", () => { b.style.background = "#2b2b2b"; }); + return b; +} + +function allGroups(graph) { + const list = graph?._groups || graph?.groups || []; + return Array.from(list).filter((g) => g && typeof g.title === "string"); +} + +/** 分组内的节点(优先用 LiteGraph 的 _children,回退到包围盒命中) */ +function groupNodes(group) { + if (!group) return []; + const kids = Array.isArray(group._children) ? group._children : null; + if (kids && kids.length) return kids.filter((c) => c && c.id != null && c.type); + const graph = group.graph || app.graph; + const x = group.pos?.[0] ?? 0; + const y = group.pos?.[1] ?? 0; + const w = group.size?.[0] ?? 0; + const h = group.size?.[1] ?? 0; + if (!(w > 0 && h > 0)) return []; + return (graph?._nodes || []).filter((n) => { + const cx = (n.pos?.[0] || 0) + (n.size?.[0] || 0) / 2; + const cy = (n.pos?.[1] || 0) + (n.size?.[1] || 0) / 2; + return cx >= x && cx <= x + w && cy >= y && cy <= y + h; + }); +} + + +/** 节点属于哪个分组(没有则空串) */ +function groupTitleOf(graph, target) { + for (const g of allGroups(graph)) { + if (groupNodes(g).some((n) => n === target || n.id === target.id)) return g.title; + } + return ""; +} + + + +function setModes(nodes, mode) { + let n = 0; + const graphs = new Set(); + for (const node of nodes) { + try { + node.mode = mode; + n++; + if (node.graph) graphs.add(node.graph); + } catch (_) { /* ignore */ } + } + // 子图里的节点只会在自己的图上重绘,所以要逐个标脏 + for (const g of graphs) markDirty(g); + markDirty(app.graph); + return n; +} + +/* ── 选择对话框(分组 / 节点)──────────────────────────────────────────── */ +function openPicker(node, kind) { + const root = rootGraphOf(node); + const props = ensureProps(node); + const isGroups = kind === "groups"; + + // 条目:{ key, label, sub, group, sg, title|nodeId } + const entries = []; + if (isGroups) { + for (const { graph, sg, group } of allGroupsExt(root)) { + const sgName = sgTitleOf(root, sg); + entries.push({ + key: groupKey(sg, group.title), + label: group.title || "(未命名分组)", + sub: `${groupNodes(group).length} 个节点${sgName ? ` · 子图 ${sgName}` : ""}`, + group: sgName ? `子图 ${sgName}` : "", + sg, + title: group.title, + }); + } + } else { + for (const { graph, sg, node: n } of allNodesExt(root)) { + if (!n || n === node) continue; + if (n.type === TYPE_GROUPS || n.type === TYPE_NODES) continue; // 控制节点本身不列 + const sgName = sgTitleOf(root, sg); + const innerGrp = groupTitleOf(graph, n); + const isSg = !!n.subgraph; + const groupParts = [sgName ? `子图 ${sgName}` : "", innerGrp].filter(Boolean); + entries.push({ + key: nodeKey(sg, n.id), + label: n.title || n.type || `#${n.id}`, + sub: [isSg ? "子工作流" : String(n.type || ""), sgName ? `子图 ${sgName}` : ""].filter(Boolean).join(" · "), + group: groupParts.join(" / ") || "(未分组)", + sg, + nodeId: n.id, + }); + } + } + + const propGroups = Array.isArray(props[PROP_GROUPS]) ? props[PROP_GROUPS] : []; + const propNodes = Array.isArray(props[PROP_NODES]) ? props[PROP_NODES] : []; + const savedGroupKeys = propGroups.map((x) => (typeof x === "string" + ? groupKey("", x) + : groupKey(x?.sg || "", x?.title ?? ""))); + const savedNodeKeys = propNodes.map((x) => nodeKey(x?.sg || "", x?.id)); + + const selected = new Set(isGroups ? savedGroupKeys : savedNodeKeys); + + // 已选项按上次保存的顺序排在前面,其余保持画布顺序(排序是稳定的) + const savedOrder = isGroups ? savedGroupKeys : savedNodeKeys; + if (savedOrder.length) { + const rank = new Map(savedOrder.map((k, i) => [k, i])); + entries.sort((a, b) => (rank.has(a.key) ? rank.get(a.key) : Number.MAX_SAFE_INTEGER) + - (rank.has(b.key) ? rank.get(b.key) : Number.MAX_SAFE_INTEGER)); + } + + const overlay = document.createElement("div"); + overlay.style.cssText = "position:fixed;inset:0;z-index:10300;background:rgba(0,0,0,.72);" + + "display:flex;overflow:auto;"; + const panel = document.createElement("div"); + panel.style.cssText = "width:660px;max-width:94vw;max-height:86vh;margin:auto;flex:0 0 auto;display:flex;flex-direction:column;" + + "background:#1c1c1e;border:1px solid #333;border-radius:8px;color:#ddd;font:13px/1.55 system-ui,sans-serif;" + + "box-shadow:0 20px 40px rgba(0,0,0,.6);overflow:hidden;"; + + const head = document.createElement("div"); + head.style.cssText = "padding:12px 16px;background:#232326;border-bottom:1px solid #3a3a3a;display:flex;align-items:center;gap:10px;"; + const hTitle = document.createElement("div"); + hTitle.textContent = isGroups ? "⚙️ 选择要控制的分组" : "⚙️ 选择要控制的节点"; + hTitle.style.cssText = "flex:1 1 auto;font-size:15px;font-weight:700;color:#eee;"; + const hClose = mkBtn("✕", "关闭", () => close()); + head.append(hTitle, hClose); + + const bar = document.createElement("div"); + bar.style.cssText = "padding:10px 16px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;border-bottom:1px solid #2c2c2e;"; + const search = document.createElement("input"); + search.type = "text"; + search.placeholder = isGroups ? "搜索分组…" : "搜索节点名 / 类型…"; + search.style.cssText = "flex:1 1 220px;min-width:160px;height:26px;background:#1e1e1e;color:#ddd;" + + "border:1px solid #444;border-radius:5px;padding:0 8px;font-size:12px;font-family:inherit;"; + const countEl = document.createElement("span"); + countEl.style.cssText = "color:#8a8a8a;font-size:12px;font-variant-numeric:tabular-nums;"; + const hintEl = document.createElement("span"); + hintEl.textContent = "⠿ 拖拽可调整顺序"; + hintEl.style.cssText = "color:#6f7883;font-size:11px;"; + bar.append(search, hintEl, countEl); + + const list = document.createElement("div"); + list.style.cssText = "flex:1 1 auto;min-height:0;overflow-y:auto;padding:8px 12px;display:flex;flex-direction:column;gap:3px;"; + + const foot = document.createElement("div"); + foot.style.cssText = "padding:10px 16px;border-top:1px solid #3a3a3a;display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap;background:#18181a;"; + + const close = () => { try { overlay.remove(); } catch (_) { /* ignore */ } }; + + // 拖拽排序:被拖的行"浮起"跟随指针,落点用虚线占位框提示 + let dragKey = null, dragStartY = 0, dragging = false; + const moveEntry = (key, targetKey, after) => { + const from = entries.findIndex((x) => x.key === key); + if (from < 0 || key === targetKey) return; + const [it] = entries.splice(from, 1); + let to = entries.findIndex((x) => x.key === targetKey); + if (to < 0) { entries.push(it); return; } + if (after) to += 1; + entries.splice(to, 0, it); + }; + const startDrag = (key, ev) => { + const src = list.querySelector(`[data-xb-row="${CSS.escape(key)}"]`); + if (!src) return; + dragKey = key; + dragStartY = ev.clientY; + dragging = false; + const srcRect = src.getBoundingClientRect(); + const grabY = ev.clientY - srcRect.top; + let ghost = null, ph = null, cursorStyle = null; + + const ensureArtifacts = () => { + if (ph) return; + // 面板高度锁死:否则插入占位框会让面板变高、margin:auto 重新居中, + // 整个列表会整体上移(实测 17px),落点判断就会偏。 + panel.style.height = `${Math.round(panel.getBoundingClientRect().height)}px`; + // 源行留在原位但淡出 + 虚线框,表示"它被拿起来了" + src.style.opacity = "0.28"; + src.style.borderStyle = "dashed"; + src.style.borderColor = "#5c5c5c"; + // 浮起的幽灵行 + ghost = src.cloneNode(true); + ghost.removeAttribute("data-xb-row"); + ghost.setAttribute("data-xb-ghost", "1"); + ghost.style.cssText += `;position:fixed;left:${srcRect.left}px;top:${srcRect.top}px;width:${srcRect.width}px;` + + "box-sizing:border-box;z-index:10360;pointer-events:none;margin:0;" + + "background:#33343a;border:1px solid #f59e0b;border-radius:6px;" + + "box-shadow:0 14px 30px rgba(0,0,0,.65),0 0 0 1px rgba(245,158,11,.25);" + + "transform:scale(1.02);opacity:.98;"; + document.body.append(ghost); + // 落点占位(虚线槽) + ph = document.createElement("div"); + ph.setAttribute("data-xb-ph", "1"); + ph.style.cssText = `flex:0 0 auto;height:${Math.round(srcRect.height)}px;box-sizing:border-box;` + + "border-radius:5px;border:2px dashed #f59e0b;background:rgba(245,158,11,.10);pointer-events:none;"; + cursorStyle = document.createElement("style"); + cursorStyle.textContent = "*{cursor:grabbing !important;}"; + document.head.append(cursorStyle); + }; + const placePh = (e) => { + // 用"第一个中线在指针下方的行"决定落点,不受占位框插入导致的位移影响 + const rows = Array.from(list.querySelectorAll("[data-xb-row]")).filter((r) => r !== src); + for (const row of rows) { + const rect = row.getBoundingClientRect(); + if (e.clientY < rect.top + rect.height / 2) { row.before(ph); return; } + } + if (rows.length) rows[rows.length - 1].after(ph); + }; + const cleanup = () => { + ghost?.remove(); + ph?.remove(); + cursorStyle?.remove(); + panel.style.height = ""; + src.style.opacity = "1"; + src.style.borderStyle = "solid"; + src.style.borderColor = "#333"; + }; + const onMove = (e) => { + if (!dragKey) return; + if (!dragging && Math.abs(e.clientY - dragStartY) < 3) return; + dragging = true; + ensureArtifacts(); + ghost.style.top = `${Math.round(e.clientY - grabY)}px`; + // 靠近列表上下边缘时自动滚动 + const lr = list.getBoundingClientRect(); + if (e.clientY < lr.top + 22) list.scrollTop -= 12; + else if (e.clientY > lr.bottom - 22) list.scrollTop += 12; + placePh(e); + }; + const finish = (commit) => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + const from = dragKey; + dragKey = null; + dragging = false; + // 用虚线槽的位置推算插入点:优先"插到下一行之前",否则"插到上一行之后" + let targetKey = null, after = false; + if (commit && ph) { + let next = ph.nextElementSibling; + while (next && !next.hasAttribute("data-xb-row")) next = next.nextElementSibling; + if (next) { + targetKey = next.getAttribute("data-xb-row"); + } else { + let prev = ph.previousElementSibling; + while (prev && !prev.hasAttribute("data-xb-row")) prev = prev.previousElementSibling; + if (prev) { targetKey = prev.getAttribute("data-xb-row"); after = true; } + } + } + cleanup(); + if (commit && from && targetKey && targetKey !== from) { + moveEntry(from, targetKey, after); + render(); + } + }; + const onUp = () => finish(true); + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + }; + + const render = () => { + const q = search.value.trim().toLowerCase(); + list.replaceChildren(); + let shown = 0; + let lastGroup = null; + for (const it of entries) { + if (!isGroups && it.group !== lastGroup) { + lastGroup = it.group; + const gh = document.createElement("div"); + gh.textContent = `— ${it.group} —`; + gh.style.cssText = "margin:6px 0 2px;color:#7f8c99;font-size:11px;"; + list.append(gh); + } + if (q && !(`${it.label} ${it.sub} ${it.group}`.toLowerCase().includes(q))) continue; + shown++; + const row = document.createElement("label"); + row.style.cssText = "display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:5px;cursor:pointer;" + + "background:#242424;border:1px solid #333;"; + row.dataset.xbRow = it.key; + const grip = document.createElement("span"); + grip.textContent = "⠿"; + grip.title = "拖拽调整顺序"; + grip.style.cssText = "flex:0 0 auto;color:#6b7480;font-size:14px;line-height:1;cursor:grab;user-select:none;" + + "padding:2px 4px;margin:-2px 0;touch-action:none;"; + grip.addEventListener("pointerdown", (e) => { + e.preventDefault(); + e.stopPropagation(); + try { grip.setPointerCapture?.(e.pointerId); } catch (_) { /* ignore */ } + startDrag(it.key, e); + }); + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.checked = selected.has(it.key); + cb.style.cssText = "width:15px;height:15px;accent-color:#f59e0b;cursor:pointer;flex:0 0 auto;"; + cb.addEventListener("change", () => { + if (cb.checked) selected.add(it.key); else selected.delete(it.key); + syncCount(); + }); + const name = document.createElement("span"); + name.textContent = it.label; + name.style.cssText = "flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#e6e6e6;"; + const sub = document.createElement("span"); + sub.textContent = it.sub || ""; + sub.style.cssText = "flex:0 0 auto;color:#7d8792;font-size:11px;"; + row.append(grip, cb, name, sub); + list.append(row); + } + if (!shown) { + const empty = document.createElement("div"); + empty.textContent = isGroups ? "当前画布上没有分组" : "没有匹配的节点"; + empty.style.cssText = "padding:16px;color:#8a8a8a;text-align:center;"; + list.append(empty); + } + syncCount(); + }; + + const syncCount = () => { countEl.textContent = `已选 ${selected.size} / ${entries.length}`; }; + + const selectAll = (on) => { + const q = search.value.trim().toLowerCase(); + for (const it of entries) { + if (q && !(`${it.label} ${it.sub} ${it.group}`.toLowerCase().includes(q))) continue; + if (on) selected.add(it.key); else selected.delete(it.key); + } + render(); + }; + + const confirm = () => { + // 顺序 = 列表里的顺序(可拖拽调整),不是勾选先后 + const chosen = entries.filter((it) => selected.has(it.key)); + if (isGroups) { + // 根图分组仍写成字符串(兼容旧工作流),子图分组写成 { sg, title } + props[PROP_GROUPS] = chosen.map((it) => (it.sg ? { sg: it.sg, title: it.title } : it.title)); + } else { + props[PROP_NODES] = chosen.map((it) => { + const hit = findNodeExt(root, it.sg, it.nodeId); + const item = { id: it.nodeId, title: hit?.node?.title || "" }; + if (it.sg) item.sg = it.sg; + return item; + }); + } + markDirty(root); + refreshRows(node); + close(); + }; + + const cancel = mkBtn("取消", "放弃修改", close); + const ok = mkBtn("确定", "保存选择", confirm, "border-color:#3f86d0;background:#22456b;color:#eaf4ff;font-weight:600;"); + foot.append( + mkBtn("全选", "选中当前筛选结果里的全部条目", () => selectAll(true)), + mkBtn("清空", "取消全部选择", () => selectAll(false)), + cancel, ok, + ); + + search.addEventListener("input", render); + overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) close(); }); + panel.append(head, bar, list, foot); + overlay.append(panel); + document.body.append(overlay); + render(); + setTimeout(() => { try { search.focus(); } catch (_) { /* ignore */ } }, 30); +} + +/* ── 面板(DOM widget)────────────────────────────────────────────────── */ +function buildPanel(node, kind) { + const isGroups = kind === "groups"; + const container = document.createElement("div"); + container.style.cssText = "width:100%;height:100%;box-sizing:border-box;display:flex;flex-direction:column;" + + `gap:4px;padding:${PANEL_PAD / 2}px;overflow:visible;background:#2b2b2b;border-radius:6px;`; + + // 节点表面只留「开关列表」——选择 / 禁用方式 / 切换模式 全部收进右键菜单 + const list = document.createElement("div"); + list.style.cssText = `flex:1 1 auto;min-height:0;overflow:visible;display:flex;flex-direction:column;gap:${ROW_GAP}px;`; + container.append(list); + + node._xbPanelList = list; + node._xbPanel = container; + + // DOM widget:经典模式按 computeSize 给高度,Nodes 2.0 按 computeLayoutSize 给最小高度 + const widget = node.addDOMWidget?.("xb_toggle_panel", "XB_TOGGLE_PANEL", container, { + serialize: false, + hideOnZoom: false, + }); + if (widget) { + try { delete widget.computeSize; } catch (_) { widget.computeSize = undefined; } + widget.options = widget.options || {}; + widget.options.serialize = false; + widget.options.getMinHeight = () => PANEL_MIN_H; + widget.options.getHeight = () => "100%"; + widget.computeSize = () => [Math.max(MIN_W, Math.round(node.size?.[0] || MIN_W)), panelHeightOf(node)]; + widget.computeLayoutSize = () => ({ minHeight: panelHeightOf(node), maxHeight: undefined, minWidth: MIN_W }); + } + node.min_width = MIN_W; + node.minWidth = MIN_W; + try { bindNodes2NodeFill(node, container, { min: PANEL_MIN_H }); } catch (_) { /* ignore */ } + // 交给 applyPanelHeight 决定高度(不要在挂载瞬间无脑 setSize,否则刷新页面会先跳一下) + setTimeout(() => { applyPanelHeight(node); }, 0); +} + +function modeOffOf(node) { + return ensureProps(node)[PROP_MODE] === "bypass" ? MODE_BYPASS : MODE_NEVER; +} + +/** 切换模式:multi = 多选(可全关全开)/one = 永远保持一个开启 */ +function restrictOf(node) { + return ensureProps(node)[PROP_RESTRICT] === "one" ? "one" : "multi"; +} + +/** 当前行数据:[{ label, nodes, missing }](分组/节点可能位于子工作流内部) */ +function rowsOf(node) { + const root = rootGraphOf(node); + const props = ensureProps(node); + const out = []; + if (node.type === TYPE_GROUPS) { + const items = Array.isArray(props[PROP_GROUPS]) ? props[PROP_GROUPS] : []; + for (const it of items) { + const title = typeof it === "string" ? it : (it?.title ?? ""); + const sg = typeof it === "string" ? "" : (it?.sg || ""); + const hit = findGroupExt(root, sg, title); + const sgName = sgTitleOf(root, sg); + out.push({ + label: title || "(未命名分组)", + tip: sgName ? `子图 ${sgName}` : "", // 子图信息只放 tooltip,不占显示名 + nodes: hit ? groupNodes(hit.group) : [], + missing: !hit, + }); + } + } else { + const items = Array.isArray(props[PROP_NODES]) ? props[PROP_NODES] : []; + for (const it of items) { + const sg = it?.sg || ""; + const hit = findNodeExt(root, sg, it?.id); + // 兜底:子图被解开 / 重新打包导致 id 变了,就按标题在整棵树里找 + const n = hit?.node + || (it?.title ? (allNodesExt(root).find((x) => x.node.title === it.title)?.node || null) : null); + const sgName = sgTitleOf(root, sg); + const title = it?.title || n?.title || `#${it?.id ?? "?"}`; + out.push({ + label: title, // 只显示节点名,不加子图前缀 + tip: sgName ? `子图 ${sgName}` : "", + nodes: n ? [n] : [], + missing: !n, + }); + } + } + return out; +} + +/** 静音(2)与绕过(4)都算关闭,与当前禁用方式无关 */ +function isOffMode(mode) { + return mode === MODE_NEVER || mode === MODE_BYPASS; +} + +/** 一行是否处于「开启」状态(读画布上节点的真实 mode) */ +function rowEnabled(row) { + return !!row.nodes.length && row.nodes.some((n) => !isOffMode(n.mode)); +} + +/** 点一行:按切换模式(多选 / 永远保持一个开启)改状态 */ +function toggleRow(node, index) { + const off = modeOffOf(node); + const one = restrictOf(node) === "one"; + const rows = rowsOf(node); + const row = rows[index]; + if (!row || !row.nodes.length) return true; + const graph = graphOf(node); + if (rowEnabled(row)) { + // 永远保持一个开启 → 不许把最后一个开启的行关掉 + if (one && !rows.some((r, i) => i !== index && rowEnabled(r))) return false; + setModes(row.nodes, off); + } else { + if (one) rows.forEach((r, i) => { if (i !== index) setModes(r.nodes, off); }); + setModes(row.nodes, MODE_ALWAYS); + } + return true; +} + +/** 开关外观(纯 CSS 的小滑块) */ +function buildSwitch(enabled, disabled) { + const box = document.createElement("span"); + box.style.cssText = "flex:0 0 auto;position:relative;width:34px;height:18px;border-radius:9px;" + + `background:${disabled ? "#3a3a3a" : enabled ? "#3f86d0" : "#4a4a4a"};` + + "transition:background .15s;"; + const knob = document.createElement("span"); + knob.style.cssText = "position:absolute;top:2px;width:14px;height:14px;border-radius:7px;background:#fff;" + + `left:${enabled ? "18px" : "2px"};transition:left .15s;`; + box.append(knob); + return box; +} + +/** 已经被删掉的目标:连续两轮刷新都找不到 → 从选择里移除(选项跟着消失) */ +function pruneMissingTargets(node) { + // 载入 / 打包子图等过渡期不做清理,避免把还能找回的选项误删 + if (performance.now() - (node.__xbMountedAt || 0) < 3000) return false; + const props = ensureProps(node); + const key = node.type === TYPE_GROUPS ? PROP_GROUPS : PROP_NODES; + const arr = Array.isArray(props[key]) ? props[key] : null; + if (!arr || !arr.length) return false; + const rows = rowsOf(node); + const seen = node.__xbMissing || (node.__xbMissing = Object.create(null)); + const kept = []; + let changed = false; + let anyMissing = false; + rows.forEach((row, i) => { + const src = arr[i]; + const id = `${src?.sg || ""}\u0001${typeof src === "string" ? src : (src?.title ?? src?.id ?? i)}`; + if (!row.missing) { delete seen[id]; kept.push(src); return; } + anyMissing = true; + seen[id] = (seen[id] || 0) + 1; + if (seen[id] >= 2) { changed = true; return; } // 第二次仍找不到 → 真的被删了 + kept.push(src); + }); + if (!changed) { + // 只是第一次没找到(可能是打包 / 撤销的中间状态)→ 稍后再确认一次 + if (anyMissing && !node.__xbPruneTimer) { + node.__xbPruneTimer = setTimeout(() => { node.__xbPruneTimer = 0; refreshRows(node); }, 450); + } + return false; + } + props[key] = kept; + markDirty(node.graph); + return true; +} + +/** 重画面板列表(读的是画布上节点的真实 mode;找不到的目标直接不显示) */ +function refreshRows(node) { + const list = node._xbPanelList; + if (!list) return; + pruneMissingTargets(node); + const all = rowsOf(node); + // 目标已被删除的行不显示(连同它的索引一起记下来,点击时仍按原始顺序定位) + const rows = all.map((row, i) => ({ row, i })).filter((x) => !x.row.missing); + const rowChanged = node.__xbRowCount !== rows.length; + node.__xbRowCount = rows.length; + list.replaceChildren(); + + if (!rows.length) { + const tip = document.createElement("div"); + tip.textContent = (node.type === TYPE_GROUPS + ? "还没有选择分组 —— " + : "还没有选择节点 —— ") + + "右击本节点 →「" + (node.type === TYPE_GROUPS ? "选择分组" : "选择节点") + "…」勾选。"; + tip.style.cssText = "padding:10px;color:#8a8a8a;font-size:11px;line-height:1.6;"; + list.append(tip); + applyPanelHeight(node, true); + return; + } + + for (const { row, i: idx } of rows) { + const enabled = rowEnabled(row); + const partial = enabled && row.nodes.some((n) => isOffMode(n.mode)); + + const el = document.createElement("div"); + el.style.cssText = "display:flex;align-items:center;gap:8px;padding:3px 6px;border-radius:5px;" + + `background:${enabled ? "#22456b" : "#1f1f1f"};` + + `border:1px solid ${enabled ? "#3f86d0" : "#3a3a3a"};` + + "cursor:pointer;"; + + const name = document.createElement("span"); + name.textContent = row.label + (partial ? " ◐" : ""); + name.style.cssText = "flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" + + `font-size:12px;color:${enabled ? "#eaf4ff" : "#c8c8c8"};`; + name.title = [row.tip, row.nodes.length ? `${row.nodes.length} 个节点` : ""].filter(Boolean).join(" · "); + el.append(name, buildSwitch(enabled, false)); + + el.addEventListener("pointerdown", (e) => e.stopPropagation()); + el.addEventListener("click", (e) => { + e.preventDefault(); e.stopPropagation(); + const ok = toggleRow(node, idx); + refreshRows(node); + if (!ok) { + // 永远保持一个开启:拒绝全关 → 闪一下提示 + el.style.borderColor = "#e05555"; + setTimeout(() => { try { el.style.borderColor = enabled ? "#3f86d0" : "#3a3a3a"; } catch (_) { /* ignore */ } }, 450); + } + }); + el.addEventListener("mouseenter", () => { el.style.filter = "brightness(1.18)"; }); + el.addEventListener("mouseleave", () => { el.style.filter = ""; }); + list.append(el); + } + applyPanelHeight(node, rowChanged); +} + +/* ── 右键菜单(节点表面不再显示任何设置,全部收在这里)───────────────── */ +function panelContentHeight(node) { + const list = node._xbPanelList; + if (!list) return 0; + const kids = Array.from(list.children); + if (!kids.length) return 0; + let h = 0; + for (const c of kids) h += c.offsetHeight || 0; + return h + Math.max(0, kids.length - 1) * ROW_GAP; +} + +function panelHeightOf(node) { + return node.__xbPanelH || PANEL_MIN_H; +} + +/** 内容区之外的"chrome"(= 容器顶部偏移 + 底部留白 - 标题栏)——Nodes 2.0 的 node.size 不含标题栏 */ +function chromeOf(node) { + const box = node._xbPanel; + const root = box?.closest?.(".lg-node"); + if (!box || !root) return 0; + let y = 0; + let cur = box; + while (cur && cur !== root) { + y += cur.offsetTop || 0; + cur = cur.offsetParent; + } + if (cur !== root) return 0; + let header = 0; + try { + const hEl = root.querySelector(".lg-node-header"); + header = hEl ? hEl.offsetHeight : 0; + } catch (_) { header = 0; } + return Math.max(0, y - header) + 12; +} + +/** 节点高度贴着开关数量走(不留空白,也不截断)——行数 / 行高变了都会重算 */ +function applyPanelHeight(node, force) { + const box = node._xbPanel; + if (!box) return; + if (node.__xbFitting) return; // 防止 setSize 引发的重排再触发自己 + const content = panelContentHeight(node); + if (!content) return; // 还没渲染到 DOM,下次刷新再算 + const h = Math.max(PANEL_MIN_H, Math.round(content + PANEL_PAD)); + node.__xbPanelH = h; + const want = Math.max(MIN_W, Math.round(node.size?.[0] || MIN_W)); + const nodeH = Math.round(node.size?.[1] || 0); + // ⚠️ 刷新页面 / 载入工作流时不要重排高度: + // ① 字体还没就绪,量到的行高偏小(25 vs 32); + // ② 存档里本来就有正确高度。 + // 所以刚挂载的一段窗口内只记录面板高度,不动 setSize。 + if (performance.now() < (node.__xbSkipFitUntil || 0)) return; + // 收缩需连续两次量到同一个更小值才采纳(变高立即生效),避免字体/布局中间帧造成抖动; + // 但差异很大时(老的存档高度偏差)直接改,不然会在载入后一秒才"跳"一下。 + const shrinkNow = (target) => target < nodeH - 2; + const allowShrink = (target) => { + if (force) return true; + if (Math.abs(nodeH - target) > 12) return true; + if (node.__xbShrinkCand === target) { node.__xbShrinkCand = null; return true; } + node.__xbShrinkCand = target; + return false; + }; + const fit = (target) => { + node.__xbFitting = true; + try { + node.setSize?.([want, target]); + markDirty(node.graph); + // 经典模式下 DOM widget 是绝对定位的浮层,尺寸变化后要整幅重绘,否则画布上会残留旧边框 + if (!isNodes2()) app.canvas?.draw?.(true, true); + } finally { + setTimeout(() => { node.__xbFitting = false; }, 80); + } + }; + // 载入的节点高度只做很小幅度的容差(老的存档高度可能略有偏差,但不该为了几像素去重排) + const maybeFit = (target, tol) => { + const diff = Math.abs(nodeH - target); + if (diff <= tol) { node.__xbFitted = true; return; } + // 挂载后的第一次校正直接生效(一次性把旧存档高度对齐),之后才走防抖逻辑 + if (!node.__xbFitted) { + node.__xbFitted = true; + fit(target); + return; + } + if (node.__xbFromWorkflow && !force && diff <= 3) return; + if (shrinkNow(target) && !allowShrink(target)) return; + fit(target); + }; + + // 经典模式:DOM widget 的高度由 LiteGraph 的 computeSize 决定,节点高度也交给 computeSize + // (这里不能用 Nodes 2.0 那套 nodeH - boxH 反推:经典模式容器高度不随 setSize 变,会一路膨胀) + if (!isNodes2()) { + const target = Math.round(node.computeSize?.()[1] || h + 40); + maybeFit(target, 4); + // 画布里 DOM widget 的高度是渲染时按旧尺寸铺的,直接钉住它,避免内容溢出到节点外 + const wrap = box.parentElement; + if (wrap && wrap.classList?.contains("dom-widget") && wrap.style.height !== `${h}px`) wrap.style.height = `${h}px`; + if (box.style.height !== `${h}px`) box.style.height = `${h}px`; + return; + } + + // 容器在节点内容区里的位置由布局决定(offsetTop 累加,与缩放无关) + // ⚠️ 不能用 nodeH - boxH 反推:容器还没被 bindNodes2NodeFill 填满时量出来会偏大, + // 之后 chrome + h 就会永远多出这几像素(实测 45 vs 38 ⇒ 节点永远高 7px)。 + const chrome = chromeOf(node) || node.__xbChrome || 0; + node.__xbChrome = chrome; + if (!chrome) return; + maybeFit(chrome + h, 6); // 几像素的差异不去动,避免载入时无意义的高度跳动 +} + +function addMenu(node, options) { + const isGroups = node.type === TYPE_GROUPS; + const graph = graphOf(node); + const off = modeOffOf(node); + const one = restrictOf(node) === "one"; + + const setAll = (mode) => { + const rows = rowsOf(node); + rows.forEach((r) => setModes(r.nodes, mode)); + refreshRows(node); + }; + + const setRestrict = (value) => { + ensureProps(node)[PROP_RESTRICT] = value; + if (value === "one") { + // 只保留第一个开启的行(没有开启的就打开第一项) + const rows = rowsOf(node); + const keep = rows.findIndex((r) => rowEnabled(r)); + rows.forEach((r, i) => { + if (i === Math.max(0, keep)) setModes(r.nodes, MODE_ALWAYS); + else setModes(r.nodes, off); + }); + } + refreshRows(node); + }; + + const setOffMode = (value) => { + const next = value === "bypass" ? MODE_BYPASS : MODE_NEVER; + ensureProps(node)[PROP_MODE] = value; + if (next !== off) { + // 已禁用的行跟随新方式,避免切换后误判为「开启」 + for (const r of rowsOf(node)) { + if (r.nodes.length && r.nodes.every((n) => n.mode === off)) setModes(r.nodes, next); + } + } + refreshRows(node); + }; + + options.unshift( + { + content: isGroups ? "⚙️ 选择分组…" : "⚙️ 选择节点…", + callback: () => openPicker(node, isGroups ? "groups" : "nodes"), + }, + { + content: `${one ? "" : "✓ "}🔀 多选:可全关 / 全开`, + callback: () => setRestrict("multi"), + }, + { + content: `${one ? "✓ " : ""}🔀 单选:永远保持一个开启`, + callback: () => setRestrict("one"), + }, + { + content: `${off === MODE_NEVER ? "✓ " : ""}⛔ 禁用方式:静音(mute)`, + callback: () => setOffMode("mute"), + }, + { + content: `${off === MODE_BYPASS ? "✓ " : ""}⛔ 禁用方式:绕过(bypass)`, + callback: () => setOffMode("bypass"), + }, + { + content: "✓ 全部启用", + callback: () => setAll(MODE_ALWAYS), + }, + { + content: "✗ 全部禁用", + callback: () => { + const rows = rowsOf(node); + // 永远保持一个开启:全关时保留第一项 + if (one && rows.length) { + rows.forEach((r, i) => setModes(r.nodes, i === 0 ? MODE_ALWAYS : off)); + refreshRows(node); + return; + } + setAll(off); + }, + }, + { + content: "⇄ 全部反转", + callback: () => { + const rows = rowsOf(node); + if (one) { + // 永远保持一个开启:反转后若全关,则打开第一项 + rows.forEach((r) => { + const on = rowEnabled(r); + setModes(r.nodes, on ? off : MODE_ALWAYS); + }); + if (rows.length && !rows.some((r) => rowEnabled(r))) { + setModes(rows[0].nodes, MODE_ALWAYS); + } + refreshRows(node); + return; + } + rows.forEach((r) => { + const on = rowEnabled(r); + setModes(r.nodes, on ? off : MODE_ALWAYS); + }); + refreshRows(node); + }, + }, + { + content: "🔄 刷新列表", + callback: () => refreshRows(node), + }, + { + content: "🗑 清空选择", + callback: () => { + const props = ensureProps(node); + if (isGroups) props[PROP_GROUPS] = []; else props[PROP_NODES] = []; + refreshRows(node); + }, + }, + null, + ); +} + +/* ── 节点安装 ─────────────────────────────────────────────────────────── */ +function setupToggleNode(node) { + if (node.__xbToggleReady) return; + node.__xbToggleReady = true; + node.__xbMountedAt = performance.now(); + // 从工作流载入的节点:存档里已有高度,前 1.5s 一律不动高度(避免刷新时先变矮再弹回) + const props0 = ensureProps(node); + const fromWorkflow = Array.isArray(props0[PROP_GROUPS]) || Array.isArray(props0[PROP_NODES]); + node.__xbFromWorkflow = fromWorkflow; + // 等布局/字体稳定(约 0.7s)后再做唯一一次高度校正,之后只随行数变化调整 + node.__xbSkipFitUntil = node.__xbMountedAt + 700; + const kind = node.type === TYPE_GROUPS ? "groups" : "nodes"; + buildPanel(node, kind); + refreshRows(node); + + // 画布有增删(分组改名 / 节点被删 / 子图内容变化)→ 低频刷新列表,不打断交互 + let lastSig = ""; + const sig = () => { + const props = ensureProps(node); + const modes = []; + for (const r of rowsOf(node)) for (const n of r.nodes) modes.push(n.mode); + const tree = graphTree(rootGraphOf(node)); + const nodes = tree.reduce((a, t) => a + (t.graph?._nodes || []).length, 0); + const groups = tree.reduce((a, t) => a + (t.graph?._groups || []).length, 0); + return `${groups}|${nodes}|${tree.length}|${node.size?.[1] || 0}` + + `|${JSON.stringify(props[PROP_GROUPS] || null)}|${JSON.stringify(props[PROP_NODES] || null)}` + + `|${restrictOf(node)}|${modeOffOf(node)}|${modes.join("")}`; + }; + node.__xbTimer = setInterval(() => { + if (!node.graph) return; + const s = sig(); + if (s !== lastSig) { lastSig = s; refreshRows(node); return; } + // 没有结构变化时也检查一下"已删除的目标"(载入的旧存档里可能带着已删条目) + if (pruneMissingTargets(node)) { lastSig = sig(); refreshRows(node); return; } + applyPanelHeight(node); // 挂载 / 字体变化后校正高度 + }, 500); + const prevRemoved = node.onRemoved; + node.onRemoved = function (...args) { + try { clearInterval(node.__xbTimer); } catch (_) { /* ignore */ } + try { clearTimeout(node.__xbPruneTimer); } catch (_) { /* ignore */ } + return prevRemoved?.apply(this, args); + }; + // 挂载后补两次刷新(首帧可能量不到容器高度,靠这里对齐) + setTimeout(() => { lastSig = sig(); refreshRows(node); }, 300); + setTimeout(() => { lastSig = sig(); refreshRows(node); }, 1200); + // 过了清理保护期后再回头看一次:旧存档里已删除的条目在这里被清掉 + setTimeout(() => { if (node.graph) { lastSig = sig(); refreshRows(node); } }, 3400); +} + +/* ── 切换任意:动态任意类型输入 ───────────────────────────────────────── */ +function inputUpstream(node, input) { + try { + const graph = graphOf(node); + if (input?.link == null) return null; + const link = graph.links?.[input.link]; + if (!link) return null; + return graph.getNodeById?.(link.origin_id) || null; + } catch (_) { return null; } +} + +function setupAnySwitch(node) { + if (node.__xbSwitchReady) return; + node.__xbSwitchReady = true; + node.min_width = 200; + node.minWidth = 200; + + // 默认 2 个输入端口;连满后自动追加,断开后自动收回(至少保留 2 个) + const MIN_SLOTS = 2; + const nameOf = (i) => `any_${String(i + 1).padStart(2, "0")}`; + // ⚠️ Nodes 2.0 里每个 widget 也会在 node.inputs 里占一个「widget 槽位」→ 只处理真正的数据槽位 + const dataInputs = () => (node.inputs || []).filter((i) => i && !i.widget); + const dataIdx = () => (node.inputs || []).map((inp, i) => (inp && !inp.widget ? i : -1)).filter((i) => i >= 0); + + const stabilize = () => { + if (node.__xbBusy || !node.inputs) return; + node.__xbBusy = true; + let changed = false; + try { + // ① 只留末尾一个空槽:中间的空槽收掉(后续端口自动上移,保持紧凑) + let idxs = dataIdx(); + for (let k = idxs.length - 2; k >= 0; k--) { + const inp = node.inputs[idxs[k]]; + if (inp && inp.link == null) { node.removeInput(idxs[k]); changed = true; } + } + // ② 不足 MIN_SLOTS 时补齐 + idxs = dataIdx(); + for (let i = idxs.length; i < MIN_SLOTS; i++) { + node.addInput(nameOf(i), "*"); + changed = true; + } + // ③ 末尾端口已接线 → 再加一个空端口,保证总有可连的地方 + idxs = dataIdx(); + if (idxs.length && node.inputs[idxs[idxs.length - 1]].link != null) { + node.addInput(nameOf(idxs.length), "*"); + changed = true; + } + // ④ 统一命名(后端按 any_01 / any_02 … 排序)+ 端口显示上游节点名 + dataInputs().forEach((inp, i) => { + const nm = nameOf(i); + if (inp.name !== nm) { inp.name = nm; changed = true; } + const up = inputUpstream(node, inp); + const label = up ? (up.title || up.type || "") : ""; + if ((inp.label || "") !== label) { inp.label = label; changed = true; } + }); + // ⑤ 输出端口标签跟随第一个已连接输入的类型(与 rgthree 一致,没连接时保持 *) + const out = node.outputs?.[0]; + if (out) { + let t = "*"; + for (const inp of dataInputs()) { + if (inp.link == null) continue; + try { t = node.graph?.links?.[inp.link]?.type || "*"; } catch (_) { t = "*"; } + break; + } + if ((out.label || "") !== t) { out.label = t; changed = true; } + } + } catch (_) { /* ignore */ } finally { + node.__xbBusy = false; + } + if (changed) { + markDirty(node.graph); + try { node.setSize?.([node.size?.[0] || 220, node.computeSize?.()[1] || node.size?.[1] || 100]); } catch (_) { /* ignore */ } + } + }; + node.__xbStabilize = stabilize; + + const prevConn = node.onConnectionsChange; + node.onConnectionsChange = function (...args) { + const r = prevConn?.apply(this, args); + setTimeout(stabilize, 0); + return r; + }; + const prevConfigure = node.onConfigure; + node.onConfigure = function (...args) { + const r = prevConfigure?.apply(this, args); + setTimeout(stabilize, 60); + return r; + }; + + // 新节点:先铺 2 个端口;恢复的工作流则按存档里的端口数走 + if (!dataInputs().length) for (let i = 0; i < MIN_SLOTS; i++) node.addInput(nameOf(i), "*"); + setTimeout(stabilize, 0); + setTimeout(stabilize, 200); + + let lastSig = ""; + const timer = setInterval(() => { + if (!node.graph) return; + const sig = dataInputs().map((i) => `${i.name}:${i.link}`).join(","); + if (sig !== lastSig) { lastSig = sig; stabilize(); } + }, 700); + const prevRemoved = node.onRemoved; + node.onRemoved = function (...args) { + try { clearInterval(timer); } catch (_) { /* ignore */ } + return prevRemoved?.apply(this, args); + }; +} + +/* ── 扩展注册 ─────────────────────────────────────────────────────────── */ +app.registerExtension({ + name: "XB_ToolBox.FastToggle", + + async beforeRegisterNodeDef(nodeType, nodeData) { + const name = nodeData?.name; + if (name !== TYPE_GROUPS && name !== TYPE_NODES && name !== TYPE_SWITCH) return; + + const prevCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const r = prevCreated?.apply(this, arguments); + const self = this; + setTimeout(() => { + if (name === TYPE_SWITCH) setupAnySwitch(self); + else setupToggleNode(self); + }, 0); + return r; + }; + + if (name === TYPE_GROUPS || name === TYPE_NODES) { + const prevMenu = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function (_, options) { + prevMenu?.apply(this, arguments); + try { addMenu(this, options); } catch (_) { /* ignore */ } + }; + } else if (name === TYPE_SWITCH) { + const prevMenu = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function (_, options) { + prevMenu?.apply(this, arguments); + const self = this; + options.unshift( + { content: "🔄 刷新端口(整理空槽 / 同步上游名称)", callback: () => { try { self.__xbStabilize?.(); } catch (_) { /* ignore */ } } }, + null, + ); + }; + } + }, + + async loadedGraphNode(node) { + if (node?.type === TYPE_SWITCH) setTimeout(() => setupAnySwitch(node), 120); + else if (node?.type === TYPE_GROUPS || node?.type === TYPE_NODES) setTimeout(() => setupToggleNode(node), 120); + }, +}); + +export const XB_FAST_TOGGLE = { version: 1 }; diff --git a/js/xb_reference_any.js b/js/xb_reference_any.js index c8298c2..048310b 100644 --- a/js/xb_reference_any.js +++ b/js/xb_reference_any.js @@ -2,24 +2,31 @@ * XB 引用任意 (Reference Any) * ============================= * 融合 rgthree Fast Muter + Any Switch: - * - 动态输入槽,连接后自动扩展 - * - 输入端口显示上游节点名称 (input.name) - * - 下拉菜单选择活跃的上游节点,选项 = 上游节点名 - * - Python 端 "选择" 下拉框 defaultInput: True → 可直接接受上游 STRING 控制 - * - 选中 → mode=0(活跃),其余 → mode=2(静音),即时生效无延迟 + * - 动态输入槽:接线自动扩展,端口名显示上游节点名 + * - 面板上一个下拉框「选择」:选中哪个上游节点就只激活它,其余静音; + * 选「全部关闭」= 上游全部静音,本节点输出空 + * - 「选择」端口还能接 STRING 上游来外部驱动选择(Python 端 defaultInput) * - * 参照 rgthree-comfy 的 BaseNodeModeChanger + BaseAnyInputConnectedNode 模式。 + * ⚠️ 为什么下拉不用 LiteGraph 的 combo widget: + * Nodes 2.0(Vue)在节点创建时就把 widget.options 快照进 store,之后 JS 改 + * options.values 不会同步过去,下拉里会一直显示「未找到结果」,并且一点就把 + * 值回退成第一项「全部关闭」。所以改为 DOM widget + 原生 ─────────────────────────────────────── + const container = document.createElement("div"); + container.style.cssText = "width:100%;height:100%;box-sizing:border-box;display:flex;align-items:center;gap:6px;" + + "padding:0 6px;background:#2b2b2b;border-radius:6px;"; + + const label = document.createElement("span"); + label.textContent = WNAME; + label.style.cssText = "flex:0 0 auto;color:#c8c8c8;font-size:12px;"; + + const sel = document.createElement("select"); + sel.style.cssText = "flex:1 1 auto;min-width:0;height:24px;background:#1e1e1e;color:#e6e6e6;" + + "border:1px solid #444;border-radius:5px;font-size:12px;font-family:inherit;padding:0 4px;cursor:pointer;"; + const stop = (e) => { e.stopPropagation(); }; + sel.addEventListener("pointerdown", stop); + sel.addEventListener("mousedown", stop); + sel.addEventListener("click", stop); + sel.addEventListener("change", () => { + const v = sel.value; + const w = valueWidget(); + if (w && w.value !== v) w.value = v; + self.__xbAppliedValue = v; + self.__xbUserPick = true; + self.applySelection?.(v); + self.graph?.setDirtyCanvas?.(true, true); + }); + container.append(label, sel); + self.__xbSelectEl = sel; + + const domWidget = self.addDOMWidget?.("xb_ref_select", "XB_REF_SELECT", container, { + serialize: false, + hideOnZoom: false, + }); + if (domWidget) { + domWidget.options = domWidget.options || {}; + domWidget.options.serialize = false; + domWidget.options.getMinHeight = () => PANEL_H; + domWidget.computeSize = () => [Math.max(160, Math.round(self.size?.[0] || 200)), PANEL_H]; + domWidget.computeLayoutSize = () => ({ minHeight: PANEL_H, maxHeight: undefined, minWidth: 160 }); } + try { self.size = [Math.max(200, self.size?.[0] || 200), Math.max(PANEL_H + 40, self.size?.[1] || 70)]; } catch (_) { /* ignore */ } - // Ensure at least one dynamic input slot exists (in case FlexibleOptionalInputType starts empty) - const hasDynamicInput = (self.inputs || []).some(inp => !isControlInput(inp)); - if (!hasDynamicInput) self.addInput("", "*"); - - // ---- stabilizeInputsOutputs ---- + // ── 端口维护:接线扩展 / 收空槽 / 端口名跟随上游 ────────────────── self.stabilizeInputsOutputs = function () { - let ch = false; - const inputs = this.inputs; - - // Find indices of dynamic inputs (skip control inputs) - const dynIndices = []; - for (let i = 0; i < inputs.length; i++) { - if (!isControlInput(inputs[i])) dynIndices.push(i); - } - - // If no dynamic inputs at all, add one - if (dynIndices.length === 0) { - this.addInput("", "*"); - return true; - } - - // Last dynamic input connected → add new empty slot - const lastDyn = inputs[dynIndices[dynIndices.length - 1]]; - if (lastDyn?.link != null) { - this.addInput("", "*"); - dynIndices.push(inputs.length - 1); - ch = true; - } - - // Remove empty dynamic inputs (keep exactly one empty at the end) - for (let di = dynIndices.length - 2; di >= 0; di--) { - const idx = dynIndices[di]; - if (inputs[idx].link == null) { - this.removeInput(idx); - ch = true; - } else { - const up = getUpstreamNode(this, idx); - const nm = up?.title || ""; - if (inputs[idx].name !== nm) { inputs[idx].name = nm; ch = true; } - } - } - return ch; - }; - - // ---- syncComboOptions (only updates dropdown list, no mode changes) ---- - self.syncComboOptions = function () { - const combo = (this.widgets || []).find(w => w.name === COMBO_WIDGET_NAME); - if (!combo) return false; - - const linked = getLinkedNodes(this); - const newVals = ["全部关闭", ...linked.map(n => n.title)]; - const curVals = combo.options?.values || []; - - // Compare (ignore first "全部关闭" if it was already there) - const cmp = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]); - if (!cmp(curVals, newVals)) { - this.__xb_syncing = true; - combo.options.values = newVals; - // Preserve current selection; never auto-override "全部关闭" - if (!newVals.includes(combo.value)) { - combo.value = newVals[0]; - } - this.__xb_syncing = false; - return true; - } - return false; - }; - - // ---- applySelection: set modes based on combo value, returns true if changed ---- - self.applySelection = function (value) { - if (value === this.__xb_lastSelection) return false; - this.__xb_lastSelection = value; - const linked = getLinkedNodes(this); let changed = false; - if (value === "全部关闭") { - // Mute all upstream nodes, output nothing - for (const { node } of linked) { - if (node && node.mode !== MODE_MUTE) { node.mode = MODE_MUTE; changed = true; } + let slots = dataSlots(this); + if (!slots.length) { + this.addInput("", "*"); + return true; + } + // 末尾已接线 → 追加一个空槽 + const last = slots[slots.length - 1]; + if (last.inp.link != null) { + this.addInput("", "*"); + changed = true; + slots = dataSlots(this); + } + // 中间的空槽收掉(只留末尾一个空槽) + for (let k = slots.length - 2; k >= 0; k--) { + const { inp, i } = slots[k]; + if (inp && inp.link == null) { + this.removeInput(i); + changed = true; } - } else { - for (const { node } of linked) { - if (!node) continue; - const targetMode = (node.title === value) ? MODE_ACTIVE : MODE_MUTE; - if (node.mode !== targetMode) { node.mode = targetMode; changed = true; } + } + // 端口名 = 上游节点名 + for (const { inp, i } of dataSlots(this)) { + if (inp.link == null) { + if (inp.name !== "") { inp.name = ""; inp.label = ""; changed = true; } + continue; } + const up = getUpstreamNode(this, i); + const nm = up?.title || up?.type || ""; + if (inp.name !== nm) { inp.name = nm; changed = true; } + if ((inp.label || "") !== nm) { inp.label = nm; changed = true; } + } + return changed; + }; + + // ── 下拉选项同步(只认连线,不看 mode,所以全静音时也照常显示)────── + self.syncSelectOptions = function () { + const el = this.__xbSelectEl; + if (!el) return false; + const linked = getLinkedNodes(this); + const titles = []; + for (const { title } of linked) { + if (title && !titles.includes(title)) titles.push(title); + } + const values = [ALL_OFF, ...titles]; + const w = valueWidget(); + let current = String(w?.value ?? el.value ?? ALL_OFF); + if (!values.includes(current)) { + // 选中的上游确实不在了(被删 / 断开 / 改名)→ 只有当它连 id 都找不到时才回退 + const stillLinked = linked.some((x) => x.node?.id === this.__xbSelectedId); + const keep = stillLinked ? (linked.find((x) => x.node?.id === this.__xbSelectedId)?.title || ALL_OFF) : ALL_OFF; + current = keep; + if (w && w.value !== current) w.value = current; + } + const same = el.options.length === values.length + && values.every((v, i) => el.options[i]?.value === v); + if (!same) { + el.replaceChildren(...values.map((v) => { + const o = document.createElement("option"); + o.value = v; + o.textContent = v; + return o; + })); + } + if (el.value !== current) el.value = current; + return !same; + }; + + // ── 应用选择:选中的激活,其余静音;「全部关闭」= 全静音 ──────────── + self.applySelection = function (value) { + const linked = getLinkedNodes(this); + let targetId = null; + if (value && value !== ALL_OFF) { + const hit = linked.find((x) => x.title === value); + if (hit) targetId = hit.node.id; + } + this.__xbSelectedId = targetId; + let changed = false; + for (const { node } of linked) { + if (!node) continue; + const want = (node.id === targetId) ? MODE_ACTIVE : MODE_MUTE; + if (node.mode !== want) { node.mode = want; changed = true; } } if (changed) this.graph?.setDirtyCanvas?.(true, true); return changed; }; - // ---- setupComboCallback (fires instantly on user click) ---- - function setupComboCallback() { - const combo = (self.widgets || []).find(w => w.name === COMBO_WIDGET_NAME); - if (!combo || combo.__xb_cb_set) return; - combo.__xb_cb_set = true; - - combo.callback = function (value) { - if (self.__xb_syncing) return; // skip if programmatic update - self.applySelection(value); - }; - } - - // ---- doStablization (rgthree pattern) ---- + // ── 周期稳定 ────────────────────────────────────────────────────── self.doStablization = function () { - if (!this.graph || this.__xb_removed) return; + if (!this.graph || this.__xbRefRemoved) return; let dirty = false; try { dirty = this.stabilizeInputsOutputs() || dirty; - dirty = this.syncComboOptions() || dirty; + dirty = this.syncSelectOptions() || dirty; - const combo = (this.widgets || []).find(w => w.name === COMBO_WIDGET_NAME); - if (!combo) return; + const el = this.__xbSelectEl; + const w = valueWidget(); + const ctrl = (this.inputs || []).find((inp) => inp && inp.name === WNAME); - // 检查 "选择" 端口是否被接入 - const selectInput = (this.inputs || []).find(inp => inp.name === COMBO_WIDGET_NAME); - const isConnected = selectInput?.link != null; - - if (isConnected) { - // 有上游接入 → 反向嗅探,匹配则激活,不匹配则"全部关闭" + if (ctrl?.link != null) { + // 外部 STRING 驱动选择:嗅探值 → 匹配选项 const sniffed = sniffControlString(this); - if (sniffed !== this.__xb_lastSniffed) { - this.__xb_lastSniffed = sniffed; - if (sniffed != null) { - const options = combo.options?.values || []; - let bestMatch = null; - const lowerInput = sniffed.toLowerCase(); - for (const opt of options) { - if (lowerInput.includes(opt.toLowerCase())) { - if (!bestMatch || opt.length > bestMatch.length) { - bestMatch = opt; - } - } - } - // 匹配成功 → 激活对应选项;匹配失败 → 回退到"全部关闭" - const target = bestMatch || "全部关闭"; - this.__xb_syncing = true; - combo.value = target; - this.__xb_syncing = false; - dirty = this.applySelection(target) || dirty; - } else { - // 嗅探不到值 → 默认"全部关闭" - dirty = this.applySelection("全部关闭") || dirty; - } + if (sniffed !== this.__xbLastSniffed) { + this.__xbLastSniffed = sniffed; + const target = sniffed == null ? ALL_OFF : matchOption(sniffed, (el ? Array.from(el.options).map((o) => o.value) : [])); + if (w && w.value !== target) w.value = target; + if (el && el.value !== target) el.value = target; + this.__xbAppliedValue = target; + dirty = this.applySelection(target) || dirty; } } else { - // 无上游接入 → 用下拉菜单手动选择的值 - if (combo.value !== this.__xb_lastSelection) { - dirty = this.applySelection(combo.value) || dirty; + this.__xbLastSniffed = null; + // 手动下拉:值变了才应用(避免每轮都改上游 mode) + const cur = String(el?.value ?? w?.value ?? ALL_OFF); + if (cur !== this.__xbAppliedValue) { + this.__xbAppliedValue = cur; + dirty = this.applySelection(cur) || dirty; } - // 清除嗅探缓存 - this.__xb_lastSniffed = null; } - } catch (e) { /* ignore */ } + } catch (_) { /* ignore */ } if (dirty) this.graph.setDirtyCanvas(true, true); - if (!this.__xb_removed) { + if (!this.__xbRefRemoved) { this._xbTimer = setTimeout(() => { this._xbTimer = null; this.doStablization?.(); @@ -284,7 +322,6 @@ function patchInstance(self) { } }; - // ---- scheduleStabilizeWidgets ---- self.scheduleStabilizeWidgets = function (ms = 80) { if (this._xbScheduled) return; this._xbScheduled = true; @@ -296,26 +333,34 @@ function patchInstance(self) { }, ms); }; - // ---- hooks ---- + // ── hooks ───────────────────────────────────────────────────────── const origConn = self.onConnectionsChange; self.onConnectionsChange = function () { if (origConn) origConn.apply(this, arguments); this.scheduleStabilizeWidgets?.(64); }; + const origConfigure = self.onConfigure; + self.onConfigure = function () { + const r = origConfigure?.apply(this, arguments); + hideValueWidget(); + this.scheduleStabilizeWidgets?.(64); + return r; + }; + const origRem = self.onRemoved; self.onRemoved = function () { - this.__xb_removed = true; + this.__xbRefRemoved = true; if (this._xbTimer) { clearTimeout(this._xbTimer); this._xbTimer = null; } if (origRem) origRem.apply(this, arguments); }; - // ---- kick off ---- + // ── 启动 ────────────────────────────────────────────────────────── setTimeout(() => { - setupComboCallback(); - // Apply initial selection (defaults to "全部关闭" → all muted) - const combo = (self.widgets || []).find(w => w.name === COMBO_WIDGET_NAME); - if (combo) self.applySelection(combo.value || "全部关闭"); + const w = valueWidget(); + const cur = String(w?.value || ALL_OFF); + if (self.__xbSelectEl) self.__xbSelectEl.value = cur; + self.__xbAppliedValue = cur; self.doStablization?.(); - }, 300); + }, 250); } diff --git a/js/xb_video.js b/js/xb_video.js index 81f2159..cd43bcf 100644 --- a/js/xb_video.js +++ b/js/xb_video.js @@ -7,8 +7,116 @@ import { setWidgetValue } from "./xb_compat.js"; const isZH = navigator.language.startsWith("zh"); +// 「时长估算」在 Nodes 2.0 里是 PrimeVue 文本框(padding 8px 16px → 31px 高), +// 比旁边的数字框高一截;用一段 CSS 压成同样高度、同样的透明背景。 +const PARAMS_CSS = ` +div[node-type="XB_VideoParamsMaster"] input.p-inputtext[readonly] { + height: 24px !important; + min-height: 24px !important; + max-height: 24px !important; + padding: 0 8px !important; + background: var(--color-component-node-widget-background, rgb(49, 50, 53)) !important; + border: 0 !important; + border-radius: 6px !important; + box-shadow: none !important; + font-size: 12px !important; + line-height: 24px !important; + color: inherit !important; +} +`; + +const injectParamsCss = () => { + try { + if (document.getElementById("xb-params-css")) return; + const st = document.createElement("style"); + st.id = "xb-params-css"; + st.textContent = PARAMS_CSS; + document.head.append(st); + } catch (_) { /* ignore */ } +}; +injectParamsCss(); + // 共用「画幅比例 + 步长」联动的图像参数节点 const IMAGE_PARAMS_NODES = new Set(["XB_ImageParamsMaster", "XB_ImageParamsMasterMini"]); +const VIDEO_PARAMS_NODE = "XB_VideoParamsMaster"; + +// 画幅比例:自定义 + 官方 Resolution Selector 的比例(旧值自动迁移) +const RATIO_ALIAS = { + "Free": "自定义", + "1:1": "1:1 (Square)", + "2:3": "2:3 (Portrait Photo)", + "3:2": "3:2 (Photo)", + "3:4": "3:4 (Portrait Standard)", + "4:3": "4:3 (Standard)", + "9:16": "9:16 (Portrait Widescreen)", + "16:9": "16:9 (Widescreen)", + "21:9": "21:9 (Ultrawide)", +}; + +const isCustomRatio = (name) => { + const v = String(name ?? ""); + return !v || v.includes("自定义") || v.includes("Free"); +}; + +/** "16:9 (Widescreen)" / "16:9 (LTX)" / "16:9" → 16/9 */ +const ratioOf = (name) => { + if (isCustomRatio(name)) return null; + const m = /^(\d+):(\d+)/.exec(String(name)); + if (!m) return null; + const w = Number(m[1]); + const h = Number(m[2]); + return h ? w / h : null; +}; + +/** 旧存档里的 "Free" / "16:9" / "16:9 (LTX)" 换成新选项名(值不在选项里就修) */ +const migrateRatio = (node, wRatio) => { + if (!wRatio) return false; + const opts = wRatio.options?.values || []; + const val = String(wRatio.value ?? ""); + if (!opts.length || opts.includes(val)) return false; + const fixed = RATIO_ALIAS[val] || (val.includes("(LTX)") ? RATIO_ALIAS[val.split(" ")[0]] : null); + if (!fixed) return false; + wRatio.value = fixed; + xb_dispatch(wRatio, fixed); + return true; +}; + +/** 「倍数」当前值(缺省 16) */ +const multipleOf = (node) => { + const w = node.widgets?.find((x) => x.name === "multiple"); + const v = Math.round(Number(w?.value)); + return Number.isFinite(v) && v >= 8 ? v : 16; +}; + +/** 把宽高按比例 + 倍数(步长)规整,保证两边都能整除 */ +const fitSize = (ratioName, w, h, step) => { + const snap = (v) => Math.max(step, Math.round((Number(v) || step) / step) * step); + let wid = snap(w); + let hei = snap(h); + const r = ratioOf(ratioName); + if (r) { + if (wid >= hei) hei = snap(wid / r); + else wid = snap(hei * r); + } + return { wid: Math.max(step, wid), hei: Math.max(step, hei) }; +}; + +/** 步长控件(数字框的上下箭头步长);min 也设成步长, + * 否则 Nodes 2.0 的数字框会按 min + k*step 取整,结果可能不是倍数的整数倍。 */ +const applyStep = (w, step) => { + if (!w) return; + w.options = w.options || {}; + w.options.step = step; + w.options.step2 = step; + w.options.min = step; + const el = w.inputEl || w.element; + if (el && typeof el.setAttribute === "function") { + el.step = step; + el.min = step; + el.setAttribute("step", String(step)); + el.setAttribute("min", String(step)); + } +}; // 旧版写值函数(仅输入框/回调,Nodes 2.0 下会漏写 widget.value) const __xbLegacyDispatch = (w, val) => { @@ -29,75 +137,87 @@ const xb_dispatch = (w, val) => setWidgetValue(w, val); app.registerExtension({ name: "xiaobai.mediaparams.split", - // —— nodeCreated:子工作流画幅联动(步长由 Python step=16 负责)—— + // —— nodeCreated:画幅比例 + 倍数(步长)联动 —— async nodeCreated(node) { if (IMAGE_PARAMS_NODES.has(node.comfyClass)) { const wR = node.widgets?.find(w => w.name === "aspect_ratio"); const wW = node.widgets?.find(w => w.name === "width"); const wH = node.widgets?.find(w => w.name === "height"); + const wM = node.widgets?.find(w => w.name === "multiple"); if (!wR || !wW || !wH) return; node._xb_syncing = false; - const rm = {"1:1":1,"16:9":16/9,"9:16":9/16,"4:3":4/3,"3:4":3/4,"21:9":21/9,"2:3":2/3,"3:2":3/2}; + migrateRatio(node, wR); + applyStep(wW, multipleOf(node)); + applyStep(wH, multipleOf(node)); - const sW = () => { if(node._xb_syncing||node._xb_from_polling) return; - const r=wR.value; if(r.includes("Free")) return; const cr=rm[r]; if(!cr) return; - let w=Math.round((parseInt(wW.value,10)||16)/16)*16; w=Math.max(16,w); - let h=Math.round((w/cr)/16)*16; h=Math.max(16,h); - if(h!==parseInt(wH.value,10)){node._xb_syncing=true;wH.value=h;xb_dispatch(wH,h);node._xb_syncing=false;} }; - const sH = () => { if(node._xb_syncing||node._xb_from_polling) return; - const r=wR.value; if(r.includes("Free")) return; const cr=rm[r]; if(!cr) return; - let h=Math.round((parseInt(wH.value,10)||16)/16)*16; h=Math.max(16,h); - let w=Math.round((h*cr)/16)*16; w=Math.max(16,w); - if(w!==parseInt(wW.value,10)){node._xb_syncing=true;wW.value=w;xb_dispatch(wW,w);node._xb_syncing=false;} }; + const sync = () => { + if (node._xb_syncing || node._xb_from_polling) return; + const step = multipleOf(node); + const { wid, hei } = fitSize(wR.value, wW.value, wH.value, step); + node._xb_syncing = true; + if (parseInt(wW.value, 10) !== wid) { wW.value = wid; xb_dispatch(wW, wid); } + if (parseInt(wH.value, 10) !== hei) { wH.value = hei; xb_dispatch(wH, hei); } + node._xb_syncing = false; + }; - const oc=wR.callback; wR.callback=function(v){if(oc)oc.apply(this,arguments);if(node._xb_from_polling)return;sW();}; - const ow=wW.callback; wW.callback=function(v){if(ow)ow.apply(this,arguments);sW();}; - const oh=wH.callback; wH.callback=function(v){if(oh)oh.apply(this,arguments);sH();}; + const oc = wR.callback; wR.callback = function (v) { if (oc) oc.apply(this, arguments); if (node._xb_from_polling) return; sync(); }; + const ow = wW.callback; wW.callback = function (v) { if (ow) ow.apply(this, arguments); sync(); }; + const oh = wH.callback; wH.callback = function (v) { if (oh) oh.apply(this, arguments); sync(); }; + const om = wM?.callback; if (wM) wM.callback = function (v) { + if (om) om.apply(this, arguments); + const step = multipleOf(node); + applyStep(wW, step); applyStep(wH, step); + sync(); + }; } - if (node.comfyClass === "XB_VideoParamsMaster") { + if (node.comfyClass === VIDEO_PARAMS_NODE) { const wR = node.widgets?.find(w => w.name === "aspect_ratio"); const wW = node.widgets?.find(w => w.name === "width"); const wH = node.widgets?.find(w => w.name === "height"); const wF = node.widgets?.find(w => w.name === "fps"); const wFF = node.widgets?.find(w => w.name === "fps_float"); const wL = node.widgets?.find(w => w.name === "length"); + const wM = node.widgets?.find(w => w.name === "multiple"); if (!wR || !wW || !wH) return; node._xb_syncing = false; - const rS={"1:1":1,"16:9":16/9,"9:16":9/16,"4:3":4/3,"3:4":3/4,"21:9":21/9,"2:3":2/3,"3:2":3/2}; - const rL={"16:9 (LTX)":16/9,"9:16 (LTX)":9/16,"4:3 (LTX)":4/3,"3:4 (LTX)":3/4}; + migrateRatio(node, wR); + applyStep(wW, multipleOf(node)); + applyStep(wH, multipleOf(node)); - const sW = () => { if(node._xb_syncing||node._xb_from_polling) return; - const r=wR.value; if(r.includes("Free")) return; - const isL=r.includes("(LTX)"); const rm=isL?rL:rS; const cr=rm[r]; if(!cr) return; - const s=isL?32:16; - let w=Math.round((parseInt(wW.value,10)||s)/s)*s; w=Math.max(s,w); - let h=Math.round((w/cr)/s)*s; h=Math.max(s,h); - if(h!==parseInt(wH.value,10)){node._xb_syncing=true;wH.value=h;xb_dispatch(wH,h);node._xb_syncing=false;} }; - const sH = () => { if(node._xb_syncing||node._xb_from_polling) return; - const r=wR.value; if(r.includes("Free")) return; - const isL=r.includes("(LTX)"); const rm=isL?rL:rS; const cr=rm[r]; if(!cr) return; - const s=isL?32:16; - let h=Math.round((parseInt(wH.value,10)||s)/s)*s; h=Math.max(s,h); - let w=Math.round((h*cr)/s)*s; w=Math.max(s,w); - if(w!==parseInt(wW.value,10)){node._xb_syncing=true;wW.value=w;xb_dispatch(wW,w);node._xb_syncing=false;} }; + const sync = (driveBy) => { + if (node._xb_syncing || node._xb_from_polling) return; + const step = multipleOf(node); + const { wid, hei } = fitSize(wR.value, wW.value, wH.value, step); + node._xb_syncing = true; + if (parseInt(wW.value, 10) !== wid) { wW.value = wid; xb_dispatch(wW, wid); } + if (parseInt(wH.value, 10) !== hei) { wH.value = hei; xb_dispatch(wH, hei); } + node._xb_syncing = false; + }; - const oc=wR.callback; wR.callback=function(v){if(oc)oc.apply(this,arguments);if(node._xb_from_polling)return;sW();}; - const ow=wW.callback; wW.callback=function(v){if(ow)ow.apply(this,arguments);sW();}; - const oh=wH.callback; wH.callback=function(v){if(oh)oh.apply(this,arguments);sH();}; + const oc = wR.callback; wR.callback = function (v) { if (oc) oc.apply(this, arguments); if (node._xb_from_polling) return; sync("w"); }; + const ow = wW.callback; wW.callback = function (v) { if (ow) ow.apply(this, arguments); sync("w"); }; + const oh = wH.callback; wH.callback = function (v) { if (oh) oh.apply(this, arguments); sync("h"); }; + const om = wM?.callback; if (wM) wM.callback = function (v) { + if (om) om.apply(this, arguments); + const step = multipleOf(node); + applyStep(wW, step); applyStep(wH, step); + sync("w"); + }; if(wF&&wFF){const of=wF.callback;wF.callback=function(v){if(of)of.apply(this,arguments);if(node._xb_syncing||node._xb_from_polling)return; const val=Math.round(Number(v));if(wFF.value!==val){node._xb_syncing=true;wFF.value=val;xb_dispatch(wFF,val);node._xb_syncing=false;}}; const off=wFF.callback;wFF.callback=function(v){if(off)off.apply(this,arguments);if(node._xb_syncing||node._xb_from_polling)return; const val=Math.round(Number(v));if(wF.value!==val){node._xb_syncing=true;wF.value=val;xb_dispatch(wF,val);node._xb_syncing=false;}};} if(wL){node._xb_last_len=parseInt(wL.value,10)||1;const ol=wL.callback;wL.callback=function(v){if(ol)ol.apply(this,arguments);if(node._xb_syncing||node._xb_from_polling)return; - const isF=(wR.value||"").includes("Free");const as=isF?1:8; + const isF=isCustomRatio(wR.value);const as=isF?1:8; let len=parseInt(v,10)||1;if(!isF&&(len-1)%as!==0){len=len>node._xb_last_len?Math.ceil((len-1)/as)*as+1:Math.floor((len-1)/as)*as+1;len=Math.max(1,len); if(wL.value!==len){node._xb_syncing=true;wL.value=len;xb_dispatch(wL,len);node._xb_syncing=false;}}node._xb_last_len=len;};} } }, init() { + injectParamsCss(); setInterval(() => { if (!app.graph || !app.graph._nodes) return; @@ -133,87 +253,49 @@ app.registerExtension({ node._xb_last_height = parseInt(wHeight.value, 10) || 1024; node._xb_last_ratio = wRatio.value; node._xb_last_dur = parseFloat(wDisp.value) || 0; + node._xb_need_normalize = true; // 载入后先规整一次 } + if (migrateRatio(node, wRatio)) node._xb_need_normalize = true; let needsUpdate = false; let valRatio = wRatio.value; - let isFree = valRatio.includes("Free"); + let isFree = isCustomRatio(valRatio); let rChanged = valRatio !== node._xb_last_ratio; - let isLTX = valRatio.includes("(LTX)"); - let currentStep = isFree ? 1 : (isLTX ? 32 : 16); - wWidth.options.step = wWidth.options.step2 = currentStep; wHeight.options.step = wHeight.options.step2 = currentStep; - if (wWidth.inputEl) { wWidth.inputEl.step = currentStep; wWidth.inputEl.setAttribute("step", String(currentStep)); } - else if (wWidth.element) { wWidth.element.step = currentStep; wWidth.element.setAttribute("step", String(currentStep)); } - if (wHeight.inputEl) { wHeight.inputEl.step = currentStep; wHeight.inputEl.setAttribute("step", String(currentStep)); } - else if (wHeight.element) { wHeight.element.step = currentStep; wHeight.element.setAttribute("step", String(currentStep)); } + // 步长 = 倍数(宽高必须能被它整除) + let currentStep = multipleOf(node); + applyStep(wWidth, currentStep); + applyStep(wHeight, currentStep); let wid = parseInt(wWidth.value, 10) || currentStep; let hei = parseInt(wHeight.value, 10) || currentStep; let wChanged = wid !== node._xb_last_width; let hChanged = hei !== node._xb_last_height; - if (wChanged || hChanged || rChanged) { - if (!isFree && !isLTX) { - const ratioMap = { - "1:1": 1.0, "16:9": 16.0 / 9.0, "9:16": 9.0 / 16.0, - "4:3": 4.0 / 3.0, "3:4": 3.0 / 4.0, "21:9": 21.0 / 9.0, - "2:3": 2.0 / 3.0, "3:2": 3.0 / 2.0 - }; - let currentRatio = ratioMap[valRatio]; - - if (wChanged && wid % currentStep !== 0) { - wid = wid > node._xb_last_width ? Math.ceil(wid / currentStep) * currentStep : Math.floor(wid / currentStep) * currentStep; - } else { wid = Math.round(wid / currentStep) * currentStep; } - wid = Math.max(currentStep, wid); - - if (hChanged && hei % currentStep !== 0) { - hei = hei > node._xb_last_height ? Math.ceil(hei / currentStep) * currentStep : Math.floor(hei / currentStep) * currentStep; - } else { hei = Math.round(hei / currentStep) * currentStep; } - hei = Math.max(currentStep, hei); - - if (currentRatio) { - if (rChanged || wChanged) { - hei = Math.round((wid / currentRatio) / currentStep) * currentStep; - hei = Math.max(currentStep, hei); - } else if (hChanged) { - wid = Math.round((hei * currentRatio) / currentStep) * currentStep; - wid = Math.max(currentStep, wid); - } - } - } else if (isLTX) { - const ratioMap = { - "16:9 (LTX)": 16.0 / 9.0, "9:16 (LTX)": 9.0 / 16.0, - "4:3 (LTX)": 4.0 / 3.0, "3:4 (LTX)": 3.0 / 4.0 - }; - let currentRatio = ratioMap[valRatio]; - - if (wChanged && wid % currentStep !== 0) { - wid = wid > node._xb_last_width ? Math.ceil(wid / currentStep) * currentStep : Math.floor(wid / currentStep) * currentStep; - } else { wid = Math.round(wid / currentStep) * currentStep; } - wid = Math.max(currentStep, wid); - - if (hChanged && hei % currentStep !== 0) { - hei = hei > node._xb_last_height ? Math.ceil(hei / currentStep) * currentStep : Math.floor(hei / currentStep) * currentStep; - } else { hei = Math.round(hei / currentStep) * currentStep; } - hei = Math.max(currentStep, hei); - - if (currentRatio) { - if (rChanged || wChanged) { - hei = Math.round((wid / currentRatio) / currentStep) * currentStep; - hei = Math.max(currentStep, hei); - } else if (hChanged) { - wid = Math.round((hei * currentRatio) / currentStep) * currentStep; - wid = Math.max(currentStep, wid); - } + if (wChanged || hChanged || rChanged || node._xb_need_normalize) { + node._xb_need_normalize = false; + const snap = (v) => Math.max(currentStep, Math.round(v / currentStep) * currentStep); + let nw = snap(wid); + let nh = snap(hei); + const currentRatio = ratioOf(valRatio); + if (currentRatio) { + const ratioOnly = rChanged && !wChanged && !hChanged; + if (ratioOnly) { + // ????????????????? Python ???? + if (nw >= nh) nh = snap(nw / currentRatio); + else nw = snap(nh * currentRatio); + } else if (wChanged || !hChanged) { + nh = snap(nw / currentRatio); + } else { + nw = snap(nh * currentRatio); } } - wWidth.value = wid; wHeight.value = hei; - node._xb_last_width = wid; node._xb_last_height = hei; + wWidth.value = nw; wHeight.value = nh; + node._xb_last_width = nw; node._xb_last_height = nh; node._xb_last_ratio = valRatio; - xb_dispatch(wWidth, wid); - xb_dispatch(wHeight, hei); + xb_dispatch(wWidth, nw); + xb_dispatch(wHeight, nh); needsUpdate = true; } @@ -279,58 +361,48 @@ app.registerExtension({ node._xb_last_width = parseInt(wWidth.value, 10) || 1024; node._xb_last_height = parseInt(wHeight.value, 10) || 1024; node._xb_last_ratio = wRatio.value; + node._xb_need_normalize = true; // ???????? } + if (migrateRatio(node, wRatio)) node._xb_need_normalize = true; let needsUpdate = false; let valRatio = wRatio.value; - let isFree = valRatio.includes("Free"); let rChanged = valRatio !== node._xb_last_ratio; - let currentStep = isFree ? 1 : 16; - wWidth.options.step = wWidth.options.step2 = currentStep; wHeight.options.step = wHeight.options.step2 = currentStep; - if (wWidth.inputEl) { wWidth.inputEl.step = currentStep; wWidth.inputEl.setAttribute("step", String(currentStep)); } - else if (wWidth.element) { wWidth.element.step = currentStep; wWidth.element.setAttribute("step", String(currentStep)); } - if (wHeight.inputEl) { wHeight.inputEl.step = currentStep; wHeight.inputEl.setAttribute("step", String(currentStep)); } - else if (wHeight.element) { wHeight.element.step = currentStep; wHeight.element.setAttribute("step", String(currentStep)); } + // 步长 = 倍数(宽高必须能被它整除) + let currentStep = multipleOf(node); + applyStep(wWidth, currentStep); + applyStep(wHeight, currentStep); let wid = parseInt(wWidth.value, 10) || currentStep; let hei = parseInt(wHeight.value, 10) || currentStep; let wChanged = wid !== node._xb_last_width; let hChanged = hei !== node._xb_last_height; - if (wChanged || hChanged || rChanged) { - const ratioMap = { - "1:1": 1.0, "16:9": 16.0 / 9.0, "9:16": 9.0 / 16.0, - "4:3": 4.0 / 3.0, "3:4": 3.0 / 4.0, "21:9": 21.0 / 9.0, - "2:3": 2.0 / 3.0, "3:2": 3.0 / 2.0 - }; - let currentRatio = ratioMap[valRatio]; - - if (wChanged && wid % currentStep !== 0) { - wid = wid > node._xb_last_width ? Math.ceil(wid / currentStep) * currentStep : Math.floor(wid / currentStep) * currentStep; - } else { wid = Math.round(wid / currentStep) * currentStep; } - wid = Math.max(currentStep, wid); - - if (hChanged && hei % currentStep !== 0) { - hei = hei > node._xb_last_height ? Math.ceil(hei / currentStep) * currentStep : Math.floor(hei / currentStep) * currentStep; - } else { hei = Math.round(hei / currentStep) * currentStep; } - hei = Math.max(currentStep, hei); - + if (wChanged || hChanged || rChanged || node._xb_need_normalize) { + node._xb_need_normalize = false; + const snap = (v) => Math.max(currentStep, Math.round(v / currentStep) * currentStep); + let nw = snap(wid); + let nh = snap(hei); + const currentRatio = ratioOf(valRatio); if (currentRatio) { - if (rChanged || wChanged) { - hei = Math.round((wid / currentRatio) / currentStep) * currentStep; - hei = Math.max(currentStep, hei); - } else if (hChanged) { - wid = Math.round((hei * currentRatio) / currentStep) * currentStep; - wid = Math.max(currentStep, wid); + const ratioOnly = rChanged && !wChanged && !hChanged; + if (ratioOnly) { + // ????????????????? Python ???? + if (nw >= nh) nh = snap(nw / currentRatio); + else nw = snap(nh * currentRatio); + } else if (wChanged || !hChanged) { + nh = snap(nw / currentRatio); + } else { + nw = snap(nh * currentRatio); } } - wWidth.value = wid; wHeight.value = hei; - node._xb_last_width = wid; node._xb_last_height = hei; + wWidth.value = nw; wHeight.value = nh; + node._xb_last_width = nw; node._xb_last_height = nh; node._xb_last_ratio = valRatio; - xb_dispatch(wWidth, wid); - xb_dispatch(wHeight, hei); + xb_dispatch(wWidth, nw); + xb_dispatch(wHeight, nh); needsUpdate = true; } diff --git a/locales/en/nodeDefs.json b/locales/en/nodeDefs.json index 6f7001c..26a2106 100644 --- a/locales/en/nodeDefs.json +++ b/locales/en/nodeDefs.json @@ -191,21 +191,47 @@ "XB_LoadVideo": { "display_name": "XB-BOX - 🎬 Load Video", "inputs": { - "file": { "name": "Video File" }, - "force_rate": { "name": "Force FPS" }, - "custom_width": { "name": "Output Width" }, - "custom_height": { "name": "Output Height" }, - "frame_load_cap": { "name": "Frame Cap" }, - "skip_first_frames": { "name": "Skip First Frames" }, - "select_every_nth": { "name": "Select Every Nth" }, - "autoplay": { "name": "Autoplay" } + "file": { + "name": "Video File" + }, + "force_rate": { + "name": "Force FPS" + }, + "custom_width": { + "name": "Output Width" + }, + "custom_height": { + "name": "Output Height" + }, + "frame_load_cap": { + "name": "Frame Cap" + }, + "skip_first_frames": { + "name": "Skip First Frames" + }, + "select_every_nth": { + "name": "Select Every Nth" + }, + "autoplay": { + "name": "Autoplay" + } }, "outputs": { - "0": { "name": "images" }, - "1": { "name": "audio" }, - "2": { "name": "fps" }, - "3": { "name": "bit_depth" }, - "4": { "name": "color_space" } + "0": { + "name": "images" + }, + "1": { + "name": "audio" + }, + "2": { + "name": "fps" + }, + "3": { + "name": "bit_depth" + }, + "4": { + "name": "color_space" + } } }, "XB_StringMerge": { @@ -707,5 +733,27 @@ "name": "📋 Report" } } + }, + "XB_DisableGroups": { + "display_name": "XB-BOX - 🚫 Disable Groups", + "description": "Inspired by rgthree Fast Groups Muter: no wiring. Right-click the node to pick the groups to control; the node surface only shows a switch list — flip a switch to mute / bypass / restore every node in that group. The right-click menu also holds the disable mode and toggle mode (always keep one on / multi with all off or all on).", + "inputs": {}, + "outputs": {} + }, + "XB_DisableNodes": { + "display_name": "XB-BOX - 🚫 Disable Nodes", + "description": "Inspired by rgthree Fast Muter: no wiring. Right-click the node to pick the nodes to control (whole groups can be added at once); the node surface only shows a switch list — flip a switch to mute / bypass / restore that node. The right-click menu also holds the disable mode and toggle mode.", + "inputs": {}, + "outputs": {} + }, + "XB_AnySwitch": { + "display_name": "XB-BOX - 🔀 Any Switch", + "description": "Inspired by rgthree Any Switch: dynamic any-type inputs (2 by default, more added as you connect) with a single any-type output. There is no selector control; it always forwards the first non-empty input.", + "inputs": {}, + "outputs": { + "0": { + "name": "Output" + } + } } } diff --git a/locales/zh/nodeDefs.json b/locales/zh/nodeDefs.json index 34a78b9..edc4110 100644 --- a/locales/zh/nodeDefs.json +++ b/locales/zh/nodeDefs.json @@ -225,6 +225,9 @@ }, "fps_float": { "name": "帧率(小数)" + }, + "multiple": { + "name": "倍数" } }, "outputs": { @@ -268,6 +271,9 @@ }, "strength_int": { "name": "强度(整数)" + }, + "multiple": { + "name": "倍数" } }, "outputs": { @@ -333,6 +339,9 @@ }, "batch_size": { "name": "生成数量" + }, + "multiple": { + "name": "倍数" } }, "outputs": { @@ -5723,5 +5732,27 @@ "name": "📋 报告" } } + }, + "XB_DisableGroups": { + "display_name": "XB-BOX - 🚫 禁用分组", + "description": "参考 rgthree 禁用框:不接线,右击节点勾选要控制的分组(可随时增删);节点表面只有开关列表,点开关即可把该分组内所有节点静音 / 绕过 / 恢复。右击还能改禁用方式、切换模式(多选可全关全开 / 永远保持一个开启)。", + "inputs": {}, + "outputs": {} + }, + "XB_DisableNodes": { + "display_name": "XB-BOX - 🚫 禁用节点", + "description": "参考 rgthree 禁用节点:不接线,右击节点勾选要控制的节点(可按分组整组加入);节点表面只有开关列表,点开关即可静音 / 绕过 / 恢复该节点。右击还能改禁用方式、切换模式。", + "inputs": {}, + "outputs": {} + }, + "XB_AnySwitch": { + "display_name": "XB-BOX - 🔀 切换任意", + "description": "参考 rgthree 任意切换:动态任意类型输入(默认 2 个,连满自动增加)/ 任意类型输出;没有选择控件,永远输出第一个非空输入。", + "inputs": {}, + "outputs": { + "0": { + "name": "任意类型" + } + } } } diff --git a/nodes_fast_toggle.py b/nodes_fast_toggle.py new file mode 100644 index 0000000..28aee65 --- /dev/null +++ b/nodes_fast_toggle.py @@ -0,0 +1,157 @@ +""" +XB-BOX 快速开关(参考 rgthree-comfy 的 Fast Muter / Fast Groups Muter / Any Switch) +================================================================================= +三个节点: + +① XB_DisableGroups —— 禁用分组 + · **不接线**:在节点面板/右键菜单里勾选要控制的分组(可以随时增删); + · 点一行 = 把该分组里的所有节点一次性「静音 / 恢复」; + · 与 rgthree「禁用框」一致:状态直接读写画布上节点自己的 mode(随工作流保存)。 + +② XB_DisableNodes —— 禁用节点 + · **不接线**:右击勾选要控制的节点(支持按分组整组加入); + · 点一行 = 把该节点「静音 / 绕过 / 恢复」; + · 与 rgthree「禁用节点」一致,但不占用输入端口、不需要拖线绑定。 + +③ XB_AnySwitch —— 切换任意 + · 动态任意类型输入(默认 any_01 / any_02,连满自动追加); + · 与 rgthree「任意切换」完全一致:没有选择控件,永远输出第一个非空输入。 + +⚠️ 前两个节点**只在前端工作**(控制别的节点的 mode),Python 端不参与执行: + 它们没有任何输入输出,ComfyUI 不会把它们放进执行队列。 +""" + + +class AnyType(str): + """一个永远“等于”任何类型的占位类型(credit: pythongosssss / rgthree)""" + + def __ne__(self, other): + return False + + def __eq__(self, other): + return True + + def __hash__(self): + return hash("*") + + +class FlexibleOptionalInputType(dict): + """让 optional 输入可以无限扩展(任何名字都视为存在,类型由构造时指定)。""" + + def __init__(self, type_, data=None): + self.type_ = type_ + self.data = data + if self.data is not None: + for k, v in self.data.items(): + self[k] = v + + def __getitem__(self, key): + if self.data is not None and key in self.data: + return self.data[key] + return (self.type_,) + + def __contains__(self, key): + return True + + +any_type = AnyType("*") + + +class XB_DisableGroups: + """禁用分组:不接线,勾选分组后一键静音 / 恢复整组节点。""" + + DESCRIPTION = ( + "禁用分组(参考 rgthree 禁用框):不接线,在面板或右键菜单里勾选要控制的分组;" + "点一行即可把该分组内所有节点静音 / 绕过 / 恢复。状态直接写在画布的节点上,随工作流保存。" + ) + + @classmethod + def INPUT_TYPES(cls): + return {"required": {}} + + RETURN_TYPES = () + FUNCTION = "noop" + CATEGORY = "XB_ToolBox/Utils" + OUTPUT_NODE = False + + def noop(self): + """纯前端控制节点,执行时什么都不做。""" + return {} + + +class XB_DisableNodes: + """禁用节点:不接线,勾选节点(可按分组整组加入)后一键静音 / 绕过 / 恢复。""" + + DESCRIPTION = ( + "禁用节点(参考 rgthree 禁用节点):不接线,在面板或右键菜单里勾选要控制的节点(支持按分组整组加入);" + "点一行即可静音 / 绕过 / 恢复该节点。状态直接写在画布的节点上,随工作流保存。" + ) + + @classmethod + def INPUT_TYPES(cls): + return {"required": {}} + + RETURN_TYPES = () + FUNCTION = "noop" + CATEGORY = "XB_ToolBox/Utils" + OUTPUT_NODE = False + + def noop(self): + return {} + + +class XB_AnySwitch: + """切换任意:动态任意类型输入,直接输出第一个非空输入(无选择控件)。""" + + DESCRIPTION = ( + "切换任意(参考 rgthree 任意切换):把不同来源接到任意类型的输入端口,端口会随连线自动增加;" + "节点没有选择控件,永远输出从上往下第一个非空的输入。" + ) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": {}, + "optional": FlexibleOptionalInputType(any_type), + } + + RETURN_TYPES = (any_type,) + RETURN_NAMES = ("*",) + FUNCTION = "switch" + CATEGORY = "XB_ToolBox/Utils" + OUTPUT_NODE = False + + @staticmethod + def _items(kwargs): + items = [(k, v) for k, v in kwargs.items() if isinstance(k, str) and k.startswith("any_")] + items.sort(key=lambda kv: kv[0]) + return items + + @staticmethod + def _is_empty(value): + if value is None: + return True + # 上下文为空时的兜底(与 rgthree 一致) + if isinstance(value, dict) and "model" in value and "clip" in value: + return all(v is None for v in value.values()) + return False + + def switch(self, **kwargs): + """取第一个非空输入(与 rgthree Any Switch 同行为)。""" + for _k, value in self._items(kwargs): + if not self._is_empty(value): + return (value,) + return (None,) + + +NODE_CLASS_MAPPINGS = { + "XB_DisableGroups": XB_DisableGroups, + "XB_DisableNodes": XB_DisableNodes, + "XB_AnySwitch": XB_AnySwitch, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "XB_DisableGroups": "XB-BOX - 🚫 禁用分组", + "XB_DisableNodes": "XB-BOX - 🚫 禁用节点", + "XB_AnySwitch": "XB-BOX - 🔀 切换任意", +} diff --git a/nodes_video.py b/nodes_video.py index 59e52f4..80c29da 100644 --- a/nodes_video.py +++ b/nodes_video.py @@ -15,6 +15,96 @@ import nodes from .xb_av import open_av +# ============================================================ +# 画幅比例 / 倍数 公共逻辑 +# 画幅比例 = 自定义 + 官方 Resolution Selector 的比例; +# 倍数(multiple)约束宽高:既作为调节步长,也保证宽高能整除。 +# ============================================================ +XB_ASPECT_RATIOS = [ + ("自定义", None), + ("1:1 (Square)", (1, 1)), + ("2:3 (Portrait Photo)", (2, 3)), + ("3:2 (Photo)", (3, 2)), + ("3:4 (Portrait Standard)", (3, 4)), + ("4:3 (Standard)", (4, 3)), + ("9:16 (Portrait Widescreen)", (9, 16)), + ("16:9 (Widescreen)", (16, 9)), + ("21:9 (Ultrawide)", (21, 9)), +] +XB_ASPECT_VALUES = [name for name, _ in XB_ASPECT_RATIOS] +XB_ASPECT_MAP = {name: r for name, r in XB_ASPECT_RATIOS if r} + +# 旧工作流里的值 → 新值(保证老图照常开) +XB_ASPECT_LEGACY = { + "Free": "自定义", + "1:1": "1:1 (Square)", + "2:3": "2:3 (Portrait Photo)", + "3:2": "3:2 (Photo)", + "3:4": "3:4 (Portrait Standard)", + "4:3": "4:3 (Standard)", + "9:16": "9:16 (Portrait Widescreen)", + "16:9": "16:9 (Widescreen)", + "21:9": "21:9 (Ultrawide)", +} + +XB_MULTIPLE_DEFAULT = 16 + + +def xb_multiple(value, default=XB_MULTIPLE_DEFAULT): + """把「倍数」规整成 >= 8 的整数。""" + try: + m = int(round(float(value))) + except Exception: + m = default + return max(8, m) + + +def xb_snap(value, m): + """吸附到 m 的整数倍(并保证 >= m)。""" + try: + v = int(round(float(value))) + except Exception: + v = m + return max(m, int(round(v / m)) * m) + + +def xb_aspect_ratio(name): + """画幅比例 → 宽高比(宽/高);自定义 / 未知返回 None。""" + if name in XB_ASPECT_MAP: + w, h = XB_ASPECT_MAP[name] + return w / h + name = XB_ASPECT_LEGACY.get(name, name) + if name in XB_ASPECT_MAP: + w, h = XB_ASPECT_MAP[name] + return w / h + # 兼容 "16:9 (LTX)" 这类旧写法 + for key, ratio in XB_ASPECT_MAP.items(): + if name and name.startswith(key.split(" ")[0]): + w, h = ratio + return w / h + return None + + +def xb_fit_size(name, width, height, m): + """按比例 + 倍数算出最终宽高(两边都能被 m 整除)。""" + w = xb_snap(width, m) + h = xb_snap(height, m) + ratio = xb_aspect_ratio(name) + if ratio: + if w >= h: + h = xb_snap(w / ratio, m) + else: + w = xb_snap(h * ratio, m) + return max(m, w), max(m, h) + + +def xb_multiple_input(): + """「倍数」控件定义(放在最下面)。""" + return ("INT", { + "default": XB_MULTIPLE_DEFAULT, "min": 8, "max": 128, "step": 4, + "tooltip": "宽高必须是它的整数倍;调节分辨率时也按这个步长走(默认 16)。", + }) + # ============================================================ # XB_ImageParamsMaster — 图像参数主控 # ============================================================ @@ -23,12 +113,13 @@ class XB_ImageParamsMaster: def INPUT_TYPES(cls): return { "required": { - "aspect_ratio": (["Free", "1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "2:3", "3:2"], {"default": "Free"}), + "aspect_ratio": (XB_ASPECT_VALUES, {"default": "自定义"}), "width": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 16}), "height": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 16}), "batch_size": ("INT", {"default": 1, "min": 1, "max": 1000, "step": 1}), "strength_float": ("FLOAT", {"default": 1.00, "min": 0.00, "max": 10.00, "step": 0.01}), "strength_int": ("INT", {"default": 1, "min": 0, "max": 1000, "step": 1}), + "multiple": xb_multiple_input(), } } @@ -37,22 +128,9 @@ class XB_ImageParamsMaster: FUNCTION = "process" CATEGORY = "XB_ToolBox/Image_Params" - def process(self, aspect_ratio, width, height, batch_size, strength_float, strength_int): - if "Free" in aspect_ratio: - return (width, height, batch_size, float(strength_float), int(strength_int), max(width, height)) - - # 解析宽高比约束,使用 round() 而非 // 避免累积误差 - ratio_map = {"1:1": 1.0, "16:9": 16/9, "9:16": 9/16, "4:3": 4/3, "3:4": 3/4, "21:9": 21/9, "2:3": 2/3, "3:2": 3/2} - target_ratio = ratio_map.get(aspect_ratio, 1.0) - step = 16 - - if width >= height: - safe_w = max(step, round(width / step) * step) - safe_h = max(step, round((safe_w / target_ratio) / step) * step) - else: - safe_h = max(step, round(height / step) * step) - safe_w = max(step, round((safe_h * target_ratio) / step) * step) - + def process(self, aspect_ratio, width, height, batch_size, strength_float, strength_int, multiple=XB_MULTIPLE_DEFAULT): + m = xb_multiple(multiple) + safe_w, safe_h = xb_fit_size(aspect_ratio, width, height, m) return (safe_w, safe_h, batch_size, float(strength_float), int(strength_int), max(safe_w, safe_h)) # ============================================================ @@ -65,10 +143,11 @@ class XB_ImageParamsMasterMini: def INPUT_TYPES(cls): return { "required": { - "aspect_ratio": (["Free", "1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "2:3", "3:2"], {"default": "Free"}), + "aspect_ratio": (XB_ASPECT_VALUES, {"default": "自定义"}), "width": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 16}), "height": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 16}), "batch_size": ("INT", {"default": 1, "min": 1, "max": 1000, "step": 1}), + "multiple": xb_multiple_input(), } } @@ -77,22 +156,9 @@ class XB_ImageParamsMasterMini: FUNCTION = "process" CATEGORY = "XB_ToolBox/Image_Params" - def process(self, aspect_ratio, width, height, batch_size): - if "Free" in aspect_ratio: - return (width, height, batch_size, max(width, height)) - - # 解析宽高比约束,使用 round() 而非 // 避免累积误差 - ratio_map = {"1:1": 1.0, "16:9": 16/9, "9:16": 9/16, "4:3": 4/3, "3:4": 3/4, "21:9": 21/9, "2:3": 2/3, "3:2": 3/2} - target_ratio = ratio_map.get(aspect_ratio, 1.0) - step = 16 - - if width >= height: - safe_w = max(step, round(width / step) * step) - safe_h = max(step, round((safe_w / target_ratio) / step) * step) - else: - safe_h = max(step, round(height / step) * step) - safe_w = max(step, round((safe_h * target_ratio) / step) * step) - + def process(self, aspect_ratio, width, height, batch_size, multiple=XB_MULTIPLE_DEFAULT): + m = xb_multiple(multiple) + safe_w, safe_h = xb_fit_size(aspect_ratio, width, height, m) return (safe_w, safe_h, batch_size, max(safe_w, safe_h)) # ============================================================ @@ -103,13 +169,14 @@ class XB_VideoParamsMaster: def INPUT_TYPES(cls): return { "required": { - "aspect_ratio": (["Free", "1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "2:3", "3:2", "16:9 (LTX)", "9:16 (LTX)", "4:3 (LTX)", "3:4 (LTX)"], {"default": "Free"}), + "aspect_ratio": (XB_ASPECT_VALUES, {"default": "自定义"}), "duration_display": ("STRING", {"default": "Video Duration: 0.00 s", "multiline": False}), "width": ("INT", {"default": 480, "min": 64, "max": 8192, "step": 16}), "height": ("INT", {"default": 832, "min": 64, "max": 8192, "step": 16}), "length": ("INT", {"default": 81, "min": 1, "max": 9999, "step": 1}), "fps": ("INT", {"default": 16, "min": 1, "max": 120, "step": 1}), "fps_float": ("FLOAT", {"default": 16.0, "min": 1.0, "max": 120.0, "step": 0.01}), + "multiple": xb_multiple_input(), } } @@ -118,36 +185,17 @@ class XB_VideoParamsMaster: FUNCTION = "process" CATEGORY = "XB_ToolBox/Image_Params" - def process(self, aspect_ratio, duration_display, width, height, length, fps, fps_float): + def process(self, aspect_ratio, duration_display, width, height, length, fps, fps_float, + multiple=XB_MULTIPLE_DEFAULT): final_fps = int(round(fps)) + m = xb_multiple(multiple) + safe_w, safe_h = xb_fit_size(aspect_ratio, width, height, m) - if "Free" in aspect_ratio: - return (width, height, length, final_fps, float(final_fps), max(width, height)) - - if "LTX" in aspect_ratio: - # LTX 模式使用 step=32 - step = 32 - ratio_map = {"16:9 (LTX)": 16/9, "9:16 (LTX)": 9/16, "4:3 (LTX)": 4/3, "3:4 (LTX)": 3/4} - target_ratio = ratio_map.get(aspect_ratio, 16/9) - if width >= height: - safe_w = max(step, round(width / step) * step) - safe_h = max(step, round((safe_w / target_ratio) / step) * step) - else: - safe_h = max(step, round(height / step) * step) - safe_w = max(step, round((safe_h * target_ratio) / step) * step) + is_free = xb_aspect_ratio(aspect_ratio) is None + if is_free: + safe_len = max(1, int(length)) else: - # 解析宽高比并强制约束,使用 round() 避免累积误差 - step = 16 - ratio_map = {"16:9": 16/9, "9:16": 9/16, "1:1": 1.0, "4:3": 4/3, "3:4": 3/4, "21:9": 21/9, "2:3": 2/3, "3:2": 3/2} - target_ratio = ratio_map.get(aspect_ratio, 1.0) - if width >= height: - safe_w = max(step, round(width / step) * step) - safe_h = max(step, round((safe_w / target_ratio) / step) * step) - else: - safe_h = max(step, round(height / step) * step) - safe_w = max(step, round((safe_h * target_ratio) / step) * step) - - safe_len = max(1, round((length - 1) / 8) * 8 + 1) + safe_len = max(1, round((length - 1) / 8) * 8 + 1) return (safe_w, safe_h, safe_len, final_fps, float(final_fps), max(safe_w, safe_h))