From da4aeaccb96b0e59acd1ee45bade92e5dd1c55e5 Mon Sep 17 00:00:00 2001 From: imeepos Date: Fri, 18 Jul 2025 16:49:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=A8=A1=E7=89=B9=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E9=A1=B5=E9=9D=A2=E5=BC=80=E5=8F=91=EF=BC=8C=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E6=A0=87=E9=A2=98=E5=92=8C=E6=8F=8F=E8=BF=B0=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src-tauri/src/app_state.rs | 19 +- .../src-tauri/src/business/services/mod.rs | 1 + .../services/model_dynamic_service.rs | 243 +++++++++++ apps/desktop/src-tauri/src/data/models/mod.rs | 1 + .../src/data/models/model_dynamic.rs | 245 ++++++++++++ .../src-tauri/src/data/repositories/mod.rs | 1 + .../repositories/model_dynamic_repository.rs | 378 ++++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 11 + .../src/presentation/commands/mod.rs | 1 + .../commands/model_dynamic_commands.rs | 170 ++++++++ apps/desktop/src/App.tsx | 2 + .../src/components/CreateDynamicModal.tsx | 265 ++++++++++++ .../src/components/ModelDynamicHeader.tsx | 190 +++++++++ .../src/components/ModelDynamicList.tsx | 186 +++++++++ apps/desktop/src/pages/ModelDetail.tsx | 37 +- apps/desktop/src/pages/ModelDynamics.tsx | 201 ++++++++++ .../src/services/modelDynamicService.ts | 206 ++++++++++ apps/desktop/src/types/model.ts | 79 ++++ 18 files changed, 2221 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src-tauri/src/business/services/model_dynamic_service.rs create mode 100644 apps/desktop/src-tauri/src/data/models/model_dynamic.rs create mode 100644 apps/desktop/src-tauri/src/data/repositories/model_dynamic_repository.rs create mode 100644 apps/desktop/src-tauri/src/presentation/commands/model_dynamic_commands.rs create mode 100644 apps/desktop/src/components/CreateDynamicModal.tsx create mode 100644 apps/desktop/src/components/ModelDynamicHeader.tsx create mode 100644 apps/desktop/src/components/ModelDynamicList.tsx create mode 100644 apps/desktop/src/pages/ModelDynamics.tsx create mode 100644 apps/desktop/src/services/modelDynamicService.ts diff --git a/apps/desktop/src-tauri/src/app_state.rs b/apps/desktop/src-tauri/src/app_state.rs index 7b1afc3..d174a29 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::model_dynamic_repository::ModelDynamicRepository; use crate::data::repositories::video_generation_repository::VideoGenerationRepository; use crate::infrastructure::database::Database; use crate::infrastructure::performance::PerformanceMonitor; @@ -14,6 +15,7 @@ pub struct AppState { pub project_repository: Mutex>, pub material_repository: Mutex>, pub model_repository: Mutex>, + pub model_dynamic_repository: Mutex>, pub video_generation_repository: Mutex>, pub performance_monitor: Mutex, pub event_bus_manager: Arc, @@ -26,6 +28,7 @@ impl AppState { project_repository: Mutex::new(None), material_repository: Mutex::new(None), model_repository: Mutex::new(None), + model_dynamic_repository: Mutex::new(None), video_generation_repository: Mutex::new(None), performance_monitor: Mutex::new(PerformanceMonitor::new()), event_bus_manager: Arc::new(EventBusManager::new()), @@ -60,15 +63,18 @@ impl AppState { let project_repository = ProjectRepository::new(database.clone())?; let material_repository = MaterialRepository::new(database.clone())?; let model_repository = ModelRepository::new(database.clone()); + let model_dynamic_repository = ModelDynamicRepository::new(database.clone()); let video_generation_repository = VideoGenerationRepository::new(database.clone()); - // 初始化视频生成任务表 + // 初始化数据库表 + model_dynamic_repository.init_tables()?; 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.model_dynamic_repository.lock().unwrap() = Some(model_dynamic_repository); *self.video_generation_repository.lock().unwrap() = Some(video_generation_repository); println!("数据库初始化完成,连接池状态: {}", @@ -83,15 +89,18 @@ impl AppState { let project_repository = ProjectRepository::new(database.clone())?; let material_repository = MaterialRepository::new(database.clone())?; let model_repository = ModelRepository::new(database.clone()); + let model_dynamic_repository = ModelDynamicRepository::new(database.clone()); let video_generation_repository = VideoGenerationRepository::new(database.clone()); - // 初始化视频生成任务表 + // 初始化数据库表 + model_dynamic_repository.init_tables()?; 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.model_dynamic_repository.lock().unwrap() = Some(model_dynamic_repository); *self.video_generation_repository.lock().unwrap() = Some(video_generation_repository); println!("数据库初始化完成,使用单连接模式"); @@ -113,6 +122,11 @@ impl AppState { Ok(self.model_repository.lock().unwrap()) } + /// 获取模特动态仓库实例 + pub fn get_model_dynamic_repository(&self) -> anyhow::Result>> { + Ok(self.model_dynamic_repository.lock().unwrap()) + } + /// 获取视频生成仓库实例 pub fn get_video_generation_repository(&self) -> anyhow::Result>> { Ok(self.video_generation_repository.lock().unwrap()) @@ -144,6 +158,7 @@ impl AppState { project_repository: Mutex::new(None), material_repository: Mutex::new(None), model_repository: Mutex::new(None), + model_dynamic_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 345c39c..a96aa64 100644 --- a/apps/desktop/src-tauri/src/business/services/mod.rs +++ b/apps/desktop/src-tauri/src/business/services/mod.rs @@ -2,6 +2,7 @@ pub mod project_service; pub mod material_service; pub mod material_segment_view_service; pub mod model_service; +pub mod model_dynamic_service; pub mod async_material_service; pub mod ai_classification_service; pub mod video_classification_service; diff --git a/apps/desktop/src-tauri/src/business/services/model_dynamic_service.rs b/apps/desktop/src-tauri/src/business/services/model_dynamic_service.rs new file mode 100644 index 0000000..3ea5e63 --- /dev/null +++ b/apps/desktop/src-tauri/src/business/services/model_dynamic_service.rs @@ -0,0 +1,243 @@ +use anyhow::{anyhow, Result}; + +use crate::data::repositories::model_dynamic_repository::ModelDynamicRepository; +use crate::data::models::model_dynamic::{ + ModelDynamic, GeneratedVideo, CreateModelDynamicRequest, UpdateModelDynamicRequest, + ModelDynamicStats, DynamicStatus, VideoGenerationStatus +}; +use crate::business::errors::BusinessError; + +/// 模特动态服务 +/// 遵循 Tauri 开发规范的业务逻辑层设计原则 +pub struct ModelDynamicService; + +impl ModelDynamicService { + /// 创建模特动态 + pub fn create_dynamic( + repository: &ModelDynamicRepository, + request: CreateModelDynamicRequest, + ) -> Result { + // 验证请求数据 + if request.model_id.is_empty() { + return Err(BusinessError::InvalidInput("模特ID不能为空".to_string()).into()); + } + + if request.description.trim().is_empty() { + return Err(BusinessError::InvalidInput("动态描述不能为空".to_string()).into()); + } + + if request.prompt.trim().is_empty() { + return Err(BusinessError::InvalidInput("提示词不能为空".to_string()).into()); + } + + if request.source_image_path.trim().is_empty() { + return Err(BusinessError::InvalidInput("源图片路径不能为空".to_string()).into()); + } + + if request.video_count < 1 || request.video_count > 9 { + return Err(BusinessError::InvalidInput("视频个数必须在1-9之间".to_string()).into()); + } + + // 创建动态 + let dynamic = repository.create(request) + .map_err(|e| anyhow!("创建模特动态失败: {}", e))?; + + Ok(dynamic) + } + + /// 获取模特动态详情 + pub fn get_dynamic_by_id( + repository: &ModelDynamicRepository, + id: &str, + ) -> Result> { + repository.get_by_id(id) + .map_err(|e| anyhow!("获取模特动态详情失败: {}", e)) + } + + /// 获取模特的所有动态 + pub fn get_dynamics_by_model_id( + repository: &ModelDynamicRepository, + model_id: &str, + ) -> Result> { + repository.get_by_model_id(model_id) + .map_err(|e| anyhow!("获取模特动态列表失败: {}", e)) + } + + /// 更新模特动态 + pub fn update_dynamic( + repository: &ModelDynamicRepository, + id: &str, + request: UpdateModelDynamicRequest, + ) -> Result { + // 检查动态是否存在 + let dynamic = repository.get_by_id(id)? + .ok_or_else(|| BusinessError::NotFound(format!("动态不存在: {}", id)))?; + + // 验证状态转换 + if let Some(new_status) = &request.status { + Self::validate_status_transition(&dynamic.status, new_status)?; + } + + // 更新动态 + repository.update(id, request) + .map_err(|e| anyhow!("更新模特动态失败: {}", e)) + } + + /// 删除模特动态 + pub fn delete_dynamic( + repository: &ModelDynamicRepository, + id: &str, + ) -> Result<()> { + // 检查动态是否存在 + let _dynamic = repository.get_by_id(id)? + .ok_or_else(|| BusinessError::NotFound(format!("动态不存在: {}", id)))?; + + // 删除动态 + repository.delete(id) + .map_err(|e| anyhow!("删除模特动态失败: {}", e)) + } + + /// 获取模特动态统计 + pub fn get_stats_by_model_id( + repository: &ModelDynamicRepository, + model_id: &str, + ) -> Result { + repository.get_stats_by_model_id(model_id) + .map_err(|e| anyhow!("获取模特动态统计失败: {}", e)) + } + + /// 添加生成的视频 + pub fn add_generated_video( + repository: &ModelDynamicRepository, + dynamic_id: &str, + video_path: String, + ) -> Result { + // 检查动态是否存在 + let mut dynamic = repository.get_by_id(dynamic_id)? + .ok_or_else(|| BusinessError::NotFound(format!("动态不存在: {}", dynamic_id)))?; + + // 创建视频对象 + let video = GeneratedVideo::new(dynamic_id.to_string(), video_path); + + // 添加到动态 + dynamic.add_generated_video(video.clone()); + + // 更新动态状态 + if dynamic.status == DynamicStatus::Draft { + dynamic.update_status(DynamicStatus::Publishing); + } + + // 更新动态 + let request = UpdateModelDynamicRequest { + title: None, + description: None, + prompt: None, + status: Some(dynamic.status.clone()), + }; + + repository.update(dynamic_id, request)?; + + Ok(video) + } + + /// 更新视频生成进度 + pub fn update_video_progress( + repository: &ModelDynamicRepository, + dynamic_id: &str, + video_id: &str, + progress: u32, + ) -> Result<()> { + // 检查动态是否存在 + let mut dynamic = repository.get_by_id(dynamic_id)? + .ok_or_else(|| BusinessError::NotFound(format!("动态不存在: {}", dynamic_id)))?; + + // 查找视频 + let video_index = dynamic.generated_videos.iter().position(|v| v.id == video_id) + .ok_or_else(|| BusinessError::NotFound(format!("视频不存在: {}", video_id)))?; + + // 更新进度 + dynamic.generated_videos[video_index].update_progress(progress); + + // 检查是否所有视频都已完成 + let all_completed = dynamic.generated_videos.iter() + .all(|v| v.status == VideoGenerationStatus::Completed || v.status == VideoGenerationStatus::Failed); + + // 如果所有视频都已完成,更新动态状态为已发布 + if all_completed && dynamic.status == DynamicStatus::Publishing { + dynamic.update_status(DynamicStatus::Published); + } + + // 更新动态 + let request = UpdateModelDynamicRequest { + title: None, + description: None, + prompt: None, + status: Some(dynamic.status.clone()), + }; + + repository.update(dynamic_id, request)?; + + Ok(()) + } + + /// 标记视频生成失败 + pub fn mark_video_as_failed( + repository: &ModelDynamicRepository, + dynamic_id: &str, + video_id: &str, + error_message: String, + ) -> Result<()> { + // 检查动态是否存在 + let mut dynamic = repository.get_by_id(dynamic_id)? + .ok_or_else(|| BusinessError::NotFound(format!("动态不存在: {}", dynamic_id)))?; + + // 查找视频 + let video_index = dynamic.generated_videos.iter().position(|v| v.id == video_id) + .ok_or_else(|| BusinessError::NotFound(format!("视频不存在: {}", video_id)))?; + + // 标记为失败 + dynamic.generated_videos[video_index].mark_as_failed(error_message); + + // 检查是否所有视频都已完成 + let all_completed = dynamic.generated_videos.iter() + .all(|v| v.status == VideoGenerationStatus::Completed || v.status == VideoGenerationStatus::Failed); + + // 如果所有视频都已完成,更新动态状态为已发布 + if all_completed && dynamic.status == DynamicStatus::Publishing { + dynamic.update_status(DynamicStatus::Published); + } + + // 更新动态 + let request = UpdateModelDynamicRequest { + title: None, + description: None, + prompt: None, + status: Some(dynamic.status.clone()), + }; + + repository.update(dynamic_id, request)?; + + Ok(()) + } + + /// 验证状态转换 + fn validate_status_transition( + current_status: &DynamicStatus, + new_status: &DynamicStatus, + ) -> Result<()> { + match (current_status, new_status) { + // 允许的状态转换 + (DynamicStatus::Draft, DynamicStatus::Publishing) => Ok(()), + (DynamicStatus::Publishing, DynamicStatus::Published) => Ok(()), + (DynamicStatus::Publishing, DynamicStatus::Failed) => Ok(()), + (DynamicStatus::Failed, DynamicStatus::Publishing) => Ok(()), + // 相同状态 + (a, b) if a == b => Ok(()), + // 不允许的状态转换 + _ => Err(BusinessError::InvalidState(format!( + "不允许的状态转换: {:?} -> {:?}", + current_status, new_status + )).into()), + } + } +} diff --git a/apps/desktop/src-tauri/src/data/models/mod.rs b/apps/desktop/src-tauri/src/data/models/mod.rs index 8618cd3..9c5862a 100644 --- a/apps/desktop/src-tauri/src/data/models/mod.rs +++ b/apps/desktop/src-tauri/src/data/models/mod.rs @@ -3,6 +3,7 @@ pub mod material; pub mod material_segment_view; pub mod material_usage; pub mod model; +pub mod model_dynamic; pub mod ai_classification; pub mod video_classification; pub mod template; diff --git a/apps/desktop/src-tauri/src/data/models/model_dynamic.rs b/apps/desktop/src-tauri/src/data/models/model_dynamic.rs new file mode 100644 index 0000000..05e4691 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/model_dynamic.rs @@ -0,0 +1,245 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// 模特动态实体模型 +/// 遵循 Tauri 开发规范的数据模型设计原则 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelDynamic { + pub id: String, + pub model_id: String, + pub title: Option, + pub description: String, + pub prompt: String, + pub source_image_path: String, + pub ai_model: String, // 使用的AI模型,如"极梦" + pub video_count: u32, // 生成视频个数 + pub generated_videos: Vec, + pub status: DynamicStatus, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 生成的视频 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeneratedVideo { + pub id: String, + pub dynamic_id: String, + pub video_path: String, + pub thumbnail_path: Option, + pub file_size: u64, + pub duration: u32, // 视频时长(秒) + pub status: VideoGenerationStatus, + pub generation_progress: Option, // 生成进度 0-100 + pub error_message: Option, + pub created_at: DateTime, +} + +/// 动态状态枚举 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum DynamicStatus { + Draft, // 草稿 + Publishing, // 发布中 + Published, // 已发布 + Failed, // 失败 +} + +/// 视频生成状态枚举 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum VideoGenerationStatus { + Pending, // 等待中 + Generating, // 生成中 + Completed, // 已完成 + Failed, // 失败 +} + +/// 创建动态请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateModelDynamicRequest { + pub model_id: String, + pub title: Option, + pub description: String, + pub prompt: String, + pub source_image_path: String, + pub ai_model: String, + pub video_count: u32, +} + +/// 更新动态请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateModelDynamicRequest { + pub title: Option, + pub description: Option, + pub prompt: Option, + pub status: Option, +} + +/// 模特动态统计 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelDynamicStats { + pub total_dynamics: u32, + pub published_dynamics: u32, + pub total_videos: u32, + pub completed_videos: u32, + pub generating_videos: u32, + pub failed_videos: u32, +} + +/// AI模型选项 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AIModelOption { + pub id: String, + pub name: String, + pub description: String, + pub is_available: bool, + pub max_video_count: u32, +} + +impl ModelDynamic { + /// 创建新的模特动态 + pub fn new( + model_id: String, + description: String, + prompt: String, + source_image_path: String, + ai_model: String, + video_count: u32, + ) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4().to_string(), + model_id, + title: None, + description, + prompt, + source_image_path, + ai_model, + video_count, + generated_videos: Vec::new(), + status: DynamicStatus::Draft, + created_at: now, + updated_at: now, + } + } + + /// 验证动态数据 + pub fn validate(&self) -> Result<(), String> { + if self.model_id.is_empty() { + return Err("模特ID不能为空".to_string()); + } + + if self.description.trim().is_empty() { + return Err("动态描述不能为空".to_string()); + } + + if self.prompt.trim().is_empty() { + return Err("提示词不能为空".to_string()); + } + + if self.source_image_path.trim().is_empty() { + return Err("源图片路径不能为空".to_string()); + } + + if self.ai_model.trim().is_empty() { + return Err("AI模型不能为空".to_string()); + } + + if self.video_count == 0 || self.video_count > 9 { + return Err("视频个数必须在1-9之间".to_string()); + } + + Ok(()) + } + + /// 更新状态 + pub fn update_status(&mut self, status: DynamicStatus) { + self.status = status; + self.updated_at = Utc::now(); + } + + /// 添加生成的视频 + pub fn add_generated_video(&mut self, video: GeneratedVideo) { + self.generated_videos.push(video); + self.updated_at = Utc::now(); + } + + /// 获取已完成的视频数量 + pub fn completed_video_count(&self) -> usize { + self.generated_videos + .iter() + .filter(|v| v.status == VideoGenerationStatus::Completed) + .count() + } + + /// 获取生成中的视频数量 + pub fn generating_video_count(&self) -> usize { + self.generated_videos + .iter() + .filter(|v| v.status == VideoGenerationStatus::Generating) + .count() + } + + /// 获取失败的视频数量 + pub fn failed_video_count(&self) -> usize { + self.generated_videos + .iter() + .filter(|v| v.status == VideoGenerationStatus::Failed) + .count() + } +} + +impl GeneratedVideo { + /// 创建新的生成视频 + pub fn new(dynamic_id: String, video_path: String) -> Self { + Self { + id: Uuid::new_v4().to_string(), + dynamic_id, + video_path, + thumbnail_path: None, + file_size: 0, + duration: 0, + status: VideoGenerationStatus::Pending, + generation_progress: None, + error_message: None, + created_at: Utc::now(), + } + } + + /// 更新生成进度 + pub fn update_progress(&mut self, progress: u32) { + self.generation_progress = Some(progress); + if progress >= 100 { + self.status = VideoGenerationStatus::Completed; + } else { + self.status = VideoGenerationStatus::Generating; + } + } + + /// 标记为失败 + pub fn mark_as_failed(&mut self, error_message: String) { + self.status = VideoGenerationStatus::Failed; + self.error_message = Some(error_message); + self.generation_progress = None; + } + + /// 标记为完成 + pub fn mark_as_completed(&mut self, file_size: u64, duration: u32, thumbnail_path: Option) { + self.status = VideoGenerationStatus::Completed; + self.file_size = file_size; + self.duration = duration; + self.thumbnail_path = thumbnail_path; + self.generation_progress = Some(100); + } +} + +impl Default for DynamicStatus { + fn default() -> Self { + DynamicStatus::Draft + } +} + +impl Default for VideoGenerationStatus { + fn default() -> Self { + VideoGenerationStatus::Pending + } +} diff --git a/apps/desktop/src-tauri/src/data/repositories/mod.rs b/apps/desktop/src-tauri/src/data/repositories/mod.rs index 17d0000..af4a429 100644 --- a/apps/desktop/src-tauri/src/data/repositories/mod.rs +++ b/apps/desktop/src-tauri/src/data/repositories/mod.rs @@ -2,6 +2,7 @@ pub mod project_repository; pub mod material_repository; pub mod material_usage_repository; pub mod model_repository; +pub mod model_dynamic_repository; pub mod ai_classification_repository; pub mod video_classification_repository; pub mod project_template_binding_repository; diff --git a/apps/desktop/src-tauri/src/data/repositories/model_dynamic_repository.rs b/apps/desktop/src-tauri/src/data/repositories/model_dynamic_repository.rs new file mode 100644 index 0000000..9ee7812 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/repositories/model_dynamic_repository.rs @@ -0,0 +1,378 @@ +use std::sync::Arc; +use anyhow::Result; +use rusqlite::{params, Row}; +use serde_json; + +use crate::infrastructure::database::Database; +use crate::data::models::model_dynamic::{ + ModelDynamic, GeneratedVideo, CreateModelDynamicRequest, UpdateModelDynamicRequest, + ModelDynamicStats, DynamicStatus, VideoGenerationStatus +}; + +/// 模特动态数据仓库 +/// 遵循 Tauri 开发规范的数据访问层设计原则 +#[derive(Clone)] +pub struct ModelDynamicRepository { + database: Arc, +} + +impl ModelDynamicRepository { + /// 创建新的模特动态仓库实例 + 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 model_dynamics ( + id TEXT PRIMARY KEY, + model_id TEXT NOT NULL, + title TEXT, + description TEXT NOT NULL, + prompt TEXT NOT NULL, + source_image_path TEXT NOT NULL, + ai_model TEXT NOT NULL, + video_count INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'Draft', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (model_id) REFERENCES models (id) + )", + [], + )?; + + // 创建生成视频表 + conn.execute( + "CREATE TABLE IF NOT EXISTS generated_videos ( + id TEXT PRIMARY KEY, + dynamic_id TEXT NOT NULL, + video_path TEXT NOT NULL, + thumbnail_path TEXT, + file_size INTEGER NOT NULL DEFAULT 0, + duration INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'Pending', + generation_progress INTEGER, + error_message TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (dynamic_id) REFERENCES model_dynamics (id) ON DELETE CASCADE + )", + [], + )?; + + // 创建索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_model_dynamics_model_id ON model_dynamics (model_id)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_model_dynamics_status ON model_dynamics (status)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_generated_videos_dynamic_id ON generated_videos (dynamic_id)", + [], + )?; + + Ok(()) + } + + /// 创建模特动态 + pub fn create(&self, request: CreateModelDynamicRequest) -> Result { + let mut dynamic = ModelDynamic::new( + request.model_id, + request.description, + request.prompt, + request.source_image_path, + request.ai_model, + request.video_count, + ); + + if let Some(title) = request.title { + dynamic.title = Some(title); + } + + dynamic.validate().map_err(|e| anyhow::anyhow!(e))?; + + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + conn.execute( + "INSERT INTO model_dynamics ( + id, model_id, title, description, prompt, source_image_path, + ai_model, video_count, status, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + dynamic.id, + dynamic.model_id, + dynamic.title, + dynamic.description, + dynamic.prompt, + dynamic.source_image_path, + dynamic.ai_model, + dynamic.video_count, + serde_json::to_string(&dynamic.status)?, + dynamic.created_at.to_rfc3339(), + dynamic.updated_at.to_rfc3339(), + ], + )?; + + Ok(dynamic) + } + + /// 根据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, title, description, prompt, source_image_path, + ai_model, video_count, status, created_at, updated_at + FROM model_dynamics WHERE id = ?1" + )?; + + let dynamic_iter = stmt.query_map([id], |row| { + self.row_to_dynamic(row) + })?; + + for dynamic in dynamic_iter { + let mut dynamic = dynamic?; + // 加载生成的视频 + dynamic.generated_videos = self.get_videos_by_dynamic_id(&dynamic.id)?; + return Ok(Some(dynamic)); + } + + Ok(None) + } + + /// 根据模特ID获取动态列表 + pub fn get_by_model_id(&self, model_id: &str) -> Result> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT id, model_id, title, description, prompt, source_image_path, + ai_model, video_count, status, created_at, updated_at + FROM model_dynamics WHERE model_id = ?1 ORDER BY created_at DESC" + )?; + + let dynamic_iter = stmt.query_map([model_id], |row| { + self.row_to_dynamic(row) + })?; + + let mut dynamics = Vec::new(); + for dynamic in dynamic_iter { + let mut dynamic = dynamic?; + // 加载生成的视频 + dynamic.generated_videos = self.get_videos_by_dynamic_id(&dynamic.id)?; + dynamics.push(dynamic); + } + + Ok(dynamics) + } + + /// 更新动态 + pub fn update(&self, id: &str, request: UpdateModelDynamicRequest) -> Result { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + // 构建更新语句 + let mut updates = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(title) = &request.title { + updates.push("title = ?"); + params.push(Box::new(title.clone())); + } + + if let Some(description) = &request.description { + updates.push("description = ?"); + params.push(Box::new(description.clone())); + } + + if let Some(prompt) = &request.prompt { + updates.push("prompt = ?"); + params.push(Box::new(prompt.clone())); + } + + if let Some(status) = &request.status { + updates.push("status = ?"); + params.push(Box::new(serde_json::to_string(status)?)); + } + + if updates.is_empty() { + return Err(anyhow::anyhow!("没有要更新的字段")); + } + + updates.push("updated_at = ?"); + params.push(Box::new(chrono::Utc::now().to_rfc3339())); + params.push(Box::new(id.to_string())); + + let sql = format!( + "UPDATE model_dynamics SET {} WHERE id = ?", + updates.join(", ") + ); + + let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + conn.execute(&sql, ¶ms_refs[..])?; + + // 返回更新后的动态 + self.get_by_id(id)?.ok_or_else(|| anyhow::anyhow!("动态不存在")) + } + + /// 删除动态 + pub fn delete(&self, id: &str) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + // 删除动态(级联删除视频) + conn.execute("DELETE FROM model_dynamics WHERE id = ?1", [id])?; + + Ok(()) + } + + /// 获取模特动态统计 + pub fn get_stats_by_model_id(&self, model_id: &str) -> Result { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + // 获取动态统计 + let mut stmt = conn.prepare( + "SELECT + COUNT(*) as total_dynamics, + SUM(CASE WHEN status = '\"Published\"' THEN 1 ELSE 0 END) as published_dynamics + FROM model_dynamics WHERE model_id = ?1" + )?; + + let (total_dynamics, published_dynamics) = stmt.query_row([model_id], |row| { + Ok((row.get::<_, u32>(0)?, row.get::<_, u32>(1)?)) + })?; + + // 获取视频统计 + let mut stmt = conn.prepare( + "SELECT + COUNT(*) as total_videos, + SUM(CASE WHEN status = '\"Completed\"' THEN 1 ELSE 0 END) as completed_videos, + SUM(CASE WHEN status = '\"Generating\"' THEN 1 ELSE 0 END) as generating_videos, + SUM(CASE WHEN status = '\"Failed\"' THEN 1 ELSE 0 END) as failed_videos + FROM generated_videos + WHERE dynamic_id IN (SELECT id FROM model_dynamics WHERE model_id = ?1)" + )?; + + let (total_videos, completed_videos, generating_videos, failed_videos) = + stmt.query_row([model_id], |row| { + Ok(( + row.get::<_, u32>(0)?, + row.get::<_, u32>(1)?, + row.get::<_, u32>(2)?, + row.get::<_, u32>(3)? + )) + })?; + + Ok(ModelDynamicStats { + total_dynamics, + published_dynamics, + total_videos, + completed_videos, + generating_videos, + failed_videos, + }) + } + + /// 获取动态的生成视频列表 + fn get_videos_by_dynamic_id(&self, dynamic_id: &str) -> Result> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT id, dynamic_id, video_path, thumbnail_path, file_size, duration, + status, generation_progress, error_message, created_at + FROM generated_videos WHERE dynamic_id = ?1 ORDER BY created_at ASC" + )?; + + let video_iter = stmt.query_map([dynamic_id], |row| { + self.row_to_video(row) + })?; + + let mut videos = Vec::new(); + for video in video_iter { + videos.push(video?); + } + + Ok(videos) + } + + /// 将数据库行转换为动态对象 + fn row_to_dynamic(&self, row: &Row) -> rusqlite::Result { + let status_str: String = row.get("status")?; + let status: DynamicStatus = serde_json::from_str(&status_str) + .map_err(|e| rusqlite::Error::InvalidColumnType( + row.as_ref().column_index("status").unwrap(), + format!("Invalid status: {}", e).into(), + rusqlite::types::Type::Text + ))?; + + Ok(ModelDynamic { + id: row.get("id")?, + model_id: row.get("model_id")?, + title: row.get("title")?, + description: row.get("description")?, + prompt: row.get("prompt")?, + source_image_path: row.get("source_image_path")?, + ai_model: row.get("ai_model")?, + video_count: row.get("video_count")?, + generated_videos: Vec::new(), // 将在外部加载 + status, + created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>("created_at")?) + .map_err(|e| rusqlite::Error::InvalidColumnType( + row.as_ref().column_index("created_at").unwrap(), + format!("Invalid datetime: {}", e).into(), + rusqlite::types::Type::Text + ))? + .with_timezone(&chrono::Utc), + updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>("updated_at")?) + .map_err(|e| rusqlite::Error::InvalidColumnType( + row.as_ref().column_index("updated_at").unwrap(), + format!("Invalid datetime: {}", e).into(), + rusqlite::types::Type::Text + ))? + .with_timezone(&chrono::Utc), + }) + } + + /// 将数据库行转换为视频对象 + fn row_to_video(&self, row: &Row) -> rusqlite::Result { + let status_str: String = row.get("status")?; + let status: VideoGenerationStatus = serde_json::from_str(&status_str) + .map_err(|e| rusqlite::Error::InvalidColumnType( + row.as_ref().column_index("status").unwrap(), + format!("Invalid status: {}", e).into(), + rusqlite::types::Type::Text + ))?; + + Ok(GeneratedVideo { + id: row.get("id")?, + dynamic_id: row.get("dynamic_id")?, + video_path: row.get("video_path")?, + thumbnail_path: row.get("thumbnail_path")?, + file_size: row.get("file_size")?, + duration: row.get("duration")?, + status, + generation_progress: row.get("generation_progress")?, + error_message: row.get("error_message")?, + created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>("created_at")?) + .map_err(|e| rusqlite::Error::InvalidColumnType( + row.as_ref().column_index("created_at").unwrap(), + format!("Invalid datetime: {}", e).into(), + rusqlite::types::Type::Text + ))? + .with_timezone(&chrono::Utc), + }) + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 568cf5a..3f740b1 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -112,6 +112,17 @@ pub fn run() { commands::model_commands::get_model_statistics, commands::model_commands::select_photo_files, commands::model_commands::select_photo_file, + // 模特动态管理命令 + commands::model_dynamic_commands::create_model_dynamic, + commands::model_dynamic_commands::get_model_dynamic_by_id, + commands::model_dynamic_commands::get_model_dynamics_by_model_id, + commands::model_dynamic_commands::update_model_dynamic, + commands::model_dynamic_commands::delete_model_dynamic, + commands::model_dynamic_commands::get_model_dynamic_stats, + commands::model_dynamic_commands::regenerate_dynamic_video, + commands::model_dynamic_commands::get_available_ai_models, + commands::model_dynamic_commands::update_video_generation_progress, + commands::model_dynamic_commands::mark_video_generation_failed, // AI分类管理命令 commands::ai_classification_commands::create_ai_classification, commands::ai_classification_commands::get_all_ai_classifications, diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index f5ab7e8..4ae2e45 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -5,6 +5,7 @@ pub mod material_usage_commands; pub mod database_commands; pub mod material_segment_view_commands; pub mod model_commands; +pub mod model_dynamic_commands; pub mod ai_classification_commands; pub mod video_classification_commands; pub mod ai_analysis_log_commands; diff --git a/apps/desktop/src-tauri/src/presentation/commands/model_dynamic_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/model_dynamic_commands.rs new file mode 100644 index 0000000..e73a037 --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/model_dynamic_commands.rs @@ -0,0 +1,170 @@ +use tauri::{command, State}; +use crate::app_state::AppState; +use crate::business::services::model_dynamic_service::ModelDynamicService; +use crate::data::models::model_dynamic::{ + ModelDynamic, CreateModelDynamicRequest, UpdateModelDynamicRequest, ModelDynamicStats +}; + +/// 创建模特动态命令 +/// 遵循 Tauri 开发规范的命令设计模式 +#[command] +pub async fn create_model_dynamic( + state: State<'_, AppState>, + request: CreateModelDynamicRequest, +) -> Result { + let repository_guard = state.get_model_dynamic_repository() + .map_err(|e| format!("获取模特动态仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("模特动态仓库未初始化")?; + + ModelDynamicService::create_dynamic(repository, request) + .map_err(|e| e.to_string()) +} + +/// 获取模特动态详情命令 +#[command] +pub async fn get_model_dynamic_by_id( + state: State<'_, AppState>, + id: String, +) -> Result, String> { + let repository_guard = state.get_model_dynamic_repository() + .map_err(|e| format!("获取模特动态仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("模特动态仓库未初始化")?; + + ModelDynamicService::get_dynamic_by_id(repository, &id) + .map_err(|e| e.to_string()) +} + +/// 获取模特的所有动态命令 +#[command] +pub async fn get_model_dynamics_by_model_id( + state: State<'_, AppState>, + model_id: String, +) -> Result, String> { + let repository_guard = state.get_model_dynamic_repository() + .map_err(|e| format!("获取模特动态仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("模特动态仓库未初始化")?; + + ModelDynamicService::get_dynamics_by_model_id(repository, &model_id) + .map_err(|e| e.to_string()) +} + +/// 更新模特动态命令 +#[command] +pub async fn update_model_dynamic( + state: State<'_, AppState>, + id: String, + request: UpdateModelDynamicRequest, +) -> Result { + let repository_guard = state.get_model_dynamic_repository() + .map_err(|e| format!("获取模特动态仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("模特动态仓库未初始化")?; + + ModelDynamicService::update_dynamic(repository, &id, request) + .map_err(|e| e.to_string()) +} + +/// 删除模特动态命令 +#[command] +pub async fn delete_model_dynamic( + state: State<'_, AppState>, + id: String, +) -> Result<(), String> { + let repository_guard = state.get_model_dynamic_repository() + .map_err(|e| format!("获取模特动态仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("模特动态仓库未初始化")?; + + ModelDynamicService::delete_dynamic(repository, &id) + .map_err(|e| e.to_string()) +} + +/// 获取模特动态统计命令 +#[command] +pub async fn get_model_dynamic_stats( + state: State<'_, AppState>, + model_id: String, +) -> Result { + let repository_guard = state.get_model_dynamic_repository() + .map_err(|e| format!("获取模特动态仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("模特动态仓库未初始化")?; + + ModelDynamicService::get_stats_by_model_id(repository, &model_id) + .map_err(|e| e.to_string()) +} + +/// 重新生成视频命令 +#[command] +pub async fn regenerate_dynamic_video( + state: State<'_, AppState>, + dynamic_id: String, + video_id: String, +) -> Result<(), String> { + // TODO: 实现视频重新生成逻辑 + // 这里可以调用视频生成服务来重新生成指定的视频 + println!("重新生成视频: dynamic_id={}, video_id={}", dynamic_id, video_id); + Ok(()) +} + +/// 获取可用的AI模型列表命令 +#[command] +pub async fn get_available_ai_models() -> Result, String> { + // 返回可用的AI模型列表 + let models = vec![ + serde_json::json!({ + "id": "jimeng", + "name": "极梦", + "description": "高质量视频生成模型", + "is_available": true, + "max_video_count": 9 + }) + ]; + + Ok(models) +} + +/// 更新视频生成进度命令 +#[command] +pub async fn update_video_generation_progress( + state: State<'_, AppState>, + dynamic_id: String, + video_id: String, + progress: u32, +) -> Result<(), String> { + let repository_guard = state.get_model_dynamic_repository() + .map_err(|e| format!("获取模特动态仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("模特动态仓库未初始化")?; + + ModelDynamicService::update_video_progress(repository, &dynamic_id, &video_id, progress) + .map_err(|e| e.to_string()) +} + +/// 标记视频生成失败命令 +#[command] +pub async fn mark_video_generation_failed( + state: State<'_, AppState>, + dynamic_id: String, + video_id: String, + error_message: String, +) -> Result<(), String> { + let repository_guard = state.get_model_dynamic_repository() + .map_err(|e| format!("获取模特动态仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("模特动态仓库未初始化")?; + + ModelDynamicService::mark_video_as_failed(repository, &dynamic_id, &video_id, error_message) + .map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 6a1a5af..622f9f2 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -5,6 +5,7 @@ import { ProjectForm } from './components/ProjectForm'; import { ProjectDetails } from './pages/ProjectDetails'; import Models from './pages/Models'; import ModelDetail from './pages/ModelDetail'; +import ModelDynamics from './pages/ModelDynamics'; import AiClassificationSettings from './pages/AiClassificationSettings'; import TemplateManagement from './pages/TemplateManagement'; import { MaterialModelBinding } from './pages/MaterialModelBinding'; @@ -77,6 +78,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/desktop/src/components/CreateDynamicModal.tsx b/apps/desktop/src/components/CreateDynamicModal.tsx new file mode 100644 index 0000000..a0a03ef --- /dev/null +++ b/apps/desktop/src/components/CreateDynamicModal.tsx @@ -0,0 +1,265 @@ +import React, { useState } from 'react'; +import { Model, CreateDynamicRequest, AIModelOption } from '../types/model'; +import { + XMarkIcon, + PhotoIcon, + SparklesIcon, + CpuChipIcon, + VideoCameraIcon +} from '@heroicons/react/24/outline'; + +interface CreateDynamicModalProps { + model: Model; + onSubmit: (data: CreateDynamicRequest) => void; + onCancel: () => void; +} + +const CreateDynamicModal: React.FC = ({ + model, + onSubmit, + onCancel +}) => { + const [formData, setFormData] = useState({ + prompt: '', + source_image_path: '', + ai_model: '极梦', + video_count: 1 + }); + const [errors, setErrors] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + + // 可用的AI模型选项 + const aiModelOptions: AIModelOption[] = [ + { + id: 'jimeng', + name: '极梦', + description: '高质量视频生成模型', + is_available: true, + max_video_count: 9 + } + ]; + + const handleInputChange = (field: string, value: any) => { + setFormData(prev => ({ ...prev, [field]: value })); + // 清除对应字段的错误 + if (errors[field]) { + setErrors(prev => ({ ...prev, [field]: '' })); + } + }; + + const handleImageSelect = async () => { + try { + // TODO: 实现图片选择功能 + // const selectedPath = await systemService.selectImageFile(); + // if (selectedPath) { + // handleInputChange('source_image_path', selectedPath); + // } + console.log('选择图片功能待实现'); + } catch (error) { + console.error('选择图片失败:', error); + } + }; + + const validateForm = () => { + const newErrors: Record = {}; + + if (!formData.prompt.trim()) { + newErrors.prompt = '请输入提示词'; + } + + if (!formData.source_image_path) { + newErrors.source_image_path = '请选择源图片'; + } + + if (formData.video_count < 1 || formData.video_count > 9) { + newErrors.video_count = '视频个数必须在1-9之间'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + try { + const submitData: CreateDynamicRequest = { + model_id: model.id, + title: undefined, + description: '模特动态', // 使用默认描述 + prompt: formData.prompt.trim(), + source_image_path: formData.source_image_path, + ai_model: formData.ai_model, + video_count: formData.video_count + }; + + onSubmit(submitData); + } catch (error) { + console.error('提交失败:', error); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ {/* 头部 */} +
+
+
+ +
+
+

生成视频

+

为 {model.name} 生成AI视频

+
+
+ +
+ + {/* 表单内容 */} +
+ + {/* 提示词 */} +
+ +