Claude Cursor Skill

slack-gif-creator

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".

LLM Mart · 0 points · 4 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download davepoon-buildwithclaude-plugins_all-skills_skills_slack-gif-creator-a6c484b.zip · 59 KB
Part of davepoon/buildwithclaude — 187 skills

Install

skills CLI npx skills add https://github.com/davepoon/buildwithclaude/tree/main/plugins/all-skills/skills/slack-gif-creator
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install davepoon-buildwithclaude@llmmart
Git git clone https://github.com/davepoon/buildwithclaude.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole davepoon/buildwithclaude collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Slack GIF Creator - Flexible Toolkit

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.

Slack's Requirements

Slack has specific requirements for GIFs based on their use:

Message GIFs:

  • Max size: ~2MB
  • Optimal dimensions: 480x480
  • Typical FPS: 15-20
  • Color limit: 128-256
  • Duration: 2-5s

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:

from core.gif_builder import GIFBuilder

# After creating your GIF, check if it meets requirements
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)

# The save method automatically warns if file exceeds limits
# info dict contains: size_kb, size_mb, frame_count, duration_seconds

File size validator:

from core.validators import check_slack_size

# Check if GIF meets size limits
passes, info = check_slack_size('emoji.gif', is_emoji=True)
# Returns: (True/False, dict with size details)

Dimension validator:

from core.validators import validate_dimensions

# Check dimensions
passes, info = validate_dimensions(128, 128, is_emoji=True)
# Returns: (True/False, dict with dimension details)

Complete validation:

from core.validators import validate_gif, is_slack_ready

# Run all validations
all_pass, results = validate_gif('emoji.gif', is_emoji=True)

# Or quick check
if is_slack_ready('emoji.gif', is_emoji=True):
    print("Ready to upload!")

Animation Primitives

These are composable building blocks for motion. Apply these to any object in any combination:

Shake

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

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

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

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

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

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

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

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

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

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

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

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

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:

# 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)

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:

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:

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:

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:

from core.easing import interpolate

# Object falling (accelerates)
y = interpolate(start=0, end=400, t=progress, easing='ease_in')

# Object landing (decelerates)
y = interpolate(start=0, end=400, t=progress, 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 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:

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
)

# 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)

Optimization Strategies

When your GIF is too large:

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

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)

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)

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)

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
)

# 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:

  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

The goal is creative freedom within Slack's technical constraints.

Dependencies

To use this toolkit, install these dependencies only if they aren't already present:

pip install pillow imageio numpy
Files (buildwithclaude)
  • core
    • color_palettes.py 8.5 KB
      #!/usr/bin/env python3
      """
      Color Palettes - Professional, harmonious color schemes for GIFs.
      
      Using consistent, well-designed color palettes makes GIFs look professional
      and polished instead of random and amateurish.
      """
      
      from typing import Optional
      import colorsys
      
      
      # Professional color palettes - hand-picked for GIF compression and visual appeal
      
      VIBRANT = {
          'primary': (255, 68, 68),      # Bright red
          'secondary': (255, 168, 0),     # Bright orange
          'accent': (0, 168, 255),        # Bright blue
          'success': (68, 255, 68),       # Bright green
          'background': (240, 248, 255),  # Alice blue
          'text': (30, 30, 30),           # Almost black
          'text_light': (255, 255, 255),  # White
      }
      
      PASTEL = {
          'primary': (255, 179, 186),     # Pastel pink
          'secondary': (255, 223, 186),   # Pastel peach
          'accent': (186, 225, 255),      # Pastel blue
          'success': (186, 255, 201),     # Pastel green
          'background': (255, 250, 240),  # Floral white
          'text': (80, 80, 80),           # Dark gray
          'text_light': (255, 255, 255),  # White
      }
      
      DARK = {
          'primary': (255, 100, 100),     # Muted red
          'secondary': (100, 200, 255),   # Muted blue
          'accent': (255, 200, 100),      # Muted gold
          'success': (100, 255, 150),     # Muted green
          'background': (30, 30, 35),     # Almost black
          'text': (220, 220, 220),        # Light gray
          'text_light': (255, 255, 255),  # White
      }
      
      NEON = {
          'primary': (255, 16, 240),      # Neon pink
          'secondary': (0, 255, 255),     # Cyan
          'accent': (255, 255, 0),        # Yellow
          'success': (57, 255, 20),       # Neon green
          'background': (20, 20, 30),     # Dark blue-black
          'text': (255, 255, 255),        # White
          'text_light': (255, 255, 255),  # White
      }
      
      PROFESSIONAL = {
          'primary': (0, 122, 255),       # System blue
          'secondary': (88, 86, 214),     # System purple
          'accent': (255, 149, 0),        # System orange
          'success': (52, 199, 89),       # System green
          'background': (255, 255, 255),  # White
          'text': (0, 0, 0),              # Black
          'text_light': (255, 255, 255),  # White
      }
      
      WARM = {
          'primary': (255, 107, 107),     # Coral red
          'secondary': (255, 159, 64),    # Orange
          'accent': (255, 218, 121),      # Yellow
          'success': (106, 176, 76),      # Olive green
          'background': (255, 246, 229),  # Warm white
          'text': (51, 51, 51),           # Charcoal
          'text_light': (255, 255, 255),  # White
      }
      
      COOL = {
          'primary': (107, 185, 240),     # Sky blue
          'secondary': (130, 202, 157),   # Mint
          'accent': (162, 155, 254),      # Lavender
          'success': (86, 217, 150),      # Aqua green
          'background': (240, 248, 255),  # Alice blue
          'text': (45, 55, 72),           # Dark slate
          'text_light': (255, 255, 255),  # White
      }
      
      MONOCHROME = {
          'primary': (80, 80, 80),        # Dark gray
          'secondary': (130, 130, 130),   # Medium gray
          'accent': (180, 180, 180),      # Light gray
          'success': (100, 100, 100),     # Gray
          'background': (245, 245, 245),  # Off-white
          'text': (30, 30, 30),           # Almost black
          'text_light': (255, 255, 255),  # White
      }
      
      # Map of palette names
      PALETTES = {
          'vibrant': VIBRANT,
          'pastel': PASTEL,
          'dark': DARK,
          'neon': NEON,
          'professional': PROFESSIONAL,
          'warm': WARM,
          'cool': COOL,
          'monochrome': MONOCHROME,
      }
      
      
      def get_palette(name: str = 'vibrant') -> dict:
          """
          Get a color palette by name.
      
          Args:
              name: Palette name (vibrant, pastel, dark, neon, professional, warm, cool, monochrome)
      
          Returns:
              Dictionary of color roles to RGB tuples
          """
          return PALETTES.get(name.lower(), VIBRANT)
      
      
      def get_text_color_for_background(bg_color: tuple[int, int, int]) -> tuple[int, int, int]:
          """
          Get the best text color (black or white) for a given background.
      
          Uses luminance calculation to ensure readability.
      
          Args:
              bg_color: Background RGB color
      
          Returns:
              Text color (black or white) that contrasts well
          """
          # Calculate relative luminance
          r, g, b = bg_color
          luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
      
          # Return black for light backgrounds, white for dark
          return (0, 0, 0) if luminance > 0.5 else (255, 255, 255)
      
      
      def get_complementary_color(color: tuple[int, int, int]) -> tuple[int, int, int]:
          """
          Get the complementary (opposite) color on the color wheel.
      
          Args:
              color: RGB color tuple
      
          Returns:
              Complementary RGB color
          """
          # Convert to HSV
          r, g, b = [x / 255.0 for x in color]
          h, s, v = colorsys.rgb_to_hsv(r, g, b)
      
          # Rotate hue by 180 degrees (0.5 in 0-1 scale)
          h_comp = (h + 0.5) % 1.0
      
          # Convert back to RGB
          r_comp, g_comp, b_comp = colorsys.hsv_to_rgb(h_comp, s, v)
          return (int(r_comp * 255), int(g_comp * 255), int(b_comp * 255))
      
      
      def lighten_color(color: tuple[int, int, int], amount: float = 0.3) -> tuple[int, int, int]:
          """
          Lighten a color by a given amount.
      
          Args:
              color: RGB color tuple
              amount: Amount to lighten (0.0-1.0)
      
          Returns:
              Lightened RGB color
          """
          r, g, b = color
          r = min(255, int(r + (255 - r) * amount))
          g = min(255, int(g + (255 - g) * amount))
          b = min(255, int(b + (255 - b) * amount))
          return (r, g, b)
      
      
      def darken_color(color: tuple[int, int, int], amount: float = 0.3) -> tuple[int, int, int]:
          """
          Darken a color by a given amount.
      
          Args:
              color: RGB color tuple
              amount: Amount to darken (0.0-1.0)
      
          Returns:
              Darkened RGB color
          """
          r, g, b = color
          r = max(0, int(r * (1 - amount)))
          g = max(0, int(g * (1 - amount)))
          b = max(0, int(b * (1 - amount)))
          return (r, g, b)
      
      
      def blend_colors(color1: tuple[int, int, int], color2: tuple[int, int, int],
                       ratio: float = 0.5) -> tuple[int, int, int]:
          """
          Blend two colors together.
      
          Args:
              color1: First RGB color
              color2: Second RGB color
              ratio: Blend ratio (0.0 = all color1, 1.0 = all color2)
      
          Returns:
              Blended RGB color
          """
          r1, g1, b1 = color1
          r2, g2, b2 = color2
      
          r = int(r1 * (1 - ratio) + r2 * ratio)
          g = int(g1 * (1 - ratio) + g2 * ratio)
          b = int(b1 * (1 - ratio) + b2 * ratio)
      
          return (r, g, b)
      
      
      def create_gradient_colors(start_color: tuple[int, int, int],
                                 end_color: tuple[int, int, int],
                                 steps: int) -> list[tuple[int, int, int]]:
          """
          Create a gradient of colors between two colors.
      
          Args:
              start_color: Starting RGB color
              end_color: Ending RGB color
              steps: Number of gradient steps
      
          Returns:
              List of RGB colors forming gradient
          """
          colors = []
          for i in range(steps):
              ratio = i / (steps - 1) if steps > 1 else 0
              colors.append(blend_colors(start_color, end_color, ratio))
          return colors
      
      
      # Impact/emphasis colors that work well across palettes
      IMPACT_COLORS = {
          'flash': (255, 255, 240),       # Bright flash (cream)
          'explosion': (255, 150, 0),     # Orange explosion
          'electricity': (100, 200, 255),  # Electric blue
          'fire': (255, 100, 0),          # Fire orange-red
          'success': (50, 255, 100),      # Success green
          'error': (255, 50, 50),         # Error red
          'warning': (255, 200, 0),       # Warning yellow
          'magic': (200, 100, 255),       # Magic purple
      }
      
      
      def get_impact_color(effect_type: str = 'flash') -> tuple[int, int, int]:
          """
          Get a color for impact/emphasis effects.
      
          Args:
              effect_type: Type of effect (flash, explosion, electricity, etc.)
      
          Returns:
              RGB color for effect
          """
          return IMPACT_COLORS.get(effect_type, IMPACT_COLORS['flash'])
      
      
      # Emoji-safe palettes (work well at 128x128 with 32-64 colors)
      EMOJI_PALETTES = {
          'simple': [
              (255, 255, 255),  # White
              (0, 0, 0),        # Black
              (255, 100, 100),  # Red
              (100, 255, 100),  # Green
              (100, 100, 255),  # Blue
              (255, 255, 100),  # Yellow
          ],
          'vibrant_emoji': [
              (255, 255, 255),  # White
              (30, 30, 30),     # Black
              (255, 68, 68),    # Red
              (68, 255, 68),    # Green
              (68, 68, 255),    # Blue
              (255, 200, 68),   # Gold
              (255, 68, 200),   # Pink
              (68, 255, 200),   # Cyan
          ]
      }
      
      
      def get_emoji_palette(name: str = 'simple') -> list[tuple[int, int, int]]:
          """
          Get a limited color palette optimized for emoji GIFs (<64KB).
      
          Args:
              name: Palette name (simple, vibrant_emoji)
      
          Returns:
              List of RGB colors (6-8 colors)
          """
          return EMOJI_PALETTES.get(name, EMOJI_PALETTES['simple'])
    • easing.py 6.1 KB
      #!/usr/bin/env python3
      """
      Easing Functions - Timing functions for smooth animations.
      
      Provides various easing functions for natural motion and timing.
      All functions take a value t (0.0 to 1.0) and return eased value (0.0 to 1.0).
      """
      
      import math
      
      
      def linear(t: float) -> float:
          """Linear interpolation (no easing)."""
          return t
      
      
      def ease_in_quad(t: float) -> float:
          """Quadratic ease-in (slow start, accelerating)."""
          return t * t
      
      
      def ease_out_quad(t: float) -> float:
          """Quadratic ease-out (fast start, decelerating)."""
          return t * (2 - t)
      
      
      def ease_in_out_quad(t: float) -> float:
          """Quadratic ease-in-out (slow start and end)."""
          if t < 0.5:
              return 2 * t * t
          return -1 + (4 - 2 * t) * t
      
      
      def ease_in_cubic(t: float) -> float:
          """Cubic ease-in (slow start)."""
          return t * t * t
      
      
      def ease_out_cubic(t: float) -> float:
          """Cubic ease-out (fast start)."""
          return (t - 1) * (t - 1) * (t - 1) + 1
      
      
      def ease_in_out_cubic(t: float) -> float:
          """Cubic ease-in-out."""
          if t < 0.5:
              return 4 * t * t * t
          return (t - 1) * (2 * t - 2) * (2 * t - 2) + 1
      
      
      def ease_in_bounce(t: float) -> float:
          """Bounce ease-in (bouncy start)."""
          return 1 - ease_out_bounce(1 - t)
      
      
      def ease_out_bounce(t: float) -> float:
          """Bounce ease-out (bouncy end)."""
          if t < 1 / 2.75:
              return 7.5625 * t * t
          elif t < 2 / 2.75:
              t -= 1.5 / 2.75
              return 7.5625 * t * t + 0.75
          elif t < 2.5 / 2.75:
              t -= 2.25 / 2.75
              return 7.5625 * t * t + 0.9375
          else:
              t -= 2.625 / 2.75
              return 7.5625 * t * t + 0.984375
      
      
      def ease_in_out_bounce(t: float) -> float:
          """Bounce ease-in-out."""
          if t < 0.5:
              return ease_in_bounce(t * 2) * 0.5
          return ease_out_bounce(t * 2 - 1) * 0.5 + 0.5
      
      
      def ease_in_elastic(t: float) -> float:
          """Elastic ease-in (spring effect)."""
          if t == 0 or t == 1:
              return t
          return -math.pow(2, 10 * (t - 1)) * math.sin((t - 1.1) * 5 * math.pi)
      
      
      def ease_out_elastic(t: float) -> float:
          """Elastic ease-out (spring effect)."""
          if t == 0 or t == 1:
              return t
          return math.pow(2, -10 * t) * math.sin((t - 0.1) * 5 * math.pi) + 1
      
      
      def ease_in_out_elastic(t: float) -> float:
          """Elastic ease-in-out."""
          if t == 0 or t == 1:
              return t
          t = t * 2 - 1
          if t < 0:
              return -0.5 * math.pow(2, 10 * t) * math.sin((t - 0.1) * 5 * math.pi)
          return math.pow(2, -10 * t) * math.sin((t - 0.1) * 5 * math.pi) * 0.5 + 1
      
      
      # 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,
      }
      
      
      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:
          """
          Interpolate between two values with easing.
      
          Args:
              start: Start value
              end: End value
              t: Progress from 0.0 to 1.0
              easing: Name of easing function
      
          Returns:
              Interpolated value
          """
          ease_func = get_easing(easing)
          eased_t = ease_func(t)
          return start + (end - start) * eased_t
      
      
      def ease_back_in(t: float) -> float:
          """Back ease-in (slight overshoot backward before forward motion)."""
          c1 = 1.70158
          c3 = c1 + 1
          return c3 * t * t * t - c1 * t * t
      
      
      def ease_back_out(t: float) -> float:
          """Back ease-out (overshoot forward then settle back)."""
          c1 = 1.70158
          c3 = c1 + 1
          return 1 + c3 * pow(t - 1, 3) + c1 * pow(t - 1, 2)
      
      
      def ease_back_in_out(t: float) -> float:
          """Back ease-in-out (overshoot at both ends)."""
          c1 = 1.70158
          c2 = c1 * 1.525
          if t < 0.5:
              return (pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
          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]:
          """
          Calculate squash and stretch scales for more dynamic animation.
      
          Args:
              base_scale: (width_scale, height_scale) base scales
              intensity: Squash/stretch intensity (0.0-1.0)
              direction: 'vertical', 'horizontal', or 'both'
      
          Returns:
              (width_scale, height_scale) with squash/stretch applied
          """
          width_scale, height_scale = base_scale
      
          if direction == 'vertical':
              # Compress vertically, expand horizontally (preserve volume)
              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':
              # General squash (both dimensions)
              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]:
          """
          Calculate position along a parabolic arc (natural motion path).
      
          Args:
              start: (x, y) starting position
              end: (x, y) ending position
              height: Arc height at midpoint (positive = upward)
              t: Progress (0.0-1.0)
      
          Returns:
              (x, y) position along arc
          """
          x1, y1 = start
          x2, y2 = end
      
          # Linear interpolation for x
          x = x1 + (x2 - x1) * t
      
          # Parabolic interpolation for y
          # y = start + progress * (end - start) + arc_offset
          # Arc offset peaks at t=0.5
          arc_offset = 4 * height * t * (1 - t)
          y = y1 + (y2 - y1) * t - arc_offset
      
          return (x, y)
      
      
      # 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
      })
    • frame_composer.py 14.1 KB
      #!/usr/bin/env python3
      """
      Frame Composer - Utilities for composing visual elements into frames.
      
      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
      
      
      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.
      
          Args:
              width: Frame width
              height: Frame height
              color: RGB color tuple (default: white)
      
          Returns:
              PIL Image
          """
          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:
          """
          Draw a circle on a frame.
      
          Args:
              frame: PIL Image to draw on
              center: (x, y) center position
              radius: Circle radius
              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)
          x, y = center
          bbox = [x - radius, y - radius, x + radius, y + radius]
          draw.ellipse(bbox, fill=fill_color, outline=outline_color, width=outline_width)
          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:
          """
          Draw text on a frame.
      
          Args:
              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
      
          Returns:
              Modified frame
          """
          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()
      
          if centered:
              bbox = draw.textbbox((0, 0), text, font=font)
              text_width = bbox[2] - bbox[0]
              text_height = bbox[3] - bbox[1]
              x = position[0] - text_width // 2
              y = position[1] - text_height // 2
              position = (x, y)
      
          draw.text(position, text, fill=color, font=font)
          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:
          """
          Create a vertical gradient background.
      
          Args:
              width: Frame width
              height: Frame height
              top_color: RGB color at top
              bottom_color: RGB color at bottom
      
          Returns:
              PIL Image with gradient
          """
          frame = Image.new('RGB', (width, height))
          draw = ImageDraw.Draw(frame)
      
          # Calculate color step for each row
          r1, g1, b1 = top_color
          r2, g2, b2 = bottom_color
      
          for y in range(height):
              # Interpolate color
              ratio = y / height
              r = int(r1 * (1 - ratio) + r2 * ratio)
              g = int(g1 * (1 - ratio) + g2 * ratio)
              b = int(b1 * (1 - ratio) + b2 * ratio)
      
              # Draw horizontal line
              draw.line([(0, y), (width, y)], fill=(r, g, b))
      
          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:
          """
          Draw a 5-pointed star.
      
          Args:
              frame: PIL Image to draw on
              center: (x, y) center position
              size: Star size (outer radius)
              fill_color: RGB fill color
              outline_color: RGB outline color (None for no outline)
              outline_width: Outline width
      
          Returns:
              Modified frame
          """
          import math
          draw = ImageDraw.Draw(frame)
          x, y = center
      
          # Calculate star points
          points = []
          for i in range(10):
              angle = (i * 36 - 90) * math.pi / 180  # 36 degrees per point, start at top
              radius = size if i % 2 == 0 else size * 0.4  # Alternate between outer and inner
              px = x + radius * math.cos(angle)
              py = y + radius * math.sin(angle)
              points.append((px, py))
      
          # Draw star
          draw.polygon(points, fill=fill_color, outline=outline_color, width=outline_width)
      
          return frame
    • gif_builder.py 9.3 KB
      #!/usr/bin/env python3
      """
      GIF Builder - Core module for assembling frames into GIFs optimized for Slack.
      
      This module provides the main interface for creating GIFs from programmatically
      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
      
      
      class GIFBuilder:
          """Builder for creating optimized GIFs from frames."""
      
          def __init__(self, width: int = 480, height: int = 480, fps: int = 15):
              """
              Initialize GIF builder.
      
              Args:
                  width: Frame width in pixels
                  height: Frame height in pixels
                  fps: Frames per second
              """
              self.width = width
              self.height = height
              self.fps = fps
              self.frames: list[np.ndarray] = []
      
          def add_frame(self, frame: np.ndarray | Image.Image):
              """
              Add a frame to the GIF.
      
              Args:
                  frame: Frame as numpy array or PIL Image (will be converted to RGB)
              """
              if isinstance(frame, Image.Image):
                  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)
                  frame = np.array(pil_frame)
      
              self.frames.append(frame)
      
          def add_frames(self, frames: list[np.ndarray | Image.Image]):
              """Add multiple frames at once."""
              for frame in frames:
                  self.add_frame(frame)
      
          def optimize_colors(self, num_colors: int = 128, use_global_palette: bool = True) -> list[np.ndarray]:
              """
              Reduce colors in all frames using quantization.
      
              Args:
                  num_colors: Target number of colors (8-256)
                  use_global_palette: Use a single palette for all frames (better compression)
      
              Returns:
                  List of color-optimized frames
              """
              optimized = []
      
              if use_global_palette and len(self.frames) > 1:
                  # 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_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)
      
                  # Create a properly-shaped RGB image from the pixel data
                  # We'll make a roughly square image from all the pixels
                  total_pixels = len(all_pixels)
                  width = min(512, int(np.sqrt(total_pixels)))  # Reasonable width, max 512
                  height = (total_pixels + width - 1) // width  # Ceiling division
      
                  # Pad if necessary to fill the rectangle
                  pixels_needed = width * height
                  if pixels_needed > total_pixels:
                      padding = np.zeros((pixels_needed - total_pixels, 3), dtype=np.uint8)
                      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')
      
                  # Generate global palette
                  global_palette = combined_img.quantize(colors=num_colors, method=2)
      
                  # Apply global palette to all frames
                  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')))
              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')))
      
              return optimized
      
          def deduplicate_frames(self, threshold: float = 0.995) -> int:
              """
              Remove duplicate or near-duplicate consecutive frames.
      
              Args:
                  threshold: Similarity threshold (0.0-1.0). Higher = more strict (0.995 = very similar).
      
              Returns:
                  Number of frames removed
              """
              if len(self.frames) < 2:
                  return 0
      
              deduplicated = [self.frames[0]]
              removed_count = 0
      
              for i in range(1, len(self.frames)):
                  # Compare with previous frame
                  prev_frame = np.array(deduplicated[-1], dtype=np.float32)
                  curr_frame = np.array(self.frames[i], dtype=np.float32)
      
                  # Calculate similarity (normalized)
                  diff = np.abs(prev_frame - curr_frame)
                  similarity = 1.0 - (np.mean(diff) / 255.0)
      
                  # Keep frame if sufficiently different
                  # High threshold (0.995) means only remove truly identical frames
                  if similarity < threshold:
                      deduplicated.append(self.frames[i])
                  else:
                      removed_count += 1
      
              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:
              """
              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
      
              Returns:
                  Dictionary with file info (path, size, dimensions, frame_count)
              """
              if not self.frames:
                  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)
                  if removed > 0:
                      print(f"  Removed {removed} duplicate frames")
      
              # 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")
                      self.width = 128
                      self.height = 128
                      # Resize all frames
                      resized_frames = []
                      for frame in self.frames:
                          pil_frame = Image.fromarray(frame)
                          pil_frame = pil_frame.resize((128, 128), Image.Resampling.LANCZOS)
                          resized_frames.append(np.array(pil_frame))
                      self.frames = resized_frames
                  num_colors = min(num_colors, 48)  # More aggressive color limit for emoji
      
                  # More aggressive FPS reduction for emoji
                  if len(self.frames) > 12:
                      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)]
      
              # Optimize colors with global palette
              optimized_frames = self.optimize_colors(num_colors, use_global_palette=True)
      
              # Calculate frame duration in milliseconds
              frame_duration = 1000 / self.fps
      
              # Save GIF
              imageio.imwrite(
                  output_path,
                  optimized_frames,
                  duration=frame_duration,
                  loop=0  # Infinite loop
              )
      
              # Get file info
              file_size_kb = output_path.stat().st_size / 1024
              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
              }
      
              # Print info
              print(f"\n✓ GIF created successfully!")
              print(f"  Path: {output_path}")
              print(f"  Size: {file_size_kb:.1f} KB ({file_size_mb:.2f} MB)")
              print(f"  Dimensions: {self.width}x{self.height}")
              print(f"  Frames: {len(optimized_frames)} @ {self.fps} fps")
              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")
      
              return info
      
          def clear(self):
              """Clear all frames (useful for creating multiple GIFs)."""
              self.frames = []
    • typography.py 10.5 KB
      #!/usr/bin/env python3
      """
      Typography System - Professional text rendering with outlines, shadows, and effects.
      
      This module provides high-quality text rendering that looks crisp and professional
      in GIFs, with outlines for readability and effects for visual impact.
      """
      
      from PIL import Image, ImageDraw, ImageFont
      from typing import Optional
      
      
      # Typography scale - proportional sizing system
      TYPOGRAPHY_SCALE = {
          'h1': 60,      # Large headers
          'h2': 48,      # Medium headers
          'h3': 36,      # Small headers
          'title': 50,   # Title text
          'body': 28,    # Body text
          'small': 20,   # Small text
          'tiny': 16,    # Tiny text
      }
      
      
      def get_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
          """
          Get a font with fallback support.
      
          Args:
              size: Font size in pixels
              bold: Use bold variant if available
      
          Returns:
              ImageFont object
          """
          # Try multiple font paths for cross-platform support
          font_paths = [
              # macOS fonts
              "/System/Library/Fonts/Helvetica.ttc",
              "/System/Library/Fonts/SF-Pro.ttf",
              "/Library/Fonts/Arial Bold.ttf" if bold else "/Library/Fonts/Arial.ttf",
              # Linux fonts
              "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
              # Windows fonts
              "C:\\Windows\\Fonts\\arialbd.ttf" if bold else "C:\\Windows\\Fonts\\arial.ttf",
          ]
      
          for font_path in font_paths:
              try:
                  return ImageFont.truetype(font_path, size)
              except:
                  continue
      
          # Ultimate fallback
          return ImageFont.load_default()
      
      
      def draw_text_with_outline(
          frame: Image.Image,
          text: str,
          position: tuple[int, int],
          font_size: int = 40,
          text_color: tuple[int, int, int] = (255, 255, 255),
          outline_color: tuple[int, int, int] = (0, 0, 0),
          outline_width: int = 3,
          centered: bool = False,
          bold: bool = True
      ) -> Image.Image:
          """
          Draw text with outline for maximum readability.
      
          This is THE most important function for professional-looking text in GIFs.
          The outline ensures text is readable on any background.
      
          Args:
              frame: PIL Image to draw on
              text: Text to draw
              position: (x, y) position
              font_size: Font size in pixels
              text_color: RGB color for text fill
              outline_color: RGB color for outline
              outline_width: Width of outline in pixels (2-4 recommended)
              centered: If True, center text at position
              bold: Use bold font variant
      
          Returns:
              Modified frame
          """
          draw = ImageDraw.Draw(frame)
          font = get_font(font_size, bold=bold)
      
          # Calculate position for centering
          if centered:
              bbox = draw.textbbox((0, 0), text, font=font)
              text_width = bbox[2] - bbox[0]
              text_height = bbox[3] - bbox[1]
              x = position[0] - text_width // 2
              y = position[1] - text_height // 2
              position = (x, y)
      
          # Draw outline by drawing text multiple times offset in all directions
          x, y = position
          for offset_x in range(-outline_width, outline_width + 1):
              for offset_y in range(-outline_width, outline_width + 1):
                  if offset_x != 0 or offset_y != 0:
                      draw.text((x + offset_x, y + offset_y), text, fill=outline_color, font=font)
      
          # Draw main text on top
          draw.text(position, text, fill=text_color, font=font)
      
          return frame
      
      
      def draw_text_with_shadow(
          frame: Image.Image,
          text: str,
          position: tuple[int, int],
          font_size: int = 40,
          text_color: tuple[int, int, int] = (255, 255, 255),
          shadow_color: tuple[int, int, int] = (0, 0, 0),
          shadow_offset: tuple[int, int] = (3, 3),
          centered: bool = False,
          bold: bool = True
      ) -> Image.Image:
          """
          Draw text with drop shadow for depth.
      
          Args:
              frame: PIL Image to draw on
              text: Text to draw
              position: (x, y) position
              font_size: Font size in pixels
              text_color: RGB color for text
              shadow_color: RGB color for shadow
              shadow_offset: (x, y) offset for shadow
              centered: If True, center text at position
              bold: Use bold font variant
      
          Returns:
              Modified frame
          """
          draw = ImageDraw.Draw(frame)
          font = get_font(font_size, bold=bold)
      
          # Calculate position for centering
          if centered:
              bbox = draw.textbbox((0, 0), text, font=font)
              text_width = bbox[2] - bbox[0]
              text_height = bbox[3] - bbox[1]
              x = position[0] - text_width // 2
              y = position[1] - text_height // 2
              position = (x, y)
      
          # Draw shadow
          shadow_pos = (position[0] + shadow_offset[0], position[1] + shadow_offset[1])
          draw.text(shadow_pos, text, fill=shadow_color, font=font)
      
          # Draw main text
          draw.text(position, text, fill=text_color, font=font)
      
          return frame
      
      
      def draw_text_with_glow(
          frame: Image.Image,
          text: str,
          position: tuple[int, int],
          font_size: int = 40,
          text_color: tuple[int, int, int] = (255, 255, 255),
          glow_color: tuple[int, int, int] = (255, 200, 0),
          glow_radius: int = 5,
          centered: bool = False,
          bold: bool = True
      ) -> Image.Image:
          """
          Draw text with glow effect for emphasis.
      
          Args:
              frame: PIL Image to draw on
              text: Text to draw
              position: (x, y) position
              font_size: Font size in pixels
              text_color: RGB color for text
              glow_color: RGB color for glow
              glow_radius: Radius of glow effect
              centered: If True, center text at position
              bold: Use bold font variant
      
          Returns:
              Modified frame
          """
          draw = ImageDraw.Draw(frame)
          font = get_font(font_size, bold=bold)
      
          # Calculate position for centering
          if centered:
              bbox = draw.textbbox((0, 0), text, font=font)
              text_width = bbox[2] - bbox[0]
              text_height = bbox[3] - bbox[1]
              x = position[0] - text_width // 2
              y = position[1] - text_height // 2
              position = (x, y)
      
          # Draw glow layers with decreasing opacity (simulated with same color at different offsets)
          x, y = position
          for radius in range(glow_radius, 0, -1):
              for offset_x in range(-radius, radius + 1):
                  for offset_y in range(-radius, radius + 1):
                      if offset_x != 0 or offset_y != 0:
                          draw.text((x + offset_x, y + offset_y), text, fill=glow_color, font=font)
      
          # Draw main text
          draw.text(position, text, fill=text_color, font=font)
      
          return frame
      
      
      def draw_text_in_box(
          frame: Image.Image,
          text: str,
          position: tuple[int, int],
          font_size: int = 40,
          text_color: tuple[int, int, int] = (255, 255, 255),
          box_color: tuple[int, int, int] = (0, 0, 0),
          box_alpha: float = 0.7,
          padding: int = 10,
          centered: bool = True,
          bold: bool = True
      ) -> Image.Image:
          """
          Draw text in a semi-transparent box for guaranteed readability.
      
          Args:
              frame: PIL Image to draw on
              text: Text to draw
              position: (x, y) position
              font_size: Font size in pixels
              text_color: RGB color for text
              box_color: RGB color for background box
              box_alpha: Opacity of box (0.0-1.0)
              padding: Padding around text in pixels
              centered: If True, center at position
              bold: Use bold font variant
      
          Returns:
              Modified frame
          """
          # Create a separate layer for the box with alpha
          overlay = Image.new('RGBA', frame.size, (0, 0, 0, 0))
          draw_overlay = ImageDraw.Draw(overlay)
          draw = ImageDraw.Draw(frame)
      
          font = get_font(font_size, bold=bold)
      
          # Get text dimensions
          bbox = draw.textbbox((0, 0), text, font=font)
          text_width = bbox[2] - bbox[0]
          text_height = bbox[3] - bbox[1]
      
          # Calculate box position
          if centered:
              box_x = position[0] - (text_width + padding * 2) // 2
              box_y = position[1] - (text_height + padding * 2) // 2
              text_x = position[0] - text_width // 2
              text_y = position[1] - text_height // 2
          else:
              box_x = position[0] - padding
              box_y = position[1] - padding
              text_x = position[0]
              text_y = position[1]
      
          # Draw semi-transparent box
          box_coords = [
              box_x,
              box_y,
              box_x + text_width + padding * 2,
              box_y + text_height + padding * 2
          ]
          alpha_value = int(255 * box_alpha)
          draw_overlay.rectangle(box_coords, fill=(*box_color, alpha_value))
      
          # Composite overlay onto frame
          frame_rgba = frame.convert('RGBA')
          frame_rgba = Image.alpha_composite(frame_rgba, overlay)
          frame = frame_rgba.convert('RGB')
      
          # Draw text on top
          draw = ImageDraw.Draw(frame)
          draw.text((text_x, text_y), text, fill=text_color, font=font)
      
          return frame
      
      
      def get_text_size(text: str, font_size: int, bold: bool = True) -> tuple[int, int]:
          """
          Get the dimensions of text without drawing it.
      
          Args:
              text: Text to measure
              font_size: Font size in pixels
              bold: Use bold font variant
      
          Returns:
              (width, height) tuple
          """
          font = get_font(font_size, bold=bold)
          # Create temporary image to measure
          temp_img = Image.new('RGB', (1, 1))
          draw = ImageDraw.Draw(temp_img)
          bbox = draw.textbbox((0, 0), text, font=font)
          width = bbox[2] - bbox[0]
          height = bbox[3] - bbox[1]
          return (width, height)
      
      
      def get_optimal_font_size(text: str, max_width: int, max_height: int,
                                start_size: int = 60) -> int:
          """
          Find the largest font size that fits within given dimensions.
      
          Args:
              text: Text to size
              max_width: Maximum width in pixels
              max_height: Maximum height in pixels
              start_size: Starting font size to try
      
          Returns:
              Optimal font size
          """
          font_size = start_size
          while font_size > 10:
              width, height = get_text_size(text, font_size)
              if width <= max_width and height <= max_height:
                  return font_size
              font_size -= 2
          return 10  # Minimum font size
      
      
      def scale_font_for_frame(base_size: int, frame_width: int, frame_height: int) -> int:
          """
          Scale font size proportionally to frame dimensions.
      
          Useful for maintaining relative text size across different GIF dimensions.
      
          Args:
              base_size: Base font size for 480x480 frame
              frame_width: Actual frame width
              frame_height: Actual frame height
      
          Returns:
              Scaled font size
          """
          # Use average dimension for scaling
          avg_dimension = (frame_width + frame_height) / 2
          base_dimension = 480  # Reference dimension
          scale_factor = avg_dimension / base_dimension
          return max(10, int(base_size * scale_factor))
    • validators.py 8 KB
      #!/usr/bin/env python3
      """
      Validators - Check if GIFs meet Slack's requirements.
      
      These validators help ensure your GIFs meet Slack's size and dimension constraints.
      """
      
      from pathlib import Path
      
      
      def check_slack_size(gif_path: str | Path, is_emoji: bool = True) -> tuple[bool, dict]:
          """
          Check if GIF meets Slack size limits.
      
          Args:
              gif_path: Path to GIF file
              is_emoji: True for emoji GIF (64KB limit), False for message GIF (2MB limit)
      
          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)
          """
          from PIL import Image
      
          gif_path = Path(gif_path)
      
          if not gif_path.exists():
              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)
      
          # Check file size
          size_pass, size_info = check_slack_size(gif_path, is_emoji)
      
          # Check dimensions
          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
                  try:
                      while True:
                          img.seek(frame_count)
                          frame_count += 1
                  except EOFError:
                      pass
      
                  # Get duration if available
                  try:
                      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}'}
      
          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
      
          results = {
              'file': str(gif_path),
              'passes': all_pass,
              'size': size_info,
              'dimensions': dim_info,
              'frame_count': frame_count,
              'duration_seconds': total_duration,
              'fps': fps
          }
      
          print("=" * 60)
          if all_pass:
              print("✓ All validations passed!")
          else:
              print("✗ Some validations failed")
          print()
      
          return all_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:
          """
          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
      
          Returns:
              True if ready, False otherwise
          """
          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
      
    • visual_effects.py 14.5 KB
      #!/usr/bin/env python3
      """
      Visual Effects - Particles, motion blur, impacts, and other effects for GIFs.
      
      This module provides high-impact visual effects that make animations feel
      professional and dynamic while keeping file sizes reasonable.
      """
      
      from PIL import Image, ImageDraw, ImageFilter
      import numpy as np
      import math
      import random
      from typing import Optional
      
      
      class Particle:
          """A single particle in a particle system."""
      
          def __init__(self, x: float, y: float, vx: float, vy: float,
                       lifetime: float, color: tuple[int, int, int],
                       size: int = 3, shape: str = 'circle'):
              """
              Initialize a particle.
      
              Args:
                  x, y: Starting position
                  vx, vy: Velocity
                  lifetime: How long particle lives (in frames)
                  color: RGB color
                  size: Particle size in pixels
                  shape: 'circle', 'square', or 'star'
              """
              self.x = x
              self.y = y
              self.vx = vx
              self.vy = vy
              self.lifetime = lifetime
              self.max_lifetime = lifetime
              self.color = color
              self.size = size
              self.shape = shape
              self.gravity = 0.5  # Pixels per frame squared
              self.drag = 0.98    # Velocity multiplier per frame
      
          def update(self):
              """Update particle position and lifetime."""
              # Apply physics
              self.vy += self.gravity
              self.vx *= self.drag
              self.vy *= self.drag
      
              # Update position
              self.x += self.vx
              self.y += self.vy
      
              # Decrease lifetime
              self.lifetime -= 1
      
          def is_alive(self) -> bool:
              """Check if particle is still alive."""
              return self.lifetime > 0
      
          def get_alpha(self) -> float:
              """Get particle opacity based on lifetime."""
              return max(0, min(1, self.lifetime / self.max_lifetime))
      
          def render(self, frame: Image.Image):
              """
              Render particle to frame.
      
              Args:
                  frame: PIL Image to draw on
              """
              if not self.is_alive():
                  return
      
              draw = ImageDraw.Draw(frame)
              alpha = self.get_alpha()
      
              # Calculate faded color
              color = tuple(int(c * alpha) for c in self.color)
      
              # Draw based on shape
              x, y = int(self.x), int(self.y)
              size = max(1, int(self.size * alpha))
      
              if self.shape == 'circle':
                  bbox = [x - size, y - size, x + size, y + size]
                  draw.ellipse(bbox, fill=color)
              elif self.shape == 'square':
                  bbox = [x - size, y - size, x + size, y + size]
                  draw.rectangle(bbox, fill=color)
              elif self.shape == 'star':
                  # Simple 4-point star
                  points = [
                      (x, y - size),
                      (x - size // 2, y),
                      (x, y),
                      (x, y + size),
                      (x, y),
                      (x + size // 2, y),
                  ]
                  draw.line(points, fill=color, width=2)
      
      
      class ParticleSystem:
          """Manages a collection of particles."""
      
          def __init__(self):
              """Initialize particle system."""
              self.particles: list[Particle] = []
      
          def emit(self, x: int, y: int, count: int = 10,
                   spread: float = 2.0, speed: float = 5.0,
                   color: tuple[int, int, int] = (255, 200, 0),
                   lifetime: float = 20.0, size: int = 3, shape: str = 'circle'):
              """
              Emit a burst of particles.
      
              Args:
                  x, y: Emission position
                  count: Number of particles to emit
                  spread: Angle spread (radians)
                  speed: Initial speed
                  color: Particle color
                  lifetime: Particle lifetime in frames
                  size: Particle size
                  shape: Particle shape
              """
              for _ in range(count):
                  # Random angle and speed
                  angle = random.uniform(0, 2 * math.pi)
                  vel_mag = random.uniform(speed * 0.5, speed * 1.5)
                  vx = math.cos(angle) * vel_mag
                  vy = math.sin(angle) * vel_mag
      
                  # Random lifetime variation
                  life = random.uniform(lifetime * 0.7, lifetime * 1.3)
      
                  particle = Particle(x, y, vx, vy, life, color, size, shape)
                  self.particles.append(particle)
      
          def emit_confetti(self, x: int, y: int, count: int = 20,
                            colors: Optional[list[tuple[int, int, int]]] = None):
              """
              Emit confetti particles (colorful, falling).
      
              Args:
                  x, y: Emission position
                  count: Number of confetti pieces
                  colors: List of colors (random if None)
              """
              if colors is None:
                  colors = [
                      (255, 107, 107), (255, 159, 64), (255, 218, 121),
                      (107, 185, 240), (162, 155, 254), (255, 182, 193)
                  ]
      
              for _ in range(count):
                  color = random.choice(colors)
                  vx = random.uniform(-3, 3)
                  vy = random.uniform(-8, -2)
                  shape = random.choice(['square', 'circle'])
                  size = random.randint(2, 4)
                  lifetime = random.uniform(40, 60)
      
                  particle = Particle(x, y, vx, vy, lifetime, color, size, shape)
                  particle.gravity = 0.3  # Lighter gravity for confetti
                  self.particles.append(particle)
      
          def emit_sparkles(self, x: int, y: int, count: int = 15):
              """
              Emit sparkle particles (twinkling stars).
      
              Args:
                  x, y: Emission position
                  count: Number of sparkles
              """
              colors = [(255, 255, 200), (255, 255, 255), (255, 255, 150)]
      
              for _ in range(count):
                  color = random.choice(colors)
                  angle = random.uniform(0, 2 * math.pi)
                  speed = random.uniform(1, 3)
                  vx = math.cos(angle) * speed
                  vy = math.sin(angle) * speed
                  lifetime = random.uniform(15, 30)
      
                  particle = Particle(x, y, vx, vy, lifetime, color, 2, 'star')
                  particle.gravity = 0
                  particle.drag = 0.95
                  self.particles.append(particle)
      
          def update(self):
              """Update all particles."""
              # Update alive particles
              for particle in self.particles:
                  particle.update()
      
              # Remove dead particles
              self.particles = [p for p in self.particles if p.is_alive()]
      
          def render(self, frame: Image.Image):
              """Render all particles to frame."""
              for particle in self.particles:
                  particle.render(frame)
      
          def get_particle_count(self) -> int:
              """Get number of active particles."""
              return len(self.particles)
      
      
      def add_motion_blur(frame: Image.Image, prev_frame: Optional[Image.Image],
                          blur_amount: float = 0.5) -> Image.Image:
          """
          Add motion blur by blending with previous frame.
      
          Args:
              frame: Current frame
              prev_frame: Previous frame (None for first frame)
              blur_amount: Amount of blur (0.0-1.0)
      
          Returns:
              Frame with motion blur applied
          """
          if prev_frame is None:
              return frame
      
          # Blend current frame with previous frame
          frame_array = np.array(frame, dtype=np.float32)
          prev_array = np.array(prev_frame, dtype=np.float32)
      
          blended = frame_array * (1 - blur_amount) + prev_array * blur_amount
          blended = np.clip(blended, 0, 255).astype(np.uint8)
      
          return Image.fromarray(blended)
      
      
      def create_impact_flash(frame: Image.Image, position: tuple[int, int],
                              radius: int = 100, intensity: float = 0.7) -> Image.Image:
          """
          Create a bright flash effect at impact point.
      
          Args:
              frame: PIL Image to draw on
              position: Center of flash
              radius: Flash radius
              intensity: Flash intensity (0.0-1.0)
      
          Returns:
              Modified frame
          """
          # Create overlay
          overlay = Image.new('RGBA', frame.size, (0, 0, 0, 0))
          draw = ImageDraw.Draw(overlay)
      
          x, y = position
      
          # Draw concentric circles with decreasing opacity
          num_circles = 5
          for i in range(num_circles):
              alpha = int(255 * intensity * (1 - i / num_circles))
              r = radius * (1 - i / num_circles)
              color = (255, 255, 240, alpha)  # Warm white
      
              bbox = [x - r, y - r, x + r, y + r]
              draw.ellipse(bbox, fill=color)
      
          # Composite onto frame
          frame_rgba = frame.convert('RGBA')
          frame_rgba = Image.alpha_composite(frame_rgba, overlay)
          return frame_rgba.convert('RGB')
      
      
      def create_shockwave_rings(frame: Image.Image, position: tuple[int, int],
                                 radii: list[int], color: tuple[int, int, int] = (255, 200, 0),
                                 width: int = 3) -> Image.Image:
          """
          Create expanding ring effects.
      
          Args:
              frame: PIL Image to draw on
              position: Center of rings
              radii: List of ring radii
              color: Ring color
              width: Ring width
      
          Returns:
              Modified frame
          """
          draw = ImageDraw.Draw(frame)
          x, y = position
      
          for radius in radii:
              bbox = [x - radius, y - radius, x + radius, y + radius]
              draw.ellipse(bbox, outline=color, width=width)
      
          return frame
      
      
      def create_explosion_effect(frame: Image.Image, position: tuple[int, int],
                                  radius: int, progress: float,
                                  color: tuple[int, int, int] = (255, 150, 0)) -> Image.Image:
          """
          Create an explosion effect that expands and fades.
      
          Args:
              frame: PIL Image to draw on
              position: Explosion center
              radius: Maximum radius
              progress: Animation progress (0.0-1.0)
              color: Explosion color
      
          Returns:
              Modified frame
          """
          current_radius = int(radius * progress)
          fade = 1 - progress
      
          # Create overlay
          overlay = Image.new('RGBA', frame.size, (0, 0, 0, 0))
          draw = ImageDraw.Draw(overlay)
      
          x, y = position
      
          # Draw expanding circle with fade
          alpha = int(255 * fade)
          r, g, b = color
          circle_color = (r, g, b, alpha)
      
          bbox = [x - current_radius, y - current_radius, x + current_radius, y + current_radius]
          draw.ellipse(bbox, fill=circle_color)
      
          # Composite
          frame_rgba = frame.convert('RGBA')
          frame_rgba = Image.alpha_composite(frame_rgba, overlay)
          return frame_rgba.convert('RGB')
      
      
      def add_glow_effect(frame: Image.Image, mask_color: tuple[int, int, int],
                          glow_color: tuple[int, int, int],
                          blur_radius: int = 10) -> Image.Image:
          """
          Add a glow effect to areas of a specific color.
      
          Args:
              frame: PIL Image
              mask_color: Color to create glow around
              glow_color: Color of glow
              blur_radius: Blur amount
      
          Returns:
              Frame with glow
          """
          # Create mask of target color
          frame_array = np.array(frame)
          mask = np.all(frame_array == mask_color, axis=-1)
      
          # Create glow layer
          glow = Image.new('RGB', frame.size, (0, 0, 0))
          glow_array = np.array(glow)
          glow_array[mask] = glow_color
          glow = Image.fromarray(glow_array)
      
          # Blur the glow
          glow = glow.filter(ImageFilter.GaussianBlur(blur_radius))
      
          # Blend with original
          blended = Image.blend(frame, glow, 0.5)
          return blended
      
      
      def add_drop_shadow(frame: Image.Image, object_bounds: tuple[int, int, int, int],
                          shadow_offset: tuple[int, int] = (5, 5),
                          shadow_color: tuple[int, int, int] = (0, 0, 0),
                          blur: int = 5) -> Image.Image:
          """
          Add drop shadow to an object.
      
          Args:
              frame: PIL Image
              object_bounds: (x1, y1, x2, y2) bounds of object
              shadow_offset: (x, y) offset of shadow
              shadow_color: Shadow color
              blur: Shadow blur amount
      
          Returns:
              Frame with shadow
          """
          # Extract object
          x1, y1, x2, y2 = object_bounds
          obj = frame.crop((x1, y1, x2, y2))
      
          # Create shadow
          shadow = Image.new('RGBA', obj.size, (*shadow_color, 180))
      
          # Create frame with alpha
          frame_rgba = frame.convert('RGBA')
      
          # Paste shadow
          shadow_pos = (x1 + shadow_offset[0], y1 + shadow_offset[1])
          frame_rgba.paste(shadow, shadow_pos, shadow)
      
          # Paste object on top
          frame_rgba.paste(obj, (x1, y1))
      
          return frame_rgba.convert('RGB')
      
      
      def create_speed_lines(frame: Image.Image, position: tuple[int, int],
                             direction: float, length: int = 50,
                             count: int = 5, color: tuple[int, int, int] = (200, 200, 200)) -> Image.Image:
          """
          Create speed lines for motion effect.
      
          Args:
              frame: PIL Image to draw on
              position: Center position
              direction: Angle in radians (0 = right, pi/2 = down)
              length: Line length
              count: Number of lines
              color: Line color
      
          Returns:
              Modified frame
          """
          draw = ImageDraw.Draw(frame)
          x, y = position
      
          # Opposite direction (lines trail behind)
          trail_angle = direction + math.pi
      
          for i in range(count):
              # Offset from center
              offset_angle = trail_angle + random.uniform(-0.3, 0.3)
              offset_dist = random.uniform(10, 30)
              start_x = x + math.cos(offset_angle) * offset_dist
              start_y = y + math.sin(offset_angle) * offset_dist
      
              # End point
              line_length = random.uniform(length * 0.7, length * 1.3)
              end_x = start_x + math.cos(trail_angle) * line_length
              end_y = start_y + math.sin(trail_angle) * line_length
      
              # Draw line with varying opacity
              alpha = random.randint(100, 200)
              width = random.randint(1, 3)
      
              # Simple line (full opacity simulation)
              draw.line([(start_x, start_y), (end_x, end_y)], fill=color, width=width)
      
          return frame
      
      
      def create_screen_shake_offset(intensity: int, frame_index: int) -> tuple[int, int]:
          """
          Calculate screen shake offset for a frame.
      
          Args:
              intensity: Shake intensity in pixels
              frame_index: Current frame number
      
          Returns:
              (x, y) offset tuple
          """
          # Use frame index for deterministic but random-looking shake
          random.seed(frame_index)
          offset_x = random.randint(-intensity, intensity)
          offset_y = random.randint(-intensity, intensity)
          random.seed()  # Reset seed
          return (offset_x, offset_y)
      
      
      def apply_screen_shake(frame: Image.Image, intensity: int, frame_index: int) -> Image.Image:
          """
          Apply screen shake effect to entire frame.
      
          Args:
              frame: PIL Image
              intensity: Shake intensity
              frame_index: Current frame number
      
          Returns:
              Shaken frame
          """
          offset_x, offset_y = create_screen_shake_offset(intensity, frame_index)
      
          # Create new frame with background
          shaken = Image.new('RGB', frame.size, (0, 0, 0))
      
          # Paste original frame with offset
          shaken.paste(frame, (offset_x, offset_y))
      
          return shaken
  • templates
    • bounce.py 3 KB
      #!/usr/bin/env python3
      """
      Bounce Animation Template - Creates bouncing motion for objects.
      
      Use this to make objects bounce up and down or horizontally with realistic physics.
      """
      
      import sys
      from pathlib import Path
      
      # Add parent directory to path
      sys.path.append(str(Path(__file__).parent.parent))
      
      from core.gif_builder import GIFBuilder
      from core.frame_composer import create_blank_frame, draw_circle, draw_emoji
      from core.easing import ease_out_bounce, interpolate
      
      
      def create_bounce_animation(
          object_type: str = 'circle',
          object_data: dict = None,
          num_frames: int = 30,
          bounce_height: int = 150,
          ground_y: int = 350,
          start_x: int = 240,
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list:
          """
          Create frames for a bouncing animation.
      
          Args:
              object_type: 'circle', 'emoji', or 'custom'
              object_data: Data for the object (e.g., {'radius': 30, 'color': (255, 0, 0)})
              num_frames: Number of frames in the animation
              bounce_height: Maximum height of bounce
              ground_y: Y position of ground
              start_x: X position (or starting X if moving horizontally)
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          frames = []
      
          # Default object data
          if object_data is None:
              if object_type == 'circle':
                  object_data = {'radius': 30, 'color': (255, 100, 100)}
              elif object_type == 'emoji':
                  object_data = {'emoji': '⚽', 'size': 60}
      
          for i in range(num_frames):
              # Create blank frame
              frame = create_blank_frame(frame_width, frame_height, bg_color)
      
              # Calculate progress (0.0 to 1.0)
              t = i / (num_frames - 1) if num_frames > 1 else 0
      
              # Calculate Y position using bounce easing
              y = ground_y - int(ease_out_bounce(t) * bounce_height)
      
              # Draw object
              if object_type == 'circle':
                  draw_circle(
                      frame,
                      center=(start_x, y),
                      radius=object_data['radius'],
                      fill_color=object_data['color']
                  )
              elif object_type == 'emoji':
                  draw_emoji(
                      frame,
                      emoji=object_data['emoji'],
                      position=(start_x - object_data['size'] // 2, y - object_data['size'] // 2),
                      size=object_data['size']
                  )
      
              frames.append(frame)
      
          return frames
      
      
      # Example usage
      if __name__ == '__main__':
          print("Creating bouncing ball GIF...")
      
          # Create GIF builder
          builder = GIFBuilder(width=480, height=480, fps=20)
      
          # Generate bounce animation
          frames = create_bounce_animation(
              object_type='circle',
              object_data={'radius': 40, 'color': (255, 100, 100)},
              num_frames=40,
              bounce_height=200
          )
      
          # Add frames to builder
          builder.add_frames(frames)
      
          # Save GIF
          builder.save('bounce_test.gif', num_colors=64)
    • explode.py 11 KB
      #!/usr/bin/env python3
      """
      Explode Animation - Break objects into pieces that fly outward.
      
      Creates explosion, shatter, and particle burst effects.
      """
      
      import sys
      from pathlib import Path
      import math
      import random
      
      sys.path.append(str(Path(__file__).parent.parent))
      
      from PIL import Image, ImageDraw
      import numpy as np
      from core.gif_builder import GIFBuilder
      from core.frame_composer import create_blank_frame, draw_emoji_enhanced
      from core.visual_effects import ParticleSystem
      from core.easing import interpolate
      
      
      def create_explode_animation(
          object_type: str = 'emoji',
          object_data: dict | None = None,
          num_frames: int = 30,
          explode_type: str = 'burst',  # 'burst', 'shatter', 'dissolve', 'implode'
          num_pieces: int = 20,
          explosion_speed: float = 5.0,
          center_pos: tuple[int, int] = (240, 240),
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list[Image.Image]:
          """
          Create explosion animation.
      
          Args:
              object_type: 'emoji', 'circle', 'text'
              object_data: Object configuration
              num_frames: Number of frames
              explode_type: Type of explosion
              num_pieces: Number of pieces/particles
              explosion_speed: Speed of explosion
              center_pos: Center position
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          frames = []
      
          # Default object data
          if object_data is None:
              if object_type == 'emoji':
                  object_data = {'emoji': '💣', 'size': 100}
      
          # Generate pieces/particles
          pieces = []
          for _ in range(num_pieces):
              angle = random.uniform(0, 2 * math.pi)
              speed = random.uniform(explosion_speed * 0.5, explosion_speed * 1.5)
              vx = math.cos(angle) * speed
              vy = math.sin(angle) * speed
              size = random.randint(3, 12)
              color = (
                  random.randint(100, 255),
                  random.randint(100, 255),
                  random.randint(100, 255)
              )
              rotation_speed = random.uniform(-20, 20)
      
              pieces.append({
                  'vx': vx,
                  'vy': vy,
                  'size': size,
                  'color': color,
                  'rotation': 0,
                  'rotation_speed': rotation_speed
              })
      
          for i in range(num_frames):
              t = i / (num_frames - 1) if num_frames > 1 else 0
              frame = create_blank_frame(frame_width, frame_height, bg_color)
              draw = ImageDraw.Draw(frame)
      
              if explode_type == 'burst':
                  # Show object at start, then explode
                  if t < 0.2:
                      # Object still intact
                      scale = interpolate(1.0, 1.2, t / 0.2, 'ease_out')
                      if object_type == 'emoji':
                          size = int(object_data['size'] * scale)
                          draw_emoji_enhanced(
                              frame,
                              emoji=object_data['emoji'],
                              position=(center_pos[0] - size // 2, center_pos[1] - size // 2),
                              size=size,
                              shadow=False
                          )
                  else:
                      # Exploded - draw pieces
                      explosion_t = (t - 0.2) / 0.8
                      for piece in pieces:
                          # Update position
                          x = center_pos[0] + piece['vx'] * explosion_t * 50
                          y = center_pos[1] + piece['vy'] * explosion_t * 50 + 0.5 * 300 * explosion_t ** 2  # Gravity
      
                          # Fade out
                          alpha = 1.0 - explosion_t
                          if alpha > 0:
                              color = tuple(int(c * alpha) for c in piece['color'])
                              size = int(piece['size'] * (1 - explosion_t * 0.5))
      
                              draw.ellipse(
                                  [x - size, y - size, x + size, y + size],
                                  fill=color
                              )
      
              elif explode_type == 'shatter':
                  # Break into geometric pieces
                  if t < 0.15:
                      # Object intact
                      if object_type == 'emoji':
                          draw_emoji_enhanced(
                              frame,
                              emoji=object_data['emoji'],
                              position=(center_pos[0] - object_data['size'] // 2,
                                      center_pos[1] - object_data['size'] // 2),
                              size=object_data['size'],
                              shadow=False
                          )
                  else:
                      # Shattered
                      shatter_t = (t - 0.15) / 0.85
      
                      # Draw triangular shards
                      for piece in pieces[:min(10, len(pieces))]:
                          x = center_pos[0] + piece['vx'] * shatter_t * 30
                          y = center_pos[1] + piece['vy'] * shatter_t * 30 + 0.5 * 200 * shatter_t ** 2
      
                          # Update rotation
                          rotation = piece['rotation_speed'] * shatter_t * 100
      
                          # Draw triangle shard
                          shard_size = piece['size'] * 2
                          points = []
                          for j in range(3):
                              angle = (rotation + j * 120) * math.pi / 180
                              px = x + shard_size * math.cos(angle)
                              py = y + shard_size * math.sin(angle)
                              points.append((px, py))
      
                          alpha = 1.0 - shatter_t
                          if alpha > 0:
                              color = tuple(int(c * alpha) for c in piece['color'])
                              draw.polygon(points, fill=color)
      
              elif explode_type == 'dissolve':
                  # Dissolve into particles
                  dissolve_scale = interpolate(1.0, 0.0, t, 'ease_in')
      
                  if dissolve_scale > 0.1:
                      # Draw fading object
                      if object_type == 'emoji':
                          size = int(object_data['size'] * dissolve_scale)
                          size = max(12, size)
      
                          emoji_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
                          draw_emoji_enhanced(
                              emoji_canvas,
                              emoji=object_data['emoji'],
                              position=(center_pos[0] - size // 2, center_pos[1] - size // 2),
                              size=size,
                              shadow=False
                          )
      
                          # Apply opacity
                          from templates.fade import apply_opacity
                          emoji_canvas = apply_opacity(emoji_canvas, dissolve_scale)
      
                          frame_rgba = frame.convert('RGBA')
                          frame = Image.alpha_composite(frame_rgba, emoji_canvas)
                          frame = frame.convert('RGB')
                          draw = ImageDraw.Draw(frame)
      
                  # Draw outward-moving particles
                  for piece in pieces:
                      x = center_pos[0] + piece['vx'] * t * 40
                      y = center_pos[1] + piece['vy'] * t * 40
      
                      alpha = 1.0 - t
                      if alpha > 0:
                          color = tuple(int(c * alpha) for c in piece['color'])
                          size = int(piece['size'] * (1 - t * 0.5))
                          draw.ellipse(
                              [x - size, y - size, x + size, y + size],
                              fill=color
                          )
      
              elif explode_type == 'implode':
                  # Reverse explosion - pieces fly inward
                  if t < 0.7:
                      # Pieces converging
                      implode_t = 1.0 - (t / 0.7)
                      for piece in pieces:
                          x = center_pos[0] + piece['vx'] * implode_t * 50
                          y = center_pos[1] + piece['vy'] * implode_t * 50
      
                          alpha = 1.0 - (1.0 - implode_t) * 0.5
                          color = tuple(int(c * alpha) for c in piece['color'])
                          size = int(piece['size'] * alpha)
      
                          draw.ellipse(
                              [x - size, y - size, x + size, y + size],
                              fill=color
                          )
                  else:
                      # Object reforms
                      reform_t = (t - 0.7) / 0.3
                      scale = interpolate(0.5, 1.0, reform_t, 'elastic_out')
      
                      if object_type == 'emoji':
                          size = int(object_data['size'] * scale)
                          draw_emoji_enhanced(
                              frame,
                              emoji=object_data['emoji'],
                              position=(center_pos[0] - size // 2, center_pos[1] - size // 2),
                              size=size,
                              shadow=False
                          )
      
              frames.append(frame)
      
          return frames
      
      
      def create_particle_burst(
          num_frames: int = 25,
          particle_count: int = 30,
          center_pos: tuple[int, int] = (240, 240),
          colors: list[tuple[int, int, int]] | None = None,
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list[Image.Image]:
          """
          Create simple particle burst effect.
      
          Args:
              num_frames: Number of frames
              particle_count: Number of particles
              center_pos: Burst center
              colors: Particle colors (None for random)
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          particles = ParticleSystem()
      
          # Emit particles
          if colors is None:
              from core.color_palettes import get_palette
              palette = get_palette('vibrant')
              colors = [palette['primary'], palette['secondary'], palette['accent']]
      
          for _ in range(particle_count):
              color = random.choice(colors)
              particles.emit(
                  center_pos[0], center_pos[1],
                  count=1,
                  speed=random.uniform(3, 8),
                  color=color,
                  lifetime=random.uniform(20, 30),
                  size=random.randint(3, 8),
                  shape='star'
              )
      
          frames = []
          for _ in range(num_frames):
              frame = create_blank_frame(frame_width, frame_height, bg_color)
      
              particles.update()
              particles.render(frame)
      
              frames.append(frame)
      
          return frames
      
      
      # Example usage
      if __name__ == '__main__':
          print("Creating explode animations...")
      
          builder = GIFBuilder(width=480, height=480, fps=20)
      
          # Example 1: Burst
          frames = create_explode_animation(
              object_type='emoji',
              object_data={'emoji': '💣', 'size': 100},
              num_frames=30,
              explode_type='burst',
              num_pieces=25
          )
          builder.add_frames(frames)
          builder.save('explode_burst.gif', num_colors=128)
      
          # Example 2: Shatter
          builder.clear()
          frames = create_explode_animation(
              object_type='emoji',
              object_data={'emoji': '🪟', 'size': 100},
              num_frames=30,
              explode_type='shatter',
              num_pieces=12
          )
          builder.add_frames(frames)
          builder.save('explode_shatter.gif', num_colors=128)
      
          # Example 3: Particle burst
          builder.clear()
          frames = create_particle_burst(num_frames=25, particle_count=40)
          builder.add_frames(frames)
          builder.save('explode_particles.gif', num_colors=128)
      
          print("Created explode animations!")
      
    • fade.py 9.9 KB
      #!/usr/bin/env python3
      """
      Fade Animation - Fade in, fade out, and crossfade effects.
      
      Creates smooth opacity transitions for appearing, disappearing, and transitioning.
      """
      
      import sys
      from pathlib import Path
      
      sys.path.append(str(Path(__file__).parent.parent))
      
      from PIL import Image, ImageDraw
      import numpy as np
      from core.gif_builder import GIFBuilder
      from core.frame_composer import create_blank_frame, draw_emoji_enhanced
      from core.easing import interpolate
      
      
      def create_fade_animation(
          object_type: str = 'emoji',
          object_data: dict | None = None,
          num_frames: int = 30,
          fade_type: str = 'in',  # 'in', 'out', 'in_out', 'blink'
          easing: str = 'ease_in_out',
          center_pos: tuple[int, int] = (240, 240),
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list[Image.Image]:
          """
          Create fade animation.
      
          Args:
              object_type: 'emoji', 'text', 'image'
              object_data: Object configuration
              num_frames: Number of frames
              fade_type: Type of fade effect
              easing: Easing function
              center_pos: Center position
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          frames = []
      
          # Default object data
          if object_data is None:
              if object_type == 'emoji':
                  object_data = {'emoji': '✨', 'size': 100}
      
          for i in range(num_frames):
              t = i / (num_frames - 1) if num_frames > 1 else 0
      
              # Calculate opacity based on fade type
              if fade_type == 'in':
                  opacity = interpolate(0, 1, t, easing)
              elif fade_type == 'out':
                  opacity = interpolate(1, 0, t, easing)
              elif fade_type == 'in_out':
                  if t < 0.5:
                      opacity = interpolate(0, 1, t * 2, easing)
                  else:
                      opacity = interpolate(1, 0, (t - 0.5) * 2, easing)
              elif fade_type == 'blink':
                  # Quick fade out and back in
                  if t < 0.2:
                      opacity = interpolate(1, 0, t / 0.2, 'ease_in')
                  elif t < 0.4:
                      opacity = interpolate(0, 1, (t - 0.2) / 0.2, 'ease_out')
                  else:
                      opacity = 1.0
              else:
                  opacity = interpolate(0, 1, t, easing)
      
              # Create background
              frame_bg = create_blank_frame(frame_width, frame_height, bg_color)
      
              # Create object layer with transparency
              if object_type == 'emoji':
                  # Create RGBA canvas for emoji
                  emoji_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
                  emoji_size = object_data['size']
                  draw_emoji_enhanced(
                      emoji_canvas,
                      emoji=object_data['emoji'],
                      position=(center_pos[0] - emoji_size // 2, center_pos[1] - emoji_size // 2),
                      size=emoji_size,
                      shadow=object_data.get('shadow', False)
                  )
      
                  # Apply opacity
                  emoji_canvas = apply_opacity(emoji_canvas, opacity)
      
                  # Composite onto background
                  frame_bg_rgba = frame_bg.convert('RGBA')
                  frame = Image.alpha_composite(frame_bg_rgba, emoji_canvas)
                  frame = frame.convert('RGB')
      
              elif object_type == 'text':
                  from core.typography import draw_text_with_outline
      
                  # Create text on separate layer
                  text_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
                  text_canvas_rgb = text_canvas.convert('RGB')
                  text_canvas_rgb.paste(bg_color, (0, 0, frame_width, frame_height))
      
                  draw_text_with_outline(
                      text_canvas_rgb,
                      text=object_data.get('text', 'FADE'),
                      position=center_pos,
                      font_size=object_data.get('font_size', 60),
                      text_color=object_data.get('text_color', (0, 0, 0)),
                      outline_color=object_data.get('outline_color', (255, 255, 255)),
                      outline_width=3,
                      centered=True
                  )
      
                  # Convert to RGBA and make background transparent
                  text_canvas = text_canvas_rgb.convert('RGBA')
                  data = text_canvas.getdata()
                  new_data = []
                  for item in data:
                      if item[:3] == bg_color:
                          new_data.append((255, 255, 255, 0))
                      else:
                          new_data.append(item)
                  text_canvas.putdata(new_data)
      
                  # Apply opacity
                  text_canvas = apply_opacity(text_canvas, opacity)
      
                  # Composite
                  frame_bg_rgba = frame_bg.convert('RGBA')
                  frame = Image.alpha_composite(frame_bg_rgba, text_canvas)
                  frame = frame.convert('RGB')
      
              else:
                  frame = frame_bg
      
              frames.append(frame)
      
          return frames
      
      
      def apply_opacity(image: Image.Image, opacity: float) -> Image.Image:
          """
          Apply opacity to an RGBA image.
      
          Args:
              image: RGBA image
              opacity: Opacity value (0.0 to 1.0)
      
          Returns:
              Image with adjusted opacity
          """
          if image.mode != 'RGBA':
              image = image.convert('RGBA')
      
          # Get alpha channel
          r, g, b, a = image.split()
      
          # Multiply alpha by opacity
          a_array = np.array(a, dtype=np.float32)
          a_array = a_array * opacity
          a = Image.fromarray(a_array.astype(np.uint8))
      
          # Merge back
          return Image.merge('RGBA', (r, g, b, a))
      
      
      def create_crossfade(
          object1_data: dict,
          object2_data: dict,
          num_frames: int = 30,
          easing: str = 'ease_in_out',
          object_type: str = 'emoji',
          center_pos: tuple[int, int] = (240, 240),
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list[Image.Image]:
          """
          Crossfade between two objects.
      
          Args:
              object1_data: First object configuration
              object2_data: Second object configuration
              num_frames: Number of frames
              easing: Easing function
              object_type: Type of objects
              center_pos: Center position
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          frames = []
      
          for i in range(num_frames):
              t = i / (num_frames - 1) if num_frames > 1 else 0
      
              # Calculate opacities
              opacity1 = interpolate(1, 0, t, easing)
              opacity2 = interpolate(0, 1, t, easing)
      
              # Create background
              frame = create_blank_frame(frame_width, frame_height, bg_color)
      
              if object_type == 'emoji':
                  # Create first emoji
                  emoji1_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
                  size1 = object1_data['size']
                  draw_emoji_enhanced(
                      emoji1_canvas,
                      emoji=object1_data['emoji'],
                      position=(center_pos[0] - size1 // 2, center_pos[1] - size1 // 2),
                      size=size1,
                      shadow=False
                  )
                  emoji1_canvas = apply_opacity(emoji1_canvas, opacity1)
      
                  # Create second emoji
                  emoji2_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
                  size2 = object2_data['size']
                  draw_emoji_enhanced(
                      emoji2_canvas,
                      emoji=object2_data['emoji'],
                      position=(center_pos[0] - size2 // 2, center_pos[1] - size2 // 2),
                      size=size2,
                      shadow=False
                  )
                  emoji2_canvas = apply_opacity(emoji2_canvas, opacity2)
      
                  # Composite both
                  frame_rgba = frame.convert('RGBA')
                  frame_rgba = Image.alpha_composite(frame_rgba, emoji1_canvas)
                  frame_rgba = Image.alpha_composite(frame_rgba, emoji2_canvas)
                  frame = frame_rgba.convert('RGB')
      
              frames.append(frame)
      
          return frames
      
      
      def create_fade_to_color(
          start_color: tuple[int, int, int],
          end_color: tuple[int, int, int],
          num_frames: int = 20,
          easing: str = 'linear',
          frame_width: int = 480,
          frame_height: int = 480
      ) -> list[Image.Image]:
          """
          Fade from one solid color to another.
      
          Args:
              start_color: Starting RGB color
              end_color: Ending RGB color
              num_frames: Number of frames
              easing: Easing function
              frame_width: Frame width
              frame_height: Frame height
      
          Returns:
              List of frames
          """
          frames = []
      
          for i in range(num_frames):
              t = i / (num_frames - 1) if num_frames > 1 else 0
      
              # Interpolate each color channel
              r = int(interpolate(start_color[0], end_color[0], t, easing))
              g = int(interpolate(start_color[1], end_color[1], t, easing))
              b = int(interpolate(start_color[2], end_color[2], t, easing))
      
              color = (r, g, b)
              frame = create_blank_frame(frame_width, frame_height, color)
              frames.append(frame)
      
          return frames
      
      
      # Example usage
      if __name__ == '__main__':
          print("Creating fade animations...")
      
          builder = GIFBuilder(width=480, height=480, fps=20)
      
          # Example 1: Fade in
          frames = create_fade_animation(
              object_type='emoji',
              object_data={'emoji': '✨', 'size': 120},
              num_frames=30,
              fade_type='in',
              easing='ease_out'
          )
          builder.add_frames(frames)
          builder.save('fade_in.gif', num_colors=128)
      
          # Example 2: Crossfade
          builder.clear()
          frames = create_crossfade(
              object1_data={'emoji': '😊', 'size': 100},
              object2_data={'emoji': '😂', 'size': 100},
              num_frames=30,
              object_type='emoji'
          )
          builder.add_frames(frames)
          builder.save('fade_crossfade.gif', num_colors=128)
      
          # Example 3: Blink
          builder.clear()
          frames = create_fade_animation(
              object_type='emoji',
              object_data={'emoji': '👀', 'size': 100},
              num_frames=20,
              fade_type='blink'
          )
          builder.add_frames(frames)
          builder.save('fade_blink.gif', num_colors=128)
      
          print("Created fade animations!")
      
    • flip.py 9.2 KB
      #!/usr/bin/env python3
      """
      Flip Animation - 3D-style card flip and rotation effects.
      
      Creates horizontal and vertical flips with perspective.
      """
      
      import sys
      from pathlib import Path
      import math
      
      sys.path.append(str(Path(__file__).parent.parent))
      
      from PIL import Image
      from core.gif_builder import GIFBuilder
      from core.frame_composer import create_blank_frame, draw_emoji_enhanced
      from core.easing import interpolate
      
      
      def create_flip_animation(
          object1_data: dict,
          object2_data: dict | None = None,
          num_frames: int = 30,
          flip_axis: str = 'horizontal',  # 'horizontal', 'vertical'
          easing: str = 'ease_in_out',
          object_type: str = 'emoji',
          center_pos: tuple[int, int] = (240, 240),
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list[Image.Image]:
          """
          Create 3D-style flip animation.
      
          Args:
              object1_data: First object (front side)
              object2_data: Second object (back side, None = same as front)
              num_frames: Number of frames
              flip_axis: Axis to flip around
              easing: Easing function
              object_type: Type of objects
              center_pos: Center position
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          frames = []
      
          if object2_data is None:
              object2_data = object1_data
      
          for i in range(num_frames):
              t = i / (num_frames - 1) if num_frames > 1 else 0
              frame = create_blank_frame(frame_width, frame_height, bg_color)
      
              # Calculate rotation angle (0 to 180 degrees)
              angle = interpolate(0, 180, t, easing)
      
              # Determine which side is visible and calculate scale
              if angle < 90:
                  # Front side visible
                  current_object = object1_data
                  scale_factor = math.cos(math.radians(angle))
              else:
                  # Back side visible
                  current_object = object2_data
                  scale_factor = abs(math.cos(math.radians(angle)))
      
              # Don't draw when edge-on (very thin)
              if scale_factor < 0.05:
                  frames.append(frame)
                  continue
      
              if object_type == 'emoji':
                  size = current_object['size']
      
                  # Create emoji on canvas
                  canvas_size = size * 2
                  emoji_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
      
                  draw_emoji_enhanced(
                      emoji_canvas,
                      emoji=current_object['emoji'],
                      position=(canvas_size // 2 - size // 2, canvas_size // 2 - size // 2),
                      size=size,
                      shadow=False
                  )
      
                  # Apply flip scaling
                  if flip_axis == 'horizontal':
                      # Scale horizontally for horizontal flip
                      new_width = max(1, int(canvas_size * scale_factor))
                      new_height = canvas_size
                  else:
                      # Scale vertically for vertical flip
                      new_width = canvas_size
                      new_height = max(1, int(canvas_size * scale_factor))
      
                  # Resize to simulate 3D rotation
                  emoji_scaled = emoji_canvas.resize((new_width, new_height), Image.LANCZOS)
      
                  # Position centered
                  paste_x = center_pos[0] - new_width // 2
                  paste_y = center_pos[1] - new_height // 2
      
                  # Composite onto frame
                  frame_rgba = frame.convert('RGBA')
                  frame_rgba.paste(emoji_scaled, (paste_x, paste_y), emoji_scaled)
                  frame = frame_rgba.convert('RGB')
      
              elif object_type == 'text':
                  from core.typography import draw_text_with_outline
      
                  # Create text on canvas
                  text = current_object.get('text', 'FLIP')
                  font_size = current_object.get('font_size', 50)
      
                  canvas_size = max(frame_width, frame_height)
                  text_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
      
                  # Draw on RGB for text rendering
                  text_canvas_rgb = text_canvas.convert('RGB')
                  text_canvas_rgb.paste(bg_color, (0, 0, canvas_size, canvas_size))
      
                  draw_text_with_outline(
                      text_canvas_rgb,
                      text=text,
                      position=(canvas_size // 2, canvas_size // 2),
                      font_size=font_size,
                      text_color=current_object.get('text_color', (0, 0, 0)),
                      outline_color=current_object.get('outline_color', (255, 255, 255)),
                      outline_width=3,
                      centered=True
                  )
      
                  # Make background transparent
                  text_canvas = text_canvas_rgb.convert('RGBA')
                  data = text_canvas.getdata()
                  new_data = []
                  for item in data:
                      if item[:3] == bg_color:
                          new_data.append((255, 255, 255, 0))
                      else:
                          new_data.append(item)
                  text_canvas.putdata(new_data)
      
                  # Apply flip scaling
                  if flip_axis == 'horizontal':
                      new_width = max(1, int(canvas_size * scale_factor))
                      new_height = canvas_size
                  else:
                      new_width = canvas_size
                      new_height = max(1, int(canvas_size * scale_factor))
      
                  text_scaled = text_canvas.resize((new_width, new_height), Image.LANCZOS)
      
                  # Center and crop
                  if flip_axis == 'horizontal':
                      left = (new_width - frame_width) // 2 if new_width > frame_width else 0
                      top = (canvas_size - frame_height) // 2
                      paste_x = center_pos[0] - min(new_width, frame_width) // 2
                      paste_y = 0
      
                      text_cropped = text_scaled.crop((
                          left,
                          top,
                          left + min(new_width, frame_width),
                          top + frame_height
                      ))
                  else:
                      left = (canvas_size - frame_width) // 2
                      top = (new_height - frame_height) // 2 if new_height > frame_height else 0
                      paste_x = 0
                      paste_y = center_pos[1] - min(new_height, frame_height) // 2
      
                      text_cropped = text_scaled.crop((
                          left,
                          top,
                          left + frame_width,
                          top + min(new_height, frame_height)
                      ))
      
                  frame_rgba = frame.convert('RGBA')
                  frame_rgba.paste(text_cropped, (paste_x, paste_y), text_cropped)
                  frame = frame_rgba.convert('RGB')
      
              frames.append(frame)
      
          return frames
      
      
      def create_quick_flip(
          emoji_front: str,
          emoji_back: str,
          num_frames: int = 20,
          frame_size: int = 128
      ) -> list[Image.Image]:
          """
          Create quick flip for emoji GIFs.
      
          Args:
              emoji_front: Front emoji
              emoji_back: Back emoji
              num_frames: Number of frames
              frame_size: Frame size (square)
      
          Returns:
              List of frames
          """
          return create_flip_animation(
              object1_data={'emoji': emoji_front, 'size': 80},
              object2_data={'emoji': emoji_back, 'size': 80},
              num_frames=num_frames,
              flip_axis='horizontal',
              easing='ease_in_out',
              object_type='emoji',
              center_pos=(frame_size // 2, frame_size // 2),
              frame_width=frame_size,
              frame_height=frame_size,
              bg_color=(255, 255, 255)
          )
      
      
      def create_nope_flip(
          num_frames: int = 25,
          frame_width: int = 480,
          frame_height: int = 480
      ) -> list[Image.Image]:
          """
          Create "nope" reaction flip (like flipping table).
      
          Args:
              num_frames: Number of frames
              frame_width: Frame width
              frame_height: Frame height
      
          Returns:
              List of frames
          """
          return create_flip_animation(
              object1_data={'text': 'NOPE', 'font_size': 80, 'text_color': (255, 50, 50)},
              object2_data={'text': 'NOPE', 'font_size': 80, 'text_color': (255, 50, 50)},
              num_frames=num_frames,
              flip_axis='horizontal',
              easing='ease_out',
              object_type='text',
              frame_width=frame_width,
              frame_height=frame_height,
              bg_color=(255, 255, 255)
          )
      
      
      # Example usage
      if __name__ == '__main__':
          print("Creating flip animations...")
      
          builder = GIFBuilder(width=480, height=480, fps=20)
      
          # Example 1: Emoji flip
          frames = create_flip_animation(
              object1_data={'emoji': '😊', 'size': 120},
              object2_data={'emoji': '😂', 'size': 120},
              num_frames=30,
              flip_axis='horizontal',
              object_type='emoji'
          )
          builder.add_frames(frames)
          builder.save('flip_emoji.gif', num_colors=128)
      
          # Example 2: Text flip
          builder.clear()
          frames = create_flip_animation(
              object1_data={'text': 'YES', 'font_size': 80, 'text_color': (100, 200, 100)},
              object2_data={'text': 'NO', 'font_size': 80, 'text_color': (200, 100, 100)},
              num_frames=30,
              flip_axis='vertical',
              object_type='text'
          )
          builder.add_frames(frames)
          builder.save('flip_text.gif', num_colors=128)
      
          # Example 3: Quick flip (emoji size)
          builder = GIFBuilder(width=128, height=128, fps=15)
          frames = create_quick_flip('👍', '👎', num_frames=20)
          builder.add_frames(frames)
          builder.save('flip_quick.gif', num_colors=48, optimize_for_emoji=True)
      
          print("Created flip animations!")
      
    • kaleidoscope.py 6.2 KB
      #!/usr/bin/env python3
      """
      Kaleidoscope Effect - Create mirror/rotation effects.
      
      Apply kaleidoscope effects to frames or objects for psychedelic visuals.
      """
      
      import sys
      from pathlib import Path
      import math
      
      sys.path.append(str(Path(__file__).parent.parent))
      
      from PIL import Image, ImageOps, ImageDraw
      import numpy as np
      
      
      def apply_kaleidoscope(frame: Image.Image, segments: int = 8,
                             center: tuple[int, int] | None = None) -> Image.Image:
          """
          Apply kaleidoscope effect by mirroring/rotating frame sections.
      
          Args:
              frame: Input frame
              segments: Number of mirror segments (4, 6, 8, 12 work well)
              center: Center point for effect (None = frame center)
      
          Returns:
              Frame with kaleidoscope effect
          """
          width, height = frame.size
      
          if center is None:
              center = (width // 2, height // 2)
      
          # Create output frame
          output = Image.new('RGB', (width, height))
      
          # Calculate angle per segment
          angle_per_segment = 360 / segments
      
          # For simplicity, we'll create a radial mirror effect
          # A full implementation would rotate and mirror properly
          # This is a simplified version that creates interesting patterns
      
          # Convert to numpy for easier manipulation
          frame_array = np.array(frame)
          output_array = np.zeros_like(frame_array)
      
          center_x, center_y = center
      
          # Create wedge mask and mirror it
          for y in range(height):
              for x in range(width):
                  # Calculate angle from center
                  dx = x - center_x
                  dy = y - center_y
      
                  angle = (math.degrees(math.atan2(dy, dx)) + 180) % 360
                  distance = math.sqrt(dx * dx + dy * dy)
      
                  # Which segment does this pixel belong to?
                  segment = int(angle / angle_per_segment)
      
                  # Mirror angle within segment
                  segment_angle = angle % angle_per_segment
                  if segment % 2 == 1:  # Mirror every other segment
                      segment_angle = angle_per_segment - segment_angle
      
                  # Calculate source position
                  source_angle = segment_angle + (segment // 2) * angle_per_segment * 2
                  source_angle_rad = math.radians(source_angle - 180)
      
                  source_x = int(center_x + distance * math.cos(source_angle_rad))
                  source_y = int(center_y + distance * math.sin(source_angle_rad))
      
                  # Bounds check
                  if 0 <= source_x < width and 0 <= source_y < height:
                      output_array[y, x] = frame_array[source_y, source_x]
                  else:
                      output_array[y, x] = frame_array[y, x]
      
          return Image.fromarray(output_array)
      
      
      def apply_simple_mirror(frame: Image.Image, mode: str = 'quad') -> Image.Image:
          """
          Apply simple mirror effect (faster than full kaleidoscope).
      
          Args:
              frame: Input frame
              mode: 'horizontal', 'vertical', 'quad' (4-way), 'radial'
      
          Returns:
              Mirrored frame
          """
          width, height = frame.size
          center_x, center_y = width // 2, height // 2
      
          if mode == 'horizontal':
              # Mirror left half to right
              left_half = frame.crop((0, 0, center_x, height))
              left_flipped = ImageOps.mirror(left_half)
              result = frame.copy()
              result.paste(left_flipped, (center_x, 0))
              return result
      
          elif mode == 'vertical':
              # Mirror top half to bottom
              top_half = frame.crop((0, 0, width, center_y))
              top_flipped = ImageOps.flip(top_half)
              result = frame.copy()
              result.paste(top_flipped, (0, center_y))
              return result
      
          elif mode == 'quad':
              # 4-way mirror (top-left quadrant mirrored to all)
              quad = frame.crop((0, 0, center_x, center_y))
      
              result = Image.new('RGB', (width, height))
      
              # Top-left (original)
              result.paste(quad, (0, 0))
      
              # Top-right (horizontal mirror)
              result.paste(ImageOps.mirror(quad), (center_x, 0))
      
              # Bottom-left (vertical mirror)
              result.paste(ImageOps.flip(quad), (0, center_y))
      
              # Bottom-right (both mirrors)
              result.paste(ImageOps.flip(ImageOps.mirror(quad)), (center_x, center_y))
      
              return result
      
          else:
              return frame
      
      
      def create_kaleidoscope_animation(
          base_frame: Image.Image | None = None,
          num_frames: int = 30,
          segments: int = 8,
          rotation_speed: float = 1.0,
          width: int = 480,
          height: int = 480
      ) -> list[Image.Image]:
          """
          Create animated kaleidoscope effect.
      
          Args:
              base_frame: Frame to apply effect to (or None for demo pattern)
              num_frames: Number of frames
              segments: Kaleidoscope segments
              rotation_speed: How fast pattern rotates (0.5-2.0)
              width: Frame width if generating demo
              height: Frame height if generating demo
      
          Returns:
              List of frames with kaleidoscope effect
          """
          frames = []
      
          # Create demo pattern if no base frame
          if base_frame is None:
              base_frame = Image.new('RGB', (width, height), (255, 255, 255))
              draw = ImageDraw.Draw(base_frame)
      
              # Draw some colored shapes
              from core.color_palettes import get_palette
              palette = get_palette('vibrant')
      
              colors = [palette['primary'], palette['secondary'], palette['accent']]
      
              for i, color in enumerate(colors):
                  x = width // 2 + int(100 * math.cos(i * 2 * math.pi / 3))
                  y = height // 2 + int(100 * math.sin(i * 2 * math.pi / 3))
                  draw.ellipse([x - 40, y - 40, x + 40, y + 40], fill=color)
      
          # Rotate base frame and apply kaleidoscope
          for i in range(num_frames):
              angle = (i / num_frames) * 360 * rotation_speed
      
              # Rotate base frame
              rotated = base_frame.rotate(angle, resample=Image.BICUBIC)
      
              # Apply kaleidoscope
              kaleido_frame = apply_kaleidoscope(rotated, segments=segments)
      
              frames.append(kaleido_frame)
      
          return frames
      
      
      # Example usage
      if __name__ == '__main__':
          from core.gif_builder import GIFBuilder
      
          print("Creating kaleidoscope GIF...")
      
          builder = GIFBuilder(width=480, height=480, fps=20)
      
          # Create kaleidoscope animation
          frames = create_kaleidoscope_animation(
              num_frames=40,
              segments=8,
              rotation_speed=0.5
          )
      
          builder.add_frames(frames)
          builder.save('kaleidoscope_test.gif', num_colors=128)
      
    • morph.py 11 KB
      #!/usr/bin/env python3
      """
      Morph Animation - Transform between different emojis or shapes.
      
      Creates smooth transitions and transformations.
      """
      
      import sys
      from pathlib import Path
      
      sys.path.append(str(Path(__file__).parent.parent))
      
      from PIL import Image
      import numpy as np
      from core.gif_builder import GIFBuilder
      from core.frame_composer import create_blank_frame, draw_emoji_enhanced, draw_circle
      from core.easing import interpolate
      
      
      def create_morph_animation(
          object1_data: dict,
          object2_data: dict,
          num_frames: int = 30,
          morph_type: str = 'crossfade',  # 'crossfade', 'scale', 'spin_morph'
          easing: str = 'ease_in_out',
          object_type: str = 'emoji',
          center_pos: tuple[int, int] = (240, 240),
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list[Image.Image]:
          """
          Create morphing animation between two objects.
      
          Args:
              object1_data: First object configuration
              object2_data: Second object configuration
              num_frames: Number of frames
              morph_type: Type of morph effect
              easing: Easing function
              object_type: Type of objects
              center_pos: Center position
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          frames = []
      
          for i in range(num_frames):
              t = i / (num_frames - 1) if num_frames > 1 else 0
              frame = create_blank_frame(frame_width, frame_height, bg_color)
      
              if morph_type == 'crossfade':
                  # Simple crossfade between two objects
                  opacity1 = interpolate(1, 0, t, easing)
                  opacity2 = interpolate(0, 1, t, easing)
      
                  if object_type == 'emoji':
                      # Create first emoji
                      emoji1_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
                      size1 = object1_data['size']
                      draw_emoji_enhanced(
                          emoji1_canvas,
                          emoji=object1_data['emoji'],
                          position=(center_pos[0] - size1 // 2, center_pos[1] - size1 // 2),
                          size=size1,
                          shadow=False
                      )
      
                      # Apply opacity
                      from templates.fade import apply_opacity
                      emoji1_canvas = apply_opacity(emoji1_canvas, opacity1)
      
                      # Create second emoji
                      emoji2_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
                      size2 = object2_data['size']
                      draw_emoji_enhanced(
                          emoji2_canvas,
                          emoji=object2_data['emoji'],
                          position=(center_pos[0] - size2 // 2, center_pos[1] - size2 // 2),
                          size=size2,
                          shadow=False
                      )
      
                      emoji2_canvas = apply_opacity(emoji2_canvas, opacity2)
      
                      # Composite both
                      frame_rgba = frame.convert('RGBA')
                      frame_rgba = Image.alpha_composite(frame_rgba, emoji1_canvas)
                      frame_rgba = Image.alpha_composite(frame_rgba, emoji2_canvas)
                      frame = frame_rgba.convert('RGB')
      
                  elif object_type == 'circle':
                      # Morph between two circles
                      radius1 = object1_data['radius']
                      radius2 = object2_data['radius']
                      color1 = object1_data['color']
                      color2 = object2_data['color']
      
                      # Interpolate properties
                      current_radius = int(interpolate(radius1, radius2, t, easing))
                      current_color = tuple(
                          int(interpolate(color1[i], color2[i], t, easing))
                          for i in range(3)
                      )
      
                      draw_circle(frame, center_pos, current_radius, fill_color=current_color)
      
              elif morph_type == 'scale':
                  # First object scales down as second scales up
                  if object_type == 'emoji':
                      scale1 = interpolate(1.0, 0.0, t, easing)
                      scale2 = interpolate(0.0, 1.0, t, easing)
      
                      # Draw first emoji (shrinking)
                      if scale1 > 0.05:
                          size1 = int(object1_data['size'] * scale1)
                          size1 = max(12, size1)
                          emoji1_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
                          draw_emoji_enhanced(
                              emoji1_canvas,
                              emoji=object1_data['emoji'],
                              position=(center_pos[0] - size1 // 2, center_pos[1] - size1 // 2),
                              size=size1,
                              shadow=False
                          )
      
                          frame_rgba = frame.convert('RGBA')
                          frame = Image.alpha_composite(frame_rgba, emoji1_canvas)
                          frame = frame.convert('RGB')
      
                      # Draw second emoji (growing)
                      if scale2 > 0.05:
                          size2 = int(object2_data['size'] * scale2)
                          size2 = max(12, size2)
                          emoji2_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
                          draw_emoji_enhanced(
                              emoji2_canvas,
                              emoji=object2_data['emoji'],
                              position=(center_pos[0] - size2 // 2, center_pos[1] - size2 // 2),
                              size=size2,
                              shadow=False
                          )
      
                          frame_rgba = frame.convert('RGBA')
                          frame = Image.alpha_composite(frame_rgba, emoji2_canvas)
                          frame = frame.convert('RGB')
      
              elif morph_type == 'spin_morph':
                  # Spin while morphing (flip-like)
                  import math
      
                  # Calculate rotation (0 to 180 degrees)
                  angle = interpolate(0, 180, t, easing)
                  scale_factor = abs(math.cos(math.radians(angle)))
      
                  # Determine which object to show
                  if angle < 90:
                      current_object = object1_data
                  else:
                      current_object = object2_data
      
                  # Skip when edge-on
                  if scale_factor < 0.05:
                      frames.append(frame)
                      continue
      
                  if object_type == 'emoji':
                      size = current_object['size']
                      canvas_size = size * 2
                      emoji_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
      
                      draw_emoji_enhanced(
                          emoji_canvas,
                          emoji=current_object['emoji'],
                          position=(canvas_size // 2 - size // 2, canvas_size // 2 - size // 2),
                          size=size,
                          shadow=False
                      )
      
                      # Scale horizontally for spin effect
                      new_width = max(1, int(canvas_size * scale_factor))
                      emoji_scaled = emoji_canvas.resize((new_width, canvas_size), Image.LANCZOS)
      
                      paste_x = center_pos[0] - new_width // 2
                      paste_y = center_pos[1] - canvas_size // 2
      
                      frame_rgba = frame.convert('RGBA')
                      frame_rgba.paste(emoji_scaled, (paste_x, paste_y), emoji_scaled)
                      frame = frame_rgba.convert('RGB')
      
              frames.append(frame)
      
          return frames
      
      
      def create_reaction_morph(
          emoji_start: str,
          emoji_end: str,
          num_frames: int = 20,
          frame_size: int = 128
      ) -> list[Image.Image]:
          """
          Create quick emoji reaction morph (for emoji GIFs).
      
          Args:
              emoji_start: Starting emoji
              emoji_end: Ending emoji
              num_frames: Number of frames
              frame_size: Frame size (square)
      
          Returns:
              List of frames
          """
          return create_morph_animation(
              object1_data={'emoji': emoji_start, 'size': 80},
              object2_data={'emoji': emoji_end, 'size': 80},
              num_frames=num_frames,
              morph_type='crossfade',
              easing='ease_in_out',
              object_type='emoji',
              center_pos=(frame_size // 2, frame_size // 2),
              frame_width=frame_size,
              frame_height=frame_size,
              bg_color=(255, 255, 255)
          )
      
      
      def create_shape_morph(
          shapes: list[dict],
          num_frames: int = 60,
          frames_per_shape: int = 20,
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list[Image.Image]:
          """
          Morph through a sequence of shapes.
      
          Args:
              shapes: List of shape dicts with 'radius' and 'color'
              num_frames: Total number of frames
              frames_per_shape: Frames to spend on each morph
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          frames = []
          center = (frame_width // 2, frame_height // 2)
      
          for i in range(num_frames):
              # Determine which shapes we're morphing between
              cycle_progress = (i % (frames_per_shape * len(shapes))) / frames_per_shape
              shape_idx = int(cycle_progress) % len(shapes)
              next_shape_idx = (shape_idx + 1) % len(shapes)
      
              # Progress between these two shapes
              t = cycle_progress - shape_idx
      
              shape1 = shapes[shape_idx]
              shape2 = shapes[next_shape_idx]
      
              # Interpolate properties
              radius = int(interpolate(shape1['radius'], shape2['radius'], t, 'ease_in_out'))
              color = tuple(
                  int(interpolate(shape1['color'][j], shape2['color'][j], t, 'ease_in_out'))
                  for j in range(3)
              )
      
              # Draw frame
              frame = create_blank_frame(frame_width, frame_height, bg_color)
              draw_circle(frame, center, radius, fill_color=color)
      
              frames.append(frame)
      
          return frames
      
      
      # Example usage
      if __name__ == '__main__':
          print("Creating morph animations...")
      
          builder = GIFBuilder(width=480, height=480, fps=20)
      
          # Example 1: Crossfade morph
          frames = create_morph_animation(
              object1_data={'emoji': '😊', 'size': 100},
              object2_data={'emoji': '😂', 'size': 100},
              num_frames=30,
              morph_type='crossfade',
              object_type='emoji'
          )
          builder.add_frames(frames)
          builder.save('morph_crossfade.gif', num_colors=128)
      
          # Example 2: Scale morph
          builder.clear()
          frames = create_morph_animation(
              object1_data={'emoji': '🌙', 'size': 100},
              object2_data={'emoji': '☀️', 'size': 100},
              num_frames=40,
              morph_type='scale',
              object_type='emoji'
          )
          builder.add_frames(frames)
          builder.save('morph_scale.gif', num_colors=128)
      
          # Example 3: Shape morph cycle
          builder.clear()
          from core.color_palettes import get_palette
          palette = get_palette('vibrant')
      
          shapes = [
              {'radius': 60, 'color': palette['primary']},
              {'radius': 80, 'color': palette['secondary']},
              {'radius': 50, 'color': palette['accent']},
              {'radius': 70, 'color': palette['success']}
          ]
          frames = create_shape_morph(shapes, num_frames=80, frames_per_shape=20)
          builder.add_frames(frames)
          builder.save('morph_shapes.gif', num_colors=64)
      
          print("Created morph animations!")
      
    • move.py 9.2 KB
      #!/usr/bin/env python3
      """
      Move Animation - Move objects along paths with various motion types.
      
      Provides flexible movement primitives for objects along linear, arc, or custom paths.
      """
      
      import sys
      from pathlib import Path
      import math
      
      sys.path.append(str(Path(__file__).parent.parent))
      
      from core.gif_builder import GIFBuilder
      from core.frame_composer import create_blank_frame, draw_circle, draw_emoji_enhanced
      from core.easing import interpolate, calculate_arc_motion
      
      
      def create_move_animation(
          object_type: str = 'emoji',
          object_data: dict | None = None,
          start_pos: tuple[int, int] = (50, 240),
          end_pos: tuple[int, int] = (430, 240),
          num_frames: int = 30,
          motion_type: str = 'linear',  # 'linear', 'arc', 'bezier', 'circle', 'wave'
          easing: str = 'ease_out',
          motion_params: dict | None = None,
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list:
          """
          Create frames showing object moving along a path.
      
          Args:
              object_type: 'circle', 'emoji', or 'custom'
              object_data: Data for the object
              start_pos: Starting (x, y) position
              end_pos: Ending (x, y) position
              num_frames: Number of frames
              motion_type: Type of motion path
              easing: Easing function name
              motion_params: Additional parameters for motion (e.g., {'arc_height': 100})
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          frames = []
      
          # Default object data
          if object_data is None:
              if object_type == 'circle':
                  object_data = {'radius': 30, 'color': (100, 150, 255)}
              elif object_type == 'emoji':
                  object_data = {'emoji': '🚀', 'size': 60}
      
          # Default motion params
          if motion_params is None:
              motion_params = {}
      
          for i in range(num_frames):
              frame = create_blank_frame(frame_width, frame_height, bg_color)
      
              t = i / (num_frames - 1) if num_frames > 1 else 0
      
              # Calculate position based on motion type
              if motion_type == 'linear':
                  # Straight line with easing
                  x = interpolate(start_pos[0], end_pos[0], t, easing)
                  y = interpolate(start_pos[1], end_pos[1], t, easing)
      
              elif motion_type == 'arc':
                  # Parabolic arc
                  arc_height = motion_params.get('arc_height', 100)
                  x, y = calculate_arc_motion(start_pos, end_pos, arc_height, t)
      
              elif motion_type == 'circle':
                  # Circular motion around a center
                  center = motion_params.get('center', (frame_width // 2, frame_height // 2))
                  radius = motion_params.get('radius', 150)
                  start_angle = motion_params.get('start_angle', 0)
                  angle_range = motion_params.get('angle_range', 360)  # Full circle
      
                  angle = start_angle + (angle_range * t)
                  angle_rad = math.radians(angle)
      
                  x = center[0] + radius * math.cos(angle_rad)
                  y = center[1] + radius * math.sin(angle_rad)
      
              elif motion_type == 'wave':
                  # Move in straight line but add wave motion
                  wave_amplitude = motion_params.get('wave_amplitude', 50)
                  wave_frequency = motion_params.get('wave_frequency', 2)
      
                  # Base linear motion
                  base_x = interpolate(start_pos[0], end_pos[0], t, easing)
                  base_y = interpolate(start_pos[1], end_pos[1], t, easing)
      
                  # Add wave offset perpendicular to motion direction
                  dx = end_pos[0] - start_pos[0]
                  dy = end_pos[1] - start_pos[1]
                  length = math.sqrt(dx * dx + dy * dy)
      
                  if length > 0:
                      # Perpendicular direction
                      perp_x = -dy / length
                      perp_y = dx / length
      
                      # Wave offset
                      wave_offset = math.sin(t * wave_frequency * 2 * math.pi) * wave_amplitude
      
                      x = base_x + perp_x * wave_offset
                      y = base_y + perp_y * wave_offset
                  else:
                      x, y = base_x, base_y
      
              elif motion_type == 'bezier':
                  # Quadratic bezier curve
                  control_point = motion_params.get('control_point', (
                      (start_pos[0] + end_pos[0]) // 2,
                      (start_pos[1] + end_pos[1]) // 2 - 100
                  ))
      
                  # Quadratic Bezier formula: B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2
                  x = (1 - t) ** 2 * start_pos[0] + 2 * (1 - t) * t * control_point[0] + t ** 2 * end_pos[0]
                  y = (1 - t) ** 2 * start_pos[1] + 2 * (1 - t) * t * control_point[1] + t ** 2 * end_pos[1]
      
              else:
                  # Default to linear
                  x = interpolate(start_pos[0], end_pos[0], t, easing)
                  y = interpolate(start_pos[1], end_pos[1], t, easing)
      
              # Draw object at calculated position
              x, y = int(x), int(y)
      
              if object_type == 'circle':
                  draw_circle(
                      frame,
                      center=(x, y),
                      radius=object_data['radius'],
                      fill_color=object_data['color']
                  )
              elif object_type == 'emoji':
                  draw_emoji_enhanced(
                      frame,
                      emoji=object_data['emoji'],
                      position=(x - object_data['size'] // 2, y - object_data['size'] // 2),
                      size=object_data['size'],
                      shadow=object_data.get('shadow', True)
                  )
      
              frames.append(frame)
      
          return frames
      
      
      def create_path_from_points(points: list[tuple[int, int]],
                                  num_frames: int = 60,
                                  easing: str = 'ease_in_out') -> list[tuple[int, int]]:
          """
          Create a smooth path through multiple points.
      
          Args:
              points: List of (x, y) waypoints
              num_frames: Total number of frames
              easing: Easing between points
      
          Returns:
              List of (x, y) positions for each frame
          """
          if len(points) < 2:
              return points * num_frames
      
          path = []
          frames_per_segment = num_frames // (len(points) - 1)
      
          for i in range(len(points) - 1):
              start = points[i]
              end = points[i + 1]
      
              # Last segment gets remaining frames
              if i == len(points) - 2:
                  segment_frames = num_frames - len(path)
              else:
                  segment_frames = frames_per_segment
      
              for j in range(segment_frames):
                  t = j / segment_frames if segment_frames > 0 else 0
                  x = interpolate(start[0], end[0], t, easing)
                  y = interpolate(start[1], end[1], t, easing)
                  path.append((int(x), int(y)))
      
          return path
      
      
      def apply_trail_effect(frames: list, trail_length: int = 5,
                            fade_alpha: float = 0.3) -> list:
          """
          Add motion trail effect to moving object.
      
          Args:
              frames: List of frames with moving object
              trail_length: Number of previous frames to blend
              fade_alpha: Opacity of trail frames
      
          Returns:
              List of frames with trail effect
          """
          from PIL import Image, ImageChops
          import numpy as np
      
          trailed_frames = []
      
          for i, frame in enumerate(frames):
              # Start with current frame
              result = frame.copy()
      
              # Blend previous frames
              for j in range(1, min(trail_length + 1, i + 1)):
                  prev_frame = frames[i - j]
      
                  # Calculate fade
                  alpha = fade_alpha ** j
      
                  # Blend
                  result_array = np.array(result, dtype=np.float32)
                  prev_array = np.array(prev_frame, dtype=np.float32)
      
                  blended = result_array * (1 - alpha) + prev_array * alpha
                  result = Image.fromarray(blended.astype(np.uint8))
      
              trailed_frames.append(result)
      
          return trailed_frames
      
      
      # Example usage
      if __name__ == '__main__':
          print("Creating movement examples...")
      
          # Example 1: Linear movement
          builder = GIFBuilder(width=480, height=480, fps=20)
          frames = create_move_animation(
              object_type='emoji',
              object_data={'emoji': '🚀', 'size': 60},
              start_pos=(50, 240),
              end_pos=(430, 240),
              num_frames=30,
              motion_type='linear',
              easing='ease_out'
          )
          builder.add_frames(frames)
          builder.save('move_linear.gif', num_colors=128)
      
          # Example 2: Arc movement
          builder.clear()
          frames = create_move_animation(
              object_type='emoji',
              object_data={'emoji': '⚽', 'size': 60},
              start_pos=(50, 350),
              end_pos=(430, 350),
              num_frames=30,
              motion_type='arc',
              motion_params={'arc_height': 150},
              easing='linear'
          )
          builder.add_frames(frames)
          builder.save('move_arc.gif', num_colors=128)
      
          # Example 3: Circular movement
          builder.clear()
          frames = create_move_animation(
              object_type='emoji',
              object_data={'emoji': '🌍', 'size': 50},
              start_pos=(0, 0),  # Ignored for circle
              end_pos=(0, 0),    # Ignored for circle
              num_frames=40,
              motion_type='circle',
              motion_params={
                  'center': (240, 240),
                  'radius': 120,
                  'start_angle': 0,
                  'angle_range': 360
              },
              easing='linear'
          )
          builder.add_frames(frames)
          builder.save('move_circle.gif', num_colors=128)
      
          print("Created movement examples!")
      
    • pulse.py 8.4 KB
      #!/usr/bin/env python3
      """
      Pulse Animation - Scale objects rhythmically for emphasis.
      
      Creates pulsing, heartbeat, and throbbing effects.
      """
      
      import sys
      from pathlib import Path
      import math
      
      sys.path.append(str(Path(__file__).parent.parent))
      
      from PIL import Image
      from core.gif_builder import GIFBuilder
      from core.frame_composer import create_blank_frame, draw_emoji_enhanced, draw_circle
      from core.easing import interpolate
      
      
      def create_pulse_animation(
          object_type: str = 'emoji',
          object_data: dict | None = None,
          num_frames: int = 30,
          pulse_type: str = 'smooth',  # 'smooth', 'heartbeat', 'throb', 'pop'
          scale_range: tuple[float, float] = (0.8, 1.2),
          pulses: float = 2.0,
          center_pos: tuple[int, int] = (240, 240),
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list[Image.Image]:
          """
          Create pulsing/scaling animation.
      
          Args:
              object_type: 'emoji', 'circle', 'text'
              object_data: Object configuration
              num_frames: Number of frames
              pulse_type: Type of pulsing motion
              scale_range: (min_scale, max_scale) tuple
              pulses: Number of pulses in animation
              center_pos: Center position
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          frames = []
      
          # Default object data
          if object_data is None:
              if object_type == 'emoji':
                  object_data = {'emoji': '❤️', 'size': 100}
              elif object_type == 'circle':
                  object_data = {'radius': 50, 'color': (255, 100, 100)}
      
          min_scale, max_scale = scale_range
      
          for i in range(num_frames):
              frame = create_blank_frame(frame_width, frame_height, bg_color)
              t = i / (num_frames - 1) if num_frames > 1 else 0
      
              # Calculate scale based on pulse type
              if pulse_type == 'smooth':
                  # Simple sinusoidal pulse
                  scale = min_scale + (max_scale - min_scale) * (
                      0.5 + 0.5 * math.sin(t * pulses * 2 * math.pi - math.pi / 2)
                  )
      
              elif pulse_type == 'heartbeat':
                  # Double pump like a heartbeat
                  phase = (t * pulses) % 1.0
                  if phase < 0.15:
                      # First pump
                      scale = interpolate(min_scale, max_scale, phase / 0.15, 'ease_out')
                  elif phase < 0.25:
                      # First release
                      scale = interpolate(max_scale, min_scale, (phase - 0.15) / 0.10, 'ease_in')
                  elif phase < 0.35:
                      # Second pump (smaller)
                      scale = interpolate(min_scale, (min_scale + max_scale) / 2, (phase - 0.25) / 0.10, 'ease_out')
                  elif phase < 0.45:
                      # Second release
                      scale = interpolate((min_scale + max_scale) / 2, min_scale, (phase - 0.35) / 0.10, 'ease_in')
                  else:
                      # Rest period
                      scale = min_scale
      
              elif pulse_type == 'throb':
                  # Sharp pulse with quick return
                  phase = (t * pulses) % 1.0
                  if phase < 0.2:
                      scale = interpolate(min_scale, max_scale, phase / 0.2, 'ease_out')
                  else:
                      scale = interpolate(max_scale, min_scale, (phase - 0.2) / 0.8, 'ease_in')
      
              elif pulse_type == 'pop':
                  # Pop out and back with overshoot
                  phase = (t * pulses) % 1.0
                  if phase < 0.3:
                      # Pop out with overshoot
                      scale = interpolate(min_scale, max_scale * 1.1, phase / 0.3, 'elastic_out')
                  else:
                      # Settle back
                      scale = interpolate(max_scale * 1.1, min_scale, (phase - 0.3) / 0.7, 'ease_out')
      
              else:
                  scale = min_scale + (max_scale - min_scale) * (
                      0.5 + 0.5 * math.sin(t * pulses * 2 * math.pi)
                  )
      
              # Draw object at calculated scale
              if object_type == 'emoji':
                  base_size = object_data['size']
                  current_size = int(base_size * scale)
                  draw_emoji_enhanced(
                      frame,
                      emoji=object_data['emoji'],
                      position=(center_pos[0] - current_size // 2, center_pos[1] - current_size // 2),
                      size=current_size,
                      shadow=object_data.get('shadow', True)
                  )
      
              elif object_type == 'circle':
                  base_radius = object_data['radius']
                  current_radius = int(base_radius * scale)
                  draw_circle(
                      frame,
                      center=center_pos,
                      radius=current_radius,
                      fill_color=object_data['color']
                  )
      
              elif object_type == 'text':
                  from core.typography import draw_text_with_outline
                  base_size = object_data.get('font_size', 50)
                  current_size = int(base_size * scale)
                  draw_text_with_outline(
                      frame,
                      text=object_data.get('text', 'PULSE'),
                      position=center_pos,
                      font_size=current_size,
                      text_color=object_data.get('text_color', (255, 100, 100)),
                      outline_color=object_data.get('outline_color', (0, 0, 0)),
                      outline_width=3,
                      centered=True
                  )
      
              frames.append(frame)
      
          return frames
      
      
      def create_attention_pulse(
          emoji: str = '⚠️',
          num_frames: int = 20,
          frame_size: int = 128,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list[Image.Image]:
          """
          Create attention-grabbing pulse (good for emoji GIFs).
      
          Args:
              emoji: Emoji to pulse
              num_frames: Number of frames
              frame_size: Frame size (square)
              bg_color: Background color
      
          Returns:
              List of frames optimized for emoji size
          """
          return create_pulse_animation(
              object_type='emoji',
              object_data={'emoji': emoji, 'size': 80, 'shadow': False},
              num_frames=num_frames,
              pulse_type='throb',
              scale_range=(0.85, 1.15),
              pulses=2,
              center_pos=(frame_size // 2, frame_size // 2),
              frame_width=frame_size,
              frame_height=frame_size,
              bg_color=bg_color
          )
      
      
      def create_breathing_animation(
          object_type: str = 'emoji',
          object_data: dict | None = None,
          num_frames: int = 60,
          breaths: float = 2.0,
          scale_range: tuple[float, float] = (0.9, 1.1),
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (240, 248, 255)
      ) -> list[Image.Image]:
          """
          Create slow, calming breathing animation (in and out).
      
          Args:
              object_type: Type of object
              object_data: Object configuration
              num_frames: Number of frames
              breaths: Number of breathing cycles
              scale_range: Min/max scale
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          if object_data is None:
              object_data = {'emoji': '😌', 'size': 100}
      
          return create_pulse_animation(
              object_type=object_type,
              object_data=object_data,
              num_frames=num_frames,
              pulse_type='smooth',
              scale_range=scale_range,
              pulses=breaths,
              center_pos=(frame_width // 2, frame_height // 2),
              frame_width=frame_width,
              frame_height=frame_height,
              bg_color=bg_color
          )
      
      
      # Example usage
      if __name__ == '__main__':
          print("Creating pulse animations...")
      
          builder = GIFBuilder(width=480, height=480, fps=20)
      
          # Example 1: Smooth pulse
          frames = create_pulse_animation(
              object_type='emoji',
              object_data={'emoji': '❤️', 'size': 100},
              num_frames=40,
              pulse_type='smooth',
              scale_range=(0.8, 1.2),
              pulses=2
          )
          builder.add_frames(frames)
          builder.save('pulse_smooth.gif', num_colors=128)
      
          # Example 2: Heartbeat
          builder.clear()
          frames = create_pulse_animation(
              object_type='emoji',
              object_data={'emoji': '💓', 'size': 100},
              num_frames=60,
              pulse_type='heartbeat',
              scale_range=(0.85, 1.2),
              pulses=3
          )
          builder.add_frames(frames)
          builder.save('pulse_heartbeat.gif', num_colors=128)
      
          # Example 3: Attention pulse (emoji size)
          builder = GIFBuilder(width=128, height=128, fps=15)
          frames = create_attention_pulse(emoji='⚠️', num_frames=20)
          builder.add_frames(frames)
          builder.save('pulse_attention.gif', num_colors=48, optimize_for_emoji=True)
      
          print("Created pulse animations!")
      
    • shake.py 3.6 KB
      #!/usr/bin/env python3
      """
      Shake Animation Template - Creates shaking/vibrating motion.
      
      Use this for impact effects, emphasis, or nervous/excited reactions.
      """
      
      import sys
      import math
      from pathlib import Path
      
      sys.path.append(str(Path(__file__).parent.parent))
      
      from core.gif_builder import GIFBuilder
      from core.frame_composer import create_blank_frame, draw_circle, draw_emoji, draw_text
      from core.easing import ease_out_quad
      
      
      def create_shake_animation(
          object_type: str = 'emoji',
          object_data: dict = None,
          num_frames: int = 20,
          shake_intensity: int = 15,
          center_x: int = 240,
          center_y: int = 240,
          direction: str = 'horizontal',  # 'horizontal', 'vertical', or 'both'
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list:
          """
          Create frames for a shaking animation.
      
          Args:
              object_type: 'circle', 'emoji', 'text', or 'custom'
              object_data: Data for the object
              num_frames: Number of frames
              shake_intensity: Maximum shake displacement in pixels
              center_x: Center X position
              center_y: Center Y position
              direction: 'horizontal', 'vertical', or 'both'
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          frames = []
      
          # Default object data
          if object_data is None:
              if object_type == 'emoji':
                  object_data = {'emoji': '😱', 'size': 80}
              elif object_type == 'text':
                  object_data = {'text': 'SHAKE!', 'font_size': 50, 'color': (255, 0, 0)}
      
          for i in range(num_frames):
              frame = create_blank_frame(frame_width, frame_height, bg_color)
      
              # Calculate progress
              t = i / (num_frames - 1) if num_frames > 1 else 0
      
              # Decay shake intensity over time
              intensity = shake_intensity * (1 - ease_out_quad(t))
      
              # Calculate shake offset using sine wave for smooth oscillation
              freq = 3  # Oscillation frequency
              offset_x = 0
              offset_y = 0
      
              if direction in ['horizontal', 'both']:
                  offset_x = int(math.sin(t * freq * 2 * math.pi) * intensity)
      
              if direction in ['vertical', 'both']:
                  offset_y = int(math.cos(t * freq * 2 * math.pi) * intensity)
      
              # Apply offset
              x = center_x + offset_x
              y = center_y + offset_y
      
              # Draw object
              if object_type == 'emoji':
                  draw_emoji(
                      frame,
                      emoji=object_data['emoji'],
                      position=(x - object_data['size'] // 2, y - object_data['size'] // 2),
                      size=object_data['size']
                  )
              elif object_type == 'text':
                  draw_text(
                      frame,
                      text=object_data['text'],
                      position=(x, y),
                      font_size=object_data['font_size'],
                      color=object_data['color'],
                      centered=True
                  )
              elif object_type == 'circle':
                  draw_circle(
                      frame,
                      center=(x, y),
                      radius=object_data.get('radius', 30),
                      fill_color=object_data.get('color', (100, 100, 255))
                  )
      
              frames.append(frame)
      
          return frames
      
      
      # Example usage
      if __name__ == '__main__':
          print("Creating shake GIF...")
      
          builder = GIFBuilder(width=480, height=480, fps=24)
      
          frames = create_shake_animation(
              object_type='emoji',
              object_data={'emoji': '😱', 'size': 100},
              num_frames=30,
              shake_intensity=20,
              direction='both'
          )
      
          builder.add_frames(frames)
          builder.save('shake_test.gif', num_colors=128)
    • slide.py 9.1 KB
      #!/usr/bin/env python3
      """
      Slide Animation - Slide elements in from edges with overshoot/bounce.
      
      Creates smooth entrance and exit animations.
      """
      
      import sys
      from pathlib import Path
      
      sys.path.append(str(Path(__file__).parent.parent))
      
      from PIL import Image
      from core.gif_builder import GIFBuilder
      from core.frame_composer import create_blank_frame, draw_emoji_enhanced
      from core.easing import interpolate
      
      
      def create_slide_animation(
          object_type: str = 'emoji',
          object_data: dict | None = None,
          num_frames: int = 30,
          direction: str = 'left',  # 'left', 'right', 'top', 'bottom'
          slide_type: str = 'in',  # 'in', 'out', 'across'
          easing: str = 'ease_out',
          overshoot: bool = False,
          final_pos: tuple[int, int] | None = None,
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list[Image.Image]:
          """
          Create slide animation.
      
          Args:
              object_type: 'emoji', 'text'
              object_data: Object configuration
              num_frames: Number of frames
              direction: Direction of slide
              slide_type: Type of slide (in/out/across)
              easing: Easing function
              overshoot: Add overshoot/bounce at end
              final_pos: Final position (None = center)
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          frames = []
      
          # Default object data
          if object_data is None:
              if object_type == 'emoji':
                  object_data = {'emoji': '➡️', 'size': 100}
      
          if final_pos is None:
              final_pos = (frame_width // 2, frame_height // 2)
      
          # Calculate start and end positions based on direction
          size = object_data.get('size', 100) if object_type == 'emoji' else 100
          margin = size
      
          if direction == 'left':
              start_pos = (-margin, final_pos[1])
              end_pos = final_pos if slide_type == 'in' else (frame_width + margin, final_pos[1])
          elif direction == 'right':
              start_pos = (frame_width + margin, final_pos[1])
              end_pos = final_pos if slide_type == 'in' else (-margin, final_pos[1])
          elif direction == 'top':
              start_pos = (final_pos[0], -margin)
              end_pos = final_pos if slide_type == 'in' else (final_pos[0], frame_height + margin)
          elif direction == 'bottom':
              start_pos = (final_pos[0], frame_height + margin)
              end_pos = final_pos if slide_type == 'in' else (final_pos[0], -margin)
          else:
              start_pos = (-margin, final_pos[1])
              end_pos = final_pos
      
          # For 'out' type, swap start and end
          if slide_type == 'out':
              start_pos, end_pos = final_pos, end_pos
          elif slide_type == 'across':
              # Slide all the way across
              if direction == 'left':
                  start_pos = (-margin, final_pos[1])
                  end_pos = (frame_width + margin, final_pos[1])
              elif direction == 'right':
                  start_pos = (frame_width + margin, final_pos[1])
                  end_pos = (-margin, final_pos[1])
              elif direction == 'top':
                  start_pos = (final_pos[0], -margin)
                  end_pos = (final_pos[0], frame_height + margin)
              elif direction == 'bottom':
                  start_pos = (final_pos[0], frame_height + margin)
                  end_pos = (final_pos[0], -margin)
      
          # Use overshoot easing if requested
          if overshoot and slide_type == 'in':
              easing = 'back_out'
      
          for i in range(num_frames):
              t = i / (num_frames - 1) if num_frames > 1 else 0
              frame = create_blank_frame(frame_width, frame_height, bg_color)
      
              # Calculate current position
              x = int(interpolate(start_pos[0], end_pos[0], t, easing))
              y = int(interpolate(start_pos[1], end_pos[1], t, easing))
      
              # Draw object
              if object_type == 'emoji':
                  size = object_data['size']
                  draw_emoji_enhanced(
                      frame,
                      emoji=object_data['emoji'],
                      position=(x - size // 2, y - size // 2),
                      size=size,
                      shadow=object_data.get('shadow', True)
                  )
      
              elif object_type == 'text':
                  from core.typography import draw_text_with_outline
                  draw_text_with_outline(
                      frame,
                      text=object_data.get('text', 'SLIDE'),
                      position=(x, y),
                      font_size=object_data.get('font_size', 50),
                      text_color=object_data.get('text_color', (0, 0, 0)),
                      outline_color=object_data.get('outline_color', (255, 255, 255)),
                      outline_width=3,
                      centered=True
                  )
      
              frames.append(frame)
      
          return frames
      
      
      def create_multi_slide(
          objects: list[dict],
          num_frames: int = 30,
          stagger_delay: int = 3,
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list[Image.Image]:
          """
          Create animation with multiple objects sliding in sequence.
      
          Args:
              objects: List of object configs with 'type', 'data', 'direction', 'final_pos'
              num_frames: Number of frames
              stagger_delay: Frames between each object starting
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          frames = []
      
          for i in range(num_frames):
              frame = create_blank_frame(frame_width, frame_height, bg_color)
      
              for idx, obj in enumerate(objects):
                  # Calculate when this object starts moving
                  start_frame = idx * stagger_delay
                  if i < start_frame:
                      continue  # Object hasn't started yet
      
                  # Calculate progress for this object
                  obj_frame = i - start_frame
                  obj_duration = num_frames - start_frame
                  if obj_duration <= 0:
                      continue
      
                  t = obj_frame / obj_duration
      
                  # Get object properties
                  obj_type = obj.get('type', 'emoji')
                  obj_data = obj.get('data', {'emoji': '➡️', 'size': 80})
                  direction = obj.get('direction', 'left')
                  final_pos = obj.get('final_pos', (frame_width // 2, frame_height // 2))
                  easing = obj.get('easing', 'back_out')
      
                  # Calculate position
                  size = obj_data.get('size', 80)
                  margin = size
      
                  if direction == 'left':
                      start_x = -margin
                      end_x = final_pos[0]
                      y = final_pos[1]
                  elif direction == 'right':
                      start_x = frame_width + margin
                      end_x = final_pos[0]
                      y = final_pos[1]
                  elif direction == 'top':
                      x = final_pos[0]
                      start_y = -margin
                      end_y = final_pos[1]
                  elif direction == 'bottom':
                      x = final_pos[0]
                      start_y = frame_height + margin
                      end_y = final_pos[1]
                  else:
                      start_x = -margin
                      end_x = final_pos[0]
                      y = final_pos[1]
      
                  # Interpolate position
                  if direction in ['left', 'right']:
                      x = int(interpolate(start_x, end_x, t, easing))
                  else:
                      y = int(interpolate(start_y, end_y, t, easing))
      
                  # Draw object
                  if obj_type == 'emoji':
                      draw_emoji_enhanced(
                          frame,
                          emoji=obj_data['emoji'],
                          position=(x - size // 2, y - size // 2),
                          size=size,
                          shadow=False
                      )
      
              frames.append(frame)
      
          return frames
      
      
      # Example usage
      if __name__ == '__main__':
          print("Creating slide animations...")
      
          builder = GIFBuilder(width=480, height=480, fps=20)
      
          # Example 1: Slide in from left with overshoot
          frames = create_slide_animation(
              object_type='emoji',
              object_data={'emoji': '➡️', 'size': 100},
              num_frames=30,
              direction='left',
              slide_type='in',
              overshoot=True
          )
          builder.add_frames(frames)
          builder.save('slide_in_left.gif', num_colors=128)
      
          # Example 2: Slide across
          builder.clear()
          frames = create_slide_animation(
              object_type='emoji',
              object_data={'emoji': '🚀', 'size': 80},
              num_frames=40,
              direction='left',
              slide_type='across',
              easing='ease_in_out'
          )
          builder.add_frames(frames)
          builder.save('slide_across.gif', num_colors=128)
      
          # Example 3: Multiple objects sliding in
          builder.clear()
          objects = [
              {
                  'type': 'emoji',
                  'data': {'emoji': '🎯', 'size': 60},
                  'direction': 'left',
                  'final_pos': (120, 240)
              },
              {
                  'type': 'emoji',
                  'data': {'emoji': '🎪', 'size': 60},
                  'direction': 'right',
                  'final_pos': (240, 240)
              },
              {
                  'type': 'emoji',
                  'data': {'emoji': '🎨', 'size': 60},
                  'direction': 'top',
                  'final_pos': (360, 240)
              }
          ]
          frames = create_multi_slide(objects, num_frames=50, stagger_delay=5)
          builder.add_frames(frames)
          builder.save('slide_multi.gif', num_colors=128)
      
          print("Created slide animations!")
      
    • spin.py 8.9 KB
      #!/usr/bin/env python3
      """
      Spin Animation - Rotate objects continuously or with variation.
      
      Creates spinning, rotating, and wobbling effects.
      """
      
      import sys
      from pathlib import Path
      import math
      
      sys.path.append(str(Path(__file__).parent.parent))
      
      from PIL import Image
      from core.gif_builder import GIFBuilder
      from core.frame_composer import create_blank_frame, draw_emoji_enhanced, draw_circle
      from core.easing import interpolate
      
      
      def create_spin_animation(
          object_type: str = 'emoji',
          object_data: dict | None = None,
          num_frames: int = 30,
          rotation_type: str = 'clockwise',  # 'clockwise', 'counterclockwise', 'wobble', 'pendulum'
          full_rotations: float = 1.0,
          easing: str = 'linear',
          center_pos: tuple[int, int] = (240, 240),
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list[Image.Image]:
          """
          Create spinning/rotating animation.
      
          Args:
              object_type: 'emoji', 'image', 'text'
              object_data: Object configuration
              num_frames: Number of frames
              rotation_type: Type of rotation
              full_rotations: Number of complete 360° rotations
              easing: Easing function for rotation speed
              center_pos: Center position for rotation
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          frames = []
      
          # Default object data
          if object_data is None:
              if object_type == 'emoji':
                  object_data = {'emoji': '🔄', 'size': 100}
      
          for i in range(num_frames):
              frame = create_blank_frame(frame_width, frame_height, bg_color)
              t = i / (num_frames - 1) if num_frames > 1 else 0
      
              # Calculate rotation angle
              if rotation_type == 'clockwise':
                  angle = interpolate(0, 360 * full_rotations, t, easing)
              elif rotation_type == 'counterclockwise':
                  angle = interpolate(0, -360 * full_rotations, t, easing)
              elif rotation_type == 'wobble':
                  # Back and forth rotation
                  angle = math.sin(t * full_rotations * 2 * math.pi) * 45
              elif rotation_type == 'pendulum':
                  # Smooth pendulum swing
                  angle = math.sin(t * full_rotations * 2 * math.pi) * 90
              else:
                  angle = interpolate(0, 360 * full_rotations, t, easing)
      
              # Create object on transparent background to rotate
              if object_type == 'emoji':
                  # For emoji, we need to create a larger canvas to avoid clipping during rotation
                  emoji_size = object_data['size']
                  canvas_size = int(emoji_size * 1.5)
                  emoji_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
      
                  # Draw emoji in center of canvas
                  from core.frame_composer import draw_emoji_enhanced
                  draw_emoji_enhanced(
                      emoji_canvas,
                      emoji=object_data['emoji'],
                      position=(canvas_size // 2 - emoji_size // 2, canvas_size // 2 - emoji_size // 2),
                      size=emoji_size,
                      shadow=False
                  )
      
                  # Rotate the canvas
                  rotated = emoji_canvas.rotate(angle, resample=Image.BICUBIC, expand=False)
      
                  # Paste onto frame
                  paste_x = center_pos[0] - canvas_size // 2
                  paste_y = center_pos[1] - canvas_size // 2
                  frame.paste(rotated, (paste_x, paste_y), rotated)
      
              elif object_type == 'text':
                  from core.typography import draw_text_with_outline
                  # Similar approach - create canvas, draw text, rotate
                  text = object_data.get('text', 'SPIN!')
                  font_size = object_data.get('font_size', 50)
      
                  canvas_size = max(frame_width, frame_height)
                  text_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
      
                  # Draw text
                  text_canvas_rgb = text_canvas.convert('RGB')
                  text_canvas_rgb.paste(bg_color, (0, 0, canvas_size, canvas_size))
                  draw_text_with_outline(
                      text_canvas_rgb,
                      text,
                      position=(canvas_size // 2, canvas_size // 2),
                      font_size=font_size,
                      text_color=object_data.get('text_color', (0, 0, 0)),
                      outline_color=object_data.get('outline_color', (255, 255, 255)),
                      outline_width=3,
                      centered=True
                  )
      
                  # Convert back to RGBA for rotation
                  text_canvas = text_canvas_rgb.convert('RGBA')
      
                  # Make background transparent
                  data = text_canvas.getdata()
                  new_data = []
                  for item in data:
                      if item[:3] == bg_color:
                          new_data.append((255, 255, 255, 0))
                      else:
                          new_data.append(item)
                  text_canvas.putdata(new_data)
      
                  # Rotate
                  rotated = text_canvas.rotate(angle, resample=Image.BICUBIC, expand=False)
      
                  # Composite onto frame
                  frame_rgba = frame.convert('RGBA')
                  frame_rgba = Image.alpha_composite(frame_rgba, rotated)
                  frame = frame_rgba.convert('RGB')
      
              frames.append(frame)
      
          return frames
      
      
      def create_loading_spinner(
          num_frames: int = 20,
          spinner_type: str = 'dots',  # 'dots', 'arc', 'emoji'
          size: int = 100,
          color: tuple[int, int, int] = (100, 150, 255),
          frame_width: int = 128,
          frame_height: int = 128,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list[Image.Image]:
          """
          Create a loading spinner animation.
      
          Args:
              num_frames: Number of frames
              spinner_type: Type of spinner
              size: Spinner size
              color: Spinner color
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          from PIL import ImageDraw
          frames = []
          center = (frame_width // 2, frame_height // 2)
      
          for i in range(num_frames):
              frame = create_blank_frame(frame_width, frame_height, bg_color)
              draw = ImageDraw.Draw(frame)
      
              angle_offset = (i / num_frames) * 360
      
              if spinner_type == 'dots':
                  # Circular dots
                  num_dots = 8
                  for j in range(num_dots):
                      angle = (j / num_dots * 360 + angle_offset) * math.pi / 180
                      x = center[0] + size * 0.4 * math.cos(angle)
                      y = center[1] + size * 0.4 * math.sin(angle)
      
                      # Fade based on position
                      alpha = 1.0 - (j / num_dots)
                      dot_color = tuple(int(c * alpha) for c in color)
                      dot_radius = int(size * 0.1)
      
                      draw.ellipse(
                          [x - dot_radius, y - dot_radius, x + dot_radius, y + dot_radius],
                          fill=dot_color
                      )
      
              elif spinner_type == 'arc':
                  # Rotating arc
                  start_angle = angle_offset
                  end_angle = angle_offset + 270
                  arc_width = int(size * 0.15)
      
                  bbox = [
                      center[0] - size // 2,
                      center[1] - size // 2,
                      center[0] + size // 2,
                      center[1] + size // 2
                  ]
                  draw.arc(bbox, start_angle, end_angle, fill=color, width=arc_width)
      
              elif spinner_type == 'emoji':
                  # Rotating emoji spinner
                  angle = angle_offset
                  emoji_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
                  draw_emoji_enhanced(
                      emoji_canvas,
                      emoji='⏳',
                      position=(center[0] - size // 2, center[1] - size // 2),
                      size=size,
                      shadow=False
                  )
                  rotated = emoji_canvas.rotate(angle, center=center, resample=Image.BICUBIC)
                  frame.paste(rotated, (0, 0), rotated)
      
              frames.append(frame)
      
          return frames
      
      
      # Example usage
      if __name__ == '__main__':
          print("Creating spin animations...")
      
          builder = GIFBuilder(width=480, height=480, fps=20)
      
          # Example 1: Clockwise spin
          frames = create_spin_animation(
              object_type='emoji',
              object_data={'emoji': '🔄', 'size': 100},
              num_frames=30,
              rotation_type='clockwise',
              full_rotations=2
          )
          builder.add_frames(frames)
          builder.save('spin_clockwise.gif', num_colors=128)
      
          # Example 2: Wobble
          builder.clear()
          frames = create_spin_animation(
              object_type='emoji',
              object_data={'emoji': '🎯', 'size': 100},
              num_frames=30,
              rotation_type='wobble',
              full_rotations=3
          )
          builder.add_frames(frames)
          builder.save('spin_wobble.gif', num_colors=128)
      
          # Example 3: Loading spinner
          builder = GIFBuilder(width=128, height=128, fps=15)
          frames = create_loading_spinner(num_frames=20, spinner_type='dots')
          builder.add_frames(frames)
          builder.save('loading_spinner.gif', num_colors=64, optimize_for_emoji=True)
      
          print("Created spin animations!")
      
    • wiggle.py 9.8 KB
      #!/usr/bin/env python3
      """
      Wiggle Animation - Smooth, organic wobbling and jiggling motions.
      
      Creates playful, elastic movements that are smoother than shake.
      """
      
      import sys
      from pathlib import Path
      import math
      
      sys.path.append(str(Path(__file__).parent.parent))
      
      from PIL import Image
      from core.gif_builder import GIFBuilder
      from core.frame_composer import create_blank_frame, draw_emoji_enhanced
      from core.easing import interpolate
      
      
      def create_wiggle_animation(
          object_type: str = 'emoji',
          object_data: dict | None = None,
          num_frames: int = 30,
          wiggle_type: str = 'jello',  # 'jello', 'wave', 'bounce', 'sway'
          intensity: float = 1.0,
          cycles: float = 2.0,
          center_pos: tuple[int, int] = (240, 240),
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list[Image.Image]:
          """
          Create wiggle/wobble animation.
      
          Args:
              object_type: 'emoji', 'text'
              object_data: Object configuration
              num_frames: Number of frames
              wiggle_type: Type of wiggle motion
              intensity: Wiggle intensity multiplier
              cycles: Number of wiggle cycles
              center_pos: Center position
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          frames = []
      
          # Default object data
          if object_data is None:
              if object_type == 'emoji':
                  object_data = {'emoji': '🎈', 'size': 100}
      
          for i in range(num_frames):
              t = i / (num_frames - 1) if num_frames > 1 else 0
              frame = create_blank_frame(frame_width, frame_height, bg_color)
      
              # Calculate wiggle transformations
              offset_x = 0
              offset_y = 0
              rotation = 0
              scale_x = 1.0
              scale_y = 1.0
      
              if wiggle_type == 'jello':
                  # Jello wobble - multiple frequencies
                  freq1 = cycles * 2 * math.pi
                  freq2 = cycles * 3 * math.pi
                  freq3 = cycles * 5 * math.pi
      
                  decay = 1.0 - t if cycles < 1.5 else 1.0  # Decay for single wiggles
      
                  offset_x = (
                      math.sin(freq1 * t) * 15 +
                      math.sin(freq2 * t) * 8 +
                      math.sin(freq3 * t) * 3
                  ) * intensity * decay
      
                  rotation = (
                      math.sin(freq1 * t) * 10 +
                      math.cos(freq2 * t) * 5
                  ) * intensity * decay
      
                  # Squash and stretch
                  scale_y = 1.0 + math.sin(freq1 * t) * 0.1 * intensity * decay
                  scale_x = 1.0 / scale_y  # Preserve volume
      
              elif wiggle_type == 'wave':
                  # Wave motion
                  freq = cycles * 2 * math.pi
                  offset_y = math.sin(freq * t) * 20 * intensity
                  rotation = math.sin(freq * t + math.pi / 4) * 8 * intensity
      
              elif wiggle_type == 'bounce':
                  # Bouncy wiggle
                  freq = cycles * 2 * math.pi
                  bounce = abs(math.sin(freq * t))
      
                  scale_y = 1.0 + bounce * 0.2 * intensity
                  scale_x = 1.0 - bounce * 0.1 * intensity
                  offset_y = -bounce * 10 * intensity
      
              elif wiggle_type == 'sway':
                  # Gentle sway back and forth
                  freq = cycles * 2 * math.pi
                  offset_x = math.sin(freq * t) * 25 * intensity
                  rotation = math.sin(freq * t) * 12 * intensity
      
                  # Subtle scale change
                  scale = 1.0 + math.sin(freq * t) * 0.05 * intensity
                  scale_x = scale
                  scale_y = scale
      
              elif wiggle_type == 'tail_wag':
                  # Like a wagging tail - base stays, tip moves
                  freq = cycles * 2 * math.pi
                  wag = math.sin(freq * t) * intensity
      
                  # Rotation focused at one end
                  rotation = wag * 20
                  offset_x = wag * 15
      
              # Apply transformations
              if object_type == 'emoji':
                  size = object_data['size']
                  size_x = int(size * scale_x)
                  size_y = int(size * scale_y)
      
                  # For non-uniform scaling or rotation, we need to use PIL transforms
                  if abs(scale_x - scale_y) > 0.01 or abs(rotation) > 0.1:
                      # Create emoji on transparent canvas
                      canvas_size = int(size * 2)
                      emoji_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
      
                      # Draw emoji
                      draw_emoji_enhanced(
                          emoji_canvas,
                          emoji=object_data['emoji'],
                          position=(canvas_size // 2 - size // 2, canvas_size // 2 - size // 2),
                          size=size,
                          shadow=False
                      )
      
                      # Scale
                      if abs(scale_x - scale_y) > 0.01:
                          new_size = (int(canvas_size * scale_x), int(canvas_size * scale_y))
                          emoji_canvas = emoji_canvas.resize(new_size, Image.LANCZOS)
                          canvas_size_x, canvas_size_y = new_size
                      else:
                          canvas_size_x = canvas_size_y = canvas_size
      
                      # Rotate
                      if abs(rotation) > 0.1:
                          emoji_canvas = emoji_canvas.rotate(
                              rotation,
                              resample=Image.BICUBIC,
                              expand=False
                          )
      
                      # Position with offset
                      paste_x = int(center_pos[0] - canvas_size_x // 2 + offset_x)
                      paste_y = int(center_pos[1] - canvas_size_y // 2 + offset_y)
      
                      frame_rgba = frame.convert('RGBA')
                      frame_rgba.paste(emoji_canvas, (paste_x, paste_y), emoji_canvas)
                      frame = frame_rgba.convert('RGB')
                  else:
                      # Simple case - just offset
                      pos_x = int(center_pos[0] - size // 2 + offset_x)
                      pos_y = int(center_pos[1] - size // 2 + offset_y)
                      draw_emoji_enhanced(
                          frame,
                          emoji=object_data['emoji'],
                          position=(pos_x, pos_y),
                          size=size,
                          shadow=object_data.get('shadow', True)
                      )
      
              elif object_type == 'text':
                  from core.typography import draw_text_with_outline
      
                  # Create text on canvas for transformation
                  canvas_size = max(frame_width, frame_height)
                  text_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
      
                  # Convert to RGB for drawing
                  text_canvas_rgb = text_canvas.convert('RGB')
                  text_canvas_rgb.paste(bg_color, (0, 0, canvas_size, canvas_size))
      
                  draw_text_with_outline(
                      text_canvas_rgb,
                      text=object_data.get('text', 'WIGGLE'),
                      position=(canvas_size // 2, canvas_size // 2),
                      font_size=object_data.get('font_size', 50),
                      text_color=object_data.get('text_color', (0, 0, 0)),
                      outline_color=object_data.get('outline_color', (255, 255, 255)),
                      outline_width=3,
                      centered=True
                  )
      
                  # Make transparent
                  text_canvas = text_canvas_rgb.convert('RGBA')
                  data = text_canvas.getdata()
                  new_data = []
                  for item in data:
                      if item[:3] == bg_color:
                          new_data.append((255, 255, 255, 0))
                      else:
                          new_data.append(item)
                  text_canvas.putdata(new_data)
      
                  # Apply rotation
                  if abs(rotation) > 0.1:
                      text_canvas = text_canvas.rotate(rotation, center=(canvas_size // 2, canvas_size // 2), resample=Image.BICUBIC)
      
                  # Crop to frame with offset
                  left = (canvas_size - frame_width) // 2 - int(offset_x)
                  top = (canvas_size - frame_height) // 2 - int(offset_y)
                  text_cropped = text_canvas.crop((left, top, left + frame_width, top + frame_height))
      
                  frame_rgba = frame.convert('RGBA')
                  frame = Image.alpha_composite(frame_rgba, text_cropped)
                  frame = frame.convert('RGB')
      
              frames.append(frame)
      
          return frames
      
      
      def create_excited_wiggle(
          emoji: str = '🎉',
          num_frames: int = 20,
          frame_size: int = 128
      ) -> list[Image.Image]:
          """
          Create excited wiggle for emoji GIFs.
      
          Args:
              emoji: Emoji to wiggle
              num_frames: Number of frames
              frame_size: Frame size (square)
      
          Returns:
              List of frames
          """
          return create_wiggle_animation(
              object_type='emoji',
              object_data={'emoji': emoji, 'size': 80, 'shadow': False},
              num_frames=num_frames,
              wiggle_type='jello',
              intensity=0.8,
              cycles=2,
              center_pos=(frame_size // 2, frame_size // 2),
              frame_width=frame_size,
              frame_height=frame_size,
              bg_color=(255, 255, 255)
          )
      
      
      # Example usage
      if __name__ == '__main__':
          print("Creating wiggle animations...")
      
          builder = GIFBuilder(width=480, height=480, fps=20)
      
          # Example 1: Jello wiggle
          frames = create_wiggle_animation(
              object_type='emoji',
              object_data={'emoji': '🎈', 'size': 100},
              num_frames=40,
              wiggle_type='jello',
              intensity=1.0,
              cycles=2
          )
          builder.add_frames(frames)
          builder.save('wiggle_jello.gif', num_colors=128)
      
          # Example 2: Wave
          builder.clear()
          frames = create_wiggle_animation(
              object_type='emoji',
              object_data={'emoji': '🌊', 'size': 100},
              num_frames=30,
              wiggle_type='wave',
              intensity=1.2,
              cycles=3
          )
          builder.add_frames(frames)
          builder.save('wiggle_wave.gif', num_colors=128)
      
          # Example 3: Excited wiggle (emoji size)
          builder = GIFBuilder(width=128, height=128, fps=15)
          frames = create_excited_wiggle(emoji='🎉', num_frames=20)
          builder.add_frames(frames)
          builder.save('wiggle_excited.gif', num_colors=48, optimize_for_emoji=True)
      
          print("Created wiggle animations!")
      
    • zoom.py 9.9 KB
      #!/usr/bin/env python3
      """
      Zoom Animation - Scale objects dramatically for emphasis.
      
      Creates zoom in, zoom out, and dramatic scaling effects.
      """
      
      import sys
      from pathlib import Path
      import math
      
      sys.path.append(str(Path(__file__).parent.parent))
      
      from PIL import Image, ImageFilter
      from core.gif_builder import GIFBuilder
      from core.frame_composer import create_blank_frame, draw_emoji_enhanced
      from core.easing import interpolate
      
      
      def create_zoom_animation(
          object_type: str = 'emoji',
          object_data: dict | None = None,
          num_frames: int = 30,
          zoom_type: str = 'in',  # 'in', 'out', 'in_out', 'punch'
          scale_range: tuple[float, float] = (0.1, 2.0),
          easing: str = 'ease_out',
          add_motion_blur: bool = False,
          center_pos: tuple[int, int] = (240, 240),
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list[Image.Image]:
          """
          Create zoom animation.
      
          Args:
              object_type: 'emoji', 'text', 'image'
              object_data: Object configuration
              num_frames: Number of frames
              zoom_type: Type of zoom effect
              scale_range: (start_scale, end_scale) tuple
              easing: Easing function
              add_motion_blur: Add blur for speed effect
              center_pos: Center position
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          frames = []
      
          # Default object data
          if object_data is None:
              if object_type == 'emoji':
                  object_data = {'emoji': '🔍', 'size': 100}
      
          base_size = object_data.get('size', 100) if object_type == 'emoji' else object_data.get('font_size', 60)
          start_scale, end_scale = scale_range
      
          for i in range(num_frames):
              t = i / (num_frames - 1) if num_frames > 1 else 0
      
              # Calculate scale based on zoom type
              if zoom_type == 'in':
                  scale = interpolate(start_scale, end_scale, t, easing)
              elif zoom_type == 'out':
                  scale = interpolate(end_scale, start_scale, t, easing)
              elif zoom_type == 'in_out':
                  if t < 0.5:
                      scale = interpolate(start_scale, end_scale, t * 2, easing)
                  else:
                      scale = interpolate(end_scale, start_scale, (t - 0.5) * 2, easing)
              elif zoom_type == 'punch':
                  # Quick zoom in with overshoot then settle
                  if t < 0.3:
                      scale = interpolate(start_scale, end_scale * 1.2, t / 0.3, 'ease_out')
                  else:
                      scale = interpolate(end_scale * 1.2, end_scale, (t - 0.3) / 0.7, 'elastic_out')
              else:
                  scale = interpolate(start_scale, end_scale, t, easing)
      
              # Create frame
              frame = create_blank_frame(frame_width, frame_height, bg_color)
      
              if object_type == 'emoji':
                  current_size = int(base_size * scale)
      
                  # Clamp size to reasonable bounds
                  current_size = max(12, min(current_size, frame_width * 2))
      
                  # Create emoji on transparent background
                  canvas_size = max(frame_width, frame_height, current_size) * 2
                  emoji_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
      
                  draw_emoji_enhanced(
                      emoji_canvas,
                      emoji=object_data['emoji'],
                      position=(canvas_size // 2 - current_size // 2, canvas_size // 2 - current_size // 2),
                      size=current_size,
                      shadow=False
                  )
      
                  # Optional motion blur for fast zooms
                  if add_motion_blur and abs(scale - 1.0) > 0.5:
                      blur_amount = min(5, int(abs(scale - 1.0) * 3))
                      emoji_canvas = emoji_canvas.filter(ImageFilter.GaussianBlur(blur_amount))
      
                  # Crop to frame size centered
                  left = (canvas_size - frame_width) // 2
                  top = (canvas_size - frame_height) // 2
                  emoji_cropped = emoji_canvas.crop((left, top, left + frame_width, top + frame_height))
      
                  # Composite
                  frame_rgba = frame.convert('RGBA')
                  frame = Image.alpha_composite(frame_rgba, emoji_cropped)
                  frame = frame.convert('RGB')
      
              elif object_type == 'text':
                  from core.typography import draw_text_with_outline
      
                  current_size = int(base_size * scale)
                  current_size = max(10, min(current_size, 500))
      
                  # Create oversized canvas for large text
                  canvas_size = max(frame_width, frame_height, current_size * 10)
                  text_canvas = Image.new('RGB', (canvas_size, canvas_size), bg_color)
      
                  draw_text_with_outline(
                      text_canvas,
                      text=object_data.get('text', 'ZOOM'),
                      position=(canvas_size // 2, canvas_size // 2),
                      font_size=current_size,
                      text_color=object_data.get('text_color', (0, 0, 0)),
                      outline_color=object_data.get('outline_color', (255, 255, 255)),
                      outline_width=max(2, int(current_size * 0.05)),
                      centered=True
                  )
      
                  # Crop to frame
                  left = (canvas_size - frame_width) // 2
                  top = (canvas_size - frame_height) // 2
                  frame = text_canvas.crop((left, top, left + frame_width, top + frame_height))
      
              frames.append(frame)
      
          return frames
      
      
      def create_explosion_zoom(
          emoji: str = '💥',
          num_frames: int = 20,
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list[Image.Image]:
          """
          Create dramatic explosion zoom effect.
      
          Args:
              emoji: Emoji to explode
              num_frames: Number of frames
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          frames = []
      
          for i in range(num_frames):
              t = i / (num_frames - 1) if num_frames > 1 else 0
      
              # Exponential zoom
              scale = 0.1 * math.exp(t * 5)
      
              # Add rotation for drama
              angle = t * 360 * 2
      
              frame = create_blank_frame(frame_width, frame_height, bg_color)
      
              current_size = int(100 * scale)
              current_size = max(12, min(current_size, frame_width * 3))
      
              # Create emoji
              canvas_size = max(frame_width, frame_height, current_size) * 2
              emoji_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
      
              draw_emoji_enhanced(
                  emoji_canvas,
                  emoji=emoji,
                  position=(canvas_size // 2 - current_size // 2, canvas_size // 2 - current_size // 2),
                  size=current_size,
                  shadow=False
              )
      
              # Rotate
              emoji_canvas = emoji_canvas.rotate(angle, center=(canvas_size // 2, canvas_size // 2), resample=Image.BICUBIC)
      
              # Add motion blur for later frames
              if t > 0.5:
                  blur_amount = int((t - 0.5) * 10)
                  emoji_canvas = emoji_canvas.filter(ImageFilter.GaussianBlur(blur_amount))
      
              # Crop and composite
              left = (canvas_size - frame_width) // 2
              top = (canvas_size - frame_height) // 2
              emoji_cropped = emoji_canvas.crop((left, top, left + frame_width, top + frame_height))
      
              frame_rgba = frame.convert('RGBA')
              frame = Image.alpha_composite(frame_rgba, emoji_cropped)
              frame = frame.convert('RGB')
      
              frames.append(frame)
      
          return frames
      
      
      def create_mind_blown_zoom(
          emoji: str = '🤯',
          num_frames: int = 30,
          frame_width: int = 480,
          frame_height: int = 480,
          bg_color: tuple[int, int, int] = (255, 255, 255)
      ) -> list[Image.Image]:
          """
          Create "mind blown" dramatic zoom with shake.
      
          Args:
              emoji: Emoji to use
              num_frames: Number of frames
              frame_width: Frame width
              frame_height: Frame height
              bg_color: Background color
      
          Returns:
              List of frames
          """
          frames = []
      
          for i in range(num_frames):
              t = i / (num_frames - 1) if num_frames > 1 else 0
      
              # Zoom in then shake
              if t < 0.5:
                  scale = interpolate(0.3, 1.2, t * 2, 'ease_out')
                  shake_x = 0
                  shake_y = 0
              else:
                  scale = 1.2
                  # Shake intensifies
                  shake_intensity = (t - 0.5) * 40
                  shake_x = int(math.sin(t * 50) * shake_intensity)
                  shake_y = int(math.cos(t * 45) * shake_intensity)
      
              frame = create_blank_frame(frame_width, frame_height, bg_color)
      
              current_size = int(100 * scale)
              center_x = frame_width // 2 + shake_x
              center_y = frame_height // 2 + shake_y
      
              emoji_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
              draw_emoji_enhanced(
                  emoji_canvas,
                  emoji=emoji,
                  position=(center_x - current_size // 2, center_y - current_size // 2),
                  size=current_size,
                  shadow=False
              )
      
              frame_rgba = frame.convert('RGBA')
              frame = Image.alpha_composite(frame_rgba, emoji_canvas)
              frame = frame.convert('RGB')
      
              frames.append(frame)
      
          return frames
      
      
      # Example usage
      if __name__ == '__main__':
          print("Creating zoom animations...")
      
          builder = GIFBuilder(width=480, height=480, fps=20)
      
          # Example 1: Zoom in
          frames = create_zoom_animation(
              object_type='emoji',
              object_data={'emoji': '🔍', 'size': 100},
              num_frames=30,
              zoom_type='in',
              scale_range=(0.1, 1.5),
              easing='ease_out'
          )
          builder.add_frames(frames)
          builder.save('zoom_in.gif', num_colors=128)
      
          # Example 2: Explosion zoom
          builder.clear()
          frames = create_explosion_zoom(emoji='💥', num_frames=20)
          builder.add_frames(frames)
          builder.save('zoom_explosion.gif', num_colors=128)
      
          # Example 3: Mind blown
          builder.clear()
          frames = create_mind_blown_zoom(emoji='🤯', num_frames=30)
          builder.add_frames(frames)
          builder.save('zoom_mind_blown.gif', num_colors=128)
      
          print("Created zoom animations!")
      
  • LICENSE.txt 11.1 KB
    
                                     Apache License
                               Version 2.0, January 2004
                            http://www.apache.org/licenses/
    
       TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    
       1. Definitions.
    
          "License" shall mean the terms and conditions for use, reproduction,
          and distribution as defined by Sections 1 through 9 of this document.
    
          "Licensor" shall mean the copyright owner or entity authorized by
          the copyright owner that is granting the License.
    
          "Legal Entity" shall mean the union of the acting entity and all
          other entities that control, are controlled by, or are under common
          control with that entity. For the purposes of this definition,
          "control" means (i) the power, direct or indirect, to cause the
          direction or management of such entity, whether by contract or
          otherwise, or (ii) ownership of fifty percent (50%) or more of the
          outstanding shares, or (iii) beneficial ownership of such entity.
    
          "You" (or "Your") shall mean an individual or Legal Entity
          exercising permissions granted by this License.
    
          "Source" form shall mean the preferred form for making modifications,
          including but not limited to software source code, documentation
          source, and configuration files.
    
          "Object" form shall mean any form resulting from mechanical
          transformation or translation of a Source form, including but
          not limited to compiled object code, generated documentation,
          and conversions to other media types.
    
          "Work" shall mean the work of authorship, whether in Source or
          Object form, made available under the License, as indicated by a
          copyright notice that is included in or attached to the work
          (an example is provided in the Appendix below).
    
          "Derivative Works" shall mean any work, whether in Source or Object
          form, that is based on (or derived from) the Work and for which the
          editorial revisions, annotations, elaborations, or other modifications
          represent, as a whole, an original work of authorship. For the purposes
          of this License, Derivative Works shall not include works that remain
          separable from, or merely link (or bind by name) to the interfaces of,
          the Work and Derivative Works thereof.
    
          "Contribution" shall mean any work of authorship, including
          the original version of the Work and any modifications or additions
          to that Work or Derivative Works thereof, that is intentionally
          submitted to Licensor for inclusion in the Work by the copyright owner
          or by an individual or Legal Entity authorized to submit on behalf of
          the copyright owner. For the purposes of this definition, "submitted"
          means any form of electronic, verbal, or written communication sent
          to the Licensor or its representatives, including but not limited to
          communication on electronic mailing lists, source code control systems,
          and issue tracking systems that are managed by, or on behalf of, the
          Licensor for the purpose of discussing and improving the Work, but
          excluding communication that is conspicuously marked or otherwise
          designated in writing by the copyright owner as "Not a Contribution."
    
          "Contributor" shall mean Licensor and any individual or Legal Entity
          on behalf of whom a Contribution has been received by Licensor and
          subsequently incorporated within the Work.
    
       2. Grant of Copyright License. Subject to the terms and conditions of
          this License, each Contributor hereby grants to You a perpetual,
          worldwide, non-exclusive, no-charge, royalty-free, irrevocable
          copyright license to reproduce, prepare Derivative Works of,
          publicly display, publicly perform, sublicense, and distribute the
          Work and such Derivative Works in Source or Object form.
    
       3. Grant of Patent License. Subject to the terms and conditions of
          this License, each Contributor hereby grants to You a perpetual,
          worldwide, non-exclusive, no-charge, royalty-free, irrevocable
          (except as stated in this section) patent license to make, have made,
          use, offer to sell, sell, import, and otherwise transfer the Work,
          where such license applies only to those patent claims licensable
          by such Contributor that are necessarily infringed by their
          Contribution(s) alone or by combination of their Contribution(s)
          with the Work to which such Contribution(s) was submitted. If You
          institute patent litigation against any entity (including a
          cross-claim or counterclaim in a lawsuit) alleging that the Work
          or a Contribution incorporated within the Work constitutes direct
          or contributory patent infringement, then any patent licenses
          granted to You under this License for that Work shall terminate
          as of the date such litigation is filed.
    
       4. Redistribution. You may reproduce and distribute copies of the
          Work or Derivative Works thereof in any medium, with or without
          modifications, and in Source or Object form, provided that You
          meet the following conditions:
    
          (a) You must give any other recipients of the Work or
              Derivative Works a copy of this License; and
    
          (b) You must cause any modified files to carry prominent notices
              stating that You changed the files; and
    
          (c) You must retain, in the Source form of any Derivative Works
              that You distribute, all copyright, patent, trademark, and
              attribution notices from the Source form of the Work,
              excluding those notices that do not pertain to any part of
              the Derivative Works; and
    
          (d) If the Work includes a "NOTICE" text file as part of its
              distribution, then any Derivative Works that You distribute must
              include a readable copy of the attribution notices contained
              within such NOTICE file, excluding those notices that do not
              pertain to any part of the Derivative Works, in at least one
              of the following places: within a NOTICE text file distributed
              as part of the Derivative Works; within the Source form or
              documentation, if provided along with the Derivative Works; or,
              within a display generated by the Derivative Works, if and
              wherever such third-party notices normally appear. The contents
              of the NOTICE file are for informational purposes only and
              do not modify the License. You may add Your own attribution
              notices within Derivative Works that You distribute, alongside
              or as an addendum to the NOTICE text from the Work, provided
              that such additional attribution notices cannot be construed
              as modifying the License.
    
          You may add Your own copyright statement to Your modifications and
          may provide additional or different license terms and conditions
          for use, reproduction, or distribution of Your modifications, or
          for any such Derivative Works as a whole, provided Your use,
          reproduction, and distribution of the Work otherwise complies with
          the conditions stated in this License.
    
       5. Submission of Contributions. Unless You explicitly state otherwise,
          any Contribution intentionally submitted for inclusion in the Work
          by You to the Licensor shall be under the terms and conditions of
          this License, without any additional terms or conditions.
          Notwithstanding the above, nothing herein shall supersede or modify
          the terms of any separate license agreement you may have executed
          with Licensor regarding such Contributions.
    
       6. Trademarks. This License does not grant permission to use the trade
          names, trademarks, service marks, or product names of the Licensor,
          except as required for reasonable and customary use in describing the
          origin of the Work and reproducing the content of the NOTICE file.
    
       7. Disclaimer of Warranty. Unless required by applicable law or
          agreed to in writing, Licensor provides the Work (and each
          Contributor provides its Contributions) on an "AS IS" BASIS,
          WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
          implied, including, without limitation, any warranties or conditions
          of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
          PARTICULAR PURPOSE. You are solely responsible for determining the
          appropriateness of using or redistributing the Work and assume any
          risks associated with Your exercise of permissions under this License.
    
       8. Limitation of Liability. In no event and under no legal theory,
          whether in tort (including negligence), contract, or otherwise,
          unless required by applicable law (such as deliberate and grossly
          negligent acts) or agreed to in writing, shall any Contributor be
          liable to You for damages, including any direct, indirect, special,
          incidental, or consequential damages of any character arising as a
          result of this License or out of the use or inability to use the
          Work (including but not limited to damages for loss of goodwill,
          work stoppage, computer failure or malfunction, or any and all
          other commercial damages or losses), even if such Contributor
          has been advised of the possibility of such damages.
    
       9. Accepting Warranty or Additional Liability. While redistributing
          the Work or Derivative Works thereof, You may choose to offer,
          and charge a fee for, acceptance of support, warranty, indemnity,
          or other liability obligations and/or rights consistent with this
          License. However, in accepting such obligations, You may act only
          on Your own behalf and on Your sole responsibility, not on behalf
          of any other Contributor, and only if You agree to indemnify,
          defend, and hold each Contributor harmless for any liability
          incurred by, or claims asserted against, such Contributor by reason
          of your accepting any such warranty or additional liability.
    
       END OF TERMS AND CONDITIONS
    
       APPENDIX: How to apply the Apache License to your work.
    
          To apply the Apache License to your work, attach the following
          boilerplate notice, with the fields enclosed by brackets "[]"
          replaced with your own identifying information. (Don't include
          the brackets!)  The text should be enclosed in the appropriate
          comment syntax for the file format. We also recommend that a
          file or class name and description of purpose be included on the
          same "printed page" as the copyright notice for easier
          identification within third-party archives.
    
       Copyright [yyyy] [name of copyright owner]
    
       Licensed under the Apache License, Version 2.0 (the "License");
       you may not use this file except in compliance with the License.
       You may obtain a copy of the License at
    
           http://www.apache.org/licenses/LICENSE-2.0
    
       Unless required by applicable law or agreed to in writing, software
       distributed under the License is distributed on an "AS IS" BASIS,
       WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
       See the License for the specific language governing permissions and
       limitations under the License.
  • requirements.txt 66 B
    pillow>=10.0.0
    imageio>=2.31.0
    imageio-ffmpeg>=0.4.9
    numpy>=1.24.0
  • SKILL.md 16.8 KB
    ---
    name: slack-gif-creator
    category: creative-collaboration
    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".
    license: Complete terms in LICENSE.txt
    ---
    
    # Slack GIF Creator - Flexible Toolkit
    
    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.**
    
    ## Slack's Requirements
    
    Slack has specific requirements for GIFs based on their use:
    
    **Message GIFs:**
    - Max size: ~2MB
    - Optimal dimensions: 480x480
    - Typical FPS: 15-20
    - Color limit: 128-256
    - Duration: 2-5s
    
    **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:
    
    ```python
    from core.gif_builder import GIFBuilder
    
    # After creating your GIF, check if it meets requirements
    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)
    
    # The save method automatically warns if file exceeds limits
    # info dict contains: size_kb, size_mb, frame_count, duration_seconds
    ```
    
    **File size validator**:
    ```python
    from core.validators import check_slack_size
    
    # Check if GIF meets size limits
    passes, info = check_slack_size('emoji.gif', is_emoji=True)
    # Returns: (True/False, dict with size details)
    ```
    
    **Dimension validator**:
    ```python
    from core.validators import validate_dimensions
    
    # Check dimensions
    passes, info = validate_dimensions(128, 128, is_emoji=True)
    # Returns: (True/False, dict with dimension details)
    ```
    
    **Complete validation**:
    ```python
    from core.validators import validate_gif, is_slack_ready
    
    # Run all validations
    all_pass, results = validate_gif('emoji.gif', is_emoji=True)
    
    # Or quick check
    if is_slack_ready('emoji.gif', is_emoji=True):
        print("Ready to upload!")
    ```
    
    ## 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:
    
    ```python
    from core.easing import interpolate
    
    # Object falling (accelerates)
    y = interpolate(start=0, end=400, t=progress, easing='ease_in')
    
    # Object landing (decelerates)
    y = interpolate(start=0, end=400, t=progress, 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 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:
    
    ```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
    )
    
    # 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)
    ```
    
    ## Optimization Strategies
    
    When your GIF is too large:
    
    **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
    
    **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
    )
    
    # 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:
    
    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
    
    **The goal is creative freedom within Slack's technical constraints.**
    
    ## Dependencies
    
    To use this toolkit, install these dependencies only if they aren't already present:
    
    ```bash
    pip install pillow imageio numpy
    ```
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related