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:
169
image_processor.py
Normal file
169
image_processor.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""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 {}
|
||||
Reference in New Issue
Block a user