450 lines
19 KiB
Python
450 lines
19 KiB
Python
"""EasyControl image conditioning for ComfyUI — KSampler-compatible.
|
|
|
|
This is a from-scratch reimplementation of anima_lora's EasyControl *inference*
|
|
path, rewritten against ComfyUI's native Anima/Cosmos DiT backbone
|
|
(``comfy/ldm/cosmos/predict2.py``) so it runs under the stock KSampler with no
|
|
dedicated sampler node.
|
|
|
|
Why a reimplementation rather than vendoring anima_lora's network module:
|
|
EasyControl reaches *inside* each DiT block's self-attention (it concatenates a
|
|
reference-image key/value stream into the target's attention). anima_lora's
|
|
training DiT uses a **fused** ``self_attn.qkv_proj`` (D->3D) and an
|
|
``adaln_fused_down`` / ``adaln_up_*`` AdaLN-LoRA topology; ComfyUI's backbone
|
|
uses **split** ``q_proj`` / ``k_proj`` / ``v_proj`` and ``adaln_modulation_*``
|
|
Sequentials. So the in-block splice cannot be reused as-is.
|
|
|
|
Correctness hinge: the trained cond-LoRA delta is a fused ``D->3D`` tensor whose
|
|
three chunks are (q, k, v) in that order — exactly the split layout ComfyUI
|
|
uses. The *base* projection weights are numerically identical between the two
|
|
DiTs (same pretrained Anima model, just fused-vs-split layout — see anima_lora
|
|
``networks/attn_fuse.py``), so adding the sliced fused delta onto ComfyUI's
|
|
split q/k/v projections reproduces what training saw.
|
|
|
|
Mechanism, per denoising step:
|
|
- The reference image is VAE-encoded once (node side) and the cond stream is
|
|
walked once through all blocks (lazily, on the first DiT forward) to produce
|
|
a per-block (cond_k, cond_v) cache. The cond stream is deterministic across
|
|
steps (cond t-embedding at t=0, frozen weights), so this is computed once
|
|
and reused for every step and every CFG branch.
|
|
- Each block's self-attention is replaced by an *extended* attention over
|
|
``[target_k ; cond_k]`` / ``[target_v ; cond_v]`` with a per-block additive
|
|
logit bias ``b_cond`` on the cond columns (the trained gate). Cross-attn and
|
|
MLP run baseline.
|
|
|
|
Robustness: the per-block forward closures resolve their block from
|
|
``state.live_blocks`` (refreshed by the pre-hook from the live diffusion_model
|
|
each forward) rather than capturing a block reference, so a downstream node that
|
|
rebuilds the DiT (e.g. block-compile) doesn't strand the patch on a dead
|
|
instance — same hazard the in-tree Anima nodes document.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections import OrderedDict
|
|
from typing import Optional
|
|
|
|
import torch
|
|
import torch.nn.functional as F
|
|
from einops import rearrange
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ComfyUI backbone helpers. Imported at module load — this node only makes sense
|
|
# inside a ComfyUI install, where these are always importable.
|
|
#
|
|
# Cosmos/Anima rope application. ComfyUI shipped the standalone
|
|
# ``apply_rotary_pos_emb(t, freqs)`` helper from June 2025 until #14181
|
|
# ("Speed up anima a bit on nvidia", in 0.24.0), which deleted the function and
|
|
# inlined its two call sites as ``comfy.quant_ops.ck.apply_rope_split_half(q, k,
|
|
# rope_emb)``. The ``rope_emb`` *format* is unchanged across that refactor — only
|
|
# the helper moved — so we keep a vendored copy of the original (numerically
|
|
# identical, split-half convention) and prefer the in-tree symbol when present.
|
|
try: # ComfyUI < 0.24
|
|
from comfy.ldm.cosmos.predict2 import apply_rotary_pos_emb as _comfy_apply_rope
|
|
except ImportError: # ComfyUI >= 0.24 — standalone helper removed
|
|
_comfy_apply_rope = None
|
|
|
|
|
|
def apply_rotary_pos_emb(t: torch.Tensor, freqs: torch.Tensor) -> torch.Tensor:
|
|
"""Apply cosmos split-half RoPE to a single [B, S, H, D] tensor.
|
|
|
|
Vendored from ComfyUI's pre-0.24 ``comfy.ldm.cosmos.predict2`` so the node
|
|
survives the helper's removal. The newer in-tree path applies rope to q and
|
|
k jointly (``apply_rope_split_half``); this per-tensor form is what the
|
|
extended-attention splice needs (cond q/k are produced separately from the
|
|
target stream), and it consumes the same ``freqs`` layout.
|
|
"""
|
|
if _comfy_apply_rope is not None:
|
|
return _comfy_apply_rope(t, freqs)
|
|
t_ = t.reshape(*t.shape[:-1], 2, -1).movedim(-2, -1).unsqueeze(-2).float()
|
|
t_out = freqs[..., 0] * t_[..., 0] + freqs[..., 1] * t_[..., 1]
|
|
t_out = t_out.movedim(-1, -2).reshape(*t.shape).type_as(t)
|
|
return t_out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Weight loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _load_weights_and_meta(path: str):
|
|
from safetensors import safe_open
|
|
from safetensors.torch import load_file
|
|
|
|
sd = load_file(path)
|
|
meta = {}
|
|
if path.endswith(".safetensors"):
|
|
with safe_open(path, framework="pt") as f:
|
|
meta = f.metadata() or {}
|
|
return sd, meta
|
|
|
|
|
|
class _LoRA:
|
|
"""A single ``D->r->out`` cond-LoRA delta with an fp32 bottleneck.
|
|
|
|
Mirrors anima_lora ``networks/methods/easycontrol.py::_LoRAProj``: the
|
|
bottleneck runs in fp32 for bf16 stability, the ``alpha/r`` scale is folded
|
|
in here, and the caller-side ``cond_scale * strength`` is applied via
|
|
``eff_scale`` at the call site.
|
|
"""
|
|
|
|
__slots__ = ("down", "up", "scale")
|
|
|
|
def __init__(self, down: torch.Tensor, up: torch.Tensor, alpha_over_r: float):
|
|
self.down = down
|
|
self.up = up
|
|
self.scale = alpha_over_r
|
|
|
|
def to(self, device, dtype):
|
|
# Keep master copies in fp32 (the forward casts to fp32 anyway).
|
|
self.down = self.down.to(device=device, dtype=torch.float32)
|
|
self.up = self.up.to(device=device, dtype=torch.float32)
|
|
return self
|
|
|
|
def __call__(self, x: torch.Tensor) -> torch.Tensor:
|
|
h = F.linear(x.float(), self.down)
|
|
h = F.linear(h, self.up)
|
|
return (h * self.scale).to(x.dtype)
|
|
|
|
|
|
class EasyControlState:
|
|
"""Holds the trained cond-LoRA tensors, the reference latent, and the lazily
|
|
built per-block KV cache. One per applied node instance; carried on the
|
|
ModelPatcher via a strong ref so it isn't GC'd."""
|
|
|
|
def __init__(self, sd: dict, meta: dict, strength: float,
|
|
cond_scale_override: Optional[float] = None):
|
|
self.num_blocks = int(meta.get("ss_num_blocks", 28))
|
|
self.r = int(meta.get("ss_cond_lora_dim", 16))
|
|
self.alpha = float(meta.get("ss_cond_lora_alpha", float(self.r)))
|
|
self.apply_ffn = bool(int(meta.get("ss_apply_ffn_lora", 1)))
|
|
cond_scale = (
|
|
float(cond_scale_override)
|
|
if cond_scale_override is not None
|
|
else float(meta.get("ss_cond_scale", 1.0))
|
|
)
|
|
# alpha/r folds into each _LoRA; cond_scale * strength is the call-site
|
|
# effective scale (matches anima_lora get_effective_scale + multiplier).
|
|
a_over_r = self.alpha / self.r if self.r > 0 else 1.0
|
|
self.eff_scale = cond_scale * float(strength)
|
|
|
|
def grab(prefix, idx):
|
|
d = sd.get(f"{prefix}.{idx}.lora_down.weight")
|
|
u = sd.get(f"{prefix}.{idx}.lora_up.weight")
|
|
if d is None or u is None:
|
|
return None
|
|
return _LoRA(d, u, a_over_r)
|
|
|
|
self.lora_qkv = [grab("cond_lora_qkv", i) for i in range(self.num_blocks)]
|
|
self.lora_o = [grab("cond_lora_o", i) for i in range(self.num_blocks)]
|
|
if self.apply_ffn:
|
|
self.lora_ffn1 = [grab("cond_lora_ffn1", i) for i in range(self.num_blocks)]
|
|
self.lora_ffn2 = [grab("cond_lora_ffn2", i) for i in range(self.num_blocks)]
|
|
else:
|
|
self.lora_ffn1 = [None] * self.num_blocks
|
|
self.lora_ffn2 = [None] * self.num_blocks
|
|
|
|
# b_cond stored as a ParameterList → keys "b_cond.{i}" (0-d tensors).
|
|
self.b_cond = [
|
|
sd.get(f"b_cond.{i}", torch.tensor(-10.0)).float().reshape(())
|
|
for i in range(self.num_blocks)
|
|
]
|
|
|
|
if any(q is None for q in self.lora_qkv):
|
|
missing = [i for i, q in enumerate(self.lora_qkv) if q is None]
|
|
raise ValueError(
|
|
f"EasyControl checkpoint missing cond_lora_qkv for blocks {missing}. "
|
|
"Is this an EasyControl (networks.methods.easycontrol) checkpoint?"
|
|
)
|
|
|
|
# Reference latent in *model space* (process_latent_in already applied by
|
|
# the node). 4D [1, C, H, W]; given a device/dtype at prefill time.
|
|
self.cond_latent: Optional[torch.Tensor] = None
|
|
|
|
# Built lazily on first DiT forward.
|
|
self.cond_kv_cache: Optional[list] = None
|
|
self._cache_device = None
|
|
# Refreshed each forward by the pre-hook from the live diffusion_model.
|
|
self.live_blocks = None
|
|
|
|
def _to(self, device, dtype):
|
|
for lst in (self.lora_qkv, self.lora_o, self.lora_ffn1, self.lora_ffn2):
|
|
for m in lst:
|
|
if m is not None:
|
|
m.to(device, dtype)
|
|
self.b_cond = [b.to(device=device) for b in self.b_cond]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cond stream prefill (build per-block KV cache)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _cond_qkv(attn, cond_normed, lora_qkv, eff_scale, rope):
|
|
"""Split-projection cond Q/K/V with the fused LoRA delta sliced into thirds,
|
|
then per-head norm + RoPE. Returns q, k, v as [B, S, H, D]."""
|
|
q = attn.q_proj(cond_normed)
|
|
k = attn.k_proj(cond_normed)
|
|
v = attn.v_proj(cond_normed)
|
|
if lora_qkv is not None:
|
|
dq, dk, dv = (eff_scale * lora_qkv(cond_normed)).chunk(3, dim=-1)
|
|
q = q + dq
|
|
k = k + dk
|
|
v = v + dv
|
|
h, d = attn.n_heads, attn.head_dim
|
|
q = rearrange(q, "b s (h d) -> b s h d", h=h, d=d)
|
|
k = rearrange(k, "b s (h d) -> b s h d", h=h, d=d)
|
|
v = rearrange(v, "b s (h d) -> b s h d", h=h, d=d)
|
|
q = attn.q_norm(q)
|
|
k = attn.k_norm(k)
|
|
v = attn.v_norm(v)
|
|
if rope is not None:
|
|
q = apply_rotary_pos_emb(q, rope)
|
|
k = apply_rotary_pos_emb(k, rope)
|
|
return q, k, v
|
|
|
|
|
|
def _self_attn_bshd(q, k, v):
|
|
"""Plain self-attention on [B, S, H, D] tensors → [B, S, H, D]."""
|
|
qt, kt, vt = (t.transpose(1, 2) for t in (q, k, v)) # [B,H,S,D]
|
|
out = F.scaled_dot_product_attention(qt, kt, vt)
|
|
return out.transpose(1, 2)
|
|
|
|
|
|
@torch.no_grad()
|
|
def _build_cond_kv(dit, state: EasyControlState, device, dtype):
|
|
"""Walk the cond stream once; cache per-block (cond_k, cond_v).
|
|
|
|
The cond stream is self-attention only (cross-attn is dropped on the cond
|
|
side, matching anima_lora's two-stream variant), evolving cond_x block by
|
|
block exactly as anima_lora ``precompute_cond_kv`` does, but against
|
|
ComfyUI's split-projection block API.
|
|
"""
|
|
state._to(device, dtype)
|
|
cond_latent = state.cond_latent.to(device=device, dtype=dtype)
|
|
if cond_latent.ndim == 4:
|
|
cond_latent = cond_latent.unsqueeze(2) # [B,C,1,H,W]
|
|
|
|
cond_x5d, cond_rope_raw, cond_extra = dit.prepare_embedded_sequence(
|
|
cond_latent, fps=None, padding_mask=None
|
|
)
|
|
cond_x = rearrange(cond_x5d, "b t h w d -> b (t h w) d")
|
|
if cond_extra is not None:
|
|
# Anima ships extra_per_block_abs_pos_emb=False, so this is None. If a
|
|
# future config enables it, the per-block add would need mirroring here.
|
|
logger.warning(
|
|
"EasyControl: extra_per_block_abs_pos_emb is set; cond stream does "
|
|
"not replicate it. Results may drift."
|
|
)
|
|
cond_rope = cond_rope_raw.unsqueeze(1).unsqueeze(0) if cond_rope_raw is not None else None
|
|
|
|
# cond t-embedding at t=0 — identical to MiniTrainDIT._forward's t path.
|
|
B = cond_x.shape[0]
|
|
zeros = torch.zeros(B, 1, device=device, dtype=dtype)
|
|
cemb, cadaln = dit.t_embedder[1](dit.t_embedder[0](zeros).to(dtype))
|
|
cemb = dit.t_embedding_norm(cemb)
|
|
|
|
eff = state.eff_scale
|
|
cache = []
|
|
for idx in range(state.num_blocks):
|
|
block = dit.blocks[idx]
|
|
attn = block.self_attn
|
|
|
|
if block.use_adaln_lora:
|
|
s_sa, sc_sa, g_sa = (
|
|
block.adaln_modulation_self_attn(cemb) + cadaln
|
|
).chunk(3, dim=-1)
|
|
s_mlp, sc_mlp, g_mlp = (
|
|
block.adaln_modulation_mlp(cemb) + cadaln
|
|
).chunk(3, dim=-1)
|
|
else:
|
|
s_sa, sc_sa, g_sa = block.adaln_modulation_self_attn(cemb).chunk(3, dim=-1)
|
|
s_mlp, sc_mlp, g_mlp = block.adaln_modulation_mlp(cemb).chunk(3, dim=-1)
|
|
|
|
# ---- self-attn (this is what we cache) ----
|
|
cond_normed = block.layer_norm_self_attn(cond_x) * (1 + sc_sa) + s_sa
|
|
cq, ck, cv = _cond_qkv(attn, cond_normed, state.lora_qkv[idx], eff, cond_rope)
|
|
cache.append((ck.detach(), cv.detach()))
|
|
|
|
# ---- evolve cond_x to feed the next block ----
|
|
attn_out = _self_attn_bshd(cq, ck, cv)
|
|
attn_out = rearrange(attn_out, "b s h d -> b s (h d)")
|
|
proj = attn.output_proj(attn_out)
|
|
if state.lora_o[idx] is not None:
|
|
proj = proj + eff * state.lora_o[idx](attn_out)
|
|
cond_x = cond_x + g_sa * proj
|
|
|
|
# ---- MLP ----
|
|
mlp_normed = block.layer_norm_mlp(cond_x) * (1 + sc_mlp) + s_mlp
|
|
hmid = block.mlp.layer1(mlp_normed)
|
|
if state.lora_ffn1[idx] is not None:
|
|
hmid = hmid + eff * state.lora_ffn1[idx](mlp_normed)
|
|
hmid = block.mlp.activation(hmid)
|
|
mlp_out = block.mlp.layer2(hmid)
|
|
if state.lora_ffn2[idx] is not None:
|
|
mlp_out = mlp_out + eff * state.lora_ffn2[idx](hmid)
|
|
cond_x = cond_x + g_mlp * mlp_out
|
|
|
|
state.cond_kv_cache = cache
|
|
state._cache_device = device
|
|
nbytes = sum(k.numel() + v.numel() for k, v in cache) * cache[0][0].element_size()
|
|
logger.info(
|
|
f"EasyControl: built cond KV cache ({len(cache)} blocks, "
|
|
f"S_c={cache[0][0].shape[1]}, {nbytes / 1e6:.0f} MB)"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Extended self-attention (target keys + cached cond keys, b_cond gate)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _extended_self_attn(tq, tk, tv, ck, cv, b_cond):
|
|
"""Attention of target queries over [target_k; cond_k] with an additive
|
|
``b_cond`` logit bias on the cond columns. All inputs [B, S, H, D] (cond may
|
|
be batch-1 and is broadcast). Returns [B, S, H, D]."""
|
|
B, S = tq.shape[0], tq.shape[1]
|
|
if ck.shape[0] != B:
|
|
ck = ck.expand(B, -1, -1, -1)
|
|
cv = cv.expand(B, -1, -1, -1)
|
|
Sc = ck.shape[1]
|
|
k = torch.cat([tk, ck], dim=1).transpose(1, 2) # [B,H,S+Sc,D]
|
|
v = torch.cat([tv, cv], dim=1).transpose(1, 2)
|
|
q = tq.transpose(1, 2)
|
|
mask = torch.zeros(S + Sc, device=q.device, dtype=q.dtype)
|
|
mask[S:] = b_cond.to(q.dtype)
|
|
out = F.scaled_dot_product_attention(q, k, v, attn_mask=mask.view(1, 1, 1, S + Sc))
|
|
return out.transpose(1, 2) # [B,S,H,D]
|
|
|
|
|
|
def _make_self_attn_forward(state: EasyControlState, idx: int):
|
|
"""Replacement for one block's ``self_attn.forward`` (an ``Attention``).
|
|
|
|
Overriding only the self-attention — not the whole Block — keeps every other
|
|
part of ComfyUI's block native (AdaLN, cross-attn, MLP, residuals, dtype
|
|
handling), so there is nothing to drift and no block-level weights to strand
|
|
(``Attention`` holds no direct parameters; its q/k/v/o projections and norms
|
|
are submodules that keep their own cast-weights forward).
|
|
|
|
Resolves the live ``Attention`` from ``state.live_blocks[idx].self_attn``
|
|
each call, so a downstream DiT rebuild can't strand the patch on a dead
|
|
instance.
|
|
|
|
``torch._dynamo.disable`` keeps this an opaque eager region so the closure
|
|
composes with per-block ``torch.compile`` (e.g. the Anima Block Compile
|
|
node). Without it, dynamo tries to trace the ``state.live_blocks[idx]``
|
|
deref while compiling that very block and re-enters the module tree from a
|
|
non-local source, tripping ``"UnspecializedNNModuleVariable(Block) is
|
|
already tracked for mutation"``. The same live-block indirection that makes
|
|
this patch survive a DiT rebuild is what dynamo can't reconcile, so we just
|
|
graph-break around the extended attention (the rest of the block still
|
|
compiles).
|
|
"""
|
|
|
|
@torch._dynamo.disable
|
|
def forward(x, context=None, rope_emb=None, transformer_options={}):
|
|
attn = state.live_blocks[idx].self_attn
|
|
cache = state.cond_kv_cache
|
|
# Cond not ready, or this somehow ran as cross-attn → native attention.
|
|
if cache is None or context is not None:
|
|
return type(attn).forward(attn, x, context, rope_emb, transformer_options)
|
|
|
|
tq, tk, tv = attn.compute_qkv(x, None, rope_emb=rope_emb) # [B,S,H,D]
|
|
ck, cv = cache[idx]
|
|
out = _extended_self_attn(
|
|
tq, tk, tv, ck.to(tk.dtype), cv.to(tv.dtype), state.b_cond[idx]
|
|
)
|
|
out = rearrange(out, "b s h d -> b s (h d)")
|
|
return attn.output_dropout(attn.output_proj(out))
|
|
|
|
return forward
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pre-hook: lazy cond-KV prefill + live-block refresh
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_prefill_pre_hook(state: EasyControlState):
|
|
def pre_hook(module, args):
|
|
# module = live diffusion_model; args[0] = x (model-space latent).
|
|
state.live_blocks = module.blocks
|
|
x = args[0]
|
|
device, dtype = x.device, x.dtype
|
|
if state.cond_kv_cache is None or state._cache_device != device:
|
|
_build_cond_kv(module, state, device, dtype)
|
|
return None
|
|
|
|
return pre_hook
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public entry point — called by the node
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def install_easycontrol(model, weight_path, cond_latent, strength, cond_scale_override=None):
|
|
"""Install EasyControl onto a cloned ModelPatcher.
|
|
|
|
``cond_latent`` is the VAE-encoded reference in **model space** (the node
|
|
applies ``process_latent_in``), 4D ``[1, C, H, W]``.
|
|
"""
|
|
sd, meta = _load_weights_and_meta(weight_path)
|
|
state = EasyControlState(sd, meta, strength, cond_scale_override)
|
|
state.cond_latent = cond_latent
|
|
|
|
diffusion_model = model.get_model_object("diffusion_model")
|
|
nblocks = len(diffusion_model.blocks)
|
|
if nblocks != state.num_blocks:
|
|
raise ValueError(
|
|
f"EasyControl checkpoint is for {state.num_blocks} blocks but the "
|
|
f"loaded DiT has {nblocks}. Wrong base model?"
|
|
)
|
|
|
|
# Lazy-prefill pre-hook on diffusion_model (object-patched OrderedDict so it
|
|
# reverts on unpatch and composes with other pre-hooks).
|
|
pre_hook = _make_prefill_pre_hook(state)
|
|
new_pre = OrderedDict(diffusion_model._forward_pre_hooks)
|
|
new_pre[id(pre_hook)] = pre_hook
|
|
model.add_object_patch("diffusion_model._forward_pre_hooks", new_pre)
|
|
|
|
# Per-block self-attention replacement (object-patched → path-resolved to
|
|
# the live blocks; reverted on unpatch). Only self_attn is swapped; the rest
|
|
# of each block stays native.
|
|
for idx in range(state.num_blocks):
|
|
model.add_object_patch(
|
|
f"diffusion_model.blocks.{idx}.self_attn.forward",
|
|
_make_self_attn_forward(state, idx),
|
|
)
|
|
|
|
# Keep the state alive for the patcher's lifetime.
|
|
if not hasattr(model, "_easycontrol_states"):
|
|
model._easycontrol_states = []
|
|
model._easycontrol_states.append(state)
|
|
logger.info(
|
|
f"EasyControl: installed ({state.num_blocks} blocks, r={state.r}, "
|
|
f"ffn_lora={state.apply_ffn}, eff_scale={state.eff_scale:.3f})"
|
|
)
|
|
return state
|