fix: keep UVC codec handoff matched
This commit is contained in:
parent
52a6c00a36
commit
5024a2eb54
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.26.4"
|
||||
version = "0.26.5"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
|
||||
@ -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<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;
|
||||
|
||||
/// Resolve whether the live bundled uplink should force one startup epoch heal.
|
||||
|
||||
@ -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<CameraConfig> {
|
||||
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<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)]
|
||||
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);
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.26.4"
|
||||
version = "0.26.5"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.26.4"
|
||||
version = "0.26.5"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user