Skip to content

Wan-Animate-2 (authored by @kelseyee) - #14413

Open
yiyixuxu wants to merge 22 commits into
huggingface:mainfrom
yiyixuxu:animate2-refactor
Open

Wan-Animate-2 (authored by @kelseyee)#14413
yiyixuxu wants to merge 22 commits into
huggingface:mainfrom
yiyixuxu:animate2-refactor

Conversation

@yiyixuxu

@yiyixuxu yiyixuxu commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

part of #14412

import time as _clock

_t0 = _clock.time()
import torch

from diffusers import ModularPipeline
from diffusers.utils import export_to_video, load_image, load_video


# to use base checkpoint, you just need to swap the repo id, it will map to the  blocks preset with own sampling defaults (10 steps/ no CFG for distilled, 40 steps / CFG 3.0 for base)
# pipe = ModularPipeline.from_pretrained("Wan-AI/Wan2.2-Animate-2-14B-Diffusers", revision="refs/pr/2")
pipe = ModularPipeline.from_pretrained("Wan-AI/Wan2.2-Animate-2-14B-Distilled-Diffusers", revision="refs/pr/1")
pipe.load_components(dtype=torch.bfloat16)

# The in-context attention runs on the flex backend; compiling fuses it. Uncompiled flex
# materialises the full score matrix, which does not fit at the default `segment_frame_length`.
from diffusers.hooks import apply_group_offloading

# Stream the transformer's blocks: weights are ~33 GB and the KV cache another ~36 GB at this
# resolution, which does not co-reside on one 80 GB card.
apply_group_offloading(
    pipe.transformer,
    onload_device=torch.device("cuda"),
    offload_device=torch.device("cpu"),
    offload_type="block_level",
    num_blocks_per_group=4,
    use_stream=True,
)
pipe.text_encoder.to("cuda")
pipe.image_encoder.to("cuda")
pipe.vae.to("cuda")
pipe.transformer.compile_repeated_blocks(fullgraph=False)

driving_video, driving_video_fps = load_video("assets/demo1/template.mp4", return_fps=True)

_t1 = _clock.time()
videos = pipe(
    image=load_image("assets/demo1/reference.png"),
    driving_video=driving_video,
    driving_video_fps=driving_video_fps,
    prompt="人物外观描述:一只银灰色虎斑纹的小猫,拥有圆润的脸庞、竖立的耳朵和巨大的圆形眼睛。它身穿一套深蓝色的制服套装,包括一件带有金色纽扣的西装外套和一条百褶裙。外套里面搭配着白色衬衫,领口处系着一个红色的蝴蝶结,袖口露出白色的衬衫边缘。背景描述:背景为纯白色,光线均匀明亮,无其他杂物或装饰。",
    height=800,
    width=640,
    generator=torch.Generator("cuda").manual_seed(42),
    output="videos",
)

print(
    f"WALL CLOCK: {(_clock.time() - _t0) / 60:.1f} min  PIPELINE: {(_clock.time() - _t1) / 60:.1f} min  "
    f"PEAK: {torch.cuda.max_memory_allocated() / 1e9:.1f} GB  FRAMES: {len(videos[0])}",
    flush=True,
)
export_to_video(videos[0], "output.mp4", fps=24)

kelseyee and others added 3 commits July 30, 2026 16:20
Model (`transformer_wan_animate_2.py`):
- Replace the `forward(*args, method=...)` dispatch and the split
  `forward_ref`/`forward_gen` with a single documented
  `forward(..., kv_cache_mode="extract"|"cached")` returning
  `Transformer2DModelOutput`, following the Flux2 KV-cache precedent. The
  `SelfAttention`/`CrossAttention` pre/post split becomes a regular
  `WanAnimate2Attention` (`AttentionModuleMixin`) with processors that run
  through `dispatch_attention_fn` - native SDPA by default, any backend via
  `set_attention_backend`; only the in-context generation path is pinned to
  `flex`, since its attention pattern is expressed as a `BlockMask`. The hard
  `flash_attn` requirement is gone.
- `IncontextAttentionBlock` was a pure pass-through around `AttentionBlock`;
  merged into one `WanAnimate2TransformerBlock` (checkpoint keys lose the
  `.block.` segment, handled in the single-file mapping).
- KV cache is a `WanAnimate2KVCache` object instead of bare dicts passed
  through `forward`. Accelerate hooks copy dict arguments, so the dict version
  breaks under `enable_model_cpu_offload` (the reference pass fills a copy and
  the generation pass KeyErrors); the object passes through by reference, and
  `_skip_keys = ["kv_cache"]` covers group offloading.
- Remove all autocast in favour of the `transformer_wan.py` dtype discipline
  (fp32 modulation with `.type_as` casts at block boundaries), so the model
  runs natively in bf16 and is no longer CUDA-only in principle. Replace the
  local float64 `sinusoidal_embedding_1d` with the existing `Timesteps` class.
- Remove dead code: the unreachable padding mask in the reference path (the
  pipeline always fills `seq_len` exactly), `init_weights`,
  `load_from_official_state_dict`, and the unused `window_size`/`qk_norm`/
  `sparse_type`/`log_scale` config flags (`log_scale` ships as 0.0, making the
  flex `score_mod` a no-op).

Pipeline: call sites updated to the merged forward, autocast wrappers replaced
with explicit casts at the call boundary.

Also:
- Fill in `convert_wan_animate_2_transformer_to_diffusers` with the actual
  key mapping (block unwrap + attention renames); it was a prefix-strip no-op.
- Revert the `pipeline_utils.py` try/except import shims - the stub exception
  classes silently break real `except OfflineModeIsEnabled` handling; upgrade
  `huggingface_hub` to the `setup.py` pin instead.
- Drop the modular pipeline for now; it needs its own pass and is not part of
  the initial release surface.

Numerics: the refactored model matches the reference implementation at
2.47e-05 max relative difference in fp32 over all 40 layers on real weights,
and end-to-end outputs match the reference pipeline to a max pixel difference
of 7e-5 (PSNR 119 dB) when kernels and environment are held fixed. The
checkpoint key rename is a pure rename - all 1303 tensors bitwise identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
yiyixuxu and others added 4 commits August 8, 2026 03:31
Each segment allocates a fresh KV cache holding the reference tokens for
every layer -- tens of GB at high resolution. Holding the previous
segment's cache alive while the next one is built fragmented the
allocator enough to OOM mid-run on an 80GB card.

Move finished frames to CPU, clear the cache and drop the per-segment
latents before starting the next segment. `out_frames` is deliberately
kept: the next segment conditions on its tail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A list of frames does not carry the rate it was sampled at, so a pipeline that
has to resample its input to the frame rate the model works at cannot get that
number from `load_video` — even though imageio hands it to us and we throw it
away. Add an opt-in `return_fps`; GIFs get it from the frame duration.

Opt-in, so every existing caller keeps returning a plain list of images.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pipeline reached for decord and cv2 to do what the processors already do.
decord was an undeclared dependency imported inside `__call__`, and cv2 is not
a diffusers requirement — it is in the deps table but not in `install_requires`,
and only consisid and `export_utils` touch it, both behind local imports.

`WanAnimateImageProcessor` already letterboxes for `WanAnimatePipeline`, which
is what `padding_resize` and `resize_by_area` were hand-rolling: keep the aspect
ratio, fill the remainder with black. Add the video counterpart and use both, so
the driving video arrives as frames from `load_video` like every other video
pipeline takes it, with `driving_video_fps` carrying the one thing a frame list
cannot — decord used to read the source rate itself, and the 30 -> 24 resample
is load-bearing.

Also drop the `seed` argument in favour of the `generator` we already accept,
and sample with `self.scheduler` instead of building a scheduler per call from
`flow_solver` and `sample_shift`. The registered scheduler was dead weight
before; the checkpoints now carry the right one, and swapping it is documented.
`_encode_vae` was defined but unused while its body was inlined three times.

Preprocessing is equivalent, not identical: output dimensions and resampled
frame indices match exactly, and content-aligned the difference is 0.2% of full
range — PIL lanczos against cv2 INTER_AREA. PIL also centres the paste one row
lower than cv2 did; each path's crop follows its own paste. Seeded end to end on
the distilled model that lands at 23.7 dB, the same band the attention refactor
already sits in, with an identical contact sheet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seed-matched against the approved commit, everything the pipeline hands the
transformer is bit-identical -- noise, timesteps, text embeddings, geometry --
except the resized pixels, and the entire pixel residual traced to three
resize facts, each verified in isolation (decode and normalization are proven
byte-identical):

- kernel choice: bilinear matches the driving frames' `INTER_LINEAR` upscale
  (same filter; only exact-half ties round differently, at most one 8-bit
  level per pixel), bicubic measures closest to `INTER_AREA` for the
  reference image's downscale of the PIL kernels diffusers exposes
- paste placement: cv2 letterboxes at `(height - src_h) // 2`, PIL at
  `height // 2 - src_h // 2` -- one row apart when the frame is even and the
  content odd, previously the largest input difference
- the interim `WanAnimateVideoProcessor` inherited `WanAnimateImageProcessor`'s
  `__init__`, whose bare `super().__init__()` re-registers every shared config
  field with the parent's defaults -- `resample` was silently lanczos

`WanAnimate2VideoProcessor` replaces it: one class, its own
`register_to_config` init that does not chain into the decorated parents, the
reference paste convention, and per-instance kernels (bicubic for the
reference image, bilinear for the driving video). `WanAnimateImageProcessor`
and the merged Wan-Animate pipeline are untouched.

First-segment output agreement with the approved commit at matched seed is
27.5 dB (base) / 28.9 dB (distilled), above the 25.1 dB the approved commit
scores against itself when only the attention backend changes. Divergence in
later segments is the chained conditioning amplifying any perturbation,
numerical noise included, and is documented where the processors are built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

Ground-up modular decomposition of WanAnimate2Pipeline in
modular_pipelines/wan_animate_2/, verified bit-identical to the standard
pipeline (tiny fp32 with and without CFG, and the real distilled
checkpoint in bf16).

- Outer segment loop as LoopSequentialPipelineBlocks (helios style), with
  the per-segment driving VAE encode, previous-frame conditioning, KV-cache
  reference extraction, scheduler reset, hand-written denoise loop, and
  in-loop decode (each segment conditions on the previous segment's
  decoded pixels) as separate loop blocks.
- CFG through the guider; `is_uncondtion` rides the guider's per-branch
  tuple inputs. Two presets: WanAnimate2Blocks (guidance_scale=3.0) and
  WanAnimate2DistilledBlocks (guidance_scale=1.0).
- Segment-invariant work hoisted out of the loop: text/CLIP encoders (the
  driving-frame CLIP context is computed once, not per segment), reference
  VAE encode, and segment geometry.
- WanAnimate2VideoProcessor moves into the modular folder; the standard
  pipeline imports it from there, and pipelines/wan/image_processor.py is
  back to zero net change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Top-level blockset children now follow the family convention
  (text_encoder / image_encoder / video_encoder / vae_encoder / denoise /
  decode), each poppable and usable standalone, each a flat sequence of
  leaf blocks (wan-i2v / flux2 shape).
- Hoist the driving-video VAE encode out of the segment loop into the
  vae_encoder group: the Wan VAE is causal in time so each slice is
  encoded separately, but all slices are known upfront. The loop keeps
  only the genuinely sequential work (prev-frame conditioning and
  per-segment decode).
- Drop the guider from the text encoder step: the denoise step owns the
  guider spec (removing the 3.0-vs-1.0 spec conflict in the distilled
  preset), and the text encoder encodes the negative prompt when the
  pipeline's guider requires unconditional embeddings or one is passed
  explicitly -- standalone, nothing is encoded unless asked.
- Rename to canonical step names: ProcessImagesInputStep /
  ProcessVideosInputStep (flux/qwen convention), {Image,Video}{Clip,Vae}
  EncoderStep leaves, short EncodeStep group names.
- Rename clip_len -> segment_frame_length and first_num ->
  prev_segment_conditioning_frames, matching the merged Wan-Animate v1
  pipeline's argument names.

Verified bit-identical to the standard pipeline after each change (tiny
fp32 base+distilled, real distilled checkpoint bf16).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
yiyixuxu and others added 3 commits August 11, 2026 23:16
…sor names

- Move the driving-video VAE encode back into the segment loop (per-segment
  causal encode, symmetric with the in-loop decode); the size check between the
  image/video preprocess outputs runs once in prepare_segments
- Rename the research-code conditioning tensors to the merged Wan-Animate v1
  names: y_ref -> reference_image_latents, y -> reference_latents,
  y_reft -> prev_segment_cond_latents; condition_latents/condition_y ->
  driving_video_latents/driving_video_condition
- Rename the preprocessed video state to driving_video_pixels; derive
  latent/pixel dims from tensors instead of passing latent_height/latent_width
- Collapse the four crop_* ints into a single crop_region tuple
- Default height/width on the video preprocess step for standalone use;
  clarifying comments (zigzag padding, loop-carried state)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sets

- tests/modular_pipelines/wan_animate_2/: ModularPipelineTesterMixin +
  ModularGuiderTesterMixin against YiYiXu/tiny-wan-animate-2-modular and
  -distilled-modular (25 passed / 15 skipped); batch tests skipped (the
  pipeline is unbatched), guider test threshold lowered with rationale
- Use randn_tensor for segment noise in both pipelines so CPU generators
  work; CUDA-generator path unchanged (parity re-verified bit-identical)
- Describe every InputParam (templates where available); no auto-docstring
  TODOs remain
- Narrow the core denoise steps' outputs to segment_frames — the only
  product the decode step consumes
- Make the distilled blockset file self-contained: it assembles its own
  image/video encoder groups from the leaf blocks instead of importing
  the base blockset's classes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Modular-only pipeline page (ModularPipeline.from_pretrained example with
offloading + compile, both presets, area-based height/width semantics) and
the transformer model page; toctree entries sorted. References the official
Wan-AI hub ids, which will need the converted weights and
modular_model_index.json before the examples run as written.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests labels Aug 12, 2026
@yiyixuxu yiyixuxu changed the title [review & refactor] Wan-Animate-2 Wan-Animate-2 (authored by @kelseyee) Aug 12, 2026
yiyixuxu and others added 4 commits August 12, 2026 02:59
Import sorting in the two __init__s and doc-builder docstring reflow;
no behavior changes. make quality now exits clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Under eager initialization (DIFFUSERS_SLOW_IMPORT, used by the doc build)
`pipelines` -> `pipeline_wan_animate_2` -> `modular_pipelines.wan_animate_2`
-> `modular_pipeline` -> `pipelines` is a cycle; importing the processor
inside `__init__` breaks it. Goes away entirely with the standard
pipeline's pre-merge removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

self.vae_scale_factor_temporal = self.vae.config.scale_factor_temporal if getattr(self, "vae", None) else 4
self.vae_scale_factor_spatial = self.vae.config.scale_factor_spatial if getattr(self, "vae", None) else 8
# Wan-Animate-2 letterboxes the reference image and the driving video into the same frame: aspect

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kelseyee
we replaced the cv2/decord-based resizing (resize_by_area / padding_resize) with diffusers' PIL/torch video processor, so cv2 and decord are no longer dependencies -> it cause a very slight difference in output

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yiyixuxu Fine on our side. The letterboxing is preprocessing, not part of the trained graph, so a difference at this level is acceptable. No changes needed from us.

…tches

- WanAnimate2DistilledCoreDenoiseStep overrides `inputs` to default
  num_inference_steps to 10 (the step count the distilled checkpoint is
  trained for), so the doc example needs no argument; base stays at 40
- transformer forward: give origin_area its own docstring entry and add
  the Returns: section; pipeline __call__: document callback_on_step_end,
  callback_on_step_end_tensor_inputs, max_sequence_length
  (fixes utils/check_forward_call_docstrings.py in CI)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
yiyixuxu and others added 2 commits August 12, 2026 07:59
test_workflow_defaults now accepts the `None` key for pipelines without
workflows (the full blockset as the single unnamed workflow) and an
optional `component_configs` section pinning config values of
`from_config` components against their creating spec — e.g. the guider
scale that tells the two Wan-Animate-2 presets apart (3.0 vs 1.0).
The Wan-Animate-2 testers pin components, the full input surface with
defaults (40 vs 10 steps), and the guider scales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… pin

The Wan-Animate-2 API doc example needs no num_inference_steps anymore —
each preset carries its own default (40 base, 10 distilled) and its own
guidance. The testing guide documents expected_workflow_defaults: the
None key for workflow-less blocksets and the optional component_configs
pin for from_config components.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@yiyixuxu
yiyixuxu requested review from dg845 and sayakpaul August 12, 2026 08:09
The modular pipeline is the only Wan-Animate-2 entry point, per the plan
(kept during review only) and with the author's ok. Every refactor of the
modular decomposition was verified bit-identical against this pipeline
while it existed. The clip_visual_encode / get_i2v_mask /
get_frame_indices helpers in the modular folder become canonical (their
"Copied from" sources are gone), and the circular import the pipeline's
processor import created disappears with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wan-animate-2-distilled maps to WanAnimate2DistilledModularPipeline, and
every distilled block class carries it, so distilled blocks init (and
save_pretrained round-trip as) the distilled pipeline class instead of
the base one. Shared leaves keep wan-animate-2 — standalone they route
to the base class, which only differs in its default blocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation models modular-pipelines single-file size/L PR with diff > 200 LOC tests utils

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants