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:
@@ -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
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user