736 lines
28 KiB
Python
736 lines
28 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""FastAPI round-trip tests for the text-to-video routes.
|
|
|
|
The video backend is replaced with a lightweight fake, so these exercise the
|
|
route wiring, validation, error mapping, and response shapes without torch,
|
|
diffusers, weights, or a GPU. The gallery persists to a real tmp directory
|
|
(via a patched gallery_dir), so the file/list/delete/clear paths run the actual
|
|
video_gallery code.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
import core.inference.gpu_arbiter as gpu_arbiter
|
|
import core.inference.video as video_module
|
|
import core.inference.video_gallery as gallery_module
|
|
from auth.authentication import get_current_subject
|
|
from core.inference.video_families import (
|
|
VIDEO_CANCELLED_MSG,
|
|
VIDEO_GENERATION_BUSY_MSG,
|
|
VIDEO_NOT_LOADED_MSG,
|
|
)
|
|
from routes.video import router as video_router
|
|
|
|
|
|
def _defaults():
|
|
return {
|
|
"steps": 40,
|
|
"guidance": 4.0,
|
|
"num_frames": 121,
|
|
"fps": 24,
|
|
"frame_step": 8,
|
|
"resolution_multiple": 32,
|
|
"resolution_presets": [[768, 512], [1216, 704]],
|
|
}
|
|
|
|
|
|
def _unloaded_status():
|
|
return {
|
|
"loaded": False,
|
|
"repo_id": None,
|
|
"family": None,
|
|
"base_repo": None,
|
|
"device": None,
|
|
"dtype": None,
|
|
"model_kind": None,
|
|
"offload_policy": None,
|
|
"vae_tiling": False,
|
|
"memory_mode": None,
|
|
"speed_mode": None,
|
|
"speed_optims": [],
|
|
"attention_backend": None,
|
|
"transformer_cache": None,
|
|
"transformer_quant": None,
|
|
"text_encoder_quant": None,
|
|
"has_audio": False,
|
|
"defaults": None,
|
|
"resolved": None,
|
|
}
|
|
|
|
|
|
class _FakeBackend(video_module.VideoBackend):
|
|
"""Overrides the heavy load/generate/status surface but INHERITS the real
|
|
begin_generate / _run_generate / generate_progress / cancel_generate job
|
|
machinery, so the asynchronous generate contract (immediate accept, busy
|
|
guard, terminal completed/failed state, cancel) is exercised for real."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.last_load_kwargs: dict = {}
|
|
# Repo ids of in-flight (not yet committed) loads; empty tuple = none. The unload
|
|
# route reads this to keep VIDEO ownership while a concurrent load is still loading.
|
|
self.loading: tuple = ()
|
|
|
|
# The real backend keys "loaded" off its committed pipeline state (_state); map
|
|
# the fake's flag onto it so the inherited begin_generate sees the same thing.
|
|
@property
|
|
def loaded(self) -> bool:
|
|
return self._state is not None
|
|
|
|
@loaded.setter
|
|
def loaded(self, value: bool) -> None:
|
|
self._state = object() if value else None
|
|
|
|
def loading_repo_ids(self) -> tuple:
|
|
return tuple(self.loading)
|
|
|
|
def validate_load_request(
|
|
self,
|
|
model_path,
|
|
*,
|
|
gguf_filename = None,
|
|
base_repo = None,
|
|
family_override = None,
|
|
model_kind = None,
|
|
transformer_quant = None,
|
|
text_encoder_quant = None,
|
|
):
|
|
# Mirror the real backend's cheap validation so the route's
|
|
# validate-before-evict ordering is exercised.
|
|
from pathlib import Path
|
|
|
|
kind = (model_kind or ("gguf" if gguf_filename else "pipeline")).lower()
|
|
if kind in ("gguf", "single_file") and not gguf_filename:
|
|
raise ValueError("A gguf/single_file load needs the checkpoint filename.")
|
|
# Non-GGUF loads are gated to unsloth/* repos, the official bases, and existing local paths.
|
|
trusted = model_path.lower().startswith(("unsloth/", "lightricks/")) or (
|
|
Path(model_path).expanduser().exists()
|
|
)
|
|
if kind != "gguf" and not trusted:
|
|
raise ValueError(
|
|
f"Non-GGUF video loads are limited to unsloth/* repos, the official family "
|
|
f"base repos, and local paths; '{model_path}' is neither."
|
|
)
|
|
if "ltx" not in model_path.lower() and family_override is None:
|
|
raise ValueError(
|
|
f"'{model_path}' is not a supported text-to-video model. Supported families: ltx-2."
|
|
)
|
|
return object()
|
|
|
|
def begin_load(self, model_path, **kwargs):
|
|
# The real backend loads on a thread; the fake completes instantly.
|
|
self.loaded = True
|
|
self.last_load_kwargs = dict(kwargs)
|
|
return {
|
|
**_unloaded_status(),
|
|
"loaded": True,
|
|
"repo_id": model_path,
|
|
"family": "ltx-2",
|
|
"base_repo": kwargs.get("base_repo") or "Lightricks/LTX-2",
|
|
"device": "cpu",
|
|
"dtype": "float32",
|
|
"model_kind": kwargs.get("model_kind")
|
|
or ("gguf" if kwargs.get("gguf_filename") else "pipeline"),
|
|
"memory_mode": kwargs.get("memory_mode") or "auto",
|
|
"has_audio": True,
|
|
"defaults": _defaults(),
|
|
}
|
|
|
|
def load_progress(self):
|
|
return {
|
|
"phase": "ready" if self.loaded else None,
|
|
"downloaded_bytes": 0,
|
|
"expected_bytes": None,
|
|
"error": None,
|
|
}
|
|
|
|
def generate(
|
|
self,
|
|
*,
|
|
prompt,
|
|
seed = None,
|
|
cancel_event = None,
|
|
**kwargs,
|
|
):
|
|
if not self.loaded:
|
|
raise RuntimeError(VIDEO_NOT_LOADED_MSG)
|
|
return {
|
|
"mp4_bytes": b"MP4-FAKE-BYTES",
|
|
"seed": seed if seed is not None else 4242,
|
|
"repo_id": "unsloth/LTX-2.3-GGUF",
|
|
"width": kwargs.get("width") or 768,
|
|
"height": kwargs.get("height") or 512,
|
|
"num_frames": kwargs.get("num_frames") or 121,
|
|
"fps": kwargs.get("fps") or 24,
|
|
"duration_s": 5.0,
|
|
"has_audio": True,
|
|
"steps": kwargs.get("steps") or 40,
|
|
"guidance": 4.0 if kwargs.get("guidance") is None else kwargs.get("guidance"),
|
|
}
|
|
|
|
def unload(self):
|
|
self.loaded = False
|
|
return _unloaded_status()
|
|
|
|
def status(self):
|
|
if not self.loaded:
|
|
return _unloaded_status()
|
|
return {
|
|
**_unloaded_status(),
|
|
"loaded": True,
|
|
"repo_id": "unsloth/LTX-2.3-GGUF",
|
|
"family": "ltx-2",
|
|
"has_audio": True,
|
|
"defaults": _defaults(),
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def client(monkeypatch, tmp_path):
|
|
backend = _FakeBackend()
|
|
monkeypatch.setattr(video_module, "get_video_backend", lambda: backend)
|
|
# Isolate from the real GPU arbiter: reset ownership and stub the evictors so
|
|
# the load route's acquire_for() never touches live backend singletons.
|
|
monkeypatch.setattr(gpu_arbiter, "_owner", None)
|
|
monkeypatch.setitem(gpu_arbiter._EVICTORS, gpu_arbiter.CHAT, lambda: None)
|
|
monkeypatch.setitem(gpu_arbiter._EVICTORS, gpu_arbiter.DIFFUSION, lambda: None)
|
|
monkeypatch.setitem(gpu_arbiter._EVICTORS, gpu_arbiter.VIDEO, lambda: None)
|
|
|
|
# Pin the resolved device to cpu so the load route deterministically follows the
|
|
# non-GPU branch on any host; GPU-arbiter gating is asserted in its own tests by
|
|
# forcing the device to cuda.
|
|
import types
|
|
|
|
import core.inference.diffusion_device as devmod
|
|
|
|
monkeypatch.setattr(
|
|
devmod, "resolve_diffusion_device_target", lambda: types.SimpleNamespace(device = "cpu")
|
|
)
|
|
|
|
# Persist to a real tmp gallery so save/list/file/delete/clear run the actual
|
|
# video_gallery code (MP4 + JSON sidecar pair) without touching studio_root.
|
|
monkeypatch.setattr(gallery_module, "gallery_dir", lambda: tmp_path)
|
|
|
|
app = FastAPI()
|
|
app.include_router(video_router, prefix = "/api/inference")
|
|
app.dependency_overrides[get_current_subject] = lambda: "test-user"
|
|
return TestClient(app)
|
|
|
|
|
|
def _wait_terminal(client, timeout = 5.0) -> dict:
|
|
"""Poll generate-progress until the background job records a terminal phase.
|
|
Generation is asynchronous now (the POST returns as soon as the job starts),
|
|
so its outcome is only observable here."""
|
|
deadline = time.monotonic() + timeout
|
|
progress: dict = {}
|
|
while time.monotonic() < deadline:
|
|
progress = client.get("/api/inference/video/generate-progress").json()
|
|
if progress.get("phase") in ("completed", "failed"):
|
|
return progress
|
|
time.sleep(0.01)
|
|
raise AssertionError(f"generation never reached a terminal state: {progress}")
|
|
|
|
|
|
def _generate_and_wait(client, payload) -> dict:
|
|
"""Start a generation, assert the immediate accepted response, and return the
|
|
saved gallery record the completed progress state carries."""
|
|
resp = client.post("/api/inference/video/generate", json = payload)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["status"] == "started" and body["video"] is None
|
|
progress = _wait_terminal(client)
|
|
assert progress["phase"] == "completed", progress
|
|
assert progress["active"] is False and progress["error"] is None
|
|
return progress["video"]
|
|
|
|
|
|
def test_load_happy_path_and_arbiter_acquired(client, monkeypatch):
|
|
# Force the device to cuda so the load takes the GPU arbiter, and record the acquire.
|
|
import types
|
|
|
|
import core.inference.diffusion_device as devmod
|
|
|
|
monkeypatch.setattr(
|
|
devmod, "resolve_diffusion_device_target", lambda: types.SimpleNamespace(device = "cuda")
|
|
)
|
|
acquired: list = []
|
|
|
|
def _fake_acquire(role, register = None):
|
|
# Mirror the real arbiter: record the handoff and run the (registered) load under it.
|
|
acquired.append(role)
|
|
return register() if register is not None else None
|
|
|
|
monkeypatch.setattr(gpu_arbiter, "acquire_for", _fake_acquire)
|
|
|
|
resp = client.post(
|
|
"/api/inference/video/load",
|
|
json = {
|
|
"model_path": "unsloth/LTX-2.3-GGUF",
|
|
"gguf_filename": "ltx-2.3-distilled-Q4_K_M.gguf",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["loaded"] is True and body["family"] == "ltx-2"
|
|
assert body["has_audio"] is True
|
|
assert body["defaults"]["num_frames"] == 121
|
|
assert acquired == [gpu_arbiter.VIDEO] # the GPU was handed to VIDEO
|
|
|
|
|
|
def test_load_value_error_returns_400(client):
|
|
# A non-ltx repo is not a supported family: the cheap validation rejects it -> 400.
|
|
resp = client.post(
|
|
"/api/inference/video/load",
|
|
json = {"model_path": "x/some-image-model", "gguf_filename": "q.gguf"},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "supported text-to-video model" in resp.json()["detail"]
|
|
# Validation runs before the arbiter handoff, so ownership is untouched.
|
|
assert gpu_arbiter._owner is None
|
|
|
|
|
|
def test_load_threads_options_through_to_backend(client):
|
|
resp = client.post(
|
|
"/api/inference/video/load",
|
|
json = {
|
|
"model_path": "unsloth/LTX-2.3-GGUF",
|
|
"gguf_filename": "q.gguf",
|
|
"memory_mode": "low_vram",
|
|
"attention_backend": "cudnn",
|
|
"transformer_cache": "fbcache",
|
|
"transformer_cache_threshold": 0.1,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
kwargs = video_module.get_video_backend().last_load_kwargs
|
|
assert kwargs.get("memory_mode") == "low_vram"
|
|
assert kwargs.get("attention_backend") == "cudnn"
|
|
assert kwargs.get("transformer_cache") == "fbcache"
|
|
assert kwargs.get("transformer_cache_threshold") == 0.1
|
|
|
|
|
|
def test_load_threads_transformer_quant_and_guidance_2(client):
|
|
# The new load-time transformer_quant field reaches the backend, and the new
|
|
# per-generation guidance_2 field reaches generate() (dual-DiT MoE second guidance).
|
|
resp = client.post(
|
|
"/api/inference/video/load",
|
|
json = {
|
|
"model_path": "unsloth/LTX-2.3-GGUF",
|
|
"gguf_filename": "q.gguf",
|
|
"transformer_quant": "fp8",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
kwargs = video_module.get_video_backend().last_load_kwargs
|
|
assert kwargs.get("transformer_quant") == "fp8"
|
|
|
|
video = _generate_and_wait(client, {"prompt": "a sloth", "guidance": 5.0, "guidance_2": 3.0})
|
|
assert video["guidance"] == 5.0 and video["guidance_2"] == 3.0
|
|
|
|
|
|
def test_load_rejects_bad_transformer_quant_422(client):
|
|
# transformer_quant is a Literal, so an unknown scheme is a 422 at request validation.
|
|
resp = client.post(
|
|
"/api/inference/video/load",
|
|
json = {
|
|
"model_path": "unsloth/LTX-2.3-GGUF",
|
|
"gguf_filename": "q.gguf",
|
|
"transformer_quant": "bogus",
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
def test_load_threads_text_encoder_quant(client):
|
|
# The load-time text_encoder_quant field reaches the backend (the video path now
|
|
# quantises the dense companion encoder, not just the DiT).
|
|
resp = client.post(
|
|
"/api/inference/video/load",
|
|
json = {
|
|
"model_path": "unsloth/LTX-2.3-GGUF",
|
|
"gguf_filename": "q.gguf",
|
|
"text_encoder_quant": "fp8",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
kwargs = video_module.get_video_backend().last_load_kwargs
|
|
assert kwargs.get("text_encoder_quant") == "fp8"
|
|
|
|
|
|
def test_load_rejects_bad_text_encoder_quant_422(client):
|
|
# text_encoder_quant is a Literal, so an unknown scheme is a 422 at request validation.
|
|
resp = client.post(
|
|
"/api/inference/video/load",
|
|
json = {
|
|
"model_path": "unsloth/LTX-2.3-GGUF",
|
|
"gguf_filename": "q.gguf",
|
|
"text_encoder_quant": "bogus",
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
def test_load_progress_route(client):
|
|
idle = client.get("/api/inference/video/load-progress")
|
|
assert idle.status_code == 200 and idle.json()["phase"] is None
|
|
client.post(
|
|
"/api/inference/video/load",
|
|
json = {"model_path": "unsloth/LTX-2.3-GGUF", "gguf_filename": "q.gguf"},
|
|
)
|
|
ready = client.get("/api/inference/video/load-progress")
|
|
assert ready.json()["phase"] == "ready"
|
|
|
|
|
|
def test_load_local_single_file_dir_routes_through_single_file(client, tmp_path):
|
|
# A local video-family dir with one .safetensors and no model_index.json arrives as a pipeline
|
|
# with no filename; the route reinterprets it as a single_file load of the sole checkpoint.
|
|
d = tmp_path / "ltx-2.3-local"
|
|
d.mkdir()
|
|
(d / "ltx-dit.safetensors").write_bytes(b"0")
|
|
resp = client.post(
|
|
"/api/inference/video/load",
|
|
json = {"model_path": str(d), "model_kind": "pipeline"},
|
|
)
|
|
assert resp.status_code == 200
|
|
kwargs = video_module.get_video_backend().last_load_kwargs
|
|
assert kwargs["model_kind"] == "single_file"
|
|
assert kwargs["gguf_filename"] == "ltx-dit.safetensors"
|
|
|
|
|
|
def test_load_local_pipeline_dir_stays_pipeline(client, tmp_path):
|
|
# A real diffusers directory (has model_index.json) is left as a pipeline load.
|
|
d = tmp_path / "ltx-2.3-pipeline"
|
|
d.mkdir()
|
|
(d / "model_index.json").write_text("{}")
|
|
(d / "diffusion_pytorch_model.safetensors").write_bytes(b"0")
|
|
resp = client.post(
|
|
"/api/inference/video/load",
|
|
json = {"model_path": str(d), "model_kind": "pipeline"},
|
|
)
|
|
assert resp.status_code == 200
|
|
kwargs = video_module.get_video_backend().last_load_kwargs
|
|
assert kwargs["model_kind"] == "pipeline"
|
|
assert not kwargs.get("gguf_filename")
|
|
|
|
|
|
def test_generate_happy_path_persists_and_reports_record(client):
|
|
client.post(
|
|
"/api/inference/video/load",
|
|
json = {"model_path": "unsloth/LTX-2.3-GGUF", "gguf_filename": "q.gguf"},
|
|
)
|
|
# The POST returns at once ("started"); the saved record arrives through the
|
|
# generate-progress terminal state (asserted inside the helper).
|
|
video = _generate_and_wait(client, {"prompt": "a sloth surfing", "seed": 7})
|
|
assert video["seed"] == 7 and video["prompt"] == "a sloth surfing" and video["id"]
|
|
assert video["has_audio"] is True
|
|
assert video["model"] == "unsloth/LTX-2.3-GGUF"
|
|
assert video["url"].endswith(f"/gallery/{video['id']}/file")
|
|
assert video["created_at"] # ISO timestamp string
|
|
|
|
# The clip is now listable and fetchable as MP4 bytes.
|
|
listed = client.get("/api/inference/video/gallery").json()["videos"]
|
|
assert [v["id"] for v in listed] == [video["id"]]
|
|
fetched = client.get(video["url"])
|
|
assert fetched.status_code == 200
|
|
assert fetched.headers["content-type"] == "video/mp4"
|
|
assert "immutable" in fetched.headers["cache-control"]
|
|
assert fetched.content == b"MP4-FAKE-BYTES"
|
|
|
|
|
|
def test_generate_without_load_returns_409(client):
|
|
resp = client.post("/api/inference/video/generate", json = {"prompt": "p"})
|
|
assert resp.status_code == 409
|
|
assert resp.json()["detail"] == VIDEO_NOT_LOADED_MSG
|
|
|
|
|
|
def test_generate_cancelled_reports_failed_with_sentinel(client, monkeypatch):
|
|
# A cancel mid-run surfaces as the job's terminal failed state carrying the exact
|
|
# sentinel (the frontend suppresses the toast on it), not as an HTTP error.
|
|
backend = video_module.get_video_backend()
|
|
backend.loaded = True
|
|
|
|
def _cancel(**kwargs):
|
|
raise RuntimeError(VIDEO_CANCELLED_MSG)
|
|
|
|
monkeypatch.setattr(backend, "generate", _cancel)
|
|
resp = client.post("/api/inference/video/generate", json = {"prompt": "p"})
|
|
assert resp.status_code == 200
|
|
progress = _wait_terminal(client)
|
|
assert progress["phase"] == "failed"
|
|
assert progress["error"] == VIDEO_CANCELLED_MSG
|
|
assert progress["active"] is False
|
|
|
|
|
|
def test_generate_pipeline_error_reports_sanitized_failure(client, monkeypatch):
|
|
# A loaded model that fails mid-pipeline (CUDA OOM) is a server failure: the job's
|
|
# terminal state carries a generic message, never the raw exception.
|
|
backend = video_module.get_video_backend()
|
|
backend.loaded = True
|
|
|
|
def _oom(**kwargs):
|
|
raise RuntimeError("CUDA out of memory. Tried to allocate 40.00 GiB")
|
|
|
|
monkeypatch.setattr(backend, "generate", _oom)
|
|
resp = client.post("/api/inference/video/generate", json = {"prompt": "p"})
|
|
assert resp.status_code == 200
|
|
progress = _wait_terminal(client)
|
|
assert progress["phase"] == "failed"
|
|
assert progress["error"] == "Video generation failed."
|
|
assert "CUDA" not in progress["error"]
|
|
|
|
|
|
def test_generate_value_error_reports_reason(client, monkeypatch):
|
|
# Bad client input is feedback: the terminal failed state carries the reason.
|
|
backend = video_module.get_video_backend()
|
|
backend.loaded = True
|
|
|
|
def _bad(**kwargs):
|
|
raise ValueError("negative_prompt is not supported by this family.")
|
|
|
|
monkeypatch.setattr(backend, "generate", _bad)
|
|
resp = client.post("/api/inference/video/generate", json = {"prompt": "p"})
|
|
assert resp.status_code == 200
|
|
progress = _wait_terminal(client)
|
|
assert progress["phase"] == "failed"
|
|
assert "not supported" in progress["error"]
|
|
|
|
|
|
def test_generate_concurrent_second_returns_409(client, monkeypatch):
|
|
# While a job is running, a second generate is refused synchronously with the busy
|
|
# sentinel; the first job still completes and persists once released.
|
|
backend = video_module.get_video_backend()
|
|
backend.loaded = True
|
|
release = threading.Event()
|
|
real_generate = _FakeBackend.generate
|
|
|
|
def _slow(**kwargs):
|
|
assert release.wait(5)
|
|
return real_generate(backend, **kwargs)
|
|
|
|
monkeypatch.setattr(backend, "generate", _slow)
|
|
first = client.post("/api/inference/video/generate", json = {"prompt": "a", "seed": 1})
|
|
assert first.status_code == 200 and first.json()["status"] == "started"
|
|
|
|
second = client.post("/api/inference/video/generate", json = {"prompt": "b"})
|
|
assert second.status_code == 409
|
|
assert second.json()["detail"] == VIDEO_GENERATION_BUSY_MSG
|
|
|
|
running = client.get("/api/inference/video/generate-progress").json()
|
|
assert running["active"] is True
|
|
|
|
release.set()
|
|
progress = _wait_terminal(client)
|
|
assert progress["phase"] == "completed" and progress["video"]["seed"] == 1
|
|
# With the job finished, a new generate is accepted again.
|
|
assert _generate_and_wait(client, {"prompt": "c", "seed": 2})["seed"] == 2
|
|
|
|
|
|
def test_generate_progress_route(client):
|
|
resp = client.get("/api/inference/video/generate-progress")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["active"] is False
|
|
assert body["phase"] is None and body["video"] is None and body["error"] is None
|
|
|
|
|
|
def test_cancel_generation_route(client):
|
|
resp = client.post("/api/inference/video/generate/cancel")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["cancelled"] is False
|
|
|
|
|
|
def test_cancel_running_job(client, monkeypatch):
|
|
# Cancel still works against the background job: begin_generate registers the
|
|
# cancel event before the worker starts, so the cancel route reports True at
|
|
# once and the job lands in the failed(cancelled) terminal state.
|
|
backend = video_module.get_video_backend()
|
|
backend.loaded = True
|
|
|
|
def _wait_for_cancel(*, cancel_event = None, **kwargs):
|
|
assert cancel_event is not None and cancel_event.wait(5)
|
|
raise RuntimeError(VIDEO_CANCELLED_MSG)
|
|
|
|
monkeypatch.setattr(backend, "generate", _wait_for_cancel)
|
|
resp = client.post("/api/inference/video/generate", json = {"prompt": "p"})
|
|
assert resp.status_code == 200
|
|
|
|
cancelled = client.post("/api/inference/video/generate/cancel")
|
|
assert cancelled.status_code == 200 and cancelled.json()["cancelled"] is True
|
|
|
|
progress = _wait_terminal(client)
|
|
assert progress["phase"] == "failed"
|
|
assert progress["error"] == VIDEO_CANCELLED_MSG
|
|
# Nothing was persisted for the cancelled run.
|
|
assert client.get("/api/inference/video/gallery").json()["videos"] == []
|
|
|
|
|
|
def test_file_endpoint_404_for_bad_id(client):
|
|
resp = client.get("/api/inference/video/gallery/does-not-exist/file")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_serve_and_export_refuse_orphan_mp4(client, tmp_path):
|
|
# A hand-dropped orphan MP4 (no readable sidecar) is hidden by the listing; the serve and
|
|
# export routes resolve through the ownership guard, so a guessed stem can neither stream nor
|
|
# transcode it out.
|
|
(tmp_path / "recording.mp4").write_bytes(b"\x00\x00\x00\x18ftypmp42")
|
|
assert client.get("/api/inference/video/gallery/recording/file").status_code == 404
|
|
assert client.get("/api/inference/video/gallery/recording/export?format=gif").status_code == 404
|
|
|
|
|
|
def test_delete_and_clear(client):
|
|
client.post(
|
|
"/api/inference/video/load",
|
|
json = {"model_path": "unsloth/LTX-2.3-GGUF", "gguf_filename": "q.gguf"},
|
|
)
|
|
first = _generate_and_wait(client, {"prompt": "a"})
|
|
second = _generate_and_wait(client, {"prompt": "b"})
|
|
assert len(client.get("/api/inference/video/gallery").json()["videos"]) == 2
|
|
|
|
# Delete one, then confirm it 404s and the other remains.
|
|
assert client.delete(f"/api/inference/video/gallery/{first['id']}").status_code == 200
|
|
assert client.delete(f"/api/inference/video/gallery/{first['id']}").status_code == 404
|
|
remaining = client.get("/api/inference/video/gallery").json()["videos"]
|
|
assert [v["id"] for v in remaining] == [second["id"]]
|
|
|
|
# Clear wipes the rest.
|
|
cleared = client.delete("/api/inference/video/gallery")
|
|
assert cleared.status_code == 200 and cleared.json()["removed"] == 1
|
|
assert client.get("/api/inference/video/gallery").json()["videos"] == []
|
|
|
|
|
|
def test_gallery_pagination(client):
|
|
client.post(
|
|
"/api/inference/video/load",
|
|
json = {"model_path": "unsloth/LTX-2.3-GGUF", "gguf_filename": "q.gguf"},
|
|
)
|
|
for i in range(5):
|
|
_generate_and_wait(client, {"prompt": f"clip {i}", "seed": i})
|
|
page1 = client.get("/api/inference/video/gallery?limit=2&offset=0").json()
|
|
assert len(page1["videos"]) == 2 and page1["has_more"] is True
|
|
last = client.get("/api/inference/video/gallery?limit=2&offset=4").json()
|
|
assert len(last["videos"]) == 1 and last["has_more"] is False
|
|
|
|
|
|
def test_status_passthrough(client, monkeypatch):
|
|
backend = video_module.get_video_backend()
|
|
resolved = {
|
|
"speed_mode": {"value": "eager", "source": "auto", "reason": "GGUF default"},
|
|
"transformer_cache": {"value": None, "source": "auto", "reason": "few-step model"},
|
|
}
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"status",
|
|
lambda: {
|
|
**_unloaded_status(),
|
|
"loaded": True,
|
|
"family": "ltx-2",
|
|
"has_audio": True,
|
|
"defaults": _defaults(),
|
|
"resolved": resolved,
|
|
},
|
|
)
|
|
body = client.get("/api/inference/video/status").json()
|
|
assert body["loaded"] is True and body["family"] == "ltx-2"
|
|
assert body["resolved"] == resolved
|
|
assert body["defaults"]["frame_step"] == 8
|
|
|
|
|
|
def test_status_resolved_defaults_to_null(client):
|
|
body = client.get("/api/inference/video/status").json()
|
|
assert body["resolved"] is None and body["defaults"] is None
|
|
|
|
|
|
def test_unload_releases_arbiter(client, monkeypatch):
|
|
# Pin VIDEO as the current owner; unload must drop that claim.
|
|
monkeypatch.setattr(gpu_arbiter, "_owner", gpu_arbiter.VIDEO)
|
|
resp = client.post("/api/inference/video/unload")
|
|
assert resp.status_code == 200 and resp.json()["loaded"] is False
|
|
assert gpu_arbiter.current_owner() is None
|
|
|
|
|
|
def test_unload_keeps_ownership_when_a_load_is_in_flight(client, monkeypatch):
|
|
# A concurrent /video/load re-acquires VIDEO and starts a background load, so the backend
|
|
# is NOT loaded yet (the pipeline commits later) but a load IS in flight. The unload route
|
|
# must keep ownership on the in-flight state alone, or a later chat/image load would see no
|
|
# owner, skip eviction, and OOM against the newly resident pipeline. The committed-loaded
|
|
# state stays False the whole load window, so the loaded-only check is insufficient here.
|
|
backend = video_module.get_video_backend()
|
|
monkeypatch.setattr(gpu_arbiter, "_owner", gpu_arbiter.VIDEO)
|
|
|
|
backend.loaded = False
|
|
backend.loading = ("unsloth/ltx-video-2b",)
|
|
resp = client.post("/api/inference/video/unload")
|
|
assert resp.status_code == 200 and resp.json()["loaded"] is False
|
|
assert gpu_arbiter.current_owner() == gpu_arbiter.VIDEO # ownership retained for the load
|
|
|
|
backend.loading = ()
|
|
|
|
|
|
def test_load_refused_during_training(client, monkeypatch):
|
|
# A video load while training is active is refused (409) before the GPU is taken.
|
|
import core.training as core_training
|
|
|
|
monkeypatch.setattr(gpu_arbiter, "_owner", gpu_arbiter.CHAT)
|
|
evicted: list = []
|
|
monkeypatch.setitem(gpu_arbiter._EVICTORS, gpu_arbiter.CHAT, lambda: evicted.append(True))
|
|
|
|
class _Training:
|
|
def is_training_active(self):
|
|
return True
|
|
|
|
monkeypatch.setattr(core_training, "get_training_backend", lambda: _Training())
|
|
|
|
resp = client.post(
|
|
"/api/inference/video/load",
|
|
json = {"model_path": "unsloth/LTX-2.3-GGUF", "gguf_filename": "q.gguf"},
|
|
)
|
|
assert resp.status_code == 409
|
|
assert "training" in resp.json()["detail"].lower()
|
|
assert evicted == [] # chat backend was never evicted
|
|
assert gpu_arbiter.current_owner() == gpu_arbiter.CHAT
|
|
|
|
|
|
def test_generate_missing_prompt_returns_422(client):
|
|
resp = client.post("/api/inference/video/generate", json = {})
|
|
assert resp.status_code == 422
|
|
|
|
|
|
def test_routes_require_auth():
|
|
# No dependency override: the auth dependency must reject the request.
|
|
app = FastAPI()
|
|
app.include_router(video_router, prefix = "/api/inference")
|
|
unauth = TestClient(app)
|
|
assert unauth.get("/api/inference/video/status").status_code in (401, 403)
|
|
|
|
|
|
def test_export_endpoint_validation(client, monkeypatch):
|
|
# Unknown format is a 400 before any work happens.
|
|
resp = client.get("/api/inference/video/gallery/x/export?format=avi")
|
|
assert resp.status_code == 400
|
|
# Unknown id is a 404.
|
|
resp = client.get("/api/inference/video/gallery/does-not-exist/export?format=gif")
|
|
assert resp.status_code == 404
|
|
|
|
# A missing codec surfaces as 501 with the transcoder's message.
|
|
def _boom(video_id, fmt):
|
|
raise RuntimeError("WebM export needs the 'av' package (PyAV).")
|
|
|
|
monkeypatch.setattr(gallery_module, "transcode", _boom)
|
|
client.post(
|
|
"/api/inference/video/load",
|
|
json = {"model_path": "unsloth/LTX-2.3-GGUF", "gguf_filename": "q.gguf"},
|
|
)
|
|
video = _generate_and_wait(client, {"prompt": "a"})
|
|
resp = client.get(f"/api/inference/video/gallery/{video['id']}/export?format=webm")
|
|
assert resp.status_code == 501
|
|
assert "PyAV" in resp.json()["detail"]
|