feat(downloads): a clip or an edit can be a GIF, shared as a picture

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.
This commit is contained in:
2026-11-11 01:50:55 +01:00
parent fe38c07e7e
commit c221452a00
24 changed files with 885 additions and 58 deletions
+117
View File
@@ -710,6 +710,123 @@ def test_a_clip_larger_than_what_its_owner_has_left_is_refused_and_not_placed(
)
# --- a GIF clip ------------------------------------------------------------------------------------------
def test_a_gif_clip_spec_names_its_size_and_is_another_clip_than_the_video():
mp4 = clipmod.normalize("youtube", "abcdefghijk", 10, 20)
gif = clipmod.normalize("youtube", "abcdefghijk", 10, 20, gif_side=320)
assert gif == {**mp4, "gif": {"side": 320}}
# A video clip's spec (and so its cache key) is the one it always was.
assert "gif" not in mp4
assert clipmod.sig(gif) != clipmod.sig(mp4)
@pytest.mark.parametrize(
("kw", "reason", "params"),
[
({"end_s": 40.001, "gif_side": 480}, "gif_too_long", {"limit_s": 30}),
({"end_s": 20, "gif_side": 500}, "bad_gif", {}),
({"end_s": 20, "gif_side": True}, "bad_gif", {}),
],
)
def test_a_gif_clip_it_cannot_make_is_refused_with_its_reason(kw, reason, params):
with pytest.raises(clipmod.ClipError) as e:
clipmod.normalize("youtube", "abcdefghijk", 10, **kw)
assert (e.value.reason, e.value.params) == (reason, params)
def test_the_thirty_second_gif_cap_itself_is_allowed():
assert clipmod.duration(clipmod.normalize("plex", "42", 0, 30, gif_side=480)) == 30
def test_a_gif_clip_is_queued_as_a_file_and_too_long_says_its_own_cap(
db, make_user, as_user, yt_video
):
c = as_user(make_user())
r = _clip(c, source="youtube", ref=yt_video, gif_side=480)
assert r.status_code == 200, r.text
job = r.json()
assert job["edit_spec"]["gif"] == {"side": 480}
# A GIF is a picture: no player, no editor; it previews as an image.
assert db.get(MediaAsset, job["asset"]["id"]).media_kind == "file"
r = _clip(c, source="youtube", ref=yt_video, gif_side=480, end_s=45)
assert r.status_code == 422
assert r.json()["detail"] == {"code": "clip", "reason": "gif_too_long", "limit_s": 30}
def _gif_bytes(w: int, h: int, n: int = 0) -> bytes:
return b"GIF89a" + w.to_bytes(2, "little") + h.to_bytes(2, "little") + b"\x00" * (1 + n)
@pytest.fixture
def gif_worker(worker, monkeypatch):
"""The worker stand-in, writing a GIF header where a GIF is asked for; the still is a sidecar."""
seen = worker
seen["gif_extra"] = 0
def ffmpeg(cmd, *_a, **_k):
seen["cmd"] = cmd
out = Path(cmd[-1])
out.write_bytes(_gif_bytes(270, 480, seen["gif_extra"]) if out.suffix == ".gif" else b"x")
def still(path):
dest = path.with_suffix(".jpg")
dest.write_bytes(b"\xff\xd8\xff")
return dest
def phone_safe(*_a, **_k):
raise AssertionError("a GIF is not a video: no phone-safe pass")
monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", ffmpeg)
monkeypatch.setattr(pipeline.gifmod, "ensure_still", still)
monkeypatch.setattr(pipeline, "_ensure_phone_safe", phone_safe)
monkeypatch.setattr(probemod, "probe_media", lambda _p: (None, None, None, None, None))
return seen
def test_a_gif_clip_is_cut_through_the_gif_command_and_stored_as_an_image_file(
db, make_user, as_user, plex_episode, gif_worker
):
gif_worker["probe"] = {
"streams": [{"codec_type": "video", "color_transfer": "smpte2084"}, {"codec_type": "audio"}]
}
job = _clip(as_user(make_user()), source="plex", ref="777", gif_side=480, end_s=25).json()
job, asset = gif_worker["run"](job)
assert (job.status, asset.status, asset.container, asset.media_kind) == (
"done",
"ready",
"gif",
"file",
)
assert asset.rel_path.endswith(".gif")
assert (asset.width, asset.height) == (270, 480)
assert asset.poster_path == str(Path(asset.rel_path).with_suffix(".jpg"))
cmd = gif_worker["cmd"]
graph = cmd[cmd.index("-filter_complex") + 1]
# The HDR source is tonemapped before the palette is made from it; no sound rides along.
assert graph.index("tonemap") < graph.index("palettegen")
assert "-an" in cmd and "-map" not in cmd
assert cmd[cmd.index("-t") + 1] == "15.000"
def test_a_gif_over_its_size_cap_is_not_kept_and_says_why(
db, make_user, as_user, plex_episode, gif_worker, monkeypatch
):
from app.downloads import gif as gifmod
monkeypatch.setattr(gifmod, "MAX_BYTES", 20)
gif_worker["gif_extra"] = 50
job = _clip(as_user(make_user()), source="plex", ref="777", gif_side=640).json()
job, asset = gif_worker["run"](job)
assert (job.status, job.error_code, job.error, asset.rel_path) == (
"error",
"gif_too_big",
dlerrors.GIF_TOO_BIG_MESSAGE,
None,
)
def test_a_youtube_clip_range_yt_dlp_refuses_for_its_cap_says_it_is_too_big(
db, make_user, as_user, yt_video, worker, monkeypatch
):