From 0b5ba6511ecbc5776c204a988c3451e841f0ee06 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Wed, 9 Sep 2026 10:19:09 +0300 Subject: [PATCH] better compression for GIFs --- tgbridge/media.py | 165 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 152 insertions(+), 13 deletions(-) diff --git a/tgbridge/media.py b/tgbridge/media.py index 679b71d..412a3f8 100644 --- a/tgbridge/media.py +++ b/tgbridge/media.py @@ -18,8 +18,11 @@ FFPROBE = shutil.which("ffprobe") MAX_CONVERTED_BYTES = 8 * 1024 * 1024 # bigger than this, discord rejects it -_GIF_FILTER = ("fps=15,scale=320:-1:flags=lanczos,split[s0][s1];" - "[s0]palettegen[p];[s1][p]paletteuse") +class _Oversize: + __slots__ = ("size",) + + def __init__(self, size: int): + self.size = size _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, - args, timeout: float): - """Shared conversion body. Returns bytes, or None if it did not work out.""" + args, timeout: float, max_bytes: int = MAX_CONVERTED_BYTES): + """Shared conversion body. + + Returns bytes, `_OVERSIZE` if the encode worked but blew the budget, or + None if it genuinely failed. + """ if not FFMPEG: return 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]) return None size = os.path.getsize(dst) - if size > MAX_CONVERTED_BYTES: - log.info("converted media too large (%d bytes) — keeping original", size) - return None + if size > max_bytes: + log.debug("encode over budget: %.2f MB > %.2f MB", + size / 1048576, max_bytes / 1048576) + return _Oversize(size) with open(dst, "rb") as handle: return handle.read() 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. """ - return await _convert( + out = await _convert( data, in_suffix, ".webp", ["-c:v", "libwebp_anim", "-pix_fmt", "yuva420p", "-lossless", "0", "-q:v", "70", "-loop", "0", "-an", "-fps_mode", "passthrough"], 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"): - """Convert a silent clip to a looping GIF (Discord autoplays these).""" - return await _convert(data, in_suffix, ".gif", - ["-filter_complex", _GIF_FILTER, "-loop", "0"], - timeout=45.0) +def _gif_filter(fps=None, width=None, per_frame=True, dither="sierra2_4a"): + pre = [] + if fps: + pre.append(f"fps={fps}") + 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) ────────────────────────────────────────