Three review findings: - The picker filtered every catalog member out of Recommended and Hub search on the way to canonical group rows, but nothing renders those rows yet (catalogGroupFitsDevice and groupMatchesQuery are imported and unused). A task-scoped picker's models list is catalogToModelOptions(), i.e. group members exclusively, so both lists came back empty and no curated model could be discovered or downloaded. Keep the artifacts listed until the grouped UI exists. - The video delete guard compared only repo_id, so deleting the companion base of a loaded GGUF video model was allowed even though it supplies the VAE and text encoders. Compare base_repo too, matching what the images guard already does for its companions. - diffusers was declared unversioned while the diffusion stack requires 0.39 (Krea2Pipeline, the cache_context child registries, the Flux2 and Z-Image pipelines), so an upgrade could keep an older release and selecting an advertised model failed until the user upgraded by hand.
759 lines
29 KiB
Python
759 lines
29 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"]
|
|
|
|
|
|
def test_delete_guard_protects_the_loaded_video_companion_base(monkeypatch):
|
|
# For a GGUF / single-file video load the companion base supplies the VAE and text
|
|
# encoders, so it is as much part of the live model as the checkpoint. Deleting it used
|
|
# to sail past the guard, which only compared repo_id.
|
|
from hub.services.models import deletion
|
|
|
|
class _Backend:
|
|
def status(self):
|
|
return {
|
|
"loaded": True,
|
|
"repo_id": "unsloth/LTX-2.3-GGUF",
|
|
"base_repo": "unsloth/LTX-2.3",
|
|
}
|
|
|
|
def loading_repo_ids(self):
|
|
return ()
|
|
|
|
monkeypatch.setattr(video_module, "get_video_backend", lambda: _Backend())
|
|
assert deletion._video_blocks_delete("unsloth/LTX-2.3-GGUF") is not None
|
|
assert deletion._video_blocks_delete("unsloth/LTX-2.3") is not None
|
|
assert deletion._video_blocks_delete("unsloth/something-else") is None
|