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>
261 lines
9.0 KiB
Python
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
|