Commit Graph
20 Commits
Author SHA1 Message Date
dailz 92760dd8ee fix(state_portal): make shutdown() idempotent to prevent duplicate log lines (#22)
shutdown() fired twice on exit (explicit call in main.rs + Drop impl), causing "Total: N frames" and "StatePortal shutdown complete" to log twice 23us apart. Add `shutdown_started: bool` guard at function entry.

Plain bool (not AtomicBool) because &mut self already grants exclusive access. Guard is set BEFORE cleanup so Drop re-entry during panic unwinding is suppressed.

Includes scripts/test_shutdown_idempotency.sh as a live regression test (requires Wayland session). Verified PASS on KWin: both lines print exactly once.
2026-06-20 18:45:53 +08:00
dailz 0aba0e651e fix(stats): real encode thread timing via SwEncodeTiming + take_timing (#17)
encode_thread_loop hardcoded sws_us=0 and output_bytes=0, making the
stats dashboard show zeros for those columns. The previous encode_us
was measured around the entire encode_cpu_frame call, mixing sws +
encode work into one bucket.

Add SwEncodeTiming { sws_us, encode_us, output_bytes } to SwEncEncode.
- drain_encoder returns Result<usize> (total encoded bytes), accumulated
  before the Muxer/Channel match so branch duplication and multi-packet
  drain are both handled correctly. output_bytes = bytes produced by
  libavcodec even if downstream delivery drops them.
- encode_cpu_frame resets last_timing to Default at entry (so early
  returns from disconnect/pause/dedup never report stale prior values),
  measures sws_us around sws_scale, measures encode_us around
  avcodec_send_frame + drain, then stores the complete snapshot.
- take_timing() uses mem::take to return and clear in one step.
- flush() ignores the byte count from drain_encoder.
- state_portal encode_thread_loop calls take_timing() for real values.
2026-06-14 08:50:47 +08:00
dailz 36cee9d9dd fix(webrtc): force IDR on KeyframeRequest via forced-idr=1 + pict_type (closes #16)
WebRTC KeyframeRequest 响应延迟 2.1s(152 P 帧丢弃)→ 端到端 5-20ms。

- BitrateCommand::ForceKeyframe variant over existing bitrate_tx channel
- SwEncEncode.force_keyframe_pending: bypass dedup, set AV_PICTURE_TYPE_I,
  clear only after avcodec_send_frame succeeds
- libx264 forced-idr=1 via av_opt_set (FFmpeg-level option, not x264-native)
- pict_type reset to NONE per frame (reused AVFrame leak guard)
- WebRtcInner.force_keyframe_to_encode + take_force_keyframe() drain API
- recreate_encoder sets force_keyframe_pending (resolution change → IDR)

Verification: skipping non-IDR frame 152→0, ForceKeyframe→IDR 4-5ms stable.
2026-06-14 08:20:06 +08:00
dailz 3e60258627 feat(portal): BWE-driven resolution adaptation + duplicate frame skipping
WebRTC client bandwidth estimate now drives both encoder bitrate and
resolution tier selection, replacing the previous static-target encoder.

- webrtc.rs: enable str0m BWE (seeded at 5 Mbps), surface
  EgressBitrateEstimate + KeyframeRequest events, expose
  get_bwe_estimate() / set_need_keyframe()
- state_portal.rs: wire bitrate/resolution channels between the WebRTC
  thread and the encode thread; tier ladder [1440p, 1080p, 720p] with
  downscale at 60% budget and upscale hysteresis (120% sustained 10s)
- avhw.rs: SwEncImport::poll_resolution_commands() rebuilds the import
  filter graph on UpdateResolution; SwEncEncode::recreate_encoder()
  rebuilds sws/enc_video/yuv_frame atomically; hash_sampled_y_plane()
  skips duplicate frames; VBV x264opts cap IDR bursts; H.264 level 4.0
  (muxer) / 4.2 (WebRTC)
- state.rs: sync wlr-screencopy GOP to fps*2 max 20 for parity
- fix: drain bitrate_rx + resolution_rx BEFORE the stride check in
  encode_cpu_frame() so the new (smaller-stride) frame produced after
  a resolution change does not hit the stale (larger) enc_width and
  crash the encode thread
- WebRTC GOP widened to fps*2 max 20 (was fps/2 max 10)
2026-06-13 22:46:33 +08:00
dailz 503e4dbc22 feat(portal): independent WebRTC thread + channel tuning for 60fps mouse latency
- Move WebRTC send to dedicated wl-webrtc-webrtc thread (was inline in main loop)
- Reduce frame_rx 16→1, input_tx 2→1 (drop-on-full), webrtc_tx 32→2
- Recv_timeout 10ms→2ms to reduce pipeline latency
- Fix sent_gap_p95 stats bug: compute gap at actual send time in WebRTC
  thread instead of batch-draining at snapshot time (was always 0.0ms)
- High profile via AVCodecContext.profile, veryfast preset, 5x bitrate
- Stats drain via sent_gap channel with record_send_from_thread()
- Shutdown: drop input_tx → join encode → drop webrtc_tx → join webrtc
2026-06-07 18:30:09 +08:00
dailz caccfec44e fix(portal): compositor stall detection + filler frames + PipeWire state logging
P0: Detect compositor frame delivery stalls (>100ms no frames) and log
    stall/resume events with duration. Rate-limited to 1 warn/sec.

P1: Insert duplicate raw CpuNv12Frame filler during stalls at target fps.
    Keeps WebRTC stream smooth (sent_fps 20-40 instead of 3-5 during
    compositor pauses). Stops after 2s max stale. WebRTC mode only.

P2: Replace silent _ => {} in PipeWire state_changed callback with
    explicit Paused/Streaming/Connecting log messages.

P4: Add PwCtrlEvent::FormatChanged for mid-stream dimension changes.
    param_changed detects resolution renegotiation (skips first call).
    Logs warning in poll_and_encode; full encoder reinit deferred.

Verified: cargo check 0 errors, 70/70 tests, release build, --stats live.
2026-06-07 17:20:54 +08:00
dailz 826f544569 feat(portal): async encode pipeline - decouple capture from encoding
Split synchronous encode pipeline so sws_scale + libx264 runs on a
dedicated thread, leaving only VAAPI import + GPU scale + GPU→CPU
transfer on the main capture thread.

Problem: encode_p95 occasionally hit 74ms, blocking the entire capture
pipeline and causing capture_gap_max=356ms stutter.

Solution:
- avhw.rs: Split SwEncState into SwEncImport (main thread: VAAPI import,
  filter_graph scale, GPU→CPU transfer) and SwEncEncode (encode thread:
  sws_scale NV12→YUV420P, libx264 encode). New CpuNv12Frame struct
  carries owned pixel data across threads via crossbeam channel.
  SwEncState wraps both for backward compat (MP4/sync path untouched).
- state_portal.rs: WebRTC portal path spawns 'wl-webrtc-encode' thread
  with bounded(2) input channel (drop-newest backpressure) and separate
  timing channel. Graceful shutdown: drop webrtc_rx → drop input_tx →
  join encode thread → flush sync encoder.
- stats.rs: Add record_import() + record_encode_thread() for async timing.

Results: encode_p95 stable at 2.9-4.2ms (was 11-74ms), capture_fps
stable 59-60fps, cap_gap_p95 17-19ms. Remaining capture stalls traced
to PipeWire compositor frame delivery (external, not our code).
2026-06-07 16:55:28 +08:00
dailz d5679be3a4 fix(state_portal): replace expect() with bail-style error propagation (closes #9) 2026-06-06 20:19:51 +08:00
dailz 36f07c92e9 fix(state_portal): prevent shutdown deadlock on full bounded channel (closes #8)
shutdown() calls enc.flush() → drain_encoder() → tx.send() on a
crossbeam bounded(32) channel.  If the channel is full and the
receiver (webrtc_rx) is alive but not being drained, send() blocks
forever — a self-deadlock since both ends belong to the same struct.

Two-layer fix:
- avhw.rs: replace tx.send() with tx.try_send(); handle Full (drop
  frame) and Disconnected (set flag) separately.
- state_portal.rs: drop webrtc_rx before flushing in shutdown() so
  try_send returns Disconnected immediately.

Regression tests added for the channel semantics.
2026-06-06 20:02:09 +08:00
dailz 226768c3e3 fix(avhw): handle tx.send() failure and pause encoding on WebRTC disconnect (closes #6)
- Replace 'let _ = tx.send()' with proper error handling: log warning,
  set webrtc_disconnected flag, and break drain loop on SendError
- Add Arc<AtomicBool> webrtc_paused shared between State/StatePortal
  and SwEncState, synced from wrtc.is_connected() in poll_webrtc()
- Skip encoding in encode_filtered_frame() when paused or disconnected
- Drain and discard stale channel frames on disconnect
- Resume encoding automatically on WebRTC reconnection
2026-06-06 15:12:49 +08:00
dailz 46367ef6b5 fix(state): add WebRTC support to wlr-screencopy backend
Fixes #1 -- --port mode with wlr-screencopy backend caused panic at
negotiate_format() because self.args.output is None and .expect() was
called unconditionally.

Changes:
- Introduce StreamingEncoder enum wrapping EncState (MP4) and
  SwEncState (WebRTC) with unified frames_rgb/encode_frame/flush API
- Add WebRTC fields to State<S> (webrtc, webrtc_tx, webrtc_rx,
  webrtc_frames_sent) matching Portal backend pattern
- State::new() returns Result<Self> for clean WebRtcState init failure
- negotiate_format() branches on webrtc_tx: WebRTC path uses
  SwEncState::new_webrtc(), MP4 path unchanged (hardware VAAPI)
- Add poll_webrtc() method to drive signaling + channel drain
- Event loop calls poll_webrtc() each iteration
- Fix pre-existing test/bench Args construction (Option<String> output,
  missing no_persist field)
2026-06-04 22:10:46 +08:00
dailz b0ed6548a6 feat: add WebRTC streaming via str0m + portal session persistence
- Add src/webrtc.rs: HTTP signaling server + str0m Sans-IO WebRTC transport
  with H.264 Annex-B → RTP packetization and key-frame request handling
- avhw: introduce FrameOutput enum (Muxer | Channel) so SwEncState can
  output to either MP4 muxer or crossbeam channel for WebRTC
- cap_portal: support portal session restore tokens (PersistMode::ExplicitlyRevoked)
  to skip re-authorization dialog; add --no-persist flag to force fresh dialog
- args: make --output optional when --port is used for WebRTC mode
- state_portal: integrate WebRTC pipeline (encoder channel → RTP forwarding)
  with shorter GOP for WebRTC (fps/2, min 10)
- main: redirect tracing to stderr; validate --output or --port required
- Add dependencies: str0m 0.20, serde_json 1, dirs 6
2026-06-04 20:54:16 +08:00
dailz 74f4dc826d perf(portal): achieve 58-60fps PipeWire screen capture
- Force PipeWire quantum=512 via NODE_FORCE_QUANTUM (48000/512=93Hz scheduling)
- Switch to libx264 ultrafast/zerolatency with 6 threads
- Use two-phase poll_and_encode: blocking recv_timeout for first frame,
  non-blocking try_recv drain for subsequent frames
- Remove fps_limit from portal path (PW already rate-limits via quantum/KWin;
  fps_limit's min_interval was silently dropping ~10% of valid frames)
- Remove diagnostic instrumentation (TIMING/PIPEWIRE logs, timing fields,
  pw_stats counters)
- Add lightweight production stats: per-10s fps log + shutdown summary
- Prefer libx264 over libopenh264 (better quality at same speed)
2026-05-30 08:44:15 +08:00
dailz d80b34f44f feat: GPU-downscale + software H.264 encode pipeline (WIP)
Add SwEncState in avhw.rs: GPU pipeline using scale_vaapi to downscale
4K BGRA -> 2K NV12 on AMD iGPU, then software encode with libopenh264.

- import_dma_buf_to_vaapi: av_hwframe_map based DMA-BUF import
- SwEncState: GPU filter graph (scale_vaapi) + NV12->YUV420P + libopenh264
- state_portal.rs: integrated SwEncState, auto DRM device detection
- vaapi_import_bench.rs: CPU vs GPU pipeline benchmark
- sw_encode_bench.rs: software encode benchmark

Benchmark results: GPU pipeline ~91 FPS theoretical (10.95ms/frame)
vs CPU pipeline ~33 FPS (30.21ms/frame).

Known issue: only 1 frame encoded in production recording,
diagnostic STATS logging added to debug frame flow.
2026-05-29 22:04:12 +08:00
dailz 60a55c17f2 fix(state_portal): add Drop impl, null dangling pointers, extract compute_pts, add tests
- Add Drop impl for StatePortal to flush encoder on drop (bug #2)
- Use enc.take() in shutdown() to prevent double-flush of write_trailer
- Null out data[0] after Box::from_raw recovery to avoid dangling pointer
- Extract compute_pts() for testable PTS calculation
- Add 8 tests: PTS calculation, DRM device resolution, descriptor building
2026-05-27 09:22:59 +08:00
dailz 5100d78aa8 fix: resolve SHM hang, DRM device mismatch, and duplicate VAAPI context
BUG-2 (HIGH): SHM Buffer event caused permanent hang
  In the ZwlrScreencopyFrameV1 dispatcher, receiving a SHM Buffer event
  left in_flight_surface stuck at AllocQueued forever, preventing
  queue_alloc_frame() from requesting new frames.
  Fix: treat Buffer as a metadata offer (v3 protocol), wait for
  BufferDone to decide failure, and add AllocQueued state guard to
  LinuxDmabuf handler.

BUG-3 (MEDIUM): Portal backend picked wrong GPU on multi-GPU systems
  state_portal.rs hardcoded /dev/dri/renderD128 then renderD129, which
  selects the wrong GPU when PipeWire uses a different device.
  Fix: extract find_drm_render_nodes() as shared utility; defer DRM
  device selection to first PipeWire frame; test each candidate with
  av_hwframe_transfer_data to find the GPU that can actually import
  the DMA-BUF frame.

BUG-4 (LOW): VAAPI device context created twice unnecessarily
  try_finalize_output() created an AvHwDevCtx stored in EverythingButFmt,
  but negotiate_format() discarded it (_hw_device_ctx) and EncState::new
  created a new one.
  Fix: thread the existing hw_device_ctx through negotiate_format() and
  create_encoder() to EncState::new() which reuses it when provided.
2026-05-25 14:32:58 +08:00
dailz dcf8d1affb docs: add Chinese documentation comments to core modules
Add comprehensive Chinese documentation comments to cap_portal,
main, and state_portal modules covering architecture, lifecycle,
and data flow for each component.
2026-05-25 08:56:43 +08:00
dailz 2d448dcac5 fix(portal): recover AVDRMFrameDescriptor before encode_frame to prevent leak on error 2026-05-22 13:20:04 +08:00
dailz ffb36b7e0d fix(portal): convert PipeWire nanosecond PTS to encoder frame-number units
PipeWire spa_meta_header.pts is CLOCK_MONOTONIC in nanoseconds, but the
encoder expects frame-number units (time_base = 1/fps). The raw nanosecond
value was assigned directly to AVFrame.pts, causing the encoder/muxer to
interpret timestamps as billions of frames, producing corrupted duration
metadata and broken rate control.

Fix: record the first frame's PTS as a nanosecond base, compute elapsed
nanoseconds for each subsequent frame, then convert to frame numbers via
elapsed_ns * fps / 1_000_000_000. Using elapsed time avoids i64 overflow
on absolute timestamps (~10^18 ns).

Matches the WLR path pattern (state.rs:525-527) which converts microseconds
to frame numbers for the same encoder.
2026-05-22 13:05:53 +08:00
dailz d7fbb5256c feat: add KWin/KDE Plasma screen capture via xdg-desktop-portal ScreenCast + PipeWire
Add a second capture backend for compositors without wlr-screencopy
(KWin, GNOME, etc.) using the xdg-desktop-portal ScreenCast interface
and PipeWire DMA-BUF streaming.

New files:
- src/backend_detect.rs: auto-detect wlr-screencopy vs portal backend
- src/cap_portal.rs: Portal session setup + PipeWire DMA-BUF thread
- src/state_portal.rs: StatePortal encoder pipeline (DMA-BUF → VAAPI)

Changes:
- Cargo.toml: add ashpd 0.13, tokio 1, pipewire 0.9, libspa 0.9,
  crossbeam-channel 0.5
- src/args.rs: add --backend CLI flag
- src/avhw.rs: extract create_encoder() from inline State code
- src/main.rs: route to portal or wlr-screencopy based on backend
- src/state.rs: fix params.destroy() on dup failure, cleanup
  in_flight_surface on copy fail, use create_encoder()
- tests/integration_test.rs: add --backend flag tests
2026-05-11 08:49:08 +08:00