mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-03-23 19:36:37 +08:00
feat: 支持硬件编码能力测试,otg 自检修改为需要手动执行
This commit is contained in:
@@ -17,6 +17,7 @@ pub mod h264;
|
|||||||
pub mod h265;
|
pub mod h265;
|
||||||
pub mod jpeg;
|
pub mod jpeg;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
|
pub mod self_check;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
pub mod vp8;
|
pub mod vp8;
|
||||||
pub mod vp9;
|
pub mod vp9;
|
||||||
@@ -31,6 +32,10 @@ pub use codec::{CodecFrame, VideoCodec, VideoCodecConfig, VideoCodecFactory, Vid
|
|||||||
|
|
||||||
// Encoder registry
|
// Encoder registry
|
||||||
pub use registry::{AvailableEncoder, EncoderBackend, EncoderRegistry, VideoEncoderType};
|
pub use registry::{AvailableEncoder, EncoderBackend, EncoderRegistry, VideoEncoderType};
|
||||||
|
pub use self_check::{
|
||||||
|
build_hardware_self_check_runtime_error, run_hardware_self_check, VideoEncoderSelfCheckCell,
|
||||||
|
VideoEncoderSelfCheckCodec, VideoEncoderSelfCheckResponse, VideoEncoderSelfCheckRow,
|
||||||
|
};
|
||||||
|
|
||||||
// H264 encoder
|
// H264 encoder
|
||||||
pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
|
pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
|
||||||
|
|||||||
335
src/video/encoder/self_check.rs
Normal file
335
src/video/encoder/self_check.rs
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
EncoderRegistry, H264Config, H264Encoder, H265Config, H265Encoder, VP8Config, VP8Encoder,
|
||||||
|
VP9Config, VP9Encoder, VideoEncoderType,
|
||||||
|
};
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
|
|
||||||
|
const SELF_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const SELF_CHECK_FRAME_ATTEMPTS: u64 = 3;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct VideoEncoderSelfCheckCodec {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub name: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct VideoEncoderSelfCheckCell {
|
||||||
|
pub codec_id: &'static str,
|
||||||
|
pub ok: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub elapsed_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct VideoEncoderSelfCheckRow {
|
||||||
|
pub resolution_id: &'static str,
|
||||||
|
pub resolution_label: &'static str,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub cells: Vec<VideoEncoderSelfCheckCell>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct VideoEncoderSelfCheckResponse {
|
||||||
|
pub current_hardware_encoder: String,
|
||||||
|
pub codecs: Vec<VideoEncoderSelfCheckCodec>,
|
||||||
|
pub rows: Vec<VideoEncoderSelfCheckRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_hardware_self_check() -> VideoEncoderSelfCheckResponse {
|
||||||
|
let registry = EncoderRegistry::global();
|
||||||
|
let codecs = codec_columns();
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
|
||||||
|
for (resolution_id, resolution_label, resolution) in test_resolutions() {
|
||||||
|
let mut cells = Vec::new();
|
||||||
|
|
||||||
|
for codec in test_codecs() {
|
||||||
|
let cell = match registry.best_encoder(codec, true) {
|
||||||
|
Some(encoder) => run_single_check(codec, resolution, encoder.codec_name.clone()),
|
||||||
|
None => unsupported_cell(codec),
|
||||||
|
};
|
||||||
|
|
||||||
|
cells.push(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(VideoEncoderSelfCheckRow {
|
||||||
|
resolution_id,
|
||||||
|
resolution_label,
|
||||||
|
width: resolution.width,
|
||||||
|
height: resolution.height,
|
||||||
|
cells,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoEncoderSelfCheckResponse {
|
||||||
|
current_hardware_encoder: current_hardware_encoder(registry),
|
||||||
|
codecs,
|
||||||
|
rows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_hardware_self_check_runtime_error() -> VideoEncoderSelfCheckResponse {
|
||||||
|
let codecs = codec_columns();
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
|
||||||
|
for (resolution_id, resolution_label, resolution) in test_resolutions() {
|
||||||
|
let cells = test_codecs()
|
||||||
|
.into_iter()
|
||||||
|
.map(|codec| VideoEncoderSelfCheckCell {
|
||||||
|
codec_id: codec_id(codec),
|
||||||
|
ok: false,
|
||||||
|
elapsed_ms: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
rows.push(VideoEncoderSelfCheckRow {
|
||||||
|
resolution_id,
|
||||||
|
resolution_label,
|
||||||
|
width: resolution.width,
|
||||||
|
height: resolution.height,
|
||||||
|
cells,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoEncoderSelfCheckResponse {
|
||||||
|
current_hardware_encoder: "None".to_string(),
|
||||||
|
codecs,
|
||||||
|
rows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_columns() -> Vec<VideoEncoderSelfCheckCodec> {
|
||||||
|
test_codecs()
|
||||||
|
.into_iter()
|
||||||
|
.map(|codec| VideoEncoderSelfCheckCodec {
|
||||||
|
id: codec_id(codec),
|
||||||
|
name: match codec {
|
||||||
|
VideoEncoderType::H265 => "H.265",
|
||||||
|
_ => codec.display_name(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_codecs() -> [VideoEncoderType; 4] {
|
||||||
|
[
|
||||||
|
VideoEncoderType::H264,
|
||||||
|
VideoEncoderType::H265,
|
||||||
|
VideoEncoderType::VP8,
|
||||||
|
VideoEncoderType::VP9,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_resolutions() -> [(&'static str, &'static str, Resolution); 4] {
|
||||||
|
[
|
||||||
|
("720p", "720p", Resolution::HD720),
|
||||||
|
("1080p", "1080p", Resolution::HD1080),
|
||||||
|
("2k", "2K", Resolution::new(2560, 1440)),
|
||||||
|
("4k", "4K", Resolution::UHD4K),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_id(codec: VideoEncoderType) -> &'static str {
|
||||||
|
match codec {
|
||||||
|
VideoEncoderType::H264 => "h264",
|
||||||
|
VideoEncoderType::H265 => "h265",
|
||||||
|
VideoEncoderType::VP8 => "vp8",
|
||||||
|
VideoEncoderType::VP9 => "vp9",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unsupported_cell(codec: VideoEncoderType) -> VideoEncoderSelfCheckCell {
|
||||||
|
VideoEncoderSelfCheckCell {
|
||||||
|
codec_id: codec_id(codec),
|
||||||
|
ok: false,
|
||||||
|
elapsed_ms: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_single_check(
|
||||||
|
codec: VideoEncoderType,
|
||||||
|
resolution: Resolution,
|
||||||
|
codec_name_ffmpeg: String,
|
||||||
|
) -> VideoEncoderSelfCheckCell {
|
||||||
|
let started = Instant::now();
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let thread_codec_name = codec_name_ffmpeg.clone();
|
||||||
|
|
||||||
|
let spawn_result = std::thread::Builder::new()
|
||||||
|
.name(format!(
|
||||||
|
"encoder-self-check-{}-{}x{}",
|
||||||
|
codec_id(codec),
|
||||||
|
resolution.width,
|
||||||
|
resolution.height
|
||||||
|
))
|
||||||
|
.spawn(move || {
|
||||||
|
let _ = tx.send(run_smoke_test(codec, resolution, &thread_codec_name));
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(e) = spawn_result {
|
||||||
|
let _ = e;
|
||||||
|
return VideoEncoderSelfCheckCell {
|
||||||
|
codec_id: codec_id(codec),
|
||||||
|
ok: false,
|
||||||
|
elapsed_ms: Some(started.elapsed().as_millis() as u64),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match rx.recv_timeout(SELF_CHECK_TIMEOUT) {
|
||||||
|
Ok(Ok(())) => VideoEncoderSelfCheckCell {
|
||||||
|
codec_id: codec_id(codec),
|
||||||
|
ok: true,
|
||||||
|
elapsed_ms: Some(started.elapsed().as_millis() as u64),
|
||||||
|
},
|
||||||
|
Ok(Err(_)) => VideoEncoderSelfCheckCell {
|
||||||
|
codec_id: codec_id(codec),
|
||||||
|
ok: false,
|
||||||
|
elapsed_ms: Some(started.elapsed().as_millis() as u64),
|
||||||
|
},
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => VideoEncoderSelfCheckCell {
|
||||||
|
codec_id: codec_id(codec),
|
||||||
|
ok: false,
|
||||||
|
elapsed_ms: Some(started.elapsed().as_millis() as u64),
|
||||||
|
},
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => VideoEncoderSelfCheckCell {
|
||||||
|
codec_id: codec_id(codec),
|
||||||
|
ok: false,
|
||||||
|
elapsed_ms: Some(started.elapsed().as_millis() as u64),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_hardware_encoder(registry: &EncoderRegistry) -> String {
|
||||||
|
let backends = registry
|
||||||
|
.available_backends()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|backend| backend.is_hardware())
|
||||||
|
.map(|backend| backend.display_name().to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if backends.is_empty() {
|
||||||
|
"None".to_string()
|
||||||
|
} else {
|
||||||
|
backends.join("/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_smoke_test(
|
||||||
|
codec: VideoEncoderType,
|
||||||
|
resolution: Resolution,
|
||||||
|
codec_name_ffmpeg: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
match codec {
|
||||||
|
VideoEncoderType::H264 => run_h264_smoke_test(resolution, codec_name_ffmpeg),
|
||||||
|
VideoEncoderType::H265 => run_h265_smoke_test(resolution, codec_name_ffmpeg),
|
||||||
|
VideoEncoderType::VP8 => run_vp8_smoke_test(resolution, codec_name_ffmpeg),
|
||||||
|
VideoEncoderType::VP9 => run_vp9_smoke_test(resolution, codec_name_ffmpeg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_h264_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
|
||||||
|
let mut encoder = H264Encoder::with_codec(
|
||||||
|
H264Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
|
||||||
|
codec_name_ffmpeg,
|
||||||
|
)?;
|
||||||
|
encoder.request_keyframe();
|
||||||
|
let frame = build_nv12_test_frame(resolution, encoder.yuv_info().2 as usize);
|
||||||
|
|
||||||
|
for sequence in 0..SELF_CHECK_FRAME_ATTEMPTS {
|
||||||
|
let frames = encoder.encode_raw(&frame, pts_ms(sequence))?;
|
||||||
|
if frames.iter().any(|frame| !frame.data.is_empty()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AppError::VideoError(
|
||||||
|
"Encoder produced no output after multiple frames".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_h265_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
|
||||||
|
let mut encoder = H265Encoder::with_codec(
|
||||||
|
H265Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
|
||||||
|
codec_name_ffmpeg,
|
||||||
|
)?;
|
||||||
|
encoder.request_keyframe();
|
||||||
|
let frame = build_nv12_test_frame(resolution, encoder.buffer_info().2 as usize);
|
||||||
|
|
||||||
|
for sequence in 0..SELF_CHECK_FRAME_ATTEMPTS {
|
||||||
|
let frames = encoder.encode_raw(&frame, pts_ms(sequence))?;
|
||||||
|
if frames.iter().any(|frame| !frame.data.is_empty()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AppError::VideoError(
|
||||||
|
"Encoder produced no output after multiple frames".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_vp8_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
|
||||||
|
let mut encoder = VP8Encoder::with_codec(
|
||||||
|
VP8Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
|
||||||
|
codec_name_ffmpeg,
|
||||||
|
)?;
|
||||||
|
let frame = build_nv12_test_frame(resolution, encoder.buffer_info().2 as usize);
|
||||||
|
|
||||||
|
for sequence in 0..SELF_CHECK_FRAME_ATTEMPTS {
|
||||||
|
let frames = encoder.encode_raw(&frame, pts_ms(sequence))?;
|
||||||
|
if frames.iter().any(|frame| !frame.data.is_empty()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AppError::VideoError(
|
||||||
|
"Encoder produced no output after multiple frames".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_vp9_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
|
||||||
|
let mut encoder = VP9Encoder::with_codec(
|
||||||
|
VP9Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
|
||||||
|
codec_name_ffmpeg,
|
||||||
|
)?;
|
||||||
|
let frame = build_nv12_test_frame(resolution, encoder.buffer_info().2 as usize);
|
||||||
|
|
||||||
|
for sequence in 0..SELF_CHECK_FRAME_ATTEMPTS {
|
||||||
|
let frames = encoder.encode_raw(&frame, pts_ms(sequence))?;
|
||||||
|
if frames.iter().any(|frame| !frame.data.is_empty()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AppError::VideoError(
|
||||||
|
"Encoder produced no output after multiple frames".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_nv12_test_frame(resolution: Resolution, buffer_length: usize) -> Vec<u8> {
|
||||||
|
let minimum_length = PixelFormat::Nv12.frame_size(resolution).unwrap_or(0);
|
||||||
|
let mut frame = vec![0x80; buffer_length.max(minimum_length)];
|
||||||
|
let y_plane_len = (resolution.width * resolution.height) as usize;
|
||||||
|
let fill_len = y_plane_len.min(frame.len());
|
||||||
|
frame[..fill_len].fill(0x10);
|
||||||
|
frame
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bitrate_kbps_for_resolution(resolution: Resolution) -> u32 {
|
||||||
|
match resolution.width {
|
||||||
|
0..=1280 => 4_000,
|
||||||
|
1281..=1920 => 8_000,
|
||||||
|
1921..=2560 => 12_000,
|
||||||
|
_ => 20_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pts_ms(sequence: u64) -> i64 {
|
||||||
|
((sequence * 1000) / 30) as i64
|
||||||
|
}
|
||||||
@@ -16,7 +16,10 @@ use crate::events::SystemEvent;
|
|||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::update::{UpdateChannel, UpdateOverviewResponse, UpdateStatusResponse, UpgradeRequest};
|
use crate::update::{UpdateChannel, UpdateOverviewResponse, UpdateStatusResponse, UpgradeRequest};
|
||||||
use crate::video::codec_constraints::codec_to_id;
|
use crate::video::codec_constraints::codec_to_id;
|
||||||
use crate::video::encoder::BitratePreset;
|
use crate::video::encoder::{
|
||||||
|
build_hardware_self_check_runtime_error, run_hardware_self_check, BitratePreset,
|
||||||
|
VideoEncoderSelfCheckResponse,
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Health & Info
|
// Health & Info
|
||||||
@@ -1848,6 +1851,15 @@ pub async fn stream_codecs_list() -> Json<AvailableCodecsResponse> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run hardware encoder smoke tests across common resolutions/codecs.
|
||||||
|
pub async fn video_encoder_self_check() -> Json<VideoEncoderSelfCheckResponse> {
|
||||||
|
let response = tokio::task::spawn_blocking(run_hardware_self_check)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| build_hardware_self_check_runtime_error());
|
||||||
|
|
||||||
|
Json(response)
|
||||||
|
}
|
||||||
|
|
||||||
/// Query parameters for MJPEG stream
|
/// Query parameters for MJPEG stream
|
||||||
#[derive(Deserialize, Default)]
|
#[derive(Deserialize, Default)]
|
||||||
pub struct MjpegStreamQuery {
|
pub struct MjpegStreamQuery {
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/stream/bitrate", post(handlers::stream_set_bitrate))
|
.route("/stream/bitrate", post(handlers::stream_set_bitrate))
|
||||||
.route("/stream/codecs", get(handlers::stream_codecs_list))
|
.route("/stream/codecs", get(handlers::stream_codecs_list))
|
||||||
.route("/stream/constraints", get(handlers::stream_constraints_get))
|
.route("/stream/constraints", get(handlers::stream_constraints_get))
|
||||||
|
.route(
|
||||||
|
"/video/encoder/self-check",
|
||||||
|
get(handlers::video_encoder_self_check),
|
||||||
|
)
|
||||||
// WebRTC endpoints
|
// WebRTC endpoints
|
||||||
.route("/webrtc/session", post(handlers::webrtc_create_session))
|
.route("/webrtc/session", post(handlers::webrtc_create_session))
|
||||||
.route("/webrtc/offer", post(handlers::webrtc_offer))
|
.route("/webrtc/offer", post(handlers::webrtc_offer))
|
||||||
|
|||||||
@@ -177,6 +177,31 @@ export interface StreamConstraintsResponse {
|
|||||||
current_mode: string
|
current_mode: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VideoEncoderSelfCheckCodec {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoEncoderSelfCheckCell {
|
||||||
|
codec_id: string
|
||||||
|
ok: boolean
|
||||||
|
elapsed_ms?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoEncoderSelfCheckRow {
|
||||||
|
resolution_id: string
|
||||||
|
resolution_label: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
cells: VideoEncoderSelfCheckCell[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoEncoderSelfCheckResponse {
|
||||||
|
current_hardware_encoder: string
|
||||||
|
codecs: VideoEncoderSelfCheckCodec[]
|
||||||
|
rows: VideoEncoderSelfCheckRow[]
|
||||||
|
}
|
||||||
|
|
||||||
export const streamApi = {
|
export const streamApi = {
|
||||||
status: () =>
|
status: () =>
|
||||||
request<{
|
request<{
|
||||||
@@ -217,6 +242,9 @@ export const streamApi = {
|
|||||||
getConstraints: () =>
|
getConstraints: () =>
|
||||||
request<StreamConstraintsResponse>('/stream/constraints'),
|
request<StreamConstraintsResponse>('/stream/constraints'),
|
||||||
|
|
||||||
|
encoderSelfCheck: () =>
|
||||||
|
request<VideoEncoderSelfCheckResponse>('/video/encoder/self-check'),
|
||||||
|
|
||||||
setBitratePreset: (bitrate_preset: import('@/types/generated').BitratePreset) =>
|
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',
|
||||||
|
|||||||
@@ -757,6 +757,15 @@ export default {
|
|||||||
udc_speed: 'Device may not be fully enumerated; try reconnecting USB',
|
udc_speed: 'Device may not be fully enumerated; try reconnecting USB',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
encoderSelfCheck: {
|
||||||
|
title: 'Hardware Encoding Capability Test',
|
||||||
|
desc: 'Test hardware encoding capability across 720p, 1080p, 2K, and 4K',
|
||||||
|
run: 'Start Test',
|
||||||
|
failed: 'Failed to run hardware encoding capability test',
|
||||||
|
resolution: 'Resolution',
|
||||||
|
currentHardwareEncoder: 'Current Hardware Encoder',
|
||||||
|
none: 'None',
|
||||||
|
},
|
||||||
// WebRTC / ICE
|
// WebRTC / ICE
|
||||||
webrtcSettings: 'WebRTC Settings',
|
webrtcSettings: 'WebRTC Settings',
|
||||||
webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal',
|
webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal',
|
||||||
|
|||||||
@@ -757,6 +757,15 @@ export default {
|
|||||||
udc_speed: '设备可能未完成枚举,可尝试重插 USB',
|
udc_speed: '设备可能未完成枚举,可尝试重插 USB',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
encoderSelfCheck: {
|
||||||
|
title: '硬件编码能力测试',
|
||||||
|
desc: '按 720p、1080p、2K、4K 测试硬件编码能力',
|
||||||
|
run: '开始测试',
|
||||||
|
failed: '执行硬件编码能力测试失败',
|
||||||
|
resolution: '分辨率',
|
||||||
|
currentHardwareEncoder: '当前硬件编码器',
|
||||||
|
none: '无',
|
||||||
|
},
|
||||||
// WebRTC / ICE
|
// WebRTC / ICE
|
||||||
webrtcSettings: 'WebRTC 设置',
|
webrtcSettings: 'WebRTC 设置',
|
||||||
webrtcSettingsDesc: '配置 STUN/TURN 服务器以实现 NAT 穿透',
|
webrtcSettingsDesc: '配置 STUN/TURN 服务器以实现 NAT 穿透',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
type UpdateOverviewResponse,
|
type UpdateOverviewResponse,
|
||||||
type UpdateStatusResponse,
|
type UpdateStatusResponse,
|
||||||
type UpdateChannel,
|
type UpdateChannel,
|
||||||
|
type VideoEncoderSelfCheckResponse,
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
import type {
|
import type {
|
||||||
ExtensionsStatus,
|
ExtensionsStatus,
|
||||||
@@ -539,6 +540,64 @@ async function onRunOtgSelfCheckClick() {
|
|||||||
await runOtgSelfCheck()
|
await runOtgSelfCheck()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VideoEncoderSelfCheckCell = VideoEncoderSelfCheckResponse['rows'][number]['cells'][number]
|
||||||
|
type VideoEncoderSelfCheckRow = VideoEncoderSelfCheckResponse['rows'][number]
|
||||||
|
|
||||||
|
const videoEncoderSelfCheckLoading = ref(false)
|
||||||
|
const videoEncoderSelfCheckResult = ref<VideoEncoderSelfCheckResponse | null>(null)
|
||||||
|
const videoEncoderSelfCheckError = ref('')
|
||||||
|
const videoEncoderRunButtonPressed = ref(false)
|
||||||
|
|
||||||
|
function videoEncoderCell(row: VideoEncoderSelfCheckRow, codecId: string): VideoEncoderSelfCheckCell | undefined {
|
||||||
|
return row.cells.find(cell => cell.codec_id === codecId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentHardwareEncoderText = computed(() =>
|
||||||
|
videoEncoderSelfCheckResult.value?.current_hardware_encoder === 'None'
|
||||||
|
? t('settings.encoderSelfCheck.none')
|
||||||
|
: (videoEncoderSelfCheckResult.value?.current_hardware_encoder || t('settings.encoderSelfCheck.none'))
|
||||||
|
)
|
||||||
|
|
||||||
|
function videoEncoderCodecLabel(codecId: string, codecName: string): string {
|
||||||
|
return codecId === 'h265' ? 'H.265' : codecName
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoEncoderCellClass(ok: boolean | undefined): string {
|
||||||
|
return ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoEncoderCellSymbol(ok: boolean | undefined): string {
|
||||||
|
return ok ? '✓' : '✗'
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoEncoderCellTime(cell: VideoEncoderSelfCheckCell | undefined): string {
|
||||||
|
if (!cell || typeof cell.elapsed_ms !== 'number') return '-'
|
||||||
|
return `${cell.elapsed_ms}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runVideoEncoderSelfCheck() {
|
||||||
|
videoEncoderSelfCheckLoading.value = true
|
||||||
|
videoEncoderSelfCheckError.value = ''
|
||||||
|
try {
|
||||||
|
videoEncoderSelfCheckResult.value = await streamApi.encoderSelfCheck()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to run encoder self-check:', e)
|
||||||
|
videoEncoderSelfCheckError.value = t('settings.encoderSelfCheck.failed')
|
||||||
|
} finally {
|
||||||
|
videoEncoderSelfCheckLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRunVideoEncoderSelfCheckClick() {
|
||||||
|
if (!videoEncoderSelfCheckLoading.value) {
|
||||||
|
videoEncoderRunButtonPressed.value = true
|
||||||
|
window.setTimeout(() => {
|
||||||
|
videoEncoderRunButtonPressed.value = false
|
||||||
|
}, 160)
|
||||||
|
}
|
||||||
|
await runVideoEncoderSelfCheck()
|
||||||
|
}
|
||||||
|
|
||||||
function alignHidProfileForLowEndpoint() {
|
function alignHidProfileForLowEndpoint() {
|
||||||
if (hidProfileAligned.value) return
|
if (hidProfileAligned.value) return
|
||||||
if (!configLoaded.value || !devicesLoaded.value) return
|
if (!configLoaded.value || !devicesLoaded.value) return
|
||||||
@@ -1781,16 +1840,15 @@ onMounted(async () => {
|
|||||||
if (updateRunning.value) {
|
if (updateRunning.value) {
|
||||||
startUpdatePolling()
|
startUpdatePolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
await runOtgSelfCheck()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(updateChannel, async () => {
|
watch(updateChannel, async () => {
|
||||||
await loadUpdateOverview()
|
await loadUpdateOverview()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => config.value.hid_backend, async () => {
|
watch(() => config.value.hid_backend, () => {
|
||||||
await runOtgSelfCheck()
|
otgSelfCheckResult.value = null
|
||||||
|
otgSelfCheckError.value = ''
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -2364,6 +2422,86 @@ watch(() => config.value.hid_backend, async () => {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="flex flex-row items-start justify-between space-y-0">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<CardTitle>{{ t('settings.encoderSelfCheck.title') }}</CardTitle>
|
||||||
|
<CardDescription>{{ t('settings.encoderSelfCheck.desc') }}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="videoEncoderSelfCheckLoading"
|
||||||
|
:class="[
|
||||||
|
'transition-all duration-150 active:scale-95 active:brightness-95',
|
||||||
|
videoEncoderRunButtonPressed ? 'scale-95 brightness-95' : ''
|
||||||
|
]"
|
||||||
|
@click="onRunVideoEncoderSelfCheckClick"
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-4 w-4 mr-2" :class="{ 'animate-spin': videoEncoderSelfCheckLoading }" />
|
||||||
|
{{ t('settings.encoderSelfCheck.run') }}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
<p v-if="videoEncoderSelfCheckError" class="text-xs text-red-600 dark:text-red-400">
|
||||||
|
{{ videoEncoderSelfCheckError }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-if="videoEncoderSelfCheckResult">
|
||||||
|
<div class="text-sm">
|
||||||
|
{{ t('settings.encoderSelfCheck.currentHardwareEncoder') }}:{{ currentHardwareEncoderText }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-md border bg-card">
|
||||||
|
<table class="w-full table-fixed text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 py-3 text-left font-medium w-[18%]">{{ t('settings.encoderSelfCheck.resolution') }}</th>
|
||||||
|
<th
|
||||||
|
v-for="codec in videoEncoderSelfCheckResult.codecs"
|
||||||
|
:key="codec.id"
|
||||||
|
class="px-2 py-3 text-center font-medium w-[20.5%]"
|
||||||
|
>
|
||||||
|
{{ videoEncoderCodecLabel(codec.id, codec.name) }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="row in videoEncoderSelfCheckResult.rows"
|
||||||
|
:key="row.resolution_id"
|
||||||
|
>
|
||||||
|
<td class="px-2 py-3 align-middle">
|
||||||
|
<div class="font-medium">{{ row.resolution_label }}</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-for="codec in videoEncoderSelfCheckResult.codecs"
|
||||||
|
:key="`${row.resolution_id}-${codec.id}`"
|
||||||
|
class="px-2 py-3 align-middle"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center gap-1"
|
||||||
|
:class="videoEncoderCellClass(videoEncoderCell(row, codec.id)?.ok)"
|
||||||
|
>
|
||||||
|
<div class="text-lg leading-none font-semibold">
|
||||||
|
{{ videoEncoderCellSymbol(videoEncoderCell(row, codec.id)?.ok) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] leading-4 text-foreground/70">
|
||||||
|
{{ videoEncoderCellTime(videoEncoderCell(row, codec.id)) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p v-else-if="videoEncoderSelfCheckLoading" class="text-xs text-muted-foreground">
|
||||||
|
{{ t('common.loading') }}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Access Section -->
|
<!-- Access Section -->
|
||||||
|
|||||||
Reference in New Issue
Block a user