"""LTX 2.4 diffusion video VAE decoder (NADiffusionDecoder). Port of the reference ``DiffusionVideoDecoder`` without the NATTEN dependency: ``natten.na3d`` is replaced by ``comfy_kitchen.na3d``, which reproduces NATTEN's semantics (window of exactly ``kernel_size`` per query, shifted inward at grid boundaries, dilation 1) and dispatches cuda/triton/eager per device and dtype (the eager backend covers CPU and fp32). Stages 1-4 deterministically upsample the latent into a context volume via NA transformer blocks + linear pixel-shuffle upsamples. Stage 5 runs ``DiffusionNABlock``s that denoise patchified noised pixels ``x_t`` guided by that context through AdaLN-Zero scale/shift. The 2.4 checkpoint is single-step ``x0``: one forward pass yields the pixels directly, no Euler loop. State dict keys match the shipped checkpoints directly (fused ``attn.qkv``, ``t_embedder.mlp.{0,2}``, ``shared_adaln.proj``); no rename pass is needed. """ import math import torch import torch.nn.functional as F from einops import rearrange from torch import nn import comfy.model_management from comfy.ldm.lightricks.model import get_timestep_embedding from .causal_video_autoencoder import Encoder, processor import comfy_kitchen # Token chunk for the SwiGLU MLP (bounds the [chunk, hidden] workspace). MLP_TOKEN_CHUNK = 65536 def rms_norm(x, weight, eps=1e-6): if hasattr(F, "rms_norm"): return F.rms_norm(x, (x.shape[-1],), weight=weight.to(x.dtype), eps=eps) x_f = x.float() x_f = x_f * torch.rsqrt(x_f.pow(2).mean(-1, keepdim=True) + eps) return (x_f * weight.float()).to(x.dtype) class RMSNorm(nn.Module): def __init__(self, dim, eps=1e-6): super().__init__() self.eps = eps self.weight = nn.Parameter(torch.ones(dim)) def forward(self, x): return rms_norm(x, self.weight, self.eps) def patchify(x, patch_size_hw, patch_size_t=1): if patch_size_hw == 1 and patch_size_t == 1: return x return rearrange(x, "b c (f p) (h q) (w r) -> b (c p r q) f h w", p=patch_size_t, q=patch_size_hw, r=patch_size_hw) def unpatchify(x, patch_size_hw, patch_size_t=1): if patch_size_hw == 1 and patch_size_t == 1: return x return rearrange(x, "b (c p r q) f h w -> b c (f p) (h q) (w r)", p=patch_size_t, q=patch_size_hw, r=patch_size_hw) # --- Absolute per-axis RoPE (matches ltx-core rope.py numerics) --- def default_rope_dim_split(head_dim): d_t = (head_dim // 4) // 2 * 2 d_hw = (head_dim - d_t) // 2 if d_hw % 2 != 0: d_t -= 2 d_hw = (head_dim - d_t) // 2 return (d_t, d_hw, d_hw) def rope_inv_freqs(dim, base=10000.0, device=None): out_device = device if not comfy.model_management.supports_fp64(device): device = torch.device("cpu") exponents = torch.arange(0, dim, 2, dtype=torch.float64, device=device) / dim return (1.0 / torch.pow(torch.tensor(float(base), dtype=torch.float64, device=device), exponents)).to(dtype=torch.float32, device=out_device) def _rope_tables(lengths, inv_freqs, device): """Precompute per-axis fp32 cos/sin tables for global 0-based positions.""" tables = [] for length, inv in zip(lengths, inv_freqs): pos = torch.arange(length, dtype=torch.float32, device=device) ang = pos[:, None] * inv[None, :] tables.append((ang.cos(), ang.sin())) return tables def _rope_matrices_slice(tables, t0, t1, h, w): """Per-token rotation matrices ``(1, ts*h*w, 1, hd/2, 2, 2)`` fp32 for ``comfy_kitchen.rms_rope_`` (interleaved-pair convention), covering global frames ``[t0, t1)`` of the axis-factorized tables.""" parts = [] for (c, s), sl in zip(tables, (slice(t0, t1), slice(None), slice(None))): c, s = c[sl], s[sl] parts.append(torch.stack([c, -s, s, c], dim=-1).reshape(c.shape[0], 1, 1, c.shape[1], 2, 2)) ts = t1 - t0 freqs = torch.cat([ parts[0].expand(ts, h, w, -1, 2, 2), parts[1].transpose(0, 1).expand(ts, h, w, -1, 2, 2), parts[2].movedim(0, 2).expand(ts, h, w, -1, 2, 2), ], dim=3) return freqs.reshape(1, ts * h * w, 1, -1, 2, 2) class NeighborhoodAttention3D(nn.Module): """QKV (fused, matching checkpoint keys) + q/k RMSNorm + abs RoPE + NA.""" def __init__(self, dim, kernel_size, head_dim=64, rope_base=10000.0): super().__init__() self.dim = dim self.num_heads = dim // head_dim self.head_dim = head_dim self.kernel_size = tuple(kernel_size) self.scale = head_dim ** -0.5 self.rope_split = default_rope_dim_split(head_dim) self.rope_base = rope_base self.qkv = nn.Linear(dim, dim * 3, bias=True) self.proj = nn.Linear(dim, dim, bias=True) self.q_norm = RMSNorm(head_dim, eps=1e-6) self.k_norm = RMSNorm(head_dim, eps=1e-6) def forward(self, x, pre=None, add_to=None): """``pre`` (per-token norm/modulate) is applied slice-wise so the full pre-attention tensor is never materialized; ``add_to`` streams the output projection into it in place (residual add) and returns it. Both bound peak memory without changing results.""" batch, t, h, w, _ = x.shape inv_freqs = tuple(rope_inv_freqs(d, self.rope_base, device=x.device) for d in self.rope_split) tables = _rope_tables((t, h, w), inv_freqs, x.device) shape = (batch, t, h, w, self.num_heads, self.head_dim) q = torch.empty(shape, dtype=x.dtype, device=x.device) k = torch.empty(shape, dtype=x.dtype, device=x.device) v = torch.empty(shape, dtype=x.dtype, device=x.device) q_weight = (self.q_norm.weight.detach() * self.scale).to(x.dtype) # scale commutes with the rotation k_weight = self.k_norm.weight.detach().to(x.dtype) chunk = max(1, (2 ** 25) // max(h * w * self.dim, 1)) for t0 in range(0, t, chunk): t1 = min(t0 + chunk, t) sl = x[:, t0:t1] if pre is None else pre(x[:, t0:t1]) qc, kc, vc = self.qkv(sl).chunk(3, dim=-1) cshape = (batch, t1 - t0, h, w, self.num_heads, self.head_dim) q[:, t0:t1] = qc.reshape(cshape) k[:, t0:t1] = kc.reshape(cshape) v[:, t0:t1] = vc.reshape(cshape) freqs = _rope_matrices_slice(tables, t0, t1, h, w) nt = (t1 - t0) * h * w for b in range(batch): comfy_kitchen.rms_rope_( q[b, t0:t1].view(1, nt, self.num_heads, self.head_dim), k[b, t0:t1].view(1, nt, self.num_heads, self.head_dim), freqs, q_weight, k_weight) out = comfy_kitchen.na3d(q, k, v, list(self.kernel_size), None, 1.0) del q, k, v out = out.reshape(batch, t, h, w, self.dim) res = add_to if add_to is not None else torch.empty_like(out) for t0 in range(0, t, chunk): t1 = min(t0 + chunk, t) if add_to is not None: res[:, t0:t1] += self.proj(out[:, t0:t1]) else: res[:, t0:t1] = self.proj(out[:, t0:t1]) return res class SwiGLU(nn.Module): """``w_down(silu(w_gate(x)) * w_up(x))``, chunked over tokens to bound the ``[chunk, hidden]`` workspace.""" def __init__(self, dim, hidden_dim): super().__init__() self.w_up = nn.Linear(dim, hidden_dim, bias=False) self.w_gate = nn.Linear(dim, hidden_dim, bias=False) self.w_down = nn.Linear(hidden_dim, dim, bias=False) def forward(self, x, pre=None, add_to=None): """``pre``/``add_to`` as in ``NeighborhoodAttention3D.forward``.""" _, t, h, w, _ = x.shape chunk = max(1, MLP_TOKEN_CHUNK // max(h * w, 1)) out = add_to if add_to is not None else torch.empty_like(x) for t0 in range(0, t, chunk): t1 = min(t0 + chunk, t) sl = x[:, t0:t1] if pre is None else pre(x[:, t0:t1]) y = self.w_down(F.silu(self.w_gate(sl)) * self.w_up(sl)) if add_to is not None: out[:, t0:t1] += y else: out[:, t0:t1] = y return out class NABlock(nn.Module): """Pre-norm transformer block: NA -> SwiGLU MLP with residual adds.""" def __init__(self, dim, kernel_size, head_dim=64, mlp_ratio=4.0): super().__init__() self.norm1 = RMSNorm(dim, eps=1e-6) self.attn = NeighborhoodAttention3D(dim, kernel_size, head_dim=head_dim) self.norm2 = RMSNorm(dim, eps=1e-6) hidden = (int(dim * mlp_ratio) + 15) // 16 * 16 self.mlp = SwiGLU(dim, hidden) def forward(self, x): x = self.attn(x, pre=self.norm1, add_to=x) return self.mlp(x, pre=self.norm2, add_to=x) def modulate(x, scale, shift): return x * (1.0 + scale) + shift class AdaLNZero(nn.Module): """``t_emb`` -> 7 (scale/shift/gate) chunks; gate slots unused (folded at export).""" NUM_CHUNKS = 7 def __init__(self, dim, t_emb_dim): super().__init__() self.proj = nn.Linear(t_emb_dim, self.NUM_CHUNKS * dim, bias=True) def forward(self, t_emb): h = self.proj(F.silu(t_emb)) return tuple(c[:, None, None, None, :] for c in h.chunk(self.NUM_CHUNKS, dim=-1)) class DiffusionNABlock(nn.Module): """NA + SwiGLU with shared AdaLN-Zero scale/shift (ungated residuals).""" def __init__(self, dim, kernel_size, context_channels, head_dim=64, mlp_ratio=4.0): super().__init__() self.context_proj = nn.Linear(context_channels, dim, bias=True) self.scale_shift_table = nn.Parameter(torch.zeros(AdaLNZero.NUM_CHUNKS, dim)) self.norm1 = RMSNorm(dim, eps=1e-6) self.attn = NeighborhoodAttention3D(dim, kernel_size, head_dim=head_dim) self.norm2 = RMSNorm(dim, eps=1e-6) hidden = (int(dim * mlp_ratio) + 15) // 16 * 16 self.mlp = SwiGLU(dim, hidden) def forward(self, x, latent_context, modulation): scale_msa, shift_msa, _, scale_mlp, shift_mlp, _, _ = [ modulation[i] + self.scale_shift_table[i].view(1, 1, 1, 1, -1) for i in range(AdaLNZero.NUM_CHUNKS) ] chunk = max(1, MLP_TOKEN_CHUNK // max(x.shape[2] * x.shape[3], 1)) for t0 in range(0, x.shape[1], chunk): x[:, t0:t0 + chunk] += self.context_proj(latent_context[:, t0:t0 + chunk]) x = self.attn(x, pre=lambda s: modulate(self.norm1(s), scale_msa, shift_msa), add_to=x) return self.mlp(x, pre=lambda s: modulate(self.norm2(s), scale_mlp, shift_mlp), add_to=x) class LinearPixelShuffleUpsample(nn.Module): """Linear channel-expand, then channels-last pixel shuffle.""" def __init__(self, in_channels, stride, out_channels_reduction_factor=1): super().__init__() self.stride = tuple(stride) proj_out_channels = math.prod(stride) * in_channels // out_channels_reduction_factor self.out_channels = proj_out_channels // math.prod(stride) self.proj = nn.Linear(in_channels, proj_out_channels, bias=True) def forward(self, x, drop_leading_frame=True): batch, t, h, w, _ = x.shape p1, p2, p3 = self.stride out = torch.empty((batch, t * p1, h * p2, w * p3, self.out_channels), dtype=x.dtype, device=x.device) chunk = max(1, MLP_TOKEN_CHUNK // max(h * w, 1)) for t0 in range(0, t, chunk): t1 = min(t0 + chunk, t) out[:, t0 * p1:t1 * p1] = rearrange( self.proj(x[:, t0:t1]), "b t h w (c p1 p2 p3) -> b (t p1) (h p2) (w p3) c", p1=p1, p2=p2, p3=p3, ) if p1 == 2 and drop_leading_frame: # The causal temporal pixel-shuffle duplicates the leading frame. out = out[:, 1:] return out class TimestepEmbedder(nn.Module): """Sinusoidal(256) -> MLP. ``mlp.{0,2}`` naming matches the checkpoint.""" def __init__(self, t_emb_dim=384, freq_dim=256): super().__init__() self.freq_dim = freq_dim self.mlp = nn.Sequential( nn.Linear(freq_dim, t_emb_dim, bias=True), nn.SiLU(), nn.Linear(t_emb_dim, t_emb_dim, bias=True), ) def forward(self, timestep, dtype): emb = get_timestep_embedding(timestep.flatten(), self.freq_dim, flip_sin_to_cos=True, downscale_freq_shift=0, scale=1) return self.mlp(emb.to(dtype)) class NADiffusionDecoder(nn.Module): """Stages 1-4 (deterministic NA upsample) + stage-5 diffusion blocks. Input latent must already be un-normalized (the wrapper applies ``per_channel_statistics.un_normalize``, same as the conv VAE path). """ def __init__( self, in_channels=128, out_channels=3, patch_size=4, head_dim=64, stage_channels=(2048, 1024, 512, 512, 256), stage_depths=(4, 6, 4, 2, 8), stage_kernels=((3, 7, 7), (3, 7, 7), (3, 5, 5), (3, 5, 5), (11, 11, 11)), upsamples=(((1, 2, 2), 2), ((2, 1, 1), 2), ((2, 2, 2), 1), ((2, 2, 2), 2)), stage5_kernel=(11, 11, 11), t_emb_dim=384, default_num_inference_steps=1, timestep_scale_multiplier=1000.0, model_output_type="x0", ): super().__init__() self.patch_size = patch_size self.out_channels = out_channels self.timestep_scale_multiplier = timestep_scale_multiplier self.model_output_type = model_output_type self.register_buffer( "default_inference_timesteps", torch.linspace(1.0, 1.0 / default_num_inference_steps, default_num_inference_steps), persistent=False, ) self.temporal_upscale = math.prod(s[0] for s, _ in upsamples) self.spatial_upscale = math.prod(s[1] for s, _ in upsamples) * patch_size # NATTEN-style last-frame border mitigation: replicate the last latent # frame through stages 1-4, crop the appendix off the context after. self.trailing_pad_latent_frames = (stage_kernels[0][0] // 2) * 2 self.conv_in = nn.Linear(in_channels, stage_channels[0], bias=True) self.det_stages = nn.ModuleList() self.upsamples = nn.ModuleList() for stage_i in range(len(stage_channels) - 1): c = stage_channels[stage_i] self.det_stages.append(nn.ModuleList( [NABlock(c, stage_kernels[stage_i], head_dim=head_dim) for _ in range(stage_depths[stage_i])] )) stride, reduction = upsamples[stage_i] self.upsamples.append(LinearPixelShuffleUpsample(c, stride, out_channels_reduction_factor=reduction)) self.t_embedder = TimestepEmbedder(t_emb_dim=t_emb_dim) c5 = stage_channels[-1] self.context_channels = c5 noised_pixel_channels = out_channels * (patch_size ** 2) self.conv_in_x_t = nn.Linear(noised_pixel_channels, c5, bias=True) self.shared_adaln = AdaLNZero(c5, t_emb_dim) self.diff_blocks = nn.ModuleList([ DiffusionNABlock(c5, stage5_kernel, context_channels=c5, head_dim=head_dim) for _ in range(stage_depths[-1]) ]) self.norm_out = RMSNorm(c5, eps=1e-6) self.conv_out = nn.Linear(c5, noised_pixel_channels, bias=True) def forward_pre_diffusion(self, z, drop_leading_frame=True, pad_trailing=True): """Stages 1-4: latent -> stage-5 context, channels-last. ``drop_leading_frame`` must be True only when ``z`` contains the latent's true temporal origin (t=0); tiled callers decoding a later temporal chunk pass False (the duplicate leading frame belongs solely to the origin chunk). ``pad_trailing`` only for chunks containing the latent's last frame.""" n = self.trailing_pad_latent_frames if pad_trailing else 0 if n > 0: z = torch.cat([z, z[:, :, -1:].expand(-1, -1, n, -1, -1)], dim=2) x = z.permute(0, 2, 3, 4, 1) x = self.conv_in(x) for stage_i, blocks in enumerate(self.det_stages): for block in blocks: x = block(x) x = self.upsamples[stage_i](x, drop_leading_frame=drop_leading_frame) if n > 0: x = x[:, :-(n * self.temporal_upscale)] return x def forward_diff_step(self, context, x_t, t): x = patchify(x_t, patch_size_hw=self.patch_size, patch_size_t=1) x = self.conv_in_x_t(x.permute(0, 2, 3, 4, 1)) t_emb = self.t_embedder(self.timestep_scale_multiplier * t, dtype=x.dtype) modulation = self.shared_adaln(t_emb) for block in self.diff_blocks: x = block(x, context, modulation) x = self.norm_out(x) x = self.conv_out(x) x = x.permute(0, 4, 1, 2, 3) return unpatchify(x, patch_size_hw=self.patch_size, patch_size_t=1) def forward(self, z, generator=None, drop_leading_frame=True, pad_trailing=True): context = self.forward_pre_diffusion(z, drop_leading_frame=drop_leading_frame, pad_trailing=pad_trailing) batch, t5, h5, w5, _ = context.shape pixel_shape = (batch, self.out_channels, t5, h5 * self.patch_size, w5 * self.patch_size) x_t = torch.randn(pixel_shape, dtype=z.dtype, device=z.device, generator=generator) timesteps = self.default_inference_timesteps.to(z.device) num_steps = timesteps.shape[0] for i in range(num_steps): t_now = timesteps[i].expand(batch) model_out = self.forward_diff_step(context, x_t, t_now) if self.model_output_type == "x0": x0 = model_out if i == num_steps - 1: return x0 velocity = (x_t.float() - x0.float()) / timesteps[i] else: # "v" velocity = model_out.float() if i == num_steps - 1: return (x_t.float() - timesteps[i] * velocity).to(z.dtype) t_next = timesteps[i + 1] if i + 1 < num_steps else torch.zeros_like(timesteps[i]) x_t = (x_t.float() - (timesteps[i] - t_next) * velocity).to(z.dtype) return x_t LTX_24_VAE_CONFIG = { "_class_name": "CausalDiffusionVAE", "dims": 3, "model_output_type": "x0", "encoder": { "dims": 3, "in_channels": 3, "out_channels": 128, "blocks": [ ["res_x", {"num_layers": 4}], ["compress_space_res", {"multiplier": 2}], ["res_x", {"num_layers": 6}], ["compress_time_res", {"multiplier": 2}], ["res_x", {"num_layers": 4}], ["compress_all_res", {"multiplier": 2}], ["res_x", {"num_layers": 2}], ["compress_all_res", {"multiplier": 1}], ["res_x", {"num_layers": 2}], ], "patch_size": 4, "latent_log_var": "constant", "norm_layer": "pixel_norm", "base_channels": 128, "spatial_padding_mode": "zeros", }, "decoder": { "in_channels": 128, "out_channels": 3, "patch_size": 4, "head_dim": 64, "stage_channels": [2048, 1024, 512, 512, 256], "stage_depths": [4, 6, 4, 2, 8], "stage_kernels": [[3, 7, 7], [3, 7, 7], [3, 5, 5], [3, 5, 5], [11, 11, 11]], "upsamples": [[[1, 2, 2], 2], [[2, 1, 1], 2], [[2, 2, 2], 1], [[2, 2, 2], 2]], "stage5_kernel": [11, 11, 11], "timestep_scale_multiplier": 1000.0, "default_num_inference_steps": 1, }, } class CausalDiffusionVAE(nn.Module): """LTX 2.4 video VAE: conv encoder (shared with the 2.0 arch) + NA diffusion decoder. Interface mirrors ``causal_video_autoencoder.VideoVAE``. """ def __init__(self, config=None): super().__init__() if config is None: config = LTX_24_VAE_CONFIG self.config = config enc = config.get("encoder", LTX_24_VAE_CONFIG["encoder"]) dec = config.get("decoder", LTX_24_VAE_CONFIG["decoder"]) dec_defaults = LTX_24_VAE_CONFIG["decoder"] self.encoder = Encoder( dims=enc.get("dims", 3), in_channels=enc.get("in_channels", 3), out_channels=enc.get("out_channels", 128), blocks=enc.get("blocks", LTX_24_VAE_CONFIG["encoder"]["blocks"]), patch_size=enc.get("patch_size", 4), latent_log_var=enc.get("latent_log_var", "constant"), norm_layer=enc.get("norm_layer", "pixel_norm"), spatial_padding_mode=enc.get("spatial_padding_mode", "zeros"), base_channels=enc.get("base_channels", 128), ) self.decoder = NADiffusionDecoder( in_channels=dec.get("in_channels", 128), out_channels=dec.get("out_channels", 3), patch_size=dec.get("patch_size", 4), head_dim=dec.get("head_dim", 64), stage_channels=tuple(dec.get("stage_channels", dec_defaults["stage_channels"])), stage_depths=tuple(dec.get("stage_depths", dec_defaults["stage_depths"])), stage_kernels=tuple(tuple(k) for k in dec.get("stage_kernels", dec_defaults["stage_kernels"])), upsamples=tuple((tuple(s), r) for s, r in dec.get("upsamples", dec_defaults["upsamples"])), stage5_kernel=tuple(dec.get("stage5_kernel", dec_defaults["stage5_kernel"])), t_emb_dim=dec.get("t_emb_dim", 384), default_num_inference_steps=dec.get("default_num_inference_steps", 1), timestep_scale_multiplier=dec.get("timestep_scale_multiplier", 1000.0), model_output_type=config.get("model_output_type", "x0"), ) self.per_channel_statistics = processor() def encode(self, x, device=None): x = x[:, :, :max(1, 1 + ((x.shape[2] - 1) // 8) * 8), :, :] means, logvar = torch.chunk(self.encoder(x, device=device), 2, dim=1) return self.per_channel_statistics.normalize(means) def decode(self, x): # Fixed-seed noise so decodes are reproducible TODO: expose? generator = torch.Generator(device=x.device) generator.manual_seed(0) return self.decoder(self.per_channel_statistics.un_normalize(x), generator=generator)