feat: Add GIF optimizer cog

This commit introduces a new cog for optimizing GIFs.
This commit is contained in:
Slipstream 2025-05-29 11:33:54 -06:00
parent a210776635
commit 4ec866adb6
Signed by: slipstream
GPG Key ID: 13E498CE010AC6FD

187
cogs/gif_optimizer_cog.py Normal file
View File

@ -0,0 +1,187 @@
import discord
from discord.ext import commands
from discord import app_commands
from PIL import Image, ImageSequence, UnidentifiedImageError
import os
import io
import tempfile
import traceback
class GIFOptimizerCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
async def _optimize_gif_internal(self, input_bytes: bytes, colors: int, dither_on: bool, crop_box_str: str, resize_dimensions_str: str):
"""
Internal function to optimize a GIF from bytes, returning optimized bytes.
Handles file I/O using temporary files.
"""
input_path = None
output_path = None
try:
# Create a temporary input file
with tempfile.NamedTemporaryFile(delete=False, suffix=".gif") as temp_input_file:
temp_input_file.write(input_bytes)
input_path = temp_input_file.name
# Create a temporary output file
with tempfile.NamedTemporaryFile(delete=False, suffix=".gif") as temp_output_file:
output_path = temp_output_file.name
# Parse crop and resize arguments
crop_box_tuple = None
if crop_box_str:
try:
parts = [int(p.strip()) for p in crop_box_str.split(',')]
if len(parts) == 4:
crop_box_tuple = tuple(parts)
else:
raise ValueError("Crop argument must be four integers: left,top,right,bottom (e.g., '10,20,100,150')")
except ValueError as e:
return None, f"Invalid crop format: {e}"
resize_dims_tuple = None
if resize_dimensions_str:
try:
parts_str = resize_dimensions_str.replace('x', ',').split(',')
parts = [int(p.strip()) for p in parts_str]
if len(parts) == 2:
if parts[0] > 0 and parts[1] > 0:
resize_dims_tuple = tuple(parts)
else:
raise ValueError("Resize dimensions (width, height) must be positive integers.")
else:
raise ValueError("Resize argument must be two positive integers: width,height (e.g., '128,128' or '128x128')")
except ValueError as e:
return None, f"Invalid resize format: {e}"
dither_method = Image.Dither.FLOYDSTEINBERG if dither_on else None
num_colors = max(2, min(colors, 256))
# --- Original optimize_gif logic, adapted ---
img = Image.open(input_path)
original_loop = img.info.get('loop', 0)
original_transparency = img.info.get('transparency')
processed_frames = []
durations = []
disposals = []
for i, frame_image in enumerate(ImageSequence.Iterator(img)):
durations.append(frame_image.info.get('duration', 100))
disposals.append(frame_image.info.get('disposal', 2))
current_frame = frame_image.copy()
if crop_box_tuple:
try:
current_frame = current_frame.crop(crop_box_tuple)
except Exception as crop_error:
# Log warning, but don't fail the entire process for a single frame crop error
print(f"Warning: Could not apply crop box {crop_box_tuple} to frame {i+1}. Error: {crop_error}")
# Optionally, you could decide to skip this frame or use the original frame if cropping is critical.
# For now, we'll let the error propagate if it's a critical image error, otherwise proceed.
if resize_dims_tuple:
try:
# Use Image.LANCZOS directly
current_frame = current_frame.resize(resize_dims_tuple, Image.LANCZOS)
except Exception as resize_error:
print(f"Warning: Could not resize frame {i+1} to {resize_dims_tuple}. Error: {resize_error}")
frame_rgba = current_frame.convert('RGBA')
quantized_frame = frame_rgba.convert('P', palette=Image.Palette.ADAPTIVE, colors=num_colors, dither=dither_method)
processed_frames.append(quantized_frame)
if not processed_frames:
return None, "No frames processed from the GIF."
processed_frames[0].save(
output_path,
save_all=True,
append_images=processed_frames[1:],
optimize=True,
duration=durations,
loop=original_loop,
disposal=disposals,
transparency=original_transparency
)
with open(output_path, 'rb') as f:
optimized_bytes = f.read()
input_size = len(input_bytes)
output_size = len(optimized_bytes)
reduction_percentage = (input_size - output_size) / input_size * 100 if input_size > 0 else 0
stats = (
f"Original size: {input_size / 1024:.2f} KB\n"
f"Optimized size: {output_size / 1024:.2f} KB\n"
f"Reduction: {reduction_percentage:.2f}%"
)
return optimized_bytes, stats
except FileNotFoundError:
return None, "Internal error: Temporary file not found."
except UnidentifiedImageError:
return None, "Cannot identify image file. It might be corrupted or not a supported GIF format."
except Exception as e:
traceback.print_exc() # Print full traceback to console for debugging
return None, f"An unexpected error occurred during GIF optimization: {e}"
finally:
# Clean up temporary files
if input_path and os.path.exists(input_path):
os.remove(input_path)
if output_path and os.path.exists(output_path):
os.remove(output_path)
@commands.command(name="optimizegif", description="Optimizes a GIF attachment.")
async def optimize_gif_prefix(self, ctx: commands.Context, attachment: discord.Attachment, colors: int = 128, dither: bool = True, crop: str = None, resize: str = None):
if not attachment.filename.lower().endswith('.gif'):
await ctx.send("Please provide a GIF file.")
return
await ctx.defer()
try:
input_bytes = await attachment.read()
optimized_bytes, stats = await self._optimize_gif_internal(input_bytes, colors, dither, crop, resize)
if optimized_bytes:
file = discord.File(io.BytesIO(optimized_bytes), filename=f"optimized_{attachment.filename}")
await ctx.send(f"GIF optimized successfully!\n{stats}", file=file)
else:
await ctx.send(f"Failed to optimize GIF: {stats}")
except Exception as e:
await ctx.send(f"An error occurred: {e}")
traceback.print_exc()
@app_commands.command(name="optimizegif", description="Optimizes a GIF attachment.")
@app_commands.describe(
attachment="The GIF file to optimize.",
colors="Number of colors to reduce to (e.g., 256, 128, 64). Max 256.",
dither="Enable Floyd-Steinberg dithering (improves quality, slightly slower).",
crop="Crop the GIF. Provide as 'left,top,right,bottom' (e.g., '10,20,100,150').",
resize="Resize the GIF. Provide as 'width,height' (e.g., '128,128' or '128x128')."
)
async def optimize_gif_slash(self, interaction: discord.Interaction, attachment: discord.Attachment, colors: app_commands.Range[int, 2, 256] = 128, dither: bool = True, crop: str = None, resize: str = None):
if not attachment.filename.lower().endswith('.gif'):
await interaction.response.send_message("Please provide a GIF file.", ephemeral=True)
return
await interaction.response.defer()
try:
input_bytes = await attachment.read()
optimized_bytes, stats = await self._optimize_gif_internal(input_bytes, colors, dither, crop, resize)
if optimized_bytes:
file = discord.File(io.BytesIO(optimized_bytes), filename=f"optimized_{attachment.filename}")
await interaction.followup.send(f"GIF optimized successfully!\n{stats}", file=file)
else:
await interaction.followup.send(f"Failed to optimize GIF: {stats}")
except Exception as e:
await interaction.followup.send(f"An error occurred: {e}")
traceback.print_exc()
async def setup(bot):
await bot.add_cog(GIFOptimizerCog(bot))