diff --git a/example-config.yaml b/example-config.yaml index 010dbb2c..5f7e9753 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -172,13 +172,15 @@ bridge: # disable - No conversion, send as-is (gzipped lottie) # png - converts to non-animated png (fastest), # gif - converts to animated gif, but loses transparency + # webm - converts to webm video, requires ffmpeg executable with vp9 codec and webm container support target: gif # Arguments for converter. All converters take width and height. # GIF converter takes background as a hex color. args: width: 256 height: 256 - background: "020202" + background: "020202" # only for gif + fps: 30 # only for webm # Whether to bridge Telegram bot messages as m.notices or m.texts. bot_messages_as_notices: true diff --git a/mautrix_telegram/util/file_transfer.py b/mautrix_telegram/util/file_transfer.py index f08ed177..e89c3465 100644 --- a/mautrix_telegram/util/file_transfer.py +++ b/mautrix_telegram/util/file_transfer.py @@ -206,8 +206,9 @@ async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, inten if is_sticker and tgs_convert and (mime_type == "application/gzip" or ( mime_type == "application/octet-stream" and magic.from_buffer(file).startswith("gzip"))): - mime_type, file, width, height, thumbnail = await convert_tgs_to( + mime_type, file, width, height = await convert_tgs_to( file, tgs_convert["target"], **tgs_convert["args"]) + thumbnail = None image_converted = mime_type != "application/gzip" if mime_type == "image/webp": diff --git a/mautrix_telegram/util/lottie2ffmpeg b/mautrix_telegram/util/lottie2ffmpeg index fd9a36b5..c5af7620 100755 --- a/mautrix_telegram/util/lottie2ffmpeg +++ b/mautrix_telegram/util/lottie2ffmpeg @@ -21,6 +21,4 @@ for i in {0..99}; do $lottieconverter input frame-${padded: -2}.png png $resolution $((i+1)) done -ffmpeg -start_number 0 -framerate 30 -i frame-%02d.png -c:v libvpx-vp9 -pix_fmt yuva420p out.webm - -cat out.webm +ffmpeg -start_number 0 -framerate 30 -i frame-%02d.png -c:v libvpx-vp9 -pix_fmt yuva420p out.webm -f webm - diff --git a/mautrix_telegram/util/tgs_converter.py b/mautrix_telegram/util/tgs_converter.py index 84f5696f..8496bb68 100644 --- a/mautrix_telegram/util/tgs_converter.py +++ b/mautrix_telegram/util/tgs_converter.py @@ -19,6 +19,7 @@ import asyncio.subprocess import logging import shutil import os.path +import tempfile log: logging.Logger = logging.getLogger("mau.util.tgs") converters: Dict[str, Callable[[bytes, int, int, Any], Awaitable[Tuple[str, bytes]]]] = {} @@ -30,7 +31,7 @@ def abswhich(program: Optional[str]) -> Optional[str]: lottieconverter = abswhich("lottieconverter") -lottie2ffmpeg = abswhich("lottie2ffmpeg") +ffmpeg = abswhich("ffmpeg") if lottieconverter: async def tgs_to_png(file: bytes, width: int, height: int, **_: Any) -> Tuple[str, bytes]: @@ -39,42 +40,73 @@ if lottieconverter: f"{width}x{height}", str(frame), stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE) - stdout, _ = await proc.communicate(file) - return "image/png", stdout + stdout, stderr = await proc.communicate(file) + if proc.returncode == 0: + return "image/png", stdout + else: + log.error("lottieconverter error: " + stderr.decode("utf-8") if stderr is not None + else "unknown") + return "application/gzip", file async def tgs_to_gif(file: bytes, width: int, height: int, background: str = "202020", **_: Any) -> Tuple[str, bytes]: proc = await asyncio.create_subprocess_exec(lottieconverter, "-", "-", "gif", - f"{width}x{height}", "0", f"0x{background}", + f"{width}x{height}", f"0x{background}", stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE) - stdout, _ = await proc.communicate(file) - return "image/gif", stdout + stdout, stderr = await proc.communicate(file) + if proc.returncode == 0: + return "image/gif", stdout + else: + log.error("lottieconverter error: " + stderr.decode("utf-8") if stderr is not None + else "unknown") + return "application/gzip", file converters["png"] = tgs_to_png converters["gif"] = tgs_to_gif -if lottieconverter and lottie2ffmpeg: - async def tgs_to_webm(file: bytes, width: int, height: int, **_: Any) -> Tuple[str, bytes]: - proc = await asyncio.create_subprocess_exec(lottie2ffmpeg, lottieconverter, - f"{width}x{height}", - stdout=asyncio.subprocess.PIPE, - stdin=asyncio.subprocess.PIPE) - stdout, _ = await proc.communicate(file) - return "video/webm", stdout +if lottieconverter and ffmpeg: + async def tgs_to_webm(file: bytes, width: int, height: int, fps: int = 30, + **_: Any) -> Tuple[str, bytes]: + with tempfile.TemporaryDirectory(prefix="tgs_") as tmpdir: + file_template = tmpdir + "/out_" + proc = await asyncio.create_subprocess_exec(lottieconverter, "-", file_template, + "pngs", f"{width}x{height}", str(fps), + stdout=asyncio.subprocess.PIPE, + stdin=asyncio.subprocess.PIPE) + _, stderr = await proc.communicate(file) + if proc.returncode == 0: + proc = await asyncio.create_subprocess_exec(ffmpeg, "-hide_banner", "-loglevel", + "error", "-framerate", str(fps), + "-pattern_type", "glob", "-i", + file_template + "*.png", + "-c:v", "libvpx-vp9", "-pix_fmt", + "yuva420p", "-f", "webm", "-", + stdout=asyncio.subprocess.PIPE, + stdin=asyncio.subprocess.PIPE) + stdout, stderr = await proc.communicate() + if proc.returncode == 0: + return "video/webm", stdout + else: + log.error("ffmpeg error: " + stderr.decode("utf-8") if stderr is not None + else "unknown") + else: + log.error("lottieconverter error: " + stderr.decode("utf-8") if stderr is not None + else "unknown") + return "application/gzip", file converters["webm"] = tgs_to_webm async def convert_tgs_to(file: bytes, convert_to: str, width: int, height: int, **kwargs: Any - ) -> Tuple[str, bytes, Optional[int], Optional[int], Optional[bytes]]: + ) -> Tuple[str, bytes, Optional[int], Optional[int]]: if convert_to in converters: converter = converters[convert_to] mime, out = await converter(file, width, height, **kwargs) - return mime, out, width, height, None + return mime, out, width, height elif convert_to != "disable": log.warning(f"Unable to convert animated sticker, type {convert_to} not supported") - return "application/gzip", file, None, None, None + return "application/gzip", file, None, None