ffgrab — Minimal X11 Screen Recorder (Region + Fullscreen, FFmpeg-powered)

Get help with XFCE specific problems
User avatar
M. Eerie
Moderator
Moderator
Posts: 746
Joined: 31 Aug 2017, 21:18
Distribution: Nemesis/Porteux XFCE4

ffgrab — Minimal X11 Screen Recorder (Region + Fullscreen, FFmpeg-powered)

Post#1 by M. Eerie » 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
  1. Trigger via panel (Genmon)
  2. Select region visually
  3. Recording starts after a short delay
  4. Timer notification updates every second
  5. Stop recording → clean shutdown
  6. 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
Also the icons needed to represent RECORD and STOP status in the panel:

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>
ffgrab-stop.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>
Feedback allways welcome 👍

Needless to say, I had someoneGPT help me polish the script and translate this :oops:
> Does not compute_ 🖖

https://forum.porteus.org/viewtopic.php?p=94310#p94310
https://forum.porteus.org/viewtopic.php?p=102066#p102066
https://forum.porteus.org/viewtopic.php?p=102306#p102306
https://forum.porteus.org/viewtopic.php?p=72741#p72741