494 lines
14 KiB
Python
494 lines
14 KiB
Python
#!/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 |