feat: add TextLineCount and SaveAudioSilent nodes, fix Civitai fetch button in Vue frontend

- Add TextLineCount node: counts lines in multiline text input
- Add SaveAudioSilent node: saves audio without being a terminal output node (for for-loops)
- Fix fetch button: use addDOMWidget() API for Vue frontend compatibility
- Fix Civitai API: add Accept-Encoding header to avoid Brotli decode errors
- Add button hover highlight styling
- Replace canvas-based preview with DOM-based rendering
This commit is contained in:
Kai Xu
2026-06-05 19:48:08 +08:00
parent 94de2d6cf0
commit 894bb723ed
4 changed files with 285 additions and 280 deletions
+11 -3
View File
@@ -13,10 +13,18 @@ from .cache_manager import cache_manager
WEB_DIRECTORY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend")
# Node exports -- required for ComfyUI to discover this node
from .nodes import KaiCivitaiLoRALoader
from .nodes import KaiCivitaiLoRALoader, TextLineCount, SaveAudioSilent
NODE_CLASS_MAPPINGS = {"KaiCivitaiLoRALoader": KaiCivitaiLoRALoader}
NODE_DISPLAY_NAME_MAPPINGS = {"KaiCivitaiLoRALoader": "Kai Civitai LoRA Loader"}
NODE_CLASS_MAPPINGS = {
"KaiCivitaiLoRALoader": KaiCivitaiLoRALoader,
"TextLineCount": TextLineCount,
"SaveAudioSilent": SaveAudioSilent,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"KaiCivitaiLoRALoader": "Kai Civitai LoRA Loader",
"TextLineCount": "Text Line Count",
"SaveAudioSilent": "Save Audio (Silent)",
}
__all__ = [
"NODE_CLASS_MAPPINGS",
+1
View File
@@ -25,6 +25,7 @@ def get_session() -> aiohttp.ClientSession:
_session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=get_timeout()),
trust_env=True,
headers={"Accept-Encoding": "gzip, deflate"},
)
return _session
+177 -277
View File
@@ -1,14 +1,8 @@
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
const EXTENSION_NAME = "ComfyUI.KaiCivitaiLoRA";
const API_BASE = "/kai/civitai";
// UI constants
const PREVIEW_HEIGHT = 200;
const ARROW_WIDTH = 30;
// LRU image cache with max 50 entries
const MAX_IMAGE_CACHE = 50;
const _imageCacheKeys = [];
const _imageCache = {};
@@ -16,7 +10,21 @@ const _imageCache = {};
app.registerExtension({
name: EXTENSION_NAME,
async beforeRegisterNodeDef(nodeType, nodeData, app) {
async setup() {
const style = document.createElement("style");
style.textContent = `
.comfy-node input[type="button"] {
transition: filter 0.15s ease;
}
.comfy-node input[type="button"]:hover {
filter: brightness(1.3);
cursor: pointer;
}
`;
document.head.appendChild(style);
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "KaiCivitaiLoRALoader") return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
@@ -25,28 +33,6 @@ app.registerExtension({
addCivitaiWidgets(this);
};
const onDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function(ctx) {
onDrawForeground?.apply(this, arguments);
drawImagePreview(this, ctx);
};
// Handle clicks on the node for pagination and NSFW toggle
const onMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function(e, pos, canvas) {
const handled = handleNodeClick(this, e, pos);
if (handled) return handled;
return onMouseDown?.apply(this, arguments);
};
// Serialize/deserialize civitai state
const onSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function(o) {
onSerialize?.apply(this, arguments);
o.civitai_cache = this.civitai_cache;
o.civitai_image_index = this.civitai_image_index;
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(o) {
onConfigure?.apply(this, arguments);
@@ -54,21 +40,17 @@ app.registerExtension({
this.civitai_image_index = o.civitai_image_index || 0;
this.civitai_nsfw_unblurred = {};
updateButtonLabel(this);
updateUI(this);
if (this.civitai_domWidget) updatePreviewContent(this);
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function() {
if (this.civitai_abortController) {
this.civitai_abortController.abort();
}
// Clear image cache entries for this node's hash
if (this.civitai_abortController) this.civitai_abortController.abort();
if (this.civitai_cache?.hash) {
const hash = this.civitai_cache.hash;
const h = this.civitai_cache.hash;
for (let i = _imageCacheKeys.length - 1; i >= 0; i--) {
if (_imageCacheKeys[i].startsWith(hash + "_")) {
const key = _imageCacheKeys.splice(i, 1)[0];
delete _imageCache[key];
if (_imageCacheKeys[i].startsWith(h + "_")) {
delete _imageCache[_imageCacheKeys.splice(i, 1)[0]];
}
}
}
@@ -78,74 +60,147 @@ app.registerExtension({
});
function addCivitaiWidgets(node) {
// State
node.civitai_cache = null;
node.civitai_image_index = 0;
node.civitai_loading = false;
node.civitai_nsfw_unblurred = {};
// Button widget
node.civitai_button = node.addWidget("button", "fetch", "Fetch Civitai Info", (w) => {
const force = node.civitai_cache !== null;
doFetch(node, force);
});
// Fetch / Refresh button
node.civitai_button = node.addWidget("button", "fetch", "Fetch Civitai Info", () => {
doFetch(node, node.civitai_cache !== null);
}, { serialize: false });
// Trigger words text widget (read-only display, fixed 3-line height)
// Use ComfyWidgets.STRING for proper multiline <textarea> rendering
const triggerWidget = ComfyWidgets["STRING"](node, "triggers", ["STRING", { multiline: true }], app);
node.civitai_triggers = triggerWidget.widget;
node.civitai_triggers.inputEl.readOnly = true;
node.civitai_triggers.inputEl.style.border = "none";
node.civitai_triggers.inputEl.style.backgroundColor = "transparent";
node.civitai_triggers.inputEl.rows = 3;
node.civitai_triggers.inputEl.style.resize = "none";
// Fix widget height to ~3 lines so it does not expand with node size
node.civitai_triggers.computeSize = function(width) {
return [width, 56];
};
// Button hover highlight
const btnEl = node.civitai_button?.inputEl || node.civitai_button?.element;
if (btnEl) {
btnEl.style.transition = "filter 0.15s ease";
btnEl.addEventListener("mouseenter", () => { btnEl.style.filter = "brightness(1.3)"; btnEl.style.cursor = "pointer"; });
btnEl.addEventListener("mouseleave", () => { btnEl.style.filter = "brightness(1.0)"; });
}
// Track last lora_name to detect changes and reset state
// LoRA dropdown change → reset cache
const loraWidget = node.widgets.find(w => w.name === "lora_name");
if (loraWidget) {
const origCallback = loraWidget.callback;
loraWidget.callback = function(value) {
if (origCallback) origCallback.apply(this, arguments);
if (node.civitai_lastLora !== value) {
node.civitai_lastLora = value;
const orig = loraWidget.callback;
loraWidget.callback = function(v) {
orig?.apply(this, arguments);
if (node.civitai_lastLora !== v) {
node.civitai_lastLora = v;
node.civitai_cache = null;
node.civitai_image_index = 0;
node.civitai_nsfw_unblurred = {};
if (node.civitai_triggers) node.civitai_triggers.value = "";
updateButtonLabel(node);
node.setDirtyCanvas(true, true);
updatePreviewContent(node);
}
};
}
// Adjust node size to accommodate preview area
// Widget area: title(40) + 4 std widgets(30*4) + trigger(56) + spacing(20) ≈ 260
// Preview min height: 80. Total min: 260 + 80 + 20 = 360
node.size[1] = Math.max(node.size[1], 360);
// DOM widget for triggers + preview (uses addDOMWidget – works in V1 + Vue V2)
const domContainer = document.createElement("div");
domContainer.style.cssText = "margin:4px 0;";
// Triggers display
const triggersEl = document.createElement("div");
triggersEl.style.cssText =
"background:#2a2a2a;color:#aaa;font:12px sans-serif;padding:4px 6px;margin:2px 0;" +
"min-height:20px;max-height:52px;overflow-y:auto;border-radius:3px;" +
"word-break:break-word;white-space:pre-wrap;";
domContainer.appendChild(triggersEl);
// Preview area
const preview = document.createElement("div");
preview.style.cssText =
"background:#1a1a1a;border-radius:4px;margin:4px 0;" +
`height:${PREVIEW_HEIGHT}px;position:relative;overflow:hidden;` +
"display:none;align-items:center;justify-content:center;";
const img = document.createElement("img");
img.style.cssText = "max-width:100%;max-height:100%;object-fit:contain;";
preview.appendChild(img);
const nsfw = document.createElement("div");
nsfw.style.cssText =
"display:none;position:absolute;inset:0;background:rgba(255,0,0,0.15);" +
"align-items:center;justify-content:center;";
nsfw.innerHTML = '<span style="background:rgba(255,0,0,0.8);color:#fff;padding:4px 12px;border-radius:3px;font:bold 14px sans-serif;">NSFW</span>';
preview.appendChild(nsfw);
const pagination = document.createElement("div");
pagination.style.cssText =
"position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,0.5);" +
"color:#fff;font:12px sans-serif;text-align:center;padding:2px 0;";
preview.appendChild(pagination);
const arrowL = document.createElement("div");
arrowL.style.cssText =
"position:absolute;left:0;top:0;bottom:0;width:30px;display:none;" +
"align-items:center;justify-content:center;color:#fff;font-size:18px;" +
"cursor:pointer;background:rgba(0,0,0,0.2);";
arrowL.textContent = "◀";
preview.appendChild(arrowL);
const arrowR = document.createElement("div");
arrowR.style.cssText =
"position:absolute;right:0;top:0;bottom:0;width:30px;display:none;" +
"align-items:center;justify-content:center;color:#fff;font-size:18px;" +
"cursor:pointer;background:rgba(0,0,0,0.2);";
arrowR.textContent = "▶";
preview.appendChild(arrowR);
domContainer.appendChild(preview);
// Click handler for pagination + NSFW toggle
preview.addEventListener("click", (e) => {
const c = node.civitai_cache;
if (!c) return;
const imgs = c.images || [];
if (!imgs.length) return;
const rect = preview.getBoundingClientRect();
const mx = e.clientX - rect.left;
const i = node.civitai_image_index || 0;
if (imgs.length > 1 && mx < 30) {
node.civitai_image_index = (i - 1 + imgs.length) % imgs.length;
updatePreviewContent(node);
} else if (imgs.length > 1 && mx > rect.width - 30) {
node.civitai_image_index = (i + 1) % imgs.length;
updatePreviewContent(node);
} else if (imgs[i]?.nsfw) {
node.civitai_nsfw_unblurred[i] = !node.civitai_nsfw_unblurred[i];
updatePreviewContent(node);
}
});
// Store refs
node.civitai_triggersEl = triggersEl;
node.civitai_previewEl = preview;
node.civitai_previewImg = img;
node.civitai_previewNsfw = nsfw;
node.civitai_previewPagination = pagination;
node.civitai_previewArrowL = arrowL;
node.civitai_previewArrowR = arrowR;
// Register as DOM widget – the frontend places it automatically
node.civitai_domWidget = node.addDOMWidget("civitai_info", "custom", domContainer, {
getMinHeight() { return PREVIEW_HEIGHT + 30; },
serialize: false,
});
// Populate with cached data restored from workflow (onConfigure fires before onNodeCreated)
updatePreviewContent(node);
node.setSize(node.computeSize());
}
async function doFetch(node, force = false) {
async function doFetch(node, force) {
if (node.civitai_loading) return;
const loraWidget = node.widgets.find(w => w.name === "lora_name");
if (!loraWidget || !loraWidget.value) {
logMessage(node, "No LoRA selected");
return;
}
if (!loraWidget?.value) return;
// Abort any previous fetch
if (node.civitai_abortController) {
node.civitai_abortController.abort();
}
if (node.civitai_abortController) node.civitai_abortController.abort();
node.civitai_abortController = new AbortController();
node.civitai_loading = true;
updateButtonLabel(node, "Loading...");
node.setDirtyCanvas(true, true);
try {
const resp = await fetch(`${API_BASE}/fetch`, {
@@ -156,235 +211,80 @@ async function doFetch(node, force = false) {
});
if (!resp.ok) {
const text = await resp.text();
console.error(`[KaiCivitaiLoRA] HTTP ${resp.status}: ${text}`);
logMessage(node, `Server error: ${resp.status}`);
node.civitai_loading = false;
updateButtonLabel(node);
node.setDirtyCanvas(true, true);
console.error(`[KaiCivitaiLoRA] HTTP ${resp.status}: ${await resp.text()}`);
return;
}
const data = await resp.json();
if (!data.success) {
const msg = data.error || "Unknown error";
if (resp.status === 404) {
logMessage(node, "Not found on Civitai");
} else if (resp.status === 429) {
logMessage(node, "Rate limited, try again later");
} else {
logMessage(node, msg);
}
console.log(`[KaiCivitaiLoRA] API: ${data.error || "unknown"}`);
node.civitai_cache = data.data || null;
updateUI(node);
return;
}
node.civitai_cache = data.data;
node.civitai_image_index = 0;
node.civitai_nsfw_unblurred = {};
updateUI(node);
} catch (e) {
if (e.name === "AbortError") {
logMessage(node, "Fetch cancelled");
} else {
logMessage(node, "Network unavailable");
node.civitai_cache = data.data;
node.civitai_image_index = 0;
node.civitai_nsfw_unblurred = {};
}
updateButtonLabel(node);
updatePreviewContent(node);
} catch (e) {
if (e.name !== "AbortError") console.error(`[KaiCivitaiLoRA] Fetch failed:`, e);
} finally {
node.civitai_loading = false;
node.civitai_abortController = null;
updateButtonLabel(node);
node.setDirtyCanvas(true, true);
}
}
function updateUI(node) {
const cache = node.civitai_cache;
if (!cache) {
node.civitai_triggers.value = "";
updateButtonLabel(node);
return;
}
const triggers = cache.triggerWords || [];
node.civitai_triggers.value = Array.isArray(triggers) ? triggers.join(", ") : "";
updateButtonLabel(node);
}
function updateButtonLabel(node, override) {
if (override) {
node.civitai_button.name = override;
return;
}
const hasCache = node.civitai_cache !== null;
node.civitai_button.name = hasCache ? "Refresh" : "Fetch Civitai Info";
if (override) { node.civitai_button.name = override; return; }
node.civitai_button.name = node.civitai_cache ? "Refresh" : "Fetch Civitai Info";
}
function logMessage(node, message) {
console.log(`[KaiCivitaiLoRA] ${message}`);
}
function updatePreviewContent(node) {
const c = node.civitai_cache;
const { civitai_triggersEl:trig, civitai_previewEl:preview,
civitai_previewImg:imgEl, civitai_previewNsfw:nsfw,
civitai_previewPagination:pag, civitai_previewArrowL:al,
civitai_previewArrowR:ar } = node;
if (!trig || !preview) return;
function getPreviewArea(node) {
// Calculate preview area position relative to node top-left.
// onDrawForeground draws in local node coordinates.
const margin = 10;
const x = margin;
const w = Math.max(0, node.size[0] - margin * 2);
// Triggers
const words = c?.triggerWords || [];
trig.textContent = words.length ? "Triggers: " + words.join(", ") : "";
// Fixed height reserved for the widget area (title, inputs, widgets, outputs).
// This must match the actual vertical space consumed by all widgets above
// the preview so that resizing the node only grows the preview, not the
// trigger-words textarea.
const WIDGET_AREA_END = 260;
if (!c) { preview.style.display = "none"; return; }
// Preview fills all remaining vertical space, minimum 80px.
const h = Math.max(80, node.size[1] - WIDGET_AREA_END - margin);
const y = node.size[1] - h - margin;
return { x, y, w, h };
}
function drawImagePreview(node, ctx) {
const cache = node.civitai_cache;
if (!cache) return;
const images = cache.images || [];
if (images.length === 0) return;
const images = c.images || [];
if (!images.length) { preview.style.display = "none"; return; }
preview.style.display = "flex";
const idx = node.civitai_image_index || 0;
const imgInfo = images[idx % images.length];
if (!imgInfo) return;
const i = idx % images.length;
const info = images[i];
if (!info) return;
const area = getPreviewArea(node);
pag.textContent = `${i + 1} / ${images.length}`;
al.style.display = images.length > 1 ? "flex" : "none";
ar.style.display = images.length > 1 ? "flex" : "none";
// Draw placeholder background
ctx.fillStyle = "#222";
ctx.fillRect(area.x, area.y, area.w, area.h);
// Load and draw image
const img = getOrLoadImage(node, imgInfo);
if (img && img.complete && img.naturalWidth > 0) {
const scale = Math.min(area.w / img.naturalWidth, area.h / img.naturalHeight);
const drawW = img.naturalWidth * scale;
const drawH = img.naturalHeight * scale;
const drawX = area.x + (area.w - drawW) / 2;
const drawY = area.y + (area.h - drawH) / 2;
// Apply NSFW blur if needed
const isNsfw = imgInfo.nsfw === true;
const isUnblurred = node.civitai_nsfw_unblurred[idx];
if (isNsfw && !isUnblurred) {
ctx.filter = "blur(10px)";
}
ctx.drawImage(img, drawX, drawY, drawW, drawH);
ctx.filter = "none";
// NSFW badge
if (isNsfw && !isUnblurred) {
ctx.fillStyle = "rgba(255, 0, 0, 0.7)";
ctx.fillRect(drawX + 5, drawY + 5, 50, 20);
ctx.fillStyle = "#fff";
ctx.font = "bold 12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("NSFW", drawX + 30, drawY + 15);
}
const imgIdx = images.findIndex(im => im.url === info.url);
if (imgIdx < 0) return;
const key = `${c.hash}_${imgIdx}`;
let imgObj = _imageCache[key];
if (!imgObj || !imgObj.complete) {
imgObj = new Image();
_setImageCache(key, imgObj);
imgObj.src = `${API_BASE}/image/${c.hash}/${imgIdx}`;
}
// Draw pagination indicator
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
ctx.fillRect(area.x + area.w / 2 - 30, area.y + area.h - 25, 60, 20);
ctx.fillStyle = "#fff";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(`${idx + 1} / ${images.length}`, area.x + area.w / 2, area.y + area.h - 15);
// Draw arrow zones
if (images.length > 1) {
ctx.fillStyle = "rgba(255, 255, 255, 0.1)";
ctx.fillRect(area.x, area.y, ARROW_WIDTH, area.h);
ctx.fillRect(area.x + area.w - ARROW_WIDTH, area.y, ARROW_WIDTH, area.h);
ctx.fillStyle = "#fff";
ctx.font = "16px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("<", area.x + ARROW_WIDTH / 2, area.y + area.h / 2);
ctx.fillText(">", area.x + area.w - ARROW_WIDTH / 2, area.y + area.h / 2);
}
}
function handleNodeClick(node, e, pos) {
const cache = node.civitai_cache;
if (!cache) return false;
const images = cache.images || [];
if (images.length === 0) return false;
const area = getPreviewArea(node);
const [mx, my] = pos;
// Check if click is inside preview area
if (mx < area.x || mx > area.x + area.w || my < area.y || my > area.y + area.h) {
return false;
}
const idx = node.civitai_image_index || 0;
// Left arrow click
if (images.length > 1 && mx < area.x + ARROW_WIDTH) {
node.civitai_image_index = (idx - 1 + images.length) % images.length;
node.setDirtyCanvas(true, true);
return true;
}
// Right arrow click
if (images.length > 1 && mx > area.x + area.w - ARROW_WIDTH) {
node.civitai_image_index = (idx + 1) % images.length;
node.setDirtyCanvas(true, true);
return true;
}
// NSFW unblur toggle (click anywhere in the image area)
const imgInfo = images[idx % images.length];
if (imgInfo && imgInfo.nsfw === true) {
node.civitai_nsfw_unblurred[idx] = !node.civitai_nsfw_unblurred[idx];
node.setDirtyCanvas(true, true);
return true;
}
return false;
imgEl.src = imgObj.src;
nsfw.style.display = (info.nsfw && !node.civitai_nsfw_unblurred[i]) ? "flex" : "none";
}
function _setImageCache(key, img) {
if (!(key in _imageCache)) {
_imageCacheKeys.push(key);
if (_imageCacheKeys.length > MAX_IMAGE_CACHE) {
const oldest = _imageCacheKeys.shift();
delete _imageCache[oldest];
}
if (_imageCacheKeys.length > MAX_IMAGE_CACHE) delete _imageCache[_imageCacheKeys.shift()];
}
_imageCache[key] = img;
}
function getOrLoadImage(node, imgInfo) {
const hash = node.civitai_cache?.hash;
if (!hash) return null;
// Find the index of this image in the cache
const images = node.civitai_cache.images || [];
const idx = images.findIndex(img => img.url === imgInfo.url);
if (idx < 0) return null;
const cacheKey = `${hash}_${idx}`;
if (_imageCache[cacheKey]) return _imageCache[cacheKey];
const img = new Image();
img.onload = () => { app.canvas.setDirty(true, true); };
img.src = `${API_BASE}/image/${hash}/${idx}`;
_setImageCache(cacheKey, img);
return img;
}
+96
View File
@@ -1,4 +1,5 @@
import json
import os
import folder_paths
@@ -63,3 +64,98 @@ class KaiCivitaiLoRALoader(LoraLoader):
civitai_json = json.dumps(cache)
return (model_out, clip_out, trigger_words, civitai_json)
class TextLineCount:
"""Count the number of lines in a multiline text input."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": ("STRING", {"multiline": True}),
}
}
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("count",)
FUNCTION = "count_lines"
CATEGORY = "KaiUtilities/text"
def count_lines(self, text):
if not text:
return (0,)
count = text.count("\n") + 1
return (count,)
class SaveAudioSilent:
"""Save audio without being a terminal output node. For use inside for-loops."""
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
self.type = "output"
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"audio": ("AUDIO",),
"filename_prefix": ("STRING", {"default": "audio"}),
},
"optional": {
"index": ("INT", {"default": 0, "min": 0, "max": 999999}),
},
}
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "save_audio"
CATEGORY = "KaiUtilities/audio"
def save_audio(self, audio, filename_prefix, index=0):
if audio is None:
print("[SaveAudioSilent] WARNING: audio input is None, skipping save")
return (audio,)
# Extract waveform and sample_rate — handle multiple dict key conventions
# Use explicit None checks (not "or") because tensors are not bool-safe
waveform = audio.get("waveform")
if waveform is None:
waveform = audio.get("samples")
if waveform is None:
waveform = audio.get("data")
sample_rate = audio.get("sample_rate")
if sample_rate is None:
sample_rate = audio.get("sr")
if sample_rate is None:
sample_rate = audio.get("sampling_rate")
if sample_rate is None:
sample_rate = 44100
if waveform is None:
print(f"[SaveAudioSilent] WARNING: no waveform found in audio dict keys={list(audio.keys())}, skipping save")
return (audio,)
try:
import torchaudio
# Resolve output path with auto-incrementing counter
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
filename_prefix, self.output_dir, 0, 0
)
os.makedirs(full_output_folder, exist_ok=True)
full_path = os.path.join(full_output_folder, f"{filename}_{counter:05d}_.wav")
# waveform: [batch, channels, samples] -> [channels, samples] for torchaudio
wav = waveform.cpu()
if wav.dim() == 3:
wav = wav.squeeze(0)
torchaudio.save(full_path, wav, int(sample_rate))
print(f"[SaveAudioSilent] Saved: {full_path}")
except Exception as e:
print(f"[SaveAudioSilent] ERROR saving audio: {e}")
raise
return (audio,)