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

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