media: keep upstream codec aligned with server

This commit is contained in:
Brad Stein 2026-05-13 03:32:31 -03:00
parent 0ef34da971
commit ea0ca9f744
19 changed files with 109 additions and 34 deletions

6
Cargo.lock generated
View File

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

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.22.20"
version = "0.22.21"
edition = "2024"
[dependencies]

View File

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

View File

@ -161,7 +161,7 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
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(),

View File

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

View File

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

View File

@ -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> {
Self::from_id(raw)
}
/// Return the environment value consumed by the relay child.
///
/// Inputs: the selected transport. Output: canonical `LESAVKA_CAM_CODEC`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.22.20"
version = "0.22.21"
edition = "2024"
build = "build.rs"

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.22.20"
version = "0.22.21"
edition = "2024"
autobins = false

View File

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

View File

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

View File

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