launcher: expose upstream webcam transport

This commit is contained in:
Brad Stein 2026-05-09 22:52:00 -03:00
parent 117323a10a
commit fb466abdfd
23 changed files with 280 additions and 39 deletions

6
Cargo.lock generated
View File

@ -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",

View File

@ -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]

View File

@ -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]

View File

@ -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,

View File

@ -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 {

View File

@ -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!(

View File

@ -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());
} }

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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"));
} }

View File

@ -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();

View File

@ -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]

View File

@ -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::<&gtk::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())

View File

@ -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,

View File

@ -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();

View File

@ -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,
}, },
}; };

View File

@ -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()));

View File

@ -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());
} }

View File

@ -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",

View File

@ -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"

View File

@ -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