← Home

Rofi screen recording with zoom

2026-01-17

Rofirecord demo

Screen recording with rofi menu, VAAPI hardware acceleration, and smooth zoom animations.

#!/bin/bash

# ==============================================================================
# ZOOM CONFIGURATION
# ==============================================================================
# Temporary workspace (will be created and cleaned up)
ZOOM_WORK_DIR="/tmp/video_zoom_session"
ZOOM_LOCK_FILE="/tmp/video_zoom.pid" # Internal lock for zoom components

# Internal Files for Zoom
ZOOM_LOG_MOUSE="$ZOOM_WORK_DIR/mouse.log"
ZOOM_LOG_KEYS="$ZOOM_WORK_DIR/keys.log"
ZOOM_LOG_COORD="$ZOOM_WORK_DIR/coords.txt"
ZOOM_VIDEO_RAW="$ZOOM_WORK_DIR/raw.mp4"
ZOOM_CMD_FILE="$ZOOM_WORK_DIR/overlay.cmd"
ZOOM_DEBUG_LOG="/tmp/video_zoom_debug.log"

# ==============================================================================
# SHARED HELPERS
# ==============================================================================

zoom_log_debug() {
    echo "[$(date '+%H:%M:%S')] $1" >> "$ZOOM_DEBUG_LOG"
}

detect_monitor() {
    # Get mouse position
    eval $(xdotool getmouselocation --shell)
    # Default to main screen if detection fails
    local w h x y
    local DETECTED_W=1920 DETECTED_H=1080 DETECTED_X=0 DETECTED_Y=0
    
    # Check each connected monitor
    # Format: 1920x1080+0+0
    while read -r geom; do
        w=$(echo "$geom" | cut -dx -f1)
        h=$(echo "$geom" | cut -dx -f2 | cut -d+ -f1)
        x=$(echo "$geom" | cut -d+ -f2)
        y=$(echo "$geom" | cut -d+ -f3)
        
        # Check if mouse is within this monitor's bounds
        if (( X >= x && X < x + w && Y >= y && Y < y + h )); then
            DETECTED_W=$w
            DETECTED_H=$h
            DETECTED_X=$x
            DETECTED_Y=$y
            break
        fi
    done < <(xrandr | grep " connected" | grep -oE '[0-9]+x[0-9]+\+[0-9]+\+[0-9]+')
    
    # Return values (only this goes to stdout)
    echo "DETECTED_W=$DETECTED_W DETECTED_H=$DETECTED_H DETECTED_X=$DETECTED_X DETECTED_Y=$DETECTED_Y"
}

screencast() {
    # 1. Detect Monitor
    eval $(detect_monitor)
    
    local outfile="$HOME/screencast-$(date '+%y%m%d-%H%M-%S').mp4"
    
    # 2. Try VAAPI first, fallback to software
    if [ -e /dev/dri/renderD128 ]; then
        ffmpeg -y \
        -f x11grab \
        -framerate 30 \
        -video_size "${DETECTED_W}x${DETECTED_H}" \
        -i ":0.0+${DETECTED_X},${DETECTED_Y}" \
        -f alsa -i default \
        -vaapi_device /dev/dri/renderD128 \
        -vf 'format=nv12,hwupload' \
        -c:v h264_vaapi -qp 24 \
        -c:a aac \
        "$outfile" >> "$ZOOM_DEBUG_LOG" 2>&1 &
    else
        ffmpeg -y \
        -f x11grab \
        -framerate 30 \
        -video_size "${DETECTED_W}x${DETECTED_H}" \
        -i ":0.0+${DETECTED_X},${DETECTED_Y}" \
        -f alsa -i default \
        -c:v libx264 -preset ultrafast -crf 23 \
        -c:a aac \
        "$outfile" >> "$ZOOM_DEBUG_LOG" 2>&1 &
    fi
    
    echo $! > /tmp/recordingpid
}

killrecording() {
    if [ -f /tmp/recordingpid ]; then
        recpid="$(cat /tmp/recordingpid)"
        kill -15 "$recpid" 2>/dev/null
        rm -f /tmp/recordingpid
        sleep 2
        kill -9 "$recpid" 2>/dev/null
        notify-send "Recording" "Recording ended."
    fi
    exit
}

# ==============================================================================
# ZOOM LOGIC
# ==============================================================================

zoom_process() {
    notify-send "Video Zoom" "Processing video... This may take a moment."
    zoom_log_debug "Processing started."
    
    if [ ! -f "$ZOOM_VIDEO_RAW" ] || [ ! -f "$ZOOM_LOG_COORD" ]; then
        notify-send "Video Zoom" "Error: Missing raw files."
        zoom_log_debug "Error: Missing raw files."
        exit 1
    fi
    
    # Check if any keys were pressed
    KEY_COUNT=$(grep -c "KEY" "$ZOOM_LOG_COORD")
    zoom_log_debug "Found $KEY_COUNT key toggle events."
    if [ "$KEY_COUNT" -eq "0" ]; then
        notify-send "Video Zoom" "Warning: No zoom toggles detected."
    fi
    
    # Detect Dimensions - parse simply
    VID_W=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of default=noprint_wrappers=1:nokey=1 "$ZOOM_VIDEO_RAW")
    VID_H=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of default=noprint_wrappers=1:nokey=1 "$ZOOM_VIDEO_RAW")
    
    # Fallback
    if [ -z "$VID_W" ]; then
        zoom_log_debug "ffprobe failed to detect width. Defaulting to 1920."
        VID_W=1920
        VID_H=1080
    fi
    
    zoom_log_debug "Raw video dimensions: ${VID_W}x${VID_H}"
    
    # Calculate zoom target size (1/3 of video dimensions, maintaining aspect ratio)
    ZOOM_W=$((VID_W / 3))
    ZOOM_H=$((VID_H / 3))
    
    # Generate Animation Commands
    awk -v vid_w="$VID_W" -v vid_h="$VID_H" -v target_w="$ZOOM_W" -v target_h="$ZOOM_H" '
    BEGIN {
        anim_duration = 0.3;
        zoom_active = 0; zoom_level = 0.0; last_ts = 0;
        smooth_len = 5; idx = 0;
        for(i=0; i<smooth_len; i++) { x_buf[i]=vid_w/2; y_buf[i]=vid_h/2; }
        last_key_time = -1.0;
        zoom_start_time = 0;
    }
    
    function ease(t) {
        if (t <= 0) return 0;
        if (t >= 1) return 1;
        return t * t * (3 - 2 * t);
    }
    
    $2 == "KEY" {
        # Simple debounce
        if ($1 - last_key_time > 0.3) {
            zoom_active = 1;
            zoom_start_time = $1;
            last_key_time = $1;
        }
    }
    
    $2 == "MOUSE" {
        ts = $1; raw_x = $3; raw_y = $4;
        dt = ts - last_ts; last_ts = ts;
        if (last_ts == 0) dt = 0;
        
        # Auto zoom-out after 1 second
        if (zoom_active && (ts - zoom_start_time) > 1.0) {
            zoom_active = 0;
        }
        
        step = dt / anim_duration;
        if (zoom_active) { zoom_level += step; if (zoom_level > 1.0) zoom_level = 1.0; }
        else { zoom_level -= step; if (zoom_level < 0.0) zoom_level = 0.0; }
        
        z_factor = ease(zoom_level);
        
        # Smoothing
        x_buf[idx] = raw_x; y_buf[idx] = raw_y; idx = (idx + 1) % smooth_len;
        sum_x=0; sum_y=0; count=0;
        for (i in x_buf) { sum_x += x_buf[i]; sum_y += y_buf[i]; count++; }
        avg_x = sum_x/count; avg_y = sum_y/count;
        
        # Geometry Math
        cur_w = vid_w + (target_w - vid_w) * z_factor;
        cur_h = vid_h + (target_h - vid_h) * z_factor;
        
        c1_x = vid_w / 2; c1_y = vid_h / 2;
        cur_cx = c1_x + (avg_x - c1_x) * z_factor;
        cur_cy = c1_y + (avg_y - c1_y) * z_factor;
        
        out_x = cur_cx - (cur_w / 2);
        out_y = cur_cy - (cur_h / 2);
        
        if (out_x < 0) out_x = 0; if (out_x > vid_w - cur_w) out_x = vid_w - cur_w;
        if (out_y < 0) out_y = 0; if (out_y > vid_h - cur_h) out_y = vid_h - cur_h;
        
        printf "%f crop@anim w %d;\n%f crop@anim h %d;\n%f crop@anim x %d;\n%f crop@anim y %d;\n", ts, int(cur_w), ts, int(cur_h), ts, int(out_x), ts, int(out_y);
    }
    ' "$ZOOM_LOG_COORD" > "$ZOOM_CMD_FILE"
    
    # Generate timestamp for output
    FINAL_OUTPUT="$HOME/zoom_capture_$(date +%Y%m%d_%H%M%S).mp4"
    zoom_log_debug "Output target: $FINAL_OUTPUT"
    zoom_log_debug "Running FFmpeg..."
    
    # FFmpeg Render - scale to original resolution
    ffmpeg -y \
        -i "$ZOOM_VIDEO_RAW" \
        -filter_complex "[0:v] sendcmd=f='$ZOOM_CMD_FILE', crop@anim=w=$VID_W:h=$VID_H:x=0:y=0, scale=${VID_W}:${VID_H} [out]" \
        -map "[out]" -map 0:a? -c:v libx264 -crf 23 -preset fast -c:a copy "$FINAL_OUTPUT" 2>> "$ZOOM_DEBUG_LOG"
    
    RET=$?
    zoom_log_debug "FFmpeg exit code: $RET"
    
    if [ $RET -eq 0 ] && [ -f "$FINAL_OUTPUT" ]; then
        notify-send "Video Zoom" "Success! Saved to: $FINAL_OUTPUT"
        zoom_log_debug "Success. Output: $FINAL_OUTPUT"
        rm -rf "$ZOOM_WORK_DIR"
    else
        notify-send "Video Zoom" "Processing Failed. Check $ZOOM_DEBUG_LOG"
        zoom_log_debug "FFmpeg failed or output missing."
    fi
}

zoom_start() {
    # Ensure Work Dir Exists
    mkdir -p "$ZOOM_WORK_DIR"
    zoom_log_debug "Starting new recording session..."
    
    # 1. Detect Monitor Geometry
    eval $(detect_monitor)
    
    # Verify detection
    if [ -z "$DETECTED_W" ] || [ "$DETECTED_W" -eq 0 ]; then
        DETECTED_W=1920; DETECTED_H=1080; DETECTED_X=0; DETECTED_Y=0
    fi
    zoom_log_debug "Monitor: ${DETECTED_W}x${DETECTED_H}+${DETECTED_X}+${DETECTED_Y}"
    
    # Save for post-processing and mouse logger
    echo "$DETECTED_X $DETECTED_Y" > "$ZOOM_WORK_DIR/monitor_offset"

    # 2. Start Loggers
    # Mouse Logger
    (
        read OFF_X OFF_Y < "$ZOOM_WORK_DIR/monitor_offset"
        while true; do
            eval $(xdotool getmouselocation --shell)
            # Adjust coordinates relative to the recorded monitor
            echo "$(date +%s.%N) $((X - OFF_X)) $((Y - OFF_Y))"
            sleep 0.04
        done
    ) > "$ZOOM_LOG_MOUSE" &
    PID_MOUSE=$!
    
    # Keyboard Logger
    # AltGr is usually keycode 108.
    KEYCODE=108
    (
        # Find all slave keyboards
        KBD_IDS=$(xinput list | grep "slave  keyboard" | grep -v -i "Virtual\|XTEST\|Power\|Video\|Sleep\|Button" | sed 's/.*id=\([0-9]*\).*/\1/')
        
        # Start a listener for each keyboard in the background
        for kid in $KBD_IDS; do
            # Use stdbuf to ensure output is not buffered
            stdbuf -oL xinput test "$kid" 2>/dev/null | grep --line-buffered "key press.*$KEYCODE" | while read line; do
                echo "$(date +%s.%N) TOGGLE"
            done &
        done
        wait
    ) > "$ZOOM_LOG_KEYS" &
    PID_KEYS=$!
    
    zoom_log_debug "Listening for AltGr (108) on keyboards: $(echo $KBD_IDS)"
    
    # Sync Time
    SYNC_START=$(date +%s.%N)
    echo "$SYNC_START" > "$ZOOM_WORK_DIR/start_time"
    
    # 3. Start FFmpeg
    # Explicitly construct geometry strings
    GEOM="${DETECTED_W}x${DETECTED_H}"
    OFFSET=":0.0+${DETECTED_X},${DETECTED_Y}"
    
    zoom_log_debug "FFmpeg starting with: $GEOM at $OFFSET"
    
    if [ -e /dev/dri/renderD128 ]; then
        ffmpeg -y -f x11grab -draw_mouse 1 -framerate 30 -video_size "$GEOM" -i "$OFFSET" \
            -f alsa -i default \
            -vaapi_device /dev/dri/renderD128 \
            -vf 'format=nv12,hwupload' \
            -c:v h264_vaapi -qp 24 \
            -c:a aac \
            "$ZOOM_VIDEO_RAW" > /dev/null 2>&1 &
    else
        ffmpeg -y -f x11grab -draw_mouse 1 -framerate 30 -video_size "$GEOM" -i "$OFFSET" \
            -f alsa -i default \
            -c:v libx264 -preset ultrafast -crf 23 \
            -c:a aac \
            "$ZOOM_VIDEO_RAW" > /dev/null 2>&1 &
    fi
    PID_FFMPEG=$!
    
    # Save PIDs
    echo "$$ $PID_FFMPEG $PID_MOUSE $PID_KEYS" > "$ZOOM_LOCK_FILE"
    echo $$ > /tmp/recordingpid
    zoom_log_debug "Recording active. PIDs: $$ $PID_FFMPEG $PID_MOUSE $PID_KEYS"
    
    notify-send "Video Zoom" "Started. Press AltGr to Zoom."
    
    # Keep alive until signaled
    trap "break" SIGINT SIGTERM
    while true; do sleep 1; done
    
    # 4. Stop & Cleanup
    zoom_log_debug "Stopping recording..."
    kill $PID_FFMPEG $PID_MOUSE $PID_KEYS 2>/dev/null
    
    # Wait for logs to flush
    sleep 0.5
    
    # Merge Logs
    zoom_log_debug "Merging logs..."
    if [ -f "$ZOOM_WORK_DIR/start_time" ]; then
        SYNC_START=$(cat "$ZOOM_WORK_DIR/start_time")
    else
        SYNC_START=$(head -n1 "$ZOOM_LOG_MOUSE" | awk '{print $1}')
    fi
    
    # Process Mouse
    awk -v start="$SYNC_START" '{
        t = $1 - start;
        if (t >= 0) print t " MOUSE " $2 " " $3
    }' "$ZOOM_LOG_MOUSE" > "$ZOOM_WORK_DIR/merged.temp"
    
    # Process Keys
    awk -v start="$SYNC_START" '{
        t = $1 - start;
        if (t >= 0) print t " KEY " $2
    }' "$ZOOM_LOG_KEYS" >> "$ZOOM_WORK_DIR/merged.temp"
    
    # Sort
    sort -n "$ZOOM_WORK_DIR/merged.temp" > "$ZOOM_LOG_COORD"
    rm "$ZOOM_WORK_DIR/merged.temp" "$ZOOM_LOG_MOUSE" "$ZOOM_LOG_KEYS" "$ZOOM_WORK_DIR/start_time" "$ZOOM_LOCK_FILE"
    
    zoom_log_debug "Logs merged. Starting background processing..."
    
    # IMPORTANT: Run processing in background and disown so it survives parent death
    zoom_process >/dev/null 2>&1 & disown
}

# ==============================================================================
# OTHER RECORDING FUNCTIONS
# ==============================================================================

selected() {
    slop -f "%x %y %w %h" > /tmp/slop
    read -r X Y W H < /tmp/slop
    rm /tmp/slop

    local outfile="$HOME/selected-$(date '+%y%m%d-%H%M-%S').mp4"
    
    # VAAPI requires minimum 128x128 and device present
    if [ -e /dev/dri/renderD128 ] && (( W >= 128 && H >= 128 )); then
        ffmpeg -y \
            -f x11grab \
            -framerate 30 \
            -video_size "${W}x${H}" \
            -i ":0.0+${X},${Y}" \
            -f alsa -i default \
            -vaapi_device /dev/dri/renderD128 \
            -vf 'format=nv12,hwupload' \
            -c:v h264_vaapi -qp 24 \
            -c:a aac \
            "$outfile" >> "$ZOOM_DEBUG_LOG" 2>&1 &
    else
        ffmpeg -y \
            -f x11grab \
            -framerate 30 \
            -video_size "${W}x${H}" \
            -i ":0.0+${X},${Y}" \
            -f alsa -i default \
            -c:v libx264 -preset ultrafast -crf 23 \
            -c:a aac \
            "$outfile" >> "$ZOOM_DEBUG_LOG" 2>&1 &
    fi
    echo $! > /tmp/recordingpid
}

# ==============================================================================
# MENU & CONTROL
# ==============================================================================

askrecording() {
    choice=$(printf "screencast\\nzoom\\nselected" | rofi -dmenu -i -p "Record")
    case "$choice" in
        screencast) screencast;;
        zoom) zoom_start;;
        selected) selected;;
    esac
}

asktoend() {
    response=$(printf "Yes\\nNo" | rofi -dmenu -i -p "End recording?")
    [ "$response" = "Yes" ] && killrecording
}

case "$1" in
    screencast) screencast;;
    zoom) zoom_start;;
    selected) selected;;
    kill) killrecording;;
    *) if [ -f /tmp/recordingpid ]; then asktoend; else askrecording; fi;;
esac