feat: 添加视频切分片段查看功能
新增功能: 1. MaterialCard组件 - 增强的素材卡片 - 显示素材基本信息和状态 - 支持展开查看切分片段详情 - 片段时间格式化显示 - 文件位置快速访问 2. get_material_segments命令 - 获取指定素材的所有切分片段 - 返回完整的片段信息包括文件路径 3. 前端store集成 - getMaterialSegments方法 - 错误处理和加载状态 4. 用户界面改进 - 状态颜色编码(完成/处理中/失败/等待) - 可折叠的片段列表 - 时间格式化显示(分:秒) - 片段索引和时长信息 视频切分结果保存位置: - 文件系统: 原视频路径_segments/原视频名_001.mp4 - 数据库: material_segments表存储片段元信息 - 前端: 通过MaterialCard组件可视化查看 现在用户可以: 查看每个素材的切分状态 展开查看具体的切分片段 了解每个片段的时间范围和文件位置 快速访问切分后的文件
This commit is contained in:
parent
44e9100f56
commit
704e6d8fff
|
|
@ -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| {
|
||||
// 初始化应用状态
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
Loading…
Reference in New Issue