diff --git a/Cargo.lock b/Cargo.lock index 5b56c19..7f95725 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.26.4" +version = "0.26.5" dependencies = [ "anyhow", "async-stream", @@ -1692,7 +1692,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.26.4" +version = "0.26.5" dependencies = [ "anyhow", "base64", @@ -1704,7 +1704,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.26.4" +version = "0.26.5" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 866a479..fd4eb70 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.26.4" +version = "0.26.5" edition = "2024" [dependencies] diff --git a/client/src/app/uplink_media/tests/mod.rs b/client/src/app/uplink_media/tests/mod.rs index 43e2ae9..0ee31b4 100644 --- a/client/src/app/uplink_media/tests/mod.rs +++ b/client/src/app/uplink_media/tests/mod.rs @@ -149,8 +149,9 @@ use super::*; } #[test] - /// Keeps live camera codec override behavior explicit because the launcher can switch transport without restarting the whole app. - fn live_camera_codec_override_updates_capture_config_aliases() { + /// Keeps live camera codec switching bounded to the negotiated server UVC contract. + #[serial_test::serial] + fn live_camera_codec_override_obeys_server_contract_unless_forced() { use crate::input::camera::{CameraCodec, CameraConfig}; use crate::live_media_control::MediaCameraCodecChoice; @@ -180,10 +181,21 @@ use super::*; Some(cfg), &MediaCameraCodecChoice::selected(Some("hevc".to_string())), ) - .expect("overridden config") + .expect("mismatched config should stay negotiated") .codec, - CameraCodec::Hevc + CameraCodec::Mjpeg ); + temp_env::with_var("LESAVKA_CAM_CODEC_FORCE", Some("1"), || { + assert_eq!( + camera_config_with_live_codec( + Some(cfg), + &MediaCameraCodecChoice::selected(Some("hevc".to_string())), + ) + .expect("forced mismatch should override") + .codec, + CameraCodec::Hevc + ); + }); assert!(camera_config_with_live_codec(None, &MediaCameraCodecChoice::Inherit).is_none()); } diff --git a/client/src/app/uplink_media/webcam_media_loop.rs b/client/src/app/uplink_media/webcam_media_loop.rs index 0188c23..0ea658b 100644 --- a/client/src/app/uplink_media/webcam_media_loop.rs +++ b/client/src/app/uplink_media/webcam_media_loop.rs @@ -381,7 +381,16 @@ fn camera_config_with_live_codec( .as_deref() .and_then(parse_live_camera_codec) { - cfg.codec = codec; + if codec == cfg.codec || live_codec_mismatch_allowed() { + cfg.codec = codec; + } else { + tracing::warn!( + target: "lesavka_client::camera", + requested = camera_codec_id(codec), + negotiated = fallback, + "ignoring live camera codec switch that would mismatch the server UVC contract" + ); + } } Some(cfg) } @@ -403,6 +412,13 @@ fn parse_live_camera_codec(raw: &str) -> Option bool { + std::env::var("LESAVKA_CAM_CODEC_FORCE") + .ok() + .map(|value| value.trim().to_ascii_lowercase()) + .is_some_and(|value| matches!(value.as_str(), "1" | "true" | "yes" | "on")) +} + const DEFAULT_UPSTREAM_AUTO_HEAL_AFTER_MS: u64 = 3_000; /// Resolve whether the live bundled uplink should force one startup epoch heal. diff --git a/client/src/app_support.rs b/client/src/app_support.rs index fa89c8e..434aa8d 100644 --- a/client/src/app_support.rs +++ b/client/src/app_support.rs @@ -22,10 +22,15 @@ pub fn resolve_server_addr(args: &[String], env_addr: Option<&str>) -> String { #[must_use] /// Build local camera capture settings from negotiated peer capabilities. pub fn camera_config_from_caps(caps: &PeerCaps) -> Option { - let codec = std::env::var("LESAVKA_CAM_CODEC") - .ok() - .and_then(|value| parse_camera_codec(&value)) - .or_else(|| parse_camera_codec(caps.camera_codec.as_deref()?))?; + let negotiated_codec = parse_camera_codec(caps.camera_codec.as_deref()?)?; + let codec = if env_flag_enabled("LESAVKA_CAM_CODEC_FORCE") { + std::env::var("LESAVKA_CAM_CODEC") + .ok() + .and_then(|value| parse_camera_codec(&value)) + .unwrap_or(negotiated_codec) + } else { + negotiated_codec + }; Some(CameraConfig { codec, width: caps.camera_width?, @@ -63,6 +68,13 @@ pub(crate) fn parse_camera_codec(raw: &str) -> Option { } } +fn env_flag_enabled(name: &str) -> bool { + std::env::var(name) + .ok() + .map(|value| value.trim().to_ascii_lowercase()) + .is_some_and(|value| matches!(value.as_str(), "1" | "true" | "yes" | "on")) +} + #[cfg(test)] mod tests { use super::{ @@ -136,7 +148,7 @@ mod tests { #[test] #[serial] - fn camera_config_from_caps_honors_launcher_codec_override() { + fn camera_config_from_caps_uses_negotiated_codec_over_launcher_default() { let caps = PeerCaps { camera: true, microphone: false, @@ -153,11 +165,72 @@ mod tests { }; temp_env::with_var("LESAVKA_CAM_CODEC", Some("mjpeg"), || { - let config = camera_config_from_caps(&caps).expect("override should map"); - assert!(matches!(config.codec, CameraCodec::Mjpeg)); + let config = camera_config_from_caps(&caps).expect("caps codec should map"); + assert!(matches!(config.codec, CameraCodec::Hevc)); }); } + #[test] + #[serial] + fn camera_config_from_caps_allows_explicit_forced_codec_override() { + let caps = PeerCaps { + camera: true, + microphone: false, + bundled_webcam_media: false, + server_version: None, + camera_output: Some(String::from("uvc")), + camera_codec: Some(String::from("hevc")), + camera_width: Some(1280), + camera_height: Some(720), + camera_fps: Some(25), + eye_width: None, + eye_height: None, + eye_fps: None, + }; + + temp_env::with_vars( + [ + ("LESAVKA_CAM_CODEC", Some("mjpeg")), + ("LESAVKA_CAM_CODEC_FORCE", Some("1")), + ], + || { + let config = camera_config_from_caps(&caps).expect("forced override should map"); + assert!(matches!(config.codec, CameraCodec::Mjpeg)); + }, + ); + } + + #[test] + #[serial] + fn camera_config_force_flag_ignores_unknown_override() { + let caps = PeerCaps { + camera: true, + microphone: false, + bundled_webcam_media: false, + server_version: None, + camera_output: Some(String::from("uvc")), + camera_codec: Some(String::from("hevc")), + camera_width: Some(1280), + camera_height: Some(720), + camera_fps: Some(25), + eye_width: None, + eye_height: None, + eye_fps: None, + }; + + temp_env::with_vars( + [ + ("LESAVKA_CAM_CODEC", Some("vp9")), + ("LESAVKA_CAM_CODEC_FORCE", Some("1")), + ], + || { + let config = + camera_config_from_caps(&caps).expect("bad forced override should fall back"); + assert!(matches!(config.codec, CameraCodec::Hevc)); + }, + ); + } + #[test] fn sanitize_video_queue_enforces_floor() { assert_eq!(sanitize_video_queue(None), 8); diff --git a/client/src/launcher/state/launcher_state_impl.rs b/client/src/launcher/state/launcher_state_impl.rs index 73bd722..7196fda 100644 --- a/client/src/launcher/state/launcher_state_impl.rs +++ b/client/src/launcher/state/launcher_state_impl.rs @@ -315,6 +315,14 @@ impl LauncherState { } pub fn effective_webcam_transport(&self) -> WebcamTransport { + if matches!(self.server_camera_output.as_deref(), Some("uvc")) + && let Some(codec) = self + .server_camera_codec + .as_deref() + .and_then(WebcamTransport::from_server_codec) + { + return codec; + } self.webcam_transport } diff --git a/client/src/launcher/tests/mod.rs b/client/src/launcher/tests/mod.rs index d495c83..281bc51 100644 --- a/client/src/launcher/tests/mod.rs +++ b/client/src/launcher/tests/mod.rs @@ -282,6 +282,17 @@ fn runtime_env_vars_emit_selected_webcam_transport() { Some("uvc".to_string()), Some("mjpeg".to_string()), ); + assert_eq!( + runtime_env_vars(&state).get("LESAVKA_CAM_CODEC"), + Some(&"mjpeg".to_string()) + ); + + state.set_server_media_caps( + None, + None, + Some("uvc".to_string()), + Some("hevc".to_string()), + ); assert_eq!( runtime_env_vars(&state).get("LESAVKA_CAM_CODEC"), Some(&"hevc".to_string()) diff --git a/client/src/launcher/tests/state.rs b/client/src/launcher/tests/state.rs index f38be75..4166808 100644 --- a/client/src/launcher/tests/state.rs +++ b/client/src/launcher/tests/state.rs @@ -492,6 +492,19 @@ fn server_identity_and_media_caps_trim_blank_values() { assert_eq!(state.server_camera_output, None); assert_eq!(state.server_camera_codec.as_deref(), Some("mjpeg")); state.select_webcam_transport(WebcamTransport::Hevc); + assert_eq!(state.effective_webcam_transport(), WebcamTransport::Hevc); + + state.set_server_media_caps( + Some(true), + Some(false), + Some("uvc".to_string()), + Some("mjpeg".to_string()), + ); + assert_eq!( + state.effective_webcam_transport(), + WebcamTransport::Mjpeg, + "UVC handoff should follow the server codec contract" + ); state.set_server_media_caps( None, @@ -504,6 +517,7 @@ fn server_identity_and_media_caps_trim_blank_values() { assert_eq!(state.server_camera_output.as_deref(), Some("uvc")); assert_eq!(state.server_camera_codec, None); assert_eq!(state.webcam_transport, WebcamTransport::Hevc); + assert_eq!(state.effective_webcam_transport(), WebcamTransport::Hevc); } #[test] diff --git a/client/src/launcher/tests/ui_runtime.rs b/client/src/launcher/tests/ui_runtime.rs index ce71c2b..37754df 100644 --- a/client/src/launcher/tests/ui_runtime.rs +++ b/client/src/launcher/tests/ui_runtime.rs @@ -1001,7 +1001,7 @@ fn write_media_control_request_formats_soft_pause_state() { state.select_speaker(Some("bluez_output.80_C3_BA_76_26_AB.1".to_string())); write_media_control_request(&path, &state).expect("write media control"); - let raw = std::fs::read_to_string(path).expect("read media control"); + let raw = std::fs::read_to_string(&path).expect("read media control"); assert!(raw.contains("camera=1"), "{raw}"); assert!(raw.contains("microphone=0"), "{raw}"); assert!(raw.contains("audio=1"), "{raw}"); @@ -1010,6 +1010,16 @@ fn write_media_control_request_formats_soft_pause_state() { assert!(raw.contains("microphone_source=b64:"), "{raw}"); assert!(raw.contains("audio_sink=b64:"), "{raw}"); assert!(raw.contains("camera_codec=mjpeg"), "{raw}"); + + state.set_server_media_caps( + Some(true), + Some(true), + Some("uvc".to_string()), + Some("hevc".to_string()), + ); + write_media_control_request(&path, &state).expect("write server-matched media control"); + let raw = std::fs::read_to_string(path).expect("read server-matched media control"); + assert!(raw.contains("camera_codec=hevc"), "{raw}"); } #[gtk::test] diff --git a/client/src/launcher/ui_runtime/control_paths.rs b/client/src/launcher/ui_runtime/control_paths.rs index aa1d3db..43d4a51 100644 --- a/client/src/launcher/ui_runtime/control_paths.rs +++ b/client/src/launcher/ui_runtime/control_paths.rs @@ -159,7 +159,7 @@ pub fn write_media_control_request(path: &Path, state: &LauncherState) -> Result state.camera_quality.map(|mode| mode.id()), state.devices.microphone.clone(), state.devices.speaker.clone(), - Some(state.webcam_transport.as_id().to_string()), + Some(state.effective_webcam_transport().as_id().to_string()), state.upstream_audio_transport.as_common_codec(), state.mic_noise_suppression, ), diff --git a/common/Cargo.toml b/common/Cargo.toml index 92648f3..cca839f 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.26.4" +version = "0.26.5" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index 49858fe..c2bc56c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,7 +16,7 @@ bench = false [package] name = "lesavka_server" -version = "0.26.4" +version = "0.26.5" edition = "2024" autobins = false diff --git a/server/src/video_sinks/webcam_sink/frame_handoff.rs b/server/src/video_sinks/webcam_sink/frame_handoff.rs index 596e1ec..c15c85a 100644 --- a/server/src/video_sinks/webcam_sink/frame_handoff.rs +++ b/server/src/video_sinks/webcam_sink/frame_handoff.rs @@ -36,9 +36,12 @@ impl WebcamSink { target:"lesavka_server::video", bytes = pkt.data.len(), pts = pkt.pts, - "dropping MJPEG packet received while HEVC UVC decoder is active; restart or update the upstream client so it emits HEVC" + "MJPEG packet received while HEVC UVC decoder is active; falling back to guarded direct MJPEG spool to avoid freezing output" ); } + if let Some(path) = &self.mjpeg_spool_path { + self.spool_direct_mjpeg_frame(path, &pkt); + } return; }