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::extract_file_metadata,
|
||||||
commands::material_commands::detect_video_scenes,
|
commands::material_commands::detect_video_scenes,
|
||||||
commands::material_commands::generate_video_thumbnail,
|
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| {
|
.setup(|app| {
|
||||||
// 初始化应用状态
|
// 初始化应用状态
|
||||||
|
|
|
||||||
|
|
@ -341,3 +341,19 @@ pub async fn test_scene_detection(file_path: String) -> Result<String, String> {
|
||||||
|
|
||||||
Ok(result)
|
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 React, { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
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 { useProjectStore } from '../store/projectStore';
|
||||||
import { useMaterialStore } from '../store/materialStore';
|
import { useMaterialStore } from '../store/materialStore';
|
||||||
import { Project } from '../types/project';
|
import { Project } from '../types/project';
|
||||||
import { MaterialImportResult } from '../types/material';
|
import { MaterialImportResult, Material, MaterialSegment } from '../types/material';
|
||||||
import { LoadingSpinner } from '../components/LoadingSpinner';
|
import { LoadingSpinner } from '../components/LoadingSpinner';
|
||||||
import { ErrorMessage } from '../components/ErrorMessage';
|
import { ErrorMessage } from '../components/ErrorMessage';
|
||||||
import { MaterialImportDialog } from '../components/MaterialImportDialog';
|
import { MaterialImportDialog } from '../components/MaterialImportDialog';
|
||||||
import { FFmpegDebugPanel } from '../components/FFmpegDebugPanel';
|
import { FFmpegDebugPanel } from '../components/FFmpegDebugPanel';
|
||||||
|
import { MaterialCard } from '../components/MaterialCard';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 项目详情页面组件
|
* 项目详情页面组件
|
||||||
|
|
@ -240,18 +241,7 @@ export const ProjectDetails: React.FC = () => {
|
||||||
) : materials.length > 0 ? (
|
) : materials.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{materials.map((material) => (
|
{materials.map((material) => (
|
||||||
<div key={material.id} className="border border-gray-200 rounded-lg p-4">
|
<MaterialCard key={material.id} material={material} />
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -313,4 +313,15 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
|
||||||
return `获取状态失败: ${error}`;
|
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