unsloth/studio/backend/tests/test_video_gallery.py
Daniel Han 9a933d375b Guard old diffusers, stream video exports, record conditioned recipes
Three fixes from review.

The 0.39-only pipeline classes (Flux2Klein, Z-Image, Krea 2, LTX-2,
HunyuanImage) were resolved by getattr deep in the load, so on the older
diffusers that packaging still allows on Python 3.9 -- diffusers dropped 3.9 in
0.38 and this project still supports it, so the 0.39 floor has to be conditional
or the extra becomes unresolvable -- an advertised model failed with a bare
AttributeError after its checkpoint had already been downloaded. Krea 2 already
guarded itself this way; assert_pipeline_class_available now runs the same check
for every image and video family from validation, before any fetch, and names
the version and the fix.

WebM export accumulated the whole VP9 output in a BytesIO and returned it as one
bytes object that the response held again. The request caps allow 2048x2048 for
1024 frames, so an export runs to hundreds of MB and concurrent clicks could
exhaust the process, while the MP4 route beside it already streamed from disk.
transcode_to_file encodes to a temp file and the route returns a FileResponse
with a background unlink, so nothing large is resident.

A conditioned generation's recipe carried only the txt2img fields, so the
gallery presented an inpaint or upscale result as a complete Create recipe and
restoring it replayed an unrelated text-to-image request. The images themselves
are still not persisted (user uploads with their own lifetime), but the workflow
and its scalars are, restore reapplies them, and the toast now names the inputs
that have to be supplied again instead of silently landing on Create.

Reported by Codex.
2026-07-27 07:41:19 +00:00

533 lines
21 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
"""Unit tests for the disk-backed video gallery: MP4 + JSON-sidecar round-trips,
listing order, safe id handling, orphan-pair skipping, and delete/clear."""
from __future__ import annotations
import json
import os
from pathlib import Path
import core.inference.video_gallery as gallery
import pytest
@pytest.fixture(autouse = True)
def _tmp_gallery(monkeypatch, tmp_path):
# Point the gallery at a throwaway root instead of ~/.unsloth/studio.
monkeypatch.setattr(gallery, "studio_root", lambda: tmp_path)
def _mp4(tag = b"\x00\x00\x00\x18ftypmp42"):
# Not a real container; the gallery treats the bytes as opaque payload.
return tag
def _meta(**over):
base = {
"prompt": "a sloth surfing",
"negative_prompt": None,
"width": 1024,
"height": 576,
"num_frames": 49,
"fps": 24,
"duration_s": 2.0,
"steps": 30,
"guidance": 6.0,
"seed": 7,
"model": "unsloth/some-video-model",
"created_at": 100.0,
}
base.update(over)
return base
def test_save_writes_pair_and_round_trips():
record = gallery.save(_mp4(), _meta())
assert record["id"] and record["url"].endswith(f"{record['id']}/file")
# Both files of the pair exist: the mp4 payload and the json recipe sidecar.
directory = gallery.gallery_dir()
assert (directory / f"{record['id']}.mp4").is_file()
sidecar = directory / f"{record['id']}.json"
assert json.loads(sidecar.read_text(encoding = "utf-8"))["prompt"] == "a sloth surfing"
listed = gallery.list_videos()
assert len(listed) == 1
assert listed[0]["prompt"] == "a sloth surfing" and listed[0]["seed"] == 7
# Meta fields survive the sidecar round-trip untouched.
assert listed[0]["num_frames"] == 49 and listed[0]["model"] == "unsloth/some-video-model"
def test_url_shape():
record = gallery.save(_mp4(), _meta())
assert record["url"] == f"/api/inference/video/gallery/{record['id']}/file"
def _save_with_mtime(prompt: str, t: float) -> dict:
record = gallery.save(_mp4(), _meta(prompt = prompt, created_at = t))
# Listing orders by mp4 mtime; set it explicitly so a tight test loop can't tie it.
os.utime(gallery.gallery_dir() / f"{record['id']}.mp4", (t, t))
return record
def test_list_is_newest_first():
old = _save_with_mtime("old", 100.0)
new = _save_with_mtime("new", 200.0)
assert [r["id"] for r in gallery.list_videos()] == [new["id"], old["id"]]
def test_list_paginates_with_limit_offset():
# 5 videos, newest (t=4) first.
for i in range(5):
_save_with_mtime(f"p{i}", float(i))
page1 = gallery.list_videos(limit = 2, offset = 0)
page2 = gallery.list_videos(limit = 2, offset = 2)
assert [r["prompt"] for r in page1] == ["p4", "p3"]
assert [r["prompt"] for r in page2] == ["p2", "p1"]
# limit=None still returns everything from the offset.
assert len(gallery.list_videos()) == 5
assert len(gallery.list_videos(offset = 4)) == 1
def test_video_path_rejects_unsafe_ids():
# Traversal / bad chars / absolute paths never resolve to a path.
assert gallery.video_path("../../etc/passwd") is None
assert gallery.video_path("/etc/passwd") is None
assert gallery.video_path("a/b") is None
assert gallery.video_path("missing") is None
def test_video_path_returns_mp4_for_saved_id():
record = gallery.save(_mp4(), _meta())
path = gallery.video_path(record["id"])
assert path is not None and path.name == f"{record['id']}.mp4"
def test_owned_video_path_serves_only_owned_clips():
# A hand-dropped orphan MP4 resolves via video_path (safe stem, on disk) but must NOT be
# served: owned_video_path applies the same sidecar check as delete/clear, so the serve and
# export routes can't stream/transcode a clip the listing hides.
orphan = gallery.gallery_dir() / "recording.mp4"
orphan.write_bytes(_mp4())
assert gallery.video_path("recording") is not None # resolvable...
assert gallery.owned_video_path("recording") is None # ...but not ours to serve
ours = gallery.save(_mp4(), _meta(prompt = "ours"))
assert gallery.owned_video_path(ours["id"]) is not None
assert gallery.owned_video_path("../../etc/passwd") is None
assert gallery.owned_video_path("missing") is None
def test_transcode_refuses_orphan_mp4():
# Export starts from the same resolver as /file, so a guessed stem for an orphan MP4 (no
# readable sidecar) must not be re-encoded out either.
orphan = gallery.gallery_dir() / "recording.mp4"
orphan.write_bytes(_real_mp4_bytes())
assert gallery.transcode("recording", "gif") is None
assert gallery.transcode("recording", "webm") is None
def test_delete_removes_both_files():
record = gallery.save(_mp4(), _meta(prompt = "a"))
gallery.save(_mp4(), _meta(prompt = "b"))
directory = gallery.gallery_dir()
assert gallery.delete(record["id"]) is True
# Both halves of the pair are gone.
assert not (directory / f"{record['id']}.mp4").exists()
assert not (directory / f"{record['id']}.json").exists()
assert gallery.delete(record["id"]) is False # already gone
assert len(gallery.list_videos()) == 1
def test_delete_keeps_sidecar_listable_when_mp4_unlink_fails(monkeypatch):
# delete() must remove the MP4 FIRST: list_videos globs *.mp4 but requires a readable sidecar,
# so if the sidecar were dropped first and the mp4 unlink then failed (a Windows lock from a
# concurrent stream/transcode), the still-present mp4 would vanish from the gallery with no way
# to retry. Simulate the mp4 unlink failing and assert the video stays listable (sidecar kept).
record = gallery.save(_mp4(), _meta(prompt = "keep"))
directory = gallery.gallery_dir()
mp4 = directory / f"{record['id']}.mp4"
sidecar = directory / f"{record['id']}.json"
real_unlink = Path.unlink
def _fail_on_mp4(self, *a, **k):
if self.suffix == ".mp4":
raise PermissionError("mp4 locked")
return real_unlink(self, *a, **k)
# Patch Path.unlink (what delete() actually calls) rather than os.unlink: on Python 3.10
# Path.unlink dispatches through a cached _accessor bound to os.unlink at import, so patching
# os.unlink there has no effect and the mp4 delete would wrongly succeed. Scope it to its own
# context so undoing it does NOT also revert the autouse fixture's studio_root redirect (both
# share the function-scoped monkeypatch); otherwise list_videos below would read the real home.
with pytest.MonkeyPatch.context() as m:
m.setattr(Path, "unlink", _fail_on_mp4)
assert gallery.delete(record["id"]) is False # mp4 unlink failed
# The sidecar was NOT dropped, so the record is still listable and the user can retry.
assert sidecar.exists() and mp4.exists()
assert [r["prompt"] for r in gallery.list_videos()] == ["keep"]
assert gallery.delete(record["id"]) is True # retry now succeeds
def test_clear_returns_count():
gallery.save(_mp4(), _meta(prompt = "a"))
gallery.save(_mp4(), _meta(prompt = "b"))
assert gallery.clear() == 2
assert gallery.list_videos() == []
# No stray sidecars left behind after a clear.
assert list(gallery.gallery_dir().glob("*.json")) == []
def test_clear_preserves_orphan_mp4():
# An orphan / foreign MP4 (no readable sidecar) is invisible to list_videos; clear must not
# destroy it while removing the owned pair.
foreign = gallery.gallery_dir() / "recording.mp4"
foreign.write_bytes(_mp4())
gallery.save(_mp4(), _meta(prompt = "ours"))
assert gallery.clear() == 1
assert foreign.exists()
assert gallery.list_videos() == []
def test_delete_ignores_orphan_mp4():
# A per-id delete must refuse an MP4 we do not own (no readable sidecar).
foreign = gallery.gallery_dir() / "recording.mp4"
foreign.write_bytes(_mp4())
assert gallery.delete("recording") is False
assert foreign.exists()
def test_list_skips_orphan_mp4_without_sidecar():
# An MP4 with no readable json sidecar (a hand-dropped file) is not a record.
orphan = gallery.gallery_dir() / "orphan.mp4"
orphan.write_bytes(_mp4())
gallery.save(_mp4(), _meta(prompt = "ours"))
listed = gallery.list_videos()
assert [r["prompt"] for r in listed] == ["ours"]
def test_list_skips_orphan_sidecar_without_mp4():
# A json sidecar with no MP4 alongside it is never surfaced (listing globs mp4s).
orphan = gallery.gallery_dir() / "lonely.json"
orphan.write_text(json.dumps(_meta(prompt = "no video")), encoding = "utf-8")
gallery.save(_mp4(), _meta(prompt = "ours"))
listed = gallery.list_videos()
assert [r["prompt"] for r in listed] == ["ours"]
def test_orphan_mp4_in_window_does_not_drop_valid_videos():
# An orphan MP4 sorting INTO the requested page must not consume a window slot
# and drop a valid video that sorts after it: paging is over readable records.
_save_with_mtime("p2", 100.0)
orphan = gallery.gallery_dir() / "zzz_orphan.mp4"
orphan.write_bytes(_mp4()) # newest by mtime (set below), sorts first
os.utime(orphan, (300.0, 300.0))
_save_with_mtime("p1", 200.0)
# First page of 2 must still return both real videos, not [p1] (orphan eating a slot).
page1 = gallery.list_videos(limit = 2, offset = 0)
assert [r["prompt"] for r in page1] == ["p1", "p2"]
def test_list_skips_corrupt_sidecar():
# A sidecar that is not valid JSON is treated as a foreign/orphan mp4 and skipped.
directory = gallery.gallery_dir()
(directory / "broken.mp4").write_bytes(_mp4())
(directory / "broken.json").write_text("{not json", encoding = "utf-8")
gallery.save(_mp4(), _meta(prompt = "ours"))
listed = gallery.list_videos()
assert [r["prompt"] for r in listed] == ["ours"]
def test_list_skips_invalid_utf8_sidecar():
# Invalid UTF-8 raises UnicodeDecodeError, which is not an OSError: one corrupt sidecar must
# be skipped like any other, not 500 the whole gallery listing.
directory = gallery.gallery_dir()
(directory / "badbytes.mp4").write_bytes(_mp4())
(directory / "badbytes.json").write_bytes(b"\xff\xfe{}")
gallery.save(_mp4(), _meta(prompt = "ours"))
assert [r["prompt"] for r in gallery.list_videos()] == ["ours"]
def test_clear_preserves_mp4_with_present_but_invalid_sidecar():
# A hand-dropped MP4 whose sidecar parses but lacks the required recipe keys (e.g. "{}") is
# hidden by list_videos, so clear must not destroy it while removing the owned pair.
directory = gallery.gallery_dir()
(directory / "foreign.mp4").write_bytes(_mp4())
(directory / "foreign.json").write_text("{}", encoding = "utf-8")
gallery.save(_mp4(), _meta(prompt = "ours"))
assert gallery.clear() == 1
assert (directory / "foreign.mp4").exists()
def test_delete_refuses_mp4_with_present_but_invalid_sidecar():
# A per-id delete of an MP4 whose sidecar parses but is missing required keys must refuse it:
# the gallery never surfaced it, so a guessed id must not destroy it.
directory = gallery.gallery_dir()
(directory / "foreign.mp4").write_bytes(_mp4())
(directory / "foreign.json").write_text(
json.dumps({"prompt": "x"}), encoding = "utf-8"
) # partial sidecar (no width/seed/...)
assert gallery.delete("foreign") is False
assert (directory / "foreign.mp4").exists()
def test_valid_callback_paginates_over_accepted_records():
# ``valid`` must filter before pagination, so offset/limit/has_more count over accepted records;
# else a leading bad record returns a short page with more remaining and stalls scroll.
_save_with_mtime("BAD", 300.0) # newest, sorts first
_save_with_mtime("g1", 200.0)
_save_with_mtime("g2", 100.0)
def _valid(rec):
return rec.get("prompt") != "BAD"
page = gallery.list_videos(limit = 2, offset = 0, valid = _valid)
assert [r["prompt"] for r in page] == ["g1", "g2"]
assert len(gallery.list_videos(limit = 3, offset = 0, valid = _valid)) == 2
def test_valid_callback_leading_bad_records_do_not_stall_at_offset_zero():
# Every record in the first window is schema-invalid: the pager must look past them and return
# the good record so has_more is False and the client advances off offset 0.
for i in range(3):
_save_with_mtime(f"BAD{i}", 300.0 - i)
_save_with_mtime("good", 10.0)
def _valid(rec):
return not str(rec.get("prompt", "")).startswith("BAD")
records = gallery.list_videos(limit = 2, offset = 0, valid = _valid)
assert [r["prompt"] for r in records] == ["good"]
def test_save_leaves_no_orphan_mp4_when_sidecar_publish_fails(monkeypatch):
# If the sidecar (the pair's commit marker) fails to publish, the MP4 must not be left as an
# invisible orphan. Fail the second os.replace and assert nothing is stranded.
real_replace = gallery.os.replace
calls = {"n": 0}
def _replace(src, dst, *a, **k):
calls["n"] += 1
if calls["n"] == 2: # the sidecar publish
raise OSError("simulated sidecar failure")
return real_replace(src, dst, *a, **k)
monkeypatch.setattr(gallery.os, "replace", _replace)
with pytest.raises(OSError, match = "simulated sidecar failure"):
gallery.save(_mp4(), _meta())
# No mp4, no sidecar, no temp files -- the whole record was rolled back.
assert list(gallery.gallery_dir().iterdir()) == []
assert gallery.list_videos() == []
def _real_mp4_bytes(
frames: int = 8,
size: int = 32,
rate: int = 8,
) -> bytes:
# A real (tiny) MP4 for the transcode tests: flat-color frames encoded with mpeg4 (bundled in
# every PyAV build, unlike libx264). The GIF cap tests ask for more/larger frames.
av = pytest.importorskip("av")
np = pytest.importorskip("numpy")
import io
buf = io.BytesIO()
with av.open(buf, "w", format = "mp4") as out:
stream = out.add_stream("mpeg4", rate = rate)
stream.width = size
stream.height = size
stream.pix_fmt = "yuv420p"
for i in range(frames):
frame = av.VideoFrame.from_ndarray(
np.full((size, size, 3), (i * 30) % 256, dtype = np.uint8), format = "rgb24"
)
for packet in stream.encode(frame):
out.mux(packet)
for packet in stream.encode():
out.mux(packet)
return buf.getvalue()
def _real_mp4_with_audio(
seconds: int = 1,
size: int = 32,
rate: int = 8,
) -> bytes:
# An LTX-2-shaped clip: video plus a synchronized audio track (a 440 Hz tone), so the WebM
# export can be checked for the track rather than assumed silent.
av = pytest.importorskip("av")
np = pytest.importorskip("numpy")
import io
import math
from fractions import Fraction
arate = 44100
buf = io.BytesIO()
with av.open(buf, "w", format = "mp4") as out:
video = out.add_stream("mpeg4", rate = rate)
video.width = video.height = size
video.pix_fmt = "yuv420p"
audio = out.add_stream("aac", rate = arate)
audio.layout = "stereo"
for i in range(seconds * rate):
frame = av.VideoFrame.from_ndarray(
np.full((size, size, 3), (i * 30) % 256, dtype = np.uint8), format = "rgb24"
)
for packet in video.encode(frame):
out.mux(packet)
written = 0
while written < seconds * arate:
count = min(1024, seconds * arate - written)
tone = np.array(
[
int(20000 * math.sin(2 * math.pi * 440 * (written + k) / arate))
for k in range(count)
],
dtype = np.int16,
)
# Packed s16 is one interleaved row.
frame = av.AudioFrame.from_ndarray(
np.repeat(tone, 2).reshape(1, count * 2), format = "s16", layout = "stereo"
)
frame.sample_rate = arate
frame.pts = written
frame.time_base = Fraction(1, arate)
for packet in audio.encode(frame):
out.mux(packet)
written += count
for packet in video.encode():
out.mux(packet)
for packet in audio.encode():
out.mux(packet)
return buf.getvalue()
def test_webm_export_keeps_the_audio_track():
"""WebM is offered as the web-embed format, so a clip generated with synchronized audio (LTX-2)
must not come back mute: the exporter has to mux the track as Opus, WebM's audio codec."""
av = pytest.importorskip("av")
import io
record = gallery.save(_real_mp4_with_audio(), _meta())
webm = gallery.transcode(record["id"], "webm")
assert webm is not None and webm[:4] == b"\x1a\x45\xdf\xa3"
with av.open(io.BytesIO(webm)) as container:
kinds = {(s.type, s.codec_context.name) for s in container.streams}
assert ("video", "vp9") in kinds
assert ("audio", "opus") in kinds
samples = 0
with av.open(io.BytesIO(webm)) as container:
for frame in container.decode(audio = 0):
samples += frame.samples
# A full second of 48 kHz audio survived (Opus pads its last 20 ms frame).
assert samples >= 48000, samples
def test_webm_export_still_works_without_an_audio_encoder(monkeypatch):
# A PyAV build with no libopus must keep exporting the video rather than failing the download.
av = pytest.importorskip("av")
import io
real_add_stream = av.container.OutputContainer.add_stream
def _no_opus(
self,
codec_name = None,
*args,
**kwargs,
):
if codec_name == "libopus":
raise ValueError("unknown encoder 'libopus'")
return real_add_stream(self, codec_name, *args, **kwargs)
monkeypatch.setattr(av.container.OutputContainer, "add_stream", _no_opus)
record = gallery.save(_real_mp4_with_audio(), _meta())
webm = gallery.transcode(record["id"], "webm")
assert webm is not None and webm[:4] == b"\x1a\x45\xdf\xa3"
with av.open(io.BytesIO(webm)) as container:
assert [s.type for s in container.streams] == ["video"]
def test_transcode_gif_and_webm_produce_real_containers():
record = gallery.save(_real_mp4_bytes(), _meta())
gif = gallery.transcode(record["id"], "gif")
assert gif is not None and gif.startswith(b"GIF8")
webm = gallery.transcode(record["id"], "webm")
# EBML magic: WebM is a Matroska container.
assert webm is not None and webm[:4] == b"\x1a\x45\xdf\xa3"
def test_transcode_unknown_id_and_bad_format():
assert gallery.transcode("does-not-exist", "gif") is None
record = gallery.save(_real_mp4_bytes(), _meta())
with pytest.raises(ValueError):
gallery.transcode(record["id"], "avi")
def test_transcode_to_file_writes_a_temp_file_the_caller_owns():
# The route streams the export from disk instead of materialising it: the request caps allow
# 2048x2048 x 1024 frames, and a VP9 export of that size held as bytes (then again in the
# response) let concurrent exports exhaust the process.
record = gallery.save(_real_mp4_bytes(), _meta())
for fmt, magic in (("webm", b"\x1a\x45\xdf\xa3"), ("gif", b"GIF8")):
path = gallery.transcode_to_file(record["id"], fmt)
assert path is not None and path.is_file(), fmt
assert path.suffix == f".{fmt}"
assert path.read_bytes()[: len(magic)] == magic
# It is a temp file, NOT something inside the gallery: deleting it must not touch the clip.
path.unlink()
assert gallery.video_path(record["id"]) is not None
assert gallery.transcode_to_file("does-not-exist", "webm") is None
def test_transcode_to_file_leaves_no_temp_file_when_the_encode_fails(monkeypatch):
# A half-written export must not accumulate in the temp dir on a host with no VP9 encoder.
import tempfile
from core.inference import video_gallery as vg
record = gallery.save(_real_mp4_bytes(), _meta())
def _boom(src, dest):
dest.write_bytes(b"partial")
raise RuntimeError("WebM export failed (libvpx-vp9 unavailable?)")
monkeypatch.setattr(vg, "_transcode_webm", _boom)
before = set(Path(tempfile.gettempdir()).glob("unsloth-export-*"))
with pytest.raises(RuntimeError):
vg.transcode_to_file(record["id"], "webm")
assert set(Path(tempfile.gettempdir()).glob("unsloth-export-*")) == before
def test_gif_export_bounds_frames_and_edge(monkeypatch):
"""Every kept frame is held as a paletted image before the encoder runs, so an unbounded walk
is a memory bomb: the generate request allows 2048x2048 for 1024 frames, and at the 12 fps
target the step is 1, which is over 4 GB of frames plus the GIF buffer. Cap both axes."""
import io as _io
from core.inference import video_gallery as vg
Image = pytest.importorskip("PIL.Image")
monkeypatch.setattr(vg, "_GIF_MAX_EDGE", 16)
monkeypatch.setattr(vg, "_GIF_MAX_FRAMES", 4)
record = gallery.save(_real_mp4_bytes(frames = 24, size = 64, rate = 12), _meta())
gif = vg._transcode_gif(vg.gallery_dir() / f"{record['id']}.mp4")
assert gif.startswith(b"GIF8")
with Image.open(_io.BytesIO(gif)) as im:
assert max(im.size) <= 16, im.size
frames = 1
try:
while True:
im.seek(im.tell() + 1)
frames += 1
except EOFError:
pass
assert frames <= 4, frames