feat: 添加视频采集卡查看器 (Nokhwa + egui, 独立线程捕获)
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -31,3 +31,8 @@ build/
|
|||||||
dist/
|
dist/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
*.spec
|
*.spec
|
||||||
|
|
||||||
|
# Rust 构建产物
|
||||||
|
target/
|
||||||
|
*.exe
|
||||||
|
*.res
|
||||||
2636
capture-card-viewer/Cargo.lock
generated
Normal file
2636
capture-card-viewer/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
capture-card-viewer/Cargo.toml
Normal file
19
capture-card-viewer/Cargo.toml
Normal 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"
|
||||||
233
capture-card-viewer/src/main.rs
Normal file
233
capture-card-viewer/src/main.rs
Normal 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())),
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user