Update example skills and rename 'artifacts-builder' (#112)

* Export updated examples

* Rename 'artifacts-builder' to 'web-artifacts-builder'
This commit is contained in:
Keith Lazuka
2025-11-17 16:34:29 -05:00
committed by GitHub
parent e5c60158df
commit 0f77e501e6
20 changed files with 1051 additions and 2287 deletions

View File

@@ -1,646 +1,254 @@
---
name: slack-gif-creator
description: Toolkit for creating animated GIFs optimized for Slack, with validators for size constraints and composable animation primitives. This skill applies when users request animated GIFs or emoji animations for Slack from descriptions like "make me a GIF for Slack of X doing Y".
description: 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."
license: Complete terms in LICENSE.txt
---
# Slack GIF Creator - Flexible Toolkit
# Slack GIF Creator
A toolkit for creating animated GIFs optimized for Slack. Provides validators for Slack's constraints, composable animation primitives, and optional helper utilities. **Apply these tools however needed to achieve the creative vision.**
A toolkit providing utilities and knowledge for creating animated GIFs optimized for Slack.
## Slack's Requirements
## Slack Requirements
Slack has specific requirements for GIFs based on their use:
**Dimensions:**
- Emoji GIFs: 128x128 (recommended)
- Message GIFs: 480x480
**Message GIFs:**
- Max size: ~2MB
- Optimal dimensions: 480x480
- Typical FPS: 15-20
- Color limit: 128-256
- Duration: 2-5s
**Parameters:**
- FPS: 10-30 (lower is smaller file size)
- Colors: 48-128 (fewer = smaller file size)
- Duration: Keep under 3 seconds for emoji GIFs
**Emoji GIFs:**
- Max size: 64KB (strict limit)
- Optimal dimensions: 128x128
- Typical FPS: 10-12
- Color limit: 32-48
- Duration: 1-2s
**Emoji GIFs are challenging** - the 64KB limit is strict. Strategies that help:
- Limit to 10-15 frames total
- Use 32-48 colors maximum
- Keep designs simple
- Avoid gradients
- Validate file size frequently
## Toolkit Structure
This skill provides three types of tools:
1. **Validators** - Check if a GIF meets Slack's requirements
2. **Animation Primitives** - Composable building blocks for motion (shake, bounce, move, kaleidoscope)
3. **Helper Utilities** - Optional functions for common needs (text, colors, effects)
**Complete creative freedom is available in how these tools are applied.**
## Core Validators
To ensure a GIF meets Slack's constraints, use these validators:
## Core Workflow
```python
from core.gif_builder import GIFBuilder
from PIL import Image, ImageDraw
# After creating your GIF, check if it meets requirements
# 1. Create builder
builder = GIFBuilder(width=128, height=128, fps=10)
# ... add your frames however you want ...
# Save and check size
info = builder.save('emoji.gif', num_colors=48, optimize_for_emoji=True)
# 2. Generate frames
for i in range(12):
frame = Image.new('RGB', (128, 128), (240, 248, 255))
draw = ImageDraw.Draw(frame)
# The save method automatically warns if file exceeds limits
# info dict contains: size_kb, size_mb, frame_count, duration_seconds
# Draw your animation using PIL primitives
# (circles, polygons, lines, etc.)
builder.add_frame(frame)
# 3. Save with optimization
builder.save('output.gif', num_colors=48, optimize_for_emoji=True)
```
**File size validator**:
## Drawing Graphics
### Working with User-Uploaded Images
If a user uploads an image, consider whether they want to:
- **Use it directly** (e.g., "animate this", "split this into frames")
- **Use it as inspiration** (e.g., "make something like this")
Load and work with images using PIL:
```python
from core.validators import check_slack_size
from PIL import Image
# Check if GIF meets size limits
passes, info = check_slack_size('emoji.gif', is_emoji=True)
# Returns: (True/False, dict with size details)
uploaded = Image.open('file.png')
# Use directly, or just as reference for colors/style
```
**Dimension validator**:
### Drawing from Scratch
When drawing graphics from scratch, use PIL ImageDraw primitives:
```python
from core.validators import validate_dimensions
from PIL import ImageDraw
# Check dimensions
passes, info = validate_dimensions(128, 128, is_emoji=True)
# Returns: (True/False, dict with dimension details)
draw = ImageDraw.Draw(frame)
# Circles/ovals
draw.ellipse([x1, y1, x2, y2], fill=(r, g, b), outline=(r, g, b), width=3)
# Stars, triangles, any polygon
points = [(x1, y1), (x2, y2), (x3, y3), ...]
draw.polygon(points, fill=(r, g, b), outline=(r, g, b), width=3)
# Lines
draw.line([(x1, y1), (x2, y2)], fill=(r, g, b), width=5)
# Rectangles
draw.rectangle([x1, y1, x2, y2], fill=(r, g, b), outline=(r, g, b), width=3)
```
**Complete validation**:
**Don't use:** Emoji fonts (unreliable across platforms) or assume pre-packaged graphics exist in this skill.
### Making Graphics Look Good
Graphics should look polished and creative, not basic. Here's how:
**Use thicker lines** - Always set `width=2` or higher for outlines and lines. Thin lines (width=1) look choppy and amateurish.
**Add visual depth**:
- Use gradients for backgrounds (`create_gradient_background`)
- Layer multiple shapes for complexity (e.g., a star with a smaller star inside)
**Make shapes more interesting**:
- Don't just draw a plain circle - add highlights, rings, or patterns
- Stars can have glows (draw larger, semi-transparent versions behind)
- Combine multiple shapes (stars + sparkles, circles + rings)
**Pay attention to colors**:
- Use vibrant, complementary colors
- Add contrast (dark outlines on light shapes, light outlines on dark shapes)
- Consider the overall composition
**For complex shapes** (hearts, snowflakes, etc.):
- Use combinations of polygons and ellipses
- Calculate points carefully for symmetry
- Add details (a heart can have a highlight curve, snowflakes have intricate branches)
Be creative and detailed! A good Slack GIF should look polished, not like placeholder graphics.
## Available Utilities
### GIFBuilder (`core.gif_builder`)
Assembles frames and optimizes for Slack:
```python
builder = GIFBuilder(width=128, height=128, fps=10)
builder.add_frame(frame) # Add PIL Image
builder.add_frames(frames) # Add list of frames
builder.save('out.gif', num_colors=48, optimize_for_emoji=True, remove_duplicates=True)
```
### Validators (`core.validators`)
Check if GIF meets Slack requirements:
```python
from core.validators import validate_gif, is_slack_ready
# Run all validations
all_pass, results = validate_gif('emoji.gif', is_emoji=True)
# Detailed validation
passes, info = validate_gif('my.gif', is_emoji=True, verbose=True)
# Or quick check
if is_slack_ready('emoji.gif', is_emoji=True):
print("Ready to upload!")
# Quick check
if is_slack_ready('my.gif'):
print("Ready!")
```
## Animation Primitives
These are composable building blocks for motion. Apply these to any object in any combination:
### Shake
```python
from templates.shake import create_shake_animation
# Shake an emoji
frames = create_shake_animation(
object_type='emoji',
object_data={'emoji': '😱', 'size': 80},
num_frames=20,
shake_intensity=15,
direction='both' # or 'horizontal', 'vertical'
)
```
### Bounce
```python
from templates.bounce import create_bounce_animation
# Bounce a circle
frames = create_bounce_animation(
object_type='circle',
object_data={'radius': 40, 'color': (255, 100, 100)},
num_frames=30,
bounce_height=150
)
```
### Spin / Rotate
```python
from templates.spin import create_spin_animation, create_loading_spinner
# Clockwise spin
frames = create_spin_animation(
object_type='emoji',
object_data={'emoji': '🔄', 'size': 100},
rotation_type='clockwise',
full_rotations=2
)
# Wobble rotation
frames = create_spin_animation(rotation_type='wobble', full_rotations=3)
# Loading spinner
frames = create_loading_spinner(spinner_type='dots')
```
### Pulse / Heartbeat
```python
from templates.pulse import create_pulse_animation, create_attention_pulse
# Smooth pulse
frames = create_pulse_animation(
object_data={'emoji': '❤️', 'size': 100},
pulse_type='smooth',
scale_range=(0.8, 1.2)
)
# Heartbeat (double-pump)
frames = create_pulse_animation(pulse_type='heartbeat')
# Attention pulse for emoji GIFs
frames = create_attention_pulse(emoji='⚠️', num_frames=20)
```
### Fade
```python
from templates.fade import create_fade_animation, create_crossfade
# Fade in
frames = create_fade_animation(fade_type='in')
# Fade out
frames = create_fade_animation(fade_type='out')
# Crossfade between two emojis
frames = create_crossfade(
object1_data={'emoji': '😊', 'size': 100},
object2_data={'emoji': '😂', 'size': 100}
)
```
### Zoom
```python
from templates.zoom import create_zoom_animation, create_explosion_zoom
# Zoom in dramatically
frames = create_zoom_animation(
zoom_type='in',
scale_range=(0.1, 2.0),
add_motion_blur=True
)
# Zoom out
frames = create_zoom_animation(zoom_type='out')
# Explosion zoom
frames = create_explosion_zoom(emoji='💥')
```
### Explode / Shatter
```python
from templates.explode import create_explode_animation, create_particle_burst
# Burst explosion
frames = create_explode_animation(
explode_type='burst',
num_pieces=25
)
# Shatter effect
frames = create_explode_animation(explode_type='shatter')
# Dissolve into particles
frames = create_explode_animation(explode_type='dissolve')
# Particle burst
frames = create_particle_burst(particle_count=30)
```
### Wiggle / Jiggle
```python
from templates.wiggle import create_wiggle_animation, create_excited_wiggle
# Jello wobble
frames = create_wiggle_animation(
wiggle_type='jello',
intensity=1.0,
cycles=2
)
# Wave motion
frames = create_wiggle_animation(wiggle_type='wave')
# Excited wiggle for emoji GIFs
frames = create_excited_wiggle(emoji='🎉')
```
### Slide
```python
from templates.slide import create_slide_animation, create_multi_slide
# Slide in from left with overshoot
frames = create_slide_animation(
direction='left',
slide_type='in',
overshoot=True
)
# Slide across
frames = create_slide_animation(direction='left', slide_type='across')
# Multiple objects sliding in sequence
objects = [
{'data': {'emoji': '🎯', 'size': 60}, 'direction': 'left', 'final_pos': (120, 240)},
{'data': {'emoji': '🎪', 'size': 60}, 'direction': 'right', 'final_pos': (240, 240)}
]
frames = create_multi_slide(objects, stagger_delay=5)
```
### Flip
```python
from templates.flip import create_flip_animation, create_quick_flip
# Horizontal flip between two emojis
frames = create_flip_animation(
object1_data={'emoji': '😊', 'size': 120},
object2_data={'emoji': '😂', 'size': 120},
flip_axis='horizontal'
)
# Vertical flip
frames = create_flip_animation(flip_axis='vertical')
# Quick flip for emoji GIFs
frames = create_quick_flip('👍', '👎')
```
### Morph / Transform
```python
from templates.morph import create_morph_animation, create_reaction_morph
# Crossfade morph
frames = create_morph_animation(
object1_data={'emoji': '😊', 'size': 100},
object2_data={'emoji': '😂', 'size': 100},
morph_type='crossfade'
)
# Scale morph (shrink while other grows)
frames = create_morph_animation(morph_type='scale')
# Spin morph (3D flip-like)
frames = create_morph_animation(morph_type='spin_morph')
```
### Move Effect
```python
from templates.move import create_move_animation
# Linear movement
frames = create_move_animation(
object_type='emoji',
object_data={'emoji': '🚀', 'size': 60},
start_pos=(50, 240),
end_pos=(430, 240),
motion_type='linear',
easing='ease_out'
)
# Arc movement (parabolic trajectory)
frames = create_move_animation(
object_type='emoji',
object_data={'emoji': '', 'size': 60},
start_pos=(50, 350),
end_pos=(430, 350),
motion_type='arc',
motion_params={'arc_height': 150}
)
# Circular movement
frames = create_move_animation(
object_type='emoji',
object_data={'emoji': '🌍', 'size': 50},
motion_type='circle',
motion_params={
'center': (240, 240),
'radius': 120,
'angle_range': 360 # full circle
}
)
# Wave movement
frames = create_move_animation(
motion_type='wave',
motion_params={
'wave_amplitude': 50,
'wave_frequency': 2
}
)
# Or use low-level easing functions
from core.easing import interpolate, calculate_arc_motion
for i in range(num_frames):
t = i / (num_frames - 1)
x = interpolate(start_x, end_x, t, easing='ease_out')
# Or: x, y = calculate_arc_motion(start, end, height, t)
```
### Kaleidoscope Effect
```python
from templates.kaleidoscope import apply_kaleidoscope, create_kaleidoscope_animation
# Apply to a single frame
kaleido_frame = apply_kaleidoscope(frame, segments=8)
# Or create animated kaleidoscope
frames = create_kaleidoscope_animation(
base_frame=my_frame, # or None for demo pattern
num_frames=30,
segments=8,
rotation_speed=1.0
)
# Simple mirror effects (faster)
from templates.kaleidoscope import apply_simple_mirror
mirrored = apply_simple_mirror(frame, mode='quad') # 4-way mirror
# modes: 'horizontal', 'vertical', 'quad', 'radial'
```
**To compose primitives freely, follow these patterns:**
```python
# Example: Bounce + shake for impact
for i in range(num_frames):
frame = create_blank_frame(480, 480, bg_color)
# Bounce motion
t_bounce = i / (num_frames - 1)
y = interpolate(start_y, ground_y, t_bounce, 'bounce_out')
# Add shake on impact (when y reaches ground)
if y >= ground_y - 5:
shake_x = math.sin(i * 2) * 10
x = center_x + shake_x
else:
x = center_x
draw_emoji(frame, '', (x, y), size=60)
builder.add_frame(frame)
```
## Helper Utilities
These are optional helpers for common needs. **Use, modify, or replace these with custom implementations as needed.**
### GIF Builder (Assembly & Optimization)
```python
from core.gif_builder import GIFBuilder
# Create builder with your chosen settings
builder = GIFBuilder(width=480, height=480, fps=20)
# Add frames (however you created them)
for frame in my_frames:
builder.add_frame(frame)
# Save with optimization
builder.save('output.gif',
num_colors=128,
optimize_for_emoji=False)
```
Key features:
- Automatic color quantization
- Duplicate frame removal
- Size warnings for Slack limits
- Emoji mode (aggressive optimization)
### Text Rendering
For small GIFs like emojis, text readability is challenging. A common solution involves adding outlines:
```python
from core.typography import draw_text_with_outline, TYPOGRAPHY_SCALE
# Text with outline (helps readability)
draw_text_with_outline(
frame, "BONK!",
position=(240, 100),
font_size=TYPOGRAPHY_SCALE['h1'], # 60px
text_color=(255, 68, 68),
outline_color=(0, 0, 0),
outline_width=4,
centered=True
)
```
To implement custom text rendering, use PIL's `ImageDraw.text()` which works fine for larger GIFs.
### Color Management
Professional-looking GIFs often use cohesive color palettes:
```python
from core.color_palettes import get_palette
# Get a pre-made palette
palette = get_palette('vibrant') # or 'pastel', 'dark', 'neon', 'professional'
bg_color = palette['background']
text_color = palette['primary']
accent_color = palette['accent']
```
To work with colors directly, use RGB tuples - whatever works for the use case.
### Visual Effects
Optional effects for impact moments:
```python
from core.visual_effects import ParticleSystem, create_impact_flash, create_shockwave_rings
# Particle system
particles = ParticleSystem()
particles.emit_sparkles(x=240, y=200, count=15)
particles.emit_confetti(x=240, y=200, count=20)
# Update and render each frame
particles.update()
particles.render(frame)
# Flash effect
frame = create_impact_flash(frame, position=(240, 200), radius=100)
# Shockwave rings
frame = create_shockwave_rings(frame, position=(240, 200), radii=[30, 60, 90])
```
### Easing Functions
Smooth motion uses easing instead of linear interpolation:
### Easing Functions (`core.easing`)
Smooth motion instead of linear:
```python
from core.easing import interpolate
# Object falling (accelerates)
y = interpolate(start=0, end=400, t=progress, easing='ease_in')
# Progress from 0.0 to 1.0
t = i / (num_frames - 1)
# Object landing (decelerates)
y = interpolate(start=0, end=400, t=progress, easing='ease_out')
# Apply easing
y = interpolate(start=0, end=400, t=t, easing='ease_out')
# Bouncing
y = interpolate(start=0, end=400, t=progress, easing='bounce_out')
# Overshoot (elastic)
scale = interpolate(start=0.5, end=1.0, t=progress, easing='elastic_out')
# Available: linear, ease_in, ease_out, ease_in_out,
# bounce_out, elastic_out, back_out
```
Available easings: `linear`, `ease_in`, `ease_out`, `ease_in_out`, `bounce_out`, `elastic_out`, `back_out` (overshoot), and more in `core/easing.py`.
### Frame Composition
Basic drawing utilities if you need them:
### Frame Helpers (`core.frame_composer`)
Convenience functions for common needs:
```python
from core.frame_composer import (
create_gradient_background, # Gradient backgrounds
draw_emoji_enhanced, # Emoji with optional shadow
draw_circle_with_shadow, # Shapes with depth
draw_star # 5-pointed stars
create_blank_frame, # Solid color background
create_gradient_background, # Vertical gradient
draw_circle, # Helper for circles
draw_text, # Simple text rendering
draw_star # 5-pointed star
)
# Gradient background
frame = create_gradient_background(480, 480, top_color, bottom_color)
# Emoji with shadow
draw_emoji_enhanced(frame, '🎉', position=(200, 200), size=80, shadow=True)
```
## Animation Concepts
### Shake/Vibrate
Offset object position with oscillation:
- Use `math.sin()` or `math.cos()` with frame index
- Add small random variations for natural feel
- Apply to x and/or y position
### Pulse/Heartbeat
Scale object size rhythmically:
- Use `math.sin(t * frequency * 2 * math.pi)` for smooth pulse
- For heartbeat: two quick pulses then pause (adjust sine wave)
- Scale between 0.8 and 1.2 of base size
### Bounce
Object falls and bounces:
- Use `interpolate()` with `easing='bounce_out'` for landing
- Use `easing='ease_in'` for falling (accelerating)
- Apply gravity by increasing y velocity each frame
### Spin/Rotate
Rotate object around center:
- PIL: `image.rotate(angle, resample=Image.BICUBIC)`
- For wobble: use sine wave for angle instead of linear
### Fade In/Out
Gradually appear or disappear:
- Create RGBA image, adjust alpha channel
- Or use `Image.blend(image1, image2, alpha)`
- Fade in: alpha from 0 to 1
- Fade out: alpha from 1 to 0
### Slide
Move object from off-screen to position:
- Start position: outside frame bounds
- End position: target location
- Use `interpolate()` with `easing='ease_out'` for smooth stop
- For overshoot: use `easing='back_out'`
### Zoom
Scale and position for zoom effect:
- Zoom in: scale from 0.1 to 2.0, crop center
- Zoom out: scale from 2.0 to 1.0
- Can add motion blur for drama (PIL filter)
### Explode/Particle Burst
Create particles radiating outward:
- Generate particles with random angles and velocities
- Update each particle: `x += vx`, `y += vy`
- Add gravity: `vy += gravity_constant`
- Fade out particles over time (reduce alpha)
## Optimization Strategies
When your GIF is too large:
Only when asked to make the file size smaller, implement a few of the following methods:
**For Message GIFs (>2MB):**
1. Reduce frames (lower FPS or shorter duration)
2. Reduce colors (128 → 64 colors)
3. Reduce dimensions (480x480 → 320x320)
4. Enable duplicate frame removal
1. **Fewer frames** - Lower FPS (10 instead of 20) or shorter duration
2. **Fewer colors** - `num_colors=48` instead of 128
3. **Smaller dimensions** - 128x128 instead of 480x480
4. **Remove duplicates** - `remove_duplicates=True` in save()
5. **Emoji mode** - `optimize_for_emoji=True` auto-optimizes
**For Emoji GIFs (>64KB) - be aggressive:**
1. Limit to 10-12 frames total
2. Use 32-40 colors maximum
3. Avoid gradients (solid colors compress better)
4. Simplify design (fewer elements)
5. Use `optimize_for_emoji=True` in save method
## Example Composition Patterns
### Simple Reaction (Pulsing)
```python
builder = GIFBuilder(128, 128, 10)
for i in range(12):
frame = Image.new('RGB', (128, 128), (240, 248, 255))
# Pulsing scale
scale = 1.0 + math.sin(i * 0.5) * 0.15
size = int(60 * scale)
draw_emoji_enhanced(frame, '😱', position=(64-size//2, 64-size//2),
size=size, shadow=False)
builder.add_frame(frame)
builder.save('reaction.gif', num_colors=40, optimize_for_emoji=True)
# Validate
from core.validators import check_slack_size
check_slack_size('reaction.gif', is_emoji=True)
```
### Action with Impact (Bounce + Flash)
```python
builder = GIFBuilder(480, 480, 20)
# Phase 1: Object falls
for i in range(15):
frame = create_gradient_background(480, 480, (240, 248, 255), (200, 230, 255))
t = i / 14
y = interpolate(0, 350, t, 'ease_in')
draw_emoji_enhanced(frame, '', position=(220, int(y)), size=80)
builder.add_frame(frame)
# Phase 2: Impact + flash
for i in range(8):
frame = create_gradient_background(480, 480, (240, 248, 255), (200, 230, 255))
# Flash on first frames
if i < 3:
frame = create_impact_flash(frame, (240, 350), radius=120, intensity=0.6)
draw_emoji_enhanced(frame, '', position=(220, 350), size=80)
# Text appears
if i > 2:
draw_text_with_outline(frame, "GOAL!", position=(240, 150),
font_size=60, text_color=(255, 68, 68),
outline_color=(0, 0, 0), outline_width=4, centered=True)
builder.add_frame(frame)
builder.save('goal.gif', num_colors=128)
```
### Combining Primitives (Move + Shake)
```python
from templates.shake import create_shake_animation
# Create shake animation
shake_frames = create_shake_animation(
object_type='emoji',
object_data={'emoji': '😰', 'size': 70},
num_frames=20,
shake_intensity=12
# Maximum optimization for emoji
builder.save(
'emoji.gif',
num_colors=48,
optimize_for_emoji=True,
remove_duplicates=True
)
# Create moving element that triggers the shake
builder = GIFBuilder(480, 480, 20)
for i in range(40):
t = i / 39
if i < 20:
# Before trigger - use blank frame with moving object
frame = create_blank_frame(480, 480, (255, 255, 255))
x = interpolate(50, 300, t * 2, 'linear')
draw_emoji_enhanced(frame, '🚗', position=(int(x), 300), size=60)
draw_emoji_enhanced(frame, '😰', position=(350, 200), size=70)
else:
# After trigger - use shake frame
frame = shake_frames[i - 20]
# Add the car in final position
draw_emoji_enhanced(frame, '🚗', position=(300, 300), size=60)
builder.add_frame(frame)
builder.save('scare.gif')
```
## Philosophy
This toolkit provides building blocks, not rigid recipes. To work with a GIF request:
This skill provides:
- **Knowledge**: Slack's requirements and animation concepts
- **Utilities**: GIFBuilder, validators, easing functions
- **Flexibility**: Create the animation logic using PIL primitives
1. **Understand the creative vision** - What should happen? What's the mood?
2. **Design the animation** - Break it into phases (anticipation, action, reaction)
3. **Apply primitives as needed** - Shake, bounce, move, effects - mix freely
4. **Validate constraints** - Check file size, especially for emoji GIFs
5. **Iterate if needed** - Reduce frames/colors if over size limits
It does NOT provide:
- Rigid animation templates or pre-made functions
- Emoji font rendering (unreliable across platforms)
- A library of pre-packaged graphics built into the skill
**The goal is creative freedom within Slack's technical constraints.**
**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.
Be creative! Combine concepts (bouncing + rotating, pulsing + sliding, etc.) and use PIL's full capabilities.
## Dependencies
To use this toolkit, install these dependencies only if they aren't already present:
```bash
pip install pillow imageio numpy
```

View File

@@ -101,25 +101,25 @@ def ease_in_out_elastic(t: float) -> float:
# Convenience mapping
EASING_FUNCTIONS = {
'linear': linear,
'ease_in': ease_in_quad,
'ease_out': ease_out_quad,
'ease_in_out': ease_in_out_quad,
'bounce_in': ease_in_bounce,
'bounce_out': ease_out_bounce,
'bounce': ease_in_out_bounce,
'elastic_in': ease_in_elastic,
'elastic_out': ease_out_elastic,
'elastic': ease_in_out_elastic,
"linear": linear,
"ease_in": ease_in_quad,
"ease_out": ease_out_quad,
"ease_in_out": ease_in_out_quad,
"bounce_in": ease_in_bounce,
"bounce_out": ease_out_bounce,
"bounce": ease_in_out_bounce,
"elastic_in": ease_in_elastic,
"elastic_out": ease_out_elastic,
"elastic": ease_in_out_elastic,
}
def get_easing(name: str = 'linear'):
def get_easing(name: str = "linear"):
"""Get easing function by name."""
return EASING_FUNCTIONS.get(name, linear)
def interpolate(start: float, end: float, t: float, easing: str = 'linear') -> float:
def interpolate(start: float, end: float, t: float, easing: str = "linear") -> float:
"""
Interpolate between two values with easing.
@@ -160,8 +160,9 @@ def ease_back_in_out(t: float) -> float:
return (pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2
def apply_squash_stretch(base_scale: tuple[float, float], intensity: float,
direction: str = 'vertical') -> tuple[float, float]:
def apply_squash_stretch(
base_scale: tuple[float, float], intensity: float, direction: str = "vertical"
) -> tuple[float, float]:
"""
Calculate squash and stretch scales for more dynamic animation.
@@ -175,24 +176,25 @@ def apply_squash_stretch(base_scale: tuple[float, float], intensity: float,
"""
width_scale, height_scale = base_scale
if direction == 'vertical':
if direction == "vertical":
# Compress vertically, expand horizontally (preserve volume)
height_scale *= (1 - intensity * 0.5)
width_scale *= (1 + intensity * 0.5)
elif direction == 'horizontal':
height_scale *= 1 - intensity * 0.5
width_scale *= 1 + intensity * 0.5
elif direction == "horizontal":
# Compress horizontally, expand vertically
width_scale *= (1 - intensity * 0.5)
height_scale *= (1 + intensity * 0.5)
elif direction == 'both':
width_scale *= 1 - intensity * 0.5
height_scale *= 1 + intensity * 0.5
elif direction == "both":
# General squash (both dimensions)
width_scale *= (1 - intensity * 0.3)
height_scale *= (1 - intensity * 0.3)
width_scale *= 1 - intensity * 0.3
height_scale *= 1 - intensity * 0.3
return (width_scale, height_scale)
def calculate_arc_motion(start: tuple[float, float], end: tuple[float, float],
height: float, t: float) -> tuple[float, float]:
def calculate_arc_motion(
start: tuple[float, float], end: tuple[float, float], height: float, t: float
) -> tuple[float, float]:
"""
Calculate position along a parabolic arc (natural motion path).
@@ -221,10 +223,12 @@ def calculate_arc_motion(start: tuple[float, float], end: tuple[float, float],
# Add new easing functions to the convenience mapping
EASING_FUNCTIONS.update({
'back_in': ease_back_in,
'back_out': ease_back_out,
'back_in_out': ease_back_in_out,
'anticipate': ease_back_in, # Alias
'overshoot': ease_back_out, # Alias
})
EASING_FUNCTIONS.update(
{
"back_in": ease_back_in,
"back_out": ease_back_out,
"back_in_out": ease_back_in_out,
"anticipate": ease_back_in, # Alias
"overshoot": ease_back_out, # Alias
}
)

View File

@@ -6,12 +6,15 @@ Provides functions for drawing shapes, text, emojis, and compositing elements
together to create animation frames.
"""
from PIL import Image, ImageDraw, ImageFont
import numpy as np
from typing import Optional
import numpy as np
from PIL import Image, ImageDraw, ImageFont
def create_blank_frame(width: int, height: int, color: tuple[int, int, int] = (255, 255, 255)) -> Image.Image:
def create_blank_frame(
width: int, height: int, color: tuple[int, int, int] = (255, 255, 255)
) -> Image.Image:
"""
Create a blank frame with solid color background.
@@ -23,13 +26,17 @@ def create_blank_frame(width: int, height: int, color: tuple[int, int, int] = (2
Returns:
PIL Image
"""
return Image.new('RGB', (width, height), color)
return Image.new("RGB", (width, height), color)
def draw_circle(frame: Image.Image, center: tuple[int, int], radius: int,
fill_color: Optional[tuple[int, int, int]] = None,
outline_color: Optional[tuple[int, int, int]] = None,
outline_width: int = 1) -> Image.Image:
def draw_circle(
frame: Image.Image,
center: tuple[int, int],
radius: int,
fill_color: Optional[tuple[int, int, int]] = None,
outline_color: Optional[tuple[int, int, int]] = None,
outline_width: int = 1,
) -> Image.Image:
"""
Draw a circle on a frame.
@@ -51,52 +58,13 @@ def draw_circle(frame: Image.Image, center: tuple[int, int], radius: int,
return frame
def draw_rectangle(frame: Image.Image, top_left: tuple[int, int], bottom_right: tuple[int, int],
fill_color: Optional[tuple[int, int, int]] = None,
outline_color: Optional[tuple[int, int, int]] = None,
outline_width: int = 1) -> Image.Image:
"""
Draw a rectangle on a frame.
Args:
frame: PIL Image to draw on
top_left: (x, y) top-left corner
bottom_right: (x, y) bottom-right corner
fill_color: RGB fill color (None for no fill)
outline_color: RGB outline color (None for no outline)
outline_width: Outline width in pixels
Returns:
Modified frame
"""
draw = ImageDraw.Draw(frame)
draw.rectangle([top_left, bottom_right], fill=fill_color, outline=outline_color, width=outline_width)
return frame
def draw_line(frame: Image.Image, start: tuple[int, int], end: tuple[int, int],
color: tuple[int, int, int] = (0, 0, 0), width: int = 2) -> Image.Image:
"""
Draw a line on a frame.
Args:
frame: PIL Image to draw on
start: (x, y) start position
end: (x, y) end position
color: RGB line color
width: Line width in pixels
Returns:
Modified frame
"""
draw = ImageDraw.Draw(frame)
draw.line([start, end], fill=color, width=width)
return frame
def draw_text(frame: Image.Image, text: str, position: tuple[int, int],
font_size: int = 40, color: tuple[int, int, int] = (0, 0, 0),
centered: bool = False) -> Image.Image:
def draw_text(
frame: Image.Image,
text: str,
position: tuple[int, int],
color: tuple[int, int, int] = (0, 0, 0),
centered: bool = False,
) -> Image.Image:
"""
Draw text on a frame.
@@ -104,7 +72,6 @@ def draw_text(frame: Image.Image, text: str, position: tuple[int, int],
frame: PIL Image to draw on
text: Text to draw
position: (x, y) position (top-left unless centered=True)
font_size: Font size in pixels
color: RGB text color
centered: If True, center text at position
@@ -113,11 +80,9 @@ def draw_text(frame: Image.Image, text: str, position: tuple[int, int],
"""
draw = ImageDraw.Draw(frame)
# Try to use default font, fall back to basic if not available
try:
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size)
except:
font = ImageFont.load_default()
# Uses Pillow's default font.
# If the font should be changed for the emoji, add additional logic here.
font = ImageFont.load_default()
if centered:
bbox = draw.textbbox((0, 0), text, font=font)
@@ -131,110 +96,12 @@ def draw_text(frame: Image.Image, text: str, position: tuple[int, int],
return frame
def draw_emoji(frame: Image.Image, emoji: str, position: tuple[int, int], size: int = 60) -> Image.Image:
"""
Draw emoji text on a frame (requires system emoji support).
Args:
frame: PIL Image to draw on
emoji: Emoji character(s)
position: (x, y) position
size: Emoji size in pixels
Returns:
Modified frame
"""
draw = ImageDraw.Draw(frame)
# Use Apple Color Emoji font on macOS
try:
font = ImageFont.truetype("/System/Library/Fonts/Apple Color Emoji.ttc", size)
except:
# Fallback to text-based emoji
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size)
draw.text(position, emoji, font=font, embedded_color=True)
return frame
def composite_layers(base: Image.Image, overlay: Image.Image,
position: tuple[int, int] = (0, 0), alpha: float = 1.0) -> Image.Image:
"""
Composite one image on top of another.
Args:
base: Base image
overlay: Image to overlay on top
position: (x, y) position to place overlay
alpha: Opacity of overlay (0.0 = transparent, 1.0 = opaque)
Returns:
Composite image
"""
# Convert to RGBA for transparency support
base_rgba = base.convert('RGBA')
overlay_rgba = overlay.convert('RGBA')
# Apply alpha
if alpha < 1.0:
overlay_rgba = overlay_rgba.copy()
overlay_rgba.putalpha(int(255 * alpha))
# Paste overlay onto base
base_rgba.paste(overlay_rgba, position, overlay_rgba)
# Convert back to RGB
return base_rgba.convert('RGB')
def draw_stick_figure(frame: Image.Image, position: tuple[int, int], scale: float = 1.0,
color: tuple[int, int, int] = (0, 0, 0), line_width: int = 3) -> Image.Image:
"""
Draw a simple stick figure.
Args:
frame: PIL Image to draw on
position: (x, y) center position of head
scale: Size multiplier
color: RGB line color
line_width: Line width in pixels
Returns:
Modified frame
"""
draw = ImageDraw.Draw(frame)
x, y = position
# Scale dimensions
head_radius = int(15 * scale)
body_length = int(40 * scale)
arm_length = int(25 * scale)
leg_length = int(35 * scale)
leg_spread = int(15 * scale)
# Head
draw.ellipse([x - head_radius, y - head_radius, x + head_radius, y + head_radius],
outline=color, width=line_width)
# Body
body_start = y + head_radius
body_end = body_start + body_length
draw.line([(x, body_start), (x, body_end)], fill=color, width=line_width)
# Arms
arm_y = body_start + int(body_length * 0.3)
draw.line([(x - arm_length, arm_y), (x + arm_length, arm_y)], fill=color, width=line_width)
# Legs
draw.line([(x, body_end), (x - leg_spread, body_end + leg_length)], fill=color, width=line_width)
draw.line([(x, body_end), (x + leg_spread, body_end + leg_length)], fill=color, width=line_width)
return frame
def create_gradient_background(width: int, height: int,
top_color: tuple[int, int, int],
bottom_color: tuple[int, int, int]) -> Image.Image:
def create_gradient_background(
width: int,
height: int,
top_color: tuple[int, int, int],
bottom_color: tuple[int, int, int],
) -> Image.Image:
"""
Create a vertical gradient background.
@@ -247,7 +114,7 @@ def create_gradient_background(width: int, height: int,
Returns:
PIL Image with gradient
"""
frame = Image.new('RGB', (width, height))
frame = Image.new("RGB", (width, height))
draw = ImageDraw.Draw(frame)
# Calculate color step for each row
@@ -267,175 +134,14 @@ def create_gradient_background(width: int, height: int,
return frame
def draw_emoji_enhanced(frame: Image.Image, emoji: str, position: tuple[int, int],
size: int = 60, shadow: bool = True,
shadow_offset: tuple[int, int] = (2, 2)) -> Image.Image:
"""
Draw emoji with optional shadow for better visual quality.
Args:
frame: PIL Image to draw on
emoji: Emoji character(s)
position: (x, y) position
size: Emoji size in pixels (minimum 12)
shadow: Whether to add drop shadow
shadow_offset: Shadow offset
Returns:
Modified frame
"""
draw = ImageDraw.Draw(frame)
# Ensure minimum size to avoid font rendering errors
size = max(12, size)
# Use Apple Color Emoji font on macOS
try:
font = ImageFont.truetype("/System/Library/Fonts/Apple Color Emoji.ttc", size)
except:
# Fallback to text-based emoji
try:
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size)
except:
font = ImageFont.load_default()
# Draw shadow first if enabled
if shadow and size >= 20: # Only draw shadow for larger emojis
shadow_pos = (position[0] + shadow_offset[0], position[1] + shadow_offset[1])
# Draw semi-transparent shadow (simulated by drawing multiple times)
for offset in range(1, 3):
try:
draw.text((shadow_pos[0] + offset, shadow_pos[1] + offset),
emoji, font=font, embedded_color=True, fill=(0, 0, 0, 100))
except:
pass # Skip shadow if it fails
# Draw main emoji
try:
draw.text(position, emoji, font=font, embedded_color=True)
except:
# Fallback to basic drawing if embedded color fails
draw.text(position, emoji, font=font, fill=(0, 0, 0))
return frame
def draw_circle_with_shadow(frame: Image.Image, center: tuple[int, int], radius: int,
fill_color: tuple[int, int, int],
shadow_offset: tuple[int, int] = (3, 3),
shadow_color: tuple[int, int, int] = (0, 0, 0)) -> Image.Image:
"""
Draw a circle with drop shadow.
Args:
frame: PIL Image to draw on
center: (x, y) center position
radius: Circle radius
fill_color: RGB fill color
shadow_offset: (x, y) shadow offset
shadow_color: RGB shadow color
Returns:
Modified frame
"""
draw = ImageDraw.Draw(frame)
x, y = center
# Draw shadow
shadow_center = (x + shadow_offset[0], y + shadow_offset[1])
shadow_bbox = [
shadow_center[0] - radius,
shadow_center[1] - radius,
shadow_center[0] + radius,
shadow_center[1] + radius
]
draw.ellipse(shadow_bbox, fill=shadow_color)
# Draw main circle
bbox = [x - radius, y - radius, x + radius, y + radius]
draw.ellipse(bbox, fill=fill_color)
return frame
def draw_rounded_rectangle(frame: Image.Image, top_left: tuple[int, int],
bottom_right: tuple[int, int], radius: int,
fill_color: Optional[tuple[int, int, int]] = None,
outline_color: Optional[tuple[int, int, int]] = None,
outline_width: int = 1) -> Image.Image:
"""
Draw a rectangle with rounded corners.
Args:
frame: PIL Image to draw on
top_left: (x, y) top-left corner
bottom_right: (x, y) bottom-right corner
radius: Corner radius
fill_color: RGB fill color (None for no fill)
outline_color: RGB outline color (None for no outline)
outline_width: Outline width
Returns:
Modified frame
"""
draw = ImageDraw.Draw(frame)
x1, y1 = top_left
x2, y2 = bottom_right
# Draw rounded rectangle using PIL's built-in method
draw.rounded_rectangle([x1, y1, x2, y2], radius=radius,
fill=fill_color, outline=outline_color, width=outline_width)
return frame
def add_vignette(frame: Image.Image, strength: float = 0.5) -> Image.Image:
"""
Add a vignette effect (darkened edges) to frame.
Args:
frame: PIL Image
strength: Vignette strength (0.0-1.0)
Returns:
Frame with vignette
"""
width, height = frame.size
# Create radial gradient mask
center_x, center_y = width // 2, height // 2
max_dist = ((width / 2) ** 2 + (height / 2) ** 2) ** 0.5
# Create overlay
overlay = Image.new('RGB', (width, height), (0, 0, 0))
pixels = overlay.load()
for y in range(height):
for x in range(width):
# Calculate distance from center
dx = x - center_x
dy = y - center_y
dist = (dx ** 2 + dy ** 2) ** 0.5
# Calculate vignette value
vignette = min(1, (dist / max_dist) * strength)
value = int(255 * (1 - vignette))
pixels[x, y] = (value, value, value)
# Blend with original using multiply
frame_array = np.array(frame, dtype=np.float32) / 255
overlay_array = np.array(overlay, dtype=np.float32) / 255
result = frame_array * overlay_array
result = (result * 255).astype(np.uint8)
return Image.fromarray(result)
def draw_star(frame: Image.Image, center: tuple[int, int], size: int,
fill_color: tuple[int, int, int],
outline_color: Optional[tuple[int, int, int]] = None,
outline_width: int = 1) -> Image.Image:
def draw_star(
frame: Image.Image,
center: tuple[int, int],
size: int,
fill_color: tuple[int, int, int],
outline_color: Optional[tuple[int, int, int]] = None,
outline_width: int = 1,
) -> Image.Image:
"""
Draw a 5-pointed star.
@@ -451,6 +157,7 @@ def draw_star(frame: Image.Image, center: tuple[int, int], size: int,
Modified frame
"""
import math
draw = ImageDraw.Draw(frame)
x, y = center
@@ -466,4 +173,4 @@ def draw_star(frame: Image.Image, center: tuple[int, int], size: int,
# Draw star
draw.polygon(points, fill=fill_color, outline=outline_color, width=outline_width)
return frame
return frame

View File

@@ -8,9 +8,10 @@ generated frames, with automatic optimization for Slack's requirements.
from pathlib import Path
from typing import Optional
import imageio.v3 as imageio
from PIL import Image
import numpy as np
from PIL import Image
class GIFBuilder:
@@ -38,12 +39,14 @@ class GIFBuilder:
frame: Frame as numpy array or PIL Image (will be converted to RGB)
"""
if isinstance(frame, Image.Image):
frame = np.array(frame.convert('RGB'))
frame = np.array(frame.convert("RGB"))
# Ensure frame is correct size
if frame.shape[:2] != (self.height, self.width):
pil_frame = Image.fromarray(frame)
pil_frame = pil_frame.resize((self.width, self.height), Image.Resampling.LANCZOS)
pil_frame = pil_frame.resize(
(self.width, self.height), Image.Resampling.LANCZOS
)
frame = np.array(pil_frame)
self.frames.append(frame)
@@ -53,7 +56,9 @@ class GIFBuilder:
for frame in frames:
self.add_frame(frame)
def optimize_colors(self, num_colors: int = 128, use_global_palette: bool = True) -> list[np.ndarray]:
def optimize_colors(
self, num_colors: int = 128, use_global_palette: bool = True
) -> list[np.ndarray]:
"""
Reduce colors in all frames using quantization.
@@ -70,12 +75,16 @@ class GIFBuilder:
# Create a global palette from all frames
# Sample frames to build palette
sample_size = min(5, len(self.frames))
sample_indices = [int(i * len(self.frames) / sample_size) for i in range(sample_size)]
sample_indices = [
int(i * len(self.frames) / sample_size) for i in range(sample_size)
]
sample_frames = [self.frames[i] for i in sample_indices]
# Combine sample frames into a single image for palette generation
# Flatten each frame to get all pixels, then stack them
all_pixels = np.vstack([f.reshape(-1, 3) for f in sample_frames]) # (total_pixels, 3)
all_pixels = np.vstack(
[f.reshape(-1, 3) for f in sample_frames]
) # (total_pixels, 3)
# Create a properly-shaped RGB image from the pixel data
# We'll make a roughly square image from all the pixels
@@ -90,8 +99,10 @@ class GIFBuilder:
all_pixels = np.vstack([all_pixels, padding])
# Reshape to proper RGB image format (H, W, 3)
img_array = all_pixels[:pixels_needed].reshape(height, width, 3).astype(np.uint8)
combined_img = Image.fromarray(img_array, mode='RGB')
img_array = (
all_pixels[:pixels_needed].reshape(height, width, 3).astype(np.uint8)
)
combined_img = Image.fromarray(img_array, mode="RGB")
# Generate global palette
global_palette = combined_img.quantize(colors=num_colors, method=2)
@@ -100,22 +111,23 @@ class GIFBuilder:
for frame in self.frames:
pil_frame = Image.fromarray(frame)
quantized = pil_frame.quantize(palette=global_palette, dither=1)
optimized.append(np.array(quantized.convert('RGB')))
optimized.append(np.array(quantized.convert("RGB")))
else:
# Use per-frame quantization
for frame in self.frames:
pil_frame = Image.fromarray(frame)
quantized = pil_frame.quantize(colors=num_colors, method=2, dither=1)
optimized.append(np.array(quantized.convert('RGB')))
optimized.append(np.array(quantized.convert("RGB")))
return optimized
def deduplicate_frames(self, threshold: float = 0.995) -> int:
def deduplicate_frames(self, threshold: float = 0.9995) -> int:
"""
Remove duplicate or near-duplicate consecutive frames.
Args:
threshold: Similarity threshold (0.0-1.0). Higher = more strict (0.995 = very similar).
threshold: Similarity threshold (0.0-1.0). Higher = more strict (0.9995 = nearly identical).
Use 0.9995+ to preserve subtle animations, 0.98 for aggressive removal.
Returns:
Number of frames removed
@@ -136,7 +148,7 @@ class GIFBuilder:
similarity = 1.0 - (np.mean(diff) / 255.0)
# Keep frame if sufficiently different
# High threshold (0.995) means only remove truly identical frames
# High threshold (0.9995+) means only remove nearly identical frames
if similarity < threshold:
deduplicated.append(self.frames[i])
else:
@@ -145,16 +157,21 @@ class GIFBuilder:
self.frames = deduplicated
return removed_count
def save(self, output_path: str | Path, num_colors: int = 128,
optimize_for_emoji: bool = False, remove_duplicates: bool = True) -> dict:
def save(
self,
output_path: str | Path,
num_colors: int = 128,
optimize_for_emoji: bool = False,
remove_duplicates: bool = False,
) -> dict:
"""
Save frames as optimized GIF for Slack.
Args:
output_path: Where to save the GIF
num_colors: Number of colors to use (fewer = smaller file)
optimize_for_emoji: If True, optimize for <64KB emoji size
remove_duplicates: Remove duplicate consecutive frames
optimize_for_emoji: If True, optimize for emoji size (128x128, fewer colors)
remove_duplicates: If True, remove duplicate consecutive frames (opt-in)
Returns:
Dictionary with file info (path, size, dimensions, frame_count)
@@ -163,18 +180,21 @@ class GIFBuilder:
raise ValueError("No frames to save. Add frames with add_frame() first.")
output_path = Path(output_path)
original_frame_count = len(self.frames)
# Remove duplicate frames to reduce file size
if remove_duplicates:
removed = self.deduplicate_frames(threshold=0.98)
removed = self.deduplicate_frames(threshold=0.9995)
if removed > 0:
print(f" Removed {removed} duplicate frames")
print(
f" Removed {removed} nearly identical frames (preserved subtle animations)"
)
# Optimize for emoji if requested
if optimize_for_emoji:
if self.width > 128 or self.height > 128:
print(f" Resizing from {self.width}x{self.height} to 128x128 for emoji")
print(
f" Resizing from {self.width}x{self.height} to 128x128 for emoji"
)
self.width = 128
self.height = 128
# Resize all frames
@@ -188,10 +208,14 @@ class GIFBuilder:
# More aggressive FPS reduction for emoji
if len(self.frames) > 12:
print(f" Reducing frames from {len(self.frames)} to ~12 for emoji size")
print(
f" Reducing frames from {len(self.frames)} to ~12 for emoji size"
)
# Keep every nth frame to get close to 12 frames
keep_every = max(1, len(self.frames) // 12)
self.frames = [self.frames[i] for i in range(0, len(self.frames), keep_every)]
self.frames = [
self.frames[i] for i in range(0, len(self.frames), keep_every)
]
# Optimize colors with global palette
optimized_frames = self.optimize_colors(num_colors, use_global_palette=True)
@@ -204,7 +228,7 @@ class GIFBuilder:
output_path,
optimized_frames,
duration=frame_duration,
loop=0 # Infinite loop
loop=0, # Infinite loop
)
# Get file info
@@ -212,14 +236,14 @@ class GIFBuilder:
file_size_mb = file_size_kb / 1024
info = {
'path': str(output_path),
'size_kb': file_size_kb,
'size_mb': file_size_mb,
'dimensions': f'{self.width}x{self.height}',
'frame_count': len(optimized_frames),
'fps': self.fps,
'duration_seconds': len(optimized_frames) / self.fps,
'colors': num_colors
"path": str(output_path),
"size_kb": file_size_kb,
"size_mb": file_size_mb,
"dimensions": f"{self.width}x{self.height}",
"frame_count": len(optimized_frames),
"fps": self.fps,
"duration_seconds": len(optimized_frames) / self.fps,
"colors": num_colors,
}
# Print info
@@ -231,16 +255,15 @@ class GIFBuilder:
print(f" Duration: {info['duration_seconds']:.1f}s")
print(f" Colors: {num_colors}")
# Warnings
if optimize_for_emoji and file_size_kb > 64:
print(f"\n⚠️ WARNING: Emoji file size ({file_size_kb:.1f} KB) exceeds 64 KB limit")
print(" Try: fewer frames, fewer colors, or simpler design")
elif not optimize_for_emoji and file_size_kb > 2048:
print(f"\n⚠️ WARNING: File size ({file_size_kb:.1f} KB) is large for Slack")
print(" Try: fewer frames, smaller dimensions, or fewer colors")
# Size info
if optimize_for_emoji:
print(f" Optimized for emoji (128x128, reduced colors)")
if file_size_mb > 1.0:
print(f"\n Note: Large file size ({file_size_kb:.1f} KB)")
print(" Consider: fewer frames, smaller dimensions, or fewer colors")
return info
def clear(self):
"""Clear all frames (useful for creating multiple GIFs)."""
self.frames = []
self.frames = []

View File

@@ -8,146 +8,36 @@ These validators help ensure your GIFs meet Slack's size and dimension constrain
from pathlib import Path
def check_slack_size(gif_path: str | Path, is_emoji: bool = True) -> tuple[bool, dict]:
def validate_gif(
gif_path: str | Path, is_emoji: bool = True, verbose: bool = True
) -> tuple[bool, dict]:
"""
Check if GIF meets Slack size limits.
Validate GIF for Slack (dimensions, size, frame count).
Args:
gif_path: Path to GIF file
is_emoji: True for emoji GIF (64KB limit), False for message GIF (2MB limit)
is_emoji: True for emoji (128x128 recommended), False for message GIF
verbose: Print validation details
Returns:
Tuple of (passes: bool, info: dict with details)
"""
gif_path = Path(gif_path)
if not gif_path.exists():
return False, {'error': f'File not found: {gif_path}'}
size_bytes = gif_path.stat().st_size
size_kb = size_bytes / 1024
size_mb = size_kb / 1024
limit_kb = 64 if is_emoji else 2048
limit_mb = limit_kb / 1024
passes = size_kb <= limit_kb
info = {
'size_bytes': size_bytes,
'size_kb': size_kb,
'size_mb': size_mb,
'limit_kb': limit_kb,
'limit_mb': limit_mb,
'passes': passes,
'type': 'emoji' if is_emoji else 'message'
}
# Print feedback
if passes:
print(f"{size_kb:.1f} KB - within {limit_kb} KB limit")
else:
print(f"{size_kb:.1f} KB - exceeds {limit_kb} KB limit")
overage_kb = size_kb - limit_kb
overage_percent = (overage_kb / limit_kb) * 100
print(f" Over by: {overage_kb:.1f} KB ({overage_percent:.1f}%)")
print(f" Try: fewer frames, fewer colors, or simpler design")
return passes, info
def validate_dimensions(width: int, height: int, is_emoji: bool = True) -> tuple[bool, dict]:
"""
Check if dimensions are suitable for Slack.
Args:
width: Frame width in pixels
height: Frame height in pixels
is_emoji: True for emoji GIF, False for message GIF
Returns:
Tuple of (passes: bool, info: dict with details)
"""
info = {
'width': width,
'height': height,
'is_square': width == height,
'type': 'emoji' if is_emoji else 'message'
}
if is_emoji:
# Emoji GIFs should be 128x128
optimal = width == height == 128
acceptable = width == height and 64 <= width <= 128
info['optimal'] = optimal
info['acceptable'] = acceptable
if optimal:
print(f"{width}x{height} - optimal for emoji")
passes = True
elif acceptable:
print(f"{width}x{height} - acceptable but 128x128 is optimal")
passes = True
else:
print(f"{width}x{height} - emoji should be square, 128x128 recommended")
passes = False
else:
# Message GIFs should be square-ish and reasonable size
aspect_ratio = max(width, height) / min(width, height) if min(width, height) > 0 else float('inf')
reasonable_size = 320 <= min(width, height) <= 640
info['aspect_ratio'] = aspect_ratio
info['reasonable_size'] = reasonable_size
# Check if roughly square (within 2:1 ratio)
is_square_ish = aspect_ratio <= 2.0
if is_square_ish and reasonable_size:
print(f"{width}x{height} - good for message GIF")
passes = True
elif is_square_ish:
print(f"{width}x{height} - square-ish but unusual size")
passes = True
elif reasonable_size:
print(f"{width}x{height} - good size but not square-ish")
passes = True
else:
print(f"{width}x{height} - unusual dimensions for Slack")
passes = False
return passes, info
def validate_gif(gif_path: str | Path, is_emoji: bool = True) -> tuple[bool, dict]:
"""
Run all validations on a GIF file.
Args:
gif_path: Path to GIF file
is_emoji: True for emoji GIF, False for message GIF
Returns:
Tuple of (all_pass: bool, results: dict)
Tuple of (passes: bool, results: dict with all details)
"""
from PIL import Image
gif_path = Path(gif_path)
if not gif_path.exists():
return False, {'error': f'File not found: {gif_path}'}
return False, {"error": f"File not found: {gif_path}"}
print(f"\nValidating {gif_path.name} as {'emoji' if is_emoji else 'message'} GIF:")
print("=" * 60)
# Get file size
size_bytes = gif_path.stat().st_size
size_kb = size_bytes / 1024
size_mb = size_kb / 1024
# Check file size
size_pass, size_info = check_slack_size(gif_path, is_emoji)
# Check dimensions
# Get dimensions and frame info
try:
with Image.open(gif_path) as img:
width, height = img.size
dim_pass, dim_info = validate_dimensions(width, height, is_emoji)
# Count frames
frame_count = 0
@@ -158,107 +48,89 @@ def validate_gif(gif_path: str | Path, is_emoji: bool = True) -> tuple[bool, dic
except EOFError:
pass
# Get duration if available
# Get duration
try:
duration_ms = img.info.get('duration', 100)
duration_ms = img.info.get("duration", 100)
total_duration = (duration_ms * frame_count) / 1000
fps = frame_count / total_duration if total_duration > 0 else 0
except:
duration_ms = None
total_duration = None
fps = None
except Exception as e:
return False, {'error': f'Failed to read GIF: {e}'}
return False, {"error": f"Failed to read GIF: {e}"}
print(f"\nFrames: {frame_count}")
if total_duration:
print(f"Duration: {total_duration:.1f}s @ {fps:.1f} fps")
all_pass = size_pass and dim_pass
# Validate dimensions
if is_emoji:
optimal = width == height == 128
acceptable = width == height and 64 <= width <= 128
dim_pass = acceptable
else:
aspect_ratio = (
max(width, height) / min(width, height)
if min(width, height) > 0
else float("inf")
)
dim_pass = aspect_ratio <= 2.0 and 320 <= min(width, height) <= 640
results = {
'file': str(gif_path),
'passes': all_pass,
'size': size_info,
'dimensions': dim_info,
'frame_count': frame_count,
'duration_seconds': total_duration,
'fps': fps
"file": str(gif_path),
"passes": dim_pass,
"width": width,
"height": height,
"size_kb": size_kb,
"size_mb": size_mb,
"frame_count": frame_count,
"duration_seconds": total_duration,
"fps": fps,
"is_emoji": is_emoji,
"optimal": optimal if is_emoji else None,
}
print("=" * 60)
if all_pass:
print("✓ All validations passed!")
else:
print("✗ Some validations failed")
print()
# Print if verbose
if verbose:
print(f"\nValidating {gif_path.name}:")
print(
f" Dimensions: {width}x{height}"
+ (
f" ({'optimal' if optimal else 'acceptable'})"
if is_emoji and acceptable
else ""
)
)
print(
f" Size: {size_kb:.1f} KB"
+ (f" ({size_mb:.2f} MB)" if size_mb >= 1.0 else "")
)
print(
f" Frames: {frame_count}"
+ (f" @ {fps:.1f} fps ({total_duration:.1f}s)" if fps else "")
)
return all_pass, results
if not dim_pass:
print(
f" Note: {'Emoji should be 128x128' if is_emoji else 'Unusual dimensions for Slack'}"
)
if size_mb > 5.0:
print(f" Note: Large file size - consider fewer frames/colors")
return dim_pass, results
def get_optimization_suggestions(results: dict) -> list[str]:
"""
Get suggestions for optimizing a GIF based on validation results.
Args:
results: Results dict from validate_gif()
Returns:
List of suggestion strings
"""
suggestions = []
if not results.get('passes', False):
size_info = results.get('size', {})
dim_info = results.get('dimensions', {})
# Size suggestions
if not size_info.get('passes', True):
overage = size_info['size_kb'] - size_info['limit_kb']
if size_info['type'] == 'emoji':
suggestions.append(f"Reduce file size by {overage:.1f} KB:")
suggestions.append(" - Limit to 10-12 frames")
suggestions.append(" - Use 32-40 colors maximum")
suggestions.append(" - Remove gradients (solid colors compress better)")
suggestions.append(" - Simplify design")
else:
suggestions.append(f"Reduce file size by {overage:.1f} KB:")
suggestions.append(" - Reduce frame count or FPS")
suggestions.append(" - Use fewer colors (128 → 64)")
suggestions.append(" - Reduce dimensions")
# Dimension suggestions
if not dim_info.get('optimal', True) and dim_info.get('type') == 'emoji':
suggestions.append("For optimal emoji GIF:")
suggestions.append(" - Use 128x128 dimensions")
suggestions.append(" - Ensure square aspect ratio")
return suggestions
# Convenience function for quick checks
def is_slack_ready(gif_path: str | Path, is_emoji: bool = True, verbose: bool = True) -> bool:
def is_slack_ready(
gif_path: str | Path, is_emoji: bool = True, verbose: bool = True
) -> bool:
"""
Quick check if GIF is ready for Slack.
Args:
gif_path: Path to GIF file
is_emoji: True for emoji GIF, False for message GIF
verbose: Print detailed feedback
verbose: Print feedback
Returns:
True if ready, False otherwise
True if dimensions are acceptable
"""
if verbose:
passes, results = validate_gif(gif_path, is_emoji)
if not passes:
suggestions = get_optimization_suggestions(results)
if suggestions:
print("\nSuggestions:")
for suggestion in suggestions:
print(suggestion)
return passes
else:
size_pass, _ = check_slack_size(gif_path, is_emoji)
return size_pass
passes, _ = validate_gif(gif_path, is_emoji, verbose)
return passes