Files
vllm-omni/vllm_omni/diffusion/models/lingbot_video/request_utils.py
T
Zhou TaichangandClaude Opus 5 2ce15ab7a0 [Rebase] Rebase to vllm 0.27.0 (#5976)
Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 15:23:46 +00:00

476 lines
18 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import json
import math
from collections.abc import Mapping
from dataclasses import dataclass
from enum import Enum
from typing import Any
from PIL import Image
from vllm.logger import init_logger
logger = init_logger(__name__)
LINGBOT_RUNTIME_PROMPT_FIELDS = frozenset(
{
"duration",
"seconds",
"fps",
"height",
"width",
"size",
"num_frames",
"resolution",
"ratio",
}
)
# Values are stored as (height, width), matching the official LingBot runner.
LINGBOT_RESOLUTION_PRESETS: dict[str, dict[str, tuple[int, int]]] = {
"192p": {
"1:1": (192, 192),
"9:16": (192, 320),
"16:9": (320, 192),
"3:4": (192, 256),
"4:3": (256, 192),
},
"480p": {
"1:1": (480, 480),
"9:16": (480, 832),
"16:9": (832, 480),
"3:4": (480, 640),
"4:3": (640, 480),
},
"720p": {
"1:1": (736, 736),
"9:16": (736, 1280),
"16:9": (1280, 736),
"3:4": (736, 960),
"4:3": (960, 736),
},
"1080p": {
"1:1": (1088, 1088),
"9:16": (1088, 1920),
"16:9": (1920, 1088),
"3:4": (1088, 1440),
"4:3": (1440, 1088),
},
"2k": {
"1:1": (1440, 1440),
"9:16": (1440, 2560),
"16:9": (2560, 1440),
"3:4": (1440, 1920),
"4:3": (1920, 1440),
},
"4k": {
"1:1": (2176, 2176),
"9:16": (2176, 3840),
"16:9": (3840, 2176),
"3:4": (2176, 2880),
"4:3": (2880, 2176),
},
}
class LingBotGenerationMode(str, Enum):
T2I = "t2i"
T2V = "t2v"
TI2V = "ti2v"
@dataclass(frozen=True)
class LingBotRequestConfig:
mode: LingBotGenerationMode
prompt: str
negative_prompt: str
height: int
width: int
num_frames: int
fps: int
num_inference_steps: int
guidance_scale: float
shift: float
output_type: str
input_image: Image.Image | None
def _json_caption(value: Any) -> str:
try:
caption = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
except (TypeError, ValueError) as exc:
raise ValueError(f"LingBot structured prompt is not JSON serializable: {value!r}.") from exc
if caption in {"", '""', "{}"}:
raise ValueError("LingBot structured prompt must contain a non-empty caption.")
return caption
def caption_from_lingbot_prompt(value: Any) -> str:
if isinstance(value, str):
if not value:
raise ValueError("LingBot `prompt` must not be empty.")
return value
if not isinstance(value, Mapping):
raise TypeError(f"LingBot `prompt` must be a string or mapping, got {type(value)!r}.")
if "caption" in value:
caption = value["caption"]
if isinstance(caption, str):
if not caption:
raise ValueError("LingBot `caption` must not be empty.")
return caption
return _json_caption(caption)
caption_fields = {key: item for key, item in value.items() if key not in LINGBOT_RUNTIME_PROMPT_FIELDS}
if not caption_fields:
raise ValueError("LingBot structured prompt is empty after removing runtime fields.")
return _json_caption(caption_fields)
def _prompt_envelope(prompt_obj: Any) -> Mapping[str, Any]:
return prompt_obj if isinstance(prompt_obj, Mapping) else {}
def _prompt_value(prompt_obj: Any) -> Any:
envelope = _prompt_envelope(prompt_obj)
return envelope["prompt"] if "prompt" in envelope else prompt_obj
def _image_items(envelope: Mapping[str, Any]) -> list[Any]:
multi_modal_data = envelope.get("multi_modal_data") or {}
if not isinstance(multi_modal_data, Mapping):
raise TypeError("LingBot `multi_modal_data` must be a mapping.")
images = multi_modal_data.get("image")
if images is None:
return []
return list(images) if isinstance(images, (list, tuple)) else [images]
def resolve_lingbot_mode(prompt_obj: Any) -> LingBotGenerationMode:
envelope = _prompt_envelope(prompt_obj)
images = _image_items(envelope)
multi_modal_data = envelope.get("multi_modal_data") or {}
unsupported = [name for name in ("video", "audio") if multi_modal_data.get(name) is not None]
if unsupported:
raise ValueError(f"LingBot does not support reference modalities {unsupported!r}; only one image is allowed.")
modalities = envelope.get("modalities")
if modalities is None:
if images:
raise ValueError("LingBot requests with an input image must explicitly set `modalities=['video']`.")
logger.warning_once(
"LingBot request omitted `modalities`; falling back to legacy text-to-video behavior. "
"Set `modalities=['video']` explicitly."
)
return LingBotGenerationMode.T2V
if not isinstance(modalities, (list, tuple)) or len(modalities) != 1:
raise ValueError(f"LingBot `modalities` must contain exactly one output modality, got {modalities!r}.")
modality = modalities[0]
if modality == "image":
if images:
raise ValueError(f"LingBot text-to-image does not accept reference images, got {len(images)}.")
return LingBotGenerationMode.T2I
if modality == "video":
if len(images) == 0:
return LingBotGenerationMode.T2V
if len(images) == 1:
return LingBotGenerationMode.TI2V
raise ValueError(f"LingBot text-image-to-video accepts exactly one reference image, got {len(images)}.")
raise ValueError(f"LingBot output modality must be 'image' or 'video', got {modality!r}.")
def _positive_int(value: Any, name: str) -> int:
if isinstance(value, bool):
raise ValueError(f"LingBot `{name}` must be a positive integer, got {value!r}.")
try:
parsed = int(value)
numeric = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"LingBot `{name}` must be a positive integer, got {value!r}.") from exc
if not math.isfinite(numeric) or numeric != parsed or parsed <= 0:
raise ValueError(f"LingBot `{name}` must be a positive integer, got {value!r}.")
return parsed
def normalize_lingbot_num_frames(value: Any) -> int:
"""Round a video length up to LingBot's causal VAE ``4n+1`` grid."""
num_frames = _positive_int(value, "num_frames")
latent_frames = math.ceil((num_frames - 1) / 4)
return latent_frames * 4 + 1
def resolve_lingbot_num_frames(duration: Any, fps: Any) -> int:
try:
duration_seconds = float(duration)
except (TypeError, ValueError) as exc:
raise ValueError(f"LingBot `duration` must be a positive number, got {duration!r}.") from exc
if not math.isfinite(duration_seconds) or duration_seconds <= 0:
raise ValueError(f"LingBot `duration` must be a positive number, got {duration!r}.")
fps_value = _positive_int(fps, "fps")
raw_frames = int(duration_seconds * fps_value)
if raw_frames < 1:
raise ValueError(
f"LingBot `duration` and `fps` must produce at least one frame, got duration={duration!r}, fps={fps!r}."
)
return normalize_lingbot_num_frames(raw_frames)
def _parse_size(value: Any) -> tuple[int, int]:
if isinstance(value, str):
parts = value.lower().split("x")
if len(parts) != 2:
raise ValueError(f"LingBot `size` must use WIDTHxHEIGHT format, got {value!r}.")
width, height = parts
return _positive_int(height, "height"), _positive_int(width, "width")
if isinstance(value, (list, tuple)) and len(value) == 2:
width, height = value
return _positive_int(height, "height"), _positive_int(width, "width")
raise ValueError(f"LingBot `size` must use WIDTHxHEIGHT format, got {value!r}.")
def resolve_lingbot_size(
*,
width: Any = None,
height: Any = None,
size: Any = None,
resolution: Any = None,
ratio: Any = None,
) -> tuple[int, int]:
has_dimensions = width is not None or height is not None
has_size = size is not None
has_preset = resolution is not None or ratio is not None
if sum((has_dimensions, has_size, has_preset)) > 1:
raise ValueError("LingBot `size`, explicit `width`/`height`, and `resolution`/`ratio` are mutually exclusive.")
if has_preset:
if resolution is None or ratio is None:
raise ValueError("LingBot `resolution` and `ratio` must be provided together.")
resolution_value = str(resolution).lower()
ratio_value = str(ratio)
if resolution_value not in LINGBOT_RESOLUTION_PRESETS:
raise ValueError(
f"Unsupported LingBot `resolution` {resolution!r}; choices are {sorted(LINGBOT_RESOLUTION_PRESETS)!r}."
)
presets = LINGBOT_RESOLUTION_PRESETS[resolution_value]
if ratio_value not in presets:
raise ValueError(
f"Unsupported LingBot `ratio` {ratio!r} for resolution {resolution_value!r}; "
f"choices are {sorted(presets)!r}."
)
resolved_height, resolved_width = presets[ratio_value]
elif has_size:
resolved_height, resolved_width = _parse_size(size)
else:
if width is None or height is None:
raise ValueError(
f"LingBot `width` and `height` must be provided together, got width={width!r}, height={height!r}."
)
resolved_height = _positive_int(height, "height")
resolved_width = _positive_int(width, "width")
if resolved_height % 16 != 0 or resolved_width % 16 != 0:
raise ValueError(
"LingBot `height` and `width` must be multiples of 16, "
f"got height={resolved_height}, width={resolved_width}."
)
return resolved_height, resolved_width
def _runtime_prompt_fields(prompt_obj: Any) -> Mapping[str, Any]:
prompt_value = _prompt_value(prompt_obj)
return prompt_value if isinstance(prompt_value, Mapping) else {}
def _pick(mapping: Mapping[str, Any], key: str) -> Any:
value = mapping.get(key)
return value if value is not None else None
def _first_not_none(*values: Any) -> Any:
return next((value for value in values if value is not None), None)
def resolve_lingbot_output_dimensions(
*,
sampling_width: Any = None,
sampling_height: Any = None,
prompt_fields: Mapping[str, Any] | None = None,
extra_fields: Mapping[str, Any] | None = None,
default_width: int = 480,
default_height: int = 480,
) -> tuple[int, int]:
"""Resolve the ``(width, height)`` that LingBot will use for generation."""
prompt_fields = prompt_fields or {}
extra_fields = extra_fields or {}
# Online requests deliver `extra_params` through sampling.extra_args; the
# offline path embeds the same fields in the prompt dict. Consult both,
# extra_args first — the same precedence fps and num_frames use.
requested_width = _first_not_none(_pick(extra_fields, "width"), _pick(prompt_fields, "width"))
requested_height = _first_not_none(_pick(extra_fields, "height"), _pick(prompt_fields, "height"))
size = _first_not_none(_pick(extra_fields, "size"), _pick(prompt_fields, "size"))
resolution = _first_not_none(_pick(extra_fields, "resolution"), _pick(prompt_fields, "resolution"))
ratio = _first_not_none(_pick(extra_fields, "ratio"), _pick(prompt_fields, "ratio"))
if requested_width is not None or requested_height is not None:
width, height = requested_width, requested_height
elif size is not None or resolution is not None or ratio is not None:
width = height = None
else:
width, height = sampling_width, sampling_height
if width is None and height is None and size is None and resolution is None and ratio is None:
width, height = default_width, default_height
resolved_height, resolved_width = resolve_lingbot_size(
width=width,
height=height,
size=size,
resolution=resolution,
ratio=ratio,
)
return resolved_width, resolved_height
def normalize_lingbot_request(
request: Any,
*,
default_negative_prompt: str,
default_image_negative_prompt: str,
default_height: int = 480,
default_width: int = 480,
default_num_frames: int = 81,
default_fps: int = 24,
default_num_inference_steps: int = 40,
default_guidance_scale: float = 6.0,
default_shift: float = 3.0,
default_output_type: str = "pt",
) -> LingBotRequestConfig:
prompt_obj = request.prompt
envelope = _prompt_envelope(prompt_obj)
prompt_fields = _runtime_prompt_fields(prompt_obj)
sampling = request.sampling_params
extra_args = dict(sampling.extra_args or {})
mode = resolve_lingbot_mode(prompt_obj)
prompt = caption_from_lingbot_prompt(_prompt_value(prompt_obj))
images = _image_items(envelope)
input_image = images[0] if images else None
if input_image is not None and not isinstance(input_image, Image.Image):
raise TypeError(f"LingBot input image must be a PIL image after preprocessing, got {type(input_image)!r}.")
prompt_negative = envelope.get("negative_prompt")
if "negative_prompt" in extra_args:
negative_prompt = extra_args["negative_prompt"]
elif prompt_negative is not None:
negative_prompt = prompt_negative
else:
negative_prompt = (
default_image_negative_prompt if mode is LingBotGenerationMode.T2I else default_negative_prompt
)
if not isinstance(negative_prompt, str):
raise TypeError(f"LingBot `negative_prompt` must be a string, got {type(negative_prompt)!r}.")
fps_raw = _first_not_none(
_pick(extra_args, "fps"),
_pick(prompt_fields, "fps"),
sampling.resolved_frame_rate,
default_fps,
)
fps = _positive_int(fps_raw, "fps")
extra_seconds = _pick(extra_args, "seconds")
prompt_seconds = _pick(prompt_fields, "seconds")
duration_value = _pick(extra_args, "duration")
prompt_duration = _pick(prompt_fields, "duration")
if any(value is not None for value in (extra_seconds, prompt_seconds)) and any(
value is not None for value in (duration_value, prompt_duration)
):
raise ValueError("LingBot `seconds` and `duration` cannot be provided together.")
duration = next(
(value for value in (extra_seconds, prompt_seconds, duration_value, prompt_duration) if value is not None),
None,
)
model_num_frames = _first_not_none(
_pick(extra_args, "num_frames"),
_pick(prompt_fields, "num_frames"),
)
explicit_num_frames = model_num_frames
if explicit_num_frames is None and sampling.num_frames not in (None, 1):
# ``sampling.num_frames`` defaults to 1 (the image-model sentinel).
# Treat any other value as an explicit request; ``1`` means "omitted".
explicit_num_frames = sampling.num_frames
if mode is LingBotGenerationMode.T2I:
if duration is not None:
raise ValueError(f"LingBot text-to-image does not accept `seconds` or `duration`, got {duration!r}.")
if explicit_num_frames is not None and _positive_int(explicit_num_frames, "num_frames") != 1:
raise ValueError(f"LingBot text-to-image requires `num_frames=1`, got {explicit_num_frames!r}.")
num_frames = 1
elif explicit_num_frames is not None:
num_frames = normalize_lingbot_num_frames(explicit_num_frames)
elif duration is not None:
num_frames = resolve_lingbot_num_frames(duration, fps)
else:
num_frames = normalize_lingbot_num_frames(default_num_frames)
width, height = resolve_lingbot_output_dimensions(
sampling_width=sampling.width,
sampling_height=sampling.height,
prompt_fields=prompt_fields,
extra_fields=extra_args,
default_width=default_width,
default_height=default_height,
)
num_inference_steps = sampling.num_inference_steps
if num_inference_steps is None:
num_inference_steps = _first_not_none(_pick(extra_args, "num_inference_steps"), default_num_inference_steps)
num_inference_steps = _positive_int(num_inference_steps, "num_inference_steps")
guidance_scale = (
sampling.guidance_scale
if sampling.guidance_scale_provided
else _first_not_none(_pick(extra_args, "guidance_scale"), default_guidance_scale)
)
try:
guidance_scale = float(guidance_scale)
except (TypeError, ValueError) as exc:
raise ValueError(f"LingBot `guidance_scale` must be numeric, got {guidance_scale!r}.") from exc
if not math.isfinite(guidance_scale) or guidance_scale < 0:
raise ValueError(f"LingBot `guidance_scale` must be non-negative, got {guidance_scale!r}.")
shift_raw = (
extra_args["shift"]
if _pick(extra_args, "shift") is not None
else _first_not_none(_pick(extra_args, "flow_shift"), default_shift)
)
try:
shift = float(shift_raw)
except (TypeError, ValueError) as exc:
raise ValueError(f"LingBot `shift` must be numeric, got {shift_raw!r}.") from exc
if not math.isfinite(shift) or shift <= 0:
raise ValueError(f"LingBot `shift` must be positive, got {shift_raw!r}.")
output_type = _first_not_none(
sampling.output_type,
_pick(extra_args, "output_type"),
default_output_type,
)
if output_type not in {"pt", "np", "latent"}:
raise ValueError(f"LingBot `output_type` must be 'pt', 'np', or 'latent', got {output_type!r}.")
return LingBotRequestConfig(
mode=mode,
prompt=prompt,
negative_prompt=negative_prompt,
height=height,
width=width,
num_frames=num_frames,
fps=fps,
num_inference_steps=num_inference_steps,
guidance_scale=guidance_scale,
shift=shift,
output_type=output_type,
input_image=input_image,
)