20260430_001744-feat: PyTorch CUDA + SAM2 GPU inference, video thumbnail, real FPS + configurable parse FPS, DICOM batch import
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { UploadCloud, Film, Settings2, MoreHorizontal, Plus, Loader2 } from 'lucide-react';
|
||||
import { UploadCloud, Film, Settings2, MoreHorizontal, Plus, Loader2, Activity } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { getProjects, createProject, uploadMedia, parseMedia } from '../lib/api';
|
||||
import { getProjects, createProject, uploadMedia, parseMedia, uploadDicomBatch } from '../lib/api';
|
||||
import type { Project } from '../store/useStore';
|
||||
|
||||
interface ProjectLibraryProps {
|
||||
@@ -19,7 +19,12 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showImportMenu, setShowImportMenu] = useState(false);
|
||||
const [showVideoConfig, setShowVideoConfig] = useState(false);
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [parseFps, setParseFps] = useState(30);
|
||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||
const dicomInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
@@ -50,6 +55,60 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
onProjectSelect();
|
||||
};
|
||||
|
||||
const handleVideoSelect = (file: File) => {
|
||||
setPendingFile(file);
|
||||
setParseFps(30);
|
||||
setShowVideoConfig(true);
|
||||
};
|
||||
|
||||
const handleVideoUpload = async () => {
|
||||
if (!pendingFile) return;
|
||||
setShowVideoConfig(false);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const newProject = await createProject({
|
||||
name: pendingFile.name,
|
||||
description: `导入于 ${new Date().toLocaleString()}`,
|
||||
parse_fps: parseFps,
|
||||
});
|
||||
const result = await uploadMedia(pendingFile, String(newProject.id));
|
||||
await parseMedia(String(newProject.id));
|
||||
alert(`上传成功: ${pendingFile.name}\n已保存至: ${result.url}`);
|
||||
const data = await getProjects();
|
||||
setProjects(data);
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
alert('上传失败,请检查后端服务');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setPendingFile(null);
|
||||
if (videoInputRef.current) videoInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDicomUpload = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
const dcmFiles = Array.from(files).filter((f) => f.name.toLowerCase().endsWith('.dcm'));
|
||||
if (dcmFiles.length === 0) {
|
||||
alert('未选择有效的 .dcm 文件');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await uploadDicomBatch(dcmFiles);
|
||||
await parseMedia(String(result.project_id));
|
||||
alert(`DICOM 上传成功: ${result.uploaded_count} 个文件`);
|
||||
const data = await getProjects();
|
||||
setProjects(data);
|
||||
} catch (err) {
|
||||
console.error('DICOM upload failed:', err);
|
||||
alert('DICOM 上传失败,请检查后端服务');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (dicomInputRef.current) dicomInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const SkeletonCard = () => (
|
||||
<div className="group flex flex-col bg-[#111] border border-white/5 rounded-xl overflow-hidden animate-pulse">
|
||||
<div className="w-full aspect-[16/9] bg-[#1a1a1a]" />
|
||||
@@ -75,45 +134,51 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
<Plus size={18} />
|
||||
<span>新建项目</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 text-white px-5 py-2.5 rounded-lg font-medium text-sm transition-colors border border-cyan-500 shadow-lg shadow-cyan-900/20"
|
||||
>
|
||||
<UploadCloud size={18} />
|
||||
<span>导入多媒体资源</span>
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowImportMenu(!showImportMenu)}
|
||||
className="flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 text-white px-5 py-2.5 rounded-lg font-medium text-sm transition-colors border border-cyan-500 shadow-lg shadow-cyan-900/20"
|
||||
>
|
||||
<UploadCloud size={18} />
|
||||
<span>导入多媒体资源</span>
|
||||
</button>
|
||||
{showImportMenu && (
|
||||
<div className="absolute right-0 top-full mt-2 w-56 bg-[#111] border border-white/10 rounded-lg shadow-2xl z-50 overflow-hidden">
|
||||
<button
|
||||
className="w-full text-left px-4 py-3 text-sm text-gray-200 hover:bg-white/5 flex items-center gap-3 transition-colors"
|
||||
onClick={() => { setShowImportMenu(false); videoInputRef.current?.click(); }}
|
||||
>
|
||||
<Film size={16} className="text-cyan-400" />
|
||||
导入视频文件
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left px-4 py-3 text-sm text-gray-200 hover:bg-white/5 flex items-center gap-3 transition-colors border-t border-white/5"
|
||||
onClick={() => { setShowImportMenu(false); dicomInputRef.current?.click(); }}
|
||||
>
|
||||
<Activity size={16} className="text-emerald-400" />
|
||||
导入 DICOM 序列
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
ref={videoInputRef}
|
||||
className="hidden"
|
||||
accept="video/*,image/*,.dcm"
|
||||
onChange={async (e) => {
|
||||
accept="video/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// 1. 创建项目
|
||||
const newProject = await createProject({
|
||||
name: file.name,
|
||||
description: `导入于 ${new Date().toLocaleString()}`,
|
||||
});
|
||||
// 2. 带 project_id 上传
|
||||
const result = await uploadMedia(file, String(newProject.id));
|
||||
// 3. 触发帧解析
|
||||
await parseMedia(String(newProject.id));
|
||||
alert(`上传成功: ${file.name}\n已保存至: ${result.url}`);
|
||||
// 4. 刷新项目列表
|
||||
const data = await getProjects();
|
||||
setProjects(data);
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
alert('上传失败,请检查后端服务');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
if (file) handleVideoSelect(file);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref={dicomInputRef}
|
||||
className="hidden"
|
||||
accept=".dcm"
|
||||
multiple
|
||||
onChange={(e) => handleDicomUpload(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -126,29 +191,38 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{projects.map((proj) => (
|
||||
<div
|
||||
key={proj.id}
|
||||
<div
|
||||
key={proj.id}
|
||||
className="group flex flex-col bg-[#111] border border-white/5 rounded-xl overflow-hidden cursor-pointer hover:border-cyan-500/50 transition-all hover:shadow-[0_0_20px_rgba(6,182,212,0.15)]"
|
||||
onClick={() => handleSelect(proj)}
|
||||
>
|
||||
<div className={cn("w-full aspect-[16/9] relative flex items-center justify-center overflow-hidden", proj.thumbnail || 'bg-[#0d0d0d]')}>
|
||||
<Film className="w-12 h-12 text-[#2a2a2a] group-hover:text-[#333] transition-colors" />
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] font-mono px-2 py-1 rounded border border-white/10 uppercase tracking-widest">
|
||||
{proj.fps || '30FPS'}
|
||||
</span>
|
||||
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] px-2 py-1 rounded border border-white/10 flex items-center gap-1 uppercase tracking-widest">
|
||||
{proj.status === 'Ready' || proj.status === 'ready' ? (
|
||||
<><div className="w-1.5 h-1.5 bg-emerald-500 rounded-full" /> 已就绪</>
|
||||
) : proj.status === 'Parsing' || proj.status === 'parsing' ? (
|
||||
<><div className="w-1.5 h-1.5 bg-amber-500 rounded-full animate-pulse" /> 解析拆帧中</>
|
||||
) : proj.status === 'pending' || proj.status === 'Pending' ? (
|
||||
<><div className="w-1.5 h-1.5 bg-blue-500 rounded-full" /> 待处理</>
|
||||
) : (
|
||||
<><div className="w-1.5 h-1.5 bg-red-500 rounded-full" /> 异常</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn("w-full aspect-[16/9] relative flex items-center justify-center overflow-hidden bg-[#0d0d0d]")}>
|
||||
{proj.thumbnail_url ? (
|
||||
<img
|
||||
src={proj.thumbnail_url}
|
||||
alt={proj.name}
|
||||
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<Film className="w-12 h-12 text-[#2a2a2a] group-hover:text-[#333] transition-colors" />
|
||||
)}
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] font-mono px-2 py-1 rounded border border-white/10 uppercase tracking-widest">
|
||||
{proj.source_type === 'dicom' ? 'DICOM' : (proj.fps || '30FPS')}
|
||||
</span>
|
||||
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] px-2 py-1 rounded border border-white/10 flex items-center gap-1 uppercase tracking-widest">
|
||||
{proj.status === 'Ready' || proj.status === 'ready' ? (
|
||||
<><div className="w-1.5 h-1.5 bg-emerald-500 rounded-full" /> 已就绪</>
|
||||
) : proj.status === 'Parsing' || proj.status === 'parsing' ? (
|
||||
<><div className="w-1.5 h-1.5 bg-amber-500 rounded-full animate-pulse" /> 解析拆帧中</>
|
||||
) : proj.status === 'pending' || proj.status === 'Pending' ? (
|
||||
<><div className="w-1.5 h-1.5 bg-blue-500 rounded-full" /> 待处理</>
|
||||
) : (
|
||||
<><div className="w-1.5 h-1.5 bg-red-500 rounded-full" /> 异常</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 flex flex-col gap-1">
|
||||
<div className="flex justify-between items-start">
|
||||
@@ -157,6 +231,9 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 font-mono mt-2">
|
||||
<span className="flex items-center gap-1.5"><Settings2 size={12} /> {proj.frames ?? 0} 帧节点</span>
|
||||
{proj.original_fps && (
|
||||
<span className="flex items-center gap-1.5 text-cyan-400/80"><Activity size={12} /> 原 {proj.original_fps.toFixed(1)}fps</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,6 +241,48 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video parse FPS config modal */}
|
||||
{showVideoConfig && pendingFile && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">导入视频配置</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-400">文件: <span className="text-gray-200">{pendingFile.name}</span></div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2">解析帧率 (FPS)</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="60"
|
||||
value={parseFps}
|
||||
onChange={(e) => setParseFps(parseInt(e.target.value))}
|
||||
className="flex-1 accent-cyan-500"
|
||||
/>
|
||||
<span className="text-sm font-mono text-cyan-400 w-12 text-right">{parseFps}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-600 mt-1">帧率越低,提取的帧越少,处理速度越快</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => { setShowVideoConfig(false); setPendingFile(null); }}
|
||||
className="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleVideoUpload}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-cyan-500 hover:bg-cyan-400 text-black transition-all"
|
||||
>
|
||||
开始导入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New project modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
||||
|
||||
@@ -48,10 +48,14 @@ export async function getProjects(): Promise<Project[]> {
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
frames: p.frame_count ?? 0,
|
||||
fps: '30FPS',
|
||||
fps: p.original_fps ? `${Math.round(p.original_fps)}FPS` : '30FPS',
|
||||
thumbnail_url: p.thumbnail_url,
|
||||
video_path: p.video_path,
|
||||
source_type: p.source_type,
|
||||
original_fps: p.original_fps,
|
||||
parse_fps: p.parse_fps,
|
||||
createdAt: p.created_at,
|
||||
updatedAt: p.updated_at,
|
||||
video_path: p.video_path,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -67,10 +71,14 @@ export async function createProject(payload: {
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
frames: p.frame_count ?? 0,
|
||||
fps: '30FPS',
|
||||
fps: p.original_fps ? `${Math.round(p.original_fps)}FPS` : '30FPS',
|
||||
thumbnail_url: p.thumbnail_url,
|
||||
video_path: p.video_path,
|
||||
source_type: p.source_type,
|
||||
original_fps: p.original_fps,
|
||||
parse_fps: p.parse_fps,
|
||||
createdAt: p.created_at,
|
||||
updatedAt: p.updated_at,
|
||||
video_path: p.video_path,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -135,6 +143,16 @@ export async function getProjectFrames(projectId: string): Promise<Array<{
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function uploadDicomBatch(files: File[], projectId?: string): Promise<{ project_id: number; uploaded_count: number; message: string }> {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append('files', file));
|
||||
if (projectId) formData.append('project_id', projectId);
|
||||
const response = await apiClient.post('/api/media/upload/dicom', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function parseMedia(projectId: string): Promise<{
|
||||
project_id: number;
|
||||
frames_extracted: number;
|
||||
|
||||
@@ -8,7 +8,11 @@ export interface Project {
|
||||
fps?: string;
|
||||
frames?: number;
|
||||
thumbnail?: string;
|
||||
thumbnail_url?: string;
|
||||
video_path?: string;
|
||||
source_type?: string;
|
||||
original_fps?: number;
|
||||
parse_fps?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user