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".
This commit is contained in:
2026-11-12 23:24:25 +01:00
parent 0b92b40db5
commit 8845d1aa49
23 changed files with 403 additions and 78 deletions
+81
View File
@@ -3,12 +3,15 @@ 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
@@ -63,6 +66,84 @@ def test_a_gif_edit_it_cannot_make_is_refused_with_its_reason(
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