Skip to content

wan_animate

Wan2.2-Animate-14B: character animation / replacement.

Ported against both references: the official Wan-Video/Wan2.2 (wan/modules/animate/) and diffusers' merged WanAnimateTransformer3DModel. The official Diffusers-format checkpoint (Wan-AI/Wan2.2-Animate-14B-Diffusers) uses the diffusers naming, which this port loads directly.

Animate required no new tower: it is the stock Wan-I2V transformer (channel-concat conditioning, 36-channel input, CLIP image branch, single global timestep, standard RoPE grid), so this class subclasses WanTransformer3DModel -- the base __init__ builds the entire tower from WanAnimateArchConfig -- and adds three things:

  1. pose_patch_embedding: a second patchifier whose output is added to the patchified video tokens, skipping the reference latent frame (frame 0 of the sequence is the reference image and carries no pose). Adding pose to frame 0 corrupts identity conditioning -- the off-by-one is load-bearing.
  2. The face-driving stack (wan_animate_face.py): 512x512 face crops -> LIA motion vectors -> causal 4x funnel -> 4+1 face tokens per latent frame.
  3. face_adapter: per-frame cross-attention applied residually after every inject_face_latents_blocks-th block (blocks 0, 5, ..., 35 -- the adapter index i serves block i * stride; asserted in the arch config).

Sequence parallelism is not wired up in this first version: the face adapter's per-frame reshape needs the full token sequence, and an SP shard splits frames across ranks mid-frame. Single-GPU and FSDP-style weight sharding work (__init__ raises on sp_world_size > 1).

Classes

fastvideo.models.dits.wan_animate.WanAnimateTransformer3DModel

WanAnimateTransformer3DModel(config: WanAnimateConfig, hf_config: dict[str, Any])

Bases: WanTransformer3DModel

Wan2.2-Animate-14B transformer.

Source code in fastvideo/models/dits/wan_animate.py
def __init__(self, config: WanAnimateConfig, hf_config: dict[str, Any]) -> None:
    # Same backend resolution the base __init__ uses to pick its block type.
    if effective_attention_backend(config) == AttentionBackendEnum.VIDEO_SPARSE_ATTN:
        raise ValueError(
            "Wan-Animate has no VSA checkpoint. The base __init__ would swap in "
            "WanTransformerBlock_VSA, whose to_gate_compress weights are absent here and get "
            "silently zero-filled by the loader (fsdp_load ALLOWED_NEW_PARAM_PATTERNS). Select a "
            "non-VSA attention backend (unset FASTVIDEO_ATTENTION_BACKEND or the transformer's "
            "attention_backend).")
    if get_sp_world_size() > 1:
        raise NotImplementedError(
            "Wan-Animate does not support sequence parallelism yet: the face adapter's "
            "per-frame attention needs the whole token sequence on one rank. Run with "
            "sp_world_size=1 (FSDP weight sharding is unaffected).")
    super().__init__(config=config, hf_config=hf_config)
    arch = config.arch_config
    inner_dim = arch.hidden_size
    self.inject_face_latents_blocks = arch.inject_face_latents_blocks
    self.motion_encoder_batch_size = arch.motion_encoder_batch_size

    # Second patchifier for the pose video: same PatchEmbed wrapper as the
    # base patch_embedding, so the checkpoint's pose_patch_embedding.*
    # lands on .proj via the mapping.
    self.pose_patch_embedding = PatchEmbed(in_chans=arch.latent_channels,
                                           embed_dim=inner_dim,
                                           patch_size=arch.patch_size,
                                           flatten=False)

    self.motion_encoder = WanAnimateMotionEncoder(size=arch.motion_encoder_size,
                                                  style_dim=arch.motion_style_dim,
                                                  motion_dim=arch.motion_dim,
                                                  out_dim=arch.motion_encoder_dim,
                                                  channels=arch.motion_encoder_channel_sizes)
    self.face_encoder = WanAnimateFaceEncoder(in_dim=arch.motion_encoder_dim,
                                              out_dim=inner_dim,
                                              hidden_dim=arch.face_encoder_hidden_dim,
                                              num_heads=arch.face_encoder_num_heads,
                                              eps=arch.eps)
    # Dense, positional: face_adapter.{i} serves block i * inject stride.
    self.face_adapter = nn.ModuleList([
        WanAnimateFaceCrossAttention(inner_dim, arch.num_attention_heads, eps=arch.eps)
        for _ in range(arch.num_layers // arch.inject_face_latents_blocks)
    ])

    # The Animate checkpoint ships no attn2.norm_added_q (verified on the
    # real weights: 40 missing keys under strict load). FastVideo's
    # WanI2VCrossAttention allocates it only because base Wan-I2V
    # checkpoints ship it; it is never used in forward. Drop it so the
    # strict load has neither missing nor unexpected keys.
    for block in self.blocks:
        if hasattr(block.attn2, "norm_added_q"):
            block.attn2.norm_added_q = nn.Identity()

Methods:

fastvideo.models.dits.wan_animate.WanAnimateTransformer3DModel.forward
forward(hidden_states: Tensor, encoder_hidden_states: Tensor | list[Tensor], timestep: LongTensor, encoder_hidden_states_image: Tensor | list[Tensor] | None = None, pose_latents: Tensor | None = None, face_pixel_values: Tensor | None = None, guidance=None, **kwargs) -> Tensor

Denoise one step of an animation/replacement segment.

hidden_states [B, 36, T+1, H, W]: noise | 4ch mask | conditional latent y, channel-concatenated by the pipeline. Frame 0 is the reference latent slot. pose_latents [B, 16, T, H, W] VAE-encoded skeleton video; exactly one frame fewer than hidden_states (no pose on ref). face_pixel_values [B, 3, F, 512, 512] raw face crops, F = 4T - 3 pixel frames for T latent frames. encoder_hidden_states_image CLIP features of the reference image (257 tokens) -- required, the I2V cross-attention splits them off positionally.

Returns the full [B, 16, T+1, H, W] velocity; the decode stage drops the reference slot (and any guidance frames).

Source code in fastvideo/models/dits/wan_animate.py
def forward(self,
            hidden_states: torch.Tensor,
            encoder_hidden_states: torch.Tensor | list[torch.Tensor],
            timestep: torch.LongTensor,
            encoder_hidden_states_image: torch.Tensor | list[torch.Tensor] | None = None,
            pose_latents: torch.Tensor | None = None,
            face_pixel_values: torch.Tensor | None = None,
            guidance=None,
            **kwargs) -> torch.Tensor:
    """Denoise one step of an animation/replacement segment.

    hidden_states    [B, 36, T+1, H, W]: noise | 4ch mask | conditional
                     latent y, channel-concatenated by the pipeline. Frame
                     0 is the reference latent slot.
    pose_latents     [B, 16, T, H, W] VAE-encoded skeleton video; exactly
                     one frame fewer than hidden_states (no pose on ref).
    face_pixel_values [B, 3, F, 512, 512] raw face crops,
                     F = 4T - 3 pixel frames for T latent frames.
    encoder_hidden_states_image  CLIP features of the reference image
                     (257 tokens) -- required, the I2V cross-attention
                     splits them off positionally.

    Returns the full [B, 16, T+1, H, W] velocity; the decode stage drops
    the reference slot (and any guidance frames).
    """
    if pose_latents is None or face_pixel_values is None:
        raise ValueError(
            "Wan-Animate needs both a pose video and a face video. Got "
            f"pose_latents={'set' if pose_latents is not None else 'None'}, "
            f"face_pixel_values={'set' if face_pixel_values is not None else 'None'}. The "
            "pipeline supplies these from the preprocessed src_pose/src_face inputs.")
    if encoder_hidden_states_image is None or (isinstance(encoder_hidden_states_image, list)
                                               and not encoder_hidden_states_image):
        raise ValueError("Wan-Animate needs CLIP features of the reference image "
                         "(encoder_hidden_states_image): the I2V cross-attention splits off "
                         "the first 257 context tokens as image tokens positionally, so this "
                         "must fail here rather than misread the text tokens.")
    if pose_latents.shape[2] + 1 != hidden_states.shape[2]:
        raise ValueError(
            f"pose_latents has {pose_latents.shape[2]} frames but must have exactly one fewer "
            f"than hidden_states ({hidden_states.shape[2]}): the reference latent frame "
            "carries no pose.")

    orig_dtype = hidden_states.dtype
    if encoder_hidden_states is not None and not isinstance(encoder_hidden_states, torch.Tensor):
        encoder_hidden_states = encoder_hidden_states[0]
    if isinstance(encoder_hidden_states_image, list):
        encoder_hidden_states_image = encoder_hidden_states_image[0]

    batch_size, _, num_frames, height, width = hidden_states.shape
    p_t, p_h, p_w = self.patch_size
    post_patch_num_frames = num_frames // p_t
    post_patch_height = height // p_h
    post_patch_width = width // p_w

    freqs_cis = self._rotary_embeddings(post_patch_num_frames, post_patch_height, post_patch_width,
                                        hidden_states.device)

    # 1. Patchify, then add pose tokens to every frame but the reference.
    hidden_states = self._patchify_with_pose(hidden_states, pose_latents)
    hidden_states = hidden_states.flatten(2).transpose(1, 2)
    original_seq_len = hidden_states.shape[1]

    # 2. Face tokens: one 4+1 group per latent frame, zero row for the ref.
    motion_vec = self._encode_face(face_pixel_values)
    if motion_vec.shape[1] != post_patch_num_frames:
        raise ValueError(
            f"face/video misalignment: the face video produced {motion_vec.shape[1]} latent-"
            f"frame token groups (incl. the reference pad) but the video has "
            f"{post_patch_num_frames} latent frames. The face video needs 4*T - 3 pixel "
            f"frames for T video latent frames (e.g. 77 for 20).")

    # 3. Condition embeddings (time, text, CLIP image) -- Wan2.1 timestep logic.
    temb, timestep_proj, encoder_hidden_states, encoder_hidden_states_image = self.condition_embedder(
        timestep, encoder_hidden_states, encoder_hidden_states_image, timestep_seq_len=None)
    timestep_proj = timestep_proj.unflatten(1, (6, -1))
    encoder_hidden_states = torch.concat([encoder_hidden_states_image, encoder_hidden_states], dim=1)
    # The blocks add cross-attention output straight onto hidden_states, so
    # a context in another dtype would silently upcast the residual stream.
    assert encoder_hidden_states.dtype == orig_dtype, (
        f"context dtype {encoder_hidden_states.dtype} != latent dtype {orig_dtype}")

    # 4. The tower, with a face cross-attention residual after every
    #    inject-stride-th block.
    for idx, block in enumerate(self.blocks):
        hidden_states = block(hidden_states, encoder_hidden_states, timestep_proj, freqs_cis, original_seq_len)
        if idx % self.inject_face_latents_blocks == 0:
            adapter = self.face_adapter[idx // self.inject_face_latents_blocks]
            hidden_states = hidden_states + adapter(hidden_states, motion_vec)

    # 5. Output norm, projection & unpatchify (single-timestep path).
    shift, scale = (self.scale_shift_table + temb.unsqueeze(1)).chunk(2, dim=1)
    hidden_states = self.norm_out(hidden_states, shift, scale)
    hidden_states = self.proj_out(hidden_states)
    hidden_states = hidden_states.reshape(batch_size, post_patch_num_frames, post_patch_height,
                                          post_patch_width, p_t, p_h, p_w, -1)
    hidden_states = hidden_states.permute(0, 7, 1, 4, 2, 5, 3, 6)
    return hidden_states.flatten(6, 7).flatten(4, 5).flatten(2, 3)
fastvideo.models.dits.wan_animate.WanAnimateTransformer3DModel.materialize_non_persistent_buffers
materialize_non_persistent_buffers(device: device, dtype: dtype | None = None) -> None

Rebuild the motion encoder's anti-aliasing blur kernels after meta load.

TransformerLoader builds the model under torch.device("meta") and streams checkpoint weights in; non-persistent buffers are not in the checkpoint and stay on meta until this hook (called by fsdp_load) recreates them. Kernels stay fp32 -- forward casts them to the activation dtype.

Source code in fastvideo/models/dits/wan_animate.py
def materialize_non_persistent_buffers(self, device: torch.device, dtype: torch.dtype | None = None) -> None:
    """Rebuild the motion encoder's anti-aliasing blur kernels after meta load.

    TransformerLoader builds the model under ``torch.device("meta")`` and
    streams checkpoint weights in; non-persistent buffers are not in the
    checkpoint and stay on meta until this hook (called by fsdp_load)
    recreates them. Kernels stay fp32 -- forward casts them to the
    activation dtype.
    """
    for module in self.modules():
        if isinstance(module, MotionConv2d) and module.blur and module.blur_kernel.is_meta:
            module.blur_kernel = module._build_blur_kernel().to(device)

Functions: