"""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