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
170 lines
5.2 KiB
Python
170 lines
5.2 KiB
Python
"""Image processing module for converting images to Telegram sticker format."""
|
|
import io
|
|
import logging
|
|
from typing import Optional, Tuple
|
|
from PIL import Image
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Telegram sticker requirements
|
|
STICKER_SIZE = 512 # One side must be exactly 512px
|
|
MAX_FILE_SIZE_MB = 3 # Maximum file size in MB
|
|
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
|
|
|
|
|
|
async def process_image_for_sticker(file_data: bytes, filename: Optional[str] = None) -> Optional[bytes]:
|
|
"""
|
|
Process an image file to meet Telegram sticker requirements.
|
|
|
|
Args:
|
|
file_data: Raw image bytes from Telegram
|
|
filename: Original filename (optional, for format detection)
|
|
|
|
Returns:
|
|
Processed image bytes in WebP format, or None if processing fails
|
|
"""
|
|
try:
|
|
# Open image from bytes
|
|
image = Image.open(io.BytesIO(file_data))
|
|
|
|
# Convert to RGBA if necessary (Telegram requires transparency support)
|
|
if image.mode != 'RGBA':
|
|
if image.mode == 'RGB':
|
|
# Add alpha channel
|
|
image = image.convert('RGBA')
|
|
elif image.mode in ('L', 'LA', 'P'):
|
|
# Grayscale or palette - convert to RGBA
|
|
image = image.convert('RGBA')
|
|
else:
|
|
# Any other mode - convert to RGBA
|
|
image = image.convert('RGBA')
|
|
|
|
# Resize to Telegram sticker format (512px on one side)
|
|
image = resize_for_sticker(image)
|
|
|
|
# Convert to WebP format with optimization
|
|
output = io.BytesIO()
|
|
|
|
# Try different quality levels to meet file size requirements
|
|
quality = 95
|
|
while quality >= 60:
|
|
output.seek(0)
|
|
output.truncate(0)
|
|
|
|
image.save(
|
|
output,
|
|
format='WEBP',
|
|
quality=quality,
|
|
method=6, # Best compression method
|
|
exact=True # Preserve transparency
|
|
)
|
|
|
|
file_size = output.tell()
|
|
|
|
if file_size <= MAX_FILE_SIZE_BYTES:
|
|
logger.info(f"Image processed: {image.size}, quality={quality}, size={file_size/1024:.1f}KB")
|
|
output.seek(0)
|
|
return output.getvalue()
|
|
|
|
# Reduce quality and try again
|
|
quality -= 5
|
|
logger.debug(f"File too large ({file_size/1024:.1f}KB), reducing quality to {quality}")
|
|
|
|
# If we get here, even lowest quality is too large
|
|
logger.error(f"Could not compress image below {MAX_FILE_SIZE_MB}MB")
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing image: {e}")
|
|
return None
|
|
|
|
|
|
def resize_for_sticker(image: Image.Image) -> Image.Image:
|
|
"""
|
|
Resize image to meet Telegram sticker requirements.
|
|
One side must be exactly 512px, other side <= 512px
|
|
|
|
Args:
|
|
image: PIL Image object
|
|
|
|
Returns:
|
|
Resized image
|
|
"""
|
|
width, height = image.size
|
|
|
|
# Determine which side is longer
|
|
if width >= height:
|
|
# Landscape or square - set width to 512
|
|
new_width = STICKER_SIZE
|
|
new_height = int((height / width) * STICKER_SIZE)
|
|
else:
|
|
# Portrait - set height to 512
|
|
new_height = STICKER_SIZE
|
|
new_width = int((width / height) * STICKER_SIZE)
|
|
|
|
# Ensure neither dimension exceeds 512
|
|
new_width = min(new_width, STICKER_SIZE)
|
|
new_height = min(new_height, STICKER_SIZE)
|
|
|
|
# Resize using LANCZOS for best quality
|
|
resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
|
|
logger.debug(f"Resized image from {width}x{height} to {new_width}x{new_height}")
|
|
return resized
|
|
|
|
|
|
async def validate_image(file_data: bytes) -> Tuple[bool, str]:
|
|
"""
|
|
Validate if file data is a valid image.
|
|
|
|
Args:
|
|
file_data: Raw file bytes
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
"""
|
|
try:
|
|
image = Image.open(io.BytesIO(file_data))
|
|
|
|
# Check format
|
|
if image.format not in ('PNG', 'JPEG', 'JPG', 'WEBP', 'GIF', 'BMP', 'TIFF'):
|
|
return False, f"Unsupported image format: {image.format}"
|
|
|
|
# Check if image can be processed
|
|
width, height = image.size
|
|
if width < 64 or height < 64:
|
|
return False, "Image too small (minimum 64x64 pixels)"
|
|
|
|
# Check file size
|
|
if len(file_data) > 10 * 1024 * 1024: # 10MB limit for input
|
|
return False, "Image file too large (max 10MB for upload)"
|
|
|
|
return True, ""
|
|
|
|
except Exception as e:
|
|
return False, f"Invalid image file: {str(e)}"
|
|
|
|
|
|
async def get_image_info(file_data: bytes) -> dict:
|
|
"""
|
|
Get information about an image.
|
|
|
|
Args:
|
|
file_data: Raw image bytes
|
|
|
|
Returns:
|
|
Dictionary with image info
|
|
"""
|
|
try:
|
|
image = Image.open(io.BytesIO(file_data))
|
|
return {
|
|
"format": image.format,
|
|
"mode": image.mode,
|
|
"size": image.size,
|
|
"width": image.width,
|
|
"height": image.height,
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Error getting image info: {e}")
|
|
return {}
|