Created
May 18, 2026 19:39
-
-
Save rock3r/2fe3d5b29f02a9c07bf937fa23602349 to your computer and use it in GitHub Desktop.
Script that renders an animated SVG to an animated GIF
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env zsh | |
| # "THE BEER-WARE LICENSE" (Revision 42): | |
| # Seb wrote this file. As long as you retain this notice you | |
| # can do whatever you want with this stuff. If we meet some day, and you think | |
| # this stuff is worth it, you can buy me a beer in return Poul-Henning Kamp | |
| # Default Configurations | |
| FPS=30 | |
| DURATION=0 # 0 means auto-detect via LCM | |
| PADDING=5 | |
| LIB_DIR="$HOME/.local/lib/svg2gif" | |
| print_usage() { | |
| cat <<EOF | |
| Usage: $(basename "$0") [options] <input.svg> [output.gif] | |
| Converts a CSS-animated SVG to a perfectly looping, auto-cropped transparent GIF. | |
| Automatically detects the perfect loop length and out-of-bounds bounding box using the Web Animations API. | |
| Options: | |
| -f, --fps <number> Frames per second (default: $FPS) | |
| -d, --duration <seconds> Override auto-detected loop length | |
| -p, --padding <pixels> Padding around the animated bounding box (default: $PADDING) | |
| --help Show this help message | |
| Example: | |
| $(basename "$0") -f 60 -p 10 -d 2.5 input.svg | |
| EOF | |
| } | |
| check_deps() { | |
| local missing=0 | |
| if ! command -v node >/dev/null 2>&1; then echo "Missing dependency: node"; missing=1; fi | |
| if ! command -v npm >/dev/null 2>&1; then echo "Missing dependency: npm"; missing=1; fi | |
| if ! command -v ffmpeg >/dev/null 2>&1; then echo "Missing dependency: ffmpeg"; missing=1; fi | |
| if (( missing )); then | |
| echo "\nSetup Instructions:\n1. Run: brew install node ffmpeg" | |
| exit 1 | |
| fi | |
| if [[ ! -d "$LIB_DIR/node_modules/puppeteer" ]]; then | |
| echo "Setting up isolated Puppeteer environment..." | |
| mkdir -p "$LIB_DIR" && cd "$LIB_DIR" || exit 1 | |
| npm install puppeteer --silent || { echo "Failed to install puppeteer"; exit 1; } | |
| cd - >/dev/null | |
| fi | |
| } | |
| while [[ "$#" -gt 0 ]]; do | |
| case $1 in | |
| -f|--fps) FPS="$2"; shift 2 ;; | |
| -d|--duration) DURATION="$2"; shift 2 ;; | |
| -p|--padding) PADDING="$2"; shift 2 ;; | |
| --help) print_usage; exit 0 ;; | |
| -*) echo "Unknown parameter: $1"; print_usage; exit 1 ;; | |
| *) break ;; | |
| esac | |
| done | |
| INPUT_SVG="$1" | |
| OUTPUT_GIF="${2:-${INPUT_SVG%.*}.gif}" | |
| if [[ -z "$INPUT_SVG" || ! -f "$INPUT_SVG" ]]; then | |
| print_usage | |
| exit 1 | |
| fi | |
| check_deps | |
| INPUT_ABS=$(cd "$(dirname "$INPUT_SVG")"; pwd)/$(basename "$INPUT_SVG") | |
| TMP_DIR=$(mktemp -d) | |
| HTML_WRAPPER="$TMP_DIR/wrapper.html" | |
| NODE_SCRIPT="$TMP_DIR/render.js" | |
| # 1. Safely wrap the SVG in an HTML document to bypass Chromium's root-document rendering limits | |
| cat <<EOF > "$HTML_WRAPPER" | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <base href="file://$(dirname "$INPUT_ABS")/"> | |
| <style> | |
| body { margin: 0; padding: 1000px; background: transparent; } | |
| svg { overflow: visible !important; } | |
| </style> | |
| </head> | |
| <body> | |
| EOF | |
| # Append the raw SVG safely | |
| cat "$INPUT_ABS" >> "$HTML_WRAPPER" | |
| cat <<EOF >> "$HTML_WRAPPER" | |
| </body> | |
| </html> | |
| EOF | |
| # 2. Generate the Puppeteer script | |
| cat <<EOF > "$NODE_SCRIPT" | |
| const puppeteer = require('$LIB_DIR/node_modules/puppeteer'); | |
| const path = require('path'); | |
| (async () => { | |
| const browser = await puppeteer.launch({ headless: true }); | |
| const page = await browser.newPage(); | |
| // Set a massive initial viewport | |
| await page.setViewport({ width: 4000, height: 4000 }); | |
| // Load the HTML wrapper instead of the raw SVG file | |
| await page.goto('file://$HTML_WRAPPER', { waitUntil: 'networkidle0' }); | |
| const config = await page.evaluate((fps, explicitDuration, padding) => { | |
| let warningMsg = null; | |
| const svg = document.querySelector('svg'); | |
| if (svg) { | |
| // Hunt down top-level artboard clip-paths | |
| const rootElements = [svg, ...svg.children]; | |
| rootElements.forEach(el => { | |
| if (el.hasAttribute('clip-path')) { | |
| const match = el.getAttribute('clip-path').match(/#([^)"]+)/); | |
| if (match) { | |
| const clipId = match[1]; | |
| const clipNode = document.getElementById(clipId); | |
| if (clipNode && clipNode.children.length === 1 && clipNode.children[0].tagName.toLowerCase() === 'rect') { | |
| el.removeAttribute('clip-path'); | |
| warningMsg = '\x1b[33mWarning: Automatically removed top-level artboard clip-path (' + clipId + ') to prevent cropping.\x1b[0m'; | |
| } else { | |
| warningMsg = '\x1b[33mWarning: Found complex top-level clip-path (' + clipId + '). If your GIF is cropped, remove it manually.\x1b[0m'; | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| const anims = document.getAnimations(); | |
| anims.forEach(a => a.pause()); | |
| let durationMs = explicitDuration * 1000; | |
| if (!durationMs) { | |
| const durs = anims.map(a => a.effect.getTiming().duration).filter(d => Number.isFinite(d) && d > 0); | |
| if (durs.length > 0) { | |
| const gcd = (a, b) => b === 0 ? a : gcd(b, a % b); | |
| const lcm = (a, b) => (a * b) / gcd(a, b); | |
| const uniqueDurs = [...new Set(durs.map(d => Math.round(d / 10) * 10))]; | |
| durationMs = uniqueDurs.reduce((acc, val) => lcm(acc, val), uniqueDurs[0]); | |
| if (durationMs > 10000) durationMs = Math.max(...uniqueDurs); | |
| } else { | |
| durationMs = 1000; | |
| } | |
| } | |
| const totalFrames = Math.ceil((durationMs / 1000) * fps); | |
| let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; | |
| // Scrub the timeline | |
| for (let frame = 0; frame <= totalFrames; frame++) { | |
| const t = (frame / fps) * 1000; | |
| anims.forEach(a => a.currentTime = t); | |
| const elements = document.querySelectorAll('svg, svg *'); | |
| for (const el of elements) { | |
| if (!el.getBoundingClientRect) continue; | |
| const rect = el.getBoundingClientRect(); | |
| if (rect.width === 0 && rect.height === 0) continue; | |
| if (rect.left < minX) minX = rect.left; | |
| if (rect.top < minY) minY = rect.top; | |
| if (rect.right > maxX) maxX = rect.right; | |
| if (rect.bottom > maxY) maxY = rect.bottom; | |
| } | |
| } | |
| if (minX === Infinity) { | |
| minX = 1000; minY = 1000; maxX = 1800; maxY = 1800; | |
| } | |
| return { | |
| durationMs, | |
| totalFrames, | |
| warningMsg, | |
| clip: { | |
| x: Math.max(0, minX - padding), | |
| y: Math.max(0, minY - padding), | |
| width: (maxX - minX) + (padding * 2), | |
| height: (maxY - minY) + (padding * 2) | |
| } | |
| }; | |
| }, $FPS, $DURATION, $PADDING); | |
| if (config.warningMsg) console.log(config.warningMsg); | |
| console.log('Calculated duration: ' + config.durationMs + 'ms (' + config.totalFrames + ' frames)'); | |
| console.log('Calculated clip bounds: ' + Math.round(config.clip.width) + 'x' + Math.round(config.clip.height)); | |
| // Render loop | |
| for (let frame = 1; frame <= config.totalFrames; frame++) { | |
| const t = (frame / $FPS) * 1000; | |
| await page.evaluate((time) => { | |
| document.getAnimations().forEach(a => a.currentTime = time); | |
| }, t); | |
| const framePath = path.join('$TMP_DIR', 'frame_' + String(frame).padStart(3, '0') + '.png'); | |
| await page.screenshot({ path: framePath, omitBackground: true, clip: config.clip }); | |
| } | |
| await browser.close(); | |
| })(); | |
| EOF | |
| echo "Starting Puppeteer..." | |
| node "$NODE_SCRIPT" | |
| echo "Encoding GIF..." | |
| ffmpeg -v warning -framerate "$FPS" -i "$TMP_DIR/frame_%03d.png" \ | |
| -filter_complex "split[s0][s1];[s0]palettegen=reserve_transparent=on:transparency_color=ffffff[p];[s1][p]paletteuse" \ | |
| -loop 0 "$OUTPUT_GIF" -y | |
| rm -rf "$TMP_DIR" | |
| echo "Success: $OUTPUT_GIF" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment