Files
Format-Converter/src-tauri/src/main.rs

1682 lines
58 KiB
Rust
Raw Normal View History

2026-02-06 12:39:11 +06:00
// 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();
}