- 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".
140 lines
5.6 KiB
Python
140 lines
5.6 KiB
Python
"""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
|