main
1#!/usr/bin/env python3
2"""
3Visual Effects - Particles, motion blur, impacts, and other effects for GIFs.
4
5This module provides high-impact visual effects that make animations feel
6professional and dynamic while keeping file sizes reasonable.
7"""
8
9from PIL import Image, ImageDraw, ImageFilter
10import numpy as np
11import math
12import random
13from typing import Optional
14
15
16class Particle:
17 """A single particle in a particle system."""
18
19 def __init__(self, x: float, y: float, vx: float, vy: float,
20 lifetime: float, color: tuple[int, int, int],
21 size: int = 3, shape: str = 'circle'):
22 """
23 Initialize a particle.
24
25 Args:
26 x, y: Starting position
27 vx, vy: Velocity
28 lifetime: How long particle lives (in frames)
29 color: RGB color
30 size: Particle size in pixels
31 shape: 'circle', 'square', or 'star'
32 """
33 self.x = x
34 self.y = y
35 self.vx = vx
36 self.vy = vy
37 self.lifetime = lifetime
38 self.max_lifetime = lifetime
39 self.color = color
40 self.size = size
41 self.shape = shape
42 self.gravity = 0.5 # Pixels per frame squared
43 self.drag = 0.98 # Velocity multiplier per frame
44
45 def update(self):
46 """Update particle position and lifetime."""
47 # Apply physics
48 self.vy += self.gravity
49 self.vx *= self.drag
50 self.vy *= self.drag
51
52 # Update position
53 self.x += self.vx
54 self.y += self.vy
55
56 # Decrease lifetime
57 self.lifetime -= 1
58
59 def is_alive(self) -> bool:
60 """Check if particle is still alive."""
61 return self.lifetime > 0
62
63 def get_alpha(self) -> float:
64 """Get particle opacity based on lifetime."""
65 return max(0, min(1, self.lifetime / self.max_lifetime))
66
67 def render(self, frame: Image.Image):
68 """
69 Render particle to frame.
70
71 Args:
72 frame: PIL Image to draw on
73 """
74 if not self.is_alive():
75 return
76
77 draw = ImageDraw.Draw(frame)
78 alpha = self.get_alpha()
79
80 # Calculate faded color
81 color = tuple(int(c * alpha) for c in self.color)
82
83 # Draw based on shape
84 x, y = int(self.x), int(self.y)
85 size = max(1, int(self.size * alpha))
86
87 if self.shape == 'circle':
88 bbox = [x - size, y - size, x + size, y + size]
89 draw.ellipse(bbox, fill=color)
90 elif self.shape == 'square':
91 bbox = [x - size, y - size, x + size, y + size]
92 draw.rectangle(bbox, fill=color)
93 elif self.shape == 'star':
94 # Simple 4-point star
95 points = [
96 (x, y - size),
97 (x - size // 2, y),
98 (x, y),
99 (x, y + size),
100 (x, y),
101 (x + size // 2, y),
102 ]
103 draw.line(points, fill=color, width=2)
104
105
106class ParticleSystem:
107 """Manages a collection of particles."""
108
109 def __init__(self):
110 """Initialize particle system."""
111 self.particles: list[Particle] = []
112
113 def emit(self, x: int, y: int, count: int = 10,
114 spread: float = 2.0, speed: float = 5.0,
115 color: tuple[int, int, int] = (255, 200, 0),
116 lifetime: float = 20.0, size: int = 3, shape: str = 'circle'):
117 """
118 Emit a burst of particles.
119
120 Args:
121 x, y: Emission position
122 count: Number of particles to emit
123 spread: Angle spread (radians)
124 speed: Initial speed
125 color: Particle color
126 lifetime: Particle lifetime in frames
127 size: Particle size
128 shape: Particle shape
129 """
130 for _ in range(count):
131 # Random angle and speed
132 angle = random.uniform(0, 2 * math.pi)
133 vel_mag = random.uniform(speed * 0.5, speed * 1.5)
134 vx = math.cos(angle) * vel_mag
135 vy = math.sin(angle) * vel_mag
136
137 # Random lifetime variation
138 life = random.uniform(lifetime * 0.7, lifetime * 1.3)
139
140 particle = Particle(x, y, vx, vy, life, color, size, shape)
141 self.particles.append(particle)
142
143 def emit_confetti(self, x: int, y: int, count: int = 20,
144 colors: Optional[list[tuple[int, int, int]]] = None):
145 """
146 Emit confetti particles (colorful, falling).
147
148 Args:
149 x, y: Emission position
150 count: Number of confetti pieces
151 colors: List of colors (random if None)
152 """
153 if colors is None:
154 colors = [
155 (255, 107, 107), (255, 159, 64), (255, 218, 121),
156 (107, 185, 240), (162, 155, 254), (255, 182, 193)
157 ]
158
159 for _ in range(count):
160 color = random.choice(colors)
161 vx = random.uniform(-3, 3)
162 vy = random.uniform(-8, -2)
163 shape = random.choice(['square', 'circle'])
164 size = random.randint(2, 4)
165 lifetime = random.uniform(40, 60)
166
167 particle = Particle(x, y, vx, vy, lifetime, color, size, shape)
168 particle.gravity = 0.3 # Lighter gravity for confetti
169 self.particles.append(particle)
170
171 def emit_sparkles(self, x: int, y: int, count: int = 15):
172 """
173 Emit sparkle particles (twinkling stars).
174
175 Args:
176 x, y: Emission position
177 count: Number of sparkles
178 """
179 colors = [(255, 255, 200), (255, 255, 255), (255, 255, 150)]
180
181 for _ in range(count):
182 color = random.choice(colors)
183 angle = random.uniform(0, 2 * math.pi)
184 speed = random.uniform(1, 3)
185 vx = math.cos(angle) * speed
186 vy = math.sin(angle) * speed
187 lifetime = random.uniform(15, 30)
188
189 particle = Particle(x, y, vx, vy, lifetime, color, 2, 'star')
190 particle.gravity = 0
191 particle.drag = 0.95
192 self.particles.append(particle)
193
194 def update(self):
195 """Update all particles."""
196 # Update alive particles
197 for particle in self.particles:
198 particle.update()
199
200 # Remove dead particles
201 self.particles = [p for p in self.particles if p.is_alive()]
202
203 def render(self, frame: Image.Image):
204 """Render all particles to frame."""
205 for particle in self.particles:
206 particle.render(frame)
207
208 def get_particle_count(self) -> int:
209 """Get number of active particles."""
210 return len(self.particles)
211
212
213def add_motion_blur(frame: Image.Image, prev_frame: Optional[Image.Image],
214 blur_amount: float = 0.5) -> Image.Image:
215 """
216 Add motion blur by blending with previous frame.
217
218 Args:
219 frame: Current frame
220 prev_frame: Previous frame (None for first frame)
221 blur_amount: Amount of blur (0.0-1.0)
222
223 Returns:
224 Frame with motion blur applied
225 """
226 if prev_frame is None:
227 return frame
228
229 # Blend current frame with previous frame
230 frame_array = np.array(frame, dtype=np.float32)
231 prev_array = np.array(prev_frame, dtype=np.float32)
232
233 blended = frame_array * (1 - blur_amount) + prev_array * blur_amount
234 blended = np.clip(blended, 0, 255).astype(np.uint8)
235
236 return Image.fromarray(blended)
237
238
239def create_impact_flash(frame: Image.Image, position: tuple[int, int],
240 radius: int = 100, intensity: float = 0.7) -> Image.Image:
241 """
242 Create a bright flash effect at impact point.
243
244 Args:
245 frame: PIL Image to draw on
246 position: Center of flash
247 radius: Flash radius
248 intensity: Flash intensity (0.0-1.0)
249
250 Returns:
251 Modified frame
252 """
253 # Create overlay
254 overlay = Image.new('RGBA', frame.size, (0, 0, 0, 0))
255 draw = ImageDraw.Draw(overlay)
256
257 x, y = position
258
259 # Draw concentric circles with decreasing opacity
260 num_circles = 5
261 for i in range(num_circles):
262 alpha = int(255 * intensity * (1 - i / num_circles))
263 r = radius * (1 - i / num_circles)
264 color = (255, 255, 240, alpha) # Warm white
265
266 bbox = [x - r, y - r, x + r, y + r]
267 draw.ellipse(bbox, fill=color)
268
269 # Composite onto frame
270 frame_rgba = frame.convert('RGBA')
271 frame_rgba = Image.alpha_composite(frame_rgba, overlay)
272 return frame_rgba.convert('RGB')
273
274
275def create_shockwave_rings(frame: Image.Image, position: tuple[int, int],
276 radii: list[int], color: tuple[int, int, int] = (255, 200, 0),
277 width: int = 3) -> Image.Image:
278 """
279 Create expanding ring effects.
280
281 Args:
282 frame: PIL Image to draw on
283 position: Center of rings
284 radii: List of ring radii
285 color: Ring color
286 width: Ring width
287
288 Returns:
289 Modified frame
290 """
291 draw = ImageDraw.Draw(frame)
292 x, y = position
293
294 for radius in radii:
295 bbox = [x - radius, y - radius, x + radius, y + radius]
296 draw.ellipse(bbox, outline=color, width=width)
297
298 return frame
299
300
301def create_explosion_effect(frame: Image.Image, position: tuple[int, int],
302 radius: int, progress: float,
303 color: tuple[int, int, int] = (255, 150, 0)) -> Image.Image:
304 """
305 Create an explosion effect that expands and fades.
306
307 Args:
308 frame: PIL Image to draw on
309 position: Explosion center
310 radius: Maximum radius
311 progress: Animation progress (0.0-1.0)
312 color: Explosion color
313
314 Returns:
315 Modified frame
316 """
317 current_radius = int(radius * progress)
318 fade = 1 - progress
319
320 # Create overlay
321 overlay = Image.new('RGBA', frame.size, (0, 0, 0, 0))
322 draw = ImageDraw.Draw(overlay)
323
324 x, y = position
325
326 # Draw expanding circle with fade
327 alpha = int(255 * fade)
328 r, g, b = color
329 circle_color = (r, g, b, alpha)
330
331 bbox = [x - current_radius, y - current_radius, x + current_radius, y + current_radius]
332 draw.ellipse(bbox, fill=circle_color)
333
334 # Composite
335 frame_rgba = frame.convert('RGBA')
336 frame_rgba = Image.alpha_composite(frame_rgba, overlay)
337 return frame_rgba.convert('RGB')
338
339
340def add_glow_effect(frame: Image.Image, mask_color: tuple[int, int, int],
341 glow_color: tuple[int, int, int],
342 blur_radius: int = 10) -> Image.Image:
343 """
344 Add a glow effect to areas of a specific color.
345
346 Args:
347 frame: PIL Image
348 mask_color: Color to create glow around
349 glow_color: Color of glow
350 blur_radius: Blur amount
351
352 Returns:
353 Frame with glow
354 """
355 # Create mask of target color
356 frame_array = np.array(frame)
357 mask = np.all(frame_array == mask_color, axis=-1)
358
359 # Create glow layer
360 glow = Image.new('RGB', frame.size, (0, 0, 0))
361 glow_array = np.array(glow)
362 glow_array[mask] = glow_color
363 glow = Image.fromarray(glow_array)
364
365 # Blur the glow
366 glow = glow.filter(ImageFilter.GaussianBlur(blur_radius))
367
368 # Blend with original
369 blended = Image.blend(frame, glow, 0.5)
370 return blended
371
372
373def add_drop_shadow(frame: Image.Image, object_bounds: tuple[int, int, int, int],
374 shadow_offset: tuple[int, int] = (5, 5),
375 shadow_color: tuple[int, int, int] = (0, 0, 0),
376 blur: int = 5) -> Image.Image:
377 """
378 Add drop shadow to an object.
379
380 Args:
381 frame: PIL Image
382 object_bounds: (x1, y1, x2, y2) bounds of object
383 shadow_offset: (x, y) offset of shadow
384 shadow_color: Shadow color
385 blur: Shadow blur amount
386
387 Returns:
388 Frame with shadow
389 """
390 # Extract object
391 x1, y1, x2, y2 = object_bounds
392 obj = frame.crop((x1, y1, x2, y2))
393
394 # Create shadow
395 shadow = Image.new('RGBA', obj.size, (*shadow_color, 180))
396
397 # Create frame with alpha
398 frame_rgba = frame.convert('RGBA')
399
400 # Paste shadow
401 shadow_pos = (x1 + shadow_offset[0], y1 + shadow_offset[1])
402 frame_rgba.paste(shadow, shadow_pos, shadow)
403
404 # Paste object on top
405 frame_rgba.paste(obj, (x1, y1))
406
407 return frame_rgba.convert('RGB')
408
409
410def create_speed_lines(frame: Image.Image, position: tuple[int, int],
411 direction: float, length: int = 50,
412 count: int = 5, color: tuple[int, int, int] = (200, 200, 200)) -> Image.Image:
413 """
414 Create speed lines for motion effect.
415
416 Args:
417 frame: PIL Image to draw on
418 position: Center position
419 direction: Angle in radians (0 = right, pi/2 = down)
420 length: Line length
421 count: Number of lines
422 color: Line color
423
424 Returns:
425 Modified frame
426 """
427 draw = ImageDraw.Draw(frame)
428 x, y = position
429
430 # Opposite direction (lines trail behind)
431 trail_angle = direction + math.pi
432
433 for i in range(count):
434 # Offset from center
435 offset_angle = trail_angle + random.uniform(-0.3, 0.3)
436 offset_dist = random.uniform(10, 30)
437 start_x = x + math.cos(offset_angle) * offset_dist
438 start_y = y + math.sin(offset_angle) * offset_dist
439
440 # End point
441 line_length = random.uniform(length * 0.7, length * 1.3)
442 end_x = start_x + math.cos(trail_angle) * line_length
443 end_y = start_y + math.sin(trail_angle) * line_length
444
445 # Draw line with varying opacity
446 alpha = random.randint(100, 200)
447 width = random.randint(1, 3)
448
449 # Simple line (full opacity simulation)
450 draw.line([(start_x, start_y), (end_x, end_y)], fill=color, width=width)
451
452 return frame
453
454
455def create_screen_shake_offset(intensity: int, frame_index: int) -> tuple[int, int]:
456 """
457 Calculate screen shake offset for a frame.
458
459 Args:
460 intensity: Shake intensity in pixels
461 frame_index: Current frame number
462
463 Returns:
464 (x, y) offset tuple
465 """
466 # Use frame index for deterministic but random-looking shake
467 random.seed(frame_index)
468 offset_x = random.randint(-intensity, intensity)
469 offset_y = random.randint(-intensity, intensity)
470 random.seed() # Reset seed
471 return (offset_x, offset_y)
472
473
474def apply_screen_shake(frame: Image.Image, intensity: int, frame_index: int) -> Image.Image:
475 """
476 Apply screen shake effect to entire frame.
477
478 Args:
479 frame: PIL Image
480 intensity: Shake intensity
481 frame_index: Current frame number
482
483 Returns:
484 Shaken frame
485 """
486 offset_x, offset_y = create_screen_shake_offset(intensity, frame_index)
487
488 # Create new frame with background
489 shaken = Image.new('RGB', frame.size, (0, 0, 0))
490
491 # Paste original frame with offset
492 shaken.paste(frame, (offset_x, offset_y))
493
494 return shaken