From 31b828808c5571377f1e8e39ed0dea1aa478604c Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Tue, 19 May 2026 12:01:13 -0300 Subject: [PATCH] fix: parse normalized UVC MJPEG caps --- Cargo.lock | 6 ++-- client/Cargo.toml | 2 +- common/Cargo.toml | 2 +- scripts/install/server.sh | 2 +- server/Cargo.toml | 2 +- server/src/video_sinks/webcam_sink.rs | 20 ++++++++--- .../video_sinks/webcam_sink/constructor.rs | 11 +++--- .../install/server_install_script_contract.rs | 7 ++++ ...rver_mjpeg_normalizer_memory_regression.rs | 36 +++++++++++++++++++ 9 files changed, 73 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bc2b7fe..9e0c88c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.25.4" +version = "0.25.5" dependencies = [ "anyhow", "async-stream", @@ -1692,7 +1692,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.25.4" +version = "0.25.5" dependencies = [ "anyhow", "base64", @@ -1704,7 +1704,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.25.4" +version = "0.25.5" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 4be7043..b1cceda 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.25.4" +version = "0.25.5" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 35fe8ca..a8dcf82 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.25.4" +version = "0.25.5" edition = "2024" build = "build.rs" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index fcf515c..9168503 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -388,7 +388,7 @@ LESAVKA_UVC_MAXBURST=$(uvc_env_value LESAVKA_UVC_MAXBURST 0) LESAVKA_UVC_BULK=$(uvc_env_value LESAVKA_UVC_BULK 1) LESAVKA_UVC_FRAME_SIZE_GUARD=$(uvc_env_value LESAVKA_UVC_FRAME_SIZE_GUARD 1) LESAVKA_UVC_FRAME_MAX_BYTES=$(uvc_env_value LESAVKA_UVC_FRAME_MAX_BYTES 0) -LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC=$(uvc_env_value LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC 4500000) +LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-4500000} LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT=$(uvc_env_value LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT 85) LESAVKA_UVC_STATS_PATH=$(uvc_env_value LESAVKA_UVC_STATS_PATH /run/lesavka-uvc-video-stats.json) EOF diff --git a/server/Cargo.toml b/server/Cargo.toml index d1ba5d7..5861cf5 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,7 +16,7 @@ bench = false [package] name = "lesavka_server" -version = "0.25.4" +version = "0.25.5" edition = "2024" autobins = false diff --git a/server/src/video_sinks/webcam_sink.rs b/server/src/video_sinks/webcam_sink.rs index 1c4c42f..8146e39 100644 --- a/server/src/video_sinks/webcam_sink.rs +++ b/server/src/video_sinks/webcam_sink.rs @@ -224,8 +224,10 @@ fn build_direct_mjpeg_normalize_branch( .field("width", width) .field("height", height) .field("pixel-aspect-ratio", gst::Fraction::new(1, 1)) - .field("colorimetry", "2:4:7:1") .build(); + let input_parser = gst::ElementFactory::make("jpegparse") + .name("direct_mjpeg_normalize_input_parse") + .build()?; let decoder = gst::ElementFactory::make("jpegdec").build()?; let decoded_queue = build_hevc_freshness_queue("direct_mjpeg_normalize_decoded_queue")?; let convert = gst::ElementFactory::make("videoconvert").build()?; @@ -243,6 +245,9 @@ fn build_direct_mjpeg_normalize_branch( hevc_mjpeg_guard::direct_mjpeg_jpeg_quality() as i32, ) .build()?; + let output_parser = gst::ElementFactory::make("jpegparse") + .name("direct_mjpeg_normalize_output_parse") + .build()?; let encoded_caps = gst::ElementFactory::make("capsfilter") .property("caps", &caps_mjpeg) .build()?; @@ -260,24 +265,28 @@ fn build_direct_mjpeg_normalize_branch( pipeline.add_many([ src.upcast_ref(), + &input_parser, &decoder, &decoded_queue, &convert, &scale, &raw_capsfilter, &encoder, + &output_parser, &encoded_caps, &encoded_queue, sink.upcast_ref(), ])?; gst::Element::link_many([ src.upcast_ref(), + &input_parser, &decoder, &decoded_queue, &convert, &scale, &raw_capsfilter, &encoder, + &output_parser, &encoded_caps, &encoded_queue, sink.upcast_ref(), @@ -290,7 +299,7 @@ fn add_hevc_mjpeg_spool_branch( pipeline: &gst::Pipeline, width: i32, height: i32, - fps: i32, + _fps: i32, ) -> anyhow::Result<(gst_app::AppSrc, gst_app::AppSink)> { let src = gst::ElementFactory::make("appsrc") .name("dynamic_hevc_mjpeg_src") @@ -311,9 +320,7 @@ fn add_hevc_mjpeg_spool_branch( .field("parsed", true) .field("width", width) .field("height", height) - .field("framerate", gst::Fraction::new(fps, 1)) .field("pixel-aspect-ratio", gst::Fraction::new(1, 1)) - .field("colorimetry", "2:4:7:1") .build(); let h265parse = gst::ElementFactory::make("h265parse") .property("disable-passthrough", true) @@ -329,6 +336,9 @@ fn add_hevc_mjpeg_spool_branch( let encoder = gst::ElementFactory::make("jpegenc") .property("quality", hevc_mjpeg_guard::hevc_jpeg_quality() as i32) .build()?; + let jpegparse = gst::ElementFactory::make("jpegparse") + .name("dynamic_hevc_mjpeg_output_parse") + .build()?; let caps = gst::ElementFactory::make("capsfilter") .property("caps", &caps_mjpeg) .build()?; @@ -351,6 +361,7 @@ fn add_hevc_mjpeg_spool_branch( &decoded_queue, &convert, &encoder, + &jpegparse, &caps, &encoded_queue, sink.upcast_ref(), @@ -362,6 +373,7 @@ fn add_hevc_mjpeg_spool_branch( &decoded_queue, &convert, &encoder, + &jpegparse, &caps, &encoded_queue, sink.upcast_ref(), diff --git a/server/src/video_sinks/webcam_sink/constructor.rs b/server/src/video_sinks/webcam_sink/constructor.rs index 1b690b0..72c09a0 100644 --- a/server/src/video_sinks/webcam_sink/constructor.rs +++ b/server/src/video_sinks/webcam_sink/constructor.rs @@ -107,9 +107,7 @@ impl WebcamSink { .field("parsed", true) .field("width", width) .field("height", height) - .field("framerate", gst::Fraction::new(fps, 1)) .field("pixel-aspect-ratio", gst::Fraction::new(1, 1)) - .field("colorimetry", "2:4:7:1") .build(); src.set_caps(Some(&caps_mjpeg)); @@ -169,7 +167,6 @@ impl WebcamSink { .field("height", height) .field("framerate", gst::Fraction::new(fps, 1)) .field("pixel-aspect-ratio", gst::Fraction::new(1, 1)) - .field("colorimetry", "2:4:7:1") .build(); src.set_caps(Some(&caps_mjpeg)); @@ -200,7 +197,6 @@ impl WebcamSink { .field("height", height) .field("framerate", gst::Fraction::new(fps, 1)) .field("pixel-aspect-ratio", gst::Fraction::new(1, 1)) - .field("colorimetry", "2:4:7:1") .build(); src.set_caps(Some(&caps_hevc)); @@ -218,6 +214,9 @@ impl WebcamSink { let encoder = gst::ElementFactory::make("jpegenc") .property("quality", hevc_mjpeg_guard::hevc_jpeg_quality() as i32) .build()?; + let jpegparse = gst::ElementFactory::make("jpegparse") + .name("hevc_mjpeg_output_parse") + .build()?; let caps = gst::ElementFactory::make("capsfilter") .property("caps", &caps_mjpeg) .build()?; @@ -247,6 +246,7 @@ impl WebcamSink { &decoded_queue, &convert, &encoder, + &jpegparse, &caps, &encoded_queue, sink.upcast_ref(), @@ -258,6 +258,7 @@ impl WebcamSink { &decoded_queue, &convert, &encoder, + &jpegparse, &caps, &encoded_queue, sink.upcast_ref(), @@ -281,6 +282,7 @@ impl WebcamSink { &decoder, &convert, &encoder, + &jpegparse, &caps, &sink, ])?; @@ -290,6 +292,7 @@ impl WebcamSink { &decoder, &convert, &encoder, + &jpegparse, &caps, &sink, ])?; diff --git a/tests/contract/scripts/install/server_install_script_contract.rs b/tests/contract/scripts/install/server_install_script_contract.rs index 6224069..ddcae7e 100644 --- a/tests/contract/scripts/install/server_install_script_contract.rs +++ b/tests/contract/scripts/install/server_install_script_contract.rs @@ -226,6 +226,13 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_BULK 1")); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_FRAME_SIZE_GUARD 1")); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_FRAME_MAX_BYTES 0")); + assert!(SERVER_INSTALL.contains( + "LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-4500000}" + )); + assert!( + !SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC"), + "installer should refresh the safer MJPEG byte-budget default instead of preserving an old field-tuned budget forever" + ); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT 85")); assert!( SERVER_INSTALL 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 2db0ac6..5b764fc 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 @@ -45,6 +45,10 @@ const WEBCAM_SINK: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/server/src/video_sinks/webcam_sink.rs" )); +const WEBCAM_CONSTRUCTOR: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video_sinks/webcam_sink/constructor.rs" +)); const WEBCAM_FRAME_HANDOFF: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/server/src/video_sinks/webcam_sink/frame_handoff.rs" @@ -113,6 +117,38 @@ fn installer_keeps_the_native_normalizer_memory_bounded_by_default() { assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB:-384}")); } +#[test] +fn native_mjpeg_sanitizer_parses_jpeg_caps_after_reencode() { + for marker in [ + "direct_mjpeg_normalize_input_parse", + "direct_mjpeg_normalize_output_parse", + "dynamic_hevc_mjpeg_output_parse", + ] { + assert!( + WEBCAM_SINK.contains(marker), + "normalizer/spool branches should preserve JPEG parser marker {marker}" + ); + } + assert!(WEBCAM_CONSTRUCTOR.contains("hevc_mjpeg_output_parse")); + assert_ordered( + WEBCAM_SINK, + "direct_mjpeg_normalize_output_parse", + ".property(\"caps\", &caps_mjpeg)", + ); + let dynamic_parse_pos = WEBCAM_SINK + .find("dynamic_hevc_mjpeg_output_parse") + .expect("dynamic HEVC spool parser marker"); + assert!( + WEBCAM_SINK[dynamic_parse_pos..].contains(".property(\"caps\", &caps_mjpeg)"), + "dynamic HEVC spool capsfilter should sit after jpegparse, not directly after jpegenc" + ); + assert!( + !WEBCAM_SINK.contains(".field(\"colorimetry\", \"2:4:7:1\")") + && !WEBCAM_CONSTRUCTOR.contains(".field(\"colorimetry\", \"2:4:7:1\")"), + "encoded JPEG parser output advertises bt709-style colorimetry, so hard-coded 2:4:7:1 caps can starve the branch" + ); +} + #[test] fn opt_in_normalizer_has_rss_fuse_before_per_frame_gstreamer_allocation() { assert!(WEBCAM_FRAME_HANDOFF.contains("current_process_rss_kb"));