Update example skills and rename 'artifacts-builder' (#112)

* Export updated examples

* Rename 'artifacts-builder' to 'web-artifacts-builder'
This commit is contained in:
Keith Lazuka
2025-11-17 16:34:29 -05:00
committed by GitHub
parent e5c60158df
commit 0f77e501e6
20 changed files with 1051 additions and 2287 deletions

View File

@@ -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
}
)

View File

@@ -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

View File

@@ -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 = []

View File

@@ -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