Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Creates optimized animated GIFs for Slack emoji (128x128) or messages (480x480) using Python PIL with polished drawing primitives.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
core/gif_builder.py
1#!/usr/bin/env python32"""3GIF Builder - Core module for assembling frames into GIFs optimized for Slack.45This module provides the main interface for creating GIFs from programmatically6generated frames, with automatic optimization for Slack's requirements.7"""89from pathlib import Path10from typing import Optional1112import imageio.v3 as imageio13import numpy as np14from PIL import Image151617class GIFBuilder:18"""Builder for creating optimized GIFs from frames."""1920def __init__(self, width: int = 480, height: int = 480, fps: int = 15):21"""22Initialize GIF builder.2324Args:25width: Frame width in pixels26height: Frame height in pixels27fps: Frames per second28"""29self.width = width30self.height = height31self.fps = fps32self.frames: list[np.ndarray] = []3334def add_frame(self, frame: np.ndarray | Image.Image):35"""36Add a frame to the GIF.3738Args:39frame: Frame as numpy array or PIL Image (will be converted to RGB)40"""41if isinstance(frame, Image.Image):42frame = np.array(frame.convert("RGB"))4344# Ensure frame is correct size45if frame.shape[:2] != (self.height, self.width):46pil_frame = Image.fromarray(frame)47pil_frame = pil_frame.resize(48(self.width, self.height), Image.Resampling.LANCZOS49)50frame = np.array(pil_frame)5152self.frames.append(frame)5354def add_frames(self, frames: list[np.ndarray | Image.Image]):55"""Add multiple frames at once."""56for frame in frames:57self.add_frame(frame)5859def optimize_colors(60self, num_colors: int = 128, use_global_palette: bool = True61) -> list[np.ndarray]:62"""63Reduce colors in all frames using quantization.6465Args:66num_colors: Target number of colors (8-256)67use_global_palette: Use a single palette for all frames (better compression)6869Returns:70List of color-optimized frames71"""72optimized = []7374if use_global_palette and len(self.frames) > 1:75# Create a global palette from all frames76# Sample frames to build palette77sample_size = min(5, len(self.frames))78sample_indices = [79int(i * len(self.frames) / sample_size) for i in range(sample_size)80]81sample_frames = [self.frames[i] for i in sample_indices]8283# Combine sample frames into a single image for palette generation84# Flatten each frame to get all pixels, then stack them85all_pixels = np.vstack(86[f.reshape(-1, 3) for f in sample_frames]87) # (total_pixels, 3)8889# Create a properly-shaped RGB image from the pixel data90# We'll make a roughly square image from all the pixels91total_pixels = len(all_pixels)92width = min(512, int(np.sqrt(total_pixels))) # Reasonable width, max 51293height = (total_pixels + width - 1) // width # Ceiling division9495# Pad if necessary to fill the rectangle96pixels_needed = width * height97if pixels_needed > total_pixels:98padding = np.zeros((pixels_needed - total_pixels, 3), dtype=np.uint8)99all_pixels = np.vstack([all_pixels, padding])100101# Reshape to proper RGB image format (H, W, 3)102img_array = (103all_pixels[:pixels_needed].reshape(height, width, 3).astype(np.uint8)104)105combined_img = Image.fromarray(img_array, mode="RGB")106107# Generate global palette108global_palette = combined_img.quantize(colors=num_colors, method=2)109110# Apply global palette to all frames111for frame in self.frames:112pil_frame = Image.fromarray(frame)113quantized = pil_frame.quantize(palette=global_palette, dither=1)114optimized.append(np.array(quantized.convert("RGB")))115else:116# Use per-frame quantization117for frame in self.frames:118pil_frame = Image.fromarray(frame)119quantized = pil_frame.quantize(colors=num_colors, method=2, dither=1)120optimized.append(np.array(quantized.convert("RGB")))121122return optimized123124def deduplicate_frames(self, threshold: float = 0.9995) -> int:125"""126Remove duplicate or near-duplicate consecutive frames.127128Args:129threshold: Similarity threshold (0.0-1.0). Higher = more strict (0.9995 = nearly identical).130Use 0.9995+ to preserve subtle animations, 0.98 for aggressive removal.131132Returns:133Number of frames removed134"""135if len(self.frames) < 2:136return 0137138deduplicated = [self.frames[0]]139removed_count = 0140141for i in range(1, len(self.frames)):142# Compare with previous frame143prev_frame = np.array(deduplicated[-1], dtype=np.float32)144curr_frame = np.array(self.frames[i], dtype=np.float32)145146# Calculate similarity (normalized)147diff = np.abs(prev_frame - curr_frame)148similarity = 1.0 - (np.mean(diff) / 255.0)149150# Keep frame if sufficiently different151# High threshold (0.9995+) means only remove nearly identical frames152if similarity < threshold:153deduplicated.append(self.frames[i])154else:155removed_count += 1156157self.frames = deduplicated158return removed_count159160def save(161self,162output_path: str | Path,163num_colors: int = 128,164optimize_for_emoji: bool = False,165remove_duplicates: bool = False,166) -> dict:167"""168Save frames as optimized GIF for Slack.169170Args:171output_path: Where to save the GIF172num_colors: Number of colors to use (fewer = smaller file)173optimize_for_emoji: If True, optimize for emoji size (128x128, fewer colors)174remove_duplicates: If True, remove duplicate consecutive frames (opt-in)175176Returns:177Dictionary with file info (path, size, dimensions, frame_count)178"""179if not self.frames:180raise ValueError("No frames to save. Add frames with add_frame() first.")181182output_path = Path(output_path)183184# Remove duplicate frames to reduce file size185if remove_duplicates:186removed = self.deduplicate_frames(threshold=0.9995)187if removed > 0:188print(189f" Removed {removed} nearly identical frames (preserved subtle animations)"190)191192# Optimize for emoji if requested193if optimize_for_emoji:194if self.width > 128 or self.height > 128:195print(196f" Resizing from {self.width}x{self.height} to 128x128 for emoji"197)198self.width = 128199self.height = 128200# Resize all frames201resized_frames = []202for frame in self.frames:203pil_frame = Image.fromarray(frame)204pil_frame = pil_frame.resize((128, 128), Image.Resampling.LANCZOS)205resized_frames.append(np.array(pil_frame))206self.frames = resized_frames207num_colors = min(num_colors, 48) # More aggressive color limit for emoji208209# More aggressive FPS reduction for emoji210if len(self.frames) > 12:211print(212f" Reducing frames from {len(self.frames)} to ~12 for emoji size"213)214# Keep every nth frame to get close to 12 frames215keep_every = max(1, len(self.frames) // 12)216self.frames = [217self.frames[i] for i in range(0, len(self.frames), keep_every)218]219220# Optimize colors with global palette221optimized_frames = self.optimize_colors(num_colors, use_global_palette=True)222223# Calculate frame duration in milliseconds224frame_duration = 1000 / self.fps225226# Save GIF227imageio.imwrite(228output_path,229optimized_frames,230duration=frame_duration,231loop=0, # Infinite loop232)233234# Get file info235file_size_kb = output_path.stat().st_size / 1024236file_size_mb = file_size_kb / 1024237238info = {239"path": str(output_path),240"size_kb": file_size_kb,241"size_mb": file_size_mb,242"dimensions": f"{self.width}x{self.height}",243"frame_count": len(optimized_frames),244"fps": self.fps,245"duration_seconds": len(optimized_frames) / self.fps,246"colors": num_colors,247}248249# Print info250print(f"\n✓ GIF created successfully!")251print(f" Path: {output_path}")252print(f" Size: {file_size_kb:.1f} KB ({file_size_mb:.2f} MB)")253print(f" Dimensions: {self.width}x{self.height}")254print(f" Frames: {len(optimized_frames)} @ {self.fps} fps")255print(f" Duration: {info['duration_seconds']:.1f}s")256print(f" Colors: {num_colors}")257258# Size info259if optimize_for_emoji:260print(f" Optimized for emoji (128x128, reduced colors)")261if file_size_mb > 1.0:262print(f"\n Note: Large file size ({file_size_kb:.1f} KB)")263print(" Consider: fewer frames, smaller dimensions, or fewer colors")264265return info266267def clear(self):268"""Clear all frames (useful for creating multiple GIFs)."""269self.frames = []270