gif_builder.py 9.6 KB

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