Files
siftlode/backend/app/downloads/gif.py
T
peter 8845d1aa49 fix(downloads): GIF review round 1 — shape, HDR, caps, audio, copy
- A GIF squares its pixels before the size, so an anamorphic source keeps
  its shown shape (a 720x480 DVD at 16:9 was 480x320).
- An editor GIF of an HDR upload is tonemapped, as a clip GIF is.
- The length caps are judged in whole milliseconds: a range exactly at a
  cap is no longer refused for a float hair over it.
- A GIF is made of a video only: an audio file is refused with its reason
  and the editor offers no GIF for it.
- A GIF clip keeps no audio track: the track neither splits its cache key
  nor fails it.
- The editor's GIF notes keep their live region mounted; one size reads
  as one ("about 1 MB"), none is shown with nothing kept; the size choices
  show their unit; a shared picture that fails to load says so.
- Copy: the two-sentence GIF notes keep their last stop (a copy-rule test
  now holds it); the English warns of "only a still picture".
2026-11-12 23:24:25 +01:00

140 lines
5.6 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 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 (`<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