Add hidden per-block LoRA support for LTX

This commit is contained in:
digital-garbage
2026-04-23 22:50:32 +03:00
parent c60b372c70
commit bee4145832
2 changed files with 181 additions and 4 deletions
+3
View File
@@ -19,6 +19,7 @@ Inputs:
- **positive_prompt**: Exact prompt text for lookup.
- **refinement_key**: Same key used by `FunPack Video Refiner`.
- **mode**: Same tokenizer mode as the refiner.
- **per_block**: If enabled for `ltx2` stacks, the loader derives hidden transformer block scales from each LoRA's own patch magnitudes. The UI still exposes only the regular base weights.
- **lora_N**: LoRA file.
- **lora_N_type**: `general`, `concept`, `style`, `quality`, or `character`.
- **lora_N_base_weight**: Trainer-recommended model base weight.
@@ -31,6 +32,8 @@ On the first run for an exact prompt, no saved suggestion exists, so base weight
This node only loads the LoRA stack. It applies the model weights prepared by `FunPack Apply LoRA Weights`, then passes the same stack forward so the refiner can learn from it. The `clip` input is optional and uses zero CLIP strength when omitted.
When `per_block` is enabled on an `ltx2` stack, the loader keeps the user-facing global LoRA weight but redistributes it across detected `transformer_blocks.N` patches automatically. Blocks with stronger LoRA magnitude get a bit more weight, weaker blocks get a bit less, and non-block patches stay at the global weight. If the loader cannot detect a usable LTX transformer block layout, it falls back to the normal global application path.
## Video Refiner Integration
Connect the `lora_stack` output from `FunPack LoRA Loader` into the optional `lora_stack` input on `FunPack Video Refiner`.
+178 -4
View File
@@ -1,17 +1,24 @@
import json
import logging
import os
import re
from collections import defaultdict
from hashlib import md5
import comfy.lora
import comfy.lora_convert
import comfy.sd
import comfy.utils
import folder_paths
import torch
from aiohttp import web
from server import PromptServer
LORA_TYPES = ["general", "concept", "style", "quality", "character"]
LORA_STACK_TYPE = "FUNPACK_LORA_STACK"
TRANSFORMER_BLOCK_PATTERN = re.compile(r"(?:^|\.)transformer_blocks\.(\d+)\.")
LTX_IMAGE_MODELS = {"ltxv", "ltxav"}
@PromptServer.instance.routes.get("/funpack/loras")
@@ -82,6 +89,46 @@ def refiner_state_path(refinement_key, mode):
return os.path.join(refinements_dir, f"refine_{safe_key}.json")
def coerce_bool(value):
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def patch_target_key(patch_key):
if isinstance(patch_key, tuple) and patch_key:
return patch_key[0]
return patch_key
def transformer_block_index(patch_key):
target_key = patch_target_key(patch_key)
if not isinstance(target_key, str):
return None
match = TRANSFORMER_BLOCK_PATTERN.search(target_key)
if not match:
return None
return int(match.group(1))
def patch_energy(value):
if isinstance(value, torch.Tensor):
return float(value.abs().mean().item())
weights = getattr(value, "weights", None)
if weights is not None:
return patch_energy(weights)
if isinstance(value, dict):
return sum(patch_energy(item) for item in value.values())
if isinstance(value, (list, tuple)):
return sum(patch_energy(item) for item in value)
return 0.0
class FunPackApplyLoraWeights:
"""
Builds a LoRA stack from user base weights, then applies prompt-specific
@@ -120,6 +167,13 @@ class FunPackApplyLoraWeights:
"positive_prompt": ("STRING", {"multiline": True, "default": ""}),
"refinement_key": ("STRING", {"default": "my_style_v1", "multiline": False}),
"mode": (["ltx2", "wan"], {"default": "ltx2"}),
"per_block": (
"BOOLEAN",
{
"default": False,
"tooltip": "For LTX-mode stacks, derive hidden transformer block strengths from the LoRA itself and apply them automatically.",
},
),
},
"optional": optional,
}
@@ -217,13 +271,15 @@ class FunPackApplyLoraWeights:
return abs(float(saved_base) - float(entry["base_model_weight"])) <= 1e-6
def apply_lora_weights(self, positive_prompt, refinement_key, mode, **kwargs):
def apply_lora_weights(self, positive_prompt, refinement_key, mode, per_block=False, **kwargs):
mode = (mode or "ltx2").lower()
per_block = coerce_bool(per_block)
prompt_key = prompt_key_for_mode(positive_prompt, mode)
suggestions, source_message = self._load_suggestions(refinement_key, mode, prompt_key)
loras = []
lines = [f"FunPack Apply LoRA Weights | {source_message}"]
lines.append(f"Per-block application: {'enabled' if per_block else 'disabled'}")
for entry in self._iter_slots(kwargs):
suggestion = self._get_suggestion(suggestions, entry)
model_weight = float(suggestion.get("model_weight", entry["base_model_weight"]))
@@ -240,9 +296,10 @@ class FunPackApplyLoraWeights:
)
stack = {
"version": 1,
"version": 2,
"refinement_key": refinement_key,
"mode": mode,
"per_block": per_block,
"positive_prompt": positive_prompt,
"prompt_key": prompt_key,
"loras": loras,
@@ -293,9 +350,115 @@ class FunPackLoraLoader:
return lora
def _model_image_model(self, model):
model_wrapper = getattr(model, "model", None)
model_config = getattr(model_wrapper, "model_config", None)
unet_config = getattr(model_config, "unet_config", None)
if isinstance(unet_config, dict):
return unet_config.get("image_model")
return None
def _per_block_requested(self, entry, lora_stack):
return coerce_bool(entry.get("per_block", lora_stack.get("per_block", False)))
def _per_block_supported(self, model, lora_stack, entry):
if not self._per_block_requested(entry, lora_stack):
return False
if (lora_stack.get("mode") or "ltx2").lower() != "ltx2":
return False
return self._model_image_model(model) in LTX_IMAGE_MODELS
def _load_model_lora_patches(self, model, lora):
key_map = comfy.lora.model_lora_keys_unet(model.model, {})
converted_lora = comfy.lora_convert.convert_lora(lora)
return comfy.lora.load_lora(converted_lora, key_map)
def _split_model_patches_by_block(self, loaded):
global_patches = {}
block_patches = defaultdict(dict)
for patch_key, patch_value in loaded.items():
block_index = transformer_block_index(patch_key)
if block_index is None:
global_patches[patch_key] = patch_value
continue
block_patches[block_index][patch_key] = patch_value
return global_patches, dict(block_patches)
def _block_scales_from_patches(self, block_patches):
block_scores = {}
for block_index, patches in block_patches.items():
score = 0.0
for patch_value in patches.values():
score += patch_energy(patch_value)
if score > 0.0:
block_scores[block_index] = score
if len(block_scores) < 2:
return {}
mean_score = sum(block_scores.values()) / len(block_scores)
if mean_score <= 0.0:
return {}
scales = {}
for block_index, score in block_scores.items():
ratio = max(0.0, score / mean_score)
scales[block_index] = max(0.25, min(1.75, ratio ** 0.5))
return scales
def _apply_model_patches(self, model, loaded, model_weight, block_scales=None):
new_model = model.clone()
applied = set()
global_patches, block_patches = self._split_model_patches_by_block(loaded)
if global_patches:
applied.update(new_model.add_patches(global_patches, model_weight))
if block_scales:
for block_index in sorted(block_patches):
block_strength = model_weight * block_scales.get(block_index, 1.0)
applied.update(new_model.add_patches(block_patches[block_index], block_strength))
else:
for block_index in sorted(block_patches):
applied.update(new_model.add_patches(block_patches[block_index], model_weight))
for patch_key in loaded:
if patch_key not in applied:
logging.warning("NOT LOADED %s", patch_key)
return new_model, len(global_patches), len(block_patches)
def _load_lora_per_block(self, model, lora, model_weight):
loaded = self._load_model_lora_patches(model, lora)
_, block_patches = self._split_model_patches_by_block(loaded)
block_scales = self._block_scales_from_patches(block_patches)
if not block_scales:
return None, "per-block fallback=global"
new_model, non_block_count, _ = self._apply_model_patches(
model,
loaded,
model_weight,
block_scales=block_scales,
)
min_scale = min(block_scales.values())
max_scale = max(block_scales.values())
status = (
f"per-block blocks={len(block_scales)} non_block={non_block_count} "
f"range={min_scale:.2f}..{max_scale:.2f}"
)
return new_model, status
def load_loras(self, model, lora_stack, clip=None):
loras = lora_stack.get("loras", []) if isinstance(lora_stack, dict) else []
per_block = coerce_bool(lora_stack.get("per_block", False)) if isinstance(lora_stack, dict) else False
lines = [f"FunPack LoRA Loader | loading {len(loras)} LoRA(s)"]
lines.append(f"Per-block application: {'enabled' if per_block else 'disabled'}")
loaded_count = 0
for entry in loras:
@@ -305,11 +468,22 @@ class FunPackLoraLoader:
continue
lora = self._load_lora_file(entry["name"])
model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_weight, 0.0)
apply_status = "global"
if self._per_block_supported(model, lora_stack, entry):
per_block_model, apply_status = self._load_lora_per_block(model, lora, model_weight)
if per_block_model is not None:
model = per_block_model
else:
model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_weight, 0.0)
elif self._per_block_requested(entry, lora_stack):
apply_status = "per-block unsupported -> global"
model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_weight, 0.0)
else:
model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_weight, 0.0)
loaded_count += 1
lines.append(
f"lora_{entry.get('slot', '?')}: {entry['name']} "
f"applied={model_weight:+.3f} source={entry.get('source', 'base')}"
f"applied={model_weight:+.3f} source={entry.get('source', 'base')} mode={apply_status}"
)
if loaded_count == 0: