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::get_segment_thumbnail_base64,
|
||||||
commands::material_commands::test_scene_detection,
|
commands::material_commands::test_scene_detection,
|
||||||
commands::material_commands::get_material_segments,
|
commands::material_commands::get_material_segments,
|
||||||
|
commands::material_commands::get_material_segment_by_id,
|
||||||
commands::material_commands::test_video_split,
|
commands::material_commands::test_video_split,
|
||||||
commands::material_commands::associate_material_to_model,
|
commands::material_commands::associate_material_to_model,
|
||||||
commands::material_commands::disassociate_material_from_model,
|
commands::material_commands::disassociate_material_from_model,
|
||||||
|
|
|
||||||
|
|
@ -1467,6 +1467,22 @@ pub async fn get_material_segments(
|
||||||
.map_err(|e| e.to_string())
|
.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]
|
#[command]
|
||||||
pub async fn test_video_split(
|
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 React, { useState, useEffect } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import {
|
import {
|
||||||
TemplateMatchingResultDetail,
|
TemplateMatchingResultDetail,
|
||||||
MatchingResultStatus
|
MatchingResultStatus
|
||||||
} from '../types/templateMatchingResult';
|
} from '../types/templateMatchingResult';
|
||||||
|
|
@ -8,6 +8,7 @@ import { Modal } from './Modal';
|
||||||
import { LoadingSpinner } from './LoadingSpinner';
|
import { LoadingSpinner } from './LoadingSpinner';
|
||||||
import { ErrorMessage } from './ErrorMessage';
|
import { ErrorMessage } from './ErrorMessage';
|
||||||
import { TabNavigation } from './TabNavigation';
|
import { TabNavigation } from './TabNavigation';
|
||||||
|
import { SegmentThumbnail } from './SegmentThumbnail';
|
||||||
|
|
||||||
interface TemplateMatchingResultDetailModalProps {
|
interface TemplateMatchingResultDetailModalProps {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
|
|
@ -24,6 +25,8 @@ export const TemplateMatchingResultDetailModal: React.FC<TemplateMatchingResultD
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState('overview');
|
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 [editMode, setEditMode] = useState(false);
|
||||||
const [editForm, setEditForm] = useState({
|
const [editForm, setEditForm] = useState({
|
||||||
result_name: '',
|
result_name: '',
|
||||||
|
|
@ -104,6 +107,49 @@ export const TemplateMatchingResultDetailModal: React.FC<TemplateMatchingResultD
|
||||||
return minutes > 0 ? `${minutes}:${remainingSeconds.padStart(4, '0')}` : `${remainingSeconds}s`;
|
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) => {
|
const getStatusStyle = (status: MatchingResultStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|
@ -383,22 +429,28 @@ export const TemplateMatchingResultDetailModal: React.FC<TemplateMatchingResultD
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{segment_results.map((segment) => (
|
{segment_results.map((segment) => (
|
||||||
<div key={segment.id} className="bg-green-50 border border-green-200 rounded-lg p-4">
|
<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 items-start gap-4">
|
||||||
<div className="flex-1">
|
{/* 缩略图 */}
|
||||||
<h4 className="font-medium text-gray-900">{segment.track_segment_name}</h4>
|
<SegmentThumbnail
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
segmentId={segment.material_segment_id}
|
||||||
素材: {segment.material_name}
|
size="large"
|
||||||
{segment.model_name && ` | 模特: ${segment.model_name}`}
|
thumbnailCache={thumbnailCache}
|
||||||
</p>
|
setThumbnailCache={setThumbnailCache}
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
/>
|
||||||
时长: {formatTime(segment.segment_duration)} |
|
|
||||||
时间: {formatTime(segment.start_time)} - {formatTime(segment.end_time)}
|
{/* 片段信息 */}
|
||||||
</p>
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<h4 className="font-medium text-gray-900 mb-2">{segment.track_segment_name}</h4>
|
||||||
匹配原因: {segment.match_reason}
|
|
||||||
</p>
|
<SegmentInfo segment={segment} />
|
||||||
|
|
||||||
|
<div className="mt-2 text-sm text-gray-500">
|
||||||
|
<span className="font-medium">匹配原因:</span> {segment.match_reason}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
|
||||||
|
{/* 匹配度 */}
|
||||||
|
<div className="text-right flex-shrink-0">
|
||||||
<div className="text-lg font-bold text-green-600">
|
<div className="text-lg font-bold text-green-600">
|
||||||
{(segment.match_score * 100).toFixed(1)}%
|
{(segment.match_score * 100).toFixed(1)}%
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue