media: keep upstream codec aligned with server
This commit is contained in:
parent
0ef34da971
commit
ea0ca9f744
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.22.20"
|
version = "0.22.21"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1686,7 +1686,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.22.20"
|
version = "0.22.21"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1698,7 +1698,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.22.20"
|
version = "0.22.21"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.22.20"
|
version = "0.22.21"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -228,7 +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(),
|
upstream_camera_transport: state.effective_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 {
|
||||||
|
|||||||
@ -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_MIC_GAIN".to_string(), state.mic_gain_env_value());
|
||||||
envs.insert(
|
envs.insert(
|
||||||
"LESAVKA_CAM_CODEC".to_string(),
|
"LESAVKA_CAM_CODEC".to_string(),
|
||||||
state.webcam_transport.env_value().to_string(),
|
state.effective_webcam_transport().env_value().to_string(),
|
||||||
);
|
);
|
||||||
envs.insert(
|
envs.insert(
|
||||||
"LESAVKA_UPLINK_AUDIO_CODEC".to_string(),
|
"LESAVKA_UPLINK_AUDIO_CODEC".to_string(),
|
||||||
|
|||||||
@ -47,6 +47,13 @@ impl LauncherState {
|
|||||||
Some(trimmed.to_string())
|
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) {
|
pub fn set_view_mode(&mut self, view_mode: ViewMode) {
|
||||||
@ -314,6 +321,13 @@ impl LauncherState {
|
|||||||
self.webcam_transport = transport;
|
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) {
|
pub fn select_upstream_audio_transport(&mut self, transport: UpstreamAudioTransport) {
|
||||||
self.upstream_audio_transport = transport;
|
self.upstream_audio_transport = transport;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +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(),
|
self.effective_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()),
|
||||||
self.upstream_audio_transport.label(),
|
self.upstream_audio_transport.label(),
|
||||||
self.mic_noise_suppression,
|
self.mic_noise_suppression,
|
||||||
|
|||||||
@ -29,8 +29,8 @@ impl InputRouting {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum WebcamTransport {
|
pub enum WebcamTransport {
|
||||||
#[default]
|
|
||||||
Hevc,
|
Hevc,
|
||||||
|
#[default]
|
||||||
Mjpeg,
|
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.
|
/// Return the environment value consumed by the relay child.
|
||||||
///
|
///
|
||||||
/// Inputs: the selected transport. Output: canonical `LESAVKA_CAM_CODEC`.
|
/// Inputs: the selected transport. Output: canonical `LESAVKA_CAM_CODEC`.
|
||||||
|
|||||||
@ -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_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_transport, "MJPEG");
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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_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_CODEC"), Some(&"mjpeg".to_string()));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
envs.get("LESAVKA_UPLINK_AUDIO_CODEC"),
|
envs.get("LESAVKA_UPLINK_AUDIO_CODEC"),
|
||||||
Some(&"opus".to_string())
|
Some(&"opus".to_string())
|
||||||
@ -265,12 +265,23 @@ fn runtime_env_vars_keeps_remote_failsafe_disabled_for_invalid_launch_option() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn runtime_env_vars_emit_selected_webcam_transport() {
|
fn runtime_env_vars_emit_selected_webcam_transport() {
|
||||||
let mut state = LauncherState::new();
|
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!(
|
assert_eq!(
|
||||||
runtime_env_vars(&state).get("LESAVKA_CAM_CODEC"),
|
runtime_env_vars(&state).get("LESAVKA_CAM_CODEC"),
|
||||||
Some(&"hevc".to_string())
|
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!(
|
assert_eq!(
|
||||||
runtime_env_vars(&state).get("LESAVKA_CAM_CODEC"),
|
runtime_env_vars(&state).get("LESAVKA_CAM_CODEC"),
|
||||||
Some(&"mjpeg".to_string())
|
Some(&"mjpeg".to_string())
|
||||||
|
|||||||
@ -8,7 +8,7 @@ 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::default(), WebcamTransport::Mjpeg);
|
||||||
assert_eq!(WebcamTransport::Hevc.as_id(), "hevc");
|
assert_eq!(WebcamTransport::Hevc.as_id(), "hevc");
|
||||||
assert_eq!(WebcamTransport::Mjpeg.as_id(), "mjpeg");
|
assert_eq!(WebcamTransport::Mjpeg.as_id(), "mjpeg");
|
||||||
assert_eq!(WebcamTransport::Hevc.env_value(), "hevc");
|
assert_eq!(WebcamTransport::Hevc.env_value(), "hevc");
|
||||||
@ -17,6 +17,10 @@ fn routing_and_view_env_values_are_stable() {
|
|||||||
WebcamTransport::from_id("h265"),
|
WebcamTransport::from_id("h265"),
|
||||||
Some(WebcamTransport::Hevc)
|
Some(WebcamTransport::Hevc)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
WebcamTransport::from_server_codec("mjpeg"),
|
||||||
|
Some(WebcamTransport::Mjpeg)
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
WebcamTransport::from_id("jpeg"),
|
WebcamTransport::from_id("jpeg"),
|
||||||
Some(WebcamTransport::Mjpeg)
|
Some(WebcamTransport::Mjpeg)
|
||||||
|
|||||||
@ -411,18 +411,18 @@ fn webcam_transport_combo_tracks_selected_upstream_codec() {
|
|||||||
.webcam_transport_combo
|
.webcam_transport_combo
|
||||||
.active_id()
|
.active_id()
|
||||||
.as_deref(),
|
.as_deref(),
|
||||||
Some("hevc")
|
Some("mjpeg")
|
||||||
);
|
);
|
||||||
assert!(view.device_stage.webcam_transport_combo.is_sensitive());
|
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);
|
refresh_launcher_ui(&view.widgets, &state, false);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
view.device_stage
|
view.device_stage
|
||||||
.webcam_transport_combo
|
.webcam_transport_combo
|
||||||
.active_id()
|
.active_id()
|
||||||
.as_deref(),
|
.as_deref(),
|
||||||
Some("mjpeg")
|
Some("hevc")
|
||||||
);
|
);
|
||||||
assert!(view.device_stage.webcam_transport_combo.is_sensitive());
|
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!(
|
assert_eq!(
|
||||||
recovery_uvc_health(&state, false, None),
|
recovery_uvc_health(&state, false, None),
|
||||||
(StatusLightState::Live, "HEVC".to_string())
|
(StatusLightState::Live, "MJPEG".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
recovery_uvc_health(&state, true, None),
|
recovery_uvc_health(&state, true, None),
|
||||||
@ -700,7 +700,7 @@ fn uvc_chip_degrades_when_live_camera_frames_are_not_flowing() {
|
|||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
recovery_uvc_health(&state, true, Some(&healthy)),
|
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);
|
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!(
|
assert_eq!(
|
||||||
recovery_uvc_health(&state, true, Some(&healthy)),
|
recovery_uvc_health(&state, true, Some(&healthy)),
|
||||||
(StatusLightState::Live, "HEVC".to_string())
|
(StatusLightState::Live, "MJPEG".to_string())
|
||||||
);
|
);
|
||||||
|
|
||||||
state.select_webcam_transport(WebcamTransport::Mjpeg);
|
state.select_webcam_transport(WebcamTransport::Mjpeg);
|
||||||
|
|||||||
@ -227,11 +227,11 @@
|
|||||||
for transport in [WebcamTransport::Hevc, WebcamTransport::Mjpeg] {
|
for transport in [WebcamTransport::Hevc, WebcamTransport::Mjpeg] {
|
||||||
webcam_transport_combo.append(Some(transport.as_id()), transport.label());
|
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_sensitive(true);
|
||||||
webcam_transport_combo.set_size_request(98, -1);
|
webcam_transport_combo.set_size_request(98, -1);
|
||||||
webcam_transport_combo.set_tooltip_text(Some(
|
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();
|
let upstream_audio_transport_combo = gtk::ComboBoxText::new();
|
||||||
|
|||||||
@ -190,7 +190,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.webcam_transport.label().to_string();
|
let codec = state.effective_webcam_transport().label().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());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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_text(&uvc_value);
|
||||||
widgets.summary.uvc_value.set_tooltip_text(Some(&format!(
|
widgets.summary.uvc_value.set_tooltip_text(Some(&format!(
|
||||||
"Upstream webcam transport: {}. Server calibration is profile-specific.",
|
"Upstream webcam transport: {}. Server calibration is profile-specific.",
|
||||||
state.webcam_transport.label()
|
state.effective_webcam_transport().label()
|
||||||
)));
|
)));
|
||||||
|
|
||||||
let power_detail = if state.server_available {
|
let power_detail = if state.server_available {
|
||||||
@ -253,11 +253,11 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
|||||||
.speaker_test_button
|
.speaker_test_button
|
||||||
.set_sensitive(!relay_live && state.channels.audio);
|
.set_sensitive(!relay_live && state.channels.audio);
|
||||||
if widgets.webcam_transport_combo.active_id().as_deref()
|
if widgets.webcam_transport_combo.active_id().as_deref()
|
||||||
!= Some(state.webcam_transport.as_id())
|
!= Some(state.effective_webcam_transport().as_id())
|
||||||
{
|
{
|
||||||
widgets
|
widgets
|
||||||
.webcam_transport_combo
|
.webcam_transport_combo
|
||||||
.set_active_id(Some(state.webcam_transport.as_id()));
|
.set_active_id(Some(state.effective_webcam_transport().as_id()));
|
||||||
}
|
}
|
||||||
widgets
|
widgets
|
||||||
.webcam_transport_combo
|
.webcam_transport_combo
|
||||||
@ -267,7 +267,7 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
|||||||
.set_tooltip_text(Some(if relay_live {
|
.set_tooltip_text(Some(if relay_live {
|
||||||
"Reconnect before changing the upstream webcam transport; the server decoder is calibrated per ingress codec."
|
"Reconnect before changing the upstream webcam transport; the server decoder is calibrated per ingress codec."
|
||||||
} else {
|
} 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
|
if widgets
|
||||||
.upstream_audio_transport_combo
|
.upstream_audio_transport_combo
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.22.20"
|
version = "0.22.21"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.22.20"
|
version = "0.22.21"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -103,6 +103,14 @@ fn uvc_appsrc_leaky_type() -> String {
|
|||||||
.unwrap_or_else(|| "downstream".to_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 {
|
fn uvc_hevc_freshness_queue_buffers() -> u32 {
|
||||||
positive_u64_env("LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS", 2)
|
positive_u64_env("LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS", 2)
|
||||||
.min(4)
|
.min(4)
|
||||||
@ -579,6 +587,15 @@ impl WebcamSink {
|
|||||||
if let Some(path) = &self.mjpeg_spool_path
|
if let Some(path) = &self.mjpeg_spool_path
|
||||||
&& self.decoded_mjpeg_sink.is_none()
|
&& 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);
|
let timing = MjpegSpoolTiming::mjpeg_passthrough(pkt.pts);
|
||||||
if let Err(err) = spool_mjpeg_frame_with_timing(path, &pkt.data, Some(timing)) {
|
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");
|
warn!(target:"lesavka_server::video", %err, "📸⚠️ failed to spool MJPEG frame for UVC helper");
|
||||||
@ -668,6 +685,25 @@ impl Drop for WebcamSink {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn uvc_session_clock_alignment_defaults_on_and_accepts_disable_overrides() {
|
fn uvc_session_clock_alignment_defaults_on_and_accepts_disable_overrides() {
|
||||||
temp_env::with_var_unset("LESAVKA_UVC_SESSION_CLOCK_ALIGN", || {
|
temp_env::with_var_unset("LESAVKA_UVC_SESSION_CLOCK_ALIGN", || {
|
||||||
|
|||||||
@ -70,7 +70,7 @@ fn report_builder_populates_schema_from_live_state_and_recent_samples() {
|
|||||||
for marker in [
|
for marker in [
|
||||||
"crate::VERSION.to_string()",
|
"crate::VERSION.to_string()",
|
||||||
"state.server_version.clone()",
|
"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.live_lag_ms",
|
||||||
"state.upstream_sync.planner_skew_ms",
|
"state.upstream_sync.planner_skew_ms",
|
||||||
"state.upstream_sync.stale_audio_drops",
|
"state.upstream_sync.stale_audio_drops",
|
||||||
|
|||||||
@ -37,13 +37,13 @@ const STAGE_DEVICE_BINDINGS_SRC: &str = include_str!(concat!(
|
|||||||
fn webcam_transport_selector_exposes_real_hevc_and_mjpeg_choices() {
|
fn webcam_transport_selector_exposes_real_hevc_and_mjpeg_choices() {
|
||||||
for marker in [
|
for marker in [
|
||||||
"pub enum WebcamTransport",
|
"pub enum WebcamTransport",
|
||||||
"#[default]\n Hevc",
|
"#[default]\n Mjpeg",
|
||||||
"Self::Hevc => \"hevc\"",
|
"Self::Hevc => \"hevc\"",
|
||||||
"Self::Mjpeg => \"mjpeg\"",
|
"Self::Mjpeg => \"mjpeg\"",
|
||||||
"Self::Hevc => \"HEVC\"",
|
"Self::Hevc => \"HEVC\"",
|
||||||
"Self::Mjpeg => \"MJPEG\"",
|
"Self::Mjpeg => \"MJPEG\"",
|
||||||
"LESAVKA_CAM_CODEC",
|
"LESAVKA_CAM_CODEC",
|
||||||
"state.webcam_transport.env_value().to_string()",
|
"state.effective_webcam_transport().env_value().to_string()",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
SELECTION_MODELS_SRC.contains(marker) || LAUNCHER_MOD_SRC.contains(marker),
|
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 marker in [
|
||||||
"for transport in [WebcamTransport::Hevc, WebcamTransport::Mjpeg]",
|
"for transport in [WebcamTransport::Hevc, WebcamTransport::Mjpeg]",
|
||||||
"webcam_transport_combo.append(Some(transport.as_id()), transport.label());",
|
"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_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!(
|
assert!(
|
||||||
BUILD_DEVICE_CONTROLS_SRC.contains(marker),
|
BUILD_DEVICE_CONTROLS_SRC.contains(marker),
|
||||||
@ -82,7 +82,7 @@ fn webcam_transport_changes_are_staged_when_relay_is_live() {
|
|||||||
for marker in [
|
for marker in [
|
||||||
".webcam_transport_combo\n .set_sensitive(!relay_live && state.channels.camera);",
|
".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.",
|
"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!(
|
assert!(
|
||||||
STATUS_REFRESH_SRC.contains(marker),
|
STATUS_REFRESH_SRC.contains(marker),
|
||||||
@ -94,7 +94,7 @@ fn webcam_transport_changes_are_staged_when_relay_is_live() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn uvc_chip_reports_selected_transport_not_stale_server_codec() {
|
fn uvc_chip_reports_selected_transport_not_stale_server_codec() {
|
||||||
for marker in [
|
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 !relay_live {\n return (StatusLightState::Live, codec);",
|
||||||
"if matches!(health, StatusLightState::Live) {\n (health, codec)",
|
"if matches!(health, StatusLightState::Live) {\n (health, codec)",
|
||||||
"state.select_webcam_transport(WebcamTransport::Mjpeg);",
|
"state.select_webcam_transport(WebcamTransport::Mjpeg);",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user