diff --git a/py/nodes/lora_loader.py b/py/nodes/lora_loader.py index e0094181..60c91472 100644 --- a/py/nodes/lora_loader.py +++ b/py/nodes/lora_loader.py @@ -1,10 +1,10 @@ -import re from nodes import LoraLoader from comfy.comfy_types import IO # type: ignore from ..services.lora_scanner import LoraScanner from ..config import config import asyncio import os +from .utils import FlexibleOptionalInputType, any_type class LoraManagerLoader: NAME = "Lora Loader (LoraManager)" @@ -23,10 +23,11 @@ class LoraManagerLoader: "placeholder": "LoRA syntax input: " }), }, + "optional": FlexibleOptionalInputType(any_type), } - RETURN_TYPES = ("MODEL", "CLIP", IO.STRING, IO.STRING) - RETURN_NAMES = ("MODEL", "CLIP", "loaded_loras", "trigger_words") + RETURN_TYPES = ("MODEL", "CLIP", IO.STRING) + RETURN_NAMES = ("MODEL", "CLIP", "trigger_words") FUNCTION = "load_loras" async def get_lora_info(self, lora_name): @@ -49,31 +50,28 @@ class LoraManagerLoader: return lora_name, [] # Fallback if not found def load_loras(self, model, clip, text, **kwargs): - """Loads multiple LoRAs based on the text input format.""" - for key, value in kwargs.items(): - print(f"{key}: {value}") - - lora_pattern = r'' - lora_matches = re.finditer(lora_pattern, text) - + """Loads multiple LoRAs based on the kwargs input.""" loaded_loras = [] all_trigger_words = [] - for match in lora_matches: - lora_name = match.group(1) - strength = float(match.group(2)) - - # Get lora path and trigger words - lora_path, trigger_words = asyncio.run(self.get_lora_info(lora_name)) - - # Apply the LoRA using the resolved path - model, clip = LoraLoader().load_lora(model, clip, lora_path, strength, strength) - loaded_loras.append(f"{lora_name}: {strength}") - - # Add trigger words to collection - all_trigger_words.extend(trigger_words) + if 'loras' in kwargs: + for lora in kwargs['loras']: + if not lora.get('active', False): + continue + + lora_name = lora['name'] + strength = float(lora['strength']) + + # Get lora path and trigger words + lora_path, trigger_words = asyncio.run(self.get_lora_info(lora_name)) + + # Apply the LoRA using the resolved path + model, clip = LoraLoader().load_lora(model, clip, lora_path, strength, strength) + loaded_loras.append(f"{lora_name}: {strength}") + + # Add trigger words to collection + all_trigger_words.extend(trigger_words) - loaded_loras_text = "\n".join(loaded_loras) if loaded_loras else "No LoRAs loaded" trigger_words_text = ", ".join(all_trigger_words) if all_trigger_words else "" - return (model, clip, loaded_loras_text, trigger_words_text) \ No newline at end of file + return (model, clip, trigger_words_text) \ No newline at end of file diff --git a/py/routes/api_routes.py b/py/routes/api_routes.py index 71dcf292..f6ea0346 100644 --- a/py/routes/api_routes.py +++ b/py/routes/api_routes.py @@ -41,6 +41,7 @@ class ApiRoutes: app.router.add_post('/api/settings', routes.update_settings) app.router.add_post('/api/move_model', routes.move_model) app.router.add_post('/loras/api/save-metadata', routes.save_metadata) + app.router.add_get('/api/lora-preview-url', routes.get_lora_preview_url) # Add new route async def delete_model(self, request: web.Request) -> web.Response: """Handle model deletion request""" @@ -593,3 +594,38 @@ class ApiRoutes: except Exception as e: logger.error(f"Error saving metadata: {e}", exc_info=True) return web.Response(text=str(e), status=500) + + async def get_lora_preview_url(self, request: web.Request) -> web.Response: + """Get the static preview URL for a LoRA file""" + try: + # Get lora file name from query parameters + lora_name = request.query.get('name') + if not lora_name: + return web.Response(text='Lora file name is required', status=400) + + # Get cache data + cache = await self.scanner.get_cached_data() + + # Search for the lora in cache data + for lora in cache.raw_data: + file_name = lora['file_name'] + if file_name == lora_name: + if preview_url := lora.get('preview_url'): + # Convert preview path to static URL + static_url = config.get_preview_static_url(preview_url) + if static_url: + return web.json_response({ + 'success': True, + 'preview_url': static_url + }) + break + + # If no preview URL found + return web.json_response({ + 'success': False, + 'error': 'No preview URL found for the specified lora' + }, status=404) + + except Exception as e: + logger.error(f"Error getting lora preview URL: {e}", exc_info=True) + return web.Response(text=str(e), status=500) diff --git a/web/comfyui/lora_loader.js b/web/comfyui/lora_loader.js index 3a19ed44..22e739bb 100644 --- a/web/comfyui/lora_loader.js +++ b/web/comfyui/lora_loader.js @@ -1,6 +1,5 @@ import { app } from "../../scripts/app.js"; import { addLorasWidget } from "./loras_widget.js"; -import { hideWidgetForGood } from "./utils.js"; function mergeLoras(lorasText, lorasJson) { const result = []; @@ -29,10 +28,6 @@ function mergeLoras(lorasText, lorasJson) { app.registerExtension({ name: "LoraManager.LoraLoader", - setup(...args) { - console.log("LoraLoader setup args:", args); - }, - async nodeCreated(node) { if (node.comfyClass === "Lora Loader (LoraManager)") { // Enable widget serialization @@ -43,9 +38,14 @@ app.registerExtension({ // Restore saved value if exists let existingLoras = []; if (node.widgets_values && node.widgets_values.length > 0) { - // 0 is input, 1 is loras widget + const savedValue = node.widgets_values[1]; try { - existingLoras = JSON.parse(node.widgets_values[1]); + // Check if the value is already an array/object + if (typeof savedValue === 'object' && savedValue !== null) { + existingLoras = savedValue; + } else if (typeof savedValue === 'string') { + existingLoras = JSON.parse(savedValue); + } } catch (e) { console.warn("Failed to parse loras data:", e); existingLoras = []; @@ -72,8 +72,6 @@ app.registerExtension({ node.lorasWidget.value = mergedLoras; }; - - console.log("node: ", node); }); } }, diff --git a/web/comfyui/loras_widget.js b/web/comfyui/loras_widget.js index 4818895b..ee49f6ad 100644 --- a/web/comfyui/loras_widget.js +++ b/web/comfyui/loras_widget.js @@ -1,3 +1,5 @@ +import { api } from "../../scripts/api.js"; + export function addLorasWidget(node, name, opts, callback) { // Create container for loras const container = document.createElement("div"); @@ -104,6 +106,129 @@ export function addLorasWidget(node, name, opts, callback) { return button; }; + // 添加预览弹窗组件 + class PreviewTooltip { + constructor() { + this.element = document.createElement('div'); + Object.assign(this.element.style, { + position: 'fixed', + zIndex: 9999, + padding: '4px', // 减小内边距 + background: 'rgba(0, 0, 0, 0.75)', // 稍微调整透明度 + borderRadius: '4px', // 减小圆角 + boxShadow: '0 2px 6px rgba(0, 0, 0, 0.15)', // 减小阴影 + display: 'none', + maxWidth: '300px', + maxHeight: '300px', + }); + document.body.appendChild(this.element); + this.hideTimeout = null; // 添加超时处理变量 + } + + async show(loraName, x, y) { + try { + // 清除之前的隐藏定时器 + if (this.hideTimeout) { + clearTimeout(this.hideTimeout); + this.hideTimeout = null; + } + // 获取预览URL + const response = await api.fetchApi(`/lora-preview-url?name=${encodeURIComponent(loraName)}`, { + method: 'GET' + }); + + if (!response.ok) { + throw new Error('Failed to fetch preview URL'); + } + + const data = await response.json(); + if (!data.success || !data.preview_url) { + throw new Error('No preview available'); + } + + // 清除现有内容 + while (this.element.firstChild) { + this.element.removeChild(this.element.firstChild); + } + + // 判断是否为视频 + const isVideo = data.preview_url.endsWith('.mp4'); + const mediaElement = isVideo ? + document.createElement('video') : + document.createElement('img'); + + Object.assign(mediaElement.style, { + maxWidth: '300px', + maxHeight: '300px', + objectFit: 'contain' + }); + + if (isVideo) { + mediaElement.autoplay = true; + mediaElement.loop = true; + mediaElement.muted = true; + mediaElement.controls = false; + } + + mediaElement.src = data.preview_url; + + this.element.appendChild(mediaElement); + this.position(x, y); + this.element.style.display = 'block'; + } catch (error) { + console.warn('Failed to load preview:', error); + } + } + + position(x, y) { + // 确保预览框不超出视窗边界 + const rect = this.element.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let left = x + 10; // 默认在鼠标右侧偏移10px + let top = y + 10; // 默认在鼠标下方偏移10px + + // 检查右边界 + if (left + rect.width > viewportWidth) { + left = x - rect.width - 10; + } + + // 检查下边界 + if (top + rect.height > viewportHeight) { + top = y - rect.height - 10; + } + + Object.assign(this.element.style, { + left: `${left}px`, + top: `${top}px` + }); + } + + hide() { + // 使用延迟来确保隐藏事件在显示事件之后执行 + this.hideTimeout = setTimeout(() => { + this.element.style.display = 'none'; + // 停止视频播放 + const video = this.element.querySelector('video'); + if (video) { + video.pause(); + } + this.hideTimeout = null; + }, 50); + } + + cleanup() { + if (this.hideTimeout) { + clearTimeout(this.hideTimeout); + } + this.element.remove(); + } + } + + // 创建预览tooltip实例 + const previewTooltip = new PreviewTooltip(); + // Function to render loras from data const renderLoras = (value, widget) => { // Clear existing content @@ -234,8 +359,36 @@ export function addLorasWidget(node, name, opts, callback) { whiteSpace: "nowrap", color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)", fontSize: "13px", + cursor: "pointer", // Add pointer cursor to indicate hoverable area }); + // Move preview tooltip events to nameEl instead of loraEl + nameEl.addEventListener('mouseenter', async (e) => { + e.stopPropagation(); // 阻止事件冒泡 + await previewTooltip.show(name, e.clientX, e.clientY); + }); + + nameEl.addEventListener('mousemove', (e) => { + e.stopPropagation(); // 阻止事件冒泡 + if (previewTooltip.element.style.display === 'block') { + previewTooltip.position(e.clientX, e.clientY); + } + }); + + nameEl.addEventListener('mouseleave', (e) => { + e.stopPropagation(); // 阻止事件冒泡 + previewTooltip.hide(); + }); + + // Remove the preview tooltip events from loraEl + loraEl.onmouseenter = () => { + loraEl.style.backgroundColor = active ? "rgba(50, 60, 80, 0.8)" : "rgba(40, 45, 55, 0.6)"; + }; + + loraEl.onmouseleave = () => { + loraEl.style.backgroundColor = active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)"; + }; + // Create strength control const strengthControl = document.createElement("div"); Object.assign(strengthControl.style, { @@ -264,14 +417,77 @@ export function addLorasWidget(node, name, opts, callback) { }); // Strength display - const strengthEl = document.createElement("div"); - const displayStrength = typeof strength === 'number' ? strength.toFixed(2) : Number(strength).toFixed(2); - strengthEl.textContent = displayStrength; + const strengthEl = document.createElement("input"); + strengthEl.type = "text"; + strengthEl.value = typeof strength === 'number' ? strength.toFixed(2) : Number(strength).toFixed(2); Object.assign(strengthEl.style, { - minWidth: "36px", + minWidth: "50px", + width: "50px", textAlign: "center", color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)", fontSize: "13px", + background: "none", + border: "1px solid transparent", + padding: "2px 4px", + borderRadius: "3px", + outline: "none", + }); + + // 添加hover效果 + strengthEl.addEventListener('mouseenter', () => { + strengthEl.style.border = "1px solid rgba(226, 232, 240, 0.2)"; + }); + + strengthEl.addEventListener('mouseleave', () => { + if (document.activeElement !== strengthEl) { + strengthEl.style.border = "1px solid transparent"; + } + }); + + // 处理焦点 + strengthEl.addEventListener('focus', () => { + strengthEl.style.border = "1px solid rgba(66, 153, 225, 0.6)"; + strengthEl.style.background = "rgba(0, 0, 0, 0.2)"; + // 自动选中所有内容 + strengthEl.select(); + }); + + strengthEl.addEventListener('blur', () => { + strengthEl.style.border = "1px solid transparent"; + strengthEl.style.background = "none"; + }); + + // 处理输入变化 + strengthEl.addEventListener('change', () => { + let newValue = parseFloat(strengthEl.value); + + // 验证输入 + if (isNaN(newValue)) { + newValue = 1.0; + } + + // 更新数值 + const lorasData = parseLoraValue(widget.value); + const loraIndex = lorasData.findIndex(l => l.name === name); + + if (loraIndex >= 0) { + lorasData[loraIndex].strength = newValue.toFixed(2); + + // 更新值并触发回调 + const newLorasValue = formatLoraValue(lorasData); + widget.value = newLorasValue; + widget.callback?.(newLorasValue); + + // 重新渲染 + renderLoras(newLorasValue, widget); + } + }); + + // 处理按键事件 + strengthEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + strengthEl.blur(); + } }); // Right arrow @@ -312,15 +528,6 @@ export function addLorasWidget(node, name, opts, callback) { loraEl.appendChild(leftSection); loraEl.appendChild(strengthControl); - // Add hover effect to the lora entry - loraEl.onmouseenter = () => { - loraEl.style.backgroundColor = active ? "rgba(50, 60, 80, 0.8)" : "rgba(40, 45, 55, 0.6)"; - }; - - loraEl.onmouseleave = () => { - loraEl.style.backgroundColor = active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)"; - }; - container.appendChild(loraEl); }); }; @@ -345,9 +552,6 @@ export function addLorasWidget(node, name, opts, callback) { lorasCount > 0 ? 60 + lorasCount * 44 : 60 // Header + entries or minimum height ); }, - onDraw: function() { - // Empty function - } }); // Initialize widget value using options methods @@ -355,11 +559,17 @@ export function addLorasWidget(node, name, opts, callback) { widget.callback = callback; + widget.computeSize = (width, height) => { + // console.log("loras_widget computeSize called: ", width, height); + return [400, 200]; + }; + // Render initial state renderLoras(widgetValue, widget); widget.onRemove = () => { container.remove(); + previewTooltip.cleanup(); }; return { minWidth: 400, minHeight: 200, widget };