mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat(nodes): add Promp (LoraManager) node and autocomplete support
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
59
py/nodes/prompt.py
Normal file
59
py/nodes/prompt.py
Normal file
@@ -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,)
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 `<lora:${fileName}:${strength}:${clipStrength}>, `;
|
||||
}
|
||||
return `<lora:${fileName}:${strength}>, `;
|
||||
}
|
||||
},
|
||||
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
|
||||
? `<lora:${fileName}:${strength}:${clipStrength}>, `
|
||||
: `<lora:${fileName}:${strength}>, `;
|
||||
|
||||
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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
258
web/comfyui/preview_tooltip.js
Normal file
258
web/comfyui/preview_tooltip.js
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
29
web/comfyui/prompt.js
Normal file
29
web/comfyui/prompt.js
Normal file
@@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user