From f38adf70f95b4926deb509c66b1aeb362ea31828 Mon Sep 17 00:00:00 2001 From: dailz Date: Sat, 20 Jun 2026 19:57:15 +0800 Subject: [PATCH] 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 --- src/avhw.rs | 26 ++++++++++++++++++++------ src/state_portal.rs | 37 ++++++++++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/avhw.rs b/src/avhw.rs index 070798f..b0cb8cf 100644 --- a/src/avhw.rs +++ b/src/avhw.rs @@ -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 { 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<()> { diff --git a/src/state_portal.rs b/src/state_portal.rs index ba83c39..1eaed46 100644 --- a/src/state_portal.rs +++ b/src/state_portal.rs @@ -9,7 +9,8 @@ use anyhow::{bail, Result}; // 错误处理工具 use crate::args::Args; // 命令行参数 use crate::avhw::{ - self, BitrateCommand, CpuNv12Frame, ResolutionChange, SwEncEncode, SwEncImport, SwEncState, + self, BitrateCommand, CpuNv12Frame, EncodeOutcome, ResolutionChange, SwEncEncode, SwEncImport, + SwEncState, }; // 软件编码器状态(VAAPI 导入 + H.264 编码) use crate::cap_portal::{CapPortal, PwCtrlEvent, PwDmaBufFrame}; // PipeWire 屏幕采集端点 use crate::stats::{FrameTimings, PipelineStats}; // 管道统计(帧计时、每秒快照) @@ -176,7 +177,23 @@ impl StatePortal { match self.stage { PortalStage::WaitingForFormat => { - // 首帧到达,记录 DMA-BUF 格式信息 + // TODO(#19): Currently the encoder initializes on first PipeWire frame, + // even in WebRTC mode before any client connects. The recurring 60fps + // idle waste (DMA-BUF import + VAAPI scale + clone + channel send) is + // eliminated by the paused gate in handle_pw_frame (see #19 fix). + // However, the one-time SwEncEncode::new_webrtc cost (~50ms, swscale + // context + x264 setup + YUV frame allocation) still occurs at startup. + // + // If this startup cost becomes user-perceptible, upgrade to "Option A": + // 1. Add PortalStage::AwaitingClient + // 2. Keep WebRtcState on main thread during AwaitingClient, pump + // handle_signaling/poll_and_feed from main loop + // 3. On is_connected() == true, initialize encoder and move WebRtcState + // into the WebRTC thread + // 4. Use resolution-aware conservative default bitrate (see #21) + // + // Trigger condition: user reports perceivable latency or CPU spike at + // startup. Until then, Option B is sufficient. tracing::info!( "First DMA-BUF frame: {}x{} format=0x{:08X} stride={} modifier=0x{:X}", frame.width, @@ -494,6 +511,15 @@ impl StatePortal { /// 通过 `av_hwframe_map` 零拷贝导入 VAAPI,然后交给 SwEncState 完成: /// scale_vaapi GPU 缩放、2K NV12 回读、YUV420P 格式转换、软件 H.264 编码。 fn handle_pw_frame(&mut self, frame: PwDmaBufFrame) -> Result<()> { + // #19: When WebRTC mode is paused (no client connected), skip ALL frame + // processing — DMA-BUF import, VAAPI scale, NV12 clone, channel send, and + // encode thread wakeup. This eliminates ~60fps of pointless work during + // the pre-connect idle window. MP4 mode (webrtc_paused == None) is unaffected. + if let Some(paused) = &self.webrtc_paused { + if paused.load(Ordering::Relaxed) { + return Ok(()); + } + } let t_import_start = Instant::now(); let pts = self.frames_encoded as i64; @@ -652,7 +678,7 @@ fn encode_thread_loop( match input_rx.recv() { Ok(frame) => { match encode.encode_cpu_frame(&frame) { - Ok(()) => { + Ok(EncodeOutcome::Encoded) => { let t = encode.take_timing(); let _ = timing_tx.try_send(EncodeThreadTiming { sws_us: t.sws_us, @@ -660,6 +686,11 @@ fn encode_thread_loop( output_bytes: t.output_bytes, }); } + Ok(_) => { + // SkippedPaused / SkippedDisconnected / SkippedDuplicate + // Do not report timing; do not tick encoded_fps. + // take_timing() intentionally NOT called — last_timing stays default. + } Err(e) => { tracing::error!("Encode thread error: {e}"); break;