Files
gh-cam10001110101-claude-sk…/skills/slack-gif-creator/core/frame_composer.py
2025-11-29 18:03:29 +08:00

469 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Frame Composer - Utilities for composing visual elements into frames.
Provides functions for drawing shapes, text, emojis, and compositing elements
together to create animation frames.
"""
from PIL import Image, ImageDraw, ImageFont
import numpy as np
from typing import Optional
def create_blank_frame(width: int, height: int, color: tuple[int, int, int] = (255, 255, 255)) -> Image.Image:
"""
Create a blank frame with solid color background.
Args:
width: Frame width
height: Frame height
color: RGB color tuple (default: white)
Returns:
PIL Image
"""
return Image.new('RGB', (width, height), color)
def draw_circle(frame: Image.Image, center: tuple[int, int], radius: int,
fill_color: Optional[tuple[int, int, int]] = None,
outline_color: Optional[tuple[int, int, int]] = None,
outline_width: int = 1) -> Image.Image:
"""
Draw a circle on a frame.
Args:
frame: PIL Image to draw on
center: (x, y) center position
radius: Circle radius
fill_color: RGB fill color (None for no fill)
outline_color: RGB outline color (None for no outline)
outline_width: Outline width in pixels
Returns:
Modified frame
"""
draw = ImageDraw.Draw(frame)
x, y = center
bbox = [x - radius, y - radius, x + radius, y + radius]
draw.ellipse(bbox, fill=fill_color, outline=outline_color, width=outline_width)
return frame
def draw_rectangle(frame: Image.Image, top_left: tuple[int, int], bottom_right: tuple[int, int],
fill_color: Optional[tuple[int, int, int]] = None,
outline_color: Optional[tuple[int, int, int]] = None,
outline_width: int = 1) -> Image.Image:
"""
Draw a rectangle on a frame.
Args:
frame: PIL Image to draw on
top_left: (x, y) top-left corner
bottom_right: (x, y) bottom-right corner
fill_color: RGB fill color (None for no fill)
outline_color: RGB outline color (None for no outline)
outline_width: Outline width in pixels
Returns:
Modified frame
"""
draw = ImageDraw.Draw(frame)
draw.rectangle([top_left, bottom_right], fill=fill_color, outline=outline_color, width=outline_width)
return frame
def draw_line(frame: Image.Image, start: tuple[int, int], end: tuple[int, int],
color: tuple[int, int, int] = (0, 0, 0), width: int = 2) -> Image.Image:
"""
Draw a line on a frame.
Args:
frame: PIL Image to draw on
start: (x, y) start position
end: (x, y) end position
color: RGB line color
width: Line width in pixels
Returns:
Modified frame
"""
draw = ImageDraw.Draw(frame)
draw.line([start, end], fill=color, width=width)
return frame
def draw_text(frame: Image.Image, text: str, position: tuple[int, int],
font_size: int = 40, color: tuple[int, int, int] = (0, 0, 0),
centered: bool = False) -> Image.Image:
"""
Draw text on a frame.
Args:
frame: PIL Image to draw on
text: Text to draw
position: (x, y) position (top-left unless centered=True)
font_size: Font size in pixels
color: RGB text color
centered: If True, center text at position
Returns:
Modified frame
"""
draw = ImageDraw.Draw(frame)
# Try to use default font, fall back to basic if not available
try:
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size)
except:
font = ImageFont.load_default()
if centered:
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = position[0] - text_width // 2
y = position[1] - text_height // 2
position = (x, y)
draw.text(position, text, fill=color, font=font)
return frame
def draw_emoji(frame: Image.Image, emoji: str, position: tuple[int, int], size: int = 60) -> Image.Image:
"""
Draw emoji text on a frame (requires system emoji support).
Args:
frame: PIL Image to draw on
emoji: Emoji character(s)
position: (x, y) position
size: Emoji size in pixels
Returns:
Modified frame
"""
draw = ImageDraw.Draw(frame)
# Use Apple Color Emoji font on macOS
try:
font = ImageFont.truetype("/System/Library/Fonts/Apple Color Emoji.ttc", size)
except:
# Fallback to text-based emoji
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size)
draw.text(position, emoji, font=font, embedded_color=True)
return frame
def composite_layers(base: Image.Image, overlay: Image.Image,
position: tuple[int, int] = (0, 0), alpha: float = 1.0) -> Image.Image:
"""
Composite one image on top of another.
Args:
base: Base image
overlay: Image to overlay on top
position: (x, y) position to place overlay
alpha: Opacity of overlay (0.0 = transparent, 1.0 = opaque)
Returns:
Composite image
"""
# Convert to RGBA for transparency support
base_rgba = base.convert('RGBA')
overlay_rgba = overlay.convert('RGBA')
# Apply alpha
if alpha < 1.0:
overlay_rgba = overlay_rgba.copy()
overlay_rgba.putalpha(int(255 * alpha))
# Paste overlay onto base
base_rgba.paste(overlay_rgba, position, overlay_rgba)
# Convert back to RGB
return base_rgba.convert('RGB')
def draw_stick_figure(frame: Image.Image, position: tuple[int, int], scale: float = 1.0,
color: tuple[int, int, int] = (0, 0, 0), line_width: int = 3) -> Image.Image:
"""
Draw a simple stick figure.
Args:
frame: PIL Image to draw on
position: (x, y) center position of head
scale: Size multiplier
color: RGB line color
line_width: Line width in pixels
Returns:
Modified frame
"""
draw = ImageDraw.Draw(frame)
x, y = position
# Scale dimensions
head_radius = int(15 * scale)
body_length = int(40 * scale)
arm_length = int(25 * scale)
leg_length = int(35 * scale)
leg_spread = int(15 * scale)
# Head
draw.ellipse([x - head_radius, y - head_radius, x + head_radius, y + head_radius],
outline=color, width=line_width)
# Body
body_start = y + head_radius
body_end = body_start + body_length
draw.line([(x, body_start), (x, body_end)], fill=color, width=line_width)
# Arms
arm_y = body_start + int(body_length * 0.3)
draw.line([(x - arm_length, arm_y), (x + arm_length, arm_y)], fill=color, width=line_width)
# Legs
draw.line([(x, body_end), (x - leg_spread, body_end + leg_length)], fill=color, width=line_width)
draw.line([(x, body_end), (x + leg_spread, body_end + leg_length)], fill=color, width=line_width)
return frame
def create_gradient_background(width: int, height: int,
top_color: tuple[int, int, int],
bottom_color: tuple[int, int, int]) -> Image.Image:
"""
Create a vertical gradient background.
Args:
width: Frame width
height: Frame height
top_color: RGB color at top
bottom_color: RGB color at bottom
Returns:
PIL Image with gradient
"""
frame = Image.new('RGB', (width, height))
draw = ImageDraw.Draw(frame)
# Calculate color step for each row
r1, g1, b1 = top_color
r2, g2, b2 = bottom_color
for y in range(height):
# Interpolate color
ratio = y / height
r = int(r1 * (1 - ratio) + r2 * ratio)
g = int(g1 * (1 - ratio) + g2 * ratio)
b = int(b1 * (1 - ratio) + b2 * ratio)
# Draw horizontal line
draw.line([(0, y), (width, y)], fill=(r, g, b))
return frame
def draw_emoji_enhanced(frame: Image.Image, emoji: str, position: tuple[int, int],
size: int = 60, shadow: bool = True,
shadow_offset: tuple[int, int] = (2, 2)) -> Image.Image:
"""
Draw emoji with optional shadow for better visual quality.
Args:
frame: PIL Image to draw on
emoji: Emoji character(s)
position: (x, y) position
size: Emoji size in pixels (minimum 12)
shadow: Whether to add drop shadow
shadow_offset: Shadow offset
Returns:
Modified frame
"""
draw = ImageDraw.Draw(frame)
# Ensure minimum size to avoid font rendering errors
size = max(12, size)
# Use Apple Color Emoji font on macOS
try:
font = ImageFont.truetype("/System/Library/Fonts/Apple Color Emoji.ttc", size)
except:
# Fallback to text-based emoji
try:
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size)
except:
font = ImageFont.load_default()
# Draw shadow first if enabled
if shadow and size >= 20: # Only draw shadow for larger emojis
shadow_pos = (position[0] + shadow_offset[0], position[1] + shadow_offset[1])
# Draw semi-transparent shadow (simulated by drawing multiple times)
for offset in range(1, 3):
try:
draw.text((shadow_pos[0] + offset, shadow_pos[1] + offset),
emoji, font=font, embedded_color=True, fill=(0, 0, 0, 100))
except:
pass # Skip shadow if it fails
# Draw main emoji
try:
draw.text(position, emoji, font=font, embedded_color=True)
except:
# Fallback to basic drawing if embedded color fails
draw.text(position, emoji, font=font, fill=(0, 0, 0))
return frame
def draw_circle_with_shadow(frame: Image.Image, center: tuple[int, int], radius: int,
fill_color: tuple[int, int, int],
shadow_offset: tuple[int, int] = (3, 3),
shadow_color: tuple[int, int, int] = (0, 0, 0)) -> Image.Image:
"""
Draw a circle with drop shadow.
Args:
frame: PIL Image to draw on
center: (x, y) center position
radius: Circle radius
fill_color: RGB fill color
shadow_offset: (x, y) shadow offset
shadow_color: RGB shadow color
Returns:
Modified frame
"""
draw = ImageDraw.Draw(frame)
x, y = center
# Draw shadow
shadow_center = (x + shadow_offset[0], y + shadow_offset[1])
shadow_bbox = [
shadow_center[0] - radius,
shadow_center[1] - radius,
shadow_center[0] + radius,
shadow_center[1] + radius
]
draw.ellipse(shadow_bbox, fill=shadow_color)
# Draw main circle
bbox = [x - radius, y - radius, x + radius, y + radius]
draw.ellipse(bbox, fill=fill_color)
return frame
def draw_rounded_rectangle(frame: Image.Image, top_left: tuple[int, int],
bottom_right: tuple[int, int], radius: int,
fill_color: Optional[tuple[int, int, int]] = None,
outline_color: Optional[tuple[int, int, int]] = None,
outline_width: int = 1) -> Image.Image:
"""
Draw a rectangle with rounded corners.
Args:
frame: PIL Image to draw on
top_left: (x, y) top-left corner
bottom_right: (x, y) bottom-right corner
radius: Corner radius
fill_color: RGB fill color (None for no fill)
outline_color: RGB outline color (None for no outline)
outline_width: Outline width
Returns:
Modified frame
"""
draw = ImageDraw.Draw(frame)
x1, y1 = top_left
x2, y2 = bottom_right
# Draw rounded rectangle using PIL's built-in method
draw.rounded_rectangle([x1, y1, x2, y2], radius=radius,
fill=fill_color, outline=outline_color, width=outline_width)
return frame
def add_vignette(frame: Image.Image, strength: float = 0.5) -> Image.Image:
"""
Add a vignette effect (darkened edges) to frame.
Args:
frame: PIL Image
strength: Vignette strength (0.0-1.0)
Returns:
Frame with vignette
"""
width, height = frame.size
# Create radial gradient mask
center_x, center_y = width // 2, height // 2
max_dist = ((width / 2) ** 2 + (height / 2) ** 2) ** 0.5
# Create overlay
overlay = Image.new('RGB', (width, height), (0, 0, 0))
pixels = overlay.load()
for y in range(height):
for x in range(width):
# Calculate distance from center
dx = x - center_x
dy = y - center_y
dist = (dx ** 2 + dy ** 2) ** 0.5
# Calculate vignette value
vignette = min(1, (dist / max_dist) * strength)
value = int(255 * (1 - vignette))
pixels[x, y] = (value, value, value)
# Blend with original using multiply
frame_array = np.array(frame, dtype=np.float32) / 255
overlay_array = np.array(overlay, dtype=np.float32) / 255
result = frame_array * overlay_array
result = (result * 255).astype(np.uint8)
return Image.fromarray(result)
def draw_star(frame: Image.Image, center: tuple[int, int], size: int,
fill_color: tuple[int, int, int],
outline_color: Optional[tuple[int, int, int]] = None,
outline_width: int = 1) -> Image.Image:
"""
Draw a 5-pointed star.
Args:
frame: PIL Image to draw on
center: (x, y) center position
size: Star size (outer radius)
fill_color: RGB fill color
outline_color: RGB outline color (None for no outline)
outline_width: Outline width
Returns:
Modified frame
"""
import math
draw = ImageDraw.Draw(frame)
x, y = center
# Calculate star points
points = []
for i in range(10):
angle = (i * 36 - 90) * math.pi / 180 # 36 degrees per point, start at top
radius = size if i % 2 == 0 else size * 0.4 # Alternate between outer and inner
px = x + radius * math.cos(angle)
py = y + radius * math.sin(angle)
points.append((px, py))
# Draw star
draw.polygon(points, fill=fill_color, outline=outline_color, width=outline_width)
return frame