feat: 实现模特详情页视频生成功能
- 新增模特详情页组件,支持照片上传和视频生成 - 实现视频生成数据模型和仓库层 - 集成Dify API进行视频生成 - 添加云存储上传功能,自动转换S3 URL为CDN地址 - 实现统一的删除确认弹框,替换window.confirm - 支持照片和视频生成任务的删除功能 - 优化UI/UX设计,符合前端开发规范 - 添加完整的错误处理和状态管理 核心功能: 模特照片上传到云端 多选照片进行视频生成 实时任务状态跟踪 视频生成历史记录 统一删除确认对话框 响应式设计和优雅动画
This commit is contained in:
@@ -2,6 +2,7 @@ use std::sync::{Arc, Mutex};
|
||||
use crate::data::repositories::project_repository::ProjectRepository;
|
||||
use crate::data::repositories::material_repository::MaterialRepository;
|
||||
use crate::data::repositories::model_repository::ModelRepository;
|
||||
use crate::data::repositories::video_generation_repository::VideoGenerationRepository;
|
||||
use crate::infrastructure::database::Database;
|
||||
use crate::infrastructure::performance::PerformanceMonitor;
|
||||
use crate::infrastructure::event_bus::EventBusManager;
|
||||
@@ -13,6 +14,7 @@ pub struct AppState {
|
||||
pub project_repository: Mutex<Option<ProjectRepository>>,
|
||||
pub material_repository: Mutex<Option<MaterialRepository>>,
|
||||
pub model_repository: Mutex<Option<ModelRepository>>,
|
||||
pub video_generation_repository: Mutex<Option<VideoGenerationRepository>>,
|
||||
pub performance_monitor: Mutex<PerformanceMonitor>,
|
||||
pub event_bus_manager: Arc<EventBusManager>,
|
||||
}
|
||||
@@ -24,6 +26,7 @@ impl AppState {
|
||||
project_repository: Mutex::new(None),
|
||||
material_repository: Mutex::new(None),
|
||||
model_repository: Mutex::new(None),
|
||||
video_generation_repository: Mutex::new(None),
|
||||
performance_monitor: Mutex::new(PerformanceMonitor::new()),
|
||||
event_bus_manager: Arc::new(EventBusManager::new()),
|
||||
}
|
||||
@@ -57,11 +60,16 @@ impl AppState {
|
||||
let project_repository = ProjectRepository::new(database.clone())?;
|
||||
let material_repository = MaterialRepository::new(database.clone())?;
|
||||
let model_repository = ModelRepository::new(database.clone());
|
||||
let video_generation_repository = VideoGenerationRepository::new(database.clone());
|
||||
|
||||
// 初始化视频生成任务表
|
||||
video_generation_repository.init_tables()?;
|
||||
|
||||
*self.database.lock().unwrap() = Some(database.clone());
|
||||
*self.project_repository.lock().unwrap() = Some(project_repository);
|
||||
*self.material_repository.lock().unwrap() = Some(material_repository);
|
||||
*self.model_repository.lock().unwrap() = Some(model_repository);
|
||||
*self.video_generation_repository.lock().unwrap() = Some(video_generation_repository);
|
||||
|
||||
println!("数据库初始化完成,连接池状态: {}",
|
||||
if database.has_pool() { "已启用" } else { "未启用" });
|
||||
@@ -75,11 +83,16 @@ impl AppState {
|
||||
let project_repository = ProjectRepository::new(database.clone())?;
|
||||
let material_repository = MaterialRepository::new(database.clone())?;
|
||||
let model_repository = ModelRepository::new(database.clone());
|
||||
let video_generation_repository = VideoGenerationRepository::new(database.clone());
|
||||
|
||||
// 初始化视频生成任务表
|
||||
video_generation_repository.init_tables()?;
|
||||
|
||||
*self.database.lock().unwrap() = Some(database.clone());
|
||||
*self.project_repository.lock().unwrap() = Some(project_repository);
|
||||
*self.material_repository.lock().unwrap() = Some(material_repository);
|
||||
*self.model_repository.lock().unwrap() = Some(model_repository);
|
||||
*self.video_generation_repository.lock().unwrap() = Some(video_generation_repository);
|
||||
|
||||
println!("数据库初始化完成,使用单连接模式");
|
||||
Ok(())
|
||||
@@ -100,6 +113,11 @@ impl AppState {
|
||||
Ok(self.model_repository.lock().unwrap())
|
||||
}
|
||||
|
||||
/// 获取视频生成仓库实例
|
||||
pub fn get_video_generation_repository(&self) -> anyhow::Result<std::sync::MutexGuard<Option<VideoGenerationRepository>>> {
|
||||
Ok(self.video_generation_repository.lock().unwrap())
|
||||
}
|
||||
|
||||
/// 获取数据库实例
|
||||
pub fn get_database(&self) -> Arc<Database> {
|
||||
// 使用全局静态数据库实例,确保整个应用只有一个数据库实例
|
||||
@@ -126,6 +144,7 @@ impl AppState {
|
||||
project_repository: Mutex::new(None),
|
||||
material_repository: Mutex::new(None),
|
||||
model_repository: Mutex::new(None),
|
||||
video_generation_repository: Mutex::new(None),
|
||||
performance_monitor: Mutex::new(PerformanceMonitor::new()),
|
||||
event_bus_manager: Arc::new(EventBusManager::new()),
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod enhanced_template_import_service;
|
||||
pub mod project_template_binding_service;
|
||||
pub mod material_matching_service;
|
||||
pub mod template_matching_result_service;
|
||||
pub mod video_generation_service;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::path::Path;
|
||||
use crate::data::models::model::{
|
||||
Model, ModelPhoto, CreateModelRequest, UpdateModelRequest,
|
||||
Model, ModelPhoto, CreateModelRequest, UpdateModelRequest,
|
||||
ModelQueryParams, Gender, PhotoType, ModelStatus
|
||||
};
|
||||
use crate::data::repositories::model_repository::ModelRepository;
|
||||
use crate::business::services::cloud_upload_service::CloudUploadService;
|
||||
|
||||
/// 模特业务服务
|
||||
/// 遵循 Tauri 开发规范的业务逻辑层设计
|
||||
@@ -265,6 +266,98 @@ impl ModelService {
|
||||
Ok(photo)
|
||||
}
|
||||
|
||||
/// 添加模特照片(上传到云端)
|
||||
pub async fn add_model_photo_with_cloud_upload(
|
||||
repository: &ModelRepository,
|
||||
model_id: &str,
|
||||
file_path: String,
|
||||
photo_type: PhotoType,
|
||||
description: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
) -> Result<ModelPhoto> {
|
||||
// 检查模特是否存在
|
||||
let _model = repository.get_by_id(model_id)?
|
||||
.ok_or_else(|| anyhow!("模特不存在: {}", model_id))?;
|
||||
|
||||
// 验证文件路径
|
||||
let path = Path::new(&file_path);
|
||||
if !path.exists() {
|
||||
return Err(anyhow!("文件不存在: {}", file_path));
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
let metadata = std::fs::metadata(&file_path)
|
||||
.map_err(|e| anyhow!("获取文件信息失败: {}", e))?;
|
||||
|
||||
let file_size = metadata.len();
|
||||
let file_name = path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
println!("📤 正在上传模特照片到云存储: {}", file_path);
|
||||
|
||||
// 上传到云存储
|
||||
let upload_service = CloudUploadService::new();
|
||||
let remote_key = format!("models/photos/{}/{}", model_id, file_name);
|
||||
|
||||
let upload_result = upload_service.upload_file(&file_path, Some(remote_key), None).await
|
||||
.map_err(|e| anyhow!("上传文件失败: {}", e))?;
|
||||
|
||||
if !upload_result.success {
|
||||
let error_msg = upload_result.error_message
|
||||
.unwrap_or_else(|| "未知上传错误".to_string());
|
||||
return Err(anyhow!("照片上传失败: {}", error_msg));
|
||||
}
|
||||
|
||||
// 获取云端URL
|
||||
let cloud_url = if let Some(remote_url) = upload_result.remote_url {
|
||||
// 转换S3 URL为CDN URL
|
||||
Self::convert_s3_to_cdn_url(&remote_url)
|
||||
} else if let Some(urn) = upload_result.urn {
|
||||
// 转换S3 URN为CDN URL
|
||||
Self::convert_s3_to_cdn_url(&urn)
|
||||
} else {
|
||||
return Err(anyhow!("上传成功但未返回URL"));
|
||||
};
|
||||
|
||||
println!("✅ 模特照片上传成功: {}", cloud_url);
|
||||
|
||||
// 创建照片对象,使用云端URL作为file_path
|
||||
let mut photo = ModelPhoto::new(
|
||||
model_id.to_string(),
|
||||
cloud_url, // 使用云端URL而不是本地路径
|
||||
file_name,
|
||||
file_size,
|
||||
photo_type,
|
||||
);
|
||||
|
||||
if let Some(desc) = description {
|
||||
photo.description = Some(desc);
|
||||
}
|
||||
|
||||
if let Some(photo_tags) = tags {
|
||||
photo.tags = photo_tags;
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
repository.add_photo(&photo)
|
||||
.map_err(|e| anyhow!("添加照片失败: {}", e))?;
|
||||
|
||||
Ok(photo)
|
||||
}
|
||||
|
||||
/// 将S3 URL转换为可访问的CDN地址
|
||||
fn convert_s3_to_cdn_url(s3_url: &str) -> String {
|
||||
// 将 s3://ap-northeast-2/modal-media-cache/ 替换为 https://cdn.roasmax.cn/
|
||||
if s3_url.starts_with("s3://ap-northeast-2/modal-media-cache/") {
|
||||
s3_url.replace("s3://ap-northeast-2/modal-media-cache/", "https://cdn.roasmax.cn/")
|
||||
} else {
|
||||
// 如果不是预期的S3格式,返回原URL
|
||||
s3_url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除模特照片
|
||||
pub fn delete_model_photo(
|
||||
repository: &ModelRepository,
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use crate::data::models::video_generation::{
|
||||
VideoGenerationTask, VideoGenerationStatus, VideoGenerationQueryParams,
|
||||
CreateVideoGenerationRequest
|
||||
};
|
||||
use crate::data::repositories::video_generation_repository::VideoGenerationRepository;
|
||||
use crate::data::repositories::model_repository::ModelRepository;
|
||||
use crate::infrastructure::video_generation_service::VideoGenerationService as ApiService;
|
||||
use crate::business::services::cloud_upload_service::CloudUploadService;
|
||||
|
||||
/// 视频生成业务服务
|
||||
/// 遵循 Tauri 开发规范的业务逻辑层设计
|
||||
pub struct VideoGenerationService;
|
||||
|
||||
impl VideoGenerationService {
|
||||
/// 创建视频生成任务
|
||||
pub async fn create_task(
|
||||
repository: &VideoGenerationRepository,
|
||||
model_repository: &ModelRepository,
|
||||
request: CreateVideoGenerationRequest,
|
||||
) -> Result<VideoGenerationTask> {
|
||||
// 验证模特是否存在
|
||||
let model = model_repository.get_by_id(&request.model_id)?
|
||||
.ok_or_else(|| anyhow!("模特不存在: {}", request.model_id))?;
|
||||
|
||||
// 验证选中的照片是否存在
|
||||
if request.selected_photos.is_empty() {
|
||||
return Err(anyhow!("必须选择至少一张照片"));
|
||||
}
|
||||
|
||||
// 验证照片是否属于该模特
|
||||
for photo_id in &request.selected_photos {
|
||||
let photo_exists = model.photos.iter().any(|p| &p.id == photo_id);
|
||||
if !photo_exists {
|
||||
return Err(anyhow!("照片不存在或不属于该模特: {}", photo_id));
|
||||
}
|
||||
}
|
||||
|
||||
// 创建任务
|
||||
let task = VideoGenerationTask::new(
|
||||
request.model_id,
|
||||
request.prompt_config,
|
||||
request.selected_photos,
|
||||
);
|
||||
|
||||
// 验证任务数据
|
||||
task.validate().map_err(|e| anyhow!(e))?;
|
||||
|
||||
// 保存到数据库
|
||||
repository.create(&task)?;
|
||||
|
||||
println!("✅ 视频生成任务创建成功: {}", task.id);
|
||||
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
/// 执行视频生成任务
|
||||
pub async fn execute_task(
|
||||
repository: &VideoGenerationRepository,
|
||||
model_repository: &ModelRepository,
|
||||
task_id: &str,
|
||||
) -> Result<VideoGenerationTask> {
|
||||
// 获取任务
|
||||
let mut task = repository.get_by_id(task_id)?
|
||||
.ok_or_else(|| anyhow!("任务不存在: {}", task_id))?;
|
||||
|
||||
// 检查任务状态
|
||||
if !matches!(task.status, VideoGenerationStatus::Pending) {
|
||||
return Err(anyhow!("任务状态不允许执行: {:?}", task.status));
|
||||
}
|
||||
|
||||
// 更新任务状态为处理中
|
||||
task.update_status(VideoGenerationStatus::Processing);
|
||||
repository.update(&task)?;
|
||||
|
||||
println!("🚀 开始执行视频生成任务: {}", task_id);
|
||||
|
||||
// 获取模特信息
|
||||
let model = model_repository.get_by_id(&task.model_id)?
|
||||
.ok_or_else(|| anyhow!("模特不存在: {}", task.model_id))?;
|
||||
|
||||
// 获取第一张选中的照片作为生成图片
|
||||
let first_photo_id = &task.selected_photos[0];
|
||||
let photo = model.photos.iter()
|
||||
.find(|p| &p.id == first_photo_id)
|
||||
.ok_or_else(|| anyhow!("照片不存在: {}", first_photo_id))?;
|
||||
|
||||
// 检查照片路径是否已经是云端URL
|
||||
let image_url = if photo.file_path.starts_with("https://") {
|
||||
// 如果已经是HTTPS URL,直接使用
|
||||
println!("📷 使用已有的云端图片URL: {}", photo.file_path);
|
||||
photo.file_path.clone()
|
||||
} else {
|
||||
// 如果是本地路径,上传到云存储
|
||||
println!("📤 本地图片需要上传到云存储: {}", photo.file_path);
|
||||
Self::upload_image_to_cloud(&photo.file_path).await?
|
||||
};
|
||||
|
||||
// 执行视频生成
|
||||
let api_service = ApiService::new();
|
||||
match api_service.generate_video(
|
||||
task.prompt_config.product.clone(),
|
||||
task.prompt_config.scene.clone(),
|
||||
task.prompt_config.model_desc.clone(),
|
||||
image_url,
|
||||
task.prompt_config.template.clone(),
|
||||
task.prompt_config.duplicate,
|
||||
).await {
|
||||
Ok(result) => {
|
||||
// 生成成功,更新任务结果
|
||||
task.set_result(result);
|
||||
repository.update(&task)?;
|
||||
println!("✅ 视频生成任务完成: {}", task_id);
|
||||
}
|
||||
Err(e) => {
|
||||
// 生成失败,更新错误信息
|
||||
let error_msg = format!("视频生成失败: {}", e);
|
||||
task.set_error(error_msg);
|
||||
repository.update(&task)?;
|
||||
println!("❌ 视频生成任务失败: {} - {}", task_id, e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
/// 获取视频生成任务
|
||||
pub fn get_task(
|
||||
repository: &VideoGenerationRepository,
|
||||
task_id: &str,
|
||||
) -> Result<Option<VideoGenerationTask>> {
|
||||
repository.get_by_id(task_id)
|
||||
}
|
||||
|
||||
/// 获取视频生成任务列表
|
||||
pub fn get_tasks(
|
||||
repository: &VideoGenerationRepository,
|
||||
params: &VideoGenerationQueryParams,
|
||||
) -> Result<Vec<VideoGenerationTask>> {
|
||||
repository.search(params)
|
||||
}
|
||||
|
||||
/// 取消视频生成任务
|
||||
pub fn cancel_task(
|
||||
repository: &VideoGenerationRepository,
|
||||
task_id: &str,
|
||||
) -> Result<VideoGenerationTask> {
|
||||
let mut task = repository.get_by_id(task_id)?
|
||||
.ok_or_else(|| anyhow!("任务不存在: {}", task_id))?;
|
||||
|
||||
// 只有等待中或处理中的任务可以取消
|
||||
if !matches!(task.status, VideoGenerationStatus::Pending | VideoGenerationStatus::Processing) {
|
||||
return Err(anyhow!("任务状态不允许取消: {:?}", task.status));
|
||||
}
|
||||
|
||||
task.update_status(VideoGenerationStatus::Cancelled);
|
||||
repository.update(&task)?;
|
||||
|
||||
println!("🚫 视频生成任务已取消: {}", task_id);
|
||||
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
/// 删除视频生成任务
|
||||
pub fn delete_task(
|
||||
repository: &VideoGenerationRepository,
|
||||
task_id: &str,
|
||||
) -> Result<()> {
|
||||
let task = repository.get_by_id(task_id)?
|
||||
.ok_or_else(|| anyhow!("任务不存在: {}", task_id))?;
|
||||
|
||||
// 删除本地视频文件
|
||||
if let Some(result) = &task.result {
|
||||
for video_path in &result.video_paths {
|
||||
if std::path::Path::new(video_path).exists() {
|
||||
if let Err(e) = std::fs::remove_file(video_path) {
|
||||
println!("⚠️ 删除视频文件失败: {} - {}", video_path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repository.delete(task_id)?;
|
||||
|
||||
println!("🗑️ 视频生成任务已删除: {}", task_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 重试视频生成任务
|
||||
pub async fn retry_task(
|
||||
repository: &VideoGenerationRepository,
|
||||
model_repository: &ModelRepository,
|
||||
task_id: &str,
|
||||
) -> Result<VideoGenerationTask> {
|
||||
let mut task = repository.get_by_id(task_id)?
|
||||
.ok_or_else(|| anyhow!("任务不存在: {}", task_id))?;
|
||||
|
||||
// 只有失败的任务可以重试
|
||||
if !matches!(task.status, VideoGenerationStatus::Failed) {
|
||||
return Err(anyhow!("只有失败的任务可以重试"));
|
||||
}
|
||||
|
||||
// 重置任务状态
|
||||
task.update_status(VideoGenerationStatus::Pending);
|
||||
task.result = None;
|
||||
task.error_message = None;
|
||||
task.completed_at = None;
|
||||
repository.update(&task)?;
|
||||
|
||||
println!("🔄 重试视频生成任务: {}", task_id);
|
||||
|
||||
// 执行任务
|
||||
Self::execute_task(repository, model_repository, task_id).await
|
||||
}
|
||||
|
||||
/// 获取模特的视频生成统计
|
||||
pub fn get_model_statistics(
|
||||
repository: &VideoGenerationRepository,
|
||||
model_id: &str,
|
||||
) -> Result<VideoGenerationStatistics> {
|
||||
let params = VideoGenerationQueryParams {
|
||||
model_id: Some(model_id.to_string()),
|
||||
status: None,
|
||||
limit: None,
|
||||
offset: None,
|
||||
};
|
||||
|
||||
let tasks = repository.search(¶ms)?;
|
||||
|
||||
let total = tasks.len();
|
||||
let pending = tasks.iter().filter(|t| matches!(t.status, VideoGenerationStatus::Pending)).count();
|
||||
let processing = tasks.iter().filter(|t| matches!(t.status, VideoGenerationStatus::Processing)).count();
|
||||
let completed = tasks.iter().filter(|t| matches!(t.status, VideoGenerationStatus::Completed)).count();
|
||||
let failed = tasks.iter().filter(|t| matches!(t.status, VideoGenerationStatus::Failed)).count();
|
||||
let cancelled = tasks.iter().filter(|t| matches!(t.status, VideoGenerationStatus::Cancelled)).count();
|
||||
|
||||
let total_videos = tasks.iter()
|
||||
.filter_map(|t| t.result.as_ref())
|
||||
.map(|r| r.video_urls.len())
|
||||
.sum();
|
||||
|
||||
let avg_generation_time = if completed > 0 {
|
||||
let total_time: u64 = tasks.iter()
|
||||
.filter_map(|t| t.result.as_ref())
|
||||
.map(|r| r.generation_time)
|
||||
.sum();
|
||||
total_time / completed as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(VideoGenerationStatistics {
|
||||
total,
|
||||
pending,
|
||||
processing,
|
||||
completed,
|
||||
failed,
|
||||
cancelled,
|
||||
total_videos,
|
||||
avg_generation_time,
|
||||
})
|
||||
}
|
||||
|
||||
/// 上传图片到云存储并返回可访问的URL
|
||||
async fn upload_image_to_cloud(file_path: &str) -> Result<String> {
|
||||
println!("📤 正在上传图片到云存储: {}", file_path);
|
||||
|
||||
// 创建云上传服务实例
|
||||
let upload_service = CloudUploadService::new();
|
||||
|
||||
// 生成远程文件名
|
||||
let file_name = std::path::Path::new(file_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("image.jpg");
|
||||
|
||||
let remote_key = format!("video-generation/images/{}", file_name);
|
||||
|
||||
// 上传文件
|
||||
match upload_service.upload_file(file_path, Some(remote_key), None).await {
|
||||
Ok(upload_result) => {
|
||||
// 打印完整的上传结果
|
||||
println!("📋 完整上传结果: {:#?}", upload_result);
|
||||
|
||||
if upload_result.success {
|
||||
if let Some(remote_url) = upload_result.remote_url {
|
||||
// 将S3 URL转换为可访问的CDN地址
|
||||
let accessible_url = Self::convert_s3_to_cdn_url(&remote_url);
|
||||
println!("✅ 图片上传成功,S3 URL: {}", remote_url);
|
||||
println!("🌐 转换为CDN URL: {}", accessible_url);
|
||||
Ok(accessible_url)
|
||||
} else if let Some(urn) = upload_result.urn {
|
||||
// 将S3 URN转换为可访问的CDN地址
|
||||
let accessible_url = Self::convert_s3_to_cdn_url(&urn);
|
||||
println!("✅ 图片上传成功,S3 URN: {}", urn);
|
||||
println!("🌐 转换为CDN URL: {}", accessible_url);
|
||||
Ok(accessible_url)
|
||||
} else {
|
||||
println!("⚠️ 上传成功但未返回URL或URN");
|
||||
Err(anyhow!("上传成功但未返回URL"))
|
||||
}
|
||||
} else {
|
||||
let error_msg = upload_result.error_message
|
||||
.unwrap_or_else(|| "未知上传错误".to_string());
|
||||
println!("❌ 图片上传失败: {}", error_msg);
|
||||
Err(anyhow!("图片上传失败: {}", error_msg))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("❌ 图片上传异常: {}", e);
|
||||
Err(anyhow!("图片上传失败: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 将S3 URL转换为可访问的CDN地址
|
||||
fn convert_s3_to_cdn_url(s3_url: &str) -> String {
|
||||
// 将 s3://ap-northeast-2/modal-media-cache/ 替换为 https://cdn.roasmax.cn/
|
||||
if s3_url.starts_with("s3://ap-northeast-2/modal-media-cache/") {
|
||||
s3_url.replace("s3://ap-northeast-2/modal-media-cache/", "https://cdn.roasmax.cn/")
|
||||
} else {
|
||||
// 如果不是预期的S3格式,返回原URL
|
||||
s3_url.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 视频生成统计信息
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct VideoGenerationStatistics {
|
||||
pub total: usize,
|
||||
pub pending: usize,
|
||||
pub processing: usize,
|
||||
pub completed: usize,
|
||||
pub failed: usize,
|
||||
pub cancelled: usize,
|
||||
pub total_videos: usize,
|
||||
pub avg_generation_time: u64,
|
||||
}
|
||||
@@ -8,3 +8,4 @@ pub mod video_classification;
|
||||
pub mod template;
|
||||
pub mod project_template_binding;
|
||||
pub mod template_matching_result;
|
||||
pub mod video_generation;
|
||||
|
||||
208
apps/desktop/src-tauri/src/data/models/video_generation.rs
Normal file
208
apps/desktop/src-tauri/src/data/models/video_generation.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// 视频生成任务
|
||||
/// 遵循 Tauri 开发规范的数据模型设计原则
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VideoGenerationTask {
|
||||
pub id: String,
|
||||
pub model_id: String,
|
||||
pub prompt_config: VideoPromptConfig,
|
||||
pub selected_photos: Vec<String>, // 选中的照片ID列表
|
||||
pub status: VideoGenerationStatus,
|
||||
pub result: Option<VideoGenerationResult>,
|
||||
pub error_message: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 视频生成提示词配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VideoPromptConfig {
|
||||
pub product: String, // 产品描述,如"超短牛仔裙(白色紧身蕾丝短袖)"
|
||||
pub scene: String, // 场景描述,如"室内可爱简约的女性卧室"
|
||||
pub model_desc: String, // 模特描述,如"单马尾充满媚态,对自己的性感妩媚十分自信,眼神勾人"
|
||||
pub template: String, // 模板类型,如"抚媚眼神"
|
||||
pub duplicate: u32, // 生成数量,默认为1
|
||||
}
|
||||
|
||||
/// 视频生成状态
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum VideoGenerationStatus {
|
||||
Pending, // 等待中
|
||||
Processing, // 处理中
|
||||
Completed, // 已完成
|
||||
Failed, // 失败
|
||||
Cancelled, // 已取消
|
||||
}
|
||||
|
||||
/// 视频生成结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VideoGenerationResult {
|
||||
pub video_urls: Vec<String>, // 生成的视频URL列表
|
||||
pub video_paths: Vec<String>, // 本地保存的视频路径列表
|
||||
pub generation_time: u64, // 生成耗时(毫秒)
|
||||
pub api_response: Option<String>, // API原始响应
|
||||
}
|
||||
|
||||
/// 创建视频生成任务请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateVideoGenerationRequest {
|
||||
pub model_id: String,
|
||||
pub prompt_config: VideoPromptConfig,
|
||||
pub selected_photos: Vec<String>,
|
||||
}
|
||||
|
||||
/// 视频生成任务查询参数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VideoGenerationQueryParams {
|
||||
pub model_id: Option<String>,
|
||||
pub status: Option<VideoGenerationStatus>,
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
/// Dify API 配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DifyApiConfig {
|
||||
pub host: String,
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
impl Default for DifyApiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: "https://dify.bowongai.com".to_string(),
|
||||
api_key: "app-Lm10XUBJKnE1bnG6VPfiD1se".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dify API 请求体
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DifyApiRequest {
|
||||
pub inputs: DifyInputs,
|
||||
pub response_mode: String,
|
||||
pub user: String,
|
||||
}
|
||||
|
||||
/// Dify API 输入参数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DifyInputs {
|
||||
pub product: String,
|
||||
pub scene: String,
|
||||
pub model: String,
|
||||
pub image: String,
|
||||
pub duplicate: u32,
|
||||
pub template: String,
|
||||
}
|
||||
|
||||
/// Dify API 响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DifyApiResponse {
|
||||
pub data: Option<DifyResponseData>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Dify API 响应数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DifyResponseData {
|
||||
pub outputs: DifyOutputs,
|
||||
}
|
||||
|
||||
/// Dify API 输出
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DifyOutputs {
|
||||
pub output: Vec<String>, // 生成的视频URL列表
|
||||
}
|
||||
|
||||
impl VideoGenerationTask {
|
||||
/// 创建新的视频生成任务
|
||||
pub fn new(
|
||||
model_id: String,
|
||||
prompt_config: VideoPromptConfig,
|
||||
selected_photos: Vec<String>,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
model_id,
|
||||
prompt_config,
|
||||
selected_photos,
|
||||
status: VideoGenerationStatus::Pending,
|
||||
result: None,
|
||||
error_message: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
completed_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新任务状态
|
||||
pub fn update_status(&mut self, status: VideoGenerationStatus) {
|
||||
self.status = status;
|
||||
self.updated_at = Utc::now();
|
||||
|
||||
if matches!(status, VideoGenerationStatus::Completed | VideoGenerationStatus::Failed) {
|
||||
self.completed_at = Some(Utc::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置任务结果
|
||||
pub fn set_result(&mut self, result: VideoGenerationResult) {
|
||||
self.result = Some(result);
|
||||
self.update_status(VideoGenerationStatus::Completed);
|
||||
}
|
||||
|
||||
/// 设置错误信息
|
||||
pub fn set_error(&mut self, error: String) {
|
||||
self.error_message = Some(error);
|
||||
self.update_status(VideoGenerationStatus::Failed);
|
||||
}
|
||||
|
||||
/// 验证任务数据
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.model_id.is_empty() {
|
||||
return Err("模特ID不能为空".to_string());
|
||||
}
|
||||
|
||||
if self.prompt_config.product.is_empty() {
|
||||
return Err("产品描述不能为空".to_string());
|
||||
}
|
||||
|
||||
if self.prompt_config.scene.is_empty() {
|
||||
return Err("场景描述不能为空".to_string());
|
||||
}
|
||||
|
||||
if self.prompt_config.model_desc.is_empty() {
|
||||
return Err("模特描述不能为空".to_string());
|
||||
}
|
||||
|
||||
if self.prompt_config.template.is_empty() {
|
||||
return Err("模板类型不能为空".to_string());
|
||||
}
|
||||
|
||||
if self.selected_photos.is_empty() {
|
||||
return Err("必须选择至少一张照片".to_string());
|
||||
}
|
||||
|
||||
if self.prompt_config.duplicate == 0 {
|
||||
return Err("生成数量必须大于0".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for VideoPromptConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
product: String::new(),
|
||||
scene: String::new(),
|
||||
model_desc: String::new(),
|
||||
template: "抚媚眼神".to_string(),
|
||||
duplicate: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,4 @@ pub mod ai_classification_repository;
|
||||
pub mod video_classification_repository;
|
||||
pub mod project_template_binding_repository;
|
||||
pub mod template_matching_result_repository;
|
||||
pub mod video_generation_repository;
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::infrastructure::database::Database;
|
||||
|
||||
/// 模特数据仓库
|
||||
/// 遵循 Tauri 开发规范的仓库模式设计
|
||||
#[derive(Clone)]
|
||||
pub struct ModelRepository {
|
||||
database: Arc<Database>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
use anyhow::Result;
|
||||
use rusqlite::{params, Row};
|
||||
use std::sync::Arc;
|
||||
use crate::data::models::video_generation::{
|
||||
VideoGenerationTask, VideoGenerationStatus, VideoGenerationQueryParams,
|
||||
VideoPromptConfig, VideoGenerationResult
|
||||
};
|
||||
use crate::infrastructure::database::Database;
|
||||
|
||||
/// 视频生成任务仓库
|
||||
/// 遵循 Tauri 开发规范的仓库设计模式
|
||||
#[derive(Clone)]
|
||||
pub struct VideoGenerationRepository {
|
||||
database: Arc<Database>,
|
||||
}
|
||||
|
||||
impl VideoGenerationRepository {
|
||||
/// 创建新的视频生成任务仓库实例
|
||||
pub fn new(database: Arc<Database>) -> Self {
|
||||
Self { database }
|
||||
}
|
||||
|
||||
/// 初始化数据库表
|
||||
pub fn init_tables(&self) -> Result<()> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS video_generation_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
model_id TEXT NOT NULL,
|
||||
product TEXT NOT NULL,
|
||||
scene TEXT NOT NULL,
|
||||
model_desc TEXT NOT NULL,
|
||||
template TEXT NOT NULL,
|
||||
duplicate INTEGER NOT NULL DEFAULT 1,
|
||||
selected_photos TEXT NOT NULL, -- JSON array of photo IDs
|
||||
status TEXT NOT NULL,
|
||||
video_urls TEXT, -- JSON array of video URLs
|
||||
video_paths TEXT, -- JSON array of local video paths
|
||||
generation_time INTEGER, -- milliseconds
|
||||
api_response TEXT,
|
||||
error_message TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
completed_at DATETIME,
|
||||
FOREIGN KEY (model_id) REFERENCES models (id)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 创建视频生成任务
|
||||
pub fn create(&self, task: &VideoGenerationTask) -> Result<()> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let selected_photos_json = serde_json::to_string(&task.selected_photos)?;
|
||||
let video_urls_json = task.result.as_ref()
|
||||
.map(|r| serde_json::to_string(&r.video_urls))
|
||||
.transpose()?;
|
||||
let video_paths_json = task.result.as_ref()
|
||||
.map(|r| serde_json::to_string(&r.video_paths))
|
||||
.transpose()?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO video_generation_tasks (
|
||||
id, model_id, product, scene, model_desc, template, duplicate,
|
||||
selected_photos, status, video_urls, video_paths, generation_time,
|
||||
api_response, error_message, created_at, updated_at, completed_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
|
||||
params![
|
||||
task.id,
|
||||
task.model_id,
|
||||
task.prompt_config.product,
|
||||
task.prompt_config.scene,
|
||||
task.prompt_config.model_desc,
|
||||
task.prompt_config.template,
|
||||
task.prompt_config.duplicate,
|
||||
selected_photos_json,
|
||||
format!("{:?}", task.status),
|
||||
video_urls_json,
|
||||
video_paths_json,
|
||||
task.result.as_ref().map(|r| r.generation_time as i64),
|
||||
task.result.as_ref().and_then(|r| r.api_response.as_ref()),
|
||||
task.error_message,
|
||||
task.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
task.updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
task.completed_at.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()),
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 根据ID获取视频生成任务
|
||||
pub fn get_by_id(&self, id: &str) -> Result<Option<VideoGenerationTask>> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, model_id, product, scene, model_desc, template, duplicate,
|
||||
selected_photos, status, video_urls, video_paths, generation_time,
|
||||
api_response, error_message, created_at, updated_at, completed_at
|
||||
FROM video_generation_tasks WHERE id = ?1"
|
||||
)?;
|
||||
|
||||
let task_iter = stmt.query_map([id], |row| {
|
||||
self.row_to_task(row)
|
||||
})?;
|
||||
|
||||
for task in task_iter {
|
||||
return Ok(Some(task?));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// 根据查询参数获取视频生成任务列表
|
||||
pub fn search(&self, params: &VideoGenerationQueryParams) -> Result<Vec<VideoGenerationTask>> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let mut query = String::from(
|
||||
"SELECT id, model_id, product, scene, model_desc, template, duplicate,
|
||||
selected_photos, status, video_urls, video_paths, generation_time,
|
||||
api_response, error_message, created_at, updated_at, completed_at
|
||||
FROM video_generation_tasks WHERE 1=1"
|
||||
);
|
||||
|
||||
let mut query_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(ref model_id) = params.model_id {
|
||||
query.push_str(" AND model_id = ?");
|
||||
query_params.push(Box::new(model_id.clone()));
|
||||
}
|
||||
|
||||
if let Some(ref status) = params.status {
|
||||
query.push_str(" AND status = ?");
|
||||
query_params.push(Box::new(format!("{:?}", status)));
|
||||
}
|
||||
|
||||
query.push_str(" ORDER BY created_at DESC");
|
||||
|
||||
if let Some(limit) = params.limit {
|
||||
query.push_str(" LIMIT ?");
|
||||
query_params.push(Box::new(limit as i64));
|
||||
}
|
||||
|
||||
if let Some(offset) = params.offset {
|
||||
query.push_str(" OFFSET ?");
|
||||
query_params.push(Box::new(offset as i64));
|
||||
}
|
||||
|
||||
let mut stmt = conn.prepare(&query)?;
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = query_params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let task_iter = stmt.query_map(param_refs.as_slice(), |row| {
|
||||
self.row_to_task(row)
|
||||
})?;
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for task in task_iter {
|
||||
tasks.push(task?);
|
||||
}
|
||||
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
/// 更新视频生成任务
|
||||
pub fn update(&self, task: &VideoGenerationTask) -> Result<()> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let selected_photos_json = serde_json::to_string(&task.selected_photos)?;
|
||||
let video_urls_json = task.result.as_ref()
|
||||
.map(|r| serde_json::to_string(&r.video_urls))
|
||||
.transpose()?;
|
||||
let video_paths_json = task.result.as_ref()
|
||||
.map(|r| serde_json::to_string(&r.video_paths))
|
||||
.transpose()?;
|
||||
|
||||
conn.execute(
|
||||
"UPDATE video_generation_tasks SET
|
||||
model_id = ?2, product = ?3, scene = ?4, model_desc = ?5, template = ?6,
|
||||
duplicate = ?7, selected_photos = ?8, status = ?9, video_urls = ?10,
|
||||
video_paths = ?11, generation_time = ?12, api_response = ?13,
|
||||
error_message = ?14, updated_at = ?15, completed_at = ?16
|
||||
WHERE id = ?1",
|
||||
params![
|
||||
task.id,
|
||||
task.model_id,
|
||||
task.prompt_config.product,
|
||||
task.prompt_config.scene,
|
||||
task.prompt_config.model_desc,
|
||||
task.prompt_config.template,
|
||||
task.prompt_config.duplicate,
|
||||
selected_photos_json,
|
||||
format!("{:?}", task.status),
|
||||
video_urls_json,
|
||||
video_paths_json,
|
||||
task.result.as_ref().map(|r| r.generation_time as i64),
|
||||
task.result.as_ref().and_then(|r| r.api_response.as_ref()),
|
||||
task.error_message,
|
||||
task.updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
task.completed_at.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()),
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除视频生成任务
|
||||
pub fn delete(&self, id: &str) -> Result<()> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
conn.execute("DELETE FROM video_generation_tasks WHERE id = ?1", [id])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将数据库行转换为VideoGenerationTask对象
|
||||
fn row_to_task(&self, row: &Row) -> rusqlite::Result<VideoGenerationTask> {
|
||||
let selected_photos_json: String = row.get("selected_photos")?;
|
||||
let selected_photos: Vec<String> = serde_json::from_str(&selected_photos_json)
|
||||
.map_err(|_e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("selected_photos").unwrap(),
|
||||
"selected_photos".to_string(),
|
||||
rusqlite::types::Type::Text
|
||||
))?;
|
||||
|
||||
let status_str: String = row.get("status")?;
|
||||
let status = match status_str.as_str() {
|
||||
"Pending" => VideoGenerationStatus::Pending,
|
||||
"Processing" => VideoGenerationStatus::Processing,
|
||||
"Completed" => VideoGenerationStatus::Completed,
|
||||
"Failed" => VideoGenerationStatus::Failed,
|
||||
"Cancelled" => VideoGenerationStatus::Cancelled,
|
||||
_ => VideoGenerationStatus::Pending,
|
||||
};
|
||||
|
||||
let result = if let (Some(video_urls_json), Some(video_paths_json)) =
|
||||
(row.get::<_, Option<String>>("video_urls")?, row.get::<_, Option<String>>("video_paths")?) {
|
||||
|
||||
let video_urls: Vec<String> = serde_json::from_str(&video_urls_json).unwrap_or_default();
|
||||
let video_paths: Vec<String> = serde_json::from_str(&video_paths_json).unwrap_or_default();
|
||||
let generation_time: Option<i64> = row.get("generation_time")?;
|
||||
let api_response: Option<String> = row.get("api_response")?;
|
||||
|
||||
Some(VideoGenerationResult {
|
||||
video_urls,
|
||||
video_paths,
|
||||
generation_time: generation_time.unwrap_or(0) as u64,
|
||||
api_response,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let created_at_str: String = row.get("created_at")?;
|
||||
let updated_at_str: String = row.get("updated_at")?;
|
||||
let completed_at_str: Option<String> = row.get("completed_at")?;
|
||||
|
||||
Ok(VideoGenerationTask {
|
||||
id: row.get("id")?,
|
||||
model_id: row.get("model_id")?,
|
||||
prompt_config: VideoPromptConfig {
|
||||
product: row.get("product")?,
|
||||
scene: row.get("scene")?,
|
||||
model_desc: row.get("model_desc")?,
|
||||
template: row.get("template")?,
|
||||
duplicate: row.get::<_, i64>("duplicate")? as u32,
|
||||
},
|
||||
selected_photos,
|
||||
status,
|
||||
result,
|
||||
error_message: row.get("error_message")?,
|
||||
created_at: chrono::DateTime::parse_from_str(&format!("{} +00:00", created_at_str), "%Y-%m-%d %H:%M:%S %z")
|
||||
.unwrap().with_timezone(&chrono::Utc),
|
||||
updated_at: chrono::DateTime::parse_from_str(&format!("{} +00:00", updated_at_str), "%Y-%m-%d %H:%M:%S %z")
|
||||
.unwrap().with_timezone(&chrono::Utc),
|
||||
completed_at: completed_at_str.map(|s|
|
||||
chrono::DateTime::parse_from_str(&format!("{} +00:00", s), "%Y-%m-%d %H:%M:%S %z")
|
||||
.unwrap().with_timezone(&chrono::Utc)
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,4 @@ pub mod ffmpeg;
|
||||
pub mod monitoring;
|
||||
pub mod logging;
|
||||
pub mod gemini_service;
|
||||
pub mod video_generation_service;
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
use crate::data::models::video_generation::{
|
||||
DifyApiConfig, DifyApiRequest, DifyApiResponse, DifyInputs, VideoGenerationResult
|
||||
};
|
||||
|
||||
/// 视频生成服务
|
||||
/// 基于Dify API实现视频生成功能
|
||||
pub struct VideoGenerationService {
|
||||
client: Client,
|
||||
config: DifyApiConfig,
|
||||
}
|
||||
|
||||
impl VideoGenerationService {
|
||||
/// 创建新的视频生成服务实例
|
||||
pub fn new() -> Self {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(300)) // 5分钟超时
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Self {
|
||||
client,
|
||||
config: DifyApiConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用自定义配置创建视频生成服务
|
||||
pub fn with_config(config: DifyApiConfig) -> Self {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Self { client, config }
|
||||
}
|
||||
|
||||
/// 生成视频
|
||||
pub async fn generate_video(
|
||||
&self,
|
||||
product: String,
|
||||
scene: String,
|
||||
model_desc: String,
|
||||
image_url: String,
|
||||
template: String,
|
||||
duplicate: u32,
|
||||
) -> Result<VideoGenerationResult> {
|
||||
println!("🎬 开始生成视频...");
|
||||
println!("产品: {}", product);
|
||||
println!("场景: {}", scene);
|
||||
println!("模特: {}", model_desc);
|
||||
println!("图片: {}", image_url);
|
||||
println!("模板: {}", template);
|
||||
println!("数量: {}", duplicate);
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// 构建请求数据
|
||||
let request_data = DifyApiRequest {
|
||||
inputs: DifyInputs {
|
||||
product: product.clone(),
|
||||
scene: scene.clone(),
|
||||
model: model_desc.clone(),
|
||||
image: image_url.clone(),
|
||||
duplicate,
|
||||
template: template.clone(),
|
||||
},
|
||||
response_mode: "blocking".to_string(),
|
||||
user: "mixvideo-desktop".to_string(),
|
||||
};
|
||||
|
||||
// 发送请求
|
||||
let url = format!("{}/v1/workflows/run", self.config.host);
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.config.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_data)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("请求失败: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let response_text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| anyhow!("读取响应失败: {}", e))?;
|
||||
|
||||
println!("📡 API响应状态: {}", status);
|
||||
println!("📡 API响应内容: {}", response_text);
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("API请求失败: {} - {}", status, response_text));
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
let api_response: DifyApiResponse = serde_json::from_str(&response_text)
|
||||
.map_err(|e| anyhow!("解析响应失败: {} - 响应内容: {}", e, response_text))?;
|
||||
|
||||
if let Some(error) = api_response.error {
|
||||
return Err(anyhow!("API返回错误: {}", error));
|
||||
}
|
||||
|
||||
let data = api_response
|
||||
.data
|
||||
.ok_or_else(|| anyhow!("API响应中缺少data字段"))?;
|
||||
|
||||
let video_urls = data.outputs.output;
|
||||
if video_urls.is_empty() {
|
||||
return Err(anyhow!("API未返回任何视频URL"));
|
||||
}
|
||||
|
||||
let generation_time = start_time.elapsed().as_millis() as u64;
|
||||
|
||||
println!("✅ 视频生成成功!");
|
||||
println!("🎥 生成的视频数量: {}", video_urls.len());
|
||||
println!("⏱️ 生成耗时: {}ms", generation_time);
|
||||
|
||||
// 处理视频路径(如果是file://协议,直接使用本地路径)
|
||||
let video_paths = self.process_video_paths(&video_urls).await?;
|
||||
|
||||
Ok(VideoGenerationResult {
|
||||
video_urls,
|
||||
video_paths,
|
||||
generation_time,
|
||||
api_response: Some(response_text),
|
||||
})
|
||||
}
|
||||
|
||||
/// 处理视频路径(支持file://协议和HTTP URL)
|
||||
async fn process_video_paths(&self, video_urls: &[String]) -> Result<Vec<String>> {
|
||||
let mut video_paths = Vec::new();
|
||||
|
||||
for url in video_urls.iter() {
|
||||
if url.starts_with("file://") {
|
||||
// 如果是file://协议,提取本地路径
|
||||
let local_path = url.replace("file://", "").replace("file:///", "");
|
||||
println!("📁 使用本地视频文件: {}", local_path);
|
||||
video_paths.push(local_path);
|
||||
} else if url.starts_with("http://") || url.starts_with("https://") {
|
||||
// 如果是HTTP URL,尝试下载
|
||||
match self.download_single_video(url, video_paths.len()).await {
|
||||
Ok(path) => {
|
||||
println!("📥 视频下载成功: {}", path);
|
||||
video_paths.push(path);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("⚠️ 视频下载失败: {} - {}", url, e);
|
||||
// 下载失败时使用原URL作为路径
|
||||
video_paths.push(url.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 其他情况直接使用原路径
|
||||
println!("📄 使用原始路径: {}", url);
|
||||
video_paths.push(url.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(video_paths)
|
||||
}
|
||||
|
||||
/// 下载视频到本地(保留原方法以备后用)
|
||||
async fn download_videos(&self, video_urls: &[String]) -> Result<Vec<String>> {
|
||||
let mut video_paths = Vec::new();
|
||||
|
||||
for (index, url) in video_urls.iter().enumerate() {
|
||||
match self.download_single_video(url, index).await {
|
||||
Ok(path) => {
|
||||
println!("📥 视频下载成功: {}", path);
|
||||
video_paths.push(path);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("⚠️ 视频下载失败: {} - {}", url, e);
|
||||
// 下载失败时使用原URL作为路径
|
||||
video_paths.push(url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(video_paths)
|
||||
}
|
||||
|
||||
/// 下载单个视频文件
|
||||
async fn download_single_video(&self, url: &str, index: usize) -> Result<String> {
|
||||
// 创建下载目录
|
||||
let download_dir = std::env::current_dir()?.join("downloads").join("videos");
|
||||
std::fs::create_dir_all(&download_dir)?;
|
||||
|
||||
// 生成文件名
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let filename = format!("video_{}_{}.mp4", timestamp, index);
|
||||
let file_path = download_dir.join(&filename);
|
||||
|
||||
// 下载文件
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("下载请求失败: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("下载失败: {}", response.status()));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| anyhow!("读取视频数据失败: {}", e))?;
|
||||
|
||||
std::fs::write(&file_path, bytes)
|
||||
.map_err(|e| anyhow!("保存视频文件失败: {}", e))?;
|
||||
|
||||
Ok(file_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// 验证图片URL是否可访问
|
||||
pub async fn validate_image_url(&self, url: &str) -> Result<bool> {
|
||||
match self.client.head(url).send().await {
|
||||
Ok(response) => Ok(response.status().is_success()),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取API配置
|
||||
pub fn get_config(&self) -> &DifyApiConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// 更新API配置
|
||||
pub fn update_config(&mut self, config: DifyApiConfig) {
|
||||
self.config = config;
|
||||
}
|
||||
|
||||
/// 测试API连接
|
||||
pub async fn test_connection(&self) -> Result<bool> {
|
||||
let url = format!("{}/health", self.config.host);
|
||||
match self.client.get(&url).send().await {
|
||||
Ok(response) => Ok(response.status().is_success()),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for VideoGenerationService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_video_generation_service_creation() {
|
||||
let service = VideoGenerationService::new();
|
||||
assert_eq!(service.config.host, "https://dify.bowongai.com");
|
||||
assert_eq!(service.config.api_key, "app-Lm10XUBJKnE1bnG6VPfiD1se");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_custom_config() {
|
||||
let custom_config = DifyApiConfig {
|
||||
host: "https://custom.api.com".to_string(),
|
||||
api_key: "custom-key".to_string(),
|
||||
};
|
||||
let service = VideoGenerationService::with_config(custom_config.clone());
|
||||
assert_eq!(service.config.host, custom_config.host);
|
||||
assert_eq!(service.config.api_key, custom_config.api_key);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_validate_image_url() {
|
||||
let service = VideoGenerationService::new();
|
||||
// 测试一个公开的图片URL
|
||||
let result = service.validate_image_url("https://httpbin.org/status/200").await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,17 @@ pub fn run() {
|
||||
// MaterialSegment聚合视图命令
|
||||
commands::material_segment_view_commands::get_project_segment_view,
|
||||
commands::material_segment_view_commands::get_project_segment_view_with_query,
|
||||
// 视频生成命令
|
||||
commands::video_generation_commands::create_video_generation_task,
|
||||
commands::video_generation_commands::execute_video_generation_task,
|
||||
commands::video_generation_commands::get_video_generation_task,
|
||||
commands::video_generation_commands::get_video_generation_tasks,
|
||||
commands::video_generation_commands::cancel_video_generation_task,
|
||||
commands::video_generation_commands::delete_video_generation_task,
|
||||
commands::video_generation_commands::retry_video_generation_task,
|
||||
commands::video_generation_commands::get_model_video_generation_statistics,
|
||||
commands::video_generation_commands::get_model_detail_with_photos,
|
||||
commands::video_generation_commands::batch_upload_model_photos,
|
||||
// 测试命令
|
||||
commands::test_commands::test_database_connection,
|
||||
commands::test_commands::test_template_table,
|
||||
|
||||
@@ -14,3 +14,4 @@ pub mod debug_commands;
|
||||
pub mod project_template_binding_commands;
|
||||
pub mod material_matching_commands;
|
||||
pub mod template_matching_result_commands;
|
||||
pub mod video_generation_commands;
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
use tauri::{command, State};
|
||||
use crate::app_state::AppState;
|
||||
use crate::business::services::video_generation_service::{VideoGenerationService, VideoGenerationStatistics};
|
||||
use crate::data::models::video_generation::{
|
||||
VideoGenerationTask, VideoGenerationQueryParams, CreateVideoGenerationRequest
|
||||
};
|
||||
|
||||
/// 创建视频生成任务命令
|
||||
/// 遵循 Tauri 开发规范的命令设计模式
|
||||
#[command]
|
||||
pub async fn create_video_generation_task(
|
||||
state: State<'_, AppState>,
|
||||
request: CreateVideoGenerationRequest,
|
||||
) -> Result<VideoGenerationTask, String> {
|
||||
// 克隆仓库引用以避免跨await边界的Send问题
|
||||
let (video_gen_repo, model_repo) = {
|
||||
let video_gen_repository_guard = state.get_video_generation_repository()
|
||||
.map_err(|e| format!("获取视频生成仓库失败: {}", e))?;
|
||||
|
||||
let video_gen_repository = video_gen_repository_guard.as_ref()
|
||||
.ok_or("视频生成仓库未初始化")?;
|
||||
|
||||
let model_repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let model_repository = model_repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
// 这里我们需要克隆仓库,但由于仓库包含Arc,这应该是轻量级的
|
||||
(video_gen_repository.clone(), model_repository.clone())
|
||||
};
|
||||
|
||||
VideoGenerationService::create_task(&video_gen_repo, &model_repo, request)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 执行视频生成任务命令
|
||||
#[command]
|
||||
pub async fn execute_video_generation_task(
|
||||
state: State<'_, AppState>,
|
||||
task_id: String,
|
||||
) -> Result<VideoGenerationTask, String> {
|
||||
let (video_gen_repo, model_repo) = {
|
||||
let video_gen_repository_guard = state.get_video_generation_repository()
|
||||
.map_err(|e| format!("获取视频生成仓库失败: {}", e))?;
|
||||
|
||||
let video_gen_repository = video_gen_repository_guard.as_ref()
|
||||
.ok_or("视频生成仓库未初始化")?;
|
||||
|
||||
let model_repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let model_repository = model_repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
(video_gen_repository.clone(), model_repository.clone())
|
||||
};
|
||||
|
||||
VideoGenerationService::execute_task(&video_gen_repo, &model_repo, &task_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取视频生成任务命令
|
||||
#[command]
|
||||
pub async fn get_video_generation_task(
|
||||
state: State<'_, AppState>,
|
||||
task_id: String,
|
||||
) -> Result<Option<VideoGenerationTask>, String> {
|
||||
let repository_guard = state.get_video_generation_repository()
|
||||
.map_err(|e| format!("获取视频生成仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("视频生成仓库未初始化")?;
|
||||
|
||||
VideoGenerationService::get_task(repository, &task_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取视频生成任务列表命令
|
||||
#[command]
|
||||
pub async fn get_video_generation_tasks(
|
||||
state: State<'_, AppState>,
|
||||
params: VideoGenerationQueryParams,
|
||||
) -> Result<Vec<VideoGenerationTask>, String> {
|
||||
let repository_guard = state.get_video_generation_repository()
|
||||
.map_err(|e| format!("获取视频生成仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("视频生成仓库未初始化")?;
|
||||
|
||||
VideoGenerationService::get_tasks(repository, ¶ms)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 取消视频生成任务命令
|
||||
#[command]
|
||||
pub async fn cancel_video_generation_task(
|
||||
state: State<'_, AppState>,
|
||||
task_id: String,
|
||||
) -> Result<VideoGenerationTask, String> {
|
||||
let repository_guard = state.get_video_generation_repository()
|
||||
.map_err(|e| format!("获取视频生成仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("视频生成仓库未初始化")?;
|
||||
|
||||
VideoGenerationService::cancel_task(repository, &task_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 删除视频生成任务命令
|
||||
#[command]
|
||||
pub async fn delete_video_generation_task(
|
||||
state: State<'_, AppState>,
|
||||
task_id: String,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_video_generation_repository()
|
||||
.map_err(|e| format!("获取视频生成仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("视频生成仓库未初始化")?;
|
||||
|
||||
VideoGenerationService::delete_task(repository, &task_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 重试视频生成任务命令
|
||||
#[command]
|
||||
pub async fn retry_video_generation_task(
|
||||
state: State<'_, AppState>,
|
||||
task_id: String,
|
||||
) -> Result<VideoGenerationTask, String> {
|
||||
let (video_gen_repo, model_repo) = {
|
||||
let video_gen_repository_guard = state.get_video_generation_repository()
|
||||
.map_err(|e| format!("获取视频生成仓库失败: {}", e))?;
|
||||
|
||||
let video_gen_repository = video_gen_repository_guard.as_ref()
|
||||
.ok_or("视频生成仓库未初始化")?;
|
||||
|
||||
let model_repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let model_repository = model_repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
(video_gen_repository.clone(), model_repository.clone())
|
||||
};
|
||||
|
||||
VideoGenerationService::retry_task(&video_gen_repo, &model_repo, &task_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取模特视频生成统计命令
|
||||
#[command]
|
||||
pub async fn get_model_video_generation_statistics(
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
) -> Result<VideoGenerationStatistics, String> {
|
||||
let repository_guard = state.get_video_generation_repository()
|
||||
.map_err(|e| format!("获取视频生成仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("视频生成仓库未初始化")?;
|
||||
|
||||
VideoGenerationService::get_model_statistics(repository, &model_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取模特详情(包含照片)命令
|
||||
#[command]
|
||||
pub async fn get_model_detail_with_photos(
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
) -> Result<Option<crate::data::models::model::Model>, String> {
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
// 使用完整的get_by_id方法获取包含照片的模特信息
|
||||
repository.get_by_id(&model_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 批量上传模特照片命令(上传到云端)
|
||||
#[command]
|
||||
pub async fn batch_upload_model_photos(
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
file_paths: Vec<String>,
|
||||
photo_type: crate::data::models::model::PhotoType,
|
||||
description: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
) -> Result<Vec<crate::data::models::model::ModelPhoto>, String> {
|
||||
// 克隆仓库引用以避免跨await边界的Send问题
|
||||
let repository = {
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
repository.clone()
|
||||
};
|
||||
|
||||
let mut uploaded_photos = Vec::new();
|
||||
|
||||
for file_path in file_paths {
|
||||
match crate::business::services::model_service::ModelService::add_model_photo_with_cloud_upload(
|
||||
&repository,
|
||||
&model_id,
|
||||
file_path,
|
||||
photo_type.clone(),
|
||||
description.clone(),
|
||||
tags.clone(),
|
||||
).await {
|
||||
Ok(photo) => uploaded_photos.push(photo),
|
||||
Err(e) => {
|
||||
println!("⚠️ 上传照片失败: {}", e);
|
||||
// 继续上传其他照片,不中断整个过程
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if uploaded_photos.is_empty() {
|
||||
return Err("没有成功上传任何照片".to_string());
|
||||
}
|
||||
|
||||
Ok(uploaded_photos)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ProjectList } from './components/ProjectList';
|
||||
import { ProjectForm } from './components/ProjectForm';
|
||||
import { ProjectDetails } from './pages/ProjectDetails';
|
||||
import Models from './pages/Models';
|
||||
import ModelDetail from './pages/ModelDetail';
|
||||
import AiClassificationSettings from './pages/AiClassificationSettings';
|
||||
import TemplateManagement from './pages/TemplateManagement';
|
||||
import { MaterialModelBinding } from './pages/MaterialModelBinding';
|
||||
@@ -73,6 +74,7 @@ function App() {
|
||||
<Route path="/" element={<ProjectList />} />
|
||||
<Route path="/project/:id" element={<ProjectDetails />} />
|
||||
<Route path="/models" element={<Models />} />
|
||||
<Route path="/models/:id" element={<ModelDetail />} />
|
||||
<Route path="/ai-classification-settings" element={<AiClassificationSettings />} />
|
||||
<Route path="/templates" element={<TemplateManagement />} />
|
||||
<Route path="/material-model-binding" element={<MaterialModelBinding />} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Model, ModelStatus, Gender, ModelViewMode } from '../types/model';
|
||||
import {
|
||||
PencilIcon,
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
CalendarIcon,
|
||||
EyeIcon,
|
||||
HeartIcon,
|
||||
ShareIcon,
|
||||
PhotoIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import {
|
||||
@@ -37,10 +37,23 @@ const ModelCard: React.FC<ModelCardProps> = ({
|
||||
isFavorite = false,
|
||||
isLoading = false
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
// 处理卡片点击事件
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
// 如果点击的是操作按钮,不触发导航
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('button')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 导航到模特详情页
|
||||
navigate(`/models/${model.id}`);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: ModelStatus) => {
|
||||
switch (status) {
|
||||
case ModelStatus.Active:
|
||||
@@ -121,7 +134,10 @@ const ModelCard: React.FC<ModelCardProps> = ({
|
||||
if (viewMode === ModelViewMode.List) {
|
||||
// 列表视图
|
||||
return (
|
||||
<div className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-300 animate-fade-in">
|
||||
<div
|
||||
className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-300 animate-fade-in cursor-pointer"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className="flex">
|
||||
{/* 头像 */}
|
||||
<div className="relative w-24 min-h-full flex-shrink-0">
|
||||
@@ -245,10 +261,10 @@ const ModelCard: React.FC<ModelCardProps> = ({
|
||||
// 网格视图 - 增强的现代化设计
|
||||
return (
|
||||
<div
|
||||
className="group relative bg-gradient-to-br from-white to-gray-50/50 hover:from-white hover:to-primary-50/30 rounded-2xl shadow-sm border border-gray-100/50 overflow-hidden hover:shadow-xl hover:border-primary-200 transition-all duration-500 hover:-translate-y-2 hover:scale-[1.02] animate-scale-in"
|
||||
className="group relative bg-gradient-to-br from-white to-gray-50/50 hover:from-white hover:to-primary-50/30 rounded-2xl shadow-sm border border-gray-100/50 overflow-hidden hover:shadow-xl hover:border-primary-200 transition-all duration-500 hover:-translate-y-2 hover:scale-[1.02] animate-scale-in cursor-pointer"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onSelect}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* 装饰性背景 */}
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-primary-100/30 to-primary-200/30 rounded-full -translate-y-10 translate-x-10 opacity-0 group-hover:opacity-100 transition-all duration-500"></div>
|
||||
@@ -435,16 +451,6 @@ const ModelCard: React.FC<ModelCardProps> = ({
|
||||
>
|
||||
<TrashIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 分享功能
|
||||
}}
|
||||
className="flex items-center justify-center px-3 py-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-green-50 hover:text-green-600 transition-all duration-200 text-sm font-medium focus-ring"
|
||||
title="分享"
|
||||
>
|
||||
<ShareIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./App.css";
|
||||
import "./styles/design-system.css";
|
||||
import "./styles/animations.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
|
||||
698
apps/desktop/src/pages/ModelDetail.tsx
Normal file
698
apps/desktop/src/pages/ModelDetail.tsx
Normal file
@@ -0,0 +1,698 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
PhotoIcon,
|
||||
PlusIcon,
|
||||
PlayIcon,
|
||||
TrashIcon,
|
||||
EyeIcon,
|
||||
ArrowPathIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Model, PhotoType } from '../types/model';
|
||||
import {
|
||||
VideoGenerationTask,
|
||||
VideoPromptConfig,
|
||||
VideoGenerationStatus,
|
||||
TEMPLATE_OPTIONS,
|
||||
SCENE_OPTIONS,
|
||||
PRODUCT_OPTIONS,
|
||||
VIDEO_GENERATION_STATUS_CONFIG
|
||||
} from '../types/videoGeneration';
|
||||
import { videoGenerationService } from '../services/videoGenerationService';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { CustomSelect } from '../components/CustomSelect';
|
||||
import { LoadingSpinner } from '../components/LoadingSpinner';
|
||||
import { DeleteConfirmDialog } from '../components/DeleteConfirmDialog';
|
||||
|
||||
const ModelDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 状态管理
|
||||
const [model, setModel] = useState<Model | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<string[]>([]);
|
||||
const [promptConfig, setPromptConfig] = useState<VideoPromptConfig>({
|
||||
product: '',
|
||||
scene: '',
|
||||
model_desc: '',
|
||||
template: '抚媚眼神',
|
||||
duplicate: 1
|
||||
});
|
||||
const [videoTasks, setVideoTasks] = useState<VideoGenerationTask[]>([]);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [uploadingPhotos, setUploadingPhotos] = useState(false);
|
||||
const [deletePhotoConfirm, setDeletePhotoConfirm] = useState<{
|
||||
show: boolean;
|
||||
photoId: string | null;
|
||||
photoName: string | null;
|
||||
deleting: boolean;
|
||||
}>({
|
||||
show: false,
|
||||
photoId: null,
|
||||
photoName: null,
|
||||
deleting: false,
|
||||
});
|
||||
const [_deleteTaskConfirm, setDeleteTaskConfirm] = useState<{
|
||||
show: boolean;
|
||||
taskId: string | null;
|
||||
taskName: string | null;
|
||||
deleting: boolean;
|
||||
}>({
|
||||
show: false,
|
||||
taskId: null,
|
||||
taskName: null,
|
||||
deleting: false,
|
||||
});
|
||||
|
||||
// 加载模特详情
|
||||
const loadModelDetail = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const modelData = await videoGenerationService.getModelDetailWithPhotos(id);
|
||||
if (modelData) {
|
||||
setModel(modelData);
|
||||
// 设置默认的模特描述
|
||||
setPromptConfig(prev => ({
|
||||
...prev,
|
||||
model_desc: modelData.description || `${modelData.name},${modelData.tags.join('、')}`
|
||||
}));
|
||||
} else {
|
||||
setError('模特不存在');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err as string);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载视频生成任务
|
||||
const loadVideoTasks = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const tasks = await videoGenerationService.getVideoGenerationTasks({
|
||||
model_id: id
|
||||
});
|
||||
setVideoTasks(tasks);
|
||||
} catch (err) {
|
||||
console.error('加载视频任务失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 上传照片
|
||||
const handleUploadPhotos = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setUploadingPhotos(true);
|
||||
|
||||
const selected = await open({
|
||||
multiple: true,
|
||||
filters: [{
|
||||
name: 'Images',
|
||||
extensions: ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']
|
||||
}]
|
||||
});
|
||||
|
||||
if (selected && Array.isArray(selected)) {
|
||||
const photos = await videoGenerationService.batchUploadModelPhotos(
|
||||
id,
|
||||
selected,
|
||||
PhotoType.Portrait,
|
||||
'模特照片',
|
||||
['视频生成']
|
||||
);
|
||||
|
||||
// 重新加载模特数据
|
||||
await loadModelDetail();
|
||||
|
||||
console.log(`成功上传 ${photos.length} 张照片`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(`上传照片失败: ${err}`);
|
||||
} finally {
|
||||
setUploadingPhotos(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 选择/取消选择照片
|
||||
const togglePhotoSelection = (photoId: string) => {
|
||||
setSelectedPhotos(prev =>
|
||||
prev.includes(photoId)
|
||||
? prev.filter(id => id !== photoId)
|
||||
: [...prev, photoId]
|
||||
);
|
||||
};
|
||||
|
||||
// 生成视频
|
||||
const handleGenerateVideo = async () => {
|
||||
if (!id || selectedPhotos.length === 0) {
|
||||
setError('请至少选择一张照片');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!promptConfig.product.trim() || !promptConfig.scene.trim()) {
|
||||
setError('请填写产品描述和场景描述');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
const request = {
|
||||
model_id: id,
|
||||
prompt_config: promptConfig,
|
||||
selected_photos: selectedPhotos
|
||||
};
|
||||
|
||||
// 创建并执行任务
|
||||
const task = await videoGenerationService.createAndExecuteVideoGeneration(request);
|
||||
|
||||
// 重新加载任务列表
|
||||
await loadVideoTasks();
|
||||
|
||||
// 开始轮询任务状态
|
||||
videoGenerationService.pollTaskUntilComplete(
|
||||
task.id,
|
||||
(updatedTask) => {
|
||||
// 更新任务列表中的任务状态
|
||||
setVideoTasks(prev =>
|
||||
prev.map(t => t.id === updatedTask.id ? updatedTask : t)
|
||||
);
|
||||
}
|
||||
).then(() => {
|
||||
// 任务完成后重新加载任务列表
|
||||
loadVideoTasks();
|
||||
}).catch(err => {
|
||||
console.error('轮询任务状态失败:', err);
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
setError(`生成视频失败: ${err}`);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 重试任务
|
||||
const handleRetryTask = async (taskId: string) => {
|
||||
try {
|
||||
await videoGenerationService.retryVideoGenerationTask(taskId);
|
||||
await loadVideoTasks();
|
||||
} catch (err) {
|
||||
setError(`重试任务失败: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 显示删除任务确认弹框
|
||||
const showDeleteTaskConfirm = (taskId: string) => {
|
||||
const task = videoTasks.find(t => t.id === taskId);
|
||||
const taskName = task ? `${task.prompt_config.product} - ${task.prompt_config.template}` : '未知任务';
|
||||
|
||||
setDeleteTaskConfirm({
|
||||
show: true,
|
||||
taskId,
|
||||
taskName,
|
||||
deleting: false,
|
||||
});
|
||||
};
|
||||
|
||||
// 显示删除照片确认弹框
|
||||
const showDeletePhotoConfirm = (photoId: string) => {
|
||||
if (!model) return;
|
||||
|
||||
const photo = model.photos.find(p => p.id === photoId);
|
||||
const photoName = photo?.description || photo?.file_name || '未知照片';
|
||||
|
||||
setDeletePhotoConfirm({
|
||||
show: true,
|
||||
photoId,
|
||||
photoName,
|
||||
deleting: false,
|
||||
});
|
||||
};
|
||||
|
||||
// 确认删除照片
|
||||
const confirmDeletePhoto = async () => {
|
||||
if (!deletePhotoConfirm.photoId || !id) return;
|
||||
|
||||
setDeletePhotoConfirm(prev => ({ ...prev, deleting: true }));
|
||||
|
||||
try {
|
||||
await invoke('delete_model_photo', {
|
||||
modelId: id,
|
||||
photoId: deletePhotoConfirm.photoId
|
||||
});
|
||||
|
||||
// 重新加载模特数据
|
||||
await loadModelDetail();
|
||||
|
||||
// 如果删除的照片在选中列表中,移除它
|
||||
setSelectedPhotos(prev => prev.filter(selectedId => selectedId !== deletePhotoConfirm.photoId));
|
||||
|
||||
console.log('照片删除成功');
|
||||
|
||||
// 关闭确认弹框
|
||||
setDeletePhotoConfirm({
|
||||
show: false,
|
||||
photoId: null,
|
||||
photoName: null,
|
||||
deleting: false,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(`删除照片失败: ${err}`);
|
||||
setDeletePhotoConfirm(prev => ({ ...prev, deleting: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 取消删除照片
|
||||
const cancelDeletePhoto = () => {
|
||||
setDeletePhotoConfirm({
|
||||
show: false,
|
||||
photoId: null,
|
||||
photoName: null,
|
||||
deleting: false,
|
||||
});
|
||||
};
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
loadModelDetail();
|
||||
loadVideoTasks();
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-600">{error}</p>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="mt-2 text-sm text-red-500 hover:text-red-700"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">模特不存在</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-blue-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{/* 头部 - 优化设计 */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => navigate('/models')}
|
||||
className="group flex items-center text-gray-600 hover:text-blue-600 transition-all duration-200 hover:bg-blue-50 px-3 py-2 rounded-lg"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform duration-200" />
|
||||
返回模特列表
|
||||
</button>
|
||||
<div className="h-8 w-px bg-gray-200"></div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-900 to-blue-600 bg-clip-text text-transparent">
|
||||
{model.name}
|
||||
</h1>
|
||||
{model.stage_name && (
|
||||
<span className="text-lg text-gray-500 font-medium">({model.stage_name})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUploadPhotos}
|
||||
disabled={uploadingPhotos}
|
||||
className="group flex items-center px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-105"
|
||||
>
|
||||
{uploadingPhotos ? (
|
||||
<LoadingSpinner size="small" className="mr-2" color="white" />
|
||||
) : (
|
||||
<PlusIcon className="w-5 h-5 mr-2 group-hover:rotate-90 transition-transform duration-200" />
|
||||
)}
|
||||
上传照片
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* 左侧:模特信息和照片 */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
{/* 模特基本信息 - 优化设计 */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 hover:shadow-md transition-all duration-300 animate-slide-up">
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="w-2 h-8 bg-gradient-to-b from-blue-500 to-blue-600 rounded-full mr-4"></div>
|
||||
<h2 className="text-xl font-bold text-gray-900">基本信息</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">性别:</span>
|
||||
<span>{model.gender}</span>
|
||||
</div>
|
||||
{model.age && (
|
||||
<div>
|
||||
<span className="text-gray-500">年龄:</span>
|
||||
<span>{model.age}岁</span>
|
||||
</div>
|
||||
)}
|
||||
{model.height && (
|
||||
<div>
|
||||
<span className="text-gray-500">身高:</span>
|
||||
<span>{model.height}cm</span>
|
||||
</div>
|
||||
)}
|
||||
{model.weight && (
|
||||
<div>
|
||||
<span className="text-gray-500">体重:</span>
|
||||
<span>{model.weight}kg</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{model.description && (
|
||||
<div className="mt-4">
|
||||
<span className="text-gray-500">描述:</span>
|
||||
<p className="mt-1 text-gray-900">{model.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{model.tags.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<span className="text-gray-500">标签:</span>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{model.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 照片选择 */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">选择照片</h2>
|
||||
<span className="text-sm text-gray-500">
|
||||
已选择 {selectedPhotos.length} 张照片
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{model.photos.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<PhotoIcon className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500">暂无照片,请先上传照片</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{model.photos.map((photo) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all group ${selectedPhotos.includes(photo.id)
|
||||
? 'border-blue-500 ring-2 ring-blue-200'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full cursor-pointer"
|
||||
onClick={() => togglePhotoSelection(photo.id)}
|
||||
>
|
||||
<img
|
||||
src={photo.file_path.startsWith('http') ? photo.file_path : `file://${photo.file_path}`}
|
||||
alt={photo.description || '模特照片'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
showDeletePhotoConfirm(photo.id);
|
||||
}}
|
||||
className="absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center hover:bg-red-600"
|
||||
title="删除照片"
|
||||
>
|
||||
<TrashIcon className="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
{/* 选中状态显示 */}
|
||||
{selectedPhotos.includes(photo.id) && (
|
||||
<div className="absolute inset-0 bg-blue-500 bg-opacity-20 flex items-center justify-center pointer-events-none">
|
||||
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-xs font-bold">
|
||||
{selectedPhotos.indexOf(photo.id) + 1}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:视频生成配置和任务 */}
|
||||
<div className="space-y-6">
|
||||
{/* 视频生成配置 */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">视频生成配置</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 产品描述 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
产品描述
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={promptConfig.product}
|
||||
onChange={(e) => setPromptConfig(prev => ({ ...prev, product: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200"
|
||||
placeholder="输入产品描述,如:超短牛仔裙(白色紧身蕾丝短袖)"
|
||||
list="product-options"
|
||||
/>
|
||||
<datalist id="product-options">
|
||||
{PRODUCT_OPTIONS.map((option, index) => (
|
||||
<option key={index} value={option} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
{/* 场景描述 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
场景描述
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={promptConfig.scene}
|
||||
onChange={(e) => setPromptConfig(prev => ({ ...prev, scene: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200"
|
||||
placeholder="输入场景描述,如:室内可爱简约的女性卧室"
|
||||
list="scene-options"
|
||||
/>
|
||||
<datalist id="scene-options">
|
||||
{SCENE_OPTIONS.map((option, index) => (
|
||||
<option key={index} value={option} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
{/* 模特描述 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
模特描述
|
||||
</label>
|
||||
<textarea
|
||||
value={promptConfig.model_desc}
|
||||
onChange={(e) => setPromptConfig(prev => ({ ...prev, model_desc: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
placeholder="描述模特的特征和风格"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 模板类型 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
模板类型
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={promptConfig.template}
|
||||
onChange={(value) => setPromptConfig(prev => ({ ...prev, template: value }))}
|
||||
options={TEMPLATE_OPTIONS}
|
||||
placeholder="选择模板类型"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 生成数量 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
生成数量
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
value={promptConfig.duplicate}
|
||||
onChange={(e) => setPromptConfig(prev => ({ ...prev, duplicate: parseInt(e.target.value) || 1 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 生成按钮 */}
|
||||
<button
|
||||
onClick={handleGenerateVideo}
|
||||
disabled={isGenerating || selectedPhotos.length === 0}
|
||||
className="w-full flex items-center justify-center px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<LoadingSpinner size="small" className="mr-2" />
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayIcon className="w-5 h-5 mr-2" />
|
||||
生成视频
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 视频生成任务 */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">生成任务</h2>
|
||||
<button
|
||||
onClick={loadVideoTasks}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowPathIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{videoTasks.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-4">暂无生成任务</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{videoTasks.map((task) => {
|
||||
const statusConfig = VIDEO_GENERATION_STATUS_CONFIG[task.status];
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`p-3 rounded-lg border ${statusConfig.borderColor} ${statusConfig.bgColor}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className={`text-sm font-medium ${statusConfig.color}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
{task.status === VideoGenerationStatus.Failed && (
|
||||
<button
|
||||
onClick={() => handleRetryTask(task.id)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
title="重试"
|
||||
>
|
||||
<ArrowPathIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => showDeleteTaskConfirm(task.id)}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
title="删除"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-600">
|
||||
<p>产品: {task.prompt_config.product}</p>
|
||||
<p>模板: {task.prompt_config.template}</p>
|
||||
<p>创建时间: {new Date(task.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
{task.result && task.result.video_urls.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-gray-600 mb-1">生成的视频:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{task.result.video_urls.map((url, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 text-xs"
|
||||
>
|
||||
<EyeIcon className="w-3 h-3 mr-1" />
|
||||
视频 {index + 1}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.error_message && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
错误: {task.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 删除照片确认弹框 */}
|
||||
<DeleteConfirmDialog
|
||||
isOpen={deletePhotoConfirm.show}
|
||||
title="删除照片"
|
||||
message="确定要删除这张照片吗?此操作不可撤销。"
|
||||
itemName={deletePhotoConfirm.photoName || undefined}
|
||||
deleting={deletePhotoConfirm.deleting}
|
||||
onConfirm={confirmDeletePhoto}
|
||||
onCancel={cancelDeletePhoto}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelDetail;
|
||||
@@ -175,7 +175,7 @@ export const ProjectDetails: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
// 加载项目分类统计信息
|
||||
const loadProjectClassificationStats = useCallback(async (projectId: string, materialList?: any[]) => {
|
||||
const loadProjectClassificationStats = useCallback(async (_projectId: string, materialList?: any[]) => {
|
||||
try {
|
||||
// 使用传入的素材列表或当前的素材列表
|
||||
const materialsToProcess = materialList || materials;
|
||||
|
||||
241
apps/desktop/src/services/videoGenerationService.ts
Normal file
241
apps/desktop/src/services/videoGenerationService.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
VideoGenerationTask,
|
||||
VideoGenerationQueryParams,
|
||||
CreateVideoGenerationRequest,
|
||||
VideoGenerationAPI,
|
||||
} from '../types/videoGeneration';
|
||||
import { Model, ModelPhoto, PhotoType } from '../types/model';
|
||||
|
||||
/**
|
||||
* 视频生成服务
|
||||
* 提供视频生成相关的API调用功能
|
||||
*/
|
||||
class VideoGenerationService implements VideoGenerationAPI {
|
||||
/**
|
||||
* 创建视频生成任务
|
||||
*/
|
||||
async createVideoGenerationTask(request: CreateVideoGenerationRequest): Promise<VideoGenerationTask> {
|
||||
try {
|
||||
const task = await invoke<VideoGenerationTask>('create_video_generation_task', {
|
||||
request
|
||||
});
|
||||
return task;
|
||||
} catch (error) {
|
||||
console.error('创建视频生成任务失败:', error);
|
||||
throw new Error(`创建视频生成任务失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行视频生成任务
|
||||
*/
|
||||
async executeVideoGenerationTask(taskId: string): Promise<VideoGenerationTask> {
|
||||
try {
|
||||
const task = await invoke<VideoGenerationTask>('execute_video_generation_task', {
|
||||
taskId
|
||||
});
|
||||
return task;
|
||||
} catch (error) {
|
||||
console.error('执行视频生成任务失败:', error);
|
||||
throw new Error(`执行视频生成任务失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频生成任务
|
||||
*/
|
||||
async getVideoGenerationTask(taskId: string): Promise<VideoGenerationTask | null> {
|
||||
try {
|
||||
const task = await invoke<VideoGenerationTask | null>('get_video_generation_task', {
|
||||
taskId
|
||||
});
|
||||
return task;
|
||||
} catch (error) {
|
||||
console.error('获取视频生成任务失败:', error);
|
||||
throw new Error(`获取视频生成任务失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频生成任务列表
|
||||
*/
|
||||
async getVideoGenerationTasks(params?: VideoGenerationQueryParams): Promise<VideoGenerationTask[]> {
|
||||
try {
|
||||
const tasks = await invoke<VideoGenerationTask[]>('get_video_generation_tasks', {
|
||||
params: params || {}
|
||||
});
|
||||
return tasks;
|
||||
} catch (error) {
|
||||
console.error('获取视频生成任务列表失败:', error);
|
||||
throw new Error(`获取视频生成任务列表失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消视频生成任务
|
||||
*/
|
||||
async cancelVideoGenerationTask(taskId: string): Promise<void> {
|
||||
try {
|
||||
await invoke('cancel_video_generation_task', {
|
||||
taskId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('取消视频生成任务失败:', error);
|
||||
throw new Error(`取消视频生成任务失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除视频生成任务
|
||||
*/
|
||||
async deleteVideoGenerationTask(taskId: string): Promise<void> {
|
||||
try {
|
||||
await invoke('delete_video_generation_task', {
|
||||
taskId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除视频生成任务失败:', error);
|
||||
throw new Error(`删除视频生成任务失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试视频生成任务
|
||||
*/
|
||||
async retryVideoGenerationTask(taskId: string): Promise<VideoGenerationTask> {
|
||||
try {
|
||||
const task = await invoke<VideoGenerationTask>('retry_video_generation_task', {
|
||||
taskId
|
||||
});
|
||||
return task;
|
||||
} catch (error) {
|
||||
console.error('重试视频生成任务失败:', error);
|
||||
throw new Error(`重试视频生成任务失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模特详情(包含照片)
|
||||
*/
|
||||
async getModelDetailWithPhotos(modelId: string): Promise<Model | null> {
|
||||
try {
|
||||
const model = await invoke<Model | null>('get_model_detail_with_photos', {
|
||||
modelId
|
||||
});
|
||||
return model;
|
||||
} catch (error) {
|
||||
console.error('获取模特详情失败:', error);
|
||||
throw new Error(`获取模特详情失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量上传模特照片
|
||||
*/
|
||||
async batchUploadModelPhotos(
|
||||
modelId: string,
|
||||
filePaths: string[],
|
||||
photoType: PhotoType,
|
||||
description?: string,
|
||||
tags?: string[]
|
||||
): Promise<ModelPhoto[]> {
|
||||
try {
|
||||
const photos = await invoke<ModelPhoto[]>('batch_upload_model_photos', {
|
||||
modelId,
|
||||
filePaths,
|
||||
photoType,
|
||||
description,
|
||||
tags
|
||||
});
|
||||
return photos;
|
||||
} catch (error) {
|
||||
console.error('批量上传模特照片失败:', error);
|
||||
throw new Error(`批量上传模特照片失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模特视频生成统计
|
||||
*/
|
||||
async getModelVideoGenerationStatistics(modelId: string) {
|
||||
try {
|
||||
const stats = await invoke('get_model_video_generation_statistics', {
|
||||
modelId
|
||||
});
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error('获取模特视频生成统计失败:', error);
|
||||
throw new Error(`获取模特视频生成统计失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并执行视频生成任务(一步完成)
|
||||
*/
|
||||
async createAndExecuteVideoGeneration(request: CreateVideoGenerationRequest): Promise<VideoGenerationTask> {
|
||||
try {
|
||||
// 1. 创建任务
|
||||
const task = await this.createVideoGenerationTask(request);
|
||||
|
||||
// 2. 执行任务
|
||||
const executedTask = await this.executeVideoGenerationTask(task.id);
|
||||
|
||||
return executedTask;
|
||||
} catch (error) {
|
||||
console.error('创建并执行视频生成任务失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询任务状态直到完成
|
||||
*/
|
||||
async pollTaskUntilComplete(
|
||||
taskId: string,
|
||||
onProgress?: (task: VideoGenerationTask) => void,
|
||||
maxAttempts: number = 60,
|
||||
intervalMs: number = 2000
|
||||
): Promise<VideoGenerationTask> {
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const task = await this.getVideoGenerationTask(taskId);
|
||||
|
||||
if (!task) {
|
||||
throw new Error('任务不存在');
|
||||
}
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(task);
|
||||
}
|
||||
|
||||
// 检查任务是否完成
|
||||
if (task.status === 'Completed' || task.status === 'Failed' || task.status === 'Cancelled') {
|
||||
return task;
|
||||
}
|
||||
|
||||
// 等待一段时间后继续轮询
|
||||
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
||||
attempts++;
|
||||
} catch (error) {
|
||||
console.error('轮询任务状态失败:', error);
|
||||
attempts++;
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
throw new Error(`轮询任务状态超时: ${error}`);
|
||||
}
|
||||
|
||||
// 等待一段时间后重试
|
||||
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('轮询任务状态超时');
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const videoGenerationService = new VideoGenerationService();
|
||||
export default videoGenerationService;
|
||||
188
apps/desktop/src/types/videoGeneration.ts
Normal file
188
apps/desktop/src/types/videoGeneration.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// 视频生成相关类型定义
|
||||
|
||||
export interface VideoGenerationTask {
|
||||
id: string;
|
||||
model_id: string;
|
||||
prompt_config: VideoPromptConfig;
|
||||
selected_photos: string[];
|
||||
status: VideoGenerationStatus;
|
||||
result?: VideoGenerationResult;
|
||||
error_message?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
export interface VideoPromptConfig {
|
||||
product: string; // 产品描述
|
||||
scene: string; // 场景描述
|
||||
model_desc: string; // 模特描述
|
||||
template: string; // 模板类型
|
||||
duplicate: number; // 生成数量
|
||||
}
|
||||
|
||||
export enum VideoGenerationStatus {
|
||||
Pending = "Pending",
|
||||
Processing = "Processing",
|
||||
Completed = "Completed",
|
||||
Failed = "Failed",
|
||||
Cancelled = "Cancelled"
|
||||
}
|
||||
|
||||
export interface VideoGenerationResult {
|
||||
video_urls: string[];
|
||||
video_paths: string[];
|
||||
generation_time: number;
|
||||
api_response?: string;
|
||||
}
|
||||
|
||||
export interface CreateVideoGenerationRequest {
|
||||
model_id: string;
|
||||
prompt_config: VideoPromptConfig;
|
||||
selected_photos: string[];
|
||||
}
|
||||
|
||||
export interface VideoGenerationQueryParams {
|
||||
model_id?: string;
|
||||
status?: VideoGenerationStatus;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface DifyApiConfig {
|
||||
host: string;
|
||||
api_key: string;
|
||||
}
|
||||
|
||||
export interface DifyApiRequest {
|
||||
inputs: DifyInputs;
|
||||
response_mode: string;
|
||||
user: string;
|
||||
}
|
||||
|
||||
export interface DifyInputs {
|
||||
product: string;
|
||||
scene: string;
|
||||
model: string;
|
||||
image: string;
|
||||
duplicate: number;
|
||||
template: string;
|
||||
}
|
||||
|
||||
export interface DifyApiResponse {
|
||||
data?: DifyResponseData;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DifyResponseData {
|
||||
outputs: DifyOutputs;
|
||||
}
|
||||
|
||||
export interface DifyOutputs {
|
||||
output: string[];
|
||||
}
|
||||
|
||||
// 视频生成表单数据
|
||||
export interface VideoGenerationFormData {
|
||||
product: string;
|
||||
scene: string;
|
||||
model_desc: string;
|
||||
template: string;
|
||||
duplicate: number;
|
||||
selected_photos: string[];
|
||||
}
|
||||
|
||||
// 视频生成表单错误
|
||||
export interface VideoGenerationFormErrors {
|
||||
product?: string;
|
||||
scene?: string;
|
||||
model_desc?: string;
|
||||
template?: string;
|
||||
duplicate?: string;
|
||||
selected_photos?: string;
|
||||
}
|
||||
|
||||
// 预设模板选项
|
||||
export interface TemplateOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// 常用模板预设
|
||||
export const TEMPLATE_OPTIONS: TemplateOption[] = [
|
||||
{ value: "抚媚眼神", label: "抚媚眼神", description: "展现模特魅惑的眼神和表情" },
|
||||
{ value: "清纯甜美", label: "清纯甜美", description: "展现模特清纯可爱的一面" },
|
||||
{ value: "性感妩媚", label: "性感妩媚", description: "展现模特性感迷人的魅力" },
|
||||
{ value: "优雅知性", label: "优雅知性", description: "展现模特优雅大方的气质" },
|
||||
{ value: "活力青春", label: "活力青春", description: "展现模特青春活力的状态" },
|
||||
{ value: "神秘冷艳", label: "神秘冷艳", description: "展现模特神秘高冷的气质" },
|
||||
];
|
||||
|
||||
// 常用场景预设
|
||||
export const SCENE_OPTIONS = [
|
||||
"室内可爱简约的女性卧室",
|
||||
"现代简约的客厅环境",
|
||||
"温馨浪漫的咖啡厅",
|
||||
"时尚现代的摄影棚",
|
||||
"自然清新的户外花园",
|
||||
"优雅精致的酒店套房",
|
||||
"艺术感十足的工作室",
|
||||
"温暖舒适的家居环境",
|
||||
];
|
||||
|
||||
// 常用产品描述预设
|
||||
export const PRODUCT_OPTIONS = [
|
||||
"超短牛仔裙(白色紧身蕾丝短袖)",
|
||||
"黑色修身连衣裙",
|
||||
"白色衬衫配黑色短裙",
|
||||
"粉色针织毛衣配牛仔裤",
|
||||
"红色晚礼服",
|
||||
"休闲运动装",
|
||||
"职业套装",
|
||||
"夏日清新连衣裙",
|
||||
];
|
||||
|
||||
// 视频生成API相关类型
|
||||
export interface VideoGenerationAPI {
|
||||
createVideoGenerationTask: (request: CreateVideoGenerationRequest) => Promise<VideoGenerationTask>;
|
||||
getVideoGenerationTask: (taskId: string) => Promise<VideoGenerationTask | null>;
|
||||
getVideoGenerationTasks: (params?: VideoGenerationQueryParams) => Promise<VideoGenerationTask[]>;
|
||||
cancelVideoGenerationTask: (taskId: string) => Promise<void>;
|
||||
deleteVideoGenerationTask: (taskId: string) => Promise<void>;
|
||||
retryVideoGenerationTask: (taskId: string) => Promise<VideoGenerationTask>;
|
||||
}
|
||||
|
||||
// 视频生成状态显示配置
|
||||
export const VIDEO_GENERATION_STATUS_CONFIG = {
|
||||
[VideoGenerationStatus.Pending]: {
|
||||
label: "等待中",
|
||||
color: "text-yellow-600",
|
||||
bgColor: "bg-yellow-50",
|
||||
borderColor: "border-yellow-200",
|
||||
},
|
||||
[VideoGenerationStatus.Processing]: {
|
||||
label: "处理中",
|
||||
color: "text-blue-600",
|
||||
bgColor: "bg-blue-50",
|
||||
borderColor: "border-blue-200",
|
||||
},
|
||||
[VideoGenerationStatus.Completed]: {
|
||||
label: "已完成",
|
||||
color: "text-green-600",
|
||||
bgColor: "bg-green-50",
|
||||
borderColor: "border-green-200",
|
||||
},
|
||||
[VideoGenerationStatus.Failed]: {
|
||||
label: "失败",
|
||||
color: "text-red-600",
|
||||
bgColor: "bg-red-50",
|
||||
borderColor: "border-red-200",
|
||||
},
|
||||
[VideoGenerationStatus.Cancelled]: {
|
||||
label: "已取消",
|
||||
color: "text-gray-600",
|
||||
bgColor: "bg-gray-50",
|
||||
borderColor: "border-gray-200",
|
||||
},
|
||||
};
|
||||
@@ -15,7 +15,7 @@ export default defineConfig(async () => ({
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
strictPort: false,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user