From c221452a0003636a088cdfab8fd9c053b147cecd Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 11 Nov 2026 01:50:55 +0100 Subject: [PATCH] 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. --- backend/app/downloads/clip.py | 28 +++- backend/app/downloads/edit.py | 25 +++- backend/app/downloads/errors.py | 6 + backend/app/downloads/gif.py | 124 +++++++++++++++++ backend/app/downloads/links.py | 4 +- backend/app/downloads/og.py | 55 ++++++-- backend/app/downloads/pipeline.py | 82 +++++++++-- backend/app/downloads/preview.py | 10 ++ backend/app/downloads/service.py | 33 ++++- backend/app/main.py | 9 +- backend/app/routes/downloads.py | 8 +- backend/app/routes/files.py | 9 +- backend/app/routes/public.py | 19 ++- backend/tests/test_clip.py | 117 ++++++++++++++++ backend/tests/test_download_errors.py | 2 + backend/tests/test_edit_gif.py | 96 +++++++++++++ backend/tests/test_gif.py | 135 ++++++++++++++++++ backend/tests/test_gif_share.py | 137 +++++++++++++++++++ frontend/src/i18n/locales/en/clip.json | 4 +- frontend/src/i18n/locales/en/downloads.json | 7 + frontend/src/i18n/locales/hu/clip.json | 4 +- frontend/src/i18n/locales/hu/downloads.json | 7 + frontend/src/lib/api/refusalText.dom.test.ts | 9 ++ frontend/src/lib/api/refusalText.ts | 13 +- 24 files changed, 885 insertions(+), 58 deletions(-) create mode 100644 backend/app/downloads/gif.py create mode 100644 backend/tests/test_edit_gif.py create mode 100644 backend/tests/test_gif.py create mode 100644 backend/tests/test_gif_share.py diff --git a/backend/app/downloads/clip.py b/backend/app/downloads/clip.py index 686e8506..3b6ff2f9 100644 --- a/backend/app/downloads/clip.py +++ b/backend/app/downloads/clip.py @@ -12,6 +12,9 @@ given in fractions of the box it was drawn in, with that box's aspect: the YouTu tells its picture's size, so the frame there is drawn over the player's 16:9 box (a short's 9:16), and the cut maps it onto the real picture (`crop_filter`) — what lies over a letterbox bar is dropped. +A clip may be a GIF instead (`spec["gif"]`, downloads/gif): the same range, crop and tonemap, then a +silent animated picture of at most 30 s rather than a video. + The spec is stored on the job (`DownloadJob.edit_spec`) and is the clip's whole identity: its signature is the asset's cache key, so marking the same range again is an instant cache hit, and a different range or audio track is a different clip. @@ -24,6 +27,7 @@ import re from pathlib import Path from typing import Literal +from app.downloads import gif as gifmod from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS from app.plex.sync import HDR_TRANSFERS @@ -70,6 +74,8 @@ ClipReason = Literal[ "not_found", "media_missing", "past_end", + "bad_gif", + "gif_too_long", ] @@ -90,9 +96,12 @@ def normalize( end_s: float, audio_track: int | None = None, crop: dict | None = None, + gif_side: object = None, ) -> dict: """The validated, canonical clip spec. Times are rounded to the millisecond and a crop to four - decimals, so the same marks always give the same cache key. Raises ClipError.""" + decimals, so the same marks always give the same cache key. A `gif_side` makes it a GIF of that + size (`downloads/gif`), held to the GIF's own length cap; without one it is the video clip it + always was, spec and cache key alike. Raises ClipError.""" if source not in SOURCES: raise ClipError("bad_source") pattern = _YOUTUBE_ID if source == "youtube" else _RATING_KEY @@ -103,9 +112,20 @@ def normalize( start, end = round(start_s, 3), round(end_s, 3) if end - start < MIN_CLIP_S: raise ClipError("too_short") + side = None + if gif_side is not None: + try: + side = gifmod.normalize_side(gif_side) + except ValueError as e: + raise ClipError("bad_gif") from e + if side is not None and end - start > gifmod.MAX_S: + # Its own reason: a GIF's cap is said in seconds, a clip's in minutes. + raise ClipError("gif_too_long", limit_s=gifmod.MAX_S) if end - start > MAX_CLIP_S: raise ClipError("too_long", limit_s=MAX_CLIP_S) spec: dict = {"source": source, "ref": ref, "start_s": start, "end_s": end} + if side is not None: + spec["gif"] = {"side": side} if source == "plex" and audio_track is not None: if not 0 <= audio_track <= _MAX_AUDIO_TRACK: raise ClipError("bad_audio") @@ -257,6 +277,12 @@ def audio_track_count(probe: dict) -> int: return sum(1 for s in probe.get("streams") or [] if s.get("codec_type") == "audio") +def gif_pre(crop: str, hdr: bool) -> str: + """What a GIF clip's frames go through before the GIF's own size and palette: the crop, then an HDR + source's tonemap (a palette made from PQ frames is as washed out as the frames are).""" + return ",".join([*([crop] if crop else []), *([_TONEMAP] if hdr else [])]) + + def build_cmd( src: Path, dest: Path, diff --git a/backend/app/downloads/edit.py b/backend/app/downloads/edit.py index 7e9a6242..88715fc6 100644 --- a/backend/app/downloads/edit.py +++ b/backend/app/downloads/edit.py @@ -109,6 +109,12 @@ def normalize_edit_spec(spec: dict | None) -> dict: spec = spec or {} out: dict = {} + # A GIF output (downloads/gif) keeps its size; one it cannot name is dropped here and refused by + # the service, which reads the raw spec for exactly that (`gif_side`). + side = gif_side(spec) + if side is not None: + out["gif"] = {"side": side} + # Crop applies to both a single trim and a multi-segment join. crop = spec.get("crop") or {} if crop: @@ -197,6 +203,21 @@ def normalize_edit_spec(spec: dict | None) -> dict: return out +def gif_side(spec: dict | None) -> int | None: + """The GIF size a raw edit spec asks for, or None when it asks for none — or for one that is not + offered (the service tells the two apart by `"gif" in spec`).""" + # Imported here: `gif` builds its command on this module's input guard. + from app.downloads import gif as gifmod + + raw = (spec or {}).get("gif") + if not isinstance(raw, dict): + return None + try: + return gifmod.normalize_side(raw.get("side")) + except ValueError: # silent-ok: an unoffered size is refused by the service (`bad_gif`) + return None + + def edit_sig(spec: dict) -> str: """Stable 24-char signature of a (normalized) edit spec — the per-user cache identity.""" payload = json.dumps(normalize_edit_spec(spec), sort_keys=True, separators=(",", ":")) @@ -208,8 +229,8 @@ def has_crop(spec: dict) -> bool: def needs_reencode(spec: dict) -> bool: - """A crop always re-encodes; a trim re-encodes only when the user asked for an accurate cut.""" - return bool(spec.get("crop")) or bool(spec.get("accurate")) + """A crop or a GIF always re-encodes; a trim only when the user asked for an accurate cut.""" + return bool(spec.get("crop")) or bool(spec.get("gif")) or bool(spec.get("accurate")) def clip_duration(spec: dict, source_duration: int | None) -> int | None: diff --git a/backend/app/downloads/errors.py b/backend/app/downloads/errors.py index 846dcb03..8bf5a62c 100644 --- a/backend/app/downloads/errors.py +++ b/backend/app/downloads/errors.py @@ -68,6 +68,10 @@ CONVERTED_TOO_BIG_MESSAGE = ( "free up space, then try again." ) UPLOAD_MISSING_MESSAGE = "The uploaded file is missing." +# A GIF over `gif.MAX_BYTES`: not the storage left, a cap of the GIF's own (its own category). +GIF_TOO_BIG_MESSAGE = ( + "The GIF came out larger than a GIF may be — mark a shorter range or choose a smaller size." +) NETWORK_REDACTED_MESSAGE = "The server could not reach that address." # (marker, category) — first match wins, so order most-specific → most-general. Matching is @@ -77,6 +81,8 @@ _RULES: tuple[tuple[str, str], ...] = ( # first — it is our wording, so it can never be a false match on an upstream message. ("download storage is full", "quota_exceeded"), ("larger than the download storage you have left", "quota_exceeded"), + # Our own refusal of a GIF over its size cap — the range or the size, not the storage left. + ("larger than a gif may be", "gif_too_big"), # Our own redaction of a transport failure (see NETWORK_REDACTED_MESSAGE) — our wording too. (NETWORK_REDACTED_MESSAGE.lower(), "network"), # Our own verdicts on a post link (adapters.py) — our wording too: its page holds no single video, diff --git a/backend/app/downloads/gif.py b/backend/app/downloads/gif.py new file mode 100644 index 00000000..c95f771e --- /dev/null +++ b/backend/app/downloads/gif.py @@ -0,0 +1,124 @@ +"""The GIF output of a clip or an edit: its limits and the one ffmpeg command every GIF goes through. + +A GIF is a picture, not a video: no sound, a library `file` that previews as an image (ffprobe's `gif` +format is one of `probe._IMAGE_FORMATS`), and never the source of another edit. The numbers were +measured on real sources (2026-11-09/11): + +- 12 frames a second, the long side capped (320 / 480 / 640): a width cap made a phone's portrait + video three times the size of a landscape one (44 MB for 21 s at "480"). +- One palette per GIF, made from the GIF's own frames (`palettegen` → `paletteuse`, light ordered + dither): the generic 256-colour palette bands every gradient. +- 30 s at most. 480 px fills 12–29 MB in 30 s, depending on the picture; a GIF over `MAX_BYTES` is + not kept, and the owner is told to cut less or smaller. +- A chat link preview: Teams moves a GIF `og:image` up to 15 MB (22 MB showed nothing), so + `PREVIEW_MAX_BYTES` decides whether the share page offers the GIF itself or its still (`ensure_still`). +""" + +import subprocess +from pathlib import Path + +from app.downloads.edit import INPUT_GUARD + +SIDES = (320, 480, 640) +DEFAULT_SIDE = 480 +FPS = 12 +MAX_S = 30 +PREVIEW_MAX_BYTES = 15 * 1024 * 1024 +MAX_BYTES = 30 * 1024 * 1024 + +# The still is cut from the GIF we made, read with the GIF demuxer alone: a library file named .gif +# whose bytes are a playlist must not open the paths inside it (see `edit.MEDIA_DEMUXERS`). +_GIF_GUARD = ["-format_whitelist", "gif"] +_STILL_TIMEOUT_S = 30 + + +def normalize_side(side: object) -> int: + """One of `SIDES`, or ValueError. A bool or a float is not a size, even when it equals one.""" + if type(side) is not int or side not in SIDES: + raise ValueError(side) + return side + + +def _scale(side: int) -> str: + """Fit the long side into `side`, never upscaling a smaller picture; the short side keeps the + aspect (-1: a GIF has no chroma grid that wants it even).""" + return ( + f"scale=w='if(gte(iw,ih),min({side},iw),-1)'" + f":h='if(gte(iw,ih),-1,min({side},ih))':flags=lanczos" + ) + + +def filter_graph(pre: str, side: int) -> str: + """The GIF's filters: `pre` (a crop, an HDR tonemap) on the source's own frame first, then the + frame rate and the size, then a palette made from these frames and applied to them.""" + head = ",".join([*([pre] if pre else []), f"fps={FPS}", _scale(side)]) + return ( + f"[0:v:0]{head},split[a][b];" + "[a]palettegen=stats_mode=diff[p];" + "[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" + ) + + +def build_cmd( + src: Path, dest: Path, start_s: float, dur_s: float, *, pre: str, side: int +) -> list[str]: + """ffmpeg for one GIF: an input seek (the GIF starts on the marked frame), the first video stream + through `filter_graph`, nothing else — no sound, subtitles, data or chapters.""" + return [ + "ffmpeg", + "-y", + "-hide_banner", + "-loglevel", + "error", + "-nostdin", + "-progress", + "pipe:1", + "-nostats", + "-ss", + f"{start_s:.3f}", + *INPUT_GUARD, + "-i", + str(src), + "-t", + f"{dur_s:.3f}", + "-filter_complex", + filter_graph(pre, side), + "-an", + "-sn", + "-dn", + "-map_chapters", + "-1", + str(dest), + ] + + +def size(path: Path) -> tuple[int, int] | None: + """(width, height) from a GIF's header — ffprobe reports none for an image format, and the share + page's preview card wants them. None for anything that is not a GIF.""" + try: + with path.open("rb") as f: + head = f.read(10) + # silent-ok: no size is what an unreadable file has; the card is then built without one. + except OSError: + return None + if len(head) < 10 or head[:6] not in (b"GIF87a", b"GIF89a"): + return None + return int.from_bytes(head[6:8], "little"), int.from_bytes(head[8:10], "little") + + +def ensure_still(path: Path) -> Path | None: + """A JPEG still of the GIF's first frame beside it (`.jpg`, the poster sidecar every library + file uses): the link preview for a chat that shows no GIF (Messenger) or a GIF too big to move. + None when it cannot be cut — the card is then built without a picture.""" + dest = path.with_suffix(".jpg") + cmd = [ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin", + *_GIF_GUARD, "-i", str(path), "-frames:v", "1", "-q:v", "3", str(dest), + ] # fmt: skip + try: + subprocess.run(cmd, capture_output=True, timeout=_STILL_TIMEOUT_S, check=True) + # silent-ok: the still is best-effort; without it the card has no picture, which is true. + except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): + dest.unlink(missing_ok=True) + return None + return dest if dest.exists() else None diff --git a/backend/app/downloads/links.py b/backend/app/downloads/links.py index f7c48724..162e92f2 100644 --- a/backend/app/downloads/links.py +++ b/backend/app/downloads/links.py @@ -171,7 +171,7 @@ def public_meta( Resolves the owner's per-download display overrides (custom title/channel) over the asset's auto-extracted values, and surfaces the source + any extra reference links as clickable URLs.""" - from app.downloads import mediakind, service + from app.downloads import mediakind, preview, service source_url = service.reference_url(job, asset) # Audio-only assets (mp3 rips, extracted audio) can't play inside a