Files
stickerbot/gif_processor.py
coding f634b884ec 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
2026-03-25 08:59:38 +00:00

268 lines
8.7 KiB
Python

"""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