"""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_the_edit_route_says_a_gif_too_long_with_its_cap( db, make_user, as_user, tmp_path, monkeypatch ): monkeypatch.setattr(settings, "files_root", str(tmp_path / "root")) u = make_user() c = as_user(u) source = _source(db, u, tmp_path) r = c.post( "/api/downloads/edit", json={ "source_job_id": source.id, "edit_spec": {"trim": {"start_s": 0, "end_s": 30.5}, "gif": {"side": 480}}, }, ) assert r.status_code == 400, r.text # The cap goes along: the refusal's sentence says it ("at most 30 seconds"). assert r.json()["detail"] == {"code": "edit", "reason": "gif_too_long", "limit_s": 30} @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" )