Merge remote-tracking branch 'origin/main' into dev/vllm-align

This commit is contained in:
tzhouam committed 2026-03-13 19:03:57 +00:00
commit 97899c6c98
47 files changed
+4161 -470

No files matched your search

+17 -17
View File
@@ -119,23 +119,23 @@ steps:
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Diffusion Tensor Parallelism Test"
timeout_in_minutes: 20
depends_on: upload-merge-pipeline
commands:
- pytest -s -v tests/e2e/offline_inference/test_zimage_parallelism.py
agents:
queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU
plugins:
- docker#v5.2.0:
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
always-pull: true
propagate-environment: true
shm-size: "8gb"
environment:
- "HF_HOME=/fsx/hf_cache"
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
# - label: "Diffusion Tensor Parallelism Test"
# timeout_in_minutes: 20
# depends_on: upload-merge-pipeline
# commands:
# - pytest -s -v tests/e2e/offline_inference/test_zimage_parallelism.py
# agents:
# queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU
# plugins:
# - docker#v5.2.0:
# image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
# always-pull: true
# propagate-environment: true
# shm-size: "8gb"
# environment:
# - "HF_HOME=/fsx/hf_cache"
# volumes:
# - "/fsx/hf_cache:/fsx/hf_cache"
- label: "Diffusion GPU Worker Test"
timeout_in_minutes: 20
@@ -6,6 +6,7 @@ The image generation part is derived from dougbtv/comfyui-vllm-omni by Doug (@do
Original source at https://github.com/dougbtv/comfyui-vllm-omni, distributed under the MIT License.
"""
import asyncio
import json
from typing import Any
@@ -18,8 +19,8 @@ from .format import (
audio_to_base64,
base64_to_audio,
base64_to_image_tensor,
base64_to_video,
bytes_to_audio,
bytes_to_video,
image_tensor_to_base64,
image_tensor_to_png_bytes,
video_to_base64,
@@ -31,10 +32,43 @@ from .types import AudioFormat
logger = get_logger(__name__)
async def url_json(session: aiohttp.ClientSession, url: str, verb: str = "get", **kwargs) -> dict[str, Any]:
try:
async with getattr(session, verb)(url, **kwargs) as response:
if not response.ok:
error_text = await response.text()
raise (ValueError if response.status < 500 else RuntimeError)(
f"vLLM-Omni API returned status {response.status}: {error_text}"
)
try:
return await response.json()
except aiohttp.ContentTypeError as e:
raise RuntimeError(f"Invalid JSON response from vLLM-Omni: {e}")
except aiohttp.ClientError as e:
raise RuntimeError(f"Network error connecting to vLLM-Omni at {url}: {e}")
async def url_bytes(session: aiohttp.ClientSession, url: str, verb: str = "get", **kwargs) -> bytes:
try:
async with getattr(session, verb)(url, **kwargs) as response:
if not response.ok:
error_text = await response.text()
raise (ValueError if response.status < 500 else RuntimeError)(
f"vLLM-Omni API returned status {response.status}: {error_text}"
)
return await response.read()
except aiohttp.ClientError as e:
raise RuntimeError(f"Network error connecting to vLLM-Omni at {url}: {e}")
class VLLMOmniClient:
def __init__(self, base_url: str, timeout: float | None = None):
def __init__(
self, base_url: str, timeout: float | None = None, poll_interval: float = 5.0, max_poll_duration: float = 60 * 5
):
self.base_url = base_url
self.timeout = aiohttp.ClientTimeout(total=timeout)
self.poll_interval = poll_interval
self.max_poll_duration = max_poll_duration
async def generate_image(
self,
@@ -262,33 +296,41 @@ class VLLMOmniClient:
content_type="image/png",
)
url = self.base_url + "/videos"
async with aiohttp.ClientSession(timeout=self.timeout) as session:
# Start the video generation job
url = f"{self.base_url}/videos"
data = await url_json(session, url, "post", data=form)
if (job_id := data.get("id", None)) is None:
raise RuntimeError("API response missing job 'id' field - expected OpenAI compliant format")
if (job_status := data.get("status", None)) is None:
raise RuntimeError("API response missing job 'status' field - expected OpenAI compliant format")
# Poll for video generation job completion
deadline = asyncio.get_running_loop().time() + self.max_poll_duration
url = f"{self.base_url}/videos/{job_id}"
while job_status not in {"completed", "failed"}:
await asyncio.sleep(self.poll_interval)
data = await url_json(session, url)
if (job_status := data.get("status", None)) is None:
raise RuntimeError("API response missing job 'status' field - expected OpenAI compliant format")
if asyncio.get_running_loop().time() >= deadline:
raise RuntimeError(f"Timed out waiting for video job {job_id} to complete")
if job_status == "failed":
raise RuntimeError(f"Video job failed: {data}")
# Retrieve completed content
video_bytes = await url_bytes(session, f"{url}/content")
# Decode video and make a best effort at cleaning up server resources
try:
async with session.post(url, data=form) as response:
if not response.ok:
error_text = await response.text()
raise (ValueError if response.status < 500 else RuntimeError)(
f"vLLM-Omni API returned status {response.status}: {error_text}"
)
try:
data = await response.json()
except aiohttp.ContentTypeError as e:
raise RuntimeError(f"Invalid JSON response from vLLM-Omni: {e}")
except aiohttp.ClientError as e:
raise RuntimeError(f"Network error connecting to vLLM-Omni at {url}: {e}")
if "data" not in data:
raise RuntimeError("API response missing 'data' field - expected OpenAI DALL-E format")
if not data["data"]:
raise RuntimeError("API returned empty data array")
try:
base64_str = data["data"][0]["b64_json"]
except (KeyError, IndexError):
raise RuntimeError("API response missing 'b64_json' field in first data item")
return base64_to_video(base64_str)
return bytes_to_video(video_bytes)
finally:
try:
await url_json(session, url, "delete")
except Exception as exc:
logger.warning("Failed to clean up video job %s: %s", job_id, exc)
async def generate_understanding_chat_completion(
self,
@@ -143,15 +143,7 @@ def video_to_base64(video: VideoInput, filename: str = "video.mp4") -> str:
return f"data:{mime_type};base64,{base64_str}"
def base64_to_video(base64_str: str) -> VideoInput:
if base64_str.startswith("data:video"):
_, base64_str = base64_str.split(",", 1)
try:
video_bytes = base64.b64decode(base64_str)
except Exception as e:
raise ValueError(f"Invalid base64 string: {e}")
def bytes_to_video(video_bytes: bytes) -> VideoInput:
video_buffer = BytesIO(video_bytes)
try:
@@ -208,6 +200,18 @@ def base64_to_video(base64_str: str) -> VideoInput:
return InputImpl.VideoFromComponents(components)
def base64_to_video(base64_str: str) -> VideoInput:
if base64_str.startswith("data:video"):
_, base64_str = base64_str.split(",", 1)
try:
video_bytes = base64.b64decode(base64_str)
except Exception as e:
raise ValueError(f"Invalid base64 string: {e}")
return bytes_to_video(video_bytes)
def audio_to_bytes(audio: AudioInput, filename: str = "audio.mp3", quality: str = "128k") -> BytesIO:
waveform = audio["waveform"][0] # Shape: (C, T)
sample_rate = audio["sample_rate"]
@@ -70,13 +70,17 @@ def add_sampling_parameters_to_stage(
stages = pipeline_spec["stages"]
if isinstance(sampling_param_list, dict):
sampling_param_list = sampling_param_list.__class__(sampling_param_list)
sampling_param_list.update(params_to_add)
elif sampling_param_list is None:
sampling_param_list = params_to_add.copy()
else:
for i, stage in enumerate(stages):
if stage == stage_type:
sampling_param_list[i].update(params_to_add)
stage_param = sampling_param_list[i]
stage_param = stage_param.__class__(stage_param)
stage_param.update(params_to_add)
sampling_param_list[i] = stage_param
return sampling_param_list
+1
View File
@@ -28,6 +28,7 @@ nav:
- Text-To-Audio: user_guide/examples/offline_inference/text_to_audio.md
- Text-To-Image: user_guide/examples/offline_inference/text_to_image.md
- Text-To-Video: user_guide/examples/offline_inference/text_to_video.md
- X-To-Video-Audio: user_guide/examples/offline_inference/x_to_video_audio.md
- Online Serving:
- BAGEL-7B-MoT: user_guide/examples/online_serving/bagel.md
- vLLM-Omni Helm Chart: user_guide/examples/online_serving/chart-helm.md
+1
View File
@@ -51,6 +51,7 @@ th {
|`MiMoAudioForConditionalGeneration` | MiMo-Audio-7B-Instruct | `XiaomiMiMo/MiMo-Audio-7B-Instruct` |
|`Flux2Pipeline` | FLUX.2-dev | `black-forest-labs/FLUX.2-dev` |
|`FishSpeechSlowARForConditionalGeneration` | Fish Speech S2 Pro | `fishaudio/s2-pro` |
|`DreamIDOmniPipeline`| DreamID-Omni | `XuGuo699/DreamID-Omni` |
## List of Supported Models for NPU
@@ -39,6 +39,7 @@ The following table shows which models are currently supported by parallelism me
| **FLUX.1-dev** | `black-forest-labs/FLUX.1-dev` | ❌ | ❌ | ✅ | ✅ | ❌ | N/A |
| **FLUX.2-dev** | `black-forest-labs/FLUX.2-dev` | ❌ | ❌ | ❌ | ✅ | ❌ | N/A |
| **HunyuanImage3.0** | `tencent/HunyuanImage-3.0`, `tencent/HunyuanImage-3.0-Instruct` | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
| **DreamID-Omni** | `XuGuo699/DreamID-Omni` | ❌ | ❌ | ✅ | ❌ | ❌ | N/A |
!!! note "TP Limitations for Diffusion Models"
We currently implement Tensor Parallelism (TP) only for the DiT (Diffusion Transformer) blocks. This is because the `text_encoder` component in vLLM-Omni uses the original Transformers implementation, which does not yet support TP.
@@ -77,6 +77,7 @@ The following table shows which models are currently supported by each accelerat
|-------|------------------|:--------:|:---------:|:----------:|:--------------:|:------------:|:----:|:----:|
| **Wan2.2** | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **LTX-2** | `Lightricks/LTX-2` | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
| **DreamID-Omni** | `XuGuo699/DreamID-Omni` | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ |
### Quantization
@@ -0,0 +1,58 @@
# X-To-Video-Audio
The `DreamID-Omni` pipeline generates short videos from text, image and video.
## Local CLI Usage
### Download the Model locally
Since DreamID-Omni combine multiple models, and without any config, so we need to download them locally.
```bash
python download_dreamid_omni.py --output-dir ./dreamid_omni
```
After download, the model directory will look like this:
```
dreamid_omni/
├── DreamID-Omni/
│ ├── dreamid_omni.safetensors
├── MMAudio/
│ ├── ext_weights/
│ │ ├── best_netG.pt
│ │ ├── v1-16.pth
├── Wan2.2-TI2V-5B/
│ ├── google/*
│ ├── models_t5_umt5-xxl-enc-bf16.pth
│ ├── Wan2.2_VAE.pth
│
├── model_index.json # create by download_dreamid_omni.py
```
### Run the Inference
```
python x_to_video_audio.py \
--model /xx/dreamid_omni \
--prompt "Two people walking together and singing happily" \
--image-path ./example0.png ./example1.png \
--audio-path ./example0.wav ./example1.wav \
--video-negative-prompt "jitter, bad hands, blur, distortion" \
--audio-negative-prompt "robotic, muffled, echo, distorted" \
--cfg-parallel-size 2 \
--num-inference-steps 45 \
--height 704 \
--width 1280 \
--output dreamid_omni.mp4
```
In the current test scenario (2 images + 2 audio inputs), the VRAM requirement is 72GB, regardless of whether cfg-parallel is enabled or disabled.
The VRAM usage can be reduced by enabling CPU offload via --enable-cpu-offload.
Key arguments:
- `--prompt`: text description (string).
- `--model`: path to the model local directory.
- `--height/--width`: output resolution (defaults 704 * 1024).
- `--image-path`: path to the input image list.
- `--audio-path`: path to the input audio list, indicate the timbre of the output video.
- `--cfg-parallel-size`: number of parallel cfg parallel (defaults 1).
- `--num-inference-steps`: number of denoising steps (defaults 45).
- `--video-negative-prompt`: negative prompt for video generation.
- `--audio-negative-prompt`: negative prompt for audio generation.
- `--enable-cpu-offload`: enable CPU offload (defaults False).
@@ -29,6 +29,35 @@ The script allows overriding:
- `CACHE_BACKEND` (default: `none`)
- `ENABLE_CACHE_DIT_SUMMARY` (default: `0`)
## Async Job Behavior
`POST /v1/videos` is asynchronous. It creates a video job and immediately
returns metadata like the job ID and initial `queued` status. To get the final
artifact, poll the job status and then download the completed file from the
content endpoint.
The main endpoints are:
- `POST /v1/videos`: create a video generation job
- `GET /v1/videos/{video_id}`: retrieve the current job status and metadata
- `GET /v1/videos`: list stored video jobs
- `GET /v1/videos/{video_id}/content`: download the generated video file
- `DELETE /v1/videos/{video_id}`: delete the job and any stored output
## Storage
Generated video files are stored on local disk by the async video API.
Local file storage behavior can be controlled via the following environment variables:
- `VLLM_OMNI_STORAGE_PATH`: directory used for generated files (default: `/tmp/storage`)
- `VLLM_OMNI_STORAGE_MAX_CONCURRENCY`: max concurrent save/delete operations (default: `4`)
Example:
```bash
export VLLM_OMNI_STORAGE_PATH=/var/tmp/vllm-omni-videos
export VLLM_OMNI_STORAGE_MAX_CONCURRENCY=8
```
## API Calls
### Method 1: Using curl
@@ -38,7 +67,7 @@ The script allows overriding:
bash run_curl_image_to_video.sh
# Or execute directly (OpenAI-style multipart)
curl -X POST http://localhost:8091/v1/videos \
create_response=$(curl -s http://localhost:8091/v1/videos \
-H "Accept: application/json" \
-F "prompt=A bear playing with yarn, smooth motion" \
-F "negative_prompt=low quality, blurry, static" \
@@ -52,7 +81,23 @@ curl -X POST http://localhost:8091/v1/videos \
-F "guidance_scale_2=1.0" \
-F "boundary_ratio=0.875" \
-F "flow_shift=12.0" \
-F "seed=42" | jq -r '.data[0].b64_json' | base64 -d > wan22_i2v_output.mp4
-F "seed=42")
video_id=$(echo "$create_response" | jq -r '.id')
while true; do
status=$(curl -s "http://localhost:8091/v1/videos/${video_id}" | jq -r '.status')
if [ "$status" = "completed" ]; then
break
fi
if [ "$status" = "failed" ]; then
echo "Video generation failed"
exit 1
fi
sleep 2
done
curl -s "http://localhost:8091/v1/videos/${video_id}" | jq .
curl -L "http://localhost:8091/v1/videos/${video_id}/content" -o wan22_i2v_output.mp4
```
## Request Format
@@ -66,6 +111,18 @@ curl -X POST http://localhost:8091/v1/videos \
-F "input_reference=@/path/to/qwen-bear.png"
```
### Alternative JSON-Safe Reference Input
Use `image_reference` when you want to pass a URL or JSON-safe image reference
instead of uploading a file. Do not send `input_reference` and
`image_reference` together.
```bash
curl -X POST http://localhost:8091/v1/videos \
-F "prompt=A bear playing with yarn, smooth motion" \
-F 'image_reference={"image_url":"https://example.com/qwen-bear.png"}'
```
### Generation with Parameters
```bash
@@ -85,6 +142,63 @@ curl -X POST http://localhost:8091/v1/videos \
-F "seed=42"
```
## Create Response Format
`POST /v1/videos` returns a job record.
```json
{
"id": "video_gen_123",
"object": "video",
"status": "queued",
"model": "Wan-AI/Wan2.2-I2V-A14B-Diffusers",
"prompt": "A bear playing with yarn, smooth motion",
"created_at": 1234567890
}
```
## Retrieve, List, Download, and Delete
### Retrieve a job
```bash
curl -s http://localhost:8091/v1/videos/${video_id} | jq .
```
### List jobs
```bash
curl -s http://localhost:8091/v1/videos | jq .
```
### Download the completed video
```bash
curl -L http://localhost:8091/v1/videos/${video_id}/content -o wan22_i2v_output.mp4
```
### Delete a job and its stored file
```bash
curl -X DELETE http://localhost:8091/v1/videos/${video_id} | jq .
```
## Poll Until Complete
```bash
while true; do
status=$(curl -s http://localhost:8091/v1/videos/${video_id} | jq -r '.status')
if [ "$status" = "completed" ]; then
break
fi
if [ "$status" = "failed" ]; then
echo "Video generation failed"
exit 1
fi
sleep 2
done
```
## Example materials
??? abstract "run_curl_image_to_video.sh"
@@ -29,6 +29,34 @@ The script allows overriding:
- `CACHE_BACKEND` (default: `none`)
- `ENABLE_CACHE_DIT_SUMMARY` (default: `0`)
## Async Job Behavior
`POST /v1/videos` is asynchronous. It creates a video job and immediately
returns metadata like the job ID and initial `queued` status. To get the final
artifact, poll the job status and then download the completed file from the
content endpoint.
The main endpoints are:
- `POST /v1/videos`: create a video generation job
- `GET /v1/videos/{video_id}`: retrieve the current job status and metadata
- `GET /v1/videos`: list stored video jobs
- `GET /v1/videos/{video_id}/content`: download the generated video file
- `DELETE /v1/videos/{video_id}`: delete the job and any stored output
## Storage
Generated video files are stored on local disk by the async video API.
- `VLLM_OMNI_STORAGE_PATH`: directory used for generated files (default: `/tmp/storage`)
- `VLLM_OMNI_STORAGE_MAX_CONCURRENCY`: max concurrent save/delete operations (default: `4`)
Example:
```bash
export VLLM_OMNI_STORAGE_PATH=/var/tmp/vllm-omni-videos
export VLLM_OMNI_STORAGE_MAX_CONCURRENCY=8
```
## API Calls
### Method 1: Using curl
@@ -38,7 +66,7 @@ The script allows overriding:
bash run_curl_text_to_video.sh
# Or execute directly (OpenAI-style multipart)
curl -s http://localhost:8091/v1/videos \
create_response=$(curl -s http://localhost:8091/v1/videos \
-H "Accept: application/json" \
-F "prompt=Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage." \
-F "width=832" \
@@ -50,7 +78,24 @@ curl -s http://localhost:8091/v1/videos \
-F "guidance_scale=4.0" \
-F "guidance_scale_2=4.0" \
-F "boundary_ratio=0.875" \
-F "seed=42" | jq -r '.data[0].b64_json' | base64 -d > wan22_output.mp4
-F "flow_shift=5.0" \
-F "seed=42")
video_id=$(echo "$create_response" | jq -r '.id')
while true; do
status=$(curl -s "http://localhost:8091/v1/videos/${video_id}" | jq -r '.status')
if [ "$status" = "completed" ]; then
break
fi
if [ "$status" = "failed" ]; then
echo "Video generation failed"
exit 1
fi
sleep 2
done
curl -s "http://localhost:8091/v1/videos/${video_id}" | jq .
curl -L "http://localhost:8091/v1/videos/${video_id}/content" -o wan22_output.mp4
```
## Request Format
@@ -85,8 +130,9 @@ curl -X POST http://localhost:8091/v1/videos \
| Parameter | Type | Default | Description |
| --------------------- | ------ | ------- | ------------------------------------------------ |
| `prompt` | str | - | Text description of the desired video |
| `seconds` | str | None | Clip duration in seconds |
| `size` | str | None | Output size in `WIDTHxHEIGHT` format |
| `negative_prompt` | str | None | Negative prompt |
| `n` | int | 1 | Number of videos to generate |
| `width` | int | None | Video width in pixels |
| `height` | int | None | Video height in pixels |
| `num_frames` | int | None | Number of frames to generate |
@@ -98,24 +144,62 @@ curl -X POST http://localhost:8091/v1/videos \
| `flow_shift` | float | None | Scheduler flow shift (Wan2.2) |
| `seed` | int | None | Random seed (reproducible) |
| `lora` | object | None | LoRA configuration |
| `extra_body` | object | None | Model-specific extra parameters |
## Response Format
## Create Response Format
`POST /v1/videos` returns a job record.
```json
{
"created": 1234567890,
"data": [
{ "b64_json": "<base64-mp4>" }
]
"id": "video_gen_123",
"object": "video",
"status": "queued",
"model": "Wan-AI/Wan2.2-T2V-A14B-Diffusers",
"prompt": "A cinematic view of a futuristic city at sunset",
"created_at": 1234567890
}
```
## Extract Video
## Retrieve, List, Download, and Delete
### Retrieve a job
```bash
# Extract base64 from response and decode to video
cat response.json | jq -r '.data[0].b64_json' | base64 -d > wan22_output.mp4
curl -s http://localhost:8091/v1/videos/${video_id} | jq .
```
### List jobs
```bash
curl -s http://localhost:8091/v1/videos | jq .
```
### Download the completed video
```bash
curl -L http://localhost:8091/v1/videos/${video_id}/content -o wan22_output.mp4
```
### Delete a job and its stored file
```bash
curl -X DELETE http://localhost:8091/v1/videos/${video_id} | jq .
```
## Poll Until Complete
```bash
while true; do
status=$(curl -s http://localhost:8091/v1/videos/${video_id} | jq -r '.status')
if [ "$status" = "completed" ]; then
break
fi
if [ "$status" = "failed" ]; then
echo "Video generation failed"
exit 1
fi
sleep 2
done
```
## Example materials
@@ -0,0 +1,103 @@
import argparse
import fcntl
import os
import site
import subprocess
import tempfile
import time
from pathlib import Path
from huggingface_hub import snapshot_download
DEPENDENCY_REPO = "https://github.com/bytedance/DreamID-V.git"
DEPENDENCY_BRANCH = "omni"
CACHE_DIR = Path(tempfile.gettempdir()) / "vllm-omni-dependency"
LOCK_FILE = CACHE_DIR / ".install.lock"
DEPENDENCY_DIR = CACHE_DIR / "DreamID-Omni"
def download_dependency():
CACHE_DIR.mkdir(parents=True, exist_ok=True)
with open(LOCK_FILE, "w") as f:
fcntl.flock(f, fcntl.LOCK_EX)
if not DEPENDENCY_DIR.exists():
print(f"Downloading DreamID-Omni to {DEPENDENCY_DIR} ...")
subprocess.run(
["git", "clone", "--depth", "1", DEPENDENCY_REPO, "--branch", DEPENDENCY_BRANCH, str(DEPENDENCY_DIR)],
check=True,
)
print("Download finished.")
fcntl.flock(f, fcntl.LOCK_UN)
# write .pth to site-packages
site_packages = Path(site.getsitepackages()[0])
pth_file = site_packages / "vllm_omni_dependency.pth"
pth_file.write_text(str(DEPENDENCY_DIR))
print(f"Added {DEPENDENCY_DIR} to site-packages via {pth_file}")
def timed_download(repo_id: str, local_dir: str, allow_patterns: list | None = None):
"""Download files from HF repo and log time + destination."""
if os.path.exists(local_dir):
print(f"Directory {local_dir} already exists. Skipping download.")
return
print(f"Starting download from {repo_id} into {local_dir}")
start_time = time.time()
snapshot_download(
repo_id=repo_id,
local_dir=local_dir,
local_dir_use_symlinks=False,
allow_patterns=allow_patterns,
)
elapsed = time.time() - start_time
print(f"✅ Finished downloading {repo_id} in {elapsed:.2f} seconds. Files saved at: {local_dir}")
def main(output_dir: str):
# Wan2.2
wan_dir = os.path.join(output_dir, "Wan2.2-TI2V-5B")
timed_download(
repo_id="Wan-AI/Wan2.2-TI2V-5B",
local_dir=wan_dir,
allow_patterns=["google/*", "models_t5_umt5-xxl-enc-bf16.pth", "Wan2.2_VAE.pth"],
)
# MMAudio
mm_audio_dir = os.path.join(output_dir, "MMAudio")
timed_download(
repo_id="hkchengrex/MMAudio",
local_dir=mm_audio_dir,
allow_patterns=["ext_weights/best_netG.pt", "ext_weights/v1-16.pth"],
)
dreamid_dir = os.path.join(output_dir, "DreamID-Omni")
timed_download(repo_id="XuGuo699/DreamID-Omni", local_dir=dreamid_dir)
# Now we construct the config file
import json
data = {
"_class_name": "DreamIDOmniPipeline",
"fusion": "DreamID-Omni/dreamid_omni.safetensors",
}
with open(os.path.join(output_dir, "model_index.json"), "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
print(f"model_index.json created at {os.path.join(output_dir, 'model_index.json')}")
# now we download the dependency code
download_dependency()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Download models from Hugging Face")
parser.add_argument(
"--output-dir", type=str, default="./dreamid_omni", help="Base directory to save downloaded models"
)
args = parser.parse_args()
main(args.output_dir)
@@ -0,0 +1,58 @@
# X-To-Video-Audio
The `DreamID-Omni` pipeline generates short videos from text, image and video.
## Local CLI Usage
### Download the Model locally
Since DreamID-Omni combine multiple models, and without any config, so we need to download them locally.
```bash
python download_dreamid_omni.py --output-dir ./dreamid_omni
```
After download, the model directory will look like this:
```
dreamid_omni/
├── DreamID-Omni/
│ ├── dreamid_omni.safetensors
├── MMAudio/
│ ├── ext_weights/
│ │ ├── best_netG.pt
│ │ ├── v1-16.pth
├── Wan2.2-TI2V-5B/
│ ├── google/*
│ ├── models_t5_umt5-xxl-enc-bf16.pth
│ ├── Wan2.2_VAE.pth
│
├── model_index.json # create by download_dreamid_omni.py
```
### Run the Inference
```
python x_to_video_audio.py \
--model /xx/dreamid_omni \
--prompt "Two people walking together and singing happily" \
--image-path ./example0.png ./example1.png \
--audio-path ./example0.wav ./example1.wav \
--video-negative-prompt "jitter, bad hands, blur, distortion" \
--audio-negative-prompt "robotic, muffled, echo, distorted" \
--cfg-parallel-size 2 \
--num-inference-steps 45 \
--height 704 \
--width 1280 \
--output dreamid_omni.mp4
```
In the current test scenario (2 images + 2 audio inputs), the VRAM requirement is 72GB, regardless of whether cfg-parallel is enabled or disabled.
The VRAM usage can be reduced by enabling CPU offload via --enable-cpu-offload.
Key arguments:
- `--prompt`: text description (string).
- `--model`: path to the model local directory.
- `--height/--width`: output resolution (defaults 704 * 1024).
- `--image-path`: path to the input image list.
- `--audio-path`: path to the input audio list, indicate the timbre of the output video.
- `--cfg-parallel-size`: number of parallel cfg parallel (defaults 1).
- `--num-inference-steps`: number of denoising steps (defaults 45).
- `--video-negative-prompt`: negative prompt for video generation.
- `--audio-negative-prompt`: negative prompt for audio generation.
- `--enable-cpu-offload`: enable CPU offload (defaults False).
@@ -0,0 +1,148 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import argparse
import re
import time
import librosa
from PIL import Image
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".gif"}
AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".m4a", ".aac", ".ogg"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Offline inference for DreamID-Omni (video + audio).")
parser.add_argument("--model", required=True, help="DreamID ckpt root directory.")
parser.add_argument("--model-type", default="dreamid-omni", help="Model type.")
parser.add_argument("--prompt", default=None, help="Text prompt.")
parser.add_argument("--image-path", type=str, nargs="+", help="list of image-path")
parser.add_argument("--audio-path", type=str, nargs="+", help="list of audio-path")
parser.add_argument("--prompt-file", type=str, default=None, help="Text prompt in json format.")
parser.add_argument("--height", type=int, default=704, help="Video height.")
parser.add_argument("--width", type=int, default=1280, help="Video width.")
parser.add_argument("--num-inference-steps", type=int, default=45, help="Sampling steps.")
parser.add_argument("--solver-name", default="unipc", help="Solver name: unipc|dpm++|euler.")
parser.add_argument("--shift", type=float, default=5.0, help="Scheduler shift.")
parser.add_argument("--seed", type=int, default=103, help="Random seed for reproducible generation.")
parser.add_argument(
"--cfg-parallel-size",
type=int,
default=1,
choices=[1, 2],
help="Number of GPUs used for classifier free guidance parallel size.",
)
parser.add_argument(
"--video-negative-prompt",
default="jitter, bad hands, blur, distortion",
help="Negative prompt for video.",
)
parser.add_argument(
"--audio-negative-prompt",
default="robotic, muffled, echo, distorted",
help="Negative prompt for audio.",
)
parser.add_argument("--output", default="dreamid_output.mp4", help="Output video path.")
parser.add_argument(
"--enable-cpu-offload",
action="store_true",
default=False,
help="Enable CPU offloading for diffusion models.",
)
return parser.parse_args()
def load_image_and_audio(image_paths, audio_paths):
image = []
audio = []
for path in image_paths:
with Image.open(path) as img:
img = img.convert("RGB")
image.append(img)
for path in audio_paths:
audio_array, sr = librosa.load(path, sr=16000)
audio_array = audio_array[int(sr * 1) : int(sr * 3)]
audio.append(audio_array)
return image, audio
def main() -> None:
args = parse_args()
if args.prompt is None and args.prompt_file is None:
raise ValueError("Either --prompt or --prompt-file must be provided.")
text_prompt = args.prompt
if args.prompt_file:
import json
with open(args.prompt_file) as f:
text_prompt = json.load(f)
text_prompt = re.sub(
r"\[SPEAKER_TIMESTAMPS_START\].*?\[SPEAKER_TIMESTAMPS_END\]", "", text_prompt, flags=re.DOTALL
).strip()
text_prompt = re.sub(
r"\[AUDIO_DESCRIPTION_START].*?\[AUDIO_DESCRIPTION_END]", "", text_prompt, flags=re.DOTALL
).strip()
text_prompt = re.sub(r"\[[A-Z_]+\]", "", text_prompt)
text_prompt = re.sub(r"\n\s*\n", "\n", text_prompt).strip()
image, audio = load_image_and_audio(args.image_path, args.audio_path)
prompt = {
"prompt": text_prompt,
"video_negative_prompt": args.video_negative_prompt,
"audio_negative_prompt": args.audio_negative_prompt,
"multi_modal_data": {"image": image, "audio": audio},
}
sampling_params = OmniDiffusionSamplingParams(
height=args.height,
width=args.width,
num_inference_steps=args.num_inference_steps,
seed=args.seed,
extra_args={
"solver_name": args.solver_name,
"shift": args.shift,
},
)
parallel_config = DiffusionParallelConfig(
cfg_parallel_size=args.cfg_parallel_size,
)
omni = Omni(
model=args.model,
parallel_config=parallel_config,
model_type=args.model_type,
enable_cpu_offload=args.enable_cpu_offload,
)
start = time.perf_counter()
outputs = omni.generate(prompt, sampling_params)
elapsed = time.perf_counter() - start
if not outputs:
raise RuntimeError("No output returned from DreamID-Omni.")
output = outputs[0].request_output
generated_video = output[0].images[0][0]
generated_audio = output[0].images[0][1]
try:
from dreamid_omni.utils.io_utils import save_video
except Exception as e:
raise RuntimeError(f"Failed to extract video and audio from DreamID-Omni output. Error: {e}")
output_path = args.output
save_video(output_path, generated_video, generated_audio, fps=24, sample_rate=16000)
print(f"Saved generated video to {output_path}")
print(f"Total time: {elapsed:.2f}s")
if __name__ == "__main__":
main()
@@ -26,6 +26,35 @@ The script allows overriding:
- `CACHE_BACKEND` (default: `none`)
- `ENABLE_CACHE_DIT_SUMMARY` (default: `0`)
## Async Job Behavior
`POST /v1/videos` is asynchronous. It creates a video job and immediately
returns metadata like the job ID and initial `queued` status. To get the final
artifact, poll the job status and then download the completed file from the
content endpoint.
The main endpoints are:
- `POST /v1/videos`: create a video generation job
- `GET /v1/videos/{video_id}`: retrieve the current job status and metadata
- `GET /v1/videos`: list stored video jobs
- `GET /v1/videos/{video_id}/content`: download the generated video file
- `DELETE /v1/videos/{video_id}`: delete the job and any stored output
## Storage
Generated video files are stored on local disk by the async video API.
Local file storage behavior can be controlled via the following environment variables:
- `VLLM_OMNI_STORAGE_PATH`: directory used for generated files (default: `/tmp/storage`)
- `VLLM_OMNI_STORAGE_MAX_CONCURRENCY`: max concurrent save/delete operations (default: `4`)
Example:
```bash
export VLLM_OMNI_STORAGE_PATH=/var/tmp/vllm-omni-videos
export VLLM_OMNI_STORAGE_MAX_CONCURRENCY=8
```
## API Calls
### Method 1: Using curl
@@ -35,7 +64,7 @@ The script allows overriding:
bash run_curl_image_to_video.sh
# Or execute directly (OpenAI-style multipart)
curl -X POST http://localhost:8091/v1/videos \
create_response=$(curl -s http://localhost:8091/v1/videos \
-H "Accept: application/json" \
-F "prompt=A bear playing with yarn, smooth motion" \
-F "negative_prompt=low quality, blurry, static" \
@@ -49,7 +78,23 @@ curl -X POST http://localhost:8091/v1/videos \
-F "guidance_scale_2=1.0" \
-F "boundary_ratio=0.875" \
-F "flow_shift=12.0" \
-F "seed=42" | jq -r '.data[0].b64_json' | base64 -d > wan22_i2v_output.mp4
-F "seed=42")
video_id=$(echo "$create_response" | jq -r '.id')
while true; do
status=$(curl -s "http://localhost:8091/v1/videos/${video_id}" | jq -r '.status')
if [ "$status" = "completed" ]; then
break
fi
if [ "$status" = "failed" ]; then
echo "Video generation failed"
exit 1
fi
sleep 2
done
curl -s "http://localhost:8091/v1/videos/${video_id}" | jq .
curl -L "http://localhost:8091/v1/videos/${video_id}/content" -o wan22_i2v_output.mp4
```
## Request Format
@@ -63,6 +108,18 @@ curl -X POST http://localhost:8091/v1/videos \
-F "input_reference=@/path/to/qwen-bear.png"
```
### Alternative JSON-Safe Reference Input
Use `image_reference` when you want to pass a URL or JSON-safe image reference
instead of uploading a file. Do not send `input_reference` and
`image_reference` together.
```bash
curl -X POST http://localhost:8091/v1/videos \
-F "prompt=A bear playing with yarn, smooth motion" \
-F 'image_reference={"image_url":"https://example.com/qwen-bear.png"}'
```
### Generation with Parameters
```bash
@@ -81,3 +138,60 @@ curl -X POST http://localhost:8091/v1/videos \
-F "flow_shift=12.0" \
-F "seed=42"
```
## Create Response Format
`POST /v1/videos` returns a job record, not inline base64 video data.
```json
{
"id": "video_gen_123",
"object": "video",
"status": "queued",
"model": "Wan-AI/Wan2.2-I2V-A14B-Diffusers",
"prompt": "A bear playing with yarn, smooth motion",
"created_at": 1234567890
}
```
## Retrieve, List, Download, and Delete
### Retrieve a job
```bash
curl -s http://localhost:8091/v1/videos/${video_id} | jq .
```
### List jobs
```bash
curl -s http://localhost:8091/v1/videos | jq .
```
### Download the completed video
```bash
curl -L http://localhost:8091/v1/videos/${video_id}/content -o wan22_i2v_output.mp4
```
### Delete a job and its stored file
```bash
curl -X DELETE http://localhost:8091/v1/videos/${video_id} | jq .
```
## Poll Until Complete
```bash
while true; do
status=$(curl -s http://localhost:8091/v1/videos/${video_id} | jq -r '.status')
if [ "$status" = "completed" ]; then
break
fi
if [ "$status" = "failed" ]; then
echo "Video generation failed"
exit 1
fi
sleep 2
done
```
@@ -1,33 +1,75 @@
#!/bin/bash
# Wan2.2 image-to-video curl example (OpenAI-style multipart)
# Wan2.2 image-to-video curl example using the async video job API.
set -euo pipefail
INPUT_IMAGE="${INPUT_IMAGE:-../../offline_inference/image_to_video/qwen-bear.png}"
BASE_URL="${BASE_URL:-http://localhost:8099}"
OUTPUT_PATH="${OUTPUT_PATH:-wan22_i2v_output.mp4}"
NEGATIVE_PROMPT="${NEGATIVE_PROMPT:-}"
POLL_INTERVAL="${POLL_INTERVAL:-2}"
if [ ! -f "$INPUT_IMAGE" ]; then
echo "Input image not found: $INPUT_IMAGE"
exit 1
fi
NEGATIVE_PROMPT_FLAG=""
if [ -n "$NEGATIVE_PROMPT" ]; then
NEGATIVE_PROMPT_FLAG="-F negative_prompt=${NEGATIVE_PROMPT}"
create_cmd=(
curl -sS -X POST "${BASE_URL}/v1/videos"
-H "Accept: application/json"
-F "prompt=A bear playing with yarn, smooth motion"
-F "input_reference=@${INPUT_IMAGE}"
-F "seconds=2"
-F "size=832x480"
-F "fps=16"
-F "num_inference_steps=40"
-F "guidance_scale=1.0"
-F "guidance_scale_2=1.0"
-F "boundary_ratio=0.875"
-F "flow_shift=12.0"
-F "seed=42"
)
if [ -n "${NEGATIVE_PROMPT}" ]; then
create_cmd+=(-F "negative_prompt=${NEGATIVE_PROMPT}")
fi
curl -X POST http://localhost:8099/v1/videos \
-H "Accept: application/json" \
-F "prompt=A bear playing with yarn, smooth motion" \
$NEGATIVE_PROMPT_FLAG \
-F "input_reference=@${INPUT_IMAGE}" \
-F "seconds=2" \
-F "size=832x480" \
-F "fps=16" \
-F "num_inference_steps=40" \
-F "guidance_scale=1.0" \
-F "guidance_scale_2=1.0" \
-F "boundary_ratio=0.875" \
-F "flow_shift=12.0" \
-F "seed=42" | jq -r '.data[0].b64_json' | base64 -d > "${OUTPUT_PATH}"
create_response="$("${create_cmd[@]}")"
video_id="$(echo "${create_response}" | jq -r '.id')"
if [ -z "${video_id}" ] || [ "${video_id}" = "null" ]; then
echo "Failed to create video job:"
echo "${create_response}" | jq .
exit 1
fi
echo "Created video job ${video_id}"
echo "${create_response}" | jq .
while true; do
status_response="$(curl -sS "${BASE_URL}/v1/videos/${video_id}")"
status="$(echo "${status_response}" | jq -r '.status')"
case "${status}" in
queued|in_progress)
echo "Video job ${video_id} status: ${status}"
sleep "${POLL_INTERVAL}"
;;
completed)
echo "${status_response}" | jq .
break
;;
failed)
echo "Video generation failed:"
echo "${status_response}" | jq .
exit 1
;;
*)
echo "Unexpected status response:"
echo "${status_response}" | jq .
exit 1
;;
esac
done
curl -sS -L "${BASE_URL}/v1/videos/${video_id}/content" -o "${OUTPUT_PATH}"
echo "Saved video to ${OUTPUT_PATH}"
+97 -12
View File
@@ -26,6 +26,35 @@ The script allows overriding:
- `CACHE_BACKEND` (default: `none`)
- `ENABLE_CACHE_DIT_SUMMARY` (default: `0`)
## Async Job Behavior
`POST /v1/videos` is asynchronous. It creates a video job and immediately
returns metadata like the job ID and initial `queued` status. To get the final
artifact, poll the job status and then download the completed file from the
content endpoint.
The main endpoints are:
- `POST /v1/videos`: create a video generation job
- `GET /v1/videos/{video_id}`: retrieve the current job status and metadata
- `GET /v1/videos`: list stored video jobs
- `GET /v1/videos/{video_id}/content`: download the generated video file
- `DELETE /v1/videos/{video_id}`: delete the job and any stored output
## Storage
Generated video files are stored on local disk by the async video API.
Local file storage behavior can be controlled via the following environment variables:
- `VLLM_OMNI_STORAGE_PATH`: directory used for generated files (default: `/tmp/storage`)
- `VLLM_OMNI_STORAGE_MAX_CONCURRENCY`: max concurrent save/delete operations (default: `4`)
Example:
```bash
export VLLM_OMNI_STORAGE_PATH=/var/tmp/vllm-omni-videos
export VLLM_OMNI_STORAGE_MAX_CONCURRENCY=8
```
## API Calls
### Method 1: Using curl
@@ -35,7 +64,7 @@ The script allows overriding:
bash run_curl_text_to_video.sh
# Or execute directly (OpenAI-style multipart)
curl -s http://localhost:8091/v1/videos \
create_response=$(curl -s http://localhost:8091/v1/videos \
-H "Accept: application/json" \
-F "prompt=Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage." \
-F "width=832" \
@@ -47,7 +76,24 @@ curl -s http://localhost:8091/v1/videos \
-F "guidance_scale=4.0" \
-F "guidance_scale_2=4.0" \
-F "boundary_ratio=0.875" \
-F "seed=42" | jq -r '.data[0].b64_json' | base64 -d > wan22_output.mp4
-F "flow_shift=5.0" \
-F "seed=42")
video_id=$(echo "$create_response" | jq -r '.id')
while true; do
status=$(curl -s "http://localhost:8091/v1/videos/${video_id}" | jq -r '.status')
if [ "$status" = "completed" ]; then
break
fi
if [ "$status" = "failed" ]; then
echo "Video generation failed"
exit 1
fi
sleep 2
done
curl -s "http://localhost:8091/v1/videos/${video_id}" | jq .
curl -L "http://localhost:8091/v1/videos/${video_id}/content" -o wan22_output.mp4
```
## Request Format
@@ -82,8 +128,9 @@ curl -X POST http://localhost:8091/v1/videos \
| Parameter | Type | Default | Description |
| --------------------- | ------ | ------- | ------------------------------------------------ |
| `prompt` | str | - | Text description of the desired video |
| `seconds` | str | None | Clip duration in seconds |
| `size` | str | None | Output size in `WIDTHxHEIGHT` format |
| `negative_prompt` | str | None | Negative prompt |
| `n` | int | 1 | Number of videos to generate |
| `width` | int | None | Video width in pixels |
| `height` | int | None | Video height in pixels |
| `num_frames` | int | None | Number of frames to generate |
@@ -95,22 +142,60 @@ curl -X POST http://localhost:8091/v1/videos \
| `flow_shift` | float | None | Scheduler flow shift (Wan2.2) |
| `seed` | int | None | Random seed (reproducible) |
| `lora` | object | None | LoRA configuration |
| `extra_body` | object | None | Model-specific extra parameters |
## Response Format
## Create Response Format
`POST /v1/videos` returns a job record, not inline base64 video data.
```json
{
"created": 1234567890,
"data": [
{ "b64_json": "<base64-mp4>" }
]
"id": "video_gen_123",
"object": "video",
"status": "queued",
"model": "Wan-AI/Wan2.2-T2V-A14B-Diffusers",
"prompt": "A cinematic view of a futuristic city at sunset",
"created_at": 1234567890
}
```
## Extract Video
## Retrieve, List, Download, and Delete
### Retrieve a job
```bash
# Extract base64 from response and decode to video
cat response.json | jq -r '.data[0].b64_json' | base64 -d > wan22_output.mp4
curl -s http://localhost:8091/v1/videos/${video_id} | jq .
```
### List jobs
```bash
curl -s http://localhost:8091/v1/videos | jq .
```
### Download the completed video
```bash
curl -L http://localhost:8091/v1/videos/${video_id}/content -o wan22_output.mp4
```
### Delete a job and its stored file
```bash
curl -X DELETE http://localhost:8091/v1/videos/${video_id} | jq .
```
## Poll Until Complete
```bash
while true; do
status=$(curl -s http://localhost:8091/v1/videos/${video_id} | jq -r '.status')
if [ "$status" = "completed" ]; then
break
fi
if [ "$status" = "failed" ]; then
echo "Video generation failed"
exit 1
fi
sleep 2
done
```
@@ -1,20 +1,63 @@
#!/bin/bash
# Wan2.2 text-to-video curl example
# Wan2.2 text-to-video curl example using the async video job API.
OUTPUT_PATH="wan22_output.mp4"
set -euo pipefail
curl -X POST http://localhost:8098/v1/videos \
-H "Accept: application/json" \
-F "prompt=Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage." \
-F "seconds=2" \
-F "size=832x480" \
-F "negative_prompt=色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走" \
-F "fps=16" \
-F "num_inference_steps=40" \
-F "guidance_scale=4.0" \
-F "guidance_scale_2=4.0" \
-F "boundary_ratio=0.875" \
-F "flow_shift=5.0" \
-F "seed=42" | jq -r '.data[0].b64_json' | base64 -d > "${OUTPUT_PATH}"
BASE_URL="${BASE_URL:-http://localhost:8098}"
OUTPUT_PATH="${OUTPUT_PATH:-wan22_output.mp4}"
POLL_INTERVAL="${POLL_INTERVAL:-2}"
create_response=$(
curl -sS -X POST "${BASE_URL}/v1/videos" \
-H "Accept: application/json" \
-F "prompt=Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage." \
-F "seconds=2" \
-F "size=832x480" \
-F "negative_prompt=色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走" \
-F "fps=16" \
-F "num_inference_steps=40" \
-F "guidance_scale=4.0" \
-F "guidance_scale_2=4.0" \
-F "boundary_ratio=0.875" \
-F "flow_shift=5.0" \
-F "seed=42"
)
video_id="$(echo "${create_response}" | jq -r '.id')"
if [ -z "${video_id}" ] || [ "${video_id}" = "null" ]; then
echo "Failed to create video job:"
echo "${create_response}" | jq .
exit 1
fi
echo "Created video job ${video_id}"
echo "${create_response}" | jq .
while true; do
status_response="$(curl -sS "${BASE_URL}/v1/videos/${video_id}")"
status="$(echo "${status_response}" | jq -r '.status')"
case "${status}" in
queued|in_progress)
echo "Video job ${video_id} status: ${status}"
sleep "${POLL_INTERVAL}"
;;
completed)
echo "${status_response}" | jq .
break
;;
failed)
echo "Video generation failed:"
echo "${status_response}" | jq .
exit 1
;;
*)
echo "Unexpected status response:"
echo "${status_response}" | jq .
exit 1
;;
esac
done
curl -sS -L "${BASE_URL}/v1/videos/${video_id}/content" -o "${OUTPUT_PATH}"
echo "Saved video to ${OUTPUT_PATH}"
+12 -3
View File
@@ -82,6 +82,15 @@ DIFFUSION_SINGLE_SAMPLING_PARAMS = DiffusionSamplingParams(
}
)
DIFFUSION_VIDEO_SINGLE_SAMPLING_PARAMS = DiffusionSamplingParams(
{
"num_inference_steps": 30,
"guidance_scale": 6.0,
"true_cfg_scale": 1.5,
}
)
AR_LIST_SAMPLING_PARAMS = [
AutoregressionSamplingParams(
{
@@ -310,8 +319,8 @@ def _build_mock_outputs(outputs: Iterable[OmniRequestOutput], sampling_case: Sam
)
elif sampling_case.kind is SamplingKind.VIDEO_DIFFUSION_SINGLE:
assert len(received_sampling_params_list) == 1
expected = DIFFUSION_SINGLE_SAMPLING_PARAMS.copy()
expected["num_outputs_per_prompt"] = expected.pop("n") # convert from n to num_outputs_per_prompt
expected = DIFFUSION_VIDEO_SINGLE_SAMPLING_PARAMS.copy()
# expected["num_outputs_per_prompt"] = expected.pop("n") # convert from n to num_outputs_per_prompt
_assert_sampling_param_values(
received_sampling_params_list[0],
{
@@ -690,7 +699,7 @@ async def test_tts_nodes(api_server: str, node_cls, call_kwargs: dict, sampling_
pytest.param(
SamplingCase(
kind=SamplingKind.VIDEO_DIFFUSION_SINGLE,
sampling_params=DIFFUSION_SINGLE_SAMPLING_PARAMS,
sampling_params=DIFFUSION_VIDEO_SINGLE_SAMPLING_PARAMS,
lora=LORA_PARAMS,
),
id="single-diffusion-sampling-params",
@@ -17,12 +17,14 @@ import time
from pathlib import Path
import numpy as np
import pytest
# import pytest
import torch
from PIL import Image
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
from tests.utils import DeviceMemoryMonitor, hardware_test
# from tests.utils import DeviceMemoryMonitor, hardware_test
from tests.utils import DeviceMemoryMonitor
from vllm_omni import Omni
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
@@ -155,69 +157,69 @@ def _run_zimage_generate(
cleanup_dist_env_and_memory()
@pytest.mark.advanced_model
@pytest.mark.diffusion
@pytest.mark.parallel
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
def test_zimage_tensor_parallel_tp2(tmp_path: Path):
if current_omni_platform.is_npu() or current_omni_platform.is_rocm():
pytest.skip("Z-Image TP e2e test is only supported on CUDA for now.")
if not current_omni_platform.is_available() or current_omni_platform.device_count() < 2:
pytest.skip("Z-Image TP=2 requires >= 2 devices.")
# @pytest.mark.advanced_model
# @pytest.mark.diffusion
# @pytest.mark.parallel
# @hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
# def test_zimage_tensor_parallel_tp2(tmp_path: Path):
# if current_omni_platform.is_npu() or current_omni_platform.is_rocm():
# pytest.skip("Z-Image TP e2e test is only supported on CUDA for now.")
# if not current_omni_platform.is_available() or current_omni_platform.device_count() < 2:
# pytest.skip("Z-Image TP=2 requires >= 2 devices.")
enforce_eager = False
# enforce_eager = False
height = 512
width = 512
num_inference_steps = 2
seed = 42
# height = 512
# width = 512
# num_inference_steps = 2
# seed = 42
tp1_img, tp1_time_s, tp1_peak_mem = _run_zimage_generate(
tp_size=1,
height=height,
width=width,
num_inference_steps=num_inference_steps,
seed=seed,
enforce_eager=enforce_eager,
)
tp2_img, tp2_time_s, tp2_peak_mem = _run_zimage_generate(
tp_size=2,
height=height,
width=width,
num_inference_steps=num_inference_steps,
seed=seed,
enforce_eager=enforce_eager,
)
# tp1_img, tp1_time_s, tp1_peak_mem = _run_zimage_generate(
# tp_size=1,
# height=height,
# width=width,
# num_inference_steps=num_inference_steps,
# seed=seed,
# enforce_eager=enforce_eager,
# )
# tp2_img, tp2_time_s, tp2_peak_mem = _run_zimage_generate(
# tp_size=2,
# height=height,
# width=width,
# num_inference_steps=num_inference_steps,
# seed=seed,
# enforce_eager=enforce_eager,
# )
tp1_path = tmp_path / "zimage_tp1.png"
tp2_path = tmp_path / "zimage_tp2.png"
tp1_img.save(tp1_path)
tp2_img.save(tp2_path)
# tp1_path = tmp_path / "zimage_tp1.png"
# tp2_path = tmp_path / "zimage_tp2.png"
# tp1_img.save(tp1_path)
# tp2_img.save(tp2_path)
assert tp1_img.width == width and tp1_img.height == height
assert tp2_img.width == width and tp2_img.height == height
# assert tp1_img.width == width and tp1_img.height == height
# assert tp2_img.width == width and tp2_img.height == height
mean_abs_diff, p99_abs_diff = _diff_metrics(tp1_img, tp2_img)
mean_threshold = 3e-2
p99_threshold = 2.5e-1
print(
"Z-Image TP image diff stats (TP=1 vs TP=2): "
f"mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e}; "
f"thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e}; "
f"tp1_img={tp1_path}, tp2_img={tp2_path}"
)
assert mean_abs_diff <= mean_threshold and p99_abs_diff <= p99_threshold, (
f"Image diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e} "
f"(thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e})"
)
# mean_abs_diff, p99_abs_diff = _diff_metrics(tp1_img, tp2_img)
# mean_threshold = 3e-2
# p99_threshold = 2.5e-1
# print(
# "Z-Image TP image diff stats (TP=1 vs TP=2): "
# f"mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e}; "
# f"thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e}; "
# f"tp1_img={tp1_path}, tp2_img={tp2_path}"
# )
# assert mean_abs_diff <= mean_threshold and p99_abs_diff <= p99_threshold, (
# f"Image diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e} "
# f"(thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e})"
# )
print(f"Z-Image TP perf (lower is better): tp1_time_s={tp1_time_s:.6f}, tp2_time_s={tp2_time_s:.6f}")
assert tp2_time_s < tp1_time_s, f"Expected TP=2 to be faster than TP=1 (tp1={tp1_time_s}, tp2={tp2_time_s})"
# print(f"Z-Image TP perf (lower is better): tp1_time_s={tp1_time_s:.6f}, tp2_time_s={tp2_time_s:.6f}")
# assert tp2_time_s < tp1_time_s, f"Expected TP=2 to be faster than TP=1 (tp1={tp1_time_s}, tp2={tp2_time_s})"
print(f"Z-Image TP peak memory (MB): tp1_peak_mem={tp1_peak_mem:.2f}, tp2_peak_mem={tp2_peak_mem:.2f}")
assert tp2_peak_mem < tp1_peak_mem, (
f"Expected TP=2 to use less peak memory than TP=1 (tp1={tp1_peak_mem}, tp2={tp2_peak_mem})"
)
# print(f"Z-Image TP peak memory (MB): tp1_peak_mem={tp1_peak_mem:.2f}, tp2_peak_mem={tp2_peak_mem:.2f}")
# assert tp2_peak_mem < tp1_peak_mem, (
# f"Expected TP=2 to use less peak memory than TP=1 (tp1={tp1_peak_mem}, tp2={tp2_peak_mem})"
# )
# @pytest.mark.integration
@@ -0,0 +1,260 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
E2E online serving tests for the async video generation API.
These tests exercise the real `/v1/videos` job lifecycle against a running
diffusion model, including create, retrieve, list, download, and delete flows.
"""
import os
import time
import uuid
from pathlib import Path
from typing import Any
import pytest
import requests
from tests.conftest import OmniServer
from tests.utils import hardware_test
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
MODEL = "Wan-AI/Wan2.2-T2V-A14B-Diffusers"
VIDEO_POLL_INTERVAL_S = 2.0
VIDEO_TIMEOUT_S = 900.0
@pytest.fixture(scope="module")
def video_server():
"""Start a real video generation server for async video API tests."""
with OmniServer(
MODEL,
[
"--num-gpus",
"1",
"--boundary-ratio",
"0.875",
"--flow-shift",
"5.0",
"--disable-log-stats",
],
) as server:
yield server
def _video_api_url(server: OmniServer, suffix: str = "") -> str:
return f"http://{server.host}:{server.port}/v1/videos{suffix}"
def _multipart_fields(payload: dict[str, Any]) -> list[tuple[str, tuple[None, str]]]:
return [(key, (None, str(value))) for key, value in payload.items() if value is not None]
def _create_video_job(
server: OmniServer,
*,
prompt: str | None = None,
**overrides: Any,
) -> requests.Response:
payload: dict[str, Any] = {
"prompt": prompt or f"video api e2e {uuid.uuid4().hex[:8]}",
"width": 640,
"height": 480,
"num_frames": 5,
"fps": 8,
"num_inference_steps": 2,
"guidance_scale": 1.0,
"guidance_scale_2": 1.0,
"boundary_ratio": 0.875,
"flow_shift": 5.0,
"seed": 42,
}
payload.update(overrides)
return requests.post(
_video_api_url(server),
files=_multipart_fields(payload),
timeout=VIDEO_TIMEOUT_S,
)
def _retrieve_video_job(server: OmniServer, video_id: str) -> requests.Response:
return requests.get(_video_api_url(server, f"/{video_id}"), timeout=VIDEO_TIMEOUT_S)
def _wait_for_video_status(server: OmniServer, video_id: str, expected_status: str) -> dict[str, Any]:
deadline = time.time() + VIDEO_TIMEOUT_S
last_payload: dict[str, Any] | None = None
while time.time() < deadline:
response = _retrieve_video_job(server, video_id)
assert response.status_code == 200, response.text
last_payload = response.json()
status = last_payload["status"]
if status == expected_status:
return last_payload
if status == "failed":
raise AssertionError(f"Video job {video_id} failed unexpectedly: {last_payload}")
time.sleep(VIDEO_POLL_INTERVAL_S)
raise AssertionError(
f"Timed out waiting for video job {video_id} to reach status={expected_status}. Last payload: {last_payload}"
)
def _wait_for_video_missing(server: OmniServer, video_id: str) -> None:
deadline = time.time() + 30.0
last_status: int | None = None
while time.time() < deadline:
response = _retrieve_video_job(server, video_id)
last_status = response.status_code
if response.status_code == 404:
return
time.sleep(0.5)
raise AssertionError(f"Timed out waiting for video job {video_id} to disappear. Last status={last_status}")
def _delete_video_job(server: OmniServer, video_id: str) -> requests.Response:
return requests.delete(_video_api_url(server, f"/{video_id}"), timeout=VIDEO_TIMEOUT_S)
def _delete_video_job_with_retry(server: OmniServer, video_id: str) -> requests.Response:
deadline = time.time() + 30.0
last_response: requests.Response | None = None
while time.time() < deadline:
response = _delete_video_job(server, video_id)
last_response = response
if response.status_code != 409:
return response
time.sleep(1.0)
raise AssertionError(
f"Timed out waiting to delete video job {video_id}. "
f"Last response: {None if last_response is None else last_response.text}"
)
def _best_effort_delete(server: OmniServer, video_id: str) -> None:
try:
response = _delete_video_job_with_retry(server, video_id)
if response.status_code not in (200, 404):
print(f"Cleanup delete for {video_id} returned {response.status_code}: {response.text}")
except Exception as exc:
print(f"Cleanup delete for {video_id} failed: {exc}")
def _assert_mp4_payload(content: bytes) -> None:
assert len(content) > 32, f"Downloaded video payload is unexpectedly small: {len(content)} bytes"
assert content[4:8] == b"ftyp", "Downloaded payload does not look like an MP4 file."
@pytest.mark.core_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100", "rocm": "MI325"})
def test_create_list_retrieve_download_video(video_server: OmniServer, tmp_path: Path) -> None:
video_id: str | None = None
try:
create_response = _create_video_job(video_server, prompt="A small paper airplane gliding over a lake at dawn.")
assert create_response.status_code == 200, create_response.text
created = create_response.json()
video_id = created["id"]
assert created["object"] == "video"
assert created["status"] == "queued"
assert created["model"] == MODEL
completed = _wait_for_video_status(video_server, video_id, "completed")
assert completed["file_name"] is not None
assert completed["completed_at"] is not None
assert completed["progress"] == 100
retrieve_response = _retrieve_video_job(video_server, video_id)
assert retrieve_response.status_code == 200, retrieve_response.text
retrieved = retrieve_response.json()
assert retrieved["id"] == video_id
assert retrieved["status"] == "completed"
list_response = requests.get(_video_api_url(video_server), timeout=VIDEO_TIMEOUT_S)
assert list_response.status_code == 200, list_response.text
listed = list_response.json()
assert listed["object"] == "list"
assert any(item["id"] == video_id for item in listed["data"])
download_response = requests.get(
_video_api_url(video_server, f"/{video_id}/content"),
timeout=VIDEO_TIMEOUT_S,
)
assert download_response.status_code == 200, download_response.text
assert download_response.headers["content-type"].startswith("video/mp4")
assert completed["file_name"] in download_response.headers.get("content-disposition", "")
_assert_mp4_payload(download_response.content)
output_path = tmp_path / completed["file_name"]
output_path.write_bytes(download_response.content)
assert output_path.stat().st_size == len(download_response.content)
finally:
if video_id is not None:
_best_effort_delete(video_server, video_id)
@pytest.mark.core_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100", "rocm": "MI325"})
def test_delete_video_while_queued_or_in_progress(video_server: OmniServer) -> None:
create_response = _create_video_job(
video_server,
prompt="A toy boat sailing through fog.",
num_frames=13,
num_inference_steps=4,
)
assert create_response.status_code == 200, create_response.text
created = create_response.json()
video_id = created["id"]
assert created["status"] == "queued"
# Delete immediately so the job is still queued or just entering execution.
delete_response = _delete_video_job_with_retry(video_server, video_id)
assert delete_response.status_code == 200, delete_response.text
deleted = delete_response.json()
assert deleted["id"] == video_id
assert deleted["deleted"] is True
assert deleted["object"] == "video.deleted"
_wait_for_video_missing(video_server, video_id)
@pytest.mark.core_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100", "rocm": "MI325"})
def test_delete_video_after_processing(video_server: OmniServer) -> None:
create_response = _create_video_job(
video_server,
prompt="A lantern drifting above a quiet forest.",
seed=43,
)
assert create_response.status_code == 200, create_response.text
video_id = create_response.json()["id"]
completed = _wait_for_video_status(video_server, video_id, "completed")
assert completed["file_name"] is not None
delete_response = _delete_video_job_with_retry(video_server, video_id)
assert delete_response.status_code == 200, delete_response.text
deleted = delete_response.json()
assert deleted["id"] == video_id
assert deleted["deleted"] is True
assert deleted["object"] == "video.deleted"
_wait_for_video_missing(video_server, video_id)
download_response = requests.get(
_video_api_url(video_server, f"/{video_id}/content"),
timeout=VIDEO_TIMEOUT_S,
)
assert download_response.status_code == 404, download_response.text
+333 -80
View File
@@ -4,19 +4,31 @@
Unit tests for OpenAI-compatible video generation endpoints.
"""
import asyncio
import base64
import io
import json
import os
import threading
import time
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from PIL import Image
from pydantic import ValidationError
from pytest_mock import MockerFixture
from vllm_omni.entrypoints.openai import api_server
from vllm_omni.entrypoints.openai.api_server import router
from vllm_omni.entrypoints.openai.protocol.videos import VideoGenerationRequest, VideoResponseFormat
from vllm_omni.entrypoints.openai.protocol.videos import (
VideoGenerationRequest,
VideoGenerationStatus,
VideoResponse,
)
from vllm_omni.entrypoints.openai.serving_video import OmniOpenAIServingVideo
from vllm_omni.entrypoints.openai.storage import LocalStorageManager
from vllm_omni.entrypoints.openai.stores import AsyncDictStore, TaskRegistry
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -44,6 +56,38 @@ class FakeAsyncOmni:
yield MockVideoResult(videos)
class BlockingVideoHandler:
def __init__(self):
self.model_name = "Wan-AI/Wan2.2-T2V-A14B-Diffusers"
self.stage_configs = None
self.started = threading.Event()
self.cancelled = threading.Event()
def set_stage_configs_if_missing(self, stage_configs):
if self.stage_configs is None:
self.stage_configs = stage_configs
async def generate_videos(self, request, reference_id, *, reference_image=None):
self.started.set()
try:
await asyncio.Future()
except asyncio.CancelledError:
self.cancelled.set()
raise
@pytest.fixture(autouse=True)
def isolated_video_backends(tmp_path, monkeypatch):
"""Use isolated in-memory metadata and local storage for each test."""
store: AsyncDictStore[VideoResponse] = AsyncDictStore()
tasks = TaskRegistry()
storage = LocalStorageManager(storage_path=str(tmp_path / "storage"))
monkeypatch.setattr(api_server, "VIDEO_STORE", store)
monkeypatch.setattr(api_server, "VIDEO_TASKS", tasks)
monkeypatch.setattr(api_server, "STORAGE_MANAGER", storage)
return store, tasks, storage
@pytest.fixture
def test_client():
app = FastAPI()
@@ -52,7 +96,8 @@ def test_client():
diffusion_engine=FakeAsyncOmni(),
model_name="Wan-AI/Wan2.2-T2V-A14B-Diffusers",
)
return TestClient(app)
with TestClient(app) as client:
yield client
def _make_test_image_bytes(size=(64, 64)) -> bytes:
@@ -62,6 +107,34 @@ def _make_test_image_bytes(size=(64, 64)) -> bytes:
return buf.getvalue()
def _make_test_image_data_url(size=(64, 64)) -> str:
image_bytes = _make_test_image_bytes(size)
encoded = base64.b64encode(image_bytes).decode("utf-8")
return f"data:image/png;base64,{encoded}"
def _wait_for_status(client: TestClient, video_id: str, status: str, timeout_s: float = 2.0):
deadline = time.time() + timeout_s
last_payload = None
while time.time() < deadline:
response = client.get(f"/v1/videos/{video_id}")
assert response.status_code == 200
last_payload = response.json()
if last_payload["status"] == status:
return last_payload
time.sleep(0.02)
raise AssertionError(f"Timed out waiting for status={status}. Last payload: {last_payload}")
def _wait_until(predicate, timeout_s: float = 2.0, interval_s: float = 0.02):
deadline = time.time() + timeout_s
while time.time() < deadline:
if predicate():
return
time.sleep(interval_s)
raise AssertionError("Timed out waiting for condition")
def test_t2v_video_generation_form(test_client, mocker: MockerFixture):
fps_values = []
@@ -80,24 +153,22 @@ def test_t2v_video_generation_form(test_client, mocker: MockerFixture):
"size": "640x360",
"seconds": "2",
"fps": "12",
"n": "2",
},
)
assert response.status_code == 200
data = response.json()
assert "data" in data and len(data["data"]) == 2
assert all(item["b64_json"] == "Zg==" for item in data["data"])
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
engine = test_client.app.state.openai_serving_video._engine_client
captured = engine.captured_sampling_params_list[0]
assert captured.num_outputs_per_prompt == 2
assert captured.num_outputs_per_prompt == 1
assert captured.width == 640
assert captured.height == 360
assert captured.num_frames == 24
assert captured.fps == 12
assert captured.frame_rate == 12.0
assert fps_values == [12, 12]
assert fps_values == [12]
def test_i2v_video_generation_form(test_client, mocker: MockerFixture):
@@ -114,9 +185,8 @@ def test_i2v_video_generation_form(test_client, mocker: MockerFixture):
)
assert response.status_code == 200
data = response.json()
assert "data" in data and len(data["data"]) == 1
assert data["data"][0]["b64_json"] == "Zg=="
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
engine = test_client.app.state.openai_serving_video._engine_client
prompt = engine.captured_prompt
@@ -145,6 +215,8 @@ def test_i2v_video_generation_resizes_input_to_requested_dimensions(test_client,
)
assert response.status_code == 200
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
engine = test_client.app.state.openai_serving_video._engine_client
prompt = engine.captured_prompt
@@ -153,6 +225,30 @@ def test_i2v_video_generation_resizes_input_to_requested_dimensions(test_client,
assert input_image.size == (96, 64)
def test_i2v_video_generation_with_image_reference_form(test_client, mocker: MockerFixture):
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video.encode_video_base64",
return_value="Zg==",
)
response = test_client.post(
"/v1/videos",
data={
"prompt": "A fox running through snow.",
"image_reference": json.dumps({"image_url": _make_test_image_data_url((40, 24))}),
},
)
assert response.status_code == 200
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
engine = test_client.app.state.openai_serving_video._engine_client
prompt = engine.captured_prompt
input_image = prompt["multi_modal_data"]["image"]
assert isinstance(input_image, Image.Image)
assert input_image.size == (40, 24)
def test_seconds_defaults_fps_and_frames(test_client, mocker: MockerFixture):
fps_values = []
@@ -173,10 +269,13 @@ def test_seconds_defaults_fps_and_frames(test_client, mocker: MockerFixture):
)
assert response.status_code == 200
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
engine = test_client.app.state.openai_serving_video._engine_client
captured = engine.captured_sampling_params_list[0]
assert captured.num_frames == 72
assert captured.fps == 24
assert captured.frame_rate == 24.0
assert fps_values == [24]
@@ -194,16 +293,50 @@ def test_size_param_sets_width_height(test_client, mocker: MockerFixture):
)
assert response.status_code == 200
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
engine = test_client.app.state.openai_serving_video._engine_client
captured = engine.captured_sampling_params_list[0]
assert captured.width == 320
assert captured.height == 240
def test_sampling_params_pass_through(test_client, mocker: MockerFixture):
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video.encode_video_base64",
return_value="Zg==",
)
response = test_client.post(
"/v1/videos",
data={
"prompt": "param pass",
"num_inference_steps": "30",
"guidance_scale": "6.5",
"guidance_scale_2": "8.0",
"true_cfg_scale": "4.0",
"boundary_ratio": "0.7",
"flow_shift": "0.25",
},
)
assert response.status_code == 200
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
engine = test_client.app.state.openai_serving_video._engine_client
captured = engine.captured_sampling_params_list[0]
assert captured.num_inference_steps == 30
assert captured.guidance_scale == 6.5
assert captured.guidance_scale_2 == 8.0
assert captured.true_cfg_scale == 4.0
assert captured.boundary_ratio == 0.7
assert captured.extra_args["flow_shift"] == 0.25
def test_audio_sample_rate_comes_from_model_config(test_client, mocker: MockerFixture):
audio_sample_rates = []
def _fake_encode(video, fps, audio=None, audio_sample_rate=None):
del video, fps, audio
audio_sample_rates.append(audio_sample_rate)
return "Zg=="
@@ -233,38 +366,11 @@ def test_audio_sample_rate_comes_from_model_config(test_client, mocker: MockerFi
)
assert response.status_code == 200
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
assert audio_sample_rates == [16000]
def test_sampling_params_pass_through(test_client, mocker: MockerFixture):
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video.encode_video_base64",
return_value="Zg==",
)
response = test_client.post(
"/v1/videos",
data={
"prompt": "param pass",
"num_inference_steps": "30",
"guidance_scale": "6.5",
"guidance_scale_2": "8.0",
"true_cfg_scale": "4.0",
"boundary_ratio": "0.7",
"flow_shift": "0.25",
},
)
assert response.status_code == 200
engine = test_client.app.state.openai_serving_video._engine_client
captured = engine.captured_sampling_params_list[0]
assert captured.num_inference_steps == 30
assert captured.guidance_scale == 6.5
assert captured.guidance_scale_2 == 8.0
assert captured.true_cfg_scale == 4.0
assert captured.boundary_ratio == 0.7
assert captured.extra_args["flow_shift"] == 0.25
def test_missing_handler_returns_503():
app = FastAPI()
app.include_router(router)
@@ -287,29 +393,29 @@ def test_missing_prompt_returns_422(test_client):
assert response.status_code == 422
def test_invalid_size_format_raises_validation_error(test_client):
with pytest.raises(ValidationError):
test_client.post(
"/v1/videos",
data={"prompt": "bad size", "size": "invalid"},
)
def test_invalid_size_parse_returns_500(test_client):
def test_invalid_size_parse_returns_422(test_client):
response = test_client.post(
"/v1/videos",
data={"prompt": "bad size", "size": "640x"},
)
assert response.status_code == 500
assert "video generation failed" in response.json()["detail"].lower()
assert response.status_code == 422
body = response.json()
assert body["detail"][0]["loc"] == ["body", "size"]
assert body["detail"][0]["type"] == "string_pattern_mismatch"
assert body["detail"][0]["input"] == "640x"
def test_invalid_response_format_raises_validation_error(test_client):
with pytest.raises(ValidationError):
test_client.post(
"/v1/videos",
data={"prompt": "bad format", "response_format": "url"},
)
def test_rejects_input_reference_and_image_reference_together(test_client):
response = test_client.post(
"/v1/videos",
data={
"prompt": "bad refs",
"image_reference": '{"image_url": "https://example.com/cat.png"}',
},
files={"input_reference": ("input.png", _make_test_image_bytes(), "image/png")},
)
assert response.status_code == 400
assert "either input_reference or image_reference" in response.json()["detail"].lower()
def test_invalid_seconds_returns_422(test_client):
@@ -320,20 +426,6 @@ def test_invalid_seconds_returns_422(test_client):
assert response.status_code == 422
def test_invalid_n_raises_validation_error(test_client):
with pytest.raises(ValidationError):
test_client.post(
"/v1/videos",
data={"prompt": "bad n", "n": "0"},
)
with pytest.raises(ValidationError):
test_client.post(
"/v1/videos",
data={"prompt": "bad n", "n": "5"},
)
def test_negative_prompt_and_seed_pass_through(test_client, mocker: MockerFixture):
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video.encode_video_base64",
@@ -349,6 +441,8 @@ def test_negative_prompt_and_seed_pass_through(test_client, mocker: MockerFixtur
)
assert response.status_code == 200
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
engine = test_client.app.state.openai_serving_video._engine_client
captured_prompt = engine.captured_prompt
captured_params = engine.captured_sampling_params_list[0]
@@ -364,19 +458,38 @@ def test_invalid_lora_returns_400(test_client):
"lora": '{"name": "bad-lora"}',
},
)
assert response.status_code == 200
video_id = response.json()["id"]
failed = _wait_for_status(test_client, video_id, VideoGenerationStatus.FAILED.value)
assert failed["error"]["code"] == "HTTPException"
assert "lora object" in failed["error"]["message"].lower()
def test_unsupported_image_reference_file_id_returns_400(test_client):
response = test_client.post(
"/v1/videos",
data={
"prompt": "unsupported ref",
"image_reference": '{"file_id": "file-123"}',
},
)
assert response.status_code == 400
assert "lora" in response.json()["detail"].lower()
assert response.json()["detail"] == "Invalid image_reference: file_id is not supported yet."
def test_invalid_uploaded_input_reference_returns_400(test_client):
response = test_client.post(
"/v1/videos",
data={"prompt": "bad upload"},
files={"input_reference": ("input.png", b"not-an-image", "image/png")},
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid input_reference: provided content is not a valid image."
def test_video_request_validation():
req = VideoGenerationRequest(prompt="test")
assert req.prompt == "test"
assert req.n == 1
assert req.response_format == VideoResponseFormat.B64_JSON
with pytest.raises(ValueError):
VideoGenerationRequest(prompt="test", response_format="url")
with pytest.raises(ValueError):
VideoGenerationRequest(prompt="test", size="invalid")
@@ -384,4 +497,144 @@ def test_video_request_validation():
VideoGenerationRequest(prompt="test", seconds="abc")
with pytest.raises(ValueError):
VideoGenerationRequest(prompt="test", n=0)
VideoGenerationRequest(prompt="test", image_reference={"file_id": "file-1", "image_url": "https://example.com"})
def test_list_videos_supports_order_after_and_limit(test_client, mocker: MockerFixture):
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video.encode_video_base64",
return_value="Zg==",
)
ids = []
for i in range(3):
create_resp = test_client.post("/v1/videos", data={"prompt": f"video-{i}"})
assert create_resp.status_code == 200
video_id = create_resp.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
ids.append(video_id)
asyncio.run(api_server.VIDEO_STORE.update_fields(ids[0], {"created_at": 100}))
asyncio.run(api_server.VIDEO_STORE.update_fields(ids[1], {"created_at": 200}))
asyncio.run(api_server.VIDEO_STORE.update_fields(ids[2], {"created_at": 300}))
asc_resp = test_client.get("/v1/videos", params={"order": "asc"})
assert asc_resp.status_code == 200
asc_body = asc_resp.json()
asc_ids = [item["id"] for item in asc_body["data"]]
assert asc_ids == [ids[0], ids[1], ids[2]]
assert asc_body["object"] == "list"
assert asc_body["first_id"] == ids[0]
assert asc_body["last_id"] == ids[2]
assert asc_body["has_more"] is False
desc_resp = test_client.get("/v1/videos", params={"order": "desc", "limit": 2})
assert desc_resp.status_code == 200
desc_body = desc_resp.json()
desc_ids = [item["id"] for item in desc_body["data"]]
assert desc_ids == [ids[2], ids[1]]
assert desc_body["object"] == "list"
assert desc_body["first_id"] == ids[2]
assert desc_body["last_id"] == ids[1]
assert desc_body["has_more"] is True
after_resp = test_client.get("/v1/videos", params={"order": "asc", "after": ids[0]})
assert after_resp.status_code == 200
after_body = after_resp.json()
after_ids = [item["id"] for item in after_body["data"]]
assert after_ids == [ids[1], ids[2]]
assert after_body["object"] == "list"
assert after_body["first_id"] == ids[1]
assert after_body["last_id"] == ids[2]
assert after_body["has_more"] is False
zero_limit_resp = test_client.get("/v1/videos", params={"order": "asc", "limit": 0})
assert zero_limit_resp.status_code == 200
zero_limit_body = zero_limit_resp.json()
assert zero_limit_body["data"] == []
assert zero_limit_body["object"] == "list"
assert zero_limit_body["first_id"] is None
assert zero_limit_body["last_id"] is None
assert zero_limit_body["has_more"] is True
zero_limit_after_resp = test_client.get(
"/v1/videos",
params={"order": "asc", "after": ids[2], "limit": 0},
)
assert zero_limit_after_resp.status_code == 200
zero_limit_after_body = zero_limit_after_resp.json()
assert zero_limit_after_body["data"] == []
assert zero_limit_after_body["object"] == "list"
assert zero_limit_after_body["first_id"] is None
assert zero_limit_after_body["last_id"] is None
assert zero_limit_after_body["has_more"] is False
def test_delete_completed_job_removes_file_and_metadata(test_client, mocker: MockerFixture):
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video.encode_video_base64",
return_value="Zg==",
)
create_resp = test_client.post("/v1/videos", data={"prompt": "Delete this video"})
assert create_resp.status_code == 200
video_id = create_resp.json()["id"]
final = _wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
file_name = final["file_name"]
assert file_name is not None
file_path = os.path.join(api_server.STORAGE_MANAGER.storage_path, file_name)
assert os.path.exists(file_path)
delete_resp = test_client.delete(f"/v1/videos/{video_id}")
assert delete_resp.status_code == 200
assert delete_resp.json()["id"] == video_id
assert delete_resp.json()["deleted"] is True
assert delete_resp.json()["object"] == "video.deleted"
assert not os.path.exists(file_path)
def test_delete_in_progress_job_cancels_task_and_removes_metadata(test_client):
handler = BlockingVideoHandler()
test_client.app.state.openai_serving_video = handler
create_resp = test_client.post("/v1/videos", data={"prompt": "Cancel this video"})
assert create_resp.status_code == 200
video_id = create_resp.json()["id"]
assert handler.started.wait(timeout=2.0)
delete_resp = test_client.delete(f"/v1/videos/{video_id}")
assert delete_resp.status_code == 200
assert delete_resp.json()["id"] == video_id
assert delete_resp.json()["deleted"] is True
assert delete_resp.json()["object"] == "video.deleted"
assert handler.cancelled.wait(timeout=2.0)
_wait_until(lambda: asyncio.run(api_server.VIDEO_TASKS.get(video_id)) is None)
assert asyncio.run(api_server.VIDEO_STORE.get(video_id)) is None
retrieve_resp = test_client.get(f"/v1/videos/{video_id}")
assert retrieve_resp.status_code == 404
def test_video_response_file_extension_is_robust():
response = VideoResponse(model="test-model", prompt="Make something beautiful")
assert response.file_extension == "mp4"
with_params = VideoResponse.model_construct(
model="test-model",
media_type="video/mp4; charset=binary",
)
assert with_params.file_extension == "mp4"
webm = VideoResponse.model_construct(
model="test-model",
media_type="video/webm",
)
assert webm.file_extension == "webm"
with pytest.raises(ValueError):
unknown = VideoResponse.model_construct(
model="test-model",
media_type="application/x-custom-video",
)
_ = unknown.file_extension
+2 -2
View File
@@ -228,8 +228,8 @@ def calculate_metrics(
good_completed += 1
if completed == 0:
warnings.formatwarning = (
lambda msg, category, filename, lineno, line=None: f"{filename}:{lineno}: {category.__name__}: {msg}\n"
warnings.formatwarning = lambda msg, category, filename, lineno, line=None: (
f"{filename}:{lineno}: {category.__name__}: {msg}\n"
)
warnings.warn(
"All requests failed. This is likely due to a misconfiguration on the benchmark arguments.",
+1
View File
@@ -322,6 +322,7 @@ class OmniDiffusionConfig:
dtype: torch.dtype = torch.bfloat16
model_config: dict[str, Any] = field(default_factory=dict)
tf_model_config: TransformerConfig = field(default_factory=TransformerConfig)
# Attention
+21 -1
View File
@@ -6,6 +6,7 @@ import time
from collections.abc import Iterable
from typing import Any
import numpy as np
import PIL.Image
from vllm.logger import init_logger
@@ -30,6 +31,13 @@ def supports_image_input(model_class_name: str) -> bool:
return bool(getattr(model_cls, "support_image_input", False))
def supports_audio_input(model_class_name: str) -> bool:
model_cls = DiffusionModelRegistry._try_load_model_cls(model_class_name)
if model_cls is None:
return False
return bool(getattr(model_cls, "support_audio_input", False))
def image_color_format(model_class_name: str) -> str:
model_cls = DiffusionModelRegistry._try_load_model_cls(model_class_name)
return getattr(model_cls, "color_format", "RGB")
@@ -369,7 +377,19 @@ class DiffusionEngine:
dummy_image = PIL.Image.new(color_format, (width, height))
else:
dummy_image = None
prompt: OmniTextPrompt = {"prompt": "dummy run", "multi_modal_data": {"image": dummy_image}}
if supports_audio_input(self.od_config.model_class_name):
audio_sr = 16000
audio_duration_sec = 4
audio_array = np.random.randn(audio_sr * audio_duration_sec).astype(np.float32)
dummy_audio = audio_array[audio_sr * 1 : audio_sr * 3]
else:
dummy_audio = None
prompt: OmniTextPrompt = {
"prompt": "dummy run",
"multi_modal_data": {"image": dummy_image, "audio": dummy_audio},
}
req = OmniDiffusionRequest(
prompts=[prompt],
sampling_params=OmniDiffusionSamplingParams(
+1 -1
View File
@@ -20,7 +20,7 @@ environment_variables: dict[str, Callable[[], Any]] = {
# used in distributed environment to determine the master address
"MASTER_ADDR": lambda: os.getenv("MASTER_ADDR", ""),
# used in distributed environment to manually set the communication port
"MASTER_PORT": lambda: (int(os.getenv("MASTER_PORT", "0")) if "MASTER_PORT" in os.environ else None),
"MASTER_PORT": lambda: int(os.getenv("MASTER_PORT", "0")) if "MASTER_PORT" in os.environ else None,
# path to cudatoolkit home directory, under which should be bin, include,
# and lib directories.
"CUDA_HOME": lambda: os.environ.get("CUDA_HOME", None),
+4 -1
View File
@@ -8,6 +8,7 @@ and modifying model forward passes without invasive changes to model code.
from __future__ import annotations
import inspect
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
@@ -166,7 +167,9 @@ class HookRegistry:
# `_original_forward` attribute on the same module.
if not hasattr(module, "_omni_original_forward"):
module._omni_original_forward = module.forward # type: ignore[attr-defined]
module.forward = _WrappedForward(module) # type: ignore[assignment]
wrapped = _WrappedForward(module)
wrapped.__signature__ = inspect.signature(module.forward) # type: ignore[attr-defined]
module.forward = wrapped # type: ignore[assignment]
return registry
@@ -0,0 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from .pipeline_dreamid_omni import DreamIDOmniPipeline
__all__ = [
"DreamIDOmniPipeline",
]
@@ -0,0 +1,335 @@
import torch
import torch.nn as nn
from vllm_omni.diffusion.attention.layer import Attention
try:
from dreamid_omni.modules.model import WanLayerNorm, WanRMSNorm
except ImportError:
raise ImportError("Failed to import from dependency 'dreamid_omni'.")
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.models.dreamid_omni.wan2_2 import WanModel, rope_apply
class FusionModel(nn.Module):
def __init__(self, video_config=None, audio_config=None):
super().__init__()
has_video = True
has_audio = True
if video_config is not None:
self.video_model = WanModel(**video_config)
else:
has_video = False
self.video_model = None
print("Warning: No video model is provided!")
if audio_config is not None:
self.audio_model = WanModel(**audio_config)
else:
has_audio = False
self.audio_model = None
print("Warning: No audio model is provided!")
if has_video and has_audio:
assert len(self.video_model.blocks) == len(self.audio_model.blocks)
self.num_blocks = len(self.video_model.blocks)
self.inject_cross_attention_kv_projections()
self.device = get_local_device()
self.num_heads = self.video_model.num_heads
self.head_dim = self.video_model.dim // self.video_model.num_heads
self.attn = Attention(
num_heads=self.num_heads,
head_size=self.head_dim,
num_kv_heads=self.num_heads,
softmax_scale=1.0 / (self.head_dim**0.5),
causal=False,
)
def inject_cross_attention_kv_projections(self):
for vid_block in self.video_model.blocks:
vid_block.cross_attn.k_fusion = nn.Linear(vid_block.dim, vid_block.dim)
vid_block.cross_attn.v_fusion = nn.Linear(vid_block.dim, vid_block.dim)
vid_block.cross_attn.pre_attn_norm_fusion = WanLayerNorm(vid_block.dim, elementwise_affine=True)
vid_block.cross_attn.norm_k_fusion = (
WanRMSNorm(vid_block.dim, eps=1e-6) if vid_block.qk_norm else nn.Identity()
)
for audio_block in self.audio_model.blocks:
audio_block.cross_attn.k_fusion = nn.Linear(audio_block.dim, audio_block.dim)
audio_block.cross_attn.v_fusion = nn.Linear(audio_block.dim, audio_block.dim)
audio_block.cross_attn.pre_attn_norm_fusion = WanLayerNorm(audio_block.dim, elementwise_affine=True)
audio_block.cross_attn.norm_k_fusion = (
WanRMSNorm(audio_block.dim, eps=1e-6) if audio_block.qk_norm else nn.Identity()
)
def merge_kwargs(self, vid_kwargs, audio_kwargs):
"""
keys in each kwarg:
e
seq_lens
grid_sizes
freqs
context
context_lens
"""
merged_kwargs = {}
for key in vid_kwargs:
merged_kwargs[f"vid_{key}"] = vid_kwargs[key]
for key in audio_kwargs:
merged_kwargs[f"audio_{key}"] = audio_kwargs[key]
return merged_kwargs
def single_fusion_cross_attention_forward(
self,
cross_attn_block,
src_seq,
src_grid_sizes,
src_freqs,
target_seq,
target_seq_lens,
target_grid_sizes,
target_freqs,
context,
context_lens,
src_ref_lengths=None,
src_freqs_scaling=None,
target_ref_lengths=None,
target_freqs_scaling=None,
):
b, n, d = src_seq.size(0), cross_attn_block.num_heads, cross_attn_block.head_dim
if hasattr(cross_attn_block, "k_img"):
## means is i2v block
q, k, v, k_img, v_img = cross_attn_block.qkv_fn(src_seq, context)
else:
## means is t2v block
q, k, v = cross_attn_block.qkv_fn(src_seq, context)
k_img = v_img = None
x = self.attn(q, k, v)
if k_img is not None:
img_x = self.attn(q, k_img, v_img)
x = x + img_x
# is_vid = src_grid_sizes.shape[1] > 1
# compute target attention
target_seq = cross_attn_block.pre_attn_norm_fusion(target_seq)
k_target = cross_attn_block.norm_k_fusion(cross_attn_block.k_fusion(target_seq)).view(b, -1, n, d)
v_target = cross_attn_block.v_fusion(target_seq).view(b, -1, n, d)
q = rope_apply(q, src_grid_sizes, src_freqs, ref_lengths=src_ref_lengths, freqs_scaling=src_freqs_scaling)
k_target = rope_apply(
k_target,
target_grid_sizes,
target_freqs,
ref_lengths=target_ref_lengths,
freqs_scaling=target_freqs_scaling,
)
target_x = self.attn(q, k_target, v_target)
x = x + target_x
x = x.flatten(2) # [B, L/P, C]
x = cross_attn_block.o(x)
return x
def single_fusion_cross_attention_ffn_forward(
self,
attn_block,
src_seq,
src_grid_sizes,
src_freqs,
target_seq,
target_seq_lens,
target_grid_sizes,
target_freqs,
context,
context_lens,
src_e,
src_ref_lengths=None,
src_freqs_scaling=None,
target_ref_lengths=None,
target_freqs_scaling=None,
):
src_seq = src_seq + self.single_fusion_cross_attention_forward(
attn_block.cross_attn,
attn_block.norm3(src_seq),
src_grid_sizes=src_grid_sizes,
src_freqs=src_freqs,
target_seq=target_seq,
target_seq_lens=target_seq_lens,
target_grid_sizes=target_grid_sizes,
target_freqs=target_freqs,
context=context,
context_lens=context_lens,
src_ref_lengths=src_ref_lengths,
src_freqs_scaling=src_freqs_scaling,
target_ref_lengths=target_ref_lengths,
target_freqs_scaling=target_freqs_scaling,
)
y = attn_block.ffn(attn_block.norm2(src_seq).bfloat16() * (1 + src_e[4].squeeze(2)) + src_e[3].squeeze(2))
with torch.autocast(device_type=self.device.type, dtype=torch.bfloat16, enabled=True):
src_seq = src_seq + y * src_e[5].squeeze(2)
return src_seq
def single_fusion_block_forward(
self,
vid_block,
audio_block,
vid,
audio,
vid_e,
vid_seq_lens,
vid_grid_sizes,
vid_freqs,
vid_context,
vid_context_lens,
vid_ref_lengths,
vid_freqs_scaling,
audio_e,
audio_seq_lens,
audio_grid_sizes,
audio_freqs,
audio_context,
audio_context_lens,
audio_ref_lengths,
audio_freqs_scaling,
):
## audio modulation
assert audio_e.dtype == torch.bfloat16
assert len(audio_e.shape) == 4 and audio_e.size(2) == 6 and audio_e.shape[1] == audio.shape[1], (
f"{audio_e.shape}, {audio.shape}"
)
with torch.autocast(device_type=self.device.type, dtype=torch.bfloat16, enabled=True):
audio_e = audio_block.modulation(audio_e).chunk(6, dim=2)
assert audio_e[0].dtype == torch.bfloat16
# audio self-attention
audio_y = audio_block.self_attn(
audio_block.norm1(audio).bfloat16() * (1 + audio_e[1].squeeze(2)) + audio_e[0].squeeze(2),
audio_seq_lens,
audio_grid_sizes,
audio_freqs,
ref_lengths=audio_ref_lengths,
freqs_scaling=audio_freqs_scaling,
)
with torch.autocast(device_type=self.device.type, dtype=torch.bfloat16, enabled=True):
audio = audio + audio_y * audio_e[2].squeeze(2)
## video modulation
assert len(vid_e.shape) == 4 and vid_e.size(2) == 6 and vid_e.shape[1] == vid.shape[1], (
f"{vid_e.shape}, {vid.shape}"
)
with torch.autocast(device_type=self.device.type, dtype=torch.bfloat16, enabled=True):
vid_e = vid_block.modulation(vid_e).chunk(6, dim=2)
# video self-attention
vid_y = vid_block.self_attn(
vid_block.norm1(vid).bfloat16() * (1 + vid_e[1].squeeze(2)) + vid_e[0].squeeze(2),
vid_seq_lens,
vid_grid_sizes,
vid_freqs,
ref_lengths=vid_ref_lengths,
)
with torch.autocast(device_type=self.device.type, dtype=torch.bfloat16, enabled=True):
vid = vid + vid_y * vid_e[2].squeeze(2)
og_audio = audio
# audio cross-attention
audio = self.single_fusion_cross_attention_ffn_forward(
audio_block,
audio,
audio_grid_sizes,
audio_freqs,
vid,
vid_seq_lens,
vid_grid_sizes,
vid_freqs,
audio_context,
audio_context_lens,
audio_e,
src_ref_lengths=audio_ref_lengths,
src_freqs_scaling=audio_freqs_scaling,
target_ref_lengths=vid_ref_lengths,
target_freqs_scaling=None,
)
assert not torch.equal(og_audio, audio), "Audio should be changed after cross-attention!"
# video cross-attention
vid = self.single_fusion_cross_attention_ffn_forward(
vid_block,
vid,
vid_grid_sizes,
vid_freqs,
og_audio,
audio_seq_lens,
audio_grid_sizes,
audio_freqs,
vid_context,
vid_context_lens,
vid_e,
src_ref_lengths=vid_ref_lengths,
src_freqs_scaling=None,
target_ref_lengths=audio_ref_lengths,
target_freqs_scaling=audio_freqs_scaling,
)
return vid, audio
def forward(
self,
vid,
audio,
t,
vid_context,
audio_context,
vid_seq_len,
audio_seq_len,
ref_ip_lengths=None,
ref_audio_lengths=None,
slg_layer=False,
freqs_scaling=None,
):
vid, vid_e, vid_kwargs = self.video_model.prepare_transformer_block_kwargs(
x=vid, t=t, context=vid_context, seq_len=vid_seq_len, ref_lengths=ref_ip_lengths
)
audio, audio_e, audio_kwargs = self.audio_model.prepare_transformer_block_kwargs(
x=audio,
t=t,
context=audio_context,
seq_len=audio_seq_len,
ref_lengths=ref_audio_lengths,
freqs_scaling=freqs_scaling,
)
kwargs = self.merge_kwargs(vid_kwargs, audio_kwargs)
for i in range(self.num_blocks):
"""
1 fusion block refers to 1 audio block with 1 video block.
"""
vid_block = self.video_model.blocks[i]
audio_block = self.audio_model.blocks[i]
vid, audio = self.single_fusion_block_forward(
vid_block=vid_block, audio_block=audio_block, vid=vid, audio=audio, **kwargs
)
vid = self.video_model.post_transformer_block_out(vid, vid_kwargs["grid_sizes"], vid_e)
audio = self.audio_model.post_transformer_block_out(audio, audio_kwargs["grid_sizes"], audio_e)
return vid, audio
def set_rope_params(self):
self.video_model.set_rope_params()
self.audio_model.set_rope_params()
@@ -0,0 +1,508 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import logging
import math
import os
import torch
import torch.distributed
from diffusers import FlowMatchEulerDiscreteScheduler
from PIL import Image, ImageOps
from torch import nn
from torchvision.transforms import Compose, Normalize
from tqdm import tqdm
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
from vllm_omni.diffusion.distributed.parallel_state import (
get_cfg_group,
get_classifier_free_guidance_rank,
get_classifier_free_guidance_world_size,
)
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.models.interface import SupportAudioInput, SupportImageInput
from vllm_omni.diffusion.request import OmniDiffusionRequest
try:
from dreamid_omni.utils.divisible_crop import DivisibleCrop
from dreamid_omni.utils.fm_solvers import FlowDPMSolverMultistepScheduler, get_sampling_sigmas, retrieve_timesteps
from dreamid_omni.utils.fm_solvers_unipc import FlowUniPCMultistepScheduler
from dreamid_omni.utils.model_loading_utils import (
init_mmaudio_vae,
init_text_model,
init_wan_vae_2_2,
load_fusion_checkpoint,
)
from dreamid_omni.utils.rearrange import Rearrange
from dreamid_omni.utils.resize import NaResize
except ImportError:
raise ImportError("Failed to import from dependency 'dreamid_omni'.")
from vllm_omni.diffusion.models.dreamid_omni.fusion import FusionModel
logger = logging.getLogger(__name__)
AUDIO_CONFIG = {
"patch_size": [1],
"model_type": "t2a",
"dim": 3072,
"ffn_dim": 14336,
"freq_dim": 256,
"num_heads": 24,
"num_layers": 30,
"in_dim": 20,
"out_dim": 20,
"text_len": 512,
"window_size": [-1, -1],
"qk_norm": True,
"cross_attn_norm": True,
"eps": 1e-6,
"temporal_rope_scaling_factor": 0.19676,
}
VIDEO_CONFIG = {
"patch_size": [1, 2, 2],
"model_type": "ti2v",
"dim": 3072,
"ffn_dim": 14336,
"freq_dim": 256,
"num_heads": 24,
"num_layers": 30,
"in_dim": 48,
"out_dim": 48,
"text_len": 512,
"window_size": [-1, -1],
"qk_norm": True,
"cross_attn_norm": True,
"eps": 1e-6,
}
class DreamIDOmniPipeline(nn.Module, CFGParallelMixin, SupportImageInput, SupportAudioInput):
"""DreamID-Omni pipeline for vLLM-Omni."""
def __init__(
self,
*,
od_config: OmniDiffusionConfig,
prefix: str = "",
):
super().__init__()
self.od_config = od_config
self.parallel_config = od_config.parallel_config
self.device = get_local_device()
model = od_config.model
self.target_dtype = od_config.dtype
# Init Models
## Load VAEs
vae_model_video = init_wan_vae_2_2(model, rank=self.device)
vae_model_video.model.requires_grad_(False).eval()
vae_model_video.model = vae_model_video.model.bfloat16()
self.vae_model_video = vae_model_video
self.vae = self.vae_model_video
vae_model_audio = init_mmaudio_vae(model, rank=self.device)
vae_model_audio.requires_grad_(False).eval()
self.vae_model_audio = vae_model_audio.bfloat16()
# Load T5 text model
self.text_model = init_text_model(model, rank=self.device)
self.text_encoder = self.text_model.model
# Fusion model
## load audio/video model config
Fusion_model = FusionModel(VIDEO_CONFIG, AUDIO_CONFIG)
checkpoint_path = self.od_config.model_config.get("fusion", None)
assert checkpoint_path is not None, "fusion checkpoint path is None"
load_fusion_checkpoint(Fusion_model, checkpoint_path=os.path.join(model, checkpoint_path))
self.model = Fusion_model
self.transformer = self.model
# Fixed attributes, non-configurable
self.audio_latent_channel = AUDIO_CONFIG.get("in_dim")
self.video_latent_channel = VIDEO_CONFIG.get("in_dim")
self.video_latent_length = 31
self.audio_latent_length = 157
self.target_area = 960 * 960
# Fixed attributes, CFG scales
self.video_cfg_scale = 3.0
self.video_ref_cfg_scale = 1.5
self.audio_cfg_scale = 4.0
self.audio_ref_cfg_scale = 2.0
# Schedulers will be set in forward
self.scheduler_video = None
self.scheduler_audio = None
def load_image_latent_ref_ip_video(
self,
images,
audios,
video_frame_height_width,
):
# Load size.
patch_size = self.model.video_model.patch_size
vae_stride = [4, 16, 16]
# Load image and video.
ref_vae_latents = {
"image": [],
"audio": [],
}
video_h = video_frame_height_width[0]
video_w = video_frame_height_width[1]
ref_audio_lengths = []
for img in images:
# Calculate the required size to keep aspect ratio and fill the rest with padding.
img_ratio = img.width / img.height
target_ratio = video_w / video_h
if img_ratio > target_ratio: # Image is wider than target
new_width = video_w
new_height = int(new_width / img_ratio)
else: # Image is taller than target
new_height = video_h
new_width = int(new_height * img_ratio)
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Create a new image with the target size and place the resized image in the center
delta_w = video_w - img.size[0]
delta_h = video_h - img.size[1]
padding = (delta_w // 2, delta_h // 2, delta_w - (delta_w // 2), delta_h - (delta_h // 2))
new_img = ImageOps.expand(img, padding, fill=(255, 255, 255))
# Transform to tensor and normalize.
image_transform = Compose(
[
NaResize(
resolution=math.sqrt(
video_frame_height_width[0] * video_frame_height_width[1]
), # 256*448, 480*832
mode="area",
downsample_only=True,
),
DivisibleCrop((vae_stride[1] * patch_size[1], vae_stride[2] * patch_size[2])),
Normalize(0.5, 0.5),
Rearrange("t c h w -> c t h w"),
]
)
new_img = image_transform([new_img])
new_img = new_img.transpose(0, 1)
new_img = new_img.to(self.device)
new_img = new_img.to(self.target_dtype)
img_vae_latent = self.vae_model_video.wrapped_encode(new_img[:, :, None]).to(self.target_dtype).squeeze(0)
ref_vae_latents["image"].append(img_vae_latent)
for audio_array in audios:
audio_tensor = torch.from_numpy(audio_array).float().unsqueeze(0)
# print(audio_tensor.shape)#torch.Size([1, 81280])
audio_vae_latent = self.vae_model_audio.wrapped_encode(audio_tensor)
# print(audio_vae_latent.shape)#torch.Size([1, 20, length])
audio_length = audio_vae_latent.shape[2]
ref_audio_lengths.append(audio_length)
audio_vae_latent = audio_vae_latent.squeeze(0).transpose(0, 1)
ref_vae_latents["audio"].append(audio_vae_latent)
ref_vae_latents["image"] = torch.cat(ref_vae_latents["image"], dim=1)
ref_vae_latents["audio"] = torch.cat(ref_vae_latents["audio"], dim=0)
return ref_vae_latents, ref_audio_lengths
def load_weights(self, weights):
pass
def get_scheduler_time_steps(self, sampling_steps, solver_name="unipc", device=0, shift=5.0):
torch.manual_seed(4)
if solver_name == "unipc":
sample_scheduler = FlowUniPCMultistepScheduler(
num_train_timesteps=1000, shift=1, use_dynamic_shifting=False
)
sample_scheduler.set_timesteps(sampling_steps, device=device, shift=shift)
timesteps = sample_scheduler.timesteps
elif solver_name == "dpm++":
sample_scheduler = FlowDPMSolverMultistepScheduler(
num_train_timesteps=1000, shift=1, use_dynamic_shifting=False
)
sampling_sigmas = get_sampling_sigmas(sampling_steps, shift=shift)
timesteps, _ = retrieve_timesteps(sample_scheduler, device=device, sigmas=sampling_sigmas)
elif solver_name == "euler":
sample_scheduler = FlowMatchEulerDiscreteScheduler(shift=shift)
timesteps, sampling_steps = retrieve_timesteps(
sample_scheduler,
sampling_steps,
device=device,
)
else:
raise NotImplementedError("Unsupported solver.")
return sample_scheduler, timesteps
def diffuse(
self,
video_noise: torch.Tensor,
audio_noise: torch.Tensor,
latents_ref_image: torch.Tensor,
latents_ref_audio: torch.Tensor,
timesteps_video: torch.Tensor,
timesteps_audio: torch.Tensor,
text_embeddings_video_pos: torch.Tensor,
text_embeddings_video_neg: torch.Tensor,
text_embeddings_audio_pos: torch.Tensor,
text_embeddings_audio_neg: torch.Tensor,
max_seq_len_video: int,
max_seq_len_audio: int,
freqs_scaling_tensor: torch.Tensor,
ref_ip_num: int,
ref_audio_length: int,
ref_audio_lengths: list,
scheduler_video,
scheduler_audio,
) -> tuple[torch.Tensor, torch.Tensor]:
"""
Diffusion loop with CFG parallel support for DreamID-Omni.
"""
for i, (t_v, t_a) in tqdm(enumerate(zip(timesteps_video, timesteps_audio))):
timestep_input = torch.full((1,), t_v, device=self.device)
model_input_video = torch.cat([video_noise[:, :-ref_ip_num], latents_ref_image], dim=1)
model_input_video_neg = torch.cat(
[video_noise[:, :-ref_ip_num], torch.zeros_like(latents_ref_image)], dim=1
)
model_input_audio = torch.cat([audio_noise[:-ref_audio_length, :], latents_ref_audio], dim=0)
model_input_audio_neg = torch.cat(
[audio_noise[:-ref_audio_length, :], torch.zeros_like(latents_ref_audio)], dim=0
)
ref_ip_lengths = [ref_ip_num]
common_args = {
"vid_seq_len": max_seq_len_video,
"audio_seq_len": max_seq_len_audio,
"freqs_scaling": freqs_scaling_tensor,
"ref_ip_lengths": [ref_ip_lengths],
"ref_audio_lengths": [ref_audio_lengths],
}
pos_args = {
**common_args,
"audio_context": [text_embeddings_audio_pos],
"vid_context": [text_embeddings_video_pos],
}
neg_args = {
**common_args,
"audio_context": [text_embeddings_audio_neg],
"vid_context": [text_embeddings_video_neg],
}
if get_classifier_free_guidance_world_size() > 1:
# Enable CFG-parallel: rank0 computes positive, rank1 computes negative.
cfg_group = get_cfg_group()
cfg_rank = get_classifier_free_guidance_rank()
if cfg_rank == 0:
pred_vid, pred_audio = self.model(
vid=[model_input_video], audio=[model_input_audio], t=timestep_input, **pos_args
)
pre_vid_ip_neg, _ = self.model(
vid=[model_input_video_neg], audio=[model_input_audio], t=timestep_input, **pos_args
)
pred_vid_0 = pred_vid[0]
pred_audio_0 = pred_audio[0]
pre_vid_ip_0 = pre_vid_ip_neg[0]
pred_refaudio_0 = torch.zeros_like(pred_audio_0) # dummy tensor
else:
pred_vid, pred_audio = self.model(
vid=[model_input_video], audio=[model_input_audio], t=timestep_input, **neg_args
)
_, pred_refaudio_neg = self.model(
vid=[model_input_video], audio=[model_input_audio_neg], t=timestep_input, **pos_args
)
pred_vid_0 = pred_vid[0]
pred_audio_0 = pred_audio[0]
pre_vid_ip_0 = torch.zeros_like(pred_vid_0) # dummy tensor
pred_refaudio_0 = pred_refaudio_neg[0]
pred_vid_gathered = cfg_group.all_gather(pred_vid_0, separate_tensors=True)
pred_audio_gathered = cfg_group.all_gather(pred_audio_0, separate_tensors=True)
pre_vid_ip_gathered = cfg_group.all_gather(pre_vid_ip_0, separate_tensors=True)
pred_refaudio_gathered = cfg_group.all_gather(pred_refaudio_0, separate_tensors=True)
pred_vid_pos = [pred_vid_gathered[0]]
pred_vid_neg = [pred_vid_gathered[1]]
pred_audio_pos = [pred_audio_gathered[0]]
pred_audio_neg = [pred_audio_gathered[1]]
pre_vid_ip_neg = [pre_vid_ip_gathered[0]]
pred_refaudio_neg = [pred_refaudio_gathered[1]]
else:
pred_vid_pos, pred_audio_pos = self.model(
vid=[model_input_video], audio=[model_input_audio], t=timestep_input, **pos_args
)
pred_vid_neg, pred_audio_neg = self.model(
vid=[model_input_video], audio=[model_input_audio], t=timestep_input, **neg_args
)
pre_vid_ip_neg, _ = self.model(
vid=[model_input_video_neg], audio=[model_input_audio], t=timestep_input, **pos_args
)
_, pred_refaudio_neg = self.model(
vid=[model_input_video], audio=[model_input_audio_neg], t=timestep_input, **pos_args
)
pred_video_guided = (
pred_vid_neg[0]
+ self.video_cfg_scale * (pred_vid_pos[0] - pred_vid_neg[0])
+ self.video_ref_cfg_scale * (pred_vid_pos[0] - pre_vid_ip_neg[0])
)
pred_audio_guided = (
pred_audio_neg[0]
+ self.audio_cfg_scale * (pred_audio_pos[0] - pred_audio_neg[0])
+ self.audio_ref_cfg_scale * (pred_audio_pos[0] - pred_refaudio_neg[0])
)
video_noise = scheduler_video.step(
pred_video_guided.unsqueeze(0), t_v, video_noise.unsqueeze(0), return_dict=False
)[0].squeeze(0)
audio_noise = scheduler_audio.step(
pred_audio_guided.unsqueeze(0), t_a, audio_noise.unsqueeze(0), return_dict=False
)[0].squeeze(0)
return video_noise, audio_noise
def forward(
self,
request: OmniDiffusionRequest,
**kwargs,
) -> DiffusionOutput:
"""Main forward pass for DreamID-Omni pipeline for R2AV task."""
# Extract parameters from request
r_prompts = request.prompts[0]
if isinstance(r_prompts, str):
prompt = r_prompts
video_negative_prompt = ""
audio_negative_prompt = ""
else:
prompt = r_prompts.get("prompt", "")
video_negative_prompt = r_prompts.get("video_negative_prompt", "")
audio_negative_prompt = r_prompts.get("audio_negative_prompt", "")
multi_modal_data = r_prompts.get("multi_modal_data", {}) if not isinstance(r_prompts, str) else None
raw_images = multi_modal_data.get("image", None) if multi_modal_data is not None else None
raw_audios = multi_modal_data.get("audio", None) if multi_modal_data is not None else None
if raw_images is None or raw_audios is None:
raise ValueError("This model requires image and audio to run.")
if not isinstance(raw_images, list):
raw_images = [raw_images]
if not isinstance(raw_audios, list):
raw_audios = [raw_audios]
if len(raw_images) != len(raw_audios):
raise ValueError("'image' and 'audio' lists must have the same length.")
height = request.sampling_params.height
width = request.sampling_params.width
num_inference_steps = request.sampling_params.num_inference_steps
shift = request.sampling_params.extra_args.get("shift", 5.0)
solver_name = request.sampling_params.extra_args.get("solver_name", "unipc")
seed = request.sampling_params.seed if request.sampling_params.seed is not None else 42
# 1. Prepare reference latents
ref_vae_latents, ref_audio_lengths = self.load_image_latent_ref_ip_video(
images=raw_images,
audios=raw_audios,
video_frame_height_width=(height, width),
)
latents_ref_image = ref_vae_latents["image"]
latents_ref_audio = ref_vae_latents["audio"]
ref_ip_num = latents_ref_image.shape[1]
ref_audio_length = latents_ref_audio.shape[0]
# 2. scheduler
scheduler_video, timesteps_video = self.get_scheduler_time_steps(
sampling_steps=num_inference_steps, device=self.device, solver_name=solver_name, shift=shift
)
scheduler_audio, timesteps_audio = self.get_scheduler_time_steps(
sampling_steps=num_inference_steps, device=self.device, solver_name=solver_name, shift=shift
)
# 3. text embedding
text_embeddings = self.text_model([prompt, video_negative_prompt, audio_negative_prompt], device=self.device)
text_embeddings = [emb.to(self.target_dtype) for emb in text_embeddings]
text_embeddings_audio_pos = text_embeddings[0]
text_embeddings_video_pos = text_embeddings[0]
text_embeddings_video_neg = text_embeddings[1]
text_embeddings_audio_neg = text_embeddings[2]
video_latent_h, video_latent_w = height // 16, width // 16
video_noise_len = self.video_latent_length + ref_ip_num
audio_noise_len = self.audio_latent_length + ref_audio_length
freqs_scaling_tensor = torch.tensor(
self.video_latent_length / self.audio_latent_length, device=self.device, dtype=self.target_dtype
)
video_noise = torch.randn(
(self.video_latent_channel, video_noise_len, video_latent_h, video_latent_w),
device=self.device,
dtype=self.target_dtype,
generator=torch.Generator(device=self.device).manual_seed(seed),
)
audio_noise = torch.randn(
(audio_noise_len, self.audio_latent_channel),
device=self.device,
dtype=self.target_dtype,
generator=torch.Generator(device=self.device).manual_seed(seed),
)
_patch_size_h, _patch_size_w = self.model.video_model.patch_size[1], self.model.video_model.patch_size[2]
max_seq_len_video = (
video_noise.shape[1] * video_noise.shape[2] * video_noise.shape[3] // (_patch_size_h * _patch_size_w)
)
max_seq_len_audio = audio_noise_len
with torch.amp.autocast("cuda", enabled=self.target_dtype != torch.float32, dtype=self.target_dtype):
video_noise, audio_noise = self.diffuse(
video_noise,
audio_noise,
latents_ref_image,
latents_ref_audio,
timesteps_video,
timesteps_audio,
text_embeddings_video_pos,
text_embeddings_video_neg,
text_embeddings_audio_pos,
text_embeddings_audio_neg,
max_seq_len_video,
max_seq_len_audio,
freqs_scaling_tensor,
ref_ip_num,
ref_audio_length,
ref_audio_lengths,
scheduler_video,
scheduler_audio,
)
video_noise_for_decode = video_noise[:, :-ref_ip_num]
audio_noise_for_decode = audio_noise[:-ref_audio_length, :]
audio_latents_for_vae = audio_noise_for_decode.unsqueeze(0).transpose(1, 2)
generated_audio = self.vae_model_audio.wrapped_decode(audio_latents_for_vae).squeeze().cpu().float().numpy()
video_latents_for_vae = video_noise_for_decode.unsqueeze(0)
generated_video = self.vae_model_video.wrapped_decode(video_latents_for_vae).squeeze(0).cpu().float().numpy()
return DiffusionOutput(output=(generated_video, generated_audio))
@@ -0,0 +1,506 @@
# Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved.
import math
import torch
import torch.amp as amp
import torch.nn as nn
from diffusers.configuration_utils import ConfigMixin, register_to_config
from diffusers.models.modeling_utils import ModelMixin
from vllm_omni.diffusion.attention.layer import Attention
try:
from dreamid_omni.modules.model import (
ChannelLastConv1d,
ConvMLP,
Head,
MLPProj,
ModulationAdd,
WanLayerNorm,
WanRMSNorm,
rope_apply, # diff from wan2.2
rope_params,
sinusoidal_embedding_1d,
)
except ImportError:
raise ImportError("Failed to import from dependency 'dreamid_omni'.")
class WanSelfAttention(nn.Module):
"""Optimized self-attention module using vLLM layers."""
def __init__(self, dim, num_heads, window_size=(-1, -1), qk_norm=True, eps=1e-6):
assert dim % num_heads == 0
super().__init__()
self.dim = dim
self.num_heads = num_heads
self.head_dim = dim // num_heads
self.window_size = window_size
self.qk_norm = qk_norm
self.eps = eps
# layers
self.q = nn.Linear(dim, dim)
self.k = nn.Linear(dim, dim)
self.v = nn.Linear(dim, dim)
self.o = nn.Linear(dim, dim)
self.norm_q = WanRMSNorm(dim, eps=eps) if qk_norm else nn.Identity()
self.norm_k = WanRMSNorm(dim, eps=eps) if qk_norm else nn.Identity()
# Unified attention layer
self.attn = Attention(
num_heads=self.num_heads,
head_size=self.head_dim,
num_kv_heads=self.num_heads,
softmax_scale=1.0 / (self.head_dim**0.5),
causal=False,
)
# query, key, value function
def qkv_fn(self, x):
b, s, n, d = *x.shape[:2], self.num_heads, self.head_dim
q = self.norm_q(self.q(x)).view(b, s, n, d)
k = self.norm_k(self.k(x)).view(b, s, n, d)
v = self.v(x).view(b, s, n, d)
return q, k, v
def forward(self, x, seq_lens, grid_sizes, freqs, ref_lengths=None, freqs_scaling=None):
r"""
Args:
x(Tensor): Shape [B, L, C]
seq_lens(Tensor): Shape [B]
grid_sizes(Tensor): Shape [B, 3], the second dimension contains (F, H, W)
freqs(Tensor): Rope freqs, shape [1024, C / num_heads / 2]
ref_lengths(Tensor): Shape [B]
"""
q, k, v = self.qkv_fn(x)
x = self.attn(
rope_apply(q, grid_sizes, freqs, ref_lengths=ref_lengths, freqs_scaling=freqs_scaling),
rope_apply(k, grid_sizes, freqs, ref_lengths=ref_lengths, freqs_scaling=freqs_scaling),
v,
)
# output
x = x.flatten(2)
x = self.o(x)
return x
class WanT2VCrossAttention(WanSelfAttention):
def qkv_fn(self, x, context):
b, n, d = x.size(0), self.num_heads, self.head_dim
# compute query, key, value
q = self.norm_q(self.q(x)).view(b, -1, n, d)
k = self.norm_k(self.k(context)).view(b, -1, n, d)
v = self.v(context).view(b, -1, n, d)
return q, k, v
def forward(self, x, context, context_lens):
r"""
Args:
x(Tensor): Shape [B, L1, C]
context(Tensor): Shape [B, L2, C]
context_lens(Tensor): Shape [B]
"""
q, k, v = self.qkv_fn(x, context)
# compute attention
x = self.attn(q, k, v)
# output
x = x.flatten(2)
x = self.o(x)
return x
class WanI2VCrossAttention(nn.Module):
def __init__(self, *args, **kwargs):
super().__init__()
def forward(self, *args, **kwargs):
raise RuntimeError("WanT2VCrossAttention is currently disabled and should not be used.")
WAN_CROSSATTENTION_CLASSES = {
"t2v_cross_attn": WanT2VCrossAttention,
"i2v_cross_attn": WanI2VCrossAttention,
}
class WanAttentionBlock(nn.Module):
def __init__(
self,
cross_attn_type,
dim,
ffn_dim,
num_heads,
window_size=(-1, -1),
qk_norm=True,
cross_attn_norm=False,
eps=1e-6,
additional_emb_length=None,
):
super().__init__()
self.dim = dim
self.ffn_dim = ffn_dim
self.num_heads = num_heads
self.window_size = window_size
self.qk_norm = qk_norm
self.cross_attn_norm = cross_attn_norm
self.eps = eps
# layers
self.norm1 = WanLayerNorm(dim, eps)
self.self_attn = WanSelfAttention(dim, num_heads, window_size, qk_norm, eps)
self.norm3 = WanLayerNorm(dim, eps, elementwise_affine=True) if cross_attn_norm else nn.Identity()
if cross_attn_type == "i2v_cross_attn":
assert additional_emb_length is not None, "additional_emb_length should be specified for i2v_cross_attn"
self.cross_attn = WanI2VCrossAttention(dim, num_heads, (-1, -1), qk_norm, eps, additional_emb_length)
else:
assert additional_emb_length is None, "additional_emb_length should be None for t2v_cross_attn"
self.cross_attn = WanT2VCrossAttention(
dim,
num_heads,
(-1, -1),
qk_norm,
eps,
)
self.norm2 = WanLayerNorm(dim, eps)
self.ffn = nn.Sequential(nn.Linear(dim, ffn_dim), nn.GELU(approximate="tanh"), nn.Linear(ffn_dim, dim))
# modulation
self.modulation = ModulationAdd(dim, 6)
def forward(
self,
x,
e,
seq_lens,
grid_sizes,
freqs,
context,
context_lens,
):
r"""
Args:
x(Tensor): Shape [B, L, C]
e(Tensor): Shape [B, L1, 6, C]
seq_lens(Tensor): Shape [B], length of each sequence in batch
grid_sizes(Tensor): Shape [B, 3], the second dimension contains (F, H, W)
freqs(Tensor): Rope freqs, shape [1024, C / num_heads / 2]
"""
assert e.dtype == torch.bfloat16
assert len(e.shape) == 4 and e.size(2) == 6 and e.shape[1] == x.shape[1], f"{e.shape}, {x.shape}"
with amp.autocast("cuda", dtype=torch.bfloat16):
e = self.modulation(e).chunk(6, dim=2)
assert e[0].dtype == torch.bfloat16
# self-attention
y = self.self_attn(
self.norm1(x).bfloat16() * (1 + e[1].squeeze(2)) + e[0].squeeze(2), seq_lens, grid_sizes, freqs
)
with amp.autocast("cuda", dtype=torch.bfloat16):
x = x + y * e[2].squeeze(2)
# cross-attention & ffn function
def cross_attn_ffn(x, context, context_lens, e):
x = x + self.cross_attn(self.norm3(x), context, context_lens)
y = self.ffn(self.norm2(x).bfloat16() * (1 + e[4].squeeze(2)) + e[3].squeeze(2))
with amp.autocast("cuda", dtype=torch.bfloat16):
x = x + y * e[5].squeeze(2)
return x
x = cross_attn_ffn(x, context, context_lens, e)
return x
class WanModel(ModelMixin, ConfigMixin):
r"""
Wan diffusion backbone supporting both text-to-video and image-to-video, text-to-audio.
"""
@register_to_config
def __init__(
self,
model_type="t2v",
patch_size=(1, 2, 2),
text_len=512,
in_dim=16,
dim=2048,
ffn_dim=8192,
freq_dim=256,
text_dim=4096,
additional_emb_dim=None,
additional_emb_length=None,
out_dim=16,
num_heads=16,
num_layers=32,
window_size=(-1, -1),
qk_norm=True,
cross_attn_norm=True,
temporal_rope_scaling_factor=1.0,
eps=1e-6,
):
r"""
Initialize the diffusion model backbone.
Args:
model_type (`str`, *optional*, defaults to 't2v'):
Model variant - 't2v' (text-to-video) or 'i2v' (image-to-video)
patch_size (`tuple`, *optional*, defaults to (1, 2, 2)):
3D patch dimensions for video embedding (t_patch, h_patch, w_patch)
text_len (`int`, *optional*, defaults to 512):
Fixed length for text embeddings
in_dim (`int`, *optional*, defaults to 16):
Input video channels (C_in)
dim (`int`, *optional*, defaults to 2048):
Hidden dimension of the transformer
ffn_dim (`int`, *optional*, defaults to 8192):
Intermediate dimension in feed-forward network
freq_dim (`int`, *optional*, defaults to 256):
Dimension for sinusoidal time embeddings
text_dim (`int`, *optional*, defaults to 4096):
Input dimension for text embeddings
out_dim (`int`, *optional*, defaults to 16):
Output video channels (C_out)
num_heads (`int`, *optional*, defaults to 16):
Number of attention heads
num_layers (`int`, *optional*, defaults to 32):
Number of transformer blocks
window_size (`tuple`, *optional*, defaults to (-1, -1)):
Window size for local attention (-1 indicates global attention)
qk_norm (`bool`, *optional*, defaults to True):
Enable query/key normalization
cross_attn_norm (`bool`, *optional*, defaults to False):
Enable cross-attention normalization
eps (`float`, *optional*, defaults to 1e-6):
Epsilon value for normalization layers
"""
super().__init__()
assert model_type in [
"t2v",
"i2v",
"t2a",
"tt2a",
"ti2v",
] ## tt2a means text transcript + text description to audio (to support both TTS and T2A
self.model_type = model_type
is_audio_type = "a" in self.model_type
is_video_type = "v" in self.model_type
assert is_audio_type ^ is_video_type, "Either audio or video model should be specified"
if is_audio_type:
## audio model
assert len(patch_size) == 1 and patch_size[0] == 1, (
"Audio model should only accept 1 dimensional input, and we dont do patchify"
)
self.patch_size = patch_size
self.text_len = text_len
self.in_dim = in_dim
self.dim = dim
self.ffn_dim = ffn_dim
self.freq_dim = freq_dim
self.text_dim = text_dim
self.out_dim = out_dim
self.num_heads = num_heads
self.num_layers = num_layers
self.window_size = window_size
self.qk_norm = qk_norm
self.cross_attn_norm = cross_attn_norm
self.eps = eps
self.temporal_rope_scaling_factor = temporal_rope_scaling_factor
self.is_audio_type = is_audio_type
self.is_video_type = is_video_type
# embeddings
if is_audio_type:
## hardcoded to MMAudio
self.patch_embedding = nn.Sequential(
ChannelLastConv1d(in_dim, dim, kernel_size=7, padding=3),
nn.SiLU(),
ConvMLP(dim, dim * 4, kernel_size=7, padding=3),
)
else:
self.patch_embedding = nn.Conv3d(in_dim, dim, kernel_size=patch_size, stride=patch_size)
self.text_embedding = nn.Sequential(nn.Linear(text_dim, dim), nn.GELU(approximate="tanh"), nn.Linear(dim, dim))
self.time_embedding = nn.Sequential(nn.Linear(freq_dim, dim), nn.SiLU(), nn.Linear(dim, dim))
self.time_projection = nn.Sequential(nn.SiLU(), nn.Linear(dim, dim * 6))
# blocks
## so i2v and tt2a share the same cross attention while t2v and t2a share the same cross attention
cross_attn_type = "t2v_cross_attn" if model_type in ["t2v", "t2a", "ti2v"] else "i2v_cross_attn"
if cross_attn_type == "t2v_cross_attn":
assert additional_emb_dim is None and additional_emb_length is None, (
"additional_emb_length should be None for t2v and t2a model"
)
else:
assert additional_emb_dim is not None and additional_emb_length is not None, (
"additional_emb_length should be specified for i2v and tt2a model"
)
self.blocks = nn.ModuleList(
[
WanAttentionBlock(
cross_attn_type,
dim,
ffn_dim,
num_heads,
window_size,
qk_norm,
cross_attn_norm,
eps,
additional_emb_length,
)
for _ in range(num_layers)
]
)
# head
self.head = Head(dim, out_dim, patch_size, eps)
self.set_rope_params()
if model_type in ["i2v", "tt2a"]:
self.img_emb = MLPProj(additional_emb_dim, dim)
def set_rope_params(self):
# buffers (don't use register_buffer otherwise dtype will be changed in to())
dim = self.dim
num_heads = self.num_heads
assert (dim % num_heads) == 0 and (dim // num_heads) % 2 == 0
d = dim // num_heads
if self.is_audio_type:
## to be determined
# self.freqs = rope_params(1024, d, freqs_scaling=temporal_rope_scaling_factor)
self.freqs = rope_params(1024, d - 4 * (d // 6), freqs_scaling=self.temporal_rope_scaling_factor)
else:
self.freqs = torch.cat(
[rope_params(1024, d - 4 * (d // 6)), rope_params(1024, 2 * (d // 6)), rope_params(1024, 2 * (d // 6))],
dim=1,
)
def prepare_transformer_block_kwargs(
self,
x,
t,
context,
seq_len,
ref_lengths=None,
freqs_scaling=None,
):
# params
## need to change!
device = next(self.patch_embedding.parameters()).device
if self.is_audio_type and freqs_scaling is not None:
if isinstance(freqs_scaling, torch.Tensor):
scale_val = freqs_scaling.item()
else:
scale_val = freqs_scaling
d = self.dim // self.num_heads
current_freqs = rope_params(1024, d - 4 * (d // 6), freqs_scaling=scale_val).to(device)
else:
current_freqs = self.freqs
if current_freqs.device != device:
current_freqs = current_freqs.to(device)
# embeddings
x = [self.patch_embedding(u.unsqueeze(0)) for u in x] ## x is list of [B L D] or [B C F H W]
if self.is_audio_type:
# [B, 1]
grid_sizes = torch.stack([torch.tensor(u.shape[1:2], dtype=torch.long) for u in x])
else:
# [B, 3]
grid_sizes = torch.stack([torch.tensor(u.shape[2:], dtype=torch.long) for u in x])
x = [u.flatten(2).transpose(1, 2) for u in x] # [B C F H W] -> [B (F H W) C] -> [B L C]
seq_lens = torch.tensor([u.size(1) for u in x], dtype=torch.long)
assert seq_lens.max() <= seq_len, f"Sequence length {seq_lens.max()} exceeds maximum {seq_len}."
x = torch.cat(
[torch.cat([u, u.new_zeros(1, seq_len - u.size(1), u.size(2))], dim=1) for u in x]
) # single [B, L, C]
# time embeddings
if t.dim() == 1:
t = t.unsqueeze(1).expand(t.size(0), seq_len)
with amp.autocast("cuda", dtype=torch.bfloat16):
bt = t.size(0)
t = t.flatten()
e = self.time_embedding(sinusoidal_embedding_1d(self.freq_dim, t).unflatten(0, (bt, seq_len)).bfloat16())
e0 = self.time_projection(e).unflatten(2, (6, self.dim)) # [1, 26784, 6, 3072] - B, seq_len, 6, dim
assert e.dtype == torch.bfloat16 and e0.dtype == torch.bfloat16
# context
context_lens = None
context = self.text_embedding(
torch.stack([torch.cat([u, u.new_zeros(self.text_len - u.size(0), u.size(1))]) for u in context])
)
# arguments
kwargs = dict(
e=e0,
seq_lens=seq_lens,
grid_sizes=grid_sizes,
freqs=current_freqs,
context=context,
context_lens=context_lens,
ref_lengths=ref_lengths,
freqs_scaling=freqs_scaling,
)
return x, e, kwargs
def post_transformer_block_out(self, x, grid_sizes, e):
# head
x = self.head(x, e)
# unpatchify
if self.is_audio_type:
## grid_sizes is [B 1] where 1 is L,
# converting grid_sizes from [B 1] -> [B]
grid_sizes = [gs[0] for gs in grid_sizes]
assert len(x) == len(grid_sizes)
x = [u[:gs] for u, gs in zip(x, grid_sizes)]
else:
## grid_sizes is [B 3] where 3 is F H w
x = self.unpatchify(x, grid_sizes)
return [u.bfloat16() for u in x]
def forward(self, *args, **kwargs):
raise NotImplementedError("WanModel model does not support forward pass.")
def unpatchify(self, x, grid_sizes) -> list[torch.Tensor]:
r"""
Reconstruct video tensors from patch embeddings.
Args:
x (List[Tensor]):
List of patchified features, each with shape [L, C_out * prod(patch_size)]
grid_sizes (Tensor):
Original spatial-temporal grid dimensions before patching,
shape [B, 3] (3 dimensions correspond to F_patches, H_patches, W_patches)
Returns:
List[Tensor]:
Reconstructed video tensors with shape [C_out, F, H / 8, W / 8]
"""
c = self.out_dim
out = []
for u, v in zip(x, grid_sizes.tolist()):
# v is [F H w] F * H * 80, 100, it was right padded by 20.
u = u[: math.prod(v)].view(*v, *self.patch_size, c)
u = torch.einsum("fhwpqrc->cfphqwr", u)
u = u.reshape(c, *[i * j for i, j in zip(v, self.patch_size)])
out.append(u)
# out is list of [C F H W]
return out
+5
View File
@@ -13,6 +13,11 @@ class SupportImageInput(Protocol):
color_format: ClassVar[str] = "RGB" # Default color format
@runtime_checkable
class SupportAudioInput(Protocol):
support_audio_input: ClassVar[bool] = True
@runtime_checkable
class SupportAudioOutput(Protocol):
support_audio_output: ClassVar[bool] = True
@@ -156,8 +156,12 @@ class SD3CrossAttention(nn.Module):
else:
self.to_out = None
self.norm_added_q = RMSNorm(head_dim, eps=eps) if qk_norm else nn.Identity()
self.norm_added_k = RMSNorm(head_dim, eps=eps) if qk_norm else nn.Identity()
if added_kv_proj_dim is not None and qk_norm:
self.norm_added_q = RMSNorm(head_dim, eps=eps)
self.norm_added_k = RMSNorm(head_dim, eps=eps)
else:
self.norm_added_q = nn.Identity()
self.norm_added_k = nn.Identity()
self.attn = Attention(
num_heads=self.to_qkv.num_heads,
@@ -299,6 +303,8 @@ class SD3TransformerBlock(nn.Module):
dim=dim,
num_heads=num_attention_heads,
head_dim=attention_head_dim,
added_kv_proj_dim=None,
context_pre_only=True,
out_dim=dim,
qk_norm=True if qk_norm == "rms_norm" else False,
eps=1e-6,
+7 -2
View File
@@ -137,6 +137,11 @@ _DIFFUSION_MODELS = {
"pipeline_flux2",
"Flux2Pipeline",
),
"DreamIDOmniPipeline": (
"dreamid_omni",
"pipeline_dreamid_omni",
"DreamIDOmniPipeline",
),
}
@@ -196,9 +201,9 @@ def initialize_model(
od_config.vae_use_tiling = True
# Configure VAE memory optimization settings from config
if hasattr(model.vae, "use_slicing"):
if hasattr(model, "vae") and hasattr(model.vae, "use_slicing"):
model.vae.use_slicing = od_config.vae_use_slicing
if hasattr(model.vae, "use_tiling"):
if hasattr(model, "vae") and hasattr(model.vae, "use_tiling"):
model.vae.use_tiling = od_config.vae_use_tiling
if is_distributed_vae:
+68 -3
View File
@@ -20,6 +20,7 @@ from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.distributed.omni_connectors.adapter import compute_talker_prompt_ids_length, try_send_via_connector
from vllm_omni.distributed.ray_utils.utils import try_close_ray
from vllm_omni.engine.input_processor import OmniInputProcessor
from vllm_omni.entrypoints.cfg_companion_tracker import CfgCompanionTracker
from vllm_omni.entrypoints.client_request_state import ClientRequestState
from vllm_omni.entrypoints.omni import OmniBase
from vllm_omni.entrypoints.omni_stage import OmniStage
@@ -28,7 +29,7 @@ from vllm_omni.entrypoints.stage_utils import maybe_load_from_ipc as _load
from vllm_omni.entrypoints.utils import (
get_final_stage_id_for_e2e,
)
from vllm_omni.inputs.data import OmniPromptType, OmniSamplingParams
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniPromptType, OmniSamplingParams
# Internal imports (our code)
from vllm_omni.lora.request import LoRARequest
@@ -125,6 +126,9 @@ class AsyncOmni(OmniBase):
# Used to avoid race condition between output_handler and collective_rpc
self._rpc_results: dict[int, dict[str, dict[str, Any]]] = {}
# CFG companion → parent request ID mapping for output routing
self._companion_to_parent: dict[str, str] = {}
super().__init__(model, **kwargs)
# Register weak reference cleanup (called on garbage collection)
@@ -389,6 +393,19 @@ class AsyncOmni(OmniBase):
req_state = ClientRequestState(request_id)
req_state.metrics = metrics
self.request_states[request_id] = req_state
# Ensure modalities is in the prompt dict for CFG expansion
# (offline path includes it; online serving passes it separately)
if isinstance(prompt, dict) and output_modalities and "modalities" not in prompt:
prompt["modalities"] = output_modalities
# CFG companion tracking (prompt expansion + lifecycle management)
cfg = CfgCompanionTracker(
prompt_expand_func=getattr(self.stage_list[0], "prompt_expand_func", None),
stage0_sampling_params=sampling_params_list[0],
)
expanded_companions = cfg.expand_prompts({request_id: prompt})
sp0: SamplingParams = sampling_params_list[0] # type: ignore[index]
task = {
"request_id": request_id,
@@ -396,6 +413,18 @@ class AsyncOmni(OmniBase):
"sampling_params": sp0,
}
self.stage_list[0].submit(task)
# Submit CFG companion requests to stage-0
if cfg.is_active:
for companion_id, companion_prompt in expanded_companions:
self._companion_to_parent[companion_id] = request_id
companion_task = {
"request_id": companion_id,
"engine_inputs": companion_prompt,
"sampling_params": cfg.stage0_sampling_params,
}
self.stage_list[0].submit(companion_task)
metrics.stage_first_ts[0] = metrics.stage_first_ts[0] or time.time()
_req_start_ts[request_id] = time.time()
logger.info(
@@ -421,6 +450,7 @@ class AsyncOmni(OmniBase):
final_stage_id_for_e2e,
sampling_params_list,
prompt,
cfg=cfg,
):
yield output
@@ -440,6 +470,9 @@ class AsyncOmni(OmniBase):
logger.exception(f"[{self._name}] Request {request_id} Failed to finalized/build/log summary: {e}")
finally:
self.request_states.pop(request_id, None)
if cfg.is_active:
for cid in cfg.get_companion_request_ids(request_id).values():
self._companion_to_parent.pop(cid, None)
except (asyncio.CancelledError, GeneratorExit):
await self.abort(request_id)
logger.info("[AsyncOrchestrator] Request %s aborted.", request_id)
@@ -603,12 +636,29 @@ class AsyncOmni(OmniBase):
final_stage_id_for_e2e: int,
sampling_params_list: list[SamplingParams],
prompt: Any,
cfg: CfgCompanionTracker | None = None,
) -> AsyncGenerator[OmniRequestOutput, None]:
for stage_id, stage in enumerate(self.stage_list[: final_stage_id_for_e2e + 1]):
cfg_stage0 = stage_id == 0 and cfg is not None and cfg.is_active
finished = False
while not finished:
while True:
if finished and (
not cfg_stage0 or cfg.all_companions_done(request_id) or cfg.is_parent_failed(request_id)
):
break
result = await req_state.queue.get()
assert stage_id == req_state.stage_id
if cfg is not None and cfg.is_companion(result.get("request_id", "")):
if cfg_stage0:
rid = result.get("request_id")
if "error" in result:
cfg.on_companion_error(rid)
else:
cfg.on_companion_completed(rid)
continue
engine_outputs, finished, output_to_yield = self._process_single_result(
result,
stage,
@@ -629,6 +679,16 @@ class AsyncOmni(OmniBase):
next_inputs = next_stage.process_engine_inputs(self.stage_list, prompt)
sp_next: SamplingParams = sampling_params_list[next_stage_id]
if cfg is not None and cfg.is_active and not cfg.is_parent_failed(request_id):
if isinstance(sp_next, OmniDiffusionSamplingParams):
sp_next = copy.deepcopy(sp_next)
sp_next.cfg_kv_request_ids = cfg.get_companion_request_ids(request_id)
logger.info(
"Attaching cfg_kv_request_ids=%s to request %s",
sp_next.cfg_kv_request_ids,
request_id,
)
# Check if we have a connector for this edge
connector_key = (str(stage_id), str(next_stage_id))
connector = self.connectors.get(connector_key)
@@ -747,6 +807,7 @@ class AsyncOmni(OmniBase):
stage_list = self.stage_list
request_states = self.request_states
companion_to_parent = self._companion_to_parent
async def output_handler():
try:
@@ -773,6 +834,10 @@ class AsyncOmni(OmniBase):
continue
req_id = result.get("request_id")
req_state = request_states.get(req_id)
if req_state is None:
parent_id = companion_to_parent.get(req_id)
if parent_id is not None:
req_state = request_states.get(parent_id)
if req_state is None:
logger.debug(
f"[{self._name}] Request may have been aborted; \
+11 -6
View File
@@ -61,18 +61,23 @@ class OmniDiffusion:
"model_index.json",
od_config.model,
)
if config_dict is not None:
if od_config.model_class_name is None:
od_config.model_class_name = config_dict.get("_class_name", None)
od_config.update_multimodal_support()
if config_dict is None:
raise FileNotFoundError("model_index.json not found")
if od_config.model_class_name is None:
od_config.model_class_name = config_dict.get("_class_name", None)
od_config.update_multimodal_support()
if od_config.model_class_name == "DreamIDOmniPipeline":
od_config.model_config = config_dict
else:
tf_config_dict = get_hf_file_to_dict(
"transformer/config.json",
od_config.model,
)
od_config.tf_model_config = TransformerConfig.from_dict(tf_config_dict)
else:
raise FileNotFoundError("model_index.json not found")
except (AttributeError, OSError, ValueError, FileNotFoundError):
cfg = get_hf_file_to_dict("config.json", od_config.model)
if cfg is None:
+555 -43
View File
@@ -1,5 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import asyncio
import base64
import io
import json
@@ -15,13 +16,14 @@ from argparse import Namespace
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from http import HTTPStatus
from typing import Annotated, Any, cast
from typing import Annotated, Any, Literal, cast
import httpx
import vllm.envs as envs
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, WebSocket
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, WebSocket
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from PIL import Image
from pydantic import BaseModel, Field
from starlette.datastructures import State
from starlette.routing import Route
from vllm import SamplingParams
@@ -82,6 +84,7 @@ from vllm.tool_parsers import ToolParserManager
from vllm.utils.system_utils import decorate_logs
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError
from vllm_omni.entrypoints.openai.image_api_utils import (
encode_image_base64,
parse_size,
@@ -93,19 +96,52 @@ from vllm_omni.entrypoints.openai.protocol.images import (
ImageGenerationResponse,
)
from vllm_omni.entrypoints.openai.protocol.videos import (
SecondStr,
SizeStr,
VideoDeleteResponse,
VideoError,
VideoGenerationRequest,
VideoGenerationResponse,
VideoGenerationStatus,
VideoListResponse,
VideoResponse,
)
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
from vllm_omni.entrypoints.openai.serving_speech_stream import OmniStreamingSpeechHandler
from vllm_omni.entrypoints.openai.serving_video import OmniOpenAIServingVideo
from vllm_omni.entrypoints.openai.serving_video import OmniOpenAIServingVideo, ReferenceImage
from vllm_omni.entrypoints.openai.storage import STORAGE_MANAGER
from vllm_omni.entrypoints.openai.stores import VIDEO_STORE, VIDEO_TASKS
from vllm_omni.entrypoints.openai.video_api_utils import decode_input_reference
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniSamplingParams, OmniTextPrompt
from vllm_omni.lora.request import LoRARequest
from vllm_omni.lora.utils import stable_lora_int_id
logger = init_logger(__name__)
router = APIRouter()
profiler_router = APIRouter()
def _should_enable_profiler_endpoints(args: Namespace) -> bool:
# Check upstream vLLM's profiler_config
profiler_config = getattr(args, "profiler_config", None)
if profiler_config is not None:
# profiler_config exists, check if profiler is set
profiler = getattr(profiler_config, "profiler", None)
if profiler is not None:
return True
# TODO: remove this env after refactoring torch profiler to CLI args
env_value = os.environ.get("VLLM_TORCH_PROFILER_DIR")
return env_value is not None
class ProfileRequest(BaseModel):
"""Request model for profiling endpoints."""
stages: list[int] | None = Field(
default=None,
description="List of stage IDs to profile. If None, profiles all stages.",
)
def _remove_route_from_router(
@@ -235,6 +271,11 @@ async def omni_run_server_worker(listen_address, sock, args, client_config=None,
await omni_init_app_state(engine_client, app.state, args)
# Conditionally register profiler endpoints based on config or env var
if _should_enable_profiler_endpoints(args):
logger.warning("Profiler endpoints are enabled. This should ONLY be used for local development!")
app.include_router(profiler_router)
vllm_config = await engine_client.get_vllm_config()
# Check if pure diffusion mode (vllm_config will be None)
@@ -823,6 +864,20 @@ _remove_route_from_router(router, "/v1/audio/speech", {"POST"})
@with_cancellation
@load_aware_call
async def create_speech(request: OpenAICreateSpeechRequest, raw_request: Request):
"""Generate speech audio from text using the loaded TTS model.
Args:
request: Speech synthesis request in OpenAI-compatible format.
raw_request: Raw FastAPI request for accessing app state.
Returns:
The generated audio response, or an OpenAI-style error payload when
the request cannot be fulfilled.
Raises:
HTTPException: If the server does not support speech generation or the
synthesis request fails unexpectedly.
"""
handler = Omnispeech(raw_request)
if handler is None:
base_server = getattr(raw_request.app.state, "openai_serving_tokenization", None)
@@ -854,13 +909,127 @@ async def create_speech(request: OpenAICreateSpeechRequest, raw_request: Request
},
)
async def list_voices(raw_request: Request):
"""List available TTS voices/speakers from the loaded model."""
"""List available TTS voices exposed by the loaded speech model.
Args:
raw_request: Raw FastAPI request for accessing app state.
Returns:
A JSON payload containing the sorted set of supported speaker names, or
an OpenAI-style error response when the current server configuration
does not support the Speech API.
"""
handler = Omnispeech(raw_request)
if handler is None:
return base(raw_request).create_error_response(message="The model does not support Speech API")
# Get all speakers (both model built-in and uploaded)
speakers = sorted(handler.supported_speakers) if handler.supported_speakers else []
return JSONResponse(content={"voices": speakers})
# Get uploaded speakers details
uploaded_speakers = []
if hasattr(handler, "uploaded_speakers"):
for voice_name, info in handler.uploaded_speakers.items():
uploaded_speakers.append(
{
"name": info.get("name", voice_name),
"consent": info.get("consent", ""),
"created_at": info.get("created_at", 0),
"file_size": info.get("file_size", 0),
"mime_type": info.get("mime_type", ""),
}
)
return JSONResponse(content={"voices": speakers, "uploaded_voices": uploaded_speakers})
@router.post(
"/v1/audio/voices",
responses={
HTTPStatus.OK.value: {"model": dict},
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
},
)
async def upload_voice(
raw_request: Request,
audio_sample: UploadFile = File(...),
consent: str = Form(...),
name: str = Form(...),
):
"""Upload a new voice sample for voice cloning.
Uploads an audio file that can be used as a reference for voice cloning
in Base task TTS requests. The voice can then be referenced by name
in subsequent TTS requests.
Args:
audio_sample: Audio file (max 10MB)
consent: Consent recording ID
name: Name for the new voice
raw_request: Raw FastAPI request
Returns:
JSON response with voice information
"""
handler = Omnispeech(raw_request)
if handler is None:
return base(raw_request).create_error_response(message="The model does not support Speech API")
try:
# Upload the voice
result = await handler.upload_voice(audio_sample, consent, name)
return JSONResponse(content={"success": True, "voice": result})
except ValueError as e:
return base(raw_request).create_error_response(message=str(e))
except Exception as e:
logger.exception(f"Failed to upload voice: {e}")
return base(raw_request).create_error_response(message=f"Failed to upload voice: {str(e)}")
@router.delete(
"/v1/audio/voices/{name}",
responses={
HTTPStatus.OK.value: {"model": dict},
HTTPStatus.NOT_FOUND.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
},
)
async def delete_voice(name: str, raw_request: Request):
"""Delete an uploaded voice.
Deletes the voice sample and associated metadata. Also removes any
cached voice clone prompts for this voice.
Args:
name: Name of the voice to delete
raw_request: Raw FastAPI request
Returns:
JSON response indicating success or failure
"""
handler = Omnispeech(raw_request)
if handler is None:
return base(raw_request).create_error_response(message="The model does not support Speech API")
try:
# Delete the voice
success = await handler.delete_voice(name)
if not success:
return JSONResponse(
content={"success": False, "error": f"Voice '{name}' not found"},
status_code=HTTPStatus.NOT_FOUND.value,
)
return JSONResponse(content={"success": True, "message": f"Voice '{name}' deleted successfully"})
except ValueError as e:
return base(raw_request).create_error_response(message=str(e))
except Exception as e:
logger.exception(f"Failed to delete voice '{name}': {e}")
return base(raw_request).create_error_response(message=f"Failed to delete voice: {str(e)}")
@router.websocket("/v1/audio/speech/stream")
@@ -1397,7 +1566,7 @@ async def _generate_with_async_omni(
return result
def _update_if_not_none(object: any, key: str, val: any) -> None:
def _update_if_not_none(object: Any, key: str, val: Any) -> None:
if val is not None:
setattr(object, key, val)
@@ -1503,7 +1672,7 @@ def _encode_image_base64_with_compression(
def apply_stage_default_sampling_params(
default_params_json: str | None,
sampling_params: any,
sampling_params: Any,
stage_key: str,
) -> None:
"""
@@ -1523,29 +1692,16 @@ def apply_stage_default_sampling_params(
setattr(sampling_params, param_name, param_value)
async def _run_video_generation(
request: VideoGenerationRequest,
raw_request: Request,
*,
input_reference_bytes: bytes | None = None,
) -> VideoGenerationResponse:
handler = Omnivideo(raw_request)
if handler is None:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
detail="Video generation handler not initialized.",
)
logger.info("Video generation handler: %s", type(handler).__name__)
try:
return await handler.generate_videos(request, raw_request, input_reference_bytes=input_reference_bytes)
except HTTPException:
raise
except Exception as e:
logger.exception("Video generation failed: %s", e)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
detail=f"Video generation failed: {str(e)}",
)
def _resolve_video_runtime_context(raw_request: Request) -> tuple[str | None, list[Any] | None]:
app_model_name = None
serving_models = getattr(raw_request.app.state, "openai_serving_models", None)
if serving_models and getattr(serving_models, "base_model_paths", None):
base_paths = serving_models.base_model_paths
if base_paths:
app_model_name = base_paths[0].name
app_stage_configs = getattr(raw_request.app.state, "stage_configs", None)
return app_model_name, app_stage_configs
def _parse_form_json(value: str | None) -> Any:
@@ -1560,10 +1716,97 @@ def _parse_form_json(value: str | None) -> Any:
) from exc
def video_response_from_request(model_name: str, req: VideoGenerationRequest) -> VideoResponse:
resp = VideoResponse(
model=model_name,
status=VideoGenerationStatus.QUEUED,
size=req.size,
prompt=req.prompt,
)
resp.seconds = str(req.seconds or resp.seconds)
return resp
async def decode_and_save_video_output(output: Any, file_name: str) -> str:
if not output.b64_json:
raise RuntimeError(f"Video output for {file_name} did not include b64_json content.")
try:
video_bytes = base64.b64decode(output.b64_json)
except Exception as decode_exc:
raise RuntimeError(f"Failed to decode generated video payload for {file_name}") from decode_exc
return await STORAGE_MANAGER.save(video_bytes, file_name)
def _cleanup_video(video_id: str, output_path: str | None):
try:
if output_path is not None:
os.remove(output_path)
except OSError:
logger.warning("Failed to cleanup partial video file '%s' for id=%s", output_path, video_id)
async def _run_video_generation_job(
handler: OmniOpenAIServingVideo,
request: VideoGenerationRequest,
video_id: str,
reference_image: ReferenceImage | None = None,
) -> None:
job = await VIDEO_STORE.get(video_id)
if job is None:
logger.warning("Video job %s missing before generation task started; skipping", video_id)
return
await VIDEO_STORE.update_fields(video_id, {"status": VideoGenerationStatus.IN_PROGRESS})
started_at = time.perf_counter()
output_path = None
try:
response = await handler.generate_videos(request, video_id, reference_image=reference_image)
if not response.data:
raise RuntimeError("Video generation completed but returned no outputs.")
if (video_count := len(response.data)) > 1:
logger.warning("Video request %s generated %s outputs but we only expected one.", video_id, video_count)
file_name = f"{video_id}.{job.file_extension}"
output_path = await decode_and_save_video_output(response.data[0], file_name)
logger.info("Video request %s persisted %s output file.", video_id, output_path)
await VIDEO_STORE.update_fields(
video_id,
{
"status": VideoGenerationStatus.COMPLETED,
"progress": 100,
"file_name": file_name,
"completed_at": int(time.time()),
"inference_time_s": time.perf_counter() - started_at,
},
)
except Exception as exc:
logger.exception("Video generation failed for id=%s", video_id)
_cleanup_video(video_id, output_path)
# TODO: It would be better to have a finite collection of errors to return rather than the exception name
await VIDEO_STORE.update_fields(
video_id,
{
"status": VideoGenerationStatus.FAILED,
"completed_at": int(time.time()),
"error": VideoError(code=type(exc).__name__, message=str(exc)),
"inference_time_s": time.perf_counter() - started_at,
},
)
except asyncio.CancelledError:
_cleanup_video(video_id, output_path)
await VIDEO_STORE.pop(video_id)
raise
@router.post(
"/v1/videos",
responses={
HTTPStatus.OK.value: {"model": VideoGenerationResponse},
HTTPStatus.OK.value: {"model": VideoResponse},
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
HTTPStatus.SERVICE_UNAVAILABLE.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
@@ -1573,11 +1816,10 @@ async def create_video(
raw_request: Request,
prompt: str = Form(...),
input_reference: UploadFile | None = File(default=None),
image_reference: str | None = Form(default=None),
model: str | None = Form(default=None),
n: int | None = Form(default=None),
seconds: int | None = Form(default=None),
size: str | None = Form(default=None),
response_format: str | None = Form(default=None),
seconds: SecondStr | None = Form(default=None),
size: SizeStr | None = Form(default=None),
user: str | None = Form(default=None),
width: int | None = Form(default=None),
height: int | None = Form(default=None),
@@ -1592,19 +1834,60 @@ async def create_video(
seed: int | None = Form(default=None),
negative_prompt: str | None = Form(default=None),
lora: str | None = Form(default=None),
) -> VideoGenerationResponse:
"""OpenAI-style video create endpoint (multipart form-data)."""
) -> VideoResponse:
"""Create an asynchronous video generation job.
This OpenAI-style endpoint accepts multipart form-data, validates the
request payload, persists a queued job record, and starts generation in the
background. The response contains metadata for polling job status rather
than the generated video bytes.
Args:
raw_request: Raw FastAPI request for accessing app state.
prompt: Text prompt describing the requested video.
input_reference: Optional uploaded reference image file.
image_reference: Optional JSON-encoded reference image descriptor.
model: Optional model name supplied by the client.
seconds: Optional target duration string accepted by the video API.
size: Optional output size string such as ``1280x720``.
user: Optional user identifier forwarded in the stored request.
width: Optional explicit output width override.
height: Optional explicit output height override.
num_frames: Optional explicit frame count override.
fps: Optional explicit frame rate override.
num_inference_steps: Optional inference step override.
guidance_scale: Optional primary guidance scale override.
guidance_scale_2: Optional secondary guidance scale override.
boundary_ratio: Optional boundary ratio override.
flow_shift: Optional flow shift override.
true_cfg_scale: Optional true CFG scale override.
seed: Optional random seed override.
negative_prompt: Optional negative prompt.
lora: Optional JSON-encoded per-request LoRA configuration.
Returns:
A queued ``VideoResponse`` that includes the generated job identifier
and initial metadata for later retrieval.
Raises:
HTTPException: If the request is invalid, the video handler is
unavailable, or job initialization fails.
"""
input_reference_bytes = await input_reference.read() if input_reference is not None else None
parsed_image_reference = _parse_form_json(image_reference)
if parsed_image_reference is not None and input_reference_bytes is not None:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST.value,
detail="Provide either input_reference or image_reference, not both.",
)
request_data: dict[str, Any] = {
"prompt": prompt,
"model": model,
"seconds": seconds,
"size": size,
"image_reference": parsed_image_reference,
"user": user,
"response_format": response_format,
"input_reference": None,
"n": n,
"width": width,
"height": height,
"num_frames": num_frames,
@@ -1619,6 +1902,235 @@ async def create_video(
"negative_prompt": negative_prompt,
"lora": _parse_form_json(lora),
}
request_data = {k: v for k, v in request_data.items() if v is not None}
request = VideoGenerationRequest(**request_data)
return await _run_video_generation(request, raw_request, input_reference_bytes=input_reference_bytes)
handler = Omnivideo(raw_request)
if handler is None:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
detail="Video generation handler not initialized.",
)
logger.info("Video generation handler: %s", type(handler).__name__)
try:
app_model_name, app_stage_configs = _resolve_video_runtime_context(raw_request)
effective_model_name = handler.model_name or app_model_name or request.model or "unknown"
if request.model is not None and effective_model_name is not None and request.model != effective_model_name:
logger.warning(
"Model mismatch: request specifies '%s' but server is running '%s'. Using server model.",
request.model,
effective_model_name,
)
handler.set_stage_configs_if_missing(app_stage_configs)
except HTTPException:
raise
except Exception as e:
logger.exception("Video generation failed: %s", e)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
detail=f"Video generation failed: {str(e)}",
)
ref = video_response_from_request(effective_model_name, request)
try:
image_data = await decode_input_reference(request.image_reference, input_reference_bytes)
except InvalidInputReferenceError as exc:
raise HTTPException(400, detail=str(exc) or "Invalid input reference.") from exc
reference_image = ReferenceImage(data=image_data) if image_data is not None else image_data
await VIDEO_STORE.upsert(ref.id, ref)
task = asyncio.create_task(_run_video_generation_job(handler, request, ref.id, reference_image))
await VIDEO_TASKS.upsert(ref.id, task)
return ref
@router.get("/v1/videos", response_model=VideoListResponse)
async def list_videos(
after: str | None = None,
limit: int | None = Query(None, ge=0, le=100),
order: Annotated[Literal["asc", "desc"], Query()] = "desc",
):
"""List stored video generation jobs.
Args:
after: Optional cursor indicating the last seen video ID.
limit: Optional maximum number of jobs to return.
order: Sort order for the returned jobs by creation time.
Returns:
A ``VideoListResponse`` containing paginated job metadata and cursor
information.
"""
jobs = await VIDEO_STORE.list_values()
jobs.sort(key=lambda j: j.created_at, reverse=order == "desc")
if after is not None:
idx = next((i for i, job in enumerate(jobs) if job.id == after), None)
jobs = [] if idx is None else jobs[idx + 1 :]
has_more = False
if limit is not None:
has_more = len(jobs) > limit
jobs = jobs[:limit]
first_id, last_id = None, None
if len(jobs) > 0:
first_id = jobs[0].id
last_id = jobs[-1].id
return VideoListResponse(data=jobs, has_more=has_more, first_id=first_id, last_id=last_id)
@router.get("/v1/videos/{video_id}")
async def retrieve_video(video_id: str) -> VideoResponse:
"""Retrieve metadata for a previously created video job.
Args:
video_id: Identifier returned by ``POST /v1/videos``.
Returns:
The stored ``VideoResponse`` for the requested job.
Raises:
HTTPException: If the video job does not exist.
"""
job = await VIDEO_STORE.get(video_id)
if job is None:
raise HTTPException(status_code=404, detail="Video not found")
return job
@router.delete("/v1/videos/{video_id}")
async def delete_video(video_id: str) -> VideoDeleteResponse:
"""Delete a stored video job and any generated output.
If the job is still queued or running, this endpoint first attempts to
cancel the in-flight generation task before removing the stored metadata.
Args:
video_id: Identifier of the video job to delete.
Returns:
A ``VideoDeleteResponse`` confirming the job was removed.
Raises:
HTTPException: If the video job does not exist, cancellation is still
in progress, or output is not yet ready for a completed job.
"""
job = await VIDEO_STORE.get(video_id)
if job is None:
raise HTTPException(status_code=404, detail="Video not found")
if job.status in (VideoGenerationStatus.QUEUED, VideoGenerationStatus.IN_PROGRESS):
task = await VIDEO_TASKS.get(video_id)
if task is not None:
task.cancel()
try:
await asyncio.wait_for(task, timeout=2.0)
except asyncio.TimeoutError:
raise HTTPException(status_code=409, detail="Cancellation in progress. Please try again later.")
except asyncio.CancelledError:
pass
await VIDEO_STORE.pop(video_id)
return VideoDeleteResponse(id=job.id, deleted=True)
elif job.status is VideoGenerationStatus.FAILED:
if job.file_name is not None:
try:
await STORAGE_MANAGER.delete(job.file_name)
except Exception:
logger.warning("Failed to delete stored artifact for failed video job %s", video_id, exc_info=True)
await VIDEO_STORE.pop(video_id)
return VideoDeleteResponse(id=job.id, deleted=True)
if job.file_name is None:
raise HTTPException(status_code=409, detail="Video output not yet available. Please try again later.")
await STORAGE_MANAGER.delete(job.file_name)
await VIDEO_STORE.pop(video_id)
return VideoDeleteResponse(id=job.id, deleted=True)
@router.get("/v1/videos/{video_id}/content")
async def download_video(video_id: str) -> FileResponse:
"""Download the generated file for a completed video job.
Args:
video_id: Identifier of the video job whose output should be returned.
Returns:
A ``FileResponse`` streaming the generated video file from local
storage.
Raises:
HTTPException: If the job does not exist, is still in progress, or the
generated file is missing from disk.
"""
job = await VIDEO_STORE.get(video_id)
if job is None:
raise HTTPException(status_code=404, detail="Video not found")
if job.status == VideoGenerationStatus.FAILED:
raise HTTPException(status_code=422, detail="Video generation failed. Check job status for error details.")
if not job.file_name:
raise HTTPException(status_code=404, detail="Generation is still in-progress")
full_path = STORAGE_MANAGER.get_full_file_path(job.file_name)
if not os.path.exists(full_path):
raise HTTPException(status_code=404, detail="Generated video file not found on disk")
return FileResponse(path=full_path, media_type=job.media_type, filename=job.file_name)
@profiler_router.post("/start_profile")
async def start_profile(raw_request: Request, request: ProfileRequest | None = None):
"""Start profiling for the engine.
Args:
request: Optional request body with stages to profile.
- stages: List of stage IDs to profile. If None, profiles all stages.
Example:
POST /start_profile
{"stages": [0, 1]} # Profile only stages 0 and 1
"""
try:
stages = request.stages if request else None
logger.info("Starting profiler for stages: %s", stages if stages else "all")
engine_client = raw_request.app.state.engine_client
result = await engine_client.start_profile(stages=stages)
logger.info("Profiler started.")
return JSONResponse(content=result)
except Exception as e:
logger.exception("Failed to start profiler: %s", e)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=f"Failed to start profiler: {str(e)}"
)
@profiler_router.post("/stop_profile")
async def stop_profile(raw_request: Request, request: ProfileRequest | None = None):
"""Stop profiling for the engine.
Args:
request: Optional request body with stages to stop profiling.
- stages: List of stage IDs to stop profiling. If None, stops all stages.
Example:
POST /stop_profile
{"stages": [0, 1]} # Stop profiling only stages 0 and 1
"""
try:
stages = request.stages if request else None
logger.info("Stopping profiler for stages: %s", stages if stages else "all")
engine_client = raw_request.app.state.engine_client
result = await engine_client.stop_profile(stages=stages)
logger.info("Profiler stopped.")
return JSONResponse(content=result)
except Exception as e:
logger.exception("Failed to stop profiler: %s", e)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=f"Failed to stop profiler: {str(e)}"
)
+3
View File
@@ -0,0 +1,3 @@
class InvalidInputReferenceError(ValueError):
def __init__(self, message: str = "Invalid input reference.") -> None:
super().__init__(message)
@@ -12,7 +12,6 @@ from vllm_omni.entrypoints.openai.protocol.videos import (
VideoData,
VideoGenerationRequest,
VideoGenerationResponse,
VideoResponseFormat,
)
__all__ = [
@@ -23,6 +22,5 @@ __all__ = [
"VideoData",
"VideoGenerationRequest",
"VideoGenerationResponse",
"VideoResponseFormat",
"OmniChatCompletionStreamResponse",
]
+147 -47
View File
@@ -8,17 +8,40 @@ mirrors the OpenAI Images API shape, with vllm-omni extensions for diffusion
video models (e.g., Wan2.2).
"""
import mimetypes
import time
import uuid
from enum import Enum
from typing import Any
from functools import lru_cache
from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
from vllm_omni.entrypoints.openai.image_api_utils import parse_size
class VideoResponseFormat(str, Enum):
"""Video response format."""
class VideoGenerationStatus(str, Enum):
QUEUED = "queued"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
FAILED = "failed"
B64_JSON = "b64_json"
URL = "url" # Not implemented in PoC
SizeStr = Annotated[str, StringConstraints(pattern=r"^\d+x\d+$")]
SecondStr = Annotated[str, StringConstraints(pattern=r"^[1-9]\d*$")]
DEFAULT_FPS = 24
@lru_cache
def file_extension(media_type: str):
media_type = str(media_type).split(";", 1)[0].strip().lower()
ext = mimetypes.guess_extension(media_type, strict=False)
if ext is None:
raise ValueError(f"No recognized file extension for media_type {media_type}")
# Keep naming stable for unknown/unsupported MIME types.
return ext.lstrip(".")
class VideoParams(BaseModel):
@@ -29,6 +52,26 @@ class VideoParams(BaseModel):
num_frames: int | None = Field(default=None, ge=1, description="Number of frames")
fps: int | None = Field(default=None, ge=1, description="Frames per second for output video")
@property
def size(self) -> str | None:
if self.width and self.height:
return f"{self.width}x{self.height}"
else:
return None
class FileImageReference(BaseModel):
model_config = ConfigDict(extra="forbid")
file_id: str
class UrlImageReference(BaseModel):
model_config = ConfigDict(extra="forbid")
image_url: str
ImageReference = UrlImageReference | FileImageReference
class VideoGenerationRequest(BaseModel):
"""
@@ -37,42 +80,37 @@ class VideoGenerationRequest(BaseModel):
Follows the OpenAI Images API conventions with extensions for video.
"""
prompt: str = Field(..., description="Text description of the desired video(s)")
# OpenAI standard fields
model: str | None = Field(
default=None,
description="Model to use (optional, uses server's configured model if omitted)",
)
n: int = Field(default=1, ge=1, le=4, description="Number of videos to generate")
seconds: int | str | None = Field(
prompt: str = Field(..., description="Text description of the desired video(s)")
seconds: SecondStr | None = Field(
default=None,
description="Clip duration in seconds (OpenAI-compatible, e.g., 4, 8, 12)",
)
size: str | None = Field(
size: SizeStr | None = Field(
default=None,
description="Video dimensions in WIDTHxHEIGHT format (e.g., '1280x720')",
)
response_format: VideoResponseFormat = Field(
default=VideoResponseFormat.B64_JSON, description="Format of the returned video"
image_reference: ImageReference | None = Field(
default=None,
description="Optional JSON-safe image reference that guides generation. Provide either image_url or file_id.",
)
user: str | None = Field(default=None, description="User identifier for tracking")
stream: bool | None = Field(default=False, description="Whether to stream outputs (not yet supported)")
# Video params block for extensibility
video_params: VideoParams | None = Field(default=None, description="Optional video-specific parameters")
# Image-to-video input (OpenAI-style name)
input_reference: str | None = Field(
default=None,
description="Image input for I2V; provided via multipart form-data as a file upload.",
)
# User specific tracking field
user: str | None = Field(default=None, description="User identifier for tracking")
# Video-specific fields (top-level for OpenAI-style compatibility)
width: int | None = Field(default=None, ge=1, description="Video width in pixels")
height: int | None = Field(default=None, ge=1, description="Video height in pixels")
num_frames: int | None = Field(default=None, ge=1, description="Number of frames to generate")
fps: int | None = Field(default=None, ge=1, description="Frames per second for output video")
num_frames: int | None = Field(default=None, ge=1, description="Number of frames to generate")
# vllm-omni extensions for diffusion control
negative_prompt: str | None = Field(default=None, description="Text describing what to avoid in the video")
@@ -123,34 +161,25 @@ class VideoGenerationRequest(BaseModel):
),
)
@field_validator("size")
@classmethod
def validate_size(cls, v):
if v is None:
return None
if not isinstance(v, str) or "x" not in v:
raise ValueError("size must be in format 'WIDTHxHEIGHT' (e.g., '1280x720')")
return v
def resolve_video_params(self) -> VideoParams:
vp = VideoParams(width=self.width, height=self.height, fps=self.fps, num_frames=self.num_frames)
@field_validator("response_format")
@classmethod
def validate_response_format(cls, v):
if v is not None and v != VideoResponseFormat.B64_JSON:
raise ValueError(f"Only 'b64_json' response format is supported, got: {v}")
return v
if self.video_params is not None:
vp.width = vp.width or self.video_params.width
vp.height = vp.height or self.video_params.height
vp.fps = vp.fps or self.video_params.fps
vp.num_frames = vp.num_frames or self.video_params.num_frames
@field_validator("seconds")
@classmethod
def validate_seconds(cls, v):
if v is None:
return None
if isinstance(v, str):
if not v.isdigit():
raise ValueError("seconds must be an integer or numeric string")
return int(v)
if isinstance(v, int):
return v
raise ValueError("seconds must be an integer or numeric string")
if self.size:
vp.width, vp.height = parse_size(self.size)
if vp.fps is None:
vp.fps = DEFAULT_FPS
if vp.num_frames is None and self.seconds is not None:
vp.num_frames = int(self.seconds) * int(vp.fps)
return vp
class VideoData(BaseModel):
@@ -166,3 +195,74 @@ class VideoGenerationResponse(BaseModel):
created: int = Field(..., description="Unix timestamp of when the generation completed")
data: list[VideoData] = Field(..., description="Array of generated videos")
class VideoError(BaseModel):
code: str = Field(..., description="A machine-readable error code that was returned.")
message: str = Field(..., description="A human-readable description of the error that was returned.")
class VideoResponse(BaseModel):
"""Stored metadata for an async video generation job."""
# OpenAI standard fields
model: str = Field(..., description="Model name used for video generation.")
prompt: str = Field(..., description="The prompt that was used to generate the video.")
id: str = Field(
default_factory=lambda: f"video_gen_{uuid.uuid4().hex}", description="Unique id for a video request"
)
object: Literal["video"] = Field(default="video", description="Object type identifier.")
status: VideoGenerationStatus = Field(
default=VideoGenerationStatus.QUEUED, description="Current lifecycle status of the video job."
)
size: SizeStr | None = Field(default=None, description="Requested output size in WIDTHxHEIGHT format.")
progress: int = Field(default=0, description="Best-effort progress indicator from 0 to 100.")
seconds: SecondStr = Field(default="4", description="Requested clip length in seconds.")
quality: str = Field(default="default", description="Requested quality level for generation.")
completed_at: int | None = Field(
default=None, description="Unix timestamp (seconds) for when the job completed, if finished."
)
created_at: int = Field(
default_factory=lambda: int(time.time()),
description="Unix timestamp (seconds) for when the job was created.",
)
remixed_from_video_id: str | None = Field(
default=None,
description="Optional source video id for remix/edit flows.",
)
error: VideoError | None = Field(
default=None,
description="Error payload that explains why generation failed, if applicable.",
)
# vLLM specific fields
media_type: Literal["video/mp4"] = Field(default="video/mp4", description="MIME type of the generated artifact.")
expires_at: int | None = Field(default=None, description="Unix timestamp when this record is considered expired.")
file_name: str | None = Field(
default=None,
description="Filename of the saved output video files for this job.",
)
inference_time_s: float | None = Field(default=None, description="End-to-end inference time in seconds.")
@property
def file_extension(self) -> str:
return file_extension(self.media_type)
class VideoDeleteResponse(BaseModel):
id: str = Field(description="Identifier of the deleted video.")
deleted: bool = Field(description="Indicates that the video resource was deleted.")
object: Literal["video.deleted"] = Field(
default="video.deleted", description="The object type that signals the deletion response."
)
class VideoListResponse(BaseModel):
"""Paginated-style wrapper for listing stored video jobs."""
first_id: str | None = Field(..., description="The ID of the first item in the list.")
last_id: str | None = Field(..., description="The ID of the last item in the list.")
has_more: bool = Field(..., description="Whether there are more items available.")
data: list[VideoResponse] = Field(..., description="Array of video job records.")
object: Literal["list"] = Field(default="list", description="Object type identifier for list responses.")
+10 -5
View File
@@ -311,17 +311,21 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
negative_prompt = extra_body.get("negative_prompt")
engine_prompt_image: dict[str, Any] | None = None
is_img2img = False
if reference_images:
# Best-effort decode first reference image for i2i.
try:
img_bytes = base64.b64decode(reference_images[0])
img = Image.open(BytesIO(img_bytes))
engine_prompt_image = {"image": img}
engine_prompt_image = {"img2img": img}
is_img2img = True
except Exception:
engine_prompt_image = None
# Override the prompts produced by chat-template preprocessing.
tprompt: OmniTextPrompt = {"prompt": extracted_prompt}
if is_img2img:
tprompt["modalities"] = ["img2img"]
if negative_prompt is not None:
tprompt["negative_prompt"] = negative_prompt
# GLM-Image's _call_hf_processor expects target_h/target_w in mm_processor_kwargs
@@ -490,10 +494,11 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
)
# Preserve a clean text prompt for downstream stages (e.g., GLM-Image diffusion).
# For /v1/chat/completions, `request_prompt` is often the rendered chat template.
# Diffusion models generally want the raw user caption instead.
output_modalities = getattr(self.engine_client, "output_modalities", None)
if output_modalities and ("image" in output_modalities):
# For image generation, we want the raw user caption instead of a rendered template.
# But for multimodal comprehension (img2text), we MUST keep the rendered prompt
# containing image tokens.
req_modalities = getattr(request, "modalities", [])
if req_modalities and ("image" in req_modalities):
messages_as_dicts: list[dict[str, Any]] = []
for msg in messages:
if hasattr(msg, "model_dump"):
+42 -87
View File
@@ -4,23 +4,22 @@
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass
from http import HTTPStatus
from typing import Any, cast
from fastapi import HTTPException, Request
from fastapi import HTTPException
from PIL import Image
from vllm.engine.protocol import EngineClient
from vllm.logger import init_logger
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.entrypoints.openai.image_api_utils import parse_size
from vllm_omni.entrypoints.openai.protocol.videos import (
VideoData,
VideoGenerationRequest,
VideoGenerationResponse,
)
from vllm_omni.entrypoints.openai.video_api_utils import decode_input_reference, encode_video_base64
from vllm_omni.entrypoints.openai.video_api_utils import encode_video_base64
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniSamplingParams, OmniTextPrompt
from vllm_omni.lora.request import LoRARequest
from vllm_omni.lora.utils import stable_lora_int_id
@@ -28,6 +27,13 @@ from vllm_omni.lora.utils import stable_lora_int_id
logger = init_logger(__name__)
@dataclass
class ReferenceImage:
"""Reference class for tracking additional metadata if needed"""
data: Image.Image
class OmniOpenAIServingVideo:
"""OpenAI-style video generation handler for omni diffusion models."""
@@ -41,6 +47,18 @@ class OmniOpenAIServingVideo:
self._model_name = model_name
self._stage_configs = stage_configs
@property
def model_name(self) -> str | None:
return self._model_name
@property
def stage_configs(self) -> list[Any] | None:
return self._stage_configs
def set_stage_configs_if_missing(self, stage_configs: list[Any] | None) -> None:
if self._stage_configs is None and stage_configs is not None:
self._stage_configs = stage_configs
@classmethod
def for_diffusion(
cls,
@@ -57,57 +75,32 @@ class OmniOpenAIServingVideo:
async def generate_videos(
self,
request: VideoGenerationRequest,
raw_request: Request | None = None,
reference_id: str,
*,
input_reference_bytes: bytes | None = None,
reference_image: ReferenceImage | None = None,
) -> VideoGenerationResponse:
if request.stream:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST.value,
detail="Streaming video generation is not supported yet.",
)
request_id = f"video_gen_{uuid.uuid4().hex}"
model_name = self._resolve_model_name(raw_request)
if request.model is not None and model_name is not None and request.model != model_name:
logger.warning(
"Model mismatch: request specifies '%s' but server is running '%s'. Using server model.",
request.model,
model_name,
)
prompt: OmniTextPrompt = {"prompt": request.prompt}
prompt: OmniTextPrompt = OmniTextPrompt(prompt=request.prompt)
if request.negative_prompt is not None:
prompt["negative_prompt"] = request.negative_prompt
input_image = None
try:
input_image = decode_input_reference(request.input_reference, input_reference_bytes)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST.value,
detail=str(exc),
) from exc
gen_params = OmniDiffusionSamplingParams()
gen_params = OmniDiffusionSamplingParams(num_outputs_per_prompt=request.n)
width, height, num_frames, fps = self._resolve_video_params(request)
if input_image is not None and width is not None and height is not None:
target_size = (width, height)
input_image = None if reference_image is None else reference_image.data
vp = request.resolve_video_params()
if input_image is not None and vp.width is not None and vp.height is not None:
target_size = (vp.width, vp.height)
if input_image.size != target_size:
input_image = input_image.resize(target_size, Image.Resampling.LANCZOS)
if input_image is not None:
prompt["multi_modal_data"] = {"image": input_image}
if width is not None and height is not None:
gen_params.width = width
gen_params.height = height
if num_frames is not None:
gen_params.num_frames = num_frames
if fps is not None:
gen_params.fps = fps
gen_params.frame_rate = float(fps)
if vp.width is not None and vp.height is not None:
gen_params.width = vp.width
gen_params.height = vp.height
if vp.num_frames is not None:
gen_params.num_frames = vp.num_frames
if vp.fps is not None:
gen_params.fps = vp.fps
gen_params.frame_rate = float(vp.fps)
if request.num_inference_steps is not None:
gen_params.num_inference_steps = request.num_inference_steps
@@ -140,12 +133,12 @@ class OmniOpenAIServingVideo:
gen_params.seed,
)
result = await self._run_generation(prompt, gen_params, request_id, raw_request)
result = await self._run_generation(prompt, gen_params, reference_id)
_t_encode_start = time.perf_counter()
videos = self._extract_video_outputs(result)
audios = self._extract_audio_outputs(result, expected_count=len(videos))
output_fps = fps or 24
audio_sample_rate = self._resolve_audio_sample_rate(result)
output_fps = vp.fps or 24
video_data = [
VideoData(
@@ -166,37 +159,6 @@ class OmniOpenAIServingVideo:
logger.info("Video response encoding (MP4+base64): %.2f ms", _t_encode_ms)
return VideoGenerationResponse(created=int(time.time()), data=video_data)
def _resolve_model_name(self, raw_request: Request | None) -> str | None:
if self._model_name:
return self._model_name
if raw_request is None:
return None
serving_models = getattr(raw_request.app.state, "openai_serving_models", None)
if serving_models and getattr(serving_models, "base_model_paths", None):
base_paths = serving_models.base_model_paths
if base_paths:
return base_paths[0].name
return None
@staticmethod
def _resolve_video_params(request: VideoGenerationRequest) -> tuple[int | None, int | None, int | None, int | None]:
width = request.width or (request.video_params.width if request.video_params else None)
height = request.height or (request.video_params.height if request.video_params else None)
num_frames = request.num_frames or (request.video_params.num_frames if request.video_params else None)
fps = request.fps or (request.video_params.fps if request.video_params else None)
seconds = request.seconds
if request.size:
width, height = parse_size(request.size)
if fps is None:
fps = 24 # Default FPS if not specified
if num_frames is None and seconds is not None:
num_frames = int(seconds) * int(fps)
return width, height, num_frames, fps
@staticmethod
def _apply_lora(lora_body: Any, gen_params: OmniDiffusionSamplingParams) -> None:
if lora_body is None:
@@ -237,22 +199,15 @@ class OmniOpenAIServingVideo:
prompt: OmniTextPrompt,
gen_params: OmniDiffusionSamplingParams,
request_id: str,
raw_request: Request | None,
) -> Any:
has_stage_list = hasattr(self._engine_client, "stage_list")
logger.info(
"Video generation routing: stage_configs=%s, has_stage_list=%s, engine_type=%s",
"present"
if (self._stage_configs or (getattr(raw_request.app.state, "stage_configs", None) if raw_request else None))
else "missing",
"present" if self._stage_configs else "missing",
has_stage_list,
type(self._engine_client).__name__,
)
stage_configs = (
self._stage_configs
or (getattr(raw_request.app.state, "stage_configs", None) if raw_request else None)
or getattr(self._engine_client, "stage_configs", None)
)
stage_configs = self._stage_configs or getattr(self._engine_client, "stage_configs", None)
if not stage_configs:
if not hasattr(self._engine_client, "stage_list"):
+70
View File
@@ -0,0 +1,70 @@
import asyncio
import os
from abc import ABC, abstractmethod
from tempfile import NamedTemporaryFile
class StorageBaseManager(ABC):
@abstractmethod
async def save(self, *args, **kwargs) -> str:
pass
@abstractmethod
async def delete(self, *args, **kwargs) -> bool:
pass
class LocalStorageManager(StorageBaseManager):
def __init__(self, storage_path: str | None = None):
if storage_path is None:
storage_path = os.getenv("VLLM_OMNI_STORAGE_PATH", "/tmp/storage")
self.storage_path = storage_path
os.makedirs(self.storage_path, exist_ok=True)
max_concurrency = int(os.getenv("VLLM_OMNI_STORAGE_MAX_CONCURRENCY", "4"))
self._io_semaphore = asyncio.Semaphore(max(1, max_concurrency))
def _save_sync(self, data: bytes, file_name: str) -> str:
filename = self.get_full_file_path(file_name)
tmp_name: str | None = None
try:
with NamedTemporaryFile("wb", dir=self.storage_path, delete=False) as f:
tmp_name = f.name
f.write(data)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_name, filename)
return filename
except Exception:
if tmp_name is not None:
try:
os.remove(tmp_name)
except OSError:
pass
raise
async def save(self, data: bytes, file_name: str) -> str:
async with self._io_semaphore:
return await asyncio.to_thread(self._save_sync, data, file_name)
def _delete_sync(self, file_name: str) -> bool:
try:
os.remove(self.get_full_file_path(file_name))
except FileNotFoundError:
return False
return True
async def delete(self, file_name: str) -> bool:
async with self._io_semaphore:
return await asyncio.to_thread(self._delete_sync, file_name)
def exists(self, file_name: str) -> bool:
return os.path.exists(self.get_full_file_path(file_name))
def get_full_file_path(self, file_name: str) -> str:
return os.path.join(self.storage_path, file_name)
# Should implement TTL management on local file storage
# and recovery on restart in the *STORE's
STORAGE_MANAGER = LocalStorageManager()
+74
View File
@@ -0,0 +1,74 @@
import asyncio
from typing import Any, Generic, TypeVar
from pydantic import BaseModel
from vllm_omni.entrypoints.openai.protocol.videos import VideoResponse
T = TypeVar("T", bound=BaseModel)
U = TypeVar("U")
class TaskRegistry:
def __init__(self) -> None:
self._tasks: dict[str, asyncio.Task[None]] = {}
self._lock = asyncio.Lock()
async def get(self, key: str) -> asyncio.Task[None] | None:
async with self._lock:
return self._tasks.get(key)
async def pop(self, key: str) -> asyncio.Task[None] | None:
async with self._lock:
return self._tasks.pop(key, None)
async def upsert(self, key: str, task: asyncio.Task[None]) -> None:
def _cleanup(_: asyncio.Task[None]) -> None:
asyncio.create_task(self.pop(key))
task.add_done_callback(_cleanup)
async with self._lock:
self._tasks[key] = task
class AsyncDictStore(Generic[T]):
"""A small async-safe in-memory key-value store for dict items.
This encapsulates the usual pattern of a module-level dict guarded by
an asyncio.Lock and provides simple CRUD methods that are safe to call
concurrently from FastAPI request handlers and background tasks.
"""
def __init__(self) -> None:
self._items: dict[str, T] = {}
self._lock = asyncio.Lock()
async def upsert(self, key: str, value: T) -> None:
async with self._lock:
self._items[key] = value
async def update_fields(self, key: str, updates: dict[str, Any]) -> T | None:
async with self._lock:
item: T | None = self._items.get(key)
if item is None:
return None
new_item = item.model_copy(update=updates)
self._items[key] = new_item
return new_item
async def get(self, key: str) -> T | None:
async with self._lock:
return self._items.get(key)
async def pop(self, key: str) -> T | None:
async with self._lock:
return self._items.pop(key, None)
async def list_values(self) -> list[T]:
async with self._lock:
return list(self._items.values())
VIDEO_STORE: AsyncDictStore[VideoResponse] = AsyncDictStore()
VIDEO_TASKS: TaskRegistry = TaskRegistry()
+60 -10
View File
@@ -7,32 +7,82 @@ Shared helper utilities for OpenAI-compatible video generation API.
from __future__ import annotations
import base64
import binascii
import os
import tempfile
from io import BytesIO
from typing import Any
import httpx
import numpy as np
import torch
from PIL import Image
from PIL import Image, UnidentifiedImageError
from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError
from vllm_omni.entrypoints.openai.protocol.videos import (
FileImageReference,
ImageReference,
UrlImageReference,
)
def decode_input_reference(input_reference: str | None, input_reference_bytes: bytes | None) -> Image.Image | None:
"""Decode image input from multipart bytes or base64/data URL."""
if input_reference and input_reference_bytes:
raise ValueError("Provide input_reference either as file upload or base64, not both.")
if input_reference_bytes:
return Image.open(BytesIO(input_reference_bytes)).convert("RGB")
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_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 Exception as exc: # pragma: no cover - malformed base64
raise ValueError("Invalid base64 input_reference.") from exc
return Image.open(BytesIO(image_bytes)).convert("RGB")
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.")
async def decode_input_reference(
image_reference: ImageReference | None,
input_reference_bytes: bytes | None,
) -> Image.Image | None:
"""Decode image input from multipart bytes, base64/data URL, or image_reference."""
if input_reference_bytes is not None and image_reference is not None:
raise InvalidInputReferenceError("Provide either input_reference or image_reference, not both.")
if isinstance(input_reference_bytes, bytes):
return _decode_image_bytes(input_reference_bytes, source="input_reference")
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.")
return None
@@ -202,6 +202,15 @@ class OmniBagelDataParser(MultiModalDataParser):
class OmniBagelMultiModalProcessor(BaseMultiModalProcessor[OmniBagelProcessingInfo]):
IMG2IMG_PLACEHOLDER = "<|fim_middle|>"
def _cached_apply_hf_processor(self, inputs, timing_ctx):
# img2img: prompt text must be modified based on mm data presence,
# so text and mm data cannot be tokenized separately — bypass cache.
if inputs.mm_data_items.get_all_counts().get("img2img", 0) > 0:
return self._apply_hf_processor(inputs, timing_ctx)
return super()._cached_apply_hf_processor(inputs, timing_ctx)
def _get_mm_fields_config(self, hf_inputs, hf_processor_mm_kwargs):
return {
"pixel_values": MultiModalFieldConfig.batched("image"),
@@ -218,6 +227,9 @@ class OmniBagelMultiModalProcessor(BaseMultiModalProcessor[OmniBagelProcessingIn
has_image = "images" in mm_data
has_img2img = "pixel_values_img2img" in mm_data
if has_img2img and self.IMG2IMG_PLACEHOLDER not in prompt:
prompt = f"{self.IMG2IMG_PLACEHOLDER}{prompt}"
if has_image and has_img2img:
outputs = BatchFeature()
@@ -78,7 +78,11 @@ from vllm.multimodal.inputs import (
MultiModalFieldConfig,
MultiModalKwargsItems,
)
from vllm.multimodal.parse import ImageProcessorItems, MultiModalDataItems
from vllm.multimodal.parse import (
ImageProcessorItems,
MultiModalDataItems,
MultiModalDataParser,
)
from vllm.multimodal.processing import (
BaseDummyInputsBuilder,
BaseMultiModalProcessor,
@@ -115,6 +119,15 @@ class GlmImagePixelInputs(TensorSchema):
image_grid_thw: Annotated[torch.Tensor, TensorShape("ni", 3)]
class GlmImageDataParser(MultiModalDataParser):
"""GLM-Image treats ``img2img`` input identically to ``image``."""
def _get_subparsers(self):
parsers = super()._get_subparsers()
parsers["img2img"] = self._parse_image_data
return parsers
class GlmImageProcessingInfo(BaseProcessingInfo):
"""
Processing information for GLM-Image model.
@@ -162,14 +175,19 @@ class GlmImageProcessingInfo(BaseProcessingInfo):
**kwargs,
)
def get_data_parser(self) -> GlmImageDataParser:
return GlmImageDataParser(
expected_hidden_size=self._get_expected_hidden_size(),
)
def get_supported_mm_limits(self) -> Mapping[str, int | None]:
# GLM-Image is an image GENERATION model that supports:
# - Text-to-image (t2i): no multimodal input needed
# - Image-to-image (i2i): source images provided as input
#
# For i2i mode, we support up to 1 image as condition.
# The model architecture supports multiple images but typical usage is 1.
return {"image": 1}
# "img2img" is an alias used by the serving layer; parsed as "image".
return {"image": 1, "img2img": 1}
def get_num_image_tokens(
self,