
Claude Gif
- 2 installs
- 22 repo stars
- Updated April 10, 2026
- agricidaniel/claude-gif
claude-gif is a Claude Code skill that orchestrates five sub-skills to create, generate, convert, optimize, and edit animated GIFs.
About
claude-gif is a Claude Code skill that orchestrates five GIF sub-skills to create, generate, convert, optimize, and edit animated GIFs. It detects intent from a description or file path and routes to the right pipeline, using Remotion, Veo, FFmpeg, and gifsicle under the hood. Developers use it as a single entry point for producing platform-sized GIFs for Discord, Slack, Twitter, or the web.
- Orchestrator routing GIF work across five sub-skills
- Create, generate, convert, optimize, and edit GIFs from one entry point
- Platform presets for Discord, Slack, Twitter, web, and HQ
Claude Gif by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,166 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
claude-gif capabilities & compatibility
- Capabilities
- gif create · gif generate · gif convert · gif optimize · gif edit
- Use cases
- image generation · video generation
What claude-gif says it does
Orchestrates five sub-skills to create, generate, convert, optimize, and edit GIFs.
Ultimate GIF creator: AI video-to-GIF (Veo 3.1), programmatic animations (Remotion),
npx skills add https://github.com/agricidaniel/claude-gif --skill claude-gifAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 22 |
| Last updated | April 10, 2026 |
| Repository | agricidaniel/claude-gif ↗ |
What it does
Route a GIF task to the right pipeline by auto-detecting intent from a description or file path.
Who is it for?
Turning an idea, video, image sequence, or SVG into an optimized, platform-sized GIF.
Skip if: Producing non-GIF video output or long-form video editing.
When should I use this skill?
You say gif, create gif, make gif, convert to gif, or optimize gif.
What you get
An optimized GIF sized for the target platform, produced through the correct sub-skill pipeline.
- optimized GIF
- platform-sized output
By the numbers
- orchestrates 5 sub-skills
- 5 quality presets (discord, slack, twitter, web, hq)
Files
claude-gif -- The Ultimate GIF Creator
Orchestrates five sub-skills to create, generate, convert, optimize, and edit GIFs. Every path from concept to final optimized GIF runs through this skill.
Quick Reference
| Command | Description | Sub-Skill |
|---|---|---|
/gif create <idea> | Programmatic animation via Remotion (text, spinners, memes, motion graphics) | claude-gif-create |
/gif generate <prompt> | AI video generation via Veo 3.1, then convert to GIF | claude-gif-generate |
/gif convert <path> | Convert video, image sequence, or animated SVG to GIF | claude-gif-convert |
/gif optimize <path> | Reduce GIF file size for a target platform | claude-gif-optimize |
/gif edit <path> | Modify an existing GIF (speed, reverse, crop, text, trim) | claude-gif-edit |
/gif setup | Install/verify all dependencies | (setup.sh) |
/gif <idea> | Auto-detect best approach from description | (orchestrator routes) |
Orchestration Logic
When the user invokes /gif, determine intent and route to the correct sub-skill.
Intent Detection
1. Has a file path to a video/image/SVG? --> /gif convert 2. Has a file path to an existing GIF?
- Mentions size, compress, platform fit -->
/gif optimize - Mentions speed, reverse, crop, text, trim -->
/gif edit
3. Describes a text animation, spinner, counter, meme, logo animation, progress bar? --> /gif create 4. Describes realistic/cinematic motion, product demo, abstract art, nature scene? --> /gif generate 5. Ambiguous description of something to animate?
- If it involves text, geometric shapes, UI elements -->
/gif create(Remotion) - If it involves photorealistic subjects, complex motion -->
/gif generate(Veo)
6. Mentions "optimize", "compress", "reduce", "fit", platform name? --> /gif optimize 7. Mentions "edit", "speed", "reverse", "crop", "text overlay"? --> /gif edit 8. Says "setup" or "install"? --> Run bash ~/.claude/skills/claude-gif/scripts/setup.sh
Routing Commands
/gif create "bouncing hello world text" --> claude-gif-create
/gif generate "campfire flickering at night" --> claude-gif-generate
/gif convert ~/Videos/clip.mp4 --> claude-gif-convert
/gif convert ~/Design/logo.svg --> claude-gif-convert (Mode D: SVG)
/gif optimize ~/Documents/gif_output/big.gif --> claude-gif-optimize
/gif edit ~/Documents/gif_output/clip.gif --> claude-gif-edit
/gif setup --> scripts/setup.shBare /gif <description> Routing
When no explicit sub-command is given, analyze the description:
- Keywords "text", "spinner", "loading", "counter", "meme", "typewriter", "logo reveal", "progress" --> create
- Keywords "realistic", "cinematic", "product", "nature", "abstract", "person", "animal" --> generate
- A file path to .mp4/.webm/.mov/.avi/.mkv/.svg --> convert
- A file path to .gif + size/platform keywords --> optimize
- A file path to .gif + edit keywords --> edit
Quality Presets
| Preset | Width | FPS | Colors | Dither | Max Size | Best For |
|---|---|---|---|---|---|---|
discord | 320px | 10 | 128 | bayer:3 | 256 KB | Discord inline embeds |
slack | 400px | 12 | 192 | floyd_steinberg | 500 KB | Slack messages |
twitter | 480px | 15 | 256 | floyd_steinberg | 15 MB | Twitter/X posts |
web | 480px | 15 | 256 | floyd_steinberg | 2 MB | General web usage |
hq | 640px | 20 | 256 | sierra2 | 10 MB | High-quality display |
Safety Rules
1. No-overwrite: Always pass -n to FFmpeg (skip if output exists). Only pass -y when user explicitly says overwrite. 2. Source protection: NEVER write output to the same path as input. Preflight catches this. 3. Cost confirmation: Veo generation costs money. Always confirm with user before calling generate.py. 4. Temp isolation: All intermediate files go in /tmp/claude-gif/. Clean up on completion. 5. Output directory: Final GIFs go to ~/Documents/gif_output/ unless user specifies a different path. 6. Preflight check: Run bash ~/.claude/skills/claude-gif/scripts/preflight.sh <input> <output> before any file write.
Multi-Step Pipelines
Complex requests may chain sub-skills:
generate --> convert --> optimize
(Veo) (to GIF) (for Discord)
create --> optimize
(Remotion) (shrink)
convert --> edit --> optimize
(video) (trim) (platform)
convert (SVG) --> optimize
(Playwright) (for web)Always plan the full pipeline before starting. Tell the user the steps.
Pre-Flight
Before ANY write operation:
bash ~/.claude/skills/claude-gif/scripts/preflight.sh <input_file> <output_file>On /gif setup or first use:
bash ~/.claude/skills/claude-gif/scripts/check_deps.shIf dependencies are missing:
bash ~/.claude/skills/claude-gif/scripts/setup.shReference Files
Load on demand -- only read the reference relevant to the current task:
| Reference | Path | When to Load |
|---|---|---|
| GIF Optimization | ~/.claude/skills/claude-gif/references/gif-optimization.md | Before optimize, or choosing palette/dither settings |
| Platform Specs | ~/.claude/skills/claude-gif/references/platform-specs.md | When targeting a specific platform |
| Remotion Patterns | ~/.claude/skills/claude-gif/references/remotion-gif.md | Before writing Remotion components |
| Prompt Engineering | ~/.claude/skills/claude-gif/references/prompt-engineering.md | Before constructing Veo prompts |
| Perfect Loops | ~/.claude/skills/claude-gif/references/perfect-loops.md | When making seamless loops |
Scripts
| Script | Path | Purpose |
|---|---|---|
setup.sh | scripts/setup.sh | Install and verify all dependencies |
check_deps.sh | scripts/check_deps.sh | Check dependency status (JSON, no installs) |
preflight.sh | scripts/preflight.sh | Safety check before writes (input/output validation) |
gif_convert.sh | scripts/gif_convert.sh | FFmpeg two-pass palette video-to-GIF conversion |
gif_optimize.py | scripts/gif_optimize.py | Multi-strategy GIF size optimizer (Python) |
gif_loop.py | scripts/gif_loop.py | Perfect loop creator: crossfade, ping-pong, freeze blend (Python) |
gif_frames.py | scripts/gif_frames.py | Assemble PNG/JPEG frames into optimized GIF (Python) |
All scripts are in ~/.claude/skills/claude-gif/scripts/. Python scripts use ~/.video-skill/bin/python3 (venv with Pillow, numpy).
Sub-Skills
| Sub-Skill | When to Use |
|---|---|
claude-gif-create | Programmatic animations: text, spinners, counters, memes, logos, progress bars, motion graphics. Uses Remotion (React + Node.js). |
claude-gif-generate | AI-generated motion: cinematic loops, product demos, abstract art, realistic subjects. Uses Veo 3.1 (costs money). |
claude-gif-convert | File conversion: video to GIF, image sequence to GIF, AI image sequence to GIF, animated SVG to transparent GIF. |
claude-gif-optimize | Size reduction: platform auto-fit, lossy compression, color/dimension/frame reduction, dither tuning. |
claude-gif-edit | Modify existing GIFs: speed change, reverse, ping-pong, crop, resize, text overlay, frame extraction, loop control, trim. |
Workflow Example
User: "Make me a Discord-sized GIF of a cozy campfire loop"
1. Route to claude-gif-generate (realistic motion = Veo) 2. Read references/prompt-engineering.md for loop-friendly prompt patterns 3. Confirm cost with user (~$0.60 for 4s Veo Fast) 4. Generate 4s campfire video via Veo 5. Convert to GIF: gif_convert.sh --input campfire.mp4 --preset discord 6. Perfect loop: gif_loop.py --input campfire.gif --method crossfade --frames 5 7. Verify size < 256KB. If over, route through claude-gif-optimize 8. Deliver to ~/Documents/gif_output/campfire_discord.gif
GIF Optimization Reference
Comprehensive technical reference for GIF optimization. Load this before any optimization task or when choosing palette/dither settings during conversion.
Palette Generation Strategies (FFmpeg palettegen)
FFmpeg's palettegen filter creates a 256-color palette from the source frames. The stats_mode parameter controls HOW colors are sampled:
| stats_mode | Method | Best For | File Size Impact |
|---|---|---|---|
full | Sample all pixels from all frames equally | General purpose, evenly animated content | Baseline |
diff | Weight pixels that change between frames | Animations with static backgrounds | Smaller (static areas get fewer palette slots) |
single | Generate a new palette per frame | Content with dramatic color changes | Larger (more complex encoding) |
When to Use Each
- `full` (default): Safe choice for most content. Every frame contributes equally to the palette.
- `diff`: Best for GIFs where a subject moves against a static background. The background gets fewer palette slots, so the moving subject gets higher color fidelity. Also produces smaller files because unchanged pixels compress better.
- `single`: Only use when colors vary drastically between frames (e.g., rainbow transitions, scene cuts). Produces larger files but prevents color banding in individual frames.
Recommendation: Start with diff for most GIFs. Fall back to full if you see color banding in static areas.
Dithering Algorithms (FFmpeg paletteuse)
Dithering distributes quantization error to simulate more colors than the palette contains.
| Algorithm | Quality | File Size | Speed | Visual Style |
|---|---|---|---|---|
floyd_steinberg | Highest | Largest | Fast | Natural, film-like grain |
sierra2 | High | Large | Fast | Slightly less grain than Floyd-Steinberg |
sierra2_4a | Medium-High | Medium | Fast | Simplified Sierra, good balance |
bayer:bayer_scale=N | Medium | Smallest | Fastest | Ordered/crosshatch pattern, retro look |
none | Lowest | Smallest | Fastest | Hard color boundaries, posterized |
bayer_scale Parameter
The bayer_scale parameter (0-5) controls the dithering matrix size for bayer dithering:
bayer_scale=0: 2x2 matrix (very visible pattern, smallest files)bayer_scale=1: 4x4 matrixbayer_scale=2: 8x8 matrixbayer_scale=3: 16x16 matrix (recommended balance)bayer_scale=4: 32x32 matrixbayer_scale=5: 64x64 matrix (subtlest pattern, largest files)
Choosing Dithering
| Content Type | Recommended Dither | Reason |
|---|---|---|
| Photography/video | floyd_steinberg | Natural error diffusion hides banding |
| Flat illustrations | bayer:3 or none | Fewer colors = less error to diffuse |
| Text animations | none or bayer:3 | Crisp edges, no grain on text |
| Pixel art / retro | bayer:2 | Matches aesthetic, intentional pattern |
| Size-critical (Discord) | bayer:3 | Smallest files with acceptable quality |
| Quality-critical | floyd_steinberg | Best perceived quality |
| Transparent GIFs | bayer:3 | Handles transparency edges cleanly |
Color Quantization
Global Palette vs Per-Frame Palette
- Global palette (default): One 256-color palette shared across all frames. Smaller files, but colors are a compromise across all frames.
- Per-frame palette (
stats_mode=single): Each frame gets its own palette. Better color accuracy per frame, but larger files and potential flickering at frame boundaries.
Color Count Impact
| Colors | Quality | Size Reduction vs 256 |
|---|---|---|
| 256 | Best | Baseline |
| 192 | Very Good | ~10-15% smaller |
| 128 | Good | ~20-30% smaller |
| 64 | Acceptable | ~40-50% smaller |
| 32 | Noticeable loss | ~55-65% smaller |
| 16 | Significant loss | ~65-75% smaller |
Color Reduction Strategy
1. Start with 256 colors 2. If file is too large, try 128 first (barely noticeable for most content) 3. Only go below 64 for extreme size constraints (Discord) 4. Pair fewer colors with bayer dithering (makes reduction less visible)
diff_mode=rectangle
The diff_mode parameter in paletteuse controls how frames are encoded:
- `diff_mode=none` (default): Each frame is encoded in full.
- `diff_mode=rectangle`: Only the rectangular region that changed between frames is encoded.
Always use `diff_mode=rectangle` for animations. It dramatically reduces file size for GIFs where only part of the frame changes (most GIFs). The savings compound:
- Subject moving on static background: 40-70% reduction
- Full-frame motion: 5-10% reduction (still worth enabling)
- Zero cost to quality
Gifsicle Optimization
Optimization Levels
| Level | Description | Speed | Savings |
|---|---|---|---|
-O1 | Basic optimization | Fast | 5-10% |
-O2 | Moderate optimization | Medium | 10-15% |
-O3 | Maximum optimization | Slow | 15-20% |
Always use -O3 -- the speed difference is negligible for typical GIFs.
Lossy Compression (--lossy=N)
Gifsicle's --lossy parameter introduces controlled quality loss for size reduction. The value is NOT a percentage -- it's an artifact tolerance scale.
| Value | Quality Impact | Typical Savings |
|---|---|---|
| 30 | Imperceptible | 10-20% |
| 60 | Barely visible | 20-30% |
| 80 | Minor artifacts | 25-40% |
| 120 | Noticeable | 35-50% |
| 150 | Visible artifacts | 45-60% |
| 200 | Significant artifacts | 55-70% |
Strategy: Start at 30, increase by 30 until target size is reached. Preview after each step.
Additional Gifsicle Flags
--no-comments # Strip comment metadata
--no-names # Strip frame name metadata
--no-extensions # Strip extension blocks
--color-method=diversity # Better color selection (default)
--color-method=blend-diversity # Blend similar colors (slightly better quality)Transparency Handling
GIF supports binary transparency (each pixel is either fully opaque or fully transparent). No semi-transparency / alpha blending.
FFmpeg Transparent GIF Settings
palettegen:
reserve_transparent=1 # Reserve one palette slot for transparency
max_colors=255 # 255 colors + 1 transparent = 256
paletteuse:
alpha_threshold=128 # Pixels with source alpha < 128 become transparentAlpha Threshold Tuning
alpha_threshold=0: Only fully transparent pixels (alpha=0) become transparentalpha_threshold=128: Default; pixels with <50% opacity become transparentalpha_threshold=255: Only fully opaque pixels remain; everything else transparent
For clean edges on transparent GIFs, use alpha_threshold=128 with dither=bayer:bayer_scale=3.
File Size Estimation
Rough formula for estimating GIF file size before creation:
estimated_bytes = width * height * frame_count * color_factor * dither_factor * motion_factor
color_factor:
256 colors = 0.5
128 colors = 0.4
64 colors = 0.3
dither_factor:
floyd_steinberg = 1.0
sierra2 = 0.95
bayer:3 = 0.7
none = 0.5
motion_factor (with diff_mode=rectangle):
Full-frame motion = 0.8
Partial motion = 0.3
Mostly static = 0.15This is very approximate. Actual compression depends heavily on content complexity.
Optimization Decision Flowchart
Is GIF too large?
|
+--> Apply gifsicle -O3 --lossy=30 (always first)
| |
| +--> Still too large?
| |
| +--> Is dither floyd_steinberg?
| | +--> Yes: Switch to bayer:3 + stats_mode=diff
| | +--> No: Reduce colors (256->128->64)
| |
| +--> Still too large?
| | +--> Reduce dimensions (640->480->320)
| |
| +--> Still too large?
| | +--> Increase lossy (60->80->120->150)
| |
| +--> Still too large?
| | +--> Reduce FPS (20->15->10->8)
| |
| +--> Still too large?
| +--> Content is too complex for target size
| +--> Suggest: trim duration, split into parts, or use video formatPerfect Loop Creation Techniques
Reference for creating seamless, perfectly looping GIFs. Load this when working with any GIF that needs to loop smoothly without visible seams.
Loop Quality Assessment
Before applying any technique, measure how close the GIF already is to a perfect loop.
Mean Absolute Error (MAE) Between First and Last Frame
Extract frames and compare:
# Extract first and last frames
ffmpeg -i INPUT.gif -vf "select=eq(n\,0)" -vframes 1 /tmp/claude-gif/first.png
LAST=$(($(ffprobe -v error -count_frames -select_streams v:0 -show_entries stream=nb_read_frames -of csv=p=0 INPUT.gif) - 1))
ffmpeg -i INPUT.gif -vf "select=eq(n\,$LAST)" -vframes 1 /tmp/claude-gif/last.png
# Compare with ImageMagick
compare -metric MAE /tmp/claude-gif/first.png /tmp/claude-gif/last.png /tmp/claude-gif/diff.png 2>&1MAE Score Interpretation
| MAE Score | Quality | Action |
|---|---|---|
| < 5 | Perfect loop | No processing needed |
| 5 - 15 | Good loop | Subtle seam visible; optional crossfade with 3 frames |
| 15 - 30 | Fair loop | Noticeable jump; crossfade with 5-8 frames recommended |
| 30 - 60 | Poor loop | Significant jump; crossfade with 8-12 frames or ping-pong |
| > 60 | Not loopable | Content doesn't cycle; use ping-pong or regenerate |
Technique 1: Crossfade Blending
The primary technique for creating seamless loops. Alpha-blends the last N frames into the first N frames using a smooth transition curve.
How It Works
Original frames: [1] [2] [3] [4] [5] [6] [7] [8] [9] [10]
With 3-frame crossfade (blend zone = frames 8,9,10 blended into 1,2,3):
Frame 8: 75% original_frame_8 + 25% original_frame_1
Frame 9: 50% original_frame_9 + 50% original_frame_2
Frame 10: 25% original_frame_10 + 75% original_frame_3
Result: [1] [2] [3] [4] [5] [6] [7] [8'] [9'] [10']
^ blended zone
When the GIF loops from frame 10' back to frame 1, the transition is seamless
because frame 10' is already 75% frame 3 (which follows frame 2 which follows frame 1).Blending Curve
Use a sine curve for smooth transitions (avoids linear-ramp artifacts):
alpha = 0.5 * (1 - math.cos(math.pi * i / num_blend_frames))
blended = frame_end * (1 - alpha) + frame_start * alphaThe sine curve produces a gradual acceleration and deceleration at the blend boundaries, making the transition invisible to the eye.
Script Usage
~/.video-skill/bin/python3 ~/.claude/skills/claude-gif/scripts/gif_loop.py \
--input INPUT.gif \
--method crossfade \
--frames 5 \
--output OUTPUT.gifFrame Count Selection
| GIF Duration | FPS | Recommended Blend Frames | Blend Duration |
|---|---|---|---|
| 2 seconds | 10 | 3 | 0.3s |
| 2 seconds | 15 | 4 | 0.27s |
| 3 seconds | 15 | 5 | 0.33s |
| 4 seconds | 15 | 6 | 0.40s |
| 5 seconds | 15 | 8 | 0.53s |
| 4 seconds | 20 | 8 | 0.40s |
Rule of thumb: Blend 10-15% of total frames. More blend frames = smoother transition but shorter effective content.
Crossfade Limitations
- Reduces effective content length (blend zone replaces content at the end)
- Can create ghosting with fast-moving subjects
- Doesn't work well when first and last frames are very different (MAE > 60)
- Subjects may appear transparent during blend zone
Technique 2: Ping-Pong (Boomerang)
Plays the GIF forward then backward. Guaranteed to loop perfectly because the reverse naturally returns to the first frame.
How It Works
Original: [1] [2] [3] [4] [5]
Ping-pong: [1] [2] [3] [4] [5] [4] [3] [2]
forward --> <-- reverse (skip first and last to avoid double-frame)Script Usage
~/.video-skill/bin/python3 ~/.claude/skills/claude-gif/scripts/gif_loop.py \
--input INPUT.gif \
--method pingpong \
--output OUTPUT.gifManual Implementation
FRAMES=$(gifsicle --info INPUT.gif | grep -oP '\d+ images' | grep -oP '\d+')
FORWARD=$(seq 0 $((FRAMES-1)) | sed 's/^/#/')
REVERSE=$(seq $((FRAMES-2)) -1 1 | sed 's/^/#/')
gifsicle INPUT.gif $FORWARD $REVERSE -O3 --loop -o OUTPUT.gifWhen to Use Ping-Pong
- Best for: Pendulum motion, oscillating movement, gestures, nods, waves
- Good for: Any content where forward-reverse looks natural
- Avoid for: Rotation (looks unnatural going backward), water flow, fire
- Caveat: Doubles the number of frames (doubles file size). May need optimization after.
Ping-Pong Variants
Symmetrical ping-pong (default): Forward then reverse, skipping duplicate frames at endpoints.
Asymmetrical ping-pong: Forward at normal speed, reverse at faster speed (or vice versa). Useful for "snap back" effects:
# Forward at normal speed, reverse at 2x speed
# Extract frames, then reassemble with different delaysTechnique 3: Freeze-Frame Blend
Averages the first and last frames to create a "meeting point" frame, then blends both the start and end of the GIF toward this average.
How It Works
1. Compute average_frame = (first_frame + last_frame) / 2
2. Prepend 2-3 frames that blend from average_frame to first_frame
3. Append 2-3 frames that blend from last_frame to average_frameWhen to Use
- Content with very slow motion (slow camera drift, subtle particle movement)
- When crossfade causes too much ghosting
- When the first and last frames are similar but not identical
Limitations
- Adds frames (increases file size)
- The "average frame" may look blurry if first and last frames are very different
- Only effective when MAE < 30
Naturally Looping Content Types
Content that inherently loops well without post-processing:
Perfect Natural Loops (MAE typically < 10)
- Fire / flames (chaotic motion, any frame connects to any other)
- Flowing water (continuous motion, no start/end)
- Rain / snow falling (particles enter and exit frame)
- Smoke / steam (continuous, chaotic)
- Sparks / particles (continuous emission)
- Rotating objects (360-degree rotation returns to start)
- Spinning/orbiting camera (completes full circle)
Good Natural Loops (MAE typically 10-30)
- Breathing (chest expands and contracts)
- Pendulum / swinging (oscillation)
- Heartbeat (rhythmic pulse)
- Blinking lights / neon signs
- Waves (periodic motion)
Difficult to Loop (MAE typically > 30)
- Walking / running (needs precise timing to match foot positions)
- Talking / speaking (mouth shapes rarely match)
- One-time actions (jumping, throwing, catching)
- Camera movement that doesn't complete a circle
Cinemagraph Technique
A special loop technique: a static photograph with one masked element that loops.
Concept
Static layer: [frozen frame from the middle of the clip]
Motion mask: [defines which area moves]
Motion layer: [only the masked area animates from the original video]Implementation via FFmpeg
# 1. Extract a "still" frame (choose the best-looking one)
ffmpeg -i INPUT.mp4 -vf "select=eq(n\,30)" -vframes 1 /tmp/claude-gif/still.png
# 2. Create motion mask (white = motion area, black = static)
# This must be done manually or with edge detection:
ffmpeg -i INPUT.mp4 -vf "
tblend=all_mode=grainextract,
lumakey=threshold=0.05:tolerance=0.05,
format=gray
" -frames:v 1 /tmp/claude-gif/mask.png
# Note: The mask usually needs manual refinement in GIMP
# 3. Composite: still background + masked motion area
ffmpeg -i INPUT.mp4 -i /tmp/claude-gif/still.png -i /tmp/claude-gif/mask.png \
-filter_complex "
[1:v]loop=loop=-1:size=1[bg];
[0:v][bg][2:v]maskedmerge[out]
" -map "[out]" /tmp/claude-gif/cinemagraph.mp4
# 4. Convert to GIF with diff_mode for tiny file size
bash gif_convert.sh --input /tmp/claude-gif/cinemagraph.mp4 --preset web \
--stats-mode diff --output OUTPUT.gifBest Cinemagraph Subjects
- Steam rising from coffee (cup static, steam loops)
- Hair blowing in wind (person static, hair moves)
- Waterfall in landscape (landscape static, water falls)
- Flickering candle in still room (room static, flame dances)
- Flag waving on building (building static, flag moves)
Why Cinemagraphs Make Great GIFs
diff_mode=rectangleencodes only the tiny moving area- File sizes are extremely small (often < 200KB even at high quality)
- Mesmerizing visual effect that draws attention
- Naturally loops because the motion area is typically chaotic/cyclical
Loop Quality Verification
After applying any loop technique, verify the result:
Visual Check
1. Open the GIF in a viewer that shows loop playback 2. Watch at least 5 complete loops 3. Focus on the loop seam -- is there a visible jump, flash, or stutter? 4. Check for ghosting or transparency artifacts in the blend zone
Automated Check
# Extract first and last frames of the processed GIF
ffmpeg -i OUTPUT.gif -vf "select=eq(n\,0)" -vframes 1 /tmp/claude-gif/loop_first.png
LAST=$(($(ffprobe -v error -count_frames -select_streams v:0 -show_entries stream=nb_read_frames -of csv=p=0 OUTPUT.gif) - 1))
ffmpeg -i OUTPUT.gif -vf "select=eq(n\,$LAST)" -vframes 1 /tmp/claude-gif/loop_last.png
# Compute MAE
compare -metric MAE /tmp/claude-gif/loop_first.png /tmp/claude-gif/loop_last.png null: 2>&1
# Target: MAE < 5 for perfect loopDecision Flowchart
Is the content naturally looping? (fire, water, rotation)
|
+--> Yes --> Convert directly, verify MAE < 15
| |
| +--> MAE < 5: Perfect, done
| +--> MAE 5-15: Optional light crossfade (3 frames)
| +--> MAE > 15: Apply crossfade (5-8 frames)
|
+--> No --> Is it oscillating/pendulum motion?
|
+--> Yes --> Ping-pong loop
|
+--> No --> Is first/last frame similar? (MAE < 30)
|
+--> Yes --> Crossfade (5-8 frames)
|
+--> No --> Can a static area be isolated?
|
+--> Yes --> Cinemagraph technique
|
+--> No --> Ping-pong, or regenerate with loop-friendly promptPlatform GIF Specifications
Definitive reference for GIF size limits, dimension requirements, and behavior across all major platforms. Updated April 2026.
Platform Specifications Table
| Platform | Max File Size | Max Dimensions | Auto-Play | Loops | Format Notes |
|---|---|---|---|---|---|
| Discord (inline) | 256 KB | 400x400 recommended | Yes (if <256KB) | Infinite | >256KB shows as attachment, requires click |
| Discord (Nitro) | 50 MB | No strict limit | Yes (if <10MB) | Infinite | Nitro users get higher upload limit |
| Discord (emoji) | 256 KB | 128x128 (resized) | Yes | Infinite | Animated emoji; uploaded at any size, displayed 128x128 |
| Slack | 500 KB | No strict limit | Yes (if <500KB) | Infinite | >500KB shows as file attachment |
| Twitter/X | 15 MB | 1280x1080 max | Yes | Infinite | Converted to MP4 on upload; quality may change |
| 20 MB | No strict limit | Yes | Infinite | Displayed inline in posts and comments | |
| GitHub (README) | 10 MB | No strict limit | Yes | Infinite | Displayed inline in markdown; affects page load |
| GitHub (Issues) | 10 MB | No strict limit | Yes | Infinite | Same as README |
| GitHub (PR comments) | 10 MB | No strict limit | Yes | Infinite | Same as README |
| iMessage | 100 MB | No strict limit | Yes | Infinite | Large GIFs may be slow to send/receive |
| Email (general) | 1 MB | 600px wide max | Varies | Varies | Outlook: first frame only; Gmail: auto-plays |
| Web (general) | 2 MB target | 480-640px wide | Yes | Infinite | Performance best practice; larger kills page speed |
| Tumblr | 10 MB | 540px wide (resized) | Yes | Infinite | Dashboard resizes to 540px wide |
| 8 MB | No strict limit | Auto (converted) | Infinite | Converted to video on upload | |
| N/A | N/A | N/A | N/A | No GIF support; use Giphy stickers in stories | |
| 5 MB | No strict limit | Yes | Infinite | Supported in posts and articles | |
| Giphy | 100 MB (upload) | No strict limit | Yes | Infinite | Optimized on their servers after upload |
| Tenor | 100 MB (upload) | No strict limit | Yes | Infinite | Optimized on their servers after upload |
| 16 MB | No strict limit | Yes | Infinite | Sent as "sticker" if <500KB and square | |
| Telegram | 50 MB | No strict limit | Yes | Infinite | Can also be sent as animated sticker (TGS) |
| 20 MB | Minimum 100x200 | Yes | Infinite | Animated pins supported | |
| Notion | 5 MB (embed) | No strict limit | Yes | Infinite | Larger files may slow page |
Recommended Settings per Platform
Discord (256 KB target)
bash gif_convert.sh --input VIDEO --preset discord --output output.gif
# Settings: 320px, 10fps, 128 colors, bayer:3, stats_mode=diffTips:
- Keep duration under 3 seconds
- Use simple content with few colors
- Static backgrounds help enormously
- If still over 256KB: reduce to 240px width, 8fps
Slack (500 KB target)
bash gif_convert.sh --input VIDEO --preset slack --output output.gif
# Settings: 400px, 12fps, 192 colors, floyd_steinbergTips:
- 3-5 seconds is comfortable
- More color headroom than Discord
- Floyd-Steinberg dithering is fine at this budget
Twitter/X (15 MB target)
bash gif_convert.sh --input VIDEO --preset twitter --output output.gif
# Settings: 480px, 15fps, 256 colors, floyd_steinbergTips:
- Twitter converts GIFs to MP4 on upload -- quality changes
- Test by uploading; Twitter's converter is unpredictable
- Keep under 5 MB for faster loading in timelines
- Maximum 1280x1080 is enforced; larger gets resized
Web General (2 MB target)
bash gif_convert.sh --input VIDEO --preset web --output output.gif
# Settings: 480px, 15fps, 256 colors, floyd_steinbergTips:
- Consider WebP or AVIF animation instead (better compression)
- Lazy-load GIFs below the fold
- Provide poster image (first frame) for progressive display
High Quality (10 MB budget)
bash gif_convert.sh --input VIDEO --preset hq --output output.gif
# Settings: 640px, 20fps, 256 colors, sierra2, stats_mode=diffTips:
- For situations where quality matters and size is secondary
- Good for presentations, documentation, demos
- 640px is the maximum recommended width for GIFs
Email (1 MB target)
bash gif_convert.sh --input VIDEO --width 600 --fps 8 --colors 64 --dither "bayer:bayer_scale=3" --output output.gifTips:
- Outlook (desktop): Shows only first frame. Design the first frame to be meaningful.
- Gmail: Auto-plays. Works well.
- Apple Mail: Auto-plays. Works well.
- Keep width at 600px max (email rendering constraint)
- Low FPS (6-8) is fine for email -- recipients aren't expecting smooth animation
- Test in multiple email clients before sending
GitHub README (10 MB target, 2 MB recommended)
bash gif_convert.sh --input VIDEO --preset web --output output.gifTips:
- Even though 10MB is allowed, keep under 2MB for page load performance
- Repository visitors may be on slow connections
- Consider using a thumbnail image that links to the full GIF
- For demo GIFs, 480px wide at 12-15fps is ideal
Platform Behavior Notes
Auto-Play Behavior
- Always auto-plays: Discord, Slack, Twitter, Reddit, GitHub, Tumblr
- Auto-plays with settings: Facebook (users can disable), LinkedIn
- Never auto-plays: Outlook desktop (shows first frame)
- Depends on connection: Some mobile apps pause auto-play on cellular data
Conversion on Upload
- Twitter/X: Converts GIFs to MP4 on upload. Original GIF is not preserved.
- Facebook: Converts to video format. Significant quality change possible.
- All others: Store and serve the original GIF file.
Dimension Resizing
- Tumblr: Resizes to 540px wide on dashboard
- Discord emoji: Resizes to 128x128
- Twitter: Resizes if >1280x1080
Quick Decision Matrix
| Need | Platform | Target Size | Recommended Preset |
|---|---|---|---|
| Reaction GIF in chat | Discord | 256 KB | discord |
| Team notification | Slack | 500 KB | slack |
| Social media post | Twitter/X | 15 MB (aim for 5 MB) | |
| Documentation demo | GitHub | 2 MB | web |
| Blog post illustration | Website | 2 MB | web |
| Email campaign | 1 MB | custom (600px, 8fps, 64 colors) | |
| High-quality showcase | Portfolio | 10 MB | hq |
| Quick share anywhere | General | 2 MB | web |
Veo Prompt Engineering for GIF-Optimized Video
MANDATORY reading before constructing any Veo prompt for GIF output. GIF-optimized prompts differ from general video prompts in critical ways.
Core Principles
1. Loop-Friendly Motion
GIFs loop infinitely. The video should either:
- Naturally cycle (motion returns to its starting position)
- Be crossfade-compatible (first and last frames can blend smoothly)
- Contain continuous motion (no start/end, just ongoing movement)
2. Static Background Strategy
GIF encoding only stores pixels that change between frames (via diff_mode=rectangle). A static background means only the moving subject is encoded, dramatically reducing file size.
Size impact: A subject moving on a static background can be 50-70% smaller than the same subject with a moving camera or changing background.
3. Simple Scenes
Complex scenes with many moving elements, colors, and textures produce larger GIFs. Simpler scenes = fewer colors = better palette utilization = smaller files.
Loop-Friendly Prompt Patterns
Circular Camera Motion
The camera orbits around the subject, returning to the starting position.
"Camera slowly orbits 360 degrees around a [subject] on a [surface],
studio lighting, smooth continuous rotation, the view returns to the starting angle"Cyclical Natural Motion
Elements that inherently repeat: fire, water, breathing, pendulums.
"[Natural element] in continuous motion, [lighting], static camera angle,
the movement naturally and seamlessly repeats"Good cyclical subjects:
- Fire: "flickering flame", "crackling campfire", "candle flame dancing"
- Water: "ocean waves rolling", "waterfall cascading", "rain falling"
- Air: "smoke swirling", "clouds drifting", "leaves rustling in wind"
- Mechanical: "gears turning", "clock pendulum swinging", "windmill rotating"
- Organic: "chest breathing", "heart beating", "wings flapping"
Rotating Objects (Turntable)
"A [object] slowly rotating 360 degrees on a [color] surface,
[lighting], product photography style, one complete rotation"Oscillating Motion (Pendulum)
"A [subject] gently [oscillating verb] back and forth,
[setting], smooth continuous motion, hypnotic rhythm"Oscillating verbs: swinging, rocking, bobbing, swaying, pulsing, breathing
Particle Systems
"[Particles] continuously [motion verb] in [direction],
against a [solid color] background, mesmerizing endless flow"Particles: sparks, snowflakes, bubbles, dust motes, stars, fireflies, confetti
What to AVOID in GIF Prompts
These create videos that loop poorly and/or produce large GIFs:
| Avoid | Why | Alternative |
|---|---|---|
| "walks across the room" | Linear motion, doesn't return | "walks in place" or "stands swaying" |
| "picks up the cup" | One-time action | "holds the cup, steam rising" |
| "enters through the door" | Narrative progression | "stands in the doorway, light behind" |
| Camera pans (left, right, up) | Camera doesn't return to start | Static camera or 360-degree orbit |
| Scene transitions | Cut = impossible to loop | Single continuous shot |
| "begins to..." | Implies start, not cycle | "continuously...", "in a loop" |
| Multiple subjects interacting | Complex, many colors, large file | Single isolated subject |
| Complex backgrounds | Many colors, all changing | Solid color or very simple backdrop |
| "zoom in" / "zoom out" | Doesn't return | Static framing or "breathing zoom" |
Static Background Templates
Isolated Subject on Solid Color
"A [subject] [action] against a solid [color] background,
[lighting], isolated, clean minimalist backdrop, studio photography"Colors that compress well: black, dark navy, white, solid mid-tones.
Cinemagraph (Partial Motion)
"A [scene] where only the [specific element] moves gently,
everything else perfectly still, cinematic quality, subtle continuous motion,
static camera, the [element] moves in a seamless loop"Silhouette on Gradient
"Silhouette of a [subject] [action] against a [color] gradient sky,
dramatic backlighting, simple composition, continuous motion"Duration Guidance
| Veo Duration | GIF Duration | Loop Friendliness | Cost |
|---|---|---|---|
| 4 seconds | 4 seconds | Best (short = easier to loop) | ~$0.60 |
| 8 seconds | 8 seconds or trimmed | Good (more content, trim to best segment) | ~$1.20 |
Recommendation: Always generate 4 seconds. It's cheaper and shorter clips loop better. If the user needs variety, generate multiple 4-second clips rather than one 8-second clip.
Aspect Ratio for GIF
| Aspect Ratio | Veo Flag | Best For |
|---|---|---|
| 16:9 | --aspect-ratio 16:9 | Landscape GIFs, web banners, GitHub README |
| 1:1 | --aspect-ratio 1:1 | Social media, Discord, chat, icons |
| 9:16 | --aspect-ratio 9:16 | Mobile, vertical stickers |
Default recommendation: 16:9 for general use, 1:1 for social/chat platforms.
Example Prompts by GIF Type
Cozy Campfire Loop
"A small campfire crackling and flickering on a dark night, close-up shot,
warm orange flames dancing continuously against a solid black background,
sparks rising gently, static camera, the flames move in a natural seamless cycle,
cinematic quality, shallow depth of field"Ocean Waves Loop
"Gentle ocean waves rolling onto a sandy beach and receding, overhead drone shot,
turquoise water, white foam, the waves repeat in a continuous natural cycle,
bright daylight, static camera position, calm rhythm"Product Turntable
"A sleek wireless headphone rotating slowly 360 degrees on a clean white surface,
soft studio lighting, product photography, shadow beneath, one complete smooth rotation,
minimalist background, high-end commercial quality"Abstract Art Loop
"Abstract liquid chrome flowing and morphing in continuous motion,
iridescent purple and blue metallic surface, solid black background,
mesmerizing seamless loop, the forms cycle back to their starting shape,
macro photography style"Neon Sign Flicker
"A neon sign reading 'OPEN' flickering with a warm glow against a dark brick wall,
the sign buzzes and pulses continuously, pink and blue neon light,
static camera, nighttime atmosphere, the flicker repeats naturally"Coffee Steam (Cinemagraph)
"A ceramic coffee mug on a wooden table, perfectly still,
only the steam rising from the cup moves gently upward in a continuous wisp,
warm morning light, shallow depth of field, everything static except the steam,
cozy cafe atmosphere"Geometric Pattern
"A kaleidoscopic geometric pattern rotating and morphing continuously,
sacred geometry, vibrant jewel tones on black background,
the pattern completes one full transformation cycle and returns to start,
perfectly symmetrical, mathematical precision"Rain on Window
"Close-up of raindrops streaming down a window pane continuously,
blurred city lights in the background, the drops flow endlessly,
moody blue-gray tones, static camera, ASMR aesthetic, nighttime"Post-Generation Loop Assessment
After generating the video, assess loop quality before converting: 1. Watch the first and last second -- do they look similar? 2. If yes: direct conversion will loop well 3. If close but not perfect: apply crossfade (5-8 frames) 4. If very different: apply ping-pong loop (plays forward then reverse) 5. If unsuitable for looping: consider regenerating with a more loop-friendly prompt
Prompt Enhancement Checklist
Before sending any prompt to Veo for GIF output, verify:
- [ ] Contains loop-friendly motion description ("continuous", "repeating", "cycling")
- [ ] Specifies static camera OR 360-degree camera orbit
- [ ] Describes simple/solid background (or cinemagraph with static scene)
- [ ] No narrative progression (no "begins", "starts", "walks to")
- [ ] No scene cuts or transitions
- [ ] Includes lighting description (affects color palette)
- [ ] Mentions quality ("cinematic", "4K", "professional")
- [ ] Single subject or simple composition (fewer moving elements = smaller GIF)
Remotion GIF Patterns Reference
Code patterns and templates for creating GIF-optimized Remotion compositions. Load this before writing any React component for GIF output.
GIF-Optimized Composition Settings
Dimensions
| Target | Width | Height | Aspect Ratio | Use Case |
|---|---|---|---|---|
| Square | 480 | 480 | 1:1 | Social media, chat, icons |
| Landscape | 480 | 270 | 16:9 | Web, GitHub README, email |
| Wide | 640 | 360 | 16:9 | High-quality display |
| Portrait | 270 | 480 | 9:16 | Mobile, stories |
| Banner | 480 | 160 | 3:1 | Email headers, banners |
Frame Rate
| FPS | durationInFrames (for 3s) | Best For | File Size |
|---|---|---|---|
| 10 | 30 | Discord, tiny GIFs | Smallest |
| 12 | 36 | Slack, text animations | Small |
| 15 | 45 | General purpose (recommended) | Medium |
| 20 | 60 | Smooth motion, HQ | Large |
Duration Formula
durationInFrames = fps * seconds- 2 seconds at 15fps = 30 frames
- 3 seconds at 15fps = 45 frames (sweet spot)
- 5 seconds at 15fps = 75 frames (maximum recommended)
Key Remotion APIs for GIF
import {
useCurrentFrame, // Current frame number (0-indexed)
useVideoConfig, // { fps, width, height, durationInFrames }
spring, // Physics-based easing
interpolate, // Linear interpolation between values
Sequence, // Time-offset child rendering
Img, // Image component (static assets)
AbsoluteFill, // Full-frame container
} from "remotion";useCurrentFrame()
Returns the current frame number (0 to durationInFrames - 1).
const frame = useCurrentFrame(); // 0, 1, 2, ... 44spring()
Physics-based animation with bounce, damping, and stiffness.
const scale = spring({
frame,
fps,
config: {
damping: 10, // Lower = more bouncy (default: 10)
stiffness: 100, // Higher = faster (default: 100)
mass: 1, // Higher = heavier/slower (default: 1)
},
});
// Returns 0 to ~1, with overshoot if damping is lowinterpolate()
Maps a value from one range to another.
const opacity = interpolate(frame, [0, 30], [0, 1], {
extrapolateRight: "clamp", // Don't go above 1
});
const x = interpolate(frame, [0, 45], [-200, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});Sequence
Delays child rendering to start at a specific frame.
<Sequence from={15} durationInFrames={30}>
<SecondElement />
</Sequence>Template Code Snippets
1. Text Bounce
import { useCurrentFrame, useVideoConfig, spring, AbsoluteFill } from "remotion";
export const TextBounce: React.FC<{ text: string }> = ({ text }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const scale = spring({
frame,
fps,
config: { damping: 8, stiffness: 200 },
});
const translateY = spring({
frame,
fps,
config: { damping: 12, stiffness: 150 },
});
return (
<AbsoluteFill
style={{
backgroundColor: "#1a1a2e",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
fontSize: 64,
fontWeight: "bold",
color: "#e94560",
fontFamily: "Arial, sans-serif",
transform: `scale(${scale}) translateY(${(1 - translateY) * -50}px)`,
}}
>
{text}
</div>
</AbsoluteFill>
);
};2. Loading Spinner
import { useCurrentFrame, useVideoConfig, interpolate, AbsoluteFill } from "remotion";
export const Spinner: React.FC = () => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const rotation = interpolate(frame, [0, durationInFrames], [0, 360]);
const dots = Array.from({ length: 8 }, (_, i) => {
const angle = (i * 360) / 8;
const delay = i * 3;
const opacity = interpolate(
(frame + delay) % durationInFrames,
[0, durationInFrames / 2, durationInFrames],
[0.2, 1, 0.2]
);
return { angle, opacity };
});
return (
<AbsoluteFill
style={{ backgroundColor: "#0f0f23", justifyContent: "center", alignItems: "center" }}
>
<div style={{ position: "relative", width: 120, height: 120 }}>
{dots.map((dot, i) => (
<div
key={i}
style={{
position: "absolute",
width: 16,
height: 16,
borderRadius: "50%",
backgroundColor: "#00d4ff",
opacity: dot.opacity,
top: 52 + Math.sin((dot.angle * Math.PI) / 180) * 50,
left: 52 + Math.cos((dot.angle * Math.PI) / 180) * 50,
}}
/>
))}
</div>
</AbsoluteFill>
);
};3. Counter / Number Roll
import { useCurrentFrame, useVideoConfig, interpolate, AbsoluteFill } from "remotion";
export const Counter: React.FC<{ target: number; label: string }> = ({ target, label }) => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
// Ease-out curve for counting
const progress = interpolate(frame, [0, durationInFrames * 0.8], [0, 1], {
extrapolateRight: "clamp",
});
const eased = 1 - Math.pow(1 - progress, 3); // Cubic ease-out
const count = Math.round(eased * target);
return (
<AbsoluteFill
style={{ backgroundColor: "#16213e", justifyContent: "center", alignItems: "center" }}
>
<div style={{ textAlign: "center" }}>
<div
style={{
fontSize: 96,
fontWeight: "bold",
color: "#e94560",
fontFamily: "monospace",
}}
>
{count.toLocaleString()}
</div>
<div style={{ fontSize: 24, color: "#a8a8b3", marginTop: 10 }}>{label}</div>
</div>
</AbsoluteFill>
);
};4. Logo Reveal
import { useCurrentFrame, useVideoConfig, spring, interpolate, Sequence, AbsoluteFill } from "remotion";
export const LogoReveal: React.FC<{ iconText: string; brandName: string }> = ({
iconText,
brandName,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const iconScale = spring({ frame, fps, config: { damping: 8 } });
const textOpacity = interpolate(frame, [15, 30], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const textX = interpolate(frame, [15, 30], [20, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<AbsoluteFill
style={{ backgroundColor: "#0a0a0a", justifyContent: "center", alignItems: "center" }}
>
<div style={{ display: "flex", alignItems: "center", gap: 20 }}>
<div
style={{
fontSize: 72,
transform: `scale(${iconScale})`,
}}
>
{iconText}
</div>
<div
style={{
fontSize: 48,
fontWeight: "bold",
color: "#ffffff",
opacity: textOpacity,
transform: `translateX(${textX}px)`,
fontFamily: "Arial, sans-serif",
}}
>
{brandName}
</div>
</div>
</AbsoluteFill>
);
};5. Progress Bar
import { useCurrentFrame, useVideoConfig, interpolate, AbsoluteFill } from "remotion";
export const ProgressBar: React.FC<{ label: string; color: string }> = ({
label,
color = "#4ecca3",
}) => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const progress = interpolate(frame, [5, durationInFrames - 10], [0, 100], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{ backgroundColor: "#1a1a2e", justifyContent: "center", alignItems: "center" }}
>
<div style={{ width: "80%", textAlign: "center" }}>
<div style={{ fontSize: 20, color: "#a8a8b3", marginBottom: 16 }}>{label}</div>
<div
style={{
width: "100%",
height: 32,
backgroundColor: "#16213e",
borderRadius: 16,
overflow: "hidden",
}}
>
<div
style={{
width: `${progress}%`,
height: "100%",
backgroundColor: color,
borderRadius: 16,
transition: "none",
}}
/>
</div>
<div
style={{
fontSize: 36,
fontWeight: "bold",
color: color,
marginTop: 16,
fontFamily: "monospace",
}}
>
{Math.round(progress)}%
</div>
</div>
</AbsoluteFill>
);
};6. Meme (Top/Bottom Text)
import { AbsoluteFill, Img } from "remotion";
export const Meme: React.FC<{ imageSrc: string; topText: string; bottomText: string }> = ({
imageSrc,
topText,
bottomText,
}) => {
const textStyle: React.CSSProperties = {
position: "absolute",
width: "100%",
textAlign: "center",
fontSize: 40,
fontWeight: "bold",
color: "white",
fontFamily: "Impact, sans-serif",
textTransform: "uppercase",
WebkitTextStroke: "2px black",
padding: "0 10px",
lineHeight: 1.2,
};
return (
<AbsoluteFill>
<Img src={imageSrc} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
<div style={{ ...textStyle, top: 20 }}>{topText}</div>
<div style={{ ...textStyle, bottom: 20 }}>{bottomText}</div>
</AbsoluteFill>
);
};7. Typewriter Effect
import { useCurrentFrame, useVideoConfig, interpolate, AbsoluteFill } from "remotion";
export const Typewriter: React.FC<{ text: string }> = ({ text }) => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const charCount = Math.floor(
interpolate(frame, [0, durationInFrames * 0.8], [0, text.length], {
extrapolateRight: "clamp",
})
);
const showCursor = frame % 15 < 10; // Blink every 15 frames
return (
<AbsoluteFill
style={{ backgroundColor: "#0d1117", justifyContent: "center", alignItems: "center" }}
>
<div
style={{
fontSize: 32,
color: "#c9d1d9",
fontFamily: "'Courier New', monospace",
padding: 40,
maxWidth: "90%",
}}
>
<span style={{ color: "#7ee787" }}>$ </span>
{text.substring(0, charCount)}
<span
style={{
display: "inline-block",
width: 12,
height: 32,
backgroundColor: showCursor ? "#c9d1d9" : "transparent",
marginLeft: 2,
verticalAlign: "bottom",
}}
/>
</div>
</AbsoluteFill>
);
};8. Confetti Burst
import { useCurrentFrame, useVideoConfig, spring, interpolate, AbsoluteFill } from "remotion";
const COLORS = ["#ff6b6b", "#ffd93d", "#6bcb77", "#4d96ff", "#ff6eb4"];
interface Particle {
x: number;
angle: number;
speed: number;
color: string;
size: number;
rotation: number;
}
// Deterministic random from seed
const seededRandom = (seed: number) => {
const x = Math.sin(seed * 9301 + 49297) * 49297;
return x - Math.floor(x);
};
const particles: Particle[] = Array.from({ length: 30 }, (_, i) => ({
x: seededRandom(i) * 480,
angle: seededRandom(i + 100) * Math.PI * 2,
speed: 200 + seededRandom(i + 200) * 300,
color: COLORS[i % COLORS.length],
size: 8 + seededRandom(i + 300) * 12,
rotation: seededRandom(i + 400) * 360,
}));
export const Confetti: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const burst = spring({ frame, fps, config: { damping: 20, stiffness: 80 } });
const gravity = interpolate(frame, [0, 45], [0, 200], { extrapolateRight: "clamp" });
return (
<AbsoluteFill style={{ backgroundColor: "#1a1a2e", overflow: "hidden" }}>
{particles.map((p, i) => {
const px = 240 + Math.cos(p.angle) * p.speed * burst;
const py = 240 + Math.sin(p.angle) * p.speed * burst + gravity;
const rot = p.rotation + frame * (i % 2 === 0 ? 8 : -8);
const opacity = interpolate(frame, [30, 45], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<div
key={i}
style={{
position: "absolute",
left: px,
top: py,
width: p.size,
height: p.size * 0.6,
backgroundColor: p.color,
transform: `rotate(${rot}deg)`,
opacity,
borderRadius: 2,
}}
/>
);
})}
</AbsoluteFill>
);
};Color Palette Considerations
GIF-Friendly Colors (Flat, Limited Palette)
// Dark themes compress best
const DARK_BG = "#0a0a0a"; // Near-black (common in all frames = 1 palette slot)
const DARK_SURFACE = "#1a1a2e";
// High-contrast accent colors
const ACCENT_RED = "#e94560";
const ACCENT_BLUE = "#00d4ff";
const ACCENT_GREEN = "#4ecca3";
const ACCENT_YELLOW = "#ffd93d";
// Text colors
const TEXT_PRIMARY = "#ffffff";
const TEXT_SECONDARY = "#a8a8b3";Colors That Waste Palette Slots
Avoid these in GIF-targeted compositions:
- CSS
linear-gradient()orradial-gradient()-- creates hundreds of intermediate colors box-shadowwith blur -- generates many semi-transparent colorstext-shadowwith blurfilter: blur()on any element- Semi-transparent overlays (
rgbawith alpha between 0.1 and 0.9) - Photographic backgrounds (use solid colors or simple patterns)
Looping Compositions
For compositions that should seamlessly loop:
- Ensure frame 0 and the last frame are visually identical
- Use modulo operations for cyclic animations:
const rotation = (frame / durationInFrames) * 360; // Completes exactly one rotation
const pulse = Math.sin((frame / durationInFrames) * Math.PI * 2); // Sine wave loop- Test by watching the GIF loop several times -- the seam should be invisible
Root.tsx Registration Template
import { Composition } from "remotion";
import { GifComposition } from "./GifComposition";
export const RemotionRoot: React.FC = () => {
return (
<Composition
id="GifOutput"
component={GifComposition}
durationInFrames={45} // 3 seconds at 15fps
fps={15}
width={480}
height={480}
defaultProps={{
text: "Hello World", // Override with --props flag
}}
/>
);
};Render Commands
# Render as PNG frame sequence (recommended for GIF)
npx remotion render src/index.ts GifOutput \
--image-format png \
--sequence \
--output /tmp/claude-gif/frames/frame%04d.png \
--concurrency 4
# Render with custom props
npx remotion render src/index.ts GifOutput \
--image-format png \
--sequence \
--output /tmp/claude-gif/frames/frame%04d.png \
--props '{"text": "Custom Text"}'
# Preview in browser
npx remotion preview src/index.ts#!/usr/bin/env bash
# Check dependency status for claude-gif (JSON output, no installs)
# Usage: bash scripts/check_deps.sh
set -euo pipefail
json_entry() {
local name="$1" available="$2" version="${3:-}" install="${4:-}"
if [ "$available" = "true" ]; then
printf '{"name":"%s","available":true,"version":"%s"}' "$name" "$version"
else
printf '{"name":"%s","available":false,"install":"%s"}' "$name" "$install"
fi
}
ENTRIES=()
# ffmpeg
if command -v ffmpeg &>/dev/null; then
ver=$(ffmpeg -version 2>&1 | head -1 | awk '{print $3}')
ENTRIES+=("$(json_entry "ffmpeg" "true" "$ver")")
else
ENTRIES+=("$(json_entry "ffmpeg" "false" "" "sudo apt install ffmpeg")")
fi
# gifsicle
if command -v gifsicle &>/dev/null; then
ver=$(gifsicle --version 2>&1 | head -1 | grep -oP '[\d.]+' || echo "unknown")
ENTRIES+=("$(json_entry "gifsicle" "true" "$ver")")
else
ENTRIES+=("$(json_entry "gifsicle" "false" "" "sudo apt install gifsicle")")
fi
# python3
if command -v python3 &>/dev/null; then
ver=$(python3 --version 2>&1 | awk '{print $2}')
ENTRIES+=("$(json_entry "python3" "true" "$ver")")
else
ENTRIES+=("$(json_entry "python3" "false" "" "pyenv install 3.12")")
fi
# Pillow
PYTHON_CMD="python3"
[ -f "$HOME/.video-skill/bin/python3" ] && PYTHON_CMD="$HOME/.video-skill/bin/python3"
if $PYTHON_CMD -c "from PIL import Image" 2>/dev/null; then
ver=$($PYTHON_CMD -c "import PIL; print(PIL.__version__)" 2>/dev/null || echo "unknown")
ENTRIES+=("$(json_entry "pillow" "true" "$ver")")
else
ENTRIES+=("$(json_entry "pillow" "false" "" "pip install Pillow")")
fi
# numpy
if $PYTHON_CMD -c "import numpy" 2>/dev/null; then
ver=$($PYTHON_CMD -c "import numpy; print(numpy.__version__)" 2>/dev/null || echo "unknown")
ENTRIES+=("$(json_entry "numpy" "true" "$ver")")
else
ENTRIES+=("$(json_entry "numpy" "false" "" "pip install numpy")")
fi
# node
if command -v node &>/dev/null; then
ver=$(node --version 2>&1)
ENTRIES+=("$(json_entry "node" "true" "$ver")")
else
ENTRIES+=("$(json_entry "node" "false" "" "nvm install --lts")")
fi
# imagemagick
if command -v convert &>/dev/null; then
ver=$(convert --version 2>&1 | head -1 | grep -oP 'ImageMagick [\d.-]+' || echo "unknown")
ENTRIES+=("$(json_entry "imagemagick" "true" "$ver")")
else
ENTRIES+=("$(json_entry "imagemagick" "false" "" "sudo apt install imagemagick")")
fi
# playwright
if command -v npx &>/dev/null && npx playwright --version &>/dev/null 2>&1; then
ver=$(npx playwright --version 2>&1 || echo "unknown")
ENTRIES+=("$(json_entry "playwright" "true" "$ver")")
else
ENTRIES+=("$(json_entry "playwright" "false" "" "npx playwright install chromium")")
fi
# Output JSON
echo "["
for i in "${!ENTRIES[@]}"; do
if [ "$i" -lt $((${#ENTRIES[@]} - 1)) ]; then
echo " ${ENTRIES[$i]},"
else
echo " ${ENTRIES[$i]}"
fi
done
echo "]"
#!/usr/bin/env bash
# Core GIF conversion — FFmpeg two-pass palette pipeline
# Usage: bash gif_convert.sh --input FILE [options]
#
# Options:
# --input PATH Input video/image file (required)
# --output PATH Output GIF path (default: input_basename.gif)
# --fps N Frame rate (default: 15)
# --width N Output width in px, height auto (default: 480)
# --preset NAME Quality preset: discord|slack|twitter|web|hq (overrides fps/width/colors/dither)
# --start SS Start time in seconds (default: 0)
# --duration SS Duration in seconds (default: full)
# --dither ALGO Dithering: floyd_steinberg|bayer:3|sierra2|sierra2_4a|none (default: floyd_steinberg)
# --stats-mode MODE Palette mode: full|diff|single (default: full)
# --colors N Max colors 2-256 (default: 256)
# --loop N Loop count: 0=infinite, N=fixed (default: 0)
# --transparent Enable transparency (single color key)
# -y Overwrite output if exists
set -euo pipefail
# Defaults
INPUT=""
OUTPUT=""
FPS=15
WIDTH=480
PRESET=""
START=""
DURATION=""
DITHER="floyd_steinberg"
STATS_MODE="full"
COLORS=256
LOOP=0
OVERWRITE=""
TRANSPARENT=""
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--input) INPUT="$2"; shift 2 ;;
--output) OUTPUT="$2"; shift 2 ;;
--fps) FPS="$2"; shift 2 ;;
--width) WIDTH="$2"; shift 2 ;;
--preset) PRESET="$2"; shift 2 ;;
--start) START="$2"; shift 2 ;;
--duration) DURATION="$2"; shift 2 ;;
--dither) DITHER="$2"; shift 2 ;;
--stats-mode) STATS_MODE="$2"; shift 2 ;;
--colors) COLORS="$2"; shift 2 ;;
--loop) LOOP="$2"; shift 2 ;;
--transparent) TRANSPARENT="1"; shift ;;
-y) OVERWRITE="-y"; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
if [ -z "$INPUT" ]; then
echo '{"error": "Missing --input argument"}' >&2
exit 1
fi
if [ ! -f "$INPUT" ]; then
echo "{\"error\": \"Input file not found: $INPUT\"}" >&2
exit 1
fi
# Apply preset overrides
case "$PRESET" in
discord)
WIDTH=320; FPS=10; COLORS=128; DITHER="bayer:bayer_scale=3"; STATS_MODE="diff"
;;
slack)
WIDTH=400; FPS=12; COLORS=192; DITHER="floyd_steinberg"; STATS_MODE="full"
;;
twitter)
WIDTH=480; FPS=15; COLORS=256; DITHER="floyd_steinberg"; STATS_MODE="full"
;;
web)
WIDTH=480; FPS=15; COLORS=256; DITHER="floyd_steinberg"; STATS_MODE="full"
;;
hq)
WIDTH=640; FPS=20; COLORS=256; DITHER="sierra2"; STATS_MODE="diff"
;;
"")
;; # No preset, use explicit values
*)
echo "{\"error\": \"Unknown preset: $PRESET. Use: discord|slack|twitter|web|hq\"}" >&2
exit 1
;;
esac
# Default output path
if [ -z "$OUTPUT" ]; then
BASENAME=$(basename "$INPUT")
OUTPUT_DIR=$(dirname "$INPUT")
OUTPUT="${OUTPUT_DIR}/${BASENAME%.*}.gif"
fi
# Ensure output directory exists
mkdir -p "$(dirname "$OUTPUT")"
# Temp palette file
PALETTE="/tmp/claude-gif/palette_$$.png"
mkdir -p /tmp/claude-gif
trap 'rm -f "$PALETTE"' EXIT INT TERM
# Build time selection flags
TIME_FLAGS=""
[ -n "$START" ] && TIME_FLAGS="-ss $START"
[ -n "$DURATION" ] && TIME_FLAGS="$TIME_FLAGS -t $DURATION"
# Build overwrite flag
OW_FLAG="${OVERWRITE:--n}"
# Build scale filter
SCALE_FILTER="scale=${WIDTH}:-1:flags=lanczos"
# Pass 1: Generate optimal palette
echo "Pass 1: Generating palette (${COLORS} colors, stats_mode=${STATS_MODE})..." >&2
ffmpeg -y $TIME_FLAGS -i "$INPUT" \
-vf "fps=${FPS},${SCALE_FILTER},palettegen=max_colors=${COLORS}:stats_mode=${STATS_MODE}:reserve_transparent=${TRANSPARENT:-0}" \
"$PALETTE" 2>/dev/null
# Pass 2: Apply palette with dithering
echo "Pass 2: Rendering GIF (dither=${DITHER}, loop=${LOOP})..." >&2
ffmpeg $OW_FLAG $TIME_FLAGS -i "$INPUT" -i "$PALETTE" \
-lavfi "fps=${FPS},${SCALE_FILTER} [x]; [x][1:v] paletteuse=dither=${DITHER}:diff_mode=rectangle" \
-loop "$LOOP" \
"$OUTPUT" 2>/dev/null
# Get output stats
if [ -f "$OUTPUT" ]; then
SIZE_BYTES=$(stat -c%s "$OUTPUT" 2>/dev/null || stat -f%z "$OUTPUT" 2>/dev/null)
SIZE_KB=$((SIZE_BYTES / 1024))
SIZE_MB=$(echo "scale=2; $SIZE_BYTES / 1048576" | bc 2>/dev/null || echo "N/A")
# Get frame count and dimensions
FRAME_INFO=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height,nb_frames,r_frame_rate -of json "$OUTPUT" 2>/dev/null || echo '{}')
OUT_WIDTH=$(echo "$FRAME_INFO" | grep -oP '"width":\s*\K\d+' || echo "$WIDTH")
OUT_HEIGHT=$(echo "$FRAME_INFO" | grep -oP '"height":\s*\K\d+' || echo "auto")
cat <<EOF
{
"success": true,
"output": "$OUTPUT",
"size_bytes": $SIZE_BYTES,
"size_kb": $SIZE_KB,
"size_mb": "$SIZE_MB",
"width": $OUT_WIDTH,
"height": $OUT_HEIGHT,
"fps": $FPS,
"colors": $COLORS,
"dither": "$DITHER",
"stats_mode": "$STATS_MODE",
"preset": "${PRESET:-custom}",
"loop": $LOOP
}
EOF
else
echo '{"success": false, "error": "Output file was not created"}' >&2
exit 1
fi
#!/usr/bin/env python3
"""Frame extraction, assembly, and SVG rendering for GIF creation.
Usage:
# Assemble image sequence to GIF
python3 gif_frames.py --input-dir DIR --output FILE [options]
# Extract frames from GIF/video
python3 gif_frames.py --extract FILE --output-dir DIR
# Render animated SVG to frames (via Playwright)
python3 gif_frames.py --svg FILE --output FILE [options]
Options:
--input-dir DIR Directory of PNG/JPG/WEBP frames
--output PATH Output GIF path
--fps N Frame rate (default: 15)
--width N Output width in px (default: 480)
--preset NAME Quality preset: discord|slack|twitter|web|hq
--sort METHOD Sort: name|modified (default: name)
--reverse Reverse frame order
--extract PATH Extract frames from GIF/video
--output-dir DIR Directory for extracted frames
--svg PATH Animated SVG file to render
--svg-duration N SVG animation duration in seconds (default: 3)
--transparent Preserve transparency (for SVG mode)
"""
import argparse
import glob
import json
import os
import re
import subprocess
import sys
import tempfile
PRESETS = {
"discord": {"width": 320, "fps": 10, "colors": 128, "dither": "bayer:bayer_scale=3"},
"slack": {"width": 400, "fps": 12, "colors": 192, "dither": "floyd_steinberg"},
"twitter": {"width": 480, "fps": 15, "colors": 256, "dither": "floyd_steinberg"},
"web": {"width": 480, "fps": 15, "colors": 256, "dither": "floyd_steinberg"},
"hq": {"width": 640, "fps": 20, "colors": 256, "dither": "sierra2"},
}
def natural_sort_key(s):
"""Sort strings with embedded numbers naturally."""
return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', s)]
def find_frames(input_dir: str, sort: str = "name") -> list[str]:
"""Find image frames in directory."""
extensions = ("*.png", "*.jpg", "*.jpeg", "*.webp", "*.bmp")
frames = []
for ext in extensions:
frames.extend(glob.glob(os.path.join(input_dir, ext)))
if sort == "modified":
frames.sort(key=os.path.getmtime)
else:
frames.sort(key=natural_sort_key)
return frames
def assemble_gif(frames: list[str], output: str, fps: int = 15, width: int = 480,
colors: int = 256, dither: str = "floyd_steinberg",
transparent: bool = False) -> dict:
"""Assemble image frames into optimized GIF using FFmpeg two-pass palette."""
if not frames:
return {"success": False, "error": "No frames found"}
tmpdir = tempfile.mkdtemp(dir="/tmp/claude-gif", prefix="assemble_")
palette = os.path.join(tmpdir, "palette.png")
concat = os.path.join(tmpdir, "concat.txt")
# Write concat file
with open(concat, "w") as f:
for frame in frames:
f.write(f"file '{os.path.abspath(frame)}'\n")
f.write(f"duration {1/fps}\n")
reserve = "1" if transparent else "0"
vf_scale = f"scale={width}:-1:flags=lanczos"
try:
# Pass 1: palette
subprocess.run(
["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat,
"-vf", f"fps={fps},{vf_scale},palettegen=max_colors={colors}:stats_mode=full:reserve_transparent={reserve}",
palette],
capture_output=True, timeout=120, check=True
)
# Pass 2: apply palette
alpha_thresh = ":alpha_threshold=128" if transparent else ""
subprocess.run(
["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat,
"-i", palette,
"-lavfi", f"fps={fps},{vf_scale} [x]; [x][1:v] paletteuse=dither={dither}:diff_mode=rectangle{alpha_thresh}",
"-loop", "0", output],
capture_output=True, timeout=120, check=True
)
size = os.path.getsize(output)
return {
"success": True,
"output": output,
"frames": len(frames),
"fps": fps,
"width": width,
"colors": colors,
"size_bytes": size,
"size_kb": size // 1024,
"transparent": transparent,
}
except subprocess.CalledProcessError as e:
return {"success": False, "error": str(e), "stderr": e.stderr.decode()[-500:] if e.stderr else ""}
finally:
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
def extract_frames(input_path: str, output_dir: str) -> dict:
"""Extract frames from GIF or video file."""
os.makedirs(output_dir, exist_ok=True)
pattern = os.path.join(output_dir, "frame_%04d.png")
try:
subprocess.run(
["ffmpeg", "-y", "-i", input_path, pattern],
capture_output=True, timeout=120, check=True
)
frames = sorted(glob.glob(os.path.join(output_dir, "frame_*.png")))
return {
"success": True,
"output_dir": output_dir,
"frames": len(frames),
"first": frames[0] if frames else None,
"last": frames[-1] if frames else None,
}
except subprocess.CalledProcessError as e:
return {"success": False, "error": str(e)}
def render_svg_to_frames(svg_path: str, output_dir: str, fps: int = 15,
duration: float = 3.0, width: int = 480,
height: int = 480) -> dict:
"""Render animated SVG to PNG frames using Playwright.
This captures each frame of a SMIL/CSS animated SVG at the target FPS,
producing transparent PNGs that can be assembled into a transparent GIF.
"""
os.makedirs(output_dir, exist_ok=True)
total_frames = int(fps * duration)
# Generate a Node.js script that uses Playwright to capture frames
capture_script = os.path.join(output_dir, "_capture.mjs")
with open(capture_script, "w") as f:
f.write(f"""
import {{ chromium }} from 'playwright';
import {{ readFileSync }} from 'fs';
import {{ resolve }} from 'path';
const svgPath = resolve('{os.path.abspath(svg_path)}');
const svgContent = readFileSync(svgPath, 'utf-8');
const outputDir = resolve('{os.path.abspath(output_dir)}');
const fps = {fps};
const duration = {duration};
const totalFrames = {total_frames};
const width = {width};
const height = {height};
const browser = await chromium.launch();
const page = await browser.newPage({{
viewport: {{ width, height }},
deviceScaleFactor: 1,
}});
// Load SVG in a minimal HTML page with transparent background
const html = `<!DOCTYPE html>
<html><head><style>
html, body {{ margin: 0; padding: 0; background: transparent; overflow: hidden; }}
svg {{ width: ${{width}}px; height: ${{height}}px; display: block; }}
</style></head>
<body>${{svgContent}}</body></html>`;
await page.setContent(html, {{ waitUntil: 'networkidle' }});
// Capture frames at target FPS intervals
const frameDelay = 1000 / fps;
for (let i = 0; i < totalFrames; i++) {{
const padded = String(i).padStart(4, '0');
await page.screenshot({{
path: `${{outputDir}}/frame_${{padded}}.png`,
omitBackground: true,
}});
// Advance time by waiting for next animation frame
await page.waitForTimeout(frameDelay);
}}
await browser.close();
console.log(JSON.stringify({{ success: true, frames: totalFrames }}));
""")
try:
result = subprocess.run(
["node", capture_script],
capture_output=True, text=True, timeout=120
)
if result.returncode != 0:
return {
"success": False,
"error": "Playwright capture failed",
"stderr": result.stderr[-500:] if result.stderr else "",
"hint": "Run 'npx playwright install chromium' if not installed"
}
frames = sorted(glob.glob(os.path.join(output_dir, "frame_*.png")))
return {
"success": True,
"output_dir": output_dir,
"frames": len(frames),
"fps": fps,
"duration": duration,
"transparent": True,
}
except subprocess.TimeoutExpired:
return {"success": False, "error": "SVG render timed out (120s)"}
finally:
if os.path.exists(capture_script):
os.unlink(capture_script)
def main():
parser = argparse.ArgumentParser(description="GIF frame tools")
parser.add_argument("--input-dir", help="Directory of image frames")
parser.add_argument("--output", help="Output GIF path")
parser.add_argument("--fps", type=int, default=15, help="Frame rate")
parser.add_argument("--width", type=int, default=480, help="Output width")
parser.add_argument("--height", type=int, default=480, help="Output height (SVG mode)")
parser.add_argument("--preset", choices=list(PRESETS.keys()), help="Quality preset")
parser.add_argument("--sort", choices=["name", "modified"], default="name")
parser.add_argument("--reverse", action="store_true")
parser.add_argument("--transparent", action="store_true")
# Extract mode
parser.add_argument("--extract", help="Extract frames from GIF/video")
parser.add_argument("--output-dir", help="Output directory for extracted frames")
# SVG mode
parser.add_argument("--svg", help="Animated SVG file to render")
parser.add_argument("--svg-duration", type=float, default=3.0, help="SVG animation duration")
args = parser.parse_args()
os.makedirs("/tmp/claude-gif", exist_ok=True)
# Apply preset
if args.preset:
p = PRESETS[args.preset]
args.width = p["width"]
args.fps = p["fps"]
colors = p["colors"]
dither = p["dither"]
else:
colors = 256
dither = "floyd_steinberg"
# Mode: Extract frames
if args.extract:
out_dir = args.output_dir or "/tmp/claude-gif/extracted"
result = extract_frames(args.extract, out_dir)
print(json.dumps(result, indent=2))
return
# Mode: SVG render → GIF
if args.svg:
if not args.output:
args.output = args.svg.replace(".svg", ".gif")
render_dir = tempfile.mkdtemp(dir="/tmp/claude-gif", prefix="svg_")
render_result = render_svg_to_frames(
args.svg, render_dir, fps=args.fps,
duration=args.svg_duration, width=args.width, height=args.height
)
if not render_result.get("success"):
print(json.dumps(render_result, indent=2))
sys.exit(1)
frames = sorted(glob.glob(os.path.join(render_dir, "frame_*.png")))
result = assemble_gif(frames, args.output, fps=args.fps, width=args.width,
colors=colors, dither=dither, transparent=True)
print(json.dumps(result, indent=2))
return
# Mode: Assemble frames → GIF
if args.input_dir:
if not args.output:
print(json.dumps({"error": "Missing --output"}))
sys.exit(1)
frames = find_frames(args.input_dir, args.sort)
if args.reverse:
frames.reverse()
if not frames:
print(json.dumps({"error": f"No image frames found in {args.input_dir}"}))
sys.exit(1)
result = assemble_gif(frames, args.output, fps=args.fps, width=args.width,
colors=colors, dither=dither, transparent=args.transparent)
print(json.dumps(result, indent=2))
return
parser.print_help()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Perfect loop creation for GIFs — crossfade, ping-pong, and freeze-frame blending.
Usage:
python3 gif_loop.py --input FILE [options]
Options:
--input PATH Input GIF file (required)
--output PATH Output path (default: input_loop.gif)
--method METHOD Loop method: crossfade|pingpong|freeze (default: crossfade)
--frames N Overlap frames for crossfade (default: 5)
--blend-curve CURVE Blending curve: sine|linear|ease (default: sine)
--assess Assess loop quality without modifying
"""
import argparse
import glob
import json
import math
import os
import subprocess
import sys
import tempfile
try:
from PIL import Image
HAS_PIL = True
except ImportError:
HAS_PIL = False
try:
import numpy as np
HAS_NUMPY = True
except ImportError:
HAS_NUMPY = False
def extract_frames(gif_path: str, output_dir: str) -> list[str]:
"""Extract GIF frames as PNG files using FFmpeg."""
os.makedirs(output_dir, exist_ok=True)
pattern = os.path.join(output_dir, "frame_%04d.png")
subprocess.run(
["ffmpeg", "-y", "-i", gif_path, pattern],
capture_output=True, timeout=60, check=True
)
frames = sorted(glob.glob(os.path.join(output_dir, "frame_*.png")))
return frames
def get_frame_delay(gif_path: str) -> float:
"""Get average frame delay from GIF in seconds."""
try:
result = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=r_frame_rate", "-of", "json", gif_path],
capture_output=True, text=True, timeout=10
)
data = json.loads(result.stdout)
fps_str = data.get("streams", [{}])[0].get("r_frame_rate", "15/1")
num, den = map(int, fps_str.split("/"))
return den / num if num > 0 else 1 / 15
except Exception:
return 1 / 15
def blend_alpha(t: float, curve: str = "sine") -> float:
"""Compute blend alpha for crossfade."""
if curve == "sine":
return (1 - math.cos(t * math.pi)) / 2
elif curve == "ease":
return t * t * (3 - 2 * t)
else: # linear
return t
def crossfade_loop(frames: list[str], output_dir: str, overlap: int = 5,
curve: str = "sine") -> list[str]:
"""Create seamless loop by crossfading end into beginning."""
if not HAS_PIL:
raise RuntimeError("Pillow required for crossfade. Install: pip install Pillow")
n = len(frames)
if overlap >= n // 2:
overlap = max(2, n // 4)
images = [Image.open(f).convert("RGBA") for f in frames]
# Blend the last `overlap` frames with the first `overlap` frames
for i in range(overlap):
t = i / (overlap - 1) if overlap > 1 else 0.5
alpha = blend_alpha(t, curve)
start_img = images[i]
end_idx = n - overlap + i
end_img = images[end_idx]
blended = Image.blend(end_img, start_img, alpha)
images[end_idx] = blended
# Remove the first `overlap` frames (they're now blended into the end)
result_images = images[overlap:]
# Save
os.makedirs(output_dir, exist_ok=True)
result_paths = []
for i, img in enumerate(result_images):
path = os.path.join(output_dir, f"loop_{i:04d}.png")
img.convert("RGB").save(path)
result_paths.append(path)
return result_paths
def pingpong_loop(frames: list[str], output_dir: str) -> list[str]:
"""Create ping-pong loop (forward then reverse, skip first/last to avoid pause)."""
os.makedirs(output_dir, exist_ok=True)
# Forward + reverse (excluding endpoints to avoid double-frame pause)
all_frames = list(frames) + list(reversed(frames[1:-1]))
result_paths = []
for i, src in enumerate(all_frames):
dst = os.path.join(output_dir, f"pong_{i:04d}.png")
if os.path.abspath(src) != os.path.abspath(dst):
subprocess.run(["cp", src, dst], check=True)
result_paths.append(dst)
return result_paths
def freeze_blend_loop(frames: list[str], output_dir: str) -> list[str]:
"""Blend first and last frames to create a subtle meeting point."""
if not HAS_PIL:
raise RuntimeError("Pillow required. Install: pip install Pillow")
os.makedirs(output_dir, exist_ok=True)
images = [Image.open(f).convert("RGB") for f in frames]
# Average of first and last
avg = Image.blend(images[0], images[-1], 0.5)
images[0] = avg
images[-1] = avg
result_paths = []
for i, img in enumerate(images):
path = os.path.join(output_dir, f"freeze_{i:04d}.png")
img.save(path)
result_paths.append(path)
return result_paths
def assess_loop_quality(gif_path: str) -> dict:
"""Measure how well a GIF loops by comparing first and last frames."""
tmpdir = tempfile.mkdtemp(dir="/tmp/claude-gif", prefix="assess_")
try:
frames = extract_frames(gif_path, tmpdir)
if len(frames) < 2:
return {"error": "Need at least 2 frames", "frames": len(frames)}
if HAS_PIL and HAS_NUMPY:
first = np.array(Image.open(frames[0]).convert("RGB"), dtype=float)
last = np.array(Image.open(frames[-1]).convert("RGB"), dtype=float)
mae = float(np.mean(np.abs(first - last)))
else:
mae = -1 # Can't compute without numpy/PIL
if mae < 0:
rating = "unknown (install Pillow + numpy)"
elif mae < 5:
rating = "perfect"
elif mae < 15:
rating = "good"
elif mae < 30:
rating = "fair"
else:
rating = "poor"
recommendation = ""
if mae > 15:
recommendation = "Use --method crossfade to blend the seam"
elif mae > 5:
recommendation = "Loop is decent; crossfade could improve it slightly"
elif mae >= 0:
recommendation = "Loop is already seamless"
return {
"frames": len(frames),
"mae": round(mae, 2),
"rating": rating,
"recommendation": recommendation,
}
finally:
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
def assemble_gif(frame_paths: list[str], output_path: str, fps: int = 15) -> bool:
"""Assemble PNG frames into optimized GIF using FFmpeg two-pass palette."""
if not frame_paths:
return False
tmpdir = os.path.dirname(frame_paths[0])
palette = os.path.join(tmpdir, "palette.png")
# Create concat file for non-sequential names
concat_file = os.path.join(tmpdir, "concat.txt")
with open(concat_file, "w") as f:
for p in frame_paths:
f.write(f"file '{os.path.abspath(p)}'\n")
f.write(f"duration {1/fps}\n")
try:
# Pass 1: palette
subprocess.run(
["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-vf", f"fps={fps},palettegen=max_colors=256:stats_mode=full",
palette],
capture_output=True, timeout=120, check=True
)
# Pass 2: apply
subprocess.run(
["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-i", palette,
"-lavfi", f"fps={fps} [x]; [x][1:v] paletteuse=dither=floyd_steinberg:diff_mode=rectangle",
"-loop", "0", output_path],
capture_output=True, timeout=120, check=True
)
return os.path.exists(output_path)
except subprocess.CalledProcessError:
return False
def main():
parser = argparse.ArgumentParser(description="Create perfect GIF loops")
parser.add_argument("--input", required=True, help="Input GIF file")
parser.add_argument("--output", help="Output path")
parser.add_argument("--method", choices=["crossfade", "pingpong", "freeze"],
default="crossfade", help="Loop method")
parser.add_argument("--frames", type=int, default=5, help="Overlap frames for crossfade")
parser.add_argument("--blend-curve", choices=["sine", "linear", "ease"],
default="sine", help="Blending curve")
parser.add_argument("--assess", action="store_true", help="Assess loop quality only")
args = parser.parse_args()
if not os.path.exists(args.input):
print(json.dumps({"error": f"Input not found: {args.input}"}))
sys.exit(1)
os.makedirs("/tmp/claude-gif", exist_ok=True)
if args.assess:
result = assess_loop_quality(args.input)
print(json.dumps(result, indent=2))
return
output = args.output or args.input.replace(".gif", "_loop.gif")
# Extract frames
work_dir = tempfile.mkdtemp(dir="/tmp/claude-gif", prefix="loop_")
extract_dir = os.path.join(work_dir, "extracted")
loop_dir = os.path.join(work_dir, "looped")
try:
frames = extract_frames(args.input, extract_dir)
if len(frames) < 4:
print(json.dumps({"error": "Need at least 4 frames for loop creation"}))
sys.exit(1)
# Get original FPS
delay = get_frame_delay(args.input)
fps = max(1, round(1 / delay))
# Apply loop method
if args.method == "crossfade":
looped = crossfade_loop(frames, loop_dir, args.frames, args.blend_curve)
elif args.method == "pingpong":
looped = pingpong_loop(frames, loop_dir)
elif args.method == "freeze":
looped = freeze_blend_loop(frames, loop_dir)
else:
looped = frames
# Assemble
if assemble_gif(looped, output, fps=fps):
# Assess result
quality = assess_loop_quality(output)
result = {
"success": True,
"output": output,
"method": args.method,
"original_frames": len(frames),
"output_frames": len(looped),
"fps": fps,
"loop_quality": quality,
"size_kb": os.path.getsize(output) // 1024,
}
if args.method == "crossfade":
result["overlap_frames"] = args.frames
result["blend_curve"] = args.blend_curve
else:
result = {"success": False, "error": "Failed to assemble GIF"}
print(json.dumps(result, indent=2))
finally:
import shutil
shutil.rmtree(work_dir, ignore_errors=True)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Multi-strategy GIF optimization with platform auto-fit.
Usage:
python3 gif_optimize.py --input FILE [options]
Options:
--input PATH Input GIF file (required)
--output PATH Output path (default: input_optimized.gif)
--target-size SIZE Target size: 256KB, 2MB, etc.
--platform NAME Platform preset: discord|slack|twitter|web|github|email
--colors N Max colors 2-256
--lossy N gifsicle lossy level 30-200 (default: 80)
--dither ALGO Dithering algorithm
--analyze-only Just report stats, don't optimize
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
PLATFORM_LIMITS = {
"discord": 256 * 1024, # 256 KB
"slack": 500 * 1024, # 500 KB
"email": 1024 * 1024, # 1 MB
"web": 2 * 1024 * 1024, # 2 MB
"github": 10 * 1024 * 1024, # 10 MB
"facebook": 8 * 1024 * 1024, # 8 MB
"twitter": 15 * 1024 * 1024, # 15 MB
"reddit": 20 * 1024 * 1024, # 20 MB
}
def parse_size(size_str: str) -> int:
"""Parse human-readable size string to bytes."""
size_str = size_str.strip().upper()
multipliers = {"KB": 1024, "MB": 1024**2, "GB": 1024**3, "B": 1}
for suffix, mult in sorted(multipliers.items(), key=lambda x: -len(x[0])):
if size_str.endswith(suffix):
return int(float(size_str[: -len(suffix)].strip()) * mult)
return int(size_str)
def get_gif_info(path: str) -> dict:
"""Get GIF file info using ffprobe."""
size = os.path.getsize(path)
try:
result = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height,nb_frames,r_frame_rate",
"-show_entries", "format=duration",
"-of", "json", path],
capture_output=True, text=True, timeout=10
)
data = json.loads(result.stdout)
stream = data.get("streams", [{}])[0]
fmt = data.get("format", {})
return {
"path": path,
"size_bytes": size,
"size_kb": size // 1024,
"width": int(stream.get("width", 0)),
"height": int(stream.get("height", 0)),
"frames": int(stream.get("nb_frames", 0)),
"duration": float(fmt.get("duration", 0)),
}
except Exception:
return {"path": path, "size_bytes": size, "size_kb": size // 1024}
def has_gifsicle() -> bool:
return shutil.which("gifsicle") is not None
def optimize_gifsicle(input_path: str, output_path: str, lossy: int = 80, colors: int = 256) -> bool:
"""Optimize with gifsicle lossy compression."""
cmd = ["gifsicle", "-O3", f"--lossy={lossy}"]
if colors < 256:
cmd.append(f"--colors={colors}")
cmd.extend([input_path, "-o", output_path])
try:
subprocess.run(cmd, capture_output=True, timeout=60, check=True)
return os.path.exists(output_path)
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def optimize_repalette(input_path: str, output_path: str, colors: int = 256,
width: int = 0, fps: int = 0, dither: str = "floyd_steinberg") -> bool:
"""Re-palette GIF through FFmpeg two-pass pipeline."""
vf_parts = []
if fps > 0:
vf_parts.append(f"fps={fps}")
if width > 0:
vf_parts.append(f"scale={width}:-1:flags=lanczos")
palette_path = tempfile.mktemp(suffix=".png", dir="/tmp/claude-gif")
try:
# Pass 1: generate palette
vf_palette = ",".join(vf_parts + [f"palettegen=max_colors={colors}:stats_mode=diff"])
subprocess.run(
["ffmpeg", "-y", "-i", input_path, "-vf", vf_palette, palette_path],
capture_output=True, timeout=120, check=True
)
# Pass 2: apply palette
vf_use = ",".join(vf_parts) if vf_parts else ""
if vf_use:
lavfi = f"{vf_use} [x]; [x][1:v] paletteuse=dither={dither}:diff_mode=rectangle"
else:
lavfi = f"[0:v][1:v] paletteuse=dither={dither}:diff_mode=rectangle"
subprocess.run(
["ffmpeg", "-y", "-i", input_path, "-i", palette_path, "-lavfi", lavfi,
"-loop", "0", output_path],
capture_output=True, timeout=120, check=True
)
return os.path.exists(output_path)
except subprocess.CalledProcessError:
return False
finally:
if os.path.exists(palette_path):
os.unlink(palette_path)
def auto_fit(input_path: str, output_path: str, target_bytes: int) -> dict:
"""Iteratively optimize until under target size."""
os.makedirs("/tmp/claude-gif", exist_ok=True)
info = get_gif_info(input_path)
current_path = input_path
methods_applied = []
# Strategy 1: gifsicle lossy
if has_gifsicle():
for lossy in [80, 120, 180]:
tmp = tempfile.mktemp(suffix=".gif", dir="/tmp/claude-gif")
if optimize_gifsicle(current_path, tmp, lossy=lossy):
if os.path.getsize(tmp) < os.path.getsize(current_path):
if current_path != input_path:
os.unlink(current_path)
current_path = tmp
methods_applied.append(f"gifsicle --lossy={lossy}")
if os.path.getsize(current_path) <= target_bytes:
break
else:
os.unlink(tmp)
if os.path.getsize(current_path) <= target_bytes:
if current_path != output_path:
shutil.copy2(current_path, output_path)
if current_path != input_path:
os.unlink(current_path)
return _result(info, output_path, methods_applied)
# Strategy 2: reduce colors
orig_width = info.get("width", 480)
for colors in [192, 128, 64]:
tmp = tempfile.mktemp(suffix=".gif", dir="/tmp/claude-gif")
if optimize_repalette(current_path, tmp, colors=colors):
if os.path.getsize(tmp) < os.path.getsize(current_path):
if current_path != input_path:
os.unlink(current_path)
current_path = tmp
methods_applied.append(f"repalette colors={colors}")
if os.path.getsize(current_path) <= target_bytes:
break
else:
os.unlink(tmp)
if os.path.getsize(current_path) <= target_bytes:
if current_path != output_path:
shutil.copy2(current_path, output_path)
if current_path != input_path:
os.unlink(current_path)
return _result(info, output_path, methods_applied)
# Strategy 3: reduce dimensions (scale down 20% each step)
width = orig_width
for _ in range(5):
width = int(width * 0.8)
if width < 160:
break
tmp = tempfile.mktemp(suffix=".gif", dir="/tmp/claude-gif")
if optimize_repalette(current_path, tmp, width=width, colors=128, dither="bayer:bayer_scale=3"):
if current_path != input_path:
os.unlink(current_path)
current_path = tmp
methods_applied.append(f"scale width={width}")
if os.path.getsize(current_path) <= target_bytes:
break
if os.path.getsize(current_path) <= target_bytes:
if current_path != output_path:
shutil.copy2(current_path, output_path)
if current_path != input_path:
os.unlink(current_path)
return _result(info, output_path, methods_applied)
# Strategy 4: reduce frame rate
for fps in [10, 8, 5]:
tmp = tempfile.mktemp(suffix=".gif", dir="/tmp/claude-gif")
if optimize_repalette(current_path, tmp, fps=fps, width=width, colors=128, dither="bayer:bayer_scale=3"):
if current_path != input_path:
os.unlink(current_path)
current_path = tmp
methods_applied.append(f"fps={fps}")
if os.path.getsize(current_path) <= target_bytes:
break
# Final copy
if current_path != output_path:
shutil.copy2(current_path, output_path)
if current_path != input_path:
os.unlink(current_path)
return _result(info, output_path, methods_applied)
def _result(original_info: dict, output_path: str, methods: list) -> dict:
"""Build result JSON."""
opt_info = get_gif_info(output_path)
orig_size = original_info["size_bytes"]
opt_size = opt_info["size_bytes"]
reduction = ((orig_size - opt_size) / orig_size * 100) if orig_size > 0 else 0
return {
"success": True,
"input": original_info.get("path", ""),
"output": output_path,
"original_size_kb": orig_size // 1024,
"optimized_size_kb": opt_size // 1024,
"reduction_pct": round(reduction, 1),
"methods_applied": methods,
"output_width": opt_info.get("width", 0),
"output_height": opt_info.get("height", 0),
"output_frames": opt_info.get("frames", 0),
}
def main():
parser = argparse.ArgumentParser(description="Optimize GIF files")
parser.add_argument("--input", required=True, help="Input GIF file")
parser.add_argument("--output", help="Output path")
parser.add_argument("--target-size", help="Target size (e.g., 256KB, 2MB)")
parser.add_argument("--platform", choices=list(PLATFORM_LIMITS.keys()), help="Platform preset")
parser.add_argument("--colors", type=int, default=256, help="Max colors")
parser.add_argument("--lossy", type=int, default=80, help="gifsicle lossy level")
parser.add_argument("--dither", default="floyd_steinberg", help="Dithering algorithm")
parser.add_argument("--analyze-only", action="store_true", help="Just report stats")
args = parser.parse_args()
if not os.path.exists(args.input):
print(json.dumps({"error": f"Input file not found: {args.input}"}))
sys.exit(1)
os.makedirs("/tmp/claude-gif", exist_ok=True)
if args.analyze_only:
info = get_gif_info(args.input)
info["gifsicle_available"] = has_gifsicle()
print(json.dumps(info, indent=2))
return
output = args.output or args.input.replace(".gif", "_optimized.gif")
# Determine target
if args.platform:
target_bytes = PLATFORM_LIMITS[args.platform]
elif args.target_size:
target_bytes = parse_size(args.target_size)
else:
target_bytes = 2 * 1024 * 1024 # Default: 2MB (web)
result = auto_fit(args.input, output, target_bytes)
result["target_bytes"] = target_bytes
result["target_platform"] = args.platform or "custom"
result["under_target"] = os.path.getsize(output) <= target_bytes
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env bash
# Pre-flight safety check before any GIF write operation
# Usage: bash scripts/preflight.sh <input_file> <output_file>
# Output: JSON with pass/fail status and any warnings
set -euo pipefail
INPUT="${1:-}"
OUTPUT="${2:-}"
if [ -z "$INPUT" ] || [ -z "$OUTPUT" ]; then
echo '{"pass":false,"error":"Usage: preflight.sh <input_file> <output_file>"}'
exit 1
fi
WARNINGS=()
ERRORS=()
# Check input exists
if [ ! -f "$INPUT" ]; then
ERRORS+=("Input file not found: $INPUT")
fi
# Check output != input (resolve symlinks and relative paths)
if [ -f "$INPUT" ]; then
REAL_INPUT=$(realpath "$INPUT" 2>/dev/null || echo "$INPUT")
REAL_OUTPUT=$(realpath -m "$OUTPUT" 2>/dev/null || echo "$OUTPUT")
if [ "$REAL_INPUT" = "$REAL_OUTPUT" ]; then
ERRORS+=("Output path equals input path — would destroy source file")
fi
fi
# Check output doesn't already exist
if [ -f "$OUTPUT" ]; then
WARNINGS+=("Output file already exists: $OUTPUT (use -y to overwrite)")
fi
# Check output directory exists
OUTPUT_DIR=$(dirname "$OUTPUT")
if [ ! -d "$OUTPUT_DIR" ]; then
WARNINGS+=("Output directory does not exist: $OUTPUT_DIR (will be created)")
fi
# Check disk space (estimate 2x input size as safety margin)
if [ -f "$INPUT" ]; then
INPUT_SIZE_KB=$(du -k "$INPUT" | cut -f1)
NEEDED_KB=$((INPUT_SIZE_KB * 2))
AVAIL_KB=$(df -k "$OUTPUT_DIR" 2>/dev/null | tail -1 | awk '{print $4}')
if [ -n "$AVAIL_KB" ] && [ "$AVAIL_KB" -lt "$NEEDED_KB" ]; then
WARNINGS+=("Low disk space: ${AVAIL_KB}KB available, estimated ${NEEDED_KB}KB needed")
fi
# GIF-specific: warn if input is very large
INPUT_SIZE_MB=$((INPUT_SIZE_KB / 1024))
if [ "$INPUT_SIZE_MB" -gt 100 ]; then
WARNINGS+=("Large input (${INPUT_SIZE_MB}MB) — GIF output may be very large. Consider trimming or reducing resolution.")
fi
fi
# Ensure temp directory exists
mkdir -p /tmp/claude-gif
# Build JSON output
PASS="true"
if [ ${#ERRORS[@]} -gt 0 ]; then
PASS="false"
fi
ERROR_JSON="[]"
if [ ${#ERRORS[@]} -gt 0 ]; then
ERROR_JSON=$(printf '%s\n' "${ERRORS[@]}" | jq -R . | jq -s .)
fi
WARN_JSON="[]"
if [ ${#WARNINGS[@]} -gt 0 ]; then
WARN_JSON=$(printf '%s\n' "${WARNINGS[@]}" | jq -R . | jq -s .)
fi
cat <<EOF
{
"pass": $PASS,
"input": "$INPUT",
"output": "$OUTPUT",
"errors": $ERROR_JSON,
"warnings": $WARN_JSON
}
EOF
[ "$PASS" = "true" ] && exit 0 || exit 1
#!/usr/bin/env bash
# Setup dependencies for claude-gif skill
# Usage: bash scripts/setup.sh [--check-only]
set -euo pipefail
CHECK_ONLY="${1:-}"
RESULTS=()
INSTALLED=0
FAILED=0
check_tool() {
local name="$1" cmd="$2" install="$3"
if command -v "$cmd" &>/dev/null; then
local ver
ver=$("$cmd" --version 2>&1 | head -1 || echo "unknown")
RESULTS+=("{\"name\":\"$name\",\"available\":true,\"version\":\"$ver\"}")
else
RESULTS+=("{\"name\":\"$name\",\"available\":false,\"install\":\"$install\"}")
if [ "$CHECK_ONLY" != "--check-only" ]; then
echo "Installing $name..."
if eval "$install" 2>/dev/null; then
((INSTALLED++))
else
((FAILED++))
echo "WARNING: Failed to install $name. Run manually: $install"
fi
fi
fi
}
check_python_pkg() {
local name="$1" import="$2"
local python_cmd="python3"
# Prefer video-skill venv if available
[ -f "$HOME/.video-skill/bin/python3" ] && python_cmd="$HOME/.video-skill/bin/python3"
if $python_cmd -c "import $import; print($import.__version__)" 2>/dev/null; then
local ver
ver=$($python_cmd -c "import $import; print($import.__version__)" 2>/dev/null)
RESULTS+=("{\"name\":\"$name\",\"available\":true,\"version\":\"$ver\"}")
else
RESULTS+=("{\"name\":\"$name\",\"available\":false,\"install\":\"pip install $name\"}")
fi
}
echo "=== claude-gif dependency check ==="
# Core tools
check_tool "ffmpeg" "ffmpeg" "sudo apt install -y ffmpeg"
check_tool "gifsicle" "gifsicle" "sudo apt install -y gifsicle"
check_tool "python3" "python3" "pyenv install 3.12"
check_tool "node" "node" "nvm install --lts"
check_tool "convert" "convert" "sudo apt install -y imagemagick"
# Python packages
check_python_pkg "Pillow" "PIL"
check_python_pkg "numpy" "numpy"
# Optional: Playwright for SVG rendering
if command -v npx &>/dev/null && npx playwright --version &>/dev/null 2>&1; then
RESULTS+=("{\"name\":\"playwright\",\"available\":true}")
else
RESULTS+=("{\"name\":\"playwright\",\"available\":false,\"install\":\"npx playwright install chromium\"}")
fi
# Create output directory
mkdir -p "$HOME/Documents/gif_output"
mkdir -p /tmp/claude-gif
# Build JSON output
echo ""
echo "{"
echo " \"status\": \"complete\","
echo " \"installed\": $INSTALLED,"
echo " \"failed\": $FAILED,"
echo " \"output_dir\": \"$HOME/Documents/gif_output\","
echo " \"temp_dir\": \"/tmp/claude-gif\","
echo " \"dependencies\": ["
for i in "${!RESULTS[@]}"; do
if [ "$i" -lt $((${#RESULTS[@]} - 1)) ]; then
echo " ${RESULTS[$i]},"
else
echo " ${RESULTS[$i]}"
fi
done
echo " ]"
echo "}"
Related skills
FAQ
What can claude-gif produce?
It creates programmatic animations, generates AI video-to-GIF, converts video/images/SVG to GIF, and optimizes or edits existing GIFs.
Does it size GIFs per platform?
Yes. It has quality presets for Discord, Slack, Twitter/X, web, and high-quality display.