Files
gh-anthropics-skills-exampl…/skills/slack-gif-creator/core/visual_effects.py
2025-11-29 17:56:12 +08:00

494 lines
14 KiB
Python
Executable File

#!/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