Add a Universal loader covering every supported architecture (#5)

The FLUX and SDXL loaders know their architecture's block names up front, which
does not scale across ComfyUI's ~99 model configs. The new Universal node
discovers a model's block layout at runtime instead: stack names, stack sizes
and their execution order all come from the loaded model, so pruned, distilled
and brand-new architectures work without a code change.

Concatenating the discovered stacks puts every block on one normalised depth
axis, split into five buckets, alongside embeddings, output head and text
encoder. Covers SD1.5/2/3, SDXL, FLUX, Chroma, AuraFlow, PixArt, HiDream,
Qwen-Image, Wan, LTX-Video, Mochi, HunyuanVideo/DiT, Lumina and Cosmos.

Verified against real ComfyUI: models built from ComfyUI's own configs on the
meta device plus its *_to_diffusers key tables, 8000+ authentic state-dict keys
across 11 architectures, none falling through to "Other Tensors". That pass
found and fixed real gaps: SD3's negative "joint_blocks.-1" index, Lumina's
noise_refiner/context_refiner stacks, AuraFlow's native double_layers naming,
several conditioning embedders, and FLUX's ControlNet pos_embed_input.

No widget was added, removed or reordered on the FLUX or SDXL nodes, so
existing workflows are unaffected.

Bumps version to 1.2.0.
This commit is contained in:
BobsBlazed
2026-07-28 20:04:26 -04:00
committed by GitHub
parent a297e24319
commit 3c7fd499b6
7 changed files with 884 additions and 14 deletions
+86
View File
@@ -10,6 +10,7 @@ This node allows you to go beyond a single strength slider and specify different
## Features
- **Any Architecture**: A third **Universal** loader covers everything else ComfyUI supports — SD1.5, SD2, SD3/3.5, Chroma, AuraFlow, PixArt, HiDream, Qwen-Image, Wan, LTX-Video, Mochi, HunyuanVideo/DiT, Lumina, Cosmos and more. It discovers the model's block layout at runtime instead of using a hard-coded table, so new and pruned architectures work without an update.
- **Dual Model Support**: Separate, optimized loaders for `SDXL` and `FLUX` models, each tailored to the architecture's specific blocks.
- **Granular Block-Level Control**: Fine-tune the strength of a LoRA on different conceptual parts of the diffusion model.
- **Intelligent Presets**: Comes with pre-configured presets for common use cases like `Character`, `Style`, `Concept`, `Detail & Texture` and `Fix Hands/Anatomy`.
@@ -95,6 +96,50 @@ Ranges below are for the canonical FLUX.1 geometry (19 double-stream blocks, 38
| Output Blocks | `output_blocks.*` |
| Other Tensors | `time_embed`, `label_emb`, `out.*` |
### Universal
The Universal loader works on a single normalised **depth axis**. Almost every
diffusion backbone is one or more ordered stacks of repeated blocks:
```
UNet (SD1.5 / SDXL) input_blocks.N -> middle_block -> output_blocks.N
Dual-stream DiT (FLUX) double_blocks.N -> single_blocks.N
HiDream double_stream_blocks.N -> single_stream_blocks.N
MMDiT (SD3) joint_blocks.N
AuraFlow double_layers.N -> single_layers.N
Qwen-Image / LTX-Video transformer_blocks.N
Wan / Mochi / PixArt blocks.N
Lumina noise_refiner.N -> context_refiner.N -> layers.N
```
Those stacks are **discovered from the loaded model**, concatenated in execution
order, and every block gets a position from 0 to 1 along the result. That axis is
split into five buckets, so the same five sliders mean the same thing on a
19+38-block FLUX, a 60-block Qwen-Image and a 20-stage SDXL UNet.
| Block | Covers |
|---|---|
| Text Encoder | Every text-encoder weight (CLIP / T5 / LLM) |
| Input & Embeddings | Patch, timestep, guidance and context embedders |
| Early Blocks (Composition) | First 20% of the stack |
| Early-Mid Blocks (Subject) | 20–40% |
| Mid Blocks (Concept & Style) | 40–60% |
| Late-Mid Blocks (Detail) | 60–80% |
| Late Blocks (Texture) | Final 20% |
| Output Head | Final projection back to latent space |
| Other Tensors | Anything unmatched (normally empty) |
The `info` output names the detected architecture and the discovered stacks, e.g.
```
[UNIVERSAL] mylora.safetensors (preset: Style)
architecture: QwenImage transformer_blocks[60] (total 60)
```
Use the dedicated FLUX or SDXL loader when you want that architecture's named
blocks; use Universal for everything else, or when you want one node whose
sliders behave consistently across models.
## Why Use Block-Weighted LoRA?
A single LoRA file often contains training for multiple concepts (e.g. a character's face, their clothing, and the overall artistic style). A standard LoRA loader applies the LoRA with one uniform strength across the entire model.
@@ -115,6 +160,47 @@ python -m unittest discover -s tests -v
## Changelog
### 1.2.0
**New: a Universal loader covering every architecture ComfyUI supports.**
- **`Bobs LoRA Loader (Universal)`** handles SD1.5, SD2, SDXL, SD3/3.5, FLUX,
Chroma, AuraFlow, PixArt, HiDream, Qwen-Image, Wan, LTX-Video, Mochi,
HunyuanVideo/DiT, Lumina, Cosmos and anything else built as stacks of
repeated blocks. The block layout is *discovered from the loaded model* —
stack names, stack sizes and their execution order — rather than read from a
per-family table, so pruned, distilled and brand-new architectures work
without a code change.
- Weights are assigned along a normalised depth axis (Early → Late), plus
embeddings, output head and text encoder, so the same sliders mean the same
thing across very different models.
- The `info` output now reports the detected architecture and the discovered
stacks, e.g. `architecture: QwenImage transformer_blocks[60] (total 60)`.
The FLUX and SDXL loaders report their architecture too.
**Verified against real ComfyUI.** The classification logic was run against
models built from ComfyUI's own configs and against its `*_to_diffusers` key
tables, covering SD1.5, SDXL, SD3, FLUX (full and pruned geometry), AuraFlow,
PixArt, LTX-Video, Lumina, Qwen-Image and Wan — 8,000+ authentic state-dict
keys, all classified with none falling through to `Other Tensors`. That pass
found and fixed several real gaps that synthetic fixtures had missed:
- SD3 addresses its final block as `joint_blocks.-1`; negative indices are now
resolved against the stack size instead of failing to match.
- Lumina's `noise_refiner` / `context_refiner` stacks are recognised and
ordered ahead of the main `layers` stack.
- AuraFlow's native `double_layers` / `single_layers` names are handled, not
just the diffusers spelling.
- Conditioning embedders that previously fell through — PixArt's `ar_embedder`,
`csize_embedder` and `t_block`, LTX-Video's `adaln_single` and
`scale_shift_table`, Qwen-Image's `txt_norm`, Wan's `time_projection`,
AuraFlow's `cond_seq_linear` / `positional_encoding` — now land in
`Input & Embeddings`.
- FLUX's ControlNet `pos_embed_input` now maps to `Image Hint`.
Existing FLUX and SDXL workflows are unaffected: no widget was added, removed or
reordered on those two nodes.
### 1.1.0
**Block weighting now actually works.** This release fixes a defect that made the per-block sliders unreliable for every LoRA.
+2
View File
@@ -7,6 +7,7 @@ from .bobs_lora_loader import (
NODE_DISPLAY_NAME_MAPPINGS,
BobsLoraLoaderFlux,
BobsLoraLoaderSdxl,
BobsLoraLoaderUniversal,
)
__all__ = [
@@ -14,6 +15,7 @@ __all__ = [
"NODE_DISPLAY_NAME_MAPPINGS",
"BobsLoraLoaderFlux",
"BobsLoraLoaderSdxl",
"BobsLoraLoaderUniversal",
]
logging.getLogger("BobsLoraLoader").info(
+1
View File
@@ -340,6 +340,7 @@ _FLUX_TOKEN_MAP: Sequence[Tuple[str, str]] = (
("_context_embedder", FLUX_TEXT_CONDITIONING), # diffusers name for txt_in
("_img_in", FLUX_IMAGE_HINT),
("_x_embedder", FLUX_IMAGE_HINT), # diffusers name for img_in
("_pos_embed_input", FLUX_IMAGE_HINT), # FLUX ControlNet hint input
("_final_layer", FLUX_FINAL),
("_proj_out", FLUX_FINAL), # diffusers name for final_layer
("_norm_out", FLUX_FINAL),
+67 -12
View File
@@ -37,6 +37,12 @@ from .bobs_blocks import (
flux_block_ranges,
resolve_block_strengths,
)
from .bobs_universal import ( # registers the UNIVERSAL family on import
ALL_UNIVERSAL_BLOCKS,
UNIVERSAL_TOOLTIPS,
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
@@ -84,6 +90,14 @@ def _build_key_maps(model, clip) -> Tuple[Dict[str, Any], set]:
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:
@@ -101,9 +115,12 @@ def _format_report(tag: str,
blocks: List[str],
grouped: Dict[str, Dict[Any, Any]],
strengths: Dict[str, float],
applied: Dict[str, int]) -> str:
lines = [f"[{tag}] {lora_name} (preset: {preset})",
f"{'block':<40} {'weight':>7} {'found':>7} {'applied':>8}"]
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} "
@@ -209,8 +226,8 @@ class _BobsLoraLoaderBase:
# ------------------------------------------------------------ classifier --
def _classifier(self, model):
"""Return ``fn(model_state_dict_key) -> block name`` for this family."""
def _classifier(self, model, unet_targets):
"""Return ``(fn(model_key) -> block name, detail_line)`` for this family."""
raise NotImplementedError
# ----------------------------------------------------------------- apply --
@@ -253,7 +270,7 @@ class _BobsLoraLoaderBase:
# Group UNet patches per conceptual block; CLIP patches all share the
# text-encoder block.
classify = self._classifier(model)
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}
@@ -278,7 +295,7 @@ class _BobsLoraLoaderBase:
# 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)
grouped, strengths, applied, detail)
self.logger.info("\n%s", report)
_explain_empty_blocks(tag, self.BLOCKS, grouped, strengths)
@@ -303,9 +320,12 @@ class BobsLoraLoaderFlux(_BobsLoraLoaderBase):
def INPUT_TYPES(cls):
return cls._input_types()
def _classifier(self, model):
ranges = flux_block_ranges(*_flux_geometry(model))
return lambda key: classify_flux_key(key, ranges)
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
# -----------------------------------------------------------------------------#
@@ -324,8 +344,41 @@ class BobsLoraLoaderSdxl(_BobsLoraLoaderBase):
def INPUT_TYPES(cls):
return cls._input_types()
def _classifier(self, model):
return classify_sdxl_key
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
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
# -----------------------------------------------------------------------------#
@@ -335,9 +388,11 @@ class BobsLoraLoaderSdxl(_BobsLoraLoaderBase):
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)",
}
+402
View File
@@ -0,0 +1,402 @@
"""
Architecture-agnostic block analysis for Bobs LoRA Loader.
The FLUX and SDXL loaders in :mod:`bobs_blocks` know their architecture's block
names up front. That does not scale: ComfyUI ships close to a hundred model
configs, and new ones land regularly. Rather than maintain a table per family,
this module *discovers* a model's block layout from the model's own key set at
runtime, then buckets each key by how deep it sits in the execution order.
The observation that makes this work: essentially every diffusion backbone is
one or more ordered stacks of repeated blocks, whatever they are named —
UNet (SD1.5 / SDXL) input_blocks.N -> middle_block -> output_blocks.N
Dual-stream DiT (FLUX) double_blocks.N -> single_blocks.N
HiDream double_stream_blocks.N -> single_stream_blocks.N
MMDiT (SD3) joint_blocks.N
AuraFlow joint_transformer_blocks.N -> single_transformer_blocks.N
Qwen-Image / LTXV transformer_blocks.N
Wan / Mochi blocks.N
Lumina layers.N
Concatenating those stacks in execution order gives every block a position on a
single 0..1 depth axis, which is then split into five buckets. Anything outside
a stack is an embedding/input layer, an output head, or genuinely unrecognised.
Because the stack sizes come from the model rather than a constant, this adapts
to pruned, distilled and brand-new architectures without a code change.
Imports nothing from ComfyUI or torch; see ``tests/test_bobs_universal.py``.
"""
import re
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
from .bobs_blocks import (
ALL_BLOCKS,
BLOCK_TOOLTIPS,
LORA_BLOCK_PRESETS,
TEXT_ENCODER_BLOCK,
normalize_key,
)
# -----------------------------------------------------------------------------#
# BLOCK NAMES #
# -----------------------------------------------------------------------------#
#
# Widget values serialise positionally — only ever append to this list.
UNIVERSAL_TEXT_ENCODER = "Text Encoder"
UNIVERSAL_INPUT = "Input & Embeddings"
UNIVERSAL_EARLY = "Early Blocks (Composition)"
UNIVERSAL_EARLY_MID = "Early-Mid Blocks (Subject)"
UNIVERSAL_MID = "Mid Blocks (Concept & Style)"
UNIVERSAL_LATE_MID = "Late-Mid Blocks (Detail)"
UNIVERSAL_LATE = "Late Blocks (Texture)"
UNIVERSAL_OUTPUT = "Output Head"
UNIVERSAL_OTHER = "Other Tensors"
ALL_UNIVERSAL_BLOCKS: List[str] = [
UNIVERSAL_TEXT_ENCODER,
UNIVERSAL_INPUT,
UNIVERSAL_EARLY,
UNIVERSAL_EARLY_MID,
UNIVERSAL_MID,
UNIVERSAL_LATE_MID,
UNIVERSAL_LATE,
UNIVERSAL_OUTPUT,
UNIVERSAL_OTHER,
]
# Depth buckets, as upper-exclusive fractions of the concatenated block stacks.
_DEPTH_BUCKETS: Sequence[Tuple[float, str]] = (
(0.2, UNIVERSAL_EARLY),
(0.4, UNIVERSAL_EARLY_MID),
(0.6, UNIVERSAL_MID),
(0.8, UNIVERSAL_LATE_MID),
(1.01, UNIVERSAL_LATE),
)
UNIVERSAL_TOOLTIPS: Dict[str, str] = {
UNIVERSAL_TEXT_ENCODER: "All text-encoder weights (CLIP / T5 / LLM), whatever the model uses.",
UNIVERSAL_INPUT: "Patch, timestep, guidance and context embedding layers feeding the block stack.",
UNIVERSAL_EARLY: "First 20% of the block stack. Global composition and layout.",
UNIVERSAL_EARLY_MID: "20–40% of the block stack. Subject identity.",
UNIVERSAL_MID: "40–60% of the block stack. Concept and dominant style.",
UNIVERSAL_LATE_MID: "60–80% of the block stack. Detail generation.",
UNIVERSAL_LATE: "Final 20% of the block stack. Fine texture and surface.",
UNIVERSAL_OUTPUT: "Final projection back to latent space.",
UNIVERSAL_OTHER: "Tensors that matched no stack, embedding or head pattern.",
}
# -----------------------------------------------------------------------------#
# STACK DISCOVERY #
# -----------------------------------------------------------------------------#
# Execution order of the stack names we know about. A model only ever uses a
# few of these; the order here is what puts them on one axis correctly.
# Unknown stack names still work — they sort after these, alphabetically.
STACK_ORDER: Tuple[str, ...] = (
# UNet
"input_blocks",
"middle_block",
"output_blocks",
# Dual-stream DiT: FLUX, HunyuanVideo, Chroma
"double_blocks",
"single_blocks",
# HiDream
"double_stream_blocks",
"single_stream_blocks",
# MMDiT: SD3 / SD3.5
"joint_blocks",
# Lumina: input refiners run ahead of the main stack
"noise_refiner",
"context_refiner",
# AuraFlow (ComfyUI names them *_layers; the diffusers names also appear)
"double_layers",
"single_layers",
"joint_transformer_blocks",
"single_transformer_blocks",
# Qwen-Image, LTX-Video, PixArt, Cosmos
"transformer_blocks",
# Wan, Mochi, Kandinsky
"blocks",
# Lumina, generic transformer stacks
"layers",
)
# Stack matching runs on the RAW dotted key, never the underscore-normalised
# one. Normalising would erase the separator that tells "mystery_blocks.0"
# (stack "mystery_blocks") apart from a stack literally named "blocks" — both
# collapse to "..._blocks_0". The dot boundary is the whole signal.
#
# No list of known names is needed to *match*: a repeated stack is always a
# module path component ending in "blocks"/"layers" followed by an index.
# STACK_ORDER below is used only to order the stacks once discovered.
# The name prefix is optional so a stack named exactly "blocks" or "layers"
# (Wan, Mochi, PixArt, Lumina) matches just as well as "single_stream_blocks".
# "refiner" is included for Lumina's noise_refiner/context_refiner stacks.
# The index may be negative: ComfyUI's SD3 key map addresses the final joint
# block as "joint_blocks.-1", which is resolved against the stack size below.
_STACK_RE = re.compile(
r"(?:^|\.)((?:[A-Za-z][A-Za-z0-9_]*)?(?:blocks|layers|refiner))\.(-?\d+)(?=\.|$)",
re.IGNORECASE,
)
# ``middle_block`` is a single module rather than an indexed list.
_MIDDLE_RE = re.compile(r"(?:^|\.)(middle_block)(?=\.|$)", re.IGNORECASE)
# Tokens that identify a non-stack tensor. Checked in order, output before
# input, because some head names also contain an embedding-ish word.
_OUTPUT_TOKENS: Tuple[str, ...] = (
"_final_layer",
"_final_linear",
"_proj_out",
"_norm_out",
"_unpatchify",
"_final_norm",
"_head_",
)
_OUTPUT_RE = re.compile(r"_out_\d+(?=_|$)") # SD "out.0" / "out.2" head
_INPUT_TOKENS: Tuple[str, ...] = (
"_img_in",
"_txt_in",
"_x_embedder",
"_patch_embed",
"_patchify",
"_time_in",
"_time_embed",
"_time_text_embed",
"_timestep_embed",
"_t_embedder",
"_guidance_in",
"_vector_in",
"_context_embedder",
"_caption_projection",
"_y_embedder",
"_label_emb",
"_pos_embed",
"_text_embedding",
"_condition_embedder",
"_img_emb",
"_register_tokens",
"_cap_embedder",
"_input_hint",
"_init_x_linear", # AuraFlow
"_cond_seq_linear", # AuraFlow text conditioning
"_positional_encoding",
"_modf", # AuraFlow final modulation
"_adaln_single", # LTX-Video / PixArt shared modulation
"_t_block", # PixArt timestep modulation
"_scale_shift_table",
"_txt_norm", # Qwen-Image text stream norm
"_time_projection", # Wan
"_rope",
"_freqs",
# Generic catch-all, last: covers *_embedder / *_embedding / *_embed names
# we have not enumerated (PixArt's ar_embedder and csize_embedder, and
# whatever the next architecture calls its conditioning embedders).
"_embed",
)
def _stack_match(model_key: str) -> Optional[Tuple[str, int]]:
"""Return ``(stack_name, index)`` for the outermost stack in a raw key.
The *leftmost* match is deliberate, and matters twice over:
- an SDXL key ``input_blocks.4.1.transformer_blocks.0.attn1.to_q.weight``
belongs to ``input_blocks``, not the attention module's inner
``transformer_blocks``;
- ``middle_block.1.transformer_blocks.0...`` belongs to ``middle_block``,
which is why the two patterns are compared by position rather than
tried in a fixed order.
"""
stack = _STACK_RE.search(model_key)
middle = _MIDDLE_RE.search(model_key)
if stack and middle:
return ((stack.group(1), int(stack.group(2))) if stack.start() < middle.start()
else ("middle_block", 0))
if stack:
return stack.group(1), int(stack.group(2))
if middle:
return "middle_block", 0
return None
def _stack_sort_key(name: str) -> Tuple[int, str]:
try:
return (STACK_ORDER.index(name), "")
except ValueError:
return (len(STACK_ORDER), name)
class BlockLayout:
"""The block-stack layout discovered from one model's key set."""
def __init__(self, stacks: Dict[str, int]):
#: stack name -> number of blocks, in execution order
self.stacks: Dict[str, int] = {
name: stacks[name] for name in sorted(stacks, key=_stack_sort_key)
}
self.offsets: Dict[str, int] = {}
running = 0
for name, size in self.stacks.items():
self.offsets[name] = running
running += size
self.total: int = running
def __bool__(self) -> bool:
return self.total > 0
def describe(self) -> str:
if not self.stacks:
return "no block stacks detected"
parts = [f"{name}[{size}]" for name, size in self.stacks.items()]
return " -> ".join(parts) + f" (total {self.total})"
def depth_fraction(self, stack: str, index: int) -> Optional[float]:
"""Position of a block on the 0..1 depth axis, centred in its slot."""
if not self.total or stack not in self.offsets:
return None
size = self.stacks[stack]
if index < 0: # Python-style: "joint_blocks.-1" is the last block.
index += size
# Guard against an index beyond what discovery saw.
index = min(max(index, 0), max(size - 1, 0))
return (self.offsets[stack] + index + 0.5) / self.total
def discover_layout(model_keys: Iterable[str]) -> BlockLayout:
"""Infer the block-stack layout from a model's state-dict key names."""
sizes: Dict[str, int] = {}
for key in model_keys:
found = _stack_match(key)
if found is None:
continue
name, index = found
if index + 1 > sizes.get(name, 0):
sizes[name] = index + 1
return BlockLayout(sizes)
# -----------------------------------------------------------------------------#
# CLASSIFICATION #
# -----------------------------------------------------------------------------#
def classify_universal_key(model_key: str, layout: BlockLayout) -> str:
"""Bucket a UNet/DiT state-dict key using a discovered :class:`BlockLayout`."""
nk = normalize_key(model_key)
found = _stack_match(model_key)
if found is not None:
fraction = layout.depth_fraction(*found)
if fraction is not None:
for upper, name in _DEPTH_BUCKETS:
if fraction < upper:
return name
return UNIVERSAL_LATE
for token in _OUTPUT_TOKENS:
if token in nk:
return UNIVERSAL_OUTPUT
if _OUTPUT_RE.search(nk):
return UNIVERSAL_OUTPUT
for token in _INPUT_TOKENS:
if token in nk:
return UNIVERSAL_INPUT
return UNIVERSAL_OTHER
# -----------------------------------------------------------------------------#
# PRESETS #
# -----------------------------------------------------------------------------#
#
# Registered into the shared tables in bobs_blocks so resolve_block_strengths
# treats "UNIVERSAL" exactly like the two hand-tuned families.
UNIVERSAL_PRESETS = {
"Custom": {},
"Full (Normal LoRA)": {
"strength": 1.0,
"block_weights": {name: 1.0 for name in ALL_UNIVERSAL_BLOCKS},
},
"Character": {
"strength": 1.0,
"block_weights": {
UNIVERSAL_TEXT_ENCODER: 1.0,
UNIVERSAL_INPUT: 1.0,
UNIVERSAL_EARLY: 0.8,
UNIVERSAL_EARLY_MID: 1.0,
UNIVERSAL_MID: 1.0,
UNIVERSAL_LATE_MID: 0.2,
UNIVERSAL_LATE: 0.0,
UNIVERSAL_OUTPUT: 0.0,
UNIVERSAL_OTHER: 1.0,
},
},
"Style": {
"strength": 1.0,
"block_weights": {
UNIVERSAL_TEXT_ENCODER: 0.2,
UNIVERSAL_INPUT: 1.0,
UNIVERSAL_EARLY: 0.1,
UNIVERSAL_EARLY_MID: 0.0,
UNIVERSAL_MID: 0.5,
UNIVERSAL_LATE_MID: 1.0,
UNIVERSAL_LATE: 1.0,
UNIVERSAL_OUTPUT: 1.0,
UNIVERSAL_OTHER: 1.0,
},
},
"Concept": {
"strength": 1.0,
"block_weights": {
UNIVERSAL_TEXT_ENCODER: 1.0,
UNIVERSAL_INPUT: 1.0,
UNIVERSAL_EARLY: 1.0,
UNIVERSAL_EARLY_MID: 0.9,
UNIVERSAL_MID: 0.7,
UNIVERSAL_LATE_MID: 0.4,
UNIVERSAL_LATE: 0.2,
UNIVERSAL_OUTPUT: 0.0,
UNIVERSAL_OTHER: 1.0,
},
},
"Detail & Texture": {
"strength": 1.0,
"block_weights": {
UNIVERSAL_TEXT_ENCODER: 0.0,
UNIVERSAL_INPUT: 1.0,
UNIVERSAL_EARLY: 0.0,
UNIVERSAL_EARLY_MID: 0.0,
UNIVERSAL_MID: 0.2,
UNIVERSAL_LATE_MID: 1.0,
UNIVERSAL_LATE: 1.0,
UNIVERSAL_OUTPUT: 1.0,
UNIVERSAL_OTHER: 0.0,
},
},
"Fix Hands/Anatomy": {
"strength": 0.4,
"block_weights": {
UNIVERSAL_TEXT_ENCODER: 0.2,
UNIVERSAL_INPUT: 1.0,
UNIVERSAL_EARLY: 1.0,
UNIVERSAL_EARLY_MID: 0.5,
UNIVERSAL_MID: 0.0,
UNIVERSAL_LATE_MID: 0.0,
UNIVERSAL_LATE: 0.0,
UNIVERSAL_OUTPUT: 0.0,
UNIVERSAL_OTHER: 0.0,
},
},
}
LORA_BLOCK_PRESETS["UNIVERSAL"] = UNIVERSAL_PRESETS
ALL_BLOCKS["UNIVERSAL"] = ALL_UNIVERSAL_BLOCKS
TEXT_ENCODER_BLOCK["UNIVERSAL"] = UNIVERSAL_TEXT_ENCODER
BLOCK_TOOLTIPS.update(UNIVERSAL_TOOLTIPS)
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "Bobs_LoRA_Loader"
description = "A custom LoRA loader node for ComfyUI with advanced block-weighting controls for both SDXL and FLUX models. Features presets for common use-cases like 'Character' and 'Style', and a 'Custom' mode for fine-grained control over individual model blocks."
version = "1.1.0"
description = "Block-weighted LoRA loaders for ComfyUI. Dedicated SDXL and FLUX nodes plus a Universal node that discovers any supported architecture's block layout at runtime (SD1.5/2/3, Chroma, AuraFlow, PixArt, HiDream, Qwen-Image, Wan, LTX-Video, Mochi, HunyuanVideo, Lumina, Cosmos and more). Presets for common use-cases like 'Character' and 'Style', plus a 'Custom' mode for per-block control."
version = "1.2.0"
license = {file = "LICENSE"}
[project.urls]
+324
View File
@@ -0,0 +1,324 @@
"""Unit tests for the architecture-agnostic block analysis.
Runs without ComfyUI or torch:
python -m unittest discover -s tests -v
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# The package modules use relative imports; expose them under a package name.
import importlib.util # noqa: E402
import types # noqa: E402
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_pkg = types.ModuleType("bobs_pkg")
_pkg.__path__ = [_REPO]
sys.modules.setdefault("bobs_pkg", _pkg)
def _load(name):
spec = importlib.util.spec_from_file_location(
f"bobs_pkg.{name}", os.path.join(_REPO, f"{name}.py"))
module = importlib.util.module_from_spec(spec)
sys.modules[f"bobs_pkg.{name}"] = module
spec.loader.exec_module(module)
return module
bobs_blocks = _load("bobs_blocks")
bu = _load("bobs_universal")
# --------------------------------------------------------------------------- #
# Representative state-dict key sets, one per architecture family.
# Names follow ComfyUI's canonical "diffusion_model.*" naming.
# --------------------------------------------------------------------------- #
def _unet_sd(n_in=9, n_out=9):
keys = ["diffusion_model.time_embed.0.weight",
"diffusion_model.label_emb.0.0.weight",
"diffusion_model.out.2.weight"]
for i in range(n_in):
keys.append(f"diffusion_model.input_blocks.{i}.1.transformer_blocks.0.attn1.to_q.weight")
keys.append("diffusion_model.middle_block.1.transformer_blocks.0.attn2.to_k.weight")
for i in range(n_out):
keys.append(f"diffusion_model.output_blocks.{i}.1.transformer_blocks.0.attn1.to_v.weight")
return keys
def _flat_stack(stack, count, extra=()):
keys = [f"diffusion_model.{stack}.{i}.attn.to_q.weight" for i in range(count)]
return keys + [f"diffusion_model.{k}" for k in extra]
def _dual_stack(first, second, n1, n2, extra=()):
keys = [f"diffusion_model.{first}.{i}.attn.qkv.weight" for i in range(n1)]
keys += [f"diffusion_model.{second}.{i}.linear1.weight" for i in range(n2)]
return keys + [f"diffusion_model.{k}" for k in extra]
FAMILIES = {
"SD15": _unet_sd(12, 12),
"SDXL": _unet_sd(9, 9),
"FLUX": _dual_stack("double_blocks", "single_blocks", 19, 38,
extra=("img_in.weight", "txt_in.weight",
"time_in.in_layer.weight", "guidance_in.in_layer.weight",
"vector_in.in_layer.weight", "final_layer.linear.weight")),
"HiDream": _dual_stack("double_stream_blocks", "single_stream_blocks", 16, 32,
extra=("x_embedder.weight", "final_layer.linear.weight")),
"HunyuanVideo": _dual_stack("double_blocks", "single_blocks", 20, 40,
extra=("img_in.proj.weight", "final_layer.linear.weight")),
"SD3": _flat_stack("joint_blocks", 24,
extra=("x_embedder.proj.weight", "t_embedder.mlp.0.weight",
"y_embedder.mlp.0.weight", "context_embedder.weight",
"pos_embed", "final_layer.linear.weight")),
"AuraFlow": (_flat_stack("joint_transformer_blocks", 4)
+ _flat_stack("single_transformer_blocks", 32)
+ ["diffusion_model.init_x_linear.weight",
"diffusion_model.final_linear.weight"]),
"QwenImage": _flat_stack("transformer_blocks", 60,
extra=("img_in.weight", "txt_in.weight",
"time_text_embed.timestep_embedder.linear_1.weight",
"proj_out.weight")),
"LTXV": _flat_stack("transformer_blocks", 28,
extra=("patchify_proj.weight", "caption_projection.linear_1.weight",
"proj_out.weight")),
"PixArt": _flat_stack("blocks", 28,
extra=("x_embedder.proj.weight", "t_embedder.mlp.0.weight",
"final_layer.linear.weight")),
"Wan21": _flat_stack("blocks", 40,
extra=("patch_embedding.weight", "time_embedding.0.weight",
"text_embedding.0.weight", "head.head.weight")),
"Mochi": _flat_stack("blocks", 48,
extra=("x_embedder.proj.weight", "final_layer.linear.weight")),
"Lumina2": _flat_stack("layers", 26,
extra=("x_embedder.weight", "cap_embedder.1.weight",
"final_layer.linear.weight")),
"Cosmos": _flat_stack("blocks", 28,
extra=("x_embedder.proj.1.weight", "final_layer.linear.weight")),
}
class TestStackDiscovery(unittest.TestCase):
def test_discovers_expected_stacks_and_sizes(self):
cases = {
"SD15": {"input_blocks": 12, "middle_block": 1, "output_blocks": 12},
"SDXL": {"input_blocks": 9, "middle_block": 1, "output_blocks": 9},
"FLUX": {"double_blocks": 19, "single_blocks": 38},
"HiDream": {"double_stream_blocks": 16, "single_stream_blocks": 32},
"SD3": {"joint_blocks": 24},
"AuraFlow": {"joint_transformer_blocks": 4, "single_transformer_blocks": 32},
"QwenImage": {"transformer_blocks": 60},
"Wan21": {"blocks": 40},
"Lumina2": {"layers": 26},
}
for family, expected in cases.items():
layout = bu.discover_layout(FAMILIES[family])
self.assertEqual(layout.stacks, expected, family)
def test_stacks_are_ordered_by_execution_order(self):
layout = bu.discover_layout(FAMILIES["SD15"])
self.assertEqual(list(layout.stacks), ["input_blocks", "middle_block", "output_blocks"])
layout = bu.discover_layout(FAMILIES["FLUX"])
self.assertEqual(list(layout.stacks), ["double_blocks", "single_blocks"])
layout = bu.discover_layout(FAMILIES["AuraFlow"])
self.assertEqual(list(layout.stacks),
["joint_transformer_blocks", "single_transformer_blocks"])
def test_outermost_stack_wins_over_nested_one(self):
# SDXL nests transformer_blocks inside input_blocks; the outer one counts.
key = "diffusion_model.input_blocks.4.1.transformer_blocks.0.attn1.to_q.weight"
self.assertEqual(bu._stack_match(key), ("input_blocks", 4))
def test_longer_stack_name_wins(self):
for key, expected in [
("diffusion_model.single_transformer_blocks.3.x.weight",
("single_transformer_blocks", 3)),
("diffusion_model.single_blocks.3.x.weight", ("single_blocks", 3)),
("diffusion_model.double_stream_blocks.2.x.weight", ("double_stream_blocks", 2)),
("diffusion_model.blocks.7.x.weight", ("blocks", 7)),
("diffusion_model.layers.7.x.weight", ("layers", 7)),
]:
self.assertEqual(bu._stack_match(key), expected, key)
def test_unknown_stack_name_still_discovered(self):
keys = [f"diffusion_model.mystery_blocks.{i}.attn.weight" for i in range(10)]
layout = bu.discover_layout(keys)
self.assertEqual(layout.stacks, {"mystery_blocks": 10})
self.assertEqual(layout.total, 10)
def test_empty_layout_is_falsy_and_described(self):
layout = bu.discover_layout(["diffusion_model.final_layer.linear.weight"])
self.assertFalse(layout)
self.assertIn("no block stacks", layout.describe())
class TestUniversalClassification(unittest.TestCase):
def test_every_family_covers_its_whole_stack_without_other(self):
"""No key from any family should fall through to 'Other Tensors'."""
for family, keys in FAMILIES.items():
layout = bu.discover_layout(keys)
unclassified = [k for k in keys
if bu.classify_universal_key(k, layout) == bu.UNIVERSAL_OTHER]
self.assertEqual(unclassified, [], f"{family}: {unclassified[:5]}")
def test_every_depth_bucket_is_reachable(self):
for family, keys in FAMILIES.items():
layout = bu.discover_layout(keys)
if not layout:
continue
buckets = {bu.classify_universal_key(k, layout) for k in keys}
for expected in (bu.UNIVERSAL_EARLY, bu.UNIVERSAL_MID, bu.UNIVERSAL_LATE):
self.assertIn(expected, buckets, f"{family} missing {expected}")
def test_depth_ordering_is_monotonic(self):
"""Walking a flat stack front to back must never move backwards."""
order = [bu.UNIVERSAL_EARLY, bu.UNIVERSAL_EARLY_MID, bu.UNIVERSAL_MID,
bu.UNIVERSAL_LATE_MID, bu.UNIVERSAL_LATE]
keys = _flat_stack("blocks", 40)
layout = bu.discover_layout(keys)
seen = [order.index(bu.classify_universal_key(k, layout)) for k in keys]
self.assertEqual(seen, sorted(seen))
self.assertEqual(seen[0], 0)
self.assertEqual(seen[-1], len(order) - 1)
def test_dual_stack_spans_the_whole_axis(self):
"""FLUX double blocks sit early, single blocks run to the end."""
layout = bu.discover_layout(FAMILIES["FLUX"])
first = bu.classify_universal_key(
"diffusion_model.double_blocks.0.img_attn.qkv.weight", layout)
last = bu.classify_universal_key(
"diffusion_model.single_blocks.37.linear1.weight", layout)
self.assertEqual(first, bu.UNIVERSAL_EARLY)
self.assertEqual(last, bu.UNIVERSAL_LATE)
def test_unet_stages_land_in_sensible_buckets(self):
layout = bu.discover_layout(FAMILIES["SDXL"])
early = bu.classify_universal_key(
"diffusion_model.input_blocks.0.1.transformer_blocks.0.attn1.to_q.weight", layout)
late = bu.classify_universal_key(
"diffusion_model.output_blocks.8.1.transformer_blocks.0.attn1.to_v.weight", layout)
self.assertEqual(early, bu.UNIVERSAL_EARLY)
self.assertEqual(late, bu.UNIVERSAL_LATE)
def test_embedding_and_head_tokens(self):
layout = bu.discover_layout(FAMILIES["FLUX"])
inputs = ["diffusion_model.img_in.weight", "diffusion_model.txt_in.weight",
"diffusion_model.time_in.in_layer.weight",
"diffusion_model.x_embedder.proj.weight",
"diffusion_model.patch_embedding.weight",
"diffusion_model.caption_projection.linear_1.weight",
"diffusion_model.label_emb.0.0.weight"]
heads = ["diffusion_model.final_layer.linear.weight",
"diffusion_model.proj_out.weight",
"diffusion_model.norm_out.linear.weight",
"diffusion_model.out.2.weight",
"diffusion_model.final_linear.weight"]
for key in inputs:
self.assertEqual(bu.classify_universal_key(key, layout),
bu.UNIVERSAL_INPUT, key)
for key in heads:
self.assertEqual(bu.classify_universal_key(key, layout),
bu.UNIVERSAL_OUTPUT, key)
def test_unrecognised_key_falls_through(self):
layout = bu.discover_layout(FAMILIES["FLUX"])
self.assertEqual(
bu.classify_universal_key("diffusion_model.mystery.weight", layout),
bu.UNIVERSAL_OTHER)
def test_classification_is_safe_with_an_empty_layout(self):
layout = bu.discover_layout([])
self.assertEqual(
bu.classify_universal_key("diffusion_model.blocks.3.attn.weight", layout),
bu.UNIVERSAL_OTHER)
def test_index_beyond_discovered_size_is_clamped(self):
layout = bu.discover_layout(_flat_stack("blocks", 10))
result = bu.classify_universal_key("diffusion_model.blocks.99.attn.weight", layout)
self.assertEqual(result, bu.UNIVERSAL_LATE)
class TestRealWorldRegressions(unittest.TestCase):
"""Cases found by running against real ComfyUI models, locked in here.
Each of these silently landed in 'Other Tensors' before being fixed.
"""
def test_sd3_negative_block_index(self):
# ComfyUI's SD3 key map addresses the final block as "joint_blocks.-1".
layout = bu.discover_layout(_flat_stack("joint_blocks", 24))
key = "diffusion_model.joint_blocks.-1.context_block.adaLN_modulation.1.weight"
self.assertEqual(bu._stack_match(key), ("joint_blocks", -1))
self.assertEqual(bu.classify_universal_key(key, layout), bu.UNIVERSAL_LATE)
def test_lumina_refiner_stacks_are_discovered(self):
keys = (_flat_stack("noise_refiner", 2) + _flat_stack("context_refiner", 2)
+ _flat_stack("layers", 32))
layout = bu.discover_layout(keys)
self.assertEqual(layout.stacks,
{"noise_refiner": 2, "context_refiner": 2, "layers": 32})
self.assertEqual(list(layout.stacks)[:2], ["noise_refiner", "context_refiner"])
self.assertEqual(
[k for k in keys if bu.classify_universal_key(k, layout) == bu.UNIVERSAL_OTHER],
[])
def test_auraflow_native_layer_stacks(self):
# ComfyUI names these *_layers, not the diffusers *_transformer_blocks.
keys = _flat_stack("double_layers", 4) + _flat_stack("single_layers", 32)
layout = bu.discover_layout(keys)
self.assertEqual(list(layout.stacks), ["double_layers", "single_layers"])
def test_conditioning_embedders_are_input_not_other(self):
layout = bu.discover_layout(_flat_stack("blocks", 28))
for key in ("diffusion_model.ar_embedder.mlp.0.weight", # PixArt
"diffusion_model.csize_embedder.mlp.0.weight", # PixArt
"diffusion_model.t_block.1.weight", # PixArt
"diffusion_model.adaln_single.linear.weight", # LTX-Video
"diffusion_model.scale_shift_table", # LTX-Video
"diffusion_model.txt_norm.weight", # Qwen-Image
"diffusion_model.time_projection.1.weight", # Wan
"diffusion_model.cond_seq_linear.weight", # AuraFlow
"diffusion_model.positional_encoding", # AuraFlow
"diffusion_model.init_x_linear.weight"): # AuraFlow
self.assertEqual(bu.classify_universal_key(key, layout),
bu.UNIVERSAL_INPUT, key)
def test_flux_controlnet_hint_input(self):
self.assertEqual(
bobs_blocks.classify_flux_key("diffusion_model.pos_embed_input.weight"),
bobs_blocks.FLUX_IMAGE_HINT)
class TestUniversalPresets(unittest.TestCase):
def test_registered_into_the_shared_tables(self):
self.assertIn("UNIVERSAL", bobs_blocks.LORA_BLOCK_PRESETS)
self.assertIs(bobs_blocks.ALL_BLOCKS["UNIVERSAL"], bu.ALL_UNIVERSAL_BLOCKS)
self.assertEqual(bobs_blocks.TEXT_ENCODER_BLOCK["UNIVERSAL"],
bu.UNIVERSAL_TEXT_ENCODER)
def test_every_preset_covers_every_block(self):
for name, config in bu.UNIVERSAL_PRESETS.items():
if name == "Custom":
self.assertEqual(config, {})
continue
self.assertEqual(set(config["block_weights"]), set(bu.ALL_UNIVERSAL_BLOCKS), name)
def test_resolve_block_strengths_handles_the_universal_family(self):
result = bobs_blocks.resolve_block_strengths("UNIVERSAL", "Full (Normal LoRA)", 0.7, {})
self.assertEqual(set(result), set(bu.ALL_UNIVERSAL_BLOCKS))
for value in result.values():
self.assertAlmostEqual(value, 0.7)
def test_every_block_has_a_tooltip(self):
for name in bu.ALL_UNIVERSAL_BLOCKS:
self.assertIn(name, bobs_blocks.BLOCK_TOOLTIPS)
if __name__ == "__main__":
unittest.main()