mirror of
https://github.com/anthropics/skills.git
synced 2026-04-19 16:43:41 +08:00
Update example skills and rename 'artifacts-builder' (#112)
* Export updated examples * Rename 'artifacts-builder' to 'web-artifacts-builder'
This commit is contained in:
@@ -101,25 +101,25 @@ def ease_in_out_elastic(t: float) -> float:
|
||||
|
||||
# Convenience mapping
|
||||
EASING_FUNCTIONS = {
|
||||
'linear': linear,
|
||||
'ease_in': ease_in_quad,
|
||||
'ease_out': ease_out_quad,
|
||||
'ease_in_out': ease_in_out_quad,
|
||||
'bounce_in': ease_in_bounce,
|
||||
'bounce_out': ease_out_bounce,
|
||||
'bounce': ease_in_out_bounce,
|
||||
'elastic_in': ease_in_elastic,
|
||||
'elastic_out': ease_out_elastic,
|
||||
'elastic': ease_in_out_elastic,
|
||||
"linear": linear,
|
||||
"ease_in": ease_in_quad,
|
||||
"ease_out": ease_out_quad,
|
||||
"ease_in_out": ease_in_out_quad,
|
||||
"bounce_in": ease_in_bounce,
|
||||
"bounce_out": ease_out_bounce,
|
||||
"bounce": ease_in_out_bounce,
|
||||
"elastic_in": ease_in_elastic,
|
||||
"elastic_out": ease_out_elastic,
|
||||
"elastic": ease_in_out_elastic,
|
||||
}
|
||||
|
||||
|
||||
def get_easing(name: str = 'linear'):
|
||||
def get_easing(name: str = "linear"):
|
||||
"""Get easing function by name."""
|
||||
return EASING_FUNCTIONS.get(name, linear)
|
||||
|
||||
|
||||
def interpolate(start: float, end: float, t: float, easing: str = 'linear') -> float:
|
||||
def interpolate(start: float, end: float, t: float, easing: str = "linear") -> float:
|
||||
"""
|
||||
Interpolate between two values with easing.
|
||||
|
||||
@@ -160,8 +160,9 @@ def ease_back_in_out(t: float) -> float:
|
||||
return (pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2
|
||||
|
||||
|
||||
def apply_squash_stretch(base_scale: tuple[float, float], intensity: float,
|
||||
direction: str = 'vertical') -> tuple[float, float]:
|
||||
def apply_squash_stretch(
|
||||
base_scale: tuple[float, float], intensity: float, direction: str = "vertical"
|
||||
) -> tuple[float, float]:
|
||||
"""
|
||||
Calculate squash and stretch scales for more dynamic animation.
|
||||
|
||||
@@ -175,24 +176,25 @@ def apply_squash_stretch(base_scale: tuple[float, float], intensity: float,
|
||||
"""
|
||||
width_scale, height_scale = base_scale
|
||||
|
||||
if direction == 'vertical':
|
||||
if direction == "vertical":
|
||||
# Compress vertically, expand horizontally (preserve volume)
|
||||
height_scale *= (1 - intensity * 0.5)
|
||||
width_scale *= (1 + intensity * 0.5)
|
||||
elif direction == 'horizontal':
|
||||
height_scale *= 1 - intensity * 0.5
|
||||
width_scale *= 1 + intensity * 0.5
|
||||
elif direction == "horizontal":
|
||||
# Compress horizontally, expand vertically
|
||||
width_scale *= (1 - intensity * 0.5)
|
||||
height_scale *= (1 + intensity * 0.5)
|
||||
elif direction == 'both':
|
||||
width_scale *= 1 - intensity * 0.5
|
||||
height_scale *= 1 + intensity * 0.5
|
||||
elif direction == "both":
|
||||
# General squash (both dimensions)
|
||||
width_scale *= (1 - intensity * 0.3)
|
||||
height_scale *= (1 - intensity * 0.3)
|
||||
width_scale *= 1 - intensity * 0.3
|
||||
height_scale *= 1 - intensity * 0.3
|
||||
|
||||
return (width_scale, height_scale)
|
||||
|
||||
|
||||
def calculate_arc_motion(start: tuple[float, float], end: tuple[float, float],
|
||||
height: float, t: float) -> tuple[float, float]:
|
||||
def calculate_arc_motion(
|
||||
start: tuple[float, float], end: tuple[float, float], height: float, t: float
|
||||
) -> tuple[float, float]:
|
||||
"""
|
||||
Calculate position along a parabolic arc (natural motion path).
|
||||
|
||||
@@ -221,10 +223,12 @@ def calculate_arc_motion(start: tuple[float, float], end: tuple[float, float],
|
||||
|
||||
|
||||
# Add new easing functions to the convenience mapping
|
||||
EASING_FUNCTIONS.update({
|
||||
'back_in': ease_back_in,
|
||||
'back_out': ease_back_out,
|
||||
'back_in_out': ease_back_in_out,
|
||||
'anticipate': ease_back_in, # Alias
|
||||
'overshoot': ease_back_out, # Alias
|
||||
})
|
||||
EASING_FUNCTIONS.update(
|
||||
{
|
||||
"back_in": ease_back_in,
|
||||
"back_out": ease_back_out,
|
||||
"back_in_out": ease_back_in_out,
|
||||
"anticipate": ease_back_in, # Alias
|
||||
"overshoot": ease_back_out, # Alias
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,12 +6,15 @@ 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
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
def create_blank_frame(width: int, height: int, color: tuple[int, int, int] = (255, 255, 255)) -> Image.Image:
|
||||
|
||||
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.
|
||||
|
||||
@@ -23,13 +26,17 @@ def create_blank_frame(width: int, height: int, color: tuple[int, int, int] = (2
|
||||
Returns:
|
||||
PIL Image
|
||||
"""
|
||||
return Image.new('RGB', (width, height), color)
|
||||
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:
|
||||
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.
|
||||
|
||||
@@ -51,52 +58,13 @@ def draw_circle(frame: Image.Image, center: tuple[int, int], radius: int,
|
||||
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:
|
||||
def draw_text(
|
||||
frame: Image.Image,
|
||||
text: str,
|
||||
position: tuple[int, int],
|
||||
color: tuple[int, int, int] = (0, 0, 0),
|
||||
centered: bool = False,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Draw text on a frame.
|
||||
|
||||
@@ -104,7 +72,6 @@ def draw_text(frame: Image.Image, text: str, position: tuple[int, int],
|
||||
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
|
||||
|
||||
@@ -113,11 +80,9 @@ def draw_text(frame: Image.Image, text: str, position: tuple[int, int],
|
||||
"""
|
||||
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()
|
||||
# Uses Pillow's default font.
|
||||
# If the font should be changed for the emoji, add additional logic here.
|
||||
font = ImageFont.load_default()
|
||||
|
||||
if centered:
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
@@ -131,110 +96,12 @@ def draw_text(frame: Image.Image, text: str, position: tuple[int, int],
|
||||
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:
|
||||
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.
|
||||
|
||||
@@ -247,7 +114,7 @@ def create_gradient_background(width: int, height: int,
|
||||
Returns:
|
||||
PIL Image with gradient
|
||||
"""
|
||||
frame = Image.new('RGB', (width, height))
|
||||
frame = Image.new("RGB", (width, height))
|
||||
draw = ImageDraw.Draw(frame)
|
||||
|
||||
# Calculate color step for each row
|
||||
@@ -267,175 +134,14 @@ def create_gradient_background(width: int, height: int,
|
||||
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:
|
||||
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.
|
||||
|
||||
@@ -451,6 +157,7 @@ def draw_star(frame: Image.Image, center: tuple[int, int], size: int,
|
||||
Modified frame
|
||||
"""
|
||||
import math
|
||||
|
||||
draw = ImageDraw.Draw(frame)
|
||||
x, y = center
|
||||
|
||||
@@ -466,4 +173,4 @@ def draw_star(frame: Image.Image, center: tuple[int, int], size: int,
|
||||
# Draw star
|
||||
draw.polygon(points, fill=fill_color, outline=outline_color, width=outline_width)
|
||||
|
||||
return frame
|
||||
return frame
|
||||
|
||||
@@ -8,9 +8,10 @@ generated frames, with automatic optimization for Slack's requirements.
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import imageio.v3 as imageio
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class GIFBuilder:
|
||||
@@ -38,12 +39,14 @@ class GIFBuilder:
|
||||
frame: Frame as numpy array or PIL Image (will be converted to RGB)
|
||||
"""
|
||||
if isinstance(frame, Image.Image):
|
||||
frame = np.array(frame.convert('RGB'))
|
||||
frame = np.array(frame.convert("RGB"))
|
||||
|
||||
# Ensure frame is correct size
|
||||
if frame.shape[:2] != (self.height, self.width):
|
||||
pil_frame = Image.fromarray(frame)
|
||||
pil_frame = pil_frame.resize((self.width, self.height), Image.Resampling.LANCZOS)
|
||||
pil_frame = pil_frame.resize(
|
||||
(self.width, self.height), Image.Resampling.LANCZOS
|
||||
)
|
||||
frame = np.array(pil_frame)
|
||||
|
||||
self.frames.append(frame)
|
||||
@@ -53,7 +56,9 @@ class GIFBuilder:
|
||||
for frame in frames:
|
||||
self.add_frame(frame)
|
||||
|
||||
def optimize_colors(self, num_colors: int = 128, use_global_palette: bool = True) -> list[np.ndarray]:
|
||||
def optimize_colors(
|
||||
self, num_colors: int = 128, use_global_palette: bool = True
|
||||
) -> list[np.ndarray]:
|
||||
"""
|
||||
Reduce colors in all frames using quantization.
|
||||
|
||||
@@ -70,12 +75,16 @@ class GIFBuilder:
|
||||
# Create a global palette from all frames
|
||||
# Sample frames to build palette
|
||||
sample_size = min(5, len(self.frames))
|
||||
sample_indices = [int(i * len(self.frames) / sample_size) for i in range(sample_size)]
|
||||
sample_indices = [
|
||||
int(i * len(self.frames) / sample_size) for i in range(sample_size)
|
||||
]
|
||||
sample_frames = [self.frames[i] for i in sample_indices]
|
||||
|
||||
# Combine sample frames into a single image for palette generation
|
||||
# Flatten each frame to get all pixels, then stack them
|
||||
all_pixels = np.vstack([f.reshape(-1, 3) for f in sample_frames]) # (total_pixels, 3)
|
||||
all_pixels = np.vstack(
|
||||
[f.reshape(-1, 3) for f in sample_frames]
|
||||
) # (total_pixels, 3)
|
||||
|
||||
# Create a properly-shaped RGB image from the pixel data
|
||||
# We'll make a roughly square image from all the pixels
|
||||
@@ -90,8 +99,10 @@ class GIFBuilder:
|
||||
all_pixels = np.vstack([all_pixels, padding])
|
||||
|
||||
# Reshape to proper RGB image format (H, W, 3)
|
||||
img_array = all_pixels[:pixels_needed].reshape(height, width, 3).astype(np.uint8)
|
||||
combined_img = Image.fromarray(img_array, mode='RGB')
|
||||
img_array = (
|
||||
all_pixels[:pixels_needed].reshape(height, width, 3).astype(np.uint8)
|
||||
)
|
||||
combined_img = Image.fromarray(img_array, mode="RGB")
|
||||
|
||||
# Generate global palette
|
||||
global_palette = combined_img.quantize(colors=num_colors, method=2)
|
||||
@@ -100,22 +111,23 @@ class GIFBuilder:
|
||||
for frame in self.frames:
|
||||
pil_frame = Image.fromarray(frame)
|
||||
quantized = pil_frame.quantize(palette=global_palette, dither=1)
|
||||
optimized.append(np.array(quantized.convert('RGB')))
|
||||
optimized.append(np.array(quantized.convert("RGB")))
|
||||
else:
|
||||
# Use per-frame quantization
|
||||
for frame in self.frames:
|
||||
pil_frame = Image.fromarray(frame)
|
||||
quantized = pil_frame.quantize(colors=num_colors, method=2, dither=1)
|
||||
optimized.append(np.array(quantized.convert('RGB')))
|
||||
optimized.append(np.array(quantized.convert("RGB")))
|
||||
|
||||
return optimized
|
||||
|
||||
def deduplicate_frames(self, threshold: float = 0.995) -> int:
|
||||
def deduplicate_frames(self, threshold: float = 0.9995) -> int:
|
||||
"""
|
||||
Remove duplicate or near-duplicate consecutive frames.
|
||||
|
||||
Args:
|
||||
threshold: Similarity threshold (0.0-1.0). Higher = more strict (0.995 = very similar).
|
||||
threshold: Similarity threshold (0.0-1.0). Higher = more strict (0.9995 = nearly identical).
|
||||
Use 0.9995+ to preserve subtle animations, 0.98 for aggressive removal.
|
||||
|
||||
Returns:
|
||||
Number of frames removed
|
||||
@@ -136,7 +148,7 @@ class GIFBuilder:
|
||||
similarity = 1.0 - (np.mean(diff) / 255.0)
|
||||
|
||||
# Keep frame if sufficiently different
|
||||
# High threshold (0.995) means only remove truly identical frames
|
||||
# High threshold (0.9995+) means only remove nearly identical frames
|
||||
if similarity < threshold:
|
||||
deduplicated.append(self.frames[i])
|
||||
else:
|
||||
@@ -145,16 +157,21 @@ class GIFBuilder:
|
||||
self.frames = deduplicated
|
||||
return removed_count
|
||||
|
||||
def save(self, output_path: str | Path, num_colors: int = 128,
|
||||
optimize_for_emoji: bool = False, remove_duplicates: bool = True) -> dict:
|
||||
def save(
|
||||
self,
|
||||
output_path: str | Path,
|
||||
num_colors: int = 128,
|
||||
optimize_for_emoji: bool = False,
|
||||
remove_duplicates: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
Save frames as optimized GIF for Slack.
|
||||
|
||||
Args:
|
||||
output_path: Where to save the GIF
|
||||
num_colors: Number of colors to use (fewer = smaller file)
|
||||
optimize_for_emoji: If True, optimize for <64KB emoji size
|
||||
remove_duplicates: Remove duplicate consecutive frames
|
||||
optimize_for_emoji: If True, optimize for emoji size (128x128, fewer colors)
|
||||
remove_duplicates: If True, remove duplicate consecutive frames (opt-in)
|
||||
|
||||
Returns:
|
||||
Dictionary with file info (path, size, dimensions, frame_count)
|
||||
@@ -163,18 +180,21 @@ class GIFBuilder:
|
||||
raise ValueError("No frames to save. Add frames with add_frame() first.")
|
||||
|
||||
output_path = Path(output_path)
|
||||
original_frame_count = len(self.frames)
|
||||
|
||||
# Remove duplicate frames to reduce file size
|
||||
if remove_duplicates:
|
||||
removed = self.deduplicate_frames(threshold=0.98)
|
||||
removed = self.deduplicate_frames(threshold=0.9995)
|
||||
if removed > 0:
|
||||
print(f" Removed {removed} duplicate frames")
|
||||
print(
|
||||
f" Removed {removed} nearly identical frames (preserved subtle animations)"
|
||||
)
|
||||
|
||||
# Optimize for emoji if requested
|
||||
if optimize_for_emoji:
|
||||
if self.width > 128 or self.height > 128:
|
||||
print(f" Resizing from {self.width}x{self.height} to 128x128 for emoji")
|
||||
print(
|
||||
f" Resizing from {self.width}x{self.height} to 128x128 for emoji"
|
||||
)
|
||||
self.width = 128
|
||||
self.height = 128
|
||||
# Resize all frames
|
||||
@@ -188,10 +208,14 @@ class GIFBuilder:
|
||||
|
||||
# More aggressive FPS reduction for emoji
|
||||
if len(self.frames) > 12:
|
||||
print(f" Reducing frames from {len(self.frames)} to ~12 for emoji size")
|
||||
print(
|
||||
f" Reducing frames from {len(self.frames)} to ~12 for emoji size"
|
||||
)
|
||||
# Keep every nth frame to get close to 12 frames
|
||||
keep_every = max(1, len(self.frames) // 12)
|
||||
self.frames = [self.frames[i] for i in range(0, len(self.frames), keep_every)]
|
||||
self.frames = [
|
||||
self.frames[i] for i in range(0, len(self.frames), keep_every)
|
||||
]
|
||||
|
||||
# Optimize colors with global palette
|
||||
optimized_frames = self.optimize_colors(num_colors, use_global_palette=True)
|
||||
@@ -204,7 +228,7 @@ class GIFBuilder:
|
||||
output_path,
|
||||
optimized_frames,
|
||||
duration=frame_duration,
|
||||
loop=0 # Infinite loop
|
||||
loop=0, # Infinite loop
|
||||
)
|
||||
|
||||
# Get file info
|
||||
@@ -212,14 +236,14 @@ class GIFBuilder:
|
||||
file_size_mb = file_size_kb / 1024
|
||||
|
||||
info = {
|
||||
'path': str(output_path),
|
||||
'size_kb': file_size_kb,
|
||||
'size_mb': file_size_mb,
|
||||
'dimensions': f'{self.width}x{self.height}',
|
||||
'frame_count': len(optimized_frames),
|
||||
'fps': self.fps,
|
||||
'duration_seconds': len(optimized_frames) / self.fps,
|
||||
'colors': num_colors
|
||||
"path": str(output_path),
|
||||
"size_kb": file_size_kb,
|
||||
"size_mb": file_size_mb,
|
||||
"dimensions": f"{self.width}x{self.height}",
|
||||
"frame_count": len(optimized_frames),
|
||||
"fps": self.fps,
|
||||
"duration_seconds": len(optimized_frames) / self.fps,
|
||||
"colors": num_colors,
|
||||
}
|
||||
|
||||
# Print info
|
||||
@@ -231,16 +255,15 @@ class GIFBuilder:
|
||||
print(f" Duration: {info['duration_seconds']:.1f}s")
|
||||
print(f" Colors: {num_colors}")
|
||||
|
||||
# Warnings
|
||||
if optimize_for_emoji and file_size_kb > 64:
|
||||
print(f"\n⚠️ WARNING: Emoji file size ({file_size_kb:.1f} KB) exceeds 64 KB limit")
|
||||
print(" Try: fewer frames, fewer colors, or simpler design")
|
||||
elif not optimize_for_emoji and file_size_kb > 2048:
|
||||
print(f"\n⚠️ WARNING: File size ({file_size_kb:.1f} KB) is large for Slack")
|
||||
print(" Try: fewer frames, smaller dimensions, or fewer colors")
|
||||
# Size info
|
||||
if optimize_for_emoji:
|
||||
print(f" Optimized for emoji (128x128, reduced colors)")
|
||||
if file_size_mb > 1.0:
|
||||
print(f"\n Note: Large file size ({file_size_kb:.1f} KB)")
|
||||
print(" Consider: fewer frames, smaller dimensions, or fewer colors")
|
||||
|
||||
return info
|
||||
|
||||
def clear(self):
|
||||
"""Clear all frames (useful for creating multiple GIFs)."""
|
||||
self.frames = []
|
||||
self.frames = []
|
||||
|
||||
@@ -8,146 +8,36 @@ These validators help ensure your GIFs meet Slack's size and dimension constrain
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def check_slack_size(gif_path: str | Path, is_emoji: bool = True) -> tuple[bool, dict]:
|
||||
def validate_gif(
|
||||
gif_path: str | Path, is_emoji: bool = True, verbose: bool = True
|
||||
) -> tuple[bool, dict]:
|
||||
"""
|
||||
Check if GIF meets Slack size limits.
|
||||
Validate GIF for Slack (dimensions, size, frame count).
|
||||
|
||||
Args:
|
||||
gif_path: Path to GIF file
|
||||
is_emoji: True for emoji GIF (64KB limit), False for message GIF (2MB limit)
|
||||
is_emoji: True for emoji (128x128 recommended), False for message GIF
|
||||
verbose: Print validation details
|
||||
|
||||
Returns:
|
||||
Tuple of (passes: bool, info: dict with details)
|
||||
"""
|
||||
gif_path = Path(gif_path)
|
||||
|
||||
if not gif_path.exists():
|
||||
return False, {'error': f'File not found: {gif_path}'}
|
||||
|
||||
size_bytes = gif_path.stat().st_size
|
||||
size_kb = size_bytes / 1024
|
||||
size_mb = size_kb / 1024
|
||||
|
||||
limit_kb = 64 if is_emoji else 2048
|
||||
limit_mb = limit_kb / 1024
|
||||
|
||||
passes = size_kb <= limit_kb
|
||||
|
||||
info = {
|
||||
'size_bytes': size_bytes,
|
||||
'size_kb': size_kb,
|
||||
'size_mb': size_mb,
|
||||
'limit_kb': limit_kb,
|
||||
'limit_mb': limit_mb,
|
||||
'passes': passes,
|
||||
'type': 'emoji' if is_emoji else 'message'
|
||||
}
|
||||
|
||||
# Print feedback
|
||||
if passes:
|
||||
print(f"✓ {size_kb:.1f} KB - within {limit_kb} KB limit")
|
||||
else:
|
||||
print(f"✗ {size_kb:.1f} KB - exceeds {limit_kb} KB limit")
|
||||
overage_kb = size_kb - limit_kb
|
||||
overage_percent = (overage_kb / limit_kb) * 100
|
||||
print(f" Over by: {overage_kb:.1f} KB ({overage_percent:.1f}%)")
|
||||
print(f" Try: fewer frames, fewer colors, or simpler design")
|
||||
|
||||
return passes, info
|
||||
|
||||
|
||||
def validate_dimensions(width: int, height: int, is_emoji: bool = True) -> tuple[bool, dict]:
|
||||
"""
|
||||
Check if dimensions are suitable for Slack.
|
||||
|
||||
Args:
|
||||
width: Frame width in pixels
|
||||
height: Frame height in pixels
|
||||
is_emoji: True for emoji GIF, False for message GIF
|
||||
|
||||
Returns:
|
||||
Tuple of (passes: bool, info: dict with details)
|
||||
"""
|
||||
info = {
|
||||
'width': width,
|
||||
'height': height,
|
||||
'is_square': width == height,
|
||||
'type': 'emoji' if is_emoji else 'message'
|
||||
}
|
||||
|
||||
if is_emoji:
|
||||
# Emoji GIFs should be 128x128
|
||||
optimal = width == height == 128
|
||||
acceptable = width == height and 64 <= width <= 128
|
||||
|
||||
info['optimal'] = optimal
|
||||
info['acceptable'] = acceptable
|
||||
|
||||
if optimal:
|
||||
print(f"✓ {width}x{height} - optimal for emoji")
|
||||
passes = True
|
||||
elif acceptable:
|
||||
print(f"⚠ {width}x{height} - acceptable but 128x128 is optimal")
|
||||
passes = True
|
||||
else:
|
||||
print(f"✗ {width}x{height} - emoji should be square, 128x128 recommended")
|
||||
passes = False
|
||||
else:
|
||||
# Message GIFs should be square-ish and reasonable size
|
||||
aspect_ratio = max(width, height) / min(width, height) if min(width, height) > 0 else float('inf')
|
||||
reasonable_size = 320 <= min(width, height) <= 640
|
||||
|
||||
info['aspect_ratio'] = aspect_ratio
|
||||
info['reasonable_size'] = reasonable_size
|
||||
|
||||
# Check if roughly square (within 2:1 ratio)
|
||||
is_square_ish = aspect_ratio <= 2.0
|
||||
|
||||
if is_square_ish and reasonable_size:
|
||||
print(f"✓ {width}x{height} - good for message GIF")
|
||||
passes = True
|
||||
elif is_square_ish:
|
||||
print(f"⚠ {width}x{height} - square-ish but unusual size")
|
||||
passes = True
|
||||
elif reasonable_size:
|
||||
print(f"⚠ {width}x{height} - good size but not square-ish")
|
||||
passes = True
|
||||
else:
|
||||
print(f"✗ {width}x{height} - unusual dimensions for Slack")
|
||||
passes = False
|
||||
|
||||
return passes, info
|
||||
|
||||
|
||||
def validate_gif(gif_path: str | Path, is_emoji: bool = True) -> tuple[bool, dict]:
|
||||
"""
|
||||
Run all validations on a GIF file.
|
||||
|
||||
Args:
|
||||
gif_path: Path to GIF file
|
||||
is_emoji: True for emoji GIF, False for message GIF
|
||||
|
||||
Returns:
|
||||
Tuple of (all_pass: bool, results: dict)
|
||||
Tuple of (passes: bool, results: dict with all details)
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
gif_path = Path(gif_path)
|
||||
|
||||
if not gif_path.exists():
|
||||
return False, {'error': f'File not found: {gif_path}'}
|
||||
return False, {"error": f"File not found: {gif_path}"}
|
||||
|
||||
print(f"\nValidating {gif_path.name} as {'emoji' if is_emoji else 'message'} GIF:")
|
||||
print("=" * 60)
|
||||
# Get file size
|
||||
size_bytes = gif_path.stat().st_size
|
||||
size_kb = size_bytes / 1024
|
||||
size_mb = size_kb / 1024
|
||||
|
||||
# Check file size
|
||||
size_pass, size_info = check_slack_size(gif_path, is_emoji)
|
||||
|
||||
# Check dimensions
|
||||
# Get dimensions and frame info
|
||||
try:
|
||||
with Image.open(gif_path) as img:
|
||||
width, height = img.size
|
||||
dim_pass, dim_info = validate_dimensions(width, height, is_emoji)
|
||||
|
||||
# Count frames
|
||||
frame_count = 0
|
||||
@@ -158,107 +48,89 @@ def validate_gif(gif_path: str | Path, is_emoji: bool = True) -> tuple[bool, dic
|
||||
except EOFError:
|
||||
pass
|
||||
|
||||
# Get duration if available
|
||||
# Get duration
|
||||
try:
|
||||
duration_ms = img.info.get('duration', 100)
|
||||
duration_ms = img.info.get("duration", 100)
|
||||
total_duration = (duration_ms * frame_count) / 1000
|
||||
fps = frame_count / total_duration if total_duration > 0 else 0
|
||||
except:
|
||||
duration_ms = None
|
||||
total_duration = None
|
||||
fps = None
|
||||
|
||||
except Exception as e:
|
||||
return False, {'error': f'Failed to read GIF: {e}'}
|
||||
return False, {"error": f"Failed to read GIF: {e}"}
|
||||
|
||||
print(f"\nFrames: {frame_count}")
|
||||
if total_duration:
|
||||
print(f"Duration: {total_duration:.1f}s @ {fps:.1f} fps")
|
||||
|
||||
all_pass = size_pass and dim_pass
|
||||
# Validate dimensions
|
||||
if is_emoji:
|
||||
optimal = width == height == 128
|
||||
acceptable = width == height and 64 <= width <= 128
|
||||
dim_pass = acceptable
|
||||
else:
|
||||
aspect_ratio = (
|
||||
max(width, height) / min(width, height)
|
||||
if min(width, height) > 0
|
||||
else float("inf")
|
||||
)
|
||||
dim_pass = aspect_ratio <= 2.0 and 320 <= min(width, height) <= 640
|
||||
|
||||
results = {
|
||||
'file': str(gif_path),
|
||||
'passes': all_pass,
|
||||
'size': size_info,
|
||||
'dimensions': dim_info,
|
||||
'frame_count': frame_count,
|
||||
'duration_seconds': total_duration,
|
||||
'fps': fps
|
||||
"file": str(gif_path),
|
||||
"passes": dim_pass,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"size_kb": size_kb,
|
||||
"size_mb": size_mb,
|
||||
"frame_count": frame_count,
|
||||
"duration_seconds": total_duration,
|
||||
"fps": fps,
|
||||
"is_emoji": is_emoji,
|
||||
"optimal": optimal if is_emoji else None,
|
||||
}
|
||||
|
||||
print("=" * 60)
|
||||
if all_pass:
|
||||
print("✓ All validations passed!")
|
||||
else:
|
||||
print("✗ Some validations failed")
|
||||
print()
|
||||
# Print if verbose
|
||||
if verbose:
|
||||
print(f"\nValidating {gif_path.name}:")
|
||||
print(
|
||||
f" Dimensions: {width}x{height}"
|
||||
+ (
|
||||
f" ({'optimal' if optimal else 'acceptable'})"
|
||||
if is_emoji and acceptable
|
||||
else ""
|
||||
)
|
||||
)
|
||||
print(
|
||||
f" Size: {size_kb:.1f} KB"
|
||||
+ (f" ({size_mb:.2f} MB)" if size_mb >= 1.0 else "")
|
||||
)
|
||||
print(
|
||||
f" Frames: {frame_count}"
|
||||
+ (f" @ {fps:.1f} fps ({total_duration:.1f}s)" if fps else "")
|
||||
)
|
||||
|
||||
return all_pass, results
|
||||
if not dim_pass:
|
||||
print(
|
||||
f" Note: {'Emoji should be 128x128' if is_emoji else 'Unusual dimensions for Slack'}"
|
||||
)
|
||||
|
||||
if size_mb > 5.0:
|
||||
print(f" Note: Large file size - consider fewer frames/colors")
|
||||
|
||||
return dim_pass, results
|
||||
|
||||
|
||||
def get_optimization_suggestions(results: dict) -> list[str]:
|
||||
"""
|
||||
Get suggestions for optimizing a GIF based on validation results.
|
||||
|
||||
Args:
|
||||
results: Results dict from validate_gif()
|
||||
|
||||
Returns:
|
||||
List of suggestion strings
|
||||
"""
|
||||
suggestions = []
|
||||
|
||||
if not results.get('passes', False):
|
||||
size_info = results.get('size', {})
|
||||
dim_info = results.get('dimensions', {})
|
||||
|
||||
# Size suggestions
|
||||
if not size_info.get('passes', True):
|
||||
overage = size_info['size_kb'] - size_info['limit_kb']
|
||||
if size_info['type'] == 'emoji':
|
||||
suggestions.append(f"Reduce file size by {overage:.1f} KB:")
|
||||
suggestions.append(" - Limit to 10-12 frames")
|
||||
suggestions.append(" - Use 32-40 colors maximum")
|
||||
suggestions.append(" - Remove gradients (solid colors compress better)")
|
||||
suggestions.append(" - Simplify design")
|
||||
else:
|
||||
suggestions.append(f"Reduce file size by {overage:.1f} KB:")
|
||||
suggestions.append(" - Reduce frame count or FPS")
|
||||
suggestions.append(" - Use fewer colors (128 → 64)")
|
||||
suggestions.append(" - Reduce dimensions")
|
||||
|
||||
# Dimension suggestions
|
||||
if not dim_info.get('optimal', True) and dim_info.get('type') == 'emoji':
|
||||
suggestions.append("For optimal emoji GIF:")
|
||||
suggestions.append(" - Use 128x128 dimensions")
|
||||
suggestions.append(" - Ensure square aspect ratio")
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
# Convenience function for quick checks
|
||||
def is_slack_ready(gif_path: str | Path, is_emoji: bool = True, verbose: bool = True) -> bool:
|
||||
def is_slack_ready(
|
||||
gif_path: str | Path, is_emoji: bool = True, verbose: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Quick check if GIF is ready for Slack.
|
||||
|
||||
Args:
|
||||
gif_path: Path to GIF file
|
||||
is_emoji: True for emoji GIF, False for message GIF
|
||||
verbose: Print detailed feedback
|
||||
verbose: Print feedback
|
||||
|
||||
Returns:
|
||||
True if ready, False otherwise
|
||||
True if dimensions are acceptable
|
||||
"""
|
||||
if verbose:
|
||||
passes, results = validate_gif(gif_path, is_emoji)
|
||||
if not passes:
|
||||
suggestions = get_optimization_suggestions(results)
|
||||
if suggestions:
|
||||
print("\nSuggestions:")
|
||||
for suggestion in suggestions:
|
||||
print(suggestion)
|
||||
return passes
|
||||
else:
|
||||
size_pass, _ = check_slack_size(gif_path, is_emoji)
|
||||
return size_pass
|
||||
passes, _ = validate_gif(gif_path, is_emoji, verbose)
|
||||
return passes
|
||||
|
||||
Reference in New Issue
Block a user