feat: 添加视频切分片段查看功能

新增功能:
1. MaterialCard组件 - 增强的素材卡片
   - 显示素材基本信息和状态
   - 支持展开查看切分片段详情
   - 片段时间格式化显示
   - 文件位置快速访问

2. get_material_segments命令
   - 获取指定素材的所有切分片段
   - 返回完整的片段信息包括文件路径

3. 前端store集成
   - getMaterialSegments方法
   - 错误处理和加载状态

4. 用户界面改进
   - 状态颜色编码(完成/处理中/失败/等待)
   - 可折叠的片段列表
   - 时间格式化显示(分:秒)
   - 片段索引和时长信息

视频切分结果保存位置:
- 文件系统: 原视频路径_segments/原视频名_001.mp4
- 数据库: material_segments表存储片段元信息
- 前端: 通过MaterialCard组件可视化查看

现在用户可以:
 查看每个素材的切分状态
 展开查看具体的切分片段
 了解每个片段的时间范围和文件位置
 快速访问切分后的文件
This commit is contained in:
imeepos 2025-07-13 21:12:01 +08:00
parent 44e9100f56
commit 704e6d8fff
5 changed files with 190 additions and 15 deletions

View File

@ -57,7 +57,8 @@ pub fn run() {
commands::material_commands::extract_file_metadata,
commands::material_commands::detect_video_scenes,
commands::material_commands::generate_video_thumbnail,
commands::material_commands::test_scene_detection
commands::material_commands::test_scene_detection,
commands::material_commands::get_material_segments
])
.setup(|app| {
// 初始化应用状态

View File

@ -341,3 +341,19 @@ pub async fn test_scene_detection(file_path: String) -> Result<String, String> {
Ok(result)
}
/// 获取素材的切分片段信息命令
#[command]
pub async fn get_material_segments(
state: State<'_, AppState>,
material_id: String,
) -> Result<Vec<crate::data::models::material::MaterialSegment>, String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
repository.get_segments(&material_id)
.map_err(|e| e.to_string())
}

View File

@ -0,0 +1,157 @@
import React, { useState, useEffect } from 'react';
import { FileVideo, FileAudio, FileImage, File, Clock, ExternalLink, ChevronDown, ChevronUp } from 'lucide-react';
import { Material, MaterialSegment } from '../types/material';
import { useMaterialStore } from '../store/materialStore';
interface MaterialCardProps {
material: Material;
}
/**
*
*
*/
export const MaterialCard: React.FC<MaterialCardProps> = ({ material }) => {
const { getMaterialSegments } = useMaterialStore();
const [segments, setSegments] = useState<MaterialSegment[]>([]);
const [showSegments, setShowSegments] = useState(false);
const [loadingSegments, setLoadingSegments] = useState(false);
// 获取素材类型图标
const getTypeIcon = (type: string) => {
switch (type) {
case 'Video':
return <FileVideo className="w-4 h-4" />;
case 'Audio':
return <FileAudio className="w-4 h-4" />;
case 'Image':
return <FileImage className="w-4 h-4" />;
default:
return <File className="w-4 h-4" />;
}
};
// 获取状态颜色
const getStatusColor = (status: string) => {
switch (status) {
case 'Completed':
return 'text-green-600 bg-green-50';
case 'Processing':
return 'text-blue-600 bg-blue-50';
case 'Failed':
return 'text-red-600 bg-red-50';
case 'Pending':
return 'text-yellow-600 bg-yellow-50';
default:
return 'text-gray-600 bg-gray-50';
}
};
// 格式化时间
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
// 加载切分片段
const loadSegments = async () => {
if (segments.length > 0) {
setShowSegments(!showSegments);
return;
}
setLoadingSegments(true);
try {
const materialSegments = await getMaterialSegments(material.id);
setSegments(materialSegments);
setShowSegments(true);
} catch (error) {
console.error('加载切分片段失败:', error);
} finally {
setLoadingSegments(false);
}
};
// 打开文件所在文件夹
const openFileLocation = (filePath: string) => {
// 这里可以调用 Tauri 的 API 来打开文件夹
console.log('打开文件位置:', filePath);
};
return (
<div className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
{/* 素材基本信息 */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center space-x-2 flex-1 min-w-0">
{getTypeIcon(material.material_type)}
<h4 className="font-medium text-gray-900 truncate">{material.name}</h4>
</div>
<span className={`px-2 py-1 text-xs rounded-full ${getStatusColor(material.processing_status)}`}>
{material.processing_status}
</span>
</div>
{/* 素材详细信息 */}
<div className="space-y-1 text-sm text-gray-600">
<p>: {material.material_type}</p>
<p>: {(material.file_size / 1024 / 1024).toFixed(2)} MB</p>
{material.segments && material.segments.length > 0 && (
<p>: {material.segments.length}</p>
)}
</div>
{/* 切分片段控制 */}
{material.material_type === 'Video' && material.processing_status === 'Completed' && (
<div className="mt-3 pt-3 border-t border-gray-100">
<button
onClick={loadSegments}
disabled={loadingSegments}
className="flex items-center space-x-2 text-sm text-blue-600 hover:text-blue-800 disabled:opacity-50"
>
{loadingSegments ? (
<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
) : showSegments ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
<span>{loadingSegments ? '加载中...' : showSegments ? '隐藏片段' : '查看切分片段'}</span>
</button>
{/* 切分片段列表 */}
{showSegments && segments.length > 0 && (
<div className="mt-3 space-y-2">
<h5 className="text-sm font-medium text-gray-900"> ({segments.length})</h5>
<div className="space-y-1 max-h-40 overflow-y-auto">
{segments.map((segment) => (
<div key={segment.id} className="flex items-center justify-between p-2 bg-gray-50 rounded text-xs">
<div className="flex items-center space-x-2">
<span className="font-medium">#{segment.segment_index + 1}</span>
<Clock className="w-3 h-3" />
<span>{formatTime(segment.start_time)} - {formatTime(segment.end_time)}</span>
<span className="text-gray-500">({formatTime(segment.duration)})</span>
</div>
<button
onClick={() => openFileLocation(segment.file_path)}
className="text-blue-600 hover:text-blue-800"
title="打开文件位置"
>
<ExternalLink className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
{showSegments && segments.length === 0 && (
<div className="mt-3 text-sm text-gray-500">
</div>
)}
</div>
)}
</div>
);
};

View File

@ -1,14 +1,15 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, FolderOpen, Calendar, Settings, Upload } from 'lucide-react';
import { ArrowLeft, FolderOpen, Calendar, Settings, Upload, FileVideo, Clock, ExternalLink } from 'lucide-react';
import { useProjectStore } from '../store/projectStore';
import { useMaterialStore } from '../store/materialStore';
import { Project } from '../types/project';
import { MaterialImportResult } from '../types/material';
import { MaterialImportResult, Material, MaterialSegment } from '../types/material';
import { LoadingSpinner } from '../components/LoadingSpinner';
import { ErrorMessage } from '../components/ErrorMessage';
import { MaterialImportDialog } from '../components/MaterialImportDialog';
import { FFmpegDebugPanel } from '../components/FFmpegDebugPanel';
import { MaterialCard } from '../components/MaterialCard';
/**
*
@ -240,18 +241,7 @@ export const ProjectDetails: React.FC = () => {
) : materials.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{materials.map((material) => (
<div key={material.id} className="border border-gray-200 rounded-lg p-4">
<h4 className="font-medium text-gray-900 truncate">{material.name}</h4>
<p className="text-sm text-gray-600 mt-1">
: {material.material_type}
</p>
<p className="text-sm text-gray-600">
: {material.processing_status}
</p>
<p className="text-sm text-gray-600">
: {(material.file_size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<MaterialCard key={material.id} material={material} />
))}
</div>
) : (

View File

@ -313,4 +313,15 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
return `获取状态失败: ${error}`;
}
},
// 获取素材的切分片段
getMaterialSegments: async (materialId: string) => {
try {
const segments = await invoke<MaterialSegment[]>('get_material_segments', { materialId });
return segments;
} catch (error) {
console.error('获取素材片段失败:', error);
return [];
}
},
}));