270 lines
9.6 KiB
Python
Executable File
270 lines
9.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
GIF Builder - Core module for assembling frames into GIFs optimized for Slack.
|
|
|
|
This module provides the main interface for creating GIFs from programmatically
|
|
generated frames, with automatic optimization for Slack's requirements.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import imageio.v3 as imageio
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
|
|
class GIFBuilder:
|
|
"""Builder for creating optimized GIFs from frames."""
|
|
|
|
def __init__(self, width: int = 480, height: int = 480, fps: int = 15):
|
|
"""
|
|
Initialize GIF builder.
|
|
|
|
Args:
|
|
width: Frame width in pixels
|
|
height: Frame height in pixels
|
|
fps: Frames per second
|
|
"""
|
|
self.width = width
|
|
self.height = height
|
|
self.fps = fps
|
|
self.frames: list[np.ndarray] = []
|
|
|
|
def add_frame(self, frame: np.ndarray | Image.Image):
|
|
"""
|
|
Add a frame to the GIF.
|
|
|
|
Args:
|
|
frame: Frame as numpy array or PIL Image (will be converted to RGB)
|
|
"""
|
|
if isinstance(frame, Image.Image):
|
|
frame = np.array(frame.convert("RGB"))
|
|
|
|
# Ensure frame is correct size
|
|
if frame.shape[:2] != (self.height, self.width):
|
|
pil_frame = Image.fromarray(frame)
|
|
pil_frame = pil_frame.resize(
|
|
(self.width, self.height), Image.Resampling.LANCZOS
|
|
)
|
|
frame = np.array(pil_frame)
|
|
|
|
self.frames.append(frame)
|
|
|
|
def add_frames(self, frames: list[np.ndarray | Image.Image]):
|
|
"""Add multiple frames at once."""
|
|
for frame in frames:
|
|
self.add_frame(frame)
|
|
|
|
def optimize_colors(
|
|
self, num_colors: int = 128, use_global_palette: bool = True
|
|
) -> list[np.ndarray]:
|
|
"""
|
|
Reduce colors in all frames using quantization.
|
|
|
|
Args:
|
|
num_colors: Target number of colors (8-256)
|
|
use_global_palette: Use a single palette for all frames (better compression)
|
|
|
|
Returns:
|
|
List of color-optimized frames
|
|
"""
|
|
optimized = []
|
|
|
|
if use_global_palette and len(self.frames) > 1:
|
|
# Create a global palette from all frames
|
|
# Sample frames to build palette
|
|
sample_size = min(5, len(self.frames))
|
|
sample_indices = [
|
|
int(i * len(self.frames) / sample_size) for i in range(sample_size)
|
|
]
|
|
sample_frames = [self.frames[i] for i in sample_indices]
|
|
|
|
# Combine sample frames into a single image for palette generation
|
|
# Flatten each frame to get all pixels, then stack them
|
|
all_pixels = np.vstack(
|
|
[f.reshape(-1, 3) for f in sample_frames]
|
|
) # (total_pixels, 3)
|
|
|
|
# Create a properly-shaped RGB image from the pixel data
|
|
# We'll make a roughly square image from all the pixels
|
|
total_pixels = len(all_pixels)
|
|
width = min(512, int(np.sqrt(total_pixels))) # Reasonable width, max 512
|
|
height = (total_pixels + width - 1) // width # Ceiling division
|
|
|
|
# Pad if necessary to fill the rectangle
|
|
pixels_needed = width * height
|
|
if pixels_needed > total_pixels:
|
|
padding = np.zeros((pixels_needed - total_pixels, 3), dtype=np.uint8)
|
|
all_pixels = np.vstack([all_pixels, padding])
|
|
|
|
# Reshape to proper RGB image format (H, W, 3)
|
|
img_array = (
|
|
all_pixels[:pixels_needed].reshape(height, width, 3).astype(np.uint8)
|
|
)
|
|
combined_img = Image.fromarray(img_array, mode="RGB")
|
|
|
|
# Generate global palette
|
|
global_palette = combined_img.quantize(colors=num_colors, method=2)
|
|
|
|
# Apply global palette to all frames
|
|
for frame in self.frames:
|
|
pil_frame = Image.fromarray(frame)
|
|
quantized = pil_frame.quantize(palette=global_palette, dither=1)
|
|
optimized.append(np.array(quantized.convert("RGB")))
|
|
else:
|
|
# Use per-frame quantization
|
|
for frame in self.frames:
|
|
pil_frame = Image.fromarray(frame)
|
|
quantized = pil_frame.quantize(colors=num_colors, method=2, dither=1)
|
|
optimized.append(np.array(quantized.convert("RGB")))
|
|
|
|
return optimized
|
|
|
|
def deduplicate_frames(self, threshold: float = 0.9995) -> int:
|
|
"""
|
|
Remove duplicate or near-duplicate consecutive frames.
|
|
|
|
Args:
|
|
threshold: Similarity threshold (0.0-1.0). Higher = more strict (0.9995 = nearly identical).
|
|
Use 0.9995+ to preserve subtle animations, 0.98 for aggressive removal.
|
|
|
|
Returns:
|
|
Number of frames removed
|
|
"""
|
|
if len(self.frames) < 2:
|
|
return 0
|
|
|
|
deduplicated = [self.frames[0]]
|
|
removed_count = 0
|
|
|
|
for i in range(1, len(self.frames)):
|
|
# Compare with previous frame
|
|
prev_frame = np.array(deduplicated[-1], dtype=np.float32)
|
|
curr_frame = np.array(self.frames[i], dtype=np.float32)
|
|
|
|
# Calculate similarity (normalized)
|
|
diff = np.abs(prev_frame - curr_frame)
|
|
similarity = 1.0 - (np.mean(diff) / 255.0)
|
|
|
|
# Keep frame if sufficiently different
|
|
# High threshold (0.9995+) means only remove nearly identical frames
|
|
if similarity < threshold:
|
|
deduplicated.append(self.frames[i])
|
|
else:
|
|
removed_count += 1
|
|
|
|
self.frames = deduplicated
|
|
return removed_count
|
|
|
|
def save(
|
|
self,
|
|
output_path: str | Path,
|
|
num_colors: int = 128,
|
|
optimize_for_emoji: bool = False,
|
|
remove_duplicates: bool = False,
|
|
) -> dict:
|
|
"""
|
|
Save frames as optimized GIF for Slack.
|
|
|
|
Args:
|
|
output_path: Where to save the GIF
|
|
num_colors: Number of colors to use (fewer = smaller file)
|
|
optimize_for_emoji: If True, optimize for emoji size (128x128, fewer colors)
|
|
remove_duplicates: If True, remove duplicate consecutive frames (opt-in)
|
|
|
|
Returns:
|
|
Dictionary with file info (path, size, dimensions, frame_count)
|
|
"""
|
|
if not self.frames:
|
|
raise ValueError("No frames to save. Add frames with add_frame() first.")
|
|
|
|
output_path = Path(output_path)
|
|
|
|
# Remove duplicate frames to reduce file size
|
|
if remove_duplicates:
|
|
removed = self.deduplicate_frames(threshold=0.9995)
|
|
if removed > 0:
|
|
print(
|
|
f" Removed {removed} nearly identical frames (preserved subtle animations)"
|
|
)
|
|
|
|
# Optimize for emoji if requested
|
|
if optimize_for_emoji:
|
|
if self.width > 128 or self.height > 128:
|
|
print(
|
|
f" Resizing from {self.width}x{self.height} to 128x128 for emoji"
|
|
)
|
|
self.width = 128
|
|
self.height = 128
|
|
# Resize all frames
|
|
resized_frames = []
|
|
for frame in self.frames:
|
|
pil_frame = Image.fromarray(frame)
|
|
pil_frame = pil_frame.resize((128, 128), Image.Resampling.LANCZOS)
|
|
resized_frames.append(np.array(pil_frame))
|
|
self.frames = resized_frames
|
|
num_colors = min(num_colors, 48) # More aggressive color limit for emoji
|
|
|
|
# More aggressive FPS reduction for emoji
|
|
if len(self.frames) > 12:
|
|
print(
|
|
f" Reducing frames from {len(self.frames)} to ~12 for emoji size"
|
|
)
|
|
# Keep every nth frame to get close to 12 frames
|
|
keep_every = max(1, len(self.frames) // 12)
|
|
self.frames = [
|
|
self.frames[i] for i in range(0, len(self.frames), keep_every)
|
|
]
|
|
|
|
# Optimize colors with global palette
|
|
optimized_frames = self.optimize_colors(num_colors, use_global_palette=True)
|
|
|
|
# Calculate frame duration in milliseconds
|
|
frame_duration = 1000 / self.fps
|
|
|
|
# Save GIF
|
|
imageio.imwrite(
|
|
output_path,
|
|
optimized_frames,
|
|
duration=frame_duration,
|
|
loop=0, # Infinite loop
|
|
)
|
|
|
|
# Get file info
|
|
file_size_kb = output_path.stat().st_size / 1024
|
|
file_size_mb = file_size_kb / 1024
|
|
|
|
info = {
|
|
"path": str(output_path),
|
|
"size_kb": file_size_kb,
|
|
"size_mb": file_size_mb,
|
|
"dimensions": f"{self.width}x{self.height}",
|
|
"frame_count": len(optimized_frames),
|
|
"fps": self.fps,
|
|
"duration_seconds": len(optimized_frames) / self.fps,
|
|
"colors": num_colors,
|
|
}
|
|
|
|
# Print info
|
|
print(f"\n✓ GIF created successfully!")
|
|
print(f" Path: {output_path}")
|
|
print(f" Size: {file_size_kb:.1f} KB ({file_size_mb:.2f} MB)")
|
|
print(f" Dimensions: {self.width}x{self.height}")
|
|
print(f" Frames: {len(optimized_frames)} @ {self.fps} fps")
|
|
print(f" Duration: {info['duration_seconds']:.1f}s")
|
|
print(f" Colors: {num_colors}")
|
|
|
|
# Size info
|
|
if optimize_for_emoji:
|
|
print(f" Optimized for emoji (128x128, reduced colors)")
|
|
if file_size_mb > 1.0:
|
|
print(f"\n Note: Large file size ({file_size_kb:.1f} KB)")
|
|
print(" Consider: fewer frames, smaller dimensions, or fewer colors")
|
|
|
|
return info
|
|
|
|
def clear(self):
|
|
"""Clear all frames (useful for creating multiple GIFs)."""
|
|
self.frames = []
|