From b4b9990efee053b83d2e85d9e95f66610e54cf4b Mon Sep 17 00:00:00 2001 From: dailz Date: Sat, 20 Jun 2026 23:20:36 +0800 Subject: [PATCH] feat(stats): populate frame_age metric for WebRTC path (partial #20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add quantitative capture-to-send latency measurement so we can diagnose remaining latency sources after #24/#25 PTS fixes. Previously frame_age_p95 was always 0.0ms because the WebRTC code path never propagated capture timestamps, even though stats.rs already supported the metric. The infrastructure existed but was disconnected. Changes: - avhw.rs: Add capture_time: Instant field to CpuNv12Frame (set when PipeWire delivers frame) and EncodedH264Frame (propagated through encode thread via new last_capture_time side-channel on SwEncEncode). - state_portal.rs: Change sent_gap channel type from Sender to Sender<(f64, Option)> so WebRTC thread can send pre-computed age_ms = capture_time.elapsed() at the exact send moment (not at stats drain time, which would inflate the measurement by ~1s). - stats.rs: record_send_from_thread now accepts Option age_ms and pushes to frame_age_ms Vec when Some. After this commit: - stats: log lines show real frame_age_p95 / frame_age_max in ms - Expected range: 5-30ms (import + scale + encode + channel send) - If much higher: server pipeline has queueing issue - If low but user still sees latency: confirms bottleneck is network or browser-side (jitter buffer, decode queue) Scope notes: - Only Portal/PipeWire path is instrumented. wlr-screencopy path uses different code path (EncState, not SwEncState) and will continue to report frame_age=0.0ms. Adding wlr instrumentation is separate scope. - This is diagnostic only — does NOT change user-visible behavior. No encoding, sending, or stats output format changes. Tests: - cargo build --release: 0 new warnings (19 baseline preserved) - cargo test: 96 lib + 3 integration, 0 failed - SAFETY comments preserved verbatim - 3 files changed, +39/-10 lines Refs #20. --- src/avhw.rs | 18 ++++++++++++++++++ src/state_portal.rs | 21 ++++++++++++++------- src/stats.rs | 10 +++++++--- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/avhw.rs b/src/avhw.rs index 059caa8..60f2be5 100644 --- a/src/avhw.rs +++ b/src/avhw.rs @@ -725,6 +725,8 @@ pub struct EncodedH264Frame { /// PTS in encoder time_base units (1/fps seconds), normalized so first frame = 0. /// Derived from real capture time, NOT frame counter. pub pts_ticks: i64, + /// Wall-clock capture time, propagated from CpuNv12Frame for frame_age stat. + pub capture_time: std::time::Instant, } pub enum FrameOutput { @@ -740,6 +742,9 @@ pub struct CpuNv12Frame { pub y_stride: usize, pub uv_stride: usize, pub pts: i64, + /// Wall-clock time when this frame was captured (PipeWire delivery). + /// Used for frame_age stat: time from capture to WebRTC send. + pub capture_time: std::time::Instant, } pub struct SwEncImport { @@ -987,6 +992,7 @@ impl SwEncImport { y_stride, uv_stride, pts, + capture_time: std::time::Instant::now(), } }; @@ -1020,6 +1026,10 @@ pub struct SwEncEncode { /// every `encode_cpu_frame` call (even on early returns) so stale values /// from a previous frame can never leak out. last_timing: SwEncodeTiming, + /// Capture time of the frame currently being encoded. Saved from the + /// input `CpuNv12Frame` so `drain_encoder` can propagate it into the + /// emitted `EncodedH264Frame` for the frame_age stat (issue #20). + last_capture_time: Option, } const FNV1A_OFFSET_BASIS: u64 = 0xcbf29ce484222325; @@ -1090,6 +1100,7 @@ impl SwEncEncode { gop_size, force_keyframe_pending: false, last_timing: SwEncodeTiming::default(), + last_capture_time: None, }) } @@ -1130,6 +1141,7 @@ impl SwEncEncode { gop_size, force_keyframe_pending: false, last_timing: SwEncodeTiming::default(), + last_capture_time: None, }) } @@ -1154,6 +1166,9 @@ impl SwEncEncode { pub fn encode_cpu_frame(&mut self, frame: &CpuNv12Frame) -> Result { self.last_timing = SwEncodeTiming::default(); + // Save capture_time so drain_encoder can propagate it into the + // EncodedH264Frame emitted via the WebRTC channel (issue #20). + self.last_capture_time = Some(frame.capture_time); if self.webrtc_disconnected { return Ok(EncodeOutcome::SkippedDisconnected); @@ -1417,6 +1432,9 @@ impl SwEncEncode { match tx.try_send(EncodedH264Frame { data: data.to_vec(), pts_ticks, + capture_time: self + .last_capture_time + .unwrap_or_else(Instant::now), }) { Ok(()) => {} Err(crossbeam_channel::TrySendError::Full(frame)) => { diff --git a/src/state_portal.rs b/src/state_portal.rs index 26ca3a5..e2d410b 100644 --- a/src/state_portal.rs +++ b/src/state_portal.rs @@ -38,7 +38,7 @@ struct EncodeThread { struct WebrtcThread { handle: Option>, - sent_gap_rx: crossbeam_channel::Receiver, + sent_gap_rx: crossbeam_channel::Receiver<(f64, Option)>, } /// 门户模式的主状态机 @@ -281,7 +281,8 @@ impl StatePortal { .clone(); let fps = self.args.fps; let max_bitrate = self.args.max_bitrate; - let (sent_gap_tx, sent_gap_rx) = crossbeam_channel::bounded(64); + let (sent_gap_tx, sent_gap_rx) = + crossbeam_channel::bounded::<(f64, Option)>(64); let webrtc_handle = std::thread::Builder::new() .name("wl-webrtc-webrtc".into()) .spawn(move || { @@ -349,8 +350,8 @@ impl StatePortal { } } if let Some(ref webrtc_thread) = self.webrtc_thread { - while let Ok(gap_ms) = webrtc_thread.sent_gap_rx.try_recv() { - self.stats.record_send_from_thread(gap_ms); + while let Ok((gap_ms, age_ms)) = webrtc_thread.sent_gap_rx.try_recv() { + self.stats.record_send_from_thread(gap_ms, age_ms); } } let snap = self.stats.snapshot_and_reset(); @@ -694,7 +695,7 @@ fn webrtc_thread_loop( enc_height: u32, max_bitrate: u64, paused: Arc, - sent_gap_tx: crossbeam_channel::Sender, + sent_gap_tx: crossbeam_channel::Sender<(f64, Option)>, bitrate_tx: crossbeam_channel::Sender, resolution_tx: crossbeam_channel::Sender, ) { @@ -799,8 +800,12 @@ fn webrtc_thread_loop( let gap_ms = last_send .map(|l| l.elapsed().as_secs_f64() * 1000.0) .unwrap_or(0.0); + // Compute capture-to-send age on the sending thread so the + // frame_age stat stays accurate when batch-drained later. + let age_ms = + Some(enc_frame.capture_time.elapsed().as_secs_f64() * 1000.0); last_send = Some(std::time::Instant::now()); - let _ = sent_gap_tx.try_send(gap_ms); + let _ = sent_gap_tx.try_send((gap_ms, age_ms)); } } else { while webrtc_rx.try_recv().is_ok() {} @@ -817,8 +822,10 @@ fn webrtc_thread_loop( let gap_ms = last_send .map(|l| l.elapsed().as_secs_f64() * 1000.0) .unwrap_or(0.0); + let age_ms = + Some(enc_frame.capture_time.elapsed().as_secs_f64() * 1000.0); last_send = Some(std::time::Instant::now()); - let _ = sent_gap_tx.try_send(gap_ms); + let _ = sent_gap_tx.try_send((gap_ms, age_ms)); } } Err(crossbeam_channel::RecvTimeoutError::Timeout) => {} diff --git a/src/stats.rs b/src/stats.rs index 1e9d196..bc87515 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -168,13 +168,17 @@ impl PipelineStats { /// Record a frame sent from a background WebRTC thread. /// `gap_ms` is the pre-computed time since the previous send (0.0 = first frame). - /// Unlike `record_send`, this does not sample `Instant::now()`, so it remains - /// accurate even when batch-drained at stats snapshot time. - pub fn record_send_from_thread(&mut self, gap_ms: f64) { + /// `age_ms` is the pre-computed capture-to-send latency (None if unavailable). + /// Both are pre-computed on the sending thread to remain accurate when + /// batch-drained at stats snapshot time on the main thread. + pub fn record_send_from_thread(&mut self, gap_ms: f64, age_ms: Option) { if gap_ms > 0.0 { self.sent_gaps_ms.push(gap_ms); } self.sent_frames += 1; + if let Some(age) = age_ms { + self.frame_age_ms.push(age); + } } /// Update PipeWire dropped counter (absolute value from AtomicU64).