Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com> Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
927 lines
34 KiB
Python
927 lines
34 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
"""
|
|
Shared helper utilities for OpenAI-compatible video generation API.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import os
|
|
import tempfile
|
|
import threading
|
|
from collections import deque
|
|
from collections.abc import Generator
|
|
from concurrent.futures import Future, ThreadPoolExecutor
|
|
from io import BytesIO
|
|
from typing import TYPE_CHECKING, Any, Literal, TypeAlias
|
|
|
|
import httpx
|
|
import numpy as np
|
|
import torch
|
|
from PIL import Image, UnidentifiedImageError
|
|
from vllm import envs
|
|
from vllm.logger import init_logger
|
|
from vllm.multimodal.video import (
|
|
VIDEO_LOADER_REGISTRY,
|
|
VideoBackend,
|
|
VideoSourceMetadata,
|
|
VideoTargetMetadata,
|
|
)
|
|
|
|
from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError
|
|
from vllm_omni.entrypoints.openai.protocol.videos import (
|
|
FileImageReference,
|
|
FileVideoReference,
|
|
ImageReference,
|
|
UrlImageReference,
|
|
UrlVideoReference,
|
|
VideoReference,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
import av
|
|
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
|
|
DEFAULT_AUDIO_SAMPLE_RATE = 24_000
|
|
|
|
|
|
VideoInput: TypeAlias = torch.Tensor | np.ndarray | list[torch.Tensor | np.ndarray | Image.Image]
|
|
AudioSample = int | float
|
|
AudioInput: TypeAlias = torch.Tensor | np.ndarray | list[AudioSample] | list[list[AudioSample]]
|
|
|
|
|
|
class VideoFrames(list[Image.Image]):
|
|
"""Decoded video frames plus source metadata."""
|
|
|
|
def __init__(
|
|
self,
|
|
frames: list[Image.Image] | None = None,
|
|
*,
|
|
fps: float | None = None,
|
|
source_path: str | None = None,
|
|
) -> None:
|
|
super().__init__(frames or [])
|
|
self.fps = fps
|
|
self.frame_rate = fps
|
|
self.source_path = source_path
|
|
|
|
|
|
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
|
|
|
|
|
|
@VIDEO_LOADER_REGISTRY.register("omni")
|
|
class OmniVideoBackend(VideoBackend):
|
|
"""Video backend that selects sequential first-N or last-N frames."""
|
|
|
|
@classmethod
|
|
def compute_frames_index_to_sample(
|
|
cls,
|
|
source: VideoSourceMetadata,
|
|
target: VideoTargetMetadata,
|
|
*,
|
|
keep: Literal["first", "last"] = "first",
|
|
**kwargs,
|
|
) -> list[int]:
|
|
num_frames_to_sample = source.total_frames_num
|
|
if target.num_frames > 0:
|
|
num_frames_to_sample = min(num_frames_to_sample, target.num_frames)
|
|
num_frames_to_sample = max(1, num_frames_to_sample)
|
|
if num_frames_to_sample >= source.total_frames_num:
|
|
return list(range(source.total_frames_num))
|
|
if keep == "last":
|
|
return list(range(source.total_frames_num - num_frames_to_sample, source.total_frames_num))
|
|
return list(range(num_frames_to_sample))
|
|
|
|
|
|
def _decode_video_bytes(
|
|
video_bytes: bytes,
|
|
*,
|
|
source: str,
|
|
max_frames: int | None = None,
|
|
keep: Literal["first", "last"] = "first",
|
|
source_path: str | None = None,
|
|
) -> VideoFrames:
|
|
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.")
|
|
|
|
loader = VIDEO_LOADER_REGISTRY.load("omni")
|
|
num_frames = max_frames if max_frames is not None else -1
|
|
try:
|
|
frames_array, metadata = loader.load_bytes(
|
|
video_bytes,
|
|
num_frames=num_frames,
|
|
backend="pyav",
|
|
keep=keep,
|
|
)
|
|
except Exception as exc:
|
|
raise InvalidInputReferenceError(f"Invalid {source}: provided content is not a valid video.") from exc
|
|
|
|
fps: float | None = positive_float(metadata.get("fps"))
|
|
|
|
frames = [Image.fromarray(f, "RGB") for f in frames_array]
|
|
if not frames:
|
|
raise InvalidInputReferenceError(f"Invalid {source}: provided content is not a valid video.")
|
|
return VideoFrames(frames, fps=fps, source_path=source_path)
|
|
|
|
|
|
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://")):
|
|
allow_redirects = envs.VLLM_MEDIA_URL_ALLOW_REDIRECTS
|
|
async with httpx.AsyncClient(timeout=60, follow_redirects=allow_redirects) as client:
|
|
try:
|
|
response = await client.get(image_url)
|
|
response.raise_for_status()
|
|
except httpx.HTTPStatusError as exc:
|
|
if exc.response.has_redirect_location and not allow_redirects:
|
|
raise InvalidInputReferenceError(
|
|
"Invalid image_reference.image_url: redirect response was rejected because "
|
|
"VLLM_MEDIA_URL_ALLOW_REDIRECTS is disabled."
|
|
) from exc
|
|
raise InvalidInputReferenceError(
|
|
f"Invalid image_reference.image_url: server returned HTTP {exc.response.status_code}."
|
|
) from exc
|
|
except httpx.RequestError 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",
|
|
source_path: str | None = None,
|
|
) -> 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
|
|
if source_path is not None:
|
|
with open(source_path, "wb") as output:
|
|
output.write(video_bytes)
|
|
return _decode_video_bytes(
|
|
video_bytes,
|
|
source=source,
|
|
max_frames=max_frames,
|
|
keep=keep,
|
|
source_path=source_path,
|
|
)
|
|
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"):
|
|
path = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4").name
|
|
try:
|
|
return _decode_base64_video(
|
|
video_url,
|
|
source="video_reference.video_url",
|
|
max_frames=max_frames,
|
|
keep=keep,
|
|
source_path=path,
|
|
)
|
|
except Exception:
|
|
if os.path.exists(path):
|
|
os.unlink(path)
|
|
raise
|
|
|
|
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
|
|
path = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4").name
|
|
try:
|
|
with open(path, "wb") as output:
|
|
output.write(response.content)
|
|
except Exception:
|
|
if os.path.exists(path):
|
|
os.unlink(path)
|
|
raise
|
|
try:
|
|
return _decode_video_bytes(
|
|
response.content,
|
|
source="video_reference.video_url",
|
|
max_frames=max_frames,
|
|
keep=keep,
|
|
source_path=path,
|
|
)
|
|
except Exception:
|
|
if os.path.exists(path):
|
|
os.unlink(path)
|
|
raise
|
|
|
|
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.")
|
|
|
|
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
|
|
elif video_tensor.dtype != torch.uint8:
|
|
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
|
|
# Prefer an explicit channel-last dimension for ambiguous 3/4-frame
|
|
# videos before interpreting a leading dimension as channels.
|
|
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.size and (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 video_array.dtype != np.uint8 and 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, uint8 ones unchanged."""
|
|
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.size and (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 frame_array.dtype != np.uint8 and 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 frames.
|
|
|
|
Frames are float32 in [0, 1], except uint8 ones, which pass through: the
|
|
muxer's own dtype is uint8, so normalising here would only pay for a
|
|
full-size conversion each way. The direct planar path additionally needs
|
|
contiguous channel planes and falls back for interleaved RGB whatever the
|
|
dtype; the standard muxer takes the uint8 frames as they are.
|
|
"""
|
|
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 _prepare_video_frames(video: Any) -> tuple[list[np.ndarray], tuple[int, ...], np.dtype]:
|
|
"""Normalize and validate frames for the common video encoding dispatcher."""
|
|
frames = _coerce_video_to_frames(video)
|
|
if not frames:
|
|
raise ValueError("No frames found to encode.")
|
|
|
|
frame_shape = frames[0].shape
|
|
if any(frame.shape != frame_shape for frame in frames[1:]):
|
|
raise ValueError("All video frames must have the same shape.")
|
|
|
|
common_dtype = np.result_type(*(frame.dtype for frame in frames))
|
|
return frames, frame_shape, common_dtype
|
|
|
|
|
|
def _coerce_prepared_video_to_uint8_frames(
|
|
frames: list[np.ndarray],
|
|
frame_shape: tuple[int, ...],
|
|
common_dtype: np.dtype,
|
|
) -> np.ndarray:
|
|
"""Convert prepared frames into contiguous uint8 frames for the legacy muxer."""
|
|
has_alpha = len(frame_shape) == 3 and frame_shape[-1] == 4
|
|
output_shape = (*frame_shape[:-1], 3) if has_alpha else frame_shape
|
|
frames_u8 = np.empty((len(frames), *output_shape), dtype=np.uint8)
|
|
|
|
# Convert one frame at a time instead of stacking the normalized float
|
|
# payload first. Long videos can otherwise require another full-size
|
|
# float array plus conversion temporaries before encoding.
|
|
for index, frame in enumerate(frames):
|
|
if frame.shape != frame_shape:
|
|
raise ValueError("All video frames must have the same shape.")
|
|
frame = frame[..., :3] if has_alpha else frame
|
|
if frame.dtype == np.uint8:
|
|
frames_u8[index] = frame
|
|
continue
|
|
|
|
# np.stack(), used by the previous implementation, promoted mixed
|
|
# frame dtypes before scaling. Preserve those rounding semantics
|
|
# without allocating a full-video float buffer.
|
|
scaled = np.clip(frame.astype(common_dtype, copy=False), 0.0, 1.0)
|
|
scaled *= 255.0
|
|
np.rint(scaled, out=scaled)
|
|
frames_u8[index] = scaled
|
|
|
|
return frames_u8
|
|
|
|
|
|
def _coerce_video_to_uint8_frames(video: Any) -> np.ndarray:
|
|
"""Convert a video payload into contiguous uint8 frames shaped (F, H, W, 3)."""
|
|
if (
|
|
isinstance(video, np.ndarray)
|
|
and video.dtype == np.uint8
|
|
and video.ndim == 4
|
|
and video.shape[-1] == 3
|
|
and video.flags.c_contiguous
|
|
):
|
|
return video
|
|
frames, frame_shape, common_dtype = _prepare_video_frames(video)
|
|
return _coerce_prepared_video_to_uint8_frames(frames, frame_shape, common_dtype)
|
|
|
|
|
|
def _direct_planar_fallback_reason(
|
|
frames: list[np.ndarray],
|
|
frame_shape: tuple[int, ...],
|
|
common_dtype: np.dtype,
|
|
*,
|
|
allow_strided_rgb_planes: bool = False,
|
|
) -> str | None:
|
|
"""Return a stable reason when direct planar muxing cannot consume frames."""
|
|
if len(frame_shape) != 3 or frame_shape[0] <= 0 or frame_shape[1] <= 0 or frame_shape[2] not in (3, 4):
|
|
return "unsupported_shape"
|
|
|
|
if not (
|
|
common_dtype == np.dtype(np.uint8)
|
|
or np.issubdtype(common_dtype, np.bool_)
|
|
or np.issubdtype(common_dtype, np.floating)
|
|
):
|
|
return "unsupported_dtype"
|
|
|
|
if not allow_strided_rgb_planes and not all(
|
|
frame[..., channel].flags.c_contiguous for frame in frames for channel in range(3)
|
|
):
|
|
return "non_contiguous_rgb_planes"
|
|
|
|
return None
|
|
|
|
|
|
class _PlanarFrameConverter:
|
|
"""Convert response frames with one reusable, bounded executor."""
|
|
|
|
def __init__(self, max_workers: int) -> None:
|
|
self._max_workers = max_workers
|
|
if self._max_workers < 1:
|
|
raise ValueError("max_workers must be positive")
|
|
self._executor = (
|
|
ThreadPoolExecutor(max_workers=self._max_workers, thread_name_prefix="video-planar")
|
|
if self._max_workers > 1
|
|
else None
|
|
)
|
|
self._scratch_local = threading.local()
|
|
self._shutdown = False
|
|
|
|
@property
|
|
def max_workers(self) -> int:
|
|
return self._max_workers
|
|
|
|
def _channel_scratch(self, height: int, width: int, common_dtype: np.dtype) -> np.ndarray:
|
|
scratch_dtype = np.dtype(common_dtype)
|
|
if not np.issubdtype(scratch_dtype, np.floating):
|
|
scratch_dtype = np.dtype(np.float64)
|
|
scratch = getattr(self._scratch_local, "channel", None)
|
|
if scratch is None or scratch.shape != (height, width) or scratch.dtype != scratch_dtype:
|
|
scratch = np.empty((height, width), dtype=scratch_dtype)
|
|
self._scratch_local.channel = scratch
|
|
return scratch
|
|
|
|
def _build_frame(self, frame: np.ndarray, common_dtype: np.dtype) -> av.VideoFrame:
|
|
"""Build one quantized GBR PyAV frame."""
|
|
import av
|
|
|
|
height, width = frame.shape[:2]
|
|
scratch = None if frame.dtype == np.uint8 else self._channel_scratch(height, width, common_dtype)
|
|
av_frame = av.VideoFrame(width, height, format="gbrp")
|
|
for plane, channel in zip(av_frame.planes, (1, 2, 0)):
|
|
if plane.height < height or plane.line_size < width:
|
|
raise ValueError("PyAV video plane is smaller than the requested frame dimensions.")
|
|
plane_view = np.frombuffer(
|
|
memoryview(plane),
|
|
dtype=np.uint8,
|
|
count=plane.height * plane.line_size,
|
|
).reshape(plane.height, plane.line_size)
|
|
plane_view.fill(0)
|
|
if frame.dtype == np.uint8:
|
|
plane_view[:height, :width] = frame[..., channel]
|
|
else:
|
|
assert scratch is not None
|
|
np.copyto(scratch, frame[..., channel], casting="unsafe")
|
|
np.clip(scratch, 0.0, 1.0, out=scratch)
|
|
scratch *= 255.0
|
|
np.rint(scratch, out=scratch)
|
|
plane_view[:height, :width] = scratch
|
|
return av_frame
|
|
|
|
def iter_frames(
|
|
self,
|
|
frames: list[np.ndarray],
|
|
common_dtype: np.dtype,
|
|
) -> Generator[av.VideoFrame, None, None]:
|
|
"""Yield converted frames in order with at most two batches in flight."""
|
|
effective_workers = min(len(frames), self._max_workers)
|
|
if effective_workers <= 1:
|
|
for frame in frames:
|
|
yield self._build_frame(frame, common_dtype)
|
|
return
|
|
|
|
executor = self._executor
|
|
assert executor is not None
|
|
pending: deque[Future[av.VideoFrame]] = deque()
|
|
next_index = 0
|
|
max_pending = 2 * effective_workers
|
|
try:
|
|
while next_index < len(frames) and len(pending) < max_pending:
|
|
pending.append(executor.submit(self._build_frame, frames[next_index], common_dtype))
|
|
next_index += 1
|
|
|
|
while pending:
|
|
converted_frame = pending.popleft().result()
|
|
if next_index < len(frames):
|
|
pending.append(executor.submit(self._build_frame, frames[next_index], common_dtype))
|
|
next_index += 1
|
|
yield converted_frame
|
|
finally:
|
|
for future in pending:
|
|
future.cancel()
|
|
|
|
def shutdown(self) -> None:
|
|
if not self._shutdown:
|
|
self._shutdown = True
|
|
if self._executor is not None:
|
|
self._executor.shutdown(wait=True, cancel_futures=True)
|
|
|
|
|
|
def _iter_planar_video_frames(
|
|
frames: list[np.ndarray],
|
|
common_dtype: np.dtype,
|
|
) -> Generator[av.VideoFrame, None, None]:
|
|
"""Yield serial planar frames for direct utility callers."""
|
|
converter = _PlanarFrameConverter(max_workers=1)
|
|
try:
|
|
yield from converter.iter_frames(frames, common_dtype)
|
|
finally:
|
|
converter.shutdown()
|
|
|
|
|
|
def _log_video_encoding_path(
|
|
*,
|
|
selected_path: str,
|
|
frames: list[np.ndarray],
|
|
frame_shape: tuple[int, ...],
|
|
common_dtype: np.dtype,
|
|
fps: int,
|
|
audio: AudioInput | None,
|
|
audio_sample_rate: int | None,
|
|
effective_frame_conversion_workers: int,
|
|
reason: str | None = None,
|
|
) -> None:
|
|
reason_field = "" if reason is None else f" reason={reason}"
|
|
logger.info(
|
|
"Video response encoding route selected: selected_path=%s%s frames=%d frame_shape=%s dtype=%s fps=%s "
|
|
"audio_present=%s effective_audio_sample_rate=%s effective_frame_conversion_workers=%s",
|
|
selected_path,
|
|
reason_field,
|
|
len(frames),
|
|
frame_shape,
|
|
np.dtype(common_dtype).name,
|
|
fps,
|
|
audio is not None,
|
|
audio_sample_rate,
|
|
effective_frame_conversion_workers,
|
|
)
|
|
|
|
|
|
def _resolve_audio_sample_rate(audio: AudioInput | None, audio_sample_rate: int | None) -> int:
|
|
if audio is not None and audio_sample_rate is None:
|
|
logger.info_once(
|
|
"Audio sample rate was not provided; using default sample rate of %s Hz.",
|
|
DEFAULT_AUDIO_SAMPLE_RATE,
|
|
)
|
|
return audio_sample_rate or DEFAULT_AUDIO_SAMPLE_RATE
|
|
|
|
|
|
def _encode_prepared_video_bytes_legacy(
|
|
frames: list[np.ndarray],
|
|
frame_shape: tuple[int, ...],
|
|
common_dtype: np.dtype,
|
|
fps: int,
|
|
audio: Any | None = None,
|
|
audio_sample_rate: int | None = None,
|
|
video_codec_options: dict[str, str] | None = None,
|
|
) -> bytes:
|
|
"""Encode validated frames through the compatibility path used before planar encoding."""
|
|
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_prepared_video_to_uint8_frames(frames, frame_shape, common_dtype),
|
|
audio_np,
|
|
fps=float(fps),
|
|
audio_sample_rate=audio_sample_rate or DEFAULT_AUDIO_SAMPLE_RATE,
|
|
video_codec_options=video_codec_options,
|
|
)
|
|
|
|
|
|
def _encode_video_bytes_legacy(
|
|
video: Any,
|
|
fps: int,
|
|
audio: Any | None = None,
|
|
audio_sample_rate: int | None = None,
|
|
video_codec_options: dict[str, str] | None = None,
|
|
) -> bytes:
|
|
"""Encode through the compatibility path used before planar encoding."""
|
|
frames, frame_shape, common_dtype = _prepare_video_frames(video)
|
|
return _encode_prepared_video_bytes_legacy(
|
|
frames,
|
|
frame_shape,
|
|
common_dtype,
|
|
fps,
|
|
audio=audio,
|
|
audio_sample_rate=_resolve_audio_sample_rate(audio, audio_sample_rate),
|
|
video_codec_options=video_codec_options,
|
|
)
|
|
|
|
|
|
def _encode_video_bytes(
|
|
video: Any,
|
|
fps: int | float,
|
|
audio: Any | None = None,
|
|
audio_sample_rate: int | None = None,
|
|
video_codec_options: dict[str, str] | None = None,
|
|
frame_converter: _PlanarFrameConverter | None = None,
|
|
) -> bytes:
|
|
"""Encode a video payload through the direct planar or legacy path."""
|
|
from vllm_omni.diffusion.utils.media_utils import mux_av_video_audio_bytes
|
|
|
|
# Prepare once so validation is shared by both paths and malformed common
|
|
# input is reported before any muxer is opened.
|
|
frames, frame_shape, common_dtype = _prepare_video_frames(video)
|
|
effective_audio_sample_rate = _resolve_audio_sample_rate(audio, audio_sample_rate) if audio is not None else None
|
|
fallback_reason = _direct_planar_fallback_reason(
|
|
frames,
|
|
frame_shape,
|
|
common_dtype,
|
|
allow_strided_rgb_planes=frame_converter is not None and frame_converter.max_workers > 1,
|
|
)
|
|
if fallback_reason is not None:
|
|
_log_video_encoding_path(
|
|
selected_path="legacy_fallback",
|
|
reason=fallback_reason,
|
|
frames=frames,
|
|
frame_shape=frame_shape,
|
|
common_dtype=common_dtype,
|
|
fps=fps,
|
|
audio=audio,
|
|
audio_sample_rate=effective_audio_sample_rate,
|
|
effective_frame_conversion_workers=0,
|
|
)
|
|
return _encode_prepared_video_bytes_legacy(
|
|
frames,
|
|
frame_shape,
|
|
common_dtype,
|
|
fps,
|
|
audio=audio,
|
|
audio_sample_rate=effective_audio_sample_rate,
|
|
video_codec_options=video_codec_options,
|
|
)
|
|
|
|
owns_frame_converter = frame_converter is None
|
|
converter = frame_converter or _PlanarFrameConverter(max_workers=1)
|
|
_log_video_encoding_path(
|
|
selected_path="direct_planar",
|
|
frames=frames,
|
|
frame_shape=frame_shape,
|
|
common_dtype=common_dtype,
|
|
fps=fps,
|
|
audio=audio,
|
|
audio_sample_rate=effective_audio_sample_rate,
|
|
effective_frame_conversion_workers=min(len(frames), converter.max_workers),
|
|
)
|
|
audio_np = _coerce_audio_to_numpy(audio) if audio is not None else None
|
|
video_frames = converter.iter_frames(frames, common_dtype)
|
|
try:
|
|
return mux_av_video_audio_bytes(
|
|
video_frames,
|
|
width=frame_shape[1],
|
|
height=frame_shape[0],
|
|
audio_waveform=audio_np,
|
|
fps=float(fps),
|
|
audio_sample_rate=effective_audio_sample_rate,
|
|
video_codec_options=video_codec_options,
|
|
)
|
|
finally:
|
|
video_frames.close()
|
|
if owns_frame_converter:
|
|
converter.shutdown()
|
|
|
|
|
|
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 | float,
|
|
audio: Any | None = None,
|
|
audio_sample_rate: int | None = None,
|
|
video_codec_options: dict[str, str] | None = None,
|
|
frame_converter: _PlanarFrameConverter | 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,
|
|
frame_converter=frame_converter,
|
|
)
|
|
return base64.b64encode(video_bytes).decode("utf-8")
|