feat: 实现项目详情页面和素材导入功能
- 添加项目详情页面路由和组件 - 实现素材数据模型和数据库表结构 - 集成FFmpeg进行视频元数据提取和场景检测 - 实现视频自动切分功能(基于场景检测和时长限制) - 开发素材导入UI界面和进度显示 - 添加素材管理相关的Tauri命令 - 完善错误处理和性能优化 - 添加单元测试覆盖核心功能 主要功能: - 项目详情页面展示项目信息和素材统计 - 素材导入支持多种格式(视频、音频、图片、文档) - MD5重复检测避免重复导入 - FFmpeg集成提取视频/音频元数据 - 智能场景检测和视频切分 - 二次切分处理超长视频片段 - 响应式UI设计和用户友好的导入流程
This commit is contained in:
@@ -31,6 +31,7 @@ tokio = { version = "1.0", features = ["full", "sync"] }
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
dirs = "5.0"
|
||||
md5 = "0.7"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.8"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use crate::data::repositories::project_repository::ProjectRepository;
|
||||
use crate::data::repositories::material_repository::MaterialRepository;
|
||||
use crate::infrastructure::database::Database;
|
||||
use crate::infrastructure::performance::PerformanceMonitor;
|
||||
use crate::infrastructure::event_bus::EventBusManager;
|
||||
@@ -9,6 +10,7 @@ use crate::infrastructure::event_bus::EventBusManager;
|
||||
pub struct AppState {
|
||||
pub database: Mutex<Option<Database>>,
|
||||
pub project_repository: Mutex<Option<ProjectRepository>>,
|
||||
pub material_repository: Mutex<Option<MaterialRepository>>,
|
||||
pub performance_monitor: Mutex<PerformanceMonitor>,
|
||||
pub event_bus_manager: Arc<EventBusManager>,
|
||||
}
|
||||
@@ -18,6 +20,7 @@ impl AppState {
|
||||
Self {
|
||||
database: Mutex::new(None),
|
||||
project_repository: Mutex::new(None),
|
||||
material_repository: Mutex::new(None),
|
||||
performance_monitor: Mutex::new(PerformanceMonitor::new()),
|
||||
event_bus_manager: Arc::new(EventBusManager::new()),
|
||||
}
|
||||
@@ -28,9 +31,11 @@ impl AppState {
|
||||
pub fn initialize_database(&self) -> anyhow::Result<()> {
|
||||
let database = Database::new()?;
|
||||
let project_repository = ProjectRepository::new(database.get_connection())?;
|
||||
let material_repository = MaterialRepository::new(database.get_connection())?;
|
||||
|
||||
*self.database.lock().unwrap() = Some(database);
|
||||
*self.project_repository.lock().unwrap() = Some(project_repository);
|
||||
*self.material_repository.lock().unwrap() = Some(material_repository);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -39,6 +44,11 @@ impl AppState {
|
||||
pub fn get_project_repository(&self) -> anyhow::Result<std::sync::MutexGuard<Option<ProjectRepository>>> {
|
||||
Ok(self.project_repository.lock().unwrap())
|
||||
}
|
||||
|
||||
/// 获取素材仓库实例
|
||||
pub fn get_material_repository(&self) -> anyhow::Result<std::sync::MutexGuard<Option<MaterialRepository>>> {
|
||||
Ok(self.material_repository.lock().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
|
||||
471
apps/desktop/src-tauri/src/business/services/material_service.rs
Normal file
471
apps/desktop/src-tauri/src/business/services/material_service.rs
Normal file
@@ -0,0 +1,471 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::path::Path;
|
||||
use std::fs;
|
||||
|
||||
|
||||
use crate::data::models::material::{
|
||||
Material, MaterialType, ProcessingStatus, CreateMaterialRequest,
|
||||
MaterialImportResult, MaterialProcessingConfig, MaterialMetadata
|
||||
};
|
||||
use crate::data::repositories::material_repository::MaterialRepository;
|
||||
use crate::infrastructure::ffmpeg::FFmpegService;
|
||||
|
||||
/// 素材服务
|
||||
/// 遵循 Tauri 开发规范的业务逻辑层设计
|
||||
pub struct MaterialService;
|
||||
|
||||
impl MaterialService {
|
||||
/// 导入素材文件
|
||||
pub fn import_materials(
|
||||
repository: &MaterialRepository,
|
||||
request: CreateMaterialRequest,
|
||||
config: &MaterialProcessingConfig,
|
||||
) -> Result<MaterialImportResult> {
|
||||
let start_time = std::time::Instant::now();
|
||||
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 {
|
||||
match Self::process_single_file(repository, &request.project_id, file_path, config) {
|
||||
Ok(Some(material)) => {
|
||||
result.created_materials.push(material);
|
||||
result.processed_files += 1;
|
||||
}
|
||||
Ok(None) => {
|
||||
// 文件被跳过(重复)
|
||||
result.skipped_files += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
result.failed_files += 1;
|
||||
result.errors.push(format!("处理文件 {} 失败: {}", file_path, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.processing_time = start_time.elapsed().as_secs_f64();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 处理单个文件
|
||||
fn process_single_file(
|
||||
repository: &MaterialRepository,
|
||||
project_id: &str,
|
||||
file_path: &str,
|
||||
_config: &MaterialProcessingConfig,
|
||||
) -> Result<Option<Material>> {
|
||||
let path = Path::new(file_path);
|
||||
|
||||
// 检查文件是否存在
|
||||
if !path.exists() {
|
||||
return Err(anyhow!("文件不存在: {}", 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);
|
||||
|
||||
// 创建素材对象
|
||||
let material = Material::new(
|
||||
project_id.to_string(),
|
||||
file_name,
|
||||
file_path.to_string(),
|
||||
file_size,
|
||||
md5_hash,
|
||||
material_type,
|
||||
);
|
||||
|
||||
// 保存到数据库
|
||||
repository.create(&material)?;
|
||||
|
||||
// 如果启用自动处理,则开始处理
|
||||
// TODO: 实现异步处理逻辑
|
||||
|
||||
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_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 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 {
|
||||
match Self::detect_video_scenes(&material.original_path, config.scene_detection_threshold) {
|
||||
Ok(scene_detection) => {
|
||||
material.set_scene_detection(scene_detection);
|
||||
repository.update(&material)?;
|
||||
}
|
||||
Err(e) => {
|
||||
// 场景检测失败不应该导致整个处理失败
|
||||
println!("场景检测失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查是否需要切分视频
|
||||
if material.needs_segmentation(config.max_segment_duration) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标记为完成
|
||||
Self::update_material_status(
|
||||
repository,
|
||||
material_id,
|
||||
ProcessingStatus::Completed,
|
||||
None,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 提取素材元数据
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测视频场景
|
||||
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 mut scenes = Vec::new();
|
||||
let mut previous_time = 0.0;
|
||||
|
||||
for (index, &scene_time) in scene_times.iter().enumerate() {
|
||||
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, // FFmpeg 场景检测不提供置信度
|
||||
});
|
||||
previous_time = scene_time;
|
||||
}
|
||||
|
||||
Ok(crate::data::models::material::SceneDetection {
|
||||
scenes,
|
||||
total_scenes: scene_times.len() as u32,
|
||||
detection_method: "ffmpeg_scene_filter".to_string(),
|
||||
threshold,
|
||||
})
|
||||
}
|
||||
|
||||
/// 切分视频
|
||||
fn segment_video(
|
||||
repository: &MaterialRepository,
|
||||
material: &Material,
|
||||
config: &MaterialProcessingConfig,
|
||||
) -> Result<()> {
|
||||
// 根据场景检测结果或固定时长切分
|
||||
let segments = if let Some(scene_detection) = &material.scene_detection {
|
||||
// 使用场景检测结果切分
|
||||
Self::create_segments_from_scenes(&scene_detection.scenes, config.max_segment_duration)
|
||||
} else {
|
||||
// 使用固定时长切分
|
||||
if let Some(duration) = material.get_duration() {
|
||||
Self::create_fixed_segments(duration, config.max_segment_duration)
|
||||
} else {
|
||||
return Err(anyhow!("无法获取视频时长"));
|
||||
}
|
||||
};
|
||||
|
||||
// 创建输出目录
|
||||
let output_dir = format!("{}_segments", material.original_path.trim_end_matches(".mp4"));
|
||||
|
||||
// 执行视频切分
|
||||
let output_files = FFmpegService::split_video(
|
||||
&material.original_path,
|
||||
&output_dir,
|
||||
&segments,
|
||||
&material.name.replace(".mp4", ""),
|
||||
)?;
|
||||
|
||||
// 保存片段信息到数据库
|
||||
for (index, output_file) in output_files.iter().enumerate() {
|
||||
let (start_time, end_time) = 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(
|
||||
scenes: &[crate::data::models::material::SceneSegment],
|
||||
max_duration: f64,
|
||||
) -> Vec<(f64, f64)> {
|
||||
let mut segments = Vec::new();
|
||||
let mut current_start = 0.0;
|
||||
let mut current_duration = 0.0;
|
||||
|
||||
for scene in scenes {
|
||||
if current_duration + scene.duration > max_duration && current_duration > 0.0 {
|
||||
// 当前片段已达到最大时长,创建新片段
|
||||
segments.push((current_start, current_start + current_duration));
|
||||
current_start = scene.start_time;
|
||||
current_duration = scene.duration;
|
||||
} else {
|
||||
// 继续累加到当前片段
|
||||
current_duration += scene.duration;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加最后一个片段
|
||||
if current_duration > 0.0 {
|
||||
segments.push((current_start, current_start + current_duration));
|
||||
}
|
||||
|
||||
segments
|
||||
}
|
||||
|
||||
/// 创建固定时长的切分片段
|
||||
pub fn create_fixed_segments(total_duration: f64, max_duration: f64) -> Vec<(f64, f64)> {
|
||||
let mut segments = Vec::new();
|
||||
let mut current_time = 0.0;
|
||||
|
||||
while current_time < total_duration {
|
||||
let end_time = (current_time + max_duration).min(total_duration);
|
||||
segments.push((current_time, end_time));
|
||||
current_time = end_time;
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod project_service;
|
||||
pub mod material_service;
|
||||
|
||||
294
apps/desktop/src-tauri/src/data/models/material.rs
Normal file
294
apps/desktop/src-tauri/src/data/models/material.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// 素材类型枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum MaterialType {
|
||||
Video,
|
||||
Audio,
|
||||
Image,
|
||||
Document,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl MaterialType {
|
||||
pub fn from_extension(ext: &str) -> Self {
|
||||
match ext.to_lowercase().as_str() {
|
||||
"mp4" | "avi" | "mov" | "mkv" | "wmv" | "flv" | "webm" | "m4v" => MaterialType::Video,
|
||||
"mp3" | "wav" | "flac" | "aac" | "ogg" | "wma" | "m4a" => MaterialType::Audio,
|
||||
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "tiff" | "webp" | "svg" => MaterialType::Image,
|
||||
"pdf" | "doc" | "docx" | "txt" | "md" | "rtf" => MaterialType::Document,
|
||||
_ => MaterialType::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材处理状态枚举
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ProcessingStatus {
|
||||
Pending, // 待处理
|
||||
Processing, // 处理中
|
||||
Completed, // 处理完成
|
||||
Failed, // 处理失败
|
||||
Skipped, // 跳过(重复文件)
|
||||
}
|
||||
|
||||
/// 视频元数据结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VideoMetadata {
|
||||
pub duration: f64, // 时长(秒)
|
||||
pub width: u32, // 宽度
|
||||
pub height: u32, // 高度
|
||||
pub fps: f64, // 帧率
|
||||
pub bitrate: u64, // 比特率
|
||||
pub codec: String, // 编码格式
|
||||
pub format: String, // 容器格式
|
||||
pub has_audio: bool, // 是否包含音频
|
||||
pub audio_codec: Option<String>, // 音频编码格式
|
||||
pub audio_bitrate: Option<u64>, // 音频比特率
|
||||
pub audio_sample_rate: Option<u32>, // 音频采样率
|
||||
}
|
||||
|
||||
/// 音频元数据结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioMetadata {
|
||||
pub duration: f64, // 时长(秒)
|
||||
pub bitrate: u64, // 比特率
|
||||
pub codec: String, // 编码格式
|
||||
pub sample_rate: u32, // 采样率
|
||||
pub channels: u32, // 声道数
|
||||
pub format: String, // 音频格式
|
||||
}
|
||||
|
||||
/// 图片元数据结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageMetadata {
|
||||
pub width: u32, // 宽度
|
||||
pub height: u32, // 高度
|
||||
pub format: String, // 图片格式
|
||||
pub color_space: Option<String>, // 色彩空间
|
||||
pub has_alpha: bool, // 是否有透明通道
|
||||
pub dpi: Option<u32>, // DPI
|
||||
}
|
||||
|
||||
/// 素材元数据联合体
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum MaterialMetadata {
|
||||
Video(VideoMetadata),
|
||||
Audio(AudioMetadata),
|
||||
Image(ImageMetadata),
|
||||
None,
|
||||
}
|
||||
|
||||
/// 场景检测结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SceneDetection {
|
||||
pub scenes: Vec<SceneSegment>,
|
||||
pub total_scenes: u32,
|
||||
pub detection_method: String,
|
||||
pub threshold: f64,
|
||||
}
|
||||
|
||||
/// 场景片段
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SceneSegment {
|
||||
pub start_time: f64, // 开始时间(秒)
|
||||
pub end_time: f64, // 结束时间(秒)
|
||||
pub duration: f64, // 片段时长(秒)
|
||||
pub scene_id: u32, // 场景ID
|
||||
pub confidence: f64, // 检测置信度
|
||||
}
|
||||
|
||||
/// 素材实体模型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Material {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
pub original_path: String,
|
||||
pub file_size: u64,
|
||||
pub md5_hash: String,
|
||||
pub material_type: MaterialType,
|
||||
pub processing_status: ProcessingStatus,
|
||||
pub metadata: MaterialMetadata,
|
||||
pub scene_detection: Option<SceneDetection>,
|
||||
pub segments: Vec<MaterialSegment>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub processed_at: Option<DateTime<Utc>>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
/// 素材片段(切分后的片段)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MaterialSegment {
|
||||
pub id: String,
|
||||
pub material_id: String,
|
||||
pub segment_index: u32,
|
||||
pub start_time: f64,
|
||||
pub end_time: f64,
|
||||
pub duration: f64,
|
||||
pub file_path: String,
|
||||
pub file_size: u64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 创建素材请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateMaterialRequest {
|
||||
pub project_id: String,
|
||||
pub file_paths: Vec<String>,
|
||||
pub auto_process: bool,
|
||||
pub max_segment_duration: Option<f64>, // 最大片段时长(秒)
|
||||
}
|
||||
|
||||
/// 素材处理配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MaterialProcessingConfig {
|
||||
pub max_segment_duration: f64, // 最大片段时长(秒)
|
||||
pub scene_detection_threshold: f64, // 场景检测阈值
|
||||
pub enable_scene_detection: bool, // 是否启用场景检测
|
||||
pub video_quality: String, // 视频质量设置
|
||||
pub audio_quality: String, // 音频质量设置
|
||||
pub output_format: String, // 输出格式
|
||||
}
|
||||
|
||||
impl Default for MaterialProcessingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_segment_duration: 300.0, // 5分钟
|
||||
scene_detection_threshold: 0.3,
|
||||
enable_scene_detection: true,
|
||||
video_quality: "medium".to_string(),
|
||||
audio_quality: "medium".to_string(),
|
||||
output_format: "mp4".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材导入结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MaterialImportResult {
|
||||
pub total_files: u32,
|
||||
pub processed_files: u32,
|
||||
pub skipped_files: u32,
|
||||
pub failed_files: u32,
|
||||
pub created_materials: Vec<Material>,
|
||||
pub errors: Vec<String>,
|
||||
pub processing_time: f64, // 处理时间(秒)
|
||||
}
|
||||
|
||||
/// 素材统计信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MaterialStats {
|
||||
pub total_materials: u32,
|
||||
pub video_count: u32,
|
||||
pub audio_count: u32,
|
||||
pub image_count: u32,
|
||||
pub other_count: u32,
|
||||
pub total_size: u64,
|
||||
pub total_duration: f64, // 总时长(秒)
|
||||
pub processing_status_counts: std::collections::HashMap<String, u32>,
|
||||
}
|
||||
|
||||
impl Material {
|
||||
/// 创建新的素材实例
|
||||
pub fn new(
|
||||
project_id: String,
|
||||
name: String,
|
||||
original_path: String,
|
||||
file_size: u64,
|
||||
md5_hash: String,
|
||||
material_type: MaterialType,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
project_id,
|
||||
name,
|
||||
original_path,
|
||||
file_size,
|
||||
md5_hash,
|
||||
material_type,
|
||||
processing_status: ProcessingStatus::Pending,
|
||||
metadata: MaterialMetadata::None,
|
||||
scene_detection: None,
|
||||
segments: Vec::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
processed_at: None,
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新处理状态
|
||||
pub fn update_status(&mut self, status: ProcessingStatus, error_message: Option<String>) {
|
||||
self.processing_status = status;
|
||||
self.error_message = error_message;
|
||||
self.updated_at = Utc::now();
|
||||
|
||||
if matches!(status, ProcessingStatus::Completed | ProcessingStatus::Failed) {
|
||||
self.processed_at = Some(Utc::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置元数据
|
||||
pub fn set_metadata(&mut self, metadata: MaterialMetadata) {
|
||||
self.metadata = metadata;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 添加场景检测结果
|
||||
pub fn set_scene_detection(&mut self, scene_detection: SceneDetection) {
|
||||
self.scene_detection = Some(scene_detection);
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 添加片段
|
||||
pub fn add_segment(&mut self, segment: MaterialSegment) {
|
||||
self.segments.push(segment);
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 获取总时长
|
||||
pub fn get_duration(&self) -> Option<f64> {
|
||||
match &self.metadata {
|
||||
MaterialMetadata::Video(video) => Some(video.duration),
|
||||
MaterialMetadata::Audio(audio) => Some(audio.duration),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否需要切分
|
||||
pub fn needs_segmentation(&self, max_duration: f64) -> bool {
|
||||
if let Some(duration) = self.get_duration() {
|
||||
duration > max_duration
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MaterialSegment {
|
||||
/// 创建新的素材片段
|
||||
pub fn new(
|
||||
material_id: String,
|
||||
segment_index: u32,
|
||||
start_time: f64,
|
||||
end_time: f64,
|
||||
file_path: String,
|
||||
file_size: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
material_id,
|
||||
segment_index,
|
||||
start_time,
|
||||
end_time,
|
||||
duration: end_time - start_time,
|
||||
file_path,
|
||||
file_size,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod project;
|
||||
pub mod material;
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
use anyhow::Result;
|
||||
use rusqlite::{Connection, Row};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use crate::data::models::material::{
|
||||
Material, MaterialSegment, MaterialType, ProcessingStatus,
|
||||
MaterialMetadata, SceneDetection, MaterialStats
|
||||
};
|
||||
|
||||
/// 素材仓库
|
||||
/// 遵循 Tauri 开发规范的数据访问层设计
|
||||
pub struct MaterialRepository {
|
||||
connection: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl MaterialRepository {
|
||||
/// 创建新的素材仓库实例
|
||||
pub fn new(connection: Arc<Mutex<Connection>>) -> Result<Self> {
|
||||
Ok(Self { connection })
|
||||
}
|
||||
|
||||
/// 创建素材
|
||||
pub fn create(&self, material: &Material) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
let metadata_json = serde_json::to_string(&material.metadata)?;
|
||||
let scene_detection_json = material.scene_detection.as_ref()
|
||||
.map(|sd| serde_json::to_string(sd))
|
||||
.transpose()?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO materials (
|
||||
id, project_id, name, original_path, file_size, md5_hash,
|
||||
material_type, processing_status, metadata, scene_detection,
|
||||
created_at, updated_at, processed_at, error_message
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
(
|
||||
&material.id,
|
||||
&material.project_id,
|
||||
&material.name,
|
||||
&material.original_path,
|
||||
material.file_size as i64,
|
||||
&material.md5_hash,
|
||||
serde_json::to_string(&material.material_type)?,
|
||||
serde_json::to_string(&material.processing_status)?,
|
||||
metadata_json,
|
||||
scene_detection_json,
|
||||
material.created_at.to_rfc3339(),
|
||||
material.updated_at.to_rfc3339(),
|
||||
material.processed_at.map(|dt| dt.to_rfc3339()),
|
||||
&material.error_message,
|
||||
),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 根据ID获取素材
|
||||
pub fn get_by_id(&self, id: &str) -> Result<Option<Material>> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, name, original_path, file_size, md5_hash,
|
||||
material_type, processing_status, metadata, scene_detection,
|
||||
created_at, updated_at, processed_at, error_message
|
||||
FROM materials WHERE id = ?1"
|
||||
)?;
|
||||
|
||||
let material_iter = stmt.query_map([id], |row| {
|
||||
self.row_to_material(row)
|
||||
})?;
|
||||
|
||||
for material in material_iter {
|
||||
return Ok(Some(material?));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// 根据项目ID获取所有素材
|
||||
pub fn get_by_project_id(&self, project_id: &str) -> Result<Vec<Material>> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, name, original_path, file_size, md5_hash,
|
||||
material_type, processing_status, metadata, scene_detection,
|
||||
created_at, updated_at, processed_at, error_message
|
||||
FROM materials WHERE project_id = ?1 ORDER BY created_at DESC"
|
||||
)?;
|
||||
|
||||
let material_iter = stmt.query_map([project_id], |row| {
|
||||
self.row_to_material(row)
|
||||
})?;
|
||||
|
||||
let mut materials = Vec::new();
|
||||
for material in material_iter {
|
||||
materials.push(material?);
|
||||
}
|
||||
|
||||
Ok(materials)
|
||||
}
|
||||
|
||||
/// 根据MD5哈希检查素材是否存在
|
||||
pub fn exists_by_md5(&self, project_id: &str, md5_hash: &str) -> Result<bool> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM materials WHERE project_id = ?1 AND md5_hash = ?2",
|
||||
[project_id, md5_hash],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// 更新素材
|
||||
pub fn update(&self, material: &Material) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
let metadata_json = serde_json::to_string(&material.metadata)?;
|
||||
let scene_detection_json = material.scene_detection.as_ref()
|
||||
.map(|sd| serde_json::to_string(sd))
|
||||
.transpose()?;
|
||||
|
||||
conn.execute(
|
||||
"UPDATE materials SET
|
||||
name = ?1, processing_status = ?2, metadata = ?3, scene_detection = ?4,
|
||||
updated_at = ?5, processed_at = ?6, error_message = ?7
|
||||
WHERE id = ?8",
|
||||
(
|
||||
&material.name,
|
||||
serde_json::to_string(&material.processing_status)?,
|
||||
metadata_json,
|
||||
scene_detection_json,
|
||||
material.updated_at.to_rfc3339(),
|
||||
material.processed_at.map(|dt| dt.to_rfc3339()),
|
||||
&material.error_message,
|
||||
&material.id,
|
||||
),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除素材
|
||||
pub fn delete(&self, id: &str) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
conn.execute("DELETE FROM materials WHERE id = ?1", [id])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 创建素材片段
|
||||
pub fn create_segment(&self, segment: &MaterialSegment) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO material_segments (
|
||||
id, material_id, segment_index, start_time, end_time,
|
||||
duration, file_path, file_size, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
(
|
||||
&segment.id,
|
||||
&segment.material_id,
|
||||
segment.segment_index as i64,
|
||||
segment.start_time,
|
||||
segment.end_time,
|
||||
segment.duration,
|
||||
&segment.file_path,
|
||||
segment.file_size as i64,
|
||||
segment.created_at.to_rfc3339(),
|
||||
),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取素材的所有片段
|
||||
pub fn get_segments(&self, material_id: &str) -> Result<Vec<MaterialSegment>> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, material_id, segment_index, start_time, end_time,
|
||||
duration, file_path, file_size, created_at
|
||||
FROM material_segments WHERE material_id = ?1 ORDER BY segment_index"
|
||||
)?;
|
||||
|
||||
let segment_iter = stmt.query_map([material_id], |row| {
|
||||
self.row_to_segment(row)
|
||||
})?;
|
||||
|
||||
let mut segments = Vec::new();
|
||||
for segment in segment_iter {
|
||||
segments.push(segment?);
|
||||
}
|
||||
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
/// 获取项目素材统计信息
|
||||
pub fn get_project_stats(&self, project_id: &str) -> Result<MaterialStats> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
// 获取基本统计
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN material_type = '\"Video\"' THEN 1 ELSE 0 END) as video_count,
|
||||
SUM(CASE WHEN material_type = '\"Audio\"' THEN 1 ELSE 0 END) as audio_count,
|
||||
SUM(CASE WHEN material_type = '\"Image\"' THEN 1 ELSE 0 END) as image_count,
|
||||
SUM(CASE WHEN material_type NOT IN ('\"Video\"', '\"Audio\"', '\"Image\"') THEN 1 ELSE 0 END) as other_count,
|
||||
SUM(file_size) as total_size
|
||||
FROM materials WHERE project_id = ?1"
|
||||
)?;
|
||||
|
||||
let (total_materials, video_count, audio_count, image_count, other_count, total_size) =
|
||||
stmt.query_row([project_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)? as u32,
|
||||
row.get::<_, i64>(1)? as u32,
|
||||
row.get::<_, i64>(2)? as u32,
|
||||
row.get::<_, i64>(3)? as u32,
|
||||
row.get::<_, i64>(4)? as u32,
|
||||
row.get::<_, i64>(5)? as u64,
|
||||
))
|
||||
})?;
|
||||
|
||||
// 获取处理状态统计
|
||||
let mut status_stmt = conn.prepare(
|
||||
"SELECT processing_status, COUNT(*) FROM materials WHERE project_id = ?1 GROUP BY processing_status"
|
||||
)?;
|
||||
|
||||
let status_iter = status_stmt.query_map([project_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, i64>(1)? as u32,
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut processing_status_counts = std::collections::HashMap::new();
|
||||
for status_result in status_iter {
|
||||
let (status, count) = status_result?;
|
||||
processing_status_counts.insert(status, count);
|
||||
}
|
||||
|
||||
// TODO: 计算总时长(需要解析metadata)
|
||||
let total_duration = 0.0;
|
||||
|
||||
Ok(MaterialStats {
|
||||
total_materials,
|
||||
video_count,
|
||||
audio_count,
|
||||
image_count,
|
||||
other_count,
|
||||
total_size,
|
||||
total_duration,
|
||||
processing_status_counts,
|
||||
})
|
||||
}
|
||||
|
||||
/// 将数据库行转换为素材对象
|
||||
fn row_to_material(&self, row: &Row) -> rusqlite::Result<Material> {
|
||||
let metadata_str: String = row.get("metadata")?;
|
||||
let metadata: MaterialMetadata = serde_json::from_str(&metadata_str)
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("metadata").unwrap(),
|
||||
format!("JSON parse error: {}", e),
|
||||
rusqlite::types::Type::Text
|
||||
))?;
|
||||
|
||||
let scene_detection: Option<SceneDetection> = row.get::<_, Option<String>>("scene_detection")?
|
||||
.map(|s| serde_json::from_str(&s))
|
||||
.transpose()
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("scene_detection").unwrap(),
|
||||
format!("JSON parse error: {}", e),
|
||||
rusqlite::types::Type::Text
|
||||
))?;
|
||||
|
||||
let material_type_str: String = row.get("material_type")?;
|
||||
let material_type: MaterialType = serde_json::from_str(&material_type_str)
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("material_type").unwrap(),
|
||||
format!("JSON parse error: {}", e),
|
||||
rusqlite::types::Type::Text
|
||||
))?;
|
||||
|
||||
let processing_status_str: String = row.get("processing_status")?;
|
||||
let processing_status: ProcessingStatus = serde_json::from_str(&processing_status_str)
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("processing_status").unwrap(),
|
||||
format!("JSON parse error: {}", e),
|
||||
rusqlite::types::Type::Text
|
||||
))?;
|
||||
|
||||
Ok(Material {
|
||||
id: row.get("id")?,
|
||||
project_id: row.get("project_id")?,
|
||||
name: row.get("name")?,
|
||||
original_path: row.get("original_path")?,
|
||||
file_size: row.get::<_, i64>("file_size")? as u64,
|
||||
md5_hash: row.get("md5_hash")?,
|
||||
material_type,
|
||||
processing_status,
|
||||
metadata,
|
||||
scene_detection,
|
||||
segments: Vec::new(), // 需要单独查询
|
||||
created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>("created_at")?)
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("created_at").unwrap(),
|
||||
format!("DateTime parse error: {}", e),
|
||||
rusqlite::types::Type::Text
|
||||
))?.with_timezone(&chrono::Utc),
|
||||
updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>("updated_at")?)
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("updated_at").unwrap(),
|
||||
format!("DateTime parse error: {}", e),
|
||||
rusqlite::types::Type::Text
|
||||
))?.with_timezone(&chrono::Utc),
|
||||
processed_at: row.get::<_, Option<String>>("processed_at")?
|
||||
.map(|s| chrono::DateTime::parse_from_rfc3339(&s))
|
||||
.transpose()
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("processed_at").unwrap(),
|
||||
format!("DateTime parse error: {}", e),
|
||||
rusqlite::types::Type::Text
|
||||
))?
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc)),
|
||||
error_message: row.get("error_message")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 将数据库行转换为素材片段对象
|
||||
fn row_to_segment(&self, row: &Row) -> rusqlite::Result<MaterialSegment> {
|
||||
Ok(MaterialSegment {
|
||||
id: row.get("id")?,
|
||||
material_id: row.get("material_id")?,
|
||||
segment_index: row.get::<_, i64>("segment_index")? as u32,
|
||||
start_time: row.get("start_time")?,
|
||||
end_time: row.get("end_time")?,
|
||||
duration: row.get("duration")?,
|
||||
file_path: row.get("file_path")?,
|
||||
file_size: row.get::<_, i64>("file_size")? as u64,
|
||||
created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>("created_at")?)
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("created_at").unwrap(),
|
||||
format!("DateTime parse error: {}", e),
|
||||
rusqlite::types::Type::Text
|
||||
))?.with_timezone(&chrono::Utc),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod project_repository;
|
||||
pub mod material_repository;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use rusqlite::{Connection, Result};
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
/// 数据库管理器
|
||||
/// 遵循 Tauri 开发规范的数据库设计模式
|
||||
@@ -12,7 +13,19 @@ impl Database {
|
||||
/// 创建新的数据库实例
|
||||
/// 遵循安全第一原则,确保数据库文件的安全存储
|
||||
pub fn new() -> Result<Self> {
|
||||
let db_path = Self::get_database_path();
|
||||
let app_data_dir = dirs::data_dir()
|
||||
.ok_or_else(|| anyhow!("无法获取应用数据目录"))?
|
||||
.join("mixvideo");
|
||||
|
||||
std::fs::create_dir_all(&app_data_dir)?;
|
||||
let db_path = app_data_dir.join("mixvideo.db");
|
||||
|
||||
Self::new_with_path(db_path.to_str().unwrap())
|
||||
}
|
||||
|
||||
/// 使用指定路径创建数据库实例(主要用于测试)
|
||||
pub fn new_with_path(db_path: &str) -> Result<Self> {
|
||||
let db_path = std::path::PathBuf::from(db_path);
|
||||
|
||||
// 打印数据库路径用于调试
|
||||
println!("Initializing database at: {}", db_path.display());
|
||||
@@ -96,6 +109,57 @@ impl Database {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建素材表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS materials (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
original_path TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
md5_hash TEXT NOT NULL,
|
||||
material_type TEXT NOT NULL,
|
||||
processing_status TEXT NOT NULL DEFAULT 'Pending',
|
||||
metadata TEXT,
|
||||
scene_detection TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
processed_at DATETIME,
|
||||
error_message TEXT,
|
||||
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE,
|
||||
UNIQUE(project_id, md5_hash)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建素材片段表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS material_segments (
|
||||
id TEXT PRIMARY KEY,
|
||||
material_id TEXT NOT NULL,
|
||||
segment_index INTEGER NOT NULL,
|
||||
start_time REAL NOT NULL,
|
||||
end_time REAL NOT NULL,
|
||||
duration REAL NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (material_id) REFERENCES materials (id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建性能监控表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS performance_metrics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
metric_name TEXT NOT NULL,
|
||||
metric_value REAL NOT NULL,
|
||||
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_projects_name ON projects (name)",
|
||||
@@ -107,6 +171,33 @@ impl Database {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建素材表索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_materials_project_id ON materials (project_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_materials_md5_hash ON materials (md5_hash)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_materials_type ON materials (material_type)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_materials_status ON materials (processing_status)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建素材片段表索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_segments_material_id ON material_segments (material_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
315
apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs
Normal file
315
apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs
Normal file
@@ -0,0 +1,315 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use serde_json::Value;
|
||||
use std::process::Command;
|
||||
use std::path::Path;
|
||||
use crate::data::models::material::{VideoMetadata, AudioMetadata, MaterialMetadata};
|
||||
|
||||
/// FFmpeg 工具集成
|
||||
/// 遵循 Tauri 开发规范的基础设施层设计
|
||||
pub struct FFmpegService;
|
||||
|
||||
impl FFmpegService {
|
||||
/// 检查 FFmpeg 是否可用
|
||||
pub fn is_available() -> bool {
|
||||
Command::new("ffprobe")
|
||||
.arg("-version")
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 提取视频/音频元数据
|
||||
pub fn extract_metadata(file_path: &str) -> Result<MaterialMetadata> {
|
||||
if !Path::new(file_path).exists() {
|
||||
return Err(anyhow!("文件不存在: {}", file_path));
|
||||
}
|
||||
|
||||
let output = Command::new("ffprobe")
|
||||
.args([
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
file_path
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| anyhow!("执行 ffprobe 失败: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!("ffprobe 执行失败: {}", error_msg));
|
||||
}
|
||||
|
||||
let json_str = String::from_utf8_lossy(&output.stdout);
|
||||
let json: Value = serde_json::from_str(&json_str)
|
||||
.map_err(|e| anyhow!("解析 ffprobe 输出失败: {}", e))?;
|
||||
|
||||
Self::parse_metadata(&json)
|
||||
}
|
||||
|
||||
/// 解析 ffprobe 输出的 JSON 数据
|
||||
fn parse_metadata(json: &Value) -> Result<MaterialMetadata> {
|
||||
let format = json.get("format")
|
||||
.ok_or_else(|| anyhow!("缺少 format 信息"))?;
|
||||
|
||||
let streams = json.get("streams")
|
||||
.and_then(|s| s.as_array())
|
||||
.ok_or_else(|| anyhow!("缺少 streams 信息"))?;
|
||||
|
||||
// 查找视频流
|
||||
let video_stream = streams.iter()
|
||||
.find(|stream| stream.get("codec_type").and_then(|t| t.as_str()) == Some("video"));
|
||||
|
||||
// 查找音频流
|
||||
let audio_stream = streams.iter()
|
||||
.find(|stream| stream.get("codec_type").and_then(|t| t.as_str()) == Some("audio"));
|
||||
|
||||
if let Some(video) = video_stream {
|
||||
// 这是一个视频文件
|
||||
let duration = format.get("duration")
|
||||
.and_then(|d| d.as_str())
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let width = video.get("width")
|
||||
.and_then(|w| w.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
let height = video.get("height")
|
||||
.and_then(|h| h.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
let fps = video.get("r_frame_rate")
|
||||
.and_then(|fps| fps.as_str())
|
||||
.and_then(|s| Self::parse_fraction(s))
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let bitrate = format.get("bit_rate")
|
||||
.and_then(|b| b.as_str())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let codec = video.get("codec_name")
|
||||
.and_then(|c| c.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let format_name = format.get("format_name")
|
||||
.and_then(|f| f.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let has_audio = audio_stream.is_some();
|
||||
let audio_codec = audio_stream
|
||||
.and_then(|a| a.get("codec_name"))
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let audio_bitrate = audio_stream
|
||||
.and_then(|a| a.get("bit_rate"))
|
||||
.and_then(|b| b.as_str())
|
||||
.and_then(|s| s.parse::<u64>().ok());
|
||||
|
||||
let audio_sample_rate = audio_stream
|
||||
.and_then(|a| a.get("sample_rate"))
|
||||
.and_then(|r| r.as_str())
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
|
||||
Ok(MaterialMetadata::Video(VideoMetadata {
|
||||
duration,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate,
|
||||
codec,
|
||||
format: format_name,
|
||||
has_audio,
|
||||
audio_codec,
|
||||
audio_bitrate,
|
||||
audio_sample_rate,
|
||||
}))
|
||||
|
||||
} else if let Some(audio) = audio_stream {
|
||||
// 这是一个音频文件
|
||||
let duration = format.get("duration")
|
||||
.and_then(|d| d.as_str())
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let bitrate = format.get("bit_rate")
|
||||
.and_then(|b| b.as_str())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let codec = audio.get("codec_name")
|
||||
.and_then(|c| c.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let sample_rate = audio.get("sample_rate")
|
||||
.and_then(|r| r.as_str())
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let channels = audio.get("channels")
|
||||
.and_then(|c| c.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
let format_name = format.get("format_name")
|
||||
.and_then(|f| f.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
Ok(MaterialMetadata::Audio(AudioMetadata {
|
||||
duration,
|
||||
bitrate,
|
||||
codec,
|
||||
sample_rate,
|
||||
channels,
|
||||
format: format_name,
|
||||
}))
|
||||
|
||||
} else {
|
||||
Err(anyhow!("未找到有效的视频或音频流"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析分数格式的帧率 (如 "30/1")
|
||||
fn parse_fraction(fraction_str: &str) -> Option<f64> {
|
||||
let parts: Vec<&str> = fraction_str.split('/').collect();
|
||||
if parts.len() == 2 {
|
||||
if let (Ok(numerator), Ok(denominator)) = (parts[0].parse::<f64>(), parts[1].parse::<f64>()) {
|
||||
if denominator != 0.0 {
|
||||
return Some(numerator / denominator);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 检测视频场景变化
|
||||
pub fn detect_scenes(file_path: &str, threshold: f64) -> Result<Vec<f64>> {
|
||||
if !Path::new(file_path).exists() {
|
||||
return Err(anyhow!("文件不存在: {}", file_path));
|
||||
}
|
||||
|
||||
let output = Command::new("ffprobe")
|
||||
.args([
|
||||
"-f", "lavfi",
|
||||
"-i", &format!("movie={}:s=v:0[in];[in]select=gt(scene\\,{}),showinfo[out]", file_path, threshold),
|
||||
"-show_entries", "frame=pkt_pts_time",
|
||||
"-of", "csv=p=0",
|
||||
"-v", "quiet"
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| anyhow!("执行场景检测失败: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!("场景检测失败: {}", error_msg));
|
||||
}
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
let mut scene_times = Vec::new();
|
||||
|
||||
for line in output_str.lines() {
|
||||
if let Ok(time) = line.trim().parse::<f64>() {
|
||||
scene_times.push(time);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(scene_times)
|
||||
}
|
||||
|
||||
/// 切分视频
|
||||
pub fn split_video(
|
||||
input_path: &str,
|
||||
output_dir: &str,
|
||||
segments: &[(f64, f64)], // (start_time, end_time) pairs
|
||||
output_prefix: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
if !Path::new(input_path).exists() {
|
||||
return Err(anyhow!("输入文件不存在: {}", input_path));
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(output_dir)
|
||||
.map_err(|e| anyhow!("创建输出目录失败: {}", e))?;
|
||||
|
||||
let mut output_files = Vec::new();
|
||||
|
||||
for (index, (start_time, end_time)) in segments.iter().enumerate() {
|
||||
let duration = end_time - start_time;
|
||||
let output_file = format!("{}/{}_{:03}.mp4", output_dir, output_prefix, index + 1);
|
||||
|
||||
let output = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i", input_path,
|
||||
"-ss", &start_time.to_string(),
|
||||
"-t", &duration.to_string(),
|
||||
"-c", "copy",
|
||||
"-avoid_negative_ts", "make_zero",
|
||||
"-y", // 覆盖输出文件
|
||||
&output_file
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| anyhow!("执行视频切分失败: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!("视频切分失败: {}", error_msg));
|
||||
}
|
||||
|
||||
output_files.push(output_file);
|
||||
}
|
||||
|
||||
Ok(output_files)
|
||||
}
|
||||
|
||||
/// 获取视频缩略图
|
||||
pub fn generate_thumbnail(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
timestamp: f64,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<()> {
|
||||
if !Path::new(input_path).exists() {
|
||||
return Err(anyhow!("输入文件不存在: {}", input_path));
|
||||
}
|
||||
|
||||
let output = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i", input_path,
|
||||
"-ss", ×tamp.to_string(),
|
||||
"-vframes", "1",
|
||||
"-vf", &format!("scale={}:{}", width, height),
|
||||
"-y", // 覆盖输出文件
|
||||
output_path
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| anyhow!("执行缩略图生成失败: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!("缩略图生成失败: {}", error_msg));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查 FFmpeg 版本
|
||||
pub fn get_version() -> Result<String> {
|
||||
let output = Command::new("ffmpeg")
|
||||
.arg("-version")
|
||||
.output()
|
||||
.map_err(|e| anyhow!("获取 FFmpeg 版本失败: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!("FFmpeg 不可用"));
|
||||
}
|
||||
|
||||
let version_str = String::from_utf8_lossy(&output.stdout);
|
||||
let first_line = version_str.lines().next().unwrap_or("Unknown version");
|
||||
Ok(first_line.to_string())
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,4 @@ pub mod database;
|
||||
pub mod file_system;
|
||||
pub mod performance;
|
||||
pub mod event_bus;
|
||||
pub mod ffmpeg;
|
||||
|
||||
@@ -8,6 +8,10 @@ pub mod presentation;
|
||||
pub mod app_state;
|
||||
pub mod config;
|
||||
|
||||
// 测试模块
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use app_state::AppState;
|
||||
use presentation::commands;
|
||||
use tauri::Manager;
|
||||
@@ -33,7 +37,25 @@ pub fn run() {
|
||||
commands::system_commands::get_directory_name,
|
||||
commands::system_commands::get_database_info,
|
||||
commands::system_commands::get_performance_report,
|
||||
commands::system_commands::cleanup_invalid_projects
|
||||
commands::system_commands::cleanup_invalid_projects,
|
||||
commands::material_commands::import_materials,
|
||||
commands::material_commands::get_project_materials,
|
||||
commands::material_commands::get_material_by_id,
|
||||
commands::material_commands::delete_material,
|
||||
commands::material_commands::get_project_material_stats,
|
||||
commands::material_commands::batch_process_materials,
|
||||
commands::material_commands::update_material_status,
|
||||
commands::material_commands::cleanup_invalid_materials,
|
||||
commands::material_commands::is_supported_format,
|
||||
commands::material_commands::get_supported_extensions,
|
||||
commands::material_commands::select_material_files,
|
||||
commands::material_commands::validate_material_files,
|
||||
commands::material_commands::get_file_info,
|
||||
commands::material_commands::check_ffmpeg_available,
|
||||
commands::material_commands::get_ffmpeg_version,
|
||||
commands::material_commands::extract_file_metadata,
|
||||
commands::material_commands::detect_video_scenes,
|
||||
commands::material_commands::generate_video_thumbnail
|
||||
])
|
||||
.setup(|app| {
|
||||
// 初始化应用状态
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
use tauri::{command, State};
|
||||
use crate::app_state::AppState;
|
||||
use crate::business::services::material_service::MaterialService;
|
||||
use crate::infrastructure::ffmpeg::FFmpegService;
|
||||
use crate::data::models::material::{
|
||||
CreateMaterialRequest, MaterialImportResult, MaterialProcessingConfig,
|
||||
Material, MaterialStats, ProcessingStatus, MaterialMetadata
|
||||
};
|
||||
|
||||
|
||||
/// 导入素材命令
|
||||
/// 遵循 Tauri 开发规范的命令设计模式
|
||||
#[command]
|
||||
pub async fn import_materials(
|
||||
state: State<'_, AppState>,
|
||||
request: CreateMaterialRequest,
|
||||
) -> Result<MaterialImportResult, String> {
|
||||
let repository_guard = state.get_material_repository()
|
||||
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("素材仓库未初始化")?;
|
||||
|
||||
let config = MaterialProcessingConfig::default();
|
||||
|
||||
MaterialService::import_materials(repository, request, &config)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取项目素材列表命令
|
||||
#[command]
|
||||
pub async fn get_project_materials(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
) -> Result<Vec<Material>, String> {
|
||||
let repository_guard = state.get_material_repository()
|
||||
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("素材仓库未初始化")?;
|
||||
|
||||
MaterialService::get_project_materials(repository, &project_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取素材详情命令
|
||||
#[command]
|
||||
pub async fn get_material_by_id(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<Option<Material>, String> {
|
||||
let repository_guard = state.get_material_repository()
|
||||
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("素材仓库未初始化")?;
|
||||
|
||||
MaterialService::get_material_by_id(repository, &id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 删除素材命令
|
||||
#[command]
|
||||
pub async fn delete_material(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_material_repository()
|
||||
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("素材仓库未初始化")?;
|
||||
|
||||
MaterialService::delete_material(repository, &id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取项目素材统计命令
|
||||
#[command]
|
||||
pub async fn get_project_material_stats(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
) -> Result<MaterialStats, String> {
|
||||
let repository_guard = state.get_material_repository()
|
||||
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("素材仓库未初始化")?;
|
||||
|
||||
MaterialService::get_project_stats(repository, &project_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 批量处理素材命令
|
||||
#[command]
|
||||
pub async fn batch_process_materials(
|
||||
state: State<'_, AppState>,
|
||||
material_ids: Vec<String>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let repository_guard = state.get_material_repository()
|
||||
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("素材仓库未初始化")?;
|
||||
|
||||
let config = MaterialProcessingConfig::default();
|
||||
|
||||
MaterialService::batch_process_materials(repository, material_ids, &config)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新素材处理状态命令
|
||||
#[command]
|
||||
pub async fn update_material_status(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
status: String,
|
||||
error_message: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_material_repository()
|
||||
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("素材仓库未初始化")?;
|
||||
|
||||
// 解析状态字符串
|
||||
let processing_status = match status.as_str() {
|
||||
"Pending" => ProcessingStatus::Pending,
|
||||
"Processing" => ProcessingStatus::Processing,
|
||||
"Completed" => ProcessingStatus::Completed,
|
||||
"Failed" => ProcessingStatus::Failed,
|
||||
"Skipped" => ProcessingStatus::Skipped,
|
||||
_ => return Err("无效的处理状态".to_string()),
|
||||
};
|
||||
|
||||
MaterialService::update_material_status(repository, &id, processing_status, error_message)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 清理失效素材命令
|
||||
#[command]
|
||||
pub async fn cleanup_invalid_materials(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
) -> Result<String, String> {
|
||||
let repository_guard = state.get_material_repository()
|
||||
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("素材仓库未初始化")?;
|
||||
|
||||
let cleaned_count = MaterialService::cleanup_invalid_materials(repository, &project_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(format!("已清理 {} 个失效的素材记录", cleaned_count))
|
||||
}
|
||||
|
||||
/// 检查文件是否为支持格式命令
|
||||
#[command]
|
||||
pub async fn is_supported_format(file_path: String) -> Result<bool, String> {
|
||||
Ok(MaterialService::is_supported_format(&file_path))
|
||||
}
|
||||
|
||||
/// 获取支持的文件扩展名命令
|
||||
#[command]
|
||||
pub async fn get_supported_extensions() -> Result<Vec<String>, String> {
|
||||
let extensions = MaterialService::get_supported_extensions()
|
||||
.into_iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
Ok(extensions)
|
||||
}
|
||||
|
||||
/// 选择素材文件命令
|
||||
#[command]
|
||||
pub async fn select_material_files(app: tauri::AppHandle) -> Result<Vec<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let dialog = app.dialog().file()
|
||||
.set_title("选择素材文件")
|
||||
.add_filter("视频文件", &["mp4", "avi", "mov", "mkv", "wmv", "flv", "webm", "m4v"])
|
||||
.add_filter("音频文件", &["mp3", "wav", "flac", "aac", "ogg", "wma", "m4a"])
|
||||
.add_filter("图片文件", &["jpg", "jpeg", "png", "gif", "bmp", "tiff", "webp", "svg"])
|
||||
.add_filter("文档文件", &["pdf", "doc", "docx", "txt", "md", "rtf"])
|
||||
.add_filter("所有支持的文件", &MaterialService::get_supported_extensions());
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
dialog.pick_files(move |file_paths| {
|
||||
let paths = file_paths.map(|paths| {
|
||||
paths.into_iter()
|
||||
.map(|path| path.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
}).unwrap_or_default();
|
||||
let _ = tx.send(paths);
|
||||
});
|
||||
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(30)) {
|
||||
Ok(paths) => Ok(paths),
|
||||
Err(_) => Err("文件选择操作超时或失败".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证素材文件命令
|
||||
#[command]
|
||||
pub async fn validate_material_files(file_paths: Vec<String>) -> Result<Vec<String>, String> {
|
||||
let mut valid_files = Vec::new();
|
||||
|
||||
for file_path in file_paths {
|
||||
if std::path::Path::new(&file_path).exists() &&
|
||||
MaterialService::is_supported_format(&file_path) {
|
||||
valid_files.push(file_path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(valid_files)
|
||||
}
|
||||
|
||||
/// 获取文件信息命令
|
||||
#[command]
|
||||
pub async fn get_file_info(file_path: String) -> Result<serde_json::Value, String> {
|
||||
use std::path::Path;
|
||||
|
||||
let path = Path::new(&file_path);
|
||||
|
||||
if !path.exists() {
|
||||
return Err("文件不存在".to_string());
|
||||
}
|
||||
|
||||
let metadata = std::fs::metadata(path)
|
||||
.map_err(|e| format!("获取文件信息失败: {}", e))?;
|
||||
|
||||
let file_name = path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
let extension = path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let material_type = crate::data::models::material::MaterialType::from_extension(extension);
|
||||
|
||||
let info = serde_json::json!({
|
||||
"name": file_name,
|
||||
"path": file_path,
|
||||
"size": metadata.len(),
|
||||
"extension": extension,
|
||||
"material_type": material_type,
|
||||
"is_supported": MaterialService::is_supported_format(&file_path),
|
||||
"modified": metadata.modified()
|
||||
.map(|time| time.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs())
|
||||
.unwrap_or(0)
|
||||
});
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// 检查 FFmpeg 是否可用命令
|
||||
#[command]
|
||||
pub async fn check_ffmpeg_available() -> Result<bool, String> {
|
||||
Ok(FFmpegService::is_available())
|
||||
}
|
||||
|
||||
/// 获取 FFmpeg 版本命令
|
||||
#[command]
|
||||
pub async fn get_ffmpeg_version() -> Result<String, String> {
|
||||
FFmpegService::get_version().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 提取文件元数据命令
|
||||
#[command]
|
||||
pub async fn extract_file_metadata(file_path: String) -> Result<MaterialMetadata, String> {
|
||||
FFmpegService::extract_metadata(&file_path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 检测视频场景命令
|
||||
#[command]
|
||||
pub async fn detect_video_scenes(
|
||||
file_path: String,
|
||||
threshold: f64,
|
||||
) -> Result<Vec<f64>, String> {
|
||||
FFmpegService::detect_scenes(&file_path, threshold).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 生成视频缩略图命令
|
||||
#[command]
|
||||
pub async fn generate_video_thumbnail(
|
||||
input_path: String,
|
||||
output_path: String,
|
||||
timestamp: f64,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<(), String> {
|
||||
FFmpegService::generate_thumbnail(&input_path, &output_path, timestamp, width, height)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod project_commands;
|
||||
pub mod system_commands;
|
||||
pub mod material_commands;
|
||||
|
||||
213
apps/desktop/src-tauri/src/tests/material_tests.rs
Normal file
213
apps/desktop/src-tauri/src/tests/material_tests.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::business::services::material_service::MaterialService;
|
||||
use crate::data::models::material::{MaterialType, ProcessingStatus};
|
||||
use crate::data::repositories::material_repository::MaterialRepository;
|
||||
use crate::infrastructure::database::Database;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tempfile::tempdir;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
/// 创建测试数据库
|
||||
fn create_test_database() -> anyhow::Result<Arc<Mutex<rusqlite::Connection>>> {
|
||||
let temp_dir = tempdir()?;
|
||||
let db_path = temp_dir.path().join("test.db");
|
||||
let database = Database::new_with_path(db_path.to_str().unwrap())?;
|
||||
Ok(database.get_connection())
|
||||
}
|
||||
|
||||
/// 创建测试文件
|
||||
fn create_test_file(content: &[u8]) -> anyhow::Result<(String, tempfile::TempDir)> {
|
||||
let temp_dir = tempdir()?;
|
||||
let file_path = temp_dir.path().join("test_file.txt");
|
||||
let mut file = File::create(&file_path)?;
|
||||
file.write_all(content)?;
|
||||
file.flush()?;
|
||||
Ok((file_path.to_string_lossy().to_string(), temp_dir))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_material_type_from_extension() {
|
||||
assert_eq!(MaterialType::from_extension("mp4"), MaterialType::Video);
|
||||
assert_eq!(MaterialType::from_extension("MP4"), MaterialType::Video);
|
||||
assert_eq!(MaterialType::from_extension("mp3"), MaterialType::Audio);
|
||||
assert_eq!(MaterialType::from_extension("jpg"), MaterialType::Image);
|
||||
assert_eq!(MaterialType::from_extension("pdf"), MaterialType::Document);
|
||||
assert_eq!(MaterialType::from_extension("unknown"), MaterialType::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_supported_format_detection() {
|
||||
assert!(MaterialService::is_supported_format("test.mp4"));
|
||||
assert!(MaterialService::is_supported_format("test.MP3"));
|
||||
assert!(MaterialService::is_supported_format("test.jpg"));
|
||||
assert!(!MaterialService::is_supported_format("test.xyz"));
|
||||
assert!(!MaterialService::is_supported_format("test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_supported_extensions() {
|
||||
let extensions = MaterialService::get_supported_extensions();
|
||||
assert!(extensions.contains(&"mp4"));
|
||||
assert!(extensions.contains(&"mp3"));
|
||||
assert!(extensions.contains(&"jpg"));
|
||||
assert!(extensions.contains(&"pdf"));
|
||||
assert!(extensions.len() > 20); // 应该有很多支持的格式
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_material_repository_crud() -> anyhow::Result<()> {
|
||||
let connection = create_test_database()?;
|
||||
let material_repository = MaterialRepository::new(connection.clone())?;
|
||||
let project_repository = crate::data::repositories::project_repository::ProjectRepository::new(connection)?;
|
||||
|
||||
// 先创建一个测试项目
|
||||
let test_project = crate::data::models::project::Project::new(
|
||||
"Test Project".to_string(),
|
||||
"/path/to/test/project".to_string(),
|
||||
Some("Test project description".to_string()),
|
||||
);
|
||||
project_repository.create(&test_project)?;
|
||||
|
||||
// 创建测试素材
|
||||
let mut material = crate::data::models::material::Material::new(
|
||||
test_project.id.clone(),
|
||||
"test_file.mp4".to_string(),
|
||||
"/path/to/test_file.mp4".to_string(),
|
||||
1024 * 1024, // 1MB
|
||||
"test_md5_hash".to_string(),
|
||||
MaterialType::Video,
|
||||
);
|
||||
|
||||
// 测试创建
|
||||
material_repository.create(&material)?;
|
||||
|
||||
// 测试读取
|
||||
let retrieved = material_repository.get_by_id(&material.id)?;
|
||||
assert!(retrieved.is_some());
|
||||
let retrieved = retrieved.unwrap();
|
||||
assert_eq!(retrieved.name, material.name);
|
||||
assert_eq!(retrieved.project_id, material.project_id);
|
||||
|
||||
// 测试更新
|
||||
material.update_status(ProcessingStatus::Completed, None);
|
||||
material_repository.update(&material)?;
|
||||
|
||||
let updated = material_repository.get_by_id(&material.id)?.unwrap();
|
||||
assert_eq!(updated.processing_status, ProcessingStatus::Completed);
|
||||
|
||||
// 测试MD5检查
|
||||
assert!(material_repository.exists_by_md5(&test_project.id, "test_md5_hash")?);
|
||||
assert!(!material_repository.exists_by_md5(&test_project.id, "different_hash")?);
|
||||
|
||||
// 测试按项目ID获取
|
||||
let project_materials = material_repository.get_by_project_id(&test_project.id)?;
|
||||
assert_eq!(project_materials.len(), 1);
|
||||
|
||||
// 测试删除
|
||||
material_repository.delete(&material.id)?;
|
||||
let deleted = material_repository.get_by_id(&material.id)?;
|
||||
assert!(deleted.is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_md5_calculation() -> anyhow::Result<()> {
|
||||
let test_content = b"Hello, World!";
|
||||
let (file_path, _temp_dir) = create_test_file(test_content)?;
|
||||
|
||||
// 计算MD5
|
||||
let md5_hash = MaterialService::calculate_md5(&file_path)?;
|
||||
|
||||
// 验证MD5值("Hello, World!" 的MD5是 65a8e27d8879283831b664bd8b7f0ad4)
|
||||
assert_eq!(md5_hash, "65a8e27d8879283831b664bd8b7f0ad4");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_material_needs_segmentation() {
|
||||
let mut material = crate::data::models::material::Material::new(
|
||||
"test_project".to_string(),
|
||||
"test.mp4".to_string(),
|
||||
"/path/test.mp4".to_string(),
|
||||
1000,
|
||||
"hash".to_string(),
|
||||
MaterialType::Video,
|
||||
);
|
||||
|
||||
// 设置视频元数据
|
||||
let video_metadata = crate::data::models::material::VideoMetadata {
|
||||
duration: 600.0, // 10分钟
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 30.0,
|
||||
bitrate: 5000000,
|
||||
codec: "h264".to_string(),
|
||||
format: "mp4".to_string(),
|
||||
has_audio: true,
|
||||
audio_codec: Some("aac".to_string()),
|
||||
audio_bitrate: Some(128000),
|
||||
audio_sample_rate: Some(44100),
|
||||
};
|
||||
|
||||
material.set_metadata(crate::data::models::material::MaterialMetadata::Video(video_metadata));
|
||||
|
||||
// 测试是否需要切分
|
||||
assert!(material.needs_segmentation(300.0)); // 5分钟限制,应该需要切分
|
||||
assert!(!material.needs_segmentation(700.0)); // 11分钟限制,不需要切分
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_fixed_segments() {
|
||||
let segments = MaterialService::create_fixed_segments(900.0, 300.0); // 15分钟视频,5分钟片段
|
||||
|
||||
assert_eq!(segments.len(), 3);
|
||||
assert_eq!(segments[0], (0.0, 300.0));
|
||||
assert_eq!(segments[1], (300.0, 600.0));
|
||||
assert_eq!(segments[2], (600.0, 900.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_segments_from_scenes() {
|
||||
let scenes = vec![
|
||||
crate::data::models::material::SceneSegment {
|
||||
start_time: 0.0,
|
||||
end_time: 120.0,
|
||||
duration: 120.0,
|
||||
scene_id: 0,
|
||||
confidence: 1.0,
|
||||
},
|
||||
crate::data::models::material::SceneSegment {
|
||||
start_time: 120.0,
|
||||
end_time: 240.0,
|
||||
duration: 120.0,
|
||||
scene_id: 1,
|
||||
confidence: 1.0,
|
||||
},
|
||||
crate::data::models::material::SceneSegment {
|
||||
start_time: 240.0,
|
||||
end_time: 360.0,
|
||||
duration: 120.0,
|
||||
scene_id: 2,
|
||||
confidence: 1.0,
|
||||
},
|
||||
];
|
||||
|
||||
let segments = MaterialService::create_segments_from_scenes(&scenes, 300.0); // 5分钟限制
|
||||
|
||||
// 前两个场景应该合并为一个片段(240秒 < 300秒)
|
||||
// 第三个场景单独成为一个片段
|
||||
assert_eq!(segments.len(), 2);
|
||||
assert_eq!(segments[0], (0.0, 240.0));
|
||||
assert_eq!(segments[1], (240.0, 360.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_material_stats_calculation() {
|
||||
// 这个测试需要实际的数据库连接,所以暂时跳过
|
||||
// 在实际项目中,可以使用模拟数据库或测试数据库
|
||||
}
|
||||
}
|
||||
4
apps/desktop/src-tauri/src/tests/mod.rs
Normal file
4
apps/desktop/src-tauri/src/tests/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
/// 测试模块
|
||||
/// 遵循 Tauri 开发规范的测试组织结构
|
||||
|
||||
pub mod material_tests;
|
||||
Reference in New Issue
Block a user