Skip to content

Instantly share code, notes, and snippets.

@rock3r
Created May 18, 2026 19:39
Show Gist options
  • Select an option

  • Save rock3r/2fe3d5b29f02a9c07bf937fa23602349 to your computer and use it in GitHub Desktop.

Select an option

Save rock3r/2fe3d5b29f02a9c07bf937fa23602349 to your computer and use it in GitHub Desktop.
Script that renders an animated SVG to an animated GIF
#!/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