From 24a10aa222301ae1c176f0f3454cd01383d004ab Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sun, 22 Mar 2026 14:55:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=A1=AC=E4=BB=B6?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E8=83=BD=E5=8A=9B=E6=B5=8B=E8=AF=95=EF=BC=8C?= =?UTF-8?q?otg=20=E8=87=AA=E6=A3=80=E4=BF=AE=E6=94=B9=E4=B8=BA=E9=9C=80?= =?UTF-8?q?=E8=A6=81=E6=89=8B=E5=8A=A8=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/video/encoder/mod.rs | 5 + src/video/encoder/self_check.rs | 335 ++++++++++++++++++++++++++++++++ src/web/handlers/mod.rs | 14 +- src/web/routes.rs | 4 + web/src/api/index.ts | 28 +++ web/src/i18n/en-US.ts | 9 + web/src/i18n/zh-CN.ts | 9 + web/src/views/SettingsView.vue | 146 +++++++++++++- 8 files changed, 545 insertions(+), 5 deletions(-) create mode 100644 src/video/encoder/self_check.rs diff --git a/src/video/encoder/mod.rs b/src/video/encoder/mod.rs index daece2a0..e33ef620 100644 --- a/src/video/encoder/mod.rs +++ b/src/video/encoder/mod.rs @@ -17,6 +17,7 @@ pub mod h264; pub mod h265; pub mod jpeg; pub mod registry; +pub mod self_check; pub mod traits; pub mod vp8; pub mod vp9; @@ -31,6 +32,10 @@ pub use codec::{CodecFrame, VideoCodec, VideoCodecConfig, VideoCodecFactory, Vid // Encoder registry 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 pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat}; diff --git a/src/video/encoder/self_check.rs b/src/video/encoder/self_check.rs new file mode 100644 index 00000000..be6eed32 --- /dev/null +++ b/src/video/encoder/self_check.rs @@ -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, +} + +#[derive(Serialize)] +pub struct VideoEncoderSelfCheckRow { + pub resolution_id: &'static str, + pub resolution_label: &'static str, + pub width: u32, + pub height: u32, + pub cells: Vec, +} + +#[derive(Serialize)] +pub struct VideoEncoderSelfCheckResponse { + pub current_hardware_encoder: String, + pub codecs: Vec, + pub rows: Vec, +} + +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 { + 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::>(); + + 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 { + 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 +} diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index ea3b94f4..f2f7e7bc 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -16,7 +16,10 @@ use crate::events::SystemEvent; use crate::state::AppState; use crate::update::{UpdateChannel, UpdateOverviewResponse, UpdateStatusResponse, UpgradeRequest}; 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 @@ -1848,6 +1851,15 @@ pub async fn stream_codecs_list() -> Json { }) } +/// Run hardware encoder smoke tests across common resolutions/codecs. +pub async fn video_encoder_self_check() -> Json { + 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 #[derive(Deserialize, Default)] pub struct MjpegStreamQuery { diff --git a/src/web/routes.rs b/src/web/routes.rs index 06489a2a..db4145b1 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -51,6 +51,10 @@ pub fn create_router(state: Arc) -> Router { .route("/stream/bitrate", post(handlers::stream_set_bitrate)) .route("/stream/codecs", get(handlers::stream_codecs_list)) .route("/stream/constraints", get(handlers::stream_constraints_get)) + .route( + "/video/encoder/self-check", + get(handlers::video_encoder_self_check), + ) // WebRTC endpoints .route("/webrtc/session", post(handlers::webrtc_create_session)) .route("/webrtc/offer", post(handlers::webrtc_offer)) diff --git a/web/src/api/index.ts b/web/src/api/index.ts index d3522ee5..427e66ae 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -177,6 +177,31 @@ export interface StreamConstraintsResponse { 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 = { status: () => request<{ @@ -217,6 +242,9 @@ export const streamApi = { getConstraints: () => request('/stream/constraints'), + encoderSelfCheck: () => + request('/video/encoder/self-check'), + setBitratePreset: (bitrate_preset: import('@/types/generated').BitratePreset) => request<{ success: boolean; message?: string }>('/stream/bitrate', { method: 'POST', diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index ead9995e..d39bf39a 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -757,6 +757,15 @@ export default { 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 webrtcSettings: 'WebRTC Settings', webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index b0108aba..cd026baa 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -757,6 +757,15 @@ export default { udc_speed: '设备可能未完成枚举,可尝试重插 USB', }, }, + encoderSelfCheck: { + title: '硬件编码能力测试', + desc: '按 720p、1080p、2K、4K 测试硬件编码能力', + run: '开始测试', + failed: '执行硬件编码能力测试失败', + resolution: '分辨率', + currentHardwareEncoder: '当前硬件编码器', + none: '无', + }, // WebRTC / ICE webrtcSettings: 'WebRTC 设置', webrtcSettingsDesc: '配置 STUN/TURN 服务器以实现 NAT 穿透', diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 34f663c2..6bbd16bd 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -24,6 +24,7 @@ import { type UpdateOverviewResponse, type UpdateStatusResponse, type UpdateChannel, + type VideoEncoderSelfCheckResponse, } from '@/api' import type { ExtensionsStatus, @@ -539,6 +540,64 @@ async function onRunOtgSelfCheckClick() { await runOtgSelfCheck() } +type VideoEncoderSelfCheckCell = VideoEncoderSelfCheckResponse['rows'][number]['cells'][number] +type VideoEncoderSelfCheckRow = VideoEncoderSelfCheckResponse['rows'][number] + +const videoEncoderSelfCheckLoading = ref(false) +const videoEncoderSelfCheckResult = ref(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() { if (hidProfileAligned.value) return if (!configLoaded.value || !devicesLoaded.value) return @@ -1781,16 +1840,15 @@ onMounted(async () => { if (updateRunning.value) { startUpdatePolling() } - - await runOtgSelfCheck() }) watch(updateChannel, async () => { await loadUpdateOverview() }) -watch(() => config.value.hid_backend, async () => { - await runOtgSelfCheck() +watch(() => config.value.hid_backend, () => { + otgSelfCheckResult.value = null + otgSelfCheckError.value = '' }) @@ -2364,6 +2422,86 @@ watch(() => config.value.hid_backend, async () => {

+ + + +
+ {{ t('settings.encoderSelfCheck.title') }} + {{ t('settings.encoderSelfCheck.desc') }} +
+ +
+ +

+ {{ videoEncoderSelfCheckError }} +

+ + +

+ {{ t('common.loading') }} +

+
+