| |
| |
|
|
| import torch |
| import torch.nn as nn |
| from typing import List, Optional, Dict, Any |
| import logging |
| import sys |
| import os |
| import json |
| from pathlib import Path |
|
|
| from wan.modules.model import WanModel, sinusoidal_embedding_1d |
| from wan.modules.vae2_2 import Wan2_2_VAE |
|
|
| |
| try: |
| from safetensors.torch import load_file as safe_load_file |
| except Exception: |
| safe_load_file = None |
|
|
| logger = logging.getLogger(__name__) |
|
|
| def _strip_known_prefixes_for_wan(sd: Dict[str, torch.Tensor], target_model: nn.Module) -> Dict[str, torch.Tensor]: |
| """Strip only the 'dit.' prefix from checkpoint keys if present.""" |
| if not isinstance(sd, dict): |
| return sd |
| if not any(k.startswith('dit.') for k in sd.keys()): |
| return sd |
| mapped = { (k[4:] if k.startswith('dit.') else k): v for k, v in sd.items() } |
| logger.info("Stripped 'dit.' prefix from checkpoint keys") |
| return mapped |
|
|
| class WanVideoModel(nn.Module): |
| """ |
| WAN Video Diffusion Model wrapper for TI2V Teacher Forcing training. |
| Provides VAE encoding/decoding and feature extraction for joint video-action training. |
| Uses Teacher Forcing approach for I2V conditioning (DiffSynth-Studio style). |
| """ |
| |
| def __init__( |
| self, |
| model_config: Dict[str, Any], |
| vae_path: str, |
| device: str = "cuda", |
| precision: str = "bfloat16" |
| ): |
| super().__init__() |
| |
| self.device = torch.device(device) |
| self.precision = { |
| "float32": torch.float32, |
| "float16": torch.float16, |
| "bfloat16": torch.bfloat16, |
| }[precision] |
| |
| |
| self.wan_model = WanModel(**model_config) |
| self.wan_model.to(device=self.device, dtype=self.precision) |
|
|
| |
| self.vae = Wan2_2_VAE(vae_pth=vae_path, device=self.device) |
| |
| logger.info(f"WAN Video Model initialized with {sum(p.numel() for p in self.wan_model.parameters()):,} parameters") |
| |
| def encode_video(self, video_pixels: torch.Tensor) -> torch.Tensor: |
| """ |
| Encode video pixels to latent space. |
| |
| Args: |
| video_pixels: Video in pixel space [B, C, T, H, W], range [-1, 1] |
| |
| Returns: |
| Video latents [B, C', T', H', W'] |
| """ |
| with torch.no_grad(): |
| return self.vae.encode(video_pixels) |
| |
| def decode_video(self, video_latents: torch.Tensor) -> torch.Tensor: |
| """ |
| Decode video latents to pixel space. |
| |
| Args: |
| video_latents: Video latents [B, C, T, H, W] |
| |
| Returns: |
| Video pixels [B, C', T', H', W'], range [-1, 1] |
| """ |
| with torch.no_grad(): |
| video_pixels = [] |
| for i in range(video_latents.shape[0]): |
| pixels = self.vae.decode([video_latents[i]])[0] |
| video_pixels.append(pixels) |
| result = torch.stack(video_pixels, dim=0) |
| return result |
| |
| def get_layer_features( |
| self, |
| video_latent: torch.Tensor, |
| timestep: torch.Tensor, |
| text_embeddings: List[torch.Tensor], |
| layer_indices: Optional[List[int]] = None |
| ) -> List[torch.Tensor]: |
| """ |
| Extract intermediate layer features for cross-attention injection. |
| |
| Args: |
| video_latent: Video latent tensors [B, C, T, H, W] |
| timestep: Diffusion timesteps [B] |
| text_embeddings: List of text embeddings |
| layer_indices: Which layers to extract (None = all layers) |
| |
| Returns: |
| List of feature tensors from specified layers |
| """ |
| if layer_indices is None: |
| layer_indices = list(range(len(self.wan_model.blocks))) |
| |
| |
| if video_latent.ndim != 5: |
| raise ValueError(f"Expected 5D tensor [B, C, f, h, w], got {video_latent.ndim}D with shape {video_latent.shape}") |
| |
| |
| expected_channels = 48 |
| if video_latent.shape[1] != expected_channels: |
| raise ValueError(f"Expected {expected_channels} channels for WAN 2.2, got {video_latent.shape[1]} channels") |
| |
| |
| video_list = [video_latent[i] for i in range(video_latent.shape[0])] |
| seq_len = video_latent.shape[2] * video_latent.shape[3] * video_latent.shape[4] // 4 |
| |
| |
| device = self.wan_model.patch_embedding.weight.device |
| if self.wan_model.freqs.device != device: |
| self.wan_model.freqs = self.wan_model.freqs.to(device) |
| |
| |
| x = [self.wan_model.patch_embedding(u.unsqueeze(0)) for u in video_list] |
| grid_sizes = torch.stack([torch.tensor(u.shape[2:], dtype=torch.long, device=device) for u in x]) |
| x = [u.flatten(2).transpose(1, 2) for u in x] |
| seq_lens = torch.tensor([u.size(1) for u in x], dtype=torch.long, device=device) |
| x = torch.cat([ |
| torch.cat([u, u.new_zeros(1, seq_len - u.size(1), u.size(2))], dim=1) |
| for u in x |
| ]) |
| |
| |
| if timestep.dim() == 1: |
| timestep = timestep.unsqueeze(1).expand(timestep.size(0), seq_len) |
| with torch.amp.autocast('cuda', dtype=torch.float32): |
| bt = timestep.size(0) |
| t_flat = timestep.flatten() |
| e = self.wan_model.time_embedding( |
| sinusoidal_embedding_1d(self.wan_model.freq_dim, t_flat).unflatten(0, (bt, seq_len)).float().to(device) |
| ) |
| e0 = self.wan_model.time_projection(e).unflatten(2, (6, self.wan_model.dim)) |
| assert e.dtype == torch.float32 and e0.dtype == torch.float32 |
| |
| |
| context = self.wan_model.text_embedding( |
| torch.stack([ |
| torch.cat([u, u.new_zeros(self.wan_model.text_len - u.size(0), u.size(1))]) |
| for u in text_embeddings |
| ]) |
| ) |
| |
| |
| layer_features = [] |
| kwargs = dict( |
| e=e0, |
| seq_lens=seq_lens, |
| grid_sizes=grid_sizes, |
| freqs=self.wan_model.freqs, |
| context=context, |
| context_lens=None |
| ) |
| |
| for i, block in enumerate(self.wan_model.blocks): |
| x = block(x, **kwargs) |
| if i in layer_indices: |
| layer_features.append(x.clone()) |
| |
| |
| x = self.wan_model.head(x, e) |
| x = self.wan_model.unpatchify(x, grid_sizes) |
| final_output = torch.stack([u.float() for u in x], dim=0) |
| |
| |
| layer_features.append(final_output) |
| |
| return layer_features |
|
|
| @classmethod |
| def from_config( |
| cls, |
| config_path: str, |
| vae_path: str, |
| device: str = "cuda", |
| precision: str = "bfloat16" |
| ) -> 'WanVideoModel': |
| """ |
| Initialize WAN model architecture and VAE only (no WAN weights). |
| Useful when model weights will be loaded from a higher-level checkpoint. |
| """ |
| |
| config_json_path = os.path.join(config_path, 'config.json') |
| if not os.path.exists(config_json_path): |
| raise FileNotFoundError(f"WAN config.json not found at {config_json_path}") |
| with open(config_json_path, 'r') as f: |
| model_config = json.load(f) |
| |
| model = cls( |
| model_config=model_config, |
| vae_path=vae_path, |
| device=device, |
| precision=precision |
| ) |
| logger.info("Initialized WAN model from config only (no WAN weights loaded)") |
| return model |
|
|
| @classmethod |
| def from_pretrained( |
| cls, |
| checkpoint_path: str, |
| vae_path: str, |
| config_path: Optional[str] = None, |
| device: str = "cuda", |
| precision: str = "bfloat16" |
| ) -> 'WanVideoModel': |
| """ |
| Load pretrained WAN model. |
| |
| Args: |
| checkpoint_path: Path to WAN checkpoint (.pt file or directory) |
| vae_path: Path to VAE checkpoint |
| config_path: Path to config directory (optional, defaults to checkpoint_path) |
| device: Device to load model on |
| precision: Model precision |
| |
| Returns: |
| WanVideoModel instance |
| """ |
| |
| if config_path is None: |
| config_path = checkpoint_path |
| |
| config_json_path = os.path.join(config_path, 'config.json') |
| if os.path.exists(config_json_path): |
| with open(config_json_path, 'r') as f: |
| model_config = json.load(f) |
| |
| |
| model = cls( |
| model_config=model_config, |
| vae_path=vae_path, |
| device=device, |
| precision=precision |
| ) |
| |
| |
| try: |
| logger.info(f"Loading WAN weights from {checkpoint_path}") |
| |
| if checkpoint_path.endswith('.pt'): |
| |
| logger.info(f"Loading weights from .pt file: {checkpoint_path}") |
| checkpoint_state_dict = torch.load(checkpoint_path, map_location='cpu') |
| |
| |
| if isinstance(checkpoint_state_dict, dict) and 'model' in checkpoint_state_dict: |
| |
| wan_state_dict = checkpoint_state_dict['model'] |
| else: |
| |
| wan_state_dict = checkpoint_state_dict |
| |
| |
| |
| try: |
| wan_state_dict = _strip_known_prefixes_for_wan(wan_state_dict, model.wan_model) |
| except Exception: |
| pass |
| incompatible_keys = model.wan_model.load_state_dict(wan_state_dict, strict=False) |
| if incompatible_keys.missing_keys: |
| logger.warning(f"Missing keys: {incompatible_keys.missing_keys}") |
| if incompatible_keys.unexpected_keys: |
| logger.warning(f"Unexpected keys: {incompatible_keys.unexpected_keys}") |
| |
| logger.info(f"Successfully loaded WAN weights from .pt file") |
| elif checkpoint_path.endswith('.bin') or checkpoint_path.endswith('.safetensors'): |
| |
| logger.info(f"Loading weights from weight file: {checkpoint_path}") |
| if checkpoint_path.endswith('.safetensors'): |
| if safe_load_file is None: |
| raise RuntimeError("safetensors not available. Please 'pip install safetensors'.") |
| wan_state_dict = safe_load_file(checkpoint_path, device='cpu') |
| else: |
| loaded = torch.load(checkpoint_path, map_location='cpu') |
| |
| if isinstance(loaded, dict) and ('state_dict' in loaded or 'model' in loaded): |
| wan_state_dict = loaded.get('state_dict', loaded.get('model')) |
| else: |
| wan_state_dict = loaded |
| |
| try: |
| wan_state_dict = _strip_known_prefixes_for_wan(wan_state_dict, model.wan_model) |
| except Exception: |
| pass |
| incompatible_keys = model.wan_model.load_state_dict(wan_state_dict, strict=False) |
| if incompatible_keys.missing_keys: |
| logger.warning(f"Missing keys: {incompatible_keys.missing_keys}") |
| if incompatible_keys.unexpected_keys: |
| logger.warning(f"Unexpected keys: {incompatible_keys.unexpected_keys}") |
| logger.info("Successfully loaded WAN weights from single file") |
| else: |
| |
| loaded_model = WanModel.from_pretrained(checkpoint_path) |
| model.wan_model.load_state_dict(loaded_model.state_dict(), strict=False) |
| logger.info(f"Successfully loaded WAN weights from directory") |
| |
| except Exception as e: |
| logger.warning(f"Failed to load WAN checkpoint from {checkpoint_path}: {e}") |
| logger.warning("Using random initialization instead") |
| |
| return model |