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('settings.encoderSelfCheck.currentHardwareEncoder') }}:{{ currentHardwareEncoderText }}
+
+
+
+
+
+
+ | {{ t('settings.encoderSelfCheck.resolution') }} |
+
+ {{ videoEncoderCodecLabel(codec.id, codec.name) }}
+ |
+
+
+
+
+ |
+ {{ row.resolution_label }}
+ |
+
+
+
+ {{ videoEncoderCellSymbol(videoEncoderCell(row, codec.id)?.ok) }}
+
+
+ {{ videoEncoderCellTime(videoEncoderCell(row, codec.id)) }}
+
+
+ |
+
+
+
+
+
+
+ {{ t('common.loading') }}
+
+
+