main
  1#!/usr/bin/env python3
  2"""
  3GIF Builder - Core module for assembling frames into GIFs optimized for Slack.
  4
  5This module provides the main interface for creating GIFs from programmatically
  6generated frames, with automatic optimization for Slack's requirements.
  7"""
  8
  9from pathlib import Path
 10from typing import Optional
 11
 12import imageio.v3 as imageio
 13import numpy as np
 14from PIL import Image
 15
 16
 17class GIFBuilder:
 18    """Builder for creating optimized GIFs from frames."""
 19
 20    def __init__(self, width: int = 480, height: int = 480, fps: int = 15):
 21        """
 22        Initialize GIF builder.
 23
 24        Args:
 25            width: Frame width in pixels
 26            height: Frame height in pixels
 27            fps: Frames per second
 28        """
 29        self.width = width
 30        self.height = height
 31        self.fps = fps
 32        self.frames: list[np.ndarray] = []
 33
 34    def add_frame(self, frame: np.ndarray | Image.Image):
 35        """
 36        Add a frame to the GIF.
 37
 38        Args:
 39            frame: Frame as numpy array or PIL Image (will be converted to RGB)
 40        """
 41        if isinstance(frame, Image.Image):
 42            frame = np.array(frame.convert("RGB"))
 43
 44        # Ensure frame is correct size
 45        if frame.shape[:2] != (self.height, self.width):
 46            pil_frame = Image.fromarray(frame)
 47            pil_frame = pil_frame.resize(
 48                (self.width, self.height), Image.Resampling.LANCZOS
 49            )
 50            frame = np.array(pil_frame)
 51
 52        self.frames.append(frame)
 53
 54    def add_frames(self, frames: list[np.ndarray | Image.Image]):
 55        """Add multiple frames at once."""
 56        for frame in frames:
 57            self.add_frame(frame)
 58
 59    def optimize_colors(
 60        self, num_colors: int = 128, use_global_palette: bool = True
 61    ) -> list[np.ndarray]:
 62        """
 63        Reduce colors in all frames using quantization.
 64
 65        Args:
 66            num_colors: Target number of colors (8-256)
 67            use_global_palette: Use a single palette for all frames (better compression)
 68
 69        Returns:
 70            List of color-optimized frames
 71        """
 72        optimized = []
 73
 74        if use_global_palette and len(self.frames) > 1:
 75            # Create a global palette from all frames
 76            # Sample frames to build palette
 77            sample_size = min(5, len(self.frames))
 78            sample_indices = [
 79                int(i * len(self.frames) / sample_size) for i in range(sample_size)
 80            ]
 81            sample_frames = [self.frames[i] for i in sample_indices]
 82
 83            # Combine sample frames into a single image for palette generation
 84            # Flatten each frame to get all pixels, then stack them
 85            all_pixels = np.vstack(
 86                [f.reshape(-1, 3) for f in sample_frames]
 87            )  # (total_pixels, 3)
 88
 89            # Create a properly-shaped RGB image from the pixel data
 90            # We'll make a roughly square image from all the pixels
 91            total_pixels = len(all_pixels)
 92            width = min(512, int(np.sqrt(total_pixels)))  # Reasonable width, max 512
 93            height = (total_pixels + width - 1) // width  # Ceiling division
 94
 95            # Pad if necessary to fill the rectangle
 96            pixels_needed = width * height
 97            if pixels_needed > total_pixels:
 98                padding = np.zeros((pixels_needed - total_pixels, 3), dtype=np.uint8)
 99                all_pixels = np.vstack([all_pixels, padding])
100
101            # Reshape to proper RGB image format (H, W, 3)
102            img_array = (
103                all_pixels[:pixels_needed].reshape(height, width, 3).astype(np.uint8)
104            )
105            combined_img = Image.fromarray(img_array, mode="RGB")
106
107            # Generate global palette
108            global_palette = combined_img.quantize(colors=num_colors, method=2)
109
110            # Apply global palette to all frames
111            for frame in self.frames:
112                pil_frame = Image.fromarray(frame)
113                quantized = pil_frame.quantize(palette=global_palette, dither=1)
114                optimized.append(np.array(quantized.convert("RGB")))
115        else:
116            # Use per-frame quantization
117            for frame in self.frames:
118                pil_frame = Image.fromarray(frame)
119                quantized = pil_frame.quantize(colors=num_colors, method=2, dither=1)
120                optimized.append(np.array(quantized.convert("RGB")))
121
122        return optimized
123
124    def deduplicate_frames(self, threshold: float = 0.9995) -> int:
125        """
126        Remove duplicate or near-duplicate consecutive frames.
127
128        Args:
129            threshold: Similarity threshold (0.0-1.0). Higher = more strict (0.9995 = nearly identical).
130                      Use 0.9995+ to preserve subtle animations, 0.98 for aggressive removal.
131
132        Returns:
133            Number of frames removed
134        """
135        if len(self.frames) < 2:
136            return 0
137
138        deduplicated = [self.frames[0]]
139        removed_count = 0
140
141        for i in range(1, len(self.frames)):
142            # Compare with previous frame
143            prev_frame = np.array(deduplicated[-1], dtype=np.float32)
144            curr_frame = np.array(self.frames[i], dtype=np.float32)
145
146            # Calculate similarity (normalized)
147            diff = np.abs(prev_frame - curr_frame)
148            similarity = 1.0 - (np.mean(diff) / 255.0)
149
150            # Keep frame if sufficiently different
151            # High threshold (0.9995+) means only remove nearly identical frames
152            if similarity < threshold:
153                deduplicated.append(self.frames[i])
154            else:
155                removed_count += 1
156
157        self.frames = deduplicated
158        return removed_count
159
160    def save(
161        self,
162        output_path: str | Path,
163        num_colors: int = 128,
164        optimize_for_emoji: bool = False,
165        remove_duplicates: bool = False,
166    ) -> dict:
167        """
168        Save frames as optimized GIF for Slack.
169
170        Args:
171            output_path: Where to save the GIF
172            num_colors: Number of colors to use (fewer = smaller file)
173            optimize_for_emoji: If True, optimize for emoji size (128x128, fewer colors)
174            remove_duplicates: If True, remove duplicate consecutive frames (opt-in)
175
176        Returns:
177            Dictionary with file info (path, size, dimensions, frame_count)
178        """
179        if not self.frames:
180            raise ValueError("No frames to save. Add frames with add_frame() first.")
181
182        output_path = Path(output_path)
183
184        # Remove duplicate frames to reduce file size
185        if remove_duplicates:
186            removed = self.deduplicate_frames(threshold=0.9995)
187            if removed > 0:
188                print(
189                    f"  Removed {removed} nearly identical frames (preserved subtle animations)"
190                )
191
192        # Optimize for emoji if requested
193        if optimize_for_emoji:
194            if self.width > 128 or self.height > 128:
195                print(
196                    f"  Resizing from {self.width}x{self.height} to 128x128 for emoji"
197                )
198                self.width = 128
199                self.height = 128
200                # Resize all frames
201                resized_frames = []
202                for frame in self.frames:
203                    pil_frame = Image.fromarray(frame)
204                    pil_frame = pil_frame.resize((128, 128), Image.Resampling.LANCZOS)
205                    resized_frames.append(np.array(pil_frame))
206                self.frames = resized_frames
207            num_colors = min(num_colors, 48)  # More aggressive color limit for emoji
208
209            # More aggressive FPS reduction for emoji
210            if len(self.frames) > 12:
211                print(
212                    f"  Reducing frames from {len(self.frames)} to ~12 for emoji size"
213                )
214                # Keep every nth frame to get close to 12 frames
215                keep_every = max(1, len(self.frames) // 12)
216                self.frames = [
217                    self.frames[i] for i in range(0, len(self.frames), keep_every)
218                ]
219
220        # Optimize colors with global palette
221        optimized_frames = self.optimize_colors(num_colors, use_global_palette=True)
222
223        # Calculate frame duration in milliseconds
224        frame_duration = 1000 / self.fps
225
226        # Save GIF
227        imageio.imwrite(
228            output_path,
229            optimized_frames,
230            duration=frame_duration,
231            loop=0,  # Infinite loop
232        )
233
234        # Get file info
235        file_size_kb = output_path.stat().st_size / 1024
236        file_size_mb = file_size_kb / 1024
237
238        info = {
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        }
248
249        # Print info
250        print(f"\n✓ GIF created successfully!")
251        print(f"  Path: {output_path}")
252        print(f"  Size: {file_size_kb:.1f} KB ({file_size_mb:.2f} MB)")
253        print(f"  Dimensions: {self.width}x{self.height}")
254        print(f"  Frames: {len(optimized_frames)} @ {self.fps} fps")
255        print(f"  Duration: {info['duration_seconds']:.1f}s")
256        print(f"  Colors: {num_colors}")
257
258        # Size info
259        if optimize_for_emoji:
260            print(f"  Optimized for emoji (128x128, reduced colors)")
261        if file_size_mb > 1.0:
262            print(f"\n  Note: Large file size ({file_size_kb:.1f} KB)")
263            print("  Consider: fewer frames, smaller dimensions, or fewer colors")
264
265        return info
266
267    def clear(self):
268        """Clear all frames (useful for creating multiple GIFs)."""
269        self.frames = []