2215 lines
96 KiB
Python
2215 lines
96 KiB
Python
"""LTXV Edit Anything — unified conditioning node for video_to_video_ref_adaln LoRAs.
|
|
|
|
Single node that applies all 3 conditioning signals from one checkpoint:
|
|
1. IC-LoRA sequence — ref image + guide video appended as clean conditioning frames
|
|
2. Role embedding — learned 128-dim fingerprint added to ref image tokens so
|
|
the model associates @reference with the right appearance
|
|
3. AdaLN conditioning — pooled ref image projected to every transformer block's
|
|
timestep bias, giving a persistent global appearance anchor
|
|
|
|
Usage in ComfyUI:
|
|
Load LoRA (standard) → LTXV Edit Anything Apply → KSampler
|
|
"""
|
|
|
|
import types
|
|
import logging
|
|
import os
|
|
|
|
import torch
|
|
import torch.nn.functional as F
|
|
import folder_paths
|
|
from safetensors.torch import load_file, save_file
|
|
|
|
logger = logging.getLogger("ltxv.editanything")
|
|
|
|
# ── transformer_options keys (must not collide with other nodes) ──────────────
|
|
_KW_ROLE_WEIGHT = "ea_role_weight"
|
|
_KW_APP_LATENTS = "ea_app_latents"
|
|
_KW_ROLE_STRENGTH = "ea_role_strength"
|
|
_KW_ADALN_COND = "ea_adaln_cond"
|
|
|
|
_EA_LOADER_SKIP_PREFIXES = (
|
|
"role_embedding.",
|
|
"_role_embedding.",
|
|
"ref_adaln_proj.",
|
|
"diffusion_model.role_embedding.",
|
|
"diffusion_model.adaln_single.linear.",
|
|
"diffusion_model.audio_adaln_single.linear.",
|
|
)
|
|
|
|
|
|
def _split_editanything_lora_state_dict(sd):
|
|
standard = {}
|
|
module = {}
|
|
for key, value in sd.items():
|
|
if key.startswith(_EA_LOADER_SKIP_PREFIXES):
|
|
module[key] = value
|
|
else:
|
|
standard[key] = value
|
|
return standard, module
|
|
|
|
|
|
def _filter_editanything_lora_for_standard_loader(sd):
|
|
"""Remove Edit Anything side-channel weights before ComfyUI's LoRA merger."""
|
|
filtered, module = _split_editanything_lora_state_dict(sd)
|
|
return filtered, list(module.keys())
|
|
|
|
|
|
class LTXVEditAnythingSplitLora:
|
|
"""Split an Edit Anything checkpoint into standard LoRA and sidecar module."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"lora_name": (folder_paths.get_filename_list("loras"), {
|
|
"tooltip": "Original Edit Anything LoRA checkpoint to split.",
|
|
}),
|
|
"overwrite": ("BOOLEAN", {"default": False}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING")
|
|
RETURN_NAMES = ("standard_lora_name", "editanything_module_name")
|
|
FUNCTION = "split"
|
|
CATEGORY = "LTXV/EditAnything"
|
|
|
|
def split(self, lora_name, overwrite):
|
|
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
|
sd = load_file(lora_path)
|
|
standard, module = _split_editanything_lora_state_dict(sd)
|
|
|
|
root, ext = os.path.splitext(lora_path)
|
|
standard_path = f"{root}.standard{ext}"
|
|
module_path = f"{root}.editanything_module{ext}"
|
|
|
|
if not overwrite:
|
|
for path in (standard_path, module_path):
|
|
if os.path.exists(path):
|
|
raise FileExistsError(f"{path} already exists. Enable overwrite to regenerate it.")
|
|
|
|
save_file(standard, standard_path)
|
|
save_file(module, module_path)
|
|
|
|
lora_root = folder_paths.get_folder_paths("loras")[0]
|
|
standard_name = os.path.relpath(standard_path, lora_root)
|
|
module_name = os.path.relpath(module_path, lora_root)
|
|
logger.info(
|
|
"Split Edit Anything LoRA into %s (%d keys) and %s (%d keys).",
|
|
standard_name,
|
|
len(standard),
|
|
module_name,
|
|
len(module),
|
|
)
|
|
return (standard_name, module_name)
|
|
|
|
|
|
class LTXVEditAnythingModuleLoader:
|
|
"""Load the sidecar module generated by LTXV Edit Anything Split LoRA."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"module_name": (folder_paths.get_filename_list("loras"), {
|
|
"tooltip": "The .editanything_module.safetensors sidecar file.",
|
|
}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("LTXV_EA_MODULE",)
|
|
RETURN_NAMES = ("editanything_module",)
|
|
FUNCTION = "load"
|
|
CATEGORY = "LTXV/EditAnything"
|
|
|
|
def load(self, module_name):
|
|
module_path = folder_paths.get_full_path_or_raise("loras", module_name)
|
|
return ({"path": module_path, "state_dict": load_file(module_path)},)
|
|
|
|
|
|
class LTXVEditAnythingLoraLoader:
|
|
"""LoRA loader for Edit Anything checkpoints."""
|
|
|
|
def __init__(self):
|
|
self.loaded_lora = None
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"lora_name": (folder_paths.get_filename_list("loras"), {
|
|
"tooltip": "Edit Anything LoRA checkpoint.",
|
|
}),
|
|
"strength_model": ("FLOAT", {
|
|
"default": 1.0,
|
|
"min": -100.0,
|
|
"max": 100.0,
|
|
"step": 0.01,
|
|
}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("MODEL",)
|
|
RETURN_NAMES = ("model",)
|
|
FUNCTION = "load_lora"
|
|
CATEGORY = "LTXV/EditAnything"
|
|
|
|
def load_lora(self, model, lora_name, strength_model):
|
|
if strength_model == 0:
|
|
return (model,)
|
|
|
|
import comfy.sd
|
|
import comfy.utils
|
|
|
|
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
|
lora = None
|
|
if self.loaded_lora is not None:
|
|
if self.loaded_lora[0] == lora_path:
|
|
lora = self.loaded_lora[1]
|
|
else:
|
|
self.loaded_lora = None
|
|
|
|
if lora is None:
|
|
raw_lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
|
|
lora, skipped = _filter_editanything_lora_for_standard_loader(raw_lora)
|
|
self.loaded_lora = (lora_path, lora)
|
|
if skipped:
|
|
logger.info(
|
|
"LTXVEditAnythingLoraLoader skipped %d Edit Anything/AdaLN keys before standard LoRA load.",
|
|
len(skipped),
|
|
)
|
|
|
|
model_lora, _ = comfy.sd.load_lora_for_models(model, None, lora, strength_model, 0)
|
|
return (model_lora,)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Model finder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _find_ltxv_model(model):
|
|
"""Traverse ComfyUI's MODEL wrapper nesting to find the LTXVModel/LTXAVModel."""
|
|
for attr in ("model", "diffusion_model"):
|
|
cur = model
|
|
for _ in range(4):
|
|
if cur is None:
|
|
break
|
|
inner = getattr(cur, attr, None)
|
|
if inner is None:
|
|
break
|
|
if hasattr(inner, "_process_input") and hasattr(inner, "patchify_proj"):
|
|
return inner
|
|
cur = inner
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Role embedding injection (pre-patchify_proj, 128-dim token space)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _role_inject_tokens(x, additional_args, role_weight, app_latents, strength):
|
|
"""Add role_weight[role_idx] to each token in x [B, S, 128].
|
|
|
|
role_weight is [3, 128] with:
|
|
[0] = target (zeros — no injection on target tokens)
|
|
[1] = appearance (embedding.weight[0] from our LoRA — added to ref image tokens)
|
|
[2] = motion (zeros — no injection on guide video tokens)
|
|
"""
|
|
num_guide_tokens = int(additional_args.get("num_guide_tokens", 0))
|
|
if num_guide_tokens <= 0 or role_weight is None or strength == 0.0:
|
|
return x
|
|
|
|
seq_len = x.shape[1]
|
|
guide_start = seq_len - num_guide_tokens
|
|
|
|
# How many tokens in entry[0] (ref image) to tag as appearance?
|
|
appearance_count = 0
|
|
resolved = additional_args.get("resolved_guide_entries")
|
|
if app_latents > 0 and resolved:
|
|
first = resolved[0]
|
|
lshape = first.get("latent_shape")
|
|
surviving = int(first.get("surviving_count", 0))
|
|
if lshape and len(lshape) >= 3:
|
|
tpf = int(lshape[1]) * int(lshape[2])
|
|
appearance_count = min(app_latents * tpf, surviving, num_guide_tokens)
|
|
|
|
# Build per-token role index
|
|
role_idx = torch.zeros(seq_len, dtype=torch.long, device=x.device)
|
|
if appearance_count > 0:
|
|
role_idx[guide_start : guide_start + appearance_count] = 1
|
|
if guide_start + appearance_count < seq_len:
|
|
role_idx[guide_start + appearance_count :] = 2
|
|
else:
|
|
role_idx[guide_start:] = 2
|
|
|
|
w = role_weight.to(device=x.device, dtype=x.dtype) # [3, 128]
|
|
injection = w[role_idx] * strength # [seq_len, 128]
|
|
return x + injection.unsqueeze(0) # broadcast over B
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Patched _process_input (mirrors comfy internals, adds role injection)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _ea_video_process_input(self, x, keyframe_idxs, denoise_mask, kwargs):
|
|
from comfy.ldm.lightricks.model import latent_to_pixel_coords
|
|
|
|
_EA_PATCH_COUNTERS["process_input_calls"] += 1
|
|
_EA_PATCH_COUNTERS["process_input_saw_kwargs_keys"].update(kwargs.keys() if kwargs else [])
|
|
if kwargs and _KW_ROLE_WEIGHT in kwargs and kwargs[_KW_ROLE_WEIGHT] is not None:
|
|
_EA_PATCH_COUNTERS["process_input_saw_role"] += 1
|
|
|
|
additional_args = {"orig_shape": list(x.shape)}
|
|
x, latent_coords = self.patchifier.patchify(x)
|
|
pixel_coords = latent_to_pixel_coords(
|
|
latent_coords=latent_coords,
|
|
scale_factors=self.vae_scale_factors,
|
|
causal_fix=self.causal_temporal_positioning,
|
|
)
|
|
|
|
if keyframe_idxs is not None:
|
|
additional_args["orig_patchified_shape"] = list(x.shape)
|
|
denoise_mask_p = self.patchifier.patchify(denoise_mask)[0]
|
|
grid_mask = ~torch.any(denoise_mask_p < 0, dim=-1)[0]
|
|
additional_args["grid_mask"] = grid_mask
|
|
x = x[:, grid_mask, :]
|
|
denoise_mask_p = denoise_mask_p[:, grid_mask, :]
|
|
pixel_coords = pixel_coords[:, :, grid_mask, ...]
|
|
|
|
kf_grid_mask = grid_mask[-keyframe_idxs.shape[2]:]
|
|
guide_entries = kwargs.get("guide_attention_entries")
|
|
if guide_entries:
|
|
total_pfc = sum(e["pre_filter_count"] for e in guide_entries)
|
|
if total_pfc != len(kf_grid_mask):
|
|
raise ValueError(
|
|
f"guide pre_filter_counts ({total_pfc}) != "
|
|
f"keyframe grid mask length ({len(kf_grid_mask)})"
|
|
)
|
|
resolved, offset = [], 0
|
|
for entry in guide_entries:
|
|
pfc = entry["pre_filter_count"]
|
|
surviving = int(kf_grid_mask[offset : offset + pfc].sum().item())
|
|
resolved.append({**entry, "surviving_count": surviving})
|
|
offset += pfc
|
|
additional_args["resolved_guide_entries"] = resolved
|
|
|
|
keyframe_idxs = keyframe_idxs[..., kf_grid_mask, :]
|
|
pixel_coords[:, :, -keyframe_idxs.shape[2] :, :] = keyframe_idxs
|
|
additional_args["num_guide_tokens"] = keyframe_idxs.shape[2]
|
|
|
|
role_weight = kwargs.get(_KW_ROLE_WEIGHT)
|
|
app_latents = int(kwargs.get(_KW_APP_LATENTS, 1))
|
|
role_strength = float(kwargs.get(_KW_ROLE_STRENGTH, 1.0))
|
|
|
|
# Pre-proj injection (128-dim token space, matching training)
|
|
x = _role_inject_tokens(x, additional_args, role_weight, app_latents, role_strength)
|
|
x = self.patchify_proj(x)
|
|
|
|
return x, pixel_coords, additional_args
|
|
|
|
|
|
def _patch_process_input_once(ltxv_model):
|
|
"""Monkey-patch _process_input. Idempotent."""
|
|
if getattr(ltxv_model, "_ea_process_input_patched", False):
|
|
return
|
|
|
|
is_av = type(ltxv_model).__name__ == "LTXAVModel"
|
|
original = ltxv_model._process_input
|
|
|
|
def patched(self, x, keyframe_idxs, denoise_mask, **kwargs):
|
|
if kwargs.get(_KW_ROLE_WEIGHT) is None:
|
|
return original(x, keyframe_idxs, denoise_mask, **kwargs)
|
|
|
|
if is_av and isinstance(x, list):
|
|
audio_length = kwargs.get("audio_length", 0)
|
|
vx, ax = self.separate_audio_and_video_latents(x, audio_length)
|
|
|
|
has_spatial_mask = False
|
|
if denoise_mask is not None:
|
|
for fi in range(denoise_mask.shape[2]):
|
|
fm = denoise_mask[0, 0, fi]
|
|
if fm.numel() > 0 and fm.min() != fm.max():
|
|
has_spatial_mask = True
|
|
break
|
|
|
|
vx, v_pixel_coords, additional_args = _ea_video_process_input(
|
|
self, vx, keyframe_idxs, denoise_mask, kwargs
|
|
)
|
|
additional_args["has_spatial_mask"] = has_spatial_mask
|
|
|
|
ax, a_latent_coords = self.a_patchifier.patchify(ax)
|
|
|
|
ref_audio = kwargs.get("ref_audio")
|
|
if ref_audio is not None:
|
|
ref_tokens = ref_audio["tokens"].to(dtype=ax.dtype, device=ax.device)
|
|
if ref_tokens.shape[0] < ax.shape[0]:
|
|
ref_tokens = ref_tokens.expand(ax.shape[0], -1, -1)
|
|
ref_audio_seq_len = ref_tokens.shape[1]
|
|
B = ax.shape[0]
|
|
p = self.a_patchifier
|
|
tpl = p.hop_length * p.audio_latent_downsample_factor / p.sample_rate
|
|
ref_start = p._get_audio_latent_time_in_sec(0, ref_audio_seq_len, torch.float32, ax.device)
|
|
ref_end = p._get_audio_latent_time_in_sec(1, ref_audio_seq_len + 1, torch.float32, ax.device)
|
|
time_offset = ref_end[-1].item() + tpl
|
|
ref_start = (ref_start - time_offset).unsqueeze(0).expand(B, -1).unsqueeze(1)
|
|
ref_end = (ref_end - time_offset).unsqueeze(0).expand(B, -1).unsqueeze(1)
|
|
ref_pos = torch.stack([ref_start, ref_end], dim=-1)
|
|
additional_args["ref_audio_seq_len"] = ref_audio_seq_len
|
|
additional_args["target_audio_seq_len"] = ax.shape[1]
|
|
ax = torch.cat([ref_tokens, ax], dim=1)
|
|
a_latent_coords = torch.cat([ref_pos.to(a_latent_coords), a_latent_coords], dim=2)
|
|
|
|
ax = self.audio_patchify_proj(ax)
|
|
return [vx, ax], [v_pixel_coords, a_latent_coords], additional_args
|
|
|
|
return _ea_video_process_input(self, x, keyframe_idxs, denoise_mask, kwargs)
|
|
|
|
ltxv_model._process_input = types.MethodType(patched, ltxv_model)
|
|
ltxv_model._ea_process_input_patched = True
|
|
logger.info("EA process_input patch installed on %s", type(ltxv_model).__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AdaLN patch (_prepare_timestep)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _patch_prepare_timestep_once(ltxv_model):
|
|
"""Monkey-patch _prepare_timestep to add ref_cond. Idempotent."""
|
|
if getattr(ltxv_model, "_ea_prepare_timestep_patched", False):
|
|
return
|
|
|
|
original = ltxv_model._prepare_timestep
|
|
|
|
def patched(self, timestep, batch_size, hidden_dtype, **kwargs):
|
|
_EA_PATCH_COUNTERS["prepare_timestep_calls"] += 1
|
|
_EA_PATCH_COUNTERS["prepare_timestep_saw_kwargs_keys"].update(kwargs.keys() if kwargs else [])
|
|
if kwargs and _KW_ADALN_COND in kwargs and kwargs[_KW_ADALN_COND] is not None:
|
|
_EA_PATCH_COUNTERS["prepare_timestep_saw_adaln"] += 1
|
|
t, emb_t, prompt_t = original(timestep, batch_size, hidden_dtype, **kwargs)
|
|
ref_cond = kwargs.get(_KW_ADALN_COND)
|
|
if ref_cond is not None:
|
|
# LTXAVModel: t = [v_timestep, a_timestep, cross_ss, v_prompt_t, a_prompt_t]
|
|
# LTXVModel: t = CompressedTimestep or plain tensor
|
|
av_mode = isinstance(t, (list, tuple))
|
|
v_t = t[0] if av_mode else t
|
|
|
|
# CompressedTimestep stores .data = [B, frames, feature_dim]
|
|
if hasattr(v_t, "data") and not isinstance(v_t, torch.Tensor):
|
|
tdata = v_t.data
|
|
rc = ref_cond.to(device=tdata.device, dtype=tdata.dtype)
|
|
if rc.shape[0] < batch_size:
|
|
rc = rc.expand(batch_size, -1)
|
|
pre_norm = tdata.norm().item()
|
|
v_t.data = tdata + rc.unsqueeze(1) # [B,F,D] + [B,1,D]
|
|
post_norm = v_t.data.norm().item()
|
|
if not getattr(self, "_ea_adaln_logged", False):
|
|
print(f"[EA AdaLN] timestep_norm: {pre_norm:.2f} → {post_norm:.2f} Δ={post_norm-pre_norm:+.4f} rc_norm={rc.norm().item():.4f}")
|
|
self._ea_adaln_logged = True
|
|
else:
|
|
rc = ref_cond.to(device=v_t.device, dtype=v_t.dtype)
|
|
if rc.shape[0] < batch_size:
|
|
rc = rc.expand(batch_size, -1)
|
|
v_t = v_t + rc.unsqueeze(1)
|
|
if av_mode:
|
|
t = [v_t] + list(t[1:])
|
|
else:
|
|
t = v_t
|
|
return t, emb_t, prompt_t
|
|
|
|
ltxv_model._prepare_timestep = types.MethodType(patched, ltxv_model)
|
|
ltxv_model._ea_prepare_timestep_patched = True
|
|
logger.info("EA prepare_timestep patch installed on %s", type(ltxv_model).__name__)
|
|
|
|
|
|
def _build_ref_projector_input(ref_lat, in_features):
|
|
"""Build the reference descriptor expected by the trained projector."""
|
|
B = ref_lat.shape[0]
|
|
ref_frame = ref_lat.float().mean(dim=2) # [B, 128, H, W]
|
|
|
|
avg_1x1 = F.adaptive_avg_pool2d(ref_frame, (1, 1)).reshape(B, -1)
|
|
if in_features == 128:
|
|
return avg_1x1
|
|
|
|
max_1x1 = F.adaptive_max_pool2d(ref_frame, (1, 1)).reshape(B, -1)
|
|
if in_features == 256:
|
|
return torch.cat([avg_1x1, max_1x1], dim=-1)
|
|
|
|
avg_2x2 = F.adaptive_avg_pool2d(ref_frame, (2, 2)).reshape(B, -1)
|
|
ref_global = torch.cat([avg_1x1, avg_2x2, max_1x1], dim=-1)
|
|
if in_features == ref_global.shape[-1]:
|
|
return ref_global
|
|
|
|
raise ValueError(
|
|
f"Unsupported ref projector input dim {in_features}. "
|
|
"Expected one of 128, 256, or 768."
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LTXVEditAnythingApply — the one node to rule them all
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class LTXVEditAnythingApply:
|
|
"""Unified Edit Anything conditioning for video_to_video_ref_adaln LoRAs.
|
|
|
|
Applies all 3 conditioning signals from a single LoRA checkpoint in one node:
|
|
|
|
① IC-LoRA sequence
|
|
Encodes the reference image and optional guide video, appends them to
|
|
the latent as clean conditioning frames. Layout in the transformer:
|
|
[target tokens | ref tokens @ t=-1 | guide tokens @ t=0..T]
|
|
|
|
② Role embedding (role_embedding.embedding.weight[0])
|
|
Adds a learned 128-dim fingerprint to the ref image tokens before
|
|
patchify_proj, so the model can distinguish @reference from guide frames
|
|
via self-attention.
|
|
|
|
③ AdaLN conditioning (ref_adaln_proj: fc1 → SiLU → proj)
|
|
Pools the ref image latent using the projector shape saved in the LoRA,
|
|
projects through a
|
|
2-layer MLP to the AdaLN timestep space, and adds it to every
|
|
transformer block's timestep bias — a persistent global appearance anchor.
|
|
|
|
Connect AFTER the standard LoRA loader, BEFORE the KSampler.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"positive": ("CONDITIONING",),
|
|
"negative": ("CONDITIONING",),
|
|
"vae": ("VAE",),
|
|
"latent": ("LATENT",),
|
|
"ref_image": ("IMAGE", {
|
|
"tooltip": "Reference image for appearance. Needs to be a clean image, aligned and facing the camera.",
|
|
}),
|
|
"resize_mode": (["pad_to_fit", "center_crop", "stretch"], {
|
|
"default": "pad_to_fit",
|
|
"tooltip": "How to resize the reference image to match the video's resolution. 'pad_to_fit' avoids distortion.",
|
|
}),
|
|
"lora_name": (folder_paths.get_filename_list("loras"), {
|
|
"tooltip": "LoRA trained with the video_to_video_ref_adaln strategy.",
|
|
}),
|
|
},
|
|
"optional": {
|
|
"guide_frames": ("IMAGE", {
|
|
"tooltip": "Guide video frames for structure/motion. "
|
|
"Leave disconnected for appearance-only (no guide).",
|
|
}),
|
|
"guide_strength": ("FLOAT", {
|
|
"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01,
|
|
"tooltip": "IC-LoRA conditioning strength for guide video tokens. "
|
|
"Capped at 1.0 — values >1 cause ComfyUI to filter tokens out.",
|
|
}),
|
|
"ref_strength": ("FLOAT", {
|
|
"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01,
|
|
"tooltip": "IC-LoRA conditioning strength for ref image tokens. "
|
|
"Capped at 1.0 — values >1 cause ComfyUI to filter tokens out.",
|
|
}),
|
|
"role_strength": ("FLOAT", {
|
|
"default": 1.0, "min": 0.0, "max": 20.0, "step": 0.1,
|
|
"tooltip": "Strength of the 'Latent Fingerprint' (Role Embedding) injected into reference image tokens. "
|
|
"Helps Attention find the reference in the latent space. Increase if the model ignores the reference.",
|
|
}),
|
|
"adaln_scale": ("FLOAT", {
|
|
"default": 2.0, "min": 0.0, "max": 10.0, "step": 0.05,
|
|
"tooltip": "Global Style Bias Multiplier (AdaLN). Must match training "
|
|
"`ref_cond_scale`. stage2_ref_visual_adaln_crossattn was trained with 2.0.",
|
|
}),
|
|
"enable_adaln": ("BOOLEAN", {
|
|
"default": True,
|
|
"tooltip": "Inject global style anchor (AdaLN). Toggle off to see only shape transfer via IC-LoRA (Self-Attention).",
|
|
}),
|
|
"enable_role_embedding": ("BOOLEAN", {
|
|
"default": False,
|
|
"tooltip": "Sum role_embedding bias onto IC-LoRA ref tokens. Must match training "
|
|
"`use_visual_ref_role_embedding`. stage2_ref_visual_adaln_crossattn "
|
|
"was trained with this DISABLED.",
|
|
}),
|
|
"debug": ("BOOLEAN", {
|
|
"default": False,
|
|
"tooltip": "Print latent shapes and token counts to console.",
|
|
}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("MODEL", "CONDITIONING", "CONDITIONING", "LATENT", "IMAGE")
|
|
RETURN_NAMES = ("model", "positive", "negative", "latent", "ref_image_preview")
|
|
FUNCTION = "apply"
|
|
CATEGORY = "LTXV/EditAnything"
|
|
|
|
def apply(
|
|
self, model, positive, negative, vae, latent, ref_image, lora_name, resize_mode="pad_to_fit",
|
|
guide_frames=None, guide_strength=1.0, ref_strength=1.0,
|
|
role_strength=1.0, adaln_scale=1.0, enable_adaln=True, enable_role_embedding=True, debug=False,
|
|
):
|
|
import comfy.utils
|
|
import comfy_extras.nodes_lt as nodes_lt
|
|
|
|
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
|
sd = load_file(lora_path)
|
|
|
|
# ── ① Load weights from LoRA ─────────────────────────────────────────
|
|
# Role embedding: new format [N_slots, 128]
|
|
role_weight = None
|
|
if "role_embedding.embedding.weight" in sd:
|
|
emb = sd["role_embedding.embedding.weight"] # [N, 128]
|
|
padded = torch.zeros(3, emb.shape[1], dtype=emb.dtype)
|
|
padded[1] = emb[0] # slot 0 → appearance (role 1)
|
|
role_weight = padded
|
|
logger.info("Role embedding: shape=%s → appearance role", tuple(emb.shape))
|
|
else:
|
|
for k in ("role_embedding.weight", "_role_embedding.weight",
|
|
"diffusion_model.role_embedding.weight"):
|
|
if k in sd:
|
|
role_weight = sd[k]
|
|
logger.info("Role embedding (legacy key '%s'): shape=%s", k, tuple(role_weight.shape))
|
|
break
|
|
|
|
# AdaLN projector
|
|
proj_w = sd.get("ref_adaln_proj.proj.weight")
|
|
proj_b = sd.get("ref_adaln_proj.proj.bias")
|
|
fc1_w = sd.get("ref_adaln_proj.fc1.weight")
|
|
fc1_b = sd.get("ref_adaln_proj.fc1.bias")
|
|
has_adaln = proj_w is not None and proj_b is not None
|
|
has_fc1 = fc1_w is not None and fc1_b is not None
|
|
|
|
# ── ② Build IC-LoRA sequence ─────────────────────────────────────────
|
|
scale_factors = vae.downscale_index_formula
|
|
time_sf, w_sf, h_sf = scale_factors
|
|
|
|
latent_image = latent["samples"] # [B, C, F, H_lat, W_lat]
|
|
noise_mask = nodes_lt.get_noise_mask(latent)
|
|
|
|
_, _, F_target, lat_h, lat_w = latent_image.shape
|
|
px_h = lat_h * h_sf
|
|
px_w = lat_w * w_sf
|
|
|
|
# Encode guide video (optional)
|
|
guide_lat = None
|
|
if guide_frames is not None:
|
|
gf = comfy.utils.common_upscale(
|
|
guide_frames.movedim(-1, 1), px_w, px_h, "bilinear", "disabled"
|
|
).movedim(1, -1)[:, :, :, :3]
|
|
n_guide = (gf.shape[0] - 1) // time_sf * time_sf + 1
|
|
guide_lat = vae.encode(gf[:n_guide])
|
|
|
|
# Encode ref image at the same spatial resolution as the target latent
|
|
ref_px = ref_image[0:1]
|
|
if resize_mode == "stretch":
|
|
ref_px = comfy.utils.common_upscale(
|
|
ref_px.movedim(-1, 1), px_w, px_h, "bilinear", "disabled"
|
|
).movedim(1, -1)[:, :, :, :3]
|
|
elif resize_mode == "center_crop":
|
|
ref_px = comfy.utils.common_upscale(
|
|
ref_px.movedim(-1, 1), px_w, px_h, "bilinear", "center"
|
|
).movedim(1, -1)[:, :, :, :3]
|
|
elif resize_mode == "pad_to_fit":
|
|
_, H, W, C = ref_px.shape
|
|
scale = min(px_w / W, px_h / H)
|
|
new_w = max(1, int(W * scale))
|
|
new_h = max(1, int(H * scale))
|
|
resized = torch.nn.functional.interpolate(
|
|
ref_px.movedim(-1, 1),
|
|
size=(new_h, new_w),
|
|
mode="bilinear",
|
|
align_corners=False,
|
|
).movedim(1, -1)
|
|
|
|
canvas = torch.full((1, px_h, px_w, C), 0.5, dtype=ref_px.dtype, device=ref_px.device)
|
|
y_offset = (px_h - new_h) // 2
|
|
x_offset = (px_w - new_w) // 2
|
|
canvas[:, y_offset:y_offset + new_h, x_offset:x_offset + new_w, :] = resized
|
|
ref_px = canvas[:, :, :, :3]
|
|
ref_lat = vae.encode(ref_px) # [1, 128, 1, lat_h, lat_w]
|
|
|
|
# Temporal position for ref: t=-1 slot → -time_sf pixel frames (BEFORE frame 0)
|
|
# Training layout: [ref @ t=-8 | guide @ t=0..T | noisy_target @ t=0..T]
|
|
ref_pixel_pos = -time_sf # = -8 for LTX VAE (one latent frame before frame 0)
|
|
|
|
# Append ref first (entry[0] = appearance)
|
|
# causal_fix=True matches training validation_sampler.py:418 (get_pixel_coords causal_fix=True)
|
|
positive, negative, latent_image, noise_mask = nodes_lt.LTXVAddGuide.append_keyframe(
|
|
positive, negative, ref_pixel_pos, latent_image, noise_mask,
|
|
ref_lat, ref_strength, scale_factors, causal_fix=True,
|
|
)
|
|
positive, negative = nodes_lt._append_guide_attention_entry(
|
|
positive, negative,
|
|
ref_lat.shape[2] * ref_lat.shape[3] * ref_lat.shape[4],
|
|
list(ref_lat.shape[2:]),
|
|
strength=ref_strength,
|
|
)
|
|
|
|
# Append guide second (entry[1] = motion)
|
|
if guide_lat is not None:
|
|
positive, negative, latent_image, noise_mask = nodes_lt.LTXVAddGuide.append_keyframe(
|
|
positive, negative, 0, latent_image, noise_mask,
|
|
guide_lat, guide_strength, scale_factors, causal_fix=True,
|
|
)
|
|
positive, negative = nodes_lt._append_guide_attention_entry(
|
|
positive, negative,
|
|
guide_lat.shape[2] * guide_lat.shape[3] * guide_lat.shape[4],
|
|
list(guide_lat.shape[2:]),
|
|
strength=guide_strength,
|
|
)
|
|
|
|
# ── ③ Patch model (role embedding + AdaLN) ──────────────────────────
|
|
m = model.clone()
|
|
ltxv = _find_ltxv_model(m.model)
|
|
if ltxv is None:
|
|
raise ValueError(
|
|
"LTXVEditAnythingApply: could not locate LTXVModel inside the MODEL wrapper."
|
|
)
|
|
|
|
m.model_options = dict(m.model_options)
|
|
to = dict(m.model_options.get("transformer_options", {}))
|
|
|
|
if role_weight is not None and enable_role_embedding:
|
|
_patch_process_input_once(ltxv)
|
|
to[_KW_ROLE_WEIGHT] = role_weight
|
|
to[_KW_APP_LATENTS] = 1 # 1 ref image frame
|
|
to[_KW_ROLE_STRENGTH] = float(role_strength)
|
|
|
|
if has_adaln and enable_adaln:
|
|
B = ref_lat.shape[0]
|
|
dtype = proj_w.dtype
|
|
|
|
if has_fc1:
|
|
ref_global = _build_ref_projector_input(ref_lat, fc1_w.shape[1]).to(
|
|
dtype=dtype, device=fc1_w.device
|
|
)
|
|
h = F.silu(F.linear(ref_global, fc1_w.to(dtype=dtype), fc1_b.to(dtype=dtype)))
|
|
ref_cond = F.linear(h, proj_w, proj_b)
|
|
else:
|
|
ref_global = _build_ref_projector_input(ref_lat, proj_w.shape[1]).to(
|
|
dtype=dtype, device=proj_w.device
|
|
)
|
|
ref_cond = F.linear(ref_global, proj_w, proj_b)
|
|
|
|
ref_cond = ref_cond * adaln_scale
|
|
_patch_prepare_timestep_once(ltxv)
|
|
to[_KW_ADALN_COND] = ref_cond
|
|
logger.info("AdaLN cond: shape=%s, norm=%.4f", tuple(ref_cond.shape), ref_cond.norm().item())
|
|
|
|
m.model_options["transformer_options"] = to
|
|
|
|
if debug:
|
|
ref_toks = ref_lat.shape[2] * ref_lat.shape[3] * ref_lat.shape[4]
|
|
target_toks = F_target * lat_h * lat_w
|
|
print("\n" + "=" * 60)
|
|
print("[LTXVEditAnythingApply DEBUG]")
|
|
print(f" target latent : {list(latent['samples'].shape)} → {target_toks} tokens")
|
|
print(f" ref img latent : {list(ref_lat.shape)} → {ref_toks} tokens (role emb: {'yes' if (role_weight is not None and enable_role_embedding) else 'no'})")
|
|
print(f" ref pixel pos : {ref_pixel_pos} (out-of-band)")
|
|
if guide_lat is not None:
|
|
guide_toks = guide_lat.shape[2] * guide_lat.shape[3] * guide_lat.shape[4]
|
|
print(f" guide latent : {list(guide_lat.shape)} → {guide_toks} tokens")
|
|
print(f" AdaLN cond : {'yes (2-layer MLP)' if (has_fc1 and enable_adaln) else 'yes (single linear)' if (has_adaln and enable_adaln) else 'no'}")
|
|
if has_adaln and enable_adaln:
|
|
print(f" adaln_scale : {adaln_scale} → ref_cond norm={ref_cond.norm():.4f}")
|
|
print("=" * 60 + "\n")
|
|
|
|
return (m, positive, negative, {"samples": latent_image, "noise_mask": noise_mask}, ref_px.clone())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ComfyUI registration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class LTXVApplyNeutralMask:
|
|
"""
|
|
Replaces masked-out image/video regions with a clean solid background,
|
|
useful before feeding reference images or guide frames to LTXV conditioning.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"mask": ("MASK",),
|
|
"background": (["white", "neutral_gray", "black"],),
|
|
"hard_edges": ("BOOLEAN", {"default": True}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("image",)
|
|
FUNCTION = "apply"
|
|
CATEGORY = "LTXV/EditAnything"
|
|
|
|
def apply(self, image, mask, background, hard_edges):
|
|
img = image.clone()
|
|
m = mask
|
|
|
|
if m.dim() == 2:
|
|
m = m.unsqueeze(0)
|
|
|
|
if m.shape[0] == 1 and img.shape[0] > 1:
|
|
m = m.expand(img.shape[0], -1, -1)
|
|
elif m.shape[0] > img.shape[0]:
|
|
m = m[:img.shape[0]]
|
|
elif m.shape[0] < img.shape[0]:
|
|
last_frame = m[-1:]
|
|
padding = last_frame.expand(img.shape[0] - m.shape[0], -1, -1)
|
|
m = torch.cat([m, padding], dim=0)
|
|
|
|
_, H, W, _ = img.shape
|
|
m = m.unsqueeze(1)
|
|
if hard_edges:
|
|
m = torch.nn.functional.interpolate(m.float(), size=(H, W), mode="nearest")
|
|
m = (m > 0.5).to(img.dtype)
|
|
else:
|
|
m = torch.nn.functional.interpolate(m.float(), size=(H, W), mode="bilinear", align_corners=False)
|
|
m = m.clamp(0.0, 1.0).to(img.dtype)
|
|
m = m.movedim(1, -1).to(img.device, dtype=img.dtype)
|
|
|
|
colors = {
|
|
"white": [1.0, 1.0, 1.0],
|
|
"neutral_gray": [0.5, 0.5, 0.5],
|
|
"black": [0.0, 0.0, 0.0],
|
|
}
|
|
bg_color = torch.tensor(colors[background], device=img.device, dtype=img.dtype)
|
|
img = img * m + bg_color * (1.0 - m)
|
|
return (img,)
|
|
|
|
|
|
class LTXVResizeReferenceByMask:
|
|
"""
|
|
Resizes a reference object on a clean canvas using a mask bbox as the
|
|
desired object dimensions. The mask position is ignored; only its width and
|
|
height define the target scale.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"ref_image": ("IMAGE",),
|
|
"size_mask": ("MASK",),
|
|
"fit": (["contain", "cover", "stretch"],),
|
|
"background": (["white", "neutral_gray", "black"],),
|
|
"padding": ("INT", {"default": 0, "min": -256, "max": 256, "step": 1}),
|
|
"hard_edges": ("BOOLEAN", {"default": True}),
|
|
},
|
|
"optional": {
|
|
"ref_mask": ("MASK",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "MASK")
|
|
RETURN_NAMES = ("image", "object_mask")
|
|
FUNCTION = "apply"
|
|
CATEGORY = "LTXV/EditAnything"
|
|
|
|
@staticmethod
|
|
def _bbox(mask):
|
|
ys, xs = torch.where(mask > 0.5)
|
|
if ys.numel() == 0 or xs.numel() == 0:
|
|
return None
|
|
return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1
|
|
|
|
@staticmethod
|
|
def _fit_size(src_w, src_h, dst_w, dst_h, fit):
|
|
if fit == "stretch":
|
|
return max(1, dst_w), max(1, dst_h)
|
|
scale_w = dst_w / max(1, src_w)
|
|
scale_h = dst_h / max(1, src_h)
|
|
scale = max(scale_w, scale_h) if fit == "cover" else min(scale_w, scale_h)
|
|
return max(1, int(round(src_w * scale))), max(1, int(round(src_h * scale)))
|
|
|
|
def apply(self, ref_image, size_mask, fit, background, padding, hard_edges, ref_mask=None):
|
|
ref = ref_image[0:1, :, :, :3]
|
|
size = size_mask[0] if size_mask.dim() == 3 else size_mask
|
|
out_h, out_w = int(size.shape[-2]), int(size.shape[-1])
|
|
|
|
colors = {
|
|
"white": [1.0, 1.0, 1.0],
|
|
"neutral_gray": [0.5, 0.5, 0.5],
|
|
"black": [0.0, 0.0, 0.0],
|
|
}
|
|
canvas = torch.tensor(colors[background], device=ref.device, dtype=ref.dtype).view(1, 1, 1, 3)
|
|
canvas = canvas.expand(1, out_h, out_w, 3).clone()
|
|
out_mask = torch.zeros((1, out_h, out_w), device=ref.device, dtype=ref.dtype)
|
|
|
|
size_bbox = self._bbox(size.to(ref.device))
|
|
if size_bbox is None:
|
|
return (canvas, out_mask)
|
|
|
|
sx_mask0, sy_mask0, sx_mask1, sy_mask1 = size_bbox
|
|
dst_w = max(1, sx_mask1 - sx_mask0 + (int(padding) * 2))
|
|
dst_h = max(1, sy_mask1 - sy_mask0 + (int(padding) * 2))
|
|
dst_w = min(out_w, dst_w)
|
|
dst_h = min(out_h, dst_h)
|
|
|
|
if ref_mask is not None:
|
|
rm = ref_mask[0] if ref_mask.dim() == 3 else ref_mask
|
|
rm = rm.to(device=ref.device, dtype=ref.dtype)
|
|
if rm.shape[-2:] != ref.shape[1:3]:
|
|
rm = torch.nn.functional.interpolate(
|
|
rm.unsqueeze(0).unsqueeze(0).float(),
|
|
size=ref.shape[1:3],
|
|
mode="nearest" if hard_edges else "bilinear",
|
|
align_corners=False if not hard_edges else None,
|
|
)[0, 0].to(dtype=ref.dtype)
|
|
if hard_edges:
|
|
rm = (rm > 0.5).to(ref.dtype)
|
|
else:
|
|
rm = rm.clamp(0.0, 1.0)
|
|
src_bbox = self._bbox(rm)
|
|
else:
|
|
rm = torch.ones((ref.shape[1], ref.shape[2]), device=ref.device, dtype=ref.dtype)
|
|
src_bbox = (0, 0, ref.shape[2], ref.shape[1])
|
|
|
|
if src_bbox is None:
|
|
return (canvas, out_mask)
|
|
|
|
sx0, sy0, sx1, sy1 = src_bbox
|
|
obj = ref[:, sy0:sy1, sx0:sx1, :]
|
|
obj_mask = rm[sy0:sy1, sx0:sx1].unsqueeze(0).unsqueeze(0)
|
|
src_h, src_w = obj.shape[1], obj.shape[2]
|
|
new_w, new_h = self._fit_size(src_w, src_h, dst_w, dst_h, fit)
|
|
|
|
obj_rs = torch.nn.functional.interpolate(
|
|
obj.movedim(-1, 1),
|
|
size=(new_h, new_w),
|
|
mode="bilinear",
|
|
align_corners=False,
|
|
).movedim(1, -1)
|
|
mask_rs = torch.nn.functional.interpolate(
|
|
obj_mask.float(),
|
|
size=(new_h, new_w),
|
|
mode="nearest" if hard_edges else "bilinear",
|
|
align_corners=False if not hard_edges else None,
|
|
)
|
|
if hard_edges:
|
|
mask_rs = (mask_rs > 0.5).to(ref.dtype)
|
|
else:
|
|
mask_rs = mask_rs.clamp(0.0, 1.0).to(ref.dtype)
|
|
|
|
paste_x0 = (out_w - new_w) // 2
|
|
paste_y0 = (out_h - new_h) // 2
|
|
src_x0 = max(0, -paste_x0)
|
|
src_y0 = max(0, -paste_y0)
|
|
paste_x0 = max(0, paste_x0)
|
|
paste_y0 = max(0, paste_y0)
|
|
paste_x1 = min(out_w, paste_x0 + new_w - src_x0)
|
|
paste_y1 = min(out_h, paste_y0 + new_h - src_y0)
|
|
src_x1 = src_x0 + (paste_x1 - paste_x0)
|
|
src_y1 = src_y0 + (paste_y1 - paste_y0)
|
|
|
|
if paste_x1 <= paste_x0 or paste_y1 <= paste_y0:
|
|
return (canvas, out_mask)
|
|
|
|
alpha = mask_rs[:, :, src_y0:src_y1, src_x0:src_x1].movedim(1, -1)
|
|
patch = obj_rs[:, src_y0:src_y1, src_x0:src_x1, :]
|
|
canvas[:, paste_y0:paste_y1, paste_x0:paste_x1, :] = (
|
|
patch * alpha + canvas[:, paste_y0:paste_y1, paste_x0:paste_x1, :] * (1.0 - alpha)
|
|
)
|
|
out_mask[:, paste_y0:paste_y1, paste_x0:paste_x1] = torch.maximum(
|
|
out_mask[:, paste_y0:paste_y1, paste_x0:paste_x1],
|
|
alpha[..., 0],
|
|
)
|
|
return (canvas, out_mask)
|
|
|
|
|
|
def _ea_loop_resize_ref_image(ref_image, width, height, resize_mode):
|
|
import comfy.utils
|
|
|
|
ref_px = ref_image[0:1, :, :, :3]
|
|
if resize_mode == "stretch":
|
|
return comfy.utils.common_upscale(
|
|
ref_px.movedim(-1, 1), width, height, "bilinear", "disabled"
|
|
).movedim(1, -1)[:, :, :, :3]
|
|
if resize_mode == "center_crop":
|
|
return comfy.utils.common_upscale(
|
|
ref_px.movedim(-1, 1), width, height, "bilinear", "center"
|
|
).movedim(1, -1)[:, :, :, :3]
|
|
|
|
_, src_h, src_w, channels = ref_px.shape
|
|
scale = min(width / src_w, height / src_h)
|
|
new_w = max(1, int(src_w * scale))
|
|
new_h = max(1, int(src_h * scale))
|
|
resized = torch.nn.functional.interpolate(
|
|
ref_px.movedim(-1, 1),
|
|
size=(new_h, new_w),
|
|
mode="bilinear",
|
|
align_corners=False,
|
|
).movedim(1, -1)
|
|
canvas = torch.full(
|
|
(1, height, width, channels),
|
|
0.5,
|
|
dtype=ref_px.dtype,
|
|
device=ref_px.device,
|
|
)
|
|
y = (height - new_h) // 2
|
|
x = (width - new_w) // 2
|
|
canvas[:, y:y + new_h, x:x + new_w, :] = resized
|
|
return canvas[:, :, :, :3]
|
|
|
|
|
|
def _ea_loop_build_ref_projector_input(ref_lat, in_features):
|
|
b = ref_lat.shape[0]
|
|
ref_frame = ref_lat.mean(dim=2)
|
|
avg_1x1 = F.adaptive_avg_pool2d(ref_frame, (1, 1)).reshape(b, -1)
|
|
if in_features == avg_1x1.shape[1]:
|
|
return avg_1x1
|
|
|
|
max_1x1 = F.adaptive_max_pool2d(ref_frame, (1, 1)).reshape(b, -1)
|
|
mean_max = torch.cat([avg_1x1, max_1x1], dim=-1)
|
|
if in_features == mean_max.shape[1]:
|
|
return mean_max
|
|
|
|
avg_2x2 = F.adaptive_avg_pool2d(ref_frame, (2, 2)).reshape(b, -1)
|
|
multiscale = torch.cat([avg_1x1, avg_2x2, max_1x1], dim=-1)
|
|
if in_features == multiscale.shape[1]:
|
|
return multiscale
|
|
|
|
raise ValueError(
|
|
f"Unsupported ref_adaln_proj input dim {in_features}. "
|
|
f"Known dims: {avg_1x1.shape[1]}, {mean_max.shape[1]}, {multiscale.shape[1]}."
|
|
)
|
|
|
|
|
|
def _ea_loop_load_state_dict(lora_name, editanything_module):
|
|
"""Load EA weights from a LoRA file or a sidecar module. Returns merged dict (may be empty)."""
|
|
sd = {}
|
|
if lora_name is not None and lora_name != "" and lora_name != "(none)":
|
|
try:
|
|
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
|
sd.update(load_file(lora_path))
|
|
except Exception as exc:
|
|
logger.warning("LTXVEditAnythingLoopingSampler: failed to load lora '%s': %s", lora_name, exc)
|
|
if editanything_module is not None:
|
|
mod_sd = editanything_module.get("state_dict") if isinstance(editanything_module, dict) else None
|
|
if mod_sd is None and isinstance(editanything_module, dict):
|
|
mod_sd = editanything_module
|
|
if mod_sd:
|
|
sd.update(mod_sd)
|
|
return sd
|
|
|
|
|
|
def _ea_loop_extract_role_weight(sd):
|
|
if "role_embedding.embedding.weight" in sd:
|
|
emb = sd["role_embedding.embedding.weight"]
|
|
padded = torch.zeros(3, emb.shape[1], dtype=emb.dtype)
|
|
padded[1] = emb[0]
|
|
return padded
|
|
for k in ("role_embedding.weight", "_role_embedding.weight",
|
|
"diffusion_model.role_embedding.weight", "diffusion_model._role_embedding.weight"):
|
|
if k in sd:
|
|
return sd[k]
|
|
return None
|
|
|
|
|
|
def _ea_loop_compute_adaln_cond(sd, ref_lat, adaln_scale):
|
|
proj_w = sd.get("ref_adaln_proj.proj.weight")
|
|
proj_b = sd.get("ref_adaln_proj.proj.bias")
|
|
fc1_w = sd.get("ref_adaln_proj.fc1.weight")
|
|
fc1_b = sd.get("ref_adaln_proj.fc1.bias")
|
|
if proj_w is None or proj_b is None:
|
|
return None
|
|
in_features = fc1_w.shape[1] if fc1_w is not None else proj_w.shape[1]
|
|
dtype = proj_w.dtype
|
|
ref_global = _ea_loop_build_ref_projector_input(ref_lat, in_features).to(dtype=dtype)
|
|
if fc1_w is not None and fc1_b is not None:
|
|
hidden = F.silu(F.linear(ref_global, fc1_w.to(dtype=dtype), fc1_b.to(dtype=dtype)))
|
|
ref_cond = F.linear(hidden, proj_w, proj_b)
|
|
else:
|
|
ref_cond = F.linear(ref_global, proj_w, proj_b)
|
|
return ref_cond * float(adaln_scale)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Visual ref-attn cross-attention path (for stage2_ref_visual_adaln_crossattn checkpoints)
|
|
# Ported from /data/comfyui/custom_nodes/ltxv_ref_visual_lora.py
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_KW_REF_CONTEXT = "ea_ref_visual_context"
|
|
_KW_REF_SCALE = "ea_ref_visual_scale"
|
|
_KW_REF_START = "ea_ref_visual_start_block"
|
|
_KW_REF_END = "ea_ref_visual_end_block"
|
|
|
|
|
|
# Diagnostic counters — incremented inside the monkey-patched methods so we can
|
|
# prove whether transformer_options is actually reaching the model forward.
|
|
_EA_PATCH_COUNTERS = {
|
|
"process_input_calls": 0,
|
|
"process_input_saw_role": 0,
|
|
"process_input_saw_kwargs_keys": set(),
|
|
"prepare_timestep_calls": 0,
|
|
"prepare_timestep_saw_adaln": 0,
|
|
"prepare_timestep_saw_kwargs_keys": set(),
|
|
"block_forward_calls": 0,
|
|
"block_forward_saw_ref_ctx": 0,
|
|
"block_forward_ref_ctx_norm": 0.0,
|
|
}
|
|
|
|
|
|
def _ea_reset_counters():
|
|
_EA_PATCH_COUNTERS["process_input_calls"] = 0
|
|
_EA_PATCH_COUNTERS["process_input_saw_role"] = 0
|
|
_EA_PATCH_COUNTERS["process_input_saw_kwargs_keys"] = set()
|
|
_EA_PATCH_COUNTERS["prepare_timestep_calls"] = 0
|
|
_EA_PATCH_COUNTERS["prepare_timestep_saw_adaln"] = 0
|
|
_EA_PATCH_COUNTERS["prepare_timestep_saw_kwargs_keys"] = set()
|
|
_EA_PATCH_COUNTERS["block_forward_calls"] = 0
|
|
_EA_PATCH_COUNTERS["block_forward_saw_ref_ctx"] = 0
|
|
_EA_PATCH_COUNTERS["block_forward_ref_ctx_norm"] = 0.0
|
|
|
|
|
|
def _ea_format_counters():
|
|
c = _EA_PATCH_COUNTERS
|
|
return (
|
|
"[EA patch counters]\n"
|
|
f" _process_input: calls={c['process_input_calls']} "
|
|
f"saw_role_weight={c['process_input_saw_role']} "
|
|
f"kwargs_keys={sorted(c['process_input_saw_kwargs_keys'])}\n"
|
|
f" _prepare_timestep: calls={c['prepare_timestep_calls']} "
|
|
f"saw_adaln_cond={c['prepare_timestep_saw_adaln']} "
|
|
f"kwargs_keys={sorted(c['prepare_timestep_saw_kwargs_keys'])}\n"
|
|
f" block.forward: calls={c['block_forward_calls']} "
|
|
f"saw_ref_context={c['block_forward_saw_ref_ctx']} "
|
|
f"ref_ctx_norm_sum={c['block_forward_ref_ctx_norm']:.4f}"
|
|
)
|
|
|
|
|
|
def _ea_factorize_tokens(num_tokens):
|
|
import math as _math
|
|
h = int(_math.isqrt(num_tokens))
|
|
while num_tokens % h != 0:
|
|
h -= 1
|
|
return h, num_tokens // h
|
|
|
|
|
|
def _ea_nested_module(root, path):
|
|
cur = root
|
|
for part in path.split("."):
|
|
cur = cur[int(part)] if part.isdigit() else getattr(cur, part)
|
|
return cur
|
|
|
|
|
|
def _ea_has_ref_visual_keys(sd):
|
|
return "ref_visual_proj.proj.weight" in sd and "ref_visual_proj.pos_embed" in sd
|
|
|
|
|
|
def _ea_has_ref_attn_lora(sd):
|
|
for k in sd:
|
|
if ".ref_attn." in k and k.endswith(".lora_A.weight"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _ea_build_ref_visual_context(sd, ref_lat, token_scale):
|
|
required = [
|
|
"ref_visual_proj.fc1.weight",
|
|
"ref_visual_proj.fc1.bias",
|
|
"ref_visual_proj.proj.weight",
|
|
"ref_visual_proj.proj.bias",
|
|
"ref_visual_proj.norm.weight",
|
|
"ref_visual_proj.norm.bias",
|
|
"ref_visual_proj.pos_embed",
|
|
]
|
|
missing = [k for k in required if k not in sd]
|
|
if missing:
|
|
raise ValueError(f"LoRA is missing ref_visual_proj keys: {missing}")
|
|
|
|
dtype = sd["ref_visual_proj.proj.weight"].dtype
|
|
device = ref_lat.device
|
|
pos = sd["ref_visual_proj.pos_embed"].to(device=device, dtype=dtype)
|
|
num_tokens = pos.shape[1]
|
|
grid_h, grid_w = _ea_factorize_tokens(num_tokens)
|
|
|
|
ref_frame = ref_lat.to(dtype=dtype).mean(dim=2)
|
|
local = F.adaptive_avg_pool2d(ref_frame, (grid_h, grid_w)).flatten(2).transpose(1, 2)
|
|
flat = ref_frame.flatten(2)
|
|
global_mean = flat.mean(dim=-1)
|
|
global_std = flat.std(dim=-1, unbiased=False)
|
|
global_stats = torch.cat([global_mean, global_std], dim=-1)[:, None, :].expand(-1, local.shape[1], -1)
|
|
tokens = torch.cat([local, global_stats], dim=-1)
|
|
|
|
fc1_w = sd["ref_visual_proj.fc1.weight"].to(device=device, dtype=dtype)
|
|
fc1_b = sd["ref_visual_proj.fc1.bias"].to(device=device, dtype=dtype)
|
|
proj_w = sd["ref_visual_proj.proj.weight"].to(device=device, dtype=dtype)
|
|
proj_b = sd["ref_visual_proj.proj.bias"].to(device=device, dtype=dtype)
|
|
norm_w = sd["ref_visual_proj.norm.weight"].to(device=device, dtype=dtype)
|
|
norm_b = sd["ref_visual_proj.norm.bias"].to(device=device, dtype=dtype)
|
|
|
|
tokens = F.linear(F.silu(F.linear(tokens, fc1_w, fc1_b)), proj_w, proj_b)
|
|
tokens = F.layer_norm(tokens, (tokens.shape[-1],), norm_w, norm_b)
|
|
tokens = tokens + pos[:, : tokens.shape[1]]
|
|
return tokens * float(token_scale)
|
|
|
|
|
|
def _ea_install_ref_attn_modules(ltxv, context_dim, init_seed, init_from="attn2"):
|
|
"""Install/reinstall a CrossAttention `ref_attn` on each transformer block.
|
|
|
|
Creates the module only once per ltxv instance (heavy: 48 CrossAttention
|
|
submodules), but ALWAYS resets weights to the chosen base init on every
|
|
call. This is critical because LoRA deltas are added on top: without a
|
|
reset, calling apply_ref_attn_lora repeatedly would compound the delta.
|
|
|
|
init_from: must match training config `init_ref_attn_from`:
|
|
"attn2" — copy attn2 weights (training default for visual_adaln_crossattn)
|
|
"attn1" — copy attn1 weights
|
|
"none" — random seeded init + zero out to_out (training only for "none" runs)
|
|
"""
|
|
from comfy.ldm.lightricks.model import CrossAttention
|
|
import comfy.ops
|
|
|
|
if init_seed is not None and init_seed >= 0:
|
|
cpu_state = torch.random.get_rng_state()
|
|
torch.manual_seed(int(init_seed))
|
|
else:
|
|
cpu_state = None
|
|
|
|
needs_create = not getattr(ltxv, "_ea_ref_visual_modules_installed", False)
|
|
copied_count = 0
|
|
|
|
try:
|
|
for block in ltxv.transformer_blocks:
|
|
if needs_create or not hasattr(block, "ref_attn"):
|
|
dim = block.scale_shift_table.shape[1]
|
|
dtype = block.scale_shift_table.dtype
|
|
device = block.scale_shift_table.device
|
|
heads = block.attn1.heads
|
|
d_head = block.attn1.dim_head
|
|
block.ref_attn = CrossAttention(
|
|
query_dim=dim,
|
|
context_dim=context_dim,
|
|
heads=heads,
|
|
dim_head=d_head,
|
|
attn_precision=getattr(block.attn1, "attn_precision", None),
|
|
dtype=dtype,
|
|
device=device,
|
|
operations=comfy.ops.disable_weight_init,
|
|
)
|
|
# disable_weight_init leaves params on meta — materialize and
|
|
# zero the biases that src (attn1/attn2) won't provide, since
|
|
# LTX-2 attn has no bias on to_q/to_k/to_v/to_out. Otherwise
|
|
# load_state_dict below hits meta tensors with no data.
|
|
if any(p.is_meta for p in block.ref_attn.parameters()):
|
|
block.ref_attn = block.ref_attn.to_empty(device=device)
|
|
with torch.no_grad():
|
|
for sub_name in ("to_q", "to_k", "to_v"):
|
|
sub = getattr(block.ref_attn, sub_name, None)
|
|
if sub is not None and getattr(sub, "bias", None) is not None:
|
|
sub.bias.zero_()
|
|
if block.ref_attn.to_out[0].bias is not None:
|
|
block.ref_attn.to_out[0].bias.zero_()
|
|
|
|
# ALWAYS reset weights to the base init on every call. Cleans up any
|
|
# previous LoRA delta application from earlier runs.
|
|
if init_from in ("attn1", "attn2"):
|
|
src_attn = getattr(block, init_from, None)
|
|
if src_attn is not None:
|
|
src_sd = src_attn.state_dict()
|
|
ref_sd = block.ref_attn.state_dict()
|
|
# Skip meta tensors — they have no data and load_state_dict raises.
|
|
compatible = {
|
|
k: v.detach().clone()
|
|
for k, v in src_sd.items()
|
|
if k in ref_sd
|
|
and ref_sd[k].shape == v.shape
|
|
and not v.is_meta
|
|
}
|
|
if compatible:
|
|
ref_sd.update(compatible)
|
|
block.ref_attn.load_state_dict(ref_sd, strict=False)
|
|
copied_count += 1
|
|
else:
|
|
# "none": random init for q/k/v under the seed, zero to_out
|
|
with torch.no_grad():
|
|
for sub_name in ("to_q", "to_k", "to_v"):
|
|
sub = getattr(block.ref_attn, sub_name, None)
|
|
if sub is not None and hasattr(sub, "weight"):
|
|
torch.nn.init.xavier_uniform_(sub.weight)
|
|
if getattr(sub, "bias", None) is not None:
|
|
torch.nn.init.zeros_(sub.bias)
|
|
torch.nn.init.zeros_(block.ref_attn.to_out[0].weight)
|
|
if block.ref_attn.to_out[0].bias is not None:
|
|
torch.nn.init.zeros_(block.ref_attn.to_out[0].bias)
|
|
finally:
|
|
if cpu_state is not None:
|
|
torch.random.set_rng_state(cpu_state)
|
|
|
|
logger.info("ref_attn modules: created=%s, weights reset from '%s' on %d/%d blocks",
|
|
needs_create, init_from, copied_count, len(ltxv.transformer_blocks))
|
|
ltxv._ea_ref_visual_modules_installed = True
|
|
|
|
|
|
def _ea_apply_ref_attn_lora(ltxv, sd, strength):
|
|
count = 0
|
|
for key, lora_a in sd.items():
|
|
if ".ref_attn." not in key or not key.endswith(".lora_A.weight"):
|
|
continue
|
|
lora_b_key = key.replace(".lora_A.weight", ".lora_B.weight")
|
|
if lora_b_key not in sd:
|
|
continue
|
|
parts = key.split(".")
|
|
try:
|
|
block_idx = int(parts[2])
|
|
module_path = ".".join(parts[4:-2])
|
|
module = _ea_nested_module(ltxv.transformer_blocks[block_idx].ref_attn, module_path)
|
|
except Exception as exc:
|
|
logger.warning("Could not map ref_attn LoRA key %s: %s", key, exc)
|
|
continue
|
|
|
|
weight = module.weight
|
|
la = lora_a.to(device=weight.device, dtype=torch.float32)
|
|
lb = sd[lora_b_key].to(device=weight.device, dtype=torch.float32)
|
|
delta = (lb @ la).to(dtype=weight.dtype) * float(strength)
|
|
weight.data.add_(delta)
|
|
count += 1
|
|
logger.info("Applied %d ref_attn LoRA deltas", count)
|
|
return count
|
|
|
|
|
|
def _ea_patch_block_forward_once(ltxv):
|
|
if getattr(ltxv, "_ea_ref_visual_forward_patched", False):
|
|
return
|
|
|
|
import comfy.ldm.common_dit
|
|
|
|
for idx, block in enumerate(ltxv.transformer_blocks):
|
|
original = block.forward
|
|
|
|
def patched(self, *args, _idx=idx, _original=original, **kwargs):
|
|
_EA_PATCH_COUNTERS["block_forward_calls"] += 1
|
|
transformer_options = kwargs.get("transformer_options") or {}
|
|
ref_context = transformer_options.get(_KW_REF_CONTEXT)
|
|
if ref_context is not None:
|
|
_EA_PATCH_COUNTERS["block_forward_saw_ref_ctx"] += 1
|
|
try:
|
|
_EA_PATCH_COUNTERS["block_forward_ref_ctx_norm"] += float(ref_context.norm().item())
|
|
except Exception:
|
|
pass
|
|
out = _original(*args, **kwargs)
|
|
if ref_context is None:
|
|
return out
|
|
|
|
start = transformer_options.get(_KW_REF_START)
|
|
end = transformer_options.get(_KW_REF_END)
|
|
use_ref = (start is None or _idx >= int(start)) and (end is None or _idx <= int(end))
|
|
if not use_ref:
|
|
return out
|
|
|
|
def apply_ref(x):
|
|
rc = ref_context.to(device=x.device, dtype=x.dtype)
|
|
if rc.shape[0] < x.shape[0]:
|
|
rc = rc.expand(x.shape[0], -1, -1)
|
|
scale = float(transformer_options.get(_KW_REF_SCALE, 0.05))
|
|
return x + self.ref_attn(
|
|
comfy.ldm.common_dit.rms_norm(x),
|
|
context=rc,
|
|
transformer_options=transformer_options,
|
|
) * scale
|
|
|
|
if isinstance(out, tuple):
|
|
if not out:
|
|
return out
|
|
vx = apply_ref(out[0])
|
|
return (vx, *out[1:])
|
|
return apply_ref(out)
|
|
|
|
block.forward = types.MethodType(patched, block)
|
|
|
|
ltxv._ea_ref_visual_forward_patched = True
|
|
|
|
|
|
def _ea_loop_compute_diff_str(prev_latent, prev_config, cur_latent, cur_config):
|
|
"""Return a short multi-line string with diff metrics between two LATENT samples tensors."""
|
|
import math
|
|
a = prev_latent.detach().float().cpu()
|
|
b = cur_latent.detach().float().cpu()
|
|
if a.shape != b.shape:
|
|
slices = tuple(slice(0, min(da, db)) for da, db in zip(a.shape, b.shape))
|
|
a = a[slices]
|
|
b = b[slices]
|
|
diff = a - b
|
|
mse = (diff.pow(2)).mean().item()
|
|
rmse = math.sqrt(mse) if mse > 0 else 0.0
|
|
l1 = diff.abs().mean().item()
|
|
max_abs = diff.abs().max().item()
|
|
peak = max(a.abs().max().item(), b.abs().max().item(), 1e-8)
|
|
psnr = 20.0 * math.log10(peak / rmse) if rmse > 0 else float("inf")
|
|
af = a.flatten()
|
|
bf = b.flatten()
|
|
cos = ((af * bf).sum() / (af.norm() * bf.norm()).clamp_min(1e-8)).item()
|
|
identical = bool(torch.equal(a, b))
|
|
|
|
# build per-toggle diff line
|
|
diff_keys = []
|
|
if prev_config and cur_config:
|
|
for k in sorted(set(prev_config.keys()) | set(cur_config.keys())):
|
|
pv = prev_config.get(k)
|
|
cv = cur_config.get(k)
|
|
if pv != cv:
|
|
diff_keys.append(f"{k}: {pv} → {cv}")
|
|
cfg_diff = "; ".join(diff_keys) if diff_keys else "(no toggle changes detected)"
|
|
|
|
return "\n".join([
|
|
f"[LoopingSampler diff vs previous run]",
|
|
f" toggle changes: {cfg_diff}",
|
|
f" identical_bitwise = {identical}",
|
|
f" MSE = {mse:.6e}",
|
|
f" RMSE = {rmse:.6e}",
|
|
f" L1_mean = {l1:.6e}",
|
|
f" MaxAbs = {max_abs:.6e}",
|
|
f" PSNR = {psnr:.2f} dB",
|
|
f" Cosine = {cos:.6f}",
|
|
])
|
|
|
|
|
|
class LTXVEditAnythingLoopingSampler:
|
|
"""Temporal looping sampler with full Edit Anything conditioning per chunk.
|
|
|
|
Mirrors LTXVEditAnythingApply but slices the video temporally and re-applies
|
|
ref+guide IC-LoRA tokens (via LTXVAddGuide.append_keyframe), role embedding,
|
|
and AdaLN per chunk. Each chunk gets its own slice of the guide video.
|
|
|
|
Inputs are explicit positive/negative CONDITIONING; the GUIDER input provides
|
|
only the sampling machinery (CFG/STG params) and its conds are overridden
|
|
per chunk via guider.set_conds.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"vae": ("VAE",),
|
|
"noise": ("NOISE",),
|
|
"sampler": ("SAMPLER",),
|
|
"sigmas": ("SIGMAS",),
|
|
"guider": ("GUIDER", {
|
|
"tooltip": "Provides CFG/STG sampling settings. Its conds are overridden per chunk.",
|
|
}),
|
|
"positive": ("CONDITIONING",),
|
|
"negative": ("CONDITIONING",),
|
|
"latents": ("LATENT",),
|
|
"temporal_tile_size": ("INT", {
|
|
"default": 80, "min": 8, "max": 1000, "step": 8,
|
|
"tooltip": "Temporal tile size in pixel frames.",
|
|
}),
|
|
"temporal_overlap": ("INT", {
|
|
"default": 24, "min": 0, "max": 256, "step": 8,
|
|
"tooltip": "Temporal overlap in pixel frames.",
|
|
}),
|
|
"blend_overlap": ("BOOLEAN", {"default": True}),
|
|
},
|
|
"optional": {
|
|
"lora_name": (["(none)"] + folder_paths.get_filename_list("loras"), {
|
|
"default": "(none)",
|
|
"tooltip": "Edit Anything LoRA. Used to load role_embedding + ref_adaln_proj weights. "
|
|
"Leave on (none) if passing editanything_module instead.",
|
|
}),
|
|
"editanything_module": ("LTXV_EA_MODULE", {
|
|
"tooltip": "Alternative to lora_name: sidecar module from LTXV Edit Anything Module Loader.",
|
|
}),
|
|
"ref_image": ("IMAGE", {
|
|
"tooltip": "Reference image (appearance anchor). Required for IC-LoRA ref tokens / AdaLN.",
|
|
}),
|
|
"guide_frames": ("IMAGE", {
|
|
"tooltip": "Guide video frames (structure/motion). VAE-encoded internally, then sliced per chunk.",
|
|
}),
|
|
"ref_resize_mode": (["pad_to_fit", "center_crop", "stretch"], {"default": "pad_to_fit"}),
|
|
"ref_strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
"guide_strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
"role_strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 20.0, "step": 0.1}),
|
|
"adaln_scale": ("FLOAT", {"default": 2.0, "min": 0.0, "max": 10.0, "step": 0.05}),
|
|
"enable_ic_lora": ("BOOLEAN", {
|
|
"default": True,
|
|
"tooltip": "Append ref + guide_slice as IC-LoRA clean tokens per chunk.",
|
|
}),
|
|
"enable_role_embedding": ("BOOLEAN", {
|
|
"default": False,
|
|
"tooltip": "Sum role_embedding bias onto IC-LoRA ref tokens. Must match training "
|
|
"config `use_visual_ref_role_embedding`. stage2_ref_visual_adaln_crossattn "
|
|
"was trained with this DISABLED.",
|
|
}),
|
|
"enable_adaln": ("BOOLEAN", {"default": True}),
|
|
"reapply_per_chunk": ("BOOLEAN", {
|
|
"default": True,
|
|
"tooltip": "If True: append ref + (sliced) guide tokens on every chunk. "
|
|
"If False: only on chunk 0; later chunks inherit no IC-LoRA append.",
|
|
}),
|
|
"enable_visual_crossattn": ("BOOLEAN", {
|
|
"default": True,
|
|
"tooltip": "Auto-detect ref_visual_proj + ref_attn LoRA keys in the checkpoint "
|
|
"and enable the visual cross-attention path (stage2_ref_visual_adaln_crossattn).",
|
|
}),
|
|
"ref_context_scale": ("FLOAT", {
|
|
"default": 0.01, "min": 0.0, "max": 2.0, "step": 0.01,
|
|
"tooltip": "Scale for ref_attn output added to each block. Training default for "
|
|
"video_to_video_ref_visual_adaln is 0.01 (very conservative).",
|
|
}),
|
|
"ref_token_scale": ("FLOAT", {
|
|
"default": 0.25, "min": 0.0, "max": 2.0, "step": 0.01,
|
|
"tooltip": "Scale applied to projected visual memory tokens before cross-attn. "
|
|
"stage2_ref_visual_adaln_crossattn was trained with 0.25.",
|
|
}),
|
|
"ref_start_block": ("INT", {
|
|
"default": 12, "min": 0, "max": 47, "step": 1,
|
|
"tooltip": "First transformer block that consumes visual ref tokens "
|
|
"(training default 12).",
|
|
}),
|
|
"ref_end_block": ("INT", {
|
|
"default": 35, "min": 0, "max": 47, "step": 1,
|
|
"tooltip": "Last transformer block that consumes visual ref tokens (training default 35).",
|
|
}),
|
|
"ref_init_from": (["attn2", "attn1", "none"], {
|
|
"default": "attn2",
|
|
"tooltip": "How to initialize ref_attn before applying its LoRA. "
|
|
"CRITICAL: must match the training config `init_ref_attn_from`. "
|
|
"stage2_ref_visual_adaln_crossattn was trained with 'attn2'.",
|
|
}),
|
|
"ref_init_seed": ("INT", {
|
|
"default": 42, "min": -1, "max": 2147483647, "step": 1,
|
|
"tooltip": "Seed for random ref_attn init when ref_init_from='none'. Ignored otherwise.",
|
|
}),
|
|
"ref_attn_lora_strength": ("FLOAT", {
|
|
"default": 1.0, "min": -2.0, "max": 2.0, "step": 0.05,
|
|
"tooltip": "Strength multiplier for the ref_attn LoRA deltas.",
|
|
}),
|
|
"debug_ea": ("BOOLEAN", {"default": False}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("LATENT", "IMAGE", "STRING")
|
|
RETURN_NAMES = ("denoised_output", "ref_image_preview", "diff_vs_previous_run")
|
|
FUNCTION = "sample"
|
|
CATEGORY = "LTXV/EditAnything"
|
|
|
|
# Class-level state: stores the previous run's output for in-node A/B diff.
|
|
# Persists across ComfyUI executions within the same Python process.
|
|
_LAST_RUN_LATENT = None
|
|
_LAST_RUN_CONFIG = None
|
|
|
|
# ---- helpers ----
|
|
|
|
@staticmethod
|
|
def _encode_ref(vae, ref_image, px_w, px_h, ref_resize_mode):
|
|
ref_px = _ea_loop_resize_ref_image(ref_image, px_w, px_h, ref_resize_mode)
|
|
return ref_px, vae.encode(ref_px)
|
|
|
|
@staticmethod
|
|
def _encode_guide(vae, guide_frames, px_w, px_h, time_sf):
|
|
import comfy.utils
|
|
gf = comfy.utils.common_upscale(
|
|
guide_frames.movedim(-1, 1), px_w, px_h, "bilinear", "disabled"
|
|
).movedim(1, -1)[:, :, :, :3]
|
|
n_guide = (gf.shape[0] - 1) // time_sf * time_sf + 1
|
|
return vae.encode(gf[:n_guide])
|
|
|
|
@staticmethod
|
|
def _slice_guide_lat(guide_lat, start_lat, end_lat):
|
|
if guide_lat is None:
|
|
return None
|
|
F_guide = guide_lat.shape[2]
|
|
s = max(0, min(start_lat, F_guide))
|
|
e = max(s, min(end_lat, F_guide))
|
|
if e <= s:
|
|
return None
|
|
return guide_lat[:, :, s:e, :, :].contiguous()
|
|
|
|
@staticmethod
|
|
def _build_chunk_conds(
|
|
positive, negative, chunk_latent, chunk_mask,
|
|
ref_lat, guide_chunk_lat, scale_factors,
|
|
ref_strength, guide_strength, enable_ic_lora,
|
|
):
|
|
"""Append ref + guide_chunk to chunk's positive/negative; return updated tuple."""
|
|
import comfy_extras.nodes_lt as nodes_lt
|
|
if not enable_ic_lora or (ref_lat is None and guide_chunk_lat is None):
|
|
return positive, negative, chunk_latent, chunk_mask, 0
|
|
|
|
time_sf = scale_factors[0]
|
|
appended_latent_frames = 0
|
|
|
|
# entry[0] = ref @ t=-time_sf (appearance), training layout
|
|
if ref_lat is not None:
|
|
positive, negative, chunk_latent, chunk_mask = nodes_lt.LTXVAddGuide.append_keyframe(
|
|
positive, negative, -time_sf, chunk_latent, chunk_mask,
|
|
ref_lat, ref_strength, scale_factors, causal_fix=True,
|
|
)
|
|
positive, negative = nodes_lt._append_guide_attention_entry(
|
|
positive, negative,
|
|
ref_lat.shape[2] * ref_lat.shape[3] * ref_lat.shape[4],
|
|
list(ref_lat.shape[2:]),
|
|
strength=ref_strength,
|
|
)
|
|
appended_latent_frames += ref_lat.shape[2]
|
|
|
|
# entry[1] = guide_chunk @ frame 0 of this chunk (motion)
|
|
if guide_chunk_lat is not None:
|
|
positive, negative, chunk_latent, chunk_mask = nodes_lt.LTXVAddGuide.append_keyframe(
|
|
positive, negative, 0, chunk_latent, chunk_mask,
|
|
guide_chunk_lat, guide_strength, scale_factors, causal_fix=True,
|
|
)
|
|
positive, negative = nodes_lt._append_guide_attention_entry(
|
|
positive, negative,
|
|
guide_chunk_lat.shape[2] * guide_chunk_lat.shape[3] * guide_chunk_lat.shape[4],
|
|
list(guide_chunk_lat.shape[2:]),
|
|
strength=guide_strength,
|
|
)
|
|
appended_latent_frames += guide_chunk_lat.shape[2]
|
|
|
|
return positive, negative, chunk_latent, chunk_mask, appended_latent_frames
|
|
|
|
@staticmethod
|
|
def _patch_chunk_model(
|
|
model, role_weight, adaln_cond, role_strength,
|
|
enable_role_embedding, enable_adaln,
|
|
ref_context=None, ref_context_scale=0.05,
|
|
ref_start_block=None, ref_end_block=None,
|
|
):
|
|
m = model.clone()
|
|
ltxv = _find_ltxv_model(m.model)
|
|
if ltxv is None:
|
|
raise ValueError("LTXVEditAnythingLoopingSampler: could not locate LTXVModel inside MODEL wrapper.")
|
|
m.model_options = dict(m.model_options)
|
|
to = dict(m.model_options.get("transformer_options", {}))
|
|
if role_weight is not None and enable_role_embedding:
|
|
_patch_process_input_once(ltxv)
|
|
to[_KW_ROLE_WEIGHT] = role_weight
|
|
to[_KW_APP_LATENTS] = 1
|
|
to[_KW_ROLE_STRENGTH] = float(role_strength)
|
|
if adaln_cond is not None and enable_adaln:
|
|
_patch_prepare_timestep_once(ltxv)
|
|
to[_KW_ADALN_COND] = adaln_cond
|
|
if ref_context is not None:
|
|
to[_KW_REF_CONTEXT] = ref_context
|
|
to[_KW_REF_SCALE] = float(ref_context_scale)
|
|
if ref_start_block is not None:
|
|
to[_KW_REF_START] = int(ref_start_block)
|
|
if ref_end_block is not None:
|
|
to[_KW_REF_END] = int(ref_end_block)
|
|
m.model_options["transformer_options"] = to
|
|
return m
|
|
|
|
@staticmethod
|
|
def _sample_chunk(model, noise, sampler, sigmas, guider, latent, seed_offset=0):
|
|
import comfy.sample
|
|
import comfy.model_management
|
|
import comfy.utils
|
|
import latent_preview
|
|
|
|
latent = latent.copy()
|
|
latent_image = latent["samples"]
|
|
latent_image = comfy.sample.fix_empty_latent_channels(
|
|
guider.model_patcher,
|
|
latent_image,
|
|
latent.get("downscale_ratio_spacial", None),
|
|
)
|
|
latent["samples"] = latent_image
|
|
noise_mask = latent.get("noise_mask")
|
|
|
|
original_seed = getattr(noise, "seed", None)
|
|
if original_seed is not None:
|
|
noise.seed = original_seed + int(seed_offset)
|
|
|
|
x0_output = {}
|
|
callback = latent_preview.prepare_callback(
|
|
guider.model_patcher,
|
|
sigmas.shape[-1] - 1,
|
|
x0_output,
|
|
)
|
|
disable_pbar = not comfy.utils.PROGRESS_BAR_ENABLED
|
|
samples = guider.sample(
|
|
noise.generate_noise(latent),
|
|
latent_image,
|
|
sampler,
|
|
sigmas,
|
|
denoise_mask=noise_mask,
|
|
callback=callback,
|
|
disable_pbar=disable_pbar,
|
|
seed=noise.seed if hasattr(noise, "seed") else None,
|
|
)
|
|
samples = samples.to(comfy.model_management.intermediate_device())
|
|
|
|
if original_seed is not None:
|
|
noise.seed = original_seed
|
|
|
|
out = latent.copy()
|
|
out.pop("downscale_ratio_spacial", None)
|
|
out["samples"] = samples
|
|
return out
|
|
|
|
@staticmethod
|
|
def _add_chunk_blended(final, weights, chunk, start, end, overlap, blend_overlap):
|
|
device = final.device
|
|
dtype = final.dtype
|
|
chunk = chunk.to(device=device, dtype=dtype)
|
|
length = end - start
|
|
w = torch.ones((1, 1, length, 1, 1), device=device, dtype=dtype)
|
|
if blend_overlap and overlap > 0:
|
|
left = min(overlap, length)
|
|
if start > 0 and left > 0:
|
|
w[:, :, :left] *= torch.linspace(0, 1, left, device=device, dtype=dtype).view(1, 1, left, 1, 1)
|
|
right = min(overlap, length)
|
|
if end < final.shape[2] and right > 0:
|
|
w[:, :, -right:] *= torch.linspace(1, 0, right, device=device, dtype=dtype).view(1, 1, right, 1, 1)
|
|
final[:, :, start:end] += chunk * w
|
|
weights[:, :, start:end] += w
|
|
|
|
@staticmethod
|
|
def _set_guider_conds(guider, positive, negative, model_patcher=None):
|
|
import copy as _copy
|
|
g = _copy.copy(guider)
|
|
if hasattr(g, "original_conds"):
|
|
g.original_conds = _copy.deepcopy(g.original_conds)
|
|
if hasattr(g, "set_conds"):
|
|
g.set_conds(positive, negative)
|
|
if hasattr(g, "raw_conds"):
|
|
g.raw_conds = (positive, negative)
|
|
# CRITICAL: replace the guider's model_patcher with our patched clone so
|
|
# that transformer_options (role/AdaLN/ref_context) actually reach the model
|
|
# forward. Without this, the guider keeps using the original (unpatched)
|
|
# model_patcher and every transformer_options-based toggle silently no-ops.
|
|
if model_patcher is not None and hasattr(g, "model_patcher"):
|
|
g.model_patcher = model_patcher
|
|
return g
|
|
|
|
# ---- main ----
|
|
|
|
def sample(
|
|
self,
|
|
model, vae, noise, sampler, sigmas, guider,
|
|
positive, negative, latents,
|
|
temporal_tile_size, temporal_overlap, blend_overlap=True,
|
|
lora_name="(none)", editanything_module=None,
|
|
ref_image=None, guide_frames=None,
|
|
ref_resize_mode="pad_to_fit",
|
|
ref_strength=1.0, guide_strength=1.0,
|
|
role_strength=1.0, adaln_scale=1.0,
|
|
enable_ic_lora=True, enable_role_embedding=True, enable_adaln=True,
|
|
reapply_per_chunk=True,
|
|
enable_visual_crossattn=True,
|
|
ref_context_scale=0.01, ref_token_scale=0.5,
|
|
ref_start_block=0, ref_end_block=35,
|
|
ref_init_from="attn2", ref_init_seed=42,
|
|
ref_attn_lora_strength=1.0,
|
|
debug_ea=False,
|
|
):
|
|
import copy as _copy
|
|
|
|
# Reset diagnostic counters to verify transformer_options propagation
|
|
_ea_reset_counters()
|
|
|
|
# ── ① Load EA weights (lora or sidecar) ────────────────────────────────
|
|
sd = _ea_loop_load_state_dict(lora_name, editanything_module)
|
|
role_weight = _ea_loop_extract_role_weight(sd) if sd else None
|
|
|
|
# Detect visual+crossattn checkpoint
|
|
has_visual = enable_visual_crossattn and sd and _ea_has_ref_visual_keys(sd)
|
|
has_ref_attn_lora = enable_visual_crossattn and sd and _ea_has_ref_attn_lora(sd)
|
|
|
|
# Always-on detection diagnostic
|
|
ref_visual_proj_keys = sorted([k for k in (sd or {}) if k.startswith("ref_visual_proj.")])
|
|
ref_attn_lora_keys = sorted([k for k in (sd or {}) if ".ref_attn." in k])[:3]
|
|
print(f"[EA detect] lora_name='{lora_name}' sd_keys={len(sd) if sd else 0} "
|
|
f"role_weight={'present' if role_weight is not None else 'MISSING'} "
|
|
f"enable_visual_crossattn={enable_visual_crossattn} "
|
|
f"has_visual={has_visual} has_ref_attn_lora={has_ref_attn_lora}")
|
|
print(f"[EA detect] ref_visual_proj.* keys in sd ({len(ref_visual_proj_keys)}): {ref_visual_proj_keys}")
|
|
print(f"[EA detect] sample ref_attn LoRA keys (first 3): {ref_attn_lora_keys}")
|
|
|
|
# ── ② Encode ref / guide once ─────────────────────────────────────────
|
|
scale_factors = vae.downscale_index_formula
|
|
time_sf, w_sf, h_sf = scale_factors
|
|
|
|
samples = latents["samples"]
|
|
_, _, total_frames, lat_h, lat_w = samples.shape
|
|
px_h = lat_h * h_sf
|
|
px_w = lat_w * w_sf
|
|
|
|
ref_lat = None
|
|
ref_preview = None
|
|
if ref_image is not None:
|
|
ref_preview, ref_lat = self._encode_ref(vae, ref_image, px_w, px_h, ref_resize_mode)
|
|
|
|
guide_lat = None
|
|
if guide_frames is not None:
|
|
guide_lat = self._encode_guide(vae, guide_frames, px_w, px_h, time_sf)
|
|
|
|
adaln_cond = None
|
|
if ref_lat is not None and enable_adaln and adaln_scale > 0 and sd:
|
|
adaln_cond = _ea_loop_compute_adaln_cond(sd, ref_lat, adaln_scale)
|
|
|
|
# ── ②b Visual cross-attn path (auto-detected from checkpoint) ─────────
|
|
# Installs ref_attn modules, applies ref_attn LoRA, and computes the
|
|
# 32-token visual context. Done ONCE on the shared ltxv module before
|
|
# the chunk loop.
|
|
ref_context = None
|
|
# Locate the shared ltxv module (same instance whether we use model or
|
|
# guider.model_patcher.model — clones share submodules).
|
|
ltxv_handle = _find_ltxv_model(model.model)
|
|
if ltxv_handle is None and hasattr(guider, "model_patcher"):
|
|
ltxv_handle = _find_ltxv_model(guider.model_patcher.model)
|
|
if has_visual and ref_lat is not None:
|
|
if ltxv_handle is None:
|
|
logger.warning("LTXVEditAnythingLoopingSampler: could not locate LTXV model; skipping visual cross-attn path.")
|
|
else:
|
|
# Force-load model weights to GPU so attn1/attn2 are materialized
|
|
# before we try to copy them into ref_attn. Without this, ComfyUI's
|
|
# lazy loading leaves them on meta device and load_state_dict fails
|
|
# with "Cannot copy out of meta tensor".
|
|
if ref_init_from in ("attn1", "attn2"):
|
|
try:
|
|
import comfy.model_management
|
|
mp_for_load = getattr(guider, "model_patcher", None) or model
|
|
comfy.model_management.load_models_gpu([mp_for_load], force_full_load=True)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Could not force-load model before ref_attn install (%s). "
|
|
"Falling back to random init.", exc
|
|
)
|
|
ref_init_from = "none"
|
|
|
|
context_dim = sd["ref_visual_proj.proj.weight"].shape[0]
|
|
_ea_install_ref_attn_modules(
|
|
ltxv_handle, context_dim, ref_init_seed, init_from=ref_init_from
|
|
)
|
|
if has_ref_attn_lora:
|
|
_ea_apply_ref_attn_lora(ltxv_handle, sd, ref_attn_lora_strength)
|
|
_ea_patch_block_forward_once(ltxv_handle)
|
|
ref_context = _ea_build_ref_visual_context(sd, ref_lat, ref_token_scale)
|
|
if debug_ea:
|
|
print(f"[LTXVEditAnythingLoopingSampler] visual cross-attn ENABLED: "
|
|
f"ref_context shape={list(ref_context.shape)} "
|
|
f"context_dim={context_dim} "
|
|
f"blocks=[{ref_start_block}, {ref_end_block}] "
|
|
f"ref_context_scale={ref_context_scale} "
|
|
f"ref_token_scale={ref_token_scale}")
|
|
|
|
# Also install role/adaln patches on the shared ltxv if needed
|
|
if ltxv_handle is not None:
|
|
if role_weight is not None and enable_role_embedding:
|
|
_patch_process_input_once(ltxv_handle)
|
|
if adaln_cond is not None and enable_adaln:
|
|
_patch_prepare_timestep_once(ltxv_handle)
|
|
|
|
# ── ②c CRITICAL: inject our keys IN-PLACE into the guider's
|
|
# transformer_options dict. The CFGGuider captures
|
|
# `self.model_options = model_patcher.model_options` BY REFERENCE at
|
|
# construction time (see comfy/samplers.py:937). Replacing the outer
|
|
# dict via `model_patcher.model_options = dict(...)` does NOT propagate
|
|
# to the guider's captured reference. We must mutate the existing
|
|
# `transformer_options` sub-dict in place.
|
|
#
|
|
# We also try guider.model_options directly as the primary target since
|
|
# that is what the sampler actually uses (line 972: clone of self.model_options).
|
|
guider_mp = getattr(guider, "model_patcher", None)
|
|
target_model_options = None
|
|
if hasattr(guider, "model_options") and isinstance(getattr(guider, "model_options"), dict):
|
|
target_model_options = guider.model_options
|
|
elif guider_mp is not None and hasattr(guider_mp, "model_options"):
|
|
target_model_options = guider_mp.model_options
|
|
|
|
injected_keys = [] # keys we added (need to delete on restore)
|
|
overwritten_keys = {} # keys we overwrote (need to restore values)
|
|
target_to_dict = None
|
|
if target_model_options is not None:
|
|
if "transformer_options" not in target_model_options:
|
|
target_model_options["transformer_options"] = {}
|
|
target_to_dict = target_model_options["transformer_options"]
|
|
|
|
def _inject(k, v):
|
|
if k in target_to_dict:
|
|
overwritten_keys[k] = target_to_dict[k]
|
|
else:
|
|
injected_keys.append(k)
|
|
target_to_dict[k] = v
|
|
|
|
if role_weight is not None and enable_role_embedding:
|
|
_inject(_KW_ROLE_WEIGHT, role_weight)
|
|
_inject(_KW_APP_LATENTS, 1)
|
|
_inject(_KW_ROLE_STRENGTH, float(role_strength))
|
|
if adaln_cond is not None and enable_adaln:
|
|
_inject(_KW_ADALN_COND, adaln_cond)
|
|
if ref_context is not None:
|
|
_inject(_KW_REF_CONTEXT, ref_context)
|
|
_inject(_KW_REF_SCALE, float(ref_context_scale))
|
|
_inject(_KW_REF_START, int(ref_start_block))
|
|
_inject(_KW_REF_END, int(ref_end_block))
|
|
|
|
# Always print injection diagnostics — critical for debugging propagation.
|
|
guider_cls = type(guider).__name__
|
|
via = "guider.model_options" if target_model_options is getattr(guider, "model_options", None) else "guider.model_patcher.model_options"
|
|
print(f"[EA inject] guider_cls={guider_cls} via={via} "
|
|
f"target_dict_id={id(target_to_dict)} "
|
|
f"role={'yes' if (role_weight is not None and enable_role_embedding) else 'no'} "
|
|
f"adaln={'yes' if (adaln_cond is not None and enable_adaln) else 'no'} "
|
|
f"ref_context={'yes' if ref_context is not None else 'no'}")
|
|
our_keys_in_dict = sorted([k for k in target_to_dict.keys() if k.startswith("ea_")])
|
|
print(f"[EA inject] ea_* keys NOW in target_to_dict: {our_keys_in_dict}")
|
|
# Also check the OTHER possible target to confirm we mutated the one the sampler uses.
|
|
other = None
|
|
other_label = None
|
|
if target_model_options is getattr(guider, "model_options", None) and guider_mp is not None and hasattr(guider_mp, "model_options"):
|
|
other = guider_mp.model_options.get("transformer_options")
|
|
other_label = "guider.model_patcher.model_options.transformer_options"
|
|
elif target_model_options is not getattr(guider, "model_options", None) and hasattr(guider, "model_options"):
|
|
other = guider.model_options.get("transformer_options") if isinstance(getattr(guider, "model_options"), dict) else None
|
|
other_label = "guider.model_options.transformer_options"
|
|
if other is not None:
|
|
other_keys = sorted([k for k in other.keys() if k.startswith("ea_")])
|
|
same = other is target_to_dict
|
|
print(f"[EA inject] other target check: {other_label} id={id(other)} same_obj={same} ea_keys={other_keys}")
|
|
|
|
# ── ③ Tile params (latent space) ──────────────────────────────────────
|
|
tile = max(1, int(temporal_tile_size) // int(time_sf))
|
|
overlap = max(0, int(temporal_overlap) // int(time_sf))
|
|
if overlap >= tile:
|
|
overlap = max(0, tile - 1)
|
|
step = max(1, tile - overlap)
|
|
|
|
final = torch.zeros_like(samples)
|
|
weights = torch.zeros_like(samples)
|
|
|
|
# Pre-derive a noise_mask for the full latent (per-chunk slices come from it)
|
|
full_noise_mask = latents.get("noise_mask")
|
|
if full_noise_mask is None:
|
|
full_noise_mask = torch.ones(
|
|
(samples.shape[0], 1, total_frames, lat_h, lat_w),
|
|
dtype=samples.dtype, device=samples.device,
|
|
)
|
|
|
|
if debug_ea:
|
|
print("[LTXVEditAnythingLoopingSampler DEBUG]")
|
|
print(f" total latent frames : {total_frames}")
|
|
print(f" tile/overlap (lat) : {tile}/{overlap} step={step}")
|
|
print(f" ref_lat : {None if ref_lat is None else list(ref_lat.shape)}")
|
|
print(f" guide_lat : {None if guide_lat is None else list(guide_lat.shape)}")
|
|
print(f" role_weight present : {role_weight is not None}")
|
|
print(f" adaln_cond present : {adaln_cond is not None}")
|
|
|
|
chunk_index = 0
|
|
start = 0
|
|
try:
|
|
while start < total_frames:
|
|
end = min(total_frames, start + tile)
|
|
if start > 0 and end - start < max(1, overlap + 1):
|
|
start = max(0, total_frames - tile)
|
|
end = total_frames
|
|
|
|
chunk_target_frames = end - start
|
|
chunk_latent = samples[:, :, start:end, :, :].clone()
|
|
chunk_mask = full_noise_mask[:, :, start:end, :, :].clone() if full_noise_mask.dim() == 5 else full_noise_mask
|
|
|
|
# Pick guide slice for this chunk
|
|
guide_chunk_lat = self._slice_guide_lat(guide_lat, start, end)
|
|
|
|
do_append = enable_ic_lora and (reapply_per_chunk or chunk_index == 0)
|
|
chunk_pos = _copy.deepcopy(positive)
|
|
chunk_neg = _copy.deepcopy(negative)
|
|
|
|
(chunk_pos, chunk_neg, chunk_latent_app, chunk_mask_app,
|
|
appended) = self._build_chunk_conds(
|
|
chunk_pos, chunk_neg, chunk_latent, chunk_mask,
|
|
ref_lat if do_append else None,
|
|
guide_chunk_lat if do_append else None,
|
|
scale_factors, ref_strength, guide_strength,
|
|
enable_ic_lora=do_append,
|
|
)
|
|
|
|
# NOTE: model_patcher swap kept for compatibility but the real
|
|
# propagation is via guider.model_patcher.model_options mutation
|
|
# above (lines ~1700). The swap alone was insufficient.
|
|
patched_model = self._patch_chunk_model(
|
|
model, role_weight, adaln_cond, role_strength,
|
|
enable_role_embedding, enable_adaln,
|
|
ref_context=ref_context,
|
|
ref_context_scale=ref_context_scale,
|
|
ref_start_block=ref_start_block,
|
|
ref_end_block=ref_end_block,
|
|
)
|
|
|
|
chunk_guider = self._set_guider_conds(
|
|
guider, chunk_pos, chunk_neg, model_patcher=patched_model
|
|
)
|
|
|
|
if debug_ea:
|
|
print(f"[LTXVEditAnythingLoopingSampler] chunk {chunk_index}: "
|
|
f"latent {start}:{end} (target={chunk_target_frames}) "
|
|
f"appended_latent_frames={appended} "
|
|
f"guide_slice={None if guide_chunk_lat is None else list(guide_chunk_lat.shape)}")
|
|
|
|
chunk_in = {"samples": chunk_latent_app, "noise_mask": chunk_mask_app}
|
|
chunk_out = self._sample_chunk(
|
|
patched_model, noise, sampler, sigmas, chunk_guider,
|
|
chunk_in, seed_offset=start,
|
|
)
|
|
|
|
# Strip appended ref/guide frames from tail before blending
|
|
chunk_samples = chunk_out["samples"][:, :, :chunk_target_frames, :, :]
|
|
self._add_chunk_blended(
|
|
final, weights, chunk_samples,
|
|
start, end, overlap, blend_overlap,
|
|
)
|
|
|
|
chunk_index += 1
|
|
if end >= total_frames:
|
|
break
|
|
start += step
|
|
finally:
|
|
# Restore the transformer_options dict we mutated in place.
|
|
# Delete keys we added; restore values we overwrote.
|
|
if target_to_dict is not None:
|
|
for k in injected_keys:
|
|
target_to_dict.pop(k, None)
|
|
for k, v in overwritten_keys.items():
|
|
target_to_dict[k] = v
|
|
|
|
out = latents.copy()
|
|
out.pop("downscale_ratio_spacial", None)
|
|
out["samples"] = final / weights.clamp_min(1e-8)
|
|
if ref_preview is None:
|
|
ref_preview = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
|
|
|
# ── In-node A/B diff: compare current output against the last run held
|
|
# in class state (survives across ComfyUI executions in the same process).
|
|
cur_config = {
|
|
"enable_ic_lora": bool(enable_ic_lora),
|
|
"enable_role_embedding": bool(enable_role_embedding),
|
|
"enable_adaln": bool(enable_adaln),
|
|
"enable_visual_crossattn": bool(enable_visual_crossattn),
|
|
"reapply_per_chunk": bool(reapply_per_chunk),
|
|
"ref_strength": float(ref_strength),
|
|
"guide_strength": float(guide_strength),
|
|
"role_strength": float(role_strength),
|
|
"adaln_scale": float(adaln_scale),
|
|
"ref_context_scale": float(ref_context_scale),
|
|
"ref_token_scale": float(ref_token_scale),
|
|
"ref_start_block": int(ref_start_block),
|
|
"ref_end_block": int(ref_end_block),
|
|
"lora_name": str(lora_name),
|
|
"has_visual_in_sd": bool(has_visual),
|
|
"has_ref_attn_lora_in_sd": bool(has_ref_attn_lora),
|
|
"temporal_tile_size": int(temporal_tile_size),
|
|
"temporal_overlap": int(temporal_overlap),
|
|
}
|
|
cls = self.__class__
|
|
if cls._LAST_RUN_LATENT is None:
|
|
diff_str = "[LoopingSampler diff vs previous run] no previous run yet — saved current as baseline"
|
|
else:
|
|
diff_str = _ea_loop_compute_diff_str(
|
|
cls._LAST_RUN_LATENT, cls._LAST_RUN_CONFIG, out["samples"], cur_config
|
|
)
|
|
|
|
# Append patch-firing counters to the diff string — always shown, so we
|
|
# can diagnose whether transformer_options is actually propagating.
|
|
counters_str = _ea_format_counters()
|
|
diff_str = diff_str + "\n" + counters_str
|
|
|
|
if debug_ea:
|
|
print(diff_str)
|
|
# Also always print counters to console for easy log inspection
|
|
print(counters_str)
|
|
# Store current as previous for next run. Keep on CPU to avoid VRAM pressure.
|
|
cls._LAST_RUN_LATENT = out["samples"].detach().cpu().clone()
|
|
cls._LAST_RUN_CONFIG = cur_config
|
|
|
|
return (out, ref_preview, diff_str)
|
|
|
|
|
|
class LTXVLatentDiffMetrics:
|
|
"""Compare two LATENTs and report MSE / RMSE / L1 / PSNR / cosine similarity.
|
|
|
|
Use to A/B-test toggles: run the sampler with config A, SaveLatent → run with
|
|
config B → wire SaveLatent.A and the new output into this node. Outputs both
|
|
a formatted string for UI/console and separate floats so you can chain to a
|
|
plotter or save-as-text node.
|
|
|
|
If the two latents have different shapes (e.g. spatial mismatch from VAE
|
|
differences), it falls back to comparing the overlapping subregion.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"latent_a": ("LATENT",),
|
|
"latent_b": ("LATENT",),
|
|
},
|
|
"optional": {
|
|
"label": ("STRING", {"default": "A vs B", "multiline": False}),
|
|
"print_console": ("BOOLEAN", {"default": True}),
|
|
"compare_noise_mask": ("BOOLEAN", {
|
|
"default": False,
|
|
"tooltip": "If True, also reports diff stats restricted to the noisy region of latent_a (where noise_mask > 0).",
|
|
}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "LATENT", "FLOAT", "FLOAT", "FLOAT", "FLOAT")
|
|
RETURN_NAMES = ("metrics", "latent_a", "mse", "psnr", "l1", "cosine")
|
|
FUNCTION = "compare"
|
|
CATEGORY = "LTXV/EditAnything"
|
|
|
|
@staticmethod
|
|
def _slice_overlap(a, b):
|
|
if a.shape == b.shape:
|
|
return a, b, False
|
|
slices = tuple(slice(0, min(da, db)) for da, db in zip(a.shape, b.shape))
|
|
return a[slices], b[slices], True
|
|
|
|
@staticmethod
|
|
def _stats(diff, a, b):
|
|
import math
|
|
mse = (diff.pow(2)).mean().item()
|
|
rmse = math.sqrt(mse) if mse > 0 else 0.0
|
|
l1 = diff.abs().mean().item()
|
|
max_abs = diff.abs().max().item()
|
|
peak = max(a.abs().max().item(), b.abs().max().item(), 1e-8)
|
|
psnr = 20.0 * math.log10(peak / rmse) if rmse > 0 else float("inf")
|
|
a_flat = a.flatten()
|
|
b_flat = b.flatten()
|
|
denom = (a_flat.norm() * b_flat.norm()).clamp_min(1e-8)
|
|
cos = ((a_flat * b_flat).sum() / denom).item()
|
|
return mse, rmse, l1, max_abs, psnr, cos
|
|
|
|
def compare(self, latent_a, latent_b, label="A vs B", print_console=True, compare_noise_mask=False):
|
|
a_full = latent_a["samples"].detach().float().cpu()
|
|
b_full = latent_b["samples"].detach().float().cpu()
|
|
a, b, sliced = self._slice_overlap(a_full, b_full)
|
|
identical = bool(torch.equal(a, b))
|
|
diff = a - b
|
|
|
|
mse, rmse, l1, max_abs, psnr, cos = self._stats(diff, a, b)
|
|
a_mean, a_std = a.mean().item(), a.std().item()
|
|
b_mean, b_std = b.mean().item(), b.std().item()
|
|
|
|
lines = [
|
|
f"[LatentDiff] {label}",
|
|
f" shape_a = {list(latent_a['samples'].shape)}",
|
|
f" shape_b = {list(latent_b['samples'].shape)}",
|
|
f" identical_bitwise = {identical}{' (after slicing to overlap)' if sliced and not identical else ''}",
|
|
f" MSE = {mse:.6e}",
|
|
f" RMSE = {rmse:.6e}",
|
|
f" L1_mean = {l1:.6e}",
|
|
f" MaxAbs = {max_abs:.6e}",
|
|
f" PSNR = {psnr:.2f} dB",
|
|
f" Cosine = {cos:.6f}",
|
|
f" A mean={a_mean:+.4f} std={a_std:.4f}",
|
|
f" B mean={b_mean:+.4f} std={b_std:.4f}",
|
|
]
|
|
|
|
if compare_noise_mask and "noise_mask" in latent_a and latent_a["noise_mask"] is not None:
|
|
mask = latent_a["noise_mask"].detach().float().cpu()
|
|
if mask.dim() == 5 and mask.shape[2:] == a.shape[2:]:
|
|
m = (mask > 0).any(dim=1, keepdim=True).expand_as(a)
|
|
if m.any():
|
|
a_m = a[m]
|
|
b_m = b[m]
|
|
diff_m = a_m - b_m
|
|
mse_m, rmse_m, l1_m, max_m, psnr_m, cos_m = self._stats(diff_m, a_m, b_m)
|
|
lines.append(f" --- restricted to noisy region ({int(m.sum().item())} voxels) ---")
|
|
lines.append(f" MSE_mask = {mse_m:.6e}")
|
|
lines.append(f" L1_mask = {l1_m:.6e}")
|
|
lines.append(f" PSNR_mask = {psnr_m:.2f} dB")
|
|
|
|
if sliced:
|
|
lines.append(f" WARNING: shapes differ; sliced to overlap {list(a.shape)}")
|
|
|
|
out = "\n".join(lines)
|
|
if print_console:
|
|
print(out)
|
|
return (out, latent_a, float(mse), float(psnr), float(l1), float(cos))
|
|
|
|
|
|
class LTXVImageDiffMetrics:
|
|
"""Same as LTXVLatentDiffMetrics but for decoded IMAGE (post-VAE) — uses [0, 1]
|
|
pixel range for PSNR. Useful after decoding two runs to compare visually-relevant
|
|
pixel error.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image_a": ("IMAGE",),
|
|
"image_b": ("IMAGE",),
|
|
},
|
|
"optional": {
|
|
"label": ("STRING", {"default": "A vs B", "multiline": False}),
|
|
"print_console": ("BOOLEAN", {"default": True}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "IMAGE", "FLOAT", "FLOAT", "FLOAT")
|
|
RETURN_NAMES = ("metrics", "image_a", "mse", "psnr", "l1")
|
|
FUNCTION = "compare"
|
|
CATEGORY = "LTXV/EditAnything"
|
|
|
|
def compare(self, image_a, image_b, label="A vs B", print_console=True):
|
|
import math
|
|
a_full = image_a.detach().float().cpu().clamp(0, 1)
|
|
b_full = image_b.detach().float().cpu().clamp(0, 1)
|
|
slices = tuple(slice(0, min(da, db)) for da, db in zip(a_full.shape, b_full.shape))
|
|
a = a_full[slices]
|
|
b = b_full[slices]
|
|
sliced = a.shape != a_full.shape or b.shape != b_full.shape
|
|
|
|
diff = a - b
|
|
mse = (diff.pow(2)).mean().item()
|
|
rmse = math.sqrt(mse) if mse > 0 else 0.0
|
|
l1 = diff.abs().mean().item()
|
|
max_abs = diff.abs().max().item()
|
|
psnr = 20.0 * math.log10(1.0 / rmse) if rmse > 0 else float("inf")
|
|
identical = bool(torch.equal(a, b))
|
|
|
|
# per-frame MSE for quick spot of where frames diverge
|
|
if a.dim() == 4:
|
|
per_frame_mse = diff.pow(2).mean(dim=(1, 2, 3)).tolist()
|
|
per_frame_summary = (
|
|
f"per_frame_mse: min={min(per_frame_mse):.4e} "
|
|
f"max={max(per_frame_mse):.4e} "
|
|
f"argmax_frame={per_frame_mse.index(max(per_frame_mse))}"
|
|
)
|
|
else:
|
|
per_frame_summary = "(non-video tensor)"
|
|
|
|
lines = [
|
|
f"[ImageDiff] {label}",
|
|
f" shape_a = {list(image_a.shape)}",
|
|
f" shape_b = {list(image_b.shape)}",
|
|
f" identical_bitwise = {identical}",
|
|
f" MSE = {mse:.6e}",
|
|
f" RMSE = {rmse:.6e}",
|
|
f" L1_mean = {l1:.6e}",
|
|
f" MaxAbs = {max_abs:.6e}",
|
|
f" PSNR = {psnr:.2f} dB (pixel range [0,1])",
|
|
f" {per_frame_summary}",
|
|
]
|
|
if sliced:
|
|
lines.append(f" WARNING: shapes differ; sliced to overlap {list(a.shape)}")
|
|
|
|
out = "\n".join(lines)
|
|
if print_console:
|
|
print(out)
|
|
return (out, image_a, float(mse), float(psnr), float(l1))
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"LTXVEditAnythingModuleLoader": LTXVEditAnythingModuleLoader,
|
|
"LTXVEditAnythingLoopingSampler": LTXVEditAnythingLoopingSampler,
|
|
"LTXVApplyNeutralMask": LTXVApplyNeutralMask,
|
|
"LTXVResizeReferenceByMask": LTXVResizeReferenceByMask,
|
|
"LTXVLatentDiffMetrics": LTXVLatentDiffMetrics,
|
|
"LTXVImageDiffMetrics": LTXVImageDiffMetrics,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"LTXVEditAnythingModuleLoader": "LTXV Edit Anything Module Loader",
|
|
"LTXVEditAnythingLoopingSampler": "LTXV Edit Anything Looping Sampler",
|
|
"LTXVApplyNeutralMask": "LTXV Apply Neutral Mask",
|
|
"LTXVResizeReferenceByMask": "LTXV Resize Reference By Mask",
|
|
"LTXVLatentDiffMetrics": "LTXV Latent Diff Metrics",
|
|
"LTXVImageDiffMetrics": "LTXV Image Diff Metrics",
|
|
}
|