Files
discordbot/tgbridge/media.py
T
2026-09-09 11:26:27 +03:00

345 lines
13 KiB
Python

#ffmpeg + lottie conversion for stickers, gifs and video##
"""Media conversion for the bridge.
Two rules here: a timed-out subprocess is always killed and reaped, and temp
paths are bound before the try so the finally can never UnboundLocalError.
"""
######################################################################
import asyncio
import logging
import os
import shutil
import tempfile
log = logging.getLogger(__name__)
FFMPEG = shutil.which("ffmpeg")
FFPROBE = shutil.which("ffprobe")
MAX_CONVERTED_BYTES = int(9.5 * 1024 * 1024)
class _Oversize:
__slots__ = ("size",)
def __init__(self, size: int):
self.size = size
_lottie_state = None # None = not yet checked
def have_ffmpeg() -> bool:
return FFMPEG is not None
async def _run(args, timeout: float) -> tuple[int, bytes]:
"""Run a subprocess, always killing and reaping it on timeout."""
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return proc.returncode, (stdout or b"") + (stderr or b"")
except asyncio.TimeoutError:
log.warning("%s timed out after %ss — killing", args[0], timeout)
try:
proc.kill()
except ProcessLookupError:
pass
try:
await proc.wait()
except Exception:
pass
raise
def _write_temp(data: bytes, suffix: str) -> str:
fd, path = tempfile.mkstemp(suffix=suffix)
with os.fdopen(fd, "wb") as handle:
handle.write(data)
return path
def _unlink(path):
if path and os.path.exists(path):
try:
os.remove(path)
except OSError as exc:
log.debug("could not remove temp file %s: %s", path, exc)
async def has_audio_track(data: bytes, suffix: str = ".mp4") -> bool:
"""True if the clip carries audio — i.e. it is a real video, not a GIF."""
if not FFPROBE:
return False
src = None
try:
src = await asyncio.to_thread(_write_temp, data, suffix)
code, out = await _run([
FFPROBE, "-v", "error", "-select_streams", "a",
"-show_entries", "stream=codec_type", "-of", "csv=p=0", src,
], timeout=15.0)
return b"audio" in out
except Exception as exc:
log.debug("ffprobe failed (%s) — assuming no audio", exc)
return False
finally:
await asyncio.to_thread(_unlink, src)
def _decoder_args(in_suffix: str) -> list[str]:
"""Input flags that go before -i.
ffmpeg's native vp9 decoder silently drops the alpha of VP9-in-WebM (every
transparent pixel comes out opaque black). Only libvpx-vp9 reads it, and
only if selected before the input. Telegram video stickers are this format.
"""
return ["-c:v", "libvpx-vp9"] if in_suffix == ".webm" else []
async def _convert(data: bytes, in_suffix: str, out_suffix: str,
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
try:
src = await asyncio.to_thread(_write_temp, data, in_suffix)
dst = src + out_suffix
code, out = await _run([FFMPEG, "-y", "-v", "error"]
+ _decoder_args(in_suffix) + ["-i", src]
+ list(args) + [dst], timeout=timeout)
if code != 0 or not os.path.exists(dst):
log.debug("ffmpeg rc=%s: %s", code, out.decode(errors="replace")[:300])
return None
size = os.path.getsize(dst)
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:
log.warning("conversion failed: %s", exc)
return None
finally:
await asyncio.to_thread(_unlink, src)
await asyncio.to_thread(_unlink, dst)
async def to_animated_webp(data: bytes, in_suffix: str = ".webm"):
"""Convert to animated WebP — discord loops it and it keeps 8-bit alpha.
GIF only has on/off transparency, which is what haloed every sticker edge.
"""
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
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 index > 0 and weighted and weighted * rate > max_bytes:
log.debug("gif tier %d skipped: predicted %.2f MB > %.2f MB budget",
index, weighted * rate / 1048576, max_bytes / 1048576)
continue
remaining = GIF_TOTAL_BUDGET - (loop.time() - started)
if remaining <= 5.0:
log.info("gif ladder out of time after tier %d", index)
break
attempt_timeout = min(GIF_ATTEMPT_TIMEOUT, remaining)
attempt_started = loop.time()
try:
out = await _convert(
data, in_suffix, ".gif",
["-filter_complex", _gif_filter(**tier), "-loop", "0"],
timeout=attempt_timeout,
max_bytes=max_bytes)
except asyncio.TimeoutError:
log.info("gif tier %d timed out after %.1fs (budget was %.1fs)",
index, loop.time() - attempt_started, attempt_timeout)
continue # too slow at this quality, drop a rung
elapsed = loop.time() - attempt_started
if isinstance(out, bytes):
log.info("gif tier %d: fps=%s width=%s per_frame=%s -> %.2f MB in %.1fs",
index, tier["fps"] or "native", tier["width"] or "native",
tier["per_frame"], len(out) / 1048576, elapsed)
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 after %.1fs — giving up",
index, elapsed)
return None
if weighted:
rate = max(rate, out.size / weighted * 0.9)
log.debug("gif tier %d oversize: %.2f MB > %.2f MB budget (%.1fs) — trying next tier",
index, out.size / 1048576, max_bytes / 1048576, elapsed)
log.info("no gif tier fit under %.1f MB — keeping original",
max_bytes / 1048576)
return None
# ── lottie (.tgs animated stickers) ────────────────────────────────────────
# .tgs is gzipped lottie json; ffmpeg exits 183 on it. optional — without the
# lib the caller falls back to the sticker's static thumbnail.
def lottie_available() -> bool:
global _lottie_state
if _lottie_state is None:
try:
import lottie # noqa: F401
from lottie.parsers.tgs import parse_tgs # noqa: F401
_lottie_state = True
log.info("lottie renderer available — .tgs stickers will animate")
except Exception:
_lottie_state = False
log.info("lottie not installed — .tgs stickers fall back to thumbnail "
"(pip install lottie to animate them)")
return _lottie_state
def _render_tgs_sync(data: bytes):
from lottie.parsers.tgs import parse_tgs
from lottie.exporters.gif import export_gif
src = dst = None
try:
src = _write_temp(data, ".tgs")
dst = src + ".gif"
animation = parse_tgs(src)
export_gif(animation, dst, skip_frames=2, fps=25)
if not os.path.exists(dst):
return None
if os.path.getsize(dst) > MAX_CONVERTED_BYTES:
log.info("rendered .tgs too large — falling back to thumbnail")
return None
with open(dst, "rb") as handle:
return handle.read()
finally:
_unlink(src)
_unlink(dst)
async def render_tgs(data: bytes):
"""Render an animated .tgs sticker, or None if that isn't possible here."""
if not lottie_available():
return None
try:
return await asyncio.wait_for(
asyncio.to_thread(_render_tgs_sync, data), timeout=60.0)
except Exception as exc:
log.warning("tgs render failed (%s) — falling back to thumbnail", exc)
return None