#!/usr/bin/env bash # scripts/manual/client_rct_remote_capture.sh # Remote Tethys-side UVC/UAC capture helper for run_client_to_rct_transport_probe.sh. set -euo pipefail remote_capture=$1 capture_seconds=$2 width=$3 height=$4 fps=$5 capture_stack=$6 pulse_tool=$7 video_mode=$8 anchor_silence=$9 preroll_discard=${10} preroll_settle=${11} ready_settle=${12} ready_marker=${13} resolve_video_device() { find /dev/v4l/by-id -maxdepth 1 -type l \ -name 'usb-Lesavka_Lesavka_Composite*video-index0' | sort | head -n 1 } resolve_pulse_source() { pactl list short sources 2>/dev/null \ | awk ' /alsa_input\..*Lesavka_Lesavka_Composite/ { print $2; found=1; exit } /Lesavka_Lesavka_Composite/ && $2 !~ /\.monitor$/ && !fallback { fallback=$2 } END { if (found) exit 0 if (fallback != "") { print fallback; exit 0 } exit 1 } ' } current_video_profile() { v4l2-ctl -d "${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[[:space:]]*:/ { split($0, a, ":") gsub(/^[ \t]+/, "", a[2]) split(a[2], fps_parts, "\\.") fps=fps_parts[1] } END { if (width && height && fps) { printf "%s %s %s\n", width, height, fps exit 0 } exit 1 } ' } gst_audio_mixer_element() { if gst-inspect-1.0 audiomixer 2>/dev/null | grep -q 'ignore-inactive-pads'; then printf 'audiomixer name=amix ignore-inactive-pads=true' else printf 'audiomixer name=amix' fi } run_preroll() { local video_device=$1 local seconds=$2 [[ "${seconds}" =~ ^[0-9]+$ && "${seconds}" -gt 0 ]] || return 0 printf 'discarding %ss of post-enumeration capture before probe\n' "${seconds}" >&2 timeout --kill-after=5 --signal=INT "$((seconds + 5))" \ gst-launch-1.0 -q -e v4l2src device="${video_device}" do-timestamp=true num-buffers="$((fps * seconds))" \ ! "image/jpeg,width=${width},height=${height},framerate=${fps}/1" ! fakesink \ >/dev/null 2>&1 || true if [[ "${preroll_settle}" =~ ^[0-9]+$ && "${preroll_settle}" -gt 0 ]]; then printf 'settling %ss after preroll discard\n' "${preroll_settle}" >&2 sleep "${preroll_settle}" fi } run_gst_pulse_capture() { local video_device=$1 local pulse_source=$2 local video_caps="image/jpeg,width=${width},height=${height},framerate=${fps}/1" local decode_chain="jpegdec !" local audio_mixer audio_mixer="$(gst_audio_mixer_element)" local audio_anchor=() if [[ "${anchor_silence}" != "0" ]]; then printf 'anchoring Pulse capture audio timeline with generated silence\n' >&2 audio_anchor=(audiotestsrc wave=silence is-live=true do-timestamp=true ! "audio/x-raw,rate=48000,channels=2" ! queue ! amix.) fi printf 'capture_start_unix_ns=%s\n' "$(date +%s%N)" >&2 if [[ "${video_mode}" == "cfr" ]]; then timeout --kill-after=5 --signal=INT "$((capture_seconds + 3))" \ gst-launch-1.0 -q -e \ matroskamux name=mux ! filesink location="${remote_capture}" \ v4l2src device="${video_device}" do-timestamp=true ! ${video_caps} ! \ ${decode_chain} videoconvert ! videorate ! video/x-raw,framerate="${fps}"/1 ! \ x264enc tune=zerolatency speed-preset=ultrafast key-int-max=1 bitrate=5000 ! \ h264parse ! queue ! mux. \ ${audio_mixer} ! audio/x-raw,rate=48000,channels=2 ! queue ! mux. \ "${audio_anchor[@]}" \ pulsesrc device="${pulse_source}" do-timestamp=true ! audio/x-raw,rate=48000,channels=2 ! \ audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 ! queue ! amix. & else timeout --kill-after=5 --signal=INT "$((capture_seconds + 3))" \ gst-launch-1.0 -q -e \ matroskamux name=mux ! filesink location="${remote_capture}" \ v4l2src device="${video_device}" do-timestamp=true ! ${video_caps} ! queue ! mux. \ ${audio_mixer} ! audio/x-raw,rate=48000,channels=2 ! queue ! mux. \ "${audio_anchor[@]}" \ pulsesrc device="${pulse_source}" do-timestamp=true ! audio/x-raw,rate=48000,channels=2 ! \ audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 ! queue ! amix. & fi local capture_pid=$! sleep "${ready_settle}" printf '%s\n' "${ready_marker}" >&2 wait "${capture_pid}" } run_ffmpeg_pulse_capture() { local video_device=$1 local pulse_source=$2 printf 'capture_start_unix_ns=%s\n' "$(date +%s%N)" >&2 timeout --kill-after=5 --signal=INT "$((capture_seconds + 5))" \ ffmpeg -nostdin -hide_banner -loglevel error -y \ -thread_queue_size 1024 -f video4linux2 -framerate "${fps}" \ -video_size "${width}x${height}" -input_format mjpeg -i "${video_device}" \ -thread_queue_size 1024 -f pulse -i "${pulse_source}" \ -map 0:v:0 -map 1:a:0 -t "${capture_seconds}" \ -c:v copy -c:a pcm_s16le "${remote_capture}" &2 wait "${capture_pid}" } rm -f "${remote_capture}" video_device="$(resolve_video_device)" if [[ -z "${video_device}" ]]; then printf 'Lesavka UVC video device not found on RCT host; refusing unrelated capture devices.\n' >&2 exit 2 fi if [[ "${width}" == "0" || "${height}" == "0" || "${fps}" == "0" ]]; then if read -r width height fps < <(current_video_profile); then : else printf 'unable to auto-detect current UVC mode; set LESAVKA_CLIENT_RCT_MODE=WIDTHxHEIGHT@FPS\n' >&2 exit 2 fi fi printf 'using video device: %s\n' "${video_device}" >&2 printf 'using video mode: %sx%s @ %s fps (mjpeg)\n' "${width}" "${height}" "${fps}" >&2 case "${capture_stack}" in pulse) pulse_source="$(resolve_pulse_source)" if [[ -z "${pulse_source}" ]]; then printf 'Lesavka Pulse audio source not found; refusing timing-sensitive fallback.\n' >&2 exit 2 fi printf 'using Pulse source: %s\n' "${pulse_source}" >&2 run_preroll "${video_device}" "${preroll_discard}" case "${pulse_tool}" in gst) run_gst_pulse_capture "${video_device}" "${pulse_source}" ;; ffmpeg) run_ffmpeg_pulse_capture "${video_device}" "${pulse_source}" ;; *) printf 'unsupported REMOTE_PULSE_CAPTURE_TOOL=%s for client-to-RCT probe; use gst or ffmpeg\n' "${pulse_tool}" >&2 exit 2 ;; esac ;; *) printf 'unsupported REMOTE_CAPTURE_STACK=%s for client-to-RCT probe\n' "${capture_stack}" >&2 exit 2 ;; esac