feat: Phase 1 MVP with audit fixes — Wayland screen capture + VAAPI encoding

Phase 1 MVP implementation of wl-webrtc: Wayland screen capture tool
with hardware-accelerated VAAPI H.264 encoding and WebTransport output.

Includes all 9 runtime bug fixes from code audit (fix-audit-issues plan):

CRITICAL:
- C2: h264_metadata BSF with repeat_sps/repeat_pps in encode pipeline
- C4: FpsLimit wired as timing gate in on_copy_complete

HIGH:
- C3+A2: DRM device discovery via dmabuf feedback MainDevice event,
  unified resolve_drm_path() helper (CLI > compositor > auto > fallback)
- H2: Separate physical_size (mm) from mode_size (pixels) in wl_output
- H1+A3: Multi-output warning + named-output-not-found error

MEDIUM:
- M5: tv_sec u32->u64 to avoid Y2106 timestamp truncation
- M4: Guard against SHM Buffer event (DMA-BUF only)

Key components:
- src/avhw.rs: FFmpeg VAAPI encoder + filter graph + BSF pipeline
- src/state.rs: Wayland event loop + output negotiation + screencopy
- src/cap_wlr_screencopy.rs: wlr-screencopy capture source
- src/fps_limit.rs: Frame rate limiting with configurable target
- src/transform.rs: Frame format conversion utilities
This commit is contained in:
dailz
2026-04-05 23:35:00 +08:00
commit 6d49222de8
17 changed files with 6964 additions and 0 deletions

77
src/fps_limit.rs Normal file
View File

@@ -0,0 +1,77 @@
use std::time::{Duration, Instant};
pub struct FpsLimit<T> {
on_deck: Option<(T, Instant)>,
min_interval: Duration,
}
impl<T> FpsLimit<T> {
pub fn new(fps: u32) -> Self {
Self {
on_deck: None,
min_interval: Duration::from_secs_f64(1.0 / fps as f64),
}
}
/// Feed a new frame. Returns:
/// - Some(previous_frame) if enough time elapsed since previous frame
/// - None if frame is buffered (first frame) or previous is dropped (too close)
pub fn on_new_frame(&mut self, frame: T, timestamp: Instant) -> Option<T> {
let old = self.on_deck.replace((frame, timestamp));
match old {
None => None, // First frame — buffer it
Some((old_frame, old_ts)) => {
if timestamp.duration_since(old_ts) >= self.min_interval {
Some(old_frame) // Enough time — output previous
} else {
None // Too close — discard previous, keep new
}
}
}
}
/// Flush the last buffered frame at end of stream
pub fn flush(&mut self) -> Option<T> {
self.on_deck.take().map(|(frame, _ts)| frame)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_frame_is_buffered() {
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
let now = Instant::now();
let result = limiter.on_new_frame(1u32, now);
assert!(result.is_none());
}
#[test]
fn frames_too_close_drops_old() {
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
let now = Instant::now();
limiter.on_new_frame(1, now);
let result = limiter.on_new_frame(2, now + Duration::from_millis(1));
assert!(result.is_none());
}
#[test]
fn frames_far_enough_output_old() {
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
let now = Instant::now();
limiter.on_new_frame(1, now);
let result = limiter.on_new_frame(2, now + Duration::from_millis(40));
assert_eq!(result, Some(1));
}
#[test]
fn flush_returns_last_buffered() {
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
let now = Instant::now();
limiter.on_new_frame(1, now);
assert_eq!(limiter.flush(), Some(1));
assert_eq!(limiter.flush(), None);
}
}