Files

1125 lines
45 KiB
Python

"""
Model Layer Analyzer + Editor for ComfyUI
Analyzes and allows per-block control of diffusion model layers.
Similar to LoRA V2 analyzer but for base models.
Supports: SDXL, SD 1.5, FLUX, Z-Image, Wan, Qwen architectures.
"""
import os
import re
import json
from datetime import datetime
from collections import defaultdict
import torch
import folder_paths
import comfy.sd
import comfy.model_patcher
from safetensors.torch import save_file
from safetensors import safe_open
# ============================================================================
# ORIGINAL WEIGHTS CACHE
# ============================================================================
# Cache original weights on first access to prevent reading patched values
# Key: id(model), Value: state_dict with cloned tensors
_original_weights_cache = {}
def _get_original_weights(model_patcher):
"""
Get the original, unmodified weights for a model.
Caches on first access because:
1. The underlying model is shared between all model_patcher clones
2. state_dict() returns current values which may be patched
3. weight_backup is only available AFTER patch_model() during inference
Returns (state_dict, key_prefix) where state_dict contains original weights.
"""
global _original_weights_cache
model = model_patcher.model
if hasattr(model, 'diffusion_model'):
diff_model = model.diffusion_model
key_prefix = "diffusion_model."
else:
diff_model = model
key_prefix = ""
model_id = id(model)
if model_id not in _original_weights_cache:
# First access - cache the current state (should be clean on first load)
# Store on CPU to avoid GPU memory bloat
_original_weights_cache[model_id] = {
k: v.detach().clone().cpu() for k, v in diff_model.state_dict().items()
}
return _original_weights_cache[model_id], key_prefix
def clear_model_cache(model_id=None):
"""Clear cached weights. Call when model is reloaded."""
global _original_weights_cache
if model_id is None:
_original_weights_cache.clear()
print("[Model Layer Editor] Cleared all weight caches")
elif model_id in _original_weights_cache:
del _original_weights_cache[model_id]
print(f"[Model Layer Editor] Cleared cache for model {model_id}")
# ============================================================================
# USER PRESETS
# ============================================================================
# Save/load user-defined presets per node type
# Preset system: localStorage for instant UI updates, Python config file for persistence across sessions
def _get_user_presets_path(node_id: str) -> str:
"""Get path to user presets file for a node type."""
return os.path.join(os.path.dirname(__file__), f".model_layer_presets_{node_id}.json")
def _load_user_presets(node_id: str) -> dict:
"""Load user presets for a node type from config file."""
path = _get_user_presets_path(node_id)
if os.path.exists(path):
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, IOError, OSError) as e:
print(f"[Model Layer Editor] Error loading presets: {e}")
return {}
def _save_user_presets(node_id: str, presets: dict):
"""Save user presets for a node type to config file."""
path = _get_user_presets_path(node_id)
try:
with open(path, 'w', encoding='utf-8') as f:
json.dump(presets, f, indent=2)
print(f"[Model Layer Editor] Saved presets to {path}")
except (IOError, OSError) as e:
print(f"[Model Layer Editor] Error saving presets: {e}")
def _save_current_as_preset(node_id: str, preset_name: str, blocks: list,
enabled_blocks: set, block_strengths: dict):
"""Save current block settings as a named preset."""
if not preset_name or not preset_name.strip():
return False
preset_name = preset_name.strip()
# Build preset data
preset_data = {
"enabled": list(enabled_blocks),
"strength": 1.0, # Base strength (individual values in overrides)
"overrides": {}
}
# Check if all blocks are enabled
if set(blocks) == enabled_blocks:
preset_data["enabled"] = "ALL"
# Add strength overrides for non-1.0 values
for block in blocks:
strength = block_strengths.get(block, 1.0)
if strength != 1.0:
preset_data["overrides"][block] = strength
# Load existing presets, add/update, save
presets = _load_user_presets(node_id)
presets[preset_name] = preset_data
_save_user_presets(node_id, presets)
print(f"[Model Layer Editor] Saved preset '{preset_name}' for {node_id}")
return True
def _delete_user_preset(node_id: str, preset_name: str) -> bool:
"""Delete a user preset by name."""
if not preset_name or not preset_name.strip():
return False
preset_name = preset_name.strip()
presets = _load_user_presets(node_id)
if preset_name in presets:
del presets[preset_name]
_save_user_presets(node_id, presets)
print(f"[Model Layer Editor] Deleted preset '{preset_name}' from {node_id}")
return True
else:
print(f"[Model Layer Editor] Preset '{preset_name}' not found in {node_id}")
return False
# Cache for loaded user presets (reloaded when saving)
_user_presets_cache = {}
def _get_all_presets(node_id: str, builtin_presets: dict) -> dict:
"""Get combined builtin + user presets for a node."""
global _user_presets_cache
# Load user presets (refresh from disk)
user_presets = _load_user_presets(node_id)
_user_presets_cache[node_id] = user_presets
# Combine: builtin first, then user presets
# User presets with same name as builtin will override
combined = dict(builtin_presets)
# Add user presets (they appear after builtins, can override)
for name, data in user_presets.items():
combined[name] = data
return combined
# ============================================================================
# ARCHITECTURE DETECTION
# ============================================================================
def _detect_model_architecture(model_patcher) -> tuple:
"""
Detect model architecture from the model structure.
Returns (architecture, confidence, block_info).
"""
try:
# Get the underlying model
model = model_patcher.model
# Try to get state dict keys
if hasattr(model, 'state_dict'):
state_dict = model.state_dict()
keys = list(state_dict.keys())
elif hasattr(model, 'diffusion_model') and hasattr(model.diffusion_model, 'state_dict'):
state_dict = model.diffusion_model.state_dict()
keys = list(state_dict.keys())
else:
return ('UNKNOWN', 'low', {})
num_keys = len(keys)
keys_str = ' '.join(keys[:100]).lower() # Sample for detection
# Z-Image detection (30 layers)
if any('layers.' in k and 'attention' in k.lower() for k in keys[:50]):
layer_nums = set()
for k in keys:
match = re.search(r'layers\.(\d+)', k)
if match:
layer_nums.add(int(match.group(1)))
if len(layer_nums) >= 25:
return ('ZIMAGE', 'high', {'layers': sorted(layer_nums)})
# FLUX detection (double + single blocks)
double_blocks = set()
single_blocks = set()
for k in keys:
match = re.search(r'double_blocks?\.(\d+)', k)
if match:
double_blocks.add(int(match.group(1)))
match = re.search(r'single_blocks?\.(\d+)', k)
if match:
single_blocks.add(int(match.group(1)))
if len(double_blocks) >= 15 or len(single_blocks) >= 30:
return ('FLUX', 'high', {'double_blocks': sorted(double_blocks), 'single_blocks': sorted(single_blocks)})
# Wan detection (blocks with self_attn, ffn)
if any('self_attn' in k and 'blocks.' in k for k in keys):
wan_blocks = set()
for k in keys:
match = re.search(r'blocks\.(\d+)', k)
if match:
wan_blocks.add(int(match.group(1)))
if len(wan_blocks) >= 30:
return ('WAN', 'high', {'blocks': sorted(wan_blocks)})
# Qwen detection (transformer_blocks with img_mlp)
if any('transformer_blocks' in k and 'img_mlp' in k for k in keys):
qwen_blocks = set()
for k in keys:
match = re.search(r'transformer_blocks\.(\d+)', k)
if match:
qwen_blocks.add(int(match.group(1)))
if len(qwen_blocks) >= 50:
return ('QWEN', 'high', {'blocks': sorted(qwen_blocks)})
# SDXL/SD15 detection (input_blocks, output_blocks)
input_blocks = set()
output_blocks = set()
has_mid = False
for k in keys:
if 'input_blocks.' in k or 'input_blocks_' in k:
match = re.search(r'input_blocks[._](\d+)', k)
if match:
input_blocks.add(int(match.group(1)))
if 'output_blocks.' in k or 'output_blocks_' in k:
match = re.search(r'output_blocks[._](\d+)', k)
if match:
output_blocks.add(int(match.group(1)))
if 'middle_block' in k or 'mid_block' in k:
has_mid = True
if len(input_blocks) >= 10 and len(output_blocks) >= 10:
# Distinguish SDXL from SD15 by parameter count
if num_keys > 1500:
return ('SDXL', 'high', {'input_blocks': sorted(input_blocks), 'output_blocks': sorted(output_blocks), 'has_mid': has_mid})
else:
return ('SD15', 'high', {'input_blocks': sorted(input_blocks), 'output_blocks': sorted(output_blocks), 'has_mid': has_mid})
return ('UNKNOWN', 'low', {})
except Exception as e:
print(f"[Model Analyzer] Detection error: {e}")
return ('UNKNOWN', 'low', {})
# ============================================================================
# BLOCK ANALYSIS
# ============================================================================
def _analyze_model_blocks(model_patcher, architecture: str) -> dict:
"""
Analyze model blocks and return stats per block.
Returns dict: {block_id: {param_count, memory_mb, dtype, keys}}
"""
try:
model = model_patcher.model
# Get state dict from the right place
if hasattr(model, 'diffusion_model'):
state_dict = model.diffusion_model.state_dict()
prefix = 'diffusion_model.'
else:
state_dict = model.state_dict()
prefix = ''
blocks = defaultdict(lambda: {
'param_count': 0,
'memory_bytes': 0,
'keys': [],
'dtype': None
})
for key, tensor in state_dict.items():
block_id = _extract_block_id(key, architecture)
blocks[block_id]['param_count'] += tensor.numel()
blocks[block_id]['memory_bytes'] += tensor.numel() * tensor.element_size()
blocks[block_id]['keys'].append(prefix + key)
if blocks[block_id]['dtype'] is None:
blocks[block_id]['dtype'] = str(tensor.dtype)
# Convert memory to MB
for block_id in blocks:
blocks[block_id]['memory_mb'] = blocks[block_id]['memory_bytes'] / (1024 * 1024)
return dict(blocks)
except Exception as e:
print(f"[Model Analyzer] Analysis error: {e}")
return {}
def _extract_block_id(key: str, architecture: str) -> str:
"""Extract block identifier from a model weight key."""
key_lower = key.lower()
if architecture == 'ZIMAGE':
match = re.search(r'layers\.(\d+)', key)
if match:
return f"layer_{match.group(1)}"
if 'final_layer' in key_lower:
return 'final_layer'
if 'x_embedder' in key_lower:
return 'x_embedder'
return 'other'
elif architecture == 'FLUX':
match = re.search(r'double_blocks?\.(\d+)', key)
if match:
return f"double_{match.group(1)}"
match = re.search(r'single_blocks?\.(\d+)', key)
if match:
return f"single_{match.group(1)}"
return 'other'
elif architecture == 'WAN':
match = re.search(r'blocks\.(\d+)', key)
if match:
return f"block_{match.group(1)}"
return 'other'
elif architecture == 'QWEN':
match = re.search(r'transformer_blocks\.(\d+)', key)
if match:
return f"block_{match.group(1)}"
return 'other'
elif architecture in ['SDXL', 'SD15']:
match = re.search(r'input_blocks?[._](\d+)', key)
if match:
return f"input_{match.group(1)}"
match = re.search(r'output_blocks?[._](\d+)', key)
if match:
return f"output_{match.group(1)}"
if 'middle_block' in key_lower or 'mid_block' in key_lower:
return 'mid'
if 'time_embed' in key_lower:
return 'time_embed'
if 'label_emb' in key_lower:
return 'label_emb'
return 'other'
return 'other'
# ============================================================================
# BLOCK SCALING (using ComfyUI's patching system)
# ============================================================================
def _apply_block_modifications(model_patcher, block_analysis: dict, enabled_blocks: set,
block_strengths: dict, architecture: str):
"""
Apply block modifications using ComfyUI's patching system.
CRITICAL: We clone the model_patcher and add patches to scale weights.
This is non-destructive - original model weights are never modified.
For scaling weight W by factor s:
- new_weight = W * s = W + W * (s - 1)
- We add a patch of W * (s - 1) with strength 1.0
Returns (cloned_model_patcher, modified_count).
"""
try:
# Get the ORIGINAL weights from cache (not current state_dict which may be patched)
base_state_dict, key_prefix = _get_original_weights(model_patcher)
# Clone the model patcher so we don't affect the original
cloned = model_patcher.clone()
# CRITICAL: Clear any inherited patches to prevent accumulation
if hasattr(cloned, 'patches'):
cloned.patches = {}
if hasattr(cloned, 'object_patches'):
cloned.object_patches = {}
# Build patches dict for add_patches
# Format: {key: (diff_tensor,)} where the diff is added to original
patches = {}
modified_count = 0
for block_id, block_info in block_analysis.items():
# Only modify blocks that are in our control list (block_strengths)
# Skip blocks we don't have controls for (like extra blocks found in model)
if block_id not in block_strengths:
continue
strength = block_strengths[block_id]
# Skip if no modification needed
if strength == 1.0:
continue
# Create patches for this block's weights
for full_key in block_info['keys']:
# Get the key without prefix for state_dict lookup
key = full_key.replace('diffusion_model.', '')
# Use base_state_dict for original unpatched weights
if key not in base_state_dict:
continue
weight = base_state_dict[key]
# Calculate the diff: to get W*s, we add W*(s-1) to W
# diff = W * (s - 1)
# IMPORTANT: Do this on CPU to avoid GPU OOM errors
# ComfyUI's patching system will handle device placement
weight_cpu = weight.to("cpu") if weight.device.type != "cpu" else weight
diff = weight_cpu * (strength - 1.0)
# Patch key needs the prefix
patch_key = key_prefix + key
# Format for add_patches: tuple of (diff_tensor,)
# This gets added to the original weight
patches[patch_key] = (diff,)
modified_count += 1
# Apply patches if any
if patches:
# add_patches(patches, strength_patch, strength_model=None)
# strength_patch=1.0 means apply the full diff
cloned.add_patches(patches, strength_patch=1.0)
return cloned, modified_count
except Exception as e:
import traceback
print(f"[Model Analyzer] Patching error: {e}")
traceback.print_exc()
return model_patcher.clone(), 0
# ============================================================================
# MODEL SAVING
# ============================================================================
def _save_modified_model(model_patcher, save_path: str, save_filename: str,
architecture: str, block_info: dict, source_model_path: str = "") -> str:
"""
Save modified model to safetensors.
Since we use patching, we need to temporarily apply patches to get
the full modified state_dict, then unpatch after saving.
Returns full path to saved file, or None if not saved.
"""
if not save_path or not save_path.strip():
return None
save_path = os.path.expanduser(save_path.strip())
# Ensure directory exists
if not os.path.exists(save_path):
try:
os.makedirs(save_path, exist_ok=True)
except Exception as e:
print(f"[Model Analyzer] Error creating directory: {e}")
return None
# Generate filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
if save_filename and save_filename.strip():
base_name = save_filename.strip()
if base_name.lower().endswith('.safetensors'):
base_name = base_name[:-12]
filename = f"{base_name}_{timestamp}.safetensors"
else:
filename = f"modified_model_{timestamp}.safetensors"
full_path = os.path.join(save_path, filename)
try:
# Try to get original model's metadata
original_metadata = {}
source_path = None
# Use provided source path first
if source_model_path and source_model_path.strip():
source_path = os.path.expanduser(source_model_path.strip())
# Fallback: Check if model_patcher has path info
if not source_path:
if hasattr(model_patcher, 'model_options'):
opts = model_patcher.model_options
if isinstance(opts, dict):
source_path = opts.get('model_path') or opts.get('path')
# Fallback: Check for model.model_path attribute
if not source_path and hasattr(model_patcher, 'model'):
if hasattr(model_patcher.model, 'model_path'):
source_path = model_patcher.model.model_path
# Try to read metadata from source file
if source_path and os.path.exists(source_path) and source_path.endswith('.safetensors'):
try:
with safe_open(source_path, framework="pt") as f:
original_metadata = dict(f.metadata()) if f.metadata() else {}
print(f"[Model Analyzer] Copied metadata from source: {os.path.basename(source_path)}")
except Exception as e:
print(f"[Model Analyzer] Could not read source metadata: {e}")
else:
print(f"[Model Analyzer] Warning: No source model specified - saved model may not load correctly")
# Load original file and apply modifications directly to preserve key format
if source_path and os.path.exists(source_path) and source_path.endswith('.safetensors'):
print(f"[Model Analyzer] Loading weights from source file...")
from safetensors.torch import load_file
state_dict = load_file(source_path)
# Get the patches from model_patcher
patches = model_patcher.patches.copy() if hasattr(model_patcher, 'patches') else {}
if len(patches) > 0:
import comfy.lora
# Build prefix mapping: patches use diffusion_model. prefix but file doesn't
# Try to detect the prefix used by patches
patch_keys = list(patches.keys())
file_keys = list(state_dict.keys())
# Common prefixes in patched models
possible_prefixes = ['diffusion_model.', 'model.diffusion_model.', 'model.', '']
detected_prefix = ''
for prefix in possible_prefixes:
# Check if removing this prefix from patch keys matches file keys
test_key = patch_keys[0]
if test_key.startswith(prefix):
stripped_key = test_key[len(prefix):]
if stripped_key in file_keys:
detected_prefix = prefix
print(f"[Model Analyzer] Detected patch key prefix: '{prefix}'")
break
# Apply patches using ComfyUI's calculate_weight function
modified_keys = 0
for patch_key, patch_list in patches.items():
# Strip the prefix to get the original file key
file_key = patch_key[len(detected_prefix):] if patch_key.startswith(detected_prefix) else patch_key
if file_key in state_dict:
original_weight = state_dict[file_key]
# Use ComfyUI's proper patch calculation
# calculate_weight expects weight to be on the same device as patches
modified_weight = comfy.lora.calculate_weight(
patch_list,
original_weight.clone(),
file_key,
intermediate_dtype=torch.float32
)
state_dict[file_key] = modified_weight.to(original_weight.dtype)
modified_keys += 1
print(f"[Model Analyzer] Applied patches to {modified_keys} keys")
else:
print(f"[Model Analyzer] No patches to apply")
else:
# Fallback: use model's state dict (may have different key format)
print("[Model Analyzer] Warning: No source file - using internal model keys (may not load correctly)")
model_patcher.patch_model()
model = model_patcher.model
if hasattr(model, 'state_dict'):
state_dict = {k: v.clone() for k, v in model.state_dict().items()}
else:
print("[Model Analyzer] Could not get model state_dict")
model_patcher.unpatch_model()
return None
model_patcher.unpatch_model()
# Start with original metadata, then add our modification info
metadata = original_metadata.copy()
metadata.update({
'modified_by': 'comfyui-zimage-realtime-lora ModelLayerEditor',
'modified_url': 'https://github.com/ShootTheSound/comfyUI-Realtime-Lora',
'modified_date': datetime.now().isoformat(),
'modified_architecture': architecture,
'block_modifications': json.dumps(block_info)
})
# Estimate file size
total_bytes = sum(t.numel() * t.element_size() for t in state_dict.values())
size_gb = total_bytes / (1024**3)
print(f"[Model Analyzer] Saving model ({size_gb:.2f} GB) to: {full_path}")
# Save
save_file(state_dict, full_path, metadata=metadata)
print(f"[Model Analyzer] Saved successfully: {full_path}")
return full_path
except Exception as e:
import traceback
print(f"[Model Analyzer] Error saving model: {e}")
traceback.print_exc()
# Make sure to unpatch if there was an error
try:
model_patcher.unpatch_model()
except:
pass
return None
# ============================================================================
# FORMATTING
# ============================================================================
def _format_analysis(block_analysis: dict, architecture: str,
enabled_blocks: set, block_strengths: dict) -> str:
"""Format block analysis as readable text."""
if not block_analysis:
return "No blocks found."
total_params = sum(b['param_count'] for b in block_analysis.values())
total_memory = sum(b['memory_mb'] for b in block_analysis.values())
lines = [
f"Model Layer Analysis ({architecture})",
"=" * 60,
f"Total Parameters: {total_params:,}",
f"Total Memory: {total_memory:.2f} MB ({total_memory/1024:.2f} GB)",
"",
f"{'Block':<20} {'Params':>12} {'Memory':>10} {'Strength':>10}",
"-" * 60
]
# Sort blocks logically
def block_sort_key(item):
block_id = item[0]
# Extract number from block_id
match = re.search(r'(\d+)', block_id)
num = int(match.group(1)) if match else 999
# Prefix for ordering
if block_id.startswith('input'):
return (0, num)
elif block_id.startswith('layer'):
return (1, num)
elif block_id.startswith('double'):
return (1, num)
elif block_id == 'mid':
return (2, 0)
elif block_id.startswith('output'):
return (3, num)
elif block_id.startswith('single'):
return (4, num)
elif block_id.startswith('block'):
return (5, num)
else:
return (9, num)
sorted_blocks = sorted(block_analysis.items(), key=block_sort_key)
for block_id, data in sorted_blocks:
if block_id == 'other':
continue # Skip 'other' in main display
params_str = f"{data['param_count']/1e6:.1f}M" if data['param_count'] >= 1e6 else f"{data['param_count']/1e3:.0f}K"
memory_str = f"{data['memory_mb']:.1f} MB"
if block_id in enabled_blocks:
strength = block_strengths.get(block_id, 1.0)
strength_str = f"{strength*100:.0f}%"
else:
strength_str = "OFF"
lines.append(f"{block_id:<20} {params_str:>12} {memory_str:>10} {strength_str:>10}")
# Show 'other' at end
if 'other' in block_analysis:
data = block_analysis['other']
params_str = f"{data['param_count']/1e6:.1f}M" if data['param_count'] >= 1e6 else f"{data['param_count']/1e3:.0f}K"
memory_str = f"{data['memory_mb']:.1f} MB"
lines.append(f"{'other':<20} {params_str:>12} {memory_str:>10} {'100%':>10}")
lines.append("-" * 60)
lines.append(f"Enabled: {len(enabled_blocks)}/{len(block_analysis)} blocks")
scaled = [f"{b}={block_strengths[b]:.0%}" for b in enabled_blocks
if block_strengths.get(b, 1.0) != 1.0]
if scaled:
lines.append(f"Scaled: {', '.join(scaled)}")
return '\n'.join(lines)
def _create_analysis_json(block_analysis: dict, architecture: str,
enabled_blocks: set, block_strengths: dict) -> str:
"""Create JSON analysis output."""
result = {
'architecture': architecture,
'total_params': sum(b['param_count'] for b in block_analysis.values()),
'total_memory_mb': sum(b['memory_mb'] for b in block_analysis.values()),
'blocks': {}
}
for block_id, data in block_analysis.items():
result['blocks'][block_id] = {
'param_count': data['param_count'],
'memory_mb': round(data['memory_mb'], 2),
'enabled': block_id in enabled_blocks,
'strength': block_strengths.get(block_id, 1.0) if block_id in enabled_blocks else 0.0
}
return json.dumps(result)
# ============================================================================
# ARCHITECTURE CONFIGS
# ============================================================================
ARCH_CONFIGS = {
"SDXL": {
"node_id": "SDXLModelLayerEditor",
"display_name": "SDXL Model Layer Editor",
"description": """Per-block control of SDXL model layers.
Block Guide:
- Input 0-11: Encoder blocks (structure, composition)
- Mid: Bottleneck (high-level features)
- Output 0-5: Main decoder (style in 1-2)
- Output 6-11: Late decoder (details, textures)""",
"architecture": "SDXL",
"blocks": [f"input_{i}" for i in range(12)] + ["mid"] + [f"output_{i}" for i in range(12)] + ["other"],
"block_labels": {f"input_{i}": f"Input {i}" for i in range(12)} |
{"mid": "Middle"} |
{f"output_{i}": f"Output {i}" for i in range(12)} |
{"other": "Other Weights"},
"presets": {
"Default": {"enabled": "ALL", "strength": 1.0},
"All Off": {"enabled": [], "strength": 0.0},
"Half Strength": {"enabled": "ALL", "strength": 0.5},
"Outputs Only": {"enabled": [f"output_{i}" for i in range(12)] + ["mid", "other"], "strength": 1.0},
"Inputs Only": {"enabled": [f"input_{i}" for i in range(12)] + ["mid", "other"], "strength": 1.0},
"Custom": None,
}
},
"SD15": {
"node_id": "SD15ModelLayerEditor",
"display_name": "SD 1.5 Model Layer Editor",
"description": """Per-block control of SD 1.5 model layers.""",
"architecture": "SD15",
"blocks": [f"input_{i}" for i in range(12)] + ["mid"] + [f"output_{i}" for i in range(12)] + ["other"],
"block_labels": {f"input_{i}": f"Input {i}" for i in range(12)} |
{"mid": "Middle"} |
{f"output_{i}": f"Output {i}" for i in range(12)} |
{"other": "Other Weights"},
"presets": {
"Default": {"enabled": "ALL", "strength": 1.0},
"All Off": {"enabled": [], "strength": 0.0},
"Half Strength": {"enabled": "ALL", "strength": 0.5},
"Custom": None,
}
},
"FLUX": {
"node_id": "FLUXModelLayerEditor",
"display_name": "FLUX Model Layer Editor",
"description": """Per-block control of FLUX model layers.
Block Guide:
- Double 0-18: Main transformer blocks (19 total)
- Single 0-37: Secondary blocks (38 total)""",
"architecture": "FLUX",
"blocks": [f"double_{i}" for i in range(19)] + [f"single_{i}" for i in range(38)] + ["other"],
"block_labels": {f"double_{i}": f"Double {i}" for i in range(19)} |
{f"single_{i}": f"Single {i}" for i in range(38)} |
{"other": "Other Weights"},
"presets": {
"Default": {"enabled": "ALL", "strength": 1.0},
"All Off": {"enabled": [], "strength": 0.0},
"Half Strength": {"enabled": "ALL", "strength": 0.5},
"Double Only": {"enabled": [f"double_{i}" for i in range(19)] + ["other"], "strength": 1.0},
"Single Only": {"enabled": [f"single_{i}" for i in range(38)] + ["other"], "strength": 1.0},
"Custom": None,
}
},
"ZIMAGE": {
"node_id": "ZImageModelLayerEditor",
"display_name": "Z-Image Model Layer Editor",
"description": """Per-block control of Z-Image model layers.
Layer Guide:
- Layers 0-9: Early processing (structure)
- Layers 10-19: Mid processing (features)
- Layers 20-29: Late processing (details)""",
"architecture": "ZIMAGE",
"blocks": [f"layer_{i}" for i in range(30)] + ["other"],
"block_labels": {f"layer_{i}": f"Layer {i}" for i in range(30)} | {"other": "Other Weights"},
"presets": {
"Default": {"enabled": "ALL", "strength": 1.0},
"All Off": {"enabled": [], "strength": 0.0},
"Half Strength": {"enabled": "ALL", "strength": 0.5},
"Late Only (20-29)": {"enabled": [f"layer_{i}" for i in range(20, 30)] + ["other"], "strength": 1.0},
"Mid-Late (15-29)": {"enabled": [f"layer_{i}" for i in range(15, 30)] + ["other"], "strength": 1.0},
"Custom": None,
}
},
"WAN": {
"node_id": "WanModelLayerEditor",
"display_name": "Wan Model Layer Editor",
"description": """Per-block control of Wan 2.2 model layers (40 blocks).""",
"architecture": "WAN",
"blocks": [f"block_{i}" for i in range(40)] + ["other"],
"block_labels": {f"block_{i}": f"Block {i}" for i in range(40)} | {"other": "Other Weights"},
"presets": {
"Default": {"enabled": "ALL", "strength": 1.0},
"All Off": {"enabled": [], "strength": 0.0},
"Half Strength": {"enabled": "ALL", "strength": 0.5},
"Late Only (30-39)": {"enabled": [f"block_{i}" for i in range(30, 40)] + ["other"], "strength": 1.0},
"Custom": None,
}
},
"QWEN": {
"node_id": "QwenModelLayerEditor",
"display_name": "Qwen Model Layer Editor",
"description": """Per-block control of Qwen-Image model layers (60 blocks).""",
"architecture": "QWEN",
"blocks": [f"block_{i}" for i in range(60)] + ["other"],
"block_labels": {f"block_{i}": f"Block {i}" for i in range(60)} | {"other": "Other Weights"},
"presets": {
"Default": {"enabled": "ALL", "strength": 1.0},
"All Off": {"enabled": [], "strength": 0.0},
"Half Strength": {"enabled": "ALL", "strength": 0.5},
"Late Only (45-59)": {"enabled": [f"block_{i}" for i in range(45, 60)] + ["other"], "strength": 1.0},
"Custom": None,
}
},
}
# ============================================================================
# PER-ARCHITECTURE NODE FACTORY
# ============================================================================
def _create_model_layer_editor_class(config: dict):
"""
Factory function to create a per-architecture Model Layer Editor node.
Similar to LoRA V2 combined nodes but for base models.
"""
class ModelLayerEditor:
"""Per-architecture Model Layer Editor (generated from config)."""
_config = config
@classmethod
def INPUT_TYPES(cls):
cfg = cls._config
blocks = cfg["blocks"]
node_id = cfg["node_id"]
builtin_presets = cfg["presets"]
# Get combined builtin + user presets
all_presets = _get_all_presets(node_id, builtin_presets)
inputs = {
"required": {
"model": ("MODEL", {
"tooltip": "Model from LoadCheckpoint or LoadDiffusionModel"
}),
"preset": (list(all_presets.keys()), {
"default": "Default",
"tooltip": "Quick preset selection. Individual controls below override when changed."
}),
},
"optional": {
"save_preset_name": ("STRING", {
"default": "",
"tooltip": "Enter preset name, then click Save Preset button (or leave for file sync on execute)"
}),
"delete_preset_name": ("STRING", {
"default": "",
"tooltip": "Preset to delete (filled automatically by Delete button)"
}),
"browser_presets_json": ("STRING", {
"default": "{}",
"tooltip": "JSON of all browser presets (auto-filled by JS for sync)"
}),
"save_model": ("BOOLEAN", {
"default": False,
"tooltip": "Save modified model to disk (large files!)"
}),
"save_path": ("STRING", {
"default": "",
"tooltip": "Directory to save modified model"
}),
"save_filename": ("STRING", {
"default": "",
"tooltip": "Filename for saved model (timestamp added)"
}),
"source_model_path": ("STRING", {
"default": "",
"tooltip": "Path to original model file (for copying metadata). Required for saved model to load correctly."
}),
}
}
# Add per-block toggle and strength inputs
for block in blocks:
inputs["required"][block] = ("BOOLEAN", {"default": True})
inputs["required"][f"{block}_str"] = ("FLOAT", {
"default": 1.0,
"min": -2.0, # Must match JS hardcoded value
"max": 2.0,
"step": 0.05
})
return inputs
RETURN_TYPES = ("MODEL", "STRING", "STRING")
RETURN_NAMES = ("model", "analysis", "analysis_json")
OUTPUT_TOOLTIPS = (
"Model with block modifications applied",
"Text analysis of model structure",
"JSON analysis data"
)
FUNCTION = "edit_model"
CATEGORY = "model"
OUTPUT_NODE = True
def edit_model(self, model, preset, save_preset_name="", delete_preset_name="", browser_presets_json="{}",
save_model=False, save_path="", save_filename="", source_model_path="",
**kwargs):
cfg = self._config
blocks = cfg["blocks"]
node_id = cfg["node_id"]
architecture = cfg["architecture"]
print(f"[{cfg['display_name']}] Starting...")
# Analyze the model
block_analysis = _analyze_model_blocks(model, architecture)
print(f"[{cfg['display_name']}] Found {len(block_analysis)} blocks")
# Determine enabled blocks and strengths from kwargs
# NOTE: Always read from kwargs (actual widget values), not preset config
# The preset dropdown only tells JavaScript which values to set on the widgets.
# Python should always use the actual widget values so manual adjustments work.
enabled_blocks = set()
block_strengths = {}
for block in blocks:
if kwargs.get(block, True):
enabled_blocks.add(block)
block_strengths[block] = kwargs.get(f"{block}_str", 1.0)
else:
# Block is disabled - add with strength 0.0 so we know it's in our config
block_strengths[block] = 0.0
print(f"[{cfg['display_name']}] Enabled: {len(enabled_blocks)}/{len(blocks)} blocks")
# Full sync between browser presets and file presets
# This handles: adding new presets, deleting removed presets
print(f"[Model Layer Editor] Browser presets JSON: {browser_presets_json[:100] if browser_presets_json else 'None'}...")
if browser_presets_json and isinstance(browser_presets_json, str) and browser_presets_json.strip():
try:
browser_presets = json.loads(browser_presets_json)
if isinstance(browser_presets, dict):
file_presets = _load_user_presets(node_id)
changed = False
print(f"[Model Layer Editor] Syncing: {len(browser_presets)} browser presets, {len(file_presets)} file presets")
# Add presets that are in browser but not in file
for name, data in browser_presets.items():
if not name or name.lower() in ('true', 'false', 'none', ''):
continue
if name not in file_presets:
file_presets[name] = data
changed = True
print(f"[Model Layer Editor] Added preset '{name}' to file")
# Remove presets that are in file but not in browser (user deleted them)
file_preset_names = list(file_presets.keys())
for name in file_preset_names:
if name not in browser_presets:
del file_presets[name]
changed = True
print(f"[Model Layer Editor] Removed preset '{name}' from file")
if changed:
_save_user_presets(node_id, file_presets)
else:
print(f"[Model Layer Editor] Presets already in sync")
except (json.JSONDecodeError, TypeError) as e:
print(f"[Model Layer Editor] Error parsing browser presets: {e}")
else:
print(f"[Model Layer Editor] No browser presets to sync (empty or invalid)")
# Save as preset if requested (syncs JS localStorage save to file)
# Skip if it's a boolean or boolean-like string (widget corruption)
saved_preset_data = None
if save_preset_name and isinstance(save_preset_name, str) and save_preset_name.strip():
save_name = save_preset_name.strip()
if save_name.lower() not in ('true', 'false', 'none', ''):
_save_current_as_preset(node_id, save_name, blocks, enabled_blocks, block_strengths)
# Prepare preset data for JS sync (only if we actually saved)
saved_preset_data = {
"node_id": node_id,
"preset_name": save_name,
"preset": {
"enabled": "ALL" if set(blocks) == enabled_blocks else list(enabled_blocks),
"strength": 1.0,
"overrides": {b: s for b, s in block_strengths.items() if s != 1.0}
}
}
# Check if any modifications needed
needs_modification = False
for block in blocks:
if block not in enabled_blocks:
needs_modification = True
break
if block_strengths.get(block, 1.0) != 1.0:
needs_modification = True
break
# Apply modifications
modified_count = 0
if needs_modification:
model, modified_count = _apply_block_modifications(
model, block_analysis, enabled_blocks, block_strengths, architecture
)
print(f"[{cfg['display_name']}] Modified {modified_count} tensors")
# Format output
analysis_text = _format_analysis(block_analysis, architecture, enabled_blocks, block_strengths)
analysis_json = _create_analysis_json(block_analysis, architecture, enabled_blocks, block_strengths)
# Include ALL user presets in JSON for JS sync (handles new computer/cleared storage)
analysis_dict = json.loads(analysis_json)
all_user_presets = _load_user_presets(node_id)
if all_user_presets:
analysis_dict["user_presets"] = {
"node_id": node_id,
"presets": all_user_presets
}
# Include just-saved preset info for confirmation
if saved_preset_data:
analysis_dict["saved_preset"] = saved_preset_data
analysis_text += f"\n\n✓ Saved preset '{save_preset_name.strip()}' (restart ComfyUI to see in dropdown)"
analysis_json = json.dumps(analysis_dict)
# Save model if requested
if save_model and needs_modification:
block_info = {
'enabled': list(enabled_blocks),
'strengths': block_strengths,
'modified_count': modified_count
}
saved_path = _save_modified_model(
model, save_path, save_filename, architecture, block_info, source_model_path
)
if saved_path:
analysis_text += f"\n\nSaved model: {saved_path}"
return (model, analysis_text, analysis_json)
# Set class attributes from config
ModelLayerEditor.__name__ = config["node_id"]
ModelLayerEditor.__doc__ = config.get("description", "Model Layer Editor")
ModelLayerEditor.DESCRIPTION = config.get("description", "Model Layer Editor")
return ModelLayerEditor
# ============================================================================
# NODE REGISTRATION
# ============================================================================
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
# Generate per-architecture editor nodes
for arch_name, config in ARCH_CONFIGS.items():
if "node_id" in config: # Only configs with node_id
node_class = _create_model_layer_editor_class(config)
NODE_CLASS_MAPPINGS[config["node_id"]] = node_class
NODE_DISPLAY_NAME_MAPPINGS[config["node_id"]] = config["display_name"]