feat: Add GIF optimizer cog
This commit introduces a new cog for optimizing GIFs.
This commit is contained in:
parent
a210776635
commit
4ec866adb6
187
cogs/gif_optimizer_cog.py
Normal file
187
cogs/gif_optimizer_cog.py
Normal 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))
|
Loading…
x
Reference in New Issue
Block a user