- 后端实现: * 在MaterialRepository中添加batch_delete方法,支持事务处理 * 在MaterialService中添加batch_delete_materials业务逻辑 * 添加BatchDeleteResult和BatchDeleteFailedItem数据结构 * 新增batch_delete_materials Tauri命令接口 * 实现参数验证和错误处理机制 - 前端实现: * 创建useBatchSelection Hook管理批量选择状态 * 实现BatchDeleteConfirmDialog批量删除确认对话框 * 在MaterialCard组件中添加批量选择支持 * 在ProjectDetails页面集成批量选择和删除功能 * 添加批量操作UI控件(全选/取消全选/批量删除按钮) - 功能特性: * 支持最多50个素材的批量选择 * 单次最多删除100个素材的限制 * 详细的删除结果反馈(成功/失败统计) * 失败项目的具体错误信息显示 * 批量选择模式的视觉反馈 * 完善的用户确认和通知机制 - 测试: * 添加批量删除功能的单元测试 * 测试数据结构创建和验证逻辑 遵循Tauri开发规范和前端UI/UX设计标准,提供安全可靠的批量删除体验。
1064 lines
37 KiB
Rust
1064 lines
37 KiB
Rust
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<MaterialImportResult> {
|
||
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<String>,
|
||
) -> Result<Option<Material>> {
|
||
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<String> {
|
||
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<Vec<Material>> {
|
||
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<Vec<Material>> {
|
||
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<Option<Material>> {
|
||
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<String>,
|
||
) -> Result<BatchDeleteResult> {
|
||
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<String>,
|
||
) -> 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<crate::data::models::material::MaterialStats> {
|
||
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<String>,
|
||
config: &MaterialProcessingConfig,
|
||
) -> Result<Vec<String>> {
|
||
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<MaterialMetadata> {
|
||
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<crate::data::models::material::SceneDetection> {
|
||
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<u32> {
|
||
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<crate::data::models::project::Project> {
|
||
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<Vec<Material>> {
|
||
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<Vec<Material>> {
|
||
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<crate::data::repositories::material_repository::ModelMaterialStatistics> {
|
||
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<ProjectModelBindingStats> {
|
||
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<ProjectModelBindingStats> {
|
||
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, // 绑定率百分比
|
||
}
|