main
1#!/usr/bin/env python3
2"""
3Pulse Animation - Scale objects rhythmically for emphasis.
4
5Creates pulsing, heartbeat, and throbbing effects.
6"""
7
8import sys
9from pathlib import Path
10import math
11
12sys.path.append(str(Path(__file__).parent.parent))
13
14from PIL import Image
15from core.gif_builder import GIFBuilder
16from core.frame_composer import create_blank_frame, draw_emoji_enhanced, draw_circle
17from core.easing import interpolate
18
19
20def create_pulse_animation(
21 object_type: str = 'emoji',
22 object_data: dict | None = None,
23 num_frames: int = 30,
24 pulse_type: str = 'smooth', # 'smooth', 'heartbeat', 'throb', 'pop'
25 scale_range: tuple[float, float] = (0.8, 1.2),
26 pulses: float = 2.0,
27 center_pos: tuple[int, int] = (240, 240),
28 frame_width: int = 480,
29 frame_height: int = 480,
30 bg_color: tuple[int, int, int] = (255, 255, 255)
31) -> list[Image.Image]:
32 """
33 Create pulsing/scaling animation.
34
35 Args:
36 object_type: 'emoji', 'circle', 'text'
37 object_data: Object configuration
38 num_frames: Number of frames
39 pulse_type: Type of pulsing motion
40 scale_range: (min_scale, max_scale) tuple
41 pulses: Number of pulses in animation
42 center_pos: Center position
43 frame_width: Frame width
44 frame_height: Frame height
45 bg_color: Background color
46
47 Returns:
48 List of frames
49 """
50 frames = []
51
52 # Default object data
53 if object_data is None:
54 if object_type == 'emoji':
55 object_data = {'emoji': '❤️', 'size': 100}
56 elif object_type == 'circle':
57 object_data = {'radius': 50, 'color': (255, 100, 100)}
58
59 min_scale, max_scale = scale_range
60
61 for i in range(num_frames):
62 frame = create_blank_frame(frame_width, frame_height, bg_color)
63 t = i / (num_frames - 1) if num_frames > 1 else 0
64
65 # Calculate scale based on pulse type
66 if pulse_type == 'smooth':
67 # Simple sinusoidal pulse
68 scale = min_scale + (max_scale - min_scale) * (
69 0.5 + 0.5 * math.sin(t * pulses * 2 * math.pi - math.pi / 2)
70 )
71
72 elif pulse_type == 'heartbeat':
73 # Double pump like a heartbeat
74 phase = (t * pulses) % 1.0
75 if phase < 0.15:
76 # First pump
77 scale = interpolate(min_scale, max_scale, phase / 0.15, 'ease_out')
78 elif phase < 0.25:
79 # First release
80 scale = interpolate(max_scale, min_scale, (phase - 0.15) / 0.10, 'ease_in')
81 elif phase < 0.35:
82 # Second pump (smaller)
83 scale = interpolate(min_scale, (min_scale + max_scale) / 2, (phase - 0.25) / 0.10, 'ease_out')
84 elif phase < 0.45:
85 # Second release
86 scale = interpolate((min_scale + max_scale) / 2, min_scale, (phase - 0.35) / 0.10, 'ease_in')
87 else:
88 # Rest period
89 scale = min_scale
90
91 elif pulse_type == 'throb':
92 # Sharp pulse with quick return
93 phase = (t * pulses) % 1.0
94 if phase < 0.2:
95 scale = interpolate(min_scale, max_scale, phase / 0.2, 'ease_out')
96 else:
97 scale = interpolate(max_scale, min_scale, (phase - 0.2) / 0.8, 'ease_in')
98
99 elif pulse_type == 'pop':
100 # Pop out and back with overshoot
101 phase = (t * pulses) % 1.0
102 if phase < 0.3:
103 # Pop out with overshoot
104 scale = interpolate(min_scale, max_scale * 1.1, phase / 0.3, 'elastic_out')
105 else:
106 # Settle back
107 scale = interpolate(max_scale * 1.1, min_scale, (phase - 0.3) / 0.7, 'ease_out')
108
109 else:
110 scale = min_scale + (max_scale - min_scale) * (
111 0.5 + 0.5 * math.sin(t * pulses * 2 * math.pi)
112 )
113
114 # Draw object at calculated scale
115 if object_type == 'emoji':
116 base_size = object_data['size']
117 current_size = int(base_size * scale)
118 draw_emoji_enhanced(
119 frame,
120 emoji=object_data['emoji'],
121 position=(center_pos[0] - current_size // 2, center_pos[1] - current_size // 2),
122 size=current_size,
123 shadow=object_data.get('shadow', True)
124 )
125
126 elif object_type == 'circle':
127 base_radius = object_data['radius']
128 current_radius = int(base_radius * scale)
129 draw_circle(
130 frame,
131 center=center_pos,
132 radius=current_radius,
133 fill_color=object_data['color']
134 )
135
136 elif object_type == 'text':
137 from core.typography import draw_text_with_outline
138 base_size = object_data.get('font_size', 50)
139 current_size = int(base_size * scale)
140 draw_text_with_outline(
141 frame,
142 text=object_data.get('text', 'PULSE'),
143 position=center_pos,
144 font_size=current_size,
145 text_color=object_data.get('text_color', (255, 100, 100)),
146 outline_color=object_data.get('outline_color', (0, 0, 0)),
147 outline_width=3,
148 centered=True
149 )
150
151 frames.append(frame)
152
153 return frames
154
155
156def create_attention_pulse(
157 emoji: str = '⚠️',
158 num_frames: int = 20,
159 frame_size: int = 128,
160 bg_color: tuple[int, int, int] = (255, 255, 255)
161) -> list[Image.Image]:
162 """
163 Create attention-grabbing pulse (good for emoji GIFs).
164
165 Args:
166 emoji: Emoji to pulse
167 num_frames: Number of frames
168 frame_size: Frame size (square)
169 bg_color: Background color
170
171 Returns:
172 List of frames optimized for emoji size
173 """
174 return create_pulse_animation(
175 object_type='emoji',
176 object_data={'emoji': emoji, 'size': 80, 'shadow': False},
177 num_frames=num_frames,
178 pulse_type='throb',
179 scale_range=(0.85, 1.15),
180 pulses=2,
181 center_pos=(frame_size // 2, frame_size // 2),
182 frame_width=frame_size,
183 frame_height=frame_size,
184 bg_color=bg_color
185 )
186
187
188def create_breathing_animation(
189 object_type: str = 'emoji',
190 object_data: dict | None = None,
191 num_frames: int = 60,
192 breaths: float = 2.0,
193 scale_range: tuple[float, float] = (0.9, 1.1),
194 frame_width: int = 480,
195 frame_height: int = 480,
196 bg_color: tuple[int, int, int] = (240, 248, 255)
197) -> list[Image.Image]:
198 """
199 Create slow, calming breathing animation (in and out).
200
201 Args:
202 object_type: Type of object
203 object_data: Object configuration
204 num_frames: Number of frames
205 breaths: Number of breathing cycles
206 scale_range: Min/max scale
207 frame_width: Frame width
208 frame_height: Frame height
209 bg_color: Background color
210
211 Returns:
212 List of frames
213 """
214 if object_data is None:
215 object_data = {'emoji': '😌', 'size': 100}
216
217 return create_pulse_animation(
218 object_type=object_type,
219 object_data=object_data,
220 num_frames=num_frames,
221 pulse_type='smooth',
222 scale_range=scale_range,
223 pulses=breaths,
224 center_pos=(frame_width // 2, frame_height // 2),
225 frame_width=frame_width,
226 frame_height=frame_height,
227 bg_color=bg_color
228 )
229
230
231# Example usage
232if __name__ == '__main__':
233 print("Creating pulse animations...")
234
235 builder = GIFBuilder(width=480, height=480, fps=20)
236
237 # Example 1: Smooth pulse
238 frames = create_pulse_animation(
239 object_type='emoji',
240 object_data={'emoji': '❤️', 'size': 100},
241 num_frames=40,
242 pulse_type='smooth',
243 scale_range=(0.8, 1.2),
244 pulses=2
245 )
246 builder.add_frames(frames)
247 builder.save('pulse_smooth.gif', num_colors=128)
248
249 # Example 2: Heartbeat
250 builder.clear()
251 frames = create_pulse_animation(
252 object_type='emoji',
253 object_data={'emoji': '💓', 'size': 100},
254 num_frames=60,
255 pulse_type='heartbeat',
256 scale_range=(0.85, 1.2),
257 pulses=3
258 )
259 builder.add_frames(frames)
260 builder.save('pulse_heartbeat.gif', num_colors=128)
261
262 # Example 3: Attention pulse (emoji size)
263 builder = GIFBuilder(width=128, height=128, fps=15)
264 frames = create_attention_pulse(emoji='⚠️', num_frames=20)
265 builder.add_frames(frames)
266 builder.save('pulse_attention.gif', num_colors=48, optimize_for_emoji=True)
267
268 print("Created pulse animations!")