feat: 添加图像编辑工具

- 基于火山云SeedEdit 3.0 API的智能图像编辑工具
- 支持单张图片编辑和批量处理功能
- 提供丰富的预设提示词和参数配置
- 实现任务管理和进度监控
- 集成到便捷工具系统

功能特性:
- 单张图片编辑:选择图片、输入提示词、实时编辑
- 批量处理:文件夹批量处理、进度监控、结果统计
- 参数配置:引导强度、随机种子、水印设置等
- 预设提示词:风格转换、场景变换、色彩调整、特效处理
- 任务管理:状态监控、历史记录、清理功能

技术实现:
- Rust后端:图像编辑服务、API调用、错误处理
- React前端:响应式界面、实时更新、用户体验优化
- 类型安全:完整的TypeScript类型定义
- 模块化设计:可扩展的架构和组件复用
This commit is contained in:
2025-07-31 15:28:29 +08:00
parent ff5cccfb05
commit 05d9709420
12 changed files with 2428 additions and 2 deletions

View File

@@ -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<String>, // "url" 或 "b64_json"
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<String>, // "adaptive"
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guidance_scale: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub watermark: Option<bool>,
}
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub b64_json: Option<String>,
}
/// 图像编辑使用量信息
#[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<ImageEditingResponseData>,
pub usage: ImageEditingUsage,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ImageEditingError>,
}
/// 图像编辑任务状态
#[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<String>,
pub prompt: String,
pub status: ImageEditingTaskStatus,
pub progress: f32, // 0.0 - 1.0
pub error_message: Option<String>,
pub request_params: ImageEditingRequest,
pub response_data: Option<ImageEditingResponse>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
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<ImageEditingTask>,
pub request_params: ImageEditingRequest,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
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(),
}
}
}

View File

@@ -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;

View File

@@ -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<String> {
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<ImageEditingResponse> {
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<ImageEditingResponse> {
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<Vec<PathBuf>> {
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<Box<dyn Fn(f32, String) + Send + Sync>>,
) -> Result<BatchImageEditingTask> {
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<ImageEditingTask> {
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, &params).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)
}
}
}
}

View File

@@ -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;

View File

@@ -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| {
// 初始化日志系统

View File

@@ -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<Mutex<ImageEditingService>>,
tasks: Arc<Mutex<HashMap<String, ImageEditingTask>>>,
batch_tasks: Arc<Mutex<HashMap<String, BatchImageEditingTask>>>,
}
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<String, String> {
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, &params).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<String, String> {
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, &params).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<ImageEditingTask, String> {
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<String, String> {
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, &params, 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<String, String> {
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<BatchImageEditingTask, String> {
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<Vec<ImageEditingTask>, 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<Vec<BatchImageEditingTask>, 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(())
}

View File

@@ -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;

View File

@@ -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() {
<Route path="/tools/outfit-comparison" element={<OutfitComparisonTool />} />
<Route path="/tools/material-search" element={<MaterialSearchTool />} />
<Route path="/tools/image-generation" element={<ImageGenerationTool />} />
<Route path="/tools/image-editing" element={<ImageEditingTool />} />
<Route path="/tools/voice-generation-history" element={<VoiceGenerationHistory />} />
<Route path="/tools/voice-clone" element={<VoiceCloneTool />} />
<Route path="/tools/volcano-video-generation" element={<VideoGenerationTool />} />

View File

@@ -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'
}
];

View File

@@ -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<ImageEditingConfig>(DEFAULT_IMAGE_EDITING_CONFIG);
const [params, setParams] = useState<ImageEditingParams>(DEFAULT_IMAGE_EDITING_PARAMS);
// 单张图片编辑状态
const [selectedImage, setSelectedImage] = useState<string>('');
const [outputPath, setOutputPath] = useState<string>('');
const [prompt, setPrompt] = useState<string>('');
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [result, setResult] = useState<string>('');
// 批量处理状态
const [inputFolder, setInputFolder] = useState<string>('');
const [outputFolder, setOutputFolder] = useState<string>('');
const [batchPrompt, setBatchPrompt] = useState<string>('');
const [batchTask, setBatchTask] = useState<BatchImageEditingTask | null>(null);
// 任务管理状态
const [tasks, setTasks] = useState<ImageEditingTask[]>([]);
const [batchTasks, setBatchTasks] = useState<BatchImageEditingTask[]>([]);
const [showTasks, setShowTasks] = useState<boolean>(false);
// 配置状态
const [showConfig, setShowConfig] = useState<boolean>(false);
const [apiKeyInput, setApiKeyInput] = useState<string>('');
// 初始化
useEffect(() => {
loadTasks();
}, []);
// 加载任务列表
const loadTasks = useCallback(async () => {
try {
// TODO: 实现后端API调用
// const [allTasks, allBatchTasks] = await Promise.all([
// invoke<ImageEditingTask[]>('get_all_image_editing_tasks'),
// invoke<BatchImageEditingTask[]>('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<string>('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<string>('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 <Clock className="w-4 h-4" />;
case 'Loader': return <Loader className="w-4 h-4 animate-spin" />;
case 'CheckCircle': return <CheckCircle className="w-4 h-4" />;
case 'XCircle': return <XCircle className="w-4 h-4" />;
case 'Ban': return <Ban className="w-4 h-4" />;
default: return <Clock className="w-4 h-4" />;
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50">
<div className="container mx-auto px-4 py-8 max-w-7xl">
{/* 页面标题 */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<div className="p-3 bg-gradient-to-r from-purple-500 to-pink-500 rounded-xl">
<Wand2 className="w-8 h-8 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-1">SeedEdit 3.0 API的智能图像编辑工具</p>
</div>
</div>
{/* 工具栏 */}
<div className="flex items-center gap-4">
<div className="flex bg-white rounded-lg border border-gray-200 p-1">
<button
onClick={() => setActiveTab('single')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'single'
? 'bg-blue-500 text-white'
: 'text-gray-600 hover:text-gray-900'
}`}
>
</button>
<button
onClick={() => setActiveTab('batch')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'batch'
? 'bg-blue-500 text-white'
: 'text-gray-600 hover:text-gray-900'
}`}
>
</button>
</div>
<button
onClick={() => setShowConfig(true)}
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<Settings className="w-4 h-4" />
</button>
<button
onClick={() => setShowTasks(true)}
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<Eye className="w-4 h-4" />
</button>
</div>
</div>
{/* 主要内容区域 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 左侧:编辑界面 */}
<div className="lg:col-span-2">
{activeTab === 'single' ? (
// 单张图片编辑界面
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-6"></h2>
{/* 图片选择 */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="flex gap-4">
<button
onClick={handleSelectImage}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<Upload className="w-4 h-4" />
</button>
{selectedImage && (
<div className="flex-1 px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-600 truncate">
{selectedImage}
</div>
)}
</div>
</div>
{/* 输出路径 */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="flex gap-4">
<button
onClick={handleSelectOutputPath}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<Save className="w-4 h-4" />
</button>
{outputPath && (
<div className="flex-1 px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-600 truncate">
{outputPath}
</div>
)}
</div>
</div>
{/* 提示词输入 */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="请输入图像编辑的提示词,例如:改成爱心形状的泡泡"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
rows={3}
/>
</div>
{/* 操作按钮 */}
<div className="flex gap-4">
<button
onClick={handleEditSingleImage}
disabled={isProcessing || !selectedImage || !outputPath || !prompt.trim()}
className="flex items-center gap-2 px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isProcessing ? (
<Loader className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
{isProcessing ? '处理中...' : '开始编辑'}
</button>
<button
onClick={() => {
setSelectedImage('');
setOutputPath('');
setPrompt('');
setResult('');
}}
className="flex items-center gap-2 px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
{/* 结果显示 */}
{result && (
<div className="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center gap-2 text-green-800">
<CheckCircle className="w-4 h-4" />
<span className="font-medium"></span>
</div>
<p className="text-green-700 mt-1 text-sm">{result}</p>
</div>
)}
</div>
) : (
// 批量处理界面
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-6"></h2>
{/* 文件夹选择 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<button
onClick={handleSelectInputFolder}
className="w-full flex items-center gap-2 px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<FolderOpen className="w-4 h-4" />
</button>
{inputFolder && (
<div className="mt-2 px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-600 truncate">
{inputFolder}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<button
onClick={handleSelectOutputFolder}
className="w-full flex items-center gap-2 px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<FolderOpen className="w-4 h-4" />
</button>
{outputFolder && (
<div className="mt-2 px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-600 truncate">
{outputFolder}
</div>
)}
</div>
</div>
{/* 提示词输入 */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<textarea
value={batchPrompt}
onChange={(e) => setBatchPrompt(e.target.value)}
placeholder="请输入批量编辑的提示词,将应用到所有图片"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
rows={3}
/>
</div>
{/* 操作按钮 */}
<div className="flex gap-4 mb-6">
<button
onClick={handleBatchEdit}
disabled={isProcessing || !inputFolder || !outputFolder || !batchPrompt.trim()}
className="flex items-center gap-2 px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isProcessing ? (
<Loader className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
{isProcessing ? '处理中...' : '开始批量处理'}
</button>
<button
onClick={() => {
setInputFolder('');
setOutputFolder('');
setBatchPrompt('');
setBatchTask(null);
}}
className="flex items-center gap-2 px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
{/* 批量处理进度 */}
{batchTask && (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{getStatusIcon(batchTask.status)}
<span className="font-medium text-blue-900">
{TASK_STATUS_CONFIG[batchTask.status].label}
</span>
</div>
<span className="text-sm text-blue-700">
{batchTask.processed_images} / {batchTask.total_images}
</span>
</div>
<div className="w-full bg-blue-200 rounded-full h-2 mb-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${batchTask.progress * 100}%` }}
/>
</div>
<div className="text-sm text-blue-700">
: {batchTask.successful_images} | : {batchTask.failed_images}
</div>
</div>
)}
</div>
)}
</div>
{/* 右侧:预设提示词和参数配置 */}
<div className="space-y-6">
{/* 预设提示词 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<div className="space-y-4">
{PRESET_PROMPTS.map((category) => (
<div key={category.category}>
<h4 className="text-sm font-medium text-gray-700 mb-2">
{category.category}
</h4>
<div className="space-y-1">
{category.prompts.map((presetPrompt) => (
<button
key={presetPrompt}
onClick={() => handleApplyPresetPrompt(presetPrompt)}
className="w-full text-left px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 rounded-lg transition-colors"
>
{presetPrompt}
</button>
))}
</div>
</div>
))}
</div>
</div>
{/* 参数配置 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<div className="space-y-4">
{/* 引导强度 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
: {params.guidance_scale}
</label>
<input
type="range"
min="1"
max="10"
step="0.5"
value={params.guidance_scale}
onChange={(e) => setParams(prev => ({
...prev,
guidance_scale: parseFloat(e.target.value)
}))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span></span>
<span></span>
</div>
</div>
{/* 随机种子 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="number"
value={params.seed}
onChange={(e) => setParams(prev => ({
...prev,
seed: parseInt(e.target.value) || -1
}))}
placeholder="-1 (随机)"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* 水印设置 */}
<div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={params.watermark}
onChange={(e) => setParams(prev => ({
...prev,
watermark: e.target.checked
}))}
className="rounded border-gray-300 text-blue-500 focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-700">
AI生成水印
</span>
</label>
</div>
</div>
</div>
</div>
</div>
{/* 配置模态框 */}
{showConfig && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">API配置</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
API密钥
</label>
<input
type="password"
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
placeholder="请输入火山云API密钥"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="text-sm text-gray-600">
<p>:</p>
<p>API地址: {config.api_url}</p>
<p>ID: {config.model_id}</p>
<p>API密钥: {config.api_key ? '已设置' : '未设置'}</p>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={handleSetApiKey}
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
</button>
<button
onClick={() => setShowConfig(false)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
</button>
</div>
</div>
</div>
)}
{/* 任务管理模态框 */}
{showTasks && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-6 w-full max-w-4xl mx-4 max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900"></h3>
<button
onClick={() => setShowTasks(false)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<XCircle className="w-5 h-5" />
</button>
</div>
{/* 任务列表 */}
<div className="space-y-4">
{/* 单个任务 */}
{tasks.length > 0 && (
<div>
<h4 className="font-medium text-gray-900 mb-2"></h4>
<div className="space-y-2">
{tasks.map((task) => (
<div key={task.id} className="p-3 border border-gray-200 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{getStatusIcon(task.status)}
<span className="text-sm font-medium">
{task.prompt.substring(0, 30)}...
</span>
</div>
<span className="text-xs text-gray-500">
{new Date(task.created_at).toLocaleString()}
</span>
</div>
<div className="mt-1 text-xs text-gray-600">
: {task.input_image_path.split('/').pop()}
</div>
{task.progress > 0 && (
<div className="mt-2 w-full bg-gray-200 rounded-full h-1">
<div
className="bg-blue-500 h-1 rounded-full"
style={{ width: `${task.progress * 100}%` }}
/>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* 批量任务 */}
{batchTasks.length > 0 && (
<div>
<h4 className="font-medium text-gray-900 mb-2"></h4>
<div className="space-y-2">
{batchTasks.map((task) => (
<div key={task.id} className="p-3 border border-gray-200 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{getStatusIcon(task.status)}
<span className="text-sm font-medium">
{task.prompt.substring(0, 30)}...
</span>
</div>
<span className="text-xs text-gray-500">
{new Date(task.created_at).toLocaleString()}
</span>
</div>
<div className="mt-1 text-xs text-gray-600">
{task.processed_images} / {task.total_images}
(: {task.successful_images}, : {task.failed_images})
</div>
{task.progress > 0 && (
<div className="mt-2 w-full bg-gray-200 rounded-full h-1">
<div
className="bg-blue-500 h-1 rounded-full"
style={{ width: `${task.progress * 100}%` }}
/>
</div>
)}
</div>
))}
</div>
</div>
)}
{tasks.length === 0 && batchTasks.length === 0 && (
<div className="text-center py-8 text-gray-500">
</div>
)}
</div>
<div className="flex gap-3 mt-6">
<button
onClick={loadTasks}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
</button>
<button
onClick={async () => {
try {
await invoke('clear_completed_tasks');
loadTasks();
} catch (error) {
console.error('清理任务失败:', error);
}
}}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default ImageEditingTool;

View File

@@ -0,0 +1,300 @@
/**
* 图像编辑工具类型定义
* 遵循 Tauri 开发规范的类型安全设计
*/
// 图像编辑API配置
export interface ImageEditingConfig {
api_url: string;
api_key: string;
model_id: string;
timeout: number;
max_retries: number;
retry_delay: number;
}
// 图像编辑请求参数
export interface ImageEditingRequest {
model: string;
prompt: string;
image: string; // Base64编码或URL
response_format?: string; // "url" 或 "b64_json"
size?: string; // "adaptive"
seed?: number;
guidance_scale?: number;
watermark?: boolean;
}
// 图像编辑响应数据
export interface ImageEditingResponseData {
url?: string;
b64_json?: string;
}
// 图像编辑使用量信息
export interface ImageEditingUsage {
generated_images: number;
output_tokens: number;
total_tokens: number;
}
// 图像编辑错误信息
export interface ImageEditingError {
code: string;
message: string;
}
// 图像编辑API响应
export interface ImageEditingResponse {
model: string;
created: number;
data: ImageEditingResponseData[];
usage: ImageEditingUsage;
error?: ImageEditingError;
}
// 图像编辑任务状态
export enum ImageEditingTaskStatus {
Pending = 'Pending',
Processing = 'Processing',
Completed = 'Completed',
Failed = 'Failed',
Cancelled = 'Cancelled',
}
// 图像编辑任务
export interface ImageEditingTask {
id: string;
input_image_path: string;
output_image_path?: string;
prompt: string;
status: ImageEditingTaskStatus;
progress: number; // 0.0 - 1.0
error_message?: string;
request_params: ImageEditingRequest;
response_data?: ImageEditingResponse;
created_at: string;
updated_at: string;
completed_at?: string;
}
// 批量图像编辑任务
export interface BatchImageEditingTask {
id: string;
input_folder_path: string;
output_folder_path: string;
prompt: string;
total_images: number;
processed_images: number;
successful_images: number;
failed_images: number;
status: ImageEditingTaskStatus;
progress: number; // 0.0 - 1.0
individual_tasks: ImageEditingTask[];
request_params: ImageEditingRequest;
created_at: string;
updated_at: string;
completed_at?: string;
}
// 图像编辑参数配置
export interface ImageEditingParams {
guidance_scale: number;
seed: number;
watermark: boolean;
response_format: string;
size: string;
}
// 默认参数配置
export const DEFAULT_IMAGE_EDITING_PARAMS: ImageEditingParams = {
guidance_scale: 5.5,
seed: -1,
watermark: true,
response_format: 'url',
size: 'adaptive',
};
// 默认API配置
export const DEFAULT_IMAGE_EDITING_CONFIG: ImageEditingConfig = {
api_url: 'https://ark.cn-beijing.volces.com/api/v3/images/generations',
api_key: '',
model_id: 'doubao-seededit-3-0-i2i-250628',
timeout: 120,
max_retries: 3,
retry_delay: 2,
};
// 任务状态显示配置
export const TASK_STATUS_CONFIG = {
[ImageEditingTaskStatus.Pending]: {
label: '等待中',
color: 'gray',
icon: 'Clock',
},
[ImageEditingTaskStatus.Processing]: {
label: '处理中',
color: 'blue',
icon: 'Loader',
},
[ImageEditingTaskStatus.Completed]: {
label: '已完成',
color: 'green',
icon: 'CheckCircle',
},
[ImageEditingTaskStatus.Failed]: {
label: '失败',
color: 'red',
icon: 'XCircle',
},
[ImageEditingTaskStatus.Cancelled]: {
label: '已取消',
color: 'orange',
icon: 'Ban',
},
};
// 预设提示词
export const PRESET_PROMPTS = [
{
category: '风格转换',
prompts: [
'转换为水彩画风格',
'转换为油画风格',
'转换为卡通动漫风格',
'转换为素描风格',
'转换为复古胶片风格',
],
},
{
category: '场景变换',
prompts: [
'改为春天的樱花背景',
'改为夏日海滩背景',
'改为秋天的枫叶背景',
'改为冬日雪景背景',
'改为城市夜景背景',
],
},
{
category: '色彩调整',
prompts: [
'增强色彩饱和度',
'转换为黑白照片',
'增加暖色调',
'增加冷色调',
'提高亮度和对比度',
],
},
{
category: '特效处理',
prompts: [
'添加光晕效果',
'添加雨滴效果',
'添加阳光透射效果',
'添加星空效果',
'添加梦幻光斑效果',
],
},
];
// 参数配置选项
export const GUIDANCE_SCALE_OPTIONS = [
{ value: 1.0, label: '1.0 - 最低引导' },
{ value: 2.5, label: '2.5 - 低引导' },
{ value: 5.5, label: '5.5 - 默认引导' },
{ value: 7.5, label: '7.5 - 高引导' },
{ value: 10.0, label: '10.0 - 最高引导' },
];
export const RESPONSE_FORMAT_OPTIONS = [
{ value: 'url', label: 'URL链接' },
{ value: 'b64_json', label: 'Base64编码' },
];
export const SIZE_OPTIONS = [
{ value: 'adaptive', label: '自适应尺寸' },
];
// 工具函数类型
export interface ImageEditingUtils {
formatTaskStatus: (status: ImageEditingTaskStatus) => string;
formatProgress: (progress: number) => string;
formatDuration: (startTime: string, endTime?: string) => string;
validatePrompt: (prompt: string) => boolean;
generateTaskId: () => string;
}
// 事件类型
export interface TaskProgressEvent {
taskId: string;
progress: number;
status: ImageEditingTaskStatus;
message?: string;
}
export interface BatchProgressEvent {
batchId: string;
totalImages: number;
processedImages: number;
successfulImages: number;
failedImages: number;
progress: number;
currentTask?: string;
}
// 文件选择配置
export interface FileSelectionConfig {
allowedExtensions: string[];
maxFileSize: number; // bytes
multiple: boolean;
}
export const IMAGE_FILE_CONFIG: FileSelectionConfig = {
allowedExtensions: ['jpg', 'jpeg', 'png'],
maxFileSize: 10 * 1024 * 1024, // 10MB
multiple: false,
};
export const BATCH_FOLDER_CONFIG = {
supportedFormats: ['jpg', 'jpeg', 'png'],
maxFilesPerBatch: 100,
maxTotalSize: 500 * 1024 * 1024, // 500MB
};
// API调用函数类型
export interface ImageEditingAPI {
setConfig: (config: ImageEditingConfig) => Promise<void>;
setApiKey: (apiKey: string) => Promise<void>;
editSingleImage: (
inputPath: string,
outputPath: string,
prompt: string,
params: ImageEditingParams
) => Promise<string>;
createTask: (
inputPath: string,
outputPath: string,
prompt: string,
params: ImageEditingParams
) => Promise<string>;
executeTask: (taskId: string) => Promise<void>;
getTaskStatus: (taskId: string) => Promise<ImageEditingTask>;
editBatchImages: (
inputFolder: string,
outputFolder: string,
prompt: string,
params: ImageEditingParams
) => Promise<string>;
createBatchTask: (
inputFolder: string,
outputFolder: string,
prompt: string,
params: ImageEditingParams
) => Promise<string>;
getBatchTaskStatus: (taskId: string) => Promise<BatchImageEditingTask>;
getAllTasks: () => Promise<ImageEditingTask[]>;
getAllBatchTasks: () => Promise<BatchImageEditingTask[]>;
clearCompletedTasks: () => Promise<void>;
cancelTask: (taskId: string) => Promise<void>;
}

View File

@@ -0,0 +1,170 @@
# 图像编辑工具使用指南
## 功能概述
图像编辑工具是基于火山云SeedEdit 3.0 API开发的智能图像编辑工具支持通过自然语言提示词对图像进行编辑和变换。
## 主要功能
### 1. 单张图片编辑
- 选择输入图片(支持 JPG、JPEG、PNG 格式)
- 输入编辑提示词
- 设置输出路径
- 配置编辑参数
- 实时查看编辑结果
### 2. 批量图片处理
- 选择包含图片的输入文件夹
- 选择输出文件夹
- 输入统一的编辑提示词
- 批量处理多张图片
- 实时监控处理进度
### 3. 参数配置
- **引导强度 (Guidance Scale)**: 控制提示词对生成结果的影响程度 (1.0-10.0)
- **随机种子 (Seed)**: 控制生成结果的随机性,相同种子产生相同结果
- **水印设置**: 是否在生成图片上添加"AI生成"水印
- **响应格式**: URL链接或Base64编码
- **尺寸设置**: 自适应尺寸
## 使用步骤
### 配置API密钥
1. 点击页面右上角的"配置"按钮
2. 输入火山云API密钥
3. 点击"保存配置"
### 单张图片编辑
1. 切换到"单张编辑"标签页
2. 点击"选择图片"按钮,选择要编辑的图片
3. 点击"选择路径"按钮,设置输出路径
4. 在提示词输入框中输入编辑指令,例如:
- "改成爱心形状的泡泡"
- "转换为水彩画风格"
- "改为春天的樱花背景"
5. 根据需要调整右侧的参数配置
6. 点击"开始编辑"按钮
7. 等待处理完成,查看结果
### 批量图片处理
1. 切换到"批量处理"标签页
2. 点击"选择输入文件夹",选择包含图片的文件夹
3. 点击"选择输出文件夹",设置处理结果的保存位置
4. 输入批量编辑的提示词
5. 调整参数配置
6. 点击"开始批量处理"
7. 实时查看处理进度和结果统计
## 预设提示词
工具提供了多种分类的预设提示词,方便快速使用:
### 风格转换
- 转换为水彩画风格
- 转换为油画风格
- 转换为卡通动漫风格
- 转换为素描风格
- 转换为复古胶片风格
### 场景变换
- 改为春天的樱花背景
- 改为夏日海滩背景
- 改为秋天的枫叶背景
- 改为冬日雪景背景
- 改为城市夜景背景
### 色彩调整
- 增强色彩饱和度
- 转换为黑白照片
- 增加暖色调
- 增加冷色调
- 提高亮度和对比度
### 特效处理
- 添加光晕效果
- 添加雨滴效果
- 添加阳光透射效果
- 添加星空效果
- 添加梦幻光斑效果
## 任务管理
### 查看任务状态
1. 点击"任务管理"按钮
2. 查看所有单个任务和批量任务的状态
3. 监控任务进度和结果
### 任务状态说明
- **等待中**: 任务已创建,等待处理
- **处理中**: 任务正在执行
- **已完成**: 任务成功完成
- **失败**: 任务执行失败
- **已取消**: 任务被用户取消
### 任务操作
- **刷新**: 更新任务列表状态
- **清理已完成**: 删除已完成和失败的任务记录
## 技术规格
### 支持的图片格式
- JPEG (.jpg, .jpeg)
- PNG (.png)
### 图片要求
- 宽高比: 1/3 到 3 之间
- 最小尺寸: 宽高均大于14像素
- 最大文件大小: 10MB
### API配置
- API地址: https://ark.cn-beijing.volces.com/api/v3/images/generations
- 模型ID: doubao-seededit-3-0-i2i-250628
- 超时时间: 120秒
- 最大重试次数: 3次
## 注意事项
1. **API密钥安全**: 请妥善保管您的火山云API密钥不要泄露给他人
2. **网络连接**: 确保网络连接稳定,图像编辑需要上传和下载数据
3. **文件路径**: 确保输出路径有写入权限
4. **批量处理**: 批量处理大量图片时请耐心等待,避免中途关闭应用
5. **费用控制**: 图像编辑会消耗API调用次数请注意控制使用量
## 故障排除
### 常见问题
**Q: 提示"API密钥未设置"**
A: 请先在配置页面设置有效的火山云API密钥
**Q: 图片编辑失败**
A: 检查图片格式是否支持,文件大小是否超限,网络连接是否正常
**Q: 批量处理中断**
A: 检查输入文件夹是否包含有效图片,输出文件夹是否有写入权限
**Q: 处理速度慢**
A: 图像编辑需要AI模型处理请耐心等待。可以尝试降低图片分辨率或减少批量数量
### 错误代码说明
- 网络错误: 检查网络连接
- 认证失败: 检查API密钥是否正确
- 参数错误: 检查输入参数是否符合要求
- 服务器错误: 稍后重试或联系技术支持
## 更新日志
### v1.0.0 (2024-01-31)
- 初始版本发布
- 支持单张图片编辑
- 支持批量图片处理
- 提供预设提示词
- 实现任务管理功能
- 集成火山云SeedEdit 3.0 API
## 技术支持
如遇到问题或需要技术支持,请联系开发团队或查看项目文档。