fix(state_portal): gate handle_pw_frame on WebRTC paused state (closes #19)

Root cause (verified via code reading + Oracle design review):
- webrtc_paused IS correctly initialized to true in StatePortal::new()
- encode_cpu_frame DOES respect paused via early return
- BUT encode_thread_loop unconditionally sent timing on every Ok(()),
  causing stats.record_encode_thread to tick encoded_frames even for
  paused-dropped frames -> phantom encoded_fps=29.7 during idle
- Real waste: handle_pw_frame imports DMA-BUF + VAAPI scale + NV12 clone
  + crossbeam send at 60fps even when no WebRTC client is connected

Fix (Option B - surgical bugfix):
- Add EncodeOutcome enum (Encoded/SkippedPaused/SkippedDisconnected/
  SkippedDuplicate) to encode_cpu_frame return type
- encode_thread_loop only reports timing on Ok(Encoded), not on skips
  -> encoded_fps naturally stays at 0 during idle (also helps #20)
- handle_pw_frame entry: early return on paused, skipping ALL frame
  processing (DMA-BUF import, VAAPI scale, NV12 clone, channel send)
- MP4 mode unchanged (webrtc_paused is None, gate is no-op)
- Side benefit: last_fillable_frame stays None during initial idle,
  so maybe_send_filler_frame also early-returns -> no filler waste
  during the pre-connect idle window (partial mitigation for #18)

Out of scope (TODO comment added at state_portal.rs:178):
- Encoder still initializes on first PipeWire frame (one-time ~50ms
  SwEncEncode::new_webrtc cost). Full deferral (Option A) requires
  splitting WebRTC signaling lifecycle from media lifecycle - deferred
  until startup cost becomes user-perceptible
- Bitrate formula unchanged (5*W*H*fps/100) - tracked by #21

Verification (34s idle + 60s connected session):
- 0 'skipping duplicate frame' events during idle (was ~30/sec before)
- 0 BWE bitrate updates during idle
- 0 IDR production during idle (was 180 frames into the void)
- First IDR produced 178ms after connect (ForceKeyframe -> IDR in 10ms)
- cargo test transform/fps_limit/backend_detect: 34 passed
- SAFETY comments preserved verbatim
This commit is contained in:
dailz
2026-06-20 19:57:15 +08:00
parent 92760dd8ee
commit f38adf70f9
2 changed files with 54 additions and 9 deletions
+20 -6
View File
@@ -48,6 +48,20 @@ pub struct SwEncodeTiming {
pub output_bytes: usize,
}
/// Outcome of a single `encode_cpu_frame` call. Used by the encode thread
/// to decide whether to report timing stats (only real encodes tick encoded_fps).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EncodeOutcome {
/// Frame was actually encoded and produced output bytes.
Encoded,
/// Frame was dropped because WebRTC is paused (no client connected).
SkippedPaused,
/// Frame was dropped because the encoder is in disconnected state.
SkippedDisconnected,
/// Frame was dropped because its Y-plane hash matched the previous frame.
SkippedDuplicate,
}
// ---------------------------------------------------------------------------
// AvHwDevCtx
// ---------------------------------------------------------------------------
@@ -1116,11 +1130,11 @@ impl SwEncEncode {
mem::take(&mut self.last_timing)
}
pub fn encode_cpu_frame(&mut self, frame: &CpuNv12Frame) -> Result<()> {
pub fn encode_cpu_frame(&mut self, frame: &CpuNv12Frame) -> Result<EncodeOutcome> {
self.last_timing = SwEncodeTiming::default();
if self.webrtc_disconnected {
return Ok(());
return Ok(EncodeOutcome::SkippedDisconnected);
}
// Must drain before the stride check: the import thread emits
@@ -1155,7 +1169,7 @@ impl SwEncEncode {
}
if let Some(ref paused) = self.webrtc_paused {
if paused.load(Ordering::Relaxed) {
return Ok(());
return Ok(EncodeOutcome::SkippedPaused);
}
}
@@ -1173,7 +1187,7 @@ impl SwEncEncode {
if frame_index > 0 && !force_gop_frame && !force_this_frame && current_hash == self.last_frame_hash {
tracing::debug!(frame_index, "skipping duplicate frame");
self.last_frame_hash = current_hash;
return Ok(());
return Ok(EncodeOutcome::SkippedDuplicate);
}
self.last_frame_hash = current_hash;
@@ -1247,7 +1261,7 @@ impl SwEncEncode {
output_bytes,
};
Ok(())
Ok(EncodeOutcome::Encoded)
}
fn recreate_encoder(&mut self, width: u32, height: u32) -> Result<()> {
@@ -1470,7 +1484,7 @@ impl SwEncState {
pub fn encode_frame(&mut self, hw_frame: &ff::frame::Video) -> Result<()> {
let cpu_frame = self.import.import_and_scale(hw_frame)?;
self.encode.encode_cpu_frame(&cpu_frame)
self.encode.encode_cpu_frame(&cpu_frame).map(|_| ())
}
pub fn flush(&mut self) -> Result<()> {