Skip to content

Add SANA-WM camera-controlled image-to-video pipeline - #13881

Open
lawrence-cj wants to merge 41 commits into
huggingface:mainfrom
lawrence-cj:feat/sana-wm-diffusers-cleanup
Open

Add SANA-WM camera-controlled image-to-video pipeline#13881
lawrence-cj wants to merge 41 commits into
huggingface:mainfrom
lawrence-cj:feat/sana-wm-diffusers-cleanup

Conversation

@lawrence-cj

@lawrence-cj lawrence-cj commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Hi @sayakpaul @dg845 , Long time no see. Hoping your are doing great. ♥️

Adds SANA-WM, the camera-controlled image-to-video world model from NVIDIA + MIT HAN Lab, as a first-class diffusers pipeline and transformer. Given a first-frame image, a text prompt, and a camera trajectory (explicit c2w poses or a WASD/IJKL action-DSL string), the pipeline generates a video whose motion follows the requested camera path. Trained natively for minute-scale generation at 704×1280.

The pipeline runs in two stages:

  1. Stage 1 — SanaWMTransformer3DModel. A 1.6B-parameter bidirectional DiT with GDN-Triton linear attention and a UCPE camera-control branch; samples with an LTX-style flow-matching Euler scheduler at per-token timesteps. The first latent frame is the conditioning anchor.
  2. Stage 2 — SanaWMLTX2Refiner (optional). A chunk-causal AR refiner that wraps diffusers' LTX2VideoTransformer3DModel + LTX2TextConnectors + Gemma-3 text encoder. Processes 3 latent frames at a time with a sliding window of [source_sink + recent_history + active_block] K/V, so per-block compute is bounded and total refinement cost is linear in video length.

Both stages decode through AutoencoderKLLTX2Video.

Layout

src/diffusers/
├── models/transformers/
│   ├── transformer_sana_wm.py          # SanaWMTransformer3DModel + blocks + helpers
│   └── transformer_sana_wm_kernels.py  # fused Triton kernels + camera math
└── pipelines/sana_wm/
    ├── __init__.py
    ├── pipeline_sana_wm.py             # SanaWMPipeline
    ├── pipeline_output.py              # SanaWMPipelineOutput
    ├── refiner.py                      # SanaWMLTX2Refiner + RefinerChunkRunner
    └── cam_utils.py                    # action DSL, intrinsics, resize+crop, Plücker/raymap

scripts/sana_wm/convert_sana_wm_to_diffusers.py
docs/source/en/api/{pipelines/sana_wm.md, models/sana_wm_transformer3d.md}

Usage

import torch
from PIL import Image
from diffusers import SanaWMPipeline
from diffusers.utils import export_to_video

pipe = SanaWMPipeline.from_pretrained(
    "Efficient-Large-Model/SANA-WM_bidirectional-diffusers",
    torch_dtype=torch.bfloat16,
)
pipe.vae.to(torch.float32)
pipe.enable_model_cpu_offload()

out = pipe(
    image=Image.open("input.png").convert("RGB"),
    prompt="A car driving across a vast desert plain at golden hour.",
    action="w-80,jw-40,w-40",                    # WASD-style action DSL
    intrinsics=[800.0, 800.0, 845.0, 464.0],      # fx, fy, cx, cy in original-image pixels
    num_frames=161,
    num_inference_steps=60,
)
export_to_video(list(out.frames), "sana_wm.mp4", fps=16)

Demo

5-second sample (30 stage-1 steps + 3-step distilled AR refiner, official asset/sana_wm/demo_0 inputs, 704×1280 @ 16 fps) :

sana_wm_5s.mp4

Smoke tests

End-to-end on 1× H100 80GB with `enable_model_cpu_offload` and the official `asset/sana_wm/demo_0.{png,txt,_pose.npy,_intrinsics.npy}`:

Duration Frames Stage-1 (30 steps) Refiner (AR, 3 blocks) Output
5s 80 1:11 5:24 / step 525 KB
10s 160 1:11 28:55 (7 blocks) 1.4 MB
20s 320 1:57 ≈ 4 min / block (14) 3.2 MB
50s 800 5:33 30:46 (34 blocks) 6.3 MB

Checkpoint conversion

scripts/sana_wm/convert_sana_wm_to_diffusers.py --src Efficient-Large-Model/SANA-WM_bidirectional --dst /local/path converts the public release into a `from_pretrained`-loadable directory (VAE, Gemma-2 tokenizer + text_encoder, transformer, scheduler, refiner subfolders, top-level `model_index.json`).

Related

Paper: https://arxiv.org/abs/2605.15178

HaoyiZhu and others added 4 commits June 1, 2026 01:28
…line

Adds the public SANA-WM bidirectional camera-controlled image-to-video
model as a first-class diffusers pipeline + transformer. Layout mirrors
``sana_video``: the model lives under ``src/diffusers/models/transformers/``
as a near-single-file (kernels split off so the ``@triton.jit`` decorators
don't drown the model body); the pipeline lives under
``src/diffusers/pipelines/sana_wm/``.

Files added:

  src/diffusers/models/transformers/
  ├── transformer_sana_wm.py         # SanaWMTransformer3DModel + blocks + helpers
  └── transformer_sana_wm_kernels.py # fused Triton kernels + camera math

  src/diffusers/pipelines/sana_wm/
  ├── __init__.py
  ├── pipeline_sana_wm.py
  ├── pipeline_output.py
  ├── refiner.py
  └── cam_utils.py

Pipeline architecture:
* Stage 1: 1600M ``SanaWMTransformer3DModel`` DiT with bidirectional
  GDN-Triton linear attention + UCPE camera-control branch, LTX-style
  flow-matching Euler scheduler with per-token timesteps.
* Stage 2: LTX-2 sink-bidirectional Euler refiner (3 distilled sigma
  steps, reuses diffusers' ``LTX2VideoTransformer3DModel`` +
  ``LTX2TextConnectors`` + Gemma-3 text encoder).
* Decode through the LTX-2 VAE (``AutoencoderKLLTX2Video``).

One-line usage:

  pipe = SanaWMPipeline.from_pretrained(
      "Efficient-Large-Model/SANA-WM_bidirectional-diffusers",
      torch_dtype=torch.bfloat16,
  ).to("cuda")
  out = pipe(image=img, prompt="...", action="w-80,jw-40,w-40",
             intrinsics=[fx, fy, cx, cy])

End-to-end smoke test (stage-1 + refiner + VAE decode) passes on H100.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…xport

transformer_sana_wm.py:
* License header switched to the "HuggingFace Team and SANA-WM Authors"
  style used by merged sana_video.
* Imports rewritten in stdlib -> third-party -> diffusers order; use
  diffusers `from ...utils import logging` instead of stdlib `logging`.
* Fix 9 `Optional[X]` annotations written as `X or None` (Python's `or`
  short-circuits and silently returns `X`).
* Fix two `assert (cond, msg)` tuple-asserts in PatchEmbedMS3D.forward
  that always pass (SyntaxWarning at import time).
* Remove duplicate `__all__` declarations (the second silently overwrote
  the first).
* Remove dead `reset_bn` (imports a nonexistent `packages.apps.utils`,
  would crash on call).
* Remove the duplicate `logger = logging.getLogger(__name__)` further
  down in the file.

transformer_sana_wm_kernels.py:
* License header normalized; collapse three duplicate triton/torch import
  blocks into one.

pipeline_sana_wm.py:
* License header normalized.
* `_decode_latents` now returns `(T, H, W, 3)` float in [0, 1], matching
  the diffusers convention used by `VideoProcessor`. Returning uint8
  silently broke `export_to_video`: it does `frame * 255` assuming float
  input, so uint8 overflows to `(-x) mod 256` and inverts colors.
* `__call__` converts to PIL/uint8 only when `output_type="pil"`.
* Intrinsics argument now accepts (4,), (F, 4), (3, 3), and (F, 3, 3)
  forms (auto-extracts fx, fy, cx, cy from a 3x3 K) and auto-trims to
  `num_frames` when a longer-than-needed trajectory is passed.
* Inline `retrieve_timesteps` with the standard `# Copied from
  diffusers.pipelines.stable_diffusion.pipeline_stable_diffusion.retrieve_timesteps`
  marker, matching merged sana_video.
* Docstrings + EXAMPLE_DOC_STRING updated to reflect the new return type.

pipeline_output.py:
* Update `frames` field docstring to describe the new float [0, 1] return.

refiner.py, cam_utils.py, scripts/sana_wm/convert_sana_wm_to_diffusers.py:
* License headers normalized.

Docs:
* New `docs/source/en/api/pipelines/sana_wm.md` and
  `docs/source/en/api/models/sana_wm_transformer3d.md`, modeled on
  sana_video.md / sana_video_transformer3d.md, wired into
  `docs/source/en/_toctree.yml` under Models and Pipelines.

5s end-to-end smoke test (81 frames @ 16fps, 30 stage-1 steps + 3-step
LTX-2 refiner) passes on 1x H100 80GB with `enable_model_cpu_offload`.
Round-trip diff vs raw float frames is 2.06/255 mean (h264 lossy noise),
confirming the export_to_video fix.
…+ KV cache hooks)

The first cleanup pass only kept the legacy single-shot refiner path. That
path is what the model was *not* trained on — its docstring even says
"feeding the full sequence at once is out-of-distribution" — and its cost
is O(T^2) attention over the full latent volume, which made longer videos
unusable (~21 min per refiner step at 321 frames on an H100).

Port the chunk-causal AR mode from the upstream reference so the refiner
matches the training contract:

* `refine_latents` now defaults to `block_size=3, kv_max_frames=11`
  (the canonical AR recipe). Pass `block_size=None` to fall back to the
  legacy single-shot path.
* New `_refine_latents_ar` + `_RefinerChunkRunner` orchestrate the sliding
  window: pre-capture pre-RoPE sink K/V on `z_sana[:source_sink_frames]`
  at sigma=0, then for each `block_size`-frame chunk run a 3-step Euler
  with prefix `{sink_k_pre, sink_v, sink_pe, history_k, history_v}` and
  capture post-RoPE K/V to feed the next window. History is bounded to
  `kv_max_frames - source_sink_frames` so per-block compute is constant.
* New `_predict_x0_active_block` runs the transformer on the active block
  only (Q from active, K/V from prefix+active).
* New `_capture_block_kv` runs sigma=0 forward with a pre_rope/post_rope
  capture flag set on each `attn1`.
* New `_forward_video_only_with_rope` takes a pre-built RoPE so each block
  can use absolute frame positions in the source video.
* `_streaming_self_attention` extended with the `_kv_cache_capture`,
  `_tf_capture_kv`, `_tf_kv_prefix` hook contract that AR mode uses to
  inject and capture K/V on each block.
* New helpers: `_build_rotary_emb_for_absolute_positions`,
  `_set_kv_prefix_on_blocks`, `_clear_kv_prefix_on_blocks`,
  `_set_capture_flag_on_blocks`, `_collect_captured_kv_from_blocks`.
* `_encode_prompt` now also moves the Gemma-3 text encoder back to CPU
  after producing the embeds — otherwise it stays resident through the
  entire AR loop and gates how much GPU memory the refiner transformer
  has left.

Module-level docstring updated to document both modes; existing
single-shot path preserved verbatim.
…eemption)

The AR refiner is expensive (~3-5 min per block) and the refinement loop
ran end-to-end has no in-progress state to recover, so a SLURM preemption
mid-refinement loses all progress. With the canonical
``block_size=3, kv_max_frames=11`` setup, refining a 50s video is 34
blocks of work that has to make it through without preemption on a
backfill queue.

Add per-block atomic checkpointing:

* ``SanaWMLTX2Refiner.refine_latents(checkpoint_dir=Path)`` and
  ``_refine_latents_ar`` accept a directory. After each completed AR
  block, the AR loop writes ``checkpoint_dir/state.pt`` atomically
  (tmp + os.replace).
* The payload is ``{block_idx_done, n_blocks, sink_size, block_size,
  output_shape, output, runner_state}``. ``runner_state`` is a CPU snapshot
  of the runner's ``_sink_kv_pre``, ``_history_kv_post``,
  ``_history_frames`` and ``torch.Generator`` state.
* On entry, if ``state.pt`` exists with a compatible shape signature, the
  AR loop loads the persisted output tensor + runner state and resumes
  from ``block_idx_done + 1`` instead of recomputing from scratch.
* ``SanaWMPipeline.__call__(refiner_checkpoint_dir=...)`` plumbs the
  directory through to the refiner.

Checkpoint size: ~output_volume + sink_KV (~360MB for 50 layers) +
rolling history KV (~3-4GB at full capacity) — saved once per block,
total per-block save overhead ~10s on lustre.
@github-actions github-actions Bot added size/L PR with diff > 200 LOC documentation Improvements or additions to documentation models pipelines and removed size/L PR with diff > 200 LOC labels Jun 7, 2026
@github-actions github-actions Bot added the size/L PR with diff > 200 LOC label Jun 9, 2026
* CPU unit tests for cam_utils helpers (action DSL → c2w, intrinsics
  rescale-for-crop, resize+center-crop, snap_num_frames 8k+1 rounding).
* Public-surface registration tests (top-level diffusers symbols,
  SanaWMPipelineOutput dataclass shape, refiner signature has AR defaults
  + checkpoint_dir, pipeline __call__ accepts c2w/action/intrinsics/
  refiner_checkpoint_dir).
* @slow @require_torch_accelerator integration stub for an end-to-end I2V
  against the public checkpoint, currently @unittest.skip — wires up the
  nightly GPU path without exploding regular CI.

SanaWMTransformer3DModel has hardcoded depth/hidden_size/num_heads inside
its inner SanaMSVideoCamCtrl (not exposed through register_to_config), so
the usual PipelineTesterMixin small-config fast tests aren't applicable
without a transformer refactor (followup PR).
@github-actions github-actions Bot added the tests label Jun 9, 2026
@dg845
dg845 requested review from dg845 and yiyixuxu June 12, 2026 03:54
@dg845

dg845 commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

As a preliminary comment, would it be possible to use PyTorch ops instead of custom Triton kernels (or add pure PyTorch fallback paths) for now? We will work on supporting the custom kernels through kernels. CC @sayakpaul

@lawrence-cj

Copy link
Copy Markdown
Contributor Author

As a preliminary comment, would it be possible to use PyTorch ops instead of custom Triton kernels (or add pure PyTorch fallback paths) for now? We will work on supporting the custom kernels through kernels. CC @sayakpaul

Yes, love to do that.

…ttention

`transformer_sana_wm_kernels.py` previously did a hard `import triton`
at the top of the file. That blocked importing the SANA-WM transformer
on any environment without Triton (CPU-only, ROCm without Triton,
older Triton, etc.), even though the model has pure-PyTorch attention
classes for every `*Triton` variant.

Make Triton optional and have the dispatcher transparently fall back:

* Wrap `import triton` / `import triton.language as tl` in try/except.
  When unavailable, install a shim where `@triton.jit` is a no-op so
  the kernel function definitions still load (they just aren't compiled
  by Triton). Module-level `triton.X` / `tl.X` lookups return a
  self-shimming sentinel so signature parsing doesn't blow up either.
* Add `is_triton_available()` + `_require_triton(entry_point)`. The four
  Triton-backed entry points called by the model (`fused_qk_inv_rms`,
  `fused_bigdn_func`, `cam_prep_func`, `cam_scan_bidi_chunkwise`) now
  raise a clear RuntimeError on a Triton-less host with a hint to use
  the pure-PyTorch attention variants — but the dispatcher does this
  automatically (see below) so users shouldn't ever see it.
* Delete the leftover duplicate `import torch / triton / triton.language`
  block at line 262 (left over from the upstream port).
* Register `BidirectionalGDNUCPESinglePathLiteLA` in `ATTENTION_BLOCKS`
  so the fallback chain can find it.
* New `_resolve_attention_block(name, role)` walks the requested class's
  MRO at dispatch time. If Triton isn't usable AND the requested class
  name ends in `Triton`, route to the closest registered non-`Triton`
  ancestor (BidirectionalGDNUCPESinglePathLiteLABothTriton ->
  BidirectionalGDNUCPESinglePathLiteLA, etc.) and log a one-shot warning.
* Rewire both `SanaVideoMSCamCtrlBlock` dispatch sites to use
  `_resolve_attention_block` for the GDN+UCPE camera branch and the main
  attention branch (the `BidirectionalSoftmaxUCPESinglePathLiteLA` branch
  doesn't use Triton at all so it stays hard-coded).

Tests:
* `test_kernels_module_imports_with_triton_hidden` — reloads the kernels
  module with `sys.modules['triton'] = None` and verifies the module
  imports, `is_triton_available()` is False, and the pure-PyTorch helpers
  remain callable.
* `test_resolve_attention_block_cpu_fallback` — on a CPU-only host, the
  three `*Triton` attn types resolve to the correct non-Triton ancestor.
* `test_triton_entry_point_raises_clean_error_without_triton` — verifies
  the `_require_triton` guard yields a RuntimeError that mentions Triton.
@lawrence-cj

lawrence-cj commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Done in c0712d3f8 — Triton is now optional, with an automatic pure-PyTorch fallback at dispatch time. Mapping when Triton isn't usable:

Requested Falls back to
BidirectionalGDNTriton BidirectionalGDN
BidirectionalGDNUCPESinglePathLiteLATriton BidirectionalGDNUCPESinglePathLiteLA
BidirectionalGDNUCPESinglePathLiteLABothTriton BidirectionalGDNUCPESinglePathLiteLA

Triton remains the default on CUDA + Triton ≥ 3. CPU tests added under tests/pipelines/sana_wm/.

@lawrence-cj

Copy link
Copy Markdown
Contributor Author

@dg845 @yiyixuxu Gentle ping here.

lawrence-cj and others added 4 commits June 18, 2026 11:26
Three CI checks were failing on the PR:

1. `check_code_quality` (43 ruff errors): mix of unused imports / import
   sorting / E731 lambdas (auto-fixable) plus a handful of F821 dead-code
   references inherited from the upstream research codebase (`xformers.*`
   inside `if _xformers_available:` blocks, an undefined `BlockHook` type
   annotation, two `x_sa`/`mlp_out` references in a block forward whose
   live assignment was already overridden by subclasses). Ran `ruff check
   --fix --unsafe-fixes` + `ruff format`, fixed the type annotation
   manually, and added targeted `# noqa: F821` markers on the conditionally
   unreachable lines.

2. `check_torch_dependencies`: `transformer_sana_wm.py` hard-imported
   `einops`, `fla`, `timm`, `termcolor`. The minimum-deps CI environment
   doesn't have them, and diffusers' lazy loader rewrites `ModuleNotFoundError`
   as `RuntimeError` so `test_pipeline_imports` blew up. Wrapped each of
   the four optional imports in a try/except shim — `rearrange`/
   `ShortConvolution`/`DropPath`/`Attention_`/`Mlp` become placeholders
   that raise a clear `ImportError` on construction, `colored` falls back
   to plain text. Class bodies that subclass these still parse at module
   load, so `import diffusers.models.transformers.transformer_sana_wm`
   succeeds anywhere. Same treatment for the kernels file's
   `from einops import rearrange, repeat`.

3. `build_pr_documentation`: doc-builder imported `SanaWMTransformer3DModel`
   from `diffusers.models.transformers` (not the diffusers top level) and
   that subpackage's `__init__.py` was missing the entry. Added the import.
* `doc-builder style src/diffusers docs/source --max_len 119` rewraps
  docstrings in the six SANA-WM files (transformer, kernels, pipeline,
  refiner, output, cam_utils) to the repo-wide 119-column limit. No
  behaviour change — purely whitespace inside docstrings.
* `make fix-copies` regenerates `dummy_pt_objects.py` and
  `dummy_torch_and_transformers_objects.py` to add `DummyObject` stubs
  for the three new public classes (`SanaWMTransformer3DModel`,
  `SanaWMPipeline`, `SanaWMLTX2Refiner`), so `from diffusers import …`
  gives the standard "missing backend" message on installs without
  torch / transformers.

Verified: `make quality` passes (ruff check, ruff format check,
doc-builder style check_only, check_doc_toc). Test suite still
15 passed / 1 skipped.
@github-actions github-actions Bot added the utils label Jun 25, 2026
@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.

@dg845 dg845 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for iterating! Here is an initial review of the modeling code. In general I think we should try to simplify it as much as possible since it is quite long and there still appears to be some unused code.

Comment thread src/diffusers/models/transformers/transformer_sana_wm.py Outdated
Comment thread src/diffusers/models/transformers/transformer_sana_wm.py Outdated
Comment thread src/diffusers/models/transformers/transformer_sana_wm.py Outdated
Comment thread src/diffusers/models/transformers/transformer_sana_wm.py Outdated
Comment thread src/diffusers/models/transformers/transformer_sana_wm.py Outdated
Comment thread src/diffusers/models/transformers/transformer_sana_wm.py Outdated
Comment thread src/diffusers/models/transformers/transformer_sana_wm.py Outdated
Comment thread src/diffusers/models/transformers/transformer_sana_wm.py Outdated
Comment thread src/diffusers/models/transformers/transformer_sana_wm.py Outdated
return latent_image_ids.to(device=device, dtype=dtype)


class WanRotaryPosEmbed(nn.Module):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
class WanRotaryPosEmbed(nn.Module):
# Copied from diffusers.models.transformers.transformer_wan.WanRotaryPosEmbed
class WanRotaryPosEmbed(nn.Module):

Is WanRotaryPosEmbed here intended to be the same as for Wan? If so we can use # Copied from to sync the implementations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It isn't the same implementation, unfortunately, so # Copied from would fail make fix-copies. Concretely, versus transformer_wan.WanRotaryPosEmbed:

  • it calls get_1d_rotary_pos_embed(..., use_real=False, repeat_interleave_real=False) and stores a single complex freqs, whereas Wan uses use_real=True, repeat_interleave_real=True and registers two real freqs_cos / freqs_sin buffers;
  • forward(fhw, device) -> Tensor returns that one complex tensor shaped (1, 1, ppf*pph*ppw, -1), while Wan's forward(hidden_states) -> (cos, sin) derives the patch counts from the input and returns a tuple shaped (1, N, 1, -1);
  • it takes an extra fhw_dim argument with different dim-splitting logic.

Reconciling them would mean rewriting this class (and its CausalWanRotaryPosEmbed subclass plus the apply_rotary_emb call sites) onto Wan's cos/sin convention — a numerics-sensitive change I'd rather not fold into this PR. Happy to rename it to something less confusing (e.g. SanaWMRotaryPosEmbed) so it doesn't read as a copy of Wan's.

Addresses several of @dg845's transformer review comments (the safe,
output-preserving subset — GPU smoke gives byte-identical output):

* Reuse diffusers' shared `FP32LayerNorm` (models/normalization.py) and
  `get_1d_rotary_pos_embed` (models/embeddings.py); delete the local copies.
* Remove dead inference code paths:
  - the `if self.diagonal_mask is not None:` flex-attention block
    (`diagonal_mask` is always `None`) + the now-unused
    `create_block_mask_cached` helper and `create_block_mask` import;
  - the `SANA_FSDP2_BLOCK_TIMING` block-timing/profiling scaffolding;
  - the `save_qkv` / `qkv_store_buffer` visualization hooks (never
    enabled at inference), at both the attention and model level.
* Collapse `SanaVideoMSCamCtrlBlock.forward_frame_aware` into `forward`
  (the pipeline/refiner always pass >=3D timesteps, so the non-frame-aware
  branch was dead — it even referenced undefined locals).
* Drop the 191-line `SanaMSVideoCamCtrl.load_state_dict` shape-remapping
  override — it's never reached by the shipped convert/inference flow
  (`nn.Module.load_state_dict` on the wrapper doesn't call it), and the
  release checkpoint already ships correctly-shaped weights.
* Delete the unused `PatchEmbedMS` module.

Net -503 lines; no numerics change (stage-1 + refiner smoke output mean
identical to the pre-refactor run).
@lawrence-cj

Copy link
Copy Markdown
Contributor Author

Pushed 6b19325 addressing most of these — reused diffusers' shared FP32LayerNorm + get_1d_rotary_pos_embed, removed the dead diagonal_mask / block-timing / qkv_store_buffer paths, dropped the unreached load_state_dict override and unused PatchEmbedMS, and collapsed forward_frame_aware into forward (−503 lines, GPU smoke output byte-identical). Three I left for a follow-up / with a note: the Sana+SanaMSVideoCamCtrl+SanaWMTransformer3DModel merge (worth its own pass since it drops the _inner. state-dict prefix and needs the checkpoint re-converted), keeping the local RMSNorm (diffusers' lacks the scale_factor/norm_dim this uses), and WanRotaryPosEmbed (it's a reimplementation, not a copy — complex-freqs vs cos/sin + extra fhw_dim — so # Copied from won't apply).

dg845 and others added 6 commits July 8, 2026 16:49
…3DModel

Per @dg845's review: `SanaWMTransformer3DModel` was a thin wrapper over
`SanaMSVideoCamCtrl`, which in turn subclassed `Sana` and overwrote most of
it. Fold all three into a single `SanaWMTransformer3DModel(ModelMixin,
ConfigMixin)`:

* The `@register_to_config` __init__ signature is unchanged (so config.json
  is identical); the body builds the modules directly on `self` instead of
  a nested `self._inner`.
* Only the surviving `Sana.__init__` pieces are kept (t_embedder,
  cfg_embedder, attention_y_norm, config attrs, initialize_weights); the
  parts the subclass overwrote are gone.
* `forward` takes the diffusers signature (hidden_states / timestep /
  encoder_hidden_states / encoder_attention_mask / return_dict) and returns
  `Transformer2DModelOutput`, folding in the old wrapper's arg-renaming.
* Deleted now-unreachable code: `class Sana`, `class SanaMSVideoCamCtrl`,
  `class SanaBlock`, `class PatchEmbed` (the video model uses
  `PatchEmbedMS3D`), the `add_inner_prefix` helper, and the dead
  `sincos`/`flux_rope` pos-embed branches in forward (release uses
  `wan_rope`). Net -468 lines.

This drops the `_inner.` state-dict prefix, so the conversion script no
longer adds it. State-dict is otherwise identical: the merged model's
`state_dict()` has exactly the same 871 param keys as before (verified),
and a stage-1 + refiner GPU smoke on the public checkpoint gives
byte-identical output (frame mean 0.5560, matching the pre-merge run).

Addresses the class-merge review comment and removes the unused
`SanaBlock`/`PatchEmbed` modules.
`auto_grad_checkpoint` gated on a `grad_checkpointing` attribute that was
never set, so it always fell through to `module(*args, **kwargs)` — i.e. a
no-op. Call the transformer blocks directly instead and delete the unused
`auto_grad_checkpoint` / `checkpoint_sequential` helpers and the
`torch.utils.checkpoint` import.

(Inference is unchanged — the block never read the `use_reentrant` kwarg
the wrapper passed. Full training-time gradient checkpointing via the
standard `_gradient_checkpointing_func` would need the block's dynamic
kwargs — camera_conditions / prope_fns / chunk_index — threaded through,
so it's left as a follow-up for this inference-focused release.)
@lawrence-cj

Copy link
Copy Markdown
Contributor Author

@dg845 gentle ping

@dg845

dg845 commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

@askserge can you do a review of the current Sana-WM pipeline implementation in this PR?

@sergereview sergereview Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤗 Serge says:

SANA-WM Pipeline Review

This is a large addition (~8000+ lines across transformer, kernels, pipeline, refiner, camera utilities, docs, tests, and conversion script) for a camera-controlled image-to-video pipeline. The architecture is complex with a two-stage design (DiT + optional LTX-2 AR refiner). Overall the code is functional but has several issues that should be addressed before merge.

Correctness

  • The transformer model file (transformer_sana_wm.py) is ~6500 lines and appears to be a near-verbatim port of the research repo rather than a clean diffusers-style implementation. It carries a large amount of dead code: unused activation/norm registries, training-time utilities (remove_bn, set_norm_eps), unused embedding paths (use_delta_actions, use_delta_translation, use_delta_pose_additive, cross_attn_image_embeds, pack_latents, etc.), and multiple attention variants that are never selected by the default config. Per the repo's coding style guide: "When porting from a research repo, delete training-time code paths, experimental flags, and ablation branches entirely — only keep the inference path you are actually integrating."

  • _xformers_available is hardcoded to False (line 5461), yet the forward method has a branch (lines 6348-6352) that raises ValueError when mask is None and _xformers_available is False. While the pipeline always passes mask, this makes the transformer unusable standalone without a mask — a surprising API trap.

  • The _tokens_per_frame calculation in _RefinerChunkRunner multiplies by patch_size_t instead of dividing. For the LTX-2 transformer with patch_size_t=1 this is a no-op, but the formula is semantically wrong: tokens per frame should be (H // patch_size) * (W // patch_size) / patch_size_t (or just the spatial product when patch_size_t=1).

Security

  • torch.load(..., weights_only=False) at refiner.py:299 is the only instance in the entire diffusers codebase. The checkpoint payload contains only dicts, ints, tuples, and tensors — weights_only=True should work (or use safetensors). Loading arbitrary pickles from a user-supplied checkpoint_dir is a deserialization vulnerability.

  • torch.load at transformer_sana_wm.py:6523 (null embed loading) also lacks weights_only=True, though null_embed_path defaults to None so it always hits the except branch in practice. Still should be cleaned up.

Style / Repo conventions

  • The transformer file is enormous (~6500 lines) with many helper classes, registries, and code paths that only exist for training or ablation variants not used at inference. The diffusers guide explicitly says to inline small helpers and delete unused code paths. This file needs significant pruning.

  • self.logger = print (line 5964) — the transformer uses print as its logger instead of the standard logging.get_logger(__name__) pattern used everywhere else in diffusers. Several self.logger(...) calls produce noisy stdout output during construction.

  • Hard assert statements (e.g., lines 5981-5984, 6165, 6425) should be replaced with proper ValueError raises per Python best practices.

  • The SanaWMPipelineOutput dataclass is missing a license header.

Tests

  • The test file acknowledges that the transformer has hardcoded architecture dimensions and cannot be tested with a tiny dummy model via PipelineTesterMixin. This means there are no fast-path pipeline tests — only CPU unit tests for helpers and a @skip-decorated integration test. This is a significant coverage gap.

Dependencies

  • The transformer requires fla (flash-linear-attention), timm, and optionally triton as runtime dependencies. These are not listed in diffusers' setup requirements. The fallback stubs raise at construction time, which is fine, but the dependency surface is unusually large for a diffusers model.

serge v0.1.0 · model: claude-opus-4-6 · 24 LLM turns · 29 tool calls · 204.6s · 2189757 in / 5733 out tokens

Comment thread src/diffusers/pipelines/sana_wm/refiner.py Outdated
Comment thread src/diffusers/pipelines/sana_wm/refiner.py
Comment thread src/diffusers/pipelines/sana_wm/pipeline_sana_wm.py Outdated
Comment thread src/diffusers/pipelines/sana_wm/pipeline_sana_wm.py Outdated
Comment thread src/diffusers/pipelines/sana_wm/pipeline_output.py
Comment thread src/diffusers/pipelines/sana_wm/cam_utils.py Outdated
Comment thread src/diffusers/pipelines/sana_wm/cam_utils.py Outdated
@dg845

dg845 commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Hi @lawrence-cj, sorry for the delay and thanks for your patience! I have asked the Serge bot to review the current code and its comments all look reasonable to me. Hopefully this helps unblock you, will follow up with further review comments.

Comment thread src/diffusers/models/transformers/transformer_sana_wm.py Outdated
Comment thread src/diffusers/models/transformers/transformer_sana_wm.py Outdated
Comment thread src/diffusers/models/transformers/transformer_sana_wm.py Outdated
@lawrence-cj

Copy link
Copy Markdown
Contributor Author

Hi @lawrence-cj, sorry for the delay and thanks for your patience! I have asked the Serge bot to review the current code and its comments all look reasonable to me. Hopefully this helps unblock you, will follow up with further review comments.

Congrads on the agent workflow for reviewing code. Hopefully it will speed up and perfect all PR processes.
I'll continue fix the problem and go through your comments.

Per @dg845 / @sayakpaul: the SANA-WM transformer required `fla-core` to be
constructible at all (and fla's `ShortConvolution` cannot even run on CPU —
its dispatch does `torch.cpu.device(...)`, which doesn't exist).

`ShortConvolution` was only ever used as a depthwise *causal* conv1d with
`activation=None`, so it is replaced by a ~20-line self-contained PyTorch
module with the same parameter layout (`weight` of shape `(C, 1, K)`, no
bias) and the same `(output, cache)` return signature. No `fla-core` and no
`kernels` dependency is needed now, and the model can be built and run on
CPU. Verified on an H100 in bf16 at the real config (hidden 2240, kernel 4):
`max|Δ|` vs fla is exactly `0.0` for every shape tested, and the state dict
keeps the identical 871 keys.

Also:
* Use the existing `is_timm_available()` utility for the timm imports. The
  `else` branch keeps a placeholder class rather than raising at module
  scope, because several layers subclass these symbols and this module is
  imported eagerly by `diffusers.models` — a module-level raise would break
  plain `import diffusers` (and the `check_torch_dependencies` CI job) when
  timm isn't installed. The error is instead raised on construction.
* Drop `termcolor` entirely: replace the `self.logger = print` +
  `colored(...)` pattern with the module-level `logger`, so there is no
  optional dependency left to gate.
* Only attempt the optional null-embedding load when `null_embed_path` is
  actually set (it is unset for the public checkpoint, so this previously
  logged a spurious failure on every construction) and load it with
  `weights_only=True`.
* refiner: load the AR resume checkpoint with `weights_only=True`. The
  payload is only tensors / ints / tuples / dicts plus the generator's uint8
  state, so it round-trips safely under the restricted unpickler.
* refiner: fix `_tokens_per_frame` to divide by `patch_size_t` rather than
  multiply — `_pack_latents` emits `(T // patch_size_t) * (H // p) * (W // p)`
  tokens, so one latent frame contributes `(H // p) * (W // p) / patch_size_t`.
  No-op for LTX-2 (`patch_size_t=1`) but the history trimming would have kept
  too many tokens otherwise.
* pipeline: replace `torch.cuda.empty_cache()` with diffusers'
  backend-agnostic `empty_device_cache(device.type)`.
* pipeline: extract the offload probe into `_model_cpu_offload_active()`,
  matching the `hasattr(self, "_all_hooks") and len(self._all_hooks) > 0`
  idiom `DiffusionPipeline` uses internally, instead of a bare truthiness
  check on the attribute.
* pipeline_output: add the missing Apache-2.0 license header.
* cam_utils: drop the unnecessary `+ 1e-6` when normalizing the forward /
  right vectors — the branch is only taken when the norm is already > 0, so
  the epsilon just introduced a small systematic bias.
@yiyixuxu

Copy link
Copy Markdown
Collaborator

Hey @lawrence-cj — sorry we've been slow on this one, and thanks for your patience.

The PR deviates quite a bit from our conventions, and I think a normal review round would take a lot of back-and-forth on both sides. But we have a self-review skill now: https://github.com/huggingface/diffusers/blob/main/.ai/skills/self-review/SKILL.md

Basically: point your agent at the skill and ask it for a self-review → you'll get back a report that separates blocking issues from everything else → you work through the blocking ones with the agent interactively. If something's ambiguous or you're not sure it's right, skip it — the agent will keep a note so we can weigh in during review.

It would speed things up a lot if you're able to do that. Otherwise we'll take over the PR and refactor it ourselves — we don't have the bandwidth right now, but we'll get to it as soon as we can. Just let us know which you'd prefer.

@lawrence-cj

Copy link
Copy Markdown
Contributor Author

Thanks @yiyixuxu — I'd much rather do the work than hand it over. Running the self-review skill against .ai/review-rules.md now and working through the blocking findings; I'll post the report here once I've been through it.

…onventions

From the `self-review` skill run against `.ai/models.md` / `.ai/AGENTS.md`.
`transformer_sana_wm.py`: 6555 -> 4592 lines.

Conventions:
* `_no_split_modules` was `["blocks"]` — an attribute name, but accelerate
  matches on class name, so it matched nothing and `device_map="auto"` could
  split a block across devices and crash. Now `["SanaVideoMSCamCtrlBlock"]`.
* Add `_repeated_blocks` (enables `compile_repeated_blocks()`) and
  `_skip_layerwise_casting_patterns`. `_keep_in_fp32_modules` is deliberately
  left unset with a note: the blocks apply the timestep modulation inline, so
  keeping `scale_shift_table` / `t_embedder` in fp32 upcasts the hidden states
  and feeds fp32 activations to bf16 weights (caught by a GPU smoke run).
* Expose `num_layers` / `hidden_size` / `num_attention_heads` / `patch_size`
  through `register_to_config` instead of hardcoding the release architecture,
  so a tiny variant can be built for tests. Defaults are the released values,
  so `config.json` and the state dict are unchanged.
* `torch.float64` -> `torch.float32` on the live RoPE paths (gotcha 5),
  `torch.empty` -> `torch.zeros` for parameter init (gotcha 6), and stop
  reading `self.proj.weight.dtype` to cast activations (gotcha 4).
* `WanRotaryPosEmbed.freqs` is a non-persistent buffer instead of a plain
  attribute reassigned inside `forward` (which broke `.to()` and compile).

Dead code (`AGENTS.md`: "delete training-time code paths, experimental flags,
and ablation branches entirely — only keep the inference path"):
* All weight-init / transfer-learning helpers — `from_pretrained` overwrites
  them, and they also printed ~20 lines of noise on every construction.
* `CaptionEmbedder.initialize_gemma_params` (fetched `google/gemma-2b-it`
  out-of-band at runtime, and referenced an attribute that never exists),
  `token_drop`, and the training branch of its forward.
* The cam-debug statistics apparatus, the `save_block_output` hooks (whose
  `get_block_output` was never defined), and `block_hook`.
* Both unreachable recurrence variants, `_maybe_drop_cam_branch`, the xformers
  branches (`_xformers_available` was a literal `False`, defined twice, and
  `xformers` was never imported), 3 env-var escape hatches, 9 unused classes,
  12 unused module-level helpers, and the sincos family — which also removes
  the NumPy import, satisfying the no-NumPy-in-forward rule.
* Inline the `fp32_attention` mechanism: it was set on every submodule via
  `model.apply` and read at 18 sites, so it was folded to the shipped
  always-on behaviour rather than deleted.

Ephemeral comments (commit SHAs from a private tree, references to files that
don't exist in diffusers, FSDP2 rationale, stale docstrings) removed.

State dict is unchanged: 871/871 keys match the released checkpoint.
…scaffolding

From the `self-review` skill run against `.ai/pipelines.md` / `.ai/testing.md`.

`flow_shift` was a dead knob. `__call__` set `self.scheduler.config.shift`,
but `FlowMatchEulerDiscreteScheduler.set_timesteps` reads `self.shift` (i.e.
`self._shift`), so the documented argument had no effect and every run used
the checkpoint's `shift=9.8`. It also half-mutated a `FrozenDict` — the freeze
guard checks a name-mangled attribute, so the assignment silently desynced
`config.shift` from `config["shift"]` on a shared component. Removed the
argument so the scheduler owns the shift (`pipelines.md` gotcha 3), which also
drops a per-call mutation of a registered component (gotcha 7).

Other pipeline fixes:
* `torch.randn` -> `randn_tensor` in `prepare_latents` and the refiner
  (gotcha 10). `__call__` advertises `generator: Generator | list[Generator]`,
  but `torch.randn` raises on a generator list and on a CPU generator with a
  CUDA device, so that path could not work.
* Delete the SLURM preemption/resume feature (`checkpoint_dir`,
  `_atomic_save_state`, `_capture_state` / `_restore_state`) and the
  single-shot refiner path the docstring itself called a debugging fallback —
  roughly 270 lines of research-cluster scaffolding.
* `_empty_cuda_cache` (CUDA-only) -> `empty_device_cache`;
  `@torch.inference_mode()` -> `@torch.no_grad()` and removed from inner
  helpers the decorator already covers (gotcha 2).
* Remove dead `_callback_tensor_inputs` (no `callback_on_step_end` exists),
  `_exclude_from_cpu_offload` (a no-op — the base class already skips
  non-`nn.Module` components), `_kv_max_frames`, and `latents.detach()`.

Tests: drop the `@slow` integration stub — `testing.md` says integration and
slow tests don't belong in the initial PR.

`refiner.py`: 1286 -> 1017 lines.
@lawrence-cj

Copy link
Copy Markdown
Contributor Author

Self-review report

Ran the self-review skill against .ai/review-rules.md + AGENTS.md / models.md / pipelines.md / testing.md. Thanks for pointing me at it — it caught a real bug and a lot of research-repo residue.

Net −2,559 lines (pushed in b9fd857e1). transformer_sana_wm.py 6555 → 4592, refiner.py 1286 → 1017.

🐛 Correctness bug it found

flow_shift was a dead knob. __call__ did self.scheduler.config.shift = flow_shift, but FlowMatchEulerDiscreteScheduler.set_timesteps reads self.shiftself._shift, not config.shift. So the documented kwarg did nothing and every run silently used the checkpoint's shift=9.8. It also half-mutated a FrozenDict (the freeze guard checks __frozen, stored name-mangled, so it never fires) — leaving config.shift and config["shift"] permanently disagreeing on a shared component.

Fixed by deleting the kwarg and letting the scheduler own the shift (pipelines.md gotcha 3). That also removes the gotcha-7 mutation and keeps the numerics every validation run was built on.

Fixed

Model (models.md)

  • _no_split_modules = ["blocks"]["SanaVideoMSCamCtrlBlock"] — it was an attribute name, so accelerate matched nothing and device_map="auto" would have split a block across GPUs and crashed. Added _repeated_blocks and _skip_layerwise_casting_patterns. I deliberately left _keep_in_fp32_modules unset: adding the usual Wan-style list broke inference on GPU, because SANA-WM applies the timestep modulation inline (t2i_modulate), so holding scale_shift_table / t_embedder in fp32 upcasts the hidden states and feeds fp32 activations into bf16 weights. Supporting it needs explicit casts in the block forward — noted in the code.
  • torch.float64torch.float32 on the three live RoPE sites (gotcha 5); torch.emptytorch.zeros for parameter init (gotcha 6); self.proj.weight.dtype → input dtype (gotcha 4); WanRotaryPosEmbed.freqs → non-persistent buffer instead of a plain attribute reassigned inside forward.
  • Deleted ~1,960 lines of unreachable code: all weight-init/transfer-learning helpers (from_pretrained overwrites them), initialize_gemma_params (out-of-band from_pretrained, and broken — read an undefined attribute), token_drop + the training branch, the cam-debug apparatus, both unreachable recurrence variants, the xformers branches (_xformers_available was a literal False defined twice and xformers was never imported), 3 env-var escape hatches, 9 unused classes, 12 unused helpers, and the sincos family — which let import numpy as np go, satisfying the no-NumPy-in-forward rule.

Pipeline / refiner (pipelines.md)

  • torch.randnrandn_tensor (gotcha 10). The signature advertises generator: Generator | list[Generator], but torch.randn raises TypeError on a list and RuntimeError on a CPU generator with a CUDA device — so that path was broken.
  • Deleted the SLURM preemption/resume feature (checkpoint_dir, _atomic_save_state, _capture_state/_restore_state) and the dead single-shot path the docstring called a "debugging fallback" — ~270 lines of cluster-ops scaffolding AGENTS.md rules out.
  • _empty_cuda_cache (CUDA-only) → empty_device_cache; @torch.inference_mode()@torch.no_grad() and dropped from inner helpers (gotcha 2); removed dead _callback_tensor_inputs, _exclude_from_cpu_offload (a no-op — pipeline_utils already skips non-nn.Module components), _kv_max_frames, latents.detach().
  • Missing license header; the + 1e-6 in a branch already guarded on norm > 0; ephemeral comments.

Verification

  • State dict unchanged: 871/871 keys, exact against the released checkpoint.
  • Differential test old-vs-new module: bit-exact (max|Δ| = 0.0) with float64 RoPE held constant; 4.98e-4 abs / 1.24e-4 rel with the prescribed float64→float32, entirely attributable to that change.
  • End-to-end GPU smoke (stage 1 + refiner, public checkpoint): (24, 704, 1280, 3), no NaN, frames visually correct. Frame mean moved 0.55600.5554, which is exactly the float64→float32 RoPE change. (This run is what caught the _keep_in_fp32_modules problem above.)
  • 15 CPU tests, ruff, doc-builder, docstring checks all clean.

Deliberately not changed — I'd like your call first

  1. Nested-pipeline composition. SanaWMLTX2Refiner is a DiffusionPipeline registered as a component of SanaWMPipeline. Because it isn't an nn.Module, none of the base-class device/offload machinery reaches its ~87 GB, which is why both pipelines hand-roll .to(device) shuffling — a pipelines.md gotcha-7 violation I did not paper over, since the honest fix is structural. Options: flatten Kandinsky-combined-style (register refiner_* sub-modules so model_cpu_offload_seq covers them), or ship stage 2 as a separate top-level pipeline chained via output_type="latent". Related: pipelines.md opens with "prefer modular for new pipelines" — happy to go that route if you'd rather.

  2. Attention pattern. The GDN linear-attention blocks don't use AttentionModuleMixin / a processor / dispatch_attention_fn, and cross-attention passes a dense additive -10000.0 mask that hard-raises on flash/FA3/sage. Converting cross-attention + the 5 softmax blocks is tractable; expressing the GDN recurrence as a processor is not obviously possible. Want me to convert the parts that fit and leave GDN as a documented exception?

  3. Single-file policy / Triton. transformer_sana_wm_kernels.py has no precedent in-tree. The pure-PyTorch camera math and RoPE prep should move into the model file regardless. For the ~3 live Triton kernels the real choice is: ship pure-PyTorch only in this PR and land kernels as a follow-up, or publish to kernels-community and dispatch (as suggested for fla). Your call — I didn't want to pick unilaterally.

  4. Tests. I removed the @slow integration stub (testing.md: none in the initial PR) and unblocked the real fix — num_layers / hidden_size / num_attention_heads / patch_size are now register_to_config params (defaults unchanged, so the released config.json still loads and the state dict is untouched), and a tiny 107-param variant now builds. What's still missing is the actual testing.md suite: test_pipeline_sana_wm.py with a SanaWMPipelineTesterConfig + PipelineTesterMixin/MemoryTesterMixin, and a generated model-level test. PipelineTesterMixin additionally needs the standard __call__ surface (batching, prompt_embeds, callback_on_step_end, num_videos_per_prompt), which this pipeline doesn't have. Since that surface is shaped by the decision in (1), I'd rather land the tests right after you weigh in than guess now.

Smaller items I left for review: seed alongside generator, check_inputs returning normalized values, _encode_first_frame/_build_camera_kwargs being private, TARGET_HEIGHT/WIDTH module constants, encode_prompt overlapping SanaPipeline's without # Copied from, and the estimate_intrinsics_with_pi3x helper that needs an undeclared pi3 dependency.

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 pipelines size/L PR with diff > 200 LOC tests utils

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants