Files
cicalooo 55da4ee624 Drop extra credits and the stale audit
Keep the alibaba-pai file connection and note that the stack row UI matured from rgthree-comfy's Power Lora Loader.
2026-10-11 15:38:09 +11:00

362 lines
14 KiB
Python

"""Node definitions for the MiniMax H3 Power LoRA Stack."""
from __future__ import annotations
import logging
import math
import folder_paths
from . import apply as apply_mod
from . import detect
from .schedule import Schedule
LOG = logging.getLogger("h3.powerlorastack")
CATEGORY = "MiniMax-H3/lora"
class AnyType(str):
def __ne__(self, other):
return False
ANY = AnyType("*")
class FlexibleOptionalInputType(dict):
"""Accepts the arbitrary ``lora_N`` inputs the frontend adds at runtime.
ComfyUI validates a prompt against INPUT_TYPES; a stack whose row count is
decided in the browser has no fixed schema, so this dict reports that it
contains every key and hands back a permissive type for the ones it does
not know about.
"""
def __init__(self, type, data=None):
super().__init__()
self.type = type
self.data = data or {}
self.update(self.data)
def __getitem__(self, key):
if key in self.data:
return self.data[key]
return (self.type,)
def __contains__(self, key):
return True
def _resolve_lora(name: str):
"""Map a stored lora name onto a real file, tolerating separator drift."""
if not name or name == "None":
return None
path = folder_paths.get_full_path("loras", name)
if path:
return path
wanted = name.replace("\\", "/").lower()
matches = []
for candidate in folder_paths.get_filename_list("loras"):
normalized = candidate.replace("\\", "/").lower()
if normalized == wanted:
matches.append(candidate)
elif "/" not in wanted and normalized.endswith("/" + wanted):
matches.append(candidate)
if len(matches) == 1:
return folder_paths.get_full_path("loras", matches[0])
if len(matches) > 1:
LOG.warning("H3 Power LoRA Stack: ambiguous lora name %r (%d matches)",
name, len(matches))
return None
def _collect(kwargs, issues=None):
"""Pull enabled rows out of the dynamic ``lora_N`` inputs, in UI order."""
rows = []
for key, value in kwargs.items():
if not key.lower().startswith("lora_") or not isinstance(value, dict):
continue
if "lora" not in value:
continue
try:
order = int(key.split("_", 1)[1])
except (IndexError, ValueError):
order = 1 << 30
rows.append((order, value))
rows.sort(key=lambda item: item[0])
entries = []
for order, value in rows:
if not value.get("on", True):
continue
try:
strength = float(value.get("strength", 1.0))
except (TypeError, ValueError):
message = f"row {order}: strength is not numeric"
LOG.warning("H3 Power LoRA Stack: %s", message)
if issues is not None:
issues.append(message)
continue
if not math.isfinite(strength):
message = f"row {order}: strength must be finite"
LOG.warning("H3 Power LoRA Stack: %s", message)
if issues is not None:
issues.append(message)
continue
if strength == 0.0:
continue
name = value.get("lora")
if not name or name == "None":
continue # a row the user added but has not filled in yet
path = _resolve_lora(name)
if path is None:
message = f"row {order}: could not resolve lora {name!r}"
LOG.warning("H3 Power LoRA Stack: %s", message)
if issues is not None:
issues.append(message)
continue
entries.append({"name": name, "path": path, "strength": strength, "row": order})
return entries
class H3PowerLoraStack:
"""Stacked multi-LoRA loader for MiniMax H3 across every weight format.
Handles the three things generic LoRA loaders get wrong on H3:
* quantized bases -- w4a8/int4 weights are patched with an exact runtime
branch instead of a lossy dequantize/requantize merge;
* adaLN basis mismatch -- LoRAs trained against a dense checkpoint are
rebased onto a pruned checkpoint's curve, instead of being skipped;
* Acc/PDD head banks -- ``video_out.set_weight`` of shape ``[N*96, hidden]``
is peeled off before the stock ``set`` path and blended per sampler step
as row 0 plus offsets;
* key conventions -- ai-toolkit, kohya, lycoris, peft and bare-prefix
layouts all resolve against the model's own key set.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"optional": FlexibleOptionalInputType(ANY, {
"model": ("MODEL",),
"quantized_layers": (["auto", "branch", "merge"], {
"default": "auto",
"tooltip": "auto: runtime branch for quantized layers, merge for the rest. "
"branch: never modify a weight. merge: stock behaviour "
"(destroys LoRAs on w4a8/int4).",
}),
"adaln_port": (["auto", "strip", "off"], {
"default": "auto",
"tooltip": "auto: rebase adaLN pairs on this stack and any already on MODEL "
"between dense (2688) and curve (8), including curve-to-curve "
"when the LoRA ships a table. strip: drop mismatched pairs. "
"off: leave them.",
}),
"adaln_modality": ("H3_MODALITY", {
"tooltip": "Optional. Wire a MiniMax H3 adaLN Modality node here to "
"scale every stacked LoRA's adaLN modulation per modality.",
}),
"schedule": ("H3_SCHEDULE", {
"tooltip": "Optional. Wire an H3 LoRA Schedule chain here to vary selected "
"row strengths over the denoising trajectory.",
}),
}),
"hidden": {},
}
RETURN_TYPES = ("MODEL", "STRING")
RETURN_NAMES = ("MODEL", "report")
OUTPUT_TOOLTIPS = ("Patched model", "Per-LoRA account of what was applied")
FUNCTION = "apply"
CATEGORY = CATEGORY
DESCRIPTION = __doc__
def apply(self, model=None, quantized_layers="auto", adaln_port="auto",
adaln_modality=None, schedule=None, **kwargs):
if model is None:
raise ValueError("H3 Power LoRA Stack: no model connected")
issues = []
entries = _collect(kwargs, issues)
try:
patcher, report = apply_mod.apply_stack(
model, entries, mode=quantized_layers, adaln_mode=adaln_port,
modality=adaln_modality, schedule=schedule,
)
except Exception as exc:
LOG.exception("H3 Power LoRA Stack failed; passing the model through")
extra = "\n".join(issues)
return (model, f"stack failed ({exc}); model unchanged"
+ ("\n" + extra if extra else ""))
text = report.text()
if not entries and not text.strip():
text = "no LoRAs enabled"
if issues:
text = text + ("\n" if text else "") + "\n".join(issues)
LOG.info("H3 Power LoRA Stack:\n%s", text)
return (patcher, text)
class H3AdalnModality:
"""Scale a stacked LoRA's adaLN modulation per modality.
H3 runs audio and video through the same 50 blocks, so there are no audio
layers to target the way LTX 2.3 allows. The one pathway that does separate
cleanly is adaLN: its projection emits three contiguous row blocks, one per
modality, so scaling a slice steers that modality's modulation exactly.
Wire the output into the stack's ``adaln_modality`` input. Leaving all three
at 1.0 is a no-op; 0.0 removes that modality's share of the adapter.
Only affects LoRAs that carry adaLN pairs -- the stack's report says, per
LoRA, whether the control was actually live. It does not isolate a modality:
attention is joint over the packed sequence, so this changes where a LoRA is
applied, not everything it eventually reaches.
"""
@classmethod
def INPUT_TYPES(cls):
slider = {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.05}
return {"required": {
"video": ("FLOAT", dict(slider, tooltip="Scale for the video modality (tag 0).")),
"text": ("FLOAT", dict(slider, tooltip="Scale for the text/conditioning modality (tag 1).")),
"audio": ("FLOAT", dict(slider, tooltip="Scale for the audio modality (tag 2).")),
}}
RETURN_TYPES = ("H3_MODALITY", "STRING")
RETURN_NAMES = ("adaln_modality", "info")
OUTPUT_TOOLTIPS = ("Wire into the stack's adaln_modality input",
"What this setting will do")
FUNCTION = "build"
CATEGORY = CATEGORY
DESCRIPTION = __doc__
def build(self, video=1.0, text=1.0, audio=1.0):
values = {"video": float(video), "text": float(text), "audio": float(audio)}
if all(v == 1.0 for v in values.values()):
info = "no-op (all modalities at 1.0)"
else:
info = "adaLN " + ", ".join(f"{k} x{v:g}" for k, v in values.items())
return (values, info)
class H3LoraSchedule:
"""Schedule selected Power LoRA Stack rows over sampler steps or sigma.
Chain multiple nodes when different rows need different schedules; the
later node wins where row selectors overlap. The stack reads ComfyUI's
sampler timeline automatically, so no SIGMAS connection is required.
"""
@classmethod
def INPUT_TYPES(cls):
strength = {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}
percent = {"min": 0.0, "max": 100.0, "step": 1.0}
return {
"required": {
"rows": ("STRING", {"default": "all", "tooltip": "all, 1,3, or 2-4"}),
"start_strength": ("FLOAT", dict(strength, default=1.0)),
"end_strength": ("FLOAT", dict(strength, default=0.1)),
"curve": (["linear", "cosine", "smoothstep", "power", "step", "explicit"],),
"curve_power": ("FLOAT", {"default": 2.0, "min": 0.01, "max": 20.0, "step": 0.05}),
"explicit_strengths": ("STRING", {
"default": "", "multiline": True,
"tooltip": "Comma- or space-separated strengths, one per intended step.",
}),
"start_percent": ("FLOAT", dict(percent, default=0.0)),
"end_percent": ("FLOAT", dict(percent, default=100.0)),
"domain": (["steps", "sigma"], {
"tooltip": "steps follows model-call indices; sigma follows the scheduler's "
"actual normalized noise values.",
}),
},
"optional": {
"schedule": ("H3_SCHEDULE", {
"tooltip": "Optional earlier schedule link. This node wins on overlaps.",
}),
},
}
RETURN_TYPES = ("H3_SCHEDULE", "STRING")
RETURN_NAMES = ("schedule", "info")
OUTPUT_TOOLTIPS = ("Wire into the Power LoRA Stack's schedule input", "Schedule summary")
FUNCTION = "build"
CATEGORY = CATEGORY
DESCRIPTION = __doc__
def build(self, rows="all", start_strength=1.0, end_strength=0.1,
curve="linear", curve_power=2.0, explicit_strengths="",
start_percent=0.0, end_percent=100.0, domain="steps", schedule=None):
item = Schedule(
rows=str(rows), start_strength=float(start_strength),
end_strength=float(end_strength), curve=curve,
curve_power=float(curve_power), explicit_strengths=str(explicit_strengths),
start_percent=float(start_percent), end_percent=float(end_percent), domain=domain,
)
previous = (schedule,) if isinstance(schedule, Schedule) else tuple(schedule or ())
chain = previous + (item,)
info = (f"rows {rows}: {start_strength:g} \u2192 {end_strength:g} {curve} "
f"({domain} {start_percent:g}\u2013{end_percent:g}%)")
if curve == "explicit":
info += f", {len(item._explicit)} values"
return (chain, info)
class H3LoraInspector:
"""Report the format, rank and adaLN basis of a LoRA without loading it."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"lora_name": (folder_paths.get_filename_list("loras"),),
}}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("info",)
FUNCTION = "inspect"
CATEGORY = CATEGORY
DESCRIPTION = __doc__
def inspect(self, lora_name):
path = _resolve_lora(lora_name)
if path is None:
return (f"{lora_name}: not found",)
info = detect.inspect(path)
if info.get("error"):
return (f"{info['name']}: {info['error']}",)
lines = [
f"file : {info['name']}",
f"size : {info['size'] / (1024 ** 2):.0f} MB, {info['tensors']} tensors",
f"format : {info['convention']}"
+ (f" ({info['trainer']})" if info.get("trainer") else ""),
f"dtype : {', '.join(info['dtypes'])}",
f"rank : {'/'.join(str(r) for r in info['ranks']) or '?'}"
+ (" (alpha present)" if info["alpha"] else " (no alpha)"),
f"adaLN : {'/'.join(str(d) for d in info['adaln_dims']) or 'none'}",
f"modules : {', '.join(info['modules'])}",
]
if info.get("pdd_heads"):
lines.append(f"PDD : {info['pdd_heads']} output heads")
if info.get("base_model"):
lines.append(f"base : {info['base_model']}")
if info.get("passenger"):
lines.append(f"passenger : {', '.join(info['passenger'])}")
return ("\n".join(lines),)
NODE_CLASS_MAPPINGS = {
"H3PowerLoraStack": H3PowerLoraStack,
"H3AdalnModality": H3AdalnModality,
"H3LoraSchedule": H3LoraSchedule,
"H3LoraInspector": H3LoraInspector,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"H3PowerLoraStack": "MiniMax H3 Power LoRA Stack",
"H3AdalnModality": "MiniMax H3 adaLN Modality",
"H3LoraSchedule": "MiniMax H3 LoRA Schedule",
"H3LoraInspector": "MiniMax H3 LoRA Inspector",
}