diff --git a/server/src/audio.rs b/server/src/audio.rs index b76136f..774df40 100644 --- a/server/src/audio.rs +++ b/server/src/audio.rs @@ -465,6 +465,15 @@ pub struct Voice { tap: ClipTap, } +fn voice_input_caps() -> gst::Caps { + gst::Caps::builder("audio/mpeg") + .field("mpegversion", 4i32) + .field("stream-format", "adts") + .field("rate", 48_000i32) + .field("channels", 2i32) + .build() +} + impl Voice { #[cfg(coverage)] pub async fn new(_alsa_dev: &str) -> anyhow::Result { @@ -478,6 +487,7 @@ impl Voice { .expect("appsrc"); appsrc.set_format(gst::Format::Time); appsrc.set_is_live(true); + appsrc.set_caps(Some(&voice_input_caps())); let sink = gst::ElementFactory::make("fakesink") .build() @@ -510,14 +520,7 @@ impl Voice { .unwrap(); // dedicated AppSrc helpers exist and avoid the needless `?` - appsrc.set_caps(Some( - &gst::Caps::builder("audio/mpeg") - .field("mpegversion", 4i32) - .field("stream-format", "adts") - .field("rate", 48_000i32) - .field("channels", 2i32) - .build(), - )); + appsrc.set_caps(Some(&voice_input_caps())); appsrc.set_format(gst::Format::Time); appsrc.set_is_live(true); @@ -636,6 +639,22 @@ impl Voice { } } +#[cfg(test)] +mod voice_caps_tests { + use super::voice_input_caps; + + #[test] + fn voice_input_caps_describe_aac_adts_stereo_48k() { + let _ = super::gst::init(); + let caps = voice_input_caps().to_string(); + assert!(caps.contains("audio/mpeg")); + assert!(caps.contains("mpegversion=(int)4")); + assert!(caps.contains("stream-format=(string)adts")); + assert!(caps.contains("rate=(int)48000")); + assert!(caps.contains("channels=(int)2")); + } +} + #[cfg(all(test, coverage))] mod tests { use super::Voice; diff --git a/server/src/camera.rs b/server/src/camera.rs index 74d88ee..a888810 100644 --- a/server/src/camera.rs +++ b/server/src/camera.rs @@ -40,10 +40,17 @@ impl CameraCodec { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HdmiMode { + pub width: u32, + pub height: u32, +} + #[derive(Clone, Debug)] pub struct HdmiConnector { pub name: String, pub id: Option, + pub modes: Vec, } #[derive(Clone, Debug)] @@ -56,6 +63,33 @@ pub struct CameraConfig { pub hdmi: Option, } +impl CameraConfig { + /// Return the HDMI display mode that should be driven on the wire. + /// + /// Inputs: the selected camera config and optional HDMI mode overrides. + /// Outputs: a width/height pair for the physical display pipeline. + /// Why: the client webcam uplink can stay at a known-good capture profile + /// while HDMI output scales to a mode the capture adapter actually locks. + pub fn hdmi_display_size(&self) -> (u32, u32) { + if self.output != CameraOutput::Hdmi { + return (self.width, self.height); + } + + if let (Some(width), Some(height)) = ( + read_u32_from_env("LESAVKA_HDMI_WIDTH"), + read_u32_from_env("LESAVKA_HDMI_HEIGHT"), + ) { + return (width, height); + } + + self.hdmi + .as_ref() + .and_then(|hdmi| preferred_hdmi_mode(&hdmi.modes)) + .map(|mode| (mode.width, mode.height)) + .unwrap_or((self.width, self.height)) + } +} + static LAST_CONFIG: OnceLock> = OnceLock::new(); /// Refresh the cached camera config from the current environment. @@ -132,12 +166,15 @@ fn select_camera_config() -> CameraConfig { CameraOutput::Uvc => select_uvc_config(), }; + let (display_width, display_height) = cfg.hdmi_display_size(); info!( output = cfg.output.as_str(), codec = cfg.codec.as_str(), width = cfg.width, height = cfg.height, fps = cfg.fps, + display_width, + display_height, hdmi = cfg.hdmi.as_ref().map(|h| h.name.as_str()).unwrap_or("none"), "📷 camera output selected" ); @@ -160,7 +197,9 @@ fn select_hdmi_config(hdmi: Option) -> CameraConfig { let fps = 30; #[cfg(not(coverage))] if !hw_decode { - warn!("📷 HDMI output: hardware H264 decoder not detected; using 720p30"); + warn!( + "📷 HDMI output: hardware H264 decoder not detected; requesting 720p30 camera uplink" + ); } CameraConfig { output: CameraOutput::Hdmi, @@ -259,7 +298,14 @@ fn detect_hdmi_connector(require_connected: bool) -> Option { let _ = require_connected; std::env::var("LESAVKA_HDMI_CONNECTOR") .ok() - .map(|name| HdmiConnector { name, id: None }) + .map(|name| HdmiConnector { + name, + id: None, + modes: std::env::var("LESAVKA_HDMI_MODES") + .ok() + .map(|raw| parse_hdmi_modes(&raw)) + .unwrap_or_default(), + }) } #[cfg(not(coverage))] @@ -281,7 +327,11 @@ fn detect_hdmi_connector(require_connected: bool) -> Option { let id = fs::read_to_string(entry.path().join("connector_id")) .ok() .and_then(|v| v.trim().parse::().ok()); - connectors.push((name, status, id)); + let modes = fs::read_to_string(entry.path().join("modes")) + .ok() + .map(|raw| parse_hdmi_modes(&raw)) + .unwrap_or_default(); + connectors.push((name, status, id, modes)); } connectors.sort_by(|a, b| a.0.cmp(&b.0)); @@ -289,11 +339,12 @@ fn detect_hdmi_connector(require_connected: bool) -> Option { |name: &str, preferred: &str| name == preferred || name.ends_with(preferred); if let Some(pref) = preferred.as_deref() { - for (name, status, id) in &connectors { + for (name, status, id, modes) in &connectors { if matches_preferred(name, pref) && (!require_connected || status == "connected") { return Some(HdmiConnector { name: name.clone(), id: *id, + modes: modes.clone(), }); } } @@ -307,26 +358,72 @@ fn detect_hdmi_connector(require_connected: bool) -> Option { .and_then(|lock| lock.read().ok()) .and_then(|cfg| cfg.hdmi.as_ref().map(|h| h.name.clone())); if let Some(prev) = previous { - for (name, status, id) in &connectors { + for (name, status, id, modes) in &connectors { if *name == prev && (!require_connected || status == "connected") { return Some(HdmiConnector { name: name.clone(), id: *id, + modes: modes.clone(), }); } } } } - for (name, status, id) in connectors { + for (name, status, id, modes) in connectors { if !require_connected || status == "connected" { - return Some(HdmiConnector { name, id }); + return Some(HdmiConnector { name, id, modes }); } } None } +fn parse_hdmi_modes(raw: &str) -> Vec { + raw.lines() + .flat_map(|line| line.split(',')) + .filter_map(parse_hdmi_mode) + .collect() +} + +fn parse_hdmi_mode(raw: &str) -> Option { + let raw = raw.trim(); + let (width, rest) = raw.split_once('x')?; + let width = width.trim().parse::().ok()?; + let height_digits: String = rest + .trim() + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .collect(); + let height = height_digits.parse::().ok()?; + (width > 0 && height > 0).then_some(HdmiMode { width, height }) +} + +fn preferred_hdmi_mode(modes: &[HdmiMode]) -> Option { + for preferred in [ + HdmiMode { + width: 1920, + height: 1080, + }, + HdmiMode { + width: 1280, + height: 720, + }, + ] { + if modes.contains(&preferred) { + return Some(preferred); + } + } + + modes + .iter() + .copied() + .filter(|mode| mode.width.saturating_mul(9) == mode.height.saturating_mul(16)) + .filter(|mode| mode.width.saturating_mul(mode.height) <= 1920 * 1080) + .max_by_key(|mode| mode.width.saturating_mul(mode.height)) + .or_else(|| modes.first().copied()) +} + #[cfg(not(coverage))] fn parse_env_file(text: &str) -> HashMap { let mut out = HashMap::new(); @@ -360,7 +457,10 @@ fn read_u32_from_map(map: &HashMap, key: &str) -> Option { #[cfg(test)] mod tests { - use super::{CameraCodec, CameraOutput, current_camera_config, update_camera_config}; + use super::{ + CameraCodec, CameraConfig, CameraOutput, HdmiConnector, HdmiMode, current_camera_config, + parse_hdmi_mode, parse_hdmi_modes, preferred_hdmi_mode, update_camera_config, + }; use serial_test::serial; use temp_env::with_var; @@ -389,4 +489,105 @@ mod tests { }); }); } + + #[test] + fn hdmi_mode_parsing_accepts_sysfs_and_override_shapes() { + assert_eq!( + parse_hdmi_mode("1920x1080"), + Some(HdmiMode { + width: 1920, + height: 1080, + }) + ); + assert_eq!( + parse_hdmi_mode("1280x720p60"), + Some(HdmiMode { + width: 1280, + height: 720, + }) + ); + assert_eq!(parse_hdmi_mode("not-a-mode"), None); + + let modes = parse_hdmi_modes("1920x1080\n1024x768,800x600\n"); + assert_eq!(modes.len(), 3); + assert_eq!(modes[0].width, 1920); + assert_eq!(modes[2].height, 600); + } + + #[test] + fn preferred_hdmi_mode_chooses_standard_capture_adapter_mode() { + let modes = parse_hdmi_modes("1024x768\n1920x1080\n800x600\n"); + assert_eq!( + preferred_hdmi_mode(&modes), + Some(HdmiMode { + width: 1920, + height: 1080, + }) + ); + + let modes = parse_hdmi_modes("1600x900\n1024x768\n"); + assert_eq!( + preferred_hdmi_mode(&modes), + Some(HdmiMode { + width: 1600, + height: 900, + }) + ); + + let modes = parse_hdmi_modes("1024x768\n800x600\n"); + assert_eq!( + preferred_hdmi_mode(&modes), + Some(HdmiMode { + width: 1024, + height: 768, + }) + ); + } + + #[test] + #[serial] + fn hdmi_display_size_uses_adapter_mode_without_changing_uplink_profile() { + let cfg = CameraConfig { + output: CameraOutput::Hdmi, + codec: CameraCodec::H264, + width: 1280, + height: 720, + fps: 30, + hdmi: Some(HdmiConnector { + name: String::from("card1-HDMI-A-2"), + id: Some(43), + modes: parse_hdmi_modes("1920x1080\n1024x768\n800x600\n"), + }), + }; + + with_var("LESAVKA_HDMI_WIDTH", None::<&str>, || { + with_var("LESAVKA_HDMI_HEIGHT", None::<&str>, || { + assert_eq!((cfg.width, cfg.height), (1280, 720)); + assert_eq!(cfg.hdmi_display_size(), (1920, 1080)); + }); + }); + } + + #[test] + #[serial] + fn hdmi_display_size_honors_explicit_local_override() { + let cfg = CameraConfig { + output: CameraOutput::Hdmi, + codec: CameraCodec::H264, + width: 1280, + height: 720, + fps: 30, + hdmi: Some(HdmiConnector { + name: String::from("card1-HDMI-A-2"), + id: Some(43), + modes: parse_hdmi_modes("1920x1080\n"), + }), + }; + + with_var("LESAVKA_HDMI_WIDTH", Some("1024"), || { + with_var("LESAVKA_HDMI_HEIGHT", Some("768"), || { + assert_eq!(cfg.hdmi_display_size(), (1024, 768)); + }); + }); + } } diff --git a/server/src/camera_runtime.rs b/server/src/camera_runtime.rs index 9caa93d..1d78fa6 100644 --- a/server/src/camera_runtime.rs +++ b/server/src/camera_runtime.rs @@ -155,6 +155,10 @@ pub fn camera_cfg_eq(a: &camera::CameraConfig, b: &camera::CameraConfig) -> bool return false; } + if a.output == camera::CameraOutput::Hdmi && a.hdmi_display_size() != b.hdmi_display_size() { + return false; + } + match (&a.hdmi, &b.hdmi) { (Some(left), Some(right)) => left.name == right.name && left.id == right.id, (None, None) => true, @@ -178,6 +182,7 @@ mod tests { hdmi: Some(HdmiConnector { name: String::from("HDMI-A-1"), id: Some(42), + modes: Vec::new(), }), }; @@ -192,6 +197,7 @@ mod tests { changed.hdmi = Some(HdmiConnector { name: String::from("HDMI-A-2"), id: Some(42), + modes: Vec::new(), }); assert!(!camera_cfg_eq(&base, &changed)); } diff --git a/server/src/video_sinks.rs b/server/src/video_sinks.rs index 8ea0fed..79bc99a 100644 --- a/server/src/video_sinks.rs +++ b/server/src/video_sinks.rs @@ -249,8 +249,11 @@ impl HdmiSink { gst::init()?; let pipeline = gst::Pipeline::new(); - let width = cfg.width as i32; - let height = cfg.height as i32; + let source_width = cfg.width as i32; + let source_height = cfg.height as i32; + let (display_width, display_height) = cfg.hdmi_display_size(); + let width = display_width as i32; + let height = display_height as i32; let fps = cfg.fps.max(1) as i32; let src = gst::ElementFactory::make("appsrc") @@ -278,6 +281,17 @@ impl HdmiSink { let scale = gst::ElementFactory::make("videoscale").build()?; let sink = build_hdmi_sink(cfg)?; + if (display_width, display_height) != (cfg.width, cfg.height) { + tracing::info!( + target: "lesavka_server::video", + source_width = cfg.width, + source_height = cfg.height, + display_width, + display_height, + "📺 HDMI sink scaling camera uplink to adapter mode" + ); + } + match cfg.codec { CameraCodec::H264 => { let caps_h264 = gst::Caps::builder("video/x-h264") @@ -317,8 +331,8 @@ impl HdmiSink { CameraCodec::Mjpeg => { let caps_mjpeg = gst::Caps::builder("image/jpeg") .field("parsed", true) - .field("width", width) - .field("height", height) + .field("width", source_width) + .field("height", source_height) .field("framerate", gst::Fraction::new(fps, 1)) .build(); src.set_caps(Some(&caps_mjpeg)); diff --git a/testing/tests/server_camera_runtime_contract.rs b/testing/tests/server_camera_runtime_contract.rs index c419324..ae93391 100644 --- a/testing/tests/server_camera_runtime_contract.rs +++ b/testing/tests/server_camera_runtime_contract.rs @@ -6,7 +6,7 @@ //! Why: camera runtime generation and guardrails are core to safe stream //! transitions, so they need direct integration-level assertions. -use lesavka_server::camera::{CameraCodec, CameraConfig, CameraOutput, HdmiConnector}; +use lesavka_server::camera::{CameraCodec, CameraConfig, CameraOutput, HdmiConnector, HdmiMode}; use lesavka_server::camera_runtime::{CameraRuntime, camera_cfg_eq}; use serial_test::serial; use temp_env::with_var; @@ -79,6 +79,7 @@ fn activate_non_uvc_returns_noop_relay_in_coverage_harness() { hdmi: Some(HdmiConnector { name: String::from("HDMI-A-1"), id: Some(1), + modes: Vec::new(), }), }; @@ -119,6 +120,7 @@ fn camera_cfg_eq_handles_none_and_hdmi_connector_edges() { hdmi: Some(HdmiConnector { name: String::from("HDMI-A-1"), id: Some(7), + modes: Vec::new(), }), }; let hdmi_b = hdmi_a.clone(); @@ -128,6 +130,7 @@ fn camera_cfg_eq_handles_none_and_hdmi_connector_edges() { different_id.hdmi = Some(HdmiConnector { name: String::from("HDMI-A-1"), id: Some(8), + modes: Vec::new(), }); assert!(!camera_cfg_eq(&hdmi_a, &different_id)); @@ -136,6 +139,36 @@ fn camera_cfg_eq_handles_none_and_hdmi_connector_edges() { assert!(!camera_cfg_eq(&missing_connector, &hdmi_b)); } +#[test] +fn camera_cfg_eq_rejects_hdmi_display_mode_changes() { + let base = CameraConfig { + output: CameraOutput::Hdmi, + codec: CameraCodec::H264, + width: 1280, + height: 720, + fps: 30, + hdmi: Some(HdmiConnector { + name: String::from("HDMI-A-2"), + id: Some(43), + modes: vec![HdmiMode { + width: 1920, + height: 1080, + }], + }), + }; + let mut changed = base.clone(); + changed.hdmi = Some(HdmiConnector { + name: String::from("HDMI-A-2"), + id: Some(43), + modes: vec![HdmiMode { + width: 1024, + height: 768, + }], + }); + + assert!(!camera_cfg_eq(&base, &changed)); +} + #[test] fn camera_cfg_eq_rejects_output_codec_resolution_and_fps_changes() { let base = CameraConfig { diff --git a/testing/tests/server_video_sinks_include_contract.rs b/testing/tests/server_video_sinks_include_contract.rs index eca2b9c..8e70cc9 100644 --- a/testing/tests/server_video_sinks_include_contract.rs +++ b/testing/tests/server_video_sinks_include_contract.rs @@ -17,7 +17,7 @@ mod video_support { mod video_sinks_include_contract { include!(env!("LESAVKA_SERVER_VIDEO_SINKS_SRC")); - use crate::camera::CameraOutput; + use crate::camera::{CameraOutput, HdmiConnector, HdmiMode}; use serial_test::serial; use temp_env::with_var; @@ -32,6 +32,30 @@ mod video_sinks_include_contract { } } + fn hdmi_cfg_with_ugreen_like_modes(codec: CameraCodec) -> CameraConfig { + CameraConfig { + output: CameraOutput::Hdmi, + codec, + width: 1280, + height: 720, + fps: 30, + hdmi: Some(HdmiConnector { + name: String::from("card1-HDMI-A-2"), + id: Some(43), + modes: vec![ + HdmiMode { + width: 1920, + height: 1080, + }, + HdmiMode { + width: 1024, + height: 768, + }, + ], + }), + } + } + fn init_gst() { let _ = gst::init(); } @@ -70,6 +94,43 @@ mod video_sinks_include_contract { }); } + #[test] + fn hdmi_display_size_scales_uplink_to_capture_adapter_mode() { + let cfg = hdmi_cfg_with_ugreen_like_modes(CameraCodec::H264); + assert_eq!((cfg.width, cfg.height), (1280, 720)); + assert_eq!(cfg.hdmi_display_size(), (1920, 1080)); + } + + #[test] + #[serial] + #[cfg(not(coverage))] + fn build_hdmi_sink_pins_kms_connector_and_modesetting_when_available() { + init_gst(); + if gst::ElementFactory::find("kmssink").is_none() { + return; + } + + with_var("LESAVKA_HDMI_SINK", None::<&str>, || { + with_var("LESAVKA_HDMI_DRIVER", Some("vc4"), || { + let sink = build_hdmi_sink(&hdmi_cfg_with_ugreen_like_modes(CameraCodec::H264)) + .expect("kmssink should build"); + + if sink.has_property("force-modesetting", None) { + assert!( + sink.property::("force-modesetting"), + "kmssink must drive the HDMI mode instead of relying on desktop state" + ); + } + if sink.has_property("connector-id", None) { + assert_eq!(sink.property::("connector-id"), 43); + } + if sink.has_property("driver-name", None) { + assert_eq!(sink.property::("driver-name"), "vc4"); + } + }); + }); + } + #[test] #[serial] fn camera_sink_dispatch_is_stable_for_hdmi_variant() {