use anyhow::{Result, anyhow}; use serde::{Deserialize, Serialize}; use std::path::Path; use std::process::Command; use std::time::Instant; use std::pin::Pin; use std::boxed::Box; use std::future::Future; use tracing::{info, warn, error, debug}; use ffmpeg_sidecar::{ command::FfmpegCommand, download::auto_download, }; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; /// 帧提取类型 #[derive(Debug, Clone, Serialize, Deserialize)] pub enum FrameType { First, Last, Custom, Multiple, } /// 时间点配置 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum TimePoint { #[serde(rename = "seconds")] Seconds { value: f64 }, #[serde(rename = "percentage")] Percentage { value: f64 }, #[serde(rename = "frame")] Frame { value: u32 }, } /// 图片格式 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ImageFormat { Jpg, Png, WebP, Bmp, } /// 图片质量设置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageQuality { pub format: ImageFormat, pub quality: u8, pub compression_level: Option, } /// 输出尺寸配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OutputSize { pub width: Option, pub height: Option, pub maintain_aspect_ratio: bool, pub scale_filter: Option, } /// 帧提取配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FrameExtractionConfig { pub frame_type: FrameType, pub time_points: Option>, pub custom_time: Option, pub output_format: ImageQuality, pub output_size: Option, pub output_directory: String, pub filename_pattern: String, pub overwrite_existing: bool, } /// 视频文件信息 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VideoFileInfo { pub path: String, pub filename: String, pub size: u64, pub duration: f64, pub width: u32, pub height: u32, pub fps: f64, pub format: String, pub is_valid: bool, pub error_message: Option, } /// 单个帧提取结果 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FrameExtractionResult { pub video_path: String, pub frame_type: FrameType, pub timestamp: f64, pub output_path: String, pub success: bool, pub error_message: Option, pub processing_time_ms: u64, pub file_size: u64, } /// 关键帧提取服务 /// 遵循 Tauri 开发规范的基础设施层设计 pub struct FrameExtractorService; impl FrameExtractorService { /// 创建隐藏控制台窗口的命令 /// 在 Windows 上防止命令行闪现 fn create_hidden_command(program: &str) -> Command { let mut cmd = Command::new(program); #[cfg(target_os = "windows")] { // 在 Windows 上隐藏控制台窗口 // CREATE_NO_WINDOW = 0x08000000 cmd.creation_flags(0x08000000); } cmd } /// 初始化 FFmpeg pub async fn initialize() -> Result<()> { info!("初始化 FrameExtractorService"); // 自动下载 FFmpeg 二进制文件 match auto_download() { Ok(_) => { info!("FFmpeg 二进制文件准备就绪"); Ok(()) } Err(e) => { error!("FFmpeg 初始化失败: {}", e); Err(anyhow!("FFmpeg 初始化失败: {}", e)) } } } /// 检查 FFmpeg 是否可用 pub fn is_available() -> bool { match FfmpegCommand::new().arg("-version").spawn() { Ok(mut child) => { match child.wait() { Ok(status) => status.success(), Err(_) => false, } } Err(_) => false, } } /// 获取视频文件信息 pub async fn get_video_info(video_path: &str) -> Result { let path = Path::new(video_path); if !path.exists() { return Ok(VideoFileInfo { path: video_path.to_string(), filename: path.file_name() .unwrap_or_default() .to_string_lossy() .to_string(), size: 0, duration: 0.0, width: 0, height: 0, fps: 0.0, format: String::new(), is_valid: false, error_message: Some("文件不存在".to_string()), }); } let metadata = std::fs::metadata(path)?; let filename = path.file_name() .unwrap_or_default() .to_string_lossy() .to_string(); // 使用 ffprobe 获取视频信息 let mut cmd = Self::create_hidden_command("ffprobe"); cmd.arg("-v") .arg("quiet") .arg("-print_format") .arg("json") .arg("-show_format") .arg("-show_streams") .arg(video_path); match cmd.output() { Ok(output) => { if output.status.success() { Self::parse_video_info(&output.stdout, video_path, &filename, metadata.len()) } else { Ok(VideoFileInfo { path: video_path.to_string(), filename, size: metadata.len(), duration: 0.0, width: 0, height: 0, fps: 0.0, format: String::new(), is_valid: false, error_message: Some("无法解析视频信息".to_string()), }) } } Err(e) => { error!("启动 ffprobe 失败: {}", e); Ok(VideoFileInfo { path: video_path.to_string(), filename, size: metadata.len(), duration: 0.0, width: 0, height: 0, fps: 0.0, format: String::new(), is_valid: false, error_message: Some(format!("启动 ffprobe 失败: {}", e)), }) } } } /// 解析视频信息 fn parse_video_info( json_data: &[u8], video_path: &str, filename: &str, file_size: u64, ) -> Result { let json_str = String::from_utf8_lossy(json_data); let json: serde_json::Value = serde_json::from_str(&json_str)?; let format = json["format"].as_object() .ok_or_else(|| anyhow!("无法解析格式信息"))?; let duration = format["duration"] .as_str() .and_then(|s| s.parse::().ok()) .unwrap_or(0.0); let format_name = format["format_name"] .as_str() .unwrap_or("unknown") .to_string(); // 查找视频流 let streams = json["streams"].as_array() .ok_or_else(|| anyhow!("无法解析流信息"))?; let video_stream = streams.iter() .find(|stream| stream["codec_type"].as_str() == Some("video")) .ok_or_else(|| anyhow!("未找到视频流"))?; let width = video_stream["width"].as_u64().unwrap_or(0) as u32; let height = video_stream["height"].as_u64().unwrap_or(0) as u32; // 解析帧率 let fps = if let Some(r_frame_rate) = video_stream["r_frame_rate"].as_str() { Self::parse_fraction(r_frame_rate).unwrap_or(0.0) } else { 0.0 }; Ok(VideoFileInfo { path: video_path.to_string(), filename: filename.to_string(), size: file_size, duration, width, height, fps, format: format_name, is_valid: duration > 0.0 && width > 0 && height > 0, error_message: None, }) } /// 解析分数格式的帧率 (如 "30/1") fn parse_fraction(fraction_str: &str) -> Option { let parts: Vec<&str> = fraction_str.split('/').collect(); if parts.len() == 2 { if let (Ok(numerator), Ok(denominator)) = ( parts[0].parse::(), parts[1].parse::() ) { if denominator != 0.0 { return Some(numerator / denominator); } } } None } /// 扫描文件夹中的视频文件 pub async fn scan_video_files( folder_path: &str, recursive: bool, supported_formats: &[String], ) -> Result> { let path = Path::new(folder_path); if !path.exists() || !path.is_dir() { return Err(anyhow!("指定路径不存在或不是文件夹")); } let mut video_files = Vec::new(); Self::scan_directory_recursive(path, recursive, supported_formats, &mut video_files).await?; info!("扫描完成,找到 {} 个视频文件", video_files.len()); Ok(video_files) } /// 递归扫描目录 fn scan_directory_recursive<'a>( dir: &'a Path, recursive: bool, supported_formats: &'a [String], video_files: &'a mut Vec, ) -> Pin> + Send + 'a>> { Box::pin(async move { let entries = std::fs::read_dir(dir)?; for entry in entries { let entry = entry?; let path = entry.path(); if path.is_file() { if let Some(extension) = path.extension() { let ext = extension.to_string_lossy().to_lowercase(); if supported_formats.contains(&ext) { match Self::get_video_info(&path.to_string_lossy()).await { Ok(info) => video_files.push(info), Err(e) => { warn!("无法获取视频信息 {}: {}", path.display(), e); } } } } } else if path.is_dir() && recursive { Self::scan_directory_recursive(&path, recursive, supported_formats, video_files).await?; } } Ok(()) }) } /// 提取单个帧 pub async fn extract_frame( video_path: &str, timestamp: f64, output_path: &str, config: &FrameExtractionConfig, ) -> Result { let start_time = Instant::now(); debug!( "开始提取帧: video={}, timestamp={}, output={}", video_path, timestamp, output_path ); // 确保输出目录存在 if let Some(parent) = Path::new(output_path).parent() { std::fs::create_dir_all(parent)?; } // 构建 FFmpeg 命令 let mut cmd = FfmpegCommand::new(); // 输入文件 cmd.arg("-i").arg(video_path); // 寻找到指定时间戳 cmd.arg("-ss").arg(timestamp.to_string()); // 只提取一帧 cmd.arg("-vframes").arg("1"); // 构建视频过滤器 let mut filters = Vec::new(); // 尺寸调整 if let Some(size) = &config.output_size { let scale_filter = if let (Some(width), Some(height)) = (size.width, size.height) { if size.maintain_aspect_ratio { format!("scale={}:{}:force_original_aspect_ratio=decrease", width, height) } else { format!("scale={}:{}", width, height) } } else if let Some(width) = size.width { format!("scale={}:-1", width) } else if let Some(height) = size.height { format!("scale=-1:{}", height) } else { String::new() }; if !scale_filter.is_empty() { filters.push(scale_filter); } } // 应用过滤器 if !filters.is_empty() { cmd.arg("-vf").arg(filters.join(",")); } // 设置像素格式 match config.output_format.format { ImageFormat::Jpg => { cmd.arg("-pix_fmt").arg("yuvj420p"); cmd.arg("-q:v").arg(Self::jpeg_quality_to_qscale(config.output_format.quality).to_string()); } ImageFormat::Png => { cmd.arg("-pix_fmt").arg("rgba"); cmd.arg("-compression_level").arg(config.output_format.quality.to_string()); } ImageFormat::WebP => { cmd.arg("-pix_fmt").arg("yuv420p"); cmd.arg("-quality").arg(config.output_format.quality.to_string()); if let Some(level) = config.output_format.compression_level { cmd.arg("-compression_level").arg(level.to_string()); } } ImageFormat::Bmp => { cmd.arg("-pix_fmt").arg("bgr24"); } } // 覆盖输出文件 if config.overwrite_existing { cmd.arg("-y"); } else { cmd.arg("-n"); } // 输出文件 cmd.arg(output_path); // 执行命令 match cmd.spawn() { Ok(mut child) => { match child.wait() { Ok(status) => { let processing_time = start_time.elapsed().as_millis() as u64; if status.success() { let file_size = std::fs::metadata(output_path) .map(|m| m.len()) .unwrap_or(0); info!( "帧提取成功: {} -> {} ({}ms)", video_path, output_path, processing_time ); Ok(FrameExtractionResult { video_path: video_path.to_string(), frame_type: config.frame_type.clone(), timestamp, output_path: output_path.to_string(), success: true, error_message: None, processing_time_ms: processing_time, file_size, }) } else { let error_msg = "FFmpeg 执行失败".to_string(); error!("{}: {}", error_msg, video_path); Ok(FrameExtractionResult { video_path: video_path.to_string(), frame_type: config.frame_type.clone(), timestamp, output_path: output_path.to_string(), success: false, error_message: Some(error_msg), processing_time_ms: processing_time, file_size: 0, }) } } Err(e) => { let error_msg = format!("等待 FFmpeg 进程失败: {}", e); error!("{}", error_msg); Ok(FrameExtractionResult { video_path: video_path.to_string(), frame_type: config.frame_type.clone(), timestamp, output_path: output_path.to_string(), success: false, error_message: Some(error_msg), processing_time_ms: start_time.elapsed().as_millis() as u64, file_size: 0, }) } } } Err(e) => { let error_msg = format!("启动 FFmpeg 进程失败: {}", e); error!("{}", error_msg); Ok(FrameExtractionResult { video_path: video_path.to_string(), frame_type: config.frame_type.clone(), timestamp, output_path: output_path.to_string(), success: false, error_message: Some(error_msg), processing_time_ms: start_time.elapsed().as_millis() as u64, file_size: 0, }) } } } /// 将 JPEG 质量值转换为 FFmpeg 的 qscale 值 fn jpeg_quality_to_qscale(quality: u8) -> u8 { // FFmpeg qscale: 1 (最高质量) 到 31 (最低质量) // 质量值: 1-100 let clamped_quality = quality.clamp(1, 100) as u32; (31 - ((clamped_quality - 1) * 30 / 99)) as u8 } /// 计算提取时间点 pub fn calculate_extraction_timestamps( video_info: &VideoFileInfo, config: &FrameExtractionConfig, ) -> Result> { let mut timestamps = Vec::new(); match &config.frame_type { FrameType::First => { timestamps.push(0.0); } FrameType::Last => { // 最后一帧,稍微提前一点以确保能提取到 let last_timestamp = (video_info.duration - 0.1).max(0.0); timestamps.push(last_timestamp); } FrameType::Custom => { if let Some(custom_time) = config.custom_time { let timestamp = custom_time.clamp(0.0, video_info.duration); timestamps.push(timestamp); } else { // 默认提取中间帧 timestamps.push(video_info.duration / 2.0); } } FrameType::Multiple => { if let Some(time_points) = &config.time_points { for time_point in time_points { let timestamp = Self::time_point_to_seconds(time_point, video_info)?; timestamps.push(timestamp); } } } } Ok(timestamps) } /// 将时间点配置转换为秒数 fn time_point_to_seconds(time_point: &TimePoint, video_info: &VideoFileInfo) -> Result { match time_point { TimePoint::Seconds { value } => { Ok(value.clamp(0.0, video_info.duration)) } TimePoint::Percentage { value } => { let percentage = value.clamp(0.0, 1.0); Ok(video_info.duration * percentage) } TimePoint::Frame { value } => { if video_info.fps > 0.0 { let timestamp = (*value as f64) / video_info.fps; Ok(timestamp.clamp(0.0, video_info.duration)) } else { Err(anyhow!("无法计算帧时间戳:视频帧率信息不可用")) } } } } /// 生成输出文件路径 pub fn generate_output_path( video_info: &VideoFileInfo, config: &FrameExtractionConfig, timestamp: f64, index: Option, ) -> Result { let video_path = Path::new(&video_info.path); let video_name = video_path .file_stem() .ok_or_else(|| anyhow!("无法获取视频文件名"))? .to_string_lossy(); let frame_type_str = match &config.frame_type { FrameType::First => "first", FrameType::Last => "last", FrameType::Custom => "custom", FrameType::Multiple => "frame", }; let extension = match config.output_format.format { ImageFormat::Jpg => "jpg", ImageFormat::Png => "png", ImageFormat::WebP => "webp", ImageFormat::Bmp => "bmp", }; // 替换文件名模式中的变量 let mut filename = config.filename_pattern.clone(); filename = filename.replace("{name}", &video_name); filename = filename.replace("{frame_type}", frame_type_str); filename = filename.replace("{timestamp}", &format!("{:.2}", timestamp)); if let Some(idx) = index { filename = filename.replace("{index}", &format!("{:03}", idx + 1)); } // 确保文件名有效 filename = Self::sanitize_filename(&filename); let output_path = Path::new(&config.output_directory) .join(format!("{}.{}", filename, extension)); Ok(output_path.to_string_lossy().to_string()) } /// 清理文件名,移除无效字符 fn sanitize_filename(filename: &str) -> String { filename .chars() .map(|c| match c { '<' | '>' | ':' | '"' | '|' | '?' | '*' => '_', '/' | '\\' => '_', c if c.is_control() => '_', c => c, }) .collect() } /// 批量提取帧 pub async fn extract_frames_batch( video_files: &[VideoFileInfo], config: &FrameExtractionConfig, ) -> Result> { let mut results = Vec::new(); for (index, video_info) in video_files.iter().enumerate() { info!( "处理视频 {}/{}: {}", index + 1, video_files.len(), video_info.filename ); if !video_info.is_valid { warn!("跳过无效视频文件: {}", video_info.path); continue; } // 计算提取时间点 let timestamps = match Self::calculate_extraction_timestamps(video_info, config) { Ok(timestamps) => timestamps, Err(e) => { error!("计算时间戳失败 {}: {}", video_info.path, e); continue; } }; // 为每个时间点提取帧 for (timestamp_index, timestamp) in timestamps.iter().enumerate() { let output_path = match Self::generate_output_path( video_info, config, *timestamp, if timestamps.len() > 1 { Some(timestamp_index) } else { None }, ) { Ok(path) => path, Err(e) => { error!("生成输出路径失败: {}", e); continue; } }; // 检查文件是否已存在 if !config.overwrite_existing && Path::new(&output_path).exists() { info!("跳过已存在的文件: {}", output_path); continue; } // 提取帧 match Self::extract_frame(&video_info.path, *timestamp, &output_path, config).await { Ok(result) => { results.push(result); } Err(e) => { error!("提取帧失败 {}: {}", video_info.path, e); results.push(FrameExtractionResult { video_path: video_info.path.clone(), frame_type: config.frame_type.clone(), timestamp: *timestamp, output_path, success: false, error_message: Some(e.to_string()), processing_time_ms: 0, file_size: 0, }); } } } } Ok(results) } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn create_test_video_info() -> VideoFileInfo { VideoFileInfo { path: "/test/video.mp4".to_string(), filename: "video.mp4".to_string(), size: 1024 * 1024, // 1MB duration: 60.0, // 60 seconds width: 1920, height: 1080, fps: 30.0, format: "mp4".to_string(), is_valid: true, error_message: None, } } fn create_test_config() -> FrameExtractionConfig { FrameExtractionConfig { frame_type: FrameType::First, time_points: None, custom_time: None, output_format: ImageQuality { format: ImageFormat::Jpg, quality: 85, compression_level: None, }, output_size: Some(OutputSize { width: Some(640), height: Some(360), maintain_aspect_ratio: true, scale_filter: Some("lanczos".to_string()), }), output_directory: "/tmp".to_string(), filename_pattern: "{name}_{frame_type}".to_string(), overwrite_existing: false, } } #[test] fn test_parse_fraction() { assert_eq!(FrameExtractorService::parse_fraction("30/1"), Some(30.0)); assert_eq!(FrameExtractorService::parse_fraction("25/1"), Some(25.0)); assert_eq!(FrameExtractorService::parse_fraction("30000/1001"), Some(29.970029970029973)); assert_eq!(FrameExtractorService::parse_fraction("invalid"), None); assert_eq!(FrameExtractorService::parse_fraction("30/0"), None); } #[test] fn test_jpeg_quality_to_qscale() { assert_eq!(FrameExtractorService::jpeg_quality_to_qscale(100), 1); assert_eq!(FrameExtractorService::jpeg_quality_to_qscale(1), 31); assert_eq!(FrameExtractorService::jpeg_quality_to_qscale(85), 6); assert_eq!(FrameExtractorService::jpeg_quality_to_qscale(50), 16); } #[test] fn test_calculate_extraction_timestamps_first_frame() { let video_info = create_test_video_info(); let config = FrameExtractionConfig { frame_type: FrameType::First, ..create_test_config() }; let timestamps = FrameExtractorService::calculate_extraction_timestamps(&video_info, &config) .expect("Should calculate timestamps"); assert_eq!(timestamps.len(), 1); assert_eq!(timestamps[0], 0.0); } #[test] fn test_calculate_extraction_timestamps_last_frame() { let video_info = create_test_video_info(); let config = FrameExtractionConfig { frame_type: FrameType::Last, ..create_test_config() }; let timestamps = FrameExtractorService::calculate_extraction_timestamps(&video_info, &config) .expect("Should calculate timestamps"); assert_eq!(timestamps.len(), 1); assert_eq!(timestamps[0], 59.9); // duration - 0.1 } #[test] fn test_calculate_extraction_timestamps_custom() { let video_info = create_test_video_info(); let config = FrameExtractionConfig { frame_type: FrameType::Custom, custom_time: Some(30.0), ..create_test_config() }; let timestamps = FrameExtractorService::calculate_extraction_timestamps(&video_info, &config) .expect("Should calculate timestamps"); assert_eq!(timestamps.len(), 1); assert_eq!(timestamps[0], 30.0); } #[test] fn test_calculate_extraction_timestamps_custom_default() { let video_info = create_test_video_info(); let config = FrameExtractionConfig { frame_type: FrameType::Custom, custom_time: None, ..create_test_config() }; let timestamps = FrameExtractorService::calculate_extraction_timestamps(&video_info, &config) .expect("Should calculate timestamps"); assert_eq!(timestamps.len(), 1); assert_eq!(timestamps[0], 30.0); // duration / 2 } #[test] fn test_time_point_to_seconds() { let video_info = create_test_video_info(); // Test seconds let time_point = TimePoint::Seconds { value: 15.5 }; let result = FrameExtractorService::time_point_to_seconds(&time_point, &video_info) .expect("Should convert seconds"); assert_eq!(result, 15.5); // Test percentage let time_point = TimePoint::Percentage { value: 0.5 }; let result = FrameExtractorService::time_point_to_seconds(&time_point, &video_info) .expect("Should convert percentage"); assert_eq!(result, 30.0); // 50% of 60 seconds // Test frame number let time_point = TimePoint::Frame { value: 900 }; let result = FrameExtractorService::time_point_to_seconds(&time_point, &video_info) .expect("Should convert frame"); assert_eq!(result, 30.0); // 900 frames / 30 fps } #[test] fn test_generate_output_path() { let video_info = create_test_video_info(); let config = create_test_config(); let output_path = FrameExtractorService::generate_output_path( &video_info, &config, 15.5, None, ).expect("Should generate output path"); assert!(output_path.contains("video_first")); assert!(output_path.contains("15.50")); assert!(output_path.ends_with(".jpg")); } #[test] fn test_generate_output_path_with_index() { let video_info = create_test_video_info(); let config = FrameExtractionConfig { filename_pattern: "{name}_{frame_type}_{index}".to_string(), ..create_test_config() }; let output_path = FrameExtractorService::generate_output_path( &video_info, &config, 15.5, Some(2), ).expect("Should generate output path"); assert!(output_path.contains("video_first_003")); } #[test] fn test_sanitize_filename() { assert_eq!( FrameExtractorService::sanitize_filename("normal_filename"), "normal_filename" ); assert_eq!( FrameExtractorService::sanitize_filename("file<>name"), "file__name" ); assert_eq!( FrameExtractorService::sanitize_filename("file/with\\path"), "file_with_path" ); assert_eq!( FrameExtractorService::sanitize_filename("file:with|special*chars?"), "file_with_special_chars_" ); } #[test] fn test_calculate_extraction_timestamps_multiple() { let video_info = create_test_video_info(); let time_points = vec![ TimePoint::Seconds { value: 10.0 }, TimePoint::Percentage { value: 0.5 }, TimePoint::Frame { value: 1500 }, ]; let config = FrameExtractionConfig { frame_type: FrameType::Multiple, time_points: Some(time_points), ..create_test_config() }; let timestamps = FrameExtractorService::calculate_extraction_timestamps(&video_info, &config) .expect("Should calculate timestamps"); assert_eq!(timestamps.len(), 3); assert_eq!(timestamps[0], 10.0); assert_eq!(timestamps[1], 30.0); // 50% of 60 seconds assert_eq!(timestamps[2], 50.0); // 1500 frames / 30 fps } #[test] fn test_time_point_clamping() { let video_info = create_test_video_info(); // Test seconds clamping let time_point = TimePoint::Seconds { value: 100.0 }; // Beyond duration let result = FrameExtractorService::time_point_to_seconds(&time_point, &video_info) .expect("Should convert seconds"); assert_eq!(result, 60.0); // Clamped to duration // Test negative seconds let time_point = TimePoint::Seconds { value: -5.0 }; let result = FrameExtractorService::time_point_to_seconds(&time_point, &video_info) .expect("Should convert seconds"); assert_eq!(result, 0.0); // Clamped to 0 // Test percentage clamping let time_point = TimePoint::Percentage { value: 1.5 }; // > 1.0 let result = FrameExtractorService::time_point_to_seconds(&time_point, &video_info) .expect("Should convert percentage"); assert_eq!(result, 60.0); // 100% of duration } }