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 template_matching_result;
|
||||||
pub mod export_record;
|
pub mod export_record;
|
||||||
pub mod video_generation;
|
pub mod video_generation;
|
||||||
|
pub mod image_editing;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod outfit_search;
|
pub mod outfit_search;
|
||||||
pub mod gemini_analysis;
|
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 logging;
|
||||||
pub mod gemini_service;
|
pub mod gemini_service;
|
||||||
pub mod video_generation_service;
|
pub mod video_generation_service;
|
||||||
|
pub mod image_editing_service;
|
||||||
pub mod tolerant_json_parser;
|
pub mod tolerant_json_parser;
|
||||||
pub mod markdown_parser;
|
pub mod markdown_parser;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub fn run() {
|
|||||||
.manage(AppState::new())
|
.manage(AppState::new())
|
||||||
.manage(commands::tolerant_json_commands::JsonParserState::new())
|
.manage(commands::tolerant_json_commands::JsonParserState::new())
|
||||||
.manage(commands::markdown_commands::MarkdownParserState::new())
|
.manage(commands::markdown_commands::MarkdownParserState::new())
|
||||||
|
.manage(commands::image_editing_commands::ImageEditingState::new())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::project_commands::create_project,
|
commands::project_commands::create_project,
|
||||||
commands::project_commands::get_all_projects,
|
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_volcano_video,
|
||||||
commands::volcano_video_commands::download_video_to_directory,
|
commands::volcano_video_commands::download_video_to_directory,
|
||||||
commands::volcano_video_commands::batch_download_volcano_videos,
|
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| {
|
.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 template_matching_result_commands;
|
||||||
pub mod export_record_commands;
|
pub mod export_record_commands;
|
||||||
pub mod video_generation_commands;
|
pub mod video_generation_commands;
|
||||||
|
pub mod image_editing_commands;
|
||||||
pub mod tools_commands;
|
pub mod tools_commands;
|
||||||
pub mod outfit_search_commands;
|
pub mod outfit_search_commands;
|
||||||
pub mod material_search_commands;
|
pub mod material_search_commands;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import OutfitFavoritesTool from './pages/tools/OutfitFavoritesTool';
|
|||||||
import OutfitComparisonTool from './pages/tools/OutfitComparisonTool';
|
import OutfitComparisonTool from './pages/tools/OutfitComparisonTool';
|
||||||
import MaterialSearchTool from './pages/tools/MaterialSearchTool';
|
import MaterialSearchTool from './pages/tools/MaterialSearchTool';
|
||||||
import ImageGenerationTool from './pages/tools/ImageGenerationTool';
|
import ImageGenerationTool from './pages/tools/ImageGenerationTool';
|
||||||
|
import ImageEditingTool from './pages/tools/ImageEditingTool';
|
||||||
import VoiceGenerationHistory from './pages/tools/VoiceGenerationHistory';
|
import VoiceGenerationHistory from './pages/tools/VoiceGenerationHistory';
|
||||||
import VoiceCloneTool from './pages/tools/VoiceCloneTool';
|
import VoiceCloneTool from './pages/tools/VoiceCloneTool';
|
||||||
import VideoGenerationTool from './pages/tools/VideoGenerationTool';
|
import VideoGenerationTool from './pages/tools/VideoGenerationTool';
|
||||||
@@ -143,6 +144,7 @@ function App() {
|
|||||||
<Route path="/tools/outfit-comparison" element={<OutfitComparisonTool />} />
|
<Route path="/tools/outfit-comparison" element={<OutfitComparisonTool />} />
|
||||||
<Route path="/tools/material-search" element={<MaterialSearchTool />} />
|
<Route path="/tools/material-search" element={<MaterialSearchTool />} />
|
||||||
<Route path="/tools/image-generation" element={<ImageGenerationTool />} />
|
<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-generation-history" element={<VoiceGenerationHistory />} />
|
||||||
<Route path="/tools/voice-clone" element={<VoiceCloneTool />} />
|
<Route path="/tools/voice-clone" element={<VoiceCloneTool />} />
|
||||||
<Route path="/tools/volcano-video-generation" element={<VideoGenerationTool />} />
|
<Route path="/tools/volcano-video-generation" element={<VideoGenerationTool />} />
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
Image,
|
Image,
|
||||||
Mic,
|
Mic,
|
||||||
Video
|
Video,
|
||||||
|
Wand2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Tool, ToolCategory, ToolStatus } from '../types/tool';
|
import { Tool, ToolCategory, ToolStatus } from '../types/tool';
|
||||||
|
|
||||||
@@ -152,6 +153,21 @@ export const TOOLS_DATA: Tool[] = [
|
|||||||
isPopular: true,
|
isPopular: true,
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
lastUpdated: '2024-01-31'
|
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