Files
hao-ai-lab-FastVideo/fastvideo2/wan21/reference.py
T

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()