From ef9701f6e9ece20f13794f8eba0654f6c122d690 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Tue, 12 May 2026 16:11:53 -0300 Subject: [PATCH] media: steady hevc to mjpeg uvc handoff --- Cargo.lock | 6 ++-- client/Cargo.toml | 2 +- common/Cargo.toml | 2 +- docs/operational-env.md | 3 +- scripts/install/server.sh | 3 +- server/Cargo.toml | 2 +- server/src/video_sinks/mjpeg_spool.rs | 10 +++---- server/src/video_sinks/webcam_sink.rs | 30 +++++++++++++++---- ...rver_upstream_media_v2_handoff_contract.rs | 3 ++ .../install/server_install_script_contract.rs | 4 ++- 10 files changed, 45 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b8f6372..33b072f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.22.16" +version = "0.22.17" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.22.16" +version = "0.22.17" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.22.16" +version = "0.22.17" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 5564489..b1cdd1b 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.22.16" +version = "0.22.17" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index fc0d2ff..d60c002 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.22.16" +version = "0.22.17" edition = "2024" build = "build.rs" diff --git a/docs/operational-env.md b/docs/operational-env.md index e7b3734..b3c7391 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -333,7 +333,8 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_UVC_FRAME_MAX_BYTES` | UVC helper MJPEG frame-size guard; explicit maximum accepted frame bytes, where `0` disables the guard and otherwise oversized frames are frozen out | | `LESAVKA_UVC_FRAME_SIZE` | server hardware/device override | | `LESAVKA_UVC_HEIGHT` | server hardware/device override | -| `LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS` | server HEVC decode-to-MJPEG freshness override; appsink pull wait for decoded MJPEG handoff before publishing newest frame to the UVC helper, defaults to `5` and is capped at `50` | +| `LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS` | server HEVC decode-to-MJPEG freshness override; appsink pull wait for decoded MJPEG handoff before publishing newest frame to the UVC helper, defaults to `20` and is capped at `50` | +| `LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS` | server HEVC decode-to-MJPEG branch queue depth; defaults to `2` and is capped at `4` so decode/JPEG scheduling jitter does not starve the UVC helper while stale frames still get dropped | | `LESAVKA_UVC_IDLE_PUMP_MS` | UVC helper freshness override; idle poll sleep while pumping host-returned buffers, defaults to `2` | | `LESAVKA_UVC_INTERVAL` | server hardware/device override | | `LESAVKA_UVC_LIMIT_PCT` | server hardware/device override | diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 888e79b..c9fd9de 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -1535,7 +1535,8 @@ SERVER_ENV_TMP=$(mktemp) printf 'LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES=%s\n' "${LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES:-65536}" printf 'LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES=%s\n' "${LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES:-12}" printf 'LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT=%s\n' "${LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT:-92}" - printf 'LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS=%s\n' "${LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS:-1}" + printf 'LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS=%s\n' "${LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS:-20}" + printf 'LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS=%s\n' "${LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS:-2}" printf 'LESAVKA_UVC_HEVC_DECODE_MISS_LIMIT=%s\n' "${LESAVKA_UVC_HEVC_DECODE_MISS_LIMIT:-15}" printf 'LESAVKA_SERVER_BIND_ADDR=%s\n' "${INSTALL_SERVER_BIND_ADDR}" printf 'LESAVKA_UVC_CODEC=%s\n' "${INSTALL_UVC_CODEC}" diff --git a/server/Cargo.toml b/server/Cargo.toml index 2d955ee..4f0227f 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.22.16" +version = "0.22.17" edition = "2024" autobins = false diff --git a/server/src/video_sinks/mjpeg_spool.rs b/server/src/video_sinks/mjpeg_spool.rs index 9a671c3..6318183 100644 --- a/server/src/video_sinks/mjpeg_spool.rs +++ b/server/src/video_sinks/mjpeg_spool.rs @@ -126,7 +126,7 @@ pub(super) fn decoded_mjpeg_pull_timeout() -> gst::ClockTime { let timeout_ms = std::env::var("LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS") .ok() .and_then(|value| value.trim().parse::().ok()) - .unwrap_or(5) + .unwrap_or(20) .min(50); gst::ClockTime::from_mseconds(timeout_ms) } @@ -248,15 +248,15 @@ pub(super) fn spool_mjpeg_frame_with_timing( mod tests { /// Verifies HEVC decoded-frame polling defaults to a freshness-first wait. /// - /// Input: unset timeout env var. Output: 5ms appsink poll timeout. Why: - /// server-side decode should keep enough patience for normal scheduling - /// jitter without letting an HEVC backlog accumulate behind UVC playback. + /// Input: unset timeout env var. Output: 20ms appsink poll timeout. Why: + /// server-side hardware decode/JPEG encode often lands inside one 30fps + /// frame interval, and a 5ms poll was starving Meet-visible UVC output. #[test] fn decoded_mjpeg_pull_timeout_defaults_to_short_bounded_wait() { temp_env::with_var_unset("LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS", || { assert_eq!( super::decoded_mjpeg_pull_timeout(), - gstreamer::ClockTime::from_mseconds(5) + gstreamer::ClockTime::from_mseconds(20) ); }); } diff --git a/server/src/video_sinks/webcam_sink.rs b/server/src/video_sinks/webcam_sink.rs index 9612077..4ed7f2c 100644 --- a/server/src/video_sinks/webcam_sink.rs +++ b/server/src/video_sinks/webcam_sink.rs @@ -104,7 +104,7 @@ fn uvc_appsrc_leaky_type() -> String { } fn uvc_hevc_freshness_queue_buffers() -> u32 { - positive_u64_env("LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS", 1) + positive_u64_env("LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS", 2) .min(4) .max(1) as u32 } @@ -134,12 +134,13 @@ fn configure_uvc_appsrc(appsrc: &gst_app::AppSrc) { } } -/// Build a single-frame leaky queue for the decoded HEVC handoff branch. +/// Build a tiny leaky queue for the decoded HEVC handoff branch. /// /// Inputs: a stable queue name. Output: configured GStreamer queue element. /// Why: hidden raw/JPEG backlogs are memory leaks in a live webcam path; after /// HEVC is decoded it is safe to drop stale raw frames and keep only the newest -/// candidate for MJPEG publication. +/// candidate for MJPEG publication while absorbing normal decoder scheduling +/// jitter. #[cfg(not(coverage))] fn build_hevc_freshness_queue(name: &str) -> anyhow::Result { let queue = gst::ElementFactory::make("queue") @@ -152,6 +153,22 @@ fn build_hevc_freshness_queue(name: &str) -> anyhow::Result { Ok(queue) } +/// Configure conservative recovery knobs on hardware HEVC decoders. +/// +/// Inputs: a decoder element selected by `require_hevc_decoder`. Output: +/// side-effect-only property updates when the element supports them. Why: the +/// Pi stateless decoder can otherwise hold onto corrupt or dependency-missing +/// pictures after live HEVC packet drops, starving the MJPEG UVC handoff. +#[cfg(not(coverage))] +fn configure_hevc_decoder(decoder: &gst::Element) { + if decoder.has_property("discard-corrupted-frames", None) { + decoder.set_property("discard-corrupted-frames", true); + } + if decoder.has_property("automatic-request-sync-points", None) { + decoder.set_property("automatic-request-sync-points", true); + } +} + #[cfg(not(coverage))] struct WebcamBusWatchHandle { alive: Arc, @@ -372,6 +389,7 @@ impl WebcamSink { let decoder = gst::ElementFactory::make(decoder_name) .build() .with_context(|| format!("building HEVC decoder element {decoder_name}"))?; + configure_hevc_decoder(&decoder); let decoded_queue = build_hevc_freshness_queue("hevc_mjpeg_decoded_queue")?; let convert = gst::ElementFactory::make("videoconvert").build()?; let encoder = gst::ElementFactory::make("jpegenc") @@ -751,10 +769,10 @@ mod tests { } #[test] - fn hevc_spool_freshness_bounds_default_to_single_frame_recovery() { + fn hevc_spool_freshness_bounds_default_to_tiny_live_handoff() { temp_env::with_var_unset("LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS", || { temp_env::with_var_unset("LESAVKA_UVC_HEVC_DECODE_MISS_LIMIT", || { - assert_eq!(super::uvc_hevc_freshness_queue_buffers(), 1); + assert_eq!(super::uvc_hevc_freshness_queue_buffers(), 2); assert_eq!(super::uvc_hevc_decode_miss_limit(), 15); }); }); @@ -775,7 +793,7 @@ mod tests { temp_env::with_var("LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS", Some("0"), || { temp_env::with_var("LESAVKA_UVC_HEVC_DECODE_MISS_LIMIT", Some("0"), || { - assert_eq!(super::uvc_hevc_freshness_queue_buffers(), 1); + assert_eq!(super::uvc_hevc_freshness_queue_buffers(), 2); assert_eq!(super::uvc_hevc_decode_miss_limit(), 15); }); }); diff --git a/tests/api/server/upstream_media_runtime/server_upstream_media_v2_handoff_contract.rs b/tests/api/server/upstream_media_runtime/server_upstream_media_v2_handoff_contract.rs index f951448..f39fb7c 100644 --- a/tests/api/server/upstream_media_runtime/server_upstream_media_v2_handoff_contract.rs +++ b/tests/api/server/upstream_media_runtime/server_upstream_media_v2_handoff_contract.rs @@ -117,6 +117,9 @@ fn hevc_ingress_decodes_to_existing_mjpeg_uvc_path() { "video/x-h265", "h265parse", "require_hevc_decoder()", + "configure_hevc_decoder(&decoder)", + "discard-corrupted-frames", + "automatic-request-sync-points", "jpegenc", "LESAVKA_UVC_HEVC_JPEG_QUALITY", "LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS", diff --git a/tests/installer/scripts/install/server_install_script_contract.rs b/tests/installer/scripts/install/server_install_script_contract.rs index fcd0519..ab00068 100644 --- a/tests/installer/scripts/install/server_install_script_contract.rs +++ b/tests/installer/scripts/install/server_install_script_contract.rs @@ -32,6 +32,7 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { "LESAVKA_UPSTREAM_PAIR_SLACK_US=%s", "LESAVKA_UPSTREAM_AUDIO_MASTER_WAIT_GRACE_MS=%s", "LESAVKA_UPSTREAM_STALE_DROP_MS=%s", + "LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS=%s", "LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS=%s", "LESAVKA_UVC_HEVC_DECODE_MISS_LIMIT=%s", "LESAVKA_SERVER_BIND_ADDR=%s", @@ -149,7 +150,8 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_PAIR_SLACK_US:-80000}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_AUDIO_MASTER_WAIT_GRACE_MS:-350}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_STALE_DROP_MS:-80}")); - assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS:-1}")); + assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS:-20}")); + assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS:-2}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEVC_DECODE_MISS_LIMIT:-15}")); assert!( SERVER_INSTALL.contains("lesavka_server::video=info"),