feat: 实现素材匹配功能 v0.1.19
- 新增素材匹配服务 (MaterialMatchingService) - 支持AI分类匹配、随机匹配等规则 - 实现模特限制逻辑(每个模特素材只能使用一次) - 时长匹配优化(相差越小越好) - 详细的匹配统计和失败原因分析 - 新增Tauri API命令 - execute_material_matching: 执行素材匹配 - get_project_material_stats_for_matching: 获取项目素材统计 - validate_template_binding_for_matching: 验证模板绑定 - 新增前端组件和服务 - MaterialMatchingResultDialog: 匹配结果对话框 - MaterialMatchingService: 前端服务层 - 完整的TypeScript类型定义 - UI集成 - 在模板绑定列表添加匹配素材按钮 - 集成到项目详情页面 - 支持完整的匹配流程和结果展示 - 核心匹配规则 - 只使用已AI分类的MaterialSegment - 每个素材只能使用一次 - 模特限制:优先同一模特,失败后尝试其他模特 - 视频时长必须大于模板需求,相差越小匹配度越高 - 测试覆盖 - 后端服务单元测试 - 覆盖正常匹配、失败场景、边界情况
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* 素材匹配服务
|
||||
* 遵循 Tauri 开发规范的业务逻辑层设计原则
|
||||
*/
|
||||
|
||||
use crate::data::models::{
|
||||
material::{Material, MaterialSegment},
|
||||
template::{Template, TrackSegment, SegmentMatchingRule},
|
||||
video_classification::VideoClassificationRecord,
|
||||
};
|
||||
use crate::data::repositories::{
|
||||
material_repository::MaterialRepository,
|
||||
video_classification_repository::VideoClassificationRepository,
|
||||
};
|
||||
use crate::business::services::template_service::TemplateService;
|
||||
use anyhow::{Result, anyhow};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 素材匹配服务
|
||||
pub struct MaterialMatchingService {
|
||||
material_repo: Arc<MaterialRepository>,
|
||||
template_service: Arc<TemplateService>,
|
||||
video_classification_repo: Arc<VideoClassificationRepository>,
|
||||
}
|
||||
|
||||
/// 素材匹配请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MaterialMatchingRequest {
|
||||
pub project_id: String,
|
||||
pub template_id: String,
|
||||
pub binding_id: String,
|
||||
pub overwrite_existing: bool,
|
||||
}
|
||||
|
||||
/// 素材匹配结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MaterialMatchingResult {
|
||||
pub binding_id: String,
|
||||
pub template_id: String,
|
||||
pub project_id: String,
|
||||
pub matches: Vec<SegmentMatch>,
|
||||
pub statistics: MatchingStatistics,
|
||||
pub failed_segments: Vec<FailedSegmentMatch>,
|
||||
}
|
||||
|
||||
/// 片段匹配结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SegmentMatch {
|
||||
pub track_segment_id: String,
|
||||
pub track_segment_name: String,
|
||||
pub material_segment_id: String,
|
||||
pub material_segment: MaterialSegment,
|
||||
pub material_name: String,
|
||||
pub model_name: Option<String>,
|
||||
pub match_score: f64,
|
||||
pub match_reason: String,
|
||||
}
|
||||
|
||||
/// 匹配失败的片段
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FailedSegmentMatch {
|
||||
pub track_segment_id: String,
|
||||
pub track_segment_name: String,
|
||||
pub matching_rule: SegmentMatchingRule,
|
||||
pub failure_reason: String,
|
||||
}
|
||||
|
||||
/// 匹配统计信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MatchingStatistics {
|
||||
pub total_segments: u32,
|
||||
pub matched_segments: u32,
|
||||
pub failed_segments: u32,
|
||||
pub success_rate: f64,
|
||||
pub used_materials: u32,
|
||||
pub used_models: u32,
|
||||
}
|
||||
|
||||
impl MaterialMatchingService {
|
||||
/// 创建新的素材匹配服务实例
|
||||
pub fn new(
|
||||
material_repo: Arc<MaterialRepository>,
|
||||
template_service: Arc<TemplateService>,
|
||||
video_classification_repo: Arc<VideoClassificationRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
material_repo,
|
||||
template_service,
|
||||
video_classification_repo,
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行素材匹配
|
||||
pub async fn match_materials(&self, request: MaterialMatchingRequest) -> Result<MaterialMatchingResult> {
|
||||
// 获取模板信息
|
||||
let template = self.template_service.get_template_by_id(&request.template_id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("模板不存在: {}", request.template_id))?;
|
||||
|
||||
// 获取项目的所有素材
|
||||
let project_materials = self.material_repo.get_by_project_id(&request.project_id)?;
|
||||
|
||||
// 获取所有素材的分类记录
|
||||
let mut classification_records = HashMap::new();
|
||||
for material in &project_materials {
|
||||
let records = self.video_classification_repo.get_by_material_id(&material.id).await?;
|
||||
classification_records.insert(material.id.clone(), records);
|
||||
}
|
||||
|
||||
// 获取所有可用的素材片段(已分类的)
|
||||
let available_segments = self.get_classified_segments(&project_materials, &classification_records).await?;
|
||||
|
||||
// 执行匹配算法
|
||||
let mut matches = Vec::new();
|
||||
let mut failed_segments = Vec::new();
|
||||
let mut used_segment_ids = HashSet::new();
|
||||
let mut used_model_ids = HashSet::new();
|
||||
|
||||
// 获取所有需要匹配的轨道片段
|
||||
let track_segments = self.get_template_track_segments(&template).await?;
|
||||
|
||||
for track_segment in &track_segments {
|
||||
match self.match_single_segment(
|
||||
track_segment,
|
||||
&available_segments,
|
||||
&classification_records,
|
||||
&project_materials,
|
||||
&mut used_segment_ids,
|
||||
).await {
|
||||
Ok(segment_match) => {
|
||||
// 记录使用的模特
|
||||
if let Some(material) = project_materials.iter().find(|m| m.id == segment_match.material_segment.material_id) {
|
||||
if let Some(model_id) = &material.model_id {
|
||||
used_model_ids.insert(model_id.clone());
|
||||
}
|
||||
}
|
||||
matches.push(segment_match);
|
||||
}
|
||||
Err(failure_reason) => {
|
||||
failed_segments.push(FailedSegmentMatch {
|
||||
track_segment_id: track_segment.id.clone(),
|
||||
track_segment_name: track_segment.name.clone(),
|
||||
matching_rule: track_segment.matching_rule.clone(),
|
||||
failure_reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算统计信息
|
||||
let total_segments = track_segments.len() as u32;
|
||||
let matched_segments = matches.len() as u32;
|
||||
let failed_segments_count = failed_segments.len() as u32;
|
||||
let success_rate = if total_segments > 0 {
|
||||
matched_segments as f64 / total_segments as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let statistics = MatchingStatistics {
|
||||
total_segments,
|
||||
matched_segments,
|
||||
failed_segments: failed_segments_count,
|
||||
success_rate,
|
||||
used_materials: used_segment_ids.len() as u32,
|
||||
used_models: used_model_ids.len() as u32,
|
||||
};
|
||||
|
||||
Ok(MaterialMatchingResult {
|
||||
binding_id: request.binding_id,
|
||||
template_id: request.template_id,
|
||||
project_id: request.project_id,
|
||||
matches,
|
||||
statistics,
|
||||
failed_segments,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取已分类的素材片段
|
||||
async fn get_classified_segments(
|
||||
&self,
|
||||
materials: &[Material],
|
||||
classification_records: &HashMap<String, Vec<VideoClassificationRecord>>,
|
||||
) -> Result<Vec<(MaterialSegment, String)>> {
|
||||
let mut classified_segments = Vec::new();
|
||||
|
||||
for material in materials {
|
||||
// 只处理有分类记录的素材
|
||||
if let Some(records) = classification_records.get(&material.id) {
|
||||
if records.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 为每个素材片段查找对应的分类记录
|
||||
for segment in &material.segments {
|
||||
// 查找该片段的分类记录
|
||||
if let Some(record) = records.iter().find(|r| r.segment_id == segment.id) {
|
||||
classified_segments.push((segment.clone(), record.category.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(classified_segments)
|
||||
}
|
||||
|
||||
/// 获取模板的所有轨道片段
|
||||
async fn get_template_track_segments(&self, template: &Template) -> Result<Vec<TrackSegment>> {
|
||||
let mut all_segments = Vec::new();
|
||||
|
||||
for track in &template.tracks {
|
||||
all_segments.extend(track.segments.clone());
|
||||
}
|
||||
|
||||
Ok(all_segments)
|
||||
}
|
||||
|
||||
/// 匹配单个轨道片段
|
||||
async fn match_single_segment(
|
||||
&self,
|
||||
track_segment: &TrackSegment,
|
||||
available_segments: &[(MaterialSegment, String)],
|
||||
_classification_records: &HashMap<String, Vec<VideoClassificationRecord>>,
|
||||
project_materials: &[Material],
|
||||
used_segment_ids: &mut HashSet<String>,
|
||||
) -> Result<SegmentMatch, String> {
|
||||
// 检查匹配规则
|
||||
match &track_segment.matching_rule {
|
||||
SegmentMatchingRule::FixedMaterial => {
|
||||
Err("固定素材不需要匹配".to_string())
|
||||
}
|
||||
SegmentMatchingRule::AiClassification { category_name, .. } => {
|
||||
self.match_by_ai_classification(
|
||||
track_segment,
|
||||
available_segments,
|
||||
category_name,
|
||||
project_materials,
|
||||
used_segment_ids,
|
||||
).await
|
||||
}
|
||||
SegmentMatchingRule::RandomMatch => {
|
||||
self.match_randomly(
|
||||
track_segment,
|
||||
available_segments,
|
||||
project_materials,
|
||||
used_segment_ids,
|
||||
).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据AI分类匹配素材
|
||||
async fn match_by_ai_classification(
|
||||
&self,
|
||||
track_segment: &TrackSegment,
|
||||
available_segments: &[(MaterialSegment, String)],
|
||||
target_category: &str,
|
||||
project_materials: &[Material],
|
||||
used_segment_ids: &mut HashSet<String>,
|
||||
) -> Result<SegmentMatch, String> {
|
||||
// 计算目标时长(微秒转秒)
|
||||
let target_duration = track_segment.duration as f64 / 1_000_000.0;
|
||||
|
||||
// 过滤出匹配分类的片段
|
||||
let category_segments: Vec<_> = available_segments
|
||||
.iter()
|
||||
.filter(|(segment, category)| {
|
||||
category == target_category && !used_segment_ids.contains(&segment.id)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if category_segments.is_empty() {
|
||||
return Err(format!("没有找到分类为'{}'的可用素材片段", target_category));
|
||||
}
|
||||
|
||||
// 按模特分组
|
||||
let mut model_groups: HashMap<Option<String>, Vec<_>> = HashMap::new();
|
||||
for (segment, category) in category_segments {
|
||||
if let Some(material) = project_materials.iter().find(|m| m.id == segment.material_id) {
|
||||
let model_id = material.model_id.clone();
|
||||
model_groups.entry(model_id).or_default().push((segment, category, material));
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试每个模特的素材
|
||||
for (model_id, model_segments) in model_groups {
|
||||
if let Some(best_match) = self.find_best_duration_match(
|
||||
&model_segments,
|
||||
target_duration,
|
||||
) {
|
||||
let (segment, _category, material) = best_match;
|
||||
|
||||
// 标记为已使用
|
||||
used_segment_ids.insert(segment.id.clone());
|
||||
|
||||
return Ok(SegmentMatch {
|
||||
track_segment_id: track_segment.id.clone(),
|
||||
track_segment_name: track_segment.name.clone(),
|
||||
material_segment_id: segment.id.clone(),
|
||||
material_segment: (*segment).clone(),
|
||||
material_name: material.name.clone(),
|
||||
model_name: model_id.clone(),
|
||||
match_score: segment.duration_match_score(target_duration),
|
||||
match_reason: format!("AI分类匹配: {}", target_category),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("没有找到满足时长要求的分类为'{}'的素材片段", target_category))
|
||||
}
|
||||
|
||||
/// 随机匹配素材
|
||||
async fn match_randomly(
|
||||
&self,
|
||||
track_segment: &TrackSegment,
|
||||
available_segments: &[(MaterialSegment, String)],
|
||||
project_materials: &[Material],
|
||||
used_segment_ids: &mut HashSet<String>,
|
||||
) -> Result<SegmentMatch, String> {
|
||||
// 计算目标时长(微秒转秒)
|
||||
let target_duration = track_segment.duration as f64 / 1_000_000.0;
|
||||
|
||||
// 过滤出未使用的片段
|
||||
let unused_segments: Vec<_> = available_segments
|
||||
.iter()
|
||||
.filter(|(segment, _)| !used_segment_ids.contains(&segment.id))
|
||||
.collect();
|
||||
|
||||
if unused_segments.is_empty() {
|
||||
return Err("没有可用的素材片段进行随机匹配".to_string());
|
||||
}
|
||||
|
||||
// 按模特分组
|
||||
let mut model_groups: HashMap<Option<String>, Vec<_>> = HashMap::new();
|
||||
for (segment, category) in unused_segments {
|
||||
if let Some(material) = project_materials.iter().find(|m| m.id == segment.material_id) {
|
||||
let model_id = material.model_id.clone();
|
||||
model_groups.entry(model_id).or_default().push((segment, category, material));
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试每个模特的素材
|
||||
for (model_id, model_segments) in model_groups {
|
||||
if let Some(best_match) = self.find_best_duration_match(
|
||||
&model_segments,
|
||||
target_duration,
|
||||
) {
|
||||
let (segment, category, material) = best_match;
|
||||
|
||||
// 标记为已使用
|
||||
used_segment_ids.insert(segment.id.clone());
|
||||
|
||||
return Ok(SegmentMatch {
|
||||
track_segment_id: track_segment.id.clone(),
|
||||
track_segment_name: track_segment.name.clone(),
|
||||
material_segment_id: segment.id.clone(),
|
||||
material_segment: (*segment).clone(),
|
||||
material_name: material.name.clone(),
|
||||
model_name: model_id.clone(),
|
||||
match_score: segment.duration_match_score(target_duration),
|
||||
match_reason: format!("随机匹配: {}", category),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err("没有找到满足时长要求的素材片段进行随机匹配".to_string())
|
||||
}
|
||||
|
||||
/// 在给定的片段中找到最佳时长匹配
|
||||
fn find_best_duration_match<'a>(
|
||||
&self,
|
||||
segments: &'a [(&MaterialSegment, &String, &Material)],
|
||||
target_duration: f64,
|
||||
) -> Option<(&'a MaterialSegment, &'a String, &'a Material)> {
|
||||
let mut best_match = None;
|
||||
let mut best_score = 0.0;
|
||||
|
||||
for (segment, category, material) in segments {
|
||||
if segment.meets_duration_requirement(target_duration) {
|
||||
let score = segment.duration_match_score(target_duration);
|
||||
if score > best_score {
|
||||
best_score = score;
|
||||
best_match = Some((*segment, *category, *material));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
best_match
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub mod template_cache_service;
|
||||
pub mod performance_monitor;
|
||||
pub mod enhanced_template_import_service;
|
||||
pub mod project_template_binding_service;
|
||||
pub mod material_matching_service;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests;
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* 素材匹配服务测试
|
||||
* 遵循 Tauri 开发规范的测试设计原则
|
||||
*/
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::business::services::material_matching_service::{
|
||||
MaterialMatchingService, MaterialMatchingRequest
|
||||
};
|
||||
use crate::business::services::template_service::TemplateService;
|
||||
use crate::data::repositories::{
|
||||
material_repository::MaterialRepository,
|
||||
video_classification_repository::VideoClassificationRepository,
|
||||
};
|
||||
use crate::data::models::{
|
||||
material::{Material, MaterialType, ProcessingStatus, MaterialMetadata, MaterialSegment},
|
||||
template::{Template, CanvasConfig, TrackSegment, SegmentMatchingRule, Track, TrackType},
|
||||
video_classification::{VideoClassificationRecord, ClassificationStatus},
|
||||
};
|
||||
use crate::infrastructure::database::Database;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use chrono::Utc;
|
||||
|
||||
/// 创建测试数据库
|
||||
async fn create_test_database() -> Arc<Database> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let db_path = temp_dir.path().join("test.db");
|
||||
let database = Database::new(db_path.to_str().unwrap()).unwrap();
|
||||
Arc::new(database)
|
||||
}
|
||||
|
||||
/// 创建测试素材
|
||||
fn create_test_material(project_id: &str, model_id: Option<String>) -> Material {
|
||||
let now = Utc::now();
|
||||
Material {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
model_id,
|
||||
name: "测试素材".to_string(),
|
||||
original_path: "/test/path.mp4".to_string(),
|
||||
file_size: 1024000,
|
||||
md5_hash: "test_hash".to_string(),
|
||||
material_type: MaterialType::Video,
|
||||
processing_status: ProcessingStatus::Completed,
|
||||
metadata: MaterialMetadata::None,
|
||||
scene_detection: None,
|
||||
segments: vec![
|
||||
MaterialSegment::new(
|
||||
"material_1".to_string(),
|
||||
0,
|
||||
0.0,
|
||||
30.0,
|
||||
"/test/segment_0.mp4".to_string(),
|
||||
512000,
|
||||
)
|
||||
],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
processed_at: Some(now),
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建测试模板
|
||||
fn create_test_template() -> Template {
|
||||
let now = Utc::now();
|
||||
let mut template = Template {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "测试模板".to_string(),
|
||||
description: Some("测试模板描述".to_string()),
|
||||
canvas_config: CanvasConfig {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
ratio: "16:9".to_string(),
|
||||
},
|
||||
duration: 60_000_000, // 60秒(微秒)
|
||||
fps: 30.0,
|
||||
materials: Vec::new(),
|
||||
tracks: Vec::new(),
|
||||
import_status: crate::data::models::template::ImportStatus::Completed,
|
||||
source_file_path: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
// 添加测试轨道和片段
|
||||
let track_id = uuid::Uuid::new_v4().to_string();
|
||||
let segment = TrackSegment {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
track_id: track_id.clone(),
|
||||
template_material_id: None,
|
||||
name: "测试片段".to_string(),
|
||||
start_time: 0,
|
||||
end_time: 30_000_000, // 30秒(微秒)
|
||||
duration: 30_000_000,
|
||||
segment_index: 0,
|
||||
properties: None,
|
||||
matching_rule: SegmentMatchingRule::AiClassification {
|
||||
category_id: "test_category".to_string(),
|
||||
category_name: "全身".to_string(),
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
let track = Track {
|
||||
id: track_id,
|
||||
template_id: template.id.clone(),
|
||||
name: "视频轨道".to_string(),
|
||||
track_type: TrackType::Video,
|
||||
track_index: 0,
|
||||
segments: vec![segment],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
template.tracks.push(track);
|
||||
template
|
||||
}
|
||||
|
||||
/// 创建测试分类记录
|
||||
fn create_test_classification_record(segment_id: &str, material_id: &str, project_id: &str) -> VideoClassificationRecord {
|
||||
let now = Utc::now();
|
||||
VideoClassificationRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
segment_id: segment_id.to_string(),
|
||||
material_id: material_id.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
category: "全身".to_string(),
|
||||
confidence: 0.95,
|
||||
reasoning: "测试分类".to_string(),
|
||||
features: vec!["feature1".to_string(), "feature2".to_string()],
|
||||
product_match: true,
|
||||
quality_score: 0.9,
|
||||
gemini_file_uri: Some("test_uri".to_string()),
|
||||
raw_response: Some("test_response".to_string()),
|
||||
status: ClassificationStatus::Completed,
|
||||
error_message: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_material_matching_service_creation() {
|
||||
let database = create_test_database().await;
|
||||
let material_repo = Arc::new(MaterialRepository::new(database.get_connection()).unwrap());
|
||||
let template_service = Arc::new(TemplateService::new(database.clone()));
|
||||
let video_classification_repo = Arc::new(VideoClassificationRepository::new(database.clone()));
|
||||
|
||||
let service = MaterialMatchingService::new(
|
||||
material_repo,
|
||||
template_service,
|
||||
video_classification_repo,
|
||||
);
|
||||
|
||||
// 测试服务创建成功
|
||||
assert!(true); // 如果能创建服务实例,说明构造函数正常
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_material_matching_with_no_materials() {
|
||||
let database = create_test_database().await;
|
||||
let material_repo = Arc::new(MaterialRepository::new(database.get_connection()).unwrap());
|
||||
let template_service = Arc::new(TemplateService::new(database.clone()));
|
||||
let video_classification_repo = Arc::new(VideoClassificationRepository::new(database.clone()));
|
||||
|
||||
let service = MaterialMatchingService::new(
|
||||
material_repo,
|
||||
template_service,
|
||||
video_classification_repo,
|
||||
);
|
||||
|
||||
// 创建测试模板
|
||||
let template = create_test_template();
|
||||
template_service.save_template(&template).await.unwrap();
|
||||
|
||||
let request = MaterialMatchingRequest {
|
||||
project_id: "test_project".to_string(),
|
||||
template_id: template.id.clone(),
|
||||
binding_id: "test_binding".to_string(),
|
||||
overwrite_existing: false,
|
||||
};
|
||||
|
||||
let result = service.match_materials(request).await;
|
||||
|
||||
// 应该成功返回结果,但没有匹配的片段
|
||||
assert!(result.is_ok());
|
||||
let matching_result = result.unwrap();
|
||||
assert_eq!(matching_result.matches.len(), 0);
|
||||
assert_eq!(matching_result.failed_segments.len(), 1); // 一个片段匹配失败
|
||||
assert_eq!(matching_result.statistics.success_rate, 0.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_material_matching_with_successful_match() {
|
||||
let database = create_test_database().await;
|
||||
let material_repo = Arc::new(MaterialRepository::new(database.get_connection()).unwrap());
|
||||
let template_service = Arc::new(TemplateService::new(database.clone()));
|
||||
let video_classification_repo = Arc::new(VideoClassificationRepository::new(database.clone()));
|
||||
|
||||
let service = MaterialMatchingService::new(
|
||||
material_repo,
|
||||
template_service,
|
||||
video_classification_repo,
|
||||
);
|
||||
|
||||
let project_id = "test_project";
|
||||
|
||||
// 创建测试素材
|
||||
let material = create_test_material(project_id, Some("test_model".to_string()));
|
||||
material_repo.create(&material).unwrap();
|
||||
|
||||
// 创建测试模板
|
||||
let template = create_test_template();
|
||||
template_service.save_template(&template).await.unwrap();
|
||||
|
||||
// 创建测试分类记录
|
||||
let segment_id = &material.segments[0].id;
|
||||
let classification_record = create_test_classification_record(segment_id, &material.id, project_id);
|
||||
video_classification_repo.create(classification_record).await.unwrap();
|
||||
|
||||
let request = MaterialMatchingRequest {
|
||||
project_id: project_id.to_string(),
|
||||
template_id: template.id.clone(),
|
||||
binding_id: "test_binding".to_string(),
|
||||
overwrite_existing: false,
|
||||
};
|
||||
|
||||
let result = service.match_materials(request).await;
|
||||
|
||||
// 应该成功匹配
|
||||
assert!(result.is_ok());
|
||||
let matching_result = result.unwrap();
|
||||
assert_eq!(matching_result.matches.len(), 1);
|
||||
assert_eq!(matching_result.failed_segments.len(), 0);
|
||||
assert_eq!(matching_result.statistics.success_rate, 1.0);
|
||||
assert_eq!(matching_result.statistics.used_materials, 1);
|
||||
assert_eq!(matching_result.statistics.used_models, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_material_matching_with_insufficient_duration() {
|
||||
let database = create_test_database().await;
|
||||
let material_repo = Arc::new(MaterialRepository::new(database.get_connection()).unwrap());
|
||||
let template_service = Arc::new(TemplateService::new(database.clone()));
|
||||
let video_classification_repo = Arc::new(VideoClassificationRepository::new(database.clone()));
|
||||
|
||||
let service = MaterialMatchingService::new(
|
||||
material_repo,
|
||||
template_service,
|
||||
video_classification_repo,
|
||||
);
|
||||
|
||||
let project_id = "test_project";
|
||||
|
||||
// 创建时长不足的测试素材(只有10秒,但模板需要30秒)
|
||||
let mut material = create_test_material(project_id, Some("test_model".to_string()));
|
||||
material.segments[0].duration = 10.0; // 10秒,不足30秒
|
||||
material_repo.create(&material).unwrap();
|
||||
|
||||
// 创建测试模板
|
||||
let template = create_test_template();
|
||||
template_service.save_template(&template).await.unwrap();
|
||||
|
||||
// 创建测试分类记录
|
||||
let segment_id = &material.segments[0].id;
|
||||
let classification_record = create_test_classification_record(segment_id, &material.id, project_id);
|
||||
video_classification_repo.create(classification_record).await.unwrap();
|
||||
|
||||
let request = MaterialMatchingRequest {
|
||||
project_id: project_id.to_string(),
|
||||
template_id: template.id.clone(),
|
||||
binding_id: "test_binding".to_string(),
|
||||
overwrite_existing: false,
|
||||
};
|
||||
|
||||
let result = service.match_materials(request).await;
|
||||
|
||||
// 应该匹配失败,因为时长不足
|
||||
assert!(result.is_ok());
|
||||
let matching_result = result.unwrap();
|
||||
assert_eq!(matching_result.matches.len(), 0);
|
||||
assert_eq!(matching_result.failed_segments.len(), 1);
|
||||
assert_eq!(matching_result.statistics.success_rate, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod draft_parser_tests;
|
||||
pub mod cloud_upload_service_tests;
|
||||
pub mod template_service_tests;
|
||||
pub mod template_integration_tests;
|
||||
pub mod material_matching_service_tests;
|
||||
|
||||
// 测试工具函数
|
||||
pub mod test_utils {
|
||||
|
||||
@@ -365,4 +365,26 @@ impl MaterialSegment {
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查片段是否满足最小时长要求
|
||||
pub fn meets_duration_requirement(&self, required_duration: f64) -> bool {
|
||||
self.duration >= required_duration
|
||||
}
|
||||
|
||||
/// 计算与目标时长的匹配度(越接近越好,返回0.0-1.0)
|
||||
pub fn duration_match_score(&self, target_duration: f64) -> f64 {
|
||||
if self.duration < target_duration {
|
||||
return 0.0; // 时长不足,不匹配
|
||||
}
|
||||
|
||||
// 计算匹配度:时长越接近目标时长,分数越高
|
||||
let excess_ratio = (self.duration - target_duration) / target_duration;
|
||||
if excess_ratio <= 0.1 {
|
||||
1.0 // 超出10%以内,完美匹配
|
||||
} else if excess_ratio <= 0.5 {
|
||||
0.8 - (excess_ratio - 0.1) * 2.0 // 超出10%-50%,线性递减
|
||||
} else {
|
||||
0.3 // 超出50%以上,低匹配度
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +181,10 @@ pub fn run() {
|
||||
commands::project_template_binding_commands::get_primary_template_binding_for_project,
|
||||
commands::project_template_binding_commands::set_primary_template_for_project,
|
||||
commands::project_template_binding_commands::check_project_template_binding_exists,
|
||||
// 素材匹配命令
|
||||
commands::material_matching_commands::execute_material_matching,
|
||||
commands::material_matching_commands::get_project_material_stats_for_matching,
|
||||
commands::material_matching_commands::validate_template_binding_for_matching,
|
||||
// 测试命令
|
||||
commands::test_commands::test_database_connection,
|
||||
commands::test_commands::test_template_table,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 素材匹配相关的 Tauri 命令
|
||||
* 遵循 Tauri 开发规范的 API 设计原则
|
||||
*/
|
||||
|
||||
use tauri::{command, State};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::business::services::material_matching_service::{
|
||||
MaterialMatchingService, MaterialMatchingRequest, MaterialMatchingResult
|
||||
};
|
||||
use crate::business::services::template_service::TemplateService;
|
||||
use crate::data::repositories::{
|
||||
material_repository::MaterialRepository,
|
||||
video_classification_repository::VideoClassificationRepository,
|
||||
};
|
||||
use crate::infrastructure::database::Database;
|
||||
|
||||
/// 执行素材匹配
|
||||
#[command]
|
||||
pub async fn execute_material_matching(
|
||||
request: MaterialMatchingRequest,
|
||||
database: State<'_, Arc<Database>>,
|
||||
) -> Result<MaterialMatchingResult, String> {
|
||||
// 创建服务实例
|
||||
let material_repo = Arc::new(
|
||||
MaterialRepository::new(database.get_connection())
|
||||
.map_err(|e| format!("创建素材仓库失败: {}", e))?
|
||||
);
|
||||
|
||||
let template_service = Arc::new(TemplateService::new(database.inner().clone()));
|
||||
|
||||
let video_classification_repo = Arc::new(
|
||||
VideoClassificationRepository::new(database.inner().clone())
|
||||
);
|
||||
|
||||
let matching_service = MaterialMatchingService::new(
|
||||
material_repo,
|
||||
template_service,
|
||||
video_classification_repo,
|
||||
);
|
||||
|
||||
// 执行匹配
|
||||
matching_service.match_materials(request)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取项目的可用素材统计信息
|
||||
#[command]
|
||||
pub async fn get_project_material_stats_for_matching(
|
||||
project_id: String,
|
||||
database: State<'_, Arc<Database>>,
|
||||
) -> Result<ProjectMaterialMatchingStats, String> {
|
||||
let material_repo = MaterialRepository::new(database.get_connection())
|
||||
.map_err(|e| format!("创建素材仓库失败: {}", e))?;
|
||||
|
||||
let video_classification_repo = VideoClassificationRepository::new(database.inner().clone());
|
||||
|
||||
// 获取项目的所有素材
|
||||
let materials = material_repo.get_by_project_id(&project_id)
|
||||
.map_err(|e| format!("获取项目素材失败: {}", e))?;
|
||||
|
||||
let mut total_segments = 0;
|
||||
let mut classified_segments = 0;
|
||||
let mut available_models = std::collections::HashSet::new();
|
||||
let mut available_categories = std::collections::HashSet::new();
|
||||
|
||||
for material in &materials {
|
||||
total_segments += material.segments.len();
|
||||
|
||||
// 获取分类记录
|
||||
let classification_records = video_classification_repo.get_by_material_id(&material.id)
|
||||
.await
|
||||
.map_err(|e| format!("获取分类记录失败: {}", e))?;
|
||||
|
||||
// 统计已分类的片段
|
||||
for segment in &material.segments {
|
||||
if classification_records.iter().any(|r| r.segment_id == segment.id) {
|
||||
classified_segments += 1;
|
||||
|
||||
// 记录分类类别
|
||||
if let Some(record) = classification_records.iter().find(|r| r.segment_id == segment.id) {
|
||||
available_categories.insert(record.category.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 记录模特
|
||||
if let Some(model_id) = &material.model_id {
|
||||
available_models.insert(model_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ProjectMaterialMatchingStats {
|
||||
project_id,
|
||||
total_materials: materials.len() as u32,
|
||||
total_segments: total_segments as u32,
|
||||
classified_segments: classified_segments as u32,
|
||||
available_models: available_models.len() as u32,
|
||||
available_categories: available_categories.into_iter().collect(),
|
||||
classification_rate: if total_segments > 0 {
|
||||
classified_segments as f64 / total_segments as f64
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// 验证模板绑定是否可以进行素材匹配
|
||||
#[command]
|
||||
pub async fn validate_template_binding_for_matching(
|
||||
binding_id: String,
|
||||
_database: State<'_, Arc<Database>>,
|
||||
) -> Result<TemplateBindingMatchingValidation, String> {
|
||||
// 这里需要根据binding_id获取模板信息并验证
|
||||
// 暂时返回一个简单的验证结果
|
||||
Ok(TemplateBindingMatchingValidation {
|
||||
binding_id,
|
||||
is_valid: true,
|
||||
validation_errors: Vec::new(),
|
||||
total_segments: 0,
|
||||
matchable_segments: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// 项目素材匹配统计信息
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ProjectMaterialMatchingStats {
|
||||
pub project_id: String,
|
||||
pub total_materials: u32,
|
||||
pub total_segments: u32,
|
||||
pub classified_segments: u32,
|
||||
pub available_models: u32,
|
||||
pub available_categories: Vec<String>,
|
||||
pub classification_rate: f64,
|
||||
}
|
||||
|
||||
/// 模板绑定匹配验证结果
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TemplateBindingMatchingValidation {
|
||||
pub binding_id: String,
|
||||
pub is_valid: bool,
|
||||
pub validation_errors: Vec<String>,
|
||||
pub total_segments: u32,
|
||||
pub matchable_segments: u32,
|
||||
}
|
||||
@@ -9,3 +9,4 @@ pub mod template_commands;
|
||||
pub mod test_commands;
|
||||
pub mod debug_commands;
|
||||
pub mod project_template_binding_commands;
|
||||
pub mod material_matching_commands;
|
||||
|
||||
435
apps/desktop/src/components/MaterialMatchingResultDialog.tsx
Normal file
435
apps/desktop/src/components/MaterialMatchingResultDialog.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* 素材匹配结果对话框组件
|
||||
* 遵循前端开发规范的组件设计原则
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
X,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
Users,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
MaterialMatchingResult,
|
||||
SegmentMatch,
|
||||
FailedSegmentMatch,
|
||||
MatchingStatistics
|
||||
} from '../types/materialMatching';
|
||||
import { SegmentMatchingRuleHelper } from '../types/template';
|
||||
import { LoadingSpinner } from './LoadingSpinner';
|
||||
|
||||
interface MaterialMatchingResultDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
result: MaterialMatchingResult | null;
|
||||
loading?: boolean;
|
||||
onApplyResult?: (result: MaterialMatchingResult) => void;
|
||||
onRetryMatching?: () => void;
|
||||
}
|
||||
|
||||
export const MaterialMatchingResultDialog: React.FC<MaterialMatchingResultDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
result,
|
||||
loading = false,
|
||||
onApplyResult,
|
||||
onRetryMatching,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'matches' | 'failures'>('overview');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleApplyResult = () => {
|
||||
if (result && onApplyResult) {
|
||||
onApplyResult(result);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (microseconds: number): string => {
|
||||
const seconds = microseconds / 1000000;
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
} else {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds.toFixed(1)}s`;
|
||||
}
|
||||
};
|
||||
|
||||
const getSuccessRateColor = (rate: number): string => {
|
||||
if (rate >= 0.8) return 'text-green-600';
|
||||
if (rate >= 0.6) return 'text-yellow-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">素材匹配结果</h2>
|
||||
{result && (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
模板: {result.template_id} | 项目: {result.project_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<LoadingSpinner size="large" />
|
||||
<p className="text-gray-600 mt-4">正在匹配素材...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : result ? (
|
||||
<>
|
||||
{/* 统计概览 */}
|
||||
<div className="p-6 bg-gray-50 border-b border-gray-200">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{result.statistics.total_segments}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">总片段数</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{result.statistics.matched_segments}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">匹配成功</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{result.statistics.failed_segments}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">匹配失败</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`text-2xl font-bold ${getSuccessRateColor(result.statistics.success_rate)}`}>
|
||||
{(result.statistics.success_rate * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">成功率</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 成功率进度条 */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between text-sm text-gray-600 mb-2">
|
||||
<span>匹配进度</span>
|
||||
<span>{result.statistics.matched_segments}/{result.statistics.total_segments}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
result.statistics.success_rate >= 0.8 ? 'bg-green-500' :
|
||||
result.statistics.success_rate >= 0.6 ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${result.statistics.success_rate * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选项卡 */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex space-x-8 px-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'overview'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
<span>概览</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('matches')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'matches'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>成功匹配 ({result.matches.length})</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('failures')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'failures'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span>匹配失败 ({result.failed_segments.length})</span>
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 选项卡内容 */}
|
||||
<div className="flex-1 overflow-y-auto max-h-96 p-6">
|
||||
{activeTab === 'overview' && (
|
||||
<OverviewTab statistics={result.statistics} />
|
||||
)}
|
||||
{activeTab === 'matches' && (
|
||||
<MatchesTab matches={result.matches} formatDuration={formatDuration} />
|
||||
)}
|
||||
{activeTab === 'failures' && (
|
||||
<FailuresTab failures={result.failed_segments} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<AlertTriangle className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600">没有匹配结果</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作按钮 */}
|
||||
{result && !loading && (
|
||||
<div className="flex items-center justify-between p-6 border-t border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
使用了 {result.statistics.used_materials} 个素材,涉及 {result.statistics.used_models} 个模特
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
{onRetryMatching && (
|
||||
<button
|
||||
onClick={onRetryMatching}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
重新匹配
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
{onApplyResult && result.statistics.matched_segments > 0 && (
|
||||
<button
|
||||
onClick={handleApplyResult}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
应用匹配结果
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 概览选项卡组件
|
||||
const OverviewTab: React.FC<{ statistics: MatchingStatistics }> = ({ statistics }) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 匹配统计 */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
||||
<BarChart3 className="w-5 h-5 mr-2" />
|
||||
匹配统计
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">总片段数:</span>
|
||||
<span className="font-medium">{statistics.total_segments}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">匹配成功:</span>
|
||||
<span className="font-medium text-green-600">{statistics.matched_segments}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">匹配失败:</span>
|
||||
<span className="font-medium text-red-600">{statistics.failed_segments}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">成功率:</span>
|
||||
<span className="font-medium">{(statistics.success_rate * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 资源使用 */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
||||
<Users className="w-5 h-5 mr-2" />
|
||||
资源使用
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">使用素材:</span>
|
||||
<span className="font-medium">{statistics.used_materials} 个</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">涉及模特:</span>
|
||||
<span className="font-medium">{statistics.used_models} 个</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 匹配质量评估 */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
||||
<TrendingUp className="w-5 h-5 mr-2" />
|
||||
匹配质量评估
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{statistics.success_rate >= 0.8 && (
|
||||
<div className="flex items-center text-green-600">
|
||||
<CheckCircle className="w-5 h-5 mr-2" />
|
||||
<span>匹配质量优秀,大部分片段都找到了合适的素材</span>
|
||||
</div>
|
||||
)}
|
||||
{statistics.success_rate >= 0.6 && statistics.success_rate < 0.8 && (
|
||||
<div className="flex items-center text-yellow-600">
|
||||
<AlertTriangle className="w-5 h-5 mr-2" />
|
||||
<span>匹配质量良好,但仍有改进空间</span>
|
||||
</div>
|
||||
)}
|
||||
{statistics.success_rate < 0.6 && (
|
||||
<div className="flex items-center text-red-600">
|
||||
<XCircle className="w-5 h-5 mr-2" />
|
||||
<span>匹配质量较低,建议检查素材分类和模板设置</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 成功匹配选项卡组件
|
||||
const MatchesTab: React.FC<{
|
||||
matches: SegmentMatch[];
|
||||
formatDuration: (microseconds: number) => string;
|
||||
}> = ({ matches, formatDuration }) => {
|
||||
if (matches.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<XCircle className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600">没有成功匹配的片段</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{matches.map((match, index) => (
|
||||
<div key={match.track_segment_id} className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
<h4 className="font-medium text-gray-900">{match.track_segment_name}</h4>
|
||||
<span className="text-sm text-gray-500">#{index + 1}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-600">匹配素材:</span>
|
||||
<span className="ml-2 font-medium">{match.material_name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">时长:</span>
|
||||
<span className="ml-2 font-medium">{formatDuration(match.material_segment.duration * 1000000)}</span>
|
||||
</div>
|
||||
{match.model_name && (
|
||||
<div>
|
||||
<span className="text-gray-600">模特:</span>
|
||||
<span className="ml-2 font-medium">{match.model_name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-gray-600">匹配度:</span>
|
||||
<span className="ml-2 font-medium">{(match.match_score * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<span className="font-medium">匹配原因:</span> {match.match_reason}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 匹配失败选项卡组件
|
||||
const FailuresTab: React.FC<{ failures: FailedSegmentMatch[] }> = ({ failures }) => {
|
||||
if (failures.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="w-16 h-16 text-green-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600">所有片段都匹配成功!</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 按失败原因分组
|
||||
const groupedFailures = failures.reduce((acc, failure) => {
|
||||
if (!acc[failure.failure_reason]) {
|
||||
acc[failure.failure_reason] = [];
|
||||
}
|
||||
acc[failure.failure_reason].push(failure);
|
||||
return acc;
|
||||
}, {} as Record<string, FailedSegmentMatch[]>);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(groupedFailures).map(([reason, failureList]) => (
|
||||
<div key={reason} className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<XCircle className="w-5 h-5 text-red-500" />
|
||||
<h4 className="font-medium text-gray-900">{reason}</h4>
|
||||
<span className="text-sm text-gray-500">({failureList.length} 个片段)</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{failureList.map((failure) => (
|
||||
<div key={failure.track_segment_id} className="flex items-center justify-between py-2 px-3 bg-red-50 rounded">
|
||||
<span className="text-sm font-medium text-gray-900">{failure.track_segment_name}</span>
|
||||
<span className="text-xs text-gray-600">
|
||||
规则: {SegmentMatchingRuleHelper.getDisplayName(failure.matching_rule)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,18 +4,19 @@
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Power,
|
||||
PowerOff,
|
||||
Star,
|
||||
import {
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Power,
|
||||
PowerOff,
|
||||
Star,
|
||||
StarOff,
|
||||
Search,
|
||||
Filter,
|
||||
CheckSquare,
|
||||
Square
|
||||
Square,
|
||||
Shuffle
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
ProjectTemplateBindingDetail,
|
||||
@@ -45,6 +46,7 @@ interface ProjectTemplateBindingListProps {
|
||||
onBatchDelete?: (ids: string[]) => void;
|
||||
onToggleStatus?: (id: string) => void;
|
||||
onSetPrimary?: (projectId: string, templateId: string) => void;
|
||||
onMatchMaterials?: (binding: ProjectTemplateBindingDetail) => void;
|
||||
searchQuery?: string;
|
||||
onSearchChange?: (query: string) => void;
|
||||
typeFilter?: BindingType | '';
|
||||
@@ -66,6 +68,7 @@ export const ProjectTemplateBindingList: React.FC<ProjectTemplateBindingListProp
|
||||
onBatchDelete,
|
||||
onToggleStatus,
|
||||
onSetPrimary,
|
||||
onMatchMaterials,
|
||||
searchQuery = '',
|
||||
onSearchChange,
|
||||
typeFilter = '',
|
||||
@@ -291,6 +294,7 @@ export const ProjectTemplateBindingList: React.FC<ProjectTemplateBindingListProp
|
||||
onDelete={(id, name) => handleDeleteConfirm(id, name)}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onSetPrimary={onSetPrimary}
|
||||
onMatchMaterials={onMatchMaterials}
|
||||
showSelection={!!onSelectionChange}
|
||||
/>
|
||||
))}
|
||||
@@ -324,6 +328,7 @@ interface BindingListItemProps {
|
||||
onDelete?: (id: string, name: string) => void;
|
||||
onToggleStatus?: (id: string) => void;
|
||||
onSetPrimary?: (projectId: string, templateId: string) => void;
|
||||
onMatchMaterials?: (binding: ProjectTemplateBindingDetail) => void;
|
||||
showSelection: boolean;
|
||||
}
|
||||
|
||||
@@ -335,6 +340,7 @@ const BindingListItem: React.FC<BindingListItemProps> = ({
|
||||
onDelete,
|
||||
onToggleStatus,
|
||||
onSetPrimary,
|
||||
onMatchMaterials,
|
||||
showSelection,
|
||||
}) => {
|
||||
const { binding } = detail;
|
||||
@@ -435,6 +441,16 @@ const BindingListItem: React.FC<BindingListItemProps> = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onMatchMaterials && binding.is_active && (
|
||||
<button
|
||||
onClick={() => onMatchMaterials(detail)}
|
||||
className="text-gray-400 hover:text-purple-500 transition-colors"
|
||||
title="匹配素材"
|
||||
>
|
||||
<Shuffle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(detail)}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { AiAnalysisLogViewer } from '../components/AiAnalysisLogViewer';
|
||||
import MaterialCardSkeleton from '../components/MaterialCardSkeleton';
|
||||
import { ProjectTemplateBindingList } from '../components/ProjectTemplateBindingList';
|
||||
import { ProjectTemplateBindingForm } from '../components/ProjectTemplateBindingForm';
|
||||
import { MaterialMatchingResultDialog } from '../components/MaterialMatchingResultDialog';
|
||||
import { useProjectTemplateBindingStore } from '../stores/projectTemplateBindingStore';
|
||||
import { useTemplateStore } from '../stores/templateStore';
|
||||
import {
|
||||
@@ -25,6 +26,8 @@ import {
|
||||
CreateProjectTemplateBindingRequest,
|
||||
UpdateProjectTemplateBindingRequest
|
||||
} from '../types/projectTemplateBinding';
|
||||
import { MaterialMatchingService } from '../services/materialMatchingService';
|
||||
import { MaterialMatchingResult, MaterialMatchingRequest } from '../types/materialMatching';
|
||||
|
||||
/**
|
||||
* 项目详情页面组件
|
||||
@@ -83,6 +86,12 @@ export const ProjectDetails: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'materials' | 'templates' | 'debug' | 'ai-logs'>('materials');
|
||||
const [_batchClassificationResult, setBatchClassificationResult] = useState<ProjectBatchClassificationResponse | null>(null);
|
||||
|
||||
// 素材匹配状态
|
||||
const [showMatchingResultDialog, setShowMatchingResultDialog] = useState(false);
|
||||
const [matchingResult, setMatchingResult] = useState<MaterialMatchingResult | null>(null);
|
||||
const [matchingLoading, setMatchingLoading] = useState(false);
|
||||
const [currentMatchingBinding, setCurrentMatchingBinding] = useState<ProjectTemplateBindingDetail | null>(null);
|
||||
|
||||
// 加载项目详情
|
||||
useEffect(() => {
|
||||
if (!projects.length) {
|
||||
@@ -275,6 +284,65 @@ export const ProjectDetails: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 素材匹配处理函数
|
||||
const handleMatchMaterials = async (binding: ProjectTemplateBindingDetail) => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
setCurrentMatchingBinding(binding);
|
||||
setMatchingLoading(true);
|
||||
setShowMatchingResultDialog(true);
|
||||
|
||||
const request: MaterialMatchingRequest = {
|
||||
project_id: project.id,
|
||||
template_id: binding.binding.template_id,
|
||||
binding_id: binding.binding.id,
|
||||
overwrite_existing: false,
|
||||
};
|
||||
|
||||
const result = await MaterialMatchingService.executeMatching(request);
|
||||
setMatchingResult(result);
|
||||
} catch (error) {
|
||||
console.error('素材匹配失败:', error);
|
||||
alert(`素材匹配失败: ${error}`);
|
||||
setShowMatchingResultDialog(false);
|
||||
} finally {
|
||||
setMatchingLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyMatchingResult = async (result: MaterialMatchingResult) => {
|
||||
try {
|
||||
// 这里可以添加应用匹配结果的逻辑
|
||||
// 例如更新模板绑定的匹配状态等
|
||||
console.log('应用匹配结果:', result);
|
||||
|
||||
// 关闭对话框
|
||||
setShowMatchingResultDialog(false);
|
||||
setMatchingResult(null);
|
||||
setCurrentMatchingBinding(null);
|
||||
|
||||
// 可以显示成功提示
|
||||
alert('匹配结果已应用');
|
||||
} catch (error) {
|
||||
console.error('应用匹配结果失败:', error);
|
||||
alert(`应用匹配结果失败: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetryMatching = () => {
|
||||
if (currentMatchingBinding) {
|
||||
handleMatchMaterials(currentMatchingBinding);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseMatchingDialog = () => {
|
||||
setShowMatchingResultDialog(false);
|
||||
setMatchingResult(null);
|
||||
setCurrentMatchingBinding(null);
|
||||
setMatchingLoading(false);
|
||||
};
|
||||
|
||||
// 素材编辑处理函数
|
||||
const handleEditMaterial = (material: Material) => {
|
||||
setEditingMaterial(material);
|
||||
@@ -633,6 +701,7 @@ export const ProjectDetails: React.FC = () => {
|
||||
}}
|
||||
onToggleStatus={handleToggleBindingStatus}
|
||||
onSetPrimary={handleSetPrimaryTemplate}
|
||||
onMatchMaterials={handleMatchMaterials}
|
||||
searchQuery={bindingFilters.search || ''}
|
||||
onSearchChange={(query) => bindingActions.setFilters({ search: query })}
|
||||
typeFilter={bindingFilters.binding_type || ''}
|
||||
@@ -698,6 +767,16 @@ export const ProjectDetails: React.FC = () => {
|
||||
material={editingMaterial}
|
||||
onSave={handleMaterialSave}
|
||||
/>
|
||||
|
||||
{/* 素材匹配结果对话框 */}
|
||||
<MaterialMatchingResultDialog
|
||||
isOpen={showMatchingResultDialog}
|
||||
onClose={handleCloseMatchingDialog}
|
||||
result={matchingResult}
|
||||
loading={matchingLoading}
|
||||
onApplyResult={handleApplyMatchingResult}
|
||||
onRetryMatching={handleRetryMatching}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
261
apps/desktop/src/services/materialMatchingService.ts
Normal file
261
apps/desktop/src/services/materialMatchingService.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 素材匹配服务
|
||||
* 遵循前端开发规范的服务层设计
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
MaterialMatchingRequest,
|
||||
MaterialMatchingResult,
|
||||
ProjectMaterialMatchingStats,
|
||||
TemplateBindingMatchingValidation,
|
||||
MatchingError,
|
||||
MatchingErrorType
|
||||
} from '../types/materialMatching';
|
||||
|
||||
export class MaterialMatchingService {
|
||||
/**
|
||||
* 执行素材匹配
|
||||
*/
|
||||
static async executeMatching(request: MaterialMatchingRequest): Promise<MaterialMatchingResult> {
|
||||
try {
|
||||
return await invoke<MaterialMatchingResult>('execute_material_matching', { request });
|
||||
} catch (error) {
|
||||
throw this.handleMatchingError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目的素材匹配统计信息
|
||||
*/
|
||||
static async getProjectMaterialStats(projectId: string): Promise<ProjectMaterialMatchingStats> {
|
||||
try {
|
||||
return await invoke<ProjectMaterialMatchingStats>('get_project_material_stats_for_matching', {
|
||||
projectId
|
||||
});
|
||||
} catch (error) {
|
||||
throw this.handleMatchingError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证模板绑定是否可以进行素材匹配
|
||||
*/
|
||||
static async validateTemplateBinding(bindingId: string): Promise<TemplateBindingMatchingValidation> {
|
||||
try {
|
||||
return await invoke<TemplateBindingMatchingValidation>('validate_template_binding_for_matching', {
|
||||
bindingId
|
||||
});
|
||||
} catch (error) {
|
||||
throw this.handleMatchingError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查项目是否准备好进行素材匹配
|
||||
*/
|
||||
static async checkProjectReadiness(projectId: string): Promise<{
|
||||
isReady: boolean;
|
||||
issues: string[];
|
||||
stats: ProjectMaterialMatchingStats;
|
||||
}> {
|
||||
const stats = await this.getProjectMaterialStats(projectId);
|
||||
|
||||
const issues: string[] = [];
|
||||
|
||||
// 检查是否有素材
|
||||
if (stats.total_materials === 0) {
|
||||
issues.push('项目中没有素材,请先导入素材');
|
||||
}
|
||||
|
||||
// 检查是否有已分类的片段
|
||||
if (stats.classified_segments === 0) {
|
||||
issues.push('没有已分类的素材片段,请先进行AI分类');
|
||||
}
|
||||
|
||||
// 检查分类率是否足够
|
||||
if (stats.classification_rate < 0.5) {
|
||||
issues.push(`分类率较低 (${(stats.classification_rate * 100).toFixed(1)}%),建议提高分类覆盖率`);
|
||||
}
|
||||
|
||||
// 检查是否有可用的分类类别
|
||||
if (stats.available_categories.length === 0) {
|
||||
issues.push('没有可用的AI分类类别');
|
||||
}
|
||||
|
||||
return {
|
||||
isReady: issues.length === 0,
|
||||
issues,
|
||||
stats
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 预估匹配结果
|
||||
*/
|
||||
static async estimateMatchingResult(request: MaterialMatchingRequest): Promise<{
|
||||
estimated_matches: number;
|
||||
estimated_failures: number;
|
||||
estimated_success_rate: number;
|
||||
potential_issues: string[];
|
||||
}> {
|
||||
// 获取项目统计信息
|
||||
const stats = await this.getProjectMaterialStats(request.project_id);
|
||||
|
||||
// 验证模板绑定
|
||||
const validation = await this.validateTemplateBinding(request.binding_id);
|
||||
|
||||
const potential_issues: string[] = [];
|
||||
|
||||
// 基于统计信息估算匹配结果
|
||||
let estimated_matches = Math.min(
|
||||
validation.matchable_segments,
|
||||
Math.floor(stats.classified_segments * 0.8) // 假设80%的已分类片段可以匹配
|
||||
);
|
||||
|
||||
let estimated_failures = validation.total_segments - estimated_matches;
|
||||
|
||||
// 检查潜在问题
|
||||
if (stats.available_models < 2) {
|
||||
potential_issues.push('模特数量较少,可能影响匹配多样性');
|
||||
estimated_matches = Math.floor(estimated_matches * 0.7);
|
||||
}
|
||||
|
||||
if (stats.classification_rate < 0.7) {
|
||||
potential_issues.push('分类率较低,可能导致匹配失败');
|
||||
estimated_matches = Math.floor(estimated_matches * 0.8);
|
||||
}
|
||||
|
||||
if (stats.available_categories.length < 3) {
|
||||
potential_issues.push('可用分类类别较少');
|
||||
estimated_matches = Math.floor(estimated_matches * 0.9);
|
||||
}
|
||||
|
||||
estimated_failures = validation.total_segments - estimated_matches;
|
||||
const estimated_success_rate = validation.total_segments > 0
|
||||
? estimated_matches / validation.total_segments
|
||||
: 0;
|
||||
|
||||
return {
|
||||
estimated_matches,
|
||||
estimated_failures,
|
||||
estimated_success_rate,
|
||||
potential_issues
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化匹配统计信息为显示文本
|
||||
*/
|
||||
static formatMatchingStats(result: MaterialMatchingResult): {
|
||||
summary: string;
|
||||
details: string[];
|
||||
} {
|
||||
const { statistics } = result;
|
||||
|
||||
const summary = `匹配完成:${statistics.matched_segments}/${statistics.total_segments} 个片段 (${(statistics.success_rate * 100).toFixed(1)}%)`;
|
||||
|
||||
const details = [
|
||||
`成功匹配:${statistics.matched_segments} 个片段`,
|
||||
`匹配失败:${statistics.failed_segments} 个片段`,
|
||||
`使用素材:${statistics.used_materials} 个`,
|
||||
`涉及模特:${statistics.used_models} 个`,
|
||||
`成功率:${(statistics.success_rate * 100).toFixed(1)}%`
|
||||
];
|
||||
|
||||
return { summary, details };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取匹配失败原因的分类统计
|
||||
*/
|
||||
static analyzeFailureReasons(result: MaterialMatchingResult): {
|
||||
[reason: string]: {
|
||||
count: number;
|
||||
segments: string[];
|
||||
};
|
||||
} {
|
||||
const analysis: { [reason: string]: { count: number; segments: string[] } } = {};
|
||||
|
||||
result.failed_segments.forEach(failure => {
|
||||
if (!analysis[failure.failure_reason]) {
|
||||
analysis[failure.failure_reason] = {
|
||||
count: 0,
|
||||
segments: []
|
||||
};
|
||||
}
|
||||
|
||||
analysis[failure.failure_reason].count++;
|
||||
analysis[failure.failure_reason].segments.push(failure.track_segment_name);
|
||||
});
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成匹配改进建议
|
||||
*/
|
||||
static generateImprovementSuggestions(result: MaterialMatchingResult, stats: ProjectMaterialMatchingStats): string[] {
|
||||
const suggestions: string[] = [];
|
||||
|
||||
// 基于失败原因生成建议
|
||||
const failureAnalysis = this.analyzeFailureReasons(result);
|
||||
|
||||
Object.keys(failureAnalysis).forEach(reason => {
|
||||
const count = failureAnalysis[reason].count;
|
||||
|
||||
if (reason.includes('没有找到分类')) {
|
||||
suggestions.push(`有 ${count} 个片段因缺少对应分类而匹配失败,建议增加相关分类的素材`);
|
||||
} else if (reason.includes('时长要求')) {
|
||||
suggestions.push(`有 ${count} 个片段因时长不足而匹配失败,建议导入更长的素材片段`);
|
||||
} else if (reason.includes('可用素材')) {
|
||||
suggestions.push(`有 ${count} 个片段因没有可用素材而匹配失败,建议增加更多素材`);
|
||||
}
|
||||
});
|
||||
|
||||
// 基于统计信息生成建议
|
||||
if (stats.classification_rate < 0.8) {
|
||||
suggestions.push('建议对更多素材进行AI分类以提高匹配成功率');
|
||||
}
|
||||
|
||||
if (stats.available_models < 3) {
|
||||
suggestions.push('建议增加更多模特以提高匹配多样性');
|
||||
}
|
||||
|
||||
if (result.statistics.success_rate < 0.6) {
|
||||
suggestions.push('匹配成功率较低,建议检查模板的匹配规则设置');
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理匹配错误
|
||||
*/
|
||||
private static handleMatchingError(error: any): MatchingError {
|
||||
let errorType: MatchingErrorType = MatchingErrorType.NoClassifiedSegments;
|
||||
let message = '素材匹配失败';
|
||||
|
||||
if (typeof error === 'string') {
|
||||
message = error;
|
||||
|
||||
if (error.includes('模板不存在')) {
|
||||
errorType = MatchingErrorType.TemplateNotFound;
|
||||
} else if (error.includes('项目不存在')) {
|
||||
errorType = MatchingErrorType.ProjectNotFound;
|
||||
} else if (error.includes('没有已分类')) {
|
||||
errorType = MatchingErrorType.NoClassifiedSegments;
|
||||
} else if (error.includes('时长不足')) {
|
||||
errorType = MatchingErrorType.InsufficientDuration;
|
||||
}
|
||||
} else if (error?.message) {
|
||||
message = error.message;
|
||||
}
|
||||
|
||||
return {
|
||||
type: errorType,
|
||||
message,
|
||||
details: error
|
||||
};
|
||||
}
|
||||
}
|
||||
158
apps/desktop/src/types/materialMatching.ts
Normal file
158
apps/desktop/src/types/materialMatching.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 素材匹配相关的类型定义
|
||||
* 遵循前端开发规范的类型设计原则
|
||||
*/
|
||||
|
||||
import { MaterialSegment } from './material';
|
||||
import { SegmentMatchingRule } from './template';
|
||||
|
||||
// 素材匹配请求
|
||||
export interface MaterialMatchingRequest {
|
||||
project_id: string;
|
||||
template_id: string;
|
||||
binding_id: string;
|
||||
overwrite_existing: boolean;
|
||||
}
|
||||
|
||||
// 素材匹配结果
|
||||
export interface MaterialMatchingResult {
|
||||
binding_id: string;
|
||||
template_id: string;
|
||||
project_id: string;
|
||||
matches: SegmentMatch[];
|
||||
statistics: MatchingStatistics;
|
||||
failed_segments: FailedSegmentMatch[];
|
||||
}
|
||||
|
||||
// 片段匹配结果
|
||||
export interface SegmentMatch {
|
||||
track_segment_id: string;
|
||||
track_segment_name: string;
|
||||
material_segment_id: string;
|
||||
material_segment: MaterialSegment;
|
||||
material_name: string;
|
||||
model_name?: string;
|
||||
match_score: number;
|
||||
match_reason: string;
|
||||
}
|
||||
|
||||
// 匹配失败的片段
|
||||
export interface FailedSegmentMatch {
|
||||
track_segment_id: string;
|
||||
track_segment_name: string;
|
||||
matching_rule: SegmentMatchingRule;
|
||||
failure_reason: string;
|
||||
}
|
||||
|
||||
// 匹配统计信息
|
||||
export interface MatchingStatistics {
|
||||
total_segments: number;
|
||||
matched_segments: number;
|
||||
failed_segments: number;
|
||||
success_rate: number;
|
||||
used_materials: number;
|
||||
used_models: number;
|
||||
}
|
||||
|
||||
// 项目素材匹配统计信息
|
||||
export interface ProjectMaterialMatchingStats {
|
||||
project_id: string;
|
||||
total_materials: number;
|
||||
total_segments: number;
|
||||
classified_segments: number;
|
||||
available_models: number;
|
||||
available_categories: string[];
|
||||
classification_rate: number;
|
||||
}
|
||||
|
||||
// 模板绑定匹配验证结果
|
||||
export interface TemplateBindingMatchingValidation {
|
||||
binding_id: string;
|
||||
is_valid: boolean;
|
||||
validation_errors: string[];
|
||||
total_segments: number;
|
||||
matchable_segments: number;
|
||||
}
|
||||
|
||||
// 匹配状态枚举
|
||||
export enum MatchingStatus {
|
||||
Idle = 'idle',
|
||||
Matching = 'matching',
|
||||
Completed = 'completed',
|
||||
Failed = 'failed'
|
||||
}
|
||||
|
||||
// 匹配结果显示选项
|
||||
export interface MatchingResultDisplayOptions {
|
||||
showSuccessOnly: boolean;
|
||||
showFailuresOnly: boolean;
|
||||
groupByModel: boolean;
|
||||
sortBy: 'score' | 'name' | 'duration';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// 匹配配置选项
|
||||
export interface MatchingOptions {
|
||||
overwrite_existing: boolean;
|
||||
prefer_higher_quality: boolean;
|
||||
strict_duration_matching: boolean;
|
||||
allow_cross_model_matching: boolean;
|
||||
}
|
||||
|
||||
// 匹配进度信息
|
||||
export interface MatchingProgress {
|
||||
current_segment: number;
|
||||
total_segments: number;
|
||||
current_segment_name: string;
|
||||
progress_percentage: number;
|
||||
estimated_remaining_time?: number;
|
||||
}
|
||||
|
||||
// 匹配错误类型
|
||||
export enum MatchingErrorType {
|
||||
NoClassifiedSegments = 'no_classified_segments',
|
||||
NoMatchingCategory = 'no_matching_category',
|
||||
InsufficientDuration = 'insufficient_duration',
|
||||
NoAvailableModels = 'no_available_models',
|
||||
TemplateNotFound = 'template_not_found',
|
||||
ProjectNotFound = 'project_not_found',
|
||||
InvalidMatchingRule = 'invalid_matching_rule'
|
||||
}
|
||||
|
||||
// 匹配错误详情
|
||||
export interface MatchingError {
|
||||
type: MatchingErrorType;
|
||||
message: string;
|
||||
segment_id?: string;
|
||||
segment_name?: string;
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
// 匹配质量评分
|
||||
export interface MatchingQualityScore {
|
||||
overall_score: number;
|
||||
duration_score: number;
|
||||
category_score: number;
|
||||
model_consistency_score: number;
|
||||
quality_factors: string[];
|
||||
}
|
||||
|
||||
// 匹配建议
|
||||
export interface MatchingSuggestion {
|
||||
type: 'improve_classification' | 'add_materials' | 'adjust_rules';
|
||||
message: string;
|
||||
action_required: boolean;
|
||||
estimated_improvement: number;
|
||||
}
|
||||
|
||||
// 匹配报告
|
||||
export interface MatchingReport {
|
||||
matching_result: MaterialMatchingResult;
|
||||
quality_score: MatchingQualityScore;
|
||||
suggestions: MatchingSuggestion[];
|
||||
performance_metrics: {
|
||||
matching_time_ms: number;
|
||||
segments_per_second: number;
|
||||
memory_usage_mb?: number;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user