# Gif Sticker Maker

> Animated GIF and sticker creation expertise using AI image generation, video processing, and frame animation. Use when creating animated stickers.

- Skill: `yigityildiz0/gif-sticker-maker` (Agent Skill)
- Install (CLI): `npx skillmds@latest add yigityildiz0/gif-sticker-maker`
- Raw SKILL.md: https://api.skillmd.com/api/skills/yigityildiz0/gif-sticker-maker/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: yigityildiz0 (https://skillmd.com/u/yigityildiz0)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/yigityildiz0/gif-sticker-maker

---


# GIF and Sticker Maker

Structured guidance for creating animated GIFs and stickers using AI image generation, video processing, and programmatic frame animation. Covers the full pipeline from image generation through animation assembly to platform-specific optimization.

## When to Use This Skill

Use this skill for:

- Generating animated stickers from text prompts using AI image providers
- Converting video clips or screen recordings into optimized GIFs
- Building sprite sheet animations with tweening and easing
- Creating avatar generators with animated expression cycles
- Automating sticker pack creation for Telegram, Discord, Slack, or WhatsApp
- Applying animation effects (bounce, pulse, rotate, fade) to static images
- Optimizing GIF file sizes while preserving visual quality

**Trigger phrases**: "animated GIF", "sticker maker", "GIF creation", "sticker generator", "sprite animation", "video to GIF", "avatar animation", "animated emoji", "GIF optimization", "sticker pack", "frame animation", "Pillow GIF", "ffmpeg GIF", "animated sticker"

## What This Skill Does

Provides GIF and sticker creation patterns including:

- **Pipeline Architecture**: Image generation, frame assembly, GIF encoding, and optimization stages
- **AI Image Generation**: Provider-agnostic integration with DALL-E, Stable Diffusion, and other APIs
- **Pillow GIF Assembly**: Frame generation, palette optimization, duration control, looping, transparency
- **ffmpeg Conversion**: Video-to-GIF with two-pass palette generation, scaling, and frame rate control
- **Animation Techniques**: Sprite sheets, tweening, easing functions, keyframe interpolation, bounce/pulse/rotate effects
- **Sticker Formatting**: Transparent backgrounds, outline strokes, caption overlays, platform-specific sizing
- **Batch Processing**: Parallel generation, file size targets, color reduction, dithering, output validation

## Instructions

### Step 1: Pipeline Architecture and Tool Selection

The GIF and sticker creation pipeline flows through four stages: content generation, frame preparation, animation assembly, and output optimization. Select tools based on your input source and quality requirements.

**Pipeline Overview**:

```
Input Source          Frame Preparation       Assembly            Optimization
-----------          -----------------       --------            ------------
AI-generated      -> Resize / crop        -> Pillow GIF      -> Color reduction
  images             Remove background       assembly            Lossy compression
                     Add outlines                                Size validation
Video clip        -> Extract frames       -> ffmpeg palette   -> Frame rate trim
                     Select key frames       generation          Dimension scaling
                     Apply filters
Sprite sheet      -> Slice into frames    -> Pillow sequence  -> Dithering
                     Apply tweening          with duration       Palette optimization
                     Easing interpolation    control
Static image      -> Duplicate + transform -> Pillow animate  -> Loop optimization
                     Apply effects            with effects       File size check
```

**Tool Selection Matrix**:

| Tool | Best For | Install | Notes |
|------|----------|---------|-------|
| Pillow (Python) | Frame-by-frame GIF assembly, text overlays, effects | `pip install Pillow` | Pure Python, cross-platform |
| ffmpeg | Video-to-GIF, scaling, frame extraction, palette gen | System package | Industry standard, CLI-based |
| ImageMagick | GIF optimization, format conversion, batch ops | System package | `convert` and `gifsicle` combo |
| sharp (Node.js) | Server-side image processing, web pipelines | `npm install sharp` | Fast, libvips-based |
| gifsicle | GIF optimization, lossy compression, frame editing | System package | Specialized GIF optimizer |
| APNG Assembler | Animated PNG for platforms that support it | System package | Lossless alternative to GIF |

**Project Scaffold**:

```python
from pathlib import Path

# Standard project layout for a sticker generation pipeline
PROJECT_LAYOUT = {
    "src/": "Pipeline source code",
    "src/generators/": "AI image generation adapters",
    "src/processors/": "Frame processing (resize, crop, effects)",
    "src/assemblers/": "GIF/APNG assembly modules",
    "src/optimizers/": "File size optimization and validation",
    "assets/sprites/": "Source sprite sheets",
    "assets/fonts/": "Fonts for text overlays",
    "output/": "Generated GIFs and stickers",
    "output/previews/": "Low-res previews for review",
    "config/": "Platform presets and generation configs",
}

# Configuration dataclass for the pipeline
from dataclasses import dataclass, field

@dataclass
class PipelineConfig:
    """Configuration for a GIF/sticker generation run."""
    output_dir: Path = Path("output")
    width: int = 512
    height: int = 512
    frame_count: int = 24
    frame_duration_ms: int = 80
    loop_count: int = 0          # 0 = infinite loop
    max_file_size_kb: int = 256
    max_colors: int = 256
    transparent_background: bool = True
    platform: str = "telegram"   # telegram, discord, slack, whatsapp
```

**Platform Presets**:

```python
PLATFORM_PRESETS: dict[str, dict] = {
    "telegram": {
        "max_size_kb": 512,
        "dimensions": (512, 512),
        "format": "webm",            # Telegram prefers WebM for animated stickers
        "fallback_format": "gif",
        "max_duration_s": 3,
        "max_fps": 30,
    },
    "discord": {
        "max_size_kb": 256,           # Standard emoji limit
        "dimensions": (128, 128),     # Emoji size; stickers can be larger
        "format": "gif",
        "max_duration_s": 5,
        "max_fps": 50,
    },
    "slack": {
        "max_size_kb": 128,           # Custom emoji limit
        "dimensions": (128, 128),
        "format": "gif",
        "max_duration_s": None,       # No strict limit
        "max_fps": 30,
    },
    "whatsapp": {
        "max_size_kb": 500,
        "dimensions": (512, 512),
        "format": "webp",             # WhatsApp uses animated WebP
        "fallback_format": "gif",
        "max_duration_s": 6,
        "max_fps": 30,
    },
    "imessage": {
        "max_size_kb": 500,
        "dimensions": (618, 618),
        "format": "gif",              # or APNG
        "max_duration_s": None,
        "max_fps": 30,
    },
}
```

### Step 2: AI Image Generation Integration

Integrate with AI image generation providers using a provider-agnostic adapter pattern. This allows swapping providers without changing the pipeline logic.

**Provider Adapter Interface**:

```python
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path

@dataclass
class GenerationRequest:
    """Provider-agnostic image generation request."""
    prompt: str
    negative_prompt: str = ""
    width: int = 512
    height: int = 512
    num_images: int = 1
    style: str = "sticker"        # sticker, pixel-art, cartoon, chibi, funko
    seed: int | None = None       # For reproducibility

@dataclass
class GeneratedImage:
    """Result from an image generation provider."""
    data: bytes
    width: int
    height: int
    provider: str
    seed: int | None = None
    metadata: dict = None

    def __post_init__(self) -> None:
        if self.metadata is None:
            self.metadata = {}

class ImageGenerator(ABC):
    """Abstract adapter for AI image generation providers."""

    @abstractmethod
    async def generate(self, request: GenerationRequest) -> list[GeneratedImage]:
        """Generate images from a text prompt."""
        ...

    @abstractmethod
    async def check_health(self) -> bool:
        """Verify the provider is accessible and quota is available."""
        ...
```

**OpenAI DALL-E Adapter**:

```python
import httpx
import base64

class DallEGenerator(ImageGenerator):
    """Adapter for OpenAI DALL-E image generation."""

    def __init__(self, api_key: str, model: str = "dall-e-3") -> None:
        self._api_key = api_key
        self._model = model
        self._base_url = "https://api.openai.com/v1"

    async def generate(self, request: GenerationRequest) -> list[GeneratedImage]:
        prompt = self._build_sticker_prompt(request)
        async with httpx.AsyncClient(timeout=60.0) as client:
            response = await client.post(
                f"{self._base_url}/images/generations",
                headers={"Authorization": f"Bearer {self._api_key}"},
                json={
                    "model": self._model,
                    "prompt": prompt,
                    "n": request.num_images,
                    "size": f"{request.width}x{request.height}",
                    "response_format": "b64_json",
                },
            )
            response.raise_for_status()
            data = response.json()

        results = []
        for item in data["data"]:
            image_bytes = base64.b64decode(item["b64_json"])
            results.append(GeneratedImage(
                data=image_bytes,
                width=request.width,
                height=request.height,
                provider="dall-e",
                metadata={"revised_prompt": item.get("revised_prompt", "")},
            ))
        return results

    def _build_sticker_prompt(self, request: GenerationRequest) -> str:
        style_modifiers = STYLE_PROMPT_MODIFIERS.get(request.style, "")
        return f"{request.prompt}. {style_modifiers}"

    async def check_health(self) -> bool:
        async with httpx.AsyncClient(timeout=10.0) as client:
            response = await client.get(
                f"{self._base_url}/models",
                headers={"Authorization": f"Bearer {self._api_key}"},
            )
            return response.status_code == 200
```

**Stable Diffusion Adapter** (local or API-based):

```python
class StableDiffusionGenerator(ImageGenerator):
    """Adapter for Stable Diffusion (via local Automatic1111 or Stability AI API)."""

    def __init__(self, base_url: str, api_key: str | None = None) -> None:
        self._base_url = base_url.rstrip("/")
        self._api_key = api_key

    async def generate(self, request: GenerationRequest) -> list[GeneratedImage]:
        prompt = self._build_sticker_prompt(request)
        headers = {}
        if self._api_key:
            headers["Authorization"] = f"Bearer {self._api_key}"

        payload = {
            "prompt": prompt,
            "negative_prompt": request.negative_prompt or DEFAULT_NEGATIVE_PROMPT,
            "width": request.width,
            "height": request.height,
            "num_inference_steps": 30,
            "guidance_scale": 7.5,
            "batch_size": request.num_images,
        }
        if request.seed is not None:
            payload["seed"] = request.seed

        async with httpx.AsyncClient(timeout=120.0) as client:
            response = await client.post(
                f"{self._base_url}/sdapi/v1/txt2img",
                headers=headers,
                json=payload,
            )
            response.raise_for_status()
            data = response.json()

        results = []
        for img_b64 in data["images"]:
            image_bytes = base64.b64decode(img_b64)
            results.append(GeneratedImage(
                data=image_bytes,
                width=request.width,
                height=request.height,
                provider="stable-diffusion",
                seed=data.get("parameters", {}).get("seed"),
            ))
        return results

    def _build_sticker_prompt(self, request: GenerationRequest) -> str:
        style_modifiers = STYLE_PROMPT_MODIFIERS.get(request.style, "")
        return f"{request.prompt}, {style_modifiers}"

    async def check_health(self) -> bool:
        async with httpx.AsyncClient(timeout=10.0) as client:
            response = await client.get(f"{self._base_url}/sdapi/v1/options")
            return response.status_code == 200

DEFAULT_NEGATIVE_PROMPT = (
    "blurry, low quality, watermark, signature, text, deformed, "
    "ugly, duplicate, morbid, mutilated, extra fingers, extra limbs"
)
```

**Style Prompt Modifiers**:

```python
STYLE_PROMPT_MODIFIERS: dict[str, str] = {
    "sticker": (
        "die-cut sticker design, white outline border, flat illustration style, "
        "vibrant colors, simple clean background, high contrast, vector art feel"
    ),
    "chibi": (
        "chibi anime style, cute proportions, large head small body, expressive eyes, "
        "kawaii aesthetic, pastel colors, clean linework, sticker-ready"
    ),
    "funko": (
        "Funko Pop vinyl figure style, large round head, small body, black bead eyes, "
        "no mouth or small simple mouth, glossy plastic look, collectible figure"
    ),
    "pixel-art": (
        "pixel art style, 32x32 grid snapped, limited color palette, retro game aesthetic, "
        "crisp pixel edges, no anti-aliasing, nostalgic 8-bit feel"
    ),
    "cartoon": (
        "cartoon illustration, bold outlines, cel-shaded, bright saturated colors, "
        "expressive character, clean vector style, animation-ready"
    ),
    "emoji": (
        "emoji style, simple geometric shapes, flat design, universal expression, "
        "circular framing, high readability at small sizes, bold colors"
    ),
    "watercolor": (
        "watercolor painting style, soft edges, translucent color washes, "
        "artistic texture, hand-painted feel, organic shapes"
    ),
}
```

**Prompt Engineering Tips for Sticker Generation**:

- Always include "sticker design" or "die-cut sticker" to signal the intended format
- Specify "transparent background" or "white background" explicitly for clean extraction
- Add "no text" to the negative prompt unless the sticker intentionally contains words
- Use "centered composition" to keep the subject within the sticker boundary
- For animated sequences, prompt for consistent character design across frames by specifying "character sheet" or "expression sheet" styles
- Include the target emotion or action clearly: "waving hello", "laughing", "thumbs up"

### Step 3: Python Pillow GIF Creation

Pillow provides full control over GIF assembly, including per-frame duration, palette optimization, transparency, and text overlays.

**Basic GIF Assembly**:

```python
from PIL import Image, ImageDraw, ImageFont, ImageSequence
from pathlib import Path
import io

def assemble_gif(
    frames: list[Image.Image],
    output_path: Path,
    duration_ms: int = 80,
    loop: int = 0,
    optimize: bool = True,
) -> Path:
    """Assemble a list of PIL Image frames into an animated GIF.

    Args:
        frames: List of PIL Image objects (same dimensions required).
        output_path: Where to save the GIF.
        duration_ms: Delay between frames in milliseconds.
        loop: Number of loops (0 = infinite).
        optimize: Enable palette optimization per frame.

    Returns:
        Path to the saved GIF.
    """
    if not frames:
        raise ValueError("At least one frame is required")

    # Ensure all frames match the first frame's dimensions
    target_size = frames[0].size
    processed = []
    for frame in frames:
        if frame.size != target_size:
            frame = frame.resize(target_size, Image.Resampling.LANCZOS)
        # Convert to palette mode for GIF compatibility
        if frame.mode != "P":
            frame = frame.convert("RGBA").convert("P", palette=Image.Palette.ADAPTIVE, colors=256)
        processed.append(frame)

    processed[0].save(
        output_path,
        save_all=True,
        append_images=processed[1:],
        duration=duration_ms,
        loop=loop,
        optimize=optimize,
    )
    return output_path
```

**Transparent GIF with Alpha Channel**:

```python
def assemble_transparent_gif(
    frames: list[Image.Image],
    output_path: Path,
    duration_ms: int = 80,
    loop: int = 0,
    transparency_color: tuple[int, int, int] = (0, 255, 0),
) -> Path:
    """Assemble a GIF with transparency support.

    GIF transparency works by designating one palette color as transparent.
    This function composites RGBA frames onto a solid color background,
    then marks that color as transparent in the palette.
    """
    processed = []
    for frame in frames:
        if frame.mode != "RGBA":
            frame = frame.convert("RGBA")

        # Create background with the transparency key color
        bg = Image.new("RGBA", frame.size, (*transparency_color, 255))
        composite = Image.alpha_composite(bg, frame)
        # Convert to palette mode
        p_frame = composite.convert("RGB").convert(
            "P", palette=Image.Palette.ADAPTIVE, colors=255,
        )
        processed.append(p_frame)

    # Find the palette index closest to our transparency color
    palette = processed[0].getpalette()
    transparency_index = _find_closest_palette_index(palette, transparency_color)

    processed[0].save(
        output_path,
        save_all=True,
        append_images=processed[1:],
        duration=duration_ms,
        loop=loop,
        transparency=transparency_index,
        disposal=2,  # Restore to background between frames
    )
    return output_path

def _find_closest_palette_index(
    palette: list[int], target: tuple[int, int, int],
) -> int:
    """Find the palette index whose RGB value is closest to target."""
    min_dist = float("inf")
    best_index = 0
    for i in range(0, len(palette), 3):
        r, g, b = palette[i], palette[i + 1], palette[i + 2]
        dist = (r - target[0]) ** 2 + (g - target[1]) ** 2 + (b - target[2]) ** 2
        if dist < min_dist:
            min_dist = dist
            best_index = i // 3
    return best_index
```

**Text Overlay on Frames**:

```python
def add_text_overlay(
    frame: Image.Image,
    text: str,
    position: str = "bottom",
    font_path: str | None = None,
    font_size: int = 24,
    text_color: tuple[int, int, int, int] = (255, 255, 255, 255),
    stroke_color: tuple[int, int, int, int] = (0, 0, 0, 255),
    stroke_width: int = 2,
    padding: int = 10,
) -> Image.Image:
    """Add a text caption to a frame with stroke outline for readability."""
    frame = frame.convert("RGBA")
    overlay = Image.new("RGBA", frame.size, (0, 0, 0, 0))
    draw = ImageDraw.Draw(overlay)

    if font_path:
        font = ImageFont.truetype(font_path, font_size)
    else:
        font = ImageFont.load_default()

    bbox = draw.textbbox((0, 0), text, font=font)
    text_width = bbox[2] - bbox[0]
    text_height = bbox[3] - bbox[1]

    # Calculate position
    x = (frame.width - text_width) // 2
    if position == "bottom":
        y = frame.height - text_height - padding
    elif position == "top":
        y = padding
    else:
        y = (frame.height - text_height) // 2

    draw.text(
        (x, y), text, font=font, fill=text_color,
        stroke_width=stroke_width, stroke_fill=stroke_color,
    )
    return Image.alpha_composite(frame, overlay)
```

**Global Palette for Consistent Colors Across Frames**:

```python
from PIL import Image
import numpy as np

def create_global_palette(frames: list[Image.Image], max_colors: int = 256) -> Image.Image:
    """Create a single optimized palette from all frames.

    Using a global palette prevents color flickering between frames
    that can occur when each frame has its own adaptive palette.
    """
    # Concatenate all frames into one tall image for palette computation
    total_height = sum(f.size[1] for f in frames)
    combined = Image.new("RGB", (frames[0].size[0], total_height))
    y_offset = 0
    for frame in frames:
        rgb = frame.convert("RGB")
        combined.paste(rgb, (0, y_offset))
        y_offset += rgb.size[1]

    # Quantize the combined image to get a global palette
    quantized = combined.quantize(colors=max_colors, method=Image.Quantize.MEDIANCUT)
    return quantized

def apply_global_palette(
    frames: list[Image.Image], palette_image: Image.Image,
) -> list[Image.Image]:
    """Apply a precomputed global palette to all frames."""
    result = []
    for frame in frames:
        rgb = frame.convert("RGB")
        quantized = rgb.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
        result.append(quantized)
    return result
```

### Step 4: ffmpeg Video-to-GIF Pipeline

ffmpeg produces higher quality GIFs from video sources than most other tools. The two-pass palette generation technique is essential for good results.

**Two-Pass Palette Generation** (the gold standard for ffmpeg GIF quality):

```bash
#!/usr/bin/env bash
set -euo pipefail

# Pass 1: Generate an optimized palette from the video content
ffmpeg -i input.mp4 \
    -vf "fps=15,scale=480:-1:flags=lanczos,palettegen=stats_mode=diff" \
    -y palette.png

# Pass 2: Use the palette to encode the GIF
ffmpeg -i input.mp4 -i palette.png \
    -lavfi "fps=15,scale=480:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=5" \
    -y output.gif
```

**Python Wrapper for ffmpeg**:

```python
import subprocess
import shutil
from pathlib import Path

class FfmpegGifConverter:
    """Convert video files to optimized GIFs using ffmpeg two-pass encoding."""

    def __init__(self) -> None:
        if not shutil.which("ffmpeg"):
            raise RuntimeError("ffmpeg not found in PATH")

    def convert(
        self,
        input_path: Path,
        output_path: Path,
        fps: int = 15,
        width: int = 480,
        start_time: float | None = None,
        duration: float | None = None,
        dither: str = "bayer",
        bayer_scale: int = 5,
    ) -> Path:
        """Convert a video file to an optimized GIF.

        Args:
            input_path: Source video file.
            output_path: Destination GIF file.
            fps: Target frame rate.
            width: Target width (-1 preserves aspect ratio for height).
            start_time: Start offset in seconds (None = beginning).
            duration: Duration in seconds (None = full video).
            dither: Dithering algorithm (bayer, floyd_steinberg, sierra2).
            bayer_scale: Bayer dither scale (0-5, lower = more dithering).
        """
        palette_path = output_path.with_suffix(".palette.png")
        input_args = self._build_input_args(input_path, start_time, duration)
        filter_base = f"fps={fps},scale={width}:-1:flags=lanczos"

        try:
            # Pass 1: palette generation
            self._run_ffmpeg([
                *input_args,
                "-vf", f"{filter_base},palettegen=stats_mode=diff",
                "-y", str(palette_path),
            ])

            # Pass 2: GIF encoding with palette
            self._run_ffmpeg([
                *input_args,
                "-i", str(palette_path),
                "-lavfi", (
                    f"{filter_base} [x]; "
                    f"[x][1:v] paletteuse=dither={dither}:bayer_scale={bayer_scale}"
                ),
                "-y", str(output_path),
            ])
        finally:
            palette_path.unlink(missing_ok=True)

        return output_path

    def _build_input_args(
        self, input_path: Path, start_time: float | None, duration: float | None,
    ) -> list[str]:
        args = []
        if start_time is not None:
            args.extend(["-ss", str(start_time)])
        args.extend(["-i", str(input_path)])
        if duration is not None:
            args.extend(["-t", str(duration)])
        return args

    def _run_ffmpeg(self, args: list[str]) -> None:
        result = subprocess.run(
            ["ffmpeg", *args],
            capture_output=True,
            text=True,
        )
        if result.returncode != 0:
            raise RuntimeError(f"ffmpeg failed: {result.stderr[-500:]}")
```

**Common ffmpeg Filter Recipes**:

```bash
# Crop to square (center crop)
ffmpeg -i input.mp4 \
    -vf "crop=min(iw\,ih):min(iw\,ih)" \
    -y cropped.mp4

# Speed up 2x (for creating fast loops)
ffmpeg -i input.mp4 \
    -vf "setpts=0.5*PTS" \
    -y fast.mp4

# Reverse playback (for ping-pong loops)
ffmpeg -i input.mp4 \
    -vf "reverse" \
    -y reversed.mp4

# Concatenate forward + reverse for seamless loop
ffmpeg -i input.mp4 -i reversed.mp4 \
    -filter_complex "[0:v][1:v]concat=n=2:v=1:a=0" \
    -y pingpong.mp4

# Extract frames as PNGs for manual editing
ffmpeg -i input.mp4 \
    -vf "fps=10" \
    frames/frame_%04d.png

# Reassemble edited frames into GIF
ffmpeg -framerate 10 -i frames/frame_%04d.png \
    -vf "palettegen" -y palette.png
ffmpeg -framerate 10 -i frames/frame_%04d.png -i palette.png \
    -lavfi "paletteuse" -y output.gif
```

### Step 5: Animation Techniques

Create animations programmatically using sprite sheets, tweening, easing functions, and keyframe interpolation.

**Easing Functions**:

```python
import math

def ease_linear(t: float) -> float:
    return t

def ease_in_quad(t: float) -> float:
    return t * t

def ease_out_quad(t: float) -> float:
    return t * (2 - t)

def ease_in_out_quad(t: float) -> float:
    if t < 0.5:
        return 2 * t * t
    return -1 + (4 - 2 * t) * t

def ease_in_cubic(t: float) -> float:
    return t * t * t

def ease_out_cubic(t: float) -> float:
    return 1 - (1 - t) ** 3

def ease_out_bounce(t: float) -> float:
    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_out_elastic(t: float) -> float:
    if t == 0 or t == 1:
        return t
    return 2 ** (-10 * t) * math.sin((t - 0.075) * (2 * math.pi) / 0.3) + 1

EASING_FUNCTIONS = {
    "linear": ease_linear,
    "ease-in": ease_in_quad,
    "ease-out": ease_out_quad,
    "ease-in-out": ease_in_out_quad,
    "ease-in-cubic": ease_in_cubic,
    "ease-out-cubic": ease_out_cubic,
    "bounce": ease_out_bounce,
    "elastic": ease_out_elastic,
}
```

**Keyframe Animation System**:

```python
from dataclasses import dataclass
from PIL import Image

@dataclass
class Keyframe:
    """A single keyframe defining a property value at a specific time."""
    time: float          # 0.0 to 1.0 (normalized)
    value: float
    easing: str = "linear"

@dataclass
class AnimationTrack:
    """A sequence of keyframes for one property (x, y, scale, rotation, opacity)."""
    property_name: str
    keyframes: list[Keyframe]

    def evaluate(self, t: float) -> float:
        """Interpolate the property value at normalized time t (0.0 to 1.0)."""
        if not self.keyframes:
            return 0.0
        if t <= self.keyframes[0].time:
            return self.keyframes[0].value
        if t >= self.keyframes[-1].time:
            return self.keyframes[-1].value

        # Find the surrounding keyframes
        for i in range(len(self.keyframes) - 1):
            k0 = self.keyframes[i]
            k1 = self.keyframes[i + 1]
            if k0.time <= t <= k1.time:
                local_t = (t - k0.time) / (k1.time - k0.time)
                easing_fn = EASING_FUNCTIONS.get(k0.easing, ease_linear)
                eased_t = easing_fn(local_t)
                return k0.value + (k1.value - k0.value) * eased_t

        return self.keyframes[-1].value

def render_animation(
    source: Image.Image,
    tracks: list[AnimationTrack],
    frame_count: int,
    canvas_size: tuple[int, int],
) -> list[Image.Image]:
    """Render an animation by evaluating tracks at each frame."""
    frames = []
    for i in range(frame_count):
        t = i / max(frame_count - 1, 1)
        props = {track.property_name: track.evaluate(t) for track in tracks}

        canvas = Image.new("RGBA", canvas_size, (0, 0, 0, 0))
        transformed = _apply_transform(
            source,
            x=props.get("x", 0),
            y=props.get("y", 0),
            scale=props.get("scale", 1.0),
            rotation=props.get("rotation", 0),
            opacity=props.get("opacity", 1.0),
        )
        # Center the transformed image on the canvas with offset
        paste_x = (canvas_size[0] - transformed.width) // 2 + int(props.get("x", 0))
        paste_y = (canvas_size[1] - transformed.height) // 2 + int(props.get("y", 0))
        canvas.paste(transformed, (paste_x, paste_y), transformed)
        frames.append(canvas)

    return frames

def _apply_transform(
    img: Image.Image,
    x: float = 0,
    y: float = 0,
    scale: float = 1.0,
    rotation: float = 0,
    opacity: float = 1.0,
) -> Image.Image:
    """Apply scale, rotation, and opacity transforms to an image."""
    if scale != 1.0:
        new_size = (int(img.width * scale), int(img.height * scale))
        img = img.resize(new_size, Image.Resampling.LANCZOS)
    if rotation != 0:
        img = img.rotate(-rotation, expand=True, resample=Image.Resampling.BICUBIC)
    if opacity < 1.0:
        img = img.convert("RGBA")
        r, g, b, a = img.split()
        a = a.point(lambda p: int(p * opacity))
        img = Image.merge("RGBA", (r, g, b, a))
    return img
```

**Common Animation Presets**:

```python
def bounce_animation(amplitude: float = 20, cycles: int = 2) -> list[AnimationTrack]:
    """Create a vertical bounce animation."""
    keyframes = []
    for i in range(cycles * 2 + 1):
        t = i / (cycles * 2)
        value = 0 if i % 2 == 0 else -amplitude
        keyframes.append(Keyframe(time=t, value=value, easing="ease-out"))
    return [AnimationTrack(property_name="y", keyframes=keyframes)]

def pulse_animation(min_scale: float = 0.9, max_scale: float = 1.1) -> list[AnimationTrack]:
    """Create a pulsing scale animation."""
    return [AnimationTrack(
        property_name="scale",
        keyframes=[
            Keyframe(time=0.0, value=1.0, easing="ease-in-out"),
            Keyframe(time=0.25, value=max_scale, easing="ease-in-out"),
            Keyframe(time=0.5, value=1.0, easing="ease-in-out"),
            Keyframe(time=0.75, value=min_scale, easing="ease-in-out"),
            Keyframe(time=1.0, value=1.0, easing="ease-in-out"),
        ],
    )]

def rotate_animation(degrees: float = 360) -> list[AnimationTrack]:
    """Create a full rotation animation."""
    return [AnimationTrack(
        property_name="rotation",
        keyframes=[
            Keyframe(time=0.0, value=0, easing="linear"),
            Keyframe(time=1.0, value=degrees, easing="linear"),
        ],
    )]

def shake_animation(intensity: float = 5, frequency: int = 8) -> list[AnimationTrack]:
    """Create a horizontal shake animation."""
    keyframes = [Keyframe(time=0.0, value=0, easing="linear")]
    for i in range(1, frequency + 1):
        t = i / (frequency + 1)
        direction = 1 if i % 2 == 0 else -1
        decay = 1 - (i / frequency)  # Decay over time
        keyframes.append(Keyframe(time=t, value=intensity * direction * decay, easing="linear"))
    keyframes.append(Keyframe(time=1.0, value=0, easing="linear"))
    return [AnimationTrack(property_name="x", keyframes=keyframes)]
```

**Sprite Sheet Slicer**:

```python
def slice_sprite_sheet(
    sheet: Image.Image,
    columns: int,
    rows: int,
    frame_count: int | None = None,
) -> list[Image.Image]:
    """Slice a sprite sheet into individual frames.

    Args:
        sheet: The full sprite sheet image.
        columns: Number of columns in the grid.
        rows: Number of rows in the grid.
        frame_count: Total frames to extract (None = all cells).
    """
    frame_width = sheet.width // columns
    frame_height = sheet.height // rows
    total = frame_count or (columns * rows)

    frames = []
    for i in range(total):
        col = i % columns
        row = i // columns
        if row >= rows:
            break
        box = (
            col * frame_width,
            row * frame_height,
            (col + 1) * frame_width,
            (row + 1) * frame_height,
        )
        frames.append(sheet.crop(box))
    return frames
```

### Step 6: Sticker-Specific Patterns

Stickers require specific visual treatments: transparent backgrounds, outline strokes, caption overlays, and precise sizing for each chat platform.

**Background Removal and Outline Stroke**:

```python
from PIL import Image, ImageFilter, ImageDraw

def add_sticker_outline(
    image: Image.Image,
    stroke_width: int = 4,
    stroke_color: tuple[int, int, int, int] = (255, 255, 255, 255),
) -> Image.Image:
    """Add a solid outline stroke around the non-transparent content.

    This creates the classic sticker "die-cut" appearance with a white
    border around the subject.
    """
    if image.mode != "RGBA":
        image = image.convert("RGBA")

    # Extract the alpha channel and create a dilated mask
    alpha = image.split()[3]
    # Dilate the alpha mask by the stroke width
    dilated = alpha.copy()
    for _ in range(stroke_width):
        dilated = dilated.filter(ImageFilter.MaxFilter(3))

    # Create the stroke layer
    stroke_layer = Image.new("RGBA", image.size, (0, 0, 0, 0))
    stroke_pixels = stroke_layer.load()
    alpha_pixels = alpha.load()
    dilated_pixels = dilated.load()

    for y in range(image.height):
        for x in range(image.width):
            # Stroke region: dilated but not in original alpha
            if dilated_pixels[x, y] > 128 and alpha_pixels[x, y] < 128:
                stroke_pixels[x, y] = stroke_color

    # Composite: stroke behind, original on top
    result = Image.alpha_composite(stroke_layer, image)
    return result

def remove_background_simple(
    image: Image.Image,
    threshold: int = 240,
) -> Image.Image:
    """Remove near-white backgrounds by converting to transparency.

    For production use, prefer a dedicated background removal model
    (rembg, remove.bg API, or SAM-based segmentation).
    """
    if image.mode != "RGBA":
        image = image.convert("RGBA")

    pixels = image.load()
    for y in range(image.height):
        for x in range(image.width):
            r, g, b, a = pixels[x, y]
            if r > threshold and g > threshold and b > threshold:
                pixels[x, y] = (r, g, b, 0)
    return image
```

**Sticker Caption Overlay**:

```python
def create_sticker_with_caption(
    image: Image.Image,
    caption: str,
    font_path: str,
    font_size: int = 28,
    caption_height: int = 50,
    bg_color: tuple[int, int, int, int] = (0, 0, 0, 160),
    text_color: tuple[int, int, int, int] = (255, 255, 255, 255),
) -> Image.Image:
    """Add a semi-transparent caption bar at the bottom of a sticker."""
    # Expand canvas to accommodate caption
    new_height = image.height + caption_height
    canvas = Image.new("RGBA", (image.width, new_height), (0, 0, 0, 0))
    canvas.paste(image, (0, 0))

    # Draw caption background
    draw = ImageDraw.Draw(canvas)
    draw.rectangle(
        [(0, image.height), (image.width, new_height)],
        fill=bg_color,
    )

    # Draw caption text centered
    font = ImageFont.truetype(font_path, font_size)
    bbox = draw.textbbox((0, 0), caption, font=font)
    text_width = bbox[2] - bbox[0]
    text_x = (image.width - text_width) // 2
    text_y = image.height + (caption_height - font_size) // 2
    draw.text((text_x, text_y), caption, font=font, fill=text_color)

    return canvas
```

**Platform Export Functions**:

```python
from pathlib import Path

def export_for_telegram(
    frames: list[Image.Image],
    output_path: Path,
    duration_ms: int = 80,
) -> Path:
    """Export animated sticker for Telegram (512x512, WebM or GIF).

    Telegram animated stickers use WebM (VP9) or Lottie format.
    For GIF fallback, use standard GIF with 512x512 dimensions.
    """
    target_size = (512, 512)
    resized = [f.resize(target_size, Image.Resampling.LANCZOS) for f in frames]
    gif_path = output_path.with_suffix(".gif")
    assemble_transparent_gif(resized, gif_path, duration_ms=duration_ms)
    return gif_path

def export_for_discord(
    frames: list[Image.Image],
    output_path: Path,
    duration_ms: int = 80,
    max_size_kb: int = 256,
) -> Path:
    """Export animated emoji for Discord (128x128, under 256 KB)."""
    target_size = (128, 128)
    resized = [f.resize(target_size, Image.Resampling.LANCZOS) for f in frames]
    gif_path = output_path.with_suffix(".gif")
    assemble_gif(resized, gif_path, duration_ms=duration_ms)

    # Check size and reduce if needed
    file_size_kb = gif_path.stat().st_size / 1024
    if file_size_kb > max_size_kb:
        _reduce_gif_size(gif_path, max_size_kb)
    return gif_path

def export_for_slack(
    frames: list[Image.Image],
    output_path: Path,
    duration_ms: int = 80,
    max_size_kb: int = 128,
) -> Path:
    """Export custom emoji for Slack (128x128, under 128 KB)."""
    target_size = (128, 128)
    resized = [f.resize(target_size, Image.Resampling.LANCZOS) for f in frames]
    gif_path = output_path.with_suffix(".gif")
    assemble_gif(resized, gif_path, duration_ms=duration_ms)

    file_size_kb = gif_path.stat().st_size / 1024
    if file_size_kb > max_size_kb:
        _reduce_gif_size(gif_path, max_size_kb)
    return gif_path
```

### Step 7: Batch Processing and Optimization

Automate large-scale sticker generation with parallel processing, file size optimization, and output validation.

**Batch Generation Pipeline**:

```python
import asyncio
from dataclasses import dataclass
from pathlib import Path

@dataclass
class BatchItem:
    """A single item in a batch generation job."""
    name: str
    prompt: str
    style: str = "sticker"
    animation: str = "bounce"      # bounce, pulse, rotate, shake, none

@dataclass
class BatchResult:
    """Result for a single batch item."""
    name: str
    output_path: Path | None
    file_size_kb: float
    frame_count: int
    success: bool
    error: str | None = None

async def generate_sticker_batch(
    items: list[BatchItem],
    generator: ImageGenerator,
    config: PipelineConfig,
    max_concurrent: int = 4,
) -> list[BatchResult]:
    """Generate a batch of animated stickers with concurrency control."""
    semaphore = asyncio.Semaphore(max_concurrent)
    results = []

    async def process_item(item: BatchItem) -> BatchResult:
        async with semaphore:
            try:
                # Generate base image
                request = GenerationRequest(
                    prompt=item.prompt,
                    width=config.width,
                    height=config.height,
                    style=item.style,
                )
                images = await generator.generate(request)
                if not images:
                    return BatchResult(
                        name=item.name, output_path=None,
                        file_size_kb=0, frame_count=0,
                        success=False, error="No images generated",
                    )

                # Load the generated image
                base_image = Image.open(io.BytesIO(images[0].data)).convert("RGBA")

                # Apply animation
                animation_tracks = _get_animation_tracks(item.animation)
                frames = render_animation(
                    base_image, animation_tracks,
                    frame_count=config.frame_count,
                    canvas_size=(config.width, config.height),
                )

                # Add sticker outline
                frames = [add_sticker_outline(f) for f in frames]

                # Export
                output_path = config.output_dir / f"{item.name}.gif"
                assemble_transparent_gif(
                    frames, output_path,
                    duration_ms=config.frame_duration_ms,
                )

                # Optimize
                optimize_gif(output_path, config.max_file_size_kb)

                file_size_kb = output_path.stat().st_size / 1024
                return BatchResult(
                    n

…(truncated)
