# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Lance transformer pieces.

The Lance LLM is BAGEL's Qwen2-MoT transformer verbatim — the released
``Lance_3B`` checkpoint uses the identical ``*_moe_gen`` / ``q_norm`` /
``vae2llm`` / ``llm2vae`` / ``time_embedder`` / ``latent_pos_embed`` layout, so
``Bagel`` / ``Qwen2MoTForCausalLM`` / ``Qwen2MoTConfig`` / ``NaiveCache`` are
re-exported unchanged.  Only the understanding ViT differs (Qwen2.5-VL vision
instead of SigLIP) and the video path adds a 3D latent position embedding.

This module also provides :class:`LanceBagel`, a thin :class:`Bagel` subclass
that overrides the two ViT entry points to consume Qwen2.5-VL's packed
``pixel_values`` + ``image_grid_thw`` layout directly (BAGEL itself assumes
SigLIP-style ``(C, H, W)`` tensors that get patchified inside the model).

NOTE ON mRoPE: Lance's backbone is Qwen2.5-VL and its understanding /
video paths use multimodal RoPE (``mrope_section=[16,24,24]``).  BAGEL's
``BagelRotaryEmbedding`` is plain 1-D RoPE on scalar position ids.  For the
text2img generation path Lance assigns scalar positions (the gen latent block
shares a single rope position, same as BAGEL), so the reused rotary is
correct there.  Full mRoPE for the x2t / video understanding path is a
follow-up — see ``LancePositionEmbedding3D``.
"""

from __future__ import annotations

import numpy as np
import torch
from torch import nn

# Lance LLM == BAGEL Qwen2-MoT: reuse verbatim (checkpoint weight names match).
from vllm_omni.diffusion.models.bagel.bagel_transformer import (  # noqa: F401
    Bagel,
    BaseNavitOutputWithPast,
    MLPconnector,
    NaiveCache,
    PackedAttentionMoT,
    PositionEmbedding,
    Qwen2MoTConfig,
    Qwen2MoTDecoderLayer,
    Qwen2MoTForCausalLM,
    Qwen2MoTModel,
    TimestepEmbedder,
    get_1d_sincos_pos_embed_from_grid,
    patchify,
)

__all__ = [
    "Bagel",
    "BaseNavitOutputWithPast",
    "LanceBagel",
    "LanceIdentityConnector",
    "LancePositionEmbedding3D",
    "LanceQwen2_5_VLNaViTWrapper",
    "LanceZeroVitPosEmbed",
    "MLPconnector",
    "NaiveCache",
    "PackedAttentionMoT",
    "PositionEmbedding",
    "Qwen2MoTConfig",
    "Qwen2MoTDecoderLayer",
    "Qwen2MoTForCausalLM",
    "Qwen2MoTModel",
    "TimestepEmbedder",
    "patchify",
]

# mRoPE temporal scaling constants for Lance (Qwen2.5-VL backbone).
# Upstream Lance computes vision rope positions as
# ``t_index = frame_idx * tokens_per_second * second_per_grid_t`` so adjacent
# latent frames sit at well-separated rope coordinates.  Lance bundles a
# ``Qwen2.5-VL-ViT/config.json`` with ``tokens_per_second: 2`` and uses
# ``second_per_grid_ts = 1.0`` in its inference script.
LANCE_TOKENS_PER_SECOND = 2
LANCE_SECONDS_PER_GRID = 1.0

# Upstream Lance ViT preprocessing (see lance-upstream
# ``data/datasets_custom/validation_dataset.py`` + ``data/video/transforms/``):
# ``VideoTransform(resolution=resolution_vit, mode='bucket',
# divisible_crop_size=28, stride_spatial=16, aspect_ratios=[...])``.
# For ``image_768res`` (Lance's default for 768px image gen), resolution_vit=672.
# Resizes to the nearest aspect-ratio bucket (max_area=672², stride=16), then
# center-crops to dims divisible by 28 (=patch_size * spatial_merge_size).
# Standard Qwen2VLImageProcessor's smart-resize is NOT used by upstream — the
# image enters the ViT at the bucket-cropped dimensions.
LANCE_VIT_BUCKET_RESOLUTION = 672
LANCE_VIT_BUCKET_STRIDE = 16
LANCE_VIT_DIVISIBLE_CROP = 28  # patch_size (14) * spatial_merge_size (2)
LANCE_VIT_ASPECT_RATIOS = ("21:9", "16:9", "4:3", "1:1", "3:4", "9:16")

# Video-specific bucket parameters (resolution=video_480p — Lance's default for
# t2v / video_edit at 12 fps).  See lance-upstream
# ``data/datasets_custom/validation_dataset.py`` lines 128-156:
#   VAE side: resolution_vae=640, divisible_crop_size=16, mean=0.5, std=0.5
#   ViT side: resolution_vit=616, divisible_crop_size=28,
#             mean=Qwen2.5-VL CLIP-norm, std=Qwen2.5-VL CLIP-norm
LANCE_VIDEO_VAE_RESOLUTION = 640
LANCE_VIDEO_VAE_DIVISIBLE_CROP = 16
LANCE_VIDEO_VIT_RESOLUTION = 616
LANCE_VIDEO_VIT_DIVISIBLE_CROP = 28
LANCE_VIDEO_BUCKET_STRIDE = 16
LANCE_VIDEO_TEMPORAL_STRIDE = 4  # Wan2.2 VAE temporal downsample
LANCE_VIDEO_SAMPLE_FPS = 12  # upstream MultiClipsFrameSampler default
LANCE_VIDEO_MAX_DURATION = 6.0  # upstream max_duration (seconds)
LANCE_VIT_PATCH_SIZE = 14  # Qwen2.5-VL ViT spatial patch
LANCE_VIT_TEMPORAL_PATCH_SIZE = 2  # Qwen2.5-VL ViT temporal patch
LANCE_VIT_SPATIAL_MERGE = 2  # Qwen2.5-VL spatial merge factor
LANCE_VIT_NORM_MEAN = (0.48145466, 0.4578275, 0.40821073)
LANCE_VIT_NORM_STD = (0.26862954, 0.26130258, 0.27577711)


def get_3d_sincos_pos_embed_from_grid(embed_dim: int, grid: np.ndarray) -> np.ndarray:
    """3-D sin-cos positional embedding (t, h, w), matching the upstream Lance
    ``modeling/lance/modeling_utils.py`` dimension split exactly."""
    assert embed_dim % 2 == 0, "Embedding dimension must be even for 3D embeddings"
    d = embed_dim // 3
    d = d if d % 2 == 0 else d - 1
    dim_t, dim_h = d, d
    dim_w = embed_dim - 2 * d
    assert dim_w % 2 == 0
    emb_t = get_1d_sincos_pos_embed_from_grid(dim_t, grid[0])
    emb_h = get_1d_sincos_pos_embed_from_grid(dim_h, grid[1])
    emb_w = get_1d_sincos_pos_embed_from_grid(dim_w, grid[2])
    return np.concatenate([emb_t, emb_h, emb_w], axis=1)


def get_3d_sincos_pos_embed(embed_dim: int, t: int, h: int, w: int) -> np.ndarray:
    grid_t = np.arange(t, dtype=np.float32)
    grid_h = np.arange(h, dtype=np.float32)
    grid_w = np.arange(w, dtype=np.float32)
    tt, hh, ww = np.meshgrid(grid_t, grid_h, grid_w, indexing="ij")
    grid = np.stack([tt, hh, ww], axis=0)
    return get_3d_sincos_pos_embed_from_grid(embed_dim, grid)


class LancePositionEmbedding3D(nn.Module):
    """Frozen 3-D sin-cos latent position embedding for the video path.

    BAGEL only ships a 2-D ``PositionEmbedding`` (image latents).  Lance's
    ``Lance_3B_Video`` checkpoint adds a temporal axis; this mirrors upstream
    ``modeling/lance/modeling_utils.py::PositionEmbedding3D``.  The image path
    uses ``t=1`` and is numerically equivalent to the 2-D embedding.
    """

    def __init__(self, max_num_frames: int, max_num_patch_per_side: int, hidden_size: int):
        super().__init__()
        self.max_num_frames = max_num_frames
        self.max_num_patch_per_side = max_num_patch_per_side
        self.hidden_size = hidden_size
        n = max_num_frames * max_num_patch_per_side * max_num_patch_per_side
        self.pos_embed = nn.Parameter(torch.zeros(n, hidden_size), requires_grad=False)
        self._init_weights()

    def _init_weights(self) -> None:
        pe = get_3d_sincos_pos_embed(
            self.hidden_size,
            self.max_num_frames,
            self.max_num_patch_per_side,
            self.max_num_patch_per_side,
        )
        self.pos_embed.data.copy_(torch.from_numpy(pe).float())

    def forward(self, position_ids: torch.Tensor) -> torch.Tensor:
        return self.pos_embed[position_ids]


class LanceIdentityConnector(nn.Module):
    """No-op connector for Lance.

    BAGEL's ``connector`` projects the ViT hidden size to the LLM hidden size.
    Qwen2.5-VL's vision tower (which Lance uses) already projects to the LLM
    hidden size internally via ``merger`` (``out_hidden_size = hidden_size``),
    and the released Lance safetensors carry no ``connector.*`` weights.  We
    therefore plug in an ``Identity`` connector so ``forward_cache_update_vit``
    keeps its existing call site without a separate code path.
    """

    def forward(self, x: torch.Tensor) -> torch.Tensor:  # noqa: D401
        return x


class LanceZeroVitPosEmbed(nn.Module):
    """No-op positional embedding for Lance's ViT tokens.

    BAGEL adds an extra 2-D sin-cos ``vit_pos_embed`` on top of the ViT output.
    Qwen2.5-VL's vision tower already carries its own (rotary) positional
    encoding, and the released Lance safetensors carry no
    ``vit_pos_embed.*`` weights.  This module returns a broadcast-friendly
    zero so the addition in ``forward_cache_update_vit`` is a no-op without
    requiring a code-path branch.
    """

    def forward(self, position_ids: torch.Tensor) -> torch.Tensor:  # noqa: D401
        return torch.zeros((), device=position_ids.device)


class LanceQwen2_5_VLNaViTWrapper(nn.Module):
    """Packed (NaViT-style) wrapper around the Qwen2.5-VL vision tower.

    Bridges BAGEL's ``vit(packed_pixel_values, ...) -> [num_tokens, vit_hidden]``
    surface to the HF ``Qwen2_5_VisionTransformerPretrainedModel`` which
    consumes ``(hidden_states, grid_thw)``.  The packed call additionally needs
    a per-image ``image_grid_thw`` so non-square images (and the
    spatial-merge token count) line up — :class:`LanceBagel` stashes the grid
    on the wrapper before invoking the ViT.
    """

    def __init__(self, vision_model: nn.Module, spatial_merge_size: int = 2):
        super().__init__()
        # Accept either the full Qwen2_5_VLForConditionalGeneration.visual or
        # the bare vision transformer.
        self.vision_model = getattr(vision_model, "visual", vision_model)
        self.spatial_merge_size = spatial_merge_size
        # Set by ``LanceBagel.forward_cache_update_vit`` before each call so
        # the wrapper can pass true per-image (T, H, W) to the HF ViT.
        self._pending_grid_thw: torch.Tensor | None = None

    @property
    def config(self):  # parity with SiglipNaViTWrapper.vision_model.config access
        return self.vision_model.config

    def set_pending_grid_thw(self, grid_thw: torch.Tensor) -> None:
        self._pending_grid_thw = grid_thw

    def forward(
        self,
        packed_pixel_values: torch.Tensor,
        packed_flattened_position_ids: torch.Tensor,
        cu_seqlens: torch.Tensor,
        max_seqlen: int,
    ) -> torch.Tensor:
        if self._pending_grid_thw is not None:
            grid_thw_t = self._pending_grid_thw.to(packed_pixel_values.device)
            self._pending_grid_thw = None
        else:
            # Fallback for square images when grid_thw was not pre-stashed.
            cu = cu_seqlens.tolist()
            grid_thw = []
            for i in range(len(cu) - 1):
                n = cu[i + 1] - cu[i]
                side = int(round(float(n) ** 0.5))
                grid_thw.append([1, side, side])
            grid_thw_t = torch.tensor(grid_thw, dtype=torch.long, device=packed_pixel_values.device)

        hidden = packed_pixel_values
        if hasattr(self.vision_model, "dtype"):
            hidden = hidden.to(self.vision_model.dtype)
        out = self.vision_model(hidden_states=hidden, grid_thw=grid_thw_t)
        # Qwen2.5-VL returns ``BaseModelOutputWithPooling(last_hidden_state=...,
        # pooler_output=merged)``; the merged sequence (post spatial-merger,
        # shape ``[sum_tokens_after_merge, out_hidden_size]``) is what the LLM
        # consumes.
        if hasattr(out, "pooler_output") and out.pooler_output is not None:
            return out.pooler_output
        if isinstance(out, tuple):
            return out[0]
        return out


class LanceBagel(Bagel):
    """Bagel subclass with Lance-specific ViT handling.

    The released Lance checkpoint pairs BAGEL's Qwen2-MoT trunk with the
    Qwen2.5-VL vision tower (whose ``merger`` already projects to LLM
    ``hidden_size`` and which carries its own rotary positional encoding).
    Two BAGEL assumptions therefore break for Lance:

    1.  ``prepare_vit_images`` calls ``transforms(image) -> (C, H, W)`` and
        then ``patchify(...)``; Lance's image processor returns
        ``(num_patches_flat, patch_features) + image_grid_thw`` already.
    2.  ``forward_cache_update_vit`` adds a ``connector`` projection plus a
        2-D ``vit_pos_embed`` that has no checkpoint weights and would
        also double-count the ViT's own positional encoding.

    This subclass overrides exactly those two methods.  Everything else —
    LLM trunk, VAE flow, generation loop, ``forward_cache_update_text`` /
    ``forward_cache_update_vae`` — is reused unchanged.
    """

    @staticmethod
    def _lance_compute_vit_buckets():
        """Replicate upstream Lance's ``BucketResize.init_buckets`` for the ViT path.

        Returns a list of ``((bucket_h, bucket_w), bucket_ratio)`` pairs in the
        same order as :data:`LANCE_VIT_ASPECT_RATIOS`.  Stays in sync with
        upstream lance ``data/video/transforms/bucket_resize.py``.
        """
        import math

        max_area = LANCE_VIT_BUCKET_RESOLUTION * LANCE_VIT_BUCKET_RESOLUTION
        stride = LANCE_VIT_BUCKET_STRIDE
        buckets: list[tuple[tuple[int, int], float]] = []
        for name in LANCE_VIT_ASPECT_RATIOS:
            w_s, h_s = (int(v) for v in name.split(":"))
            aspect = w_s / h_s
            # Option 1: solve for width first.
            rw1 = math.sqrt(max_area * aspect)
            bw1 = round(rw1 / stride) * stride
            bh1 = round(bw1 / aspect / stride) * stride
            br1 = bw1 / bh1
            # Option 2: solve for height first.
            rh2 = math.sqrt(max_area / aspect)
            bh2 = round(rh2 / stride) * stride
            bw2 = round(bh2 * aspect / stride) * stride
            br2 = bw2 / bh2
            if abs(br1 - aspect) < abs(br2 - aspect):
                bh, bw = bh1, bw1
            elif abs(br1 - aspect) > abs(br2 - aspect):
                bh, bw = bh2, bw2
            else:
                area1 = bh1 * bw1
                area2 = bh2 * bw2
                if abs(area1 - max_area) <= abs(area2 - max_area):
                    bh, bw = bh1, bw1
                else:
                    bh, bw = bh2, bw2
            buckets.append(((bh, bw), bw / bh))
        return buckets

    @staticmethod
    def _lance_bucket_resize_pil(image):
        """Apply upstream Lance's ViT preprocessing to a PIL image.

        Pipeline (matches lance-upstream's ``VideoTransform`` for the ViT path):

        1. ``BucketResize``: pick the aspect-ratio bucket nearest to the input
           and resize via ``RandomResizedCrop(scale=(1,1), ratio=fixed)``,
           which for fixed ratio is a deterministic center-crop + resize.
        2. ``DivisibleCrop(28)``: center-crop to dims divisible by
           ``patch_size * spatial_merge_size`` so the resulting ViT grid is
           clean.

        Returns the post-pipeline PIL image (still RGB, just resized).  Doing
        this BEFORE the Qwen2VLImageProcessor means smart-resize is a no-op
        and the ViT sees exactly upstream Lance's input.
        """
        import numpy as _np
        from torchvision.transforms import InterpolationMode, RandomResizedCrop

        if image.mode != "RGB":
            image = image.convert("RGB")
        w, h = image.size
        img_ratio = w / h

        buckets = LanceBagel._lance_compute_vit_buckets()
        # nearest bucket by absolute ratio difference
        diffs = _np.array([abs(img_ratio - br) for _, br in buckets])
        bh, bw = buckets[int(diffs.argmin())][0]
        bratio = bw / bh

        # RandomResizedCrop(scale=(1,1), ratio=(bratio, bratio)) is deterministic:
        # picks the largest sub-image of the target aspect, then resizes to
        # (bh, bw).  We replicate this directly to avoid the torchvision
        # transform's random-state dependency and to keep the result a PIL.
        # IMPORTANT: upstream Lance wires BucketResize through ``NaResize``
        # which defaults to BICUBIC interpolation (NOT LANCZOS — BucketResize's
        # own default is LANCZOS but ``NaResize`` overrides it).  Using
        # LANCZOS here gave byte-different ViT input pixels (max_diff~0.13)
        # which fed into the noise-query attention as wrong K/V — symptom:
        # vllm-omni preserved the input image's hat when upstream removed it.
        rrc = RandomResizedCrop(
            size=(bh, bw),
            scale=(1.0, 1.0),
            ratio=(bratio, bratio),
            interpolation=InterpolationMode.BICUBIC,
        )
        out = rrc(image)
        # DivisibleCrop(28): center-crop to dims divisible by 28.
        ow, oh = out.size
        cropped_w = ow - (ow % LANCE_VIT_DIVISIBLE_CROP)
        cropped_h = oh - (oh % LANCE_VIT_DIVISIBLE_CROP)
        if cropped_w != ow or cropped_h != oh:
            left = (ow - cropped_w) // 2
            top = (oh - cropped_h) // 2
            out = out.crop((left, top, left + cropped_w, top + cropped_h))
        return out

    # ------------------------------------------------------------------ #
    # Video preprocessing — frame sampler + bucket resize for VAE / ViT
    # ------------------------------------------------------------------ #
    @staticmethod
    def _lance_compute_video_buckets(resolution: int, stride: int):
        """Generic version of :meth:`_lance_compute_vit_buckets` for arbitrary
        ``resolution`` / ``stride`` (the video VAE uses 640/16, the video ViT
        uses 616/16, both with the same 6 aspect-ratio table)."""
        import math

        max_area = resolution * resolution
        buckets: list[tuple[tuple[int, int], float]] = []
        for name in LANCE_VIT_ASPECT_RATIOS:
            w_s, h_s = (int(v) for v in name.split(":"))
            aspect = w_s / h_s
            rw1 = math.sqrt(max_area * aspect)
            bw1 = round(rw1 / stride) * stride
            bh1 = round(bw1 / aspect / stride) * stride
            br1 = bw1 / bh1
            rh2 = math.sqrt(max_area / aspect)
            bh2 = round(rh2 / stride) * stride
            bw2 = round(bh2 * aspect / stride) * stride
            br2 = bw2 / bh2
            if abs(br1 - aspect) < abs(br2 - aspect):
                bh, bw = bh1, bw1
            elif abs(br1 - aspect) > abs(br2 - aspect):
                bh, bw = bh2, bw2
            else:
                area1 = bh1 * bw1
                area2 = bh2 * bw2
                bh, bw = (bh1, bw1) if abs(area1 - max_area) <= abs(area2 - max_area) else (bh2, bw2)
            buckets.append(((bh, bw), bw / bh))
        return buckets

    @staticmethod
    def _lance_sample_frame_indices(num_input_frames: int, origin_fps: float) -> list[int]:
        """Replicate upstream's ``MultiClipsFrameSampler`` (single-clip path).

        Upstream config (validation_dataset.py:110):
            temporal=4, sample_fps=12, truncate=False, max_duration=6.0,
            length_type="kn+1", assert_seconds=False.

        For a single clip covering the full input video this reduces to:

            duration = min(num_input_frames / origin_fps, max_duration)
            n_frames = round(duration * sample_fps)
            # round to k*temporal + 1 (or -temporal+1 if already a multiple)
            if n_frames % temporal != 0:
                n_frames = (n_frames // temporal) * temporal + 1
            else:
                n_frames = (n_frames // temporal) * temporal + 1 - temporal
            indices = np.linspace(0, num_input_frames - 1, n_frames, dtype=int)

        Verified against upstream for car (93 frames @ 24fps → 45 sampled) and
        woman (121 frames @ 24fps → 57 sampled).
        """
        import numpy as _np

        duration = num_input_frames / origin_fps
        duration = min(duration, LANCE_VIDEO_MAX_DURATION)
        n_frames = int(round(duration * LANCE_VIDEO_SAMPLE_FPS))
        t = LANCE_VIDEO_TEMPORAL_STRIDE
        if n_frames % t != 0:
            n_frames = (n_frames // t) * t + 1
        else:
            n_frames = (n_frames // t) * t + 1 - t
        n_frames = max(n_frames, 1)
        return _np.linspace(0, num_input_frames - 1, n_frames, dtype=int).tolist()

    @staticmethod
    def _lance_bucket_resize_video(frames_thwc, resolution: int, stride: int, divisible_crop: int, mean, std):
        """Apply upstream Lance's VideoTransform pipeline to a (T,H,W,C) uint8
        ndarray.  Returns ``(C, T, H', W')`` float tensor.

        Pipeline (mirrors lance-upstream's ``data/transforms.py::VideoTransform``):

            1. BucketResize per PIL frame: RandomResizedCrop(scale=(1,1),
               ratio=fixed-bucket-ratio, BICUBIC) → resized PIL → to_tensor.
               Per-frame PIL path matches upstream's
               ``BucketResize.__call__(list[PIL.Image])`` exactly — using a
               batched tensor RRC instead drifted pixel values by ~0.13
               (in [0,1] range) because torchvision's tensor-BICUBIC kernel
               differs subtly from PIL's BICUBIC kernel.
            2. DivisibleCrop(divisible_crop) — center-crop to dims divisible
               by patch_size * spatial_merge_size (28) for ViT, or by VAE
               downsample (16) for VAE.
            3. Normalize(mean, std) — VAE uses 0.5/0.5; ViT uses Qwen CLIP.
            4. Rearrange "t c h w -> c t h w"
        """
        import numpy as _np
        from PIL import Image as _Image
        from torchvision.transforms import InterpolationMode, RandomResizedCrop
        from torchvision.transforms.functional import center_crop as _ccrop
        from torchvision.transforms.functional import to_tensor as _to_tensor

        if not isinstance(frames_thwc, _np.ndarray):
            raise ValueError(f"Expected (T,H,W,C) uint8 ndarray; got {type(frames_thwc)}")
        if frames_thwc.dtype != _np.uint8:
            frames_thwc = frames_thwc.astype(_np.uint8)
        if frames_thwc.ndim != 4 or frames_thwc.shape[-1] != 3:
            raise ValueError(f"Expected (T,H,W,3) uint8; got {frames_thwc.shape}")

        T_in, H_in, W_in, _ = frames_thwc.shape
        aspect = W_in / H_in
        buckets = LanceBagel._lance_compute_video_buckets(resolution, stride)
        diffs = _np.array([abs(aspect - br) for _, br in buckets])
        bh, bw = buckets[int(diffs.argmin())][0]
        bratio = bw / bh

        rrc = RandomResizedCrop(
            size=(bh, bw),
            scale=(1.0, 1.0),
            ratio=(bratio, bratio),
            interpolation=InterpolationMode.BICUBIC,
        )
        # Per-frame PIL → RRC → to_tensor → stack — matches upstream's
        # ``BucketResize.__call__`` for ``list[PIL.Image]`` exactly.
        per_frame = []
        for t in range(T_in):
            img = _Image.fromarray(frames_thwc[t])
            img = rrc(img)
            per_frame.append(_to_tensor(img))  # (C, bh, bw) float [0,1]
        frames = torch.stack(per_frame, dim=0)  # (T, C, bh, bw)

        # DivisibleCrop(divisible_crop) — center-crop along H/W.
        _, C, Hb, Wb = frames.shape
        Hc = Hb - (Hb % divisible_crop)
        Wc = Wb - (Wb % divisible_crop)
        if Hc != Hb or Wc != Wb:
            frames = _ccrop(frames, [Hc, Wc])

        # Normalize.  Accept scalar or per-channel mean/std.
        if isinstance(mean, (int, float)):
            mean_t = torch.tensor([float(mean)] * C, dtype=frames.dtype).view(1, C, 1, 1)
        else:
            mean_t = torch.tensor(list(mean), dtype=frames.dtype).view(1, C, 1, 1)
        if isinstance(std, (int, float)):
            std_t = torch.tensor([float(std)] * C, dtype=frames.dtype).view(1, C, 1, 1)
        else:
            std_t = torch.tensor(list(std), dtype=frames.dtype).view(1, C, 1, 1)
        frames = (frames - mean_t) / std_t

        # Rearrange "t c h w -> c t h w"
        frames = frames.permute(1, 0, 2, 3).contiguous()
        return frames

    @staticmethod
    def _lance_patchify_vit_video(video_cthw: torch.Tensor):
        """Replicate upstream ``patchify_video_with_merge``: (C, T, H, W) →
        (num_patches, patch_dim).  ``patch_dim = C * tp * p² = 3 * 2 * 14² = 1176``.

        Returns ``(packed_pixels, grid_thw)`` where ``grid_thw`` is the pre-merge
        grid ``(T // tp, H // p, W // p)`` — same layout the Qwen2.5-VL ViT
        expects (matches ``Qwen2VLImageProcessor.video_processor`` output up to
        the actual pixel preprocessing).
        """
        p = LANCE_VIT_PATCH_SIZE
        tp = LANCE_VIT_TEMPORAL_PATCH_SIZE
        ms = LANCE_VIT_SPATIAL_MERGE
        # (C, T, H, W) -> (T, C, H, W)
        video = video_cthw.permute(1, 0, 2, 3).contiguous()
        T, C, H, W = video.shape
        if T % tp != 0:
            raise ValueError(f"ViT T={T} must be divisible by temporal_patch={tp}")
        if H % p != 0 or W % p != 0:
            raise ValueError(f"ViT H,W=({H},{W}) must be divisible by patch={p}")
        gt, gh, gw = T // tp, H // p, W // p
        if gh % ms != 0 or gw % ms != 0:
            raise ValueError(f"ViT grid ({gh},{gw}) must be divisible by merge={ms}")
        video = video.reshape(gt, tp, C, gh // ms, ms, p, gw // ms, ms, p)
        # permute order matches upstream: (0,3,6,4,7,2,1,5,8)
        video = video.permute(0, 3, 6, 4, 7, 2, 1, 5, 8)
        patches = video.reshape(gt * gh * gw, C * tp * p * p)
        grid_thw = torch.tensor([[gt, gh, gw]], dtype=torch.long)
        return patches, grid_thw

    @classmethod
    def _lance_video_preprocess(cls, frames_thwc, origin_fps: float):
        """Full upstream-compatible video preprocessing for video_edit.

        Pipeline (mirrors lance-upstream ``validation_dataset.py`` for
        ``--resolution video_480p``):

            1. Frame-sample at 12 fps with max_duration=6s (k*temporal+1 rule).
            2. Bucket-resize the sampled frames TWICE:
               - VAE branch (resolution=640, divisible=16, norm=0.5)
               - ViT branch (resolution=616, divisible=28, norm=Qwen CLIP)
            3. ViT branch is patchified to (num_patches, 1176) for direct
               consumption by Qwen2.5-VL.

        Returns:
            vae_video   — (C, T_lat_in, H_vae, W_vae) float tensor in
                          model-friendly [-1, 1] (mean=0.5, std=0.5).
            vit_pixels  — (num_patches, 1176) float tensor.
            vit_grid_thw — (1, 3) long tensor with pre-merge grid (gt, gh, gw).
            sampled_T   — number of RGB frames after the temporal sampler.
        """
        import numpy as _np

        if isinstance(frames_thwc, torch.Tensor):
            frames_thwc = frames_thwc.detach().cpu().numpy()
        if frames_thwc.dtype != _np.uint8:
            # Allow [-1,1] / [0,1] float arrays; convert back to uint8 so the
            # bucket transforms operate on the same domain as upstream.
            arr = frames_thwc
            if arr.max() <= 1.5:
                arr = (arr * 255.0).clip(0, 255).astype(_np.uint8)
            else:
                arr = arr.astype(_np.uint8)
            frames_thwc = arr

        T_in = frames_thwc.shape[0]
        indices = cls._lance_sample_frame_indices(T_in, origin_fps)
        sampled = frames_thwc[indices]  # (T_sampled, H, W, 3)

        vae_video = cls._lance_bucket_resize_video(
            sampled,
            resolution=LANCE_VIDEO_VAE_RESOLUTION,
            stride=LANCE_VIDEO_BUCKET_STRIDE,
            divisible_crop=LANCE_VIDEO_VAE_DIVISIBLE_CROP,
            mean=0.5,
            std=0.5,
        )
        vit_video = cls._lance_bucket_resize_video(
            sampled,
            resolution=LANCE_VIDEO_VIT_RESOLUTION,
            stride=LANCE_VIDEO_BUCKET_STRIDE,
            divisible_crop=LANCE_VIDEO_VIT_DIVISIBLE_CROP,
            mean=LANCE_VIT_NORM_MEAN,
            std=LANCE_VIT_NORM_STD,
        )
        # Qwen2.5-VL ViT's temporal patch size is 2 → T must be even.
        # Upstream pads by repeating the last frame for odd T (see
        # ``validation_dataset.py:266``):
        #     ``video_tensor = torch.cat([video_tensor, last_frame], dim=1)``
        if vit_video.shape[1] % 2 == 1:
            vit_video = torch.cat([vit_video, vit_video[:, -1:]], dim=1)
        vit_pixels, vit_grid_thw = cls._lance_patchify_vit_video(vit_video)
        return vae_video, vit_pixels, vit_grid_thw, len(indices)

    @staticmethod
    def _qwen_vl_processor_call(processor, image):
        """Invoke the Qwen2-VL image processor and return ``(pixel_values, grid_thw)``.

        Applies upstream Lance's BucketResize+DivisibleCrop BEFORE calling the
        standard Qwen2VLImageProcessor.  After the pre-resize the input dims
        are already divisible by ``patch_size * spatial_merge_size`` and within
        the processor's pixel bounds, so the processor's own smart-resize is a
        no-op — the ViT sees the exact bucket-cropped pixels upstream uses.
        """
        prepped = LanceBagel._lance_bucket_resize_pil(image)
        proc_out = processor(images=prepped, return_tensors="pt")
        pixel_values = proc_out["pixel_values"]
        grid_thw = proc_out["image_grid_thw"]
        return pixel_values, grid_thw

    # ------------------------------------------------------------------ #
    # 3-D mRoPE plumbing
    # ------------------------------------------------------------------ #
    @staticmethod
    def _mrope_broadcast(position_ids: torch.Tensor) -> torch.Tensor:
        """Convert scalar ``(S,)`` position ids to mrope ``(3, S)`` by
        broadcasting the scalar value to all three (t, h, w) axes.

        Lance/Qwen2.5-VL's mRoPE expects 3-D positions per token; for text
        tokens (and image-edit context tokens) all three axes share the
        same scalar position id.  Vision-generation paths override this with
        per-token ``(t, h, w)`` indices.
        """
        if position_ids is None:
            return position_ids
        if position_ids.ndim == 1:
            return position_ids.unsqueeze(0).expand(3, -1).contiguous()
        return position_ids

    def prepare_prompts(self, *args, **kwargs):
        gen_input, newlens, new_rope = super().prepare_prompts(*args, **kwargs)
        if "packed_text_position_ids" in gen_input:
            gen_input["packed_text_position_ids"] = self._mrope_broadcast(gen_input["packed_text_position_ids"])
        return gen_input, newlens, new_rope

    def _per_token_mrope_for_vae_latent(self, image_sizes, curr_rope) -> torch.Tensor:
        """Build per-token Qwen2.5-VL mRoPE positions for the gen latent block.

        Matches upstream Lance (``Lance.validation_gen_KVcache`` →
        ``get_rope_index``):

            start_of_image  ->  (P-1,   P-1,   P-1)
            latent (hi, wi) ->  (P,     P+hi,  P+wi)
            end_of_image    ->  (P+max(h,w), ..., ...)

        where ``P = curr_position_id`` is the position counter AFTER the text
        prefix (BAGEL parent already passes ``gen_context['ropes']``). The
        ``-1`` on start_of_image matches upstream's get_rope_index layout: the
        start marker sits one position before the latent block (which is
        anchored at P), and the end marker sits ``max(h,w)`` positions past.

        Without per-token (h, w) variation, attention can't see the spatial
        layout → image PSNR collapses ~6 dB after 30 steps.
        """
        pos_t: list[int] = []
        pos_h: list[int] = []
        pos_w: list[int] = []
        for (H, W), P in zip(image_sizes, curr_rope):
            h = H // self.latent_downsample
            w = W // self.latent_downsample
            # Upstream Lance (``shift_position_ids`` + ``get_rope_index``)
            # layout for the gen latent block:
            #   start_of_image  -> (P,         P,         P)
            #   latent (hi, wi) -> (P+1,       P+1+hi,    P+1+wi)
            #   end_of_image    -> (P+max+1,   P+max+1,   P+max+1)
            # where ``P = curr_position_id`` is the rope counter AFTER the
            # text+ViT prefix (i.e., :attr:`gen_context['ropes']` snapshot
            # taken before the VAE-ref prefill in ``_forward_image_edit``).
            # This matches :meth:`_lance_native_prepare_vae_images` so the
            # gen latent occupies the SAME rope range as the VAE-ref block
            # (modality==1 / modality==2 share rope per upstream's
            # ``i_sample_modality==2`` branch in ``shift_position_ids``).
            max_hw = max(h, w)
            pos_t.append(P)
            pos_h.append(P)
            pos_w.append(P)
            for hi in range(h):
                for wi in range(w):
                    pos_t.append(P + 1)
                    pos_h.append(P + 1 + hi)
                    pos_w.append(P + 1 + wi)
            end_p = P + max_hw + 1
            pos_t.append(end_p)
            pos_h.append(end_p)
            pos_w.append(end_p)
        return torch.stack(
            [
                torch.tensor(pos_t, dtype=torch.long),
                torch.tensor(pos_h, dtype=torch.long),
                torch.tensor(pos_w, dtype=torch.long),
            ],
            dim=0,
        )

    def _per_token_mrope_for_video_latent(self, video_shapes, curr_rope) -> torch.Tensor:
        """3-D analogue of :meth:`_per_token_mrope_for_vae_latent` for video.

        Matches upstream Lance's ``get_rope_index`` for ``modality==noise``
        (and ``modality==ref_source`` since they share rope per
        ``shift_position_ids``).  The temporal axis is amplified by
        ``LANCE_TOKENS_PER_SECOND * LANCE_SECONDS_PER_GRID`` so neighbouring
        latent frames are well-separated, matching the upstream Qwen2.5-VL
        rope convention.  Verified against upstream's rope dump for
        video_edit at video_480p (call09 cond noise iter 0):

            start_of_image  -> (P,           P,         P)
            latent (t,h,w)  -> (P+1+t*2,     P+1+h,     P+1+w)
            end_of_image    -> (P+max+1,     P+max+1,   P+max+1)

        where ``max = max(t_lat*2-1, h_lat-1, w_lat-1) + 1`` matches the
        end marker placement upstream uses (call09 final token at 159 for
        T_lat=12, H_lat=35, W_lat=47 → max(22, 34, 46)+1=47, start=111,
        end=158... actual upstream end=159, suggesting +2 from the body's
        max).  We mirror :meth:`_lance_native_prepare_vae_images` exactly
        so the VAE_ref prefill and the gen noise share the same rope range.
        """
        downsample_t = int(getattr(self.config.vae_config, "downsample_temporal", 4))
        t_scale = LANCE_TOKENS_PER_SECOND * LANCE_SECONDS_PER_GRID
        pos_t: list[int] = []
        pos_h: list[int] = []
        pos_w: list[int] = []
        for (T, H, W), P in zip(video_shapes, curr_rope):
            h = H // self.latent_downsample
            w = W // self.latent_downsample
            t = (T - 1) // downsample_t + 1
            max_thw = max(int((t - 1) * t_scale), h - 1, w - 1) + 1
            pos_t.append(P)
            pos_h.append(P)
            pos_w.append(P)
            for ti in range(t):
                for hi in range(h):
                    for wi in range(w):
                        pos_t.append(P + 1 + int(ti * t_scale))
                        pos_h.append(P + 1 + hi)
                        pos_w.append(P + 1 + wi)
            end_p = P + max_thw + 1
            pos_t.append(end_p)
            pos_h.append(end_p)
            pos_w.append(end_p)
        return torch.stack(
            [
                torch.tensor(pos_t, dtype=torch.long),
                torch.tensor(pos_h, dtype=torch.long),
                torch.tensor(pos_w, dtype=torch.long),
            ],
            dim=0,
        )

    def prepare_vae_latent(self, curr_kvlens, curr_rope, image_sizes, new_token_ids):
        gen_input = super().prepare_vae_latent(curr_kvlens, curr_rope, image_sizes, new_token_ids)
        gen_input["packed_position_ids"] = self._per_token_mrope_for_vae_latent(image_sizes, curr_rope)
        return gen_input

    def prepare_vae_latent_cfg(self, curr_kvlens, curr_rope, image_sizes):
        gen_input = super().prepare_vae_latent_cfg(curr_kvlens, curr_rope, image_sizes)
        gen_input["cfg_packed_position_ids"] = self._per_token_mrope_for_vae_latent(image_sizes, curr_rope)
        return gen_input

    def prepare_start_tokens(self, *args, **kwargs):
        gen_input = super().prepare_start_tokens(*args, **kwargs)
        if isinstance(gen_input, dict):
            for k in (
                "packed_query_position_ids",
                "packed_text_position_ids",
                "packed_position_ids",
            ):
                if k in gen_input and torch.is_tensor(gen_input[k]):
                    gen_input[k] = self._mrope_broadcast(gen_input[k])
        return gen_input

    def prepare_vit_videos(
        self,
        curr_kvlens,
        curr_rope,
        videos,
        new_token_ids,
        precomputed_vit=None,
    ):
        """Multi-frame ViT prefill for the ``x2t_video`` / ``video_edit`` paths.

        ``videos`` is a list of per-request video tensors / numpy arrays of
        shape ``(T, H, W, 3)``.  By default the Qwen2-VL video processor is
        used to convert each video to ``(pixel_values_videos, video_grid_thw)``.
        For ``video_edit`` precision matching, the pipeline may pre-compute the
        upstream-style BucketResize output and pass it via ``precomputed_vit``
        — a list of ``(pixel_values, grid_thw)`` per video, in which case the
        processor call is skipped.
        """
        processor = getattr(self, "_lance_video_processor", None)
        if processor is None and precomputed_vit is None:
            raise RuntimeError(
                "LanceBagel.prepare_vit_videos requires either ``precomputed_vit`` "
                "or ``bagel._lance_video_processor`` (a Qwen2-VL-compatible video processor)."
            )
        if precomputed_vit is not None and len(precomputed_vit) != len(videos):
            raise ValueError(
                f"precomputed_vit length ({len(precomputed_vit)}) must match number of videos ({len(videos)})"
            )

        packed_vit_token_indexes: list[int] = []
        vit_token_seqlens: list[int] = []
        packed_vit_tokens: list[torch.Tensor] = []
        packed_vit_position_ids: list[torch.Tensor] = []
        packed_text_ids: list[int] = []
        packed_text_indexes: list[int] = []
        packed_seqlens: list[int] = []
        packed_indexes: list[int] = []
        packed_key_value_indexes: list[int] = []
        grid_thw_list: list[torch.Tensor] = []
        per_axis_pos: list[tuple[int, int, int]] = []

        merge_size = int(getattr(self.vit_model, "spatial_merge_size", 2))
        merge_factor = merge_size * merge_size

        _curr = curr = 0
        newlens: list[int] = []
        new_rope: list[int] = []
        for vi, (video, curr_kvlen, curr_position_id) in enumerate(zip(videos, curr_kvlens, curr_rope)):
            packed_key_value_indexes.extend(range(curr, curr + curr_kvlen))
            curr += curr_kvlen

            packed_text_ids.append(new_token_ids["start_of_image"])
            packed_text_indexes.append(_curr)
            packed_indexes.append(curr)
            curr += 1
            _curr += 1
            per_axis_pos.append((curr_position_id, curr_position_id, curr_position_id))

            if precomputed_vit is not None:
                pixel_values, grid_thw = precomputed_vit[vi]
                if not torch.is_tensor(grid_thw):
                    grid_thw = torch.as_tensor(grid_thw, dtype=torch.long)
                if grid_thw.dim() == 1:
                    grid_thw = grid_thw.unsqueeze(0)
            else:
                proc_out = processor(videos=video, return_tensors="pt")
                # Qwen2-VL video processor: pixel_values_videos is packed
                # (num_patches_flat, patch_features) with a temporal axis
                # ``T_lat`` in video_grid_thw.
                pixel_values = proc_out["pixel_values_videos"]
                grid_thw = proc_out["video_grid_thw"]
            T_lat, H, W = (int(v) for v in grid_thw[0].tolist())
            num_patches_pre_merge = T_lat * H * W
            assert num_patches_pre_merge == pixel_values.shape[0], (
                f"pixel_values rows ({pixel_values.shape[0]}) != T_lat*H*W ({num_patches_pre_merge}); "
                f"video_grid_thw={grid_thw.tolist()}"
            )
            num_vit_tokens = num_patches_pre_merge // merge_factor

            packed_vit_tokens.append(pixel_values)
            vit_token_seqlens.append(num_patches_pre_merge)
            packed_vit_position_ids.append(torch.arange(num_patches_pre_merge, dtype=torch.long))
            grid_thw_list.append(grid_thw[0].to(torch.long))

            packed_vit_token_indexes.extend(range(_curr, _curr + num_vit_tokens))
            packed_indexes.extend(range(curr, curr + num_vit_tokens))
            curr += num_vit_tokens
            _curr += num_vit_tokens
            # Upstream Lance ViT layout (verified against video_edit rope dump
            # for car @ video_480p — call2 t=(1000,1046), h,w=(64,110)):
            #   start_of_image -> (P,         P,         P)
            #   body (t,h,w)   -> (P+1+t*ts,  P+1+h,     P+1+w)
            #   end_of_image   -> (P+max+2,   P+max+2,   P+max+2)
            #     where max = max(ts*(T-1), h-1, w-1)
            # then the entire ViT block's t-channel is shifted to 1000+ via
            # ``shift_position_ids(pos_shift=1000)`` for modality=ref_vit.
            h_merged = H // merge_size
            w_merged = W // merge_size
            t_scale = LANCE_TOKENS_PER_SECOND * LANCE_SECONDS_PER_GRID
            for ti in range(T_lat):
                for hi in range(h_merged):
                    for wi in range(w_merged):
                        per_axis_pos.append(
                            (
                                curr_position_id + 1 + int(ti * t_scale),
                                curr_position_id + 1 + hi,
                                curr_position_id + 1 + wi,
                            )
                        )

            packed_text_ids.append(new_token_ids["end_of_image"])
            packed_text_indexes.append(_curr)
            packed_indexes.append(curr)
            curr += 1
            _curr += 1
            max_offset = max(int((T_lat - 1) * t_scale), h_merged - 1, w_merged - 1)
            end_p = curr_position_id + max_offset + 2
            per_axis_pos.append((end_p, end_p, end_p))

            packed_seqlens.append(num_vit_tokens + 2)
            newlens.append(curr_kvlen + num_vit_tokens + 2)
            new_rope.append(end_p + 1)

        pos_t = torch.tensor([p[0] for p in per_axis_pos], dtype=torch.long)
        pos_h = torch.tensor([p[1] for p in per_axis_pos], dtype=torch.long)
        pos_w = torch.tensor([p[2] for p in per_axis_pos], dtype=torch.long)
        # Apply upstream's ViT t-channel shift (modality=ref_vit=4 → +1000).
        # Without this, the ViT block's K/V at the t-channel collides with the
        # VAE_ref / noise block in mRoPE space (same low-100s range), and
        # noise-query attention picks up cross-modal leakage.
        pos_t = pos_t - pos_t[0] + 1000
        packed_position_ids_3d = torch.stack([pos_t, pos_h, pos_w], dim=0)

        generation_input = {
            "packed_text_ids": torch.tensor(packed_text_ids, dtype=torch.long),
            "packed_text_indexes": torch.tensor(packed_text_indexes, dtype=torch.long),
            "vit_token_seqlens": torch.tensor(vit_token_seqlens, dtype=torch.int),
            "packed_vit_tokens": torch.cat(packed_vit_tokens, dim=0),
            "packed_vit_position_ids": torch.cat(packed_vit_position_ids, dim=0),
            "packed_vit_token_indexes": torch.tensor(packed_vit_token_indexes, dtype=torch.long),
            "packed_position_ids": packed_position_ids_3d,
            "packed_seqlens": torch.tensor(packed_seqlens, dtype=torch.int),
            "packed_indexes": torch.tensor(packed_indexes, dtype=torch.long),
            "packed_key_value_indexes": torch.tensor(packed_key_value_indexes, dtype=torch.long),
            "key_values_lens": torch.tensor(curr_kvlens, dtype=torch.int),
            "_lance_grid_thw": torch.stack(grid_thw_list, dim=0),
        }
        return generation_input, newlens, new_rope

    def prepare_vit_images(self, curr_kvlens, curr_rope, images, transforms, new_token_ids):
        # ``transforms`` is set up by ``BagelPipeline.forward`` to be
        # ``processor(images=img, return_tensors='pt').pixel_values[0]``, which
        # discards the ``image_grid_thw``.  For Lance we need both, so we ignore
        # the supplied lambda and re-call the processor here directly.  Lance's
        # pipeline always sets ``self.image_processor`` to ``Qwen2VLImageProcessor``.
        if not hasattr(self, "_lance_image_processor"):
            raise RuntimeError(
                "LanceBagel.prepare_vit_images requires the pipeline to set "
                "``bagel._lance_image_processor`` (a Qwen2-VL-compatible processor)."
            )
        processor = self._lance_image_processor

        packed_vit_token_indexes: list[int] = []
        vit_token_seqlens: list[int] = []
        packed_vit_tokens: list[torch.Tensor] = []
        packed_vit_position_ids: list[torch.Tensor] = []
        packed_text_ids: list[int] = []
        packed_text_indexes: list[int] = []
        packed_seqlens: list[int] = []
        packed_indexes: list[int] = []
        packed_key_value_indexes: list[int] = []
        grid_thw_list: list[torch.Tensor] = []

        merge_size = int(getattr(self.vit_model, "spatial_merge_size", 2))
        merge_factor = merge_size * merge_size

        _curr = curr = 0
        newlens: list[int] = []
        new_rope: list[int] = []
        # 3-D position ids per query token (mRoPE) — text framing tokens use
        # scalar broadcast, image patches use per-token ``(P + t, P + h_merged,
        # P + w_merged)`` so the Qwen2.5-VL backbone sees the spatial layout
        # along the (h, w) axes of mRoPE.
        per_axis_pos: list[tuple[int, int, int]] = []
        for image, curr_kvlen, curr_position_id in zip(images, curr_kvlens, curr_rope):
            packed_key_value_indexes.extend(range(curr, curr + curr_kvlen))
            curr += curr_kvlen

            packed_text_ids.append(new_token_ids["start_of_image"])
            packed_text_indexes.append(_curr)
            packed_indexes.append(curr)
            curr += 1
            _curr += 1
            per_axis_pos.append((curr_position_id, curr_position_id, curr_position_id))

            pixel_values, grid_thw = self._qwen_vl_processor_call(processor, image)
            # pixel_values: (num_patches_flat, patch_features). grid_thw: (1, 3) with (T, H, W) in patch units.
            T, H, W = (int(v) for v in grid_thw[0].tolist())
            num_patches_pre_merge = T * H * W
            assert num_patches_pre_merge == pixel_values.shape[0], (
                f"pixel_values rows ({pixel_values.shape[0]}) != T*H*W ({num_patches_pre_merge}); "
                f"image_grid_thw={grid_thw.tolist()}"
            )
            num_img_tokens = num_patches_pre_merge // merge_factor

            packed_vit_tokens.append(pixel_values)
            vit_token_seqlens.append(num_patches_pre_merge)
            packed_vit_position_ids.append(torch.arange(num_patches_pre_merge, dtype=torch.long))
            grid_thw_list.append(grid_thw[0].to(torch.long))

            packed_vit_token_indexes.extend(range(_curr, _curr + num_img_tokens))
            packed_indexes.extend(range(curr, curr + num_img_tokens))
            curr += num_img_tokens
            _curr += num_img_tokens
            # 3-D mRoPE positions for image patches.  Upstream Lance
            # (``modeling/lance/lance.py::get_rope_index`` + ``shift_position_ids``)
            # places ViT body tokens at ``(P+1+t, P+1+h, P+1+w)`` and the
            # surrounding markers at ``(P, ...)`` (start) / ``(P+max+1, ...)``
            # (end), then SHIFTS the t-channel of the entire ViT block to
            # 1000+ (modality=ref_vit=4) so ViT keys don't conflate with VAE
            # ref / noise keys in mRoPE space.  ``new_rope = P + max + 2``.
            h_merged = H // merge_size
            w_merged = W // merge_size
            max_hw = max(h_merged, w_merged)
            for hi in range(h_merged):
                for wi in range(w_merged):
                    per_axis_pos.append((curr_position_id + 1, curr_position_id + 1 + hi, curr_position_id + 1 + wi))

            packed_text_ids.append(new_token_ids["end_of_image"])
            packed_text_indexes.append(_curr)
            packed_indexes.append(curr)
            curr += 1
            _curr += 1
            end_p = curr_position_id + max_hw + 1
            per_axis_pos.append((end_p, end_p, end_p))

            packed_seqlens.append(num_img_tokens + 2)
            newlens.append(curr_kvlen + num_img_tokens + 2)
            new_rope.append(end_p + 1)

        pos_t = torch.tensor([p[0] for p in per_axis_pos], dtype=torch.long)
        pos_h = torch.tensor([p[1] for p in per_axis_pos], dtype=torch.long)
        pos_w = torch.tensor([p[2] for p in per_axis_pos], dtype=torch.long)
        # Upstream Lance shifts the ViT block's t-channel into a far-away rope
        # range (1000+) via ``shift_position_ids(pos_shift=1000)`` on
        # modality=ref_vit (4).  This makes ViT keys/queries mRoPE-orthogonal
        # to noise/VAE-ref tokens along the t-channel, preventing leakage in
        # the time-frequency dimension.  We apply the same shift here so the
        # ViT segment's K, V cache content matches upstream.
        pos_t = pos_t - pos_t[0] + 1000
        packed_position_ids_3d = torch.stack([pos_t, pos_h, pos_w], dim=0)

        generation_input = {
            "packed_text_ids": torch.tensor(packed_text_ids, dtype=torch.long),
            "packed_text_indexes": torch.tensor(packed_text_indexes, dtype=torch.long),
            "vit_token_seqlens": torch.tensor(vit_token_seqlens, dtype=torch.int),
            "packed_vit_tokens": torch.cat(packed_vit_tokens, dim=0),
            "packed_vit_position_ids": torch.cat(packed_vit_position_ids, dim=0),
            "packed_vit_token_indexes": torch.tensor(packed_vit_token_indexes, dtype=torch.long),
            "packed_position_ids": packed_position_ids_3d,
            "packed_seqlens": torch.tensor(packed_seqlens, dtype=torch.int),
            "packed_indexes": torch.tensor(packed_indexes, dtype=torch.long),
            "packed_key_value_indexes": torch.tensor(packed_key_value_indexes, dtype=torch.long),
            "key_values_lens": torch.tensor(curr_kvlens, dtype=torch.int),
            # Lance-extra: per-image (T, H, W) so the ViT wrapper can pass the
            # true grid to the HF tower (non-square images would otherwise be
            # reconstructed incorrectly as ``sqrt(n)`` squares).
            "_lance_grid_thw": torch.stack(grid_thw_list, dim=0),
        }
        return generation_input, newlens, new_rope

    def forward_cache_update_vit(self, *args, **kwargs):
        # Pluck the Lance-specific extra and stash on the wrapper for the
        # upcoming ViT call.  Everything else delegates to the parent so we
        # don't have to copy 60 lines of LLM-call boilerplate.
        grid_thw = kwargs.pop("_lance_grid_thw", None)
        if grid_thw is not None and hasattr(self.vit_model, "set_pending_grid_thw"):
            self.vit_model.set_pending_grid_thw(grid_thw)
        return super().forward_cache_update_vit(*args, **kwargs)

    def prepare_video_latent(self, curr_kvlens, curr_rope, video_shapes, new_token_ids):
        """3-D analogue of :meth:`prepare_vae_latent`.

        ``video_shapes`` is a list of ``(T, H, W)`` per request (RGB pixel
        space).  We package one packed-init-noise tensor over ``T_lat × H_lat ×
        W_lat`` latent tokens per video, plus 1-D indices into the 3-D position
        embedding table maintained by :class:`LancePositionEmbedding3D`
        (`bagel.latent_pos_embed`).  Latent geometry:

        - spatial: ``H_lat = H // latent_downsample`` (``=16`` for Lance)
        - temporal: ``T_lat = (T - 1) // downsample_temporal + 1`` (``=4`` for Wan2.2)
        - channels: ``latent_channel = 48``

        Position ids are flattened ``t * max_per_side² + h * max_per_side + w``
        so they index directly into the ``(max_num_frames * max_per_side²,
        hidden_size)`` table.
        """
        downsample_t = int(getattr(self.config.vae_config, "downsample_temporal", 4))
        max_per_side = int(self.max_latent_size)

        packed_text_ids, packed_text_indexes = [], []
        packed_vae_position_ids, packed_vae_token_indexes, packed_init_noises = [], [], []
        packed_seqlens, packed_indexes = [], []
        packed_key_value_indexes = []
        # 3-D position ids per token (mRoPE).  ``per_axis_pos[i]`` is the list
        # of (t, h, w) for the i-th query token; we stack into ``(3, S)`` at the
        # end.  Text framing tokens broadcast scalar to all axes; video latent
        # tokens carry per-token ``(P + t, P + h, P + w)``.
        per_axis_pos: list[tuple[int, int, int]] = []

        query_curr = curr = 0
        for (T, H, W), curr_kvlen, curr_position_id in zip(video_shapes, curr_kvlens, curr_rope):
            packed_key_value_indexes.extend(range(curr, curr + curr_kvlen))
            curr += curr_kvlen

            packed_text_ids.append(new_token_ids["start_of_image"])
            packed_text_indexes.append(query_curr)
            packed_indexes.append(curr)
            curr += 1
            query_curr += 1
            # Upstream Lance places gen-latent start_of_image at ``(P, P, P)``
            # (NOT ``P-1`` and NOT shifted to ViT t-range), body at
            # ``(P+1+ti*t_scale, P+1+hi, P+1+wi)``, end at ``(P+max+1, ...)``.
            # Verified against upstream's video_edit rope dump for T_lat=12,
            # H_lat=35, W_lat=47 (call09 / cond noise iter 0).
            per_axis_pos.append((curr_position_id, curr_position_id, curr_position_id))

            h = H // self.latent_downsample
            w = W // self.latent_downsample
            t = (T - 1) // downsample_t + 1
            num_video_tokens = t * h * w

            # 1-D position ids into the 3-D table (frame-major, then row, then col).
            tt, hh, ww = torch.meshgrid(torch.arange(t), torch.arange(h), torch.arange(w), indexing="ij")
            tt_flat = tt.flatten().tolist()
            hh_flat = hh.flatten().tolist()
            ww_flat = ww.flatten().tolist()
            vae_position_ids = (tt * (max_per_side * max_per_side) + hh * max_per_side + ww).flatten()
            packed_vae_position_ids.append(vae_position_ids)

            packed_init_noises.append(torch.randn(num_video_tokens, self.latent_channel * self.latent_patch_size**2))

            packed_vae_token_indexes.extend(range(query_curr, query_curr + num_video_tokens))
            packed_indexes.extend(range(curr, curr + num_video_tokens))
            curr += num_video_tokens
            query_curr += num_video_tokens
            # Per-token 3-D mRoPE positions, matching upstream Lance's
            # ``get_rope_index``: the temporal axis is amplified by
            # ``tokens_per_second * second_per_grid_t`` (see Qwen2.5-VL docs)
            # so neighbouring latent frames are well-separated in rope space.
            # ``second_per_grid_t = 1.0`` matches upstream's default;
            # ``tokens_per_second = 2`` is Lance's vit config.
            t_scale = LANCE_TOKENS_PER_SECOND * LANCE_SECONDS_PER_GRID
            for ti, hi, wi in zip(tt_flat, hh_flat, ww_flat):
                per_axis_pos.append(
                    (
                        curr_position_id + 1 + int(ti * t_scale),
                        curr_position_id + 1 + hi,
                        curr_position_id + 1 + wi,
                    )
                )

            packed_text_ids.append(new_token_ids["end_of_image"])
            packed_text_indexes.append(query_curr)
            packed_indexes.append(curr)
            curr += 1
            query_curr += 1
            # end_of_image sits "past" the latent block at ``P+max+1`` on every
            # axis, where ``max = max(t_scale*(t-1), h-1, w-1) + 1``.  Verified
            # against upstream's call09 final token at (159, 159, 159) for
            # T_lat=12 (t_scale=2 → max_t=22), H_lat=35 (max_h=34), W_lat=47
            # (max_w=46), with P=111: end_p = 111 + 46 + 1 + 1 = 159. ✓
            max_thw = max(int((t - 1) * t_scale), h - 1, w - 1) + 1
            end_p = curr_position_id + max_thw + 1
            per_axis_pos.append((end_p, end_p, end_p))

            packed_seqlens.append(num_video_tokens + 2)

        # Stack into (3, total_query_tokens) for mRoPE.
        pos_t = torch.tensor([p[0] for p in per_axis_pos], dtype=torch.long)
        pos_h = torch.tensor([p[1] for p in per_axis_pos], dtype=torch.long)
        pos_w = torch.tensor([p[2] for p in per_axis_pos], dtype=torch.long)
        packed_position_ids_3d = torch.stack([pos_t, pos_h, pos_w], dim=0)

        generation_input = {
            "packed_text_ids": torch.tensor(packed_text_ids, dtype=torch.long),
            "packed_text_indexes": torch.tensor(packed_text_indexes, dtype=torch.long),
            "packed_init_noises": torch.cat(packed_init_noises, dim=0),
            "packed_vae_position_ids": torch.cat(packed_vae_position_ids, dim=0),
            "packed_vae_token_indexes": torch.tensor(packed_vae_token_indexes, dtype=torch.long),
            "packed_seqlens": torch.tensor(packed_seqlens, dtype=torch.int),
            "packed_position_ids": packed_position_ids_3d,
            "key_values_lens": torch.tensor(curr_kvlens, dtype=torch.int),
            "packed_indexes": torch.tensor(packed_indexes, dtype=torch.long),
            "packed_key_value_indexes": torch.tensor(packed_key_value_indexes, dtype=torch.long),
        }
        return generation_input

    def prepare_video_latent_cfg(self, curr_kvlens, curr_rope, video_shapes):
        """3-D analogue of :meth:`prepare_vae_latent_cfg` (CFG side).

        Mirrors :meth:`prepare_video_latent`'s mRoPE 3-D position layout
        EXACTLY, including the ``LANCE_TOKENS_PER_SECOND * LANCE_SECONDS_PER_GRID``
        temporal scaling.  Without that scaling, the cfg_text branch attends
        with different rope coordinates than the cond branch (and than
        upstream's ``get_rope_index``), which makes ``cfg_text_v_t`` diverge
        from upstream and the CFG combination amplifies the error every
        denoise step.
        """
        downsample_t = int(getattr(self.config.vae_config, "downsample_temporal", 4))

        packed_indexes, packed_key_value_indexes = [], []
        per_axis_pos: list[tuple[int, int, int]] = []
        query_curr = curr = 0
        t_scale = LANCE_TOKENS_PER_SECOND * LANCE_SECONDS_PER_GRID
        for (T, H, W), curr_kvlen, curr_position_id in zip(video_shapes, curr_kvlens, curr_rope):
            packed_key_value_indexes.extend(range(curr, curr + curr_kvlen))
            curr += curr_kvlen

            packed_indexes.append(curr)
            curr += 1
            query_curr += 1
            # Match :meth:`prepare_video_latent` convention exactly (see
            # comments there for the upstream rope layout this replicates).
            per_axis_pos.append((curr_position_id, curr_position_id, curr_position_id))

            h = H // self.latent_downsample
            w = W // self.latent_downsample
            t = (T - 1) // downsample_t + 1
            num_video_tokens = t * h * w
            packed_indexes.extend(range(curr, curr + num_video_tokens))
            curr += num_video_tokens
            query_curr += num_video_tokens
            tt, hh, ww = torch.meshgrid(torch.arange(t), torch.arange(h), torch.arange(w), indexing="ij")
            for ti, hi, wi in zip(tt.flatten().tolist(), hh.flatten().tolist(), ww.flatten().tolist()):
                per_axis_pos.append(
                    (
                        curr_position_id + 1 + int(ti * t_scale),
                        curr_position_id + 1 + hi,
                        curr_position_id + 1 + wi,
                    )
                )

            packed_indexes.append(curr)
            curr += 1
            query_curr += 1
            max_thw = max(int((t - 1) * t_scale), h - 1, w - 1) + 1
            end_p = curr_position_id + max_thw + 1
            per_axis_pos.append((end_p, end_p, end_p))

        pos_t = torch.tensor([p[0] for p in per_axis_pos], dtype=torch.long)
        pos_h = torch.tensor([p[1] for p in per_axis_pos], dtype=torch.long)
        pos_w = torch.tensor([p[2] for p in per_axis_pos], dtype=torch.long)
        cfg_packed_position_ids_3d = torch.stack([pos_t, pos_h, pos_w], dim=0)

        return {
            "cfg_packed_position_ids": cfg_packed_position_ids_3d,
            "cfg_packed_query_indexes": torch.tensor(packed_indexes, dtype=torch.long),
            "cfg_key_values_lens": torch.tensor(curr_kvlens, dtype=torch.int),
            "cfg_packed_key_value_indexes": torch.tensor(packed_key_value_indexes, dtype=torch.long),
        }

    # ------------------------------------------------------------------ #
    # Image-edit / video-edit VAE prefill
    # ------------------------------------------------------------------ #
    def _lance_native_prepare_vae_images(
        self,
        curr_kvlens,
        curr_rope,
        images,
        transforms,
        new_token_ids,
        timestep=0,
        is_video: bool = False,
    ):
        """Lance-native ``prepare_vae_images`` with 3-D mRoPE positions.

        Mirrors :meth:`Bagel.prepare_vae_images` but emits per-token mRoPE
        ``(curr + t, curr + h, curr + w)`` positions for VAE latent tokens
        (BAGEL emits scalar ``curr_position_id`` for all of them, which
        triggers a CUDA index-out-of-bounds gather on Lance's Qwen2.5-VL
        backbone).  For still images ``t = 0``; for video clips ``t``
        ranges over ``T_lat = (T - 1) // downsample_temporal + 1``.
        """
        downsample_t = int(getattr(self.config.vae_config, "downsample_temporal", 4))
        max_per_side = int(self.max_latent_size)

        packed_text_ids: list[int] = []
        packed_text_indexes: list[int] = []
        packed_vae_position_ids: list[torch.Tensor] = []
        packed_vae_token_indexes: list[int] = []
        packed_indexes: list[int] = []
        packed_key_value_indexes: list[int] = []
        packed_seqlens: list[int] = []
        patchified_vae_latent_shapes: list[tuple] = []
        vae_image_tensors: list[torch.Tensor] = []
        per_axis_pos: list[tuple[int, int, int]] = []

        query_curr = curr = 0
        newlens: list[int] = []
        new_rope: list[int] = []
        for image, curr_kvlen, curr_position_id in zip(images, curr_kvlens, curr_rope):
            packed_key_value_indexes.extend(range(curr, curr + curr_kvlen))
            curr += curr_kvlen

            packed_text_ids.append(new_token_ids["start_of_image"])
            packed_text_indexes.append(query_curr)
            packed_indexes.append(curr)
            curr += 1
            query_curr += 1
            per_axis_pos.append((curr_position_id, curr_position_id, curr_position_id))

            image_tensor = transforms(image)
            vae_image_tensors.append(image_tensor)
            # ``image_tensor`` is ``(C, H, W)`` for image input or ``(C, T, H, W)``
            # for video input.  We derive the *latent* grid from the RGB spatial
            # dims and our Wan2.2 VAE downsample factor.
            if image_tensor.dim() == 3:
                H, W = image_tensor.shape[1:]
                T = 1
            elif image_tensor.dim() == 4:
                _, T, H, W = image_tensor.shape
            else:
                raise ValueError(f"vae transforms must return 3-D or 4-D tensor; got {image_tensor.shape}")
            h_lat = H // self.latent_downsample
            w_lat = W // self.latent_downsample
            t_lat = ((T - 1) // downsample_t + 1) if is_video else 1
            patchified_vae_latent_shapes.append((t_lat, h_lat, w_lat) if is_video else (h_lat, w_lat))

            # 1-D index into the (3-D when video) ``latent_pos_embed`` table.
            tt, hh, ww = torch.meshgrid(torch.arange(t_lat), torch.arange(h_lat), torch.arange(w_lat), indexing="ij")
            vae_pos_ids = (tt * (max_per_side * max_per_side) + hh * max_per_side + ww).flatten()
            packed_vae_position_ids.append(vae_pos_ids)

            num_image_tokens = t_lat * h_lat * w_lat
            packed_vae_token_indexes.extend(range(query_curr, query_curr + num_image_tokens))
            packed_indexes.extend(range(curr, curr + num_image_tokens))
            curr += num_image_tokens
            query_curr += num_image_tokens
            # Upstream Lance places VAE-ref body at ``(P+1+t*t_scale, P+1+h, P+1+w)``
            # with the start marker at ``(P, P, P)`` and the end marker at
            # ``(P+max+1, ...)``; gen-latent tokens (noise) share the SAME
            # rope range via the modality_map==1/2 trick, so this is the
            # convention used by :meth:`_per_token_mrope_for_vae_latent` (2D)
            # and :meth:`_per_token_mrope_for_video_latent` (3D) below.
            # For video, ``t_scale = LANCE_TOKENS_PER_SECOND * LANCE_SECONDS_PER_GRID``
            # matches upstream Qwen2.5-VL's temporal-axis scaling; for still
            # images we leave the t-channel scalar (T=1 → ``ti=0`` anyway).
            t_scale = LANCE_TOKENS_PER_SECOND * LANCE_SECONDS_PER_GRID if is_video else 1
            max_thw = max(int((t_lat - 1) * t_scale), h_lat - 1, w_lat - 1) + 1
            for ti, hi, wi in zip(tt.flatten().tolist(), hh.flatten().tolist(), ww.flatten().tolist()):
                per_axis_pos.append(
                    (
                        curr_position_id + 1 + int(ti * t_scale),
                        curr_position_id + 1 + hi,
                        curr_position_id + 1 + wi,
                    )
                )

            packed_text_ids.append(new_token_ids["end_of_image"])
            packed_text_indexes.append(query_curr)
            packed_indexes.append(curr)
            curr += 1
            query_curr += 1
            end_p = curr_position_id + max_thw + 1
            per_axis_pos.append((end_p, end_p, end_p))

            packed_seqlens.append(num_image_tokens + 2)
            newlens.append(curr_kvlen + num_image_tokens + 2)
            new_rope.append(end_p + 1)

        image_sizes = [item.shape for item in vae_image_tensors]
        max_image_size = [max(item) for item in list(zip(*image_sizes))]
        padded_images = torch.zeros(size=(len(vae_image_tensors), *max_image_size))
        for i, image_tensor in enumerate(vae_image_tensors):
            if image_tensor.dim() == 3:
                padded_images[i, :, : image_tensor.shape[1], : image_tensor.shape[2]] = image_tensor
            else:
                padded_images[i, :, : image_tensor.shape[1], : image_tensor.shape[2], : image_tensor.shape[3]] = (
                    image_tensor
                )

        pos_t = torch.tensor([p[0] for p in per_axis_pos], dtype=torch.long)
        pos_h = torch.tensor([p[1] for p in per_axis_pos], dtype=torch.long)
        pos_w = torch.tensor([p[2] for p in per_axis_pos], dtype=torch.long)
        packed_position_ids_3d = torch.stack([pos_t, pos_h, pos_w], dim=0)

        generation_input = {
            "padded_images": padded_images,
            "patchified_vae_latent_shapes": patchified_vae_latent_shapes,
            "packed_vae_position_ids": torch.cat(packed_vae_position_ids, dim=0),
            "packed_timesteps": torch.tensor([timestep]),
            "packed_vae_token_indexes": torch.tensor(packed_vae_token_indexes, dtype=torch.long),
            "packed_text_ids": torch.tensor(packed_text_ids, dtype=torch.long),
            "packed_text_indexes": torch.tensor(packed_text_indexes, dtype=torch.long),
            "packed_position_ids": packed_position_ids_3d,
            "packed_seqlens": torch.tensor(packed_seqlens, dtype=torch.int),
            "packed_indexes": torch.tensor(packed_indexes, dtype=torch.long),
            "packed_key_value_indexes": torch.tensor(packed_key_value_indexes, dtype=torch.long),
            "key_values_lens": torch.tensor(curr_kvlens, dtype=torch.int),
        }
        return generation_input, newlens, new_rope

    def prepare_vae_images(self, curr_kvlens, curr_rope, images, transforms, new_token_ids, timestep=0):
        """VAE prefill router.

        - When ``images`` is non-empty (image_edit / video_edit path on the
          image side): delegate to :meth:`_lance_native_prepare_vae_images`
          which emits Lance's 3-D mRoPE positions and a real VAE prefill,
          letting BAGEL's parent ``image_edit`` flow handle the rest.
        - When ``images`` is empty (t2i / x2t paths): short-circuit with the
          no-op output, mirroring BAGEL's "no image to prefill" sentinel.
        """
        if images:
            return self._lance_native_prepare_vae_images(
                curr_kvlens=curr_kvlens,
                curr_rope=curr_rope,
                images=images,
                transforms=transforms,
                new_token_ids=new_token_ids,
                timestep=timestep,
                is_video=False,
            )
        generation_input = {
            "padded_images": torch.empty(0, 3, 0, 0),
            "patchified_vae_latent_shapes": [],
            "packed_vae_position_ids": torch.empty(0, dtype=torch.long),
            "packed_timesteps": torch.tensor([timestep]),
            "packed_vae_token_indexes": torch.empty(0, dtype=torch.long),
            "packed_text_ids": torch.empty(0, dtype=torch.long),
            "packed_text_indexes": torch.empty(0, dtype=torch.long),
            "packed_position_ids": torch.empty(0, dtype=torch.long),
            "packed_seqlens": torch.empty(0, dtype=torch.int),
            "packed_indexes": torch.empty(0, dtype=torch.long),
            "packed_key_value_indexes": torch.empty(0, dtype=torch.long),
            "key_values_lens": torch.tensor(curr_kvlens, dtype=torch.int),
        }
        return generation_input, list(curr_kvlens), list(curr_rope)

    def forward_cache_update_vae(
        self,
        vae_model,
        past_key_values,
        padded_images=None,
        patchified_vae_latent_shapes=None,
        packed_vae_position_ids=None,
        packed_timesteps=None,
        packed_vae_token_indexes=None,
        packed_text_ids=None,
        packed_text_indexes=None,
        packed_position_ids=None,
        packed_seqlens=None,
        packed_indexes=None,
        key_values_lens=None,
        packed_key_value_indexes=None,
        precomputed_latent=None,
    ):
        """Lance-native VAE prefill that *actually* scatters the encoded
        latents into the LLM query sequence.

        :meth:`Bagel.forward_cache_update_vae` in vllm-omni computes
        ``packed_latent = vae2llm(...) + time_embed + pos_embed`` and then
        passes only ``packed_text_ids`` to the LLM — the VAE embeddings
        never enter the query sequence (the LLM builds it from
        ``embed_tokens(packed_text_ids)`` which is just the 2 framing
        tokens).  The mismatch between ``query_lens = [num_vae + 2]`` and
        the resulting 2-token sequence is what crashes the gather inside
        attention.

        We scatter both pieces explicitly: text framing tokens at
        ``packed_text_indexes`` and the VAE latent embeddings at
        ``packed_vae_token_indexes``, producing a full-length
        ``(sum(packed_seqlens), hidden)`` sequence the LLM can attend
        over.  Empty prep data (legacy x2t / x2t_video no-op path) is
        short-circuited.
        """
        if (
            packed_text_ids is None
            or packed_text_ids.numel() == 0
            or (precomputed_latent is None and (padded_images is None or padded_images.numel() == 0))
        ):
            return past_key_values

        # ``vae_model.encode`` samples from the posterior via
        # ``mu + std * randn_like(std)`` (Wan2.2 VAE's ``reparameterize`` path
        # — both upstream and vllm-omni default to ``use_sample=True``).  Each
        # call therefore returns a DIFFERENT latent.  Upstream's image_edit
        # encodes the reference image ONCE and reuses the resulting latent
        # for both the cond and cfg_text branches; calling encode separately
        # per branch made vllm-omni's gen vs cfg VAE-ref K cache diverge by
        # ~6% rel_l2, which cascaded into ~33% v_t divergence at iter 0 on
        # the cond branch (cfg matched within bf16 noise because it sees the
        # SAME ref VAE K in both implementations — only gen-vs-cfg drift
        # matters for the qualitative edit, since the noise query attends to
        # both branches' caches at every iter).  The pipeline now encodes
        # the ref once and threads the encoded latent into both prefill
        # calls via ``precomputed_latent``.
        if precomputed_latent is not None:
            padded_latent = precomputed_latent
        else:
            padded_latent = vae_model.encode(padded_images)
        p = self.latent_patch_size
        packed_latent_list = []
        for latent, shape in zip(padded_latent, patchified_vae_latent_shapes):
            if isinstance(shape, tuple) and len(shape) == 3:
                t_lat, h_lat, w_lat = shape
                latent = latent[:, :t_lat, : h_lat * p, : w_lat * p].reshape(
                    self.latent_channel, t_lat, h_lat, p, w_lat, p
                )
                latent = torch.einsum("cthpwq->thwpqc", latent).reshape(-1, p * p * self.latent_channel)
            else:
                h_lat, w_lat = shape
                latent = latent[:, : h_lat * p, : w_lat * p].reshape(self.latent_channel, h_lat, p, w_lat, p)
                latent = torch.einsum("chpwq->hwpqc", latent).reshape(-1, p * p * self.latent_channel)
            packed_latent_list.append(latent)
        packed_latent = torch.cat(packed_latent_list, dim=0)

        packed_pos_embed = self.latent_pos_embed(packed_vae_position_ids)
        packed_timestep_embeds = self.time_embedder(packed_timesteps)
        packed_latent = self.vae2llm(packed_latent) + packed_timestep_embeds + packed_pos_embed

        # ---- Scatter text + VAE embeds into the full query sequence ---- #
        total_len = int(packed_seqlens.sum().item())
        packed_text_embed = self.language_model.model.embed_tokens(packed_text_ids)
        packed_query_sequence = packed_latent.new_zeros((total_len, packed_latent.shape[-1]))
        packed_query_sequence[packed_text_indexes] = packed_text_embed.to(packed_query_sequence.dtype)
        packed_query_sequence[packed_vae_token_indexes] = packed_latent.to(packed_query_sequence.dtype)

        # Upstream Lance routes the VAE-ref block through MoE_GEN because
        # ``packed_gen_token_indexes`` in ``Lance.validation_gen_KVcache``
        # is set to ``current_vae_token_indexes_local`` — i.e., it includes
        # BOTH ref VAE tokens (modality=2 ref_source) AND noise tokens
        # (modality=1).  For the VAE-ref segment forward, the mask
        # ``packed_gen_token_indexes ∈ [current_cond_start, current_cond_end)``
        # filters in the VAE-ref body tokens → ``mode_ = "gen"``.
        extra_inputs = {}
        if self.use_moe:
            extra_inputs = {
                "mode": "gen",
                "packed_vae_token_indexes": packed_vae_token_indexes,
                "packed_text_indexes": packed_text_indexes,
            }

        # ``Qwen2MoTForCausalLM.forward`` post-main-merge no longer accepts
        # ``packed_query_indexes`` / ``key_values_lens`` /
        # ``packed_key_value_indexes`` — derived internally now.
        output = self.language_model.forward(
            packed_query_sequence=packed_query_sequence,
            query_lens=packed_seqlens,
            packed_query_position_ids=packed_position_ids,
            past_key_values=past_key_values,
            update_past_key_values=True,
            is_causal=False,
            **extra_inputs,
        )
        return output.past_key_values
