feat(stats): expose duplicate_frames_skipped counter (closes #20)

Final piece of #20. The EncodeOutcome::SkippedDuplicate variant was
introduced in #19 but its count was invisible — silent Ok(_) arm in
encode_thread_loop. Now exposed as a stat.

Changes:

- stats.rs: PipelineStats gains duplicate_frames_skipped (window delta)
  and prev_duplicate_frames_skipped (running total). Snapshot field
  added. Display format places it after over_budget (both are counters).
  Reset clears window delta but preserves running total (same pattern
  as pipewire_dropped).

- state_portal.rs: EncodeThread struct gains duplicate_count:
  Arc<AtomicU64>. Cloned for encode_thread_loop, stored for main-thread
  reads. encode_thread_loop now explicitly matches SkippedDuplicate and
  increments with Ordering::Relaxed. Stats snapshot code reads atomic
  and calls set_duplicate_frames_skipped after timing drain.

What this enables:

Diagnosing encoded_fps health. Examples:
  - capture_fps=60 encoded_fps=30 duplicate_frames_skipped=30
    → healthy: encoder at 30fps target, 30 frames were true duplicates
  - capture_fps=60 encoded_fps=5 duplicate_frames_skipped=0
    → problem: frames not being dedup'd but encoder can't keep up
  - capture_fps=1.7 encoded_fps=1.7 duplicate_frames_skipped=0
    → healthy static: low fps because KWin damage-driven delivery

Original #20 issues status:
  - 'encoded_fps stuck at ~30': FIXED via #19 (EncodeOutcome enum), now
    tracks capture_fps when below 30
  - 'filler masking real fps': FIXED via #15/#18 (filler deleted)
  - 'duplicate count invisible': FIXED via this commit
  - 'unique_encoded_fps / delivered_fps': not implemented, deemed
    unnecessary now that the core metrics are trustworthy

Tests:
- cargo build --release: 0 new warnings (19 baseline preserved)
- cargo test: 96 lib + 3 integration, 0 failed
- SAFETY comments preserved verbatim
- 2 files changed, +45/-6 lines
This commit is contained in:
dailz
2026-06-21 10:04:41 +08:00
parent 631934458c
commit a06a41f5f2
2 changed files with 45 additions and 6 deletions
+21 -2
View File
@@ -39,6 +39,12 @@ pub struct PipelineStats {
sent_frames: u64,
pipewire_dropped: u64,
over_budget_count: u64,
/// Count of frames dropped by encode thread due to Y-plane hash dedup
/// (EncodeOutcome::SkippedDuplicate). Read from atomic counter set by
/// encode thread, computed as delta since previous snapshot.
duplicate_frames_skipped: u64,
// Running total from the encode-thread atomic; NOT reset between windows.
prev_duplicate_frames_skipped: u64,
// --- queue depth at last observation ---
capture_queue_depth: usize,
@@ -75,6 +81,8 @@ impl PipelineStats {
sent_frames: 0,
pipewire_dropped: 0,
over_budget_count: 0,
duplicate_frames_skipped: 0,
prev_duplicate_frames_skipped: 0,
capture_queue_depth: 0,
encoded_queue_depth: 0,
capture_gaps_ms: Vec::new(),
@@ -186,6 +194,13 @@ impl PipelineStats {
self.pipewire_dropped = total_dropped.saturating_sub(prev_dropped);
}
/// Update duplicate frames skipped counter (absolute value from atomic).
/// Computes delta from previous value, like set_pipewire_dropped.
pub fn set_duplicate_frames_skipped(&mut self, total_skipped: u64) {
self.duplicate_frames_skipped = total_skipped.saturating_sub(self.prev_duplicate_frames_skipped);
self.prev_duplicate_frames_skipped = total_skipped;
}
/// Update queue depth observations.
pub fn set_queue_depths(&mut self, capture: usize, encoded: usize) {
self.capture_queue_depth = capture;
@@ -216,6 +231,7 @@ impl PipelineStats {
sent_frames: self.sent_frames,
pipewire_dropped: self.pipewire_dropped,
over_budget_count: self.over_budget_count,
duplicate_frames_skipped: self.duplicate_frames_skipped,
capture_queue_depth: self.capture_queue_depth,
encoded_queue_depth: self.encoded_queue_depth,
capture_gap_avg_ms: avg_f64(&self.capture_gaps_ms),
@@ -254,6 +270,7 @@ impl PipelineStats {
self.sent_frames = 0;
self.pipewire_dropped = 0;
self.over_budget_count = 0;
self.duplicate_frames_skipped = 0;
self.capture_queue_depth = 0;
self.encoded_queue_depth = 0;
self.capture_gaps_ms.clear();
@@ -288,6 +305,7 @@ pub struct StatsSnapshot {
pub sent_frames: u64,
pub pipewire_dropped: u64,
pub over_budget_count: u64,
pub duplicate_frames_skipped: u64,
// Queue depths
pub capture_queue_depth: usize,
pub encoded_queue_depth: usize,
@@ -330,8 +348,8 @@ impl std::fmt::Display for StatsSnapshot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"capture_fps={:.1} encoded_fps={:.1} sent_fps={:.1} \
pw_dropped={} over_budget={} \
"capture_fps={:.1} encoded_fps={:.1} sent_fps={:.1} \
pw_dropped={} over_budget={} duplicate_frames_skipped={} \
cap_q={} enc_q={} \
cap_gap_p95={:.1}ms cap_gap_max={:.1}ms \
enc_gap_p95={:.1}ms enc_gap_max={:.1}ms \
@@ -346,6 +364,7 @@ impl std::fmt::Display for StatsSnapshot {
self.sent_fps,
self.pipewire_dropped,
self.over_budget_count,
self.duplicate_frames_skipped,
self.capture_queue_depth,
self.encoded_queue_depth,
self.capture_gap_p95_ms,