ffgrab — Minimal X11 Screen Recorder (Region + Fullscreen, FFmpeg-powered)
Posted: 28 Apr 2026, 10:40
I’ve been working on a lightweight screen recording script for Linux (XFCE/X11) and finally got it to a point where it’s clean, reliable, and actually pleasant to use. Sharing it in case it’s useful to others.
ffgrab
Features
Region selection using slop (with visual highlight)
Fullscreen capture option
Live recording timer via notifications
Audio capture (PulseAudio)
Post-recording stats
File size
Duration
State tracking (robust across async calls)
🧩 Designed to work nicely with XFCE Genmon plugin
Tech stack (DEPENDENCIES)
ffmpeg (x11grab + libx264 / libopus)
slop (region selection)
notify-send (UI feedback)
ffprobe (stats extraction)
Key design details
Uses /tmp state files to track:
active PID
start time
output file path
Avoids race conditions when stopping recording
Ensures file is fully written before stats are shown
Works cleanly with set -euo pipefail (no silent failures)
🧪 Example workflow
Future ideas
* Hardware encoding (VAAPI / NVENC)
* Optional overlay (dimensions, guides, etc.)
ffgrab script follows:
Howto implement this thing in your XFCE4 panel:
Save this companion as say.. ~/.local/bin/ffgrab-status and attach-it to a new genmon-plugin panel element
Also the icons needed to represent RECORD and STOP status in the panel:
ffgrab-rec.svg
ffgrab-stop.svg
Feedback allways welcome 
Needless to say, I had someoneGPT help me polish the script and translate this
ffgrab
Features
Region selection using slop (with visual highlight)
Fullscreen capture option
Live recording timer via notifications
Audio capture (PulseAudio)
Post-recording statsFile size
Duration
State tracking (robust across async calls)🧩 Designed to work nicely with XFCE Genmon plugin
Tech stack (DEPENDENCIES)ffmpeg (x11grab + libx264 / libopus)
slop (region selection)
notify-send (UI feedback)
ffprobe (stats extraction)
Key design detailsUses /tmp state files to track:
active PID
start time
output file path
Avoids race conditions when stopping recording
Ensures file is fully written before stats are shown
Works cleanly with set -euo pipefail (no silent failures)
🧪 Example workflow
- Trigger via panel (Genmon)
- Select region visually
- Recording starts after a short delay
- Timer notification updates every second
- Stop recording → clean shutdown
- Final notification shows:
- path
- size
- duration
Future ideas* Hardware encoding (VAAPI / NVENC)
* Optional overlay (dimensions, guides, etc.)
ffgrab script follows:
Code: Select all
#!/usr/bin/env bash
set -euo pipefail
PIDFILE="/tmp/ffgrab.pid"
STARTFILE="/tmp/ffgrab.start"
STATEFILE="/tmp/ffgrab.outfile"
OUTDIR="$HOME" #~/Videos
OUTFILE=""
FPS=30
CRF=23
PRESET="veryfast"
ABR=(-b:a 96k)
#TODO: 🎥 Hardware encoding (significant performance improvement)
# Check dependencies ffmpeg slop xdpyinfo & xfce4-genmon-plugin
for cmd in ffmpeg slop xdpyinfo; do command -v $cmd &> /dev/null || exit 1; done
[ -f /usr/lib/xfce4/panel/plugins/libgenmon.so ] || exit 1
mkdir -p "$OUTDIR"
notify() {
notify-send -u low -t 5000 "$1"
}
update_timer() {
while true; do
PID=$(cat "$PIDFILE" 2>/dev/null) || break
[[ -n "$PID" ]] || break
kill -0 "$PID" 2>/dev/null || break
START=$(cat "$STARTFILE" 2>/dev/null || date +%s)
NOW=$(date +%s)
ELAPSED=$((NOW - START))
TIME=$(printf '%02d:%02d' $((ELAPSED/60)) $((ELAPSED%60)))
notify-send -r 9999 -t 1100 "🔴 REC $TIME"
sleep 1
done
}
show_stats() {
local file
file="$(cat "$STATEFILE" 2>/dev/null)"
if [[ -z "$file" || ! -f "$file" ]]; then
notify-send -t 3000 "⚠️ Recording canceled" "No file was created"
return
fi
# wait real flush
for i in {1..20}; do
[[ -s "$file" ]] && break
sleep 0.2
done
if [[ ! -s "$file" ]]; then
notify-send -t 3000 "⚠️ Recording canceled" "Incomplete file"
return
fi
local SIZE DURATION MINS SECS
SIZE=$(ls -lh "$file" 2>/dev/null | awk '{print $5}')
DURATION=$(ffprobe -v error \
-show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 \
"$file" 2>/dev/null || echo "")
DURATION=${DURATION%.*}
if [[ -n "$DURATION" ]]; then
MINS=$((DURATION / 60))
SECS=$((DURATION % 60))
notify-send -i video-x-generic -t 0 \
"✅ Recording finished" \
"📁 ${file}\n💾 ${SIZE}\n⏱️ ${MINS}m ${SECS}s"
else
notify-send -i video-x-generic -t 5000 \
"✅ Recording finished" \
"📁 ${file}\n💾 ${SIZE}\n📍 ${OUTDIR}"
fi
}
align8() {
local v=$1
echo $(( (v + 7) & ~7 ))
}
record_region() {
OUTFILE="$OUTDIR/rec_$(date +"%Y-%m-%d_%H-%M-%S").mp4"
echo "$OUTFILE" > "$STATEFILE"
GEO=$(slop --format "%w %h %x %y" \
--bordersize=5 \
--nodecorations=1 \
--tolerance=5 \
--highlight \
--color=0.3,0.4,1.0,0.4)
if [[ -z "$GEO" ]]; then
notify "❌ Canceled"
return 0
fi
read -r W H X Y <<< "$GEO"
W8=$(align8 "$W")
H8=$(align8 "$H")
notify "📐 ${W}x${H} → ${W8}x${H8}
Starting recording"
sleep 5
FFPARAMS=(
-f x11grab
-framerate "$FPS"
-video_size "${W8}x${H8}"
-i ":0.0+${X},${Y}"
-f pulse -i default
-c:v libx264
-crf "$CRF"
-preset "$PRESET"
-c:a libopus
"${ABR[@]}"
"$OUTFILE"
)
PID=$(cat "$PIDFILE" 2>/dev/null || true)
if [[ -n "${PID:-}" ]] && kill -0 "$PID" 2>/dev/null; then
notify "⚠️ A recording is already in progress"
return 1
fi
date +%s > "$STARTFILE"
ffmpeg "${FFPARAMS[@]}" >/dev/null 2>&1 &
echo $! > "$PIDFILE"
update_timer &
echo $! > /tmp/ffgrab.timer.pid
}
record_screen() {
OUTFILE="$OUTDIR/rec_$(date +"%Y-%m-%d_%H-%M-%S").mp4"
echo "$OUTFILE" > "$STATEFILE"
GEO=$(xdpyinfo | awk '/dimensions/{print $2}')
W=${GEO%x*}
H=${GEO#*x}
FFPARAMS=(
-f x11grab
-framerate "$FPS"
-video_size "${W}x${H}"
-i ":0.0+0,0"
-f pulse -i default
-c:v libx264
-crf "$CRF"
-preset "$PRESET"
-c:a libopus
"${ABR[@]}"
"$OUTFILE"
)
PID=$(cat "$PIDFILE" 2>/dev/null || true)
if [[ -n "${PID:-}" ]] && kill -0 "$PID" 2>/dev/null; then
notify "⚠️ A recording is already in progress"
exit 1
fi
date +%s > "$STARTFILE"
ffmpeg "${FFPARAMS[@]}" >/dev/null 2>&1 &
echo $! > "$PIDFILE"
update_timer &
echo $! > /tmp/ffgrab.timer.pid
}
toggle() {
PID=$(cat "$PIDFILE" 2>/dev/null || true)
if [[ -n "${PID:-}" ]] && kill -0 "$PID" 2>/dev/null; then
stop_record
else
record_region
fi
}
stop_record() {
if [[ -f "$PIDFILE" ]]; then
PID=$(cat "$PIDFILE" 2>/dev/null || true)
if [[ -n "$PID" ]]; then
kill -INT "$PID" 2>/dev/null
while kill -0 "$PID" 2>/dev/null; do
sleep 0.1
done
fi
if [[ -f /tmp/ffgrab.timer.pid ]]; then
kill "$(cat /tmp/ffgrab.timer.pid)" 2>/dev/null
rm -f /tmp/ffgrab.timer.pid
fi
notify "🟥 STOP"
sleep 0.5
show_stats
rm -f "$PIDFILE" "$STARTFILE" "$STATEFILE"
else
notify "No active recording"
fi
}
trap 'stop_record' INT TERM
case "$1" in
region|-r) record_region ;;
screen|-s) record_screen ;;
stop|-q) stop_record ;;
toggle|-t) toggle ;;
*) echo "Usage: ffgrab {region|screen|stop|toggle}" ;;
esac
Howto implement this thing in your XFCE4 panel:
Save this companion as say.. ~/.local/bin/ffgrab-status and attach-it to a new genmon-plugin panel element
Code: Select all
#!/usr/bin/env bash
# ffgrab-status
# switches genmon status icon
if [[ -f "/tmp/ffgrab.pid" ]]; then
# REC on: Icono de stop detiene al clicar
echo "<img>$HOME/.local/share/icons/ffgrab-stop.svg</img><click>$HOME/.local/bin/ffgrab -q</click>"
else
# REC off: Icono de rec inicia al clicar
echo "<img>$HOME/.local/share/icons/ffgrab-rec.svg</img><click>$HOME/.local/bin/ffgrab -r</click>"
fi
ffgrab-rec.svg
Code: Select all
<svg width="18" height="18" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="9" fill="#FF4444"/>
</svg>
Code: Select all
<svg width="18" height="18" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="4" width="18" height="18" rx="3" ry="3" fill="#FF4444"/>
</svg>

Needless to say, I had someoneGPT help me polish the script and translate this