#
# 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.
#
from __future__ import annotations

import argparse
import inspect
import os
import time
import warnings
from typing import Any, List, Optional

import numpy as np
import tensorrt as trt
import torch
from cuda.bindings import runtime as cudart
from diffusers.image_processor import VaeImageProcessor
from flux.content_filters import PixtralContentFilter
from huggingface_hub import snapshot_download

from demo_diffusion import path as path_module
from demo_diffusion.model import (
    CLIPModel,
    FLUXLoraLoader,
    FluxTransformerModel,
    T5Model,
    VAEEncoderModel,
    VAEModel,
    get_clip_embedding_dim,
    load,
    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)

PREFERRED_KONTEXT_RESOLUTIONS = [
    (672, 1568),
    (688, 1504),
    (720, 1456),
    (752, 1392),
    (800, 1328),
    (832, 1248),
    (880, 1184),
    (944, 1104),
    (1024, 1024),
    (1104, 944),
    (1184, 880),
    (1248, 832),
    (1328, 800),
    (1392, 752),
    (1456, 720),
    (1504, 688),
    (1568, 672),
]


class FluxKontextUtil:
    """
    Utility class for Flux Kontext-related operations including context dimension calculations
    and resolution handling.
    """

    @staticmethod
    def _get_context_dim(
        image_height: int,
        image_width: int,
        compression_factor: int,
    ) -> int:
        """
        Calculate the context dimension based on image dimensions and compression factor.

        Args:
            image_height: Height of the image
            image_width: Width of the image
            compression_factor: Compression factor applied to the image

        Returns:
            The calculated sequence length for the context
        """
        seq_len = (image_height // (2 * compression_factor)) * (image_width // (2 * compression_factor))
        return seq_len

    @staticmethod
    def get_context_latent_dim(
        version: str,
        kontext_resolution: tuple = None,
        compression_factor: int = 8,
        static_shape: bool = False,
    ):
        """
        Get the context latent dimensions for Flux Kontext models.

        Args:
            version: Model version string
            kontext_resolution: Tuple of (width, height) for kontext resolution
            compression_factor: Compression factor for the model
            static_shape: Whether to use static shapes

        Returns:
            Tuple of (min_context_latent_dim, context_latent_dim, max_context_latent_dim)
        """
        min_context_latent_dim, context_latent_dim, max_context_latent_dim = 0, 0, 0

        if version == "flux.1-kontext-dev":
            assert kontext_resolution is not None, "kontext_resolution must be provided for flux.1-kontext-dev"

            # get opt context size
            context_latent_dim = FluxKontextUtil._get_context_dim(
                image_height=kontext_resolution[1],
                image_width=kontext_resolution[0],
                compression_factor=compression_factor,
            )

            # get min context size
            _, min_context_width, min_context_height = min((w * h, w, h) for w, h in PREFERRED_KONTEXT_RESOLUTIONS)
            min_context_latent_dim = (
                context_latent_dim
                if static_shape
                else FluxKontextUtil._get_context_dim(
                    image_height=min_context_height,
                    image_width=min_context_width,
                    compression_factor=compression_factor,
                )
            )

            # get max context size
            _, max_context_width, max_context_height = max((w * h, w, h) for w, h in PREFERRED_KONTEXT_RESOLUTIONS)
            max_context_latent_dim = (
                context_latent_dim
                if static_shape
                else FluxKontextUtil._get_context_dim(
                    image_height=max_context_height,
                    image_width=max_context_width,
                    compression_factor=compression_factor,
                )
            )

        return min_context_latent_dim, context_latent_dim, max_context_latent_dim

    @staticmethod
    def get_preferred_resolutions():
        """
        Get the list of preferred Kontext resolutions.

        Returns:
            List of (width, height) tuples representing preferred resolutions
        """
        return PREFERRED_KONTEXT_RESOLUTIONS.copy()

    @staticmethod
    def get_min_max_kontext_dimensions():
        """
        Get the resolution tuples with minimum and maximum aspect ratios from preferred Kontext resolutions.

        Returns:
            Tuple of ((min_aspect_width, min_aspect_height), (max_aspect_width, max_aspect_height))
        """
        widths = [w for w, h in PREFERRED_KONTEXT_RESOLUTIONS]
        heights = [h for w, h in PREFERRED_KONTEXT_RESOLUTIONS]

        min_width = min(widths)
        max_width = max(widths)
        min_height = min(heights)
        max_height = max(heights)

        return (min_width, min_height), (max_width, max_height)

def calculate_shift(
    image_seq_len,
    base_seq_len: int = 256,
    max_seq_len: int = 4096,
    base_shift: float = 0.5,
    max_shift: float = 1.16,
):
    m = (max_shift - base_shift) / (max_seq_len - base_seq_len)
    b = base_shift - m * base_seq_len
    mu = image_seq_len * m + b
    return mu


class FluxPipeline(DiffusionPipeline):
    """
    Application showcasing the acceleration of Flux pipelines using Nvidia TensorRT.
    """

    def __init__(
        self,
        version="flux.1-dev",
        pipeline_type=PIPELINE_TYPE.TXT2IMG,
        guidance_scale=3.5,
        max_sequence_length=512,
        calibration_dataset=None,
        t5_weight_streaming_budget_percentage=None,
        transformer_weight_streaming_budget_percentage=None,
        lora_scale: float = 1.0,
        lora_weight: Optional[List[float]] = None,
        lora_path: Optional[List[str]] = None,
        **kwargs,
    ):
        """
        Initializes the Flux pipeline.

        Args:
            version (`str`, defaults to `flux.1-dev`)
                Version of the underlying Flux model.
            guidance_scale (`float`, defaults to 3.5):
                Guidance scale is enabled by setting as > 1.
                Higher guidance scale encourages to generate images that are closely linked to the text prompt, usually at the expense of lower image quality.
            max_sequence_length (`int`, defaults to 512):
                Maximum sequence length to use with the `prompt`.
            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 model.
        """
        super().__init__(
            version=version,
            pipeline_type=pipeline_type,
            text_encoder_weight_streaming_budget_percentage=t5_weight_streaming_budget_percentage,
            denoiser_weight_streaming_budget_percentage=transformer_weight_streaming_budget_percentage,
            **kwargs,
        )
        self.guidance_scale = guidance_scale
        self.max_sequence_length = max_sequence_length
        self.calibration_dataset = calibration_dataset  # Currently supported for Flux ControlNet pipelines only

        # Initialize LoRA
        self.lora_loader = None
        if lora_path:
            self.lora_weights = dict()
            self.lora_loader = FLUXLoraLoader(lora_path, lora_weight, lora_scale)
            assert len(lora_path) == len(lora_weight)
            for i, path in enumerate(lora_path):
                self.lora_weights[path] = lora_weight[i]

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

        Overrides:
            DiffusionPipeline.FromArgs
        """
        MAX_BATCH_SIZE = 4
        DEVICE = "cuda"
        DO_RETURN_LATENTS = False

        # Resolve all paths.
        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,
            guidance_scale=args.guidance_scale,
            max_sequence_length=args.max_sequence_length,
            bf16=args.bf16,
            calibration_dataset=args.calibration_dataset if hasattr(args, "calibration_dataset") else None,
            low_vram=args.low_vram,
            torch_fallback=args.torch_fallback,
            weight_streaming=args.ws,
            t5_weight_streaming_budget_percentage=args.t5_ws_percentage,
            transformer_weight_streaming_budget_percentage=args.transformer_ws_percentage,
            max_batch_size=MAX_BATCH_SIZE,
            denoising_steps=args.denoising_steps,
            scheduler=args.scheduler,
            lora_scale=args.lora_scale,
            lora_weight=args.lora_weight,
            lora_path=args.lora_path,
            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,
            return_latents=DO_RETURN_LATENTS,
            torch_inference=args.torch_inference,
        )

    @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
        """
        if pipeline_type.is_img2img():
            return ["clip", "t5", "transformer", "vae", "vae_encoder"]
        else:
            return ["clip", "t5", "transformer", "vae"]

    def download_onnx_models(self, model_name: str, model_config: dict[str, Any]) -> None:
        if self.fp16:
            raise ValueError(
                "ONNX models can be downloaded only for the following precisions: BF16, FP8, FP4. This pipeline is running in FP16."
            )

        hf_download_path = "-".join([load.get_path(self.version, self.pipeline_type.name), "onnx"])
        model_path = model_config["onnx_opt_path"]
        base_dir = os.path.dirname(os.path.dirname(model_config["onnx_opt_path"]))

        if not os.path.exists(model_path):
            if model_name == "clip":
                dirname = "clip.opt"
            elif model_name == "t5":
                dirname = "t5.opt"
            elif model_name == "transformer":
                if model_config["use_fp4"]:
                    dirname = "transformer.opt/fp4"
                    if self.version == "flux.1-kontext-dev":
                        dirname = "_".join([dirname, "svd32"])
                elif model_config["use_fp8"]:
                    dirname = "transformer.opt/fp8"
                elif self.bf16:
                    dirname = "transformer.opt/bf16"
            elif model_name == "vae":
                dirname = "vae.opt"
            elif model_name == "vae_encoder":
                dirname = "vae_encoder.opt"
            else:
                raise ValueError(f"{model_name} not found in {self.stages}")

            snapshot_download(
                repo_id=hf_download_path,
                allow_patterns=os.path.join(dirname, "*"),
                local_dir=base_dir,
                token=self.hf_token,
            )
            # Rename directory from <model_name>.opt to <model_name>
            saved_dir = os.path.join(base_dir, dirname)
            model_dir = os.path.dirname(model_path)
            os.rename(saved_dir, model_dir)
            # Rename model from model.onnx to model_optimized.onnx
            os.rename(os.path.join(model_dir, "model.onnx"), model_path)

    def is_native_export_supported(self, model_config: dict[str, Any]) -> bool:
        if self.version.startswith("flux.1") and model_config["use_fp4"]:
            # Native export not supported for FP4.
            raise ValueError(
                f"Native FP4 quantization is not supported. No ONNX model found in {model_config['onnx_opt_path']}. Please pass --download-onnx-models."
            )
        if (
            self.version in ["flux.1-dev-canny", "flux.1-dev-depth"]
            and model_config["use_fp8"]
            and not self.calibration_dataset
        ):
            # Native export of FP8 model requires calibration data.
            raise ValueError(
                f"No ONNX model found in {model_config['onnx_opt_path']}. Please pass --download-onnx-models. If you would like to quantize and export natively, please provide calibration data using --calibration-."
            )
        return True

    def _initialize_models(self, framework_model_dir, int8, fp8, fp4):
        # Load text tokenizer(s)
        self.tokenizer = make_tokenizer(
            self.version, self.pipeline_type, self.hf_token, framework_model_dir,
        )
        self.tokenizer2 = make_tokenizer(
            self.version,
            self.pipeline_type,
            self.hf_token,
            framework_model_dir,
            subfolder="tokenizer_2",
            tokenizer_type="t5",
        )

        # Load pipeline models
        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": self.max_batch_size,
        }

        self.bf16 = True if int8 or fp8 or fp4 else self.bf16
        self.fp16 = True if not self.bf16 else False
        self.tf32 = True
        if "clip" in self.stages:
            # BF16 CLIP ONNX export fails with ComplexDouble error in newer PyTorch; use FP16.
            self.models["clip"] = CLIPModel(
                **models_args,
                fp16=True,
                tf32=self.tf32,
                bf16=False,
                embedding_dim=get_clip_embedding_dim(self.version, self.pipeline_type),
                keep_pooled_output=True,
                subfolder="text_encoder",
            )

        if "t5" in self.stages:
            # Known accuracy issues with FP16
            self.models["t5"] = T5Model(
                **models_args,
                fp16=self.fp16,
                tf32=self.tf32,
                bf16=self.bf16,
                subfolder="text_encoder_2",
                text_maxlen=self.max_sequence_length,
                weight_streaming=self.weight_streaming,
                weight_streaming_budget_percentage=self.text_encoder_weight_streaming_budget_percentage,
            )

        if "vae" in self.stages:
            # Accuracy issues with FP16
            self.models["vae"] = VAEModel(**models_args, fp16=False, tf32=self.tf32, bf16=self.bf16)

        self.vae_scale_factor = (
            2 ** (len(self.models["vae"].config["block_out_channels"]))
            if "vae" in self.stages and self.models["vae"] is not None
            else 16
        )
        self.vae_latent_channels = (
            self.models["vae"].config["latent_channels"]
            if "vae" in self.stages and self.models["vae"] is not None
            else 16
        )

        if "vae_encoder" in self.stages:
            self.image_processor = VaeImageProcessor(
                vae_scale_factor=self.vae_scale_factor * 2, vae_latent_channels=self.vae_latent_channels
            )
            # Add kontext_resolution if available (for FluxKontextPipeline)
            vae_encoder_kwargs = {}
            if hasattr(self, "kontext_image") and self.kontext_image is not None:
                self.resize_height, self.resize_width = self._get_resize_dimensions(self.kontext_image)
                vae_encoder_kwargs["kontext_resolution"] = (self.resize_width, self.resize_height)

            self.models["vae_encoder"] = VAEEncoderModel(**models_args, fp16=False, tf32=self.tf32, bf16=self.bf16, **vae_encoder_kwargs)

        if "transformer" in self.stages:
            transformer_kwargs = {
                **models_args,
                "bf16": self.bf16,
                "fp16": self.fp16,
                "int8": int8,
                "fp8": fp8,
                "tf32": self.tf32,
                "text_maxlen": self.max_sequence_length,
                "weight_streaming": self.weight_streaming,
                "weight_streaming_budget_percentage": self.denoiser_weight_streaming_budget_percentage,
            }
            if hasattr(self, "kontext_image") and self.kontext_image is not None:
                transformer_kwargs["kontext_resolution"] = (self.resize_width, self.resize_height)
            self.models["transformer"] = FluxTransformerModel(**transformer_kwargs)

    def encode_image(self, input_image, encoder="vae_encoder"):
        self.profile_start(encoder, color='red')
        cast_to = torch.float16 if self.models[encoder].fp16 else torch.bfloat16 if self.models[encoder].bf16 else torch.float32
        input_image = input_image.to(dtype=cast_to)
        if self.torch_inference or self.torch_fallback[encoder]:
            image_latents = self.torch_models[encoder](input_image)
        else:
            image_latents = self.run_engine(encoder, {'images': input_image})['latent']

        image_latents = self.models[encoder].config["scaling_factor"] * (image_latents - self.models[encoder].config["shift_factor"])
        self.profile_stop(encoder)
        return image_latents

    # Copied from https://github.com/huggingface/diffusers/blob/main/src/diffusers/pipelines/flux/pipeline_flux_controlnet.py#L546
    def prepare_image(
        self,
        image,
        width,
        height,
        batch_size,
        num_images_per_prompt,
        device,
        dtype,
        do_classifier_free_guidance=False,
        guess_mode=False,
    ):
        if isinstance(image, torch.Tensor):
            pass
        else:
            image = self.image_processor.preprocess(image, height=height, width=width)

        image_batch_size = image.shape[0]

        if image_batch_size == 1:
            repeat_by = batch_size
        else:
            # image batch size is the same as prompt batch size
            repeat_by = num_images_per_prompt

        image = image.repeat_interleave(repeat_by, dim=0)

        image = image.to(device=device, dtype=dtype)

        if do_classifier_free_guidance and not guess_mode:
            image = torch.cat([image] * 2)

        return image

    # Copied from https://github.com/huggingface/diffusers/blob/v0.30.1/src/diffusers/pipelines/flux/pipeline_flux.py#L436
    @staticmethod
    def _pack_latents(latents, batch_size, num_channels_latents, height, width):
        """
        Reshapes latents from (B, C, H, W) to (B, H/2, W/2, C*4) as expected by the denoiser
        """
        latents = latents.view(
            batch_size, num_channels_latents, height // 2, 2, width // 2, 2
        )
        latents = latents.permute(0, 2, 4, 1, 3, 5)
        latents = latents.reshape(
            batch_size, (height // 2) * (width // 2), num_channels_latents * 4
        )

        return latents

    # Copied from https://github.com/huggingface/diffusers/blob/v0.30.1/src/diffusers/pipelines/flux/pipeline_flux.py#L444
    @staticmethod
    def _unpack_latents(latents, height, width, vae_scale_factor):
        """
        Reshapes denoised latents to the format (B, C, H, W)
        """
        batch_size, num_patches, channels = latents.shape

        height = height // vae_scale_factor
        width = width // vae_scale_factor

        latents = latents.view(batch_size, height, width, channels // 4, 2, 2)
        latents = latents.permute(0, 3, 1, 4, 2, 5)

        latents = latents.reshape(
            batch_size, channels // (2 * 2), height * 2, width * 2
        )

        return latents

    # Copied from https://github.com/huggingface/diffusers/blob/v0.30.1/src/diffusers/pipelines/flux/pipeline_flux.py#L421
    @staticmethod
    def _prepare_latent_image_ids(height, width, dtype, device):
        """
        Prepares latent image indices
        """
        latent_image_ids = torch.zeros(height // 2, width // 2, 3)
        latent_image_ids[..., 1] = (
            latent_image_ids[..., 1] + torch.arange(height // 2)[:, None]
        )
        latent_image_ids[..., 2] = (
            latent_image_ids[..., 2] + torch.arange(width // 2)[None, :]
        )

        latent_image_id_height, latent_image_id_width, latent_image_id_channels = (
            latent_image_ids.shape
        )

        latent_image_ids = latent_image_ids.reshape(
            latent_image_id_height * latent_image_id_width, latent_image_id_channels
        )

        return latent_image_ids.to(device=device, dtype=dtype)

    def initialize_latents(
        self,
        batch_size,
        num_channels_latents,
        latent_height,
        latent_width,
        latent_timestep=None,
        image_latents=None,
        latents_dtype=torch.float32,
    ):
        latents_dtype = latents_dtype  # text_embeddings.dtype
        latents_shape = (batch_size, num_channels_latents, latent_height, latent_width)
        latents = torch.randn(
            latents_shape,
            device=self.device,
            dtype=latents_dtype,
            generator=self.generator,
        )

        if image_latents is not None:
            image_latents = torch.cat([image_latents], dim=0).to(latents_dtype)
            latents = self.scheduler.scale_noise(image_latents, latent_timestep, latents)

        latents = self._pack_latents(
            latents, batch_size, num_channels_latents, latent_height, latent_width
        )

        latent_image_ids = self._prepare_latent_image_ids(latent_height, latent_width, latents_dtype, self.device)

        return latents, latent_image_ids

    # Copied from https://github.com/huggingface/diffusers/blob/main/src/diffusers/pipelines/flux/pipeline_flux_img2img.py#L416C1
    def get_timesteps(self, num_inference_steps, strength):
        # get the original timestep using init_timestep
        init_timestep = min(num_inference_steps * strength, num_inference_steps)

        t_start = int(max(num_inference_steps - init_timestep, 0))
        timesteps = self.scheduler.timesteps[t_start * self.scheduler.order :]
        if hasattr(self.scheduler, "set_begin_index"):
            self.scheduler.set_begin_index(t_start * self.scheduler.order)

        return timesteps, num_inference_steps - t_start

    def encode_prompt(
        self, prompt, encoder="clip", max_sequence_length=None, pooled_output=False
    ):
        self.profile_start(encoder, color="green")

        tokenizer = self.tokenizer2 if encoder == "t5" else self.tokenizer
        max_sequence_length = (
            tokenizer.model_max_length
            if max_sequence_length is None
            else max_sequence_length
        )

        def tokenize(prompt, max_sequence_length):
            text_input_ids = (
                tokenizer(
                    prompt,
                    padding="max_length",
                    max_length=max_sequence_length,
                    truncation=True,
                    return_overflowing_tokens=False,
                    return_length=False,
                    return_tensors="pt",
                )
                .input_ids.type(torch.int32)
                .to(self.device)
            )

            untruncated_ids = tokenizer(
                prompt, padding="longest", return_tensors="pt"
            ).input_ids.type(torch.int32).to(self.device)
            if untruncated_ids.shape[-1] >= text_input_ids.shape[
                -1
            ] and not torch.equal(text_input_ids, untruncated_ids):
                removed_text = tokenizer.batch_decode(
                    untruncated_ids[:, max_sequence_length - 1 : -1]
                )
                warnings.warn(
                    "The following part of your input was truncated because `max_sequence_length` is set to "
                    f"{max_sequence_length} tokens: {removed_text}"
                )

            if self.torch_inference or self.torch_fallback[encoder]:
                outputs = self.torch_models[encoder](
                    text_input_ids, output_hidden_states=False
                )
                text_encoder_output = (
                    outputs[0].clone()
                    if pooled_output == False
                    else outputs.pooler_output.clone()
                )
            else:
                # NOTE: output tensor for the encoder must be cloned because it will be overwritten when called again for prompt2
                outputs = self.run_engine(encoder, {"input_ids": text_input_ids})
                output_name = (
                    "text_embeddings" if not pooled_output else "pooled_embeddings"
                )
                text_encoder_output = outputs[output_name].clone()

            return text_encoder_output

        # Tokenize prompt
        text_encoder_output = tokenize(prompt, max_sequence_length)

        self.profile_stop(encoder)
        return text_encoder_output.to(torch.float16) if self.fp16 else text_encoder_output.to(torch.bfloat16) if self.bf16 else text_encoder_output

    def denoise_latent(
        self,
        latents,
        timesteps,
        text_embeddings,
        pooled_embeddings,
        text_ids,
        latent_image_ids,
        denoiser="transformer",
        guidance=None,
        control_latent=None,
    ):
        do_autocast = self.torch_inference != "" and self.models[denoiser].fp16
        with torch.autocast("cuda", enabled=do_autocast):
            self.profile_start(denoiser, color="blue")

            # handle guidance
            if self.models[denoiser].config["guidance_embeds"] and guidance is None:
                guidance = torch.full(
                    [1], self.guidance_scale, device=self.device, dtype=torch.float32
                )
                guidance = guidance.expand(latents.shape[0])

            for step_index, timestep in enumerate(timesteps):
                # Prepare latents
                latents_input = latents if control_latent is None else torch.cat((latents, control_latent), dim=-1)

                # prepare inputs
                timestep_inp = timestep.expand(latents.shape[0]).to(latents_input.dtype)

                params = {
                    "hidden_states": latents_input,
                    "timestep": timestep_inp / 1000,
                    "pooled_projections": pooled_embeddings,
                    "encoder_hidden_states": text_embeddings,
                    "txt_ids": text_ids.float(),
                    "img_ids": latent_image_ids.float(),
                }
                if guidance is not None:
                    params.update({"guidance": guidance})

                # Predict the noise residual
                if self.torch_inference or self.torch_fallback[denoiser]:
                    noise_pred = self.torch_models[denoiser](**params)["sample"]
                else:
                    noise_pred = self.run_engine(denoiser, params)["latent"]

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

        self.profile_stop(denoiser)
        return latents.to(dtype=torch.bfloat16) if self.bf16 else latents.to(dtype=torch.float32)

    def decode_latent(self, latents, decoder="vae"):
        self.profile_start(decoder, color="red")
        cast_to = torch.float16 if self.models[decoder].fp16 else torch.bfloat16 if self.models[decoder].bf16 else torch.float32
        latents = latents.to(dtype=cast_to)
        if self.torch_inference or self.torch_fallback[decoder]:
            images = self.torch_models[decoder](latents, return_dict=False)[0]
        else:
            images = self.run_engine(decoder, {"latent": latents})["images"]
        self.profile_stop(decoder)
        return images

    def print_summary(self, denoising_steps, walltime_ms, batch_size):
        print("|-----------------|--------------|")
        print("| {:^15} | {:^12} |".format("Module", "Latency"))
        print("|-----------------|--------------|")
        print(
            "| {:^15} | {:>9.2f} ms |".format(
                "CLIP",
                cudart.cudaEventElapsedTime(
                    self.events["clip"][0], self.events["clip"][1]
                )[1],
            )
        )
        print(
            "| {:^15} | {:>9.2f} ms |".format(
                "T5",
                cudart.cudaEventElapsedTime(self.events["t5"][0], self.events["t5"][1])[
                    1
                ],
            )
        )
        if "vae_encoder" in self.stages:
            print(
                "| {:^15} | {:>9.2f} ms |".format(
                    "VAE-Enc",
                    cudart.cudaEventElapsedTime(
                        self.events["vae_encoder"][0], self.events["vae_encoder"][1]
                    )[1],
                )
            )
        print(
            "| {:^15} | {:>9.2f} ms |".format(
                "Transformer x " + str(denoising_steps),
                cudart.cudaEventElapsedTime(
                    self.events["transformer"][0], self.events["transformer"][1]
                )[1],
            )
        )
        print(
            "| {:^15} | {:>9.2f} ms |".format(
                "VAE-Dec",
                cudart.cudaEventElapsedTime(
                    self.events["vae"][0], self.events["vae"][1]
                )[1],
            )
        )
        print("|-----------------|--------------|")
        print("| {:^15} | {:>9.2f} ms |".format("Pipeline", walltime_ms))
        print("|-----------------|--------------|")
        print("Throughput: {:.5f} image/s".format(batch_size * 1000.0 / walltime_ms))

    def _check_integrity(self, images):
        integrity_checker = PixtralContentFilter(self.device)
        for image in images:
            image_ = np.array(image) / 255.0
            image_ = 2 * image_ - 1
            image_ = torch.from_numpy(image_).to(self.device, dtype=torch.float32).permute(0, 3, 1, 2)
            if integrity_checker.test_image(image_):
                raise ValueError("Your image has been flagged. Choose another prompt/image or try again.")

    def save_images(self, prompt, images, check_integrity=False):
        if check_integrity:
            self._check_integrity(images)
        for image in images:
            self.save_image(image, self.pipeline_type.name.lower(), prompt, self.seed)

    def infer(
        self,
        prompt,
        prompt2,
        image_height,
        image_width,
        input_image=None,
        image_strength=1.0,
        control_image=None,
        warmup=False,
        save_image=True,
    ):
        """
        Run the diffusion pipeline.

        Args:
            prompt (str):
                The text prompt to guide image generation.
            prompt2 (str):
                The prompt to be sent to the T5 tokenizer and text encoder
            image_height (int):
                Height (in pixels) of the image to be generated. Must be a multiple of 8.
            image_width (int):
                Width (in pixels) of the image to be generated. Must be a multiple of 8.
            input_image (PIL.Image.Image):
                `Image` representing an image batch to be used as the starting point.
            image_strength (`float`, *optional*, defaults to 1.0):
                Indicates extent to transform the reference `image`. Must be between 0 and 1. `image` is used as a
                starting point and more noise is added the higher the `strength`. The number of denoising steps depends
                on the amount of noise initially added. When `strength` is 1, added noise is maximum and the denoising
                process runs for the full number of iterations specified in `num_inference_steps`. A value of 1
                essentially ignores `image`.
            control_image (PIL.Image.Image):
                The ControlNet input condition to provide guidance to the `transformer` for generation.
            warmup (bool):
                Indicate if this is a warmup run.
            save_image (bool):
                Save the generated image (if applicable)
        """
        assert len(prompt) == len(prompt2)
        batch_size = len(prompt)

        # Spatial dimensions of latent tensor
        latent_height = 2 * (int(image_height) // self.vae_scale_factor)
        latent_width = 2 * (int(image_width) // self.vae_scale_factor)

        num_inference_steps = self.denoising_steps
        latent_kwargs = {}

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

            num_channels_latents = self.models["transformer"].config["in_channels"] // 4
            if control_image:
                num_channels_latents = self.models["transformer"].config["in_channels"] // 8

                # Prepare control latents
                control_image = self.prepare_image(
                    image=control_image,
                    width=image_width,
                    height=image_height,
                    batch_size=batch_size,
                    num_images_per_prompt=1,
                    device=self.device,
                    dtype=torch.float16 if self.models["vae"].fp16 else torch.bfloat16 if self.models["vae"].bf16 else torch.float32,
                )

                if control_image.ndim == 4:
                    with self.model_memory_manager(["vae_encoder"], low_vram=self.low_vram):
                        control_image = self.encode_image(control_image)

                    height_control_image, width_control_image = control_image.shape[2:]
                    control_image = self._pack_latents(
                        control_image,
                        batch_size,
                        num_channels_latents,
                        height_control_image,
                        width_control_image,
                    )

            # CLIP and T5 text encoder(s)
            with self.model_memory_manager(["clip", "t5"], low_vram=self.low_vram):
                pooled_embeddings = self.encode_prompt(prompt, pooled_output=True)
                text_embeddings = self.encode_prompt(
                    prompt2, encoder="t5", max_sequence_length=self.max_sequence_length
                )
                text_ids = torch.zeros(text_embeddings.shape[1], 3).to(
                    device=self.device, dtype=text_embeddings.dtype
                )

            # Prepare timesteps
            sigmas = np.linspace(1.0, 1 / num_inference_steps, num_inference_steps)
            image_seq_len = (latent_height // 2) * (latent_width // 2)
            mu = calculate_shift(
                image_seq_len,
                self.scheduler.config.base_image_seq_len,
                self.scheduler.config.max_image_seq_len,
                self.scheduler.config.base_shift,
                self.scheduler.config.max_shift,
            )
            timesteps = None
            # TODO: support custom timesteps
            if timesteps is not None:
                if (
                    "timesteps"
                    not in inspect.signature(self.scheduler.set_timesteps).parameters
                ):
                    raise ValueError(
                        f"The current scheduler class {self.scheduler.__class__}'s `set_timesteps` does not support custom"
                        f" timestep schedules. Please check whether you are using the correct scheduler."
                    )
                self.scheduler.set_timesteps(timesteps=timesteps, device=self.device)
                assert self.denoising_steps == len(self.scheduler.timesteps)
            else:
                self.scheduler.set_timesteps(sigmas=sigmas, mu=mu, device=self.device)
            timesteps = self.scheduler.timesteps.to(self.device)
            num_inference_steps = len(timesteps)

            # Pre-process input image and timestep for the img2img pipeline
            if input_image:
                input_image = self.image_processor.preprocess(input_image, height=image_height, width=image_width).to(
                    self.device
                )
                with self.model_memory_manager(["vae_encoder"], low_vram=self.low_vram):
                    image_latents = self.encode_image(input_image)

                timesteps, num_inference_steps = self.get_timesteps(self.denoising_steps, image_strength)
                if num_inference_steps < 1:
                    raise ValueError(
                        f"After adjusting the num_inference_steps by strength parameter: {image_strength}, the number of pipeline"
                        f"steps is {num_inference_steps} which is < 1 and not appropriate for this pipeline."
                    )
                latent_timestep = timesteps[:1].repeat(batch_size)

                latent_kwargs.update({"image_latents": image_latents, "latent_timestep": latent_timestep})

            # Initialize latents
            latents, latent_image_ids = self.initialize_latents(
                batch_size=batch_size,
                num_channels_latents=num_channels_latents,
                latent_height=latent_height,
                latent_width=latent_width,
                latents_dtype=torch.float16 if self.fp16 else torch.bfloat16 if self.bf16 else torch.float32,
                **latent_kwargs,
            )

            # DiT denoiser
            with self.model_memory_manager(["transformer"], low_vram=self.low_vram):
                latents = self.denoise_latent(
                    latents,
                    timesteps,
                    text_embeddings,
                    pooled_embeddings,
                    text_ids,
                    latent_image_ids,
                    control_latent=control_image,
                )

            # VAE decode latent
            with self.model_memory_manager(["vae"], low_vram=self.low_vram):
                latents = self._unpack_latents(
                    latents, image_height, image_width, self.vae_scale_factor
                )
                latents = (
                    latents / self.models["vae"].config["scaling_factor"]
                ) + self.models["vae"].config["shift_factor"]
                images = self.decode_latent(latents)

            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)
            if not self.return_latents and save_image:
                # post-process images
                images = (
                    ((images + 1) * 255 / 2)
                    .clamp(0, 255)
                    .detach()
                    .permute(0, 2, 3, 1)
                    .round()
                    .type(torch.uint8)
                    .cpu()
                    .numpy()
                )

        return (latents, walltime_ms) if self.return_latents else (images, walltime_ms)

    def run(
        self,
        prompt,
        prompt2,
        height,
        width,
        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, prompt2, height, width, warmup=True, **kwargs)

        images = []
        for _ in range(batch_count):
            print("[I] Running Flux pipeline")
            if self.nvtx_profile:
                cudart.cudaProfilerStart()
            image, _ = self.infer(prompt, prompt2, height, width, warmup=False, **kwargs)
            images.append(image)
            if self.nvtx_profile:
                cudart.cudaProfilerStop()
        return images


class FluxKontextPipeline(FluxPipeline):
    """
    Application showcasing the acceleration of Flux Kontext pipelines using Nvidia TensorRT.
    This pipeline handles the specific logic for the Kontext model variant.
    """

    def __init__(
        self,
        kontext_image,
        version="flux.1-kontext-dev",
        pipeline_type=PIPELINE_TYPE.IMG2IMG,
        guidance_scale=3.5,
        max_sequence_length=512,
        **kwargs,
    ):
        """
        Initializes the Flux Kontext pipeline.

        Args:
            kontext_image (`PIL.Image.Image`):
                Kontext Image to be edited.
            version (`str`, defaults to `flux.1-kontext-dev`)
                Version of the underlying Flux Kontext model.
            guidance_scale (`float`, defaults to 3.5):
                Guidance scale is enabled by setting as > 1.
                Higher guidance scale encourages to generate images that are closely linked to the text prompt, usually at the expense of lower image quality.
            max_sequence_length (`int`, defaults to 512):
                Maximum sequence length to use with the `prompt`.
        """
        super().__init__(
            version=version,
            pipeline_type=pipeline_type,
            guidance_scale=guidance_scale,
            max_sequence_length=max_sequence_length,
            **kwargs,
        )
        self.kontext_image = kontext_image
        # WAR to avoid RuntimeError: ScalarType ComplexDouble is an unexpected tensor scalar type during CLIP export
        self.config["clip_torch_fallback"] = True

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

        Overrides:
            FluxPipeline.FromArgs
        """
        MAX_BATCH_SIZE = 4
        DEVICE = "cuda"
        DO_RETURN_LATENTS = False

        # Resolve all paths.
        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,
            guidance_scale=args.guidance_scale,
            max_sequence_length=args.max_sequence_length,
            bf16=args.bf16,
            low_vram=args.low_vram,
            torch_fallback=args.torch_fallback,
            weight_streaming=args.ws,
            t5_weight_streaming_budget_percentage=args.t5_ws_percentage,
            transformer_weight_streaming_budget_percentage=args.transformer_ws_percentage,
            max_batch_size=MAX_BATCH_SIZE,
            denoising_steps=args.denoising_steps,
            scheduler=args.scheduler,
            lora_scale=args.lora_scale,
            lora_weight=args.lora_weight,
            lora_path=args.lora_path,
            kontext_image=args.kontext_image if hasattr(args, "kontext_image") else None,
            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,
            return_latents=DO_RETURN_LATENTS,
            torch_inference=args.torch_inference,
        )

    def initialize_latents(
        self,
        batch_size,
        num_channels_latents,
        latent_height,
        latent_width,
        latent_timestep=None,
        image_latents=None,
        latents_dtype=torch.float32,
    ):
        """
        Initialize latents for Kontext pipeline.
        Overrides FluxPipeline.initialize_latents to handle Kontext-specific logic.
        """
        latents_dtype = latents_dtype  # text_embeddings.dtype
        latents_shape = (batch_size, num_channels_latents, latent_height, latent_width)
        latents = torch.randn(
            latents_shape,
            device=self.device,
            dtype=latents_dtype,
            generator=self.generator,
        )

        image_ids = None
        if image_latents is not None:
            image_latents = torch.cat([image_latents], dim=0).to(latents_dtype)
            image_latent_height, image_latent_width = image_latents.shape[2:]
            image_latents = self._pack_latents(
                image_latents, batch_size, num_channels_latents, image_latent_height, image_latent_width
            )
            image_ids = self._prepare_latent_image_ids(
                image_latent_height, image_latent_width, latents_dtype, self.device
            )
            # image ids are the same as latent ids with the first dimension set to 1 instead of 0
            image_ids[..., 0] = 1

        latents = self._pack_latents(latents, batch_size, num_channels_latents, latent_height, latent_width)

        latent_ids = self._prepare_latent_image_ids(latent_height, latent_width, latents_dtype, self.device)

        latent_image_ids = torch.cat([latent_ids, image_ids], dim=0) if image_ids is not None else latent_ids

        return latents, image_latents, latent_image_ids

    def denoise_latent(
        self,
        latents,
        timesteps,
        text_embeddings,
        pooled_embeddings,
        text_ids,
        latent_image_ids,
        image_latents,
        denoiser="transformer",
        guidance=None,
    ):
        """
        Denoise latents for Kontext pipeline.
        Overrides FluxPipeline.denoise_latent to handle Kontext-specific logic.
        """
        do_autocast = self.torch_inference != "" and self.models[denoiser].fp16
        with torch.autocast("cuda", enabled=do_autocast):
            self.profile_start(denoiser, color="blue")

            # handle guidance
            if self.models[denoiser].config["guidance_embeds"] and guidance is None:
                guidance = torch.full([1], self.guidance_scale, device=self.device, dtype=torch.float32)
                guidance = guidance.expand(latents.shape[0])

            for step_index, timestep in enumerate(timesteps):
                # Kontext-specific: concatenate image_latents along dim=1
                latents_input = torch.cat([latents, image_latents], dim=1)

                # prepare inputs
                timestep_inp = timestep.expand(latents.shape[0]).to(latents_input.dtype)

                params = {
                    "hidden_states": latents_input,
                    "timestep": timestep_inp / 1000,
                    "pooled_projections": pooled_embeddings,
                    "encoder_hidden_states": text_embeddings,
                    "txt_ids": text_ids.float(),
                    "img_ids": latent_image_ids.float(),
                }
                if guidance is not None:
                    params.update({"guidance": guidance})

                # Predict the noise residual
                if self.torch_inference or self.torch_fallback[denoiser]:
                    noise_pred = self.torch_models[denoiser](**params)["sample"]
                else:
                    noise_pred = self.run_engine(denoiser, params)["latent"]

                # Kontext-specific: extract only the latent part of the prediction
                noise_pred = noise_pred[:, : latents.size(1)]

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

        self.profile_stop(denoiser)
        return latents.to(dtype=torch.bfloat16) if self.bf16 else latents.to(dtype=torch.float32)

    def _get_resize_dimensions(self, input_image):
        """
        Preprocess input image for Kontext pipeline using preferred resolutions.
        Uses FluxKontextUtil to get the standardized list of preferred resolutions.
        """
        multiple_of = self.vae_scale_factor * 2
        resize_height, resize_width = self.image_processor.get_default_height_width(input_image)
        aspect_ratio = resize_width / resize_height
        # Kontext is trained on specific resolutions, using one of them is recommended
        preferred_resolutions = FluxKontextUtil.get_preferred_resolutions()
        _, resize_width, resize_height = min(
            (abs(aspect_ratio - w / h), w, h) for w, h in preferred_resolutions
        )
        resize_width = resize_width // multiple_of * multiple_of
        resize_height = resize_height // multiple_of * multiple_of

        return resize_height, resize_width

    def infer(
        self,
        prompt,
        prompt2,
        image_height,
        image_width,
        image_strength=1.0,
        warmup=False,
        save_image=True,
    ):
        """
        Run the Kontext diffusion pipeline.

        Args:
            prompt (str):
                The text prompt to guide image generation.
            prompt2 (str):
                The prompt to be sent to the T5 tokenizer and text encoder
            image_height (int):
                Height (in pixels) of the image to be generated. Must be a multiple of 8.
            image_width (int):
                Width (in pixels) of the image to be generated. Must be a multiple of 8.
            image_strength (`float`, *optional*, defaults to 1.0):
                Indicates extent to transform the reference `image`. Must be between 0 and 1. `image` is used as a
                starting point and more noise is added the higher the `strength`. The number of denoising steps depends
                on the amount of noise initially added. When `strength` is 1, added noise is maximum and the denoising
                process runs for the full number of iterations specified in `num_inference_steps`. A value of 1
                essentially ignores `image`.
            warmup (bool):
                Indicate if this is a warmup run.
            save_image (bool):
                Save the generated image (if applicable)
        """
        assert len(prompt) == len(prompt2)
        batch_size = len(prompt)

        # Spatial dimensions of latent tensor
        latent_height = 2 * (int(image_height) // self.vae_scale_factor)
        latent_width = 2 * (int(image_width) // self.vae_scale_factor)

        num_inference_steps = self.denoising_steps
        latent_kwargs = {}

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

            num_channels_latents = self.models["transformer"].config["in_channels"] // 4

            # CLIP and T5 text encoder(s)
            with self.model_memory_manager(["clip", "t5"], low_vram=self.low_vram):
                pooled_embeddings = self.encode_prompt(prompt, pooled_output=True)
                text_embeddings = self.encode_prompt(
                    prompt2, encoder="t5", max_sequence_length=self.max_sequence_length
                )
                text_ids = torch.zeros(text_embeddings.shape[1], 3).to(device=self.device, dtype=text_embeddings.dtype)

            # Prepare timesteps
            sigmas = np.linspace(1.0, 1 / num_inference_steps, num_inference_steps)
            image_seq_len = (latent_height // 2) * (latent_width // 2)
            mu = calculate_shift(
                image_seq_len,
                self.scheduler.config.base_image_seq_len,
                self.scheduler.config.max_image_seq_len,
                self.scheduler.config.base_shift,
                self.scheduler.config.max_shift,
            )
            timesteps = None
            # TODO: support custom timesteps
            if timesteps is not None:
                if "timesteps" not in inspect.signature(self.scheduler.set_timesteps).parameters:
                    raise ValueError(
                        f"The current scheduler class {self.scheduler.__class__}'s `set_timesteps` does not support custom"
                        f" timestep schedules. Please check whether you are using the correct scheduler."
                    )
                self.scheduler.set_timesteps(timesteps=timesteps, device=self.device)
                assert self.denoising_steps == len(self.scheduler.timesteps)
            else:
                self.scheduler.set_timesteps(sigmas=sigmas, mu=mu, device=self.device)
            timesteps = self.scheduler.timesteps.to(self.device)
            num_inference_steps = len(timesteps)

            # Pre-process kontext image and timestep for the img2img pipeline
            if self.kontext_image:
                # Kontext-specific image preprocessing
                kontext_image = self.image_processor.resize(self.kontext_image, self.resize_height, self.resize_width)

                kontext_image = self.image_processor.preprocess(
                    kontext_image, height=self.resize_height, width=self.resize_width
                ).to(self.device)
                with self.model_memory_manager(["vae_encoder"], low_vram=self.low_vram):
                    image_latents = self.encode_image(kontext_image)

                timesteps, num_inference_steps = self.get_timesteps(self.denoising_steps, image_strength)
                if num_inference_steps < 1:
                    raise ValueError(
                        f"After adjusting the num_inference_steps by strength parameter: {image_strength}, the number of pipeline"
                        f"steps is {num_inference_steps} which is < 1 and not appropriate for this pipeline."
                    )
                latent_timestep = timesteps[:1].repeat(batch_size)

                latent_kwargs.update({"image_latents": image_latents, "latent_timestep": latent_timestep})

            # Initialize latents
            latents, image_latents, latent_ids = self.initialize_latents(
                batch_size=batch_size,
                num_channels_latents=num_channels_latents,
                latent_height=latent_height,
                latent_width=latent_width,
                latents_dtype=torch.float16 if self.fp16 else torch.bfloat16 if self.bf16 else torch.float32,
                **latent_kwargs,
            )

            # DiT denoiser
            with self.model_memory_manager(["transformer"], low_vram=self.low_vram):
                latents = self.denoise_latent(
                    latents,
                    timesteps,
                    text_embeddings,
                    pooled_embeddings,
                    text_ids,
                    latent_ids,
                    image_latents,
                )

            # VAE decode latent
            with self.model_memory_manager(["vae"], low_vram=self.low_vram):
                latents = self._unpack_latents(latents, image_height, image_width, self.vae_scale_factor)
                latents = (latents / self.models["vae"].config["scaling_factor"]) + self.models["vae"].config[
                    "shift_factor"
                ]
                images = self.decode_latent(latents)

            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)
            if not self.return_latents and save_image:
                # post-process images
                images = (
                    ((images + 1) * 255 / 2)
                    .clamp(0, 255)
                    .detach()
                    .permute(0, 2, 3, 1)
                    .round()
                    .type(torch.uint8)
                    .cpu()
                    .numpy()
                )

        return (latents, walltime_ms) if self.return_latents else (images, walltime_ms)
