darask0's picture
Anima In-Context Character LoRA v1 + ComfyUI nodes + workflow
e545366 verified
Raw
History Blame Contribute Delete
6.88 kB
"""ComfyUI nodes for the Anima style stream (decoupled cross-attention).
Requires a trained AnimaStyleAdapter checkpoint (see style_adapter.py
and the project docs); with an untrained adapter the gates are zero and
the nodes are an exact no-op.
The style stream composes freely with the in-context character stream:
AnimaInContextApply patches self-attention (T-axis reference frames),
AnimaStyleApply patches cross-attention — chain both Apply nodes and
control char/style strength independently.
"""
import os
import torch
import comfy.utils
import folder_paths
from comfy.patcher_extension import WrappersMP
from .style_adapter import AnimaStyleAdapter, StyleState
STYLE_WRAPPER_KEY = "anima_style_ref"
_ADAPTER_DIR = "anima_style_adapters"
if _ADAPTER_DIR not in folder_paths.folder_names_and_paths:
folder_paths.add_model_folder_path(
_ADAPTER_DIR, os.path.join(folder_paths.models_dir, _ADAPTER_DIR)
)
class AnimaStyleAdapterLoader:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"adapter_name": (folder_paths.get_filename_list(_ADAPTER_DIR),),
},
}
RETURN_TYPES = ("ANIMA_STYLE_ADAPTER",)
FUNCTION = "load"
CATEGORY = "anima/style"
def load(self, adapter_name):
path = folder_paths.get_full_path_or_raise(_ADAPTER_DIR, adapter_name)
sd = comfy.utils.load_torch_file(path, safe_load=True)
adapter = AnimaStyleAdapter.from_state_dict(sd)
adapter.eval()
return (adapter,)
class AnimaStyleEncode:
"""Encode style reference image(s) with a SigLIP CLIP_VISION model,
keeping the hidden-state stack the adapter aggregates over.
Multiple images are encoded independently and their patch tokens are
concatenated at apply time (attention pools over all of them)."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"clip_vision": ("CLIP_VISION",),
"image": ("IMAGE",),
},
}
RETURN_TYPES = ("ANIMA_STYLE_EMBEDS",)
FUNCTION = "encode"
CATEGORY = "anima/style"
def encode(self, clip_vision, image):
out = clip_vision.encode_image(image)
hs = out["all_hidden_states"] # (B, L, N, D)
if hs is None:
raise RuntimeError(
"this CLIP_VISION model does not expose all hidden states; "
"use a SigLIP vision model"
)
return ({"hidden_states": hs},)
class AnimaStyleApply:
"""Attach the style stream to an Anima model.
style_weight: global multiplier on the (learned, per-block) gates.
cond_only: apply style only to the cond half of the CFG batch.
start/end_percent: sampling window, same semantics as the
in-context Apply node.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"style_adapter": ("ANIMA_STYLE_ADAPTER",),
"style_embeds": ("ANIMA_STYLE_EMBEDS",),
"style_weight": ("FLOAT", {"default": 1.0, "min": -2.0, "max": 5.0, "step": 0.05}),
"start_percent": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001}),
"end_percent": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.001}),
},
"optional": {
"cond_only": ("BOOLEAN", {"default": True}),
},
}
RETURN_TYPES = ("MODEL",)
FUNCTION = "apply"
CATEGORY = "anima/style"
def apply(self, model, style_adapter, style_embeds, style_weight,
start_percent, end_percent, cond_only=True):
m = model.clone()
ms = m.get_model_object("model_sampling")
sigma_start = ms.percent_to_sigma(start_percent)
sigma_end = ms.percent_to_sigma(end_percent)
dm = m.get_model_object("diffusion_model")
n_layers = style_adapter.config["n_layers"]
hidden_states = style_embeds["hidden_states"][:, -n_layers:] # (B, K, N, D)
state = StyleState()
# per-(device, dtype) cache of the per-block K/V — the style
# tokens are constant across sampling steps
kv_cache = {}
def compute_kv(device, dtype):
key = (device, dtype)
if key not in kv_cache:
adapter = style_adapter.to(device)
hs = hidden_states.to(device=device, dtype=next(adapter.parameters()).dtype)
with torch.no_grad():
tokens = adapter.aggregator(hs) # (B, N, style_dim)
# multiple style images -> one long token sequence
tokens = tokens.reshape(1, -1, tokens.shape[-1])
kv_cache[key] = [
tuple(t.to(dtype) for t in blk.kv(tokens)) for blk in adapter.blocks
]
return kv_cache[key]
def wrapper(executor, x, timesteps, context, fps=None, padding_mask=None, **kwargs):
to = kwargs.get("transformer_options", {})
sigmas = to.get("sigmas", None)
if sigmas is not None:
s = float(sigmas.max())
if s > sigma_start or s < sigma_end:
return executor(x, timesteps, context, fps, padding_mask, **kwargs)
state.kv_per_block = compute_kv(x.device, x.dtype)
state.weight = style_weight
state.sample_scale_B = None
cou = to.get("cond_or_uncond", None)
if cond_only and cou and x.shape[0] % len(cou) == 0:
chunk = x.shape[0] // len(cou)
scale = torch.ones(x.shape[0], device=x.device)
for i, kind in enumerate(cou):
if kind == 1:
scale[i * chunk:(i + 1) * chunk] = 0.0
state.sample_scale_B = scale
state.active = True
try:
return executor(x, timesteps, context, fps, padding_mask, **kwargs)
finally:
state.active = False
m.add_wrapper_with_key(WrappersMP.DIFFUSION_MODEL, STYLE_WRAPPER_KEY, wrapper)
from .style_adapter import StyleCrossAttention
for i, block in enumerate(dm.blocks):
m.add_object_patch(
"diffusion_model.blocks.{}.cross_attn".format(i),
StyleCrossAttention(block.cross_attn, style_adapter.blocks[i], state, i),
)
return (m,)
NODE_CLASS_MAPPINGS = {
"AnimaStyleAdapterLoader": AnimaStyleAdapterLoader,
"AnimaStyleEncode": AnimaStyleEncode,
"AnimaStyleApply": AnimaStyleApply,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AnimaStyleAdapterLoader": "Anima Style Adapter Loader",
"AnimaStyleEncode": "Anima Style Encode (SigLIP)",
"AnimaStyleApply": "Anima Style Apply",
}