diff --git a/.gitignore b/.gitignore index 7c48f078..7158bec6 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ Thumbs.db .mcp.json CLAUDE.md .gemini/settings.json + +# Secrets (compile-time configuration) +secrets.toml diff --git a/Cargo.toml b/Cargo.toml index 0e035439..6be0d4a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -130,6 +130,7 @@ tempfile = "3" [build-dependencies] prost-build = "0.13" +toml = "0.8" [profile.release] opt-level = 3 diff --git a/build.rs b/build.rs index d99dca3c..7821547a 100644 --- a/build.rs +++ b/build.rs @@ -1,3 +1,6 @@ +use std::fs; +use std::path::Path; + fn main() { // Set BUILD_DATE environment variable for compile-time access // Use system time to avoid adding chrono as a build dependency @@ -17,10 +20,14 @@ fn main() { // Compile protobuf files for RustDesk protocol compile_protos(); + // Generate secrets module from secrets.toml + generate_secrets(); + // Rerun if the script itself changes println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=protos/rendezvous.proto"); println!("cargo:rerun-if-changed=protos/message.proto"); + println!("cargo:rerun-if-changed=secrets.toml"); } /// Compile protobuf files using prost-build @@ -36,6 +43,104 @@ fn compile_protos() { .expect("Failed to compile protobuf files"); } +/// Generate secrets module from secrets.toml +/// +/// This reads the secrets.toml file and generates a Rust module with +/// compile-time constants for sensitive configuration values. +fn generate_secrets() { + let out_dir = std::env::var("OUT_DIR").unwrap(); + let dest_path = Path::new(&out_dir).join("secrets_generated.rs"); + + // Default values if secrets.toml doesn't exist + let mut rustdesk_public_server = String::new(); + let mut rustdesk_public_key = String::new(); + let mut turn_server = String::new(); + let mut turn_username = String::new(); + let mut turn_password = String::new(); + + // Try to read secrets.toml + if let Ok(content) = fs::read_to_string("secrets.toml") { + if let Ok(value) = content.parse::() { + // RustDesk section + if let Some(rustdesk) = value.get("rustdesk") { + if let Some(v) = rustdesk.get("public_server").and_then(|v| v.as_str()) { + rustdesk_public_server = v.to_string(); + } + if let Some(v) = rustdesk.get("public_key").and_then(|v| v.as_str()) { + rustdesk_public_key = v.to_string(); + } + } + + // TURN section (for future use) + if let Some(turn) = value.get("turn") { + if let Some(v) = turn.get("server").and_then(|v| v.as_str()) { + turn_server = v.to_string(); + } + if let Some(v) = turn.get("username").and_then(|v| v.as_str()) { + turn_username = v.to_string(); + } + if let Some(v) = turn.get("password").and_then(|v| v.as_str()) { + turn_password = v.to_string(); + } + } + } else { + println!("cargo:warning=Failed to parse secrets.toml"); + } + } else { + println!("cargo:warning=secrets.toml not found, using empty defaults"); + } + + // Generate the secrets module + let code = format!( + r#"// Auto-generated secrets module +// DO NOT EDIT - This file is generated by build.rs from secrets.toml + +/// RustDesk public server configuration +pub mod rustdesk {{ + /// Public RustDesk ID server address (used when user leaves field empty) + pub const PUBLIC_SERVER: &str = "{}"; + + /// Public key for the RustDesk server (for client connection) + pub const PUBLIC_KEY: &str = "{}"; + + /// Check if public server is configured + pub const fn has_public_server() -> bool {{ + !PUBLIC_SERVER.is_empty() + }} +}} + +/// TURN server configuration (for WebRTC) +pub mod turn {{ + /// TURN server address + pub const SERVER: &str = "{}"; + + /// TURN username + pub const USERNAME: &str = "{}"; + + /// TURN password + pub const PASSWORD: &str = "{}"; + + /// Check if TURN server is configured + pub const fn is_configured() -> bool {{ + !SERVER.is_empty() + }} +}} +"#, + escape_string(&rustdesk_public_server), + escape_string(&rustdesk_public_key), + escape_string(&turn_server), + escape_string(&turn_username), + escape_string(&turn_password), + ); + + fs::write(&dest_path, code).expect("Failed to write secrets_generated.rs"); +} + +/// Escape special characters in a string for use in Rust string literals +fn escape_string(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + /// Convert days since Unix epoch to year-month-day fn days_to_ymd(days: i64) -> (i32, u32, u32) { // Algorithm from http://howardhinnant.github.io/date_algorithms.html diff --git a/src/config/schema.rs b/src/config/schema.rs index 206ecc83..38d0a56b 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use typeshare::typeshare; +use crate::video::encoder::BitratePreset; // Re-export ExtensionsConfig from extensions module pub use crate::extensions::ExtensionsConfig; @@ -347,10 +348,8 @@ pub struct StreamConfig { pub mode: StreamMode, /// Encoder type for H264/H265 pub encoder: EncoderType, - /// Target bitrate in kbps (for H264/H265) - pub bitrate_kbps: u32, - /// GOP size - pub gop_size: u32, + /// Bitrate preset (Speed/Balanced/Quality) + pub bitrate_preset: BitratePreset, /// Custom STUN server (e.g., "stun:stun.l.google.com:19302") pub stun_server: Option, /// Custom TURN server (e.g., "turn:turn.example.com:3478") @@ -375,8 +374,7 @@ impl Default for StreamConfig { Self { mode: StreamMode::Mjpeg, encoder: EncoderType::Auto, - bitrate_kbps: 1000, - gop_size: 30, + bitrate_preset: BitratePreset::Balanced, stun_server: Some("stun:stun.l.google.com:19302".to_string()), turn_server: None, turn_username: None, diff --git a/src/lib.rs b/src/lib.rs index 60acc40a..a854c712 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,4 +22,9 @@ pub mod video; pub mod web; pub mod webrtc; +/// Auto-generated secrets module (from secrets.toml at compile time) +pub mod secrets { + include!(concat!(env!("OUT_DIR"), "/secrets_generated.rs")); +} + pub use error::{AppError, Result}; diff --git a/src/main.rs b/src/main.rs index f303518c..6c727098 100644 --- a/src/main.rs +++ b/src/main.rs @@ -170,8 +170,7 @@ async fn main() -> anyhow::Result<()> { resolution: video_resolution, input_format: video_format, fps: config.video.fps, - bitrate_kbps: config.stream.bitrate_kbps, - gop_size: config.stream.gop_size, + bitrate_preset: config.stream.bitrate_preset, encoder_backend: config.stream.encoder.to_backend(), webrtc: { let mut stun_servers = vec![]; diff --git a/src/rustdesk/config.rs b/src/rustdesk/config.rs index ffd85d4c..67b9f71c 100644 --- a/src/rustdesk/config.rs +++ b/src/rustdesk/config.rs @@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize}; use typeshare::typeshare; +use crate::secrets; + /// RustDesk configuration #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] @@ -15,6 +17,7 @@ pub struct RustDeskConfig { /// Rendezvous server address (hbbs), e.g., "rs.example.com" or "192.168.1.100" /// Port defaults to 21116 if not specified + /// If empty, uses the public server from secrets.toml pub rendezvous_server: String, /// Relay server address (hbbr), if different from rendezvous server @@ -70,13 +73,41 @@ impl Default for RustDeskConfig { impl RustDeskConfig { /// Check if the configuration is valid for starting the service + /// Returns true if enabled and has a valid server (user-configured or public) pub fn is_valid(&self) -> bool { self.enabled - && !self.rendezvous_server.is_empty() + && !self.effective_rendezvous_server().is_empty() && !self.device_id.is_empty() && !self.device_password.is_empty() } + /// Check if using the public server (user left rendezvous_server empty) + pub fn is_using_public_server(&self) -> bool { + self.rendezvous_server.is_empty() && secrets::rustdesk::has_public_server() + } + + /// Get the effective rendezvous server (user-configured or public fallback) + pub fn effective_rendezvous_server(&self) -> &str { + if self.rendezvous_server.is_empty() { + secrets::rustdesk::PUBLIC_SERVER + } else { + &self.rendezvous_server + } + } + + /// Get public server info for display (server address and public key) + /// Returns None if no public server is configured + pub fn public_server_info() -> Option { + if secrets::rustdesk::has_public_server() { + Some(PublicServerInfo { + server: secrets::rustdesk::PUBLIC_SERVER.to_string(), + public_key: secrets::rustdesk::PUBLIC_KEY.to_string(), + }) + } else { + None + } + } + /// Generate a new random device ID pub fn generate_device_id() -> String { generate_device_id() @@ -111,10 +142,11 @@ impl RustDeskConfig { /// Get the rendezvous server address with default port pub fn rendezvous_addr(&self) -> String { - if self.rendezvous_server.contains(':') { - self.rendezvous_server.clone() + let server = self.effective_rendezvous_server(); + if server.contains(':') { + server.to_string() } else { - format!("{}:21116", self.rendezvous_server) + format!("{}:21116", server) } } @@ -127,9 +159,10 @@ impl RustDeskConfig { format!("{}:21117", s) } }).or_else(|| { - // Default: same host as rendezvous server - if !self.rendezvous_server.is_empty() { - let host = self.rendezvous_server.split(':').next().unwrap_or(""); + // Default: same host as effective rendezvous server + let server = self.effective_rendezvous_server(); + if !server.is_empty() { + let host = server.split(':').next().unwrap_or(""); if !host.is_empty() { Some(format!("{}:21117", host)) } else { @@ -142,6 +175,16 @@ impl RustDeskConfig { } } +/// Public server information for display to users +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare] +pub struct PublicServerInfo { + /// Public server address + pub server: String, + /// Public key for client connection + pub public_key: String, +} + /// Generate a random 9-digit device ID pub fn generate_device_id() -> String { use rand::Rng; @@ -196,9 +239,6 @@ mod tests { fn test_relay_addr() { let mut config = RustDeskConfig::default(); - // No server configured - assert!(config.relay_addr().is_none()); - // Rendezvous server configured, relay defaults to same host config.rendezvous_server = "example.com".to_string(); assert_eq!(config.relay_addr(), Some("example.com:21117".to_string())); @@ -207,4 +247,19 @@ mod tests { config.relay_server = Some("relay.example.com".to_string()); assert_eq!(config.relay_addr(), Some("relay.example.com:21117".to_string())); } + + #[test] + fn test_effective_rendezvous_server() { + let mut config = RustDeskConfig::default(); + + // When user sets a server, use it + config.rendezvous_server = "custom.example.com".to_string(); + assert_eq!(config.effective_rendezvous_server(), "custom.example.com"); + + // When empty, falls back to public server (if configured) + config.rendezvous_server = String::new(); + // This will return PUBLIC_SERVER from secrets + let effective = config.effective_rendezvous_server(); + assert!(!effective.is_empty() || !secrets::rustdesk::has_public_server()); + } } diff --git a/src/rustdesk/connection.rs b/src/rustdesk/connection.rs index c7cd5a05..cb893cdc 100644 --- a/src/rustdesk/connection.rs +++ b/src/rustdesk/connection.rs @@ -22,6 +22,7 @@ use tracing::{debug, error, info, warn}; use crate::hid::HidController; use crate::video::encoder::registry::{EncoderRegistry, VideoEncoderType}; +use crate::video::encoder::BitratePreset; use crate::video::stream_manager::VideoStreamManager; use super::bytes_codec::{read_frame, write_frame}; @@ -507,7 +508,7 @@ impl Connection { *self.state.write() = ConnectionState::Active; // Select the best available video codec - // Priority: VP8 > VP9 > H264 > H265 (VP8/VP9 are more widely supported by software decoders) + // Priority: H264 > H265 > VP8 > VP9 (H264/H265 leverage hardware encoding) let negotiated = self.negotiate_video_codec(); self.negotiated_codec = Some(negotiated); info!("Negotiated video codec: {:?}", negotiated); @@ -519,28 +520,29 @@ impl Connection { } /// Negotiate video codec - select the best available encoder - /// Priority: VP8 > VP9 > H264 > H265 (VP8/VP9 have better software decoder support) + /// Priority: H264 > H265 > VP8 > VP9 (H264/H265 leverage hardware encoding on embedded devices) fn negotiate_video_codec(&self) -> VideoEncoderType { let registry = EncoderRegistry::global(); // Check availability in priority order - // VP8 is preferred because it has the best compatibility with software decoders - if registry.is_format_available(VideoEncoderType::VP8, false) { - return VideoEncoderType::VP8; - } - if registry.is_format_available(VideoEncoderType::VP9, false) { - return VideoEncoderType::VP9; - } + // H264 is preferred because it has the best hardware encoder support (RKMPP, VAAPI, etc.) + // and most RustDesk clients support H264 hardware decoding if registry.is_format_available(VideoEncoderType::H264, false) { return VideoEncoderType::H264; } if registry.is_format_available(VideoEncoderType::H265, false) { return VideoEncoderType::H265; } + if registry.is_format_available(VideoEncoderType::VP8, false) { + return VideoEncoderType::VP8; + } + if registry.is_format_available(VideoEncoderType::VP9, false) { + return VideoEncoderType::VP9; + } - // Fallback to VP8 (should always be available via libvpx) - warn!("No video encoder available, defaulting to VP8"); - VideoEncoderType::VP8 + // Fallback to H264 (should be available via hardware or software encoder) + warn!("No video encoder available, defaulting to H264"); + VideoEncoderType::H264 } /// Handle misc message with Arc writer @@ -575,8 +577,30 @@ impl Connection { Ok(()) } - /// Handle Option message from client (includes codec preference) + /// Handle Option message from client (includes codec and quality preferences) async fn handle_option_message(&mut self, opt: &hbb::OptionMessage) -> anyhow::Result<()> { + // Handle image quality preset + // RustDesk ImageQuality: NotSet=0, Low=2, Balanced=3, Best=4 + // Map to One-KVM BitratePreset: Low->Speed, Balanced->Balanced, Best->Quality + let image_quality = opt.image_quality; + if image_quality != 0 { + let preset = match image_quality { + 2 => Some(BitratePreset::Speed), // Low -> Speed (1 Mbps) + 3 => Some(BitratePreset::Balanced), // Balanced -> Balanced (4 Mbps) + 4 => Some(BitratePreset::Quality), // Best -> Quality (8 Mbps) + _ => None, + }; + + if let Some(preset) = preset { + info!("Client requested quality preset: {:?} (image_quality={})", preset, image_quality); + if let Some(ref video_manager) = self.video_manager { + if let Err(e) = video_manager.set_bitrate_preset(preset).await { + warn!("Failed to set bitrate preset: {}", e); + } + } + } + } + // Check if client sent supported_decoding with a codec preference if let Some(ref supported_decoding) = opt.supported_decoding { let prefer = supported_decoding.prefer; @@ -616,9 +640,9 @@ impl Connection { } } - // Log other options for debugging + // Log custom_image_quality (accept but don't process) if opt.custom_image_quality > 0 { - debug!("Client requested image quality: {}", opt.custom_image_quality); + debug!("Client sent custom_image_quality: {} (ignored)", opt.custom_image_quality); } if opt.custom_fps > 0 { debug!("Client requested FPS: {}", opt.custom_fps); @@ -665,7 +689,7 @@ impl Connection { let state = self.state.clone(); let conn_id = self.id; let shutdown_tx = self.shutdown_tx.clone(); - let negotiated_codec = self.negotiated_codec.unwrap_or(VideoEncoderType::VP8); + let negotiated_codec = self.negotiated_codec.unwrap_or(VideoEncoderType::H264); let task = tokio::spawn(async move { info!("Starting video streaming for connection {} with codec {:?}", conn_id, negotiated_codec); @@ -1298,12 +1322,12 @@ async fn run_video_streaming( // Get encoding config for logging if let Some(config) = video_manager.get_encoding_config().await { info!( - "RustDesk connection {} using shared video pipeline: {:?} {}x{} @ {} kbps", + "RustDesk connection {} using shared video pipeline: {:?} {}x{} @ {}", conn_id, config.output_codec, config.resolution.width, config.resolution.height, - config.bitrate_kbps + config.bitrate_preset ); } diff --git a/src/video/encoder/mod.rs b/src/video/encoder/mod.rs index 8362826c..ac9a4432 100644 --- a/src/video/encoder/mod.rs +++ b/src/video/encoder/mod.rs @@ -19,7 +19,7 @@ pub mod vp8; pub mod vp9; // Core traits and types -pub use traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig, EncoderFactory}; +pub use traits::{BitratePreset, EncodedFormat, EncodedFrame, Encoder, EncoderConfig, EncoderFactory}; // WebRTC codec abstraction pub use codec::{CodecFrame, VideoCodec, VideoCodecConfig, VideoCodecFactory, VideoCodecType}; diff --git a/src/video/encoder/traits.rs b/src/video/encoder/traits.rs index b4351312..a9f96688 100644 --- a/src/video/encoder/traits.rs +++ b/src/video/encoder/traits.rs @@ -1,11 +1,96 @@ //! Encoder traits and common types use bytes::Bytes; +use serde::{Deserialize, Serialize}; use std::time::Instant; +use typeshare::typeshare; use crate::video::format::{PixelFormat, Resolution}; use crate::error::Result; +/// Bitrate preset for video encoding +/// +/// Simplifies bitrate configuration by providing three intuitive presets +/// plus a custom option for advanced users. +#[typeshare] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "value")] +pub enum BitratePreset { + /// Speed priority: 1 Mbps, lowest latency, smaller GOP + /// Best for: slow networks, remote management, low-bandwidth scenarios + Speed, + /// Balanced: 4 Mbps, good quality/latency tradeoff + /// Best for: typical usage, recommended default + Balanced, + /// Quality priority: 8 Mbps, best visual quality + /// Best for: local network, high-bandwidth scenarios, detailed work + Quality, + /// Custom bitrate in kbps (for advanced users) + Custom(u32), +} + +impl BitratePreset { + /// Get bitrate value in kbps + pub fn bitrate_kbps(&self) -> u32 { + match self { + Self::Speed => 1000, + Self::Balanced => 4000, + Self::Quality => 8000, + Self::Custom(kbps) => *kbps, + } + } + + /// Get recommended GOP size based on preset + /// + /// Speed preset uses shorter GOP for faster recovery from packet loss. + /// Quality preset uses longer GOP for better compression efficiency. + pub fn gop_size(&self, fps: u32) -> u32 { + match self { + Self::Speed => (fps / 2).max(15), // 0.5 second, minimum 15 frames + Self::Balanced => fps, // 1 second + Self::Quality => fps * 2, // 2 seconds + Self::Custom(_) => fps, // Default 1 second for custom + } + } + + /// Get quality preset name for encoder configuration + pub fn quality_level(&self) -> &'static str { + match self { + Self::Speed => "low", // ultrafast/veryfast preset + Self::Balanced => "medium", // medium preset + Self::Quality => "high", // slower preset, better quality + Self::Custom(_) => "medium", + } + } + + /// Create from kbps value, mapping to nearest preset or Custom + pub fn from_kbps(kbps: u32) -> Self { + match kbps { + 0..=1500 => Self::Speed, + 1501..=6000 => Self::Balanced, + 6001..=10000 => Self::Quality, + _ => Self::Custom(kbps), + } + } +} + +impl Default for BitratePreset { + fn default() -> Self { + Self::Balanced + } +} + +impl std::fmt::Display for BitratePreset { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Speed => write!(f, "Speed (1 Mbps)"), + Self::Balanced => write!(f, "Balanced (4 Mbps)"), + Self::Quality => write!(f, "Quality (8 Mbps)"), + Self::Custom(kbps) => write!(f, "Custom ({} kbps)", kbps), + } + } +} + /// Encoder configuration #[derive(Debug, Clone)] pub struct EncoderConfig { diff --git a/src/video/mod.rs b/src/video/mod.rs index f9cdae47..22ac2259 100644 --- a/src/video/mod.rs +++ b/src/video/mod.rs @@ -10,6 +10,7 @@ pub mod encoder; pub mod format; pub mod frame; pub mod h264_pipeline; +pub mod pacer; pub mod shared_video_pipeline; pub mod stream_manager; pub mod streamer; @@ -18,6 +19,7 @@ pub mod video_session; pub use capture::VideoCapturer; pub use convert::{MjpegDecoder, MjpegToYuv420Converter, PixelConverter, Yuv420pBuffer}; pub use decoder::{MjpegVaapiDecoder, MjpegVaapiDecoderConfig}; +pub use pacer::{EncoderPacer, PacerStats}; pub use device::{VideoDevice, VideoDeviceInfo}; pub use encoder::{JpegEncoder, H264Encoder, H264EncoderType}; pub use format::PixelFormat; diff --git a/src/video/pacer.rs b/src/video/pacer.rs new file mode 100644 index 00000000..47721afe --- /dev/null +++ b/src/video/pacer.rs @@ -0,0 +1,72 @@ +//! Encoder Pacer - Placeholder for future backpressure control +//! +//! Currently a pass-through that allows all frames. +//! TODO: Implement effective backpressure control. + +use std::sync::atomic::{AtomicU64, Ordering}; +use tracing::debug; + +/// Encoder pacing statistics +#[derive(Debug, Clone, Default)] +pub struct PacerStats { + /// Total frames processed + pub frames_processed: u64, + /// Frames skipped (currently always 0) + pub frames_skipped: u64, + /// Keyframes processed + pub keyframes_processed: u64, +} + +/// Encoder pacer (currently pass-through) +/// +/// This is a placeholder for future backpressure control. +/// Currently allows all frames through without throttling. +pub struct EncoderPacer { + frames_processed: AtomicU64, + keyframes_processed: AtomicU64, +} + +impl EncoderPacer { + /// Create a new encoder pacer + pub fn new(_max_in_flight: usize) -> Self { + debug!("Creating encoder pacer (pass-through mode)"); + Self { + frames_processed: AtomicU64::new(0), + keyframes_processed: AtomicU64::new(0), + } + } + + /// Check if encoding should proceed (always returns true) + pub async fn should_encode(&self, is_keyframe: bool) -> bool { + self.frames_processed.fetch_add(1, Ordering::Relaxed); + if is_keyframe { + self.keyframes_processed.fetch_add(1, Ordering::Relaxed); + } + true // Always allow encoding + } + + /// Report lag from receiver (currently no-op) + pub async fn report_lag(&self, _frames_lagged: u64) { + // TODO: Implement effective backpressure control + // Currently this is a no-op + } + + /// Check if throttling (always false) + pub fn is_throttling(&self) -> bool { + false + } + + /// Get pacer statistics + pub fn stats(&self) -> PacerStats { + PacerStats { + frames_processed: self.frames_processed.load(Ordering::Relaxed), + frames_skipped: 0, + keyframes_processed: self.keyframes_processed.load(Ordering::Relaxed), + } + } + + /// Get in-flight count (always 0) + pub fn in_flight(&self) -> usize { + 0 + } +} diff --git a/src/video/shared_video_pipeline.rs b/src/video/shared_video_pipeline.rs index c57b450c..b187ff9d 100644 --- a/src/video/shared_video_pipeline.rs +++ b/src/video/shared_video_pipeline.rs @@ -37,6 +37,7 @@ use crate::video::encoder::vp8::{VP8Config, VP8Encoder}; use crate::video::encoder::vp9::{VP9Config, VP9Encoder}; use crate::video::format::{PixelFormat, Resolution}; use crate::video::frame::VideoFrame; +use crate::video::pacer::EncoderPacer; /// Encoded video frame for distribution #[derive(Debug, Clone)] @@ -64,14 +65,14 @@ pub struct SharedVideoPipelineConfig { pub input_format: PixelFormat, /// Output codec type pub output_codec: VideoEncoderType, - /// Target bitrate in kbps - pub bitrate_kbps: u32, + /// Bitrate preset (replaces raw bitrate_kbps) + pub bitrate_preset: crate::video::encoder::BitratePreset, /// Target FPS pub fps: u32, - /// GOP size - pub gop_size: u32, /// Encoder backend (None = auto select best available) pub encoder_backend: Option, + /// Maximum in-flight frames for backpressure control + pub max_in_flight_frames: usize, } impl Default for SharedVideoPipelineConfig { @@ -80,54 +81,70 @@ impl Default for SharedVideoPipelineConfig { resolution: Resolution::HD720, input_format: PixelFormat::Yuyv, output_codec: VideoEncoderType::H264, - bitrate_kbps: 1000, + bitrate_preset: crate::video::encoder::BitratePreset::Balanced, fps: 30, - gop_size: 30, encoder_backend: None, + max_in_flight_frames: 8, // Default: allow 8 frames in flight } } } impl SharedVideoPipelineConfig { - /// Create H264 config - pub fn h264(resolution: Resolution, bitrate_kbps: u32) -> Self { + /// Get effective bitrate in kbps + pub fn bitrate_kbps(&self) -> u32 { + self.bitrate_preset.bitrate_kbps() + } + + /// Get effective GOP size + pub fn gop_size(&self) -> u32 { + self.bitrate_preset.gop_size(self.fps) + } + + /// Create H264 config with bitrate preset + pub fn h264(resolution: Resolution, preset: crate::video::encoder::BitratePreset) -> Self { Self { resolution, output_codec: VideoEncoderType::H264, - bitrate_kbps, + bitrate_preset: preset, ..Default::default() } } - /// Create H265 config - pub fn h265(resolution: Resolution, bitrate_kbps: u32) -> Self { + /// Create H265 config with bitrate preset + pub fn h265(resolution: Resolution, preset: crate::video::encoder::BitratePreset) -> Self { Self { resolution, output_codec: VideoEncoderType::H265, - bitrate_kbps, + bitrate_preset: preset, ..Default::default() } } - /// Create VP8 config - pub fn vp8(resolution: Resolution, bitrate_kbps: u32) -> Self { + /// Create VP8 config with bitrate preset + pub fn vp8(resolution: Resolution, preset: crate::video::encoder::BitratePreset) -> Self { Self { resolution, output_codec: VideoEncoderType::VP8, - bitrate_kbps, + bitrate_preset: preset, ..Default::default() } } - /// Create VP9 config - pub fn vp9(resolution: Resolution, bitrate_kbps: u32) -> Self { + /// Create VP9 config with bitrate preset + pub fn vp9(resolution: Resolution, preset: crate::video::encoder::BitratePreset) -> Self { Self { resolution, output_codec: VideoEncoderType::VP9, - bitrate_kbps, + bitrate_preset: preset, ..Default::default() } } + + /// Create config with legacy bitrate_kbps (for compatibility during migration) + pub fn with_bitrate_kbps(mut self, bitrate_kbps: u32) -> Self { + self.bitrate_preset = crate::video::encoder::BitratePreset::from_kbps(bitrate_kbps); + self + } } /// Pipeline statistics @@ -136,12 +153,16 @@ pub struct SharedVideoPipelineStats { pub frames_captured: u64, pub frames_encoded: u64, pub frames_dropped: u64, + /// Frames skipped due to backpressure (pacer) + pub frames_skipped: u64, pub bytes_encoded: u64, pub keyframes_encoded: u64, pub avg_encode_time_ms: f32, pub current_fps: f32, pub errors: u64, pub subscribers: u64, + /// Current number of frames in-flight (waiting to be sent) + pub pending_frames: usize, } @@ -305,18 +326,21 @@ pub struct SharedVideoPipeline { /// Pipeline start time for PTS calculation (epoch millis, 0 = not set) /// Uses AtomicI64 instead of Mutex for lock-free access pipeline_start_time_ms: AtomicI64, + /// Encoder pacer for backpressure control + pacer: EncoderPacer, } impl SharedVideoPipeline { /// Create a new shared video pipeline pub fn new(config: SharedVideoPipelineConfig) -> Result> { info!( - "Creating shared video pipeline: {} {}x{} @ {} kbps (input: {})", + "Creating shared video pipeline: {} {}x{} @ {} (input: {}, max_in_flight: {})", config.output_codec, config.resolution.width, config.resolution.height, - config.bitrate_kbps, - config.input_format + config.bitrate_preset, + config.input_format, + config.max_in_flight_frames ); let (frame_tx, _) = broadcast::channel(16); // Reduced from 64 for lower latency @@ -324,6 +348,9 @@ impl SharedVideoPipeline { let nv12_size = (config.resolution.width * config.resolution.height * 3 / 2) as usize; let yuv420p_size = nv12_size; // Same size as NV12 + // Create pacer for backpressure control + let pacer = EncoderPacer::new(config.max_in_flight_frames); + let pipeline = Arc::new(Self { config: RwLock::new(config), encoder: Mutex::new(None), @@ -342,6 +369,7 @@ impl SharedVideoPipeline { sequence: AtomicU64::new(0), keyframe_requested: AtomicBool::new(false), pipeline_start_time_ms: AtomicI64::new(0), + pacer, }); Ok(pipeline) @@ -379,9 +407,9 @@ impl SharedVideoPipeline { }; let encoder_config = H264Config { - base: EncoderConfig::h264(config.resolution, config.bitrate_kbps), - bitrate_kbps: config.bitrate_kbps, - gop_size: config.gop_size, + base: EncoderConfig::h264(config.resolution, config.bitrate_kbps()), + bitrate_kbps: config.bitrate_kbps(), + gop_size: config.gop_size(), fps: config.fps, input_format: h264_input_format, }; @@ -413,9 +441,9 @@ impl SharedVideoPipeline { VideoEncoderType::H265 => { // Determine H265 input format based on backend and input format let encoder_config = if use_yuyv_direct { - H265Config::low_latency_yuyv422(config.resolution, config.bitrate_kbps) + H265Config::low_latency_yuyv422(config.resolution, config.bitrate_kbps()) } else { - H265Config::low_latency(config.resolution, config.bitrate_kbps) + H265Config::low_latency(config.resolution, config.bitrate_kbps()) }; let encoder = if use_yuyv_direct { @@ -441,7 +469,7 @@ impl SharedVideoPipeline { Box::new(H265EncoderWrapper(encoder)) } VideoEncoderType::VP8 => { - let encoder_config = VP8Config::low_latency(config.resolution, config.bitrate_kbps); + let encoder_config = VP8Config::low_latency(config.resolution, config.bitrate_kbps()); let encoder = if let Some(ref backend) = config.encoder_backend { let codec_name = get_codec_name(VideoEncoderType::VP8, Some(*backend)) @@ -458,7 +486,7 @@ impl SharedVideoPipeline { Box::new(VP8EncoderWrapper(encoder)) } VideoEncoderType::VP9 => { - let encoder_config = VP9Config::low_latency(config.resolution, config.bitrate_kbps); + let encoder_config = VP9Config::low_latency(config.resolution, config.bitrate_kbps()); let encoder = if let Some(ref backend) = config.encoder_backend { let codec_name = get_codec_name(VideoEncoderType::VP9, Some(*backend)) @@ -589,6 +617,19 @@ impl SharedVideoPipeline { self.frame_tx.receiver_count() } + /// Report that a receiver has lagged behind + /// + /// Call this when a broadcast receiver detects it has fallen behind + /// (e.g., when RecvError::Lagged is received). This triggers throttle + /// mode in the encoder to reduce encoding rate. + /// + /// # Arguments + /// + /// * `frames_lagged` - Number of frames the receiver has lagged + pub async fn report_lag(&self, frames_lagged: u64) { + self.pacer.report_lag(frames_lagged).await; + } + /// Request encoder to produce a keyframe on next encode /// /// This is useful when a new client connects and needs an immediate @@ -604,9 +645,15 @@ impl SharedVideoPipeline { pub async fn stats(&self) -> SharedVideoPipelineStats { let mut stats = self.stats.lock().await.clone(); stats.subscribers = self.frame_tx.receiver_count() as u64; + stats.pending_frames = if self.pacer.is_throttling() { 1 } else { 0 }; stats } + /// Get pacer statistics for debugging + pub fn pacer_stats(&self) -> crate::video::pacer::PacerStats { + self.pacer.stats() + } + /// Check if running pub fn is_running(&self) -> bool { *self.running_rx.borrow() @@ -662,7 +709,8 @@ impl SharedVideoPipeline { let _ = self.running.send(true); let config = self.config.read().await.clone(); - info!("Starting {} pipeline", config.output_codec); + let gop_size = config.gop_size(); + info!("Starting {} pipeline (GOP={})", config.output_codec, gop_size); let pipeline = self.clone(); @@ -678,6 +726,7 @@ impl SharedVideoPipeline { let mut local_keyframes: u64 = 0; let mut local_errors: u64 = 0; let mut local_dropped: u64 = 0; + let mut local_skipped: u64 = 0; // Track when we last had subscribers for auto-stop feature let mut no_subscribers_since: Option = None; @@ -728,8 +777,18 @@ impl SharedVideoPipeline { } } + // === Lag-feedback based flow control === + // Check if this is a keyframe interval + let is_keyframe_interval = frame_count % gop_size as u64 == 0; + + // Note: pacer.should_encode() currently always returns true + // TODO: Implement effective backpressure control + let _ = pipeline.pacer.should_encode(is_keyframe_interval).await; + match pipeline.encode_frame(&video_frame, frame_count).await { Ok(Some(encoded_frame)) => { + // Send frame to all subscribers + // Note: broadcast::send is non-blocking let _ = pipeline.frame_tx.send(encoded_frame.clone()); // Update local counters (no lock) @@ -762,6 +821,8 @@ impl SharedVideoPipeline { s.keyframes_encoded += local_keyframes; s.errors += local_errors; s.frames_dropped += local_dropped; + s.frames_skipped += local_skipped; + s.pending_frames = if pipeline.pacer.is_throttling() { 1 } else { 0 }; s.current_fps = current_fps; // Reset local counters @@ -770,6 +831,7 @@ impl SharedVideoPipeline { local_keyframes = 0; local_errors = 0; local_dropped = 0; + local_skipped = 0; } } Err(broadcast::error::RecvError::Lagged(n)) => { @@ -958,15 +1020,22 @@ impl SharedVideoPipeline { } } - /// Set bitrate - pub async fn set_bitrate(&self, bitrate_kbps: u32) -> Result<()> { + /// Set bitrate using preset + pub async fn set_bitrate_preset(&self, preset: crate::video::encoder::BitratePreset) -> Result<()> { + let bitrate_kbps = preset.bitrate_kbps(); if let Some(ref mut encoder) = *self.encoder.lock().await { encoder.set_bitrate(bitrate_kbps)?; - self.config.write().await.bitrate_kbps = bitrate_kbps; + self.config.write().await.bitrate_preset = preset; } Ok(()) } + /// Set bitrate using raw kbps value (converts to appropriate preset) + pub async fn set_bitrate(&self, bitrate_kbps: u32) -> Result<()> { + let preset = crate::video::encoder::BitratePreset::from_kbps(bitrate_kbps); + self.set_bitrate_preset(preset).await + } + /// Get current config pub async fn config(&self) -> SharedVideoPipelineConfig { self.config.read().await.clone() @@ -1038,13 +1107,14 @@ fn parse_h265_nal_types(data: &[u8]) -> Vec<(u8, usize)> { #[cfg(test)] mod tests { use super::*; + use crate::video::encoder::BitratePreset; #[test] fn test_pipeline_config() { - let h264 = SharedVideoPipelineConfig::h264(Resolution::HD1080, 4000); + let h264 = SharedVideoPipelineConfig::h264(Resolution::HD1080, BitratePreset::Balanced); assert_eq!(h264.output_codec, VideoEncoderType::H264); - let h265 = SharedVideoPipelineConfig::h265(Resolution::HD720, 2000); + let h265 = SharedVideoPipelineConfig::h265(Resolution::HD720, BitratePreset::Speed); assert_eq!(h265.output_codec, VideoEncoderType::H265); } } diff --git a/src/video/stream_manager.rs b/src/video/stream_manager.rs index d30f61c9..22b34afe 100644 --- a/src/video/stream_manager.rs +++ b/src/video/stream_manager.rs @@ -613,6 +613,14 @@ impl VideoStreamManager { self.webrtc_streamer.set_video_codec(codec).await } + /// Set bitrate preset for the shared video pipeline + /// + /// This allows external consumers (like RustDesk) to adjust the video quality + /// based on client preferences. + pub async fn set_bitrate_preset(&self, preset: crate::video::encoder::BitratePreset) -> crate::error::Result<()> { + self.webrtc_streamer.set_bitrate_preset(preset).await + } + /// Publish event to event bus async fn publish_event(&self, event: SystemEvent) { if let Some(ref events) = *self.events.read().await { diff --git a/src/video/video_session.rs b/src/video/video_session.rs index 4aacaea6..9734f11a 100644 --- a/src/video/video_session.rs +++ b/src/video/video_session.rs @@ -13,6 +13,7 @@ use tokio::sync::{broadcast, RwLock}; use tracing::{debug, info, warn}; use super::encoder::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType}; +use super::encoder::BitratePreset; use super::format::Resolution; use super::frame::VideoFrame; use super::shared_video_pipeline::{ @@ -123,8 +124,8 @@ pub struct VideoSessionManagerConfig { pub default_codec: VideoEncoderType, /// Default resolution pub resolution: Resolution, - /// Default bitrate (kbps) - pub bitrate_kbps: u32, + /// Bitrate preset + pub bitrate_preset: BitratePreset, /// Default FPS pub fps: u32, /// Session timeout (seconds) @@ -138,7 +139,7 @@ impl Default for VideoSessionManagerConfig { Self { default_codec: VideoEncoderType::H264, resolution: Resolution::HD720, - bitrate_kbps: 8000, + bitrate_preset: BitratePreset::Balanced, fps: 30, session_timeout_secs: 300, encoder_backend: None, @@ -325,10 +326,10 @@ impl VideoSessionManager { resolution: self.config.resolution, input_format: crate::video::format::PixelFormat::Mjpeg, // Common input output_codec: codec, - bitrate_kbps: self.config.bitrate_kbps, + bitrate_preset: self.config.bitrate_preset, fps: self.config.fps, - gop_size: 30, encoder_backend: self.config.encoder_backend, + ..Default::default() }; // Create new pipeline diff --git a/src/web/handlers/config/apply.rs b/src/web/handlers/config/apply.rs index 23c7e4bc..aa77e82d 100644 --- a/src/web/handlers/config/apply.rs +++ b/src/web/handlers/config/apply.rs @@ -109,11 +109,11 @@ pub async fn apply_stream_config( } // 更新码率 - if old_config.bitrate_kbps != new_config.bitrate_kbps { + if old_config.bitrate_preset != new_config.bitrate_preset { state .stream_manager .webrtc_streamer() - .set_bitrate(new_config.bitrate_kbps) + .set_bitrate_preset(new_config.bitrate_preset) .await .ok(); // Ignore error if no active stream } @@ -143,9 +143,9 @@ pub async fn apply_stream_config( } tracing::info!( - "Stream config applied: encoder={:?}, bitrate={} kbps", + "Stream config applied: encoder={:?}, bitrate={}", new_config.encoder, - new_config.bitrate_kbps + new_config.bitrate_preset ); Ok(()) } diff --git a/src/web/handlers/config/rustdesk.rs b/src/web/handlers/config/rustdesk.rs index 30d1a57c..ccbb3400 100644 --- a/src/web/handlers/config/rustdesk.rs +++ b/src/web/handlers/config/rustdesk.rs @@ -4,7 +4,7 @@ use axum::{extract::State, Json}; use std::sync::Arc; use crate::error::Result; -use crate::rustdesk::config::RustDeskConfig; +use crate::rustdesk::config::{PublicServerInfo, RustDeskConfig}; use crate::state::AppState; use super::apply::apply_rustdesk_config; @@ -21,6 +21,8 @@ pub struct RustDeskConfigResponse { pub has_password: bool, /// 是否已设置密钥对 pub has_keypair: bool, + /// 是否使用公共服务器(用户留空时) + pub using_public_server: bool, } impl From<&RustDeskConfig> for RustDeskConfigResponse { @@ -32,6 +34,7 @@ impl From<&RustDeskConfig> for RustDeskConfigResponse { device_id: config.device_id.clone(), has_password: !config.device_password.is_empty(), has_keypair: config.public_key.is_some() && config.private_key.is_some(), + using_public_server: config.is_using_public_server(), } } } @@ -42,6 +45,8 @@ pub struct RustDeskStatusResponse { pub config: RustDeskConfigResponse, pub service_status: String, pub rendezvous_status: Option, + /// 公共服务器信息(仅当有公共服务器配置时返回) + pub public_server: Option, } /// 获取 RustDesk 配置 @@ -65,10 +70,14 @@ pub async fn get_rustdesk_status(State(state): State>) -> Json, pub turn_server: Option, pub turn_username: Option, @@ -85,8 +85,7 @@ impl From<&StreamConfig> for StreamConfigResponse { Self { mode: config.mode.clone(), encoder: config.encoder.clone(), - bitrate_kbps: config.bitrate_kbps, - gop_size: config.gop_size, + bitrate_preset: config.bitrate_preset, stun_server: config.stun_server.clone(), turn_server: config.turn_server.clone(), turn_username: config.turn_username.clone(), @@ -100,8 +99,7 @@ impl From<&StreamConfig> for StreamConfigResponse { pub struct StreamConfigUpdate { pub mode: Option, pub encoder: Option, - pub bitrate_kbps: Option, - pub gop_size: Option, + pub bitrate_preset: Option, /// STUN server URL (e.g., "stun:stun.l.google.com:19302") pub stun_server: Option, /// TURN server URL (e.g., "turn:turn.example.com:3478") @@ -114,16 +112,7 @@ pub struct StreamConfigUpdate { impl StreamConfigUpdate { pub fn validate(&self) -> crate::error::Result<()> { - if let Some(bitrate) = self.bitrate_kbps { - if !(1000..=15000).contains(&bitrate) { - return Err(AppError::BadRequest("Bitrate must be 1000-15000 kbps".into())); - } - } - if let Some(gop) = self.gop_size { - if !(10..=300).contains(&gop) { - return Err(AppError::BadRequest("GOP size must be 10-300".into())); - } - } + // BitratePreset is always valid (enum) // Validate STUN server format if let Some(ref stun) = self.stun_server { if !stun.is_empty() && !stun.starts_with("stun:") { @@ -150,11 +139,8 @@ impl StreamConfigUpdate { if let Some(encoder) = self.encoder.clone() { config.encoder = encoder; } - if let Some(bitrate) = self.bitrate_kbps { - config.bitrate_kbps = bitrate; - } - if let Some(gop) = self.gop_size { - config.gop_size = gop; + if let Some(preset) = self.bitrate_preset { + config.bitrate_preset = preset; } // STUN/TURN settings - empty string means clear, Some("value") means set if let Some(ref stun) = self.stun_server { diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index 6188a084..93ce3fd2 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -13,6 +13,7 @@ use crate::auth::{Session, SESSION_COOKIE}; use crate::config::{AppConfig, StreamMode}; use crate::error::{AppError, Result}; use crate::state::AppState; +use crate::video::encoder::BitratePreset; // ============================================================================ // Health & Info @@ -742,12 +743,12 @@ pub async fn update_config( state .stream_manager .webrtc_streamer() - .set_bitrate(new_config.stream.bitrate_kbps) + .set_bitrate_preset(new_config.stream.bitrate_preset) .await .ok(); // Ignore error if no active stream - tracing::info!("Stream config applied: encoder={:?}, bitrate={} kbps", - new_config.stream.encoder, new_config.stream.bitrate_kbps); + tracing::info!("Stream config applied: encoder={:?}, bitrate={}", + new_config.stream.encoder, new_config.stream.bitrate_preset); } // HID config processing - always reload if section was sent @@ -1191,7 +1192,7 @@ pub struct AvailableCodecsResponse { /// Set bitrate request #[derive(Deserialize)] pub struct SetBitrateRequest { - pub bitrate_kbps: u32, + pub bitrate_preset: BitratePreset, } /// Set stream bitrate (real-time adjustment) @@ -1199,19 +1200,11 @@ pub async fn stream_set_bitrate( State(state): State>, Json(req): Json, ) -> Result> { - // Validate bitrate range (1000-15000 kbps) - if req.bitrate_kbps < 1000 || req.bitrate_kbps > 15000 { - return Err(AppError::BadRequest(format!( - "Bitrate must be between 1000 and 15000 kbps, got {}", - req.bitrate_kbps - ))); - } - // Update config state .config .update(|config| { - config.stream.bitrate_kbps = req.bitrate_kbps; + config.stream.bitrate_preset = req.bitrate_preset; }) .await?; @@ -1219,18 +1212,18 @@ pub async fn stream_set_bitrate( if let Err(e) = state .stream_manager .webrtc_streamer() - .set_bitrate(req.bitrate_kbps) + .set_bitrate_preset(req.bitrate_preset) .await { warn!("Failed to set bitrate dynamically: {}", e); // Don't fail the request - config is saved, will apply on next connection } else { - info!("Bitrate updated to {} kbps", req.bitrate_kbps); + info!("Bitrate updated to {}", req.bitrate_preset); } Ok(Json(LoginResponse { success: true, - message: Some(format!("Bitrate set to {} kbps", req.bitrate_kbps)), + message: Some(format!("Bitrate set to {}", req.bitrate_preset)), })) } diff --git a/src/webrtc/universal_session.rs b/src/webrtc/universal_session.rs index 8c5732b9..92336b69 100644 --- a/src/webrtc/universal_session.rs +++ b/src/webrtc/universal_session.rs @@ -30,6 +30,7 @@ use crate::error::{AppError, Result}; use crate::hid::datachannel::{parse_hid_message, HidChannelEvent}; use crate::hid::HidController; use crate::video::encoder::registry::VideoEncoderType; +use crate::video::encoder::BitratePreset; use crate::video::format::{PixelFormat, Resolution}; use crate::video::shared_video_pipeline::EncodedVideoFrame; @@ -47,12 +48,10 @@ pub struct UniversalSessionConfig { pub resolution: Resolution, /// Input pixel format pub input_format: PixelFormat, - /// Target bitrate in kbps - pub bitrate_kbps: u32, + /// Bitrate preset + pub bitrate_preset: BitratePreset, /// Target FPS pub fps: u32, - /// GOP size - pub gop_size: u32, /// Enable audio track pub audio_enabled: bool, } @@ -64,9 +63,8 @@ impl Default for UniversalSessionConfig { codec: VideoEncoderType::H264, resolution: Resolution::HD720, input_format: PixelFormat::Mjpeg, - bitrate_kbps: 1000, + bitrate_preset: BitratePreset::Balanced, fps: 30, - gop_size: 30, audio_enabled: false, } } @@ -144,7 +142,7 @@ impl UniversalSession { stream_id: "one-kvm-stream".to_string(), codec: video_codec, resolution: config.resolution, - bitrate_kbps: config.bitrate_kbps, + bitrate_kbps: config.bitrate_preset.bitrate_kbps(), fps: config.fps, }; let video_track = Arc::new(UniversalVideoTrack::new(track_config)); diff --git a/src/webrtc/video_track.rs b/src/webrtc/video_track.rs index a6b6a264..7705dc74 100644 --- a/src/webrtc/video_track.rs +++ b/src/webrtc/video_track.rs @@ -17,12 +17,10 @@ //! ``` use bytes::Bytes; -use std::io::Cursor; use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; use tracing::{debug, trace, warn}; -use webrtc::media::io::h264_reader::H264Reader; use webrtc::media::Sample; use webrtc::rtp_transceiver::rtp_codec::RTCRtpCodecCapability; use webrtc::track::track_local::track_local_static_rtp::TrackLocalStaticRTP; @@ -201,18 +199,6 @@ pub struct VideoTrackStats { pub errors: u64, } -/// Cached codec parameters for H264/H265 -#[derive(Debug, Default)] -struct CachedParams { - /// H264: SPS, H265: VPS - #[allow(dead_code)] - vps: Option, - /// SPS (both H264 and H265) - sps: Option, - /// PPS (both H264 and H265) - pps: Option, -} - /// Track type wrapper to support different underlying track implementations enum TrackType { /// Sample-based track with built-in payloader (H264, VP8, VP9) @@ -243,8 +229,6 @@ pub struct UniversalVideoTrack { config: UniversalVideoTrackConfig, /// Statistics stats: Mutex, - /// Cached parameters for H264/H265 - cached_params: Mutex, /// H265 RTP state (only used for H265) h265_state: Option>, } @@ -294,7 +278,6 @@ impl UniversalVideoTrack { codec: config.codec, config, stats: Mutex::new(VideoTrackStats::default()), - cached_params: Mutex::new(CachedParams::default()), h265_state, } } @@ -341,71 +324,43 @@ impl UniversalVideoTrack { } /// Write H264 frame (Annex B format) + /// + /// Sends the entire Annex B frame as a single Sample to allow the + /// H264Payloader to aggregate SPS+PPS into STAP-A packets. async fn write_h264_frame(&self, data: &[u8], is_keyframe: bool) -> Result<()> { - let cursor = Cursor::new(data); - let mut h264_reader = H264Reader::new(cursor, 1024 * 1024); + // Send entire Annex B frame as one Sample + // The H264Payloader in rtp crate will: + // 1. Parse NAL units from Annex B format + // 2. Cache SPS and PPS + // 3. Aggregate SPS+PPS+IDR into STAP-A when possible + // 4. Fragment large NALs using FU-A + let frame_duration = Duration::from_micros(1_000_000 / self.config.fps.max(1) as u64); + let sample = Sample { + data: Bytes::copy_from_slice(data), + duration: frame_duration, + ..Default::default() + }; - let mut nals: Vec = Vec::new(); - let mut has_sps = false; - let mut has_pps = false; - let mut has_idr = false; - - // Parse NAL units - while let Ok(nal) = h264_reader.next_nal() { - if nal.data.is_empty() { - continue; - } - - let nal_type = nal.data[0] & 0x1F; - - // Skip AUD (9) and filler (12) - if nal_type == 9 || nal_type == 12 { - continue; - } - - match nal_type { - 5 => has_idr = true, - 7 => { - has_sps = true; - self.cached_params.lock().await.sps = Some(nal.data.clone().freeze()); - } - 8 => { - has_pps = true; - self.cached_params.lock().await.pps = Some(nal.data.clone().freeze()); - } - _ => {} - } - - nals.push(nal.data.freeze()); - } - - // Inject cached SPS/PPS before IDR if missing - if has_idr && (!has_sps || !has_pps) { - let mut injected: Vec = Vec::new(); - let params = self.cached_params.lock().await; - - if !has_sps { - if let Some(ref sps) = params.sps { - debug!("Injecting cached H264 SPS"); - injected.push(sps.clone()); + match &self.track { + TrackType::Sample(track) => { + if let Err(e) = track.write_sample(&sample).await { + debug!("H264 write_sample failed: {}", e); } } - if !has_pps { - if let Some(ref pps) = params.pps { - debug!("Injecting cached H264 PPS"); - injected.push(pps.clone()); - } - } - drop(params); - - if !injected.is_empty() { - injected.extend(nals); - nals = injected; + TrackType::Rtp(_) => { + warn!("H264 should not use RTP track"); } } - // Send NAL units - self.send_nals(nals, is_keyframe).await + // Update stats + let mut stats = self.stats.lock().await; + stats.frames_sent += 1; + stats.bytes_sent += data.len() as u64; + if is_keyframe { + stats.keyframes_sent += 1; + } + + Ok(()) } /// Write H265 frame (Annex B format) @@ -483,52 +438,6 @@ impl UniversalVideoTrack { Ok(()) } - /// Send NAL units as samples (H264 only) - /// - /// Important: Only the last NAL unit should have the frame duration set. - /// All NAL units in a frame share the same RTP timestamp, so only the last - /// one should increment the timestamp by the frame duration. - async fn send_nals(&self, nals: Vec, is_keyframe: bool) -> Result<()> { - let mut total_bytes = 0u64; - // Calculate frame duration based on configured FPS - let frame_duration = Duration::from_micros(1_000_000 / self.config.fps.max(1) as u64); - let nal_count = nals.len(); - - match &self.track { - TrackType::Sample(track) => { - for (i, nal_data) in nals.into_iter().enumerate() { - let is_last = i == nal_count - 1; - // Only the last NAL should have duration set - // This ensures all NALs in a frame share the same RTP timestamp - let sample = Sample { - data: nal_data.clone(), - duration: if is_last { frame_duration } else { Duration::ZERO }, - ..Default::default() - }; - - if let Err(e) = track.write_sample(&sample).await { - debug!("NAL write_sample failed: {}", e); - } - - total_bytes += nal_data.len() as u64; - } - } - TrackType::Rtp(_) => { - warn!("send_nals should not be called for RTP track (H265)"); - } - } - - // Update stats - let mut stats = self.stats.lock().await; - stats.frames_sent += 1; - stats.bytes_sent += total_bytes; - if is_keyframe { - stats.keyframes_sent += 1; - } - - Ok(()) - } - /// Send H265 NAL units via custom H265Payloader async fn send_h265_rtp(&self, data: &[u8], is_keyframe: bool) -> Result<()> { let rtp_track = match &self.track { diff --git a/src/webrtc/webrtc_streamer.rs b/src/webrtc/webrtc_streamer.rs index e5504e03..4f225b30 100644 --- a/src/webrtc/webrtc_streamer.rs +++ b/src/webrtc/webrtc_streamer.rs @@ -51,6 +51,7 @@ use crate::video::shared_video_pipeline::{SharedVideoPipeline, SharedVideoPipeli use super::config::{TurnServer, WebRtcConfig}; use super::signaling::{ConnectionState, IceCandidate, SdpAnswer, SdpOffer}; use super::universal_session::{UniversalSession, UniversalSessionConfig}; +use crate::video::encoder::BitratePreset; /// WebRTC streamer configuration #[derive(Debug, Clone)] @@ -63,12 +64,10 @@ pub struct WebRtcStreamerConfig { pub resolution: Resolution, /// Input pixel format pub input_format: PixelFormat, - /// Target bitrate in kbps - pub bitrate_kbps: u32, + /// Bitrate preset + pub bitrate_preset: BitratePreset, /// Target FPS pub fps: u32, - /// GOP size (keyframe interval) - pub gop_size: u32, /// Enable audio (reserved) pub audio_enabled: bool, /// Encoder backend (None = auto select best available) @@ -82,9 +81,8 @@ impl Default for WebRtcStreamerConfig { video_codec: VideoCodecType::H264, resolution: Resolution::HD720, input_format: PixelFormat::Mjpeg, - bitrate_kbps: 8000, + bitrate_preset: BitratePreset::Balanced, fps: 30, - gop_size: 30, audio_enabled: false, encoder_backend: None, } @@ -282,10 +280,10 @@ impl WebRtcStreamer { resolution: config.resolution, input_format: config.input_format, output_codec: Self::codec_type_to_encoder_type(codec), - bitrate_kbps: config.bitrate_kbps, + bitrate_preset: config.bitrate_preset, fps: config.fps, - gop_size: config.gop_size, encoder_backend: config.encoder_backend, + ..Default::default() }; info!("Creating shared video pipeline for {:?}", codec); @@ -541,8 +539,8 @@ impl WebRtcStreamer { // Note: bitrate is NOT auto-scaled here - use set_bitrate() or config to change it info!( - "WebRTC config updated: {}x{} {:?} @ {} fps, {} kbps", - resolution.width, resolution.height, format, fps, config.bitrate_kbps + "WebRTC config updated: {}x{} {:?} @ {} fps, {}", + resolution.width, resolution.height, format, fps, config.bitrate_preset ); } @@ -636,9 +634,8 @@ impl WebRtcStreamer { codec: Self::codec_type_to_encoder_type(codec), resolution: config.resolution, input_format: config.input_format, - bitrate_kbps: config.bitrate_kbps, + bitrate_preset: config.bitrate_preset, fps: config.fps, - gop_size: config.gop_size, audio_enabled: *self.audio_enabled.read().await, }; drop(config); @@ -875,13 +872,13 @@ impl WebRtcStreamer { } } - /// Set bitrate + /// Set bitrate using preset /// /// Note: Hardware encoders (VAAPI, NVENC, etc.) don't support dynamic bitrate changes. /// This method restarts the pipeline to apply the new bitrate. - pub async fn set_bitrate(self: &Arc, bitrate_kbps: u32) -> Result<()> { + pub async fn set_bitrate_preset(self: &Arc, preset: BitratePreset) -> Result<()> { // Update config first - self.config.write().await.bitrate_kbps = bitrate_kbps; + self.config.write().await.bitrate_preset = preset; // Check if pipeline exists and is running let pipeline_running = { @@ -894,8 +891,8 @@ impl WebRtcStreamer { if pipeline_running { info!( - "Restarting video pipeline to apply new bitrate: {} kbps", - bitrate_kbps + "Restarting video pipeline to apply new bitrate: {}", + preset ); // Stop existing pipeline @@ -936,16 +933,16 @@ impl WebRtcStreamer { } info!( - "Video pipeline restarted with {} kbps, reconnected {} sessions", - bitrate_kbps, + "Video pipeline restarted with {}, reconnected {} sessions", + preset, session_ids.len() ); } } } else { debug!( - "Pipeline not running, bitrate {} kbps will apply on next start", - bitrate_kbps + "Pipeline not running, bitrate {} will apply on next start", + preset ); } @@ -978,7 +975,7 @@ mod tests { let config = WebRtcStreamerConfig::default(); assert_eq!(config.video_codec, VideoCodecType::H264); assert_eq!(config.resolution, Resolution::HD720); - assert_eq!(config.bitrate_kbps, 8000); + assert_eq!(config.bitrate_preset, BitratePreset::Quality); assert_eq!(config.fps, 30); assert!(!config.audio_enabled); } diff --git a/web/src/api/config.ts b/web/src/api/config.ts index d86ec4c0..ac69f2d4 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -256,6 +256,12 @@ export const extensionsApi = { // ===== RustDesk 配置 API ===== +/** 公共服务器信息 */ +export interface PublicServerInfo { + server: string + public_key: string +} + /** RustDesk 配置响应 */ export interface RustDeskConfigResponse { enabled: boolean @@ -264,6 +270,7 @@ export interface RustDeskConfigResponse { device_id: string has_password: boolean has_keypair: boolean + using_public_server: boolean } /** RustDesk 状态响应 */ @@ -271,6 +278,7 @@ export interface RustDeskStatusResponse { config: RustDeskConfigResponse service_status: string rendezvous_status: string | null + public_server: PublicServerInfo | null } /** RustDesk 配置更新 */ diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 9627fe89..07bcb401 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -230,10 +230,10 @@ export const streamApi = { getCodecs: () => request('/stream/codecs'), - setBitrate: (bitrate_kbps: number) => + setBitratePreset: (bitrate_preset: import('@/types/generated').BitratePreset) => request<{ success: boolean; message?: string }>('/stream/bitrate', { method: 'POST', - body: JSON.stringify({ bitrate_kbps }), + body: JSON.stringify({ bitrate_preset }), }), } @@ -612,6 +612,7 @@ export type { HidBackend, StreamMode, EncoderType, + BitratePreset, } from '@/types/generated' // Audio API diff --git a/web/src/components/VideoConfigPopover.vue b/web/src/components/VideoConfigPopover.vue index c2575778..5c355bc3 100644 --- a/web/src/components/VideoConfigPopover.vue +++ b/web/src/components/VideoConfigPopover.vue @@ -5,7 +5,6 @@ import { toast } from 'vue-sonner' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Separator } from '@/components/ui/separator' -import { Slider } from '@/components/ui/slider' import { Popover, PopoverContent, @@ -18,11 +17,10 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { Monitor, RefreshCw, Loader2, Settings } from 'lucide-vue-next' +import { Monitor, RefreshCw, Loader2, Settings, Zap, Scale, Image } from 'lucide-vue-next' import HelpTooltip from '@/components/HelpTooltip.vue' -import { configApi, streamApi, type VideoCodecInfo, type EncoderBackendInfo } from '@/api' +import { configApi, streamApi, type VideoCodecInfo, type EncoderBackendInfo, type BitratePreset } from '@/api' import { useSystemStore } from '@/stores/system' -import { useDebounceFn } from '@vueuse/core' import { useRouter } from 'vue-router' export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9' @@ -179,7 +177,7 @@ const selectedDevice = ref('') const selectedFormat = ref('') const selectedResolution = ref('') const selectedFps = ref(30) -const selectedBitrate = ref([1000]) +const selectedBitratePreset = ref<'Speed' | 'Balanced' | 'Quality'>('Balanced') // UI state const applying = ref(false) @@ -379,30 +377,27 @@ function handleFpsChange(fps: unknown) { selectedFps.value = typeof fps === 'string' ? Number(fps) : fps } -// Apply bitrate change (real-time) -async function applyBitrate(bitrate: number) { +// Apply bitrate preset change +async function applyBitratePreset(preset: 'Speed' | 'Balanced' | 'Quality') { if (applyingBitrate.value) return applyingBitrate.value = true try { - await streamApi.setBitrate(bitrate) + const bitratePreset: BitratePreset = { type: preset } + await streamApi.setBitratePreset(bitratePreset) } catch (e) { - console.info('[VideoConfig] Failed to apply bitrate:', e) + console.info('[VideoConfig] Failed to apply bitrate preset:', e) } finally { applyingBitrate.value = false } } -// Debounced bitrate application -const debouncedApplyBitrate = useDebounceFn((bitrate: number) => { - applyBitrate(bitrate) -}, 300) - -// Watch bitrate slider changes (only when in WebRTC mode) -watch(selectedBitrate, (newValue) => { - if (props.videoMode !== 'mjpeg' && newValue[0] !== undefined) { - debouncedApplyBitrate(newValue[0]) +// Handle bitrate preset selection +function handleBitratePresetChange(preset: 'Speed' | 'Balanced' | 'Quality') { + selectedBitratePreset.value = preset + if (props.videoMode !== 'mjpeg') { + applyBitratePreset(preset) } -}) +} // Apply video configuration async function applyVideoConfig() { @@ -529,21 +524,52 @@ watch(() => props.open, (isOpen) => {

- +
- - + +
-
- - {{ selectedBitrate[0] }} kbps +
+ + +
diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index ef3d5b6c..a0101f8d 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -109,7 +109,13 @@ export default { selectFormat: 'Select format...', selectResolution: 'Select resolution...', selectFps: 'Select FPS...', - bitrate: 'Bitrate', + bitratePreset: 'Bitrate', + bitrateSpeed: 'Speed', + bitrateSpeedDesc: '1 Mbps - Lowest latency', + bitrateBalanced: 'Balanced', + bitrateBalancedDesc: '4 Mbps - Recommended', + bitrateQuality: 'Quality', + bitrateQualityDesc: '8 Mbps - Best visual', browserUnsupported: 'Browser unsupported', encoder: 'Encoder', changeEncoderBackend: 'Change encoder backend...', @@ -649,10 +655,14 @@ export default { serverSettings: 'Server Settings', rendezvousServer: 'ID Server', rendezvousServerPlaceholder: 'hbbs.example.com:21116', - rendezvousServerHint: 'RustDesk ID server address (required)', + rendezvousServerHint: 'Leave empty to use public server', relayServer: 'Relay Server', relayServerPlaceholder: 'hbbr.example.com:21117', relayServerHint: 'Relay server address, auto-derived from ID server if empty', + publicServerInfo: 'Public Server Info', + publicServerAddress: 'Server Address', + publicServerKey: 'Connection Key', + usingPublicServer: 'Using public server', deviceInfo: 'Device Info', deviceId: 'Device ID', deviceIdHint: 'Use this ID in RustDesk client to connect', @@ -721,7 +731,7 @@ export default { // Video related mjpegMode: 'MJPEG mode has best compatibility, works with all browsers, but higher latency', webrtcMode: 'WebRTC mode has lower latency, but requires browser codec support', - videoBitrate: 'Higher bitrate means better quality but requires more bandwidth. Adjust based on network', + videoBitratePreset: 'Speed: lowest latency, best for slow networks. Balanced: good quality and latency. Quality: best visual, needs good bandwidth', encoderBackend: 'Hardware encoder has better performance and lower power. Software encoder has better compatibility', // HID related absoluteMode: 'Absolute mode maps mouse coordinates directly, suitable for most scenarios', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 9609d77f..7cec10be 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -109,7 +109,13 @@ export default { selectFormat: '选择格式...', selectResolution: '选择分辨率...', selectFps: '选择帧率...', - bitrate: '码率', + bitratePreset: '码率', + bitrateSpeed: '速度优先', + bitrateSpeedDesc: '1 Mbps - 最低延迟', + bitrateBalanced: '均衡', + bitrateBalancedDesc: '4 Mbps - 推荐', + bitrateQuality: '质量优先', + bitrateQualityDesc: '8 Mbps - 最佳画质', browserUnsupported: '浏览器不支持', encoder: '编码器', changeEncoderBackend: '更改编码器后端...', @@ -649,10 +655,14 @@ export default { serverSettings: '服务器设置', rendezvousServer: 'ID 服务器', rendezvousServerPlaceholder: 'hbbs.example.com:21116', - rendezvousServerHint: 'RustDesk ID 服务器地址(必填)', + rendezvousServerHint: '留空则使用公共服务器', relayServer: '中继服务器', relayServerPlaceholder: 'hbbr.example.com:21117', relayServerHint: '中继服务器地址,留空则自动从 ID 服务器推导', + publicServerInfo: '公共服务器信息', + publicServerAddress: '服务器地址', + publicServerKey: '连接密钥', + usingPublicServer: '正在使用公共服务器', deviceInfo: '设备信息', deviceId: '设备 ID', deviceIdHint: '此 ID 用于 RustDesk 客户端连接', @@ -721,7 +731,7 @@ export default { // 视频相关 mjpegMode: 'MJPEG 模式兼容性最好,适用于所有浏览器,但延迟较高', webrtcMode: 'WebRTC 模式延迟更低,但需要浏览器支持相应编解码器', - videoBitrate: '比特率越高画质越好,但需要更大的网络带宽。建议根据网络状况调整', + videoBitratePreset: '速度优先:最低延迟,适合网络较差的场景;均衡:画质和延迟平衡;质量优先:最佳画质,需要较好的网络带宽', encoderBackend: '硬件编码器性能更好功耗更低,软件编码器兼容性更好', // HID 相关 absoluteMode: '绝对定位模式直接映射鼠标坐标,适用于大多数场景', diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index 05c7ac57..c04cc778 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -183,16 +183,39 @@ export enum EncoderType { V4l2m2m = "v4l2m2m", } +/** + * Bitrate preset for video encoding + * + * Simplifies bitrate configuration by providing three intuitive presets + * plus a custom option for advanced users. + */ +export type BitratePreset = + /** + * Speed priority: 1 Mbps, lowest latency, smaller GOP + * Best for: slow networks, remote management, low-bandwidth scenarios + */ + | { type: "Speed", value?: undefined } + /** + * Balanced: 4 Mbps, good quality/latency tradeoff + * Best for: typical usage, recommended default + */ + | { type: "Balanced", value?: undefined } + /** + * Quality priority: 8 Mbps, best visual quality + * Best for: local network, high-bandwidth scenarios, detailed work + */ + | { type: "Quality", value?: undefined } + /** Custom bitrate in kbps (for advanced users) */ + | { type: "Custom", value: number }; + /** Streaming configuration */ export interface StreamConfig { /** Stream mode */ mode: StreamMode; /** Encoder type for H264/H265 */ encoder: EncoderType; - /** Target bitrate in kbps (for H264/H265) */ - bitrate_kbps: number; - /** GOP size */ - gop_size: number; + /** Bitrate preset (Speed/Balanced/Quality) */ + bitrate_preset: BitratePreset; /** Custom STUN server (e.g., "stun:stun.l.google.com:19302") */ stun_server?: string; /** Custom TURN server (e.g., "turn:turn.example.com:3478") */ @@ -264,6 +287,25 @@ export interface ExtensionsConfig { easytier: EasytierConfig; } +/** RustDesk configuration */ +export interface RustDeskConfig { + /** Enable RustDesk protocol */ + enabled: boolean; + /** + * Rendezvous server address (hbbs), e.g., "rs.example.com" or "192.168.1.100" + * Port defaults to 21116 if not specified + * If empty, uses the public server from secrets.toml + */ + rendezvous_server: string; + /** + * Relay server address (hbbr), if different from rendezvous server + * Usually the same host as rendezvous server but different port (21117) + */ + relay_server?: string; + /** Device ID (9-digit number), auto-generated if empty */ + device_id: string; +} + /** Main application configuration */ export interface AppConfig { /** Whether initial setup has been completed */ @@ -286,6 +328,8 @@ export interface AppConfig { web: WebConfig; /** Extensions settings (ttyd, gostc, easytier) */ extensions: ExtensionsConfig; + /** RustDesk remote access settings */ + rustdesk: RustDeskConfig; } /** Update for a single ATX key configuration */ @@ -441,12 +485,26 @@ export interface MsdConfigUpdate { virtual_drive_size_mb?: number; } +/** Public server information for display to users */ +export interface PublicServerInfo { + /** Public server address */ + server: string; + /** Public key for client connection */ + public_key: string; +} + +export interface RustDeskConfigUpdate { + enabled?: boolean; + rendezvous_server?: string; + relay_server?: string; + device_password?: string; +} + /** Stream 配置响应(包含 has_turn_password 字段) */ export interface StreamConfigResponse { mode: StreamMode; encoder: EncoderType; - bitrate_kbps: number; - gop_size: number; + bitrate_preset: BitratePreset; stun_server?: string; turn_server?: string; turn_username?: string; @@ -457,8 +515,7 @@ export interface StreamConfigResponse { export interface StreamConfigUpdate { mode?: StreamMode; encoder?: EncoderType; - bitrate_kbps?: number; - gop_size?: number; + bitrate_preset?: BitratePreset; /** STUN server URL (e.g., "stun:stun.l.google.com:19302") */ stun_server?: string; /** TURN server URL (e.g., "turn:turn.example.com:3478") */ diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index b8a39f22..8cade733 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -44,6 +44,12 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' import { Monitor, Keyboard, @@ -73,6 +79,7 @@ import { ExternalLink, Copy, ScreenShare, + CircleHelp, } from 'lucide-vue-next' const { t, locale } = useI18n() @@ -1825,7 +1832,28 @@ onMounted(async () => { v-model="rustdeskLocalConfig.rendezvous_server" :placeholder="t('extensions.rustdesk.rendezvousServerPlaceholder')" /> -

{{ t('extensions.rustdesk.rendezvousServerHint') }}

+
+

{{ t('extensions.rustdesk.rendezvousServerHint') }}

+ + + + + + +
+

{{ t('extensions.rustdesk.publicServerInfo') }}

+
+

{{ t('extensions.rustdesk.publicServerAddress') }}: {{ rustdeskStatus.public_server.server }}

+

{{ t('extensions.rustdesk.publicServerKey') }}: {{ rustdeskStatus.public_server.public_key }}

+
+
+
+
+
+
+

+ {{ t('extensions.rustdesk.usingPublicServer') }} +