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:
+11
-3
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,)
|
||||
|
||||
Reference in New Issue
Block a user