Review follow-ups for the inline video/audio reference decoder: - decode_data_url now undoes percent escapes and strips whitespace in 64 KiB chunks and stops as soon as the payload must decode to more than server.media_reference_max_bytes. An oversized request costs memory in proportion to the limit, not to its own size. Escapes are never split across chunks. - Data URL checks lower-case only the scheme instead of copying the whole URL. - Tests: the wrapped/percent-escaped payload decodes the same at chunk sizes that split escapes and line breaks; an oversized payload is refused after a few chunks; and a request cancelled before its decoding thread writes the file still leaves no upload once the thread finishes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
852 lines
31 KiB
Python
852 lines
31 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""CPU-light contracts for the shared OpenAI serving engine and adapter."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import os
|
|
from pathlib import Path
|
|
import threading
|
|
import time
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from fastvideo.api.parser import parse_config
|
|
from fastvideo.api.schema import GenerationRequest
|
|
from fastvideo.entrypoints.openai import media_policy, request_adapter
|
|
from fastvideo.entrypoints.openai.media_policy import MediaPolicy
|
|
from fastvideo.entrypoints.openai.protocol import UrlAudioReference, VideoGenerationRequest
|
|
from fastvideo.entrypoints.openai.request_adapter import (
|
|
RequestAdaptationError,
|
|
build_generation_request,
|
|
discard_materialized_media,
|
|
prepare_reference_media,
|
|
validate_served_model_name,
|
|
)
|
|
from fastvideo.entrypoints.openai.serving_engine import OpenAIServingEngine
|
|
from fastvideo.entrypoints.openai.stores import VIDEO_STORE
|
|
from fastvideo.entrypoints.openai.video_api import _media_type_for_path
|
|
|
|
|
|
class _BlockingGenerator:
|
|
|
|
def __init__(self) -> None:
|
|
self.started = threading.Event()
|
|
self.release = threading.Event()
|
|
self.shutdown_called = False
|
|
|
|
def generate(self, request):
|
|
self.started.set()
|
|
self.release.wait(timeout=5)
|
|
return request
|
|
|
|
def shutdown(self) -> None:
|
|
self.shutdown_called = True
|
|
|
|
|
|
class _WaveGenerator:
|
|
|
|
def __init__(self) -> None:
|
|
self.last_output: Path | None = None
|
|
|
|
def generate(self, request):
|
|
output = Path(request.output.output_path)
|
|
self.last_output = output
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_bytes(b"RIFF\x00\x00\x00\x00WAVEfmt fastvideo-test")
|
|
return SimpleNamespace(
|
|
video_path=str(output),
|
|
generation_time=0.01,
|
|
peak_memory_mb=12.5,
|
|
logging_info=None,
|
|
)
|
|
|
|
def shutdown(self) -> None:
|
|
return None
|
|
|
|
|
|
class _FileGenerator:
|
|
|
|
def __init__(self) -> None:
|
|
self.last_output: Path | None = None
|
|
|
|
def generate(self, request):
|
|
output = Path(request.output.output_path)
|
|
self.last_output = output
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_bytes(b"\x00\x00\x00\x18ftypmp42fastvideo-test")
|
|
return SimpleNamespace(
|
|
video_path=str(output),
|
|
generation_time=0.01,
|
|
peak_memory_mb=12.5,
|
|
logging_info=None,
|
|
)
|
|
|
|
def shutdown(self) -> None:
|
|
return None
|
|
|
|
|
|
def _args(model_path: str, **overrides):
|
|
values = {
|
|
"model_path": model_path,
|
|
"lora_path": None,
|
|
"lora_nickname": "default",
|
|
"lora_strength": 1.0,
|
|
"override_pipeline_cls_name": None,
|
|
}
|
|
values.update(overrides)
|
|
return SimpleNamespace(**values)
|
|
|
|
|
|
def test_engine_cancellation_keeps_pipeline_locked() -> None:
|
|
asyncio.run(_assert_engine_cancellation_keeps_pipeline_locked())
|
|
|
|
|
|
async def _assert_engine_cancellation_keeps_pipeline_locked() -> None:
|
|
generator = _BlockingGenerator()
|
|
engine = OpenAIServingEngine(generator) # type: ignore[arg-type]
|
|
|
|
first = asyncio.create_task(engine.generate("first")) # type: ignore[arg-type]
|
|
await asyncio.to_thread(generator.started.wait, 2)
|
|
first.cancel()
|
|
await asyncio.sleep(0)
|
|
first.cancel()
|
|
|
|
second_started = threading.Event()
|
|
|
|
def second_call():
|
|
second_started.set()
|
|
return "second"
|
|
|
|
second = asyncio.create_task(engine.run_serialized(second_call))
|
|
await asyncio.sleep(0.05)
|
|
assert not second_started.is_set()
|
|
|
|
generator.release.set()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await first
|
|
assert await second == "second"
|
|
|
|
await engine.shutdown()
|
|
assert generator.shutdown_called
|
|
|
|
|
|
def test_protocol_rejects_unknown_and_client_output_fields() -> None:
|
|
with pytest.raises(Exception, match="prompt_path"):
|
|
VideoGenerationRequest(prompt="a fox", prompt_path="/srv/prompts.txt")
|
|
with pytest.raises(Exception, match="output_path"):
|
|
VideoGenerationRequest(prompt="a fox", output_path="/srv/keep.mp4")
|
|
|
|
|
|
def test_validate_served_model_name_accepts_omitted_or_matching_name() -> None:
|
|
args = _args("Wan-AI/Wan2.1-T2V-1.3B-Diffusers")
|
|
validate_served_model_name(None, args, "wan")
|
|
validate_served_model_name("wan", args, "wan")
|
|
|
|
|
|
def test_validate_served_model_name_rejects_mismatch() -> None:
|
|
with pytest.raises(RequestAdaptationError, match="this server provides wan"):
|
|
validate_served_model_name("other-model", _args("Wan-AI/Wan2.1-T2V-1.3B-Diffusers"), "wan")
|
|
|
|
|
|
def test_validate_served_model_name_uses_lora_nickname() -> None:
|
|
args = _args(
|
|
"Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
|
|
lora_path="/models/startup.safetensors",
|
|
lora_nickname="fast",
|
|
)
|
|
validate_served_model_name("fast", args, "wan")
|
|
with pytest.raises(RequestAdaptationError, match="this server provides fast"):
|
|
validate_served_model_name("wan", args, "wan")
|
|
|
|
|
|
def test_request_adapter_restricts_extra_params(tmp_path: Path) -> None:
|
|
request = VideoGenerationRequest(prompt="a fox", extra_params={"prompt_path": "/srv/prompts.txt"})
|
|
with pytest.raises(RequestAdaptationError, match="Unsupported extra_params fields: prompt_path"):
|
|
build_generation_request(
|
|
"video_gen_test",
|
|
request,
|
|
_args("Wan-AI/Wan2.1-T2V-1.3B-Diffusers"),
|
|
served_model_name="wan",
|
|
output_dir=str(tmp_path),
|
|
)
|
|
|
|
|
|
def test_request_adapter_owns_output_path(tmp_path: Path) -> None:
|
|
request = VideoGenerationRequest(prompt="a fox", size="64x64", num_frames=1)
|
|
default_request = parse_config(GenerationRequest, {"output": {"output_path": "/srv/untrusted-default.mp4"}})
|
|
adapted = build_generation_request(
|
|
"video_gen_test",
|
|
request,
|
|
_args("Wan-AI/Wan2.1-T2V-1.3B-Diffusers"),
|
|
served_model_name="wan",
|
|
output_dir=str(tmp_path),
|
|
default_request=default_request,
|
|
)
|
|
assert adapted.output.output_path == str(tmp_path / "videos" / "video_gen_test.mp4")
|
|
|
|
|
|
def test_audio_request_maps_seconds_to_duration_and_wav_output(tmp_path: Path) -> None:
|
|
request = VideoGenerationRequest(prompt="rain on a metal roof", seconds=4, audio_start_in_s=0)
|
|
adapted = build_generation_request(
|
|
"audio_gen_test",
|
|
request,
|
|
_args("FastVideo/stable-audio-open-1.0-Diffusers", workload_type=SimpleNamespace(value="t2a")),
|
|
served_model_name="stable-audio",
|
|
output_dir=str(tmp_path),
|
|
)
|
|
assert adapted.sampling.audio_start_in_s == 0.0
|
|
assert adapted.sampling.audio_end_in_s == 4.0
|
|
assert adapted.output.output_path == str(tmp_path / "audio" / "audio_gen_test.wav")
|
|
|
|
|
|
def test_audio_request_rejects_video_geometry(tmp_path: Path) -> None:
|
|
request = VideoGenerationRequest(prompt="rain", size="512x512")
|
|
with pytest.raises(RequestAdaptationError, match="does not accept size"):
|
|
build_generation_request(
|
|
"audio_gen_test",
|
|
request,
|
|
_args("FastVideo/stable-audio-open-1.0-Diffusers", workload_type=SimpleNamespace(value="t2a")),
|
|
served_model_name="stable-audio",
|
|
output_dir=str(tmp_path),
|
|
)
|
|
|
|
|
|
def test_audio_seconds_override_operator_duration(tmp_path: Path) -> None:
|
|
default_request = parse_config(
|
|
GenerationRequest,
|
|
{"sampling": {
|
|
"audio_start_in_s": 1.0,
|
|
"audio_end_in_s": 10.0,
|
|
}},
|
|
)
|
|
request = VideoGenerationRequest(prompt="rain", seconds=4)
|
|
adapted = build_generation_request(
|
|
"audio_gen_test",
|
|
request,
|
|
_args("FastVideo/stable-audio-open-1.0-Diffusers", workload_type=SimpleNamespace(value="t2a")),
|
|
served_model_name="stable-audio",
|
|
output_dir=str(tmp_path),
|
|
default_request=default_request,
|
|
)
|
|
assert adapted.sampling.audio_start_in_s == 1.0
|
|
assert adapted.sampling.audio_end_in_s == 5.0
|
|
|
|
|
|
def test_wav_media_type_is_detected_from_output_path() -> None:
|
|
assert _media_type_for_path("/tmp/request.wav") == "audio/wav"
|
|
assert _media_type_for_path("/tmp/request.MP4") == "video/mp4"
|
|
|
|
|
|
def test_missing_video_reference_fails_during_admission(tmp_path: Path) -> None:
|
|
request = VideoGenerationRequest(prompt="waves", video_reference={"video_url": str(tmp_path / "missing.mp4")})
|
|
with pytest.raises(RequestAdaptationError, match="does not exist"):
|
|
asyncio.run(
|
|
prepare_reference_media("request", request, str(tmp_path), policy=MediaPolicy(roots=(str(tmp_path), ))))
|
|
|
|
|
|
def test_local_media_paths_need_a_configured_root(tmp_path: Path) -> None:
|
|
clip = tmp_path / "clip.mp4"
|
|
clip.write_bytes(b"x")
|
|
request = VideoGenerationRequest(prompt="waves", video_reference={"video_url": str(clip)})
|
|
with pytest.raises(RequestAdaptationError, match="server.media_roots"):
|
|
asyncio.run(prepare_reference_media("request", request, str(tmp_path), policy=MediaPolicy()))
|
|
|
|
|
|
def _data_url(media_type: str, payload: bytes) -> str:
|
|
return f"data:{media_type};base64,{base64.b64encode(payload).decode()}"
|
|
|
|
|
|
def _ref(kind: str, url: str) -> dict[str, str]:
|
|
return {f"{kind}_url": url}
|
|
|
|
|
|
def _uploads(tmp_path: Path) -> list[Path]:
|
|
return list((tmp_path / "uploads").glob("*"))
|
|
|
|
|
|
def test_inline_video_and_audio_references_are_decoded_in_order(tmp_path: Path) -> None:
|
|
request = VideoGenerationRequest(
|
|
prompt="waves",
|
|
video_reference=[
|
|
_ref("video", _data_url("video/mp4", b"first-clip")),
|
|
_ref("video", _data_url("video/quicktime", b"second-clip")),
|
|
],
|
|
audio_reference=[
|
|
_ref("audio", _data_url("audio/mpeg", b"first-track")),
|
|
_ref("audio", _data_url("audio/wav", b"second-track")),
|
|
],
|
|
)
|
|
created = asyncio.run(prepare_reference_media("request", request, str(tmp_path), policy=MediaPolicy()))
|
|
assert isinstance(request.video_reference, list) and isinstance(request.audio_reference, list)
|
|
videos = [reference.video_url for reference in request.video_reference]
|
|
audios = [reference.audio_url for reference in request.audio_reference]
|
|
assert created == videos + audios
|
|
contents = [Path(path).read_bytes() for path in created]
|
|
assert contents == [b"first-clip", b"second-clip", b"first-track", b"second-track"]
|
|
assert [Path(path).suffix for path in created] == [".mp4", ".mov", ".mp3", ".wav"]
|
|
assert {Path(path).parent for path in created} == {tmp_path / "uploads"}
|
|
discard_materialized_media(created)
|
|
assert not _uploads(tmp_path)
|
|
|
|
|
|
@pytest.mark.parametrize("chunk_chars", [media_policy.DATA_URL_CHUNK_CHARS, 3, 4, 5, 7])
|
|
def test_inline_reference_payload_may_be_wrapped_and_percent_escaped(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
chunk_chars: int) -> None:
|
|
# Small chunks put chunk boundaries inside percent escapes and line breaks.
|
|
monkeypatch.setattr(media_policy, "DATA_URL_CHUNK_CHARS", chunk_chars)
|
|
track = bytes(range(256)) * 2
|
|
encoded = base64.b64encode(track).decode()
|
|
lines = [encoded[start:start + 16] for start in range(0, len(encoded), 16)]
|
|
wrapped = "".join(line + ("%0A" if number % 2 else "\r\n") for number, line in enumerate(lines))
|
|
escaped = wrapped.replace("+", "%2B").replace("/", "%2f").replace("=", "%3D")
|
|
request = VideoGenerationRequest(prompt="waves", audio_reference={"audio_url": f"data:audio/flac;base64,{escaped}"})
|
|
created = asyncio.run(prepare_reference_media("request", request, str(tmp_path), policy=MediaPolicy()))
|
|
assert Path(created[0]).read_bytes() == track
|
|
assert created[0].endswith(".flac")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("kind", "url", "message"),
|
|
[
|
|
("video", "data:video/mp4;base64,not*base64", "not valid base64"),
|
|
("audio", "data:audio/mpeg;base64,", "payload is empty"),
|
|
("video", "data:video/mp4,raw-bytes", "must be base64-encoded"),
|
|
("video", _data_url("audio/mpeg", b"track"), "need a data:video/"),
|
|
("audio", _data_url("video/mp4", b"clip"), "need a data:audio/"),
|
|
("audio", _data_url("application/octet-stream", b"track"), "need a data:audio/"),
|
|
("image", _data_url("video/mp4", b"clip"), "need a data:image/"),
|
|
],
|
|
)
|
|
def test_invalid_inline_references_are_refused(tmp_path: Path, kind: str, url: str, message: str) -> None:
|
|
request = VideoGenerationRequest(prompt="waves", **{f"{kind}_reference": _ref(kind, url)})
|
|
with pytest.raises(RequestAdaptationError, match=message):
|
|
asyncio.run(prepare_reference_media("request", request, str(tmp_path), policy=MediaPolicy()))
|
|
assert not _uploads(tmp_path)
|
|
|
|
|
|
def test_inline_reference_over_the_media_limit_is_refused(tmp_path: Path) -> None:
|
|
request = VideoGenerationRequest(prompt="waves", audio_reference={"audio_url": _data_url("audio/wav", b"12345")})
|
|
with pytest.raises(RequestAdaptationError, match="4-byte limit"):
|
|
asyncio.run(prepare_reference_media("request", request, str(tmp_path), max_bytes=4, policy=MediaPolicy()))
|
|
assert not _uploads(tmp_path)
|
|
at_limit = VideoGenerationRequest(prompt="waves", video_reference={"video_url": _data_url("video/mp4", b"1234")})
|
|
created = asyncio.run(prepare_reference_media("request", at_limit, str(tmp_path), max_bytes=4,
|
|
policy=MediaPolicy()))
|
|
assert Path(created[0]).read_bytes() == b"1234"
|
|
|
|
|
|
def test_oversized_inline_payload_is_refused_before_it_is_all_read(tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(media_policy, "DATA_URL_CHUNK_CHARS", 8)
|
|
unescaped: list[int] = []
|
|
original_unquote = media_policy.unquote
|
|
|
|
def counting_unquote(chunk: str) -> str:
|
|
unescaped.append(len(chunk))
|
|
return original_unquote(chunk)
|
|
|
|
monkeypatch.setattr(media_policy, "unquote", counting_unquote)
|
|
source = "data:video/mp4;base64," + "QU%4AB\n" * 10_000
|
|
with pytest.raises(media_policy.MediaPolicyError, match="4-byte limit"):
|
|
media_policy.decode_data_url(source, str(tmp_path / "clip.mp4"), max_bytes=4)
|
|
assert max(unescaped) <= 8 and sum(unescaped) < 64
|
|
assert not (tmp_path / "clip.mp4").exists()
|
|
|
|
|
|
def test_decoded_references_are_removed_when_a_later_reference_fails(tmp_path: Path) -> None:
|
|
request = VideoGenerationRequest(
|
|
prompt="waves",
|
|
video_reference={"video_url": _data_url("video/mp4", b"clip")},
|
|
audio_reference={"audio_url": "data:audio/mpeg;base64,%%%"},
|
|
)
|
|
with pytest.raises(RequestAdaptationError, match="not valid base64"):
|
|
asyncio.run(prepare_reference_media("request", request, str(tmp_path), policy=MediaPolicy()))
|
|
assert not _uploads(tmp_path)
|
|
|
|
|
|
def test_cancelled_decoding_removes_its_file_once_the_thread_finishes(tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch) -> None:
|
|
asyncio.run(_assert_cancelled_decoding_removes_its_file(tmp_path, monkeypatch))
|
|
|
|
|
|
async def _assert_cancelled_decoding_removes_its_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
started, release, finished = threading.Event(), threading.Event(), threading.Event()
|
|
written: list[bool] = []
|
|
|
|
def decode_after_release(source: str, target: str, *, max_bytes: int) -> None:
|
|
started.set()
|
|
release.wait(timeout=5)
|
|
try:
|
|
media_policy.decode_data_url(source, target, max_bytes=max_bytes)
|
|
written.append(os.path.exists(target))
|
|
finally:
|
|
finished.set()
|
|
|
|
monkeypatch.setattr(request_adapter, "decode_data_url", decode_after_release)
|
|
request = VideoGenerationRequest(prompt="waves", video_reference={"video_url": _data_url("video/mp4", b"clip")})
|
|
preparing = asyncio.create_task(prepare_reference_media("request", request, str(tmp_path), policy=MediaPolicy()))
|
|
assert await asyncio.to_thread(started.wait, 5)
|
|
preparing.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await preparing
|
|
# The thread writes its file only after the request is gone, so cleanup has to wait for the thread.
|
|
release.set()
|
|
assert await asyncio.to_thread(finished.wait, 5)
|
|
assert written == [True]
|
|
deadline = time.monotonic() + 5
|
|
while _uploads(tmp_path) and time.monotonic() < deadline:
|
|
await asyncio.sleep(0.01)
|
|
assert not _uploads(tmp_path)
|
|
|
|
|
|
def test_local_audio_paths_need_a_configured_root(tmp_path: Path) -> None:
|
|
track = tmp_path / "track.wav"
|
|
track.write_bytes(b"x")
|
|
request = VideoGenerationRequest(prompt="waves", audio_reference={"audio_url": str(track)})
|
|
with pytest.raises(RequestAdaptationError, match="server.media_roots"):
|
|
asyncio.run(prepare_reference_media("request", request, str(tmp_path), policy=MediaPolicy()))
|
|
created = asyncio.run(
|
|
prepare_reference_media("request", request, str(tmp_path), policy=MediaPolicy(roots=(str(tmp_path), ))))
|
|
assert created == []
|
|
assert isinstance(request.audio_reference, UrlAudioReference)
|
|
assert request.audio_reference.audio_url == os.path.realpath(track)
|
|
|
|
|
|
def test_mmaudio_v2a_requires_video_reference(tmp_path: Path) -> None:
|
|
request = VideoGenerationRequest(prompt="waves")
|
|
with pytest.raises(RequestAdaptationError, match="requires video_reference"):
|
|
build_generation_request(
|
|
"audio_gen_test",
|
|
request,
|
|
_args("FastVideo/MMAudio-large-44k-v2-Diffusers", workload_type=SimpleNamespace(value="v2a")),
|
|
served_model_name="mmaudio",
|
|
output_dir=str(tmp_path),
|
|
)
|
|
|
|
|
|
def test_missing_reference_fails_during_admission(tmp_path: Path) -> None:
|
|
request = VideoGenerationRequest(prompt="a fox", input_reference=str(tmp_path / "missing.png"))
|
|
with pytest.raises(RequestAdaptationError, match="does not exist"):
|
|
asyncio.run(
|
|
prepare_reference_media("request", request, str(tmp_path), policy=MediaPolicy(roots=(str(tmp_path), ))))
|
|
|
|
|
|
def test_request_adapter_resolves_vllm_nested_params(tmp_path: Path) -> None:
|
|
request = VideoGenerationRequest(
|
|
prompt="a fox",
|
|
video_params={
|
|
"width": 832,
|
|
"height": 480,
|
|
"fps": 30
|
|
},
|
|
seconds="2",
|
|
seed=7,
|
|
)
|
|
adapted = build_generation_request(
|
|
"video_gen_test",
|
|
request,
|
|
_args("Wan-AI/Wan2.1-T2V-1.3B-Diffusers"),
|
|
served_model_name="wan",
|
|
output_dir=str(tmp_path),
|
|
)
|
|
|
|
assert adapted.sampling.width == 832
|
|
assert adapted.sampling.height == 480
|
|
assert adapted.sampling.fps == 30
|
|
assert adapted.sampling.num_frames == 60
|
|
assert adapted.sampling.seed == 7
|
|
assert adapted.output.output_path.endswith("video_gen_test.mp4")
|
|
|
|
|
|
def test_request_adapter_accepts_matching_startup_lora(tmp_path: Path) -> None:
|
|
adapter = str(tmp_path / "adapter.safetensors")
|
|
args = _args(
|
|
"Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
|
|
lora_path=adapter,
|
|
lora_nickname="fast",
|
|
lora_strength=0.75,
|
|
)
|
|
request = VideoGenerationRequest(
|
|
prompt="a fox",
|
|
model="fast",
|
|
lora={
|
|
"name": "fast",
|
|
"path": adapter,
|
|
"scale": 0.75
|
|
},
|
|
)
|
|
|
|
build_generation_request(
|
|
"video_gen_test",
|
|
request,
|
|
args,
|
|
served_model_name="wan",
|
|
output_dir=str(tmp_path),
|
|
)
|
|
|
|
|
|
def test_request_adapter_rejects_runtime_lora_swap(tmp_path: Path) -> None:
|
|
args = _args(
|
|
"Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
|
|
lora_path="/models/startup.safetensors",
|
|
lora_nickname="fast",
|
|
)
|
|
request = VideoGenerationRequest(prompt="a fox", lora={"path": "/models/other.safetensors"})
|
|
|
|
with pytest.raises(RequestAdaptationError, match="does not match the startup adapter"):
|
|
build_generation_request(
|
|
"video_gen_test",
|
|
request,
|
|
args,
|
|
served_model_name="wan",
|
|
output_dir=str(tmp_path),
|
|
)
|
|
|
|
|
|
def test_fasth3_request_uses_the_general_adapter(tmp_path: Path) -> None:
|
|
request = VideoGenerationRequest(
|
|
prompt="a fox",
|
|
task="t2va",
|
|
aspect_ratio="16:9",
|
|
num_frames=124,
|
|
num_inference_steps=5,
|
|
guidance_scale=1.0,
|
|
)
|
|
adapted = build_generation_request(
|
|
"video_gen_test",
|
|
request,
|
|
_args("FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2"),
|
|
served_model_name="fasth3",
|
|
output_dir=str(tmp_path),
|
|
)
|
|
|
|
assert adapted.sampling.width == 1344
|
|
assert adapted.sampling.height == 768
|
|
assert adapted.sampling.num_frames == 124
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("overrides", "message"),
|
|
[
|
|
({
|
|
"aspect_ratio": "16:9",
|
|
"num_frames": 22
|
|
}, "generates 5-15 seconds"),
|
|
({
|
|
"size": "100x100",
|
|
"num_frames": 124
|
|
}, "positive multiples of 32"),
|
|
({
|
|
"aspect_ratio": "1:9",
|
|
"num_frames": 124
|
|
}, "supports aspect ratios"),
|
|
({
|
|
"aspect_ratio": "16:9",
|
|
"num_frames": 120
|
|
}, "causal-VAE grid"),
|
|
],
|
|
)
|
|
def test_fasth3_invalid_geometry_fails_at_admission(tmp_path: Path, overrides, message) -> None:
|
|
request = VideoGenerationRequest(prompt="a fox", **overrides)
|
|
with pytest.raises(RequestAdaptationError, match=message):
|
|
build_generation_request(
|
|
"video_gen_test",
|
|
request,
|
|
_args("FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2"),
|
|
served_model_name="fasth3",
|
|
output_dir=str(tmp_path),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(("seconds", "expected_frames"), [("5", 124), ("15", 362)])
|
|
def test_fasth3_seconds_align_to_causal_vae_grid(tmp_path: Path, seconds: str, expected_frames: int) -> None:
|
|
request = VideoGenerationRequest(prompt="a fox", aspect_ratio="16:9", seconds=seconds)
|
|
adapted = build_generation_request(
|
|
"video_gen_test",
|
|
request,
|
|
_args("FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2"),
|
|
served_model_name="fasth3",
|
|
output_dir=str(tmp_path),
|
|
)
|
|
assert adapted.sampling.num_frames == expected_frames
|
|
|
|
|
|
def test_fasth3_ref2va_limits_references_at_admission(tmp_path: Path) -> None:
|
|
request = VideoGenerationRequest(
|
|
prompt="a fox",
|
|
task="ref2va",
|
|
aspect_ratio="16:9",
|
|
num_frames=124,
|
|
image_reference=[{
|
|
"image_url": f"/tmp/reference-{index}.png"
|
|
} for index in range(10)],
|
|
)
|
|
with pytest.raises(RequestAdaptationError, match="at most 9 image references"):
|
|
build_generation_request(
|
|
"video_gen_test",
|
|
request,
|
|
_args(
|
|
"MiniMaxAI/MiniMax-H3",
|
|
override_pipeline_cls_name="MiniMaxH3Ref2VAModularPipeline",
|
|
),
|
|
served_model_name="minimax-h3",
|
|
output_dir=str(tmp_path),
|
|
)
|
|
|
|
|
|
def test_aspect_ratio_overrides_operator_dimensions(tmp_path: Path) -> None:
|
|
default_request = parse_config(
|
|
GenerationRequest,
|
|
{"sampling": {
|
|
"width": 1344,
|
|
"height": 768,
|
|
"num_frames": 124,
|
|
}},
|
|
)
|
|
request = VideoGenerationRequest(prompt="a fox", aspect_ratio="9:16")
|
|
adapted = build_generation_request(
|
|
"video_gen_test",
|
|
request,
|
|
_args("FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2"),
|
|
served_model_name="fasth3",
|
|
output_dir=str(tmp_path),
|
|
default_request=default_request,
|
|
)
|
|
assert adapted.sampling.width == 768
|
|
assert adapted.sampling.height == 1344
|
|
|
|
|
|
def test_explicit_null_uses_seconds_and_operator_fps(tmp_path: Path) -> None:
|
|
default_request = parse_config(
|
|
GenerationRequest,
|
|
{"sampling": {
|
|
"fps": 30,
|
|
"num_frames": 99,
|
|
}},
|
|
)
|
|
request = VideoGenerationRequest(prompt="a fox", seconds=2, fps=None, num_frames=None)
|
|
adapted = build_generation_request(
|
|
"video_gen_test",
|
|
request,
|
|
_args("Wan-AI/Wan2.1-T2V-1.3B-Diffusers"),
|
|
served_model_name="wan",
|
|
output_dir=str(tmp_path),
|
|
default_request=default_request,
|
|
)
|
|
assert adapted.sampling.fps == 30
|
|
assert adapted.sampling.num_frames == 60
|
|
|
|
|
|
def test_unsupported_vllm_postprocessing_fails_at_admission(tmp_path: Path) -> None:
|
|
request = VideoGenerationRequest(prompt="a fox", enable_frame_interpolation=True)
|
|
|
|
with pytest.raises(RequestAdaptationError, match="frame_interpolation"):
|
|
build_generation_request(
|
|
"video_gen_test",
|
|
request,
|
|
_args("Wan-AI/Wan2.1-T2V-1.3B-Diffusers"),
|
|
served_model_name="wan",
|
|
output_dir=str(tmp_path),
|
|
)
|
|
|
|
|
|
def test_audio_jobs_publish_wav_media_type_and_seconds(tmp_path: Path) -> None:
|
|
from fastvideo.entrypoints.openai import state
|
|
from fastvideo.entrypoints.openai.video_api import router
|
|
|
|
generator = _WaveGenerator()
|
|
engine = OpenAIServingEngine(generator) # type: ignore[arg-type,unused-ignore]
|
|
args = _args("FastVideo/stable-audio-open-1.0-Diffusers", workload_type=SimpleNamespace(value="t2a"))
|
|
state.set_state(
|
|
generator, # type: ignore[arg-type]
|
|
engine,
|
|
args, # type: ignore[arg-type]
|
|
str(tmp_path),
|
|
served_model_name="stable-audio",
|
|
)
|
|
asyncio.run(VIDEO_STORE.clear())
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
|
|
try:
|
|
with TestClient(app) as client:
|
|
created = client.post(
|
|
"/v1/videos",
|
|
json={
|
|
"model": "stable-audio",
|
|
"prompt": "rain",
|
|
"seconds": 3
|
|
},
|
|
)
|
|
assert created.status_code == 200
|
|
audio_id = created.json()["id"]
|
|
|
|
for _ in range(50):
|
|
detail = client.get(f"/v1/videos/{audio_id}")
|
|
if detail.json()["status"] == "completed":
|
|
break
|
|
time.sleep(0.01)
|
|
body = detail.json()
|
|
assert body["status"] == "completed"
|
|
assert body["media_type"] == "audio/wav"
|
|
assert body["seconds"] == "3"
|
|
assert body["size"] is None
|
|
assert body["file_name"].endswith(".wav")
|
|
|
|
content = client.get(f"/v1/videos/{audio_id}/content")
|
|
assert content.status_code == 200
|
|
assert content.headers["content-type"] == "audio/wav"
|
|
assert content.content.startswith(b"RIFF")
|
|
|
|
sync = client.post("/v1/videos/sync", json={"prompt": "rain", "seconds": 2})
|
|
assert sync.status_code == 200
|
|
assert sync.headers["content-type"] == "audio/wav"
|
|
assert sync.content.startswith(b"RIFF")
|
|
assert generator.last_output is not None
|
|
assert not generator.last_output.exists()
|
|
finally:
|
|
asyncio.run(VIDEO_STORE.clear())
|
|
state.clear_state()
|
|
|
|
|
|
class _ReferenceGenerator(_FileGenerator):
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.references: list[tuple[str, bytes]] = []
|
|
|
|
def generate(self, request):
|
|
self.references = [(reference.media_type, Path(reference.source).read_bytes())
|
|
for reference in request.inputs.references]
|
|
return super().generate(request)
|
|
|
|
|
|
def test_ref2va_route_accepts_inline_references_and_removes_them(tmp_path: Path) -> None:
|
|
from fastvideo.entrypoints.openai import state
|
|
from fastvideo.entrypoints.openai.video_api import router
|
|
|
|
generator = _ReferenceGenerator()
|
|
engine = OpenAIServingEngine(generator) # type: ignore[arg-type]
|
|
args = _args("MiniMaxAI/MiniMax-H3", override_pipeline_cls_name="MiniMaxH3Ref2VAModularPipeline")
|
|
state.set_state(
|
|
generator, # type: ignore[arg-type]
|
|
engine,
|
|
args, # type: ignore[arg-type]
|
|
str(tmp_path),
|
|
served_model_name="minimax-h3",
|
|
)
|
|
asyncio.run(VIDEO_STORE.clear())
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
body = dict(
|
|
model="minimax-h3",
|
|
prompt="a fox sings along",
|
|
task="ref2va",
|
|
aspect_ratio="16:9",
|
|
num_frames=124,
|
|
video_reference=[_ref("video", _data_url("video/mp4", b"clip-with-sound"))],
|
|
audio_reference=[
|
|
_ref("audio", _data_url("audio/mpeg", b"first-voice")),
|
|
_ref("audio", _data_url("audio/wav", b"second-voice")),
|
|
],
|
|
)
|
|
|
|
try:
|
|
with TestClient(app) as client:
|
|
sync = client.post("/v1/videos/sync", json=body)
|
|
assert sync.status_code == 200, sync.text
|
|
assert generator.references == [
|
|
("video", b"clip-with-sound"),
|
|
("audio", b"first-voice"),
|
|
("audio", b"second-voice"),
|
|
]
|
|
assert not _uploads(tmp_path)
|
|
|
|
mismatched = {**body, "audio_reference": [_ref("audio", _data_url("video/mp4", b"clip"))]}
|
|
refused = client.post("/v1/videos", json=mismatched)
|
|
assert refused.status_code == 400
|
|
assert "need a data:audio/" in refused.text
|
|
assert not _uploads(tmp_path)
|
|
finally:
|
|
asyncio.run(VIDEO_STORE.clear())
|
|
state.clear_state()
|
|
|
|
|
|
def test_video_routes_cover_async_sync_list_content_and_delete(tmp_path: Path) -> None:
|
|
from fastvideo.entrypoints.openai import state
|
|
from fastvideo.entrypoints.openai.video_api import router
|
|
|
|
generator = _FileGenerator()
|
|
engine = OpenAIServingEngine(generator) # type: ignore[arg-type]
|
|
args = _args("Wan-AI/Wan2.1-T2V-1.3B-Diffusers")
|
|
state.set_state(
|
|
generator, # type: ignore[arg-type]
|
|
engine,
|
|
args, # type: ignore[arg-type]
|
|
str(tmp_path),
|
|
served_model_name="wan-test",
|
|
)
|
|
asyncio.run(VIDEO_STORE.clear())
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
|
|
try:
|
|
with TestClient(app) as client:
|
|
created = client.post(
|
|
"/v1/videos",
|
|
json={
|
|
"model": "wan-test",
|
|
"prompt": "a fox",
|
|
"size": "64x64",
|
|
"num_frames": 1,
|
|
},
|
|
)
|
|
assert created.status_code == 200
|
|
video_id = created.json()["id"]
|
|
|
|
for _ in range(50):
|
|
detail = client.get(f"/v1/videos/{video_id}")
|
|
if detail.json()["status"] == "completed":
|
|
break
|
|
time.sleep(0.01)
|
|
assert detail.status_code == 200
|
|
assert detail.json()["status"] == "completed"
|
|
|
|
listing = client.get("/v1/videos", params={"limit": 1})
|
|
assert listing.status_code == 200
|
|
assert listing.json()["data"][0]["id"] == video_id
|
|
|
|
content = client.get(f"/v1/videos/{video_id}/content")
|
|
assert content.status_code == 200
|
|
assert content.content[4:8] == b"ftyp"
|
|
|
|
deleted = client.delete(f"/v1/videos/{video_id}")
|
|
assert deleted.status_code == 200
|
|
assert deleted.json() == {
|
|
"id": video_id,
|
|
"deleted": True,
|
|
"object": "video.deleted",
|
|
}
|
|
|
|
sync = client.post(
|
|
"/v1/videos/sync",
|
|
json={
|
|
"prompt": "a fox",
|
|
"size": "64x64",
|
|
"num_frames": 1,
|
|
},
|
|
)
|
|
assert sync.status_code == 200
|
|
assert sync.headers["x-model"] == "wan-test"
|
|
assert sync.content[4:8] == b"ftyp"
|
|
assert generator.last_output is not None
|
|
assert not generator.last_output.exists()
|
|
finally:
|
|
asyncio.run(VIDEO_STORE.clear())
|
|
state.clear_state()
|