main
1#!/usr/bin/env python3
2"""
3Wiggle Animation - Smooth, organic wobbling and jiggling motions.
4
5Creates playful, elastic movements that are smoother than shake.
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
17from core.easing import interpolate
18
19
20def create_wiggle_animation(
21 object_type: str = 'emoji',
22 object_data: dict | None = None,
23 num_frames: int = 30,
24 wiggle_type: str = 'jello', # 'jello', 'wave', 'bounce', 'sway'
25 intensity: float = 1.0,
26 cycles: 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 wiggle/wobble animation.
34
35 Args:
36 object_type: 'emoji', 'text'
37 object_data: Object configuration
38 num_frames: Number of frames
39 wiggle_type: Type of wiggle motion
40 intensity: Wiggle intensity multiplier
41 cycles: Number of wiggle cycles
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
57 for i in range(num_frames):
58 t = i / (num_frames - 1) if num_frames > 1 else 0
59 frame = create_blank_frame(frame_width, frame_height, bg_color)
60
61 # Calculate wiggle transformations
62 offset_x = 0
63 offset_y = 0
64 rotation = 0
65 scale_x = 1.0
66 scale_y = 1.0
67
68 if wiggle_type == 'jello':
69 # Jello wobble - multiple frequencies
70 freq1 = cycles * 2 * math.pi
71 freq2 = cycles * 3 * math.pi
72 freq3 = cycles * 5 * math.pi
73
74 decay = 1.0 - t if cycles < 1.5 else 1.0 # Decay for single wiggles
75
76 offset_x = (
77 math.sin(freq1 * t) * 15 +
78 math.sin(freq2 * t) * 8 +
79 math.sin(freq3 * t) * 3
80 ) * intensity * decay
81
82 rotation = (
83 math.sin(freq1 * t) * 10 +
84 math.cos(freq2 * t) * 5
85 ) * intensity * decay
86
87 # Squash and stretch
88 scale_y = 1.0 + math.sin(freq1 * t) * 0.1 * intensity * decay
89 scale_x = 1.0 / scale_y # Preserve volume
90
91 elif wiggle_type == 'wave':
92 # Wave motion
93 freq = cycles * 2 * math.pi
94 offset_y = math.sin(freq * t) * 20 * intensity
95 rotation = math.sin(freq * t + math.pi / 4) * 8 * intensity
96
97 elif wiggle_type == 'bounce':
98 # Bouncy wiggle
99 freq = cycles * 2 * math.pi
100 bounce = abs(math.sin(freq * t))
101
102 scale_y = 1.0 + bounce * 0.2 * intensity
103 scale_x = 1.0 - bounce * 0.1 * intensity
104 offset_y = -bounce * 10 * intensity
105
106 elif wiggle_type == 'sway':
107 # Gentle sway back and forth
108 freq = cycles * 2 * math.pi
109 offset_x = math.sin(freq * t) * 25 * intensity
110 rotation = math.sin(freq * t) * 12 * intensity
111
112 # Subtle scale change
113 scale = 1.0 + math.sin(freq * t) * 0.05 * intensity
114 scale_x = scale
115 scale_y = scale
116
117 elif wiggle_type == 'tail_wag':
118 # Like a wagging tail - base stays, tip moves
119 freq = cycles * 2 * math.pi
120 wag = math.sin(freq * t) * intensity
121
122 # Rotation focused at one end
123 rotation = wag * 20
124 offset_x = wag * 15
125
126 # Apply transformations
127 if object_type == 'emoji':
128 size = object_data['size']
129 size_x = int(size * scale_x)
130 size_y = int(size * scale_y)
131
132 # For non-uniform scaling or rotation, we need to use PIL transforms
133 if abs(scale_x - scale_y) > 0.01 or abs(rotation) > 0.1:
134 # Create emoji on transparent canvas
135 canvas_size = int(size * 2)
136 emoji_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
137
138 # Draw emoji
139 draw_emoji_enhanced(
140 emoji_canvas,
141 emoji=object_data['emoji'],
142 position=(canvas_size // 2 - size // 2, canvas_size // 2 - size // 2),
143 size=size,
144 shadow=False
145 )
146
147 # Scale
148 if abs(scale_x - scale_y) > 0.01:
149 new_size = (int(canvas_size * scale_x), int(canvas_size * scale_y))
150 emoji_canvas = emoji_canvas.resize(new_size, Image.LANCZOS)
151 canvas_size_x, canvas_size_y = new_size
152 else:
153 canvas_size_x = canvas_size_y = canvas_size
154
155 # Rotate
156 if abs(rotation) > 0.1:
157 emoji_canvas = emoji_canvas.rotate(
158 rotation,
159 resample=Image.BICUBIC,
160 expand=False
161 )
162
163 # Position with offset
164 paste_x = int(center_pos[0] - canvas_size_x // 2 + offset_x)
165 paste_y = int(center_pos[1] - canvas_size_y // 2 + offset_y)
166
167 frame_rgba = frame.convert('RGBA')
168 frame_rgba.paste(emoji_canvas, (paste_x, paste_y), emoji_canvas)
169 frame = frame_rgba.convert('RGB')
170 else:
171 # Simple case - just offset
172 pos_x = int(center_pos[0] - size // 2 + offset_x)
173 pos_y = int(center_pos[1] - size // 2 + offset_y)
174 draw_emoji_enhanced(
175 frame,
176 emoji=object_data['emoji'],
177 position=(pos_x, pos_y),
178 size=size,
179 shadow=object_data.get('shadow', True)
180 )
181
182 elif object_type == 'text':
183 from core.typography import draw_text_with_outline
184
185 # Create text on canvas for transformation
186 canvas_size = max(frame_width, frame_height)
187 text_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
188
189 # Convert to RGB for drawing
190 text_canvas_rgb = text_canvas.convert('RGB')
191 text_canvas_rgb.paste(bg_color, (0, 0, canvas_size, canvas_size))
192
193 draw_text_with_outline(
194 text_canvas_rgb,
195 text=object_data.get('text', 'WIGGLE'),
196 position=(canvas_size // 2, canvas_size // 2),
197 font_size=object_data.get('font_size', 50),
198 text_color=object_data.get('text_color', (0, 0, 0)),
199 outline_color=object_data.get('outline_color', (255, 255, 255)),
200 outline_width=3,
201 centered=True
202 )
203
204 # Make transparent
205 text_canvas = text_canvas_rgb.convert('RGBA')
206 data = text_canvas.getdata()
207 new_data = []
208 for item in data:
209 if item[:3] == bg_color:
210 new_data.append((255, 255, 255, 0))
211 else:
212 new_data.append(item)
213 text_canvas.putdata(new_data)
214
215 # Apply rotation
216 if abs(rotation) > 0.1:
217 text_canvas = text_canvas.rotate(rotation, center=(canvas_size // 2, canvas_size // 2), resample=Image.BICUBIC)
218
219 # Crop to frame with offset
220 left = (canvas_size - frame_width) // 2 - int(offset_x)
221 top = (canvas_size - frame_height) // 2 - int(offset_y)
222 text_cropped = text_canvas.crop((left, top, left + frame_width, top + frame_height))
223
224 frame_rgba = frame.convert('RGBA')
225 frame = Image.alpha_composite(frame_rgba, text_cropped)
226 frame = frame.convert('RGB')
227
228 frames.append(frame)
229
230 return frames
231
232
233def create_excited_wiggle(
234 emoji: str = '🎉',
235 num_frames: int = 20,
236 frame_size: int = 128
237) -> list[Image.Image]:
238 """
239 Create excited wiggle for emoji GIFs.
240
241 Args:
242 emoji: Emoji to wiggle
243 num_frames: Number of frames
244 frame_size: Frame size (square)
245
246 Returns:
247 List of frames
248 """
249 return create_wiggle_animation(
250 object_type='emoji',
251 object_data={'emoji': emoji, 'size': 80, 'shadow': False},
252 num_frames=num_frames,
253 wiggle_type='jello',
254 intensity=0.8,
255 cycles=2,
256 center_pos=(frame_size // 2, frame_size // 2),
257 frame_width=frame_size,
258 frame_height=frame_size,
259 bg_color=(255, 255, 255)
260 )
261
262
263# Example usage
264if __name__ == '__main__':
265 print("Creating wiggle animations...")
266
267 builder = GIFBuilder(width=480, height=480, fps=20)
268
269 # Example 1: Jello wiggle
270 frames = create_wiggle_animation(
271 object_type='emoji',
272 object_data={'emoji': '🎈', 'size': 100},
273 num_frames=40,
274 wiggle_type='jello',
275 intensity=1.0,
276 cycles=2
277 )
278 builder.add_frames(frames)
279 builder.save('wiggle_jello.gif', num_colors=128)
280
281 # Example 2: Wave
282 builder.clear()
283 frames = create_wiggle_animation(
284 object_type='emoji',
285 object_data={'emoji': '🌊', 'size': 100},
286 num_frames=30,
287 wiggle_type='wave',
288 intensity=1.2,
289 cycles=3
290 )
291 builder.add_frames(frames)
292 builder.save('wiggle_wave.gif', num_colors=128)
293
294 # Example 3: Excited wiggle (emoji size)
295 builder = GIFBuilder(width=128, height=128, fps=15)
296 frames = create_excited_wiggle(emoji='🎉', num_frames=20)
297 builder.add_frames(frames)
298 builder.save('wiggle_excited.gif', num_colors=48, optimize_for_emoji=True)
299
300 print("Created wiggle animations!")