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]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.16.8" version = "0.16.9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.16.8" version = "0.16.9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.16.8" version = "0.16.9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",

View File

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

View File

@ -64,13 +64,13 @@ include!("camera/bus_and_encoder.rs");
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{CameraCodec, CameraConfig, resolved_capture_profile}; use super::{CameraCodec, CameraConfig, resolved_capture_profile, resolved_output_profile};
use serial_test::serial; use serial_test::serial;
#[test] #[test]
#[serial] #[serial]
/// Guards the browser-facing UVC contract against launcher preview overrides. /// Keeps the selected local webcam mode independent from the UVC gadget mode.
fn negotiated_capture_profile_overrides_launcher_quality_env_by_default() { fn local_capture_profile_keeps_launcher_quality_env_by_default() {
let cfg = CameraConfig { let cfg = CameraConfig {
codec: CameraCodec::Mjpeg, codec: CameraCodec::Mjpeg,
width: 640, width: 640,
@ -84,7 +84,35 @@ mod tests {
("LESAVKA_CAM_FPS", Some("30")), ("LESAVKA_CAM_FPS", Some("30")),
("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE", None), ("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_FPS", Some("30")),
("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE", Some("1")), ("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), |cfg| matches!(cfg.codec, CameraCodec::Mjpeg),
); );
let jpeg_quality = env_u32("LESAVKA_CAM_JPEG_QUALITY", 85).clamp(1, 100); 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 keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps);
let source_profile = camera_source_profile(allow_mjpg_source); let source_profile = camera_source_profile(allow_mjpg_source);
let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg; 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 { let (enc, kf_prop) = if use_mjpg_source && !output_mjpeg {
("x264enc", Some("key-int-max")) ("x264enc", Some("key-int-max"))
} else { } else {
@ -66,29 +70,29 @@ impl CameraCapture {
} }
#[cfg(not(coverage))] #[cfg(not(coverage))]
let have_nvvidconv = gst::ElementFactory::find("nvvidconv").is_some(); 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) // Jetson (has nvvidconv) Desktop (falls back to videoconvert)
// ─────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────
#[cfg(not(coverage))] #[cfg(not(coverage))]
"nvh264enc" if have_nvvidconv => "nvh264enc" if have_nvvidconv =>
(format!( format!(
"video/x-raw(memory:NVMM),format=NV12,width={width},height={height},framerate={fps}/1" "nvvidconv ! video/x-raw(memory:NVMM),format=NV12,width={width},height={height},framerate={fps}/1 !"
), "nvvidconv !"), ),
#[cfg(not(coverage))] #[cfg(not(coverage))]
"nvh264enc" /* else */ => "nvh264enc" /* else */ =>
(format!( format!(
"video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1" "videoconvert ! video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1 !"
), "videoconvert !"), ),
#[cfg(not(coverage))] #[cfg(not(coverage))]
"vaapih264enc" => "vaapih264enc" =>
(format!( format!(
"video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1" "videoconvert ! video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1 !"
), "videoconvert !"), ),
_ => _ =>
(format!( format!(
"video/x-raw,width={width},height={height},framerate={fps}/1" "videoconvert ! video/x-raw,width={width},height={height},framerate={fps}/1 !"
), "videoconvert !"), ),
}; };
// let desc = format!( // let desc = format!(
@ -104,11 +108,24 @@ impl CameraCapture {
// * x264enc needs the usual raw caps. // * x264enc needs the usual raw caps.
let preview_tap_path = camera_preview_tap_path(); let preview_tap_path = camera_preview_tap_path();
let preview_tap_branch = camera_preview_tap_branch(width, height, fps); let preview_tap_branch = camera_preview_tap_branch(width, height, fps);
let raw_source_chain = let source_raw_caps = format!(
camera_raw_source_chain(&src_desc, &src_caps, width, height, fps, source_profile); "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() { let desc = if preview_tap_path.is_some() {
if output_mjpeg { if output_mjpeg {
if use_mjpg_source { if passthrough_mjpg_source {
format!( format!(
"{src_desc} ! \ "{src_desc} ! \
image/jpeg,width={width},height={height},framerate={fps}/1 ! \ image/jpeg,width={width},height={height},framerate={fps}/1 ! \
@ -120,7 +137,7 @@ impl CameraCapture {
) )
} else { } else {
format!( format!(
"{raw_source_chain} ! \ "{normalized_raw_chain} ! \
tee name=t \ tee name=t \
t. ! queue max-size-buffers=30 leaky=downstream ! \ t. ! queue max-size-buffers=30 leaky=downstream ! \
videoconvert ! jpegenc quality={jpeg_quality} ! \ videoconvert ! jpegenc quality={jpeg_quality} ! \
@ -129,22 +146,9 @@ impl CameraCapture {
{preview_tap_branch}" {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 { } else {
format!( format!(
"{raw_source_chain} ! \ "{normalized_raw_chain} ! \
tee name=t \ tee name=t \
t. ! queue max-size-buffers=30 leaky=downstream ! \ t. ! queue max-size-buffers=30 leaky=downstream ! \
{preenc} {enc_opts} ! \ {preenc} {enc_opts} ! \
@ -155,7 +159,7 @@ impl CameraCapture {
) )
} }
} else if output_mjpeg { } else if output_mjpeg {
if use_mjpg_source { if passthrough_mjpg_source {
format!( format!(
"{src_desc} ! \ "{src_desc} ! \
image/jpeg,width={width},height={height},framerate={fps}/1 ! \ image/jpeg,width={width},height={height},framerate={fps}/1 ! \
@ -164,32 +168,32 @@ impl CameraCapture {
) )
} else { } else {
format!( format!(
"{raw_source_chain} ! \ "{normalized_raw_chain} ! \
videoconvert ! jpegenc quality={jpeg_quality} ! \ videoconvert ! jpegenc quality={jpeg_quality} ! \
queue max-size-buffers=30 leaky=downstream ! \ queue max-size-buffers=30 leaky=downstream ! \
appsink name=asink emit-signals=true max-buffers=60 drop=true" 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 { } else {
format!( format!(
"{raw_source_chain} ! \ "{normalized_raw_chain} ! \
{preenc} {enc_opts} ! \ {preenc} {enc_opts} ! \
h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \ h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \
queue max-size-buffers=30 leaky=downstream ! \ queue max-size-buffers=30 leaky=downstream ! \
appsink name=asink emit-signals=true max-buffers=60 drop=true" 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) let pipeline: gst::Pipeline = gst::parse::launch(&desc)
.context("gst parse_launch(cam)")? .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) { 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_WIDTH", cfg.map_or(1280, |cfg| cfg.width)),
env_u32("LESAVKA_CAM_HEIGHT", cfg.map_or(720, |cfg| cfg.height)), 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 { fn env_flag_enabled(name: &str) -> bool {
std::env::var(name).ok().is_some_and(|value| { std::env::var(name).ok().is_some_and(|value| {
let trimmed = value.trim(); 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> { fn camera_preview_tap_path() -> Option<PathBuf> {
std::env::var(CAMERA_PREVIEW_TAP_ENV) std::env::var(CAMERA_PREVIEW_TAP_ENV)
.ok() .ok()

View File

@ -10,12 +10,17 @@
state state
.borrow_mut() .borrow_mut()
.select_microphone(selected_combo_value(&microphone_combo_read)); .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( widgets.status_label.set_text(
"Microphone selection changed. Restart Monitor Mic to audition the new input.", "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()); refresh_test_buttons(&widgets, &mut tests.borrow_mut());
}); });
} }
@ -34,12 +39,17 @@
let speaker_running = tests.borrow_mut().is_running(DeviceTestKind::Speaker); let speaker_running = tests.borrow_mut().is_running(DeviceTestKind::Speaker);
let microphone_running = let microphone_running =
tests.borrow_mut().is_running(DeviceTestKind::Microphone); 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( widgets.status_label.set_text(
"Speaker selection changed. Restart the local audio tests to hear the new output.", "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()); refresh_test_buttons(&widgets, &mut tests.borrow_mut());
}); });
} }

View File

@ -30,6 +30,10 @@
widgets widgets
.status_label .status_label
.set_text(&format!("Camera quality update failed: {err}")); .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 { } else if preview_was_running {
widgets.status_label.set_text(&format!( widgets.status_label.set_text(&format!(
"Local camera preview switched to {}{}.", "Local camera preview switched to {}{}.",
@ -68,6 +72,10 @@
widgets widgets
.status_label .status_label
.set_text(&format!("Camera quality update failed: {err}")); .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 { } else if preview_was_running {
widgets.status_label.set_text(&format!( widgets.status_label.set_text(&format!(
"Local camera preview switched to {}.", "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.")); console_level_combo.set_tooltip_text(Some("Show relay logs at this level or higher."));
let console_copy_button = gtk::Button::with_label("Copy"); let console_copy_button = gtk::Button::with_label("Copy");
console_copy_button.set_tooltip_text(Some("Copy visible log.")); console_copy_button.set_tooltip_text(Some("Copy visible log."));
let console_popout_button = gtk::Button::with_label("Pop Out"); let console_popout_button = gtk::Button::with_label("Break Out");
console_popout_button.set_tooltip_text(Some("Open log window.")); console_popout_button.set_tooltip_text(Some("Break out the log window."));
let console_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); let console_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
console_buttons.set_hexpand(true); console_buttons.set_hexpand(true);
console_buttons.set_homogeneous(true); console_buttons.set_homogeneous(true);

View File

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

View File

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

View File

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

View File

@ -47,7 +47,18 @@ MEDIA_TESTS=(
start_seconds=$(date +%s) start_seconds=$(date +%s)
status=0 status=0
set +e 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]} status=${PIPESTATUS[0]}
set -e set -e
end_seconds=$(date +%s) end_seconds=$(date +%s)

View File

@ -10,7 +10,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.16.8" version = "0.16.9"
edition = "2024" edition = "2024"
autobins = false 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/activation_setup.rs"),
include_str!("../../client/src/launcher/ui/device_refresh_binding.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/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/relay_input_bindings.rs"),
include_str!("../../client/src/launcher/ui/runtime_poll.rs"), include_str!("../../client/src/launcher/ui/runtime_poll.rs"),
include_str!("../../client/src/launcher/ui/stage_device_bindings.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] #[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("widgets.server_entry.set_sensitive(!relay_live);"));
assert!( assert!(
UI_RUNTIME_SRC.contains( UI_RUNTIME_SRC.contains(".camera_combo\n .set_sensitive(state.channels.camera);")
".camera_combo\n .set_sensitive(!relay_live && state.channels.camera);"
)
); );
assert!(UI_RUNTIME_SRC.contains(".camera_quality_combo")); assert!(UI_RUNTIME_SRC.contains(".camera_quality_combo"));
assert!(UI_RUNTIME_SRC.contains("widgets.camera_quality_combo.set_sensitive(")); 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.devices.camera.is_some()"));
assert!(UI_RUNTIME_SRC.contains("state.camera_quality.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!( assert!(
UI_RUNTIME_SRC.contains( UI_RUNTIME_SRC
".speaker_combo\n .set_sensitive(!relay_live && state.channels.audio);" .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(".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(".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.keyboard_combo.set_sensitive(!relay_live);"));
assert!(UI_RUNTIME_SRC.contains("widgets.mouse_combo.set_sensitive(!relay_live);")); assert!(UI_RUNTIME_SRC.contains("widgets.mouse_combo.set_sensitive(!relay_live);"));
assert!( 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_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("\"Connect\""));
assert!(UI_RUNTIME_SRC.contains("\"Disconnect\"")); assert!(UI_RUNTIME_SRC.contains("\"Disconnect\""));
} }