diff --git a/apps/desktop/src-tauri/src/app_state.rs b/apps/desktop/src-tauri/src/app_state.rs index 9826668..7b1afc3 100644 --- a/apps/desktop/src-tauri/src/app_state.rs +++ b/apps/desktop/src-tauri/src/app_state.rs @@ -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>, pub material_repository: Mutex>, pub model_repository: Mutex>, + pub video_generation_repository: Mutex>, pub performance_monitor: Mutex, pub event_bus_manager: Arc, } @@ -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>> { + Ok(self.video_generation_repository.lock().unwrap()) + } + /// 获取数据库实例 pub fn get_database(&self) -> Arc { // 使用全局静态数据库实例,确保整个应用只有一个数据库实例 @@ -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()), }; diff --git a/apps/desktop/src-tauri/src/business/services/mod.rs b/apps/desktop/src-tauri/src/business/services/mod.rs index 49db08c..b1848e8 100644 --- a/apps/desktop/src-tauri/src/business/services/mod.rs +++ b/apps/desktop/src-tauri/src/business/services/mod.rs @@ -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; diff --git a/apps/desktop/src-tauri/src/business/services/model_service.rs b/apps/desktop/src-tauri/src/business/services/model_service.rs index 082a9b0..4357c5f 100644 --- a/apps/desktop/src-tauri/src/business/services/model_service.rs +++ b/apps/desktop/src-tauri/src/business/services/model_service.rs @@ -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, + tags: Option>, + ) -> Result { + // 检查模特是否存在 + 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, diff --git a/apps/desktop/src-tauri/src/business/services/video_generation_service.rs b/apps/desktop/src-tauri/src/business/services/video_generation_service.rs new file mode 100644 index 0000000..6cf2786 --- /dev/null +++ b/apps/desktop/src-tauri/src/business/services/video_generation_service.rs @@ -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 { + // 验证模特是否存在 + 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 { + // 获取任务 + 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> { + repository.get_by_id(task_id) + } + + /// 获取视频生成任务列表 + pub fn get_tasks( + repository: &VideoGenerationRepository, + params: &VideoGenerationQueryParams, + ) -> Result> { + repository.search(params) + } + + /// 取消视频生成任务 + pub fn cancel_task( + repository: &VideoGenerationRepository, + task_id: &str, + ) -> Result { + 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 { + 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 { + 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 { + 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, +} diff --git a/apps/desktop/src-tauri/src/data/models/mod.rs b/apps/desktop/src-tauri/src/data/models/mod.rs index f5c0043..8560fac 100644 --- a/apps/desktop/src-tauri/src/data/models/mod.rs +++ b/apps/desktop/src-tauri/src/data/models/mod.rs @@ -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; diff --git a/apps/desktop/src-tauri/src/data/models/video_generation.rs b/apps/desktop/src-tauri/src/data/models/video_generation.rs new file mode 100644 index 0000000..4df6421 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/video_generation.rs @@ -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, // 选中的照片ID列表 + pub status: VideoGenerationStatus, + pub result: Option, + pub error_message: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub completed_at: Option>, +} + +/// 视频生成提示词配置 +#[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, // 生成的视频URL列表 + pub video_paths: Vec, // 本地保存的视频路径列表 + pub generation_time: u64, // 生成耗时(毫秒) + pub api_response: Option, // API原始响应 +} + +/// 创建视频生成任务请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateVideoGenerationRequest { + pub model_id: String, + pub prompt_config: VideoPromptConfig, + pub selected_photos: Vec, +} + +/// 视频生成任务查询参数 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VideoGenerationQueryParams { + pub model_id: Option, + pub status: Option, + pub limit: Option, + pub offset: Option, +} + +/// 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, + pub error: Option, +} + +/// 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, // 生成的视频URL列表 +} + +impl VideoGenerationTask { + /// 创建新的视频生成任务 + pub fn new( + model_id: String, + prompt_config: VideoPromptConfig, + selected_photos: Vec, + ) -> 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, + } + } +} diff --git a/apps/desktop/src-tauri/src/data/repositories/mod.rs b/apps/desktop/src-tauri/src/data/repositories/mod.rs index 78351bb..e642f74 100644 --- a/apps/desktop/src-tauri/src/data/repositories/mod.rs +++ b/apps/desktop/src-tauri/src/data/repositories/mod.rs @@ -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; diff --git a/apps/desktop/src-tauri/src/data/repositories/model_repository.rs b/apps/desktop/src-tauri/src/data/repositories/model_repository.rs index f16515a..201c993 100644 --- a/apps/desktop/src-tauri/src/data/repositories/model_repository.rs +++ b/apps/desktop/src-tauri/src/data/repositories/model_repository.rs @@ -9,6 +9,7 @@ use crate::infrastructure::database::Database; /// 模特数据仓库 /// 遵循 Tauri 开发规范的仓库模式设计 +#[derive(Clone)] pub struct ModelRepository { database: Arc, } diff --git a/apps/desktop/src-tauri/src/data/repositories/video_generation_repository.rs b/apps/desktop/src-tauri/src/data/repositories/video_generation_repository.rs new file mode 100644 index 0000000..994ca76 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/repositories/video_generation_repository.rs @@ -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, +} + +impl VideoGenerationRepository { + /// 创建新的视频生成任务仓库实例 + pub fn new(database: Arc) -> 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> { + 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> { + 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> = 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 { + let selected_photos_json: String = row.get("selected_photos")?; + let selected_photos: Vec = 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>("video_urls")?, row.get::<_, Option>("video_paths")?) { + + let video_urls: Vec = serde_json::from_str(&video_urls_json).unwrap_or_default(); + let video_paths: Vec = serde_json::from_str(&video_paths_json).unwrap_or_default(); + let generation_time: Option = row.get("generation_time")?; + let api_response: Option = 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 = 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) + ), + }) + } +} diff --git a/apps/desktop/src-tauri/src/infrastructure/mod.rs b/apps/desktop/src-tauri/src/infrastructure/mod.rs index 67ee194..bb8e0db 100644 --- a/apps/desktop/src-tauri/src/infrastructure/mod.rs +++ b/apps/desktop/src-tauri/src/infrastructure/mod.rs @@ -9,3 +9,4 @@ pub mod ffmpeg; pub mod monitoring; pub mod logging; pub mod gemini_service; +pub mod video_generation_service; diff --git a/apps/desktop/src-tauri/src/infrastructure/video_generation_service.rs b/apps/desktop/src-tauri/src/infrastructure/video_generation_service.rs new file mode 100644 index 0000000..127efa6 --- /dev/null +++ b/apps/desktop/src-tauri/src/infrastructure/video_generation_service.rs @@ -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 { + 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> { + 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> { + 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 { + // 创建下载目录 + 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 { + 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 { + 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()); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index a1a1268..92d3958 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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, diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index b76170a..f5c6994 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -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; diff --git a/apps/desktop/src-tauri/src/presentation/commands/video_generation_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/video_generation_commands.rs new file mode 100644 index 0000000..d086b37 --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/video_generation_commands.rs @@ -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 { + // 克隆仓库引用以避免跨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 { + 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, 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, 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 { + 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 { + 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 { + 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, 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, + photo_type: crate::data::models::model::PhotoType, + description: Option, + tags: Option>, +) -> Result, 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) +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 766d1e8..9b20475 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/desktop/src/components/ModelCard.tsx b/apps/desktop/src/components/ModelCard.tsx index 81f0346..c035e30 100644 --- a/apps/desktop/src/components/ModelCard.tsx +++ b/apps/desktop/src/components/ModelCard.tsx @@ -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 = ({ 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 = ({ if (viewMode === ModelViewMode.List) { // 列表视图 return ( -
+
{/* 头像 */}
@@ -245,10 +261,10 @@ const ModelCard: React.FC = ({ // 网格视图 - 增强的现代化设计 return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} - onClick={onSelect} + onClick={handleCardClick} > {/* 装饰性背景 */}
@@ -435,16 +451,6 @@ const ModelCard: React.FC = ({ > -
diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 2be325e..cbc2e77 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -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( diff --git a/apps/desktop/src/pages/ModelDetail.tsx b/apps/desktop/src/pages/ModelDetail.tsx new file mode 100644 index 0000000..69d00b5 --- /dev/null +++ b/apps/desktop/src/pages/ModelDetail.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedPhotos, setSelectedPhotos] = useState([]); + const [promptConfig, setPromptConfig] = useState({ + product: '', + scene: '', + model_desc: '', + template: '抚媚眼神', + duplicate: 1 + }); + const [videoTasks, setVideoTasks] = useState([]); + 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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + if (!model) { + return ( +
+

模特不存在

+
+ ); + } + + return ( +
+
+ {/* 头部 - 优化设计 */} +
+
+
+ +
+
+

+ {model.name} +

+ {model.stage_name && ( + ({model.stage_name}) + )} +
+
+ + +
+
+ +
+ {/* 左侧:模特信息和照片 */} +
+ {/* 模特基本信息 - 优化设计 */} +
+
+
+

基本信息

+
+
+
+ 性别: + {model.gender} +
+ {model.age && ( +
+ 年龄: + {model.age}岁 +
+ )} + {model.height && ( +
+ 身高: + {model.height}cm +
+ )} + {model.weight && ( +
+ 体重: + {model.weight}kg +
+ )} +
+ + {model.description && ( +
+ 描述: +

{model.description}

+
+ )} + + {model.tags.length > 0 && ( +
+ 标签: +
+ {model.tags.map((tag, index) => ( + + {tag} + + ))} +
+
+ )} +
+ + {/* 照片选择 */} +
+
+

选择照片

+ + 已选择 {selectedPhotos.length} 张照片 + +
+ + {model.photos.length === 0 ? ( +
+ +

暂无照片,请先上传照片

+
+ ) : ( +
+ {model.photos.map((photo) => ( +
+
togglePhotoSelection(photo.id)} + > + {photo.description +
+ + {/* 删除按钮 */} + + + {/* 选中状态显示 */} + {selectedPhotos.includes(photo.id) && ( +
+
+ + {selectedPhotos.indexOf(photo.id) + 1} + +
+
+ )} +
+ ))} +
+ )} +
+
+ + {/* 右侧:视频生成配置和任务 */} +
+ {/* 视频生成配置 */} +
+

视频生成配置

+ +
+ {/* 产品描述 */} +
+ + 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" + /> + + {PRODUCT_OPTIONS.map((option, index) => ( + +
+ + {/* 场景描述 */} +
+ + 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" + /> + + {SCENE_OPTIONS.map((option, index) => ( + +
+ + {/* 模特描述 */} +
+ +