mixvideo-v2/apps/desktop/src/components/MaterialSegmentView.tsx

579 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState, useMemo } from 'react';
import {
Filter,
Tag,
Users,
Video,
RefreshCw,
Eye,
Edit,
Trash2,
FolderOpen
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { SearchInput } from './InteractiveInput';
import { InteractiveButton } from './InteractiveButton';
interface MaterialSegmentViewProps {
projectId: string;
}
interface SegmentWithDetails {
segment: {
id: string;
material_id: string;
start_time: number;
end_time: number;
duration: number;
file_path: string;
};
material_name: string;
material_type: string;
classification?: {
category: string;
confidence: number;
reasoning: string;
features: string[];
product_match: boolean;
quality_score: number;
};
model?: {
id: string;
name: string;
model_type: string;
};
}
interface ClassificationGroup {
category: string;
segment_count: number;
total_duration: number;
segments: SegmentWithDetails[];
}
interface ModelGroup {
model_id: string;
model_name: string;
segment_count: number;
total_duration: number;
segments: SegmentWithDetails[];
}
interface MaterialSegmentView {
project_id: string;
by_classification: ClassificationGroup[];
by_model: ModelGroup[];
stats: {
total_segments: number;
classified_segments: number;
unclassified_segments: number;
classification_coverage: number;
classification_counts: Record<string, number>;
model_counts: Record<string, number>;
total_duration: number;
};
}
// 提取文件名的工具函数
const extractFileName = (filePath: string): string => {
if (!filePath) return '未知文件';
// 处理Windows路径格式包括长路径前缀
const cleanPath = filePath.replace(/^\\\\\?\\/, '');
const parts = cleanPath.split(/[\\\/]/);
return parts[parts.length - 1] || '未知文件';
};
// 打开文件所在目录
const openFileDirectory = async (filePath: string) => {
try {
await invoke('open_file_directory', { filePath });
} catch (error) {
console.error('打开目录失败:', error);
}
};
// 播放视频片段
const playVideoSegment = async (filePath: string, startTime: number, endTime: number) => {
try {
await invoke('play_video_segment', {
filePath,
startTime,
endTime
});
} catch (error) {
console.error('播放视频失败:', error);
}
};
// 生成片段缩略图(使用首帧)
const generateSegmentThumbnail = async (segment: SegmentWithDetails): Promise<string | null> => {
try {
// 使用片段的开始时间作为缩略图时间戳(首帧)
const timestamp = 0;
// 生成缩略图文件名
const thumbnailFileName = `${segment.segment.id}_thumbnail.jpg`;
const thumbnailPath = `${segment.segment.file_path.replace(/\.[^/.]+$/, '')}_${thumbnailFileName}`;
await invoke('generate_video_thumbnail', {
inputPath: segment.segment.file_path,
outputPath: thumbnailPath,
timestamp,
width: 160,
height: 120
});
return thumbnailPath;
} catch (error) {
console.error('生成缩略图失败:', error);
return null;
}
};
// 缩略图显示组件
interface ThumbnailDisplayProps {
segment: SegmentWithDetails;
thumbnailCache: Map<string, string>;
setThumbnailCache: React.Dispatch<React.SetStateAction<Map<string, string>>>;
generateSegmentThumbnail: (segment: SegmentWithDetails) => Promise<string | null>;
}
const ThumbnailDisplay: React.FC<ThumbnailDisplayProps> = ({
segment,
thumbnailCache,
setThumbnailCache,
generateSegmentThumbnail
}) => {
const [loading, setLoading] = useState(false);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
useEffect(() => {
const loadThumbnail = async () => {
const segmentId = segment.segment.id;
// 检查缓存
if (thumbnailCache.has(segmentId)) {
setThumbnailUrl(thumbnailCache.get(segmentId) || null);
return;
}
// 生成缩略图
setLoading(true);
try {
const thumbnailPath = await generateSegmentThumbnail(segment);
if (thumbnailPath) {
// 转换为可访问的URL这里需要根据实际情况调整
const thumbnailUrl = `file://${thumbnailPath}`;
setThumbnailUrl(thumbnailUrl);
// 更新缓存
setThumbnailCache(prev => new Map(prev.set(segmentId, thumbnailUrl)));
}
} catch (error) {
console.error('加载缩略图失败:', error);
} finally {
setLoading(false);
}
};
loadThumbnail();
}, [segment.segment.id, thumbnailCache, setThumbnailCache, generateSegmentThumbnail]);
return (
<div className="w-20 h-16 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
{loading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
) : thumbnailUrl ? (
<img
src={thumbnailUrl}
alt="视频缩略图"
className="w-full h-full object-cover rounded-lg"
onError={() => setThumbnailUrl(null)}
/>
) : (
<Video className="w-8 h-8 text-gray-400" />
)}
</div>
);
};
/**
* 素材片段管理组件 - 多条件检索标签页风格
*/
export const MaterialSegmentView: React.FC<MaterialSegmentViewProps> = ({ projectId }) => {
const [segmentView, setSegmentView] = useState<MaterialSegmentView | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [selectedClassification, setSelectedClassification] = useState<string>('全部');
const [selectedModel, setSelectedModel] = useState<string>('全部');
const [thumbnailCache, setThumbnailCache] = useState<Map<string, string>>(new Map());
// 加载数据
const loadSegmentView = async () => {
try {
setLoading(true);
setError(null);
const data = await invoke('get_project_segment_view', { projectId }) as MaterialSegmentView;
setSegmentView(data);
} catch (err) {
console.error('Failed to load segment view:', err);
setError('加载片段数据失败');
} finally {
setLoading(false);
}
};
// 初始加载
useEffect(() => {
if (projectId) {
loadSegmentView();
}
}, [projectId]);
// 格式化时长
const formatDuration = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
// 获取分类选项
const classificationOptions = useMemo(() => {
if (!segmentView) return [{ label: '全部', value: '全部', count: 0 }];
const options = [
{
label: '全部',
value: '全部',
count: segmentView.stats.total_segments
}
];
Object.entries(segmentView.stats.classification_counts).forEach(([category, count]) => {
options.push({
label: category || '未分类',
value: category || '未分类',
count
});
});
return options;
}, [segmentView]);
// 获取模特选项
const modelOptions = useMemo(() => {
if (!segmentView) return [{ label: '全部', value: '全部', count: 0 }];
const options = [
{
label: '全部',
value: '全部',
count: segmentView.stats.total_segments
}
];
Object.entries(segmentView.stats.model_counts).forEach(([modelName, count]) => {
options.push({
label: modelName || '未指定',
value: modelName || '未指定',
count
});
});
return options;
}, [segmentView]);
// 获取过滤后的片段
const filteredSegments = useMemo(() => {
if (!segmentView) return [];
// 获取所有片段
let segments: SegmentWithDetails[] = [];
segmentView.by_classification.forEach(group => {
segments.push(...group.segments);
});
// 应用分类过滤
if (selectedClassification !== '全部') {
segments = segments.filter(segment =>
segment.classification?.category === selectedClassification
);
}
// 应用模特过滤
if (selectedModel !== '全部') {
segments = segments.filter(segment =>
segment.model?.name === selectedModel
);
}
// 应用搜索过滤
if (searchTerm.trim()) {
const searchLower = searchTerm.toLowerCase();
segments = segments.filter(segment =>
segment.material_name.toLowerCase().includes(searchLower) ||
segment.classification?.category?.toLowerCase().includes(searchLower) ||
segment.model?.name?.toLowerCase().includes(searchLower)
);
}
return segments;
}, [segmentView, selectedClassification, selectedModel, searchTerm]);
// 渲染片段卡片
const renderSegmentCard = (segment: SegmentWithDetails) => {
// 安全检查
if (!segment || !segment.segment || !segment.material_name) {
return null;
}
return (
<div key={segment.segment.id} className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow">
<div className="flex items-start gap-4">
{/* 缩略图 */}
<ThumbnailDisplay
segment={segment}
thumbnailCache={thumbnailCache}
setThumbnailCache={setThumbnailCache}
generateSegmentThumbnail={generateSegmentThumbnail}
/>
{/* 内容信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-gray-900 truncate">
{extractFileName(segment.segment.file_path)}
</h4>
{segment.segment.file_path && (
<button
onClick={() => openFileDirectory(segment.segment.file_path)}
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
title="打开文件所在目录"
>
<FolderOpen size={14} />
</button>
)}
</div>
<p className="text-xs text-gray-500 mt-1">
{formatDuration(segment.segment.start_time)} - {formatDuration(segment.segment.end_time)}
<span className="ml-2">: {formatDuration(segment.segment.duration)}</span>
</p>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-1 ml-2">
<button
onClick={() => playVideoSegment(
segment.segment.file_path,
segment.segment.start_time,
segment.segment.end_time
)}
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
title="播放视频片段"
>
<Eye size={16} />
</button>
<button className="p-1 text-gray-400 hover:text-gray-600 transition-colors">
<Edit size={16} />
</button>
<button className="p-1 text-gray-400 hover:text-red-600 transition-colors">
<Trash2 size={16} />
</button>
</div>
</div>
{/* 标签信息 */}
<div className="flex items-center gap-2 mt-3">
{segment.classification && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<Tag size={12} className="mr-1" />
{segment.classification.category}
</span>
)}
{segment.model && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
<Users size={12} className="mr-1" />
{segment.model.name}
</span>
)}
{segment.classification?.confidence && (
<span className="text-xs text-gray-500">
: {Math.round(segment.classification.confidence * 100)}%
</span>
)}
</div>
</div>
</div>
</div>
);
};
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-center py-12">
<div className="flex items-center gap-3">
<RefreshCw className="w-6 h-6 animate-spin text-primary-600" />
<span className="text-gray-600">...</span>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div className="text-center py-12">
<p className="text-red-600 mb-4">{error}</p>
<InteractiveButton
variant="primary"
onClick={loadSegmentView}
icon={<RefreshCw size={16} />}
>
</InteractiveButton>
</div>
</div>
);
}
if (!segmentView) {
return (
<div className="text-center py-12">
<p className="text-gray-500"></p>
</div>
);
}
return (
<div className="space-y-6">
{/* 搜索和刷新 */}
<div className="flex items-center justify-between gap-4">
<div className="flex-1 max-w-md">
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder="搜索片段、分类或模特..."
/>
</div>
<InteractiveButton
variant="secondary"
onClick={loadSegmentView}
icon={<RefreshCw size={16} />}
>
</InteractiveButton>
</div>
{/* 筛选条件 */}
<div className="space-y-4">
{/* AI分类筛选 - 单行显示 */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 flex-shrink-0">
<Tag size={16} className="text-gray-600" />
<span className="text-sm font-medium text-gray-700">AI分类</span>
</div>
<div className="flex flex-wrap gap-2">
{classificationOptions.map(option => (
<button
key={option.value}
onClick={() => setSelectedClassification(option.value)}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
selectedClassification === option.value
? 'bg-blue-100 text-blue-800 border border-blue-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-200'
}`}
>
<span>{option.label}</span>
<span className="ml-1.5 px-1.5 py-0.5 bg-white rounded-full text-xs">
{option.count}
</span>
</button>
))}
</div>
</div>
{/* 模特筛选 - 单行显示 */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 flex-shrink-0">
<Users size={16} className="text-gray-600" />
<span className="text-sm font-medium text-gray-700"></span>
</div>
<div className="flex flex-wrap gap-2">
{modelOptions.map(option => (
<button
key={option.value}
onClick={() => setSelectedModel(option.value)}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
selectedModel === option.value
? 'bg-green-100 text-green-800 border border-green-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-200'
}`}
>
<span>{option.label}</span>
<span className="ml-1.5 px-1.5 py-0.5 bg-white rounded-full text-xs">
{option.count}
</span>
</button>
))}
</div>
</div>
{/* 当前筛选条件显示 */}
{(selectedClassification !== '全部' || selectedModel !== '全部') && (
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<Filter size={16} className="text-gray-500" />
<span className="text-sm text-gray-600"></span>
{selectedClassification !== '全部' && (
<span className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
AI分类: {selectedClassification}
</span>
)}
{selectedClassification !== '全部' && selectedModel !== '全部' && (
<span className="text-xs text-gray-500">AND</span>
)}
{selectedModel !== '全部' && (
<span className="inline-flex items-center px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
: {selectedModel}
</span>
)}
<button
onClick={() => {
setSelectedClassification('全部');
setSelectedModel('全部');
}}
className="ml-auto text-xs text-gray-500 hover:text-gray-700"
>
</button>
</div>
)}
</div>
{/* 片段列表 */}
<div className="space-y-4">
{filteredSegments.length > 0 ? (
filteredSegments.map(segment => renderSegmentCard(segment))
) : (
<div className="text-center py-12">
<div className="flex flex-col items-center">
<Video className="w-12 h-12 text-gray-400 mb-4" />
<p className="text-gray-500 mb-2"></p>
<p className="text-sm text-gray-400">
{searchTerm ? '尝试调整搜索条件' : '选择不同的分类或模特'}
</p>
</div>
</div>
)}
</div>
</div>
);
};