Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8affabd527 | ||
|
|
7cb7b3e8ad | ||
|
|
de1ac5ee88 | ||
|
|
e68ca4aa5c | ||
|
|
0cbd1cbeb8 | ||
|
|
c1a978ae8d | ||
|
|
1734c1ec23 | ||
|
|
12a8f57934 |
No files matched your search
@@ -355,6 +355,48 @@ steps:
|
||||
path: /mnt/hf-cache
|
||||
type: DirectoryOrCreate
|
||||
|
||||
- label: ":full_moon: Wan22 I2V Accuracy Test with H100"
|
||||
key: nightly-wan22-i2v-accuracy
|
||||
timeout_in_minutes: 180
|
||||
depends_on: upload-nightly-pipeline
|
||||
if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"
|
||||
commands:
|
||||
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
- pytest -s -v tests/e2e/accuracy/wan22_i2v/test_wan22_i2v_video_similarity.py --run-level advanced_model
|
||||
agents:
|
||||
queue: "mithril-h100-pool"
|
||||
plugins:
|
||||
- kubernetes:
|
||||
podSpec:
|
||||
containers:
|
||||
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:c392ce21e9cf9ea65c52b866447793db10e0261c
|
||||
resources:
|
||||
limits:
|
||||
nvidia.com/gpu: 2
|
||||
volumeMounts:
|
||||
- name: devshm
|
||||
mountPath: /dev/shm
|
||||
- name: hf-cache
|
||||
mountPath: /root/.cache/huggingface
|
||||
env:
|
||||
- name: HF_HOME
|
||||
value: /root/.cache/huggingface
|
||||
- name: HF_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: hf-token-secret
|
||||
key: token
|
||||
nodeSelector:
|
||||
node.kubernetes.io/instance-type: gpu-h100-sxm
|
||||
volumes:
|
||||
- name: devshm
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
- name: hf-cache
|
||||
hostPath:
|
||||
path: /mnt/hf-cache
|
||||
type: DirectoryOrCreate
|
||||
|
||||
- label: ":full_moon: Qwen-Image Diffusion Perf Test with H100"
|
||||
key: nightly-qwen-image-performance
|
||||
timeout_in_minutes: 300
|
||||
|
||||
+36
-38
@@ -216,44 +216,42 @@ steps:
|
||||
volumes:
|
||||
- "/fsx/hf_cache:/fsx/hf_cache"
|
||||
|
||||
# - label: "Omni Model Test with H100"
|
||||
# depends_on: upload-ready-pipeline
|
||||
# commands:
|
||||
# - |
|
||||
# timeout 20m bash -c '
|
||||
# export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
# export VLLM_TEST_CLEAN_GPU_MEMORY="1"
|
||||
# # - pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py
|
||||
# pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -m "core_model" --run-level "core_model"
|
||||
# '
|
||||
# agents:
|
||||
# queue: "mithril-h100-pool"
|
||||
# plugins:
|
||||
# - kubernetes:
|
||||
# podSpec:
|
||||
# containers:
|
||||
# - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
# resources:
|
||||
# limits:
|
||||
# nvidia.com/gpu: 2
|
||||
# volumeMounts:
|
||||
# - name: devshm
|
||||
# mountPath: /dev/shm
|
||||
# - name: hf-cache
|
||||
# mountPath: /root/.cache/huggingface
|
||||
# env:
|
||||
# - name: HF_HOME
|
||||
# value: /root/.cache/huggingface
|
||||
# nodeSelector:
|
||||
# node.kubernetes.io/instance-type: gpu-h100-sxm
|
||||
# volumes:
|
||||
# - name: devshm
|
||||
# emptyDir:
|
||||
# medium: Memory
|
||||
# - name: hf-cache
|
||||
# hostPath:
|
||||
# path: /mnt/hf-cache
|
||||
# type: DirectoryOrCreate
|
||||
- label: "Omni Model Test with H100"
|
||||
depends_on: upload-ready-pipeline
|
||||
commands:
|
||||
- |
|
||||
timeout 20m bash -c '
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -m "core_model" --run-level "core_model"
|
||||
'
|
||||
agents:
|
||||
queue: "mithril-h100-pool"
|
||||
plugins:
|
||||
- kubernetes:
|
||||
podSpec:
|
||||
containers:
|
||||
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
resources:
|
||||
limits:
|
||||
nvidia.com/gpu: 2
|
||||
volumeMounts:
|
||||
- name: devshm
|
||||
mountPath: /dev/shm
|
||||
- name: hf-cache
|
||||
mountPath: /root/.cache/huggingface
|
||||
env:
|
||||
- name: HF_HOME
|
||||
value: /root/.cache/huggingface
|
||||
nodeSelector:
|
||||
node.kubernetes.io/instance-type: gpu-h100-sxm
|
||||
volumes:
|
||||
- name: devshm
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
- name: hf-cache
|
||||
hostPath:
|
||||
path: /mnt/hf-cache
|
||||
type: DirectoryOrCreate
|
||||
|
||||
- label: "MiMo-Audio E2E Test with H100"
|
||||
depends_on: upload-ready-pipeline
|
||||
|
||||
@@ -16,7 +16,7 @@ Easy, fast, and cheap omni-modality model serving for everyone
|
||||
---
|
||||
|
||||
*Latest News* 🔥
|
||||
|
||||
- [2026/03] We released [0.18.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.18.0) - strengthens the core runtime through a large entrypoint refactor and scheduler/runtime cleanups, expands unified quantization and diffusion execution, broadens multimodal model coverage, and improves production readiness across audio, omni, image, video, RL, and multi-platform deployments.
|
||||
- [2026/03] Check out our first public [project deepdive](https://youtu.be/sgwNfsNnR9I) at the vLLM Hong Kong Meetup!
|
||||
- [2026/03] **[vllm-omni-skills](https://github.com/hsliuustc0106/vllm-omni-skills)** is a community-driven collection of AI assistant skills that help developers work with vLLM-Omni more effectively. These skills can be used with popular agentic AI coding assistants like **Cursor IDE**, **Claude**, **Codex**, and more.
|
||||
- [2026/02] We released [0.16.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.16.0) - A major alignment + capability release that rebases onto **upstream vLLM v0.16.0** and significantly expands performance, distributed execution, and production readiness across **Qwen3-Omni / Qwen3-TTS**, **Bagel**, **MiMo-Audio**, **GLM-Image** and the **Diffusion (DiT) image/video stack**—while also improving platform coverage (CUDA / ROCm / NPU / XPU), CI quality, and documentation.
|
||||
|
||||
@@ -125,7 +125,7 @@ PLAYER_HTML = """
|
||||
def _build_player_js(sample_rate: int) -> str:
|
||||
"""Build the JavaScript that powers the AudioWorklet player."""
|
||||
return f"""
|
||||
() => {{
|
||||
<script>
|
||||
const SR = {sample_rate};
|
||||
const WC = {json.dumps(WORKLET_JS)};
|
||||
let ctx = null, node = null, abort = null, gen = false, st = {{}};
|
||||
@@ -284,7 +284,7 @@ def _build_player_js(sample_rate: int) -> str:
|
||||
}}
|
||||
}}
|
||||
}};
|
||||
}}
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
@@ -435,10 +435,7 @@ def create_app(api_base: str):
|
||||
)
|
||||
|
||||
with gr.Blocks(
|
||||
css=css,
|
||||
title="Qwen3-TTS Demo",
|
||||
js=_build_player_js(PCM_SAMPLE_RATE),
|
||||
theme=theme,
|
||||
) as demo:
|
||||
gr.HTML(f"""
|
||||
<div style="display:flex; align-items:center; gap:16px; margin-bottom:8px;">
|
||||
@@ -773,7 +770,14 @@ def create_app(api_base: str):
|
||||
|
||||
demo.queue()
|
||||
|
||||
return gr.mount_gradio_app(fastapi_app, demo, path="/")
|
||||
return gr.mount_gradio_app(
|
||||
fastapi_app,
|
||||
demo,
|
||||
path="/",
|
||||
css=css,
|
||||
theme=theme,
|
||||
head=_build_player_js(PCM_SAMPLE_RATE),
|
||||
)
|
||||
|
||||
|
||||
def parse_args():
|
||||
|
||||
@@ -103,7 +103,7 @@ def build_payload(
|
||||
|
||||
if task_type == "CustomVoice":
|
||||
if voice:
|
||||
payload["speaker"] = voice
|
||||
payload["voice"] = voice
|
||||
if instructions and instructions.strip():
|
||||
payload["instructions"] = instructions.strip()
|
||||
|
||||
|
||||
+163
-38
@@ -688,8 +688,112 @@ def generate_synthetic_audio(
|
||||
return result
|
||||
|
||||
|
||||
def generate_synthetic_video(width: int, height: int, num_frames: int, save_to_file: bool = False) -> dict[str, Any]:
|
||||
"""Generate synthetic video with bouncing balls and return base64 string."""
|
||||
def _mux_mp4_bytes_with_synthetic_audio(
|
||||
video_mp4_bytes: bytes,
|
||||
*,
|
||||
num_frames: int,
|
||||
fps: float = 30.0,
|
||||
sample_rate: int = 48000,
|
||||
) -> bytes:
|
||||
"""
|
||||
Mux a video-only MP4 with mono TTS audio from :func:`generate_synthetic_audio` (AAC).
|
||||
|
||||
Audio length is at least the video duration in whole seconds (rounded up); ffmpeg
|
||||
``-shortest`` trims to the video when the WAV is longer.
|
||||
|
||||
Uses ffmpeg from ``imageio_ffmpeg`` when available, else ``ffmpeg`` on PATH.
|
||||
If TTS or mux fails, returns ``video_mp4_bytes`` unchanged.
|
||||
|
||||
Mux subprocess does **not** use ``capture_output=True``: ffmpeg can block writing
|
||||
to a full stderr pipe while :func:`subprocess.run` waits for exit (classic deadlock).
|
||||
"""
|
||||
duration_sec = num_frames / fps if fps > 0 else 0.0
|
||||
# generate_synthetic_audio(duration=int) uses at least 1s of buffer internally
|
||||
duration_int = max(1, int(math.ceil(duration_sec)))
|
||||
|
||||
try:
|
||||
audio_result = generate_synthetic_audio(
|
||||
duration=duration_int,
|
||||
num_channels=1,
|
||||
sample_rate=sample_rate,
|
||||
save_to_file=False,
|
||||
)
|
||||
audio_pcm = audio_result["np_array"]
|
||||
except Exception as e:
|
||||
logger.warning("Synthetic video: generate_synthetic_audio failed (%s); using video-only MP4.", e)
|
||||
return video_mp4_bytes
|
||||
|
||||
try:
|
||||
import imageio_ffmpeg
|
||||
|
||||
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
|
||||
except Exception:
|
||||
ffmpeg_exe = "ffmpeg"
|
||||
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="syn_vid_mux_") as tmp:
|
||||
vid_path = os.path.join(tmp, "video.mp4")
|
||||
wav_path = os.path.join(tmp, "audio.wav")
|
||||
out_path = os.path.join(tmp, "out.mp4")
|
||||
with open(vid_path, "wb") as f:
|
||||
f.write(video_mp4_bytes)
|
||||
sf.write(wav_path, audio_pcm, sample_rate, format="WAV", subtype="PCM_16")
|
||||
cmd = [
|
||||
ffmpeg_exe,
|
||||
"-y",
|
||||
"-nostdin",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
vid_path,
|
||||
"-i",
|
||||
wav_path,
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-shortest",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
out_path,
|
||||
]
|
||||
subprocess.run(
|
||||
cmd,
|
||||
check=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
timeout=300,
|
||||
)
|
||||
with open(out_path, "rb") as f:
|
||||
return f.read()
|
||||
except (
|
||||
FileNotFoundError,
|
||||
subprocess.CalledProcessError,
|
||||
subprocess.TimeoutExpired,
|
||||
OSError,
|
||||
) as e:
|
||||
logger.warning("Synthetic video: audio mux failed (%s); using video-only MP4.", e)
|
||||
return video_mp4_bytes
|
||||
|
||||
|
||||
def generate_synthetic_video(
|
||||
width: int,
|
||||
height: int,
|
||||
num_frames: int,
|
||||
save_to_file: bool = False,
|
||||
*,
|
||||
embed_audio: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate synthetic video with bouncing balls and base64 MP4.
|
||||
|
||||
When ``embed_audio`` is True, muxes mono AAC from :func:`generate_synthetic_audio`
|
||||
(TTS + ffmpeg) into the MP4; otherwise returns video-only MP4 (faster when tests do
|
||||
not need an audio track).
|
||||
"""
|
||||
|
||||
import cv2
|
||||
import imageio
|
||||
@@ -762,13 +866,13 @@ def generate_synthetic_video(width: int, height: int, num_frames: int, save_to_f
|
||||
result = {
|
||||
"np_array": video_array,
|
||||
}
|
||||
video_bytes = None
|
||||
saved_file_path = None
|
||||
|
||||
fps = 30
|
||||
buffer = io.BytesIO()
|
||||
writer_kwargs = {
|
||||
"format": "mp4",
|
||||
"fps": 30,
|
||||
"fps": fps,
|
||||
"codec": "libx264",
|
||||
"quality": 7,
|
||||
"pixelformat": "yuv420p",
|
||||
@@ -787,32 +891,31 @@ def generate_synthetic_video(width: int, height: int, num_frames: int, save_to_f
|
||||
],
|
||||
}
|
||||
|
||||
if save_to_file:
|
||||
import datetime
|
||||
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output_path = f"video_{width}x{height}_{timestamp}.mp4"
|
||||
try:
|
||||
with imageio.get_writer(output_path, **writer_kwargs) as writer:
|
||||
for frame in video_frames:
|
||||
writer.append_data(frame)
|
||||
|
||||
saved_file_path = output_path
|
||||
print(f"Video saved to: {saved_file_path}")
|
||||
with open(output_path, "rb") as f:
|
||||
video_bytes = f.read()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to save video to file {output_path}: {e}")
|
||||
save_to_file = False
|
||||
|
||||
if not save_to_file or video_bytes is None:
|
||||
try:
|
||||
with imageio.get_writer(buffer, **writer_kwargs) as writer:
|
||||
for frame in video_frames:
|
||||
writer.append_data(frame)
|
||||
|
||||
buffer.seek(0)
|
||||
video_bytes = buffer.read()
|
||||
video_only_bytes = buffer.read()
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to encode synthetic video: {e}")
|
||||
raise
|
||||
|
||||
if embed_audio:
|
||||
video_bytes = _mux_mp4_bytes_with_synthetic_audio(video_only_bytes, num_frames=num_frames, fps=float(fps))
|
||||
else:
|
||||
video_bytes = video_only_bytes
|
||||
|
||||
if save_to_file:
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output_path = f"video_{width}x{height}_{timestamp}.mp4"
|
||||
try:
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(video_bytes)
|
||||
saved_file_path = output_path
|
||||
print(f"Video saved to: {saved_file_path}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to save video to file {output_path}: {e}")
|
||||
|
||||
base64_video = base64.b64encode(video_bytes).decode("utf-8")
|
||||
|
||||
@@ -1262,10 +1365,12 @@ def modify_stage_config(
|
||||
# Direct top-level key
|
||||
config[key] = value
|
||||
|
||||
# Save to new file with timestamp
|
||||
timestamp = int(time.time())
|
||||
# Unique suffix: multiple modify_stage_config calls in one process often run
|
||||
# within the same second (e.g. test_qwen3_omni_expansion imports both
|
||||
# get_chunk_config and get_batch_token_config). int(time.time()) would collide
|
||||
# and the later write would overwrite the earlier YAML on disk.
|
||||
base_name = yaml_path.rsplit(".", 1)[0] if "." in yaml_path else yaml_path
|
||||
output_path = f"{base_name}_{timestamp}.yaml"
|
||||
output_path = f"{base_name}_{time.time_ns()}.yaml"
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(config, f, default_flow_style=None, sort_keys=False, allow_unicode=True, indent=2)
|
||||
@@ -1733,9 +1838,11 @@ def assert_omni_response(response: OmniResponse, request_config: dict[str, Any],
|
||||
"The output does not contain any of the keywords."
|
||||
)
|
||||
|
||||
# Verify similarity
|
||||
# Verify similarity (Whisper transcript vs streamed/detokenized text)
|
||||
if "text" in modalities and "audio" in modalities:
|
||||
assert response.similarity > 0.9, "The audio content is not same as the text"
|
||||
assert response.similarity is not None and response.similarity > 0.9, (
|
||||
"The audio content is not same as the text"
|
||||
)
|
||||
print(f"similarity is: {response.similarity}")
|
||||
|
||||
|
||||
@@ -2135,7 +2242,11 @@ class OpenAIClientHandler:
|
||||
Send OpenAI requests.
|
||||
|
||||
Args:
|
||||
request_config: Request configuration dictionary containing parameters like model, messages, stream
|
||||
request_config: Request configuration dictionary containing parameters like model, messages, stream.
|
||||
Optional ``use_audio_in_video`` (bool): when true, sets
|
||||
``extra_body["mm_processor_kwargs"] = {"use_audio_in_video": True}`` for Qwen-Omni video+audio
|
||||
extraction (merged with any existing ``extra_body`` / ``mm_processor_kwargs``).
|
||||
Optional ``extra_body`` (dict): passed through to ``chat.completions.create`` after merge.
|
||||
request_num: Number of requests, defaults to 1 (single request)
|
||||
|
||||
Returns:
|
||||
@@ -2146,14 +2257,28 @@ class OpenAIClientHandler:
|
||||
stream = request_config.get("stream", False)
|
||||
modalities = request_config.get("modalities", ["text", "audio"])
|
||||
|
||||
extra_body: dict[str, Any] = {}
|
||||
raw_extra = request_config.get("extra_body")
|
||||
if raw_extra:
|
||||
extra_body.update(raw_extra)
|
||||
if request_config.get("use_audio_in_video"):
|
||||
mm = dict(extra_body.get("mm_processor_kwargs") or {})
|
||||
mm["use_audio_in_video"] = True
|
||||
extra_body["mm_processor_kwargs"] = mm
|
||||
extra_body_arg: dict[str, Any] | None = extra_body if extra_body else None
|
||||
|
||||
create_kwargs: dict[str, Any] = {
|
||||
"model": request_config.get("model"),
|
||||
"messages": request_config.get("messages"),
|
||||
"stream": stream,
|
||||
"modalities": modalities,
|
||||
}
|
||||
if extra_body_arg is not None:
|
||||
create_kwargs["extra_body"] = extra_body_arg
|
||||
|
||||
if request_num == 1:
|
||||
# Send single request
|
||||
chat_completion = self.client.chat.completions.create(
|
||||
model=request_config.get("model"),
|
||||
messages=request_config.get("messages"),
|
||||
stream=stream,
|
||||
modalities=modalities,
|
||||
)
|
||||
chat_completion = self.client.chat.completions.create(**create_kwargs)
|
||||
|
||||
if stream:
|
||||
response = self._process_stream_omni_response(chat_completion)
|
||||
|
||||
@@ -47,6 +47,19 @@ def pytest_addoption(parser):
|
||||
help="Balanced sample count per GEdit task group",
|
||||
)
|
||||
group.addoption("--accuracy-workers", action="store", type=int, default=1, help="Worker count for accuracy benches")
|
||||
group.addoption(
|
||||
"--wan22-i2v-image-source",
|
||||
action="store",
|
||||
default=None,
|
||||
help="Image source for Wan2.2 I2V accuracy tests. Can be local path or remote URL.",
|
||||
)
|
||||
group.addoption(
|
||||
"--wan22-i2v-online-timeout-seconds",
|
||||
action="store",
|
||||
type=int,
|
||||
default=1200,
|
||||
help="Online serving timeout in seconds for Wan2.2 I2V accuracy tests.",
|
||||
)
|
||||
|
||||
|
||||
def _hf_cache_root() -> Path:
|
||||
@@ -142,6 +155,17 @@ def accuracy_workers(request: pytest.FixtureRequest) -> int:
|
||||
return int(request.config.getoption("accuracy_workers"))
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def wan22_i2v_image_source(request: pytest.FixtureRequest) -> str | None:
|
||||
value = request.config.getoption("wan22_i2v_image_source")
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def wan22_i2v_online_timeout_seconds(request: pytest.FixtureRequest) -> int:
|
||||
return int(request.config.getoption("wan22_i2v_online_timeout_seconds"))
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def gebench_samples_per_type(request: pytest.FixtureRequest) -> int:
|
||||
return int(request.config.getoption("gebench_samples_per_type"))
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
import torch
|
||||
from diffusers import UniPCMultistepScheduler, WanImageToVideoPipeline
|
||||
from diffusers.pipelines.wan import pipeline_wan_i2v as wan_i2v_module
|
||||
from diffusers.utils import export_to_video, load_image
|
||||
from PIL import Image
|
||||
|
||||
from tests.e2e.accuracy.wan22_i2v.wan22_i2v_video_similarity_common import BOUNDARY_RATIO
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run Wan2.2 I2V diffusers offline generation.")
|
||||
parser.add_argument("--model", required=True)
|
||||
parser.add_argument("--image-source", required=True)
|
||||
parser.add_argument("--prompt", required=True)
|
||||
parser.add_argument("--negative-prompt", required=True)
|
||||
parser.add_argument("--size", required=True)
|
||||
parser.add_argument("--fps", type=int, required=True)
|
||||
parser.add_argument("--num-frames", type=int, required=True)
|
||||
parser.add_argument("--guidance-scale", type=float, required=True)
|
||||
parser.add_argument("--guidance-scale-2", type=float, required=True)
|
||||
parser.add_argument("--flow-shift", type=float, required=True)
|
||||
parser.add_argument("--num-inference-steps", type=int, required=True)
|
||||
parser.add_argument("--seed", type=int, required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--metadata-output", required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _parse_size(size: str) -> tuple[int, int]:
|
||||
width_str, height_str = size.lower().split("x", 1)
|
||||
return int(width_str), int(height_str)
|
||||
|
||||
|
||||
class _IdentityFtfy:
|
||||
@staticmethod
|
||||
def fix_text(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _ensure_wan_ftfy_fallback() -> None:
|
||||
if not hasattr(wan_i2v_module, "ftfy"):
|
||||
wan_i2v_module.ftfy = _IdentityFtfy()
|
||||
|
||||
|
||||
def _offline_cuda_device() -> torch.device:
|
||||
return torch.device("cuda:0")
|
||||
|
||||
|
||||
def _load_input_image(source: str) -> Image.Image:
|
||||
if source.startswith("data:image"):
|
||||
_, encoded = source.split(",", 1)
|
||||
image = Image.open(BytesIO(base64.b64decode(encoded)))
|
||||
image.load()
|
||||
return image.convert("RGB")
|
||||
|
||||
source_path = Path(source)
|
||||
if source_path.exists():
|
||||
image = Image.open(source_path)
|
||||
image.load()
|
||||
return image.convert("RGB")
|
||||
|
||||
image = load_image(source)
|
||||
if isinstance(image, Image.Image):
|
||||
image.load()
|
||||
return image.convert("RGB")
|
||||
|
||||
response = requests.get(source, timeout=60)
|
||||
response.raise_for_status()
|
||||
image = Image.open(BytesIO(response.content))
|
||||
image.load()
|
||||
return image.convert("RGB")
|
||||
|
||||
|
||||
def _resize_to_target(image: Image.Image, *, width: int, height: int) -> Image.Image:
|
||||
return image.resize((width, height), Image.Resampling.LANCZOS)
|
||||
|
||||
|
||||
def _configure_scheduler(pipe: WanImageToVideoPipeline, *, flow_shift: float) -> None:
|
||||
pipe.scheduler = UniPCMultistepScheduler.from_config(
|
||||
pipe.scheduler.config,
|
||||
flow_shift=flow_shift,
|
||||
)
|
||||
|
||||
|
||||
def _write_metadata(
|
||||
path: Path,
|
||||
*,
|
||||
args: argparse.Namespace,
|
||||
width: int,
|
||||
height: int,
|
||||
frame_count: int,
|
||||
) -> None:
|
||||
payload = {
|
||||
"model": args.model,
|
||||
"image_source": args.image_source,
|
||||
"size": args.size,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"fps": args.fps,
|
||||
"num_frames": args.num_frames,
|
||||
"actual_frame_count": frame_count,
|
||||
"guidance_scale": args.guidance_scale,
|
||||
"guidance_scale_2": args.guidance_scale_2,
|
||||
"boundary_ratio": BOUNDARY_RATIO,
|
||||
"flow_shift": args.flow_shift,
|
||||
"num_inference_steps": args.num_inference_steps,
|
||||
"seed": args.seed,
|
||||
"world_size": 1,
|
||||
}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _parse_args()
|
||||
device = _offline_cuda_device()
|
||||
torch.cuda.set_device(device)
|
||||
_ensure_wan_ftfy_fallback()
|
||||
|
||||
pipe = WanImageToVideoPipeline.from_pretrained(args.model, torch_dtype=torch.bfloat16)
|
||||
pipe.register_to_config(boundary_ratio=BOUNDARY_RATIO)
|
||||
_configure_scheduler(pipe, flow_shift=args.flow_shift)
|
||||
pipe.to(device)
|
||||
pipe.set_progress_bar_config(disable=False)
|
||||
|
||||
input_image = _load_input_image(args.image_source)
|
||||
width, height = _parse_size(args.size)
|
||||
resized_image = _resize_to_target(input_image, width=width, height=height)
|
||||
|
||||
generator = torch.Generator(device=device.type).manual_seed(args.seed)
|
||||
frames = pipe(
|
||||
image=resized_image,
|
||||
prompt=args.prompt,
|
||||
negative_prompt=args.negative_prompt,
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=args.num_frames,
|
||||
guidance_scale=args.guidance_scale,
|
||||
guidance_scale_2=args.guidance_scale_2,
|
||||
num_inference_steps=args.num_inference_steps,
|
||||
generator=generator,
|
||||
).frames[0]
|
||||
|
||||
output_path = Path(args.output)
|
||||
metadata_path = Path(args.metadata_output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
export_to_video(frames, str(output_path), fps=args.fps)
|
||||
_write_metadata(metadata_path, args=args, width=width, height=height, frame_count=len(frames))
|
||||
|
||||
if hasattr(pipe, "maybe_free_model_hooks"):
|
||||
pipe.maybe_free_model_hooks()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,672 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from base64 import b64decode, b64encode
|
||||
from fractions import Fraction
|
||||
from hashlib import sha1
|
||||
from io import BytesIO
|
||||
from mimetypes import guess_type
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import torch
|
||||
from diffusers import UniPCMultistepScheduler
|
||||
from PIL import Image
|
||||
|
||||
from tests.conftest import OmniServerParams
|
||||
from tests.e2e.accuracy.wan22_i2v.run_wan22_i2v_diffusers_cp import (
|
||||
_configure_scheduler,
|
||||
_ensure_wan_ftfy_fallback,
|
||||
_IdentityFtfy,
|
||||
_offline_cuda_device,
|
||||
_resize_to_target,
|
||||
)
|
||||
from tests.e2e.accuracy.wan22_i2v.wan22_i2v_video_similarity_common import (
|
||||
FLOW_SHIFT,
|
||||
FPS,
|
||||
GUIDANCE_SCALE,
|
||||
GUIDANCE_SCALE_2,
|
||||
HEIGHT,
|
||||
MODEL_NAME,
|
||||
NEGATIVE_PROMPT,
|
||||
NUM_FRAMES,
|
||||
NUM_INFERENCE_STEPS,
|
||||
PROMPT,
|
||||
PSNR_THRESHOLD,
|
||||
RABBIT_IMAGE_URL,
|
||||
SEED,
|
||||
SIZE,
|
||||
SSIM_THRESHOLD,
|
||||
WIDTH,
|
||||
)
|
||||
from tests.utils import hardware_test
|
||||
|
||||
|
||||
def test_parse_video_metadata_extracts_dimensions_and_fps() -> None:
|
||||
payload = {
|
||||
"streams": [
|
||||
{
|
||||
"width": 832,
|
||||
"height": 480,
|
||||
"avg_frame_rate": "16/1",
|
||||
"nb_read_frames": "93",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
metadata = _parse_video_metadata(payload)
|
||||
|
||||
assert metadata["width"] == 832
|
||||
assert metadata["height"] == 480
|
||||
assert metadata["fps"] == 16.0
|
||||
assert metadata["frame_count"] == 93
|
||||
|
||||
|
||||
def test_parse_ssim_summary_extracts_all_score() -> None:
|
||||
output = """
|
||||
[Parsed_ssim_0 @ 000001] SSIM Y:0.971903 (15.512007) U:0.965077 (14.569044) V:0.962414 (14.252637) All:0.968311 (15.035654)
|
||||
"""
|
||||
|
||||
assert _parse_ssim_score(output) == 0.968311
|
||||
|
||||
|
||||
def test_parse_psnr_summary_extracts_average_score() -> None:
|
||||
output = """
|
||||
[Parsed_psnr_0 @ 000001] PSNR y:32.670157 u:31.844621 v:31.513839 average:32.148004 min:31.563744 max:33.201457
|
||||
"""
|
||||
|
||||
assert _parse_psnr_score(output) == 32.148004
|
||||
|
||||
|
||||
def test_build_diffusers_command_uses_python_runner_path(tmp_path: Path) -> None:
|
||||
runner_path = tmp_path / "run_wan22_i2v_diffusers_cp.py"
|
||||
command = _build_diffusers_command(
|
||||
runner_path=runner_path,
|
||||
image_source=RABBIT_IMAGE_URL,
|
||||
output_path=tmp_path / "offline.mp4",
|
||||
metadata_path=tmp_path / "offline.json",
|
||||
)
|
||||
|
||||
assert command[:2] == [
|
||||
sys.executable,
|
||||
str(runner_path),
|
||||
]
|
||||
assert "--output" in command
|
||||
assert "--metadata-output" in command
|
||||
|
||||
|
||||
def test_resolve_image_source_prefers_existing_local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
image_path = tmp_path / "rabbit.png"
|
||||
Image.new("RGB", (8, 8), color=(255, 128, 64)).save(image_path)
|
||||
|
||||
assert _resolve_image_source(str(image_path)) == str(image_path.resolve())
|
||||
|
||||
|
||||
def test_build_online_image_reference_uses_data_url_for_local_path(tmp_path: Path) -> None:
|
||||
image_path = tmp_path / "rabbit.png"
|
||||
Image.new("RGB", (4, 2), color=(10, 20, 30)).save(image_path)
|
||||
|
||||
reference = _build_online_image_reference(str(image_path))
|
||||
|
||||
assert reference.startswith("data:image/png;base64,")
|
||||
encoded = reference.split(",", 1)[1]
|
||||
assert len(b64decode(encoded)) > 0
|
||||
|
||||
|
||||
def test_send_video_request_with_timeout_uses_requested_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class DummyResponse:
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict[str, str]:
|
||||
return {"id": "video_123"}
|
||||
|
||||
class DummyClient:
|
||||
def __init__(self) -> None:
|
||||
self.run_level = "core_model"
|
||||
self.wait_args: tuple[str, int, int] | None = None
|
||||
|
||||
def _build_url(self, path: str) -> str:
|
||||
return f"http://localhost:8000{path}"
|
||||
|
||||
def _wait_until_video_completed(
|
||||
self,
|
||||
video_id: str,
|
||||
poll_interval_seconds: int = 2,
|
||||
timeout_seconds: int = 300,
|
||||
) -> None:
|
||||
self.wait_args = (video_id, poll_interval_seconds, timeout_seconds)
|
||||
|
||||
def _download_video_content(self, video_id: str) -> bytes:
|
||||
return b"video-bytes"
|
||||
|
||||
monkeypatch.setattr(requests, "post", lambda *args, **kwargs: DummyResponse())
|
||||
|
||||
client = DummyClient()
|
||||
result = _send_video_request_with_timeout(
|
||||
client,
|
||||
{
|
||||
"form_data": {"prompt": "test"},
|
||||
"image_reference": RABBIT_IMAGE_URL,
|
||||
},
|
||||
timeout_seconds=1200,
|
||||
)
|
||||
|
||||
assert result == b"video-bytes"
|
||||
assert client.wait_args == ("video_123", 2, 1200)
|
||||
|
||||
|
||||
def test_online_timeout_defaults_to_1200() -> None:
|
||||
assert _online_timeout_seconds(None) == 1200
|
||||
|
||||
|
||||
def test_artifact_dir_is_under_repo_result_folder(tmp_path: Path) -> None:
|
||||
artifact_dir = _artifact_dir(str(tmp_path / "rabbit.png"))
|
||||
|
||||
assert artifact_dir.parent == Path(__file__).parent / "result"
|
||||
assert artifact_dir.name.startswith("rabbit-")
|
||||
|
||||
|
||||
def test_offline_cuda_device_uses_indexed_cuda_device() -> None:
|
||||
assert _offline_cuda_device() == torch.device("cuda:0")
|
||||
|
||||
|
||||
def test_ensure_wan_ftfy_fallback_sets_identity(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from diffusers.pipelines.wan import pipeline_wan_i2v as wan_i2v_module
|
||||
|
||||
monkeypatch.delattr(wan_i2v_module, "ftfy", raising=False)
|
||||
_ensure_wan_ftfy_fallback()
|
||||
|
||||
assert hasattr(wan_i2v_module, "ftfy")
|
||||
assert isinstance(wan_i2v_module.ftfy, _IdentityFtfy)
|
||||
assert wan_i2v_module.ftfy.fix_text("abc") == "abc"
|
||||
|
||||
|
||||
def test_resize_to_target_matches_requested_dimensions() -> None:
|
||||
image = Image.new("RGB", (100, 50), color=(1, 2, 3))
|
||||
|
||||
resized = _resize_to_target(image, width=832, height=480)
|
||||
|
||||
assert resized.size == (832, 480)
|
||||
|
||||
|
||||
def test_configure_scheduler_switches_to_unipc() -> None:
|
||||
class DummyPipe:
|
||||
def __init__(self) -> None:
|
||||
self.scheduler = UniPCMultistepScheduler(num_train_timesteps=1000, flow_shift=1.0)
|
||||
|
||||
pipe = DummyPipe()
|
||||
_configure_scheduler(pipe, flow_shift=5.0)
|
||||
|
||||
assert isinstance(pipe.scheduler, UniPCMultistepScheduler)
|
||||
assert pipe.scheduler.config.flow_shift == 5.0
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
WORKSPACE_ROOT = REPO_ROOT.parent
|
||||
RUNNER_PATH = Path(__file__).with_name("run_wan22_i2v_diffusers_cp.py")
|
||||
RESULT_ROOT = Path(__file__).parent / "result"
|
||||
VIDEO_TIMEOUT_SECONDS = 60 * 60
|
||||
_SSIM_RE = re.compile(r"All:(?P<score>[0-9.]+)")
|
||||
_PSNR_RE = re.compile(r"average:(?P<score>[0-9.]+)")
|
||||
SERVER_CASES = [
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model=MODEL_NAME,
|
||||
server_args=[
|
||||
"--usp",
|
||||
"2",
|
||||
"--use-hsdp",
|
||||
"--hsdp-shard-size",
|
||||
"2",
|
||||
],
|
||||
use_omni=True,
|
||||
),
|
||||
id="wan22_i2v_usp2_hsdp2",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _parse_video_metadata(payload: dict[str, Any]) -> dict[str, int | float]:
|
||||
streams = payload.get("streams")
|
||||
if not isinstance(streams, list) or not streams:
|
||||
raise ValueError(f"ffprobe payload did not include video streams: {payload}")
|
||||
|
||||
stream = streams[0]
|
||||
width = int(stream["width"])
|
||||
height = int(stream["height"])
|
||||
fps = float(Fraction(stream["avg_frame_rate"]))
|
||||
frame_count_value = stream.get("nb_read_frames") or stream.get("nb_frames")
|
||||
if frame_count_value is None:
|
||||
raise ValueError(f"ffprobe payload did not include frame count: {payload}")
|
||||
frame_count = int(frame_count_value)
|
||||
return {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"fps": fps,
|
||||
"frame_count": frame_count,
|
||||
}
|
||||
|
||||
|
||||
def _parse_ssim_score(output: str) -> float:
|
||||
match = _SSIM_RE.search(output)
|
||||
if match is None:
|
||||
raise ValueError(f"Could not parse SSIM score from ffmpeg output:\n{output}")
|
||||
return float(match.group("score"))
|
||||
|
||||
|
||||
def _parse_psnr_score(output: str) -> float:
|
||||
match = _PSNR_RE.search(output)
|
||||
if match is None:
|
||||
raise ValueError(f"Could not parse PSNR score from ffmpeg output:\n{output}")
|
||||
return float(match.group("score"))
|
||||
|
||||
|
||||
def _probe_binary(binary: str) -> str:
|
||||
resolved = shutil.which(binary)
|
||||
if resolved is None:
|
||||
pytest.skip(f"{binary} is required for Wan2.2 video similarity e2e test.")
|
||||
return resolved
|
||||
|
||||
|
||||
def _build_diffusers_command(
|
||||
*,
|
||||
runner_path: Path,
|
||||
image_source: str,
|
||||
output_path: Path,
|
||||
metadata_path: Path,
|
||||
) -> list[str]:
|
||||
return [
|
||||
sys.executable,
|
||||
str(runner_path),
|
||||
"--model",
|
||||
MODEL_NAME,
|
||||
"--image-source",
|
||||
image_source,
|
||||
"--prompt",
|
||||
PROMPT,
|
||||
"--negative-prompt",
|
||||
NEGATIVE_PROMPT,
|
||||
"--size",
|
||||
SIZE,
|
||||
"--fps",
|
||||
str(FPS),
|
||||
"--num-frames",
|
||||
str(NUM_FRAMES),
|
||||
"--guidance-scale",
|
||||
str(GUIDANCE_SCALE),
|
||||
"--guidance-scale-2",
|
||||
str(GUIDANCE_SCALE_2),
|
||||
"--flow-shift",
|
||||
str(FLOW_SHIFT),
|
||||
"--num-inference-steps",
|
||||
str(NUM_INFERENCE_STEPS),
|
||||
"--seed",
|
||||
str(SEED),
|
||||
"--output",
|
||||
str(output_path),
|
||||
"--metadata-output",
|
||||
str(metadata_path),
|
||||
]
|
||||
|
||||
|
||||
def _runner_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
pythonpath_parts = [
|
||||
str(REPO_ROOT),
|
||||
str(WORKSPACE_ROOT / "diffusers" / "src"),
|
||||
]
|
||||
existing_pythonpath = env.get("PYTHONPATH")
|
||||
if existing_pythonpath:
|
||||
pythonpath_parts.append(existing_pythonpath)
|
||||
env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts)
|
||||
env.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
|
||||
return env
|
||||
|
||||
|
||||
def _resolve_image_source(configured: str | None) -> str:
|
||||
configured = configured or RABBIT_IMAGE_URL
|
||||
candidate = Path(configured)
|
||||
if candidate.exists():
|
||||
return str(candidate.resolve())
|
||||
return configured
|
||||
|
||||
|
||||
def _is_remote_image_source(source: str) -> bool:
|
||||
return source.startswith("http://") or source.startswith("https://")
|
||||
|
||||
|
||||
def _validate_image_source(source: str) -> None:
|
||||
if _is_remote_image_source(source):
|
||||
response = requests.get(source, timeout=60)
|
||||
response.raise_for_status()
|
||||
return
|
||||
|
||||
image_path = Path(source)
|
||||
if not image_path.exists():
|
||||
raise FileNotFoundError(f"Local image source does not exist: {image_path}")
|
||||
|
||||
|
||||
def _build_online_image_reference(source: str) -> str:
|
||||
if _is_remote_image_source(source) or source.startswith("data:image"):
|
||||
return source
|
||||
|
||||
image_path = Path(source)
|
||||
mime_type = guess_type(image_path.name)[0] or "image/png"
|
||||
encoded = b64encode(image_path.read_bytes()).decode("ascii")
|
||||
return f"data:{mime_type};base64,{encoded}"
|
||||
|
||||
|
||||
def _probe_video(path: Path) -> dict[str, int | float]:
|
||||
_probe_binary("ffprobe")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-count_frames",
|
||||
"-show_entries",
|
||||
"stream=width,height,avg_frame_rate,nb_read_frames",
|
||||
"-of",
|
||||
"json",
|
||||
str(path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return _parse_video_metadata(json.loads(result.stdout))
|
||||
|
||||
|
||||
def _run_ffmpeg_similarity(filter_name: str, first: Path, second: Path) -> str:
|
||||
_probe_binary("ffmpeg")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-i",
|
||||
str(first),
|
||||
"-i",
|
||||
str(second),
|
||||
"-lavfi",
|
||||
f"[0:v][1:v]{filter_name}",
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stderr
|
||||
|
||||
|
||||
def _online_timeout_seconds(configured: int | None) -> int:
|
||||
return int(configured) if configured is not None else 1200
|
||||
|
||||
|
||||
def _artifact_dir(image_source: str) -> Path:
|
||||
if _is_remote_image_source(image_source):
|
||||
source_name = Path(urlparse(image_source).path).stem or "remote"
|
||||
else:
|
||||
source_name = Path(image_source).stem or "local"
|
||||
|
||||
safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", source_name)
|
||||
digest = sha1(image_source.encode("utf-8")).hexdigest()[:8]
|
||||
return RESULT_ROOT / f"{safe_name}-{digest}"
|
||||
|
||||
|
||||
def _artifact_paths(image_source: str) -> tuple[Path, Path, Path]:
|
||||
artifact_dir = _artifact_dir(image_source)
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
return (
|
||||
artifact_dir / "online.mp4",
|
||||
artifact_dir / "offline.mp4",
|
||||
artifact_dir / "offline_metadata.json",
|
||||
)
|
||||
|
||||
|
||||
def _send_video_request_with_timeout(
|
||||
openai_client,
|
||||
request_config: dict[str, Any],
|
||||
*,
|
||||
timeout_seconds: int,
|
||||
) -> bytes:
|
||||
form_data = request_config.get("form_data")
|
||||
if not isinstance(form_data, dict):
|
||||
raise ValueError("Video request_config must contain 'form_data'")
|
||||
|
||||
if not form_data.get("prompt"):
|
||||
raise ValueError("Video request_config['form_data'] must contain 'prompt'")
|
||||
|
||||
normalized_form_data = {key: str(value) for key, value in form_data.items() if value is not None}
|
||||
|
||||
files: dict[str, tuple[str, BytesIO, str]] = {}
|
||||
image_reference = request_config.get("image_reference")
|
||||
if image_reference:
|
||||
if image_reference.startswith("data:image"):
|
||||
header, encoded = image_reference.split(",", 1)
|
||||
content_type = header.split(";")[0].removeprefix("data:")
|
||||
extension = content_type.split("/")[-1]
|
||||
file_data = b64decode(encoded)
|
||||
files["input_reference"] = (
|
||||
f"reference.{extension}",
|
||||
BytesIO(file_data),
|
||||
content_type,
|
||||
)
|
||||
else:
|
||||
normalized_form_data["image_reference"] = json.dumps({"image_url": image_reference})
|
||||
|
||||
start_time = time.perf_counter()
|
||||
create_url = openai_client._build_url("/v1/videos")
|
||||
response = requests.post(
|
||||
create_url,
|
||||
data=normalized_form_data,
|
||||
files=files,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=60,
|
||||
)
|
||||
response.raise_for_status()
|
||||
job_data = response.json()
|
||||
video_id = job_data["id"]
|
||||
openai_client._wait_until_video_completed(video_id, timeout_seconds=timeout_seconds)
|
||||
video_content = openai_client._download_video_content(video_id)
|
||||
print(f"online_video_e2e_latency_s={time.perf_counter() - start_time:.3f}")
|
||||
return video_content
|
||||
|
||||
|
||||
def _generate_online_video(
|
||||
*,
|
||||
omni_server,
|
||||
openai_client,
|
||||
image_source: str,
|
||||
online_timeout_seconds: int,
|
||||
) -> Path:
|
||||
online_path, _, _ = _artifact_paths(image_source)
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"form_data": {
|
||||
"prompt": PROMPT,
|
||||
"negative_prompt": NEGATIVE_PROMPT,
|
||||
"size": SIZE,
|
||||
"fps": FPS,
|
||||
"num_frames": NUM_FRAMES,
|
||||
"guidance_scale": GUIDANCE_SCALE,
|
||||
"guidance_scale_2": GUIDANCE_SCALE_2,
|
||||
"flow_shift": FLOW_SHIFT,
|
||||
"num_inference_steps": NUM_INFERENCE_STEPS,
|
||||
"seed": SEED,
|
||||
},
|
||||
"image_reference": _build_online_image_reference(image_source),
|
||||
}
|
||||
online_video_bytes = _send_video_request_with_timeout(
|
||||
openai_client,
|
||||
request_config,
|
||||
timeout_seconds=_online_timeout_seconds(online_timeout_seconds),
|
||||
)
|
||||
online_path.write_bytes(online_video_bytes)
|
||||
return online_path
|
||||
|
||||
|
||||
def _generate_offline_video(*, image_source: str) -> tuple[Path, Path]:
|
||||
_, offline_path, offline_metadata_path = _artifact_paths(image_source)
|
||||
command = _build_diffusers_command(
|
||||
runner_path=RUNNER_PATH,
|
||||
image_source=image_source,
|
||||
output_path=offline_path,
|
||||
metadata_path=offline_metadata_path,
|
||||
)
|
||||
subprocess.run(
|
||||
command,
|
||||
cwd=REPO_ROOT,
|
||||
env=_runner_env(),
|
||||
check=True,
|
||||
timeout=VIDEO_TIMEOUT_SECONDS,
|
||||
)
|
||||
return offline_path, offline_metadata_path
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.benchmark
|
||||
@pytest.mark.diffusion
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
def test_wan22_i2v_diffusers_offline_generates_video(
|
||||
wan22_i2v_image_source: str | None,
|
||||
) -> None:
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("Wan2.2 I2V diffusers offline test requires CUDA.")
|
||||
|
||||
_probe_binary("ffprobe")
|
||||
if not RUNNER_PATH.exists():
|
||||
raise AssertionError(f"Offline diffusers runner does not exist: {RUNNER_PATH}")
|
||||
|
||||
image_source = _resolve_image_source(wan22_i2v_image_source)
|
||||
_validate_image_source(image_source)
|
||||
offline_path, offline_metadata_path = _generate_offline_video(image_source=image_source)
|
||||
assert offline_path.exists(), f"Expected offline video artifact at {offline_path}"
|
||||
assert offline_metadata_path.exists(), f"Expected offline metadata artifact at {offline_metadata_path}"
|
||||
offline_metadata = _probe_video(offline_path)
|
||||
assert offline_metadata["width"] == WIDTH
|
||||
assert offline_metadata["height"] == HEIGHT
|
||||
assert offline_metadata["fps"] == float(FPS)
|
||||
assert offline_metadata["frame_count"] == NUM_FRAMES
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.benchmark
|
||||
@pytest.mark.diffusion
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server", SERVER_CASES, indirect=True)
|
||||
def test_wan22_i2v_online_serving_generates_video(
|
||||
omni_server,
|
||||
openai_client,
|
||||
wan22_i2v_image_source: str | None,
|
||||
wan22_i2v_online_timeout_seconds: int,
|
||||
) -> None:
|
||||
if not torch.cuda.is_available() or torch.cuda.device_count() < 2:
|
||||
pytest.skip("Wan2.2 I2V similarity e2e test requires >= 2 CUDA GPUs.")
|
||||
|
||||
_probe_binary("ffprobe")
|
||||
image_source = _resolve_image_source(wan22_i2v_image_source)
|
||||
_validate_image_source(image_source)
|
||||
online_path = _generate_online_video(
|
||||
omni_server=omni_server,
|
||||
openai_client=openai_client,
|
||||
image_source=image_source,
|
||||
online_timeout_seconds=wan22_i2v_online_timeout_seconds,
|
||||
)
|
||||
assert online_path.exists(), f"Expected online video artifact at {online_path}"
|
||||
online_metadata = _probe_video(online_path)
|
||||
assert online_metadata["width"] == WIDTH
|
||||
assert online_metadata["height"] == HEIGHT
|
||||
assert online_metadata["fps"] == float(FPS)
|
||||
assert online_metadata["frame_count"] == NUM_FRAMES
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.benchmark
|
||||
@pytest.mark.diffusion
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=2)
|
||||
def test_wan22_i2v_serving_matches_diffusers_video_similarity(
|
||||
wan22_i2v_image_source: str | None,
|
||||
) -> None:
|
||||
if not torch.cuda.is_available() or torch.cuda.device_count() < 2:
|
||||
pytest.skip("Wan2.2 I2V similarity e2e test requires >= 2 CUDA GPUs.")
|
||||
|
||||
_probe_binary("ffmpeg")
|
||||
_probe_binary("ffprobe")
|
||||
if not RUNNER_PATH.exists():
|
||||
raise AssertionError(f"Offline diffusers runner does not exist: {RUNNER_PATH}")
|
||||
|
||||
image_source = _resolve_image_source(wan22_i2v_image_source)
|
||||
_validate_image_source(image_source)
|
||||
online_path, offline_path, offline_metadata_path = _artifact_paths(image_source)
|
||||
|
||||
if not online_path.exists():
|
||||
pytest.skip(f"Missing online artifact from prerequisite test: {online_path}")
|
||||
if not offline_path.exists() or not offline_metadata_path.exists():
|
||||
pytest.skip(f"Missing offline artifacts from prerequisite test: {offline_path}, {offline_metadata_path}")
|
||||
|
||||
assert online_path.exists(), f"Expected online video artifact at {online_path}"
|
||||
assert offline_path.exists(), f"Expected offline video artifact at {offline_path}"
|
||||
assert offline_metadata_path.exists(), f"Expected offline metadata artifact at {offline_metadata_path}"
|
||||
|
||||
online_metadata = _probe_video(online_path)
|
||||
offline_metadata = _probe_video(offline_path)
|
||||
assert online_metadata == offline_metadata, (
|
||||
f"Video metadata mismatch:\n"
|
||||
f"online={online_metadata}\n"
|
||||
f"offline={offline_metadata}\n"
|
||||
f"online_path={online_path}\n"
|
||||
f"offline_path={offline_path}"
|
||||
)
|
||||
assert online_metadata["width"] == WIDTH
|
||||
assert online_metadata["height"] == HEIGHT
|
||||
assert online_metadata["fps"] == float(FPS)
|
||||
assert online_metadata["frame_count"] == NUM_FRAMES
|
||||
|
||||
ssim_output = _run_ffmpeg_similarity("ssim", online_path, offline_path)
|
||||
psnr_output = _run_ffmpeg_similarity("psnr", online_path, offline_path)
|
||||
ssim_score = _parse_ssim_score(ssim_output)
|
||||
psnr_score = _parse_psnr_score(psnr_output)
|
||||
|
||||
print("wan22_i2v_similarity metrics:")
|
||||
print(
|
||||
" SSIM:"
|
||||
f" value={ssim_score:.6f},"
|
||||
f" threshold>={SSIM_THRESHOLD:.6f},"
|
||||
" range=[0, 1],"
|
||||
" higher_is_better=True,"
|
||||
" interpretation=structural_similarity"
|
||||
)
|
||||
print(
|
||||
" PSNR:"
|
||||
f" value={psnr_score:.6f} dB,"
|
||||
f" threshold>={PSNR_THRESHOLD:.6f} dB,"
|
||||
" range=[0, +inf),"
|
||||
" higher_is_better=True,"
|
||||
" interpretation=pixel_error_in_decibels"
|
||||
)
|
||||
print(f"online_video={online_path}")
|
||||
print(f"offline_video={offline_path}")
|
||||
print(f"offline_metadata={offline_metadata_path}")
|
||||
|
||||
assert ssim_score >= SSIM_THRESHOLD, (
|
||||
f"SSIM below threshold: got {ssim_score:.6f}, expected >= {SSIM_THRESHOLD:.6f}. "
|
||||
f"online={online_path} offline={offline_path}"
|
||||
)
|
||||
assert psnr_score >= PSNR_THRESHOLD, (
|
||||
f"PSNR below threshold: got {psnr_score:.6f}, expected >= {PSNR_THRESHOLD:.6f}. "
|
||||
f"online={online_path} offline={offline_path}"
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
RABBIT_IMAGE_URL = "https://vllm-public-assets.s3.us-west-2.amazonaws.com/omni-assets/rabbit.png"
|
||||
MODEL_NAME = "Wan-AI/Wan2.2-I2V-A14B-Diffusers"
|
||||
SIZE = "832x480"
|
||||
WIDTH = 832
|
||||
HEIGHT = 480
|
||||
FPS = 16
|
||||
NUM_FRAMES = 93
|
||||
GUIDANCE_SCALE = 3.5
|
||||
GUIDANCE_SCALE_2 = 3.5
|
||||
FLOW_SHIFT = 5.0
|
||||
NUM_INFERENCE_STEPS = 50
|
||||
SEED = 42
|
||||
BOUNDARY_RATIO = 0.875
|
||||
SSIM_THRESHOLD = 0.94
|
||||
PSNR_THRESHOLD = 28.0
|
||||
|
||||
PROMPT = """一只棕色野兔的正面特写镜头,采用低角度仰拍视角,营造亲密而庄严的视觉冲击。兔子一双圆润漆黑的大眼睛直视镜头深处,眼神中交织着野生动物的警觉与一丝难以言喻的温柔好奇,仿佛在与观者建立跨越物种的静默对话。它毛色呈现层次丰富的棕褐渐变,从浅奶油色腹部过渡到深棕背部,每根毛发纹理清晰可辨,在侧光下泛着丝绸般的光泽。细长洁白的胡须共有三对,随呼吸节奏微微颤动,偶尔因捕捉气流信息而轻轻摇摆。
|
||||
两只标志性的长耳完全竖立,耳廓外侧覆盖短密棕毛,内侧则露出粉嫩的血管网络,薄如蝉翼的皮肤下血液流动隐约可见,耳朵以细微幅度不时转动,精准定位声源方向。背景是一片澄澈的蔚蓝天空,形态蓬松的白色积云以缓慢速度横向漂移,云影在兔子头顶交替变化,光线随之明暗流转。晴朗天气的明媚阳光从画面左上方45度角倾泻而下,在兔脸右侧形成柔和的伦勃朗式阴影,强化了面部立体感和皮毛质感。
|
||||
兔子湿润的黑鼻子持续进行每秒三至四次的快速抽动,这是它们感知化学信号的本能动作,粉色三瓣嘴随之轻启,露出正在反刍的洁白门齿,下颌以稳定节奏左右研磨。摄影采用大光圈浅景深,焦点牢牢锁定在兔子双眼连线所在的焦平面,背景天空和远景绿色植被虚化成圆润的彩色光斑,前景几根嫩绿草叶闯入画面边缘,以缓慢弧线随风摇曳,暗示着和煦的春日微风。
|
||||
整个场景弥漫着宁静致远的田园诗意,色彩温暖饱和,充满生命力。兔子在持续五秒的对视后,以典型 lagomorph 特征完成一次完整的瞬膜眨眼——第三眼睑从内侧横向滑过眼球,继而缓缓歪头向右十五度,这个行为在动物行为学中代表认知加工和好奇心表达,耳朵随之向同一方向倾斜,最终恢复正视姿态,胡须舒展,完成这段短暂而珍贵的自然纪录。"""
|
||||
|
||||
NEGATIVE_PROMPT = """色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走"""
|
||||
@@ -23,7 +23,7 @@ from tests.conftest import (
|
||||
)
|
||||
from tests.utils import hardware_test
|
||||
|
||||
models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
|
||||
model = "Qwen/Qwen3-Omni-30B-A3B-Instruct"
|
||||
|
||||
AUDIO_KEY = ["test"]
|
||||
IMAGE_KEY = ["square", "quadrate"]
|
||||
@@ -49,16 +49,32 @@ def get_chunk_config(default_path):
|
||||
return path
|
||||
|
||||
|
||||
def get_batch_token_config(default_path):
|
||||
path = modify_stage_config(
|
||||
default_path,
|
||||
updates={
|
||||
"stage_args": {1: {"engine_args.max_num_batched_tokens": 64}},
|
||||
},
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
# CI stage config for 2*H100-80G GPUs
|
||||
default_path = str(Path(__file__).parent.parent / "stage_configs" / "qwen3_omni_ci.yaml")
|
||||
stage_configs = [default_path, get_chunk_config(default_path)]
|
||||
|
||||
if current_omni_platform.is_xpu():
|
||||
stage_configs = [str(Path(__file__).parent.parent / "stage_configs" / "xpu" / "qwen3_omni_ci.yaml")]
|
||||
default_path = str(Path(__file__).parent.parent / "stage_configs" / "xpu" / "qwen3_omni_ci.yaml")
|
||||
|
||||
# Create parameter combinations for model and stage config
|
||||
test_params = [
|
||||
OmniServerParams(model=model, stage_config_path=stage_config) for model in models for stage_config in stage_configs
|
||||
pytest.param(OmniServerParams(model=model, stage_config_path=default_path), id="default"),
|
||||
pytest.param(OmniServerParams(model=model, stage_config_path=get_chunk_config(default_path)), id="async_chunk"),
|
||||
]
|
||||
|
||||
test_token_params = [
|
||||
pytest.param(
|
||||
OmniServerParams(model=model, stage_config_path=get_batch_token_config(default_path)), id="batch_token_64"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -85,6 +101,8 @@ def get_prompt(prompt_type="text_only"):
|
||||
"text_video": "What is in this video? ",
|
||||
"text_image": "What is in this image? ",
|
||||
"text_audio": "What is in this audio? ",
|
||||
"text_audio_video": "First, what is in this audio? Then, what is in this video? ",
|
||||
"one_word": "What is the capital of UK? Answer in one word",
|
||||
}
|
||||
return prompts.get(prompt_type, prompts["text_only"])
|
||||
|
||||
@@ -121,7 +139,7 @@ def test_text_to_audio_001(omni_server, openai_client) -> None:
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
@pytest.mark.parametrize("omni_server", test_params + test_token_params, indirect=True)
|
||||
def test_text_to_text_audio_001(omni_server, openai_client) -> None:
|
||||
"""
|
||||
Input Modal: text
|
||||
@@ -288,7 +306,7 @@ def test_video_to_text_audio_001(omni_server, openai_client) -> None:
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
@pytest.mark.parametrize("omni_server", test_params + test_token_params, indirect=True)
|
||||
def test_text_audio_to_text_audio_001(omni_server, openai_client) -> None:
|
||||
"""
|
||||
Input Modal: text, audio
|
||||
@@ -313,7 +331,7 @@ def test_text_audio_to_text_audio_001(omni_server, openai_client) -> None:
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
@pytest.mark.parametrize("omni_server", test_params + test_token_params, indirect=True)
|
||||
def test_text_image_to_text_audio_001(omni_server, openai_client) -> None:
|
||||
"""
|
||||
Input Modal: text, image
|
||||
@@ -339,7 +357,7 @@ def test_text_image_to_text_audio_001(omni_server, openai_client) -> None:
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
@pytest.mark.parametrize("omni_server", test_params + test_token_params, indirect=True)
|
||||
def test_text_video_to_text_audio_001(omni_server, openai_client) -> None:
|
||||
"""
|
||||
Input Modal: text, video
|
||||
@@ -367,7 +385,7 @@ def test_text_video_to_text_audio_001(omni_server, openai_client) -> None:
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
@pytest.mark.parametrize("omni_server", test_params + test_token_params, indirect=True)
|
||||
def test_mix_to_text_audio_001(omni_server, openai_client) -> None:
|
||||
"""
|
||||
Input Modal: text, audio, image, video
|
||||
@@ -393,3 +411,106 @@ def test_mix_to_text_audio_001(omni_server, openai_client) -> None:
|
||||
"key_words": {"audio": AUDIO_KEY, "image": IMAGE_KEY, "video": VIDEO_KEY},
|
||||
}
|
||||
openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_audio_in_video_001(omni_server, openai_client) -> None:
|
||||
"""
|
||||
Input Modal: text + video (synthetic MP4 with embedded audio; ``use_audio_in_video`` uses audio from the video).
|
||||
Output Modal: text, audio
|
||||
Input Setting: stream=False
|
||||
Datasets: single request
|
||||
"""
|
||||
video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300, embed_audio=True)['base64']}"
|
||||
messages = dummy_messages_from_mix_data(
|
||||
system_prompt=get_system_prompt(),
|
||||
video_data_url=video_data_url,
|
||||
content_text=get_prompt("text_audio_video"),
|
||||
)
|
||||
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"use_audio_in_video": True,
|
||||
"key_words": {"video": VIDEO_KEY, "audio": AUDIO_KEY + ["beep", "electronic"]},
|
||||
}
|
||||
openai_client.send_omni_request(request_config)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_audio_in_video_002(omni_server, openai_client) -> None:
|
||||
"""
|
||||
Input Modal: text + video (synthetic MP4 with embedded audio; ``use_audio_in_video`` uses audio from the video).
|
||||
Output Modal: text, audio
|
||||
Input Setting: stream=True
|
||||
Datasets: few requests
|
||||
"""
|
||||
video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300, embed_audio=True)['base64']}"
|
||||
messages = dummy_messages_from_mix_data(
|
||||
system_prompt=get_system_prompt(),
|
||||
video_data_url=video_data_url,
|
||||
content_text=get_prompt("text_audio_video"),
|
||||
)
|
||||
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
"use_audio_in_video": True,
|
||||
"key_words": {"video": VIDEO_KEY, "audio": AUDIO_KEY + ["beep", "electronic"]},
|
||||
}
|
||||
|
||||
# Retry when assert_omni_response fails on key_words (see tests/conftest.py).
|
||||
_keyword_assert_msg = "The output does not contain any of the keywords."
|
||||
_max_retries = 3
|
||||
for attempt in range(_max_retries):
|
||||
try:
|
||||
openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
|
||||
break
|
||||
except AssertionError as e:
|
||||
if _keyword_assert_msg not in str(e) or attempt == _max_retries - 1:
|
||||
raise
|
||||
print(f"Keyword assertion failed, retrying {attempt + 2}/{_max_retries}: {e!r}")
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_one_word_prompt_001(omni_server, openai_client) -> None:
|
||||
"""
|
||||
Input Modal: text only (one-word answer constraint).
|
||||
Output Modal: text, audio (default ``modalities``); ``key_words`` only assert on text.
|
||||
Input Setting: stream=True
|
||||
Datasets: single request
|
||||
"""
|
||||
messages = dummy_messages_from_mix_data(
|
||||
system_prompt=get_system_prompt(),
|
||||
content_text=get_prompt("one_word"),
|
||||
)
|
||||
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
"key_words": {"text": ["london"]},
|
||||
}
|
||||
|
||||
# Retry only when assert_omni_response fails on text/audio cosine similarity (see tests/conftest.py).
|
||||
_similarity_assert_msg = "The audio content is not same as the text"
|
||||
_max_retries = 3
|
||||
for attempt in range(_max_retries):
|
||||
try:
|
||||
openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
|
||||
break
|
||||
except AssertionError as e:
|
||||
if _similarity_assert_msg not in str(e) or attempt == _max_retries - 1:
|
||||
raise
|
||||
print(f"Similarity assertion failed, retrying {attempt + 2}/{_max_retries}: {e!r}")
|
||||
@@ -7,7 +7,7 @@ and are supported by Qwen-Image-Layered model.
|
||||
Kept cases (maximum feature coverage, no cpu-offload):
|
||||
sp_001 : cache_dit + Ulysses-SP 2 (2×H100)
|
||||
cfg_parallel_002 : cache_dit + CFG-Parallel 2 (2×H100)
|
||||
layers_guard_001 : layerwise offload + layers=2 (1×H100, issue #1969 guard)
|
||||
layers_guard_001 : layerwise offload + layers=3 (1×H100, issue #1969 guard)
|
||||
|
||||
Total distinct features covered: cache_dit, Ulysses-SP, CFG-Parallel, layerwise-offload.
|
||||
"""
|
||||
@@ -102,8 +102,8 @@ LAYERS_GUARD_CASES = [
|
||||
model=MODEL,
|
||||
server_args=["--enable-layerwise-offload"],
|
||||
),
|
||||
2,
|
||||
id="layers_guard_001_layers2",
|
||||
3,
|
||||
id="layers_guard_001_layers3",
|
||||
marks=SINGLE_CARD_FEATURE_MARKS,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -33,7 +33,7 @@ stage_args:
|
||||
temperature: 0.4
|
||||
top_p: 0.9
|
||||
top_k: 1
|
||||
max_tokens: 100
|
||||
max_tokens: 150
|
||||
seed: 42
|
||||
ignore_eos: False
|
||||
detokenize: True
|
||||
@@ -74,7 +74,7 @@ stage_args:
|
||||
devices: "1"
|
||||
engine_args:
|
||||
model_stage: code2wav
|
||||
max_num_seqs: 1
|
||||
max_num_seqs: 5
|
||||
model_arch: Qwen3OmniMoeForConditionalGeneration
|
||||
worker_type: generation
|
||||
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
|
||||
|
||||
@@ -16,6 +16,8 @@ from vllm_omni.model_executor.stage_input_processors.qwen3_tts import (
|
||||
talker2code2wav_async_chunk,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
_FRAME = [1, 2, 3, 4]
|
||||
_Q = len(_FRAME)
|
||||
|
||||
@@ -90,7 +92,7 @@ def test_flush_on_finish():
|
||||
is_finished=True,
|
||||
)
|
||||
assert p is not None
|
||||
assert p["finished"].item() is True
|
||||
assert p["finished"] is True
|
||||
assert len(p["code_predictor_codes"]) == _Q * 24
|
||||
|
||||
|
||||
@@ -158,24 +160,29 @@ def test_dynamic_ic_adapts_to_load():
|
||||
|
||||
|
||||
def test_ic_load_change_mid_request():
|
||||
"""IC stateless: load spike mid-request shifts initial_coverage."""
|
||||
"""IC is cached per request; a load spike only affects new requests."""
|
||||
tm = _tm(chunk_frames=25, left_context=25, max_num_seqs=8)
|
||||
|
||||
# Low load -> IC=2 -> emit at frame 2
|
||||
# Low load -> IC=2 (cached for "r"), emit at frame 2
|
||||
p1 = _call(tm, "r", n_frames=2)
|
||||
assert p1 is not None
|
||||
|
||||
# Spike load: 6 others -> IC=16 -> initial_coverage=16
|
||||
# Spike load: 6 others running
|
||||
for i in range(6):
|
||||
tm.code_prompt_token_ids[f"other-{i}"] = [[0]] * 10
|
||||
|
||||
# adjusted=25-16=9, 9%25!=0 -> hold
|
||||
# IC for "r" is still cached as 2.
|
||||
# initial_coverage = ((25-1)//2)*2 = 24, first normal emit at 24+25=49
|
||||
assert _call(tm, "r", n_frames=25) is None
|
||||
|
||||
# First normal emit at 16+25=41
|
||||
p3 = _call(tm, "r", n_frames=41)
|
||||
assert _call(tm, "r", n_frames=27) is None
|
||||
p3 = _call(tm, "r", n_frames=49)
|
||||
assert p3 is not None
|
||||
assert p3["left_context_size"] == 16
|
||||
|
||||
# A *new* request under high load gets IC=16 (not IC=2).
|
||||
# Frame 2 would emit under IC=2 but must hold under IC=16.
|
||||
assert _call(tm, "new_req", n_frames=2) is None
|
||||
p4 = _call(tm, "new_req", n_frames=16)
|
||||
assert p4 is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -295,8 +302,13 @@ def test_non_async_processor_prepends_ref_code_and_sets_trim_context():
|
||||
],
|
||||
dtype=torch.long,
|
||||
)
|
||||
output = SimpleNamespace(multimodal_output={"audio_codes": audio_codes, "ref_code": ref_code})
|
||||
stage = SimpleNamespace(engine_outputs=[SimpleNamespace(outputs=[output])])
|
||||
output = SimpleNamespace(
|
||||
multimodal_output={"audio_codes": audio_codes, "ref_code": ref_code},
|
||||
token_ids=list(range(3)),
|
||||
)
|
||||
stage = SimpleNamespace(
|
||||
engine_outputs=[SimpleNamespace(outputs=[output], finished=True)],
|
||||
)
|
||||
|
||||
prompts = talker2code2wav(stage_list=[stage], engine_input_source=[0])
|
||||
|
||||
@@ -335,8 +347,13 @@ def test_non_async_processor_filters_out_of_range_codec_values():
|
||||
],
|
||||
dtype=torch.long,
|
||||
)
|
||||
output = SimpleNamespace(multimodal_output={"audio_codes": audio_codes, "ref_code": ref_code})
|
||||
stage = SimpleNamespace(engine_outputs=[SimpleNamespace(outputs=[output])])
|
||||
output = SimpleNamespace(
|
||||
multimodal_output={"audio_codes": audio_codes, "ref_code": ref_code},
|
||||
token_ids=list(range(4)),
|
||||
)
|
||||
stage = SimpleNamespace(
|
||||
engine_outputs=[SimpleNamespace(outputs=[output], finished=True)],
|
||||
)
|
||||
|
||||
prompts = talker2code2wav(stage_list=[stage], engine_input_source=[0])
|
||||
|
||||
|
||||
@@ -1122,7 +1122,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
)
|
||||
# Prefer GPU for encoder if available; otherwise keep CPU.
|
||||
dev = next(self.parameters()).device
|
||||
if getattr(dev, "type", None) == "cuda":
|
||||
if dev.type != "cpu":
|
||||
try:
|
||||
tok.model.to(dev)
|
||||
tok.device = dev
|
||||
@@ -1450,10 +1450,10 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
)
|
||||
if not speaker:
|
||||
raise ValueError("CustomVoice requires additional_information.speaker.")
|
||||
spk_id_map = getattr(self.talker_config, "spk_id", None) or {}
|
||||
spk_id_map = {k.lower(): v for k, v in (getattr(self.talker_config, "spk_id", None) or {}).items()}
|
||||
if speaker not in spk_id_map:
|
||||
raise ValueError(f"Unsupported speaker: {speaker}")
|
||||
spk_id = spk_id_map[speaker.lower()]
|
||||
spk_id = spk_id_map[speaker]
|
||||
# Keep it at least 1D; embedding on a 0-d tensor can return 1D.
|
||||
spk_tensor = torch.tensor([spk_id], device=input_ids.device, dtype=torch.long)
|
||||
spk_embed = self.embed_input_ids(spk_tensor)
|
||||
|
||||
Reference in new issue
Block a user