Files
siftlode/backend/tests/test_edit_gif.py
T
peter 8845d1aa49 fix(downloads): GIF review round 1 — shape, HDR, caps, audio, copy
- A GIF squares its pixels before the size, so an anamorphic source keeps
  its shown shape (a 720x480 DVD at 16:9 was 480x320).
- An editor GIF of an HDR upload is tonemapped, as a clip GIF is.
- The length caps are judged in whole milliseconds: a range exactly at a
  cap is no longer refused for a float hair over it.
- A GIF is made of a video only: an audio file is refused with its reason
  and the editor offers no GIF for it.
- A GIF clip keeps no audio track: the track neither splits its cache key
  nor fails it.
- The editor's GIF notes keep their live region mounted; one size reads
  as one ("about 1 MB"), none is shown with nothing kept; the size choices
  show their unit; a shared picture that fails to load says so.
- Copy: the two-sentence GIF notes keep their last stop (a copy-rule test
  now holds it); the English warns of "only a still picture".
2026-11-12 23:24:25 +01:00

178 lines
6.7 KiB
Python

"""The editor's GIF output: one marked range of a library video made into a GIF (downloads/gif), held
to the GIF's limits at enqueue, and made by the worker with real ffmpeg."""
import shutil
import subprocess
from pathlib import Path
import pytest
from app.config import settings
from app.downloads import clip as clipmod
from app.downloads import edit as editmod
from app.downloads import pipeline, service
from app.downloads import probe as probemod
from app.downloads import gif as gifmod
from app.downloads import upload as uploadmod
from app.downloads.models import DownloadJob, MediaAsset
needs_ffmpeg = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
def test_a_gif_edit_spec_keeps_its_size_and_is_another_edit_than_the_video():
video = editmod.normalize_edit_spec({"trim": {"start_s": 1, "end_s": 5}})
gif = editmod.normalize_edit_spec({"trim": {"start_s": 1, "end_s": 5}, "gif": {"side": 320}})
assert gif["gif"] == {"side": 320} and "gif" not in video
assert editmod.edit_sig(gif) != editmod.edit_sig(video)
# A GIF is always made, never stream-copied.
assert editmod.needs_reencode(gif)
def _source(db, user, tmp_path, seconds=40):
src = tmp_path / "in.mp4"
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", f"testsrc2=size=640x360:rate=10:duration={seconds}",
"-c:v", "libx264", "-pix_fmt", "yuv420p", str(src),
],
check=True,
) # fmt: skip
return uploadmod.ingest_upload(
db, user.id, filename="in.mp4", src_path=src, size=src.stat().st_size
)
@needs_ffmpeg
@pytest.mark.parametrize(
("spec", "reason"),
[
# A GIF is one range with an end: no join, no open end.
({"segments": [{"start_s": 0, "end_s": 2}, {"start_s": 4, "end_s": 6}]}, "gif_range"),
({"trim": {"start_s": 3}}, "gif_range"),
({"trim": {"start_s": 0, "end_s": 30.5}}, "gif_too_long"),
({"trim": {"start_s": 0, "end_s": 5}, "gif_side": 999}, "bad_gif"),
],
)
def test_a_gif_edit_it_cannot_make_is_refused_with_its_reason(
db, make_user, tmp_path, monkeypatch, spec, reason
):
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
u = make_user()
source = _source(db, u, tmp_path)
side = spec.pop("gif_side", 480)
with pytest.raises(service.EditError) as e:
service.enqueue_edit(db, u.id, source, {**spec, "gif": {"side": side}})
assert e.value.reason == reason
@needs_ffmpeg
def test_a_gif_edit_of_exactly_thirty_seconds_is_allowed(db, make_user, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
u = make_user()
source = _source(db, u, tmp_path)
# Ends kept to the millisecond whose float difference lands a hair over 30 s.
assert 32.02 - 2.02 > gifmod.MAX_S
job = service.enqueue_edit(
db, u.id, source, {"trim": {"start_s": 2.02, "end_s": 32.02}, "gif": {"side": 480}}
)
assert job.edit_spec["gif"] == {"side": 480}
@needs_ffmpeg
def test_a_gif_is_made_of_a_video_never_of_audio(db, make_user, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
u = make_user()
src = tmp_path / "in.m4a"
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", "sine=frequency=440:duration=10", "-c:a", "aac", str(src),
],
check=True,
) # fmt: skip
source = uploadmod.ingest_upload(
db, u.id, filename="in.m4a", src_path=src, size=src.stat().st_size
)
assert db.get(MediaAsset, source.asset_id).media_kind == "audio"
spec = {"trim": {"start_s": 1, "end_s": 5}}
with pytest.raises(service.EditError) as e:
service.enqueue_edit(db, u.id, source, {**spec, "gif": {"side": 480}})
assert e.value.reason == "gif_not_video"
# The same range as audio is an edit as it always was.
assert service.enqueue_edit(db, u.id, source, spec).edit_spec["trim"]["end_s"] == 5
@needs_ffmpeg
def test_an_hdr_source_is_tonemapped_before_its_gif_palette(db, make_user, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
u = make_user()
src = tmp_path / "hlg.mp4"
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc2=size=640x360:rate=10:duration=4",
"-vf", "setparams=color_trc=arib-std-b67:color_primaries=bt2020:colorspace=bt2020nc",
"-c:v", "libx264", "-pix_fmt", "yuv420p", str(src),
],
check=True,
) # fmt: skip
source = uploadmod.ingest_upload(
db, u.id, filename="hlg.mp4", src_path=src, size=src.stat().st_size
)
# The upload keeps its HLG tag, as an HDR phone video's does.
placed = Path(settings.files_root) / db.get(MediaAsset, source.asset_id).rel_path
assert clipmod.is_hdr(probemod.probe_json(placed) or {})
job = service.enqueue_edit(
db, u.id, source, {"trim": {"start_s": 1, "end_s": 3}, "gif": {"side": 320}}
)
asset = db.get(MediaAsset, job.asset_id)
job.status, asset.status = "running", "downloading"
db.commit()
seen: dict = {}
build = gifmod.build_cmd
def spy(*a, **k):
seen["pre"] = k["pre"]
return build(*a, **k)
monkeypatch.setattr(pipeline.gifmod, "build_cmd", spy)
pipeline._process_edit(job.id, asset.id)
assert "tonemap" in seen["pre"]
db.expire_all()
assert db.get(DownloadJob, job.id).status == "done"
@needs_ffmpeg
def test_a_gif_edit_is_made_by_the_worker_and_kept_as_an_image_file(
db, make_user, tmp_path, monkeypatch
):
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
u = make_user()
source = _source(db, u, tmp_path, seconds=6)
spec = {"trim": {"start_s": 1, "end_s": 3}, "crop": {"x": 0, "y": 0, "w": 320, "h": 360}}
job = service.enqueue_edit(db, u.id, source, {**spec, "gif": {"side": 320}})
asset = db.get(MediaAsset, job.asset_id)
assert asset.media_kind == "file"
job.status, asset.status = "running", "downloading" # the worker's claim
db.commit()
pipeline._process_edit(job.id, asset.id)
db.expire_all()
job, asset = db.get(DownloadJob, job.id), db.get(MediaAsset, job.asset_id)
assert (job.status, asset.status, asset.container, asset.media_kind) == (
"done",
"ready",
"gif",
"file",
)
placed = tmp_path / "root" / asset.rel_path
assert placed.read_bytes()[:6] == b"GIF89a"
# The crop's 320x360 has its long side (360) fitted into 320.
assert (asset.width, asset.height) == gifmod.size(placed) == (284, 320)
assert asset.poster_path and (tmp_path / "root" / asset.poster_path).read_bytes()[:2] == (
b"\xff\xd8"
)