mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-01-28 16:41:52 +08:00
feat(rustdesk): 优化视频编码协商和添加公共服务器支持
- 调整视频编码优先级为 H264 > H265 > VP8 > VP9,优先使用硬件编码 - 对接 RustDesk 客户端质量预设 (Low/Balanced/Best) 到 BitratePreset - 添加 secrets.toml 编译时读取机制,支持配置公共服务器 - 默认公共服务器: rustdesk.mofeng.run:21116 - 前端 ID 服务器输入框添加问号提示,显示公共服务器信息 - 用户留空时自动使用公共服务器
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -36,3 +36,6 @@ Thumbs.db
|
|||||||
.mcp.json
|
.mcp.json
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.gemini/settings.json
|
.gemini/settings.json
|
||||||
|
|
||||||
|
# Secrets (compile-time configuration)
|
||||||
|
secrets.toml
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ tempfile = "3"
|
|||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
prost-build = "0.13"
|
prost-build = "0.13"
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|||||||
105
build.rs
105
build.rs
@@ -1,3 +1,6 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Set BUILD_DATE environment variable for compile-time access
|
// Set BUILD_DATE environment variable for compile-time access
|
||||||
// Use system time to avoid adding chrono as a build dependency
|
// Use system time to avoid adding chrono as a build dependency
|
||||||
@@ -17,10 +20,14 @@ fn main() {
|
|||||||
// Compile protobuf files for RustDesk protocol
|
// Compile protobuf files for RustDesk protocol
|
||||||
compile_protos();
|
compile_protos();
|
||||||
|
|
||||||
|
// Generate secrets module from secrets.toml
|
||||||
|
generate_secrets();
|
||||||
|
|
||||||
// Rerun if the script itself changes
|
// Rerun if the script itself changes
|
||||||
println!("cargo:rerun-if-changed=build.rs");
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
println!("cargo:rerun-if-changed=protos/rendezvous.proto");
|
println!("cargo:rerun-if-changed=protos/rendezvous.proto");
|
||||||
println!("cargo:rerun-if-changed=protos/message.proto");
|
println!("cargo:rerun-if-changed=protos/message.proto");
|
||||||
|
println!("cargo:rerun-if-changed=secrets.toml");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compile protobuf files using prost-build
|
/// Compile protobuf files using prost-build
|
||||||
@@ -36,6 +43,104 @@ fn compile_protos() {
|
|||||||
.expect("Failed to compile protobuf files");
|
.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::<toml::Value>() {
|
||||||
|
// 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
|
/// Convert days since Unix epoch to year-month-day
|
||||||
fn days_to_ymd(days: i64) -> (i32, u32, u32) {
|
fn days_to_ymd(days: i64) -> (i32, u32, u32) {
|
||||||
// Algorithm from http://howardhinnant.github.io/date_algorithms.html
|
// Algorithm from http://howardhinnant.github.io/date_algorithms.html
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
|
use crate::video::encoder::BitratePreset;
|
||||||
|
|
||||||
// Re-export ExtensionsConfig from extensions module
|
// Re-export ExtensionsConfig from extensions module
|
||||||
pub use crate::extensions::ExtensionsConfig;
|
pub use crate::extensions::ExtensionsConfig;
|
||||||
@@ -347,10 +348,8 @@ pub struct StreamConfig {
|
|||||||
pub mode: StreamMode,
|
pub mode: StreamMode,
|
||||||
/// Encoder type for H264/H265
|
/// Encoder type for H264/H265
|
||||||
pub encoder: EncoderType,
|
pub encoder: EncoderType,
|
||||||
/// Target bitrate in kbps (for H264/H265)
|
/// Bitrate preset (Speed/Balanced/Quality)
|
||||||
pub bitrate_kbps: u32,
|
pub bitrate_preset: BitratePreset,
|
||||||
/// GOP size
|
|
||||||
pub gop_size: u32,
|
|
||||||
/// Custom STUN server (e.g., "stun:stun.l.google.com:19302")
|
/// Custom STUN server (e.g., "stun:stun.l.google.com:19302")
|
||||||
pub stun_server: Option<String>,
|
pub stun_server: Option<String>,
|
||||||
/// Custom TURN server (e.g., "turn:turn.example.com:3478")
|
/// Custom TURN server (e.g., "turn:turn.example.com:3478")
|
||||||
@@ -375,8 +374,7 @@ impl Default for StreamConfig {
|
|||||||
Self {
|
Self {
|
||||||
mode: StreamMode::Mjpeg,
|
mode: StreamMode::Mjpeg,
|
||||||
encoder: EncoderType::Auto,
|
encoder: EncoderType::Auto,
|
||||||
bitrate_kbps: 1000,
|
bitrate_preset: BitratePreset::Balanced,
|
||||||
gop_size: 30,
|
|
||||||
stun_server: Some("stun:stun.l.google.com:19302".to_string()),
|
stun_server: Some("stun:stun.l.google.com:19302".to_string()),
|
||||||
turn_server: None,
|
turn_server: None,
|
||||||
turn_username: None,
|
turn_username: None,
|
||||||
|
|||||||
@@ -22,4 +22,9 @@ pub mod video;
|
|||||||
pub mod web;
|
pub mod web;
|
||||||
pub mod webrtc;
|
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};
|
pub use error::{AppError, Result};
|
||||||
|
|||||||
@@ -170,8 +170,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
resolution: video_resolution,
|
resolution: video_resolution,
|
||||||
input_format: video_format,
|
input_format: video_format,
|
||||||
fps: config.video.fps,
|
fps: config.video.fps,
|
||||||
bitrate_kbps: config.stream.bitrate_kbps,
|
bitrate_preset: config.stream.bitrate_preset,
|
||||||
gop_size: config.stream.gop_size,
|
|
||||||
encoder_backend: config.stream.encoder.to_backend(),
|
encoder_backend: config.stream.encoder.to_backend(),
|
||||||
webrtc: {
|
webrtc: {
|
||||||
let mut stun_servers = vec![];
|
let mut stun_servers = vec![];
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
|
|
||||||
|
use crate::secrets;
|
||||||
|
|
||||||
/// RustDesk configuration
|
/// RustDesk configuration
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[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"
|
/// Rendezvous server address (hbbs), e.g., "rs.example.com" or "192.168.1.100"
|
||||||
/// Port defaults to 21116 if not specified
|
/// Port defaults to 21116 if not specified
|
||||||
|
/// If empty, uses the public server from secrets.toml
|
||||||
pub rendezvous_server: String,
|
pub rendezvous_server: String,
|
||||||
|
|
||||||
/// Relay server address (hbbr), if different from rendezvous server
|
/// Relay server address (hbbr), if different from rendezvous server
|
||||||
@@ -70,13 +73,41 @@ impl Default for RustDeskConfig {
|
|||||||
|
|
||||||
impl RustDeskConfig {
|
impl RustDeskConfig {
|
||||||
/// Check if the configuration is valid for starting the service
|
/// 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 {
|
pub fn is_valid(&self) -> bool {
|
||||||
self.enabled
|
self.enabled
|
||||||
&& !self.rendezvous_server.is_empty()
|
&& !self.effective_rendezvous_server().is_empty()
|
||||||
&& !self.device_id.is_empty()
|
&& !self.device_id.is_empty()
|
||||||
&& !self.device_password.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<PublicServerInfo> {
|
||||||
|
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
|
/// Generate a new random device ID
|
||||||
pub fn generate_device_id() -> String {
|
pub fn generate_device_id() -> String {
|
||||||
generate_device_id()
|
generate_device_id()
|
||||||
@@ -111,10 +142,11 @@ impl RustDeskConfig {
|
|||||||
|
|
||||||
/// Get the rendezvous server address with default port
|
/// Get the rendezvous server address with default port
|
||||||
pub fn rendezvous_addr(&self) -> String {
|
pub fn rendezvous_addr(&self) -> String {
|
||||||
if self.rendezvous_server.contains(':') {
|
let server = self.effective_rendezvous_server();
|
||||||
self.rendezvous_server.clone()
|
if server.contains(':') {
|
||||||
|
server.to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("{}:21116", self.rendezvous_server)
|
format!("{}:21116", server)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,9 +159,10 @@ impl RustDeskConfig {
|
|||||||
format!("{}:21117", s)
|
format!("{}:21117", s)
|
||||||
}
|
}
|
||||||
}).or_else(|| {
|
}).or_else(|| {
|
||||||
// Default: same host as rendezvous server
|
// Default: same host as effective rendezvous server
|
||||||
if !self.rendezvous_server.is_empty() {
|
let server = self.effective_rendezvous_server();
|
||||||
let host = self.rendezvous_server.split(':').next().unwrap_or("");
|
if !server.is_empty() {
|
||||||
|
let host = server.split(':').next().unwrap_or("");
|
||||||
if !host.is_empty() {
|
if !host.is_empty() {
|
||||||
Some(format!("{}:21117", host))
|
Some(format!("{}:21117", host))
|
||||||
} else {
|
} 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
|
/// Generate a random 9-digit device ID
|
||||||
pub fn generate_device_id() -> String {
|
pub fn generate_device_id() -> String {
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
@@ -196,9 +239,6 @@ mod tests {
|
|||||||
fn test_relay_addr() {
|
fn test_relay_addr() {
|
||||||
let mut config = RustDeskConfig::default();
|
let mut config = RustDeskConfig::default();
|
||||||
|
|
||||||
// No server configured
|
|
||||||
assert!(config.relay_addr().is_none());
|
|
||||||
|
|
||||||
// Rendezvous server configured, relay defaults to same host
|
// Rendezvous server configured, relay defaults to same host
|
||||||
config.rendezvous_server = "example.com".to_string();
|
config.rendezvous_server = "example.com".to_string();
|
||||||
assert_eq!(config.relay_addr(), Some("example.com:21117".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());
|
config.relay_server = Some("relay.example.com".to_string());
|
||||||
assert_eq!(config.relay_addr(), Some("relay.example.com:21117".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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use tracing::{debug, error, info, warn};
|
|||||||
|
|
||||||
use crate::hid::HidController;
|
use crate::hid::HidController;
|
||||||
use crate::video::encoder::registry::{EncoderRegistry, VideoEncoderType};
|
use crate::video::encoder::registry::{EncoderRegistry, VideoEncoderType};
|
||||||
|
use crate::video::encoder::BitratePreset;
|
||||||
use crate::video::stream_manager::VideoStreamManager;
|
use crate::video::stream_manager::VideoStreamManager;
|
||||||
|
|
||||||
use super::bytes_codec::{read_frame, write_frame};
|
use super::bytes_codec::{read_frame, write_frame};
|
||||||
@@ -507,7 +508,7 @@ impl Connection {
|
|||||||
*self.state.write() = ConnectionState::Active;
|
*self.state.write() = ConnectionState::Active;
|
||||||
|
|
||||||
// Select the best available video codec
|
// 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();
|
let negotiated = self.negotiate_video_codec();
|
||||||
self.negotiated_codec = Some(negotiated);
|
self.negotiated_codec = Some(negotiated);
|
||||||
info!("Negotiated video codec: {:?}", negotiated);
|
info!("Negotiated video codec: {:?}", negotiated);
|
||||||
@@ -519,28 +520,29 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Negotiate video codec - select the best available encoder
|
/// 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 {
|
fn negotiate_video_codec(&self) -> VideoEncoderType {
|
||||||
let registry = EncoderRegistry::global();
|
let registry = EncoderRegistry::global();
|
||||||
|
|
||||||
// Check availability in priority order
|
// Check availability in priority order
|
||||||
// VP8 is preferred because it has the best compatibility with software decoders
|
// H264 is preferred because it has the best hardware encoder support (RKMPP, VAAPI, etc.)
|
||||||
if registry.is_format_available(VideoEncoderType::VP8, false) {
|
// and most RustDesk clients support H264 hardware decoding
|
||||||
return VideoEncoderType::VP8;
|
|
||||||
}
|
|
||||||
if registry.is_format_available(VideoEncoderType::VP9, false) {
|
|
||||||
return VideoEncoderType::VP9;
|
|
||||||
}
|
|
||||||
if registry.is_format_available(VideoEncoderType::H264, false) {
|
if registry.is_format_available(VideoEncoderType::H264, false) {
|
||||||
return VideoEncoderType::H264;
|
return VideoEncoderType::H264;
|
||||||
}
|
}
|
||||||
if registry.is_format_available(VideoEncoderType::H265, false) {
|
if registry.is_format_available(VideoEncoderType::H265, false) {
|
||||||
return VideoEncoderType::H265;
|
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)
|
// Fallback to H264 (should be available via hardware or software encoder)
|
||||||
warn!("No video encoder available, defaulting to VP8");
|
warn!("No video encoder available, defaulting to H264");
|
||||||
VideoEncoderType::VP8
|
VideoEncoderType::H264
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle misc message with Arc writer
|
/// Handle misc message with Arc writer
|
||||||
@@ -575,8 +577,30 @@ impl Connection {
|
|||||||
Ok(())
|
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<()> {
|
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
|
// Check if client sent supported_decoding with a codec preference
|
||||||
if let Some(ref supported_decoding) = opt.supported_decoding {
|
if let Some(ref supported_decoding) = opt.supported_decoding {
|
||||||
let prefer = supported_decoding.prefer;
|
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 {
|
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 {
|
if opt.custom_fps > 0 {
|
||||||
debug!("Client requested FPS: {}", opt.custom_fps);
|
debug!("Client requested FPS: {}", opt.custom_fps);
|
||||||
@@ -665,7 +689,7 @@ impl Connection {
|
|||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
let conn_id = self.id;
|
let conn_id = self.id;
|
||||||
let shutdown_tx = self.shutdown_tx.clone();
|
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 {
|
let task = tokio::spawn(async move {
|
||||||
info!("Starting video streaming for connection {} with codec {:?}", conn_id, negotiated_codec);
|
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
|
// Get encoding config for logging
|
||||||
if let Some(config) = video_manager.get_encoding_config().await {
|
if let Some(config) = video_manager.get_encoding_config().await {
|
||||||
info!(
|
info!(
|
||||||
"RustDesk connection {} using shared video pipeline: {:?} {}x{} @ {} kbps",
|
"RustDesk connection {} using shared video pipeline: {:?} {}x{} @ {}",
|
||||||
conn_id,
|
conn_id,
|
||||||
config.output_codec,
|
config.output_codec,
|
||||||
config.resolution.width,
|
config.resolution.width,
|
||||||
config.resolution.height,
|
config.resolution.height,
|
||||||
config.bitrate_kbps
|
config.bitrate_preset
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ pub mod vp8;
|
|||||||
pub mod vp9;
|
pub mod vp9;
|
||||||
|
|
||||||
// Core traits and types
|
// Core traits and types
|
||||||
pub use traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig, EncoderFactory};
|
pub use traits::{BitratePreset, EncodedFormat, EncodedFrame, Encoder, EncoderConfig, EncoderFactory};
|
||||||
|
|
||||||
// WebRTC codec abstraction
|
// WebRTC codec abstraction
|
||||||
pub use codec::{CodecFrame, VideoCodec, VideoCodecConfig, VideoCodecFactory, VideoCodecType};
|
pub use codec::{CodecFrame, VideoCodec, VideoCodecConfig, VideoCodecFactory, VideoCodecType};
|
||||||
|
|||||||
@@ -1,11 +1,96 @@
|
|||||||
//! Encoder traits and common types
|
//! Encoder traits and common types
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
use typeshare::typeshare;
|
||||||
|
|
||||||
use crate::video::format::{PixelFormat, Resolution};
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
use crate::error::Result;
|
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
|
/// Encoder configuration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct EncoderConfig {
|
pub struct EncoderConfig {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub mod encoder;
|
|||||||
pub mod format;
|
pub mod format;
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
pub mod h264_pipeline;
|
pub mod h264_pipeline;
|
||||||
|
pub mod pacer;
|
||||||
pub mod shared_video_pipeline;
|
pub mod shared_video_pipeline;
|
||||||
pub mod stream_manager;
|
pub mod stream_manager;
|
||||||
pub mod streamer;
|
pub mod streamer;
|
||||||
@@ -18,6 +19,7 @@ pub mod video_session;
|
|||||||
pub use capture::VideoCapturer;
|
pub use capture::VideoCapturer;
|
||||||
pub use convert::{MjpegDecoder, MjpegToYuv420Converter, PixelConverter, Yuv420pBuffer};
|
pub use convert::{MjpegDecoder, MjpegToYuv420Converter, PixelConverter, Yuv420pBuffer};
|
||||||
pub use decoder::{MjpegVaapiDecoder, MjpegVaapiDecoderConfig};
|
pub use decoder::{MjpegVaapiDecoder, MjpegVaapiDecoderConfig};
|
||||||
|
pub use pacer::{EncoderPacer, PacerStats};
|
||||||
pub use device::{VideoDevice, VideoDeviceInfo};
|
pub use device::{VideoDevice, VideoDeviceInfo};
|
||||||
pub use encoder::{JpegEncoder, H264Encoder, H264EncoderType};
|
pub use encoder::{JpegEncoder, H264Encoder, H264EncoderType};
|
||||||
pub use format::PixelFormat;
|
pub use format::PixelFormat;
|
||||||
|
|||||||
72
src/video/pacer.rs
Normal file
72
src/video/pacer.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ use crate::video::encoder::vp8::{VP8Config, VP8Encoder};
|
|||||||
use crate::video::encoder::vp9::{VP9Config, VP9Encoder};
|
use crate::video::encoder::vp9::{VP9Config, VP9Encoder};
|
||||||
use crate::video::format::{PixelFormat, Resolution};
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
use crate::video::frame::VideoFrame;
|
use crate::video::frame::VideoFrame;
|
||||||
|
use crate::video::pacer::EncoderPacer;
|
||||||
|
|
||||||
/// Encoded video frame for distribution
|
/// Encoded video frame for distribution
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -64,14 +65,14 @@ pub struct SharedVideoPipelineConfig {
|
|||||||
pub input_format: PixelFormat,
|
pub input_format: PixelFormat,
|
||||||
/// Output codec type
|
/// Output codec type
|
||||||
pub output_codec: VideoEncoderType,
|
pub output_codec: VideoEncoderType,
|
||||||
/// Target bitrate in kbps
|
/// Bitrate preset (replaces raw bitrate_kbps)
|
||||||
pub bitrate_kbps: u32,
|
pub bitrate_preset: crate::video::encoder::BitratePreset,
|
||||||
/// Target FPS
|
/// Target FPS
|
||||||
pub fps: u32,
|
pub fps: u32,
|
||||||
/// GOP size
|
|
||||||
pub gop_size: u32,
|
|
||||||
/// Encoder backend (None = auto select best available)
|
/// Encoder backend (None = auto select best available)
|
||||||
pub encoder_backend: Option<EncoderBackend>,
|
pub encoder_backend: Option<EncoderBackend>,
|
||||||
|
/// Maximum in-flight frames for backpressure control
|
||||||
|
pub max_in_flight_frames: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SharedVideoPipelineConfig {
|
impl Default for SharedVideoPipelineConfig {
|
||||||
@@ -80,54 +81,70 @@ impl Default for SharedVideoPipelineConfig {
|
|||||||
resolution: Resolution::HD720,
|
resolution: Resolution::HD720,
|
||||||
input_format: PixelFormat::Yuyv,
|
input_format: PixelFormat::Yuyv,
|
||||||
output_codec: VideoEncoderType::H264,
|
output_codec: VideoEncoderType::H264,
|
||||||
bitrate_kbps: 1000,
|
bitrate_preset: crate::video::encoder::BitratePreset::Balanced,
|
||||||
fps: 30,
|
fps: 30,
|
||||||
gop_size: 30,
|
|
||||||
encoder_backend: None,
|
encoder_backend: None,
|
||||||
|
max_in_flight_frames: 8, // Default: allow 8 frames in flight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SharedVideoPipelineConfig {
|
impl SharedVideoPipelineConfig {
|
||||||
/// Create H264 config
|
/// Get effective bitrate in kbps
|
||||||
pub fn h264(resolution: Resolution, bitrate_kbps: u32) -> Self {
|
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 {
|
Self {
|
||||||
resolution,
|
resolution,
|
||||||
output_codec: VideoEncoderType::H264,
|
output_codec: VideoEncoderType::H264,
|
||||||
bitrate_kbps,
|
bitrate_preset: preset,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create H265 config
|
/// Create H265 config with bitrate preset
|
||||||
pub fn h265(resolution: Resolution, bitrate_kbps: u32) -> Self {
|
pub fn h265(resolution: Resolution, preset: crate::video::encoder::BitratePreset) -> Self {
|
||||||
Self {
|
Self {
|
||||||
resolution,
|
resolution,
|
||||||
output_codec: VideoEncoderType::H265,
|
output_codec: VideoEncoderType::H265,
|
||||||
bitrate_kbps,
|
bitrate_preset: preset,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create VP8 config
|
/// Create VP8 config with bitrate preset
|
||||||
pub fn vp8(resolution: Resolution, bitrate_kbps: u32) -> Self {
|
pub fn vp8(resolution: Resolution, preset: crate::video::encoder::BitratePreset) -> Self {
|
||||||
Self {
|
Self {
|
||||||
resolution,
|
resolution,
|
||||||
output_codec: VideoEncoderType::VP8,
|
output_codec: VideoEncoderType::VP8,
|
||||||
bitrate_kbps,
|
bitrate_preset: preset,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create VP9 config
|
/// Create VP9 config with bitrate preset
|
||||||
pub fn vp9(resolution: Resolution, bitrate_kbps: u32) -> Self {
|
pub fn vp9(resolution: Resolution, preset: crate::video::encoder::BitratePreset) -> Self {
|
||||||
Self {
|
Self {
|
||||||
resolution,
|
resolution,
|
||||||
output_codec: VideoEncoderType::VP9,
|
output_codec: VideoEncoderType::VP9,
|
||||||
bitrate_kbps,
|
bitrate_preset: preset,
|
||||||
..Default::default()
|
..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
|
/// Pipeline statistics
|
||||||
@@ -136,12 +153,16 @@ pub struct SharedVideoPipelineStats {
|
|||||||
pub frames_captured: u64,
|
pub frames_captured: u64,
|
||||||
pub frames_encoded: u64,
|
pub frames_encoded: u64,
|
||||||
pub frames_dropped: u64,
|
pub frames_dropped: u64,
|
||||||
|
/// Frames skipped due to backpressure (pacer)
|
||||||
|
pub frames_skipped: u64,
|
||||||
pub bytes_encoded: u64,
|
pub bytes_encoded: u64,
|
||||||
pub keyframes_encoded: u64,
|
pub keyframes_encoded: u64,
|
||||||
pub avg_encode_time_ms: f32,
|
pub avg_encode_time_ms: f32,
|
||||||
pub current_fps: f32,
|
pub current_fps: f32,
|
||||||
pub errors: u64,
|
pub errors: u64,
|
||||||
pub subscribers: 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)
|
/// Pipeline start time for PTS calculation (epoch millis, 0 = not set)
|
||||||
/// Uses AtomicI64 instead of Mutex for lock-free access
|
/// Uses AtomicI64 instead of Mutex for lock-free access
|
||||||
pipeline_start_time_ms: AtomicI64,
|
pipeline_start_time_ms: AtomicI64,
|
||||||
|
/// Encoder pacer for backpressure control
|
||||||
|
pacer: EncoderPacer,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SharedVideoPipeline {
|
impl SharedVideoPipeline {
|
||||||
/// Create a new shared video pipeline
|
/// Create a new shared video pipeline
|
||||||
pub fn new(config: SharedVideoPipelineConfig) -> Result<Arc<Self>> {
|
pub fn new(config: SharedVideoPipelineConfig) -> Result<Arc<Self>> {
|
||||||
info!(
|
info!(
|
||||||
"Creating shared video pipeline: {} {}x{} @ {} kbps (input: {})",
|
"Creating shared video pipeline: {} {}x{} @ {} (input: {}, max_in_flight: {})",
|
||||||
config.output_codec,
|
config.output_codec,
|
||||||
config.resolution.width,
|
config.resolution.width,
|
||||||
config.resolution.height,
|
config.resolution.height,
|
||||||
config.bitrate_kbps,
|
config.bitrate_preset,
|
||||||
config.input_format
|
config.input_format,
|
||||||
|
config.max_in_flight_frames
|
||||||
);
|
);
|
||||||
|
|
||||||
let (frame_tx, _) = broadcast::channel(16); // Reduced from 64 for lower latency
|
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 nv12_size = (config.resolution.width * config.resolution.height * 3 / 2) as usize;
|
||||||
let yuv420p_size = nv12_size; // Same size as NV12
|
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 {
|
let pipeline = Arc::new(Self {
|
||||||
config: RwLock::new(config),
|
config: RwLock::new(config),
|
||||||
encoder: Mutex::new(None),
|
encoder: Mutex::new(None),
|
||||||
@@ -342,6 +369,7 @@ impl SharedVideoPipeline {
|
|||||||
sequence: AtomicU64::new(0),
|
sequence: AtomicU64::new(0),
|
||||||
keyframe_requested: AtomicBool::new(false),
|
keyframe_requested: AtomicBool::new(false),
|
||||||
pipeline_start_time_ms: AtomicI64::new(0),
|
pipeline_start_time_ms: AtomicI64::new(0),
|
||||||
|
pacer,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(pipeline)
|
Ok(pipeline)
|
||||||
@@ -379,9 +407,9 @@ impl SharedVideoPipeline {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let encoder_config = H264Config {
|
let encoder_config = H264Config {
|
||||||
base: EncoderConfig::h264(config.resolution, config.bitrate_kbps),
|
base: EncoderConfig::h264(config.resolution, config.bitrate_kbps()),
|
||||||
bitrate_kbps: config.bitrate_kbps,
|
bitrate_kbps: config.bitrate_kbps(),
|
||||||
gop_size: config.gop_size,
|
gop_size: config.gop_size(),
|
||||||
fps: config.fps,
|
fps: config.fps,
|
||||||
input_format: h264_input_format,
|
input_format: h264_input_format,
|
||||||
};
|
};
|
||||||
@@ -413,9 +441,9 @@ impl SharedVideoPipeline {
|
|||||||
VideoEncoderType::H265 => {
|
VideoEncoderType::H265 => {
|
||||||
// Determine H265 input format based on backend and input format
|
// Determine H265 input format based on backend and input format
|
||||||
let encoder_config = if use_yuyv_direct {
|
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 {
|
} else {
|
||||||
H265Config::low_latency(config.resolution, config.bitrate_kbps)
|
H265Config::low_latency(config.resolution, config.bitrate_kbps())
|
||||||
};
|
};
|
||||||
|
|
||||||
let encoder = if use_yuyv_direct {
|
let encoder = if use_yuyv_direct {
|
||||||
@@ -441,7 +469,7 @@ impl SharedVideoPipeline {
|
|||||||
Box::new(H265EncoderWrapper(encoder))
|
Box::new(H265EncoderWrapper(encoder))
|
||||||
}
|
}
|
||||||
VideoEncoderType::VP8 => {
|
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 encoder = if let Some(ref backend) = config.encoder_backend {
|
||||||
let codec_name = get_codec_name(VideoEncoderType::VP8, Some(*backend))
|
let codec_name = get_codec_name(VideoEncoderType::VP8, Some(*backend))
|
||||||
@@ -458,7 +486,7 @@ impl SharedVideoPipeline {
|
|||||||
Box::new(VP8EncoderWrapper(encoder))
|
Box::new(VP8EncoderWrapper(encoder))
|
||||||
}
|
}
|
||||||
VideoEncoderType::VP9 => {
|
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 encoder = if let Some(ref backend) = config.encoder_backend {
|
||||||
let codec_name = get_codec_name(VideoEncoderType::VP9, Some(*backend))
|
let codec_name = get_codec_name(VideoEncoderType::VP9, Some(*backend))
|
||||||
@@ -589,6 +617,19 @@ impl SharedVideoPipeline {
|
|||||||
self.frame_tx.receiver_count()
|
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
|
/// Request encoder to produce a keyframe on next encode
|
||||||
///
|
///
|
||||||
/// This is useful when a new client connects and needs an immediate
|
/// This is useful when a new client connects and needs an immediate
|
||||||
@@ -604,9 +645,15 @@ impl SharedVideoPipeline {
|
|||||||
pub async fn stats(&self) -> SharedVideoPipelineStats {
|
pub async fn stats(&self) -> SharedVideoPipelineStats {
|
||||||
let mut stats = self.stats.lock().await.clone();
|
let mut stats = self.stats.lock().await.clone();
|
||||||
stats.subscribers = self.frame_tx.receiver_count() as u64;
|
stats.subscribers = self.frame_tx.receiver_count() as u64;
|
||||||
|
stats.pending_frames = if self.pacer.is_throttling() { 1 } else { 0 };
|
||||||
stats
|
stats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get pacer statistics for debugging
|
||||||
|
pub fn pacer_stats(&self) -> crate::video::pacer::PacerStats {
|
||||||
|
self.pacer.stats()
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if running
|
/// Check if running
|
||||||
pub fn is_running(&self) -> bool {
|
pub fn is_running(&self) -> bool {
|
||||||
*self.running_rx.borrow()
|
*self.running_rx.borrow()
|
||||||
@@ -662,7 +709,8 @@ impl SharedVideoPipeline {
|
|||||||
let _ = self.running.send(true);
|
let _ = self.running.send(true);
|
||||||
|
|
||||||
let config = self.config.read().await.clone();
|
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();
|
let pipeline = self.clone();
|
||||||
|
|
||||||
@@ -678,6 +726,7 @@ impl SharedVideoPipeline {
|
|||||||
let mut local_keyframes: u64 = 0;
|
let mut local_keyframes: u64 = 0;
|
||||||
let mut local_errors: u64 = 0;
|
let mut local_errors: u64 = 0;
|
||||||
let mut local_dropped: u64 = 0;
|
let mut local_dropped: u64 = 0;
|
||||||
|
let mut local_skipped: u64 = 0;
|
||||||
|
|
||||||
// Track when we last had subscribers for auto-stop feature
|
// Track when we last had subscribers for auto-stop feature
|
||||||
let mut no_subscribers_since: Option<Instant> = None;
|
let mut no_subscribers_since: Option<Instant> = 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 {
|
match pipeline.encode_frame(&video_frame, frame_count).await {
|
||||||
Ok(Some(encoded_frame)) => {
|
Ok(Some(encoded_frame)) => {
|
||||||
|
// Send frame to all subscribers
|
||||||
|
// Note: broadcast::send is non-blocking
|
||||||
let _ = pipeline.frame_tx.send(encoded_frame.clone());
|
let _ = pipeline.frame_tx.send(encoded_frame.clone());
|
||||||
|
|
||||||
// Update local counters (no lock)
|
// Update local counters (no lock)
|
||||||
@@ -762,6 +821,8 @@ impl SharedVideoPipeline {
|
|||||||
s.keyframes_encoded += local_keyframes;
|
s.keyframes_encoded += local_keyframes;
|
||||||
s.errors += local_errors;
|
s.errors += local_errors;
|
||||||
s.frames_dropped += local_dropped;
|
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;
|
s.current_fps = current_fps;
|
||||||
|
|
||||||
// Reset local counters
|
// Reset local counters
|
||||||
@@ -770,6 +831,7 @@ impl SharedVideoPipeline {
|
|||||||
local_keyframes = 0;
|
local_keyframes = 0;
|
||||||
local_errors = 0;
|
local_errors = 0;
|
||||||
local_dropped = 0;
|
local_dropped = 0;
|
||||||
|
local_skipped = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||||
@@ -958,15 +1020,22 @@ impl SharedVideoPipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set bitrate
|
/// Set bitrate using preset
|
||||||
pub async fn set_bitrate(&self, bitrate_kbps: u32) -> Result<()> {
|
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 {
|
if let Some(ref mut encoder) = *self.encoder.lock().await {
|
||||||
encoder.set_bitrate(bitrate_kbps)?;
|
encoder.set_bitrate(bitrate_kbps)?;
|
||||||
self.config.write().await.bitrate_kbps = bitrate_kbps;
|
self.config.write().await.bitrate_preset = preset;
|
||||||
}
|
}
|
||||||
Ok(())
|
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
|
/// Get current config
|
||||||
pub async fn config(&self) -> SharedVideoPipelineConfig {
|
pub async fn config(&self) -> SharedVideoPipelineConfig {
|
||||||
self.config.read().await.clone()
|
self.config.read().await.clone()
|
||||||
@@ -1038,13 +1107,14 @@ fn parse_h265_nal_types(data: &[u8]) -> Vec<(u8, usize)> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::video::encoder::BitratePreset;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pipeline_config() {
|
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);
|
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);
|
assert_eq!(h265.output_codec, VideoEncoderType::H265);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -613,6 +613,14 @@ impl VideoStreamManager {
|
|||||||
self.webrtc_streamer.set_video_codec(codec).await
|
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
|
/// Publish event to event bus
|
||||||
async fn publish_event(&self, event: SystemEvent) {
|
async fn publish_event(&self, event: SystemEvent) {
|
||||||
if let Some(ref events) = *self.events.read().await {
|
if let Some(ref events) = *self.events.read().await {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use tokio::sync::{broadcast, RwLock};
|
|||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use super::encoder::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
use super::encoder::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
||||||
|
use super::encoder::BitratePreset;
|
||||||
use super::format::Resolution;
|
use super::format::Resolution;
|
||||||
use super::frame::VideoFrame;
|
use super::frame::VideoFrame;
|
||||||
use super::shared_video_pipeline::{
|
use super::shared_video_pipeline::{
|
||||||
@@ -123,8 +124,8 @@ pub struct VideoSessionManagerConfig {
|
|||||||
pub default_codec: VideoEncoderType,
|
pub default_codec: VideoEncoderType,
|
||||||
/// Default resolution
|
/// Default resolution
|
||||||
pub resolution: Resolution,
|
pub resolution: Resolution,
|
||||||
/// Default bitrate (kbps)
|
/// Bitrate preset
|
||||||
pub bitrate_kbps: u32,
|
pub bitrate_preset: BitratePreset,
|
||||||
/// Default FPS
|
/// Default FPS
|
||||||
pub fps: u32,
|
pub fps: u32,
|
||||||
/// Session timeout (seconds)
|
/// Session timeout (seconds)
|
||||||
@@ -138,7 +139,7 @@ impl Default for VideoSessionManagerConfig {
|
|||||||
Self {
|
Self {
|
||||||
default_codec: VideoEncoderType::H264,
|
default_codec: VideoEncoderType::H264,
|
||||||
resolution: Resolution::HD720,
|
resolution: Resolution::HD720,
|
||||||
bitrate_kbps: 8000,
|
bitrate_preset: BitratePreset::Balanced,
|
||||||
fps: 30,
|
fps: 30,
|
||||||
session_timeout_secs: 300,
|
session_timeout_secs: 300,
|
||||||
encoder_backend: None,
|
encoder_backend: None,
|
||||||
@@ -325,10 +326,10 @@ impl VideoSessionManager {
|
|||||||
resolution: self.config.resolution,
|
resolution: self.config.resolution,
|
||||||
input_format: crate::video::format::PixelFormat::Mjpeg, // Common input
|
input_format: crate::video::format::PixelFormat::Mjpeg, // Common input
|
||||||
output_codec: codec,
|
output_codec: codec,
|
||||||
bitrate_kbps: self.config.bitrate_kbps,
|
bitrate_preset: self.config.bitrate_preset,
|
||||||
fps: self.config.fps,
|
fps: self.config.fps,
|
||||||
gop_size: 30,
|
|
||||||
encoder_backend: self.config.encoder_backend,
|
encoder_backend: self.config.encoder_backend,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create new pipeline
|
// Create new pipeline
|
||||||
|
|||||||
@@ -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
|
state
|
||||||
.stream_manager
|
.stream_manager
|
||||||
.webrtc_streamer()
|
.webrtc_streamer()
|
||||||
.set_bitrate(new_config.bitrate_kbps)
|
.set_bitrate_preset(new_config.bitrate_preset)
|
||||||
.await
|
.await
|
||||||
.ok(); // Ignore error if no active stream
|
.ok(); // Ignore error if no active stream
|
||||||
}
|
}
|
||||||
@@ -143,9 +143,9 @@ pub async fn apply_stream_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Stream config applied: encoder={:?}, bitrate={} kbps",
|
"Stream config applied: encoder={:?}, bitrate={}",
|
||||||
new_config.encoder,
|
new_config.encoder,
|
||||||
new_config.bitrate_kbps
|
new_config.bitrate_preset
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use axum::{extract::State, Json};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::rustdesk::config::RustDeskConfig;
|
use crate::rustdesk::config::{PublicServerInfo, RustDeskConfig};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
use super::apply::apply_rustdesk_config;
|
use super::apply::apply_rustdesk_config;
|
||||||
@@ -21,6 +21,8 @@ pub struct RustDeskConfigResponse {
|
|||||||
pub has_password: bool,
|
pub has_password: bool,
|
||||||
/// 是否已设置密钥对
|
/// 是否已设置密钥对
|
||||||
pub has_keypair: bool,
|
pub has_keypair: bool,
|
||||||
|
/// 是否使用公共服务器(用户留空时)
|
||||||
|
pub using_public_server: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&RustDeskConfig> for RustDeskConfigResponse {
|
impl From<&RustDeskConfig> for RustDeskConfigResponse {
|
||||||
@@ -32,6 +34,7 @@ impl From<&RustDeskConfig> for RustDeskConfigResponse {
|
|||||||
device_id: config.device_id.clone(),
|
device_id: config.device_id.clone(),
|
||||||
has_password: !config.device_password.is_empty(),
|
has_password: !config.device_password.is_empty(),
|
||||||
has_keypair: config.public_key.is_some() && config.private_key.is_some(),
|
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 config: RustDeskConfigResponse,
|
||||||
pub service_status: String,
|
pub service_status: String,
|
||||||
pub rendezvous_status: Option<String>,
|
pub rendezvous_status: Option<String>,
|
||||||
|
/// 公共服务器信息(仅当有公共服务器配置时返回)
|
||||||
|
pub public_server: Option<PublicServerInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 RustDesk 配置
|
/// 获取 RustDesk 配置
|
||||||
@@ -65,10 +70,14 @@ pub async fn get_rustdesk_status(State(state): State<Arc<AppState>>) -> Json<Rus
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取公共服务器信息
|
||||||
|
let public_server = RustDeskConfig::public_server_info();
|
||||||
|
|
||||||
Json(RustDeskStatusResponse {
|
Json(RustDeskStatusResponse {
|
||||||
config: RustDeskConfigResponse::from(&config),
|
config: RustDeskConfigResponse::from(&config),
|
||||||
service_status,
|
service_status,
|
||||||
rendezvous_status,
|
rendezvous_status,
|
||||||
|
public_server,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use typeshare::typeshare;
|
|||||||
use crate::config::*;
|
use crate::config::*;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::rustdesk::config::RustDeskConfig;
|
use crate::rustdesk::config::RustDeskConfig;
|
||||||
|
use crate::video::encoder::BitratePreset;
|
||||||
|
|
||||||
// ===== Video Config =====
|
// ===== Video Config =====
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
@@ -71,8 +72,7 @@ impl VideoConfigUpdate {
|
|||||||
pub struct StreamConfigResponse {
|
pub struct StreamConfigResponse {
|
||||||
pub mode: StreamMode,
|
pub mode: StreamMode,
|
||||||
pub encoder: EncoderType,
|
pub encoder: EncoderType,
|
||||||
pub bitrate_kbps: u32,
|
pub bitrate_preset: BitratePreset,
|
||||||
pub gop_size: u32,
|
|
||||||
pub stun_server: Option<String>,
|
pub stun_server: Option<String>,
|
||||||
pub turn_server: Option<String>,
|
pub turn_server: Option<String>,
|
||||||
pub turn_username: Option<String>,
|
pub turn_username: Option<String>,
|
||||||
@@ -85,8 +85,7 @@ impl From<&StreamConfig> for StreamConfigResponse {
|
|||||||
Self {
|
Self {
|
||||||
mode: config.mode.clone(),
|
mode: config.mode.clone(),
|
||||||
encoder: config.encoder.clone(),
|
encoder: config.encoder.clone(),
|
||||||
bitrate_kbps: config.bitrate_kbps,
|
bitrate_preset: config.bitrate_preset,
|
||||||
gop_size: config.gop_size,
|
|
||||||
stun_server: config.stun_server.clone(),
|
stun_server: config.stun_server.clone(),
|
||||||
turn_server: config.turn_server.clone(),
|
turn_server: config.turn_server.clone(),
|
||||||
turn_username: config.turn_username.clone(),
|
turn_username: config.turn_username.clone(),
|
||||||
@@ -100,8 +99,7 @@ impl From<&StreamConfig> for StreamConfigResponse {
|
|||||||
pub struct StreamConfigUpdate {
|
pub struct StreamConfigUpdate {
|
||||||
pub mode: Option<StreamMode>,
|
pub mode: Option<StreamMode>,
|
||||||
pub encoder: Option<EncoderType>,
|
pub encoder: Option<EncoderType>,
|
||||||
pub bitrate_kbps: Option<u32>,
|
pub bitrate_preset: Option<BitratePreset>,
|
||||||
pub gop_size: Option<u32>,
|
|
||||||
/// STUN server URL (e.g., "stun:stun.l.google.com:19302")
|
/// STUN server URL (e.g., "stun:stun.l.google.com:19302")
|
||||||
pub stun_server: Option<String>,
|
pub stun_server: Option<String>,
|
||||||
/// TURN server URL (e.g., "turn:turn.example.com:3478")
|
/// TURN server URL (e.g., "turn:turn.example.com:3478")
|
||||||
@@ -114,16 +112,7 @@ pub struct StreamConfigUpdate {
|
|||||||
|
|
||||||
impl StreamConfigUpdate {
|
impl StreamConfigUpdate {
|
||||||
pub fn validate(&self) -> crate::error::Result<()> {
|
pub fn validate(&self) -> crate::error::Result<()> {
|
||||||
if let Some(bitrate) = self.bitrate_kbps {
|
// BitratePreset is always valid (enum)
|
||||||
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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Validate STUN server format
|
// Validate STUN server format
|
||||||
if let Some(ref stun) = self.stun_server {
|
if let Some(ref stun) = self.stun_server {
|
||||||
if !stun.is_empty() && !stun.starts_with("stun:") {
|
if !stun.is_empty() && !stun.starts_with("stun:") {
|
||||||
@@ -150,11 +139,8 @@ impl StreamConfigUpdate {
|
|||||||
if let Some(encoder) = self.encoder.clone() {
|
if let Some(encoder) = self.encoder.clone() {
|
||||||
config.encoder = encoder;
|
config.encoder = encoder;
|
||||||
}
|
}
|
||||||
if let Some(bitrate) = self.bitrate_kbps {
|
if let Some(preset) = self.bitrate_preset {
|
||||||
config.bitrate_kbps = bitrate;
|
config.bitrate_preset = preset;
|
||||||
}
|
|
||||||
if let Some(gop) = self.gop_size {
|
|
||||||
config.gop_size = gop;
|
|
||||||
}
|
}
|
||||||
// STUN/TURN settings - empty string means clear, Some("value") means set
|
// STUN/TURN settings - empty string means clear, Some("value") means set
|
||||||
if let Some(ref stun) = self.stun_server {
|
if let Some(ref stun) = self.stun_server {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use crate::auth::{Session, SESSION_COOKIE};
|
|||||||
use crate::config::{AppConfig, StreamMode};
|
use crate::config::{AppConfig, StreamMode};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
use crate::video::encoder::BitratePreset;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Health & Info
|
// Health & Info
|
||||||
@@ -742,12 +743,12 @@ pub async fn update_config(
|
|||||||
state
|
state
|
||||||
.stream_manager
|
.stream_manager
|
||||||
.webrtc_streamer()
|
.webrtc_streamer()
|
||||||
.set_bitrate(new_config.stream.bitrate_kbps)
|
.set_bitrate_preset(new_config.stream.bitrate_preset)
|
||||||
.await
|
.await
|
||||||
.ok(); // Ignore error if no active stream
|
.ok(); // Ignore error if no active stream
|
||||||
|
|
||||||
tracing::info!("Stream config applied: encoder={:?}, bitrate={} kbps",
|
tracing::info!("Stream config applied: encoder={:?}, bitrate={}",
|
||||||
new_config.stream.encoder, new_config.stream.bitrate_kbps);
|
new_config.stream.encoder, new_config.stream.bitrate_preset);
|
||||||
}
|
}
|
||||||
|
|
||||||
// HID config processing - always reload if section was sent
|
// HID config processing - always reload if section was sent
|
||||||
@@ -1191,7 +1192,7 @@ pub struct AvailableCodecsResponse {
|
|||||||
/// Set bitrate request
|
/// Set bitrate request
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct SetBitrateRequest {
|
pub struct SetBitrateRequest {
|
||||||
pub bitrate_kbps: u32,
|
pub bitrate_preset: BitratePreset,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set stream bitrate (real-time adjustment)
|
/// Set stream bitrate (real-time adjustment)
|
||||||
@@ -1199,19 +1200,11 @@ pub async fn stream_set_bitrate(
|
|||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<SetBitrateRequest>,
|
Json(req): Json<SetBitrateRequest>,
|
||||||
) -> Result<Json<LoginResponse>> {
|
) -> Result<Json<LoginResponse>> {
|
||||||
// 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
|
// Update config
|
||||||
state
|
state
|
||||||
.config
|
.config
|
||||||
.update(|config| {
|
.update(|config| {
|
||||||
config.stream.bitrate_kbps = req.bitrate_kbps;
|
config.stream.bitrate_preset = req.bitrate_preset;
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -1219,18 +1212,18 @@ pub async fn stream_set_bitrate(
|
|||||||
if let Err(e) = state
|
if let Err(e) = state
|
||||||
.stream_manager
|
.stream_manager
|
||||||
.webrtc_streamer()
|
.webrtc_streamer()
|
||||||
.set_bitrate(req.bitrate_kbps)
|
.set_bitrate_preset(req.bitrate_preset)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!("Failed to set bitrate dynamically: {}", e);
|
warn!("Failed to set bitrate dynamically: {}", e);
|
||||||
// Don't fail the request - config is saved, will apply on next connection
|
// Don't fail the request - config is saved, will apply on next connection
|
||||||
} else {
|
} else {
|
||||||
info!("Bitrate updated to {} kbps", req.bitrate_kbps);
|
info!("Bitrate updated to {}", req.bitrate_preset);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: Some(format!("Bitrate set to {} kbps", req.bitrate_kbps)),
|
message: Some(format!("Bitrate set to {}", req.bitrate_preset)),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ use crate::error::{AppError, Result};
|
|||||||
use crate::hid::datachannel::{parse_hid_message, HidChannelEvent};
|
use crate::hid::datachannel::{parse_hid_message, HidChannelEvent};
|
||||||
use crate::hid::HidController;
|
use crate::hid::HidController;
|
||||||
use crate::video::encoder::registry::VideoEncoderType;
|
use crate::video::encoder::registry::VideoEncoderType;
|
||||||
|
use crate::video::encoder::BitratePreset;
|
||||||
use crate::video::format::{PixelFormat, Resolution};
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
use crate::video::shared_video_pipeline::EncodedVideoFrame;
|
use crate::video::shared_video_pipeline::EncodedVideoFrame;
|
||||||
|
|
||||||
@@ -47,12 +48,10 @@ pub struct UniversalSessionConfig {
|
|||||||
pub resolution: Resolution,
|
pub resolution: Resolution,
|
||||||
/// Input pixel format
|
/// Input pixel format
|
||||||
pub input_format: PixelFormat,
|
pub input_format: PixelFormat,
|
||||||
/// Target bitrate in kbps
|
/// Bitrate preset
|
||||||
pub bitrate_kbps: u32,
|
pub bitrate_preset: BitratePreset,
|
||||||
/// Target FPS
|
/// Target FPS
|
||||||
pub fps: u32,
|
pub fps: u32,
|
||||||
/// GOP size
|
|
||||||
pub gop_size: u32,
|
|
||||||
/// Enable audio track
|
/// Enable audio track
|
||||||
pub audio_enabled: bool,
|
pub audio_enabled: bool,
|
||||||
}
|
}
|
||||||
@@ -64,9 +63,8 @@ impl Default for UniversalSessionConfig {
|
|||||||
codec: VideoEncoderType::H264,
|
codec: VideoEncoderType::H264,
|
||||||
resolution: Resolution::HD720,
|
resolution: Resolution::HD720,
|
||||||
input_format: PixelFormat::Mjpeg,
|
input_format: PixelFormat::Mjpeg,
|
||||||
bitrate_kbps: 1000,
|
bitrate_preset: BitratePreset::Balanced,
|
||||||
fps: 30,
|
fps: 30,
|
||||||
gop_size: 30,
|
|
||||||
audio_enabled: false,
|
audio_enabled: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,7 +142,7 @@ impl UniversalSession {
|
|||||||
stream_id: "one-kvm-stream".to_string(),
|
stream_id: "one-kvm-stream".to_string(),
|
||||||
codec: video_codec,
|
codec: video_codec,
|
||||||
resolution: config.resolution,
|
resolution: config.resolution,
|
||||||
bitrate_kbps: config.bitrate_kbps,
|
bitrate_kbps: config.bitrate_preset.bitrate_kbps(),
|
||||||
fps: config.fps,
|
fps: config.fps,
|
||||||
};
|
};
|
||||||
let video_track = Arc::new(UniversalVideoTrack::new(track_config));
|
let video_track = Arc::new(UniversalVideoTrack::new(track_config));
|
||||||
|
|||||||
@@ -17,12 +17,10 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use std::io::Cursor;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::{debug, trace, warn};
|
use tracing::{debug, trace, warn};
|
||||||
use webrtc::media::io::h264_reader::H264Reader;
|
|
||||||
use webrtc::media::Sample;
|
use webrtc::media::Sample;
|
||||||
use webrtc::rtp_transceiver::rtp_codec::RTCRtpCodecCapability;
|
use webrtc::rtp_transceiver::rtp_codec::RTCRtpCodecCapability;
|
||||||
use webrtc::track::track_local::track_local_static_rtp::TrackLocalStaticRTP;
|
use webrtc::track::track_local::track_local_static_rtp::TrackLocalStaticRTP;
|
||||||
@@ -201,18 +199,6 @@ pub struct VideoTrackStats {
|
|||||||
pub errors: u64,
|
pub errors: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cached codec parameters for H264/H265
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct CachedParams {
|
|
||||||
/// H264: SPS, H265: VPS
|
|
||||||
#[allow(dead_code)]
|
|
||||||
vps: Option<Bytes>,
|
|
||||||
/// SPS (both H264 and H265)
|
|
||||||
sps: Option<Bytes>,
|
|
||||||
/// PPS (both H264 and H265)
|
|
||||||
pps: Option<Bytes>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Track type wrapper to support different underlying track implementations
|
/// Track type wrapper to support different underlying track implementations
|
||||||
enum TrackType {
|
enum TrackType {
|
||||||
/// Sample-based track with built-in payloader (H264, VP8, VP9)
|
/// Sample-based track with built-in payloader (H264, VP8, VP9)
|
||||||
@@ -243,8 +229,6 @@ pub struct UniversalVideoTrack {
|
|||||||
config: UniversalVideoTrackConfig,
|
config: UniversalVideoTrackConfig,
|
||||||
/// Statistics
|
/// Statistics
|
||||||
stats: Mutex<VideoTrackStats>,
|
stats: Mutex<VideoTrackStats>,
|
||||||
/// Cached parameters for H264/H265
|
|
||||||
cached_params: Mutex<CachedParams>,
|
|
||||||
/// H265 RTP state (only used for H265)
|
/// H265 RTP state (only used for H265)
|
||||||
h265_state: Option<Mutex<H265RtpState>>,
|
h265_state: Option<Mutex<H265RtpState>>,
|
||||||
}
|
}
|
||||||
@@ -294,7 +278,6 @@ impl UniversalVideoTrack {
|
|||||||
codec: config.codec,
|
codec: config.codec,
|
||||||
config,
|
config,
|
||||||
stats: Mutex::new(VideoTrackStats::default()),
|
stats: Mutex::new(VideoTrackStats::default()),
|
||||||
cached_params: Mutex::new(CachedParams::default()),
|
|
||||||
h265_state,
|
h265_state,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,71 +324,43 @@ impl UniversalVideoTrack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Write H264 frame (Annex B format)
|
/// 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<()> {
|
async fn write_h264_frame(&self, data: &[u8], is_keyframe: bool) -> Result<()> {
|
||||||
let cursor = Cursor::new(data);
|
// Send entire Annex B frame as one Sample
|
||||||
let mut h264_reader = H264Reader::new(cursor, 1024 * 1024);
|
// 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<Bytes> = Vec::new();
|
match &self.track {
|
||||||
let mut has_sps = false;
|
TrackType::Sample(track) => {
|
||||||
let mut has_pps = false;
|
if let Err(e) = track.write_sample(&sample).await {
|
||||||
let mut has_idr = false;
|
debug!("H264 write_sample failed: {}", e);
|
||||||
|
|
||||||
// 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<Bytes> = 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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !has_pps {
|
TrackType::Rtp(_) => {
|
||||||
if let Some(ref pps) = params.pps {
|
warn!("H264 should not use RTP track");
|
||||||
debug!("Injecting cached H264 PPS");
|
|
||||||
injected.push(pps.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drop(params);
|
|
||||||
|
|
||||||
if !injected.is_empty() {
|
|
||||||
injected.extend(nals);
|
|
||||||
nals = injected;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send NAL units
|
// Update stats
|
||||||
self.send_nals(nals, is_keyframe).await
|
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)
|
/// Write H265 frame (Annex B format)
|
||||||
@@ -483,52 +438,6 @@ impl UniversalVideoTrack {
|
|||||||
Ok(())
|
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<Bytes>, 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
|
/// Send H265 NAL units via custom H265Payloader
|
||||||
async fn send_h265_rtp(&self, data: &[u8], is_keyframe: bool) -> Result<()> {
|
async fn send_h265_rtp(&self, data: &[u8], is_keyframe: bool) -> Result<()> {
|
||||||
let rtp_track = match &self.track {
|
let rtp_track = match &self.track {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ use crate::video::shared_video_pipeline::{SharedVideoPipeline, SharedVideoPipeli
|
|||||||
use super::config::{TurnServer, WebRtcConfig};
|
use super::config::{TurnServer, WebRtcConfig};
|
||||||
use super::signaling::{ConnectionState, IceCandidate, SdpAnswer, SdpOffer};
|
use super::signaling::{ConnectionState, IceCandidate, SdpAnswer, SdpOffer};
|
||||||
use super::universal_session::{UniversalSession, UniversalSessionConfig};
|
use super::universal_session::{UniversalSession, UniversalSessionConfig};
|
||||||
|
use crate::video::encoder::BitratePreset;
|
||||||
|
|
||||||
/// WebRTC streamer configuration
|
/// WebRTC streamer configuration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -63,12 +64,10 @@ pub struct WebRtcStreamerConfig {
|
|||||||
pub resolution: Resolution,
|
pub resolution: Resolution,
|
||||||
/// Input pixel format
|
/// Input pixel format
|
||||||
pub input_format: PixelFormat,
|
pub input_format: PixelFormat,
|
||||||
/// Target bitrate in kbps
|
/// Bitrate preset
|
||||||
pub bitrate_kbps: u32,
|
pub bitrate_preset: BitratePreset,
|
||||||
/// Target FPS
|
/// Target FPS
|
||||||
pub fps: u32,
|
pub fps: u32,
|
||||||
/// GOP size (keyframe interval)
|
|
||||||
pub gop_size: u32,
|
|
||||||
/// Enable audio (reserved)
|
/// Enable audio (reserved)
|
||||||
pub audio_enabled: bool,
|
pub audio_enabled: bool,
|
||||||
/// Encoder backend (None = auto select best available)
|
/// Encoder backend (None = auto select best available)
|
||||||
@@ -82,9 +81,8 @@ impl Default for WebRtcStreamerConfig {
|
|||||||
video_codec: VideoCodecType::H264,
|
video_codec: VideoCodecType::H264,
|
||||||
resolution: Resolution::HD720,
|
resolution: Resolution::HD720,
|
||||||
input_format: PixelFormat::Mjpeg,
|
input_format: PixelFormat::Mjpeg,
|
||||||
bitrate_kbps: 8000,
|
bitrate_preset: BitratePreset::Balanced,
|
||||||
fps: 30,
|
fps: 30,
|
||||||
gop_size: 30,
|
|
||||||
audio_enabled: false,
|
audio_enabled: false,
|
||||||
encoder_backend: None,
|
encoder_backend: None,
|
||||||
}
|
}
|
||||||
@@ -282,10 +280,10 @@ impl WebRtcStreamer {
|
|||||||
resolution: config.resolution,
|
resolution: config.resolution,
|
||||||
input_format: config.input_format,
|
input_format: config.input_format,
|
||||||
output_codec: Self::codec_type_to_encoder_type(codec),
|
output_codec: Self::codec_type_to_encoder_type(codec),
|
||||||
bitrate_kbps: config.bitrate_kbps,
|
bitrate_preset: config.bitrate_preset,
|
||||||
fps: config.fps,
|
fps: config.fps,
|
||||||
gop_size: config.gop_size,
|
|
||||||
encoder_backend: config.encoder_backend,
|
encoder_backend: config.encoder_backend,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
info!("Creating shared video pipeline for {:?}", codec);
|
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
|
// Note: bitrate is NOT auto-scaled here - use set_bitrate() or config to change it
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"WebRTC config updated: {}x{} {:?} @ {} fps, {} kbps",
|
"WebRTC config updated: {}x{} {:?} @ {} fps, {}",
|
||||||
resolution.width, resolution.height, format, fps, config.bitrate_kbps
|
resolution.width, resolution.height, format, fps, config.bitrate_preset
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,9 +634,8 @@ impl WebRtcStreamer {
|
|||||||
codec: Self::codec_type_to_encoder_type(codec),
|
codec: Self::codec_type_to_encoder_type(codec),
|
||||||
resolution: config.resolution,
|
resolution: config.resolution,
|
||||||
input_format: config.input_format,
|
input_format: config.input_format,
|
||||||
bitrate_kbps: config.bitrate_kbps,
|
bitrate_preset: config.bitrate_preset,
|
||||||
fps: config.fps,
|
fps: config.fps,
|
||||||
gop_size: config.gop_size,
|
|
||||||
audio_enabled: *self.audio_enabled.read().await,
|
audio_enabled: *self.audio_enabled.read().await,
|
||||||
};
|
};
|
||||||
drop(config);
|
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.
|
/// Note: Hardware encoders (VAAPI, NVENC, etc.) don't support dynamic bitrate changes.
|
||||||
/// This method restarts the pipeline to apply the new bitrate.
|
/// This method restarts the pipeline to apply the new bitrate.
|
||||||
pub async fn set_bitrate(self: &Arc<Self>, bitrate_kbps: u32) -> Result<()> {
|
pub async fn set_bitrate_preset(self: &Arc<Self>, preset: BitratePreset) -> Result<()> {
|
||||||
// Update config first
|
// 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
|
// Check if pipeline exists and is running
|
||||||
let pipeline_running = {
|
let pipeline_running = {
|
||||||
@@ -894,8 +891,8 @@ impl WebRtcStreamer {
|
|||||||
|
|
||||||
if pipeline_running {
|
if pipeline_running {
|
||||||
info!(
|
info!(
|
||||||
"Restarting video pipeline to apply new bitrate: {} kbps",
|
"Restarting video pipeline to apply new bitrate: {}",
|
||||||
bitrate_kbps
|
preset
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stop existing pipeline
|
// Stop existing pipeline
|
||||||
@@ -936,16 +933,16 @@ impl WebRtcStreamer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Video pipeline restarted with {} kbps, reconnected {} sessions",
|
"Video pipeline restarted with {}, reconnected {} sessions",
|
||||||
bitrate_kbps,
|
preset,
|
||||||
session_ids.len()
|
session_ids.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
debug!(
|
debug!(
|
||||||
"Pipeline not running, bitrate {} kbps will apply on next start",
|
"Pipeline not running, bitrate {} will apply on next start",
|
||||||
bitrate_kbps
|
preset
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -978,7 +975,7 @@ mod tests {
|
|||||||
let config = WebRtcStreamerConfig::default();
|
let config = WebRtcStreamerConfig::default();
|
||||||
assert_eq!(config.video_codec, VideoCodecType::H264);
|
assert_eq!(config.video_codec, VideoCodecType::H264);
|
||||||
assert_eq!(config.resolution, Resolution::HD720);
|
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_eq!(config.fps, 30);
|
||||||
assert!(!config.audio_enabled);
|
assert!(!config.audio_enabled);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,6 +256,12 @@ export const extensionsApi = {
|
|||||||
|
|
||||||
// ===== RustDesk 配置 API =====
|
// ===== RustDesk 配置 API =====
|
||||||
|
|
||||||
|
/** 公共服务器信息 */
|
||||||
|
export interface PublicServerInfo {
|
||||||
|
server: string
|
||||||
|
public_key: string
|
||||||
|
}
|
||||||
|
|
||||||
/** RustDesk 配置响应 */
|
/** RustDesk 配置响应 */
|
||||||
export interface RustDeskConfigResponse {
|
export interface RustDeskConfigResponse {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -264,6 +270,7 @@ export interface RustDeskConfigResponse {
|
|||||||
device_id: string
|
device_id: string
|
||||||
has_password: boolean
|
has_password: boolean
|
||||||
has_keypair: boolean
|
has_keypair: boolean
|
||||||
|
using_public_server: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** RustDesk 状态响应 */
|
/** RustDesk 状态响应 */
|
||||||
@@ -271,6 +278,7 @@ export interface RustDeskStatusResponse {
|
|||||||
config: RustDeskConfigResponse
|
config: RustDeskConfigResponse
|
||||||
service_status: string
|
service_status: string
|
||||||
rendezvous_status: string | null
|
rendezvous_status: string | null
|
||||||
|
public_server: PublicServerInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** RustDesk 配置更新 */
|
/** RustDesk 配置更新 */
|
||||||
|
|||||||
@@ -230,10 +230,10 @@ export const streamApi = {
|
|||||||
getCodecs: () =>
|
getCodecs: () =>
|
||||||
request<AvailableCodecsResponse>('/stream/codecs'),
|
request<AvailableCodecsResponse>('/stream/codecs'),
|
||||||
|
|
||||||
setBitrate: (bitrate_kbps: number) =>
|
setBitratePreset: (bitrate_preset: import('@/types/generated').BitratePreset) =>
|
||||||
request<{ success: boolean; message?: string }>('/stream/bitrate', {
|
request<{ success: boolean; message?: string }>('/stream/bitrate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ bitrate_kbps }),
|
body: JSON.stringify({ bitrate_preset }),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,6 +612,7 @@ export type {
|
|||||||
HidBackend,
|
HidBackend,
|
||||||
StreamMode,
|
StreamMode,
|
||||||
EncoderType,
|
EncoderType,
|
||||||
|
BitratePreset,
|
||||||
} from '@/types/generated'
|
} from '@/types/generated'
|
||||||
|
|
||||||
// Audio API
|
// Audio API
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { toast } from 'vue-sonner'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Slider } from '@/components/ui/slider'
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -18,11 +17,10 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} 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 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 { useSystemStore } from '@/stores/system'
|
||||||
import { useDebounceFn } from '@vueuse/core'
|
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
|
export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
|
||||||
@@ -179,7 +177,7 @@ const selectedDevice = ref<string>('')
|
|||||||
const selectedFormat = ref<string>('')
|
const selectedFormat = ref<string>('')
|
||||||
const selectedResolution = ref<string>('')
|
const selectedResolution = ref<string>('')
|
||||||
const selectedFps = ref<number>(30)
|
const selectedFps = ref<number>(30)
|
||||||
const selectedBitrate = ref<number[]>([1000])
|
const selectedBitratePreset = ref<'Speed' | 'Balanced' | 'Quality'>('Balanced')
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
const applying = ref(false)
|
const applying = ref(false)
|
||||||
@@ -379,30 +377,27 @@ function handleFpsChange(fps: unknown) {
|
|||||||
selectedFps.value = typeof fps === 'string' ? Number(fps) : fps
|
selectedFps.value = typeof fps === 'string' ? Number(fps) : fps
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply bitrate change (real-time)
|
// Apply bitrate preset change
|
||||||
async function applyBitrate(bitrate: number) {
|
async function applyBitratePreset(preset: 'Speed' | 'Balanced' | 'Quality') {
|
||||||
if (applyingBitrate.value) return
|
if (applyingBitrate.value) return
|
||||||
applyingBitrate.value = true
|
applyingBitrate.value = true
|
||||||
try {
|
try {
|
||||||
await streamApi.setBitrate(bitrate)
|
const bitratePreset: BitratePreset = { type: preset }
|
||||||
|
await streamApi.setBitratePreset(bitratePreset)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.info('[VideoConfig] Failed to apply bitrate:', e)
|
console.info('[VideoConfig] Failed to apply bitrate preset:', e)
|
||||||
} finally {
|
} finally {
|
||||||
applyingBitrate.value = false
|
applyingBitrate.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounced bitrate application
|
// Handle bitrate preset selection
|
||||||
const debouncedApplyBitrate = useDebounceFn((bitrate: number) => {
|
function handleBitratePresetChange(preset: 'Speed' | 'Balanced' | 'Quality') {
|
||||||
applyBitrate(bitrate)
|
selectedBitratePreset.value = preset
|
||||||
}, 300)
|
if (props.videoMode !== 'mjpeg') {
|
||||||
|
applyBitratePreset(preset)
|
||||||
// Watch bitrate slider changes (only when in WebRTC mode)
|
|
||||||
watch(selectedBitrate, (newValue) => {
|
|
||||||
if (props.videoMode !== 'mjpeg' && newValue[0] !== undefined) {
|
|
||||||
debouncedApplyBitrate(newValue[0])
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
// Apply video configuration
|
// Apply video configuration
|
||||||
async function applyVideoConfig() {
|
async function applyVideoConfig() {
|
||||||
@@ -529,21 +524,52 @@ watch(() => props.open, (isOpen) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bitrate Slider - Only shown for WebRTC modes -->
|
<!-- Bitrate Preset - Only shown for WebRTC modes -->
|
||||||
<div v-if="props.videoMode !== 'mjpeg'" class="space-y-2">
|
<div v-if="props.videoMode !== 'mjpeg'" class="space-y-2">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Label class="text-xs">{{ t('actionbar.bitrate') }}</Label>
|
<Label class="text-xs">{{ t('actionbar.bitratePreset') }}</Label>
|
||||||
<HelpTooltip :content="t('help.videoBitrate')" icon-size="sm" />
|
<HelpTooltip :content="t('help.videoBitratePreset')" icon-size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="grid grid-cols-3 gap-1.5">
|
||||||
<Slider
|
<Button
|
||||||
v-model="selectedBitrate"
|
variant="outline"
|
||||||
:min="1000"
|
size="sm"
|
||||||
:max="15000"
|
:class="[
|
||||||
:step="500"
|
'h-auto py-1.5 px-2 flex flex-col items-center gap-0.5',
|
||||||
class="flex-1"
|
selectedBitratePreset === 'Speed' && 'border-primary bg-primary/10'
|
||||||
/>
|
]"
|
||||||
<span class="text-xs text-muted-foreground w-20 text-right">{{ selectedBitrate[0] }} kbps</span>
|
:disabled="applyingBitrate"
|
||||||
|
@click="handleBitratePresetChange('Speed')"
|
||||||
|
>
|
||||||
|
<Zap class="h-3.5 w-3.5" />
|
||||||
|
<span class="text-[10px] font-medium">{{ t('actionbar.bitrateSpeed') }}</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:class="[
|
||||||
|
'h-auto py-1.5 px-2 flex flex-col items-center gap-0.5',
|
||||||
|
selectedBitratePreset === 'Balanced' && 'border-primary bg-primary/10'
|
||||||
|
]"
|
||||||
|
:disabled="applyingBitrate"
|
||||||
|
@click="handleBitratePresetChange('Balanced')"
|
||||||
|
>
|
||||||
|
<Scale class="h-3.5 w-3.5" />
|
||||||
|
<span class="text-[10px] font-medium">{{ t('actionbar.bitrateBalanced') }}</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:class="[
|
||||||
|
'h-auto py-1.5 px-2 flex flex-col items-center gap-0.5',
|
||||||
|
selectedBitratePreset === 'Quality' && 'border-primary bg-primary/10'
|
||||||
|
]"
|
||||||
|
:disabled="applyingBitrate"
|
||||||
|
@click="handleBitratePresetChange('Quality')"
|
||||||
|
>
|
||||||
|
<Image class="h-3.5 w-3.5" />
|
||||||
|
<span class="text-[10px] font-medium">{{ t('actionbar.bitrateQuality') }}</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,13 @@ export default {
|
|||||||
selectFormat: 'Select format...',
|
selectFormat: 'Select format...',
|
||||||
selectResolution: 'Select resolution...',
|
selectResolution: 'Select resolution...',
|
||||||
selectFps: 'Select FPS...',
|
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',
|
browserUnsupported: 'Browser unsupported',
|
||||||
encoder: 'Encoder',
|
encoder: 'Encoder',
|
||||||
changeEncoderBackend: 'Change encoder backend...',
|
changeEncoderBackend: 'Change encoder backend...',
|
||||||
@@ -649,10 +655,14 @@ export default {
|
|||||||
serverSettings: 'Server Settings',
|
serverSettings: 'Server Settings',
|
||||||
rendezvousServer: 'ID Server',
|
rendezvousServer: 'ID Server',
|
||||||
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
|
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
|
||||||
rendezvousServerHint: 'RustDesk ID server address (required)',
|
rendezvousServerHint: 'Leave empty to use public server',
|
||||||
relayServer: 'Relay Server',
|
relayServer: 'Relay Server',
|
||||||
relayServerPlaceholder: 'hbbr.example.com:21117',
|
relayServerPlaceholder: 'hbbr.example.com:21117',
|
||||||
relayServerHint: 'Relay server address, auto-derived from ID server if empty',
|
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',
|
deviceInfo: 'Device Info',
|
||||||
deviceId: 'Device ID',
|
deviceId: 'Device ID',
|
||||||
deviceIdHint: 'Use this ID in RustDesk client to connect',
|
deviceIdHint: 'Use this ID in RustDesk client to connect',
|
||||||
@@ -721,7 +731,7 @@ export default {
|
|||||||
// Video related
|
// Video related
|
||||||
mjpegMode: 'MJPEG mode has best compatibility, works with all browsers, but higher latency',
|
mjpegMode: 'MJPEG mode has best compatibility, works with all browsers, but higher latency',
|
||||||
webrtcMode: 'WebRTC mode has lower latency, but requires browser codec support',
|
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',
|
encoderBackend: 'Hardware encoder has better performance and lower power. Software encoder has better compatibility',
|
||||||
// HID related
|
// HID related
|
||||||
absoluteMode: 'Absolute mode maps mouse coordinates directly, suitable for most scenarios',
|
absoluteMode: 'Absolute mode maps mouse coordinates directly, suitable for most scenarios',
|
||||||
|
|||||||
@@ -109,7 +109,13 @@ export default {
|
|||||||
selectFormat: '选择格式...',
|
selectFormat: '选择格式...',
|
||||||
selectResolution: '选择分辨率...',
|
selectResolution: '选择分辨率...',
|
||||||
selectFps: '选择帧率...',
|
selectFps: '选择帧率...',
|
||||||
bitrate: '码率',
|
bitratePreset: '码率',
|
||||||
|
bitrateSpeed: '速度优先',
|
||||||
|
bitrateSpeedDesc: '1 Mbps - 最低延迟',
|
||||||
|
bitrateBalanced: '均衡',
|
||||||
|
bitrateBalancedDesc: '4 Mbps - 推荐',
|
||||||
|
bitrateQuality: '质量优先',
|
||||||
|
bitrateQualityDesc: '8 Mbps - 最佳画质',
|
||||||
browserUnsupported: '浏览器不支持',
|
browserUnsupported: '浏览器不支持',
|
||||||
encoder: '编码器',
|
encoder: '编码器',
|
||||||
changeEncoderBackend: '更改编码器后端...',
|
changeEncoderBackend: '更改编码器后端...',
|
||||||
@@ -649,10 +655,14 @@ export default {
|
|||||||
serverSettings: '服务器设置',
|
serverSettings: '服务器设置',
|
||||||
rendezvousServer: 'ID 服务器',
|
rendezvousServer: 'ID 服务器',
|
||||||
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
|
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
|
||||||
rendezvousServerHint: 'RustDesk ID 服务器地址(必填)',
|
rendezvousServerHint: '留空则使用公共服务器',
|
||||||
relayServer: '中继服务器',
|
relayServer: '中继服务器',
|
||||||
relayServerPlaceholder: 'hbbr.example.com:21117',
|
relayServerPlaceholder: 'hbbr.example.com:21117',
|
||||||
relayServerHint: '中继服务器地址,留空则自动从 ID 服务器推导',
|
relayServerHint: '中继服务器地址,留空则自动从 ID 服务器推导',
|
||||||
|
publicServerInfo: '公共服务器信息',
|
||||||
|
publicServerAddress: '服务器地址',
|
||||||
|
publicServerKey: '连接密钥',
|
||||||
|
usingPublicServer: '正在使用公共服务器',
|
||||||
deviceInfo: '设备信息',
|
deviceInfo: '设备信息',
|
||||||
deviceId: '设备 ID',
|
deviceId: '设备 ID',
|
||||||
deviceIdHint: '此 ID 用于 RustDesk 客户端连接',
|
deviceIdHint: '此 ID 用于 RustDesk 客户端连接',
|
||||||
@@ -721,7 +731,7 @@ export default {
|
|||||||
// 视频相关
|
// 视频相关
|
||||||
mjpegMode: 'MJPEG 模式兼容性最好,适用于所有浏览器,但延迟较高',
|
mjpegMode: 'MJPEG 模式兼容性最好,适用于所有浏览器,但延迟较高',
|
||||||
webrtcMode: 'WebRTC 模式延迟更低,但需要浏览器支持相应编解码器',
|
webrtcMode: 'WebRTC 模式延迟更低,但需要浏览器支持相应编解码器',
|
||||||
videoBitrate: '比特率越高画质越好,但需要更大的网络带宽。建议根据网络状况调整',
|
videoBitratePreset: '速度优先:最低延迟,适合网络较差的场景;均衡:画质和延迟平衡;质量优先:最佳画质,需要较好的网络带宽',
|
||||||
encoderBackend: '硬件编码器性能更好功耗更低,软件编码器兼容性更好',
|
encoderBackend: '硬件编码器性能更好功耗更低,软件编码器兼容性更好',
|
||||||
// HID 相关
|
// HID 相关
|
||||||
absoluteMode: '绝对定位模式直接映射鼠标坐标,适用于大多数场景',
|
absoluteMode: '绝对定位模式直接映射鼠标坐标,适用于大多数场景',
|
||||||
|
|||||||
@@ -183,16 +183,39 @@ export enum EncoderType {
|
|||||||
V4l2m2m = "v4l2m2m",
|
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 */
|
/** Streaming configuration */
|
||||||
export interface StreamConfig {
|
export interface StreamConfig {
|
||||||
/** Stream mode */
|
/** Stream mode */
|
||||||
mode: StreamMode;
|
mode: StreamMode;
|
||||||
/** Encoder type for H264/H265 */
|
/** Encoder type for H264/H265 */
|
||||||
encoder: EncoderType;
|
encoder: EncoderType;
|
||||||
/** Target bitrate in kbps (for H264/H265) */
|
/** Bitrate preset (Speed/Balanced/Quality) */
|
||||||
bitrate_kbps: number;
|
bitrate_preset: BitratePreset;
|
||||||
/** GOP size */
|
|
||||||
gop_size: number;
|
|
||||||
/** Custom STUN server (e.g., "stun:stun.l.google.com:19302") */
|
/** Custom STUN server (e.g., "stun:stun.l.google.com:19302") */
|
||||||
stun_server?: string;
|
stun_server?: string;
|
||||||
/** Custom TURN server (e.g., "turn:turn.example.com:3478") */
|
/** Custom TURN server (e.g., "turn:turn.example.com:3478") */
|
||||||
@@ -264,6 +287,25 @@ export interface ExtensionsConfig {
|
|||||||
easytier: EasytierConfig;
|
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 */
|
/** Main application configuration */
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
/** Whether initial setup has been completed */
|
/** Whether initial setup has been completed */
|
||||||
@@ -286,6 +328,8 @@ export interface AppConfig {
|
|||||||
web: WebConfig;
|
web: WebConfig;
|
||||||
/** Extensions settings (ttyd, gostc, easytier) */
|
/** Extensions settings (ttyd, gostc, easytier) */
|
||||||
extensions: ExtensionsConfig;
|
extensions: ExtensionsConfig;
|
||||||
|
/** RustDesk remote access settings */
|
||||||
|
rustdesk: RustDeskConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update for a single ATX key configuration */
|
/** Update for a single ATX key configuration */
|
||||||
@@ -441,12 +485,26 @@ export interface MsdConfigUpdate {
|
|||||||
virtual_drive_size_mb?: number;
|
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 字段) */
|
/** Stream 配置响应(包含 has_turn_password 字段) */
|
||||||
export interface StreamConfigResponse {
|
export interface StreamConfigResponse {
|
||||||
mode: StreamMode;
|
mode: StreamMode;
|
||||||
encoder: EncoderType;
|
encoder: EncoderType;
|
||||||
bitrate_kbps: number;
|
bitrate_preset: BitratePreset;
|
||||||
gop_size: number;
|
|
||||||
stun_server?: string;
|
stun_server?: string;
|
||||||
turn_server?: string;
|
turn_server?: string;
|
||||||
turn_username?: string;
|
turn_username?: string;
|
||||||
@@ -457,8 +515,7 @@ export interface StreamConfigResponse {
|
|||||||
export interface StreamConfigUpdate {
|
export interface StreamConfigUpdate {
|
||||||
mode?: StreamMode;
|
mode?: StreamMode;
|
||||||
encoder?: EncoderType;
|
encoder?: EncoderType;
|
||||||
bitrate_kbps?: number;
|
bitrate_preset?: BitratePreset;
|
||||||
gop_size?: number;
|
|
||||||
/** STUN server URL (e.g., "stun:stun.l.google.com:19302") */
|
/** STUN server URL (e.g., "stun:stun.l.google.com:19302") */
|
||||||
stun_server?: string;
|
stun_server?: string;
|
||||||
/** TURN server URL (e.g., "turn:turn.example.com:3478") */
|
/** TURN server URL (e.g., "turn:turn.example.com:3478") */
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
import {
|
import {
|
||||||
Monitor,
|
Monitor,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
@@ -73,6 +79,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Copy,
|
Copy,
|
||||||
ScreenShare,
|
ScreenShare,
|
||||||
|
CircleHelp,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
@@ -1825,7 +1832,28 @@ onMounted(async () => {
|
|||||||
v-model="rustdeskLocalConfig.rendezvous_server"
|
v-model="rustdeskLocalConfig.rendezvous_server"
|
||||||
:placeholder="t('extensions.rustdesk.rendezvousServerPlaceholder')"
|
:placeholder="t('extensions.rustdesk.rendezvousServerPlaceholder')"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.rendezvousServerHint') }}</p>
|
<div class="flex items-center gap-1">
|
||||||
|
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.rendezvousServerHint') }}</p>
|
||||||
|
<TooltipProvider v-if="rustdeskStatus?.public_server">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<CircleHelp class="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" class="max-w-xs">
|
||||||
|
<div class="space-y-1.5 text-xs">
|
||||||
|
<p class="font-medium">{{ t('extensions.rustdesk.publicServerInfo') }}</p>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p><span class="text-muted-foreground">{{ t('extensions.rustdesk.publicServerAddress') }}:</span> {{ rustdeskStatus.public_server.server }}</p>
|
||||||
|
<p><span class="text-muted-foreground">{{ t('extensions.rustdesk.publicServerKey') }}:</span> <code class="text-[10px] break-all">{{ rustdeskStatus.public_server.public_key }}</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<p v-if="rustdeskStatus?.config?.using_public_server" class="text-xs text-blue-500">
|
||||||
|
{{ t('extensions.rustdesk.usingPublicServer') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-4 items-center gap-4">
|
<div class="grid grid-cols-4 items-center gap-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user