* Anchor the webcam overlay to the recorded region, not the monitor Recording a single window on an ultrawide put the camera outside the frame. The overlay is placed twice, and neither placement knows what is being captured: the window rules in default/hypr/apps/webcam-overlay.lua position it from monitor_w/monitor_h, and omarchy-capture-webcam-resize recomputes the same corner from hyprctl monitors. Both resolve to the monitor's bottom-right, so a window anywhere but the far right of the display records without the camera in it, and dragging it into frame by hand is undone by the next resize. The geometry is already known. select_capture_target resolves the picked window to a region rectangle before start_webcam_overlay runs, and a bare click in smart mode snaps that rectangle to the window's own bounds. gpu-screen-recorder takes the region in the compositor's logical coordinate space, which is the same space window moves take, so the value needs no conversion to be reused as an anchor. Publish it for the resize helper, which becomes the single place that positions the overlay. It anchors to the region when one is recorded and falls back to the monitor otherwise, so full-monitor captures, the portal backend, and manual resizes outside a recording all keep their current behaviour. A malformed or empty file falls back the same way. Presets scale from the anchor's height rather than the monitor's, so the camera keeps its proportion of the frame instead of covering a small capture outright. A tall, narrow region cannot fit a preset derived from its height, so widths are capped to the space available and heights follow at the same 8:9 aspect. Placement happens inside start_webcam_overlay rather than after it returns. Correcting the position once that function had returned left the move adjacent to gpu-screen-recorder starting, and the camera was recorded sliding the last stretch into its corner over the opening frames. The overlay is waited for explicitly instead, positioned, and only then does capture start. Waiting for the map is what the blind second was partly guessing at, so the remainder is trimmed to hold the delay before capture where it was. Starting later is not free: it eats the opening words of whatever is being narrated. * Keep the webcam size ladder usable in a narrow region Capping each preset's width to the region separately collapsed small, medium and large onto the same width, so Super + Alt + [ and ] had nothing to step between, and integer division left small taller than medium. Cap the height the presets scale from instead, which shrinks the ladder as a whole and keeps the three sizes ordered and distinct. Cover the anchoring in the test suite: a region the camera follows, the fallback for an unreadable one, and the narrow-region ladder. Point XDG_RUNTIME_DIR at the test's own directory while doing so — the resize helper now reads a region file from there, and the existing geometry assertions would pick up a real one from a live recording. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: David Heinemeier Hansson <david@hey.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
284 lines
10 KiB
Bash
Executable File
284 lines
10 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
# omarchy:summary=Start or stop screen recording
|
|
# omarchy:group=capture
|
|
# omarchy:args=[--fullscreen] [--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=<device>] [--webcam-size=<small|medium|large>] [--resolution=<size>] [--stop-recording]
|
|
# omarchy:examples=omarchy screenrecord | omarchy capture screenrecording --with-desktop-audio
|
|
# omarchy:aliases=omarchy screenrecord
|
|
#
|
|
# Env: OMARCHY_SCREENRECORD_USE_PORTAL=true skips the built-in slurp picker and
|
|
# uses gpu-screen-recorder's xdg-desktop-portal capture backend instead. The
|
|
# portal backend was originally added (PR #3401) for HDR-aware capture, support
|
|
# for monitors driven by external GPUs, and window capture — enable it if any
|
|
# of those matter to you. Off by default because the portal path can fail EGL
|
|
# DMA-BUF modifier import on some configurations, leaving recording unable to
|
|
# start.
|
|
#
|
|
# Env: OMARCHY_SCREENRECORD_DEBUG=true appends gpu-screen-recorder's stderr (and
|
|
# the picker target it was launched with) to /tmp/omarchy-screenrecord.log so
|
|
# users can attach a log when reporting capture failures.
|
|
|
|
[[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs
|
|
OUTPUT_DIR="${OMARCHY_SCREENRECORD_DIR:-${XDG_VIDEOS_DIR:-$HOME/Videos}}"
|
|
|
|
if [[ ! -d $OUTPUT_DIR ]]; then
|
|
omarchy-notification-send -u critical -t 3000 "Screen recording directory does not exist: $OUTPUT_DIR"
|
|
exit 1
|
|
fi
|
|
|
|
DESKTOP_AUDIO="false"
|
|
MICROPHONE_AUDIO="false"
|
|
WEBCAM="false"
|
|
WEBCAM_DEVICE=""
|
|
WEBCAM_SIZE="medium"
|
|
RESOLUTION=""
|
|
FULLSCREEN="false"
|
|
STOP_RECORDING="false"
|
|
RECORDING_FILE="/tmp/omarchy-screenrecord-filename"
|
|
REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region"
|
|
LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "/tmp/omarchy-screenrecord.log" || echo "/dev/null")
|
|
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--with-desktop-audio) DESKTOP_AUDIO="true" ;;
|
|
--with-microphone-audio) MICROPHONE_AUDIO="true" ;;
|
|
--with-webcam) WEBCAM="true" ;;
|
|
--webcam-device=*) WEBCAM_DEVICE="${arg#*=}" ;;
|
|
--webcam-size=*) WEBCAM_SIZE="${arg#*=}" ;;
|
|
--resolution=*) RESOLUTION="${arg#*=}" ;;
|
|
--fullscreen) FULLSCREEN="true" ;;
|
|
--stop-recording) STOP_RECORDING="true" ;;
|
|
esac
|
|
done
|
|
|
|
case $WEBCAM_SIZE in
|
|
small | medium | large) ;;
|
|
*)
|
|
echo "Invalid webcam size: $WEBCAM_SIZE (expected small, medium, or large)" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
start_webcam_overlay() {
|
|
cleanup_webcam
|
|
|
|
# Auto-detect first available webcam if none specified
|
|
if [[ -z $WEBCAM_DEVICE ]]; then
|
|
WEBCAM_DEVICE=$(v4l2-ctl --list-devices 2>/dev/null | grep -m1 "^[[:space:]]*/dev/video" | tr -d '\t')
|
|
if [[ -z $WEBCAM_DEVICE ]]; then
|
|
omarchy-notification-send -u critical -t 3000 "No webcam devices found"
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
# Try preferred 16:9 resolutions in order, use first available
|
|
local preferred_resolutions=("640x360" "1280x720" "1920x1080")
|
|
local capture_options="framerate=30"
|
|
local available_formats=$(v4l2-ctl --list-formats-ext -d "$WEBCAM_DEVICE" 2>/dev/null)
|
|
|
|
for resolution in "${preferred_resolutions[@]}"; do
|
|
if echo "$available_formats" | grep -q "$resolution"; then
|
|
capture_options="video_size=$resolution,$capture_options"
|
|
break
|
|
fi
|
|
done
|
|
|
|
mpv "av://v4l2:$WEBCAM_DEVICE" \
|
|
--profile=low-latency --untimed --no-cache \
|
|
--demuxer-lavf-o="$capture_options" \
|
|
'--vf=lavfi=[crop=ih*8/9:ih]' \
|
|
--title="WebcamOverlay" --wayland-app-id="WebcamOverlay-$WEBCAM_SIZE" \
|
|
--no-border --no-audio --no-osc --osd-level=0 \
|
|
--really-quiet &>/dev/null &
|
|
|
|
# The move has to settle before gpu-screen-recorder starts, or the camera is
|
|
# recorded sliding into its corner. Waiting for the map is what the blind
|
|
# second was partly guessing at, so the remainder is trimmed to hold the
|
|
# pre-capture delay where it was: starting later costs the first words spoken.
|
|
local waited=0
|
|
while ((waited < 40)) && ! hyprctl clients -j | jq -e 'any(.[]; .title == "WebcamOverlay")' >/dev/null 2>&1; do
|
|
sleep 0.05
|
|
((waited++))
|
|
done
|
|
|
|
[[ ${1:-} == region:* ]] && echo "${1#region:}" >"$REGION_FILE"
|
|
omarchy-capture-webcam-resize "$WEBCAM_SIZE"
|
|
|
|
sleep 0.6
|
|
}
|
|
|
|
cleanup_webcam() {
|
|
pkill -f "WebcamOverlay" 2>/dev/null
|
|
rm -f "$REGION_FILE"
|
|
}
|
|
|
|
default_resolution() {
|
|
local width height
|
|
read -r width height < <(hyprctl monitors -j | jq -r '.[] | select(.focused == true) | "\(.width) \(.height)"')
|
|
if ((width > 3840 || height > 2160)); then
|
|
echo "3840x2160"
|
|
else
|
|
echo "0x0"
|
|
fi
|
|
}
|
|
|
|
# Echoes "monitor:NAME" when the selection matches an entire monitor (prefer
|
|
# -w <monitor> over a region capture — same kms backend, but no scaling math
|
|
# and full native res), otherwise "region:WxH+X+Y". Returns non-zero if the
|
|
# user cancelled the picker.
|
|
select_capture_target() {
|
|
local target
|
|
target=$(omarchy-capture-region smart --match-monitor) || return 1
|
|
|
|
if [[ $target == monitor:* ]]; then
|
|
echo "$target"
|
|
return
|
|
fi
|
|
|
|
[[ $target =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || return 1
|
|
|
|
# gpu-screen-recorder wants region geometry in the compositor's logical
|
|
# coordinate space — same space slurp returns — so pass the values through
|
|
# untouched. (gsr scales to physical pixels itself based on the monitor.)
|
|
echo "region:${BASH_REMATCH[3]}x${BASH_REMATCH[4]}+${BASH_REMATCH[1]}+${BASH_REMATCH[2]}"
|
|
}
|
|
|
|
start_screenrecording() {
|
|
local capture_args=()
|
|
local target
|
|
|
|
# Opt-in path for HDR, external-GPU monitors, and window capture (all things
|
|
# the portal backend supports and the kms backend doesn't). Default flow uses
|
|
# slurp + the kms backend, which avoids the EGL DMA-BUF modifier import
|
|
# failures the portal path can hit on some configurations.
|
|
if [[ $FULLSCREEN == "true" ]]; then
|
|
target="monitor:$(omarchy-hyprland-monitor-focused)"
|
|
capture_args=(-w "${target#monitor:}" -s "${RESOLUTION:-$(default_resolution)}")
|
|
elif [[ ${OMARCHY_SCREENRECORD_USE_PORTAL:-false} == "true" ]]; then
|
|
target="portal"
|
|
capture_args=(-w portal -s "${RESOLUTION:-$(default_resolution)}")
|
|
else
|
|
target=$(select_capture_target) || return 1
|
|
|
|
case $target in
|
|
monitor:*)
|
|
capture_args=(-w "${target#monitor:}" -s "${RESOLUTION:-$(default_resolution)}")
|
|
;;
|
|
region:*)
|
|
capture_args=(-w "${target#region:}")
|
|
[[ -n $RESOLUTION ]] && capture_args+=(-s "$RESOLUTION")
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
[[ $WEBCAM == "true" ]] && start_webcam_overlay "$target"
|
|
|
|
local filename="$OUTPUT_DIR/screenrecording-$(date +'%Y-%m-%d_%H-%M-%S').mp4"
|
|
local audio_devices=""
|
|
local audio_args=()
|
|
|
|
[[ $DESKTOP_AUDIO == "true" ]] && audio_devices+="default_output"
|
|
|
|
if [[ $MICROPHONE_AUDIO == "true" ]]; then
|
|
# Merge audio tracks into one - separate tracks only play one at a time in most players
|
|
[[ -n $audio_devices ]] && audio_devices+="|"
|
|
audio_devices+="default_input"
|
|
fi
|
|
|
|
[[ -n $audio_devices ]] && audio_args+=(-a "$audio_devices" -ac aac)
|
|
|
|
echo "===== $(date '+%F %T') args: $* target: $target =====" >>"$LOG_FILE"
|
|
gpu-screen-recorder "${capture_args[@]}" -k auto -f 60 -fm cfr -fallback-cpu-encoding yes -o "$filename" "${audio_args[@]}" 2>>"$LOG_FILE" &
|
|
local pid=$!
|
|
|
|
while kill -0 $pid 2>/dev/null && [[ ! -f $filename ]]; do
|
|
sleep 0.2
|
|
done
|
|
|
|
if kill -0 $pid 2>/dev/null; then
|
|
echo "$filename" >"$RECORDING_FILE"
|
|
toggle_screenrecording_indicator
|
|
fi
|
|
}
|
|
|
|
stop_screenrecording() {
|
|
pkill -SIGINT -f "^gpu-screen-recorder" # SIGINT required to save video properly
|
|
|
|
# Wait a maximum of 5 seconds to finish before hard killing
|
|
local count=0
|
|
while pgrep -f "^gpu-screen-recorder" >/dev/null && ((count < 50)); do
|
|
sleep 0.1
|
|
count=$((count + 1))
|
|
done
|
|
|
|
toggle_screenrecording_indicator
|
|
cleanup_webcam
|
|
|
|
if pgrep -f "^gpu-screen-recorder" >/dev/null; then
|
|
pkill -9 -f "^gpu-screen-recorder"
|
|
omarchy-notification-send -u critical -t 5000 "Screen recording error" "Recording process had to be force-killed. Video may be corrupted."
|
|
else
|
|
finalize_recording
|
|
local filename=$(cat "$RECORDING_FILE" 2>/dev/null)
|
|
echo "$filename"
|
|
local preview="${filename%.mp4}-preview.png"
|
|
|
|
# Generate a preview thumbnail from the first frame
|
|
ffmpeg -y -i "$filename" -ss 00:00:00.1 -vframes 1 -q:v 2 "$preview" -loglevel quiet 2>/dev/null
|
|
|
|
(
|
|
if [[ -n $(omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" -t 10000 --image "${preview:-$filename}" -a) ]]; then
|
|
mpv "$filename"
|
|
fi
|
|
rm -f "$preview"
|
|
) &
|
|
fi
|
|
|
|
rm -f "$RECORDING_FILE"
|
|
}
|
|
|
|
toggle_screenrecording_indicator() {
|
|
omarchy-shell -q omarchy.indicators refresh
|
|
}
|
|
|
|
screenrecording_active() {
|
|
pgrep -f "^gpu-screen-recorder" >/dev/null
|
|
}
|
|
|
|
finalize_recording() {
|
|
local latest
|
|
latest=$(cat "$RECORDING_FILE" 2>/dev/null)
|
|
[[ -f $latest ]] || return
|
|
|
|
# Re-encode only when the first GOP contains discardable warmup packets — stream copy can't
|
|
# trim those (it rewinds to the keyframe). Clean recordings stay on the fast stream-copy path.
|
|
local video_codec=(-c:v copy)
|
|
if ffprobe -v error -select_streams v:0 -read_intervals %+0.2 -show_entries packet=flags -of csv=p=0 "$latest" 2>/dev/null | grep -q D; then
|
|
video_codec=(-c:v libx264 -preset veryfast -crf 20)
|
|
fi
|
|
|
|
# Trim the first frame, and normalize audio to -14 LUFS if present, in a single pass
|
|
local args=(-y -ss 0.1 -i "$latest" "${video_codec[@]}")
|
|
if ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$latest" 2>/dev/null | grep -q audio; then
|
|
# Hard-mute the first 400ms to drop the PipeWire capture-open pop (a near-clipping
|
|
# transient around 130-200ms that a gentle fade-in can't attenuate enough), then a
|
|
# 50ms fade avoids a click at the boundary before loudnorm normalizes the rest.
|
|
args+=(-af "volume=enable='lt(t,0.4)':volume=0,afade=t=in:st=0.4:d=0.05,loudnorm=I=-14:TP=-1.5:LRA=11")
|
|
fi
|
|
|
|
local processed="${latest%.mp4}-processed.mp4"
|
|
if ffmpeg "${args[@]}" "$processed" -loglevel quiet 2>/dev/null; then
|
|
mv "$processed" "$latest"
|
|
else
|
|
rm -f "$processed"
|
|
fi
|
|
}
|
|
|
|
if screenrecording_active; then
|
|
stop_screenrecording
|
|
elif [[ $STOP_RECORDING == "true" ]]; then
|
|
exit 1
|
|
else
|
|
start_screenrecording || cleanup_webcam
|
|
fi
|