Compare commits

...
Author SHA1 Message Date
dailz 74ac8750dc fix(test_portal): 修复 doc list 缩进警告 2026-06-22 19:10:57 +08:00
dailz f44e848c77 fix(safety): 移除新增的中文 // SAFETY: 注释为合规 baseline 不变量 2026-06-22 19:01:42 +08:00
dailz 41093a4f99 docs(avhw): [4/4] 中文注释 SwEncState 与 unsafe impl Send 2026-06-22 18:33:42 +08:00
dailz 052729529e docs(avhw): [3/4] 中文注释软件编码路径 SwEnc* 2026-06-22 18:19:31 +08:00
dailz 98eb72e2a2 docs(vaapi_import_bench): [2/2] 中文注释 CPU/GPU pipeline 与报告 2026-06-22 18:17:22 +08:00
dailz c780d6aeff docs(test_portal): 中文注释 Portal 冒烟测试示例 2026-06-22 18:13:42 +08:00
dailz c68457e0e3 docs(integration_test): 中文注释集成测试 2026-06-22 18:11:15 +08:00
dailz e6b5a5dc36 docs(list_globals): 中文注释 Wayland globals 示例 2026-06-22 18:10:03 +08:00
dailz b2ba34ef04 docs(stats): 中文注释管道性能统计 2026-06-22 18:03:56 +08:00
dailz 2f8197210e docs(webrtc): 中文注释 str0m WebRTC 信令服务器 2026-06-22 17:51:40 +08:00
dailz d94431bd1e docs(sw_encode_bench): 中文注释软编码基准二进制 2026-06-22 17:49:25 +08:00
dailz 895b9aeb32 docs(vaapi_import_bench): [1/2] 中文注释基准 setup 与类型定义 2026-06-22 17:48:59 +08:00
dailz 8460c56bd5 docs(avhw): [2/4] 中文注释 EncState 编码主循环 2026-06-22 17:44:08 +08:00
dailz 1518da4f30 docs(avhw): [1/4] 中文注释 FFmpeg/VAAPI 初始化 2026-06-22 17:34:53 +08:00
dailz 279ec97647 docs(cap_portal): [2/2] 中文注释 PipeWire 帧流与 unsafe FFI 2026-06-22 17:32:01 +08:00
dailz 09123fcd65 docs(state_portal): [2/2] 中文注释 Portal 帧循环与编码调度 2026-06-22 17:28:53 +08:00
dailz f3c0a83a9a docs(state): [3/3] 中文注释 state.rs 帧捕获与输出管理 2026-06-22 17:27:05 +08:00
dailz de93d31c89 docs(fps_limit): 中文注释帧率限制器 2026-06-22 17:26:03 +08:00
dailz f9e59756c3 docs(state): [2/3] 中文注释 state.rs Wayland Dispatch trait 实现 2026-06-22 17:18:54 +08:00
dailz e96af51ee1 docs(cap_portal): [1/2] 中文注释 XDG Portal 授权与 CapPortal 初始化 2026-06-22 17:18:10 +08:00
dailz fffa440e68 docs(state_portal): [1/2] 中文注释 Portal 状态机初始化 2026-06-22 17:18:00 +08:00
dailz 2841d93afa docs(transform): 中文注释图像变换逻辑 2026-06-22 17:15:28 +08:00
dailz 9e2f726491 docs(state): [1/3] 中文注释 state.rs 状态机初始化与类型定义 2026-06-22 17:04:14 +08:00
dailz ef4bd904db docs(backend_detect): 中文注释后端检测逻辑与 ashpd 规避原因 2026-06-22 16:56:25 +08:00
dailz d3016161d1 docs(cap_wlr): 中文注释 wlr-screencopy 协议绑定 2026-06-22 16:52:48 +08:00
dailz 3d314a35aa docs(main): 中文注释 src/main.rs 入口与事件循环 2026-06-22 16:44:20 +08:00
dailz 13b7466c57 docs(args): 中文注释 src/args.rs CLI 参数定义 2026-06-22 16:41:29 +08:00
dailz c12ae6ddcc docs(lib): 中文注释 build.rs 与 src/lib.rs 模块总览 2026-06-22 16:37:11 +08:00
dailz e7accecfec chore: trim verbose docstrings on Portal timeout constants and enum
Cleanup pass after Portal resilience commits (6ccb225, 68a6eec).

Trimmed docstrings that restated information already obvious from the
type/variant names or duplicated elsewhere:

- PORTAL_SERVICE_TIMEOUT: 6 lines -> 3 lines (keep 'why 5s' rationale)
- PORTAL_USER_DIALOG_TIMEOUT: 6 lines -> 2 lines (keep 'why 30s' rationale)
- PortalPhaseTimeout enum: 5 lines -> 1 line (variants are self-documenting)
- Service variant: 2 lines -> 1 line
- TokenDependent variant: 2 lines -> 1 line

Net: -15 lines of comment overhead. No behavior change.

Tests:
- cargo build --release: 0 new warnings (19 baseline preserved)
- cargo test: 97 lib + 3 integration, 0 failed
2026-06-21 11:00:29 +08:00
dailz 68a6eecfbe fix(cap_portal): phased timeouts + token-aware retry for Portal setup
Phase 2 of Portal resilience. When xdg-desktop-portal is stuck,
setup_portal now fails fast with actionable diagnostics instead of
hanging indefinitely. Auto-recovers from stale restore token case.

Phased timeouts (per Oracle review):

  Phase 1: Screencast proxy creation          5s (no user interaction)
  Phase 2: create_session                      5s (no user interaction)
  Phase 3: select_sources                      5s with token / 30s without
  Phase 4: start + response                    5s with token / 30s without
  Phase 5: open_pipe_wire_remote               5s (no user interaction)

Phase 3/4 timeout depends on whether restore token was loaded:
  - With valid token: no permission dialog expected, 5s
  - Without token: user must click Allow in dialog, allow 30s

Token-aware retry (Oracle B'):

On timeout in phase 3 or 4 IF restore token was in use:
  1. Log warning explaining auto-recovery
  2. Delete cached token (~/.cache/wl-webrtc/portal-restore-token)
  3. Retry whole setup_portal once with no_persist=true behavior
  4. On second failure: exit with diagnostic

Retry is whole-flow (new Screencast proxy, new session). Does NOT
reuse half-created objects — Oracle warned this can leak state.

Diagnostic messages:

Service-side timeout (phases 1, 2, 5, or phase 3/4 without token):
  'Portal service did not respond within timeout while <phase>.
   Try: systemctl --user restart xdg-desktop-portal xdg-desktop-portal-kde,
   then re-run wl-webrtc.'

Token-side timeout (phase 3/4 with token, after auto-retry exhausted):
  Same message + ' If this recurs, try: wl-webrtc --no-persist'

Implementation:

- PortalPhaseTimeout enum distinguishes Service vs TokenDependent failures
  (only TokenDependent triggers retry)
- _setup_portal_inner does the actual phased work with timeouts
- setup_portal wraps inner, handles retry on TokenDependent
- log_portal_phase_timeout helper for consistent diagnostics
- delete_restore_token for safe token removal (concurrent-instance safe)

Tests:
- cargo build --release: 0 new warnings (19 baseline preserved)
- cargo test: 97 lib + 3 integration, 0 failed
- All 7 existing token tests pass unchanged
- SAFETY comments preserved verbatim
- 1 file changed, +199/-22 lines

Out of scope (Oracle deferred):
- --doctor diagnostic CLI subcommand
- Runtime watchdog (Portal going bad mid-session)
- systemd auto-restart (disrupts other Portal clients)
- Phased diagnostics for the optional PipeWire first-frame wait

Combined with Phase 1 (backend_detect.rs, commit 6ccb225), Portal
service issues now fail fast with clear recovery instructions instead
of hanging indefinitely.
2026-06-21 10:44:15 +08:00
dailz 6ccb225784 fix(backend_detect): add 5s timeout to Portal availability check
Prevents indefinite hang when xdg-desktop-portal service is stuck.
Previously the check used zbus::Connection::session() with no timeout,
waiting forever for D-Bus responses.

User observed 11+ second delay at startup when Portal service was
wedged, causing 'client can't connect' because wl-webrtc never reached
the WebRTC signaling stage.

Changes per Oracle review (Phase 1 of 2 for Portal resilience):

- Replace Connection::session() with connection::Builder::session()
  + method_timeout(5s) to bound method replies
- Wrap each async operation (connection build, proxy build, version
  query) with tokio::time::timeout(5s) for comprehensive coverage
- Add log_portal_unresponsive() helper with actionable diagnostic:
  'systemctl --user restart xdg-desktop-portal xdg-desktop-portal-kde'
- Return false on timeout (existing behavior) so caller falls through
  to wlr-screencopy detection or fails with clear error

Why both method_timeout AND tokio::time::timeout (per Oracle):
- method_timeout bounds D-Bus method reply waits
- tokio::time::timeout bounds connection/proxy setup and any ashpd
  future composition (relevant for Phase 2)
- Neither alone is sufficient

What this does NOT do (deferred to Phase 2 / cap_portal.rs):
- Token-aware retry logic (Phase 2)
- --no-persist suggestion in diagnostic (Phase 2: only appropriate
  when restore token was actually in use)
- Phased diagnostics for CreateSession/SelectSources/Start operations
- Runtime watchdog

zbus version note: crate uses zbus 5.x with tokio feature only.
Builder::method_timeout() available in zbus 5.x.

Tests:
- cargo build --release: 0 new warnings (19 baseline preserved)
- cargo test: 97 lib + 3 integration, 0 failed
- 1 file changed, +55/-9 lines
2026-06-21 10:37:43 +08:00
dailz 727893fdc2 fix(webrtc): conservative resolution-aware startup bitrate (closes #21)
WebRTC mode now uses tier-based conservative defaults for initial encoder
bitrate instead of the aggressive formula. BWE estimate arrives within
milliseconds of client connect and overrides this; the startup value
only affects the first IDR frame.

Before (both modes used same formula):
  5 * W * H * fps / 100

  1440p@30fps = 5_529_600 bps (5.5 Mbps)
  1440p@60fps = 11_059_200 bps (11 Mbps)
  4K@30fps   = 8_294_400 bps (8.3 Mbps)

After (WebRTC uses conservative tier-based, MP4 keeps formula):

  fn webrtc_startup_bitrate_bps(width, height) -> u64:
    pixels <= 1_000_000  (720p):   1 Mbps
    pixels <= 2_500_000  (1080p):  2 Mbps
    pixels <= 4_500_000  (1440p):  4 Mbps
    else                 (4K+):    8 Mbps

Why this is safe for WebRTC:

1. BWE_INITIAL = 5 Mbps in RtcConfig (webrtc.rs)
2. Client connect triggers BWE estimate within ~10ms
3. Encoder bitrate immediately updated via BitrateCommand::UpdateBitrate
4. First IDR frame is the only output affected by startup value
5. With #23's VBV buffer_size = bitrate/4, first IDR is bounded to ~170KB
   regardless of startup bitrate

Why MP4 keeps the formula:

MP4 mode has no BWE feedback channel. The formula provides reasonable
quality for file output. Users who want specific bitrate can pass --bitrate.

Resolution tiers chosen to match common display resolutions:
  720p (1280x720 =   921_600 pixels)  → 1 Mbps
  1080p (1920x1080 = 2_073_600 pixels) → 2 Mbps
  1440p (2560x1440 = 3_686_400 pixels) → 4 Mbps
  4K (3840x2160 = 8_294_400 pixels)   → 8 Mbps (= --max-bitrate cap)

User-supplied --bitrate flag still takes precedence in both modes.

Tests:
- cargo build --release: 0 new warnings (19 baseline preserved)
- cargo test: 97 lib + 3 integration, 0 failed
- New webrtc_startup_bitrate_tiers_by_pixel_count test covers all 4 tiers
- SAFETY comments preserved verbatim
- 1 file changed
2026-06-21 10:10:19 +08:00
dailz a06a41f5f2 feat(stats): expose duplicate_frames_skipped counter (closes #20)
Final piece of #20. The EncodeOutcome::SkippedDuplicate variant was
introduced in #19 but its count was invisible — silent Ok(_) arm in
encode_thread_loop. Now exposed as a stat.

Changes:

- stats.rs: PipelineStats gains duplicate_frames_skipped (window delta)
  and prev_duplicate_frames_skipped (running total). Snapshot field
  added. Display format places it after over_budget (both are counters).
  Reset clears window delta but preserves running total (same pattern
  as pipewire_dropped).

- state_portal.rs: EncodeThread struct gains duplicate_count:
  Arc<AtomicU64>. Cloned for encode_thread_loop, stored for main-thread
  reads. encode_thread_loop now explicitly matches SkippedDuplicate and
  increments with Ordering::Relaxed. Stats snapshot code reads atomic
  and calls set_duplicate_frames_skipped after timing drain.

What this enables:

Diagnosing encoded_fps health. Examples:
  - capture_fps=60 encoded_fps=30 duplicate_frames_skipped=30
    → healthy: encoder at 30fps target, 30 frames were true duplicates
  - capture_fps=60 encoded_fps=5 duplicate_frames_skipped=0
    → problem: frames not being dedup'd but encoder can't keep up
  - capture_fps=1.7 encoded_fps=1.7 duplicate_frames_skipped=0
    → healthy static: low fps because KWin damage-driven delivery

Original #20 issues status:
  - 'encoded_fps stuck at ~30': FIXED via #19 (EncodeOutcome enum), now
    tracks capture_fps when below 30
  - 'filler masking real fps': FIXED via #15/#18 (filler deleted)
  - 'duplicate count invisible': FIXED via this commit
  - 'unique_encoded_fps / delivered_fps': not implemented, deemed
    unnecessary now that the core metrics are trustworthy

Tests:
- cargo build --release: 0 new warnings (19 baseline preserved)
- cargo test: 96 lib + 3 integration, 0 failed
- SAFETY comments preserved verbatim
- 2 files changed, +45/-6 lines
2026-06-21 10:04:41 +08:00
dailz 631934458c chore: remove obsolete TODO(#19) and dead _fps binding
Cleanup pass after the latency investigation concluded:

- state_portal.rs: remove TODO(#19) comment block (17 lines). The Option A
  upgrade path was tentatively documented during the #19 fix, but the user
  decided to accept current latency behavior. The TODO is now noise.

- state.rs:605: remove 'let _fps = self.args.fps as i64;' dead binding left
  over from #25 time_base change. The variable became unused when PTS formula
  switched from fps-multiplier to literal 90_000, and was renamed to _fps to
  silence the warning. Removing it entirely is cleaner.

No behavior change. Build clean (0 new warnings). All 96+96+3 tests pass.
2026-06-21 09:52:27 +08:00
dailz 46e7a9785d fix(webrtc): bypass str0m LeakyBucketPacer for low-latency LAN streaming
Final piece of the latency puzzle. Server-side frame_age was 6ms but browser
jitterBufferDelay still spiked to 500+ ms during active periods.

Root cause found via librarian investigation of str0m 0.20 source:

  str0m LeakyBucketPacer (active when BWE enabled) limits send rate to
  BWE_estimate * 1.1. For a 100KB IDR frame at 8Mbps pacing, the pacer
  queue adds ~100ms of send-side latency. Multiple frames stack during
  burst encode, causing receiver jitter buffer to grow.

  Video streams are PACED BY DEFAULT in str0m (audio is unpaced).
  Evidence: str0m/src/streams/send.rs:1116 default unpaced logic.

Fix: call stream_tx.set_unpaced(true) on the video stream when media
is added. BWE remains enabled for bitrate adaptation / TWCC feedback,
but the pacer no longer throttles our video egress.

Why this is the right call for wl-webrtc:

- LAN scenario: dedicated link, no competing flows to smooth against
- Encoder cap (8 Mbps) + VBV buffer (250ms) already provide rate control
- The pacer's smoothing benefit (avoid bursts that compete with TCP) is
  irrelevant for our use case
- BWE adaptation still works (we still receive EgressBitrateEstimate events
  and adapt encoder bitrate via BitrateCommand::UpdateBitrate)

Verification expectations:

- Active-period jitterBufferDelay: 500+ ms -> < 200 ms
- 'Stop mouse, client continues 10s' symptom: should disappear entirely
- Server-side frame_age: unchanged (~6ms)
- Bitrate/resolution adaptation: unchanged (still uses BWE)
- MP4 mode: unaffected (different code path)

Tests:
- cargo build --release: 0 new warnings (19 baseline preserved)
- cargo test: 96 lib + 3 integration, 0 failed
- SAFETY comments preserved verbatim
- 1 file changed, 7 insertions(+), 1 deletion(-)

Closes the latency investigation started in #23 / #24 / #25.
2026-06-21 09:10:28 +08:00
dailz b4b9990efe feat(stats): populate frame_age metric for WebRTC path (partial #20)
Add quantitative capture-to-send latency measurement so we can diagnose
remaining latency sources after #24/#25 PTS fixes.

Previously frame_age_p95 was always 0.0ms because the WebRTC code path
never propagated capture timestamps, even though stats.rs already
supported the metric. The infrastructure existed but was disconnected.

Changes:

- avhw.rs: Add capture_time: Instant field to CpuNv12Frame (set when
  PipeWire delivers frame) and EncodedH264Frame (propagated through
  encode thread via new last_capture_time side-channel on SwEncEncode).

- state_portal.rs: Change sent_gap channel type from Sender<f64> to
  Sender<(f64, Option<f64>)> so WebRTC thread can send pre-computed
  age_ms = capture_time.elapsed() at the exact send moment (not at
  stats drain time, which would inflate the measurement by ~1s).

- stats.rs: record_send_from_thread now accepts Option<f64> age_ms
  and pushes to frame_age_ms Vec when Some.

After this commit:
- stats: log lines show real frame_age_p95 / frame_age_max in ms
- Expected range: 5-30ms (import + scale + encode + channel send)
- If much higher: server pipeline has queueing issue
- If low but user still sees latency: confirms bottleneck is network
  or browser-side (jitter buffer, decode queue)

Scope notes:

- Only Portal/PipeWire path is instrumented. wlr-screencopy path uses
  different code path (EncState, not SwEncState) and will continue to
  report frame_age=0.0ms. Adding wlr instrumentation is separate scope.

- This is diagnostic only — does NOT change user-visible behavior.
  No encoding, sending, or stats output format changes.

Tests:
- cargo build --release: 0 new warnings (19 baseline preserved)
- cargo test: 96 lib + 3 integration, 0 failed
- SAFETY comments preserved verbatim
- 3 files changed, +39/-10 lines

Refs #20.
2026-06-20 23:20:36 +08:00
dailz ad28af6ff3 fix(webrtc): switch encoder time_base to 90kHz to stop RTP time inflation (closes #25)
Root cause:

After #24 fixed PTS propagation, browser jitter buffer still accumulated
to 10+ seconds during active mouse movement. User reported: stop moving
mouse, client continues showing motion for ~10 seconds.

The encoder time_base was 1/fps (33ms granularity at 30fps). When KWin
delivers frames at 60fps (16.7ms apart), compute_capture_pts integer
math mapped multiple captures to the same tick. The monotonicity guard
then bumped them to sequential ticks (0, 1, 2, 3, ...).

Result: 60 captures in 1 real second produced 60 sequential RTP
timestamps spanning 60 * 33ms = 1.98 seconds of RTP time. Browser
played at RTP rate (half real speed), buffer accumulated.

Math verification from test8 log:
- 2067 frames * 3000 RTP jumps = 68s of active RTP time
- 61 frames * 54000 RTP jumps = 37s of static RTP time
- Total RTP time 105s vs real time 98.6s (7% inflation in short session;
  long active sessions amplify to 50%+ inflation matching user-reported
  10-second trailing).

Fix (per Oracle round review):

Change WebRTC encoder time_base from 1/fps to 1/90000 (90kHz). This
matches the RTP video clock directly, providing 11us PTS granularity.
Captures 16.7ms apart now produce distinct ticks (~1500 each), no
quantization, RTP timestamps accurately reflect real time.

Oracle-required revisions incorporated:

1. **set_frame_rate alongside time_base** — libx264 infers fps from
   time_base when not explicit. With 1/90000 time_base and no explicit
   framerate, x264 would assume ~90000fps and VBV rate control would
   break. Setting framerate=fps/1 preserves real frame semantics while
   using 90kHz PTS precision.

2. **rtp_timestamp_from_pts_ticks returns u64 not u32** — MediaTime::new
   takes u64. Returning u32 would truncate at 13.25 hours and create
   backwards MediaTime. str0m handles RTP u32 wrap internally; we feed
   it full u64.

3. **wlr-screencopy path also updated** — state.rs:605 used fps-based
   PTS formula. Changed to 90kHz ticks so wlr path matches Portal path
   unit. Without this, wlr-screencopy users would have wrong PTS after
   the time_base change.

4. **MP4 path (create_software_h264_muxer) UNCHANGED** — verified at
   avhw.rs:1693-1789, keeps 1/fps time_base, no set_frame_rate added.
   File output doesn't need real-time PTS.

Implementation:

- src/avhw.rs: WEBRTC_RTP_CLOCK_HZ=90_000 const; create_software_h264_encoder
  uses 1/90000 time_base + explicit framerate
- src/state_portal.rs: compute_capture_pts uses WEBRTC_RTP_CLOCK_HZ for
  tick conversion (was fps multiplier)
- src/state.rs: wlr PTS formula uses 90_000 (was fps multiplier)
- src/webrtc.rs: rtp_timestamp_from_pts_ticks simplified to identity
  function (pts_ticks.max(0) as u64), drop fps parameter; write_h264_frame
  signature drops fps (was only used for rtp conversion); 5 unit tests
  updated to assert 90kHz identity (0->0, 1500->1500, 90000->90000)

Verification expectations:

- Active period jitterBufferDelay: 1000+ ms -> < 100 ms
- 'Stop mouse, client continues 10s' symptom: should disappear
- Static period behavior: unchanged (was already correct)
- MP4 file output: unchanged
- VBV-constrained IDR sizes: unchanged (framerate explicit preserves
  rate control semantics)
- All prior fixes (#19, #23, #15, #18, #24) preserved

Out of scope (Oracle noted, not blocking):

- build_swenc_filter_graph still uses 1/fps time_base at avhw.rs:1603/1620
  (semantic mismatch but no functional impact since scale_vaapi passes
  PTS integers through)
- Runtime VBV update on bitrate change (separate pre-existing issue)

Tests:
- cargo build --release: 0 new warnings (23 baseline preserved)
- cargo test: 96 lib + 3 integration, 0 failed
- 5 rtp_timestamp_* tests updated for 90kHz identity
- SAFETY comments preserved verbatim
- 4 files changed, +48/-35 lines
2026-06-20 22:59:43 +08:00
dailz 1e792f191c fix(state_portal): use webrtc_thread.is_some() for PTS mode gating (really fixes #24)
Previous commit 079611a claimed to fix #24 but the gating condition was
wrong, making the entire fix dead code:

  src/state_portal.rs:467
  - let pts = if self.webrtc.is_some() {        // ALWAYS false here
  + let pts = if self.webrtc_thread.is_some() {  // correct lifecycle check

Why self.webrtc was wrong:

  WebRtcState lifecycle in Portal path:
  1. StatePortal::new() sets self.webrtc = Some(...) if args.port > 0
  2. First frame arrives -> WaitingForFormat branch
  3. state_portal.rs:274 does self.webrtc.take() and moves WebRtcState
     into the webrtc thread
  4. Subsequent frames -> Streaming branch -> handle_pw_frame
  5. By this point self.webrtc is None

So my gating check 'if self.webrtc.is_some()' at handle_pw_frame ALWAYS
returned false, and compute_capture_pts was NEVER called. Confirmed by
debug instrumentation showing 0 invocations across a 174s WebRTC session.

Net effect: #24's PTS fix was completely inert. RTP timestamps were
still computed from sequential frame counter (old broken behavior).
User reports of 'latency got worse' were due to other test conditions,
not the dead code.

The correct check is self.webrtc_thread.is_some() because:
- webrtc_thread is set AFTER WebRtcState is moved into it (line 301)
- webrtc_thread stays Some for the entire WebRTC session
- webrtc_thread is None for MP4 mode (no thread spawned)

So this check correctly distinguishes WebRTC mode from MP4 mode at the
point where PTS is computed for each frame in handle_pw_frame.

Lesson learned:
- Oracle review (rounds 1 and 2) verified the design and code structure
  but did not catch the lifecycle issue because they reasoned about the
  code statically.
- Runtime verification via debug instrumentation was needed to confirm
  the function was never called.
- This is why the user ran the test BEFORE I committed - their feedback
  that latency got worse was the canary that exposed the dead code.

Verification plan (next user test):
- Run with --stats and confirm rtp= field in write_h264 debug logs
  shows VARIABLE jumps (not uniform 3000 increments)
- During static periods (capture_fps < 5), rtp should jump by 30000+
- During active periods (capture_fps > 30), rtp increments may still
  look sequential due to 1/fps time_base quantization (acceptable)
- Browser jitter buffer should stabilize at < 500ms
2026-06-20 22:15:50 +08:00
dailz 079611acfc fix(webrtc): propagate real capture PTS through WebRTC channel (closes #24)
Root cause (3-stage bug found via Oracle round 1+2 review):

Browser WebRTC clients accumulated 2-3 seconds jitter buffer under
damage-driven variable frame rate (KWin Portal/PipeWire). User moved
mouse, saw action 2-3 seconds later on client.

Three coordinated bugs formed a chain that defeated any single-point fix:

1. state_portal.rs:455 used sequential frame counter as PTS instead of
   real capture time. (Portal path only — wlr-screencopy already correct.)

2. avhw.rs Channel output sent only Vec<u8>, DISCARDING AVPacket PTS.
   Even with correct encoder PTS, timing metadata was thrown away.

3. webrtc.rs:695 computed RTP timestamp as 'frame_number * 90000 / fps'
   from a counter, ignoring any real PTS. Browser saw uniform 33ms RTP
   spacing regardless of actual 1.6-57fps variable delivery, growing
   jitter buffer to compensate for perceived 'network jitter'.

Fix (7-step ordered implementation per Oracle round 2):

1. EncodedH264Frame struct in avhw.rs carries data + pts_ticks
2. FrameOutput::Channel type changed from Sender<Vec<u8>> to
   Sender<EncodedH264Frame>; both new_webrtc signatures updated
3. Channel drain in avhw.rs extracts pkt.pts(), normalizes via
   'p - start_ts' (mirrors existing Muxer branch logic). Drops
   packets with missing PTS instead of silently emitting zero.
4. write_h264_frame signature: frame_number:u64 -> pts_ticks:i64
   (both WebRtcState and WebRtcInner layers). Extracted pure function
   rtp_timestamp_from_pts_ticks(pts_ticks, fps) with 5 unit tests
   covering zero, one-frame, one-second, negative clamp, fps=0.
5. state_portal.rs receiver loop consumes EncodedH264Frame, passes
   .data and .pts_ticks to write_h264_frame.
6. state.rs (wlr-screencopy) receiver loop updated for compile
   compatibility — its existing real-time PTS computation at state.rs:606
   was already correct, now properly propagates through new channel type.
7. state_portal.rs:467 PTS computation GATED on output mode:
   - WebRTC branch: compute_capture_pts() uses PipeWire's ns timestamp
     (or Instant fallback), normalizes to first-frame-origin, converts
     to encoder time_base units with i128 intermediate math, enforces
     monotonicity via safe checked_add pattern.
   - MP4 branch: KEEPS self.frames_encoded as i64 (sequential counter).
     File output does not need real-time PTS; changing it would alter
     file playback speed during static periods.

Oracle round 2 critical revisions incorporated:

- Single-point PTS normalization (only in avhw Channel drain), NOT at
  source. Avoids double-subtraction with existing Muxer logic.
- MP4 path explicitly preserved — real PTS only applied to WebRTC branch.
- Safe Rust monotonicity guard (no unsafe pointer tricks).
- i64::try_from(ticks_i128) instead of broken i128::try_from(...).unwrap_or(i64::MAX).
- pkt.pts() missing -> log + drop, not silent unwrap_or(0).
- Updates span 4 files (avhw.rs, webrtc.rs, state_portal.rs, state.rs)
  because channel type change ripples through both Portal and wlr paths.

Verification expectations:

- Browser jitter buffer should stabilize at 100-500ms (typical) instead
  of growing to 2-3 seconds under damage-driven delivery.
- chrome://webrtc-internals: jitterBufferDelay / jitterBufferEmittedCount
  ratio should drop significantly.
- Server-side metrics (output_bps, frame rate, IDR size) unchanged.
- MP4 file output (--output mode) behavior unchanged.

Out of scope (deferred):

- frame_age metric fix (Oracle: separate commit to isolate behavioral
  change from observability change)
- VFR encoder redesign (1/fps time_base sufficient for this fix)
- MP4 VFR recording (semantic change, separate decision)
- Existing client jitter buffers may not auto-shrink; reconnect may be
  required for users with already-accumulated latency

Tests:
- cargo build --release: clean, 0 new warnings (19 pre-existing)
- cargo test: 96 lib + 96 bin + 3 integration, 0 failed
- 5 new RTP unit tests covering edge cases
- SAFETY comments preserved verbatim
- 4 files changed, +157/-27 lines
2026-06-20 21:53:27 +08:00
dailz 2f0b858920 fix(state_portal): remove filler + accept Wayland damage-driven delivery (fixes #15, fixes #18)
Paradigm shift: stop treating KWin's damage-driven frame delivery as an
anomaly. Static content = no new frames is correct Wayland behavior.

Root causes (combined #15 + #18):

#15: stall detection threshold was 100ms (max(100ms, 3*frame_interval)).
KWin/PipeWire damage-driven delivery meant normal static periods triggered
WARN 'compositor frame delivery stalled' continuously. Test3 data showed
55% stall rate during active streaming (38 stalls in 69s). User perceived
severe stutter pattern 'cardboard-effect freeze-release-freeze'.

#18: filler mechanism (maybe_send_filler_frame) cloned 3MB NV12 data and
sent to encode thread during stalls. Encode thread hashed Y plane, found
duplicate, skipped via dedup. Net: wasted CPU + channel bandwidth with
zero visual benefit (the dedup path was already catching it).

Oracle review revealed the two issues are causally linked: filler is the
failed response to the false stall alarm. Removing both together is correct.

Fixes (all in state_portal.rs):

1. Remove filler mechanism entirely:
   - Remove fields: last_fillable_frame, next_filler_at, filler_frames_sent
   - Remove method: maybe_send_filler_frame (49 lines)
   - Remove constant: MAX_FILLER_DURATION
   - Remove fillable_frame clone cascade in handle_pw_frame (10 lines
     of 3MB NV12 cloning per frame, the largest CPU/memory win)
   - Remove filler_frames_sent from stats output

2. Redefine stall as idle (Oracle-revised):
   - Rename: stall_start -> idle_log_start (semantic clarity)
   - Change threshold: 100ms -> 5s (CAPTURE_IDLE_LOG_THRESHOLD)
   - Change log level: WARN -> DEBUG
   - Change wording: 'compositor frame delivery stalled' ->
     'portal capture idle; no damage frames received (normal Wayland behavior)'
   - One-shot log per idle episode (not repeated every second)
   - Use last_capture_arrival as idle start for accurate elapsed duration
     (old code set stall_start=now at first detection, undercounting by
     threshold value)

Explicit product decision (Oracle flagged trade-off):

  Static-content PLI repair is deferred. When WebRTC client sends PLI
  during static content:
  - Server sets force_keyframe_pending in encode thread
  - Encode thread blocks on input_rx.recv() (no frames coming)
  - Client may send more PLIs (all rate-limited by #23 to 1/sec)
  - When user interacts -> KWin delivers frame -> encode thread produces IDR

  This means during fully static content, client may wait for next damage
  to receive keyframe. Acceptable because static content is by definition
  unchanged - the last received frame is still visually accurate. If user
  reports unacceptable PLI latency on static screens, follow-up with
  event-driven one-shot IDR mechanism (Option B per Oracle).

Verification expectation:
  - Zero WARN 'stalled' messages during normal session
  - Optional DEBUG 'portal capture idle' after 5s of no frames
  - Optional DEBUG 'portal capture resumed after idle period' on recovery
  - capture_fps will still vary with content activity (this is correct)
  - encoded_fps will only count real frames (filler no longer inflates it)

Out of scope:
  - Event-driven cached-frame IDR (Option B): follow-up if needed
  - PipeWire CursorFromCache negotiation: separate enhancement
  - capture_fps expectations documentation: defer to #20 stats rework

Tests:
  - cargo build --release: clean, no new warnings
  - cargo test: 91 passed + 3 passed + 0 failed
  - SAFETY comments preserved verbatim
  - Net change: -80 lines (20 insertions, 100 deletions)

Closes #15, closes #18.
2026-06-20 20:56:34 +08:00
dailz 9a522e2f99 fix(webrtc): PLI rate limiting + bitrate cap + VBV units (closes #23)
Root causes (3, discovered via Oracle review + x264 source verification):

A. PLI storm: WebRtcInner.force_keyframe_to_encode is a single bool with
   no rate limit. Client PLI storms (5 PLIs in 845ms observed) produced
   5 back-to-back IDRs (~1.5MB burst), swamping the network.

B. Bitrate runaway: BWE feedback had no upper bound. Observed bitrate
   escalating from 5 Mbps to 9.9 Mbps in 12 seconds, all applied to
   encoder. Combined with A, made each IDR grow 4-5x.

C. VBV effectively disabled (NEW finding not in original issue):
   x264's vbv-maxrate and vbv-bufsize x264opts expect kbit/s and kbit
   (confirmed via x264 source ratecontrol.c:658-661 which multiplies by
   1000 at use site). The code passed bps, making VBV interpret 5.5 Mbps
   as 5.5 Gbps (clipped to 2 Gbps). Buffer was 173MB instead of 172KB,
   so VBV never constrained anything. This is why IDRs could balloon to
   336KB after runtime bitrate increases.

Fixes (3, all in this commit per Oracle review):

Fix 0 (avhw.rs): divide bitrate by 1000 when formatting x264opts string.
  Updated existing vbv_x264opts_format and vbv_bufsize_is_quarter_of_maxrate
  tests to assert correct kbit/s values. Old tests passed but asserted
  wrong values - classic 'tests covered the wrong implementation'.

Fix 1 (webrtc.rs): split keyframe trigger into two paths.
  - set_need_keyframe() (internal: connect, resolution change) remains
    unthrottled but updates last_forced_keyframe_at timestamp.
  - request_keyframe_from_viewer() (external PLI) rate-limited to
    Duration::from_secs(1), checked against last_forced_keyframe_at.
  - Key insight from Oracle: track ALL keyframe production time, not
    just PLI time, to prevent 'connect -> immediate PLI -> duplicate IDR'.

Fix 2 (args.rs + state_portal.rs + avhw.rs):
  - New --max-bitrate CLI flag, default 8 Mbps.
  - Primary clamp in webrtc_thread_loop (policy layer): clamp BWE via
    variable shadowing so all downstream code (bitrate_tx, resolution
    adaptation) uses clamped value.
  - Defensive guardrail in encode_cpu_frame UpdateBitrate handler:
    50 Mbps hard ceiling in case future callers bypass policy layer.
  - Per Oracle: flat 8 Mbps default, no auto-scaling
    (max(8M, 2*initial) would have failed the observed case).

Verification (82.1s session, 34.7 fps avg vs previous 18.2):

PLI storm absorption:
  - 15 PLIs received from viewer
  - 10 PLIs throttled (67%)
  - 6 IDRs produced total (1 connect + 5 honored)
  - 3 distinct PLI storms (625ms, 640ms, 858ms duration) all absorbed

VBV constraint working:
  - First IDR: 65KB (was 67KB)
  - Largest IDR: 169KB (was 336KB)
  - All IDRs under VBV buffer bound of 172KB
  - No more runaway IDR growth

Bitrate cap working:
  - 5 bitrate updates applied (was 8)
  - Peak bitrate: 7.4 Mbps (was 9.9 Mbps)
  - 52952 BWE readings clamped (>8 Mbps filtered out)

Overall quality:
  - Frame rate: 34.7 fps (was 18.2, 1.9x improvement, exceeds 30 fps target)
  - Average bitrate: 4451 kb/s (was 5616, lower and more stable)

Out of scope (deferred to future work):
  - Runtime VBV reconfiguration on bitrate change (Fix 3): encoder
    recreation is expensive, observe whether VBV mismatch becomes a
    quality issue after cap is in place
  - Asymmetric BWE filter (Fix 4): rise=15%/fall=5% thresholds; nice
    tuning but cap is sufficient for now
  - Compositor stalls (#15): still causes some stutter at session end
    but no longer compounds into latency

Tests:
  - cargo test: 91 passed + 3 passed + 0 failed
  - vbv_x264opts_format and vbv_bufsize_is_quarter_of_maxrate updated
    and passing with new kbit/s assertions
  - SAFETY comments preserved verbatim
  - cargo build --release: clean, no new warnings
2026-06-20 20:36:44 +08:00
dailz f38adf70f9 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
2026-06-20 19:57:15 +08:00
19 changed files with 3839 additions and 202 deletions
+5
View File
@@ -1 +1,6 @@
//! Cargo build script(编译前钩子)。
//!
//! 当前为空:本项目直接复用 `wayland-client`、`pipewire`、`ffmpeg-sys` 等现成 crate
//! 不需要在编译前跑 wayland-scanner 或 bindgen 生成代码。类比 Go 无 `//go:generate`。
// Cargo 编译前不需要生成任何代码(无 wayland-scanner / bindgen),因此 build script 留空。
fn main() {}
+47
View File
@@ -1,11 +1,43 @@
//! 列出当前 Wayland 桌面广播的全部全局对象(registry globals)。
//!
//! Wayland 协议采用"客户端发现"模型:客户端连接到 compositor 后,第一件事是从
//! registry 中枚举所有被广播的 interface(如 `wl_compositor`、`wl_shm`、
//! `zwlr_screencopy_manager_v1`、`zxdg_portal_screencast` 等),每个 global 带有
//! 唯一数字 name、interface 名字符串、最高支持版本号。本示例即打印这三元组。
//!
//! 类比 Go 的 `xcursor` / wayland-client 示例:用最小可运行代码确认运行环境。
//!
//! 运行(参见 AGENTS.md "Useful manual commands"):
//! ```sh
//! cargo run --example list_globals
//! ```
//!
//! 该示例也是 `src/backend_detect.rs` 中检测 `zwlr_screencopy_manager_v1` 是否存在的
//! 同款机制(参见 `check_screencopy_available`),用于决定走 wlr-screencopy 还是 Portal。
// 引入 wayland-client 的快捷初始化辅助函数:内部完成 connect + registry bind + 同步枚举。
use wayland_client::globals::registry_queue_init;
// GlobalListContents 是 registry_queue_init 返回的"已收集好的 global 列表"句柄类型。
use wayland_client::globals::GlobalListContents;
// WlRegistry 是 Wayland 协议对象;Event 是其产生的枚举事件(global/global_remove)。
use wayland_client::protocol::wl_registry::{Event, WlRegistry};
// Connection 表示与 compositor 的 socket 连接;QueueHandle 是事件队列句柄;
// Dispatch 是 trait,用户必须为关心的协议对象实现它以接收事件回调。
use wayland_client::{Connection, Dispatch, QueueHandle};
// 示例用的极简 state:无字段。Wayland 客户端需要至少一个 state 类型作为
// Dispatch trait 的 `Self`,这里就用零大小类型 `Ls`list globals 的缩写)。
struct Ls;
// 为 Ls 实现 WlRegistry 的 Dispatch:本示例只需枚举 globals,不需要响应任何
// registry 事件,因此 event 函数留空。wayland-client 要求即便不处理事件也必须
// 实现 Dispatchtrait contract 强制),否则 `registry_queue_init::<Ls>` 无法编译。
//
// Go 类比:类似 `type Ls struct{}` + `func (Ls) HandleEvent(...) {}`——
// 显式声明"我接收事件但不响应"。
impl Dispatch<WlRegistry, GlobalListContents> for Ls {
// 所有参数加 `_` 前缀表示本实现不读取任何参数(Rust 中 `_x` 与 `x` 区分:
// 前者显式标记未使用,避免 dead_code 警告)。
fn event(
_state: &mut Self,
_registry: &WlRegistry,
@@ -17,10 +49,25 @@ impl Dispatch<WlRegistry, GlobalListContents> for Ls {
}
}
// 程序入口。Rust 的 `fn main()` 不能返回 `Result`(标准约定),故用 `.unwrap()`
// 简单 panic;示例程序通常省略错误处理以突出主线逻辑。
fn main() {
// 从 `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` 环境变量建立与 compositor 的 socket 连接。
// 类比 Go 的 `net.Dial("unix", path)`。`.unwrap()` 在连接失败时 panic(示例代码约定)。
let conn = Connection::connect_to_env().unwrap();
// registry_queue_init 是 wayland-client 的高层辅助:内部发送 sync request 并阻塞
// 直到 registry 全部 global 事件到达。返回 (GlobalList, EventQueue)。
// `::<Ls>` 是 turbofish 显式指定 state 类型,对应上面 `impl Dispatch for Ls`。
// 类比 Go 的 `globals, queue := wayland.RegistryQueueInit[Ls](conn)`(泛型实例化)。
let (globals, _queue) = registry_queue_init::<Ls>(&conn).unwrap();
// 遍历所有已收集的 globals。`globals.contents()` 返回内部快照引用,
// `.clone_list()` 复制成 `Vec<GlobalListEntry>`(每个 entry 含 name/interface/version)。
// 类比 Go `for _, g := range globals { ... }`——Rust 的 `for ... in` 直接消费迭代器。
for g in globals.contents().clone_list() {
// `println!` 是 Rust 标准宏(不是函数),类比 Go `fmt.Printf("%d: %s v%d\n", ...)`。
// `{}` 自动调用参数的 `Display` traitname 是 u32、interface 是 String、version 是 u32。
println!("{}: {} v{}", g.name, g.interface, g.version);
}
}
+45
View File
@@ -1,11 +1,41 @@
//! XDG Portal 权限冒烟测试示例。
//!
//! 本示例演示完整的 Portal ScreenCast 授权流程,分四步:
//! 创建 Screencast proxy,再创建 session,然后选择源(显示器/窗口),
//! 最后 start() 触发系统授权对话框(用户点击"共享"后返回流信息)。
//!
//! 运行:`cargo run --example test_portal`(参见 AGENTS.md "Useful manual commands")。
//!
//! Rust 异步模型(与 Go 对比):
//! - Go 用 goroutine + channelasync 函数本身**惰性**,需 runtime 驱动。
//! - 本示例刻意**不用** `#[tokio::main]` 宏,而是手动 `Runtime::new()` + `block_on`
//! (与 src/backend_detect.rs 同款"手动 runtime"模式);这是因为 ashpd 内部缓存
//! zbus::Connection 到全局 OnceLock,若宏自动建的 runtime 被 drop
//! 缓存的 connection 会"僵尸化"导致后续 hang(详见 AGENTS.md)。
//!
//! 对照 Go`go func() { ... }()` ≈ `tokio::spawn(async { ... })`
//! 而 `block_on` 类似 Go 的 `select {}` 阻塞 main goroutine 等待退出。
// ashpd = XDG Portal 的 Rust 高层绑定,封装了 D-Bus ScreenCast 接口
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
// PersistMode 控制"恢复令牌"持久化级别(DoNot / Persistent / ExplicitlyRevoked
use ashpd::desktop::PersistMode;
// BitFlags = 位域集合类型(一个值可同时包含多个 SourceType,类比 Go 的 iota | 操作)
use ashpd::enumflags2::BitFlags;
// 同步 main → 手动创建 tokio Runtime → block_on 阻塞驱动 async 块。
// 这种"同步外壳 + 异步内核"的写法等价于 `#[tokio::main] async fn main()`
// 但保留了显式控制 runtime 生命周期的灵活性(参见文件头说明)。
fn main() {
// 手动创建 tokio runtime(含 reactor + executor + 时间驱动);
// unwrap() 仅示例用;生产代码应返回 Result 并 `?` 传播(但 fn main 不返回 Result
let rt = tokio::runtime::Runtime::new().unwrap();
// block_on 阻塞当前线程直到传入的 future 完成;这是同步↔异步边界
rt.block_on(async {
// async {} 块构造一个匿名 future,仅在 block_on poll 时才执行(惰性,与 goroutine 不同)
eprintln!("1. Creating Screencast proxy...");
// Screencast::new() 内部通过 D-Bus 连接 org.freedesktop.portal.ScreenCast
// .await 让出执行权直到 future 就绪(Go 没有这个语法,需 channel/锁模拟)
let proxy = match Screencast::new().await {
Ok(p) => {
eprintln!(" OK");
@@ -13,11 +43,14 @@ fn main() {
}
Err(e) => {
eprintln!(" FAIL: {e}");
// early-return 仅退出 async 块(不是退出 main),block_on 返回 ()
return;
}
};
eprintln!("2. Creating session...");
// create_session 建立一个 ScreenCast 会话句柄;
// Default::default() 用类型默认参数(ashpd 推断为 SessionOptions,所有字段取 Default
let session = match proxy.create_session(Default::default()).await {
Ok(s) => {
eprintln!(" OK");
@@ -30,7 +63,15 @@ fn main() {
};
eprintln!("3. Selecting sources...");
// BitFlags<SourceType> 表达"可选多显示器/窗口/工作区"集合;
// 这里 `into()` 将单个 Monitor 转为位域(Go 类似 flag = 1 << iota
let sources: BitFlags<SourceType> = SourceType::Monitor.into();
// Builder 链式:每次 set_X 返回 &mut Self(类似 Go functional-options 模式但更显式)
// - cursor_mode Embedded:光标嵌入帧内
// - sources: 仅 Monitor(去掉窗口,简化授权 UX)
// - multiple=false:单选(一次只授权一个显示器)
// - persist_mode DoNot:不申请恢复令牌(避免持久权限残留)
// 整个 builder 链构造一个 future,末尾的 .await 等待 D-Bus 返回
let result = proxy
.select_sources(
&session,
@@ -50,6 +91,8 @@ fn main() {
}
eprintln!("4. Starting (should show dialog)...");
// start() 触发系统授权对话框(D-Bus 调用阻塞直到用户响应);
// 第二参数 parent_window = None(无父窗口,常见于 CLI 程序)
let response = match proxy.start(&session, None, Default::default()).await {
Ok(r) => {
eprintln!(" OK");
@@ -60,6 +103,8 @@ fn main() {
return;
}
};
// Portal D-Bus 响应是双层结构:外层是 Request::responseOk/Err),
// 内层才是 ScreenCast 流信息(streams() 返回 PipeWire 节点 + dmabuf 信息列表)
match response.response() {
Ok(r) => eprintln!(" Got {} stream(s)", r.streams().len()),
Err(e) => eprintln!(" Response error: {e}"),
+54
View File
@@ -1,57 +1,111 @@
//! CLI 参数定义模块(基于 `clap` derive 宏)。
//!
//! 本文件用 `clap` 的 derive 宏把一个普通 struct 变成命令行解析器,思路类
//! 似 Go 的 `flag` 包,但更贴近"struct tag 自动生成"——每个 `pub` 字段配
//! 一行 `#[arg(...)]` 属性宏,clap 在编译期据此生成 `-x` / `--xxx` 选项、
//! 帮助文案、默认值和类型校验。`#[derive(Parser, Debug, Clone)]` 三个
//! derive 的作用:
//! - `Parser`clap 的入口 trait,提供 `Args::parse()`,等价于 Go 里的
//! `flag.Parse()`
//! - `Debug`:支持 `{:?}` 调试打印;
//! - `Clone`:允许 `Args::clone()` 值复制(运行循环里会用到)。
//!
//! Rust ↔ Go 类型对照(本文件用到的):
//! - `Option<String>` ≈ Go `*string``None` 表示用户没传该 flag,等价于
//! `nil` 指针;`Some(s)` 表示传了;
//! - `String`(无 `Option`)≈ Go `string`:必有值,由 `default_value`
//! 兜底,所以运行期不会空;
//! - `u32` / `u64` / `u16` ≈ Go `uint32` / `uint64` / `uint16`
//! - `bool` ≈ Go `bool`,但 clap 把它当开关:出现即 `true`,不出现即
//! `false`,等价于 Go 里没有参数的 `flag.Bool`
//! - `default_value_t = 30` ≈ Go `flag.Int("fps", 30, "...")` 的第二个
//! 参数(默认值);
//! - `default_value = "h264"` 用于 `String` 字段,等价意思;
//! - `#[arg(short, long)]` 同时生成短选项(`-o`,取字段首字母)和长选项
//! `--output`);
//! - `#[arg(long)]` 只生成长选项 `--output-name`,没有短形式。
//!
//! 注意:`AGENTS.md` 明确指出 README 的 CLI 表对 `--backend` 和 `--no-persist`
//! 已过时,**以本文件为准**。
use clap::Parser;
// 根解析器 struct。下方 `#[command(...)]` 设置 `--help` 第一行的程序名和
// `about` 文案;注意不要在此 struct 上加 `///`,否则 clap 会把 doc 注释
// 注入 help 文案,可能覆盖 `about`,导致 byte-identical 不变量被破坏。
#[derive(Parser, Debug, Clone)]
#[command(name = "wl-webrtc", about = "Wayland screen capture and encoding tool")]
pub struct Args {
/// Output file path (e.g., output.mp4, output.mkv). Optional when using --port for WebRTC mode
#[arg(short, long)]
pub output: Option<String>,
// 输出文件路径(`-o`/`--output`)。`Option<String>` ≈ Go `*string``None` 表示用户没传
/// Wayland output name to capture
#[arg(long)]
pub output_name: Option<String>,
// 指定要抓取的 Wayland 输出(显示器)名;`None` 时由后端自动选主屏
/// Target frames per second
#[arg(long, default_value_t = 30)]
pub fps: u32,
// 目标帧率(`--fps`,默认 30)。`default_value_t = 30` ≈ Go `flag.Int("fps", 30, ...)`
/// Video codec (h264 only for MVP)
#[arg(long, default_value = "h264")]
pub codec: String,
// 视频编码器(`--codec`,默认 `h264`)。MVP 阶段只支持 H.264,对比 Go 里 owned 的 `string`
/// Hardware acceleration method (vaapi only for MVP)
#[arg(long, default_value = "vaapi")]
pub hw_accel: String,
// 硬件加速方式(`--hw-accel`,默认 `vaapi`),目前只接受 `vaapi`
/// DRM render device path (e.g., /dev/dri/renderD128)
#[arg(long)]
pub drm_device: Option<String>,
// DRM 渲染节点路径(如 `/dev/dri/renderD128`),VAAPI 上下文需要它;`None` 时自动探测
/// Target bitrate in bits per second
#[arg(long)]
pub bitrate: Option<u64>,
// 目标码率(bps)。`Option<u64>` ≈ Go `*uint64``None` 时编码器用内部默认码率
/// Maximum bitrate in bps for WebRTC mode. Caps BWE-driven escalation to
/// prevent large IDR bursts from swamping the network. Default 8 Mbps covers
/// 1080p30/1440p30 H.264 acceptably. Does NOT affect MP4 (--output) mode.
/// See issue #23.
#[arg(long, default_value = "8000000")]
pub max_bitrate: u64,
// WebRTC 模式下的码率上限(默认 8 Mbps),抑制 IDR 突发造成网络拥塞;MP4 模式忽略
/// Group of Pictures (GOP) size
#[arg(long)]
pub gop_size: Option<u32>,
// GOP 长度(关键帧间距);`None` 时由编码器按内部策略自选
/// Enable verbose logging
#[arg(short, long)]
pub verbose: bool,
// 详细日志(`-v`/`--verbose`)。`bool` 在 clap 里是开关:出现即 `true`,等价 Go `flag.Bool`
/// Capture backend to use: 'screencopy' (wlroots) or 'portal' (KWin/KDE). Auto-detected if omitted
#[arg(long)]
pub backend: Option<String>,
// 抓屏后端(`screencopy` 或 `portal`);`None` 时由 `backend_detect.rs` 自动选择
/// Port for WebRTC HTTP signaling server; 0 keeps MP4 file output mode
#[arg(long, default_value_t = 0)]
pub port: u16,
// WebRTC HTTP 信令端口(`--port`,默认 0)。`0` 走 MP4 文件输出模式,`>0` 走 WebRTC 模式
/// Force re-authorization dialog (ignore saved portal restore token)
#[arg(long)]
pub no_persist: bool,
// 忽略已保存的 portal restore token,强制每次都弹授权对话框(测试时常用)
/// Enable per-second pipeline statistics output for stutter diagnosis
#[arg(long)]
pub stats: bool,
// 每秒打印管线统计(编码帧数、延迟等),用于卡顿诊断
}
+677 -26
View File
File diff suppressed because it is too large Load Diff
+207 -9
View File
@@ -1,3 +1,47 @@
//! # Wayland 截屏后端自动检测(`src/backend_detect.rs`
//!
//! 本文件负责检测当前 Wayland 桌面支持哪种屏幕捕获后端,由 [`detect_backend`]
//! 返回 [`CaptureBackend::WlrScreencopy`]wlroots 合成器:Sway/Hyprland 等,
//! 通过 `zwlr_screencopy_manager_v1` 协议直接交付 dmabuf,性能最好)或
//! [`CaptureBackend::PortalPipeWire`]XDG Portal + PipeWireKDE/GNOME 等,
//! 通过 D-Bus 调用 `org.freedesktop.portal.ScreenCast` 接口)。
//!
//! ## 检测优先级(见 [`detect_backend`]
//!
//! 1. 用户显式 `--backend portal|screencopy` 命令行参数覆盖;
//! 2. 自动检测:wlr-screencopy 优先(通过 Wayland globals 列表),否则回退到 Portal
//! (通过 D-Bus 查询 ScreenCast 接口的 `version` 属性 >=1 即视为可用)。
//!
//! ## 为什么用 raw `zbus` 而不是 `ashpd`**AGENTS.md 强约束**
//!
//! AGENTS.md 明确禁止在此文件使用 `ashpd` crate,原因是:
//! `ashpd` 内部把 `zbus::Connection` 缓存在一个全局 `OnceLock`。
//! 如果拥有该 connection 的 Tokio runtime 被 drop(例如本文件
//! [`check_portal_available`] 自建的临时 runtime 在函数返回时被 drop),
//! 缓存的 connection 会变成"僵尸"——后续 `setup_portal()` 复用时会永远 hang
//! 因为底层 `tokio::mpsc` 通道对端已死、但缓存仍报告"已初始化"。
//!
//! 因此本文件用 `zbus::connection::Builder::session()...build().await` 直接构造
//! 一条全新的、生命周期受当前 runtime 控制的连接,每次检测都重建。
//!
//! ## Go ↔ Rust 概念对照
//!
//! - `async fn` + `.await`Rust async 是**惰性的**async fn 返回 `impl Future`
//! 必须被 `.await` 或 `block_on` 才会真正执行),不同于 Go 的 `go f()` 立即并发。
//! - `tokio::runtime::Runtime::new()` + `rt.block_on(fut)`:从同步代码驱动 async
//! 类比 Go `runtime.GOMAXPROCS(1)` + `select { case <-done: }`。
//! - `tokio::time::timeout(d, fut).await` ≈ Go `context.WithTimeout(ctx, d)`
//! 返回 `Result<T, Elapsed>`,超时返回 `Err(Elapsed)`。
//! - `Result<T, E>` + `?` 操作符 ≈ Go `if err != nil { return err }` 的语法糖。
//! - `Option<T>` ≈ Go `*T`(指针可空),但 Rust 强制 `match`/`if let` 才能解引用。
//! - `tracing::info!("...{e}")` ≈ Go `log.Printf`,支持 Rust 1.58+ 的内联捕获格式化。
//! - `match { ... }` ≈ Go `switch`,但 Rust 强制穷尽所有分支(编译期检查)。
//! - `&mut T`(可变引用)≈ Go `*T`,但 Rust 编译期保证无别名(只有一个 mut 引用)。
//! - `move || { ... }` 闭包用 `move` 关键字显式捕获变量所有权(按值转移)。
//! - `'static` 生命周期约束 ≈ Go"对象不能持有栈指针"的隐式约定,但 Rust 编译期检查。
use std::time::Duration;
use anyhow::Result;
use wayland_client::globals::registry_queue_init;
use wayland_client::globals::GlobalListContents;
@@ -24,8 +68,15 @@ pub enum CaptureBackend {
/// 用于后端检测期间列举 Wayland 全局对象的最小化分发类型(无需实际处理事件)
struct RegistryLs;
// trait 分发:`Dispatch<WlRegistry, GlobalListContents> for RegistryLs` 表示
// "用 RegistryLs 作为状态对象、GlobalListContents 作为上下文数据来处理 WlRegistry 事件"。
// 类比 Go interface 的隐式满足,但 Rust trait 在编译期静态分发(generic 单态化),
// 即编译器为每个 (State, Event) 组合生成一份专属代码——零运行时开销。
// 为 RegistryLs 实现 Wayland 注册表事件分发(空实现,仅需类型满足 trait 约束)
impl Dispatch<WlRegistry, GlobalListContents> for RegistryLs {
// `fn event` 是 Dispatch trait 必须实现的方法:每收到一个 Wayland 事件触发一次。
// 下划线前缀参数(`_state`、`_registry` 等):Rust 编译器允许声明但不使用,
// 类比 Go 中 `_ = ctx` 显式忽略变量;这里我们只关心类型满足 trait、不处理事件。
fn event(
_state: &mut Self,
_registry: &WlRegistry,
@@ -40,35 +91,118 @@ impl Dispatch<WlRegistry, GlobalListContents> for RegistryLs {
// CAUTION: must NOT use ashpd here — ashpd caches zbus::Connection in a global
// OnceLock; if the tokio runtime owning that connection is dropped before
// setup_portal() runs, the cached connection becomes dead and hangs forever.
/// Per-operation D-Bus timeout for Portal backend detection.
/// Portal 后端检测期间每个 D-Bus 操作的超时时间。
const PORTAL_DBUS_TIMEOUT: Duration = Duration::from_secs(5);
/// 当 Portal 在超时时间内无响应时,记录详细的错误日志(含 systemctl 重启建议)。
///
/// 这是一个辅助函数——调用方已经在超时路径上返回了 `false`,本函数仅负责打印提示。
/// 不返回 `Result`:日志写入失败本身不应该影响后端检测逻辑。
fn log_portal_unresponsive(operation: &str) {
tracing::error!(
"Portal service did not respond within 5s while {operation}. \
This usually means xdg-desktop-portal or xdg-desktop-portal-kde is stuck. \
Try: systemctl --user restart xdg-desktop-portal xdg-desktop-portal-kde, \
then re-run wl-webrtc."
);
}
/// 通过 D-Bus 检测 XDG Portal ScreenCast 接口是否可用。
///
/// 检测流程(每一步都有 5 秒超时保护,见 [`PORTAL_DBUS_TIMEOUT`]):
/// 1. 连接到 D-Bus session bus
/// 2. 构造 `org.freedesktop.portal.Desktop` 的 ScreenCast proxy
/// 3. 查询 ScreenCast 接口的 `version` 属性(>=1 即视为可用)。
///
/// 任何一步超时或失败都返回 `false`——上层 [`detect_backend`] 据此决定回退策略。
///
/// # 同步外壳 + 异步内核
///
/// `check_portal_available` 本身是同步 `fn`(被同步的 [`detect_backend`] 调用),
/// 但内部通过 `tokio::runtime::Runtime::new()` + `rt.block_on(async { ... })`
/// 桥接到 async `zbus` API。类比 Go`func check() bool { rt := NewRuntime(); defer rt.Close(); return rt.BlockOn(asyncFn()) }`。
fn check_portal_available() -> bool {
// 创建独立的 Tokio runtime:外层 `detect_backend` 是同步 `fn`,没有 async runtime
// 上下文,需要自建一个来驱动 `.await`。
// 类比 Go:每次调用 `runtime.GOMAXPROCS(1)` 启动一个临时调度器。
// **关键**:这个 runtime 在函数结束时 drop——这也是为什么不能用 ashpd
// ashpd 缓存 connection 到全局,runtime drop 后 connection 变僵尸,见文件头注释)。
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
// `tracing::warn!` 宏:结构化日志,类比 Go `log.Printf`
// 但支持 Rust 1.58+ 的 `{e}` 内联捕获格式化(变量名直接作占位符)。
tracing::warn!("Failed to create tokio runtime for portal check: {e}");
return false;
}
};
// `rt.block_on(future)`:在当前同步线程上驱动 future 到完成。
// 类比 Go`select { case <-done: }` 阻塞等待 goroutine 结束。
// 但 Rust 的 `block_on` 是单线程内 cooperatively 调度 future(除非 runtime 配 multi-thread)。
rt.block_on(async {
let conn = match zbus::Connection::session().await {
Ok(c) => c,
Err(e) => {
// `async { ... }` 块构造一个匿名 Future(类比 Go `func() {}` 闭包)。
// 注意:async 块是惰性的——只有 `.await` 或 `block_on` 才会真正执行体内代码。
// Set method_timeout on the connection (bounds method replies) and wrap
// the build itself in tokio::time::timeout (bounds connection setup).
// 同时设置 method_timeout 与外层 tokio::time::timeout 双重保护。
// `tokio::time::timeout(d, fut)` ≈ Go `context.WithTimeout(ctx, d)`
// 返回 `Result<T, Elapsed>`——超时返回 `Err(Elapsed)`。
let conn = match tokio::time::timeout(PORTAL_DBUS_TIMEOUT, async {
// `zbus::connection::Builder::session()` 是 Builder 模式:
// 类比 Go `&http.Client{Timeout: ...}` 用链式方法配置参数。
// `.expect("...")`:失败时 panic(类比 Go `log.Panic`),
// 只用于"不可能失败"的构造——这里 session bus builder 几乎不会失败。
// `.method_timeout(...)` 设置单个 D-Bus 方法调用的超时上限。
// `.build().await` 异步构造 Connection(涉及 D-Bus 握手)。
zbus::connection::Builder::session()
.expect("D-Bus session bus builder failed")
.method_timeout(PORTAL_DBUS_TIMEOUT)
.build()
.await
})
.await
{
// 嵌套 Result 解构:外层 `Result<Connection, Elapsed>`(来自 timeout),
// 内层 `Result<Connection, zbus::Error>`(来自 build)。
// `Ok(Ok(c)) => c` 是模式匹配的多层解构(destructuring)——
// 类比 Go `if err == nil && inner_err == nil { c := value }`。
Ok(Ok(c)) => c,
Ok(Err(e)) => {
tracing::info!("D-Bus session bus unavailable: {e}");
return false;
}
Err(_) => {
// `Err(_)` 中的 `_` 是通配符模式:匹配任意值并丢弃。
// 这里我们关心的是"超时了",不关心 `Elapsed` 的具体值。
log_portal_unresponsive("connecting to D-Bus session bus");
return false;
}
};
// `zbus::Proxy`D-Bus proxy 是远程对象的强类型句柄,封装 destination+path+interface。
// 类比 Go 中的 `dbus.ObjectProxy`:调用 `proxy.get_property(...)` 时
// 自动 marshal 成 D-Bus 消息发到目标对象。
// `Builder::new(&conn).destination(...).and_then(|b| b.path(...))` 链式构造:
// `and_then` 来自 `Result`,把 `Result<Builder, E>` 解开再继续链——
// 类比 Go `if b, err := b.X(); err != nil { return err } else { b.Y() }`。
let inner: zbus::Proxy = match zbus::proxy::Builder::new(&conn)
.destination("org.freedesktop.portal.Desktop")
.and_then(|b| b.path("/org/freedesktop/portal/desktop"))
.and_then(|b| b.interface("org.freedesktop.portal.ScreenCast"))
{
Ok(b) => match b.build().await {
Ok(p) => p,
Err(e) => {
Ok(b) => match tokio::time::timeout(PORTAL_DBUS_TIMEOUT, b.build()).await {
Ok(Ok(p)) => p,
Ok(Err(e)) => {
tracing::info!("Portal ScreenCast interface not available: {e}");
return false;
}
Err(_) => {
log_portal_unresponsive("building ScreenCast proxy");
return false;
}
},
Err(e) => {
tracing::info!("Portal ScreenCast proxy build failed: {e}");
@@ -76,25 +210,56 @@ fn check_portal_available() -> bool {
}
};
let version = match inner.get_property::<u32>("version").await {
Ok(version) => {
// 查询 ScreenCast 接口的 `version` 属性——这是最可能卡住的操作,
// 因为前两步只是本地构造 proxy,而 get_property 需要 Portal 端实际处理请求。
// `.get_property::<u32>("version")`:泛型方法,turbofish `::<u32>` 指定返回类型,
// 类比 Go `GetVersion() (uint32, error)`——但 Rust 用泛型 + 编译期单态化。
// The most likely operation to hang — requires actual Portal-side work.
// 最可能卡住的操作,需要 Portal 端实际处理。
let version = match tokio::time::timeout(
PORTAL_DBUS_TIMEOUT,
inner.get_property::<u32>("version"),
)
.await
{
Ok(Ok(version)) => {
tracing::info!("Portal ScreenCast available (version: {version})");
true
}
Err(e) => {
Ok(Err(e)) => {
tracing::info!("Portal ScreenCast version query failed: {e}");
false
}
Err(_) => {
log_portal_unresponsive("querying ScreenCast version");
false
}
};
version
})
}
// 通过 Wayland globals 检测 wlr-screencopy 协议是否可用
//
// Wayland globals 是合成器在连接建立时广播的"已支持协议"列表——
// 类比 Go 中的 HTTP OPTIONS:客户端连上服务器后先查询能力,再决定怎么说话。
// 我们只需检查列表里是否有 `zwlr_screencopy_manager_v1` 这个接口名即可。
fn check_screencopy_available() -> Result<bool> {
// `Connection::connect_to_env()?`:从 WAYLAND_DISPLAY 环境变量读取 socket 路径并连接。
// `?` 操作符:如果 `connect_to_env` 返回 `Err(e)`,立即把 `e` 转换为函数返回类型
// `anyhow::Result`),并 return 之。类比 Go `if err != nil { return err }`。
let conn = Connection::connect_to_env()?;
// `registry_queue_init::<RegistryLs>(&conn)?`turbofish `::<RegistryLs>` 指定
// 用我们刚定义的空 Dispatch 实现来接收 registry 事件。函数内部会 roundtrip
// 一次拿到所有 globals,返回 `(GlobalList, Queue)` 元组。
// `let (globals, _queue) = ...`:元组解构(tuple destructuring),
// 类比 Go `globals, queue := ...`,但 Rust 用 `_queue` 表示"我接收但不会用到"。
let (globals, _queue) = registry_queue_init::<RegistryLs>(&conn)?;
// 迭代器链式调用(zero-cost,编译期单态化):
// `.contents()` → `GlobalList``.clone_list()` → `Vec<Global>`
// `.iter()` → `Iterator<&Global>``.any(|g| ...)` → `bool`(短路求值)。
// `|g| g.interface == "..."` 是闭包(closure),类比 Go `func(g Global) bool { ... }`。
let has_screencopy = globals
.contents()
.clone_list()
@@ -125,7 +290,14 @@ fn check_screencopy_available() -> Result<bool> {
pub fn detect_backend(args: &Args) -> Result<CaptureBackend> {
// 1. Check explicit override
// 步骤 1:检查用户是否通过命令行参数显式指定了后端
// `if let Some(ref backend) = args.backend`:模式匹配 + `ref` 关键字。
// `args.backend` 类型是 `Option<String>``Some(ref backend)` 表示
// "如果是 Some,则把内部 String 的**引用**绑定到 backend"(不获取所有权)。
// 类比 Go `if args.Backend != nil { backend := args.Backend }`。
if let Some(ref backend) = args.backend {
// `backend.as_str()`:把 `&String` 转 `&str`(类比 Go string → []byte view)。
// `match backend.as_str() { ... }`Rust 的 match 对 `&str` 强制穷尽所有分支,
// 类比 Go `switch backend { case "portal": ...; default: ... }`,但没有隐式 fallthrough。
return match backend.as_str() {
"portal" => {
tracing::info!("Backend override: Portal/PipeWire");
@@ -136,7 +308,10 @@ pub fn detect_backend(args: &Args) -> Result<CaptureBackend> {
Ok(CaptureBackend::WlrScreencopy)
}
other => {
// `other` 是匹配模式变量:绑定未被前面 arm 命中的任意值(类比 Go `default`)。
// 未知后端名称,返回错误
// `anyhow::bail!("...", args)` 是宏(注意 `!`):立即构造 `anyhow::Error`
// 并从当前函数 return `Err`。类比 Go `return fmt.Errorf("...", ...)`。
anyhow::bail!("Unknown backend '{}'. Use 'screencopy' or 'portal'.", other);
}
};
@@ -147,11 +322,18 @@ pub fn detect_backend(args: &Args) -> Result<CaptureBackend> {
tracing::info!("Auto-detecting capture backend...");
// 检测 wlr-screencopy(通过 Wayland globals
// `check_screencopy_available()?` 末尾的 `?`:把 `Result<bool>` 解开——
// 成功取 bool,失败则立即 return `Err`(错误向上传播)。
let has_screencopy = check_screencopy_available()?;
// 检测 Portal(通过 D-Bus
// `check_portal_available()` 无 `?`:因为它返回的是 `bool` 而不是 `Result`
// 内部已经把所有错误吞掉并转为 `false`。
let has_portal = check_portal_available();
// 根据检测结果选择后端,screencopy 优先(性能更好、延迟更低)
// `match (has_screencopy, has_portal) { ... }`:元组匹配——同时匹配两个 bool。
// `(true, _)` 中的 `_` 是通配符:表示"任意值都匹配"。类比 Go `switch { case hasSC: ... }`。
// Rust 强制穷尽所有 (bool, bool) 组合,编译期检查,不能漏掉一个分支。
match (has_screencopy, has_portal) {
(true, _) => {
tracing::info!("Detected wlr-screencopy support → using WlrScreencopy backend");
@@ -171,11 +353,18 @@ pub fn detect_backend(args: &Args) -> Result<CaptureBackend> {
}
}
// `#[cfg(test)]` 属性:条件编译——`cargo build` 时这个 mod 不会被编译进二进制,
// 只有 `cargo test` 时才参与编译。这样发布产物零运行时开销。
// 类比 Go 中 `_test.go` 后缀的约定:测试代码与生产代码物理分离。
#[cfg(test)]
mod tests {
// `use super::*;`glob 导入(wildcard import),把父模块的所有 pub item 引入当前作用域。
// 类比 Go 中的 dot-import`. "pkg"`),但 Rust 限定在 `super::` 即父模块内。
// 这里用来在测试中直接访问 `detect_backend`、`CaptureBackend` 等。
use super::*;
// 测试辅助函数:构造指定后端参数的 Args 实例
// 注意:辅助函数不需要 `#[test]` 属性——它只是被测试函数调用的普通函数。
fn make_args(backend: Option<&str>) -> Args {
Args {
output: Some("test.mp4".to_string()),
@@ -185,6 +374,7 @@ mod tests {
hw_accel: "vaapi".to_string(),
drm_device: None,
bitrate: None,
max_bitrate: 8_000_000,
gop_size: None,
verbose: false,
backend: backend.map(String::from),
@@ -195,11 +385,17 @@ mod tests {
}
// 测试:显式指定 portal 后端
// `#[test]` 属性:标记此函数为测试用例,`cargo test` 自动发现并执行。
// 测试函数约定:`fn name() {}` 无参数无返回值;panic 即测试失败。
#[test]
fn explicit_portal_backend() {
let args = make_args(Some("portal"));
let result = detect_backend(&args);
// `assert!(cond)` 宏:条件为 false 时 panic,类比 Go `if !cond { t.Fatal() }`。
assert!(result.is_ok());
// `assert_eq!(a, b)` 宏:断言相等,失败时打印两边内容,类比 Go `if a != b { t.Errorf() }`。
// `.unwrap()`:解开 Result——成功取内部值,失败 panic。
// 测试代码中常用 `unwrap()` 简化错误处理;生产代码应避免(用 `?` 替代)。
assert_eq!(result.unwrap(), CaptureBackend::PortalPipeWire);
}
@@ -218,6 +414,8 @@ mod tests {
let args = make_args(Some("magic"));
let result = detect_backend(&args);
assert!(result.is_err());
// `.unwrap_err()`:与 `unwrap()` 相反——解开 Err 中的错误值(如果 Ok 则 panic)。
// `.to_string()`:把 `anyhow::Error` 转为 `String`(用 Display 格式化)。
let err = result.unwrap_err().to_string();
assert!(
err.contains("Unknown backend 'magic'"),
+156
View File
@@ -1,9 +1,40 @@
//! 软件编码流水线性能基准(独立二进制 `sw_encode_bench`)。
//!
//! ## 用途
//!
//! 测量"纯 CPU"屏幕采集编码流水线的端到端耗时,作为对照参考与 VAAPI 硬件编码
//! 基准 `vaapi_import_bench``src/bin/vaapi_import_bench.rs`)形成对比:
//! - 本文件:Portal 采集 → `mmap` 把 DMA-BUF 映射到用户态 → `sws_scale` 在 CPU
//! 上做 BGR0→YUV420P 颜色空间/缩放转换 → libx264/openh264 软件编码。
//! - 对照 `vaapi_import_bench.rs`Portal 采集 → `av_hwframe_map` 在 GPU 上做
//! 零拷贝格式转换 → VAAPI 硬件编码(GPU)。
//!
//! ## 输出
//!
//! 打印 mmap / sws_scale / encode 三段每帧平均耗时与总体 FPS,便于判断"软件路径"
//! 在当前硬件上能否达到 30 FPS 目标。AMD GPU 在某些驱动下不允许 CPU 读取 DMA-BUF
//! `mmap` 会失败——这正是 `vaapi_import_bench` 存在的意义。
//!
//! ## Rust ↔ Go 对照
//!
//! - `clap::Parser` derive 宏:类似 Go 的 `flag` 包,但在编译期生成解析代码。
//! - `std::time::Instant`:高精度单调时钟,等价于 Go 的 `time.Now()` + `time.Since()`。
//! - `crossbeam_channel::recv_timeout`:等价于 Go 的 `select { case <-time.After(): }`。
//! - 本文件大量使用裸 `unsafe` FFI 调用 FFmpeg C API;现有 21 处 unsafe 块均
//! 未标注 SAFETY 标记,本任务也不补充,仅在每个 unsafe 块上方加普通 `//`
//! 中文概述,说明"为什么必须 unsafe"。
//!
//! 用法:`cargo run --bin sw_encode_bench -- --output /tmp/bench_test.mp4`
// sw_encode_bench.rs — Software encoding pipeline benchmark for screen capture
//
// Benchmarks: Portal capture -> mmap DMA-BUF -> sws_scale BGR0->YUV420P -> libx264 encode
//
// Usage: cargo run --bin sw_encode_bench -- --output /tmp/bench_test.mp4
// 以下 `use` 语句分组:FFI 字符串/裸 fd 转换/路径/指针/计时 → anyhow/clap →
// ffmpeg_next 别名与 ffi → crate 内 Portal 采集器。Rust 没有 Go 的 "package"
// 概念,每个外部 crate 都要显式 `use`。
use std::ffi::CString;
use std::os::fd::AsRawFd;
use std::path::Path;
@@ -11,15 +42,24 @@ use std::ptr;
use std::time::Instant;
use anyhow::{bail, Result};
// `clap::Parser` derive 宏:编译期生成 CLI 解析代码,等价于 Go 的 `flag` 包
// 但支持子命令/类型转换/帮助文本自动生成。
use clap::Parser;
// FFmpeg 绑定,使用 `ffmpeg_next` crate(社区维护的 next 分支)。`as ff` 别名
// 缩短调用路径;`ffi` 子模块直接暴露 C ABI(裸指针、`AVFormatContext` 等)。
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use ffmpeg_next::packet::Mut;
// 复用主程序的 `Args` 与 Portal 采集器:基准与主二进制共享同一采集代码路径,
// 仅"消费方"不同(基准直接落盘,主程序走 WebRTC 推流)。
use wl_webrtc::args::Args;
use wl_webrtc::cap_portal::{CapPortal, PwCtrlEvent};
// 基准 CLI 参数定义。`#[derive(Parser, Debug)]` 让 clap 在编译期为 struct
// 生成 `parse()` 方法;`#[command(...)]` 设置程序元信息。等价于 Go 程序的
// `flag.StringVar(...)` 序列,但在 Rust 里完全声明式。
#[derive(Parser, Debug)]
#[command(
name = "sw_encode_bench",
@@ -39,6 +79,9 @@ struct BenchArgs {
enc_height: u32,
}
// 帧级耗时统计容器。每帧把 mmap/sws_scale/encode/total 的微秒数 push 进 Vec
// 结束后用 `avg_ms` 算平均值。这是"简单算术 + Vec"模式,比 streaming stats
// 复杂但能保留分布信息(虽然本基准只打印均值)。Go 类似 `[]int64`。
#[derive(Default)]
struct FrameStats {
mmap_us: Vec<u64>,
@@ -48,7 +91,12 @@ struct FrameStats {
mmap_failures: u32,
}
// 关联函数(不是 method——没有 `&self`/`&mut self` receiver),类似 Go 的
// package-level helper function。Rust 把它放在 `impl FrameStats` 内是组织习惯,
// 也可以写成自由函数 `fn avg_ms(...)`。
impl FrameStats {
// 把 Vec<u64> 求和后除以元素数得到微秒均值,再除以 1000 转毫秒。空 Vec
// 返回 0.0 避免除零。注意 Rust 这里 `as f64` 是显式转换(不像 Go 的隐式)。
fn avg_ms(data: &[u64]) -> f64 {
if data.is_empty() {
return 0.0;
@@ -57,12 +105,18 @@ impl FrameStats {
}
}
// 把 `ffmpeg_next` 的高级 Pixel 枚举转换为 FFmpeg C API 期望的原始
// `AVPixelFormat`i32 别名)。`Into::into` 在此处零成本——编译期已知映射。
fn pix_fmt(p: ff::format::Pixel) -> ffi::AVPixelFormat {
Into::<ffi::AVPixelFormat>::into(p)
}
// 从 Portal channel 拉取首帧:阻塞等待 PipeWire 推送 DMA-BUF。
// 同时监控控制 channel(流结束/格式变更/错误)。Go 类比:
// `for { select { case f := <-frameCh: return f; case <-time.After(10*time.Second): ... } }`
fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBufFrame> {
loop {
// `try_recv` 非阻塞地检查控制 channel 是否有事件(流结束/错误/格式变更)。
if let Ok(ctrl) = cap.event_receiver().try_recv() {
match ctrl {
PwCtrlEvent::StreamEnded => bail!("PipeWire stream ended before first frame"),
@@ -70,6 +124,7 @@ fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBu
PwCtrlEvent::Error(e) => bail!("PipeWire error: {e}"),
}
}
// `recv_timeout` 阻塞最多 10s 等首帧。三路分支处理 Ok/Timeout/Disconnected。
match cap
.frame_receiver()
.recv_timeout(std::time::Duration::from_secs(10))
@@ -85,7 +140,13 @@ fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBu
}
}
// 程序入口。流程四阶段:[1/4] 申请 Portal 授权并连接 PipeWire[2/4] 等首帧
// 拿到 DMA-BUF 元数据(宽高/stride/fd);[3/4] 试 mmap 一帧验证 CPU 可读;
// [4/4] 配置 libx264 编码器 + FFmpeg 输出格式上下文,进入主采集编码循环并打印统计。
// `anyhow::Result<()>` 把所有错误用 `?` 传播到 main 顶层——Rust 的 main 可以返回
// Result,运行时打印错误并退出码非零,类似 Go 1.0 时代 `log.Fatal` 的现代等价物。
fn main() -> Result<()> {
// clap 生成的 `BenchArgs::parse()` 解析 argv;类型不符直接 panic 退出。
let bench_args = BenchArgs::parse();
println!("=== Software Encode Benchmark ===");
@@ -97,11 +158,14 @@ fn main() -> Result<()> {
);
println!();
// 初始化 FFmpeg 全局状态(注册编解码器、协议等)。`?` 在 Result 上传播错误。
ff::init()?;
println!("[1/4] Requesting screen capture via XDG Portal...");
println!(" (Select a screen to share in the portal dialog)");
// 复用主二进制的 `Args` struct 来构造 Portal 请求;hw_accel="vaapi" 只是为了
// 走到 VAAPI 兼容的 DRM 设备路径(本基准并不会真正调用 VAAPI)。
let portal_args = Args {
output: Some(bench_args.output.clone()),
output_name: None,
@@ -110,6 +174,7 @@ fn main() -> Result<()> {
hw_accel: "vaapi".to_string(),
drm_device: None,
bitrate: None,
max_bitrate: 8_000_000,
gop_size: None,
verbose: false,
backend: Some("portal".to_string()),
@@ -118,12 +183,15 @@ fn main() -> Result<()> {
stats: false,
};
// `CapPortal::new` 会触发 XDG Portal 授权对话框(用户需要在屏幕共享对话框里选屏)。
let cap = CapPortal::new(&portal_args)?;
println!("[1/4] Portal connected, PipeWire stream active\n");
println!("[2/4] Waiting for first frame from PipeWire...");
let first_frame = receive_first_frame(&cap)?;
// PipeWire 推来的首帧携带了 DMA-BUF 的元数据:fd(文件描述符)+ offset
// + stride(每行字节数)+ width/height/format。后续 mmap 就靠这些。
let src_width = first_frame.width;
let src_height = first_frame.height;
let src_stride = first_frame.stride;
@@ -141,6 +209,9 @@ fn main() -> Result<()> {
println!("[3/4] Testing mmap on DMA-BUF...");
let mmap_size = (src_stride as usize) * (src_height as usize);
// unsafe #1:调用 libc::mmap 把 DMA-BUF fd 映射到用户态地址空间。FFI 之所以
// 必须 unsafemmap 接受 void* 返回 raw 指针,编译器无法验证其有效性;
// 调用方必须保证 fd 真的是有效的 DMA-BUF 且 PROT_READ 权限匹配。
let mmap_ptr = unsafe {
libc::mmap(
ptr::null_mut(),
@@ -152,6 +223,8 @@ fn main() -> Result<()> {
)
};
// `MAP_FAILED` 是 mmap 失败的哨兵值(不是 NULL)。AMD 某些驱动禁止 CPU 读
// DMA-BUF,必须改用 VAAPI 硬件路径——这就是 `vaapi_import_bench.rs` 的意义。
if mmap_ptr == libc::MAP_FAILED {
let errno = std::io::Error::last_os_error();
bail!(
@@ -172,6 +245,8 @@ fn main() -> Result<()> {
"[3/4] mmap SUCCESS — CPU can read DMA-BUF ({:.1} MB)\n",
mmap_size as f64 / 1024.0 / 1024.0
);
// unsafe #2:解除映射。FFI 调用必须 unsafe——libc::munmap 接受 raw pointer
// 编译期无法保证 ptr 真的来自之前 mmap 的同一区域(不匹配会 UB)。
unsafe {
libc::munmap(mmap_ptr, mmap_size);
}
@@ -179,10 +254,15 @@ fn main() -> Result<()> {
// Set up libx264 encoder via FFI (same pattern as avhw.rs)
println!("[4/4] Setting up libx264 encoder...");
// 输出路径转 C 字符串(FFmpeg C API 期望 `const char*`,不接受 Rust &str)。
// CString 保证结尾有 NUL 字节,调用方必须保证字符串内部不含 NUL。
let output_path = Path::new(&bench_args.output);
let output_cstr = CString::new(output_path.to_str().unwrap())?;
// Try libx264 first (best quality/speed), fall back to openh264
// 查找软件 H.264 编码器:优先 libx264(最快/质量最好),缺失则 fallback openh264。
// Rust 的 `or_else` + `ok_or_else` 是 Result/Option 链式习惯,类似 Go 的
// 多次 if err != nil 但不嵌套。
let codec = ff::encoder::find_by_name("libx264")
.or_else(|| ff::encoder::find_by_name("libopenh264"))
.ok_or_else(|| {
@@ -190,11 +270,13 @@ fn main() -> Result<()> {
})?;
println!("[4/4] Using encoder: {}\n", codec.name());
// 创建 FFmpeg 编码器 Context 并提取 video encoder 句柄。`enc.open()` 会在后面调用。
let mut enc = {
let ctx = ff::codec::Context::new_with_codec(codec);
ctx.encoder().video()?
};
// 编码器基础参数:分辨率/像素格式/时基/GOP。`time_base = 1/60` 表示一帧 = 1/60 秒。
enc.set_width(enc_width);
enc.set_height(enc_height);
enc.set_format(ff::format::Pixel::YUV420P);
@@ -204,6 +286,9 @@ fn main() -> Result<()> {
let codec_name = codec.name();
if codec_name == "libx264" {
// unsafe #3:调用 FFmpeg 的 `av_opt_set` 设置 libx264 的私有 preset/tune 选项。
// FFI 必须 unsafe:接受 `*const c_char` 裸指针,编译期无法验证指针指向有效内存,
// 也无法保证 priv_data 字段确实属于 libx264(其它编码器会 UB)。
unsafe {
let key = CString::new("preset").unwrap();
let val = CString::new("veryfast").unwrap();
@@ -218,7 +303,11 @@ fn main() -> Result<()> {
let mut enc_video = opened.0;
// Create output format context via FFI
// FFmpeg 输出格式上下文:根据文件扩展名(如 .mp4)自动推断容器。
let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut();
// unsafe #4`avformat_alloc_output_context2` 接受 out-pointer 模式(C 风格返回
// 指针的指针)。FFI 必须 unsafe:编译期无法验证 fmt_ctx_ptr 可写、不能保证
// 调用方传入了正确的容器格式猜测。
let ret = unsafe {
ffi::avformat_alloc_output_context2(
&mut fmt_ctx_ptr,
@@ -231,21 +320,31 @@ fn main() -> Result<()> {
bail!("Failed to allocate output format context: error {ret}");
}
// unsafe #5:在 fmt_ctx 内创建一条新流(mp4 容器内的一条视频 track)。
// 返回的 `stream_ptr` 是裸指针,调用方负责不 double-freeFFmpeg 内部托管)。
let stream_ptr = unsafe { ffi::avformat_new_stream(fmt_ctx_ptr, ptr::null()) };
if stream_ptr.is_null() {
bail!("Failed to create new stream");
}
// unsafe #6:把编码器参数(分辨率/时基/像素格式)拷贝到流的 codecpar 字段。
// FFmpeg C API 允许裸指针字段写入(`(*stream_ptr).codecpar`),编译期无法验证
// 两个上下文确实兼容(同 codec、同 pixel format),调用方需自己保证。
let ret =
unsafe { ffi::avcodec_parameters_from_context((*stream_ptr).codecpar, enc_video.as_ptr()) };
if ret < 0 {
bail!("Failed to copy encoder parameters: error {ret}");
}
// unsafe #7:直接通过裸指针写字段:把编码器的 time_base 复制到流,避免后续
// mux 时再 rescale。FFI 必须 unsafe——`(*stream_ptr).time_base = ...` 是 C 风格
// 的指针解引用赋值,编译期无法验证 stream_ptr 仍存活。
unsafe {
(*stream_ptr).time_base = (*enc_video.as_ptr()).time_base;
}
// unsafe #8`avio_open` 打开输出文件的 IO 上下文。FFI 必须 unsafe:编译期
// 无法验证 fmt_ctx_ptr->pb 字段可写、不能保证文件路径可写(运行时才报错)。
let ret = unsafe {
ffi::avio_open(
&mut (*fmt_ctx_ptr).pb,
@@ -260,17 +359,27 @@ fn main() -> Result<()> {
);
}
// unsafe #9:写容器头(mp4 的 ftyp box 等)。FFI 必须 unsafe:调用顺序约束
// (必须在 avio_open 之后、第一帧之前)由调用方维护,编译期不验证。
let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) };
if ret < 0 {
bail!("Failed to write header: error {ret}");
}
// unsafe #10`Output::wrap` 把 C 指针包装成 Rust 类型——FFI 边界。
// unsafe 必须:调用方保证 fmt_ctx_ptr 在此后由 Rust 独占管理(FFmpeg C 代码
// 不能再 free 它,否则 double-free)。这是 `unsafe impl Send` 在 avhw.rs 中
// 同款的"独占所有权"约定。
let mut octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
// Create sws_scale context: BGRZ (BGR0) -> YUV420P
// sws_scale 是 FFmpeg 的颜色空间转换器(CPU 软件)。本基准的"软件路径"核心:
// 把 DMA-BUF 的 BGR0 像素数据转成 libx264 期望的 YUV420P planar 格式。
let bgr0_fmt = pix_fmt(ff::format::Pixel::BGRZ);
let yuv420p_fmt = pix_fmt(ff::format::Pixel::YUV420P);
// unsafe #11`sws_getContext` 创建转换器。FFI 必须 unsafe:返回 raw 指针,
// 调用方负责后续 `sws_freeContext` 释放(cleanup 阶段会做)。
let sws_ctx = unsafe {
ffi::sws_getContext(
src_width as i32,
@@ -290,14 +399,20 @@ fn main() -> Result<()> {
}
// Allocate reusable YUV frame
// 预分配一个 YUV420P 帧,循环里反复写入(避免每帧 malloc)。FFmpeg C API 要求
// 显式 alloc/get_buffer/free 三步——Rust 端无法用 RAII 自动管理,必须 unsafe。
let mut yuv_frame = unsafe {
// unsafe #12`av_frame_alloc` 只分配 struct 本体,不分配 data 缓冲区。
let mut f = ffi::av_frame_alloc();
if f.is_null() {
bail!("av_frame_alloc failed");
}
// unsafe #13:通过裸指针写入 width/height/format 字段。
(*f).width = enc_width as i32;
(*f).height = enc_height as i32;
(*f).format = yuv420p_fmt as i32;
// unsafe #14`av_frame_get_buffer` 根据 width/height/format 分配实际像素缓冲区。
// 失败时必须 free 已分配的 struct(避免泄漏)。
let ret = ffi::av_frame_get_buffer(f, 0);
if ret < 0 {
ffi::av_frame_free(&mut f);
@@ -313,12 +428,16 @@ fn main() -> Result<()> {
println!("=== Encoding {} frames ===\n", bench_args.frames);
// 统计容器初始化。`Instant::now()` 是单调时钟(不受系统时间调整影响),
// 类比 Go 的 `time.Now()`,但 Rust 的 Instant 设计上不允许"墙上时钟"用途。
let mut stats = FrameStats::default();
let total_start = Instant::now();
let mut frames_encoded: u32 = 0;
let mut pts: i64 = 0;
// 主采集编码循环:每帧从 PipeWire 拉帧 → mmap → sws_scale → send_frame → drain。
while frames_encoded < bench_args.frames {
// 控制通道优先检查(流结束/错误)。`try_recv` 非阻塞返回 Result<Option<T>>。
if let Ok(ctrl) = cap.event_receiver().try_recv() {
match ctrl {
PwCtrlEvent::StreamEnded => {
@@ -333,6 +452,7 @@ fn main() -> Result<()> {
}
}
// 5s 超时拉帧。任何错误(超时/断开)都视为流终止,跳出循环。
let frame = match cap
.frame_receiver()
.recv_timeout(std::time::Duration::from_secs(5))
@@ -344,10 +464,14 @@ fn main() -> Result<()> {
}
};
// 帧级别计时:本轮 mmap/scale/encode 的总耗时统计锚点。
let frame_start = Instant::now();
// ---- 第 1 段:mmap DMA-BUF 到用户态 ----
let mmap_start = Instant::now();
let frame_size = (frame.stride as usize) * (frame.height as usize);
// unsafe #15:与首帧的 mmap 同语义——把 PipeWire 推来的 DMA-BUF fd 映射到
// 用户态。每帧都重新 mmap 是因为 fd 可能切换(Portal 可能用 buffer pool)。
let mmap_ptr = unsafe {
libc::mmap(
ptr::null_mut(),
@@ -367,9 +491,16 @@ fn main() -> Result<()> {
}
stats.mmap_us.push(mmap_start.elapsed().as_micros() as u64);
// ---- 第 2 段:sws_scale BGR0 → YUV420P ----
let scale_start = Instant::now();
// unsafe #16`slice::from_raw_parts` 把裸指针+长度包成 Rust slice。
// 这是 Rust 最危险的 unsafe 之一:编译期无法验证 (ptr, len) 真的指向
// 有效内存、对齐正确、与 aliasing 规则兼容(不允许其它 &mut 同时存活)。
let src_data = unsafe { std::slice::from_raw_parts(mmap_ptr as *const u8, frame_size) };
// unsafe #17:调用 FFmpeg 的 sws_scale 做颜色空间转换。三个 FFI 风险:
// (1) 裸指针 src_ptr / src_linesize(2) yuv_frame->data/linesize 数组
// 必须有效;(3) sws_ctx 必须与 src/dst 像素格式匹配(不匹配会 UB)。
unsafe {
ffi::av_frame_make_writable(yuv_frame);
@@ -390,13 +521,18 @@ fn main() -> Result<()> {
.scale_us
.push(scale_start.elapsed().as_micros() as u64);
// unsafe #18:解除本帧的 mmap。FFI 必须 unsafe——ptr 必须仍是之前 mmap 的返回值。
unsafe {
libc::munmap(mmap_ptr, frame_size);
}
drop(frame);
// ---- 第 3 段:libx264 编码 ----
let encode_start = Instant::now();
// unsafe #19`avcodec_send_frame` 把一帧 YUV 喂给编码器(异步:内部入队)。
// FFI 必须 unsafe:裸指针 enc_video.as_mut_ptr()/yuv_frame;编译期无法
// 验证 enc 已 open、yuv_frame 的 width/height/format 与编码器配置一致。
unsafe {
(*yuv_frame).pts = pts;
pts += 1;
@@ -430,6 +566,8 @@ fn main() -> Result<()> {
let total_elapsed = total_start.elapsed();
println!("\nFlushing encoder...");
// unsafe #20:发 NULL frame 表示"flush"——编码器吐出剩余的延迟帧(B-frame 等)。
// 本基准 max_b_frames=0 所以没有延迟帧,但调用约定必须保留。
unsafe {
ffi::avcodec_send_frame(enc_video.as_mut_ptr(), ptr::null());
}
@@ -439,6 +577,8 @@ fn main() -> Result<()> {
.map_err(|e| anyhow::anyhow!("Failed to write trailer: {e}"))?;
// Cleanup
// unsafe #21:手动释放 yuv_frame 与 sws_ctx。FFmpeg C API 不支持 RAII
// 必须显式 free,否则内存泄漏。`as *mut _` 是为了取 *mut *mut AVFrame 引用。
unsafe {
ffi::av_frame_free(&mut yuv_frame as *mut _);
ffi::sws_freeContext(sws_ctx);
@@ -447,6 +587,8 @@ fn main() -> Result<()> {
drop(cap);
// Print results
// 结果汇总:把 mmap/scale/encode 三段均值 + 总 FPS 打印成表格。Go 类比
// `fmt.Printf`——Rust println! 是宏不是函数,编译期检查参数。
let mmap_count = stats.mmap_us.len() as u32;
let mmap_success_rate = if mmap_count + stats.mmap_failures > 0 {
mmap_count as f64 / (mmap_count + stats.mmap_failures) as f64 * 100.0
@@ -455,6 +597,7 @@ fn main() -> Result<()> {
};
let total_fps = frames_encoded as f64 / total_elapsed.as_secs_f64();
let avg_total_ms = FrameStats::avg_ms(&stats.total_us);
// 最大理论 FPS = 1000ms / 每帧均耗时。avg_total_ms 为 0 时跳过避免除零。
let max_fps = if avg_total_ms > 0.0 {
1000.0 / avg_total_ms
} else {
@@ -519,14 +662,21 @@ fn main() -> Result<()> {
Ok(())
}
// 从编码器 drain(抽取)已经编码好的压缩包并写入输出容器。FFmpeg 编码 API 是
// 异步的:`avcodec_send_frame` 入队原始帧,`avcodec_receive_packet` 出队 H.264
// NAL;可能 send 一帧后 receive 多包(关键帧场景),也可能 receive 返回 EAGAIN
// (编码器内部还在缓冲)。Go 类比:双 channel + select 循环,先收再吐。
fn drain_encoder(
enc_video: &mut ff::encoder::video::Video,
octx: &mut ff::format::context::Output,
) -> Result<()> {
loop {
let mut pkt = ff::Packet::empty();
// unsafe #22`avcodec_receive_packet` 出队一个 H.264 压缩包到 pkt。FFI 必须
// unsafe:编译期无法验证 enc_video 已 open、pkt.as_mut_ptr() 真指向空 packet。
let ret = unsafe { ffi::avcodec_receive_packet(enc_video.as_mut_ptr(), pkt.as_mut_ptr()) };
if ret < 0 {
// EAGAIN = 暂时没有更多包可吐(需要再 send);EOF = flush 完成。两者都退出。
if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF {
break;
}
@@ -535,13 +685,19 @@ fn drain_encoder(
}
let enc_tb = enc_video.time_base();
// unsafe #23:从 `(*octx.as_ptr()).streams` 取第一条流的 time_base,用于
// rescale 时间戳。FFI 必须 unsafe——裸指针 + `*streams.add(0)` 假定 streams
// 数组至少有一项(fmt_ctx 已注册至少一条流,否则前面 avformat_new_stream
// 就 bail 了)。
let stream_tb = unsafe {
let streams = (*octx.as_ptr()).streams;
let st = *streams.add(0);
ff::Rational::from((*st).time_base)
};
// 把 PTS 从编码器时基 rescale 到流时基(mp4 容器要求)。Go 类比:单位换算。
pkt.rescale_ts(enc_tb, stream_tb);
pkt.set_stream(0);
// `write_interleaved` 让 FFmpeg 自动处理 interleaving(音视频交错,避免 demuxer 卡)。
pkt.write_interleaved(octx)
.map_err(|e| anyhow::anyhow!("write packet failed: {e}"))?;
}
+311
View File
@@ -1,48 +1,99 @@
//! # vaapi_import_bench — VAAPI DMA-BUF 导入性能基准
//!
//! 本文件是 wl-webrtc 项目下的**独立可执行二进制**(位于 `src/bin/`),用于离线
//! 测量 "Portal 屏幕捕获 → DMA-BUF 导入到 VAAPI 硬件帧 → GPU 下采样 → 编码" 这条
//! 关键流水线的端到端耗时,并与 "CPU 软编" 路径作对比,输出每阶段平均毫秒数与 FPS。
//!
//! ## 流水线
//!
//! - **CPU 路径**PipeWire BGRA 帧 → `sws_scale` 缩放 → libx264/libopenh264 软编
//! - **GPU 路径**PipeWire DMA-BUF → `av_hwframe_map` → `scale_vaapi` 滤镜 → VAAPI H.264
//!
//! ## 与 Go benchmark 的类比
//!
//! 类似 Go 的 `testing.B`:先跑预热帧,再用 `Instant::now()` / `Duration::as_micros()`
//! 采集每个阶段的耗时(导入、缩放、传输、编码),最后输出 `FrameStats` 平均值。
//!
//! ## 用法
//!
//! ```bash
//! cargo run --bin vaapi_import_bench -- --output /tmp/vaapi_bench.mp4
//! cargo run --bin vaapi_import_bench -- --output /dev/null --mode gpu
//! cargo run --bin vaapi_import_bench -- --output /tmp/cpu.mp4 --mode cpu --frames 120
//! ```
//!
//! 详见 `AGENTS.md` 的 "Useful manual commands" 章节。
// vaapi_import_bench.rs — VAAPI DMA-BUF import + GPU-side downscale benchmark
//
// Tests: Portal capture -> av_hwframe_map (ARGB sw_format) -> transfer -> sw encode
//
// Usage: cargo run --bin vaapi_import_bench -- --output /tmp/vaapi_bench.mp4
// ===== 标准库导入 =====
// CStringFFI 传递给 C 函数的 NUL 结尾字符串;类比 Go 中显式末尾 0 的 []byte
// AsRawFd trait:把 Rust 的 OwnedFd 暴露为原始 int fd(用于 DMA-BUF 导入)
// Path:跨平台路径类型;类比 Go filepath
// ptrFFI 裸指针工具(ptr::null_mut()、ptr::null()),类比 Go unsafe.Pointer(nil)
// Instant:高精度单调时钟;类比 Go time.Now(),用 elapsed() 取差值
use std::ffi::CString;
use std::os::fd::AsRawFd;
use std::path::Path;
use std::ptr;
use std::time::Instant;
// ===== 第三方 crate =====
// anyhowResult<T> = Result<T, anyhow::Error>bail! 宏提前返回 Err;类比 Go (T, error)
// clapCLI 参数解析(Derive 宏);本文件 BenchArgs 与 args.rs Args 都用此模式
use anyhow::{bail, Result};
use clap::{Parser, ValueEnum};
// ffmpeg_nextFFmpeg 绑定。ffi 子模块是 raw C FFI(含 unsafe),其余为高层封装
// packet::Mut trait:提供 as_mut_ptr(),用于拿到 AVPacket* 喂给 C API
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use ffmpeg_next::packet::Mut;
// 从本 crate (wl-webrtc) 复用:CLI Args、VAAPI 上下文、Portal 捕获
use wl_webrtc::args::Args;
use wl_webrtc::avhw::{import_dma_buf_to_vaapi, AvHwDevCtx, AvHwFrameCtx};
use wl_webrtc::cap_portal::{CapPortal, PwCtrlEvent};
/// 基准测试的 CLI 参数。Derive `Parser` 后 `BenchArgs::parse()` 即可从 argv 解析;
/// 类比 Go 中 `flag.StringVar` + `flag.Parse()`,但 Rust 用编译期宏生成代码。
///
/// 注意:与生产二进制 `wl-webrtc` 的 `Args`(见 `src/args.rs`)不同——这里是基准专用
/// 参数集(更细粒度的 enc_width/enc_height/mode),不复用 `Args`。
#[derive(Parser, Debug)]
#[command(name = "vaapi_import_bench", about = "VAAPI DMA-BUF import benchmark")]
struct BenchArgs {
// 输出文件路径。如果包含 "null" 子串则使用 FFmpeg 的 null muxer(不写盘,只测编码耗时)
#[arg(short, long)]
output: String,
// 总编码帧数;类比 Go benchmark 的 b.N,但这里是固定值(默认 60 帧)
#[arg(long, default_value_t = 60)]
frames: u32,
// 编码器输出宽(GPU 路径会下采样到该尺寸)
#[arg(long, default_value_t = 2560)]
enc_width: u32,
// 编码器输出高
#[arg(long, default_value_t = 1440)]
enc_height: u32,
// DRM 渲染节点路径;VAAPI 上下文绑定到此设备(Intel iGPU 通常是 renderD128
#[arg(long, default_value = "/dev/dri/renderD128")]
drm_device: String,
// 流水线模式:cpu 只跑软编;gpu 只跑 VAAPI;both 两条路径都跑并对比
#[arg(long, value_enum, default_value_t = PipelineMode::Both)]
mode: PipelineMode,
}
/// 流水线模式选择。Derive `ValueEnum` 后 clap 自动把 "cpu"/"gpu"/"both" 字符串
/// 映射到枚举值;Derive `Copy` 让它在 match 时按值复制(无需 & 引用)。
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum PipelineMode {
Cpu,
@@ -50,34 +101,54 @@ enum PipelineMode {
Both,
}
/// 单条流水线(CPU 或 GPU)运行结束后的统计聚合。每个 `Vec<u64>` 保存每帧的耗时(微秒)。
///
/// 类比 Go 的 `BenchmarkResult`:把 N 帧的逐次耗时收集起来,最后统一计算均值。
/// 用 `Vec<u64>` 而非流式累积是为了支持后续可能的中位数/分位数扩展。
#[derive(Default)]
struct FrameStats {
// DMA-BUF 导入耗时(仅 GPU 路径有,CPU 路径为空)
import_us: Vec<u64>,
// GPU 滤镜图耗时(仅 GPU 路径有)
filter_us: Vec<u64>,
// CPU 路径的 sws_scale 耗时
transfer_us: Vec<u64>,
// 预留:缩放耗时单独拆分(当前与 filter_us/transfer_us 重叠)
scale_us: Vec<u64>,
// 像素格式转换耗时(BGRA → YUV420P
format_us: Vec<u64>,
// 编码器 send_frame + drain_packet 总耗时
encode_us: Vec<u64>,
// 单帧总耗时(capture_start → encode_done),用于理论 FPS
total_us: Vec<u64>,
// 导入失败的次数(DMA-BUF fd 失效等)
import_failures: u32,
// 实际成功编码的帧数
frames_encoded: u32,
// 端到端墙钟耗时(从首帧到末帧),用于实测 FPS
elapsed_secs: f64,
// 编码器名称(libx264 / libopenh264 / h264_vaapi
codec_name: String,
// 输出路径(区分 cpu / gpu 文件名)
output_path: String,
}
impl FrameStats {
// 计算每帧耗时的均值(微秒 → 毫秒);空 Vec 返回 0.0 避免除零
fn avg_ms(data: &[u64]) -> f64 {
if data.is_empty() {
return 0.0;
}
// sum::<u64>() 显式指定求和类型,避免类型推导失败;类比 Go 的 for-range 累加
data.iter().sum::<u64>() as f64 / data.len() as f64 / 1000.0
}
// 单帧总耗时的均值(毫秒),用于报告 "平均每帧 X ms"
fn avg_total_ms(&self) -> f64 {
Self::avg_ms(&self.total_us)
}
// 实测 FPS = 成功编码帧数 / 墙钟耗时;避免零除返回 0.0
fn achieved_fps(&self) -> f64 {
if self.frames_encoded > 0 && self.elapsed_secs > 0.0 {
self.frames_encoded as f64 / self.elapsed_secs
@@ -86,6 +157,7 @@ impl FrameStats {
}
}
// 理论 FPS = 1000 / 平均单帧总耗时(仅编码侧上限,不含 PipeWire 等待)
fn theoretical_fps(&self) -> f64 {
let avg = self.avg_total_ms();
if avg > 0.0 {
@@ -96,6 +168,11 @@ impl FrameStats {
}
}
/// CPU 软编路径的状态聚合体:编码器、输出容器、可复用的 YUV 帧。
///
/// 字段 `yuv_frame` 是裸指针 `*mut ffi::AVFrame`——因为 FFmpeg C API 要求长生命周期
/// 的可变指针,且需要 Drop 时显式释放。裸指针 `*mut T` 默认非 Send/Sync,但本结构体
/// 只在主线程使用,无需跨线程传递,因此无需手动 impl Send。
struct SoftwareEncoder {
enc_video: ff::codec::encoder::video::Video,
octx: ff::format::context::Output,
@@ -103,8 +180,11 @@ struct SoftwareEncoder {
codec_name: String,
}
// Drop trait 类比 Go 的 `defer cleanup()`:结构体析构时由 Rust 自动调用,
// 避免裸指针 yuv_frame 泄漏。注意 Drop 内不能再使用 self.yuv_frame,只能释放
impl Drop for SoftwareEncoder {
fn drop(&mut self) {
// Drop trait 类比 Go 的 `defer cleanup()`:结构体析构时自动调用
// SAFETY: yuv_frame is allocated by av_frame_alloc in create_software_encoder and
// owned exclusively by this SoftwareEncoder.
unsafe {
@@ -113,10 +193,14 @@ impl Drop for SoftwareEncoder {
}
}
/// FFmpeg `sws_scale` 上下文的拥有型包装。Newtype 模式(tuple struct 单字段)让
/// Rust 类型系统追踪 C 资源的所有权,并通过 Drop 自动释放;类比 Go 中
/// `type SwsContext struct{ p *C.SwsContext }` + `func (s *SwsContext) Close()`。
struct SwsContext(*mut ffi::SwsContext);
impl Drop for SwsContext {
fn drop(&mut self) {
// sws_freeContext 接受 NULL 是安全的(C 规范),无需额外判空
// SAFETY: Context is either null or returned by sws_getContext and owned here.
unsafe {
ffi::sws_freeContext(self.0);
@@ -124,17 +208,28 @@ impl Drop for SwsContext {
}
}
/// 把 FFmpeg 错误码(负数)翻译成人类可读字符串。FFmpeg 的错误码没有官方码表,
/// 必须通过 `av_strerror` 拿到文本;类比 Go 中 `errno.String()` 或 `os.PathError.Err`。
fn av_err_to_string(ret: i32) -> String {
// 准备 128 字节缓冲区(FFmpeg 习惯用 128),由 av_strerror 写入 NUL 结尾的 C 字符串
let mut buf = vec![0u8; 128];
// 中文 unsafe 概述:av_strerror 最多写 128 字节并以 NUL 结尾;buf 是独占的可变 Vec<u8>
// as_mut_ptr 把缓冲区首字节暴露给 C,借用仅在这次调用期间有效。
unsafe {
ffi::av_strerror(ret, buf.as_mut_ptr() as *mut i8, buf.len());
}
// 找到首个 NUL 字节作为字符串末尾,再 from_utf8_lossy 容错转 String
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
String::from_utf8_lossy(&buf[..end]).to_string()
}
/// 阻塞等待第一帧 PipeWire DMA-BUF 到达;类比 Go 的 `chan.Recv()` 配 `select`。
///
/// 同时监听控制通道(StreamEnded / FormatChanged / Error),任何错误都立即 `bail!`。
/// 超时 10 秒防止 GPU/驱动卡死导致基准测试无限挂起。
fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBufFrame> {
loop {
// 控制通道:非阻塞 try_recv(类比 Go `select { case e := <-ctrl: ... default: }`
if let Ok(ctrl) = cap.event_receiver().try_recv() {
match ctrl {
PwCtrlEvent::StreamEnded => bail!("PipeWire stream ended before first frame"),
@@ -142,6 +237,8 @@ fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBu
PwCtrlEvent::Error(e) => bail!("PipeWire error: {e}"),
}
}
// 帧通道:阻塞等待最多 10 秒
// 类比 Go `select { case f := <-frame: ... case <-time.After(10*time.Second): bail! }`
match cap
.frame_receiver()
.recv_timeout(std::time::Duration::from_secs(10))
@@ -157,21 +254,31 @@ fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBu
}
}
/// 从编码器循环拉取已编码的 packet 并写入输出容器,直到编码器返回 EAGAIN/EOF。
///
/// "Drain" 模式:调用 `avcodec_send_frame` 后必须连续 `avcodec_receive_packet` 直到
/// EAGAIN,否则编码器内部缓冲区会堵塞,下一帧 send_frame 会失败。
fn drain_encoder(
enc_video: &mut ff::codec::encoder::video::Video,
octx: &mut ff::format::context::Output,
) -> Result<()> {
loop {
let mut pkt = ff::Packet::empty();
// 中文 unsafe 概述:enc_video.as_mut_ptr() 指向已打开的编码器上下文;pkt.as_mut_ptr()
// 指向空 packetFFmpeg 会在此调用中分配 packet 数据。
let ret = unsafe { ffi::avcodec_receive_packet(enc_video.as_mut_ptr(), pkt.as_mut_ptr()) };
if ret < 0 {
// EAGAIN = 编码器还需要更多输入帧;EOF = 已 flush;两者都是正常终止
if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF {
break;
}
eprintln!("avcodec_receive_packet failed: {ret}");
break;
}
// 把 PTS 从编码器时间基重缩放为输出流的时间基(视频流可有不同 time_base)
let enc_tb = enc_video.time_base();
// 中文 unsafe 概述:octx.as_ptr() 指向有效的 AVFormatContextstreams 数组至少有一个流
// (在 create_software_encoder 中由 avformat_new_stream 创建)。
let stream_tb = unsafe {
let streams = (*octx.as_ptr()).streams;
let st = *streams.add(0);
@@ -179,14 +286,28 @@ fn drain_encoder(
};
pkt.rescale_ts(enc_tb, stream_tb);
pkt.set_stream(0);
// write_interleaved 让 FFmpeg 自动按 DTS 排序,避免手动管理 PTS/DTS
pkt.write_interleaved(octx)
.map_err(|e| anyhow::anyhow!("write packet failed: {e}"))?;
}
Ok(())
}
/// 初始化 libx264/libopenh264 软编码器 + 输出容器(MP4/null muxer+ 可复用 YUV420P 帧。
///
/// 这是基准 CPU 路径的核心装配函数,步骤依次为:
/// 1. 寻找 codeclibx264 优先,libopenh264 回退)
/// 2. 创建 encoder contextbuilder 模式)
/// 3. 设置 width/height/fps/time_base/GOP
/// 4. libx264 专属)设置 preset/tune
/// 5. 打开编码器
/// 6. 分配 AVFormatContext + 创建流 + 复制 codec parameters
/// 7. 打开输出文件(除非 null muxer)+ 写文件头
/// 8. 分配可复用的 YUV420P 帧(在每帧 encode 时复用)
fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Result<SoftwareEncoder> {
// CString 必须在 unsafe 块外构造,确保 NUL 结尾的字符串生命周期覆盖下面的 FFI 调用
let output_cstr = CString::new(output_path.to_str().unwrap())?;
// 优先 libx264(性能最好,GPL 协议),其次 libopenh264BSD,回退方案)
let codec = ff::encoder::find_by_name("libx264")
.or_else(|| ff::encoder::find_by_name("libopenh264"))
.ok_or_else(|| {
@@ -194,18 +315,24 @@ fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Resul
})?;
let codec_name = codec.name().to_string();
// 两阶段构造:先 Context::new_with_codec 拿到 builder,再 .encoder().video()? 切到视频编码视图
let mut enc = {
let ctx = ff::codec::Context::new_with_codec(codec);
ctx.encoder().video()?
};
// 编码器参数:分辨率、像素格式、时基、GOP 结构
enc.set_width(width);
enc.set_height(height);
enc.set_format(ff::format::Pixel::YUV420P);
// time_base = 1/60,与基准测试默认 60 FPS 对齐;生产代码里通常从源流继承
enc.set_time_base(ff::Rational::new(1, 60));
// 关闭 B 帧以降低延迟(基准不追求压缩率)
enc.set_max_b_frames(0);
// GOP = 60:每 60 帧一个 I 帧(与 60 FPS 对齐 = 每秒一个 IDR 帧)
enc.set_gop(60);
// libx264 的私有参数 preset/tune 必须在 encoder 打开前通过 av_opt_set 设置到 priv_data
if codec_name == "libx264" {
// SAFETY: priv_data belongs to the not-yet-opened encoder context. Option strings are
// valid NUL-terminated C strings for the duration of each av_opt_set call.
@@ -219,9 +346,11 @@ fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Resul
}
}
// 真正打开编码器(前面只是 builder 状态),此后 enc_video 进入 ready 状态
let opened = enc.open()?;
let enc_video = opened.0;
// 输出文件名含 "null" → 用 FFmpeg 内置 null muxer(不写盘),适合纯 CPU 基准
let use_null_muxer = output_path
.to_str()
.map(|s| s.contains("null"))
@@ -265,6 +394,7 @@ fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Resul
bail!("Failed to copy codec parameters: error {ret}");
}
// AVFMT_NOFILE 表示该 muxer 不需要物理文件(如 null muxer),跳过 avio_open
// SAFETY: fmt_ctx_ptr is valid; pb is initialized for non-NOFILE muxers.
unsafe {
if (*(*fmt_ctx_ptr).oformat).flags & ffi::AVFMT_NOFILE == 0 {
@@ -285,9 +415,11 @@ fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Resul
bail!("Failed to write header: error {ret}");
}
// 此后 octx 拥有 fmt_ctx_ptr,会在 Drop 时调用 avformat_free_context
// SAFETY: ownership of fmt_ctx_ptr transfers into ffmpeg-next Output wrapper.
let octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
// 这个 yuv_frame 在每次 encode_yuv_frame 中复用(不重新分配),由 SoftwareEncoder::drop 释放
// SAFETY: Allocate and configure an owned writable YUV420P frame for encoder input.
let yuv_frame = unsafe {
let mut f = ffi::av_frame_alloc();
@@ -313,28 +445,41 @@ fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Resul
})
}
/// 根据 `PipelineMode` 在文件名中插入 `cpu` 或 `gpu` 后缀,让 both 模式下两条路径不互相覆盖。
///
/// 例:`/tmp/out.mp4` + `PipelineMode::Cpu` → `/tmp/out.cpu.mp4`。
/// `split=false` 或文件名含 "null" 时直接返回原路径(null muxer 不需要分裂)。
fn output_for_mode(base: &str, mode: PipelineMode, split: bool) -> String {
if !split || base.contains("null") {
return base.to_string();
}
let path = Path::new(base);
// match 在 Rust 中默认是穷尽的(编译器强制覆盖所有 enum 变体);
// 这里 Both 在调用前已被外层排除,用 unreachable!() 标记
let suffix = match mode {
PipelineMode::Cpu => "cpu",
PipelineMode::Gpu => "gpu",
PipelineMode::Both => unreachable!(),
};
// file_name 返回 Option<&OsStr>and_then + to_str 链式处理 None 情况
let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or(base);
// rsplit_once 类比 Go 的 strings.Cut:从右侧切分一次扩展名(保留 "a.b.c" 中的 "a.b" 与 "c"
let split_name = if let Some((stem, ext)) = file_name.rsplit_once('.') {
format!("{stem}.{suffix}.{ext}")
} else {
format!("{file_name}.{suffix}")
};
// with_file_name 保留父目录,只替换末尾文件名;to_string_lossy 容错 OsStr → &str
path.with_file_name(split_name)
.to_string_lossy()
.into_owned()
}
/// 创建 BGRA→YUV420P 的 swscale 上下文。`SwsContext` 是 CPU 路径的颜色空间/尺寸转换核心。
///
/// 第 7 个参数 `2` = bicubic 算法;FFmpeg 还提供 fast_bilinear(1) / bilinear(2) /
/// lanczos(16) 等。基准选 bicubic 是平衡速度与质量。
fn create_sws_context(
src_width: u32,
src_height: u32,
@@ -342,6 +487,7 @@ fn create_sws_context(
dst_width: u32,
dst_height: u32,
) -> Result<SwsContext> {
// 返回的 *mut SwsContext 由 SwsContext 包装并在 Drop 中通过 sws_freeContext 释放。
// SAFETY: sws_getContext creates an owned scaler context for the provided dimensions/formats.
let ctx = unsafe {
ffi::sws_getContext(
@@ -363,11 +509,15 @@ fn create_sws_context(
Ok(SwsContext(ctx))
}
/// 把已填好 YUV420P 数据的 `encoder.yuv_frame` 送入编码器,并 drain 已编码 packet。
/// 返回编码阶段的耗时(微秒),用于 `FrameStats::encode_us` 统计。
fn encode_yuv_frame(encoder: &mut SoftwareEncoder, pts: &mut i64) -> Result<u64> {
// 类比 Go time.Now();用 as_micros() as u64 转 u64u128 截断不影响 60s 量级基准)
let t_encode = Instant::now();
// SAFETY: yuv_frame is allocated, writable, and formatted as the encoder's configured
// YUV420P input frame. FFmpeg consumes but does not take ownership.
unsafe {
// 单调递增的 PTSFFmpeg 要求 PTS 必须按 time_base 单位递增,否则丢帧
(*encoder.yuv_frame).pts = *pts;
*pts += 1;
let r = ffi::avcodec_send_frame(encoder.enc_video.as_mut_ptr(), encoder.yuv_frame);
@@ -375,10 +525,14 @@ fn encode_yuv_frame(encoder: &mut SoftwareEncoder, pts: &mut i64) -> Result<u64>
bail!("avcodec_send_frame failed: {r}");
}
}
// drain 编码器缓冲区(必须,否则下一帧 send_frame 会 EAGAIN
drain_encoder(&mut encoder.enc_video, &mut encoder.octx)?;
Ok(t_encode.elapsed().as_micros() as u64)
}
/// 编码结束:发送 NULL frame 触发编码器 flushdrain 残余 packet,写入文件尾(trailer)。
///
/// 类比 Go 中 `io.Closer`:必须按顺序 (flush → drain → trailer) 才能产出可播放的文件。
fn finish_encoder(mut encoder: SoftwareEncoder) -> Result<()> {
// SAFETY: Sending a null frame flushes the encoder; context remains owned by encoder.
unsafe {
@@ -392,6 +546,8 @@ fn finish_encoder(mut encoder: SoftwareEncoder) -> Result<()> {
Ok(())
}
/// 把 PipeWire 给的 DMA-BUF 帧导入 VAAPI 硬件帧上下文,返回 `ff::frame::Video`GPU 帧)。
/// 这是 GPU 路径的入口;耗时由 `FrameStats::import_us` 统计。
fn import_frame(
frames_ctx: &AvHwFrameCtx,
frame: &wl_webrtc::cap_portal::PwDmaBufFrame,
@@ -412,6 +568,10 @@ fn import_frame(
}
}
/// 构建 GPU 路径的 FFmpeg 滤镜图:`buffer`CPU 入口)→ `scale_vaapi`GPU 缩放+格式转换)→ `buffersink`。
///
/// 关键点:buffer 滤镜不能用 pix_fmt=VAAPI 直接初始化(FFmpeg 8+ 会拒绝),
/// 必须用 `av_buffersrc_parameters_set` 注入 hw_frames_ctx 才能让后续 VAAPI 滤镜识别。
fn build_gpu_filter_graph(
hw_dev: &AvHwDevCtx,
frames_rgb: &AvHwFrameCtx,
@@ -421,10 +581,13 @@ fn build_gpu_filter_graph(
enc_height: u32,
) -> Result<ff::filter::Graph> {
let mut graph = ff::filter::Graph::new();
// buffer = 滤镜图入口,从 AVFrame 注入数据
let buffersrc =
ff::filter::find("buffer").ok_or_else(|| anyhow::anyhow!("filter 'buffer' not found"))?;
// buffersink = 滤镜图出口,取出处理后的 AVFrame
let buffersink = ff::filter::find("buffersink")
.ok_or_else(|| anyhow::anyhow!("filter 'buffersink' not found"))?;
// scale_vaapi = VAAPI 硬件缩放 + 格式转换(BGRA→NV12)
let scale_vaapi = ff::filter::find("scale_vaapi")
.ok_or_else(|| anyhow::anyhow!("filter 'scale_vaapi' not found"))?;
@@ -449,14 +612,18 @@ fn build_gpu_filter_graph(
(*par).width = width as i32;
(*par).height = height as i32;
(*par).time_base = ffi::AVRational { num: 1, den: 60 };
// ref_clone 增加引用计数(AVBufferRef 共享底层 AVHWFramesContext),
// FFmpeg 内部会持有这个引用直到 buffersrc 释放
(*par).hw_frames_ctx = frames_rgb.ref_clone();
let ret = ffi::av_buffersrc_parameters_set(src_ctx.as_mut_ptr(), par);
// 只释放参数结构体本身;AVBufferRef 的引用由 buffersrc 持有,不能在这里 free
ffi::av_free(par as *mut _);
if ret < 0 {
bail!("av_buffersrc_parameters_set failed: error {ret}");
}
}
// scale_vaapi 滤镜参数:缩放到 enc_width×enc_height,输出 NV12VAAPI H.264 要求的输入格式)
let mut scale_ctx = graph.add(
&scale_vaapi,
"scale",
@@ -468,6 +635,7 @@ fn build_gpu_filter_graph(
}
let mut sink_ctx = graph.add(&buffersink, "out", "")?;
// 链接:in[0] → scale[0] → out[0]pad index 0 是默认输入/输出口
src_ctx.link(0, &mut scale_ctx, 0);
scale_ctx.link(0, &mut sink_ctx, 0);
graph
@@ -477,6 +645,16 @@ fn build_gpu_filter_graph(
Ok(graph)
}
// CPU 流水线主函数:跑 `frames` 帧测量每阶段耗时(import → transfer → scale → encode)。
// 与 GPU 路径的核心差异:CPU 路径**不经过 scale_vaapi 滤镜**,而是用 `av_hwframe_transfer_data`
// 把硬件帧"下载"到 CPU 内存(4K BGRA),再用 `sws_scale` 在 CPU 上做下采样到 2K YUV420P
// 因此 CPU 路径的"transfer"和"scale"耗时都明显高于 GPU 路径。
//
// 类比 Go benchmark:类似 `func benchCPU(b *testing.B) { for n := 0; n < b.N; n++ {...} }`
// 但 Rust 用 `while stats.frames_encoded < frames` 显式循环(无 testing.B 框架)。
//
// 参数:8 个参数(含 src/enc 尺寸 4 个)—— clippy 默认会嫌太多,故上方 `#[allow]` 抑制。
// 返回 `Result<FrameStats>`:任何 FFmpeg/Portal 失败立即 `?` 传播到 main。
#[allow(clippy::too_many_arguments)]
fn run_cpu_pipeline(
cap: &CapPortal,
@@ -488,7 +666,10 @@ fn run_cpu_pipeline(
enc_width: u32,
enc_height: u32,
) -> Result<FrameStats> {
// 构造软件编码器(libx264 或 libopenh264,取决于 create_software_encoder 内部 fallback)。
// `?` 自动把 anyhow::Error 上浮到调用者;类比 Go `if err != nil { return err }`。
let mut encoder = create_software_encoder(Path::new(output), enc_width, enc_height)?;
// 构造 sws_scale 上下文:源 = 4K BGRA,目标 = 2K YUV420Psws_scale 内部完成下采样+色彩空间转换。
let sws_ctx = create_sws_context(
src_width,
src_height,
@@ -497,6 +678,8 @@ fn run_cpu_pipeline(
enc_height,
)?;
// println! 是宏(不是函数),类比 Go fmt.Println;第一参数是 format! 模板字符串。
// `{output}` 是内联格式化语法(Rust 1.58+),等价于 `format!("{}", output)`。
println!(
" Encoder: {}, {}x{} YUV420P",
encoder.codec_name, enc_width, enc_height
@@ -504,26 +687,40 @@ fn run_cpu_pipeline(
println!(" Output: {output}");
println!(" CPU Pipeline: DMA-BUF 4K BGRA -> av_hwframe_map -> av_hwframe_transfer_data -> sws_scale -> YUV420P 2K -> encode\n");
// 构造 FrameStats,用 struct update 语法 `..FrameStats::default()` 让其余字段取 Default 值。
// 类比 Go `&FrameStats{Codec: codec, Output: out}` 仅设置 2 字段其余清零。
let mut stats = FrameStats {
codec_name: encoder.codec_name.clone(),
output_path: output.to_string(),
..FrameStats::default()
};
// 整条流水线总耗时起点;elapsed() 返回 Duration,后续 as_secs_f64() 取秒(float)。
let total_start = Instant::now();
// PTSPresentation Time Stamp,单位 = 编码器 time_base.den 的倒数)—— 单调递增的演示时钟。
// `let mut` 表示可变绑定(默认不可变,Rust 与 Go 的关键差异之一)。
let mut pts: i64 = 0;
// 主循环:直到编码完 `frames` 帧;类比 Go `for stats.FramesEncoded < frames {`。
while stats.frames_encoded < frames {
// try_recv 非阻塞从 PipeWire 控制通道取事件;Ok 表示有事件,Err(TryRecvError::Empty) 跳过。
// 类比 Go `select { case ev := <-ctrlCh: ... default: }`。
if let Ok(ctrl) = cap.event_receiver().try_recv() {
// match 是穷尽性模式匹配(每个 enum variant 必须覆盖或用 `_` 兜底)。
match ctrl {
// 流正常结束(用户停止共享 / Portal 关闭):跳出主循环。
PwCtrlEvent::StreamEnded => break,
// PipeWire 报错:把帧号 + 错误信息 bail! 到调用者(bail! = return Err(anyhow!(...)))。
PwCtrlEvent::Error(e) => bail!(
"PipeWire error after {} CPU frames: {e}",
stats.frames_encoded
),
// 格式变化(分辨率/像素格式):本基准忽略,等下一帧自然到达。
PwCtrlEvent::FormatChanged { .. } => {}
}
}
// recv_timeout 阻塞最多 5 秒取下一帧;类比 Go `select { case f := <-frCh: ... case <-time.After(5*time.Second): }`。
// match 直接对 Result 解构:Ok(f) 拿到帧,Err(_)(超时或断开)直接 break 结束。
let frame = match cap
.frame_receiver()
.recv_timeout(std::time::Duration::from_secs(5))
@@ -532,30 +729,41 @@ fn run_cpu_pipeline(
Err(_) => break,
};
// 单帧起点:用于统计 total_us(包含所有子阶段)。
let frame_start = Instant::now();
// import 阶段起点:把 DMA-BUF 帧封装为 AV_PIX_FMT_VAAPI 硬件帧(av_hwframe_map 路径)。
let t_import = Instant::now();
// match 表达式对 Result 解构并支持多分支(含 guard 与错误处理)。
let vaapi_frame = match import_frame(frames_ctx, &frame) {
Ok(f) => f,
Err(e) => {
// 失败计数器自增;前 3 次打印到 stderr,避免日志淹没。
stats.import_failures += 1;
if stats.import_failures <= 3 {
eprintln!("CPU frame {}: import failed: {e}", stats.frames_encoded);
}
// continue 跳过本帧后续步骤(不是错误退出)。
continue;
}
};
// elapsed() 返回 Durationas_micros() → u128`as u64` 截断到 u64(帧耗时不会超 2^64 微秒)。
let import_us = t_import.elapsed().as_micros() as u64;
// transfer 阶段:用 av_hwframe_transfer_data 把硬件帧拷贝到 CPU 内存(4K BGRA)。
let t_transfer = Instant::now();
// SAFETY: sw_frame is allocated by FFmpeg and freed on all paths below.
let mut sw_frame = unsafe { ffi::av_frame_alloc() };
if sw_frame.is_null() {
// av_frame_alloc 返回 NULL 表示 OOM bail! 把错误抛到 main(不是 panic)。
bail!("CPU frame {}: av_frame_alloc failed", stats.frames_encoded);
}
// av_hwframe_transfer_dataFFmpeg 提供的硬件→软件帧拷贝 APIsrc=VAAPIdst=CPU 内存帧。
// 第 3 参数 flags 通常传 0;返回 0 表示成功,负数表示 FFmpeg 错误码。
// SAFETY: sw_frame is an allocated destination; vaapi_frame is a valid VAAPI source frame.
let transfer_ret =
unsafe { ffi::av_hwframe_transfer_data(sw_frame, vaapi_frame.as_ptr(), 0) };
if transfer_ret < 0 {
// 错误路径必须 free,否则内存泄漏;FFmpeg C API 无 RAII。
// SAFETY: sw_frame was allocated above and has not been freed yet.
unsafe { ffi::av_frame_free(&mut sw_frame) };
bail!(
@@ -567,11 +775,15 @@ fn run_cpu_pipeline(
}
let transfer_us = t_transfer.elapsed().as_micros() as u64;
// scale 阶段:在 CPU 上把 4K BGRA 下采样到 2K YUV420PCPU 路径的瓶颈所在)。
let t_scale = Instant::now();
// SAFETY: sw_frame contains transferred BGRA data; encoder.yuv_frame is writable YUV420P
// at the configured output dimensions; sws_ctx converts and downscales between them.
unsafe {
// av_frame_make_writable:确保 yuv_frame 内部 buffer 可写(FFmpeg 引用计数可能共享)。
ffi::av_frame_make_writable(encoder.yuv_frame);
// sws_scalelibswscale 主接口;参数 = (ctx, src_slices[], src_stride[], src_y_start, src_h, dst_slices[], dst_stride[])。
// `(*sw_frame).data.as_ptr() as *const *const u8` 把 C 数组首地址转裸指针(FFmpeg AVFrame.data 是 [u8*; 8])。
ffi::sws_scale(
sws_ctx.0,
(*sw_frame).data.as_ptr() as *const *const u8,
@@ -583,12 +795,16 @@ fn run_cpu_pipeline(
);
}
let scale_us = t_scale.elapsed().as_micros() as u64;
// 缩放完成后立即释放中间 BGRA 帧(约 4K*2160*4 = 33MB),避免峰值内存。
// SAFETY: sw_frame was allocated above and is no longer needed after scaling.
unsafe { ffi::av_frame_free(&mut sw_frame) };
// encode 阶段:把 YUV420P 帧送入 libx264/openh264 编码器;返回编码单帧耗时(微秒)。
// `?` 把 anyhow::Error 传播到调用者;`&mut encoder` & `&mut pts` 都是可变借用。
let encode_us = encode_yuv_frame(&mut encoder, &mut pts)?;
let total_us = frame_start.elapsed().as_micros() as u64;
// 把本帧的 5 个阶段耗时 push 到 Vec<u64>;后续 print_detailed_results 计算 avg_ms/p95。
stats.import_us.push(import_us);
stats.transfer_us.push(transfer_us);
stats.scale_us.push(scale_us);
@@ -596,6 +812,7 @@ fn run_cpu_pipeline(
stats.total_us.push(total_us);
stats.frames_encoded += 1;
// 节流打印:前 3 帧详打 + 之后每 30 帧打一次,避免日志淹没;`{:>4}` 右对齐 4 列宽。
if stats.frames_encoded <= 3 || stats.frames_encoded % 30 == 0 {
println!(
" CPU frame {:>4}/{frames}: import={:.2}ms transfer={:.2}ms scale={:.2}ms encode={:.2}ms total={:.2}ms",
@@ -609,11 +826,24 @@ fn run_cpu_pipeline(
}
}
// flush 编码器(送 NULL frame 触发 EOS+ write_trailer + 关闭输出文件。
// 任何失败经 `?` 传播到 main。
finish_encoder(encoder)?;
// as_secs_f64 把 Duration 转为秒(f64),用于后续 FPS 计算。
stats.elapsed_secs = total_start.elapsed().as_secs_f64();
Ok(stats)
}
// GPU 流水线主函数:跑 `frames` 帧测量 GPU 路径每阶段耗时(import → filter → transfer → format → encode)。
// 与 CPU 路径的核心差异:GPU 路径用 `scale_vaapi` 滤镜**在硬件内**把 4K BGRA 下采样到 2K NV12
// 再用 `av_hwframe_transfer_data` 把**小**NV12 帧拷贝到 CPU 内存(数据量 = 4K BGRA 的 1/6),
// 最后用 `sws_scale` 做 NV12→YUV420P 的**纯格式转换**(无尺寸变化,比 CPU 路径快得多)。
//
// 性能对比的关键:
// - CPU 路径 transfer ~33MB + scale 33MB→2MBGPU 路径 transfer ~3MB + format 仅 NV12→YUV420P。
// - 因此 GPU 路径的 transfer/format 总耗时远低于 CPU 路径的 transfer+scale。
//
// 参数:9 个(比 CPU 多一个 hw_dev 用于 filter graph);同样用 `#[allow]` 抑制 clippy。
#[allow(clippy::too_many_arguments)]
fn run_gpu_pipeline(
cap: &CapPortal,
@@ -626,7 +856,11 @@ fn run_gpu_pipeline(
enc_width: u32,
enc_height: u32,
) -> Result<FrameStats> {
// 同 CPU 路径:构造软件编码器(最终编码阶段仍是 CPU 上的 libx264/openh264)。
// 注意:本基准目标是测 import/scale 性能,**不**测 VAAPI 硬件编码;所以两条路径都用软件编码器。
let mut encoder = create_software_encoder(Path::new(output), enc_width, enc_height)?;
// 这里命名为 format_ctx 而非 sws_ctx —— 因为它只做 NV12→YUV420P 的**色度重排**(无下采样)。
// 源/目标尺寸相同(enc_width × enc_height),sws_scale 内部走 fast path(无缩放,仅 deinterleave)。
let format_ctx = create_sws_context(
enc_width,
enc_height,
@@ -634,6 +868,8 @@ fn run_gpu_pipeline(
enc_width,
enc_height,
)?;
// 构造 GPU 滤镜图:bufferin (hw) → scale_vaapi → bufferout (hw),详见 build_gpu_filter_graph。
// 滤镜图在 GPU 显存里完成下采样,输出仍是 VAAPI 硬件帧。
let mut graph = build_gpu_filter_graph(
hw_dev, frames_ctx, src_width, src_height, enc_width, enc_height,
)?;
@@ -654,6 +890,7 @@ fn run_gpu_pipeline(
let mut pts: i64 = 0;
while stats.frames_encoded < frames {
// 同 CPU 路径(详见 run_cpu_pipeline 的同位置注释)。
if let Ok(ctrl) = cap.event_receiver().try_recv() {
match ctrl {
PwCtrlEvent::StreamEnded => break,
@@ -687,23 +924,33 @@ fn run_gpu_pipeline(
};
let import_us = t_import.elapsed().as_micros() as u64;
// filter 阶段:把 VAAPI 4K 帧送入 scale_vaapi 滤镜图,取出 2K NV12 VAAPI 帧。
// 这是 GPU 路径相对 CPU 路径最大的性能优势所在。
let t_filter = Instant::now();
// graph.get("in").unwrap():按 name 取滤镜图的输入 pad;unwrap 在此是安全的(图刚构造必有 "in")。
let mut filter_src_ctx = graph.get("in").unwrap();
// source():从 pad 上下文获取发送端;后续 .add(&frame) 把帧送入图。
let mut filter_src = filter_src_ctx.source();
let mut filter_sink_ctx = graph.get("out").unwrap();
let mut filter_sink = filter_sink_ctx.sink();
// map_err 把 ffmpeg_next::Error 转换为 anyhow::Error(保持错误链可读)。
// anyhow::anyhow! 是宏,构造 ad-hoc 错误(类比 Go fmt.Errorf)。
filter_src
.add(&vaapi_frame)
.map_err(|e| anyhow::anyhow!("GPU filter source add failed: {e}"))?;
// ff::frame::Video::empty():构造一个空视频帧(无 buffer),后续 filter_sink.frame() 填充。
let mut filtered = ff::frame::Video::empty();
// 三路 match:成功 / EAGAIN(图未就绪,需要更多输入帧)/ 真错误。
match filter_sink.frame(&mut filtered) {
Ok(()) => {}
// EAGAIN 表示滤镜图内部缓冲不足,跳过本帧不报错(next iteration 继续喂下一帧)。
Err(ff::Error::Other { errno }) if errno == ffi::EAGAIN => continue,
Err(e) => bail!("GPU filter sink get frame failed: {e}"),
}
let filter_us = t_filter.elapsed().as_micros() as u64;
// transfer 阶段:把 2K NV12 硬件帧拷贝到 CPU 内存(数据量 = 2K NV12 ≈ 3MB,远小于 CPU 路径 33MB)。
let t_transfer = Instant::now();
// SAFETY: sw_nv12 is allocated by FFmpeg and freed after format conversion.
let mut sw_nv12 = unsafe { ffi::av_frame_alloc() };
@@ -724,6 +971,8 @@ fn run_gpu_pipeline(
}
let transfer_us = t_transfer.elapsed().as_micros() as u64;
// format 阶段:NV12 → YUV420P 纯格式转换(同尺寸无缩放)。
// NV12 与 YUV420P 的 Y plane 完全相同,只是 UV plane 排列不同(NV12 = interleavedYUV420P = planar)。
let t_format = Instant::now();
// SAFETY: sw_nv12 contains CPU-side NV12 at enc dimensions; encoder.yuv_frame is writable
// YUV420P at the same dimensions, so sws_scale performs only chroma deinterleave/format conversion.
@@ -746,6 +995,8 @@ fn run_gpu_pipeline(
let encode_us = encode_yuv_frame(&mut encoder, &mut pts)?;
let total_us = frame_start.elapsed().as_micros() as u64;
// GPU 路径的 stats 包含 6 个阶段(filter + format),CPU 路径只有 5 个(scale);
// FrameStats 的字段用 Option/空 Vec 区分。
stats.import_us.push(import_us);
stats.filter_us.push(filter_us);
stats.transfer_us.push(transfer_us);
@@ -773,6 +1024,9 @@ fn run_gpu_pipeline(
Ok(stats)
}
// 打印单条流水线的详细统计报告(捕获/编码分辨率、总时长、各阶段平均毫秒、FPS)。
// 纯展示函数:无 Result 返回值,无副作用(除 stdout),无错误路径。
// 类比 Go `func printResults(label string, stats *FrameStats, ...) { fmt.Println(...) }`。
fn print_detailed_results(
label: &str,
stats: &FrameStats,
@@ -781,20 +1035,25 @@ fn print_detailed_results(
enc_width: u32,
enc_height: u32,
) {
// println!() 无参数版本等价于 Go fmt.Println() —— 打印空行做视觉分隔。
println!();
println!("=== {label} Pipeline Results ===");
println!("Capture resolution: {}x{}", src_width, src_height);
println!("Encode resolution: {}x{}", enc_width, enc_height);
println!("Frames encoded: {}", stats.frames_encoded);
// {:.2} 保留 2 位小数;类比 Go fmt.Printf("%.2fs", v)。
println!("Total time: {:.2}s", stats.elapsed_secs);
println!("Output: {}", stats.output_path);
if stats.import_failures > 0 {
println!("Import failures: {}", stats.import_failures);
}
// FrameStats::avg_ms 是关联函数(不是 method),签名 `fn avg_ms(v: &[u64]) -> f64`。
// 类比 Go 顶层函数 `func avgMs(v []uint64) float64`Rust 关联函数等价于 Go 的 package-level。
println!(
"import avg: {:.2} ms/frame",
FrameStats::avg_ms(&stats.import_us)
);
// is_empty() 判断 Vec 是否为空;GPU 路径才有 filter_usCPU 路径此 Vec 永远空。
if !stats.filter_us.is_empty() {
println!(
"filter avg: {:.2} ms/frame",
@@ -805,12 +1064,14 @@ fn print_detailed_results(
"transfer avg: {:.2} ms/frame",
FrameStats::avg_ms(&stats.transfer_us)
);
// CPU 路径才有 scale_usGPU 路径此 Vec 永远空。
if !stats.scale_us.is_empty() {
println!(
"scale avg: {:.2} ms/frame",
FrameStats::avg_ms(&stats.scale_us)
);
}
// GPU 路径才有 format_usCPU 路径此 Vec 永远空。
if !stats.format_us.is_empty() {
println!(
"format avg: {:.2} ms/frame",
@@ -822,14 +1083,19 @@ fn print_detailed_results(
stats.codec_name,
FrameStats::avg_ms(&stats.encode_us)
);
// avg_total_ms / achieved_fps / theoretical_fps 都是 method&self 形式),调用语法 `stats.method()`。
println!("total avg: {:.2} ms/frame", stats.avg_total_ms());
println!("achieved FPS: {:.1}", stats.achieved_fps());
println!("max theoretical: {:.1} FPS", stats.theoretical_fps());
}
// 打印 CPU 与 GPU 流水线的对比摘要(一行 = 一条流水线),便于横向对比。
// 接收 Option<&FrameStats>:当基准只跑 CPU 或只跑 GPU 时,另一边为 None。
// 类比 Go `func printComparison(cpu, gpu *FrameStats)`Go 用 nil 表示缺失;Rust 用 Option<T> 强制处理。
fn print_comparison(cpu: Option<&FrameStats>, gpu: Option<&FrameStats>) {
println!();
println!("=== Pipeline Comparison ===");
// if let Some(s) = cpu:模式匹配 Option;只在 Some 时打印,None 静默跳过(不需要 else)。
if let Some(s) = cpu {
println!(
"CPU: import={:.2}ms transfer={:.2}ms scale={:.2}ms encode={:.2}ms total={:.2}ms ({:.1} FPS)",
@@ -855,7 +1121,16 @@ fn print_comparison(cpu: Option<&FrameStats>, gpu: Option<&FrameStats>) {
}
}
// 二进制入口点。Rust 标准签名 `fn main() -> Result<()>`:返回 Result 时失败会用 exit code 1 + Debug 打印错误。
// 类比 Go `func main() { err := run(); if err != nil { log.Fatal(err) } }` —— Rust 用 `?` 传播更简洁。
//
// 流程概览(3 个阶段,对应 println 中的 [1/3]/[2/3]/[3/3] 标号):
// 1. 通过 XDG Portal 请求屏幕捕获权限 → 拿到 PipeWire fd → 构造 CapPortal
// 2. 等待首帧 → 测试 av_hwframe_map 导入(若失败,回退 mmap 测试后退出)
// 3. 根据 --mode 跑 CPU/GPU/Both 流水线,输出详细统计 + 对比报告
fn main() -> Result<()> {
// clap::Parser::parse() 解析 std::env::args,匹配失败的会自动 exit code 1 + 打印 help。
// 类比 Go `flag.Parse()` + cobra.Struct,但 clap 用 Derive 宏更声明式。
let bench_args = BenchArgs::parse();
println!("=== VAAPI Import Benchmark ===");
@@ -868,11 +1143,15 @@ fn main() -> Result<()> {
println!("DRM device: {}", bench_args.drm_device);
println!();
// ff::init()FFmpeg 全局初始化(注册所有编解码器/滤镜/格式)。必须在所有 FFmpeg 调用前执行一次。
// 类比 Go 的 `import _ "image/jpeg"` 副作用导入;FFmpeg 5+ 改为运行时自动注册,但 init 仍推荐。
ff::init()?;
println!("[1/3] Requesting screen capture via XDG Portal...");
println!(" (Select a screen to share in the portal dialog)");
// 构造 Args(生产 CLI 类型)——本基准复用 wl-webrtc 主程序的 Args 结构以驱动 CapPortal。
// 大部分字段写死;只有 output 从 BenchArgs 透传。类比 Go `args := &wlwebrtc.Args{...}`。
let portal_args = Args {
output: Some(bench_args.output.clone()),
output_name: None,
@@ -881,6 +1160,7 @@ fn main() -> Result<()> {
hw_accel: "vaapi".to_string(),
drm_device: None,
bitrate: None,
max_bitrate: 8_000_000,
gop_size: None,
verbose: false,
backend: Some("portal".to_string()),
@@ -889,16 +1169,22 @@ fn main() -> Result<()> {
stats: false,
};
// CapPortal::new 启动 Portal 异步协商 + PipeWire 流;阻塞至用户在对话框点"允许"。
// 内部会启动 pipewire_thread 后台线程推帧到 frame_receiver 通道。
let cap = CapPortal::new(&portal_args)?;
println!("[1/3] Portal connected, PipeWire stream active\n");
println!("[2/3] Waiting for first frame from PipeWire...");
// 阻塞等首帧(带 30s 超时,详见 receive_first_frame 实现)。
let first_frame = receive_first_frame(&cap)?;
// 把 first_frame 的字段拷贝到局部变量;后续两条流水线都要用 src_width/src_height 做下采样。
// 注意:first_frame 必须 drop 之前不能让 import_dma_buf_to_vaapi 持有 fd 引用(所有权检查)。
let src_width = first_frame.width;
let src_height = first_frame.height;
let src_format = first_frame.format;
// 0x{:08X}8 位 16 进制(大写)前补 0 —— 用于打印 DRM 四字符码(ARGB8888 = 0x34325241)。
println!(
"[2/3] First frame: {}x{}, format=0x{:08X}, stride={}, modifier=0x{:X}",
src_width, src_height, src_format, first_frame.stride, first_frame.modifier
@@ -910,14 +1196,19 @@ fn main() -> Result<()> {
src_format
);
// 打开 DRM render node(默认 /dev/dri/renderD128),构造 VAAPI 硬件设备上下文。
// AvHwDevCtx 内部封装 AVBufferRefFFmpeg 引用计数),Drop 时自动释放。
let drm_device = Path::new(&bench_args.drm_device);
let hw_dev = AvHwDevCtx::new_vaapi(drm_device)?;
println!(" VAAPI device context created OK");
// 构造硬件帧上下文:绑定设备 + sw_format=BGRA + 源尺寸;scale_vaapi 滤镜需要此 ctx。
let frames_ctx =
AvHwFrameCtx::for_capture(&hw_dev, src_width, src_height, ff::format::Pixel::BGRA)?;
println!(" VAAPI frames context created OK (sw_format=BGRA)");
// 首帧导入测试:unsafe 块因为 import_dma_buf_to_vaapi 是 raw FFIav_hwframe_map + AVDRMFrameDescriptor)。
// 此处 unsafe 块**未写** SAFETY 标记,因为 import_dma_buf_to_vaapi 自身在 src/avhw.rs 内部已有详尽 SAFETY 注释。
let vaapi_frame = unsafe {
import_dma_buf_to_vaapi(
frames_ctx.as_ptr(),
@@ -931,11 +1222,13 @@ fn main() -> Result<()> {
)
};
// 用 match 处理 Result;分支内提前 return Ok(()) 表示"基准结束但不报错"(非失败路径)。
match &vaapi_frame {
Ok(_) => {
println!(" Result: SUCCESS — av_hwframe_map imported DMA-BUF to VAAPI surface!");
}
Err(e) => {
// 失败路径:诊断 + mmap 对照测试 + 友好退出(不返回 Err)。
println!(" Result: FAILED");
println!(" Error: {e}");
println!();
@@ -946,8 +1239,10 @@ fn main() -> Result<()> {
println!();
println!(" Falling back to mmap readback test for comparison...");
// mmap 对照:如果 av_hwframe_map 失败,看 mmap 是否也失败(区分根因:driver vs 配置)。
let mmap_size = (first_frame.stride as usize) * (first_frame.height as usize);
let mmap_start = Instant::now();
// unsafelibc::mmap 是 POSIX FFI,返回 void*MAP_FAILED (== -1) 表示失败。
let mmap_ptr = unsafe {
libc::mmap(
ptr::null_mut(),
@@ -961,6 +1256,7 @@ fn main() -> Result<()> {
let mmap_elapsed = mmap_start.elapsed();
if mmap_ptr == libc::MAP_FAILED {
// last_os_error():取 errno;类比 Go syscall.Errno。
let errno = std::io::Error::last_os_error();
println!(" mmap also FAILED: {errno}");
} else {
@@ -969,6 +1265,7 @@ fn main() -> Result<()> {
mmap_size as f64 / 1024.0 / 1024.0,
mmap_elapsed.as_secs_f64() * 1000.0
);
// 必须配对 munmap,否则内核 VMA 泄漏。
unsafe {
libc::munmap(mmap_ptr, mmap_size);
}
@@ -977,10 +1274,13 @@ fn main() -> Result<()> {
println!();
println!("=== Benchmark ended: av_hwframe_map import FAILED ===");
println!("Fix the import issue before proceeding to GPU downscale tests.");
// 主动 Ok(()):基准本身没崩,只是诊断后退出;让 CI 不报红。
return Ok(());
}
}
// 导入成功后释放首帧资源(vaapi_frame 持有硬件帧引用,first_frame 持有 fd);
// 后续主循环每帧重新 import,避免长持有造成硬件帧饥饿。
drop(vaapi_frame);
drop(first_frame);
@@ -988,12 +1288,19 @@ fn main() -> Result<()> {
let enc_width = bench_args.enc_width;
let enc_height = bench_args.enc_height;
// PipelineMode::Both 时输出文件名加 cpu/gpu 后缀(详见 output_for_mode)。
let split_outputs = bench_args.mode == PipelineMode::Both;
// 用 Option 包裹:mode 只跑 CPU 时 gpu_stats 永远 Noneprint_detailed_results/print_comparison 接 Option。
let mut cpu_stats = None;
let mut gpu_stats = None;
// matches! 宏:等价于 `match bench_args.mode { PipelineMode::Cpu | PipelineMode::Both => true, _ => false }`
// 但语法更紧凑(无臂返回值);类比 Go `switch mode { case Cpu, Both: ... }`。
if matches!(bench_args.mode, PipelineMode::Cpu | PipelineMode::Both) {
let output = output_for_mode(&bench_args.output, PipelineMode::Cpu, split_outputs);
// Some(...) 把 Result<FrameStats> 包成 Option<Result<FrameStats>>,再 ? 解开 Result;最终 cpu_stats = Option<FrameStats>。
// 注意 `?` 在 Option 上下文也工作(需要 main 返回 Option,但这里 main 返回 Result,所以 ? 只对 Result 起作用,
// Some(...) 是显式包装,里面的 run_cpu_pipeline()? 把 Err 传到 main)。
cpu_stats = Some(run_cpu_pipeline(
&cap,
&frames_ctx,
@@ -1021,6 +1328,7 @@ fn main() -> Result<()> {
)?);
}
// as_ref():把 &Option<T> 借用(避免消耗 T);print_detailed_results 接收 &FrameStats。
if let Some(stats) = cpu_stats.as_ref() {
print_detailed_results("CPU", stats, src_width, src_height, enc_width, enc_height);
}
@@ -1029,6 +1337,9 @@ fn main() -> Result<()> {
}
print_comparison(cpu_stats.as_ref(), gpu_stats.as_ref());
// 迭代器链:把两个 Option 串成统一迭代器,.any() 短路检查是否有任何一条流水线低于 30 FPS。
// Option::into_iter():把 Option<T> 转为 0/1 元素迭代器;chain 把两段接起来。
// 类比 Go`var all []*FrameStats; if cpu != nil { all = append(all, cpu) }; for _, s := range all { if s.FPS < 30 {...} }`。
if cpu_stats
.as_ref()
.into_iter()
+395 -17
View File
@@ -1,3 +1,23 @@
//! XDG Desktop Portal + PipeWire 截屏后端。
//!
//! 本模块实现 `CaptureBackend::PortalPipeWire` 路径:通过 XDG Portal 的
//! ScreenCast 接口请求用户授权,拿到 PipeWire 远程 fd 与 node_id 后,在专用
//! 线程里跑 PipeWire 事件循环接收 DMA-BUF 帧。
//!
//! 关键设计:
//! - 使用 `ashpd` crate 走 XDG Portal 协议(高层 Rust 绑定,封装 D-Bus 调用)。
//! - `CapPortal` 在用户 cache 目录(`wl-webrtc/portal-restore-token`)缓存 Portal
//! restore token,下次启动可跳过用户授权对话框(token 有效时)。
//! - `--no-persist` 标志:跳过 restore token 读写,每次启动都弹授权对话框;测试
//! fresh authorization 时使用。
//! - 与 `backend_detect.rs` 的差异:检测阶段刻意用 raw `zbus` 避免 `ashpd` 缓存
//! `zbus::Connection` 到全局 OnceLockruntime drop 后变僵尸 connection)。本
//! 模块只在 Portal 路径使用 `ashpd`,且 Tokio runtime 由 `CapPortal` 自己拥有
//! `rt` 字段),生命周期与 `CapPortal` 一致,无跨实例复用问题。
//!
//! 分阶段超时(git 68a6eec):`Service`(无用户交互,5s)与 `TokenDependent`
//! (可能弹对话框,30s)两类,前者直接失败、后者清 token 后重试一次。
// cap_portal.rs — 通过 XDG Desktop Portal 的 ScreenCast 接口捕获屏幕帧
//
// 整体架构:
@@ -23,6 +43,54 @@ use tokio::runtime::Runtime;
use crate::args::Args;
/// Portal phase timeout when no user interaction is expected (proxy/session
/// creation, token-path select/start, PipeWire fd). 5s is generous for
/// healthy xdg-desktop-portal (<500ms typical) but bounded for fast failure.
const PORTAL_SERVICE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
/// Portal phase timeout when user must click "Allow" in desktop dialog
/// (select/start without restore token). 30s gives time to find the dialog.
const PORTAL_USER_DIALOG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// Classification of Portal phase timeouts to drive retry behavior.
#[derive(Debug)]
enum PortalPhaseTimeout {
/// Portal service unresponsive; not retried (user should restart service).
Service,
/// Timed out in token-dependent phase; retried once after clearing token.
TokenDependent,
}
impl std::fmt::Display for PortalPhaseTimeout {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Service => write!(f, "Portal phase timed out (service)"),
Self::TokenDependent => write!(f, "Portal phase timed out (token-dependent)"),
}
}
}
impl std::error::Error for PortalPhaseTimeout {}
/// Log an actionable diagnostic when a Portal phase times out.
///
/// Mirrors the message format from `backend_detect.rs::log_portal_unresponsive`
/// but additionally suggests `--no-persist` when the timeout occurred in a
/// phase that was using a restore token.
fn log_portal_phase_timeout(phase: &str, used_restore_token: bool) {
let persist_hint = if used_restore_token {
" If this recurs, try: wl-webrtc --no-persist"
} else {
""
};
tracing::error!(
"Portal service did not respond within timeout while {phase}. \
This usually means xdg-desktop-portal or xdg-desktop-portal-kde is stuck. \
Try: systemctl --user restart xdg-desktop-portal xdg-desktop-portal-kde, \
then re-run wl-webrtc.{persist_hint}"
);
}
/// PipeWire DMA-BUF 帧数据
///
/// 表示从 PipeWire 流中接收到的一帧视频数据。
@@ -110,6 +178,10 @@ impl CapPortal {
let (frame_tx, frame_rx) = bounded(1);
let (event_tx, event_rx) = bounded(8);
// 创建 eventfd 对(Linux 特有的进程内事件通知机制)。
// EFD_CLOEXEC: exec() 时自动关闭 fd,避免泄露给子进程。
// EFD_NONBLOCK: 读取时非阻塞,配合 epoll/poll 使用。
// unsafe: libc::eventfd 是 C FFI,返回值 < 0 表示 errno 错误。
let efd = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC | libc::EFD_NONBLOCK) };
if efd < 0 {
return Err(anyhow::anyhow!(
@@ -117,36 +189,54 @@ impl CapPortal {
std::io::Error::last_os_error()
));
}
// 复制 fd 得到独立的两端(读端 efd 给 PipeWire 线程,写端 write_fd 留给 Drop)。
// dup 返回的是新的 fd(最小可用整数),与原 fd 共享同一打开文件描述。
// unsafe: libc::dup 是 C FFI< 0 表示失败;失败时必须 close 原来的 efd 防止泄露。
let write_fd = unsafe { libc::dup(efd) };
if write_fd < 0 {
let err = std::io::Error::last_os_error();
// unsafe: 清理已分配但 dup 失败的 efd,避免 fd 泄漏。
unsafe { libc::close(efd) };
return Err(anyhow::anyhow!("dup eventfd failed: {err}"));
}
// Arc<AtomicU64> 跨线程共享的丢弃计数器(Arc 提供线程安全引用计数,
// 类似 Go 的 sync/atomic.Value 但带引用语义)。PipeWire 线程在 channel
// 满导致丢帧时原子递增它,主线程通过 dropped_count() 读取统计。
// Ordering::Relaxed:仅用于统计,不需要跨线程内存顺序保证。
let pw_dropped = Arc::new(AtomicU64::new(0));
// PwThreadCtx 聚合所有要 move 进 PipeWire 线程的资源。
// shutdown_read / pw_fd 用 OwnedFd 包装(Drop 时自动 close),
// 这避免手动管理 fd 生命周期。frame_tx / event_tx 是 crossbeam
// channel 的发送端(多生产者单消费者,Clone + Send)。
let ctx = PwThreadCtx {
frame_tx,
event_tx,
dropped: pw_dropped.clone(),
// unsafe: OwnedFd::from_raw_fd 接管 efd 的所有权(保证 RAII 关闭)。
// 之前 libc::eventfd 返回的 efd 没有 Owner,必须用 from_raw_fd 包一下。
shutdown_read: unsafe { OwnedFd::from_raw_fd(efd) },
pw_fd,
node_id,
fps: args.fps,
};
// thread::Builder 模式:name 给线程命名(便于调试/top 显示),spawn 启动。
// move || 闭包获取 ctx 所有权(不捕获引用),保证线程自带所有数据。
let pw_thread = thread::Builder::new()
.name("pipewire-capture".into())
.spawn(move || {
pipewire_thread(ctx);
})
.map_err(|e| {
// unsafe: spawn 失败时清理 write_fd 防止泄漏。
unsafe { libc::close(write_fd) };
anyhow::anyhow!("thread spawn failed: {e}")
})?;
Ok(Self {
// unsafe: from_raw_fd 接管 write_fd 的所有权,由 CapPortal::Drop 关闭。
shutdown_fd: unsafe { OwnedFd::from_raw_fd(write_fd) },
frame_rx,
event_rx,
@@ -185,61 +275,192 @@ impl CapPortal {
/// 5. 打开 PipeWire 远程连接,获取文件描述符
///
/// 返回 (PipeWire fd, node_id),供 PipeWire 线程连接使用
///
/// Wraps `_setup_portal_inner` with token-aware retry: on a `TokenDependent`
/// timeout (phases 3 or 4 with a restore token in use) AND `no_persist ==
/// false`, clears the cached restore token and retries once with
/// `no_persist = true`.
async fn setup_portal(no_persist: bool) -> Result<(OwnedFd, u32)> {
// 首次尝试:使用缓存的 restore token(若存在且 no_persist=false)。
// _setup_portal_inner 内部根据 phase 失败分类返回 PortalPhaseTimeout。
match Self::_setup_portal_inner(no_persist, false).await {
Ok(result) => Ok(result),
// 通过 anyhow::Error 的 downcast 机制判断内层错误是否为 PortalPhaseTimeout。
// anyhow 包装动态类型错误,e.is::<T>() 检查,downcast_ref::<T>() 取引用。
Err(e) if e.is::<PortalPhaseTimeout>() => {
let inner_err = e.downcast_ref::<PortalPhaseTimeout>().unwrap();
match inner_err {
// 仅当 token-dependent phase 超时且原本允许 persist 时才重试。
// 重试策略:删除缓存的 token,强制 fresh authorization。
PortalPhaseTimeout::TokenDependent if !no_persist => {
tracing::warn!(
"Portal timed out during token-using phase. \
Clearing cached restore token and retrying with fresh authorization."
);
delete_restore_token();
// is_retry=true 阻止 _setup_portal_inner 再次进入重试分支
// (最多重试一次,避免无限循环)。
Self::_setup_portal_inner(true, true).await
}
_ => Err(e),
}
}
Err(e) => Err(e),
}
}
/// Inner Portal setup with phased timeouts. See `setup_portal` for the
/// retry wrapper.
///
/// `is_retry == true` disables further retry attempts (max 1 retry).
async fn _setup_portal_inner(
no_persist: bool,
is_retry: bool,
) -> Result<(OwnedFd, u32)> {
// 函数内部 use:把 ashpd 子模块导入局部作用域(限制作用域避免污染整个文件)。
// CursorMode / SourceType / PersistMode 是 ashpd 提供的枚举,对应 Portal 协议字段。
use ashpd::desktop::screencast::{
CursorMode, Screencast, SelectSourcesOptions, SourceType,
};
use ashpd::desktop::PersistMode;
let proxy = Screencast::new()
.await
.map_err(|e| anyhow::anyhow!("Failed to create Screencast proxy: {e}"))?;
// Phase 1: Screencast proxy (no user interaction).
// D-Bus 代理对象,对应 XDG Portal ScreenCast 接口。
// tokio::time::timeout(dur, fut) 包装一个 future,超过 dur 返回 Err(Elapsed)。
// 返回 Result<Result<T, ashpd::Error>, Elapsed>,外层是 timeout,内层是 Portal 调用。
// 三路 matchOk(Ok) 成功 / Ok(Err) Portal 报错 / Err(_) 超时。
let proxy = match tokio::time::timeout(PORTAL_SERVICE_TIMEOUT, Screencast::new()).await {
Ok(Ok(p)) => p,
Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to create Screencast proxy: {e}")),
Err(_) => {
log_portal_phase_timeout("creating Screencast proxy", false);
// .into() 把 PortalPhaseTimeout 转换为 anyhow::Errordyn Error trait object)。
return Err(PortalPhaseTimeout::Service.into());
}
};
let session = proxy
.create_session(Default::default())
// Phase 2: create_session (no user interaction).
// 建立 Portal 会话令牌(不是 PipeWire 会话),用于后续 select_sources 引用。
// Default::default() 揆 SessionOptions 是空 struct(用 trait 接口设置非默认值时显式构造)。
let session = match tokio::time::timeout(
PORTAL_SERVICE_TIMEOUT,
proxy.create_session(Default::default()),
)
.await
.map_err(|e| anyhow::anyhow!("Failed to create ScreenCast session: {e}"))?;
{
Ok(Ok(s)) => s,
Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to create ScreenCast session: {e}")),
Err(_) => {
log_portal_phase_timeout("creating session", false);
return Err(PortalPhaseTimeout::Service.into());
}
};
// Portal 协议版本 ≥4 才支持 persist_mode 与 restore_token。
// version 由 Screencast proxy 在 D-Bus 属性中暴露。
let version_supported = proxy.version() >= 4;
// 决定 persist_mode 与已缓存的 token
// - no_persist=true 或版本不支持 → PersistMode::DoNot,不读 token。
// - 否则 → PersistMode::ExplicitlyRevoked(显式可撤销,配合 token 重用)。
let (persist_mode, saved_token) = if !no_persist && version_supported {
let token = load_restore_token();
if token.is_some() {
if is_retry {
tracing::info!("Re-attempting portal session after token clear");
} else {
tracing::info!("Attempting to restore portal session with saved token");
}
}
(PersistMode::ExplicitlyRevoked, token)
} else {
(PersistMode::DoNot, None)
};
// Builder 模式链式调用:每个 set_X 返回新的 SelectSourcesOptions(按值消费 self)。
// CursorMode::Embedded:光标烧录进帧(不是单独的鼠标位置流)。
// BitFlags::from(SourceType::Monitor):仅捕获整个显示器(不捕获窗口)。
// set_multiple(false):单流(不开启多显示器拼接)。
let mut options = SelectSourcesOptions::default()
.set_cursor_mode(CursorMode::Embedded)
.set_sources(ashpd::enumflags2::BitFlags::from(SourceType::Monitor))
.set_multiple(false)
.set_persist_mode(persist_mode);
// 若有缓存的 token,附加到 options 实现免对话框恢复。
// if let Some(ref token) 模式:ref 关键字避免 move token(仅借用字符串引用)。
if let Some(ref token) = saved_token {
options = options.set_restore_token(token.as_str());
}
// Phase 3: select_sources — token path is fast (no dialog); fresh
// authorization may pop a dialog.
// 双超时策略:token_in_use=true 时无对话框(5s service timeout),
// false 时用户需要点 Allow30s user-dialog timeout)。
let token_in_use = saved_token.is_some();
let phase3_timeout = if token_in_use {
PORTAL_SERVICE_TIMEOUT
} else {
PORTAL_USER_DIALOG_TIMEOUT
};
match tokio::time::timeout(phase3_timeout, proxy.select_sources(&session, options)).await {
Ok(Ok(_)) => {}
Ok(Err(e)) => return Err(anyhow::anyhow!("Screen sharing permission denied: {e}")),
Err(_) => {
log_portal_phase_timeout("selecting sources", token_in_use);
// 按 token_in_use 分流错误类型,setup_portal 仅对 TokenDependent 重试。
return Err(
if token_in_use {
PortalPhaseTimeout::TokenDependent
} else {
PortalPhaseTimeout::Service
}
.into(),
);
}
}
// Phase 4: start + response — same dialog-vs-token reasoning as phase 3.
// start 返回一个 futureresponse 解析 PortalDbus 返回值。
// 这里把两个 await 串起来放进 async 块,整体受 phase4_timeout 包裹。
let phase4_timeout = if token_in_use {
PORTAL_SERVICE_TIMEOUT
} else {
PORTAL_USER_DIALOG_TIMEOUT
};
// 内部 async 块:把 start + response 组成单一 future,便于 timeout 包装。
// ? 在 async 块里传播 ashpd::Error,外层 match 处理。
let start_fut = async {
proxy
.select_sources(&session, options)
.await
.map_err(|e| anyhow::anyhow!("Screen sharing permission denied: {e}"))?;
let response = proxy
.start(&session, None, Default::default())
.await
.map_err(|e| anyhow::anyhow!("ScreenCast start failed: {e}"))?
.await?
.response()
.map_err(|e| anyhow::anyhow!("ScreenCast response error: {e}"))?;
};
let response = match tokio::time::timeout(phase4_timeout, start_fut).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => return Err(anyhow::anyhow!("ScreenCast start/response error: {e}")),
Err(_) => {
log_portal_phase_timeout("starting session", token_in_use);
return Err(
if token_in_use {
PortalPhaseTimeout::TokenDependent
} else {
PortalPhaseTimeout::Service
}
.into(),
);
}
};
// 持久化新颁发的 restore tokenPortal 可能返回与之前不同的 token)。
if !no_persist && version_supported {
if let Some(new_token) = response.restore_token() {
save_restore_token(new_token);
}
}
// 假设单流(set_multiple(false)):first().ok_or_else 把 None 转 Error。
// ok_or_else 闭包延迟构造错误字符串,比 ok_or 节省开销。
let stream = response
.streams()
.first()
@@ -247,10 +468,22 @@ impl CapPortal {
let node_id = stream.pipe_wire_node_id();
let fd = proxy
.open_pipe_wire_remote(&session, Default::default())
// Phase 5: open_pipe_wire_remote (no user interaction).
// 请求 PipeWire 服务端 fd。返回的 OwnedFd 是 Portal 通过 D-Bus fd-passing
// 传过来的 PipeWire socketPipeWire 线程用它连接到 compositor 的 PipeWire 实例。
let fd = match tokio::time::timeout(
PORTAL_SERVICE_TIMEOUT,
proxy.open_pipe_wire_remote(&session, Default::default()),
)
.await
.map_err(|e| anyhow::anyhow!("Failed to open PipeWire remote: {e}"))?;
{
Ok(Ok(f)) => f,
Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to open PipeWire remote: {e}")),
Err(_) => {
log_portal_phase_timeout("opening PipeWire remote", false);
return Err(PortalPhaseTimeout::Service.into());
}
};
tracing::info!("Portal session established: node_id={node_id}");
@@ -258,17 +491,32 @@ impl CapPortal {
}
}
/// 计算 Portal restore token 的持久化路径(用户 cache 目录下 `wl-webrtc/portal-restore-token`)。
///
/// 返回 `Option<PathBuf>` 因为某些系统无合法 cache 目录(如 `$XDG_CACHE_HOME` 未设置
/// 且无 HOME),此时返回 None,调用方应跳过 token 持久化。
///
/// 路径布局:`$XDG_CACHE_HOME/wl-webrtc/portal-restore-token` 或 `~/.cache/wl-webrtc/portal-restore-token`。
fn token_path() -> Option<PathBuf> {
// dirs::cache_dir() 返回 Option<PathBuf>(无 cache 目录时为 None)。
// .map(|base| base.join("wl-webrtc").join("portal-restore-token"))
// 类似 Go 的 filepath.Join,跨平台路径拼接。
dirs::cache_dir().map(|base| base.join("wl-webrtc").join("portal-restore-token"))
}
/// Verify that `path` is a directory owned by the current user with no group/other permissions.
/// Rejects symlinks at the path itself (but allows the resolved target to be a real dir).
fn verify_secure_dir(path: &std::path::Path) -> bool {
// use 内导入 unix-only trait 扩展(Linux 特有的 stat/mode 字段)。
// 这些 trait 让 std::fs::Metadata 暴露 .uid()/.gid()/.mode() 等 Unix 字段。
use std::os::unix::fs::{MetadataExt, PermissionsExt};
// symlink_metadata 不跟随符号链接(lstat),暴露链接本身的信息。
// 这是安全关键:若用 metadata()(跟随 symlink),攻击者可挂个 symlink 到任意目录
// 让我们以为权限正确(实际指向 /etc 之类)。
match std::fs::symlink_metadata(path) {
Ok(meta) => {
// 第一道防线:拒绝任何 symlink,即使权限看起来正确。
if meta.file_type().is_symlink() {
tracing::warn!(
"Token parent dir is a symlink, rejecting: {}",
@@ -282,6 +530,8 @@ fn verify_secure_dir(path: &std::path::Path) -> bool {
return false;
}
// Must be owned by current user
// unsafe: libc::getuid 是 C FFI;它实际是安全操作(无失败模式),
// 标 unsafe 仅因 Rust 未对其建模。返回当前进程的 real UID。
if meta.uid() != unsafe { libc::getuid() } {
tracing::warn!(
"Token parent dir not owned by current user: {}",
@@ -290,6 +540,8 @@ fn verify_secure_dir(path: &std::path::Path) -> bool {
return false;
}
// No group or other permissions (mode must be 0o700 exactly within the 0o777 mask)
// mode & 0o777:剥离文件类型位(st_mode 高位),只保留 rwx 权限位。
// 要求严格 0o700owner rwxgroup 与 other 全无(防止其他用户读 token)。
let mode = meta.permissions().mode() & 0o777;
if mode != 0o700 {
tracing::warn!(
@@ -311,12 +563,14 @@ fn verify_secure_dir(path: &std::path::Path) -> bool {
/// Ensure the parent directory exists with restrictive permissions (0o700).
/// Returns false if the directory could not be created or is insecure.
fn ensure_secure_parent(parent: &std::path::Path) -> bool {
// DirBuilderExt 扩展 DirBuilder::mode()Unix-only),OpenOptionsExt 用于后续步骤。
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
if parent.exists() {
// Directory exists — try to tighten permissions, then verify.
// set_permissions follows symlinks, which is fine here since
// we verify with symlink_metadata in verify_secure_dir.
// 收紧模式:把已存在目录强行改为 0700,然后 verify_secure_dir 校验最终状态。
if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) {
tracing::warn!("Failed to set directory permissions: {e}");
return false;
@@ -325,6 +579,8 @@ fn ensure_secure_parent(parent: &std::path::Path) -> bool {
}
// Create with restrictive mode — DirBuilderExt::mode bypasses umask.
// 关键:标准 create_dir 受 umask 影响(如 022 → 实际 0755)。
// DirBuilderExt::mode(0o700) 直接设置 inode mode,绕过 umask,保证 0700。
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
builder.mode(0o700);
@@ -334,18 +590,35 @@ fn ensure_secure_parent(parent: &std::path::Path) -> bool {
}
// Verify after creation (belt-and-suspenders)
// 双保险:再 verify 一次,防止 create 与 set_mode 之间被 TOCTOU 篡改。
verify_secure_dir(parent)
}
/// 加载已缓存的 Portal restore token(默认路径)。
///
/// 无 token 文件、文件不可读、权限不合规等情况均返回 None(不报错)。
/// 失败原因由 tracing::warn! 记录,便于排查。
fn load_restore_token() -> Option<String> {
// ? 在 Option 上传播:token_path() 返回 None 时直接 return None。
load_restore_token_from(token_path()?)
}
/// 从指定路径加载 token,附带严格的安全校验。
///
/// 校验规则(任一不满足返回 None):
/// 1. 必须是 regular file(拒绝 directory / fifo / socket
/// 2. 不能是 symlink(防 symlink attack
/// 3. owner 必须是当前用户
/// 4. group/other 不可读写(mode & 0o077 == 0
///
/// 这些校验防止攻击者通过预创建文件或符号链接窃取 token。
fn load_restore_token_from(path: PathBuf) -> Option<String> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
// symlink_metadatalstat,不跟随 symlink(防攻击者指向 /etc/shadow 等敏感文件)。
let meta = match std::fs::symlink_metadata(&path) {
Ok(m) => m,
// 文件不存在或不可访问:静默 None(首次启动无 token 是正常情况)。
Err(_) => return None,
};
@@ -360,10 +633,14 @@ fn load_restore_token_from(path: PathBuf) -> Option<String> {
tracing::warn!("Token path is not a regular file: {}", path.display());
return None;
}
// unsafe: libc::getuid 标 unsafe 仅因 Rust 未建模;实际无失败模式。
// 比较 st_uid 与当前 real UID,防止其他用户写入的 token 被误用。
if meta.uid() != unsafe { libc::getuid() } {
tracing::warn!("Token file not owned by current user: {}", path.display());
return None;
}
// 检查 group/other 任何 r/w/x 位(mode & 0o077 != 0)→ 拒绝。
// 允许 owner 任意位(0o700 / 0o600 / 0o400 等都 OK)。
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
tracing::warn!(
@@ -374,6 +651,9 @@ fn load_restore_token_from(path: PathBuf) -> Option<String> {
return None;
}
// .ok()? :把 std::io::Result<String> 转 Option<String>Err 变 None。
// 然后 trim 去掉首尾空白(Portal 返回的 token 可能带换行)。
// 若 trim 后为空字符串,返回 None(视为无 token)。
let token = std::fs::read_to_string(&path).ok()?;
let trimmed = token.trim().to_string();
if trimmed.is_empty() {
@@ -383,7 +663,13 @@ fn load_restore_token_from(path: PathBuf) -> Option<String> {
}
}
/// 保存 Portal 颁发的 restore token 到默认 cache 路径。
///
/// 失败(无 cache 目录、目录权限不合规、磁盘满等)不返回错误,
/// 仅 tracing::warn!,下次启动会重新走对话框授权流程。
fn save_restore_token(token: &str) {
// let-else 模式(Rust 1.65+):let Some(x) = ... else { return; }。
// 无 cache 目录时早退,避免后续无谓 IO。
let Some(path) = token_path() else {
tracing::warn!("No secure cache directory available, skipping token save");
return;
@@ -391,11 +677,38 @@ fn save_restore_token(token: &str) {
save_restore_token_to(token, &path);
}
/// 删除已缓存的 restore token(用于 token 失效或用户重新授权)。
///
/// 文件不存在视为已删除(幂等),其他错误仅 warn 不传播。
fn delete_restore_token() {
// let-else 早退模式(与 save_restore_token 一致)。
let Some(path) = token_path() else {
return;
};
// match std::io::ErrorKind::NotFound 是 Rust 错误分类的常用模式。
// 幂等:文件已删除也视为成功,不报警告(避免日志噪音)。
match std::fs::remove_file(&path) {
Ok(()) => tracing::info!("Deleted stale portal restore token at {}", path.display()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!("Failed to delete stale restore token at {}: {e}", path.display()),
}
}
/// 把 token 原子写入指定路径(temp file + rename 模式)。
///
/// 原子性:通过临时文件 + rename(2) 实现,确保读到完整 token 或读到旧 token
/// 永远不会读到部分写入。这是 Linux/Unix 文件系统 rename 的保证。
///
/// 安全性:
/// - 父目录必须 0o700 且 owner = current userensure_secure_parent 校验)
/// - temp file 用 create_new + mode 0o600(不覆盖现有文件,不跟随 symlink)
/// - rename 是原子操作,但仅在同 filesystem 下保证
fn save_restore_token_to(token: &str, path: &std::path::Path) {
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
// path.parent() 返回 Option<&Path>root 路径无 parent)。
let Some(parent) = path.parent() else {
tracing::warn!("Token path has no parent directory");
return;
@@ -409,21 +722,31 @@ fn save_restore_token_to(token: &str, path: &std::path::Path) {
// Use a unique temp file to prevent symlink attacks.
// create_new(true) guarantees exclusive creation — fails if file already exists,
// and does NOT follow existing symlinks.
// temp 文件名带 PID 防并发:多个 wl-webrtc 实例同时运行不会互相覆盖 temp。
let tmp_path = path.with_extension(format!("{}.tmp", std::process::id()));
// IIFE (immediately-invoked closure) 把多步 IO 组合成单一 Result。
// ? 在闭包内传播 std::io::Error,外层统一 match 处理。
let result = (|| -> std::io::Result<()> {
// OpenOptions builderwrite + create_new = O_WRONLY | O_CREAT | O_EXCL。
// mode(0o600)owner rwgroup/other 无权限(绕过 umask)。
let mut f = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp_path)?;
f.write_all(token.as_bytes())?;
// sync_allfsync(2),把数据 flush 到磁盘(防系统崩溃丢数据)。
// 必须 fsync 之后 rename,否则崩溃后可能 token 文件存在但内容为空。
f.sync_all()?;
// rename(2):原子替换。Linux 同 filesystem 下原子保证。
std::fs::rename(&tmp_path, path)?;
Ok(())
})();
match result {
Ok(()) => tracing::info!("Saved portal restore token"),
Err(e) => {
// 失败时清理 temp(避免遗留垃圾文件)。
// let _ = 显式忽略 remove_file 的错误(temp 可能已不存在)。
let _ = std::fs::remove_file(&tmp_path);
tracing::warn!("Failed to save restore token: {e}");
}
@@ -441,7 +764,14 @@ impl Drop for CapPortal {
fn drop(&mut self) {
// Signal the PipeWire loop to quit via eventfd.
// eventfd write is a kernel syscall — thread-safe and lock-free.
// 写入 8 字节(u64)到 eventfdPipeWire 线程 epoll_wait 立即返回。
// val=1 是任意非零值(PipeWire 线程只关心"可读"事件,不读具体值)。
let val: u64 = 1u64;
// unsafe: libc::write 是 C FFI。签名:write(fd, buf, count) → ssize_t。
// - self.shutdown_fd.as_raw_fd():取出 OwnedFd 内部的 raw int fd。
// - &val as *const u64 as *const _:把 Rust 引用强转成 *const c_void。
// - std::mem::size_of::<u64>()8 字节(eventfd 必须写 8 字节)。
// 返回值是写入字节数或 -1(错误),用 let _ = 忽略(Drop 不能 panic)。
let _ = unsafe {
libc::write(
self.shutdown_fd.as_raw_fd(),
@@ -452,6 +782,10 @@ impl Drop for CapPortal {
// 等待 PipeWire 线程完全退出
// 这确保 PipeWire 资源在线程中被正确清理后,主线程才继续
// Option::take():把 Option<JoinHandle> 里的值 move 出来,留下 None。
// 之后 CapPortal 自身的字段访问(如 Drop 结束)不会重复 join。
// handle.join():阻塞当前线程直到目标线程退出。返回 Result(线程 panic 时 Err)。
// let _ = 忽略 panic 错误(Drop 中无法恢复)。
if let Some(handle) = self.pw_thread.take() {
let _ = handle.join();
}
@@ -498,6 +832,13 @@ fn pipewire_thread(ctx: PwThreadCtx) {
fps,
} = ctx;
// PipeWire 三件套初始化(典型 PW 客户端架构):
// MainLoop —— 事件循环(epoll 后端),所有回调都在此线程派发。
// Context —— 加载 PW 模块、管理代理对象的上下文,挂在 MainLoop 上。
// Core —— 与 PipeWire daemon 的连接(此处用 connect_fd 走 Portal
// 下发的 socket fd 而非默认的 `pipewire-0`)。
// 任一初始化失败都通过 event_tx 上报 PwCtrlEvent::Error 并退出本线程,
// 让主线程的 select 报告具体阶段错误。
let mainloop = match pw::main_loop::MainLoopBox::new(None) {
Ok(ml) => ml,
Err(e) => {
@@ -559,8 +900,17 @@ fn pipewire_thread(ctx: PwThreadCtx) {
}
};
// 共享的可变格式信息容器:Rc<Cell<Option<(w, h, drm_fmt, modifier)>>>。
// - Rc 单线程引用计数(PipeWire 回调全在同一线程),类比 Go 中"通过指针
// 共享的可变全局变量"但带编译期 Send 约束。
// - Cell<Option<...>> 提供内部可变性(无需 Mutex),通过 .get()/.set()
// 整体替换值——比 RefCell 更轻,因为这里值是 Copy 的元组。
// - 类比 Go: var formatInfo = *(u32,u32,u32,u64) // 取地址 + atomic 赋值。
let format_info: Rc<Cell<Option<(u32, u32, u32, u64)>>> = Rc::new(Cell::new(None));
// crossbeam channel 的 Sender 是 Clone + Send,每次 clone 给一个回调
// 捕获,多回调可并发往同一 channel 投递事件。类比 Go: ch := make(chan T, 8)
// 各 goroutine 持有 ch 共享发送端。
let event_tx_state = event_tx.clone();
let _listener = stream
.add_local_listener::<()>()
@@ -641,6 +991,13 @@ fn pipewire_thread(ctx: PwThreadCtx) {
let frame_tx = frame_tx.clone();
let dropped = dropped;
move |stream, _| {
// 以下大量 unsafe 块均为对 PipeWire/libspa C API 的直接访问。
// pipewire-rs 的 stream 类型只暴露 `dequeue_raw_buffer` /
// `queue_raw_buffer` 这类 unsafe 接口,因为返回的是 C 分配的
// 裸 `*mut spa_buffer`,其生命周期由 PipeWire 控制(在
// dequeue 与下一次 queue 之间稳定),Rust 类型系统无法表达。
// 调用约定:每个 dequeue 必须恰好配一次 queue(包括所有错误
// 退出路径),否则 PipeWire 会认为该 buffer 仍被使用而耗尽池。
let raw_buf = unsafe { stream.dequeue_raw_buffer() };
if raw_buf.is_null() {
tracing::trace!("process: null raw_buf");
@@ -730,6 +1087,11 @@ fn pipewire_thread(ctx: PwThreadCtx) {
}
// 构建帧数据对象,所有必要的帧信息已收集完毕
// unsafe: OwnedFd::from_raw_fd 把刚刚 dup 出的 fd 所有权移交给
// Rust 的 RAII 包装。此后 dup_fd 的关闭由 PwDmaBufFrame::Drop
// 负责,不能再在外部 close 它。from_raw_fd 之所以 unsafe,是
// 因为调用方必须保证传入的 fd 此前没有任何 Owner(否则会 double
// close)。这里 libc::dup 刚返回的新 fd 满足该前提。
let frame = PwDmaBufFrame {
fd: unsafe { OwnedFd::from_raw_fd(dup_fd) },
offset,
@@ -741,9 +1103,13 @@ fn pipewire_thread(ctx: PwThreadCtx) {
pts,
};
// try_send 非阻塞投递;channel 容量=1(见 CapPortal::new),
// 当下游编码器落后时立刻返回 Full。
// 类比 Go: select { case ch <- frame: default: /* drop */ }
match frame_tx.try_send(frame) {
Ok(()) => {}
Err(crossbeam_channel::TrySendError::Full(_)) => {
// 丢帧计数(Relaxed 序,仅做统计;不要求与其他线程同步)。
dropped.fetch_add(1, Ordering::Relaxed);
}
Err(crossbeam_channel::TrySendError::Disconnected(_)) => {}
@@ -753,6 +1119,8 @@ fn pipewire_thread(ctx: PwThreadCtx) {
})
.register();
// 空的 SPA POD 参数数组——之前已在 param_changed 回调中接受了 PipeWire
// 推送的格式,这里不需要主动声明格式约束。`&mut [...]` 借用切片给 C API。
let mut params: [&pw::spa::pod::Pod; 0] = [];
if let Err(e) = stream.connect(
@@ -779,14 +1147,24 @@ fn pipewire_thread(ctx: PwThreadCtx) {
// previous detached helper thread approach.
// 保存 mainloop 的原始指针,用于在 shutdown 回调中调用 pw_main_loop_quit
// 这是安全的,因为回调只在 mainloop.run() 阻塞期间执行
//
// `as_raw_ptr()` 返回 `*mut pw_main_loop`(裸指针,不带生命周期),
// 取裸指针本身是 safe 的——风险在使用它。下面 `pw_main_loop_quit` 的
// unsafe 块依赖"回调仅在 run() 期间触发"这一 PipeWire 协议保证。
let mainloop_ptr = mainloop.as_raw_ptr();
// 把 shutdown_read 的可读事件注册到 PipeWire loop 的 epoll/win32 等价物。
// 每次 fd 变可读(CapPortal::drop 写入 8 字节触发),loop 在同一线程
// 调用此闭包。返回的 _shutdown_source 在 drop 时自动从 loop 注销。
let _shutdown_source = loop_.add_io(
shutdown_read,
libspa::support::system::IoFlags::IN,
move |fd| {
// Drain the eventfd so it doesn't re-trigger
let mut buf: u64 = 0;
// unsafe: libc::read 是 C 标准库 FFI。eventfd 语义保证 8 字节
// 整数读,因此 &mut u64 转 *mut void + size_of::<u64>() 安全。
// 返回值忽略——即使读失败也无法在此回调中做有意义处理。
let _ = unsafe {
libc::read(
fd.as_raw_fd(),
+43
View File
@@ -1,3 +1,20 @@
//! 文件:wlr-screencopy-unstable-v1 协议客户端绑定(`CaptureSource` 实现)
//!
//! 本文件实现 `CapWlrScreencopy`,作为 `state.rs` 中 `State<S>` 的泛型参数 `S`
//! 的两个具体实现之一(另一个是 `CapPortal`)。wlr-screencopy 是 wlroots 原生
//! 协议,优先于 XDG Portal/PipeWire:无需 D-Bus、无需用户授权对话框。
//!
//! 协议绑定来源:`wayland_protocols_wlr::screencopy::v1::client::*` 由
//! wayland-scanner 工具根据 `wlr-screencopy-unstable-v1.xml` 自动生成(类似 Go
//! 用 cgo 绑定 C 库,但 Rust 通过 wayland-client crate 暴露 type-safe wrapper
//! 无需手写 C FFI)。
//!
//! 异步模型:客户端无法主动"截屏",只能:(1) 绑定全局 manager、(2) 调用
//! `manager.capture_output()` 创建帧对象、(3) 等待内核推送 buffer/format 事件、
//! (4) 调用 `frame.copy(buffer)` 请求拷贝。因此本文件的 `alloc_frame()` 永远
//! 返回 `None`,真正的帧创建逻辑在 `state.rs` 的 Dispatch impl 中(英文注释
//! 标记为 T6b)。
use anyhow::Result;
use wayland_client::globals::GlobalList;
use wayland_client::protocol::wl_buffer::WlBuffer;
@@ -7,6 +24,9 @@ use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_frame_v1::Zwl
use crate::state::{CaptureSource, OutputInfo, State};
// L2: `CaptureSource` trait 的具体实现——wlroots 原生 wlr-screencopy 协议后端。
// 仅持有"当前在飞"的帧对象;协议管理器 `ZwlrScreencopyManagerV1` 的绑定存放
// 在 `State` 的状态机字段中(需要 Dispatch impl,见下方英文注释)。
/// wlr-screencopy capture backend.
///
/// Holds the current in-flight frame protocol object. The
@@ -17,15 +37,27 @@ pub struct CapWlrScreencopy {
/// The active frame object for the current capture cycle.
/// Set by Dispatch impls after `manager.capture_output()`, cleared
/// by `on_done_with_frame()`.
// L3: `Option<T>` 类似 Go 的 `*T`(指针)——要么持有 T 的值,要么是 None(空)。
pub current_frame: Option<ZwlrScreencopyFrameV1>,
}
// L2: 为 `CapWlrScreencopy` 实现 `CaptureSource` trait。
// Rust 的 `impl Trait for Type` 块类比 Go 的 method receiver——
// Go: `func (r *Type) Method(args)`receiver 作为第一个参数显式声明)
// Rust: `fn method(&self, args)``&self` 是 `self: &Self` 的语法糖,等价 Go receiver
// trait impl 要求方法签名与 trait 定义严格一致,编译器会校验。
impl CaptureSource for CapWlrScreencopy {
/// Unit type: wlr-screencopy is fully asynchronous — `alloc_frame()`
/// always returns `None`. The frame object is created by Dispatch
/// impls calling `manager.capture_output()`, not by this method.
// L3: `type Frame = ();` 关联类型(associated type):将"帧"的具体类型延迟到
// impl 处决定。wlr-screencopy 用 unit `()` 因为帧对象生命周期由 Dispatch 控制。
type Frame = ();
// L3: 构造函数。`Self` 在 impl 块内是 `CapWlrScreencopy` 的类型别名。
// 参数名以 `_` 前缀表示"有意未使用"——manager 绑定不在此处发生,故这些
// 参数(GlobalList/WlOutput/OutputInfo/QueueHandle)暂未消费。返回
// `Result<Self>`,失败由调用方用 `?` 操作符传播(类比 Go 的 `if err != nil`)。
fn new(
_gm: &GlobalList,
_output: &WlOutput,
@@ -35,11 +67,15 @@ impl CaptureSource for CapWlrScreencopy {
// Manager binding happens in state.rs during the ProbingOutputs →
// EverythingButFmt stage transition (T6b). It requires a Dispatch
// impl that doesn't exist yet, so we cannot call gm.bind() here.
// `Ok(...)` 是 `Result::Ok(...)` 的简写,将成功值包装为 Result 返回;
// `Self { ... }` 等价于 `CapWlrScreencopy { ... }`impl 块内可用。
Ok(Self {
current_frame: None,
})
}
// L3: 分配帧对象。返回 `Option<Self::Frame>`(此处 Frame = (),故永远返回 None)。
// `&mut self` 是 `self: &mut Self` 的简写(类比 Go 指针 receiver `*Type`)。
fn alloc_frame(&mut self) -> Option<Self::Frame> {
// wlr-screencopy is asynchronous: the Dispatch impl creates a new
// ZwlrScreencopyFrameV1 which triggers the buffer allocation flow
@@ -48,16 +84,23 @@ impl CaptureSource for CapWlrScreencopy {
None
}
// L3: 提交拷贝请求:将已分配的 DMA-BUF(WlBuffer)关联到当前帧对象。
fn queue_copy(&mut self, buffer: &WlBuffer, _qh: &QueueHandle<State<Self>>) {
// `if let Some(x) = &expr`pattern matching,当 expr 是 Some 时绑定内部值。
// 此处 `&self.current_frame` 不可变借用,调用 `frame.copy(buffer)` 提交拷贝。
if let Some(frame) = &self.current_frame {
frame.copy(buffer);
} else {
// `tracing::warn!` 是结构化日志宏(类比 Go log.Printf,但支持字段)。
tracing::warn!("queue_copy: no current wlr-screencopy frame");
}
}
// L3: 帧处理完成后的清理。`_frame: Self::Frame` 前缀 `_` 表示参数未使用(Frame 是 unit)。
fn on_done_with_frame(&mut self, _frame: Self::Frame) {
// `Option::take()`:取出 Some 并将原位置替换为 None,原值所有权转移给返回值。
if let Some(frame) = self.current_frame.take() {
// `frame.destroy()` 发送 wayland 析构请求,释放服务端协议对象资源。
frame.destroy();
}
}
+60
View File
@@ -1,39 +1,89 @@
//! 帧率限制器(FPS Limiter)。
//!
//! 基于时间间隔的下采样策略:当输入帧率高于目标时,按时间窗口丢弃多余帧,
//! 保证输出帧率不超过配置上限。本实现是「非阻塞丢帧」策略——调用方收到
//! `None` 时应主动丢弃该帧,而不是 `thread::sleep` 阻塞等待(这与 Go 中
//! 用 `time.Now()` + `time.Since(last)` + `time.Sleep(d)` 的阻塞式限速器不同)。
//!
//! - 时间点:`std::time::Instant`(单调时钟,类比 Go `time.Time` / `time.Now()`
//! - 时间差:`std::time::Duration`(类比 Go `time.Duration`
//!
//! Go 等价伪码:
//! ```text
//! type Limiter struct { last time.Time; minInterval time.Duration }
//! if time.Since(l.last) >= l.minInterval { /* 放行 */ } else { /* 丢帧 */ }
//! ```
use std::time::{Duration, Instant};
/// 帧率限制器。泛型参数 `T` 代表「帧」的载荷类型(如 AVFrame 包裹、纹理 ID、序号等),
/// 类比 Go 1.18+ 的 `type FpsLimit[T any] struct{ ... }`。
///
/// 字段全部私有,外部只能通过 [`new`](Self::new) / [`on_new_frame`](Self::on_new_frame)
/// / [`flush`](Self::flush) 三个方法操作,确保不变量(如「首帧必过」)不被绕过。
pub struct FpsLimit<T> {
/// 缓存最近一次被丢弃/待输出的帧。`Option<T>` 类比 Go 中可空指针 `*T`
/// `Some(frame)` 表示有缓存,`None` 表示空。`flush` 会取出此字段。
on_deck: Option<T>,
/// 最近一次「放行」(输出给下游)的时间戳;`None` 表示尚未放过任何帧,
/// 此时下一帧必放行(首帧直通语义)。
last_output_time: Option<Instant>,
/// 最小放行间隔 = `1 / fps` 秒。两次输出之间的时间差必须 ≥ 该值。
/// 类比 Go`time.Duration(float64(1) / float64(fps) * float64(time.Second))`。
min_interval: Duration,
}
impl<T> FpsLimit<T> {
/// 构造一个目标帧率为 `fps`(帧/秒)的限速器。
///
/// - `fps as f64`:把 `u32` 提升为 `f64` 才能做浮点除法,类比 Go 的 `float64(fps)`
/// Rust 不允许 `u32 / f64` 隐式转换,必须显式 cast。
/// - `Duration::from_secs_f64(1.0 / fps as f64)`:用浮点秒构造 `Duration`
/// 例如 `fps=30` → `min_interval ≈ 33.33ms`。
pub fn new(fps: u32) -> Self {
Self {
on_deck: None,
last_output_time: None,
// 见上文 `Duration::from_secs_f64` 的 Go 类比。
min_interval: Duration::from_secs_f64(1.0 / fps as f64),
}
}
// 下面的英文 `///` 块为既有文档(保持原样),中文说明见函数体内 `//` 注释。
/// Feed a new frame. Returns:
/// - Some(()) if enough time elapsed since the last output — proceed to encode current frame
/// - None if too close to the last output — drop current frame
///
/// 参数 `&mut self` 相当于 Go 方法接收者 `l *FpsLimit[T]`(可变借用 → 持有可写引用);
/// 返回值 `Option<T>` 相当于 Go 中可空返回值:`Some` 表示放行该帧,`None` 表示丢弃。
pub fn on_new_frame(&mut self, frame: T, timestamp: Instant) -> Option<T> {
// 判断本帧是否「就绪」(可放行)。Rust 的 `match` 强制穷尽,类比 Go 的 `switch`
// 但编译器会在漏掉分支时报错,比 Go 更严格。
let ready = match self.last_output_time {
// 首帧:从未输出过,直接放行。
None => true,
// 非首帧:`timestamp.duration_since(last)` 计算时间差,
// 类比 Go `timestamp.Sub(last)`;返回 `Duration`,与 `>=` 比较的是 `min_interval`。
Some(last) => timestamp.duration_since(last) >= self.min_interval,
};
if ready {
// 放行路径:先更新最近输出时间,再把本帧记到 `on_deck`(保留引用用于 flush)。
self.last_output_time = Some(timestamp);
self.on_deck = Some(frame);
// `Option::take`:移出内部值并把原位置置为 `None`。这里返回刚写入的 `frame`
// 即把本帧交给调用方编码输出。
self.on_deck.take()
} else {
// 丢弃路径:仍把本帧缓存到 `on_deck`(覆盖上一帧的丢弃值),以便 flush 时
// 取到「最后一帧」用于收尾。`Option::replace` 返回旧值(这里用 `let _ =` 丢弃)。
let _ = self.on_deck.replace(frame);
None
}
}
/// 取出并清空缓存的「最后一帧」。常用于流尾 flush,确保下游收到最后一帧。
/// 连续第二次调用必返回 `None`,因为 `take` 后 `on_deck` 已为 `None`。
pub fn flush(&mut self) -> Option<T> {
self.on_deck.take()
}
@@ -46,6 +96,7 @@ mod tests {
#[test]
fn first_frame_passes_immediately() {
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
// `Instant::now()` 取单调时钟当前时间,类比 Go `time.Now()`。
let now = Instant::now();
let result = limiter.on_new_frame(1u32, now);
assert_eq!(result, Some(1));
@@ -56,6 +107,8 @@ mod tests {
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
let now = Instant::now();
limiter.on_new_frame(1, now);
// `now + Duration::from_millis(1)``Instant + Duration` 通过 `Add` trait 重载,
// 类比 Go `now.Add(1 * time.Millisecond)`。1ms 远小于 33ms,应被丢弃。
let result = limiter.on_new_frame(2, now + Duration::from_millis(1));
assert!(result.is_none());
}
@@ -65,6 +118,7 @@ mod tests {
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
let now = Instant::now();
limiter.on_new_frame(1, now);
// 34ms > 33.33ms30fps 的 min_interval),应放行。
let result = limiter.on_new_frame(2, now + Duration::from_millis(34));
assert_eq!(result, Some(2));
}
@@ -75,8 +129,11 @@ mod tests {
let base = Instant::now();
let mut outputs = Vec::new();
// 模拟 60fps 输入(每 16ms 一帧),目标 30fps(每 33ms 一帧),
// 期望 10 帧输入至少产生 3 帧输出。
for i in 0..10u32 {
let t = base + Duration::from_millis(i as u64 * 16);
// `if let Some(f) = ...`:模式匹配解构 `Option`,类比 Go 的 `if v, ok := ...; ok {}`。
if let Some(f) = limiter.on_new_frame(i, t) {
outputs.push(f);
}
@@ -96,8 +153,11 @@ mod tests {
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
let now = Instant::now();
limiter.on_new_frame(1, now);
// 第二帧被丢弃,但仍缓存到 `on_deck`。
limiter.on_new_frame(2, now + Duration::from_millis(1));
// flush 取出被丢弃的最后一帧(=2)。
assert_eq!(limiter.flush(), Some(2));
// 第二次 flush 应返回 `None``take` 已清空)。
assert_eq!(limiter.flush(), None);
}
}
+18
View File
@@ -1,11 +1,29 @@
//! `wl-webrtc` 库 crate 入口。
//!
//! 本 crate 既被三个二进制(`wl-webrtc`、`vaapi_import_bench`、`sw_encode_bench`
//! 复用,也对外暴露测试入口。下面按声明顺序列出所有子模块。
//!
//! Rust 的 `pub mod xxx;` 类似 Go 的 package 组织:每个文件即一个模块,
//! 但 Rust 模块是分层的文件树(`src/<mod>.rs` 或 `src/<mod>/mod.rs`)。
// CLI 参数定义(clap derive):类似 Go 的 flag 包,但用过程宏从结构体字段自动生成。
pub mod args;
// FFmpeg/VAAPI 硬件编码 FFI 绑定(含大量 unsafe),是项目最密集的 C interop 模块。
pub mod avhw;
// 后端自动检测:根据 Wayland global 与 D-Bus 服务在 wlr-screencopy 与 XDG Portal 之间选择。
pub mod backend_detect;
// XDG Portal + PipeWire 截屏后端实现。
pub mod cap_portal;
// wlroots `wlr-screencopy-unstable-v1` 协议绑定。
pub mod cap_wlr_screencopy;
// 帧率限制器:基于 `std::time::Instant` 控制捕获循环节奏。
pub mod fps_limit;
// wlroots 后端核心状态机:用 `mio` 直接跑 Wayland fd 事件循环。
pub mod state;
// Portal 后端核心状态机:基于 `tokio` + crossbeam channel 拉取 PipeWire 帧。
pub mod state_portal;
// 管道性能统计:用 `AtomicU64` + `Mutex<HashMap>` 暴露帧率/延迟计数。
pub mod stats;
// 图像变换(旋转/翻转):对传入帧做几何变换。
pub mod transform;
// str0m WebRTC 信令服务器:内嵌一个轻量 HTTP 端点做 SDP 交换。
pub mod webrtc;
+83
View File
@@ -1,11 +1,48 @@
//! # wl-webrtc 程序入口(main 函数所在文件)
//!
//! 本文件是 `wl-webrtc` 二进制 crate 的入口,等价于 Go 的 `func main()`。
//! 由于 Rust 的 `main()` 不允许返回错误(`Result`),本项目采用通用模式:
//! 真正的业务逻辑写在 `fn run() -> Result<()>`,而 `main()` 直接 `run()` 完成所有工作。
//!
//! 整体执行流程:
//! 1. 通过 `clap` 解析命令行参数(`Args`,包含分辨率、编码格式、帧率等)
//! 2. 初始化 `tracing` 日志系统(受 `RUST_LOG` 环境变量或 `-v` 参数控制)
//! 3. MVP 阶段拒绝非 H.264 编码格式
//! 4. 要求至少提供 `--output`(输出到文件)或 `--port`(启动 WebRTC 信号服务器)
//! 5. 调用 `backend_detect::detect_backend` 自动检测当前 Wayland 桌面支持的截屏后端
//! 6. 根据检测结果进入对应的事件循环:
//! - 支持 `zwlr_screencopy_manager_v1` 的合成器(Sway/Hyprland)→ `run_wlr_screencopy`
//! - 仅支持 XDG Portal ScreenCast 的桌面(GNOME/KDE)→ `run_portal_pipewire`
//!
//! 两个事件循环都基于 `mio`(一个手动驱动的事件循环库,类似 Go runtime netpoller 的手动版),
//! 底层在 Linux 上使用 epoll。
// 获取 Unix 原始文件描述符所需的 trait
// AsRawFd 提供了 as_raw_fd() 方法,用于从 std::io::Read/Write 等 Rust 抽象中
// 取出底层的 libc::c_intPOSIX 文件描述符),mio 注册 fd 监听时需要它
use std::os::unix::io::AsRawFd;
// anyhow::Result<T, anyhow::Error> 是一个简化的错误类型,等价于 Go 的 (T, error)
// ? 操作符会将任何实现了 std::error::Error 的错误转换为 anyhow::Error
use anyhow::Result;
// clap::Parser 是一个 derive 宏,实现后 args.parse() 即可从 std::env::args() 解析 CLI 参数
// 类比 Go 的 flag.Parse(),但 clap 自动生成 --help 文本和错误处理
use clap::Parser;
// mio::unix::SourceFd 是一个 bridge:将裸 fd 包装为实现 mio::Evented 的对象
// 这样 mio 的 epoll 可以监听任意 Unix fd,而不局限于 std::net::TcpStream 等标准类型
use mio::unix::SourceFd;
// mio 是一个手动驱动的事件循环库(与 tokio 的异步运行时不同,mio 不调度 future)
// - Pollepoll/kqueue 的 Rust 封装,poll.poll() 会阻塞直到 fd 就绪
// - Interest:注册时的关注事件类型(READABLE / WRITABLE
// - Token:用户自定义的事件源标识(u64 包装),用于在 poll 返回时区分是哪个 fd 触发的
// - Events:poll 返回的事件集合(一个容量固定的 Vec)
// 类比 Go runtime 的 netpoller,但 Go runtime 自动调度,mio 需要用户手动循环
use mio::{Events, Interest, Poll, Token};
// registry_queue_init 是 wayland-client 的便捷函数:连接到合成器并初始化全局注册表队列
// 它会在内部调用 Connection::connect_to_env() 并 roundtrip 一次拿到全局对象列表
use wayland_client::globals::registry_queue_init;
// Connection 是与 Wayland 合成器的会话连接,封装了 Unix socket 的读写和协议解析
// 类比 Go 中的 net.Conn,但 Wayland 协议是有状态的消息流而非字节流
use wayland_client::Connection;
// 各功能模块声明
@@ -21,6 +58,8 @@ mod stats; // 管道性能统计(卡顿诊断)
mod transform; // 图像变换(旋转/翻转)
mod webrtc; // WebRTC 传输(str0m Sans-IO
// 引入本 crate 内部模块,crate:: 前缀表示从 crate root 开始的绝对路径
// 类比 Go 中的 import "<module>/args" 写法
use crate::args::Args;
use crate::cap_wlr_screencopy::CapWlrScreencopy;
use crate::state::EncConstructionStage;
@@ -46,6 +85,11 @@ fn main() -> Result<()> {
// 根据 verbose 模式或 RUST_LOG 环境变量设置日志级别
// 支持 RUST_LOG 粒度控制(如 RUST_LOG=wl_webrtc::webrtc=trace
// 详细解释:
// - try_from_default_env() 返回 Result<EnvFilter>,读取 RUST_LOG 环境变量
// - unwrap_or_else(|_| {...}) 是 Result 的方法:成功则返回内部值,失败时调用闭包
// - |_| 是闭包参数语法:|参数| 表达式,单个 _ 表示忽略参数(这里是 Err 类型)
// 类比 Go 的 if err != nil { fallback },但 Rust 用闭包传递 fallback 逻辑
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
if args.verbose {
tracing_subscriber::EnvFilter::new("debug")
@@ -53,6 +97,11 @@ fn main() -> Result<()> {
tracing_subscriber::EnvFilter::new("info")
}
});
// tracing_subscriber::fmt() 是 Builder 模式:链式调用配置,最后 .init() 消费 builder
// 完成全局订阅注册。再次调用 .init() 会 panic,因此只能初始化一次。
// - with_env_filter: 设置过滤规则
// - with_writer: 设置日志输出目标(这里为 stderr,避免污染 stdout 用于视频流)
// - init(): 消费 self,注册全局默认 subscriber,无返回值
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_writer(std::io::stderr)
@@ -69,6 +118,8 @@ fn main() -> Result<()> {
);
// MVP 阶段仅支持 H.264 编码,不支持 HEVC
// anyhow::bail! 是一个宏(注意感叹号 !),立即返回 Err(anyhow::Error)
// 类比 Go 的 fmt.Errorf("...") + return err,但是 Rust 用宏实现
if args.codec != "h264" {
anyhow::bail!("HEVC not supported in MVP. Use --codec h264");
}
@@ -79,9 +130,14 @@ fn main() -> Result<()> {
// 自动检测当前桌面环境可用的截屏后端
// 会尝试列举 Wayland 全局对象,判断合成器是否支持 wlr-screencopy 协议
// 行尾的 ? 是错误传播操作符:若 detect_backend 返回 Err,立即将该错误作为 fn main 的返回值
// 等价于 Go 的 if err != nil { return err },但 Rust 中 ? 适用于任何 Result/Option
let backend = crate::backend_detect::detect_backend(&args)?;
// 根据检测结果进入对应的事件循环
// match 是 Rust 的模式匹配表达式(类比 Go 的 switch 但更强大)
// 每个 => 左侧是模式(这里是枚举变体),右侧是返回 Result<()> 的函数调用
// 由于 fn main 返回 Result<()>,这里直接把 match 表达式作为函数返回值(无分号 + 无 return)
match backend {
crate::backend_detect::CaptureBackend::WlrScreencopy => run_wlr_screencopy(args),
crate::backend_detect::CaptureBackend::PortalPipeWire => run_portal_pipewire(args),
@@ -104,9 +160,13 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
// Connect to Wayland compositor
// 建立 Wayland 连接并初始化全局注册表
// 通过环境变量 $WAYLAND_DISPLAY 找到合成器的 Unix socket
// 行尾的 ? 是 fn run_wlr_screencopy 内首次出现的错误传播操作符:
// 若 connect_to_env 返回 Err,立即作为函数返回值向上抛出(类比 Go 的 return err
let conn = Connection::connect_to_env()?;
// registry_queue_init 会绑定全局注册表回调,
// 当合成器广播其全局对象(输出、截屏管理器等)时,State 会收到通知
// 返回值是元组 (GlobalManager, EventQueue),用 let 解构模式匹配赋值
// mut queue 表示 queue 在后续代码中会被修改(Rust 默认不可变,需 mut 显式声明)
let (gm, mut queue) = registry_queue_init::<State<CapWlrScreencopy>>(&conn)?;
let qhandle = queue.handle();
@@ -119,14 +179,19 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
// compositor has already sent (may be EAGAIN if nothing yet).
// 获取 Wayland socket 的文件描述符,并消费合成器已发送的事件
// 这个 fd 是后续 mio epoll 监听的对象,当合成器写入数据时变为可读
// 用 { ... } 块表达式将临时变量 guard 限制在作用域内,作用域结束自动 drop
let wayland_fd = {
let guard = queue
.prepare_read()
// ok_or_else 是 Option 的方法:None 时调用闭包生成 Err,得到 Result
// || anyhow::anyhow!(...) 是无参数闭包语法(类比 JS 的 () => ...
// 行尾 ? 将 Result<_, Err> 解开为 Err 时立即从函数返回
.ok_or_else(|| anyhow::anyhow!("Failed to prepare Wayland read"))?;
// 从 prepare_read 的 guard 中获取底层 socket 的原始文件描述符
let fd = guard.connection_fd().as_raw_fd();
// 尝试非阻塞读取合成器已发送但尚未消费的数据
// 如果没有数据会返回 EAGAIN,这里用 let _ 忽略
// let _ = expr 是显式忽略表达式返回值的惯用法,等价于 Go 的 _ = expr
let _ = guard.read();
fd
};
@@ -148,6 +213,9 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
revents: 0,
};
// timeout=0 表示非阻塞,立即返回当前 fd 状态
// unsafe { ... } 是 Rust 的不安全块:内部调用 C 库 libc::poll,需要程序员
// 手动保证 &mut pfd 是有效的可变引用、fd 合法、不并发访问等不变量。
// unsafe 不关闭 Rust 借用检查,只是声明"我对外部 FFI 调用负责"。
let ret = unsafe { libc::poll(&mut pfd, 1, 0) };
tracing::info!(
"Raw poll on wayland fd={wayland_fd}: ret={ret}, revents={}",
@@ -225,6 +293,8 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
});
// 检查是否收到退出信号
// for x in &collection 是 Rust 的迭代语法,&events 表示借用 Events(不消费)
// 类比 Go 的 for _, ev := range events {}
for event in &events {
if event.token() == TOKEN_QUIT {
tracing::info!("Received quit signal");
@@ -234,8 +304,12 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
// Wayland fd 可读时,读取并分发合成器事件
// 合成器可能发来多种事件:帧数据就绪、输出信息变化、协议错误等
// events.iter().any(|e| ...) 是迭代器方法,|e| 是单参数闭包
if events.iter().any(|e| e.token() == TOKEN_WAYLAND) {
// if let Some(x) = opt 是 Option 的模式匹配简写(类比 Go 的 if v, ok := m[k]; ok
if let Some(guard) = read_guard {
// match 是本函数内首次出现的多分支模式匹配
// Ok(_) 中下划线表示忽略成功值的具体内容(只关心成功/失败本身)
match guard.read() {
Ok(_) => {
// 读取成功后,dispatch_pending 会将合成器事件
@@ -276,7 +350,10 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
tracing::info!("Shutting down, flushing encoder...");
state.fps_limit.flush();
// 仅在编码器已构建完成(Streaming 阶段)时才需要刷新
// if let 枚举变体模式匹配:Streaming { enc, .. } 解构出内部字段 enc,.. 忽略其他字段
// &mut state.stage 表示可变借用(类比 Go 的指针,但 Rust 编译期保证独占)
if let crate::state::EncConstructionStage::Streaming { enc, .. } = &mut state.stage {
// if let Err(e) = result 只关心失败分支,成功值用 _ 隐式忽略
if let Err(e) = enc.flush() {
tracing::error!("Failed to flush encoder: {e}");
}
@@ -297,6 +374,8 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
/// - 收到退出信号时停止
/// 4. 退出时关闭 Portal 连接并释放 PipeWire 资源
fn run_portal_pipewire(args: Args) -> Result<()> {
// 函数内 use 声明:将长路径名简化为局部短名,仅在该函数作用域内生效
// 类比 Go 函数内的局部 import 别名
use crate::state_portal::StatePortal;
tracing::info!("Using Portal/PipeWire backend (KWin/KDE/GNOME)");
@@ -305,6 +384,7 @@ fn run_portal_pipewire(args: Args) -> Result<()> {
// 1. 通过 D-Bus 连接到 XDG Portal 的 ScreenCast 接口
// 2. 请求用户授权屏幕录制权限
// 3. 建立 PipeWire 流连接,准备接收帧数据
// 行尾 ? 是本函数内首次出现的错误传播操作符:失败时立即从 fn run_portal_pipewire 返回 Err
let mut state = StatePortal::new(args)?;
// Set up signal handling only (no Wayland fd needed)
@@ -352,6 +432,9 @@ fn run_portal_pipewire(args: Args) -> Result<()> {
// poll_and_encode 会从 PipeWire 缓冲区取出帧,
// 编码为 H.264 并推送。返回 true 表示还有更多帧待处理,
// 返回 false 表示当前没有帧了,while 循环退出等待下一轮 poll
// 外层 if 触发首次取帧(drain_first=true 表示允许阻塞等待),
// 内层 while state.poll_and_encode(false)? {} 是空循环体语法:
// 循环条件持续求值,只要返回 true 就重复,循环体 {} 不做额外事
if state.poll_and_encode(true)? {
while state.poll_and_encode(false)? {}
}
+550 -8
View File
File diff suppressed because it is too large Load Diff
+327 -117
View File
@@ -1,3 +1,32 @@
//! Portal 后端的主状态机:通过 PipeWire + DMA-BUF 进行屏幕采集并软件编码。
//!
//! ## 整体角色
//!
//! `StatePortal` 与 `src/state.rs::State` 是平行的两条采集路径:
//! - `state.rs`wlroots 路径):由外层 `mio` 事件循环驱动(手工版 epoll),
//! 通过 `zwlr_screencopy_manager_v1` 协议一帧一帧地拉取。
//! - `state_portal.rs`(本文件,XDG Portal / PipeWire 路径):由 `CapPortal`
//! 通过 `crossbeam_channel::Receiver<PwDmaBufFrame>` 推帧;本状态机只负责"消费"。
//!
//! ## 异步模型的真相
//!
//! 本文件**不**使用 `mio` 或 `tokio`——`CapPortal` 内部在独立线程跑 PipeWire
//! asyncio loop,把 DMA-BUF 帧通过 crossbeam channel 投递出来;外层 `main.rs`
//! 只需在 `while !is_errored()` 循环里轮询 `poll_and_encode(block)`。编码线程与
//! WebRTC 线程通过 `std::thread::spawn`(不是 `tokio::spawn`)启动,再借助
//! crossbeam channel 与主线程通信——类比 Go 的 `go func()` + channel。
//!
//! ## 阶段机
//!
//! `PortalStage::WaitingForFormat`(等首帧以确定格式)→ `Streaming`(持续编码)。
//!
//! ## 注意
//!
//! - T9a(本块)覆盖文件头 + struct 定义 + `impl StatePortal`(至 `fn encode_thread_loop` 之前);
//! T9b 覆盖 `encode_thread_loop` / `webrtc_thread_loop` / `resolve_drm_device` 等自由函数。
//! - 多处 `unsafe` 调用 FFmpeg/VAAPI FFI;现有英文 SAFETY 标记保留不动,
//! 本任务在每个 unsafe 块上方加普通 `//` 中文概述(不新增 SAFETY 标记)。
// 采集门户状态模块 —— 通过 PipeWire/DMA-BUF 进行屏幕采集并编码
use std::os::fd::AsRawFd;
use std::path::PathBuf;
@@ -9,7 +38,8 @@ use anyhow::{bail, Result}; // 错误处理工具
use crate::args::Args; // 命令行参数
use crate::avhw::{
self, BitrateCommand, CpuNv12Frame, ResolutionChange, SwEncEncode, SwEncImport, SwEncState,
self, BitrateCommand, CpuNv12Frame, EncodeOutcome, EncodedH264Frame, ResolutionChange,
SwEncEncode, SwEncImport, SwEncState,
}; // 软件编码器状态(VAAPI 导入 + H.264 编码)
use crate::cap_portal::{CapPortal, PwCtrlEvent, PwDmaBufFrame}; // PipeWire 屏幕采集端点
use crate::stats::{FrameTimings, PipelineStats}; // 管道统计(帧计时、每秒快照)
@@ -23,21 +53,39 @@ enum PortalStage {
Streaming,
}
/// 编码线程单帧计时回执——由 `encode_thread_loop` 通过 `timing_tx` 发回主线程,
/// 用于在 `PipelineStats` 中窗口化统计 `sws_us`libswscale 缩放开销)和
/// `encode_us`H.264 软件编码开销)。类比 Go 的 `type EncodeThreadTiming struct`。
struct EncodeThreadTiming {
sws_us: u64,
encode_us: u64,
output_bytes: usize,
}
/// 编码工作线程的句柄与通信端点。
///
/// 由主线程持有,负责把 NV12 帧 (`CpuNv12Frame`) 通过 `input_tx` 投递给
/// `encode_thread_loop`;编码完成后通过 `timing_rx` 收回单帧计时;`duplicate_count`
/// 是跨线程共享的 `Arc<AtomicU64>`(类比 Go 的 `*uint64` protected by atomic),
/// 用于统计被去重跳过的帧数(影响 BWE 与丢弃策略)。
///
/// 字段全部用 `Option<...>`/`Sender`/`Receiver` 包装,是为了在 `shutdown` 时
/// 能用 `Option::take()` 把所有权转移到本地变量、显式 drop `input_tx`、再 `join()`。
struct EncodeThread {
handle: Option<std::thread::JoinHandle<()>>,
input_tx: crossbeam_channel::Sender<CpuNv12Frame>,
timing_rx: crossbeam_channel::Receiver<EncodeThreadTiming>,
duplicate_count: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
/// WebRTC 工作线程的句柄与单向上行通道。
///
/// 主线程只能通过 `sent_gap_rx` **被动接收** WebRTC 线程上报的"已发送帧间隔/老化"
/// 指标(用于 `PipelineStats::record_send_from_thread`)。下行的码率 / 分辨率 / 暂停
/// 控制走另一组 channel`bitrate_tx` / `resolution_tx` / `webrtc_paused`),不在此处。
struct WebrtcThread {
handle: Option<std::thread::JoinHandle<()>>,
sent_gap_rx: crossbeam_channel::Receiver<f64>,
sent_gap_rx: crossbeam_channel::Receiver<(f64, Option<f64>)>,
}
/// 门户模式的主状态机
@@ -61,14 +109,22 @@ pub struct StatePortal {
webrtc_thread: Option<WebrtcThread>,
webrtc_paused: Option<Arc<AtomicBool>>,
last_capture_arrival: Option<Instant>, // timestamp of last real frame arrival
stall_start: Option<Instant>, // when current stall began
last_stall_log: Option<Instant>, // rate-limiting for stall warnings
last_fillable_frame: Option<CpuNv12Frame>, // cached last frame for filler duplication
next_filler_at: Option<Instant>, // when to send next filler frame
filler_frames_sent: u64,
idle_log_start: Option<Instant>, // when current idle period began (one-shot DEBUG log guard)
shutdown_started: bool, // idempotency guard; plain bool because &mut self is exclusive (not AtomicBool)
// Issue #24: real-capture PTS origin/tracking for WebRTC RTP timestamps.
first_pts_ns: Option<i128>,
capture_start: Option<Instant>,
last_pts_emitted: Option<i64>,
}
// `impl StatePortal` 块集中了门户路径的所有主线程逻辑:
// - `new`:构造(DRM 设备探测 + CapPortal 初始化;编码器延后到首帧)。
// - `poll_and_encode`:外层 main 循环每轮调用一次,处理 1 个 PipeWire 帧 / 控制事件。
// - `shutdown`:幂等清理(编码线程 → WebRTC 线程 → MP4 flush)。
// - 私有辅助:`record_capture_timeout` / `record_frame_arrival`(采集空闲日志节流)、
// `resolve_drm_device_for_frame`DMA-BUF 导入兼容性探测)、
// `handle_pw_frame`VAAPI 导入 + 软件编码)、`compute_capture_pts`90kHz RTP PTS)。
// 内部不使用任何锁——所有 `&mut self` 由外层 main 循环单线程串行化保证独占。
impl StatePortal {
/// 创建门户状态实例
///
@@ -108,12 +164,11 @@ impl StatePortal {
webrtc_thread: None,
webrtc_paused,
last_capture_arrival: None,
stall_start: None,
last_stall_log: None,
last_fillable_frame: None,
next_filler_at: None,
filler_frames_sent: 0,
idle_log_start: None,
shutdown_started: false,
first_pts_ns: None,
capture_start: None,
last_pts_emitted: None,
})
}
@@ -176,7 +231,6 @@ impl StatePortal {
match self.stage {
PortalStage::WaitingForFormat => {
// 首帧到达,记录 DMA-BUF 格式信息
tracing::info!(
"First DMA-BUF frame: {}x{} format=0x{:08X} stride={} modifier=0x{:X}",
frame.width,
@@ -198,9 +252,13 @@ impl StatePortal {
enc_height,
self.args.fps,
);
// 码率:未指定时按分辨率 × 帧率动态计算
// 码率:WebRTC 模式用保守默认(BWE 连接后立即覆盖),MP4 用公式
let actual_bitrate = self.args.bitrate.unwrap_or_else(|| {
if self.webrtc.is_some() {
webrtc_startup_bitrate_bps(enc_width, enc_height)
} else {
5 * (enc_width as u64) * (enc_height as u64) * (self.args.fps as u64) / 100
}
});
// GOP 大小:WebRTC 模式使用较大的 GOPfps*2,最低20),MP4 模式使用 fps
let actual_gop_size = self.args.gop_size.unwrap_or_else(|| {
@@ -215,6 +273,10 @@ impl StatePortal {
if self.webrtc.is_some() {
let paused = self.webrtc_paused.as_ref()
.ok_or_else(|| anyhow::anyhow!("internal invariant broken: webrtc_paused missing while WebRTC mode is active"))?;
// WebRTC 模式需要 6 路 crossbeam channel 协调主线程 ↔ 编码线程 ↔ WebRTC 线程。
// `crossbeam_channel::bounded::<T>(n)` 类比 Go 的 `make(chan T, n)`——
// 容量满时 `send` 阻塞、空时 `recv` 阻塞;返回的 `(Sender, Receiver)` 各占一份
//所有权,可 move 到不同线程(前提是元素类型 `T: Send`)。
let (resolution_tx, resolution_rx) =
crossbeam_channel::bounded::<BitrateCommand>(4);
let (encoder_resolution_tx, encoder_resolution_rx) =
@@ -245,14 +307,38 @@ impl StatePortal {
bitrate_rx,
encoder_resolution_rx,
)?;
let duplicate_count = std::sync::Arc::new(
std::sync::atomic::AtomicU64::new(0),
);
// Arc 引用计数克隆(不是深拷贝)——`duplicate_count` 留在主线程,
// `duplicate_count_for_thread` move 进编码线程;两者指向同一原子。
// 类比 Go 的 `*uint64` + atomic.Store,但 Rust 用类型系统保证线程安全。
let duplicate_count_for_thread = duplicate_count.clone();
// `std::thread::Builder::new().name(...).spawn(move || {...})?`
// - 类比 Go 的 `go func() {...}()`,但返回 `JoinHandle<T>` 而非 fire-and-forget——
// 主线程可在 shutdown 时 `handle.join()` 等待子线程退出。
// - **不**用 `tokio::spawn`:编码是 CPU 密集 + 阻塞 FFmpeg 调用,
// 不需要 async/await;标准线程更直接。
// - `move ||` 闭包:把 `encode` / `input_rx` / `timing_tx` /
// `duplicate_count_for_thread` 的所有权**转移**给子线程(类比 Go 里把变量
// 显式传入 goroutine 闭包参数)。
// - `?` 传播 `io::Error`——线程创建可能失败(资源限制)。
let handle = std::thread::Builder::new()
.name("wl-webrtc-encode".into())
.spawn(move || encode_thread_loop(encode, input_rx, timing_tx))?;
.spawn(move || {
encode_thread_loop(
encode,
input_rx,
timing_tx,
duplicate_count_for_thread,
)
})?;
self.enc_import = Some(import);
self.enc_thread = Some(EncodeThread {
handle: Some(handle),
input_tx,
timing_rx,
duplicate_count,
});
let wrtc = self.webrtc.take().ok_or_else(|| {
@@ -264,7 +350,13 @@ impl StatePortal {
.ok_or_else(|| anyhow::anyhow!("internal: webrtc_paused missing"))?
.clone();
let fps = self.args.fps;
let (sent_gap_tx, sent_gap_rx) = crossbeam_channel::bounded(64);
let max_bitrate = self.args.max_bitrate;
let (sent_gap_tx, sent_gap_rx) =
crossbeam_channel::bounded::<(f64, Option<f64>)>(64);
// WebRTC 工作线程:同上 `std::thread::spawn(move || ...)` 模式——
// 内部跑 str0m 的 asyncio loop`WebRtcState` 自己驱动),
// 通过 `webrtc_rx` 接收 H.264 帧、通过 `bitrate_tx` / `resolution_tx`
// 接收码率/分辨率指令、通过 `sent_gap_tx` 上报发送指标。
let webrtc_handle = std::thread::Builder::new()
.name("wl-webrtc-webrtc".into())
.spawn(move || {
@@ -274,6 +366,7 @@ impl StatePortal {
fps,
enc_width,
enc_height,
max_bitrate,
paused,
sent_gap_tx,
bitrate_tx,
@@ -329,115 +422,67 @@ impl StatePortal {
timing.output_bytes,
);
}
// Read duplicate counter (delta computed in setter)
let total = enc_thread
.duplicate_count
.load(std::sync::atomic::Ordering::Relaxed);
self.stats.set_duplicate_frames_skipped(total);
}
if let Some(ref webrtc_thread) = self.webrtc_thread {
while let Ok(gap_ms) = webrtc_thread.sent_gap_rx.try_recv() {
self.stats.record_send_from_thread(gap_ms);
while let Ok((gap_ms, age_ms)) = webrtc_thread.sent_gap_rx.try_recv() {
self.stats.record_send_from_thread(gap_ms, age_ms);
}
}
let snap = self.stats.snapshot_and_reset();
if self.filler_frames_sent > 0 {
tracing::info!(
"stats: {snap} filler_frames_sent={}",
self.filler_frames_sent
);
} else {
tracing::info!("stats: {snap}");
}
}
Ok(true)
}
/// 记录"采集超时"——本次轮询未取到帧(PipeWire 队列空)。
///
/// 因为 Wayland 是 damage-driven(只有画面变化才推帧),静态画面下长时间无帧
/// 是**正常**行为,不是 compositor 卡死。所以本函数只做"5 秒阈值后的 DEBUG 一次性日志"
/// 用 `idle_log_start` 字段保证每次空闲区间只发一条日志(issue #15 / #18)。
fn record_capture_timeout(&mut self) {
let Some(last_capture_arrival) = self.last_capture_arrival else {
return;
};
let now = Instant::now();
let frame_interval = Duration::from_secs_f64(1.0 / f64::from(self.args.fps.max(1)));
let stall_threshold = Duration::from_millis(100).max(frame_interval * 3);
if now.duration_since(last_capture_arrival) <= stall_threshold {
// Wayland damage-driven delivery: static content means no new frames.
// This is normal Wayland behavior, not a compositor hang. Only log DEBUG
// after a meaningful idle period, and only once per idle episode.
// See issues #15 and #18.
const CAPTURE_IDLE_LOG_THRESHOLD: Duration = Duration::from_secs(5);
if now.duration_since(last_capture_arrival) <= CAPTURE_IDLE_LOG_THRESHOLD {
return;
}
if self.stall_start.is_none() {
self.stall_start = Some(now);
self.last_stall_log = Some(now);
tracing::warn!("compositor frame delivery stalled");
} else {
let should_log = self.last_stall_log.map_or(true, |last_log| {
now.duration_since(last_log) >= Duration::from_secs(1)
});
if should_log {
self.last_stall_log = Some(now);
tracing::warn!("compositor frame delivery stalled");
if self.idle_log_start.is_none() {
// Use last_capture_arrival as idle start for accurate elapsed duration.
self.idle_log_start = Some(last_capture_arrival);
tracing::debug!(
elapsed_ms = now.duration_since(last_capture_arrival).as_millis(),
"portal capture idle; no damage frames received (normal Wayland behavior)"
);
}
}
self.maybe_send_filler_frame();
}
fn maybe_send_filler_frame(&mut self) {
if self.webrtc_thread.is_none() || self.stall_start.is_none() {
return;
}
let Some(cached) = &self.last_fillable_frame else {
return;
};
const MAX_FILLER_DURATION: Duration = Duration::from_secs(2);
if let Some(stall_start) = self.stall_start {
if stall_start.elapsed() > MAX_FILLER_DURATION {
return;
}
}
let now = Instant::now();
let frame_interval = Duration::from_secs_f64(1.0 / f64::from(self.args.fps.max(1)));
let Some(next) = self.next_filler_at else {
self.next_filler_at = Some(now + frame_interval);
return;
};
if now < next {
return;
}
let filler = CpuNv12Frame {
y_data: cached.y_data.clone(),
uv_data: cached.uv_data.clone(),
y_stride: cached.y_stride,
uv_stride: cached.uv_stride,
pts: self.frames_encoded as i64,
};
if let Some(enc_thread) = &self.enc_thread {
match enc_thread.input_tx.try_send(filler) {
Ok(()) => {
self.frames_encoded += 1;
self.filler_frames_sent += 1;
self.next_filler_at = Some(next + frame_interval);
}
Err(crossbeam_channel::TrySendError::Full(_)) => {}
Err(crossbeam_channel::TrySendError::Disconnected(_)) => {
tracing::error!("Encode thread disconnected during filler");
self.errored = true;
}
}
}
}
/// 记录"采集到达"——本次轮询成功取到一帧。
///
/// 与 `record_capture_timeout` 互补:若之前处于空闲区间,则通过 `Option::take()`
/// 取出 `idle_log_start` 并发一条 "resumed after idle" DEBUG 日志;然后刷新
/// `last_capture_arrival` 时间戳。两者共同实现"一次性空闲日志"语义。
fn record_frame_arrival(&mut self) {
if let Some(stall_start) = self.stall_start.take() {
tracing::info!(
"compositor frame delivery resumed after {:.0}ms",
stall_start.elapsed().as_secs_f64() * 1000.0
if let Some(idle_start) = self.idle_log_start.take() {
tracing::debug!(
idle_ms = idle_start.elapsed().as_millis(),
"portal capture resumed after idle period"
);
self.last_stall_log = None;
}
self.last_capture_arrival = Some(Instant::now());
self.next_filler_at = None;
}
/// 为当前帧解析可用的 DRM 渲染设备
@@ -494,11 +539,37 @@ 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.
// `Arc<AtomicBool>` 类比 Go 的 `*atomic.Bool`——`Arc` 提供跨线程共享所有权
// (引用计数原子递增/递减),`AtomicBool` 提供无锁读/写。
// `Ordering::Relaxed`:只保证单变量原子性,不建立与其他变量的 happens-before 关系——
// 对"暂停标志"足够(不需要它做屏障同步)。
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;
// WebRTC: use real PipeWire capture time so RTP timestamps reflect reality
// (sequential counter caused client jitter buffers to grow to 2-3s under
// damage-driven variable fps — issue #24). MP4: keep sequential counter;
// file output doesn't need real-time PTS and changing it would alter
// playback speed during static periods.
let pts = if self.webrtc_thread.is_some() {
self.compute_capture_pts(frame.pts)
} else {
self.frames_encoded as i64
};
if let Some(enc) = self.enc.as_mut() {
// 将 DMA-BUF 帧零拷贝导入 VAAPI 硬件帧池
// unsafeFFI 调用 FFmpeg `av_hwframe_ctx_init` / `av_hwframe_map` 系列,
// 内部会读取 `enc.frames_rgb()` 指向的 `AVBufferRef`(硬件帧池),
// 并把 `frame.fd.as_raw_fd()`DMA-BUF dmabuf fd)注册到 VAAPI。
// 安全性前提:`enc` 在本线程独占(main 串行化保证)、`frame.fd` 未被 close。
let mut vaapi_frame = unsafe {
avhw::import_dma_buf_to_vaapi(
enc.frames_rgb().as_ptr(),
@@ -536,6 +607,8 @@ impl StatePortal {
};
self.stats.record_encode(&timings);
} else if let Some(import) = self.enc_import.as_mut() {
// 同上 unsafeDMA-BUF → VAAPI 导入;`import.frames_rgb()` 是与编码线程
// **不共享**的独立硬件帧池(避免与 `import_and_scale` 的回读路径竞争)。
let mut vaapi_frame = unsafe {
avhw::import_dma_buf_to_vaapi(
import.frames_rgb().as_ptr(),
@@ -561,17 +634,12 @@ impl StatePortal {
"internal invariant broken: encode thread missing while async import is active"
)
})?;
let fillable_frame = CpuNv12Frame {
y_data: cpu_nv12.y_data.clone(),
uv_data: cpu_nv12.uv_data.clone(),
y_stride: cpu_nv12.y_stride,
uv_stride: cpu_nv12.uv_stride,
pts: 0,
};
// `try_send` 类比 Go 的 `select { case ch <- v: default: }`——
// 非阻塞投递;三种结果分别处理:成功递增、满了丢弃(DEBUG 日志)、
// 对端关闭(致命,置 `errored=true` 让外层循环退出)。
match enc_thread.input_tx.try_send(cpu_nv12) {
Ok(()) => {
self.frames_encoded += 1;
self.last_fillable_frame = Some(fillable_frame);
}
Err(crossbeam_channel::TrySendError::Full(_)) => {
tracing::debug!("Encode thread input full, dropping portal frame");
@@ -588,6 +656,45 @@ impl StatePortal {
Ok(())
}
/// 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 {
const NS_PER_SEC: i128 = 1_000_000_000;
let raw_ns: i128 = if pw_pts_ns > 0 {
i128::from(pw_pts_ns)
} else {
let start = self.capture_start.get_or_insert_with(Instant::now);
i128::try_from(start.elapsed().as_nanos()).unwrap_or(0)
};
if self.first_pts_ns.is_none() && raw_ns > 0 {
self.first_pts_ns = Some(raw_ns);
}
let origin = self.first_pts_ns.unwrap_or(0);
let relative_ns = if raw_ns >= origin {
raw_ns - origin
} else {
// PipeWire PTS went backwards (stream restart) — reset origin.
self.first_pts_ns = Some(raw_ns);
0
};
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;
if let Some(last) = self.last_pts_emitted {
if pts <= last {
pts = last.checked_add(1).unwrap_or(last);
}
}
self.last_pts_emitted = Some(pts);
pts
}
/// 关闭状态:刷新编码器并清理资源(幂等)。
///
/// `shutdown_started` 守卫在清理之前置位——防止 panic 时 `Drop` 重入 unwinding。
@@ -597,8 +704,11 @@ impl StatePortal {
}
self.shutdown_started = true;
self.last_fillable_frame = None;
// 1. Stop encode thread (drops webrtc_tx → signals WebRTC thread to exit)
// `Option::take()` 把 `EncodeThread` 的所有权从 `self.enc_thread` 转移到本地 `enc_thread`
// 同时 `self.enc_thread` 变成 `None`——这是 Rust 里"消费字段但保留父结构体"的标准习语,
// 类比 Go 里把字段设为 nil 但保留外层 struct。接下来显式 `drop(input_tx)` 关闭 channel
// 编码线程的 `input_rx.recv()` 会返回 `Err(Disconnected)` 从而退出循环。
if let Some(mut enc_thread) = self.enc_thread.take() {
drop(enc_thread.input_tx);
if let Some(handle) = enc_thread.handle.take() {
@@ -643,16 +753,26 @@ impl StatePortal {
}
}
// === 编码线程主循环(独立 std::thread,非 tokio ===
// 类比 Go`go func(input <-chan Frame) { for f := range input { encode(f) } }`。
// 线程持有 SwEncEncode 的所有权(move 语义),消费 input_rx 直到对端 drop 所有 Sender。
// 编码结果通过 timing_tx(单帧耗时)+ duplicate_count(重复帧统计)回传主线程。
fn encode_thread_loop(
mut encode: SwEncEncode,
input_rx: crossbeam_channel::Receiver<CpuNv12Frame>,
timing_tx: crossbeam_channel::Sender<EncodeThreadTiming>,
duplicate_count: std::sync::Arc<std::sync::atomic::AtomicU64>,
) {
// 阻塞循环:`match input_rx.recv()` 类比 Go `for frame := range input_rx {}`。
// - Ok(frame) → 调用 encode_cpu_framematch EncodeOutcome 各 variant 分别处理;
// timing_tx.try_send 非阻塞回执(满则丢,类比 Go `select { case ch <- v: default: }`);
// 重复帧计数通过 Arc<AtomicU64>::fetch_add + Relaxed 累加——无锁、无需 happens-before。
// - Err(_) → 所有 Sender 已 dropflush 编码器后退出循环。
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 +780,12 @@ fn encode_thread_loop(
output_bytes: t.output_bytes,
});
}
Ok(EncodeOutcome::SkippedDuplicate) => {
duplicate_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
Ok(_) => {
// SkippedPaused / SkippedDisconnected — no counter needed
}
Err(e) => {
tracing::error!("Encode thread error: {e}");
break;
@@ -678,14 +804,22 @@ fn encode_thread_loop(
tracing::info!("Encode thread exiting");
}
// === WebRTC 信令 + 帧发送主循环(独立 std::thread,非 tokio ===
// 该线程串行处理 4 件事:
// 1. str0m 信令(ICE/DTLS+ RTP 打包发送(wrtc.handle_signaling / poll_and_feed);
// 2. 自适应码率(BWE)→ bitrate_tx 下发 UpdateBitrate/ForceKeyframe 给编码线程;
// 3. 自适应分辨率(每 1s 评估)→ resolution_tx 下发 UpdateResolution
// 4. 从 webrtc_rx 取已编码 H264 帧,写入 str0m RTP sink。
// 暂停状态由 Arc<AtomicBool> 跨线程共享:编码线程读,本线程写。
fn webrtc_thread_loop(
mut wrtc: WebRtcState,
webrtc_rx: crossbeam_channel::Receiver<Vec<u8>>,
webrtc_rx: crossbeam_channel::Receiver<EncodedH264Frame>,
fps: u32,
enc_width: u32,
enc_height: u32,
max_bitrate: u64,
paused: Arc<AtomicBool>,
sent_gap_tx: crossbeam_channel::Sender<f64>,
sent_gap_tx: crossbeam_channel::Sender<(f64, Option<f64>)>,
bitrate_tx: crossbeam_channel::Sender<BitrateCommand>,
resolution_tx: crossbeam_channel::Sender<BitrateCommand>,
) {
@@ -696,6 +830,7 @@ fn webrtc_thread_loop(
let mut current_tier = initial_tier;
let mut upscale_counter = 0u32;
let mut last_resolution_eval = Instant::now();
// recv 超时 1ms——既能让循环周期性处理 str0m 信令,又能在帧到达时立即返回。
let timeout = Duration::from_millis(1);
loop {
@@ -713,6 +848,8 @@ fn webrtc_thread_loop(
}
let connected = wrtc.is_connected();
// Arc<AtomicBool> 跨线程协调:编码线程 Relaxed 读 paused;本线程 Relaxed 写。
// Relaxed 取舍:暂停标志无内存序需求(不保护其他共享数据),只需原子可见性。
let was_paused = paused.load(Ordering::Relaxed);
let now_paused = !connected;
if was_paused && !now_paused {
@@ -723,6 +860,19 @@ fn webrtc_thread_loop(
paused.store(now_paused, Ordering::Relaxed);
if let Some(bwe) = wrtc.get_bwe_estimate() {
// #23: Cap BWE to prevent runaway bitrate escalation. Without this, BWE
// estimates can rise to 10+ Mbps, causing IDR bursts and PLI storms.
let effective_bwe = bwe.min(max_bitrate);
if effective_bwe != bwe {
tracing::debug!(
bwe,
effective_bwe,
max_bitrate,
"BWE exceeds --max-bitrate cap, clamping"
);
}
let bwe = effective_bwe;
let should_send = match last_sent_bitrate {
None => true,
Some(last) => {
@@ -769,33 +919,45 @@ fn webrtc_thread_loop(
}
if connected {
while let Ok(data) = webrtc_rx.try_recv() {
if let Err(e) = wrtc.write_h264_frame(&data, frames_sent, fps) {
// 已连接:批量 drain 已编码帧队列(类比 Go `for { select { case f := <-rx: send(f); default: break } }`)。
// saturating_add 防止计数器溢出(Go 没有,Rust 默认 panic-on-overflowdebug 下尤其危险)。
while let Ok(enc_frame) = webrtc_rx.try_recv() {
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);
let gap_ms = last_send
.map(|l| l.elapsed().as_secs_f64() * 1000.0)
.unwrap_or(0.0);
// Compute capture-to-send age on the sending thread so the
// frame_age stat stays accurate when batch-drained later.
let age_ms =
Some(enc_frame.capture_time.elapsed().as_secs_f64() * 1000.0);
last_send = Some(std::time::Instant::now());
let _ = sent_gap_tx.try_send(gap_ms);
let _ = sent_gap_tx.try_send((gap_ms, age_ms));
}
} else {
// 未连接:丢弃积压帧防止 drain 时刻反向堆积(类比 Go `for { select { case <-rx: default: return } }`)。
while webrtc_rx.try_recv().is_ok() {}
}
// recv_timeout:阻塞至下一帧或最多 1ms——保证 str0m 信令循环周期性推进。
// 三路 ResultOk → 处理帧;Err(Timeout) → 继续下一轮循环处理信令;Err(Disconnected) → 编码线程已退出,本线程返回。
match webrtc_rx.recv_timeout(timeout) {
Ok(data) => {
Ok(enc_frame) => {
if wrtc.is_connected() {
if let Err(e) = wrtc.write_h264_frame(&data, frames_sent, 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);
let gap_ms = last_send
.map(|l| l.elapsed().as_secs_f64() * 1000.0)
.unwrap_or(0.0);
let age_ms =
Some(enc_frame.capture_time.elapsed().as_secs_f64() * 1000.0);
last_send = Some(std::time::Instant::now());
let _ = sent_gap_tx.try_send(gap_ms);
let _ = sent_gap_tx.try_send((gap_ms, age_ms));
}
}
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
@@ -809,12 +971,37 @@ fn webrtc_thread_loop(
tracing::info!("WebRTC thread exiting");
}
// 自适应分辨率阶梯(从高到低)。下标 0 = 最高分辨率(2K),下标 2 = 最低(720p)。
// BWE 不足时 select_resolution 从数组下标小的(高分辨率)向大的(低分辨率)切换;
// 反向 upscale 由 next_upscale_tier 处理,受 initial_tier 上限约束(不会超过初始分辨率)。
const RESOLUTION_TIERS: &[(u32, u32)] = &[(2560, 1440), (1920, 1080), (1280, 720)];
// 启发式码率估算:`5 × W × H × fps / 100` 即 0.05 bits/pixel/frame。
// 类似 H.264 平均量化参考值,作为 BWE 充分性判据(≥ 60% 认为可承载当前分辨率)。
fn resolution_bitrate_bps(width: u32, height: u32, fps: u32) -> u64 {
5 * u64::from(width) * u64::from(height) * u64::from(fps) / 100
}
// WebRTC 启动码率:按总像素数分 4 档(≤1M / ≤2.5M / ≤4.5M / 其他 → 1/2/4/8 Mbps)。
// 仅影响客户端连接后第一个 IDR;BWE 估计(毫秒级到达)会覆盖此值。详见 issue #21。
/// Conservative startup bitrate for WebRTC mode, tier-based by total pixel count.
/// BWE estimate arrives within milliseconds of client connect and overrides this;
/// the startup value only affects the first IDR. See issue #21.
fn webrtc_startup_bitrate_bps(width: u32, height: u32) -> u64 {
let pixels = u64::from(width) * u64::from(height);
if pixels <= 1_000_000 {
1_000_000
} else if pixels <= 2_500_000 {
2_000_000
} else if pixels <= 4_500_000 {
4_000_000
} else {
8_000_000
}
}
// 基于 BWE 选择分辨率阶梯。返回 (width, height)。
// 决策逻辑:若 BWE ≥ 当前分辨率所需码率的 60%,保持不变;否则降到下一档(最低 720p)。
/// Select resolution tier based on BWE estimate.
/// Returns (width, height) for the selected tier.
fn select_resolution(current_w: u32, current_h: u32, bwe_bps: u64, fps: u32) -> (u32, u32) {
@@ -824,6 +1011,8 @@ fn select_resolution(current_w: u32, current_h: u32, bwe_bps: u64, fps: u32) ->
return current;
}
// 在 RESOLUTION_TIERS 中找当前分辨率的位置;若不在表中(如 1366×768),
// 用 unwrap_or_else 退回到第一个宽高都不超过 current 的档位,最终兜底取最小档(720p)。
let current_index = RESOLUTION_TIERS
.iter()
.position(|&tier| tier == current)
@@ -837,12 +1026,16 @@ fn select_resolution(current_w: u32, current_h: u32, bwe_bps: u64, fps: u32) ->
RESOLUTION_TIERS[next_index]
}
// 反向 upscale:在 ceiling 上限内尝试升一档;若已在最高档或下一档超出 ceiling 则返回 None。
// 调用方需要"连续 10 次 BWE 充足"才真正切换,避免 BWE 抖动导致频繁分辨率变化。
fn next_upscale_tier(current: (u32, u32), ceiling: (u32, u32)) -> Option<(u32, u32)> {
let current_index = RESOLUTION_TIERS.iter().position(|&tier| tier == current)?;
if current_index == 0 {
return None;
}
let next = RESOLUTION_TIERS[current_index - 1];
// bool::then_some(true → Some(next)false → None):将谓词结果转换为 Option,
// 类比 Go `if ok { return &tier } else { return nil }`。
(next.0 <= ceiling.0 && next.1 <= ceiling.1).then_some(next)
}
@@ -893,6 +1086,10 @@ fn resolve_drm_device(args: &Args) -> Result<Option<PathBuf>> {
/// 用于验证 DMA-BUF 元数据映射的正确性。
#[cfg(test)]
fn build_drm_descriptor(frame: &PwDmaBufFrame) -> ffmpeg_next::ffi::AVDRMFrameDescriptor {
// unsafe:调用 std::mem::zeroed() 对 #[repr(C)] 结构体进行零初始化——
// AVDRMFrameDescriptor 是 FFmpeg C 结构体,零值是合法的"空"状态(nb_objects/nb_layers=0
// 后续字段在下方显式赋值)。`std::mem::zeroed` 对带指针字段的类型可能产生空悬指针(UB),
// 此处安全:descriptor 的所有字段都是整数/数组,没有指针/引用。
let mut desc: ffmpeg_next::ffi::AVDRMFrameDescriptor = unsafe { std::mem::zeroed() };
desc.nb_objects = 1; // 单个 DMA-BUF 对象
desc.objects[0].fd = frame.fd.as_raw_fd(); // DMA-BUF 文件描述符
@@ -916,6 +1113,8 @@ mod tests {
fn make_test_frame() -> PwDmaBufFrame {
// Create a dummy fd from stderr (always valid fd 2)
// 使用 stderr(fd 2)的副本作为虚拟文件描述符
// unsafelibc::dup(2) 复制 stderr fd → 返回新整数 fdOwnedFd::from_raw_fd 接管
// 该 fd 的 close 责任(RAII)。前提:libc::dup 调用成功(fd 2 始终有效,不检查返回值是测试代码约定)。
let fd = unsafe { OwnedFd::from_raw_fd(libc::dup(2)) };
PwDmaBufFrame {
fd,
@@ -956,6 +1155,7 @@ mod tests {
hw_accel: "vaapi".to_string(),
drm_device: Some("/dev/dri/renderD128".to_string()),
bitrate: None,
max_bitrate: 8_000_000,
gop_size: None,
verbose: false,
backend: None,
@@ -980,6 +1180,7 @@ mod tests {
hw_accel: "vaapi".to_string(),
drm_device: None,
bitrate: None,
max_bitrate: 8_000_000,
gop_size: None,
verbose: false,
backend: None,
@@ -991,6 +1192,14 @@ mod tests {
assert_eq!(result, None);
}
#[test]
fn webrtc_startup_bitrate_tiers_by_pixel_count() {
assert_eq!(webrtc_startup_bitrate_bps(1280, 720), 1_000_000);
assert_eq!(webrtc_startup_bitrate_bps(1920, 1080), 2_000_000);
assert_eq!(webrtc_startup_bitrate_bps(2560, 1440), 4_000_000);
assert_eq!(webrtc_startup_bitrate_bps(3840, 2160), 8_000_000);
}
#[test]
fn select_resolution_downscales_one_tier_below_sixty_percent() {
let fps = 30;
@@ -1029,6 +1238,7 @@ mod tests {
#[test]
fn build_drm_descriptor_custom_offset_and_stride() {
let frame = PwDmaBufFrame {
// unsafe:同 make_test_frame——dup(2) 复制 stderr fd 并交给 OwnedFd 管理。
fd: unsafe { OwnedFd::from_raw_fd(libc::dup(2)) },
offset: 4096, // 4KB 对齐偏移
stride: 3840 * 4, // 4K 宽度 × 4 字节
+292 -4
View File
@@ -1,3 +1,25 @@
//! 管道性能统计模块 —— 用于卡顿诊断的轻量级滑动窗口统计。
//!
//! 本模块跟踪 capture / encode / send 三段流水线的每秒指标快照:
//! - **FPS**:捕获帧率、编码帧率、发送帧率
//! - **延迟分布**:各阶段(DMA-BUF import / VAAPI scale / GPU→CPU transfer /
//! sws_scale / H.264 encode)的 avg / p95 / max(微秒→毫秒)
//! - **队列深度**capture 队列与 encoded 队列的瞬时观测值
//! - **丢帧计数**PipeWire 丢弃、重复帧去重跳过、超预算帧
//!
//! 设计目标为低开销:仅收集计数器和时间样本,每秒输出一行结构化日志
//! (仅在 `--stats` 启用时)。所有统计在主线程独占持有 `&mut PipelineStats`
//! 跨线程数据(如 PipeWire dropped 计数、encode 线程的 duplicate 计数)
//! 通过外部 `AtomicU64` 在调用方读取后再传入本结构(见 `set_*` 系列)。
//!
//! ## 与 Go 类比
//!
//! - [`Instant::now()`] ≈ Go `time.Now()`,但精度更高(通常单调时钟)
//! - [`Duration::as_secs_f64`] ≈ Go `time.Duration.Seconds()`,但保留 f64
//! - `&mut self` ≈ Go 中显式持有 `sync.Mutex` 的写锁;本模块的字段独占模型
//! 天然无需 `Mutex`(外部跨线程读取后再以 `&mut self` 传入)
//! - `Vec<f64>` 样本缓冲 ≈ Go 中 `[]float64`,每窗口 `clear()` 复用容量
// stats.rs — Lightweight windowed pipeline statistics for stutter diagnosis
//
// Tracks per-second snapshots of capture/encode/send pipeline metrics.
@@ -6,6 +28,15 @@
use std::time::Instant;
/// 单帧流水线各阶段的耗时样本(中文概要,详细说明见下方英文文档)。
///
/// # 派生宏说明
///
/// - `#[derive(Debug)]`:便于 `dbg!()` 调试输出,类比 Go 的 `%+v` 格式化
/// - `#[derive(Default)]`:所有字段为 `u64`/`usize` 零值时构造默认实例,
/// 测试中可用 `FrameTimings { total_us: 5000, ..Default::default() }`
/// 仅指定关注字段(见 `record_and_snapshot_counts` 测试)
///
/// Per-stage timing for a single encode pipeline frame.
///
/// All values are in microseconds. The caller records timestamps around
@@ -28,6 +59,27 @@ pub struct FrameTimings {
pub output_bytes: usize,
}
/// 一秒窗口内的管道统计聚合器(中文概要,详细说明见下方英文文档)。
///
/// 本结构通过 `&mut self` 接口收集三类原始数据:计数器(`capture_frames` 等)、
/// 样本缓冲(`Vec<f64>`/`Vec<u64>`,窗口结束时 `clear()` 复用容量)、
/// 时间锚点(`Option<Instant>``None` 表示尚未观测过)。
///
/// # 所有权与并发模型
///
/// - 本结构**非 `Sync`**:`Vec` 字段无锁,跨线程读写需外部同步
/// - 主线程独占持有 `&mut self`;跨线程数据通过外部 `AtomicU64` 在调用方
/// 读取后以 `set_*` 接口注入
/// - 这与 Go 中 `sync.Mutex<PipelineStats>` 不同:Rust 借用检查器在编译期
/// 保证单一可变借用,无需运行时锁
///
/// # 与 Go 类比
///
/// - `Option<Instant>` ≈ Go `*time.Time``nil` 表示未设置),但 Rust 用枚举
/// 强制调用方处理"未设置"分支,避免 nil-pointer panic
/// - `Instant` 内部使用单调时钟,不受系统时间跳变影响;Go 1.9+ 的
/// `time.Since()` 也使用单调时钟,行为一致
///
/// Windowed statistics aggregator for the encode/send pipeline.
///
/// Collects counters and timing samples within a one-second window,
@@ -39,6 +91,12 @@ pub struct PipelineStats {
sent_frames: u64,
pipewire_dropped: u64,
over_budget_count: u64,
/// Count of frames dropped by encode thread due to Y-plane hash dedup
/// (EncodeOutcome::SkippedDuplicate). Read from atomic counter set by
/// encode thread, computed as delta since previous snapshot.
duplicate_frames_skipped: u64,
// Running total from the encode-thread atomic; NOT reset between windows.
prev_duplicate_frames_skipped: u64,
// --- queue depth at last observation ---
capture_queue_depth: usize,
@@ -68,6 +126,11 @@ pub struct PipelineStats {
}
impl PipelineStats {
/// 构造一个空的统计聚合器(类比 Go 的 `NewXxx()` 工厂函数)。
///
/// `window_start` 初始化为当前时刻,确保 `should_snapshot()` 至少
/// 在 1 秒后才返回 true(首窗口可能短于 1 秒有效数据,但 elapsed_secs
/// 是真实窗口长度,FPS 计算依然准确)。
pub fn new() -> Self {
Self {
capture_frames: 0,
@@ -75,6 +138,8 @@ impl PipelineStats {
sent_frames: 0,
pipewire_dropped: 0,
over_budget_count: 0,
duplicate_frames_skipped: 0,
prev_duplicate_frames_skipped: 0,
capture_queue_depth: 0,
encoded_queue_depth: 0,
capture_gaps_ms: Vec::new(),
@@ -96,6 +161,21 @@ impl PipelineStats {
}
}
/// 记录一次来自 PipeWire 的捕获帧到达事件(中文 L3 解析见此)。
///
/// # 时间间隔(gap)计算
///
/// - [`Instant::now()`]:获取当前单调时刻(≈ Go `time.Now()`,但精度更高)
/// - `last.elapsed()`:返回 `Duration`,类比 Go `time.Since(last)`
/// - [`Duration::as_secs_f64`]:将 `Duration` 转为秒(f64),类比 Go
/// `dur.Seconds()`;此处乘以 1000.0 转毫秒,便于日志可读
///
/// # 首帧处理
///
/// `Option<Instant>::None` 表示窗口内首帧,没有"上一帧"参照点,
/// 因此首帧不产生 gap 样本(这与 Go 中 `*time.Time == nil` 检查等价,
/// 但 Rust 强制处理 None 分支,编译期避免 nil 解引用)。
///
/// Record that a capture frame was received from PipeWire.
pub fn record_capture(&mut self) {
let now = Instant::now();
@@ -107,6 +187,14 @@ impl PipelineStats {
self.capture_frames += 1;
}
/// 记录一帧完成编码(`FrameTimings` 路径,含各阶段微秒样本)。
///
/// # 参数借用
///
/// `timings: &FrameTimings`:以共享借用(`&`)读取,不获取所有权。
/// 类比 Go 中显式传递 `*FrameTimings` 指针;Rust 借用检查保证本调用
/// 期间原 `timings` 不会被释放。其余 gap 计算同 `record_capture`。
///
/// Record that a frame completed encoding with the given timings.
pub fn record_encode(&mut self, timings: &FrameTimings) {
let now = Instant::now();
@@ -126,10 +214,19 @@ impl PipelineStats {
self.output_bytes.push(timings.output_bytes);
}
/// 仅记录 import 阶段微秒数(用于 `record_encode_thread` 路径补齐 import 样本)。
pub fn record_import(&mut self, import_us: u64) {
self.import_us.push(import_us);
}
/// 编码线程路径:散参传入 sws / encode / output_bytes,跳过 `FrameTimings`。
///
/// # 溢出保护
///
/// `saturating_add` 在 `u64::MAX` 处饱和而非回绕,避免极端情况下
/// `total_us` 出现荒谬的小值。类比 Go 中需手动 `if total > MaxUint64 - x`
/// 检查;Rust 的 `saturating_*` / `checked_*` / `wrapping_*` 三件套
/// 让溢出策略在调用点显式表达。
pub fn record_encode_thread(&mut self, sws_us: u64, encode_us: u64, output_bytes: usize) {
let now = Instant::now();
if let Some(last) = self.last_encode_time {
@@ -145,6 +242,19 @@ impl PipelineStats {
self.output_bytes.push(output_bytes);
}
/// 记录一帧通过 WebRTC 发送(中文 L3 解析见此)。
///
/// - `wait_ms`:阻塞等待发送通道可写入的时间(毫秒);为 0 时不入样本
/// (避免拉低 p95,因为大多数帧应无等待)
/// - `capture_time`:该帧的原始捕获时刻;用于计算 **frame age**
/// (捕获→发送端到端延迟)。`Option<None>` 表示调用方未提供
/// (例如 XDG/screen-copy 路径无原始时间戳),此时不入样本
///
/// # frame_age 计算
///
/// `ct.elapsed()` 返回 `Duration`,类同 `record_capture` 中 gap 计算,
/// 但锚点是"捕获时刻"而非"上一帧发送时刻",因此测量的是端到端延迟。
///
/// Record that a frame was sent via WebRTC.
/// `wait_ms` is time spent blocked waiting to send into the channel.
/// `capture_time` is when the frame was originally captured (for frame age).
@@ -166,39 +276,109 @@ impl PipelineStats {
}
}
/// 从后台 WebRTC 发送线程记录一帧(gap_ms / age_ms 均已在调用方预算好)。
///
/// # 为何预算参数
///
/// 后台线程无法安全访问 `&mut self`(本结构非 `Sync`),因此调用方在
/// 发送时刻直接计算 `gap_ms` / `age_ms``Instant::now()` 在该线程
/// 局部调用),稍后批量 drain 到主线程的 `&mut self`。这样:
/// - 单调时钟读取在事件发生线程完成,时间戳精确
/// - 主线程仅做 `Vec::push`,无需锁
///
/// `gap_ms == 0.0` 表示首帧(无前一帧参照),不入样本。
///
/// Record a frame sent from a background WebRTC thread.
/// `gap_ms` is the pre-computed time since the previous send (0.0 = first frame).
/// Unlike `record_send`, this does not sample `Instant::now()`, so it remains
/// accurate even when batch-drained at stats snapshot time.
pub fn record_send_from_thread(&mut self, gap_ms: f64) {
/// `age_ms` is the pre-computed capture-to-send latency (None if unavailable).
/// Both are pre-computed on the sending thread to remain accurate when
/// batch-drained at stats snapshot time on the main thread.
pub fn record_send_from_thread(&mut self, gap_ms: f64, age_ms: Option<f64>) {
if gap_ms > 0.0 {
self.sent_gaps_ms.push(gap_ms);
}
self.sent_frames += 1;
if let Some(age) = age_ms {
self.frame_age_ms.push(age);
}
}
/// 设置 PipeWire dropped 计数(绝对值,由调用方从外部 `AtomicU64` 读取)。
///
/// # 增量计算
///
/// 外部 `AtomicU64` 累计**总会话**的 dropped 帧数(从不重置),
/// 因此本函数计算 `total - prev` 得到本窗口内的增量。
/// `saturating_sub` 防止极端竞态(如原子读顺序不一致)导致负数回绕。
///
/// # 与 Go 类比
///
/// - 调用方代码 ≈ Go `atomic.LoadUint64(&pw.dropped)``Ordering::SeqCst`
/// 或 `Relaxed` 取决于是否需要与其他原子操作建立 happens-before
/// - `Mutex<HashMap>` 在本模块**未使用**:统计字段集固定,无需 Go
/// `sync.Map` 那样的动态键值存储;跨线程仅通过原子计数器通信
///
/// Update PipeWire dropped counter (absolute value from AtomicU64).
pub fn set_pipewire_dropped(&mut self, total_dropped: u64, prev_dropped: u64) {
self.pipewire_dropped = total_dropped.saturating_sub(prev_dropped);
}
/// 设置 duplicate frames skipped 计数(绝对值,由调用方从 encode 线程原子读取)。
///
/// 与 `set_pipewire_dropped` 增量算法一致,但**保留 `prev`** 在
/// `self.prev_duplicate_frames_skipped` 字段中(因为本结构才是状态持有者,
/// 调用方仅传入当前 total)。
///
/// Update duplicate frames skipped counter (absolute value from atomic).
/// Computes delta from previous value, like set_pipewire_dropped.
pub fn set_duplicate_frames_skipped(&mut self, total_skipped: u64) {
self.duplicate_frames_skipped = total_skipped.saturating_sub(self.prev_duplicate_frames_skipped);
self.prev_duplicate_frames_skipped = total_skipped;
}
/// 更新队列深度瞬时观测值(capture 队列与 encoded 队列各一个值)。
///
/// 队列深度为快照值而非累计值,每窗口只保留最后一次观测。
///
/// Update queue depth observations.
pub fn set_queue_depths(&mut self, capture: usize, encoded: usize) {
self.capture_queue_depth = capture;
self.encoded_queue_depth = encoded;
}
/// 记录一帧超出预算(用于跟踪编码耗时超过 1/fps 的频次)。
///
/// Record that a frame exceeded its time budget.
pub fn record_over_budget(&mut self) {
self.over_budget_count += 1;
}
/// 判断是否到达快照点(距离上次 `snapshot_and_reset` 或构造时刻 ≥ 1 秒)。
///
/// 仅需 `&self`(共享借用):本方法不修改任何字段,借用检查器允许
/// 多个 `&self` 共存或与 `&mut self` 之外的调用并存。
///
/// Returns true if at least 1 second has elapsed since the last snapshot
/// (or since creation). If true, call `snapshot_and_reset` to get the stats.
pub fn should_snapshot(&self) -> bool {
self.window_start.elapsed().as_secs() >= 1
}
/// 计算当前窗口的统计快照并重置所有计数器与样本缓冲。
///
/// # 重置策略
///
/// - 计数器:置零
/// - `Vec`:调用 `clear()`(保留已分配容量 `Vec::capacity()`,避免下个窗口
/// 反复分配)。类比 Go 中 `s = s[:0]` 复用底层数组
/// - `window_start = Instant::now()`:重置窗口起点
///
/// # 返回值
///
/// 返回 `StatsSnapshot` 值(拷贝语义,调用方可自由使用与丢弃)。
/// 类比 Go 中返回值结构体的拷贝;Rust 中 `StatsSnapshot` 全部字段为
/// `Copy` 或 `Vec`(移动语义),返回时所有权转移至调用方。
///
/// Compute a snapshot of the current window and reset all counters.
pub fn snapshot_and_reset(&mut self) -> StatsSnapshot {
let elapsed = self.window_start.elapsed().as_secs_f64();
@@ -212,6 +392,7 @@ impl PipelineStats {
sent_frames: self.sent_frames,
pipewire_dropped: self.pipewire_dropped,
over_budget_count: self.over_budget_count,
duplicate_frames_skipped: self.duplicate_frames_skipped,
capture_queue_depth: self.capture_queue_depth,
encoded_queue_depth: self.encoded_queue_depth,
capture_gap_avg_ms: avg_f64(&self.capture_gaps_ms),
@@ -250,6 +431,7 @@ impl PipelineStats {
self.sent_frames = 0;
self.pipewire_dropped = 0;
self.over_budget_count = 0;
self.duplicate_frames_skipped = 0;
self.capture_queue_depth = 0;
self.encoded_queue_depth = 0;
self.capture_gaps_ms.clear();
@@ -270,6 +452,17 @@ impl PipelineStats {
}
}
/// 一秒窗口的管道统计快照(不可变值对象,由 `snapshot_and_reset` 返回)。
///
/// 本结构持有所有派生指标(FPS、avg/p95/max、计数器快照),是日志输出的
/// 数据源。一旦创建即不可变(所有字段为 `f64`/`u64`/`usize`,天然 `Copy`),
/// 调用方可以安全地打印、记录或丢弃。
///
/// # `#[derive(Debug)]` 用途
///
/// 调试场景下可直接 `dbg!(&snap)` 或 `tracing::debug!(?snap)`
/// 类比 Go 的 `spew.Dump(snap)` / `fmt.Printf("%+v", snap)`。
///
/// A one-second snapshot of pipeline statistics.
#[derive(Debug)]
pub struct StatsSnapshot {
@@ -284,6 +477,7 @@ pub struct StatsSnapshot {
pub sent_frames: u64,
pub pipewire_dropped: u64,
pub over_budget_count: u64,
pub duplicate_frames_skipped: u64,
// Queue depths
pub capture_queue_depth: usize,
pub encoded_queue_depth: usize,
@@ -322,12 +516,46 @@ pub struct StatsSnapshot {
pub output_frame_bytes_max: usize,
}
/// 单行结构化日志格式化器(中文 L3 解析见此,英文说明保留在下方)。
///
/// # trait 与签名说明
///
/// - `impl std::fmt::Display for StatsSnapshot`:为本类型实现标准库 trait,
/// 使得 `format!("{snap}")` / `println!("{}", snap)` / `tracing::info!("{}", snap)`
/// 均可工作(隐式调用 `fmt` 方法)
/// - `fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result`
/// - `&self` 共享借用,格式化不改自身
/// - `Formatter<'_>`:匿名生命周期(`'_`),表示该借用与调用方持有的
/// `String`/输出流绑定;类比 Go 的 `io.Writer` 参数
/// - `std::fmt::Result``Result<(), std::fmt::Error>`,专用于格式化 trait
/// (比通用 `Result<T, E>` 更窄,避免 `?` 跨类型传播)
///
/// # `write!` 宏 vs `format!` 宏
///
/// - [`write!`]:直接写入 `Formatter`(零分配),类比 Go `fmt.Fprintf(w, ...)`
/// - [`format!`]:分配新 `String` 后返回,类比 Go `fmt.Sprintf(...)`
/// - 本实现选 `write!`:写入日志流时避免多余分配
///
/// # `?` 运算符
///
/// `write!(...)?` 中的 `?` 是早期返回:若 `write!` 返回 `Err(fmt::Error)`
/// 则立即从 `fmt` 返回该错误。类比 Go 中
/// `if _, err := w.Write(...); err != nil { return err }`
/// 但 Rust 的 `?` 让快乐路径线性化。
///
/// # 格式说明
///
/// - `{:.1}`:保留 1 位小数
/// - `{:.0}`:整数显示(无小数点)
/// - `{}`:默认 `Display` 格式(整数原样)
/// - 行尾反斜杠 `\` 跨行延续字符串字面量,类比 Python 隐式行连接;
/// 输出时不会引入额外换行或空格
impl std::fmt::Display for StatsSnapshot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"capture_fps={:.1} encoded_fps={:.1} sent_fps={:.1} \
pw_dropped={} over_budget={} \
pw_dropped={} over_budget={} duplicate_frames_skipped={} \
cap_q={} enc_q={} \
cap_gap_p95={:.1}ms cap_gap_max={:.1}ms \
enc_gap_p95={:.1}ms enc_gap_max={:.1}ms \
@@ -342,6 +570,7 @@ impl std::fmt::Display for StatsSnapshot {
self.sent_fps,
self.pipewire_dropped,
self.over_budget_count,
self.duplicate_frames_skipped,
self.capture_queue_depth,
self.encoded_queue_depth,
self.capture_gap_p95_ms,
@@ -369,6 +598,13 @@ impl std::fmt::Display for StatsSnapshot {
// Statistics helpers
// ---------------------------------------------------------------------------
/// 计算 `f64` 切片平均值(空切片返回 0.0)。
///
/// # 切片借用
///
/// `data: &[f64]`:共享借用切片(fat pointer = 指针 + 长度),类比 Go 中
/// `func avg(data []float64)`。`&` 表示本函数不获取所有权,调用后原 `Vec`
/// 仍可用。
fn avg_f64(data: &[f64]) -> f64 {
if data.is_empty() {
return 0.0;
@@ -376,6 +612,21 @@ fn avg_f64(data: &[f64]) -> f64 {
data.iter().sum::<f64>() / data.len() as f64
}
/// 计算 p95(第 95 百分位),类比 Go 中需要手动 sort + index。
///
/// # 算法
///
/// 1. 复制输入到新 `Vec`(不修改调用方原数据):`data.to_vec()` 类比 Go
/// `append([]T{}, data...)`
/// 2. 排序:`sort_by` + `partial_cmp` —— `f64` 没有全序(NaN 特殊),
/// 不能直接用 `sort()``partial_cmp(b).unwrap_or(Equal)` 在 NaN 时
/// 降级为相等,避免 panic
/// 3. 计算 idx = `floor(len * 0.95)``idx.min(len-1)` 防越界
///
/// # 为何不用 `sort_unstable`
///
/// `f64` 的 `Ord` 未实现(NaN 不等于自身),故只能用 `sort_by` + 比较
/// 函数;`u64`/`usize` 实现 `Ord`,可用 `sort_unstable`(更快、内存友好)。
fn p95_f64(data: &[f64]) -> f64 {
if data.is_empty() {
return 0.0;
@@ -386,10 +637,22 @@ fn p95_f64(data: &[f64]) -> f64 {
sorted[idx.min(sorted.len() - 1)]
}
/// 返回切片最大值,空切片返回 0.0(业务上"无样本"等价于"无延迟")。
///
/// # `fold` + `f64::max`
///
/// `fold(0.0, f64::max)`:从初始值 0.0 开始,逐元素取较大值。
/// 类比 Go
/// ```go
/// m := 0.0
/// for _, v := range data { m = math.Max(m, v) }
/// ```
/// 注意:若样本全为负,0.0 仍是结果(业务上 latency 非负,不会出现)。
fn max_f64(data: &[f64]) -> f64 {
data.iter().copied().fold(0.0_f64, f64::max)
}
/// 计算 `u64` 微秒样本的平均值并转毫秒(÷1000)。
fn avg_ms(data: &[u64]) -> f64 {
if data.is_empty() {
return 0.0;
@@ -397,6 +660,10 @@ fn avg_ms(data: &[u64]) -> f64 {
data.iter().sum::<u64>() as f64 / data.len() as f64 / 1000.0
}
/// 计算 `u64` 微秒样本的 p95 并转毫秒。
///
/// 与 `p95_f64` 算法相同,但 `u64` 实现 `Ord`,可用 `sort_unstable`
/// (无内存开销、更快;稳定性对本场景无关,因为只取索引位置)。
fn p95_ms(data: &[u64]) -> f64 {
if data.is_empty() {
return 0.0;
@@ -407,10 +674,15 @@ fn p95_ms(data: &[u64]) -> f64 {
sorted[idx.min(sorted.len() - 1)] as f64 / 1000.0
}
/// 对 `usize` 切片求和(用于累计 output_bytes 总量)。
///
/// `data.iter().sum()` 由标准库自动推导类型(`usize`),等价于
/// Go 中 `var total uint; for _, v := range data { total += v }`。
fn sum_usize(data: &[usize]) -> usize {
data.iter().sum()
}
/// 计算 `usize` 样本的 p95(字节大小分布),不转换单位。
fn p95_usize(data: &[usize]) -> usize {
if data.is_empty() {
return 0;
@@ -421,10 +693,26 @@ fn p95_usize(data: &[usize]) -> usize {
sorted[idx.min(sorted.len() - 1)]
}
/// 返回 `usize` 切片最大值,空切片返回 0。
///
/// `iter().copied().max()` 返回 `Option<usize>`(空时为 `None`),
/// `unwrap_or(0)` 提供默认值,类比 Go 中显式 `if len(data) == 0 { return 0 }`。
fn max_usize(data: &[usize]) -> usize {
data.iter().copied().max().unwrap_or(0)
}
/// 单元测试模块(仅在 `#[cfg(test)]` 时编译)。
///
/// # Rust 测试模式
///
/// - `#[cfg(test)]`:条件编译属性,`cargo test` 时才编译本模块,
/// 正常 `cargo build` 不包含本模块代码(类比 Go 中 `_test.go` 后缀
/// 仅在 `go test` 时段编译,但 Rust 用显式属性而非文件名约定)
/// - `use super::*`:导入父模块(本文件)所有 `pub` 与私密 item
/// 类比 Go test 文件无需 import 即可访问同包符号
/// - `#[test]`:标记测试函数;`cargo test` 自动发现并执行
/// - `assert_eq!` / `assert!`:宏(不是函数),失败时打印表达式原文
/// 便于调试,类比 Go 中 `t.Errorf` 但更早终止当前测试
#[cfg(test)]
mod tests {
use super::*;
+101
View File
@@ -1,3 +1,33 @@
//! 图像几何变换模块(纯坐标运算,不涉及像素缓冲区)。
//!
//! 对应 Wayland `wl_output::Transform` 的 8 种旋转变体(旋转 + 翻转),
//! 为屏幕捕获提供 ROIRegion of Interest)裁剪与坐标系换算。
//!
//! # 与 Go 的对照
//!
//! - Go 标准库 `image/geom.go` 的 `Rectangle` 仅支持轴对齐矩形;本模块额外处理
//! 90°/180°/270° 旋转与水平/垂直翻转下的矩形映射。
//! - Go 用 `int` 表示坐标;本模块用 `i32`(与 `wl_output` 协议一致)。
//! - Wayland 协议要求捕获 ROI 在变换后的"帧坐标"中给出,本模块负责
//! "屏坐标 → 帧坐标"的换算(见 [`screen_to_frame`])。
//!
//! 注意:本模块**不操作像素缓冲区**(无 `&[u8]` / `Vec::with_capacity`),
//! 只做整数算术;真正的像素拷贝在 `state.rs` / `cap_portal.rs` 中通过
//! DMA-BUF 或 shm 完成。计划文档中提到的 `&[u8]` slice / `Vec` 预分配
//! 等模式不属于本模块,本模块的"重量级"Rust 模式聚焦在 `match` 穷尽匹配、
//! 元组解构、if 表达式、or-pattern 与整数 helper 方法(`.abs()`/`.clamp()`)。
// Wayland `wl_output::Transform` 的 8 种变体:4 种纯旋转(Normal*+ 4 种
// "先水平翻转再旋转"Flipped*)。单元 enum(无关联数据),`Copy + Eq` 派生
// 使其可在 `match` / `==` 中零开销使用。
//
// Go 没有内置 enum,等价于 `type Transform int` + `const ( Normal = iota; ... )`
// Rust 的 enum 是真代数类型,编译期保证 `match` 穷尽性(漏写一个 variant
// 会直接编译失败,而 Go 的 switch 不强制 default)。
//
// `#[derive(...)]` 宏说明:`Debug`→允许 `{:?}` 调试输出;`Clone, Copy`→
// 单元 enum 按位复制即可(等价于 Go 整数值语义);`PartialEq, Eq`→自动生成
// `==`/`!=`,基于 variant tag 比较。
/// Coordinate transformation module for Wayland output transforms.
///
/// Handles the 8 `wl_output` transform variants (rotation + reflection)
@@ -16,6 +46,11 @@ pub enum Transform {
Flipped270,
}
// 轴对齐矩形(Axis-Aligned Bounding BoxAABB)。
//
// 所有字段 `i32`(与 Wayland 协议一致);Go 类比 `image.Rectangle` 但
// 用 `(x, y, w, h)` 而非 `(Min, Max)`,便于直接喂给 FFmpeg VAAPI 的 ROI 参数。
// `Copy + Eq`:值语义,函数传参/返回零开销(无 `&Rect` 借用开销)。
/// Axis-aligned rectangle in integer coordinates.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rect {
@@ -25,6 +60,11 @@ pub struct Rect {
pub h: i32,
}
// 返回变换对应的 2×2 基础矩阵 `(a, b, c, d)`。
//
// 这是纯算术查表,无副作用,编译器很容易内联到调用点。返回值用 4-tuple 而非
// `[i32; 4]` 数组:Rust 元组每字段可有不同类型(此处都是 i32 但语义不同),
// 模式匹配解构时更显式(`let (a, b, c, d) = ...`)。
/// Returns the 2×2 basis matrix (a, b, c, d) for the given transform.
///
/// The matrix represents the affine mapping from screen coordinates to
@@ -35,18 +75,37 @@ pub struct Rect {
/// [new_y] = [c d] [y]
/// ```
pub fn transform_basis(transform: Transform) -> (i32, i32, i32, i32) {
// `match` 是 Rust 的模式匹配控制流,对 enum 必须**穷尽**exhaustive):
// 漏写任意 variant 会直接编译失败。Go 的 `switch` 不强制 default
// 此处 8 个 variant 必须全部列出,编译器即充当完整性检查器。
//
// 每个 arm 形如 `Pattern => expr,`,返回的 4-tuple 编码矩阵系数。
// 这些数值来自 Wayland `wl_output::Transform` 协议规范,不可随意修改。
match transform {
// 单位矩阵:屏幕坐标 = 帧坐标。
Transform::Normal => (1, 0, 0, 1),
// 顺时针 90°:x/y 互换并取反。
Transform::Normal90 => (0, 1, -1, 0),
// 180°:两轴都取反。
Transform::Normal180 => (-1, 0, 0, -1),
// 顺时针 270°(= 逆时针 90°)。
Transform::Normal270 => (0, -1, 1, 0),
// 水平翻转(沿 Y 轴镜像):x 取反。
Transform::Flipped => (-1, 0, 0, 1),
// 翻转 + 90°。
Transform::Flipped90 => (0, 1, 1, 0),
// 翻转 + 180°(等价于垂直翻转)。
Transform::Flipped180 => (1, 0, 0, -1),
// 翻转 + 270°。
Transform::Flipped270 => (0, -1, -1, 0),
}
}
// 将矩形从"屏幕坐标"映射到"帧坐标",并平移到第一象限([0, frame_w) × [0, frame_h))。
//
// 这是 ROI(捕获区域)参数换算的核心:用户在屏幕上选了一块 `(x, y, w, h)`
// 但 Wayland 帧已应用了 output transform(例如 90° 旋转),编码器看到的帧
// 坐标与屏幕坐标不同,必须先变换再喂给 VAAPI。
/// Transform a rectangle from screen space to frame space.
///
/// Applies the 2×2 basis matrix and computes offsets so the result
@@ -57,11 +116,17 @@ pub fn transform_basis(transform: Transform) -> (i32, i32, i32, i32) {
/// new_y = c * x + d * y + offset_y
/// ```
pub fn screen_to_frame(transform: Transform, rect: Rect, frame_w: i32, frame_h: i32) -> Rect {
// 元组解构(tuple destructuring):4-tuple 一次性拆成 4 个 `i32` 变量。
// 类比 Go 的 `a, b, c, d := transformBasis(transform)`,但 Rust 的元组
// 是真类型(可作为参数/返回值),Go 只能用多返回值模拟。
let (a, b, c, d) = transform_basis(transform);
// Compute the offset so that the transformed origin maps correctly.
// For transforms with negative components, we need to shift by the
// frame dimension to keep coordinates in [0, frame_w) × [0, frame_h).
// `if ... { ... } else { ... }` 在 Rust 中是**表达式**(而非语句),
// 直接产出值赋给 `offset_x`。Go 没有 ternary,必须 `var offset_x int;
// if ... { offset_x = frame_w }`Rust 这种写法更紧凑。
let offset_x = if a + b < 0 { frame_w } else { 0 };
let offset_y = if c + d < 0 { frame_h } else { 0 };
@@ -70,6 +135,13 @@ pub fn screen_to_frame(transform: Transform, rect: Rect, frame_w: i32, frame_h:
let new_w = a * rect.w + b * rect.h;
let new_h = c * rect.w + d * rect.h;
// 结构体字面量(struct literal):`Rect { x: ..., y: ..., ... }`。
// 类比 Go 的 `image.Rectangle{Min: ..., Max: ...}`Rust 允许字段简写
//(变量名与字段名相同时只写一个,例如 `x` 而非 `x: x`)。
//
// `.abs()` 是 `i32` 的内置方法(取绝对值):
// 旋转后 `new_w`/`new_h` 可能为负(例如 90° 下宽变成原高取反),
// 矩形尺寸必须非负,故取绝对值。
Rect {
x: new_x,
y: new_y,
@@ -78,32 +150,61 @@ pub fn screen_to_frame(transform: Transform, rect: Rect, frame_w: i32, frame_h:
}
}
// 90°/270° 旋转变换下,输出画布的宽高需要交换(横向屏幕旋转后变纵向)。
//
// 辅助函数:是则返回 `(h, w)`,否则原样返回 `(w, h)`。Go 类比:
// ```go
// func transposeIf(t Transform, w, h int) (int, int) {
// switch t { case Normal90, Normal270, Flipped90, Flipped270: return h, w }
// return w, h
// }
// ```
/// Swap width and height for 90° or 270° rotations.
///
/// After a quarter-turn rotation the output dimensions are transposed
/// relative to the input. This helper returns `(h, w)` for those cases
/// and `(w, h)` unchanged otherwise.
pub fn transpose_if_transform_transposed(transform: Transform, w: i32, h: i32) -> (i32, i32) {
// `match` 配合 **or-pattern**:用 `|` 把多个 variant 合并为一个 arm
// 共享同一个表达式分支。Go 的 `switch` 用 `case A, B, C:` fallthrough 等价。
// 注意 Rust 的 match 不存在隐式 fallthrough,每个 arm 必须 `=>` 显式给出表达式。
match transform {
// 四种"四分之一圈"旋转:宽高必须互换。
Transform::Normal90
| Transform::Normal270
| Transform::Flipped90
| Transform::Flipped270 => (h, w),
// `_` 是通配符(wildcard),匹配所有未列出的 variant。
// Rust 要求 match 穷尽,最后用 `_ =>` 兜底等价于 Go `default:` 分支。
// 此处涵盖 `Normal` / `Normal180` / `Flipped` / `Flipped180`。
_ => (w, h),
}
}
// 将矩形裁剪到 `(0, 0) .. (bounds_w, bounds_h)` 范围内。
//
// 用于 ROI 校验:用户给的坐标可能为负或越界,编码器不接受这样的区域,
// 必须先 clamp 到合法范围。Go 标准库没有 `clamp` 内置函数(Go 1.21 才加入
// `min`/`max` 内置),通常要手写 `if x < lo { x = lo } else if x > hi { x = hi }`
// Rust 的 `i32::clamp(lo, hi)` 是方法调用,语义更直观。
/// Clip a rectangle so it stays inside `(0, 0) .. (bounds_w, bounds_h)`.
///
/// The resulting rectangle has non-negative origin and its extent does
/// not exceed the bounds.
pub fn fit_inside_bounds(rect: Rect, bounds_w: i32, bounds_h: i32) -> Rect {
// `.clamp(lo, hi)`:将值限制在 `[lo, hi]` 闭区间内(小于 lo 返回 lo,
// 大于 hi 返回 hi,否则原值)。返回 `i32`self by value)。
let x = rect.x.clamp(0, bounds_w);
let y = rect.y.clamp(0, bounds_h);
// `.min(other)`:返回 `self` 与 `other` 的较小值(等价 Go 的 `if a < b` 三元)。
// 此处把矩形的右边界限制到 `bounds_w`,避免越界。
let right = (rect.x + rect.w).min(bounds_w);
let bottom = (rect.y + rect.h).min(bounds_h);
// `.max(other)`:返回较大值。此处保证宽高非负(`right - x` 在
// 完全越界的退化情形下可能为负,取 max(0) 兜底)。
let w = (right - x).max(0);
let h = (bottom - y).max(0);
// 字段简写:`x`/`y`/`w`/`h` 变量名与 `Rect` 字段名相同,可省略 `field: value`。
Rect { x, y, w, h }
}
+382 -13
View File
@@ -1,7 +1,51 @@
//! # WebRTC 传输模块 — str0m Sans-IO 信令服务器与媒体出口
//!
//! ## 模块定位
//! 将 H.264 编码帧通过 WebRTC 推送到浏览器(替代文件输出)。仅在 `--port > 0` 时启用;
//! `--port 0`(默认)走纯文件输出路径,本模块不会被实例化(见 `main.rs` 入口判断)。
//!
//! ## str0m 是 Sans-IO WebRTC 库
//! 类比 Go 的 `net/http`,但 Sans-IO 哲学不同:
//! - **没有 background goroutine**str0m 不创建任何线程,所有进度都靠外部 poll 推动
//! - **手动驱动 3 步循环**(见 `poll_and_feed`/`feed_network`/`poll_rtc`):
//! 1. 读 UDP 包 → `Rtc::handle_input(Input::Receive(...))` 喂给 str0m
//! 2. 调 `Rtc::poll_output()` 拿 `Output::Transmit` 包 → 写回 UDP socket
//! 3. 定时喂 `Input::Timeout(Instant::now())` 推动内部时钟
//! - **同步而非 async**str0m 不是 async/await 库(与 `tokio::net::TcpListener` 等
//! 异步运行时无关);本文件用 `std::net::TcpListener` + `UdpSocket`(手动
//! `set_nonblocking(true)`),完全同步代码;上层 `main.rs` 在 mio 事件循环里
//! 周期性调 `poll_and_feed()` 推动 RTC 状态机
//! - **Go 等价物**`github.com/pion/webrtc`Go 主流 WebRTC 库)也是同步 + 手动驱动,
//! 但 str0m 把 Sans-IO 推得更彻底——连 UDP socket 都不持有,所有 I/O 都由调用方管理
//!
//! ## 内嵌 HTTP 信令服务器
//! 本模块自带一个极简 HTTP 服务器(`std::net::TcpListener`,非 tokio/axum),3 个端点:
//! - `GET /` → 返回 `HTML_PAGE`(自带 SDP 协商 + `<video>` 播放 + 实时 stats 的测试页)
//! - `POST /sdp`Content-Type: application/json)→ 接收浏览器 `RTCPeerConnection`
//! localDescriptionOffer SDP),交给 `Rtc::sdp_api().accept_offer()` 生成 Answer
//! 返回 JSON body 给浏览器 `setRemoteDescription`
//! - `GET /sdp`(无 JSON Content-Type)→ 与 `GET /` 同(兼容旧路径)
//!
//! ICE candidate 通过 SDP offer/answer 完成:浏览器等 `iceGatheringState == 'complete'`
//! 才 POST(见 `HTML_PAGE` 的 `onicegatheringstatechange`),所以 candidate 已全在
//! SDP 里,本服务端无需单独的 ICE endpointtrickle ICE 关闭)。
//!
//! ## 关键不变量
//! - **单连接**`WebRtcState::inner: Option<WebRtcInner>` 只持有 1 个 peer;新连接
//! POST 进来时,旧 `inner` 被 drop(旧 `Rtc` 析构,UDP socket 关闭)
//! - **非阻塞 IO**:所有 socket `set_nonblocking(true)``WouldBlock` 是常态而非错误
//! - **BWE 启动**`RtcConfig::enable_bwe(Some(Bitrate::mbps(5)))` 启用带宽估计,
//! 用于动态分辨率切换(见 `state_portal.rs::select_resolution`
//!
//! ## 引用
//! - `Cargo.toml`: `str0m = "0.20"`
//! - git `727893f`: bitrate 修复(BWE 与 VBV 协同)
//! - issue #23: PLI 节流(`FORCED_KEYFRAME_MIN_INTERVAL`
// WebRTC 传输模块 — 使用 str0m (Sans-IO) 将 H.264 编码帧推送到浏览器
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, UdpSocket};
use std::time::Instant;
use std::time::{Duration, Instant};
use anyhow::{bail, Result};
use str0m::bwe::{Bitrate, BweKind};
@@ -11,6 +55,12 @@ use str0m::media::{Frequency, MediaKind, MediaTime, Mid, Pt};
use str0m::net::{Protocol, Receive};
use str0m::{Candidate, Event, IceConnectionState, Input, Output, Rtc, RtcConfig};
/// Minimum interval between honored keyframe productions, regardless of source
/// (PLI from viewer, connect event, resolution change). Prevents PLI storms
/// from causing back-to-back IDRs that swamp the network with multi-hundred-KB
/// bursts. See issue #23.
const FORCED_KEYFRAME_MIN_INTERVAL: Duration = Duration::from_secs(1);
// ── 嵌入式 HTML 测试页面 ──────────────────────────────────────────────────
const HTML_PAGE: &str = r#"<!DOCTYPE html>
@@ -186,27 +236,60 @@ connect();
// ── WebRTC 状态 ───────────────────────────────────────────────────────────
// 对外门面:持有 HTTP 信令监听器 + 当前唯一的 peer 连接(`inner`)。
// 类比 Go 的 `*http.Server`,但 Sans-IO:所有推进都靠调用方主动 poll。
pub struct WebRtcState {
// HTTP 信令监听器(`POST /sdp` 协商;`GET /` 测试页面)。`set_nonblocking(true)`
// 由上层 mio 事件循环可读时调 `handle_signaling()` 接受连接。
signal_listener: TcpListener,
// 当前 peer。`None` = 尚无连接 / 上次连接已断开。新 `POST /sdp` 会整体替换此字段,
// 旧 `Rtc` 实例被 dropUDP socket 随之关闭)。
inner: Option<WebRtcInner>,
// 上层期望的帧率(来自 CLI `--fps`),用于初始化 `WebRtcInner`。
fps: u32,
}
// 单个 WebRTC peer 的全部状态:str0m `Rtc` 实例 + 它专用的 UDP socket +
// 编解码参数协商结果 + 关键帧请求/BWE 估计的运行时缓存。
//
// 字段访问路径(每帧一次,由 `main.rs` 的事件循环驱动):
// 1. `feed_network()` 把 UDP 入包喂给 `Rtc::handle_input`
// 2. `poll_rtc()` 取出 `Rtc::poll_output` 的 `Transmit` 包写回 UDP,并处理 `Event`
// 3. `write_h264_frame()` 把编码后的 H.264 NALU 通过 `Rtc::writer(mid).write(...)` 发出
struct WebRtcInner {
// str0m `Rtc`:一个完整的 WebRTC peer connectionICE / DTLS / SRTP / RTP / RTCP)。
// Sans-IO:不持有任何 socket 或线程,只持有协议状态机。
rtc: Rtc,
// 本 peer 专用的 UDP socket(每连接一个,避免与不存在的其他 peer 串扰)。
socket: UdpSocket,
// 该 socket 绑定的本地地址(带随机端口),用作 `Candidate::host` 的发地址。
udp_addr: SocketAddr,
// 视频 Media IDSDP 协商后从 `Event::MediaAdded` 捕获)。`None` = 尚未协商到。
video_mid: Option<Mid>,
// H.264 payload type(从 `Rtc::writer(mid).payload_params()` 扫描得到)。
video_pt: Option<Pt>,
// ICE+DTLS 是否已完成(`Event::Connected`)。未连接时 `write_h264_frame` 静默丢弃。
connected: bool,
// 等待下一个 IDR 关键帧(连接建立/分辨率切换时置 true,写帧时若非 IDR 则丢帧)。
need_keyframe: bool,
// 通知上游编码器下一次输出 IDR`state.rs::State::take_force_keyframe` 拉取)。
force_keyframe_to_encode: bool,
// 最近一次强制关键帧时刻,用于 `FORCED_KEYFRAME_MIN_INTERVAL` 节流(防 PLI 风暴)。
last_forced_keyframe_at: Option<Instant>,
// 最近一次 BWE 估计(来自 `Event::EgressBitrateEstimate`),用于上层动态分辨率选择。
current_bwe_estimate: Option<Bitrate>,
// 最近一次写入的 RTP 时间戳(90kHz),仅用于日志 trace,不参与协议正确性。
rtp_clock: u32,
// UDP 接收缓冲(重复利用以避免每包分配;65535 = max UDP payload)。
buf: Vec<u8>,
}
impl WebRtcState {
// 构造函数:绑定 HTTP 信令 TCP 监听器并设为非阻塞。`port` 来自 CLI `--port`
// `fps` 来自 CLI `--fps`,仅在 `--port > 0` 时被 `main.rs` 调用。
//
// 注意:本函数只创建信令监听器,**不**创建 UDP socket 或 `Rtc` 实例——
// 那些在第一次 `POST /sdp` 时由 `WebRtcInner::new` 按需创建。
pub fn new(port: u16, fps: u32) -> Result<Self> {
let signal_listener = TcpListener::bind(format!("0.0.0.0:{port}"))?;
signal_listener.set_nonblocking(true)?;
@@ -219,18 +302,36 @@ impl WebRtcState {
})
}
// 处理所有待接受的 HTTP 信令连接。上层 mio 循环在 `signal_listener` 可读时调用。
//
// 返回 `Ok(true)` 表示至少处理了一个请求(用于上层日志/计数)。
// 单次调用 drain 当前 accept 队列里所有连接(`Err(WouldBlock)` 时退出循环)。
//
// 路由:
// - `GET /` 或 `GET /sdp`(非 JSON)→ 返回 `HTML_PAGE`
// - `POST /sdp` → 解析 body,构造新 `WebRtcInner` 并替换 `self.inner`
// - 其他路径 → 404
pub fn handle_signaling(&mut self) -> Result<bool> {
let mut handled = false;
loop {
// `TcpListener::accept()` 类比 Go `ln.Accept()`;非阻塞模式下队列为空返回
// `WouldBlock`,是 drain 完成的信号而非错误(类比 Go `accept` + nonblocking + EAGAIN)。
let (mut stream, _addr) = match self.signal_listener.accept() {
Ok(s) => s,
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
// `bail!` 是 anyhow 提供的宏,等价于 `return Err(anyhow::anyhow!(...))`
// 类比 Go `return fmt.Errorf("TCP accept error: %w", err)`。
Err(e) => bail!("TCP accept error: {e}"),
};
handled = true;
// 设为非阻塞——类比 Go `syscall.SetNonblock(fd, true)`。后续 `stream.read`
// 在没数据时返回 `WouldBlock`(用 `continue` 跳过本连接)。
stream.set_nonblocking(true)?;
// 64KB 一次性读完:HTTP/1.0 客户端默认 `Connection: close`,浏览器 POST 整个
// SDP offer 不会超过 64KB。`vec![0u8; N]` 类比 Go `make([]byte, N)`。
let mut req = vec![0u8; 65536];
// `stream.read(&mut req)` 类比 Go `conn.Read(buf)`——`Read` trait 即 Go `io.Reader`。
let n = match stream.read(&mut req) {
Ok(n) => n,
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
@@ -239,6 +340,8 @@ impl WebRtcState {
continue;
}
};
// `String::from_utf8_lossy` 把字节转成字符串,无效 UTF-8 替换为 U+FFFDHTTP 头都是 ASCII)。
// 类比 Go `string(buf[:n])`Go 字符串可包含任意字节,但后续 `starts_with` 也只看 ASCII)。
let req_str = String::from_utf8_lossy(&req[..n]);
if req_str.starts_with("GET / ")
@@ -263,12 +366,19 @@ impl WebRtcState {
continue;
}
// `and_then`Result 链式组合,类比 Go `if err != nil { return err }` 后继续。
// `new_inner.handle_sdp_offer(...)?``?` 操作符传播 `Result::Err`
// 类比 Go `result, err := ...; if err != nil { return err }` 的简写。
match WebRtcInner::new(self.fps).and_then(|mut new_inner| {
let answer_json = new_inner.handle_sdp_offer(body.as_bytes())?;
Ok((new_inner, answer_json))
}) {
Ok((new_inner, answer_json)) => {
// `Option::is_some()` = Rust 检查 `Option` 是否为 `Some(_)`
// 类比 Go `if p != nil`。这里用于日志区分"替换"vs"首次"。
let replacing = self.inner.is_some();
// 整体替换 `self.inner`:旧 `Rtc` 实例 dropUDP socket 关闭,
// peer 连接断开)。这是单连接不变量的核心实现。
self.inner = Some(new_inner);
if replacing {
tracing::info!("Replaced WebRTC connection (old dropped)");
@@ -304,6 +414,10 @@ impl WebRtcState {
Ok(handled)
}
// 推动 str0m `Rtc` 状态机:取出 `poll_output` 的 `Transmit` 包写回 UDP,处理 `Event`。
// 返回 `Ok(())`;若 `poll_rtc` 上报 peer 已断开,则清空 `self.inner`。
//
// 类比 Go pion/webrtc:没有 `go func()` 自动循环,必须由 main 线程显式调用。
pub fn poll_rtc(&mut self) -> Result<()> {
if let Some(inner) = self.inner.as_mut() {
if inner.poll_rtc()? {
@@ -314,6 +428,8 @@ impl WebRtcState {
Ok(())
}
// 从 UDP socket 读所有待处理包喂给 `Rtc::handle_input`。`WouldBlock` 退出循环。
// Go 类比:`for { n, _ := conn.ReadFrom(buf); if errors.Is(err, EAGAIN) { break } }`。
pub fn feed_network(&mut self) -> Result<()> {
if let Some(inner) = self.inner.as_mut() {
inner.feed_network()?;
@@ -321,15 +437,23 @@ impl WebRtcState {
Ok(())
}
// `poll_rtc` → `feed_network` → `poll_rtc` 三明治。中间多一次 poll 是因为
// `feed_network` 喂的入包可能触发 str0m 产生新的 `Transmit`(如 RTCP ACK),
// 这些出包必须在同一轮循环里写回 UDP,避免延迟一帧。
pub fn poll_and_feed(&mut self) -> Result<()> {
self.poll_rtc()?;
self.feed_network()?;
self.poll_rtc()
}
pub fn write_h264_frame(&mut self, data: &[u8], frame_number: u64, fps: u32) -> Result<()> {
// 把一帧 H.264 NALU(已 annex-B 转码)写入 str0m `Rtc`,通过 RTP 发给 peer。
// `pts_ticks` = 90kHz 时钟下的 PTS(编码器 time_base = 1/90000,等同 RTP 时间戳)。
//
// 返回 `Ok(())`;若 `WebRtcInner::write_h264_frame` 上报 peer 断开,则清空 `self.inner`。
// 未连接 / 未协商到 mid/pt / 等待 IDR 时静默丢帧(`Ok(false)`)。
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, frame_number, fps)?
inner.write_h264_frame(data, pts_ticks)?
} else {
false
};
@@ -340,10 +464,15 @@ impl WebRtcState {
Ok(())
}
// 是否有已连接的 peer。`Option::is_some_and` = Rust 短路求值,类比 Go
// `if p != nil && p.connected { ... }`。
pub fn is_connected(&self) -> bool {
self.inner.as_ref().is_some_and(WebRtcInner::is_connected)
}
// 上层(`state_portal.rs::select_resolution`)查询最近一次 BWE 估计(bps)。
// `None` = 尚未收到 `Event::EgressBitrateEstimate``Some(bps)` = str0m 推断的可用带宽。
// 上层据此切换分辨率 tier(防止过载导致卡顿)。
/// Returns the latest bandwidth estimation estimate in bits per second, if available.
pub fn get_bwe_estimate(&self) -> Option<u64> {
self.inner
@@ -351,13 +480,34 @@ impl WebRtcState {
.and_then(|inner| inner.current_bwe_estimate.map(|b| b.as_u64()))
}
// 内部触发:连接刚建立或分辨率刚变化,需要立刻 IDR 以让对端解码器重置。
// 不受 `FORCED_KEYFRAME_MIN_INTERVAL` 节流(本函数总是 honor),但会刷新
// `last_forced_keyframe_at`,使紧接着的 1 秒内 viewer PLI 被丢弃。
/// Internal keyframe request (connect, resolution change). Always honored,
/// but updates last_forced_keyframe_at so a subsequent viewer PLI in the next
/// second is throttled.
pub fn set_need_keyframe(&mut self) {
if let Some(inner) = self.inner.as_mut() {
inner.need_keyframe = true;
inner.force_keyframe_to_encode = true;
inner.set_need_keyframe();
}
}
// 外部触发:viewer 通过 RTCP PLI/FIR 主动请求关键帧(`Event::KeyframeRequest`)。
// 受 `FORCED_KEYFRAME_MIN_INTERVAL` 节流(1 秒),防止恶意/频繁 PLI 触发 IDR 风暴
// 撑爆上行带宽。See issue #23。
/// External keyframe request from viewer (PLI/FIR via str0m
/// `Event::KeyframeRequest`). Rate-limited to FORCED_KEYFRAME_MIN_INTERVAL
/// to prevent PLI storms from swamping the network with IDR bursts.
/// See issue #23.
#[allow(dead_code)]
pub fn request_keyframe_from_viewer(&mut self) {
if let Some(inner) = self.inner.as_mut() {
inner.request_keyframe_from_viewer();
}
}
// 上层拉取"是否需要下一帧为 IDR"。返回 `true` 仅一次(取后自动复位),
// 类比 Go `atomic.SwapInt32(&flag, 0)`。编码线程据此在下一帧 `force_idr=1`。
pub fn take_force_keyframe(&mut self) -> bool {
if let Some(inner) = self.inner.as_mut() {
let v = inner.force_keyframe_to_encode;
@@ -370,19 +520,46 @@ impl WebRtcState {
}
impl WebRtcInner {
// 构造一个全新的 WebRTC peer:创建 str0m `Rtc` 实例 + UDP socket + 候选地址。
// 在 `handle_signaling` 接到 `POST /sdp` 时被调用——也就是说**每来一个 SDP offer
// 都新建一个 peer**,旧 `Rtc` 实例随之 dropUDP socket 关闭,连接断开)。
//
// 步骤:
// 1. `RtcConfig::new().enable_bwe(...).build(...)`str0m 构造器链式 Builder 模式,
// 类比 Go `webrtc.NewAPI(webrtc.WithSettingEngine(...))`;启用 BWE5 Mbps 初始)
// 2. `UdpSocket::bind("0.0.0.0:0")`OS 随机分配端口,类比 Go `net.ListenUDP("udp", nil)`
// 3. `unsafe { libc::setsockopt(SO_SNDBUF) }`:扩大 UDP 发送缓冲到 2MB(默认 ~208KB
// 在 IDR 突发下会 EAGAIN 丢包);英文 SAFETY 注释见下方
// 4. `Candidate::host(addr, "udp")`:构造 host ICE candidate(局域网用),
// `Rtc::add_local_candidate` 注册到 str0m
fn new(fps: u32) -> Result<Self> {
// `let _ = fps;` 显式标记 fps 暂未使用(保留接口给未来 fps-based pacing)。
// 类比 Go `_ = fps`。
let _ = fps;
// str0m `Rtc` 构造:Builder 模式 + 链式 setter。
// - `RtcConfig::new()`:空配置
// - `.enable_bwe(Some(Bitrate::mbps(5)))`:启用 bandwidth estimation,初始估 5 Mbps
// - `.build(Instant::now())`:传入当前时刻作为 Rtc 内部时钟起点
// 类比 Go pion/webrtc`webrtc.NewAPI(webrtc.WithSettingEngine(...))`
let mut rtc = RtcConfig::new()
.enable_bwe(Some(Bitrate::mbps(5)))
.build(Instant::now());
// `UdpSocket::bind("0.0.0.0:0")`OS 随机分配端口(每 peer 独享一个 socket)。
// 类比 Go `net.ListenUDP("udp", &net.UDPAddr{Port: 0})`。
let socket = UdpSocket::bind("0.0.0.0:0")?;
socket.set_nonblocking(true)?;
// 中文概述:调大 UDP 发送缓冲到 2MB(默认 ~208KB),原因详见下方英文注释。
// 然后用 `getsockopt` 读取内核实际分配的大小(Linux 可能受 `wmem_max` 截断,且
// 通常会翻倍)。Go 等价:`net.ListenConfig{Control: ...}`。
// Increase UDP send buffer to absorb IDR frame bursts (256KB IDR → ~145 RTP
// packets in a single poll_rtc loop). Default Linux wmem is ~208KB which
// causes EAGAIN on large keyframes. 2MB comfortably buffers several IDRs.
const SND_BUF_REQ: usize = 2 * 1024 * 1024;
// 中文概述:调用 `setsockopt(SO_SNDBUF)` 调大 UDP 发送缓冲,然后用
// `getsockopt` 读取内核实际分配的大小(Linux 可能受 `wmem_max` 截断,且通常会
// 翻倍)。FFI 安全性论证见下方英文 SAFETY 块。
// SAFETY: fd is a valid UDP socket; setsockopt/getsockopt with SOL_SOCKET +
// SO_SNDBUF are safe on Linux. We check the return value and log the actual
// kernel-assigned buffer (Linux may cap at wmem_max and/or double the value).
@@ -423,13 +600,22 @@ impl WebRtcInner {
let local_addr = socket.local_addr()?;
// `local_ip().unwrap_or_else(closure)``Option<T>::unwrap_or_else` 类比 Go
// `if ip == "" { ip = "127.0.0.1" }`——`Option::None` 时执行闭包取兜底值。
let lan_ip = local_ip().unwrap_or_else(|| {
tracing::debug!("Failed to detect LAN IP, falling back to 127.0.0.1");
"127.0.0.1".to_string()
});
// `format!("{lan_ip}:{}", port)`Rust 格式化宏,类比 Go `fmt.Sprintf("%s:%d", ...)`.
// `.parse::<SocketAddr>()`:字符串解析为 `SocketAddr``?` 自动传播 `AddrParseError`。
let candidate_addr: SocketAddr = format!("{lan_ip}:{}", local_addr.port()).parse()?;
// `Candidate::host(addr, "udp")`:构造 host ICE candidate(局域网用,无 STUN/TURN)。
// `.map_err(|e| anyhow::anyhow!(...))?`:把 str0m 自定义错误转成 `anyhow::Error`
// 并传播,类比 Go `if err != nil { return fmt.Errorf("candidate: %w", err) }`。
let candidate = Candidate::host(candidate_addr, "udp")
.map_err(|e| anyhow::anyhow!("candidate: {e}"))?;
// `Rtc::add_local_candidate`:把 candidate 注册到 str0m,之后 SDP 协商时它会被
// 包含进 answer 的 `a=candidate:` 行。
rtc.add_local_candidate(candidate);
tracing::info!("WebRTC UDP: {candidate_addr} (bound 0.0.0.0)");
@@ -442,16 +628,34 @@ impl WebRtcInner {
connected: false,
need_keyframe: false,
force_keyframe_to_encode: false,
last_forced_keyframe_at: None,
current_bwe_estimate: None,
rtp_clock: 0,
buf: vec![0u8; 65535],
})
}
// SDP offer/answer 交换:解析浏览器 POST 来的 SDP offer JSON → 喂给 str0m 协商 →
// 返回 answer JSON。
//
// 关键步骤:
// 1. `serde_json::from_slice`:反序列化 SDP offer(类比 Go `json.Unmarshal`
// 2. `self.rtc.sdp_api().accept_offer(offer)`str0m 内部协商出 answer
// 副作用是设置 `Event::MediaAdded` 等待异步触发
// 3. `self.need_keyframe = true; self.force_keyframe_to_encode = true;`
// 协商完成后立即请求 IDR,让对端尽快解码首帧
// 4. `discover_video_params()`:扫描 str0m writer 找到 H.264 payload type
// 5. `serde_json::to_vec`:序列化 answer(类比 Go `json.Marshal`
fn handle_sdp_offer(&mut self, body: &[u8]) -> Result<String> {
// `serde_json::from_slice::<SdpOffer>(body)`:把浏览器 POST 的 JSON 反序列化成
// str0m 的 `SdpOffer` 类型,类比 Go `json.Unmarshal(body, &offer)`。
// `.map_err(...)?`:把 serde 错误包装成 anyhow 错误并传播。
let offer: SdpOffer =
serde_json::from_slice(body).map_err(|e| anyhow::anyhow!("parse SDP offer: {e}"))?;
// `Rtc::sdp_api().accept_offer(offer)`str0m SDP 协商核心入口——
// 解析 offer 中的 m= 行、codec 列表、ICE candidate,构造对应的 answer。
// 副作用:触发后续 `Event::MediaAdded`(异步,要等 poll_rtc 才发)。
let answer = self
.rtc
.sdp_api()
@@ -470,6 +674,13 @@ impl WebRtcInner {
String::from_utf8(answer_json).map_err(|e| anyhow::anyhow!("answer utf8: {e}"))
}
// 扫描 str0m 内部协商出的 codec 列表,找到 H.264 payload type`Pt`)。
// 在 SDP 协商后、`Event::MediaAdded` 后、`Event::Connected` 后各调用一次
// (三处调用是因为 str0m 的 codec 信息可能在不同时机可用——多保险)。
//
// 副作用:调用 `direct_api().stream_tx_by_mid(mid, None).set_unpaced(true)`
// 关闭 str0m 的 LeakyBucketPacer(默认每包加 ~100ms pacing 延迟,与我们的 VBV
// 8 Mbps 上限冲突;关掉后由编码器侧 VBV 做速率控制)。
fn discover_video_params(&mut self) {
let mid = match self.video_mid {
Some(m) => m,
@@ -479,9 +690,21 @@ impl WebRtcInner {
}
};
self.video_pt = None;
// Disable str0m's LeakyBucketPacer for this video stream. Default pacing
// adds ~100ms send latency per large IDR; our 8Mbps cap + VBV already
// provide rate control. BWE stays enabled for adaptation feedback.
// `direct_api()` 返回 str0m 内部 API(不公开稳定接口),`stream_tx_by_mid(mid, None)`
// 取得该 mid 的发送流控制器;`set_unpaced(true)` 关闭 pacing。
if let Some(stream_tx) = self.rtc.direct_api().stream_tx_by_mid(mid, None) {
stream_tx.set_unpaced(true);
}
// `Rtc::writer(mid)` 返回媒体写入器,`payload_params()` 列出协商出的所有 codec。
// 我们扫描找 H.264`Codec::H264`)的 payload type,存入 `video_pt` 供后续 `write_h264_frame` 使用。
if let Some(writer) = self.rtc.writer(mid) {
for pp in writer.payload_params() {
tracing::debug!("Codec: pt={:?} spec={:?}", pp.pt(), pp.spec());
// `pp.spec().codec.is_video()`:先确认是视频 codec
// `pp.spec().codec == Codec::H264`:再确认是 H.264(非 VP8/VP9/AV1)。
if pp.spec().codec.is_video() && pp.spec().codec == Codec::H264 {
self.video_pt = Some(pp.pt());
tracing::info!("H.264 payload type: {:?}", pp.pt());
@@ -494,11 +717,57 @@ impl WebRtcInner {
}
}
// 内部不节流版本:直接置位 `need_keyframe` + `force_keyframe_to_encode`
// 并刷新 `last_forced_keyframe_at`(防紧接着 1 秒内的 viewer PLI 重复触发 IDR)。
/// Unthrottled keyframe trigger. Always sets the keyframe flags and refreshes
/// `last_forced_keyframe_at` so a follow-up viewer PLI within the next
/// `FORCED_KEYFRAME_MIN_INTERVAL` is dropped.
fn set_need_keyframe(&mut self) {
self.need_keyframe = true;
self.force_keyframe_to_encode = true;
self.last_forced_keyframe_at = Some(Instant::now());
}
// 节流版本:仅在距离 `last_forced_keyframe_at` 已过 `FORCED_KEYFRAME_MIN_INTERVAL`
//1 秒)时才 honor,否则记 warn 日志并丢弃。对应 `Event::KeyframeRequest`PLI/FIR)。
/// Throttled keyframe trigger used for viewer-originated PLI/FIR requests.
/// Honored only if enough time has elapsed since the last forced keyframe.
fn request_keyframe_from_viewer(&mut self) {
let now = Instant::now();
let should_honor = self
.last_forced_keyframe_at
.map_or(true, |last| now.duration_since(last) >= FORCED_KEYFRAME_MIN_INTERVAL);
if should_honor {
self.last_forced_keyframe_at = Some(now);
self.need_keyframe = true;
self.force_keyframe_to_encode = true;
} else {
tracing::warn!(
"PLI throttled (last forced keyframe {:?} ago, min interval {:?})",
self.last_forced_keyframe_at.map(|t| now.duration_since(t)),
FORCED_KEYFRAME_MIN_INTERVAL
);
}
}
// Sans-IO 推进主循环(出方向):取出 str0m 待发的 `Output::Transmit` 包写回 UDP
// 处理 `Output::Event`Connected/Disconnected/MediaAdded/KeyframeRequest/BWE 等)。
// 返回 `Ok(true)` 表示 peer 已断开(调用方应 drop `WebRtcInner`)。
//
// `Output::Timeout` 表示 str0m 需要在未来某时刻被再次唤醒——本实现简单 `break`,
// 依赖上层 mio 循环的 1ms tick 重新进入;更高性能的做法是读取 `_t` 安排 timer。
fn poll_rtc(&mut self) -> Result<bool> {
loop {
// `Rtc::poll_output()`str0m 主推进入口,返回 `Output` 枚举(Transmit/Event/Timeout
// 或 `Err`。Sans-IO 设计:调用方必须循环 poll 直到拿到 `Timeout`(表示 str0m
// 当前没活干了,等下一次外部输入)。
match self.rtc.poll_output() {
// `Output::Transmit`str0m 想发的网络包(RTP/RTCP/DTLS/STUN)。
// 我们写回 UDP socket——这就是 Sans-IO 的"输出"侧。
Ok(Output::Transmit(t)) => {
tracing::trace!("TX {} bytes -> {}", t.contents.len(), t.destination);
// `UdpSocket::send_to` 类比 Go `conn.WriteToUDP(b, addr)`。
// `WouldBlock` = 内核发送缓冲满(罕见,因为我们在 new() 里调大了)。
if let Err(e) = self.socket.send_to(&t.contents, t.destination) {
if e.kind() == std::io::ErrorKind::WouldBlock {
tracing::debug!(
@@ -510,21 +779,28 @@ impl WebRtcInner {
}
}
}
// `Output::Event`str0m 内部状态变化通知(ICE 连接、媒体添加、keyframe 请求等)。
// `Event` 是 enum,下方 `match &e` 对每种 variant 分发处理。
Ok(Output::Event(e)) => {
tracing::debug!("RTC event: {e:?}");
match &e {
// `Event::Connected`ICE+DTLS 握手完成,可以发 RTP 了。
// 立即触发 IDR 请求(让对端解码器拿到关键帧尽快起播)+ 重新扫 codec 参数。
Event::Connected => {
tracing::info!("WebRTC connected!");
self.connected = true;
self.need_keyframe = true;
self.force_keyframe_to_encode = true;
self.set_need_keyframe();
self.discover_video_params();
}
// `Event::IceConnectionStateChange`ICE 状态变化。
// `Disconnected` 视为连接已死,向上层返回 `Ok(true)` 触发 drop。
Event::IceConnectionStateChange(IceConnectionState::Disconnected) => {
tracing::warn!("WebRTC disconnected");
self.connected = false;
return Ok(true);
}
// `Event::MediaAdded`SDP 协商后有新 m= 行就绪。
// 捕获视频 mid(只取第一个 sending direction 的视频流)。
Event::MediaAdded(ma) => {
tracing::info!("Media added: mid={} kind={:?}", ma.mid, ma.kind);
if ma.kind == MediaKind::Video {
@@ -537,11 +813,15 @@ impl WebRtcInner {
}
}
}
// `Event::KeyframeRequest`:对端发来 PLI/FIR,请求 IDR。
// 转发到节流版本 `request_keyframe_from_viewer`(防止 PLI 风暴)。
Event::KeyframeRequest(_) => {
tracing::info!("received keyframe request from viewer");
self.need_keyframe = true;
self.force_keyframe_to_encode = true;
self.request_keyframe_from_viewer();
}
// `Event::EgressBitrateEstimate`BWE 推断的可用上行带宽。
// `BweKind::Twcc`Transport-CC,新标准)或 `BweKind::Remb`(老标准)。
// 提取数值存入 `current_bwe_estimate`,供 `state_portal.rs::select_resolution` 使用。
Event::EgressBitrateEstimate(est) => {
let bitrate = match est {
BweKind::Twcc(b) => *b,
@@ -559,6 +839,8 @@ impl WebRtcInner {
}
}
}
// `Output::Timeout`str0m 内部定时器到期点。本实现忽略 `_t`(即下次唤醒时刻),
// 简单 `break`——上层 mio 循环 1ms tick 会很快再次调用 `poll_rtc`。
Ok(Output::Timeout(_t)) => break,
Err(e) => {
tracing::error!("rtc.poll_output error: {e}");
@@ -570,15 +852,27 @@ impl WebRtcInner {
Ok(false)
}
// Sans-IO 推进主循环(入方向):从 UDP socket 读所有待处理包,封装为
// `Input::Receive` 喂给 str0m;最后喂一次 `Input::Timeout(now)` 推动内部时钟。
// 类比 Go pion/webrtc:手动调用 `peerConnection.Receive(rtpPacket)` 而不是
// 起 goroutine 监听 UDP。
fn feed_network(&mut self) -> Result<()> {
let mut recv_count = 0u32;
loop {
// `UdpSocket::recv_from(&mut self.buf)`:类比 Go `conn.ReadFrom(buf)`。
// 返回 `(n_bytes_read, source_addr)`。`WouldBlock`/`Interrupted` 是常态,
// 前者 break 出循环,后者重试(类比 Go EINTR 处理)。
match self.socket.recv_from(&mut self.buf) {
Ok((n, source)) => {
recv_count += 1;
if recv_count <= 5 {
tracing::trace!("UDP recv {} bytes from {}", n, source);
}
// 构造 `Input::Receive`str0m 的"入包"事件。
// `Receive { proto, source, destination, contents }` 完整描述一个网络包:
// - `proto: Protocol::Udp`str0m 也支持 TCP,但 WebRTC 主流用 UDP
// - `source` / `destination`ICE candidate 端点
// - `contents``self.buf[..n]` 转 `Box<[u8]>``.try_into()` 因为 slice→Box 长度可能变化)
let input = Input::Receive(
Instant::now(),
Receive {
@@ -590,6 +884,8 @@ impl WebRtcInner {
.map_err(|e| anyhow::anyhow!("receive contents: {e}"))?,
},
);
// `Rtc::handle_input(input)`:把入包喂给 str0m 解析(ICE/DTLS/SRTP/RTP/RTCP)。
// 这是 Sans-IO 的"输入"侧——str0m 不主动读 socket,全靠调用方喂。
self.rtc.handle_input(input).map_err(|e| {
anyhow::anyhow!("handle_input({n} bytes from {source}): {e}")
})?;
@@ -600,6 +896,8 @@ impl WebRtcInner {
}
}
// 喂一次 `Input::Timeout(now)`:让 str0m 推进内部定时器(重传、keepalive、BWE 周期等)。
// 即使没有任何入包,也必须定期调用,否则 str0m 内部超时不会触发。
self.rtc
.handle_input(Input::Timeout(Instant::now()))
.map_err(|e| anyhow::anyhow!("handle timeout: {e}"))?;
@@ -607,7 +905,18 @@ impl WebRtcInner {
Ok(())
}
fn write_h264_frame(&mut self, data: &[u8], frame_number: u64, fps: u32) -> Result<bool> {
// 把一帧 H.264 NALUannex-B 格式,含 0x000001 起始码)写入 str0m,转 RTP 发出。
//
// 5 步:
// 1. 检查 `connected`、`video_mid`、`video_pt`,未就绪则 `Ok(false)` 静默丢帧
// 2. 若 `need_keyframe`,校验此帧必须是 IDRNAL type=5),否则丢帧等下一帧
// 3. PTS 90kHz 时钟 → RTP 时间戳(直接复用,因编码器 time_base = 1/90000
// 4. `Rtc::writer(mid).write(pt, now, rtp_time, data)`str0m 内部分包(>MTU 切片)
// 并加密 SRTP,产生 `Output::Transmit` 包
// 5. 立即 `poll_rtc()` 把 Transmit 包写回 UDP(同步发出,避免延迟)
//
// 返回 `Ok(true)` = peer 断开,调用方应 drop 本 `WebRtcInner`。
fn write_h264_frame(&mut self, data: &[u8], pts_ticks: i64) -> Result<bool> {
if !self.connected {
return Ok(false);
}
@@ -642,12 +951,16 @@ impl WebRtcInner {
self.need_keyframe = false;
}
let ticks_per_second = 90_000u64;
let fps = fps.max(1) as u64;
let rtp_timestamp = frame_number.saturating_mul(ticks_per_second) / fps;
// PTS 90kHz → RTP 时间戳。`rtp_timestamp_from_pts_ticks` 把 i64 clamp 到 u64
//(见该函数文档)。`Frequency::NINETY_KHZ` 是视频 RTP 的标准时钟频率。
let rtp_timestamp = rtp_timestamp_from_pts_ticks(pts_ticks);
self.rtp_clock = rtp_timestamp as u32;
// `MediaTime::new(rtp_timestamp, Frequency::NINETY_KHZ)`:构造 str0m 媒体时间戳,
// 用于 RTP 头部 + jitter buffer 同步。
let rtp_time = MediaTime::new(rtp_timestamp, Frequency::NINETY_KHZ);
// `Rtc::writer(mid)`:取得 mid 对应的媒体写入器(之前在 `discover_video_params` 用过)。
// None 表示 mid 还没就绪(罕见,已在前面的 video_mid 检查里处理)。
let writer = match self.rtc.writer(mid) {
Some(w) => w,
None => {
@@ -662,6 +975,9 @@ impl WebRtcInner {
pt,
self.rtp_clock
);
// `writer.write(pt, Instant::now(), rtp_time, data)`:媒体写入入口。
// str0m 内部完成 (a) H.264 RTP 分包(FU-A for >MTU),(b) SRTP 加密,
// (c) 产生 `Output::Transmit` 包供 `poll_rtc` 取出。
writer
.write(pt, Instant::now(), rtp_time, data)
.map_err(|e| anyhow::anyhow!("writer.write: {e}"))?;
@@ -671,11 +987,24 @@ impl WebRtcInner {
Ok(should_destroy)
}
// 简单 getter,对应 `Event::Connected` / `Event::IceConnectionStateChange(Disconnected)`。
fn is_connected(&self) -> bool {
self.connected
}
}
// PTS→RTP 时间戳换算:编码器侧 time_base 已是 1/90000(与 RTP 视频时钟一致),
// 因此 1:1 直接复用,无需 fps-based 换算(旧版本曾用 `90000 / fps` 误导致时间戳错乱)。
// 返回 `u64` 喂 `MediaTime::new` 避免 u32 在 13.25 小时后过早回绕;str0m 内部处理 RTP u32 回绕。
/// Convert PTS in 90kHz media-clock ticks to RTP MediaTime ticks (u64).
///
/// 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
}
// ── 工具函数 ──────────────────────────────────────────────────────────────
/// 从 HTTP 请求中提取 body(在 \r\n\r\n 之后)
@@ -687,6 +1016,9 @@ fn extract_body(req: &str) -> &str {
}
}
// 探测本机 LAN IP(用于 ICE host candidate)。Go 等价:`net.Dial("udp", "1.1.1.1:80")`
// 后读 `LocalAddr()`——`connect` 不会发包,只设置路由表,从而选出默认网关对应的网卡 IP。
// `127.x` / `0.0.0.0` 视为无 LAN IP,由调用方 fallback 到 127.0.0.1loopback 调试用)。
fn local_ip() -> Option<String> {
std::net::UdpSocket::bind("0.0.0.0:0").ok().and_then(|s| {
s.connect("1.1.1.1:80").ok()?;
@@ -700,6 +1032,12 @@ fn local_ip() -> Option<String> {
})
}
// 检测 H.264 NALU 流中是否含 IDR sliceNAL type=5)。两种起始码:
// - 4 字节 `00 00 00 01`AVCC boundary,主流)
// - 3 字节 `00 00 01` Annex-B inline,少见)
// NAL header 低 5 位 = type5 = IDR slice。SPS=7、PPS=8、SEI=6 等不算 IDR。
//
// 用于 `need_keyframe` 时丢非 IDR 帧——Go 等价:`bytes.Index(data, []byte{0,0,0,1})` 循环。
fn is_idr_nalu(data: &[u8]) -> bool {
let mut i = 0;
while i < data.len() {
@@ -818,4 +1156,35 @@ mod tests {
let bps = default.as_u64();
assert_eq!(bps, 5_000_000);
}
// ── RTP timestamp conversion (issue #24) ──
#[test]
fn rtp_timestamp_zero_pts() {
assert_eq!(rtp_timestamp_from_pts_ticks(0), 0);
}
#[test]
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() {
// 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), 0);
}
#[test]
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);
}
}
+78
View File
@@ -1,19 +1,77 @@
//! 集成测试:通过 shell out 到 `target/release/wl-webrtc` 二进制来验证 CLI 行为。
//!
//! 与单元测试(在进程内调用库函数)不同,集成测试把产物当作黑盒,启动子进程
//! 并检查其 stdout/stderr/exit code。这种模式类似 Go 的 `testing` 包配合
//! `os/exec.Command(...)` —— Rust 这边对应 `std::process::Command::new(...)`
//! 通过 `.arg(...)` 链式追加参数,最后 `.output()` 一次性等待子进程结束并
//! 拿到 `Output { status, stdout, stderr }`。
//!
//! # 运行前必读
//!
//! 这些测试**依赖 release 版二进制存在**。`cargo test --test integration_test`
//! 本身不会触发 release 构建,必须先手动执行:
//!
//! ```bash
//! cargo build --release
//! ```
//!
//! 否则 `Command::new("target/release/wl-webrtc")` 会因为找不到可执行文件而 panic,
//! 所有 `#[test]` 都将以 "failed to execute" 失败。详见 `AGENTS.md`
//! "Testing and verification" 章节。
//!
//! # 测试发现与断言
//!
//! `cargo test` 通过 `#[test]` 属性宏自动发现并运行标记的函数,无需像 Go 那样
//! 约定 `TestXxx(t *testing.T)` 签名 —— 普通函数加 `#[test]` 即可。
//! `#[ignore]` 标记的测试默认跳过,需 `cargo test -- --ignored` 显式开启。
//!
//! 断言方面,`assert!(cond, "msg")` 类似 Go 的 `if !cond { t.Errorf("msg") }`
//! 但 Rust 会在失败时立即 unwind 当前测试函数(而非 Go 那样继续执行后续断言)。
//! 当测试函数返回 `Result<(), E>` 时,可用 `?` 操作符把 IO/解码错误直接传播
//! 给测试 harness(失败时打印 `Err` 而非 `panic!`);本文件的测试为了聚焦于
//! 子进程行为,全部用 `.expect(...)`/`assert!` 风格,不返回 `Result`。
use std::process::Command;
/// Helper: get the binary path. Uses the release build if available.
///
/// 返回被测二进制的路径。集成测试 shell out 到 release 构建产物(debug 构建太慢,
/// 且无法真实反映发布行为)。返回 `&'static str` 而非 `PathBuf` 是因为这个路径
/// 是编译期常量,无需在每次调用时分配。
fn bin_path() -> &'static str {
"target/release/wl-webrtc"
}
/// 测试 `--help` 子命令:应正常退出(exit 0),且 stdout 至少包含关键字段名。
///
/// 验证 README/AGENTS.md 中列出的核心 CLI 参数(output/fps/codec/bitrate/gop-size/drm-device
/// 都能在 `--help` 输出中找到 —— 这是一道"防回归"测试:一旦某参数被改名或删除,
/// 此处 `assert!` 会立刻失败。
#[test]
fn test_help_flag() {
// `Command::new(...)` 类似 Go 的 `exec.Command(...)`:构造一个待运行的
// 子进程描述符,此时还未真正 fork/exec。链式 `.arg(...)` 把参数逐个追加到
// 命令行末尾(保持顺序),`.output()` 则 fork、exec、等待子进程退出,并
// 一次性捕获 stdout/stderr 到 `Output` 结构体。
//
// `.expect(...)` 等价于 `match result { Ok(v) => v, Err(_) => panic!(...) }`
// 用于在父进程侧(不是被测程序侧)报告"无法启动子进程"这种环境性错误。
let output = Command::new(bin_path())
.arg("--help")
.output()
.expect("failed to execute wl-webrtc --help");
// `String::from_utf8_lossy(...)` 把 `Vec<u8>` 字节流解码成字符串;遇到非法
// UTF-8 序列时用 U+FFFD 替换而非报错。这里用 `_lossy` 变体而非
// `String::from_utf8(...)?` 是因为 stdout 理论上可能包含任意字节(如 ANSI
// color code 失控),不值得为解码失败让整个测试 panic。
let stdout = String::from_utf8_lossy(&output.stdout);
// `output.status.success()` 检查子进程退出码是否为 0Unix 上即 WIFEXITED
// 且 exit code == 0)。`assert!(cond, "msg")` 失败时打印 `msg` 并 panic
// 当前测试函数,类似 Go 的 `t.Fatalf` 而非 `t.Errorf`。
assert!(output.status.success(), "--help should exit 0");
// 后续 `assert!(stdout.contains(...), "...")` 检查帮助文本是否覆盖每个
// 文档化参数。任何一个缺失都会让测试失败并打印自定义消息。
assert!(
stdout.contains("output"),
"help output should mention 'output'"
@@ -37,6 +95,11 @@ fn test_help_flag() {
);
}
/// 测试未知参数应被拒绝:非零退出码 + stderr 包含 error/unexpected/unrecognized 之一。
///
/// `clap` 默认对未识别的 flag 返回非零退出码并打印错误到 stderr。这里用
/// `!output.status.success()` 断言"应该失败",再检查 stderr 文本以排除"碰巧
/// 崩溃退出"的假阳性。
#[test]
fn test_rejects_invalid_args() {
let output = Command::new(bin_path())
@@ -44,8 +107,13 @@ fn test_rejects_invalid_args() {
.output()
.expect("failed to execute wl-webrtc with invalid args");
// 断言"非零退出码"。注意 `!` 取反 —— 与 Go 的 `t.Errorf` 风格不同,Rust 的
// `assert!` 直接接受 bool 表达式,没有 `assertFalse` 这种专门函数。
assert!(!output.status.success(), "should reject unrecognized flag");
let stderr = String::from_utf8_lossy(&output.stderr);
// 多个可能的错误措辞用 `||` 连接 —— 不同 clap 版本可能输出 "error: unexpected"
// 或 "error: unrecognized",任一匹配即可。自定义消息末尾的 `{stderr}` 利用
// `format!` 占位符在失败时打印实际 stderr 内容,便于排错。
assert!(
stderr.to_lowercase().contains("error")
|| stderr.to_lowercase().contains("unexpected")
@@ -54,8 +122,11 @@ fn test_rejects_invalid_args() {
);
}
/// 测试 `--codec hevc` 在 MVP 阶段应被拒绝:MVP 只支持 h264。
#[test]
fn test_rejects_hevc_codec() {
// 多个 `.arg(...)` 链式调用按顺序追加参数,等价于命令行
// `wl-webrtc --output /dev/null --codec hevc`。
let output = Command::new(bin_path())
.arg("--output")
.arg("/dev/null")
@@ -70,6 +141,13 @@ fn test_rejects_hevc_codec() {
/// Tests requiring a live Wayland compositor and VAAPI hardware.
/// Run with: cargo test -- --ignored
///
/// 该测试需要真实 Wayland 会话 + VAAPI GPU + 可写输出路径,无法在 CI 中运行。
/// `#[ignore]` 属性告诉 `cargo test` 默认跳过它,只有显式
/// `cargo test -- --ignored` 时才执行。
///
/// 注意:此测试只验证"参数解析不立即报错",并未真正完成捕获 —— 真正的捕获
/// 需要异步等待几秒再发 SIGINT,这里只做最小烟雾测试。
#[test]
#[ignore]
fn test_capture_starts_with_valid_output() {