main
1#!/usr/bin/env python3
2"""
3Spin Animation - Rotate objects continuously or with variation.
4
5Creates spinning, rotating, and wobbling 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_spin_animation(
21 object_type: str = 'emoji',
22 object_data: dict | None = None,
23 num_frames: int = 30,
24 rotation_type: str = 'clockwise', # 'clockwise', 'counterclockwise', 'wobble', 'pendulum'
25 full_rotations: float = 1.0,
26 easing: str = 'linear',
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 spinning/rotating animation.
34
35 Args:
36 object_type: 'emoji', 'image', 'text'
37 object_data: Object configuration
38 num_frames: Number of frames
39 rotation_type: Type of rotation
40 full_rotations: Number of complete 360° rotations
41 easing: Easing function for rotation speed
42 center_pos: Center position for rotation
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 frame = create_blank_frame(frame_width, frame_height, bg_color)
59 t = i / (num_frames - 1) if num_frames > 1 else 0
60
61 # Calculate rotation angle
62 if rotation_type == 'clockwise':
63 angle = interpolate(0, 360 * full_rotations, t, easing)
64 elif rotation_type == 'counterclockwise':
65 angle = interpolate(0, -360 * full_rotations, t, easing)
66 elif rotation_type == 'wobble':
67 # Back and forth rotation
68 angle = math.sin(t * full_rotations * 2 * math.pi) * 45
69 elif rotation_type == 'pendulum':
70 # Smooth pendulum swing
71 angle = math.sin(t * full_rotations * 2 * math.pi) * 90
72 else:
73 angle = interpolate(0, 360 * full_rotations, t, easing)
74
75 # Create object on transparent background to rotate
76 if object_type == 'emoji':
77 # For emoji, we need to create a larger canvas to avoid clipping during rotation
78 emoji_size = object_data['size']
79 canvas_size = int(emoji_size * 1.5)
80 emoji_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
81
82 # Draw emoji in center of canvas
83 from core.frame_composer import draw_emoji_enhanced
84 draw_emoji_enhanced(
85 emoji_canvas,
86 emoji=object_data['emoji'],
87 position=(canvas_size // 2 - emoji_size // 2, canvas_size // 2 - emoji_size // 2),
88 size=emoji_size,
89 shadow=False
90 )
91
92 # Rotate the canvas
93 rotated = emoji_canvas.rotate(angle, resample=Image.BICUBIC, expand=False)
94
95 # Paste onto frame
96 paste_x = center_pos[0] - canvas_size // 2
97 paste_y = center_pos[1] - canvas_size // 2
98 frame.paste(rotated, (paste_x, paste_y), rotated)
99
100 elif object_type == 'text':
101 from core.typography import draw_text_with_outline
102 # Similar approach - create canvas, draw text, rotate
103 text = object_data.get('text', 'SPIN!')
104 font_size = object_data.get('font_size', 50)
105
106 canvas_size = max(frame_width, frame_height)
107 text_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
108
109 # Draw text
110 text_canvas_rgb = text_canvas.convert('RGB')
111 text_canvas_rgb.paste(bg_color, (0, 0, canvas_size, canvas_size))
112 draw_text_with_outline(
113 text_canvas_rgb,
114 text,
115 position=(canvas_size // 2, canvas_size // 2),
116 font_size=font_size,
117 text_color=object_data.get('text_color', (0, 0, 0)),
118 outline_color=object_data.get('outline_color', (255, 255, 255)),
119 outline_width=3,
120 centered=True
121 )
122
123 # Convert back to RGBA for rotation
124 text_canvas = text_canvas_rgb.convert('RGBA')
125
126 # Make background transparent
127 data = text_canvas.getdata()
128 new_data = []
129 for item in data:
130 if item[:3] == bg_color:
131 new_data.append((255, 255, 255, 0))
132 else:
133 new_data.append(item)
134 text_canvas.putdata(new_data)
135
136 # Rotate
137 rotated = text_canvas.rotate(angle, resample=Image.BICUBIC, expand=False)
138
139 # Composite onto frame
140 frame_rgba = frame.convert('RGBA')
141 frame_rgba = Image.alpha_composite(frame_rgba, rotated)
142 frame = frame_rgba.convert('RGB')
143
144 frames.append(frame)
145
146 return frames
147
148
149def create_loading_spinner(
150 num_frames: int = 20,
151 spinner_type: str = 'dots', # 'dots', 'arc', 'emoji'
152 size: int = 100,
153 color: tuple[int, int, int] = (100, 150, 255),
154 frame_width: int = 128,
155 frame_height: int = 128,
156 bg_color: tuple[int, int, int] = (255, 255, 255)
157) -> list[Image.Image]:
158 """
159 Create a loading spinner animation.
160
161 Args:
162 num_frames: Number of frames
163 spinner_type: Type of spinner
164 size: Spinner size
165 color: Spinner color
166 frame_width: Frame width
167 frame_height: Frame height
168 bg_color: Background color
169
170 Returns:
171 List of frames
172 """
173 from PIL import ImageDraw
174 frames = []
175 center = (frame_width // 2, frame_height // 2)
176
177 for i in range(num_frames):
178 frame = create_blank_frame(frame_width, frame_height, bg_color)
179 draw = ImageDraw.Draw(frame)
180
181 angle_offset = (i / num_frames) * 360
182
183 if spinner_type == 'dots':
184 # Circular dots
185 num_dots = 8
186 for j in range(num_dots):
187 angle = (j / num_dots * 360 + angle_offset) * math.pi / 180
188 x = center[0] + size * 0.4 * math.cos(angle)
189 y = center[1] + size * 0.4 * math.sin(angle)
190
191 # Fade based on position
192 alpha = 1.0 - (j / num_dots)
193 dot_color = tuple(int(c * alpha) for c in color)
194 dot_radius = int(size * 0.1)
195
196 draw.ellipse(
197 [x - dot_radius, y - dot_radius, x + dot_radius, y + dot_radius],
198 fill=dot_color
199 )
200
201 elif spinner_type == 'arc':
202 # Rotating arc
203 start_angle = angle_offset
204 end_angle = angle_offset + 270
205 arc_width = int(size * 0.15)
206
207 bbox = [
208 center[0] - size // 2,
209 center[1] - size // 2,
210 center[0] + size // 2,
211 center[1] + size // 2
212 ]
213 draw.arc(bbox, start_angle, end_angle, fill=color, width=arc_width)
214
215 elif spinner_type == 'emoji':
216 # Rotating emoji spinner
217 angle = angle_offset
218 emoji_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
219 draw_emoji_enhanced(
220 emoji_canvas,
221 emoji='⏳',
222 position=(center[0] - size // 2, center[1] - size // 2),
223 size=size,
224 shadow=False
225 )
226 rotated = emoji_canvas.rotate(angle, center=center, resample=Image.BICUBIC)
227 frame.paste(rotated, (0, 0), rotated)
228
229 frames.append(frame)
230
231 return frames
232
233
234# Example usage
235if __name__ == '__main__':
236 print("Creating spin animations...")
237
238 builder = GIFBuilder(width=480, height=480, fps=20)
239
240 # Example 1: Clockwise spin
241 frames = create_spin_animation(
242 object_type='emoji',
243 object_data={'emoji': '🔄', 'size': 100},
244 num_frames=30,
245 rotation_type='clockwise',
246 full_rotations=2
247 )
248 builder.add_frames(frames)
249 builder.save('spin_clockwise.gif', num_colors=128)
250
251 # Example 2: Wobble
252 builder.clear()
253 frames = create_spin_animation(
254 object_type='emoji',
255 object_data={'emoji': '🎯', 'size': 100},
256 num_frames=30,
257 rotation_type='wobble',
258 full_rotations=3
259 )
260 builder.add_frames(frames)
261 builder.save('spin_wobble.gif', num_colors=128)
262
263 # Example 3: Loading spinner
264 builder = GIFBuilder(width=128, height=128, fps=15)
265 frames = create_loading_spinner(num_frames=20, spinner_type='dots')
266 builder.add_frames(frames)
267 builder.save('loading_spinner.gif', num_colors=64, optimize_for_emoji=True)
268
269 print("Created spin animations!")