use anyhow::{Result, anyhow}; use std::path::Path; use std::fs; use std::time::Instant; use std::sync::Arc; use crate::data::models::material::{ Material, MaterialType, ProcessingStatus, CreateMaterialRequest, MaterialImportResult, MaterialProcessingConfig, MaterialMetadata, BatchDeleteResult, BatchDeleteFailedItem }; use crate::data::repositories::material_repository::MaterialRepository; use crate::infrastructure::ffmpeg::FFmpegService; use crate::infrastructure::monitoring::PERFORMANCE_MONITOR; use crate::business::errors::error_utils; use tracing::{info, warn, error, debug}; /// 素材服务 /// 遵循 Tauri 开发规范的业务逻辑层设计 pub struct MaterialService; impl MaterialService { /// 导入素材文件 pub fn import_materials( repository: &MaterialRepository, request: CreateMaterialRequest, config: &MaterialProcessingConfig, ) -> Result { let timer = PERFORMANCE_MONITOR.start_operation("import_materials"); let start_time = Instant::now(); info!( project_id = %request.project_id, file_count = request.file_paths.len(), "开始导入素材文件" ); let mut result = MaterialImportResult { total_files: request.file_paths.len() as u32, processed_files: 0, skipped_files: 0, failed_files: 0, created_materials: Vec::new(), errors: Vec::new(), processing_time: 0.0, }; for file_path in &request.file_paths { debug!(file_path = %file_path, "处理文件"); match Self::process_single_file(repository, &request.project_id, file_path, config, request.model_id.clone()) { Ok(Some(material)) => { info!( file_path = %file_path, material_id = %material.id, "文件处理成功" ); result.created_materials.push(material); result.processed_files += 1; } Ok(None) => { // 文件被跳过(重复) warn!(file_path = %file_path, "文件被跳过(重复)"); result.skipped_files += 1; } Err(e) => { error!( file_path = %file_path, error = %e, "文件处理失败" ); result.failed_files += 1; result.errors.push(format!("处理文件 {} 失败: {}", file_path, e)); } } } result.processing_time = start_time.elapsed().as_secs_f64(); let success = result.failed_files == 0; timer.finish(success); info!( processed_files = result.processed_files, skipped_files = result.skipped_files, failed_files = result.failed_files, processing_time = result.processing_time, "素材导入完成" ); PERFORMANCE_MONITOR.record_metric("import_files_per_second", result.total_files as f64 / result.processing_time); Ok(result) } /// 处理单个文件 fn process_single_file( repository: &MaterialRepository, project_id: &str, file_path: &str, config: &MaterialProcessingConfig, model_id: Option, ) -> Result> { let _timer = PERFORMANCE_MONITOR.start_operation("process_single_file"); // 验证文件路径 error_utils::validate_file_path(file_path) .map_err(|e| anyhow!("文件验证失败: {}", e))?; let path = Path::new(file_path); // 获取文件信息 let metadata = fs::metadata(path)?; let file_size = metadata.len(); // 计算MD5哈希 let md5_hash = Self::calculate_md5(file_path)?; // 检查是否已存在相同的文件 if repository.exists_by_md5(project_id, &md5_hash)? { return Ok(None); // 跳过重复文件 } // 获取文件名和扩展名 let file_name = path.file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string(); let extension = path.extension() .and_then(|ext| ext.to_str()) .unwrap_or(""); // 确定素材类型 let material_type = MaterialType::from_extension(extension); // 添加调试日志 println!("Creating material with model_id: {:?}", model_id); // 创建素材对象(带模特绑定) let material = Material::new_with_model( project_id.to_string(), file_name, file_path.to_string(), file_size, md5_hash, material_type, model_id, ); // 保存到数据库 repository.create(&material)?; // 如果启用自动处理,则开始处理 if config.auto_process.unwrap_or(true) { // 同步处理素材(提取元数据、场景检测等) match Self::process_material(repository, &material.id, config) { Ok(_) => { // 处理成功,重新获取更新后的素材 if let Ok(Some(updated_material)) = repository.get_by_id(&material.id) { return Ok(Some(updated_material)); } } Err(e) => { // 处理失败,但不影响导入结果,只记录错误 eprintln!("素材处理失败: {} - {}", material.name, e); } } } Ok(Some(material)) } /// 计算文件的MD5哈希值 pub fn calculate_md5(file_path: &str) -> Result { let data = fs::read(file_path)?; let digest = md5::compute(&data); Ok(format!("{:x}", digest)) } /// 获取项目的所有素材 pub fn get_project_materials( repository: &MaterialRepository, project_id: &str, ) -> Result> { let mut materials = repository.get_by_project_id(project_id)?; // 为每个素材加载片段信息 for material in &mut materials { material.segments = repository.get_segments(&material.id)?; } Ok(materials) } /// 获取所有素材 pub fn get_all_materials( repository: &MaterialRepository, ) -> Result> { let mut materials = repository.get_all()?; // 为每个素材加载片段信息 for material in &mut materials { material.segments = repository.get_segments(&material.id)?; } Ok(materials) } /// 获取素材详情 pub fn get_material_by_id( repository: &MaterialRepository, id: &str, ) -> Result> { if let Some(mut material) = repository.get_by_id(id)? { // 加载片段信息 material.segments = repository.get_segments(&material.id)?; Ok(Some(material)) } else { Ok(None) } } /// 删除素材 pub fn delete_material( repository: &MaterialRepository, id: &str, ) -> Result<()> { // TODO: 删除相关的文件 repository.delete(id)?; Ok(()) } /// 批量删除素材 /// 返回删除结果统计信息 pub fn batch_delete_materials( repository: &MaterialRepository, material_ids: Vec, ) -> Result { info!( material_count = material_ids.len(), "开始批量删除素材" ); // 参数验证 if material_ids.is_empty() { return Err(anyhow!("素材ID列表不能为空")); } if material_ids.len() > 100 { return Err(anyhow!("单次批量删除不能超过100个素材")); } // 执行批量删除 let (successful_deletes, failed_deletes) = repository.batch_delete(&material_ids)?; let result = BatchDeleteResult { total_count: material_ids.len(), success_count: successful_deletes.len(), failed_count: failed_deletes.len(), successful_ids: successful_deletes, failed_items: failed_deletes.into_iter().map(|(id, error)| BatchDeleteFailedItem { id, error_message: error, }).collect(), }; info!( total_count = result.total_count, success_count = result.success_count, failed_count = result.failed_count, "批量删除素材完成" ); Ok(result) } /// 更新素材处理状态 pub fn update_material_status( repository: &MaterialRepository, id: &str, status: ProcessingStatus, error_message: Option, ) -> Result<()> { if let Some(mut material) = repository.get_by_id(id)? { material.update_status(status, error_message); repository.update(&material)?; } Ok(()) } /// 获取项目素材统计 pub fn get_project_stats( repository: &MaterialRepository, project_id: &str, ) -> Result { repository.get_project_stats(project_id) } /// 验证文件是否为支持的格式 pub fn is_supported_format(file_path: &str) -> bool { let path = Path::new(file_path); if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) { let material_type = MaterialType::from_extension(extension); !matches!(material_type, MaterialType::Other) } else { false } } /// 获取支持的文件扩展名列表 pub fn get_supported_extensions() -> Vec<&'static str> { vec![ // 视频格式 "mp4", "avi", "mov", "mkv", "wmv", "flv", "webm", "m4v", // 音频格式 "mp3", "wav", "flac", "aac", "ogg", "wma", "m4a", // 图片格式 "jpg", "jpeg", "png", "gif", "bmp", "tiff", "webp", "svg", // 文档格式 "pdf", "doc", "docx", "txt", "md", "rtf", ] } /// 批量处理素材 pub fn batch_process_materials( repository: &MaterialRepository, material_ids: Vec, config: &MaterialProcessingConfig, ) -> Result> { let mut processed_ids = Vec::new(); for material_id in material_ids { match Self::process_material(repository, &material_id, config) { Ok(_) => { processed_ids.push(material_id); } Err(e) => { // 更新状态为失败 let _ = Self::update_material_status( repository, &material_id, ProcessingStatus::Failed, Some(e.to_string()), ); } } } Ok(processed_ids) } /// 处理单个素材(提取元数据、场景检测等) fn process_material( repository: &MaterialRepository, material_id: &str, config: &MaterialProcessingConfig, ) -> Result<()> { // 更新状态为处理中 Self::update_material_status( repository, material_id, ProcessingStatus::Processing, None, )?; // 获取素材信息 let mut material = repository.get_by_id(material_id)? .ok_or_else(|| anyhow!("素材不存在: {}", material_id))?; // 1. 提取元数据 match Self::extract_metadata(&material.original_path, &material.material_type) { Ok(metadata) => { material.set_metadata(metadata); repository.update(&material)?; } Err(e) => { Self::update_material_status( repository, material_id, ProcessingStatus::Failed, Some(format!("元数据提取失败: {}", e)), )?; return Err(e); } } // 2. 场景检测(如果是视频且启用了场景检测) if matches!(material.material_type, MaterialType::Video) && config.enable_scene_detection { println!("开始视频场景检测: {}", material.original_path); // 如果设置了跳过开头,先创建临时视频文件 let detection_file_path = if let Some(skip_ms) = config.skip_start_ms { if skip_ms > 0 { println!("AI生成视频前置跳过: {}ms", skip_ms); match crate::infrastructure::ffmpeg::FFmpegService::create_trimmed_video(&material.original_path, skip_ms) { Ok(temp_path) => { println!("临时视频创建成功,用于场景检测: {}", temp_path); temp_path } Err(e) => { eprintln!("创建临时视频失败,使用原视频: {}", e); material.original_path.clone() } } } else { material.original_path.clone() } } else { material.original_path.clone() }; match Self::detect_video_scenes(&detection_file_path, config.scene_detection_threshold) { Ok(mut scene_detection) => { // 如果使用了临时文件,需要调整场景时间戳 if let Some(skip_ms) = config.skip_start_ms { if skip_ms > 0 && detection_file_path != material.original_path { let skip_seconds = skip_ms as f64 / 1000.0; println!("调整场景时间戳,补偿跳过的{}秒", skip_seconds); for scene in &mut scene_detection.scenes { scene.start_time += skip_seconds; scene.end_time += skip_seconds; } } } println!("场景检测成功,发现 {} 个场景", scene_detection.scenes.len()); material.set_scene_detection(scene_detection); repository.update(&material)?; // 清理临时文件 if detection_file_path != material.original_path { if let Err(e) = std::fs::remove_file(&detection_file_path) { eprintln!("清理临时文件失败: {}", e); } else { println!("临时文件清理成功: {}", detection_file_path); } } } Err(e) => { // 场景检测失败不应该导致整个处理失败 eprintln!("场景检测失败: {}", e); // 清理临时文件 if detection_file_path != material.original_path { if let Err(e) = std::fs::remove_file(&detection_file_path) { eprintln!("清理临时文件失败: {}", e); } } } } } else { println!("跳过场景检测 - 视频类型: {}, 启用检测: {}", matches!(material.material_type, MaterialType::Video), config.enable_scene_detection); } // 3. 检查是否需要切分视频 let should_segment = material.needs_segmentation(config.max_segment_duration) || (matches!(material.material_type, MaterialType::Video) && material.scene_detection.is_some()); if should_segment { println!("开始视频切分 - 总时长检查: {}, 场景检测: {}", material.needs_segmentation(config.max_segment_duration), material.scene_detection.is_some()); match Self::segment_video(repository, &material, config) { Ok(_) => { println!("视频切分完成: {}", material.name); } Err(e) => { Self::update_material_status( repository, material_id, ProcessingStatus::Failed, Some(format!("视频切分失败: {}", e)), )?; return Err(e); } } } else { println!("跳过视频切分 - 不满足切分条件"); } // 标记为完成 Self::update_material_status( repository, material_id, ProcessingStatus::Completed, None, )?; Ok(()) } /// 提取素材元数据 pub fn extract_metadata(file_path: &str, material_type: &MaterialType) -> Result { match material_type { MaterialType::Video | MaterialType::Audio => { // 使用 FFmpeg 提取视频/音频元数据 FFmpegService::extract_metadata(file_path) } MaterialType::Image => { // TODO: 实现图片元数据提取 Ok(MaterialMetadata::None) } _ => { // 其他类型不需要元数据 Ok(MaterialMetadata::None) } } } /// 检测视频场景 pub fn detect_video_scenes( file_path: &str, threshold: f64, ) -> Result { let scene_times = FFmpegService::detect_scenes(file_path, threshold)?; // 获取视频总时长 let total_duration = if let Ok(metadata) = Self::extract_metadata(file_path, &MaterialType::Video) { if let MaterialMetadata::Video(video_meta) = metadata { video_meta.duration } else { return Err(anyhow!("无法获取视频元数据")); } } else { return Err(anyhow!("无法提取视频元数据")); }; let mut scenes = Vec::new(); let mut previous_time = 0.0; // 根据场景切换点创建场景片段 for (index, &scene_time) in scene_times.iter().enumerate() { if scene_time > previous_time { scenes.push(crate::data::models::material::SceneSegment { start_time: previous_time, end_time: scene_time, duration: scene_time - previous_time, scene_id: index as u32, confidence: 1.0, }); previous_time = scene_time; } } // 添加最后一个场景片段 if previous_time < total_duration { scenes.push(crate::data::models::material::SceneSegment { start_time: previous_time, end_time: total_duration, duration: total_duration - previous_time, scene_id: scenes.len() as u32, confidence: 1.0, }); } println!("场景检测完成,共 {} 个场景:", scenes.len()); for (i, scene) in scenes.iter().enumerate() { println!(" 场景 {}: {:.2}s - {:.2}s (时长: {:.2}s)", i + 1, scene.start_time, scene.end_time, scene.duration); } let total_scenes = scenes.len() as u32; Ok(crate::data::models::material::SceneDetection { scenes, total_scenes, detection_method: "ffmpeg_scene_filter".to_string(), threshold, }) } /// 切分视频 pub fn segment_video( repository: &MaterialRepository, material: &Material, config: &MaterialProcessingConfig, ) -> Result<()> { // 首先获取项目信息来确定输出路径 let project = Self::get_project_for_material(&material.project_id)?; // 第一步:根据场景检测结果进行分镜头切分 let primary_segments = if let Some(scene_detection) = &material.scene_detection { // 使用场景检测结果,每个场景作为一个片段 println!("使用场景检测结果进行分镜头切分,共 {} 个场景", scene_detection.scenes.len()); Self::create_segments_from_scenes_direct(&scene_detection.scenes) } else { // 没有场景检测结果,使用固定时长切分 if let Some(duration) = material.get_duration() { println!("没有场景检测结果,使用固定时长切分"); Self::create_fixed_segments(duration, config.max_segment_duration) } else { return Err(anyhow!("无法获取视频时长")); } }; // 第二步:对超长的分镜头进行二次切分 let final_segments = Self::apply_duration_limit(&primary_segments, config.max_segment_duration); println!("分镜头切分完成:{} 个片段", primary_segments.len()); println!("二次切分后:{} 个片段", final_segments.len()); // 创建输出目录:项目目录/pending/素材名称_segments (使用英文避免中文路径问题) let mut project_path = project.path.clone(); // 处理 Windows 长路径格式,移除 \\?\ 前缀 if project_path.starts_with("\\\\?\\") { project_path = project_path[4..].to_string(); } let pending_dir = std::path::Path::new(&project_path).join("pending"); // 确保pending目录存在 if !pending_dir.exists() { std::fs::create_dir_all(&pending_dir) .map_err(|e| anyhow!("创建pending目录失败: {}", e))?; println!("创建pending目录: {:?}", pending_dir); } let material_name = material.name.trim_end_matches(".mp4"); let output_dir = pending_dir.join(format!("{}_segments", material_name)); // 标准化路径,确保FFmpeg可以处理 let output_dir_str = output_dir.to_string_lossy().to_string(); let normalized_output_dir = if output_dir_str.starts_with("\\\\?\\") { output_dir_str[4..].to_string() } else { output_dir_str }; println!("视频切分输出目录: {}", normalized_output_dir); // 根据配置选择切分模式 let output_files = match config.split_mode { crate::data::models::material::VideoSplitMode::Fast => { println!("使用快速切分模式(可能有前几秒无画面问题)"); FFmpegService::split_video_fast( &material.original_path, &normalized_output_dir, &final_segments, &material.name.replace(".mp4", ""), )? } crate::data::models::material::VideoSplitMode::Accurate => { println!("使用精确切分模式(重新编码,确保画面完整)"); FFmpegService::split_video( &material.original_path, &normalized_output_dir, &final_segments, &material.name.replace(".mp4", ""), )? } crate::data::models::material::VideoSplitMode::Smart => { println!("使用智能切分模式(关键帧对齐)"); FFmpegService::split_video_smart( &material.original_path, &normalized_output_dir, &final_segments, &material.name.replace(".mp4", ""), )? } }; // 保存片段信息到数据库 for (index, output_file) in output_files.iter().enumerate() { let (start_time, end_time) = final_segments[index]; let file_size = std::fs::metadata(output_file)?.len(); let segment = crate::data::models::material::MaterialSegment::new( material.id.clone(), index as u32, start_time, end_time, output_file.clone(), file_size, ); repository.create_segment(&segment)?; } Ok(()) } /// 直接根据场景创建分镜头片段(第一次切分) pub fn create_segments_from_scenes_direct( scenes: &[crate::data::models::material::SceneSegment], ) -> Vec<(f64, f64)> { let mut segments = Vec::new(); for scene in scenes { segments.push((scene.start_time, scene.end_time)); } println!("分镜头切分:根据 {} 个场景创建 {} 个片段", scenes.len(), segments.len()); for (i, (start, end)) in segments.iter().enumerate() { println!(" 分镜头 {}: {:.2}s - {:.2}s (时长: {:.2}s)", i + 1, start, end, end - start); } segments } /// 对片段应用时长限制(二次切分) pub fn apply_duration_limit( segments: &[(f64, f64)], max_duration: f64, ) -> Vec<(f64, f64)> { let mut final_segments = Vec::new(); println!("开始二次切分,最大时长限制: {}秒", max_duration); for (i, (start, end)) in segments.iter().enumerate() { let duration = end - start; if duration > max_duration { println!(" 分镜头 {} 超长 ({:.2}s > {:.2}s),进行二次切分", i + 1, duration, max_duration); // 将超长片段按最大时长切分 let sub_segments = Self::create_fixed_segments_range(*start, *end, max_duration); println!(" 切分为 {} 个子片段:", sub_segments.len()); for (j, (sub_start, sub_end)) in sub_segments.iter().enumerate() { println!(" 子片段 {}: {:.2}s - {:.2}s (时长: {:.2}s)", j + 1, sub_start, sub_end, sub_end - sub_start); } final_segments.extend(sub_segments); } else { println!(" 分镜头 {} 时长合适 ({:.2}s ≤ {:.2}s),保持不变", i + 1, duration, max_duration); final_segments.push((*start, *end)); } } println!("二次切分完成,最终 {} 个片段", final_segments.len()); final_segments } /// 根据场景创建切分片段(旧版本,保留兼容性) pub fn create_segments_from_scenes( scenes: &[crate::data::models::material::SceneSegment], max_duration: f64, ) -> Vec<(f64, f64)> { // 使用新的两步法 let primary_segments = Self::create_segments_from_scenes_direct(scenes); Self::apply_duration_limit(&primary_segments, max_duration) } /// 创建固定时长的切分片段 pub fn create_fixed_segments(total_duration: f64, max_duration: f64) -> Vec<(f64, f64)> { Self::create_fixed_segments_range(0.0, total_duration, max_duration) } /// 在指定时间范围内创建固定时长的切分片段 fn create_fixed_segments_range(start_time: f64, end_time: f64, max_duration: f64) -> Vec<(f64, f64)> { let mut segments = Vec::new(); let mut current_time = start_time; while current_time < end_time { let segment_end = (current_time + max_duration).min(end_time); segments.push((current_time, segment_end)); current_time = segment_end; } segments } /// 清理失效的素材记录 pub fn cleanup_invalid_materials( repository: &MaterialRepository, project_id: &str, ) -> Result { let materials = repository.get_by_project_id(project_id)?; let mut cleaned_count = 0; for material in materials { // 检查原始文件是否还存在 if !Path::new(&material.original_path).exists() { repository.delete(&material.id)?; cleaned_count += 1; } } Ok(cleaned_count) } /// 获取素材对应的项目信息 fn get_project_for_material(project_id: &str) -> Result { use crate::infrastructure::database::Database; use crate::data::repositories::project_repository::ProjectRepository; use crate::business::services::project_service::ProjectService; // 创建数据库连接 let db = Arc::new(Database::new()?); let project_repo = ProjectRepository::new(db.clone())?; // 获取项目信息 ProjectService::get_project_by_id(&project_repo, project_id)? .ok_or_else(|| anyhow!("找不到项目: {}", project_id)) } /// 绑定素材到模特 pub fn bind_material_to_model( repository: &MaterialRepository, material_id: &str, model_id: &str, ) -> Result<()> { info!( material_id = %material_id, model_id = %model_id, "开始绑定素材到模特" ); // 验证素材是否存在 let material = repository.get_by_id(material_id)? .ok_or_else(|| anyhow!("找不到素材: {}", material_id))?; // 验证模特是否存在 Self::validate_model_exists(model_id)?; // 执行绑定 repository.update_model_binding(material_id, Some(model_id))?; info!( material_id = %material_id, model_id = %model_id, material_name = %material.name, "素材绑定到模特成功" ); Ok(()) } /// 解除素材与模特的绑定 pub fn unbind_material_from_model( repository: &MaterialRepository, material_id: &str, ) -> Result<()> { info!(material_id = %material_id, "开始解除素材与模特的绑定"); // 验证素材是否存在 let material = repository.get_by_id(material_id)? .ok_or_else(|| anyhow!("找不到素材: {}", material_id))?; // 执行解绑 repository.update_model_binding(material_id, None)?; info!( material_id = %material_id, material_name = %material.name, "素材与模特解绑成功" ); Ok(()) } /// 批量绑定素材到模特 pub fn batch_bind_materials_to_model( repository: &MaterialRepository, material_ids: &[String], model_id: &str, ) -> Result<()> { info!( material_count = material_ids.len(), model_id = %model_id, "开始批量绑定素材到模特" ); // 验证模特是否存在 Self::validate_model_exists(model_id)?; // 验证所有素材是否存在 for material_id in material_ids { if repository.get_by_id(material_id)?.is_none() { return Err(anyhow!("找不到素材: {}", material_id)); } } // 执行批量绑定 repository.batch_update_model_binding(material_ids, Some(model_id))?; info!( material_count = material_ids.len(), model_id = %model_id, "批量绑定素材到模特成功" ); Ok(()) } /// 批量解除素材与模特的绑定 pub fn batch_unbind_materials_from_model( repository: &MaterialRepository, material_ids: &[String], ) -> Result<()> { info!( material_count = material_ids.len(), "开始批量解除素材与模特的绑定" ); // 验证所有素材是否存在 for material_id in material_ids { if repository.get_by_id(material_id)?.is_none() { return Err(anyhow!("找不到素材: {}", material_id)); } } // 执行批量解绑 repository.batch_update_model_binding(material_ids, None)?; info!( material_count = material_ids.len(), "批量解除素材与模特绑定成功" ); Ok(()) } /// 获取模特的所有素材 pub fn get_materials_by_model( repository: &MaterialRepository, model_id: &str, ) -> Result> { debug!(model_id = %model_id, "获取模特的所有素材"); // 验证模特是否存在 Self::validate_model_exists(model_id)?; repository.get_by_model_id(model_id) } /// 获取未绑定模特的素材 pub fn get_unbound_materials( repository: &MaterialRepository, project_id: Option<&str>, ) -> Result> { debug!(project_id = ?project_id, "获取未绑定模特的素材"); if let Some(pid) = project_id { repository.get_unassociated_materials(Some(pid)) } else { repository.get_unbound_materials() } } /// 获取模特的素材统计信息 pub fn get_model_material_statistics( repository: &MaterialRepository, model_id: &str, ) -> Result { debug!(model_id = %model_id, "获取模特的素材统计信息"); // 验证模特是否存在 Self::validate_model_exists(model_id)?; repository.get_model_statistics(model_id) } /// 验证模特是否存在 fn validate_model_exists(model_id: &str) -> Result<()> { use crate::infrastructure::database::Database; use crate::data::repositories::model_repository::ModelRepository; let db = Arc::new(Database::new()?); let model_repo = ModelRepository::new(db.clone()); if model_repo.get_by_id(model_id)?.is_none() { return Err(anyhow!("找不到模特: {}", model_id)); } Ok(()) } /// 切换素材的模特绑定 pub fn switch_material_model( repository: &MaterialRepository, material_id: &str, new_model_id: &str, ) -> Result<()> { info!( material_id = %material_id, new_model_id = %new_model_id, "开始切换素材的模特绑定" ); // 验证素材是否存在 let material = repository.get_by_id(material_id)? .ok_or_else(|| anyhow!("找不到素材: {}", material_id))?; // 验证新模特是否存在 Self::validate_model_exists(new_model_id)?; // 执行切换 repository.update_model_binding(material_id, Some(new_model_id))?; info!( material_id = %material_id, material_name = %material.name, new_model_id = %new_model_id, "素材模特绑定切换成功" ); Ok(()) } /// 获取项目中绑定模特的素材统计 pub fn get_project_model_binding_stats( repository: &MaterialRepository, project_id: &str, ) -> Result { debug!(project_id = %project_id, "获取项目中模特绑定统计"); let bound_count = repository.count_bound_materials_in_project(project_id)?; let unbound_count = repository.count_unbound_materials_in_project(project_id)?; let total_count = bound_count + unbound_count; Ok(ProjectModelBindingStats { total_materials: total_count, bound_materials: bound_count, unbound_materials: unbound_count, binding_rate: if total_count > 0 { (bound_count as f64 / total_count as f64) * 100.0 } else { 0.0 }, }) } /// 获取全局模特绑定统计 pub fn get_global_model_binding_stats( repository: &MaterialRepository, ) -> Result { debug!("获取全局模特绑定统计"); let bound_count = repository.count_global_bound_materials()?; let unbound_count = repository.count_global_unbound_materials()?; let total_count = bound_count + unbound_count; Ok(ProjectModelBindingStats { total_materials: total_count, bound_materials: bound_count, unbound_materials: unbound_count, binding_rate: if total_count > 0 { (bound_count as f64 / total_count as f64) * 100.0 } else { 0.0 }, }) } } /// 项目模特绑定统计信息 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ProjectModelBindingStats { pub total_materials: u32, pub bound_materials: u32, pub unbound_materials: u32, pub binding_rate: f64, // 绑定率百分比 }