From 29ecc1dc24c9a5563cb0ad602ef0dd4964f058ad Mon Sep 17 00:00:00 2001 From: Ovler Date: Tue, 5 Mar 2024 00:52:19 +0800 Subject: [PATCH 1/6] add gif conversion and compression --- efb_wechat_slave/__init__.py | 8 +- efb_wechat_slave/utils.py | 159 ++++++++++++++++++++++++++++++++++- 2 files changed, 162 insertions(+), 5 deletions(-) diff --git a/efb_wechat_slave/__init__.py b/efb_wechat_slave/__init__.py index 37b8cd4..a4cee6e 100644 --- a/efb_wechat_slave/__init__.py +++ b/efb_wechat_slave/__init__.py @@ -6,6 +6,7 @@ import tempfile import time import threading +import subprocess from gettext import translation from json import JSONDecodeError from pathlib import Path @@ -33,7 +34,7 @@ from .__version__ import __version__ from .chats import ChatManager from .slave_message import SlaveMessageManager -from .utils import ExperimentalFlagsManager +from .utils import ExperimentalFlagsManager, gif_conversion from .vendor import wxpy from .vendor.wxpy import ResponseError from .vendor.wxpy.utils import PuidMap @@ -436,9 +437,6 @@ def send_message(self, msg: Message) -> Message: self.logger.debug( '[%s] Image converted from %s to GIF', msg.uid, msg.mime) file.close() - if f.seek(0, 2) > self.MAX_FILE_SIZE: - raise EFBMessageError( - self._("Image size is too large. (IS02)")) f.seek(0) r.append(self._bot_send_image(chat, f.name, f)) finally: @@ -464,6 +462,8 @@ def send_message(self, msg: Message) -> Message: if not file.closed: file.close() else: + if msg.mime == "image/gif" and file.seek(0, 2) > 1048576: + file = gif_conversion(file) try: if file.seek(0, 2) > self.MAX_FILE_SIZE: raise EFBMessageError( diff --git a/efb_wechat_slave/utils.py b/efb_wechat_slave/utils.py index b6705e3..d21313b 100644 --- a/efb_wechat_slave/utils.py +++ b/efb_wechat_slave/utils.py @@ -2,8 +2,15 @@ import base64 import io import os +import subprocess +import sys import json -from typing import Dict, Any, TYPE_CHECKING, List +from shutil import copyfileobj +from tempfile import NamedTemporaryFile +from typing import Dict, Any, TYPE_CHECKING, List, IO + +import ffmpeg +from ffmpeg._utils import convert_kwargs_to_cmd_line_args from ehforwarderbot.types import MessageID from .vendor.itchat import utils as itchat_utils @@ -221,3 +228,153 @@ def print_st(): res += base64.b64encode(file.getvalue()).decode() res += print_st() return res + + +if os.name == "nt": + # Workaround for Windows which cannot open the same file as "read" twice. + # Using stdin/stdout pipe for IO with ffmpeg. + # Said to be only working with a few encodings. It seems that Telegram GIF + # (MP4, h264, soundless) luckily felt in that range. + # + # See: https://etm.1a23.studio/issues/90 + + def ffprobe(stream: IO[bytes], cmd='ffprobe', **kwargs): + """Run ffprobe on an input stream and return a JSON representation of the output. + + Code adopted from ffmpeg-python by Karl Kroening (Apache License 2.0). + Copyright 2017 Karl Kroening + + Raises: + :class:`ffmpeg.Error`: if ffprobe returns a non-zero exit code, + an :class:`Error` is returned with a generic error message. + The stderr output can be retrieved by accessing the + ``stderr`` property of the exception. + """ + args = [cmd, '-show_format', '-show_streams', '-of', 'json'] + args += convert_kwargs_to_cmd_line_args(kwargs) + args += ["-"] + + p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + assert p.stdin + copyfileobj(p.stdin, stream) + out, err = p.communicate() + if p.returncode != 0: + raise ffmpeg.Error('ffprobe', out, err) + return json.loads(out.decode('utf-8')) + + + def gif_conversion(file: IO[bytes]) -> IO[bytes]: + """Convert Telegram GIF to real GIF, the NT way.""" + gif_file = NamedTemporaryFile(suffix='.gif') + file.seek(0) + + # Use custom ffprobe command to read from stream + metadata = ffprobe(file) + + # Set input/output of ffmpeg to stream + stream = ffmpeg.input("pipe:") + if metadata['streams'][0]['codec_name'] == 'vp9': + stream = ffmpeg.input(file.name, vcodec='libvpx-vp9') + if metadata.get('width', 0) > 600: + stream = stream.filter("scale", 600, -2) + if metadata.get('fps', 0) > 12: + stream = stream.filter("fps", 12, round='up') + split = ( + stream + .split() + ) + stream_paletteuse = ( + ffmpeg + .filter( + [ + split[0], + split[1] + .filter( + filter_name='palettegen', + reserve_transparent='on', + ) + ], + filter_name='paletteuse', + ) + ) + # Need to specify file format here as no extension hint presents. + args = stream_paletteuse.output("pipe:", format="gif").compile() + file.seek(0) + + # subprocess.Popen would still try to access the file handle instead of + # using standard IO interface. Not sure if that would work on Windows. + # Using the most classic buffer and copy via IO interface just to play + # safe. + p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + assert p.stdin + copyfileobj(file, p.stdin) + p.stdin.close() + + # Raise exception if error occurs, just like ffmpeg-python. + if p.returncode != 0 and p.stderr: + err = p.stderr.read().decode() + print(err, file=sys.stderr) + raise ffmpeg.Error('ffmpeg', "", err) + + assert p.stdout + copyfileobj(p.stdout, gif_file) + file.close() + gif_file.seek(0) + return gif_file + +else: + def gif_conversion(file: IO[bytes]) -> IO[bytes]: + """Convert Telegram GIF to real GIF, the non-NT way.""" + gif_file = NamedTemporaryFile(suffix='.gif') + file.seek(0) + metadata = ffmpeg.probe(file.name) + stream = ffmpeg.input(file.name) + # 检查视频编码类型是否为VP9 + if metadata['streams'][0]['codec_name'] == 'vp9': + stream = ffmpeg.input(file.name, vcodec='libvpx-vp9') # 只有这个能保持透明背景 + if metadata.get('fps', 0) > 12: + stream = stream.filter("fps", 12, round='up') # 限制帧率 + if metadata.get('width', 0) > 600: + stream = stream.filter("scale", 600, -2) # 限制宽度 + split = ( + stream + .split() + ) + stream_paletteuse = ( + ffmpeg + .filter( + [ + split[0], + split[1] + .filter( + filter_name='palettegen', + reserve_transparent='on', + ) + ], + filter_name='paletteuse', + ) + ) + stream_paletteuse.output(gif_file.name).overwrite_output().run() + new_file_size = os.path.getsize(gif_file.name) + print(f"file_size: {new_file_size/1024}KB") + if new_file_size > 1024 * 1024: + # try to use gifsicle lossy compression + compress_file = NamedTemporaryFile(suffix='.gif') + subprocess.run(["gifsicle", "--resize-method=catrom", "--lossy=100", "-O2", "-o", compress_file.name, gif_file.name], check=True) + new_file_size = os.path.getsize(compress_file.name) + if new_file_size > 1024 * 1024: + scales = [600, 512, 480, 400, 360, 300, 256, 250, 200, 150, 100] + scales = [scale for scale in scales if scale < metadata['streams'][0]['width']] + scales = sorted(scales, reverse=True) + for scale in scales: + subprocess.run(["gifsicle", "--resize-method=catrom", "--resize-fit", f"{scale}x{scale}", "--lossy=100", "-O2", "-o", compress_file.name, gif_file.name], check=True) + new_file_size = os.path.getsize(compress_file.name) + print(f"new_file_size: {new_file_size/1024}KB after resize to {scale}x{scale}") + if new_file_size < 1024 * 1024: + break + gif_file.close() + gif_file = compress_file + file.close() + gif_file.seek(0) + return gif_file From e23e2d27fd9a1fafe745f1a21f5c0356c19f982b Mon Sep 17 00:00:00 2001 From: Ovler Date: Wed, 6 Mar 2024 19:58:14 +0800 Subject: [PATCH 2/6] remove unused import --- efb_wechat_slave/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/efb_wechat_slave/__init__.py b/efb_wechat_slave/__init__.py index a4cee6e..2d88da5 100644 --- a/efb_wechat_slave/__init__.py +++ b/efb_wechat_slave/__init__.py @@ -6,7 +6,6 @@ import tempfile import time import threading -import subprocess from gettext import translation from json import JSONDecodeError from pathlib import Path From dd5ab3f7a937bd732b937e6d58a2e60182f324f4 Mon Sep 17 00:00:00 2001 From: Ovler Date: Wed, 6 Mar 2024 20:31:01 +0800 Subject: [PATCH 3/6] no need to use ffmpeg again here --- efb_wechat_slave/utils.py | 44 +++++++-------------------------------- 1 file changed, 7 insertions(+), 37 deletions(-) diff --git a/efb_wechat_slave/utils.py b/efb_wechat_slave/utils.py index d21313b..5bdc5b8 100644 --- a/efb_wechat_slave/utils.py +++ b/efb_wechat_slave/utils.py @@ -326,55 +326,25 @@ def gif_conversion(file: IO[bytes]) -> IO[bytes]: else: def gif_conversion(file: IO[bytes]) -> IO[bytes]: """Convert Telegram GIF to real GIF, the non-NT way.""" - gif_file = NamedTemporaryFile(suffix='.gif') file.seek(0) - metadata = ffmpeg.probe(file.name) - stream = ffmpeg.input(file.name) - # 检查视频编码类型是否为VP9 - if metadata['streams'][0]['codec_name'] == 'vp9': - stream = ffmpeg.input(file.name, vcodec='libvpx-vp9') # 只有这个能保持透明背景 - if metadata.get('fps', 0) > 12: - stream = stream.filter("fps", 12, round='up') # 限制帧率 - if metadata.get('width', 0) > 600: - stream = stream.filter("scale", 600, -2) # 限制宽度 - split = ( - stream - .split() - ) - stream_paletteuse = ( - ffmpeg - .filter( - [ - split[0], - split[1] - .filter( - filter_name='palettegen', - reserve_transparent='on', - ) - ], - filter_name='paletteuse', - ) - ) - stream_paletteuse.output(gif_file.name).overwrite_output().run() - new_file_size = os.path.getsize(gif_file.name) + new_file_size = os.path.getsize(file.name) print(f"file_size: {new_file_size/1024}KB") if new_file_size > 1024 * 1024: # try to use gifsicle lossy compression compress_file = NamedTemporaryFile(suffix='.gif') - subprocess.run(["gifsicle", "--resize-method=catrom", "--lossy=100", "-O2", "-o", compress_file.name, gif_file.name], check=True) + subprocess.run(["gifsicle", "--resize-method=catrom", "--lossy=100", "-O2", "-o", compress_file.name, file.name], check=True) new_file_size = os.path.getsize(compress_file.name) if new_file_size > 1024 * 1024: scales = [600, 512, 480, 400, 360, 300, 256, 250, 200, 150, 100] scales = [scale for scale in scales if scale < metadata['streams'][0]['width']] scales = sorted(scales, reverse=True) for scale in scales: - subprocess.run(["gifsicle", "--resize-method=catrom", "--resize-fit", f"{scale}x{scale}", "--lossy=100", "-O2", "-o", compress_file.name, gif_file.name], check=True) + subprocess.run(["gifsicle", "--resize-method=catrom", "--resize-fit", f"{scale}x{scale}", "--lossy=100", "-O2", "-o", compress_file.name, file.name], check=True) new_file_size = os.path.getsize(compress_file.name) print(f"new_file_size: {new_file_size/1024}KB after resize to {scale}x{scale}") if new_file_size < 1024 * 1024: break - gif_file.close() - gif_file = compress_file - file.close() - gif_file.seek(0) - return gif_file + file.close() + file = compress_file + file.seek(0) + return file From 30cc3db838c6018dc6029601fbf4e4e9b39d5a1d Mon Sep 17 00:00:00 2001 From: Ovler Date: Wed, 6 Mar 2024 20:49:23 +0800 Subject: [PATCH 4/6] update logic on windows --- efb_wechat_slave/utils.py | 100 ++++++++------------------------------ 1 file changed, 20 insertions(+), 80 deletions(-) diff --git a/efb_wechat_slave/utils.py b/efb_wechat_slave/utils.py index 5bdc5b8..2b6655c 100644 --- a/efb_wechat_slave/utils.py +++ b/efb_wechat_slave/utils.py @@ -238,90 +238,30 @@ def print_st(): # # See: https://etm.1a23.studio/issues/90 - def ffprobe(stream: IO[bytes], cmd='ffprobe', **kwargs): - """Run ffprobe on an input stream and return a JSON representation of the output. - - Code adopted from ffmpeg-python by Karl Kroening (Apache License 2.0). - Copyright 2017 Karl Kroening - - Raises: - :class:`ffmpeg.Error`: if ffprobe returns a non-zero exit code, - an :class:`Error` is returned with a generic error message. - The stderr output can be retrieved by accessing the - ``stderr`` property of the exception. - """ - args = [cmd, '-show_format', '-show_streams', '-of', 'json'] - args += convert_kwargs_to_cmd_line_args(kwargs) - args += ["-"] - - p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - assert p.stdin - copyfileobj(p.stdin, stream) - out, err = p.communicate() - if p.returncode != 0: - raise ffmpeg.Error('ffprobe', out, err) - return json.loads(out.decode('utf-8')) - - def gif_conversion(file: IO[bytes]) -> IO[bytes]: """Convert Telegram GIF to real GIF, the NT way.""" - gif_file = NamedTemporaryFile(suffix='.gif') file.seek(0) - - # Use custom ffprobe command to read from stream - metadata = ffprobe(file) - - # Set input/output of ffmpeg to stream - stream = ffmpeg.input("pipe:") - if metadata['streams'][0]['codec_name'] == 'vp9': - stream = ffmpeg.input(file.name, vcodec='libvpx-vp9') - if metadata.get('width', 0) > 600: - stream = stream.filter("scale", 600, -2) - if metadata.get('fps', 0) > 12: - stream = stream.filter("fps", 12, round='up') - split = ( - stream - .split() - ) - stream_paletteuse = ( - ffmpeg - .filter( - [ - split[0], - split[1] - .filter( - filter_name='palettegen', - reserve_transparent='on', - ) - ], - filter_name='paletteuse', - ) - ) - # Need to specify file format here as no extension hint presents. - args = stream_paletteuse.output("pipe:", format="gif").compile() + new_file_size = os.path.getsize(file.name) + print(f"file_size: {new_file_size/1024}KB") + if new_file_size > 1024 * 1024: + # try to use gifsicle lossy compression + compress_file = NamedTemporaryFile(suffix='.gif') + subprocess.run(["gifsicle", "--resize-method=catrom", "--lossy=100", "-O2", "-o", compress_file.name, file.name], check=True) + new_file_size = os.path.getsize(compress_file.name) + if new_file_size > 1024 * 1024: + scales = [600, 512, 480, 400, 360, 300, 256, 250, 200, 150, 100] + scales = [scale for scale in scales if scale < metadata['streams'][0]['width']] + scales = sorted(scales, reverse=True) + for scale in scales: + subprocess.run(["gifsicle", "--resize-method=catrom", "--resize-fit", f"{scale}x{scale}", "--lossy=100", "-O2", "-o", compress_file.name, file.name], check=True) + new_file_size = os.path.getsize(compress_file.name) + print(f"new_file_size: {new_file_size/1024}KB after resize to {scale}x{scale}") + if new_file_size < 1024 * 1024: + break + file.close() + file = compress_file file.seek(0) - - # subprocess.Popen would still try to access the file handle instead of - # using standard IO interface. Not sure if that would work on Windows. - # Using the most classic buffer and copy via IO interface just to play - # safe. - p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - assert p.stdin - copyfileobj(file, p.stdin) - p.stdin.close() - - # Raise exception if error occurs, just like ffmpeg-python. - if p.returncode != 0 and p.stderr: - err = p.stderr.read().decode() - print(err, file=sys.stderr) - raise ffmpeg.Error('ffmpeg', "", err) - - assert p.stdout - copyfileobj(p.stdout, gif_file) - file.close() - gif_file.seek(0) - return gif_file + return file else: def gif_conversion(file: IO[bytes]) -> IO[bytes]: From 148462c4fc30dc5df1100510bc3776d9e3b00382 Mon Sep 17 00:00:00 2001 From: Ovler Date: Wed, 6 Mar 2024 20:59:34 +0800 Subject: [PATCH 5/6] remove unused import --- efb_wechat_slave/utils.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/efb_wechat_slave/utils.py b/efb_wechat_slave/utils.py index 2b6655c..5f118fd 100644 --- a/efb_wechat_slave/utils.py +++ b/efb_wechat_slave/utils.py @@ -3,14 +3,10 @@ import io import os import subprocess -import sys import json -from shutil import copyfileobj from tempfile import NamedTemporaryFile from typing import Dict, Any, TYPE_CHECKING, List, IO -import ffmpeg -from ffmpeg._utils import convert_kwargs_to_cmd_line_args from ehforwarderbot.types import MessageID from .vendor.itchat import utils as itchat_utils From db730f551945267fbd9554e28d9f9fc5a39e6cb3 Mon Sep 17 00:00:00 2001 From: Ovler Date: Wed, 6 Mar 2024 21:10:48 +0800 Subject: [PATCH 6/6] avoid metadata not found error --- efb_wechat_slave/utils.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/efb_wechat_slave/utils.py b/efb_wechat_slave/utils.py index 5f118fd..cb8cfcd 100644 --- a/efb_wechat_slave/utils.py +++ b/efb_wechat_slave/utils.py @@ -245,9 +245,7 @@ def gif_conversion(file: IO[bytes]) -> IO[bytes]: subprocess.run(["gifsicle", "--resize-method=catrom", "--lossy=100", "-O2", "-o", compress_file.name, file.name], check=True) new_file_size = os.path.getsize(compress_file.name) if new_file_size > 1024 * 1024: - scales = [600, 512, 480, 400, 360, 300, 256, 250, 200, 150, 100] - scales = [scale for scale in scales if scale < metadata['streams'][0]['width']] - scales = sorted(scales, reverse=True) + scales = [512, 480, 400, 360, 300, 256, 250, 200, 150, 100] for scale in scales: subprocess.run(["gifsicle", "--resize-method=catrom", "--resize-fit", f"{scale}x{scale}", "--lossy=100", "-O2", "-o", compress_file.name, file.name], check=True) new_file_size = os.path.getsize(compress_file.name) @@ -271,9 +269,7 @@ def gif_conversion(file: IO[bytes]) -> IO[bytes]: subprocess.run(["gifsicle", "--resize-method=catrom", "--lossy=100", "-O2", "-o", compress_file.name, file.name], check=True) new_file_size = os.path.getsize(compress_file.name) if new_file_size > 1024 * 1024: - scales = [600, 512, 480, 400, 360, 300, 256, 250, 200, 150, 100] - scales = [scale for scale in scales if scale < metadata['streams'][0]['width']] - scales = sorted(scales, reverse=True) + scales = [512, 480, 400, 360, 300, 256, 250, 200, 150, 100] for scale in scales: subprocess.run(["gifsicle", "--resize-method=catrom", "--resize-fit", f"{scale}x{scale}", "--lossy=100", "-O2", "-o", compress_file.name, file.name], check=True) new_file_size = os.path.getsize(compress_file.name)