LTX数字人
This commit is contained in:
@@ -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 - 🎵 语音转视频分块(双人)",
|
||||
|
||||
@@ -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: 面板太大/太小?
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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": "📁 输出文件路径" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user