新增节点

This commit is contained in:
wjluoxiao
2026-10-21 02:00:36 +08:00
parent 9f523c3afa
commit 1cae034790
9 changed files with 1947 additions and 390 deletions
+19 -1
View File
@@ -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")
+1 -2
View File
@@ -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")
+1139
View File
File diff suppressed because it is too large Load Diff
+221 -176
View File
@@ -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 + 原生 <select>,
* 两种渲染器下都可靠,也不受上游全静音影响(选项只认连线,不认 mode)。
*/
import { app } from "../../scripts/app.js";
import { setWidgetHidden } from "./xb_compat.js";
const NODE_TYPE = "XB_ReferenceAny";
const COMBO_WIDGET_NAME = "选择";
const WNAME = "选择";
const ALL_OFF = "全部关闭";
const MODE_ACTIVE = 0;
const MODE_MUTE = 2;
const PANEL_H = 32;
// ── helpers ────────────────────────────────────────────────────────────
/** 顺着连线(含 Reroute)找真正的上游节点 */
function getUpstreamNode(self, inputIndex) {
const graph = self.graph;
if (!graph) return null;
@@ -44,29 +51,38 @@ function getUpstreamNode(self, inputIndex) {
return null;
}
/** Check if an input is the control input (not a dynamic data input). */
function isControlInput(inp) {
return inp.name === COMBO_WIDGET_NAME;
/** 「选择」是控制端口(可以是隐藏 widget 自己占的槽位) */
function isValuePort(self, inp) {
if (!inp) return true;
if (inp.widget) return inp.name === WNAME;
return inp.name === WNAME;
}
/** Collect all connected upstream node info (skips control input). */
/** 所有已连线的上游节点:[{ index, node, title }] */
function getLinkedNodes(self) {
const list = [];
for (let i = 0; i < (self.inputs?.length || 0); i++) {
const inp = self.inputs[i];
if (!inp.link || isControlInput(inp)) continue;
if (!inp || inp.link == null || isValuePort(self, inp)) continue;
const up = getUpstreamNode(self, i);
if (up) list.push({ index: i, node: up, title: up.title || `输入${i + 1}` });
if (up) list.push({ index: i, node: up, title: up.title || up.type || `输入${i + 1}` });
}
return list;
}
/** 数据槽(排除「选择」控制槽与 widget 槽) */
function dataSlots(self) {
return (self.inputs || [])
.map((inp, i) => ({ inp, i }))
.filter((x) => x.inp && !isValuePort(self, x.inp));
}
/**
* 反向嗅探:顺 "选择" 端口连接找到上游节点,读取其第一个 string 类型 widget 的值。
* 反向嗅探:顺「选择」端口找到上游节点,读取它第一个 string 类型 widget 的值。
* 支持 String/Text 原语节点在编辑期间实时嗅探。
*/
function sniffControlString(self) {
const input = (self.inputs || []).find(inp => inp.name === COMBO_WIDGET_NAME);
const input = (self.inputs || []).find((inp) => inp && inp.name === WNAME && inp.link != null);
if (!input?.link) return null;
const graph = self.graph;
if (!graph) return null;
@@ -74,13 +90,26 @@ function sniffControlString(self) {
if (!link) return null;
const src = graph.getNodeById?.(link.origin_id);
if (!src) return null;
// Read widget values from source (works for String/Text primitives)
for (const w of (src.widgets || [])) {
if (w.value != null && typeof w.value === 'string' && w.value !== '') return w.value;
if (typeof w.value === "string" && w.value !== "") return w.value;
}
return null;
}
/** 用嗅探到的字符串匹配一个选项(包含匹配,取最长命中) */
function matchOption(text, values) {
if (typeof text !== "string" || !text) return ALL_OFF;
const lower = text.toLowerCase();
let best = null;
for (const v of values) {
if (v === ALL_OFF) continue;
if (lower.includes(String(v).toLowerCase())) {
if (!best || String(v).length > best.length) best = String(v);
}
}
return best || ALL_OFF;
}
// ── extension ──────────────────────────────────────────────────────────
app.registerExtension({
@@ -97,186 +126,195 @@ app.registerExtension({
},
async loadedGraphNode(node) {
if (node.type === NODE_TYPE) setTimeout(() => patchInstance(node), 200);
if (node?.type === NODE_TYPE) setTimeout(() => patchInstance(node), 200);
},
});
// ── instance patching ──────────────────────────────────────────────────
function patchInstance(self) {
if (self.__xb_patched) return;
self.__xb_patched = true;
if (self.__xbRefReady) return;
self.__xbRefReady = true;
// Replace the Python-created STRING widget with a combo widget (same name, keeps defaultInput port working)
const strIdx = (self.widgets || []).findIndex(w => w.name === COMBO_WIDGET_NAME && w.type !== "combo");
if (strIdx >= 0) {
const oldW = self.widgets[strIdx];
const savedValue = oldW.value || "";
self.widgets.splice(strIdx, 1);
const combo = self.addWidget("combo", COMBO_WIDGET_NAME, savedValue, () => {}, { values: [] });
// Move it to the same position
const newIdx = self.widgets.indexOf(combo);
if (newIdx > strIdx) {
self.widgets.splice(newIdx, 1);
self.widgets.splice(strIdx, 0, combo);
}
const valueWidget = () => (self.widgets || []).find((w) => w.name === WNAME);
// 「选择」的原生 STRING widget 只作为取值 / 端口载体,界面上藏掉,用下面的下拉代替
const hideValueWidget = () => {
const w = valueWidget();
if (!w || w.__xbHidden) return;
w.__xbHidden = true;
try { setWidgetHidden(self, w, true); } catch (_) { /* ignore */ }
};
hideValueWidget();
// 至少留一个空数据槽
if (!dataSlots(self).length) self.addInput("", "*");
// ── 面板:一个原生 <select> ───────────────────────────────────────
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);
}
+208 -136
View File
@@ -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;
}
+61 -13
View File
@@ -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"
}
}
}
}
+31
View File
@@ -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": "任意类型"
}
}
}
}
+157
View File
@@ -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 - 🔀 切换任意",
}
+110 -62
View File
@@ -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))