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;