1682 lines
58 KiB
Rust
1682 lines
58 KiB
Rust
// 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();
|
||
} |