Add doc-coauthoring skill and update example skills (#134)

* export/update example skills

* Add 'doc-coauthoring' to example-skills plugin

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Keith Lazuka
2025-12-04 12:01:46 -05:00
committed by GitHub
parent ef740771ac
commit 00756142ab
20 changed files with 377 additions and 4684 deletions
@@ -1,302 +0,0 @@
#!/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'])
-357
View File
@@ -1,357 +0,0 @@
#!/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))
@@ -1,494 +0,0 @@
#!/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