diff --git a/src/avhw.rs b/src/avhw.rs index 1c5b3d8..059caa8 100644 --- a/src/avhw.rs +++ b/src/avhw.rs @@ -1026,6 +1026,12 @@ const FNV1A_OFFSET_BASIS: u64 = 0xcbf29ce484222325; const FNV1A_PRIME: u64 = 0x100000001b3; const Y_PLANE_HASH_ROW_STEP: usize = 8; +/// WebRTC media clock frequency in Hz. Matches RTP clock for video (RFC 3551). +/// Used as encoder time_base denominator for WebRTC mode (1/90000) so that +/// PTS values directly become RTP timestamps with microsecond precision. +/// MP4 mode keeps 1/fps time_base for file output simplicity. +pub const WEBRTC_RTP_CLOCK_HZ: i128 = 90_000; + fn hash_sampled_y_plane(y_data: &[u8], width: usize, height: usize, stride: usize) -> u64 { let mut hash = FNV1A_OFFSET_BASIS; @@ -1846,7 +1852,15 @@ fn create_software_h264_encoder( enc.set_format(ff::format::Pixel::YUV420P); enc.set_bit_rate(bitrate as usize); enc.set_gop(gop_size); - enc.set_time_base(ff::Rational::new(1, fps as i32)); + // 90kHz media clock matches RTP directly. Eliminates 1/fps quantization + // that previously caused sequential RTP timestamps during 60fps capture, + // leading to 2x RTP time inflation and 10s+ browser jitter buffer growth. + // See issue #25. + enc.set_time_base(ff::Rational::new(1, 90_000)); + // Explicit framerate is REQUIRED when time_base is not 1/fps, otherwise + // libx264 infers wrong fps from the 90kHz time_base and VBV rate control + // breaks. Per Oracle review round for #25. + enc.set_frame_rate(Some(ff::Rational::new(fps as i32, 1))); enc.set_max_b_frames(0); if codec_name == "libx264" { diff --git a/src/state.rs b/src/state.rs index 352f4f7..7d1d1a8 100644 --- a/src/state.rs +++ b/src/state.rs @@ -602,9 +602,10 @@ impl State { return; } }; - let fps = self.args.fps as i64; - // PTS in frame-number units (encoder time_base = 1/fps) - let pts = (tv_sec as i64) * fps + (tv_usec as i64) * fps / 1_000_000; + let _fps = self.args.fps as i64; + // PTS in 90kHz media-clock ticks (WebRTC encoder time_base = 1/90000). + // Must match Portal path's compute_capture_pts unit. See issue #25. + let pts = (tv_sec as i64) * 90_000 + (tv_usec as i64) * 90_000 / 1_000_000; surface.set_pts(Some(pts)); drop(buffer); let cap = match &mut self.stage { @@ -707,7 +708,7 @@ impl State { } count += 1; if let Err(e) = wrtc - .write_h264_frame(&enc_frame.data, enc_frame.pts_ticks, self.args.fps) + .write_h264_frame(&enc_frame.data, enc_frame.pts_ticks) { tracing::debug!("WebRTC write frame error: {e}"); } diff --git a/src/state_portal.rs b/src/state_portal.rs index e895ef6..26ca3a5 100644 --- a/src/state_portal.rs +++ b/src/state_portal.rs @@ -553,7 +553,7 @@ impl StatePortal { Ok(()) } - /// Compute PTS in encoder time_base units (1/fps) from PipeWire's nanosecond + /// Compute PTS in 90kHz media-clock ticks from PipeWire's nanosecond /// capture timestamp. Falls back to `Instant`-based elapsed time when PipeWire /// does not provide PTS. Maintains strict monotonicity (encoder requirement). fn compute_capture_pts(&mut self, pw_pts_ns: i64) -> i64 { @@ -578,7 +578,8 @@ impl StatePortal { self.first_pts_ns = Some(raw_ns); 0 }; - let ticks_i128 = (relative_ns.saturating_mul(i128::from(self.args.fps))) / NS_PER_SEC; + let ticks_i128 = + (relative_ns.saturating_mul(crate::avhw::WEBRTC_RTP_CLOCK_HZ)) / NS_PER_SEC; let computed_pts = i64::try_from(ticks_i128).unwrap_or(i64::MAX); let mut pts = computed_pts; @@ -791,7 +792,7 @@ fn webrtc_thread_loop( if connected { while let Ok(enc_frame) = webrtc_rx.try_recv() { - if let Err(e) = wrtc.write_h264_frame(&enc_frame.data, enc_frame.pts_ticks, fps) { + if let Err(e) = wrtc.write_h264_frame(&enc_frame.data, enc_frame.pts_ticks) { tracing::debug!("WebRTC write frame error: {e}"); } frames_sent = frames_sent.saturating_add(1); @@ -808,7 +809,7 @@ fn webrtc_thread_loop( match webrtc_rx.recv_timeout(timeout) { Ok(enc_frame) => { if wrtc.is_connected() { - if let Err(e) = wrtc.write_h264_frame(&enc_frame.data, enc_frame.pts_ticks, fps) + if let Err(e) = wrtc.write_h264_frame(&enc_frame.data, enc_frame.pts_ticks) { tracing::debug!("WebRTC write frame error: {e}"); } diff --git a/src/webrtc.rs b/src/webrtc.rs index 36ee2e9..893f015 100644 --- a/src/webrtc.rs +++ b/src/webrtc.rs @@ -334,9 +334,9 @@ impl WebRtcState { self.poll_rtc() } - pub fn write_h264_frame(&mut self, data: &[u8], pts_ticks: i64, fps: u32) -> Result<()> { + pub fn write_h264_frame(&mut self, data: &[u8], pts_ticks: i64) -> Result<()> { let should_destroy = if let Some(inner) = self.inner.as_mut() { - inner.write_h264_frame(data, pts_ticks, fps)? + inner.write_h264_frame(data, pts_ticks)? } else { false }; @@ -655,7 +655,7 @@ impl WebRtcInner { Ok(()) } - fn write_h264_frame(&mut self, data: &[u8], pts_ticks: i64, fps: u32) -> Result { + fn write_h264_frame(&mut self, data: &[u8], pts_ticks: i64) -> Result { if !self.connected { return Ok(false); } @@ -690,9 +690,9 @@ impl WebRtcInner { self.need_keyframe = false; } - let rtp_timestamp = rtp_timestamp_from_pts_ticks(pts_ticks, fps); - self.rtp_clock = rtp_timestamp; - let rtp_time = MediaTime::new(rtp_timestamp as u64, Frequency::NINETY_KHZ); + let rtp_timestamp = rtp_timestamp_from_pts_ticks(pts_ticks); + self.rtp_clock = rtp_timestamp as u32; + let rtp_time = MediaTime::new(rtp_timestamp, Frequency::NINETY_KHZ); let writer = match self.rtc.writer(mid) { Some(w) => w, @@ -722,17 +722,13 @@ impl WebRtcInner { } } -/// Convert PTS in encoder time_base units (1/fps) to RTP timestamp (90kHz clock). +/// Convert PTS in 90kHz media-clock ticks to RTP MediaTime ticks (u64). /// -/// Extracted as a pure function for unit testing. Clamps negative pts_ticks to 0 -/// (encoder should never emit negative PTS, but defensive). Saturating multiply -/// to avoid overflow on long sessions. -pub fn rtp_timestamp_from_pts_ticks(pts_ticks: i64, fps: u32) -> u32 { - const TICKS_PER_SECOND: u64 = 90_000; - let fps_safe = (fps.max(1) as u64).max(1); - let pts_u64 = (pts_ticks.max(0) as u64).min(u64::MAX / TICKS_PER_SECOND); - let rtp_ts = pts_u64.saturating_mul(TICKS_PER_SECOND) / fps_safe; - rtp_ts as u32 +/// With WebRTC encoder time_base = 1/90000, pts_ticks ARE RTP timestamps. +/// No fps-based conversion needed. Returned as u64 to feed MediaTime::new +/// without premature 13.25-hour u32 wrap; str0m handles RTP u32 wrap internally. +pub fn rtp_timestamp_from_pts_ticks(pts_ticks: i64) -> u64 { + pts_ticks.max(0) as u64 } // ── 工具函数 ────────────────────────────────────────────────────────────── @@ -882,29 +878,30 @@ mod tests { #[test] fn rtp_timestamp_zero_pts() { - assert_eq!(rtp_timestamp_from_pts_ticks(0, 30), 0); + assert_eq!(rtp_timestamp_from_pts_ticks(0), 0); } #[test] - fn rtp_timestamp_one_frame() { - // 1 frame at 30fps = 33ms = 3000 RTP ticks (90kHz / 30) - assert_eq!(rtp_timestamp_from_pts_ticks(1, 30), 3000); + fn rtp_timestamp_one_frame_at_60fps() { + // 16.7ms at 90kHz = ~1500 ticks. Real time maps directly to ticks now. + assert_eq!(rtp_timestamp_from_pts_ticks(1500), 1500); } #[test] fn rtp_timestamp_one_second() { - // 30 frames at 30fps = 1 second = 90000 RTP ticks - assert_eq!(rtp_timestamp_from_pts_ticks(30, 30), 90000); + // 1 second at 90kHz = 90000 ticks + assert_eq!(rtp_timestamp_from_pts_ticks(90_000), 90_000); } #[test] fn rtp_timestamp_negative_clamps_to_zero() { - assert_eq!(rtp_timestamp_from_pts_ticks(-5, 30), 0); + assert_eq!(rtp_timestamp_from_pts_ticks(-5), 0); } #[test] - fn rtp_timestamp_zero_fps_does_not_panic() { - // fps=0 should clamp to 1 internally, not divide by zero - let _ = rtp_timestamp_from_pts_ticks(100, 0); + fn rtp_timestamp_u64_no_truncation() { + // Value above u32::MAX should NOT truncate when feeding MediaTime + let large = u32::MAX as i64 + 1000; + assert_eq!(rtp_timestamp_from_pts_ticks(large), large as u64); } }