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