diff --git a/Cargo.lock b/Cargo.lock index 6e9ace3..08c75e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.22.20" +version = "0.22.21" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.22.20" +version = "0.22.21" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.22.20" +version = "0.22.21" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 3e9ac9c..b145812 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.22.20" +version = "0.22.21" edition = "2024" [dependencies] diff --git a/client/src/launcher/diagnostics/snapshot_report.rs b/client/src/launcher/diagnostics/snapshot_report.rs index 1787c15..b140424 100644 --- a/client/src/launcher/diagnostics/snapshot_report.rs +++ b/client/src/launcher/diagnostics/snapshot_report.rs @@ -228,7 +228,7 @@ impl SnapshotReport { .camera_quality .map(CameraMode::short_label) .unwrap_or_else(|| "default".to_string()), - upstream_camera_transport: state.webcam_transport.label().to_string(), + upstream_camera_transport: state.effective_webcam_transport().label().to_string(), selected_microphone: state.devices.microphone.clone(), selected_speaker: state.devices.speaker.clone(), media_channels: MediaChannelState { diff --git a/client/src/launcher/mod.rs b/client/src/launcher/mod.rs index dd56f77..25f7f68 100644 --- a/client/src/launcher/mod.rs +++ b/client/src/launcher/mod.rs @@ -161,7 +161,7 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap { 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(), + state.effective_webcam_transport().env_value().to_string(), ); envs.insert( "LESAVKA_UPLINK_AUDIO_CODEC".to_string(), diff --git a/client/src/launcher/state/launcher_state_impl.rs b/client/src/launcher/state/launcher_state_impl.rs index 89b15c1..c5a7931 100644 --- a/client/src/launcher/state/launcher_state_impl.rs +++ b/client/src/launcher/state/launcher_state_impl.rs @@ -47,6 +47,13 @@ impl LauncherState { Some(trimmed.to_string()) } }); + if let Some(transport) = self + .server_camera_codec + .as_deref() + .and_then(WebcamTransport::from_server_codec) + { + self.webcam_transport = transport; + } } pub fn set_view_mode(&mut self, view_mode: ViewMode) { @@ -314,6 +321,13 @@ impl LauncherState { self.webcam_transport = transport; } + pub fn effective_webcam_transport(&self) -> WebcamTransport { + self.server_camera_codec + .as_deref() + .and_then(WebcamTransport::from_server_codec) + .unwrap_or(self.webcam_transport) + } + pub fn select_upstream_audio_transport(&mut self, transport: UpstreamAudioTransport) { self.upstream_audio_transport = transport; } diff --git a/client/src/launcher/state/launcher_status_line.rs b/client/src/launcher/state/launcher_status_line.rs index 1fddf49..441a01f 100644 --- a/client/src/launcher/state/launcher_status_line.rs +++ b/client/src/launcher/state/launcher_status_line.rs @@ -27,7 +27,7 @@ impl LauncherState { self.camera_quality .map(CameraMode::short_label) .unwrap_or_else(|| "default".to_string()), - self.webcam_transport.label(), + self.effective_webcam_transport().label(), media_status_label(self.channels.microphone, self.devices.microphone.as_deref()), self.upstream_audio_transport.label(), self.mic_noise_suppression, diff --git a/client/src/launcher/state/selection_models.rs b/client/src/launcher/state/selection_models.rs index dc1612f..f585844 100644 --- a/client/src/launcher/state/selection_models.rs +++ b/client/src/launcher/state/selection_models.rs @@ -29,8 +29,8 @@ impl InputRouting { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum WebcamTransport { - #[default] Hevc, + #[default] Mjpeg, } @@ -60,6 +60,16 @@ impl WebcamTransport { } } + /// Parse the camera codec advertised by the server caps response. + /// + /// Inputs: raw server codec string. Output: matching launcher transport. + /// Why: the relay child must send the codec the server is currently + /// configured to consume, otherwise MJPEG UVC can receive HEVC bytes and + /// browsers show a black webcam preview. + pub fn from_server_codec(raw: &str) -> Option { + Self::from_id(raw) + } + /// Return the environment value consumed by the relay child. /// /// Inputs: the selected transport. Output: canonical `LESAVKA_CAM_CODEC`. diff --git a/client/src/launcher/tests/diagnostics.rs b/client/src/launcher/tests/diagnostics.rs index 7144315..78f5d0d 100644 --- a/client/src/launcher/tests/diagnostics.rs +++ b/client/src/launcher/tests/diagnostics.rs @@ -158,7 +158,7 @@ fn snapshot_report_contains_state_fields_and_samples() { assert!(report.left_stream_caps_label.contains("video/x-h264")); assert!(report.left_decoded_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_transport, "MJPEG"); assert_eq!(report.upstream_camera.queue_peak, 7); assert_eq!(report.upstream_microphone.reconnect_count, 1); } diff --git a/client/src/launcher/tests/mod.rs b/client/src/launcher/tests/mod.rs index d61e578..4b92b68 100644 --- a/client/src/launcher/tests/mod.rs +++ b/client/src/launcher/tests/mod.rs @@ -162,7 +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_MIC_GAIN"), Some(&"1.000".to_string())); - assert_eq!(envs.get("LESAVKA_CAM_CODEC"), Some(&"hevc".to_string())); + assert_eq!(envs.get("LESAVKA_CAM_CODEC"), Some(&"mjpeg".to_string())); assert_eq!( envs.get("LESAVKA_UPLINK_AUDIO_CODEC"), Some(&"opus".to_string()) @@ -265,12 +265,23 @@ 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(&"mjpeg".to_string()) + ); + + state.select_webcam_transport(WebcamTransport::Hevc); assert_eq!( runtime_env_vars(&state).get("LESAVKA_CAM_CODEC"), Some(&"hevc".to_string()) ); - state.select_webcam_transport(WebcamTransport::Mjpeg); + state.set_server_media_caps( + None, + None, + Some("uvc".to_string()), + Some("mjpeg".to_string()), + ); assert_eq!( runtime_env_vars(&state).get("LESAVKA_CAM_CODEC"), Some(&"mjpeg".to_string()) diff --git a/client/src/launcher/tests/state.rs b/client/src/launcher/tests/state.rs index 312d7df..48726a2 100644 --- a/client/src/launcher/tests/state.rs +++ b/client/src/launcher/tests/state.rs @@ -8,7 +8,7 @@ fn routing_and_view_env_values_are_stable() { assert_eq!(ViewMode::Breakout.as_env(), "breakout"); assert_eq!(DisplaySurface::Preview.label(), "preview"); assert_eq!(DisplaySurface::Window.label(), "window"); - assert_eq!(WebcamTransport::default(), WebcamTransport::Hevc); + assert_eq!(WebcamTransport::default(), WebcamTransport::Mjpeg); assert_eq!(WebcamTransport::Hevc.as_id(), "hevc"); assert_eq!(WebcamTransport::Mjpeg.as_id(), "mjpeg"); assert_eq!(WebcamTransport::Hevc.env_value(), "hevc"); @@ -17,6 +17,10 @@ fn routing_and_view_env_values_are_stable() { WebcamTransport::from_id("h265"), Some(WebcamTransport::Hevc) ); + assert_eq!( + WebcamTransport::from_server_codec("mjpeg"), + Some(WebcamTransport::Mjpeg) + ); assert_eq!( WebcamTransport::from_id("jpeg"), Some(WebcamTransport::Mjpeg) diff --git a/client/src/launcher/tests/ui_runtime.rs b/client/src/launcher/tests/ui_runtime.rs index 46b9454..e074028 100644 --- a/client/src/launcher/tests/ui_runtime.rs +++ b/client/src/launcher/tests/ui_runtime.rs @@ -411,18 +411,18 @@ fn webcam_transport_combo_tracks_selected_upstream_codec() { .webcam_transport_combo .active_id() .as_deref(), - Some("hevc") + Some("mjpeg") ); assert!(view.device_stage.webcam_transport_combo.is_sensitive()); - state.select_webcam_transport(WebcamTransport::Mjpeg); + state.select_webcam_transport(WebcamTransport::Hevc); refresh_launcher_ui(&view.widgets, &state, false); assert_eq!( view.device_stage .webcam_transport_combo .active_id() .as_deref(), - Some("mjpeg") + Some("hevc") ); assert!(view.device_stage.webcam_transport_combo.is_sensitive()); @@ -682,7 +682,7 @@ fn uvc_chip_degrades_when_live_camera_frames_are_not_flowing() { assert_eq!( recovery_uvc_health(&state, false, None), - (StatusLightState::Live, "HEVC".to_string()) + (StatusLightState::Live, "MJPEG".to_string()) ); assert_eq!( recovery_uvc_health(&state, true, None), @@ -700,7 +700,7 @@ fn uvc_chip_degrades_when_live_camera_frames_are_not_flowing() { }; assert_eq!( recovery_uvc_health(&state, true, Some(&healthy)), - (StatusLightState::Live, "HEVC".to_string()) + (StatusLightState::Live, "MJPEG".to_string()) ); state.set_server_media_caps(None, None, None, None); @@ -710,7 +710,7 @@ fn uvc_chip_degrades_when_live_camera_frames_are_not_flowing() { ); assert_eq!( recovery_uvc_health(&state, true, Some(&healthy)), - (StatusLightState::Live, "HEVC".to_string()) + (StatusLightState::Live, "MJPEG".to_string()) ); state.select_webcam_transport(WebcamTransport::Mjpeg); diff --git a/client/src/launcher/ui_components/build_device_controls.rs b/client/src/launcher/ui_components/build_device_controls.rs index 3b1532f..f6ecb69 100644 --- a/client/src/launcher/ui_components/build_device_controls.rs +++ b/client/src/launcher/ui_components/build_device_controls.rs @@ -227,11 +227,11 @@ for transport in [WebcamTransport::Hevc, WebcamTransport::Mjpeg] { webcam_transport_combo.append(Some(transport.as_id()), transport.label()); } - webcam_transport_combo.set_active_id(Some(state.webcam_transport.as_id())); + webcam_transport_combo.set_active_id(Some(state.effective_webcam_transport().as_id())); webcam_transport_combo.set_sensitive(true); webcam_transport_combo.set_size_request(98, -1); webcam_transport_combo.set_tooltip_text(Some( - "Upstream webcam transport for the next relay connection. HEVC is the low-latency default; MJPEG is the calibrated fallback.", + "Upstream webcam transport for the next relay connection. MJPEG is the safe calibrated default; HEVC is used only when the server advertises it.", )); let upstream_audio_transport_combo = gtk::ComboBoxText::new(); diff --git a/client/src/launcher/ui_runtime/status_details.rs b/client/src/launcher/ui_runtime/status_details.rs index 125c06a..3c06c97 100644 --- a/client/src/launcher/ui_runtime/status_details.rs +++ b/client/src/launcher/ui_runtime/status_details.rs @@ -190,7 +190,7 @@ fn recovery_uvc_health( if !state.server_available { return (StatusLightState::Idle, "Offline".to_string()); } - let codec = state.webcam_transport.label().to_string(); + let codec = state.effective_webcam_transport().label().to_string(); if state.server_camera == Some(false) { return (StatusLightState::Warning, "Missing".to_string()); } diff --git a/client/src/launcher/ui_runtime/status_refresh.rs b/client/src/launcher/ui_runtime/status_refresh.rs index 4fa42e5..e41ea54 100644 --- a/client/src/launcher/ui_runtime/status_refresh.rs +++ b/client/src/launcher/ui_runtime/status_refresh.rs @@ -118,7 +118,7 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi widgets.summary.uvc_value.set_text(&uvc_value); widgets.summary.uvc_value.set_tooltip_text(Some(&format!( "Upstream webcam transport: {}. Server calibration is profile-specific.", - state.webcam_transport.label() + state.effective_webcam_transport().label() ))); let power_detail = if state.server_available { @@ -253,11 +253,11 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi .speaker_test_button .set_sensitive(!relay_live && state.channels.audio); if widgets.webcam_transport_combo.active_id().as_deref() - != Some(state.webcam_transport.as_id()) + != Some(state.effective_webcam_transport().as_id()) { widgets .webcam_transport_combo - .set_active_id(Some(state.webcam_transport.as_id())); + .set_active_id(Some(state.effective_webcam_transport().as_id())); } widgets .webcam_transport_combo @@ -267,7 +267,7 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi .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." + "Use the server-advertised upstream webcam transport for the next relay launch; MJPEG is the safe calibrated default." })); if widgets .upstream_audio_transport_combo diff --git a/common/Cargo.toml b/common/Cargo.toml index 0166bc1..0625cb4 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.22.20" +version = "0.22.21" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index af21493..a6c5dc0 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.22.20" +version = "0.22.21" edition = "2024" autobins = false diff --git a/server/src/video_sinks/webcam_sink.rs b/server/src/video_sinks/webcam_sink.rs index 4ed7f2c..de7f683 100644 --- a/server/src/video_sinks/webcam_sink.rs +++ b/server/src/video_sinks/webcam_sink.rs @@ -103,6 +103,14 @@ fn uvc_appsrc_leaky_type() -> String { .unwrap_or_else(|| "downstream".to_string()) } +fn looks_like_mjpeg_frame(data: &[u8]) -> bool { + data.len() >= 4 && data.starts_with(&[0xff, 0xd8, 0xff]) +} + +fn looks_like_annex_b_hevc(data: &[u8]) -> bool { + data.starts_with(&[0, 0, 0, 1]) || data.starts_with(&[0, 0, 1]) +} + fn uvc_hevc_freshness_queue_buffers() -> u32 { positive_u64_env("LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS", 2) .min(4) @@ -579,6 +587,15 @@ impl WebcamSink { if let Some(path) = &self.mjpeg_spool_path && self.decoded_mjpeg_sink.is_none() { + if !looks_like_mjpeg_frame(&pkt.data) { + warn!( + target:"lesavka_server::video", + bytes = pkt.data.len(), + hevc_annex_b = looks_like_annex_b_hevc(&pkt.data), + "📸⚠️ dropping non-MJPEG packet before UVC spool; client/server camera codec mismatch would black-screen the browser webcam" + ); + return; + } let timing = MjpegSpoolTiming::mjpeg_passthrough(pkt.pts); if let Err(err) = spool_mjpeg_frame_with_timing(path, &pkt.data, Some(timing)) { warn!(target:"lesavka_server::video", %err, "📸⚠️ failed to spool MJPEG frame for UVC helper"); @@ -668,6 +685,25 @@ impl Drop for WebcamSink { #[cfg(test)] mod tests { + #[test] + fn mjpeg_spool_byte_guard_accepts_jpeg_and_identifies_hevc_annex_b() { + assert!(super::looks_like_mjpeg_frame(&[ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10 + ])); + assert!(!super::looks_like_mjpeg_frame(&[ + 0x00, 0x00, 0x00, 0x01, 0x46, 0x01 + ])); + assert!(super::looks_like_annex_b_hevc(&[ + 0x00, 0x00, 0x00, 0x01, 0x46, 0x01 + ])); + assert!(super::looks_like_annex_b_hevc(&[ + 0x00, 0x00, 0x01, 0x26 + ])); + assert!(!super::looks_like_annex_b_hevc(&[ + 0xff, 0xd8, 0xff, 0xdb + ])); + } + #[test] fn uvc_session_clock_alignment_defaults_on_and_accepts_disable_overrides() { temp_env::with_var_unset("LESAVKA_UVC_SESSION_CLOCK_ALIGN", || { diff --git a/tests/contract/diagnostics/report_schema_contract.rs b/tests/contract/diagnostics/report_schema_contract.rs index 0161d44..30668ea 100644 --- a/tests/contract/diagnostics/report_schema_contract.rs +++ b/tests/contract/diagnostics/report_schema_contract.rs @@ -70,7 +70,7 @@ fn report_builder_populates_schema_from_live_state_and_recent_samples() { for marker in [ "crate::VERSION.to_string()", "state.server_version.clone()", - "state.webcam_transport.label().to_string()", + "state.effective_webcam_transport().label().to_string()", "state.upstream_sync.live_lag_ms", "state.upstream_sync.planner_skew_ms", "state.upstream_sync.stale_audio_drops", diff --git a/tests/ui/client/launcher/client_codec_transport_ui_contract.rs b/tests/ui/client/launcher/client_codec_transport_ui_contract.rs index 1e9ba0c..878a43a 100644 --- a/tests/ui/client/launcher/client_codec_transport_ui_contract.rs +++ b/tests/ui/client/launcher/client_codec_transport_ui_contract.rs @@ -37,13 +37,13 @@ const STAGE_DEVICE_BINDINGS_SRC: &str = include_str!(concat!( fn webcam_transport_selector_exposes_real_hevc_and_mjpeg_choices() { for marker in [ "pub enum WebcamTransport", - "#[default]\n Hevc", + "#[default]\n Mjpeg", "Self::Hevc => \"hevc\"", "Self::Mjpeg => \"mjpeg\"", "Self::Hevc => \"HEVC\"", "Self::Mjpeg => \"MJPEG\"", "LESAVKA_CAM_CODEC", - "state.webcam_transport.env_value().to_string()", + "state.effective_webcam_transport().env_value().to_string()", ] { assert!( SELECTION_MODELS_SRC.contains(marker) || LAUNCHER_MOD_SRC.contains(marker), @@ -54,9 +54,9 @@ fn webcam_transport_selector_exposes_real_hevc_and_mjpeg_choices() { for marker in [ "for transport in [WebcamTransport::Hevc, WebcamTransport::Mjpeg]", "webcam_transport_combo.append(Some(transport.as_id()), transport.label());", - "webcam_transport_combo.set_active_id(Some(state.webcam_transport.as_id()));", + "webcam_transport_combo.set_active_id(Some(state.effective_webcam_transport().as_id()));", "webcam_transport_combo.set_sensitive(true);", - "HEVC is the low-latency default; MJPEG is the calibrated fallback", + "MJPEG is the safe calibrated default; HEVC is used only when the server advertises it", ] { assert!( BUILD_DEVICE_CONTROLS_SRC.contains(marker), @@ -82,7 +82,7 @@ fn webcam_transport_changes_are_staged_when_relay_is_live() { for marker in [ ".webcam_transport_combo\n .set_sensitive(!relay_live && state.channels.camera);", "Reconnect before changing the upstream webcam transport; the server decoder is calibrated per ingress codec.", - "Choose HEVC for low-latency upstream video or MJPEG as the calibrated fallback for the next relay launch.", + "Use the server-advertised upstream webcam transport for the next relay launch; MJPEG is the safe calibrated default.", ] { assert!( STATUS_REFRESH_SRC.contains(marker), @@ -94,7 +94,7 @@ fn webcam_transport_changes_are_staged_when_relay_is_live() { #[test] fn uvc_chip_reports_selected_transport_not_stale_server_codec() { for marker in [ - "let codec = state.webcam_transport.label().to_string();", + "let codec = state.effective_webcam_transport().label().to_string();", "if !relay_live {\n return (StatusLightState::Live, codec);", "if matches!(health, StatusLightState::Live) {\n (health, codec)", "state.select_webcam_transport(WebcamTransport::Mjpeg);",