fix(server): scale hdmi output to adapter mode

This commit is contained in:
Brad Stein 2026-04-22 16:50:28 -03:00
parent 5e6935121a
commit b322396739
6 changed files with 356 additions and 22 deletions

View File

@ -465,6 +465,15 @@ pub struct Voice {
tap: ClipTap, 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 { impl Voice {
#[cfg(coverage)] #[cfg(coverage)]
pub async fn new(_alsa_dev: &str) -> anyhow::Result<Self> { pub async fn new(_alsa_dev: &str) -> anyhow::Result<Self> {
@ -478,6 +487,7 @@ impl Voice {
.expect("appsrc"); .expect("appsrc");
appsrc.set_format(gst::Format::Time); appsrc.set_format(gst::Format::Time);
appsrc.set_is_live(true); appsrc.set_is_live(true);
appsrc.set_caps(Some(&voice_input_caps()));
let sink = gst::ElementFactory::make("fakesink") let sink = gst::ElementFactory::make("fakesink")
.build() .build()
@ -510,14 +520,7 @@ impl Voice {
.unwrap(); .unwrap();
// dedicated AppSrc helpers exist and avoid the needless `?` // dedicated AppSrc helpers exist and avoid the needless `?`
appsrc.set_caps(Some( appsrc.set_caps(Some(&voice_input_caps()));
&gst::Caps::builder("audio/mpeg")
.field("mpegversion", 4i32)
.field("stream-format", "adts")
.field("rate", 48_000i32)
.field("channels", 2i32)
.build(),
));
appsrc.set_format(gst::Format::Time); appsrc.set_format(gst::Format::Time);
appsrc.set_is_live(true); 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))] #[cfg(all(test, coverage))]
mod tests { mod tests {
use super::Voice; use super::Voice;

View File

@ -40,10 +40,17 @@ impl CameraCodec {
} }
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HdmiMode {
pub width: u32,
pub height: u32,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct HdmiConnector { pub struct HdmiConnector {
pub name: String, pub name: String,
pub id: Option<u32>, pub id: Option<u32>,
pub modes: Vec<HdmiMode>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -56,6 +63,33 @@ pub struct CameraConfig {
pub hdmi: Option<HdmiConnector>, pub hdmi: Option<HdmiConnector>,
} }
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<RwLock<CameraConfig>> = OnceLock::new(); static LAST_CONFIG: OnceLock<RwLock<CameraConfig>> = OnceLock::new();
/// Refresh the cached camera config from the current environment. /// Refresh the cached camera config from the current environment.
@ -132,12 +166,15 @@ fn select_camera_config() -> CameraConfig {
CameraOutput::Uvc => select_uvc_config(), CameraOutput::Uvc => select_uvc_config(),
}; };
let (display_width, display_height) = cfg.hdmi_display_size();
info!( info!(
output = cfg.output.as_str(), output = cfg.output.as_str(),
codec = cfg.codec.as_str(), codec = cfg.codec.as_str(),
width = cfg.width, width = cfg.width,
height = cfg.height, height = cfg.height,
fps = cfg.fps, fps = cfg.fps,
display_width,
display_height,
hdmi = cfg.hdmi.as_ref().map(|h| h.name.as_str()).unwrap_or("none"), hdmi = cfg.hdmi.as_ref().map(|h| h.name.as_str()).unwrap_or("none"),
"📷 camera output selected" "📷 camera output selected"
); );
@ -160,7 +197,9 @@ fn select_hdmi_config(hdmi: Option<HdmiConnector>) -> CameraConfig {
let fps = 30; let fps = 30;
#[cfg(not(coverage))] #[cfg(not(coverage))]
if !hw_decode { 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 { CameraConfig {
output: CameraOutput::Hdmi, output: CameraOutput::Hdmi,
@ -259,7 +298,14 @@ fn detect_hdmi_connector(require_connected: bool) -> Option<HdmiConnector> {
let _ = require_connected; let _ = require_connected;
std::env::var("LESAVKA_HDMI_CONNECTOR") std::env::var("LESAVKA_HDMI_CONNECTOR")
.ok() .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))] #[cfg(not(coverage))]
@ -281,7 +327,11 @@ fn detect_hdmi_connector(require_connected: bool) -> Option<HdmiConnector> {
let id = fs::read_to_string(entry.path().join("connector_id")) let id = fs::read_to_string(entry.path().join("connector_id"))
.ok() .ok()
.and_then(|v| v.trim().parse::<u32>().ok()); .and_then(|v| v.trim().parse::<u32>().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)); connectors.sort_by(|a, b| a.0.cmp(&b.0));
@ -289,11 +339,12 @@ fn detect_hdmi_connector(require_connected: bool) -> Option<HdmiConnector> {
|name: &str, preferred: &str| name == preferred || name.ends_with(preferred); |name: &str, preferred: &str| name == preferred || name.ends_with(preferred);
if let Some(pref) = preferred.as_deref() { 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") { if matches_preferred(name, pref) && (!require_connected || status == "connected") {
return Some(HdmiConnector { return Some(HdmiConnector {
name: name.clone(), name: name.clone(),
id: *id, id: *id,
modes: modes.clone(),
}); });
} }
} }
@ -307,26 +358,72 @@ fn detect_hdmi_connector(require_connected: bool) -> Option<HdmiConnector> {
.and_then(|lock| lock.read().ok()) .and_then(|lock| lock.read().ok())
.and_then(|cfg| cfg.hdmi.as_ref().map(|h| h.name.clone())); .and_then(|cfg| cfg.hdmi.as_ref().map(|h| h.name.clone()));
if let Some(prev) = previous { 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") { if *name == prev && (!require_connected || status == "connected") {
return Some(HdmiConnector { return Some(HdmiConnector {
name: name.clone(), name: name.clone(),
id: *id, id: *id,
modes: modes.clone(),
}); });
} }
} }
} }
} }
for (name, status, id) in connectors { for (name, status, id, modes) in connectors {
if !require_connected || status == "connected" { if !require_connected || status == "connected" {
return Some(HdmiConnector { name, id }); return Some(HdmiConnector { name, id, modes });
} }
} }
None None
} }
fn parse_hdmi_modes(raw: &str) -> Vec<HdmiMode> {
raw.lines()
.flat_map(|line| line.split(','))
.filter_map(parse_hdmi_mode)
.collect()
}
fn parse_hdmi_mode(raw: &str) -> Option<HdmiMode> {
let raw = raw.trim();
let (width, rest) = raw.split_once('x')?;
let width = width.trim().parse::<u32>().ok()?;
let height_digits: String = rest
.trim()
.chars()
.take_while(|ch| ch.is_ascii_digit())
.collect();
let height = height_digits.parse::<u32>().ok()?;
(width > 0 && height > 0).then_some(HdmiMode { width, height })
}
fn preferred_hdmi_mode(modes: &[HdmiMode]) -> Option<HdmiMode> {
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))] #[cfg(not(coverage))]
fn parse_env_file(text: &str) -> HashMap<String, String> { fn parse_env_file(text: &str) -> HashMap<String, String> {
let mut out = HashMap::new(); let mut out = HashMap::new();
@ -360,7 +457,10 @@ fn read_u32_from_map(map: &HashMap<String, String>, key: &str) -> Option<u32> {
#[cfg(test)] #[cfg(test)]
mod tests { 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 serial_test::serial;
use temp_env::with_var; 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));
});
});
}
} }

View File

@ -155,6 +155,10 @@ pub fn camera_cfg_eq(a: &camera::CameraConfig, b: &camera::CameraConfig) -> bool
return false; return false;
} }
if a.output == camera::CameraOutput::Hdmi && a.hdmi_display_size() != b.hdmi_display_size() {
return false;
}
match (&a.hdmi, &b.hdmi) { match (&a.hdmi, &b.hdmi) {
(Some(left), Some(right)) => left.name == right.name && left.id == right.id, (Some(left), Some(right)) => left.name == right.name && left.id == right.id,
(None, None) => true, (None, None) => true,
@ -178,6 +182,7 @@ mod tests {
hdmi: Some(HdmiConnector { hdmi: Some(HdmiConnector {
name: String::from("HDMI-A-1"), name: String::from("HDMI-A-1"),
id: Some(42), id: Some(42),
modes: Vec::new(),
}), }),
}; };
@ -192,6 +197,7 @@ mod tests {
changed.hdmi = Some(HdmiConnector { changed.hdmi = Some(HdmiConnector {
name: String::from("HDMI-A-2"), name: String::from("HDMI-A-2"),
id: Some(42), id: Some(42),
modes: Vec::new(),
}); });
assert!(!camera_cfg_eq(&base, &changed)); assert!(!camera_cfg_eq(&base, &changed));
} }

View File

@ -249,8 +249,11 @@ impl HdmiSink {
gst::init()?; gst::init()?;
let pipeline = gst::Pipeline::new(); let pipeline = gst::Pipeline::new();
let width = cfg.width as i32; let source_width = cfg.width as i32;
let height = cfg.height 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 fps = cfg.fps.max(1) as i32;
let src = gst::ElementFactory::make("appsrc") let src = gst::ElementFactory::make("appsrc")
@ -278,6 +281,17 @@ impl HdmiSink {
let scale = gst::ElementFactory::make("videoscale").build()?; let scale = gst::ElementFactory::make("videoscale").build()?;
let sink = build_hdmi_sink(cfg)?; 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 { match cfg.codec {
CameraCodec::H264 => { CameraCodec::H264 => {
let caps_h264 = gst::Caps::builder("video/x-h264") let caps_h264 = gst::Caps::builder("video/x-h264")
@ -317,8 +331,8 @@ impl HdmiSink {
CameraCodec::Mjpeg => { CameraCodec::Mjpeg => {
let caps_mjpeg = gst::Caps::builder("image/jpeg") let caps_mjpeg = gst::Caps::builder("image/jpeg")
.field("parsed", true) .field("parsed", true)
.field("width", width) .field("width", source_width)
.field("height", height) .field("height", source_height)
.field("framerate", gst::Fraction::new(fps, 1)) .field("framerate", gst::Fraction::new(fps, 1))
.build(); .build();
src.set_caps(Some(&caps_mjpeg)); src.set_caps(Some(&caps_mjpeg));

View File

@ -6,7 +6,7 @@
//! Why: camera runtime generation and guardrails are core to safe stream //! Why: camera runtime generation and guardrails are core to safe stream
//! transitions, so they need direct integration-level assertions. //! 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 lesavka_server::camera_runtime::{CameraRuntime, camera_cfg_eq};
use serial_test::serial; use serial_test::serial;
use temp_env::with_var; use temp_env::with_var;
@ -79,6 +79,7 @@ fn activate_non_uvc_returns_noop_relay_in_coverage_harness() {
hdmi: Some(HdmiConnector { hdmi: Some(HdmiConnector {
name: String::from("HDMI-A-1"), name: String::from("HDMI-A-1"),
id: Some(1), id: Some(1),
modes: Vec::new(),
}), }),
}; };
@ -119,6 +120,7 @@ fn camera_cfg_eq_handles_none_and_hdmi_connector_edges() {
hdmi: Some(HdmiConnector { hdmi: Some(HdmiConnector {
name: String::from("HDMI-A-1"), name: String::from("HDMI-A-1"),
id: Some(7), id: Some(7),
modes: Vec::new(),
}), }),
}; };
let hdmi_b = hdmi_a.clone(); 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 { different_id.hdmi = Some(HdmiConnector {
name: String::from("HDMI-A-1"), name: String::from("HDMI-A-1"),
id: Some(8), id: Some(8),
modes: Vec::new(),
}); });
assert!(!camera_cfg_eq(&hdmi_a, &different_id)); 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)); 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] #[test]
fn camera_cfg_eq_rejects_output_codec_resolution_and_fps_changes() { fn camera_cfg_eq_rejects_output_codec_resolution_and_fps_changes() {
let base = CameraConfig { let base = CameraConfig {

View File

@ -17,7 +17,7 @@ mod video_support {
mod video_sinks_include_contract { mod video_sinks_include_contract {
include!(env!("LESAVKA_SERVER_VIDEO_SINKS_SRC")); include!(env!("LESAVKA_SERVER_VIDEO_SINKS_SRC"));
use crate::camera::CameraOutput; use crate::camera::{CameraOutput, HdmiConnector, HdmiMode};
use serial_test::serial; use serial_test::serial;
use temp_env::with_var; 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() { fn init_gst() {
let _ = gst::init(); 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::<bool>("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::<i32>("connector-id"), 43);
}
if sink.has_property("driver-name", None) {
assert_eq!(sink.property::<String>("driver-name"), "vc4");
}
});
});
}
#[test] #[test]
#[serial] #[serial]
fn camera_sink_dispatch_is_stable_for_hdmi_variant() { fn camera_sink_dispatch_is_stable_for_hdmi_variant() {