fix(sync): harden mjpeg uvc capture path

This commit is contained in:
Brad Stein 2026-04-27 21:36:30 -03:00
parent 8b5dc220ad
commit ecc5b6df87
7 changed files with 164 additions and 34 deletions

6
Cargo.lock generated
View File

@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.14.30" version = "0.14.31"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1676,7 +1676,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.14.30" version = "0.14.31"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1688,7 +1688,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.14.30" version = "0.14.31"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package] [package]
name = "lesavka_client" name = "lesavka_client"
version = "0.14.30" version = "0.14.31"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lesavka_common" name = "lesavka_common"
version = "0.14.30" version = "0.14.31"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"

View File

@ -27,7 +27,7 @@ render_uvc_env_file() {
# generated by lesavka/scripts/install/server.sh # generated by lesavka/scripts/install/server.sh
# Edit only for local UVC hardware overrides; rerunning the installer refreshes defaults. # Edit only for local UVC hardware overrides; rerunning the installer refreshes defaults.
LESAVKA_UVC_DEBUG=${LESAVKA_UVC_DEBUG:-1} 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_LIMIT_PCT=${LESAVKA_UVC_LIMIT_PCT:-100}
LESAVKA_UVC_FPS=${LESAVKA_UVC_FPS:-20} LESAVKA_UVC_FPS=${LESAVKA_UVC_FPS:-20}
LESAVKA_UVC_INTERVAL=${LESAVKA_UVC_INTERVAL:-500000} LESAVKA_UVC_INTERVAL=${LESAVKA_UVC_INTERVAL:-500000}

View File

@ -19,8 +19,9 @@ TAIL_SECONDS=${TAIL_SECONDS:-2}
CAPTURE_SECONDS=${CAPTURE_SECONDS:-$((PROBE_DURATION_SECONDS + PROBE_WARMUP_SECONDS + LEAD_IN_SECONDS + TAIL_SECONDS))} 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} REMOTE_CAPTURE=${REMOTE_CAPTURE:-/tmp/lesavka-upstream-av-sync.mkv}
LOCAL_OUTPUT_DIR=${LOCAL_OUTPUT_DIR:-"${REPO_ROOT}/tmp"} LOCAL_OUTPUT_DIR=${LOCAL_OUTPUT_DIR:-"${REPO_ROOT}/tmp"}
REMOTE_VIDEO_DEVICE=${REMOTE_VIDEO_DEVICE:-auto}
VIDEO_SIZE=${VIDEO_SIZE:-auto} VIDEO_SIZE=${VIDEO_SIZE:-auto}
VIDEO_FPS=${VIDEO_FPS:-30} VIDEO_FPS=${VIDEO_FPS:-auto}
VIDEO_FORMAT=${VIDEO_FORMAT:-mjpeg} VIDEO_FORMAT=${VIDEO_FORMAT:-mjpeg}
REMOTE_CAPTURE_STACK=${REMOTE_CAPTURE_STACK:-pulse} REMOTE_CAPTURE_STACK=${REMOTE_CAPTURE_STACK:-pulse}
REMOTE_PULSE_CAPTURE_TOOL=${REMOTE_PULSE_CAPTURE_TOOL:-ffmpeg} 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 -- \ ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \
"${REMOTE_CAPTURE}" \ "${REMOTE_CAPTURE}" \
"${CAPTURE_SECONDS}" \ "${CAPTURE_SECONDS}" \
"${REMOTE_VIDEO_DEVICE}" \
"${VIDEO_SIZE}" \ "${VIDEO_SIZE}" \
"${VIDEO_FPS}" \ "${VIDEO_FPS}" \
"${VIDEO_FORMAT}" \ "${VIDEO_FORMAT}" \
@ -133,14 +135,15 @@ ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \
set -euo pipefail set -euo pipefail
remote_capture=$1 remote_capture=$1
capture_seconds=$2 capture_seconds=$2
video_size=$3 remote_video_device=$3
video_fps=$4 video_size=$4
video_format=$5 video_fps=$5
remote_capture_stack=$6 video_format=$6
remote_pulse_capture_tool=$7 remote_capture_stack=$7
remote_pulse_video_mode=$8 remote_pulse_capture_tool=$8
remote_audio_source=$9 remote_pulse_video_mode=$9
remote_audio_quiesce_user_audio=${10} remote_audio_source=${10}
remote_audio_quiesce_user_audio=${11}
rm -f "${remote_capture}" rm -f "${remote_capture}"
@ -156,6 +159,44 @@ quiesce_user_audio() {
sleep 1 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() { resolve_pulse_source() {
if ! command -v pactl >/dev/null 2>&1; then if ! command -v pactl >/dev/null 2>&1; then
return 1 return 1
@ -178,13 +219,13 @@ gst_video_source_caps() {
printf 'image/jpeg,width=%s,height=%s,framerate=%s/1' \ printf 'image/jpeg,width=%s,height=%s,framerate=%s/1' \
"${resolved_video_size%x*}" \ "${resolved_video_size%x*}" \
"${resolved_video_size#*x}" \ "${resolved_video_size#*x}" \
"${video_fps}" "${resolved_video_fps}"
;; ;;
yuyv422|YUYV|yuyv) yuyv422|YUYV|yuyv)
printf 'video/x-raw,format=YUY2,width=%s,height=%s,framerate=%s/1' \ printf 'video/x-raw,format=YUY2,width=%s,height=%s,framerate=%s/1' \
"${resolved_video_size%x*}" \ "${resolved_video_size%x*}" \
"${resolved_video_size#*x}" \ "${resolved_video_size#*x}" \
"${video_fps}" "${resolved_video_fps}"
;; ;;
*) *)
printf 'unsupported gst video_format=%s\n' "${video_format}" >&2 printf 'unsupported gst video_format=%s\n' "${video_format}" >&2
@ -208,18 +249,56 @@ gst_video_decode_chain() {
esac 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() { resolve_video_size() {
local requested=$1 local requested=$1
if [[ "${requested}" != "auto" ]]; then if [[ "${requested}" != "auto" ]]; then
printf '%s\n' "${requested}" printf '%s\n' "${requested}"
return 0 return 0
fi 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 if ! command -v v4l2-ctl >/dev/null 2>&1; then
printf '1280x720\n' printf '640x480\n'
return 0 return 0
fi fi
local listing 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 local preferred
for preferred in 1920x1080 1360x768 1280x720; do for preferred in 1920x1080 1360x768 1280x720; do
if grep -q "Size: Discrete ${preferred}" <<<"${listing}"; then if grep -q "Size: Discrete ${preferred}" <<<"${listing}"; then
@ -227,7 +306,42 @@ resolve_video_size() {
return 0 return 0
fi fi
done 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() { resolve_pw_audio_target() {
@ -327,15 +441,31 @@ case "${remote_capture_stack}" in
;; ;;
esac esac
resolved_video_device="$(resolve_video_device "${remote_video_device}")"
resolved_video_size="$(resolve_video_size "${video_size}")" resolved_video_size="$(resolve_video_size "${video_size}")"
printf 'using video mode: %s (%s)\n' "${resolved_video_size}" "${video_format:-driver-default}" >&2 resolved_video_fps="$(resolve_video_fps "${video_fps}")"
video_args=(-f video4linux2 -framerate "${video_fps}" -video_size "${resolved_video_size}") 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 if [[ -n "${video_format}" ]]; then
video_args+=(-input_format "${video_format}") video_args+=(-input_format "${video_format}")
fi fi
gst_source_caps="$(gst_video_source_caps)" gst_source_caps="$(gst_video_source_caps)"
gst_decode_chain="$(gst_video_decode_chain)" 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 quiesce_for_alsa=0
case "${remote_audio_quiesce_user_audio}" in case "${remote_audio_quiesce_user_audio}" in
1|true|yes) 1|true|yes)
@ -372,7 +502,7 @@ if [[ "${capture_mode}" == "pwpipe" ]]; then
| pw-v4l2 ffmpeg -hide_banner -loglevel error -y \ | pw-v4l2 ffmpeg -hide_banner -loglevel error -y \
-thread_queue_size 1024 \ -thread_queue_size 1024 \
"${video_args[@]}" \ "${video_args[@]}" \
-i /dev/video0 \ -i "${resolved_video_device}" \
-thread_queue_size 1024 \ -thread_queue_size 1024 \
-f s16le -ar 48000 -ac 2 \ -f s16le -ar 48000 -ac 2 \
-i pipe:0 \ -i pipe:0 \
@ -386,10 +516,10 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
ffmpeg) ffmpeg)
case "${remote_pulse_video_mode}" in case "${remote_pulse_video_mode}" in
copy) copy)
ffmpeg -hide_banner -loglevel error -y \ run_ffmpeg_capture ffmpeg -hide_banner -loglevel error -y \
-thread_queue_size 1024 \ -thread_queue_size 1024 \
"${video_args[@]}" \ "${video_args[@]}" \
-i /dev/video0 \ -i "${resolved_video_device}" \
-thread_queue_size 1024 \ -thread_queue_size 1024 \
-f pulse \ -f pulse \
-i "${pulse_source}" \ -i "${pulse_source}" \
@ -399,15 +529,15 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
"${remote_capture}" "${remote_capture}"
;; ;;
cfr) cfr)
ffmpeg -hide_banner -loglevel error -y \ run_ffmpeg_capture ffmpeg -hide_banner -loglevel error -y \
-thread_queue_size 1024 \ -thread_queue_size 1024 \
"${video_args[@]}" \ "${video_args[@]}" \
-i /dev/video0 \ -i "${resolved_video_device}" \
-thread_queue_size 1024 \ -thread_queue_size 1024 \
-f pulse \ -f pulse \
-i "${pulse_source}" \ -i "${pulse_source}" \
-t "${capture_seconds}" \ -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:v libx264 -preset ultrafast -crf 12 -g 1 -pix_fmt yuv420p \
-c:a pcm_s16le \ -c:a pcm_s16le \
"${remote_capture}" "${remote_capture}"
@ -428,7 +558,7 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
timeout --signal=INT "$((capture_seconds + 3))" \ timeout --signal=INT "$((capture_seconds + 3))" \
gst-launch-1.0 -q -e \ gst-launch-1.0 -q -e \
matroskamux name=mux ! filesink location="${remote_capture}" \ 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_source_caps} ! \
queue ! mux. \ queue ! mux. \
pulsesrc device="${pulse_source}" do-timestamp=true ! \ pulsesrc device="${pulse_source}" do-timestamp=true ! \
@ -439,7 +569,7 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
timeout --signal=INT "$((capture_seconds + 3))" \ timeout --signal=INT "$((capture_seconds + 3))" \
gst-launch-1.0 -q -e \ gst-launch-1.0 -q -e \
matroskamux name=mux ! filesink location="${remote_capture}" \ 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_source_caps} ! \
${gst_decode_chain} \ ${gst_decode_chain} \
videoconvert ! videorate ! video/x-raw,framerate="${video_fps}"/1 ! \ videoconvert ! videorate ! video/x-raw,framerate="${video_fps}"/1 ! \
@ -462,10 +592,10 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
;; ;;
esac esac
else else
ffmpeg -hide_banner -loglevel error -y \ run_ffmpeg_capture ffmpeg -hide_banner -loglevel error -y \
-thread_queue_size 1024 \ -thread_queue_size 1024 \
"${video_args[@]}" \ "${video_args[@]}" \
-i /dev/video0 \ -i "${resolved_video_device}" \
-thread_queue_size 1024 \ -thread_queue_size 1024 \
-f alsa -ac 2 -ar 48000 \ -f alsa -ac 2 -ar 48000 \
-i "${alsa_audio_dev}" \ -i "${alsa_audio_dev}" \

View File

@ -10,7 +10,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.14.30" version = "0.14.31"
edition = "2024" edition = "2024"
autobins = false autobins = false

View File

@ -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_PLAYOUT_DELAY_MS:-1000}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_PAIR_SLACK_US:-20000}")); 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_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_INTERVAL:-500000}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_WIDTH:-640}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_WIDTH:-640}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEIGHT:-480}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEIGHT:-480}"));