Signed-off-by: linzhenpl07 <linzhenpl07@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1861 lines
86 KiB
Python
1861 lines
86 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
"""Request-scoped LingBot-World v2 causal DMD pipeline."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import os
|
|
from collections import OrderedDict
|
|
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
from contextlib import contextmanager, nullcontext
|
|
from dataclasses import dataclass, replace
|
|
from typing import TYPE_CHECKING, Any, ClassVar, cast
|
|
|
|
import numpy as np
|
|
import PIL.Image
|
|
import torch
|
|
import torch.nn.functional as F
|
|
from diffusers.models.autoencoders.autoencoder_kl_wan import CACHE_T, WanAttentionBlock, WanResample, WanResidualBlock
|
|
from diffusers.utils.torch_utils import randn_tensor
|
|
from torch import nn
|
|
from transformers import AutoTokenizer, UMT5EncoderModel
|
|
from vllm.logger import init_logger
|
|
from vllm.model_executor.models.utils import AutoWeightsLoader
|
|
|
|
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
|
|
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import DistributedAutoencoderKLWan
|
|
from vllm_omni.diffusion.distributed.utils import get_local_device
|
|
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
|
|
from vllm_omni.diffusion.model_loader.hub_prefetch import from_pretrained_with_prefetch, prefetch_subfolders
|
|
from vllm_omni.diffusion.models.interface import SupportImageInput, SupportsComponentDiscovery, SupportsStepExecution
|
|
from vllm_omni.diffusion.models.lingbot_world.actions import (
|
|
LINGBOT_CAMERA_ACTION_SCHEMA,
|
|
LINGBOT_CAMERA_TRAJECTORY_SCHEMA,
|
|
LingBotCameraActionFrames,
|
|
LingBotCameraActionScript,
|
|
as_camera_action_frames,
|
|
as_camera_action_script,
|
|
integrate_lingbot_camera_actions,
|
|
parse_lingbot_camera_action_frames,
|
|
parse_lingbot_camera_action_script,
|
|
)
|
|
from vllm_omni.diffusion.models.lingbot_world.camera import (
|
|
CameraTrajectory,
|
|
build_plucker_embedding,
|
|
interpolate_camera_trajectory,
|
|
load_camera_trajectory,
|
|
resolve_trusted_action_directory,
|
|
)
|
|
from vllm_omni.diffusion.models.lingbot_world.dmd_block import ARBlockContext, LingBotDMDBlockRunner
|
|
from vllm_omni.diffusion.models.lingbot_world.transformer import (
|
|
CausalLingBotWorldTransformer3DModel,
|
|
LingBotAttentionCache,
|
|
LingBotTransformerCache,
|
|
)
|
|
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin
|
|
from vllm_omni.diffusion.models.schedulers import FlowUniPCMultistepScheduler
|
|
from vllm_omni.diffusion.models.utils import _load_json
|
|
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import load_transformer_config, retrieve_latents
|
|
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin
|
|
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
|
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
|
from vllm_omni.experimental.ar_diffusion.capability import (
|
|
ARDiffusionCrossAttentionKVSpec,
|
|
ARDiffusionKVBranchSpec,
|
|
ARDiffusionKVCacheSpec,
|
|
)
|
|
from vllm_omni.experimental.ar_diffusion.streaming_decode import (
|
|
StreamingDecodeState,
|
|
WanStreamingDecoder,
|
|
)
|
|
from vllm_omni.experimental.ar_diffusion.tick_protocol import (
|
|
ARDiffusionChunkMetadata,
|
|
ARDiffusionTickRequest,
|
|
)
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
# Resident streaming-decoder cache per session, as bytes per output pixel in
|
|
# fp32: measured once on the reference checkpoint at 37832 KiB for a 64x64
|
|
# output. Every cached tensor is [B, C, <= CACHE_T, H/8, W/8] per causal
|
|
# convolution, so the total scales with height * width and never with session
|
|
# length; tests/diffusion/ar_diffusion/test_streaming_decode.py pins that
|
|
# scaling, and at 832x480 in bf16 this yields the 1801 MiB the decoder was
|
|
# measured to hold.
|
|
_STREAMING_DECODE_BYTES_PER_PIXEL_FP32 = 37832 * 1024 / (64 * 64)
|
|
|
|
if TYPE_CHECKING:
|
|
from diffusers.video_processor import VideoProcessor
|
|
from tqdm.std import tqdm as TqdmProgressBar
|
|
|
|
from vllm_omni.diffusion.worker.input_batch import InputBatch
|
|
from vllm_omni.diffusion.worker.utils import StepRequestState
|
|
from vllm_omni.experimental.ar_diffusion.kv_cache.state import (
|
|
ARDiffusionKVState,
|
|
)
|
|
|
|
LINGBOT_DMD_TIMESTEPS = (1000, 750, 500, 250)
|
|
_CAMERA_SPATIAL_FOLD = 8
|
|
_MAX_PIXEL_AREA = 480 * 832
|
|
_MAX_SOURCE_IMAGE_PIXELS = 4096 * 4096
|
|
_MAX_SEQUENCE_LENGTH = 512
|
|
# Distinct prompts whose text encodes are kept. One entry is a single padded
|
|
# sequence -- a few MiB at 512 tokens -- so a handful covers a session's prompt
|
|
# plus the switches it makes, at no meaningful cost next to its KV.
|
|
_PROMPT_EMBEDS_CACHE_SIZE = 4
|
|
_ACTION_ROOT_ENV = "VLLM_OMNI_LINGBOT_ACTION_ROOT"
|
|
_PREPROCESSED_CAMERA_KEY = "_lingbot_camera_trajectory"
|
|
_PREPROCESSED_CAMERA_ACTIONS_KEY = "_lingbot_camera_actions"
|
|
_PREPROCESSED_CAMERA_ACTION_SCRIPT_KEY = "_lingbot_camera_action_script"
|
|
_SOURCE_IMAGE_ERROR = (
|
|
"Unable to load multi_modal_data.image; expected a decodable image within 4096 * 4096 source pixels."
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _LingBotRequestInputs:
|
|
"""Validated model-specific request boundary.
|
|
|
|
Pre-processing materializes exactly one of the three camera fields, chosen
|
|
from the shape of the incoming control. They are not alternative spellings
|
|
of one input: ``camera_action_script`` is a *container* whose element is a
|
|
``camera_actions``, and the two are live at once during step execution.
|
|
|
|
- ``camera_trajectory`` is explicit per-frame poses and intrinsics. Offline
|
|
replay loads it from ``extra_args["action_path"]``; the tick entry point
|
|
receives the same content as a ``lingbot.camera_trajectory.v1`` control.
|
|
Integrating WASD actions also produces one, written back onto this field.
|
|
- ``camera_actions`` is WASD key state for *one* three-latent-frame block:
|
|
the unit every path reduces to before the camera is embedded. The tick
|
|
entry point fills it directly, because one of its requests is one block;
|
|
step execution derives it per block by slicing ``camera_action_script``.
|
|
``_prepare_camera`` also reads it as the signal that the trajectory it
|
|
was handed is already latent-aligned.
|
|
- ``camera_action_script`` holds one such action list *per generated chunk*
|
|
for the whole rollout, which only step execution needs, because only it
|
|
runs every block inside a single ``generate()``. The tick entry point has
|
|
no use for it and ``forward()`` rejects a request that carries it.
|
|
"""
|
|
|
|
prompt: str
|
|
image: PIL.Image.Image | torch.Tensor
|
|
camera_trajectory: CameraTrajectory | None
|
|
camera_actions: LingBotCameraActionFrames | None
|
|
camera_action_script: LingBotCameraActionScript | None
|
|
height: int
|
|
width: int
|
|
num_frames: int
|
|
num_latent_frames: int
|
|
output_type: str
|
|
max_sequence_length: int
|
|
flow_shift: float
|
|
generator: torch.Generator
|
|
|
|
|
|
@dataclass
|
|
class _LingBotARSessionState:
|
|
"""Model-owned state; attention tensors remain runner-owned."""
|
|
|
|
next_chunk_index: int = 0
|
|
prompt: str | None = None
|
|
generator_state: torch.Tensor | None = None
|
|
encoder_cache: list[torch.Tensor | None] | None = None
|
|
pending_encoder_cache: list[torch.Tensor | None] | None = None
|
|
camera_tail: CameraTrajectory | None = None
|
|
camera_pitch: float = 0.0
|
|
|
|
|
|
def _positive_finite_flow_shift(value: Any) -> float:
|
|
if isinstance(value, bool):
|
|
raise ValueError("flow_shift must be a positive finite number.")
|
|
try:
|
|
flow_shift = float(value)
|
|
except (TypeError, ValueError, OverflowError) as exc:
|
|
raise ValueError("flow_shift must be a positive finite number.") from exc
|
|
if not math.isfinite(flow_shift) or flow_shift <= 0:
|
|
raise ValueError("flow_shift must be a positive finite number.")
|
|
return flow_shift
|
|
|
|
|
|
def _build_shifted_flow_schedule(
|
|
*,
|
|
flow_shift: float,
|
|
) -> tuple[tuple[float, float], ...]:
|
|
"""Map checkpoint DMD labels to request-local warped timestep/sigma pairs."""
|
|
|
|
# The checkpoint labels index the unshifted [1, ..., 1 / N] training
|
|
# lattice. Apply the request's flow shift before using the value both as
|
|
# the transformer timestep and the sampling sigma.
|
|
base_sigmas = torch.tensor(LINGBOT_DMD_TIMESTEPS, dtype=torch.float64) / 1000
|
|
shifted_numerators = flow_shift * base_sigmas
|
|
shifted_sigmas = shifted_numerators / ((1.0 - base_sigmas) + shifted_numerators)
|
|
warped_timesteps = shifted_sigmas * 1000
|
|
return tuple(
|
|
(float(timestep), float(sigma))
|
|
for timestep, sigma in zip(warped_timesteps.tolist(), shifted_sigmas.tolist(), strict=True)
|
|
)
|
|
|
|
|
|
def _validate_scheduler_config(config: dict[str, Any]) -> None:
|
|
contract = {
|
|
"_class_name": "UniPCMultistepScheduler",
|
|
"num_train_timesteps": 1000,
|
|
"prediction_type": "flow_prediction",
|
|
"predict_x0": True,
|
|
"use_flow_sigmas": True,
|
|
"use_dynamic_shifting": False,
|
|
"use_beta_sigmas": False,
|
|
"use_exponential_sigmas": False,
|
|
"use_karras_sigmas": False,
|
|
"final_sigmas_type": "zero",
|
|
"timestep_spacing": "linspace",
|
|
}
|
|
for name, expected in contract.items():
|
|
if config.get(name) != expected:
|
|
raise ValueError(f"LingBot scheduler config {name} must be {expected!r}, got {config.get(name)!r}.")
|
|
|
|
|
|
def _validate_parallel_config(od_config: OmniDiffusionConfig) -> None:
|
|
if getattr(od_config, "quantization_config", None) is not None:
|
|
raise NotImplementedError("LingBot World v1 does not support quantization.")
|
|
parallel_config = getattr(od_config, "parallel_config", None)
|
|
if parallel_config is None:
|
|
return
|
|
unsupported_sizes = {
|
|
"pipeline_parallel_size": "pipeline parallelism",
|
|
"cfg_parallel_size": "CFG parallelism",
|
|
"vae_patch_parallel_size": "VAE parallelism",
|
|
}
|
|
for field, feature in unsupported_sizes.items():
|
|
size = getattr(parallel_config, field, 1) or 1
|
|
if size > 1:
|
|
raise NotImplementedError(f"LingBot World v1 does not support {feature} ({field}={size}).")
|
|
sequence_parallel_size = getattr(parallel_config, "sequence_parallel_size", 1) or 1
|
|
ulysses_degree = getattr(parallel_config, "ulysses_degree", 1) or 1
|
|
ring_degree = getattr(parallel_config, "ring_degree", 1) or 1
|
|
allgather_degree = getattr(parallel_config, "allgather_degree", 1) or 1
|
|
ulysses_mode = getattr(parallel_config, "ulysses_mode", "strict")
|
|
ulysses_a2a_permute = bool(getattr(parallel_config, "ulysses_a2a_permute", False))
|
|
if (
|
|
sequence_parallel_size != ulysses_degree
|
|
or ring_degree != 1
|
|
or allgather_degree != 1
|
|
or ulysses_mode != "strict"
|
|
or ulysses_a2a_permute
|
|
):
|
|
raise NotImplementedError(
|
|
"LingBot World sequence parallelism requires pure Ulysses with ulysses_a2a_permute disabled: "
|
|
f"sequence_parallel_size={sequence_parallel_size}, ulysses_degree={ulysses_degree}, "
|
|
f"ring_degree={ring_degree}, allgather_degree={allgather_degree}, ulysses_mode={ulysses_mode!r}, "
|
|
f"ulysses_a2a_permute={ulysses_a2a_permute}."
|
|
)
|
|
if getattr(parallel_config, "use_hsdp", False):
|
|
raise NotImplementedError("LingBot World v1 does not support HSDP.")
|
|
if getattr(parallel_config, "enable_expert_parallel", False):
|
|
raise NotImplementedError("LingBot World v1 does not support expert parallelism.")
|
|
|
|
|
|
def _validate_source_image_size(image: PIL.Image.Image) -> None:
|
|
width, height = image.size
|
|
if (
|
|
isinstance(width, bool)
|
|
or not isinstance(width, int)
|
|
or width <= 0
|
|
or isinstance(height, bool)
|
|
or not isinstance(height, int)
|
|
or height <= 0
|
|
):
|
|
raise ValueError("source image width and height must be positive integers.")
|
|
if width * height > _MAX_SOURCE_IMAGE_PIXELS:
|
|
raise ValueError("source image pixel count must not exceed 4096 * 4096.")
|
|
|
|
|
|
def _decode_source_image(image: PIL.Image.Image) -> PIL.Image.Image:
|
|
_validate_source_image_size(image)
|
|
try:
|
|
return image.convert("RGB")
|
|
except (OSError, SyntaxError, ValueError, PIL.Image.DecompressionBombError):
|
|
raise ValueError(_SOURCE_IMAGE_ERROR) from None
|
|
|
|
|
|
def _load_source_image(path: str | os.PathLike[str]) -> PIL.Image.Image:
|
|
try:
|
|
source_image = PIL.Image.open(path)
|
|
except (OSError, SyntaxError, ValueError, PIL.Image.DecompressionBombError):
|
|
raise ValueError(_SOURCE_IMAGE_ERROR) from None
|
|
try:
|
|
return _decode_source_image(source_image)
|
|
finally:
|
|
source_image.close()
|
|
|
|
|
|
def get_lingbot_world_pre_process_func(
|
|
od_config: OmniDiffusionConfig,
|
|
) -> Callable[[OmniDiffusionRequest], OmniDiffusionRequest]:
|
|
"""Materialize request-local files once before dispatching GPU workers."""
|
|
|
|
model_config = getattr(od_config, "model_config", None) or {}
|
|
configured_action_root = model_config.get("lingbot_action_root") or os.environ.get(_ACTION_ROOT_ENV)
|
|
|
|
def pre_process_func(request: OmniDiffusionRequest) -> OmniDiffusionRequest:
|
|
prompt = request.prompt
|
|
if not isinstance(prompt, dict):
|
|
raise ValueError("LingBot World requires a prompt mapping containing multi_modal_data.image.")
|
|
multi_modal_data = prompt.get("multi_modal_data") or {}
|
|
additional_information = prompt.get("additional_information") or {}
|
|
if not isinstance(multi_modal_data, dict):
|
|
raise ValueError("prompt.multi_modal_data must be a mapping containing image.")
|
|
if not isinstance(additional_information, dict):
|
|
raise ValueError("prompt.additional_information must be a mapping.")
|
|
if "action_path" in additional_information:
|
|
raise ValueError(
|
|
"prompt.additional_information.action_path is not supported; use "
|
|
"sampling_params.extra_args.action_path."
|
|
)
|
|
|
|
image = multi_modal_data.get("image")
|
|
if isinstance(image, list):
|
|
raise ValueError("LingBot World requires one image and does not accept an image list.")
|
|
if image is None:
|
|
raise ValueError("LingBot World requires exactly one image in multi_modal_data.image.")
|
|
if isinstance(image, (str, os.PathLike)):
|
|
image = _load_source_image(image)
|
|
elif isinstance(image, PIL.Image.Image):
|
|
image = _decode_source_image(image)
|
|
elif not isinstance(image, torch.Tensor):
|
|
raise ValueError("multi_modal_data.image must be a PIL image, tensor, or file path.")
|
|
|
|
extra_args = getattr(request.sampling_params, "extra_args", None) or {}
|
|
if not isinstance(extra_args, dict):
|
|
raise ValueError("sampling_params.extra_args must be a mapping.")
|
|
tick = ARDiffusionTickRequest.from_extra_args(extra_args)
|
|
if tick is not None:
|
|
camera_controls = [control for control in tick.controls if control.track == "camera"]
|
|
if len(camera_controls) != 1:
|
|
raise ValueError("LingBot realtime ticks require exactly one camera control.")
|
|
camera_control = camera_controls[0]
|
|
if camera_control.schema == LINGBOT_CAMERA_TRAJECTORY_SCHEMA:
|
|
camera_data = camera_control.data
|
|
try:
|
|
poses = torch.as_tensor(camera_data["poses"], dtype=torch.float32)
|
|
intrinsics = torch.as_tensor(
|
|
camera_data["intrinsics"],
|
|
dtype=torch.float32,
|
|
)
|
|
except (KeyError, TypeError, ValueError):
|
|
raise ValueError(
|
|
"lingbot.camera_trajectory.v1 requires numeric poses and intrinsics fields."
|
|
) from None
|
|
if poses.ndim != 3 or poses.shape[1:] != (4, 4):
|
|
raise ValueError("camera control poses must have shape [frames, 4, 4].")
|
|
if intrinsics.ndim != 2 or intrinsics.shape[1:] != (4,):
|
|
raise ValueError("camera control intrinsics must have shape [frames, 4].")
|
|
if poses.shape[0] != intrinsics.shape[0] or poses.shape[0] == 0:
|
|
raise ValueError(
|
|
"camera control poses and intrinsics must contain the same positive number of frames."
|
|
)
|
|
if not torch.isfinite(poses).all() or not torch.isfinite(intrinsics).all():
|
|
raise ValueError("camera control values must all be finite.")
|
|
trajectory = CameraTrajectory(
|
|
poses=poses,
|
|
intrinsics=intrinsics,
|
|
)
|
|
camera_actions = None
|
|
elif camera_control.schema == LINGBOT_CAMERA_ACTION_SCHEMA:
|
|
trajectory = None
|
|
camera_actions = parse_lingbot_camera_action_frames(
|
|
camera_control.data,
|
|
expected_frames=3,
|
|
)
|
|
else:
|
|
raise ValueError(
|
|
"LingBot realtime camera controls require schema "
|
|
"'lingbot.camera_trajectory.v1' or 'lingbot.camera_actions.v1'."
|
|
)
|
|
camera_action_script = None
|
|
elif extra_args.get("camera_action_script") is not None:
|
|
if extra_args.get("action_path"):
|
|
raise ValueError("camera_action_script cannot be combined with action_path.")
|
|
camera_action_script = parse_lingbot_camera_action_script(
|
|
extra_args.get("camera_action_script"),
|
|
frames_per_chunk=3,
|
|
)
|
|
trajectory = None
|
|
camera_actions = None
|
|
else:
|
|
action_path = extra_args.get("action_path")
|
|
if not isinstance(action_path, (str, os.PathLike)) or not str(action_path):
|
|
raise ValueError("action_path is required in sampling_params.extra_args.action_path.")
|
|
if not configured_action_root:
|
|
raise ValueError(
|
|
"sampling_params.extra_args.action_path requires a trusted action root configured by "
|
|
f"model_config.lingbot_action_root or {_ACTION_ROOT_ENV}."
|
|
)
|
|
action_directory = resolve_trusted_action_directory(
|
|
action_path,
|
|
configured_action_root,
|
|
)
|
|
try:
|
|
trajectory = load_camera_trajectory(action_directory)
|
|
except OSError:
|
|
raise ValueError(
|
|
"Unable to load camera trajectory from action_path; expected poses.npy and intrinsics.npy."
|
|
) from None
|
|
camera_actions = None
|
|
camera_action_script = None
|
|
|
|
updated_prompt = dict(prompt)
|
|
updated_multi_modal_data = dict(multi_modal_data)
|
|
updated_multi_modal_data["image"] = image
|
|
updated_prompt["multi_modal_data"] = updated_multi_modal_data
|
|
request.prompt = updated_prompt
|
|
request.sampling_params.extra_args = {
|
|
**extra_args,
|
|
_PREPROCESSED_CAMERA_KEY: trajectory,
|
|
_PREPROCESSED_CAMERA_ACTIONS_KEY: camera_actions,
|
|
_PREPROCESSED_CAMERA_ACTION_SCRIPT_KEY: camera_action_script,
|
|
}
|
|
return request
|
|
|
|
return pre_process_func
|
|
|
|
|
|
def _fold_camera_embedding(
|
|
camera_embedding: torch.Tensor,
|
|
) -> torch.Tensor:
|
|
"""Pixel-unshuffle ``[frames, 6, H, W]`` onto the Wan latent grid."""
|
|
|
|
folded = F.pixel_unshuffle(camera_embedding, _CAMERA_SPATIAL_FOLD)
|
|
return folded.permute(1, 0, 2, 3).unsqueeze(0).contiguous()
|
|
|
|
|
|
def get_lingbot_world_post_process_func(od_config: OmniDiffusionConfig) -> Callable[..., Any]:
|
|
del od_config
|
|
from diffusers.video_processor import VideoProcessor
|
|
|
|
video_processor = VideoProcessor(vae_scale_factor=8)
|
|
|
|
def post_process_func(
|
|
video: torch.Tensor,
|
|
output_type: str = "np",
|
|
sampling_params: Any | None = None,
|
|
) -> Any:
|
|
if isinstance(video, dict) and isinstance(video.get("payload"), dict):
|
|
return video
|
|
if sampling_params is not None:
|
|
output_type = getattr(sampling_params, "output_type", None) or output_type
|
|
if output_type == "latent":
|
|
return video
|
|
return {
|
|
"payload": {"video": video_processor.postprocess_video(video, output_type=output_type)},
|
|
"metadata": {},
|
|
}
|
|
|
|
return post_process_func
|
|
|
|
|
|
class LingBotWorldCausalDMDPipeline(
|
|
nn.Module,
|
|
SupportImageInput,
|
|
SupportsComponentDiscovery,
|
|
SupportsStepExecution,
|
|
ProgressBarMixin,
|
|
DiffusionPipelineProfilerMixin,
|
|
):
|
|
"""LingBot-World v2 I2V generation with a request-local causal cache."""
|
|
|
|
supports_step_execution: ClassVar[bool] = True
|
|
_dit_modules: ClassVar[list[str]] = ["transformer"]
|
|
_encoder_modules: ClassVar[list[str]] = ["text_encoder"]
|
|
_vae_modules: ClassVar[list[str]] = ["vae"]
|
|
# Generic warmup cannot synthesize the required camera action directory.
|
|
dummy_run_num_frames: ClassVar[int] = 0
|
|
_AR_BRANCH = "main"
|
|
_AR_TEXT_CACHE = "text"
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
od_config: OmniDiffusionConfig,
|
|
prefix: str = "",
|
|
) -> None:
|
|
super().__init__()
|
|
del prefix
|
|
_validate_parallel_config(od_config)
|
|
self.od_config = od_config
|
|
self.device = get_local_device()
|
|
dtype = getattr(od_config, "dtype", torch.bfloat16)
|
|
model = od_config.model
|
|
local_files_only = os.path.exists(model)
|
|
managed_component_placement = bool(
|
|
getattr(od_config, "enable_cpu_offload", False) or getattr(od_config, "enable_layerwise_offload", False)
|
|
)
|
|
|
|
# Standard components use from_pretrained; the custom transformer uses the loader.
|
|
self.weights_sources = [
|
|
DiffusersPipelineLoader.ComponentSource(
|
|
model_or_path=model,
|
|
subfolder="transformer",
|
|
revision=None,
|
|
prefix="transformer.",
|
|
fall_back_to_pt=True,
|
|
)
|
|
]
|
|
|
|
subfolders = ["tokenizer", "text_encoder", "vae"]
|
|
prefetch_subfolders(model, subfolders, local_files_only=local_files_only)
|
|
self.tokenizer = from_pretrained_with_prefetch(
|
|
AutoTokenizer.from_pretrained,
|
|
model,
|
|
subfolder="tokenizer",
|
|
prefetch_list=subfolders,
|
|
local_files_only=local_files_only,
|
|
)
|
|
self.text_encoder = from_pretrained_with_prefetch(
|
|
UMT5EncoderModel.from_pretrained,
|
|
model,
|
|
subfolder="text_encoder",
|
|
prefetch_list=subfolders,
|
|
local_files_only=local_files_only,
|
|
torch_dtype=dtype,
|
|
)
|
|
if not managed_component_placement:
|
|
self.text_encoder = self.text_encoder.to(self.device)
|
|
self.vae = from_pretrained_with_prefetch(
|
|
DistributedAutoencoderKLWan.from_pretrained,
|
|
model,
|
|
subfolder="vae",
|
|
prefetch_list=subfolders,
|
|
local_files_only=local_files_only,
|
|
torch_dtype=dtype,
|
|
)
|
|
if not managed_component_placement:
|
|
self.vae = self.vae.to(self.device)
|
|
|
|
transformer_config = load_transformer_config(model, "transformer", local_files_only)
|
|
self.transformer = CausalLingBotWorldTransformer3DModel.from_config(
|
|
transformer_config,
|
|
quant_config=getattr(od_config, "quantization_config", None),
|
|
prefix="transformer",
|
|
)
|
|
|
|
scheduler_config = _load_json(model, "scheduler/scheduler_config.json", local_files_only)
|
|
_validate_scheduler_config(scheduler_config)
|
|
configured_shift = getattr(od_config, "flow_shift", None)
|
|
checkpoint_shift = scheduler_config.get("flow_shift", scheduler_config.get("shift", 5.0))
|
|
scheduler_shift = _positive_finite_flow_shift(
|
|
checkpoint_shift if configured_shift is None else configured_shift
|
|
)
|
|
scheduler_keys = {
|
|
"num_train_timesteps",
|
|
"solver_order",
|
|
"prediction_type",
|
|
"use_dynamic_shifting",
|
|
"thresholding",
|
|
"dynamic_thresholding_ratio",
|
|
"sample_max_value",
|
|
"predict_x0",
|
|
"solver_type",
|
|
"lower_order_final",
|
|
"disable_corrector",
|
|
"timestep_spacing",
|
|
"steps_offset",
|
|
"final_sigmas_type",
|
|
}
|
|
scheduler_kwargs = {name: value for name, value in scheduler_config.items() if name in scheduler_keys}
|
|
scheduler_kwargs["shift"] = scheduler_shift
|
|
self.scheduler = FlowUniPCMultistepScheduler(**scheduler_kwargs)
|
|
|
|
self.vae_scale_factor_temporal = int(getattr(self.vae.config, "scale_factor_temporal", 4))
|
|
self.vae_scale_factor_spatial = int(getattr(self.vae.config, "scale_factor_spatial", 8))
|
|
model_config = getattr(od_config, "model_config", None) or {}
|
|
self._ar_height = int(model_config.get("ar_diffusion_height", 480))
|
|
self._ar_width = int(model_config.get("ar_diffusion_width", 832))
|
|
self._ar_diffusion_kv_state: ARDiffusionKVState | None = None
|
|
self._ar_sessions: dict[str, _LingBotARSessionState] = {}
|
|
# Text encodes keyed by what they depend on; see encode_prompt.
|
|
self._prompt_embeds_cache: OrderedDict[tuple[str, int, torch.dtype], torch.Tensor] = OrderedDict()
|
|
# One temporal decoder cache per stepwise session, keyed the way the
|
|
# runner keys AR sessions (session_id == request_id) so both are
|
|
# released together rather than through two independent lifecycles.
|
|
self._streaming_decode_states: dict[str, StreamingDecodeState] = {}
|
|
self._cached_streaming_decoder: WanStreamingDecoder | None = None
|
|
self._streaming_decode_unsupported = False
|
|
self.setup_diffusion_pipeline_profiler(
|
|
profiler_targets=[
|
|
"vae.encode",
|
|
"_prepare_condition_chunk",
|
|
"vae.decode",
|
|
# Streaming decode never reaches vae.decode, so it needs its
|
|
# own stage or a streamed rollout reports no decode time at
|
|
# all -- the one stage this path changes.
|
|
"_streaming_decode_chunk",
|
|
"_generate_block",
|
|
"text_encoder.forward",
|
|
"tokenizer.forward",
|
|
],
|
|
enable_diffusion_pipeline_profiler=od_config.enable_diffusion_pipeline_profiler,
|
|
)
|
|
|
|
def ar_diffusion_kv_cache_spec(self) -> ARDiffusionKVCacheSpec:
|
|
"""Describe the fixed worker-local cache geometry for realtime ticks."""
|
|
patch_frames, patch_height, patch_width = self.transformer.config.patch_size
|
|
if patch_frames != 1:
|
|
raise ValueError("LingBot AR-Diffusion requires temporal patch size 1.")
|
|
spatial = self.vae_scale_factor_spatial
|
|
if self._ar_height % (spatial * patch_height) or self._ar_width % (spatial * patch_width):
|
|
raise ValueError("ar_diffusion_height/width must align with VAE and DiT patch sizes.")
|
|
latent_height = self._ar_height // spatial
|
|
latent_width = self._ar_width // spatial
|
|
tokens_per_frame = (latent_height // patch_height) * (latent_width // patch_width)
|
|
total_window_frames = (
|
|
int(self.transformer.config.local_attn_size)
|
|
if int(self.transformer.config.local_attn_size) != -1
|
|
else int(self.transformer.config.sliding_window_num_frames)
|
|
)
|
|
sink_frames = int(self.transformer.config.sink_size)
|
|
recent_window_frames = total_window_frames - sink_frames
|
|
if recent_window_frames <= 0:
|
|
raise ValueError("LingBot AR-Diffusion cache needs a positive recent window after reserving sink frames.")
|
|
condition_latent_frames = int(self.transformer.config.num_frames_per_block)
|
|
condition_channels = self.vae_scale_factor_temporal + int(self.transformer.config.out_channels)
|
|
condition_bytes_per_session = (
|
|
condition_channels
|
|
* condition_latent_frames
|
|
* latent_height
|
|
* latent_width
|
|
* torch.empty((), dtype=self.transformer.dtype).element_size()
|
|
)
|
|
return ARDiffusionKVCacheSpec(
|
|
num_layers=int(self.transformer.config.num_layers),
|
|
num_kv_heads=int(self.transformer.blocks[0].self_attn.num_sp_heads),
|
|
head_size=int(self.transformer.config.attention_head_dim),
|
|
tokens_per_frame=tokens_per_frame,
|
|
frames_per_block=int(self.transformer.config.num_frames_per_block),
|
|
window_frames=recent_window_frames,
|
|
sink_frames=sink_frames,
|
|
kv_branches=(ARDiffusionKVBranchSpec(self._AR_BRANCH, 0),),
|
|
session_capacity=2,
|
|
cross_attention=(
|
|
ARDiffusionCrossAttentionKVSpec(
|
|
self._AR_TEXT_CACHE,
|
|
_MAX_SEQUENCE_LENGTH,
|
|
),
|
|
),
|
|
model_owned_state_bytes_per_session=(
|
|
condition_bytes_per_session
|
|
+ 2 * self._condition_encoder_cache_bytes() # Committed and in-flight histories.
|
|
+ self._streaming_decode_bytes_per_session()
|
|
),
|
|
)
|
|
|
|
def _condition_encoder_cache_bytes(self) -> int:
|
|
"""Bound one Wan encoder history from its per-convolution input grids."""
|
|
if getattr(self.vae.config, "patch_size", None) is not None:
|
|
raise ValueError("Stateful LingBot conditioning requires an unpatched Wan VAE.")
|
|
encoder = self.vae.encoder
|
|
height, width = self._ar_height, self._ar_width
|
|
elements = CACHE_T * encoder.conv_in.in_channels * height * width
|
|
for layer in encoder.down_blocks:
|
|
if isinstance(layer, WanResidualBlock):
|
|
elements += CACHE_T * (layer.conv1.in_channels + layer.conv2.in_channels) * height * width
|
|
elif isinstance(layer, WanResample) and layer.mode in ("downsample2d", "downsample3d"):
|
|
height, width = height // 2, width // 2
|
|
if layer.mode == "downsample3d":
|
|
# Temporal downsampling retains one frame after spatial downsampling.
|
|
elements += layer.time_conv.in_channels * height * width
|
|
elif not isinstance(layer, WanAttentionBlock):
|
|
raise ValueError("Stateful LingBot conditioning requires the Wan encoder cache layout.")
|
|
for layer in encoder.mid_block.resnets:
|
|
elements += CACHE_T * (layer.conv1.in_channels + layer.conv2.in_channels) * height * width
|
|
elements += CACHE_T * encoder.conv_out.in_channels * height * width
|
|
return elements * torch.empty((), dtype=self.vae.dtype).element_size()
|
|
|
|
def _streaming_decode_bytes_per_session(self) -> int:
|
|
"""Resident streaming-decoder bytes one session can hold, for admission.
|
|
|
|
``model_owned_state_bytes_per_session`` is what the runner subtracts
|
|
from the KV budget before sizing the block pools, and this cache is
|
|
three orders of magnitude larger than the image condition that used to
|
|
be the only entry: leaving it out lets the profiler report a
|
|
configuration as fitting and then OOM partway through the second
|
|
session's rollout.
|
|
|
|
Declared for every session rather than only for pixel-output ones: the
|
|
field is documented as a conservative upper bound, it is fixed at load
|
|
time, and whether a request asks for pixels is not known then.
|
|
"""
|
|
decoder = self._streaming_decoder()
|
|
if decoder is None:
|
|
return 0
|
|
return decoder.declared_state_bytes(
|
|
height=self._ar_height,
|
|
width=self._ar_width,
|
|
dtype=self.vae.dtype,
|
|
)
|
|
|
|
@contextmanager
|
|
def bind_ar_diffusion_state(
|
|
self,
|
|
session_id: str,
|
|
state: ARDiffusionKVState,
|
|
) -> Iterator[None]:
|
|
if self._ar_diffusion_kv_state is not None:
|
|
raise RuntimeError("LingBot AR-Diffusion state is already bound.")
|
|
if state.session_id != session_id:
|
|
raise ValueError(f"LingBot bound session mismatch: {state.session_id!r} != {session_id!r}.")
|
|
self._ar_diffusion_kv_state = state
|
|
try:
|
|
yield
|
|
finally:
|
|
self._ar_diffusion_kv_state = None
|
|
|
|
def _release_condition_encoder_state(self, session_id: str) -> None:
|
|
state = self._ar_sessions.pop(session_id, None)
|
|
if state is not None:
|
|
state.encoder_cache = None
|
|
state.pending_encoder_cache = None
|
|
|
|
def reset_ar_diffusion_session(self, session_id: str) -> None:
|
|
self._release_condition_encoder_state(session_id)
|
|
self._release_streaming_decode_state(session_id)
|
|
|
|
def close_ar_diffusion_session(self, session_id: str) -> None:
|
|
self._release_condition_encoder_state(session_id)
|
|
self._release_streaming_decode_state(session_id)
|
|
|
|
def _parse_request(self, req: DiffusionRequestBatch) -> _LingBotRequestInputs:
|
|
if req.num_reqs != 1 or len(req.prompts) != 1:
|
|
raise ValueError("LingBot World supports a single prompt request, not request batching.")
|
|
sampling = req.sampling_params
|
|
if int(sampling.num_outputs_per_prompt or 1) != 1:
|
|
raise ValueError("LingBot World requires num_outputs_per_prompt=1.")
|
|
generator = getattr(sampling, "generator", None)
|
|
if isinstance(generator, list):
|
|
raise ValueError("LingBot World accepts one torch.Generator, not a generator list.")
|
|
if not isinstance(generator, torch.Generator):
|
|
raise ValueError("LingBot World requires the runner-provided torch.Generator.")
|
|
if getattr(sampling, "latents", None) is not None:
|
|
raise ValueError("LingBot World does not support caller-provided latents.")
|
|
|
|
prompt_value = req.prompts[0]
|
|
multi_modal_data: dict[str, Any]
|
|
if isinstance(prompt_value, str):
|
|
prompt = prompt_value
|
|
multi_modal_data = {}
|
|
elif isinstance(prompt_value, dict):
|
|
prompt = prompt_value.get("prompt") or ""
|
|
multi_modal_data = prompt_value.get("multi_modal_data") or {}
|
|
else:
|
|
raise ValueError("prompt must be a string or prompt mapping.")
|
|
if not isinstance(prompt, str) or not prompt.strip():
|
|
raise ValueError("prompt must contain non-empty text.")
|
|
if not isinstance(multi_modal_data, dict):
|
|
raise ValueError("prompt.multi_modal_data must be a mapping containing image.")
|
|
|
|
image = multi_modal_data.get("image")
|
|
if isinstance(image, list):
|
|
raise ValueError("LingBot World requires one image and does not accept an image list.")
|
|
if image is None:
|
|
raise ValueError("LingBot World requires exactly one image in multi_modal_data.image.")
|
|
if isinstance(image, (str, os.PathLike)):
|
|
raise ValueError("file-path images must be materialized by the LingBot pre-process function.")
|
|
if not isinstance(image, (PIL.Image.Image, torch.Tensor)):
|
|
raise ValueError("multi_modal_data.image must be a PIL image, tensor, or file path.")
|
|
|
|
extra_args = getattr(sampling, "extra_args", None) or {}
|
|
if not isinstance(extra_args, dict):
|
|
raise ValueError("sampling_params.extra_args must be a mapping.")
|
|
camera_trajectory = extra_args.get(_PREPROCESSED_CAMERA_KEY)
|
|
camera_actions = extra_args.get(_PREPROCESSED_CAMERA_ACTIONS_KEY)
|
|
camera_action_script = extra_args.get(_PREPROCESSED_CAMERA_ACTION_SCRIPT_KEY)
|
|
if camera_trajectory is not None and not isinstance(camera_trajectory, CameraTrajectory):
|
|
raise ValueError("LingBot camera trajectory must be materialized by the pre-process function.")
|
|
if camera_actions is not None:
|
|
camera_actions = as_camera_action_frames(camera_actions)
|
|
if camera_action_script is not None:
|
|
camera_action_script = as_camera_action_script(camera_action_script)
|
|
camera_sources = (
|
|
int(camera_trajectory is not None) + int(camera_actions is not None) + int(camera_action_script is not None)
|
|
)
|
|
if camera_sources != 1:
|
|
raise ValueError("LingBot pre-processing must materialize exactly one camera input.")
|
|
|
|
request_flow_shift = (
|
|
extra_args["flow_shift"] if "flow_shift" in extra_args else getattr(self.scheduler.config, "shift", 5.0)
|
|
)
|
|
flow_shift = _positive_finite_flow_shift(request_flow_shift)
|
|
|
|
height = getattr(sampling, "height", None)
|
|
width = getattr(sampling, "width", None)
|
|
if isinstance(image, PIL.Image.Image):
|
|
image_width, image_height = image.size
|
|
elif image.ndim == 3 and image.shape[0] == 3:
|
|
image_height, image_width = image.shape[-2:]
|
|
elif image.ndim == 4 and image.shape[:2] == (1, 3):
|
|
image_height, image_width = image.shape[-2:]
|
|
else:
|
|
raise ValueError("tensor image must have shape [3, height, width] or [1, 3, height, width].")
|
|
if image_height <= 0 or image_width <= 0:
|
|
raise ValueError("source image width and height must be positive integers.")
|
|
if image_height * image_width > _MAX_SOURCE_IMAGE_PIXELS:
|
|
raise ValueError("source image pixel count must not exceed 4096 * 4096.")
|
|
if (height is None) != (width is None):
|
|
raise ValueError("height and width must either both be provided or both be omitted.")
|
|
patch_size = tuple(self.transformer.config.patch_size)
|
|
if len(patch_size) != 3 or patch_size[0] != 1:
|
|
raise RuntimeError(
|
|
"transformer.config.patch_size must be a three-dimensional tuple with temporal patch size 1."
|
|
)
|
|
height_divisor = self.vae_scale_factor_spatial * patch_size[1]
|
|
width_divisor = self.vae_scale_factor_spatial * patch_size[2]
|
|
if height is None or width is None:
|
|
aspect_ratio = image_height / image_width
|
|
latent_height = round(
|
|
math.sqrt(_MAX_PIXEL_AREA * aspect_ratio)
|
|
// self.vae_scale_factor_spatial
|
|
// patch_size[1]
|
|
* patch_size[1]
|
|
)
|
|
latent_width = round(
|
|
math.sqrt(_MAX_PIXEL_AREA / aspect_ratio)
|
|
// self.vae_scale_factor_spatial
|
|
// patch_size[2]
|
|
* patch_size[2]
|
|
)
|
|
height = latent_height * self.vae_scale_factor_spatial
|
|
width = latent_width * self.vae_scale_factor_spatial
|
|
if isinstance(height, bool) or not isinstance(height, int) or height <= 0:
|
|
raise ValueError(f"height must be a positive integer, got {height!r}.")
|
|
if isinstance(width, bool) or not isinstance(width, int) or width <= 0:
|
|
raise ValueError(f"width must be a positive integer, got {width!r}.")
|
|
if height % height_divisor:
|
|
raise ValueError(f"height must be divisible by {height_divisor}, got {height}.")
|
|
if width % width_divisor:
|
|
raise ValueError(f"width must be divisible by {width_divisor}, got {width}.")
|
|
if height * width > _MAX_PIXEL_AREA:
|
|
raise ValueError("height * width pixel area must not exceed 480 * 832.")
|
|
|
|
num_inference_steps = getattr(sampling, "num_inference_steps", None)
|
|
if num_inference_steps is None:
|
|
num_inference_steps = len(LINGBOT_DMD_TIMESTEPS)
|
|
if num_inference_steps != len(LINGBOT_DMD_TIMESTEPS):
|
|
raise ValueError(
|
|
"num_inference_steps must be 4 for LingBot World causal DMD "
|
|
f"timesteps {list(LINGBOT_DMD_TIMESTEPS)}, got {num_inference_steps}."
|
|
)
|
|
|
|
num_frames = getattr(sampling, "num_frames", None)
|
|
if isinstance(num_frames, bool) or not isinstance(num_frames, int) or num_frames <= 0:
|
|
raise ValueError(f"num_frames must be a positive integer, got {num_frames!r}.")
|
|
temporal_factor = self.vae_scale_factor_temporal
|
|
if (num_frames - 1) % temporal_factor:
|
|
raise ValueError(
|
|
"num_frames must satisfy the causal Wan VAE geometry "
|
|
f"(num_frames - 1) divisible by {temporal_factor}, got {num_frames}."
|
|
)
|
|
num_latent_frames = (num_frames - 1) // temporal_factor + 1
|
|
block_frames = int(self.transformer.config.num_frames_per_block)
|
|
if num_latent_frames % block_frames:
|
|
raise ValueError(
|
|
"num_frames must map to a whole number of configured three-frame latent blocks; "
|
|
f"got num_frames={num_frames}, latent_frames={num_latent_frames}, block_frames={block_frames}."
|
|
)
|
|
|
|
max_sequence_length = getattr(sampling, "max_sequence_length", None)
|
|
if max_sequence_length is None:
|
|
max_sequence_length = _MAX_SEQUENCE_LENGTH
|
|
if isinstance(max_sequence_length, bool) or not isinstance(max_sequence_length, int):
|
|
raise ValueError(f"max_sequence_length must be exactly {_MAX_SEQUENCE_LENGTH}.")
|
|
if max_sequence_length != _MAX_SEQUENCE_LENGTH:
|
|
raise ValueError(f"max_sequence_length must be exactly {_MAX_SEQUENCE_LENGTH}.")
|
|
|
|
return _LingBotRequestInputs(
|
|
prompt=prompt.strip(),
|
|
image=image,
|
|
camera_trajectory=camera_trajectory,
|
|
camera_actions=camera_actions,
|
|
camera_action_script=camera_action_script,
|
|
height=height,
|
|
width=width,
|
|
num_frames=num_frames,
|
|
num_latent_frames=num_latent_frames,
|
|
output_type=getattr(sampling, "output_type", None) or "np",
|
|
max_sequence_length=max_sequence_length,
|
|
flow_shift=flow_shift,
|
|
generator=generator,
|
|
)
|
|
|
|
def _prepare_image_tensor(self, image: PIL.Image.Image | torch.Tensor, *, height: int, width: int) -> torch.Tensor:
|
|
if isinstance(image, PIL.Image.Image):
|
|
array = np.asarray(image, dtype=np.float32).copy()
|
|
image_tensor = torch.from_numpy(array).permute(2, 0, 1).unsqueeze(0) / 255.0
|
|
else:
|
|
image_tensor = image.detach()
|
|
if image_tensor.ndim == 3:
|
|
image_tensor = image_tensor.unsqueeze(0)
|
|
image_tensor = image_tensor.to(dtype=torch.float32)
|
|
if not torch.isfinite(image_tensor).all():
|
|
raise ValueError("tensor image values must all be finite.")
|
|
minimum = image_tensor.min().item()
|
|
maximum = image_tensor.max().item()
|
|
if minimum >= 0.0 and maximum > 255.0:
|
|
raise ValueError("tensor image values must be in [0, 1], [0, 255], or [-1, 1].")
|
|
if minimum < -1.0 or (minimum < 0.0 and maximum > 1.0):
|
|
raise ValueError("tensor image values must be in [0, 1], [0, 255], or [-1, 1].")
|
|
if maximum > 1.0:
|
|
image_tensor = image_tensor / 255.0
|
|
if image_tensor.min().item() >= 0.0:
|
|
image_tensor = image_tensor * 2.0 - 1.0
|
|
if image_tensor.shape[-2:] != (height, width):
|
|
image_tensor = F.interpolate(
|
|
image_tensor,
|
|
size=(height, width),
|
|
mode="bicubic",
|
|
align_corners=False,
|
|
)
|
|
return image_tensor.to(device=self.device, dtype=torch.float32)
|
|
|
|
def _vae_latent_stats(self, reference: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
|
shape = (1, -1, 1, 1, 1)
|
|
latent_mean = torch.as_tensor(
|
|
self.vae.config.latents_mean,
|
|
device=reference.device,
|
|
dtype=reference.dtype,
|
|
).view(*shape)
|
|
latent_std = torch.as_tensor(
|
|
self.vae.config.latents_std,
|
|
device=reference.device,
|
|
dtype=reference.dtype,
|
|
).view(*shape)
|
|
return latent_mean, latent_std
|
|
|
|
def _prepare_condition(self, inputs: _LingBotRequestInputs, *, dtype: torch.dtype) -> torch.Tensor:
|
|
"""Encode the first frame as ``[mask4, image_latent16]``."""
|
|
|
|
image = self._prepare_image_tensor(inputs.image, height=inputs.height, width=inputs.width)
|
|
video_condition = image.new_zeros(1, 3, inputs.num_frames, inputs.height, inputs.width)
|
|
video_condition[:, :, 0] = image
|
|
latent_condition = retrieve_latents(
|
|
self.vae.encode(video_condition.to(dtype=self.vae.dtype)),
|
|
sample_mode="argmax",
|
|
)
|
|
if latent_condition.shape != (
|
|
1,
|
|
self.transformer.config.out_channels,
|
|
inputs.num_latent_frames,
|
|
inputs.height // self.vae_scale_factor_spatial,
|
|
inputs.width // self.vae_scale_factor_spatial,
|
|
):
|
|
raise RuntimeError(
|
|
f"vae.encode returned an incompatible image latent shape: got {tuple(latent_condition.shape)}."
|
|
)
|
|
return self._condition_from_latents(latent_condition, first_frame=True, dtype=dtype)
|
|
|
|
def _condition_from_latents(
|
|
self, latent_condition: torch.Tensor, *, first_frame: bool, dtype: torch.dtype
|
|
) -> torch.Tensor:
|
|
latent_mean, latent_std = self._vae_latent_stats(latent_condition)
|
|
latent_condition = (latent_condition - latent_mean) / latent_std
|
|
temporal_mask = latent_condition.new_zeros(1, self.vae_scale_factor_temporal, *latent_condition.shape[2:])
|
|
if first_frame:
|
|
temporal_mask[:, :, 0] = 1
|
|
condition = torch.cat((temporal_mask, latent_condition), dim=1).to(dtype=dtype)
|
|
if condition.shape[1] != 20:
|
|
raise RuntimeError(
|
|
"LingBot image condition must contain 4 temporal-mask then 16 image-latent channels, "
|
|
f"got {condition.shape[1]}."
|
|
)
|
|
return condition
|
|
|
|
@torch.no_grad()
|
|
def _prepare_condition_chunk(
|
|
self,
|
|
inputs: _LingBotRequestInputs,
|
|
*,
|
|
start_frame: int,
|
|
encoder_cache: list[torch.Tensor | None] | None,
|
|
dtype: torch.dtype,
|
|
) -> tuple[torch.Tensor, list[torch.Tensor | None]]:
|
|
"""Continue the causal encoder without modifying its committed history."""
|
|
if getattr(self.vae.config, "patch_size", None) is not None or self.vae_scale_factor_temporal != 4:
|
|
raise ValueError("Stateful LingBot conditioning requires an unpatched Wan VAE with temporal factor 4.")
|
|
if getattr(self.vae, "use_tiling", False) and (
|
|
inputs.height > self.vae.tile_sample_min_height or inputs.width > self.vae.tile_sample_min_width
|
|
):
|
|
raise ValueError("Stateful LingBot conditioning does not support tiled VAE encoding.")
|
|
if (encoder_cache is None) != (start_frame == 0):
|
|
raise ValueError("LingBot condition encoder history must match the current temporal position.")
|
|
|
|
count = int(self.vae._cached_conv_counts["encoder"])
|
|
# Wan replaces cache entries with new tensors; a separate list keeps the
|
|
# committed history intact until this AR block succeeds.
|
|
pending_cache = [None] * count if encoder_cache is None else list(encoder_cache)
|
|
if len(pending_cache) != count:
|
|
raise ValueError("LingBot condition encoder cache has an incompatible number of slots.")
|
|
block_frames = int(self.transformer.config.num_frames_per_block)
|
|
zeros = torch.zeros(
|
|
1,
|
|
3,
|
|
self.vae_scale_factor_temporal,
|
|
inputs.height,
|
|
inputs.width,
|
|
device=self.device,
|
|
dtype=self.vae.dtype,
|
|
)
|
|
image = (
|
|
self._prepare_image_tensor(inputs.image, height=inputs.height, width=inputs.width)
|
|
.unsqueeze(2)
|
|
.to(dtype=self.vae.dtype)
|
|
if start_frame == 0
|
|
else None
|
|
)
|
|
context = getattr(self.vae, "_execution_context", None)
|
|
encoded_frames = []
|
|
with context() if callable(context) else nullcontext():
|
|
for index in range(block_frames):
|
|
# Match Wan's encode loop: one opening pixel frame, then four
|
|
# fresh zero pixel frames per subsequent latent frame.
|
|
pixel_frames = image if start_frame == 0 and index == 0 else zeros
|
|
encoded_frames.append(self.vae.encoder(pixel_frames, feat_cache=pending_cache, feat_idx=[0]))
|
|
# The posterior projection is pointwise in time. GPU bitwise parity
|
|
# with the whole-clip projection still requires the real-weight gate.
|
|
moments = self.vae.quant_conv(torch.cat(encoded_frames, dim=2))
|
|
latent_condition = moments.chunk(2, dim=1)[0]
|
|
expected_shape = (
|
|
1,
|
|
self.transformer.config.out_channels,
|
|
block_frames,
|
|
inputs.height // self.vae_scale_factor_spatial,
|
|
inputs.width // self.vae_scale_factor_spatial,
|
|
)
|
|
if latent_condition.shape != expected_shape:
|
|
raise RuntimeError(
|
|
f"Wan condition encoder returned {tuple(latent_condition.shape)}, expected {expected_shape}."
|
|
)
|
|
condition = self._condition_from_latents(
|
|
latent_condition,
|
|
first_frame=start_frame == 0,
|
|
dtype=dtype,
|
|
)
|
|
return condition, pending_cache
|
|
|
|
def _prepare_camera(
|
|
self,
|
|
inputs: _LingBotRequestInputs,
|
|
*,
|
|
dtype: torch.dtype,
|
|
previous: CameraTrajectory | None = None,
|
|
latent_aligned: bool = False,
|
|
) -> tuple[torch.Tensor, CameraTrajectory]:
|
|
"""Convert raw camera frames to a latent-aligned ray tensor."""
|
|
|
|
trajectory = inputs.camera_trajectory
|
|
if trajectory is None:
|
|
raise RuntimeError("LingBot camera trajectory was not prepared before camera embedding.")
|
|
available_frames = int(trajectory.poses.shape[0])
|
|
if inputs.camera_actions is not None or latent_aligned:
|
|
if available_frames != inputs.num_latent_frames:
|
|
raise ValueError(
|
|
"camera actions must produce exactly one pose per latent frame; "
|
|
f"got camera_frames={available_frames}, latent_frames={inputs.num_latent_frames}."
|
|
)
|
|
else:
|
|
if available_frames < inputs.num_frames:
|
|
raise ValueError(
|
|
"camera trajectory frames must be at least num_frames; "
|
|
f"got camera_frames={available_frames}, num_frames={inputs.num_frames}."
|
|
)
|
|
trajectory = CameraTrajectory(
|
|
poses=trajectory.poses[: inputs.num_frames],
|
|
intrinsics=trajectory.intrinsics[: inputs.num_frames],
|
|
)
|
|
trajectory = interpolate_camera_trajectory(trajectory, inputs.num_latent_frames)
|
|
embedding_trajectory = trajectory
|
|
drop_anchor = False
|
|
if previous is not None:
|
|
embedding_trajectory = CameraTrajectory(
|
|
poses=torch.cat((previous.poses, trajectory.poses), dim=0),
|
|
intrinsics=torch.cat(
|
|
(previous.intrinsics, trajectory.intrinsics),
|
|
dim=0,
|
|
),
|
|
)
|
|
drop_anchor = True
|
|
elif inputs.camera_actions is not None:
|
|
# The action integrator returns post-action poses. Keep the first
|
|
# action visible to framewise-delta conditioning by prepending its
|
|
# known pre-action state (identity for a new realtime session).
|
|
identity = torch.eye(
|
|
4,
|
|
device=trajectory.poses.device,
|
|
dtype=trajectory.poses.dtype,
|
|
).unsqueeze(0)
|
|
embedding_trajectory = CameraTrajectory(
|
|
poses=torch.cat((identity, trajectory.poses), dim=0),
|
|
intrinsics=torch.cat(
|
|
(trajectory.intrinsics[:1], trajectory.intrinsics),
|
|
dim=0,
|
|
),
|
|
)
|
|
drop_anchor = True
|
|
camera_embedding = build_plucker_embedding(
|
|
embedding_trajectory,
|
|
height=inputs.height,
|
|
width=inputs.width,
|
|
target_height=inputs.height,
|
|
target_width=inputs.width,
|
|
device=self.device,
|
|
dtype=dtype,
|
|
)
|
|
if drop_anchor:
|
|
camera_embedding = camera_embedding[1:]
|
|
tail = CameraTrajectory(
|
|
poses=trajectory.poses[-1:].clone(),
|
|
intrinsics=trajectory.intrinsics[-1:].clone(),
|
|
)
|
|
return _fold_camera_embedding(camera_embedding), tail
|
|
|
|
def _ar_text_caches(
|
|
self,
|
|
prompt_embeds: torch.Tensor,
|
|
*,
|
|
invalidate: bool,
|
|
) -> list[LingBotAttentionCache]:
|
|
state = self._ar_diffusion_kv_state
|
|
if state is None:
|
|
raise RuntimeError("LingBot AR text cache requested without a bound state.")
|
|
if invalidate:
|
|
state.clear_cross_attention()
|
|
if not state.is_cross_attention_populated(
|
|
self._AR_BRANCH,
|
|
self._AR_TEXT_CACHE,
|
|
):
|
|
projected_text = self.transformer.text_embedding(prompt_embeds)
|
|
|
|
def layer_kv() -> Iterator[tuple[torch.Tensor, torch.Tensor]]:
|
|
for block in self.transformer.blocks:
|
|
cross_attention = block.cross_attn
|
|
shape = (cross_attention.num_local_heads, cross_attention.head_dim)
|
|
key = cross_attention.shard_kv_heads(
|
|
cross_attention.norm_k(cross_attention.k(projected_text)).unflatten(2, shape)
|
|
)
|
|
value = cross_attention.shard_kv_heads(cross_attention.v(projected_text).unflatten(2, shape))
|
|
yield key, value
|
|
|
|
state.populate_cross_attention(
|
|
self._AR_BRANCH,
|
|
self._AR_TEXT_CACHE,
|
|
layer_kv(),
|
|
)
|
|
return [
|
|
LingBotAttentionCache(
|
|
key=layer["k"],
|
|
value=layer["v"],
|
|
end=prompt_embeds.shape[1],
|
|
absolute_end=prompt_embeds.shape[1],
|
|
last_start=0,
|
|
)
|
|
for layer in state.get_cross_attention_kv(
|
|
self._AR_BRANCH,
|
|
self._AR_TEXT_CACHE,
|
|
)
|
|
]
|
|
|
|
# ── DMD block helpers ─────────────────────────────────────────────────
|
|
#
|
|
# The DMD math for one AR block lives in ``LingBotDMDBlockRunner`` so that
|
|
# request mode (``_generate_block`` in a loop) and stepwise execution
|
|
# (``denoise_step`` / ``step_scheduler`` / ``post_decode``) share one copy.
|
|
# The pipeline's only jobs here are building the runner and translating
|
|
# the bound AR session into an ``ARBlockContext``.
|
|
|
|
@property
|
|
def _dmd_blocks(self) -> LingBotDMDBlockRunner:
|
|
runner = getattr(self, "_dmd_block_runner", None)
|
|
if runner is None:
|
|
runner = LingBotDMDBlockRunner(
|
|
self.transformer,
|
|
device=self.device,
|
|
enforce_eager=bool(self.od_config.enforce_eager),
|
|
)
|
|
self._dmd_block_runner = runner
|
|
return runner
|
|
|
|
def _ar_block_context(
|
|
self,
|
|
cross_attention: list[LingBotAttentionCache] | None,
|
|
) -> ARBlockContext | None:
|
|
"""Wrap the bound session for the block runner; ``None`` keeps
|
|
request-local KV."""
|
|
if cross_attention is None:
|
|
return None
|
|
state = self._ar_diffusion_kv_state
|
|
if state is None:
|
|
raise RuntimeError("LingBot AR cache requested without a bound state.")
|
|
return ARBlockContext(state=state, cross_attention=cross_attention, branch=self._AR_BRANCH)
|
|
|
|
def _generate_block(
|
|
self,
|
|
*,
|
|
condition: torch.Tensor,
|
|
camera: torch.Tensor,
|
|
prompt_embeds: torch.Tensor,
|
|
cache: LingBotTransformerCache | None,
|
|
ar_cross_attention: list[LingBotAttentionCache] | None,
|
|
start_frame: int,
|
|
schedule: tuple[tuple[float, float], ...],
|
|
generator: torch.Generator,
|
|
progress_bar: TqdmProgressBar[Any],
|
|
) -> torch.Tensor:
|
|
return self._dmd_blocks.generate_block(
|
|
condition=condition,
|
|
camera=camera,
|
|
prompt_embeds=prompt_embeds,
|
|
cache=cache,
|
|
ar=self._ar_block_context(ar_cross_attention),
|
|
start_frame=start_frame,
|
|
schedule=schedule,
|
|
generator=generator,
|
|
progress_bar=progress_bar,
|
|
)
|
|
|
|
def encode_prompt(
|
|
self,
|
|
prompt: str,
|
|
*,
|
|
max_sequence_length: int,
|
|
dtype: torch.dtype,
|
|
) -> torch.Tensor:
|
|
"""Encode ``prompt`` with UMT5, reusing the result for a prompt already encoded.
|
|
|
|
The encode depends only on the whitespace-normalised text, the sequence
|
|
length and the dtype -- the tokenizer and text encoder are fixed once
|
|
loaded -- so those three are the key. That covers every caller the same
|
|
way: a realtime tick repeats its session's prompt on every block, a
|
|
stepwise request encodes once per request, and a server tends to open
|
|
many sessions with the same scene prompt.
|
|
|
|
A hit returns the stored tensor itself. Callers only read it: the
|
|
cross-attention projection and the DMD transformer both work out of
|
|
place, so a shared encode is never changed under another session.
|
|
"""
|
|
text = " ".join(prompt.strip().split())
|
|
key = (text, int(max_sequence_length), dtype)
|
|
cached = self._prompt_embeds_cache.get(key)
|
|
if cached is not None:
|
|
self._prompt_embeds_cache.move_to_end(key)
|
|
return cached
|
|
text_inputs = self.tokenizer(
|
|
[text],
|
|
padding="max_length",
|
|
max_length=max_sequence_length,
|
|
truncation=True,
|
|
add_special_tokens=True,
|
|
return_attention_mask=True,
|
|
return_tensors="pt",
|
|
)
|
|
input_ids = text_inputs.input_ids.to(self.device)
|
|
attention_mask = text_inputs.attention_mask.to(self.device)
|
|
prompt_embeds = self.text_encoder(input_ids, attention_mask).last_hidden_state
|
|
prompt_embeds = prompt_embeds.to(device=self.device, dtype=dtype)
|
|
prompt_embeds = prompt_embeds * attention_mask.unsqueeze(-1).to(dtype=prompt_embeds.dtype)
|
|
self._prompt_embeds_cache[key] = prompt_embeds
|
|
if len(self._prompt_embeds_cache) > _PROMPT_EMBEDS_CACHE_SIZE:
|
|
self._prompt_embeds_cache.popitem(last=False)
|
|
return prompt_embeds
|
|
|
|
def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput:
|
|
inputs = self._parse_request(req)
|
|
if inputs.camera_action_script is not None:
|
|
# Only step execution walks a whole rollout inside one request, so
|
|
# a per-chunk script here would be silently dropped rather than
|
|
# steering the camera.
|
|
raise ValueError(
|
|
"camera_action_script is read only by LingBot step execution; "
|
|
"request mode takes one three-frame camera_actions control per "
|
|
"block, or a camera_trajectory for the request."
|
|
)
|
|
tick = ARDiffusionTickRequest.from_extra_args(req.sampling_params.extra_args)
|
|
if tick is not None and self._ar_diffusion_kv_state is None:
|
|
raise RuntimeError("LingBot typed ticks require ARDiffusionEngine session binding.")
|
|
if tick is None and self._ar_diffusion_kv_state is not None:
|
|
raise ValueError("LingBot ARDiffusionEngine requests must carry ar_diffusion_tick.")
|
|
if tick is not None and inputs.output_type != "latent":
|
|
raise ValueError(
|
|
"LingBot realtime ticks currently require output_type='latent'; "
|
|
"stateful streaming VAE decode is a separate integration step."
|
|
)
|
|
session_state: _LingBotARSessionState | None = None
|
|
if tick is not None:
|
|
if tick.prompt is not None and " ".join(tick.prompt.split()) != " ".join(inputs.prompt.split()):
|
|
raise ValueError("ar_diffusion_tick.prompt must match the standard request prompt.")
|
|
block_frames = int(self.transformer.config.num_frames_per_block)
|
|
if inputs.num_latent_frames != block_frames:
|
|
raise ValueError("A LingBot AR-Diffusion request must generate exactly one three-latent-frame block.")
|
|
if (inputs.height, inputs.width) != (
|
|
self._ar_height,
|
|
self._ar_width,
|
|
):
|
|
raise ValueError(
|
|
"LingBot AR-Diffusion request resolution must match the "
|
|
"fixed cache geometry "
|
|
f"{self._ar_height}x{self._ar_width}."
|
|
)
|
|
session_state = self._ar_sessions.setdefault(
|
|
tick.session_id,
|
|
_LingBotARSessionState(),
|
|
)
|
|
if tick.chunk_index != session_state.next_chunk_index:
|
|
raise ValueError(
|
|
"LingBot chunk_index must be contiguous within a session: "
|
|
f"expected {session_state.next_chunk_index}, got "
|
|
f"{tick.chunk_index}."
|
|
)
|
|
schedule = _build_shifted_flow_schedule(
|
|
flow_shift=inputs.flow_shift,
|
|
)
|
|
dtype = self.transformer.dtype
|
|
# Phase 1: turn all three user inputs into DiT-ready conditions.
|
|
prompt_embeds = self.encode_prompt(
|
|
inputs.prompt,
|
|
max_sequence_length=inputs.max_sequence_length,
|
|
dtype=dtype,
|
|
)
|
|
if tick is None:
|
|
condition = self._prepare_condition(inputs, dtype=dtype)
|
|
else:
|
|
assert session_state is not None
|
|
condition, pending_encoder_cache = self._prepare_condition_chunk(
|
|
inputs,
|
|
start_frame=tick.chunk_index * block_frames,
|
|
encoder_cache=session_state.encoder_cache,
|
|
dtype=dtype,
|
|
)
|
|
camera_pitch: float | None = None
|
|
if inputs.camera_actions is not None:
|
|
assert session_state is not None
|
|
action_trajectory, camera_pitch = integrate_lingbot_camera_actions(
|
|
inputs.camera_actions,
|
|
width=inputs.width,
|
|
height=inputs.height,
|
|
initial_pose=(session_state.camera_tail.poses[-1] if session_state.camera_tail is not None else None),
|
|
initial_pitch=session_state.camera_pitch,
|
|
)
|
|
inputs = replace(inputs, camera_trajectory=action_trajectory)
|
|
camera, camera_tail = self._prepare_camera(
|
|
inputs,
|
|
dtype=dtype,
|
|
previous=session_state.camera_tail if session_state is not None else None,
|
|
)
|
|
if camera.shape[2:] != condition.shape[2:]:
|
|
raise RuntimeError(
|
|
"folded camera and image condition must share latent frame/height/width geometry; "
|
|
f"got camera={tuple(camera.shape)}, condition={tuple(condition.shape)}."
|
|
)
|
|
|
|
block_frames = int(self.transformer.config.num_frames_per_block)
|
|
cache: LingBotTransformerCache | None = None
|
|
ar_cross_attention: list[LingBotAttentionCache] | None = None
|
|
if tick is None:
|
|
# Direct/offline oracle keeps request-local contiguous K/V.
|
|
cache = self.transformer.allocate_cache(
|
|
batch_size=1,
|
|
latent_height=condition.shape[-2],
|
|
latent_width=condition.shape[-1],
|
|
device=self.device,
|
|
dtype=dtype,
|
|
)
|
|
else:
|
|
assert session_state is not None
|
|
prompt_changed = session_state.prompt is not None and session_state.prompt != inputs.prompt
|
|
if session_state.generator_state is not None:
|
|
inputs.generator.set_state(session_state.generator_state)
|
|
ar_cross_attention = self._ar_text_caches(
|
|
prompt_embeds,
|
|
invalidate=prompt_changed,
|
|
)
|
|
generated_blocks: list[torch.Tensor] = []
|
|
total_steps = (inputs.num_latent_frames // block_frames) * len(LINGBOT_DMD_TIMESTEPS)
|
|
first_start_frame = tick.chunk_index * block_frames if tick is not None else 0
|
|
with self.progress_bar(total=total_steps) as progress_bar:
|
|
# Phase 3: generate left-to-right in latent-frame blocks. The
|
|
# monotonically increasing ``start_frame`` is also the temporal
|
|
# position and cache offset used by the Transformer.
|
|
for local_start_frame in range(
|
|
0,
|
|
inputs.num_latent_frames,
|
|
block_frames,
|
|
):
|
|
start_frame = first_start_frame + local_start_frame
|
|
stop_frame = local_start_frame + block_frames
|
|
generated_blocks.append(
|
|
self._generate_block(
|
|
condition=condition[:, :, local_start_frame:stop_frame],
|
|
camera=camera[:, :, local_start_frame:stop_frame],
|
|
prompt_embeds=prompt_embeds,
|
|
cache=cache,
|
|
ar_cross_attention=ar_cross_attention,
|
|
start_frame=start_frame,
|
|
schedule=schedule,
|
|
generator=inputs.generator,
|
|
progress_bar=progress_bar,
|
|
)
|
|
)
|
|
generated_latents = torch.cat(generated_blocks, dim=2)
|
|
# The direct/offline cache is request-local and no longer needed once
|
|
# all latent blocks have been generated. Release it before VAE decode
|
|
# so the two large allocations do not overlap.
|
|
cache = None
|
|
if tick is not None:
|
|
assert session_state is not None
|
|
session_state.prompt = inputs.prompt
|
|
session_state.generator_state = inputs.generator.get_state()
|
|
session_state.encoder_cache = pending_encoder_cache
|
|
session_state.camera_tail = camera_tail
|
|
if camera_pitch is not None:
|
|
session_state.camera_pitch = camera_pitch
|
|
session_state.next_chunk_index += 1
|
|
|
|
# Phase 4: either expose model-space latents or invert the checkpoint's
|
|
# latent normalization and decode to pixel-space video.
|
|
if tick is not None:
|
|
output = {
|
|
"payload": {"latents": generated_latents},
|
|
"metadata": {"ar_diffusion": ARDiffusionChunkMetadata.from_tick(tick).to_dict()},
|
|
}
|
|
elif inputs.output_type == "latent":
|
|
output = generated_latents
|
|
else:
|
|
latent_mean, latent_std = self._vae_latent_stats(generated_latents)
|
|
vae_latents = (generated_latents * latent_std + latent_mean).to(dtype=self.vae.dtype)
|
|
output = self.vae.decode(vae_latents, return_dict=False)[0]
|
|
if output.shape[2] != inputs.num_frames:
|
|
raise RuntimeError(
|
|
"vae.decode returned an incompatible temporal geometry: "
|
|
f"expected {inputs.num_frames} frames, got {output.shape[2]}."
|
|
)
|
|
return DiffusionOutput(
|
|
output=output,
|
|
stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None,
|
|
)
|
|
|
|
def _vae_would_tile(self, latents: torch.Tensor) -> bool:
|
|
"""Whether ``vae.decode`` would take its tiled path for this shape.
|
|
|
|
Tiled decode splits a frame spatially and drives the module's own
|
|
``feat_cache`` across those tiles, so a per-session cache threaded
|
|
through it is a separate design and is not attempted here. The test
|
|
mirrors ``AutoencoderKLWan._decode``: the flag alone does not tile, the
|
|
latent also has to exceed a tile, so a sub-threshold resolution with
|
|
tiling enabled still streams. A VAE that sets the flag but reports no
|
|
tile size is treated as tiling, because the safe answer when the
|
|
threshold is unknown is the one that keeps ``vae.decode`` in charge.
|
|
"""
|
|
if not bool(getattr(self.vae, "use_tiling", False)):
|
|
return False
|
|
compression = int(getattr(self.vae, "spatial_compression_ratio", None) or self.vae_scale_factor_spatial)
|
|
tile_latent_min_height = int(getattr(self.vae, "tile_sample_min_height", None) or 0) // compression
|
|
tile_latent_min_width = int(getattr(self.vae, "tile_sample_min_width", None) or 0) // compression
|
|
return bool(latents.shape[-2] > tile_latent_min_height or latents.shape[-1] > tile_latent_min_width)
|
|
|
|
def _streaming_decoder(self) -> WanStreamingDecoder | None:
|
|
"""The session-owned streaming decoder, or ``None`` on a VAE that cannot stream.
|
|
|
|
Only the decoder's own capability is decided here, and the decision is
|
|
cached including the negative. Whether a particular *shape* would have
|
|
been tiled is a per-call question and lives in :meth:`_vae_would_tile`.
|
|
"""
|
|
if self._streaming_decode_unsupported:
|
|
return None
|
|
if self._cached_streaming_decoder is not None:
|
|
return self._cached_streaming_decoder
|
|
try:
|
|
decoder = WanStreamingDecoder(
|
|
self.vae,
|
|
bytes_per_pixel_fp32=_STREAMING_DECODE_BYTES_PER_PIXEL_FP32,
|
|
)
|
|
# Materialize the cache geometry now: a decoder that cannot report
|
|
# it would otherwise fail on the first chunk instead.
|
|
_ = decoder.num_causal_convs
|
|
except TypeError as exc:
|
|
logger.warning(
|
|
"LingBot streaming decode is unavailable (%s); AR blocks will be decoded independently, "
|
|
"which drops each block's opening frame and restarts temporal context at every boundary.",
|
|
exc,
|
|
)
|
|
self._streaming_decode_unsupported = True
|
|
return None
|
|
self._cached_streaming_decoder = decoder
|
|
return decoder
|
|
|
|
def _streaming_decode_state(self, decoder: WanStreamingDecoder, session_id: str) -> StreamingDecodeState:
|
|
state = self._streaming_decode_states.get(session_id)
|
|
if state is None:
|
|
state = decoder.new_decode_state(session_id)
|
|
self._streaming_decode_states[session_id] = state
|
|
return state
|
|
|
|
def _release_streaming_decode_state(self, session_id: str) -> None:
|
|
"""Drop one session's decoder cache. Safe for a session that never decoded.
|
|
|
|
The state is released directly rather than through
|
|
``WanStreamingDecoder.release``, which is the same one-line call: the
|
|
decoder's method exists for a caller holding only the
|
|
``SupportsStreamingDecode`` protocol, and this pipeline holds the state.
|
|
"""
|
|
state = self._streaming_decode_states.pop(session_id, None)
|
|
if state is not None:
|
|
state.release()
|
|
|
|
def _decode_chunk_to_pixels(
|
|
self, latents: torch.Tensor, *, output_type: str, session_id: str | None = None
|
|
) -> torch.Tensor | np.ndarray | list[list[PIL.Image.Image]]:
|
|
"""Decode one AR block so streaming consumers receive pixels, not latents.
|
|
|
|
With a ``session_id`` the block is decoded through that session's own
|
|
temporal cache, so chunk *N + 1* continues chunk *N*: the causal decoder
|
|
expands the session's opening latent frame to one raw frame and every
|
|
later one to the full temporal factor, which is the frame timeline an
|
|
offline whole-clip decode of the same latents produces. The cache lives
|
|
on the session rather than on the shared VAE module, so concurrent
|
|
sessions cannot overwrite one another's temporal context.
|
|
|
|
Without a ``session_id`` -- or on a VAE that cannot stream -- blocks are
|
|
decoded independently: every block re-expands its own opening frame, so
|
|
the rollout loses frames and continuity at each block boundary.
|
|
|
|
The registered post-process hook returns a ``{"payload", "metadata"}``
|
|
envelope untouched, so the pixel conversion it would do for a bare
|
|
tensor has to happen here instead.
|
|
"""
|
|
latent_mean, latent_std = self._vae_latent_stats(latents)
|
|
vae_latents = (latents * latent_std + latent_mean).to(dtype=self.vae.dtype)
|
|
decoder = None
|
|
if session_id is not None and not self._vae_would_tile(vae_latents):
|
|
decoder = self._streaming_decoder()
|
|
if decoder is None:
|
|
video = self.vae.decode(vae_latents, return_dict=False)[0]
|
|
else:
|
|
video = self._streaming_decode_chunk(decoder, vae_latents, cast(str, session_id))
|
|
return self._video_processor().postprocess_video(video, output_type=output_type)
|
|
|
|
def _streaming_decode_chunk(
|
|
self, decoder: WanStreamingDecoder, vae_latents: torch.Tensor, session_id: str
|
|
) -> torch.Tensor:
|
|
"""Decode one chunk through ``session_id``'s temporal cache.
|
|
|
|
A separate method so the profiler has a stage to wrap: streaming never
|
|
calls ``vae.decode``, and without this a profiled rollout would report
|
|
no decode time for the one stage this path changes.
|
|
"""
|
|
state = self._streaming_decode_state(decoder, session_id)
|
|
try:
|
|
return decoder.decode_chunk(vae_latents, state)
|
|
except Exception:
|
|
# A half-advanced cache cannot be resumed: the session's next chunk
|
|
# would continue from a temporal context that was never completed,
|
|
# so drop it here rather than at close.
|
|
self._release_streaming_decode_state(session_id)
|
|
raise
|
|
|
|
def _video_processor(self) -> VideoProcessor:
|
|
processor = getattr(self, "_cached_video_processor", None)
|
|
if processor is None:
|
|
from diffusers.video_processor import VideoProcessor
|
|
|
|
processor = VideoProcessor(vae_scale_factor=self.vae_scale_factor_spatial)
|
|
self._cached_video_processor = processor
|
|
return processor
|
|
|
|
def _require_bound_ar_state(self) -> None:
|
|
if self._ar_diffusion_kv_state is None:
|
|
raise RuntimeError("LingBot step execution requires AR-Diffusion session binding.")
|
|
|
|
def prepare_encode(self, state: StepRequestState, **kwargs: Any) -> StepRequestState:
|
|
del kwargs
|
|
self._require_bound_ar_state()
|
|
req = DiffusionRequestBatch(
|
|
requests=[
|
|
OmniDiffusionRequest(
|
|
prompt=state.prompt,
|
|
sampling_params=state.sampling,
|
|
request_id=state.request_id,
|
|
)
|
|
]
|
|
)
|
|
inputs = self._parse_request(req)
|
|
if ARDiffusionTickRequest.from_extra_args(state.sampling.extra_args) is not None:
|
|
raise ValueError("LingBot step execution does not accept AR-Diffusion ticks.")
|
|
if (inputs.height, inputs.width) != (self._ar_height, self._ar_width):
|
|
raise ValueError(
|
|
"LingBot AR-Diffusion request resolution must match the "
|
|
f"fixed cache geometry {self._ar_height}x{self._ar_width}."
|
|
)
|
|
block_frames = int(self.transformer.config.num_frames_per_block)
|
|
total_chunks = inputs.num_latent_frames // block_frames
|
|
if inputs.camera_action_script is not None and len(inputs.camera_action_script) != total_chunks:
|
|
raise ValueError(
|
|
"camera_action_script must contain one action list per generated chunk; "
|
|
f"got {len(inputs.camera_action_script)} chunks for {total_chunks} requested chunks."
|
|
)
|
|
dtype = self.transformer.dtype
|
|
prompt_embeds = self.encode_prompt(
|
|
inputs.prompt,
|
|
max_sequence_length=inputs.max_sequence_length,
|
|
dtype=dtype,
|
|
)
|
|
camera_trajectory_cache = None
|
|
camera_embedding_cache = None
|
|
if inputs.camera_trajectory is not None:
|
|
available_frames = int(inputs.camera_trajectory.poses.shape[0])
|
|
if available_frames < inputs.num_frames:
|
|
raise ValueError(
|
|
"camera trajectory frames must be at least num_frames; "
|
|
f"got camera_frames={available_frames}, num_frames={inputs.num_frames}."
|
|
)
|
|
truncated = CameraTrajectory(
|
|
poses=inputs.camera_trajectory.poses[: inputs.num_frames],
|
|
intrinsics=inputs.camera_trajectory.intrinsics[: inputs.num_frames],
|
|
)
|
|
camera_trajectory_cache = interpolate_camera_trajectory(truncated, inputs.num_latent_frames)
|
|
# Embed the whole trajectory once, exactly as request mode does, and
|
|
# slice it per block in ``_prepare_next_chunk``. Embedding each chunk
|
|
# on its own would normalize that block's framewise translations by
|
|
# its own largest step, so a slow block would be conditioned as
|
|
# full-speed motion and drift from offline replay of the same path.
|
|
camera_embedding_cache, _ = self._prepare_camera(inputs, dtype=dtype)
|
|
state.prompt_embeds = prompt_embeds
|
|
state.chunk_index = 0
|
|
state.step_index = 0
|
|
state.step_in_chunk = 0
|
|
state.total_chunks = total_chunks
|
|
state.chunk_num_steps = len(LINGBOT_DMD_TIMESTEPS)
|
|
state.extra = {
|
|
"inputs": inputs,
|
|
"schedule": _build_shifted_flow_schedule(flow_shift=inputs.flow_shift),
|
|
"dtype": dtype,
|
|
"block_frames": block_frames,
|
|
"camera_pitch": 0.0,
|
|
"camera_tail": None,
|
|
"camera_action_script": inputs.camera_action_script,
|
|
"camera_trajectory_cache": camera_trajectory_cache,
|
|
"camera_embedding_cache": camera_embedding_cache,
|
|
}
|
|
self._ar_text_caches(prompt_embeds, invalidate=False)
|
|
self._ar_sessions[state.request_id] = _LingBotARSessionState()
|
|
try:
|
|
self._prepare_next_chunk(state)
|
|
except Exception:
|
|
self._release_condition_encoder_state(state.request_id)
|
|
raise
|
|
return state
|
|
|
|
def _prepare_next_chunk(self, state: StepRequestState) -> None:
|
|
extra = state.extra
|
|
inputs: _LingBotRequestInputs = extra["inputs"]
|
|
block_frames = int(extra["block_frames"])
|
|
start_frame = state.chunk_index * block_frames
|
|
stop_frame = start_frame + block_frames
|
|
session_state = self._ar_sessions[state.request_id]
|
|
if session_state.next_chunk_index != state.chunk_index:
|
|
raise ValueError("LingBot condition chunks must be contiguous within the session.")
|
|
condition, pending_encoder_cache = self._prepare_condition_chunk(
|
|
inputs,
|
|
start_frame=start_frame,
|
|
encoder_cache=session_state.encoder_cache,
|
|
dtype=extra["dtype"],
|
|
)
|
|
previous = extra.get("camera_tail")
|
|
if extra.get("camera_action_script") is not None:
|
|
chunk_actions = extra["camera_action_script"][state.chunk_index]
|
|
action_trajectory, camera_pitch = integrate_lingbot_camera_actions(
|
|
chunk_actions,
|
|
width=inputs.width,
|
|
height=inputs.height,
|
|
initial_pose=(previous.poses[-1] if previous is not None else None),
|
|
initial_pitch=float(extra.get("camera_pitch", 0.0)),
|
|
)
|
|
extra["camera_pitch"] = camera_pitch
|
|
chunk_inputs = replace(
|
|
inputs,
|
|
camera_trajectory=action_trajectory,
|
|
camera_actions=chunk_actions,
|
|
num_frames=(block_frames - 1) * self.vae_scale_factor_temporal + 1,
|
|
num_latent_frames=block_frames,
|
|
)
|
|
camera, camera_tail = self._prepare_camera(
|
|
chunk_inputs,
|
|
dtype=extra["dtype"],
|
|
previous=previous,
|
|
)
|
|
elif extra.get("camera_embedding_cache") is not None:
|
|
# Same slice request mode takes from its one full-trajectory
|
|
# embedding, so both paths condition a block identically.
|
|
trajectory = extra["camera_trajectory_cache"]
|
|
camera = extra["camera_embedding_cache"][:, :, start_frame:stop_frame]
|
|
camera_tail = CameraTrajectory(
|
|
poses=trajectory.poses[stop_frame - 1 : stop_frame].clone(),
|
|
intrinsics=trajectory.intrinsics[stop_frame - 1 : stop_frame].clone(),
|
|
)
|
|
else:
|
|
raise RuntimeError("LingBot step execution is missing a camera trajectory or action script.")
|
|
if camera.shape[2:] != condition.shape[2:]:
|
|
raise RuntimeError(
|
|
"folded camera and image condition must share latent frame/height/width geometry; "
|
|
f"got camera={tuple(camera.shape)}, condition={tuple(condition.shape)}."
|
|
)
|
|
generator = getattr(state.sampling, "generator", None)
|
|
if not isinstance(generator, torch.Generator):
|
|
raise ValueError("LingBot World requires the runner-provided torch.Generator.")
|
|
extra["condition"] = condition
|
|
extra["camera"] = camera
|
|
extra["camera_tail"] = camera_tail
|
|
extra["start_frame"] = start_frame
|
|
extra["ar_cross_attention"] = self._ar_text_caches(
|
|
cast(torch.Tensor, state.prompt_embeds),
|
|
invalidate=False,
|
|
)
|
|
state.latents = randn_tensor(
|
|
(
|
|
1,
|
|
self.transformer.config.out_channels,
|
|
condition.shape[2],
|
|
condition.shape[3],
|
|
condition.shape[4],
|
|
),
|
|
generator=generator,
|
|
device=self.device,
|
|
dtype=torch.float32,
|
|
)
|
|
state.timesteps = torch.tensor(
|
|
[timestep for timestep, _ in extra["schedule"]],
|
|
device=self.device,
|
|
dtype=torch.float32,
|
|
)
|
|
state.step_in_chunk = 0
|
|
state.step_index = 0
|
|
session_state.pending_encoder_cache = pending_encoder_cache
|
|
|
|
def denoise_step(
|
|
self,
|
|
input_batch: InputBatch,
|
|
*,
|
|
states: Sequence[StepRequestState] | None = None,
|
|
**kwargs: Any,
|
|
) -> torch.Tensor | None:
|
|
del input_batch, kwargs
|
|
if states is None or len(states) != 1:
|
|
raise ValueError("LingBot step execution supports a single request, not a batched request.")
|
|
self._require_bound_ar_state()
|
|
state = states[0]
|
|
extra = state.extra
|
|
latents = state.latents
|
|
if latents is None:
|
|
raise RuntimeError("LingBot step execution requires latents before denoise_step.")
|
|
timestep = state.current_timestep
|
|
if timestep is None:
|
|
raise RuntimeError("LingBot step execution requires a current timestep before denoise_step.")
|
|
schedule = extra["schedule"]
|
|
step_in_chunk = state.step_in_chunk
|
|
return self._dmd_blocks.probe_step(
|
|
current_latents=latents,
|
|
condition=extra["condition"],
|
|
camera=extra["camera"],
|
|
prompt_embeds=cast(torch.Tensor, state.prompt_embeds),
|
|
cache=None,
|
|
ar=self._ar_block_context(extra["ar_cross_attention"]),
|
|
start_frame=int(extra["start_frame"]),
|
|
timestep_value=float(schedule[step_in_chunk][0]),
|
|
step_index=step_in_chunk,
|
|
)
|
|
|
|
def step_scheduler(self, state: StepRequestState, noise_pred: torch.Tensor, **kwargs: Any) -> None:
|
|
del kwargs
|
|
extra = state.extra
|
|
latents = state.latents
|
|
if latents is None:
|
|
raise RuntimeError("LingBot step execution requires latents before step_scheduler.")
|
|
generator = getattr(state.sampling, "generator", None)
|
|
if not isinstance(generator, torch.Generator):
|
|
raise ValueError("LingBot World requires the runner-provided torch.Generator.")
|
|
schedule = extra["schedule"]
|
|
step_in_chunk = state.step_in_chunk
|
|
next_sigma = schedule[step_in_chunk + 1][1] if step_in_chunk + 1 < len(schedule) else None
|
|
state.latents = self._dmd_blocks.apply_transition(
|
|
latents,
|
|
noise_pred,
|
|
schedule[step_in_chunk][1],
|
|
next_sigma=next_sigma,
|
|
generator=generator,
|
|
)
|
|
state.step_in_chunk += 1
|
|
state.step_index = state.step_in_chunk
|
|
|
|
def post_decode(self, state: StepRequestState, **kwargs: Any) -> DiffusionOutput:
|
|
del kwargs
|
|
self._require_bound_ar_state()
|
|
extra = state.extra
|
|
latents = state.latents
|
|
if latents is None:
|
|
raise RuntimeError("LingBot step execution requires latents before post_decode.")
|
|
session_state = self._ar_sessions[state.request_id]
|
|
if session_state.pending_encoder_cache is None:
|
|
raise RuntimeError("LingBot condition encoder has no prepared chunk to commit.")
|
|
self._dmd_blocks.commit_block_kv(
|
|
latents=latents,
|
|
condition=extra["condition"],
|
|
camera=extra["camera"],
|
|
prompt_embeds=cast(torch.Tensor, state.prompt_embeds),
|
|
cache=None,
|
|
ar=self._ar_block_context(extra["ar_cross_attention"]),
|
|
start_frame=int(extra["start_frame"]),
|
|
)
|
|
completed_chunk_index = state.chunk_index
|
|
inputs: _LingBotRequestInputs = extra["inputs"]
|
|
# "video" is the payload key the output formatter treats as primary, so
|
|
# a streaming client receives frames on ``images`` like any other video
|
|
# pipeline; latent mode keeps the tensor for offline consumers.
|
|
if inputs.output_type == "latent":
|
|
payload: dict[str, Any] = {"latents": latents}
|
|
else:
|
|
payload = {
|
|
"video": self._decode_chunk_to_pixels(
|
|
latents,
|
|
output_type=inputs.output_type,
|
|
session_id=state.request_id,
|
|
)
|
|
}
|
|
output = {
|
|
"payload": payload,
|
|
"metadata": {
|
|
"ar_diffusion": ARDiffusionChunkMetadata(
|
|
session_id=state.request_id,
|
|
request_id=state.request_id,
|
|
chunk_index=completed_chunk_index,
|
|
applied_event_ids=(),
|
|
).to_dict()
|
|
},
|
|
}
|
|
session_state.encoder_cache = session_state.pending_encoder_cache
|
|
session_state.pending_encoder_cache = None
|
|
session_state.next_chunk_index += 1
|
|
state.chunk_index += 1
|
|
finished = state.request_denoise_completed
|
|
if not finished:
|
|
self._prepare_next_chunk(state)
|
|
else:
|
|
self._release_condition_encoder_state(state.request_id)
|
|
return DiffusionOutput(
|
|
output=output,
|
|
stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None,
|
|
chunk_index=completed_chunk_index,
|
|
total_chunks=state.total_chunks,
|
|
finished=finished,
|
|
)
|
|
|
|
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
|
loader = AutoWeightsLoader(self)
|
|
return cast(set[str], loader.load_weights(weights))
|