LTX数字人

This commit is contained in:
AI_Architect
2026-07-26 10:28:34 +08:00
parent 786c2ef0f8
commit 350e52cc13
10 changed files with 1614 additions and 20 deletions
+8
View File
@@ -61,6 +61,7 @@ try:
from .nodes_wan_vae import (XB_WanImageToVideo, XB_WanFirstLastFrameToVideo, XB_WanSoundImageToVideo, XB_WanFunControlToVideo, XB_WanVaceToVideo, XB_Wan22FunControlToVideo, XB_WanInfiniteTalkToVideo, XB_WanInfiniteTalkToVideo_Single, XB_WanInfiniteTalkToVideo_Dual, XB_WanVAEDecodeTiled, XB_WanFunInpaintToVideo, XB_WanCameraImageToVideo, XB_WanPhantomSubjectToVideo, XB_WanHuMoImageToVideo, XB_Wan22ImageToVideoLatent, XB_WanSoundImageToVideoExtend, XB_WanSCAILToVideo, XB_WanSCAILToVideoPro, XB_WanDancerVideo, XB_WanDanceSwitcher, XB_WanDancerCombo, XB_BerniniConditioning)
from .nodes_batch import XB_BatchFolderLoader
from .nodes_pipeline import XB_Wan_ParamBus, XB_Wan_RelayNode, XB_Wan_InfiniteRelayNode, XB_Video_Merger, XB_StoryboardSlicer,XB_WanAnimate_ParamBus,XB_WanAnimate_RelayNode, XB_WanInfiniteTalk_ParamBus, XB_WanInfiniteTalk_RelayNode, XB_Wan_InfiniteRelayNode_New, XB_WanAnimate_RelayNode_New, XB_WanInfiniteTalk_RelayNode_New, XB_WanSCAIL_ParamBus_New, XB_WanSCAIL_RelayNode_New, XB_WanInfiniteTalk_RelayNode_MultiRef, XB_WanInfiniteTalk_RelayNode_AllInOne
from .nodes_ltx_streamer import XB_LTX23_InfiniteStreamer
from .nodes_sageatt import XB_SageAttentionAccelerator
from .nodes_wan_t5 import XB_WanT5Loader
from .nodes_wan import (XB_WanCompileSettings, XB_WanModelLoader, XB_WanBlockSwap,
@@ -69,6 +70,7 @@ try:
from .nodes_label import XB_CanvasLabel
from .nodes_audio_slicer import XB_AudioSlicer, XB_AudioSlicerV1, XB_AudioSlicerV2, XB_AudioSlicerV3
from .nodes_digital_human import XB_DigitalHumanParams_Single, XB_DigitalHumanParams_Dual
from .nodes_segmentation import XB_HumanSegModelLoader, XB_HumanSegmentation
# 注册视频转码端点(预览窗口实时缩放依赖)
@@ -208,6 +210,7 @@ try:
"XB_WanSCAIL_RelayNode_New": XB_WanSCAIL_RelayNode_New,
"XB_WanInfiniteTalk_RelayNode_MultiRef": XB_WanInfiniteTalk_RelayNode_MultiRef,
"XB_WanInfiniteTalk_RelayNode_AllInOne": XB_WanInfiniteTalk_RelayNode_AllInOne,
"XB_LTX23_InfiniteStreamer": XB_LTX23_InfiniteStreamer,
"XB_HumanSegmentation": XB_HumanSegmentation,
"XB_HumanSegModelLoader": XB_HumanSegModelLoader,
"XB_CanvasLabel": XB_CanvasLabel,
@@ -215,6 +218,8 @@ try:
"XB_AudioSlicerV1": XB_AudioSlicerV1,
"XB_AudioSlicerV2": XB_AudioSlicerV2,
"XB_AudioSlicerV3": XB_AudioSlicerV3,
"XB_DigitalHumanParams_Single": XB_DigitalHumanParams_Single,
"XB_DigitalHumanParams_Dual": XB_DigitalHumanParams_Dual,
"XB_StringMerge": XB_StringMerge,
"XB_MSR": XB_MSR,
"XB_ComicPromptParser": XB_ComicPromptParser,
@@ -329,6 +334,7 @@ try:
"XB_WanSCAIL_RelayNode_New": "XB-BOX - 🆕 SCAIL 无限接力点 (New)",
"XB_WanInfiniteTalk_RelayNode_MultiRef": "XB-BOX - 🆕 InfiniteTalk 无限接力点 (多图)",
"XB_WanInfiniteTalk_RelayNode_AllInOne": "XB-BOX - 🆕 InfiniteTalk 无限接力点 (全能)",
"XB_LTX23_InfiniteStreamer": "XB-BOX - 🔄 LTX2.3 无限时长 (8步 + 302拼接)",
"XB_HumanSegmentation": "XB-BOX - ✂️ 人物分割 (DirectML/ROCm)",
"XB_HumanSegModelLoader": "XB-BOX - 📥 人物分割模型加载",
"XB_CanvasLabel": "XB-BOX - 🏷️ Canvas Label (文字标签)",
@@ -336,6 +342,8 @@ try:
"XB_AudioSlicerV1": "XB-BOX - 🎵 音频切片V1(单人)",
"XB_AudioSlicerV2": "XB-BOX - 🎵 音频切片V2(双人)",
"XB_AudioSlicerV3": "XB-BOX - 🎵 音频切片V3(高级)",
"XB_DigitalHumanParams_Single": "XB-BOX - 🤖 数字人参数调节(单人)",
"XB_DigitalHumanParams_Dual": "XB-BOX - 🤖 数字人参数调节(双人)",
"XB_WanInfiniteTalkToVideo": "XB-BOX - 🎵 语音转视频分块",
"XB_WanInfiniteTalkToVideo_Single": "XB-BOX - 🎵 语音转视频分块(单人)",
"XB_WanInfiniteTalkToVideo_Dual": "XB-BOX - 🎵 语音转视频分块(双人)",
+11 -1
View File
@@ -75,6 +75,15 @@
### 必备依赖
**第一步:安装 .NET 运行时(AMD GPU 必需)**
下载并安装 Windows Desktop Runtime 10.0.10:
```
https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.10/windowsdesktop-runtime-10.0.10-win-x64.exe
```
**第二步:安装 Python 依赖**
```bash
pip install psutil pythonnet
```
@@ -83,6 +92,7 @@ pip install psutil pythonnet
|------|------|------|
| `psutil` | CPU/内存/磁盘/进程信息 | ✅ 是 |
| `pythonnet` | .NET 桥接,驱动 LibreHardwareMonitor | ✅ 是 |
| `.NET Windows Desktop Runtime 10.0.10` | pythonnet 运行所需 | ✅ 是 |
| `LibreHardwareMonitorLib.dll` | GPU 传感器读取(已内置) | ✅ 是 |
| `nvidia-ml-py` | NVIDIA GPU 精准显存数据 | ⚠️ 可选 |
@@ -398,7 +408,7 @@ A: 检查 ComfyUI 控制台是否有 JavaScript 报错。常见原因:
A:
- **NVIDIA**:安装 `nvidia-ml-py` 并重启
- **AMD**:确认 `pythonnet` 已安装,查看控制台日志
- **AMD**:确认 `pythonnet` 已安装且 .NET Windows Desktop Runtime 10.0.10 已安装,查看控制台日志
### Q: 面板太大/太小?
+457
View File
@@ -0,0 +1,457 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// ============================================================
// XB_DigitalHumanParams_Dual — 数字人参数调节(双人)
// 融合 视频参数大全 + 音频切片V3 的 UI 交互
// ============================================================
const PAD = 10, HANDLE_HIT = 12;
const xb_dispatch = (w, val) => {
if (w.inputEl) {
w.inputEl.value = val;
w.inputEl.dispatchEvent(new Event("input", { bubbles: true }));
} else if (w.element) {
w.element.value = val;
w.element.dispatchEvent(new Event("input", { bubbles: true }));
} else if (w.callback) {
w.callback(val);
}
};
app.registerExtension({
name: "XB_ToolBox.DigitalHumanParams_Dual",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "XB_DigitalHumanParams_Dual") return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) onNodeCreated.apply(this, arguments);
const node = this;
// ── 控件引用 ──
const w = {};
["audio1", "start1", "end1", "mute_count1", "mutes1_data",
"audio2", "start2", "end2", "mute_count2", "mutes2_data",
"merge_mode", "total_display"].forEach(n => { w[n] = node.widgets.find(x => x.name === n); });
const wR = node.widgets.find(x => x.name === "aspect_ratio");
const wW = node.widgets.find(x => x.name === "width");
const wH = node.widgets.find(x => x.name === "height");
const wFps = node.widgets.find(x => x.name === "fps");
const wFF = node.widgets.find(x => x.name === "fps_float");
if (!w.audio1 || !w.audio2) return;
// ── 画幅联动(来自 xb_video.js)──
if (wR && wW && wH) {
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 };
const rL = { "16:9 (LTX)": 16 / 9, "9:16 (LTX)": 9 / 16, "4:3 (LTX)": 4 / 3, "3:4 (LTX)": 3 / 4 };
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 wv = Math.round((parseInt(wW.value, 10) || s) / s) * s; wv = Math.max(s, wv);
let hv = Math.round((wv / cr) / s) * s; hv = Math.max(s, hv);
if (hv !== parseInt(wH.value, 10)) { node._xb_syncing = true; wH.value = hv; xb_dispatch(wH, hv); 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 hv = Math.round((parseInt(wH.value, 10) || s) / s) * s; hv = Math.max(s, hv);
let wv = Math.round((hv * cr) / s) * s; wv = Math.max(s, wv);
if (wv !== parseInt(wW.value, 10)) { node._xb_syncing = true; wW.value = wv; xb_dispatch(wW, wv); 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(); };
}
// ── 帧率联动 ──
if (wFps && wFF) {
const of = wFps.callback; wFps.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 (wFps.value !== val) { node._xb_syncing = true; wFps.value = val; xb_dispatch(wFps, val); node._xb_syncing = false; } };
}
// ── 隐藏隐形总线 ──
[w.mutes1_data, w.mutes2_data].forEach(wd => { if (wd) { wd.type = "hidden"; wd.computeSize = () => [0, -4]; } });
if (w.total_display) setTimeout(() => { const el = w.total_display.inputEl || w.total_display.element; if (el) { el.readOnly = true; el.style.cssText = "background-color:#1a1a1a;color:#00E676;text-align:center;font-weight:bold;font-size:14px;border:none;"; } }, 200);
// ── 轨道状态机(来自 xb_audio_slicer_v3.js)──
const tracks = {
1: { wA: w.audio1, wS: w.start1, wE: w.end1, wMC: w.mute_count1, wMD: w.mutes1_data, peaks: null, dur: 10, cvs: null, ctx: null, ael: null, mutes: [], drag: null, fetchId: 0, vpRange: null },
2: { wA: w.audio2, wS: w.start2, wE: w.end2, wMC: w.mute_count2, wMD: w.mutes2_data, peaks: null, dur: 10, cvs: null, ctx: null, ael: null, mutes: [], drag: null, fetchId: 0, vpRange: null }
};
const dpr = window.devicePixelRatio || 1;
const updateTot = () => {
if (!w.total_display) return;
const fps = parseFloat(wFps?.value) || 24;
const d1 = Math.max(0, (parseFloat(w.end1?.value) || 0) - (parseFloat(w.start1?.value) || 0));
const d2 = Math.max(0, (parseFloat(w.end2?.value) || 0) - (parseFloat(w.start2?.value) || 0));
const mode = w.merge_mode?.value || "接力";
const rf1 = Math.round(d1 * fps), rf2 = Math.round(d2 * fps);
const rawTotal = mode === "接力" ? rf1 + rf2 : Math.max(rf1, rf2);
const totF = Math.max(1, Math.floor((rawTotal + 2) / 4) * 4 + 1);
const totS = mode === "接力" ? d1 + d2 : Math.max(d1, d2);
w.total_display.value = `${totF} 帧 (${totS.toFixed(2)}s)`;
};
const parseMutes = (t) => {
const tr = tracks[t], str = (tr.wMD?.value || "").trim();
tr.mutes = str ? str.split(";").filter(x => x).map(m => { const [s, e] = m.split(","); return { s: parseFloat(s) || 0, e: parseFloat(e) || 0 }; }) : [];
const count = parseInt(tr.wMC?.value) || 0;
while (tr.mutes.length < count) {
const s = parseFloat(tr.wS?.value) || 0, e = parseFloat(tr.wE?.value) || (s + 10);
const mid = (s + e) / 2, half = (e - s) * 0.1;
tr.mutes.push({ s: mid - half, e: mid + half });
}
while (tr.mutes.length > count) tr.mutes.pop();
if (tr.wMD) tr.wMD.value = tr.mutes.map(m => m.s.toFixed(3) + "," + m.e.toFixed(3)).join(";");
};
const fetchPeaks = async (filename, t) => {
if (!filename || filename === "none") { tracks[t].peaks = null; return; }
try {
const tr = tracks[t];
const currentFetchId = ++tr.fetchId;
const m = getMapper(tr, tr.cvs);
let payload = { filename, num_peaks: 400, start_time: m.sVis, end_time: m.eVis };
const resp = await api.fetchApi("/xb_audio_waveform", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
if (resp.ok) {
const d = await resp.json();
if (tr.fetchId !== currentFetchId) return;
tracks[t].peaks = d.peaks;
tracks[t].dur = d.duration || 10;
tracks[t].vpRange = { s: payload.start_time, e: payload.end_time };
}
} catch (e) { }
};
const ctr = document.createElement("div");
Object.assign(ctr.style, { display: "flex", flexDirection: "column", gap: "6px", width: "100%", padding: "6px", boxSizing: "border-box", background: "#161616", borderRadius: "6px" });
const mkUpload = (aw, label) => {
const fi = document.createElement("input"); fi.type = "file"; fi.accept = "audio/*,video/*"; fi.style.display = "none";
fi.onchange = async () => {
if (!fi.files.length) return;
const body = new FormData(); body.append("image", fi.files[0]);
const resp = await api.fetchApi("/upload/image", { method: "POST", body });
if (resp.status === 200) { const d = await resp.json(); const n = d.name || fi.files[0].name; if (!aw.options.values.includes(n)) aw.options.values.push(n); aw.value = n; if (aw.callback) aw.callback(n); }
};
document.body.appendChild(fi);
const btn = node.addWidget("button", label, "image", () => { app.canvas.node_widget = null; fi.click(); });
if (btn.options) btn.options.serialize = false;
return fi;
};
mkUpload(w.audio1, "📁 上传音频1"); mkUpload(w.audio2, "📁 上传音频2");
[1, 2].forEach(t => {
const tr = tracks[t], color = t === 1 ? "#4FC3F7" : "#F44336", tag = t === 1 ? "🎵 轨道 1" : "🎵 轨道 2";
const lbl = document.createElement("div"); lbl.textContent = tag; lbl.style.cssText = `font-size:11px;color:${color};font-weight:bold;margin-top:4px;`; ctr.appendChild(lbl);
const ael = document.createElement("audio"); ael.controls = true; ael.style.cssText = "width:100%;height:30px;outline:none;"; ctr.appendChild(ael); tr.ael = ael;
const cvs = document.createElement("canvas"); cvs.style.cssText = "width:100%;height:64px;border-radius:4px;background:#0A0A0A;cursor:crosshair;display:block;";
cvs.width = 1200 * dpr; cvs.height = 128 * dpr; ctr.appendChild(cvs);
tr.cvs = cvs; tr.ctx = cvs.getContext("2d");
});
const domWidget = node.addDOMWidget("xb_dh_dual_ui", "custom", ctr);
domWidget.computeSize = () => [node.size[0] - 16, 280];
if (node.size[1] < 500) node.size[1] = 500;
const getMapper = (tr, cvs) => {
const s = parseFloat(tr.wS?.value) || 0, e = parseFloat(tr.wE?.value) || (s + 10), dur = tr.dur || 10;
const selW = Math.max(0.01, e - s);
const sVis = Math.max(0, s - 0.125 * selW), eVis = Math.min(dur, e + 0.125 * selW);
const visW = Math.max(0.01, eVis - sVis);
const cssW = cvs.width / dpr, wwCSS = cssW - PAD * 2;
return { s, e, dur, sVis, eVis, visW, cssW, wwCSS, toCSS: (t) => PAD + ((t - sVis) / visW) * wwCSS, toTime: (cssX) => sVis + ((cssX - PAD) / wwCSS) * visW };
};
const getMouseCSSX = (ev, cvs) => { const rect = cvs.getBoundingClientRect(); return (ev.clientX - rect.left) * ((cvs.width / dpr) / rect.width); };
const drawWave = (t) => {
const tr = tracks[t], ctx = tr.ctx;
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const W = tr.cvs.width / dpr, H = tr.cvs.height / dpr;
ctx.clearRect(0, 0, W, H); ctx.fillStyle = "#111"; ctx.fillRect(0, 0, W, H);
if (!tr.peaks || tr.peaks.length === 0) {
ctx.fillStyle = "#555"; ctx.font = "14px sans-serif";
ctx.fillText("分析波形中...", PAD, H / 2);
ctx.setTransform(1, 0, 0, 1, 0, 0); return;
}
const m = getMapper(tr, tr.cvs);
const mid = H / 2;
const vp = tr.peaks;
if (vp && vp.length > 1 && tr.vpRange) {
const localMax = Math.max(...vp.map(p => p[0]), 0.001);
const localMin = Math.min(...vp.map(p => p[1]), -0.001);
const absMax = Math.max(localMax, Math.abs(localMin));
const scale = (v) => (v / absMax) * (H * 0.45);
const rangeDur = tr.vpRange.e - tr.vpRange.s;
const pathActive = new Path2D(), pathInactive = new Path2D();
pathActive.moveTo(PAD, mid); pathInactive.moveTo(PAD, mid);
for (let i = 0; i < vp.length; i++) {
const tAt = tr.vpRange.s + (i / (vp.length - 1)) * rangeDur;
const x = m.toCSS(tAt), y = mid - scale(vp[i][0]);
if (tAt >= m.s && tAt <= m.e) pathActive.lineTo(x, y); else pathInactive.lineTo(x, y);
}
for (let i = vp.length - 1; i >= 0; i--) {
const tAt = tr.vpRange.s + (i / (vp.length - 1)) * rangeDur;
const x = m.toCSS(tAt), y = mid - scale(vp[i][1]);
if (tAt >= m.s && tAt <= m.e) pathActive.lineTo(x, y); else pathInactive.lineTo(x, y);
}
pathActive.closePath(); pathInactive.closePath();
ctx.fillStyle = "rgba(100,100,100,0.3)"; ctx.fill(pathInactive);
ctx.fillStyle = t === 1 ? "#4FC3F7" : "#F44336"; ctx.fill(pathActive);
}
tr.mutes.forEach(mt => {
if (mt.e > m.sVis && mt.s < m.eVis) {
const xS = m.toCSS(Math.max(m.sVis, mt.s)), xE = m.toCSS(Math.min(m.eVis, mt.e));
ctx.fillStyle = "rgba(100,100,100,0.65)";
ctx.fillRect(xS, 0, xE - xS, H);
ctx.fillStyle = "#9ca3af";
ctx.fillRect(xS, 0, 2, H); ctx.fillRect(xE - 2, 0, 2, H);
}
});
let gx = m.toCSS(m.s), rx = m.toCSS(m.e);
if (tr.drag && tr.drag.type === "start_line" && typeof tr.drag.tempX === "number") gx = tr.drag.tempX;
if (tr.drag && tr.drag.type === "end_line" && typeof tr.drag.tempX === "number") rx = tr.drag.tempX;
if (gx > rx) { const tmp = gx; gx = rx; rx = tmp; }
ctx.fillStyle = "rgba(0,0,0,0.65)"; ctx.fillRect(PAD, 0, gx - PAD, H); ctx.fillRect(rx, 0, W - PAD - rx, H);
const bc = t === 1 ? "#4CAF50" : "#FF9800";
ctx.fillStyle = bc; ctx.fillRect(gx - 2, 0, 4, H); ctx.fillRect(rx - 2, 0, 4, H);
if (tr.ael.readyState >= 1 && tr.ael.currentTime > 0) {
const px = m.toCSS(tr.ael.currentTime);
if (px > PAD && px < W - PAD) { ctx.fillStyle = "#FFF"; ctx.fillRect(px - 1, 0, 2, H); }
}
ctx.fillStyle = "#fff"; ctx.font = "bold 11px sans-serif";
ctx.fillText(m.s.toFixed(2) + "s", Math.max(PAD + 4, gx - 45), 18);
const ew = ctx.measureText(m.e.toFixed(2) + "s").width;
ctx.fillText(m.e.toFixed(2) + "s", Math.min(W - PAD - ew - 4, rx + 6), 18);
ctx.setTransform(1, 0, 0, 1, 0, 0);
};
[1, 2].forEach(t => {
const tr = tracks[t], cvs = tr.cvs;
cvs.onmousedown = (ev) => {
const mx = getMouseCSSX(ev, cvs), m = getMapper(tr, cvs);
const sx = m.toCSS(m.s), ex = m.toCSS(m.e);
tr.drag = null;
if (Math.abs(mx - sx) < HANDLE_HIT) { tr.drag = { type: "start_line" }; return; }
if (Math.abs(mx - ex) < HANDLE_HIT) { tr.drag = { type: "end_line" }; return; }
for (let i = 0; i < tr.mutes.length; i++) {
const xS = m.toCSS(tr.mutes[i].s), xE = m.toCSS(tr.mutes[i].e);
if (Math.abs(mx - xS) < HANDLE_HIT) { tr.drag = { type: "mute_s", i }; return; }
if (Math.abs(mx - xE) < HANDLE_HIT) { tr.drag = { type: "mute_e", i }; return; }
if (mx > xS && mx < xE) { tr.drag = { type: "mute_body", i, offset: m.toTime(mx) - tr.mutes[i].s }; return; }
}
if (!tr.drag) { const clickTime = Math.max(0, Math.min(tr.dur, m.toTime(mx))); if (tr.ael.readyState >= 1) tr.ael.currentTime = clickTime; }
};
cvs.onmousemove = (ev) => {
const mx = getMouseCSSX(ev, cvs), m = getMapper(tr, cvs);
if (tr.drag) {
cvs.style.cursor = tr.drag.type === "mute_body" ? "grabbing" : "ew-resize";
tr.drag.tempX = Math.max(PAD, Math.min(cvs.width / dpr - PAD, mx));
if (tr.drag.type === "mute_s") tr.mutes[tr.drag.i].s = Math.min(Math.max(0, m.toTime(mx)), tr.mutes[tr.drag.i].e - 0.02);
if (tr.drag.type === "mute_e") tr.mutes[tr.drag.i].e = Math.max(Math.min(m.dur, m.toTime(mx)), tr.mutes[tr.drag.i].s + 0.02);
if (tr.drag.type === "mute_body") {
const md = tr.mutes[tr.drag.i].e - tr.mutes[tr.drag.i].s;
tr.mutes[tr.drag.i].s = Math.max(0, Math.min(m.dur - md, m.toTime(mx) - tr.drag.offset));
tr.mutes[tr.drag.i].e = tr.mutes[tr.drag.i].s + md;
}
if (tr.drag.type.includes("mute")) { if (tr.wMD) tr.wMD.value = tr.mutes.map(mt => mt.s.toFixed(3) + "," + mt.e.toFixed(3)).join(";"); }
} else {
const sx = m.toCSS(m.s), ex = m.toCSS(m.e);
let cursor = "crosshair";
if (Math.abs(mx - sx) < HANDLE_HIT || Math.abs(mx - ex) < HANDLE_HIT) {
cursor = "ew-resize";
} else {
let inBody = false;
for (let i = 0; i < tr.mutes.length; i++) {
const xS = m.toCSS(tr.mutes[i].s), xE = m.toCSS(tr.mutes[i].e);
if (Math.abs(mx - xS) < HANDLE_HIT || Math.abs(mx - xE) < HANDLE_HIT) {
cursor = "ew-resize"; inBody = false; break;
} else if (mx > xS && mx < xE) {
inBody = true;
}
}
if (cursor !== "ew-resize" && inBody) cursor = "grab";
}
cvs.style.cursor = cursor;
}
};
});
window.addEventListener("mouseup", () => {
[1, 2].forEach(t => {
const tr = tracks[t];
if (tr.drag && (tr.drag.type === "start_line" || tr.drag.type === "end_line") && typeof tr.drag.tempX === "number") {
const m = getMapper(tr, tr.cvs), newVal = m.toTime(tr.drag.tempX);
const fps = parseFloat(wFps?.value) || 24, snap = v => Math.round(v * fps) / fps;
if (tr.drag.type === "start_line") tr.wS.value = snap(Math.max(0, Math.min(newVal, m.e - 0.1)));
if (tr.drag.type === "end_line") tr.wE.value = snap(Math.min(tr.dur, Math.max(newVal, m.s + 0.1)));
updateTot(); if (tr.ael.readyState >= 1) tr.ael.currentTime = parseFloat(tr.wS.value);
fetchPeaks(tr.wA?.value, t);
}
tr.drag = null;
});
});
const updateSrc = (t) => {
const tr = tracks[t], f = tr.wA?.value;
if (f && f !== "none") tr.ael.src = api.apiURL("/view?" + new URLSearchParams({ filename: f, type: "input", t: Date.now() }));
};
[1, 2].forEach(t => {
const tr = tracks[t];
if (tr.wA) { const o = tr.wA.callback; tr.wA.callback = function () { o?.apply(this, arguments); updateSrc(t); fetchPeaks(tr.wA.value, t).then(() => drawWave(t)); }; }
if (tr.wMC) { const o = tr.wMC.callback; tr.wMC.callback = function () { o?.apply(this, arguments); parseMutes(t); drawWave(t); }; }
if (tr.wS) { const o = tr.wS.callback; tr.wS.callback = function () { o?.apply(this, arguments); const val = parseFloat(tr.wS?.value) || 0; if (val < 0) tr.wS.value = 0; updateTot(); if (tr.ael.readyState >= 1) tr.ael.currentTime = parseFloat(tr.wS?.value) || 0; }; }
if (tr.wE) { const o = tr.wE.callback; tr.wE.callback = function () { o?.apply(this, arguments); const dur = tr.dur || 10; const val = parseFloat(tr.wE?.value) || 0; if (val > dur) { const fps = parseFloat(wFps?.value) || 24; tr.wE.value = Math.floor(dur * fps) / fps; } updateTot(); }; }
tr.ael.addEventListener("loadedmetadata", () => { tr.dur = tr.ael.duration; const fps = parseFloat(wFps?.value) || 24; const curStart = parseFloat(tr.wS?.value) || 0; const curEnd = parseFloat(tr.wE?.value) || 0; if (curStart === 0 && (curEnd <= 0 || curEnd > tr.dur + 1 || Math.abs(curEnd - 10.0) < 0.001)) { tr.wS.value = 0; tr.wE.value = Math.floor(tr.dur * fps) / fps; } updateTot(); fetchPeaks(tr.wA?.value, t); });
tr.ael.addEventListener("timeupdate", () => {
const s = parseFloat(tr.wS?.value) || 0, e = parseFloat(tr.wE?.value) || 10;
if (tr.ael.currentTime >= e) { tr.ael.pause(); tr.ael.currentTime = s; }
let isMute = false;
for (const m of tr.mutes) { if (tr.ael.currentTime >= m.s && tr.ael.currentTime < m.e) { isMute = true; break; } }
tr.ael.muted = isMute;
});
});
if (w.merge_mode) { const o = w.merge_mode.callback; w.merge_mode.callback = function () { o?.apply(this, arguments); updateTot(); }; }
let _running = true;
const renderLoop = () => {
if (!_running) return;
drawWave(1); drawWave(2);
requestAnimationFrame(renderLoop);
};
renderLoop();
setTimeout(() => { [1, 2].forEach(t => { parseMutes(t); updateSrc(t); if (tracks[t].wA?.value && tracks[t].wA.value !== "none") fetchPeaks(tracks[t].wA.value, t); }); updateTot(); }, 400);
const o2 = node.onRemoved;
node.onRemoved = () => { _running = false; o2?.apply(node); };
};
},
// ── init:轮询更新步长(来自 xb_video.js)──
init() {
setInterval(() => {
if (!app.graph || !app.graph._nodes) return;
for (const node of app.graph._nodes) {
if (node.comfyClass === "XB_DigitalHumanParams_Dual" && node.widgets) {
const wRatio = node.widgets.find(w => w.name === "aspect_ratio");
const wWidth = node.widgets.find(w => w.name === "width");
const wHeight = node.widgets.find(w => w.name === "height");
const wFps = node.widgets.find(w => w.name === "fps");
const wFF = node.widgets.find(w => w.name === "fps_float");
if (wRatio && wWidth && wHeight && wFps && wFF) {
node._xb_from_polling = true;
if (node._xb_last_fps === undefined) {
node._xb_last_fps = wFps.value;
node._xb_last_fps_float = wFF.value;
node._xb_last_width = parseInt(wWidth.value, 10) || 480;
node._xb_last_height = parseInt(wHeight.value, 10) || 832;
node._xb_last_ratio = wRatio.value;
}
let needsUpdate = false;
let valRatio = wRatio.value;
let isFree = valRatio.includes("Free");
let rChanged = valRatio !== node._xb_last_ratio;
let isLTX = valRatio.includes("(LTX)");
let currentStep = isFree ? 1 : (isLTX ? 32 : 16);
wWidth.options.step = currentStep; wHeight.options.step = 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 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 };
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); }
}
}
wWidth.value = wid; wHeight.value = hei;
node._xb_last_width = wid; node._xb_last_height = hei;
node._xb_last_ratio = valRatio;
xb_dispatch(wWidth, wid);
xb_dispatch(wHeight, hei);
needsUpdate = true;
}
if (wFps.value !== node._xb_last_fps) {
let val = Math.round(Number(wFps.value)); wFps.value = val; wFF.value = val;
node._xb_last_fps = val; node._xb_last_fps_float = val;
xb_dispatch(wFps, val); xb_dispatch(wFF, val);
needsUpdate = true;
} else if (wFF.value !== node._xb_last_fps_float) {
let val = Math.round(Number(wFF.value)); wFps.value = val; wFF.value = val;
node._xb_last_fps = val; node._xb_last_fps_float = val;
xb_dispatch(wFps, val); xb_dispatch(wFF, val);
needsUpdate = true;
}
wFF.options.precision = 2;
if (needsUpdate) app.graph.setDirtyCanvas(true, true);
node._xb_from_polling = false;
}
}
}
}, 200);
}
});
+376
View File
@@ -0,0 +1,376 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// ============================================================
// XB_DigitalHumanParams_Single — 数字人参数调节(单人)
// 融合 视频参数大全 + 音频切片V1 的 UI 交互
// ============================================================
const PAD = 10, HANDLE_HIT = 12;
const isZH = navigator.language.startsWith("zh");
const xb_dispatch = (w, val) => {
if (w.inputEl) {
w.inputEl.value = val;
w.inputEl.dispatchEvent(new Event("input", { bubbles: true }));
} else if (w.element) {
w.element.value = val;
w.element.dispatchEvent(new Event("input", { bubbles: true }));
} else if (w.callback) {
w.callback(val);
}
};
app.registerExtension({
name: "XB_ToolBox.DigitalHumanParams_Single",
// —— nodeCreated:画幅联动 + 音频上传 + 波形可视化 ——
async nodeCreated(node) {
if (node.comfyClass !== "XB_DigitalHumanParams_Single") return;
// ── 控件引用 ──
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 wAudio = node.widgets?.find(w => w.name === "audio");
const wStart = node.widgets?.find(w => w.name === "start_time");
const wEnd = node.widgets?.find(w => w.name === "end_time");
const wDur = node.widgets?.find(w => w.name === "duration_display");
// ── 画幅联动(来自 xb_video.js)──
if (wR && wW && wH) {
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 };
const rL = { "16:9 (LTX)": 16 / 9, "9:16 (LTX)": 9 / 16, "4:3 (LTX)": 4 / 3, "3:4 (LTX)": 3 / 4 };
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 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(); };
}
// ── 帧率联动 ──
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; } };
}
// ── duration_display 样式 ──
if (wDur) {
setTimeout(() => {
const el = wDur.inputEl || wDur.element;
if (el) {
el.readOnly = true;
el.style.cssText = "background-color:#1a1a1a;color:#00E676;text-align:center;font-weight:bold;font-size:15px;min-width:120px;white-space:nowrap;overflow:hidden;";
}
}, 100);
}
// ── 音频上传按钮(来自 xb_audio_slicer_v1.js)──
if (wAudio) {
const fi = document.createElement("input");
fi.type = "file"; fi.accept = "audio/*,video/*"; fi.style.display = "none";
fi.onchange = async () => {
if (!fi.files.length) return;
const body = new FormData(); body.append("image", fi.files[0]);
const resp = await api.fetchApi("/upload/image", { method: "POST", body });
if (resp.status === 200) {
const d = await resp.json(); const n = d.name || fi.files[0].name;
if (!wAudio.options.values.includes(n)) wAudio.options.values.push(n);
wAudio.value = n; if (wAudio.callback) wAudio.callback(n);
}
};
document.body.appendChild(fi);
const btn = node.addWidget("button", "选择音频上传", "image", () => { app.canvas.node_widget = null; fi.click(); });
btn.options.serialize = false;
const o1 = node.onRemoved; node.onRemoved = () => { fi?.remove(); o1?.apply(node); };
}
// ── 波形可视化 UI(来自 xb_audio_slicer_v1.js)──
const ctr = document.createElement("div");
Object.assign(ctr.style, { display: "flex", flexDirection: "column", gap: "6px", width: "100%", padding: "6px", boxSizing: "border-box", background: "#161616", borderRadius: "6px", color: "#ccc", fontFamily: "sans-serif", marginTop: "4px" });
const audioEl = document.createElement("audio");
audioEl.controls = true; audioEl.style.cssText = "width:100%;height:34px;outline:none;";
ctr.appendChild(audioEl);
const canvas = document.createElement("canvas");
canvas.style.cssText = "width:100%;height:40px;border-radius:4px;background:#111;cursor:pointer;display:block;";
const dpr = window.devicePixelRatio || 1;
canvas.width = 1200 * dpr; canvas.height = 80 * dpr;
ctr.appendChild(canvas);
const domWidget = node.addDOMWidget("xb_dh_single_ui", "custom", ctr);
domWidget.computeSize = () => [node.size[0] - 16, 100];
if (node.size[1] < 320) node.size[1] = 320;
let totalDur = 0, _lastFile = null, _pausing = false;
let peaks = null, drag = null;
const getDur = () => totalDur || 10;
const getS = () => parseFloat(wStart?.value) || 0;
const getE = () => { const e = parseFloat(wEnd?.value); return e > 0 ? e : getDur(); };
const mouseToCanvasX = (clientX) => { const rect = canvas.getBoundingClientRect(); return (clientX - rect.left) * (canvas.width / rect.width); };
const syncWidgets = () => {
let s = getS(), e = getE();
const fps = parseFloat(wF?.value) || 25, fDur = 1.0 / fps;
if (e < s + fDur) e = s + fDur;
const dur = getDur(); if (e > dur) e = dur;
if (wStart.value !== s) { wStart.value = s; if (wStart.inputEl) wStart.inputEl.value = s; if (wStart.element) wStart.element.value = s; }
if (wEnd.value !== e) { wEnd.value = e; if (wEnd.inputEl) wEnd.inputEl.value = e; if (wEnd.element) wEnd.element.value = e; }
if (wDur) {
const durSec = Math.max(0, e - s);
const frames = Math.max(1, Math.floor((Math.round(durSec * fps) + 2) / 4) * 4 + 1);
const txt = frames + " 帧 (" + durSec.toFixed(2) + "s)";
if (wDur.value !== txt) { wDur.value = txt; if (wDur.inputEl) wDur.inputEl.value = txt; if (wDur.element) wDur.element.value = txt; }
}
};
const visMapper = () => {
const dur = getDur(), s = getS(), e = getE(), selW = (e - s) || 0.01;
const sVis = Math.max(0, s - 0.125 * selW), eVis = Math.min(dur, e + 0.125 * selW);
const visW = (eVis - sVis) || 0.01, wwCSS = (canvas.width / dpr) - PAD * 2;
return { sVis, eVis, visW, wwCSS, toCSS: (time) => PAD + ((time - sVis) / visW) * wwCSS, toTime: (cssX) => sVis + ((cssX - PAD) / wwCSS) * visW };
};
const draw = () => {
const ctx = canvas.getContext("2d"), w = canvas.width, h = canvas.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const W = w / dpr, H = h / dpr;
ctx.clearRect(0, 0, W, H); ctx.fillStyle = "#111"; ctx.fillRect(0, 0, W, H);
const dur = getDur(), s = getS(), e = getE(), vm = visMapper(), ww = vm.wwCSS, mid = H / 2;
if (peaks && peaks.length > 1) {
const startIdx = Math.max(0, Math.floor((vm.sVis / dur) * peaks.length));
const endIdx = Math.min(peaks.length - 1, Math.ceil((vm.eVis / dur) * peaks.length));
const vp = peaks.slice(startIdx, endIdx + 1);
if (vp.length > 1) {
const DISPLAY_POINTS = Math.min(1000, Math.floor(ww * 1.5));
let maxPoints = [], minPoints = [];
const step = vp.length / DISPLAY_POINTS;
for (let i = 0; i < DISPLAY_POINTS; i++) {
const idx = i * step, lo = Math.floor(idx), hi = Math.min(lo + 1, vp.length - 1), frac = idx - lo;
maxPoints.push(vp[lo][0] * (1 - frac) + vp[hi][0] * frac); minPoints.push(vp[lo][1] * (1 - frac) + vp[hi][1] * frac);
}
const absMax = Math.max(Math.max(...maxPoints, 0.001), Math.abs(Math.min(...minPoints, -0.001)));
const scale = (v) => (v / absMax) * (H * 0.48);
const pathActive = new Path2D(), pathInactive = new Path2D();
pathActive.moveTo(PAD, mid); pathInactive.moveTo(PAD, mid);
for (let i = 0; i < DISPLAY_POINTS; i++) {
const x = PAD + (i / (DISPLAY_POINTS - 1)) * ww, tAt = vm.sVis + (i / (DISPLAY_POINTS - 1)) * vm.visW;
if (tAt >= s && tAt <= e) pathActive.lineTo(x, mid - scale(maxPoints[i])); else pathInactive.lineTo(x, mid - scale(maxPoints[i]));
}
for (let i = DISPLAY_POINTS - 1; i >= 0; i--) {
const x = PAD + (i / (DISPLAY_POINTS - 1)) * ww, tAt = vm.sVis + (i / (DISPLAY_POINTS - 1)) * vm.visW;
if (tAt >= s && tAt <= e) pathActive.lineTo(x, mid - scale(minPoints[i])); else pathInactive.lineTo(x, mid - scale(minPoints[i]));
}
pathActive.closePath(); pathInactive.closePath();
ctx.fillStyle = "rgba(100,100,100,0.3)"; ctx.fill(pathInactive); ctx.fillStyle = "#4FC3F7"; ctx.fill(pathActive);
}
}
let gx = vm.toCSS(s), rx = vm.toCSS(e);
if (drag && drag.type === "start_line" && typeof drag.tempX === "number") gx = drag.tempX;
if (drag && drag.type === "end_line" && typeof drag.tempX === "number") rx = drag.tempX;
if (gx > rx) { const tmp = gx; gx = rx; rx = tmp; }
ctx.fillStyle = "rgba(0,0,0,0.65)"; ctx.fillRect(PAD, 0, gx - PAD, H); ctx.fillRect(rx, 0, W - PAD - rx, H);
ctx.fillStyle = "#4CAF50"; ctx.fillRect(gx - 2, 0, 4, H);
ctx.fillStyle = "#F44336"; ctx.fillRect(rx - 2, 0, 4, H);
if (audioEl.readyState >= 1 && audioEl.currentTime > 0) {
const px = vm.toCSS(audioEl.currentTime);
if (px > PAD && px < W - PAD) { ctx.fillStyle = "#FFF"; ctx.fillRect(px - 1, 0, 2, H); }
}
ctx.fillStyle = "#fff"; ctx.font = "bold 11px sans-serif";
ctx.fillText(s.toFixed(2) + "s", Math.max(PAD + 4, gx - 45), 18);
const ewt = ctx.measureText(e.toFixed(2) + "s").width;
ctx.fillText(e.toFixed(2) + "s", Math.min(W - PAD - ewt - 4, rx + 6), 18);
ctx.setTransform(1, 0, 0, 1, 0, 0);
};
// ── 事件引擎 ──
canvas.onmousedown = (ev) => {
if (totalDur <= 0) return;
const mx = mouseToCanvasX(ev.clientX) / dpr, vm = visMapper();
const sx = vm.toCSS(getS()), ex = vm.toCSS(getE());
drag = null;
if (Math.abs(mx - sx) < HANDLE_HIT) { drag = { type: "start_line" }; return; }
if (Math.abs(mx - ex) < HANDLE_HIT) { drag = { type: "end_line" }; return; }
const clickTime = vm.toTime(mx);
if (audioEl.readyState >= 1) audioEl.currentTime = Math.max(0, Math.min(totalDur, clickTime));
};
window.addEventListener("mousemove", (ev) => {
const mx = mouseToCanvasX(ev.clientX) / dpr;
if (drag) {
drag.tempX = Math.max(PAD, Math.min(canvas.width / dpr - PAD, mx));
} else {
const rect = canvas.getBoundingClientRect();
if (ev.clientX >= rect.left && ev.clientX <= rect.right && ev.clientY >= rect.top && ev.clientY <= rect.bottom) {
const vm = visMapper(), sx = vm.toCSS(getS()), ex = vm.toCSS(getE());
canvas.style.cursor = (Math.abs(mx - sx) < HANDLE_HIT || Math.abs(mx - ex) < HANDLE_HIT) ? "ew-resize" : "pointer";
}
}
});
window.addEventListener("mouseup", () => {
if (drag && typeof drag.tempX === "number") {
const vm = visMapper(), newVal = vm.toTime(drag.tempX);
const fps = parseFloat(wF?.value) || 25, snap = v => Math.round(v * fps) / fps;
if (drag.type === "start_line") wStart.value = snap(Math.max(0, Math.min(newVal, getE() - 0.1)));
if (drag.type === "end_line") wEnd.value = snap(Math.min(totalDur, Math.max(newVal, getS() + 0.1)));
syncWidgets(); if (audioEl.readyState >= 1) audioEl.currentTime = getS();
}
drag = null;
});
// ── 音频核心逻辑 ──
const updateSrc = () => { const f = wAudio?.value; if (f && f !== "none" && f !== _lastFile) { _lastFile = f; totalDur = 0; audioEl.src = api.apiURL("/view?" + new URLSearchParams({ filename: f, type: "input", t: Date.now() })); } };
audioEl.addEventListener("loadedmetadata", () => { totalDur = audioEl.duration; const fps = parseFloat(wF?.value) || 25; const curStart = parseFloat(wStart?.value) || 0; const curEnd = parseFloat(wEnd?.value) || 0; if (curStart === 0 && (curEnd <= 0 || curEnd > totalDur + 1 || Math.abs(curEnd - 10.0) < 0.001)) { wStart.value = 0; wEnd.value = Math.floor(totalDur * fps) / fps; } syncWidgets(); audioEl.currentTime = parseFloat(wStart?.value) || 0; });
audioEl.addEventListener("play", () => { _pausing = false; const st = getS(), et = getE(); if (audioEl.currentTime < st || audioEl.currentTime >= et) audioEl.currentTime = st; });
audioEl.addEventListener("timeupdate", () => { if (_pausing) return; if (audioEl.currentTime >= getE()) { _pausing = true; audioEl.pause(); audioEl.currentTime = getS(); setTimeout(() => { _pausing = false; }, 100); } });
const fetchPeaks = async (filename) => {
if (!filename || filename === "none") { peaks = null; return; }
try { const resp = await api.fetchApi("/xb_toolbox/audio_waveform", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename, num_peaks: 4000 }) }); if (resp.ok) { const data = await resp.json(); peaks = data.peaks; totalDur = data.duration || 10; } } catch (e) { }
};
if (wAudio) { const orig = wAudio.callback; wAudio.callback = function () { orig?.apply(this, arguments); updateSrc(); fetchPeaks(wAudio.value).then(() => { const curStart = parseFloat(wStart?.value) || 0; const curEnd = parseFloat(wEnd?.value) || 0; if (curStart === 0 && (curEnd <= 0 || curEnd > totalDur + 1 || Math.abs(curEnd - 10.0) < 0.001)) { wStart.value = 0; if (totalDur > 0) { const fps = parseFloat(wF?.value) || 25; wEnd.value = Math.floor(totalDur * fps) / fps; } } syncWidgets(); }); }; }
const onTimeChange = (resetPlayback) => { let s = getS(), e = getE(), dur = getDur(); if (e < s + 0.01) { e = Math.min(s + 0.01, dur); wEnd.value = e; } syncWidgets(); if (resetPlayback && audioEl.readyState >= 1) audioEl.currentTime = s; };
if (wStart) { const orig = wStart.callback; wStart.callback = function () { orig?.apply(this, arguments); onTimeChange(true); }; }
if (wEnd) { const orig = wEnd.callback; wEnd.callback = function () { orig?.apply(this, arguments); onTimeChange(false); }; }
let _running = true;
const renderLoop = () => { if (!_running) return; if (totalDur > 0) { syncWidgets(); draw(); } requestAnimationFrame(renderLoop); };
renderLoop();
setTimeout(() => { updateSrc(); if (wAudio?.value && wAudio.value !== "none") fetchPeaks(wAudio.value); }, 400);
const o2 = node.onRemoved; node.onRemoved = () => { _running = false; o2?.apply(node); };
},
// ── init:轮询更新步长和时长显示(来自 xb_video.js)──
init() {
setInterval(() => {
if (!app.graph || !app.graph._nodes) return;
for (const node of app.graph._nodes) {
if (node.comfyClass === "XB_DigitalHumanParams_Single" && node.widgets) {
const wRatio = node.widgets.find(w => w.name === "aspect_ratio");
const wWidth = node.widgets.find(w => w.name === "width");
const wHeight = node.widgets.find(w => w.name === "height");
const wFps = node.widgets.find(w => w.name === "fps");
const wFpsF = node.widgets.find(w => w.name === "fps_float");
const wDur = node.widgets.find(w => w.name === "duration_display");
if (wRatio && wWidth && wHeight && wFps && wFpsF) {
node._xb_from_polling = true;
if (node._xb_last_fps === undefined) {
node._xb_last_fps = wFps.value;
node._xb_last_fps_float = wFpsF.value;
node._xb_last_width = parseInt(wWidth.value, 10) || 480;
node._xb_last_height = parseInt(wHeight.value, 10) || 832;
node._xb_last_ratio = wRatio.value;
}
let needsUpdate = false;
let valRatio = wRatio.value;
let isFree = valRatio.includes("Free");
let rChanged = valRatio !== node._xb_last_ratio;
let isLTX = valRatio.includes("(LTX)");
let currentStep = isFree ? 1 : (isLTX ? 32 : 16);
wWidth.options.step = currentStep; wHeight.options.step = 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 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 };
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); }
}
}
wWidth.value = wid; wHeight.value = hei;
node._xb_last_width = wid; node._xb_last_height = hei;
node._xb_last_ratio = valRatio;
xb_dispatch(wWidth, wid);
xb_dispatch(wHeight, hei);
needsUpdate = true;
}
if (wFps.value !== node._xb_last_fps) {
let val = Math.round(Number(wFps.value)); wFps.value = val; wFpsF.value = val;
node._xb_last_fps = val; node._xb_last_fps_float = val;
xb_dispatch(wFps, val); xb_dispatch(wFpsF, val);
needsUpdate = true;
} else if (wFpsF.value !== node._xb_last_fps_float) {
let val = Math.round(Number(wFpsF.value)); wFps.value = val; wFpsF.value = val;
node._xb_last_fps = val; node._xb_last_fps_float = val;
xb_dispatch(wFps, val); xb_dispatch(wFpsF, val);
needsUpdate = true;
}
wFpsF.options.precision = 2;
if (needsUpdate) app.graph.setDirtyCanvas(true, true);
node._xb_from_polling = false;
}
}
}
}, 200);
}
});
+2
View File
@@ -71,6 +71,8 @@ const SIZES = {
"XB_AudioSlicerV1": [360, 200],
"XB_AudioSlicerV2": [360, 200],
"XB_AudioSlicerV3": [360, 200],
"XB_DigitalHumanParams_Single": [360, 200],
"XB_DigitalHumanParams_Dual": [360, 200],
"XB_StringMerge": [360, 200],
"XB_MSR": [360, 200],
};
+207
View File
@@ -2073,6 +2073,140 @@
}
}
},
"XB_DigitalHumanParams_Single": {
"display_name": "XB-BOX - 🤖 数字人参数调节(单人)",
"inputs": {
"aspect_ratio": {
"name": "画幅比例"
},
"width": {
"name": "宽度"
},
"height": {
"name": "高度"
},
"fps": {
"name": "帧率整数"
},
"fps_float": {
"name": "帧率浮点"
},
"audio": {
"name": "音频文件"
},
"start_time": {
"name": "音频开始时间"
},
"end_time": {
"name": "音频结束时间"
},
"duration_display": {
"name": "输出总帧数"
}
},
"outputs": {
"0": {
"name": "宽度"
},
"1": {
"name": "高度"
},
"2": {
"name": "总帧数"
},
"3": {
"name": "帧率整数"
},
"4": {
"name": "帧率浮点"
},
"5": {
"name": "音频"
}
}
},
"XB_DigitalHumanParams_Dual": {
"display_name": "XB-BOX - 🤖 数字人参数调节(双人)",
"inputs": {
"aspect_ratio": {
"name": "画幅比例"
},
"width": {
"name": "宽度"
},
"height": {
"name": "高度"
},
"fps": {
"name": "帧率整数"
},
"fps_float": {
"name": "帧率浮点"
},
"audio1": {
"name": "音频文件1"
},
"start1": {
"name": "音频1开始时间"
},
"end1": {
"name": "音频1结束时间"
},
"mute_count1": {
"name": "音频1消音数量"
},
"mutes1_data": {
"name": "消音数据1"
},
"audio2": {
"name": "音频文件2"
},
"start2": {
"name": "音频2开始时间"
},
"end2": {
"name": "音频2结束时间"
},
"mute_count2": {
"name": "音频2消音数量"
},
"mutes2_data": {
"name": "消音数据2"
},
"merge_mode": {
"name": "合并模式"
},
"total_display": {
"name": "合并总帧数"
}
},
"outputs": {
"0": {
"name": "宽度"
},
"1": {
"name": "高度"
},
"2": {
"name": "总帧数"
},
"3": {
"name": "帧率整数"
},
"4": {
"name": "帧率浮点"
},
"5": {
"name": "音频1"
},
"6": {
"name": "音频2"
},
"7": {
"name": "合并音频"
}
}
},
"XB_WanInfiniteTalk_ParamBus": {
"display_name": "XB-BOX - 🎵 InfiniteTalk 无限对口型总线(单人)",
"inputs": {
@@ -4679,5 +4813,78 @@
"name": "任意输入(可选)"
}
}
},
"XB_LTX23_InfiniteStreamer": {
"display_name": "XB-BOX - 🔄 LTX2.3 无限时长 (8+3步双次采样)",
"inputs": {
"model": { "name": "模型" },
"video_vae": { "name": "视频 VAE" },
"audio_vae": { "name": "音频 VAE" },
"clip": { "name": "CLIP" },
"latent_upscale_model": { "name": "潜空间放大模型" },
"start_image": { "name": "参考图" },
"width": { "name": "宽度" },
"height": { "name": "高度" },
"total_frames": { "name": "总帧数" },
"fps": { "name": "帧率" },
"audio": { "name": "音频" },
"segment_frames": { "name": "每段生成帧数" },
"overlap_frames": { "name": "接力重叠帧数(建议8的倍数)" },
"positive_prompt": { "name": "正面提示词" },
"negative_prompt": { "name": "负面提示词" },
"seed": { "name": "随机种子" },
"sampler_gen": { "name": "低分辨率采样器(8步)" },
"sampler_upscale": { "name": "高分辨率采样器(3步)" },
"gen_sigmas": { "name": "低分辨率 Sigmas(8步)" },
"upscale_sigmas": { "name": "高分辨率 Sigmas(3步)" },
"img_compression": { "name": "图像压缩率" },
"img2video_strength": { "name": "参考图注入强度" },
"prev_video": { "name": "前段累积视频" },
"vae_tile_size": { "name": "VAE分块尺寸" },
"vae_overlap": { "name": "VAE空间重叠" },
"vae_temporal_size": { "name": "时间分块尺寸" },
"vae_temporal_overlap": { "name": "时间重叠" }
},
"outputs": {
"0": { "name": "🎞️ 累积视频" },
"1": { "name": "🔊 原始音频" },
"2": { "name": "输出帧率" },
"3": { "name": "📁 输出文件路径" }
}
},
"XB_LTX23_InfiniteStreamer_Single": {
"display_name": "XB-BOX - 🔄 LTX2.3 无限时长 (8步单次采样)",
"inputs": {
"model": { "name": "模型" },
"video_vae": { "name": "视频 VAE" },
"audio_vae": { "name": "音频 VAE" },
"clip": { "name": "CLIP" },
"start_image": { "name": "参考图" },
"width": { "name": "宽度" },
"height": { "name": "高度" },
"total_frames": { "name": "总帧数" },
"fps": { "name": "帧率" },
"audio": { "name": "音频" },
"segment_frames": { "name": "每段生成帧数" },
"overlap_frames": { "name": "接力重叠帧数(建议8的倍数)" },
"positive_prompt": { "name": "正面提示词" },
"negative_prompt": { "name": "负面提示词" },
"seed": { "name": "随机种子" },
"sampler_gen": { "name": "采样器(8步)" },
"gen_sigmas": { "name": "Sigmas(8步)" },
"img_compression": { "name": "图像压缩率" },
"img2video_strength": { "name": "参考图注入强度" },
"prev_video": { "name": "前段累积视频" },
"vae_tile_size": { "name": "VAE分块尺寸" },
"vae_overlap": { "name": "VAE空间重叠" },
"vae_temporal_size": { "name": "时间分块尺寸" },
"vae_temporal_overlap": { "name": "时间重叠" }
},
"outputs": {
"0": { "name": "🎞️ 累积视频" },
"1": { "name": "🔊 原始音频" },
"2": { "name": "输出帧率" },
"3": { "name": "📁 输出文件路径" }
}
}
}
+318
View File
@@ -0,0 +1,318 @@
"""
XB-ToolBox 数字人参数调节节点
==============================
融合「视频参数大全」与「音频切片」节点,
为数字人推理提供一站式的参数管理与音频预处理。
"""
import os
import folder_paths
import torch
from .nodes_audio_slicer import _load_audio_file, _make_audio, _snap_4n1
# ============================================================
# XB_DigitalHumanParams_Single — 数字人参数调节(单人)
# ============================================================
class XB_DigitalHumanParams_Single:
"""融合「视频参数大全」的画幅/分辨率/帧率引擎与「音频切片V1」的音频截取。
输出宽高、帧率、总帧数、切片音频,可直接对接单人数字人推理管线。"""
@classmethod
def INPUT_TYPES(cls):
input_dir = folder_paths.get_input_directory()
audio_files = ["none"]
if os.path.exists(input_dir):
for f in sorted(os.listdir(input_dir)):
if f.lower().endswith(('.mp3', '.wav', '.flac', '.ogg', '.m4a', '.aac', '.opus', '.wma', '.webm')):
audio_files.append(f)
return {
"required": {
# ── 视频参数区(来自 XB_VideoParamsMaster)──
"aspect_ratio": (["Free", "1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "16:9 (LTX)", "9:16 (LTX)", "4:3 (LTX)", "3:4 (LTX)"], {"default": "Free"}),
"width": ("INT", {"default": 480, "min": 64, "max": 8192, "step": 16}),
"height": ("INT", {"default": 832, "min": 64, "max": 8192, "step": 16}),
"fps": ("INT", {"default": 25, "min": 1, "max": 120, "step": 1}),
"fps_float": ("FLOAT", {"default": 25.0, "min": 1.0, "max": 120.0, "step": 0.01}),
# ── 音频区(来自 XB_AudioSlicerV1)──
"audio": (audio_files,),
"start_time": ("FLOAT", {"default": 0.00, "min": 0.00, "max": 99999.00, "step": 0.01}),
"end_time": ("FLOAT", {"default": 10.00, "min": 0.00, "max": 99999.00, "step": 0.01}),
"duration_display": ("STRING", {"default": "0 帧", "multiline": False}),
}
}
RETURN_TYPES = ("INT", "INT", "INT", "INT", "FLOAT", "AUDIO")
RETURN_NAMES = ("Width", "Height", "Total Frames", "FPS", "FPS_Float", "Audio")
FUNCTION = "process"
CATEGORY = "XB_ToolBox/DigitalHuman"
@classmethod
def VALIDATE_INPUTS(cls, audio, **kwargs):
return True
def process(self, aspect_ratio, width, height, fps, fps_float,
audio, start_time, end_time, duration_display):
# ── 1. 视频参数处理(来自 XB_VideoParamsMaster)──
final_fps = int(round(fps))
if "Free" in aspect_ratio:
safe_w, safe_h = width, height
elif "LTX" in aspect_ratio:
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)
else:
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}
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)
# ── 2. 音频切片处理(来自 XB_AudioSlicerV1)──
if audio == "none":
sliced_audio = _make_audio(torch.zeros((1, 1), dtype=torch.float32), 44100)
total_frames = 0
else:
result = _load_audio_file(os.path.join(folder_paths.get_input_directory(), audio))
if result is None:
sliced_audio = _make_audio(torch.zeros((1, 1), dtype=torch.float32), 44100)
total_frames = 0
else:
waveform, sample_rate = result
if waveform.shape[0] > 1:
waveform = waveform.mean(dim=0, keepdim=True)
total_duration = waveform.shape[1] / sample_rate
frame_duration = 1.0 / final_fps
st = max(0.0, min(start_time, total_duration))
et = max(st + frame_duration, min(end_time, total_duration))
start_sample = int(st * sample_rate)
end_sample = int(et * sample_rate)
if end_sample <= start_sample:
end_sample = min(start_sample + int(frame_duration * 4 * sample_rate), waveform.shape[1])
sliced = waveform[:, start_sample:end_sample]
duration_sec = (end_sample - start_sample) / sample_rate
raw_frames = int(round(duration_sec * final_fps))
total_frames = _snap_4n1(raw_frames)
# 音频物理长度对齐 4N+1 帧数
target_samples = int((total_frames / final_fps) * sample_rate)
if sliced.shape[1] < target_samples:
pad = torch.zeros((sliced.shape[0], target_samples - sliced.shape[1]),
dtype=sliced.dtype, device=sliced.device)
sliced = torch.cat([sliced, pad], dim=1)
elif sliced.shape[1] > target_samples:
sliced = sliced[:, :target_samples]
if total_frames != raw_frames:
print(f"🔧 [数字人参数(单人)] 帧数 {raw_frames} → {total_frames} (对齐 4N+1)")
sliced_audio = _make_audio(sliced, sample_rate)
return (safe_w, safe_h, total_frames, final_fps, float(final_fps), sliced_audio)
# ============================================================
# XB_DigitalHumanParams_Dual — 数字人参数调节(双人)
# ============================================================
class XB_DigitalHumanParams_Dual:
"""融合「视频参数大全」的画幅/分辨率/帧率引擎与「音频切片V3」的双轨混音。
支持静音消音、接力/重叠合并,输出可直接对接双人数字人推理管线。"""
@classmethod
def INPUT_TYPES(cls):
input_dir = folder_paths.get_input_directory()
audio_files = ["none"]
if os.path.exists(input_dir):
for f in sorted(os.listdir(input_dir)):
if f.lower().endswith(('.mp3', '.wav', '.flac', '.ogg', '.m4a', '.aac', '.opus', '.wma', '.webm')):
audio_files.append(f)
return {
"required": {
# ── 视频参数区(来自 XB_VideoParamsMaster)──
"aspect_ratio": (["Free", "1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "16:9 (LTX)", "9:16 (LTX)", "4:3 (LTX)", "3:4 (LTX)"], {"default": "Free"}),
"width": ("INT", {"default": 480, "min": 64, "max": 8192, "step": 16}),
"height": ("INT", {"default": 832, "min": 64, "max": 8192, "step": 16}),
"fps": ("INT", {"default": 24, "min": 1, "max": 120, "step": 1}),
"fps_float": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.01}),
# ── 音频区(来自 XB_AudioSlicerV3)──
"audio1": (audio_files,),
"start1": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.01}),
"end1": ("FLOAT", {"default": 10.0, "min": 0.0, "max": 3600.0, "step": 0.01}),
"mute_count1": ("INT", {"default": 0, "min": 0, "max": 20, "step": 1}),
"mutes1_data": ("STRING", {"default": ""}),
"audio2": (audio_files,),
"start2": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.01}),
"end2": ("FLOAT", {"default": 10.0, "min": 0.0, "max": 3600.0, "step": 0.01}),
"mute_count2": ("INT", {"default": 0, "min": 0, "max": 20, "step": 1}),
"mutes2_data": ("STRING", {"default": ""}),
"merge_mode": (["接力", "重叠"], {"default": "接力"}),
"total_display": ("STRING", {"default": "0 / 0帧", "multiline": False}),
}
}
RETURN_TYPES = ("INT", "INT", "INT", "INT", "FLOAT", "AUDIO", "AUDIO", "AUDIO")
RETURN_NAMES = ("Width", "Height", "Total Frames", "FPS", "FPS_Float", "Audio1", "Audio2", "Combined Audio")
FUNCTION = "process"
CATEGORY = "XB_ToolBox/DigitalHuman"
@classmethod
def VALIDATE_INPUTS(cls, audio1, audio2, **kwargs):
return True
def _load(self, name):
if name == "none":
return None, 0
result = _load_audio_file(os.path.join(folder_paths.get_input_directory(), name))
if result is None:
return None, 0
wf, sr = result
if wf.shape[0] > 1:
wf = wf.mean(dim=0, keepdim=True)
return {"waveform": wf, "sample_rate": sr}, wf.shape[1] / sr
def _trim(self, audio, td, st, et, sr, fps):
fd = 1.0 / fps
st = max(0.0, min(st, td))
et = max(st + fd, min(et, td))
ss, es = int(st * sr), int(et * sr)
if es <= ss:
es = min(ss + int(fd * 4 * sr), audio.shape[1])
dur = (es - ss) / sr
raw = int(round(dur * fps))
fc = _snap_4n1(raw)
sliced = audio[:, ss:es]
target = int((fc / fps) * sr)
if sliced.shape[1] < target:
pad = torch.zeros((sliced.shape[0], target - sliced.shape[1]),
dtype=sliced.dtype, device=sliced.device)
sliced = torch.cat([sliced, pad], dim=1)
elif sliced.shape[1] > target:
sliced = sliced[:, :target]
return sliced, fc
def _apply_mutes(self, waveform, sample_rate, mute_data_str, start_offset):
"""静音消音:减去 start_offset,将绝对时间转换为切片内相对时间"""
if not mute_data_str:
return waveform
wav = waveform.clone()
for r in mute_data_str.split(';'):
if not r.strip():
continue
try:
s_str, e_str = r.split(',')
rel_s = float(s_str) - start_offset
rel_e = float(e_str) - start_offset
s_idx = max(0, int(rel_s * sample_rate))
e_idx = min(wav.shape[1], int(rel_e * sample_rate))
if e_idx > s_idx:
wav[:, s_idx:e_idx] = 0.0
except Exception:
pass
return wav
def process(self, aspect_ratio, width, height, fps, fps_float,
audio1, start1, end1, mute_count1, mutes1_data,
audio2, start2, end2, mute_count2, mutes2_data,
merge_mode, total_display):
# ── 1. 视频参数处理(来自 XB_VideoParamsMaster)──
final_fps = int(round(fps))
if "Free" in aspect_ratio:
safe_w, safe_h = width, height
elif "LTX" in aspect_ratio:
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)
else:
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}
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)
# ── 2. 双轨音频处理(来自 XB_AudioSlicerV3)──
a1, d1f = self._load(audio1)
a2, d2f = self._load(audio2)
sr = 44100
if a1:
sr = a1["sample_rate"]
elif a2:
sr = a2["sample_rate"]
t1 = torch.zeros((1, int(0.04 * sr)), dtype=torch.float32)
f1 = 0
t2 = torch.zeros((1, int(0.04 * sr)), dtype=torch.float32)
f2 = 0
if a1 and d1f > 0:
t1, f1 = self._trim(a1["waveform"], d1f, start1, end1, a1["sample_rate"], final_fps)
t1 = self._apply_mutes(t1, a1["sample_rate"], mutes1_data, start1)
if a2 and d2f > 0:
t2, f2 = self._trim(a2["waveform"], d2f, start2, end2, a2["sample_rate"], final_fps)
t2 = self._apply_mutes(t2, a2["sample_rate"], mutes2_data, start2)
# 采样率对齐
import torchaudio
if a2 and a2["sample_rate"] != sr:
t2 = torchaudio.functional.resample(t2, a2["sample_rate"], sr)
if a1 and a1["sample_rate"] != sr:
t1 = torchaudio.functional.resample(t1, a1["sample_rate"], sr)
# 核心混音引擎
if merge_mode == "接力":
combined = torch.cat([t1, t2], dim=1)
total_frames = _snap_4n1(f1 + f2)
else: # 重叠混音模式
len1, len2 = t1.shape[1], t2.shape[1]
max_len = max(len1, len2)
if len1 < max_len:
t1 = torch.cat([t1, torch.zeros((t1.shape[0], max_len - len1),
dtype=t1.dtype, device=t1.device)], dim=1)
if len2 < max_len:
t2 = torch.cat([t2, torch.zeros((t2.shape[0], max_len - len2),
dtype=t2.dtype, device=t2.device)], dim=1)
# 硬件级压限 (Hard Limiter)
combined = torch.clamp(t1 + t2, min=-1.0, max=1.0)
total_frames = _snap_4n1(max(f1, f2))
# 最终长度保护
target_samples = int((total_frames / final_fps) * sr)
if combined.shape[1] < target_samples:
combined = torch.cat([combined,
torch.zeros((combined.shape[0], target_samples - combined.shape[1]),
dtype=combined.dtype, device=combined.device)], dim=1)
elif combined.shape[1] > target_samples:
combined = combined[:, :target_samples]
return (safe_w, safe_h, total_frames, final_fps, float(final_fps),
_make_audio(t1, sr), _make_audio(t2, sr), _make_audio(combined, sr))
+181
View File
@@ -0,0 +1,181 @@
"""
XB-ToolBox LTX-2.3 IS2V Infinite Relay Node
=============================================
单次采样 (8步) + 302工作流 AddGuide 拼接
"""
import os, gc, math
import torch, nodes, comfy.utils, folder_paths, comfy.model_management as mm
from .nodes_pipeline import _safe_video_accumulate, _apply_progressive_color_correction, _refresh_models, _match_color_to_ref
class XB_LTX23_InfiniteStreamer:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"video_vae": ("VAE",),
"audio_vae": ("VAE",),
"clip": ("CLIP",),
"start_image": ("IMAGE",),
"width": ("INT", {"forceInput": True, "tooltip": "宽度"}),
"height": ("INT", {"forceInput": True, "tooltip": "高度"}),
"total_frames": ("INT", {"forceInput": True, "tooltip": "总帧数"}),
"fps": ("INT", {"forceInput": True, "tooltip": "帧率"}),
"audio": ("AUDIO",),
"segment_frames": ("INT", {"default": 97, "min": 5, "max": 4096, "step": 4, "tooltip": "每段生成帧数"}),
"overlap_frames": ("INT", {"default": 8, "min": 1, "max": 33, "step": 1, "tooltip": "接力重叠帧数(建议8的倍数)"}),
"positive_prompt": ("STRING", {"multiline": True, "default": ""}),
"negative_prompt": ("STRING", {"multiline": True, "default": ""}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
"sampler_gen": (["euler","euler_ancestral","heun","dpm_2","dpm_2_ancestral","lms","dpmpp_2m","dpmpp_2m_sde","dpmpp_3m_sde","ddim","uni_pc"], {"default":"euler","tooltip":"采样器(8步)"}),
"gen_sigmas": ("STRING", {"default":"1.0,0.99375,0.9875,0.98125,0.975,0.909375,0.725,0.421875,0.0","tooltip":"Sigmas(8步)"}),
"img_compression": ("INT", {"default":18,"min":0,"max":100,"step":1}),
"img2video_strength": ("FLOAT", {"default":0.7,"min":0.0,"max":1.0,"step":0.05}),
},
"optional": {
"prev_video": ("IMAGE",),
"vae_tile_size": ("INT", {"default":320,"min":64,"max":4096,"step":64}),
"vae_overlap": ("INT", {"default":64,"min":0,"max":512,"step":16}),
"vae_temporal_size": ("INT", {"default":80,"min":1,"max":4096,"step":1}),
"vae_temporal_overlap": ("INT", {"default":16,"min":0,"max":256,"step":1}),
}
}
RETURN_TYPES = ("IMAGE", "AUDIO", "FLOAT", "STRING")
RETURN_NAMES = ("accumulated_video", "original_audio", "output_fps", "output_filepath")
FUNCTION = "execute_relay"
CATEGORY = "XB_ToolBox/Pipeline"
def execute_relay(self, model, video_vae, audio_vae, clip,
start_image, width, height, total_frames, fps, audio,
segment_frames, overlap_frames,
positive_prompt, negative_prompt,
seed, sampler_gen, gen_sigmas,
img_compression, img2video_strength,
prev_video=None,
vae_tile_size=320, vae_overlap=64,
vae_temporal_size=80, vae_temporal_overlap=16):
overlap = overlap_frames
audio_sr, audio_wf = audio["sample_rate"], audio["waveform"]
relay_count = math.ceil(total_frames / segment_frames)
print(f"\n{'='*60}\n[XB-BOX] LTX-2.3 (003单次采样) | {total_frames}f | seg {segment_frames}f | x{relay_count}\n{'='*60}")
accumulated_video = prev_video
current_image = start_image.clone()
gen_sigmas_list = [float(x.strip()) for x in gen_sigmas.split(",") if x.strip()]
total_written = 0
NCM = nodes.NODE_CLASS_MAPPINGS
Preproc = NCM.get("LTXVPreprocess")
EmptyLat = NCM["EmptyLTXVLatentVideo"]
Img2Vid = NCM.get("LTXVImgToVideoInplace")
AudioEnc = NCM["LTXVAudioVAEEncode"]
NoiseMask = NCM.get("SetLatentNoiseMask")
LtxCond = NCM.get("LTXVConditioning")
ConcatAV = NCM["LTXVConcatAVLatent"]
SeparateAV= NCM["LTXVSeparateAVLatent"]
CropGuides= NCM.get("LTXVCropGuides")
Decoder = NCM["VAEDecodeTiled"]
CFGG, RandN, KSampler, SSA = NCM["CFGGuider"], NCM["RandomNoise"], NCM["KSamplerSelect"], NCM["SamplerCustomAdvanced"]
output_path = os.path.join(folder_paths.get_output_directory(), "LTX23_Infinite_Output.mp4")
try:
for r in range(relay_count):
remaining = total_frames - total_written
if remaining <= 0:
break
eff_seg = min(segment_frames, remaining)
actual_frames = ((eff_seg + 2)//4)*4 + 1
print(f"\n[relay {r+1}/{relay_count}] written={total_written} remaining={remaining} gen={actual_frames}")
seg_start_s = total_written * audio_sr // fps
audio_s_per = actual_frames * audio_sr // fps
seg_end_s = min(seg_start_s + audio_s_per, audio_wf.shape[-1])
if seg_start_s >= audio_wf.shape[-1]:
print("[XB-BOX] Audio exhausted"); break
seg_wf = audio_wf[..., seg_start_s:seg_end_s]
if seg_wf.shape[-1] < audio_s_per:
ps = seg_wf.shape[:-1] + (audio_s_per - seg_wf.shape[-1],)
seg_wf = torch.cat([seg_wf, torch.zeros(ps, dtype=seg_wf.dtype, device=seg_wf.device)], dim=-1)
chunk_audio = {"waveform": seg_wf, "sample_rate": audio_sr}
# Stage 1: 首段缩放到目标尺寸,后续段 VAE 原生分辨率不缩放(003 模式)
if r == 0:
current_image = comfy.utils.common_upscale(current_image.movedim(-1,1), width, height, "lanczos","center").movedim(1,-1)
if Preproc is not None:
prep_out, = getattr(Preproc(), Preproc.FUNCTION)(image=current_image, img_compression=img_compression)
else:
prep_out = current_image
lr_w, lr_h = prep_out.shape[2], prep_out.shape[1]
el = EmptyLat()
lr_lat, = getattr(el, el.FUNCTION)(width=lr_w, height=lr_h, length=actual_frames, batch_size=1)
if Img2Vid is not None:
lr_lat, = getattr(Img2Vid(), Img2Vid.FUNCTION)(vae=video_vae, image=prep_out, latent=lr_lat, strength=img2video_strength, bypass=False)
# Stage 2: Audio + Conditioning
aud_lat, = getattr(AudioEnc(), AudioEnc.FUNCTION)(audio=chunk_audio, audio_vae=audio_vae)
if NoiseMask is not None:
aud_lat, = getattr(NoiseMask(), NoiseMask.FUNCTION)(samples=aud_lat, mask=torch.zeros((1,1,1024,1024), dtype=torch.float32))
pos_cond, = nodes.CLIPTextEncode().encode(clip, positive_prompt)
neg_cond, = nodes.CLIPTextEncode().encode(clip, negative_prompt)
if LtxCond is not None:
pos_cond, neg_cond = getattr(LtxCond(), LtxCond.FUNCTION)(positive=pos_cond, negative=neg_cond, frame_rate=float(fps))
# Stage 3: Sampling + CropGuides(003 模式)
av_low, = getattr(ConcatAV(), ConcatAV.FUNCTION)(video_latent=lr_lat, audio_latent=aud_lat)
g1, = getattr(CFGG(), CFGG.FUNCTION)(model=model, positive=pos_cond, negative=neg_cond, cfg=1.0)
base_seed = seed + r * 10000
n1, = getattr(RandN(), RandN.FUNCTION)(noise_seed=base_seed)
s1, = getattr(KSampler(), KSampler.FUNCTION)(sampler_name=sampler_gen)
st1 = torch.tensor(gen_sigmas_list, dtype=torch.float32)
sampled, _ = getattr(SSA(), SSA.FUNCTION)(noise=n1, guider=g1, sampler=s1, sigmas=st1, latent_image=av_low)
seg_latent, _ = getattr(SeparateAV(), SeparateAV.FUNCTION)(av_latent=sampled)
if CropGuides is not None:
_, _, seg_latent = getattr(CropGuides(), CropGuides.FUNCTION)(positive=pos_cond, negative=neg_cond, latent=seg_latent)
# Stage 4: VAE decode + 像素累积(零重叠,纯顺序拼接)
decoded, = getattr(Decoder(), Decoder.FUNCTION)(
samples=seg_latent, vae=video_vae,
tile_size=vae_tile_size, overlap=vae_overlap,
temporal_size=vae_temporal_size, temporal_overlap=vae_temporal_overlap)
is_4d = len(decoded.shape) == 4
current_image = decoded[-1:].clone() if is_4d else decoded[:, -1:].clone()
cd = 0 if is_4d else 1
accumulated_video = _safe_video_accumulate(accumulated_video, decoded, cd, label=f"LTX-{r+1}", concat_mode="自动")
total_written += decoded.shape[cd]
print(f" accum: +{decoded.shape[cd]}f -> total {total_written}f/{total_frames}f")
if total_written > total_frames:
excess = total_written - total_frames
if is_4d:
accumulated_video = accumulated_video[:-excess]
else:
accumulated_video = accumulated_video[:, :-excess]
total_written = total_frames
print(f"[XB-BOX] ⚠️ 裁剪多余帧: {excess}f")
gc.collect()
mm.soft_empty_cache()
# 全视频统一色彩校正,消除段间边界差异
if accumulated_video is not None:
is_4d = len(accumulated_video.shape) == 4
accumulated_video = _apply_progressive_color_correction(accumulated_video, start_image, is_4d)
print(f"\n[DONE] {total_written}f ({total_written/fps:.1f}s)")
except Exception as e:
print(f"[ERROR] {e}")
import traceback; traceback.print_exc()
_refresh_models(force=True)
return (accumulated_video, audio, float(fps), output_path)
+53 -18
View File
@@ -35,36 +35,42 @@ _amd_state = {"checked": False, "available": False, "gpu_name": None,
"power_cache": None, "cache_time": 0}
def _init_lhm():
"""初始化 LibreHardwareMonitor (跨厂商硬件监控)"""
"""初始化 LibreHardwareMonitor (仅 Windows)"""
if _lhm_state["checked"]:
return _lhm_state["available"]
_lhm_state["checked"] = True
# LibreHardwareMonitorLib.dll 是 Windows 专有 DLL,Linux 直接跳过
if platform.system() != "Windows":
log.info("aimdo-viz: 非 Windows 系统,跳过 LHM")
return False
try:
import pythonnet
except ImportError:
print("[XB 硬件监控] ⚠️ pythonnet 未安装,AMD GPU 详细监控不可用。安装命令: pip install pythonnet")
print("[XB 硬件监控] [WARN] pythonnet 未安装,AMD GPU 详细监控不可用。安装: pip install pythonnet")
log.info("aimdo-viz: pythonnet 未安装")
return False
try:
pythonnet.load('coreclr')
except Exception as e:
print(f"[XB 硬件监控] ⚠️ .NET CoreCLR 加载失败: {e}")
print(" 请安装 .NET 8.0 运行时: https://dotnet.microsoft.com/en-us/download/dotnet/8.0")
print(f"[XB 硬件监控] [WARN] .NET CoreCLR 加载失败: {e}")
print(" 请安装 .NET Windows Desktop Runtime 10.0.10:")
print(" https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.10/windowsdesktop-runtime-10.0.10-win-x64.exe")
log.info("aimdo-viz: coreclr 加载失败: %s", e)
return False
try:
import clr
except Exception as e:
print(f"[XB 硬件监控] ⚠️ clr 模块加载失败: {e}")
print(f"[XB 硬件监控] [WARN] clr 模块加载失败: {e}")
return False
dll_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"LibreHardwareMonitorLib.dll")
if not os.path.exists(dll_path):
print(f"[XB 硬件监控] ⚠️ LibreHardwareMonitorLib.dll 未找到: {dll_path}")
print(f"[XB 硬件监控] [WARN] LibreHardwareMonitorLib.dll 未找到: {dll_path}")
print(" 请确保 DLL 文件在 XB_ToolBox 目录中")
log.info("aimdo-viz: LibreHardwareMonitorLib.dll 未找到")
return False
@@ -79,11 +85,11 @@ def _init_lhm():
_lhm_state["computer"] = computer
_lhm_state["available"] = True
print("[XB 硬件监控] ✅ AMD GPU 详细监控已就绪 (LibreHardwareMonitor)")
print("[XB 硬件监控] [OK] AMD GPU 详细监控已就绪 (LibreHardwareMonitor)")
log.info("aimdo-viz: LibreHardwareMonitor 初始化成功")
return True
except Exception as e:
print(f"[XB 硬件监控] ⚠️ LibreHardwareMonitor 初始化失败: {e}")
print(f"[XB 硬件监控] [WARN] LibreHardwareMonitor 初始化失败: {e}")
log.info("aimdo-viz: LHM 初始化失败: %s", e)
_lhm_state["available"] = False
return False
@@ -218,13 +224,12 @@ def _detect_amd_gpu():
log.info("aimdo-viz: 检测到 AMD GPU: %s", gpu_name)
_amd_state["gpu_name"] = gpu_name
# 方案 1: LibreHardwareMonitor (推荐,支持 RDNA 3)
# 方案 1: LibreHardwareMonitor (仅 Windows,支持 RDNA 3)
if _init_lhm():
_amd_state["available"] = True
log.info("aimdo-viz: 使用 LibreHardwareMonitor 监控 AMD GPU")
return True
# 方案 2: rocm-smi CLI (Linux / 未来 Windows)
# 方案 2: rocm-smi CLI (Linux 主方案,Windows 备选)
import shutil
candidates = []
if platform.system() == "Windows":
@@ -234,17 +239,38 @@ def _detect_amd_gpu():
"rocm-smi.exe",
]
else:
candidates = ["rocm-smi", "/opt/rocm/bin/rocm-smi"]
# Linux: rocm-smi 通常通过 ROCm 包安装
candidates = [
"rocm-smi",
"/opt/rocm/bin/rocm-smi",
"/usr/bin/rocm-smi",
"/usr/lib/rocm/bin/rocm-smi",
]
for path in candidates:
if "*" not in path and shutil.which(path):
if shutil.which(path):
_amd_state["rocm_smi_path"] = path
log.info("aimdo-viz: 使用 rocm-smi: %s", path)
_amd_state["available"] = True
print("[XB 硬件监控] [OK] AMD GPU 监控已就绪 (rocm-smi)")
return True
print("[XB 硬件监控] ⚠️ AMD GPU 详细监控不可用,仅显示 VRAM 基本信息")
print(" 请检查: 1) pythonnet 已安装 2) .NET 8.0 运行时已安装 3) LibreHardwareMonitorLib.dll 在工具箱目录中")
# 无可用后端,打印平台相关提示
sys_name = platform.system()
if sys_name == "Windows":
print("[XB 硬件监控] [WARN] AMD GPU 详细监控不可用,仅显示 VRAM")
print(" 请检查: 1) pythonnet 已安装 2) .NET Windows Desktop Runtime 10.0.10")
print(" 下载: https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.10/windowsdesktop-runtime-10.0.10-win-x64.exe")
print(" 3) LibreHardwareMonitorLib.dll 在工具箱目录中")
elif sys_name == "Linux":
print("[XB 硬件监控] [WARN] AMD GPU 详细监控不可用,仅显示 VRAM")
print(" 请安装 ROCm SMI: sudo apt install rocm-smi-lib rocm-smi")
print(" 或: pip install amdsmi && 确认 /opt/rocm/bin/rocm-smi 可用")
elif sys_name == "Darwin":
print("[XB 硬件监控] [WARN] macOS AMD GPU 详细监控不可用,仅显示 VRAM")
print(" macOS 暂不支持 GPU 传感器读取,仅能显示 VRAM 基本信息")
else:
print("[XB 硬件监控] [WARN] AMD GPU 详细监控不可用,仅显示 VRAM")
log.info("aimdo-viz: AMD GPU 监控不可用,仅显示 VRAM")
_amd_state["available"] = True
return True
@@ -281,16 +307,25 @@ def _query_amd_gpu():
data = _json.loads(proc.stdout)
for card_key in data:
card = data[card_key]
# 尝试多种可能的 JSON key(不同 rocm-smi 版本格式不同)
util_str = (card.get("GPU use (%)") or card.get("GPU utilization (%)")
or card.get("GPU Utilization (%)") or "0")
temp_str = (card.get("Temperature (C)") or card.get("Temperature (Sensor edge) (C)")
or card.get("Temperature (Edge) (C)") or "0")
power_str = (card.get("Average Graphics Package Power (W)")
or card.get("Graphics Package Power (W)")
or card.get("Current Graphics Package Power (W)")
or card.get("GPU Package Power (W)") or "0")
try:
_amd_state["util_cache"] = int(float(card.get("GPU use (%)", "0")))
_amd_state["util_cache"] = int(float(util_str))
except (ValueError, TypeError):
_amd_state["util_cache"] = None
try:
_amd_state["temp_cache"] = float(card.get("Temperature (C)", "0"))
_amd_state["temp_cache"] = float(temp_str)
except (ValueError, TypeError):
_amd_state["temp_cache"] = None
try:
_amd_state["power_cache"] = float(card.get("Average Graphics Package Power (W)", 0)) * 1000
_amd_state["power_cache"] = float(power_str) * 1000
except (ValueError, TypeError):
_amd_state["power_cache"] = None
break
+1 -1
View File
@@ -2503,4 +2503,4 @@ class XB_WanInfiniteTalk_RelayNode_AllInOne:
print("🌐 [XB-BOX] 总线状态已更新:独立参考图已成为下游节点的新全局空间基准。")
_refresh_models()
return (b, accumulated_video, accumulated_audio)
return (b, accumulated_video, accumulated_audio)