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
):
+2
View File
@@ -75,6 +75,8 @@ def test_our_own_worker_refusals_classify_as_themselves():
assert classify(dlerrors.CLIP_TOO_BIG_MESSAGE) == "quota_exceeded"
assert classify(dlerrors.CONVERTED_TOO_BIG_MESSAGE) == "quota_exceeded"
assert classify(dlerrors.DOWNLOAD_TOO_BIG_MESSAGE) == "quota_exceeded"
# A GIF over its own cap is not the storage: its own category, its own sentence.
assert classify(dlerrors.GIF_TOO_BIG_MESSAGE) == "gif_too_big"
def test_the_redacted_network_text_classifies_back_to_network():
+96
View File
@@ -0,0 +1,96 @@
"""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"
)
+135
View File
@@ -0,0 +1,135 @@
"""The GIF output (downloads/gif): its limits, the one ffmpeg command every GIF goes through, and what
that command really makes — run with real ffmpeg, since the size, the palette and the frame are the
encoder's, not the argv's."""
import shutil
import subprocess
from pathlib import Path
import pytest
from app.downloads import gif as gifmod
from app.downloads import mediakind
from app.downloads import probe as probemod
from app.downloads.edit import INPUT_GUARD
needs_ffmpeg = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
def test_a_side_is_one_of_the_offered_sizes_and_anything_else_is_refused():
assert gifmod.SIDES == (320, 480, 640) and gifmod.DEFAULT_SIDE == 480
for side in gifmod.SIDES:
assert gifmod.normalize_side(side) == side
for bad in (0, 100, 481, 1080, -480, True, "480", None, 480.5):
with pytest.raises(ValueError):
gifmod.normalize_side(bad)
def test_the_limits_are_the_measured_ones():
# 30 s at most; a link preview moves in Teams up to 15 MB (measured: 15 moved, 22 did not); a GIF
# over 30 MB is not kept.
assert gifmod.MAX_S == 30
assert gifmod.PREVIEW_MAX_BYTES == 15 * 1024 * 1024
assert gifmod.MAX_BYTES == 30 * 1024 * 1024
assert gifmod.FPS == 12
def _cmd(**kw):
args = {"pre": "", "side": 480, **kw}
return gifmod.build_cmd(Path("/m/in.mkv"), Path("/s/out.gif"), 61.5, 12.0, **args)
def test_the_seek_is_on_the_input_and_the_library_read_is_guarded():
cmd = _cmd()
i = cmd.index("-i")
assert cmd[cmd.index("-ss") + 1] == "61.500" and cmd.index("-ss") < i
assert cmd[i + 1] == "/m/in.mkv"
assert cmd[cmd.index("-t") + 1] == "12.000" and cmd.index("-t") > i
g = cmd.index(INPUT_GUARD[0])
assert cmd[g : g + len(INPUT_GUARD)] == INPUT_GUARD and g < i
assert cmd[-1] == "/s/out.gif"
def test_a_gif_has_no_sound_and_one_palette_of_its_own():
cmd = _cmd()
assert "-an" in cmd and "-sn" in cmd and "-dn" in cmd
graph = cmd[cmd.index("-filter_complex") + 1]
assert f"fps={gifmod.FPS}" in graph
assert "palettegen=stats_mode=diff" in graph
assert "paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" in graph
assert "flags=lanczos" in graph
def test_a_crop_or_a_tonemap_comes_before_the_scale_and_the_palette():
cmd = _cmd(pre="crop=100:100:0:0,format=yuv420p")
graph = cmd[cmd.index("-filter_complex") + 1]
assert graph.index("crop=100:100:0:0") < graph.index("scale=") < graph.index("palettegen")
def _video(path: Path, size: str, seconds: int = 2) -> Path:
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", f"testsrc2=size={size}:rate=25:duration={seconds}",
"-c:v", "libx264", "-pix_fmt", "yuv420p", str(path),
],
check=True,
) # fmt: skip
return path
@needs_ffmpeg
@pytest.mark.parametrize(
("size", "side", "expected"),
[
# The long side is capped, so a phone's portrait video is as small as a landscape one.
("1280x720", 480, (480, 270)),
("720x1280", 480, (270, 480)),
("1280x720", 320, (320, 180)),
# A picture already smaller than the size is never blown up.
("160x120", 640, (160, 120)),
],
)
def test_the_long_side_is_capped_and_a_small_picture_is_kept(tmp_path, size, side, expected):
src = _video(tmp_path / "in.mp4", size)
out = tmp_path / "out.gif"
subprocess.run(gifmod.build_cmd(src, out, 0.5, 1.0, pre="", side=side), check=True)
assert out.read_bytes()[:6] == b"GIF89a"
assert gifmod.size(out) == expected
@needs_ffmpeg
def test_a_made_gif_is_a_library_file_that_previews_as_an_image(tmp_path):
src = _video(tmp_path / "in.mp4", "320x240")
out = tmp_path / "out.gif"
subprocess.run(gifmod.build_cmd(src, out, 0.0, 1.0, pre="", side=320), check=True)
vcodec, acodec, *_ = probemod.probe_media(out)
assert mediakind.derive(vcodec, acodec) == mediakind.FILE
@needs_ffmpeg
def test_a_gif_gets_a_jpeg_still_for_link_previews(tmp_path):
src = _video(tmp_path / "in.mp4", "320x240")
out = tmp_path / "out.gif"
subprocess.run(gifmod.build_cmd(src, out, 0.0, 1.0, pre="", side=320), check=True)
still = gifmod.ensure_still(out)
assert still == out.with_suffix(".jpg")
assert still.read_bytes()[:3] == b"\xff\xd8\xff"
@needs_ffmpeg
def test_the_still_is_cut_only_from_a_real_gif(tmp_path):
# The still's read is held to the GIF demuxer: a "gif" that is something else gives no still.
fake = tmp_path / "out.gif"
fake.write_bytes(b"ffconcat version 1.0\nfile '/etc/passwd'\n")
assert gifmod.ensure_still(fake) is None
def test_the_size_is_read_from_the_gif_header_and_nothing_else_has_one(tmp_path):
g = tmp_path / "a.gif"
g.write_bytes(b"GIF89a" + (300).to_bytes(2, "little") + (170).to_bytes(2, "little") + b"\x00")
assert gifmod.size(g) == (300, 170)
other = tmp_path / "b.gif"
other.write_bytes(b"\x89PNG\r\n\x1a\n")
assert gifmod.size(other) is None
assert gifmod.size(tmp_path / "missing.gif") is None
+137
View File
@@ -0,0 +1,137 @@
"""A shared GIF (or any shared picture): served inline as itself on the public page, and its link
preview card. Teams moves a GIF `og:image` up to 15 MB; Messenger never showed one and showed a JPEG
(measured 2026-11-09/11), so Facebook's crawler and a GIF too big to move get the still. DB-lane."""
import pytest
from app import main
from app.config import settings
from app.downloads import og
from app.downloads import probe as probemod
from app.downloads.models import DownloadJob, MediaAsset
_PAGE = """<!doctype html><html><head>
<!--OG:START--><meta property="og:title" content="Siftlode" /><!--OG:END-->
</head><body><script src="/assets/watch.js"></script></body></html>"""
_GIF_PROBE = {
"streams": [{"codec_type": "video", "codec_name": "gif", "width": 300, "height": 170}],
"format": {"format_name": "gif"},
}
_GIF = b"GIF89a" + (300).to_bytes(2, "little") + (170).to_bytes(2, "little") + b"\x00" * 16
_TEAMS = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) SkypeUriPreview Preview/0.5 skype-url-preview@microsoft.com"
_FACEBOOK = "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"
@pytest.fixture
def pages(tmp_path, monkeypatch):
watch = tmp_path / "watch.html"
watch.write_text(_PAGE, encoding="utf-8")
monkeypatch.setattr(main, "WATCH_HTML", watch)
@pytest.fixture
def shared(as_user, make_user, tmp_path, monkeypatch, upload_headers):
"""Uploads `content` as `name` (ffprobe reading it as `data`) and shares it."""
monkeypatch.setattr(settings, "files_root", str(tmp_path / "media"))
owner = as_user(make_user())
def share(name: str, content: bytes, data: dict | None, **link: object) -> dict:
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
job = owner.post(
"/api/files/upload", content=content, headers=upload_headers(name, display_name=name)
)
assert job.status_code == 200, job.text
made = owner.post(f"/api/downloads/{job.json()['id']}/links", json=link)
assert made.status_code == 200, made.text
return {**made.json(), "job_id": job.json()["id"]}
return share
def _asset(db, made) -> MediaAsset:
return db.get(MediaAsset, db.get(DownloadJob, made["job_id"]).asset_id)
def test_a_shared_gif_is_served_inline_as_a_gif_and_can_run_nothing(shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE, allow_download=False)
r = client.get(f"/api/public/watch/{made['token']}/file")
assert r.status_code == 200
assert r.headers["content-type"] == "image/gif"
assert r.headers["content-disposition"].startswith("inline")
# A "gif" whose bytes are a page must not run on this login-free origin.
assert r.headers["x-content-type-options"] == "nosniff"
assert "sandbox" in r.headers["content-security-policy"]
assert r.content == _GIF
def test_a_gif_link_that_allows_download_hands_it_over_as_a_file(shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE, allow_download=True)
r = client.get(f"/api/public/watch/{made['token']}/file")
assert r.headers["content-type"] == "image/gif"
assert r.headers["content-disposition"].startswith("attachment")
def test_any_other_file_is_still_only_a_download(shared, client):
made = shared("notes.html", b"<script>alert(1)</script>", None)
r = client.get(f"/api/public/watch/{made['token']}/file")
assert r.headers["content-type"] == "application/octet-stream"
assert r.headers["content-disposition"].startswith("attachment")
def test_the_page_is_told_a_picture_is_a_picture(shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE)
meta = client.get(f"/api/public/watch/{made['token']}").json()
assert meta["preview"] == "image"
other = shared("backup.tar.gz", b"x", None)
assert client.get(f"/api/public/watch/{other['token']}").json()["preview"] is None
def _og(text: str, prop: str) -> str | None:
marker = f'property="{prop}" content="'
if marker not in text:
return None
return text.split(marker, 1)[1].split('"', 1)[0]
def test_a_gif_link_previews_as_the_moving_gif_itself(pages, shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE)
page = client.get(f"/file/{made['token']}", headers={"User-Agent": _TEAMS}).text
assert _og(page, "og:image") == (f"{settings.app_base}/api/public/watch/{made['token']}/file")
assert _og(page, "og:image:type") == "image/gif"
# Its size from its own header: a card wants it up front.
assert (_og(page, "og:image:width"), _og(page, "og:image:height")) == ("300", "170")
def test_facebooks_crawler_gets_the_still_of_a_gif(pages, shared, client, db):
made = shared("wave.gif", _GIF, _GIF_PROBE)
asset = _asset(db, made)
asset.poster_path = "wave.jpg"
db.commit()
page = client.get(f"/file/{made['token']}", headers={"User-Agent": _FACEBOOK}).text
assert _og(page, "og:image") == (
f"{settings.app_base}/api/public/watch/{made['token']}/poster.jpg"
)
assert _og(page, "og:image:type") == "image/jpeg"
def test_a_gif_too_big_to_move_previews_as_its_still(pages, shared, client, db):
made = shared("wave.gif", _GIF, _GIF_PROBE)
asset = _asset(db, made)
asset.poster_path = "wave.jpg"
asset.size_bytes = 15 * 1024 * 1024 + 1
db.commit()
page = client.get(f"/file/{made['token']}", headers={"User-Agent": _TEAMS}).text
assert _og(page, "og:image").endswith("/poster.jpg")
def test_a_password_gif_link_shows_nothing_of_it(pages, shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE, password="pw")
page = client.get(f"/file/{made['token']}", headers={"User-Agent": _TEAMS}).text
assert _og(page, "og:image") is None
def test_the_card_builder_takes_no_agent_as_a_browser(shared, db):
made = shared("wave.gif", _GIF, _GIF_PROBE)
assert 'content="image/gif"' in og._watch_tags(made["token"], db)