Files
IAMCCS-IAMCCS-nodes/iamccs_ltx2_lora_stack.py
T

425 lines
16 KiB
Python
Raw 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.
# iamccs_ltx2_lora_stack.py
# ===============================================================
# IAMCCS_LTX2_LoRAStack / IAMCCS_ModelWithLoRA_LTX2
# 3-slot LoRA loader intended for LTX-2 style LoRAs.
# ===============================================================
import logging
import comfy.sd
import comfy.utils
import folder_paths
def standardize_ltx2_lora_keys(sd: dict) -> dict:
"""Minimal standardization for LTX-2 LoRA state_dict.
Notes:
- Does NOT apply WAN-style key remapping.
- Converts old LoRA formats using lora_down/lora_up to lora_A/lora_B.
This is intentionally conservative: LTX-2/Flux/WAN families differ in key
naming, and aggressive remapping can cause widespread 'key not loaded' spam.
"""
if not sd:
return sd
sample_keys = list(sd.keys())[:80]
has_down_up = any(".lora_down." in k or ".lora_up." in k for k in sample_keys)
has_a_b = any(".lora_A." in k or ".lora_B." in k for k in sample_keys)
needs_convert = has_down_up and not has_a_b
if not needs_convert:
return sd
new_sd = {}
for k, v in sd.items():
nk = k
if nk.endswith(".lora_down.weight"):
nk = nk.replace(".lora_down.weight", ".lora_A.weight")
elif nk.endswith(".lora_up.weight"):
nk = nk.replace(".lora_up.weight", ".lora_B.weight")
new_sd[nk] = v
logging.info("[IAMCCS_LTX2_LoRAStack] Detected legacy LoRA format - converted lora_down/up → lora_A/B")
return new_sd
class SuppressLTX2MissingKeysFilter(logging.Filter):
"""Suppresses noisy 'lora key not loaded' lines for known-mismatch key prefixes.
This does NOT change which weights are actually applied; it only reduces log spam.
"""
def __init__(self):
super().__init__()
self.suppressed_count = 0
self.suppressed_prefixes = set()
# Common spammy prefixes seen when LTX-2 style LoRAs are applied to a model
# with a different internal layout (e.g. Flux variants).
self._prefixes = (
"diffusion_model.transformer_blocks.",
"text_embedding_projection.",
)
def filter(self, record):
msg = record.getMessage()
msg_lower = msg.lower()
if "lora key not loaded:" not in msg_lower:
return True
# Message format usually: "lora key not loaded: <key>"
key = msg.split(":", 1)[-1].strip() if ":" in msg else msg
for p in self._prefixes:
if key.startswith(p):
self.suppressed_count += 1
self.suppressed_prefixes.add(p)
return False
return True
# ===============================================================
# Node 1 — LTX-2 LoRA Stack (up to 3 LoRAs with strength)
# ===============================================================
class IAMCCS_LTX2_LoRAStack:
@classmethod
def INPUT_TYPES(cls):
lora_list = folder_paths.get_filename_list("loras") + ["no"]
return {
"required": {
"lora1": (lora_list, {"default": "no"}),
"strength1": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.01}),
"lora2": (lora_list, {"default": "no"}),
"strength2": ("FLOAT", {"default": 0.0, "min": -5.0, "max": 5.0, "step": 0.01}),
"lora3": (lora_list, {"default": "no"}),
"strength3": ("FLOAT", {"default": 0.0, "min": -5.0, "max": 5.0, "step": 0.01}),
},
"optional": {
"lora": ("LORA",),
},
}
RETURN_TYPES = ("LORA",)
FUNCTION = "load_ltx2"
CATEGORY = "IAMCCS/LoRA"
def load_ltx2(self, lora1, strength1, lora2, strength2, lora3, strength3, lora=None):
loras = []
for name, strength in (
(lora1, strength1),
(lora2, strength2),
(lora3, strength3),
):
if not name or name == "no" or strength == 0.0:
continue
path = folder_paths.get_full_path_or_raise("loras", name)
sd = comfy.utils.load_torch_file(path, safe_load=True)
sd = standardize_ltx2_lora_keys(sd)
loras.append({"name": name, "strength": strength, "state_dict": sd})
if lora is not None and isinstance(lora, list):
loras.extend(lora)
if not loras:
logging.warning("[IAMCCS_LTX2_LoRAStack] ⚠ No LoRA selected")
else:
total_keys = sum(len(l.get("state_dict", {})) for l in loras)
logging.info("=" * 60)
logging.info(f"[IAMCCS_LTX2_LoRAStack] ✅ SUCCESS: {len(loras)} LoRA(s) loaded")
logging.info(f" • {total_keys} total parameters active")
logging.info("=" * 60)
return (loras,)
# ===============================================================
# Node 1b — LTX-2 LoRA Stack (staged: outputs 2 stacks)
# ===============================================================
class IAMCCS_LTX2_LoRAStackStaged:
@classmethod
def INPUT_TYPES(cls):
lora_list = folder_paths.get_filename_list("loras") + ["no"]
return {
"required": {
"lora1": (lora_list, {"default": "no"}),
"strength1_stage1": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.01}),
"strength1_stage2": ("FLOAT", {"default": 0.0, "min": -5.0, "max": 5.0, "step": 0.01}),
"lora2": (lora_list, {"default": "no"}),
"strength2_stage1": ("FLOAT", {"default": 0.0, "min": -5.0, "max": 5.0, "step": 0.01}),
"strength2_stage2": ("FLOAT", {"default": 0.0, "min": -5.0, "max": 5.0, "step": 0.01}),
"lora3": (lora_list, {"default": "no"}),
"strength3_stage1": ("FLOAT", {"default": 0.0, "min": -5.0, "max": 5.0, "step": 0.01}),
"strength3_stage2": ("FLOAT", {"default": 0.0, "min": -5.0, "max": 5.0, "step": 0.01}),
},
"optional": {
"lora": ("LORA",),
},
}
RETURN_TYPES = ("LORA", "LORA")
RETURN_NAMES = ("lora_stage1", "lora_stage2")
FUNCTION = "load_ltx2_staged"
CATEGORY = "IAMCCS/LoRA"
def load_ltx2_staged(
self,
lora1,
strength1_stage1,
strength1_stage2,
lora2,
strength2_stage1,
strength2_stage2,
lora3,
strength3_stage1,
strength3_stage2,
lora=None,
):
loras_stage1 = []
loras_stage2 = []
for name, s1, s2 in (
(lora1, strength1_stage1, strength1_stage2),
(lora2, strength2_stage1, strength2_stage2),
(lora3, strength3_stage1, strength3_stage2),
):
if not name or name == "no":
continue
if float(s1) == 0.0 and float(s2) == 0.0:
continue
path = folder_paths.get_full_path_or_raise("loras", name)
sd = comfy.utils.load_torch_file(path, safe_load=True)
sd = standardize_ltx2_lora_keys(sd)
if float(s1) != 0.0:
loras_stage1.append({"name": name, "strength": float(s1), "state_dict": sd})
if float(s2) != 0.0:
loras_stage2.append({"name": name, "strength": float(s2), "state_dict": sd})
if lora is not None and isinstance(lora, list):
# External stack is included identically in both stages
loras_stage1.extend(lora)
loras_stage2.extend(lora)
if not loras_stage1:
logging.warning("[IAMCCS_LTX2_LoRAStackStaged] ⚠ Stage 1: no LoRA selected")
if not loras_stage2:
logging.warning("[IAMCCS_LTX2_LoRAStackStaged] ⚠ Stage 2: no LoRA selected")
return (loras_stage1, loras_stage2)
# ===============================================================
# Node 2 — Apply LoRA to MODEL (LTX-2 oriented, with log suppression)
# ===============================================================
class IAMCCS_ModelWithLoRA_LTX2:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"lora": ("LORA",),
}
}
RETURN_TYPES = ("MODEL",)
FUNCTION = "apply"
CATEGORY = "IAMCCS/LoRA"
def apply(self, model, lora):
if not lora:
return (model,)
model_out = model
logger = logging.getLogger()
missing_keys_filter = SuppressLTX2MissingKeysFilter()
logger.addFilter(missing_keys_filter)
try:
for entry in lora:
sd = entry["state_dict"]
strength = entry["strength"]
model_out, _ = comfy.sd.load_lora_for_models(model_out, None, sd, strength, 0)
logging.info(f"[IAMCCS_ModelWithLoRA_LTX2] ✅ '{entry['name']}' strength={strength}")
if missing_keys_filter.suppressed_count > 0:
prefixes = ", ".join(sorted(missing_keys_filter.suppressed_prefixes))
logging.info(
"[IAMCCS_ModelWithLoRA_LTX2] ℹ Suppressed "
f"{missing_keys_filter.suppressed_count} 'lora key not loaded' lines (prefixes: {prefixes})."
)
logging.info(
"[IAMCCS_ModelWithLoRA_LTX2] ℹ If the LoRA effect is weak/absent, verify the base model matches the LoRA family."
)
finally:
logger.removeFilter(missing_keys_filter)
return (model_out,)
# ===============================================================
# Node 2b — Apply staged LoRA stacks to MODEL (LTX-2)
# ===============================================================
class IAMCCS_ModelWithLoRA_LTX2_Staged:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"lora_stage1": ("LORA",),
"lora_stage2": ("LORA",),
},
"optional": {
# If omitted, stage2 uses the same model as stage1.
"model_stage2": ("MODEL",),
},
}
RETURN_TYPES = ("MODEL", "MODEL")
RETURN_NAMES = ("model_stage1", "model_stage2")
FUNCTION = "apply_staged"
CATEGORY = "IAMCCS/LoRA"
def apply_staged(self, model, lora_stage1, lora_stage2, model_stage2=None):
model_stage1_out = model
model_stage2_out = model_stage2 if model_stage2 is not None else model
lora_stage1_list = lora_stage1 if isinstance(lora_stage1, list) else []
lora_stage2_list = lora_stage2 if isinstance(lora_stage2, list) else []
if not lora_stage1_list and not lora_stage2_list:
return (model_stage1_out, model_stage2_out)
logger = logging.getLogger()
missing_keys_filter = SuppressLTX2MissingKeysFilter()
logger.addFilter(missing_keys_filter)
try:
if lora_stage1_list:
for entry in lora_stage1_list:
sd = entry["state_dict"]
strength = entry["strength"]
model_stage1_out, _ = comfy.sd.load_lora_for_models(model_stage1_out, None, sd, strength, 0)
logging.info(f"[IAMCCS_ModelWithLoRA_LTX2_Staged] ✅ Stage1: '{entry['name']}' strength={strength}")
if lora_stage2_list:
for entry in lora_stage2_list:
sd = entry["state_dict"]
strength = entry["strength"]
model_stage2_out, _ = comfy.sd.load_lora_for_models(model_stage2_out, None, sd, strength, 0)
logging.info(f"[IAMCCS_ModelWithLoRA_LTX2_Staged] ✅ Stage2: '{entry['name']}' strength={strength}")
if missing_keys_filter.suppressed_count > 0:
prefixes = ", ".join(sorted(missing_keys_filter.suppressed_prefixes))
logging.info(
"[IAMCCS_ModelWithLoRA_LTX2_Staged] ℹ Suppressed "
f"{missing_keys_filter.suppressed_count} 'lora key not loaded' lines (prefixes: {prefixes})."
)
logging.info(
"[IAMCCS_ModelWithLoRA_LTX2_Staged] ℹ If the LoRA effect is weak/absent, verify the base model matches the LoRA family."
)
finally:
logger.removeFilter(missing_keys_filter)
return (model_stage1_out, model_stage2_out)
# ===============================================================
# Node 3 — LTX-2 LoRA Stack (MODEL In→Out)
# ===============================================================
class IAMCCS_LTX2_LoRAStackModelIO:
@classmethod
def INPUT_TYPES(cls):
lora_list = folder_paths.get_filename_list("loras") + ["no"]
return {
"required": {
"model": ("MODEL",),
"lora1": (lora_list, {"default": "no"}),
"strength1": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.01}),
"lora2": (lora_list, {"default": "no"}),
"strength2": ("FLOAT", {"default": 0.0, "min": -5.0, "max": 5.0, "step": 0.01}),
"lora3": (lora_list, {"default": "no"}),
"strength3": ("FLOAT", {"default": 0.0, "min": -5.0, "max": 5.0, "step": 0.01}),
},
"optional": {
"lora": ("LORA",),
},
}
RETURN_TYPES = ("MODEL",)
FUNCTION = "apply_stack"
CATEGORY = "IAMCCS/LoRA"
def apply_stack(self, model, lora1, strength1, lora2, strength2, lora3, strength3, lora=None):
entries = []
for name, strength in (
(lora1, strength1),
(lora2, strength2),
(lora3, strength3),
):
if not name or name == "no" or float(strength) == 0.0:
continue
path = folder_paths.get_full_path_or_raise("loras", name)
sd = comfy.utils.load_torch_file(path, safe_load=True)
sd = standardize_ltx2_lora_keys(sd)
entries.append({"name": name, "strength": float(strength), "state_dict": sd})
if lora is not None and isinstance(lora, list):
entries.extend(lora)
if not entries:
logging.warning("[IAMCCS_LTX2_LoRAStackModelIO] ⚠ No LoRA selected; returning input model unchanged")
return (model,)
model_out = model
logger = logging.getLogger()
missing_keys_filter = SuppressLTX2MissingKeysFilter()
logger.addFilter(missing_keys_filter)
try:
for entry in entries:
sd = entry["state_dict"]
strength = entry["strength"]
model_out, _ = comfy.sd.load_lora_for_models(model_out, None, sd, strength, 0)
logging.info(f"[IAMCCS_LTX2_LoRAStackModelIO] ✅ '{entry['name']}' strength={strength}")
if missing_keys_filter.suppressed_count > 0:
prefixes = ", ".join(sorted(missing_keys_filter.suppressed_prefixes))
logging.info(
"[IAMCCS_LTX2_LoRAStackModelIO] ℹ Suppressed "
f"{missing_keys_filter.suppressed_count} 'lora key not loaded' lines (prefixes: {prefixes})."
)
finally:
logger.removeFilter(missing_keys_filter)
return (model_out,)
NODE_CLASS_MAPPINGS = {
"IAMCCS_LTX2_LoRAStack": IAMCCS_LTX2_LoRAStack,
"IAMCCS_LTX2_LoRAStackStaged": IAMCCS_LTX2_LoRAStackStaged,
"IAMCCS_ModelWithLoRA_LTX2": IAMCCS_ModelWithLoRA_LTX2,
"IAMCCS_ModelWithLoRA_LTX2_Staged": IAMCCS_ModelWithLoRA_LTX2_Staged,
"IAMCCS_LTX2_LoRAStackModelIO": IAMCCS_LTX2_LoRAStackModelIO,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"IAMCCS_LTX2_LoRAStack": "LoRA Stack (LTX-2, 3 slots)",
"IAMCCS_LTX2_LoRAStackStaged": "LoRA Stack (LTX-2, staged: stage1+stage2) (BETA)",
"IAMCCS_ModelWithLoRA_LTX2": "Apply LoRA to MODEL (LTX-2, quiet logs)",
"IAMCCS_ModelWithLoRA_LTX2_Staged": "Apply LoRA to MODEL (LTX-2, staged) (BETA)",
"IAMCCS_LTX2_LoRAStackModelIO": "LoRA Stack (Model In→Out) LTX-2",
}