fix(camera): preserve webcam capture mode

This commit is contained in:
Brad Stein 2026-04-30 19:40:23 -03:00
parent 8f319549e1
commit c12f5bf50c
14 changed files with 184 additions and 97 deletions

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lesavka_client"
version = "0.16.8"
version = "0.16.9"
dependencies = [
"anyhow",
"async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.16.8"
version = "0.16.9"
dependencies = [
"anyhow",
"base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.16.8"
version = "0.16.9"
dependencies = [
"anyhow",
"base64",

View File

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

View File

@ -64,13 +64,13 @@ include!("camera/bus_and_encoder.rs");
#[cfg(test)]
mod tests {
use super::{CameraCodec, CameraConfig, resolved_capture_profile};
use super::{CameraCodec, CameraConfig, resolved_capture_profile, resolved_output_profile};
use serial_test::serial;
#[test]
#[serial]
/// Guards the browser-facing UVC contract against launcher preview overrides.
fn negotiated_capture_profile_overrides_launcher_quality_env_by_default() {
/// Keeps the selected local webcam mode independent from the UVC gadget mode.
fn local_capture_profile_keeps_launcher_quality_env_by_default() {
let cfg = CameraConfig {
codec: CameraCodec::Mjpeg,
width: 640,
@ -84,7 +84,35 @@ mod tests {
("LESAVKA_CAM_FPS", Some("30")),
("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE", None),
],
|| assert_eq!(resolved_capture_profile(Some(cfg)), (640, 480, 20)),
|| assert_eq!(resolved_capture_profile(Some(cfg)), (1280, 720, 30)),
);
}
#[test]
#[serial]
/// Guards the browser-facing UVC contract against launcher quality selection.
fn negotiated_output_profile_uses_server_uvc_contract_by_default() {
let cfg = CameraConfig {
codec: CameraCodec::Mjpeg,
width: 640,
height: 480,
fps: 20,
};
temp_env::with_vars(
[
("LESAVKA_CAM_WIDTH", Some("1280")),
("LESAVKA_CAM_HEIGHT", Some("720")),
("LESAVKA_CAM_FPS", Some("30")),
("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE", None),
],
|| {
let capture_profile = resolved_capture_profile(Some(cfg));
assert_eq!(capture_profile, (1280, 720, 30));
assert_eq!(
resolved_output_profile(Some(cfg), capture_profile),
(640, 480, 20)
);
},
);
}
@ -105,7 +133,14 @@ mod tests {
("LESAVKA_CAM_FPS", Some("30")),
("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE", Some("1")),
],
|| assert_eq!(resolved_capture_profile(Some(cfg)), (1280, 720, 30)),
|| {
let capture_profile = resolved_capture_profile(Some(cfg));
assert_eq!(capture_profile, (1280, 720, 30));
assert_eq!(
resolved_output_profile(Some(cfg), capture_profile),
(1280, 720, 30)
);
},
);
}
}

View File

@ -40,10 +40,14 @@ impl CameraCapture {
|cfg| matches!(cfg.codec, CameraCodec::Mjpeg),
);
let jpeg_quality = env_u32("LESAVKA_CAM_JPEG_QUALITY", 85).clamp(1, 100);
let (width, height, fps) = resolved_capture_profile(cfg);
let capture_profile = resolved_capture_profile(cfg);
let (capture_width, capture_height, capture_fps) = capture_profile;
let (width, height, fps) = resolved_output_profile(cfg, capture_profile);
let keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps);
let source_profile = camera_source_profile(allow_mjpg_source);
let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg;
let passthrough_mjpg_source =
use_mjpg_source && capture_profile == (width, height, fps);
let (enc, kf_prop) = if use_mjpg_source && !output_mjpeg {
("x264enc", Some("key-int-max"))
} else {
@ -66,29 +70,29 @@ impl CameraCapture {
}
#[cfg(not(coverage))]
let have_nvvidconv = gst::ElementFactory::find("nvvidconv").is_some();
let (src_caps, preenc) = match enc {
let preenc = match enc {
// ───────────────────────────────────────────────────────────────────
// Jetson (has nvvidconv) Desktop (falls back to videoconvert)
// ───────────────────────────────────────────────────────────────────
#[cfg(not(coverage))]
"nvh264enc" if have_nvvidconv =>
(format!(
"video/x-raw(memory:NVMM),format=NV12,width={width},height={height},framerate={fps}/1"
), "nvvidconv !"),
format!(
"nvvidconv ! video/x-raw(memory:NVMM),format=NV12,width={width},height={height},framerate={fps}/1 !"
),
#[cfg(not(coverage))]
"nvh264enc" /* else */ =>
(format!(
"video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1"
), "videoconvert !"),
format!(
"videoconvert ! video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1 !"
),
#[cfg(not(coverage))]
"vaapih264enc" =>
(format!(
"video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1"
), "videoconvert !"),
format!(
"videoconvert ! video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1 !"
),
_ =>
(format!(
"video/x-raw,width={width},height={height},framerate={fps}/1"
), "videoconvert !"),
format!(
"videoconvert ! video/x-raw,width={width},height={height},framerate={fps}/1 !"
),
};
// let desc = format!(
@ -104,11 +108,24 @@ impl CameraCapture {
// * x264enc needs the usual raw caps.
let preview_tap_path = camera_preview_tap_path();
let preview_tap_branch = camera_preview_tap_branch(width, height, fps);
let raw_source_chain =
camera_raw_source_chain(&src_desc, &src_caps, width, height, fps, source_profile);
let source_raw_caps = format!(
"video/x-raw,width={capture_width},height={capture_height},framerate={capture_fps}/1"
);
let raw_source_chain = camera_raw_source_chain(
&src_desc,
&source_raw_caps,
capture_width,
capture_height,
capture_fps,
source_profile,
);
let normalized_raw_chain = format!(
"{raw_source_chain} ! {}",
camera_output_raw_chain(width, height, fps)
);
let desc = if preview_tap_path.is_some() {
if output_mjpeg {
if use_mjpg_source {
if passthrough_mjpg_source {
format!(
"{src_desc} ! \
image/jpeg,width={width},height={height},framerate={fps}/1 ! \
@ -120,7 +137,7 @@ impl CameraCapture {
)
} else {
format!(
"{raw_source_chain} ! \
"{normalized_raw_chain} ! \
tee name=t \
t. ! queue max-size-buffers=30 leaky=downstream ! \
videoconvert ! jpegenc quality={jpeg_quality} ! \
@ -129,22 +146,9 @@ impl CameraCapture {
{preview_tap_branch}"
)
}
} else if use_mjpg_source {
format!(
"{src_desc} ! \
image/jpeg,width={width},height={height} ! \
jpegdec ! videorate ! video/x-raw,framerate={fps}/1 ! \
tee name=t \
t. ! queue max-size-buffers=30 leaky=downstream ! \
videoconvert ! {enc_opts} ! \
h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \
appsink name=asink emit-signals=true max-buffers=60 drop=true \
t. ! queue max-size-buffers=2 leaky=downstream ! \
{preview_tap_branch}"
)
} else {
format!(
"{raw_source_chain} ! \
"{normalized_raw_chain} ! \
tee name=t \
t. ! queue max-size-buffers=30 leaky=downstream ! \
{preenc} {enc_opts} ! \
@ -155,7 +159,7 @@ impl CameraCapture {
)
}
} else if output_mjpeg {
if use_mjpg_source {
if passthrough_mjpg_source {
format!(
"{src_desc} ! \
image/jpeg,width={width},height={height},framerate={fps}/1 ! \
@ -164,32 +168,32 @@ impl CameraCapture {
)
} else {
format!(
"{raw_source_chain} ! \
"{normalized_raw_chain} ! \
videoconvert ! jpegenc quality={jpeg_quality} ! \
queue max-size-buffers=30 leaky=downstream ! \
appsink name=asink emit-signals=true max-buffers=60 drop=true"
)
}
} else if use_mjpg_source {
format!(
"{src_desc} ! \
image/jpeg,width={width},height={height} ! \
jpegdec ! videorate ! video/x-raw,framerate={fps}/1 ! \
videoconvert ! {enc_opts} ! \
h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \
queue max-size-buffers=30 leaky=downstream ! \
appsink name=asink emit-signals=true max-buffers=60 drop=true"
)
} else {
format!(
"{raw_source_chain} ! \
"{normalized_raw_chain} ! \
{preenc} {enc_opts} ! \
h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \
queue max-size-buffers=30 leaky=downstream ! \
appsink name=asink emit-signals=true max-buffers=60 drop=true"
)
};
tracing::info!(%enc, width, height, fps, ?desc, "📸 using encoder element");
tracing::info!(
%enc,
capture_width,
capture_height,
capture_fps,
output_width = width,
output_height = height,
output_fps = fps,
?desc,
"📸 using encoder element"
);
let pipeline: gst::Pipeline = gst::parse::launch(&desc)
.context("gst parse_launch(cam)")?
@ -259,15 +263,12 @@ impl CameraCapture {
}
}
/// Resolve the exact profile the client sends, preferring the server UVC contract.
/// Resolve the profile requested from the local webcam.
///
/// The server UVC contract is applied after capture. Keeping these separate
/// prevents a browser-facing 640x480/20 gadget mode from forcing a local webcam
/// to expose that exact mode when the selected camera quality is 720p/30.
fn resolved_capture_profile(cfg: Option<CameraConfig>) -> (u32, u32, u32) {
match cfg {
Some(cfg) if !env_flag_enabled("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE") => {
return (cfg.width, cfg.height, cfg.fps.max(1));
}
_ => {}
}
(
env_u32("LESAVKA_CAM_WIDTH", cfg.map_or(1280, |cfg| cfg.width)),
env_u32("LESAVKA_CAM_HEIGHT", cfg.map_or(720, |cfg| cfg.height)),
@ -275,6 +276,19 @@ fn resolved_capture_profile(cfg: Option<CameraConfig>) -> (u32, u32, u32) {
)
}
/// Resolve the profile emitted toward the remote UVC gadget.
fn resolved_output_profile(
cfg: Option<CameraConfig>,
capture_profile: (u32, u32, u32),
) -> (u32, u32, u32) {
match cfg {
Some(cfg) if !env_flag_enabled("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE") => {
(cfg.width, cfg.height, cfg.fps.max(1))
}
_ => capture_profile,
}
}
fn env_flag_enabled(name: &str) -> bool {
std::env::var(name).ok().is_some_and(|value| {
let trimmed = value.trim();

View File

@ -56,6 +56,14 @@ fn camera_auto_decode_caps(width: u32, height: u32, fps: u32) -> String {
)
}
/// Convert local webcam frames into the exact outbound UVC profile.
fn camera_output_raw_chain(width: u32, height: u32, fps: u32) -> String {
format!(
"videoconvert ! videoscale ! videorate ! \
video/x-raw,width={width},height={height},framerate={fps}/1,pixel-aspect-ratio=1/1"
)
}
fn camera_preview_tap_path() -> Option<PathBuf> {
std::env::var(CAMERA_PREVIEW_TAP_ENV)
.ok()

View File

@ -10,12 +10,17 @@
state
.borrow_mut()
.select_microphone(selected_combo_value(&microphone_combo_read));
if tests.borrow_mut().is_running(DeviceTestKind::Microphone) {
let relay_live = child_proc.borrow().is_some();
if relay_live {
widgets.status_label.set_text(
"Microphone selection staged for the next relay launch. Use the Mic toggle to soft-pause or resume the current live feed.",
);
} else if tests.borrow_mut().is_running(DeviceTestKind::Microphone) {
widgets.status_label.set_text(
"Microphone selection changed. Restart Monitor Mic to audition the new input.",
);
}
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
refresh_launcher_ui(&widgets, &state.borrow(), relay_live);
refresh_test_buttons(&widgets, &mut tests.borrow_mut());
});
}
@ -34,12 +39,17 @@
let speaker_running = tests.borrow_mut().is_running(DeviceTestKind::Speaker);
let microphone_running =
tests.borrow_mut().is_running(DeviceTestKind::Microphone);
if speaker_running || microphone_running {
let relay_live = child_proc.borrow().is_some();
if relay_live {
widgets.status_label.set_text(
"Speaker selection staged for the next relay launch. Speaker gain still applies live.",
);
} else if speaker_running || microphone_running {
widgets.status_label.set_text(
"Speaker selection changed. Restart the local audio tests to hear the new output.",
);
}
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
refresh_launcher_ui(&widgets, &state.borrow(), relay_live);
refresh_test_buttons(&widgets, &mut tests.borrow_mut());
});
}

View File

@ -30,6 +30,10 @@
widgets
.status_label
.set_text(&format!("Camera quality update failed: {err}"));
} else if child_proc.borrow().is_some() {
widgets.status_label.set_text(
"Camera selection staged for the next relay launch. Use the Camera toggle to soft-pause or resume the current live feed.",
);
} else if preview_was_running {
widgets.status_label.set_text(&format!(
"Local camera preview switched to {}{}.",
@ -68,6 +72,10 @@
widgets
.status_label
.set_text(&format!("Camera quality update failed: {err}"));
} else if child_proc.borrow().is_some() {
widgets.status_label.set_text(
"Camera quality staged for the next relay launch. The live feed keeps its current capture pipeline.",
);
} else if preview_was_running {
widgets.status_label.set_text(&format!(
"Local camera preview switched to {}.",

View File

@ -235,8 +235,8 @@
console_level_combo.set_tooltip_text(Some("Show relay logs at this level or higher."));
let console_copy_button = gtk::Button::with_label("Copy");
console_copy_button.set_tooltip_text(Some("Copy visible log."));
let console_popout_button = gtk::Button::with_label("Pop Out");
console_popout_button.set_tooltip_text(Some("Open log window."));
let console_popout_button = gtk::Button::with_label("Break Out");
console_popout_button.set_tooltip_text(Some("Break out the log window."));
let console_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
console_buttons.set_hexpand(true);
console_buttons.set_homogeneous(true);

View File

@ -171,25 +171,24 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
widgets
.uvc_recover_button
.set_sensitive(state.server_available);
widgets.device_refresh_button.set_sensitive(!relay_live);
widgets.device_refresh_button.set_sensitive(true);
widgets
.camera_combo
.set_sensitive(!relay_live && state.channels.camera);
.set_sensitive(state.channels.camera);
widgets.camera_quality_combo.set_sensitive(
!relay_live
&& state.channels.camera
state.channels.camera
&& state.devices.camera.is_some()
&& state.camera_quality.is_some(),
);
widgets
.microphone_combo
.set_sensitive(!relay_live && state.channels.microphone);
.set_sensitive(state.channels.microphone);
widgets
.speaker_combo
.set_sensitive(!relay_live && state.channels.audio);
.set_sensitive(state.channels.audio);
widgets
.audio_gain_scale
.set_sensitive(!relay_live && state.channels.audio);
.set_sensitive(state.channels.audio);
widgets.keyboard_combo.set_sensitive(!relay_live);
widgets.mouse_combo.set_sensitive(!relay_live);
widgets
@ -223,7 +222,7 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
.set_sensitive(!relay_live && state.channels.microphone);
widgets
.mic_gain_scale
.set_sensitive(!relay_live && state.channels.microphone);
.set_sensitive(state.channels.microphone);
widgets
.speaker_test_button
.set_sensitive(!relay_live && state.channels.audio);

View File

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

View File

@ -58,7 +58,7 @@
"client/src/input/camera.rs": {
"clippy_warnings": 0,
"doc_debt": 0,
"loc": 111
"loc": 146
},
"client/src/input/camera/bus_and_encoder.rs": {
"clippy_warnings": 0,
@ -68,7 +68,7 @@
"client/src/input/camera/capture_pipeline.rs": {
"clippy_warnings": 0,
"doc_debt": 4,
"loc": 320
"loc": 334
},
"client/src/input/camera/device_selection.rs": {
"clippy_warnings": 0,
@ -88,7 +88,7 @@
"client/src/input/camera/source_description.rs": {
"clippy_warnings": 0,
"doc_debt": 0,
"loc": 76
"loc": 84
},
"client/src/input/inputs.rs": {
"clippy_warnings": 0,
@ -338,7 +338,7 @@
"client/src/launcher/ui/media_device_bindings.rs": {
"clippy_warnings": 0,
"doc_debt": 0,
"loc": 182
"loc": 192
},
"client/src/launcher/ui/message_and_network_state.rs": {
"clippy_warnings": 0,
@ -373,7 +373,7 @@
"client/src/launcher/ui/stage_device_bindings.rs": {
"clippy_warnings": 0,
"doc_debt": 0,
"loc": 176
"loc": 184
},
"client/src/launcher/ui/startup_window_guard.rs": {
"clippy_warnings": 0,

View File

@ -47,7 +47,18 @@ MEDIA_TESTS=(
start_seconds=$(date +%s)
status=0
set +e
cargo test -p lesavka_testing "${MEDIA_TESTS[@]}" --color never 2>&1 | tee "${TEST_LOG}"
{
echo '==> client camera profile/unit guards'
cargo test -p lesavka_client --color never input::camera::tests -- --nocapture
camera_status=${PIPESTATUS[0]}
echo
echo '==> media reliability contract tests'
cargo test -p lesavka_testing --color never "${MEDIA_TESTS[@]}"
contract_status=${PIPESTATUS[0]}
if [[ "${camera_status}" -ne 0 || "${contract_status}" -ne 0 ]]; then
exit 1
fi
} 2>&1 | tee "${TEST_LOG}"
status=${PIPESTATUS[0]}
set -e
end_seconds=$(date +%s)

View File

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

View File

@ -19,6 +19,7 @@ const UI_SRC: &str = concat!(
include_str!("../../client/src/launcher/ui/activation_setup.rs"),
include_str!("../../client/src/launcher/ui/device_refresh_binding.rs"),
include_str!("../../client/src/launcher/ui/local_test_bindings.rs"),
include_str!("../../client/src/launcher/ui/media_device_bindings.rs"),
include_str!("../../client/src/launcher/ui/relay_input_bindings.rs"),
include_str!("../../client/src/launcher/ui/runtime_poll.rs"),
include_str!("../../client/src/launcher/ui/stage_device_bindings.rs"),
@ -68,29 +69,26 @@ fn relay_child_starts_safe_parent_watchdog_on_boot() {
}
#[test]
fn relay_address_entry_is_locked_while_relay_is_live() {
fn relay_address_locks_but_media_staging_stays_available_while_relay_is_live() {
assert!(UI_RUNTIME_SRC.contains("widgets.server_entry.set_sensitive(!relay_live);"));
assert!(
UI_RUNTIME_SRC.contains(
".camera_combo\n .set_sensitive(!relay_live && state.channels.camera);"
)
UI_RUNTIME_SRC.contains(".camera_combo\n .set_sensitive(state.channels.camera);")
);
assert!(UI_RUNTIME_SRC.contains(".camera_quality_combo"));
assert!(UI_RUNTIME_SRC.contains("widgets.camera_quality_combo.set_sensitive("));
assert!(UI_RUNTIME_SRC.contains("state.devices.camera.is_some()"));
assert!(UI_RUNTIME_SRC.contains("state.camera_quality.is_some()"));
assert!(UI_RUNTIME_SRC.contains(
".microphone_combo\n .set_sensitive(!relay_live && state.channels.microphone);"
));
assert!(
UI_RUNTIME_SRC.contains(
".speaker_combo\n .set_sensitive(!relay_live && state.channels.audio);"
)
UI_RUNTIME_SRC
.contains(".microphone_combo\n .set_sensitive(state.channels.microphone);")
);
assert!(
UI_RUNTIME_SRC.contains(".speaker_combo\n .set_sensitive(state.channels.audio);")
);
assert!(UI_RUNTIME_SRC.contains(".audio_gain_scale"));
assert!(UI_RUNTIME_SRC.contains(".set_sensitive(!relay_live && state.channels.audio);"));
assert!(UI_RUNTIME_SRC.contains(".set_sensitive(state.channels.audio);"));
assert!(UI_RUNTIME_SRC.contains(".mic_gain_scale"));
assert!(UI_RUNTIME_SRC.contains(".set_sensitive(!relay_live && state.channels.microphone);"));
assert!(UI_RUNTIME_SRC.contains(".set_sensitive(state.channels.microphone);"));
assert!(UI_RUNTIME_SRC.contains("widgets.keyboard_combo.set_sensitive(!relay_live);"));
assert!(UI_RUNTIME_SRC.contains("widgets.mouse_combo.set_sensitive(!relay_live);"));
assert!(
@ -108,6 +106,10 @@ fn relay_address_entry_is_locked_while_relay_is_live() {
)
);
assert!(UI_RUNTIME_SRC.contains("Soft-pause or resume this feed in the running relay"));
assert!(UI_SRC.contains("Camera selection staged for the next relay launch"));
assert!(UI_SRC.contains("Camera quality staged for the next relay launch"));
assert!(UI_SRC.contains("Microphone selection staged for the next relay launch"));
assert!(UI_SRC.contains("Speaker selection staged for the next relay launch"));
assert!(UI_RUNTIME_SRC.contains("\"Connect\""));
assert!(UI_RUNTIME_SRC.contains("\"Disconnect\""));
}