fix: parse normalized UVC MJPEG caps

This commit is contained in:
Brad Stein 2026-05-19 12:01:13 -03:00
parent eec6c67679
commit 31b828808c
9 changed files with 73 additions and 15 deletions

6
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.25.4"
version = "0.25.5"
edition = "2024"
autobins = false

View File

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

View File

@ -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,
])?;

View File

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

View File

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