# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project from __future__ import annotations import json import logging import os import time from collections.abc import Iterable from typing import Any, cast import PIL.Image import torch from diffusers.utils.torch_utils import randn_tensor from torch import nn from transformers import AutoTokenizer, UMT5EncoderModel from vllm.model_executor.layers.quantization.base_config import QuantizationConfig from vllm.model_executor.models.utils import AutoWeightsLoader from vllm.sequence import IntermediateTensors from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import DistributedAutoencoderKLWan from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin from vllm_omni.diffusion.distributed.pipeline_parallel import AsyncLatents, PipelineParallelMixin from vllm_omni.diffusion.distributed.utils import get_local_device from vllm_omni.diffusion.forward_context import set_forward_context_denoise_step_idx from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader from vllm_omni.diffusion.model_loader.hub_prefetch import prefetch_subfolders from vllm_omni.diffusion.models.dmd2 import DMD2PipelineMixin from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin, _is_rank_zero from vllm_omni.diffusion.models.schedulers import FlowUniPCMultistepScheduler from vllm_omni.diffusion.models.wan2_2.scheduling_wan_euler import WanEulerScheduler from vllm_omni.diffusion.models.wan2_2.wan2_2_transformer import WanTransformer3DModel from vllm_omni.diffusion.postprocess import interpolate_video_tensor from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.inputs.data import OmniTextPrompt from vllm_omni.platforms import current_omni_platform logger = logging.getLogger(__name__) DEBUG_PERF = False WAN_SAMPLE_SOLVER_CHOICES = {"unipc", "euler"} def build_wan_scheduler(sample_solver: str, flow_shift: float) -> Any: if sample_solver == "unipc": return FlowUniPCMultistepScheduler( num_train_timesteps=1000, shift=flow_shift, prediction_type="flow_prediction", ) if sample_solver == "euler": return WanEulerScheduler( num_train_timesteps=1000, shift=flow_shift, ) raise ValueError( f"Unsupported Wan sample_solver: {sample_solver}. Expected one of: {sorted(WAN_SAMPLE_SOLVER_CHOICES)}" ) def resolve_wan_sample_solver(req: OmniDiffusionRequest, default: str = "unipc") -> str: extra_args = getattr(req.sampling_params, "extra_args", {}) or {} raw = extra_args.get("sample_solver", default) sample_solver = str(raw).strip().lower() if sample_solver not in WAN_SAMPLE_SOLVER_CHOICES: raise ValueError(f"Invalid sample_solver={raw!r}. Expected one of: {sorted(WAN_SAMPLE_SOLVER_CHOICES)}") return sample_solver def resolve_wan_flow_shift(req: OmniDiffusionRequest, od_config: OmniDiffusionConfig) -> float: extra_args = getattr(req.sampling_params, "extra_args", {}) or {} raw_flow_shift = extra_args.get("flow_shift") if raw_flow_shift is None: raw_flow_shift = od_config.flow_shift if od_config.flow_shift is not None else 5.0 try: return float(raw_flow_shift) except (TypeError, ValueError) as exc: raise ValueError(f"Invalid flow_shift={raw_flow_shift!r}. flow_shift must be a float.") from exc def retrieve_latents( encoder_output: torch.Tensor, generator: torch.Generator | None = None, sample_mode: str = "sample", ): """Retrieve latents from VAE encoder output.""" if hasattr(encoder_output, "latent_dist") and sample_mode == "sample": return encoder_output.latent_dist.sample(generator) elif hasattr(encoder_output, "latent_dist") and sample_mode == "argmax": return encoder_output.latent_dist.mode() elif hasattr(encoder_output, "latents"): return encoder_output.latents else: raise AttributeError("Could not access latents of provided encoder_output") def load_transformer_config(model_path: str, subfolder: str = "transformer", local_files_only: bool = True) -> dict: """Load transformer config from model directory or HF Hub.""" if local_files_only: config_path = os.path.join(model_path, subfolder, "config.json") if os.path.exists(config_path): with open(config_path) as f: return json.load(f) else: # Try to download config from HF Hub try: from huggingface_hub import hf_hub_download config_path = hf_hub_download( repo_id=model_path, filename=f"{subfolder}/config.json", ) with open(config_path) as f: return json.load(f) except Exception: pass return {} def create_transformer_from_config( config: dict, quant_config: QuantizationConfig | None = None, prefix: str = "" ) -> WanTransformer3DModel: """Create WanTransformer3DModel from config dict.""" kwargs: dict = {} if "patch_size" in config: kwargs["patch_size"] = tuple(config["patch_size"]) if "num_attention_heads" in config: kwargs["num_attention_heads"] = config["num_attention_heads"] if "attention_head_dim" in config: kwargs["attention_head_dim"] = config["attention_head_dim"] if "in_channels" in config: kwargs["in_channels"] = config["in_channels"] if "out_channels" in config: kwargs["out_channels"] = config["out_channels"] if "text_dim" in config: kwargs["text_dim"] = config["text_dim"] if "freq_dim" in config: kwargs["freq_dim"] = config["freq_dim"] if "ffn_dim" in config: kwargs["ffn_dim"] = config["ffn_dim"] if "num_layers" in config: kwargs["num_layers"] = config["num_layers"] if "cross_attn_norm" in config: kwargs["cross_attn_norm"] = config["cross_attn_norm"] if "eps" in config: kwargs["eps"] = config["eps"] if "image_dim" in config: kwargs["image_dim"] = config["image_dim"] if "added_kv_proj_dim" in config: kwargs["added_kv_proj_dim"] = config["added_kv_proj_dim"] if "rope_max_seq_len" in config: kwargs["rope_max_seq_len"] = config["rope_max_seq_len"] if "pos_embed_seq_len" in config: kwargs["pos_embed_seq_len"] = config["pos_embed_seq_len"] if "quantization_config" in config: from vllm_omni.quantization.factory import resolve_quant_config_from_disk quant_config = resolve_quant_config_from_disk(quant_config, config["quantization_config"]) if quant_config is not None: kwargs["quant_config"] = quant_config if prefix: kwargs["prefix"] = prefix return WanTransformer3DModel(**kwargs) def get_wan22_post_process_func( od_config: OmniDiffusionConfig, ): from diffusers.video_processor import VideoProcessor video_processor = VideoProcessor(vae_scale_factor=8) def post_process_func( video: torch.Tensor, output_type: str = "np", sampling_params=None, ): if output_type == "latent": return video custom_output = {} if sampling_params is not None and getattr(sampling_params, "enable_frame_interpolation", False): video, multiplier = interpolate_video_tensor( video, exp=sampling_params.frame_interpolation_exp, scale=sampling_params.frame_interpolation_scale, model_path=sampling_params.frame_interpolation_model_path, ) custom_output["video_fps_multiplier"] = multiplier return { "video": video_processor.postprocess_video(video, output_type=output_type), "custom_output": custom_output, } return post_process_func def get_wan22_pre_process_func( od_config: OmniDiffusionConfig, ): """Pre-process function for Wan2.2: optionally load and resize input image for I2V mode.""" import numpy as np def pre_process_func(request: OmniDiffusionRequest) -> OmniDiffusionRequest: for i, prompt in enumerate(request.prompts): multi_modal_data = prompt.get("multi_modal_data", {}) if not isinstance(prompt, str) else None raw_image = multi_modal_data.get("image", None) if multi_modal_data is not None else None if isinstance(prompt, str): prompt = OmniTextPrompt(prompt=prompt) if "additional_information" not in prompt: prompt["additional_information"] = {} if raw_image is None: continue if not isinstance(raw_image, (str, PIL.Image.Image)): raise TypeError( f"""Unsupported image format {raw_image.__class__}.""", """Please correctly set `"multi_modal_data": {"image": , …}`""", ) image = PIL.Image.open(raw_image).convert("RGB") if isinstance(raw_image, str) else raw_image # Calculate dimensions based on aspect ratio if not provided if request.sampling_params.height is None or request.sampling_params.width is None: # Default max area for 720P max_area = 720 * 1280 aspect_ratio = image.height / image.width # Calculate dimensions maintaining aspect ratio mod_value = 16 # Must be divisible by 16 height = round(np.sqrt(max_area * aspect_ratio)) // mod_value * mod_value width = round(np.sqrt(max_area / aspect_ratio)) // mod_value * mod_value if request.sampling_params.height is None: request.sampling_params.height = height if request.sampling_params.width is None: request.sampling_params.width = width # Resize image to target dimensions image = image.resize( (request.sampling_params.width, request.sampling_params.height), # type: ignore # Above has ensured that width & height are not None PIL.Image.Resampling.LANCZOS, ) prompt["multi_modal_data"]["image"] = image # type: ignore # key existence already checked above request.prompts[i] = prompt return request return pre_process_func class Wan22Pipeline( nn.Module, PipelineParallelMixin, CFGParallelMixin, ProgressBarMixin, DiffusionPipelineProfilerMixin ): def __init__( self, *, od_config: OmniDiffusionConfig, prefix: str = "", ): super().__init__() self.od_config = od_config self.device = get_local_device() dtype = getattr(od_config, "dtype", torch.bfloat16) model = od_config.model local_files_only = os.path.exists(model) # Read model_index.json to detect expand_timesteps mode (for TI2V-5B) self.expand_timesteps = False self.has_transformer_2 = False if local_files_only: model_index_path = os.path.join(model, "model_index.json") if os.path.exists(model_index_path): with open(model_index_path) as f: model_index = json.load(f) self.expand_timesteps = model_index.get("expand_timesteps", False) # Check if this is a two-stage model (MoE with transformer_2) transformer_2_path = os.path.join(model, "transformer_2") self.has_transformer_2 = os.path.exists(transformer_2_path) else: # For remote models, download and read model_index.json try: from huggingface_hub import hf_hub_download model_index_path = hf_hub_download(repo_id=model, filename="model_index.json") with open(model_index_path) as f: model_index = json.load(f) self.expand_timesteps = model_index.get("expand_timesteps", False) # Check transformer_2 from model_index transformer_2_info = model_index.get("transformer_2", [None, None]) self.has_transformer_2 = transformer_2_info[0] is not None except Exception: pass self.boundary_ratio = od_config.boundary_ratio # Determine which transformers to load based on boundary_ratio # boundary_ratio=1.0: only load transformer_2 (low-noise stage only) # boundary_ratio=0.0: only load transformer (high-noise stage only) # otherwise: load both transformers load_transformer = self.boundary_ratio != 1.0 if self.boundary_ratio is not None else True load_transformer_2 = self.has_transformer_2 and ( self.boundary_ratio != 0.0 if self.boundary_ratio is not None else True ) # Set up weights sources for transformer(s) self.weights_sources = [] if load_transformer: self.weights_sources.append( DiffusersPipelineLoader.ComponentSource( model_or_path=od_config.model, subfolder="transformer", revision=None, prefix="transformer.", fall_back_to_pt=True, ) ) if load_transformer_2: self.weights_sources.append( DiffusersPipelineLoader.ComponentSource( model_or_path=od_config.model, subfolder="transformer_2", revision=None, prefix="transformer_2.", fall_back_to_pt=True, ) ) # See ``hub_prefetch.py`` for the transformers v5 subfolder race. prefetch_subfolders( model, ["tokenizer", "text_encoder", "vae"], local_files_only=local_files_only, ) self.tokenizer = AutoTokenizer.from_pretrained(model, subfolder="tokenizer", local_files_only=local_files_only) self.text_encoder = UMT5EncoderModel.from_pretrained( model, subfolder="text_encoder", torch_dtype=dtype, local_files_only=local_files_only ).to(self.device) self.vae = DistributedAutoencoderKLWan.from_pretrained( model, subfolder="vae", torch_dtype=dtype, local_files_only=local_files_only ).to(self.device) # Initialize transformers with correct config (weights loaded via load_weights) if load_transformer: transformer_config = load_transformer_config(model, "transformer", local_files_only) self.transformer = self._create_transformer(transformer_config) else: self.transformer = None if load_transformer_2: transformer_2_config = load_transformer_config(model, "transformer_2", local_files_only) self.transformer_2 = self._create_transformer(transformer_2_config) else: self.transformer_2 = None # Store the active transformer config if load_transformer: self.transformer_config = self.transformer.config elif load_transformer_2: self.transformer_config = self.transformer_2.config else: raise RuntimeError("No transformer loaded") self._sample_solver = "unipc" self._flow_shift = od_config.flow_shift if od_config.flow_shift is not None else 5.0 self.scheduler = build_wan_scheduler(self._sample_solver, self._flow_shift) 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 self._guidance_scale = None self._guidance_scale_2 = None self._num_timesteps = None self._current_timestep = None self.setup_diffusion_pipeline_profiler( enable_diffusion_pipeline_profiler=self.od_config.enable_diffusion_pipeline_profiler ) def _create_transformer(self, config: dict) -> WanTransformer3DModel: """Create a transformer from a config dict. Respects od_config.quantization_config.""" quant_config = getattr(self.od_config, "quantization_config", None) return create_transformer_from_config(config, quant_config=quant_config) @property def guidance_scale(self): return self._guidance_scale @property def do_classifier_free_guidance(self): return self._guidance_scale is not None and self._guidance_scale > 1.0 @property def num_timesteps(self): return self._num_timesteps @property def current_timestep(self): return self._current_timestep def diffuse( self, latents: torch.Tensor, timesteps: torch.Tensor, prompt_embeds: torch.Tensor, negative_prompt_embeds: torch.Tensor | None, guidance_low: float, guidance_high: float, boundary_timestep: float | None, dtype: torch.dtype, attention_kwargs: dict[str, Any], latent_condition: torch.Tensor | None = None, first_frame_mask: torch.Tensor | None = None, ) -> torch.Tensor | AsyncLatents: if attention_kwargs is None: attention_kwargs = {} with self.progress_bar(total=len(timesteps)) as pbar: for step_idx, t in enumerate(timesteps): self._current_timestep = t set_forward_context_denoise_step_idx(step_idx) # Select model based on timestep and boundary_ratio # High noise stage (t >= boundary_timestep): use transformer # Low noise stage (t < boundary_timestep): use transformer_2 if boundary_timestep is not None and t < boundary_timestep: # Low noise stage - always use guidance_high for this stage current_guidance_scale = guidance_high if self.transformer_2 is not None: current_model = self.transformer_2 elif self.transformer is not None: # Fallback to transformer if transformer_2 not loaded current_model = self.transformer else: raise RuntimeError("No transformer available for low-noise stage") else: # High noise stage - always use guidance_low for this stage current_guidance_scale = guidance_low if self.transformer is not None: current_model = self.transformer elif self.transformer_2 is not None: # Fallback to transformer_2 if transformer not loaded current_model = self.transformer_2 else: raise RuntimeError("No transformer available for high-noise stage") if self.expand_timesteps and latent_condition is not None: # I2V mode: blend condition with latents using mask latent_model_input = (1 - first_frame_mask) * latent_condition + first_frame_mask * latents latent_model_input = latent_model_input.to(dtype) # Expand timesteps per patch - use floor division to match patch embedding patch_size = self.transformer_config.patch_size patch_height = latents.shape[3] // patch_size[1] patch_width = latents.shape[4] // patch_size[2] # Create mask at patch resolution (same as hidden states sequence length) patch_mask = first_frame_mask[:, :, :, :: patch_size[1], :: patch_size[2]] patch_mask = patch_mask[:, :, :, :patch_height, :patch_width] # Ensure correct dimensions temp_ts = (patch_mask[0][0] * t).flatten() timestep = temp_ts.unsqueeze(0).expand(latents.shape[0], -1) else: # T2V mode: standard forward latent_model_input = latents.to(dtype) timestep = t.expand(latents.shape[0]) do_true_cfg = current_guidance_scale > 1.0 and negative_prompt_embeds is not None positive_kwargs = { "hidden_states": latent_model_input, "timestep": timestep, "encoder_hidden_states": prompt_embeds, "attention_kwargs": attention_kwargs, "return_dict": False, "current_model": current_model, } if do_true_cfg: negative_kwargs = { "hidden_states": latent_model_input, "timestep": timestep, "encoder_hidden_states": negative_prompt_embeds, "attention_kwargs": attention_kwargs, "return_dict": False, "current_model": current_model, } else: negative_kwargs = None noise_pred = self.predict_noise_maybe_with_cfg( do_true_cfg=do_true_cfg, true_cfg_scale=current_guidance_scale, positive_kwargs=positive_kwargs, negative_kwargs=negative_kwargs, cfg_normalize=False, ) latents = self.scheduler_step_maybe_with_cfg(noise_pred, t, latents, do_true_cfg) pbar.update() return latents def forward( self, req: OmniDiffusionRequest, prompt: str | None = None, negative_prompt: str | None = None, height: int = 480, width: int = 832, num_inference_steps: int = 40, guidance_scale: float | tuple[float, float] = 4.0, frame_num: int = 81, output_type: str | None = "np", generator: torch.Generator | list[torch.Generator] | None = None, prompt_embeds: torch.Tensor | None = None, negative_prompt_embeds: torch.Tensor | None = None, attention_kwargs: dict | None = None, **kwargs, ) -> DiffusionOutput: # Get parameters from request or arguments if len(req.prompts) > 1: raise ValueError( """This model only supports a single prompt, not a batched request.""", """Please pass in a single prompt object or string, or a single-item list.""", ) if len(req.prompts) == 1: # If req.prompt is empty, default to prompt & neg_prompt in param list prompt = req.prompts[0] if isinstance(req.prompts[0], str) else req.prompts[0].get("prompt") negative_prompt = None if isinstance(req.prompts[0], str) else req.prompts[0].get("negative_prompt") if prompt is None and prompt_embeds is None: raise ValueError("Prompt or prompt_embeds is required for Wan2.2 generation.") height = req.sampling_params.height or height width = req.sampling_params.width or width num_frames = req.sampling_params.num_frames if req.sampling_params.num_frames else frame_num # Ensure dimensions are compatible with VAE and patch size # For expand_timesteps mode, we need latent dims to be even (divisible by patch_size) patch_size = self.transformer_config.patch_size mod_value = self.vae_scale_factor_spatial * patch_size[1] # 16*2=32 for TI2V, 8*2=16 for I2V height = (height // mod_value) * mod_value width = (width // mod_value) * mod_value num_steps = req.sampling_params.num_inference_steps or num_inference_steps # Respect per-request guidance_scale when explicitly provided. if req.sampling_params.guidance_scale_provided: guidance_scale = req.sampling_params.guidance_scale guidance_low = guidance_scale if isinstance(guidance_scale, (int, float)) else guidance_scale[0] guidance_high = ( req.sampling_params.guidance_scale_2 if req.sampling_params.guidance_scale_2 is not None else ( guidance_scale[1] if isinstance(guidance_scale, (list, tuple)) and len(guidance_scale) > 1 else guidance_low ) ) # record guidance for properties self._guidance_scale = guidance_low self._guidance_scale_2 = guidance_high # Prefer engine-configured boundary_ratio, but allow per-request fallback. boundary_ratio = self.boundary_ratio if self.boundary_ratio is not None else req.sampling_params.boundary_ratio if boundary_ratio is None: boundary_ratio = 0.875 logger.warning("boundary_ratio is required for T2V generation. using default value 0.875") # validate shapes self.check_inputs( prompt=prompt, negative_prompt=negative_prompt, height=height, width=width, prompt_embeds=prompt_embeds, negative_prompt_embeds=negative_prompt_embeds, guidance_scale_2=guidance_high if boundary_ratio is not None else None, boundary_ratio=boundary_ratio, ) if num_frames % self.vae_scale_factor_temporal != 1: num_frames = num_frames // self.vae_scale_factor_temporal * self.vae_scale_factor_temporal + 1 num_frames = max(num_frames, 1) device = self.device # Get dtype from whichever transformer is loaded if self.transformer is not None: dtype = self.transformer.dtype elif self.transformer_2 is not None: dtype = self.transformer_2.dtype else: # Fallback to text_encoder dtype if no transformer loaded dtype = self.text_encoder.dtype # Seed / generator if generator is None: generator = req.sampling_params.generator if generator is None and req.sampling_params.seed is not None: generator = torch.Generator(device=device).manual_seed(req.sampling_params.seed) if DEBUG_PERF: # Sync GPU before timing to ensure accurate measurements current_omni_platform.synchronize() _t_pipeline_start = time.perf_counter() _t_text_enc_start = _t_pipeline_start if prompt_embeds is None: prompt_embeds, negative_prompt_embeds = self.encode_prompt( prompt=prompt, negative_prompt=negative_prompt, do_classifier_free_guidance=guidance_low > 1.0 or guidance_high > 1.0, num_videos_per_prompt=req.sampling_params.num_outputs_per_prompt or 1, max_sequence_length=req.sampling_params.max_sequence_length or 512, device=device, dtype=dtype, ) else: prompt_embeds = prompt_embeds.to(device=device, dtype=dtype) if negative_prompt_embeds is not None: negative_prompt_embeds = negative_prompt_embeds.to(device=device, dtype=dtype) elif guidance_low > 1.0 or guidance_high > 1.0: raise ValueError( "negative_prompt_embeds must be provided when prompt_embeds are given and guidance > 1." ) if DEBUG_PERF: current_omni_platform.synchronize() _t_text_enc_ms = (time.perf_counter() - _t_text_enc_start) * 1000 sample_solver = resolve_wan_sample_solver(req, default=self._sample_solver) flow_shift = resolve_wan_flow_shift(req, self.od_config) if sample_solver != self._sample_solver or abs(flow_shift - self._flow_shift) > 1e-6: self.scheduler = build_wan_scheduler(sample_solver, flow_shift) self._sample_solver = sample_solver self._flow_shift = flow_shift # Timesteps self.scheduler.set_timesteps(num_steps, device=device) timesteps = self.scheduler.timesteps self._num_timesteps = len(timesteps) boundary_timestep = None if boundary_ratio is not None: boundary_timestep = boundary_ratio * self.scheduler.config.num_train_timesteps if DEBUG_PERF: _t_latent_prep_start = time.perf_counter() multi_modal_data = req.prompts[0].get("multi_modal_data", {}) if not isinstance(req.prompts[0], str) else None raw_image = multi_modal_data.get("image", None) if multi_modal_data is not None else None if isinstance(raw_image, list): if len(raw_image) > 1: logger.warning( """Received a list of image. Only a single image is supported by this model.""" """Taking only the first image for now.""" ) raw_image = raw_image[0] if raw_image is None: image = None elif isinstance(raw_image, str): image = PIL.Image.open(raw_image) else: image = cast(PIL.Image.Image | torch.Tensor, raw_image) latent_condition = None first_frame_mask = None if self.expand_timesteps and image is not None: # I2V mode: encode image and prepare condition from diffusers.video_processor import VideoProcessor video_processor = VideoProcessor(vae_scale_factor=self.vae_scale_factor_spatial) # Preprocess image if isinstance(image, PIL.Image.Image): image = image.resize((width, height), PIL.Image.Resampling.LANCZOS) image_tensor = video_processor.preprocess(image, height=height, width=width) else: image_tensor = image # Use out_channels for noise latents (not in_channels which includes condition) num_channels_latents = self.transformer_config.out_channels batch_size = prompt_embeds.shape[0] # Prepare noise latents latents = self.prepare_latents( batch_size=batch_size, num_channels_latents=num_channels_latents, height=height, width=width, num_frames=num_frames, dtype=torch.float32, device=device, generator=generator, latents=req.sampling_params.latents, ) # Encode image condition num_latent_frames = latents.shape[2] latent_height = latents.shape[3] latent_width = latents.shape[4] image_tensor = image_tensor.unsqueeze(2) # [B, C, 1, H, W] image_tensor = image_tensor.to(device=device, dtype=self.vae.dtype) latent_condition = retrieve_latents(self.vae.encode(image_tensor), sample_mode="argmax") latent_condition = latent_condition.repeat(batch_size, 1, 1, 1, 1) # Normalize condition latents latents_mean = ( torch.tensor(self.vae.config.latents_mean) .view(1, self.vae.config.z_dim, 1, 1, 1) .to(latent_condition.device, latent_condition.dtype) ) latents_std = 1.0 / torch.tensor(self.vae.config.latents_std).view(1, self.vae.config.z_dim, 1, 1, 1).to( latent_condition.device, latent_condition.dtype ) latent_condition = (latent_condition - latents_mean) * latents_std latent_condition = latent_condition.to(torch.float32) # Create mask: 0 for first frame (condition), 1 for rest (to denoise) first_frame_mask = torch.ones( 1, 1, num_latent_frames, latent_height, latent_width, dtype=torch.float32, device=device ) first_frame_mask[:, :, 0] = 0 else: # T2V mode: standard latent preparation num_channels_latents = self.transformer_config.in_channels latents = self.prepare_latents( batch_size=prompt_embeds.shape[0], num_channels_latents=num_channels_latents, height=height, width=width, num_frames=num_frames, dtype=torch.float32, device=device, generator=generator, latents=req.sampling_params.latents, ) if DEBUG_PERF: current_omni_platform.synchronize() _t_latent_prep_ms = (time.perf_counter() - _t_latent_prep_start) * 1000 if attention_kwargs is None: attention_kwargs = {} if DEBUG_PERF: _t_denoise_start = time.perf_counter() latents = self.diffuse( latents=latents, timesteps=timesteps, prompt_embeds=prompt_embeds, negative_prompt_embeds=negative_prompt_embeds, guidance_low=guidance_low, guidance_high=guidance_high, boundary_timestep=boundary_timestep, dtype=dtype, attention_kwargs=attention_kwargs, latent_condition=latent_condition, first_frame_mask=first_frame_mask, ) # Wan2.2 is prone to out of memory errors when predicting large videos # so we empty the cache here to avoid OOM before vae decoding. if current_omni_platform.is_available(): current_omni_platform.empty_cache() self._current_timestep = None if DEBUG_PERF: current_omni_platform.synchronize() _t_denoise_ms = (time.perf_counter() - _t_denoise_start) * 1000 # For I2V mode: blend final latents with condition if self.expand_timesteps and latent_condition is not None: latents = (1 - first_frame_mask) * latent_condition + first_frame_mask * latents if DEBUG_PERF: _t_decode_start = time.perf_counter() if output_type == "latent": output = latents else: latents = latents.to(self.vae.dtype) latents_mean = ( torch.tensor(self.vae.config.latents_mean) .view(1, self.vae.config.z_dim, 1, 1, 1) .to(latents.device, latents.dtype) ) latents_std = 1.0 / torch.tensor(self.vae.config.latents_std).view(1, self.vae.config.z_dim, 1, 1, 1).to( latents.device, latents.dtype ) latents = latents / latents_std + latents_mean output = self.vae.decode(latents, return_dict=False)[0] if DEBUG_PERF: current_omni_platform.synchronize() _t_decode_ms = (time.perf_counter() - _t_decode_start) * 1000 _t_pipeline_wall_ms = (time.perf_counter() - _t_pipeline_start) * 1000 _t_stages_sum = _t_text_enc_ms + _t_latent_prep_ms + _t_denoise_ms + _t_decode_ms if _is_rank_zero(): logger.info( "Pipeline stage timing summary: " "TextEncoding=%.2f ms, LatentPreparation=%.2f ms, " "Denoising=%.2f ms (%d steps), Decoding=%.2f ms, " "StagesSum=%.2f ms, PipelineWall=%.2f ms, Unaccounted=%.2f ms", _t_text_enc_ms, _t_latent_prep_ms, _t_denoise_ms, len(timesteps), _t_decode_ms, _t_stages_sum, _t_pipeline_wall_ms, _t_pipeline_wall_ms - _t_stages_sum, ) return DiffusionOutput( output=output, stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None ) def predict_noise( self, current_model: nn.Module | None = None, **kwargs: Any, ) -> torch.Tensor | IntermediateTensors: """ Forward pass through transformer to predict noise. Args: current_model: The transformer model to use (transformer or transformer_2) **kwargs: Arguments to pass to the transformer Returns: Predicted noise tensor or IntermediateTensors on non-last PP stages. """ if current_model is None: current_model = self.transformer result = current_model(**kwargs) return result if isinstance(result, IntermediateTensors) else result[0] def encode_prompt( self, prompt: str | list[str], negative_prompt: str | list[str] | None = None, do_classifier_free_guidance: bool = True, num_videos_per_prompt: int = 1, max_sequence_length: int = 512, device: torch.device | None = None, dtype: torch.dtype | None = None, ): device = device or self.device dtype = dtype or self.text_encoder.dtype prompt = [prompt] if isinstance(prompt, str) else prompt prompt_clean = [self._prompt_clean(p) for p in prompt] batch_size = len(prompt_clean) text_inputs = self.tokenizer( prompt_clean, padding="max_length", max_length=max_sequence_length, truncation=True, add_special_tokens=True, return_attention_mask=True, return_tensors="pt", ) ids, mask = text_inputs.input_ids, text_inputs.attention_mask seq_lens = mask.gt(0).sum(dim=1).long() prompt_embeds = self.text_encoder(ids.to(device), mask.to(device)).last_hidden_state prompt_embeds = prompt_embeds.to(dtype=dtype, device=device) prompt_embeds = [u[:v] for u, v in zip(prompt_embeds, seq_lens)] prompt_embeds = torch.stack( [torch.cat([u, u.new_zeros(max_sequence_length - u.size(0), u.size(1))]) for u in prompt_embeds], dim=0 ) _, seq_len, _ = prompt_embeds.shape prompt_embeds = prompt_embeds.repeat(1, num_videos_per_prompt, 1) prompt_embeds = prompt_embeds.view(batch_size * num_videos_per_prompt, seq_len, -1) negative_prompt_embeds = None if do_classifier_free_guidance: negative_prompt = negative_prompt or "" negative_prompt = batch_size * [negative_prompt] if isinstance(negative_prompt, str) else negative_prompt neg_text_inputs = self.tokenizer( [self._prompt_clean(p) for p in negative_prompt], padding="max_length", max_length=max_sequence_length, truncation=True, add_special_tokens=True, return_attention_mask=True, return_tensors="pt", ) ids_neg, mask_neg = neg_text_inputs.input_ids, neg_text_inputs.attention_mask seq_lens_neg = mask_neg.gt(0).sum(dim=1).long() negative_prompt_embeds = self.text_encoder(ids_neg.to(device), mask_neg.to(device)).last_hidden_state negative_prompt_embeds = negative_prompt_embeds.to(dtype=dtype, device=device) negative_prompt_embeds = [u[:v] for u, v in zip(negative_prompt_embeds, seq_lens_neg)] negative_prompt_embeds = torch.stack( [ torch.cat([u, u.new_zeros(max_sequence_length - u.size(0), u.size(1))]) for u in negative_prompt_embeds ], dim=0, ) negative_prompt_embeds = negative_prompt_embeds.repeat(1, num_videos_per_prompt, 1) negative_prompt_embeds = negative_prompt_embeds.view(batch_size * num_videos_per_prompt, seq_len, -1) return prompt_embeds, negative_prompt_embeds @staticmethod def _prompt_clean(text: str) -> str: return " ".join(text.strip().split()) def prepare_latents( self, batch_size: int, num_channels_latents: int, height: int, width: int, num_frames: int, dtype: torch.dtype | None, device: torch.device | None, generator: torch.Generator | list[torch.Generator] | None, latents: torch.Tensor | None = None, ) -> torch.Tensor: if latents is not None: return latents.to(device=device, dtype=dtype) num_latent_frames = (num_frames - 1) // self.vae_scale_factor_temporal + 1 shape = ( batch_size, num_channels_latents, num_latent_frames, int(height) // self.vae_scale_factor_spatial, int(width) // self.vae_scale_factor_spatial, ) if isinstance(generator, list) and len(generator) != batch_size: raise ValueError(f"Generator list length {len(generator)} does not match batch size {batch_size}.") latents = randn_tensor(shape, generator=generator, device=device, dtype=dtype) return latents def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: """Load weights using AutoWeightsLoader for vLLM integration.""" loader = AutoWeightsLoader(self) return loader.load_weights(weights) def check_inputs( self, prompt, negative_prompt, height, width, prompt_embeds=None, negative_prompt_embeds=None, guidance_scale_2=None, boundary_ratio=None, ): if height % 16 != 0 or width % 16 != 0: raise ValueError(f"`height` and `width` have to be divisible by 16 but are {height} and {width}.") if prompt is not None and prompt_embeds is not None: raise ValueError( f"Cannot forward both `prompt`: {prompt} and `prompt_embeds`: {prompt_embeds}. Please make sure to" " only forward one of the two." ) elif negative_prompt is not None and negative_prompt_embeds is not None: raise ValueError( f"Cannot forward both `negative_prompt`: {negative_prompt} and " f"`negative_prompt_embeds`: {negative_prompt_embeds}. " "Please make sure to only forward one of the two." ) elif prompt is None and prompt_embeds is None: raise ValueError( "Provide either `prompt` or `prompt_embeds`. Cannot leave both `prompt` and `prompt_embeds` undefined." ) elif prompt is not None and (not isinstance(prompt, str) and not isinstance(prompt, list)): raise ValueError(f"`prompt` has to be of type `str` or `list` but is {type(prompt)}") elif negative_prompt is not None and ( not isinstance(negative_prompt, str) and not isinstance(negative_prompt, list) ): raise ValueError(f"`negative_prompt` has to be of type `str` or `list` but is {type(negative_prompt)}") if boundary_ratio is None and guidance_scale_2 is not None: raise ValueError("`guidance_scale_2` is only supported when `boundary_ratio` is set.") # --------------------------------------------------------------------------- # DMD2-distilled variant # --------------------------------------------------------------------------- class WanT2VDMD2Pipeline(DMD2PipelineMixin, Wan22Pipeline): """Wan 2.x T2V pipeline for FastGen DMD2-distilled models.""" def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""): super().__init__(od_config=od_config, prefix=prefix) self.__init_dmd2__()