From 350e52cc13b292912bb05207e87b0c19e8d9fec3 Mon Sep 17 00:00:00 2001 From: AI_Architect Date: Sun, 26 Jul 2026 10:28:34 +0800 Subject: [PATCH] =?UTF-8?q?LTX=E6=95=B0=E5=AD=97=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 8 + docs/XB_MemoryVisualization_manual.md | 12 +- js/xb_digital_human_dual.js | 457 ++++++++++++++++++++++++++ js/xb_digital_human_single.js | 376 +++++++++++++++++++++ js/xb_node_sizer.js | 2 + locales/zh/nodeDefs.json | 207 ++++++++++++ nodes_digital_human.py | 318 ++++++++++++++++++ nodes_ltx_streamer.py | 181 ++++++++++ nodes_memory_viz.py | 71 +++- nodes_pipeline.py | 2 +- 10 files changed, 1614 insertions(+), 20 deletions(-) create mode 100644 js/xb_digital_human_dual.js create mode 100644 js/xb_digital_human_single.js create mode 100644 nodes_digital_human.py create mode 100644 nodes_ltx_streamer.py diff --git a/__init__.py b/__init__.py index 5c60d34..4359ad7 100644 --- a/__init__.py +++ b/__init__.py @@ -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 - 🎵 语音转视频分块(双人)", diff --git a/docs/XB_MemoryVisualization_manual.md b/docs/XB_MemoryVisualization_manual.md index 2e958fb..bb4a08e 100644 --- a/docs/XB_MemoryVisualization_manual.md +++ b/docs/XB_MemoryVisualization_manual.md @@ -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: 面板太大/太小? diff --git a/js/xb_digital_human_dual.js b/js/xb_digital_human_dual.js new file mode 100644 index 0000000..b52d3c0 --- /dev/null +++ b/js/xb_digital_human_dual.js @@ -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); + } +}); diff --git a/js/xb_digital_human_single.js b/js/xb_digital_human_single.js new file mode 100644 index 0000000..c77cc44 --- /dev/null +++ b/js/xb_digital_human_single.js @@ -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); + } +}); diff --git a/js/xb_node_sizer.js b/js/xb_node_sizer.js index a493d55..e3749f9 100644 --- a/js/xb_node_sizer.js +++ b/js/xb_node_sizer.js @@ -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], }; diff --git a/locales/zh/nodeDefs.json b/locales/zh/nodeDefs.json index 908208d..e74d1f6 100644 --- a/locales/zh/nodeDefs.json +++ b/locales/zh/nodeDefs.json @@ -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": "📁 输出文件路径" } + } } } \ No newline at end of file diff --git a/nodes_digital_human.py b/nodes_digital_human.py new file mode 100644 index 0000000..65b6734 --- /dev/null +++ b/nodes_digital_human.py @@ -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)) diff --git a/nodes_ltx_streamer.py b/nodes_ltx_streamer.py new file mode 100644 index 0000000..9cc96ea --- /dev/null +++ b/nodes_ltx_streamer.py @@ -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) diff --git a/nodes_memory_viz.py b/nodes_memory_viz.py index ca2e4ab..45a23ea 100644 --- a/nodes_memory_viz.py +++ b/nodes_memory_viz.py @@ -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 diff --git a/nodes_pipeline.py b/nodes_pipeline.py index aab31ac..19b7858 100644 --- a/nodes_pipeline.py +++ b/nodes_pipeline.py @@ -2503,4 +2503,4 @@ class XB_WanInfiniteTalk_RelayNode_AllInOne: print("🌐 [XB-BOX] 总线状态已更新:独立参考图已成为下游节点的新全局空间基准。") _refresh_models() - return (b, accumulated_video, accumulated_audio) \ No newline at end of file + return (b, accumulated_video, accumulated_audio)