summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-14 14:35:00 +0200
committerPaul Buetow <paul@buetow.org>2026-02-14 14:35:00 +0200
commitcacee90b045e64c75656618f06aa23cd7b9e0115 (patch)
treeda43629176e2cd9e1821aa4165435a632c0afa66 /scripts
parent8a895591820298b9b37d3f6c06c75cdb2d23806b (diff)
Add GIF recording helper script for Wayland and X11
Supports Wayland (grim + slurp for frame capture) and X11 (ffmpeg x11grab + xdotool). Converts to GIF via gifski or ffmpeg palettegen fallback. Includes cleanup trap, dependency checks, and configurable duration/fps/output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/record-gif.sh338
1 files changed, 338 insertions, 0 deletions
diff --git a/scripts/record-gif.sh b/scripts/record-gif.sh
new file mode 100755
index 0000000..bd15413
--- /dev/null
+++ b/scripts/record-gif.sh
@@ -0,0 +1,338 @@
+#!/bin/bash
+# record-gif.sh - Record a loadbars session as an animated GIF.
+#
+# Wayland: Uses grim for frame capture + slurp for region selection.
+# X11: Uses ffmpeg x11grab + xdotool for automatic window detection.
+# GIF conversion: gifski (preferred, high quality) or ffmpeg palettegen (fallback).
+#
+# Usage: ./scripts/record-gif.sh [options] [-- loadbars-args...]
+# Arguments before -- are for this script; arguments after -- are passed to loadbars.
+#
+# Examples:
+# ./scripts/record-gif.sh
+# ./scripts/record-gif.sh --duration 20 --output demo.gif -- --hosts host1,host2 --showcores
+# ./scripts/record-gif.sh --duration 15 --fps 15 -- --hosts localhost
+
+set -euo pipefail
+
+# Defaults
+DURATION=10
+OUTPUT="loadbars.gif"
+FPS=10
+LOADBARS_BIN="./loadbars"
+LOADBARS_ARGS=()
+TMPDIR_REC=""
+LOADBARS_PID=""
+IS_WAYLAND=false
+
+# Colored output to stderr so stdout stays clean
+info() { echo -e "\033[1;34m[info]\033[0m $*" >&2; }
+warn() { echo -e "\033[1;33m[warn]\033[0m $*" >&2; }
+error() { echo -e "\033[1;31m[error]\033[0m $*" >&2; }
+
+# Cleanup on exit/interrupt: kill loadbars, remove temp files
+cleanup() {
+ if [[ -n "$LOADBARS_PID" ]] && kill -0 "$LOADBARS_PID" 2>/dev/null; then
+ info "Stopping loadbars (PID $LOADBARS_PID)"
+ kill "$LOADBARS_PID" 2>/dev/null || true
+ wait "$LOADBARS_PID" 2>/dev/null || true
+ fi
+ if [[ -n "$TMPDIR_REC" && -d "$TMPDIR_REC" ]]; then
+ rm -rf "$TMPDIR_REC"
+ fi
+}
+trap cleanup EXIT INT TERM
+
+# --- Detect display server ---
+
+detect_display_server() {
+ if [[ "${XDG_SESSION_TYPE:-}" == "wayland" || -n "${WAYLAND_DISPLAY:-}" ]]; then
+ IS_WAYLAND=true
+ fi
+}
+
+# --- Dependency checks ---
+
+check_deps_wayland() {
+ local missing=()
+ command -v grim >/dev/null 2>&1 || missing+=("grim")
+ command -v slurp >/dev/null 2>&1 || missing+=("slurp")
+ command -v ffmpeg >/dev/null 2>&1 || missing+=("ffmpeg")
+
+ if (( ${#missing[@]} > 0 )); then
+ error "Missing required dependencies: ${missing[*]}"
+ error "Install with: sudo dnf install ${missing[*]}"
+ exit 1
+ fi
+}
+
+check_deps_x11() {
+ local missing=()
+ command -v ffmpeg >/dev/null 2>&1 || missing+=("ffmpeg")
+ command -v xdotool >/dev/null 2>&1 || missing+=("xdotool")
+
+ if (( ${#missing[@]} > 0 )); then
+ error "Missing required dependencies: ${missing[*]}"
+ error "Install with: sudo dnf install ${missing[*]}"
+ exit 1
+ fi
+}
+
+check_gifski() {
+ if ! command -v gifski >/dev/null 2>&1; then
+ warn "gifski not found — will use ffmpeg palettegen fallback (lower quality)."
+ warn "For better GIFs, install gifski: https://gif.ski"
+ fi
+}
+
+# --- Argument parsing ---
+
+parse_args() {
+ while (( $# > 0 )); do
+ case "$1" in
+ --duration) DURATION="$2"; shift 2 ;;
+ --output) OUTPUT="$2"; shift 2 ;;
+ --fps) FPS="$2"; shift 2 ;;
+ --bin) LOADBARS_BIN="$2"; shift 2 ;;
+ --) shift; LOADBARS_ARGS=("$@"); break ;;
+ -h|--help)
+ cat >&2 <<'HELP'
+Usage: record-gif.sh [options] [-- loadbars-args...]
+
+Options:
+ --duration SECS Recording duration (default: 10)
+ --output FILE Output GIF path (default: loadbars.gif)
+ --fps N Frames per second (default: 10)
+ --bin PATH Path to loadbars binary (default: ./loadbars)
+ -- Separator; remaining args passed to loadbars
+
+Wayland: uses grim + slurp (click to select the loadbars window)
+X11: uses ffmpeg x11grab + xdotool (automatic window detection)
+
+Examples:
+ record-gif.sh --duration 20 --output demo.gif -- --hosts h1,h2
+ record-gif.sh --fps 15 -- --hosts localhost --showcores
+HELP
+ exit 0
+ ;;
+ *)
+ error "Unknown option: $1 (use -- to separate loadbars args)"
+ exit 1
+ ;;
+ esac
+ done
+}
+
+# --- Wayland recording: grim frame capture ---
+
+# Prompt user to select the loadbars window region with slurp
+select_region_wayland() {
+ info "Click and drag to select the loadbars window region..."
+ local region
+ region=$(slurp 2>/dev/null) || {
+ error "Region selection cancelled."
+ exit 1
+ }
+ echo "$region"
+}
+
+# Capture frames with grim at the target fps for the given duration
+record_frames_wayland() {
+ local region="$1"
+ local framedir="$TMPDIR_REC/frames"
+ mkdir -p "$framedir"
+
+ local interval
+ interval=$(python3 -c "print(1.0 / $FPS)")
+ local total_frames=$(( DURATION * FPS ))
+
+ info "Recording ${DURATION}s at ${FPS} fps (${total_frames} frames)..."
+ local i=0
+ local start_time
+ start_time=$(date +%s%N)
+
+ while (( i < total_frames )); do
+ local frame_num
+ printf -v frame_num "%04d" "$i"
+ grim -g "$region" -t ppm "$framedir/frame${frame_num}.ppm" 2>/dev/null
+
+ i=$(( i + 1 ))
+
+ # Sleep to maintain target fps (subtract capture time from interval)
+ local now elapsed_ns target_ns sleep_s
+ now=$(date +%s%N)
+ elapsed_ns=$(( now - start_time ))
+ target_ns=$(( i * 1000000000 / FPS ))
+ if (( target_ns > elapsed_ns )); then
+ sleep_s=$(python3 -c "print(($target_ns - $elapsed_ns) / 1e9)")
+ sleep "$sleep_s"
+ fi
+ done
+
+ local end_time
+ end_time=$(date +%s%N)
+ local actual_duration=$(( (end_time - start_time) / 1000000 ))
+ info "Captured ${i} frames in ${actual_duration}ms."
+ echo "$framedir"
+}
+
+# --- X11 recording: ffmpeg x11grab ---
+
+# Wait for the loadbars SDL window to appear on X11
+wait_for_window_x11() {
+ local attempts=0
+ local max_attempts=50 # 50 * 100ms = 5 seconds
+ info "Waiting for Loadbars window..."
+ while (( attempts < max_attempts )); do
+ local wid
+ wid=$(xdotool search --name "Loadbars" 2>/dev/null | head -1) || true
+ if [[ -n "$wid" ]]; then
+ echo "$wid"
+ return 0
+ fi
+ sleep 0.1
+ (( attempts++ ))
+ done
+ error "Timed out waiting for Loadbars window (5s)."
+ return 1
+}
+
+# Record using ffmpeg x11grab targeting the window region
+record_x11() {
+ local wid="$1"
+ local raw_video="$TMPDIR_REC/capture.mp4"
+
+ eval "$(xdotool getwindowgeometry --shell "$wid")"
+ info "Window geometry: ${WIDTH}x${HEIGHT} at +${X}+${Y}"
+
+ local display="${DISPLAY:-:0}"
+ info "Recording ${DURATION}s at ${FPS} fps..."
+ ffmpeg -loglevel warning \
+ -video_size "${WIDTH}x${HEIGHT}" \
+ -framerate "$FPS" \
+ -f x11grab \
+ -i "${display}+${X},${Y}" \
+ -t "$DURATION" \
+ -c:v libx264 -preset ultrafast -crf 0 \
+ -y "$raw_video"
+
+ echo "$raw_video"
+}
+
+# --- GIF conversion ---
+
+# Convert PPM frames (from Wayland grim capture) to GIF
+frames_to_gif() {
+ local framedir="$1"
+ local output="$2"
+ local frame_count
+ frame_count=$(ls "$framedir"/frame*.ppm 2>/dev/null | wc -l)
+
+ if (( frame_count == 0 )); then
+ error "No frames captured."
+ exit 1
+ fi
+
+ if command -v gifski >/dev/null 2>&1; then
+ info "Converting ${frame_count} frames to GIF with gifski..."
+ # gifski needs PNG input, convert from PPM
+ local pngdir="$TMPDIR_REC/png"
+ mkdir -p "$pngdir"
+ ffmpeg -loglevel warning -framerate "$FPS" -i "$framedir/frame%04d.ppm" \
+ "$pngdir/frame%04d.png"
+ gifski --repeat 0 --fps "$FPS" --quality 90 -o "$output" "$pngdir"/frame*.png
+ else
+ # Use ffmpeg palettegen+paletteuse
+ info "Converting ${frame_count} frames to GIF with ffmpeg..."
+ local palette="$TMPDIR_REC/palette.png"
+ ffmpeg -loglevel warning -framerate "$FPS" -i "$framedir/frame%04d.ppm" \
+ -vf "palettegen=stats_mode=full" \
+ -update 1 -frames:v 1 -y "$palette"
+ ffmpeg -loglevel warning -framerate "$FPS" -i "$framedir/frame%04d.ppm" -i "$palette" \
+ -filter_complex "[0:v][1:v]paletteuse=dither=sierra2_4a" \
+ -loop 0 -y "$output"
+ fi
+}
+
+# Convert video file (from X11 ffmpeg capture) to GIF
+video_to_gif() {
+ local input="$1"
+ local output="$2"
+
+ if command -v gifski >/dev/null 2>&1; then
+ info "Converting to GIF with gifski..."
+ local framedir="$TMPDIR_REC/frames"
+ mkdir -p "$framedir"
+ ffmpeg -loglevel warning -i "$input" -vf "fps=$FPS" "$framedir/frame%04d.png"
+ gifski --repeat 0 --fps "$FPS" --quality 90 -o "$output" "$framedir"/frame*.png
+ else
+ info "Converting to GIF with ffmpeg..."
+ local palette="$TMPDIR_REC/palette.png"
+ ffmpeg -loglevel warning -i "$input" \
+ -vf "fps=$FPS,palettegen=stats_mode=full" \
+ -update 1 -frames:v 1 -y "$palette"
+ ffmpeg -loglevel warning -i "$input" -i "$palette" \
+ -filter_complex "[0:v]fps=$FPS[x];[x][1:v]paletteuse=dither=sierra2_4a" \
+ -loop 0 -y "$output"
+ fi
+}
+
+# --- Main ---
+
+main() {
+ parse_args "$@"
+ detect_display_server
+ check_gifski
+
+ # Verify loadbars binary exists
+ if [[ ! -x "$LOADBARS_BIN" ]]; then
+ error "Loadbars binary not found or not executable: $LOADBARS_BIN"
+ error "Build it first: mage build (or: go build -o loadbars ./cmd/loadbars)"
+ exit 1
+ fi
+
+ TMPDIR_REC=$(mktemp -d /tmp/loadbars-rec.XXXXXX)
+
+ # Launch loadbars in background
+ info "Starting loadbars: $LOADBARS_BIN ${LOADBARS_ARGS[*]:-}"
+ "$LOADBARS_BIN" "${LOADBARS_ARGS[@]:+${LOADBARS_ARGS[@]}}" &
+ LOADBARS_PID=$!
+ sleep 2 # give loadbars time to open its window
+
+ if $IS_WAYLAND; then
+ check_deps_wayland
+
+ # User selects the loadbars window region interactively
+ local region
+ region=$(select_region_wayland)
+ info "Selected region: $region"
+
+ # Capture frames with grim
+ local framedir
+ framedir=$(record_frames_wayland "$region")
+
+ # Convert frames to GIF
+ frames_to_gif "$framedir" "$OUTPUT"
+ else
+ check_deps_x11
+
+ # Automatically find the loadbars window via xdotool
+ local wid
+ wid=$(wait_for_window_x11) || exit 1
+
+ # Record with ffmpeg x11grab (software rendering for capture compatibility)
+ export SDL_RENDER_DRIVER=software
+ local raw_video
+ raw_video=$(record_x11 "$wid")
+
+ # Convert video to GIF
+ video_to_gif "$raw_video" "$OUTPUT"
+ fi
+
+ # Report result
+ local size
+ size=$(du -h "$OUTPUT" | cut -f1)
+ info "Done! Output: $OUTPUT ($size)"
+}
+
+main "$@"