diff --git a/README.md b/README.md index 82b9d79..6f78ea4 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,44 @@ The package requires Python 3.10 or newer and a ComfyUI revision providing the MiniMax H3 model, `QuantizedTensor`, weight adapters, and patcher wrappers used by this node. +
+(Funny Find) + +• I found overlap, but the evidence does not establish that PlagueKind took the AdaLN fix from your PowerLoraStack. The strongest code matches actually appear earlier in their Git history. + +I compared both local repositories, historical versions, tests, and attribution, and verified that the original AdaLN files match their public GitHub snapshots. + +| Date (2026) | Evidence | +| :--- | :--- | +| August 8 | Your commit 28ac439 introduces AdaLN conversion, including least-squares fitting, reverse conversion, and bias correction. | +| August 20 | Their commit 7e5743f imports the standalone fix, explicitly credited “by pl0x.” | +| August 27 | Your commit f38d084 introduces the helpers that most closely match their code. | + +### What matches + +Two current helpers have identical Python syntax trees after removing docstrings—meaning their executable implementation matches despite differences in comments: + +- `_safetensors_header`: yours (`h3lora/adaln.py:155`) / theirs (`C:/comfycli/custom_nodes/ComfyUI-PlagueKind-Nodes/ComfyUI-H3-AdaLN-LoRA-Fix/adaln.py:205`). +- `_time_embedder_prefix`: yours (`h3lora/adaln.py:166`) / theirs (`C:/comfycli/custom_nodes/ComfyUI-PlagueKind-Nodes/ComfyUI-H3-AdaLN-LoRA-Fix/adaln.py:217`). + +Hashing and time-embedding grid helpers also closely resemble each other. These helpers existed in their August 20 import before entering your repository on August 27. That chronology cannot support them copying those helpers from your published version. It also does not establish who originally wrote them. + +### What differs + +Your earlier implementation converts LoRA state-dictionary entries before loading, using `AdalnContext`, `port_adaln_pairs`, and `.diff_b`. Their implementation repairs already-attached model patches through a separate node. + +Comparing your last committed version before their import against all five imported Python files, I found no substantive matching sequence of three or more consecutive nonblank lines after trimming whitespace; matches were import boilerplate. This does not rule out a rewrite or conceptual influence, but it weakens a direct-copy claim. + +The shared grid filename also appears in another public H3 project, so that filename alone is not evidence of copying yours. Example + +### Attribution and conclusion + +I found no credit to you or PowerLoraStack in their current README or fix files. Their import credits “pl0x”; the repository declares MIT, while yours declares Apache-2.0. Those facts alone do not establish a licensing violation. + +Your repository records the core solution earlier. Possible influence remains unproven; direct copying from your original implementation is not demonstrated. The most useful next evidence would be the original package supplied by “pl0x” and the development context behind your August 27 changes. Git dates establish recorded chronology, not private authorship or first publication. + +
+ ## Development checks From the ComfyUI root, make the package and ComfyUI import roots visible before diff --git a/__init__.py b/__init__.py index dbd4339..d4e5755 100644 --- a/__init__.py +++ b/__init__.py @@ -2,9 +2,11 @@ from .h3lora import server as _server from .h3lora.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS +from .h3lora.sidecars import ensure_load_hook WEB_DIRECTORY = "./web" _server.register() # /h3_power_lora_stack/balance, for the auto-balance button +ensure_load_hook() # peel adaln_basis/adaln_mean before Comfy drops them __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/h3lora/apply.py b/h3lora/apply.py index c17c0da..3892051 100644 --- a/h3lora/apply.py +++ b/h3lora/apply.py @@ -20,8 +20,10 @@ from . import keymap from . import modality as modality_mod from . import pdd as pdd_mod from . import schedule as schedule_mod +from . import sidecars as sidecar_mod LOG = logging.getLogger("h3.powerlorastack") +sidecar_mod.ensure_load_hook() # Layers whose forward is bypassed by a fused kernel, per weight layout. A # runtime branch on these would be silently dropped, so they must merge. @@ -135,11 +137,9 @@ def apply_stack(model, entries, mode="auto", adaln_mode="auto", grid_path="", else: if not grid_path: grid_path = adaln_mod.find_silu_grid() - cfg = getattr(getattr(patcher, "model", None), "model_config", None) - sidecars = getattr(cfg, "adaln_sidecars", None) if cfg is not None else None adaln_ctx = adaln_mod.AdalnContext( target_dim, table, grid_path, - sidecars=sidecars, + sidecars=sidecar_mod.collect(patcher, diffusion_model), time_embedder=getattr(diffusion_model, "time_embedder", None), ) diff --git a/h3lora/sidecars.py b/h3lora/sidecars.py new file mode 100644 index 0000000..89f57cd --- /dev/null +++ b/h3lora/sidecars.py @@ -0,0 +1,203 @@ +"""Capture MiniMax H3 adaLN sidecar tensors without touching native ComfyUI. + +Curve H3 checkpoints often bake ``adaln_basis`` + ``adaln_mean`` (or a +``silu_t_emb_grid``) next to the DiT weights. ComfyUI does not register those +keys on the UNet, so ``load_model_weights`` logs them as unexpected and drops +them. The Power LoRA Stack needs them to port dense-trained adaLN pairs onto +the loaded curve basis. + +We never read missing attributes off Comfy's model-config object: its +``__getattr__`` warns ``Please fix your code`` instead of raising. Sidecars are +peeled from the checkpoint state dict (via a ``BaseModel.load_model_weights`` +wrapper installed by this pack) and stashed on the H3 model instance. +""" + +from __future__ import annotations + +import logging + +import torch + +LOG = logging.getLogger("h3.powerlorastack") + +SIDECAR_NAMES = ("adaln_basis", "adaln_mean", "silu_t_emb_grid") +STASH_ATTR = "_h3_adaln_sidecars" +PATCHER_ATTACHMENT = "h3_adaln_sidecars" + + +def _as_mapping(value): + return value if isinstance(value, dict) else None + + +def _instance_dict(obj): + try: + return vars(obj) + except TypeError: + return None + + +def unet_config_of(model): + """``unet_config`` from a BaseModel, never via model-config ``__getattr__``.""" + cfg = getattr(model, "model_config", None) + data = _instance_dict(cfg) if cfg is not None else None + if not data: + return None + unet = data.get("unet_config") + return unet if isinstance(unet, dict) else None + + +def is_h3_base(model) -> bool: + if model is None: + return False + if type(model).__name__ == "MiniMaxH3": + return True + diffusion = getattr(model, "diffusion_model", None) + if diffusion is not None and type(diffusion).__name__ == "MiniMaxH3Model": + return True + unet = unet_config_of(model) + return bool(unet) and unet.get("image_model") == "minimax_h3" + + +def _key_for(sd, prefix: str, name: str): + prefix = prefix or "" + for key in (prefix + name if prefix else None, name, + None if prefix else "diffusion_model." + name): + if key and key in sd: + return key + return None + + +def peel(sd, prefix: str = "") -> dict: + """Pop H3 adaLN sidecar tensors out of a checkpoint state dict. + + Mutates ``sd``. Non-tensor values are ignored and left in place. + """ + if not isinstance(sd, dict) or not sd: + return {} + found = {} + for name in SIDECAR_NAMES: + key = _key_for(sd, prefix, name) + if key is None: + continue + value = sd[key] + if torch.is_tensor(value): + found[name] = sd.pop(key) + return found + + +def attach(model, sidecars: dict): + """Stash sidecars on the H3 BaseModel and its DiT. Survives ModelPatcher.clone.""" + if not sidecars or model is None: + return + existing = _as_mapping(getattr(model, STASH_ATTR, None)) or {} + merged = {**existing, **sidecars} + try: + object.__setattr__(model, STASH_ATTR, merged) + except Exception: + pass + diffusion = getattr(model, "diffusion_model", None) + if diffusion is not None and diffusion is not model: + try: + object.__setattr__(diffusion, STASH_ATTR, merged) + except Exception: + pass + patcher = getattr(model, "current_patcher", None) + setter = getattr(patcher, "set_attachments", None) if patcher is not None else None + if callable(setter): + try: + setter(PATCHER_ATTACHMENT, merged) + except Exception: + pass + LOG.info("H3 PowerLoraStack: captured checkpoint adaLN sidecars %s", + ", ".join(sorted(merged))) + + +def _take_named_tensors(obj, found: dict): + extra = _as_mapping(getattr(obj, STASH_ATTR, None)) + if extra: + found.update(extra) + for name in SIDECAR_NAMES: + try: + value = object.__getattribute__(obj, name) + except AttributeError: + continue + if torch.is_tensor(value): + found.setdefault(name, value) + + +def collect(patcher=None, diffusion_model=None) -> dict | None: + """Gather baked adaLN sidecars without touching model-config ``__getattr__``.""" + found: dict = {} + objects = [] + model = None + if patcher is not None: + objects.append(patcher) + getter = getattr(patcher, "get_attachment", None) + if callable(getter): + extra = _as_mapping(getter(PATCHER_ATTACHMENT)) + if extra: + found.update(extra) + model = getattr(patcher, "model", None) + if model is not None: + objects.append(model) + cfg = getattr(model, "model_config", None) + data = _instance_dict(cfg) if cfg is not None else None + if data: + extra = _as_mapping(data.get("adaln_sidecars")) + if extra: + found.update(extra) + if diffusion_model is None: + diffusion_model = getattr(model, "diffusion_model", None) + if diffusion_model is not None: + objects.append(diffusion_model) + for obj in objects: + if obj is None: + continue + _take_named_tensors(obj, found) + return found or None + + +def wrap_load_model_weights(cls) -> bool: + """Wrap ``cls.load_model_weights`` so H3 sidecar keys never become unexpected.""" + orig = getattr(cls, "load_model_weights", None) + if not callable(orig) or getattr(orig, "_h3_adaln_hook", False): + return bool(getattr(orig, "_h3_adaln_hook", False)) + + def wrapped(self, sd, *args, **kwargs): + prefix = kwargs["unet_prefix"] if "unet_prefix" in kwargs else ( + args[0] if args else "") + if isinstance(sd, dict) and is_h3_base(self): + found = peel(sd, prefix) + if found: + attach(self, found) + return orig(self, sd, *args, **kwargs) + + wrapped._h3_adaln_hook = True + wrapped._h3_adaln_orig = orig + cls.load_model_weights = wrapped + return True + + +def ensure_load_hook() -> bool: + """Install the loader wrap on Comfy's BaseModel. No-op if Comfy is absent. + + Also wraps ``MiniMaxH3`` when that subclass defines its own + ``load_model_weights``, so a native override cannot bypass the peel. + """ + try: + from comfy import model_base + except Exception: + return False + installed = False + for name in ("BaseModel", "MiniMaxH3"): + cls = getattr(model_base, name, None) + if cls is None: + continue + if name != "BaseModel" and "load_model_weights" not in vars(cls): + continue + try: + installed = wrap_load_model_weights(cls) or installed + except Exception: + LOG.debug("H3 PowerLoraStack: could not wrap %s.load_model_weights", + name, exc_info=True) + return installed diff --git a/tests/test_sidecars.py b/tests/test_sidecars.py new file mode 100644 index 0000000..3ac8344 --- /dev/null +++ b/tests/test_sidecars.py @@ -0,0 +1,162 @@ +import unittest +from types import SimpleNamespace + +import torch + +from h3lora.sidecars import ( + STASH_ATTR, + attach, + collect, + is_h3_base, + peel, + wrap_load_model_weights, +) + + +class _WarnConfig: + """Mimics Comfy ``supported_models_base.BASE.__getattr__``.""" + + def __init__(self, unet_config=None, sidecars=None): + self.unet_config = unet_config or {"image_model": "minimax_h3"} + if sidecars is not None: + self.adaln_sidecars = sidecars + self.accessed = [] + + def __getattr__(self, name): + self.accessed.append(name) + raise AssertionError( + "model config __getattr__(%r) must not be used" % (name,) + ) + + +class _H3Base: + def __init__(self, config=None, leftover_capture=None): + self.model_config = config or _WarnConfig() + self.diffusion_model = SimpleNamespace() + self.leftover_capture = leftover_capture if leftover_capture is not None else [] + self.current_patcher = None + + def load_model_weights(self, sd, unet_prefix="", assign=False): + self.leftover_capture.extend( + k[len(unet_prefix):] for k in list(sd) if k.startswith(unet_prefix) + ) + return self + + +class SidecarPeelTests(unittest.TestCase): + def test_peel_pops_prefixed_tensors(self): + basis = torch.randn(8, 3) + mean = torch.randn(8) + sd = { + "diffusion_model.adaln_basis": basis, + "diffusion_model.adaln_mean": mean, + "diffusion_model.adaln_t_table": torch.randn(16, 3), + "diffusion_model.blocks.0.weight": torch.randn(2, 2), + } + got = peel(sd, "diffusion_model.") + self.assertTrue(torch.equal(got["adaln_basis"], basis)) + self.assertTrue(torch.equal(got["adaln_mean"], mean)) + self.assertNotIn("silu_t_emb_grid", got) + self.assertEqual(sorted(sd), [ + "diffusion_model.adaln_t_table", + "diffusion_model.blocks.0.weight", + ]) + + def test_peel_bare_keys_and_grid(self): + grid = torch.randn(4, 8) + sd = {"adaln_basis": torch.randn(8, 2), "silu_t_emb_grid": grid} + got = peel(sd, "") + self.assertIn("adaln_basis", got) + self.assertTrue(torch.equal(got["silu_t_emb_grid"], grid)) + self.assertFalse(sd) + + def test_peel_ignores_non_tensors(self): + sd = {"adaln_basis": "not-a-tensor", "adaln_mean": torch.randn(3)} + got = peel(sd) + self.assertEqual(list(got), ["adaln_mean"]) + self.assertIn("adaln_basis", sd) + + +class SidecarCollectTests(unittest.TestCase): + def test_collect_uses_stashed_model_attr(self): + basis = torch.randn(8, 3) + mean = torch.randn(8) + model = _H3Base() + attach(model, {"adaln_basis": basis, "adaln_mean": mean}) + patcher = SimpleNamespace(model=model, get_attachment=lambda key: None) + got = collect(patcher, model.diffusion_model) + self.assertTrue(torch.equal(got["adaln_basis"], basis)) + self.assertTrue(torch.equal(got["adaln_mean"], mean)) + self.assertEqual(model.model_config.accessed, []) + + def test_collect_reads_config_dict_without_getattr(self): + sidecars = {"adaln_basis": torch.randn(4, 2), "adaln_mean": torch.randn(4)} + cfg = _WarnConfig(sidecars=sidecars) + model = SimpleNamespace(model_config=cfg, diffusion_model=SimpleNamespace()) + patcher = SimpleNamespace(model=model, get_attachment=lambda key: None) + got = collect(patcher) + self.assertTrue(torch.equal(got["adaln_basis"], sidecars["adaln_basis"])) + self.assertEqual(cfg.accessed, []) + + def test_collect_skips_missing_config_attr(self): + cfg = _WarnConfig() + model = SimpleNamespace(model_config=cfg, diffusion_model=SimpleNamespace()) + patcher = SimpleNamespace(model=model) + self.assertIsNone(collect(patcher)) + self.assertEqual(cfg.accessed, []) + + +class SidecarHookTests(unittest.TestCase): + def test_is_h3_from_unet_config_without_getattr(self): + model = _H3Base() + self.assertTrue(is_h3_base(model)) + self.assertEqual(model.model_config.accessed, []) + other = SimpleNamespace( + model_config=_WarnConfig({"image_model": "flux"}), + diffusion_model=SimpleNamespace(), + ) + self.assertFalse(is_h3_base(other)) + self.assertEqual(other.model_config.accessed, []) + + def test_hook_peels_before_load_and_is_idempotent(self): + leftover = [] + wrap_load_model_weights(_H3Base) + wrap_load_model_weights(_H3Base) + model = _H3Base(leftover_capture=leftover) + basis = torch.randn(8, 3) + mean = torch.randn(8) + sd = { + "adaln_basis": basis, + "adaln_mean": mean, + "blocks.0.weight": torch.randn(2, 2), + } + model.load_model_weights(sd, unet_prefix="") + self.assertEqual(leftover, ["blocks.0.weight"]) + self.assertNotIn("adaln_basis", sd) + stashed = getattr(model, STASH_ATTR) + self.assertTrue(torch.equal(stashed["adaln_basis"], basis)) + self.assertTrue(torch.equal(stashed["adaln_mean"], mean)) + self.assertTrue(torch.equal( + getattr(model.diffusion_model, STASH_ATTR)["adaln_basis"], basis)) + self.assertEqual(model.model_config.accessed, []) + + def test_hook_leaves_non_h3_state_dict_alone(self): + class Other: + def __init__(self): + self.model_config = _WarnConfig({"image_model": "flux"}) + self.seen = None + + def load_model_weights(self, sd, unet_prefix="", assign=False): + self.seen = dict(sd) + return self + + wrap_load_model_weights(Other) + model = Other() + sd = {"adaln_basis": torch.randn(2, 2), "w": torch.randn(1)} + model.load_model_weights(sd) + self.assertIn("adaln_basis", model.seen) + self.assertFalse(hasattr(model, STASH_ATTR)) + + +if __name__ == "__main__": + unittest.main()