refactor: 改用FFmpeg枚举DirectShow设备并推流到MediaMTX,移除Nokhwa依赖

This commit is contained in:
xiaji
2026-04-14 11:00:08 +08:00
parent d5e0791f3c
commit 78b50e8ecf
3 changed files with 543 additions and 471 deletions

View File

@@ -1,232 +1,582 @@
#![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 egui::{Color32, FontFamily, FontId, RichText};
use serde::{Deserialize, Serialize};
use std::fs;
use std::net::TcpStream;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::mpsc::{channel, Receiver, Sender};
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>>>,
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Config {
ffmpeg_path: String,
server_ip: String,
stream_path: String,
}
impl Default for Config {
fn default() -> Self {
Self {
ffmpeg_path: r"D:\ScreenCast\ffmpeg\bin\ffmpeg.exe".to_string(),
server_ip: "192.168.1.100".to_string(),
stream_path: "hdmi".to_string(),
}
}
}
impl Config {
fn load() -> Self {
if let Ok(content) = fs::read_to_string("capture_card_config.json") {
if let Ok(config) = serde_json::from_str(&content) {
return config;
}
}
Self::default()
}
fn save(&self) {
if let Ok(json) = serde_json::to_string_pretty(self) {
let _ = fs::write("capture_card_config.json", json);
}
}
}
enum Message {
DevicesFound(Vec<String>),
ScanFailed(String),
StatusUpdate { ffmpeg_ok: bool, server_ok: bool },
}
struct CaptureCardViewer {
state: CaptureState,
texture: Option<TextureHandle>,
config: Config,
temp_config: Config,
show_settings: bool,
video_devices: Vec<String>,
selected_device_idx: usize,
is_scanning: bool,
is_streaming: bool,
ffmpeg_ok: bool,
server_ok: bool,
log_message: String,
rx: Receiver<Message>,
tx: Sender<Message>,
}
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 config = Config::load();
let (tx, rx) = channel();
let ffmpeg_ok = Path::new(&config.ffmpeg_path).exists();
let server_ok = check_server(&config.server_ip, 8554);
let mut app = Self {
temp_config: config.clone(),
config,
show_settings: false,
video_devices: Vec::new(),
selected_device_idx: 0,
is_scanning: false,
is_streaming: false,
ffmpeg_ok,
server_ok,
log_message: "就绪,请点击「扫描设备」查找视频采集卡".to_string(),
rx,
tx,
};
let capture_state = CaptureState {
device_name: state.device_name.clone(),
frame_data: state.frame_data.clone(),
running: state.running.clone(),
error: state.error.clone(),
};
app.scan_devices();
app.start_status_thread();
app
}
fn scan_devices(&mut self) {
if self.is_scanning {
return;
}
self.is_scanning = true;
self.log_message = "正在扫描 DirectShow 视频设备...".to_string();
let ffmpeg_path = self.config.ffmpeg_path.clone();
let tx = self.tx.clone();
thread::spawn(move || {
capture_thread(capture_state);
let result = enumerate_dshow_devices(&ffmpeg_path);
match result {
Ok(devices) => {
if devices.is_empty() {
let _ = tx.send(Message::ScanFailed(
"未找到任何视频采集设备,请确认设备已连接".to_string(),
));
} else {
let _ = tx.send(Message::DevicesFound(devices));
}
}
Err(e) => {
let _ = tx.send(Message::ScanFailed(format!("扫描失败: {}", e)));
}
}
});
}
CaptureCardViewer {
state,
texture: None,
fn start_status_thread(&self) {
let tx = self.tx.clone();
let ffmpeg_path = self.config.ffmpeg_path.clone();
let server_ip = self.config.server_ip.clone();
thread::spawn(move || loop {
let ffmpeg_ok = Path::new(&ffmpeg_path).exists();
let server_ok = check_server(&server_ip, 8554);
let _ = tx.send(Message::StatusUpdate {
ffmpeg_ok,
server_ok,
});
thread::sleep(Duration::from_secs(3));
});
}
fn start_stream(&mut self) {
if self.is_streaming {
return;
}
if !self.ffmpeg_ok {
self.log_message = "错误: FFmpeg 未找到,请检查配置".to_string();
return;
}
if self.video_devices.is_empty() {
self.log_message = "错误: 没有可用的视频设备,请先扫描设备".to_string();
return;
}
let device_name = self.video_devices[self.selected_device_idx].clone();
let video_input = format!("video={}", device_name);
let output_url = format!(
"rtsp://{}:8554/{}",
self.config.server_ip, self.config.stream_path
);
let args = vec![
"-f",
"dshow",
"-i",
&video_input,
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-tune",
"zerolatency",
"-f",
"rtsp",
"-rtsp_transport",
"tcp",
&output_url,
];
match Command::new(&self.config.ffmpeg_path)
.args(&args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(_) => {
self.is_streaming = true;
self.log_message = format!(
"推流已启动\n设备: {}\n地址: {}",
device_name, output_url
);
}
Err(e) => {
self.log_message = format!("启动 FFmpeg 失败: {}", e);
}
}
}
fn stop_stream(&mut self) {
let _ = Command::new("taskkill")
.args(&["/F", "/IM", "ffmpeg.exe"])
.output();
self.is_streaming = false;
self.log_message = "推流已停止".to_string();
}
}
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;
fn enumerate_dshow_devices(ffmpeg_path: &str) -> Result<Vec<String>, String> {
if !Path::new(ffmpeg_path).exists() {
return Err(format!("FFmpeg 不存在: {}", ffmpeg_path));
}
let device_info = &devices[0];
*state.device_name.lock().unwrap() = device_info.human_name().to_string();
let output = Command::new(ffmpeg_path)
.args(&["-list_devices", "true", "-f", "dshow", "-i", "dummy"])
.stderr(Stdio::piped())
.stdout(Stdio::null())
.output()
.map_err(|e| format!("执行 FFmpeg 失败: {}", e))?;
let index = device_info.index().clone();
let requested = RequestedFormat::new::<nokhwa::pixel_format::RgbFormat>(
RequestedFormatType::AbsoluteHighestFrameRate,
);
let stderr = String::from_utf8_lossy(&output.stderr);
let mut devices = Vec::new();
let mut is_video = false;
let mut camera = match Camera::new(index, requested) {
Ok(c) => c,
Err(e) => {
*state.error.lock().unwrap() = Some(format!("打开摄像头失败: {}", e));
return;
for line in stderr.lines() {
let trimmed = line.trim();
if trimmed.contains("(video)") {
is_video = true;
} else if trimmed.contains("(audio)") {
is_video = false;
}
};
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));
}
if is_video {
if let Some(name) = extract_device_name(trimmed) {
if !devices.contains(&name) {
devices.push(name);
}
is_video = false;
}
Err(e) => {
*state.error.lock().unwrap() = Some(format!("捕获帧失败: {}", e));
thread::sleep(Duration::from_millis(100));
}
}
Ok(devices)
}
fn extract_device_name(line: &str) -> Option<String> {
if let Some(start) = line.find('"') {
if let Some(end) = line[start + 1..].find('"') {
let name = line[start + 1..start + 1 + end].to_string();
if !name.is_empty() {
return Some(name);
}
}
}
None
}
fn check_server(ip: &str, port: u16) -> bool {
let addr = format!("{}:{}", ip, port);
addr.parse::<std::net::SocketAddr>().is_ok()
&& TcpStream::connect_timeout(&addr.parse().unwrap(), Duration::from_secs(2)).is_ok()
}
fn configure_fonts(ctx: &egui::Context) {
let mut fonts = egui::FontDefinitions::default();
let font_paths = [
r"C:\Windows\Fonts\msyh.ttc",
r"C:\Windows\Fonts\simhei.ttf",
r"C:\Windows\Fonts\simsun.ttc",
];
for font_path in &font_paths {
if let Ok(font_data) = std::fs::read(font_path) {
let font_name = Path::new(font_path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("chinese_font");
fonts.font_data.insert(
font_name.to_owned(),
egui::FontData::from_owned(font_data),
);
fonts
.families
.get_mut(&FontFamily::Proportional)
.unwrap()
.insert(0, font_name.to_owned());
fonts
.families
.get_mut(&FontFamily::Monospace)
.unwrap()
.push(font_name.to_owned());
ctx.set_fonts(fonts);
break;
}
}
}
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;
}
}
configure_fonts(ctx);
});
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
while let Ok(msg) = self.rx.try_recv() {
match msg {
Message::DevicesFound(devices) => {
self.video_devices = devices;
self.selected_device_idx = 0;
self.is_scanning = false;
if self.video_devices.is_empty() {
self.log_message = "未找到视频采集设备".to_string();
} else {
self.log_message = format!(
"找到 {} 个视频设备:\n{}",
self.video_devices.len(),
self.video_devices
.iter()
.enumerate()
.map(|(i, d)| format!(" {}. {}", i + 1, d))
.collect::<Vec<_>>()
.join("\n")
);
}
}
Message::ScanFailed(e) => {
self.is_scanning = false;
self.video_devices.clear();
self.log_message = e;
}
Message::StatusUpdate {
ffmpeg_ok,
server_ok,
} => {
self.ffmpeg_ok = ffmpeg_ok;
self.server_ok = server_ok;
}
})
};
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.add_space(8.0);
ui.label(
RichText::new(if device_name.is_empty() {
"正在查找设备..."
} else {
&device_name
})
.font(FontId::new(18.0, FontFamily::Proportional))
.strong(),
RichText::new("视频采集卡推流工具")
.font(FontId::new(20.0, FontFamily::Proportional))
.strong(),
);
});
ui.add_space(8.0);
ui.add_space(5.0);
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.label(
RichText::new("状态检查")
.font(FontId::new(14.0, FontFamily::Proportional))
.strong(),
);
ui.add_space(4.0);
let error = self.state.error.lock().unwrap();
if let Some(e) = error.as_ref() {
ui.horizontal(|ui| {
let (color, text) = if self.ffmpeg_ok {
(Color32::GREEN, "FFmpeg: 已就绪")
} else {
(Color32::RED, "FFmpeg: 未找到")
};
ui.colored_label(color, "");
ui.label(text);
});
ui.horizontal(|ui| {
let (color, text) = if self.server_ok {
(Color32::GREEN, "MediaMTX 服务器: 已连接")
} else {
(Color32::from_rgb(255, 165, 0), "MediaMTX 服务器: 未连接")
};
ui.colored_label(color, "");
ui.label(text);
});
});
ui.add_space(6.0);
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.label(
RichText::new("设备选择")
.font(FontId::new(14.0, FontFamily::Proportional))
.strong(),
);
ui.add_space(4.0);
ui.horizontal(|ui| {
let scan_text = if self.is_scanning {
"扫描中..."
} else {
"扫描设备"
};
if ui
.add_sized(
[100.0, 28.0],
egui::Button::new(scan_text),
)
.clicked()
&& !self.is_scanning
{
self.scan_devices();
}
if self.video_devices.is_empty() {
ui.label(
RichText::new("未检测到设备").color(Color32::from_rgb(180, 180, 180)),
);
} else {
let selected_text = if self.selected_device_idx < self.video_devices.len() {
&self.video_devices[self.selected_device_idx]
} else {
&String::new()
};
egui::ComboBox::from_id_source("device_select")
.selected_text(selected_text)
.show_ui(ui, |ui| {
for (i, device) in self.video_devices.iter().enumerate() {
ui.selectable_value(&mut self.selected_device_idx, i, device);
}
});
}
});
});
ui.add_space(6.0);
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.label(
RichText::new("推流信息")
.font(FontId::new(14.0, FontFamily::Proportional))
.strong(),
);
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("推流地址:");
ui.label(
RichText::new(e)
.font(FontId::new(12.0, FontFamily::Proportional))
.color(Color32::RED),
RichText::new(format!(
"rtsp://{}:8554/{}",
self.config.server_ip, self.config.stream_path
))
.color(Color32::from_rgb(100, 149, 237)),
);
});
ui.horizontal(|ui| {
ui.label("观看地址:");
ui.label(
RichText::new(format!(
"http://{}:8889/webrtc.html?src={}",
self.config.server_ip, self.config.stream_path
))
.color(Color32::from_rgb(100, 149, 237)),
);
});
if !self.video_devices.is_empty() && self.selected_device_idx < self.video_devices.len() {
ui.horizontal(|ui| {
ui.label("当前设备:");
ui.label(
RichText::new(&self.video_devices[self.selected_device_idx])
.color(Color32::from_rgb(144, 238, 144)),
);
});
}
drop(error);
});
ui.add_space(5.0);
ui.add_space(10.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));
ui.vertical_centered(|ui| {
let (btn_text, btn_color) = if self.is_streaming {
("停止推流", Color32::from_rgb(183, 28, 28))
} else {
ui.add_space(100.0);
ui.label(
RichText::new("等待视频画面...")
.font(FontId::new(14.0, FontFamily::Proportional))
.color(Color32::from_rgb(128, 128, 128)),
);
("开始推流", Color32::from_rgb(46, 125, 50))
};
if ui
.add_sized(
[ui.available_width(), 44.0],
egui::Button::new(
RichText::new(btn_text)
.font(FontId::new(16.0, FontFamily::Proportional))
.color(Color32::WHITE),
)
.fill(btn_color)
.rounding(8.0),
)
.clicked()
{
if self.is_streaming {
self.stop_stream();
} else {
self.start_stream();
}
}
});
ui.add_space(8.0);
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.label(
RichText::new("日志")
.font(FontId::new(12.0, FontFamily::Proportional))
.strong(),
);
ui.add_space(2.0);
ui.label(
RichText::new(&self.log_message)
.font(FontId::new(11.0, FontFamily::Proportional))
.color(Color32::from_rgb(200, 200, 200)),
);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("⚙ 设置").clicked() {
self.temp_config = self.config.clone();
self.show_settings = true;
}
});
});
ctx.request_repaint_after(Duration::from_millis(33));
if self.show_settings {
egui::Window::new("设置")
.collapsible(false)
.resizable(false)
.fixed_size([480.0, 220.0])
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label("FFmpeg 路径:");
ui.text_edit_singleline(&mut self.temp_config.ffmpeg_path);
});
ui.horizontal(|ui| {
ui.label("服务器 IP:");
ui.text_edit_singleline(&mut self.temp_config.server_ip);
});
ui.horizontal(|ui| {
ui.label("推流路径:");
ui.text_edit_singleline(&mut self.temp_config.stream_path);
});
ui.add_space(12.0);
ui.horizontal(|ui| {
if ui.button("确定").clicked() {
self.config = self.temp_config.clone();
self.config.save();
self.show_settings = false;
self.start_status_thread();
self.log_message = "配置已更新".to_string();
}
if ui.button("取消").clicked() {
self.show_settings = false;
}
});
});
}
ctx.request_repaint_after(Duration::from_millis(200));
}
}
fn main() -> Result<(), eframe::Error> {
let options = NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([960.0, 600.0])
.with_inner_size([560.0, 480.0])
.with_resizable(true),
..Default::default()
};
eframe::run_native(
"视频采集卡查看器",
"视频采集卡推流",
options,
Box::new(|_cc| Box::new(CaptureCardViewer::new())),
)