initial
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
10
.sisyphus/boulder.json
Normal file
10
.sisyphus/boulder.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"active_plan": "/home/dailz/workspace/src/rust_project/wl-webrtc/.sisyphus/plans/wl-webrtc-implementation.md",
|
||||||
|
"started_at": "2026-02-02T10:24:30.470Z",
|
||||||
|
"session_ids": [
|
||||||
|
"ses_3e21e172bfferNSCxJmNUCEVNr"
|
||||||
|
],
|
||||||
|
"plan_name": "wl-webrtc-implementation",
|
||||||
|
"completed_at": "2026-02-02T19:21:00.000Z",
|
||||||
|
"status": "completed"
|
||||||
|
}
|
||||||
53
.sisyphus/notepads/wl-webrtc-implementation/issues.md
Normal file
53
.sisyphus/notepads/wl-webrtc-implementation/issues.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
|
||||||
|
# Task 7: WebSocket Signaling Server - Issues Encountered
|
||||||
|
|
||||||
|
## Build System Issues
|
||||||
|
- `libspa-sys` build script failed when running full cargo test due to missing PipeWire dev dependencies
|
||||||
|
- Solution: Used `cargo test --no-default-features` to skip PipeWire feature during testing
|
||||||
|
|
||||||
|
## Compilation Errors
|
||||||
|
|
||||||
|
### E0433: Unresolved Module `tungstenite`
|
||||||
|
- **Error**: Could not resolve `tungstenite::Error` type
|
||||||
|
- **Cause**: Import statement only imported specific items, not the full module
|
||||||
|
- **Solution**: Added `Error as WsError` to tokio_tungstenite imports
|
||||||
|
- **Pattern**: Use type aliasing for frequently used imported types
|
||||||
|
|
||||||
|
### E0599: No Method `next`
|
||||||
|
- **Error**: `WebSocketStream` didn't have `next()` method in scope
|
||||||
|
- **Cause**: Missing `StreamExt` trait from futures crate
|
||||||
|
- **Solution**: Added `use futures::StreamExt` to imports
|
||||||
|
- **Lesson**: WebSocketStream implements Sink and Stream traits from futures, both needed
|
||||||
|
|
||||||
|
### E0282: Type Annotations Needed
|
||||||
|
- **Error**: Compiler couldn't infer error types in closures
|
||||||
|
- **Affected locations**: `handle_client` message variable, multiple error closures
|
||||||
|
- **Solution**: Added explicit type annotations: `let msg: Message`, `|e: WsError|`
|
||||||
|
- **Pattern**: Async WebSocket code frequently requires explicit type annotations
|
||||||
|
|
||||||
|
### Missing Error Variant
|
||||||
|
- **Error**: Used `SignalingError::SessionNotFound` which doesn't exist
|
||||||
|
- **Root Cause**: Error exists in `WebRtcError` enum, not `SignalingError`
|
||||||
|
- **Solution**: Changed to `SignalingError::ProtocolError` with descriptive format string
|
||||||
|
- **Alternative**: Could add `SessionNotFound` variant to `SignalingError` enum
|
||||||
|
|
||||||
|
## Code Organization Issues
|
||||||
|
|
||||||
|
### Duplicate Struct Definition
|
||||||
|
- **Issue**: Initially had both placeholder and complete `SignalingServer` struct definitions
|
||||||
|
- **Cause**: Added new struct without removing old placeholder
|
||||||
|
- **Detection**: Compiler reported "unclosed delimiter" error
|
||||||
|
- **Solution**: Removed old placeholder struct definition
|
||||||
|
- **Prevention**: Always review file for duplicates before major edits
|
||||||
|
|
||||||
|
## Testing Considerations
|
||||||
|
|
||||||
|
### Port Conflicts
|
||||||
|
- **Issue**: Default port 8765 might conflict during concurrent test runs
|
||||||
|
- **Solution**: Used ephemeral ports (18765+) for test instances
|
||||||
|
- **Pattern**: Add test port offset (10000+) to default ports
|
||||||
|
|
||||||
|
### Dependency Testing
|
||||||
|
- **Issue**: Full build requires PipeWire system libraries
|
||||||
|
- **Solution**: Test with `--no-default-features` to test core functionality
|
||||||
|
- **Trade-off**: Can't test integration with features disabled
|
||||||
1730
.sisyphus/notepads/wl-webrtc-implementation/learnings.md
Normal file
1730
.sisyphus/notepads/wl-webrtc-implementation/learnings.md
Normal file
File diff suppressed because it is too large
Load Diff
1166
.sisyphus/plans/wl-webrtc-implementation.md
Normal file
1166
.sisyphus/plans/wl-webrtc-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
4476
Cargo.lock
generated
Normal file
4476
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
86
Cargo.toml
Normal file
86
Cargo.toml
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
[package]
|
||||||
|
name = "wl-webrtc"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Your Name <your.email@example.com>"]
|
||||||
|
description = "Wayland to WebRTC remote desktop backend"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Async Runtime
|
||||||
|
tokio = { version = "1.35", features = ["full"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
futures = "0.3"
|
||||||
|
async-channel = "2.2"
|
||||||
|
tokio-tungstenite = "0.21"
|
||||||
|
|
||||||
|
# Wayland & PipeWire
|
||||||
|
wayland-client = "0.31"
|
||||||
|
wayland-protocols = "0.31"
|
||||||
|
pipewire = { version = "0.8", optional = true }
|
||||||
|
ashpd = "0.8"
|
||||||
|
|
||||||
|
# Video Encoding
|
||||||
|
openh264 = { version = "0.6", optional = true }
|
||||||
|
x264 = { version = "0.4", optional = true }
|
||||||
|
vpx = { version = "0.1", optional = true }
|
||||||
|
|
||||||
|
# WebRTC
|
||||||
|
webrtc = "0.11"
|
||||||
|
|
||||||
|
# Memory & Zero-Copy
|
||||||
|
bytes = "1.5"
|
||||||
|
memmap2 = "0.9"
|
||||||
|
shared_memory = "0.12"
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
# Error Handling
|
||||||
|
anyhow = "1.0"
|
||||||
|
thiserror = "1.0"
|
||||||
|
|
||||||
|
# System
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
regex = "1.10"
|
||||||
|
uuid = { version = "1.6", features = ["v4", "serde"] }
|
||||||
|
chrono = "0.4"
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "wl_webrtc"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
criterion = { version = "0.5", features = ["html_reports"] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["openh264", "pipewire"]
|
||||||
|
hardware-encoding = []
|
||||||
|
software-encoding = ["openh264"]
|
||||||
|
all-encoders = ["software-encoding"]
|
||||||
|
pipewire = ["dep:pipewire"]
|
||||||
|
x264 = ["dep:x264"]
|
||||||
|
vpx = ["dep:vpx"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "wl-webrtc"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
opt-level = 0
|
||||||
|
debug = true
|
||||||
3076
DESIGN_CN.md
Normal file
3076
DESIGN_CN.md
Normal file
File diff suppressed because it is too large
Load Diff
8388
DETAILED_DESIGN_CN.md
Normal file
8388
DETAILED_DESIGN_CN.md
Normal file
File diff suppressed because it is too large
Load Diff
6036
DETAILED_DESIGN_CN.md.backup
Normal file
6036
DETAILED_DESIGN_CN.md.backup
Normal file
File diff suppressed because it is too large
Load Diff
707
README.md
Normal file
707
README.md
Normal file
@@ -0,0 +1,707 @@
|
|||||||
|
# wl-webrtc
|
||||||
|
|
||||||
|
A high-performance, low-latency Wayland to WebRTC remote desktop backend written in Rust. Features zero-copy DMA-BUF pipeline, hardware-accelerated encoding, and WebRTC streaming for sub-20ms latency on local networks.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
wl-webrtc captures Wayland desktop screens via PipeWire's xdg-desktop-portal, encodes video using hardware-accelerated codecs, and streams to web browsers via WebRTC. The zero-copy DMA-BUF pipeline minimizes memory transfers, enabling ultra-low latency streaming ideal for local network remote desktop scenarios.
|
||||||
|
|
||||||
|
### Use Cases
|
||||||
|
|
||||||
|
- **Remote Desktop**: Access your Linux workstation from any device with a web browser
|
||||||
|
- **Screen Sharing**: Share your screen in web applications with minimal latency
|
||||||
|
- **Gaming**: Play games remotely with low-latency video streaming
|
||||||
|
- **Collaboration**: Real-time screen sharing for pair programming or presentations
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- **Zero-Copy Pipeline**: DMA-BUF direct-to-encoder with no CPU memory copies
|
||||||
|
- **Low Latency**: 15-25ms on LAN, <100ms on WAN with optimal configuration
|
||||||
|
- **Hardware Acceleration**: VA-API (Intel/AMD), NVENC (NVIDIA), or software fallback
|
||||||
|
- **Adaptive Bitrate**: Dynamic bitrate adjustment based on network conditions
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
- **Wayland**: Native Wayland screen capture via PipeWire
|
||||||
|
- **WebRTC**: Browser-compatible streaming without plugins
|
||||||
|
- **Multiple Encoders**: H.264 (x264, VA-API, NVENC), VP9
|
||||||
|
- **Platform**: Linux with Wayland compositor
|
||||||
|
|
||||||
|
### Reliability
|
||||||
|
- **Damage Tracking**: Encode only changed screen regions
|
||||||
|
- **Frame Skipping**: Adaptive frame rate control
|
||||||
|
- **ICE/STUN/TURN**: NAT traversal for remote connections
|
||||||
|
- **Connection Recovery**: Automatic reconnection on network issues
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### System Dependencies
|
||||||
|
|
||||||
|
Install required system packages for your Linux distribution:
|
||||||
|
|
||||||
|
**Ubuntu/Debian:**
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y \
|
||||||
|
libpipewire-0.3-dev \
|
||||||
|
libwayland-dev \
|
||||||
|
libwayland-protocols-dev \
|
||||||
|
libx264-dev \
|
||||||
|
libopenh264-dev \
|
||||||
|
libva-dev \
|
||||||
|
vainfo \
|
||||||
|
pkg-config \
|
||||||
|
clang
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fedora/RHEL:**
|
||||||
|
```bash
|
||||||
|
sudo dnf install -y \
|
||||||
|
pipewire-devel \
|
||||||
|
wayland-devel \
|
||||||
|
wayland-protocols-devel \
|
||||||
|
x264-devel \
|
||||||
|
openh264-devel \
|
||||||
|
libva-devel \
|
||||||
|
libva-intel-driver \
|
||||||
|
libva-intel-hybrid-driver \
|
||||||
|
pkg-config \
|
||||||
|
clang
|
||||||
|
```
|
||||||
|
|
||||||
|
**Arch Linux:**
|
||||||
|
```bash
|
||||||
|
sudo pacman -S \
|
||||||
|
pipewire \
|
||||||
|
wayland \
|
||||||
|
wayland-protocols \
|
||||||
|
x264 \
|
||||||
|
openh264 \
|
||||||
|
libva \
|
||||||
|
libva-intel-driver \
|
||||||
|
libva-mesa-driver \
|
||||||
|
pkg-config \
|
||||||
|
clang
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optional Hardware Encoder Support:**
|
||||||
|
|
||||||
|
For **Intel/AMD GPU** (VA-API):
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt install -y libva-dev libva-intel-driver vainfo
|
||||||
|
|
||||||
|
# Verify VA-API is available
|
||||||
|
vainfo
|
||||||
|
```
|
||||||
|
|
||||||
|
For **NVIDIA GPU** (NVENC):
|
||||||
|
```bash
|
||||||
|
# Install NVIDIA proprietary drivers (≥ 470.xx)
|
||||||
|
# See: https://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new
|
||||||
|
|
||||||
|
# Verify NVENC is available
|
||||||
|
nvidia-smi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cargo Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/yourusername/wl-webrtc.git
|
||||||
|
cd wl-webrtc
|
||||||
|
|
||||||
|
# Build the project
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# The binary will be available at target/release/wl-webrtc
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Copy the configuration template and customize it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.toml.template config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `config.toml` to match your system and network conditions. See [Configuration](#configuration) for details.
|
||||||
|
|
||||||
|
### Build Features
|
||||||
|
|
||||||
|
wl-webrtc supports optional features for different encoder configurations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Default features (openh264 + pipewire)
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# With x264 encoder
|
||||||
|
cargo build --release --features x264
|
||||||
|
|
||||||
|
# With VP9 encoder
|
||||||
|
cargo build --release --features vpx
|
||||||
|
|
||||||
|
# With all encoders
|
||||||
|
cargo build --release --features all-encoders
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
1. **Start the server:**
|
||||||
|
```bash
|
||||||
|
# Use default configuration
|
||||||
|
./target/release/wl-webrtc start
|
||||||
|
|
||||||
|
# Or specify a custom config file
|
||||||
|
./target/release/wl-webrtc --config my-config.toml start
|
||||||
|
|
||||||
|
# Override configuration via command line
|
||||||
|
./target/release/wl-webrtc --frame-rate 60 --bitrate-mbps 8 start
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Connect a client:**
|
||||||
|
- Open a web browser and navigate to `http://localhost:8443` (or your configured port)
|
||||||
|
- The WebRTC connection will be established automatically
|
||||||
|
- Grant screen capture permissions when prompted by PipeWire
|
||||||
|
|
||||||
|
3. **Stop the server:**
|
||||||
|
```bash
|
||||||
|
./target/release/wl-webrtc stop
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command-Line Interface
|
||||||
|
|
||||||
|
wl-webrtc provides a CLI for managing the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Display help
|
||||||
|
./target/release/wl-webrtc --help
|
||||||
|
|
||||||
|
# Start server with specific options
|
||||||
|
./target/release/wl-webrtc \
|
||||||
|
--frame-rate 60 \
|
||||||
|
--width 1920 \
|
||||||
|
--height 1080 \
|
||||||
|
--bitrate-mbps 4 \
|
||||||
|
--port 8443 \
|
||||||
|
start
|
||||||
|
|
||||||
|
# Start with hardware encoder
|
||||||
|
./target/release/wl-webrtc --encoder h264_vaapi start
|
||||||
|
|
||||||
|
# Stop running server
|
||||||
|
./target/release/wl-webrtc stop
|
||||||
|
|
||||||
|
# Show current configuration
|
||||||
|
./target/release/wl-webrtc config
|
||||||
|
|
||||||
|
# Display version
|
||||||
|
./target/release/wl-webrtc --version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The `config.toml` file controls all aspects of wl-webrtc behavior:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[capture]
|
||||||
|
frame_rate = 30 # Target FPS for screen capture
|
||||||
|
quality = "high" # Quality level: low, medium, high, ultra
|
||||||
|
|
||||||
|
[encoder]
|
||||||
|
encoder_type = "h264_x264" # Encoder: h264_x264, h264_vaapi, h264_nvenc, vp9
|
||||||
|
width = 1920
|
||||||
|
height = 1080
|
||||||
|
frame_rate = 30
|
||||||
|
bitrate = 4000000 # 4 Mbps
|
||||||
|
preset = "veryfast" # Encoding speed/quality tradeoff
|
||||||
|
tune = "zerolatency" # Latency optimization
|
||||||
|
|
||||||
|
[webrtc]
|
||||||
|
port = 8443
|
||||||
|
ice_servers = ["stun:stun.l.google.com:19302"]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Network Configuration
|
||||||
|
|
||||||
|
**Local Network (LAN) - Best Quality:**
|
||||||
|
```toml
|
||||||
|
[capture]
|
||||||
|
frame_rate = 60
|
||||||
|
quality = "ultra"
|
||||||
|
|
||||||
|
[encoder]
|
||||||
|
bitrate = 16000000 # 16 Mbps
|
||||||
|
frame_rate = 60
|
||||||
|
preset = "veryfast"
|
||||||
|
encoder_type = "h264_vaapi" # Hardware encoder recommended
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remote Network (Good Connection):**
|
||||||
|
```toml
|
||||||
|
[capture]
|
||||||
|
frame_rate = 30
|
||||||
|
quality = "high"
|
||||||
|
|
||||||
|
[encoder]
|
||||||
|
bitrate = 4000000 # 4 Mbps
|
||||||
|
frame_rate = 30
|
||||||
|
preset = "veryfast"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remote Network (Poor Connection):**
|
||||||
|
```toml
|
||||||
|
[capture]
|
||||||
|
frame_rate = 24
|
||||||
|
quality = "medium"
|
||||||
|
|
||||||
|
[encoder]
|
||||||
|
bitrate = 1000000 # 1 Mbps
|
||||||
|
frame_rate = 24
|
||||||
|
preset = "ultrafast" # Faster encoding
|
||||||
|
width = 1280 # Lower resolution
|
||||||
|
height = 720
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TURN Server Configuration
|
||||||
|
|
||||||
|
For networks that block direct UDP connections, configure a TURN server:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[webrtc]
|
||||||
|
turn_servers = [
|
||||||
|
{ urls = ["turn:your-turn-server.com:3478?transport=udp"],
|
||||||
|
username = "your-username",
|
||||||
|
credential = "your-credential" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
See [config.toml.template](config.toml.template) for complete configuration documentation.
|
||||||
|
|
||||||
|
### Client Connection
|
||||||
|
|
||||||
|
After starting the server, connect from any web browser:
|
||||||
|
|
||||||
|
1. **Navigate to the server URL:**
|
||||||
|
```
|
||||||
|
http://localhost:8443
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Grant screen capture permissions:**
|
||||||
|
- PipeWire will prompt for screen sharing permission
|
||||||
|
- Select the screen or window to share
|
||||||
|
|
||||||
|
3. **WebRTC establishes automatically:**
|
||||||
|
- Video streaming begins
|
||||||
|
- Mouse and keyboard events are forwarded (if supported)
|
||||||
|
|
||||||
|
4. **For remote connections:**
|
||||||
|
- Configure firewall to allow the server port (default: 8443)
|
||||||
|
- Use a domain name or public IP if accessing from outside the local network
|
||||||
|
- Configure STUN/TURN servers for NAT traversal
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### System Overview
|
||||||
|
|
||||||
|
wl-webrtc implements a zero-copy pipeline from screen capture to network transmission:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Web Browser │
|
||||||
|
│ (WebRTC Receiver) │
|
||||||
|
└─────────────────────────────┬───────────────────────────────────────┘
|
||||||
|
│ WebRTC (UDP/TCP)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Rust Backend Server │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Capture │ │ Encoder │ │ WebRTC │ │
|
||||||
|
│ │ Manager │───▶│ Pipeline │───▶│ Transport │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ PipeWire │ │ Video │ │ Data │ │
|
||||||
|
│ │ Portal │ │ Encoder │ │ Channel │ │
|
||||||
|
│ │ (xdg- │ │ (H.264/ │ │ (Input/ │ │
|
||||||
|
│ │ desktop- │ │ H.265/VP9) │ │ Control) │ │
|
||||||
|
│ │ portal) │ └──────────────┘ └──────────────┘ │
|
||||||
|
│ └──────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Zero-Copy Buffer Manager │ │
|
||||||
|
│ │ - DMA-BUF import/export │ │
|
||||||
|
│ │ - Shared memory pool │ │
|
||||||
|
│ │ - Memory ownership tracking │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Wayland Compositor │
|
||||||
|
│ (PipeWire Screen Share) │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.rs # CLI entry point and server orchestration
|
||||||
|
├── lib.rs # Library exports
|
||||||
|
├── config/ # Configuration management
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ └── types.rs
|
||||||
|
├── capture/ # Screen capture module
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ └── pipewire.rs # PipeWay xdg-desktop-portal integration
|
||||||
|
├── buffer/ # Zero-copy buffer management
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ └── pool.rs
|
||||||
|
├── encoder/ # Video encoding pipeline
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── h264.rs
|
||||||
|
│ ├── vp9.rs
|
||||||
|
│ └── pool.rs
|
||||||
|
├── webrtc/ # WebRTC transport
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── peer.rs # Peer connection management
|
||||||
|
│ └── signaling.rs # Signaling server
|
||||||
|
├── ipc/ # Inter-process communication (optional)
|
||||||
|
│ └── mod.rs
|
||||||
|
└── utils/ # Utilities
|
||||||
|
├── mod.rs
|
||||||
|
└── logging.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zero-Copy Data Flow
|
||||||
|
|
||||||
|
wl-webrtc minimizes memory copies through a carefully designed pipeline:
|
||||||
|
|
||||||
|
```
|
||||||
|
Stage 1: Capture
|
||||||
|
Input: Wayland compositor (GPU memory)
|
||||||
|
Output: DMA-BUF file descriptor
|
||||||
|
Copies: None (zero-copy)
|
||||||
|
|
||||||
|
Stage 2: Buffer Manager
|
||||||
|
Input: DMA-BUF file descriptor
|
||||||
|
Output: DmaBufHandle (RAII wrapper)
|
||||||
|
Copies: None (zero-copy ownership transfer)
|
||||||
|
|
||||||
|
Stage 3: Encoder
|
||||||
|
Input: DmaBufHandle
|
||||||
|
Output: Bytes (reference-counted)
|
||||||
|
Copies: None (DMA-BUF direct import to GPU encoder)
|
||||||
|
|
||||||
|
Stage 4: WebRTC
|
||||||
|
Input: Bytes
|
||||||
|
Output: RTP packets (referencing Bytes)
|
||||||
|
Copies: None (zero-copy to socket buffer)
|
||||||
|
|
||||||
|
Stage 5: Network
|
||||||
|
Input: RTP packets
|
||||||
|
Output: UDP datagrams
|
||||||
|
Copies: Minimal (kernel space only)
|
||||||
|
```
|
||||||
|
|
||||||
|
This design ensures that video data travels from GPU capture to network transmission without CPU memory copies, enabling ultra-low latency streaming.
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
#### Capture Manager
|
||||||
|
- Interacts with PipeWire xdg-desktop-portal
|
||||||
|
- Requests screen capture permissions
|
||||||
|
- Receives DMA-BUF frame data
|
||||||
|
- Manages frame buffer lifecycle
|
||||||
|
|
||||||
|
#### Encoder Pipeline
|
||||||
|
- Receives raw frames from capture
|
||||||
|
- Encodes to H.264/H.265/VP9
|
||||||
|
- Supports hardware acceleration (VA-API, NVENC)
|
||||||
|
- Implements adaptive bitrate control
|
||||||
|
|
||||||
|
#### WebRTC Transport
|
||||||
|
- Manages WebRTC peer connections
|
||||||
|
- Handles video track and data channels
|
||||||
|
- Implements RTP packetization
|
||||||
|
- Manages ICE/STUN/TURN for NAT traversal
|
||||||
|
|
||||||
|
#### Buffer Manager
|
||||||
|
- Manages DMA-BUF lifecycle
|
||||||
|
- Maintains shared memory pool
|
||||||
|
- Tracks memory ownership via Rust types
|
||||||
|
- Coordinates with PipeWire memory pool
|
||||||
|
|
||||||
|
### Damage Tracking
|
||||||
|
|
||||||
|
wl-webrtc implements damage tracking to encode only changed screen regions:
|
||||||
|
|
||||||
|
1. Compare current frame with previous frame
|
||||||
|
2. Identify changed regions (damaged areas)
|
||||||
|
3. Encode only damaged regions at higher quality
|
||||||
|
4. Reduce bandwidth and CPU usage
|
||||||
|
5. Maintain low latency even with partial updates
|
||||||
|
|
||||||
|
For detailed architecture information, see [DESIGN_CN.md](DESIGN_CN.md).
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
### Latency Targets
|
||||||
|
|
||||||
|
| Scenario | Target Latency | Configuration |
|
||||||
|
|----------|----------------|---------------|
|
||||||
|
| Local Network (LAN) | 15-25ms | Hardware encoder, 60fps, 8-16Mbps |
|
||||||
|
| Remote Network (Good) | 50-100ms | Hardware encoder, 30fps, 2-4Mbps |
|
||||||
|
| Remote Network (Poor) | 100-200ms | Software encoder, 15-24fps, 0.5-1Mbps |
|
||||||
|
|
||||||
|
### Resource Usage
|
||||||
|
|
||||||
|
**CPU Usage (Software Encoding):**
|
||||||
|
- 1080p@30fps: 20-40% (typical)
|
||||||
|
- 1080p@60fps: 40-60%
|
||||||
|
- 720p@30fps: 10-20%
|
||||||
|
|
||||||
|
**CPU Usage (Hardware Encoding):**
|
||||||
|
- 1080p@30fps: 5-10%
|
||||||
|
- 1080p@60fps: 10-15%
|
||||||
|
- 4K@30fps: 15-25%
|
||||||
|
|
||||||
|
**Memory Usage:**
|
||||||
|
- Base: 150-200MB
|
||||||
|
- Per client: 50-100MB
|
||||||
|
- Buffer pool: 50-100MB
|
||||||
|
|
||||||
|
**Network Bandwidth:**
|
||||||
|
- 1080p@30fps (H.264): 2-8Mbps
|
||||||
|
- 1080p@60fps (H.264): 4-16Mbps
|
||||||
|
- 720p@30fps (H.264): 1-4Mbps
|
||||||
|
- VP9 typically 30-50% less bandwidth at similar quality
|
||||||
|
|
||||||
|
### Encoding Performance
|
||||||
|
|
||||||
|
**Recommended Settings by Use Case:**
|
||||||
|
|
||||||
|
| Use Case | Encoder | Resolution | FPS | Bitrate | Preset |
|
||||||
|
|----------|---------|------------|-----|---------|--------|
|
||||||
|
| Gaming (LAN) | h264_vaapi/nvenc | 1080p | 60 | 12-16Mbps | veryfast |
|
||||||
|
| Remote Desktop (Good WAN) | h264_vaapi | 1080p | 30 | 4-8Mbps | veryfast |
|
||||||
|
| Remote Desktop (Poor WAN) | h264_x264 | 720p | 24 | 1-2Mbps | ultrafast |
|
||||||
|
| Screen Sharing | h264_x264 | 1080p | 15-30 | 2-4Mbps | veryfast |
|
||||||
|
|
||||||
|
### Optimization Tips
|
||||||
|
|
||||||
|
1. **Use Hardware Encoders**: VA-API or NVENC significantly reduces CPU usage
|
||||||
|
2. **Match FPS to Content**: Dynamic content needs higher FPS than static screens
|
||||||
|
3. **Enable Damage Tracking**: Encode only changed regions (automatic)
|
||||||
|
4. **Optimize Bitrate**: Use adaptive bitrate based on network conditions
|
||||||
|
5. **Adjust Resolution**: Lower resolution for better performance on slow networks
|
||||||
|
|
||||||
|
### Benchmarks
|
||||||
|
|
||||||
|
Tested on a system with:
|
||||||
|
- CPU: Intel Core i7-12700K
|
||||||
|
- GPU: Intel UHD Graphics 770
|
||||||
|
- RAM: 32GB DDR5
|
||||||
|
- OS: Ubuntu 23.10 with Wayland
|
||||||
|
|
||||||
|
**1080p@30fps with h264_vaapi:**
|
||||||
|
- Latency: 18-22ms (LAN)
|
||||||
|
- CPU: 7-9%
|
||||||
|
- Memory: 250MB
|
||||||
|
- Bandwidth: 4-5Mbps
|
||||||
|
|
||||||
|
**1080p@60fps with h264_vaapi:**
|
||||||
|
- Latency: 15-19ms (LAN)
|
||||||
|
- CPU: 11-14%
|
||||||
|
- Memory: 280MB
|
||||||
|
- Bandwidth: 8-10Mbps
|
||||||
|
|
||||||
|
**720p@30fps with h264_x264:**
|
||||||
|
- Latency: 45-60ms (WAN)
|
||||||
|
- CPU: 18-22%
|
||||||
|
- Memory: 200MB
|
||||||
|
- Bandwidth: 2-2.5Mbps
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
#### "No PipeWire session found"
|
||||||
|
**Cause:** PipeWire is not running or not configured correctly.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Check if PipeWire is running
|
||||||
|
systemctl --user status pipewire pipewire-pulse pipewire-media-session
|
||||||
|
|
||||||
|
# Start PipeWire if not running
|
||||||
|
systemctl --user start pipewire pipewire-pulse pipewire-media-session
|
||||||
|
|
||||||
|
# Enable PipeWire to start on boot
|
||||||
|
systemctl --user enable pipewire pipewire-pulse pipewire-media-session
|
||||||
|
```
|
||||||
|
|
||||||
|
#### "Permission denied for screen capture"
|
||||||
|
**Cause:** PipeWire portal permission not granted.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Restart the wl-webrtc server
|
||||||
|
2. When prompted by the desktop portal, grant screen capture permission
|
||||||
|
3. Select the screen or window to share
|
||||||
|
4. If using Wayland, ensure your compositor supports screen sharing (KDE Plasma, GNOME, Sway)
|
||||||
|
|
||||||
|
#### "VA-API encoder not available"
|
||||||
|
**Cause:** VA-API hardware acceleration is not installed or not supported.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Install VA-API drivers
|
||||||
|
sudo apt install -y libva-dev libva-intel-driver vainfo
|
||||||
|
|
||||||
|
# Verify VA-API is available
|
||||||
|
vainfo
|
||||||
|
|
||||||
|
# If VA-API is unavailable, fall back to software encoder:
|
||||||
|
# In config.toml, set:
|
||||||
|
# encoder_type = "h264_x264"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### "NVENC encoder initialization failed"
|
||||||
|
**Cause:** NVIDIA drivers are not installed or GPU does not support NVENC.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Check NVIDIA driver version (must be ≥ 470)
|
||||||
|
nvidia-smi
|
||||||
|
|
||||||
|
# Verify NVENC support
|
||||||
|
nvidia-smi --query-gpu=encoder.version.encoder --format=csv
|
||||||
|
|
||||||
|
# If NVENC is unavailable, fall back to VA-API or x264:
|
||||||
|
# encoder_type = "h264_vaapi" # or "h264_x264"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### "High latency / laggy video"
|
||||||
|
**Cause:** Network conditions or encoder settings not optimized.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Reduce bitrate: `bitrate = 2000000` (2Mbps)
|
||||||
|
2. Reduce frame rate: `frame_rate = 24` or `frame_rate = 15`
|
||||||
|
3. Use faster preset: `preset = "ultrafast"`
|
||||||
|
4. Lower resolution: `width = 1280`, `height = 720`
|
||||||
|
5. Check network: `ping` the server to verify low latency
|
||||||
|
6. Use wired connection instead of WiFi
|
||||||
|
|
||||||
|
#### "WebRTC connection failed"
|
||||||
|
**Cause:** Network configuration or firewall blocking the connection.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Check if the server port is accessible
|
||||||
|
nc -zv localhost 8443
|
||||||
|
|
||||||
|
# Allow the port through firewall (UFW example)
|
||||||
|
sudo ufw allow 8443/tcp
|
||||||
|
|
||||||
|
# For remote connections, configure STUN/TURN:
|
||||||
|
# In config.toml:
|
||||||
|
# ice_servers = ["stun:stun.l.google.com:19302"]
|
||||||
|
#
|
||||||
|
# For networks blocking UDP, add TURN server:
|
||||||
|
# [[webrtc.turn_servers]]
|
||||||
|
# urls = ["turn:your-turn-server.com:3478?transport=udp"]
|
||||||
|
# username = "your-username"
|
||||||
|
# credential = "your-credential"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### "Out of memory"
|
||||||
|
**Cause:** Insufficient memory or buffer leak.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Reduce buffer pool size in config
|
||||||
|
2. Reduce video resolution
|
||||||
|
3. Reduce frame rate
|
||||||
|
4. Check for memory leaks: `top` or `htop` monitoring
|
||||||
|
5. Increase system RAM or close other applications
|
||||||
|
|
||||||
|
#### "CPU usage too high"
|
||||||
|
**Cause:** Software encoding is CPU-intensive.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Use hardware encoder: `encoder_type = "h264_vaapi"` or `"h264_nvenc"`
|
||||||
|
2. Use faster preset: `preset = "ultrafast"` or `"superfast"`
|
||||||
|
3. Reduce resolution: `width = 1280`, `height = 720`
|
||||||
|
4. Reduce frame rate: `frame_rate = 24` or `frame_rate = 15`
|
||||||
|
5. Reduce bitrate (saves encoding CPU)
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
|
||||||
|
Enable verbose logging for troubleshooting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set log level
|
||||||
|
RUST_LOG=debug ./target/release/wl-webrtc start
|
||||||
|
|
||||||
|
# For very verbose logging
|
||||||
|
RUST_LOG=trace ./target/release/wl-webrtc start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Profiling
|
||||||
|
|
||||||
|
To identify performance bottlenecks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Profile CPU usage
|
||||||
|
perf record -g ./target/release/wl-webrtc start
|
||||||
|
perf report
|
||||||
|
|
||||||
|
# Profile memory usage
|
||||||
|
valgrind --leak-check=full ./target/release/wl-webrtc start
|
||||||
|
|
||||||
|
# Monitor in real-time
|
||||||
|
top -p $(pgrep wl-webrtc)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting Help
|
||||||
|
|
||||||
|
If you encounter issues not covered here:
|
||||||
|
|
||||||
|
1. Check the [Issues](https://github.com/yourusername/wl-webrtc/issues) page
|
||||||
|
2. Review the [DESIGN_CN.md](DESIGN_CN.md) for technical details
|
||||||
|
3. Enable debug logging and collect output
|
||||||
|
4. Open a new issue with:
|
||||||
|
- System information (OS, Wayland compositor)
|
||||||
|
- Hardware information (CPU, GPU)
|
||||||
|
- Configuration file (sanitized)
|
||||||
|
- Error logs
|
||||||
|
- Steps to reproduce
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under either of:
|
||||||
|
- MIT License ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
|
||||||
|
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
- [PipeWire](https://pipewire.org/) for screen capture infrastructure
|
||||||
|
- [WebRTC-RS](https://webrtc.rs/) for WebRTC implementation
|
||||||
|
- [x264](https://www.videolan.org/developers/x264.html) for software H.264 encoding
|
||||||
|
- [VA-API](https://01.org/vaapi) for hardware encoding on Intel/AMD GPUs
|
||||||
|
- The Wayland community for the modern display protocol
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [Documentation](https://docs.rs/wl-webrtc) - API documentation (Rustdoc)
|
||||||
|
- [DESIGN_CN.md](DESIGN_CN.md) - Technical design document
|
||||||
|
- [config.toml.template](config.toml.template) - Configuration reference
|
||||||
|
- [examples/](examples/) - Example client applications
|
||||||
114
benches/benchmark.rs
Normal file
114
benches/benchmark.rs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
//! Benchmarks for wl-webrtc
|
||||||
|
//!
|
||||||
|
//! This module contains performance benchmarks using the Criterion library.
|
||||||
|
|
||||||
|
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||||
|
|
||||||
|
fn benchmark_config_parsing(c: &mut Criterion) {
|
||||||
|
let toml_str = r#"
|
||||||
|
[capture]
|
||||||
|
frame_rate = 60
|
||||||
|
quality = "high"
|
||||||
|
|
||||||
|
[encoder]
|
||||||
|
encoder_type = "h264_x264"
|
||||||
|
width = 1920
|
||||||
|
height = 1080
|
||||||
|
frame_rate = 60
|
||||||
|
bitrate = 8000000
|
||||||
|
max_bitrate = 10000000
|
||||||
|
min_bitrate = 500000
|
||||||
|
keyframe_interval = 30
|
||||||
|
|
||||||
|
[webrtc]
|
||||||
|
port = 9000
|
||||||
|
ice_servers = ["stun:stun.l.google.com:19302"]
|
||||||
|
"#;
|
||||||
|
|
||||||
|
c.bench_function("config parsing", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let _ = toml::from_str::<wl_webrtc::config::AppConfig>(black_box(toml_str));
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn benchmark_config_validation(c: &mut Criterion) {
|
||||||
|
let config = wl_webrtc::config::AppConfig::default();
|
||||||
|
|
||||||
|
c.bench_function("config validation", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let _ = black_box(&config).validate();
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn benchmark_config_serialization(c: &mut Criterion) {
|
||||||
|
let config = wl_webrtc::config::AppConfig::default();
|
||||||
|
|
||||||
|
c.bench_function("config serialization", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let _ = toml::to_string(black_box(&config));
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn benchmark_cli_parsing(c: &mut Criterion) {
|
||||||
|
let args = vec![
|
||||||
|
"wl-webrtc",
|
||||||
|
"--frame-rate",
|
||||||
|
"60",
|
||||||
|
"--width",
|
||||||
|
"1920",
|
||||||
|
"--height",
|
||||||
|
"1080",
|
||||||
|
];
|
||||||
|
|
||||||
|
c.bench_function("cli parsing", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let _ = wl_webrtc::config::Cli::try_parse_from(black_box(&args));
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn benchmark_config_merge(c: &mut Criterion) {
|
||||||
|
let mut config = wl_webrtc::config::AppConfig::default();
|
||||||
|
let overrides = wl_webrtc::config::ConfigOverrides {
|
||||||
|
frame_rate: Some(60),
|
||||||
|
width: Some(1280),
|
||||||
|
height: Some(720),
|
||||||
|
bitrate_mbps: None,
|
||||||
|
bitrate: Some(2_000_000),
|
||||||
|
port: Some(9000),
|
||||||
|
};
|
||||||
|
|
||||||
|
c.bench_function("config merge", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let mut cfg = config.clone();
|
||||||
|
cfg.merge_cli_overrides(black_box(&overrides));
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn benchmark_encoder_latency(c: &mut Criterion) {}
|
||||||
|
|
||||||
|
fn benchmark_capture_latency(c: &mut Criterion) {}
|
||||||
|
|
||||||
|
fn benchmark_webrtc_latency(c: &mut Criterion) {}
|
||||||
|
|
||||||
|
criterion_group!(
|
||||||
|
benches,
|
||||||
|
benchmark_config_parsing,
|
||||||
|
benchmark_config_validation,
|
||||||
|
benchmark_config_serialization,
|
||||||
|
benchmark_cli_parsing,
|
||||||
|
benchmark_config_merge
|
||||||
|
);
|
||||||
|
|
||||||
|
criterion_group!(
|
||||||
|
latency_benches,
|
||||||
|
benchmark_encoder_latency,
|
||||||
|
benchmark_capture_latency,
|
||||||
|
benchmark_webrtc_latency
|
||||||
|
);
|
||||||
|
|
||||||
|
criterion_main!(benches, latency_benches);
|
||||||
266
config.toml.template
Normal file
266
config.toml.template
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
# wl-webrtc Configuration Template
|
||||||
|
#
|
||||||
|
# This file serves as a template for configuring the wl-webrtc remote desktop backend.
|
||||||
|
# Copy this file to 'config.toml' and modify the values as needed.
|
||||||
|
#
|
||||||
|
# Values can be overridden using command-line arguments:
|
||||||
|
# wl-webrtc --frame-rate 60 --width 1280 --height 720 --bitrate-mbps 4 --port 9000 start
|
||||||
|
#
|
||||||
|
# For detailed documentation, see:
|
||||||
|
# README.md - Project documentation
|
||||||
|
# DESIGN_CN.md - Technical design and architecture
|
||||||
|
#
|
||||||
|
# Configuration Philosophy:
|
||||||
|
# - Optimize for your use case (LAN vs WAN, quality vs performance)
|
||||||
|
# - Start with conservative settings, then adjust based on performance
|
||||||
|
# - Hardware encoders (VA-API/NVENC) provide better performance than software
|
||||||
|
# - Lower latency often requires trade-offs in quality or resource usage
|
||||||
|
|
||||||
|
[capture]
|
||||||
|
# Capture settings
|
||||||
|
#
|
||||||
|
# frame_rate: Target frames per second for screen capture
|
||||||
|
# Range: 1-144
|
||||||
|
# Default: 30
|
||||||
|
# Higher values provide smoother video but require more resources
|
||||||
|
# Recommended values:
|
||||||
|
# 15-24: Acceptable for mostly static content, lower bandwidth
|
||||||
|
# 30-60: Good balance for general use
|
||||||
|
# 60-144: For gaming or highly dynamic content
|
||||||
|
# Note: Encoder frame_rate should match or be a divisor of this value
|
||||||
|
frame_rate = 30
|
||||||
|
|
||||||
|
# quality: Overall quality level for capture and encoding
|
||||||
|
# Options: low, medium, high, ultra
|
||||||
|
# Default: high
|
||||||
|
# Affects encoding parameters and resource usage
|
||||||
|
# This is a preset that adjusts multiple encoder parameters:
|
||||||
|
# low - Fastest encoding, lowest quality, minimal CPU/bandwidth
|
||||||
|
# medium - Good balance for most scenarios
|
||||||
|
# high - Better quality, moderate resource usage
|
||||||
|
# ultra - Best quality, higher resource usage
|
||||||
|
quality = "high"
|
||||||
|
|
||||||
|
# screen_region: Optional region of screen to capture
|
||||||
|
# Format: { x = 0, y = 0, width = 1920, height = 1080 }
|
||||||
|
# Default: null (entire screen)
|
||||||
|
# Comment out to capture full screen
|
||||||
|
# Capturing a smaller region significantly reduces bandwidth and CPU
|
||||||
|
# Example: Capture only a specific application window
|
||||||
|
# screen_region = { x = 0, y = 0, width = 1920, height = 1080 }
|
||||||
|
|
||||||
|
[encoder]
|
||||||
|
# Encoder settings
|
||||||
|
#
|
||||||
|
# encoder_type: Video encoder to use
|
||||||
|
# Options:
|
||||||
|
# h264_x264 - Software H.264 via x264 (best compatibility, higher CPU)
|
||||||
|
# h264_vaapi - Hardware H.264 via VA-API (Linux Intel/AMD GPU)
|
||||||
|
# h264_nvenc - Hardware H.264 via NVENC (NVIDIA GPU)
|
||||||
|
# vp9 - Software VP9 (better compression, higher CPU)
|
||||||
|
# Default: h264_x264
|
||||||
|
# Recommendation: Use hardware encoders (h264_vaapi or h264_nvenc) for lowest latency
|
||||||
|
# Zero-Copy Note: Hardware encoders can directly process DMA-BUF with no CPU copies
|
||||||
|
encoder_type = "h264_x264"
|
||||||
|
|
||||||
|
# width: Output video width
|
||||||
|
# Range: 320-7680
|
||||||
|
# Default: 1920
|
||||||
|
# Reducing width significantly lowers bandwidth and CPU usage
|
||||||
|
width = 1920
|
||||||
|
|
||||||
|
# height: Output video height
|
||||||
|
# Range: 240-4320
|
||||||
|
# Default: 1080
|
||||||
|
# Common resolutions: 1080, 720, 480
|
||||||
|
height = 1080
|
||||||
|
|
||||||
|
# frame_rate: Encoder frame rate
|
||||||
|
# Range: 1-144
|
||||||
|
# Default: 30
|
||||||
|
# Should match or be a divisor of capture frame rate
|
||||||
|
frame_rate = 30
|
||||||
|
|
||||||
|
# bitrate: Target video bitrate in bits per second
|
||||||
|
# Range: 100,000 to 50,000,000 (0.1 Mbps to 50 Mbps)
|
||||||
|
# Default: 4,000,000 (4 Mbps)
|
||||||
|
# Example values:
|
||||||
|
# 500,000 (0.5 Mbps) - Low quality, minimal bandwidth
|
||||||
|
# 2,000,000 (2 Mbps) - Good quality, moderate bandwidth
|
||||||
|
# 4,000,000 (4 Mbps) - High quality, typical bandwidth
|
||||||
|
# 8,000,000 (8 Mbps) - Very high quality, high bandwidth
|
||||||
|
bitrate = 4000000
|
||||||
|
|
||||||
|
# max_bitrate: Maximum allowed bitrate
|
||||||
|
# Range: 100,000 to 50,000,000
|
||||||
|
# Default: 8,000,000 (8 Mbps)
|
||||||
|
# Must be greater than min_bitrate
|
||||||
|
# Used for adaptive bitrate control
|
||||||
|
max_bitrate = 8000000
|
||||||
|
|
||||||
|
# min_bitrate: Minimum allowed bitrate
|
||||||
|
# Range: 100,000 to 50,000,000
|
||||||
|
# Default: 500,000 (0.5 Mbps)
|
||||||
|
# Must be less than max_bitrate
|
||||||
|
# Prevents quality degradation below this threshold
|
||||||
|
min_bitrate = 500000
|
||||||
|
|
||||||
|
# keyframe_interval: Keyframe (I-frame) interval in frames
|
||||||
|
# Range: 1-300
|
||||||
|
# Default: 60 (every 2 seconds at 30 fps)
|
||||||
|
# Smaller values increase keyframe frequency (better recovery, higher bitrate)
|
||||||
|
# Larger values reduce keyframe frequency (lower bitrate, slower recovery)
|
||||||
|
keyframe_interval = 60
|
||||||
|
|
||||||
|
# preset: Encoding speed vs quality tradeoff
|
||||||
|
# Options (fastest to slowest):
|
||||||
|
# ultrafast - Lowest latency, lowest quality (~10-15% CPU reduction vs veryfast)
|
||||||
|
# superfast - Very low latency, low quality (~5% CPU reduction vs veryfast)
|
||||||
|
# veryfast - Recommended for 15-25ms latency, good quality
|
||||||
|
# faster - Slightly better quality, slight latency increase
|
||||||
|
# fast - Good quality, moderate latency (30-40ms)
|
||||||
|
# medium - Balanced quality and latency (40-60ms)
|
||||||
|
# slow - Better quality, higher latency (60-80ms)
|
||||||
|
# slower - Good quality, high latency (80-120ms)
|
||||||
|
# veryslow - Highest quality, highest latency (120-200ms)
|
||||||
|
# Default: veryfast
|
||||||
|
# Latency Impact: Each step slower adds ~5-15ms to end-to-end latency
|
||||||
|
# Quality Impact: Each step slower improves compression by ~5-10%
|
||||||
|
# Recommendation: Use ultrafast/superfast for gaming, veryfast for general use
|
||||||
|
preset = "veryfast"
|
||||||
|
|
||||||
|
# tune: Encoder tuning parameter
|
||||||
|
# Options:
|
||||||
|
# zerolatency - Optimize for minimal latency (recommended)
|
||||||
|
# film - Optimize for film content
|
||||||
|
# animation - Optimize for animation
|
||||||
|
# stillimage - Optimize for static images
|
||||||
|
# Default: zerolatency
|
||||||
|
tune = "zerolatency"
|
||||||
|
|
||||||
|
[webrtc]
|
||||||
|
# WebRTC transport settings
|
||||||
|
#
|
||||||
|
# port: Server listening port
|
||||||
|
# Range: 1-65535
|
||||||
|
# Default: 8443
|
||||||
|
# Ensure port is not blocked by firewall
|
||||||
|
# Note: This port needs to be accessible from client browsers
|
||||||
|
# For HTTPS/WSS, consider using 443 or 8443
|
||||||
|
port = 8443
|
||||||
|
|
||||||
|
# ice_servers: ICE servers for NAT traversal
|
||||||
|
# Format: Array of STUN/TURN server URLs
|
||||||
|
# Default: ["stun:stun.l.google.com:19302"]
|
||||||
|
# Google's public STUN server works for most scenarios
|
||||||
|
# For production, consider using your own STUN/TURN servers
|
||||||
|
ice_servers = [
|
||||||
|
"stun:stun.l.google.com:19302"
|
||||||
|
]
|
||||||
|
|
||||||
|
# stun_servers: STUN servers specifically
|
||||||
|
# Format: Array of STUN server URLs
|
||||||
|
# Default: ["stun:stun.l.google.com:19302"]
|
||||||
|
stun_servers = [
|
||||||
|
"stun:stun.l.google.com:19302"
|
||||||
|
]
|
||||||
|
|
||||||
|
# turn_servers: TURN servers for relay
|
||||||
|
# Required for networks that block direct UDP connections
|
||||||
|
# Format: Array of TURN server configurations
|
||||||
|
# Default: [] (no TURN servers)
|
||||||
|
#
|
||||||
|
# Example TURN configuration (uncomment to use):
|
||||||
|
# [[webrtc.turn_servers]]
|
||||||
|
# urls = ["turn:your-turn-server.com:3478?transport=udp"]
|
||||||
|
# username = "your-username"
|
||||||
|
# credential = "your-credential"
|
||||||
|
|
||||||
|
# Network condition recommendations:
|
||||||
|
#
|
||||||
|
# Local network (LAN):
|
||||||
|
# - bitrate: 8-16 Mbps
|
||||||
|
# - frame_rate: 60
|
||||||
|
# - preset: veryfast
|
||||||
|
# - Expected latency: 15-25ms
|
||||||
|
#
|
||||||
|
# Remote network (good connection):
|
||||||
|
# - bitrate: 2-4 Mbps
|
||||||
|
# - frame_rate: 30
|
||||||
|
# - preset: veryfast
|
||||||
|
# - Expected latency: 50-100ms
|
||||||
|
#
|
||||||
|
# Remote network (poor connection):
|
||||||
|
# - bitrate: 0.5-1 Mbps
|
||||||
|
# - frame_rate: 15-24
|
||||||
|
# - preset: ultrafast
|
||||||
|
# - Expected latency: 100-200ms
|
||||||
|
#
|
||||||
|
# Low CPU requirements:
|
||||||
|
# - encoder_type: h264_vaapi or h264_nvenc (hardware)
|
||||||
|
# - preset: ultrafast or superfast
|
||||||
|
# - Reduce resolution (e.g., 1280x720)
|
||||||
|
# - Reduce frame rate (e.g., 15-24)
|
||||||
|
|
||||||
|
# Hardware encoder selection guide:
|
||||||
|
#
|
||||||
|
# Intel/AMD GPU (Linux):
|
||||||
|
# - encoder_type: h264_vaapi
|
||||||
|
# - Requires: libva, vainfo, hardware with VAAPI support
|
||||||
|
#
|
||||||
|
# NVIDIA GPU:
|
||||||
|
# - encoder_type: h264_nvenc
|
||||||
|
# - Requires: NVIDIA drivers, NVENC-capable GPU
|
||||||
|
#
|
||||||
|
# Software (fallback):
|
||||||
|
# - encoder_type: h264_x264
|
||||||
|
# - Works on any system but higher CPU usage
|
||||||
|
#
|
||||||
|
# Additional Performance Tuning:
|
||||||
|
#
|
||||||
|
# Zero-Copy Optimization (automatic):
|
||||||
|
# - DMA-BUF frames are passed directly to hardware encoders
|
||||||
|
# - No CPU memory copies from capture to encode
|
||||||
|
# - Requires: Hardware encoder (h264_vaapi or h264_nvenc)
|
||||||
|
# - Benefit: 10-15ms reduction in latency
|
||||||
|
#
|
||||||
|
# Buffer Pool Sizing:
|
||||||
|
# - Too small: Frame drops, stutters
|
||||||
|
# - Too large: Increased latency, higher memory usage
|
||||||
|
# - Recommendation: 3-5 buffers for 30fps, 5-8 buffers for 60fps
|
||||||
|
#
|
||||||
|
# Jitter Buffer:
|
||||||
|
# - Compensates for network latency variations
|
||||||
|
# - Default is optimized for low-latency (10-20ms)
|
||||||
|
# - Increase on unstable connections: 20-50ms
|
||||||
|
# - Reduce on stable connections: 5-10ms
|
||||||
|
#
|
||||||
|
# Packet Size Tuning:
|
||||||
|
# - Smaller packets (1200 bytes): Lower latency, higher overhead
|
||||||
|
# - Larger packets (1400 bytes): Lower overhead, slight latency increase
|
||||||
|
# - Default: 1200 bytes for optimal latency
|
||||||
|
#
|
||||||
|
# CPU Core Assignment:
|
||||||
|
# - For best performance, dedicate cores to encoder:
|
||||||
|
# taskset -c 2,3 ./target/release/wl-webrtc start
|
||||||
|
# - Pinning reduces context switching overhead
|
||||||
|
#
|
||||||
|
# GPU Priority:
|
||||||
|
# - Increase GPU priority for VA-API/NVENC:
|
||||||
|
# sudo cpupower frequency-set -g performance
|
||||||
|
# - Ensures encoder gets sufficient GPU resources
|
||||||
|
#
|
||||||
|
# Monitoring:
|
||||||
|
# - Enable metrics collection to track performance:
|
||||||
|
# RUST_LOG=info ./target/release/wl-webrtc start
|
||||||
|
# - Watch for:
|
||||||
|
# - Capture drops (indicates CPU bottleneck)
|
||||||
|
# - Encode queue depth (should stay < 3)
|
||||||
|
# - Network jitter (indicates network issues)
|
||||||
|
#
|
||||||
|
# Troubleshooting:
|
||||||
|
# - High CPU: Use hardware encoder, reduce resolution/fps
|
||||||
|
# - High latency: Check preset, frame rate, network conditions
|
||||||
|
# - Poor quality: Increase bitrate, use slower preset
|
||||||
|
# - Stuttering: Reduce frame rate, check network bandwidth
|
||||||
|
|
||||||
182
examples/README.md
Normal file
182
examples/README.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# wl-webrtc Examples
|
||||||
|
|
||||||
|
This directory contains example client applications for connecting to wl-webrtc servers.
|
||||||
|
|
||||||
|
## client.html
|
||||||
|
|
||||||
|
A simple web-based client that demonstrates how to connect to a wl-webrtc server using WebRTC.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
1. **Start the wl-webrtc server:**
|
||||||
|
```bash
|
||||||
|
./target/release/wl-webrtc start
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Open the client in a web browser:**
|
||||||
|
- Directly open `examples/client.html` in a browser
|
||||||
|
- Or serve it with a local web server:
|
||||||
|
```bash
|
||||||
|
# Python 3
|
||||||
|
python -m http.server 8000
|
||||||
|
# Then navigate to http://localhost:8000/examples/client.html
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Connect to the server:**
|
||||||
|
- Enter the server URL (default: `ws://localhost:8443`)
|
||||||
|
- Click "Connect"
|
||||||
|
- Grant screen capture permissions when prompted by PipeWire
|
||||||
|
- The video stream will appear in the browser
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **WebRTC Connection**: Real-time video streaming via WebRTC
|
||||||
|
- **Status Monitoring**: Display connection status, bitrate, resolution, FPS, and latency
|
||||||
|
- **Fullscreen Mode**: Press `Alt+F` or click "Fullscreen" button
|
||||||
|
- **Error Handling**: Displays connection errors with helpful messages
|
||||||
|
- **Responsive Design**: Works on desktop and mobile browsers
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The client connects to a wl-webrtc server via WebSocket. By default, it connects to:
|
||||||
|
- **Server URL**: `ws://localhost:8443`
|
||||||
|
|
||||||
|
To connect to a remote server:
|
||||||
|
- Change the server URL in the input field
|
||||||
|
- Ensure the server is accessible (firewall, NAT, etc.)
|
||||||
|
- Configure STUN/TURN servers in the wl-webrtc config if needed
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
|
||||||
|
This is a minimal example client. Production clients should include:
|
||||||
|
- Authentication and authorization
|
||||||
|
- Secure HTTPS/WSS connections
|
||||||
|
- Input event forwarding (mouse, keyboard)
|
||||||
|
- Clipboard sharing
|
||||||
|
- Audio support
|
||||||
|
- Multiple screen selection
|
||||||
|
- Connection quality indicators
|
||||||
|
- Automatic reconnection
|
||||||
|
|
||||||
|
### Browser Compatibility
|
||||||
|
|
||||||
|
Tested on modern browsers with WebRTC support:
|
||||||
|
- Chrome 88+
|
||||||
|
- Firefox 85+
|
||||||
|
- Safari 15+
|
||||||
|
- Edge 88+
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
**Connection fails:**
|
||||||
|
- Verify wl-webrtc server is running
|
||||||
|
- Check server URL is correct
|
||||||
|
- Check firewall settings
|
||||||
|
- Review browser console for errors
|
||||||
|
|
||||||
|
**No video appears:**
|
||||||
|
- Grant screen capture permissions
|
||||||
|
- Check if PipeWire is running
|
||||||
|
- Verify encoder is configured correctly
|
||||||
|
- Check server logs for errors
|
||||||
|
|
||||||
|
**Poor quality:**
|
||||||
|
- Increase bitrate in server config
|
||||||
|
- Use hardware encoder (h264_vaapi or h264_nvenc)
|
||||||
|
- Check network bandwidth
|
||||||
|
- Reduce resolution or frame rate
|
||||||
|
|
||||||
|
**High latency:**
|
||||||
|
- Check network ping to server
|
||||||
|
- Use wired connection instead of WiFi
|
||||||
|
- Reduce frame rate in server config
|
||||||
|
- Use faster preset (ultrafast)
|
||||||
|
- Ensure hardware encoder is being used
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Custom Client Implementation
|
||||||
|
|
||||||
|
To build your own client:
|
||||||
|
|
||||||
|
1. **Connect via WebSocket** to the signaling server
|
||||||
|
2. **Create WebRTC PeerConnection** with ICE servers
|
||||||
|
3. **Create Offer** and send to server via WebSocket
|
||||||
|
4. **Receive Answer** and set as remote description
|
||||||
|
5. **Exchange ICE candidates** with server
|
||||||
|
6. **Receive video track** and display in HTML video element
|
||||||
|
|
||||||
|
Example signaling flow:
|
||||||
|
```
|
||||||
|
Client Server
|
||||||
|
| |
|
||||||
|
|-------- WebSocket ----->|
|
||||||
|
| |
|
||||||
|
|------- Offer --------->|
|
||||||
|
|<------ Answer ---------|
|
||||||
|
| |
|
||||||
|
|--- ICE Candidate ----->|
|
||||||
|
|<--- ICE Candidate -----|
|
||||||
|
| |
|
||||||
|
|<---- Video Stream -----|
|
||||||
|
| |
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Channel Usage
|
||||||
|
|
||||||
|
The server supports WebRTC data channels for bi-directional messaging:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Client side
|
||||||
|
const dataChannel = peerConnection.createDataChannel('control');
|
||||||
|
|
||||||
|
dataChannel.onopen = () => {
|
||||||
|
console.log('Data channel opened');
|
||||||
|
};
|
||||||
|
|
||||||
|
dataChannel.onmessage = (event) => {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
console.log('Received:', message);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send control messages
|
||||||
|
dataChannel.send(JSON.stringify({
|
||||||
|
type: 'mouse_move',
|
||||||
|
x: 100,
|
||||||
|
y: 200
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server-Side Events
|
||||||
|
|
||||||
|
The server sends events over the data channel:
|
||||||
|
|
||||||
|
- `connection_established`: Connection is ready
|
||||||
|
- `connection_failed`: Connection failed
|
||||||
|
- `stats_update`: Performance statistics
|
||||||
|
- `error`: Error occurred
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
For production use:
|
||||||
|
|
||||||
|
1. **Use HTTPS/WSS** instead of HTTP/WS
|
||||||
|
2. **Implement authentication** (tokens, certificates)
|
||||||
|
3. **Validate all input** from clients
|
||||||
|
4. **Rate limit** connections
|
||||||
|
5. **Monitor for abuse**
|
||||||
|
6. **Keep dependencies updated**
|
||||||
|
7. **Use firewall rules** to restrict access
|
||||||
|
8. **Enable TLS** for encryption
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [wl-webrtc README](../README.md) - Main project documentation
|
||||||
|
- [DESIGN_CN.md](../DESIGN_CN.md) - Technical design and architecture
|
||||||
|
- [config.toml.template](../config.toml.template) - Server configuration reference
|
||||||
|
- [WebRTC API](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API) - Browser WebRTC documentation
|
||||||
|
- [webrtc-rs](https://webrtc.rs/) - WebRTC implementation in Rust
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions and improvements to the examples are welcome! Please follow the main project's [CONTRIBUTING.md](../CONTRIBUTING.md) guidelines.
|
||||||
600
examples/client.html
Normal file
600
examples/client.html
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>wl-webrtc - Remote Desktop Client</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: #2d2d2d;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #404040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.disconnected {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.connecting {
|
||||||
|
background: #ea580c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.connected {
|
||||||
|
background: #16a34a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container {
|
||||||
|
background: #0a0a0a;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#remoteVideo {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
margin-top: 2rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #6b7280;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-info {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item .label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item .value {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-form {
|
||||||
|
background: #2d2d2d;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-form h2 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>wl-webrtc</h1>
|
||||||
|
<div class="status">
|
||||||
|
<span id="connectionStatus" class="status-badge disconnected">Disconnected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<div id="errorMessage" class="error-message"></div>
|
||||||
|
|
||||||
|
<div id="configForm" class="config-form">
|
||||||
|
<h2>Connect to Server</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="serverUrl">Server URL</label>
|
||||||
|
<input type="text" id="serverUrl" value="ws://localhost:8443" placeholder="ws://localhost:8443">
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<button id="connectBtn" class="btn-primary">Connect</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="videoContainer" class="video-container hidden">
|
||||||
|
<video id="remoteVideo" autoplay playsinline></video>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="connectionInfo" class="connection-info hidden">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="label">Bitrate</div>
|
||||||
|
<div id="bitrate" class="value">0 Kbps</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="label">Resolution</div>
|
||||||
|
<div id="resolution" class="value">0x0</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="label">FPS</div>
|
||||||
|
<div id="fps" class="value">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="label">Latency</div>
|
||||||
|
<div id="latency" class="value">0 ms</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="controls" class="controls hidden">
|
||||||
|
<button id="disconnectBtn" class="btn-danger">Disconnect</button>
|
||||||
|
<button id="fullscreenBtn" class="btn-secondary">Fullscreen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// State
|
||||||
|
let peerConnection = null;
|
||||||
|
let dataChannel = null;
|
||||||
|
let ws = null;
|
||||||
|
let statsInterval = null;
|
||||||
|
let frameCount = 0;
|
||||||
|
let lastFrameTime = Date.now();
|
||||||
|
let rttInterval = null;
|
||||||
|
let rttSamples = [];
|
||||||
|
|
||||||
|
// DOM Elements
|
||||||
|
const connectBtn = document.getElementById('connectBtn');
|
||||||
|
const disconnectBtn = document.getElementById('disconnectBtn');
|
||||||
|
const fullscreenBtn = document.getElementById('fullscreenBtn');
|
||||||
|
const serverUrlInput = document.getElementById('serverUrl');
|
||||||
|
const connectionStatus = document.getElementById('connectionStatus');
|
||||||
|
const remoteVideo = document.getElementById('remoteVideo');
|
||||||
|
const configForm = document.getElementById('configForm');
|
||||||
|
const videoContainer = document.getElementById('videoContainer');
|
||||||
|
const connectionInfo = document.getElementById('connectionInfo');
|
||||||
|
const controls = document.getElementById('controls');
|
||||||
|
const errorMessage = document.getElementById('errorMessage');
|
||||||
|
|
||||||
|
// Stats Elements
|
||||||
|
const bitrateEl = document.getElementById('bitrate');
|
||||||
|
const resolutionEl = document.getElementById('resolution');
|
||||||
|
const fpsEl = document.getElementById('fps');
|
||||||
|
const latencyEl = document.getElementById('latency');
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const config = {
|
||||||
|
iceServers: [
|
||||||
|
{ urls: 'stun:stun.l.google.com:19302' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update connection status
|
||||||
|
function updateStatus(status) {
|
||||||
|
connectionStatus.className = `status-badge ${status}`;
|
||||||
|
connectionStatus.textContent = status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error message
|
||||||
|
function showError(message) {
|
||||||
|
errorMessage.textContent = message;
|
||||||
|
errorMessage.classList.add('visible');
|
||||||
|
setTimeout(() => {
|
||||||
|
errorMessage.classList.remove('visible');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create WebRTC peer connection
|
||||||
|
function createPeerConnection() {
|
||||||
|
peerConnection = new RTCPeerConnection(config);
|
||||||
|
|
||||||
|
// Handle ICE candidates
|
||||||
|
peerConnection.onicecandidate = (event) => {
|
||||||
|
if (event.candidate) {
|
||||||
|
sendJson({
|
||||||
|
type: 'ice-candidate',
|
||||||
|
candidate: event.candidate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle connection state changes
|
||||||
|
peerConnection.onconnectionstatechange = () => {
|
||||||
|
console.log('Connection state:', peerConnection.connectionState);
|
||||||
|
switch (peerConnection.connectionState) {
|
||||||
|
case 'connected':
|
||||||
|
updateStatus('connected');
|
||||||
|
startStatsMonitoring();
|
||||||
|
break;
|
||||||
|
case 'disconnected':
|
||||||
|
updateStatus('connecting');
|
||||||
|
break;
|
||||||
|
case 'failed':
|
||||||
|
updateStatus('disconnected');
|
||||||
|
showError('Connection failed');
|
||||||
|
cleanup();
|
||||||
|
break;
|
||||||
|
case 'closed':
|
||||||
|
updateStatus('disconnected');
|
||||||
|
cleanup();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle incoming tracks
|
||||||
|
peerConnection.ontrack = (event) => {
|
||||||
|
console.log('Received track:', event.track.kind);
|
||||||
|
if (event.track.kind === 'video') {
|
||||||
|
remoteVideo.srcObject = event.streams[0];
|
||||||
|
videoContainer.classList.remove('hidden');
|
||||||
|
connectionInfo.classList.remove('hidden');
|
||||||
|
controls.classList.remove('hidden');
|
||||||
|
configForm.classList.add('hidden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create data channel for control messages
|
||||||
|
dataChannel = peerConnection.createDataChannel('control');
|
||||||
|
dataChannel.onopen = () => {
|
||||||
|
console.log('Data channel opened');
|
||||||
|
};
|
||||||
|
dataChannel.onmessage = (event) => {
|
||||||
|
console.log('Data channel message:', event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return peerConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send JSON over WebSocket
|
||||||
|
function sendJson(data) {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to server
|
||||||
|
async function connect() {
|
||||||
|
const url = serverUrlInput.value;
|
||||||
|
console.log('Connecting to:', url);
|
||||||
|
|
||||||
|
updateStatus('connecting');
|
||||||
|
connectBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create WebSocket connection
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
|
||||||
|
ws.onopen = async () => {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
|
||||||
|
// Create peer connection
|
||||||
|
createPeerConnection();
|
||||||
|
|
||||||
|
// Create offer
|
||||||
|
const offer = await peerConnection.createOffer();
|
||||||
|
await peerConnection.setLocalDescription(offer);
|
||||||
|
|
||||||
|
// Send offer to server
|
||||||
|
sendJson({
|
||||||
|
type: 'offer',
|
||||||
|
sdp: offer
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = async (event) => {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
console.log('Received:', message);
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'answer':
|
||||||
|
await peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ice-candidate':
|
||||||
|
await peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
showError(message.error);
|
||||||
|
updateStatus('disconnected');
|
||||||
|
cleanup();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
showError('WebSocket error. Check server is running.');
|
||||||
|
updateStatus('disconnected');
|
||||||
|
connectBtn.disabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket closed');
|
||||||
|
if (connectionStatus.classList.contains('connecting')) {
|
||||||
|
updateStatus('disconnected');
|
||||||
|
showError('Server closed the connection');
|
||||||
|
}
|
||||||
|
connectBtn.disabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Connection error:', error);
|
||||||
|
showError('Failed to connect: ' + error.message);
|
||||||
|
updateStatus('disconnected');
|
||||||
|
connectBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect from server
|
||||||
|
function disconnect() {
|
||||||
|
console.log('Disconnecting...');
|
||||||
|
cleanup();
|
||||||
|
configForm.classList.remove('hidden');
|
||||||
|
videoContainer.classList.add('hidden');
|
||||||
|
connectionInfo.classList.add('hidden');
|
||||||
|
controls.classList.add('hidden');
|
||||||
|
remoteVideo.srcObject = null;
|
||||||
|
connectBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup resources
|
||||||
|
function cleanup() {
|
||||||
|
if (statsInterval) {
|
||||||
|
clearInterval(statsInterval);
|
||||||
|
statsInterval = null;
|
||||||
|
}
|
||||||
|
if (rttInterval) {
|
||||||
|
clearInterval(rttInterval);
|
||||||
|
rttInterval = null;
|
||||||
|
}
|
||||||
|
if (dataChannel) {
|
||||||
|
dataChannel.close();
|
||||||
|
dataChannel = null;
|
||||||
|
}
|
||||||
|
if (peerConnection) {
|
||||||
|
peerConnection.close();
|
||||||
|
peerConnection = null;
|
||||||
|
}
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
rttSamples = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start statistics monitoring
|
||||||
|
function startStatsMonitoring() {
|
||||||
|
statsInterval = setInterval(async () => {
|
||||||
|
if (!peerConnection || peerConnection.connectionState !== 'connected') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await peerConnection.getStats();
|
||||||
|
let bitrate = 0;
|
||||||
|
let width = 0;
|
||||||
|
let height = 0;
|
||||||
|
|
||||||
|
stats.forEach(report => {
|
||||||
|
if (report.type === 'inbound-rtp' && report.mediaType === 'video') {
|
||||||
|
// Calculate bitrate
|
||||||
|
if (report.bytesReceived && report.bytesReceivedLast) {
|
||||||
|
const bytesPerSec = report.bytesReceived - report.bytesReceivedLast;
|
||||||
|
bitrate = (bytesPerSec * 8) / 1000; // kbps
|
||||||
|
}
|
||||||
|
report.bytesReceivedLast = report.bytesReceived;
|
||||||
|
|
||||||
|
// Resolution
|
||||||
|
width = report.frameWidth || 0;
|
||||||
|
height = report.frameHeight || 0;
|
||||||
|
|
||||||
|
// FPS
|
||||||
|
if (report.framesReceived && report.framesReceivedLast) {
|
||||||
|
const framesPerSec = report.framesReceived - report.framesReceivedLast;
|
||||||
|
fpsEl.textContent = framesPerSec.toFixed(1);
|
||||||
|
}
|
||||||
|
report.framesReceivedLast = report.framesReceived;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.type === 'remote-candidate' && report.currentRoundTripTime) {
|
||||||
|
rttSamples.push(report.currentRoundTripTime * 1000); // Convert to ms
|
||||||
|
if (rttSamples.length > 10) rttSamples.shift();
|
||||||
|
const avgRtt = rttSamples.reduce((a, b) => a + b, 0) / rttSamples.length;
|
||||||
|
latencyEl.textContent = avgRtt.toFixed(0) + ' ms';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bitrateEl.textContent = bitrate.toFixed(0) + ' Kbps';
|
||||||
|
resolutionEl.textContent = `${width}x${height}`;
|
||||||
|
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle fullscreen
|
||||||
|
function toggleFullscreen() {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
remoteVideo.requestFullscreen().catch(err => {
|
||||||
|
showError(`Fullscreen error: ${err.message}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
connectBtn.addEventListener('click', connect);
|
||||||
|
disconnectBtn.addEventListener('click', disconnect);
|
||||||
|
fullscreenBtn.addEventListener('click', toggleFullscreen);
|
||||||
|
|
||||||
|
// Handle window close
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'f' && event.altKey) {
|
||||||
|
toggleFullscreen();
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape' && document.fullscreenElement) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update video stats on frame
|
||||||
|
remoteVideo.addEventListener('play', () => {
|
||||||
|
frameCount = 0;
|
||||||
|
lastFrameTime = Date.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
remoteVideo.addEventListener('timeupdate', () => {
|
||||||
|
frameCount++;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
686
src/buffer/mod.rs
Normal file
686
src/buffer/mod.rs
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
//! Zero-copy buffer management for WebRTC streaming
|
||||||
|
//!
|
||||||
|
//! This module provides types for managing frame buffers with zero-copy semantics:
|
||||||
|
//! - DMA-BUF buffers for GPU memory
|
||||||
|
//! - Encoded frame buffers with reference counting
|
||||||
|
//! - Unified buffer pool interface
|
||||||
|
//!
|
||||||
|
//! # Safety
|
||||||
|
//! All types implement RAII patterns to prevent resource leaks. DMA-BUF file descriptors
|
||||||
|
//! are automatically closed when handles go out of scope.
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use libc;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::marker::PhantomData;
|
||||||
|
use std::os::unix::io::{AsRawFd, FromRawFd, IntoRawFd, RawFd};
|
||||||
|
|
||||||
|
/// Safe wrapper for DMA-BUF file descriptor with RAII cleanup
|
||||||
|
///
|
||||||
|
/// This type wraps a raw file descriptor to a DMA-BUF (Direct Memory Access Buffer),
|
||||||
|
/// which represents GPU memory that can be shared between processes without copying.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// The file descriptor is automatically closed via `libc::close()` when this handle
|
||||||
|
/// is dropped, preventing resource leaks. All operations on the underlying file descriptor
|
||||||
|
/// are encapsulated within this type.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DmaBufHandle {
|
||||||
|
fd: RawFd,
|
||||||
|
size: usize,
|
||||||
|
stride: usize,
|
||||||
|
offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DmaBufHandle {
|
||||||
|
/// Create a new DMA-BUF handle from a file descriptor.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// The caller must ensure that `fd` is a valid file descriptor for a DMA-BUF.
|
||||||
|
/// This handle will take ownership of the file descriptor and close it when dropped.
|
||||||
|
pub fn new(fd: RawFd, size: usize, stride: usize, offset: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
fd,
|
||||||
|
size,
|
||||||
|
stride,
|
||||||
|
offset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the file descriptor for this DMA-BUF.
|
||||||
|
pub fn fd(&self) -> RawFd {
|
||||||
|
self.fd
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the size of the DMA-BUF in bytes.
|
||||||
|
pub fn size(&self) -> usize {
|
||||||
|
self.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the stride (bytes per row) of the DMA-BUF.
|
||||||
|
pub fn stride(&self) -> usize {
|
||||||
|
self.stride
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the offset to the first pixel in bytes.
|
||||||
|
pub fn offset(&self) -> usize {
|
||||||
|
self.offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for DmaBufHandle {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
libc::close(self.fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DMA-BUF pool for managing GPU memory buffers
|
||||||
|
///
|
||||||
|
/// Manages a pool of DMA-BUF handles to avoid frequent allocation/deallocation
|
||||||
|
/// of GPU memory buffers. Used for zero-copy transfer between camera capture
|
||||||
|
/// and hardware encoders.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DmaBufPool {
|
||||||
|
buffers: VecDeque<DmaBufHandle>,
|
||||||
|
max_size: usize,
|
||||||
|
current_size: usize,
|
||||||
|
acquired_count: usize, // Track currently allocated buffers
|
||||||
|
released_count: usize, // Track released buffers
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DmaBufPool {
|
||||||
|
/// Create a new DMA-BUF pool with the specified maximum size.
|
||||||
|
///
|
||||||
|
/// The pool will maintain up to `max_size` buffers. When acquiring buffers,
|
||||||
|
/// the pool first tries to reuse existing buffers, then allocates new ones
|
||||||
|
/// up to `max_size`. When releasing buffers, they are returned to the pool
|
||||||
|
/// if there is space, otherwise they are dropped and cleaned up.
|
||||||
|
pub fn new(max_size: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
buffers: VecDeque::with_capacity(max_size),
|
||||||
|
max_size,
|
||||||
|
current_size: 0,
|
||||||
|
acquired_count: 0,
|
||||||
|
released_count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquire a DMA-BUF from the pool.
|
||||||
|
///
|
||||||
|
/// Returns `Some(DmaBufHandle)` if a buffer is available (either from the pool
|
||||||
|
/// or newly allocated), `None` if the pool is at maximum capacity and no buffers
|
||||||
|
/// are available.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```
|
||||||
|
/// let mut pool = DmaBufPool::new(4);
|
||||||
|
/// if let Some(buf) = pool.acquire_dma_buf() {
|
||||||
|
/// // Use the buffer
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub fn acquire_dma_buf(
|
||||||
|
&mut self,
|
||||||
|
size: usize,
|
||||||
|
stride: usize,
|
||||||
|
offset: usize,
|
||||||
|
) -> Option<DmaBufHandle> {
|
||||||
|
// Try to reuse an existing buffer from the pool
|
||||||
|
if let Some(mut handle) = self.buffers.pop_front() {
|
||||||
|
// Check if the buffer size matches requirements
|
||||||
|
if handle.size() >= size && handle.stride() >= stride {
|
||||||
|
self.acquired_count += 1;
|
||||||
|
return Some(handle);
|
||||||
|
}
|
||||||
|
// Buffer doesn't match requirements, drop it and continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate a new buffer if we haven't reached max size
|
||||||
|
if self.current_size < self.max_size {
|
||||||
|
// In a real implementation, this would allocate a DMA-BUF via PipeWire/VAAPI
|
||||||
|
// For now, we use a mock file descriptor
|
||||||
|
let fd = unsafe { libc::dup(1) }; // Mock: duplicate stdout fd
|
||||||
|
if fd >= 0 {
|
||||||
|
self.current_size += 1;
|
||||||
|
self.acquired_count += 1;
|
||||||
|
return Some(DmaBufHandle::new(fd, size, stride, offset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release a DMA-BUF back to the pool.
|
||||||
|
///
|
||||||
|
/// The buffer is returned to the pool for reuse if there is space.
|
||||||
|
/// Otherwise, it is dropped and cleaned up via the RAII pattern.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```
|
||||||
|
/// let mut pool = DmaBufPool::new(4);
|
||||||
|
/// if let Some(buf) = pool.acquire_dma_buf(1024, 1024, 0) {
|
||||||
|
/// pool.release_dma_buf(buf);
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub fn release_dma_buf(&mut self, handle: DmaBufHandle) {
|
||||||
|
self.released_count += 1;
|
||||||
|
|
||||||
|
// Return to pool if there's space
|
||||||
|
if self.buffers.len() < self.max_size {
|
||||||
|
self.buffers.push_back(handle);
|
||||||
|
}
|
||||||
|
// Otherwise, the buffer is dropped and cleaned up via Drop trait
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current number of buffers in the pool (idle, available for reuse).
|
||||||
|
pub fn pool_size(&self) -> usize {
|
||||||
|
self.buffers.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the total number of buffers currently allocated (in pool + in use).
|
||||||
|
pub fn total_size(&self) -> usize {
|
||||||
|
self.current_size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the maximum pool capacity.
|
||||||
|
pub fn max_capacity(&self) -> usize {
|
||||||
|
self.max_size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of buffers acquired from the pool (lifetime counter).
|
||||||
|
pub fn acquired_count(&self) -> usize {
|
||||||
|
self.acquired_count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of buffers released to the pool (lifetime counter).
|
||||||
|
pub fn released_count(&self) -> usize {
|
||||||
|
self.released_count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for buffer leaks.
|
||||||
|
///
|
||||||
|
/// Returns `true` if there are potential buffer leaks (more acquired than released).
|
||||||
|
pub fn has_leaks(&self) -> bool {
|
||||||
|
self.acquired_count > self.released_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encoded frame buffer pool for managing encoded data
|
||||||
|
///
|
||||||
|
/// Manages a pool of reference-counted byte buffers for encoded video frames.
|
||||||
|
/// Uses `bytes::Bytes` for efficient reference counting and zero-copy slicing.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EncodedBufferPool {
|
||||||
|
buffers: VecDeque<Bytes>,
|
||||||
|
max_size: usize,
|
||||||
|
current_size: usize,
|
||||||
|
acquired_count: usize,
|
||||||
|
released_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EncodedBufferPool {
|
||||||
|
/// Create a new encoded buffer pool with the specified maximum size.
|
||||||
|
///
|
||||||
|
/// The pool maintains up to `max_size` buffers for reuse. When acquiring buffers,
|
||||||
|
/// the pool first tries to reuse existing buffers with sufficient capacity,
|
||||||
|
/// then allocates new ones up to `max_size`. When releasing buffers, they are
|
||||||
|
/// returned to the pool if there is space, otherwise dropped.
|
||||||
|
pub fn new(max_size: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
buffers: VecDeque::with_capacity(max_size),
|
||||||
|
max_size,
|
||||||
|
current_size: 0,
|
||||||
|
acquired_count: 0,
|
||||||
|
released_count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquire a buffer for encoded data of the specified size.
|
||||||
|
///
|
||||||
|
/// Tries to reuse an existing buffer from the pool with sufficient capacity.
|
||||||
|
/// If no suitable buffer exists, allocates a new one if under the capacity limit.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `size` - Minimum required buffer size in bytes
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// A `Bytes` buffer with at least `size` bytes capacity. The buffer may be larger
|
||||||
|
/// than requested if reusing an existing buffer.
|
||||||
|
pub fn acquire_encoded_buffer(&mut self, size: usize) -> Bytes {
|
||||||
|
self.acquired_count += 1;
|
||||||
|
|
||||||
|
// Try to reuse an existing buffer
|
||||||
|
if let Some(buf) = self.buffers.pop_front() {
|
||||||
|
if buf.len() >= size {
|
||||||
|
// Zero-copy slice to the requested size
|
||||||
|
return buf.slice(0..size);
|
||||||
|
}
|
||||||
|
// Buffer too small, will allocate new
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate new buffer if under capacity
|
||||||
|
if self.current_size < self.max_size {
|
||||||
|
self.current_size += 1;
|
||||||
|
return Bytes::from(vec![0u8; size]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pool at capacity, just allocate (will be dropped on release)
|
||||||
|
Bytes::from(vec![0u8; size])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release a buffer back to the pool for reuse.
|
||||||
|
///
|
||||||
|
/// The buffer is returned to the pool if there is space available.
|
||||||
|
/// Otherwise, it is dropped and the memory is freed when the reference count reaches zero.
|
||||||
|
pub fn release_encoded_buffer(&mut self, buf: Bytes) {
|
||||||
|
self.released_count += 1;
|
||||||
|
|
||||||
|
if self.buffers.len() < self.max_size {
|
||||||
|
self.buffers.push_back(buf);
|
||||||
|
}
|
||||||
|
// Otherwise, buffer is dropped and memory freed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current number of buffers in the pool (available for reuse).
|
||||||
|
pub fn pool_size(&self) -> usize {
|
||||||
|
self.buffers.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the maximum pool capacity.
|
||||||
|
pub fn max_capacity(&self) -> usize {
|
||||||
|
self.max_size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of buffers acquired from the pool (lifetime counter).
|
||||||
|
pub fn acquired_count(&self) -> usize {
|
||||||
|
self.acquired_count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of buffers released to the pool (lifetime counter).
|
||||||
|
pub fn released_count(&self) -> usize {
|
||||||
|
self.released_count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for buffer leaks.
|
||||||
|
///
|
||||||
|
/// Returns `true` if there are potential buffer leaks (more acquired than released).
|
||||||
|
pub fn has_leaks(&self) -> bool {
|
||||||
|
self.acquired_count > self.released_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified buffer pool interface combining DMA-BUF and encoded buffers
|
||||||
|
///
|
||||||
|
/// Provides a single interface for managing both raw frame buffers (DMA-BUF)
|
||||||
|
/// and encoded frame buffers. This unified approach simplifies buffer management
|
||||||
|
/// across the encoding pipeline.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct FrameBufferPool {
|
||||||
|
dma_pool: DmaBufPool,
|
||||||
|
encoded_pool: EncodedBufferPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameBufferPool {
|
||||||
|
/// Create a new unified buffer pool.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `max_dma_bufs` - Maximum number of DMA-BUF handles to pool
|
||||||
|
/// * `max_encoded` - Maximum number of encoded byte buffers to pool
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```
|
||||||
|
/// let pool = FrameBufferPool::new(4, 8);
|
||||||
|
/// ```
|
||||||
|
pub fn new(max_dma_bufs: usize, max_encoded: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
dma_pool: DmaBufPool::new(max_dma_bufs),
|
||||||
|
encoded_pool: EncodedBufferPool::new(max_encoded),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquire a DMA-BUF handle from the pool.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `size` - Minimum required buffer size in bytes
|
||||||
|
/// * `stride` - Stride (bytes per row) for the buffer
|
||||||
|
/// * `offset` - Offset to first pixel in bytes
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// `Some(DmaBufHandle)` if a buffer is available, `None` if pool is at capacity
|
||||||
|
/// and no buffers are available.
|
||||||
|
pub fn acquire_dma_buf(
|
||||||
|
&mut self,
|
||||||
|
size: usize,
|
||||||
|
stride: usize,
|
||||||
|
offset: usize,
|
||||||
|
) -> Option<DmaBufHandle> {
|
||||||
|
self.dma_pool.acquire_dma_buf(size, stride, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release a DMA-BUF handle back to the pool.
|
||||||
|
pub fn release_dma_buf(&mut self, handle: DmaBufHandle) {
|
||||||
|
self.dma_pool.release_dma_buf(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquire an encoded buffer from the pool.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `size` - Minimum required buffer size in bytes
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// A `Bytes` buffer with at least `size` bytes capacity.
|
||||||
|
pub fn acquire_encoded_buffer(&mut self, size: usize) -> Bytes {
|
||||||
|
self.encoded_pool.acquire_encoded_buffer(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release an encoded buffer back to the pool.
|
||||||
|
pub fn release_encoded_buffer(&mut self, buf: Bytes) {
|
||||||
|
self.encoded_pool.release_encoded_buffer(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get DMA-BUF pool size (buffers available for reuse).
|
||||||
|
pub fn dma_pool_size(&self) -> usize {
|
||||||
|
self.dma_pool.pool_size()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get encoded buffer pool size (buffers available for reuse).
|
||||||
|
pub fn encoded_pool_size(&self) -> usize {
|
||||||
|
self.encoded_pool.pool_size()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for buffer leaks in the DMA-BUF pool.
|
||||||
|
pub fn has_dma_leaks(&self) -> bool {
|
||||||
|
self.dma_pool.has_leaks()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for buffer leaks in the encoded buffer pool.
|
||||||
|
pub fn has_encoded_leaks(&self) -> bool {
|
||||||
|
self.encoded_pool.has_leaks()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for any buffer leaks in the pool.
|
||||||
|
pub fn has_leaks(&self) -> bool {
|
||||||
|
self.has_dma_leaks() || self.has_encoded_leaks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zero-copy frame wrapper with reference counting
|
||||||
|
///
|
||||||
|
/// Combines a reference-counted data buffer with frame metadata,
|
||||||
|
/// enabling zero-copy transfers through the encoding pipeline.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ZeroCopyFrame {
|
||||||
|
pub data: Bytes,
|
||||||
|
pub metadata: FrameMetadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata for video frames
|
||||||
|
///
|
||||||
|
/// Contains information about frame dimensions, format, and timing.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FrameMetadata {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub format: crate::capture::PixelFormat,
|
||||||
|
pub timestamp: u64,
|
||||||
|
pub is_keyframe: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DMA-BUF smart pointer for zero-copy access
|
||||||
|
///
|
||||||
|
/// Provides safe access to DMA-BUF memory without taking ownership of the
|
||||||
|
/// underlying file descriptor. The pointer is Send+Sync but memory
|
||||||
|
/// is managed by the owning DmaBufHandle.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DmaBufPtr {
|
||||||
|
ptr: *mut u8,
|
||||||
|
len: usize,
|
||||||
|
_marker: PhantomData<&'static mut [u8]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: DmaBufPtr can be sent across threads because the underlying
|
||||||
|
// DMA-BUF memory is shared between processes. The file descriptor
|
||||||
|
// ownership is maintained by DmaBufHandle, which ensures the memory
|
||||||
|
// remains valid.
|
||||||
|
unsafe impl Send for DmaBufPtr {}
|
||||||
|
|
||||||
|
// SAFETY: DmaBufPtr can be shared across threads for read-only access.
|
||||||
|
// The memory is shared between processes anyway, so concurrent access
|
||||||
|
// is expected and handled by the GPU driver.
|
||||||
|
unsafe impl Sync for DmaBufPtr {}
|
||||||
|
|
||||||
|
impl Drop for DmaBufPtr {
|
||||||
|
fn drop(&mut self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dma_buf_handle_creation() {
|
||||||
|
let fd = unsafe { libc::dup(1) };
|
||||||
|
let handle = DmaBufHandle::new(fd, 1024, 1024, 0);
|
||||||
|
assert_eq!(handle.fd(), fd);
|
||||||
|
assert_eq!(handle.size(), 1024);
|
||||||
|
assert_eq!(handle.stride(), 1024);
|
||||||
|
assert_eq!(handle.offset(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dma_buf_handle_clone() {
|
||||||
|
let fd1 = unsafe { libc::dup(1) };
|
||||||
|
let handle1 = DmaBufHandle::new(fd1, 1024, 1024, 0);
|
||||||
|
let handle2 = handle1.clone();
|
||||||
|
assert_eq!(handle1.fd(), handle2.fd());
|
||||||
|
assert_eq!(handle1.size(), handle2.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dma_buf_pool_creation() {
|
||||||
|
let pool = DmaBufPool::new(4);
|
||||||
|
assert_eq!(pool.max_capacity(), 4);
|
||||||
|
assert_eq!(pool.pool_size(), 0);
|
||||||
|
assert_eq!(pool.total_size(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dma_buf_pool_acquire_release() {
|
||||||
|
let mut pool = DmaBufPool::new(4);
|
||||||
|
|
||||||
|
let handle = pool.acquire_dma_buf(1024, 1024, 0);
|
||||||
|
assert!(handle.is_some());
|
||||||
|
assert_eq!(pool.pool_size(), 0);
|
||||||
|
assert_eq!(pool.total_size(), 1);
|
||||||
|
|
||||||
|
if let Some(h) = handle {
|
||||||
|
pool.release_dma_buf(h);
|
||||||
|
}
|
||||||
|
assert_eq!(pool.pool_size(), 1);
|
||||||
|
assert_eq!(pool.total_size(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dma_buf_pool_reuse() {
|
||||||
|
let mut pool = DmaBufPool::new(2);
|
||||||
|
|
||||||
|
let handle1 = pool.acquire_dma_buf(1024, 1024, 0);
|
||||||
|
assert!(handle1.is_some());
|
||||||
|
|
||||||
|
if let Some(h1) = handle1 {
|
||||||
|
pool.release_dma_buf(h1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle2 = pool.acquire_dma_buf(512, 512, 0);
|
||||||
|
assert!(handle2.is_some());
|
||||||
|
|
||||||
|
if let Some(h2) = handle2 {
|
||||||
|
pool.release_dma_buf(h2);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(pool.pool_size(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dma_buf_pool_capacity_limit() {
|
||||||
|
let mut pool = DmaBufPool::new(2);
|
||||||
|
|
||||||
|
let h1 = pool.acquire_dma_buf(1024, 1024, 0);
|
||||||
|
let h2 = pool.acquire_dma_buf(1024, 1024, 0);
|
||||||
|
let h3 = pool.acquire_dma_buf(1024, 1024, 0);
|
||||||
|
|
||||||
|
assert!(h1.is_some());
|
||||||
|
assert!(h2.is_some());
|
||||||
|
assert!(h3.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dma_buf_pool_memory_tracking() {
|
||||||
|
let mut pool = DmaBufPool::new(4);
|
||||||
|
|
||||||
|
assert_eq!(pool.acquired_count(), 0);
|
||||||
|
assert_eq!(pool.released_count(), 0);
|
||||||
|
assert!(!pool.has_leaks());
|
||||||
|
|
||||||
|
let _h = pool.acquire_dma_buf(1024, 1024, 0);
|
||||||
|
assert_eq!(pool.acquired_count(), 1);
|
||||||
|
assert_eq!(pool.released_count(), 0);
|
||||||
|
assert!(pool.has_leaks());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encoded_buffer_pool_creation() {
|
||||||
|
let pool = EncodedBufferPool::new(4);
|
||||||
|
assert_eq!(pool.max_capacity(), 4);
|
||||||
|
assert_eq!(pool.pool_size(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encoded_buffer_pool_acquire_release() {
|
||||||
|
let mut pool = EncodedBufferPool::new(4);
|
||||||
|
|
||||||
|
let buf = pool.acquire_encoded_buffer(1024);
|
||||||
|
assert_eq!(buf.len(), 1024);
|
||||||
|
assert_eq!(pool.pool_size(), 0);
|
||||||
|
|
||||||
|
pool.release_encoded_buffer(buf);
|
||||||
|
assert_eq!(pool.pool_size(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encoded_buffer_pool_reuse() {
|
||||||
|
let mut pool = EncodedBufferPool::new(2);
|
||||||
|
|
||||||
|
let buf1 = pool.acquire_encoded_buffer(1024);
|
||||||
|
pool.release_encoded_buffer(buf1);
|
||||||
|
|
||||||
|
let buf2 = pool.acquire_encoded_buffer(512);
|
||||||
|
assert_eq!(buf2.len(), 512);
|
||||||
|
|
||||||
|
pool.release_encoded_buffer(buf2);
|
||||||
|
assert_eq!(pool.pool_size(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encoded_buffer_pool_memory_tracking() {
|
||||||
|
let mut pool = EncodedBufferPool::new(4);
|
||||||
|
|
||||||
|
assert_eq!(pool.acquired_count(), 0);
|
||||||
|
assert_eq!(pool.released_count(), 0);
|
||||||
|
assert!(!pool.has_leaks());
|
||||||
|
|
||||||
|
let _buf = pool.acquire_encoded_buffer(1024);
|
||||||
|
assert_eq!(pool.acquired_count(), 1);
|
||||||
|
assert_eq!(pool.released_count(), 0);
|
||||||
|
assert!(pool.has_leaks());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_frame_buffer_pool_creation() {
|
||||||
|
let pool = FrameBufferPool::new(4, 8);
|
||||||
|
assert_eq!(pool.dma_pool_size(), 0);
|
||||||
|
assert_eq!(pool.encoded_pool_size(), 0);
|
||||||
|
assert!(!pool.has_leaks());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_frame_buffer_pool_dma_operations() {
|
||||||
|
let mut pool = FrameBufferPool::new(2, 4);
|
||||||
|
|
||||||
|
let handle = pool.acquire_dma_buf(1024, 1024, 0);
|
||||||
|
assert!(handle.is_some());
|
||||||
|
assert_eq!(pool.dma_pool_size(), 0);
|
||||||
|
|
||||||
|
if let Some(h) = handle {
|
||||||
|
pool.release_dma_buf(h);
|
||||||
|
}
|
||||||
|
assert_eq!(pool.dma_pool_size(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_frame_buffer_pool_encoded_operations() {
|
||||||
|
let mut pool = FrameBufferPool::new(2, 4);
|
||||||
|
|
||||||
|
let buf = pool.acquire_encoded_buffer(1024);
|
||||||
|
assert_eq!(buf.len(), 1024);
|
||||||
|
assert_eq!(pool.encoded_pool_size(), 0);
|
||||||
|
|
||||||
|
pool.release_encoded_buffer(buf);
|
||||||
|
assert_eq!(pool.encoded_pool_size(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_frame_buffer_pool_leak_detection() {
|
||||||
|
let mut pool = FrameBufferPool::new(4, 8);
|
||||||
|
|
||||||
|
let _handle = pool.acquire_dma_buf(1024, 1024, 0);
|
||||||
|
assert!(pool.has_dma_leaks());
|
||||||
|
assert!(!pool.has_encoded_leaks());
|
||||||
|
assert!(pool.has_leaks());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_zero_copy_frame_creation() {
|
||||||
|
let data = Bytes::from(vec![1u8, 2, 3, 4]);
|
||||||
|
let metadata = FrameMetadata {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
format: crate::capture::PixelFormat::NV12,
|
||||||
|
timestamp: 123456,
|
||||||
|
is_keyframe: true,
|
||||||
|
};
|
||||||
|
let frame = ZeroCopyFrame { data, metadata };
|
||||||
|
assert_eq!(frame.data.len(), 4);
|
||||||
|
assert_eq!(frame.metadata.width, 1920);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_zero_copy_frame_clone() {
|
||||||
|
let data = Bytes::from(vec![1u8, 2, 3, 4]);
|
||||||
|
let metadata = FrameMetadata {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
format: crate::capture::PixelFormat::NV12,
|
||||||
|
timestamp: 123456,
|
||||||
|
is_keyframe: true,
|
||||||
|
};
|
||||||
|
let frame1 = ZeroCopyFrame { data, metadata };
|
||||||
|
let frame2 = frame1.clone();
|
||||||
|
|
||||||
|
assert_eq!(frame1.data.as_ptr(), frame2.data.as_ptr());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dma_buf_ptr_is_send_sync() {
|
||||||
|
let data = vec![1u8, 2, 3, 4];
|
||||||
|
let ptr = DmaBufPtr {
|
||||||
|
ptr: data.as_ptr() as *mut u8,
|
||||||
|
len: data.len(),
|
||||||
|
_marker: PhantomData,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn assert_send_sync<T: Send + Sync>(_t: &T) {}
|
||||||
|
assert_send_sync(&ptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
888
src/capture/mod.rs
Normal file
888
src/capture/mod.rs
Normal file
@@ -0,0 +1,888 @@
|
|||||||
|
//! PipeWire screen capture module
|
||||||
|
//!
|
||||||
|
//! This module provides types for capturing screen frames from Wayland compositors
|
||||||
|
//! via PipeWire, with zero-copy DMA-BUF support.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::os::fd::RawFd;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
use std::time::{Duration, Instant, SystemTime};
|
||||||
|
|
||||||
|
use async_channel::{Receiver, Sender};
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::buffer::DmaBufHandle;
|
||||||
|
use crate::config::CaptureConfig;
|
||||||
|
pub use crate::config::{QualityLevel, ScreenRegion};
|
||||||
|
use crate::error::CaptureError;
|
||||||
|
|
||||||
|
#[cfg(feature = "pipewire")]
|
||||||
|
use pipewire as pw;
|
||||||
|
#[cfg(feature = "pipewire")]
|
||||||
|
use pipewire::properties;
|
||||||
|
#[cfg(feature = "pipewire")]
|
||||||
|
use pipewire::spa::param::format::{Format, VideoInfo};
|
||||||
|
#[cfg(feature = "pipewire")]
|
||||||
|
use pipewire::stream::{Data, Stream, StreamFlags};
|
||||||
|
|
||||||
|
/// Main capture manager that coordinates PipeWire screen capture
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CaptureManager {
|
||||||
|
/// PipeWire connection reference
|
||||||
|
#[cfg(feature = "pipewire")]
|
||||||
|
pub pipewire_connection: PipewireCore,
|
||||||
|
#[cfg(not(feature = "pipewire"))]
|
||||||
|
pub _pipewire: (),
|
||||||
|
/// Optional stream handle for the active capture stream
|
||||||
|
#[cfg(feature = "pipewire")]
|
||||||
|
pub stream_handle: Option<PipewireStream>,
|
||||||
|
#[cfg(not(feature = "pipewire"))]
|
||||||
|
pub stream_handle: Option<()>,
|
||||||
|
/// Async channel for sending captured frames
|
||||||
|
pub frame_sender: Sender<CapturedFrame>,
|
||||||
|
/// Async channel for receiving captured frames
|
||||||
|
pub frame_receiver: Receiver<CapturedFrame>,
|
||||||
|
/// Capture configuration
|
||||||
|
pub config: CaptureConfig,
|
||||||
|
/// Damage tracker for detecting screen changes
|
||||||
|
damage_tracker: DamageTracker,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// Captured screen frame from PipeWire
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CapturedFrame {
|
||||||
|
/// DMA-BUF handle for zero-copy buffer access
|
||||||
|
pub dma_buf: DmaBufHandle,
|
||||||
|
/// Frame width in pixels
|
||||||
|
pub width: u32,
|
||||||
|
/// Frame height in pixels
|
||||||
|
pub height: u32,
|
||||||
|
/// Pixel format of the frame
|
||||||
|
pub format: PixelFormat,
|
||||||
|
/// Timestamp in nanoseconds
|
||||||
|
pub timestamp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pixel format for captured frames
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PixelFormat {
|
||||||
|
/// 8-bit RGBA
|
||||||
|
RGBA,
|
||||||
|
/// 8-bit RGB
|
||||||
|
RGB,
|
||||||
|
/// YUV 4:2:0 planar
|
||||||
|
YUV420,
|
||||||
|
/// YUV 4:2:2 planar
|
||||||
|
YUV422,
|
||||||
|
/// YUV 4:4:4 planar
|
||||||
|
YUV444,
|
||||||
|
/// YUV 4:2:0 semi-planar
|
||||||
|
NV12,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PipeWire stream state
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum StreamState {
|
||||||
|
/// Not connected to PipeWire
|
||||||
|
Unconnected,
|
||||||
|
/// Establishing connection
|
||||||
|
Connecting,
|
||||||
|
/// Connected but not streaming
|
||||||
|
Connected,
|
||||||
|
/// Actively streaming frames
|
||||||
|
Streaming,
|
||||||
|
/// Error state
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Buffer configuration for PipeWire stream
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct BufferConfig {
|
||||||
|
/// Number of buffers in the pool
|
||||||
|
pub num_buffers: usize,
|
||||||
|
/// Size of each buffer in bytes
|
||||||
|
pub buffer_size: usize,
|
||||||
|
/// Minimum number of buffers required
|
||||||
|
pub min_buffers: usize,
|
||||||
|
/// Maximum number of buffers allowed
|
||||||
|
pub max_buffers: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BufferConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
num_buffers: 4,
|
||||||
|
buffer_size: 4 * 1920 * 1080,
|
||||||
|
min_buffers: 2,
|
||||||
|
max_buffers: 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipewire")]
|
||||||
|
/// PipeWire core connection
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PipewireCore {
|
||||||
|
/// PipeWire main loop
|
||||||
|
main_loop: pw::MainLoop,
|
||||||
|
/// PipeWire context
|
||||||
|
context: pw::Context,
|
||||||
|
/// PipeWire core connection
|
||||||
|
core: Arc<pw::Core>,
|
||||||
|
/// Event loop thread handle
|
||||||
|
thread_handle: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipewire")]
|
||||||
|
impl PipewireCore {
|
||||||
|
/// Create a new PipeWire core connection
|
||||||
|
pub fn new() -> Result<Self, CaptureError> {
|
||||||
|
info!("Initializing PipeWire core");
|
||||||
|
|
||||||
|
let main_loop = pw::MainLoop::new()
|
||||||
|
.map_err(|e| CaptureError::InitializationFailed(format!("Failed to create main loop: {}", e)))?;
|
||||||
|
|
||||||
|
let context = pw::Context::new(&main_loop)
|
||||||
|
.map_err(|e| CaptureError::InitializationFailed(format!("Failed to create context: {}", e)))?;
|
||||||
|
|
||||||
|
let core = context.connect(None)
|
||||||
|
.map_err(|e| CaptureError::InitializationFailed(format!("Failed to connect to PipeWire daemon: {}", e)))?;
|
||||||
|
|
||||||
|
let core = Arc::new(core);
|
||||||
|
|
||||||
|
let main_loop_clone = main_loop.clone();
|
||||||
|
|
||||||
|
let thread_handle = std::thread::spawn(move || {
|
||||||
|
debug!("PipeWire event loop thread started");
|
||||||
|
main_loop_clone.run();
|
||||||
|
debug!("PipeWire event loop thread exited");
|
||||||
|
});
|
||||||
|
|
||||||
|
info!("PipeWire core initialized successfully");
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
main_loop,
|
||||||
|
context,
|
||||||
|
core,
|
||||||
|
thread_handle: Some(thread_handle),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the PipeWire core
|
||||||
|
pub fn core(&self) -> &Arc<pw::Core> {
|
||||||
|
&self.core
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the PipeWire context
|
||||||
|
pub fn context(&self) -> &pw::Context {
|
||||||
|
&self.context
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shutdown the PipeWire core
|
||||||
|
pub fn shutdown(mut self) {
|
||||||
|
info!("Shutting down PipeWire core");
|
||||||
|
self.main_loop.quit();
|
||||||
|
if let Some(handle) = self.thread_handle.take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
info!("PipeWire core shut down successfully");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipewire")]
|
||||||
|
impl Drop for PipewireCore {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.thread_handle.is_some() {
|
||||||
|
info!("Dropping PipewireCore, shutting down");
|
||||||
|
self.main_loop.quit();
|
||||||
|
if let Some(handle) = self.thread_handle.take() {
|
||||||
|
let _ = handle.join().timeout(Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipewire")]
|
||||||
|
/// PipeWire stream for video capture
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PipewireStream {
|
||||||
|
/// Stream handle
|
||||||
|
stream: Stream,
|
||||||
|
/// Stream state
|
||||||
|
state: StreamState,
|
||||||
|
/// Frame format
|
||||||
|
format: Option<Format>,
|
||||||
|
/// Buffer configuration
|
||||||
|
buffer_config: BufferConfig,
|
||||||
|
/// Frame sender
|
||||||
|
frame_sender: Sender<CapturedFrame>,
|
||||||
|
/// Minimum damage threshold (pixels)
|
||||||
|
min_damage_threshold: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipewire")]
|
||||||
|
impl PipewireStream {
|
||||||
|
/// Create a new PipeWire capture stream
|
||||||
|
pub fn new(
|
||||||
|
core: &Arc<pw::Core>,
|
||||||
|
sender: Sender<CapturedFrame>,
|
||||||
|
buffer_config: BufferConfig,
|
||||||
|
) -> Result<Self, CaptureError> {
|
||||||
|
info!("Creating PipeWire stream");
|
||||||
|
|
||||||
|
let mut stream = Stream::new(
|
||||||
|
core,
|
||||||
|
"wl-webrtc-capture",
|
||||||
|
properties! {
|
||||||
|
*pw::keys::MEDIA_TYPE => "Video",
|
||||||
|
*pw::keys::MEDIA_CATEGORY => "Capture",
|
||||||
|
*pw::keys::MEDIA_ROLE => "Screen",
|
||||||
|
},
|
||||||
|
).map_err(|e| CaptureError::StreamCreationFailed(format!("Failed to create stream: {}", e)))?;
|
||||||
|
|
||||||
|
let listener = stream.add_local_listener()
|
||||||
|
.map_err(|e| CaptureError::StreamCreationFailed(format!("Failed to create listener: {}", e)))?;
|
||||||
|
|
||||||
|
let sender_clone = sender.clone();
|
||||||
|
|
||||||
|
listener
|
||||||
|
.register(pw::stream::events::Events::param_changed, {
|
||||||
|
let sender = sender.clone();
|
||||||
|
move |stream_data, event_id, event_data| {
|
||||||
|
Self::on_param_changed(stream_data, event_id, event_data, &sender);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| CaptureError::StreamCreationFailed(format!("Failed to register param_changed callback: {}", e)))?;
|
||||||
|
|
||||||
|
listener
|
||||||
|
.register(pw::stream::events::Events::process, {
|
||||||
|
move |stream_data| {
|
||||||
|
Self::on_process(stream_data, &sender_clone);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| CaptureError::StreamCreationFailed(format!("Failed to register process callback: {}", e)))?;
|
||||||
|
|
||||||
|
info!("PipeWire stream created successfully");
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
stream,
|
||||||
|
state: StreamState::Unconnected,
|
||||||
|
format: None,
|
||||||
|
buffer_config,
|
||||||
|
frame_sender: sender,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect the stream to a screen capture source (node_id)
|
||||||
|
pub fn connect(&mut self, node_id: Option<u32>) -> Result<(), CaptureError> {
|
||||||
|
info!("Connecting PipeWire stream to node {:?}", node_id);
|
||||||
|
|
||||||
|
self.stream.connect(
|
||||||
|
pw::spa::direction::Direction::Input,
|
||||||
|
node_id,
|
||||||
|
StreamFlags::AUTOCONNECT | StreamFlags::MAP_BUFFERS,
|
||||||
|
).map_err(|e| CaptureError::StreamCreationFailed(format!("Failed to connect stream: {}", e)))?;
|
||||||
|
|
||||||
|
self.state = StreamState::Connected;
|
||||||
|
info!("PipeWire stream connected successfully");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disconnect the stream
|
||||||
|
pub fn disconnect(&mut self) -> Result<(), CaptureError> {
|
||||||
|
info!("Disconnecting PipeWire stream");
|
||||||
|
self.stream.disconnect()
|
||||||
|
.map_err(|e| CaptureError::ConnectionLost)?;
|
||||||
|
self.state = StreamState::Unconnected;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the stream state
|
||||||
|
pub fn state(&self) -> StreamState {
|
||||||
|
self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the stream format
|
||||||
|
pub fn format(&self) -> Option<&Format> {
|
||||||
|
self.format.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle parameter change events
|
||||||
|
fn on_param_changed(
|
||||||
|
stream_data: &Data<Stream>,
|
||||||
|
_event_id: u32,
|
||||||
|
_event_data: *mut std::ffi::c_void,
|
||||||
|
_sender: &Sender<CapturedFrame>,
|
||||||
|
) {
|
||||||
|
debug!("PipeWire param_changed event");
|
||||||
|
|
||||||
|
if let Some(format) = stream_data.stream.format() {
|
||||||
|
debug!("Stream format received: {:?}", format);
|
||||||
|
|
||||||
|
if let Ok(video_info) = format.parse::<VideoInfo>() {
|
||||||
|
info!("Video info: {}x{} @ {} fps",
|
||||||
|
video_info.size().width,
|
||||||
|
video_info.size().height,
|
||||||
|
1000000000 / video_info.framerate().denom as u32 * video_info.framerate().numer as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle new frames from the stream
|
||||||
|
fn on_process(stream_data: &Data<Stream>, sender: &Sender<CapturedFrame>) {
|
||||||
|
let stream = stream_data.stream;
|
||||||
|
|
||||||
|
let buffer = match stream.dequeue_buffer() {
|
||||||
|
Some(buffer) => buffer,
|
||||||
|
None => {
|
||||||
|
warn!("No buffer available in process callback");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let datas = buffer.datas();
|
||||||
|
if datas.is_empty() {
|
||||||
|
warn!("Buffer has no data planes");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = &datas[0];
|
||||||
|
|
||||||
|
let fd = match data.fd() {
|
||||||
|
Some(fd) => fd,
|
||||||
|
None => {
|
||||||
|
warn!("Buffer has no file descriptor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let chunk = data.chunk();
|
||||||
|
let size = chunk.size() as usize;
|
||||||
|
let stride = chunk.stride() as u32;
|
||||||
|
let offset = chunk.offset() as u32;
|
||||||
|
|
||||||
|
let format = match stream.format() {
|
||||||
|
Some(fmt) => fmt,
|
||||||
|
None => {
|
||||||
|
warn!("Stream has no format");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let video_info = match format.parse::<VideoInfo>() {
|
||||||
|
Ok(info) => info,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to parse video info: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let width = video_info.size().width;
|
||||||
|
let height = video_info.size().height;
|
||||||
|
|
||||||
|
let pixel_format = PixelFormat::from_spa_format(&format);
|
||||||
|
|
||||||
|
let timestamp = timestamp_ns();
|
||||||
|
|
||||||
|
debug!("Captured frame: {}x{}, format: {:?}, fd: {}, size: {}, stride: {}",
|
||||||
|
width, height, pixel_format, fd, size, stride);
|
||||||
|
|
||||||
|
let dma_buf = DmaBufHandle::new(fd, size, stride as usize, offset as usize);
|
||||||
|
|
||||||
|
let frame = CapturedFrame {
|
||||||
|
dma_buf,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
format: pixel_format,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = sender.try_send(frame) {
|
||||||
|
warn!("Failed to send captured frame: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipewire")]
|
||||||
|
impl PixelFormat {
|
||||||
|
/// Convert from PipeWire SPA format
|
||||||
|
pub fn from_spa_format(format: &Format) -> Self {
|
||||||
|
if let Ok(video_info) = format.parse::<VideoInfo>() {
|
||||||
|
let format_raw = video_info.format();
|
||||||
|
match format_raw {
|
||||||
|
pw::spa::param::video::VideoFormat::RGBA => PixelFormat::RGBA,
|
||||||
|
pw::spa::param::video::VideoFormat::RGBx => PixelFormat::RGBA,
|
||||||
|
pw::spa::param::video::VideoFormat::RGB => PixelFormat::RGB,
|
||||||
|
pw::spa::param::video::VideoFormat::BGRx => PixelFormat::RGB,
|
||||||
|
pw::spa::param::video::VideoFormat::YUY2 => PixelFormat::YUV422,
|
||||||
|
pw::spa::param::video::VideoFormat::I420 => PixelFormat::YUV420,
|
||||||
|
pw::spa::param::video::VideoFormat::NV12 => PixelFormat::NV12,
|
||||||
|
_ => {
|
||||||
|
debug!("Unknown SPA format {:?}, defaulting to RGBA", format_raw);
|
||||||
|
PixelFormat::RGBA
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug!("Failed to parse video format, defaulting to RGBA");
|
||||||
|
PixelFormat::RGBA
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Damage tracker for detecting screen changes
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DamageTracker {
|
||||||
|
/// Last frame's data (stored as a hash for efficiency)
|
||||||
|
last_frame_hash: Option<u64>,
|
||||||
|
/// Damaged regions queue
|
||||||
|
damaged_regions: VecDeque<ScreenRegion>,
|
||||||
|
/// Minimum damage threshold (pixels)
|
||||||
|
min_damage_threshold: u32,
|
||||||
|
/// Maximum number of regions to track
|
||||||
|
max_regions: usize,
|
||||||
|
/// Statistics
|
||||||
|
stats: DamageStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Damage statistics
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct DamageStats {
|
||||||
|
/// Total frames processed
|
||||||
|
pub total_frames: u64,
|
||||||
|
/// Frames with detected damage
|
||||||
|
pub damaged_frames: u64,
|
||||||
|
/// Total damaged regions detected
|
||||||
|
pub total_regions: u64,
|
||||||
|
/// Average region size (pixels)
|
||||||
|
pub avg_region_size: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DamageTracker {
|
||||||
|
/// Create a new damage tracker
|
||||||
|
pub fn new(min_threshold: u32, max_regions: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
last_frame_hash: None,
|
||||||
|
damaged_regions: VecDeque::with_capacity(max_regions),
|
||||||
|
max_regions,
|
||||||
|
min_damage_threshold: min_threshold,
|
||||||
|
stats: DamageStats::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update damage regions based on a new frame
|
||||||
|
pub fn update(&mut self, new_frame: &CapturedFrame) -> Vec<ScreenRegion> {
|
||||||
|
self.stats.total_frames += 1;
|
||||||
|
|
||||||
|
let frame_hash = self.compute_frame_hash(new_frame);
|
||||||
|
|
||||||
|
let regions = match self.last_frame_hash {
|
||||||
|
Some(last_hash) => {
|
||||||
|
if last_hash == frame_hash {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
self.stats.damaged_frames += 1;
|
||||||
|
self.stats.total_regions += 1;
|
||||||
|
self.stats.avg_region_size =
|
||||||
|
(new_frame.width * new_frame.height) as f32;
|
||||||
|
|
||||||
|
vec![ScreenRegion {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: new_frame.width,
|
||||||
|
height: new_frame.height,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.stats.damaged_frames += 1;
|
||||||
|
self.stats.total_regions += 1;
|
||||||
|
self.stats.avg_region_size = (new_frame.width * new_frame.height) as f32;
|
||||||
|
|
||||||
|
vec![ScreenRegion {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: new_frame.width,
|
||||||
|
height: new_frame.height,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.last_frame_hash = Some(frame_hash);
|
||||||
|
regions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a simple hash of the frame data
|
||||||
|
/// Note: This is a simplified implementation. In production, you'd want
|
||||||
|
/// to use a more sophisticated approach or skip hashing altogether.
|
||||||
|
fn compute_frame_hash(&self, _frame: &CapturedFrame) -> u64 {
|
||||||
|
let _now = Instant::now();
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos() as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get damage statistics
|
||||||
|
pub fn stats(&self) -> &DamageStats {
|
||||||
|
&self.stats
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the tracker state
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.last_frame_hash = None;
|
||||||
|
self.damaged_regions.clear();
|
||||||
|
self.stats = DamageStats::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current timestamp in nanoseconds
|
||||||
|
fn timestamp_ns() -> u64 {
|
||||||
|
let now = Instant::now();
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos() as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CaptureManager {
|
||||||
|
/// Create a new capture manager
|
||||||
|
#[cfg(feature = "pipewire")]
|
||||||
|
pub fn new(config: CaptureConfig) -> Result<Self, CaptureError> {
|
||||||
|
info!("Creating CaptureManager with config: {:?}", config);
|
||||||
|
|
||||||
|
let pipewire_connection = PipewireCore::new()?;
|
||||||
|
|
||||||
|
let (frame_sender, frame_receiver) = async_channel::bounded(30);
|
||||||
|
|
||||||
|
let damage_tracker = DamageTracker::new(
|
||||||
|
config.frame_rate,
|
||||||
|
16,
|
||||||
|
);
|
||||||
|
|
||||||
|
info!("CaptureManager created successfully");
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
pipewire_connection,
|
||||||
|
stream_handle: None,
|
||||||
|
frame_sender,
|
||||||
|
frame_receiver,
|
||||||
|
config,
|
||||||
|
damage_tracker,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new capture manager (stub when pipewire feature is disabled)
|
||||||
|
#[cfg(not(feature = "pipewire"))]
|
||||||
|
pub fn new(config: CaptureConfig) -> Result<Self, CaptureError> {
|
||||||
|
info!("Creating CaptureManager with config: {:?}", config);
|
||||||
|
warn!("PipeWire feature not enabled, capture will not be functional");
|
||||||
|
|
||||||
|
let (frame_sender, frame_receiver) = async_channel::bounded(30);
|
||||||
|
|
||||||
|
let damage_tracker = DamageTracker::new(
|
||||||
|
config.frame_rate,
|
||||||
|
16,
|
||||||
|
);
|
||||||
|
|
||||||
|
info!("CaptureManager created successfully");
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
_pipewire: (),
|
||||||
|
stream_handle: None,
|
||||||
|
frame_sender,
|
||||||
|
frame_receiver,
|
||||||
|
config,
|
||||||
|
damage_tracker,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start screen capture
|
||||||
|
#[cfg(feature = "pipewire")]
|
||||||
|
pub async fn start(&mut self, node_id: Option<u32>) -> Result<(), CaptureError> {
|
||||||
|
info!("Starting screen capture for node {:?}", node_id);
|
||||||
|
|
||||||
|
let buffer_config = BufferConfig::default();
|
||||||
|
|
||||||
|
let stream = PipewireStream::new(
|
||||||
|
self.pipewire_connection.core(),
|
||||||
|
self.frame_sender.clone(),
|
||||||
|
buffer_config,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
stream.connect(node_id)?;
|
||||||
|
|
||||||
|
self.stream_handle = Some(stream);
|
||||||
|
self.damage_tracker.reset();
|
||||||
|
|
||||||
|
info!("Screen capture started successfully");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start screen capture (stub when pipewire feature is disabled)
|
||||||
|
#[cfg(not(feature = "pipewire"))]
|
||||||
|
pub async fn start(&mut self, _node_id: Option<u32>) -> Result<(), CaptureError> {
|
||||||
|
Err(CaptureError::InitializationFailed("PipeWire feature not enabled".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop screen capture
|
||||||
|
#[cfg(feature = "pipewire")]
|
||||||
|
pub fn stop(&mut self) -> Result<(), CaptureError> {
|
||||||
|
info!("Stopping screen capture");
|
||||||
|
|
||||||
|
if let Some(mut stream) = self.stream_handle.take() {
|
||||||
|
stream.disconnect()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.damage_tracker.reset();
|
||||||
|
|
||||||
|
info!("Screen capture stopped successfully");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop screen capture (stub when pipewire feature is disabled)
|
||||||
|
#[cfg(not(feature = "pipewire"))]
|
||||||
|
pub fn stop(&mut self) -> Result<(), CaptureError> {
|
||||||
|
warn!("Cannot stop capture - PipeWire feature not enabled");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the next captured frame
|
||||||
|
pub async fn next_frame(&mut self) -> Result<CapturedFrame, CaptureError> {
|
||||||
|
let frame = self.frame_receiver.recv().await
|
||||||
|
.map_err(|_e| CaptureError::ConnectionLost)?;
|
||||||
|
|
||||||
|
let damaged_regions = self.damage_tracker.update(&frame);
|
||||||
|
|
||||||
|
debug!("Frame received, damaged regions: {:?}", damaged_regions);
|
||||||
|
|
||||||
|
Ok(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get damage statistics
|
||||||
|
pub fn damage_stats(&self) -> &DamageStats {
|
||||||
|
self.damage_tracker.stats()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the frame sender (for external use)
|
||||||
|
pub fn frame_sender(&self) -> &Sender<CapturedFrame> {
|
||||||
|
&self.frame_sender
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the frame receiver (for external use)
|
||||||
|
pub fn frame_receiver(&self) -> &Receiver<CapturedFrame> {
|
||||||
|
&self.frame_receiver
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if capture is active
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.stream_handle.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the PipeWire core (only available when pipewire feature is enabled)
|
||||||
|
#[cfg(feature = "pipewire")]
|
||||||
|
pub fn pipewire_core(&self) -> &PipewireCore {
|
||||||
|
&self.pipewire_connection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::{CaptureConfig, QualityLevel};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pixel_format_from_spa() {
|
||||||
|
let format = PixelFormat::RGBA;
|
||||||
|
assert_eq!(format, PixelFormat::RGBA);
|
||||||
|
assert_eq!(format, PixelFormat::RGBA);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_damage_tracker_creation() {
|
||||||
|
let tracker = DamageTracker::new(100, 16);
|
||||||
|
|
||||||
|
assert!(tracker.last_frame_hash.is_none());
|
||||||
|
assert_eq!(tracker.damaged_regions.len(), 0);
|
||||||
|
assert_eq!(tracker.min_damage_threshold, 100);
|
||||||
|
assert_eq!(tracker.max_regions, 16);
|
||||||
|
assert_eq!(tracker.stats.total_frames, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_damage_tracker_first_frame() {
|
||||||
|
let mut tracker = DamageTracker::new(100, 16);
|
||||||
|
|
||||||
|
let frame = CapturedFrame {
|
||||||
|
dma_buf: DmaBufHandle::new(0, 100, 10, 0),
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
format: PixelFormat::RGBA,
|
||||||
|
timestamp: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let regions = tracker.update(&frame);
|
||||||
|
|
||||||
|
assert_eq!(regions.len(), 1);
|
||||||
|
assert_eq!(regions[0].x, 0);
|
||||||
|
assert_eq!(regions[0].y, 0);
|
||||||
|
assert_eq!(regions[0].width, 100);
|
||||||
|
assert_eq!(regions[0].height, 100);
|
||||||
|
|
||||||
|
assert_eq!(tracker.stats.total_frames, 1);
|
||||||
|
assert_eq!(tracker.stats.damaged_frames, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_damage_tracker_stats() {
|
||||||
|
let mut tracker = DamageTracker::new(100, 16);
|
||||||
|
|
||||||
|
let frame = CapturedFrame {
|
||||||
|
dma_buf: DmaBufHandle::new(0, 100, 10, 0),
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
format: PixelFormat::RGBA,
|
||||||
|
timestamp: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
tracker.update(&frame);
|
||||||
|
|
||||||
|
let stats = tracker.stats();
|
||||||
|
assert_eq!(stats.total_frames, 1);
|
||||||
|
assert_eq!(stats.damaged_frames, 1);
|
||||||
|
assert_eq!(stats.total_regions, 1);
|
||||||
|
assert_eq!(stats.avg_region_size, 10000.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_damage_tracker_reset() {
|
||||||
|
let mut tracker = DamageTracker::new(100, 16);
|
||||||
|
|
||||||
|
let frame = CapturedFrame {
|
||||||
|
dma_buf: DmaBufHandle::new(0, 100, 10, 0),
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
format: PixelFormat::RGBA,
|
||||||
|
timestamp: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
tracker.update(&frame);
|
||||||
|
tracker.reset();
|
||||||
|
|
||||||
|
assert!(tracker.last_frame_hash.is_none());
|
||||||
|
assert_eq!(tracker.stats.total_frames, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_buffer_config_default() {
|
||||||
|
let config = BufferConfig::default();
|
||||||
|
|
||||||
|
assert_eq!(config.num_buffers, 4);
|
||||||
|
assert_eq!(config.min_buffers, 2);
|
||||||
|
assert_eq!(config.max_buffers, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stream_state_variants() {
|
||||||
|
let states = [
|
||||||
|
StreamState::Unconnected,
|
||||||
|
StreamState::Connecting,
|
||||||
|
StreamState::Connected,
|
||||||
|
StreamState::Streaming,
|
||||||
|
StreamState::Error,
|
||||||
|
];
|
||||||
|
|
||||||
|
for state in states {
|
||||||
|
assert_eq!(state, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_capture_config_defaults() {
|
||||||
|
let config = CaptureConfig::default();
|
||||||
|
|
||||||
|
assert_eq!(config.frame_rate, 30);
|
||||||
|
assert_eq!(config.quality, QualityLevel::High);
|
||||||
|
assert!(config.screen_region.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_captured_frame_creation() {
|
||||||
|
let frame = CapturedFrame {
|
||||||
|
dma_buf: DmaBufHandle::new(42, 1024, 32, 0),
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
format: PixelFormat::RGBA,
|
||||||
|
timestamp: 1234567890,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(frame.width, 1920);
|
||||||
|
assert_eq!(frame.height, 1080);
|
||||||
|
assert_eq!(frame.format, PixelFormat::RGBA);
|
||||||
|
assert_eq!(frame.timestamp, 1234567890);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_capture_manager_creation() {
|
||||||
|
let config = CaptureConfig::default();
|
||||||
|
|
||||||
|
let result = CaptureManager::new(config);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
println!("PipeWire is available, test passed");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("PipeWire not available (expected in test env): {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_screen_region() {
|
||||||
|
let region = ScreenRegion {
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 100,
|
||||||
|
height: 200,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(region.x, 10);
|
||||||
|
assert_eq!(region.y, 20);
|
||||||
|
assert_eq!(region.width, 100);
|
||||||
|
assert_eq!(region.height, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_damage_tracker_updates() {
|
||||||
|
let mut tracker = DamageTracker::new(100, 16);
|
||||||
|
|
||||||
|
let frame1 = CapturedFrame {
|
||||||
|
dma_buf: DmaBufHandle::new(0, 100, 10, 0),
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
format: PixelFormat::RGBA,
|
||||||
|
timestamp: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let frame2 = CapturedFrame {
|
||||||
|
dma_buf: DmaBufHandle::new(1, 100, 10, 0),
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
format: PixelFormat::RGBA,
|
||||||
|
timestamp: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
tracker.update(&frame1);
|
||||||
|
assert_eq!(tracker.stats.total_frames, 1);
|
||||||
|
assert_eq!(tracker.stats.damaged_frames, 1);
|
||||||
|
|
||||||
|
tracker.update(&frame2);
|
||||||
|
assert_eq!(tracker.stats.total_frames, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
663
src/config.rs
Normal file
663
src/config.rs
Normal file
@@ -0,0 +1,663 @@
|
|||||||
|
//! Configuration system for wl-webrtc
|
||||||
|
//!
|
||||||
|
//! This module provides configuration management with TOML file parsing,
|
||||||
|
//! CLI argument overrides, and validation for reasonable values.
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Capture configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct CaptureConfig {
|
||||||
|
/// Frame rate for screen capture (fps)
|
||||||
|
#[serde(default = "default_frame_rate")]
|
||||||
|
pub frame_rate: u32,
|
||||||
|
|
||||||
|
/// Quality level for capture
|
||||||
|
#[serde(default = "default_quality")]
|
||||||
|
pub quality: QualityLevel,
|
||||||
|
|
||||||
|
/// Optional screen region to capture
|
||||||
|
#[serde(default)]
|
||||||
|
pub screen_region: Option<ScreenRegion>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quality levels for capture
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum QualityLevel {
|
||||||
|
Low,
|
||||||
|
Medium,
|
||||||
|
High,
|
||||||
|
Ultra,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Screen region definition
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ScreenRegion {
|
||||||
|
pub x: u32,
|
||||||
|
pub y: u32,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encoder configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct EncoderConfig {
|
||||||
|
/// Encoder type
|
||||||
|
#[serde(default = "default_encoder_type")]
|
||||||
|
pub encoder_type: EncoderType,
|
||||||
|
|
||||||
|
/// Video width
|
||||||
|
#[serde(default = "default_width")]
|
||||||
|
pub width: u32,
|
||||||
|
|
||||||
|
/// Video height
|
||||||
|
#[serde(default = "default_height")]
|
||||||
|
pub height: u32,
|
||||||
|
|
||||||
|
/// Frame rate
|
||||||
|
#[serde(default = "default_encoder_frame_rate")]
|
||||||
|
pub frame_rate: u32,
|
||||||
|
|
||||||
|
/// Target bitrate (bps)
|
||||||
|
#[serde(default = "default_bitrate")]
|
||||||
|
pub bitrate: u32,
|
||||||
|
|
||||||
|
/// Maximum bitrate (bps)
|
||||||
|
#[serde(default = "default_max_bitrate")]
|
||||||
|
pub max_bitrate: u32,
|
||||||
|
|
||||||
|
/// Minimum bitrate (bps)
|
||||||
|
#[serde(default = "default_min_bitrate")]
|
||||||
|
pub min_bitrate: u32,
|
||||||
|
|
||||||
|
/// Keyframe interval (frames)
|
||||||
|
#[serde(default = "default_keyframe_interval")]
|
||||||
|
pub keyframe_interval: u32,
|
||||||
|
|
||||||
|
/// Encode preset
|
||||||
|
#[serde(default = "default_preset")]
|
||||||
|
pub preset: EncodePreset,
|
||||||
|
|
||||||
|
/// Encoder tuning
|
||||||
|
#[serde(default = "default_tune")]
|
||||||
|
pub tune: EncodeTune,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encoder types
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum EncoderType {
|
||||||
|
/// H.264 via x264 (software)
|
||||||
|
#[serde(rename = "h264_x264")]
|
||||||
|
H264X264,
|
||||||
|
|
||||||
|
/// H.264 via VA-API (hardware)
|
||||||
|
#[serde(rename = "h264_vaapi")]
|
||||||
|
H264VAAPI,
|
||||||
|
|
||||||
|
/// H.264 via NVENC (NVIDIA hardware)
|
||||||
|
#[serde(rename = "h264_nvenc")]
|
||||||
|
H264NVENC,
|
||||||
|
|
||||||
|
/// VP9 via software
|
||||||
|
#[serde(rename = "vp9")]
|
||||||
|
VP9,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode presets
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum EncodePreset {
|
||||||
|
Ultrafast,
|
||||||
|
Superfast,
|
||||||
|
Veryfast,
|
||||||
|
Faster,
|
||||||
|
Fast,
|
||||||
|
Medium,
|
||||||
|
Slow,
|
||||||
|
Slower,
|
||||||
|
Veryslow,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encoder tuning options
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum EncodeTune {
|
||||||
|
Zerolatency,
|
||||||
|
Film,
|
||||||
|
Animation,
|
||||||
|
Stillimage,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebRTC configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct WebRtcConfig {
|
||||||
|
/// Server port
|
||||||
|
#[serde(default = "default_port")]
|
||||||
|
pub port: u16,
|
||||||
|
|
||||||
|
/// ICE servers
|
||||||
|
#[serde(default = "default_ice_servers")]
|
||||||
|
pub ice_servers: Vec<String>,
|
||||||
|
|
||||||
|
/// STUN servers
|
||||||
|
#[serde(default = "default_stun_servers")]
|
||||||
|
pub stun_servers: Vec<String>,
|
||||||
|
|
||||||
|
/// TURN servers
|
||||||
|
#[serde(default)]
|
||||||
|
pub turn_servers: Vec<TurnServerConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TURN server configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct TurnServerConfig {
|
||||||
|
pub urls: Vec<String>,
|
||||||
|
pub username: String,
|
||||||
|
pub credential: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main application configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub capture: CaptureConfig,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub encoder: EncoderConfig,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub webrtc: WebRtcConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CaptureConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
frame_rate: default_frame_rate(),
|
||||||
|
quality: default_quality(),
|
||||||
|
screen_region: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EncoderConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
encoder_type: default_encoder_type(),
|
||||||
|
width: default_width(),
|
||||||
|
height: default_height(),
|
||||||
|
frame_rate: default_encoder_frame_rate(),
|
||||||
|
bitrate: default_bitrate(),
|
||||||
|
max_bitrate: default_max_bitrate(),
|
||||||
|
min_bitrate: default_min_bitrate(),
|
||||||
|
keyframe_interval: default_keyframe_interval(),
|
||||||
|
preset: default_preset(),
|
||||||
|
tune: default_tune(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WebRtcConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
port: default_port(),
|
||||||
|
ice_servers: default_ice_servers(),
|
||||||
|
stun_servers: default_stun_servers(),
|
||||||
|
turn_servers: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
capture: CaptureConfig::default(),
|
||||||
|
encoder: EncoderConfig::default(),
|
||||||
|
webrtc: WebRtcConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_frame_rate() -> u32 {
|
||||||
|
30
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_quality() -> QualityLevel {
|
||||||
|
QualityLevel::High
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_encoder_type() -> EncoderType {
|
||||||
|
EncoderType::H264X264
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_width() -> u32 {
|
||||||
|
1920
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_height() -> u32 {
|
||||||
|
1080
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_encoder_frame_rate() -> u32 {
|
||||||
|
30
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_bitrate() -> u32 {
|
||||||
|
4_000_000
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_bitrate() -> u32 {
|
||||||
|
8_000_000
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_min_bitrate() -> u32 {
|
||||||
|
500_000
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_keyframe_interval() -> u32 {
|
||||||
|
60
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_preset() -> EncodePreset {
|
||||||
|
EncodePreset::Veryfast
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_tune() -> EncodeTune {
|
||||||
|
EncodeTune::Zerolatency
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_port() -> u16 {
|
||||||
|
8443
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ice_servers() -> Vec<String> {
|
||||||
|
vec!["stun:stun.l.google.com:19302".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_stun_servers() -> Vec<String> {
|
||||||
|
vec!["stun:stun.l.google.com:19302".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration validation
|
||||||
|
impl AppConfig {
|
||||||
|
/// Validate configuration and return errors if any values are invalid
|
||||||
|
pub fn validate(&self) -> Result<(), ConfigError> {
|
||||||
|
if self.capture.frame_rate < 1 || self.capture.frame_rate > 144 {
|
||||||
|
return Err(ConfigError::InvalidFrameRate(self.capture.frame_rate));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.encoder.width < 320 || self.encoder.width > 7680 {
|
||||||
|
return Err(ConfigError::InvalidResolution(
|
||||||
|
self.encoder.width,
|
||||||
|
self.encoder.height,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.encoder.height < 240 || self.encoder.height > 4320 {
|
||||||
|
return Err(ConfigError::InvalidResolution(
|
||||||
|
self.encoder.width,
|
||||||
|
self.encoder.height,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.encoder.frame_rate < 1 || self.encoder.frame_rate > 144 {
|
||||||
|
return Err(ConfigError::InvalidFrameRate(self.encoder.frame_rate));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.encoder.bitrate < 100_000 || self.encoder.bitrate > 50_000_000 {
|
||||||
|
return Err(ConfigError::InvalidBitrate(self.encoder.bitrate));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.encoder.min_bitrate >= self.encoder.max_bitrate {
|
||||||
|
return Err(ConfigError::InvalidBitrateRange(
|
||||||
|
self.encoder.min_bitrate,
|
||||||
|
self.encoder.max_bitrate,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.encoder.bitrate < self.encoder.min_bitrate
|
||||||
|
|| self.encoder.bitrate > self.encoder.max_bitrate
|
||||||
|
{
|
||||||
|
return Err(ConfigError::BitrateOutOfRange {
|
||||||
|
bitrate: self.encoder.bitrate,
|
||||||
|
min: self.encoder.min_bitrate,
|
||||||
|
max: self.encoder.max_bitrate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.encoder.keyframe_interval < 1 || self.encoder.keyframe_interval > 300 {
|
||||||
|
return Err(ConfigError::InvalidKeyframeInterval(
|
||||||
|
self.encoder.keyframe_interval,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.webrtc.port == 0 || self.webrtc.port > 65535 {
|
||||||
|
return Err(ConfigError::InvalidPort(self.webrtc.port));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load configuration from a TOML file
|
||||||
|
pub fn from_file<P: Into<PathBuf>>(path: P) -> Result<Self, ConfigError> {
|
||||||
|
let path = path.into();
|
||||||
|
let content = std::fs::read_to_string(&path).map_err(|e| {
|
||||||
|
ConfigError::IoError(format!(
|
||||||
|
"Failed to read config file {}: {}",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut config: AppConfig = toml::from_str(&content).map_err(|e| {
|
||||||
|
ConfigError::ParseError(format!(
|
||||||
|
"Failed to parse config file {}: {}",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Apply validation
|
||||||
|
config.validate()?;
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge CLI overrides into the configuration
|
||||||
|
pub fn merge_cli_overrides(&mut self, overrides: &ConfigOverrides) {
|
||||||
|
if let Some(frame_rate) = overrides.frame_rate {
|
||||||
|
self.capture.frame_rate = frame_rate;
|
||||||
|
}
|
||||||
|
if let Some(width) = overrides.width {
|
||||||
|
self.encoder.width = width;
|
||||||
|
}
|
||||||
|
if let Some(height) = overrides.height {
|
||||||
|
self.encoder.height = height;
|
||||||
|
}
|
||||||
|
if let Some(bitrate) = overrides.bitrate {
|
||||||
|
self.encoder.bitrate = bitrate;
|
||||||
|
}
|
||||||
|
if let Some(port) = overrides.port {
|
||||||
|
self.webrtc.port = port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration errors
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
#[error("Invalid frame rate: {0}. Must be between 1 and 144")]
|
||||||
|
InvalidFrameRate(u32),
|
||||||
|
|
||||||
|
#[error("Invalid resolution: {0}x{1}. Width must be 320-7680, height must be 240-4320")]
|
||||||
|
InvalidResolution(u32, u32),
|
||||||
|
|
||||||
|
#[error("Invalid bitrate: {0}. Must be between 100,000 and 50,000,000 bps")]
|
||||||
|
InvalidBitrate(u32),
|
||||||
|
|
||||||
|
#[error("Invalid bitrate range: min ({0}) must be less than max ({1})")]
|
||||||
|
InvalidBitrateRange(u32, u32),
|
||||||
|
|
||||||
|
#[error("Bitrate {bitrate} out of range. Must be between {min} and {max}")]
|
||||||
|
BitrateOutOfRange { bitrate: u32, min: u32, max: u32 },
|
||||||
|
|
||||||
|
#[error("Invalid keyframe interval: {0}. Must be between 1 and 300 frames")]
|
||||||
|
InvalidKeyframeInterval(u32),
|
||||||
|
|
||||||
|
#[error("Invalid port: {0}. Must be between 1 and 65535")]
|
||||||
|
InvalidPort(u16),
|
||||||
|
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
IoError(String),
|
||||||
|
|
||||||
|
#[error("Parse error: {0}")]
|
||||||
|
ParseError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CLI configuration overrides
|
||||||
|
#[derive(Debug, Clone, Parser, Default)]
|
||||||
|
pub struct ConfigOverrides {
|
||||||
|
/// Override capture frame rate
|
||||||
|
#[arg(long)]
|
||||||
|
pub frame_rate: Option<u32>,
|
||||||
|
|
||||||
|
/// Override video width
|
||||||
|
#[arg(long)]
|
||||||
|
pub width: Option<u32>,
|
||||||
|
|
||||||
|
/// Override video height
|
||||||
|
#[arg(long)]
|
||||||
|
pub height: Option<u32>,
|
||||||
|
|
||||||
|
/// Override bitrate in Mbps
|
||||||
|
#[arg(long)]
|
||||||
|
pub bitrate_mbps: Option<f64>,
|
||||||
|
|
||||||
|
/// Override bitrate (used internally, converted from bitrate_mbps)
|
||||||
|
#[arg(skip)]
|
||||||
|
pub bitrate: Option<u32>,
|
||||||
|
|
||||||
|
/// Override server port
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub port: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigOverrides {
|
||||||
|
/// Convert bitrate from Mbps to bps
|
||||||
|
pub fn normalize(&mut self) {
|
||||||
|
if let Some(mbps) = self.bitrate_mbps.take() {
|
||||||
|
self.bitrate = Some((mbps * 1_000_000.0) as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CLI command structure
|
||||||
|
#[derive(Debug, Clone, Parser)]
|
||||||
|
#[command(name = "wl-webrtc")]
|
||||||
|
#[command(about = "Wayland to WebRTC remote desktop backend", long_about = None)]
|
||||||
|
pub struct Cli {
|
||||||
|
/// Path to configuration file
|
||||||
|
#[arg(short, long, value_name = "FILE")]
|
||||||
|
pub config: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Configuration overrides
|
||||||
|
#[command(flatten)]
|
||||||
|
pub overrides: ConfigOverrides,
|
||||||
|
|
||||||
|
/// Subcommand
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Option<Commands>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Available subcommands
|
||||||
|
#[derive(Debug, Clone, Subcommand)]
|
||||||
|
pub enum Commands {
|
||||||
|
/// Start the remote desktop server
|
||||||
|
Start {
|
||||||
|
/// Optional configuration file path (overrides main config argument)
|
||||||
|
#[arg(short, long)]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Stop the running server
|
||||||
|
Stop,
|
||||||
|
|
||||||
|
/// Show server status
|
||||||
|
Status,
|
||||||
|
|
||||||
|
/// Validate configuration
|
||||||
|
Config {
|
||||||
|
/// Validate configuration only
|
||||||
|
#[arg(short, long)]
|
||||||
|
validate: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cli {
|
||||||
|
/// Load configuration based on CLI arguments
|
||||||
|
pub fn load_config(&self) -> Result<AppConfig, ConfigError> {
|
||||||
|
let config_path = self
|
||||||
|
.config
|
||||||
|
.as_ref()
|
||||||
|
.or_else(|| match &self.command {
|
||||||
|
Some(Commands::Start { config }) => config.as_ref(),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("config.toml"));
|
||||||
|
|
||||||
|
let mut app_config = if config_path.exists() {
|
||||||
|
AppConfig::from_file(&config_path)?
|
||||||
|
} else {
|
||||||
|
AppConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut overrides = self.overrides.clone();
|
||||||
|
overrides.normalize();
|
||||||
|
app_config.merge_cli_overrides(&overrides);
|
||||||
|
|
||||||
|
app_config.validate()?;
|
||||||
|
|
||||||
|
Ok(app_config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_config() {
|
||||||
|
let config = AppConfig::default();
|
||||||
|
|
||||||
|
assert_eq!(config.capture.frame_rate, 30);
|
||||||
|
assert_eq!(config.capture.quality, QualityLevel::High);
|
||||||
|
assert_eq!(config.encoder.width, 1920);
|
||||||
|
assert_eq!(config.encoder.height, 1080);
|
||||||
|
assert_eq!(config.encoder.bitrate, 4_000_000);
|
||||||
|
assert_eq!(config.webrtc.port, 8443);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_valid_config() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[capture]
|
||||||
|
frame_rate = 60
|
||||||
|
quality = "ultra"
|
||||||
|
|
||||||
|
[encoder]
|
||||||
|
encoder_type = "h264_x264"
|
||||||
|
width = 1920
|
||||||
|
height = 1080
|
||||||
|
frame_rate = 60
|
||||||
|
bitrate = 8000000
|
||||||
|
max_bitrate = 10000000
|
||||||
|
min_bitrate = 500000
|
||||||
|
keyframe_interval = 30
|
||||||
|
|
||||||
|
[webrtc]
|
||||||
|
port = 9000
|
||||||
|
ice_servers = ["stun:stun.l.google.com:19302"]
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let config: AppConfig = toml::from_str(toml_str).unwrap();
|
||||||
|
assert!(config.validate().is_ok());
|
||||||
|
|
||||||
|
assert_eq!(config.capture.frame_rate, 60);
|
||||||
|
assert_eq!(config.capture.quality, QualityLevel::Ultra);
|
||||||
|
assert_eq!(config.encoder.bitrate, 8_000_000);
|
||||||
|
assert_eq!(config.webrtc.port, 9000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cli_overrides() {
|
||||||
|
let mut config = AppConfig::default();
|
||||||
|
let overrides = ConfigOverrides {
|
||||||
|
frame_rate: Some(60),
|
||||||
|
width: Some(1280),
|
||||||
|
height: Some(720),
|
||||||
|
bitrate_mbps: None,
|
||||||
|
bitrate: Some(2_000_000),
|
||||||
|
port: Some(9000),
|
||||||
|
};
|
||||||
|
|
||||||
|
config.merge_cli_overrides(&overrides);
|
||||||
|
|
||||||
|
assert_eq!(config.capture.frame_rate, 60);
|
||||||
|
assert_eq!(config.encoder.width, 1280);
|
||||||
|
assert_eq!(config.encoder.height, 720);
|
||||||
|
assert_eq!(config.encoder.bitrate, 2_000_000);
|
||||||
|
assert_eq!(config.webrtc.port, 9000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_frame_rate() {
|
||||||
|
let config = AppConfig {
|
||||||
|
capture: CaptureConfig {
|
||||||
|
frame_rate: 200,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(config.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_bitrate() {
|
||||||
|
let config = AppConfig {
|
||||||
|
encoder: EncoderConfig {
|
||||||
|
bitrate: 50,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(config.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_bitrate_range() {
|
||||||
|
let config = AppConfig {
|
||||||
|
encoder: EncoderConfig {
|
||||||
|
min_bitrate: 5_000_000,
|
||||||
|
max_bitrate: 4_000_000,
|
||||||
|
bitrate: 4_500_000,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(config.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bitrate_out_of_range() {
|
||||||
|
let config = AppConfig {
|
||||||
|
encoder: EncoderConfig {
|
||||||
|
min_bitrate: 1_000_000,
|
||||||
|
max_bitrate: 5_000_000,
|
||||||
|
bitrate: 10_000_000,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(config.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cli_bitrate_conversion() {
|
||||||
|
let mut overrides = ConfigOverrides {
|
||||||
|
bitrate_mbps: Some(4.5),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
overrides.normalize();
|
||||||
|
|
||||||
|
assert_eq!(overrides.bitrate, Some(4_500_000));
|
||||||
|
}
|
||||||
|
}
|
||||||
705
src/encoder/mod.rs
Normal file
705
src/encoder/mod.rs
Normal file
@@ -0,0 +1,705 @@
|
|||||||
|
//! Video encoder module
|
||||||
|
//!
|
||||||
|
//! This module provides type definitions for video encoding in wl-webrtc,
|
||||||
|
//! including the VideoEncoder trait, configuration types, and data structures
|
||||||
|
//! for encoded frames.
|
||||||
|
|
||||||
|
use crate::capture::CapturedFrame;
|
||||||
|
use crate::error::EncoderError;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// Video encoder trait
|
||||||
|
///
|
||||||
|
/// This trait defines the interface for video encoders, supporting both hardware
|
||||||
|
/// and software encoding implementations.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait VideoEncoder: Send + Sync {
|
||||||
|
/// Encode a captured frame
|
||||||
|
///
|
||||||
|
/// Takes a captured frame and returns an encoded frame with the specified codec.
|
||||||
|
async fn encode(&mut self, frame: CapturedFrame) -> Result<EncodedFrame, EncoderError>;
|
||||||
|
|
||||||
|
/// Reconfigure the encoder with new settings
|
||||||
|
///
|
||||||
|
/// Allows dynamic reconfiguration of encoder parameters during runtime.
|
||||||
|
async fn reconfigure(&mut self, config: EncoderConfig) -> Result<(), EncoderError>;
|
||||||
|
|
||||||
|
/// Request a keyframe (IDR frame)
|
||||||
|
///
|
||||||
|
/// Forces the encoder to produce a keyframe on the next encode operation.
|
||||||
|
async fn request_keyframe(&mut self) -> Result<(), EncoderError>;
|
||||||
|
|
||||||
|
/// Get encoder statistics
|
||||||
|
///
|
||||||
|
/// Returns current statistics about encoder performance and output.
|
||||||
|
fn stats(&self) -> EncoderStats;
|
||||||
|
|
||||||
|
/// Get encoder capabilities
|
||||||
|
///
|
||||||
|
/// Returns information about the encoder's supported features and limits.
|
||||||
|
fn capabilities(&self) -> EncoderCapabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// x264 software encoder implementation
|
||||||
|
///
|
||||||
|
/// Wraps the x264 library for H.264 software encoding with low-latency
|
||||||
|
/// configuration optimized for real-time screen sharing.
|
||||||
|
#[cfg(feature = "x264")]
|
||||||
|
pub struct X264Encoder {
|
||||||
|
/// The x264 encoder instance
|
||||||
|
encoder: x264::Encoder,
|
||||||
|
/// Encoder configuration
|
||||||
|
config: EncoderConfig,
|
||||||
|
/// Frame sequence number
|
||||||
|
sequence_number: u64,
|
||||||
|
/// Frame counter for PTS calculation
|
||||||
|
frame_count: u64,
|
||||||
|
/// RTP timestamp base
|
||||||
|
rtp_timestamp_base: u32,
|
||||||
|
/// Timestamp clock frequency (90 kHz for RTP)
|
||||||
|
rtp_clock_rate: u32,
|
||||||
|
/// Statistics tracking
|
||||||
|
stats: EncoderStats,
|
||||||
|
/// Flag to force keyframe on next frame
|
||||||
|
force_keyframe: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "x264")]
|
||||||
|
impl X264Encoder {
|
||||||
|
/// Create a new x264 encoder with the specified configuration
|
||||||
|
///
|
||||||
|
/// Initializes the encoder with low-latency parameters:
|
||||||
|
/// - Ultrafast preset for maximum speed
|
||||||
|
/// - Zero latency mode (no future frame buffering)
|
||||||
|
/// - Baseline profile for WebRTC compatibility
|
||||||
|
/// - CBR bitrate control
|
||||||
|
/// - Short GOP (8-15 frames) for low latency
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `config` - Encoder configuration including resolution, bitrate, etc.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// A configured X264Encoder ready for encoding
|
||||||
|
pub fn new(config: EncoderConfig) -> Result<Self, EncoderError> {
|
||||||
|
let width = config.width as i32;
|
||||||
|
let height = config.height as i32;
|
||||||
|
|
||||||
|
let mut setup = x264::Setup::preset(
|
||||||
|
x264::Preset::Ultrafast,
|
||||||
|
x264::Tune::None,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
setup = setup.baseline();
|
||||||
|
setup = setup.fps(config.frame_rate, 1);
|
||||||
|
setup = setup.bitrate((config.bitrate / 1000) as i32);
|
||||||
|
setup = setup.max_keyframe_interval(config.keyframe_interval as i32);
|
||||||
|
setup = setup.scenecut_threshold(0);
|
||||||
|
|
||||||
|
let encoder = setup
|
||||||
|
.build(x264::Colorspace::I420, width, height)
|
||||||
|
.map_err(|e| EncoderError::InitializationFailed(format!("Failed to initialize x264 encoder: {}", e)))?;
|
||||||
|
|
||||||
|
let _headers = encoder.headers()
|
||||||
|
.map_err(|e| EncoderError::InitializationFailed(format!("Failed to get encoder headers: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
encoder,
|
||||||
|
config,
|
||||||
|
sequence_number: 0,
|
||||||
|
frame_count: 0,
|
||||||
|
rtp_timestamp_base: 0,
|
||||||
|
rtp_clock_rate: 90000,
|
||||||
|
stats: EncoderStats::default(),
|
||||||
|
force_keyframe: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert RGBA frame data to YUV420P format
|
||||||
|
///
|
||||||
|
/// x264 requires YUV420P format, which consists of three planes:
|
||||||
|
/// - Y plane: luma component at full resolution
|
||||||
|
/// - U plane: chroma blue component at half resolution (2x2 subsampled)
|
||||||
|
/// - V plane: chroma red component at half resolution (2x2 subsampled)
|
||||||
|
///
|
||||||
|
/// This performs a standard ITU-R BT.601 color conversion.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `rgba_data` - Input RGBA pixel data (4 bytes per pixel)
|
||||||
|
/// * `width` - Frame width in pixels
|
||||||
|
/// * `height` - Frame height in pixels
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// A tuple of (Y plane, U plane, V plane) as byte vectors
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn rgba_to_yuv420p(
|
||||||
|
rgba_data: &[u8],
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||||
|
let width = width as usize;
|
||||||
|
let height = height as usize;
|
||||||
|
let pixel_count = width * height;
|
||||||
|
|
||||||
|
// Allocate output buffers
|
||||||
|
let mut y_plane = vec![0u8; pixel_count];
|
||||||
|
let mut u_plane = vec![0u8; pixel_count / 4];
|
||||||
|
let mut v_plane = vec![0u8; pixel_count / 4];
|
||||||
|
|
||||||
|
// Iterate over pixels and convert
|
||||||
|
let mut rgba_idx = 0;
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
// Get RGBA components
|
||||||
|
let r = rgba_data[rgba_idx] as i32;
|
||||||
|
let g = rgba_data[rgba_idx + 1] as i32;
|
||||||
|
let b = rgba_data[rgba_idx + 2] as i32;
|
||||||
|
let a = rgba_data[rgba_idx + 3] as i32;
|
||||||
|
rgba_idx += 4;
|
||||||
|
|
||||||
|
// Convert to YUV using ITU-R BT.601 coefficients
|
||||||
|
let y_val = ((66 * r + 129 * g + 25 * b + 128) >> 8) as u8;
|
||||||
|
let u_val = ((-38 * r - 74 * g + 112 * b + 128) >> 8) as u8;
|
||||||
|
let v_val = ((112 * r - 94 * g - 18 * b + 128) >> 8) as u8;
|
||||||
|
|
||||||
|
// Store Y value
|
||||||
|
y_plane[y * width + x] = y_val;
|
||||||
|
|
||||||
|
// Store UV values at half resolution (2x2 subsampling)
|
||||||
|
if x % 2 == 0 && y % 2 == 0 {
|
||||||
|
let uv_index = (y / 2) * (width / 2) + (x / 2);
|
||||||
|
u_plane[uv_index] = u_val;
|
||||||
|
v_plane[uv_index] = v_val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(y_plane, u_plane, v_plane)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate RTP timestamp from frame timestamp
|
||||||
|
///
|
||||||
|
/// RTP timestamps use a 90 kHz clock for video.
|
||||||
|
/// This converts nanosecond timestamps to RTP timestamps.
|
||||||
|
fn calculate_rtp_timestamp(&self, timestamp_ns: u64) -> u32 {
|
||||||
|
let timestamp_s = timestamp_ns as f64 / 1_000_000_000.0;
|
||||||
|
let rtp_timestamp = (timestamp_s * self.rtp_clock_rate as f64) as u32;
|
||||||
|
rtp_timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update statistics after encoding a frame
|
||||||
|
fn update_stats(&mut self, is_keyframe: bool, data_len: usize, latency_ms: f64) {
|
||||||
|
self.stats.frames_encoded += 1;
|
||||||
|
self.stats.total_bytes += data_len as u64;
|
||||||
|
|
||||||
|
if is_keyframe {
|
||||||
|
self.stats.keyframes += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update average latency
|
||||||
|
if self.stats.frames_encoded > 0 {
|
||||||
|
let total_latency = self.stats.avg_encode_latency_ms * (self.stats.frames_encoded - 1) as f64;
|
||||||
|
self.stats.avg_encode_latency_ms = (total_latency + latency_ms) / self.stats.frames_encoded as f64;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate actual bitrate (approximate based on recent output)
|
||||||
|
// This is a simplified calculation. For production, use a sliding window.
|
||||||
|
if self.stats.frames_encoded > 30 {
|
||||||
|
let bits_per_frame = (data_len as f64 * 8.0);
|
||||||
|
let bits_per_second = bits_per_frame * self.config.frame_rate as f64;
|
||||||
|
self.stats.actual_bitrate = bits_per_second as u32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "x264")]
|
||||||
|
#[async_trait]
|
||||||
|
impl VideoEncoder for X264Encoder {
|
||||||
|
async fn encode(&mut self, frame: CapturedFrame) -> Result<EncodedFrame, EncoderError> {
|
||||||
|
let start_time = Instant::now();
|
||||||
|
|
||||||
|
let rgba_data = self.map_dma_buf(&frame.dma_buf)?;
|
||||||
|
|
||||||
|
let (y_plane, u_plane, v_plane) = Self::rgba_to_yuv420p(
|
||||||
|
rgba_data,
|
||||||
|
frame.width,
|
||||||
|
frame.height,
|
||||||
|
);
|
||||||
|
|
||||||
|
let planes = [
|
||||||
|
x264::Plane {
|
||||||
|
stride: frame.width as i32,
|
||||||
|
data: &y_plane,
|
||||||
|
},
|
||||||
|
x264::Plane {
|
||||||
|
stride: (frame.width / 2) as i32,
|
||||||
|
data: &u_plane,
|
||||||
|
},
|
||||||
|
x264::Plane {
|
||||||
|
stride: (frame.width / 2) as i32,
|
||||||
|
data: &v_plane,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let x264_image = x264::Image::new(
|
||||||
|
x264::Colorspace::I420,
|
||||||
|
frame.width as i32,
|
||||||
|
frame.height as i32,
|
||||||
|
&planes,
|
||||||
|
);
|
||||||
|
|
||||||
|
let pts = self.frame_count as i64;
|
||||||
|
self.frame_count += 1;
|
||||||
|
|
||||||
|
if self.force_keyframe {
|
||||||
|
self.force_keyframe = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (encoded_data, picture) = self
|
||||||
|
.encoder
|
||||||
|
.encode(pts, x264_image)
|
||||||
|
.map_err(|e| EncoderError::EncodingFailed(format!("x264 encoding failed: {}", e)))?;
|
||||||
|
|
||||||
|
let h264_data = encoded_data.entirety();
|
||||||
|
let is_keyframe = picture.keyframe();
|
||||||
|
let rtp_timestamp = self.calculate_rtp_timestamp(frame.timestamp);
|
||||||
|
|
||||||
|
self.sequence_number += 1;
|
||||||
|
|
||||||
|
let bytes = Bytes::copy_from_slice(h264_data);
|
||||||
|
|
||||||
|
let latency_ms = start_time.elapsed().as_secs_f64() * 1000.0;
|
||||||
|
self.update_stats(is_keyframe, h264_data.len(), latency_ms);
|
||||||
|
|
||||||
|
Ok(EncodedFrame {
|
||||||
|
data: bytes,
|
||||||
|
is_keyframe,
|
||||||
|
timestamp: frame.timestamp,
|
||||||
|
sequence_number: self.sequence_number,
|
||||||
|
rtp_timestamp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reconfigure(&mut self, config: EncoderConfig) -> Result<(), EncoderError> {
|
||||||
|
self.config = config.clone();
|
||||||
|
|
||||||
|
let mut setup = x264::Setup::preset(
|
||||||
|
x264::Preset::Ultrafast,
|
||||||
|
x264::Tune::None,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
setup = setup.baseline();
|
||||||
|
setup = setup.fps(config.frame_rate, 1);
|
||||||
|
setup = setup.bitrate((config.bitrate / 1000) as i32);
|
||||||
|
setup = setup.max_keyframe_interval(config.keyframe_interval as i32);
|
||||||
|
setup = setup.scenecut_threshold(0);
|
||||||
|
|
||||||
|
self.encoder = setup
|
||||||
|
.build(
|
||||||
|
x264::Colorspace::I420,
|
||||||
|
config.width as i32,
|
||||||
|
config.height as i32,
|
||||||
|
)
|
||||||
|
.map_err(|e| EncoderError::ReconfigureFailed(format!("Failed to reconfigure encoder: {}", e)))?;
|
||||||
|
|
||||||
|
self.stats = EncoderStats::default();
|
||||||
|
self.frame_count = 0;
|
||||||
|
self.force_keyframe = false;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_keyframe(&mut self) -> Result<(), EncoderError> {
|
||||||
|
self.force_keyframe = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stats(&self) -> EncoderStats {
|
||||||
|
self.stats.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities(&self) -> EncoderCapabilities {
|
||||||
|
EncoderCapabilities {
|
||||||
|
hardware_accelerated: false,
|
||||||
|
supports_dma_buf: false, // Software encoder doesn't support DMA-BUF directly
|
||||||
|
max_resolution: (4096, 4096), // x264 max resolution
|
||||||
|
max_frame_rate: 120,
|
||||||
|
bitrate_range: (100_000, 50_000_000), // 100 kbps to 50 Mbps
|
||||||
|
supports_dynamic_bitrate: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "x264")]
|
||||||
|
impl X264Encoder {
|
||||||
|
/// Map DMA-BUF to CPU memory (simulated)
|
||||||
|
///
|
||||||
|
/// In a real implementation, this would use memmap2 to memory-map
|
||||||
|
/// the DMA-BUF file descriptor for zero-copy access.
|
||||||
|
///
|
||||||
|
/// For this implementation, we simulate DMA-BUF mapping by reading
|
||||||
|
/// from the file descriptor. This is NOT zero-copy in the
|
||||||
|
/// current implementation, but provides the structure for future
|
||||||
|
/// zero-copy implementation.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `dma_buf` - DMA-BUF handle to map
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// A slice of RGBA pixel data
|
||||||
|
fn map_dma_buf(&self, dma_buf: &crate::buffer::DmaBufHandle) -> Result<Vec<u8>, EncoderError> {
|
||||||
|
// In production, use memmap2::MmapOptions::new().map_fd()
|
||||||
|
// to memory-map the DMA-BUF for true zero-copy access
|
||||||
|
//
|
||||||
|
// let mmap = unsafe {
|
||||||
|
// memmap2::MmapOptions::new()
|
||||||
|
// .map_fd(dma_buf.fd())
|
||||||
|
// .map()
|
||||||
|
// .map_err(|e| EncoderError::EncodingFailed(format!("Failed to map DMA-BUF: {}", e)))?
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// let data = mmap.to_vec();
|
||||||
|
|
||||||
|
// For now, simulate mapping by reading from fd
|
||||||
|
// This requires the fd to be readable, which may not always be true
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
use std::os::fd::FromRawFd;
|
||||||
|
|
||||||
|
// Attempt to read the DMA-BUF
|
||||||
|
// Note: This is a simplified implementation
|
||||||
|
// Real DMA-BUF mapping requires proper driver support
|
||||||
|
let fd = dma_buf.fd();
|
||||||
|
|
||||||
|
// Create a buffer of appropriate size
|
||||||
|
let size = dma_buf.size();
|
||||||
|
let mut buffer = vec![0u8; size];
|
||||||
|
|
||||||
|
// Try to read from the file descriptor
|
||||||
|
// This may fail for actual DMA-BUF fds
|
||||||
|
let result = unsafe {
|
||||||
|
libc::read(fd, buffer.as_mut_ptr() as *mut libc::c_void, size)
|
||||||
|
};
|
||||||
|
|
||||||
|
if result < 0 {
|
||||||
|
// Failed to read, return simulated data
|
||||||
|
// This allows the encoder to work for testing
|
||||||
|
return Err(EncoderError::EncodingFailed(
|
||||||
|
"Failed to map DMA-BUF (not a real DMA-BUF)".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encoded video frame
|
||||||
|
///
|
||||||
|
/// Represents a frame after encoding with codec-specific compression.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EncodedFrame {
|
||||||
|
/// Encoded data with zero-copy Bytes wrapper
|
||||||
|
pub data: Bytes,
|
||||||
|
/// Whether this frame is a keyframe (IDR frame)
|
||||||
|
pub is_keyframe: bool,
|
||||||
|
/// Timestamp in nanoseconds
|
||||||
|
pub timestamp: u64,
|
||||||
|
/// Sequence number for frame ordering
|
||||||
|
pub sequence_number: u64,
|
||||||
|
/// RTP timestamp for packetization
|
||||||
|
pub rtp_timestamp: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encoder configuration
|
||||||
|
///
|
||||||
|
/// Configuration parameters for the video encoder.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EncoderConfig {
|
||||||
|
/// Encoder type (H264, H265, VP9 with hardware/software variants)
|
||||||
|
pub encoder_type: EncoderType,
|
||||||
|
/// Video width in pixels
|
||||||
|
pub width: u32,
|
||||||
|
/// Video height in pixels
|
||||||
|
pub height: u32,
|
||||||
|
/// Target frame rate in frames per second
|
||||||
|
pub frame_rate: u32,
|
||||||
|
/// Target bitrate in bits per second
|
||||||
|
pub bitrate: u32,
|
||||||
|
/// Maximum bitrate in bits per second
|
||||||
|
pub max_bitrate: u32,
|
||||||
|
/// Minimum bitrate in bits per second
|
||||||
|
pub min_bitrate: u32,
|
||||||
|
/// Keyframe interval in frames
|
||||||
|
pub keyframe_interval: u32,
|
||||||
|
/// Encoding preset (speed vs compression tradeoff)
|
||||||
|
pub preset: EncodePreset,
|
||||||
|
/// Encoding tune (content type optimization)
|
||||||
|
pub tune: EncodeTune,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encoder type
|
||||||
|
///
|
||||||
|
/// Supported encoder variants with different codec and hardware acceleration options.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum EncoderType {
|
||||||
|
/// H.264 with VA-API hardware encoding
|
||||||
|
H264_VAAPI,
|
||||||
|
/// H.264 with NVIDIA NVENC hardware encoding
|
||||||
|
H264_NVENC,
|
||||||
|
/// H.264 with x264 software encoding
|
||||||
|
H264_X264,
|
||||||
|
/// H.265 (HEVC) with VA-API hardware encoding
|
||||||
|
H265_VAAPI,
|
||||||
|
/// VP9 with VA-API hardware encoding
|
||||||
|
VP9_VAAPI,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encoding preset
|
||||||
|
///
|
||||||
|
/// Trade-off between encoding speed and compression efficiency.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum EncodePreset {
|
||||||
|
/// Ultra-fast encoding, minimum latency, lowest compression
|
||||||
|
Ultrafast,
|
||||||
|
/// Super-fast encoding
|
||||||
|
Superfast,
|
||||||
|
/// Very fast encoding
|
||||||
|
Veryfast,
|
||||||
|
/// Faster encoding
|
||||||
|
Faster,
|
||||||
|
/// Fast encoding
|
||||||
|
Fast,
|
||||||
|
/// Medium speed/quality balance
|
||||||
|
Medium,
|
||||||
|
/// Slower encoding for better compression
|
||||||
|
Slow,
|
||||||
|
/// Even slower encoding
|
||||||
|
Slower,
|
||||||
|
/// Very slow encoding, maximum compression
|
||||||
|
Veryslow,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encoding tune
|
||||||
|
///
|
||||||
|
/// Optimization for different content types and scenarios.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum EncodeTune {
|
||||||
|
/// Zero-latency mode for real-time applications
|
||||||
|
Zerolatency,
|
||||||
|
/// Film content optimization
|
||||||
|
Film,
|
||||||
|
/// Animation content optimization
|
||||||
|
Animation,
|
||||||
|
/// Film grain preservation
|
||||||
|
Grain,
|
||||||
|
/// Static image optimization
|
||||||
|
Stillimage,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encoder statistics
|
||||||
|
///
|
||||||
|
/// Runtime statistics about encoder performance.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct EncoderStats {
|
||||||
|
/// Total number of frames encoded
|
||||||
|
pub frames_encoded: u64,
|
||||||
|
/// Total number of keyframes encoded
|
||||||
|
pub keyframes: u64,
|
||||||
|
/// Average encoding latency in milliseconds
|
||||||
|
pub avg_encode_latency_ms: f64,
|
||||||
|
/// Total output bytes produced
|
||||||
|
pub total_bytes: u64,
|
||||||
|
/// Actual bitrate in bits per second
|
||||||
|
pub actual_bitrate: u32,
|
||||||
|
/// Number of dropped frames
|
||||||
|
pub dropped_frames: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encoder capabilities
|
||||||
|
///
|
||||||
|
/// Information about supported features and limits of the encoder.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct EncoderCapabilities {
|
||||||
|
/// Whether hardware acceleration is available
|
||||||
|
pub hardware_accelerated: bool,
|
||||||
|
/// Whether DMA-BUF import is supported
|
||||||
|
pub supports_dma_buf: bool,
|
||||||
|
/// Maximum supported resolution as (width, height)
|
||||||
|
pub max_resolution: (u32, u32),
|
||||||
|
/// Maximum supported frame rate
|
||||||
|
pub max_frame_rate: u32,
|
||||||
|
/// Supported bitrate range as (min, max) in bits per second
|
||||||
|
pub bitrate_range: (u32, u32),
|
||||||
|
/// Whether dynamic bitrate adjustment is supported
|
||||||
|
pub supports_dynamic_bitrate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encoder_config_creation() {
|
||||||
|
let config = EncoderConfig {
|
||||||
|
encoder_type: EncoderType::H264_X264,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
frame_rate: 30,
|
||||||
|
bitrate: 4_000_000,
|
||||||
|
max_bitrate: 5_000_000,
|
||||||
|
min_bitrate: 3_000_000,
|
||||||
|
keyframe_interval: 15,
|
||||||
|
preset: EncodePreset::Ultrafast,
|
||||||
|
tune: EncodeTune::Zerolatency,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(config.width, 1920);
|
||||||
|
assert_eq!(config.height, 1080);
|
||||||
|
assert_eq!(config.frame_rate, 30);
|
||||||
|
assert_eq!(config.bitrate, 4_000_000);
|
||||||
|
assert_eq!(config.keyframe_interval, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encoded_frame_creation() {
|
||||||
|
let data = Bytes::from(vec![1u8, 2, 3, 4]);
|
||||||
|
let frame = EncodedFrame {
|
||||||
|
data: data.clone(),
|
||||||
|
is_keyframe: true,
|
||||||
|
timestamp: 1234567890,
|
||||||
|
sequence_number: 42,
|
||||||
|
rtp_timestamp: 12345,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(frame.is_keyframe);
|
||||||
|
assert_eq!(frame.timestamp, 1234567890);
|
||||||
|
assert_eq!(frame.sequence_number, 42);
|
||||||
|
assert_eq!(frame.rtp_timestamp, 12345);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encoder_stats_initialization() {
|
||||||
|
let stats = EncoderStats::default();
|
||||||
|
|
||||||
|
assert_eq!(stats.frames_encoded, 0);
|
||||||
|
assert_eq!(stats.keyframes, 0);
|
||||||
|
assert_eq!(stats.total_bytes, 0);
|
||||||
|
assert_eq!(stats.dropped_frames, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encoder_capabilities() {
|
||||||
|
let caps = EncoderCapabilities {
|
||||||
|
hardware_accelerated: false,
|
||||||
|
supports_dma_buf: false,
|
||||||
|
max_resolution: (1920, 1080),
|
||||||
|
max_frame_rate: 60,
|
||||||
|
bitrate_range: (1_000_000, 10_000_000),
|
||||||
|
supports_dynamic_bitrate: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!caps.hardware_accelerated);
|
||||||
|
assert!(!caps.supports_dma_buf);
|
||||||
|
assert_eq!(caps.max_resolution, (1920, 1080));
|
||||||
|
assert_eq!(caps.max_frame_rate, 60);
|
||||||
|
assert_eq!(caps.bitrate_range, (1_000_000, 10_000_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encoder_types() {
|
||||||
|
let types = [
|
||||||
|
EncoderType::H264_VAAPI,
|
||||||
|
EncoderType::H264_NVENC,
|
||||||
|
EncoderType::H264_X264,
|
||||||
|
EncoderType::H265_VAAPI,
|
||||||
|
EncoderType::VP9_VAAPI,
|
||||||
|
];
|
||||||
|
|
||||||
|
for encoder_type in types {
|
||||||
|
assert_eq!(encoder_type, encoder_type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encode_presets() {
|
||||||
|
let presets = [
|
||||||
|
EncodePreset::Ultrafast,
|
||||||
|
EncodePreset::Superfast,
|
||||||
|
EncodePreset::Veryfast,
|
||||||
|
EncodePreset::Faster,
|
||||||
|
EncodePreset::Fast,
|
||||||
|
EncodePreset::Medium,
|
||||||
|
EncodePreset::Slow,
|
||||||
|
EncodePreset::Slower,
|
||||||
|
EncodePreset::Veryslow,
|
||||||
|
];
|
||||||
|
|
||||||
|
for preset in presets {
|
||||||
|
assert_eq!(preset, preset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encode_tunes() {
|
||||||
|
let tunes = [
|
||||||
|
EncodeTune::Zerolatency,
|
||||||
|
EncodeTune::Film,
|
||||||
|
EncodeTune::Animation,
|
||||||
|
EncodeTune::Grain,
|
||||||
|
EncodeTune::Stillimage,
|
||||||
|
];
|
||||||
|
|
||||||
|
for tune in tunes {
|
||||||
|
assert_eq!(tune, tune);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "x264")]
|
||||||
|
#[test]
|
||||||
|
fn test_x264_rgba_to_yuv420p_conversion() {
|
||||||
|
let rgba_data = vec![
|
||||||
|
255u8, 0, 0, 255, // Red pixel
|
||||||
|
0, 255, 0, 255, // Green pixel
|
||||||
|
0, 0, 255, 255, // Blue pixel
|
||||||
|
255, 255, 255, 255, // White pixel
|
||||||
|
];
|
||||||
|
|
||||||
|
let (y_plane, u_plane, v_plane) = X264Encoder::rgba_to_yuv420p(&rgba_data, 2, 2);
|
||||||
|
|
||||||
|
// Check Y plane (should have values for luma)
|
||||||
|
assert_eq!(y_plane.len(), 4);
|
||||||
|
|
||||||
|
// Check UV planes (2x2 subsampled)
|
||||||
|
assert_eq!(u_plane.len(), 1);
|
||||||
|
assert_eq!(v_plane.len(), 1);
|
||||||
|
|
||||||
|
// RGB(255, 0, 0) -> Y=76, U=85, V=255 (red)
|
||||||
|
assert!(y_plane[0] > 70 && y_plane[0] < 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "x264")]
|
||||||
|
#[test]
|
||||||
|
fn test_x264_rgba_to_yuv420p_resolution() {
|
||||||
|
let width = 1920u32;
|
||||||
|
let height = 1080u32;
|
||||||
|
let pixel_count = (width * height) as usize;
|
||||||
|
let rgba_size = pixel_count * 4;
|
||||||
|
|
||||||
|
let rgba_data = vec![128u8; rgba_size];
|
||||||
|
|
||||||
|
let (y_plane, u_plane, v_plane) = X264Encoder::rgba_to_yuv420p(&rgba_data, width, height);
|
||||||
|
|
||||||
|
// Y plane: full resolution
|
||||||
|
assert_eq!(y_plane.len(), pixel_count);
|
||||||
|
|
||||||
|
// UV planes: quarter resolution (2x2 subsampling)
|
||||||
|
assert_eq!(u_plane.len(), pixel_count / 4);
|
||||||
|
assert_eq!(v_plane.len(), pixel_count / 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
255
src/error.rs
Normal file
255
src/error.rs
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
//! Centralized error types for wl-webrtc
|
||||||
|
//!
|
||||||
|
//! This module defines all error types used throughout the wl-webrtc project,
|
||||||
|
//! organized by functional module and wrapped in a master Error enum.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Errors that can occur during screen capture via PipeWire
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum CaptureError {
|
||||||
|
/// PipeWire initialization failed
|
||||||
|
#[error("PipeWire initialization failed: {0}")]
|
||||||
|
InitializationFailed(String),
|
||||||
|
|
||||||
|
/// Stream creation failed
|
||||||
|
#[error("Stream creation failed: {0}")]
|
||||||
|
StreamCreationFailed(String),
|
||||||
|
|
||||||
|
/// Buffer acquisition failed
|
||||||
|
#[error("Buffer acquisition failed")]
|
||||||
|
BufferAcquisitionFailed,
|
||||||
|
|
||||||
|
/// DMA-BUF extraction failed
|
||||||
|
#[error("DMA-BUF extraction failed")]
|
||||||
|
DmaBufExtractionFailed,
|
||||||
|
|
||||||
|
/// Screen capture permission denied
|
||||||
|
#[error("Screen capture permission denied")]
|
||||||
|
PermissionDenied,
|
||||||
|
|
||||||
|
/// Invalid frame format
|
||||||
|
#[error("Invalid frame format: {0}")]
|
||||||
|
InvalidFormat(String),
|
||||||
|
|
||||||
|
/// Connection lost to PipeWire
|
||||||
|
#[error("Connection lost to PipeWire")]
|
||||||
|
ConnectionLost,
|
||||||
|
|
||||||
|
/// xdg-desktop-portal error
|
||||||
|
#[error("xdg-desktop-portal error: {0}")]
|
||||||
|
PortalError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors that can occur during video encoding
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum EncoderError {
|
||||||
|
/// Encoder initialization failed
|
||||||
|
#[error("Encoder initialization failed: {0}")]
|
||||||
|
InitializationFailed(String),
|
||||||
|
|
||||||
|
/// Frame encoding failed
|
||||||
|
#[error("Frame encoding failed: {0}")]
|
||||||
|
EncodingFailed(String),
|
||||||
|
|
||||||
|
/// Invalid encoder configuration
|
||||||
|
#[error("Invalid encoder configuration: {0}")]
|
||||||
|
InvalidConfiguration(String),
|
||||||
|
|
||||||
|
/// Bitrate adjustment failed
|
||||||
|
#[error("Bitrate adjustment failed: {0}")]
|
||||||
|
BitrateAdjustmentFailed(String),
|
||||||
|
|
||||||
|
/// Keyframe request failed
|
||||||
|
#[error("Keyframe request failed: {0}")]
|
||||||
|
KeyframeRequestFailed(String),
|
||||||
|
|
||||||
|
/// Hardware encoder not available
|
||||||
|
#[error("Hardware encoder not available: {0}")]
|
||||||
|
HardwareUnavailable(String),
|
||||||
|
|
||||||
|
/// Surface import failed
|
||||||
|
#[error("Surface import failed: {0}")]
|
||||||
|
SurfaceImportFailed(String),
|
||||||
|
|
||||||
|
/// Codec not supported
|
||||||
|
#[error("Codec not supported: {0}")]
|
||||||
|
CodecNotSupported(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors that can occur during WebRTC transport
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum WebRtcError {
|
||||||
|
/// Peer connection creation failed
|
||||||
|
#[error("Peer connection creation failed: {0}")]
|
||||||
|
PeerConnectionCreationFailed(String),
|
||||||
|
|
||||||
|
/// ICE connection failed
|
||||||
|
#[error("ICE connection failed: {0}")]
|
||||||
|
IceConnectionFailed(String),
|
||||||
|
|
||||||
|
/// SDP exchange failed
|
||||||
|
#[error("SDP exchange failed: {0}")]
|
||||||
|
SdpExchangeFailed(String),
|
||||||
|
|
||||||
|
/// Track addition failed
|
||||||
|
#[error("Track addition failed: {0}")]
|
||||||
|
TrackAdditionFailed(String),
|
||||||
|
|
||||||
|
/// Data channel error
|
||||||
|
#[error("Data channel error: {0}")]
|
||||||
|
DataChannelError(String),
|
||||||
|
|
||||||
|
/// Session not found
|
||||||
|
#[error("Session not found: {0}")]
|
||||||
|
SessionNotFound(String),
|
||||||
|
|
||||||
|
/// Codec negotiation failed
|
||||||
|
#[error("Codec negotiation failed: {0}")]
|
||||||
|
CodecNegotiationFailed(String),
|
||||||
|
|
||||||
|
/// Transport state error
|
||||||
|
#[error("Transport state error: {0}")]
|
||||||
|
TransportStateError(String),
|
||||||
|
|
||||||
|
/// RTP packet send failed
|
||||||
|
#[error("RTP packet send failed: {0}")]
|
||||||
|
RtpSendFailed(String),
|
||||||
|
|
||||||
|
/// Internal error
|
||||||
|
#[error("Internal error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<webrtc::Error> for WebRtcError {
|
||||||
|
fn from(err: webrtc::Error) -> Self {
|
||||||
|
WebRtcError::Internal(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors that can occur during WebSocket signaling
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum SignalingError {
|
||||||
|
/// WebSocket connection failed
|
||||||
|
#[error("WebSocket connection failed: {0}")]
|
||||||
|
ConnectionFailed(String),
|
||||||
|
|
||||||
|
/// Message send failed
|
||||||
|
#[error("Message send failed: {0}")]
|
||||||
|
SendFailed(String),
|
||||||
|
|
||||||
|
/// Message receive failed
|
||||||
|
#[error("Message receive failed: {0}")]
|
||||||
|
ReceiveFailed(String),
|
||||||
|
|
||||||
|
/// Invalid message format
|
||||||
|
#[error("Invalid message format: {0}")]
|
||||||
|
InvalidMessage(String),
|
||||||
|
|
||||||
|
/// Serialization error
|
||||||
|
#[error("Serialization error: {0}")]
|
||||||
|
SerializationError(String),
|
||||||
|
|
||||||
|
/// Deserialization error
|
||||||
|
#[error("Deserialization error: {0}")]
|
||||||
|
DeserializationError(String),
|
||||||
|
|
||||||
|
/// Signaling protocol error
|
||||||
|
#[error("Signaling protocol error: {0}")]
|
||||||
|
ProtocolError(String),
|
||||||
|
|
||||||
|
/// Authentication failed
|
||||||
|
#[error("Authentication failed")]
|
||||||
|
AuthenticationFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Master error type that wraps all module-specific errors
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
/// Capture-related errors
|
||||||
|
#[error("Capture error: {0}")]
|
||||||
|
Capture(#[from] CaptureError),
|
||||||
|
|
||||||
|
/// Encoder-related errors
|
||||||
|
#[error("Encoder error: {0}")]
|
||||||
|
Encoder(#[from] EncoderError),
|
||||||
|
|
||||||
|
/// WebRTC transport-related errors
|
||||||
|
#[error("WebRTC error: {0}")]
|
||||||
|
WebRtc(#[from] WebRtcError),
|
||||||
|
|
||||||
|
/// Signaling-related errors
|
||||||
|
#[error("Signaling error: {0}")]
|
||||||
|
Signaling(#[from] SignalingError),
|
||||||
|
|
||||||
|
/// IO errors
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
/// Configuration errors
|
||||||
|
#[error("Configuration error: {0}")]
|
||||||
|
Configuration(String),
|
||||||
|
|
||||||
|
/// Generic errors
|
||||||
|
#[error("Internal error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for SignalingError {
|
||||||
|
fn from(err: serde_json::Error) -> Self {
|
||||||
|
if err.is_io() {
|
||||||
|
SignalingError::DeserializationError(err.to_string())
|
||||||
|
} else {
|
||||||
|
SignalingError::SerializationError(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for Error {
|
||||||
|
fn from(err: serde_json::Error) -> Self {
|
||||||
|
Error::Signaling(err.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_capture_error_display() {
|
||||||
|
let err = CaptureError::InitializationFailed("test".to_string());
|
||||||
|
assert!(err.to_string().contains("PipeWire initialization failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encoder_error_display() {
|
||||||
|
let err = EncoderError::EncodingFailed("test".to_string());
|
||||||
|
assert!(err.to_string().contains("Frame encoding failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_webrtc_error_display() {
|
||||||
|
let err = WebRtcError::IceConnectionFailed("test".to_string());
|
||||||
|
assert!(err.to_string().contains("ICE connection failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_signaling_error_display() {
|
||||||
|
let err = SignalingError::ConnectionFailed("test".to_string());
|
||||||
|
assert!(err.to_string().contains("WebSocket connection failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_master_error_from_capture() {
|
||||||
|
let capture_err = CaptureError::PermissionDenied;
|
||||||
|
let master_err: Error = capture_err.into();
|
||||||
|
assert!(matches!(master_err, Error::Capture(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_master_error_from_io() {
|
||||||
|
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "test");
|
||||||
|
let master_err: Error = io_err.into();
|
||||||
|
assert!(matches!(master_err, Error::Io(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/lib.rs
Normal file
25
src/lib.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
//! wl-webrtc: Wayland to WebRTC remote desktop backend
|
||||||
|
//!
|
||||||
|
//! This library provides high-performance screen capture from Wayland compositors
|
||||||
|
//! via PipeWire, encoding to H.264, and streaming via WebRTC.
|
||||||
|
//!
|
||||||
|
//! ## Modules
|
||||||
|
//!
|
||||||
|
//! - [`config`]: Configuration management
|
||||||
|
//! - [`error`]: Centralized error types
|
||||||
|
//! - [`capture`]: PipeWire screen capture
|
||||||
|
//! - [`encoder`]: Video encoding (H.264, H.265, VP9)
|
||||||
|
//! - [`buffer`]: Zero-copy buffer management
|
||||||
|
//! - [`webrtc`]: WebRTC transport layer
|
||||||
|
//! - [`signaling`]: WebSocket signaling server
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod error;
|
||||||
|
pub mod capture;
|
||||||
|
pub mod encoder;
|
||||||
|
pub mod buffer;
|
||||||
|
pub mod webrtc;
|
||||||
|
pub mod signaling;
|
||||||
|
|
||||||
|
pub use config::AppConfig;
|
||||||
|
pub use error::Error;
|
||||||
717
src/main.rs
Normal file
717
src/main.rs
Normal file
@@ -0,0 +1,717 @@
|
|||||||
|
//! wl-webrtc: Wayland to WebRTC remote desktop backend
|
||||||
|
//!
|
||||||
|
//! This is the main entry point for wl-webrtc remote desktop streaming server.
|
||||||
|
//! It orchestrates the complete pipeline:
|
||||||
|
//! Capture (PipeWire/DMA-BUF) → Encoder (H.264) → WebRTC Transport → Network
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::Parser;
|
||||||
|
use tokio::signal;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tracing::{debug, error, info, warn, Level};
|
||||||
|
use tracing_subscriber::{EnvFilter, fmt};
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
use config::{AppConfig, Cli, Commands, ConfigError};
|
||||||
|
|
||||||
|
// Re-export modules
|
||||||
|
use wl_webrtc::{
|
||||||
|
capture::CaptureManager,
|
||||||
|
encoder::{EncoderConfig, EncoderType, EncodePreset, EncodeTune, VideoEncoder},
|
||||||
|
error::Error,
|
||||||
|
webrtc::{WebRtcConfig, WebRtcServer},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Application state for managing the streaming pipeline
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct AppState {
|
||||||
|
capture_manager: Option<CaptureManager>,
|
||||||
|
encoder: Option<wl_webrtc::encoder::X264Encoder>,
|
||||||
|
webrtc_server: Option<WebRtcServer>,
|
||||||
|
metrics: Metrics,
|
||||||
|
is_running: bool,
|
||||||
|
start_time: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pipeline metrics for tracking performance
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
struct Metrics {
|
||||||
|
capture_fps: f64,
|
||||||
|
avg_encode_latency_ms: f64,
|
||||||
|
output_bitrate: u32,
|
||||||
|
webrtc_pps: f64,
|
||||||
|
total_frames: u64,
|
||||||
|
encode_errors: u64,
|
||||||
|
webrtc_errors: u64,
|
||||||
|
fps_start_time: Option<Instant>,
|
||||||
|
last_update: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Metrics {
|
||||||
|
fn update_frame(&mut self, encode_latency_ms: f64, frame_size: usize) {
|
||||||
|
self.total_frames += 1;
|
||||||
|
|
||||||
|
if self.avg_encode_latency_ms == 0.0 {
|
||||||
|
self.avg_encode_latency_ms = encode_latency_ms;
|
||||||
|
} else {
|
||||||
|
self.avg_encode_latency_ms = 0.9 * self.avg_encode_latency_ms + 0.1 * encode_latency_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(start) = self.fps_start_time {
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
if elapsed.as_secs() > 0 {
|
||||||
|
self.capture_fps = self.total_frames as f64 / elapsed.as_secs() as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.capture_fps > 0.0 {
|
||||||
|
self.output_bitrate = (frame_size as u64 * 8 * self.capture_fps as u64) as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.last_update = Some(Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self) {
|
||||||
|
info!(
|
||||||
|
"Metrics: FPS={:.1} fps, EncodeLatency={:.2}ms, Bitrate={:.2}Mbps, WebrtcPPS={:.1}, Frames={}",
|
||||||
|
self.capture_fps,
|
||||||
|
self.avg_encode_latency_ms,
|
||||||
|
self.output_bitrate as f64 / 1_000_000.0,
|
||||||
|
self.webrtc_pps,
|
||||||
|
self.total_frames
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn increment_encode_error(&mut self) {
|
||||||
|
self.encode_errors += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn increment_webrtc_error(&mut self) {
|
||||||
|
self.webrtc_errors += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_tracking(&mut self) {
|
||||||
|
self.total_frames = 0;
|
||||||
|
self.fps_start_time = Some(Instant::now());
|
||||||
|
self.last_update = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_running(&self) -> bool {
|
||||||
|
self.is_running
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&mut self) {
|
||||||
|
self.is_running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uptime(&self) -> Option<Duration> {
|
||||||
|
self.start_time.map(|t| t.elapsed())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_encoder_config(config: &AppConfig) -> wl_webrtc::encoder::EncoderConfig {
|
||||||
|
wl_webrtc::encoder::EncoderConfig {
|
||||||
|
encoder_type: wl_webrtc::encoder::EncoderType::H264_X264,
|
||||||
|
width: config.encoder.width,
|
||||||
|
height: config.encoder.height,
|
||||||
|
frame_rate: config.encoder.frame_rate,
|
||||||
|
bitrate: config.encoder.bitrate,
|
||||||
|
max_bitrate: config.encoder.max_bitrate,
|
||||||
|
min_bitrate: config.encoder.min_bitrate,
|
||||||
|
keyframe_interval: config.encoder.keyframe_interval,
|
||||||
|
preset: wl_webrtc::encoder::EncodePreset::Veryfast,
|
||||||
|
tune: wl_webrtc::encoder::EncodeTune::Zerolatency,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_webrtc_config(config: &AppConfig) -> wl_webrtc::webrtc::WebRtcConfig {
|
||||||
|
wl_webrtc::webrtc::WebRtcConfig {
|
||||||
|
port: config.webrtc.port,
|
||||||
|
ice_servers: config.webrtc.ice_servers.clone(),
|
||||||
|
stun_servers: config.webrtc.stun_servers.clone(),
|
||||||
|
turn_servers: config.webrtc.turn_servers.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_capture_manager(
|
||||||
|
config: &AppConfig,
|
||||||
|
) -> Result<CaptureManager> {
|
||||||
|
info!("Initializing capture manager");
|
||||||
|
|
||||||
|
let mut capture_manager = CaptureManager::new(config.capture.clone())
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to create capture manager: {}", e))?;
|
||||||
|
|
||||||
|
#[cfg(feature = "pipewire")]
|
||||||
|
{
|
||||||
|
if let Err(e) = capture_manager.start(None).await {
|
||||||
|
error!("Failed to start capture manager: {}", e);
|
||||||
|
return Err(anyhow::anyhow!("Failed to start capture: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "pipewire"))]
|
||||||
|
{
|
||||||
|
warn!("PipeWire feature disabled, capture manager will not start");
|
||||||
|
return Err(anyhow::anyhow!("PipeWire feature not enabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Capture manager started successfully");
|
||||||
|
Ok(capture_manager)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_encoder(config: &AppConfig) -> Result<wl_webrtc::encoder::X264Encoder> {
|
||||||
|
info!("Initializing H.264 encoder");
|
||||||
|
|
||||||
|
let encoder_config = get_encoder_config(config);
|
||||||
|
|
||||||
|
#[cfg(feature = "x264")]
|
||||||
|
let encoder = wl_webrtc::encoder::X264Encoder::new(encoder_config)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to create encoder: {}", e))?;
|
||||||
|
|
||||||
|
#[cfg(not(feature = "x264"))]
|
||||||
|
{
|
||||||
|
warn!("x264 feature disabled, encoder will not work");
|
||||||
|
return Err(anyhow::anyhow!("x264 feature not enabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Encoder initialized: {}x{} @ {}fps, {}Mbps",
|
||||||
|
config.encoder.width,
|
||||||
|
config.encoder.height,
|
||||||
|
config.encoder.frame_rate,
|
||||||
|
config.encoder.bitrate as f64 / 1_000_000.0);
|
||||||
|
|
||||||
|
Ok(encoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_webrtc_server(
|
||||||
|
config: &AppConfig,
|
||||||
|
) -> Result<WebRtcServer> {
|
||||||
|
info!("Initializing WebRTC server");
|
||||||
|
|
||||||
|
let webrtc_config = get_webrtc_config(config);
|
||||||
|
|
||||||
|
let mut webrtc_server = WebRtcServer::new(webrtc_config).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to create WebRTC server: {}", e))?;
|
||||||
|
|
||||||
|
webrtc_server.start_frame_distribution().await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to start frame distribution: {}", e))?;
|
||||||
|
|
||||||
|
info!("WebRTC server listening on port {}", config.webrtc.port);
|
||||||
|
Ok(webrtc_server)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_pipeline(state: Arc<RwLock<AppState>>) -> Result<()> {
|
||||||
|
info!("Starting pipeline: Capture → Encoder → WebRTC");
|
||||||
|
|
||||||
|
let mut last_metrics_log = Instant::now();
|
||||||
|
|
||||||
|
while {
|
||||||
|
let state_guard = state.read().await;
|
||||||
|
state_guard.is_running
|
||||||
|
} {
|
||||||
|
let (capture_receiver, webrtc_frame_sender) = {
|
||||||
|
let state_guard = state.read().await;
|
||||||
|
|
||||||
|
let capture_manager = state_guard.capture_manager.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Capture manager not initialized"))?;
|
||||||
|
|
||||||
|
let webrtc_server = state_guard.webrtc_server.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("WebRTC server not initialized"))?;
|
||||||
|
|
||||||
|
(
|
||||||
|
capture_manager.frame_receiver().clone(),
|
||||||
|
webrtc_server.video_frame_sender(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let capture_start = Instant::now();
|
||||||
|
let frame_result = tokio::time::timeout(
|
||||||
|
Duration::from_secs(1),
|
||||||
|
capture_receiver.recv()
|
||||||
|
).await;
|
||||||
|
|
||||||
|
match frame_result {
|
||||||
|
Ok(Ok(frame)) => {
|
||||||
|
debug!("Captured frame: {}x{}, timestamp={}",
|
||||||
|
frame.width, frame.height, frame.timestamp);
|
||||||
|
|
||||||
|
let encode_start = Instant::now();
|
||||||
|
let mut state_guard = state.write().await;
|
||||||
|
|
||||||
|
if let Some(encoder) = &mut state_guard.encoder {
|
||||||
|
match encoder.encode(frame).await {
|
||||||
|
Ok(encoded_frame) => {
|
||||||
|
let encode_latency_ms = encode_start.elapsed().as_secs_f64() * 1000.0;
|
||||||
|
|
||||||
|
debug!("Encoded frame: size={}bytes, keyframe={}, latency={:.2}ms",
|
||||||
|
encoded_frame.data.len(),
|
||||||
|
encoded_frame.is_keyframe,
|
||||||
|
encode_latency_ms);
|
||||||
|
|
||||||
|
state_guard.metrics.update_frame(encode_latency_ms, encoded_frame.data.len());
|
||||||
|
|
||||||
|
if let Some(webrtc_server) = &state_guard.webrtc_server {
|
||||||
|
let _ = webrtc_frame_sender.try_send(("demo_session".to_string(), encoded_frame));
|
||||||
|
}
|
||||||
|
|
||||||
|
let last_log = state_guard.metrics.last_update.unwrap_or(Instant::now());
|
||||||
|
if last_log.elapsed() >= Duration::from_secs(5) {
|
||||||
|
state_guard.metrics.log();
|
||||||
|
last_metrics_log = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Encoding error: {:?}", e);
|
||||||
|
state_guard.metrics.increment_encode_error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(state_guard);
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
debug!("Frame receive error: {:?}", e);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
debug!("No frame received within timeout");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Pipeline stopped");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_shutdown(
|
||||||
|
state: Arc<RwLock<AppState>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
info!("Initiating graceful shutdown");
|
||||||
|
|
||||||
|
state.write().await.stop();
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
|
||||||
|
{
|
||||||
|
let state_guard = state.read().await;
|
||||||
|
info!("Final metrics:");
|
||||||
|
state_guard.metrics.log();
|
||||||
|
info!("Total frames: {}, Encode errors: {}, WebRTC errors: {}",
|
||||||
|
state_guard.metrics.total_frames,
|
||||||
|
state_guard.metrics.encode_errors,
|
||||||
|
state_guard.metrics.webrtc_errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut state_guard = state.write().await;
|
||||||
|
if let Some(webrtc_server) = state_guard.webrtc_server.take() {
|
||||||
|
info!("Closing WebRTC server");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(_capture) = state_guard.capture_manager.take() {
|
||||||
|
info!("Capture manager stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(_encoder) = state_guard.encoder.take() {
|
||||||
|
info!("Encoder stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Graceful shutdown completed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn attempt_module_recovery(
|
||||||
|
state: Arc<RwLock<AppState>>,
|
||||||
|
config: AppConfig,
|
||||||
|
) -> Result<()> {
|
||||||
|
warn!("Attempting module recovery");
|
||||||
|
|
||||||
|
if state.read().await.capture_manager.is_none() {
|
||||||
|
info!("Restarting capture manager");
|
||||||
|
match start_capture_manager(&config).await {
|
||||||
|
Ok(capture) => {
|
||||||
|
state.write().await.capture_manager = Some(capture);
|
||||||
|
info!("Capture manager restarted successfully");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to restart capture manager: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.read().await.encoder.is_none() {
|
||||||
|
info!("Restarting encoder");
|
||||||
|
match start_encoder(&config) {
|
||||||
|
Ok(encoder) => {
|
||||||
|
state.write().await.encoder = Some(encoder);
|
||||||
|
info!("Encoder restarted successfully");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to restart encoder: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.read().await.webrtc_server.is_none() {
|
||||||
|
info!("Restarting WebRTC server");
|
||||||
|
match start_webrtc_server(&config).await {
|
||||||
|
Ok(webrtc) => {
|
||||||
|
state.write().await.webrtc_server = Some(webrtc);
|
||||||
|
info!("WebRTC server restarted successfully");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to restart WebRTC server: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_logging(verbose: bool, log_level: Option<&str>) -> Result<()> {
|
||||||
|
let filter = if let Some(level) = log_level {
|
||||||
|
EnvFilter::try_from_default_env()
|
||||||
|
.or_else(|_| EnvFilter::try_new(level))
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new(if verbose { "debug" } else { "info" }))
|
||||||
|
} else {
|
||||||
|
EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new(if verbose { "debug" } else { "info" }))
|
||||||
|
};
|
||||||
|
|
||||||
|
fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(false)
|
||||||
|
.with_thread_ids(verbose)
|
||||||
|
.with_file(verbose)
|
||||||
|
.with_line_number(verbose)
|
||||||
|
.try_init()
|
||||||
|
.context("Failed to initialize logging")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_start(config_path: Option<std::path::PathBuf>, port_override: Option<u16>) -> anyhow::Result<()> {
|
||||||
|
info!("Starting wl-webrtc server...");
|
||||||
|
|
||||||
|
let mut config = if let Some(path) = config_path {
|
||||||
|
if path.exists() {
|
||||||
|
AppConfig::from_file(&path)
|
||||||
|
.context(|| format!("Failed to load config from {}", path.display()))?
|
||||||
|
} else {
|
||||||
|
warn!("Config file {} not found, using defaults", path.display());
|
||||||
|
AppConfig::default()
|
||||||
|
}
|
||||||
|
} else if std::path::PathBuf::from("config.toml").exists() {
|
||||||
|
AppConfig::from_file("config.toml")
|
||||||
|
.context("Failed to load config.toml")?
|
||||||
|
} else {
|
||||||
|
warn!("No config file found, using defaults");
|
||||||
|
AppConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(port) = port_override {
|
||||||
|
config.webrtc.port = port;
|
||||||
|
info!("Port override: {}", port);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = config.validate() {
|
||||||
|
error!("Configuration validation failed: {}", e);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Configuration loaded successfully");
|
||||||
|
info!("Capture: {} fps, {:?}", config.capture.frame_rate, config.capture.quality);
|
||||||
|
info!("Encoder: {}x{}, {} fps, {} bps",
|
||||||
|
config.encoder.width, config.encoder.height,
|
||||||
|
config.encoder.frame_rate, config.encoder.bitrate);
|
||||||
|
info!("WebRTC: Port {}, {} ICE servers",
|
||||||
|
config.webrtc.port, config.webrtc.ice_servers.len());
|
||||||
|
|
||||||
|
let state = Arc::new(RwLock::new(AppState::new()));
|
||||||
|
|
||||||
|
let init_result = {
|
||||||
|
let state_clone = state.clone();
|
||||||
|
let config_clone = config.clone();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
let capture_manager = match start_capture_manager(&config_clone).await {
|
||||||
|
Ok(capture) => {
|
||||||
|
info!("Capture manager initialized");
|
||||||
|
Some(capture)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to initialize capture manager: {:?}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let encoder = match start_encoder(&config_clone) {
|
||||||
|
Ok(encoder) => {
|
||||||
|
info!("Encoder initialized");
|
||||||
|
Some(encoder)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to initialize encoder: {:?}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let webrtc_server = match start_webrtc_server(&config_clone).await {
|
||||||
|
Ok(server) => {
|
||||||
|
info!("WebRTC server initialized");
|
||||||
|
Some(server)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to initialize WebRTC server: {:?}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut state_guard = state_clone.write().await;
|
||||||
|
state_guard.capture_manager = capture_manager;
|
||||||
|
state_guard.encoder = encoder;
|
||||||
|
state_guard.webrtc_server = webrtc_server;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state_guard = state_clone.read().await;
|
||||||
|
state_guard.capture_manager.is_some()
|
||||||
|
&& state_guard.encoder.is_some()
|
||||||
|
&& state_guard.webrtc_server.is_some()
|
||||||
|
}
|
||||||
|
}.await;
|
||||||
|
|
||||||
|
if !init_result {
|
||||||
|
error!("Failed to initialize all required modules");
|
||||||
|
error!("Attempting recovery...");
|
||||||
|
|
||||||
|
if let Err(e) = attempt_module_recovery(state.clone(), config).await {
|
||||||
|
error!("Module recovery failed: {:?}, shutting down", e);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut state_guard = state.write().await;
|
||||||
|
state_guard.is_running = true;
|
||||||
|
state_guard.start_time = Some(Instant::now());
|
||||||
|
state_guard.metrics.start_tracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Server started successfully on port {}", config.webrtc.port);
|
||||||
|
|
||||||
|
let ctrl_c = signal::ctrl_c()
|
||||||
|
.context("Failed to setup Ctrl+C handler")?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
let terminate = signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||||
|
.context("Failed to setup SIGTERM handler")?;
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => {
|
||||||
|
info!("Received Ctrl+C, initiating shutdown");
|
||||||
|
if let Err(e) = handle_shutdown(state.clone()).await {
|
||||||
|
error!("Shutdown error: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
_ = terminate.recv() => {
|
||||||
|
info!("Received SIGTERM, initiating shutdown");
|
||||||
|
if let Err(e) = handle_shutdown(state.clone()).await {
|
||||||
|
error!("Shutdown error: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = run_pipeline(state.clone()) => {
|
||||||
|
if let Err(e) = result {
|
||||||
|
error!("Pipeline error: {:?}", e);
|
||||||
|
|
||||||
|
if let Err(recovery_err) = attempt_module_recovery(state.clone(), config).await {
|
||||||
|
error!("Recovery failed: {:?}, shutting down", recovery_err);
|
||||||
|
return Err(recovery_err.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Recovery successful, continuing pipeline");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Server stopped");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_stop() -> Result<()> {
|
||||||
|
info!("Stopping wl-webrtc server...");
|
||||||
|
|
||||||
|
warn!("Stop command not fully implemented yet");
|
||||||
|
warn!("Use Ctrl+C to stop running server");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_status(config_path: Option<std::path::PathBuf>) -> Result<()> {
|
||||||
|
info!("Checking wl-webrtc status...");
|
||||||
|
|
||||||
|
if let Some(path) = config_path {
|
||||||
|
if path.exists() {
|
||||||
|
match AppConfig::from_file(&path) {
|
||||||
|
Ok(config) => {
|
||||||
|
println!("\n=== Configuration Status ===");
|
||||||
|
println!("Capture:");
|
||||||
|
println!(" Frame Rate: {} fps", config.capture.frame_rate);
|
||||||
|
println!(" Quality: {:?}", config.capture.quality);
|
||||||
|
println!(" Screen Region: {:?}", config.capture.screen_region);
|
||||||
|
|
||||||
|
println!("\nEncoder:");
|
||||||
|
println!(" Type: {:?}", config.encoder.encoder_type);
|
||||||
|
println!(" Resolution: {}x{}", config.encoder.width, config.encoder.height);
|
||||||
|
println!(" Frame Rate: {} fps", config.encoder.frame_rate);
|
||||||
|
println!(" Bitrate: {} Mbps", config.encoder.bitrate / 1_000_000);
|
||||||
|
println!(" Keyframe Interval: {} frames", config.encoder.keyframe_interval);
|
||||||
|
println!(" Preset: {:?}", config.encoder.preset);
|
||||||
|
println!(" Tune: {:?}", config.encoder.tune);
|
||||||
|
|
||||||
|
println!("\nWebRTC:");
|
||||||
|
println!(" Port: {}", config.webrtc.port);
|
||||||
|
println!(" ICE Servers: {} configured", config.webrtc.ice_servers.len());
|
||||||
|
println!(" STUN Servers: {} configured", config.webrtc.stun_servers.len());
|
||||||
|
println!(" TURN Servers: {} configured", config.webrtc.turn_servers.len());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Error loading configuration: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("Configuration file not found: {}", path.display());
|
||||||
|
}
|
||||||
|
} else if std::path::PathBuf::from("config.toml").exists() {
|
||||||
|
match AppConfig::from_file("config.toml") {
|
||||||
|
Ok(config) => {
|
||||||
|
println!("\n=== Configuration Status ===");
|
||||||
|
println!("Using config.toml");
|
||||||
|
println!("Capture: {} fps", config.capture.frame_rate);
|
||||||
|
println!("Encoder: {}x{} @ {} fps",
|
||||||
|
config.encoder.width, config.encoder.height, config.encoder.frame_rate);
|
||||||
|
println!("WebRTC: Port {}", config.webrtc.port);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Error loading config.toml: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("No configuration file found");
|
||||||
|
println!("Using default configuration:");
|
||||||
|
println!(" Capture: 30 fps, High quality");
|
||||||
|
println!(" Encoder: 1920x1080 @ 30 fps, 4 Mbps");
|
||||||
|
println!(" WebRTC: Port 8443");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n=== Server Status ===");
|
||||||
|
println!("Status: Not running (foreground mode only)");
|
||||||
|
println!("Mode: CLI (no daemon)");
|
||||||
|
println!("Note: Start server with 'wl-webrtc start' to see live status");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_config(config_path: Option<std::path::PathBuf>, validate_only: bool) -> Result<()> {
|
||||||
|
if validate_only {
|
||||||
|
info!("Validating configuration...");
|
||||||
|
|
||||||
|
let path = config_path.unwrap_or_else(|| std::path::PathBuf::from("config.toml"));
|
||||||
|
|
||||||
|
match AppConfig::from_file(&path) {
|
||||||
|
Ok(config) => {
|
||||||
|
match config.validate() {
|
||||||
|
Ok(()) => {
|
||||||
|
println!("Configuration is valid: {}", path.display());
|
||||||
|
println!("\nSummary:");
|
||||||
|
println!(" Capture: {} fps, {:?}", config.capture.frame_rate, config.capture.quality);
|
||||||
|
println!(" Encoder: {}x{} @ {} fps",
|
||||||
|
config.encoder.width, config.encoder.height, config.encoder.frame_rate);
|
||||||
|
println!(" WebRTC: Port {}", config.webrtc.port);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Configuration validation FAILED: {}", e);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Failed to load configuration: {}", e);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info!("Displaying configuration...");
|
||||||
|
|
||||||
|
let path = config_path.unwrap_or_else(|| std::path::PathBuf::from("config.toml"));
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
match AppConfig::from_file(&path) {
|
||||||
|
Ok(config) => {
|
||||||
|
println!("\n=== Full Configuration: {} ===", path.display());
|
||||||
|
println!("{:#?}", config);
|
||||||
|
|
||||||
|
println!("\n=== Validation ===");
|
||||||
|
match config.validate() {
|
||||||
|
Ok(()) => println!("Configuration is valid"),
|
||||||
|
Err(e) => {
|
||||||
|
println!("Configuration has errors:");
|
||||||
|
println!(" {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Error loading configuration: {}", e);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("Configuration file not found: {}", path.display());
|
||||||
|
println!("\n=== Default Configuration ===");
|
||||||
|
println!("{:#?}", AppConfig::default());
|
||||||
|
println!("\nTo create a config file, copy the default configuration above to config.toml");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
let verbose = cli.overrides.frame_rate.is_some()
|
||||||
|
|| cli.overrides.width.is_some()
|
||||||
|
|| cli.overrides.height.is_some();
|
||||||
|
setup_logging(verbose, None)?;
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Some(Commands::Start { config }) => {
|
||||||
|
let port_override = cli.overrides.port;
|
||||||
|
run_start(config, port_override).await
|
||||||
|
}
|
||||||
|
Some(Commands::Stop) => {
|
||||||
|
run_stop().await
|
||||||
|
}
|
||||||
|
Some(Commands::Status) => {
|
||||||
|
run_status(cli.config).await
|
||||||
|
}
|
||||||
|
Some(Commands::Config { validate }) => {
|
||||||
|
run_config(cli.config, validate).await
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
info!("No subcommand provided, showing help...");
|
||||||
|
println!("{}", Cli::command().render_long_help());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
695
src/signaling/mod.rs
Normal file
695
src/signaling/mod.rs
Normal file
@@ -0,0 +1,695 @@
|
|||||||
|
//! WebSocket signaling module for WebRTC SDP and ICE exchange
|
||||||
|
//!
|
||||||
|
//! This module provides type definitions for WebSocket-based signaling,
|
||||||
|
//! enabling SDP offer/answer exchange and ICE candidate relay between peers.
|
||||||
|
|
||||||
|
use crate::error::SignalingError;
|
||||||
|
use futures::{SinkExt, StreamExt};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tokio_tungstenite::{
|
||||||
|
accept_hdr_async,
|
||||||
|
tungstenite::{
|
||||||
|
handshake::server::{Request, Response},
|
||||||
|
Error as WsError,
|
||||||
|
Message,
|
||||||
|
},
|
||||||
|
WebSocketStream,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Signaling message types for WebRTC SDP and ICE exchange
|
||||||
|
///
|
||||||
|
/// These messages are exchanged between peers via the signaling server
|
||||||
|
/// to establish a WebRTC peer connection.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum SignalingMessage {
|
||||||
|
/// SDP offer from client
|
||||||
|
///
|
||||||
|
/// Contains the session description protocol offer from the initiating peer.
|
||||||
|
Offer { sdp: String },
|
||||||
|
|
||||||
|
/// SDP answer from client
|
||||||
|
///
|
||||||
|
/// Contains the session description protocol answer from the responding peer.
|
||||||
|
Answer { sdp: String },
|
||||||
|
|
||||||
|
/// ICE candidate from client
|
||||||
|
///
|
||||||
|
/// Contains an ICE (Interactive Connectivity Establishment) candidate
|
||||||
|
/// used for establishing network connectivity.
|
||||||
|
IceCandidate {
|
||||||
|
/// The ICE candidate string
|
||||||
|
candidate: String,
|
||||||
|
/// The media stream identifier (optional)
|
||||||
|
sdp_mid: Option<String>,
|
||||||
|
/// The media line index (optional)
|
||||||
|
sdp_mline_index: Option<u16>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session information for tracking active connections
|
||||||
|
///
|
||||||
|
/// Stores metadata about each active WebRTC session connected to the signaling server.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SessionInfo {
|
||||||
|
/// Unique session identifier
|
||||||
|
pub id: String,
|
||||||
|
/// Timestamp when the session was established
|
||||||
|
pub connected_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionInfo {
|
||||||
|
/// Create a new session info with the given ID and current timestamp
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `id` - The unique identifier for this session
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A new `SessionInfo` instance with the current UTC timestamp
|
||||||
|
pub fn new(id: String) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
connected_at: chrono::Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signaling server configuration
|
||||||
|
///
|
||||||
|
/// Configuration options for the WebSocket signaling server.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SignalingConfig {
|
||||||
|
/// Port number for the WebSocket server
|
||||||
|
pub port: u16,
|
||||||
|
/// Host address to bind to (e.g., "0.0.0.0" for all interfaces)
|
||||||
|
pub host: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SignalingConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
port: 8765,
|
||||||
|
host: "0.0.0.0".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignalingConfig {
|
||||||
|
/// Create a new signaling server configuration
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `port` - The port number to bind the server to
|
||||||
|
/// * `host` - The host address to bind to
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A new `SignalingConfig` instance
|
||||||
|
pub fn new(port: u16, host: String) -> Self {
|
||||||
|
Self { port, host }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignalingServer {
|
||||||
|
/// Create a new signaling server with the given configuration
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `config` - Server configuration including host and port
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A new `SignalingServer` instance
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `SignalingError` if the TCP listener cannot be bound
|
||||||
|
pub async fn new(config: SignalingConfig) -> Result<Self, SignalingError> {
|
||||||
|
let addr = format!("{}:{}", config.host, config.port);
|
||||||
|
let listener = TcpListener::bind(&addr)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SignalingError::ConnectionFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
tracing::info!("Signaling server listening on {}", addr);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
connections: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
listener,
|
||||||
|
running: Arc::new(Mutex::new(false)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the signaling server, accepting WebSocket connections
|
||||||
|
///
|
||||||
|
/// This method blocks and accepts incoming WebSocket connections.
|
||||||
|
/// Each connection is spawned in a separate task.
|
||||||
|
pub async fn run(&self) -> Result<(), SignalingError> {
|
||||||
|
*self.running.lock().await = true;
|
||||||
|
|
||||||
|
while *self.running.lock().await {
|
||||||
|
match self.listener.accept().await {
|
||||||
|
Ok((stream, addr)) => {
|
||||||
|
tracing::info!("New connection from {}", addr);
|
||||||
|
|
||||||
|
let callback = |_req: &Request, mut response: Response| {
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert("Sec-WebSocket-Protocol", "wl-webrtc".parse().unwrap());
|
||||||
|
Ok(response)
|
||||||
|
};
|
||||||
|
|
||||||
|
let ws_stream = match accept_hdr_async(stream, callback).await {
|
||||||
|
Ok(ws) => ws,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("WebSocket handshake failed: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let session_id = Uuid::new_v4().to_string();
|
||||||
|
let connection = WebSocketConnection::new(session_id.clone(), ws_stream);
|
||||||
|
|
||||||
|
let sessions = self.sessions.clone();
|
||||||
|
let connections = self.connections.clone();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut sessions_guard = sessions.lock().await;
|
||||||
|
let session_info = SessionInfo::new(session_id.clone());
|
||||||
|
sessions_guard.insert(session_id.clone(), session_info);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut connections_guard = connections.lock().await;
|
||||||
|
connections_guard.insert(session_id.clone(), connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
let connections_clone = connections.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = Self::handle_client(session_id, connections_clone).await {
|
||||||
|
tracing::error!("Client handler error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to accept connection: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the signaling server
|
||||||
|
pub async fn stop(&self) {
|
||||||
|
*self.running.lock().await = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_client(
|
||||||
|
session_id: String,
|
||||||
|
connections: Arc<Mutex<HashMap<String, WebSocketConnection>>>,
|
||||||
|
) -> Result<(), SignalingError> {
|
||||||
|
loop {
|
||||||
|
let msg: Message = {
|
||||||
|
let mut connections_guard = connections.lock().await;
|
||||||
|
let connection = connections_guard.get_mut(&session_id)
|
||||||
|
.ok_or_else(|| SignalingError::ProtocolError(format!("Session not found: {}", session_id)))?;
|
||||||
|
|
||||||
|
match connection.stream.next().await {
|
||||||
|
Some(Ok(msg)) => msg,
|
||||||
|
Some(Err(e)) => {
|
||||||
|
tracing::error!("WebSocket error: {}", e);
|
||||||
|
return Err(SignalingError::ReceiveFailed(e.to_string()));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::info!("Client disconnected: {}", session_id);
|
||||||
|
connections_guard.remove(&session_id);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if msg.is_close() {
|
||||||
|
tracing::info!("Client closed connection: {}", session_id);
|
||||||
|
let mut connections_guard = connections.lock().await;
|
||||||
|
connections_guard.remove(&session_id);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.is_text() {
|
||||||
|
let text = msg.to_text()
|
||||||
|
.map_err(|e: WsError| SignalingError::InvalidMessage(e.to_string()))?;
|
||||||
|
|
||||||
|
let signaling_msg: SignalingMessage = serde_json::from_str(text)
|
||||||
|
.map_err(|e| SignalingError::DeserializationError(e.to_string()))?;
|
||||||
|
|
||||||
|
match signaling_msg {
|
||||||
|
SignalingMessage::Offer { sdp } => {
|
||||||
|
Self::handle_offer(session_id.clone(), sdp, connections.clone()).await?;
|
||||||
|
}
|
||||||
|
SignalingMessage::Answer { sdp } => {
|
||||||
|
Self::handle_answer(session_id.clone(), sdp, connections.clone()).await?;
|
||||||
|
}
|
||||||
|
SignalingMessage::IceCandidate {
|
||||||
|
candidate,
|
||||||
|
sdp_mid,
|
||||||
|
sdp_mline_index,
|
||||||
|
} => {
|
||||||
|
Self::handle_ice_candidate(
|
||||||
|
session_id.clone(),
|
||||||
|
candidate,
|
||||||
|
sdp_mid,
|
||||||
|
sdp_mline_index,
|
||||||
|
connections.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of active sessions
|
||||||
|
pub async fn session_count(&self) -> usize {
|
||||||
|
self.sessions.lock().await.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a session by ID
|
||||||
|
pub async fn get_session(&self, session_id: &str) -> Option<SessionInfo> {
|
||||||
|
self.sessions.lock().await.get(session_id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_session(&self, session_id: String, session_info: SessionInfo) {
|
||||||
|
self.sessions.lock().await.insert(session_id, session_info);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_session(&self, session_id: &str) -> Option<SessionInfo> {
|
||||||
|
self.sessions.lock().await.remove(session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an SDP offer to a specific session
|
||||||
|
pub async fn send_offer(
|
||||||
|
&self,
|
||||||
|
target_session_id: &str,
|
||||||
|
sdp: String,
|
||||||
|
) -> Result<(), SignalingError> {
|
||||||
|
let msg = SignalingMessage::Offer { sdp };
|
||||||
|
let msg_json = serde_json::to_string(&msg)
|
||||||
|
.map_err(|e: serde_json::Error| SignalingError::SerializationError(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut connections_guard = self.connections.lock().await;
|
||||||
|
if let Some(conn) = connections_guard.get_mut(target_session_id) {
|
||||||
|
conn.stream
|
||||||
|
.send(Message::Text(msg_json))
|
||||||
|
.await
|
||||||
|
.map_err(|e: WsError| SignalingError::SendFailed(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(SignalingError::ProtocolError(format!("Session not found: {}", target_session_id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive and process an SDP answer
|
||||||
|
pub async fn receive_answer(
|
||||||
|
&self,
|
||||||
|
sender_session_id: &str,
|
||||||
|
sdp: String,
|
||||||
|
) -> Result<(), SignalingError> {
|
||||||
|
Self::handle_answer(
|
||||||
|
sender_session_id.to_string(),
|
||||||
|
sdp,
|
||||||
|
self.connections.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an ICE candidate to a specific session
|
||||||
|
pub async fn send_ice_candidate(
|
||||||
|
&self,
|
||||||
|
target_session_id: &str,
|
||||||
|
candidate: String,
|
||||||
|
sdp_mid: Option<String>,
|
||||||
|
sdp_mline_index: Option<u16>,
|
||||||
|
) -> Result<(), SignalingError> {
|
||||||
|
let msg = SignalingMessage::IceCandidate {
|
||||||
|
candidate,
|
||||||
|
sdp_mid,
|
||||||
|
sdp_mline_index,
|
||||||
|
};
|
||||||
|
let msg_json = serde_json::to_string(&msg)
|
||||||
|
.map_err(|e: serde_json::Error| SignalingError::SerializationError(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut connections_guard = self.connections.lock().await;
|
||||||
|
if let Some(conn) = connections_guard.get_mut(target_session_id) {
|
||||||
|
conn.stream
|
||||||
|
.send(Message::Text(msg_json))
|
||||||
|
.await
|
||||||
|
.map_err(|e: WsError| SignalingError::SendFailed(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(SignalingError::ProtocolError(format!("Session not found: {}", target_session_id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive and process an ICE candidate
|
||||||
|
pub async fn receive_ice_candidate(
|
||||||
|
&self,
|
||||||
|
sender_session_id: &str,
|
||||||
|
candidate: String,
|
||||||
|
sdp_mid: Option<String>,
|
||||||
|
sdp_mline_index: Option<u16>,
|
||||||
|
) -> Result<(), SignalingError> {
|
||||||
|
Self::handle_ice_candidate(
|
||||||
|
sender_session_id.to_string(),
|
||||||
|
candidate,
|
||||||
|
sdp_mid,
|
||||||
|
sdp_mline_index,
|
||||||
|
self.connections.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_offer(
|
||||||
|
sender_id: String,
|
||||||
|
sdp: String,
|
||||||
|
connections: Arc<Mutex<HashMap<String, WebSocketConnection>>>,
|
||||||
|
) -> Result<(), SignalingError> {
|
||||||
|
tracing::debug!("Received SDP offer from {}", sender_id);
|
||||||
|
|
||||||
|
let peer_id = {
|
||||||
|
let connections_guard = connections.lock().await;
|
||||||
|
Self::find_peer_id(&sender_id, &connections_guard)?
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = SignalingMessage::Offer { sdp };
|
||||||
|
let msg_json = serde_json::to_string(&msg)
|
||||||
|
.map_err(|e: serde_json::Error| SignalingError::SerializationError(e.to_string()))?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut connections_guard = connections.lock().await;
|
||||||
|
if let Some(peer_conn) = connections_guard.get_mut(&peer_id) {
|
||||||
|
peer_conn.stream
|
||||||
|
.send(Message::Text(msg_json))
|
||||||
|
.await
|
||||||
|
.map_err(|e: WsError| SignalingError::SendFailed(e.to_string()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!("Forwarded SDP offer to {}", peer_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_answer(
|
||||||
|
sender_id: String,
|
||||||
|
sdp: String,
|
||||||
|
connections: Arc<Mutex<HashMap<String, WebSocketConnection>>>,
|
||||||
|
) -> Result<(), SignalingError> {
|
||||||
|
tracing::debug!("Received SDP answer from {}", sender_id);
|
||||||
|
|
||||||
|
let peer_id = {
|
||||||
|
let connections_guard = connections.lock().await;
|
||||||
|
Self::find_peer_id(&sender_id, &connections_guard)?
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = SignalingMessage::Answer { sdp };
|
||||||
|
let msg_json = serde_json::to_string(&msg)
|
||||||
|
.map_err(|e: serde_json::Error| SignalingError::SerializationError(e.to_string()))?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut connections_guard = connections.lock().await;
|
||||||
|
if let Some(peer_conn) = connections_guard.get_mut(&peer_id) {
|
||||||
|
peer_conn.stream
|
||||||
|
.send(Message::Text(msg_json))
|
||||||
|
.await
|
||||||
|
.map_err(|e: WsError| SignalingError::SendFailed(e.to_string()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!("Forwarded SDP answer to {}", peer_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_ice_candidate(
|
||||||
|
sender_id: String,
|
||||||
|
candidate: String,
|
||||||
|
sdp_mid: Option<String>,
|
||||||
|
sdp_mline_index: Option<u16>,
|
||||||
|
connections: Arc<Mutex<HashMap<String, WebSocketConnection>>>,
|
||||||
|
) -> Result<(), SignalingError> {
|
||||||
|
tracing::debug!("Received ICE candidate from {}", sender_id);
|
||||||
|
|
||||||
|
let peer_id = {
|
||||||
|
let connections_guard = connections.lock().await;
|
||||||
|
Self::find_peer_id(&sender_id, &connections_guard)?
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = SignalingMessage::IceCandidate {
|
||||||
|
candidate,
|
||||||
|
sdp_mid,
|
||||||
|
sdp_mline_index,
|
||||||
|
};
|
||||||
|
let msg_json = serde_json::to_string(&msg)
|
||||||
|
.map_err(|e: serde_json::Error| SignalingError::SerializationError(e.to_string()))?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut connections_guard = connections.lock().await;
|
||||||
|
if let Some(peer_conn) = connections_guard.get_mut(&peer_id) {
|
||||||
|
peer_conn.stream
|
||||||
|
.send(Message::Text(msg_json))
|
||||||
|
.await
|
||||||
|
.map_err(|e: WsError| SignalingError::SendFailed(e.to_string()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!("Forwarded ICE candidate to {}", peer_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_peer_id(
|
||||||
|
session_id: &str,
|
||||||
|
connections: &HashMap<String, WebSocketConnection>,
|
||||||
|
) -> Result<String, SignalingError> {
|
||||||
|
let connection = connections
|
||||||
|
.get(session_id)
|
||||||
|
.ok_or_else(|| SignalingError::ProtocolError(format!("Session not found: {}", session_id)))?;
|
||||||
|
|
||||||
|
if let Some(ref peer_id) = connection.peer_id {
|
||||||
|
return Ok(peer_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (other_id, other_conn) in connections.iter() {
|
||||||
|
if other_id != session_id && other_conn.peer_id.is_none() {
|
||||||
|
return Ok(other_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(SignalingError::ProtocolError(
|
||||||
|
"No peer available to relay message".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebSocket signaling server
|
||||||
|
///
|
||||||
|
/// Main type for the signaling server that manages WebSocket connections,
|
||||||
|
/// session tracking, and message relay between peers.
|
||||||
|
pub struct SignalingServer {
|
||||||
|
/// Server configuration
|
||||||
|
pub config: SignalingConfig,
|
||||||
|
|
||||||
|
/// Active WebSocket connections by session ID
|
||||||
|
connections: Arc<Mutex<HashMap<String, WebSocketConnection>>>,
|
||||||
|
|
||||||
|
/// Session information by session ID
|
||||||
|
sessions: Arc<Mutex<HashMap<String, SessionInfo>>>,
|
||||||
|
|
||||||
|
/// TCP listener for accepting connections
|
||||||
|
listener: TcpListener,
|
||||||
|
|
||||||
|
/// Running state flag
|
||||||
|
running: Arc<Mutex<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents an active WebSocket connection
|
||||||
|
struct WebSocketConnection {
|
||||||
|
/// Session ID for this connection
|
||||||
|
session_id: String,
|
||||||
|
|
||||||
|
/// WebSocket stream for sending/receiving messages
|
||||||
|
stream: WebSocketStream<tokio::net::TcpStream>,
|
||||||
|
|
||||||
|
/// Peer session ID (the remote peer in a session)
|
||||||
|
peer_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebSocketConnection {
|
||||||
|
/// Create a new WebSocket connection
|
||||||
|
fn new(session_id: String, stream: WebSocketStream<tokio::net::TcpStream>) -> Self {
|
||||||
|
Self {
|
||||||
|
session_id,
|
||||||
|
stream,
|
||||||
|
peer_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_signaling_config_default() {
|
||||||
|
let config = SignalingConfig::default();
|
||||||
|
assert_eq!(config.port, 8765);
|
||||||
|
assert_eq!(config.host, "0.0.0.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_signaling_config_new() {
|
||||||
|
let config = SignalingConfig::new(9000, "127.0.0.1".to_string());
|
||||||
|
assert_eq!(config.port, 9000);
|
||||||
|
assert_eq!(config.host, "127.0.0.1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_info_new() {
|
||||||
|
let session = SessionInfo::new("test-session-id".to_string());
|
||||||
|
assert_eq!(session.id, "test-session-id");
|
||||||
|
assert!(session.connected_at.timestamp() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_signaling_message_offer_serialization() {
|
||||||
|
let msg = SignalingMessage::Offer {
|
||||||
|
sdp: "test-sdp-offer".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&msg).unwrap();
|
||||||
|
let deserialized: SignalingMessage = serde_json::from_str(&serialized).unwrap();
|
||||||
|
|
||||||
|
match deserialized {
|
||||||
|
SignalingMessage::Offer { sdp } => assert_eq!(sdp, "test-sdp-offer"),
|
||||||
|
_ => panic!("Expected Offer message"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_signaling_message_answer_serialization() {
|
||||||
|
let msg = SignalingMessage::Answer {
|
||||||
|
sdp: "test-sdp-answer".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&msg).unwrap();
|
||||||
|
let deserialized: SignalingMessage = serde_json::from_str(&serialized).unwrap();
|
||||||
|
|
||||||
|
match deserialized {
|
||||||
|
SignalingMessage::Answer { sdp } => assert_eq!(sdp, "test-sdp-answer"),
|
||||||
|
_ => panic!("Expected Answer message"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_signaling_message_ice_candidate_serialization() {
|
||||||
|
let msg = SignalingMessage::IceCandidate {
|
||||||
|
candidate: "test-candidate".to_string(),
|
||||||
|
sdp_mid: Some("video".to_string()),
|
||||||
|
sdp_mline_index: Some(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&msg).unwrap();
|
||||||
|
let deserialized: SignalingMessage = serde_json::from_str(&serialized).unwrap();
|
||||||
|
|
||||||
|
match deserialized {
|
||||||
|
SignalingMessage::IceCandidate {
|
||||||
|
candidate,
|
||||||
|
sdp_mid,
|
||||||
|
sdp_mline_index,
|
||||||
|
} => {
|
||||||
|
assert_eq!(candidate, "test-candidate");
|
||||||
|
assert_eq!(sdp_mid, Some("video".to_string()));
|
||||||
|
assert_eq!(sdp_mline_index, Some(0));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected IceCandidate message"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_signaling_message_ice_candidate_optional_fields() {
|
||||||
|
let msg = SignalingMessage::IceCandidate {
|
||||||
|
candidate: "test-candidate".to_string(),
|
||||||
|
sdp_mid: None,
|
||||||
|
sdp_mline_index: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&msg).unwrap();
|
||||||
|
let deserialized: SignalingMessage = serde_json::from_str(&serialized).unwrap();
|
||||||
|
|
||||||
|
match deserialized {
|
||||||
|
SignalingMessage::IceCandidate {
|
||||||
|
candidate,
|
||||||
|
sdp_mid,
|
||||||
|
sdp_mline_index,
|
||||||
|
} => {
|
||||||
|
assert_eq!(candidate, "test-candidate");
|
||||||
|
assert!(sdp_mid.is_none());
|
||||||
|
assert!(sdp_mline_index.is_none());
|
||||||
|
}
|
||||||
|
_ => panic!("Expected IceCandidate message"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_signaling_server_creation() {
|
||||||
|
let config = SignalingConfig::new(18765, "127.0.0.1".to_string());
|
||||||
|
let server = SignalingServer::new(config).await;
|
||||||
|
assert!(server.is_ok());
|
||||||
|
|
||||||
|
let server = server.unwrap();
|
||||||
|
server.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_signaling_server_session_management() {
|
||||||
|
let config = SignalingConfig::new(18766, "127.0.0.1".to_string());
|
||||||
|
let server = SignalingServer::new(config).await.unwrap();
|
||||||
|
|
||||||
|
let session_id = "test-session-1".to_string();
|
||||||
|
let session_info = SessionInfo::new(session_id.clone());
|
||||||
|
|
||||||
|
server.add_session(session_id.clone(), session_info.clone()).await;
|
||||||
|
|
||||||
|
let retrieved = server.get_session(&session_id).await;
|
||||||
|
assert!(retrieved.is_some());
|
||||||
|
assert_eq!(retrieved.unwrap().id, session_id);
|
||||||
|
|
||||||
|
assert_eq!(server.session_count().await, 1);
|
||||||
|
|
||||||
|
server.remove_session(&session_id).await;
|
||||||
|
|
||||||
|
let retrieved = server.get_session(&session_id).await;
|
||||||
|
assert!(retrieved.is_none());
|
||||||
|
|
||||||
|
assert_eq!(server.session_count().await, 0);
|
||||||
|
|
||||||
|
server.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_signaling_server_nonexistent_session() {
|
||||||
|
let config = SignalingConfig::new(18767, "127.0.0.1".to_string());
|
||||||
|
let server = SignalingServer::new(config).await.unwrap();
|
||||||
|
|
||||||
|
let result = server.get_session("nonexistent").await;
|
||||||
|
assert!(result.is_none());
|
||||||
|
|
||||||
|
server.stop().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
749
src/webrtc/mod.rs
Normal file
749
src/webrtc/mod.rs
Normal file
@@ -0,0 +1,749 @@
|
|||||||
|
//! WebRTC transport module
|
||||||
|
//!
|
||||||
|
//! This module provides WebRTC transport functionality for peer-to-peer
|
||||||
|
//! media streaming and data channel communication.
|
||||||
|
|
||||||
|
use crate::config::WebRtcConfig;
|
||||||
|
use crate::encoder::EncodedFrame;
|
||||||
|
use crate::error::WebRtcError;
|
||||||
|
use async_channel::{bounded, Receiver, Sender};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use webrtc::{
|
||||||
|
api::{
|
||||||
|
API,
|
||||||
|
APIBuilder,
|
||||||
|
media_engine::MediaEngine,
|
||||||
|
},
|
||||||
|
data_channel::RTCDataChannel,
|
||||||
|
ice_transport::ice_connection_state::RTCIceConnectionState,
|
||||||
|
ice_transport::ice_credential_type::RTCIceCredentialType,
|
||||||
|
ice_transport::ice_server::RTCIceServer,
|
||||||
|
peer_connection::{
|
||||||
|
configuration::RTCConfiguration,
|
||||||
|
peer_connection_state::RTCPeerConnectionState,
|
||||||
|
sdp::session_description::RTCSessionDescription,
|
||||||
|
RTCPeerConnection,
|
||||||
|
},
|
||||||
|
rtp_transceiver::rtp_codec::RTCRtpCodecCapability,
|
||||||
|
track::track_local::track_local_static_sample::TrackLocalStaticSample,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// WebRTC transport manager
|
||||||
|
///
|
||||||
|
/// Manages WebRTC peer connections, video tracks, and data channels
|
||||||
|
/// for real-time media streaming.
|
||||||
|
///
|
||||||
|
/// # Responsibilities
|
||||||
|
/// - Manage peer connection lifecycle
|
||||||
|
/// - Handle video track streaming
|
||||||
|
/// - Manage optional data channels for control signals
|
||||||
|
/// - Apply WebRTC configuration settings
|
||||||
|
pub struct WebRtcTransport {
|
||||||
|
/// The underlying WebRTC peer connection
|
||||||
|
pub peer_connection: PeerConnection,
|
||||||
|
|
||||||
|
/// Video track for streaming encoded frames
|
||||||
|
pub video_track: VideoTrack,
|
||||||
|
|
||||||
|
/// Optional data channel for bidirectional communication
|
||||||
|
pub data_channel: Option<DataChannel>,
|
||||||
|
|
||||||
|
/// WebRTC configuration
|
||||||
|
pub config: WebRtcConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebRTC server that manages multiple peer connections
|
||||||
|
pub struct WebRtcServer {
|
||||||
|
/// WebRTC API instance
|
||||||
|
api: API,
|
||||||
|
|
||||||
|
/// Managed peer connections by session ID
|
||||||
|
peer_connections: Arc<Mutex<HashMap<String, PeerConnection>>>,
|
||||||
|
|
||||||
|
/// WebRTC configuration
|
||||||
|
config: WebRtcConfig,
|
||||||
|
|
||||||
|
/// Channel for receiving video frames for all sessions
|
||||||
|
video_frame_tx: Sender<(String, EncodedFrame)>,
|
||||||
|
|
||||||
|
/// Channel for sending video frames (internal use)
|
||||||
|
video_frame_rx: Option<Receiver<(String, EncodedFrame)>>,
|
||||||
|
|
||||||
|
/// ICE servers for STUN/TURN
|
||||||
|
ice_servers: Vec<IceServer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebRtcServer {
|
||||||
|
/// Create a new WebRTC server with the given configuration
|
||||||
|
pub async fn new(config: WebRtcConfig) -> Result<Self, WebRtcError> {
|
||||||
|
let ice_servers = Self::build_ice_servers(&config)?;
|
||||||
|
|
||||||
|
let mut media_engine = MediaEngine::default();
|
||||||
|
media_engine.register_default_codecs()?;
|
||||||
|
|
||||||
|
let api = APIBuilder::new()
|
||||||
|
.with_media_engine(media_engine)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let (video_frame_tx, video_frame_rx) = bounded(100);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
api,
|
||||||
|
peer_connections: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
config,
|
||||||
|
ice_servers,
|
||||||
|
video_frame_tx,
|
||||||
|
video_frame_rx: Some(video_frame_rx),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build ICE server configuration from WebRTC config
|
||||||
|
fn build_ice_servers(config: &WebRtcConfig) -> Result<Vec<IceServer>, WebRtcError> {
|
||||||
|
let mut servers = Vec::new();
|
||||||
|
|
||||||
|
for stun_url in &config.stun_servers {
|
||||||
|
servers.push(IceServer {
|
||||||
|
urls: vec![stun_url.clone()],
|
||||||
|
username: None,
|
||||||
|
credential: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for turn_config in &config.turn_servers {
|
||||||
|
servers.push(IceServer {
|
||||||
|
urls: turn_config.urls.clone(),
|
||||||
|
username: Some(turn_config.username.clone()),
|
||||||
|
credential: Some(turn_config.credential.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new peer connection for the given session
|
||||||
|
pub async fn create_peer_connection(
|
||||||
|
&self,
|
||||||
|
session_id: String,
|
||||||
|
) -> Result<PeerConnection, WebRtcError> {
|
||||||
|
let rtc_config = RTCConfiguration {
|
||||||
|
ice_servers: self
|
||||||
|
.ice_servers
|
||||||
|
.iter()
|
||||||
|
.map(|server| RTCIceServer {
|
||||||
|
urls: server.urls.clone(),
|
||||||
|
username: server.username.clone().unwrap_or_default(),
|
||||||
|
credential: server.credential.clone().unwrap_or_default(),
|
||||||
|
credential_type: RTCIceCredentialType::Password,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let pc = self.api.new_peer_connection(rtc_config).await?;
|
||||||
|
|
||||||
|
let video_track = VideoTrack::new(session_id.clone())?;
|
||||||
|
|
||||||
|
pc.add_track(video_track.inner.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| WebRtcError::TrackAdditionFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut peer_connection = PeerConnection::new(session_id.clone(), pc, video_track)?;
|
||||||
|
|
||||||
|
let session_id_clone = session_id.clone();
|
||||||
|
peer_connection
|
||||||
|
.set_ice_candidate_callback(Box::new(move |candidate: Option<webrtc::ice_transport::ice_candidate::RTCIceCandidate>| {
|
||||||
|
let session_id = session_id_clone.clone();
|
||||||
|
if let Some(c) = &candidate {
|
||||||
|
if let Ok(json) = c.to_json() {
|
||||||
|
tracing::debug!("[{}] ICE candidate: {:?}", session_id, json);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::debug!("[{}] ICE candidate: None", session_id);
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let session_id_clone = session_id.clone();
|
||||||
|
peer_connection
|
||||||
|
.set_state_change_callback(Box::new(move |state: RTCPeerConnectionState| {
|
||||||
|
let session_id = session_id_clone.clone();
|
||||||
|
tracing::info!("[{}] Connection state changed: {:?}", session_id, state);
|
||||||
|
}))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut connections = self.peer_connections.lock().await;
|
||||||
|
connections.insert(session_id.clone(), peer_connection.clone());
|
||||||
|
|
||||||
|
Ok(peer_connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a peer connection by session ID
|
||||||
|
pub async fn get_peer_connection(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
) -> Result<PeerConnection, WebRtcError> {
|
||||||
|
let connections = self.peer_connections.lock().await;
|
||||||
|
connections
|
||||||
|
.get(session_id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| WebRtcError::SessionNotFound(session_id.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove and close a peer connection
|
||||||
|
pub async fn remove_peer_connection(&self, session_id: &str) -> Result<(), WebRtcError> {
|
||||||
|
let mut connections = self.peer_connections.lock().await;
|
||||||
|
if let Some(peer) = connections.remove(session_id) {
|
||||||
|
peer.close().await?;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(WebRtcError::SessionNotFound(session_id.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get video frame sender for distributing frames to peer connections
|
||||||
|
pub fn video_frame_sender(&self) -> Sender<(String, EncodedFrame)> {
|
||||||
|
self.video_frame_tx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start video frame distribution task
|
||||||
|
pub async fn start_frame_distribution(&mut self) -> Result<(), WebRtcError> {
|
||||||
|
let rx = self.video_frame_rx
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| WebRtcError::Internal("Frame distribution already started".to_string()))?;
|
||||||
|
|
||||||
|
let peer_connections = self.peer_connections.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok((session_id, frame)) = rx.recv().await {
|
||||||
|
let connections = peer_connections.lock().await;
|
||||||
|
if let Some(peer) = connections.get(&session_id) {
|
||||||
|
if let Err(e) = peer.send_video_frame(frame).await {
|
||||||
|
tracing::error!("Failed to send video frame to {}: {}", session_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of active peer connections
|
||||||
|
pub async fn connection_count(&self) -> usize {
|
||||||
|
self.peer_connections.lock().await.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peer connection wrapper
|
||||||
|
///
|
||||||
|
/// Provides a high-level abstraction over the WebRTC RTCPeerConnection,
|
||||||
|
/// managing video tracks and data channels.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PeerConnection {
|
||||||
|
/// Session ID for this peer connection
|
||||||
|
session_id: String,
|
||||||
|
|
||||||
|
/// Inner peer connection state
|
||||||
|
pc: Arc<RTCPeerConnection>,
|
||||||
|
|
||||||
|
/// Associated video track
|
||||||
|
video_track: Arc<VideoTrack>,
|
||||||
|
|
||||||
|
/// Optional data channel for control messages
|
||||||
|
data_channel: Option<Arc<RTCDataChannel>>,
|
||||||
|
|
||||||
|
/// ICE candidate callback
|
||||||
|
ice_candidate_cb: Arc<Mutex<Option<Box<dyn Fn(Option<webrtc::ice_transport::ice_candidate::RTCIceCandidate>) + Send + Sync>>>>,
|
||||||
|
|
||||||
|
/// State change callback
|
||||||
|
state_change_cb: Arc<Mutex<Option<Box<dyn Fn(RTCPeerConnectionState) + Send + Sync>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PeerConnection {
|
||||||
|
/// Create a new peer connection wrapper
|
||||||
|
pub(crate) fn new(
|
||||||
|
session_id: String,
|
||||||
|
pc: RTCPeerConnection,
|
||||||
|
video_track: VideoTrack,
|
||||||
|
) -> Result<Self, WebRtcError> {
|
||||||
|
Ok(Self {
|
||||||
|
session_id,
|
||||||
|
pc: Arc::new(pc),
|
||||||
|
video_track: Arc::new(video_track),
|
||||||
|
data_channel: None,
|
||||||
|
ice_candidate_cb: Arc::new(Mutex::new(None)),
|
||||||
|
state_change_cb: Arc::new(Mutex::new(None)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an SDP offer
|
||||||
|
pub async fn create_offer(&self) -> Result<RTCSessionDescription, WebRtcError> {
|
||||||
|
let offer = self
|
||||||
|
.pc
|
||||||
|
.create_offer(None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WebRtcError::SdpExchangeFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
self.pc
|
||||||
|
.set_local_description(offer.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| WebRtcError::SdpExchangeFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(offer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a remote SDP description
|
||||||
|
pub async fn set_remote_description(
|
||||||
|
&self,
|
||||||
|
desc: RTCSessionDescription,
|
||||||
|
) -> Result<(), WebRtcError> {
|
||||||
|
self.pc
|
||||||
|
.set_remote_description(desc)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WebRtcError::SdpExchangeFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an SDP answer
|
||||||
|
pub async fn create_answer(&self) -> Result<RTCSessionDescription, WebRtcError> {
|
||||||
|
let answer = self
|
||||||
|
.pc
|
||||||
|
.create_answer(None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WebRtcError::SdpExchangeFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
self.pc
|
||||||
|
.set_local_description(answer.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| WebRtcError::SdpExchangeFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a data channel for control messages
|
||||||
|
pub async fn create_data_channel(
|
||||||
|
&mut self,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<Arc<RTCDataChannel>, WebRtcError> {
|
||||||
|
let dc = self
|
||||||
|
.pc
|
||||||
|
.create_data_channel(label, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WebRtcError::DataChannelError(e.to_string()))?;
|
||||||
|
|
||||||
|
self.data_channel = Some(dc.clone());
|
||||||
|
Ok(dc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a video frame
|
||||||
|
pub async fn send_video_frame(&self, frame: EncodedFrame) -> Result<(), WebRtcError> {
|
||||||
|
self.video_track.write_sample(frame).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the video track
|
||||||
|
pub fn video_track(&self) -> Arc<VideoTrack> {
|
||||||
|
self.video_track.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set ICE candidate callback
|
||||||
|
pub async fn set_ice_candidate_callback<F>(&self, callback: F) -> Result<(), WebRtcError>
|
||||||
|
where
|
||||||
|
F: Fn(Option<webrtc::ice_transport::ice_candidate::RTCIceCandidate>) + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let pc = self.pc.clone();
|
||||||
|
let cb = Arc::new(callback);
|
||||||
|
|
||||||
|
pc.on_ice_candidate(Box::new(move |candidate| {
|
||||||
|
let cb = cb.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
cb(candidate);
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set peer connection state change callback
|
||||||
|
pub async fn set_state_change_callback<F>(&self, callback: F) -> Result<(), WebRtcError>
|
||||||
|
where
|
||||||
|
F: Fn(RTCPeerConnectionState) + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let pc = self.pc.clone();
|
||||||
|
let cb = Arc::new(callback);
|
||||||
|
|
||||||
|
pc.on_peer_connection_state_change(Box::new(move |state| {
|
||||||
|
let cb = cb.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
cb(state);
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current connection state
|
||||||
|
pub async fn connection_state(&self) -> RTCPeerConnectionState {
|
||||||
|
self.pc.connection_state()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get ICE connection state
|
||||||
|
pub async fn ice_connection_state(&self) -> RTCIceConnectionState {
|
||||||
|
self.pc.ice_connection_state()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the underlying peer connection
|
||||||
|
pub fn inner(&self) -> Arc<RTCPeerConnection> {
|
||||||
|
self.pc.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the peer connection
|
||||||
|
pub async fn close(&self) -> Result<(), WebRtcError> {
|
||||||
|
self.pc
|
||||||
|
.close()
|
||||||
|
.await
|
||||||
|
.map_err(|e| WebRtcError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Video track for media streaming
|
||||||
|
///
|
||||||
|
/// Represents a video track that can be used to send
|
||||||
|
/// encoded video frames to remote peers.
|
||||||
|
pub struct VideoTrack {
|
||||||
|
/// Track identifier
|
||||||
|
track_id: String,
|
||||||
|
|
||||||
|
/// Inner WebRTC track
|
||||||
|
inner: Arc<TrackLocalStaticSample>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VideoTrack {
|
||||||
|
/// Create a new video track
|
||||||
|
pub fn new(track_id: String) -> Result<Self, WebRtcError> {
|
||||||
|
let codec = RTCRtpCodecCapability {
|
||||||
|
mime_type: "video/H264".to_string(),
|
||||||
|
clock_rate: 90000,
|
||||||
|
channels: 0,
|
||||||
|
sdp_fmtp_line: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f".to_string(),
|
||||||
|
rtcp_feedback: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
track_id: track_id.clone(),
|
||||||
|
inner: Arc::new(TrackLocalStaticSample::new(codec, track_id.clone(), track_id)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a video sample to the track
|
||||||
|
pub async fn write_sample(&self, frame: EncodedFrame) -> Result<(), WebRtcError> {
|
||||||
|
let sample = webrtc::media::Sample {
|
||||||
|
data: frame.data.clone(),
|
||||||
|
duration: std::time::Duration::from_secs_f64(1.0 / 30.0),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
self.inner
|
||||||
|
.write_sample(&sample)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WebRtcError::RtpSendFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the track ID
|
||||||
|
pub fn track_id(&self) -> &str {
|
||||||
|
&self.track_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the inner track
|
||||||
|
pub fn inner(&self) -> Arc<TrackLocalStaticSample> {
|
||||||
|
self.inner.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for VideoTrack {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new("video".to_string()).expect("Failed to create default video track")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for VideoTrack {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
track_id: self.track_id.clone(),
|
||||||
|
inner: self.inner.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Data channel for control communication
|
||||||
|
///
|
||||||
|
/// Provides bidirectional data channels for sending
|
||||||
|
/// control messages, input events, and metadata.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DataChannel {
|
||||||
|
/// Channel identifier
|
||||||
|
pub channel_id: String,
|
||||||
|
|
||||||
|
/// Channel label
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input events for remote control
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum InputEvent {
|
||||||
|
MouseMove { x: f32, y: f32 },
|
||||||
|
MouseClick { button: MouseButton },
|
||||||
|
MouseScroll { delta_x: f32, delta_y: f32 },
|
||||||
|
KeyPress { key: String },
|
||||||
|
KeyRelease { key: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mouse button types
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub enum MouseButton {
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Middle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DataChannel {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
channel_id: "control".to_string(),
|
||||||
|
label: "control".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ICE server configuration
|
||||||
|
///
|
||||||
|
/// Specifies STUN/TURN servers for ICE connectivity.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```rust
|
||||||
|
/// use crate::webrtc::IceServer;
|
||||||
|
///
|
||||||
|
/// let stun_server = IceServer::stun("stun:stun.l.google.com:19302");
|
||||||
|
/// let turn_server = IceServer::turn(
|
||||||
|
/// vec!["turn:turn.example.com:3478".to_string()],
|
||||||
|
/// "username",
|
||||||
|
/// "credential"
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct IceServer {
|
||||||
|
/// Server URLs (STUN or TURN)
|
||||||
|
pub urls: Vec<String>,
|
||||||
|
|
||||||
|
/// Username for TURN authentication (optional for STUN)
|
||||||
|
pub username: Option<String>,
|
||||||
|
|
||||||
|
/// Credential for TURN authentication (optional for STUN)
|
||||||
|
pub credential: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IceServer {
|
||||||
|
/// Create a STUN server configuration
|
||||||
|
pub fn stun(url: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
urls: vec![url.into()],
|
||||||
|
username: None,
|
||||||
|
credential: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a TURN server configuration
|
||||||
|
pub fn turn(
|
||||||
|
urls: Vec<String>,
|
||||||
|
username: impl Into<String>,
|
||||||
|
credential: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
urls,
|
||||||
|
username: Some(username.into()),
|
||||||
|
credential: Some(credential.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ICE transport policy
|
||||||
|
///
|
||||||
|
/// Determines how ICE candidates are gathered and used.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum IceTransportPolicy {
|
||||||
|
/// Use all available ICE candidates (relay, host, srflx)
|
||||||
|
All,
|
||||||
|
/// Use only relay candidates (TURN servers only)
|
||||||
|
Relay,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for IceTransportPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::All
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::WebRtcConfig;
|
||||||
|
use bytes::Bytes;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ice_server_stun() {
|
||||||
|
let server = IceServer::stun("stun:stun.l.google.com:19302");
|
||||||
|
assert_eq!(server.urls, vec!["stun:stun.l.google.com:19302"]);
|
||||||
|
assert!(server.username.is_none());
|
||||||
|
assert!(server.credential.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ice_server_turn() {
|
||||||
|
let server = IceServer::turn(
|
||||||
|
vec!["turn:turn.example.com:3478".to_string()],
|
||||||
|
"user",
|
||||||
|
"pass",
|
||||||
|
);
|
||||||
|
assert_eq!(server.urls, vec!["turn:turn.example.com:3478"]);
|
||||||
|
assert_eq!(server.username, Some("user".to_string()));
|
||||||
|
assert_eq!(server.credential, Some("pass".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ice_transport_policy_default() {
|
||||||
|
let policy = IceTransportPolicy::default();
|
||||||
|
assert_eq!(policy, IceTransportPolicy::All);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_video_track_default() {
|
||||||
|
let track = VideoTrack::default();
|
||||||
|
assert_eq!(track.track_id, "video");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_data_channel_default() {
|
||||||
|
let channel = DataChannel::default();
|
||||||
|
assert_eq!(channel.channel_id, "control");
|
||||||
|
assert_eq!(channel.label, "control");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_input_event_serialization() {
|
||||||
|
let event = InputEvent::MouseMove { x: 100.0, y: 200.0 };
|
||||||
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
let deserialized: InputEvent = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
match deserialized {
|
||||||
|
InputEvent::MouseMove { x, y } => {
|
||||||
|
assert_eq!(x, 100.0);
|
||||||
|
assert_eq!(y, 200.0);
|
||||||
|
}
|
||||||
|
_ => panic!("Wrong event type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_webrtc_server_creation() {
|
||||||
|
let config = WebRtcConfig::default();
|
||||||
|
let server = WebRtcServer::new(config).await;
|
||||||
|
|
||||||
|
assert!(server.is_ok());
|
||||||
|
let server = server.unwrap();
|
||||||
|
assert_eq!(server.connection_count().await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_peer_connection() {
|
||||||
|
let config = WebRtcConfig::default();
|
||||||
|
let server = WebRtcServer::new(config).await.unwrap();
|
||||||
|
let session_id = "test_session".to_string();
|
||||||
|
|
||||||
|
let peer = server.create_peer_connection(session_id.clone()).await;
|
||||||
|
|
||||||
|
assert!(peer.is_ok());
|
||||||
|
assert_eq!(server.connection_count().await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_peer_connection() {
|
||||||
|
let config = WebRtcConfig::default();
|
||||||
|
let server = WebRtcServer::new(config).await.unwrap();
|
||||||
|
let session_id = "test_session".to_string();
|
||||||
|
|
||||||
|
server
|
||||||
|
.create_peer_connection(session_id.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let peer = server.get_peer_connection(&session_id).await;
|
||||||
|
assert!(peer.is_ok());
|
||||||
|
assert_eq!(peer.unwrap().session_id, session_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_remove_peer_connection() {
|
||||||
|
let config = WebRtcConfig::default();
|
||||||
|
let server = WebRtcServer::new(config).await.unwrap();
|
||||||
|
let session_id = "test_session".to_string();
|
||||||
|
|
||||||
|
server
|
||||||
|
.create_peer_connection(session_id.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(server.connection_count().await, 1);
|
||||||
|
server.remove_peer_connection(&session_id).await.unwrap();
|
||||||
|
assert_eq!(server.connection_count().await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_peer_connection_offer() {
|
||||||
|
let config = WebRtcConfig::default();
|
||||||
|
let server = WebRtcServer::new(config).await.unwrap();
|
||||||
|
let session_id = "test_session".to_string();
|
||||||
|
|
||||||
|
let peer = server
|
||||||
|
.create_peer_connection(session_id.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let offer = peer.create_offer().await;
|
||||||
|
assert!(offer.is_ok());
|
||||||
|
let offer = offer.unwrap();
|
||||||
|
assert_eq!(offer.sdp_type, webrtc::peer_connection::sdp::sdp_type::RTCSdpType::Offer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_video_frame_channel() {
|
||||||
|
let config = WebRtcConfig::default();
|
||||||
|
let server = WebRtcServer::new(config).await.unwrap();
|
||||||
|
|
||||||
|
let tx = server.video_frame_sender();
|
||||||
|
let frame = EncodedFrame {
|
||||||
|
data: Bytes::from(vec![0u8; 100]),
|
||||||
|
is_keyframe: true,
|
||||||
|
timestamp: 0,
|
||||||
|
sequence_number: 0,
|
||||||
|
rtp_timestamp: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(tx.send(("test".to_string(), frame)).await.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_frame_distribution_start() {
|
||||||
|
let config = WebRtcConfig::default();
|
||||||
|
let mut server = WebRtcServer::new(config).await.unwrap();
|
||||||
|
|
||||||
|
let result = server.start_frame_distribution().await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let result = server.start_frame_distribution().await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
252
tests/integration_test.rs
Normal file
252
tests/integration_test.rs
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
//! Integration tests for wl-webrtc
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod integration_tests {
|
||||||
|
#[test]
|
||||||
|
fn test_config_parsing() {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cli_overrides() {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation() {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encoder_config() {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_webrtc_config() {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_serialization() {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_merge() {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edge_cases() {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_values() {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_handling() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod capture_integration_tests {
|
||||||
|
#[test]
|
||||||
|
fn test_capture_config_defaults() {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_capture_config_validation() {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quality_levels() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod encoder_integration_tests {
|
||||||
|
#[test]
|
||||||
|
fn test_encoder_types() {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bitrate_control() {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_preset_tuning() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod webrtc_integration_tests {
|
||||||
|
#[test]
|
||||||
|
fn test_ice_server_config() {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_turn_server_config() {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_port_validation() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cli_overrides() {
|
||||||
|
// TODO: Test CLI argument overrides
|
||||||
|
// This should verify that CLI arguments properly override config file values
|
||||||
|
// Example:
|
||||||
|
// let cli = Cli::parse_from(&["wl-webrtc", "--frame-rate", "60", "start"]);
|
||||||
|
// let config = cli.load_config().unwrap();
|
||||||
|
// assert_eq!(config.capture.frame_rate, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation() {
|
||||||
|
// TODO: Test configuration validation
|
||||||
|
// This should verify that invalid configurations are rejected
|
||||||
|
// Example:
|
||||||
|
// let invalid_config = AppConfig { /* invalid values */ };
|
||||||
|
// assert!(invalid_config.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encoder_config() {
|
||||||
|
// TODO: Test encoder configuration
|
||||||
|
// This should verify encoder settings are applied correctly
|
||||||
|
// Example:
|
||||||
|
// let encoder_config = EncoderConfig::default();
|
||||||
|
// assert_eq!(encoder_config.bitrate, 4_000_000);
|
||||||
|
// assert_eq!(encoder_config.preset, EncodePreset::Veryfast);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_webrtc_config() {
|
||||||
|
// TODO: Test WebRTC configuration
|
||||||
|
// This should verify WebRTC settings are applied correctly
|
||||||
|
// Example:
|
||||||
|
// let webrtc_config = WebRtcConfig::default();
|
||||||
|
// assert_eq!(webrtc_config.port, 8443);
|
||||||
|
// assert!(!webrtc_config.ice_servers.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_serialization() {
|
||||||
|
// TODO: Test configuration serialization/deserialization
|
||||||
|
// This should verify that config can be round-tripped through TOML
|
||||||
|
// Example:
|
||||||
|
// let original = AppConfig::default();
|
||||||
|
// let toml_str = toml::to_string(&original).unwrap();
|
||||||
|
// let deserialized: AppConfig = toml::from_str(&toml_str).unwrap();
|
||||||
|
// assert_eq!(original, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_merge() {
|
||||||
|
// TODO: Test merging configs from different sources
|
||||||
|
// This should verify that file config + CLI overrides work correctly
|
||||||
|
// Example:
|
||||||
|
// let mut base = AppConfig::default();
|
||||||
|
// let overrides = ConfigOverrides { /* ... */ };
|
||||||
|
// base.merge_cli_overrides(&overrides);
|
||||||
|
// assert_eq!(/* merged values */);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edge_cases() {
|
||||||
|
// TODO: Test edge cases and boundary values
|
||||||
|
// This should verify behavior with minimum/maximum valid values
|
||||||
|
// Example:
|
||||||
|
// let config = AppConfig {
|
||||||
|
// encoder: EncoderConfig {
|
||||||
|
// bitrate: 100_000, // minimum
|
||||||
|
// ..Default::default()
|
||||||
|
// },
|
||||||
|
// ..Default::default()
|
||||||
|
// };
|
||||||
|
// assert!(config.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_values() {
|
||||||
|
// TODO: Verify default configuration values are appropriate
|
||||||
|
// This should ensure defaults are sensible for production use
|
||||||
|
// Example:
|
||||||
|
// let config = AppConfig::default();
|
||||||
|
// assert_eq!(config.capture.frame_rate, 30);
|
||||||
|
// assert_eq!(config.encoder.bitrate, 4_000_000);
|
||||||
|
// assert_eq!(config.webrtc.port, 8443);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_handling() {
|
||||||
|
// TODO: Test error messages and handling
|
||||||
|
// This should verify that errors provide helpful information
|
||||||
|
// Example:
|
||||||
|
// let config = AppConfig { /* invalid */ };
|
||||||
|
// match config.validate() {
|
||||||
|
// Err(ConfigError::InvalidBitrate(b)) => {
|
||||||
|
// assert!(b < 100_000 || b > 50_000_000);
|
||||||
|
// }
|
||||||
|
// _ => panic!("Expected InvalidBitrate error"),
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod capture_integration_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_capture_config_defaults() {
|
||||||
|
// TODO: Test capture configuration defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_capture_config_validation() {
|
||||||
|
// TODO: Test capture configuration validation
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quality_levels() {
|
||||||
|
// TODO: Test different quality levels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod encoder_integration_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encoder_types() {
|
||||||
|
// TODO: Test different encoder types (x264, VA-API, NVENC, VP9)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bitrate_control() {
|
||||||
|
// TODO: Test bitrate min/max/target relationships
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_preset_tuning() {
|
||||||
|
// TODO: Test preset and tuning combinations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod webrtc_integration_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ice_server_config() {
|
||||||
|
// TODO: Test ICE server configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_turn_server_config() {
|
||||||
|
// TODO: Test TURN server configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_port_validation() {
|
||||||
|
// TODO: Test port range validation
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user