better compression for GIFs
This commit is contained in:
1 parent
eac6dbda3a
commit
0b5ba6511e
1 file changed
+152
-13
+152
-13
@@ -18,8 +18,11 @@ FFPROBE = shutil.which("ffprobe")
|
|||||||
|
|
||||||
MAX_CONVERTED_BYTES = 8 * 1024 * 1024 # bigger than this, discord rejects it
|
MAX_CONVERTED_BYTES = 8 * 1024 * 1024 # bigger than this, discord rejects it
|
||||||
|
|
||||||
_GIF_FILTER = ("fps=15,scale=320:-1:flags=lanczos,split[s0][s1];"
|
class _Oversize:
|
||||||
"[s0]palettegen[p];[s1][p]paletteuse")
|
__slots__ = ("size",)
|
||||||
|
|
||||||
|
def __init__(self, size: int):
|
||||||
|
self.size = size
|
||||||
|
|
||||||
_lottie_state = None # None = not yet checked
|
_lottie_state = None # None = not yet checked
|
||||||
|
|
||||||
@@ -96,8 +99,12 @@ def _decoder_args(in_suffix: str) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
async def _convert(data: bytes, in_suffix: str, out_suffix: str,
|
async def _convert(data: bytes, in_suffix: str, out_suffix: str,
|
||||||
args, timeout: float):
|
args, timeout: float, max_bytes: int = MAX_CONVERTED_BYTES):
|
||||||
"""Shared conversion body. Returns bytes, or None if it did not work out."""
|
"""Shared conversion body.
|
||||||
|
|
||||||
|
Returns bytes, `_OVERSIZE` if the encode worked but blew the budget, or
|
||||||
|
None if it genuinely failed.
|
||||||
|
"""
|
||||||
if not FFMPEG:
|
if not FFMPEG:
|
||||||
return None
|
return None
|
||||||
src = dst = None
|
src = dst = None
|
||||||
@@ -111,9 +118,10 @@ async def _convert(data: bytes, in_suffix: str, out_suffix: str,
|
|||||||
log.debug("ffmpeg rc=%s: %s", code, out.decode(errors="replace")[:300])
|
log.debug("ffmpeg rc=%s: %s", code, out.decode(errors="replace")[:300])
|
||||||
return None
|
return None
|
||||||
size = os.path.getsize(dst)
|
size = os.path.getsize(dst)
|
||||||
if size > MAX_CONVERTED_BYTES:
|
if size > max_bytes:
|
||||||
log.info("converted media too large (%d bytes) — keeping original", size)
|
log.debug("encode over budget: %.2f MB > %.2f MB",
|
||||||
return None
|
size / 1048576, max_bytes / 1048576)
|
||||||
|
return _Oversize(size)
|
||||||
with open(dst, "rb") as handle:
|
with open(dst, "rb") as handle:
|
||||||
return handle.read()
|
return handle.read()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -129,18 +137,149 @@ async def to_animated_webp(data: bytes, in_suffix: str = ".webm"):
|
|||||||
|
|
||||||
GIF only has on/off transparency, which is what haloed every sticker edge.
|
GIF only has on/off transparency, which is what haloed every sticker edge.
|
||||||
"""
|
"""
|
||||||
return await _convert(
|
out = await _convert(
|
||||||
data, in_suffix, ".webp",
|
data, in_suffix, ".webp",
|
||||||
["-c:v", "libwebp_anim", "-pix_fmt", "yuva420p", "-lossless", "0",
|
["-c:v", "libwebp_anim", "-pix_fmt", "yuva420p", "-lossless", "0",
|
||||||
"-q:v", "70", "-loop", "0", "-an", "-fps_mode", "passthrough"],
|
"-q:v", "70", "-loop", "0", "-an", "-fps_mode", "passthrough"],
|
||||||
timeout=45.0)
|
timeout=45.0)
|
||||||
|
if isinstance(out, _Oversize):
|
||||||
|
log.info("animated webp too large — keeping original")
|
||||||
|
return None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def to_gif(data: bytes, in_suffix: str = ".mp4"):
|
def _gif_filter(fps=None, width=None, per_frame=True, dither="sierra2_4a"):
|
||||||
"""Convert a silent clip to a looping GIF (Discord autoplays these)."""
|
pre = []
|
||||||
return await _convert(data, in_suffix, ".gif",
|
if fps:
|
||||||
["-filter_complex", _GIF_FILTER, "-loop", "0"],
|
pre.append(f"fps={fps}")
|
||||||
timeout=45.0)
|
if width:
|
||||||
|
# min() so we never upscale something already smaller than the cap
|
||||||
|
pre.append(f"scale='min({width},iw)':-1:flags=lanczos")
|
||||||
|
prefix = ",".join(pre) + "," if pre else ""
|
||||||
|
|
||||||
|
if per_frame:
|
||||||
|
gen = "palettegen=stats_mode=single:reserve_transparent=0"
|
||||||
|
use = f"paletteuse=new=1:dither={dither}"
|
||||||
|
else:
|
||||||
|
gen = "palettegen=stats_mode=diff"
|
||||||
|
use = f"paletteuse=dither={dither}"
|
||||||
|
return f"{prefix}split[s0][s1];[s0]{gen}[p];[s1][p]{use}"
|
||||||
|
|
||||||
|
|
||||||
|
# Quality ladder, best first. Each rung gives up the least it can:
|
||||||
|
# per-frame palette -> global palette -> cheaper dither -> framerate -> size.
|
||||||
|
# Dither drops before framerate deliberately: dithering breaks up LZW runs, so
|
||||||
|
# it costs more bytes than resolution does. Measured on a 6s 640x480 clip,
|
||||||
|
# sierra2_4a -> bayer:5 went 13.8 MB -> 8.4 MB with no visible loss in motion.
|
||||||
|
# The last rung is the old hardcoded behaviour, kept as a floor, not a default.
|
||||||
|
_GIF_TIERS = (
|
||||||
|
dict(fps=None, width=None, per_frame=True, dither="sierra2_4a"),
|
||||||
|
dict(fps=None, width=None, per_frame=False, dither="sierra2_4a"),
|
||||||
|
dict(fps=None, width=None, per_frame=False, dither="bayer:bayer_scale=5"),
|
||||||
|
dict(fps=24, width=None, per_frame=False, dither="bayer:bayer_scale=5"),
|
||||||
|
dict(fps=20, width=720, per_frame=False, dither="bayer:bayer_scale=5"),
|
||||||
|
dict(fps=20, width=560, per_frame=False, dither="bayer:bayer_scale=5"),
|
||||||
|
dict(fps=15, width=420, per_frame=False, dither="bayer:bayer_scale=5"),
|
||||||
|
dict(fps=15, width=320, per_frame=False, dither="bayer:bayer_scale=5"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_GIF_BYTES_PER_PXFRAME = 0.03
|
||||||
|
|
||||||
|
GIF_ATTEMPT_TIMEOUT = 120.0 # native-res per-frame palettes are not fast
|
||||||
|
GIF_TOTAL_BUDGET = 240.0 # ceiling across the whole ladder
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_video(data: bytes, suffix: str = ".mp4"):
|
||||||
|
"""-> (width, height, duration, fps), or None if ffprobe cannot say."""
|
||||||
|
if not FFPROBE:
|
||||||
|
return None
|
||||||
|
src = None
|
||||||
|
try:
|
||||||
|
src = await asyncio.to_thread(_write_temp, data, suffix)
|
||||||
|
code, out = await _run([
|
||||||
|
FFPROBE, "-v", "error", "-select_streams", "v:0", "-show_entries",
|
||||||
|
"stream=width,height,avg_frame_rate:format=duration",
|
||||||
|
"-of", "default=noprint_wrappers=1:nokey=0", src,
|
||||||
|
], timeout=15.0)
|
||||||
|
if code != 0:
|
||||||
|
return None
|
||||||
|
fields = dict(
|
||||||
|
line.split("=", 1)
|
||||||
|
for line in out.decode(errors="replace").splitlines() if "=" in line)
|
||||||
|
width = int(fields.get("width", 0))
|
||||||
|
height = int(fields.get("height", 0))
|
||||||
|
duration = float(fields.get("duration", 0) or 0)
|
||||||
|
num, _, den = fields.get("avg_frame_rate", "0/0").partition("/")
|
||||||
|
fps = float(num) / float(den) if float(den or 0) else 0.0
|
||||||
|
if not (width and height and duration > 0 and fps > 0):
|
||||||
|
return None
|
||||||
|
return width, height, duration, fps
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("video probe failed (%s) — trying every tier", exc)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
await asyncio.to_thread(_unlink, src)
|
||||||
|
|
||||||
|
|
||||||
|
def _tier_cost(tier) -> float:
|
||||||
|
return 1.0 if tier["dither"].startswith("sierra") else 0.61
|
||||||
|
|
||||||
|
|
||||||
|
def _pixel_frames(tier, probed):
|
||||||
|
if not probed:
|
||||||
|
return None
|
||||||
|
width, height, duration, fps = probed
|
||||||
|
out_w = min(tier["width"], width) if tier["width"] else width
|
||||||
|
out_h = height * (out_w / width)
|
||||||
|
out_fps = min(tier["fps"], fps) if tier["fps"] else fps
|
||||||
|
return out_w * out_h * duration * out_fps * _tier_cost(tier)
|
||||||
|
|
||||||
|
|
||||||
|
async def to_gif(data: bytes, in_suffix: str = ".mp4",
|
||||||
|
max_bytes: int = MAX_CONVERTED_BYTES):
|
||||||
|
if not FFMPEG:
|
||||||
|
return None
|
||||||
|
|
||||||
|
probed = await probe_video(data, in_suffix)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
started = loop.time()
|
||||||
|
rate = _GIF_BYTES_PER_PXFRAME # refined once this clip tells us better
|
||||||
|
|
||||||
|
for index, tier in enumerate(_GIF_TIERS):
|
||||||
|
weighted = _pixel_frames(tier, probed)
|
||||||
|
if weighted and weighted * rate > max_bytes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
remaining = GIF_TOTAL_BUDGET - (loop.time() - started)
|
||||||
|
if remaining <= 5.0:
|
||||||
|
log.info("gif ladder out of time after tier %d", index)
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
out = await _convert(
|
||||||
|
data, in_suffix, ".gif",
|
||||||
|
["-filter_complex", _gif_filter(**tier), "-loop", "0"],
|
||||||
|
timeout=min(GIF_ATTEMPT_TIMEOUT, remaining),
|
||||||
|
max_bytes=max_bytes)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue # too slow at this quality, drop a rung
|
||||||
|
|
||||||
|
if isinstance(out, bytes):
|
||||||
|
log.info("gif tier %d: fps=%s width=%s per_frame=%s -> %.2f MB",
|
||||||
|
index, tier["fps"] or "native", tier["width"] or "native",
|
||||||
|
tier["per_frame"], len(out) / 1048576)
|
||||||
|
return out
|
||||||
|
if out is None:
|
||||||
|
# a real ffmpeg failure — every other tier reads the same input
|
||||||
|
log.debug("gif encode failed outright at tier %d — giving up", index)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if weighted:
|
||||||
|
rate = max(rate, out.size / weighted * 0.9)
|
||||||
|
|
||||||
|
log.info("no gif tier fit under %.1f MB — keeping original",
|
||||||
|
max_bytes / 1048576)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ── lottie (.tgs animated stickers) ────────────────────────────────────────
|
# ── lottie (.tgs animated stickers) ────────────────────────────────────────
|
||||||
|
|||||||
Reference in new issue
Block a user