fix: keep UVC codec handoff matched

This commit is contained in:
Brad Stein 2026-05-20 16:37:22 -03:00
parent 52a6c00a36
commit 5024a2eb54
13 changed files with 168 additions and 21 deletions

6
Cargo.lock generated
View File

@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.26.4" version = "0.26.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1692,7 +1692,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.26.4" version = "0.26.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1704,7 +1704,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.26.4" version = "0.26.5"
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.26.4" version = "0.26.5"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -149,8 +149,9 @@ use super::*;
} }
#[test] #[test]
/// Keeps live camera codec override behavior explicit because the launcher can switch transport without restarting the whole app. /// Keeps live camera codec switching bounded to the negotiated server UVC contract.
fn live_camera_codec_override_updates_capture_config_aliases() { #[serial_test::serial]
fn live_camera_codec_override_obeys_server_contract_unless_forced() {
use crate::input::camera::{CameraCodec, CameraConfig}; use crate::input::camera::{CameraCodec, CameraConfig};
use crate::live_media_control::MediaCameraCodecChoice; use crate::live_media_control::MediaCameraCodecChoice;
@ -180,10 +181,21 @@ use super::*;
Some(cfg), Some(cfg),
&MediaCameraCodecChoice::selected(Some("hevc".to_string())), &MediaCameraCodecChoice::selected(Some("hevc".to_string())),
) )
.expect("overridden config") .expect("mismatched config should stay negotiated")
.codec, .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()); assert!(camera_config_with_live_codec(None, &MediaCameraCodecChoice::Inherit).is_none());
} }

View File

@ -381,7 +381,16 @@ fn camera_config_with_live_codec(
.as_deref() .as_deref()
.and_then(parse_live_camera_codec) .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) Some(cfg)
} }
@ -403,6 +412,13 @@ fn parse_live_camera_codec(raw: &str) -> Option<crate::input::camera::CameraCode
} }
} }
fn live_codec_mismatch_allowed() -> 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; const DEFAULT_UPSTREAM_AUTO_HEAL_AFTER_MS: u64 = 3_000;
/// Resolve whether the live bundled uplink should force one startup epoch heal. /// Resolve whether the live bundled uplink should force one startup epoch heal.

View File

@ -22,10 +22,15 @@ pub fn resolve_server_addr(args: &[String], env_addr: Option<&str>) -> String {
#[must_use] #[must_use]
/// Build local camera capture settings from negotiated peer capabilities. /// Build local camera capture settings from negotiated peer capabilities.
pub fn camera_config_from_caps(caps: &PeerCaps) -> Option<CameraConfig> { pub fn camera_config_from_caps(caps: &PeerCaps) -> Option<CameraConfig> {
let codec = std::env::var("LESAVKA_CAM_CODEC") let negotiated_codec = parse_camera_codec(caps.camera_codec.as_deref()?)?;
.ok() let codec = if env_flag_enabled("LESAVKA_CAM_CODEC_FORCE") {
.and_then(|value| parse_camera_codec(&value)) std::env::var("LESAVKA_CAM_CODEC")
.or_else(|| parse_camera_codec(caps.camera_codec.as_deref()?))?; .ok()
.and_then(|value| parse_camera_codec(&value))
.unwrap_or(negotiated_codec)
} else {
negotiated_codec
};
Some(CameraConfig { Some(CameraConfig {
codec, codec,
width: caps.camera_width?, width: caps.camera_width?,
@ -63,6 +68,13 @@ pub(crate) fn parse_camera_codec(raw: &str) -> Option<CameraCodec> {
} }
} }
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)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
@ -136,7 +148,7 @@ mod tests {
#[test] #[test]
#[serial] #[serial]
fn camera_config_from_caps_honors_launcher_codec_override() { fn camera_config_from_caps_uses_negotiated_codec_over_launcher_default() {
let caps = PeerCaps { let caps = PeerCaps {
camera: true, camera: true,
microphone: false, microphone: false,
@ -153,11 +165,72 @@ mod tests {
}; };
temp_env::with_var("LESAVKA_CAM_CODEC", Some("mjpeg"), || { temp_env::with_var("LESAVKA_CAM_CODEC", Some("mjpeg"), || {
let config = camera_config_from_caps(&caps).expect("override should map"); let config = camera_config_from_caps(&caps).expect("caps codec should map");
assert!(matches!(config.codec, CameraCodec::Mjpeg)); 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] #[test]
fn sanitize_video_queue_enforces_floor() { fn sanitize_video_queue_enforces_floor() {
assert_eq!(sanitize_video_queue(None), 8); assert_eq!(sanitize_video_queue(None), 8);

View File

@ -315,6 +315,14 @@ impl LauncherState {
} }
pub fn effective_webcam_transport(&self) -> WebcamTransport { 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 self.webcam_transport
} }

View File

@ -282,6 +282,17 @@ fn runtime_env_vars_emit_selected_webcam_transport() {
Some("uvc".to_string()), Some("uvc".to_string()),
Some("mjpeg".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!( assert_eq!(
runtime_env_vars(&state).get("LESAVKA_CAM_CODEC"), runtime_env_vars(&state).get("LESAVKA_CAM_CODEC"),
Some(&"hevc".to_string()) Some(&"hevc".to_string())

View File

@ -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_output, None);
assert_eq!(state.server_camera_codec.as_deref(), Some("mjpeg")); assert_eq!(state.server_camera_codec.as_deref(), Some("mjpeg"));
state.select_webcam_transport(WebcamTransport::Hevc); 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( state.set_server_media_caps(
None, 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_output.as_deref(), Some("uvc"));
assert_eq!(state.server_camera_codec, None); assert_eq!(state.server_camera_codec, None);
assert_eq!(state.webcam_transport, WebcamTransport::Hevc); assert_eq!(state.webcam_transport, WebcamTransport::Hevc);
assert_eq!(state.effective_webcam_transport(), WebcamTransport::Hevc);
} }
#[test] #[test]

View File

@ -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())); 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"); 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("camera=1"), "{raw}");
assert!(raw.contains("microphone=0"), "{raw}"); assert!(raw.contains("microphone=0"), "{raw}");
assert!(raw.contains("audio=1"), "{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("microphone_source=b64:"), "{raw}");
assert!(raw.contains("audio_sink=b64:"), "{raw}"); assert!(raw.contains("audio_sink=b64:"), "{raw}");
assert!(raw.contains("camera_codec=mjpeg"), "{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] #[gtk::test]

View File

@ -159,7 +159,7 @@ pub fn write_media_control_request(path: &Path, state: &LauncherState) -> Result
state.camera_quality.map(|mode| mode.id()), state.camera_quality.map(|mode| mode.id()),
state.devices.microphone.clone(), state.devices.microphone.clone(),
state.devices.speaker.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.upstream_audio_transport.as_common_codec(),
state.mic_noise_suppression, state.mic_noise_suppression,
), ),

View File

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

View File

@ -16,7 +16,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.26.4" version = "0.26.5"
edition = "2024" edition = "2024"
autobins = false autobins = false

View File

@ -36,9 +36,12 @@ impl WebcamSink {
target:"lesavka_server::video", target:"lesavka_server::video",
bytes = pkt.data.len(), bytes = pkt.data.len(),
pts = pkt.pts, 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; return;
} }