feat: 为模板匹配片段添加缩略图功能
- 创建SegmentThumbnail组件,支持懒加载和缓存 - 修改TemplateMatchingResultDetailModal,集成缩略图显示 - 添加get_material_segment_by_id API命令获取片段详细信息 - 优化片段信息布局,简化显示内容(只显示片段名称和匹配原因) - 支持通过material_segment_id获取和显示实际的片段文件名
This commit is contained in:
parent
66f50a80c6
commit
822bfe6e9c
|
|
@ -79,6 +79,7 @@ pub fn run() {
|
|||
commands::material_commands::get_segment_thumbnail_base64,
|
||||
commands::material_commands::test_scene_detection,
|
||||
commands::material_commands::get_material_segments,
|
||||
commands::material_commands::get_material_segment_by_id,
|
||||
commands::material_commands::test_video_split,
|
||||
commands::material_commands::associate_material_to_model,
|
||||
commands::material_commands::disassociate_material_from_model,
|
||||
|
|
|
|||
|
|
@ -1467,6 +1467,22 @@ pub async fn get_material_segments(
|
|||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 根据片段ID获取片段详细信息命令
|
||||
#[command]
|
||||
pub async fn get_material_segment_by_id(
|
||||
state: State<'_, AppState>,
|
||||
segment_id: String,
|
||||
) -> Result<Option<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_segment_by_id_sync(&segment_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 测试视频切分命令(用于调试不同切分模式)
|
||||
#[command]
|
||||
pub async fn test_video_split(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Loader2, Image as ImageIcon } from 'lucide-react';
|
||||
import { useLazyLoad } from '../hooks/useLazyLoad';
|
||||
|
||||
interface SegmentThumbnailProps {
|
||||
segmentId: string;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
className?: string;
|
||||
thumbnailCache?: Map<string, string>;
|
||||
setThumbnailCache?: React.Dispatch<React.SetStateAction<Map<string, string>>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 片段缩略图组件
|
||||
* 专门用于显示模板匹配结果中的片段缩略图
|
||||
* 遵循Tauri开发规范的组件设计模式
|
||||
* 支持懒加载、缓存机制、错误处理
|
||||
*/
|
||||
export const SegmentThumbnail: React.FC<SegmentThumbnailProps> = ({
|
||||
segmentId,
|
||||
size = 'medium',
|
||||
className = '',
|
||||
thumbnailCache = new Map(),
|
||||
setThumbnailCache = () => {},
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
// 使用懒加载Hook,当缩略图容器可见时才开始加载
|
||||
const { isVisible, elementRef } = useLazyLoad(0.1, '100px');
|
||||
|
||||
// 根据size确定尺寸
|
||||
const getSizeClasses = () => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return 'w-12 h-12';
|
||||
case 'medium':
|
||||
return 'w-16 h-16';
|
||||
case 'large':
|
||||
return 'w-24 h-24';
|
||||
default:
|
||||
return 'w-16 h-16';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取图标尺寸
|
||||
const getIconSize = () => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return 'w-3 h-3';
|
||||
case 'medium':
|
||||
return 'w-4 h-4';
|
||||
case 'large':
|
||||
return 'w-6 h-6';
|
||||
default:
|
||||
return 'w-4 h-4';
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 只有当元素可见时才加载缩略图
|
||||
if (!isVisible || !segmentId) return;
|
||||
|
||||
const loadThumbnail = async () => {
|
||||
// 检查缓存
|
||||
if (thumbnailCache.has(segmentId)) {
|
||||
const cachedUrl = thumbnailCache.get(segmentId);
|
||||
setThumbnailUrl(cachedUrl || null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载缩略图
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
try {
|
||||
console.log('获取片段缩略图:', segmentId);
|
||||
const dataUrl = await invoke<string>('get_segment_thumbnail_base64', {
|
||||
segmentId: segmentId
|
||||
});
|
||||
console.log('获取缩略图成功');
|
||||
setThumbnailUrl(dataUrl);
|
||||
|
||||
// 更新缓存
|
||||
const newCache = new Map(thumbnailCache);
|
||||
newCache.set(segmentId, dataUrl);
|
||||
setThumbnailCache(newCache);
|
||||
} catch (error) {
|
||||
console.error('获取缩略图失败:', error);
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadThumbnail();
|
||||
}, [isVisible, segmentId, thumbnailCache, setThumbnailCache]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={elementRef}
|
||||
className={`${getSizeClasses()} bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden ${className}`}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className={`${getIconSize()} animate-spin text-blue-600`} />
|
||||
) : thumbnailUrl && !error ? (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt="片段缩略图"
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
onError={() => {
|
||||
setError(true);
|
||||
setThumbnailUrl(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className={`${getIconSize()} text-gray-400`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
import {
|
||||
TemplateMatchingResultDetail,
|
||||
MatchingResultStatus
|
||||
} from '../types/templateMatchingResult';
|
||||
|
|
@ -8,6 +8,7 @@ import { Modal } from './Modal';
|
|||
import { LoadingSpinner } from './LoadingSpinner';
|
||||
import { ErrorMessage } from './ErrorMessage';
|
||||
import { TabNavigation } from './TabNavigation';
|
||||
import { SegmentThumbnail } from './SegmentThumbnail';
|
||||
|
||||
interface TemplateMatchingResultDetailModalProps {
|
||||
resultId: string;
|
||||
|
|
@ -24,6 +25,8 @@ export const TemplateMatchingResultDetailModal: React.FC<TemplateMatchingResultD
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [thumbnailCache, setThumbnailCache] = useState<Map<string, string>>(new Map());
|
||||
const [segmentDetails, setSegmentDetails] = useState<Map<string, any>>(new Map());
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editForm, setEditForm] = useState({
|
||||
result_name: '',
|
||||
|
|
@ -104,6 +107,49 @@ export const TemplateMatchingResultDetailModal: React.FC<TemplateMatchingResultD
|
|||
return minutes > 0 ? `${minutes}:${remainingSeconds.padStart(4, '0')}` : `${remainingSeconds}s`;
|
||||
};
|
||||
|
||||
// 从文件路径提取文件名
|
||||
const extractFileName = (filePath: string): string => {
|
||||
if (!filePath) return '';
|
||||
return filePath.split(/[/\\]/).pop() || filePath;
|
||||
};
|
||||
|
||||
// 获取片段详细信息
|
||||
const getSegmentDetails = async (segmentId: string) => {
|
||||
if (segmentDetails.has(segmentId)) {
|
||||
return segmentDetails.get(segmentId);
|
||||
}
|
||||
|
||||
try {
|
||||
const segmentInfo = await invoke('get_material_segment_by_id', { segmentId });
|
||||
setSegmentDetails(prev => new Map(prev.set(segmentId, segmentInfo)));
|
||||
return segmentInfo;
|
||||
} catch (error) {
|
||||
console.error('获取片段详情失败:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染片段信息组件
|
||||
const SegmentInfo: React.FC<{ segment: any }> = ({ segment }) => {
|
||||
const [segmentDetail, setSegmentDetail] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSegmentDetail = async () => {
|
||||
const detail = await getSegmentDetails(segment.material_segment_id);
|
||||
setSegmentDetail(detail);
|
||||
};
|
||||
loadSegmentDetail();
|
||||
}, [segment.material_segment_id]);
|
||||
|
||||
return (
|
||||
<div className="text-sm text-gray-600">
|
||||
<div>
|
||||
<span className="font-medium">素材:</span> {segmentDetail ? extractFileName(segmentDetail.file_path) : segment.material_segment_id}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 获取状态样式
|
||||
const getStatusStyle = (status: MatchingResultStatus) => {
|
||||
switch (status) {
|
||||
|
|
@ -383,22 +429,28 @@ export const TemplateMatchingResultDetailModal: React.FC<TemplateMatchingResultD
|
|||
<div className="space-y-3">
|
||||
{segment_results.map((segment) => (
|
||||
<div key={segment.id} className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{segment.track_segment_name}</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
素材: {segment.material_name}
|
||||
{segment.model_name && ` | 模特: ${segment.model_name}`}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
时长: {formatTime(segment.segment_duration)} |
|
||||
时间: {formatTime(segment.start_time)} - {formatTime(segment.end_time)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
匹配原因: {segment.match_reason}
|
||||
</p>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* 缩略图 */}
|
||||
<SegmentThumbnail
|
||||
segmentId={segment.material_segment_id}
|
||||
size="large"
|
||||
thumbnailCache={thumbnailCache}
|
||||
setThumbnailCache={setThumbnailCache}
|
||||
/>
|
||||
|
||||
{/* 片段信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-gray-900 mb-2">{segment.track_segment_name}</h4>
|
||||
|
||||
<SegmentInfo segment={segment} />
|
||||
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
<span className="font-medium">匹配原因:</span> {segment.match_reason}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
|
||||
{/* 匹配度 */}
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className="text-lg font-bold text-green-600">
|
||||
{(segment.match_score * 100).toFixed(1)}%
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue