Files
2026-05-19 22:27:40 +03:00

1420 lines
58 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# klein_normalizer.py - UNIVERSAL NORMALIZER WITH ALL FORMATS (CLEANED)
import torch
import re
from collections import defaultdict
from typing import Dict, List
try:
from ..utils import safe_get_rank, silent_pad_or_truncate
except ImportError:
from utils import safe_get_rank, silent_pad_or_truncate
# ==================== PERFORMANCE & REPAIR CONFIGURATION ====================
ENABLE_AUTO_REPAIR = True
ENABLE_BATCH_RANK_FIX = True
DEBUG_KEY_DIFF = False # Enable for debugging conversions
_current_mapping_ref = None # optional mapping dict for identity tracking
# Cache for conversion results keyed by (format_type, key_fingerprint)
# Stores the converted state dict to avoid redundant conversions.
_NORMALIZE_CACHE: dict = {}
# Increment this version counter to bust all cached normalization results.
# Use when normalization logic changes (e.g., new key conversion rules).
# Version 1 → 2: SD1.5 TE key normalization fix (text_model. prefix handling)
NORMALIZE_CACHE_VERSION = 2
# ==================== PRE-COMPILED REGEX PATTERNS ====================
LAYER_PATTERN = re.compile(r'layers_(\d+)_')
BLOCK_PATTERN = re.compile(r'(double|single)_blocks_(\d+)_')
ATTN_PATTERN = re.compile(r'(img|txt)_attn_(proj|qkv)')
TO_OUT_PATTERN = re.compile(r'to_out_(\d+)')
MLP_PATTERN = re.compile(r'(img|txt)_mlp_(\d+)')
FEED_FORWARD_PATTERN = re.compile(r'feed_forward_w(\d)')
RESBLOCK_PATTERN = re.compile(r'resblocks\.(\d+)_')
ANIMA_BLOCK_PATTERN = re.compile(r'blocks_(\d+)_')
# ==================== UTILITY FUNCTIONS ====================
def fast_cleanup_key(key):
"""Standardizes underscores into dots for Flux/Z-Image compatibility."""
# 1. Remove common prefixes that shouldn't be there
key = key.replace("lora_unet_", "diffusion_model.")
# 2. Fix the 'attention_to' vs 'attention.to' (Z-Image & Flux)
key = key.replace("attention_to_", "attention.to_")
# 3. Fix the 'img_attn_qkv' and 'txt_attn_proj' (Flux 4B/9B)
# This handles the mismatch where one side has dots and the other underscores
replacements = {
"img_attn_": "img_attn.",
"txt_attn_": "txt_attn.",
"img_mlp_": "img_mlp.",
"txt_mlp_": "txt_mlp.",
"to_out_": "to_out.",
"feed_forward_w": "feed_forward.w"
}
for old, new in replacements.items():
key = key.replace(old, new)
# 4. Final safety: remove double dots if they were accidentally created
while ".." in key:
key = key.replace("..", ".")
return key
def debug_key_change(old, new):
"""Print key transformations when debugging is enabled."""
if DEBUG_KEY_DIFF and old != new:
print(f"🔧 {old} → {new}")
def safe_insert(converted, key, value, original_key=None):
"""
Prevent silent overwriting if two keys collapse into one.
Keeps first occurrence, warns on collision.
"""
if key in converted:
print("WARNING: Key collision detected!")
if original_key:
print(f" Original: {original_key}")
print(f" Collides with existing: {key}")
print(" Keeping first occurrence.")
return
converted[key] = value
# Store mapping if global mapping ref exists and original_key provided
if original_key is not None and _current_mapping_ref is not None:
_current_mapping_ref[key] = original_key
def get_alpha_value(state_dict, key, default=16.0):
"""Determine alpha value, checking for prefixed metadata from previous merges."""
# 1. Check for alpha key inside the state_dict (tensors)
base_key = (key.replace(".lora_A.weight", "")
.replace(".lora_B.weight", "")
.replace(".lora_down.weight", "")
.replace(".lora_up.weight", "")
.replace(".lora.down.weight", "")
.replace(".lora.up.weight", ""))
alpha_patterns = [
f"{base_key}.alpha",
f"{base_key}_alpha",
key.replace("lora_A.weight", "alpha"),
key.replace("lora_B.weight", "alpha"),
]
for ak in alpha_patterns:
if ak in state_dict:
val = state_dict[ak]
return val.item() if isinstance(val, torch.Tensor) else float(val)
# 2. METADATA CHECK (3-way check for EasyLoRAMerger styles)
meta = getattr(state_dict, 'metadata', {})
if meta:
# Check standard, then Preserve A prefix, then Preserve B prefix
for mk in ['ss_network_alpha', 'lora_a_ss_network_alpha', 'lora_b_ss_network_alpha']:
if mk in meta:
try:
return float(meta[mk])
except:
continue
# 3. FALLBACK: RANK INFERENCE (For 'None' mode merges)
if key in state_dict:
tensor = state_dict[key]
if hasattr(tensor, 'shape') and len(tensor.shape) >= 2:
# Pick dim 0 for A/down, dim 1 for B/up
if "lora_A" in key or "lora_down" in key or "lora.down" in key:
rank = tensor.shape[0]
else:
rank = tensor.shape[1]
return float(rank)
return default
# ==================== DETECTION & REPAIR ====================
def detect_broken_lora(state_dict):
"""Detect common LoRA corruption issues."""
issues = []
a_keys = [k for k in state_dict if "lora_A" in k]
b_keys = [k for k in state_dict if "lora_B" in k]
# If there are no LoRA keys, this is not a LoRA - skip detection
if len(a_keys) == 0 and len(b_keys) == 0:
return issues
if len(a_keys) != len(b_keys):
issues.append("Mismatch between lora_A and lora_B counts")
for key, tensor in state_dict.items():
if hasattr(tensor, "shape"):
# Skip alpha tensors - they can be 0D (scalars)
if '.alpha' in key and len(tensor.shape) == 0:
continue # This is fine!
# Only check shape for LoRA-related tensors
if "lora" in key.lower() and len(tensor.shape) < 2 and '.alpha' not in key:
issues.append(f"Bad tensor shape: {key}")
if issues:
print("WARNING: Broken LoRA detected:")
for i in issues[:5]:
print(" -", i)
return issues
def repair_missing_alpha(state_dict):
"""Add missing alpha keys."""
repaired = dict(state_dict)
added = 0
for key in list(state_dict.keys()):
if "lora_A.weight" in key:
alpha_key = key.replace(".lora_A.weight", ".alpha")
if alpha_key not in repaired:
from .scale_utils import find_alpha_value
meta = getattr(state_dict, 'metadata', {})
alpha_val = find_alpha_value(state_dict, key, default=16.0, metadata=meta)
if alpha_val is None:
# Rank inference fallback (original behavior)
alpha_val = 16.0
repaired[alpha_key] = torch.tensor(alpha_val)
added += 1
if added:
print(f"🛠 Added {added} missing alpha keys")
return repaired
def batch_fix_lora_ranks(state_dict, target_rank=None):
"""Fix mismatched LoRA ranks across a batch."""
if not ENABLE_BATCH_RANK_FIX:
return state_dict
grouped = defaultdict(list)
modified_count = 0
for key, tensor in state_dict.items():
if "lora_" in key and hasattr(tensor, "shape"):
grouped[key.split(".lora_")[0]].append((key, tensor))
fixed = dict(state_dict)
for group, entries in grouped.items():
ranks = []
for k, t in entries:
if len(t.shape) >= 2:
ranks.append(min(t.shape[:2]))
if not ranks:
continue
desired_rank = target_rank or min(ranks)
# Check if any tensor needs adjustment
for key, tensor in entries:
current_rank = safe_get_rank(tensor, key)
if current_rank != desired_rank:
fixed[key] = silent_pad_or_truncate(tensor, desired_rank, key)
modified_count += 1
if modified_count > 0:
print(f"WARNING: Batch rank fix adjusted {modified_count} tensors")
else:
print("* Batch rank fix complete (no changes needed)")
return fixed
def auto_repair_lora(state_dict):
"""Automatically detect and repair common LoRA issues."""
if not ENABLE_AUTO_REPAIR:
return state_dict
# Preserve metadata
metadata = getattr(state_dict, "metadata", None)
issues = detect_broken_lora(state_dict)
repaired = dict(state_dict) # Convert to dict to avoid metadata issues
if issues:
repaired = repair_missing_alpha(repaired)
repaired = batch_fix_lora_ranks(repaired)
# Restore metadata if it was a safetensors object
if metadata and hasattr(state_dict, "metadata"):
# If we need to preserve metadata, we'd need to return to safetensors format
# This is complex - maybe just log a warning
print("Note: Metadata preservation requires safetensors object")
return repaired
def accelerate_lora_load(state_dict):
"""Run repair pipeline on LoRA load."""
print("* Accelerating LoRA load...")
state_dict = auto_repair_lora(state_dict)
return state_dict
# ==================== FORMAT DETECTION ====================
def detect_lora_format(state_dict, mapping_ref=None):
"""Detect which LoRA format we're dealing with."""
if not state_dict:
return "unknown"
keys = list(state_dict.keys())
if not keys:
return "unknown"
# Infer separator and naming styles (for universal key normalization)
if mapping_ref is not None:
separator_style = infer_separator_style(keys)
naming_style = infer_naming_style(keys)
mapping_ref["__separator_style__"] = separator_style
mapping_ref["__naming_style__"] = naming_style
# Check for SDXL Kohya format (with or without text encoder)
# Check for both original (underscore) and normalized (dot) notation
has_sdxl_patterns = any("input_blocks_" in k or "middle_block_" in k or "output_blocks_" in k or
"input_blocks." in k or "middle_block." in k or "output_blocks." in k for k in keys)
if has_sdxl_patterns:
has_lora_a_b = any("lora_A" in k or "lora_B" in k for k in keys)
if has_lora_a_b:
# SDXL-specific patterns
# Check for SDXL-specific architecture patterns
# IMPORTANT: Check ALL keys for SDXL architecture, not just lora_A/lora_B keys
# This fixes issue where wai-A3-V140.safetensors was misdetected as SD1.5
has_emb_layers = any("emb_layers" in k for k in keys)
has_proj_in_out = any("proj_in" in k or "proj_out" in k for k in keys)
has_in_out_layers = any("in_layers" in k or "out_layers" in k for k in keys)
has_time_embed = any("time_embed" in k for k in keys)
has_transformer_blocks = any("transformer_blocks" in k for k in keys)
has_sdxl_architecture = (
has_emb_layers or
has_proj_in_out or
has_in_out_layers or
has_time_embed or
has_transformer_blocks
)
if has_sdxl_architecture:
return "sdxl_kohya"
else:
return "sd15_kohya"
# Count different key patterns
lora_down_count = sum(1 for k in keys if "lora_down" in k and "weight" in k)
lora_up_count = sum(1 for k in keys if "lora_up" in k and "weight" in k)
lora_a_count = sum(1 for k in keys if "lora_A" in k and "weight" in k)
lora_b_count = sum(1 for k in keys if "lora_B" in k and "weight" in k)
# Check for SDXL with Text Encoder (must come before SDXL standard check)
# Check for both original (lora_te) and normalized (te1., te.) TE patterns
has_te_original = any("lora_te" in k for k in keys)
has_te_normalized = any("te1." in k or "te." in k for k in keys)
has_te = has_te_original or has_te_normalized
if has_te:
# Check if it has SDXL patterns (original or normalized)
has_sdxl_original = any("lora_unet_" in k for k in keys) and \
any("input_blocks_" in k or "middle_block_" in k or "output_blocks_" in k for k in keys)
has_sdxl_normalized = any("input_blocks." in k or "middle_block." in k or "output_blocks." in k for k in keys)
has_sdxl = has_sdxl_original or has_sdxl_normalized
if has_sdxl:
# This is SDXL with TE, not Pony
# Check for both original (lora_down/lora_up) and normalized (lora_A/lora_B) formats
has_down_up = (lora_down_count > 0 and lora_up_count > 0)
has_a_b = (lora_a_count > 0 and lora_b_count > 0)
if has_down_up:
return "sdxl_with_te_lora_down_up"
elif has_a_b:
return "sdxl_with_te_lora_a_b"
else:
# Fallback based on key count
return "sdxl_with_te_lora_down_up" if lora_down_count > lora_a_count else "sdxl_with_te_lora_a_b"
# Check for SDXL standard format (lora_unet_ prefix with lora_down/lora_up)
if any("lora_unet_" in k for k in keys):
if any("input_blocks_" in k or "middle_block_" in k or "output_blocks_" in k for k in keys):
if any("lora_down" in k or "lora_up" in k for k in keys):
# Check if it's a small/partial LoRA (Illustrious style)
if len(keys) < 50:
return "illustrious_sdxl"
return "sdxl_standard"
# Check for Musubi Text Encoder format (needs conversion)
if any("lora_te_res_blocks" in k for k in keys):
if any("diffusion_model" in k for k in keys) or any("layers" in k for k in keys):
return "musubi_zimage_te_needs_conversion"
else:
return "musubi_te_needs_conversion"
# Check for Musubi format (needs conversion)
if any("lora_unet_double_blocks" in k for k in keys):
return "musubi_flux_needs_conversion"
if any("lora_unet_layers" in k for k in keys):
return "musubi_zimage_needs_conversion"
# Check if partially converted
if any("diffusion_model" in k for k in keys) and any("lora_unet" in k for k in keys):
return "musubi_partially_converted"
# Check for Anima format (lora_unet_blocks_ without double/single prefix)
if any("lora_unet_blocks_" in k for k in keys):
if not any("double_blocks" in k or "single_blocks" in k for k in keys):
return "anima_needs_conversion"
# =====================================================================
# 🔥 SD1.5 Diffusers format detection
# Detects LoRAs using HuggingFace Diffusers naming convention:
# down_blocks_X / up_blocks_X / mid_block_X
# instead of ComfyUI-native:
# input_blocks_X / output_blocks_X / middle_block_X
#
# Must come BEFORE the generic musubi catch-all (line 375) because
# these keys also contain "lora_unet_" and would be misrouted.
#
# Examples:
# lora_unet_down_blocks_0_attentions_1_transformer_blocks_0_attn2_to_out.0
# lora_unet_mid_block_attentions_0_transformer_blocks_0_attn1_to_q
# lora_unet_up_blocks_2_attentions_0_transformer_blocks_0_attn2_to_v
#
# Excludes Flux (double_blocks/single_blocks — different structure)
# and Anima (blocks_N_ without down/up prefix — already caught above).
# =====================================================================
if any("lora_unet_" in k for k in keys):
has_diffusers_sd15 = any(
"_down_blocks_" in k or "_up_blocks_" in k or "_mid_block_" in k
for k in keys
)
has_flux_patterns = any("double_blocks" in k or "single_blocks" in k for k in keys)
if has_diffusers_sd15 and not has_flux_patterns:
return "sd15_diffusers_needs_conversion"
if any("lora_unet_" in k for k in keys):
return "musubi_other_needs_conversion"
# Check for Pony Diffusion (only if not SDXL with TE)
if any("lora_te" in k for k in keys):
if lora_down_count > 0 and lora_up_count > 0:
return "pony_diffusion_lora_down_up"
elif lora_a_count > 0 and lora_b_count > 0:
return "pony_diffusion_lora_a_b"
# Check for Flux Klein
if any("diffusion_model" in k for k in keys):
if any("double_blocks" in k for k in keys):
if lora_down_count > 0 and lora_up_count > 0:
return "flux_klein_lora_down_up"
elif lora_a_count > 0 and lora_b_count > 0:
return "flux_klein_lora_a_b"
# Check for Z-Image format
if any("diffusion_model.layers" in k for k in keys):
if lora_a_count > 0 and lora_b_count > 0:
return "z_image_lora_a_b"
elif lora_down_count > 0 and lora_up_count > 0:
return "z_image_lora_down_up"
# Check for SDXL Diffusers format
if any("lora.down" in k or "lora.up" in k for k in keys):
return "sdxl_diffusers"
# Check for Z-Image variants
if any("diffusion_model.layers." in k for k in keys):
if any("adaLN_modulation" in k for k in keys):
return "z_image_ai_toolkit"
else:
return "z_image_other"
if any(".lora_A.default.weight" in k or ".lora_B.default.weight" in k for k in keys):
return "z_image_default"
# Check for Flux Klein variants
if any("diffusion_model.double_blocks." in k for k in keys):
# Check for 9B by tensor shape
for k in keys[:5]:
if "lora_A" in k and state_dict[k].shape[1] == 4096:
return "flux_klein_9b"
return "flux_klein_4b"
if any("diffusion_model.single_blocks." in k for k in keys):
return "flux_klein_4b"
if any("diffusion_model." in k and ("double_blocks" in k or "single_blocks" in k) for k in keys):
return "flux_klein_4b"
# SD1.5 Kohya
if any("lora_A" in k or "lora_B" in k for k in keys):
return "sd15_kohya"
# LyCORIS
if any("lora." in k and ("alpha" in k or "dyn" in k) for k in keys):
return "lycoris"
return "unknown"
# ==================== INFERENCE FUNCTIONS ====================
def infer_separator_style(keys):
"""
Infer separator style from a list of keys.
Returns "dot", "underscore", or "hybrid".
"""
dot_pattern = re.compile(r'\.\d+\.') # .0.
hybrid_pattern = re.compile(r'_\d+\.') # _0.
underscore_pattern = re.compile(r'_\d+_') # _0_
dot_count = 0
hybrid_count = 0
underscore_count = 0
for key in keys:
if hybrid_pattern.search(key):
hybrid_count += 1
elif dot_pattern.search(key):
dot_count += 1
elif underscore_pattern.search(key):
underscore_count += 1
if hybrid_count > 0:
return "hybrid"
if dot_count > underscore_count:
return "dot"
else:
return "underscore"
def infer_naming_style(keys):
"""
Infer naming style from a list of keys.
Returns "lora_a_b" or "lora_down_up".
"""
lora_a_count = sum(1 for k in keys if "lora_A" in k)
lora_b_count = sum(1 for k in keys if "lora_B" in k)
lora_down_count = sum(1 for k in keys if "lora_down" in k)
lora_up_count = sum(1 for k in keys if "lora_up" in k)
if lora_a_count + lora_b_count > lora_down_count + lora_up_count:
return "lora_a_b"
else:
return "lora_down_up"
def repair_hybrid_separators(key):
"""Convert hybrid underscore‑before‑index patterns to pure dot notation."""
# Pattern: _(\d+)\. (underscore before index, dot after)
hybrid = re.compile(r'_(\d+)\.')
# Replace with .\1.
new_key = hybrid.sub(r'.\1.', key)
return new_key
# ==================== MUSUBI CONVERTERS ====================
def convert_musubi_te_to_standard(state_dict):
"""
Convert Musubi Text Encoder format to standard ComfyUI format.
Musubi TE: lora_te_res_blocks_0_mlp_c_fc.lora_down.weight
Standard: conditioner.embedders.0.transformer.resblocks.0.mlp.c_fc.lora_A.weight
"""
print("🔄 Converting Musubi Text Encoder format...")
converted = {}
for key, value in state_dict.items():
new_key = key
# Convert resblock pattern
new_key = RESBLOCK_PATTERN.sub(r'resblocks.\1.', new_key)
# Convert lora_down/up to lora_A/B
new_key = (new_key
.replace("lora_down.weight", "lora_A.weight")
.replace("lora_up.weight", "lora_B.weight"))
new_key = fast_cleanup_key(new_key)
debug_key_change(key, new_key)
safe_insert(converted, new_key, value, key)
print(f"✅ Converted {len(converted)} TE keys")
return converted
def convert_musubi_to_standard(state_dict):
"""Convert Musubi Flux/Klein format to standard."""
print("🔄 Converting Musubi format...")
converted = {}
# Check if this is actually Pony format
is_pony = any("lora_te" in k for k in state_dict.keys())
for key, value in state_dict.items():
new_key = key
# PONY BYPASS - don't convert these!
if is_pony and "lora_te" in key:
converted[key] = value
continue
# Prefix normalization
if not new_key.startswith("diffusion_model."):
new_key = new_key.replace("lora_unet_", "diffusion_model.")
# Repair hybrid separators (e.g., _0. -> .0.)
new_key = repair_hybrid_separators(new_key)
# Pattern substitutions
new_key = LAYER_PATTERN.sub(r'layers.\1.', new_key)
new_key = BLOCK_PATTERN.sub(r'\1_blocks.\2.', new_key)
new_key = ATTN_PATTERN.sub(r'\1_attn.\2', new_key)
new_key = TO_OUT_PATTERN.sub(r'to_out.\1', new_key)
new_key = MLP_PATTERN.sub(r'\1_mlp.\2', new_key)
new_key = FEED_FORWARD_PATTERN.sub(r'feed_forward.w\1', new_key)
# Attention routing
new_key = (new_key
.replace("attention_to_k", "attention.to_k")
.replace("attention_to_q", "attention.to_q")
.replace("attention_to_v", "attention.to_v")
.replace("attention_to_out_0", "attention.to_out.0"))
# Index normalization
new_key = re.sub(r'\.(\d+)_', r'.\1.', new_key)
new_key = re.sub(r'_(\d+)\.', r'.\1.', new_key)
# LoRA naming
new_key = (new_key
.replace("lora_down.weight", "lora_A.weight")
.replace("lora_up.weight", "lora_B.weight"))
new_key = fast_cleanup_key(new_key)
debug_key_change(key, new_key)
safe_insert(converted, new_key, value, key)
print(f"✅ Converted {len(converted)} keys")
return converted
def _convert_sd15_diffusers_key(key: str) -> str:
"""
Convert a single SD1.5 Diffusers-format key to ComfyUI-native format.
This is the core conversion logic extracted as a pure string transformation
so it can be reused by convert_sd15_diffusers_to_comfyui() and tests.
See convert_sd15_diffusers_to_comfyui() for full mapping documentation.
"""
new_key = key
# Step 1: Convert lora_unet_ prefix → diffusion_model.
if new_key.startswith("lora_unet_"):
new_key = "diffusion_model." + new_key[len("lora_unet_"):]
# Step 1b: Handle lora_te_ prefix (Text Encoder keys for SD1.5 Diffusers)
# Raw input: lora_te_text_model_encoder_layers_0_self_attn_k_proj
# Output: text_model.encoder.layers.0.self_attn.k_proj
# This normalizes the TE path so the reverse map (Strategy 0) can match
# against the checkpoint's cond_stage_model.transformer.text_model.* keys.
if new_key.startswith("lora_te_"):
new_key = new_key[len("lora_te_"):] # Remove lora_te_ prefix
# Convert text_model_encoder_layers_N_ → text_model.encoder.layers.N.
new_key = re.sub(
r'text_model_encoder_layers_(\d+)_',
r'text_model.encoder.layers.\1.',
new_key
)
# Convert self_attn_k_proj → self_attn.k_proj
new_key = re.sub(
r'self_attn_(q|k|v|out)_proj',
r'self_attn.\1_proj',
new_key
)
# Convert mlp_fc1 → mlp.fc1, mlp_fc2 → mlp.fc2
new_key = re.sub(r'mlp_fc(\d)', r'mlp.fc\1', new_key)
# Step 2: Convert down_blocks_X → input_blocks.(X+1)
# (input_blocks.0 = conv_in, so down_blocks.0 → input_blocks.1, etc.)
def _shift_down_block(m):
idx = int(m.group(1))
return f"input_blocks_{idx + 1}_"
new_key = re.sub(r'down_blocks_(\d+)_', _shift_down_block, new_key)
# Step 3: Convert up_blocks_X → output_blocks_X (1:1)
new_key = re.sub(r'up_blocks_(\d+)_', r'output_blocks_\1_', new_key)
# Step 4: Convert mid_block_ → middle_block_ (1:1)
new_key = new_key.replace("mid_block_", "middle_block_")
# Step 5: Convert attentions_N_ → sub-block index 1_ (ComfyUI SD1.5)
# 🔥 CRITICAL FIX: ComfyUI SD1.5 places attention at sub-block index 1
# (e.g., input_blocks.1.1.transformer_blocks.0), NOT at the Diffusers
# attention index N. Previous code used '\1_' (the attention index),
# which produced sub-block .0 for all attention keys, making them
# un-matchable via the reverse key map (Strategy 0). All attention
# deltas then fell through to Energy Redirect, causing shape mismatches
# and OOM crashes.
# Reference: SD1.5 sub-block map — .0=resnet, .1=attention
new_key = re.sub(r'attentions_(\d+)_', r'1_', new_key)
# Step 5b: Convert resnets_N_ → sub-block index N_ (ComfyUI SD1.5)
# ComfyUI SD1.5 places resnet at sub-block index 0 for N=0.
# Keeping the original index N works for N=0 (most common case).
# For N>0 (second resnet at sub-block .2), this is approximately correct
# since LoRAs rarely target both resnet and attention in the same block.
new_key = re.sub(r'resnets_(\d+)_', r'\1_', new_key)
# Step 6: Convert remaining underscore numerics to dot numerics
# e.g., input_blocks_1_1_ → input_blocks.1.1.
new_key = re.sub(r'_(\d+)_', r'.\1.', new_key)
# Step 6b: Fix missing dots from adjacent underscore-numeric patterns
# After step 5 strips "attentions_N_" → "N_", we get patterns like
# "1_1_transformer" where the middle "1_" isn't caught by step 6
# because its leading underscore was consumed by the previous match.
# This converts "1_transformer" → "1.transformer".
# Also handles "1_resnets" → "1.resnets" for ResNet submodules.
new_key = re.sub(r'(\d+)_([a-zA-Z])', r'\1.\2', new_key)
# Step 7: Convert attention projection names (underscore → dot)
# attn1_to_q → attn1.to.q, attn2_to_out_0 → attn2.to.out.0
new_key = re.sub(r'attn(\d)_to_q\b', r'attn\1.to_q', new_key)
new_key = re.sub(r'attn(\d)_to_k\b', r'attn\1.to_k', new_key)
new_key = re.sub(r'attn(\d)_to_v\b', r'attn\1.to_v', new_key)
new_key = re.sub(r'attn(\d)_to_out_(\d+)', r'attn\1.to_out.\2', new_key)
new_key = re.sub(r'attn(\d)_to_out\b', r'attn\1.to_out', new_key)
# Step 7b: Catch any remaining to_out_N (underscore) → to_out.N (dot)
# The SD1.5 Diffusers format produces keys like "attn2_to_out.0" where
# the dot is already present after "to_out", so Step 7's
# attn(\d)_to_out_(\d+) regex doesn't match (no underscore after "to_out").
# This cleanup catches any lingering to_out_N patterns from edge cases.
new_key = re.sub(r'to_out_(\d+)', r'to_out.\1', new_key)
# Step 7c: Convert ff_net → ff.net (ComfyUI uses dots for hierarchy)
# In ComfyUI SD1.5, the feed-forward network module is named "ff.net"
# (with a dot), not "ff_net" (with underscore). The Diffusers format
# serializes this as "ff_net" which Step 6's _(\d+)_ regex can't convert
# because it only targets numeric underscores. Without this fix, keys
# like "ff_net.0.proj" fail to match the checkpoint's "ff.net.0.proj"
# in the reverse key map (Strategy 0), falling through to Energy Redirect.
# Reference: ComfyUI SD1.5 UNet — transformer_blocks.N.ff.net.0.proj
#
# 🔥 SUB-FIX: Handle ff_net_N (anime Diffusers second FF network)
# Some anime Diffusers models have a second FF network (ff_net_2) per
# transformer block. The key looks like ff_net_2.lora_down.weight — note
# the DOT after the numeral (not underscore), so Step 6's _(\d+)_ regex
# cannot convert it (requires trailing underscore). The boundary \b
# between 't' (word) and '2' (word) in ff_net_2 also prevents
# \bff_net\b from matching. This regex converts ff_net_N → ff.net.N.
new_key = re.sub(r'\bff_net_(\d+)\b', r'ff.net.\1', new_key)
# Secondary: handles ff_net (standard, followed by non-word like . or _)
new_key = re.sub(r'\bff_net\b', r'ff.net', new_key)
# Step 8: Convert lora_down/lora_up → lora_A/lora_B
new_key = (new_key
.replace("lora_down.weight", "lora_A.weight")
.replace("lora_up.weight", "lora_B.weight"))
# Step 9: Final cleanup — remove double dots
while ".." in new_key:
new_key = new_key.replace("..", ".")
# Safety: remove ._.
new_key = new_key.replace("._.", ".")
return new_key
def convert_sd15_diffusers_to_comfyui(state_dict):
"""
Convert SD1.5 Diffusers-format LoRA to ComfyUI-native format.
Diffusers SD1.5 keys use down_blocks/up_blocks/mid_block naming
(from HuggingFace diffusers library). ComfyUI uses input_blocks/
output_blocks/middle_block naming.
⚠️ KEY COLLISION HANDLING (CRITICAL):
SD1.5 Diffusers blocks can have MULTIPLE attention sub-modules
(attentions_0, attentions_1, etc.) that all map to the same ComfyUI
sub-block index .1. When two (or more) Diffusers keys map to the same
ComfyUI key, their tensors are SUMMED instead of dropped. This preserves
the total LoRA signal from all attention modules.
Previously, safe_insert() kept only the first occurrence and silently
dropped the second, causing ~41% key loss and weak baking.
Block index mapping (SD1.5 specific):
down_blocks.X → input_blocks.(X+1) (input_blocks.0 = conv_in)
up_blocks.X → output_blocks.X (1:1)
mid_block → middle_block (1:1)
Attention submodule mapping (same for all blocks that have attention):
down_blocks.X.attentions.N.transformer_blocks.M.*
→ input_blocks.(X+1).1.transformer_blocks.M.*
mid_block.attentions.N.transformer_blocks.M.*
→ middle_block.1.transformer_blocks.M.*
up_blocks.X.attentions.N.transformer_blocks.M.*
→ output_blocks.X.1.transformer_blocks.M.*
ResNet submodule mapping:
down_blocks.X.resnets.N.* → input_blocks.(X+1).0.*
up_blocks.X.resnets.N.* → output_blocks.X.0.*
"""
print(" 🔄 Converting SD1.5 Diffusers format to ComfyUI-native...")
# Phase 1: Convert all keys and group by output key
# Groups collect tensors that map to the same ComfyUI key (collisions).
groups: Dict[str, List[torch.Tensor]] = {}
for key, value in state_dict.items():
new_key = _convert_sd15_diffusers_key(key)
if new_key not in groups:
groups[new_key] = []
groups[new_key].append(value)
# Phase 2: Sum colliding groups, preserve unique entries
converted = {}
collision_count = 0
for new_key, tensors in groups.items():
if len(tensors) > 1:
# Sum colliding tensors — multiple Diffusers attention modules
# map to the same ComfyUI sub-block. Summing preserves the total
# LoRA signal.
converted[new_key] = sum(tensors[1:], tensors[0])
collision_count += len(tensors) - 1
else:
converted[new_key] = tensors[0]
total_original = len(state_dict)
total_converted = len(converted)
print(f" ✅ Converted {total_converted} keys to ComfyUI SD1.5 format "
f"({collision_count} collisions resolved via summation, "
f"{total_original - total_converted - collision_count} duplicates dropped)")
return converted
def convert_musubi_to_anima(state_dict):
"""Convert Musubi Anima format to standard.
Anima model uses MiniTrainDIT internally (self.blocks = nn.ModuleList).
In ComfyUI, BaseModel wraps the unet under self.diffusion_model, so
runtime state_dict keys are diffusion_model.blocks.0... NOT net.blocks.*.
The "net." prefix only exists in the trainer checkpoint file, not in the
model's internal module structure.
model_lora_keys_unet() builds key_map from diffusion_model.* keys, so
the LoRA keys must match: diffusion_model.blocks.0.adaln_modulation...
(without "net." prefix).
Mapping guide (trainer_key → model_module_name):
cross_attn_output_proj → cross_attn.output_proj (Attention.output_proj)
self_attn_output_proj → self_attn.output_proj (Attention.output_proj)
mlp_layer1 → mlp.layer1 (GPT2FeedForward.layer1)
mlp_layer2 → mlp.layer2 (GPT2FeedForward.layer2)
cross_attn_q_proj → cross_attn.q_proj (Attention.q_proj)
cross_attn_k_proj → cross_attn.k_proj (Attention.k_proj)
cross_attn_v_proj → cross_attn.v_proj (Attention.v_proj)
Input: lora_unet_blocks_0_adaln_modulation_cross_attn_1.lora_down.weight
Output: diffusion_model.blocks.0.adaln_modulation_cross_attn.1.lora_A.weight
"""
print("🔄 Converting Anima format...")
converted = {}
for key, value in state_dict.items():
new_key = key
# 0. Handle TE keys (Anima format: lora_te_layers_N_self_attn.X_proj.lora_A.weight)
# ComfyUI's model_lora_keys_clip() builds key_map from model CLIP state_dict keys.
# For SD1.5, the CLIP state dict has keys like:
# transformer.text_model.encoder.layers.N.self_attn.q_proj.weight
# model_lora_keys_clip() (line 101) creates:
# key_map["text_encoders.transformer.text_model.encoder.layers.N.self_attn.q_proj"]
# → "transformer.text_model.encoder.layers.N.self_attn.q_proj.weight"
# load_lora() then looks for "{key_map_key}.lora_B.weight" in the LoRA dict,
# so we must produce:
# text_encoders.transformer.text_model.encoder.layers.N.self_attn.X_proj.lora_A.weight
#
# The musubi format lora_te_layers_N_ is shorthand for the model's actual
# transformer.text_model.encoder.layers.N. structure, so we must produce:
# transformer.text_model.encoder.layers.N.self_attn.X_proj (NOT te.layers.N.)
if key.startswith("lora_te_"):
new_key = key
# a. Convert prefix: lora_te_layers → transformer.model.layers
# Anima TE uses Qwen3 (Llama2_ architecture), whose state dict keys are:
# qwen3_06b.transformer.model.layers.N.self_attn.q_proj.weight
# (via SD1ClipModel → SDClipModel → Qwen3_06BModel → Qwen3_06B → Llama2_)
# NOT SD1.5 CLIP which uses transformer.text_model.encoder.layers.N.*
# model_lora_keys_clip() strips the qwen3_06b. wrapper via .transformer. detection,
# producing: text_encoders.transformer.model.layers.N.self_attn.q_proj
new_key = new_key.replace("lora_te_layers", "transformer.model.layers", 1)
# b. Convert layers_N_ → layers.N. (now inside transformer.text_model.encoder.layers.N.)
new_key = re.sub(r'layers_(\d+)_', r'layers.\1.', new_key)
# c. Convert remaining underscore-to-dot for known submodule names
# o_proj is the short form of out_proj (self_attn_o_proj → self_attn.out_proj)
new_key = re.sub(
r'_(q_proj|k_proj|v_proj|output_proj|out_proj|o_proj|gate_proj|up_proj|down_proj|c_fc|c_proj)',
r'.\1',
new_key,
)
# d. Convert lora naming (master format: lora_A/lora_B)
new_key = (new_key
.replace("lora_down.weight", "lora_A.weight")
.replace("lora_up.weight", "lora_B.weight"))
# e. Final cleanup: remove double dots
while ".." in new_key:
new_key = new_key.replace("..", ".")
debug_key_change(key, new_key)
safe_insert(converted, new_key, value, key)
continue # Skip UNet handling for TE keys
# 1. Change prefix: lora_unet_ → diffusion_model.
# ComfyUI's model_lora_keys_unet() iterates model state_dict keys
# starting with "diffusion_model." and builds key_map entries.
# The Anima model (MiniTrainDIT) creates self.blocks internally,
# so the model's state_dict keys are:
# diffusion_model.blocks.0.adaln_modulation_cross_attn.1.weight
# NOT diffusion_model.net.blocks.* — "net." is only in the trainer
# checkpoint format, not in the model's internal module structure.
# load_lora() then looks for "{key}.lora_A.weight" matching our output.
new_key = new_key.replace("lora_unet_", "diffusion_model.", 1)
# 2. Convert blocks_N_ → blocks.N.
new_key = ANIMA_BLOCK_PATTERN.sub(r'blocks.\1.', new_key)
# 3. Convert remaining index patterns (_N. → .N. and .N_ → .N.)
new_key = re.sub(r'_(\d+)\.', r'.\1.', new_key)
new_key = re.sub(r'\.(\d+)_', r'.\1.', new_key)
# =====================================================================
# 🔥 FIX: Handle llm_adapter and final_layer naming (Anima-specific)
#
# The main DIT blocks (lora_unet_blocks_0_...) are handled by step 2
# (ANIMA_BLOCK_PATTERN). But Anima also has:
#
# 1. LLMAdapter (llm_adapter.blocks.N.*) — a separate ModuleList of
# TransformerBlocks for T5 cross-attention injection.
# Trainer format: lora_unet_llm_adapter_blocks_0_cross_attn_q_proj
# Model format: diffusion_model.llm_adapter.blocks.0.cross_attn.q_proj
#
# After step 2, llm_adapter_blocks_N_ becomes llm_adapter_blocks.N.
# (ANIMA_BLOCK_PATTERN matches blocks_0_ and converts to blocks.0.)
# We then need llm_adapter_blocks → llm_adapter.blocks
#
# 2. FinalLayer (net.final_layer.X) — single output projection + adaLN.
# Trainer format: lora_unet_final_layer_linear
# Model format: diffusion_model.final_layer.linear
#
# After step 1: diffusion_model.final_layer_linear
# Needs: diffusion_model.final_layer.linear
# =====================================================================
# 3a) llm_adapter_blocks → llm_adapter.blocks
new_key = new_key.replace('llm_adapter_blocks', 'llm_adapter.blocks')
# 3b) final_layer_N → final_layer.N (for numbered sub-parts like adaln_modulation.1)
new_key = re.sub(r'final_layer_(\d+)', r'final_layer.\1', new_key)
# 3c) final_layer_ → final_layer. (for non-digit suffixes like linear)
new_key = new_key.replace('final_layer_', 'final_layer.')
# 4. Map trainer-specific naming to model's actual module names.
# CRITICAL: Names must match predict2.py's module definitions:
# GPT2FeedForward uses layer1/layer2 (NOT c_fc/c_proj)
# Attention uses output_proj (NOT out_proj)
new_key = (new_key
.replace("mlp_layer1", "mlp.layer1")
.replace("mlp_layer2", "mlp.layer2"))
# 5. Convert structural underscores to dots for known submodule names.
# The trainer flattens ALL hierarchy separators to underscores, so
# 'self_attn.q_proj' becomes 'self_attn_q_proj' in trainer format.
# The _N. regex above only handles digit-indexed parts; for named
# submodules we need to convert the preceding underscore to a dot.
# NOTE: output_proj must appear BEFORE out_proj in the alternation
# because out_proj is a substring of output_proj.
new_key = re.sub(
r'_(q_proj|k_proj|v_proj|output_proj|out_proj|gate_proj|up_proj|down_proj|c_fc|c_proj)',
r'.\1',
new_key,
)
# 6. Convert lora naming (master format: lora_A/lora_B)
new_key = (new_key
.replace("lora_down.weight", "lora_A.weight")
.replace("lora_up.weight", "lora_B.weight"))
# 7. Final cleanup: remove double dots
while ".." in new_key:
new_key = new_key.replace("..", ".")
debug_key_change(key, new_key)
safe_insert(converted, new_key, value, key)
print(f"✅ Converted {len(converted)} Anima keys")
return converted
def convert_musubi_zimage_to_standard(state_dict):
"""Convert Musubi Z-Image format to standard with strict dot-notation."""
print("🔄 Converting Musubi Z-Image format...")
converted = {}
for key, value in state_dict.items():
# 1. Primary prefix change
new_key = key.replace("lora_unet_", "diffusion_model.")
# Repair hybrid separators (e.g., _0. -> .0.)
new_key = repair_hybrid_separators(new_key)
# 2. Convert underscores in layers and blocks
new_key = LAYER_PATTERN.sub(r'layers.\1.', new_key)
new_key = TO_OUT_PATTERN.sub(r'to_out.\1', new_key)
new_key = FEED_FORWARD_PATTERN.sub(r'feed_forward.w\1', new_key)
# 3. FIX: Specific Z-Image underscore-to-dot mapping
# This fixes the "attention_to_out" vs "attention.to_out" mismatch
new_key = new_key.replace("attention_to_", "attention.to_")
# 4. Standard LoRA naming conversion
new_key = (new_key
.replace("lora_down.weight", "lora_A.weight")
.replace("lora_up.weight", "lora_B.weight"))
new_key = fast_cleanup_key(new_key)
debug_key_change(key, new_key)
safe_insert(converted, new_key, value, key)
print(f"✅ Converted {len(converted)} Z-Image keys")
return converted
# ==================== NORMALIZERS ====================
def normalize_z_image_context_refiner(state_dict):
"""Convert Z-Image context_refiner format to standard."""
normalized = {}
for key, value in state_dict.items():
new_key = (key
.replace("context_refiner.", "diffusion_model.layers.")
.replace(".default", ""))
normalized[new_key] = value
return normalized
def normalize_sdxl_kohya_with_te(state_dict, include_te=True):
"""Convert SDXL Kohya format with optional TE normalization."""
normalized = {}
for key, value in state_dict.items():
new_key = key
# Handle TE keys
if include_te and new_key.startswith('lora_te'):
# SDXL TE format: lora_te1_text_model_encoder_layers_0_mlp_fc1.lora_down.weight
# Remove lora_ prefix
# ⚠️ ORDER MATTERS: lora_te2_ must be replaced BEFORE lora_te_ because
# 'lora_te_' is a SUBSTRING of 'lora_te2_'. If lora_te_ runs first,
# 'lora_te2_text_model...' becomes 'te.2_text_model...' (corrupted!).
new_key = new_key.replace('lora_te2_', 'te2.')
new_key = new_key.replace('lora_te1_', 'te1.')
new_key = new_key.replace('lora_te_', 'te.')
# Convert underscores to dots for TE structure
# text_model_encoder_layers_0_mlp_fc1 → text_model.encoder.layers.0.mlp.fc1
new_key = re.sub(r'text_model_encoder_layers_(\d+)_', r'text_model.encoder.layers.\1.', new_key)
new_key = re.sub(r'mlp_fc(\d)', r'mlp.fc\1', new_key)
new_key = re.sub(r'self_attn_', r'self_attn.', new_key)
new_key = re.sub(r'(k|q|v|out)_proj', r'\1_proj', new_key) # k_proj, q_proj etc.
# Convert lora_down/lora_up to lora_A/lora_B for consistency
new_key = new_key.replace('lora_down.', 'lora_A.')
new_key = new_key.replace('lora_up.', 'lora_B.')
# Handle UNet keys
elif new_key.startswith('lora_unet_'):
# Remove lora_unet_ prefix if present
new_key = new_key.replace('lora_unet_', '', 1)
# Convert underscores to dots in block patterns
patterns = [
(r'input_blocks_(\d+)_(\d+)_', r'input_blocks.\1.\2.'),
(r'middle_block_(\d+)_', r'middle_block.\1.'),
(r'output_blocks_(\d+)_(\d+)_', r'output_blocks.\1.\2.'),
(r'time_embed_', r'time_embed.'),
]
for pattern, replacement in patterns:
new_key = re.sub(pattern, replacement, new_key)
# Handle transformer blocks
if 'transformer_blocks' in new_key:
new_key = re.sub(r'transformer_blocks_(\d+)_', r'transformer_blocks.\1.', new_key)
# Convert lora_down/lora_up to lora_A/lora_B for consistency
# Use exact string replacement to catch all variations
new_key = new_key.replace('lora_down', 'lora_A')
new_key = new_key.replace('lora_up', 'lora_B')
# Ensure proper suffix
if not new_key.endswith(".weight") and ".alpha" not in new_key and "bias" not in new_key:
if any(x in new_key for x in ['lora_down', 'lora_up', 'lora_A', 'lora_B']):
new_key += ".weight"
normalized[new_key] = value
return normalized
def normalize_sdxl_diffusers(state_dict):
"""Convert SDXL diffusers format to standard."""
normalized = {}
for key, value in state_dict.items():
new_key = key
# Convert lora.down → lora_A, lora.up → lora_B
new_key = new_key.replace("lora.down", "lora_A").replace("lora.up", "lora_B")
# Convert to standard ComfyUI naming
if "to_k" in new_key or "to_v" in new_key or "to_q" in new_key or "to_out" in new_key:
new_key = new_key.replace("processor.", "").replace("to_out.0", "to_out")
# Add .weight suffix if missing
if not new_key.endswith(".weight") and ".alpha" not in new_key:
new_key += ".weight"
normalized[new_key] = value
return normalized
def normalize_z_image_ai_toolkit(state_dict):
"""Convert Z-Image AI-Toolkit format to standard."""
normalized = {}
for key, value in state_dict.items():
new_key = key
# Already uses lora_A/lora_B format
if not new_key.endswith(".weight") and ".alpha" not in new_key:
new_key += ".weight"
normalized[new_key] = value
return normalized
def normalize_z_image_default(state_dict):
"""Convert Z-Image .default.weight format to standard."""
normalized = {}
for key, value in state_dict.items():
new_key = key
# Remove .default from keys
new_key = (new_key
.replace(".lora_A.default", ".lora_A")
.replace(".lora_B.default", ".lora_B"))
# Ensure .weight suffix
if not new_key.endswith(".weight") and ".alpha" not in new_key:
new_key += ".weight"
normalized[new_key] = value
return normalized
def normalize_all_klein_formats(state_dict):
"""Standardizes Musubi and AI-Toolkit names into one format."""
normalized = {}
for key, value in state_dict.items():
new_key = key
new_key = repair_hybrid_separators(new_key)
# Klein format handling
new_key = re.sub(r'(img|txt)_attn_(proj|qkv)', r'\1_attn.\2', new_key)
new_key = re.sub(r'(img|txt)_mlp_', r'\1_mlp.', new_key)
if "single_blocks" in new_key:
new_key = re.sub(r'single_blocks_(\d+)_attn_(proj|qkv)', r'single_blocks.\1.attn.\2', new_key)
new_key = re.sub(r'single_blocks_(\d+)_mlp_', r'single_blocks.\1.mlp.', new_key)
if "lora_unet_" in new_key:
new_key = new_key.replace("lora_unet_", "diffusion_model.")
# Convert lora_down/lora_up to lora_A/lora_B
new_key = new_key.replace("lora_down", "lora_A").replace("lora_up", "lora_B")
new_key = new_key.replace("attn_proj", "attn.proj").replace("attn_qkv", "attn.qkv")
# Remove double dots
while ".." in new_key:
new_key = new_key.replace("..", ".")
# Add .weight suffix
if ("lora_A" in new_key or "lora_B" in new_key) and "weight" not in new_key and "alpha" not in new_key:
new_key += ".weight"
normalized[new_key] = value
return normalized
def normalize_key_for_flux(key: str) -> str:
"""Normalize Flux model key to standard format."""
normalized = key
patterns = [
(r'img_attn_proj', 'img_attn.proj'),
(r'img_attn_qkv', 'img_attn.qkv'),
(r'txt_attn_proj', 'txt_attn.proj'),
(r'txt_attn_qkv', 'txt_attn.qkv'),
(r'mlp_fc1', 'mlp.fc1'),
(r'mlp_fc2', 'mlp.fc2'),
]
for pattern, replacement in patterns:
normalized = re.sub(pattern, replacement, normalized)
# Remove double dots
while '..' in normalized:
normalized = normalized.replace('..', '.')
return normalized
# ==================== UNIVERSAL NORMALIZER ====================
def _compute_key_fingerprint(state_dict):
"""Compute a hashable fingerprint from sorted state dict keys for cache lookup."""
return tuple(sorted(state_dict.keys()))
def universal_normalize(state_dict, metadata=None, mapping_ref=None):
"""Universal normalizer with format preservation and metadata awareness."""
# Inject metadata into the object so get_alpha_value can find it
if metadata is not None:
# We use setattr because some state_dicts are custom objects
try:
state_dict.metadata = metadata
except:
# If it's a plain dict, we just attach it as a hidden property
pass
# B2: Skip format re-detection if already stored in mapping_ref
if mapping_ref is not None and "__format__" in mapping_ref:
format_type = mapping_ref["__format__"]
print(f"Detected format (from mapping_ref): {format_type}")
else:
format_type = detect_lora_format(state_dict, mapping_ref)
print(f"Detected format: {format_type}")
# Store detected format in mapping_ref for later reuse (B2)
if mapping_ref is not None:
mapping_ref["__format__"] = format_type
# Set global mapping reference if provided
global _current_mapping_ref
previous_mapping = _current_mapping_ref
if mapping_ref is not None:
_current_mapping_ref = mapping_ref
else:
_current_mapping_ref = None
# 1. Run the Auto-Repair/Acceleration pipeline FIRST
# This ensures Alphas are calculated before keys get renamed
state_dict = accelerate_lora_load(state_dict)
# B1: Check conversion cache — if we've already converted a LoRA with the
# same key structure (same format + same set of keys), reuse the key mapping
# to avoid redundant string processing and tensor iteration.
fingerprint = _compute_key_fingerprint(state_dict)
cache_key = (format_type, fingerprint, NORMALIZE_CACHE_VERSION)
if cache_key in _NORMALIZE_CACHE:
cached_mapping = _NORMALIZE_CACHE[cache_key]
print(f" [Cache] Reusing cached key mapping for format '{format_type}' ({len(cached_mapping)} keys)")
result = {}
for new_key, old_key in cached_mapping.items():
result[new_key] = state_dict[old_key]
return result
# Bypass standard SD formats
bypass_formats = ["pony", "illustrious", "sd15", "sd1.5", "sdxl_with_te", "illustrious_sdxl", "sdxl_kohya", "sdxl_standard", "sdxl_diffusers"]
bypass_match = any(fmt in format_type.lower() for fmt in bypass_formats)
# Special handling: SDXL formats should still be normalized (remove from bypass)
if "sdxl" in format_type.lower():
bypass_match = False
# CRITICAL: "needs_conversion" formats MUST NEVER be bypassed — they require
# active transformation to produce ComfyUI-native key format. A format like
# "sd15_diffusers_needs_conversion" would otherwise match "sd15" in the
# bypass list (substring match) and skip conversion entirely.
if "needs_conversion" in format_type.lower():
bypass_match = False
if bypass_match:
print(f"OK: Keeping {format_type} format as-is (Standard SD format)")
return state_dict
# Auto-convert Anima format (must be checked before Musubi catch-all)
if format_type == "anima_needs_conversion":
print("Converting Anima format...")
result = convert_musubi_to_anima(state_dict)
# Cache the key mapping (new_key → old_key): post-conversion → pre-conversion
_NORMALIZE_CACHE[cache_key] = {new: old for new, old in zip(result.keys(), state_dict.keys())}
return result
# Auto-convert SD1.5 Diffusers format (must be checked before Musubi catch-all)
if format_type == "sd15_diffusers_needs_conversion":
print("Converting SD1.5 Diffusers to ComfyUI format...")
result = convert_sd15_diffusers_to_comfyui(state_dict)
# Cache the key mapping (new_key → old_key): post-conversion → pre-conversion
_NORMALIZE_CACHE[cache_key] = {new: old for new, old in zip(result.keys(), state_dict.keys())}
return result
# Auto-convert Musubi formats
if "musubi" in format_type.lower() and "needs_conversion" in format_type:
print("Converting Musubi to standard format...")
if "zimage" in format_type:
result = convert_musubi_zimage_to_standard(state_dict)
elif "flux" in format_type or "klein" in format_type:
result = convert_musubi_to_standard(state_dict)
elif "te" in format_type:
result = convert_musubi_te_to_standard(state_dict)
else:
result = convert_musubi_to_standard(state_dict)
# Cache the key mapping (new_key → old_key): post-conversion → pre-conversion
_NORMALIZE_CACHE[cache_key] = {new: old for new, old in zip(result.keys(), state_dict.keys())}
return result
# Already converted Musubi
if "musubi" in format_type.lower() and "converted" in format_type:
print(" Musubi already converted, using as-is")
return state_dict
# Keep Pony as-is
if "pony" in format_type.lower():
print(" Keeping Pony Diffusion format as-is")
return state_dict
# Apply format-specific normalization
normalizers = {
"sdxl_kohya": lambda sd: normalize_sdxl_kohya_with_te(sd, include_te=False),
"sdxl_standard": lambda sd: normalize_sdxl_kohya_with_te(sd, include_te=False),
"sdxl_with_te_lora_down_up": lambda sd: normalize_sdxl_kohya_with_te(sd, include_te=True),
"sdxl_with_te_lora_a_b": lambda sd: normalize_sdxl_kohya_with_te(sd, include_te=True),
"illustrious_sdxl": lambda sd: normalize_sdxl_kohya_with_te(sd, include_te=False),
"sdxl_diffusers": normalize_sdxl_diffusers,
"z_image_ai_toolkit": normalize_z_image_ai_toolkit,
"z_image_default": normalize_z_image_default,
"z_image_context_refiner": normalize_z_image_context_refiner,
"z_image_lora_down_up": normalize_all_klein_formats,
"z_image_lora_a_b": normalize_all_klein_formats,
"flux_klein_9b": normalize_all_klein_formats,
"flux_klein_4b": normalize_all_klein_formats,
"flux_klein_lora_a_b": normalize_all_klein_formats,
"flux_klein_lora_down_up": normalize_all_klein_formats,
"ai_toolkit_klein": normalize_all_klein_formats,
}
if format_type in normalizers:
normalized = normalizers[format_type](state_dict)
# Apply Flux key normalization if needed
if "flux" in format_type or "klein" in format_type:
final = {}
for key, value in normalized.items():
new_key = normalize_key_for_flux(key)
final[new_key] = value
# Also cache the key mapping for normalizers
_NORMALIZE_CACHE[cache_key] = {new: old for new, old in zip(final.keys(), normalized.keys())}
return final
return normalized
# Unknown format - try Klein normalization
print(f"WARNING: Unknown format, trying Klein normalization")
try:
normalized = normalize_all_klein_formats(state_dict)
final = {}
for key, value in normalized.items():
new_key = normalize_key_for_flux(key)
final[new_key] = value
return final
except:
print(f"WARNING: Could not normalize, using raw format")
return state_dict
# ==================== FORMAT INFO ====================
_FORMAT_STYLE_MAP = {
# SDXL formats (underscore separator)
"sdxl_standard": ("underscore", "lora_down_up"),
"sdxl_kohya": ("underscore", "lora_down_up"),
"sdxl_with_te_lora_down_up": ("underscore", "lora_down_up"),
"sdxl_with_te_lora_a_b": ("underscore", "lora_a_b"),
"illustrious_sdxl": ("underscore", "lora_down_up"),
"sdxl_diffusers": ("underscore", "lora_down_up"),
# SD1.5 Kohya
"sd15_kohya": ("underscore", "lora_a_b"),
# Z-Image formats (dot separator)
"z_image_lora_a_b": ("dot", "lora_a_b"),
"z_image_lora_down_up": ("dot", "lora_down_up"),
"z_image_ai_toolkit": ("dot", "lora_a_b"),
"z_image_default": ("dot", "lora_a_b"),
"z_image_context_refiner": ("dot", "lora_a_b"),
"z_image_other": ("dot", "lora_a_b"),
# Flux Klein formats (dot separator)
"flux_klein_lora_a_b": ("dot", "lora_a_b"),
"flux_klein_lora_down_up": ("dot", "lora_down_up"),
"flux_klein_9b": ("dot", "lora_a_b"),
"flux_klein_4b": ("dot", "lora_a_b"),
"ai_toolkit_klein": ("dot", "lora_a_b"),
# Musubi formats (dot after conversion)
"musubi_zimage_needs_conversion": ("dot", "lora_a_b"),
"musubi_zimage_te_needs_conversion": ("dot", "lora_a_b"),
"musubi_flux_needs_conversion": ("dot", "lora_a_b"),
"musubi_te_needs_conversion": ("dot", "lora_a_b"),
"musubi_partially_converted": ("dot", "lora_a_b"),
"musubi_other_needs_conversion": ("dot", "lora_a_b"),
# Anima format (dot separator, lora_down_up naming to match model keys)
"anima_needs_conversion": ("dot", "lora_down_up"),
# Pony Diffusion (underscore)
"pony_diffusion_lora_down_up": ("underscore", "lora_down_up"),
"pony_diffusion_lora_a_b": ("underscore", "lora_a_b"),
# LyCORIS (unknown, default underscore)
"lycoris": ("underscore", "lora_down_up"),
# Unknown fallback
"standard_webui": ("underscore", "lora_down_up"),
"comfy_native": ("dot", "lora_a_b"),
"forge_optimized": ("underscore", "lora_down_up"),
"unknown": ("underscore", "lora_down_up"),
}
def get_format_style(format_str: str) -> tuple[str, str]:
"""
Return (separator_style, naming_style) for a given format string.
separator_style: "dot" or "underscore"
naming_style: "lora_a_b" or "lora_down_up"
"""
return _FORMAT_STYLE_MAP.get(format_str, ("underscore", "lora_down_up"))