diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b12ffd..41171261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.119.0 — 2026-11-13 + +### New + +- Make a GIF of up to 30 seconds: in a player's Clip panel, or in the Downloads editor, where each kept segment becomes a GIF of its own. Both say the expected size before it is made. +- A shared GIF's link opens the moving picture itself, with Download beside it. In Teams its preview moves when the GIF is 15 MB or less. + ## 0.118.0 — 2026-11-08 ### New diff --git a/VERSION b/VERSION index 12f9c914..f34340fc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.118.0 +0.119.0 diff --git a/backend/app/downloads/clip.py b/backend/app/downloads/clip.py index 686e8506..99d8385f 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 @@ -101,12 +110,27 @@ def normalize( if not (math.isfinite(start_s) and math.isfinite(end_s)) or start_s < 0: raise ClipError("bad_range") start, end = round(start_s, 3), round(end_s, 3) - if end - start < MIN_CLIP_S: + # Judged in whole milliseconds, as the ends are kept: a float difference lands a hair off + # (60.529 - 30.529 = 30.000000000000004), which would refuse a range exactly at a cap. + span_ms = round((end - start) * 1000) + if span_ms < MIN_CLIP_S * 1000: raise ClipError("too_short") - if end - start > MAX_CLIP_S: + 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 gifmod.too_long(start, end): + # 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 span_ms > MAX_CLIP_S * 1000: raise ClipError("too_long", limit_s=MAX_CLIP_S) spec: dict = {"source": source, "ref": ref, "start_s": start, "end_s": end} - if source == "plex" and audio_track is not None: + if side is not None: + spec["gif"] = {"side": side} + # A GIF has no sound: the track the player was on is not part of what it is (its cache key). + if source == "plex" and audio_track is not None and side is None: if not 0 <= audio_track <= _MAX_AUDIO_TRACK: raise ClipError("bad_audio") spec["audio_track"] = audio_track @@ -257,6 +281,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..be0c678f --- /dev/null +++ b/backend/app/downloads/gif.py @@ -0,0 +1,139 @@ +"""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 too_long(start_s: float, end_s: float) -> bool: + """Over `MAX_S`, counted in whole milliseconds: the ends are kept to the millisecond, and their + float difference may land a hair over (60.529 - 30.529 = 30.000000000000004), which would refuse + the very 30 s the panels allow.""" + return round((end_s - start_s) * 1000) > MAX_S * 1000 + + +# A GIF has no pixel shape a viewer honours (browsers ignore its aspect byte), so a source stored +# with wide or narrow pixels (an anamorphic DVD: 720x480 shown at 16:9) is first resampled to square +# ones, or it shows squeezed. `sar` is 1 for a source that names none; even, as a 4:2:0 frame wants. +_SQUARE_PIXELS = "scale=w='trunc(iw*sar/2)*2':h=ih,setsar=1" + + +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, square pixels and the size, then a palette made from these frames and applied to them. + A crop counts the stored frame (both callers' crops are fractions of `iw`/`ih`), so it comes + before the pixels are squared.""" + head = ",".join([*([pre] if pre else []), f"fps={FPS}", _SQUARE_PIXELS, _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