Back to snippets

slack_gif_creator_toolkit_with_pil_easing_and_validation.py

python

A 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```