diff --git a/capture-card-viewer/Cargo.lock b/capture-card-viewer/Cargo.lock index 775eca0..68580d1 100644 --- a/capture-card-viewer/Cargo.lock +++ b/capture-card-viewer/Cargo.lock @@ -77,12 +77,6 @@ dependencies = [ "x11rb", ] -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - [[package]] name = "autocfg" version = "1.5.0" @@ -158,12 +152,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - [[package]] name = "bytes" version = "1.11.1" @@ -180,7 +168,7 @@ dependencies = [ "log", "nix 0.25.1", "slotmap", - "thiserror 1.0.69", + "thiserror", "vec_map", ] @@ -190,7 +178,8 @@ version = "0.1.0" dependencies = [ "eframe", "egui", - "nokhwa", + "serde", + "serde_json", "winapi", ] @@ -379,12 +368,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "ecolor" version = "0.24.1" @@ -408,7 +391,7 @@ dependencies = [ "glow", "glutin", "glutin-winit", - "image 0.24.9", + "image", "js-sys", "log", "objc", @@ -416,7 +399,7 @@ dependencies = [ "percent-encoding", "raw-window-handle", "static_assertions", - "thiserror 1.0.69", + "thiserror", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -539,18 +522,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "nanorand", - "spin", -] - [[package]] name = "foreign-types" version = "0.3.2" @@ -581,12 +552,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - [[package]] name = "futures-task" version = "0.3.32" @@ -615,19 +580,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - [[package]] name = "getrandom" version = "0.3.4" @@ -858,18 +810,6 @@ dependencies = [ "png", ] -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "moxcms", - "num-traits", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -892,6 +832,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.21.1" @@ -903,7 +849,7 @@ dependencies = [ "combine", "jni-sys 0.3.1", "log", - "thiserror 1.0.69", + "thiserror", "walkdir", "windows-sys 0.45.0", ] @@ -942,7 +888,7 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.3.4", + "getrandom", "libc", ] @@ -1099,60 +1045,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - -[[package]] -name = "mozjpeg" -version = "0.10.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7891b80aaa86097d38d276eb98b3805d6280708c4e0a1e6f6aed9380c51fec9" -dependencies = [ - "arrayvec", - "bytemuck", - "libc", - "mozjpeg-sys", - "rgb", -] - -[[package]] -name = "mozjpeg-sys" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f0dc668bf9bf888c88e2fb1ab16a406d2c380f1d082b20d51dd540ab2aa70c1" -dependencies = [ - "cc", - "dunce", - "libc", - "nasm-rs", -] - -[[package]] -name = "nanorand" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "nasm-rs" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149" -dependencies = [ - "jobserver", - "log", -] - [[package]] name = "ndk" version = "0.7.0" @@ -1164,7 +1056,7 @@ dependencies = [ "ndk-sys", "num_enum 0.5.11", "raw-window-handle", - "thiserror 1.0.69", + "thiserror", ] [[package]] @@ -1213,43 +1105,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" -[[package]] -name = "nokhwa" -version = "0.10.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4cae50786bfa1214ed441f98addbea51ca1b9aaa9e4bf5369cda36654b3efaa" -dependencies = [ - "flume", - "image 0.25.10", - "nokhwa-bindings-windows", - "nokhwa-core", - "paste", - "thiserror 2.0.18", -] - -[[package]] -name = "nokhwa-bindings-windows" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899799275c93ef69bbe8cb888cf6f8249abe751cbc50be5299105022aec14a1c" -dependencies = [ - "nokhwa-core", - "once_cell", - "windows", -] - -[[package]] -name = "nokhwa-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "109975552bbd690894f613bce3d408222911e317197c72b2e8b9a1912dc261ae" -dependencies = [ - "bytes", - "image 0.25.10", - "mozjpeg", - "thiserror 2.0.18", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1457,12 +1312,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1528,12 +1377,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "pxfm" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" - [[package]] name = "quote" version = "1.0.45" @@ -1582,15 +1425,6 @@ dependencies = [ "bitflags 2.11.0", ] -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" -dependencies = [ - "bytemuck", -] - [[package]] name = "rustix" version = "1.1.4" @@ -1638,6 +1472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -1660,6 +1495,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1722,15 +1570,6 @@ dependencies = [ "wayland-client", ] -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1782,16 +1621,7 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl", ] [[package]] @@ -1805,17 +1635,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -2118,107 +1937,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.45.0" @@ -2302,15 +2026,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -2634,3 +2349,9 @@ dependencies = [ "quote", "syn 2.0.117", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/capture-card-viewer/Cargo.toml b/capture-card-viewer/Cargo.toml index d9b63cf..e222945 100644 --- a/capture-card-viewer/Cargo.toml +++ b/capture-card-viewer/Cargo.toml @@ -4,9 +4,10 @@ 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" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" winapi = { version = "0.3", features = ["winuser", "windef", "processthreadsapi", "handleapi", "winbase"] } [profile.release] diff --git a/capture-card-viewer/src/main.rs b/capture-card-viewer/src/main.rs index 0ce04b2..1a899cd 100644 --- a/capture-card-viewer/src/main.rs +++ b/capture-card-viewer/src/main.rs @@ -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>, - frame_data: Arc)>>>, - running: Arc>, - error: Arc>>, +#[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), + ScanFailed(String), + StatusUpdate { ffmpeg_ok: bool, server_ok: bool }, } struct CaptureCardViewer { - state: CaptureState, - texture: Option, + config: Config, + temp_config: Config, + show_settings: bool, + + video_devices: Vec, + selected_device_idx: usize, + is_scanning: bool, + + is_streaming: bool, + ffmpeg_ok: bool, + server_ok: bool, + + log_message: String, + + rx: Receiver, + tx: Sender, } 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, 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::( - 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::() { - 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 { + 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::().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::>() + .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())), )