Files
Format-Converter/src-tauri/src/main.rs
2026-02-06 12:39:11 +06:00

1682 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<String>,
pub audio_codec: Option<String>,
pub resolution: Option<String>,
pub bitrate: Option<String>,
pub frame_rate: Option<String>,
pub audio_bitrate: Option<String>,
pub sample_rate: Option<String>,
pub channels: Option<String>,
pub duration: Option<String>,
}
/// 输入文件信息
#[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<String>, // base64 thumbnail
pub codec_info: Option<FileCodecInfo>, // 编码参数信息
}
/// 转换参数模板
#[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<String>,
pub audio_codec: Option<String>,
pub resolution: Option<String>,
pub bitrate: Option<String>,
pub frame_rate: Option<String>,
pub audio_bitrate: Option<String>,
pub sample_rate: Option<String>,
pub channels: Option<String>,
pub quality: Option<i32>, // 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<BatchFileTask>,
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<String>,
pub audio_codecs: Vec<String>,
}
// ============ 全局状态 ============
type BatchTasks = Arc<Mutex<HashMap<String, BatchTask>>>;
type Templates = Arc<Mutex<Vec<ConversionTemplate>>>;
#[derive(Default, Clone)]
struct AppState {
batch_tasks: BatchTasks,
templates: Templates,
}
// ============ 命令实现 ============
/// 自定义文件选择对话框
#[tauri::command]
async fn select_files(_app: AppHandle) -> Result<Vec<String>, 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<String> = 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<Option<String>, 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>), 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<FormatPreset> {
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<ConversionTemplate> {
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<AppState>) -> 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<AppState>) -> 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<AppState>) -> Vec<ConversionTemplate> {
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<String>) -> Result<Vec<InputFile>, 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<FileCodecInfo> {
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::<u64>() {
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::<f64>(), parts[1].parse::<f64>()) {
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::<u64>() {
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::<u32>() {
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::<f64>() {
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<String, String> {
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<String, String> {
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::<Vec<u8>, 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<String, String> {
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<u8>), 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<String, String> {
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<String, String> {
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<InputFile>,
template: ConversionTemplate,
output_folder: Option<String>,
state: tauri::State<'_, AppState>,
) -> Result<(), String> {
// 创建批量任务
let batch_files: Vec<BatchFileTask> = 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<AppState>) -> 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<AppState>) -> 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<AppState>) -> 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<AppState>) -> Option<BatchTask> {
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();
}