Files
vllm-omni/tests/e2e/online_serving/test_video_generation_api.py
T
92f771d455 [Frontend] Rewrite video API for async job lifecycle (#1665)
Signed-off-by: Ian Eaves <ian.k.eaves@gmail.com>
Signed-off-by: Samit <285365963@qq.com>
Co-authored-by: Samit <285365963@qq.com>
Co-authored-by: Gao Han <gaohan19@huawei.com>
2026-03-13 17:11:06 +08:00

261 lines
9.0 KiB
Python

# 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