summaryrefslogtreecommitdiff
path: root/scripts/record-gif.sh
blob: bd15413ccd8fa7f728d9d6c9396fe0738b897e49 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
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 "$@"