Files
siftlode/backend/app/downloads/gif.py
T
peter c221452a00 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.
2026-11-11 01:50:55 +01:00

125 lines
4.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 (`<name>.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