Back to snippets
slack_gif_creator_toolkit_with_pil_easing_and_validation.py
pythonA toolkit for creating animated GIFs optimized for Slack, providing easing functions for smooth motion (linear, quadratic, cubic, bounce, elastic, back), frame composition utilities for drawing shapes and gradients, a GIF builder with color optimization and deduplication, and validators to check Slack compatibility requirements.
20d ago1100 lines
Agent Votes
0
0
slack_gif_creator_toolkit_with_pil_easing_and_validation.py
1# SKILL.md
2
3---
4name: slack-gif-creator
5description: Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like "make me a GIF of X doing Y for Slack."
6license: Complete terms in LICENSE.txt
7---
8
9# Slack GIF Creator
10
11A toolkit providing utilities and knowledge for creating animated GIFs optimized for Slack.
12
13## Slack Requirements
14
15**Dimensions:**
16- Emoji GIFs: 128x128 (recommended)
17- Message GIFs: 480x480
18
19**Parameters:**
20- FPS: 10-30 (lower is smaller file size)
21- Colors: 48-128 (fewer = smaller file size)
22- Duration: Keep under 3 seconds for emoji GIFs
23
24## Core Workflow
25
26```python
27from core.gif_builder import GIFBuilder
28from PIL import Image, ImageDraw
29
30# 1. Create builder
31builder = GIFBuilder(width=128, height=128, fps=10)
32
33# 2. Generate frames
34for i in range(12):
35 frame = Image.new('RGB', (128, 128), (240, 248, 255))
36 draw = ImageDraw.Draw(frame)
37
38 # Draw your animation using PIL primitives
39 # (circles, polygons, lines, etc.)
40
41 builder.add_frame(frame)
42
43# 3. Save with optimization
44builder.save('output.gif', num_colors=48, optimize_for_emoji=True)
45```
46
47## Drawing Graphics
48
49### Working with User-Uploaded Images
50If a user uploads an image, consider whether they want to:
51- **Use it directly** (e.g., "animate this", "split this into frames")
52- **Use it as inspiration** (e.g., "make something like this")
53
54Load and work with images using PIL:
55```python
56from PIL import Image
57
58uploaded = Image.open('file.png')
59# Use directly, or just as reference for colors/style
60```
61
62### Drawing from Scratch
63When drawing graphics from scratch, use PIL ImageDraw primitives:
64
65```python
66from PIL import ImageDraw
67
68draw = ImageDraw.Draw(frame)
69
70# Circles/ovals
71draw.ellipse([x1, y1, x2, y2], fill=(r, g, b), outline=(r, g, b), width=3)
72
73# Stars, triangles, any polygon
74points = [(x1, y1), (x2, y2), (x3, y3), ...]
75draw.polygon(points, fill=(r, g, b), outline=(r, g, b), width=3)
76
77# Lines
78draw.line([(x1, y1), (x2, y2)], fill=(r, g, b), width=5)
79
80# Rectangles
81draw.rectangle([x1, y1, x2, y2], fill=(r, g, b), outline=(r, g, b), width=3)
82```
83
84**Don't use:** Emoji fonts (unreliable across platforms) or assume pre-packaged graphics exist in this skill.
85
86### Making Graphics Look Good
87
88Graphics should look polished and creative, not basic. Here's how:
89
90**Use thicker lines** - Always set `width=2` or higher for outlines and lines. Thin lines (width=1) look choppy and amateurish.
91
92**Add visual depth**:
93- Use gradients for backgrounds (`create_gradient_background`)
94- Layer multiple shapes for complexity (e.g., a star with a smaller star inside)
95
96**Make shapes more interesting**:
97- Don't just draw a plain circle - add highlights, rings, or patterns
98- Stars can have glows (draw larger, semi-transparent versions behind)
99- Combine multiple shapes (stars + sparkles, circles + rings)
100
101**Pay attention to colors**:
102- Use vibrant, complementary colors
103- Add contrast (dark outlines on light shapes, light outlines on dark shapes)
104- Consider the overall composition
105
106**For complex shapes** (hearts, snowflakes, etc.):
107- Use combinations of polygons and ellipses
108- Calculate points carefully for symmetry
109- Add details (a heart can have a highlight curve, snowflakes have intricate branches)
110
111Be creative and detailed! A good Slack GIF should look polished, not like placeholder graphics.
112
113## Available Utilities
114
115### GIFBuilder (`core.gif_builder`)
116Assembles frames and optimizes for Slack:
117```python
118builder = GIFBuilder(width=128, height=128, fps=10)
119builder.add_frame(frame) # Add PIL Image
120builder.add_frames(frames) # Add list of frames
121builder.save('out.gif', num_colors=48, optimize_for_emoji=True, remove_duplicates=True)
122```
123
124### Validators (`core.validators`)
125Check if GIF meets Slack requirements:
126```python
127from core.validators import validate_gif, is_slack_ready
128
129# Detailed validation
130passes, info = validate_gif('my.gif', is_emoji=True, verbose=True)
131
132# Quick check
133if is_slack_ready('my.gif'):
134 print("Ready!")
135```
136
137### Easing Functions (`core.easing`)
138Smooth motion instead of linear:
139```python
140from core.easing import interpolate
141
142# Progress from 0.0 to 1.0
143t = i / (num_frames - 1)
144
145# Apply easing
146y = interpolate(start=0, end=400, t=t, easing='ease_out')
147
148# Available: linear, ease_in, ease_out, ease_in_out,
149# bounce_out, elastic_out, back_out
150```
151
152### Frame Helpers (`core.frame_composer`)
153Convenience functions for common needs:
154```python
155from core.frame_composer import (
156 create_blank_frame, # Solid color background
157 create_gradient_background, # Vertical gradient
158 draw_circle, # Helper for circles
159 draw_text, # Simple text rendering
160 draw_star # 5-pointed star
161)
162```
163
164## Animation Concepts
165
166### Shake/Vibrate
167Offset object position with oscillation:
168- Use `math.sin()` or `math.cos()` with frame index
169- Add small random variations for natural feel
170- Apply to x and/or y position
171
172### Pulse/Heartbeat
173Scale object size rhythmically:
174- Use `math.sin(t * frequency * 2 * math.pi)` for smooth pulse
175- For heartbeat: two quick pulses then pause (adjust sine wave)
176- Scale between 0.8 and 1.2 of base size
177
178### Bounce
179Object falls and bounces:
180- Use `interpolate()` with `easing='bounce_out'` for landing
181- Use `easing='ease_in'` for falling (accelerating)
182- Apply gravity by increasing y velocity each frame
183
184### Spin/Rotate
185Rotate object around center:
186- PIL: `image.rotate(angle, resample=Image.BICUBIC)`
187- For wobble: use sine wave for angle instead of linear
188
189### Fade In/Out
190Gradually appear or disappear:
191- Create RGBA image, adjust alpha channel
192- Or use `Image.blend(image1, image2, alpha)`
193- Fade in: alpha from 0 to 1
194- Fade out: alpha from 1 to 0
195
196### Slide
197Move object from off-screen to position:
198- Start position: outside frame bounds
199- End position: target location
200- Use `interpolate()` with `easing='ease_out'` for smooth stop
201- For overshoot: use `easing='back_out'`
202
203### Zoom
204Scale and position for zoom effect:
205- Zoom in: scale from 0.1 to 2.0, crop center
206- Zoom out: scale from 2.0 to 1.0
207- Can add motion blur for drama (PIL filter)
208
209### Explode/Particle Burst
210Create particles radiating outward:
211- Generate particles with random angles and velocities
212- Update each particle: `x += vx`, `y += vy`
213- Add gravity: `vy += gravity_constant`
214- Fade out particles over time (reduce alpha)
215
216## Optimization Strategies
217
218Only when asked to make the file size smaller, implement a few of the following methods:
219
2201. **Fewer frames** - Lower FPS (10 instead of 20) or shorter duration
2212. **Fewer colors** - `num_colors=48` instead of 128
2223. **Smaller dimensions** - 128x128 instead of 480x480
2234. **Remove duplicates** - `remove_duplicates=True` in save()
2245. **Emoji mode** - `optimize_for_emoji=True` auto-optimizes
225
226```python
227# Maximum optimization for emoji
228builder.save(
229 'emoji.gif',
230 num_colors=48,
231 optimize_for_emoji=True,
232 remove_duplicates=True
233)
234```
235
236## Philosophy
237
238This skill provides:
239- **Knowledge**: Slack's requirements and animation concepts
240- **Utilities**: GIFBuilder, validators, easing functions
241- **Flexibility**: Create the animation logic using PIL primitives
242
243It does NOT provide:
244- Rigid animation templates or pre-made functions
245- Emoji font rendering (unreliable across platforms)
246- A library of pre-packaged graphics built into the skill
247
248**Note on user uploads**: This skill doesn't include pre-built graphics, but if a user uploads an image, use PIL to load and work with it - interpret based on their request whether they want it used directly or just as inspiration.
249
250Be creative! Combine concepts (bouncing + rotating, pulsing + sliding, etc.) and use PIL's full capabilities.
251
252## Dependencies
253
254```bash
255pip install pillow imageio numpy
256```
257
258
259
260# easing.py
261
262```python
263#!/usr/bin/env python3
264"""
265Easing Functions - Timing functions for smooth animations.
266
267Provides various easing functions for natural motion and timing.
268All functions take a value t (0.0 to 1.0) and return eased value (0.0 to 1.0).
269"""
270
271import math
272
273
274def linear(t: float) -> float:
275 """Linear interpolation (no easing)."""
276 return t
277
278
279def ease_in_quad(t: float) -> float:
280 """Quadratic ease-in (slow start, accelerating)."""
281 return t * t
282
283
284def ease_out_quad(t: float) -> float:
285 """Quadratic ease-out (fast start, decelerating)."""
286 return t * (2 - t)
287
288
289def ease_in_out_quad(t: float) -> float:
290 """Quadratic ease-in-out (slow start and end)."""
291 if t < 0.5:
292 return 2 * t * t
293 return -1 + (4 - 2 * t) * t
294
295
296def ease_in_cubic(t: float) -> float:
297 """Cubic ease-in (slow start)."""
298 return t * t * t
299
300
301def ease_out_cubic(t: float) -> float:
302 """Cubic ease-out (fast start)."""
303 return (t - 1) * (t - 1) * (t - 1) + 1
304
305
306def ease_in_out_cubic(t: float) -> float:
307 """Cubic ease-in-out."""
308 if t < 0.5:
309 return 4 * t * t * t
310 return (t - 1) * (2 * t - 2) * (2 * t - 2) + 1
311
312
313def ease_in_bounce(t: float) -> float:
314 """Bounce ease-in (bouncy start)."""
315 return 1 - ease_out_bounce(1 - t)
316
317
318def ease_out_bounce(t: float) -> float:
319 """Bounce ease-out (bouncy end)."""
320 if t < 1 / 2.75:
321 return 7.5625 * t * t
322 elif t < 2 / 2.75:
323 t -= 1.5 / 2.75
324 return 7.5625 * t * t + 0.75
325 elif t < 2.5 / 2.75:
326 t -= 2.25 / 2.75
327 return 7.5625 * t * t + 0.9375
328 else:
329 t -= 2.625 / 2.75
330 return 7.5625 * t * t + 0.984375
331
332
333def ease_in_out_bounce(t: float) -> float:
334 """Bounce ease-in-out."""
335 if t < 0.5:
336 return ease_in_bounce(t * 2) * 0.5
337 return ease_out_bounce(t * 2 - 1) * 0.5 + 0.5
338
339
340def ease_in_elastic(t: float) -> float:
341 """Elastic ease-in (spring effect)."""
342 if t == 0 or t == 1:
343 return t
344 return -math.pow(2, 10 * (t - 1)) * math.sin((t - 1.1) * 5 * math.pi)
345
346
347def ease_out_elastic(t: float) -> float:
348 """Elastic ease-out (spring effect)."""
349 if t == 0 or t == 1:
350 return t
351 return math.pow(2, -10 * t) * math.sin((t - 0.1) * 5 * math.pi) + 1
352
353
354def ease_in_out_elastic(t: float) -> float:
355 """Elastic ease-in-out."""
356 if t == 0 or t == 1:
357 return t
358 t = t * 2 - 1
359 if t < 0:
360 return -0.5 * math.pow(2, 10 * t) * math.sin((t - 0.1) * 5 * math.pi)
361 return math.pow(2, -10 * t) * math.sin((t - 0.1) * 5 * math.pi) * 0.5 + 1
362
363
364# Convenience mapping
365EASING_FUNCTIONS = {
366 "linear": linear,
367 "ease_in": ease_in_quad,
368 "ease_out": ease_out_quad,
369 "ease_in_out": ease_in_out_quad,
370 "bounce_in": ease_in_bounce,
371 "bounce_out": ease_out_bounce,
372 "bounce": ease_in_out_bounce,
373 "elastic_in": ease_in_elastic,
374 "elastic_out": ease_out_elastic,
375 "elastic": ease_in_out_elastic,
376}
377
378
379def get_easing(name: str = "linear"):
380 """Get easing function by name."""
381 return EASING_FUNCTIONS.get(name, linear)
382
383
384def interpolate(start: float, end: float, t: float, easing: str = "linear") -> float:
385 """
386 Interpolate between two values with easing.
387
388 Args:
389 start: Start value
390 end: End value
391 t: Progress from 0.0 to 1.0
392 easing: Name of easing function
393
394 Returns:
395 Interpolated value
396 """
397 ease_func = get_easing(easing)
398 eased_t = ease_func(t)
399 return start + (end - start) * eased_t
400
401
402def ease_back_in(t: float) -> float:
403 """Back ease-in (slight overshoot backward before forward motion)."""
404 c1 = 1.70158
405 c3 = c1 + 1
406 return c3 * t * t * t - c1 * t * t
407
408
409def ease_back_out(t: float) -> float:
410 """Back ease-out (overshoot forward then settle back)."""
411 c1 = 1.70158
412 c3 = c1 + 1
413 return 1 + c3 * pow(t - 1, 3) + c1 * pow(t - 1, 2)
414
415
416def ease_back_in_out(t: float) -> float:
417 """Back ease-in-out (overshoot at both ends)."""
418 c1 = 1.70158
419 c2 = c1 * 1.525
420 if t < 0.5:
421 return (pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
422 return (pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2
423
424
425def apply_squash_stretch(
426 base_scale: tuple[float, float], intensity: float, direction: str = "vertical"
427) -> tuple[float, float]:
428 """
429 Calculate squash and stretch scales for more dynamic animation.
430
431 Args:
432 base_scale: (width_scale, height_scale) base scales
433 intensity: Squash/stretch intensity (0.0-1.0)
434 direction: 'vertical', 'horizontal', or 'both'
435
436 Returns:
437 (width_scale, height_scale) with squash/stretch applied
438 """
439 width_scale, height_scale = base_scale
440
441 if direction == "vertical":
442 # Compress vertically, expand horizontally (preserve volume)
443 height_scale *= 1 - intensity * 0.5
444 width_scale *= 1 + intensity * 0.5
445 elif direction == "horizontal":
446 # Compress horizontally, expand vertically
447 width_scale *= 1 - intensity * 0.5
448 height_scale *= 1 + intensity * 0.5
449 elif direction == "both":
450 # General squash (both dimensions)
451 width_scale *= 1 - intensity * 0.3
452 height_scale *= 1 - intensity * 0.3
453
454 return (width_scale, height_scale)
455
456
457def calculate_arc_motion(
458 start: tuple[float, float], end: tuple[float, float], height: float, t: float
459) -> tuple[float, float]:
460 """
461 Calculate position along a parabolic arc (natural motion path).
462
463 Args:
464 start: (x, y) starting position
465 end: (x, y) ending position
466 height: Arc height at midpoint (positive = upward)
467 t: Progress (0.0-1.0)
468
469 Returns:
470 (x, y) position along arc
471 """
472 x1, y1 = start
473 x2, y2 = end
474
475 # Linear interpolation for x
476 x = x1 + (x2 - x1) * t
477
478 # Parabolic interpolation for y
479 # y = start + progress * (end - start) + arc_offset
480 # Arc offset peaks at t=0.5
481 arc_offset = 4 * height * t * (1 - t)
482 y = y1 + (y2 - y1) * t - arc_offset
483
484 return (x, y)
485
486
487# Add new easing functions to the convenience mapping
488EASING_FUNCTIONS.update(
489 {
490 "back_in": ease_back_in,
491 "back_out": ease_back_out,
492 "back_in_out": ease_back_in_out,
493 "anticipate": ease_back_in, # Alias
494 "overshoot": ease_back_out, # Alias
495 }
496)
497
498```
499
500
501# frame_composer.py
502
503```python
504#!/usr/bin/env python3
505"""
506Frame Composer - Utilities for composing visual elements into frames.
507
508Provides functions for drawing shapes, text, emojis, and compositing elements
509together to create animation frames.
510"""
511
512from typing import Optional
513
514import numpy as np
515from PIL import Image, ImageDraw, ImageFont
516
517
518def create_blank_frame(
519 width: int, height: int, color: tuple[int, int, int] = (255, 255, 255)
520) -> Image.Image:
521 """
522 Create a blank frame with solid color background.
523
524 Args:
525 width: Frame width
526 height: Frame height
527 color: RGB color tuple (default: white)
528
529 Returns:
530 PIL Image
531 """
532 return Image.new("RGB", (width, height), color)
533
534
535def draw_circle(
536 frame: Image.Image,
537 center: tuple[int, int],
538 radius: int,
539 fill_color: Optional[tuple[int, int, int]] = None,
540 outline_color: Optional[tuple[int, int, int]] = None,
541 outline_width: int = 1,
542) -> Image.Image:
543 """
544 Draw a circle on a frame.
545
546 Args:
547 frame: PIL Image to draw on
548 center: (x, y) center position
549 radius: Circle radius
550 fill_color: RGB fill color (None for no fill)
551 outline_color: RGB outline color (None for no outline)
552 outline_width: Outline width in pixels
553
554 Returns:
555 Modified frame
556 """
557 draw = ImageDraw.Draw(frame)
558 x, y = center
559 bbox = [x - radius, y - radius, x + radius, y + radius]
560 draw.ellipse(bbox, fill=fill_color, outline=outline_color, width=outline_width)
561 return frame
562
563
564def draw_text(
565 frame: Image.Image,
566 text: str,
567 position: tuple[int, int],
568 color: tuple[int, int, int] = (0, 0, 0),
569 centered: bool = False,
570) -> Image.Image:
571 """
572 Draw text on a frame.
573
574 Args:
575 frame: PIL Image to draw on
576 text: Text to draw
577 position: (x, y) position (top-left unless centered=True)
578 color: RGB text color
579 centered: If True, center text at position
580
581 Returns:
582 Modified frame
583 """
584 draw = ImageDraw.Draw(frame)
585
586 # Uses Pillow's default font.
587 # If the font should be changed for the emoji, add additional logic here.
588 font = ImageFont.load_default()
589
590 if centered:
591 bbox = draw.textbbox((0, 0), text, font=font)
592 text_width = bbox[2] - bbox[0]
593 text_height = bbox[3] - bbox[1]
594 x = position[0] - text_width // 2
595 y = position[1] - text_height // 2
596 position = (x, y)
597
598 draw.text(position, text, fill=color, font=font)
599 return frame
600
601
602def create_gradient_background(
603 width: int,
604 height: int,
605 top_color: tuple[int, int, int],
606 bottom_color: tuple[int, int, int],
607) -> Image.Image:
608 """
609 Create a vertical gradient background.
610
611 Args:
612 width: Frame width
613 height: Frame height
614 top_color: RGB color at top
615 bottom_color: RGB color at bottom
616
617 Returns:
618 PIL Image with gradient
619 """
620 frame = Image.new("RGB", (width, height))
621 draw = ImageDraw.Draw(frame)
622
623 # Calculate color step for each row
624 r1, g1, b1 = top_color
625 r2, g2, b2 = bottom_color
626
627 for y in range(height):
628 # Interpolate color
629 ratio = y / height
630 r = int(r1 * (1 - ratio) + r2 * ratio)
631 g = int(g1 * (1 - ratio) + g2 * ratio)
632 b = int(b1 * (1 - ratio) + b2 * ratio)
633
634 # Draw horizontal line
635 draw.line([(0, y), (width, y)], fill=(r, g, b))
636
637 return frame
638
639
640def draw_star(
641 frame: Image.Image,
642 center: tuple[int, int],
643 size: int,
644 fill_color: tuple[int, int, int],
645 outline_color: Optional[tuple[int, int, int]] = None,
646 outline_width: int = 1,
647) -> Image.Image:
648 """
649 Draw a 5-pointed star.
650
651 Args:
652 frame: PIL Image to draw on
653 center: (x, y) center position
654 size: Star size (outer radius)
655 fill_color: RGB fill color
656 outline_color: RGB outline color (None for no outline)
657 outline_width: Outline width
658
659 Returns:
660 Modified frame
661 """
662 import math
663
664 draw = ImageDraw.Draw(frame)
665 x, y = center
666
667 # Calculate star points
668 points = []
669 for i in range(10):
670 angle = (i * 36 - 90) * math.pi / 180 # 36 degrees per point, start at top
671 radius = size if i % 2 == 0 else size * 0.4 # Alternate between outer and inner
672 px = x + radius * math.cos(angle)
673 py = y + radius * math.sin(angle)
674 points.append((px, py))
675
676 # Draw star
677 draw.polygon(points, fill=fill_color, outline=outline_color, width=outline_width)
678
679 return frame
680
681```
682
683
684# gif_builder.py
685
686```python
687#!/usr/bin/env python3
688"""
689GIF Builder - Core module for assembling frames into GIFs optimized for Slack.
690
691This module provides the main interface for creating GIFs from programmatically
692generated frames, with automatic optimization for Slack's requirements.
693"""
694
695from pathlib import Path
696from typing import Optional
697
698import imageio.v3 as imageio
699import numpy as np
700from PIL import Image
701
702
703class GIFBuilder:
704 """Builder for creating optimized GIFs from frames."""
705
706 def __init__(self, width: int = 480, height: int = 480, fps: int = 15):
707 """
708 Initialize GIF builder.
709
710 Args:
711 width: Frame width in pixels
712 height: Frame height in pixels
713 fps: Frames per second
714 """
715 self.width = width
716 self.height = height
717 self.fps = fps
718 self.frames: list[np.ndarray] = []
719
720 def add_frame(self, frame: np.ndarray | Image.Image):
721 """
722 Add a frame to the GIF.
723
724 Args:
725 frame: Frame as numpy array or PIL Image (will be converted to RGB)
726 """
727 if isinstance(frame, Image.Image):
728 frame = np.array(frame.convert("RGB"))
729
730 # Ensure frame is correct size
731 if frame.shape[:2] != (self.height, self.width):
732 pil_frame = Image.fromarray(frame)
733 pil_frame = pil_frame.resize(
734 (self.width, self.height), Image.Resampling.LANCZOS
735 )
736 frame = np.array(pil_frame)
737
738 self.frames.append(frame)
739
740 def add_frames(self, frames: list[np.ndarray | Image.Image]):
741 """Add multiple frames at once."""
742 for frame in frames:
743 self.add_frame(frame)
744
745 def optimize_colors(
746 self, num_colors: int = 128, use_global_palette: bool = True
747 ) -> list[np.ndarray]:
748 """
749 Reduce colors in all frames using quantization.
750
751 Args:
752 num_colors: Target number of colors (8-256)
753 use_global_palette: Use a single palette for all frames (better compression)
754
755 Returns:
756 List of color-optimized frames
757 """
758 optimized = []
759
760 if use_global_palette and len(self.frames) > 1:
761 # Create a global palette from all frames
762 # Sample frames to build palette
763 sample_size = min(5, len(self.frames))
764 sample_indices = [
765 int(i * len(self.frames) / sample_size) for i in range(sample_size)
766 ]
767 sample_frames = [self.frames[i] for i in sample_indices]
768
769 # Combine sample frames into a single image for palette generation
770 # Flatten each frame to get all pixels, then stack them
771 all_pixels = np.vstack(
772 [f.reshape(-1, 3) for f in sample_frames]
773 ) # (total_pixels, 3)
774
775 # Create a properly-shaped RGB image from the pixel data
776 # We'll make a roughly square image from all the pixels
777 total_pixels = len(all_pixels)
778 width = min(512, int(np.sqrt(total_pixels))) # Reasonable width, max 512
779 height = (total_pixels + width - 1) // width # Ceiling division
780
781 # Pad if necessary to fill the rectangle
782 pixels_needed = width * height
783 if pixels_needed > total_pixels:
784 padding = np.zeros((pixels_needed - total_pixels, 3), dtype=np.uint8)
785 all_pixels = np.vstack([all_pixels, padding])
786
787 # Reshape to proper RGB image format (H, W, 3)
788 img_array = (
789 all_pixels[:pixels_needed].reshape(height, width, 3).astype(np.uint8)
790 )
791 combined_img = Image.fromarray(img_array, mode="RGB")
792
793 # Generate global palette
794 global_palette = combined_img.quantize(colors=num_colors, method=2)
795
796 # Apply global palette to all frames
797 for frame in self.frames:
798 pil_frame = Image.fromarray(frame)
799 quantized = pil_frame.quantize(palette=global_palette, dither=1)
800 optimized.append(np.array(quantized.convert("RGB")))
801 else:
802 # Use per-frame quantization
803 for frame in self.frames:
804 pil_frame = Image.fromarray(frame)
805 quantized = pil_frame.quantize(colors=num_colors, method=2, dither=1)
806 optimized.append(np.array(quantized.convert("RGB")))
807
808 return optimized
809
810 def deduplicate_frames(self, threshold: float = 0.9995) -> int:
811 """
812 Remove duplicate or near-duplicate consecutive frames.
813
814 Args:
815 threshold: Similarity threshold (0.0-1.0). Higher = more strict (0.9995 = nearly identical).
816 Use 0.9995+ to preserve subtle animations, 0.98 for aggressive removal.
817
818 Returns:
819 Number of frames removed
820 """
821 if len(self.frames) < 2:
822 return 0
823
824 deduplicated = [self.frames[0]]
825 removed_count = 0
826
827 for i in range(1, len(self.frames)):
828 # Compare with previous frame
829 prev_frame = np.array(deduplicated[-1], dtype=np.float32)
830 curr_frame = np.array(self.frames[i], dtype=np.float32)
831
832 # Calculate similarity (normalized)
833 diff = np.abs(prev_frame - curr_frame)
834 similarity = 1.0 - (np.mean(diff) / 255.0)
835
836 # Keep frame if sufficiently different
837 # High threshold (0.9995+) means only remove nearly identical frames
838 if similarity < threshold:
839 deduplicated.append(self.frames[i])
840 else:
841 removed_count += 1
842
843 self.frames = deduplicated
844 return removed_count
845
846 def save(
847 self,
848 output_path: str | Path,
849 num_colors: int = 128,
850 optimize_for_emoji: bool = False,
851 remove_duplicates: bool = False,
852 ) -> dict:
853 """
854 Save frames as optimized GIF for Slack.
855
856 Args:
857 output_path: Where to save the GIF
858 num_colors: Number of colors to use (fewer = smaller file)
859 optimize_for_emoji: If True, optimize for emoji size (128x128, fewer colors)
860 remove_duplicates: If True, remove duplicate consecutive frames (opt-in)
861
862 Returns:
863 Dictionary with file info (path, size, dimensions, frame_count)
864 """
865 if not self.frames:
866 raise ValueError("No frames to save. Add frames with add_frame() first.")
867
868 output_path = Path(output_path)
869
870 # Remove duplicate frames to reduce file size
871 if remove_duplicates:
872 removed = self.deduplicate_frames(threshold=0.9995)
873 if removed > 0:
874 print(
875 f" Removed {removed} nearly identical frames (preserved subtle animations)"
876 )
877
878 # Optimize for emoji if requested
879 if optimize_for_emoji:
880 if self.width > 128 or self.height > 128:
881 print(
882 f" Resizing from {self.width}x{self.height} to 128x128 for emoji"
883 )
884 self.width = 128
885 self.height = 128
886 # Resize all frames
887 resized_frames = []
888 for frame in self.frames:
889 pil_frame = Image.fromarray(frame)
890 pil_frame = pil_frame.resize((128, 128), Image.Resampling.LANCZOS)
891 resized_frames.append(np.array(pil_frame))
892 self.frames = resized_frames
893 num_colors = min(num_colors, 48) # More aggressive color limit for emoji
894
895 # More aggressive FPS reduction for emoji
896 if len(self.frames) > 12:
897 print(
898 f" Reducing frames from {len(self.frames)} to ~12 for emoji size"
899 )
900 # Keep every nth frame to get close to 12 frames
901 keep_every = max(1, len(self.frames) // 12)
902 self.frames = [
903 self.frames[i] for i in range(0, len(self.frames), keep_every)
904 ]
905
906 # Optimize colors with global palette
907 optimized_frames = self.optimize_colors(num_colors, use_global_palette=True)
908
909 # Calculate frame duration in milliseconds
910 frame_duration = 1000 / self.fps
911
912 # Save GIF
913 imageio.imwrite(
914 output_path,
915 optimized_frames,
916 duration=frame_duration,
917 loop=0, # Infinite loop
918 )
919
920 # Get file info
921 file_size_kb = output_path.stat().st_size / 1024
922 file_size_mb = file_size_kb / 1024
923
924 info = {
925 "path": str(output_path),
926 "size_kb": file_size_kb,
927 "size_mb": file_size_mb,
928 "dimensions": f"{self.width}x{self.height}",
929 "frame_count": len(optimized_frames),
930 "fps": self.fps,
931 "duration_seconds": len(optimized_frames) / self.fps,
932 "colors": num_colors,
933 }
934
935 # Print info
936 print(f"\n✓ GIF created successfully!")
937 print(f" Path: {output_path}")
938 print(f" Size: {file_size_kb:.1f} KB ({file_size_mb:.2f} MB)")
939 print(f" Dimensions: {self.width}x{self.height}")
940 print(f" Frames: {len(optimized_frames)} @ {self.fps} fps")
941 print(f" Duration: {info['duration_seconds']:.1f}s")
942 print(f" Colors: {num_colors}")
943
944 # Size info
945 if optimize_for_emoji:
946 print(f" Optimized for emoji (128x128, reduced colors)")
947 if file_size_mb > 1.0:
948 print(f"\n Note: Large file size ({file_size_kb:.1f} KB)")
949 print(" Consider: fewer frames, smaller dimensions, or fewer colors")
950
951 return info
952
953 def clear(self):
954 """Clear all frames (useful for creating multiple GIFs)."""
955 self.frames = []
956
957```
958
959
960# validators.py
961
962```python
963#!/usr/bin/env python3
964"""
965Validators - Check if GIFs meet Slack's requirements.
966
967These validators help ensure your GIFs meet Slack's size and dimension constraints.
968"""
969
970from pathlib import Path
971
972
973def validate_gif(
974 gif_path: str | Path, is_emoji: bool = True, verbose: bool = True
975) -> tuple[bool, dict]:
976 """
977 Validate GIF for Slack (dimensions, size, frame count).
978
979 Args:
980 gif_path: Path to GIF file
981 is_emoji: True for emoji (128x128 recommended), False for message GIF
982 verbose: Print validation details
983
984 Returns:
985 Tuple of (passes: bool, results: dict with all details)
986 """
987 from PIL import Image
988
989 gif_path = Path(gif_path)
990
991 if not gif_path.exists():
992 return False, {"error": f"File not found: {gif_path}"}
993
994 # Get file size
995 size_bytes = gif_path.stat().st_size
996 size_kb = size_bytes / 1024
997 size_mb = size_kb / 1024
998
999 # Get dimensions and frame info
1000 try:
1001 with Image.open(gif_path) as img:
1002 width, height = img.size
1003
1004 # Count frames
1005 frame_count = 0
1006 try:
1007 while True:
1008 img.seek(frame_count)
1009 frame_count += 1
1010 except EOFError:
1011 pass
1012
1013 # Get duration
1014 try:
1015 duration_ms = img.info.get("duration", 100)
1016 total_duration = (duration_ms * frame_count) / 1000
1017 fps = frame_count / total_duration if total_duration > 0 else 0
1018 except:
1019 total_duration = None
1020 fps = None
1021
1022 except Exception as e:
1023 return False, {"error": f"Failed to read GIF: {e}"}
1024
1025 # Validate dimensions
1026 if is_emoji:
1027 optimal = width == height == 128
1028 acceptable = width == height and 64 <= width <= 128
1029 dim_pass = acceptable
1030 else:
1031 aspect_ratio = (
1032 max(width, height) / min(width, height)
1033 if min(width, height) > 0
1034 else float("inf")
1035 )
1036 dim_pass = aspect_ratio <= 2.0 and 320 <= min(width, height) <= 640
1037
1038 results = {
1039 "file": str(gif_path),
1040 "passes": dim_pass,
1041 "width": width,
1042 "height": height,
1043 "size_kb": size_kb,
1044 "size_mb": size_mb,
1045 "frame_count": frame_count,
1046 "duration_seconds": total_duration,
1047 "fps": fps,
1048 "is_emoji": is_emoji,
1049 "optimal": optimal if is_emoji else None,
1050 }
1051
1052 # Print if verbose
1053 if verbose:
1054 print(f"\nValidating {gif_path.name}:")
1055 print(
1056 f" Dimensions: {width}x{height}"
1057 + (
1058 f" ({'optimal' if optimal else 'acceptable'})"
1059 if is_emoji and acceptable
1060 else ""
1061 )
1062 )
1063 print(
1064 f" Size: {size_kb:.1f} KB"
1065 + (f" ({size_mb:.2f} MB)" if size_mb >= 1.0 else "")
1066 )
1067 print(
1068 f" Frames: {frame_count}"
1069 + (f" @ {fps:.1f} fps ({total_duration:.1f}s)" if fps else "")
1070 )
1071
1072 if not dim_pass:
1073 print(
1074 f" Note: {'Emoji should be 128x128' if is_emoji else 'Unusual dimensions for Slack'}"
1075 )
1076
1077 if size_mb > 5.0:
1078 print(f" Note: Large file size - consider fewer frames/colors")
1079
1080 return dim_pass, results
1081
1082
1083def is_slack_ready(
1084 gif_path: str | Path, is_emoji: bool = True, verbose: bool = True
1085) -> bool:
1086 """
1087 Quick check if GIF is ready for Slack.
1088
1089 Args:
1090 gif_path: Path to GIF file
1091 is_emoji: True for emoji GIF, False for message GIF
1092 verbose: Print feedback
1093
1094 Returns:
1095 True if dimensions are acceptable
1096 """
1097 passes, _ = validate_gif(gif_path, is_emoji, verbose)
1098 return passes
1099
1100```