完成模特动态页面开发,移除标题和描述字段

This commit is contained in:
imeepos
2025-07-18 16:49:08 +08:00
parent b7954497b0
commit da4aeaccb9
18 changed files with 2221 additions and 15 deletions

View File

@@ -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<Option<ProjectRepository>>,
pub material_repository: Mutex<Option<MaterialRepository>>,
pub model_repository: Mutex<Option<ModelRepository>>,
pub model_dynamic_repository: Mutex<Option<ModelDynamicRepository>>,
pub video_generation_repository: Mutex<Option<VideoGenerationRepository>>,
pub performance_monitor: Mutex<PerformanceMonitor>,
pub event_bus_manager: Arc<EventBusManager>,
@@ -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<std::sync::MutexGuard<Option<ModelDynamicRepository>>> {
Ok(self.model_dynamic_repository.lock().unwrap())
}
/// 获取视频生成仓库实例
pub fn get_video_generation_repository(&self) -> anyhow::Result<std::sync::MutexGuard<Option<VideoGenerationRepository>>> {
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()),

View File

@@ -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;

View File

@@ -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<ModelDynamic> {
// 验证请求数据
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<Option<ModelDynamic>> {
repository.get_by_id(id)
.map_err(|e| anyhow!("获取模特动态详情失败: {}", e))
}
/// 获取模特的所有动态
pub fn get_dynamics_by_model_id(
repository: &ModelDynamicRepository,
model_id: &str,
) -> Result<Vec<ModelDynamic>> {
repository.get_by_model_id(model_id)
.map_err(|e| anyhow!("获取模特动态列表失败: {}", e))
}
/// 更新模特动态
pub fn update_dynamic(
repository: &ModelDynamicRepository,
id: &str,
request: UpdateModelDynamicRequest,
) -> Result<ModelDynamic> {
// 检查动态是否存在
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<ModelDynamicStats> {
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<GeneratedVideo> {
// 检查动态是否存在
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()),
}
}
}

View File

@@ -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;

View File

@@ -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<String>,
pub description: String,
pub prompt: String,
pub source_image_path: String,
pub ai_model: String, // 使用的AI模型如"极梦"
pub video_count: u32, // 生成视频个数
pub generated_videos: Vec<GeneratedVideo>,
pub status: DynamicStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// 生成的视频
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneratedVideo {
pub id: String,
pub dynamic_id: String,
pub video_path: String,
pub thumbnail_path: Option<String>,
pub file_size: u64,
pub duration: u32, // 视频时长(秒)
pub status: VideoGenerationStatus,
pub generation_progress: Option<u32>, // 生成进度 0-100
pub error_message: Option<String>,
pub created_at: DateTime<Utc>,
}
/// 动态状态枚举
#[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<String>,
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<String>,
pub description: Option<String>,
pub prompt: Option<String>,
pub status: Option<DynamicStatus>,
}
/// 模特动态统计
#[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<String>) {
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
}
}

View File

@@ -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;

View File

@@ -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<Database>,
}
impl ModelDynamicRepository {
/// 创建新的模特动态仓库实例
pub fn new(database: Arc<Database>) -> Self {
Self { database }
}
/// 初始化数据库表
pub fn init_tables(&self) -> Result<()> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
// 创建模特动态表
conn.execute(
"CREATE TABLE IF NOT EXISTS 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<ModelDynamic> {
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<Option<ModelDynamic>> {
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<Vec<ModelDynamic>> {
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<ModelDynamic> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
// 构建更新语句
let mut updates = Vec::new();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = 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, &params_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<ModelDynamicStats> {
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<Vec<GeneratedVideo>> {
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<ModelDynamic> {
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<GeneratedVideo> {
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),
})
}
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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<ModelDynamic, String> {
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<Option<ModelDynamic>, 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<Vec<ModelDynamic>, 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<ModelDynamic, String> {
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<ModelDynamicStats, String> {
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<Vec<serde_json::Value>, 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())
}

View File

@@ -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() {
<Route path="/project/:id" element={<ProjectDetails />} />
<Route path="/models" element={<Models />} />
<Route path="/models/:id" element={<ModelDetail />} />
<Route path="/models/:modelId/dynamics" element={<ModelDynamics />} />
<Route path="/ai-classification-settings" element={<AiClassificationSettings />} />
<Route path="/templates" element={<TemplateManagement />} />
<Route path="/material-model-binding" element={<MaterialModelBinding />} />

View File

@@ -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<CreateDynamicModalProps> = ({
model,
onSubmit,
onCancel
}) => {
const [formData, setFormData] = useState({
prompt: '',
source_image_path: '',
ai_model: '极梦',
video_count: 1
});
const [errors, setErrors] = useState<Record<string, string>>({});
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<string, string> = {};
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 (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden animate-scale-in">
{/* 头部 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center">
<SparklesIcon className="h-5 w-5 text-white" />
</div>
<div>
<h2 className="text-xl font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-500"> {model.name} AI视频</p>
</div>
</div>
<button
onClick={onCancel}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<XMarkIcon className="h-5 w-5 text-gray-500" />
</button>
</div>
{/* 表单内容 */}
<form onSubmit={handleSubmit} className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-140px)]">
{/* 提示词 */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
<SparklesIcon className="h-4 w-4" />
AI提示词 *
</label>
<textarea
value={formData.prompt}
onChange={(e) => handleInputChange('prompt', e.target.value)}
placeholder="描述您希望生成的视频内容..."
rows={4}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none ${
errors.prompt ? 'border-red-300' : 'border-gray-300'
}`}
/>
{errors.prompt && (
<p className="mt-1 text-sm text-red-600">{errors.prompt}</p>
)}
</div>
{/* 源图片 */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
<PhotoIcon className="h-4 w-4" />
*
</label>
<div className="space-y-3">
<button
type="button"
onClick={handleImageSelect}
className={`w-full p-4 border-2 border-dashed rounded-lg transition-colors ${
errors.source_image_path
? 'border-red-300 bg-red-50'
: 'border-gray-300 hover:border-primary-400 hover:bg-primary-50'
}`}
>
<div className="flex flex-col items-center gap-2">
<PhotoIcon className="h-8 w-8 text-gray-400" />
<span className="text-sm text-gray-600"></span>
</div>
</button>
{formData.source_image_path && (
<div className="relative">
<img
src={formData.source_image_path}
alt="源图片预览"
className="w-full h-48 object-contain bg-gray-50 rounded-lg"
/>
<button
type="button"
onClick={() => handleInputChange('source_image_path', '')}
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
)}
{errors.source_image_path && (
<p className="text-sm text-red-600">{errors.source_image_path}</p>
)}
</div>
</div>
{/* AI模型选择 */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
<CpuChipIcon className="h-4 w-4" />
AI模型
</label>
<select
value={formData.ai_model}
onChange={(e) => handleInputChange('ai_model', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors"
>
{aiModelOptions.map((option) => (
<option key={option.id} value={option.name} disabled={!option.is_available}>
{option.name} - {option.description}
</option>
))}
</select>
</div>
{/* 视频个数 */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
<VideoCameraIcon className="h-4 w-4" />
</label>
<div className="flex items-center gap-4">
<input
type="range"
min="1"
max="9"
value={formData.video_count}
onChange={(e) => handleInputChange('video_count', parseInt(e.target.value))}
className="flex-1"
/>
<span className="text-sm font-medium text-gray-700 min-w-[2rem]">
{formData.video_count}
</span>
</div>
{errors.video_count && (
<p className="mt-1 text-sm text-red-600">{errors.video_count}</p>
)}
</div>
</form>
{/* 底部按钮 */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
</button>
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="px-6 py-2 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-lg hover:from-primary-600 hover:to-primary-700 transition-all duration-200 shadow-sm hover:shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? '生成中...' : '开始生成'}
</button>
</div>
</div>
</div>
);
};
export default CreateDynamicModal;

View File

@@ -0,0 +1,190 @@
import React from 'react';
import { Model, ModelDynamicStats, Gender } from '../types/model';
import {
UserIcon,
SparklesIcon,
VideoCameraIcon,
ClockIcon,
CheckCircleIcon,
ExclamationCircleIcon,
PlusIcon
} from '@heroicons/react/24/outline';
interface ModelDynamicHeaderProps {
model: Model;
stats: ModelDynamicStats | null;
onCreateDynamic: () => void;
}
const ModelDynamicHeader: React.FC<ModelDynamicHeaderProps> = ({
model,
stats,
onCreateDynamic
}) => {
const getGenderIcon = (gender: Gender) => {
switch (gender) {
case Gender.Male:
return '♂️';
case Gender.Female:
return '♀️';
default:
return '⚧️';
}
};
const getGenderColor = (gender: Gender) => {
switch (gender) {
case Gender.Male:
return 'text-blue-600 bg-blue-100';
case Gender.Female:
return 'text-pink-600 bg-pink-100';
default:
return 'text-purple-600 bg-purple-100';
}
};
return (
<div className="bg-gradient-to-r from-white via-primary-50/30 to-white rounded-xl shadow-sm border border-gray-200/50 p-6 relative overflow-hidden">
{/* 装饰性背景 */}
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-primary-100/30 to-primary-200/30 rounded-full -translate-y-16 translate-x-16 opacity-50"></div>
<div className="absolute bottom-0 left-0 w-24 h-24 bg-gradient-to-tr from-primary-100/20 to-primary-200/20 rounded-full translate-y-12 -translate-x-12 opacity-30"></div>
<div className="relative z-10">
{/* 主要信息区域 */}
<div className="flex flex-col lg:flex-row items-start lg:items-center gap-6 mb-6">
{/* 头像和基本信息 */}
<div className="flex items-center gap-4">
<div className="relative">
{model.avatar_path ? (
<img
src={model.avatar_path}
alt={model.name}
className="w-20 h-20 rounded-full object-cover border-4 border-white shadow-lg"
/>
) : (
<div className="w-20 h-20 bg-gradient-to-br from-gray-100 to-gray-200 rounded-full flex items-center justify-center border-4 border-white shadow-lg">
<UserIcon className="h-8 w-8 text-gray-400" />
</div>
)}
{/* 性别标识 */}
<div className={`absolute -bottom-1 -right-1 w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border-2 border-white shadow-sm ${getGenderColor(model.gender)}`}>
{getGenderIcon(model.gender)}
</div>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-gray-900">{model.name}</h1>
{model.stage_name && (
<span className="text-lg text-gray-600">({model.stage_name})</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-600">
{model.age && (
<span>{model.age}</span>
)}
{model.height && (
<span>{model.height}cm</span>
)}
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getGenderColor(model.gender)}`}>
{model.gender === Gender.Male ? '男' : model.gender === Gender.Female ? '女' : '其他'}
</span>
</div>
{model.description && (
<p className="text-gray-700 max-w-md">{model.description}</p>
)}
{/* 标签 */}
{model.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{model.tags.slice(0, 5).map((tag, index) => (
<span
key={index}
className="px-2 py-1 bg-primary-100 text-primary-700 rounded-full text-xs font-medium"
>
#{tag}
</span>
))}
{model.tags.length > 5 && (
<span className="px-2 py-1 bg-gray-100 text-gray-600 rounded-full text-xs font-medium">
+{model.tags.length - 5}
</span>
)}
</div>
)}
</div>
</div>
{/* 发布动态按钮 */}
<div className="lg:ml-auto">
<button
onClick={onCreateDynamic}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-xl hover:from-primary-600 hover:to-primary-700 transition-all duration-200 shadow-sm hover:shadow-md font-medium hover:scale-105 active:scale-95"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
{/* 统计信息 */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div className="bg-white/80 backdrop-blur-sm rounded-lg p-4 border border-gray-200/50 hover:bg-white/90 transition-all duration-200 hover:scale-105">
<div className="flex items-center gap-2 mb-1">
<SparklesIcon className="h-4 w-4 text-primary-600" />
<span className="text-xs font-medium text-gray-600"></span>
</div>
<div className="text-xl font-bold text-gray-900">{stats.total_dynamics}</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-lg p-4 border border-gray-200/50 hover:bg-white/90 transition-all duration-200 hover:scale-105">
<div className="flex items-center gap-2 mb-1">
<CheckCircleIcon className="h-4 w-4 text-green-600" />
<span className="text-xs font-medium text-gray-600"></span>
</div>
<div className="text-xl font-bold text-gray-900">{stats.published_dynamics}</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-lg p-4 border border-gray-200/50 hover:bg-white/90 transition-all duration-200 hover:scale-105">
<div className="flex items-center gap-2 mb-1">
<VideoCameraIcon className="h-4 w-4 text-blue-600" />
<span className="text-xs font-medium text-gray-600"></span>
</div>
<div className="text-xl font-bold text-gray-900">{stats.total_videos}</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-lg p-4 border border-gray-200/50 hover:bg-white/90 transition-all duration-200 hover:scale-105">
<div className="flex items-center gap-2 mb-1">
<CheckCircleIcon className="h-4 w-4 text-green-600" />
<span className="text-xs font-medium text-gray-600"></span>
</div>
<div className="text-xl font-bold text-gray-900">{stats.completed_videos}</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-lg p-4 border border-gray-200/50 hover:bg-white/90 transition-all duration-200">
<div className="flex items-center gap-2 mb-1">
<ClockIcon className={`h-4 w-4 text-yellow-600 ${stats.generating_videos > 0 ? 'animate-pulse' : ''}`} />
<span className="text-xs font-medium text-gray-600"></span>
</div>
<div className="text-xl font-bold text-gray-900">{stats.generating_videos}</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-lg p-4 border border-gray-200/50 hover:bg-white/90 transition-all duration-200 hover:scale-105">
<div className="flex items-center gap-2 mb-1">
<ExclamationCircleIcon className="h-4 w-4 text-red-600" />
<span className="text-xs font-medium text-gray-600"></span>
</div>
<div className="text-xl font-bold text-gray-900">{stats.failed_videos}</div>
</div>
</div>
)}
</div>
</div>
);
};
export default ModelDynamicHeader;

View File

@@ -0,0 +1,186 @@
import React from 'react';
import { ModelDynamic, VideoGenerationStatus } from '../types/model';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import {
ClockIcon,
VideoCameraIcon,
SparklesIcon,
ExclamationCircleIcon,
ArrowPathIcon
} from '@heroicons/react/24/outline';
interface ModelDynamicListProps {
dynamics: ModelDynamic[];
onRefresh: () => void;
}
const ModelDynamicList: React.FC<ModelDynamicListProps> = ({ dynamics, onRefresh }) => {
const formatDate = (dateString: string) => {
try {
return formatDistanceToNow(new Date(dateString), {
addSuffix: true,
locale: zhCN
});
} catch (error) {
return '未知时间';
}
};
const getVideoStatusIcon = (status: VideoGenerationStatus) => {
switch (status) {
case VideoGenerationStatus.Pending:
return <ClockIcon className="h-4 w-4 text-gray-500" />;
case VideoGenerationStatus.Generating:
return <SparklesIcon className="h-4 w-4 text-yellow-500 animate-pulse" />;
case VideoGenerationStatus.Completed:
return <VideoCameraIcon className="h-4 w-4 text-green-500" />;
case VideoGenerationStatus.Failed:
return <ExclamationCircleIcon className="h-4 w-4 text-red-500" />;
}
};
if (dynamics.length === 0) {
return (
<div className="text-center py-16">
<div className="max-w-md mx-auto">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<SparklesIcon className="h-8 w-8 text-gray-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2"></h3>
<p className="text-gray-600 mb-6">
"生成视频"AI视频
</p>
</div>
</div>
);
}
return (
<div className="space-y-8">
<div className="flex justify-end">
<button
onClick={onRefresh}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
>
<ArrowPathIcon className="h-4 w-4" />
</button>
</div>
{dynamics.map((dynamic) => (
<div
key={dynamic.id}
className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden animate-fade-in hover:shadow-md transition-all duration-200 hover:border-gray-300"
>
{/* 动态头部 */}
<div className="p-4 border-b border-gray-100">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<SparklesIcon className="h-5 w-5 text-primary-600" />
<span className="text-sm font-medium text-gray-900">
{dynamic.title || '动态'}
</span>
</div>
<span className="text-xs text-gray-500">
{formatDate(dynamic.created_at)}
</span>
</div>
</div>
{/* 动态内容 */}
<div className="p-4">
{/* 描述 */}
<p className="text-gray-700 mb-4">{dynamic.description}</p>
{/* 提示词 */}
<div className="bg-gray-50 rounded-lg p-3 mb-4 border border-gray-200">
<div className="text-xs font-medium text-gray-500 mb-1"></div>
<p className="text-sm text-gray-800">{dynamic.prompt}</p>
</div>
{/* 源图片 */}
<div className="mb-4">
<div className="text-xs font-medium text-gray-500 mb-2"></div>
<div className="relative aspect-video rounded-lg overflow-hidden bg-gray-100">
<img
src={dynamic.source_image_path}
alt="源图片"
className="w-full h-full object-contain"
/>
</div>
</div>
{/* 生成视频统计 */}
<div className="flex items-center gap-2 mb-3">
<VideoCameraIcon className="h-4 w-4 text-gray-600" />
<span className="text-sm text-gray-700">
{dynamic.generated_videos.filter(v => v.status === VideoGenerationStatus.Completed).length}/{dynamic.video_count}
</span>
</div>
{/* 视频网格 */}
{dynamic.generated_videos.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{dynamic.generated_videos.map((video) => (
<div
key={video.id}
className="relative aspect-video bg-gray-100 rounded-lg overflow-hidden border border-gray-200 group"
>
{/* 视频缩略图 */}
{video.thumbnail_path ? (
<img
src={video.thumbnail_path}
alt="视频缩略图"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<VideoCameraIcon className="h-8 w-8 text-gray-400" />
</div>
)}
{/* 状态指示器 */}
<div className="absolute top-2 right-2">
{getVideoStatusIcon(video.status)}
</div>
{/* 进度指示器 */}
{video.status === VideoGenerationStatus.Generating && video.generation_progress !== undefined && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gray-200">
<div
className="h-full bg-yellow-500"
style={{ width: `${video.generation_progress}%` }}
></div>
</div>
)}
{/* 悬停覆盖层 */}
{video.status === VideoGenerationStatus.Completed && (
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button className="text-white text-xs font-medium px-2 py-1 bg-white/20 rounded-full backdrop-blur-sm">
</button>
</div>
)}
{/* 错误信息 */}
{video.status === VideoGenerationStatus.Failed && video.error_message && (
<div className="absolute inset-0 bg-red-500/10 flex items-center justify-center">
<div className="text-xs text-red-600 text-center px-2">
{video.error_message}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
))}
</div>
);
};
export default ModelDynamicList;

View File

@@ -7,7 +7,8 @@ import {
PlayIcon,
TrashIcon,
EyeIcon,
ArrowPathIcon
ArrowPathIcon,
SparklesIcon
} from '@heroicons/react/24/outline';
import { Model, PhotoType } from '../types/model';
import {
@@ -346,6 +347,15 @@ const ModelDetail: React.FC = () => {
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => navigate(`/models/${id}/dynamics`)}
className="group flex items-center px-4 py-2 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-lg hover:from-primary-600 hover:to-primary-700 shadow-sm hover:shadow-md transition-all duration-200 text-sm font-medium"
>
<SparklesIcon className="w-4 h-4 mr-2 group-hover:rotate-12 transition-transform duration-200" />
</button>
<button
onClick={handleUploadPhotos}
disabled={uploadingPhotos}
@@ -360,6 +370,7 @@ const ModelDetail: React.FC = () => {
</button>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 左侧:模特信息和照片 */}

View File

@@ -0,0 +1,201 @@
import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Model, ModelDynamic, ModelDynamicStats } from '../types/model';
import { modelService } from '../services/modelService';
import { modelDynamicService } from '../services/modelDynamicService';
import ModelDynamicHeader from '../components/ModelDynamicHeader';
import ModelDynamicList from '../components/ModelDynamicList';
import CreateDynamicModal from '../components/CreateDynamicModal';
import {
PlusIcon,
SparklesIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline';
const ModelDynamics: React.FC = () => {
const { modelId } = useParams<{ modelId: string }>();
const [model, setModel] = useState<Model | null>(null);
const [dynamics, setDynamics] = useState<ModelDynamic[]>([]);
const [stats, setStats] = useState<ModelDynamicStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
useEffect(() => {
if (modelId) {
loadModelData();
loadDynamics();
loadStats();
}
}, [modelId]);
const loadModelData = async () => {
if (!modelId) return;
try {
const modelData = await modelService.getModelById(modelId);
setModel(modelData);
} catch (err) {
console.error('加载模特信息失败:', err);
setError('加载模特信息失败');
}
};
const loadDynamics = async () => {
if (!modelId) return;
try {
// 使用模拟数据进行开发测试
const dynamicsList = modelDynamicService.getMockDynamics(modelId);
setDynamics(dynamicsList);
// TODO: 后续替换为真实API调用
// const dynamicsList = await modelDynamicService.getDynamicsByModelId(modelId);
// setDynamics(dynamicsList);
} catch (err) {
console.error('加载动态列表失败:', err);
}
};
const loadStats = async () => {
if (!modelId) return;
try {
// 使用模拟数据进行开发测试
const statsData = modelDynamicService.getMockStats(modelId);
setStats(statsData);
// TODO: 后续替换为真实API调用
// const statsData = await modelDynamicService.getStatsByModelId(modelId);
// setStats(statsData);
} catch (err) {
console.error('加载统计数据失败:', err);
} finally {
setLoading(false);
}
};
const handleCreateDynamic = async (dynamicData: any) => {
try {
// TODO: 实现创建动态API
// await modelDynamicService.createDynamic(dynamicData);
console.log('创建动态数据:', dynamicData);
setShowCreateModal(false);
await loadDynamics();
await loadStats();
} catch (err) {
console.error('创建动态失败:', err);
}
};
if (loading) {
return (
<div className="space-y-6 animate-fade-in">
{/* 头部骨架 */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center space-x-4">
<div className="h-20 w-20 bg-gray-200 rounded-full loading-shimmer" />
<div className="space-y-2 flex-1">
<div className="h-6 bg-gray-200 rounded w-32 loading-shimmer" />
<div className="h-4 bg-gray-200 rounded w-24 loading-shimmer" />
<div className="h-4 bg-gray-200 rounded w-48 loading-shimmer" />
</div>
</div>
</div>
{/* 动态列表骨架 */}
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
<div className="space-y-4">
<div className="h-4 bg-gray-300 rounded w-3/4"></div>
<div className="h-32 bg-gray-300 rounded"></div>
<div className="grid grid-cols-3 gap-2">
{[...Array(6)].map((_, j) => (
<div key={j} className="h-20 bg-gray-300 rounded"></div>
))}
</div>
</div>
</div>
))}
</div>
</div>
);
}
if (error || !model) {
return (
<div className="text-center py-16 animate-fade-in">
<div className="max-w-md mx-auto">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<ExclamationTriangleIcon className="h-8 w-8 text-red-500" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2"></h3>
<p className="text-gray-600 mb-6">{error || '模特信息不存在'}</p>
<button
onClick={() => window.history.back()}
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl hover:from-blue-600 hover:to-blue-700 transition-all duration-200 shadow-sm hover:shadow-md font-medium"
>
</button>
</div>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* 模特头部信息 */}
<ModelDynamicHeader
model={model}
stats={stats}
onCreateDynamic={() => setShowCreateModal(true)}
/>
{/* 动态列表 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center">
<SparklesIcon className="h-5 w-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-500">
{dynamics.length}
</p>
</div>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-lg hover:from-primary-600 hover:to-primary-700 transition-all duration-200 shadow-sm hover:shadow-md text-sm font-medium"
>
<PlusIcon className="h-4 w-4" />
</button>
</div>
</div>
<div className="p-6">
<ModelDynamicList
dynamics={dynamics}
onRefresh={loadDynamics}
/>
</div>
</div>
{/* 创建动态模态框 */}
{showCreateModal && model && (
<CreateDynamicModal
model={model}
onSubmit={handleCreateDynamic}
onCancel={() => setShowCreateModal(false)}
/>
)}
</div>
);
};
export default ModelDynamics;

View File

@@ -0,0 +1,206 @@
import { invoke } from '@tauri-apps/api/core';
import {
ModelDynamic,
CreateDynamicRequest,
UpdateDynamicRequest,
ModelDynamicStats,
DynamicStatus,
VideoGenerationStatus
} from '../types/model';
// 模特动态服务
export const modelDynamicService = {
// 创建动态
async createDynamic(request: CreateDynamicRequest): Promise<ModelDynamic> {
try {
return await invoke<ModelDynamic>('create_model_dynamic', { request });
} catch (error) {
console.error('创建模特动态失败:', error);
throw error;
}
},
// 获取动态详情
async getDynamicById(id: string): Promise<ModelDynamic | null> {
try {
return await invoke<ModelDynamic>('get_model_dynamic_by_id', { id });
} catch (error) {
console.error('获取模特动态详情失败:', error);
throw error;
}
},
// 获取模特的所有动态
async getDynamicsByModelId(modelId: string): Promise<ModelDynamic[]> {
try {
return await invoke<ModelDynamic[]>('get_model_dynamics_by_model_id', { modelId });
} catch (error) {
console.error('获取模特动态列表失败:', error);
throw error;
}
},
// 更新动态
async updateDynamic(id: string, request: UpdateDynamicRequest): Promise<ModelDynamic> {
try {
return await invoke<ModelDynamic>('update_model_dynamic', { id, request });
} catch (error) {
console.error('更新模特动态失败:', error);
throw error;
}
},
// 删除动态
async deleteDynamic(id: string): Promise<void> {
try {
await invoke('delete_model_dynamic', { id });
} catch (error) {
console.error('删除模特动态失败:', error);
throw error;
}
},
// 获取模特动态统计
async getStatsByModelId(modelId: string): Promise<ModelDynamicStats> {
try {
return await invoke<ModelDynamicStats>('get_model_dynamic_stats', { modelId });
} catch (error) {
console.error('获取模特动态统计失败:', error);
throw error;
}
},
// 重新生成视频
async regenerateVideo(dynamicId: string, videoId: string): Promise<void> {
try {
await invoke('regenerate_dynamic_video', { dynamicId, videoId });
} catch (error) {
console.error('重新生成视频失败:', error);
throw error;
}
},
// 获取可用的AI模型列表
async getAvailableAIModels(): Promise<any[]> {
try {
return await invoke<any[]>('get_available_ai_models');
} catch (error) {
console.error('获取可用AI模型列表失败:', error);
throw error;
}
},
// 模拟数据 - 仅用于开发测试
getMockDynamics(modelId: string): ModelDynamic[] {
const now = new Date();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
return [
{
id: '1',
model_id: modelId,
title: '夏日写真',
description: '今天拍了一组夏日写真用AI生成了一些有趣的视频',
prompt: '一个年轻女孩在海滩上奔跑,穿着白色连衣裙,阳光明媚,海浪轻拍,微风吹拂她的长发',
source_image_path: 'https://images.unsplash.com/photo-1503185912284-5271ff81b9a8?q=80&w=1000',
ai_model: '极梦',
video_count: 4,
generated_videos: [
{
id: 'v1',
dynamic_id: '1',
video_path: '/path/to/video1.mp4',
thumbnail_path: 'https://images.unsplash.com/photo-1503185912284-5271ff81b9a8?q=80&w=1000',
file_size: 1024 * 1024 * 5,
duration: 15,
status: VideoGenerationStatus.Completed,
created_at: now.toISOString()
},
{
id: 'v2',
dynamic_id: '1',
video_path: '/path/to/video2.mp4',
thumbnail_path: 'https://images.unsplash.com/photo-1503185912284-5271ff81b9a8?q=80&w=1000',
file_size: 1024 * 1024 * 4.5,
duration: 12,
status: VideoGenerationStatus.Completed,
created_at: now.toISOString()
},
{
id: 'v3',
dynamic_id: '1',
video_path: '/path/to/video3.mp4',
thumbnail_path: 'https://images.unsplash.com/photo-1503185912284-5271ff81b9a8?q=80&w=1000',
file_size: 1024 * 1024 * 6,
duration: 18,
status: VideoGenerationStatus.Generating,
generation_progress: 75,
created_at: now.toISOString()
},
{
id: 'v4',
dynamic_id: '1',
video_path: '/path/to/video4.mp4',
file_size: 0,
duration: 0,
status: VideoGenerationStatus.Failed,
error_message: '生成失败,请重试',
created_at: now.toISOString()
}
],
status: DynamicStatus.Published,
created_at: now.toISOString(),
updated_at: now.toISOString()
},
{
id: '2',
model_id: modelId,
description: '尝试了一些新的风格',
prompt: '一个女孩站在城市街道上,霓虹灯照亮她的侧脸,赛博朋克风格,未来感十足',
source_image_path: 'https://images.unsplash.com/photo-1508214751196-bcfd4ca60f91?q=80&w=1000',
ai_model: '极梦',
video_count: 2,
generated_videos: [
{
id: 'v5',
dynamic_id: '2',
video_path: '/path/to/video5.mp4',
thumbnail_path: 'https://images.unsplash.com/photo-1508214751196-bcfd4ca60f91?q=80&w=1000',
file_size: 1024 * 1024 * 7,
duration: 20,
status: VideoGenerationStatus.Completed,
created_at: yesterday.toISOString()
},
{
id: 'v6',
dynamic_id: '2',
video_path: '/path/to/video6.mp4',
thumbnail_path: 'https://images.unsplash.com/photo-1508214751196-bcfd4ca60f91?q=80&w=1000',
file_size: 1024 * 1024 * 6.5,
duration: 17,
status: VideoGenerationStatus.Completed,
created_at: yesterday.toISOString()
}
],
status: DynamicStatus.Published,
created_at: yesterday.toISOString(),
updated_at: yesterday.toISOString()
}
];
},
// 模拟统计数据 - 仅用于开发测试
getMockStats(modelId: string): ModelDynamicStats {
return {
total_dynamics: 2,
published_dynamics: 2,
total_videos: 6,
completed_videos: 4,
generating_videos: 1,
failed_videos: 1
};
}
};
export default modelDynamicService;

View File

@@ -263,3 +263,82 @@ export interface ModelBatchOperation {
modelIds: string[];
params?: any;
}
// 模特动态相关类型
export interface ModelDynamic {
id: string;
model_id: string;
title?: string;
description: string;
prompt: string;
source_image_path: string;
ai_model: string; // 使用的AI模型如"极梦"
video_count: number; // 生成视频个数
generated_videos: GeneratedVideo[];
status: DynamicStatus;
created_at: string;
updated_at: string;
}
export interface GeneratedVideo {
id: string;
dynamic_id: string;
video_path: string;
thumbnail_path?: string;
file_size: number;
duration: number; // 视频时长(秒)
status: VideoGenerationStatus;
generation_progress?: number; // 生成进度 0-100
error_message?: string;
created_at: string;
}
export enum DynamicStatus {
Draft = "Draft",
Publishing = "Publishing",
Published = "Published",
Failed = "Failed"
}
export enum VideoGenerationStatus {
Pending = "Pending",
Generating = "Generating",
Completed = "Completed",
Failed = "Failed"
}
export interface CreateDynamicRequest {
model_id: string;
title?: string;
description: string;
prompt: string;
source_image_path: string;
ai_model: string;
video_count: number;
}
export interface UpdateDynamicRequest {
title?: string;
description?: string;
prompt?: string;
status?: DynamicStatus;
}
// 模特动态统计
export interface ModelDynamicStats {
total_dynamics: number;
published_dynamics: number;
total_videos: number;
completed_videos: number;
generating_videos: number;
failed_videos: number;
}
// AI模型选项
export interface AIModelOption {
id: string;
name: string;
description: string;
is_available: boolean;
max_video_count: number;
}