Initial commit: Telegram Sticker Bot Manager
Features: - Create and manage sticker packs - Add existing sticker sets to database - Automatic image optimization (512px, WebP conversion) - GIF to animated video sticker conversion (WebM, VP9) - Background removal (local rembg + optional Remove.bg API) - Hybrid API with retry logic and usage tracking - Premium status detection - SQLite database for persistence - Docker containerization - Complete OPENCODE.md documentation Technical Stack: - python-telegram-bot 22.7 - Pillow for image processing - FFmpeg for video conversion - rembg for local background removal - aiosqlite for async database operations
This commit is contained in:
267
gif_processor.py
Normal file
267
gif_processor.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""GIF to WebM Video Sticker converter using ffmpeg."""
|
||||
import io
|
||||
import logging
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Optional, Tuple, Dict, Any
|
||||
from PIL import Image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Telegram Video Sticker requirements
|
||||
MAX_DURATION_SECONDS = 3
|
||||
MAX_FILE_SIZE_KB = 256
|
||||
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_KB * 1024
|
||||
STICKER_SIZE = 512
|
||||
TARGET_FPS = 30
|
||||
|
||||
|
||||
async def get_gif_info(gif_bytes: bytes) -> Dict[str, Any]:
|
||||
"""
|
||||
Get GIF metadata using ffprobe.
|
||||
|
||||
Returns dict with duration, width, height, fps
|
||||
"""
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix='.gif', delete=False) as tmp_in:
|
||||
tmp_in.write(gif_bytes)
|
||||
tmp_in.flush()
|
||||
|
||||
# Use ffprobe to get duration
|
||||
result = subprocess.run(
|
||||
[
|
||||
'ffprobe', '-v', 'error', '-show_entries',
|
||||
'format=duration', '-show_entries',
|
||||
'stream=width,height,r_frame_rate',
|
||||
'-of', 'default=noprint_wrappers=1',
|
||||
tmp_in.name
|
||||
],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
info = {'duration': 0, 'width': 0, 'height': 0, 'fps': 0}
|
||||
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
if key == 'duration':
|
||||
info['duration'] = float(value)
|
||||
elif key == 'width':
|
||||
info['width'] = int(value)
|
||||
elif key == 'height':
|
||||
info['height'] = int(value)
|
||||
elif key == 'r_frame_rate':
|
||||
# Parse fraction like "30/1"
|
||||
if '/' in value:
|
||||
num, den = value.split('/')
|
||||
info['fps'] = int(num) / int(den) if int(den) != 0 else 0
|
||||
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting GIF info: {e}")
|
||||
return {'duration': 0, 'width': 0, 'height': 0, 'fps': 0}
|
||||
|
||||
|
||||
async def validate_gif_duration(duration: float) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if GIF duration is within limits.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, message)
|
||||
"""
|
||||
if duration > MAX_DURATION_SECONDS:
|
||||
return False, f"GIF ist {duration:.1f}s lang. Maximal erlaubt: {MAX_DURATION_SECONDS}s"
|
||||
return True, ""
|
||||
|
||||
|
||||
async def convert_gif_to_webm(
|
||||
gif_bytes: bytes,
|
||||
max_duration: Optional[float] = None
|
||||
) -> Optional[bytes]:
|
||||
"""
|
||||
Convert GIF to WebM Video Sticker format using ffmpeg.
|
||||
|
||||
Args:
|
||||
gif_bytes: Raw GIF file bytes
|
||||
max_duration: Maximum duration in seconds (None = no limit, will be truncated)
|
||||
|
||||
Returns:
|
||||
WebM video bytes or None if conversion fails
|
||||
"""
|
||||
try:
|
||||
# Get GIF info
|
||||
gif_info = await get_gif_info(gif_bytes)
|
||||
duration = gif_info.get('duration', 0)
|
||||
width = gif_info.get('width', 512)
|
||||
height = gif_info.get('height', 512)
|
||||
|
||||
logger.info(f"Converting GIF: {width}x{height}, {duration}s, "
|
||||
f"{gif_info.get('fps', 0)}fps")
|
||||
|
||||
# Calculate dimensions (one side must be exactly 512px)
|
||||
if width >= height:
|
||||
new_width = STICKER_SIZE
|
||||
new_height = int((height / width) * STICKER_SIZE)
|
||||
else:
|
||||
new_height = STICKER_SIZE
|
||||
new_width = int((width / height) * STICKER_SIZE)
|
||||
|
||||
# Ensure neither exceeds 512
|
||||
new_width = min(new_width, STICKER_SIZE)
|
||||
new_height = min(new_height, STICKER_SIZE)
|
||||
|
||||
# Determine duration limit
|
||||
duration_limit = min(duration, max_duration or duration)
|
||||
if duration > MAX_DURATION_SECONDS:
|
||||
duration_limit = MAX_DURATION_SECONDS
|
||||
logger.info(f"GIF too long ({duration}s), truncating to {duration_limit}s")
|
||||
|
||||
# Create temporary files
|
||||
with tempfile.NamedTemporaryFile(suffix='.gif', delete=False) as tmp_in:
|
||||
tmp_in.write(gif_bytes)
|
||||
tmp_in_path = tmp_in.name
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.webm', delete=False) as tmp_out:
|
||||
tmp_out_path = tmp_out.name
|
||||
|
||||
# Build ffmpeg command
|
||||
# Video sticker requirements: WebM, VP9, 512px, 30fps, no audio, max 3s, max 256KB
|
||||
cmd = [
|
||||
'ffmpeg',
|
||||
'-i', tmp_in_path,
|
||||
'-t', str(duration_limit), # Duration limit
|
||||
'-vf', f'scale={new_width}:{new_height}:flags=lanczos,fps={TARGET_FPS}', # Resize + FPS
|
||||
'-c:v', 'libvpx-vp9', # VP9 codec
|
||||
'-pix_fmt', 'yuva420p', # With alpha channel (transparency)
|
||||
'-b:v', '0', # Use constant quality
|
||||
'-crf', '40', # Quality (lower = better, 0-63)
|
||||
'-deadline', 'good', # Encoding speed/quality tradeoff
|
||||
'-cpu-used', '2', # Encoding speed
|
||||
'-row-mt', '1', # Multi-threading
|
||||
'-an', # No audio
|
||||
'-f', 'webm', # Output format
|
||||
'-y', # Overwrite output
|
||||
tmp_out_path
|
||||
]
|
||||
|
||||
# Run ffmpeg
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"ffmpeg failed: {result.stderr}")
|
||||
return None
|
||||
|
||||
# Read output file
|
||||
with open(tmp_out_path, 'rb') as f:
|
||||
webm_bytes = f.read()
|
||||
|
||||
file_size_kb = len(webm_bytes) / 1024
|
||||
logger.info(f"Initial conversion: {file_size_kb:.1f}KB")
|
||||
|
||||
# If too large, try with lower quality
|
||||
if len(webm_bytes) > MAX_FILE_SIZE_BYTES:
|
||||
webm_bytes = await _compress_webm(tmp_in_path, tmp_out_path, duration_limit, new_width, new_height)
|
||||
|
||||
if webm_bytes and len(webm_bytes) <= MAX_FILE_SIZE_BYTES:
|
||||
final_size_kb = len(webm_bytes) / 1024
|
||||
logger.info(f"Final WebM: {final_size_kb:.1f}KB, "
|
||||
f"{new_width}x{new_height}")
|
||||
return webm_bytes
|
||||
else:
|
||||
logger.error("Could not compress to required size")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting GIF to WebM: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _compress_webm(
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
duration: float,
|
||||
width: int,
|
||||
height: int
|
||||
) -> Optional[bytes]:
|
||||
"""
|
||||
Try to compress WebM to meet size requirements.
|
||||
|
||||
Tries progressively lower quality settings.
|
||||
"""
|
||||
# Try different CRF values (lower = better quality, higher = smaller file)
|
||||
crf_values = [45, 50, 55, 60]
|
||||
|
||||
for crf in crf_values:
|
||||
try:
|
||||
logger.info(f"Trying compression with CRF={crf}")
|
||||
|
||||
cmd = [
|
||||
'ffmpeg',
|
||||
'-i', input_path,
|
||||
'-t', str(duration),
|
||||
'-vf', f'scale={width}:{height}:flags=lanczos,fps={TARGET_FPS}',
|
||||
'-c:v', 'libvpx-vp9',
|
||||
'-pix_fmt', 'yuva420p',
|
||||
'-b:v', '0',
|
||||
'-crf', str(crf),
|
||||
'-deadline', 'good',
|
||||
'-cpu-used', '3', # Faster encoding for smaller files
|
||||
'-row-mt', '1',
|
||||
'-an',
|
||||
'-f', 'webm',
|
||||
'-y',
|
||||
output_path
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
with open(output_path, 'rb') as f:
|
||||
webm_bytes = f.read()
|
||||
|
||||
file_size_kb = len(webm_bytes) / 1024
|
||||
logger.info(f"Compression CRF={crf}: {file_size_kb:.1f}KB")
|
||||
|
||||
if len(webm_bytes) <= MAX_FILE_SIZE_BYTES:
|
||||
return webm_bytes
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Compression attempt failed: {e}")
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def is_animated_image(file_bytes: bytes) -> bool:
|
||||
"""
|
||||
Check if image file is animated (GIF with multiple frames).
|
||||
|
||||
Args:
|
||||
file_bytes: Raw file bytes
|
||||
|
||||
Returns:
|
||||
True if animated, False otherwise
|
||||
"""
|
||||
try:
|
||||
image = Image.open(io.BytesIO(file_bytes))
|
||||
|
||||
# Check if it's a GIF
|
||||
if image.format != 'GIF':
|
||||
return False
|
||||
|
||||
# Check if it has multiple frames
|
||||
try:
|
||||
image.seek(1) # Try to go to second frame
|
||||
return True
|
||||
except EOFError:
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking if image is animated: {e}")
|
||||
return False
|
||||
Reference in New Issue
Block a user