From 05d97094203f58d48d7fdb48b666e274db1a8255 Mon Sep 17 00:00:00 2001 From: imeepos Date: Thu, 31 Jul 2025 15:28:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9B=BE=E5=83=8F?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 基于火山云SeedEdit 3.0 API的智能图像编辑工具 - 支持单张图片编辑和批量处理功能 - 提供丰富的预设提示词和参数配置 - 实现任务管理和进度监控 - 集成到便捷工具系统 功能特性: - 单张图片编辑:选择图片、输入提示词、实时编辑 - 批量处理:文件夹批量处理、进度监控、结果统计 - 参数配置:引导强度、随机种子、水印设置等 - 预设提示词:风格转换、场景变换、色彩调整、特效处理 - 任务管理:状态监控、历史记录、清理功能 技术实现: - Rust后端:图像编辑服务、API调用、错误处理 - React前端:响应式界面、实时更新、用户体验优化 - 类型安全:完整的TypeScript类型定义 - 模块化设计:可扩展的架构和组件复用 --- .../src/data/models/image_editing.rs | 290 ++++++ apps/desktop/src-tauri/src/data/models/mod.rs | 1 + .../infrastructure/image_editing_service.rs | 441 +++++++++ .../src-tauri/src/infrastructure/mod.rs | 1 + apps/desktop/src-tauri/src/lib.rs | 17 +- .../commands/image_editing_commands.rs | 305 ++++++ .../src/presentation/commands/mod.rs | 1 + apps/desktop/src/App.tsx | 2 + apps/desktop/src/data/tools.ts | 18 +- .../src/pages/tools/ImageEditingTool.tsx | 884 ++++++++++++++++++ apps/desktop/src/types/imageEditing.ts | 300 ++++++ docs/image-editing-tool-guide.md | 170 ++++ 12 files changed, 2428 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src-tauri/src/data/models/image_editing.rs create mode 100644 apps/desktop/src-tauri/src/infrastructure/image_editing_service.rs create mode 100644 apps/desktop/src-tauri/src/presentation/commands/image_editing_commands.rs create mode 100644 apps/desktop/src/pages/tools/ImageEditingTool.tsx create mode 100644 apps/desktop/src/types/imageEditing.ts create mode 100644 docs/image-editing-tool-guide.md diff --git a/apps/desktop/src-tauri/src/data/models/image_editing.rs b/apps/desktop/src-tauri/src/data/models/image_editing.rs new file mode 100644 index 0000000..a514f30 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/image_editing.rs @@ -0,0 +1,290 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; + +/// 图像编辑API配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageEditingConfig { + pub api_url: String, + pub api_key: String, + pub model_id: String, + pub timeout: u64, + pub max_retries: u32, + pub retry_delay: u64, +} + +impl Default for ImageEditingConfig { + fn default() -> Self { + Self { + api_url: "https://ark.cn-beijing.volces.com/api/v3/images/generations".to_string(), + api_key: String::new(), // 需要用户配置 + model_id: "doubao-seededit-3-0-i2i-250628".to_string(), + timeout: 120, + max_retries: 3, + retry_delay: 2, + } + } +} + +/// 图像编辑请求参数 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageEditingRequest { + pub model: String, + pub prompt: String, + pub image: String, // Base64编码或URL + #[serde(skip_serializing_if = "Option::is_none")] + pub response_format: Option, // "url" 或 "b64_json" + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, // "adaptive" + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub guidance_scale: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub watermark: Option, +} + +impl Default for ImageEditingRequest { + fn default() -> Self { + Self { + model: "doubao-seededit-3-0-i2i-250628".to_string(), + prompt: String::new(), + image: String::new(), + response_format: Some("url".to_string()), + size: Some("adaptive".to_string()), + seed: Some(-1), + guidance_scale: Some(5.5), + watermark: Some(true), + } + } +} + +/// 图像编辑响应数据 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageEditingResponseData { + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub b64_json: Option, +} + +/// 图像编辑使用量信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageEditingUsage { + pub generated_images: u32, + pub output_tokens: u32, + pub total_tokens: u32, +} + +/// 图像编辑错误信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageEditingError { + pub code: String, + pub message: String, +} + +/// 图像编辑API响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageEditingResponse { + pub model: String, + pub created: u64, + pub data: Vec, + pub usage: ImageEditingUsage, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// 图像编辑任务状态 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ImageEditingTaskStatus { + Pending, + Processing, + Completed, + Failed, + Cancelled, +} + +/// 图像编辑任务 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageEditingTask { + pub id: String, + pub input_image_path: String, + pub output_image_path: Option, + pub prompt: String, + pub status: ImageEditingTaskStatus, + pub progress: f32, // 0.0 - 1.0 + pub error_message: Option, + pub request_params: ImageEditingRequest, + pub response_data: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub completed_at: Option>, +} + +impl ImageEditingTask { + pub fn new( + id: String, + input_image_path: String, + prompt: String, + request_params: ImageEditingRequest, + ) -> Self { + let now = Utc::now(); + Self { + id, + input_image_path, + output_image_path: None, + prompt, + status: ImageEditingTaskStatus::Pending, + progress: 0.0, + error_message: None, + request_params, + response_data: None, + created_at: now, + updated_at: now, + completed_at: None, + } + } + + pub fn set_processing(&mut self) { + self.status = ImageEditingTaskStatus::Processing; + self.progress = 0.1; + self.updated_at = Utc::now(); + } + + pub fn set_completed(&mut self, output_path: String, response: ImageEditingResponse) { + self.status = ImageEditingTaskStatus::Completed; + self.progress = 1.0; + self.output_image_path = Some(output_path); + self.response_data = Some(response); + let now = Utc::now(); + self.updated_at = now; + self.completed_at = Some(now); + } + + pub fn set_failed(&mut self, error_message: String) { + self.status = ImageEditingTaskStatus::Failed; + self.error_message = Some(error_message); + self.updated_at = Utc::now(); + } + + pub fn update_progress(&mut self, progress: f32) { + self.progress = progress.clamp(0.0, 1.0); + self.updated_at = Utc::now(); + } +} + +/// 批量图像编辑任务 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchImageEditingTask { + pub id: String, + pub input_folder_path: String, + pub output_folder_path: String, + pub prompt: String, + pub total_images: u32, + pub processed_images: u32, + pub successful_images: u32, + pub failed_images: u32, + pub status: ImageEditingTaskStatus, + pub progress: f32, // 0.0 - 1.0 + pub individual_tasks: Vec, + pub request_params: ImageEditingRequest, + pub created_at: DateTime, + pub updated_at: DateTime, + pub completed_at: Option>, +} + +impl BatchImageEditingTask { + pub fn new( + id: String, + input_folder_path: String, + output_folder_path: String, + prompt: String, + request_params: ImageEditingRequest, + ) -> Self { + let now = Utc::now(); + Self { + id, + input_folder_path, + output_folder_path, + prompt, + total_images: 0, + processed_images: 0, + successful_images: 0, + failed_images: 0, + status: ImageEditingTaskStatus::Pending, + progress: 0.0, + individual_tasks: Vec::new(), + request_params, + created_at: now, + updated_at: now, + completed_at: None, + } + } + + pub fn add_task(&mut self, task: ImageEditingTask) { + self.individual_tasks.push(task); + self.total_images = self.individual_tasks.len() as u32; + self.updated_at = Utc::now(); + } + + pub fn update_progress(&mut self) { + self.processed_images = self.individual_tasks + .iter() + .filter(|task| matches!( + task.status, + ImageEditingTaskStatus::Completed | ImageEditingTaskStatus::Failed + )) + .count() as u32; + + self.successful_images = self.individual_tasks + .iter() + .filter(|task| task.status == ImageEditingTaskStatus::Completed) + .count() as u32; + + self.failed_images = self.individual_tasks + .iter() + .filter(|task| task.status == ImageEditingTaskStatus::Failed) + .count() as u32; + + if self.total_images > 0 { + self.progress = self.processed_images as f32 / self.total_images as f32; + } + + // 更新整体状态 + if self.processed_images == self.total_images { + self.status = if self.failed_images == 0 { + ImageEditingTaskStatus::Completed + } else if self.successful_images == 0 { + ImageEditingTaskStatus::Failed + } else { + ImageEditingTaskStatus::Completed // 部分成功也算完成 + }; + self.completed_at = Some(Utc::now()); + } else if self.processed_images > 0 { + self.status = ImageEditingTaskStatus::Processing; + } + + self.updated_at = Utc::now(); + } +} + +/// 图像编辑参数配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageEditingParams { + pub guidance_scale: f32, + pub seed: i32, + pub watermark: bool, + pub response_format: String, + pub size: String, +} + +impl Default for ImageEditingParams { + fn default() -> Self { + Self { + guidance_scale: 5.5, + seed: -1, + watermark: true, + response_format: "url".to_string(), + size: "adaptive".to_string(), + } + } +} diff --git a/apps/desktop/src-tauri/src/data/models/mod.rs b/apps/desktop/src-tauri/src/data/models/mod.rs index 529b1bf..84e0cd3 100644 --- a/apps/desktop/src-tauri/src/data/models/mod.rs +++ b/apps/desktop/src-tauri/src/data/models/mod.rs @@ -11,6 +11,7 @@ pub mod project_template_binding; pub mod template_matching_result; pub mod export_record; pub mod video_generation; +pub mod image_editing; pub mod conversation; pub mod outfit_search; pub mod gemini_analysis; diff --git a/apps/desktop/src-tauri/src/infrastructure/image_editing_service.rs b/apps/desktop/src-tauri/src/infrastructure/image_editing_service.rs new file mode 100644 index 0000000..0197ed7 --- /dev/null +++ b/apps/desktop/src-tauri/src/infrastructure/image_editing_service.rs @@ -0,0 +1,441 @@ +use anyhow::{anyhow, Result}; +use reqwest::Client; +use std::time::Duration; +use std::path::{Path, PathBuf}; +use std::fs; +use base64::prelude::*; +use uuid::Uuid; +use tokio::time::sleep; + +use crate::data::models::image_editing::{ + ImageEditingConfig, ImageEditingRequest, ImageEditingResponse, ImageEditingTask, + BatchImageEditingTask, ImageEditingTaskStatus, ImageEditingParams, +}; + +/// 图像编辑服务 +/// 基于火山云SeedEdit 3.0 API实现图像编辑功能 +#[derive(Clone)] +pub struct ImageEditingService { + client: Client, + config: ImageEditingConfig, +} + +impl ImageEditingService { + /// 创建新的图像编辑服务实例 + pub fn new() -> Self { + let client = Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .expect("Failed to create HTTP client"); + + Self { + client, + config: ImageEditingConfig::default(), + } + } + + /// 使用自定义配置创建图像编辑服务 + pub fn with_config(config: ImageEditingConfig) -> Self { + let client = Client::builder() + .timeout(Duration::from_secs(config.timeout)) + .build() + .expect("Failed to create HTTP client"); + + Self { client, config } + } + + /// 设置API密钥 + pub fn set_api_key(&mut self, api_key: String) { + self.config.api_key = api_key; + } + + /// 验证图像文件格式 + fn validate_image_format(file_path: &Path) -> Result<()> { + let extension = file_path + .extension() + .and_then(|ext| ext.to_str()) + .ok_or_else(|| anyhow!("无法获取文件扩展名"))? + .to_lowercase(); + + match extension.as_str() { + "jpg" | "jpeg" | "png" => Ok(()), + _ => Err(anyhow!("不支持的图像格式: {},仅支持 JPEG 和 PNG", extension)), + } + } + + /// 将图像文件转换为Base64编码 + async fn image_to_base64(file_path: &Path) -> Result { + Self::validate_image_format(file_path)?; + + let image_data = tokio::fs::read(file_path).await + .map_err(|e| anyhow!("读取图像文件失败: {}", e))?; + + // 检查文件大小(10MB限制) + if image_data.len() > 10 * 1024 * 1024 { + return Err(anyhow!("图像文件过大,超过10MB限制")); + } + + let extension = file_path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("jpeg") + .to_lowercase(); + + let mime_type = match extension.as_str() { + "png" => "image/png", + _ => "image/jpeg", + }; + + let base64_data = BASE64_STANDARD.encode(&image_data); + Ok(format!("data:{};base64,{}", mime_type, base64_data)) + } + + /// 调用图像编辑API + async fn call_api(&self, request: &ImageEditingRequest) -> Result { + if self.config.api_key.is_empty() { + return Err(anyhow!("API密钥未设置")); + } + + let mut retries = 0; + loop { + let response = self + .client + .post(&self.config.api_url) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", self.config.api_key)) + .json(request) + .send() + .await; + + match response { + Ok(resp) => { + if resp.status().is_success() { + let api_response: ImageEditingResponse = resp.json().await + .map_err(|e| anyhow!("解析API响应失败: {}", e))?; + + // 检查API响应中的错误 + if let Some(error) = &api_response.error { + return Err(anyhow!("API错误: {} - {}", error.code, error.message)); + } + + return Ok(api_response); + } else { + let status = resp.status(); + let error_text = resp.text().await.unwrap_or_else(|_| "未知错误".to_string()); + if retries >= self.config.max_retries { + return Err(anyhow!("API请求失败: HTTP {} - {}", status, error_text)); + } + } + } + Err(e) => { + if retries >= self.config.max_retries { + return Err(anyhow!("网络请求失败: {}", e)); + } + } + } + + retries += 1; + println!("API请求失败,{}秒后重试 ({}/{})", self.config.retry_delay, retries, self.config.max_retries); + sleep(Duration::from_secs(self.config.retry_delay)).await; + } + } + + /// 下载生成的图像 + async fn download_image(&self, url: &str, output_path: &Path) -> Result<()> { + let response = self.client.get(url).send().await + .map_err(|e| anyhow!("下载图像失败: {}", e))?; + + if !response.status().is_success() { + return Err(anyhow!("下载图像失败: HTTP {}", response.status())); + } + + let image_data = response.bytes().await + .map_err(|e| anyhow!("读取图像数据失败: {}", e))?; + + // 确保输出目录存在 + if let Some(parent) = output_path.parent() { + tokio::fs::create_dir_all(parent).await + .map_err(|e| anyhow!("创建输出目录失败: {}", e))?; + } + + tokio::fs::write(output_path, image_data).await + .map_err(|e| anyhow!("保存图像文件失败: {}", e))?; + + Ok(()) + } + + /// 编辑单张图像 + pub async fn edit_single_image( + &self, + input_path: &Path, + output_path: &Path, + prompt: &str, + params: &ImageEditingParams, + ) -> Result { + println!("🎨 开始编辑图像: {}", input_path.display()); + println!("提示词: {}", prompt); + + // 转换图像为Base64 + let base64_image = Self::image_to_base64(input_path).await?; + + // 构建请求 + let mut request = ImageEditingRequest::default(); + request.model = self.config.model_id.clone(); + request.prompt = prompt.to_string(); + request.image = base64_image; + request.guidance_scale = Some(params.guidance_scale); + request.seed = Some(params.seed); + request.watermark = Some(params.watermark); + request.response_format = Some(params.response_format.clone()); + request.size = Some(params.size.clone()); + + // 调用API + let response = self.call_api(&request).await?; + + // 下载生成的图像 + if let Some(data) = response.data.first() { + if let Some(url) = &data.url { + self.download_image(url, output_path).await?; + println!("✅ 图像编辑完成: {}", output_path.display()); + } else if let Some(b64_data) = &data.b64_json { + // 处理Base64格式的响应 + let image_data = BASE64_STANDARD.decode(b64_data) + .map_err(|e| anyhow!("解码Base64图像数据失败: {}", e))?; + + if let Some(parent) = output_path.parent() { + tokio::fs::create_dir_all(parent).await + .map_err(|e| anyhow!("创建输出目录失败: {}", e))?; + } + + tokio::fs::write(output_path, image_data).await + .map_err(|e| anyhow!("保存图像文件失败: {}", e))?; + + println!("✅ 图像编辑完成: {}", output_path.display()); + } else { + return Err(anyhow!("API响应中没有图像数据")); + } + } else { + return Err(anyhow!("API响应中没有数据")); + } + + Ok(response) + } + + /// 获取文件夹中的所有图像文件 + fn get_image_files(folder_path: &Path) -> Result> { + let mut image_files = Vec::new(); + + if !folder_path.exists() { + return Err(anyhow!("文件夹不存在: {}", folder_path.display())); + } + + if !folder_path.is_dir() { + return Err(anyhow!("路径不是文件夹: {}", folder_path.display())); + } + + let entries = fs::read_dir(folder_path) + .map_err(|e| anyhow!("读取文件夹失败: {}", e))?; + + for entry in entries { + 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 image_files.is_empty() { + return Err(anyhow!("文件夹中没有找到支持的图像文件")); + } + + image_files.sort(); + Ok(image_files) + } + + /// 生成输出文件名 + fn generate_output_filename(input_path: &Path, output_folder: &Path) -> PathBuf { + let file_stem = input_path.file_stem().unwrap_or_default(); + let extension = input_path.extension().unwrap_or_default(); + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); + + let output_filename = format!("{}_{}.{}", + file_stem.to_string_lossy(), + timestamp, + extension.to_string_lossy() + ); + + output_folder.join(output_filename) + } + + /// 批量编辑图像 + pub async fn edit_batch_images( + &self, + input_folder: &Path, + output_folder: &Path, + prompt: &str, + params: &ImageEditingParams, + progress_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; + + // 处理每个图像 + for (index, input_file) in image_files.iter().enumerate() { + let output_file = Self::generate_output_filename(input_file, output_folder); + + // 更新进度 + let progress = (index as f32) / (image_files.len() as f32); + if let Some(ref callback) = progress_callback { + callback(progress, format!("正在处理: {}", input_file.file_name().unwrap_or_default().to_string_lossy())); + } + + // 更新任务状态 + if let Some(task) = batch_task.individual_tasks.get_mut(index) { + task.set_processing(); + } + + // 处理单个图像 + match self.edit_single_image(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) = progress_callback { + callback(1.0, "批量处理完成".to_string()); + } + + println!("🎉 批量编辑完成!"); + println!("总计: {} 个文件", batch_task.total_images); + println!("成功: {} 个文件", batch_task.successful_images); + println!("失败: {} 个文件", batch_task.failed_images); + + Ok(batch_task) + } + + /// 创建编辑任务(用于异步处理) + pub async fn create_edit_task( + &self, + input_path: &Path, + _output_path: &Path, + prompt: &str, + params: &ImageEditingParams, + ) -> Result { + let task_id = Uuid::new_v4().to_string(); + let request = 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), + }; + + let task = ImageEditingTask::new( + task_id, + input_path.to_string_lossy().to_string(), + prompt.to_string(), + request, + ); + + Ok(task) + } + + /// 执行编辑任务 + pub async fn execute_task(&self, task: &mut ImageEditingTask) -> Result<()> { + task.set_processing(); + + let input_path = Path::new(&task.input_image_path); + let output_path = if let Some(ref output) = task.output_image_path { + Path::new(output).to_path_buf() + } else { + // 生成默认输出路径 + let parent = input_path.parent().unwrap_or(Path::new(".")); + Self::generate_output_filename(input_path, parent) + }; + + let params = ImageEditingParams { + guidance_scale: task.request_params.guidance_scale.unwrap_or(5.5), + seed: task.request_params.seed.unwrap_or(-1), + watermark: task.request_params.watermark.unwrap_or(true), + response_format: task.request_params.response_format.clone().unwrap_or_else(|| "url".to_string()), + size: task.request_params.size.clone().unwrap_or_else(|| "adaptive".to_string()), + }; + + match self.edit_single_image(input_path, &output_path, &task.prompt, ¶ms).await { + Ok(response) => { + task.set_completed(output_path.to_string_lossy().to_string(), response); + Ok(()) + } + Err(e) => { + task.set_failed(e.to_string()); + Err(e) + } + } + } +} diff --git a/apps/desktop/src-tauri/src/infrastructure/mod.rs b/apps/desktop/src-tauri/src/infrastructure/mod.rs index 1816fab..27040fa 100644 --- a/apps/desktop/src-tauri/src/infrastructure/mod.rs +++ b/apps/desktop/src-tauri/src/infrastructure/mod.rs @@ -15,5 +15,6 @@ pub mod monitoring; pub mod logging; pub mod gemini_service; pub mod video_generation_service; +pub mod image_editing_service; pub mod tolerant_json_parser; pub mod markdown_parser; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 815926b..32fe94f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -27,6 +27,7 @@ pub fn run() { .manage(AppState::new()) .manage(commands::tolerant_json_commands::JsonParserState::new()) .manage(commands::markdown_commands::MarkdownParserState::new()) + .manage(commands::image_editing_commands::ImageEditingState::new()) .invoke_handler(tauri::generate_handler![ commands::project_commands::create_project, commands::project_commands::get_all_projects, @@ -500,7 +501,21 @@ pub fn run() { commands::volcano_video_commands::download_volcano_video, commands::volcano_video_commands::download_video_to_directory, commands::volcano_video_commands::batch_download_volcano_videos, - commands::volcano_video_commands::get_video_stream_base64 + commands::volcano_video_commands::get_video_stream_base64, + // 图像编辑命令 + commands::image_editing_commands::set_image_editing_config, + commands::image_editing_commands::set_image_editing_api_key, + commands::image_editing_commands::edit_single_image, + commands::image_editing_commands::create_image_editing_task, + commands::image_editing_commands::execute_image_editing_task, + commands::image_editing_commands::get_image_editing_task_status, + commands::image_editing_commands::edit_batch_images, + commands::image_editing_commands::create_batch_editing_task, + commands::image_editing_commands::get_batch_editing_task_status, + commands::image_editing_commands::get_all_image_editing_tasks, + commands::image_editing_commands::get_all_batch_editing_tasks, + commands::image_editing_commands::clear_completed_tasks, + commands::image_editing_commands::cancel_image_editing_task ]) .setup(|app| { // 初始化日志系统 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 new file mode 100644 index 0000000..d98a88d --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/image_editing_commands.rs @@ -0,0 +1,305 @@ +use tauri::State; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::collections::HashMap; +use uuid::Uuid; + +use crate::data::models::image_editing::{ + ImageEditingConfig, ImageEditingTask, BatchImageEditingTask, + ImageEditingParams, ImageEditingTaskStatus, +}; +use crate::infrastructure::image_editing_service::ImageEditingService; + +/// 图像编辑服务状态管理 +pub struct ImageEditingState { + service: Arc>, + tasks: Arc>>, + batch_tasks: Arc>>, +} + +impl ImageEditingState { + pub fn new() -> Self { + Self { + service: Arc::new(Mutex::new(ImageEditingService::new())), + tasks: Arc::new(Mutex::new(HashMap::new())), + batch_tasks: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +/// 设置API配置 +#[tauri::command] +pub async fn set_image_editing_config( + state: State<'_, ImageEditingState>, + config: ImageEditingConfig, +) -> Result<(), String> { + let new_service = ImageEditingService::with_config(config); + + if let Ok(mut current_service) = state.service.lock() { + *current_service = new_service; + } + + Ok(()) +} + +/// 设置API密钥 +#[tauri::command] +pub async fn set_image_editing_api_key( + state: State<'_, ImageEditingState>, + api_key: String, +) -> Result<(), String> { + if let Ok(mut service) = state.service.lock() { + service.set_api_key(api_key); + } + + Ok(()) +} + +/// 编辑单张图像 +#[tauri::command] +pub async fn edit_single_image( + state: State<'_, ImageEditingState>, + input_path: String, + output_path: String, + prompt: String, + params: ImageEditingParams, +) -> Result { + let input_path = Path::new(&input_path); + let output_path = Path::new(&output_path); + + // 克隆服务以避免跨await持有锁 + let service = { + let service_guard = state.service.lock().map_err(|e| format!("获取服务失败: {}", e))?; + service_guard.clone() + }; + + match service.edit_single_image(input_path, output_path, &prompt, ¶ms).await { + Ok(_response) => { + // 返回任务ID或结果信息 + Ok(format!("图像编辑完成,输出路径: {}", output_path.display())) + } + Err(e) => Err(format!("图像编辑失败: {}", e)), + } +} + +/// 创建图像编辑任务 +#[tauri::command] +pub async fn create_image_editing_task( + state: State<'_, ImageEditingState>, + input_path: String, + output_path: String, + prompt: String, + params: ImageEditingParams, +) -> Result { + let input_path = Path::new(&input_path); + let output_path = Path::new(&output_path); + + // 克隆服务以避免跨await持有锁 + let service = { + let service_guard = state.service.lock().map_err(|e| format!("获取服务失败: {}", e))?; + service_guard.clone() + }; + + match service.create_edit_task(input_path, output_path, &prompt, ¶ms).await { + Ok(task) => { + let task_id = task.id.clone(); + + // 存储任务 + if let Ok(mut tasks) = state.tasks.lock() { + tasks.insert(task_id.clone(), task); + } + + Ok(task_id) + } + Err(e) => Err(format!("创建任务失败: {}", e)), + } +} + +/// 执行图像编辑任务 +#[tauri::command] +pub async fn execute_image_editing_task( + state: State<'_, ImageEditingState>, + task_id: String, +) -> Result<(), String> { + // 克隆服务以避免跨await持有锁 + let service = { + let service_guard = state.service.lock().map_err(|e| format!("获取服务失败: {}", e))?; + service_guard.clone() + }; + + // 获取任务 + let mut task = { + let tasks = state.tasks.lock().map_err(|e| format!("获取任务失败: {}", e))?; + tasks.get(&task_id).cloned().ok_or_else(|| "任务不存在".to_string())? + }; + + // 执行任务 + match service.execute_task(&mut task).await { + Ok(_) => { + // 更新任务状态 + if let Ok(mut tasks) = state.tasks.lock() { + tasks.insert(task_id, task); + } + Ok(()) + } + Err(e) => { + // 更新失败状态 + if let Ok(mut tasks) = state.tasks.lock() { + tasks.insert(task_id, task); + } + Err(format!("执行任务失败: {}", e)) + } + } +} + +/// 获取任务状态 +#[tauri::command] +pub async fn get_image_editing_task_status( + state: State<'_, ImageEditingState>, + task_id: String, +) -> Result { + let tasks = state.tasks.lock().map_err(|e| format!("获取任务失败: {}", e))?; + + tasks.get(&task_id) + .cloned() + .ok_or_else(|| "任务不存在".to_string()) +} + +/// 批量编辑图像 +#[tauri::command] +pub async fn edit_batch_images( + state: State<'_, ImageEditingState>, + input_folder: String, + output_folder: String, + prompt: String, + params: ImageEditingParams, +) -> Result { + let input_folder = Path::new(&input_folder); + let output_folder = Path::new(&output_folder); + + // 克隆服务以避免跨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(); + + // 存储批量任务 + if let Ok(mut batch_tasks) = state.batch_tasks.lock() { + batch_tasks.insert(task_id.clone(), batch_task); + } + + Ok(task_id) + } + Err(e) => Err(format!("批量编辑失败: {}", e)), + } +} + +/// 创建批量编辑任务 +#[tauri::command] +pub async fn create_batch_editing_task( + state: State<'_, ImageEditingState>, + input_folder: String, + output_folder: String, + prompt: String, + params: ImageEditingParams, +) -> Result { + let task_id = Uuid::new_v4().to_string(); + + // 创建批量任务(不立即执行) + let batch_task = BatchImageEditingTask::new( + task_id.clone(), + input_folder, + output_folder, + prompt, + crate::data::models::image_editing::ImageEditingRequest { + model: "doubao-seededit-3-0-i2i-250628".to_string(), + prompt: String::new(), + 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), + }, + ); + + // 存储批量任务 + if let Ok(mut batch_tasks) = state.batch_tasks.lock() { + batch_tasks.insert(task_id.clone(), batch_task); + } + + Ok(task_id) +} + +/// 获取批量任务状态 +#[tauri::command] +pub async fn get_batch_editing_task_status( + state: State<'_, ImageEditingState>, + task_id: String, +) -> Result { + let batch_tasks = state.batch_tasks.lock().map_err(|e| format!("获取任务失败: {}", e))?; + + batch_tasks.get(&task_id) + .cloned() + .ok_or_else(|| "批量任务不存在".to_string()) +} + +/// 获取所有任务列表 +#[tauri::command] +pub async fn get_all_image_editing_tasks( + state: State<'_, ImageEditingState>, +) -> Result, String> { + let tasks = state.tasks.lock().map_err(|e| format!("获取任务失败: {}", e))?; + + Ok(tasks.values().cloned().collect()) +} + +/// 获取所有批量任务列表 +#[tauri::command] +pub async fn get_all_batch_editing_tasks( + state: State<'_, ImageEditingState>, +) -> Result, String> { + let batch_tasks = state.batch_tasks.lock().map_err(|e| format!("获取任务失败: {}", e))?; + + Ok(batch_tasks.values().cloned().collect()) +} + +/// 清除已完成的任务 +#[tauri::command] +pub async fn clear_completed_tasks( + state: State<'_, ImageEditingState>, +) -> Result<(), String> { + // 清除单个任务 + if let Ok(mut tasks) = state.tasks.lock() { + tasks.retain(|_, task| !matches!(task.status, ImageEditingTaskStatus::Completed | ImageEditingTaskStatus::Failed)); + } + + // 清除批量任务 + if let Ok(mut batch_tasks) = state.batch_tasks.lock() { + batch_tasks.retain(|_, task| !matches!(task.status, ImageEditingTaskStatus::Completed | ImageEditingTaskStatus::Failed)); + } + + Ok(()) +} + +/// 取消任务 +#[tauri::command] +pub async fn cancel_image_editing_task( + state: State<'_, ImageEditingState>, + task_id: String, +) -> Result<(), String> { + if let Ok(mut tasks) = state.tasks.lock() { + if let Some(task) = tasks.get_mut(&task_id) { + if matches!(task.status, ImageEditingTaskStatus::Pending | ImageEditingTaskStatus::Processing) { + task.status = ImageEditingTaskStatus::Cancelled; + task.updated_at = chrono::Utc::now(); + } + } + } + + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index 5a08c3a..38ae946 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -17,6 +17,7 @@ pub mod material_matching_commands; pub mod template_matching_result_commands; pub mod export_record_commands; pub mod video_generation_commands; +pub mod image_editing_commands; pub mod tools_commands; pub mod outfit_search_commands; pub mod material_search_commands; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index a14935c..fcf3522 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -26,6 +26,7 @@ import OutfitFavoritesTool from './pages/tools/OutfitFavoritesTool'; import OutfitComparisonTool from './pages/tools/OutfitComparisonTool'; import MaterialSearchTool from './pages/tools/MaterialSearchTool'; import ImageGenerationTool from './pages/tools/ImageGenerationTool'; +import ImageEditingTool from './pages/tools/ImageEditingTool'; import VoiceGenerationHistory from './pages/tools/VoiceGenerationHistory'; import VoiceCloneTool from './pages/tools/VoiceCloneTool'; import VideoGenerationTool from './pages/tools/VideoGenerationTool'; @@ -143,6 +144,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/desktop/src/data/tools.ts b/apps/desktop/src/data/tools.ts index 97cc3e2..233cf6e 100644 --- a/apps/desktop/src/data/tools.ts +++ b/apps/desktop/src/data/tools.ts @@ -9,7 +9,8 @@ import { ArrowLeftRight, Image, Mic, - Video + Video, + Wand2 } from 'lucide-react'; import { Tool, ToolCategory, ToolStatus } from '../types/tool'; @@ -152,6 +153,21 @@ export const TOOLS_DATA: Tool[] = [ isPopular: true, version: '1.0.0', lastUpdated: '2024-01-31' + }, + { + id: 'image-editing', + name: '图像编辑工具', + description: '基于火山云SeedEdit 3.0 API的智能图像编辑工具,支持单张和批量图片编辑', + longDescription: '专业的AI图像编辑工具,集成火山云SeedEdit 3.0先进的图像编辑模型。支持通过提示词进行图像编辑、风格转换、场景变换等功能。提供单张图片编辑和批量处理模式,支持多种参数配置、实时进度监控、任务管理等完整的图像编辑流程。适用于创意设计、内容创作、图片处理等多种场景。', + icon: Wand2, + route: '/tools/image-editing', + category: ToolCategory.AI_TOOLS, + status: ToolStatus.STABLE, + tags: ['图像编辑', 'AI编辑', '批量处理', '火山云API', '提示词编辑'], + isNew: true, + isPopular: true, + version: '1.0.0', + lastUpdated: '2024-01-31' } ]; diff --git a/apps/desktop/src/pages/tools/ImageEditingTool.tsx b/apps/desktop/src/pages/tools/ImageEditingTool.tsx new file mode 100644 index 0000000..649c9c1 --- /dev/null +++ b/apps/desktop/src/pages/tools/ImageEditingTool.tsx @@ -0,0 +1,884 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { + Upload, + Image as ImageIcon, + Settings, + Play, + Pause, + RotateCcw, + Download, + FolderOpen, + Wand2, + AlertCircle, + CheckCircle, + Clock, + Loader, + XCircle, + Ban, + Trash2, + Eye, + Copy, + Save +} from 'lucide-react'; +import { invoke } from '@tauri-apps/api/core'; +import { open } from '@tauri-apps/plugin-dialog'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import { + ImageEditingTask, + BatchImageEditingTask, + ImageEditingParams, + ImageEditingConfig, + ImageEditingTaskStatus, + DEFAULT_IMAGE_EDITING_PARAMS, + DEFAULT_IMAGE_EDITING_CONFIG, + PRESET_PROMPTS, + GUIDANCE_SCALE_OPTIONS, + TASK_STATUS_CONFIG, + IMAGE_FILE_CONFIG, +} from '../../types/imageEditing'; + +/** + * 图像编辑工具页面 + * 支持单张图片编辑和批量处理功能 + */ +const ImageEditingTool: React.FC = () => { + // 状态管理 + const [activeTab, setActiveTab] = useState<'single' | 'batch'>('single'); + const [config, setConfig] = useState(DEFAULT_IMAGE_EDITING_CONFIG); + const [params, setParams] = useState(DEFAULT_IMAGE_EDITING_PARAMS); + + // 单张图片编辑状态 + const [selectedImage, setSelectedImage] = useState(''); + const [outputPath, setOutputPath] = useState(''); + const [prompt, setPrompt] = useState(''); + const [isProcessing, setIsProcessing] = useState(false); + const [result, setResult] = useState(''); + + // 批量处理状态 + const [inputFolder, setInputFolder] = useState(''); + const [outputFolder, setOutputFolder] = useState(''); + const [batchPrompt, setBatchPrompt] = useState(''); + const [batchTask, setBatchTask] = useState(null); + + // 任务管理状态 + const [tasks, setTasks] = useState([]); + const [batchTasks, setBatchTasks] = useState([]); + const [showTasks, setShowTasks] = useState(false); + + // 配置状态 + const [showConfig, setShowConfig] = useState(false); + const [apiKeyInput, setApiKeyInput] = useState(''); + + // 初始化 + useEffect(() => { + loadTasks(); + }, []); + + // 加载任务列表 + const loadTasks = useCallback(async () => { + try { + // TODO: 实现后端API调用 + // const [allTasks, allBatchTasks] = await Promise.all([ + // invoke('get_all_image_editing_tasks'), + // invoke('get_all_batch_editing_tasks'), + // ]); + // setTasks(allTasks); + // setBatchTasks(allBatchTasks); + console.log('加载任务列表(演示模式)'); + } catch (error) { + console.error('加载任务失败:', error); + } + }, []); + + // 设置API密钥 + const handleSetApiKey = useCallback(async () => { + if (!apiKeyInput.trim()) { + alert('请输入API密钥'); + return; + } + + try { + // TODO: 实现后端API调用 + // await invoke('set_image_editing_api_key', { apiKey: apiKeyInput }); + setConfig(prev => ({ ...prev, api_key: apiKeyInput })); + alert('API密钥设置成功(演示模式)'); + setShowConfig(false); + } catch (error) { + console.error('设置API密钥失败:', error); + alert(`设置API密钥失败: ${error}`); + } + }, [apiKeyInput]); + + // 选择图片文件 + const handleSelectImage = useCallback(async () => { + try { + const selected = await open({ + multiple: false, + filters: [{ + name: '图片文件', + extensions: IMAGE_FILE_CONFIG.allowedExtensions, + }], + }); + + if (selected && typeof selected === 'string') { + setSelectedImage(selected); + // 自动生成输出路径 + const pathParts = selected.split('.'); + const extension = pathParts.pop(); + const basePath = pathParts.join('.'); + setOutputPath(`${basePath}_edited.${extension}`); + } + } catch (error) { + console.error('选择图片失败:', error); + alert(`选择图片失败: ${error}`); + } + }, []); + + // 选择输出路径 + const handleSelectOutputPath = useCallback(async () => { + try { + const selected = await open({ + multiple: false, + filters: [{ + name: '图片文件', + extensions: IMAGE_FILE_CONFIG.allowedExtensions, + }], + }); + + if (selected && typeof selected === 'string') { + setOutputPath(selected); + } + } catch (error) { + console.error('选择输出路径失败:', error); + alert(`选择输出路径失败: ${error}`); + } + }, []); + + // 选择输入文件夹 + const handleSelectInputFolder = useCallback(async () => { + try { + const selected = await open({ + directory: true, + multiple: false, + }); + + if (selected && typeof selected === 'string') { + setInputFolder(selected); + } + } catch (error) { + console.error('选择输入文件夹失败:', error); + alert(`选择输入文件夹失败: ${error}`); + } + }, []); + + // 选择输出文件夹 + const handleSelectOutputFolder = useCallback(async () => { + try { + const selected = await open({ + directory: true, + multiple: false, + }); + + if (selected && typeof selected === 'string') { + setOutputFolder(selected); + } + } catch (error) { + console.error('选择输出文件夹失败:', error); + alert(`选择输出文件夹失败: ${error}`); + } + }, []); + + // 编辑单张图片 + const handleEditSingleImage = useCallback(async () => { + if (!selectedImage || !outputPath || !prompt.trim()) { + alert('请选择图片、输出路径并输入提示词'); + return; + } + + if (!config.api_key) { + alert('请先设置API密钥'); + setShowConfig(true); + return; + } + + setIsProcessing(true); + setResult(''); + + try { + // TODO: 实现后端API调用 + // const result = await invoke('edit_single_image', { + // inputPath: selectedImage, + // outputPath: outputPath, + // prompt: prompt, + // params: params, + // }); + + // 模拟处理时间 + await new Promise(resolve => setTimeout(resolve, 2000)); + + const mockResult = `图像编辑完成(演示模式),输出路径: ${outputPath}`; + setResult(mockResult); + alert('图片编辑完成!(演示模式)'); + loadTasks(); // 刷新任务列表 + } catch (error) { + console.error('图片编辑失败:', error); + alert(`图片编辑失败: ${error}`); + } finally { + setIsProcessing(false); + } + }, [selectedImage, outputPath, prompt, params, config.api_key, loadTasks]); + + // 批量编辑图片 + const handleBatchEdit = useCallback(async () => { + if (!inputFolder || !outputFolder || !batchPrompt.trim()) { + alert('请选择输入文件夹、输出文件夹并输入提示词'); + return; + } + + if (!config.api_key) { + alert('请先设置API密钥'); + setShowConfig(true); + return; + } + + setIsProcessing(true); + setBatchTask(null); + + try { + // TODO: 实现后端API调用 + // const taskId = await invoke('edit_batch_images', { + // inputFolder: inputFolder, + // outputFolder: outputFolder, + // prompt: batchPrompt, + // params: params, + // }); + + // 模拟批量处理进度 + const mockTask: BatchImageEditingTask = { + id: 'mock-batch-task', + input_folder_path: inputFolder, + output_folder_path: outputFolder, + prompt: batchPrompt, + total_images: 5, + 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(mockTask); + + // 模拟进度更新 + let progress = 0; + const progressInterval = setInterval(() => { + progress += 0.2; + if (progress >= 1) { + clearInterval(progressInterval); + setBatchTask(prev => prev ? { + ...prev, + status: ImageEditingTaskStatus.Completed, + progress: 1, + processed_images: 5, + successful_images: 5, + failed_images: 0, + } : null); + setIsProcessing(false); + alert('批量处理完成!(演示模式)'); + } else { + setBatchTask(prev => prev ? { + ...prev, + progress, + processed_images: Math.floor(progress * 5), + successful_images: Math.floor(progress * 5), + } : null); + } + }, 1000); + + } catch (error) { + console.error('批量编辑失败:', error); + alert(`批量编辑失败: ${error}`); + setIsProcessing(false); + } + }, [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) => { + const config = TASK_STATUS_CONFIG[status]; + switch (config.icon) { + case 'Clock': return ; + case 'Loader': return ; + case 'CheckCircle': return ; + case 'XCircle': return ; + case 'Ban': return ; + default: return ; + } + }; + + return ( +
+
+ {/* 页面标题 */} +
+
+
+ +
+
+

图像编辑工具

+

基于火山云SeedEdit 3.0 API的智能图像编辑工具

+
+
+ + {/* 工具栏 */} +
+
+ + +
+ + + + +
+
+ + {/* 主要内容区域 */} +
+ {/* 左侧:编辑界面 */} +
+ {activeTab === 'single' ? ( + // 单张图片编辑界面 +
+

单张图片编辑

+ + {/* 图片选择 */} +
+ +
+ + {selectedImage && ( +
+ {selectedImage} +
+ )} +
+
+ + {/* 输出路径 */} +
+ +
+ + {outputPath && ( +
+ {outputPath} +
+ )} +
+
+ + {/* 提示词输入 */} +
+ +