Files
vllm-omni/vllm_omni/transformers_utils/processors/audiox.py
T

265 lines
8.7 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Input transform utilities for the AudioX diffusion pipeline.
Loads and normalizes the raw audio/video conditioning signals (file path / URL /
``data:`` URI / ``np.ndarray`` / ``torch.Tensor``) into the (channels, samples) and
[T, C, H, W] tensors the pipeline needs. The pipeline itself stays focused on model
forward + sampling logic.
"""
from __future__ import annotations
import base64
import os
import tempfile
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import av
import numpy as np
import soundfile
import torch
import torch.nn.functional as F
import torchaudio.functional as taF
from einops import rearrange
from torchvision.io import read_image
from vllm.multimodal.media import MediaConnector
from vllm.multimodal.media.base import MediaIO
# AudioX task taxonomy. Tasks beginning with "v" require a video input; tasks containing
# "t" carry a text prompt. tv2*/v2* share the same conditioning pathways.
VIDEO_ONLY_TASKS = frozenset({"v2a", "v2m"})
TEXT_VIDEO_TASKS = frozenset({"tv2a", "tv2m"})
VIDEO_CONDITIONED_TASKS = VIDEO_ONLY_TASKS | TEXT_VIDEO_TASKS
_IMAGE_EXTS = frozenset({".jpg", ".jpeg", ".png"})
def normalize_prompts(prompts: list[Any]) -> list[dict[str, Any]]:
"""Coerce raw prompt entries into ``{"prompt": str, ...}`` dicts (preserves extras)."""
out: list[dict[str, Any]] = []
for i, raw in enumerate(prompts):
if isinstance(raw, str):
out.append({"prompt": raw.strip()})
elif isinstance(raw, dict):
p = dict(raw)
p["prompt"] = str(p.get("prompt") or "").strip()
out.append(p)
else:
raise TypeError(f"AudioX prompt {i} must be str or dict, got {type(raw)!r}")
return out
class _TempFileMediaIO(MediaIO[str]):
"""MediaIO that writes fetched bytes to a temp file, returns the path."""
def load_bytes(self, data: bytes) -> str:
return self._write_temp(data)
def load_base64(self, media_type: str, data: str) -> str:
return self._write_temp(base64.b64decode(data))
def load_file(self, filepath: Path) -> str:
return str(filepath)
@staticmethod
def _write_temp(data: bytes) -> str:
f = tempfile.NamedTemporaryFile(prefix="audiox_media_", suffix=".bin", delete=False)
f.write(data)
f.close()
return f.name
def _get_media_connector() -> MediaConnector:
"""Build a MediaConnector from the current forward context's model config."""
from vllm_omni.diffusion.forward_context import get_forward_context
ctx = get_forward_context()
model_config = ctx.vllm_config.model_config if ctx.vllm_config else None
return MediaConnector(
allowed_local_media_path=getattr(model_config, "allowed_local_media_path", ""),
allowed_media_domains=getattr(model_config, "allowed_media_domains", None),
)
def materialize_media_source(source: str) -> str:
"""Return a local filesystem path for ``source``.
Accepts a local path, a ``data:<mime>;base64,...`` URI, or an ``http(s)://`` URL.
All inputs are routed through vLLM's ``MediaConnector`` which respects
``--allowed-media-domains`` and ``--allowed-local-media-path`` to prevent
SSRF. Bare local paths are converted to ``file://`` URIs so that the
same path restriction applies.
"""
if not urlparse(source).scheme:
source = Path(source).resolve().as_uri()
connector = _get_media_connector()
return connector.load_from_url(source, _TempFileMediaIO())
def _load_video_path_pyav(
path: str,
*,
target_fps: int,
duration: float,
seek_time: float,
) -> torch.Tensor:
path = materialize_media_source(path)
seek_time = float(seek_time)
duration = float(duration)
end_time = seek_time + duration if duration > 0 else None
with av.open(path) as container:
stream = container.streams.video[0]
time_base = float(stream.time_base)
src_fps = float(stream.average_rate or target_fps)
if seek_time > 0:
container.seek(int(seek_time / time_base), stream=stream)
frames: list[np.ndarray] = []
for frame in container.decode(stream):
t = float(frame.pts) * time_base
if t < seek_time:
continue
if end_time is not None and t >= end_time:
break
frames.append(frame.to_ndarray(format="rgb24"))
if not frames:
raise ValueError(f"No frames in range seek_time={seek_time!r}, duration={duration!r} for {path!r}")
# PyAV gives [H, W, C] uint8 RGB per frame; AudioX expects [T, C, H, W].
video = torch.from_numpy(np.stack(frames)).permute(0, 3, 1, 2).contiguous()
n_target = max(1, int(round(video.shape[0] * float(target_fps) / src_fps)))
if n_target >= video.shape[0]:
return video
indices = torch.linspace(0, video.shape[0] - 1, n_target).round().long()
return video[indices]
def load_video_source(
source: Any,
*,
target_fps: int,
duration: float,
seek_time: float = 0.0,
) -> torch.Tensor:
if isinstance(source, str):
ext = os.path.splitext(source)[1].lower()
if ext in _IMAGE_EXTS:
return read_image(materialize_media_source(source)).unsqueeze(0)
return _load_video_path_pyav(
source,
target_fps=target_fps,
duration=duration,
seek_time=seek_time,
)
if isinstance(source, torch.Tensor):
return source
if isinstance(source, np.ndarray):
return torch.from_numpy(source)
raise TypeError(f"Unsupported video source type: {type(source)!r}")
def normalize_video_tensor(frames: torch.Tensor, size: int = 224) -> torch.Tensor:
if frames.dim() != 4:
raise ValueError(f"Expected [T, C, H, W], got {tuple(frames.shape)}")
frames = frames.float()
if frames.max() > 1.5:
frames = frames / 255.0
if frames.shape[-2:] != (size, size):
frames = F.interpolate(frames, size=(size, size), mode="bicubic", align_corners=False)
return frames
def adjust_video_duration(frames: torch.Tensor, duration: float, target_fps: int) -> torch.Tensor:
target_t = int(duration * target_fps)
cur_t = frames.shape[0]
if cur_t > target_t:
return frames[:target_t]
if cur_t < target_t:
last = frames[-1:].repeat(target_t - cur_t, 1, 1, 1)
return torch.cat([frames, last], dim=0)
return frames
def prepare_video_reference(
source: Any,
*,
duration: float,
target_fps: int,
seek_time: float = 0.0,
) -> torch.Tensor:
"""Decode a video clip (or single image) into the AudioX [T, 3, 224, 224] form."""
frames = load_video_source(
source,
target_fps=target_fps,
duration=duration,
seek_time=seek_time,
)
if frames.dim() == 4 and frames.shape[-1] == 3:
frames = rearrange(frames, "t h w c -> t c h w")
frames = normalize_video_tensor(frames, size=224)
if duration > 0:
frames = adjust_video_duration(frames, duration, target_fps)
return frames
def prepare_audio_reference(
source: Any,
*,
model_sample_rate: int,
seconds_start: float,
seconds_total: float,
device: torch.device,
) -> torch.Tensor:
"""Decode an audio source into a stereo (2, samples) tensor at the model's rate."""
target_len = int(model_sample_rate * seconds_total)
start = int(model_sample_rate * seconds_start)
if isinstance(source, str):
data, sr = soundfile.read(materialize_media_source(source), dtype="float32", always_2d=True)
# soundfile returns channels-last (T, C); project convention is (C, T).
wav = torch.from_numpy(data).transpose(0, 1).contiguous()
if sr != model_sample_rate:
wav = taF.resample(wav, sr, model_sample_rate)
elif isinstance(source, torch.Tensor):
wav = source.float()
elif isinstance(source, np.ndarray):
wav = torch.from_numpy(source).float()
else:
raise TypeError(f"Unsupported audio source type: {type(source)!r}")
if wav.dim() == 1:
wav = wav.unsqueeze(0)
if wav.shape[0] == 1:
wav = wav.repeat(2, 1)
elif wav.shape[0] > 2:
wav = wav[:2]
wav = wav[:, start : start + target_len]
if wav.shape[1] < target_len:
wav = F.pad(wav, (target_len - wav.shape[1], 0))
return wav.to(device=device, dtype=torch.float32)
__all__ = [
"VIDEO_ONLY_TASKS",
"TEXT_VIDEO_TASKS",
"VIDEO_CONDITIONED_TASKS",
"normalize_prompts",
"materialize_media_source",
"load_video_source",
"normalize_video_tensor",
"adjust_video_duration",
"prepare_video_reference",
"prepare_audio_reference",
]