From ab00babf99cd5f73afd2c7df7f7b2f3631e06aff Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Mon, 27 Apr 2026 13:50:48 -0300 Subject: [PATCH] fix(sync): honor configurable uvc codec --- Cargo.lock | 6 +-- client/Cargo.toml | 2 +- client/src/sync_probe/capture/tests.rs | 56 ++++++++++++++++++++++++++ common/Cargo.toml | 2 +- scripts/install/server.sh | 4 +- server/Cargo.toml | 2 +- server/src/camera/selection.rs | 17 +++++++- server/src/tests/camera.rs | 12 ++++++ 8 files changed, 92 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 86e73ed..c662bb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.14.19" +version = "0.14.20" dependencies = [ "anyhow", "async-stream", @@ -1676,7 +1676,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.14.19" +version = "0.14.20" dependencies = [ "anyhow", "base64", @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.14.19" +version = "0.14.20" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 9e2b3e1..c008314 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.14.19" +version = "0.14.20" edition = "2024" [dependencies] diff --git a/client/src/sync_probe/capture/tests.rs b/client/src/sync_probe/capture/tests.rs index 1fda995..8566892 100644 --- a/client/src/sync_probe/capture/tests.rs +++ b/client/src/sync_probe/capture/tests.rs @@ -7,6 +7,12 @@ use crate::sync_probe::schedule::PulseSchedule; use lesavka_common::lesavka::{AudioPacket, VideoPacket}; use std::time::Duration; use std::time::Instant; +#[cfg(not(coverage))] +use gstreamer as gst; +#[cfg(not(coverage))] +use gstreamer::prelude::*; +#[cfg(not(coverage))] +use gstreamer_app as gst_app; fn stub_camera() -> CameraConfig { CameraConfig { @@ -141,6 +147,49 @@ fn probe_video_frames_render_distinct_idle_regular_and_marker_patterns() { assert_ne!(regular, marker); } +#[cfg(not(coverage))] +fn decode_mjpeg_packet_mean_luma(packet: &VideoPacket) -> u8 { + gst::init().expect("gst init"); + let pipeline = gst::parse::launch( + "appsrc name=src is-live=false format=time do-timestamp=false \ + caps=image/jpeg,parsed=true ! jpegdec ! videoconvert ! \ + video/x-raw,format=GRAY8 ! \ + appsink name=sink emit-signals=false sync=false max-buffers=1 drop=false", + ) + .expect("decode pipeline") + .downcast::() + .expect("pipeline"); + let src = pipeline + .by_name("src") + .expect("src") + .downcast::() + .expect("appsrc"); + let sink = pipeline + .by_name("sink") + .expect("sink") + .downcast::() + .expect("appsink"); + + pipeline + .set_state(gst::State::Playing) + .expect("pipeline playing"); + let mut buffer = gst::Buffer::from_slice(packet.data.clone()); + if let Some(meta) = buffer.get_mut() { + meta.set_pts(Some(gst::ClockTime::from_useconds(packet.pts))); + } + src.push_buffer(buffer).expect("push buffer"); + src.end_of_stream().expect("end of stream"); + let sample = sink.pull_sample().expect("decoded sample"); + pipeline + .set_state(gst::State::Null) + .expect("pipeline null"); + let buffer = sample.buffer().expect("sample buffer"); + let map = buffer.map_readable().expect("buffer readable"); + let bytes = map.as_slice(); + let mean = bytes.iter().map(|value| u64::from(*value)).sum::() / bytes.len().max(1) as u64; + mean.min(u64::from(u8::MAX)) as u8 +} + #[test] fn probe_video_pts_are_lag_capped_like_audio() { let rebaser = crate::live_capture_clock::DurationPacedSourcePtsRebaser::default(); @@ -415,4 +464,11 @@ async fn runtime_probe_video_packets_change_across_a_pulse_boundary() { assert_ne!(dark_packet.data, pulse_packet.data); assert!(!dark_packet.data.is_empty()); assert!(!pulse_packet.data.is_empty()); + + let dark_mean = decode_mjpeg_packet_mean_luma(&dark_packet); + let pulse_mean = decode_mjpeg_packet_mean_luma(&pulse_packet); + assert!( + pulse_mean > dark_mean.saturating_add(100), + "expected decoded pulse frame to be much brighter than decoded dark frame, got dark={dark_mean} pulse={pulse_mean}" + ); } diff --git a/common/Cargo.toml b/common/Cargo.toml index 1305185..ce2fa16 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.14.19" +version = "0.14.20" edition = "2024" build = "build.rs" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 6bfac21..133b5a6 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -499,6 +499,7 @@ fi printf 'LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US:-0}" printf 'LESAVKA_UPSTREAM_PAIR_SLACK_US=%s\n' "${LESAVKA_UPSTREAM_PAIR_SLACK_US:-20000}" printf 'LESAVKA_UPSTREAM_STALE_DROP_MS=%s\n' "${LESAVKA_UPSTREAM_STALE_DROP_MS:-80}" + printf 'LESAVKA_UVC_CODEC=%s\n' "${LESAVKA_UVC_CODEC:-mjpeg}" } | sudo tee /etc/lesavka/server.env >/dev/null echo "==> 6a. Systemd units - lesavka-core" @@ -514,6 +515,7 @@ ExecStart=/usr/local/bin/lesavka-core.sh RemainAfterExit=yes Environment=LESAVKA_UVC_FALLBACK=0 Environment=LESAVKA_UVC_CODEC=mjpeg +EnvironmentFile=-/etc/lesavka/server.env CapabilityBoundingSet=CAP_SYS_ADMIN CAP_SYS_MODULE AmbientCapabilities=CAP_SYS_MODULE MountFlags=slave @@ -572,7 +574,7 @@ if [[ -n ${LESAVKA_ALLOW_GADGET_RESET:-} ]] || ! is_attached_state "$UDC_STATE"; LESAVKA_DETACH_CLEAR_UDC=1 \ LESAVKA_RELOAD_UVCVIDEO=1 \ LESAVKA_UVC_FALLBACK=1 \ - LESAVKA_UVC_CODEC=mjpeg \ + LESAVKA_UVC_CODEC="${LESAVKA_UVC_CODEC:-mjpeg}" \ /usr/local/bin/lesavka-core.sh sudo systemctl restart lesavka-core echo "✅ lesavka-core installed and restarted..." diff --git a/server/Cargo.toml b/server/Cargo.toml index 09b35d6..a3b326c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.14.19" +version = "0.14.20" edition = "2024" autobins = false diff --git a/server/src/camera/selection.rs b/server/src/camera/selection.rs index d3db95f..fad05ed 100644 --- a/server/src/camera/selection.rs +++ b/server/src/camera/selection.rs @@ -90,6 +90,17 @@ fn select_hdmi_codec(hw_decode: bool) -> CameraCodec { }) } +fn select_uvc_codec(uvc_env: Option<&HashMap>) -> CameraCodec { + std::env::var("LESAVKA_UVC_CODEC") + .ok() + .or_else(|| std::env::var("LESAVKA_CAM_CODEC").ok()) + .or_else(|| uvc_env.and_then(|env| env.get("LESAVKA_UVC_CODEC").cloned())) + .or_else(|| uvc_env.and_then(|env| env.get("LESAVKA_CAM_CODEC").cloned())) + .as_deref() + .and_then(parse_camera_codec) + .unwrap_or(CameraCodec::Mjpeg) +} + fn select_hdmi_config(hdmi: Option) -> CameraConfig { let hw_decode = has_hw_h264_decode(); let (default_width, default_height) = if hw_decode { (1920, 1080) } else { (1280, 720) }; @@ -144,10 +155,11 @@ fn select_uvc_config() -> CameraConfig { }) }) .unwrap_or(25); + let codec = select_uvc_codec(None); CameraConfig { output: CameraOutput::Uvc, - codec: CameraCodec::Mjpeg, + codec, width, height, fps, @@ -182,10 +194,11 @@ fn select_uvc_config() -> CameraConfig { }) }) .unwrap_or(25); + let codec = select_uvc_codec(Some(&uvc_env)); CameraConfig { output: CameraOutput::Uvc, - codec: CameraCodec::Mjpeg, + codec, width, height, fps, diff --git a/server/src/tests/camera.rs b/server/src/tests/camera.rs index b7800f0..7658f8a 100644 --- a/server/src/tests/camera.rs +++ b/server/src/tests/camera.rs @@ -32,6 +32,18 @@ fn camera_config_env_override_prefers_uvc_values() { }); } +#[test] +#[serial] +fn camera_config_env_override_honors_uvc_codec() { + with_var("LESAVKA_CAM_OUTPUT", Some("uvc"), || { + with_var("LESAVKA_UVC_CODEC", Some("h264"), || { + let cfg = update_camera_config(); + assert_eq!(cfg.output, CameraOutput::Uvc); + assert_eq!(cfg.codec, CameraCodec::H264); + }); + }); +} + #[test] #[serial] fn hdmi_camera_profile_honors_installed_1080p_override() {