200 lines
9.4 KiB
Python
200 lines
9.4 KiB
Python
"""Wan2.1 T2V reference — the oracle, in one readable file.
|
|
|
|
This is the complete generation path in plain eager PyTorch: encode the
|
|
prompt with UMT5 (HF transformers — anchored bit-identical to official), run N
|
|
classifier-free-guided flow-match Euler steps on the OFFICIAL WanModel (the
|
|
vendored authors' modeling code in ``fastvideo2/wan21/model.py`` — the only
|
|
fastvideo2 import here, itself a standalone file), decode with the Wan VAE
|
|
(diffusers — anchored to 3e-4). No runtime, no policies — copy this file plus
|
|
``model.py``/``attention.py`` out of the repo and they still run.
|
|
|
|
It serves three roles:
|
|
* the textbook: what Wan2.1 T2V *is*, in execution order;
|
|
* the T2 oracle: the production pipeline must reproduce this trajectory
|
|
within the card's declared tolerance (``fastvideo2 verify --tier 2``);
|
|
* the porting aid: the starting point an agent copies when building a
|
|
bespoke stack.
|
|
|
|
Deliberate simplification: the sampler is first-order flow-match Euler (the
|
|
same solver the production loop uses), not the multistep UniPC that upstream
|
|
Wan defaults to. The reference defines what the production path must match;
|
|
it does not chase upstream sampler variants.
|
|
|
|
Authority ordering: the OFFICIAL implementation (Wan-Video/Wan2.1) is the
|
|
numerics ground truth. This file currently runs on diffusers components as a
|
|
convenience backend — that fidelity is *certified* against captured official
|
|
goldens (``verify --anchor``, see ``capture_official.py``), never assumed;
|
|
ports like diffusers are known to drift from official implementations in ways
|
|
that surface later as training skew. When a convention conflicts, official wins.
|
|
|
|
Usage:
|
|
python -m fastvideo2.wan21.reference --prompt "a cat surfing" --out cat.mp4
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
WEIGHTS = "Wan-AI/Wan2.1-T2V-1.3B-Diffusers" # text encoder / tokenizer / VAE
|
|
DIT_WEIGHTS = "Wan-AI/Wan2.1-T2V-1.3B" # the official transformer artifact
|
|
NEGATIVE = ("色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,"
|
|
"低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,"
|
|
"毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走")
|
|
|
|
|
|
@dataclass
|
|
class ReferenceResult:
|
|
video: Any # uint8 numpy [T, H, W, C]
|
|
latents: Any # final fp32 latents [1, 16, T', h, w] (cpu)
|
|
trajectory: list = field(default_factory=list) # per-step fp32 latents (cpu), when captured
|
|
|
|
|
|
def load_models(root: str | None = None, device: str = "cuda", dit_root: str | None = None):
|
|
"""Load the four components exactly as declared on the card: the official
|
|
WanModel (bf16, official-layout weights), UMT5 bf16, VAE fp32."""
|
|
import torch
|
|
from diffusers import AutoencoderKLWan
|
|
from transformers import AutoTokenizer, UMT5EncoderModel
|
|
|
|
from fastvideo2.wan21.model import WanModel # the vendored official DiT
|
|
if root is None:
|
|
from huggingface_hub import snapshot_download
|
|
root = snapshot_download(WEIGHTS)
|
|
if dit_root is None:
|
|
from huggingface_hub import snapshot_download
|
|
dit_root = snapshot_download(DIT_WEIGHTS)
|
|
tokenizer = AutoTokenizer.from_pretrained(root, subfolder="tokenizer")
|
|
text_encoder = UMT5EncoderModel.from_pretrained(
|
|
root, subfolder="text_encoder", torch_dtype=torch.bfloat16).to(device).eval()
|
|
dit = WanModel.from_pretrained(dit_root, torch_dtype=torch.bfloat16).to(device).eval()
|
|
vae = AutoencoderKLWan.from_pretrained(
|
|
root, subfolder="vae", torch_dtype=torch.float32).to(device).eval()
|
|
return tokenizer, text_encoder, dit, vae
|
|
|
|
|
|
def clean_text(text: str) -> str:
|
|
"""Official Wan text preprocessing (wan/modules/tokenizers.py,
|
|
clean='whitespace'): ftfy + double html-unescape + whitespace collapse.
|
|
ftfy's width fixing folds full-width punctuation (,→ ,), which changes
|
|
CJK tokenization — skipping it diverges from official embeddings."""
|
|
import html
|
|
import re
|
|
|
|
import ftfy
|
|
text = ftfy.fix_text(text)
|
|
text = html.unescape(html.unescape(text))
|
|
return re.sub(r"\s+", " ", text).strip()
|
|
|
|
|
|
def encode_prompt(tokenizer, text_encoder, text: str):
|
|
"""UMT5 encode, padded to 512 with encoder outputs past the true sequence
|
|
length zeroed (the Wan convention)."""
|
|
import torch
|
|
batch = tokenizer([clean_text(text)], padding="max_length", max_length=512, truncation=True,
|
|
add_special_tokens=True, return_attention_mask=True,
|
|
return_tensors="pt")
|
|
device = next(text_encoder.parameters()).device
|
|
ids, mask = batch.input_ids.to(device), batch.attention_mask.to(device)
|
|
with torch.no_grad():
|
|
embeds = text_encoder(ids, mask).last_hidden_state # [1, 512, 4096]
|
|
embeds[:, int(mask[0].sum()):] = 0
|
|
return embeds
|
|
|
|
|
|
def flow_sigmas(num_steps: int, shift: float) -> list[float]:
|
|
"""Shifted flow-match schedule: sigma = shift*t / (1 + (shift-1)*t) over
|
|
t = 1 .. 0 in N steps. sigma_0 = 1 (pure noise), sigma_N = 0 (clean)."""
|
|
ts = [(num_steps - i) / num_steps for i in range(num_steps + 1)]
|
|
return [shift * t / (1.0 + (shift - 1.0) * t) for t in ts]
|
|
|
|
|
|
def generate(prompt: str,
|
|
*,
|
|
root: str | None = None,
|
|
device: str = "cuda",
|
|
negative_prompt: str = NEGATIVE,
|
|
seed: int = 0,
|
|
num_steps: int = 50,
|
|
guidance_scale: float = 5.0,
|
|
height: int = 480,
|
|
width: int = 832,
|
|
num_frames: int = 81,
|
|
shift: float = 3.0,
|
|
capture_trajectory: bool = False,
|
|
models: tuple | None = None) -> ReferenceResult:
|
|
"""The whole model, in order. Deterministic given (seed, config, env)."""
|
|
import torch
|
|
tokenizer, text_encoder, dit, vae = models or load_models(root, device)
|
|
|
|
# 1) Conditioning: one embedding per CFG branch.
|
|
text = encode_prompt(tokenizer, text_encoder, prompt)
|
|
neg = encode_prompt(tokenizer, text_encoder, negative_prompt or "")
|
|
|
|
# 2) Initial noise in the VAE latent space: 16 channels, 4x temporal and
|
|
# 8x spatial compression. Latents stay fp32; the DiT runs in bf16.
|
|
shape = (1, 16, (num_frames - 1) // 4 + 1, height // 8, width // 8)
|
|
gen = torch.Generator(device).manual_seed(seed)
|
|
x = torch.randn(shape, generator=gen, device=device, dtype=torch.float32)
|
|
|
|
# 3) CFG flow-match Euler: v = v_neg + g*(v_cond - v_neg); x += dsigma * v.
|
|
# The DiT runs the official convention: fp32 latents under autocast
|
|
# bf16, list-based call, seq_len from the (1,2,2) patch grid.
|
|
sigmas = flow_sigmas(num_steps, shift)
|
|
seq_len = x.shape[2] * (x.shape[3] // 2) * (x.shape[4] // 2)
|
|
trajectory: list = []
|
|
for i in range(num_steps):
|
|
sigma, sigma_next = sigmas[i], sigmas[i + 1]
|
|
t = torch.tensor([sigma * 1000.0], device=device, dtype=torch.float32)
|
|
with torch.no_grad(), torch.amp.autocast("cuda", dtype=torch.bfloat16):
|
|
v_cond = dit([x[0]], t=t, context=[text[0]], seq_len=seq_len)[0][None].to(torch.float32)
|
|
if guidance_scale == 1.0:
|
|
v = v_cond
|
|
else:
|
|
v_neg = dit([x[0]], t=t, context=[neg[0]], seq_len=seq_len)[0][None].to(torch.float32)
|
|
v = v_neg + guidance_scale * (v_cond - v_neg)
|
|
x = x + (sigma_next - sigma) * v
|
|
if capture_trajectory:
|
|
trajectory.append(x.detach().to("cpu", copy=True))
|
|
|
|
# 4) Decode: denormalize by the VAE's per-channel latent stats, then decode.
|
|
mean = torch.tensor(vae.config.latents_mean, device=device,
|
|
dtype=torch.float32).view(1, -1, 1, 1, 1)
|
|
std = torch.tensor(vae.config.latents_std, device=device,
|
|
dtype=torch.float32).view(1, -1, 1, 1, 1)
|
|
with torch.no_grad():
|
|
video = vae.decode((x * std + mean).to(torch.float32), return_dict=False)[0]
|
|
video = (video / 2 + 0.5).clamp(0, 1)
|
|
frames = (video[0].permute(1, 2, 3, 0) * 255).round().to(torch.uint8).cpu().numpy()
|
|
|
|
return ReferenceResult(video=frames, latents=x.detach().cpu(), trajectory=trajectory)
|
|
|
|
|
|
def save_video(frames, path: str, fps: int = 16) -> str:
|
|
import imageio.v2 as imageio
|
|
imageio.mimsave(path, list(frames), fps=fps, format="mp4")
|
|
return path
|
|
|
|
|
|
def main() -> None:
|
|
p = argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument("--prompt", required=True)
|
|
p.add_argument("--out", default="reference.mp4")
|
|
p.add_argument("--root", default=None, help="local checkpoint dir (else HF cache)")
|
|
p.add_argument("--seed", type=int, default=0)
|
|
p.add_argument("--steps", type=int, default=50)
|
|
p.add_argument("--guidance", type=float, default=5.0)
|
|
p.add_argument("--height", type=int, default=480)
|
|
p.add_argument("--width", type=int, default=832)
|
|
p.add_argument("--frames", type=int, default=81)
|
|
p.add_argument("--shift", type=float, default=3.0)
|
|
args = p.parse_args()
|
|
res = generate(args.prompt, root=args.root, seed=args.seed, num_steps=args.steps,
|
|
guidance_scale=args.guidance, height=args.height, width=args.width,
|
|
num_frames=args.frames, shift=args.shift)
|
|
print(f"video {res.video.shape} -> {save_video(res.video, args.out)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|