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
+34 -3
View File
@@ -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;