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, 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 // AvHwDevCtx
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1116,11 +1130,11 @@ impl SwEncEncode {
mem::take(&mut self.last_timing) 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(); self.last_timing = SwEncodeTiming::default();
if self.webrtc_disconnected { if self.webrtc_disconnected {
return Ok(()); return Ok(EncodeOutcome::SkippedDisconnected);
} }
// Must drain before the stride check: the import thread emits // 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 let Some(ref paused) = self.webrtc_paused {
if paused.load(Ordering::Relaxed) { 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 { if frame_index > 0 && !force_gop_frame && !force_this_frame && current_hash == self.last_frame_hash {
tracing::debug!(frame_index, "skipping duplicate frame"); tracing::debug!(frame_index, "skipping duplicate frame");
self.last_frame_hash = current_hash; self.last_frame_hash = current_hash;
return Ok(()); return Ok(EncodeOutcome::SkippedDuplicate);
} }
self.last_frame_hash = current_hash; self.last_frame_hash = current_hash;
@@ -1247,7 +1261,7 @@ impl SwEncEncode {
output_bytes, output_bytes,
}; };
Ok(()) Ok(EncodeOutcome::Encoded)
} }
fn recreate_encoder(&mut self, width: u32, height: u32) -> Result<()> { 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<()> { pub fn encode_frame(&mut self, hw_frame: &ff::frame::Video) -> Result<()> {
let cpu_frame = self.import.import_and_scale(hw_frame)?; 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<()> { pub fn flush(&mut self) -> Result<()> {
+34 -3
View File
@@ -9,7 +9,8 @@ use anyhow::{bail, Result}; // 错误处理工具
use crate::args::Args; // 命令行参数 use crate::args::Args; // 命令行参数
use crate::avhw::{ use crate::avhw::{
self, BitrateCommand, CpuNv12Frame, ResolutionChange, SwEncEncode, SwEncImport, SwEncState, self, BitrateCommand, CpuNv12Frame, EncodeOutcome, ResolutionChange, SwEncEncode, SwEncImport,
SwEncState,
}; // 软件编码器状态(VAAPI 导入 + H.264 编码) }; // 软件编码器状态(VAAPI 导入 + H.264 编码)
use crate::cap_portal::{CapPortal, PwCtrlEvent, PwDmaBufFrame}; // PipeWire 屏幕采集端点 use crate::cap_portal::{CapPortal, PwCtrlEvent, PwDmaBufFrame}; // PipeWire 屏幕采集端点
use crate::stats::{FrameTimings, PipelineStats}; // 管道统计(帧计时、每秒快照) use crate::stats::{FrameTimings, PipelineStats}; // 管道统计(帧计时、每秒快照)
@@ -176,7 +177,23 @@ impl StatePortal {
match self.stage { match self.stage {
PortalStage::WaitingForFormat => { 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!( tracing::info!(
"First DMA-BUF frame: {}x{} format=0x{:08X} stride={} modifier=0x{:X}", "First DMA-BUF frame: {}x{} format=0x{:08X} stride={} modifier=0x{:X}",
frame.width, frame.width,
@@ -494,6 +511,15 @@ impl StatePortal {
/// 通过 `av_hwframe_map` 零拷贝导入 VAAPI,然后交给 SwEncState 完成: /// 通过 `av_hwframe_map` 零拷贝导入 VAAPI,然后交给 SwEncState 完成:
/// scale_vaapi GPU 缩放、2K NV12 回读、YUV420P 格式转换、软件 H.264 编码。 /// scale_vaapi GPU 缩放、2K NV12 回读、YUV420P 格式转换、软件 H.264 编码。
fn handle_pw_frame(&mut self, frame: PwDmaBufFrame) -> Result<()> { 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 t_import_start = Instant::now();
let pts = self.frames_encoded as i64; let pts = self.frames_encoded as i64;
@@ -652,7 +678,7 @@ fn encode_thread_loop(
match input_rx.recv() { match input_rx.recv() {
Ok(frame) => { Ok(frame) => {
match encode.encode_cpu_frame(&frame) { match encode.encode_cpu_frame(&frame) {
Ok(()) => { Ok(EncodeOutcome::Encoded) => {
let t = encode.take_timing(); let t = encode.take_timing();
let _ = timing_tx.try_send(EncodeThreadTiming { let _ = timing_tx.try_send(EncodeThreadTiming {
sws_us: t.sws_us, sws_us: t.sws_us,
@@ -660,6 +686,11 @@ fn encode_thread_loop(
output_bytes: t.output_bytes, 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) => { Err(e) => {
tracing::error!("Encode thread error: {e}"); tracing::error!("Encode thread error: {e}");
break; break;