Setting the file. One moment.
GIF Builder · Slack GIF Creator · anthropics/skills · Skills Docs
ContentsBack to the top of the page Previous
Bundled file Frame Composer
core/ gif_builder.py
Python · 269 lines · 10 KB
np
14 from PIL import Image
15
16
17 class 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 = []