Initial commit
This commit is contained in:
302
skills/slack-gif-creator/core/color_palettes.py
Normal file
302
skills/slack-gif-creator/core/color_palettes.py
Normal file
@@ -0,0 +1,302 @@
|
||||
#!/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'])
|
||||
230
skills/slack-gif-creator/core/easing.py
Normal file
230
skills/slack-gif-creator/core/easing.py
Normal file
@@ -0,0 +1,230 @@
|
||||
#!/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
|
||||
})
|
||||
469
skills/slack-gif-creator/core/frame_composer.py
Normal file
469
skills/slack-gif-creator/core/frame_composer.py
Normal file
@@ -0,0 +1,469 @@
|
||||
#!/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
|
||||
246
skills/slack-gif-creator/core/gif_builder.py
Normal file
246
skills/slack-gif-creator/core/gif_builder.py
Normal file
@@ -0,0 +1,246 @@
|
||||
#!/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 = []
|
||||
357
skills/slack-gif-creator/core/typography.py
Normal file
357
skills/slack-gif-creator/core/typography.py
Normal file
@@ -0,0 +1,357 @@
|
||||
#!/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))
|
||||
264
skills/slack-gif-creator/core/validators.py
Normal file
264
skills/slack-gif-creator/core/validators.py
Normal file
@@ -0,0 +1,264 @@
|
||||
#!/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
|
||||
494
skills/slack-gif-creator/core/visual_effects.py
Normal file
494
skills/slack-gif-creator/core/visual_effects.py
Normal file
@@ -0,0 +1,494 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user