301 lines
9.8 KiB
Python
301 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Wiggle Animation - Smooth, organic wobbling and jiggling motions.
|
|
|
|
Creates playful, elastic movements that are smoother than shake.
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
import math
|
|
|
|
sys.path.append(str(Path(__file__).parent.parent))
|
|
|
|
from PIL import Image
|
|
from core.gif_builder import GIFBuilder
|
|
from core.frame_composer import create_blank_frame, draw_emoji_enhanced
|
|
from core.easing import interpolate
|
|
|
|
|
|
def create_wiggle_animation(
|
|
object_type: str = 'emoji',
|
|
object_data: dict | None = None,
|
|
num_frames: int = 30,
|
|
wiggle_type: str = 'jello', # 'jello', 'wave', 'bounce', 'sway'
|
|
intensity: float = 1.0,
|
|
cycles: float = 2.0,
|
|
center_pos: tuple[int, int] = (240, 240),
|
|
frame_width: int = 480,
|
|
frame_height: int = 480,
|
|
bg_color: tuple[int, int, int] = (255, 255, 255)
|
|
) -> list[Image.Image]:
|
|
"""
|
|
Create wiggle/wobble animation.
|
|
|
|
Args:
|
|
object_type: 'emoji', 'text'
|
|
object_data: Object configuration
|
|
num_frames: Number of frames
|
|
wiggle_type: Type of wiggle motion
|
|
intensity: Wiggle intensity multiplier
|
|
cycles: Number of wiggle cycles
|
|
center_pos: Center position
|
|
frame_width: Frame width
|
|
frame_height: Frame height
|
|
bg_color: Background color
|
|
|
|
Returns:
|
|
List of frames
|
|
"""
|
|
frames = []
|
|
|
|
# Default object data
|
|
if object_data is None:
|
|
if object_type == 'emoji':
|
|
object_data = {'emoji': '🎈', 'size': 100}
|
|
|
|
for i in range(num_frames):
|
|
t = i / (num_frames - 1) if num_frames > 1 else 0
|
|
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
|
|
|
# Calculate wiggle transformations
|
|
offset_x = 0
|
|
offset_y = 0
|
|
rotation = 0
|
|
scale_x = 1.0
|
|
scale_y = 1.0
|
|
|
|
if wiggle_type == 'jello':
|
|
# Jello wobble - multiple frequencies
|
|
freq1 = cycles * 2 * math.pi
|
|
freq2 = cycles * 3 * math.pi
|
|
freq3 = cycles * 5 * math.pi
|
|
|
|
decay = 1.0 - t if cycles < 1.5 else 1.0 # Decay for single wiggles
|
|
|
|
offset_x = (
|
|
math.sin(freq1 * t) * 15 +
|
|
math.sin(freq2 * t) * 8 +
|
|
math.sin(freq3 * t) * 3
|
|
) * intensity * decay
|
|
|
|
rotation = (
|
|
math.sin(freq1 * t) * 10 +
|
|
math.cos(freq2 * t) * 5
|
|
) * intensity * decay
|
|
|
|
# Squash and stretch
|
|
scale_y = 1.0 + math.sin(freq1 * t) * 0.1 * intensity * decay
|
|
scale_x = 1.0 / scale_y # Preserve volume
|
|
|
|
elif wiggle_type == 'wave':
|
|
# Wave motion
|
|
freq = cycles * 2 * math.pi
|
|
offset_y = math.sin(freq * t) * 20 * intensity
|
|
rotation = math.sin(freq * t + math.pi / 4) * 8 * intensity
|
|
|
|
elif wiggle_type == 'bounce':
|
|
# Bouncy wiggle
|
|
freq = cycles * 2 * math.pi
|
|
bounce = abs(math.sin(freq * t))
|
|
|
|
scale_y = 1.0 + bounce * 0.2 * intensity
|
|
scale_x = 1.0 - bounce * 0.1 * intensity
|
|
offset_y = -bounce * 10 * intensity
|
|
|
|
elif wiggle_type == 'sway':
|
|
# Gentle sway back and forth
|
|
freq = cycles * 2 * math.pi
|
|
offset_x = math.sin(freq * t) * 25 * intensity
|
|
rotation = math.sin(freq * t) * 12 * intensity
|
|
|
|
# Subtle scale change
|
|
scale = 1.0 + math.sin(freq * t) * 0.05 * intensity
|
|
scale_x = scale
|
|
scale_y = scale
|
|
|
|
elif wiggle_type == 'tail_wag':
|
|
# Like a wagging tail - base stays, tip moves
|
|
freq = cycles * 2 * math.pi
|
|
wag = math.sin(freq * t) * intensity
|
|
|
|
# Rotation focused at one end
|
|
rotation = wag * 20
|
|
offset_x = wag * 15
|
|
|
|
# Apply transformations
|
|
if object_type == 'emoji':
|
|
size = object_data['size']
|
|
size_x = int(size * scale_x)
|
|
size_y = int(size * scale_y)
|
|
|
|
# For non-uniform scaling or rotation, we need to use PIL transforms
|
|
if abs(scale_x - scale_y) > 0.01 or abs(rotation) > 0.1:
|
|
# Create emoji on transparent canvas
|
|
canvas_size = int(size * 2)
|
|
emoji_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
|
|
|
|
# Draw emoji
|
|
draw_emoji_enhanced(
|
|
emoji_canvas,
|
|
emoji=object_data['emoji'],
|
|
position=(canvas_size // 2 - size // 2, canvas_size // 2 - size // 2),
|
|
size=size,
|
|
shadow=False
|
|
)
|
|
|
|
# Scale
|
|
if abs(scale_x - scale_y) > 0.01:
|
|
new_size = (int(canvas_size * scale_x), int(canvas_size * scale_y))
|
|
emoji_canvas = emoji_canvas.resize(new_size, Image.LANCZOS)
|
|
canvas_size_x, canvas_size_y = new_size
|
|
else:
|
|
canvas_size_x = canvas_size_y = canvas_size
|
|
|
|
# Rotate
|
|
if abs(rotation) > 0.1:
|
|
emoji_canvas = emoji_canvas.rotate(
|
|
rotation,
|
|
resample=Image.BICUBIC,
|
|
expand=False
|
|
)
|
|
|
|
# Position with offset
|
|
paste_x = int(center_pos[0] - canvas_size_x // 2 + offset_x)
|
|
paste_y = int(center_pos[1] - canvas_size_y // 2 + offset_y)
|
|
|
|
frame_rgba = frame.convert('RGBA')
|
|
frame_rgba.paste(emoji_canvas, (paste_x, paste_y), emoji_canvas)
|
|
frame = frame_rgba.convert('RGB')
|
|
else:
|
|
# Simple case - just offset
|
|
pos_x = int(center_pos[0] - size // 2 + offset_x)
|
|
pos_y = int(center_pos[1] - size // 2 + offset_y)
|
|
draw_emoji_enhanced(
|
|
frame,
|
|
emoji=object_data['emoji'],
|
|
position=(pos_x, pos_y),
|
|
size=size,
|
|
shadow=object_data.get('shadow', True)
|
|
)
|
|
|
|
elif object_type == 'text':
|
|
from core.typography import draw_text_with_outline
|
|
|
|
# Create text on canvas for transformation
|
|
canvas_size = max(frame_width, frame_height)
|
|
text_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
|
|
|
|
# Convert to RGB for drawing
|
|
text_canvas_rgb = text_canvas.convert('RGB')
|
|
text_canvas_rgb.paste(bg_color, (0, 0, canvas_size, canvas_size))
|
|
|
|
draw_text_with_outline(
|
|
text_canvas_rgb,
|
|
text=object_data.get('text', 'WIGGLE'),
|
|
position=(canvas_size // 2, canvas_size // 2),
|
|
font_size=object_data.get('font_size', 50),
|
|
text_color=object_data.get('text_color', (0, 0, 0)),
|
|
outline_color=object_data.get('outline_color', (255, 255, 255)),
|
|
outline_width=3,
|
|
centered=True
|
|
)
|
|
|
|
# Make transparent
|
|
text_canvas = text_canvas_rgb.convert('RGBA')
|
|
data = text_canvas.getdata()
|
|
new_data = []
|
|
for item in data:
|
|
if item[:3] == bg_color:
|
|
new_data.append((255, 255, 255, 0))
|
|
else:
|
|
new_data.append(item)
|
|
text_canvas.putdata(new_data)
|
|
|
|
# Apply rotation
|
|
if abs(rotation) > 0.1:
|
|
text_canvas = text_canvas.rotate(rotation, center=(canvas_size // 2, canvas_size // 2), resample=Image.BICUBIC)
|
|
|
|
# Crop to frame with offset
|
|
left = (canvas_size - frame_width) // 2 - int(offset_x)
|
|
top = (canvas_size - frame_height) // 2 - int(offset_y)
|
|
text_cropped = text_canvas.crop((left, top, left + frame_width, top + frame_height))
|
|
|
|
frame_rgba = frame.convert('RGBA')
|
|
frame = Image.alpha_composite(frame_rgba, text_cropped)
|
|
frame = frame.convert('RGB')
|
|
|
|
frames.append(frame)
|
|
|
|
return frames
|
|
|
|
|
|
def create_excited_wiggle(
|
|
emoji: str = '🎉',
|
|
num_frames: int = 20,
|
|
frame_size: int = 128
|
|
) -> list[Image.Image]:
|
|
"""
|
|
Create excited wiggle for emoji GIFs.
|
|
|
|
Args:
|
|
emoji: Emoji to wiggle
|
|
num_frames: Number of frames
|
|
frame_size: Frame size (square)
|
|
|
|
Returns:
|
|
List of frames
|
|
"""
|
|
return create_wiggle_animation(
|
|
object_type='emoji',
|
|
object_data={'emoji': emoji, 'size': 80, 'shadow': False},
|
|
num_frames=num_frames,
|
|
wiggle_type='jello',
|
|
intensity=0.8,
|
|
cycles=2,
|
|
center_pos=(frame_size // 2, frame_size // 2),
|
|
frame_width=frame_size,
|
|
frame_height=frame_size,
|
|
bg_color=(255, 255, 255)
|
|
)
|
|
|
|
|
|
# Example usage
|
|
if __name__ == '__main__':
|
|
print("Creating wiggle animations...")
|
|
|
|
builder = GIFBuilder(width=480, height=480, fps=20)
|
|
|
|
# Example 1: Jello wiggle
|
|
frames = create_wiggle_animation(
|
|
object_type='emoji',
|
|
object_data={'emoji': '🎈', 'size': 100},
|
|
num_frames=40,
|
|
wiggle_type='jello',
|
|
intensity=1.0,
|
|
cycles=2
|
|
)
|
|
builder.add_frames(frames)
|
|
builder.save('wiggle_jello.gif', num_colors=128)
|
|
|
|
# Example 2: Wave
|
|
builder.clear()
|
|
frames = create_wiggle_animation(
|
|
object_type='emoji',
|
|
object_data={'emoji': '🌊', 'size': 100},
|
|
num_frames=30,
|
|
wiggle_type='wave',
|
|
intensity=1.2,
|
|
cycles=3
|
|
)
|
|
builder.add_frames(frames)
|
|
builder.save('wiggle_wave.gif', num_colors=128)
|
|
|
|
# Example 3: Excited wiggle (emoji size)
|
|
builder = GIFBuilder(width=128, height=128, fps=15)
|
|
frames = create_excited_wiggle(emoji='🎉', num_frames=20)
|
|
builder.add_frames(frames)
|
|
builder.save('wiggle_excited.gif', num_colors=48, optimize_for_emoji=True)
|
|
|
|
print("Created wiggle animations!")
|