A clip and an editor range can come out as a silent GIF (long side 320/480/640, 12 fps, at most 30 s, one palette per GIF); one over 30 MB is not kept. A shared picture is served inline under the preview's sandbox, and a GIF's link card is the GIF itself up to 15 MB, the still for Facebook's crawler.
97 lines
3.6 KiB
Python
97 lines
3.6 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
|
|
|
|
import pytest
|
|
|
|
from app.config import settings
|
|
from app.downloads import edit as editmod
|
|
from app.downloads import pipeline, service
|
|
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_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"
|
|
)
|