launcher: expose upstream webcam transport
This commit is contained in:
parent
117323a10a
commit
fb466abdfd
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.21.15"
|
version = "0.21.16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1686,7 +1686,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.21.15"
|
version = "0.21.16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1698,7 +1698,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.21.15"
|
version = "0.21.16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.21.15"
|
version = "0.21.16"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -22,7 +22,10 @@ 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 = parse_camera_codec(caps.camera_codec.as_deref()?)?;
|
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()?))?;
|
||||||
Some(CameraConfig {
|
Some(CameraConfig {
|
||||||
codec,
|
codec,
|
||||||
width: caps.camera_width?,
|
width: caps.camera_width?,
|
||||||
@ -63,6 +66,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use crate::handshake::PeerCaps;
|
use crate::handshake::PeerCaps;
|
||||||
use crate::input::camera::CameraCodec;
|
use crate::input::camera::CameraCodec;
|
||||||
|
use serial_test::serial;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -94,14 +98,47 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial]
|
||||||
fn camera_config_from_caps_requires_complete_profile() {
|
fn camera_config_from_caps_requires_complete_profile() {
|
||||||
let mut caps = PeerCaps {
|
temp_env::with_var("LESAVKA_CAM_CODEC", None::<&str>, || {
|
||||||
|
let mut caps = PeerCaps {
|
||||||
|
camera: true,
|
||||||
|
microphone: false,
|
||||||
|
bundled_webcam_media: false,
|
||||||
|
server_version: None,
|
||||||
|
camera_output: Some(String::from("uvc")),
|
||||||
|
camera_codec: Some(String::from("mjpeg")),
|
||||||
|
camera_width: Some(1280),
|
||||||
|
camera_height: Some(720),
|
||||||
|
camera_fps: Some(25),
|
||||||
|
eye_width: None,
|
||||||
|
eye_height: None,
|
||||||
|
eye_fps: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = camera_config_from_caps(&caps).expect("complete caps should map");
|
||||||
|
assert!(matches!(config.codec, CameraCodec::Mjpeg));
|
||||||
|
assert_eq!(config.width, 1280);
|
||||||
|
|
||||||
|
caps.camera_codec = Some(String::from("h265"));
|
||||||
|
let config = camera_config_from_caps(&caps).expect("h265 alias should map");
|
||||||
|
assert!(matches!(config.codec, CameraCodec::Hevc));
|
||||||
|
|
||||||
|
caps.camera_codec = Some(String::from("vp9"));
|
||||||
|
assert!(camera_config_from_caps(&caps).is_none());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn camera_config_from_caps_honors_launcher_codec_override() {
|
||||||
|
let caps = PeerCaps {
|
||||||
camera: true,
|
camera: true,
|
||||||
microphone: false,
|
microphone: false,
|
||||||
bundled_webcam_media: false,
|
bundled_webcam_media: false,
|
||||||
server_version: None,
|
server_version: None,
|
||||||
camera_output: Some(String::from("uvc")),
|
camera_output: Some(String::from("uvc")),
|
||||||
camera_codec: Some(String::from("mjpeg")),
|
camera_codec: Some(String::from("hevc")),
|
||||||
camera_width: Some(1280),
|
camera_width: Some(1280),
|
||||||
camera_height: Some(720),
|
camera_height: Some(720),
|
||||||
camera_fps: Some(25),
|
camera_fps: Some(25),
|
||||||
@ -110,16 +147,10 @@ mod tests {
|
|||||||
eye_fps: None,
|
eye_fps: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let config = camera_config_from_caps(&caps).expect("complete caps should map");
|
temp_env::with_var("LESAVKA_CAM_CODEC", Some("mjpeg"), || {
|
||||||
assert!(matches!(config.codec, CameraCodec::Mjpeg));
|
let config = camera_config_from_caps(&caps).expect("override should map");
|
||||||
assert_eq!(config.width, 1280);
|
assert!(matches!(config.codec, CameraCodec::Mjpeg));
|
||||||
|
});
|
||||||
caps.camera_codec = Some(String::from("h265"));
|
|
||||||
let config = camera_config_from_caps(&caps).expect("h265 alias should map");
|
|
||||||
assert!(matches!(config.codec, CameraCodec::Hevc));
|
|
||||||
|
|
||||||
caps.camera_codec = Some(String::from("vp9"));
|
|
||||||
assert!(camera_config_from_caps(&caps).is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -154,6 +154,7 @@ pub struct SnapshotReport {
|
|||||||
pub right_rendered_caps_label: String,
|
pub right_rendered_caps_label: String,
|
||||||
pub selected_camera: Option<String>,
|
pub selected_camera: Option<String>,
|
||||||
pub camera_quality_label: String,
|
pub camera_quality_label: String,
|
||||||
|
pub upstream_camera_transport: String,
|
||||||
pub selected_microphone: Option<String>,
|
pub selected_microphone: Option<String>,
|
||||||
pub selected_speaker: Option<String>,
|
pub selected_speaker: Option<String>,
|
||||||
pub media_channels: MediaChannelState,
|
pub media_channels: MediaChannelState,
|
||||||
|
|||||||
@ -228,6 +228,7 @@ impl SnapshotReport {
|
|||||||
.camera_quality
|
.camera_quality
|
||||||
.map(CameraMode::short_label)
|
.map(CameraMode::short_label)
|
||||||
.unwrap_or_else(|| "default".to_string()),
|
.unwrap_or_else(|| "default".to_string()),
|
||||||
|
upstream_camera_transport: state.webcam_transport.label().to_string(),
|
||||||
selected_microphone: state.devices.microphone.clone(),
|
selected_microphone: state.devices.microphone.clone(),
|
||||||
selected_speaker: state.devices.speaker.clone(),
|
selected_speaker: state.devices.speaker.clone(),
|
||||||
media_channels: MediaChannelState {
|
media_channels: MediaChannelState {
|
||||||
|
|||||||
@ -90,9 +90,10 @@ impl SnapshotReport {
|
|||||||
let _ = writeln!(text, "media staging");
|
let _ = writeln!(text, "media staging");
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
text,
|
text,
|
||||||
" camera: {} | quality={} | enabled={}",
|
" camera: {} | quality={} | transport={} | enabled={}",
|
||||||
self.selected_camera.as_deref().unwrap_or("auto"),
|
self.selected_camera.as_deref().unwrap_or("auto"),
|
||||||
self.camera_quality_label,
|
self.camera_quality_label,
|
||||||
|
self.upstream_camera_transport,
|
||||||
self.media_channels.camera
|
self.media_channels.camera
|
||||||
);
|
);
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
|
|||||||
@ -159,6 +159,10 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
|
|||||||
state.audio_gain_env_value(),
|
state.audio_gain_env_value(),
|
||||||
);
|
);
|
||||||
envs.insert("LESAVKA_MIC_GAIN".to_string(), state.mic_gain_env_value());
|
envs.insert("LESAVKA_MIC_GAIN".to_string(), state.mic_gain_env_value());
|
||||||
|
envs.insert(
|
||||||
|
"LESAVKA_CAM_CODEC".to_string(),
|
||||||
|
state.webcam_transport.env_value().to_string(),
|
||||||
|
);
|
||||||
if matches!(state.view_mode, ViewMode::Unified) {
|
if matches!(state.view_mode, ViewMode::Unified) {
|
||||||
envs.insert("LESAVKA_DISABLE_VIDEO_RENDER".to_string(), "1".to_string());
|
envs.insert("LESAVKA_DISABLE_VIDEO_RENDER".to_string(), "1".to_string());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -310,6 +310,10 @@ impl LauncherState {
|
|||||||
self.camera_quality = mode;
|
self.camera_quality = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn select_webcam_transport(&mut self, transport: WebcamTransport) {
|
||||||
|
self.webcam_transport = transport;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn camera_quality_options(&self, catalog: &DeviceCatalog) -> Vec<CameraMode> {
|
pub fn camera_quality_options(&self, catalog: &DeviceCatalog) -> Vec<CameraMode> {
|
||||||
self.devices
|
self.devices
|
||||||
.camera
|
.camera
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
impl LauncherState {
|
impl LauncherState {
|
||||||
pub fn status_line(&self) -> String {
|
pub fn status_line(&self) -> String {
|
||||||
format!(
|
format!(
|
||||||
"server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} remote_caps=cam:{:?}/mic:{:?}/output:{:?}/codec:{:?} cal={}:{:+.1}ms audio_gain={} mic_gain={} kbd={} mouse={} swap={}",
|
"server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} camera_transport={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} remote_caps=cam:{:?}/mic:{:?}/output:{:?}/codec:{:?} cal={}:{:+.1}ms audio_gain={} mic_gain={} kbd={} mouse={} swap={}",
|
||||||
self.server_available,
|
self.server_available,
|
||||||
match self.routing {
|
match self.routing {
|
||||||
InputRouting::Local => "local",
|
InputRouting::Local => "local",
|
||||||
@ -27,6 +27,7 @@ impl LauncherState {
|
|||||||
self.camera_quality
|
self.camera_quality
|
||||||
.map(CameraMode::short_label)
|
.map(CameraMode::short_label)
|
||||||
.unwrap_or_else(|| "default".to_string()),
|
.unwrap_or_else(|| "default".to_string()),
|
||||||
|
self.webcam_transport.label(),
|
||||||
media_status_label(self.channels.microphone, self.devices.microphone.as_deref()),
|
media_status_label(self.channels.microphone, self.devices.microphone.as_deref()),
|
||||||
media_status_label(self.channels.audio, self.devices.speaker.as_deref()),
|
media_status_label(self.channels.audio, self.devices.speaker.as_deref()),
|
||||||
self.channels.camera,
|
self.channels.camera,
|
||||||
|
|||||||
@ -27,6 +27,66 @@ impl InputRouting {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum WebcamTransport {
|
||||||
|
Hevc,
|
||||||
|
Mjpeg,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebcamTransport {
|
||||||
|
/// Return the stable GTK row id for the upstream webcam transport.
|
||||||
|
///
|
||||||
|
/// Inputs: the selected transport. Output: the id stored in the combo box.
|
||||||
|
/// Why: the UI must map operator intent onto the exact codec environment
|
||||||
|
/// variable the relay child consumes when it starts its webcam pipeline.
|
||||||
|
pub const fn as_id(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Hevc => "hevc",
|
||||||
|
Self::Mjpeg => "mjpeg",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a GTK row id back into an upstream webcam transport.
|
||||||
|
///
|
||||||
|
/// Inputs: raw combo id. Output: a supported transport when recognized.
|
||||||
|
/// Why: unsupported codecs should never silently enter launcher state,
|
||||||
|
/// since the server decoder path is calibrated per ingress codec.
|
||||||
|
pub fn from_id(raw: &str) -> Option<Self> {
|
||||||
|
match raw.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"hevc" | "h265" | "h.265" => Some(Self::Hevc),
|
||||||
|
"mjpeg" | "mjpg" | "jpeg" => Some(Self::Mjpeg),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the environment value consumed by the relay child.
|
||||||
|
///
|
||||||
|
/// Inputs: the selected transport. Output: canonical `LESAVKA_CAM_CODEC`.
|
||||||
|
/// Why: the launcher and headless client share the same camera pipeline
|
||||||
|
/// selector, so keeping this value canonical prevents HEVC/MJPEG drift.
|
||||||
|
pub const fn env_value(self) -> &'static str {
|
||||||
|
self.as_id()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short label displayed in the launcher dropdown.
|
||||||
|
///
|
||||||
|
/// Inputs: the selected transport. Output: a human-readable label. Why:
|
||||||
|
/// this makes the compact control understandable without implying YUY2 or
|
||||||
|
/// H.264 are ready for the current calibrated upstream path.
|
||||||
|
pub const fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Hevc => "HEVC",
|
||||||
|
Self::Mjpeg => "MJPEG",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WebcamTransport {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Hevc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum ViewMode {
|
pub enum ViewMode {
|
||||||
Unified,
|
Unified,
|
||||||
|
|||||||
@ -276,6 +276,7 @@ pub struct LauncherState {
|
|||||||
pub breakout_sizes: [BreakoutSizePreset; 2],
|
pub breakout_sizes: [BreakoutSizePreset; 2],
|
||||||
pub devices: DeviceSelection,
|
pub devices: DeviceSelection,
|
||||||
pub camera_quality: Option<CameraMode>,
|
pub camera_quality: Option<CameraMode>,
|
||||||
|
pub webcam_transport: WebcamTransport,
|
||||||
pub channels: ChannelSelection,
|
pub channels: ChannelSelection,
|
||||||
pub audio_gain_percent: u32,
|
pub audio_gain_percent: u32,
|
||||||
pub mic_gain_percent: u32,
|
pub mic_gain_percent: u32,
|
||||||
@ -313,6 +314,7 @@ impl Default for LauncherState {
|
|||||||
breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source],
|
breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source],
|
||||||
devices: DeviceSelection::default(),
|
devices: DeviceSelection::default(),
|
||||||
camera_quality: None,
|
camera_quality: None,
|
||||||
|
webcam_transport: WebcamTransport::default(),
|
||||||
channels: ChannelSelection::default(),
|
channels: ChannelSelection::default(),
|
||||||
audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT,
|
audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT,
|
||||||
mic_gain_percent: DEFAULT_MIC_GAIN_PERCENT,
|
mic_gain_percent: DEFAULT_MIC_GAIN_PERCENT,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::launcher::state::{
|
use crate::launcher::state::{
|
||||||
CaptureSizePreset, DeviceSelection, DisplaySurface, FeedSourcePreset, LauncherState,
|
CaptureSizePreset, DeviceSelection, DisplaySurface, FeedSourcePreset, LauncherState,
|
||||||
|
WebcamTransport,
|
||||||
};
|
};
|
||||||
use crate::uplink_telemetry::UpstreamStreamTelemetry;
|
use crate::uplink_telemetry::UpstreamStreamTelemetry;
|
||||||
|
|
||||||
@ -157,6 +158,7 @@ fn snapshot_report_contains_state_fields_and_samples() {
|
|||||||
assert!(report.left_stream_caps_label.contains("video/x-h264"));
|
assert!(report.left_stream_caps_label.contains("video/x-h264"));
|
||||||
assert!(report.left_decoded_caps_label.contains("video/x-raw"));
|
assert!(report.left_decoded_caps_label.contains("video/x-raw"));
|
||||||
assert!(report.left_rendered_caps_label.contains("video/x-raw"));
|
assert!(report.left_rendered_caps_label.contains("video/x-raw"));
|
||||||
|
assert_eq!(report.upstream_camera_transport, "HEVC");
|
||||||
assert_eq!(report.upstream_camera.queue_peak, 7);
|
assert_eq!(report.upstream_camera.queue_peak, 7);
|
||||||
assert_eq!(report.upstream_microphone.reconnect_count, 1);
|
assert_eq!(report.upstream_microphone.reconnect_count, 1);
|
||||||
}
|
}
|
||||||
@ -240,6 +242,7 @@ fn snapshot_text_reflects_live_media_control_changes() {
|
|||||||
state.select_camera_quality(Some(crate::launcher::devices::CameraMode::new(
|
state.select_camera_quality(Some(crate::launcher::devices::CameraMode::new(
|
||||||
1920, 1080, 30,
|
1920, 1080, 30,
|
||||||
)));
|
)));
|
||||||
|
state.select_webcam_transport(WebcamTransport::Mjpeg);
|
||||||
state.select_microphone(Some("alsa_input.usb".to_string()));
|
state.select_microphone(Some("alsa_input.usb".to_string()));
|
||||||
state.select_speaker(Some("alsa_output.usb".to_string()));
|
state.select_speaker(Some("alsa_output.usb".to_string()));
|
||||||
state.set_audio_gain_percent(250);
|
state.set_audio_gain_percent(250);
|
||||||
@ -254,7 +257,9 @@ fn snapshot_text_reflects_live_media_control_changes() {
|
|||||||
);
|
);
|
||||||
let text = report.to_pretty_text();
|
let text = report.to_pretty_text();
|
||||||
|
|
||||||
assert!(text.contains("camera: /dev/video9 | quality=1080p@30 | enabled=false"));
|
assert!(
|
||||||
|
text.contains("camera: /dev/video9 | quality=1080p@30 | transport=MJPEG | enabled=false")
|
||||||
|
);
|
||||||
assert!(text.contains("speaker: alsa_output.usb | volume=250% | enabled=true"));
|
assert!(text.contains("speaker: alsa_output.usb | volume=250% | enabled=true"));
|
||||||
assert!(text.contains("microphone: alsa_input.usb | gain=125% | enabled=true"));
|
assert!(text.contains("microphone: alsa_input.usb | gain=125% | enabled=true"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::launcher::state::WebcamTransport;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
@ -161,6 +162,7 @@ fn runtime_env_vars_emit_selected_controls() {
|
|||||||
);
|
);
|
||||||
assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"2.000".to_string()));
|
assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"2.000".to_string()));
|
||||||
assert_eq!(envs.get("LESAVKA_MIC_GAIN"), Some(&"1.000".to_string()));
|
assert_eq!(envs.get("LESAVKA_MIC_GAIN"), Some(&"1.000".to_string()));
|
||||||
|
assert_eq!(envs.get("LESAVKA_CAM_CODEC"), Some(&"hevc".to_string()));
|
||||||
assert_eq!(envs.get("LESAVKA_CAM_WIDTH"), Some(&"1920".to_string()));
|
assert_eq!(envs.get("LESAVKA_CAM_WIDTH"), Some(&"1920".to_string()));
|
||||||
assert_eq!(envs.get("LESAVKA_CAM_HEIGHT"), Some(&"1080".to_string()));
|
assert_eq!(envs.get("LESAVKA_CAM_HEIGHT"), Some(&"1080".to_string()));
|
||||||
assert_eq!(envs.get("LESAVKA_CAM_FPS"), Some(&"30".to_string()));
|
assert_eq!(envs.get("LESAVKA_CAM_FPS"), Some(&"30".to_string()));
|
||||||
@ -252,6 +254,21 @@ fn runtime_env_vars_keeps_remote_failsafe_disabled_for_invalid_launch_option() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_env_vars_emit_selected_webcam_transport() {
|
||||||
|
let mut state = LauncherState::new();
|
||||||
|
assert_eq!(
|
||||||
|
runtime_env_vars(&state).get("LESAVKA_CAM_CODEC"),
|
||||||
|
Some(&"hevc".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
state.select_webcam_transport(WebcamTransport::Mjpeg);
|
||||||
|
assert_eq!(
|
||||||
|
runtime_env_vars(&state).get("LESAVKA_CAM_CODEC"),
|
||||||
|
Some(&"mjpeg".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_env_vars_do_not_disable_breakout_video_windows() {
|
fn runtime_env_vars_do_not_disable_breakout_video_windows() {
|
||||||
let mut state = LauncherState::new();
|
let mut state = LauncherState::new();
|
||||||
|
|||||||
@ -8,6 +8,20 @@ fn routing_and_view_env_values_are_stable() {
|
|||||||
assert_eq!(ViewMode::Breakout.as_env(), "breakout");
|
assert_eq!(ViewMode::Breakout.as_env(), "breakout");
|
||||||
assert_eq!(DisplaySurface::Preview.label(), "preview");
|
assert_eq!(DisplaySurface::Preview.label(), "preview");
|
||||||
assert_eq!(DisplaySurface::Window.label(), "window");
|
assert_eq!(DisplaySurface::Window.label(), "window");
|
||||||
|
assert_eq!(WebcamTransport::default(), WebcamTransport::Hevc);
|
||||||
|
assert_eq!(WebcamTransport::Hevc.as_id(), "hevc");
|
||||||
|
assert_eq!(WebcamTransport::Mjpeg.as_id(), "mjpeg");
|
||||||
|
assert_eq!(WebcamTransport::Hevc.env_value(), "hevc");
|
||||||
|
assert_eq!(WebcamTransport::Mjpeg.label(), "MJPEG");
|
||||||
|
assert_eq!(
|
||||||
|
WebcamTransport::from_id("h265"),
|
||||||
|
Some(WebcamTransport::Hevc)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
WebcamTransport::from_id("jpeg"),
|
||||||
|
Some(WebcamTransport::Mjpeg)
|
||||||
|
);
|
||||||
|
assert_eq!(WebcamTransport::from_id("yuy2"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -2,7 +2,7 @@ use super::*;
|
|||||||
use crate::launcher::{
|
use crate::launcher::{
|
||||||
devices::{CameraMode, DeviceCatalog},
|
devices::{CameraMode, DeviceCatalog},
|
||||||
preview::PreviewBinding,
|
preview::PreviewBinding,
|
||||||
state::{BreakoutSizePreset, LauncherState, PreviewSourceSize},
|
state::{BreakoutSizePreset, LauncherState, PreviewSourceSize, WebcamTransport},
|
||||||
ui_components::build_launcher_view,
|
ui_components::build_launcher_view,
|
||||||
};
|
};
|
||||||
use crate::uplink_telemetry::UpstreamStreamTelemetry;
|
use crate::uplink_telemetry::UpstreamStreamTelemetry;
|
||||||
@ -322,6 +322,59 @@ fn recovery_buttons_use_upstream_device_labels() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gtk::test]
|
||||||
|
#[serial]
|
||||||
|
fn webcam_transport_combo_tracks_selected_upstream_codec() {
|
||||||
|
if gtk::gdk::Display::default().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let app = gtk::Application::builder()
|
||||||
|
.application_id("dev.lesavka.test-webcam-transport")
|
||||||
|
.build();
|
||||||
|
let _ = app.register(None::<>k::gio::Cancellable>);
|
||||||
|
|
||||||
|
let mut state = LauncherState::new();
|
||||||
|
state.set_camera_channel_enabled(true);
|
||||||
|
let view = build_launcher_view(
|
||||||
|
&app,
|
||||||
|
"http://127.0.0.1:50051",
|
||||||
|
&DeviceCatalog::default(),
|
||||||
|
&state,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
view.device_stage
|
||||||
|
.webcam_transport_combo
|
||||||
|
.active_id()
|
||||||
|
.as_deref(),
|
||||||
|
Some("hevc")
|
||||||
|
);
|
||||||
|
assert!(view.device_stage.webcam_transport_combo.is_sensitive());
|
||||||
|
|
||||||
|
state.select_webcam_transport(WebcamTransport::Mjpeg);
|
||||||
|
refresh_launcher_ui(&view.widgets, &state, false);
|
||||||
|
assert_eq!(
|
||||||
|
view.device_stage
|
||||||
|
.webcam_transport_combo
|
||||||
|
.active_id()
|
||||||
|
.as_deref(),
|
||||||
|
Some("mjpeg")
|
||||||
|
);
|
||||||
|
assert!(view.device_stage.webcam_transport_combo.is_sensitive());
|
||||||
|
|
||||||
|
refresh_launcher_ui(&view.widgets, &state, true);
|
||||||
|
assert!(!view.device_stage.webcam_transport_combo.is_sensitive());
|
||||||
|
assert!(
|
||||||
|
view.device_stage
|
||||||
|
.webcam_transport_combo
|
||||||
|
.tooltip_text()
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("Reconnect")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[gtk::test]
|
#[gtk::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn diagnostics_and_log_popouts_install_native_window_chrome() {
|
fn diagnostics_and_log_popouts_install_native_window_chrome() {
|
||||||
@ -515,7 +568,7 @@ fn uvc_chip_degrades_when_live_camera_frames_are_not_flowing() {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
recovery_uvc_health(&state, false, None),
|
recovery_uvc_health(&state, false, None),
|
||||||
(StatusLightState::Live, "MJPEG".to_string())
|
(StatusLightState::Live, "HEVC".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
recovery_uvc_health(&state, true, None),
|
recovery_uvc_health(&state, true, None),
|
||||||
@ -531,6 +584,12 @@ fn uvc_chip_degrades_when_live_camera_frames_are_not_flowing() {
|
|||||||
queue_depth: 3,
|
queue_depth: 3,
|
||||||
..UpstreamStreamTelemetry::default()
|
..UpstreamStreamTelemetry::default()
|
||||||
};
|
};
|
||||||
|
assert_eq!(
|
||||||
|
recovery_uvc_health(&state, true, Some(&healthy)),
|
||||||
|
(StatusLightState::Live, "HEVC".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
state.select_webcam_transport(WebcamTransport::Mjpeg);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
recovery_uvc_health(&state, true, Some(&healthy)),
|
recovery_uvc_health(&state, true, Some(&healthy)),
|
||||||
(StatusLightState::Live, "MJPEG".to_string())
|
(StatusLightState::Live, "MJPEG".to_string())
|
||||||
|
|||||||
@ -20,7 +20,7 @@ use {
|
|||||||
super::state::{
|
super::state::{
|
||||||
BreakoutSizePreset, CalibrationStatus, CapturePowerStatus, CaptureSizePreset,
|
BreakoutSizePreset, CalibrationStatus, CapturePowerStatus, CaptureSizePreset,
|
||||||
DisplaySurface, FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT,
|
DisplaySurface, FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT,
|
||||||
MAX_MIC_GAIN_PERCENT, UpstreamSyncStatus,
|
MAX_MIC_GAIN_PERCENT, UpstreamSyncStatus, WebcamTransport,
|
||||||
},
|
},
|
||||||
super::ui_components::{
|
super::ui_components::{
|
||||||
ConsoleLogLevel, build_launcher_view, sync_camera_quality_combo, sync_input_device_combo,
|
ConsoleLogLevel, build_launcher_view, sync_camera_quality_combo, sync_input_device_combo,
|
||||||
|
|||||||
@ -51,6 +51,35 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let state = Rc::clone(&state);
|
||||||
|
let widgets = widgets.clone();
|
||||||
|
let child_proc = Rc::clone(&child_proc);
|
||||||
|
let transport_combo = widgets.webcam_transport_combo.clone();
|
||||||
|
let transport_combo_read = transport_combo.clone();
|
||||||
|
transport_combo.connect_changed(move |_| {
|
||||||
|
let selected = transport_combo_read
|
||||||
|
.active_id()
|
||||||
|
.as_deref()
|
||||||
|
.and_then(WebcamTransport::from_id)
|
||||||
|
.unwrap_or_default();
|
||||||
|
state.borrow_mut().select_webcam_transport(selected);
|
||||||
|
let relay_live = child_proc.borrow().is_some();
|
||||||
|
if relay_live {
|
||||||
|
widgets.status_label.set_text(&format!(
|
||||||
|
"Webcam transport changed to {} for the next reconnect; keeping the live decoder path stable.",
|
||||||
|
selected.label()
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
widgets.status_label.set_text(&format!(
|
||||||
|
"Webcam transport set to {} for the next relay launch.",
|
||||||
|
selected.label()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
refresh_launcher_ui(&widgets, &state.borrow(), relay_live);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let state = Rc::clone(&state);
|
let state = Rc::clone(&state);
|
||||||
let widgets = widgets.clone();
|
let widgets = widgets.clone();
|
||||||
|
|||||||
@ -9,7 +9,7 @@ use super::{
|
|||||||
preview::{LauncherPreview, PreviewBinding, PreviewSurface},
|
preview::{LauncherPreview, PreviewBinding, PreviewSurface},
|
||||||
state::{
|
state::{
|
||||||
BreakoutSizeChoice, BreakoutSizePreset, CaptureSizeChoice, CaptureSizePreset,
|
BreakoutSizeChoice, BreakoutSizePreset, CaptureSizeChoice, CaptureSizePreset,
|
||||||
FeedSourceChoice, FeedSourcePreset, LauncherState,
|
FeedSourceChoice, FeedSourcePreset, LauncherState, WebcamTransport,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -330,14 +330,14 @@
|
|||||||
camera_preview_shell.append(&camera_preview_stack);
|
camera_preview_shell.append(&camera_preview_stack);
|
||||||
let webcam_transport_combo = gtk::ComboBoxText::new();
|
let webcam_transport_combo = gtk::ComboBoxText::new();
|
||||||
webcam_transport_combo.add_css_class("compact-combo");
|
webcam_transport_combo.add_css_class("compact-combo");
|
||||||
webcam_transport_combo.append(Some("mjpeg"), "MJPEG");
|
for transport in [WebcamTransport::Hevc, WebcamTransport::Mjpeg] {
|
||||||
webcam_transport_combo.append(Some("yuy2"), "YUY2");
|
webcam_transport_combo.append(Some(transport.as_id()), transport.label());
|
||||||
webcam_transport_combo.append(Some("h264"), "H.264");
|
}
|
||||||
webcam_transport_combo.set_active_id(Some("mjpeg"));
|
webcam_transport_combo.set_active_id(Some(state.webcam_transport.as_id()));
|
||||||
webcam_transport_combo.set_sensitive(false);
|
webcam_transport_combo.set_sensitive(true);
|
||||||
webcam_transport_combo.set_size_request(112, -1);
|
webcam_transport_combo.set_size_request(112, -1);
|
||||||
webcam_transport_combo.set_tooltip_text(Some(
|
webcam_transport_combo.set_tooltip_text(Some(
|
||||||
"Upstream transport format. MJPEG is pinned while sync hardening is in progress.",
|
"Upstream webcam transport for the next relay connection. HEVC is the low-latency default; MJPEG is the calibrated fallback.",
|
||||||
));
|
));
|
||||||
let webcam_group =
|
let webcam_group =
|
||||||
build_subgroup_with_action("Webcam Preview", Some(webcam_transport_combo.upcast_ref()));
|
build_subgroup_with_action("Webcam Preview", Some(webcam_transport_combo.upcast_ref()));
|
||||||
|
|||||||
@ -162,7 +162,7 @@ fn recovery_uac_health(
|
|||||||
media_stream_health(stream, MediaStreamKind::Microphone)
|
media_stream_health(stream, MediaStreamKind::Microphone)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Summarize whether the UVC camera function is advertised with the expected codec.
|
/// Summarize whether the UVC camera function is advertised with the selected upstream codec.
|
||||||
fn recovery_uvc_health(
|
fn recovery_uvc_health(
|
||||||
state: &LauncherState,
|
state: &LauncherState,
|
||||||
relay_live: bool,
|
relay_live: bool,
|
||||||
@ -171,11 +171,7 @@ fn recovery_uvc_health(
|
|||||||
if !state.server_available {
|
if !state.server_available {
|
||||||
return (StatusLightState::Idle, "Offline".to_string());
|
return (StatusLightState::Idle, "Offline".to_string());
|
||||||
}
|
}
|
||||||
let codec = state
|
let codec = state.webcam_transport.label().to_string();
|
||||||
.server_camera_codec
|
|
||||||
.as_deref()
|
|
||||||
.map(|value| value.to_ascii_uppercase())
|
|
||||||
.unwrap_or_else(|| "READY".to_string());
|
|
||||||
if state.server_camera == Some(false) {
|
if state.server_camera == Some(false) {
|
||||||
return (StatusLightState::Warning, "Missing".to_string());
|
return (StatusLightState::Warning, "Missing".to_string());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -226,7 +226,23 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
|||||||
widgets
|
widgets
|
||||||
.speaker_test_button
|
.speaker_test_button
|
||||||
.set_sensitive(!relay_live && state.channels.audio);
|
.set_sensitive(!relay_live && state.channels.audio);
|
||||||
widgets.webcam_transport_combo.set_sensitive(false);
|
if widgets.webcam_transport_combo.active_id().as_deref()
|
||||||
|
!= Some(state.webcam_transport.as_id())
|
||||||
|
{
|
||||||
|
widgets
|
||||||
|
.webcam_transport_combo
|
||||||
|
.set_active_id(Some(state.webcam_transport.as_id()));
|
||||||
|
}
|
||||||
|
widgets
|
||||||
|
.webcam_transport_combo
|
||||||
|
.set_sensitive(!relay_live && state.channels.camera);
|
||||||
|
widgets
|
||||||
|
.webcam_transport_combo
|
||||||
|
.set_tooltip_text(Some(if relay_live {
|
||||||
|
"Reconnect before changing the upstream webcam transport; the server decoder is calibrated per ingress codec."
|
||||||
|
} else {
|
||||||
|
"Choose HEVC for low-latency upstream video or MJPEG as the calibrated fallback for the next relay launch."
|
||||||
|
}));
|
||||||
widgets.input_toggle_button.set_label(match state.routing {
|
widgets.input_toggle_button.set_label(match state.routing {
|
||||||
InputRouting::Remote => "Route Local",
|
InputRouting::Remote => "Route Local",
|
||||||
InputRouting::Local => "Route Remote",
|
InputRouting::Local => "Route Remote",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.21.15"
|
version = "0.21.16"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.21.15"
|
version = "0.21.16"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user