feat: 实现项目详情页面和素材导入功能

- 添加项目详情页面路由和组件
- 实现素材数据模型和数据库表结构
- 集成FFmpeg进行视频元数据提取和场景检测
- 实现视频自动切分功能(基于场景检测和时长限制)
- 开发素材导入UI界面和进度显示
- 添加素材管理相关的Tauri命令
- 完善错误处理和性能优化
- 添加单元测试覆盖核心功能

主要功能:
- 项目详情页面展示项目信息和素材统计
- 素材导入支持多种格式(视频、音频、图片、文档)
- MD5重复检测避免重复导入
- FFmpeg集成提取视频/音频元数据
- 智能场景检测和视频切分
- 二次切分处理超长视频片段
- 响应式UI设计和用户友好的导入流程
This commit is contained in:
imeepos
2025-07-13 20:45:05 +08:00
parent 5a5e17d58b
commit fdb87bf64e
16 changed files with 2077 additions and 3 deletions

View File

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

View File

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

View 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)
}
}

View File

@@ -1 +1,2 @@
pub mod project_service;
pub mod material_service;

View 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(),
}
}
}

View File

@@ -1 +1,2 @@
pub mod project;
pub mod material;

View File

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

View File

@@ -1 +1,2 @@
pub mod project_repository;
pub mod material_repository;

View File

@@ -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(())
}

View 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", &timestamp.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())
}
}

View File

@@ -4,3 +4,4 @@ pub mod database;
pub mod file_system;
pub mod performance;
pub mod event_bus;
pub mod ffmpeg;

View File

@@ -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| {
// 初始化应用状态

View File

@@ -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())
}

View File

@@ -1,2 +1,3 @@
pub mod project_commands;
pub mod system_commands;
pub mod material_commands;

View 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() {
// 这个测试需要实际的数据库连接,所以暂时跳过
// 在实际项目中,可以使用模拟数据库或测试数据库
}
}

View File

@@ -0,0 +1,4 @@
/// 测试模块
/// 遵循 Tauri 开发规范的测试组织结构
pub mod material_tests;