// Prevents additional console window on Windows in release #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod ffmpeg_installer; use ffmpeg_installer::FFmpegInstaller; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::{Path}; use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter}; use tokio::process::Command; use uuid::Uuid; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STD}; // ============ 数据结构定义 ============ /// 文件类型 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "lowercase")] pub enum FileType { Video, Audio, Image, Document, Other, } impl FileType { pub fn from_extension(ext: &str) -> Self { match ext.to_lowercase().as_str() { "mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" | "mpg" | "mpeg" | "3gp" | "ts" | "m2ts" => FileType::Video, "mp3" | "wav" | "aac" | "flac" | "ogg" | "wma" | "m4a" | "opus" | "ape" | "ac3" => FileType::Audio, "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "svg" | "tiff" | "heic" | "raw" | "cr2" | "nef" => FileType::Image, "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" | "txt" | "rtf" => FileType::Document, _ => FileType::Other, } } pub fn display_name(&self) -> &'static str { match self { FileType::Video => "视频", FileType::Audio => "音频", FileType::Image => "图片", FileType::Document => "文档", FileType::Other => "其他", } } } /// 文件编码信息 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileCodecInfo { pub video_codec: Option, pub audio_codec: Option, pub resolution: Option, pub bitrate: Option, pub frame_rate: Option, pub audio_bitrate: Option, pub sample_rate: Option, pub channels: Option, pub duration: Option, } /// 输入文件信息 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InputFile { pub id: String, pub path: String, pub name: String, pub extension: String, pub file_type: FileType, pub size: u64, pub thumbnail: Option, // base64 thumbnail pub codec_info: Option, // 编码参数信息 } /// 转换参数模板 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConversionTemplate { pub id: String, pub name: String, pub file_type: FileType, pub output_format: String, pub params: ConversionParams, pub is_default: bool, pub is_custom: bool, } /// 转换参数 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConversionParams { pub video_codec: Option, pub audio_codec: Option, pub resolution: Option, pub bitrate: Option, pub frame_rate: Option, pub audio_bitrate: Option, pub sample_rate: Option, pub channels: Option, pub quality: Option, // 0-100, for images } impl Default for ConversionParams { fn default() -> Self { Self { video_codec: None, audio_codec: None, resolution: None, bitrate: None, frame_rate: None, audio_bitrate: None, sample_rate: None, channels: None, quality: None, } } } /// 批量转换任务 #[derive(Debug, Clone, Serialize)] pub struct BatchTask { pub id: String, pub file_type: FileType, pub files: Vec, pub template: ConversionTemplate, pub output_folder: String, pub status: TaskStatus, pub progress: f64, pub message: String, } #[derive(Debug, Clone, Serialize)] pub struct BatchFileTask { pub id: String, pub input_file: InputFile, pub output_path: String, pub status: TaskStatus, pub progress: f64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum TaskStatus { Pending, Converting, Completed, Error, Paused, } /// 格式预设 #[derive(Debug, Clone, Serialize)] pub struct FormatPreset { pub name: String, pub extension: String, pub description: String, pub file_type: FileType, pub video_codecs: Vec, pub audio_codecs: Vec, } // ============ 全局状态 ============ type BatchTasks = Arc>>; type Templates = Arc>>; #[derive(Default, Clone)] struct AppState { batch_tasks: BatchTasks, templates: Templates, } // ============ 命令实现 ============ /// 自定义文件选择对话框 #[tauri::command] async fn select_files(_app: AppHandle) -> Result, String> { // 使用 rfd 直接创建文件对话框 let files = rfd::AsyncFileDialog::new() .add_filter("媒体文件", &["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "m4v", "mpg", "mpeg", "3gp", "ts", "mp3", "wav", "aac", "flac", "ogg", "wma", "m4a", "opus", "ape", "jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "tiff", "heic", "raw"]) .add_filter("视频", &["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "m4v", "mpg", "mpeg", "3gp", "ts"]) .add_filter("音频", &["mp3", "wav", "aac", "flac", "ogg", "wma", "m4a", "opus", "ape"]) .add_filter("图片", &["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "tiff", "heic", "raw"]) .set_title("选择要转换的文件") .pick_files() .await; match files { Some(file_handles) => { let paths: Vec = file_handles .into_iter() .map(|handle| handle.path().to_string_lossy().to_string()) .collect(); Ok(paths) }, None => Ok(vec![]), } } /// 选择输出文件夹 #[tauri::command] async fn select_output_folder() -> Result, String> { let folder = rfd::AsyncFileDialog::new() .set_title("选择输出文件夹") .pick_folder() .await; match folder { Some(handle) => Ok(Some(handle.path().to_string_lossy().to_string())), None => Ok(None), } } /// 检查 FFmpeg 状态 #[tauri::command] async fn check_ffmpeg_status() -> Result<(bool, Option), String> { if FFmpegInstaller::is_installed() { let version = FFmpegInstaller::get_version().await; Ok((true, version)) } else { Ok((false, None)) } } /// 安装 FFmpeg #[tauri::command] async fn install_ffmpeg(app: AppHandle) -> Result<(), String> { FFmpegInstaller::install(move |progress, message| { let _ = app.emit("ffmpeg-install-progress", serde_json::json!({ "progress": progress, "message": message })); }) .await } /// 获取支持的格式 #[tauri::command] fn get_supported_formats() -> Vec { vec![ // 视频格式 FormatPreset { name: "MP4".to_string(), extension: "mp4".to_string(), description: "H.264/AVC 视频".to_string(), file_type: FileType::Video, video_codecs: vec!["h264".to_string(), "hevc".to_string(), "mpeg4".to_string()], audio_codecs: vec!["aac".to_string(), "mp3".to_string()], }, FormatPreset { name: "MKV".to_string(), extension: "mkv".to_string(), description: "Matroska 视频".to_string(), file_type: FileType::Video, video_codecs: vec!["h264".to_string(), "hevc".to_string(), "vp9".to_string()], audio_codecs: vec!["aac".to_string(), "opus".to_string(), "flac".to_string()], }, FormatPreset { name: "MOV".to_string(), extension: "mov".to_string(), description: "QuickTime 视频".to_string(), file_type: FileType::Video, video_codecs: vec!["h264".to_string(), "hevc".to_string(), "prores".to_string()], audio_codecs: vec!["aac".to_string(), "pcm_s16le".to_string()], }, FormatPreset { name: "AVI".to_string(), extension: "avi".to_string(), description: "AVI 视频".to_string(), file_type: FileType::Video, video_codecs: vec!["mpeg4".to_string(), "mjpeg".to_string()], audio_codecs: vec!["mp3".to_string(), "ac3".to_string()], }, FormatPreset { name: "WebM".to_string(), extension: "webm".to_string(), description: "WebM 视频".to_string(), file_type: FileType::Video, video_codecs: vec!["vp8".to_string(), "vp9".to_string()], audio_codecs: vec!["vorbis".to_string(), "opus".to_string()], }, // 音频格式 FormatPreset { name: "MP3".to_string(), extension: "mp3".to_string(), description: "MP3 音频".to_string(), file_type: FileType::Audio, video_codecs: vec![], audio_codecs: vec!["mp3".to_string()], }, FormatPreset { name: "WAV".to_string(), extension: "wav".to_string(), description: "无损 WAV 音频".to_string(), file_type: FileType::Audio, video_codecs: vec![], audio_codecs: vec!["pcm_s16le".to_string()], }, FormatPreset { name: "FLAC".to_string(), extension: "flac".to_string(), description: "无损 FLAC 音频".to_string(), file_type: FileType::Audio, video_codecs: vec![], audio_codecs: vec!["flac".to_string()], }, FormatPreset { name: "AAC".to_string(), extension: "aac".to_string(), description: "AAC 音频".to_string(), file_type: FileType::Audio, video_codecs: vec![], audio_codecs: vec!["aac".to_string()], }, FormatPreset { name: "OGG".to_string(), extension: "ogg".to_string(), description: "Ogg Vorbis 音频".to_string(), file_type: FileType::Audio, video_codecs: vec![], audio_codecs: vec!["vorbis".to_string(), "opus".to_string()], }, FormatPreset { name: "Opus".to_string(), extension: "opus".to_string(), description: "Opus 音频".to_string(), file_type: FileType::Audio, video_codecs: vec![], audio_codecs: vec!["opus".to_string()], }, // 图片格式 FormatPreset { name: "JPEG".to_string(), extension: "jpg".to_string(), description: "JPEG 图片".to_string(), file_type: FileType::Image, video_codecs: vec![], audio_codecs: vec![], }, FormatPreset { name: "PNG".to_string(), extension: "png".to_string(), description: "PNG 图片".to_string(), file_type: FileType::Image, video_codecs: vec![], audio_codecs: vec![], }, FormatPreset { name: "WebP".to_string(), extension: "webp".to_string(), description: "WebP 图片".to_string(), file_type: FileType::Image, video_codecs: vec![], audio_codecs: vec![], }, FormatPreset { name: "GIF".to_string(), extension: "gif".to_string(), description: "GIF 动画".to_string(), file_type: FileType::Image, video_codecs: vec![], audio_codecs: vec![], }, FormatPreset { name: "BMP".to_string(), extension: "bmp".to_string(), description: "BMP 图片".to_string(), file_type: FileType::Image, video_codecs: vec![], audio_codecs: vec![], }, ] } /// 获取默认模板 #[tauri::command] fn get_default_templates() -> Vec { vec![ // 视频格式 ConversionTemplate { id: "video-mp4-hd".to_string(), name: "MP4 高清".to_string(), file_type: FileType::Video, output_format: "mp4".to_string(), params: ConversionParams { video_codec: Some("h264".to_string()), audio_codec: Some("aac".to_string()), resolution: Some("1920x1080".to_string()), bitrate: Some("5M".to_string()), frame_rate: None, audio_bitrate: Some("192k".to_string()), sample_rate: None, channels: None, quality: None, }, is_default: true, is_custom: false, }, ConversionTemplate { id: "video-mkv-hd".to_string(), name: "MKV 高清".to_string(), file_type: FileType::Video, output_format: "mkv".to_string(), params: ConversionParams { video_codec: Some("h264".to_string()), audio_codec: Some("aac".to_string()), resolution: Some("1920x1080".to_string()), bitrate: Some("5M".to_string()), frame_rate: None, audio_bitrate: Some("192k".to_string()), sample_rate: None, channels: None, quality: None, }, is_default: true, is_custom: false, }, ConversionTemplate { id: "video-mov-hd".to_string(), name: "MOV 高清".to_string(), file_type: FileType::Video, output_format: "mov".to_string(), params: ConversionParams { video_codec: Some("h264".to_string()), audio_codec: Some("aac".to_string()), resolution: Some("1920x1080".to_string()), bitrate: Some("5M".to_string()), frame_rate: None, audio_bitrate: Some("192k".to_string()), sample_rate: None, channels: None, quality: None, }, is_default: true, is_custom: false, }, ConversionTemplate { id: "video-avi".to_string(), name: "AVI 标准".to_string(), file_type: FileType::Video, output_format: "avi".to_string(), params: ConversionParams { video_codec: Some("mpeg4".to_string()), audio_codec: Some("mp3".to_string()), resolution: Some("1280x720".to_string()), bitrate: Some("2M".to_string()), frame_rate: None, audio_bitrate: Some("128k".to_string()), sample_rate: None, channels: None, quality: None, }, is_default: true, is_custom: false, }, ConversionTemplate { id: "video-webm".to_string(), name: "WebM 网络".to_string(), file_type: FileType::Video, output_format: "webm".to_string(), params: ConversionParams { video_codec: Some("vp9".to_string()), audio_codec: Some("opus".to_string()), resolution: Some("1280x720".to_string()), bitrate: Some("2M".to_string()), frame_rate: None, audio_bitrate: Some("128k".to_string()), sample_rate: None, channels: None, quality: None, }, is_default: true, is_custom: false, }, // 音频格式 ConversionTemplate { id: "audio-mp3-hq".to_string(), name: "MP3 高质量".to_string(), file_type: FileType::Audio, output_format: "mp3".to_string(), params: ConversionParams { video_codec: None, audio_codec: Some("mp3".to_string()), resolution: None, bitrate: None, frame_rate: None, audio_bitrate: Some("320k".to_string()), sample_rate: Some("48000".to_string()), channels: Some("2".to_string()), quality: None, }, is_default: true, is_custom: false, }, ConversionTemplate { id: "audio-wav".to_string(), name: "WAV 无损".to_string(), file_type: FileType::Audio, output_format: "wav".to_string(), params: ConversionParams { video_codec: None, audio_codec: Some("pcm_s16le".to_string()), resolution: None, bitrate: None, frame_rate: None, audio_bitrate: None, sample_rate: Some("48000".to_string()), channels: Some("2".to_string()), quality: None, }, is_default: true, is_custom: false, }, ConversionTemplate { id: "audio-flac".to_string(), name: "FLAC 无损".to_string(), file_type: FileType::Audio, output_format: "flac".to_string(), params: ConversionParams { video_codec: None, audio_codec: Some("flac".to_string()), resolution: None, bitrate: None, frame_rate: None, audio_bitrate: None, sample_rate: Some("48000".to_string()), channels: Some("2".to_string()), quality: None, }, is_default: true, is_custom: false, }, ConversionTemplate { id: "audio-aac".to_string(), name: "AAC 高效".to_string(), file_type: FileType::Audio, output_format: "aac".to_string(), params: ConversionParams { video_codec: None, audio_codec: Some("aac".to_string()), resolution: None, bitrate: None, frame_rate: None, audio_bitrate: Some("256k".to_string()), sample_rate: Some("48000".to_string()), channels: Some("2".to_string()), quality: None, }, is_default: true, is_custom: false, }, ConversionTemplate { id: "audio-ogg".to_string(), name: "OGG Vorbis".to_string(), file_type: FileType::Audio, output_format: "ogg".to_string(), params: ConversionParams { video_codec: None, audio_codec: Some("vorbis".to_string()), resolution: None, bitrate: None, frame_rate: None, audio_bitrate: Some("192k".to_string()), sample_rate: Some("48000".to_string()), channels: Some("2".to_string()), quality: None, }, is_default: true, is_custom: false, }, // 图片格式 ConversionTemplate { id: "image-jpg-hq".to_string(), name: "JPEG 高质量".to_string(), file_type: FileType::Image, output_format: "jpg".to_string(), params: ConversionParams { video_codec: None, audio_codec: None, resolution: None, bitrate: None, frame_rate: None, audio_bitrate: None, sample_rate: None, channels: None, quality: Some(95), }, is_default: true, is_custom: false, }, ConversionTemplate { id: "image-png".to_string(), name: "PNG 无损".to_string(), file_type: FileType::Image, output_format: "png".to_string(), params: ConversionParams { video_codec: None, audio_codec: None, resolution: None, bitrate: None, frame_rate: None, audio_bitrate: None, sample_rate: None, channels: None, quality: None, }, is_default: true, is_custom: false, }, ConversionTemplate { id: "image-webp".to_string(), name: "WebP 压缩".to_string(), file_type: FileType::Image, output_format: "webp".to_string(), params: ConversionParams { video_codec: None, audio_codec: None, resolution: None, bitrate: None, frame_rate: None, audio_bitrate: None, sample_rate: None, channels: None, quality: Some(85), }, is_default: true, is_custom: false, }, ConversionTemplate { id: "image-gif".to_string(), name: "GIF 动画".to_string(), file_type: FileType::Image, output_format: "gif".to_string(), params: ConversionParams { video_codec: None, audio_codec: None, resolution: None, bitrate: None, frame_rate: None, audio_bitrate: None, sample_rate: None, channels: None, quality: None, }, is_default: true, is_custom: false, }, ConversionTemplate { id: "image-bmp".to_string(), name: "BMP 位图".to_string(), file_type: FileType::Image, output_format: "bmp".to_string(), params: ConversionParams { video_codec: None, audio_codec: None, resolution: None, bitrate: None, frame_rate: None, audio_bitrate: None, sample_rate: None, channels: None, quality: None, }, is_default: true, is_custom: false, }, ConversionTemplate { id: "image-tiff".to_string(), name: "TIFF 高质量".to_string(), file_type: FileType::Image, output_format: "tiff".to_string(), params: ConversionParams { video_codec: None, audio_codec: None, resolution: None, bitrate: None, frame_rate: None, audio_bitrate: None, sample_rate: None, channels: None, quality: None, }, is_default: true, is_custom: false, }, ] } /// 保存自定义模板 #[tauri::command] fn save_template(template: ConversionTemplate, state: tauri::State) -> Result<(), String> { let mut templates = state.templates.lock().unwrap(); if let Some(idx) = templates.iter().position(|t| t.id == template.id) { templates[idx] = template; } else { templates.push(template); } Ok(()) } /// 删除模板 #[tauri::command] fn delete_template(template_id: String, state: tauri::State) -> Result<(), String> { let mut templates = state.templates.lock().unwrap(); templates.retain(|t| t.id != template_id); Ok(()) } /// 获取所有模板 #[tauri::command] fn get_all_templates(state: tauri::State) -> Vec { let default_templates = get_default_templates(); let custom_templates = state.templates.lock().unwrap().clone(); let mut all = default_templates; all.extend(custom_templates); all } /// 分析文件并返回文件信息 #[tauri::command] async fn analyze_files(paths: Vec) -> Result, String> { let mut files = Vec::new(); for path in paths { let path_obj = Path::new(&path); if !path_obj.exists() { continue; } let name = path_obj .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string(); let extension = path_obj .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_string(); let file_type = FileType::from_extension(&extension); let metadata = fs::metadata(&path).map_err(|e: std::io::Error| e.to_string())?; let size = metadata.len(); // 生成缩略图(仅视频和图片) let thumbnail = if file_type == FileType::Video || file_type == FileType::Image { match generate_thumbnail(&path, &file_type).await { Ok(thumb) => { println!("✅ 缩略图生成成功: {}", name); Some(thumb) } Err(e) => { println!("⚠️ 缩略图生成失败 '{}': {}", name, e); None } } } else { None }; // 获取文件编码信息(视频、音频和图片) let codec_info = if file_type == FileType::Video || file_type == FileType::Audio || file_type == FileType::Image { match get_file_codec_info(&path, &file_type).await { Some(info) => { println!("✅ 编码信息获取成功: {}", name); Some(info) } None => { println!("⚠️ 编码信息获取失败: {}", name); None } } } else { None }; files.push(InputFile { id: Uuid::new_v4().to_string(), path: path.clone(), name, extension, file_type, size, thumbnail, codec_info, }); } Ok(files) } /// 获取可用的 FFmpeg 路径(优先系统 PATH,其次应用目录) async fn get_ffmpeg_path() -> String { // 1. 优先尝试系统 PATH 中的 ffmpeg if let Ok(output) = tokio::time::timeout( std::time::Duration::from_secs(3), Command::new("ffmpeg").args(["-version"]).output() ).await { if let Ok(output) = output { if output.status.success() { return "ffmpeg".to_string(); } } } // 2. 尝试应用目录中的 ffmpeg let app_ffmpeg = FFmpegInstaller::get_ffmpeg_path(); if app_ffmpeg.exists() { if let Some(path_str) = app_ffmpeg.to_str() { return path_str.to_string(); } } // 3. 尝试常见系统路径 let common_paths = vec![ "/opt/homebrew/bin/ffmpeg", // macOS Apple Silicon "/usr/local/bin/ffmpeg", // macOS Intel "/usr/bin/ffmpeg", // Linux ]; for path in common_paths { if std::path::Path::new(path).exists() { return path.to_string(); } } // 最后 fallback 到 "ffmpeg" 让系统尝试 "ffmpeg".to_string() } /// 获取可用的 ffprobe 路径 async fn get_ffprobe_path() -> String { // 1. 优先尝试系统 PATH 中的 ffprobe if let Ok(output) = tokio::time::timeout( std::time::Duration::from_secs(3), Command::new("ffprobe").args(["-version"]).output() ).await { if let Ok(output) = output { if output.status.success() { return "ffprobe".to_string(); } } } // 2. 尝试应用目录中的 ffprobe(与 ffmpeg 同目录) let app_ffmpeg = FFmpegInstaller::get_ffmpeg_path(); if let Some(parent) = app_ffmpeg.parent() { let app_ffprobe = parent.join("ffprobe"); if app_ffprobe.exists() { if let Some(path_str) = app_ffprobe.to_str() { return path_str.to_string(); } } } // 3. 尝试常见系统路径 let common_paths = vec![ "/opt/homebrew/bin/ffprobe", // macOS Apple Silicon "/usr/local/bin/ffprobe", // macOS Intel "/usr/bin/ffprobe", // Linux ]; for path in common_paths { if std::path::Path::new(path).exists() { return path.to_string(); } } // 最后 fallback 到 "ffprobe" 让系统尝试 "ffprobe".to_string() } /// 获取文件编码信息 async fn get_file_codec_info(path: &str, _file_type: &FileType) -> Option { let ffprobe_path = get_ffprobe_path().await; // 使用 ffprobe 获取文件信息 let output = Command::new(&ffprobe_path) .args([ "-v", "quiet", "-print_format", "json", "-show_streams", path ]) .output() .await; match output { Ok(output) => { if !output.status.success() { return None; } let json_str = String::from_utf8_lossy(&output.stdout); let json: serde_json::Value = match serde_json::from_str(&json_str) { Ok(v) => v, Err(_) => return None, }; let streams = json.get("streams")?.as_array()?; let mut video_codec = None; let mut audio_codec = None; let mut resolution = None; let mut bitrate = None; let mut frame_rate = None; let mut audio_bitrate = None; let mut sample_rate = None; let mut channels = None; let mut duration = None; for stream in streams { let codec_type = stream.get("codec_type")?.as_str()?; match codec_type { "video" => { video_codec = stream.get("codec_name").and_then(|v| v.as_str()).map(|s| s.to_uppercase()); let width = stream.get("width").and_then(|v| v.as_i64()); let height = stream.get("height").and_then(|v| v.as_i64()); if let (Some(w), Some(h)) = (width, height) { resolution = Some(format!("{}x{}", w, h)); } // 获取视频比特率 if let Some(bit_rate) = stream.get("bit_rate").and_then(|v| v.as_str()) { if let Ok(bits) = bit_rate.parse::() { bitrate = Some(format!("{:.1} Mbps", bits as f64 / 1_000_000.0)); } } // 获取帧率 if let Some(r_frame_rate) = stream.get("r_frame_rate").and_then(|v| v.as_str()) { let parts: Vec<&str> = r_frame_rate.split('/').collect(); if parts.len() == 2 { if let (Ok(num), Ok(den)) = (parts[0].parse::(), parts[1].parse::()) { if den > 0.0 { frame_rate = Some(format!("{:.2} fps", num / den)); } } } } } "audio" => { audio_codec = stream.get("codec_name").and_then(|v| v.as_str()).map(|s| s.to_uppercase()); // 获取音频比特率 if let Some(bit_rate) = stream.get("bit_rate").and_then(|v| v.as_str()) { if let Ok(bits) = bit_rate.parse::() { audio_bitrate = Some(format!("{} kbps", bits / 1000)); } } // 获取采样率 if let Some(sample) = stream.get("sample_rate").and_then(|v| v.as_str()) { if let Ok(rate) = sample.parse::() { sample_rate = Some(format!("{} Hz", rate)); } } // 获取声道数 if let Some(ch) = stream.get("channels").and_then(|v| v.as_i64()) { let ch_label = match ch { 1 => "1 (单声道)", 2 => "2 (立体声)", 6 => "6 (5.1环绕)", 8 => "8 (7.1环绕)", _ => "", }; channels = Some(if ch_label.is_empty() { ch.to_string() } else { ch_label.to_string() }); } } _ => {} } } // 获取时长(从 format 部分) if let Ok(format_output) = Command::new(&ffprobe_path) .args([ "-v", "quiet", "-print_format", "json", "-show_format", path ]) .output() .await { if format_output.status.success() { let format_json: serde_json::Value = serde_json::from_slice(&format_output.stdout).ok()?; if let Some(dur_str) = format_json.get("format").and_then(|f| f.get("duration")).and_then(|v| v.as_str()) { if let Ok(dur_secs) = dur_str.parse::() { let hours = (dur_secs / 3600.0) as u64; let mins = ((dur_secs % 3600.0) / 60.0) as u64; let secs = (dur_secs % 60.0) as u64; if hours > 0 { duration = Some(format!("{}:{:02}:{:02}", hours, mins, secs)); } else { duration = Some(format!("{}:{:02}", mins, secs)); } } } } } Some(FileCodecInfo { video_codec, audio_codec, resolution, bitrate, frame_rate, audio_bitrate, sample_rate, channels, duration, }) } Err(_) => None, } } /// 生成缩略图 /// 生成缩略图 - 优化版 async fn generate_thumbnail(path: &str, file_type: &FileType) -> Result { println!("生成缩略图 - 文件: {}, 类型: {:?}", path, file_type); match file_type { FileType::Image => { // 图片:使用 image crate 直接处理,比 ffmpeg 快得多 generate_image_thumbnail(path).await } FileType::Audio => { // 音频:尝试提取内置封面 match extract_audio_cover(path).await { Ok(thumb) => Ok(thumb), Err(e) => { println!("⚠️ 提取音频封面失败: {}, 将使用默认图标", e); Err("无封面".to_string()) } } } FileType::Video => { // 视频:使用 ffmpeg 提取第一帧(优化参数) generate_video_thumbnail_fast(path).await } _ => Err("不支持的类型".to_string()), } } /// 生成图片缩略图 - 使用 image crate(比 ffmpeg 快 5-10 倍) async fn generate_image_thumbnail(path: &str) -> Result { use image::GenericImageView; println!("使用 image crate 生成图片缩略图..."); let path_str = path.to_string(); let path_for_error = path_str.clone(); // 在阻塞线程中处理图片 let result = tokio::task::spawn_blocking(move || { // 读取图片 let img = image::open(&path_str).map_err(|e| format!("读取图片失败: {}", e))?; // 计算缩放尺寸(最大宽度 320px) let (width, height) = img.dimensions(); let max_width = 320u32; let thumb = if width > max_width { let ratio = max_width as f32 / width as f32; let new_height = (height as f32 * ratio) as u32; img.resize(max_width, new_height, image::imageops::FilterType::Lanczos3) } else { img }; // 编码为 JPEG let mut buffer = Vec::new(); { let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buffer, 85); thumb.write_with_encoder(encoder) .map_err(|e| format!("编码 JPEG 失败: {}", e))?; } Ok::, String>(buffer) }).await.map_err(|e| format!("任务执行失败: {}", e))?; match result { Ok(buffer) => { println!("✅ 图片缩略图生成成功 ({} bytes)", buffer.len()); Ok(format!("data:image/jpeg;base64,{}", BASE64_STD.encode(&buffer))) } Err(e) => { println!("⚠️ image crate 失败,回退到 ffmpeg: {}", e); // 回退到 ffmpeg generate_video_thumbnail_ffmpeg(&path_for_error).await } } } /// 提取音频文件内置封面 - 使用 lofty crate async fn extract_audio_cover(path: &str) -> Result { use image::GenericImageView; println!("提取音频封面..."); use lofty::file::TaggedFileExt; use lofty::probe::Probe; let path = path.to_string(); // 在阻塞线程中处理 let result = tokio::task::spawn_blocking(move || { let tagged_file = Probe::open(&path) .map_err(|e| format!("打开音频文件失败: {}", e))? .read() .map_err(|e| format!("读取音频文件失败: {}", e))?; // 获取标签 let tag = tagged_file.primary_tag() .or_else(|| tagged_file.first_tag()); if let Some(tag) = tag { // 获取封面图片 let pictures = tag.pictures(); if let Some(picture) = pictures.first() { let data: &[u8] = picture.data(); let mime_type: String = picture.mime_type().as_ref().map(|m| m.to_string()) .unwrap_or_else(|| "image/jpeg".to_string()); // 如果图片太大,缩放它 let data = if data.len() > 100 * 1024 { // 尝试缩放 match image::load_from_memory(data) { Ok(img) => { let (width, height) = img.dimensions(); if width > 320 { let ratio = 320.0 / width as f32; let new_height = (height as f32 * ratio) as u32; let thumb = img.resize(320, new_height, image::imageops::FilterType::Lanczos3); let mut buffer = Vec::new(); if let Ok(_) = thumb.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Jpeg) { buffer } else { data.to_vec() } } else { data.to_vec() } } Err(_) => data.to_vec() } } else { data.to_vec() }; return Ok::<(String, Vec), String>((mime_type, data)); } } Err("音频文件没有封面".to_string()) }).await.map_err(|e| format!("任务执行失败: {}", e))?; match result { Ok((mime_type, data)) => { println!("✅ 音频封面提取成功 ({} bytes)", data.len()); Ok(format!("data:{};base64,{}", mime_type, BASE64_STD.encode(&data))) } Err(e) => Err(e) } } /// 快速生成视频缩略图 - 优化 ffmpeg 参数 async fn generate_video_thumbnail_fast(path: &str) -> Result { let ffmpeg_path = get_ffmpeg_path().await; // 优化策略: // 1. 使用 -ss 0.5 跳过可能的黑帧 // 2. 减少输出质量以提高速度 // 3. 使用更快的缩放算法 let output = Command::new(&ffmpeg_path) .args([ "-ss", "0.5", // 从0.5秒开始,跳过可能的黑帧 "-i", path, "-vframes", "1", // 只取一帧 "-vf", "scale=320:-1:flags=fast_bilinear", // 使用更快的缩放算法 "-f", "image2", "-vcodec", "mjpeg", "-q:v", "5", // 降低质量以提高速度(1-31,越大质量越低) "pipe:1" ]) .output() .await; match output { Ok(output) => { if output.status.success() && !output.stdout.is_empty() { println!("✅ 视频缩略图生成成功"); Ok(format!("data:image/jpeg;base64,{}", BASE64_STD.encode(&output.stdout))) } else { // 如果快速模式失败,回退到标准模式 println!("⚠️ 快速模式失败,回退到标准模式"); generate_video_thumbnail_ffmpeg(path).await } } Err(e) => { println!("❌ 执行失败: {}", e); Err(format!("执行 FFmpeg 失败: {}", e)) } } } /// 使用 ffmpeg 生成视频缩略图(标准模式,作为回退) async fn generate_video_thumbnail_ffmpeg(path: &str) -> Result { let ffmpeg_path = get_ffmpeg_path().await; let seek_positions = ["00:00:00.500", "00:00:01", "00:00:02"]; let mut last_error = String::new(); for seek_time in &seek_positions { let output = Command::new(&ffmpeg_path) .args([ "-ss", seek_time, "-i", path, "-vframes", "1", "-vf", "scale=320:-1:flags=lanczos", "-f", "image2", "-vcodec", "mjpeg", "-q:v", "3", "pipe:1" ]) .output() .await; match output { Ok(output) => { if output.status.success() && !output.stdout.is_empty() { return Ok(format!("data:image/jpeg;base64,{}", BASE64_STD.encode(&output.stdout))); } else { let stderr = String::from_utf8_lossy(&output.stderr); last_error = format!("FFmpeg at {}: {}", seek_time, stderr); } } Err(e) => { last_error = format!("执行失败 at {}: {}", seek_time, e); } } } Err(format!("生成视频缩略图失败: {}", last_error)) } /// 开始批量转换 #[tauri::command] async fn start_batch_conversion( app: AppHandle, task_id: String, files: Vec, template: ConversionTemplate, output_folder: Option, state: tauri::State<'_, AppState>, ) -> Result<(), String> { // 创建批量任务 let batch_files: Vec = files .into_iter() .map(|f| { let output_path = generate_output_path(&f, &template, output_folder.as_deref()); BatchFileTask { id: Uuid::new_v4().to_string(), input_file: f, output_path, status: TaskStatus::Pending, progress: 0.0, } }) .collect(); let batch_task = BatchTask { id: task_id.clone(), file_type: template.file_type.clone(), files: batch_files, template, output_folder: output_folder.unwrap_or_default(), status: TaskStatus::Converting, progress: 0.0, message: "准备中...".to_string(), }; // 保存任务 { let mut tasks = state.batch_tasks.lock().unwrap(); tasks.insert(task_id.clone(), batch_task); } // 启动转换 let app_clone = app.clone(); let state_clone = state.inner().clone(); tokio::spawn(async move { run_batch_conversion(app_clone, task_id, state_clone).await; }); Ok(()) } /// 生成输出路径 fn generate_output_path(input: &InputFile, template: &ConversionTemplate, output_folder: Option<&str>) -> String { let base_name = input.name.rsplitn(2, '.').last().unwrap_or(&input.name); let new_name = format!("{}_converted.{}", base_name, template.output_format); if let Some(folder) = output_folder { Path::new(folder).join(new_name).to_string_lossy().to_string() } else { let input_dir = Path::new(&input.path).parent().unwrap_or(Path::new(".")); input_dir.join(new_name).to_string_lossy().to_string() } } /// 执行批量转换 async fn run_batch_conversion(app: AppHandle, task_id: String, state: AppState) { let ffmpeg_path = FFmpegInstaller::get_ffmpeg_path(); // 获取总文件数用于进度计算 let total_files = { let tasks = state.batch_tasks.lock().unwrap(); tasks.get(&task_id).map(|t| t.files.len()).unwrap_or(1) }; loop { // 获取当前需要处理的文件 let file_task = { let tasks = state.batch_tasks.lock().unwrap(); if let Some(task) = tasks.get(&task_id) { task.files .iter() .find(|f| f.status == TaskStatus::Pending) .cloned() } else { return; // 任务不存在 } }; let file_task = match file_task { Some(f) => f, None => break, // 所有文件处理完成 }; // 更新状态为转换中 update_file_status(&task_id, &file_task.id, TaskStatus::Converting, 0.0, &state); emit_progress(&app, &task_id, &state).await; // 执行转换 let template = { let tasks = state.batch_tasks.lock().unwrap(); tasks.get(&task_id).map(|t| t.template.clone()) }; if let Some(template) = template { // 获取已完成的文件数 let completed_count = { let tasks = state.batch_tasks.lock().unwrap(); tasks.get(&task_id).map(|t| { t.files.iter().filter(|f| f.status == TaskStatus::Completed).count() }).unwrap_or(0) }; // 更新整体进度(基于已完成的文件) { let mut tasks = state.batch_tasks.lock().unwrap(); if let Some(task) = tasks.get_mut(&task_id) { let base_progress = (completed_count as f64 / total_files as f64) * 100.0; task.progress = base_progress; task.message = format!("正在转换 {}/{}...", completed_count + 1, total_files); } } emit_progress(&app, &task_id, &state).await; match convert_single_file( &ffmpeg_path, &file_task.input_file, &file_task.output_path, &template, ) .await { Ok(_) => { update_file_status(&task_id, &file_task.id, TaskStatus::Completed, 100.0, &state); } Err(e) => { update_file_status(&task_id, &file_task.id, TaskStatus::Error, 0.0, &state); eprintln!("转换失败: {}", e); } } } else { eprintln!("找不到任务模板: {}", task_id); break; } // 更新整体进度 update_batch_progress(&task_id, &state).await; emit_progress(&app, &task_id, &state).await; } // 更新任务状态为完成 { let mut tasks = state.batch_tasks.lock().unwrap(); if let Some(task) = tasks.get_mut(&task_id) { task.status = TaskStatus::Completed; task.progress = 100.0; task.message = "转换完成".to_string(); } } emit_progress(&app, &task_id, &state).await; } /// 转换单个文件 async fn convert_single_file( ffmpeg_path: &Path, input: &InputFile, output: &str, template: &ConversionTemplate, ) -> Result<(), String> { let mut args = vec!["-i".to_string(), input.path.clone(), "-y".to_string()]; // 根据模板参数构建 FFmpeg 命令 match template.file_type { FileType::Video => { // 视频编码器 if let Some(codec) = &template.params.video_codec { args.extend(["-c:v".to_string(), codec.clone()]); } else { // 默认使用 copy 以保留原始视频流 args.extend(["-c:v".to_string(), "copy".to_string()]); } // 音频编码器 if let Some(codec) = &template.params.audio_codec { args.extend(["-c:a".to_string(), codec.clone()]); } else { // 默认使用 copy 以保留原始音频流 args.extend(["-c:a".to_string(), "copy".to_string()]); } // 分辨率 if let Some(resolution) = &template.params.resolution { args.extend(["-s".to_string(), resolution.clone()]); // 如果设置了分辨率,需要重新编码,移除 copy if let Some(idx) = args.iter().position(|x| x == "-c:v") { if args.get(idx + 1) == Some(&"copy".to_string()) { args.remove(idx + 1); args.remove(idx); } } } // 视频比特率 if let Some(bitrate) = &template.params.bitrate { args.extend(["-b:v".to_string(), bitrate.clone()]); // 如果设置了比特率,需要重新编码,移除 copy if let Some(idx) = args.iter().position(|x| x == "-c:v") { if args.get(idx + 1) == Some(&"copy".to_string()) { args.remove(idx + 1); args.remove(idx); } } } // 音频比特率 if let Some(audio_bitrate) = &template.params.audio_bitrate { args.extend(["-b:a".to_string(), audio_bitrate.clone()]); // 如果设置了音频比特率,需要重新编码,移除 copy if let Some(idx) = args.iter().position(|x| x == "-c:a") { if args.get(idx + 1) == Some(&"copy".to_string()) { args.remove(idx + 1); args.remove(idx); } } } // 帧率 if let Some(fps) = &template.params.frame_rate { args.extend(["-r".to_string(), fps.clone()]); // 如果设置了帧率,需要重新编码,移除 copy if let Some(idx) = args.iter().position(|x| x == "-c:v") { if args.get(idx + 1) == Some(&"copy".to_string()) { args.remove(idx + 1); args.remove(idx); } } } } FileType::Audio => { // 音频编码器 if let Some(codec) = &template.params.audio_codec { args.extend(["-c:a".to_string(), codec.clone()]); } else { // 默认使用 copy args.extend(["-c:a".to_string(), "copy".to_string()]); } // 音频比特率 if let Some(audio_bitrate) = &template.params.audio_bitrate { args.extend(["-b:a".to_string(), audio_bitrate.clone()]); // 移除 copy 以应用比特率设置 if let Some(idx) = args.iter().position(|x| x == "-c:a") { if args.get(idx + 1) == Some(&"copy".to_string()) { args.remove(idx + 1); args.remove(idx); } } } // 采样率 if let Some(sample_rate) = &template.params.sample_rate { args.extend(["-ar".to_string(), sample_rate.clone()]); // 移除 copy 以应用采样率设置 if let Some(idx) = args.iter().position(|x| x == "-c:a") { if args.get(idx + 1) == Some(&"copy".to_string()) { args.remove(idx + 1); args.remove(idx); } } } // 声道数 if let Some(channels) = &template.params.channels { args.extend(["-ac".to_string(), channels.clone()]); // 移除 copy 以应用声道设置 if let Some(idx) = args.iter().position(|x| x == "-c:a") { if args.get(idx + 1) == Some(&"copy".to_string()) { args.remove(idx + 1); args.remove(idx); } } } } FileType::Image => { // 图片质量 if let Some(quality) = template.params.quality { args.extend(["-q:v".to_string(), quality.to_string()]); } } _ => {} } args.push(output.to_string()); // 首先检查 FFmpeg 是否可用 let ffmpeg_cmd = if ffmpeg_path.exists() { ffmpeg_path.to_string_lossy().to_string() } else { // 尝试使用系统 PATH 中的 ffmpeg "ffmpeg".to_string() }; println!("使用 FFmpeg 命令: {}", ffmpeg_cmd); println!("FFmpeg 参数: {:?}", args); let output_result = Command::new(&ffmpeg_cmd) .args(&args) .output() .await .map_err(|e| format!("执行失败: {} (命令: {})", e, ffmpeg_cmd))?; if output_result.status.success() { println!("转换成功: {} -> {}", input.path, output); Ok(()) } else { let stderr = String::from_utf8_lossy(&output_result.stderr); let stdout = String::from_utf8_lossy(&output_result.stdout); Err(format!("转换失败: stderr={}, stdout={}", stderr, stdout)) } } /// 更新文件状态 fn update_file_status(task_id: &str, file_id: &str, status: TaskStatus, progress: f64, state: &AppState) { let mut tasks = state.batch_tasks.lock().unwrap(); if let Some(task) = tasks.get_mut(task_id) { if let Some(file) = task.files.iter_mut().find(|f| f.id == file_id) { file.status = status; file.progress = progress; } } } /// 更新批量任务整体进度 async fn update_batch_progress(task_id: &str, state: &AppState) { let mut tasks = state.batch_tasks.lock().unwrap(); if let Some(task) = tasks.get_mut(task_id) { let total = task.files.len() as f64; let completed: f64 = task.files.iter().map(|f| f.progress).sum(); task.progress = completed / total; task.message = format!("转换中... {:.1}%", task.progress); } } /// 发送进度事件 async fn emit_progress(app: &AppHandle, task_id: &str, state: &AppState) { let task = { let tasks = state.batch_tasks.lock().unwrap(); tasks.get(task_id).cloned() }; if let Some(task) = task { let _ = app.emit("batch-conversion-progress", &task); } } /// 暂停/继续任务 #[tauri::command] fn pause_task(task_id: String, state: tauri::State) -> Result<(), String> { let mut tasks = state.batch_tasks.lock().unwrap(); if let Some(task) = tasks.get_mut(&task_id) { task.status = TaskStatus::Paused; task.message = "已暂停".to_string(); } Ok(()) } /// 恢复任务 #[tauri::command] fn resume_task(task_id: String, state: tauri::State) -> Result<(), String> { let mut tasks = state.batch_tasks.lock().unwrap(); if let Some(task) = tasks.get_mut(&task_id) { task.status = TaskStatus::Converting; task.message = "转换中...".to_string(); } Ok(()) } /// 取消任务 #[tauri::command] fn cancel_task(task_id: String, state: tauri::State) -> Result<(), String> { let mut tasks = state.batch_tasks.lock().unwrap(); tasks.remove(&task_id); Ok(()) } /// 获取任务状态 #[tauri::command] fn get_task_status(task_id: String, state: tauri::State) -> Option { let tasks = state.batch_tasks.lock().unwrap(); tasks.get(&task_id).cloned() } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_http::init()) .manage(AppState::default()) .invoke_handler(tauri::generate_handler![ check_ffmpeg_status, install_ffmpeg, select_files, select_output_folder, get_supported_formats, get_default_templates, get_all_templates, save_template, delete_template, analyze_files, start_batch_conversion, pause_task, resume_task, cancel_task, get_task_status, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } fn main() { run(); }