调整项目库拆帧与长帧序列加载

- 删除项目库右上角独立新建项目入口,保留导入视频/DICOM 自动建项目流程

- 视频项目支持已生成帧后的重新生成帧入口,并提示会清空旧帧、标注和 mask

- 后端重新拆帧任务开始前清理旧帧、旧标注和旧 mask,避免重复帧序列

- 项目帧列表接口默认返回完整帧序列,避免工作区总帧数被 1000 条默认 limit 截断

- 增加可选 docker-compose.gpu.yml,并补充 Docker 使用本机 GPU 的前提和启动说明

- 更新项目库、API 映射、恢复演示文案、后端媒体/项目测试和前端文档
This commit is contained in:
2026-05-07 16:38:13 +08:00
parent 620e95ff91
commit 2a2e6b9b6c
19 changed files with 196 additions and 126 deletions

View File

@@ -70,20 +70,11 @@ describe('ProjectLibrary', () => {
expect(screen.queryByText('30FPS')).not.toBeInTheDocument();
});
it('creates a new project from the modal', async () => {
apiMock.createProject.mockResolvedValueOnce({ id: 'p2', name: 'New Project', status: 'pending' });
it('does not expose manual project creation from the project library header', async () => {
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
fireEvent.click(screen.getByText('新建项目'));
fireEvent.change(screen.getByPlaceholderText('输入项目名称'), { target: { value: 'New Project' } });
fireEvent.change(screen.getByPlaceholderText('输入项目描述'), { target: { value: 'desc' } });
fireEvent.click(screen.getByRole('button', { name: '创建' }));
await waitFor(() => expect(apiMock.createProject).toHaveBeenCalledWith({
name: 'New Project',
description: 'desc',
}));
expect(useStore.getState().projects[0]).toEqual(expect.objectContaining({ id: 'p2' }));
await waitFor(() => expect(apiMock.getProjects).toHaveBeenCalled());
expect(screen.queryByRole('button', { name: '新建项目' })).not.toBeInTheDocument();
});
it('imports video by creating a project and uploading media without parsing frames', async () => {
@@ -156,6 +147,30 @@ describe('ProjectLibrary', () => {
expect(await screen.findByRole('status')).toHaveTextContent('视频帧生成完成,项目封面已自动更新');
});
it('allows regenerating an already parsed video and clears the current workspace cache', async () => {
apiMock.getProjects
.mockResolvedValueOnce([{ id: 'p-ready', name: 'ready.mp4', status: 'ready', frames: 1500, video_path: 'uploads/ready.mp4', parse_fps: 30, source_type: 'video' }])
.mockResolvedValueOnce([{ id: 'p-ready', name: 'ready.mp4', status: 'parsing', frames: 0, video_path: 'uploads/ready.mp4', parse_fps: 24, source_type: 'video' }]);
apiMock.parseMedia.mockResolvedValueOnce({ status: 'queued', progress: 0 });
useStore.setState({
currentProject: { id: 'p-ready', name: 'ready.mp4', status: 'ready' },
frames: [{ id: 'old-frame', projectId: 'p-ready', index: 0, url: '/old.jpg', width: 640, height: 360 }],
masks: [{ id: 'old-mask', frameId: 'old-frame', pathData: 'M 0 0 Z', label: 'old', color: '#fff' }],
selectedMaskIds: ['old-mask'],
});
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
fireEvent.click(await screen.findByRole('button', { name: '重新生成帧' }));
expect(screen.getByText('重新生成会清空该项目现有帧序列、标注和 mask再按新的 FPS 从源视频生成帧。')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '开始生成帧' }));
await waitFor(() => expect(apiMock.parseMedia).toHaveBeenCalledWith('p-ready', { parseFps: 30 }));
expect(useStore.getState().frames).toEqual([]);
expect(useStore.getState().masks).toEqual([]);
expect(useStore.getState().selectedMaskIds).toEqual([]);
});
it('hides frame generation while editing a project name', async () => {
apiMock.getProjects.mockResolvedValueOnce([
{ id: 'p-edit', name: 'Editable Clip', status: 'pending', frames: 0, video_path: 'uploads/editable.mp4', parse_fps: 30, source_type: 'video' },