From 1e4d1b8f15c699a93f62c01765920fe18e86e809 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Mon, 13 Oct 2025 23:23:32 +0800 Subject: [PATCH] feat(nodes): add Promp (LoraManager) node and autocomplete support --- __init__.py | 3 + py/metadata_collector/node_extractors.py | 1 + py/nodes/prompt.py | 59 ++++ .../components/autocomplete.behavior.test.js | 2 +- web/comfyui/autocomplete.js | 232 ++++++++++++---- web/comfyui/loras_widget.js | 7 +- web/comfyui/loras_widget_components.js | 225 --------------- web/comfyui/preview_tooltip.js | 258 ++++++++++++++++++ web/comfyui/prompt.js | 29 ++ web/comfyui/utils.js | 22 +- 10 files changed, 546 insertions(+), 292 deletions(-) create mode 100644 py/nodes/prompt.py create mode 100644 web/comfyui/preview_tooltip.js create mode 100644 web/comfyui/prompt.js diff --git a/__init__.py b/__init__.py index 5223fc45..8e8fbd26 100644 --- a/__init__.py +++ b/__init__.py @@ -2,6 +2,7 @@ try: # pragma: no cover - import fallback for pytest collection from .py.lora_manager import LoraManager from .py.nodes.lora_loader import LoraManagerLoader, LoraManagerTextLoader from .py.nodes.trigger_word_toggle import TriggerWordToggle + from .py.nodes.prompt import PromptLoraManager from .py.nodes.lora_stacker import LoraStacker from .py.nodes.save_image import SaveImage from .py.nodes.debug_metadata import DebugMetadata @@ -17,6 +18,7 @@ except ImportError: # pragma: no cover - allows running under pytest without pa if str(package_root) not in sys.path: sys.path.append(str(package_root)) + PromptLoraManager = importlib.import_module("py.nodes.prompt").PromptLoraManager LoraManager = importlib.import_module("py.lora_manager").LoraManager LoraManagerLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerLoader LoraManagerTextLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerTextLoader @@ -29,6 +31,7 @@ except ImportError: # pragma: no cover - allows running under pytest without pa init_metadata_collector = importlib.import_module("py.metadata_collector").init NODE_CLASS_MAPPINGS = { + PromptLoraManager.NAME: PromptLoraManager, LoraManagerLoader.NAME: LoraManagerLoader, LoraManagerTextLoader.NAME: LoraManagerTextLoader, TriggerWordToggle.NAME: TriggerWordToggle, diff --git a/py/metadata_collector/node_extractors.py b/py/metadata_collector/node_extractors.py index 2f99db54..289e2160 100644 --- a/py/metadata_collector/node_extractors.py +++ b/py/metadata_collector/node_extractors.py @@ -666,6 +666,7 @@ NODE_EXTRACTORS = { "LoraManagerLoader": LoraLoaderManagerExtractor, # Conditioning "CLIPTextEncode": CLIPTextEncodeExtractor, + "PromptLoraManager": CLIPTextEncodeExtractor, "CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux "WAS_Text_to_Conditioning": CLIPTextEncodeExtractor, "AdvancedCLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/BlenderNeko/ComfyUI_ADV_CLIP_emb diff --git a/py/nodes/prompt.py b/py/nodes/prompt.py new file mode 100644 index 00000000..220505c2 --- /dev/null +++ b/py/nodes/prompt.py @@ -0,0 +1,59 @@ +from typing import Any, Optional +from nodes import CLIPTextEncode # type: ignore + +class PromptLoraManager: + """Encodes text (and optional trigger words) into CLIP conditioning.""" + + NAME = "Prompt (LoraManager)" + CATEGORY = "Lora Manager/conditioning" + DESCRIPTION = ( + "Encodes a text prompt using a CLIP model into an embedding that can be used " + "to guide the diffusion model towards generating specific images." + ) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ( + 'STRING', + { + "multiline": True, + "pysssss.autocomplete": False, + "dynamicPrompts": True, + "tooltip": "The text to be encoded.", + }, + ), + "clip": ( + 'CLIP', + {"tooltip": "The CLIP model used for encoding the text."}, + ), + }, + "optional": { + "trigger_words": ( + 'STRING', + { + "forceInput": True, + "tooltip": ( + "Optional trigger words to prepend to the text before " + "encoding." + ) + }, + ) + }, + } + + RETURN_TYPES = ('CONDITIONING', 'STRING',) + RETURN_NAMES = ('CONDITIONING', 'PROMPT',) + OUTPUT_TOOLTIPS = ( + "A conditioning containing the embedded text used to guide the diffusion model.", + ) + FUNCTION = "encode" + + def encode(self, text: str, clip: Any, trigger_words: Optional[str] = None): + prompt = text + if trigger_words: + prompt = ", ".join([trigger_words, text]) + + conditioning = CLIPTextEncode().encode(clip, prompt)[0] + return (conditioning, prompt,) \ No newline at end of file diff --git a/tests/frontend/components/autocomplete.behavior.test.js b/tests/frontend/components/autocomplete.behavior.test.js index 742fdf16..d0497be7 100644 --- a/tests/frontend/components/autocomplete.behavior.test.js +++ b/tests/frontend/components/autocomplete.behavior.test.js @@ -10,7 +10,7 @@ const { API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname, APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname, CARET_HELPER_MODULE: new URL('../../../web/comfyui/textarea_caret_helper.js', import.meta.url).pathname, - PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/loras_widget_components.js', import.meta.url).pathname, + PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/preview_tooltip.js', import.meta.url).pathname, AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname, })); diff --git a/web/comfyui/autocomplete.js b/web/comfyui/autocomplete.js index 790c062a..ae9e1b30 100644 --- a/web/comfyui/autocomplete.js +++ b/web/comfyui/autocomplete.js @@ -15,15 +15,136 @@ function parseUsageTipNumber(value) { return null; } +function splitRelativePath(relativePath = '') { + const parts = relativePath.split(/[/\\]+/).filter(Boolean); + const fileName = parts.pop() ?? ''; + return { + directories: parts, + fileName, + }; +} + +function removeGeneralExtension(fileName = '') { + return fileName.replace(/\.[^.]+$/, ''); +} + +function removeLoraExtension(fileName = '') { + return fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, ''); +} + +function createDefaultBehavior(modelType) { + return { + enablePreview: false, + async getInsertText(_instance, relativePath) { + const trimmed = relativePath?.trim() ?? ''; + if (!trimmed) { + return ''; + } + return `${trimmed}, `; + }, + }; +} + +const MODEL_BEHAVIORS = { + loras: { + enablePreview: true, + init(instance) { + if (!instance.options.showPreview) { + return; + } + instance.initPreviewTooltip({ modelType: instance.modelType }); + }, + showPreview(instance, relativePath, itemElement) { + if (!instance.previewTooltip) { + return; + } + instance.showPreviewForItem(relativePath, itemElement); + }, + hidePreview(instance) { + if (!instance.previewTooltip) { + return; + } + instance.previewTooltip.hide(); + }, + destroy(instance) { + if (instance.previewTooltip) { + instance.previewTooltip.cleanup(); + instance.previewTooltip = null; + } + }, + async getInsertText(_instance, relativePath) { + const fileName = removeLoraExtension(splitRelativePath(relativePath).fileName); + + let strength = 1.0; + let hasStrength = false; + let clipStrength = null; + + try { + const response = await api.fetchApi(`/lm/loras/usage-tips-by-path?relative_path=${encodeURIComponent(relativePath)}`); + if (response.ok) { + const data = await response.json(); + if (data.success && data.usage_tips) { + try { + const usageTips = JSON.parse(data.usage_tips); + const parsedStrength = parseUsageTipNumber(usageTips.strength); + if (parsedStrength !== null) { + strength = parsedStrength; + hasStrength = true; + } + const clipSource = usageTips.clip_strength ?? usageTips.clipStrength; + const parsedClipStrength = parseUsageTipNumber(clipSource); + if (parsedClipStrength !== null) { + clipStrength = parsedClipStrength; + if (!hasStrength) { + strength = 1.0; + } + } + } catch (parseError) { + console.warn('Failed to parse usage tips JSON:', parseError); + } + } + } + } catch (error) { + console.warn('Failed to fetch usage tips:', error); + } + + if (clipStrength !== null) { + return `, `; + } + return `, `; + } + }, + embeddings: { + enablePreview: true, + init(instance) { + if (!instance.options.showPreview) { + return; + } + instance.initPreviewTooltip({ modelType: instance.modelType }); + }, + async getInsertText(_instance, relativePath) { + const { directories, fileName } = splitRelativePath(relativePath); + const trimmedName = removeGeneralExtension(fileName); + const folder = directories.length ? `${directories.join('\\')}\\` : ''; + return `embedding:${folder}${trimmedName}, `; + }, + }, +}; + +function getModelBehavior(modelType) { + return MODEL_BEHAVIORS[modelType] ?? createDefaultBehavior(modelType); +} + class AutoComplete { constructor(inputElement, modelType = 'loras', options = {}) { this.inputElement = inputElement; this.modelType = modelType; + this.behavior = getModelBehavior(modelType); this.options = { maxItems: 20, minChars: 1, debounceDelay: 200, - showPreview: true, + showPreview: this.behavior.enablePreview ?? false, ...options }; @@ -34,6 +155,7 @@ class AutoComplete { this.isVisible = false; this.currentSearchTerm = ''; this.previewTooltip = null; + this.previewTooltipPromise = null; // Initialize TextAreaCaretHelper this.helper = new TextAreaCaretHelper(inputElement, () => app.canvas.ds.scale); @@ -88,19 +210,24 @@ class AutoComplete { // Append to body to avoid overflow issues document.body.appendChild(this.dropdown); - - // Initialize preview tooltip if needed - if (this.options.showPreview && this.modelType === 'loras') { - this.initPreviewTooltip(); + + if (typeof this.behavior.init === 'function') { + this.behavior.init(this); } } - initPreviewTooltip() { + initPreviewTooltip(options = {}) { + if (this.previewTooltip || this.previewTooltipPromise) { + return; + } // Dynamically import and create preview tooltip - import('./loras_widget_components.js').then(module => { - this.previewTooltip = new module.PreviewTooltip(); + this.previewTooltipPromise = import('./preview_tooltip.js').then(module => { + const config = { modelType: this.modelType, ...options }; + this.previewTooltip = new module.PreviewTooltip(config); }).catch(err => { console.warn('Failed to load preview tooltip:', err); + }).finally(() => { + this.previewTooltipPromise = null; }); } @@ -220,7 +347,6 @@ class AutoComplete { // Hover and selection handlers item.addEventListener('mouseenter', () => { this.selectItem(index); - this.showPreviewForItem(relativePath, item); }); item.addEventListener('mouseleave', () => { @@ -256,7 +382,7 @@ class AutoComplete { } showPreviewForItem(relativePath, itemElement) { - if (!this.previewTooltip) return; + if (!this.options.showPreview || !this.previewTooltip) return; // Extract filename without extension for preview const fileName = relativePath.split(/[/\\]/).pop(); @@ -271,7 +397,12 @@ class AutoComplete { } hidePreview() { - if (this.previewTooltip) { + if (!this.options.showPreview) { + return; + } + if (typeof this.behavior.hidePreview === 'function') { + this.behavior.hidePreview(this); + } else if (this.previewTooltip) { this.previewTooltip.hide(); } } @@ -354,7 +485,11 @@ class AutoComplete { // Show preview for selected item if (this.options.showPreview) { - this.showPreviewForItem(this.items[index], item); + if (typeof this.behavior.showPreview === 'function') { + this.behavior.showPreview(this, this.items[index], item); + } else if (this.previewTooltip) { + this.showPreviewForItem(this.items[index], item); + } } } } @@ -390,47 +525,12 @@ class AutoComplete { } async insertSelection(relativePath) { - // Extract just the filename for LoRA name - const fileName = relativePath.split(/[/\\]/).pop().replace(/\.(safetensors|ckpt|pt|bin)$/i, ''); - - // Get usage tips and extract strength information - let strength = 1.0; // Default strength - let hasStrength = false; - let clipStrength = null; - try { - const response = await api.fetchApi(`/lm/loras/usage-tips-by-path?relative_path=${encodeURIComponent(relativePath)}`); - if (response.ok) { - const data = await response.json(); - if (data.success && data.usage_tips) { - try { - const usageTips = JSON.parse(data.usage_tips); - const parsedStrength = parseUsageTipNumber(usageTips.strength); - if (parsedStrength !== null) { - strength = parsedStrength; - hasStrength = true; - } - const clipSource = usageTips.clip_strength ?? usageTips.clipStrength; - const parsedClipStrength = parseUsageTipNumber(clipSource); - if (parsedClipStrength !== null) { - clipStrength = parsedClipStrength; - if (!hasStrength) { - strength = 1.0; - } - } - } catch (parseError) { - console.warn('Failed to parse usage tips JSON:', parseError); - } - } - } - } catch (error) { - console.warn('Failed to fetch usage tips:', error); + const insertText = await this.getInsertText(relativePath); + if (!insertText) { + this.hide(); + return; } - // Format the LoRA code with strength values - const loraCode = clipStrength !== null - ? `, ` - : `, `; - const currentValue = this.inputElement.value; const caretPos = this.getCaretPosition(); @@ -440,8 +540,8 @@ class AutoComplete { const searchStartPos = caretPos - searchTerm.length; // Only replace the search term, not everything after the last comma - const newValue = currentValue.substring(0, searchStartPos) + loraCode + currentValue.substring(caretPos); - const newCaretPos = searchStartPos + loraCode.length; + const newValue = currentValue.substring(0, searchStartPos) + insertText + currentValue.substring(caretPos); + const newCaretPos = searchStartPos + insertText.length; this.inputElement.value = newValue; @@ -455,18 +555,42 @@ class AutoComplete { this.inputElement.focus(); this.inputElement.setSelectionRange(newCaretPos, newCaretPos); } + + async getInsertText(relativePath) { + if (typeof this.behavior.getInsertText === 'function') { + try { + const result = await this.behavior.getInsertText(this, relativePath); + if (typeof result === 'string' && result.length > 0) { + return result; + } + } catch (error) { + console.warn('Failed to format autocomplete insertion:', error); + } + } + + const trimmed = typeof relativePath === 'string' ? relativePath.trim() : ''; + if (!trimmed) { + return ''; + } + return `${trimmed}, `; + } destroy() { if (this.debounceTimer) { clearTimeout(this.debounceTimer); } - if (this.previewTooltip) { + if (typeof this.behavior.destroy === 'function') { + this.behavior.destroy(this); + } else if (this.previewTooltip) { this.previewTooltip.cleanup(); + this.previewTooltip = null; } + this.previewTooltipPromise = null; if (this.dropdown && this.dropdown.parentNode) { this.dropdown.parentNode.removeChild(this.dropdown); + this.dropdown = null; } // Remove event listeners would be added here if we tracked them diff --git a/web/comfyui/loras_widget.js b/web/comfyui/loras_widget.js index 29946313..73755782 100644 --- a/web/comfyui/loras_widget.js +++ b/web/comfyui/loras_widget.js @@ -1,4 +1,4 @@ -import { createToggle, createArrowButton, PreviewTooltip, createDragHandle, updateEntrySelection, createExpandButton, updateExpandButtonState } from "./loras_widget_components.js"; +import { createToggle, createArrowButton, createDragHandle, updateEntrySelection, createExpandButton, updateExpandButtonState } from "./loras_widget_components.js"; import { parseLoraValue, formatLoraValue, @@ -12,6 +12,7 @@ import { } from "./loras_widget_utils.js"; import { initDrag, createContextMenu, initHeaderDrag, initReorderDrag, handleKeyboardNavigation } from "./loras_widget_events.js"; import { forwardMiddleMouseToCanvas } from "./utils.js"; +import { PreviewTooltip } from "./preview_tooltip.js"; export function addLorasWidget(node, name, opts, callback) { // Create container for loras @@ -39,7 +40,7 @@ export function addLorasWidget(node, name, opts, callback) { const defaultValue = opts?.defaultVal || []; // Create preview tooltip instance - const previewTooltip = new PreviewTooltip(); + const previewTooltip = new PreviewTooltip({ modelType: "loras" }); // Selection state - only one LoRA can be selected at a time let selectedLora = null; @@ -705,4 +706,4 @@ export function addLorasWidget(node, name, opts, callback) { }; return { minWidth: 400, minHeight: defaultHeight, widget }; -} \ No newline at end of file +} diff --git a/web/comfyui/loras_widget_components.js b/web/comfyui/loras_widget_components.js index fc40dce3..b8e91fd3 100644 --- a/web/comfyui/loras_widget_components.js +++ b/web/comfyui/loras_widget_components.js @@ -1,5 +1,3 @@ -import { api } from "../../scripts/api.js"; - // Function to create toggle element export function createToggle(active, onChange) { const toggle = document.createElement("div"); @@ -217,229 +215,6 @@ export function createMenuItem(text, icon, onClick) { return menuItem; } -// Preview tooltip class -export class PreviewTooltip { - constructor() { - this.element = document.createElement('div'); - Object.assign(this.element.style, { - position: 'fixed', - zIndex: 9999, - background: 'rgba(0, 0, 0, 0.85)', - borderRadius: '6px', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)', - display: 'none', - overflow: 'hidden', - maxWidth: '300px', - pointerEvents: 'none', // Prevent interference with autocomplete - }); - document.body.appendChild(this.element); - this.hideTimeout = null; - this.isFromAutocomplete = false; - - // Modified event listeners for autocomplete compatibility - this.globalClickHandler = (e) => { - // Don't hide if click is on autocomplete dropdown - if (!e.target.closest('.comfy-autocomplete-dropdown')) { - this.hide(); - } - }; - document.addEventListener('click', this.globalClickHandler); - - this.globalScrollHandler = () => this.hide(); - document.addEventListener('scroll', this.globalScrollHandler, true); - } - - async show(loraName, x, y, fromAutocomplete = false) { - try { - // Clear previous hide timer - if (this.hideTimeout) { - clearTimeout(this.hideTimeout); - this.hideTimeout = null; - } - - // Track if this is from autocomplete - this.isFromAutocomplete = fromAutocomplete; - - // Don't redisplay the same lora preview - if (this.element.style.display === 'block' && this.currentLora === loraName) { - this.position(x, y); - return; - } - - this.currentLora = loraName; - - // Get preview URL - const response = await api.fetchApi(`/lm/loras/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'); - } - - // Clear existing content - while (this.element.firstChild) { - this.element.removeChild(this.element.firstChild); - } - - // Create media container with relative positioning - const mediaContainer = document.createElement('div'); - Object.assign(mediaContainer.style, { - position: 'relative', - maxWidth: '300px', - maxHeight: '300px', - }); - - 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', - display: 'block', - }); - - if (isVideo) { - mediaElement.autoplay = true; - mediaElement.loop = true; - mediaElement.muted = true; - mediaElement.controls = false; - } - - // Create name label with absolute positioning - const nameLabel = document.createElement('div'); - nameLabel.textContent = loraName; - Object.assign(nameLabel.style, { - position: 'absolute', - bottom: '0', - left: '0', - right: '0', - padding: '8px', - color: 'white', - fontSize: '13px', - fontFamily: "'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif", - background: 'linear-gradient(transparent, rgba(0, 0, 0, 0.8))', - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - textAlign: 'center', - backdropFilter: 'blur(4px)', - WebkitBackdropFilter: 'blur(4px)', - }); - - mediaContainer.appendChild(mediaElement); - mediaContainer.appendChild(nameLabel); - this.element.appendChild(mediaContainer); - - // Show element with opacity 0 first to get dimensions - this.element.style.opacity = '0'; - this.element.style.display = 'block'; - - // Wait for media to load before positioning - const waitForLoad = () => { - return new Promise((resolve) => { - if (isVideo) { - if (mediaElement.readyState >= 2) { // HAVE_CURRENT_DATA - resolve(); - } else { - mediaElement.addEventListener('loadeddata', resolve, { once: true }); - mediaElement.addEventListener('error', resolve, { once: true }); - } - } else { - if (mediaElement.complete) { - resolve(); - } else { - mediaElement.addEventListener('load', resolve, { once: true }); - mediaElement.addEventListener('error', resolve, { once: true }); - } - } - - // Set a timeout to prevent hanging - setTimeout(resolve, 1000); - }); - }; - - // Set source after setting up load listeners - mediaElement.src = data.preview_url; - - // Wait for content to load, then position and show - await waitForLoad(); - - // Small delay to ensure layout is complete - requestAnimationFrame(() => { - this.position(x, y); - this.element.style.transition = 'opacity 0.15s ease'; - this.element.style.opacity = '1'; - }); - } catch (error) { - console.warn('Failed to load preview:', error); - } - } - - position(x, y) { - // Ensure preview box doesn't exceed viewport boundaries - const rect = this.element.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - let left = x + 10; // Default 10px offset to the right of mouse - let top = y + 10; // Default 10px offset below mouse - - // Check right boundary - if (left + rect.width > viewportWidth) { - left = x - rect.width - 10; - } - - // Check bottom boundary - if (top + rect.height > viewportHeight) { - top = y - rect.height - 10; - } - - // Ensure minimum distance from edges - left = Math.max(10, Math.min(left, viewportWidth - rect.width - 10)); - top = Math.max(10, Math.min(top, viewportHeight - rect.height - 10)); - - Object.assign(this.element.style, { - left: `${left}px`, - top: `${top}px` - }); - } - - hide() { - // Use fade-out effect - if (this.element.style.display === 'block') { - this.element.style.opacity = '0'; - this.hideTimeout = setTimeout(() => { - this.element.style.display = 'none'; - this.currentLora = null; - this.isFromAutocomplete = false; - // Stop video playback - const video = this.element.querySelector('video'); - if (video) { - video.pause(); - } - this.hideTimeout = null; - }, 150); - } - } - - cleanup() { - if (this.hideTimeout) { - clearTimeout(this.hideTimeout); - } - // Remove event listeners properly - document.removeEventListener('click', this.globalClickHandler); - document.removeEventListener('scroll', this.globalScrollHandler, true); - this.element.remove(); - } -} - // Function to create expand/collapse button export function createExpandButton(isExpanded, onClick) { const button = document.createElement("button"); diff --git a/web/comfyui/preview_tooltip.js b/web/comfyui/preview_tooltip.js new file mode 100644 index 00000000..042bd45f --- /dev/null +++ b/web/comfyui/preview_tooltip.js @@ -0,0 +1,258 @@ +import { api } from "../../scripts/api.js"; + +/** + * Lightweight preview tooltip that can display images or videos for different model types. + */ +export class PreviewTooltip { + constructor(options = {}) { + const { + modelType = "loras", + previewUrlResolver, + displayNameFormatter, + } = options; + + this.modelType = modelType; + this.previewUrlResolver = + typeof previewUrlResolver === "function" + ? previewUrlResolver + : (name) => this.defaultPreviewUrlResolver(name); + this.displayNameFormatter = + typeof displayNameFormatter === "function" + ? displayNameFormatter + : (name) => name; + + this.element = document.createElement("div"); + Object.assign(this.element.style, { + position: "fixed", + zIndex: 9999, + background: "rgba(0, 0, 0, 0.85)", + borderRadius: "6px", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)", + display: "none", + overflow: "hidden", + maxWidth: "300px", + pointerEvents: "none", + }); + document.body.appendChild(this.element); + this.hideTimeout = null; + this.isFromAutocomplete = false; + this.currentModelName = null; + + this.globalClickHandler = (event) => { + if (!event.target.closest(".comfy-autocomplete-dropdown")) { + this.hide(); + } + }; + document.addEventListener("click", this.globalClickHandler); + + this.globalScrollHandler = () => this.hide(); + document.addEventListener("scroll", this.globalScrollHandler, true); + } + + async defaultPreviewUrlResolver(modelName) { + const response = await api.fetchApi( + `/lm/${this.modelType}/preview-url?name=${encodeURIComponent(modelName)}`, + { 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"); + } + return { + previewUrl: data.preview_url, + displayName: data.display_name ?? modelName, + }; + } + + async resolvePreviewData(modelName) { + const raw = await this.previewUrlResolver(modelName); + if (!raw) { + throw new Error("No preview data returned"); + } + if (typeof raw === "string") { + return { + previewUrl: raw, + displayName: this.displayNameFormatter(modelName), + }; + } + + const { previewUrl, displayName } = raw; + if (!previewUrl) { + throw new Error("No preview URL available"); + } + return { + previewUrl, + displayName: + displayName !== undefined + ? displayName + : this.displayNameFormatter(modelName), + }; + } + + async show(modelName, x, y, fromAutocomplete = false) { + try { + if (this.hideTimeout) { + clearTimeout(this.hideTimeout); + this.hideTimeout = null; + } + + this.isFromAutocomplete = fromAutocomplete; + + if ( + this.element.style.display === "block" && + this.currentModelName === modelName + ) { + this.position(x, y); + return; + } + + this.currentModelName = modelName; + const { previewUrl, displayName } = await this.resolvePreviewData( + modelName + ); + + while (this.element.firstChild) { + this.element.removeChild(this.element.firstChild); + } + + const mediaContainer = document.createElement("div"); + Object.assign(mediaContainer.style, { + position: "relative", + maxWidth: "300px", + maxHeight: "300px", + }); + + const isVideo = previewUrl.endsWith(".mp4"); + const mediaElement = isVideo + ? document.createElement("video") + : document.createElement("img"); + + Object.assign(mediaElement.style, { + maxWidth: "300px", + maxHeight: "300px", + objectFit: "contain", + display: "block", + }); + + if (isVideo) { + mediaElement.autoplay = true; + mediaElement.loop = true; + mediaElement.muted = true; + mediaElement.controls = false; + } + + const nameLabel = document.createElement("div"); + nameLabel.textContent = displayName; + Object.assign(nameLabel.style, { + position: "absolute", + bottom: "0", + left: "0", + right: "0", + padding: "8px", + color: "white", + fontSize: "13px", + fontFamily: + "'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif", + background: "linear-gradient(transparent, rgba(0, 0, 0, 0.8))", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + textAlign: "center", + backdropFilter: "blur(4px)", + WebkitBackdropFilter: "blur(4px)", + }); + + mediaContainer.appendChild(mediaElement); + mediaContainer.appendChild(nameLabel); + this.element.appendChild(mediaContainer); + + this.element.style.opacity = "0"; + this.element.style.display = "block"; + + const waitForLoad = () => + new Promise((resolve) => { + if (isVideo) { + if (mediaElement.readyState >= 2) { + resolve(); + } else { + mediaElement.addEventListener("loadeddata", resolve, { + once: true, + }); + mediaElement.addEventListener("error", resolve, { once: true }); + } + } else if (mediaElement.complete) { + resolve(); + } else { + mediaElement.addEventListener("load", resolve, { once: true }); + mediaElement.addEventListener("error", resolve, { once: true }); + } + + setTimeout(resolve, 1000); + }); + + mediaElement.src = previewUrl; + await waitForLoad(); + + requestAnimationFrame(() => { + this.position(x, y); + this.element.style.transition = "opacity 0.15s ease"; + this.element.style.opacity = "1"; + }); + } 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; + let top = y + 10; + + if (left + rect.width > viewportWidth) { + left = x - rect.width - 10; + } + + if (top + rect.height > viewportHeight) { + top = y - rect.height - 10; + } + + left = Math.max(10, Math.min(left, viewportWidth - rect.width - 10)); + top = Math.max(10, Math.min(top, viewportHeight - rect.height - 10)); + + Object.assign(this.element.style, { + left: `${left}px`, + top: `${top}px`, + }); + } + + hide() { + if (this.element.style.display === "block") { + this.element.style.opacity = "0"; + this.hideTimeout = setTimeout(() => { + this.element.style.display = "none"; + this.currentModelName = null; + this.isFromAutocomplete = false; + const video = this.element.querySelector("video"); + if (video) { + video.pause(); + } + this.hideTimeout = null; + }, 150); + } + } + + cleanup() { + if (this.hideTimeout) { + clearTimeout(this.hideTimeout); + } + document.removeEventListener("click", this.globalClickHandler); + document.removeEventListener("scroll", this.globalScrollHandler, true); + this.element.remove(); + } +} diff --git a/web/comfyui/prompt.js b/web/comfyui/prompt.js new file mode 100644 index 00000000..e0c5e076 --- /dev/null +++ b/web/comfyui/prompt.js @@ -0,0 +1,29 @@ +import { app } from "../../scripts/app.js"; +import { chainCallback, setupInputWidgetWithAutocomplete } from "./utils.js"; + +app.registerExtension({ + name: "LoraManager.Prompt", + + async beforeRegisterNodeDef(nodeType) { + if (nodeType.comfyClass === "Prompt (LoraManager)") { + chainCallback(nodeType.prototype, "onNodeCreated", function () { + this.serialize_widgets = true; + + const textWidget = this.widgets?.[0]; + if (!textWidget) { + return; + } + + const originalCallback = + typeof textWidget.callback === "function" ? textWidget.callback : null; + + textWidget.callback = setupInputWidgetWithAutocomplete( + this, + textWidget, + originalCallback, + "embeddings" + ); + }); + } + }, +}); diff --git a/web/comfyui/utils.js b/web/comfyui/utils.js index 54f41f24..7606eee8 100644 --- a/web/comfyui/utils.js +++ b/web/comfyui/utils.js @@ -383,27 +383,31 @@ export function mergeLoras(lorasText, lorasArr) { * @param {Object} node - The node instance * @param {Object} inputWidget - The input widget to add autocomplete to * @param {Function} originalCallback - The original callback function + * @param {string} [modelType='loras'] - The model type used by the autocomplete API + * @param {Object} [autocompleteOptions] - Additional options for the autocomplete instance * @returns {Function} Enhanced callback function with autocomplete */ -export function setupInputWidgetWithAutocomplete(node, inputWidget, originalCallback) { +export function setupInputWidgetWithAutocomplete(node, inputWidget, originalCallback, modelType = 'loras', autocompleteOptions = {}) { let autocomplete = null; + const defaultOptions = { + maxItems: 20, + minChars: 1, + debounceDelay: 200, + }; + const mergedOptions = { ...defaultOptions, ...autocompleteOptions }; // Enhanced callback that initializes autocomplete and calls original callback const enhancedCallback = (value) => { // Initialize autocomplete on first callback if not already done if (!autocomplete && inputWidget.inputEl) { - autocomplete = new AutoComplete(inputWidget.inputEl, 'loras', { - maxItems: 20, - minChars: 1, - debounceDelay: 200 - }); + autocomplete = new AutoComplete(inputWidget.inputEl, modelType, mergedOptions); // Store reference for cleanup node.autocomplete = autocomplete; } // Call the original callback - if (originalCallback) { - originalCallback(value); + if (typeof originalCallback === "function") { + originalCallback.call(node, value); } }; @@ -457,4 +461,4 @@ export function forwardMiddleMouseToCanvas(container) { app.canvas.processMouseUp(event); } }); -} \ No newline at end of file +}