feat: 添加视频采集卡查看器 (Nokhwa + egui, 独立线程捕获)

This commit is contained in:
xiaji
2026-04-13 12:41:41 +08:00
parent d0caea5b0c
commit d5e0791f3c
4 changed files with 2894 additions and 1 deletions

2636
capture-card-viewer/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
[package]
name = "capture-card-viewer"
version = "0.1.0"
edition = "2021"
[dependencies]
nokhwa = { version = "0.10", features = ["input-msmf"] }
eframe = { version = "0.24", default-features = false, features = ["default_fonts", "glow"] }
egui = "0.24"
winapi = { version = "0.3", features = ["winuser", "windef", "processthreadsapi", "handleapi", "winbase"] }
[profile.release]
opt-level = 3
lto = true
strip = true
[[bin]]
name = "capture-card-viewer"
path = "src/main.rs"

View File

@@ -0,0 +1,233 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use eframe::{egui, App, Frame, NativeOptions};
use egui::{Color32, FontFamily, FontId, RichText, TextureHandle, TextureOptions};
use nokhwa::utils::{ApiBackend, RequestedFormat, RequestedFormatType};
use nokhwa::Camera;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
struct CaptureState {
device_name: Arc<Mutex<String>>,
frame_data: Arc<Mutex<Option<(usize, usize, Vec<u8>)>>>,
running: Arc<Mutex<bool>>,
error: Arc<Mutex<Option<String>>>,
}
struct CaptureCardViewer {
state: CaptureState,
texture: Option<TextureHandle>,
}
impl CaptureCardViewer {
fn new() -> Self {
let state = CaptureState {
device_name: Arc::new(Mutex::new(String::new())),
frame_data: Arc::new(Mutex::new(None)),
running: Arc::new(Mutex::new(true)),
error: Arc::new(Mutex::new(None)),
};
let capture_state = CaptureState {
device_name: state.device_name.clone(),
frame_data: state.frame_data.clone(),
running: state.running.clone(),
error: state.error.clone(),
};
thread::spawn(move || {
capture_thread(capture_state);
});
CaptureCardViewer {
state,
texture: None,
}
}
}
fn capture_thread(state: CaptureState) {
let devices = match nokhwa::query(ApiBackend::MediaFoundation) {
Ok(d) => d,
Err(e) => {
*state.error.lock().unwrap() = Some(format!("查询设备失败: {}", e));
return;
}
};
if devices.is_empty() {
*state.error.lock().unwrap() = Some("未找到视频采集设备".to_string());
return;
}
let device_info = &devices[0];
*state.device_name.lock().unwrap() = device_info.human_name().to_string();
let index = device_info.index().clone();
let requested = RequestedFormat::new::<nokhwa::pixel_format::RgbFormat>(
RequestedFormatType::AbsoluteHighestFrameRate,
);
let mut camera = match Camera::new(index, requested) {
Ok(c) => c,
Err(e) => {
*state.error.lock().unwrap() = Some(format!("打开摄像头失败: {}", e));
return;
}
};
if let Err(e) = camera.open_stream() {
*state.error.lock().unwrap() = Some(format!("启动视频流失败: {}", e));
return;
}
while *state.running.lock().unwrap() {
match camera.frame() {
Ok(buffer) => {
match buffer.decode_image::<nokhwa::pixel_format::RgbFormat>() {
Ok(rgb_image) => {
let w = rgb_image.width() as usize;
let h = rgb_image.height() as usize;
let raw = rgb_image.into_raw();
let mut rgba = Vec::with_capacity(w * h * 4);
for chunk in raw.chunks(3) {
rgba.push(chunk[0]);
rgba.push(chunk[1]);
rgba.push(chunk[2]);
rgba.push(255);
}
*state.frame_data.lock().unwrap() = Some((w, h, rgba));
*state.error.lock().unwrap() = None;
}
Err(e) => {
*state.error.lock().unwrap() = Some(format!("解码帧失败: {}", e));
}
}
}
Err(e) => {
*state.error.lock().unwrap() = Some(format!("捕获帧失败: {}", e));
thread::sleep(Duration::from_millis(100));
}
}
}
}
impl App for CaptureCardViewer {
fn update(&mut self, ctx: &egui::Context, _frame: &mut Frame) {
static FONT_LOADED: std::sync::Once = std::sync::Once::new();
FONT_LOADED.call_once(|| {
let mut fonts = egui::FontDefinitions::default();
let font_paths = [
"C:/Windows/Fonts/msyh.ttc",
"C:/Windows/Fonts/simhei.ttf",
"C:/Windows/Fonts/simsun.ttc",
];
for font_path in &font_paths {
if let Ok(font_data) = std::fs::read(font_path) {
fonts.font_data.insert(
"chinese_font".to_owned(),
egui::FontData::from_owned(font_data),
);
fonts
.families
.get_mut(&egui::FontFamily::Proportional)
.unwrap()
.insert(0, "chinese_font".to_owned());
fonts
.families
.get_mut(&egui::FontFamily::Monospace)
.unwrap()
.push("chinese_font".to_owned());
ctx.set_fonts(fonts);
break;
}
}
});
let frame_update = {
let frame_guard = self.state.frame_data.lock().unwrap();
frame_guard.as_ref().map(|(w, h, rgba)| {
if *w > 0 && *h > 0 {
let color_image = egui::ColorImage::from_rgba_unmultiplied([*w, *h], rgba);
Some(color_image)
} else {
None
}
})
};
if let Some(Some(color_image)) = frame_update {
if let Some(tex) = self.texture.as_mut() {
tex.set(color_image, TextureOptions::LINEAR);
} else {
self.texture = Some(
ctx.load_texture("capture_frame", color_image, TextureOptions::LINEAR),
);
}
}
egui::CentralPanel::default().show(ctx, |ui| {
ui.vertical_centered(|ui| {
ui.add_space(10.0);
let device_name = self.state.device_name.lock().unwrap().clone();
ui.label(
RichText::new(if device_name.is_empty() {
"正在查找设备..."
} else {
&device_name
})
.font(FontId::new(18.0, FontFamily::Proportional))
.strong(),
);
ui.add_space(5.0);
let error = self.state.error.lock().unwrap();
if let Some(e) = error.as_ref() {
ui.label(
RichText::new(e)
.font(FontId::new(12.0, FontFamily::Proportional))
.color(Color32::RED),
);
}
drop(error);
ui.add_space(5.0);
if let Some(tex) = &self.texture {
let avail_width = ui.available_width();
let size = tex.size_vec2();
let scale = (avail_width / size.x).min(1.0);
let display_size = size * scale;
ui.add(egui::Image::new(tex).max_size(display_size));
} else {
ui.add_space(100.0);
ui.label(
RichText::new("等待视频画面...")
.font(FontId::new(14.0, FontFamily::Proportional))
.color(Color32::from_rgb(128, 128, 128)),
);
}
});
});
ctx.request_repaint_after(Duration::from_millis(33));
}
}
fn main() -> Result<(), eframe::Error> {
let options = NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([960.0, 600.0])
.with_resizable(true),
..Default::default()
};
eframe::run_native(
"视频采集卡查看器",
options,
Box::new(|_cc| Box::new(CaptureCardViewer::new())),
)
}