diff --git a/Cargo.lock b/Cargo.lock index 9e0c88c..42383ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.25.5" +version = "0.26.0" dependencies = [ "anyhow", "async-stream", @@ -1692,7 +1692,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.25.5" +version = "0.26.0" dependencies = [ "anyhow", "base64", @@ -1704,7 +1704,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.25.5" +version = "0.26.0" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index b1cceda..3095742 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.25.5" +version = "0.26.0" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index a8dcf82..36786b0 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.25.5" +version = "0.26.0" edition = "2024" build = "build.rs" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 9168503..cf7bcf3 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -104,7 +104,12 @@ if [[ "${REQUESTED_UVC_CODEC,,}" != "${INSTALL_UVC_CODEC}" ]]; then echo "⚠️ UVC gadget output codec '${REQUESTED_UVC_CODEC}' is not supported by the MJPEG UVC helper; using '${INSTALL_UVC_CODEC}' for the host-facing gadget." echo " Use LESAVKA_INSTALL_CAM_CODEC=hevc to choose HEVC for the client-to-server upstream transport." fi -INSTALL_CAM_CODEC=$(normalize_cam_codec "${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-mjpeg}}") +INSTALL_CAM_CODEC_EXPLICIT=0 +if [[ -n "${LESAVKA_INSTALL_CAM_CODEC+x}" || -n "${LESAVKA_CAM_CODEC+x}" ]]; then + INSTALL_CAM_CODEC_EXPLICIT=1 +fi +REQUESTED_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-hevc}} +INSTALL_CAM_CODEC=$(normalize_cam_codec "${REQUESTED_CAM_CODEC}") INSTALL_UPLINK_AUDIO_CODEC=${LESAVKA_INSTALL_UPLINK_AUDIO_CODEC:-${LESAVKA_UPLINK_AUDIO_CODEC:-pcm}} INSTALL_UVC_FRAME_META=${LESAVKA_INSTALL_UVC_FRAME_META:-${LESAVKA_UVC_FRAME_META:-0}} INSTALL_UVC_FRAME_META_LOG_PATH=${LESAVKA_INSTALL_UVC_FRAME_META_LOG_PATH:-${LESAVKA_UVC_FRAME_META_LOG_PATH:-/tmp/lesavka-uvc-frame-meta.jsonl}} @@ -259,6 +264,11 @@ ensure_hevc_decode_support() { else echo "❌ no hardware HEVC decoder exposed to GStreamer; Lesavka will not fall back to avdec_h265 in production." >&2 if [[ "$INSTALL_UVC_CODEC" == "hevc" || "$INSTALL_CAM_CODEC" == "hevc" ]]; then + if [[ "$INSTALL_CAM_CODEC" == "hevc" && "$INSTALL_CAM_CODEC_EXPLICIT" == "0" ]]; then + echo " Default HEVC upstream cannot be proven on this host; falling back to MJPEG ingress." >&2 + INSTALL_CAM_CODEC=mjpeg + return 0 + fi echo " Install/repair v4l2slh265dec or set LESAVKA_INSTALL_CAM_CODEC=mjpeg before running the server installer." >&2 exit 1 fi @@ -278,6 +288,14 @@ ensure_hevc_decode_support() { echo "✅ hardware HEVC decoder passed a real 1280x720 decode smoke: $hevc_decoder" else if [[ "$INSTALL_CAM_CODEC" == "hevc" ]]; then + if [[ "$INSTALL_CAM_CODEC_EXPLICIT" == "0" ]]; then + echo "⚠️ hardware HEVC decoder is exposed but the synthetic 1280x720 decode smoke failed: $hevc_decoder" >&2 + echo " smoke log: $hevc_smoke_log" >&2 + sed -n '1,120p' "$hevc_smoke_log" >&2 || true + echo " Default HEVC upstream cannot be proven on this host; falling back to MJPEG ingress." >&2 + INSTALL_CAM_CODEC=mjpeg + return 0 + fi echo "❌ hardware HEVC decoder is exposed but failed a real 1280x720 decode smoke: $hevc_decoder" >&2 echo " smoke log: $hevc_smoke_log" >&2 sed -n '1,120p' "$hevc_smoke_log" >&2 || true @@ -1647,7 +1665,7 @@ SERVER_ENV_TMP=$(mktemp) printf 'LESAVKA_UVC_HEVC_DECODE_MISS_LIMIT=%s\n' "${LESAVKA_UVC_HEVC_DECODE_MISS_LIMIT:-15}" printf 'LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE:-1}" printf 'LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY:-60}" - printf 'LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS:-25}" + printf 'LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS:-50}" printf 'LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT:-30}" printf 'LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB:-384}" printf 'LESAVKA_UVC_DIRECT_MJPEG_VISUAL_GUARD=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_VISUAL_GUARD:-1}" diff --git a/server/Cargo.toml b/server/Cargo.toml index 5861cf5..7eb7429 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,7 +16,7 @@ bench = false [package] name = "lesavka_server" -version = "0.25.5" +version = "0.26.0" edition = "2024" autobins = false diff --git a/server/src/video_sinks/hevc_mjpeg_guard.rs b/server/src/video_sinks/hevc_mjpeg_guard.rs index fe7b1af..b87c3a3 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard.rs @@ -18,7 +18,7 @@ const DEFAULT_DIRECT_MJPEG_MIN_REFERENCE_BYTES: u32 = 48 * 1024; const DEFAULT_DIRECT_MJPEG_PROFILE_MISMATCH_REJECT: bool = false; const DEFAULT_DIRECT_MJPEG_NORMALIZE: bool = true; const DEFAULT_DIRECT_MJPEG_JPEG_QUALITY: u32 = 60; -const DEFAULT_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS: u32 = 25; +const DEFAULT_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS: u32 = 50; const DEFAULT_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT: u32 = 30; const DEFAULT_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB: u32 = 384; diff --git a/server/src/video_sinks/hevc_mjpeg_guard/tests.rs b/server/src/video_sinks/hevc_mjpeg_guard/tests.rs index ccc2e1b..ac907e7 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard/tests.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard/tests.rs @@ -35,7 +35,7 @@ fn direct_mjpeg_normalization_defaults_on_and_clamps_tuning() { || { assert!(super::direct_mjpeg_normalize_enabled()); assert_eq!(super::direct_mjpeg_jpeg_quality(), 60); - assert_eq!(super::direct_mjpeg_normalize_pull_timeout_ms(), 25); + assert_eq!(super::direct_mjpeg_normalize_pull_timeout_ms(), 50); assert_eq!(super::direct_mjpeg_normalize_miss_limit(), 30); assert_eq!( super::direct_mjpeg_normalize_rss_limit_kb(), diff --git a/server/src/video_sinks/webcam_sink/frame_handoff.rs b/server/src/video_sinks/webcam_sink/frame_handoff.rs index dc018d9..c30c31a 100644 --- a/server/src/video_sinks/webcam_sink/frame_handoff.rs +++ b/server/src/video_sinks/webcam_sink/frame_handoff.rs @@ -229,7 +229,13 @@ impl WebcamSink { return; } - let buf = gst::Buffer::from_slice(pkt.data.clone()); + let mut buf = gst::Buffer::from_slice(pkt.data.clone()); + if let Some(meta) = buf.get_mut() { + let ts = gst::ClockTime::from_useconds(pkt.pts); + meta.set_pts(Some(ts)); + meta.set_dts(Some(ts)); + meta.set_duration(Some(gst::ClockTime::from_useconds(self.frame_step_us))); + } if let Err(err) = src.push_buffer(buf) { tracing::warn!( target:"lesavka_server::video", diff --git a/server/src/video_sinks/webcam_sink/tests.rs b/server/src/video_sinks/webcam_sink/tests.rs index 1627d00..dafa618 100644 --- a/server/src/video_sinks/webcam_sink/tests.rs +++ b/server/src/video_sinks/webcam_sink/tests.rs @@ -176,3 +176,30 @@ fn webcam_bus_watch_stops_promptly_on_drop() { "webcam bus watcher should not outlive dropped webcam sinks" ); } + +#[cfg(not(coverage))] +#[test] +fn direct_mjpeg_normalizer_branch_reencodes_a_valid_frame() { + use gstreamer as gst; + use gstreamer::prelude::ElementExt; + + const FIXTURE: &[u8] = include_bytes!("../../bin/lesavka_uvc/idle_1280x720_black.jpg"); + + gst::init().expect("gstreamer init"); + let pipeline = gst::Pipeline::new(); + let (src, sink) = super::build_direct_mjpeg_normalize_branch(&pipeline, 1280, 720, 30) + .expect("normalizer branch"); + pipeline + .set_state(gst::State::Playing) + .expect("normalizer pipeline playing"); + + src.push_buffer(gst::Buffer::from_slice(FIXTURE)) + .expect("push fixture"); + let sample = super::freshest_direct_mjpeg_sample(&sink).expect("normalized sample"); + let buffer = sample.buffer().expect("sample buffer"); + let map = buffer.map_readable().expect("readable normalized buffer"); + + assert!(map.as_slice().starts_with(&[0xff, 0xd8, 0xff])); + assert!(map.as_slice().ends_with(&[0xff, 0xd9])); + pipeline.set_state(gst::State::Null).ok(); +} diff --git a/tests/contract/scripts/install/server_install_script_contract.rs b/tests/contract/scripts/install/server_install_script_contract.rs index ddcae7e..b25773a 100644 --- a/tests/contract/scripts/install/server_install_script_contract.rs +++ b/tests/contract/scripts/install/server_install_script_contract.rs @@ -84,9 +84,16 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { assert!(SERVER_INSTALL.contains("${LESAVKA_CAM_FPS:-30}")); assert!(SERVER_INSTALL.contains("${LESAVKA_INSTALL_CAM_OUTPUT:-uvc}")); assert!(SERVER_INSTALL.contains("normalize_cam_codec()")); - assert!(SERVER_INSTALL.contains( - "INSTALL_CAM_CODEC=$(normalize_cam_codec \"${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-mjpeg}}\")" - )); + assert!( + SERVER_INSTALL.contains( + "REQUESTED_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-hevc}}" + ) + ); + assert!( + SERVER_INSTALL + .contains("INSTALL_CAM_CODEC=$(normalize_cam_codec \"${REQUESTED_CAM_CODEC}\")") + ); + assert!(SERVER_INSTALL.contains("INSTALL_CAM_CODEC_EXPLICIT=0")); assert!( SERVER_INSTALL .contains("${LESAVKA_INSTALL_UPLINK_AUDIO_CODEC:-${LESAVKA_UPLINK_AUDIO_CODEC:-pcm}}") @@ -194,7 +201,7 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEVC_DECODE_MISS_LIMIT:-15}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE:-1}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY:-60}")); - assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS:-25}")); + assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS:-50}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT:-30}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB:-384}")); assert!( @@ -279,8 +286,9 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { ); assert!( SERVER_INSTALL.contains("Refusing HEVC upstream install because production video decode must be hardware-accelerated and proven") - && SERVER_INSTALL.contains("Use LESAVKA_INSTALL_CAM_CODEC=mjpeg while the HEVC decoder stack is repaired"), - "explicit HEVC installs should fail loud instead of producing a black UVC webcam feed" + && SERVER_INSTALL.contains("Use LESAVKA_INSTALL_CAM_CODEC=mjpeg while the HEVC decoder stack is repaired") + && SERVER_INSTALL.contains("Default HEVC upstream cannot be proven on this host; falling back to MJPEG ingress."), + "explicit HEVC installs should fail loud while default HEVC installs can safely fall back" ); assert!( !SERVER_INSTALL diff --git a/tests/regression/install/install_preserves_codec_settings_contract.rs b/tests/regression/install/install_preserves_codec_settings_contract.rs index cc06bd7..555c3b6 100644 --- a/tests/regression/install/install_preserves_codec_settings_contract.rs +++ b/tests/regression/install/install_preserves_codec_settings_contract.rs @@ -1,11 +1,11 @@ // Regression contract for preserving codec settings across upgrades. // -// Scope: keep safe MJPEG ingress and MJPEG UVC output defaults explicit, while still -// allowing operator-provided install overrides. +// Scope: keep safe HEVC/MJPEG ingress selection and MJPEG UVC output defaults +// explicit, while still allowing operator-provided install overrides. // Targets: server/client install scripts and client camera capture defaults. // Why: Lesavka now supports both MJPEG and HEVC upstream media, and installer -// reruns must not silently select a HEVC profile that produces black frames -// when hardware decode is not proven. +// reruns may prefer HEVC only when hardware decode is proven, and must fall +// back to MJPEG instead of producing black frames when it is not. const SERVER_INSTALL: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -17,14 +17,17 @@ const CLIENT_CAMERA: &str = include_str!(concat!( )); #[test] -fn server_install_defaults_to_mjpeg_ingress_and_mjpeg_uvc_output() { +fn server_install_defaults_to_hevc_ingress_with_mjpeg_fallback_and_mjpeg_uvc_output() { for marker in [ "PERSISTED_UVC_CODEC=$(persisted_uvc_value LESAVKA_UVC_CODEC || true)", "normalize_uvc_codec()", "normalize_cam_codec()", "REQUESTED_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-${PERSISTED_UVC_CODEC:-mjpeg}}", "INSTALL_UVC_CODEC=$(normalize_uvc_codec \"$REQUESTED_UVC_CODEC\")", - "INSTALL_CAM_CODEC=$(normalize_cam_codec \"${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-mjpeg}}\")", + "REQUESTED_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-hevc}}", + "INSTALL_CAM_CODEC=$(normalize_cam_codec \"${REQUESTED_CAM_CODEC}\")", + "INSTALL_CAM_CODEC_EXPLICIT=0", + "Default HEVC upstream cannot be proven on this host; falling back to MJPEG ingress.", "printf 'LESAVKA_CAM_CODEC=%s\\n' \"${INSTALL_CAM_CODEC}\"", "printf 'LESAVKA_UVC_CODEC=%s\\n' \"${INSTALL_UVC_CODEC}\"", "\"LESAVKA_UVC_CODEC=${INSTALL_UVC_CODEC}\"", diff --git a/tests/regression/server/video_sinks/server_mjpeg_normalizer_memory_regression.rs b/tests/regression/server/video_sinks/server_mjpeg_normalizer_memory_regression.rs index 5b764fc..166ee0e 100644 --- a/tests/regression/server/video_sinks/server_mjpeg_normalizer_memory_regression.rs +++ b/tests/regression/server/video_sinks/server_mjpeg_normalizer_memory_regression.rs @@ -115,6 +115,7 @@ fn installer_keeps_the_native_normalizer_memory_bounded_by_default() { assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE:-1}")); assert!(!SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE:-0}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB:-384}")); + assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS:-50}")); } #[test] @@ -161,6 +162,15 @@ fn opt_in_normalizer_has_rss_fuse_before_per_frame_gstreamer_allocation() { "direct_mjpeg_normalize_rss_limit_kb()", "gst::Buffer::from_slice(pkt.data.clone())", ); + let normalizer_push = WEBCAM_FRAME_HANDOFF + .split("gst::Buffer::from_slice(pkt.data.clone())") + .nth(1) + .expect("direct normalizer buffer allocation block"); + assert_ordered( + normalizer_push, + "meta.set_duration(Some(gst::ClockTime::from_useconds(self.frame_step_us)))", + "src.push_buffer(buf)", + ); } #[test]