feat: 添加图像编辑工具
- 基于火山云SeedEdit 3.0 API的智能图像编辑工具 - 支持单张图片编辑和批量处理功能 - 提供丰富的预设提示词和参数配置 - 实现任务管理和进度监控 - 集成到便捷工具系统 功能特性: - 单张图片编辑:选择图片、输入提示词、实时编辑 - 批量处理:文件夹批量处理、进度监控、结果统计 - 参数配置:引导强度、随机种子、水印设置等 - 预设提示词:风格转换、场景变换、色彩调整、特效处理 - 任务管理:状态监控、历史记录、清理功能 技术实现: - Rust后端:图像编辑服务、API调用、错误处理 - React前端:响应式界面、实时更新、用户体验优化 - 类型安全:完整的TypeScript类型定义 - 模块化设计:可扩展的架构和组件复用
This commit is contained in:
290
apps/desktop/src-tauri/src/data/models/image_editing.rs
Normal file
290
apps/desktop/src-tauri/src/data/models/image_editing.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ¶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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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| {
|
||||
// 初始化日志系统
|
||||
|
||||
@@ -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, ¶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<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, ¶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<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, ¶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<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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
884
apps/desktop/src/pages/tools/ImageEditingTool.tsx
Normal file
884
apps/desktop/src/pages/tools/ImageEditingTool.tsx
Normal 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;
|
||||
300
apps/desktop/src/types/imageEditing.ts
Normal file
300
apps/desktop/src/types/imageEditing.ts
Normal 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>;
|
||||
}
|
||||
170
docs/image-editing-tool-guide.md
Normal file
170
docs/image-editing-tool-guide.md
Normal 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
|
||||
|
||||
## 技术支持
|
||||
|
||||
如遇到问题或需要技术支持,请联系开发团队或查看项目文档。
|
||||
Reference in New Issue
Block a user