Files
vllm-omni/vllm_omni/entrypoints/openai/video_api_utils.py
T

528 lines
20 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Shared helper utilities for OpenAI-compatible video generation API.
"""
from __future__ import annotations
import base64
import binascii
from collections import deque
from io import BytesIO
from typing import Any, Literal
import httpx
import numpy as np
import torch
from PIL import Image, UnidentifiedImageError
from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError
from vllm_omni.entrypoints.openai.protocol.videos import (
FileImageReference,
FileVideoReference,
ImageReference,
UrlImageReference,
UrlVideoReference,
VideoReference,
)
class VideoFrames(list[Image.Image]):
"""Decoded video frames plus source metadata."""
def __init__(self, frames: list[Image.Image] | None = None, *, fps: float | None = None) -> None:
super().__init__(frames or [])
self.fps = fps
self.frame_rate = fps
def positive_float(value: Any) -> float | None:
if value is None:
return None
if hasattr(value, "item") and not isinstance(value, (bytes, str)):
value = value.item()
try:
result = float(value)
except (TypeError, ValueError):
return None
if result <= 0:
return None
return result
def _decode_image_bytes(image_bytes: bytes, *, source: str) -> Image.Image:
try:
return Image.open(BytesIO(image_bytes)).convert("RGB")
except (UnidentifiedImageError, OSError, ValueError) as exc:
raise InvalidInputReferenceError(f"Invalid {source}: provided content is not a valid image.") from exc
def _decode_video_bytes(
video_bytes: bytes,
*,
source: str,
max_frames: int | None = None,
keep: Literal["first", "last"] = "first",
) -> VideoFrames:
try:
import av
except ImportError as exc: # pragma: no cover - av is a serving dependency via media_utils
raise InvalidInputReferenceError(f"Invalid {source}: video decoding requires PyAV.") from exc
if keep not in {"first", "last"}:
raise InvalidInputReferenceError(f"Invalid {source}: video frame selection must be 'first' or 'last'.")
if max_frames is not None and max_frames <= 0:
raise InvalidInputReferenceError(f"Invalid {source}: max video frames must be positive.")
frames: list[Image.Image] = []
tail_frames: deque[Image.Image] | None = (
deque(maxlen=max_frames) if keep == "last" and max_frames is not None else None
)
fps: float | None = None
try:
with av.open(BytesIO(video_bytes)) as container:
video_stream = container.streams.video[0] if container.streams.video else None
if video_stream is not None:
fps = (
positive_float(getattr(video_stream, "average_rate", None))
or positive_float(getattr(video_stream, "base_rate", None))
or positive_float(getattr(video_stream, "guessed_rate", None))
)
for frame in container.decode(video=0):
image = frame.to_image().convert("RGB")
if tail_frames is not None:
tail_frames.append(image)
else:
frames.append(image)
if keep == "first" and max_frames is not None and len(frames) >= max_frames:
break
except Exception as exc:
raise InvalidInputReferenceError(f"Invalid {source}: provided content is not a valid video.") from exc
if tail_frames is not None:
frames = list(tail_frames)
if not frames:
raise InvalidInputReferenceError(f"Invalid {source}: provided content is not a valid video.")
return VideoFrames(frames, fps=fps)
def _decode_media_bytes(
media_bytes: bytes,
*,
source: str,
max_video_frames: int | None = None,
video_keep: Literal["first", "last"] = "first",
) -> Image.Image | VideoFrames:
try:
return _decode_image_bytes(media_bytes, source=source)
except InvalidInputReferenceError:
try:
return _decode_video_bytes(
media_bytes,
source=source,
max_frames=max_video_frames,
keep=video_keep,
)
except InvalidInputReferenceError as video_exc:
raise InvalidInputReferenceError(
f"Invalid {source}: provided content is not a valid image or video."
) from video_exc
def _decode_base64_image(input_reference: str, *, source: str) -> Image.Image:
if input_reference:
if input_reference.startswith("data:image"):
_, b64_data = input_reference.split(",", 1)
else:
b64_data = input_reference
try:
image_bytes = base64.b64decode(b64_data)
except (binascii.Error, ValueError) as exc: # pragma: no cover - malformed base64
raise InvalidInputReferenceError(f"Invalid {source}: image data is not valid base64.") from exc
return _decode_image_bytes(image_bytes, source=source)
raise InvalidInputReferenceError(f"Invalid {source}: image data is empty.")
async def decode_image_url(image_url: str) -> Image.Image:
if image_url.startswith("data:image"):
return _decode_base64_image(image_url, source="image_reference.image_url")
if image_url.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=60) as client:
try:
response = await client.get(image_url)
response.raise_for_status()
except httpx.HTTPError as exc:
raise InvalidInputReferenceError(
"Invalid image_reference.image_url: failed to download image."
) from exc
return _decode_image_bytes(response.content, source="image_reference.image_url")
raise InvalidInputReferenceError("Invalid image_reference.image_url: must be an http(s) URL or data URL.")
def _decode_base64_video(
video_reference: str,
*,
source: str,
max_frames: int | None = None,
keep: Literal["first", "last"] = "first",
) -> VideoFrames:
if video_reference:
if video_reference.startswith("data:video"):
_, b64_data = video_reference.split(",", 1)
else:
b64_data = video_reference
try:
video_bytes = base64.b64decode(b64_data)
except (binascii.Error, ValueError) as exc: # pragma: no cover - malformed base64
raise InvalidInputReferenceError(f"Invalid {source}: video data is not valid base64.") from exc
return _decode_video_bytes(video_bytes, source=source, max_frames=max_frames, keep=keep)
raise InvalidInputReferenceError(f"Invalid {source}: video data is empty.")
async def decode_video_url(
video_url: str,
*,
max_frames: int | None = None,
keep: Literal["first", "last"] = "first",
) -> VideoFrames:
if video_url.startswith("data:video"):
return _decode_base64_video(
video_url,
source="video_reference.video_url",
max_frames=max_frames,
keep=keep,
)
if video_url.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=60) as client:
try:
response = await client.get(video_url)
response.raise_for_status()
except httpx.HTTPError as exc:
raise InvalidInputReferenceError(
"Invalid video_reference.video_url: failed to download video."
) from exc
return _decode_video_bytes(
response.content,
source="video_reference.video_url",
max_frames=max_frames,
keep=keep,
)
raise InvalidInputReferenceError("Invalid video_reference.video_url: must be an http(s) URL or data URL.")
async def decode_audio_url(audio_url: str) -> str:
"""Decode an audio URL or data-URL to a temporary file path."""
import tempfile
audio_bytes: bytes | None = None
if audio_url.startswith("data:audio"):
_, b64_data = audio_url.split(",", 1)
try:
audio_bytes = base64.b64decode(b64_data)
except (binascii.Error, ValueError) as exc:
raise InvalidInputReferenceError(
"Invalid audio_reference.audio_url: audio data is not valid base64."
) from exc
elif audio_url.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=60) as client:
try:
response = await client.get(audio_url)
response.raise_for_status()
except httpx.HTTPError as exc:
raise InvalidInputReferenceError(
"Invalid audio_reference.audio_url: failed to download audio."
) from exc
audio_bytes = response.content
else:
raise InvalidInputReferenceError("Invalid audio_reference.audio_url: must be an http(s) URL or data URL.")
if not audio_bytes:
raise InvalidInputReferenceError("Invalid audio_reference: audio data is empty.")
suffix = ".wav"
if audio_url.startswith("data:audio/"):
mime = audio_url.split(";")[0].removeprefix("data:")
ext = mime.split("/")[-1]
if ext in ("mpeg", "mp3"):
suffix = ".mp3"
elif ext == "wav":
suffix = ".wav"
elif ext.isalnum() and len(ext) <= 8:
suffix = f".{ext}"
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
tmp.write(audio_bytes)
tmp.close()
return tmp.name
async def decode_input_reference(
image_reference: ImageReference | None,
video_reference: VideoReference | None,
input_reference_bytes: bytes | None,
*,
max_video_frames: int | None = None,
video_keep: Literal["first", "last"] = "first",
) -> Image.Image | VideoFrames | None:
"""Decode media input from multipart bytes, data URLs, or typed references."""
provided = sum(item is not None for item in (input_reference_bytes, image_reference, video_reference))
if provided > 1:
raise InvalidInputReferenceError("Provide only one of input_reference, image_reference, or video_reference.")
if isinstance(input_reference_bytes, bytes):
return _decode_media_bytes(
input_reference_bytes,
source="input_reference",
max_video_frames=max_video_frames,
video_keep=video_keep,
)
if isinstance(image_reference, UrlImageReference):
return await decode_image_url(image_reference.image_url)
elif isinstance(image_reference, FileImageReference):
raise InvalidInputReferenceError("Invalid image_reference: file_id is not supported yet.")
if isinstance(video_reference, UrlVideoReference):
return await decode_video_url(
video_reference.video_url,
max_frames=max_video_frames,
keep=video_keep,
)
elif isinstance(video_reference, FileVideoReference):
raise InvalidInputReferenceError("Invalid video_reference: file_id is not supported yet.")
return None
def _normalize_video_tensor(video_tensor: torch.Tensor) -> np.ndarray:
"""Normalize a torch video tensor into a numpy array of frames (F, H, W, C)."""
video_tensor = video_tensor.detach().cpu()
if video_tensor.dim() == 5:
raise ValueError("Batched video tensors are not supported for single-video encoding.")
elif video_tensor.dim() == 4 and video_tensor.shape[0] in (3, 4):
# [C, F, H, W] -> [F, H, W, C]
video_tensor = video_tensor.permute(1, 2, 3, 0)
if video_tensor.is_floating_point():
# Cast to float32 first: bf16 (e.g. SANA-WM's refiner output) has no
# numpy dtype, so ``.numpy()`` below raises on it.
video_tensor = video_tensor.float().clamp(-1, 1) * 0.5 + 0.5
else:
video_tensor = video_tensor.to(torch.float32) / 255.0
video_array = video_tensor.numpy()
return _normalize_single_video_array(video_array)
def _normalize_single_video_array(video_array: np.ndarray) -> np.ndarray:
"""Normalize a single video array into shape (F, H, W, C)."""
if video_array.ndim == 5:
raise ValueError("Batched video arrays are not supported for single-video encoding.")
if video_array.ndim == 4:
# Convert channel-first layouts to channel-last
if video_array.shape[0] in (3, 4) and video_array.shape[-1] not in (3, 4):
video_array = np.transpose(video_array, (1, 2, 3, 0))
elif video_array.shape[1] in (3, 4) and video_array.shape[-1] not in (3, 4):
video_array = np.transpose(video_array, (0, 2, 3, 1))
if np.issubdtype(video_array.dtype, np.floating):
if video_array.min() < 0.0 or video_array.max() > 1.0:
video_array = np.clip(video_array, -1.0, 1.0) * 0.5 + 0.5
elif np.issubdtype(video_array.dtype, np.integer):
video_array = video_array.astype(np.float32) / 255.0
return video_array
def _normalize_video_array(video_array: np.ndarray) -> list[np.ndarray] | np.ndarray:
"""Normalize a numpy video array into shape (F, H, W, C).
If a batch dimension is present, returns a list of per-video arrays.
"""
if video_array.ndim == 5:
return [_normalize_single_video_array(video_array[i]) for i in range(video_array.shape[0])]
return _normalize_single_video_array(video_array)
def _normalize_frames(frames: list[Any]) -> list[np.ndarray]:
"""Normalize a list of frames into numpy arrays with values in [0,1]."""
normalized: list[np.ndarray] = []
for frame in frames:
if isinstance(frame, torch.Tensor):
frame_array = frame.detach().cpu().numpy()
elif isinstance(frame, Image.Image):
frame_array = np.array(frame)
elif isinstance(frame, np.ndarray):
frame_array = frame
else:
raise ValueError(f"Unsupported frame type: {type(frame)}")
if frame_array.ndim == 3 and frame_array.shape[0] in (3, 4) and frame_array.shape[-1] not in (3, 4):
frame_array = np.transpose(frame_array, (1, 2, 0))
if np.issubdtype(frame_array.dtype, np.floating):
if frame_array.min() < 0.0 or frame_array.max() > 1.0:
frame_array = np.clip(frame_array, -1.0, 1.0) * 0.5 + 0.5
elif np.issubdtype(frame_array.dtype, np.integer):
frame_array = frame_array.astype(np.float32) / 255.0
normalized.append(frame_array)
return normalized
def _coerce_video_to_frames(video: Any) -> list[np.ndarray]:
"""Convert a video payload into a list of normalized float32 frames."""
if isinstance(video, torch.Tensor):
video_array = _normalize_video_tensor(video)
return list(video_array)
if isinstance(video, np.ndarray):
video_array = _normalize_video_array(video)
if isinstance(video_array, list):
raise ValueError("Batched video arrays must be split before encoding.")
if video_array.ndim == 4:
return list(video_array)
if video_array.ndim == 3:
return [video_array]
raise ValueError(f"Unsupported video array shape: {video_array.shape}")
if isinstance(video, list):
if not video:
return []
# If this looks like a list of frames, normalize directly.
if all(isinstance(item, (np.ndarray, torch.Tensor, Image.Image)) for item in video):
# If each item is itself a video (ndim==4), handle elsewhere.
if all(hasattr(item, "ndim") and item.ndim >= 4 for item in video):
raise ValueError("Expected a single video, got a list of video tensors/arrays.")
return _normalize_frames(video)
raise ValueError("Unsupported list contents for video payload.")
raise ValueError(f"Unsupported video payload type: {type(video)}")
def _coerce_audio_to_numpy(audio: Any) -> np.ndarray:
"""Convert an audio payload into a float32 numpy array for muxing."""
if isinstance(audio, torch.Tensor):
arr = audio.detach().cpu().float().numpy()
elif isinstance(audio, np.ndarray):
arr = audio
elif isinstance(audio, list):
arr = np.array(audio)
else:
raise ValueError(f"Unsupported audio payload type: {type(audio)}")
arr = np.squeeze(arr)
if arr.ndim == 0:
raise ValueError("Audio payload must contain at least one sample.")
return arr.astype(np.float32)
def _coerce_video_to_uint8_frames(video: Any) -> np.ndarray:
"""Convert a video payload into contiguous uint8 frames shaped (F, H, W, 3)."""
frames = _coerce_video_to_frames(video)
if not frames:
raise ValueError("No frames found to encode.")
frames_np = np.stack(frames, axis=0)
if frames_np.ndim == 4 and frames_np.shape[-1] == 4:
frames_np = frames_np[..., :3]
if frames_np.dtype == np.uint8:
frames_u8 = frames_np
else:
frames_np = np.clip(frames_np, 0.0, 1.0)
frames_np *= 255.0
frames_u8 = np.round(frames_np).astype(np.uint8)
return np.ascontiguousarray(frames_u8)
def _encode_video_bytes(
video: Any,
fps: int,
audio: Any | None = None,
audio_sample_rate: int | None = None,
video_codec_options: dict[str, str] | None = None,
) -> bytes:
"""Encode a video payload into MP4 bytes, optionally muxing audio."""
from vllm_omni.diffusion.utils.media_utils import mux_video_audio_bytes
audio_np = _coerce_audio_to_numpy(audio) if audio is not None else None
return mux_video_audio_bytes(
_coerce_video_to_uint8_frames(video),
audio_np,
fps=float(fps),
audio_sample_rate=audio_sample_rate or 24000,
video_codec_options=video_codec_options,
)
class FragmentedMP4VideoEncoder:
"""Normalize video chunks and append them to one fragmented MP4 stream."""
def __init__(
self,
*,
fps: int | float,
video_codec_options: dict[str, str] | None = None,
) -> None:
self._fps = float(fps)
self._video_codec_options = video_codec_options
self._muxer: Any | None = None
def encode(self, video: Any) -> bytes:
"""Encode one generated video chunk and return newly emitted fMP4 bytes."""
from vllm_omni.diffusion.utils.media_utils import FragmentedMP4Muxer
frames_u8 = _coerce_video_to_uint8_frames(video)
if self._muxer is None:
self._muxer = FragmentedMP4Muxer(
width=frames_u8.shape[2],
height=frames_u8.shape[1],
fps=self._fps,
video_codec_options=self._video_codec_options,
)
return self._muxer.mux_video_frames(frames_u8)
def close(self) -> bytes:
"""Close the underlying fMP4 muxer and return trailing bytes, if any."""
if self._muxer is None:
return b""
return self._muxer.close()
StreamingVideoFormat = Literal["m4s"]
def create_streaming_video_encoder(
*,
output_format: StreamingVideoFormat,
fps: int | float,
video_codec_options: dict[str, str] | None = None,
) -> FragmentedMP4VideoEncoder:
"""Create an incremental encoder for the requested WebSocket video format."""
if output_format == "m4s":
return FragmentedMP4VideoEncoder(fps=fps, video_codec_options=video_codec_options)
raise ValueError(f"Unsupported streaming video format: {output_format}")
def encode_video_base64(
video: Any,
fps: int,
audio: Any | None = None,
audio_sample_rate: int | None = None,
video_codec_options: dict[str, str] | None = None,
) -> str:
"""Encode a video (frames/array/tensor) to base64 MP4."""
video_bytes = _encode_video_bytes(
video, fps=fps, audio=audio, audio_sample_rate=audio_sample_rate, video_codec_options=video_codec_options
)
return base64.b64encode(video_bytes).decode("utf-8")