From ecc5b6df873cdb7d08e86cfe98ef4e29a3a8ea70 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Mon, 27 Apr 2026 21:36:30 -0300 Subject: [PATCH] fix(sync): harden mjpeg uvc capture path --- Cargo.lock | 6 +- client/Cargo.toml | 2 +- common/Cargo.toml | 2 +- scripts/install/server.sh | 2 +- scripts/manual/run_upstream_av_sync.sh | 182 +++++++++++++++--- server/Cargo.toml | 2 +- .../tests/server_install_script_contract.rs | 2 +- 7 files changed, 164 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 02813eb..7869907 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.14.30" +version = "0.14.31" dependencies = [ "anyhow", "async-stream", @@ -1676,7 +1676,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.14.30" +version = "0.14.31" dependencies = [ "anyhow", "base64", @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.14.30" +version = "0.14.31" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index a8c1506..fa00546 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.14.30" +version = "0.14.31" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index ad3bcd7..d585366 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.14.30" +version = "0.14.31" edition = "2024" build = "build.rs" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 9708ece..2fa9fe9 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -27,7 +27,7 @@ render_uvc_env_file() { # generated by lesavka/scripts/install/server.sh # Edit only for local UVC hardware overrides; rerunning the installer refreshes defaults. LESAVKA_UVC_DEBUG=${LESAVKA_UVC_DEBUG:-1} -LESAVKA_UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-256} +LESAVKA_UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024} LESAVKA_UVC_LIMIT_PCT=${LESAVKA_UVC_LIMIT_PCT:-100} LESAVKA_UVC_FPS=${LESAVKA_UVC_FPS:-20} LESAVKA_UVC_INTERVAL=${LESAVKA_UVC_INTERVAL:-500000} diff --git a/scripts/manual/run_upstream_av_sync.sh b/scripts/manual/run_upstream_av_sync.sh index 766ea23..c87bc15 100755 --- a/scripts/manual/run_upstream_av_sync.sh +++ b/scripts/manual/run_upstream_av_sync.sh @@ -19,8 +19,9 @@ TAIL_SECONDS=${TAIL_SECONDS:-2} CAPTURE_SECONDS=${CAPTURE_SECONDS:-$((PROBE_DURATION_SECONDS + PROBE_WARMUP_SECONDS + LEAD_IN_SECONDS + TAIL_SECONDS))} REMOTE_CAPTURE=${REMOTE_CAPTURE:-/tmp/lesavka-upstream-av-sync.mkv} LOCAL_OUTPUT_DIR=${LOCAL_OUTPUT_DIR:-"${REPO_ROOT}/tmp"} +REMOTE_VIDEO_DEVICE=${REMOTE_VIDEO_DEVICE:-auto} VIDEO_SIZE=${VIDEO_SIZE:-auto} -VIDEO_FPS=${VIDEO_FPS:-30} +VIDEO_FPS=${VIDEO_FPS:-auto} VIDEO_FORMAT=${VIDEO_FORMAT:-mjpeg} REMOTE_CAPTURE_STACK=${REMOTE_CAPTURE_STACK:-pulse} REMOTE_PULSE_CAPTURE_TOOL=${REMOTE_PULSE_CAPTURE_TOOL:-ffmpeg} @@ -120,6 +121,7 @@ echo "==> starting Tethys capture on ${TETHYS_HOST}" ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \ "${REMOTE_CAPTURE}" \ "${CAPTURE_SECONDS}" \ + "${REMOTE_VIDEO_DEVICE}" \ "${VIDEO_SIZE}" \ "${VIDEO_FPS}" \ "${VIDEO_FORMAT}" \ @@ -133,14 +135,15 @@ ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \ set -euo pipefail remote_capture=$1 capture_seconds=$2 -video_size=$3 -video_fps=$4 -video_format=$5 -remote_capture_stack=$6 -remote_pulse_capture_tool=$7 -remote_pulse_video_mode=$8 -remote_audio_source=$9 -remote_audio_quiesce_user_audio=${10} +remote_video_device=$3 +video_size=$4 +video_fps=$5 +video_format=$6 +remote_capture_stack=$7 +remote_pulse_capture_tool=$8 +remote_pulse_video_mode=$9 +remote_audio_source=${10} +remote_audio_quiesce_user_audio=${11} rm -f "${remote_capture}" @@ -156,6 +159,44 @@ quiesce_user_audio() { sleep 1 } +resolve_video_device() { + local requested=$1 + if [[ "${requested}" != "auto" ]]; then + printf '%s\n' "${requested}" + return 0 + fi + + local by_id + by_id=$(find /dev/v4l/by-id -maxdepth 1 -type l -name '*Lesavka*video-index0' 2>/dev/null | head -n1 || true) + if [[ -n "${by_id}" ]]; then + printf '%s\n' "${by_id}" + return 0 + fi + + if command -v v4l2-ctl >/dev/null 2>&1; then + local resolved + resolved="$( + v4l2-ctl --list-devices 2>/dev/null \ + | awk ' + BEGIN { want=0 } + /Lesavka Composite: UVC Camera/ { want=1; next } + /^[^ \t]/ { want=0 } + want && /^[ \t]+\/dev\/video[0-9]+/ { + gsub(/^[ \t]+/, "", $0) + print + exit + } + ' + )" + if [[ -n "${resolved}" ]]; then + printf '%s\n' "${resolved}" + return 0 + fi + fi + + printf '/dev/video0\n' +} + resolve_pulse_source() { if ! command -v pactl >/dev/null 2>&1; then return 1 @@ -178,13 +219,13 @@ gst_video_source_caps() { printf 'image/jpeg,width=%s,height=%s,framerate=%s/1' \ "${resolved_video_size%x*}" \ "${resolved_video_size#*x}" \ - "${video_fps}" + "${resolved_video_fps}" ;; yuyv422|YUYV|yuyv) printf 'video/x-raw,format=YUY2,width=%s,height=%s,framerate=%s/1' \ "${resolved_video_size%x*}" \ "${resolved_video_size#*x}" \ - "${video_fps}" + "${resolved_video_fps}" ;; *) printf 'unsupported gst video_format=%s\n' "${video_format}" >&2 @@ -208,18 +249,56 @@ gst_video_decode_chain() { esac } +current_video_profile() { + if ! command -v v4l2-ctl >/dev/null 2>&1; then + return 1 + fi + + v4l2-ctl -d "${resolved_video_device}" --all 2>/dev/null \ + | awk ' + /Width\/Height[[:space:]]*:/ { + split($0, a, ":") + gsub(/^[ \t]+/, "", a[2]) + split(a[2], wh, "/") + width=wh[1] + height=wh[2] + next + } + /Frames per second:/ { + fps=$4 + sub(/\..*/, "", fps) + } + END { + if (width != "" && height != "") { + print "size=" width "x" height + } + if (fps != "") { + print "fps=" fps + } + } + ' +} + resolve_video_size() { local requested=$1 if [[ "${requested}" != "auto" ]]; then printf '%s\n' "${requested}" return 0 fi + local current_profile + current_profile="$(current_video_profile || true)" + local current_size + current_size="$(awk -F= '/^size=/{print $2; exit}' <<<"${current_profile}")" + if [[ -n "${current_size}" ]]; then + printf '%s\n' "${current_size}" + return 0 + fi if ! command -v v4l2-ctl >/dev/null 2>&1; then - printf '1280x720\n' + printf '640x480\n' return 0 fi local listing - listing="$(v4l2-ctl -d /dev/video0 --list-formats-ext 2>/dev/null || true)" + listing="$(v4l2-ctl -d "${resolved_video_device}" --list-formats-ext 2>/dev/null || true)" local preferred for preferred in 1920x1080 1360x768 1280x720; do if grep -q "Size: Discrete ${preferred}" <<<"${listing}"; then @@ -227,7 +306,42 @@ resolve_video_size() { return 0 fi done - printf '1280x720\n' + local first_size + first_size="$(grep -m1 -o 'Size: Discrete [0-9]\+x[0-9]\+' <<<"${listing}" | awk '{print $3}' || true)" + if [[ -n "${first_size}" ]]; then + printf '%s\n' "${first_size}" + return 0 + fi + printf '640x480\n' +} + +resolve_video_fps() { + local requested=$1 + if [[ "${requested}" != "auto" ]]; then + printf '%s\n' "${requested}" + return 0 + fi + local current_profile + current_profile="$(current_video_profile || true)" + local current_fps + current_fps="$(awk -F= '/^fps=/{print $2; exit}' <<<"${current_profile}")" + if [[ -n "${current_fps}" ]]; then + printf '%s\n' "${current_fps}" + return 0 + fi + if ! command -v v4l2-ctl >/dev/null 2>&1; then + printf '20\n' + return 0 + fi + local listing + listing="$(v4l2-ctl -d "${resolved_video_device}" --list-formats-ext 2>/dev/null || true)" + local first_fps + first_fps="$(grep -m1 -o '[0-9]\+\.[0-9]\+ fps' <<<"${listing}" | awk '{sub(/\..*/, "", $1); print $1}' || true)" + if [[ -n "${first_fps}" ]]; then + printf '%s\n' "${first_fps}" + return 0 + fi + printf '20\n' } resolve_pw_audio_target() { @@ -327,15 +441,31 @@ case "${remote_capture_stack}" in ;; esac +resolved_video_device="$(resolve_video_device "${remote_video_device}")" resolved_video_size="$(resolve_video_size "${video_size}")" -printf 'using video mode: %s (%s)\n' "${resolved_video_size}" "${video_format:-driver-default}" >&2 -video_args=(-f video4linux2 -framerate "${video_fps}" -video_size "${resolved_video_size}") +resolved_video_fps="$(resolve_video_fps "${video_fps}")" +printf 'using video device: %s\n' "${resolved_video_device}" >&2 +printf 'using video mode: %s @ %s fps (%s)\n' "${resolved_video_size}" "${resolved_video_fps}" "${video_format:-driver-default}" >&2 +video_args=(-f video4linux2 -framerate "${resolved_video_fps}" -video_size "${resolved_video_size}") if [[ -n "${video_format}" ]]; then video_args+=(-input_format "${video_format}") fi gst_source_caps="$(gst_video_source_caps)" gst_decode_chain="$(gst_video_decode_chain)" +run_ffmpeg_capture() { + local rc=0 + timeout --signal=INT "$((capture_seconds + 5))" "$@" || rc=$? + case "${rc}" in + 0|124|130) + return 0 + ;; + *) + return "${rc}" + ;; + esac +} + quiesce_for_alsa=0 case "${remote_audio_quiesce_user_audio}" in 1|true|yes) @@ -372,7 +502,7 @@ if [[ "${capture_mode}" == "pwpipe" ]]; then | pw-v4l2 ffmpeg -hide_banner -loglevel error -y \ -thread_queue_size 1024 \ "${video_args[@]}" \ - -i /dev/video0 \ + -i "${resolved_video_device}" \ -thread_queue_size 1024 \ -f s16le -ar 48000 -ac 2 \ -i pipe:0 \ @@ -386,10 +516,10 @@ elif [[ "${capture_mode}" == "pulse" ]]; then ffmpeg) case "${remote_pulse_video_mode}" in copy) - ffmpeg -hide_banner -loglevel error -y \ + run_ffmpeg_capture ffmpeg -hide_banner -loglevel error -y \ -thread_queue_size 1024 \ "${video_args[@]}" \ - -i /dev/video0 \ + -i "${resolved_video_device}" \ -thread_queue_size 1024 \ -f pulse \ -i "${pulse_source}" \ @@ -399,15 +529,15 @@ elif [[ "${capture_mode}" == "pulse" ]]; then "${remote_capture}" ;; cfr) - ffmpeg -hide_banner -loglevel error -y \ + run_ffmpeg_capture ffmpeg -hide_banner -loglevel error -y \ -thread_queue_size 1024 \ "${video_args[@]}" \ - -i /dev/video0 \ + -i "${resolved_video_device}" \ -thread_queue_size 1024 \ -f pulse \ -i "${pulse_source}" \ -t "${capture_seconds}" \ - -vf "fps=${video_fps}" \ + -vf "fps=${resolved_video_fps}" \ -c:v libx264 -preset ultrafast -crf 12 -g 1 -pix_fmt yuv420p \ -c:a pcm_s16le \ "${remote_capture}" @@ -428,7 +558,7 @@ elif [[ "${capture_mode}" == "pulse" ]]; then timeout --signal=INT "$((capture_seconds + 3))" \ gst-launch-1.0 -q -e \ matroskamux name=mux ! filesink location="${remote_capture}" \ - v4l2src device=/dev/video0 do-timestamp=true ! \ + v4l2src device="${resolved_video_device}" do-timestamp=true ! \ ${gst_source_caps} ! \ queue ! mux. \ pulsesrc device="${pulse_source}" do-timestamp=true ! \ @@ -439,7 +569,7 @@ elif [[ "${capture_mode}" == "pulse" ]]; then timeout --signal=INT "$((capture_seconds + 3))" \ gst-launch-1.0 -q -e \ matroskamux name=mux ! filesink location="${remote_capture}" \ - v4l2src device=/dev/video0 do-timestamp=true ! \ + v4l2src device="${resolved_video_device}" do-timestamp=true ! \ ${gst_source_caps} ! \ ${gst_decode_chain} \ videoconvert ! videorate ! video/x-raw,framerate="${video_fps}"/1 ! \ @@ -462,10 +592,10 @@ elif [[ "${capture_mode}" == "pulse" ]]; then ;; esac else - ffmpeg -hide_banner -loglevel error -y \ + run_ffmpeg_capture ffmpeg -hide_banner -loglevel error -y \ -thread_queue_size 1024 \ "${video_args[@]}" \ - -i /dev/video0 \ + -i "${resolved_video_device}" \ -thread_queue_size 1024 \ -f alsa -ac 2 -ar 48000 \ -i "${alsa_audio_dev}" \ diff --git a/server/Cargo.toml b/server/Cargo.toml index 59a5c38..48cb65f 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.14.30" +version = "0.14.31" edition = "2024" autobins = false diff --git a/testing/tests/server_install_script_contract.rs b/testing/tests/server_install_script_contract.rs index 516fb75..7d69773 100644 --- a/testing/tests/server_install_script_contract.rs +++ b/testing/tests/server_install_script_contract.rs @@ -46,7 +46,7 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS:-1000}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_PAIR_SLACK_US:-20000}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_STALE_DROP_MS:-80}")); - assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_MAXPACKET:-256}")); + assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_MAXPACKET:-1024}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_INTERVAL:-500000}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_WIDTH:-640}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEIGHT:-480}"));