Files
BobsBlazed-Bobs-Lora-Loader/bobs_lora_loader.py
T
BobsBlazed e7f867a9ed Fix issues found in code review (#6)
Eight findings from a review of 1.2.0. None changes behaviour for a correctly
paired LoRA and model; the first two matter when the pairing is wrong.

The FLUX and SDXL classifiers never received the dot-boundary fix the Universal
loader got, so an SDXL key "input_blocks.4.1.transformer_blocks.0.attn1.to_q.
weight" matched the nested transformer_blocks and was read as double-stream
block 0. The stack patterns are now anchored to the start of the module path,
since the nested stack is dot-preceded too.

Choosing the wrong loader for a model was silent: the keys still match, because
ComfyUI builds the key map from the model, so every tensor lands in whichever
single bucket the classifier reaches. A mismatch check now warns when 90% or
more of the UNet tensors land in one block -- a threshold rather than "all of
them" because cross-family names overlap slightly.

Also: families register through register_family() instead of mutating module
tables at import time; tooltips are stored per family so the shared "Text
Encoder" name no longer collides; the "_head_" output token no longer matches
multi_head_attention; unknown preset names warn; FLUX accepts negative block
indices; and a bucket boundary guarding an impossible fraction was dropped.

Tests grow from 55 to 71, adding tests/test_node_behaviour.py. The real ComfyUI
integration harnesses still classify 8000+ authentic keys across 11
architectures with nothing falling through.

Bumps version to 1.2.1.
2026-07-29 10:49:09 -04:00

465 lines
19 KiB
Python

"""
Bobs LoRA Loader — block-weighted LoRA loading for ComfyUI (FLUX + SDXL).
How this works
--------------
ComfyUI's ``comfy.lora`` builds a ``key_map`` that translates every LoRA
exporter dialect (kohya ``lora_unet_*``, OneTrainer ``lora_transformer_*``,
diffusers ``transformer.*``, lycoris, DiffSynth, ...) into the *canonical model
state-dict key* the patch targets. ``comfy.lora.load_lora`` then returns a patch
dict keyed by those canonical names — either a plain string, or a
``(key, offset)`` tuple when several LoRA tensors are packed into one fused
weight (FLUX ``qkv``/``linear1``).
We therefore classify patches by their canonical target key rather than by the
raw name inside the LoRA file. That is dialect-proof: if ComfyUI can load the
LoRA at all, this node can bucket it.
"""
import logging
import os
from typing import Any, Dict, List, Optional, Tuple
import comfy.lora
import comfy.utils
import folder_paths
from .bobs_blocks import (
ALL_FLUX_BLOCKS,
ALL_SDXL_BLOCKS,
FLUX_DEFAULT_DEPTH,
FLUX_DEFAULT_DEPTH_SINGLE,
FLUX_OTHER,
LORA_BLOCK_PRESETS,
SDXL_OTHER,
TEXT_ENCODER_BLOCK,
classify_flux_key,
classify_sdxl_key,
flux_block_ranges,
resolve_block_strengths,
tooltip_for,
)
from .bobs_universal import ( # registers the UNIVERSAL family on import
ALL_UNIVERSAL_BLOCKS,
UNIVERSAL_OTHER,
classify_universal_key,
discover_layout,
)
try: # Added to ComfyUI in 2025; handles BFL-control / Wan-Fun / USO LoRAs.
import comfy.lora_convert as _lora_convert
except ImportError: # pragma: no cover - older ComfyUI
_lora_convert = None
logger = logging.getLogger("BobsLoraLoader")
MAX_STRENGTH = 5.0
MAX_BLOCK_WEIGHT = 2.0
#: Share of UNet tensors in a single block above which the family looks wrong.
#: A correctly-paired LoRA spreads across blocks; the worst real case measured
#: was 60/200 (30%) in one block, so 0.9 leaves a wide margin.
_MISMATCH_SHARE = 0.9
# -----------------------------------------------------------------------------#
# HELPER FUNCTIONS #
# -----------------------------------------------------------------------------#
def _patch_target(patch_key: Any) -> str:
"""Return the model state-dict key a patch entry targets.
``load_lora`` keys are either the key itself or ``(key, offset)`` for
patches into a slice of a fused weight — the same shape ``ModelPatcher.
add_patches`` unpacks.
"""
return patch_key if isinstance(patch_key, str) else patch_key[0]
def _build_key_maps(model, clip) -> Tuple[Dict[str, Any], set]:
"""Build ComfyUI's LoRA key map and the set of keys owned by the UNet.
Returning the UNet key set is what lets us route text-encoder patches to the
CLIP patcher and everything else to the model patcher without guessing from
key names.
"""
unet_map: Dict[str, Any] = {}
clip_map: Dict[str, Any] = {}
if model is not None and hasattr(comfy.lora, "model_lora_keys_unet"):
comfy.lora.model_lora_keys_unet(model.model, unet_map)
if clip is not None and hasattr(comfy.lora, "model_lora_keys_clip"):
comfy.lora.model_lora_keys_clip(clip.cond_stage_model, clip_map)
key_map = dict(clip_map)
key_map.update(unet_map) # UNet wins on the (unlikely) collision
unet_targets = {_patch_target(v) for v in unet_map.values()}
return key_map, unet_targets
def _architecture_name(model) -> str:
"""Best-effort name of the loaded backbone, for the report."""
try:
return type(model.model).__name__
except Exception: # noqa: BLE001
return "unknown"
def _flux_geometry(model) -> Tuple[int, int]:
"""Read the double/single stream depths off the loaded FLUX model."""
try:
config = model.model.model_config.unet_config
depth = int(config.get("depth", 0)) or FLUX_DEFAULT_DEPTH
depth_single = int(config.get("depth_single_blocks", 0)) or FLUX_DEFAULT_DEPTH_SINGLE
return depth, depth_single
except Exception: # noqa: BLE001 - any unexpected config shape falls back
return FLUX_DEFAULT_DEPTH, FLUX_DEFAULT_DEPTH_SINGLE
def _format_report(tag: str,
lora_name: str,
preset: str,
blocks: List[str],
grouped: Dict[str, Dict[Any, Any]],
strengths: Dict[str, float],
applied: Dict[str, int],
detail: str = "") -> str:
lines = [f"[{tag}] {lora_name} (preset: {preset})"]
if detail:
lines.append(detail)
lines.append(f"{'block':<40} {'weight':>7} {'found':>7} {'applied':>8}")
for name in blocks:
lines.append(
f"{name:<40} {strengths.get(name, 0.0):>7.2f} "
f"{len(grouped.get(name, {})):>7} {applied.get(name, 0):>8}"
)
total_found = sum(len(g) for g in grouped.values())
total_applied = sum(applied.values())
lines.append(f"{'TOTAL':<40} {'':>7} {total_found:>7} {total_applied:>8}")
return "\n".join(lines)
def _mismatch_warning(tag: str,
blocks: List[str],
grouped: Dict[str, Dict[Any, Any]],
text_encoder_block: str,
catch_all_block: str) -> Optional[str]:
"""Detect a LoRA/model pairing that the family classifier cannot resolve.
Picking the wrong loader for a model is silent otherwise: the keys still
match (ComfyUI's key map is built from the model, so the LoRA loads), they
just all land in whichever single bucket the classifier happens to reach.
Two shapes of that are worth calling out:
- everything piled into the family's catch-all, which then applies at that
slider's weight with no block weighting at all;
- everything piled into one *named* block, which means the sliders are
lying about what they control.
"""
unet_counts = {name: len(patches) for name, patches in grouped.items()
if name != text_encoder_block and patches}
total = sum(unet_counts.values())
if total < 8: # too few patches for the distribution to mean anything
return None
# A threshold rather than "all of them": cross-family key names overlap just
# enough to scatter a few tensors. Feeding a real SDXL key set through the
# FLUX classifier leaves 98.7% in the catch-all and sends the rest to
# "Final Output Layer" via the shared proj_out token, which an exact-equality
# test would wave through.
def _share(name: str) -> float:
return unet_counts.get(name, 0) / total
if _share(catch_all_block) >= _MISMATCH_SHARE:
return (f"WARNING: {_share(catch_all_block):.0%} of UNet tensors landed in "
f"'{catch_all_block}'. This LoRA does not look like a {tag} LoRA, or the "
f"model is not a {tag} model — the per-block sliders are barely doing "
f"anything. Try the Universal loader.")
dominant, count = max(unet_counts.items(), key=lambda kv: kv[1])
if count / total >= _MISMATCH_SHARE and len(blocks) > 2:
return (f"WARNING: {count / total:.0%} of UNet tensors landed in '{dominant}'. "
f"That usually means this model is not a {tag} model, so the block "
f"sliders do not map to what their names say. Try the Universal loader.")
return None
def _explain_empty_blocks(tag: str,
blocks: List[str],
grouped: Dict[str, Dict[Any, Any]],
strengths: Dict[str, float]) -> None:
"""Log why a block with a non-zero weight contributed nothing."""
for name in blocks:
if grouped.get(name) or strengths.get(name, 0.0) == 0.0:
continue
logger.info(
"[%s] %s: weight %.2f but this LoRA contains no tensors for that block.",
tag, name, strengths[name],
)
# -----------------------------------------------------------------------------#
# SHARED NODE #
# -----------------------------------------------------------------------------#
class _BobsLoraLoaderBase:
"""Shared load / classify / patch pipeline for the FLUX and SDXL loaders."""
FAMILY = "FLUX"
BLOCKS: List[str] = ALL_FLUX_BLOCKS
#: Bucket that receives anything the family classifier could not place.
CATCH_ALL_BLOCK = FLUX_OTHER
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
RETURN_NAMES = ("MODEL", "CLIP", "info")
OUTPUT_TOOLTIPS = (
"Model with the block-weighted LoRA applied.",
"CLIP with the text-encoder portion of the LoRA applied.",
"Per-block report: weight, tensors found and tensors actually patched.",
)
FUNCTION = "apply_lora"
CATEGORY = "Bobs_Nodes"
def __init__(self):
self.logger = logging.getLogger(self.__class__.__name__)
self._cache_key: Optional[tuple] = None
self._cache_sd: Optional[Dict[str, Any]] = None
# ---------------------------------------------------------------- inputs --
@classmethod
def _input_types(cls) -> Dict[str, Any]:
required = {
"model": ("MODEL", {"tooltip": "Diffusion model to patch."}),
"lora_name": (
["None"] + folder_paths.get_filename_list("loras"),
{"tooltip": "LoRA file to load. 'None' passes the model through untouched."},
),
"strength": ("FLOAT", {
"default": 1.0, "min": -MAX_STRENGTH, "max": MAX_STRENGTH, "step": 0.01,
"tooltip": "Global multiplier applied on top of every block weight.",
}),
"preset": (
list(LORA_BLOCK_PRESETS[cls.FAMILY].keys()),
{"tooltip": "Choose 'Custom' to use the sliders below; any other preset overrides them."},
),
}
for block in cls.BLOCKS:
required[block] = ("FLOAT", {
"default": 1.0, "min": -MAX_BLOCK_WEIGHT, "max": MAX_BLOCK_WEIGHT, "step": 0.05,
"tooltip": tooltip_for(cls.FAMILY, block),
})
return {
"required": required,
"optional": {
"clip": ("CLIP", {"tooltip": "Optional. Leave unconnected to patch the model only."}),
},
}
# ------------------------------------------------------------ lora file --
def _load_lora_state_dict(self, lora_path: str) -> Dict[str, Any]:
"""Load a LoRA, reusing the previous load when the file is unchanged."""
try:
stat = os.stat(lora_path)
cache_key = (lora_path, stat.st_mtime_ns, stat.st_size)
except OSError:
cache_key = None
if cache_key is not None and cache_key == self._cache_key:
return self._cache_sd
# load_torch_file handles .safetensors and pickled checkpoints, and
# applies weights_only=True to the latter.
state_dict = comfy.utils.load_torch_file(lora_path, safe_load=True)
if _lora_convert is not None:
state_dict = _lora_convert.convert_lora(state_dict)
self._cache_key, self._cache_sd = cache_key, state_dict
return state_dict
# ------------------------------------------------------------ classifier --
def _classifier(self, model, unet_targets):
"""Return ``(fn(model_key) -> block name, detail_line)`` for this family."""
raise NotImplementedError
# ----------------------------------------------------------------- apply --
def apply_lora(self, model, lora_name, strength, preset, clip=None, **kwargs):
tag = self.FAMILY
if lora_name in (None, "", "None"):
return (model, clip, f"[{tag}] no LoRA selected.")
if strength == 0.0:
return (model, clip, f"[{tag}] {lora_name}: strength is 0.00, nothing applied.")
lora_path = folder_paths.get_full_path("loras", lora_name)
if not lora_path:
message = f"[{tag}] LoRA file not found: {lora_name}"
self.logger.error(message)
return (model, clip, message)
try:
lora_sd = self._load_lora_state_dict(lora_path)
except Exception as exc: # noqa: BLE001 - surface, never crash the graph
message = f"[{tag}] failed to read {lora_name}: {exc}"
self.logger.error(message)
return (model, clip, message)
if preset not in LORA_BLOCK_PRESETS[self.FAMILY]:
self.logger.warning(
"[%s] unknown preset %r; falling back to the slider values.", tag, preset)
strengths = resolve_block_strengths(self.FAMILY, preset, strength, kwargs)
key_map, unet_targets = _build_key_maps(model, clip)
if not key_map:
message = f"[{tag}] could not build a key map for this model; LoRA not applied."
self.logger.warning(message)
return (model, clip, message)
all_patches = comfy.lora.load_lora(lora_sd, key_map)
if not all_patches:
message = (f"[{tag}] {lora_name}: none of its tensors match this model. "
f"Is it a LoRA for a different architecture?")
self.logger.warning(message)
return (model, clip, message)
# Group UNet patches per conceptual block; CLIP patches all share the
# text-encoder block.
classify, detail = self._classifier(model, unet_targets)
text_encoder_block = TEXT_ENCODER_BLOCK[self.FAMILY]
grouped: Dict[str, Dict[Any, Any]] = {name: {} for name in self.BLOCKS}
for patch_key, patch in all_patches.items():
target = _patch_target(patch_key)
block = classify(target) if target in unet_targets else text_encoder_block
grouped[block][patch_key] = patch
out_model = model.clone() if model is not None else None
out_clip = clip.clone() if clip is not None else None
applied: Dict[str, int] = {}
for name in self.BLOCKS:
patches = grouped[name]
block_strength = strengths.get(name, 0.0)
patcher = out_clip if name == text_encoder_block else out_model
if not patches or block_strength == 0.0 or patcher is None:
applied[name] = 0
continue
applied[name] = len(patcher.add_patches(patches, block_strength))
# Any tensor in the file that ComfyUI could not place is reported by
# comfy.lora.load_lora itself as a "lora key not loaded" warning.
report = _format_report(tag, lora_name, preset, self.BLOCKS,
grouped, strengths, applied, detail)
mismatch = _mismatch_warning(tag, self.BLOCKS, grouped,
text_encoder_block, self.CATCH_ALL_BLOCK)
if mismatch:
self.logger.warning("[%s] %s", tag, mismatch)
report = f"{report}\n{mismatch}"
self.logger.info("\n%s", report)
_explain_empty_blocks(tag, self.BLOCKS, grouped, strengths)
return (out_model, out_clip, report)
# -----------------------------------------------------------------------------#
# FLUX LOADER #
# -----------------------------------------------------------------------------#
class BobsLoraLoaderFlux(_BobsLoraLoaderBase):
FAMILY = "FLUX"
BLOCKS = ALL_FLUX_BLOCKS
DESCRIPTION = (
"Applies a LoRA to a FLUX model with an independent weight per conceptual "
"block (composition, subject, style core, detail, texture, text encoder). "
"Block ranges are derived from the loaded model's own depth, so FLUX.1 "
"dev/schnell and pruned variants are all handled."
)
@classmethod
def INPUT_TYPES(cls):
return cls._input_types()
def _classifier(self, model, unet_targets):
depth, depth_single = _flux_geometry(model)
ranges = flux_block_ranges(depth, depth_single)
detail = (f"architecture: {_architecture_name(model)} "
f"double_blocks[{depth}] -> single_blocks[{depth_single}]")
return (lambda key: classify_flux_key(key, ranges)), detail
# -----------------------------------------------------------------------------#
# SDXL LOADER #
# -----------------------------------------------------------------------------#
class BobsLoraLoaderSdxl(_BobsLoraLoaderBase):
FAMILY = "SDXL"
BLOCKS = ALL_SDXL_BLOCKS
CATCH_ALL_BLOCK = SDXL_OTHER
DESCRIPTION = (
"Applies a LoRA to an SDXL model with independent weights for the text "
"encoder and the UNet input / middle / output stages."
)
@classmethod
def INPUT_TYPES(cls):
return cls._input_types()
def _classifier(self, model, unet_targets):
return classify_sdxl_key, f"architecture: {_architecture_name(model)}"
# -----------------------------------------------------------------------------#
# UNIVERSAL LOADER #
# -----------------------------------------------------------------------------#
class BobsLoraLoaderUniversal(_BobsLoraLoaderBase):
FAMILY = "UNIVERSAL"
BLOCKS = ALL_UNIVERSAL_BLOCKS
CATCH_ALL_BLOCK = UNIVERSAL_OTHER
DESCRIPTION = (
"Block-weighted LoRA loading for any architecture ComfyUI supports — "
"SD1.5, SD2, SDXL, SD3/3.5, FLUX, Chroma, AuraFlow, PixArt, HiDream, "
"Qwen-Image, Wan, LTX-Video, Mochi, HunyuanVideo/DiT, Lumina, Cosmos and "
"others. The block stacks are discovered from the loaded model itself "
"rather than a hard-coded table, so new and pruned architectures work "
"without an update. Weights are assigned by depth: Early through Late "
"across the whole block stack, plus embeddings, output head and text "
"encoder."
)
@classmethod
def INPUT_TYPES(cls):
return cls._input_types()
def _classifier(self, model, unet_targets):
layout = discover_layout(unet_targets)
detail = f"architecture: {_architecture_name(model)} {layout.describe()}"
if not layout:
self.logger.warning(
"[UNIVERSAL] No block stacks recognised in this model; only the "
"embedding, output-head and text-encoder weights can be targeted."
)
return (lambda key: classify_universal_key(key, layout)), detail
# -----------------------------------------------------------------------------#
# COMFYUI REGISTRATION #
# -----------------------------------------------------------------------------#
NODE_CLASS_MAPPINGS = {
"BobsLoraLoaderFlux": BobsLoraLoaderFlux,
"BobsLoraLoaderSdxl": BobsLoraLoaderSdxl,
"BobsLoraLoaderUniversal": BobsLoraLoaderUniversal,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"BobsLoraLoaderFlux": "Bobs LoRA Loader (FLUX)",
"BobsLoraLoaderSdxl": "Bobs LoRA Loader (SDXL)",
"BobsLoraLoaderUniversal": "Bobs LoRA Loader (Universal)",
}