feat: 实现图片格式转换和实时事件通知功能
- 添加多种图片格式支持(WebP, BMP, TIFF, GIF等) - 实现自动格式转换功能,将不支持的格式转换为JPG - 使用Tauri事件系统替代定时轮询,实现任务状态实时更新 - 优化批量处理性能和用户体验 - 修复前端状态不实时更新的问题 主要变更: 1. 后端添加image crate依赖和格式转换逻辑 2. 前端添加事件监听机制,移除定时轮询 3. 实现进度回调和实时状态通知 4. 支持更多图片格式的批量处理
This commit is contained in:
parent
d9d1c4df52
commit
2f463507b8
|
|
@ -40,6 +40,7 @@ tracing = "0.1"
|
|||
tracing-subscriber = { version = "0.3", features = ["env-filter", "chrono"] }
|
||||
tracing-appender = "0.2"
|
||||
reqwest = { version = "0.11", features = ["json", "multipart"] }
|
||||
image = { version = "0.24", features = ["jpeg", "png", "webp", "bmp", "tiff", "gif"] }
|
||||
toml = "0.8"
|
||||
tree-sitter = "0.20"
|
||||
tree-sitter-json = "0.20"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use std::fs;
|
|||
use base64::prelude::*;
|
||||
use uuid::Uuid;
|
||||
use tokio::time::sleep;
|
||||
use image::{ImageFormat, DynamicImage};
|
||||
|
||||
use crate::data::models::image_editing::{
|
||||
ImageEditingConfig, ImageEditingRequest, ImageEditingResponse, ImageEditingTask,
|
||||
|
|
@ -34,6 +35,142 @@ impl ImageEditingService {
|
|||
}
|
||||
}
|
||||
|
||||
/// 支持的图片格式列表
|
||||
const SUPPORTED_FORMATS: &'static [&'static str] = &[
|
||||
"jpg", "jpeg", "png", "webp", "bmp", "tiff", "gif", "ico", "tga"
|
||||
];
|
||||
|
||||
/// 原生支持的格式(无需转换)
|
||||
const NATIVE_FORMATS: &'static [&'static str] = &["jpg", "jpeg", "png"];
|
||||
|
||||
/// 检查文件是否为支持的图片格式
|
||||
fn is_supported_image_format(file_path: &Path) -> bool {
|
||||
if let Some(extension) = file_path.extension() {
|
||||
let ext = extension.to_string_lossy().to_lowercase();
|
||||
Self::SUPPORTED_FORMATS.contains(&ext.as_str())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查文件是否需要格式转换
|
||||
fn needs_format_conversion(file_path: &Path) -> bool {
|
||||
if let Some(extension) = file_path.extension() {
|
||||
let ext = extension.to_string_lossy().to_lowercase();
|
||||
Self::SUPPORTED_FORMATS.contains(&ext.as_str()) &&
|
||||
!Self::NATIVE_FORMATS.contains(&ext.as_str())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取图片格式
|
||||
fn get_image_format(file_path: &Path) -> Option<ImageFormat> {
|
||||
if let Some(extension) = file_path.extension() {
|
||||
let ext = extension.to_string_lossy().to_lowercase();
|
||||
match ext.as_str() {
|
||||
"jpg" | "jpeg" => Some(ImageFormat::Jpeg),
|
||||
"png" => Some(ImageFormat::Png),
|
||||
"webp" => Some(ImageFormat::WebP),
|
||||
"bmp" => Some(ImageFormat::Bmp),
|
||||
"tiff" | "tif" => Some(ImageFormat::Tiff),
|
||||
"gif" => Some(ImageFormat::Gif),
|
||||
"ico" => Some(ImageFormat::Ico),
|
||||
"tga" => Some(ImageFormat::Tga),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 确定转换目标格式
|
||||
fn determine_target_format(source_format: ImageFormat) -> ImageFormat {
|
||||
match source_format {
|
||||
// 有损格式转为JPEG
|
||||
ImageFormat::WebP | ImageFormat::Jpeg => ImageFormat::Jpeg,
|
||||
// 无损格式转为PNG
|
||||
ImageFormat::Png | ImageFormat::Bmp | ImageFormat::Tiff |
|
||||
ImageFormat::Gif | ImageFormat::Ico | ImageFormat::Tga => ImageFormat::Png,
|
||||
// 其他格式默认转为PNG
|
||||
_ => ImageFormat::Png,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取格式对应的文件扩展名
|
||||
fn get_format_extension(format: ImageFormat) -> &'static str {
|
||||
match format {
|
||||
ImageFormat::Jpeg => "jpg",
|
||||
ImageFormat::Png => "png",
|
||||
_ => "png", // 默认使用PNG
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换图片格式
|
||||
async fn convert_image_format(
|
||||
input_path: &Path,
|
||||
output_dir: &Path,
|
||||
) -> Result<PathBuf> {
|
||||
println!("🔄 开始转换图片格式: {}", input_path.display());
|
||||
|
||||
// 检查输入文件是否存在
|
||||
if !input_path.exists() {
|
||||
return Err(anyhow!("输入文件不存在: {}", input_path.display()));
|
||||
}
|
||||
|
||||
// 获取源格式
|
||||
let source_format = Self::get_image_format(input_path)
|
||||
.ok_or_else(|| anyhow!("不支持的图片格式: {}", input_path.display()))?;
|
||||
|
||||
// 确定目标格式
|
||||
let target_format = Self::determine_target_format(source_format);
|
||||
let target_extension = Self::get_format_extension(target_format);
|
||||
|
||||
// 生成输出文件路径
|
||||
let file_stem = input_path.file_stem()
|
||||
.ok_or_else(|| anyhow!("无法获取文件名"))?;
|
||||
let output_filename = format!("{}_converted.{}",
|
||||
file_stem.to_string_lossy(), target_extension);
|
||||
let output_path = output_dir.join(output_filename);
|
||||
|
||||
// 确保输出目录存在
|
||||
tokio::fs::create_dir_all(output_dir).await
|
||||
.map_err(|e| anyhow!("创建输出目录失败: {}", e))?;
|
||||
|
||||
// 读取并转换图片
|
||||
let img = image::open(input_path)
|
||||
.map_err(|e| anyhow!("读取图片失败: {}", e))?;
|
||||
|
||||
// 保存转换后的图片
|
||||
img.save_with_format(&output_path, target_format)
|
||||
.map_err(|e| anyhow!("保存转换后的图片失败: {}", e))?;
|
||||
|
||||
println!("✅ 图片格式转换完成: {} -> {}",
|
||||
input_path.display(), output_path.display());
|
||||
|
||||
Ok(output_path)
|
||||
}
|
||||
|
||||
/// 获取或创建临时转换目录
|
||||
async fn get_temp_conversion_dir() -> Result<PathBuf> {
|
||||
let temp_dir = std::env::temp_dir().join("mixvideo_image_conversion");
|
||||
tokio::fs::create_dir_all(&temp_dir).await
|
||||
.map_err(|e| anyhow!("创建临时转换目录失败: {}", e))?;
|
||||
Ok(temp_dir)
|
||||
}
|
||||
|
||||
/// 清理临时转换文件
|
||||
async fn cleanup_converted_files(temp_files: &[PathBuf]) -> Result<()> {
|
||||
for file_path in temp_files {
|
||||
if file_path.exists() {
|
||||
if let Err(e) = tokio::fs::remove_file(file_path).await {
|
||||
println!("⚠️ 清理临时文件失败: {} - {}", file_path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 使用自定义配置创建图像编辑服务
|
||||
pub fn with_config(config: ImageEditingConfig) -> Self {
|
||||
let client = Client::builder()
|
||||
|
|
@ -49,7 +186,7 @@ impl ImageEditingService {
|
|||
self.config.api_key = api_key;
|
||||
}
|
||||
|
||||
/// 验证图像文件格式
|
||||
/// 验证图像文件格式(用于Base64编码,只接受原生支持的格式)
|
||||
fn validate_image_format(file_path: &Path) -> Result<()> {
|
||||
let extension = file_path
|
||||
.extension()
|
||||
|
|
@ -59,7 +196,7 @@ impl ImageEditingService {
|
|||
|
||||
match extension.as_str() {
|
||||
"jpg" | "jpeg" | "png" => Ok(()),
|
||||
_ => Err(anyhow!("不支持的图像格式: {},仅支持 JPEG 和 PNG", extension)),
|
||||
_ => Err(anyhow!("文件格式错误: {},此时应该已经转换为 JPEG 或 PNG 格式", extension)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -221,7 +358,7 @@ impl ImageEditingService {
|
|||
Ok(response)
|
||||
}
|
||||
|
||||
/// 获取文件夹中的所有图像文件
|
||||
/// 获取文件夹中的所有图像文件(包括需要转换的格式)
|
||||
fn get_image_files(folder_path: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut image_files = Vec::new();
|
||||
|
||||
|
|
@ -240,13 +377,8 @@ impl ImageEditingService {
|
|||
let entry = entry.map_err(|e| anyhow!("读取文件夹条目失败: {}", e))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() {
|
||||
if let Some(extension) = path.extension() {
|
||||
let ext = extension.to_string_lossy().to_lowercase();
|
||||
if matches!(ext.as_str(), "jpg" | "jpeg" | "png") {
|
||||
image_files.push(path);
|
||||
}
|
||||
}
|
||||
if path.is_file() && Self::is_supported_image_format(&path) {
|
||||
image_files.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -255,6 +387,21 @@ impl ImageEditingService {
|
|||
}
|
||||
|
||||
image_files.sort();
|
||||
|
||||
// 统计不同格式的文件数量
|
||||
let mut format_counts = std::collections::HashMap::new();
|
||||
for file in &image_files {
|
||||
if let Some(ext) = file.extension() {
|
||||
let ext_str = ext.to_string_lossy().to_lowercase();
|
||||
*format_counts.entry(ext_str).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
println!("📊 发现的图片格式统计:");
|
||||
for (format, count) in format_counts {
|
||||
println!(" {} 格式: {} 个文件", format.to_uppercase(), count);
|
||||
}
|
||||
|
||||
Ok(image_files)
|
||||
}
|
||||
|
||||
|
|
@ -376,6 +523,149 @@ impl ImageEditingService {
|
|||
Ok(batch_task)
|
||||
}
|
||||
|
||||
/// 批量编辑图像(带任务状态更新回调)
|
||||
pub async fn edit_batch_images_with_callback(
|
||||
&self,
|
||||
input_folder: &Path,
|
||||
output_folder: &Path,
|
||||
prompt: &str,
|
||||
params: &ImageEditingParams,
|
||||
task_update_callback: Option<Box<dyn Fn(BatchImageEditingTask) + 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;
|
||||
|
||||
// 初始回调,通知任务已开始
|
||||
if let Some(ref callback) = task_update_callback {
|
||||
callback(batch_task.clone());
|
||||
}
|
||||
|
||||
// 创建临时转换目录
|
||||
let temp_conversion_dir = Self::get_temp_conversion_dir().await?;
|
||||
let mut converted_files = Vec::new();
|
||||
|
||||
// 处理每个图像
|
||||
for (index, input_file) in image_files.iter().enumerate() {
|
||||
let output_file = Self::generate_output_filename(input_file, output_folder);
|
||||
|
||||
// 更新任务状态
|
||||
if let Some(task) = batch_task.individual_tasks.get_mut(index) {
|
||||
task.set_processing();
|
||||
}
|
||||
|
||||
// 检查是否需要格式转换
|
||||
let actual_input_file = if Self::needs_format_conversion(input_file) {
|
||||
println!("🔄 需要转换格式: {}", input_file.display());
|
||||
match Self::convert_image_format(input_file, &temp_conversion_dir).await {
|
||||
Ok(converted_path) => {
|
||||
converted_files.push(converted_path.clone());
|
||||
converted_path
|
||||
}
|
||||
Err(e) => {
|
||||
println!("❌ 格式转换失败: {} - {}", input_file.display(), e);
|
||||
if let Some(task) = batch_task.individual_tasks.get_mut(index) {
|
||||
task.set_failed(format!("格式转换失败: {}", e));
|
||||
}
|
||||
batch_task.update_progress();
|
||||
if let Some(ref callback) = task_update_callback {
|
||||
callback(batch_task.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
input_file.clone()
|
||||
};
|
||||
|
||||
// 处理单个图像
|
||||
match self.edit_single_image(&actual_input_file, &output_file, prompt, params).await {
|
||||
Ok(response) => {
|
||||
if let Some(task) = batch_task.individual_tasks.get_mut(index) {
|
||||
task.set_completed(output_file.to_string_lossy().to_string(), response);
|
||||
}
|
||||
println!("✅ 完成: {}", input_file.file_name().unwrap_or_default().to_string_lossy());
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(task) = batch_task.individual_tasks.get_mut(index) {
|
||||
task.set_failed(e.to_string());
|
||||
}
|
||||
println!("❌ 失败: {} - {}", input_file.file_name().unwrap_or_default().to_string_lossy(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新批量任务进度
|
||||
batch_task.update_progress();
|
||||
|
||||
// 每处理完一个图像就回调更新状态
|
||||
if let Some(ref callback) = task_update_callback {
|
||||
callback(batch_task.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 清理临时转换文件
|
||||
if !converted_files.is_empty() {
|
||||
println!("🧹 清理 {} 个临时转换文件", converted_files.len());
|
||||
if let Err(e) = Self::cleanup_converted_files(&converted_files).await {
|
||||
println!("⚠️ 清理临时文件时出错: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
println!("🎉 批量编辑完成!");
|
||||
println!("总计: {} 个文件", batch_task.total_images);
|
||||
println!("成功: {} 个文件", batch_task.successful_images);
|
||||
println!("失败: {} 个文件", batch_task.failed_images);
|
||||
|
||||
// 最终回调
|
||||
if let Some(ref callback) = task_update_callback {
|
||||
callback(batch_task.clone());
|
||||
}
|
||||
|
||||
Ok(batch_task)
|
||||
}
|
||||
|
||||
/// 创建编辑任务(用于异步处理)
|
||||
pub async fn create_edit_task(
|
||||
&self,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use tauri::State;
|
||||
use tauri::{State, Manager, AppHandle, Emitter};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -168,6 +168,7 @@ pub async fn get_image_editing_task_status(
|
|||
/// 批量编辑图像
|
||||
#[tauri::command]
|
||||
pub async fn edit_batch_images(
|
||||
app: AppHandle,
|
||||
state: State<'_, ImageEditingState>,
|
||||
input_folder: String,
|
||||
output_folder: String,
|
||||
|
|
@ -177,25 +178,112 @@ pub async fn edit_batch_images(
|
|||
let input_folder = Path::new(&input_folder);
|
||||
let output_folder = Path::new(&output_folder);
|
||||
|
||||
// 生成任务ID
|
||||
let task_id = Uuid::new_v4().to_string();
|
||||
|
||||
// 立即创建批量任务并存储到状态中
|
||||
let mut batch_task = BatchImageEditingTask::new(
|
||||
task_id.clone(),
|
||||
input_folder.to_string_lossy().to_string(),
|
||||
output_folder.to_string_lossy().to_string(),
|
||||
prompt.clone(),
|
||||
crate::data::models::image_editing::ImageEditingRequest {
|
||||
model: "doubao-seededit-3-0-i2i-250628".to_string(),
|
||||
prompt: prompt.clone(),
|
||||
image: String::new(),
|
||||
response_format: Some(params.response_format.clone()),
|
||||
size: Some(params.size.clone()),
|
||||
seed: Some(params.seed),
|
||||
guidance_scale: Some(params.guidance_scale),
|
||||
watermark: Some(params.watermark),
|
||||
},
|
||||
);
|
||||
|
||||
// 设置任务状态为处理中
|
||||
batch_task.status = ImageEditingTaskStatus::Processing;
|
||||
|
||||
// 立即存储批量任务,这样前端就能获取到
|
||||
{
|
||||
let mut batch_tasks = state.batch_tasks.lock().map_err(|e| format!("获取任务存储失败: {}", e))?;
|
||||
batch_tasks.insert(task_id.clone(), batch_task.clone());
|
||||
}
|
||||
|
||||
// 克隆服务以避免跨await持有锁
|
||||
let service = {
|
||||
let service_guard = state.service.lock().map_err(|e| format!("获取服务失败: {}", e))?;
|
||||
service_guard.clone()
|
||||
};
|
||||
|
||||
match service.edit_batch_images(input_folder, output_folder, &prompt, ¶ms, None).await {
|
||||
Ok(batch_task) => {
|
||||
let task_id = batch_task.id.clone();
|
||||
// 克隆必要的数据用于异步任务
|
||||
let batch_tasks_clone = state.batch_tasks.clone();
|
||||
let task_id_clone = task_id.clone();
|
||||
|
||||
// 存储批量任务
|
||||
if let Ok(mut batch_tasks) = state.batch_tasks.lock() {
|
||||
batch_tasks.insert(task_id.clone(), batch_task);
|
||||
}
|
||||
|
||||
Ok(task_id)
|
||||
// 创建任务状态更新回调
|
||||
let app_clone = app.clone();
|
||||
let update_callback = Box::new(move |updated_task: BatchImageEditingTask| {
|
||||
if let Ok(mut batch_tasks) = batch_tasks_clone.lock() {
|
||||
batch_tasks.insert(task_id_clone.clone(), updated_task.clone());
|
||||
}
|
||||
Err(e) => Err(format!("批量编辑失败: {}", e)),
|
||||
}
|
||||
|
||||
// 发送Tauri事件通知前端
|
||||
if let Err(e) = app_clone.emit("batch-task-updated", &updated_task) {
|
||||
println!("发送任务更新事件失败: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// 异步执行批量处理
|
||||
let service_clone = service.clone();
|
||||
let input_folder_clone = input_folder.to_path_buf();
|
||||
let output_folder_clone = output_folder.to_path_buf();
|
||||
let prompt_clone = prompt.clone();
|
||||
let params_clone = params.clone();
|
||||
let task_id_for_spawn = task_id.clone();
|
||||
let batch_tasks_for_spawn = state.batch_tasks.clone();
|
||||
let app_for_spawn = app.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
match service_clone.edit_batch_images_with_callback(
|
||||
&input_folder_clone,
|
||||
&output_folder_clone,
|
||||
&prompt_clone,
|
||||
¶ms_clone,
|
||||
Some(update_callback),
|
||||
).await {
|
||||
Ok(final_task) => {
|
||||
// 存储最终任务状态
|
||||
if let Ok(mut batch_tasks) = batch_tasks_for_spawn.lock() {
|
||||
batch_tasks.insert(task_id_for_spawn.clone(), final_task.clone());
|
||||
}
|
||||
|
||||
// 发送最终完成事件
|
||||
if let Err(e) = app_for_spawn.emit("batch-task-completed", &final_task) {
|
||||
println!("发送任务完成事件失败: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// 处理失败,更新任务状态
|
||||
let mut failed_task = None;
|
||||
if let Ok(mut batch_tasks) = batch_tasks_for_spawn.lock() {
|
||||
if let Some(task) = batch_tasks.get_mut(&task_id_for_spawn) {
|
||||
task.status = ImageEditingTaskStatus::Failed;
|
||||
task.updated_at = chrono::Utc::now();
|
||||
failed_task = Some(task.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 发送失败事件
|
||||
if let Some(task) = failed_task {
|
||||
if let Err(e) = app_for_spawn.emit("batch-task-failed", &task) {
|
||||
println!("发送任务失败事件失败: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
println!("批量编辑失败: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(task_id)
|
||||
}
|
||||
|
||||
/// 创建批量编辑任务
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
Save
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import {
|
||||
ImageEditingTask,
|
||||
|
|
@ -24,7 +25,6 @@ import {
|
|||
ImageEditingTaskStatus,
|
||||
DEFAULT_IMAGE_EDITING_PARAMS,
|
||||
DEFAULT_IMAGE_EDITING_CONFIG,
|
||||
PRESET_PROMPTS,
|
||||
TASK_STATUS_CONFIG,
|
||||
IMAGE_FILE_CONFIG,
|
||||
} from '../../types/imageEditing';
|
||||
|
|
@ -56,14 +56,73 @@ const ImageEditingTool: React.FC = () => {
|
|||
const [tasks, setTasks] = useState<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();
|
||||
|
||||
// 设置Tauri事件监听
|
||||
const setupEventListeners = async () => {
|
||||
try {
|
||||
// 监听批量任务更新事件
|
||||
const unlistenUpdate = await listen<BatchImageEditingTask>('batch-task-updated', (event) => {
|
||||
console.log('收到任务更新事件:', event.payload);
|
||||
setBatchTasks(prev => {
|
||||
const updated = [...prev];
|
||||
const index = updated.findIndex(task => task.id === event.payload.id);
|
||||
if (index >= 0) {
|
||||
updated[index] = event.payload;
|
||||
} else {
|
||||
updated.push(event.payload);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
});
|
||||
|
||||
// 监听批量任务完成事件
|
||||
const unlistenComplete = await listen<BatchImageEditingTask>('batch-task-completed', (event) => {
|
||||
console.log('收到任务完成事件:', event.payload);
|
||||
setBatchTasks(prev => {
|
||||
const updated = [...prev];
|
||||
const index = updated.findIndex(task => task.id === event.payload.id);
|
||||
if (index >= 0) {
|
||||
updated[index] = event.payload;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
});
|
||||
|
||||
// 监听批量任务失败事件
|
||||
const unlistenFailed = await listen<BatchImageEditingTask>('batch-task-failed', (event) => {
|
||||
console.log('收到任务失败事件:', event.payload);
|
||||
setBatchTasks(prev => {
|
||||
const updated = [...prev];
|
||||
const index = updated.findIndex(task => task.id === event.payload.id);
|
||||
if (index >= 0) {
|
||||
updated[index] = event.payload;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
});
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
unlistenUpdate();
|
||||
unlistenComplete();
|
||||
unlistenFailed();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('设置事件监听失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
setupEventListeners();
|
||||
}, []);
|
||||
|
||||
// 加载任务列表
|
||||
|
|
@ -80,6 +139,8 @@ const ImageEditingTool: React.FC = () => {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// 注意:现在使用事件驱动的实时更新,不再需要定时刷新和状态检查
|
||||
|
||||
// 设置API密钥
|
||||
const handleSetApiKey = useCallback(async () => {
|
||||
if (!apiKeyInput.trim()) {
|
||||
|
|
@ -236,37 +297,12 @@ const ImageEditingTool: React.FC = () => {
|
|||
params: params,
|
||||
});
|
||||
|
||||
// 创建任务对象用于UI显示
|
||||
const newTask: BatchImageEditingTask = {
|
||||
id: taskId,
|
||||
input_folder_path: inputFolder,
|
||||
output_folder_path: outputFolder,
|
||||
prompt: batchPrompt,
|
||||
total_images: 0, // 将由后端更新
|
||||
processed_images: 0,
|
||||
successful_images: 0,
|
||||
failed_images: 0,
|
||||
status: ImageEditingTaskStatus.Processing,
|
||||
progress: 0,
|
||||
individual_tasks: [],
|
||||
request_params: {
|
||||
model: 'doubao-seededit-3-0-i2i-250628',
|
||||
prompt: batchPrompt,
|
||||
image: '',
|
||||
response_format: 'url',
|
||||
size: 'adaptive',
|
||||
seed: params.seed,
|
||||
guidance_scale: params.guidance_scale,
|
||||
watermark: params.watermark,
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setBatchTask(newTask);
|
||||
console.log('批量处理任务已创建,任务ID:', taskId);
|
||||
setIsProcessing(false);
|
||||
alert('批量处理任务已启动!');
|
||||
loadTasks(); // 刷新任务列表
|
||||
|
||||
// 立即刷新任务列表以显示新创建的任务
|
||||
loadTasks();
|
||||
|
||||
} catch (error) {
|
||||
console.error('批量编辑失败:', error);
|
||||
|
|
@ -275,14 +311,7 @@ const ImageEditingTool: React.FC = () => {
|
|||
}
|
||||
}, [inputFolder, outputFolder, batchPrompt, params, config.api_key, loadTasks]);
|
||||
|
||||
// 应用预设提示词
|
||||
const handleApplyPresetPrompt = useCallback((presetPrompt: string) => {
|
||||
if (activeTab === 'single') {
|
||||
setPrompt(presetPrompt);
|
||||
} else {
|
||||
setBatchPrompt(presetPrompt);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
|
||||
// 获取状态图标
|
||||
const getStatusIcon = (status: ImageEditingTaskStatus) => {
|
||||
|
|
@ -578,31 +607,80 @@ const ImageEditingTool: React.FC = () => {
|
|||
)}
|
||||
</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>
|
||||
))}
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">最近任务</h3>
|
||||
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{/* 显示最近的单个任务 */}
|
||||
{tasks.slice(0, 3).map((task) => (
|
||||
<div key={task.id} className="p-3 border border-gray-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{getStatusIcon(task.status)}
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{TASK_STATUS_CONFIG[task.status].label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 truncate">
|
||||
{task.prompt.substring(0, 40)}...
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{new Date(task.created_at).toLocaleString()}
|
||||
</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 transition-all duration-300"
|
||||
style={{ width: `${task.progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 显示最近的批量任务 */}
|
||||
{batchTasks.slice(0, 2).map((task) => (
|
||||
<div key={task.id} className="p-3 border border-gray-200 rounded-lg bg-blue-50">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{getStatusIcon(task.status)}
|
||||
<span className="text-sm font-medium text-blue-900">
|
||||
批量 - {TASK_STATUS_CONFIG[task.status].label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-blue-700 truncate">
|
||||
{task.prompt.substring(0, 40)}...
|
||||
</div>
|
||||
<div className="text-xs text-blue-600 mt-1">
|
||||
{task.processed_images} / {task.total_images} 已处理
|
||||
</div>
|
||||
{task.progress > 0 && (
|
||||
<div className="mt-2 w-full bg-blue-200 rounded-full h-1">
|
||||
<div
|
||||
className="bg-blue-500 h-1 rounded-full transition-all duration-300"
|
||||
style={{ width: `${task.progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{tasks.length === 0 && batchTasks.length === 0 && (
|
||||
<div className="text-center py-4 text-gray-500 text-sm">
|
||||
暂无任务
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(tasks.length > 3 || batchTasks.length > 2) && (
|
||||
<button
|
||||
onClick={() => setShowTasks(true)}
|
||||
className="w-full mt-3 px-3 py-2 text-sm text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
查看全部任务
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 参数配置 */}
|
||||
|
|
@ -669,6 +747,8 @@ const ImageEditingTool: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 将任务管理里的任务列表放到这里 */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue