diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 7394dc2..7525695 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -40,6 +40,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "chrono"] } tracing-appender = "0.2" reqwest = { version = "0.11", features = ["json", "multipart"] } +image = { version = "0.24", features = ["jpeg", "png", "webp", "bmp", "tiff", "gif"] } toml = "0.8" tree-sitter = "0.20" tree-sitter-json = "0.20" diff --git a/apps/desktop/src-tauri/src/infrastructure/image_editing_service.rs b/apps/desktop/src-tauri/src/infrastructure/image_editing_service.rs index de5e378..1b6d0ba 100644 --- a/apps/desktop/src-tauri/src/infrastructure/image_editing_service.rs +++ b/apps/desktop/src-tauri/src/infrastructure/image_editing_service.rs @@ -6,6 +6,7 @@ use std::fs; use base64::prelude::*; use uuid::Uuid; use tokio::time::sleep; +use image::{ImageFormat, DynamicImage}; use crate::data::models::image_editing::{ ImageEditingConfig, ImageEditingRequest, ImageEditingResponse, ImageEditingTask, @@ -34,6 +35,142 @@ impl ImageEditingService { } } + /// 支持的图片格式列表 + const SUPPORTED_FORMATS: &'static [&'static str] = &[ + "jpg", "jpeg", "png", "webp", "bmp", "tiff", "gif", "ico", "tga" + ]; + + /// 原生支持的格式(无需转换) + const NATIVE_FORMATS: &'static [&'static str] = &["jpg", "jpeg", "png"]; + + /// 检查文件是否为支持的图片格式 + fn is_supported_image_format(file_path: &Path) -> bool { + if let Some(extension) = file_path.extension() { + let ext = extension.to_string_lossy().to_lowercase(); + Self::SUPPORTED_FORMATS.contains(&ext.as_str()) + } else { + false + } + } + + /// 检查文件是否需要格式转换 + fn needs_format_conversion(file_path: &Path) -> bool { + if let Some(extension) = file_path.extension() { + let ext = extension.to_string_lossy().to_lowercase(); + Self::SUPPORTED_FORMATS.contains(&ext.as_str()) && + !Self::NATIVE_FORMATS.contains(&ext.as_str()) + } else { + false + } + } + + /// 获取图片格式 + fn get_image_format(file_path: &Path) -> Option { + if let Some(extension) = file_path.extension() { + let ext = extension.to_string_lossy().to_lowercase(); + match ext.as_str() { + "jpg" | "jpeg" => Some(ImageFormat::Jpeg), + "png" => Some(ImageFormat::Png), + "webp" => Some(ImageFormat::WebP), + "bmp" => Some(ImageFormat::Bmp), + "tiff" | "tif" => Some(ImageFormat::Tiff), + "gif" => Some(ImageFormat::Gif), + "ico" => Some(ImageFormat::Ico), + "tga" => Some(ImageFormat::Tga), + _ => None, + } + } else { + None + } + } + + /// 确定转换目标格式 + fn determine_target_format(source_format: ImageFormat) -> ImageFormat { + match source_format { + // 有损格式转为JPEG + ImageFormat::WebP | ImageFormat::Jpeg => ImageFormat::Jpeg, + // 无损格式转为PNG + ImageFormat::Png | ImageFormat::Bmp | ImageFormat::Tiff | + ImageFormat::Gif | ImageFormat::Ico | ImageFormat::Tga => ImageFormat::Png, + // 其他格式默认转为PNG + _ => ImageFormat::Png, + } + } + + /// 获取格式对应的文件扩展名 + fn get_format_extension(format: ImageFormat) -> &'static str { + match format { + ImageFormat::Jpeg => "jpg", + ImageFormat::Png => "png", + _ => "png", // 默认使用PNG + } + } + + /// 转换图片格式 + async fn convert_image_format( + input_path: &Path, + output_dir: &Path, + ) -> Result { + println!("🔄 开始转换图片格式: {}", input_path.display()); + + // 检查输入文件是否存在 + if !input_path.exists() { + return Err(anyhow!("输入文件不存在: {}", input_path.display())); + } + + // 获取源格式 + let source_format = Self::get_image_format(input_path) + .ok_or_else(|| anyhow!("不支持的图片格式: {}", input_path.display()))?; + + // 确定目标格式 + let target_format = Self::determine_target_format(source_format); + let target_extension = Self::get_format_extension(target_format); + + // 生成输出文件路径 + let file_stem = input_path.file_stem() + .ok_or_else(|| anyhow!("无法获取文件名"))?; + let output_filename = format!("{}_converted.{}", + file_stem.to_string_lossy(), target_extension); + let output_path = output_dir.join(output_filename); + + // 确保输出目录存在 + tokio::fs::create_dir_all(output_dir).await + .map_err(|e| anyhow!("创建输出目录失败: {}", e))?; + + // 读取并转换图片 + let img = image::open(input_path) + .map_err(|e| anyhow!("读取图片失败: {}", e))?; + + // 保存转换后的图片 + img.save_with_format(&output_path, target_format) + .map_err(|e| anyhow!("保存转换后的图片失败: {}", e))?; + + println!("✅ 图片格式转换完成: {} -> {}", + input_path.display(), output_path.display()); + + Ok(output_path) + } + + /// 获取或创建临时转换目录 + async fn get_temp_conversion_dir() -> Result { + let temp_dir = std::env::temp_dir().join("mixvideo_image_conversion"); + tokio::fs::create_dir_all(&temp_dir).await + .map_err(|e| anyhow!("创建临时转换目录失败: {}", e))?; + Ok(temp_dir) + } + + /// 清理临时转换文件 + async fn cleanup_converted_files(temp_files: &[PathBuf]) -> Result<()> { + for file_path in temp_files { + if file_path.exists() { + if let Err(e) = tokio::fs::remove_file(file_path).await { + println!("⚠️ 清理临时文件失败: {} - {}", file_path.display(), e); + } + } + } + Ok(()) + } + /// 使用自定义配置创建图像编辑服务 pub fn with_config(config: ImageEditingConfig) -> Self { let client = Client::builder() @@ -49,7 +186,7 @@ impl ImageEditingService { self.config.api_key = api_key; } - /// 验证图像文件格式 + /// 验证图像文件格式(用于Base64编码,只接受原生支持的格式) fn validate_image_format(file_path: &Path) -> Result<()> { let extension = file_path .extension() @@ -59,7 +196,7 @@ impl ImageEditingService { match extension.as_str() { "jpg" | "jpeg" | "png" => Ok(()), - _ => Err(anyhow!("不支持的图像格式: {},仅支持 JPEG 和 PNG", extension)), + _ => Err(anyhow!("文件格式错误: {},此时应该已经转换为 JPEG 或 PNG 格式", extension)), } } @@ -221,7 +358,7 @@ impl ImageEditingService { Ok(response) } - /// 获取文件夹中的所有图像文件 + /// 获取文件夹中的所有图像文件(包括需要转换的格式) fn get_image_files(folder_path: &Path) -> Result> { let mut image_files = Vec::new(); @@ -240,13 +377,8 @@ impl ImageEditingService { let entry = entry.map_err(|e| anyhow!("读取文件夹条目失败: {}", e))?; let path = entry.path(); - if path.is_file() { - if let Some(extension) = path.extension() { - let ext = extension.to_string_lossy().to_lowercase(); - if matches!(ext.as_str(), "jpg" | "jpeg" | "png") { - image_files.push(path); - } - } + if path.is_file() && Self::is_supported_image_format(&path) { + image_files.push(path); } } @@ -255,6 +387,21 @@ impl ImageEditingService { } image_files.sort(); + + // 统计不同格式的文件数量 + let mut format_counts = std::collections::HashMap::new(); + for file in &image_files { + if let Some(ext) = file.extension() { + let ext_str = ext.to_string_lossy().to_lowercase(); + *format_counts.entry(ext_str).or_insert(0) += 1; + } + } + + println!("📊 发现的图片格式统计:"); + for (format, count) in format_counts { + println!(" {} 格式: {} 个文件", format.to_uppercase(), count); + } + Ok(image_files) } @@ -376,6 +523,149 @@ impl ImageEditingService { Ok(batch_task) } + /// 批量编辑图像(带任务状态更新回调) + pub async fn edit_batch_images_with_callback( + &self, + input_folder: &Path, + output_folder: &Path, + prompt: &str, + params: &ImageEditingParams, + task_update_callback: Option>, + ) -> Result { + println!("🎨 开始批量编辑图像(带回调)"); + println!("输入文件夹: {}", input_folder.display()); + println!("输出文件夹: {}", output_folder.display()); + println!("提示词: {}", prompt); + + // 获取所有图像文件 + let image_files = Self::get_image_files(input_folder)?; + println!("找到 {} 个图像文件", image_files.len()); + + // 创建输出文件夹 + tokio::fs::create_dir_all(output_folder).await + .map_err(|e| anyhow!("创建输出文件夹失败: {}", e))?; + + // 创建批量任务 + let task_id = Uuid::new_v4().to_string(); + let mut batch_task = BatchImageEditingTask::new( + task_id, + input_folder.to_string_lossy().to_string(), + output_folder.to_string_lossy().to_string(), + prompt.to_string(), + ImageEditingRequest { + model: self.config.model_id.clone(), + prompt: prompt.to_string(), + image: String::new(), // 每个任务单独设置 + response_format: Some(params.response_format.clone()), + size: Some(params.size.clone()), + seed: Some(params.seed), + guidance_scale: Some(params.guidance_scale), + watermark: Some(params.watermark), + }, + ); + + // 为每个图像文件创建单独的任务 + for (_index, input_file) in image_files.iter().enumerate() { + let _output_file = Self::generate_output_filename(input_file, output_folder); + let individual_task = ImageEditingTask::new( + Uuid::new_v4().to_string(), + input_file.to_string_lossy().to_string(), + prompt.to_string(), + batch_task.request_params.clone(), + ); + batch_task.add_task(individual_task); + } + + batch_task.status = ImageEditingTaskStatus::Processing; + + // 初始回调,通知任务已开始 + if let Some(ref callback) = task_update_callback { + callback(batch_task.clone()); + } + + // 创建临时转换目录 + let temp_conversion_dir = Self::get_temp_conversion_dir().await?; + let mut converted_files = Vec::new(); + + // 处理每个图像 + for (index, input_file) in image_files.iter().enumerate() { + let output_file = Self::generate_output_filename(input_file, output_folder); + + // 更新任务状态 + if let Some(task) = batch_task.individual_tasks.get_mut(index) { + task.set_processing(); + } + + // 检查是否需要格式转换 + let actual_input_file = if Self::needs_format_conversion(input_file) { + println!("🔄 需要转换格式: {}", input_file.display()); + match Self::convert_image_format(input_file, &temp_conversion_dir).await { + Ok(converted_path) => { + converted_files.push(converted_path.clone()); + converted_path + } + Err(e) => { + println!("❌ 格式转换失败: {} - {}", input_file.display(), e); + if let Some(task) = batch_task.individual_tasks.get_mut(index) { + task.set_failed(format!("格式转换失败: {}", e)); + } + batch_task.update_progress(); + if let Some(ref callback) = task_update_callback { + callback(batch_task.clone()); + } + continue; + } + } + } else { + input_file.clone() + }; + + // 处理单个图像 + match self.edit_single_image(&actual_input_file, &output_file, prompt, params).await { + Ok(response) => { + if let Some(task) = batch_task.individual_tasks.get_mut(index) { + task.set_completed(output_file.to_string_lossy().to_string(), response); + } + println!("✅ 完成: {}", input_file.file_name().unwrap_or_default().to_string_lossy()); + } + Err(e) => { + if let Some(task) = batch_task.individual_tasks.get_mut(index) { + task.set_failed(e.to_string()); + } + println!("❌ 失败: {} - {}", input_file.file_name().unwrap_or_default().to_string_lossy(), e); + } + } + + // 更新批量任务进度 + batch_task.update_progress(); + + // 每处理完一个图像就回调更新状态 + if let Some(ref callback) = task_update_callback { + callback(batch_task.clone()); + } + } + + // 清理临时转换文件 + if !converted_files.is_empty() { + println!("🧹 清理 {} 个临时转换文件", converted_files.len()); + if let Err(e) = Self::cleanup_converted_files(&converted_files).await { + println!("⚠️ 清理临时文件时出错: {}", e); + } + } + + println!("🎉 批量编辑完成!"); + println!("总计: {} 个文件", batch_task.total_images); + println!("成功: {} 个文件", batch_task.successful_images); + println!("失败: {} 个文件", batch_task.failed_images); + + // 最终回调 + if let Some(ref callback) = task_update_callback { + callback(batch_task.clone()); + } + + Ok(batch_task) + } + /// 创建编辑任务(用于异步处理) pub async fn create_edit_task( &self, diff --git a/apps/desktop/src-tauri/src/presentation/commands/image_editing_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/image_editing_commands.rs index d98a88d..1f3b594 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/image_editing_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/image_editing_commands.rs @@ -1,4 +1,4 @@ -use tauri::State; +use tauri::{State, Manager, AppHandle, Emitter}; use std::path::Path; use std::sync::{Arc, Mutex}; use std::collections::HashMap; @@ -168,6 +168,7 @@ pub async fn get_image_editing_task_status( /// 批量编辑图像 #[tauri::command] pub async fn edit_batch_images( + app: AppHandle, state: State<'_, ImageEditingState>, input_folder: String, output_folder: String, @@ -177,25 +178,112 @@ pub async fn edit_batch_images( let input_folder = Path::new(&input_folder); let output_folder = Path::new(&output_folder); + // 生成任务ID + let task_id = Uuid::new_v4().to_string(); + + // 立即创建批量任务并存储到状态中 + let mut batch_task = BatchImageEditingTask::new( + task_id.clone(), + input_folder.to_string_lossy().to_string(), + output_folder.to_string_lossy().to_string(), + prompt.clone(), + crate::data::models::image_editing::ImageEditingRequest { + model: "doubao-seededit-3-0-i2i-250628".to_string(), + prompt: prompt.clone(), + image: String::new(), + response_format: Some(params.response_format.clone()), + size: Some(params.size.clone()), + seed: Some(params.seed), + guidance_scale: Some(params.guidance_scale), + watermark: Some(params.watermark), + }, + ); + + // 设置任务状态为处理中 + batch_task.status = ImageEditingTaskStatus::Processing; + + // 立即存储批量任务,这样前端就能获取到 + { + let mut batch_tasks = state.batch_tasks.lock().map_err(|e| format!("获取任务存储失败: {}", e))?; + batch_tasks.insert(task_id.clone(), batch_task.clone()); + } + // 克隆服务以避免跨await持有锁 let service = { let service_guard = state.service.lock().map_err(|e| format!("获取服务失败: {}", e))?; service_guard.clone() }; - match service.edit_batch_images(input_folder, output_folder, &prompt, ¶ms, None).await { - Ok(batch_task) => { - let task_id = batch_task.id.clone(); + // 克隆必要的数据用于异步任务 + let batch_tasks_clone = state.batch_tasks.clone(); + let task_id_clone = task_id.clone(); - // 存储批量任务 - if let Ok(mut batch_tasks) = state.batch_tasks.lock() { - batch_tasks.insert(task_id.clone(), batch_task); - } - - Ok(task_id) + // 创建任务状态更新回调 + let app_clone = app.clone(); + let update_callback = Box::new(move |updated_task: BatchImageEditingTask| { + if let Ok(mut batch_tasks) = batch_tasks_clone.lock() { + batch_tasks.insert(task_id_clone.clone(), updated_task.clone()); } - Err(e) => Err(format!("批量编辑失败: {}", e)), - } + + // 发送Tauri事件通知前端 + if let Err(e) = app_clone.emit("batch-task-updated", &updated_task) { + println!("发送任务更新事件失败: {}", e); + } + }); + + // 异步执行批量处理 + let service_clone = service.clone(); + let input_folder_clone = input_folder.to_path_buf(); + let output_folder_clone = output_folder.to_path_buf(); + let prompt_clone = prompt.clone(); + let params_clone = params.clone(); + let task_id_for_spawn = task_id.clone(); + let batch_tasks_for_spawn = state.batch_tasks.clone(); + let app_for_spawn = app.clone(); + + tokio::spawn(async move { + match service_clone.edit_batch_images_with_callback( + &input_folder_clone, + &output_folder_clone, + &prompt_clone, + ¶ms_clone, + Some(update_callback), + ).await { + Ok(final_task) => { + // 存储最终任务状态 + if let Ok(mut batch_tasks) = batch_tasks_for_spawn.lock() { + batch_tasks.insert(task_id_for_spawn.clone(), final_task.clone()); + } + + // 发送最终完成事件 + if let Err(e) = app_for_spawn.emit("batch-task-completed", &final_task) { + println!("发送任务完成事件失败: {}", e); + } + } + Err(e) => { + // 处理失败,更新任务状态 + let mut failed_task = None; + if let Ok(mut batch_tasks) = batch_tasks_for_spawn.lock() { + if let Some(task) = batch_tasks.get_mut(&task_id_for_spawn) { + task.status = ImageEditingTaskStatus::Failed; + task.updated_at = chrono::Utc::now(); + failed_task = Some(task.clone()); + } + } + + // 发送失败事件 + if let Some(task) = failed_task { + if let Err(e) = app_for_spawn.emit("batch-task-failed", &task) { + println!("发送任务失败事件失败: {}", e); + } + } + + println!("批量编辑失败: {}", e); + } + } + }); + + Ok(task_id) } /// 创建批量编辑任务 diff --git a/apps/desktop/src/pages/tools/ImageEditingTool.tsx b/apps/desktop/src/pages/tools/ImageEditingTool.tsx index fff9b3d..253e3fb 100644 --- a/apps/desktop/src/pages/tools/ImageEditingTool.tsx +++ b/apps/desktop/src/pages/tools/ImageEditingTool.tsx @@ -15,6 +15,7 @@ import { Save } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; import { open } from '@tauri-apps/plugin-dialog'; import { ImageEditingTask, @@ -24,7 +25,6 @@ import { ImageEditingTaskStatus, DEFAULT_IMAGE_EDITING_PARAMS, DEFAULT_IMAGE_EDITING_CONFIG, - PRESET_PROMPTS, TASK_STATUS_CONFIG, IMAGE_FILE_CONFIG, } from '../../types/imageEditing'; @@ -56,14 +56,73 @@ const ImageEditingTool: React.FC = () => { const [tasks, setTasks] = useState([]); const [batchTasks, setBatchTasks] = useState([]); const [showTasks, setShowTasks] = useState(false); - + // 配置状态 const [showConfig, setShowConfig] = useState(false); const [apiKeyInput, setApiKeyInput] = useState(''); + // 注意:现在使用事件驱动更新,不再需要定时刷新状态 + // 初始化 useEffect(() => { loadTasks(); + + // 设置Tauri事件监听 + const setupEventListeners = async () => { + try { + // 监听批量任务更新事件 + const unlistenUpdate = await listen('batch-task-updated', (event) => { + console.log('收到任务更新事件:', event.payload); + setBatchTasks(prev => { + const updated = [...prev]; + const index = updated.findIndex(task => task.id === event.payload.id); + if (index >= 0) { + updated[index] = event.payload; + } else { + updated.push(event.payload); + } + return updated; + }); + }); + + // 监听批量任务完成事件 + const unlistenComplete = await listen('batch-task-completed', (event) => { + console.log('收到任务完成事件:', event.payload); + setBatchTasks(prev => { + const updated = [...prev]; + const index = updated.findIndex(task => task.id === event.payload.id); + if (index >= 0) { + updated[index] = event.payload; + } + return updated; + }); + }); + + // 监听批量任务失败事件 + const unlistenFailed = await listen('batch-task-failed', (event) => { + console.log('收到任务失败事件:', event.payload); + setBatchTasks(prev => { + const updated = [...prev]; + const index = updated.findIndex(task => task.id === event.payload.id); + if (index >= 0) { + updated[index] = event.payload; + } + return updated; + }); + }); + + // 返回清理函数 + return () => { + unlistenUpdate(); + unlistenComplete(); + unlistenFailed(); + }; + } catch (error) { + console.error('设置事件监听失败:', error); + } + }; + + setupEventListeners(); }, []); // 加载任务列表 @@ -80,6 +139,8 @@ const ImageEditingTool: React.FC = () => { } }, []); + // 注意:现在使用事件驱动的实时更新,不再需要定时刷新和状态检查 + // 设置API密钥 const handleSetApiKey = useCallback(async () => { if (!apiKeyInput.trim()) { @@ -236,37 +297,12 @@ const ImageEditingTool: React.FC = () => { params: params, }); - // 创建任务对象用于UI显示 - const newTask: BatchImageEditingTask = { - id: taskId, - input_folder_path: inputFolder, - output_folder_path: outputFolder, - prompt: batchPrompt, - total_images: 0, // 将由后端更新 - processed_images: 0, - successful_images: 0, - failed_images: 0, - status: ImageEditingTaskStatus.Processing, - progress: 0, - individual_tasks: [], - request_params: { - model: 'doubao-seededit-3-0-i2i-250628', - prompt: batchPrompt, - image: '', - response_format: 'url', - size: 'adaptive', - seed: params.seed, - guidance_scale: params.guidance_scale, - watermark: params.watermark, - }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }; - - setBatchTask(newTask); + console.log('批量处理任务已创建,任务ID:', taskId); setIsProcessing(false); alert('批量处理任务已启动!'); - loadTasks(); // 刷新任务列表 + + // 立即刷新任务列表以显示新创建的任务 + loadTasks(); } catch (error) { console.error('批量编辑失败:', error); @@ -275,14 +311,7 @@ const ImageEditingTool: React.FC = () => { } }, [inputFolder, outputFolder, batchPrompt, params, config.api_key, loadTasks]); - // 应用预设提示词 - const handleApplyPresetPrompt = useCallback((presetPrompt: string) => { - if (activeTab === 'single') { - setPrompt(presetPrompt); - } else { - setBatchPrompt(presetPrompt); - } - }, [activeTab]); + // 获取状态图标 const getStatusIcon = (status: ImageEditingTaskStatus) => { @@ -578,31 +607,80 @@ const ImageEditingTool: React.FC = () => { )} - {/* 右侧:预设提示词和参数配置 */} + {/* 右侧:参数配置和任务列表 */}
- {/* 预设提示词 */} + {/* 任务列表 */}
-

预设提示词

-
- {PRESET_PROMPTS.map((category) => ( -
-

- {category.category} -

-
- {category.prompts.map((presetPrompt) => ( - - ))} +

最近任务

+ +
+ {/* 显示最近的单个任务 */} + {tasks.slice(0, 3).map((task) => ( +
+
+ {getStatusIcon(task.status)} + + {TASK_STATUS_CONFIG[task.status].label} +
+
+ {task.prompt.substring(0, 40)}... +
+
+ {new Date(task.created_at).toLocaleString()} +
+ {task.progress > 0 && ( +
+
+
+ )}
))} + + {/* 显示最近的批量任务 */} + {batchTasks.slice(0, 2).map((task) => ( +
+
+ {getStatusIcon(task.status)} + + 批量 - {TASK_STATUS_CONFIG[task.status].label} + +
+
+ {task.prompt.substring(0, 40)}... +
+
+ {task.processed_images} / {task.total_images} 已处理 +
+ {task.progress > 0 && ( +
+
+
+ )} +
+ ))} + + {tasks.length === 0 && batchTasks.length === 0 && ( +
+ 暂无任务 +
+ )}
+ + {(tasks.length > 3 || batchTasks.length > 2) && ( + + )}
{/* 参数配置 */} @@ -669,6 +747,8 @@ const ImageEditingTool: React.FC = () => {
+ + {/* 将任务管理里的任务列表放到这里 */}