lesavka: expose preview stream and decode caps

This commit is contained in:
Brad Stein 2026-04-18 02:12:01 -03:00
parent 381676f3a3
commit 2e47863584
7 changed files with 135 additions and 6 deletions

View File

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

View File

@ -26,6 +26,8 @@ pub struct PerformanceSample {
pub left_server_queue_peak: u32,
pub left_server_encoder_label: String,
pub left_decoder_label: String,
pub left_stream_caps_label: String,
pub left_decoded_caps_label: String,
pub right_receive_fps: f32,
pub right_present_fps: f32,
pub right_server_fps: f32,
@ -39,6 +41,8 @@ pub struct PerformanceSample {
pub right_server_queue_peak: u32,
pub right_server_encoder_label: String,
pub right_decoder_label: String,
pub right_stream_caps_label: String,
pub right_decoded_caps_label: String,
pub dropped_frames: u64,
pub queue_depth: u32,
}
@ -109,6 +113,8 @@ pub struct SnapshotReport {
pub left_server_send_gap_peak_ms: f32,
pub left_server_queue_peak: u32,
pub left_server_encoder_label: String,
pub left_stream_caps_label: String,
pub left_decoded_caps_label: String,
pub right_surface: String,
pub right_capture_profile: String,
pub right_capture_transport: String,
@ -123,6 +129,8 @@ pub struct SnapshotReport {
pub right_server_send_gap_peak_ms: f32,
pub right_server_queue_peak: u32,
pub right_server_encoder_label: String,
pub right_stream_caps_label: String,
pub right_decoded_caps_label: String,
pub selected_camera: Option<String>,
pub selected_microphone: Option<String>,
pub selected_speaker: Option<String>,
@ -223,6 +231,24 @@ impl SnapshotReport {
}
})
.unwrap_or_else(|| "pending".to_string()),
left_stream_caps_label: latest
.map(|sample| {
if sample.left_stream_caps_label.is_empty() {
"pending".to_string()
} else {
sample.left_stream_caps_label.clone()
}
})
.unwrap_or_else(|| "pending".to_string()),
left_decoded_caps_label: latest
.map(|sample| {
if sample.left_decoded_caps_label.is_empty() {
"pending".to_string()
} else {
sample.left_decoded_caps_label.clone()
}
})
.unwrap_or_else(|| "pending".to_string()),
right_surface: state.display_surface(1).label().to_string(),
right_capture_profile: format!(
"{} | {}x{} | {} fps | {} kbit",
@ -277,6 +303,24 @@ impl SnapshotReport {
}
})
.unwrap_or_else(|| "pending".to_string()),
right_stream_caps_label: latest
.map(|sample| {
if sample.right_stream_caps_label.is_empty() {
"pending".to_string()
} else {
sample.right_stream_caps_label.clone()
}
})
.unwrap_or_else(|| "pending".to_string()),
right_decoded_caps_label: latest
.map(|sample| {
if sample.right_decoded_caps_label.is_empty() {
"pending".to_string()
} else {
sample.right_decoded_caps_label.clone()
}
})
.unwrap_or_else(|| "pending".to_string()),
selected_camera: state.devices.camera.clone(),
selected_microphone: state.devices.microphone.clone(),
selected_speaker: state.devices.speaker.clone(),
@ -333,6 +377,8 @@ impl SnapshotReport {
self.left_queue_depth,
self.left_queue_peak
);
let _ = writeln!(text, " stream caps: {}", self.left_stream_caps_label);
let _ = writeln!(text, " decoded caps: {}", self.left_decoded_caps_label);
let _ = writeln!(
text,
" server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}",
@ -357,6 +403,8 @@ impl SnapshotReport {
self.right_queue_depth,
self.right_queue_peak
);
let _ = writeln!(text, " stream caps: {}", self.right_stream_caps_label);
let _ = writeln!(text, " decoded caps: {}", self.right_decoded_caps_label);
let _ = writeln!(
text,
" server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}",
@ -657,6 +705,9 @@ mod tests {
left_server_queue_peak: n as u32 + 1,
left_server_encoder_label: "x264enc".to_string(),
left_decoder_label: "decodebin".to_string(),
left_stream_caps_label: "video/x-h264, width=(int)1920, height=(int)1080".to_string(),
left_decoded_caps_label:
"video/x-raw, format=(string)NV12, width=(int)1920, height=(int)1080".to_string(),
right_receive_fps: 30.0,
right_present_fps: 28.0,
right_server_fps: 30.0,
@ -670,6 +721,9 @@ mod tests {
right_server_queue_peak: n as u32 + 1,
right_server_encoder_label: "source-pass-through".to_string(),
right_decoder_label: "decodebin".to_string(),
right_stream_caps_label: "video/x-h264, width=(int)1920, height=(int)1080".to_string(),
right_decoded_caps_label:
"video/x-raw, format=(string)NV12, width=(int)1920, height=(int)1080".to_string(),
dropped_frames: n,
queue_depth: n as u32,
}
@ -730,6 +784,8 @@ mod tests {
assert!(report.left_capture_profile.contains("fps"));
assert_eq!(report.left_capture_transport, "server re-encode");
assert_eq!(report.left_decoder_label, "decodebin");
assert!(report.left_stream_caps_label.contains("video/x-h264"));
assert!(report.left_decoded_caps_label.contains("video/x-raw"));
}
#[test]
@ -758,6 +814,8 @@ mod tests {
assert!(text.contains("left eye"));
assert!(text.contains("transport:"));
assert!(text.contains("live: decoder="));
assert!(text.contains("stream caps:"));
assert!(text.contains("decoded caps:"));
assert!(text.contains("recommendations"));
}

View File

@ -5,7 +5,7 @@ use anyhow::{Context, Result};
#[cfg(not(coverage))]
use gstreamer as gst;
#[cfg(not(coverage))]
use gstreamer::prelude::{Cast, ElementExt, GstBinExt, GstObjectExt};
use gstreamer::prelude::{Cast, ElementExt, GstBinExt, GstObjectExt, PadExt};
#[cfg(not(coverage))]
use gstreamer_app as gst_app;
#[cfg(not(coverage))]
@ -90,6 +90,8 @@ pub struct PreviewMetricsSnapshot {
pub server_queue_peak: u32,
pub server_encoder_label: String,
pub decoder_label: String,
pub stream_caps_label: String,
pub decoded_caps_label: String,
}
#[cfg(not(coverage))]
@ -496,6 +498,8 @@ struct PreviewTelemetry {
latest_server_queue_peak: u32,
latest_server_encoder_label: String,
decoder_label: String,
stream_caps_label: String,
decoded_caps_label: String,
}
#[cfg(not(coverage))]
@ -597,6 +601,18 @@ impl PreviewTelemetry {
}
}
fn note_stream_caps(&mut self, caps_label: &str) {
if !caps_label.is_empty() {
self.stream_caps_label = caps_label.to_string();
}
}
fn note_decoded_caps(&mut self, caps_label: &str) {
if !caps_label.is_empty() {
self.decoded_caps_label = caps_label.to_string();
}
}
fn snapshot(&mut self) -> PreviewMetricsSnapshot {
self.snapshot_at(Instant::now())
}
@ -640,6 +656,8 @@ impl PreviewTelemetry {
server_queue_peak: self.latest_server_queue_peak,
server_encoder_label: self.latest_server_encoder_label.clone(),
decoder_label: self.decoder_label.clone(),
stream_caps_label: self.stream_caps_label.clone(),
decoded_caps_label: self.decoded_caps_label.clone(),
}
}
@ -810,6 +828,8 @@ fn run_preview_feed(
log_sink: Arc<Mutex<Option<std::sync::mpsc::Sender<String>>>>,
) -> Result<()> {
let (pipeline, appsrc, appsink, decoder_name) = build_preview_pipeline(profile)?;
let parser = pipeline.by_name("preview_parse");
let decoder = pipeline.by_name("decoder");
if let Ok(mut slot) = shared.lock() {
slot.telemetry.note_decoder(&decoder_name);
}
@ -830,6 +850,8 @@ fn run_preview_feed(
{
let shared = Arc::clone(&shared);
let appsink = appsink.clone();
let parser = parser.clone();
let decoder = decoder.clone();
let running = Arc::clone(&running);
std::thread::spawn(move || {
loop {
@ -837,6 +859,12 @@ fn run_preview_feed(
break;
}
if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(250)) {
if let Some(parser) = parser.as_ref() {
record_preview_caps(&shared, parser, "src", PreviewCapsKind::Stream);
}
if let Some(decoder) = decoder.as_ref() {
record_preview_caps(&shared, decoder, "src", PreviewCapsKind::Decoded);
}
if let Some(frame) = sample_to_frame(&sample) {
if let Ok(mut slot) = shared.lock() {
slot.push_frame(frame);
@ -1225,7 +1253,7 @@ fn build_preview_pipeline(
let desc = format!(
"appsrc name=src is-live=true format=time do-timestamp=true block=false ! \
queue max-size-buffers=6 max-size-time=0 max-size-bytes=0 leaky=downstream ! \
h264parse disable-passthrough=true ! {decoder_name} name=decoder ! videoconvert ! videoscale ! \
h264parse name=preview_parse disable-passthrough=true ! {decoder_name} name=decoder ! videoconvert ! videoscale ! \
video/x-raw,format=RGBA,width={},height={},pixel-aspect-ratio=1/1 ! \
appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true",
profile.display_width, profile.display_height
@ -1272,6 +1300,45 @@ fn push_preview_packet(appsrc: &gst_app::AppSrc, pkt: VideoPacket) {
let _ = appsrc.push_buffer(buf);
}
#[cfg(not(coverage))]
#[derive(Clone, Copy)]
enum PreviewCapsKind {
Stream,
Decoded,
}
#[cfg(not(coverage))]
fn record_preview_caps(
shared: &Arc<Mutex<SharedPreviewState>>,
element: &gst::Element,
pad_name: &str,
kind: PreviewCapsKind,
) {
let Some(pad) = element.static_pad(pad_name) else {
return;
};
let Some(caps) = pad.current_caps() else {
return;
};
let caps_label = preview_caps_summary(&caps);
if caps_label.is_empty() {
return;
}
if let Ok(mut slot) = shared.lock() {
match kind {
PreviewCapsKind::Stream => slot.telemetry.note_stream_caps(&caps_label),
PreviewCapsKind::Decoded => slot.telemetry.note_decoded_caps(&caps_label),
}
}
}
#[cfg(not(coverage))]
fn preview_caps_summary(caps: &gst::Caps) -> String {
caps.structure(0)
.map(|structure| structure.to_string())
.unwrap_or_else(|| caps.to_string())
}
#[cfg(not(coverage))]
fn record_preview_packet(shared: &Arc<Mutex<SharedPreviewState>>, pkt: &VideoPacket) {
if let Ok(mut slot) = shared.lock() {

View File

@ -303,6 +303,8 @@ fn record_diagnostics_sample(
left_server_queue_peak: left_metrics.server_queue_peak,
left_server_encoder_label: left_metrics.server_encoder_label.clone(),
left_decoder_label: left_metrics.decoder_label.clone(),
left_stream_caps_label: left_metrics.stream_caps_label.clone(),
left_decoded_caps_label: left_metrics.decoded_caps_label.clone(),
right_receive_fps: right_metrics.receive_fps,
right_present_fps: right_metrics.present_fps,
right_server_fps: right_metrics.server_fps,
@ -316,6 +318,8 @@ fn record_diagnostics_sample(
right_server_queue_peak: right_metrics.server_queue_peak,
right_server_encoder_label: right_metrics.server_encoder_label.clone(),
right_decoder_label: right_metrics.decoder_label.clone(),
right_stream_caps_label: right_metrics.stream_caps_label.clone(),
right_decoded_caps_label: right_metrics.decoded_caps_label.clone(),
dropped_frames: left_metrics
.dropped_frames
.saturating_add(right_metrics.dropped_frames),

View File

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

View File

@ -17,6 +17,6 @@ mod tests {
#[test]
fn banner_includes_version() {
assert_eq!(banner("0.11.9"), "lesavka-common CLI (v0.11.9)");
assert_eq!(banner("0.11.10"), "lesavka-common CLI (v0.11.10)");
}
}

View File

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