The panels judge a GIF's length in whole milliseconds between the ends as sent, the server's own rule, and a refused length reads rounded up. A shared picture says it failed only once its retry failed too. Join's reason shows on a key's focus; the editor's failure toasts name GIFs. The edit route's cap is test-held, and DESIGN.md records the GIF UI.
198 lines
7.4 KiB
Python
198 lines
7.4 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_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"
|
|
)
|