Files
doggeddalle 4ef4de0a5d Robust adaln, pdd, keymap, modality fixes
Multiple improvements and hardening across the Power LoRA stack:

- adaln: stronger caching (file-version keys), device-safe grid/basis computation, normalized tensor hashing for basis cache, more robust table-to-table rebasing with residual checks and fallback, better detection of adaLN weight name variants.
- apply: validate numeric strengths, reject unsupported passengers, handle loading errors, detect shape mismatches, track rejected/skipped/quantized merges, improved PDD handoff and reporting.
- pdd: introduce movable PddBank/PddState, register banks as additional models, thread-local PDD context, strength-scheduled blending and safer interval clamping.
- keymap/detect/gain/modality/nodes/branch: add support for varied LoRA key spellings, more validation, skip incomplete adapters, and small bugfixes.
- server: surface partial calibration note.
- pyproject/README/.gitignore: packaging, docs, and ignore cleanup; add Python >=3.10 requirement and publisher repo fix.
- web UI: fix balance button behavior, safer auto-balance error handling and factor sanitization.
- tests: add unit tests for adaln, modality and pdd.

Overall: better robustness to varied LoRA formats, mixed devices, malformed inputs, and improved user-facing error/reporting.
2026-08-28 02:22:53 +10:00

167 lines
6.5 KiB
Python

"""Per-modality scaling of adaLN LoRA rows.
MiniMax H3 is not built like LTX 2.3. LTX duplicates the tower per modality
(``audio_attn``, ``audio_ff``, ``audio_patchify_proj``, ``audio_to_video_attn``),
so a LoRA can be steered by picking layers. H3 packs audio and video into one
token sequence and pushes both through the same 50 blocks, and the only
modality-specific weights in the whole checkpoint are four tensors --
``video_patch_proj``, ``audio_patch_proj``, ``final_layer.video_out``,
``final_layer.audio_out`` -- none of which any observed LoRA touches.
One place does separate cleanly: **adaLN**. ``AdalnProj.forward`` computes
``linear(t) -> [M, expand*hidden*modalities]`` and then
``view(M*modalities, expand*hidden)``, so output feature ``j`` belongs to
modality ``j // (expand*hidden)``. The 96768 rows are three contiguous blocks
of 32256, and ``comfy/ldm/minimax/model.py`` tags segments
``{video: 0, text: 1, audio: 2}`` (``seg_tag``, and ``row_base + tag`` when the
modulation rows are built).
Scaling a slice of ``lora_B``'s rows therefore scales that modality's modulation
exactly, with no runtime hook. It is a property of the trained weights rather
than of ComfyUI: any LoRA that loads onto ``adaln_proj.linear`` at all must have
its rows in this order, so this holds for future adapters by construction --
independent of rank, alpha, trainer convention, and of whether the adaLN basis
is dense (2688) or curve (8), which only affects the ``A`` side.
Two things this deliberately does not do:
* The geometry is read off the model's own ``AdalnProj`` rather than hardcoded,
and every layer is shape-checked before it is touched. A future H3 variant
with different geometry silently keeps today's behaviour instead of slicing
at the wrong offsets.
* ``final_layer.adaln_proj`` is ``AdalnProj(t_dim, hidden, 2, 1)`` -- one
modality, differentiated only by timestep -- so it has 10752 rows, fails the
shape check, and is left alone.
It is also worth being clear about the ceiling: attention is joint over the
packed sequence, so damping the video modality changes where the LoRA is applied,
not everything it eventually reaches.
"""
from __future__ import annotations
import logging
import math
import torch
LOG = logging.getLogger("h3.powerlorastack")
# Index is the modality tag from comfy/ldm/minimax/model.py ``seg_tag``.
TAGS = ("video", "text", "audio")
# lora_B spellings; ``.diff`` is a full-weight replacement, which slices the same
# way. ``.set_weight`` is an absolute value, not a delta, so it is never scaled.
_ROW_KEYS = (
".lora_B.weight", ".lora_B.default.weight", ".lora_B",
".lora_up.weight", "_lora.up.weight", ".lora.up.weight",
".lora_linear_layer.up.weight", ".diff", ".diff_b",
)
_ADALN_SUFFIX = "adaln_proj.linear"
def geometry(diffusion_model):
"""``(expand, modalities, hidden)`` read off the model's own AdalnProj.
Returns ``None`` when the model does not look like an H3 whose adaLN splits
into the three tags this module knows how to name.
"""
blocks = getattr(diffusion_model, "blocks", None)
if not blocks or len(blocks) == 0:
return None
proj = getattr(blocks[0], "adaln_proj", None)
if proj is None:
return None
try:
expand = int(proj.expand)
modalities = int(proj.modalities)
hidden = int(proj.hidden)
except (AttributeError, TypeError, ValueError):
return None
if modalities != len(TAGS) or expand <= 0 or hidden <= 0:
LOG.warning("H3 PowerLoraStack: adaLN has %d modalities, expected %d - "
"modality control disabled", modalities, len(TAGS))
return None
return expand, modalities, hidden
def normalize_scales(scales) -> tuple:
"""``{'video':..,'text':..,'audio':..}`` -> a tuple in tag order."""
if scales is None:
return None
if isinstance(scales, dict):
values = tuple(float(scales.get(tag, 1.0)) for tag in TAGS)
else:
values = tuple(float(v) for v in scales)
if len(values) != len(TAGS):
return None
if not all(math.isfinite(value) for value in values):
raise ValueError("modality scales must be finite")
return values
def is_identity(values) -> bool:
return values is None or all(v == 1.0 for v in values)
def _scale_rows(tensor, values, block):
out = tensor.to(torch.float32).clone()
for i, scale in enumerate(values):
if scale != 1.0:
out[i * block:(i + 1) * block] *= scale
return out.to(tensor.dtype)
def apply_to_state_dict(sd: dict, values, geom):
"""Scale every block-level adaLN ``lora_B`` in ``sd`` by modality.
Must run **before** :func:`h3lora.adaln.port_adaln_pairs`: the port derives
its bias delta as ``B @ const``, so scaling B's rows first carries through to
the emitted ``.diff_b`` with no extra work.
Returns ``(new_sd, stats)``; ``sd`` is not mutated.
"""
stats = {"scaled": 0, "mismatched": 0, "present": 0}
if is_identity(values) or geom is None:
return sd, stats
expand, modalities, hidden = geom
rows = expand * modalities * hidden
block = expand * hidden
out = dict(sd)
for key, tensor in sd.items():
suffix = next((s for s in _ROW_KEYS if key.endswith(s)), None)
if suffix is None:
continue
module = key[: -len(suffix)]
if not module.endswith(_ADALN_SUFFIX):
continue
stats["present"] += 1
shape = getattr(tensor, "shape", None)
is_bias = suffix == ".diff_b"
expected_rank = 1 if is_bias else 2
if shape is None or len(shape) != expected_rank or shape[0] != rows:
# final_layer.adaln_proj (one modality) and any future geometry land
# here and are left exactly as they were
stats["mismatched"] += 1
continue
out[key] = _scale_rows(tensor, values, block)
stats["scaled"] += 1
return out, stats
def describe(values, stats) -> str:
"""One-line account for the node's report, including when nothing happened."""
if is_identity(values):
return ""
setting = "/".join(f"{v:g}" for v in values)
if stats["scaled"]:
note = f", adaLN modality {'/'.join(TAGS)} = {setting} x{stats['scaled']}"
if stats["mismatched"]:
note += f" ({stats['mismatched']} unsplittable)"
return note
if stats["present"]:
return f", adaLN modality {setting} INACTIVE (no layer matched the split)"
return f", adaLN modality {setting} INACTIVE (LoRA has no adaLN pairs)"