Commit Graph
87 Commits
Author SHA1 Message Date
dailz eca8032bcc ci(gitea): use cargo git registry index
CI / Build + Clippy + Test (pull_request) Failing after 55s
CI / Security audit (RUSTSEC) (pull_request) Failing after 1h57m35s
2026-07-03 13:39:07 +08:00
dailzandSisyphus b96b99fc9c ci(gitea): install libavfilter dev package
CI / Build + Clippy + Test (pull_request) Failing after 2m47s
CI / Security audit (RUSTSEC) (pull_request) Failing after 16m56s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-03 13:10:15 +08:00
dailzandSisyphus 823dd53745 chore(deps): address cargo audit findings
CI / Build + Clippy + Test (pull_request) Failing after 6m5s
CI / Security audit (RUSTSEC) (pull_request) Successful in 10m32s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-03 11:23:48 +08:00
dailzandSisyphus d53e881496 refactor(avhw): split encoder module
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-03 10:57:05 +08:00
dailz 17f5e235a9 ci(gitea): broaden libclang find pattern to match versioned sonames
CI / Security audit (RUSTSEC) (push) Failing after 1m31s
CI / Build + Clippy + Test (push) Failing after 4m18s
Second LIBCLANG_PATH failure mode: my 'libclang.so.*' with -type f
pattern returned nothing because Debian Bookworm's runtime library is
'libclang-14.so.1' (versioned, with no plain libclang.so.* symlink),
and the .so symlink is itself a symlink not a regular file (-type f
excludes it).

bindgen accepts any of: libclang.so, libclang-*.so, libclang.so.*,
libclang-*.so.* — so the broader 'libclang*.so*' (no -type filter)
catches every variant. Also broadened search root from /usr/lib to
/usr to cover both /usr/lib/x86_64-linux-gnu/ (runtime lib) and
/usr/lib/llvm-*/lib/ (dev symlink).

Log line now includes the actual matched path so future debugging
is one glance.
2026-06-28 17:20:02 +08:00
dailz 4e65f4175b ci(gitea): dynamically resolve LIBCLANG_PATH for act_runner container
CI / Build + Clippy + Test (push) Failing after 20m22s
CI / Security audit (RUSTSEC) (push) Failing after 1m31s
First real CI run on the Gitea Actions runner hit the predicted
LIBCLANG_PATH issue but for an unexpected reason: workflow-level
'env:' does not reliably propagate into act_runner's Docker executor
(bindgen received empty LIBCLANG_PATH despite /usr/lib/llvm-*/lib
being correct on the runner).

Two changes:

  1. Drop the hardcoded workflow-level 'env: LIBCLANG_PATH' and add a
     dedicated 'Resolve LIBCLANG_PATH' step that does:
         find /usr/lib -name 'libclang.so.*' | head -1 | xargs dirname
     and writes the result to $GITHUB_ENV. Dynamic discovery also
     future-proofs against Debian/Ubuntu version drift (llvm-14 today,
     llvm-18 tomorrow). The $GITHUB_ENV mechanism is reliably visible
     across step boundaries inside the act_runner Docker container,
     whereas workflow-level env: is not.

  2. apt install: drop '--no-install-recommends' on libclang-dev
     (Debian Bookworm's metapackage uses Recommends to pull in
     versioned toolchain bits bindgen needs). Also install 'clang'
     (not just llvm-14) to get version-agnostic libclang shared lib.

Comments inline in ci.yml document both decisions to prevent future
regression.

The 'Resolve LIBCLANG_PATH' step fails fast with a clear message if
libclang isn't installed, instead of letting the clippy/build steps
fail cryptically later.
2026-06-28 16:58:04 +08:00
dailz c772e4eb0b chore: bump MSRV to 1.87 to match actual API usage
CI / Build + Clippy + Test (push) Failing after 1h10m8s
CI / Security audit (RUSTSEC) (push) Failing after 1m31s
Oracle P2 follow-up. The clippy --fix autofixes earlier in this branch
silently introduced dependencies on APIs newer than the README's
1.70+ claim:
  - u32::is_multiple_of  (stable 1.87)
  - Option::is_none_or   (stable 1.82)

clippy::incompatible_msrv flagged the mismatch once rust-version was
pinned. Bumping the floor to 1.87 is the honest fix — the codebase
genuinely depends on 1.87 features now, and 1.87 has been stable
long enough (current stable is 1.96) that desktop CLI users on stable
Rust already have it.

  - Cargo.toml: rust-version '1.70' -> '1.87'. Comment lists the specific
    APIs that drove the bump and notes that further bumps need to be
    validated against clippy::incompatible_msrv.
  - README.md: Prerequisites line updated to 1.87+ with a brief why.
  - src/state_portal.rs: added the AsRawFd rustc-quirk comment that was
    already in avhw.rs (rustc emits a false 'unused_imports' warning;
    removing it produces E0599). Same known quirk, same documentation
    pattern.
  - src/transform.rs: fixed empty_line_after_doc_comments warning by
    converting the leading // doc-style comment to a //! module-level
    doc comment (which is what it should have been when I rewrote the
    file in commit 145b5d3).

All 79 unit tests + 3 integration tests pass. clippy: 0 errors,
0 incompatible_msrv warnings, 0 empty_line_after_doc_comments warnings.
Remaining warnings are: 1 AsRawFd rustc false-positive (documented),
5 unnecessary_cast FFI false-positives (rustc quirk on pointer casts),
and 8 dead-code items that need product decisions.
2026-06-28 14:39:00 +08:00
dailz 86a8b61b07 refactor: clear too_many_arguments and large_enum_variant warnings
Oracle P2 batch 2 (refactor items). Drops both remaining design-shape
clippy warnings to zero without behavior change.

  - avhw.rs build_filter_graph: drop unused _enc_width/_enc_height params
    (Oracle caught them during P2 review — passed by EncState::new but
    never read inside the function; the filter graph uses width/height
    only). Signature: 8 args -> 6 args (under clippy's 7 threshold).

  - state.rs InFlightSurface::CopyQueued: Box the drm_map field.
    AVDRMFrameDescriptor is ~592 bytes (4 objects + 4 layers); the enum
    size was being dominated by this variant, ballooning every
    InFlightSurface value to 592 bytes even for the None/AllocQueued
    variants. Box<AVDRMFrameDescriptor> shrinks the enum to ~32 bytes
    regardless of variant. The drm_map field is currently destructured
    under _drm_map (unused), so the boxing has no consumer-side impact.

  - state_portal.rs webrtc_thread_loop: 10 args -> 4 args via two new
    structs:
      * WebRtcThreadConfig { fps, enc_width, enc_height, max_bitrate }
        — immutable for the thread's lifetime; a tier change spawns a
        new thread rather than mutating.
      * WebRtcThreadChannels { webrtc_rx, sent_gap_tx, bitrate_tx,
        resolution_tx } — channel endpoints owned exclusively by the
        sender thread after spawn.
    wrtc (WebRtcState) and paused (Arc<AtomicBool>) stay as separate
    args because they have different ownership semantics (moved-in
    state vs shared atomic). Documented as doc comments on the new
    types so the next reader understands the bundle rationale.

All 79 unit tests + 3 integration tests pass. clippy: 0 errors.
Per-file warning counts: state_portal.rs down from 3 to 0; state.rs
down from 8 to 4 (remaining are unrelated dead-code on OutputInfo /
starting_timestamp).
2026-06-28 14:35:35 +08:00
dailz ed39d3d873 ci: add build/test/clippy gate + cargo audit; pin rust-version
Oracle P2 plan step 1+2+missed-fields. Locks in the audit cleanup so
future PRs can't regress the 0-errors / deny-unsafe / 79-tests baseline.

  - .github/workflows/ci.yml: two jobs on ubuntu-latest (Linux only —
    project is Wayland/VAAPI-specific, no macOS/Windows story).
      * build-test: installs ffmpeg + libavcodec/libavformat/libavutil/
        libswscale/libva dev + libwayland + libdrm + libpipewire-0.3-dev
        + libclang-dev/llvm-14 (LIBCLANG_PATH pinned); caches cargo +
        target; runs clippy -> build --release -> test --release.
        Release build before tests is mandatory because
        tests/integration_test.rs shells out to target/release/wl-webrtc.
      * audit: installs cargo-audit and runs 'cargo audit --deny warnings'
        as a separate job so a RUSTSEC advisory fails the build
        independently of compile state.
    No -D warnings on clippy yet — undocumented_unsafe_blocks is already
    deny via Cargo.toml; remaining warnings are advisory and can be
    tightened later.

  - Cargo.toml: pin rust-version = '1.70' to match README's claim.
    Without this, cargo builds silently on older toolchains and surfaces
    errors as cryptic parse failures instead of a clean version-mismatch
    message. Oracle flagged this as a missing field during P2 review.

License field intentionally omitted — repo has no LICENSE file and no
publication plan yet. Add when publication becomes a goal.

Verified locally: YAML parses, cargo build --release Finished in 9.82s,
cargo test --release 79 passed, cargo clippy 0 errors.
2026-06-28 14:31:42 +08:00
dailz a6560cff6c feat(stats): wire real scale/transfer/encode timing from EncState
Oracle step 4 (option A) — give the scale_*, transfer_*, encode_* stats
fields real producers instead of misleading zeros. The fields existed in
FrameTimings and PipelineStats already; producers just weren't passing
non-zero values.

  - avhw.rs: new EncodeStages { scale_us, transfer_us, encode_us } struct.
    EncState::encode_frame (HW VAAPI path) now times the filter graph
    separately from avcodec_send_frame, returning EncodeStages. transfer_us
    is honestly 0 because the HW path never reads back to CPU.
    SwEncState::encode_frame (SW fallback path) returns EncodeStages too;
    there import_and_scale bundles GPU scale + GPU→CPU readback into one
    call, so scale_us includes transfer for SW. Documented inline.

  - state.rs: StreamingEncoder::encode_frame return type bumps from
    Result<()> to Result<EncodeStages>; wlr-screencopy path now feeds
    real per-stage timings into FrameTimings instead of just total_us.

  - state_portal.rs: HW portal path (enc.encode_frame) now extracts
    stages.scale_us / stages.transfer_us / stages.encode_us into
    FrameTimings. Removed the now-unused t_encode_start binding.

Deferred (documented):
  - state_portal.rs SW portal path (line 525) calls import_and_scale +
    enc_thread separately and bypasses SwEncState::encode_frame. To wire
    scale/transfer timing there too, either route through SwEncState or
    thread timing out of import_and_scale. Out of scope for this commit.
  - SW path lumps transfer into scale_us. Splitting requires extending
    import_and_scale's return type — left as a follow-up if operational
    need arises (current default is HW VAAPI).

Oracle audit 2026-06-28 step 4 (option A: integrate, not delete).

All 79 unit tests + 3 integration tests pass. clippy: 0 errors.
2026-06-28 14:22:45 +08:00
dailz 2ac37a1dd1 fix(stats): wire PipeWire drops, expand Display, purge dead residue
Oracle-driven P1 fix plan. Resolves the StatsSnapshot 'computed but never
consumed' debt that was silently zeroing two real diagnostic fields and
leaving a dozen more unreported.

Bug fix (Oracle step 2):
  - state_portal.rs: set_pipewire_dropped(0, 0) and set_queue_depths(0, 0)
    were hardcoded, silently discarding real PipeWire diagnostics. Now wires
    to self.cap.dropped_count() (with pw_dropped_prev delta tracking) and
    self.cap.capture_queue_depth(). The encoded side stays 0 because the
    encoder thread exposes no queue-depth API.

Display expansion (Oracle step 1):
  - stats.rs: StatsSnapshot::Display now reports 12 previously-silent fields
    paired with their existing p95/max counterparts — capture/encoded/sent
    frame counts, elapsed_secs, *_avg_ms gap timing, frame_age_avg_ms,
    per-stage import/sws/encode/total avg_ms, output_frame_bytes_p95. Each
    line of the format string maps to one operational question (cadence,
    drops, queue pressure, latency, bandwidth); layout note added.

Dead residue purge (Oracle steps 5 + 6):
  - stats.rs: removed record_over_budget method + over_budget_count field
    (no caller; total_p95_ms answers the useful question without an
    arbitrary budget threshold).
  - state.rs: removed InFlightSurface::Allocd variant (never constructed)
    and CaptureSource::alloc_frame trait method (prototype leftover; the
    sole impl in cap_wlr_screencopy.rs returned None unconditionally).
  - cap_wlr_screencopy.rs: removed the alloc_frame stub; updated the
    unit-type Frame doc to reference the asynchronicity rationale without
    the deleted method.
  - cap_portal.rs: removed redundant 'let dropped = dropped;' shadowing
    flagged by clippy::redundant_locals (line 849).

Deferred (Oracle step 4 — needs product decision):
  - scale_avg/scale_p95/transfer_avg/transfer_p95/send_wait_p95 fields
    still appear in Display but producers in the live encode path don't
    record them, so they often show misleading zeros. Either add real
    EncState timing for scale/transfer stages, or remove the fields from
    Display until then.

All 79 unit tests + 3 integration tests still pass. clippy: 0 errors.
Warning count: multiple_fields_never_read on StatsSnapshot,
method_never_used on record_over_budget/dropped_count/capture_queue_depth/
alloc_frame, variant_never_constructed on Allocd, redundant_locals on
dropped — all gone.
2026-06-28 14:15:55 +08:00
dailz 145b5d3e7e chore: design cleanup, dead-code purge, README/doc refresh
Audit-driven follow-up after the SAFETY-debt commit (Oracle steps 6-7).
End state: cargo clippy --release --all-targets still 0 errors; private_interfaces
and type_complexity warnings cleared.

Design cleanups (Oracle step 6):
  - cap_portal.rs: introduce PortalFormatInfo struct to replace the
    Rc<Cell<Option<(u32,u32,u32,u64)>>> cross-callback hand-off. Self-
    documenting struct fields replace positional tuple access at the
    format-change and process callbacks.
  - avhw.rs: import_dma_buf_to_vaapi signature collapses from 8 args
    (fd/width/height/drm_format/modifier/stride/offset) to
    (*mut AVBufferRef, &PwDmaBufFrame). Callers in avhw.rs,
    state_portal.rs, and vaapi_import_bench.rs now pass the frame by
    reference instead of unpacking 7 fields just to repack them. Drops
    the unused width parameter and the too_many_arguments(8/7) warning.
  - state.rs: visibility hygiene. EncConstructionStage and WlrHeadInfo
    downgrade pub -> pub(crate); State.stage field downgrades to
    pub(crate). These are internal state-machine types not exposed
    across the crate boundary; making them pub(crate) clears all
    private_interfaces warnings without leaking more types.

Dead-code purge (Oracle step 7):
  - transform.rs: remove unused Rect struct, transform_basis,
    screen_to_frame, fit_inside_bounds helpers and their 18 dedicated
    tests. Transform enum and transpose_if_transform_transposed remain
    (both are actively used by state.rs and avhw.rs). File shrinks
    from 409 -> 109 lines.

Repository housekeeping (Oracle step 7):
  - .gitignore: add review.json (stray review-tool output that
    regenerates per run).
  - README.md: refresh CLI table to match src/args.rs (now lists
    --backend, --no-persist, --port-as-WebRTC-signaling, --max-bitrate,
    --stats). Add capture-backend explainer + 4 new usage examples.
    Note in README points readers at src/args.rs as the authoritative
    source. Remove stale 'WebTransport, unused in MVP' description.

avhw.rs: AsRawFd import annotated with a rustc-quirk explanation — the
import triggers a false 'unused_imports' warning but E0599 if removed.
Left as-is with explanatory comment rather than chasing the lint.

All 79 remaining unit tests + 3 integration tests still pass. Cargo
build --release clean.
2026-06-28 13:58:52 +08:00
dailz 30f8fe51f2 chore: clear clippy errors, document all unsafe blocks, deny new SAFETY debt
Audit-driven cleanup pass. End state:
  - cargo clippy --release --all-targets: 0 errors (was 4)
  - undocumented_unsafe_blocks warnings: 0 (was 67)
  - Cargo.toml: undocumented_unsafe_blocks escalated warn -> deny

Clippy correctness errors fixed:
  - src/bin/{sw_encode_bench,vaapi_import_bench}.rs: receive_first_frame
    rewritten per Oracle plan with total 10s deadline + 200ms wait slice +
    while-let drain of all control events. The previous loop body always
    exited on first iteration (never_loop); the new version actually retries
    and matches production's repeated-poll semantics in state_portal.rs.
  - src/avhw.rs: hash_sampled_y_plane tests now use a row_range(row, stride,
    width) helper instead of inline stride * N. Preserves the row-index
    intent across all sibling tests without tripping erasing_op (row==0) or
    identity_op (row==1).

Machine-applicable clippy autofixes applied via 'cargo clippy --fix':
  - unnecessary_cast, manual_is_multiple_of, needless_borrows_for_generic_args
  - manual_abs_diff, derivable_impls, new_without_default
  - unnecessary_map_or, unneeded_struct_pattern, redundant_locals

webrtc_gop_formula test rewritten to wrap the (fps * 2).max(20) formula in
a runtime lambda. The previous clippy --fix pass had constant-folded the
5fps case into assert_eq!(20, 20), silently stripping the floor-case
coverage. The lambda blocks the fold while keeping the formula exercisable.

67 SAFETY comments added across 7 files (cap_portal.rs 26, sw_encode_bench
21, state_portal.rs 7, vaapi_import_bench.rs 6, avhw.rs 5, state.rs 1,
main.rs 1). Two sites carry load-bearing invariant documentation:
  - cap_portal.rs:806 process callback documents the PipeWire raw_buf
    ownership contract across all 10 exit paths (audited: every path
    correctly requeues; fd ownership via dup() is independent and also
    exactly-once closed).
  - avhw.rs:341 unsafe impl Send for EncState documents the single-thread
    exclusivity assumption referenced by AGENTS.md.

All 97 unit tests + 3 integration tests still pass; cargo build --release
finishes clean. Lint escalation to deny freezes the SAFETY baseline: any
future patch adding an unsafe block without a // SAFETY: comment will fail
clippy at compile time.
2026-06-28 13:44:27 +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
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 0e91c793c7 docs: add AGENTS.md project guide
Capture sources of truth, build/test instructions, runtime architecture,
and unsafe/FFI notes for AI agents working in this repo.
2026-06-13 22:46:41 +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 aae030f309 fix(webrtc): SO_SNDBUF 2MB + VBV rate limiting + stats integration
P0 - UDP send buffer: set SO_SNDBUF=2MB to prevent EAGAIN on large IDR
frames (218KB/256KB keyframes caused 18+ EAGAIN bursts). Actual Linux
buffer 4096KB confirmed.

P1 - VBV rate limiting: cap rc_max_rate=bitrate and rc_buffer_size=
bitrate/4 for WebRTC encode path, preventing oversized IDR frames.

Stats: integrate PipelineStats into cap_portal (dropped_count), state.rs
(wlroots path), webrtc.rs (browser getStats enhancement + stats panel).
2026-06-07 16:55:07 +08:00
dailz 029fe13e37 feat(stats): add --stats flag and PipelineStats windowed diagnostics
Add lightweight per-second pipeline statistics for stutter diagnosis:
- --stats CLI flag enables structured stats logging
- PipelineStats tracks capture/encode/send timing with p95/pmax
- FrameTimings records import/scale/transfer/sws/encode per-frame
- StatsSnapshot produces one structured log line per second
2026-06-07 16:54:45 +08:00
dailz f3da1e4e6c fix(webrtc): propagate poll_output error as cleanup signal to prevent zombie state (closes #14) 2026-06-06 21:48:38 +08:00
dailz e6e05fb44a fix(webrtc): fix is_idr_nalu boundary bug missing tail NAL units (closes #13) 2026-06-06 21:34:22 +08:00
dailz 8b04893ceb fix(security): remove error details from HTTP 500 response (#12)
The 500 error response previously included the raw error message {e}
in the body, potentially leaking internal implementation details (SDP
parse errors, ICE candidate info) to clients.

The detailed error is already logged server-side via tracing::error!,
so the response body is now a fixed generic string with a proper
HTTP/1.1 status line.
2026-06-06 21:22:57 +08:00
dailz 1beaea8088 fix(webrtc): use MediaAdded event to discover video mid instead of hardcoded iteration (closes #11) 2026-06-06 21:16:55 +08:00
dailz fc4733ffe8 fix: return Ok(true) on ICE Disconnected to prevent resource leak
poll_rtc() always returned Ok(false), preventing WebRtcState from
clearing self.inner on disconnect. This leaked the UDP socket, Rtc
instance, and 65KB buffer permanently if the client never reconnected.

Closes #10
2026-06-06 20:57:25 +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 7c1c9b2e19 fix(avhw): add SAFETY comments to all undocumented unsafe blocks
Close #7

- Add // SAFETY: comments to 19 undocumented unsafe blocks and impls
- Add nb_streams/null guard on stream array dereference (drain_encoder)
- Add clippy undocumented_unsafe_blocks = warn lint to prevent regression

avhw.rs now has 0 clippy unsafe documentation warnings.
2026-06-06 15:54: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 fd170b66d9 fix(unsafe): add SAFETY comment and runtime guards for from_raw_parts in drain_encoder
Issue: #5

- Read AVPacket fields into local variable to avoid repeated pointer deref
- Guard against size <= 0 (prevents c_int negative wrap to huge usize)
- Guard against null data pointer (from_raw_parts(null, 0) is UB in Rust)
- Add SAFETY comment matching existing codebase convention (30+ instances)
2026-06-06 11:56:47 +08:00
dailz 9a5b09cd7f fix(security): harden token file permissions (closes #2)
- save_restore_token: use create_new(true) + mode(0o600) for exclusive
  atomic file creation, preventing symlink attacks and predictable
  temp file exploitation
- token_path: return Option, eliminate insecure /tmp fallback
- load_restore_token: reject insecure files (symlinks, wrong owner,
  group/world-readable permissions)
- Directory creation uses DirBuilderExt::mode(0o700) bypassing umask
- Added verify_secure_dir and ensure_secure_parent with full metadata
  validation (owner, permissions, symlink rejection)
- Added 11 regression tests covering all security scenarios
2026-06-06 11:05:00 +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