feat(video): 事务化切换与前端统一编排,增强视频输入格式支持

- 后端:切换事务+transition_id,/stream/mode 返回 switching/transition_id 与实际 codec

- 事件:新增 mode_switching/mode_ready,config/webrtc_ready/mode_changed 关联事务

- 编码/格式:扩展 NV21/NV16/NV24/RGB/BGR 输入与转换链路,RKMPP direct input 优化

- 前端:useVideoSession 统一切换,失败回退真实切回 MJPEG,菜单格式同步修复

- 清理:useVideoStream 降级为 MJPEG-only
This commit is contained in:
mofeng-git
2026-01-11 10:41:57 +08:00
parent 9feb74b72c
commit 206594e292
110 changed files with 3955 additions and 2251 deletions

View File

@@ -31,15 +31,14 @@ pub async fn apply_video_config(
.format
.as_ref()
.and_then(|f| {
serde_json::from_value::<crate::video::format::PixelFormat>(
serde_json::Value::String(f.clone()),
)
serde_json::from_value::<crate::video::format::PixelFormat>(serde_json::Value::String(
f.clone(),
))
.ok()
})
.unwrap_or(crate::video::format::PixelFormat::Mjpeg);
let resolution =
crate::video::format::Resolution::new(new_config.width, new_config.height);
let resolution = crate::video::format::Resolution::new(new_config.width, new_config.height);
// Step 1: 更新 WebRTC streamer 配置(停止现有 pipeline 和 sessions
state
@@ -162,9 +161,16 @@ pub async fn apply_hid_config(
// 如果描述符变更且当前使用 OTG 后端,需要重建 Gadget
if descriptor_changed && new_config.backend == HidBackend::Otg {
tracing::info!("OTG descriptor changed, updating gadget...");
if let Err(e) = state.otg_service.update_descriptor(&new_config.otg_descriptor).await {
if let Err(e) = state
.otg_service
.update_descriptor(&new_config.otg_descriptor)
.await
{
tracing::error!("Failed to update OTG descriptor: {}", e);
return Err(AppError::Config(format!("OTG descriptor update failed: {}", e)));
return Err(AppError::Config(format!(
"OTG descriptor update failed: {}",
e
)));
}
tracing::info!("OTG descriptor updated successfully");
}
@@ -197,7 +203,10 @@ pub async fn apply_hid_config(
.await
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
tracing::info!("HID backend reloaded successfully: {:?}", new_config.backend);
tracing::info!(
"HID backend reloaded successfully: {:?}",
new_config.backend
);
// When switching to OTG backend, automatically enable MSD if not already enabled
// OTG HID and MSD share the same USB gadget, so it makes sense to enable both
@@ -245,7 +254,11 @@ pub async fn apply_msd_config(
let old_msd_enabled = old_config.enabled;
let new_msd_enabled = new_config.enabled;
tracing::info!("MSD enabled: old={}, new={}", old_msd_enabled, new_msd_enabled);
tracing::info!(
"MSD enabled: old={}, new={}",
old_msd_enabled,
new_msd_enabled
);
if old_msd_enabled != new_msd_enabled {
if new_msd_enabled {
@@ -257,9 +270,9 @@ pub async fn apply_msd_config(
&new_config.images_path,
&new_config.drive_path,
);
msd.init().await.map_err(|e| {
AppError::Config(format!("MSD initialization failed: {}", e))
})?;
msd.init()
.await
.map_err(|e| AppError::Config(format!("MSD initialization failed: {}", e)))?;
// Set event bus
let events = state.events.clone();
@@ -429,7 +442,10 @@ pub async fn apply_rustdesk_config(
if let Err(e) = service.restart(new_config.clone()).await {
tracing::error!("Failed to restart RustDesk service: {}", e);
} else {
tracing::info!("RustDesk service restarted with ID: {}", new_config.device_id);
tracing::info!(
"RustDesk service restarted with ID: {}",
new_config.device_id
);
// Save generated keypair and UUID to config
credentials_to_save = service.save_credentials();
}

View File

@@ -19,26 +19,26 @@
pub(crate) mod apply;
mod types;
pub(crate) mod video;
mod stream;
mod hid;
mod msd;
mod atx;
mod audio;
mod hid;
mod msd;
mod rustdesk;
mod stream;
pub(crate) mod video;
mod web;
// 导出 handler 函数
pub use video::{get_video_config, update_video_config};
pub use stream::{get_stream_config, update_stream_config};
pub use hid::{get_hid_config, update_hid_config};
pub use msd::{get_msd_config, update_msd_config};
pub use atx::{get_atx_config, update_atx_config};
pub use audio::{get_audio_config, update_audio_config};
pub use hid::{get_hid_config, update_hid_config};
pub use msd::{get_msd_config, update_msd_config};
pub use rustdesk::{
get_rustdesk_config, get_rustdesk_status, update_rustdesk_config,
regenerate_device_id, regenerate_device_password, get_device_password,
get_device_password, get_rustdesk_config, get_rustdesk_status, regenerate_device_id,
regenerate_device_password, update_rustdesk_config,
};
pub use stream::{get_stream_config, update_stream_config};
pub use video::{get_video_config, update_video_config};
pub use web::{get_web_config, update_web_config};
// 保留全局配置查询(向后兼容)

View File

@@ -48,12 +48,16 @@ pub struct RustDeskStatusResponse {
}
/// 获取 RustDesk 配置
pub async fn get_rustdesk_config(State(state): State<Arc<AppState>>) -> Json<RustDeskConfigResponse> {
pub async fn get_rustdesk_config(
State(state): State<Arc<AppState>>,
) -> Json<RustDeskConfigResponse> {
Json(RustDeskConfigResponse::from(&state.config.get().rustdesk))
}
/// 获取 RustDesk 完整状态(配置 + 服务状态)
pub async fn get_rustdesk_status(State(state): State<Arc<AppState>>) -> Json<RustDeskStatusResponse> {
pub async fn get_rustdesk_status(
State(state): State<Arc<AppState>>,
) -> Json<RustDeskStatusResponse> {
let config = state.config.get().rustdesk.clone();
// 获取服务状态

View File

@@ -1,9 +1,9 @@
use serde::Deserialize;
use typeshare::typeshare;
use crate::config::*;
use crate::error::AppError;
use crate::rustdesk::config::RustDeskConfig;
use crate::video::encoder::BitratePreset;
use serde::Deserialize;
use typeshare::typeshare;
// ===== Video Config =====
#[typeshare]
@@ -21,12 +21,16 @@ impl VideoConfigUpdate {
pub fn validate(&self) -> crate::error::Result<()> {
if let Some(width) = self.width {
if !(320..=7680).contains(&width) {
return Err(AppError::BadRequest("Invalid width: must be 320-7680".into()));
return Err(AppError::BadRequest(
"Invalid width: must be 320-7680".into(),
));
}
}
if let Some(height) = self.height {
if !(240..=4320).contains(&height) {
return Err(AppError::BadRequest("Invalid height: must be 240-4320".into()));
return Err(AppError::BadRequest(
"Invalid height: must be 240-4320".into(),
));
}
}
if let Some(fps) = self.fps {
@@ -36,7 +40,9 @@ impl VideoConfigUpdate {
}
if let Some(quality) = self.quality {
if !(1..=100).contains(&quality) {
return Err(AppError::BadRequest("Invalid quality: must be 1-100".into()));
return Err(AppError::BadRequest(
"Invalid quality: must be 1-100".into(),
));
}
}
Ok(())
@@ -126,7 +132,8 @@ impl StreamConfigUpdate {
if let Some(ref stun) = self.stun_server {
if !stun.is_empty() && !stun.starts_with("stun:") {
return Err(AppError::BadRequest(
"STUN server must start with 'stun:' (e.g., stun:stun.l.google.com:19302)".into(),
"STUN server must start with 'stun:' (e.g., stun:stun.l.google.com:19302)"
.into(),
));
}
}
@@ -153,16 +160,32 @@ impl StreamConfigUpdate {
}
// STUN/TURN settings - empty string means clear (use public servers), Some("value") means set custom
if let Some(ref stun) = self.stun_server {
config.stun_server = if stun.is_empty() { None } else { Some(stun.clone()) };
config.stun_server = if stun.is_empty() {
None
} else {
Some(stun.clone())
};
}
if let Some(ref turn) = self.turn_server {
config.turn_server = if turn.is_empty() { None } else { Some(turn.clone()) };
config.turn_server = if turn.is_empty() {
None
} else {
Some(turn.clone())
};
}
if let Some(ref username) = self.turn_username {
config.turn_username = if username.is_empty() { None } else { Some(username.clone()) };
config.turn_username = if username.is_empty() {
None
} else {
Some(username.clone())
};
}
if let Some(ref password) = self.turn_password {
config.turn_password = if password.is_empty() { None } else { Some(password.clone()) };
config.turn_password = if password.is_empty() {
None
} else {
Some(password.clone())
};
}
}
}
@@ -185,19 +208,25 @@ impl OtgDescriptorConfigUpdate {
// Validate manufacturer string length
if let Some(ref s) = self.manufacturer {
if s.len() > 126 {
return Err(AppError::BadRequest("Manufacturer string too long (max 126 chars)".into()));
return Err(AppError::BadRequest(
"Manufacturer string too long (max 126 chars)".into(),
));
}
}
// Validate product string length
if let Some(ref s) = self.product {
if s.len() > 126 {
return Err(AppError::BadRequest("Product string too long (max 126 chars)".into()));
return Err(AppError::BadRequest(
"Product string too long (max 126 chars)".into(),
));
}
}
// Validate serial number string length
if let Some(ref s) = self.serial_number {
if s.len() > 126 {
return Err(AppError::BadRequest("Serial number string too long (max 126 chars)".into()));
return Err(AppError::BadRequest(
"Serial number string too long (max 126 chars)".into(),
));
}
}
Ok(())
@@ -469,7 +498,8 @@ impl RustDeskConfigUpdate {
if let Some(ref server) = self.rendezvous_server {
if !server.is_empty() && !server.contains(':') {
return Err(AppError::BadRequest(
"Rendezvous server must be in format 'host:port' (e.g., rs.example.com:21116)".into(),
"Rendezvous server must be in format 'host:port' (e.g., rs.example.com:21116)"
.into(),
));
}
}
@@ -477,7 +507,8 @@ impl RustDeskConfigUpdate {
if let Some(ref server) = self.relay_server {
if !server.is_empty() && !server.contains(':') {
return Err(AppError::BadRequest(
"Relay server must be in format 'host:port' (e.g., rs.example.com:21117)".into(),
"Relay server must be in format 'host:port' (e.g., rs.example.com:21117)"
.into(),
));
}
}
@@ -500,10 +531,18 @@ impl RustDeskConfigUpdate {
config.rendezvous_server = server.clone();
}
if let Some(ref server) = self.relay_server {
config.relay_server = if server.is_empty() { None } else { Some(server.clone()) };
config.relay_server = if server.is_empty() {
None
} else {
Some(server.clone())
};
}
if let Some(ref key) = self.relay_key {
config.relay_key = if key.is_empty() { None } else { Some(key.clone()) };
config.relay_key = if key.is_empty() {
None
} else {
Some(key.clone())
};
}
if let Some(ref password) = self.device_password {
if !password.is_empty() {