Initial commit
This commit is contained in:
469
skills/slack-gif-creator/core/frame_composer.py
Executable file
469
skills/slack-gif-creator/core/frame_composer.py
Executable file
@@ -0,0 +1,469 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user