Files
FugitiveExpert01 29336cbbe4 Update nodes_lora.py
the weight cache block in load() was replaced with a direct load:
Before:
pythonif lora_path in _lora_weight_cache:
    logger.info(f"[FEnodes/FELoraLoad] Cache hit: '{lora_name}'")
    weights = _lora_weight_cache[lora_path]
else:
    logger.info(...)
    weights = comfy.utils.load_torch_file(lora_path, safe_load=True)
    _lora_weight_cache[lora_path] = weights
After:
pythonlogger.info(...)
weights = comfy.utils.load_torch_file(lora_path, safe_load=True)
And _lora_weight_cache was removed from the import line at the top. Everything else in nodes_lora.py is identical.
2026-03-17 00:21:27 +01:00

298 lines
9.9 KiB
Python

"""
FEnodes -- LoRA nodes
Author: FugitiveExpert01
FELoraLoad -- multi-LoRA browser UI -> FE_LORA_STACK
FEApplyLora -- MODEL + FE_LORA_STACK -> MODEL / CLIP
FELoraTriggerAnalysis -- FE_LORA_STACK + CLIP -> STRING
FELoraLoad has a custom JS UI (web/js/fe_power_lora.js).
"""
__version__ = "0.1.2"
import json
import logging
import comfy.sd
import comfy.utils
import folder_paths
from .lora_utils import (
FE_LORA_STACK,
_cached_file_hash,
normalize_lora_name,
merge_lora_weights,
discover_encoders,
analyse_encoder,
)
logger = logging.getLogger("FEnodes")
class FELoraLoad:
"""
Multi-LoRA loader with a custom folder-tree browser UI (fe_power_lora.js).
The JS widget serialises the LoRA list as JSON into the hidden loras_json
STRING widget. Each entry:
{ "enabled": bool, "lora": "path/to/file.safetensors",
"strength_model": float, "strength_clip": float }
Disabled and empty rows are skipped. Outputs FE_LORA_STACK for FEApplyLora.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"loras_json": ("STRING", {"default": "[]", "multiline": False}),
}
}
RETURN_TYPES = (FE_LORA_STACK,)
RETURN_NAMES = ("lora_stack",)
FUNCTION = "load"
CATEGORY = "FEnodes"
DESCRIPTION = (
"Multi-LoRA loader with folder-tree browser UI. "
"Add LoRAs, toggle on/off, set per-LoRA strength. "
"Connect lora_stack to FEApplyLora."
)
@classmethod
def IS_CHANGED(cls, loras_json):
try:
entries = json.loads(loras_json)
except Exception:
return loras_json
parts = []
for entry in entries:
if not entry.get("enabled", True) or not entry.get("lora"):
continue
lora_name = normalize_lora_name(entry["lora"])
path = folder_paths.get_full_path("loras", lora_name)
file_hash = _cached_file_hash(path) if path else lora_name
sm = round(float(entry.get("strength_model", 1.0)), 4)
sc = round(float(entry.get("strength_clip", sm)), 4)
parts.append(f"{lora_name}:{file_hash}:{sm}:{sc}")
return "|".join(parts)
def load(self, loras_json):
try:
entries = json.loads(loras_json)
except Exception as e:
logger.warning(f"[FEnodes/FELoraLoad] Failed to parse loras_json: {e}")
return ([],)
stack = []
for entry in entries:
if not entry.get("enabled", True):
continue
lora_name = normalize_lora_name(entry.get("lora", ""))
if not lora_name:
continue
try:
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
except FileNotFoundError as e:
logger.error(f"[FEnodes/FELoraLoad] {e}")
raise
strength_model = float(entry.get("strength_model", 1.0))
# strength_clip falls back to strength_model if not explicitly set.
# The JS widget always writes it; fallback covers older workflows.
strength_clip = float(entry.get("strength_clip", strength_model))
logger.info(
f"[FEnodes/FELoraLoad] Loading '{lora_name}' "
f"(model_str={strength_model}, clip_str={strength_clip})"
)
weights = comfy.utils.load_torch_file(lora_path, safe_load=True)
stack.append({
"name": lora_name,
"weights": weights,
"strength_model": strength_model,
"strength_clip": strength_clip,
})
logger.info(f"[FEnodes/FELoraLoad] Stack ready: {len(stack)} LoRA(s).")
return (stack,)
class FEApplyLora:
"""
Applies a FE_LORA_STACK to a MODEL in the chosen application mode.
Architecture-agnostic: SD1, SDXL, Flux, WAN 2.1/2.2, HunyuanVideo, etc.
Stack -- sequential patching, safe with any combination.
Merge -- pre-combines all deltas into one patch, best when LoRAs share layers.
strength_scale multiplies all per-LoRA strengths uniformly.
lora_stack is optional -- model passes through if unconnected.
"""
APPLICATION_MODES = ["Stack", "Merge"]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"application_mode": (cls.APPLICATION_MODES, {"default": "Stack"}),
},
"optional": {
"lora_stack": (FE_LORA_STACK,),
"clip": ("CLIP",),
"strength_scale": ("FLOAT", {
"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01,
"tooltip": "Multiplies all per-LoRA model and clip strengths uniformly.",
}),
},
}
RETURN_TYPES = ("MODEL", "CLIP")
RETURN_NAMES = ("model", "clip")
FUNCTION = "apply"
CATEGORY = "FEnodes"
DESCRIPTION = (
"Applies a LoRA stack from FELoraLoad to a MODEL (and optionally CLIP). "
"strength_scale multiplies all per-LoRA strengths uniformly. "
"Stack: sequential patching. Merge: pre-combines all deltas into one patch."
)
@classmethod
def IS_CHANGED(cls, model, application_mode, lora_stack=None,
clip=None, strength_scale=1.0):
if not lora_stack:
return f"empty:{application_mode}:{round(float(strength_scale), 4)}"
parts = [application_mode, str(round(float(strength_scale), 4))]
for entry in lora_stack:
sm = round(float(entry.get("strength_model", 1.0)), 4)
sc = round(float(entry.get("strength_clip", sm)), 4)
parts.append(f"{entry.get('name', '')}:{sm}:{sc}")
return "|".join(parts)
def apply(self, model, application_mode, lora_stack=None,
clip=None, strength_scale=1.0):
if not lora_stack:
logger.info("[FEnodes/FEApplyLora] No lora_stack connected -- model unchanged.")
return (model, clip)
scale = float(strength_scale)
if scale != 1.0:
logger.info(f"[FEnodes/FEApplyLora] strength_scale={scale}")
if application_mode == "Merge":
logger.info(
f"[FEnodes/FEApplyLora] Merge: combining {len(lora_stack)} LoRA(s) "
f"(strength_scale={scale})"
)
scaled_stack = [
{**e,
"strength_model": e["strength_model"] * scale,
"strength_clip": e["strength_clip"] * scale}
for e in lora_stack
]
merged = merge_lora_weights(scaled_stack)
model, clip = comfy.sd.load_lora_for_models(model, clip, merged, 1.0, 1.0)
else:
for entry in lora_stack:
sm = entry["strength_model"] * scale
sc = entry["strength_clip"] * scale
logger.info(
f"[FEnodes/FEApplyLora] Stack: '{entry['name']}' "
f"(model_str={sm:.4f}, clip_str={sc:.4f})"
)
model, clip = comfy.sd.load_lora_for_models(
model, clip, entry["weights"], sm, sc
)
return (model, clip)
class FELoraTriggerAnalysis:
"""
Analyses LoRA weight deltas against every text encoder in the wired CLIP
to surface candidate trigger words.
Architecture-agnostic: CLIP-L/G, T5-XXL, LLaMA/Gemma and combinations.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"lora_stack": (FE_LORA_STACK,),
"clip": ("CLIP",),
"top_k": ("INT", {"default": 10, "min": 1, "max": 50}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("candidate_triggers",)
OUTPUT_NODE = True
FUNCTION = "analyse"
CATEGORY = "FEnodes"
DESCRIPTION = (
"Analyses LoRA weight deltas against all text encoders in the wired CLIP "
"to surface candidate trigger words. Works with CLIP-L/G, T5-XXL, LLaMA/Gemma "
"and any combination of dual/triple encoders."
)
def analyse(self, lora_stack, clip, top_k):
encoders = discover_encoders(clip)
if not encoders:
logger.warning("[FEnodes/TriggerAnalysis] No token embedding tables found.")
return ("",)
logger.info(
f"[FEnodes/TriggerAnalysis] Found {len(encoders)} encoder(s): "
f"{[e['name'] for e in encoders]}"
)
sections = []
multi = len(encoders) > 1
for enc in encoders:
results, layers_used = analyse_encoder(enc, lora_stack, top_k)
if layers_used == 0:
logger.info(f"[FEnodes/TriggerAnalysis] '{enc['name']}': no matching layers.")
continue
logger.info(
f"[FEnodes/TriggerAnalysis] '{enc['name']}': "
f"{layers_used} layer(s), top: {[t for t, _ in results[:5]]}"
)
if not results:
continue
tokens_str = ", ".join(t for t, _ in results)
sections.append(f"[{enc['name']}] {tokens_str}" if multi else tokens_str)
if not sections:
logger.warning(
"[FEnodes/TriggerAnalysis] No LoRA layers matched any encoder. "
"LoRA likely targets the diffusion model only."
)
return ("",)
return ("\n".join(sections),)
NODE_CLASS_MAPPINGS = {
"FELoraLoad": FELoraLoad,
"FEApplyLora": FEApplyLora,
"FELoraTriggerAnalysis": FELoraTriggerAnalysis,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FELoraLoad": "LoRA Load ⚡",
"FEApplyLora": "Apply LoRA ⚡",
"FELoraTriggerAnalysis": "LoRA Trigger Analysis 🔍",
}