#
# SPDX-FileCopyrightText: Copyright (c) 1993-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

import argparse
import gc
import html
import os
import pathlib
import random
import re
import time
from typing import Any, Callable, Dict, List, Optional, Union

import tensorrt as trt
import torch
from cuda.bindings import runtime as cudart
from diffusers.utils.torch_utils import randn_tensor
from PIL import Image
from tqdm.auto import tqdm

try:
    import ftfy
    FTFY_AVAILABLE = True
except ImportError:
    FTFY_AVAILABLE = False

import demo_diffusion.engine as engine_module
import demo_diffusion.image as image_module
import demo_diffusion.path as path_module
from demo_diffusion.model import (
    T5Model,
    WanTransformerModel,
    AutoencoderKLWanModel,
    make_tokenizer,
)
from demo_diffusion.pipeline.diffusion_pipeline import DiffusionPipeline
from demo_diffusion.pipeline.type import PIPELINE_TYPE

TRT_LOGGER = trt.Logger(trt.Logger.ERROR)

# Copied from https://github.com/huggingface/diffusers/blob/main/src/diffusers/pipelines/wan/pipeline_wan.py#L78
def basic_clean(text):
    if FTFY_AVAILABLE:
        text = ftfy.fix_text(text)
    text = html.unescape(html.unescape(text))
    return text.strip()

# Copied from https://github.com/huggingface/diffusers/blob/main/src/diffusers/pipelines/wan/pipeline_wan.py#L84
def whitespace_clean(text):
    text = re.sub(r"\s+", " ", text)
    text = text.strip()
    return text

# Copied from https://github.com/huggingface/diffusers/blob/main/src/diffusers/pipelines/wan/pipeline_wan.py#L90
def prompt_clean(text):
    text = whitespace_clean(basic_clean(text))
    return text


class WanPipeline(DiffusionPipeline):
    """
    Application showcasing the acceleration of Wan 2.2 T2V pipeline using Nvidia TensorRT.
    """
    
    _callback_tensor_inputs = ["latents", "prompt_embeds", "negative_prompt_embeds"]
    
    def __init__(
        self,
        dd_path,
        version='wan2.2-t2v-a14b',
        pipeline_type=PIPELINE_TYPE.TXT2VID,
        boundary_ratio: float = 0.875,
        guidance_scale: float = 4.0,
        guidance_scale_2: float = 3.0,
        t5_weight_streaming_budget_percentage=None,
        transformer_weight_streaming_budget_percentage=None,
        **kwargs
    ):
        """
        Initializes the Wan T2V pipeline.

        Args:
            dd_path (load_module.DDPath): 
                DDPath object that contains all paths used in DemoDiffusion
            version (str):
                The version of the pipeline. Should be [wan2.2-t2v-a14b]
            pipeline_type (PIPELINE_TYPE):
                Type of current pipeline (TXT2VID)
            boundary_ratio (float, defaults to 0.875):
                Ratio of total timesteps to use as the boundary for switching between transformers
                in two-stage denoising. Transformer handles high-noise stages (timesteps >= boundary)
                and transformer_2 handles low-noise stages (timesteps < boundary). 
                Wan 2.2 T2V always uses two-stage denoising.
            guidance_scale (float):
                Guidance scale for high-noise stage (transformer). Wan default: 4.0
            guidance_scale_2 (float):
                Guidance scale for low-noise stage (transformer_2). Wan default: 3.0
            t5_weight_streaming_budget_percentage (`int`, defaults to None):
                Weight streaming budget as a percentage of the size of total streamable weights for the T5 model.
            transformer_weight_streaming_budget_percentage (`int`, defaults to None):
                Weight streaming budget as a percentage of the size of total streamable weights for the transformer models.
        """
        super().__init__(
            dd_path=dd_path,
            version=version,
            pipeline_type=pipeline_type,
            scheduler="UniPC",
            bf16=True,
            text_encoder_weight_streaming_budget_percentage=t5_weight_streaming_budget_percentage,
            denoiser_weight_streaming_budget_percentage=transformer_weight_streaming_budget_percentage,
            **kwargs
        )

        # Validate boundary_ratio (required for Wan 2.2 two-stage denoising)
        if boundary_ratio is None or not (0.0 < boundary_ratio < 1.0):
            raise ValueError(
                f"`boundary_ratio` must be between 0.0 and 1.0, got {boundary_ratio}"
            )

        self.boundary_ratio = boundary_ratio
        self.guidance_scale = guidance_scale
        self.guidance_scale_2 = guidance_scale_2

        self.vae_scale_factor_temporal = 4
        self.vae_scale_factor_spatial = 8
        
        self.opt_image_height = 720
        self.opt_image_width = 1280
        self.opt_num_frames = 81
        self.max_sequence_length = 512

    @classmethod
    def FromArgs(cls, args: argparse.Namespace, pipeline_type: PIPELINE_TYPE) -> 'WanPipeline':
        """Factory method to construct a WanPipeline object from parsed arguments."""

        MAX_BATCH_SIZE = 1  # Wan always uses batch size 1
        DEVICE = "cuda"

        dd_path = path_module.resolve_path(
            cls.get_model_names(pipeline_type), args, pipeline_type, cls._get_pipeline_uid(args.version)
        )
        
        return cls(
            dd_path=dd_path,
            version=args.version,
            pipeline_type=pipeline_type,
            boundary_ratio=args.boundary_ratio,
            denoising_steps=args.denoising_steps,
            guidance_scale=args.guidance_scale,
            guidance_scale_2=args.guidance_scale_2,
            t5_weight_streaming_budget_percentage=args.t5_ws_percentage if hasattr(args, 't5_ws_percentage') else None,
            transformer_weight_streaming_budget_percentage=args.transformer_ws_percentage if hasattr(args, 'transformer_ws_percentage') else None,
            max_batch_size=MAX_BATCH_SIZE,
            device=DEVICE,
            output_dir=args.output_dir,
            hf_token=args.hf_token,
            verbose=args.verbose,
            nvtx_profile=args.nvtx_profile,
            use_cuda_graph=args.use_cuda_graph,
            framework_model_dir=args.framework_model_dir,
            low_vram=args.low_vram,
            torch_inference=args.torch_inference,
            torch_fallback=args.torch_fallback if hasattr(args, 'torch_fallback') else None,
            weight_streaming=args.ws if hasattr(args, 'ws') else False,
        )
    
    @classmethod
    def get_model_names(cls, pipeline_type: PIPELINE_TYPE, controlnet_type: str = None) -> List[str]:
        """Return a list of model names used by this pipeline.

        Overrides:
            DiffusionPipeline.get_model_names
        """
        return ["text_encoder", "transformer", "transformer_2", "vae_decoder"]
    
    def download_onnx_models(self, model_name: str, model_config: dict[str, Any]) -> None:
        raise NotImplementedError(
            "Pre-exported Wan ONNX models are not available for download. "
            "Export ONNX models locally using the provided export script."
        )
    
    def _initialize_models(self, framework_model_dir, int8=False, fp8=False, fp4=False):
        self.tokenizer = make_tokenizer(
            self.version,
            self.pipeline_type,
            self.hf_token,
            framework_model_dir,
            subfolder='tokenizer',
            tokenizer_type='t5'
        )

        models_args = {
            'version': self.version,
            'pipeline': self.pipeline_type,
            'device': self.device,
            'hf_token': self.hf_token,
            'verbose': self.verbose,
            'framework_model_dir': framework_model_dir,
            'max_batch_size': 1
        }

        if "text_encoder" in self.stages:
            self.models['text_encoder'] = T5Model(
                **models_args,
                fp16=False,
                bf16=True,
                text_maxlen=self.max_sequence_length,
                weight_streaming=self.weight_streaming,
                weight_streaming_budget_percentage=self.text_encoder_weight_streaming_budget_percentage,
                use_attention_mask=True,
            )

        if "transformer" in self.stages:
            self.models['transformer'] = WanTransformerModel(
                **models_args,
                subfolder='transformer',
                fp16=False,
                bf16=True,
                text_maxlen=self.max_sequence_length,
                num_frames=self.opt_num_frames,
                height=self.opt_image_height,
                width=self.opt_image_width,
                weight_streaming=self.weight_streaming,
                weight_streaming_budget_percentage=self.denoiser_weight_streaming_budget_percentage,
            )

        if "transformer_2" in self.stages:
            self.models['transformer_2'] = WanTransformerModel(
                **models_args,
                subfolder='transformer_2',
                fp16=False,
                bf16=True,
                text_maxlen=self.max_sequence_length,
                num_frames=self.opt_num_frames,
                height=self.opt_image_height,
                width=self.opt_image_width,
                weight_streaming=self.weight_streaming,
                weight_streaming_budget_percentage=self.denoiser_weight_streaming_budget_percentage,
            )

        if "vae_decoder" in self.stages:
            self.models['vae_decoder'] = AutoencoderKLWanModel(
                **models_args,
            )

            self.config['vae_decoder_torch_fallback'] = True

    def load_resources(self, image_height, image_width, batch_size, seed):
        """Override to create additional 'denoise' event for combined transformer timing."""
        super().load_resources(image_height, image_width, batch_size, seed)
        # additional event for combined denoising timing (both transformers)
        self.events['denoise'] = [cudart.cudaEventCreate()[1], cudart.cudaEventCreate()[1]]

    def print_summary(self, denoising_steps, walltime_ms, batch_size, num_frames):
        print("|----------------------|--------------|")
        print("| {:^20} | {:^12} |".format("Module", "Latency"))
        print("|----------------------|--------------|")
        
        # calculate transformer timings from combined denoise event
        total_denoise_time = cudart.cudaEventElapsedTime(self.events['denoise'][0], self.events['denoise'][1])[1]
        transformer_steps_map = {
            'transformer': self.transformer_steps if (self.transformer_steps > 0 and self.transformer_2_steps > 0) else denoising_steps,
            'transformer_2': self.transformer_2_steps if self.transformer_2_steps > 0 else 0
        }
        
        for stage in self.stages:
            if stage in transformer_steps_map and transformer_steps_map[stage] > 0:
                steps = transformer_steps_map[stage]
                time_ms = total_denoise_time * (steps / denoising_steps)
                stage_label = f"{stage} x {steps}"
            elif stage in transformer_steps_map:
                continue # skip transformer_2 if unused
            else:
                time_ms = cudart.cudaEventElapsedTime(self.events[stage][0], self.events[stage][1])[1]
                stage_label = stage
            
            print("| {:^20} | {:>9.2f} ms |".format(stage_label, time_ms))
        
        print("|----------------------|--------------|")
        print("| {:^20} | {:>9.2f} ms |".format("Pipeline", walltime_ms))
        print("|----------------------|--------------|")
        print("Throughput: {:.2f} videos/min ({} frames)".format(batch_size * 60000.0 / walltime_ms, num_frames))

    def save_video(self, frames, pipeline, prompt, seed):
        if isinstance(prompt, list):
            prompt_prefix = ''.join(set([p.replace(' ','_')[:10] for p in prompt]))
        else:
            prompt_prefix = prompt.replace(' ','_')[:10]
        
        seed_str = str(seed) if seed is not None else 'random'
        precision = 'bf16' if self.bf16 else 'fp16' if self.fp16 else 'fp32'
        video_name_prefix = '-'.join([pipeline, prompt_prefix, precision, seed_str, str(random.randint(1000,9999))])
        video_name_suffix = 'torch' if self.torch_inference else 'trt'
        video_path = video_name_prefix+'-'+video_name_suffix+'.gif'
        full_path = os.path.join(self.output_dir, video_path)
        print(f"Saving video to: {full_path}")
        frames[0].save(full_path, save_all=True, optimize=False, append_images=frames[1:], loop=0)

    # Adapted from https://github.com/huggingface/diffusers/blob/main/src/diffusers/pipelines/wan/pipeline_wan.py#L198
    def encode_prompt(
        self,
        prompt: Union[str, List[str]],
        negative_prompt: Optional[Union[str, List[str]]] = None,
        do_classifier_free_guidance: bool = True,
        num_videos_per_prompt: int = 1,
        prompt_embeds: Optional[torch.Tensor] = None,
        negative_prompt_embeds: Optional[torch.Tensor] = None,
        max_sequence_length: int = 226,
        device: Optional[torch.device] = None,
        dtype: Optional[torch.dtype] = None,
    ):
        r"""
        Encodes the prompt into text encoder hidden states.
        
        Implementation modeled from diffusers Wan pipeline, adapted for TensorRT.

        Args:
            prompt (`str` or `List[str]`, *optional*):
                prompt to be encoded
            negative_prompt (`str` or `List[str]`, *optional*):
                The prompt or prompts not to guide the image generation. If not defined, one has to pass
                `negative_prompt_embeds` instead. Ignored when not using guidance (i.e., ignored if `guidance_scale` is
                less than `1`).
            do_classifier_free_guidance (`bool`, *optional*, defaults to `True`):
                Whether to use classifier free guidance or not.
            num_videos_per_prompt (`int`, *optional*, defaults to 1):
                Number of videos that should be generated per prompt. torch device to place the resulting embeddings on
            prompt_embeds (`torch.Tensor`, *optional*):
                Pre-generated text embeddings. Can be used to easily tweak text inputs, *e.g.* prompt weighting. If not
                provided, text embeddings will be generated from `prompt` input argument.
            negative_prompt_embeds (`torch.Tensor`, *optional*):
                Pre-generated negative text embeddings. Can be used to easily tweak text inputs, *e.g.* prompt
                weighting. If not provided, negative_prompt_embeds will be generated from `negative_prompt` input
                argument.
            max_sequence_length (`int`, defaults to `226`):
                Maximum sequence length for text encoder.
            device: (`torch.device`, *optional*):
                torch device
            dtype: (`torch.dtype`, *optional*):
                torch dtype
        """
        self.profile_start('text_encoder', color='green')
        
        device = device or self._execution_device

        prompt = [prompt] if isinstance(prompt, str) else prompt
        if prompt is not None:
            batch_size = len(prompt)
        else:
            batch_size = prompt_embeds.shape[0]

        if prompt_embeds is None:
            prompt_embeds = self._get_t5_prompt_embeds(
                prompt=prompt,
                num_videos_per_prompt=num_videos_per_prompt,
                max_sequence_length=max_sequence_length,
                device=device,
                dtype=dtype,
            )

        if do_classifier_free_guidance and negative_prompt_embeds is None:
            negative_prompt = negative_prompt or ""
            negative_prompt = batch_size * [negative_prompt] if isinstance(negative_prompt, str) else negative_prompt

            if prompt is not None and type(prompt) is not type(negative_prompt):
                raise TypeError(
                    f"`negative_prompt` should be the same type to `prompt`, but got {type(negative_prompt)} !="
                    f" {type(prompt)}."
                )
            elif batch_size != len(negative_prompt):
                raise ValueError(
                    f"`negative_prompt`: {negative_prompt} has batch size {len(negative_prompt)}, but `prompt`:"
                    f" {prompt} has batch size {batch_size}. Please make sure that passed `negative_prompt` matches"
                    " the batch size of `prompt`."
                )

            negative_prompt_embeds = self._get_t5_prompt_embeds(
                prompt=negative_prompt,
                num_videos_per_prompt=num_videos_per_prompt,
                max_sequence_length=max_sequence_length,
                device=device,
                dtype=dtype,
            )

        self.profile_stop('text_encoder')
        return prompt_embeds, negative_prompt_embeds

    def denoise_latents(
        self,
        latents: torch.Tensor,
        prompt_embeds: torch.Tensor,
        negative_prompt_embeds: Optional[torch.Tensor],
        timesteps: torch.Tensor,
        guidance_scale: float,
        guidance_scale_2: float,
        transformer_dtype: torch.dtype,
        num_warmup_steps: int,
        attention_kwargs: Optional[Dict[str, Any]] = None,
        callback_on_step_end: Optional[Callable] = None,
        callback_on_step_end_tensor_inputs: Optional[List[str]] = None,
        warmup: bool = False,
    ) -> torch.Tensor:
        boundary_timestep = self.boundary_ratio * self.scheduler.config.num_train_timesteps
        self.profile_start('denoise', color='blue')
        
        timestep_stages = []
        for i, t in enumerate(timesteps):
            if t >= boundary_timestep:
                timestep_stages.append((i, t, 'transformer', guidance_scale))
            else:
                timestep_stages.append((i, t, 'transformer_2', guidance_scale_2))
        
        stage_groups = []
        if timestep_stages:
            current_group = {
                'transformer': timestep_stages[0][2],
                'guidance_scale': timestep_stages[0][3],
                'timesteps': [(timestep_stages[0][0], timestep_stages[0][1])]
            }
            
            for i, t, transformer_name, gs in timestep_stages[1:]:
                if transformer_name == current_group['transformer']:
                    current_group['timesteps'].append((i, t))
                else:
                    stage_groups.append(current_group)
                    current_group = {
                        'transformer': transformer_name,
                        'guidance_scale': gs,
                        'timesteps': [(i, t)]
                    }
            stage_groups.append(current_group)
        
        self.transformer_steps = sum(len(g['timesteps']) for g in stage_groups if g['transformer'] == 'transformer')
        self.transformer_2_steps = sum(len(g['timesteps']) for g in stage_groups if g['transformer'] == 'transformer_2')
        
        with tqdm(total=len(timesteps)) as progress_bar:
            for stage_group in stage_groups:
                transformer_name = stage_group['transformer']
                current_guidance_scale = stage_group['guidance_scale']
                
                with self.model_memory_manager([transformer_name], low_vram=self.low_vram):
                    for step_index, t in stage_group['timesteps']:
                        latent_model_input = latents.to(transformer_dtype)
                        timestep = t.expand(latent_model_input.shape[0])

                        if self.torch_inference or self.torch_fallback[transformer_name]:
                            current_model = self.torch_models[transformer_name]
                            
                            noise_pred_cond = current_model(
                                hidden_states=latent_model_input,
                                timestep=timestep,
                                encoder_hidden_states=prompt_embeds,
                                attention_kwargs=attention_kwargs,
                                return_dict=False,
                            )[0]

                            if self.do_classifier_free_guidance:
                                noise_pred_uncond = current_model(
                                    hidden_states=latent_model_input,
                                    timestep=timestep,
                                    encoder_hidden_states=negative_prompt_embeds,
                                    attention_kwargs=attention_kwargs,
                                    return_dict=False,
                                )[0]

                                noise_pred = noise_pred_uncond + current_guidance_scale * (noise_pred_cond - noise_pred_uncond)
                            else:
                                noise_pred = noise_pred_cond
                        else:
                            if self.do_classifier_free_guidance:
                                params_cond = {
                                    "hidden_states": latent_model_input,
                                    "timestep": timestep,
                                    "encoder_hidden_states": prompt_embeds,
                                }
                                
                                # conditional engine call
                                output_cond = self.run_engine(transformer_name, params_cond)['denoised_latents']
                                
                                noise_pred_cond = output_cond.clone()
                                
                                params_uncond = {
                                    "hidden_states": latent_model_input,
                                    "timestep": timestep,
                                    "encoder_hidden_states": negative_prompt_embeds,
                                }
                                
                                # unconditional engine call
                                output_uncond = self.run_engine(transformer_name, params_uncond)['denoised_latents']
                                
                                noise_pred_uncond = output_uncond.clone()
                                
                                # Apply classifier-free guidance
                                noise_pred = noise_pred_uncond + current_guidance_scale * (noise_pred_cond - noise_pred_uncond)
                            else:
                                # No CFG
                                params = {
                                    "hidden_states": latent_model_input,
                                    "timestep": timestep,
                                    "encoder_hidden_states": prompt_embeds,
                                }
                                noise_pred = self.run_engine(transformer_name, params)['denoised_latents']

                        latents = self.scheduler.step(noise_pred, t, latents, return_dict=False)[0]

                        if callback_on_step_end is not None:
                            callback_kwargs = {}
                            for k in callback_on_step_end_tensor_inputs:
                                callback_kwargs[k] = locals()[k]
                            callback_outputs = callback_on_step_end(self, step_index, t, callback_kwargs)

                            latents = callback_outputs.pop("latents", latents)
                            prompt_embeds = callback_outputs.pop("prompt_embeds", prompt_embeds)
                            negative_prompt_embeds = callback_outputs.pop("negative_prompt_embeds", negative_prompt_embeds)

                        if step_index == len(timesteps) - 1 or ((step_index + 1) > num_warmup_steps and (step_index + 1) % self.scheduler.order == 0):
                            progress_bar.update()
            
        self.profile_stop('denoise')
        return latents

    # Adapted from https://github.com/huggingface/diffusers/blob/main/src/diffusers/pipelines/wan/pipeline_wan.py#L324
    def prepare_latents(
        self,
        batch_size: int,
        num_channels_latents: int = 16,
        height: int = 720,
        width: int = 1280,
        num_frames: int = 81,
        dtype: Optional[torch.dtype] = None,
        device: Optional[torch.device] = None,
        generator: Optional[Union[torch.Generator, List[torch.Generator]]] = None,
        latents: Optional[torch.Tensor] = 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"You have passed a list of generators of length {len(generator)}, but requested an effective batch"
                f" size of {batch_size}. Make sure the batch size matches the length of the generators."
            )

        if generator is None and hasattr(self, 'generator'):
            generator = self.generator

        latents = randn_tensor(shape, generator=generator, device=device, dtype=dtype)
        return latents

    # Copied from https://github.com/huggingface/diffusers/blob/main/src/diffusers/pipelines/wan/pipeline_wan.py#L279
    def check_inputs(
        self,
        prompt,
        negative_prompt,
        height,
        width,
        prompt_embeds=None,
        negative_prompt_embeds=None,
        callback_on_step_end_tensor_inputs=None,
        guidance_scale_2=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 callback_on_step_end_tensor_inputs is not None:
            pass

        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 `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)}")

    # Adapted from https://github.com/huggingface/diffusers/blob/main/src/diffusers/pipelines/wan/pipeline_wan.py#L157
    def _get_t5_prompt_embeds(
        self,
        prompt: Union[str, List[str]] = None,
        num_videos_per_prompt: int = 1,
        max_sequence_length: int = 226,
        device: Optional[torch.device] = None,
        dtype: Optional[torch.dtype] = None,
        encoder: str = "text_encoder",
    ):
        device = device or self._execution_device
        dtype = dtype or (self.torch_models[encoder].dtype if self.torch_fallback.get(encoder) and encoder in self.torch_models else torch.float32)

        prompt = [prompt] if isinstance(prompt, str) else prompt
        prompt = [prompt_clean(u) for u in prompt]
        batch_size = len(prompt)

        text_inputs = self.tokenizer(
            prompt,
            padding="max_length",
            max_length=max_sequence_length,
            truncation=True,
            add_special_tokens=True,
            return_attention_mask=True,
            return_tensors="pt",
        )
        text_input_ids, mask = text_inputs.input_ids, text_inputs.attention_mask
        seq_lens = mask.gt(0).sum(dim=1).long()

        if self.torch_inference or self.torch_fallback[encoder]:
            outputs = self.torch_models[encoder](text_input_ids.to(device), mask.to(device))
            prompt_embeds = outputs.last_hidden_state.clone()
        else:
            outputs = self.run_engine(encoder, {
                "input_ids": text_input_ids.to(device),
                "attention_mask": mask.to(device)
            })
            prompt_embeds = outputs['text_embeddings'].clone()
        
        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)

        return prompt_embeds

    @property
    def _execution_device(self):
        return self.device

    @property
    def do_classifier_free_guidance(self):
        return self.guidance_scale > 1.0

    @property
    def num_timesteps(self):
        return self._num_timesteps

    def decode_latents(self, latents, num_frames):
        self.profile_start('vae_decoder', color='red')
        
        vae_config = self.models['vae_decoder'].config
        z_dim = vae_config.get("z_dim", 16)

        vae_dtype = torch.float32
        latents = latents.to(vae_dtype)
        
        latents_mean = (
            torch.tensor(vae_config.get("latents_mean"))
            .view(1, z_dim, 1, 1, 1)
            .to(latents.device, latents.dtype)
        )
        latents_std = 1.0 / torch.tensor(vae_config.get("latents_std")).view(1, z_dim, 1, 1, 1).to(
            latents.device, latents.dtype
        )
        
        latents = latents / latents_std + latents_mean

        frames = self.torch_models['vae_decoder'].decode(latents, return_dict=False)[0]
        
        self.profile_stop('vae_decoder')
        return frames

    def postprocess(self, video: torch.Tensor, output_type: str = "pil"):
        # Convert [F, C, H, W] -> [F, H, W, C]
        video = video.permute(0, 2, 3, 1)
        # Convert to list of PIL Images
        video = (video + 1.0) / 2.0
        video = torch.clamp(video, 0.0, 1.0)
        video = (video * 255.0).to(torch.uint8).cpu().numpy()
        pil_frames = [Image.fromarray(frame) for frame in video]
        return pil_frames

    def infer(
        self,
        prompt: Union[str, List[str]],
        negative_prompt: Optional[Union[str, List[str]]] = None,
        height: int = 720,
        width: int = 1280,
        num_frames: int = 81,
        num_inference_steps: int = 40,
        num_videos_per_prompt: int = 1,
        generator: Optional[Union[torch.Generator, List[torch.Generator]]] = None,
        latents: Optional[torch.Tensor] = None,
        prompt_embeds: Optional[torch.Tensor] = None,
        negative_prompt_embeds: Optional[torch.Tensor] = None,
        output_type: str = "pil",
        attention_kwargs: Optional[Dict[str, Any]] = None,
        callback_on_step_end: Optional[Union[Callable, Any]] = None,
        callback_on_step_end_tensor_inputs: List[str] = ["latents"],
        max_sequence_length: int = 512,
        warmup: bool = False,
        save_video: bool = True,
    ):
        """
        Run the Wan text-to-video diffusion pipeline.
        """

        self.check_inputs(
            prompt,
            negative_prompt,
            height,
            width,
            prompt_embeds,
            negative_prompt_embeds,
            callback_on_step_end_tensor_inputs,
            self.guidance_scale_2,
        )

        if num_frames % self.vae_scale_factor_temporal != 1:
            print(f"[W] `num_frames - 1` has to be divisible by {self.vae_scale_factor_temporal}. Rounding to the nearest number.")
            num_frames = num_frames // self.vae_scale_factor_temporal * self.vae_scale_factor_temporal + 1
        num_frames = max(num_frames, 1)

        device = self._execution_device
        batch_size = 1

        with torch.inference_mode(), trt.Runtime(TRT_LOGGER):
            torch.cuda.synchronize()
            e2e_tic = time.perf_counter()

            with self.model_memory_manager(["text_encoder"], low_vram=self.low_vram):
                prompt_embeds, negative_prompt_embeds = self.encode_prompt(
                    prompt=prompt,
                    negative_prompt=negative_prompt,
                    do_classifier_free_guidance=self.do_classifier_free_guidance,
                    num_videos_per_prompt=num_videos_per_prompt,
                    prompt_embeds=prompt_embeds,
                    negative_prompt_embeds=negative_prompt_embeds,
                    max_sequence_length=max_sequence_length,
                    device=device,
                )

            transformer_dtype = torch.bfloat16
            prompt_embeds = prompt_embeds.to(transformer_dtype)
            if negative_prompt_embeds is not None:
                negative_prompt_embeds = negative_prompt_embeds.to(transformer_dtype)

            self.scheduler.set_timesteps(num_inference_steps, device=self.device)
            timesteps = self.scheduler.timesteps

            num_channels_latents = self.models["transformer"].config.get("in_channels", 16)
            latents = self.prepare_latents(
                batch_size * num_videos_per_prompt,
                num_channels_latents,
                height,
                width,
                num_frames,
                torch.float32,
                device,
                generator,
                latents,
            )

            num_warmup_steps = len(timesteps) - num_inference_steps * self.scheduler.order
            self._num_timesteps = len(timesteps)

            latents = self.denoise_latents(
                latents=latents,
                prompt_embeds=prompt_embeds,
                negative_prompt_embeds=negative_prompt_embeds,
                timesteps=timesteps,
                guidance_scale=self.guidance_scale,
                guidance_scale_2=self.guidance_scale_2,
                transformer_dtype=transformer_dtype,
                num_warmup_steps=num_warmup_steps,
                attention_kwargs=attention_kwargs,
                callback_on_step_end=callback_on_step_end,
                callback_on_step_end_tensor_inputs=callback_on_step_end_tensor_inputs,
                warmup=warmup,
            )

            with self.model_memory_manager(["vae_decoder"], low_vram=self.low_vram):
                video_raw = self.decode_latents(latents, num_frames)
            video = image_module.tensor2vid(video_raw, self, output_type="pil")

            torch.cuda.synchronize()
            e2e_toc = time.perf_counter()

        walltime_ms = (e2e_toc - e2e_tic) * 1000.0
        if not warmup:
            self.print_summary(num_inference_steps, walltime_ms, batch_size, num_frames)
            if save_video:
                self.save_video(video[0], self.pipeline_type.name.lower(), prompt, self.seed)

        return video, walltime_ms

    def run(self, prompt, height, width, num_frames, batch_size, batch_count, num_warmup_runs, use_cuda_graph, **kwargs):
        if self.low_vram and self.use_cuda_graph:
            print("[W] Using low_vram, use_cuda_graph will be disabled")
            self.use_cuda_graph = False
        
        num_warmup_runs = max(1, num_warmup_runs) if use_cuda_graph else num_warmup_runs
        if num_warmup_runs > 0:
            print("[I] Warming up ..")
            for _ in range(num_warmup_runs):
                self.infer(prompt, height=height, width=width, num_frames=num_frames, warmup=True, **kwargs)

        for _ in range(batch_count):
            print("[I] Running Wan T2V pipeline")
            if self.nvtx_profile:
                cudart.cudaProfilerStart()
            self.infer(prompt, height=height, width=width, num_frames=num_frames, warmup=False, **kwargs)
            if self.nvtx_profile:
                cudart.cudaProfilerStop()
