refactor(lora-loader, lora-stacker, loras-widget): enhance handling of model and clip strengths; update formatting and UI interactions. Fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/171

This commit is contained in:
Will Miao
2025-05-09 11:05:59 +08:00
parent 9169bbd04d
commit 969f949330
5 changed files with 362 additions and 53 deletions

View File

@@ -1,10 +1,7 @@
import logging
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, get_lora_info, extract_lora_name, get_loras_list
logger = logging.getLogger(__name__)
@@ -51,23 +48,35 @@ class LoraManagerLoader:
_, trigger_words = asyncio.run(get_lora_info(lora_name))
all_trigger_words.extend(trigger_words)
loaded_loras.append(f"{lora_name}: {model_strength}")
# Add clip strength to output if different from model strength
if abs(model_strength - clip_strength) > 0.001:
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
else:
loaded_loras.append(f"{lora_name}: {model_strength}")
# Then process loras from kwargs with support for both old and new formats
loras_list = get_loras_list(kwargs)
print(f"Loaded loras list: {loras_list}")
for lora in loras_list:
if not lora.get('active', False):
continue
lora_name = lora['name']
strength = float(lora['strength'])
model_strength = float(lora['strength'])
# Get clip strength - use model strength as default if not specified
clip_strength = float(lora.get('clipStrength', model_strength))
# Get lora path and trigger words
lora_path, trigger_words = asyncio.run(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}")
# Apply the LoRA using the resolved path with separate strengths
model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
# Include clip strength in output if different from model strength
if abs(model_strength - clip_strength) > 0.001:
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
else:
loaded_loras.append(f"{lora_name}: {model_strength}")
# Add trigger words to collection
all_trigger_words.extend(trigger_words)
@@ -75,8 +84,23 @@ class LoraManagerLoader:
# use ',, ' to separate trigger words for group mode
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
# Format loaded_loras as <lora:lora_name:strength> separated by spaces
formatted_loras = " ".join([f"<lora:{name.split(':')[0].strip()}:{str(strength).strip()}>"
for name, strength in [item.split(':') for item in loaded_loras]])
# Format loaded_loras with support for both formats
formatted_loras = []
for item in loaded_loras:
parts = item.split(":")
lora_name = parts[0].strip()
strength_parts = parts[1].strip().split(",")
if len(strength_parts) > 1:
# Different model and clip strengths
model_str = strength_parts[0].strip()
clip_str = strength_parts[1].strip()
formatted_loras.append(f"<lora:{lora_name}:{model_str}:{clip_str}>")
else:
# Same strength for both
model_str = strength_parts[0].strip()
formatted_loras.append(f"<lora:{lora_name}:{model_str}>")
formatted_loras_text = " ".join(formatted_loras)
return (model, clip, trigger_words_text, formatted_loras)
return (model, clip, trigger_words_text, formatted_loras_text)

View File

@@ -38,7 +38,7 @@ class LoraStacker:
# Process existing lora_stack if available
lora_stack = kwargs.get('lora_stack', None)
if lora_stack:
if (lora_stack):
stack.extend(lora_stack)
# Get trigger words from existing stack entries
for lora_path, _, _ in lora_stack:
@@ -54,7 +54,8 @@ class LoraStacker:
lora_name = lora['name']
model_strength = float(lora['strength'])
clip_strength = model_strength # Using same strength for both as in the original loader
# Get clip strength - use model strength as default if not specified
clip_strength = float(lora.get('clipStrength', model_strength))
# Get lora path and trigger words
lora_path, trigger_words = asyncio.run(get_lora_info(lora_name))
@@ -62,15 +63,24 @@ class LoraStacker:
# Add to stack without loading
# replace '/' with os.sep to avoid different OS path format
stack.append((lora_path.replace('/', os.sep), model_strength, clip_strength))
active_loras.append((lora_name, model_strength))
active_loras.append((lora_name, model_strength, clip_strength))
# Add trigger words to collection
all_trigger_words.extend(trigger_words)
# use ',, ' to separate trigger words for group mode
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
# Format active_loras as <lora:lora_name:strength> separated by spaces
active_loras_text = " ".join([f"<lora:{name}:{str(strength).strip()}>"
for name, strength in active_loras])
# Format active_loras with support for both formats
formatted_loras = []
for name, model_strength, clip_strength in active_loras:
if abs(model_strength - clip_strength) > 0.001:
# Different model and clip strengths
formatted_loras.append(f"<lora:{name}:{str(model_strength).strip()}:{str(clip_strength).strip()}>")
else:
# Same strength for both
formatted_loras.append(f"<lora:{name}:{str(model_strength).strip()}>")
active_loras_text = " ".join(formatted_loras)
return (stack, trigger_words_text, active_loras_text)

View File

@@ -1,8 +1,8 @@
import { app } from "../../scripts/app.js";
import { dynamicImportByVersion } from "./utils.js";
// Extract pattern into a constant for consistent use
const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)>/g;
// Update pattern to match both formats: <lora:name:model_strength> or <lora:name:model_strength:clip_strength>
const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)(?::([-\d\.]+))?>/g;
// Function to get the appropriate loras widget based on ComfyUI version
async function getLorasWidgetModule() {
@@ -61,10 +61,15 @@ function mergeLoras(lorasText, lorasArr) {
const result = [];
let match;
// Reset pattern index before using
LORA_PATTERN.lastIndex = 0;
// Parse text input and create initial entries
while ((match = LORA_PATTERN.exec(lorasText)) !== null) {
const name = match[1];
const inputStrength = Number(match[2]);
const modelStrength = Number(match[2]);
// Extract clip strength if provided, otherwise use model strength
const clipStrength = match[3] ? Number(match[3]) : modelStrength;
// Find if this lora exists in the array data
const existingLora = lorasArr.find(l => l.name === name);
@@ -72,8 +77,9 @@ function mergeLoras(lorasText, lorasArr) {
result.push({
name: name,
// Use existing strength if available, otherwise use input strength
strength: existingLora ? existingLora.strength : inputStrength,
active: existingLora ? existingLora.active : true
strength: existingLora ? existingLora.strength : modelStrength,
active: existingLora ? existingLora.active : true,
clipStrength: existingLora ? existingLora.clipStrength : clipStrength,
});
}
@@ -102,18 +108,7 @@ app.registerExtension({
let existingLoras = [];
if (node.widgets_values && node.widgets_values.length > 0) {
const savedValue = node.widgets_values[1];
// TODO: clean up this code
try {
// 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 = [];
}
existingLoras = savedValue || [];
}
// Merge the loras data
const mergedLoras = mergeLoras(node.widgets[0].value, existingLoras);
@@ -139,7 +134,7 @@ app.registerExtension({
const currentLoras = value.map(l => l.name);
// Use the constant pattern here as well
let newText = inputWidget.value.replace(LORA_PATTERN, (match, name, strength) => {
let newText = inputWidget.value.replace(LORA_PATTERN, (match, name, strength, clipStrength) => {
return currentLoras.includes(name) ? match : '';
});

View File

@@ -1,8 +1,8 @@
import { app } from "../../scripts/app.js";
import { dynamicImportByVersion } from "./utils.js";
// Extract pattern into a constant for consistent use
const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)>/g;
// Update pattern to match both formats: <lora:name:model_strength> or <lora:name:model_strength:clip_strength>
const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)(?::([-\d\.]+))?>/g;
// Function to get the appropriate loras widget based on ComfyUI version
async function getLorasWidgetModule() {
@@ -57,10 +57,15 @@ function mergeLoras(lorasText, lorasArr) {
const result = [];
let match;
// Reset pattern index before using
LORA_PATTERN.lastIndex = 0;
// Parse text input and create initial entries
while ((match = LORA_PATTERN.exec(lorasText)) !== null) {
const name = match[1];
const inputStrength = Number(match[2]);
const modelStrength = Number(match[2]);
// Extract clip strength if provided, otherwise use model strength
const clipStrength = match[3] ? Number(match[3]) : modelStrength;
// Find if this lora exists in the array data
const existingLora = lorasArr.find(l => l.name === name);
@@ -68,8 +73,9 @@ function mergeLoras(lorasText, lorasArr) {
result.push({
name: name,
// Use existing strength if available, otherwise use input strength
strength: existingLora ? existingLora.strength : inputStrength,
active: existingLora ? existingLora.active : true
strength: existingLora ? existingLora.strength : modelStrength,
active: existingLora ? existingLora.active : true,
clipStrength: existingLora ? existingLora.clipStrength : clipStrength,
});
}

View File

@@ -29,10 +29,13 @@ export function addLorasWidget(node, name, opts, callback) {
// Fixed sizes for component calculations
const LORA_ENTRY_HEIGHT = 40; // Height of a single lora entry
const CLIP_ENTRY_HEIGHT = 40; // Height of a clip entry
const HEADER_HEIGHT = 40; // Height of the header section
const CONTAINER_PADDING = 12; // Top and bottom padding
const EMPTY_CONTAINER_HEIGHT = 100; // Height when no loras are present
// Remove expandedClipEntries Set since we'll determine expansion based on strength values
// Parse LoRA entries from value
const parseLoraValue = (value) => {
if (!value) return [];
@@ -367,7 +370,7 @@ export function addLorasWidget(node, name, opts, callback) {
};
// Function to handle strength adjustment via dragging
const handleStrengthDrag = (name, initialStrength, initialX, event, widget) => {
const handleStrengthDrag = (name, initialStrength, initialX, event, widget, isClipStrength = false) => {
// Calculate drag sensitivity (how much the strength changes per pixel)
// Using 0.01 per 10 pixels of movement
const sensitivity = 0.001;
@@ -391,7 +394,12 @@ export function addLorasWidget(node, name, opts, callback) {
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].strength = newStrength;
// Update the appropriate strength property based on isClipStrength flag
if (isClipStrength) {
lorasData[loraIndex].clipStrength = newStrength;
} else {
lorasData[loraIndex].strength = newStrength;
}
// Update the widget value
widget.value = formatLoraValue(lorasData);
@@ -402,7 +410,7 @@ export function addLorasWidget(node, name, opts, callback) {
};
// Function to initialize drag operation
const initDrag = (loraEl, nameEl, name, widget) => {
const initDrag = (dragEl, name, widget, isClipStrength = false) => {
let isDragging = false;
let initialX = 0;
let initialStrength = 0;
@@ -420,9 +428,8 @@ export function addLorasWidget(node, name, opts, callback) {
document.head.appendChild(styleEl);
}
// Create a drag handler that's applied to the entire lora entry
// except toggle and strength controls
loraEl.addEventListener('mousedown', (e) => {
// Create a drag handler
dragEl.addEventListener('mousedown', (e) => {
// Skip if clicking on toggle or strength control areas
if (e.target.closest('.comfy-lora-toggle') ||
e.target.closest('input') ||
@@ -437,7 +444,7 @@ export function addLorasWidget(node, name, opts, callback) {
if (!loraData) return;
initialX = e.clientX;
initialStrength = loraData.strength;
initialStrength = isClipStrength ? loraData.clipStrength : loraData.strength;
isDragging = true;
// Add class to body to enforce cursor style globally
@@ -453,7 +460,7 @@ export function addLorasWidget(node, name, opts, callback) {
if (!isDragging) return;
// Call the strength adjustment function
handleStrengthDrag(name, initialStrength, initialX, e, widget);
handleStrengthDrag(name, initialStrength, initialX, e, widget, isClipStrength);
// Prevent showing the preview tooltip during drag
previewTooltip.hide();
@@ -691,10 +698,17 @@ export function addLorasWidget(node, name, opts, callback) {
header.appendChild(strengthLabel);
container.appendChild(header);
// Track the total visible entries for height calculation
let totalVisibleEntries = lorasData.length;
// Render each lora entry
lorasData.forEach((loraData) => {
const { name, strength, active } = loraData;
const { name, strength, clipStrength, active } = loraData;
// Determine expansion state using our helper function
const isExpanded = shouldShowClipEntry(loraData);
// Create the main LoRA entry
const loraEl = document.createElement("div");
loraEl.className = "comfy-lora-entry";
Object.assign(loraEl.style, {
@@ -708,6 +722,41 @@ export function addLorasWidget(node, name, opts, callback) {
marginBottom: "4px",
});
// Add double-click handler to toggle clip entry
loraEl.addEventListener('dblclick', (e) => {
// Skip if clicking on toggle or strength control areas
if (e.target.closest('.comfy-lora-toggle') ||
e.target.closest('input') ||
e.target.closest('.comfy-lora-arrow')) {
return;
}
// Prevent default behavior
e.preventDefault();
e.stopPropagation();
// Toggle the clip entry expanded state
const lorasData = parseLoraValue(widget.value);
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
// Explicitly toggle the expansion state
const currentExpanded = shouldShowClipEntry(lorasData[loraIndex]);
lorasData[loraIndex].expanded = !currentExpanded;
// If collapsing, set clipStrength = strength
if (!lorasData[loraIndex].expanded) {
lorasData[loraIndex].clipStrength = lorasData[loraIndex].strength;
}
// Update the widget value
widget.value = formatLoraValue(lorasData);
// Re-render to show/hide clip entry
renderLoras(widget.value, widget);
}
});
// Create toggle for this lora
const toggle = createToggle(active, (newActive) => {
// Update this lora's active state
@@ -740,6 +789,14 @@ export function addLorasWidget(node, name, opts, callback) {
msUserSelect: "none",
});
// Add expand indicator to name element
const expandIndicator = document.createElement("span");
expandIndicator.textContent = isExpanded ? " ▼" : " ▶";
expandIndicator.style.opacity = "0.7";
expandIndicator.style.fontSize = "9px";
expandIndicator.style.marginLeft = "4px";
nameEl.appendChild(expandIndicator);
// Move preview tooltip events to nameEl instead of loraEl
nameEl.addEventListener('mouseenter', async (e) => {
e.stopPropagation();
@@ -753,7 +810,7 @@ export function addLorasWidget(node, name, opts, callback) {
});
// Initialize drag functionality for strength adjustment
initDrag(loraEl, nameEl, name, widget);
initDrag(loraEl, name, widget, false);
// Remove the preview tooltip events from loraEl
loraEl.onmouseenter = () => {
@@ -897,10 +954,185 @@ export function addLorasWidget(node, name, opts, callback) {
loraEl.appendChild(strengthControl);
container.appendChild(loraEl);
// If expanded, show the clip entry
if (isExpanded) {
totalVisibleEntries++;
const clipEl = document.createElement("div");
clipEl.className = "comfy-lora-clip-entry";
Object.assign(clipEl.style, {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "6px",
paddingLeft: "20px", // Indent to align with parent name
borderRadius: "6px",
backgroundColor: active ? "rgba(65, 70, 90, 0.6)" : "rgba(50, 55, 65, 0.5)",
borderLeft: "2px solid rgba(72, 118, 255, 0.6)",
transition: "all 0.2s ease",
marginBottom: "4px",
marginLeft: "10px",
marginTop: "-2px"
});
// Create clip name display
const clipNameEl = document.createElement("div");
clipNameEl.textContent = "[clip] " + name;
Object.assign(clipNameEl.style, {
flex: "1",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: active ? "rgba(200, 215, 240, 0.9)" : "rgba(200, 215, 240, 0.6)",
fontSize: "13px",
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
});
// Create clip strength control
const clipStrengthControl = document.createElement("div");
Object.assign(clipStrengthControl.style, {
display: "flex",
alignItems: "center",
gap: "8px",
});
// Left arrow for clip
const clipLeftArrow = createArrowButton("left", () => {
// Decrease clip strength
const lorasData = parseLoraValue(widget.value);
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) - 0.05).toFixed(2);
const newValue = formatLoraValue(lorasData);
widget.value = newValue;
}
});
// Clip strength display
const clipStrengthEl = document.createElement("input");
clipStrengthEl.type = "text";
clipStrengthEl.value = typeof clipStrength === 'number' ? clipStrength.toFixed(2) : Number(clipStrength).toFixed(2);
Object.assign(clipStrengthEl.style, {
minWidth: "50px",
width: "50px",
textAlign: "center",
color: active ? "rgba(200, 215, 240, 0.9)" : "rgba(200, 215, 240, 0.6)",
fontSize: "13px",
background: "none",
border: "1px solid transparent",
padding: "2px 4px",
borderRadius: "3px",
outline: "none",
});
// Add hover effect
clipStrengthEl.addEventListener('mouseenter', () => {
clipStrengthEl.style.border = "1px solid rgba(226, 232, 240, 0.2)";
});
clipStrengthEl.addEventListener('mouseleave', () => {
if (document.activeElement !== clipStrengthEl) {
clipStrengthEl.style.border = "1px solid transparent";
}
});
// Handle focus
clipStrengthEl.addEventListener('focus', () => {
clipStrengthEl.style.border = "1px solid rgba(72, 118, 255, 0.6)";
clipStrengthEl.style.background = "rgba(0, 0, 0, 0.2)";
// Auto-select all content
clipStrengthEl.select();
});
clipStrengthEl.addEventListener('blur', () => {
clipStrengthEl.style.border = "1px solid transparent";
clipStrengthEl.style.background = "none";
});
// Handle input changes
clipStrengthEl.addEventListener('change', () => {
let newValue = parseFloat(clipStrengthEl.value);
// Validate input
if (isNaN(newValue)) {
newValue = 1.0;
}
// Update value
const lorasData = parseLoraValue(widget.value);
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].clipStrength = newValue.toFixed(2);
// Update value and trigger callback
const newLorasValue = formatLoraValue(lorasData);
widget.value = newLorasValue;
}
});
// Handle key events
clipStrengthEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
clipStrengthEl.blur();
}
});
// Right arrow for clip
const clipRightArrow = createArrowButton("right", () => {
// Increase clip strength
const lorasData = parseLoraValue(widget.value);
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) + 0.05).toFixed(2);
const newValue = formatLoraValue(lorasData);
widget.value = newValue;
}
});
clipStrengthControl.appendChild(clipLeftArrow);
clipStrengthControl.appendChild(clipStrengthEl);
clipStrengthControl.appendChild(clipRightArrow);
// Assemble clip entry
const clipLeftSection = document.createElement("div");
Object.assign(clipLeftSection.style, {
display: "flex",
alignItems: "center",
flex: "1",
minWidth: "0", // Allow shrinking
});
clipLeftSection.appendChild(clipNameEl);
clipEl.appendChild(clipLeftSection);
clipEl.appendChild(clipStrengthControl);
// Hover effects for clip entry
clipEl.onmouseenter = () => {
clipEl.style.backgroundColor = active ? "rgba(70, 75, 95, 0.7)" : "rgba(55, 60, 70, 0.6)";
};
clipEl.onmouseleave = () => {
clipEl.style.backgroundColor = active ? "rgba(65, 70, 90, 0.6)" : "rgba(50, 55, 65, 0.5)";
};
// Add drag functionality to clip entry
initDrag(clipEl, name, widget, true);
container.appendChild(clipEl);
}
});
// Calculate height based on number of loras and fixed sizes
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(lorasData.length, 5) * LORA_ENTRY_HEIGHT);
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(totalVisibleEntries, 8) * LORA_ENTRY_HEIGHT);
updateWidgetHeight(calculatedHeight);
};
@@ -921,7 +1153,37 @@ export function addLorasWidget(node, name, opts, callback) {
return [...filtered, lora];
}, []);
widgetValue = uniqueValue;
// Preserve clip strengths and expanded state when updating the value
const oldLoras = parseLoraValue(widgetValue);
// Apply existing clip strength values and transfer them to the new value
const updatedValue = uniqueValue.map(lora => {
const existingLora = oldLoras.find(oldLora => oldLora.name === lora.name);
// If there's an existing lora with the same name, preserve its clip strength and expanded state
if (existingLora) {
return {
...lora,
clipStrength: existingLora.clipStrength || lora.strength,
expanded: existingLora.hasOwnProperty('expanded') ?
existingLora.expanded :
Number(existingLora.clipStrength || lora.strength) !== Number(lora.strength)
};
}
// For new loras, default clip strength to model strength and expanded to false
// unless clipStrength is already different from strength
const clipStrength = lora.clipStrength || lora.strength;
return {
...lora,
clipStrength: clipStrength,
expanded: lora.hasOwnProperty('expanded') ?
lora.expanded :
Number(clipStrength) !== Number(lora.strength)
};
});
widgetValue = updatedValue;
renderLoras(widgetValue, widget);
},
getMinHeight: function() {
@@ -962,6 +1224,8 @@ export function addLorasWidget(node, name, opts, callback) {
// Function to directly save the recipe without dialog
async function saveRecipeDirectly(widget) {
try {
const prompt = await app.graphToPrompt();
console.log(prompt);
// Show loading toast
if (app && app.extensionManager && app.extensionManager.toast) {
app.extensionManager.toast.add({
@@ -1010,4 +1274,14 @@ async function saveRecipeDirectly(widget) {
});
}
}
}
// Determine if clip entry should be shown - now based on expanded property or initial diff values
const shouldShowClipEntry = (loraData) => {
// If expanded property exists, use that
if (loraData.hasOwnProperty('expanded')) {
return loraData.expanded;
}
// Otherwise use the legacy logic - if values differ, it should be expanded
return Number(loraData.strength) !== Number(loraData.clipStrength);
}