media: steady hevc to mjpeg uvc handoff

This commit is contained in:
Brad Stein 2026-05-12 16:11:53 -03:00
parent 1eabd30fa1
commit ef9701f6e9
10 changed files with 45 additions and 20 deletions

6
Cargo.lock generated
View File

@ -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",

View File

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

View File

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

View File

@ -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 |

View File

@ -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}"

View File

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

View File

@ -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::<u64>().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)
);
});
}

View File

@ -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<gst::Element> {
let queue = gst::ElementFactory::make("queue")
@ -152,6 +153,22 @@ fn build_hevc_freshness_queue(name: &str) -> anyhow::Result<gst::Element> {
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<AtomicBool>,
@ -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);
});
});

View File

@ -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",

View File

@ -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"),