功能增加:点击 Canvas mask 后,右侧语义分类树会按 classId/className/label 自动匹配分类,并滚动聚焦到对应分类按钮。
功能增加:工作区新增按起止帧批量清空片段遮罩,复用传播范围输入,范围内已保存标注走 DELETE /api/ai/annotations/{id},本地 draft mask 同步移除。
功能增加:右侧语义分类树上方新增工作区 mask 透明度滑杆,写入 Zustand maskPreviewOpacity,Canvas mask 预览按该值渲染并保留选中加亮反馈。
功能增加:视频处理进度条记录最近自动传播区间,使用不同色系深浅渐变提示最近处理片段。
功能增加:工作区自动传播前会先保存 draft/dirty seed mask,使用稳定后端 source_annotation_id 入队,减少二次传播重复结果。
Bugfix:后端传播任务对旧临时 seed id、不同 SAM 2.1 权重结果做兼容清理;相同 seed 和相同权重才跳过,否则先删旧自动传播标注再重传。
Bugfix:修复 polygon 顶点拖拽结束后触发 Stage 平移导致画布中心偏移的问题,并补充测试环境对 drag target 的模拟。
Bugfix:工具提示会在数秒后自动隐藏,避免创建多边形/矩形等提示长期遮挡画布。
UI 调整:移除右侧面板顶部‘本体论与属性分类管理树’说明栏,减少无效占位。
UI 调整:左侧工具栏和右侧语义面板使用低对比 seg-scrollbar;左侧工具栏外扩滚动条槽位,避免滚动条挤占图标列。
UI 调整:工作区模型状态徽标改为紧凑显示,减少与传播权重选择重复;传播权重下拉改成深色背景和青色文字,避免灰底白字不可读。
UI 调整:缩略图状态框固定优先级,当前帧、人工/AI 标注帧、自动传播帧可用外框/内框组合同时表达。
测试:补充 VideoWorkspace、CanvasArea、FrameTimeline、OntologyInspector、ToolsPalette、useStore 和后端 test_ai 覆盖新增交互、传播去重、批量清空、透明度、滚动条和 UI 状态。
文档:同步更新 README、AGENTS 和 doc/03、doc/04、doc/07、doc/08、doc/09,记录当前功能、接口契约、需求设计冻结和测试覆盖。
824 lines
30 KiB
TypeScript
824 lines
30 KiB
TypeScript
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { resetStore } from '../test/storeTestUtils';
|
||
import { useStore } from '../store/useStore';
|
||
import { VideoWorkspace } from './VideoWorkspace';
|
||
|
||
const apiMock = vi.hoisted(() => ({
|
||
getProjectFrames: vi.fn(),
|
||
parseMedia: vi.fn(),
|
||
propagateMasks: vi.fn(),
|
||
queuePropagationTask: vi.fn(),
|
||
getTask: vi.fn(),
|
||
cancelTask: vi.fn(),
|
||
getTemplates: vi.fn(),
|
||
getProjectAnnotations: vi.fn(),
|
||
saveAnnotation: vi.fn(),
|
||
updateAnnotation: vi.fn(),
|
||
deleteAnnotation: vi.fn(),
|
||
exportCoco: vi.fn(),
|
||
exportMasks: vi.fn(),
|
||
importGtMask: vi.fn(),
|
||
annotationToMask: vi.fn(),
|
||
buildAnnotationPayload: vi.fn(),
|
||
getAiModelStatus: vi.fn(),
|
||
analyzeMask: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../lib/api', () => ({
|
||
getProjectFrames: apiMock.getProjectFrames,
|
||
parseMedia: apiMock.parseMedia,
|
||
propagateMasks: apiMock.propagateMasks,
|
||
queuePropagationTask: apiMock.queuePropagationTask,
|
||
getTask: apiMock.getTask,
|
||
cancelTask: apiMock.cancelTask,
|
||
getTemplates: apiMock.getTemplates,
|
||
getProjectAnnotations: apiMock.getProjectAnnotations,
|
||
saveAnnotation: apiMock.saveAnnotation,
|
||
updateAnnotation: apiMock.updateAnnotation,
|
||
deleteAnnotation: apiMock.deleteAnnotation,
|
||
exportCoco: apiMock.exportCoco,
|
||
exportMasks: apiMock.exportMasks,
|
||
importGtMask: apiMock.importGtMask,
|
||
annotationToMask: apiMock.annotationToMask,
|
||
buildAnnotationPayload: apiMock.buildAnnotationPayload,
|
||
getAiModelStatus: apiMock.getAiModelStatus,
|
||
analyzeMask: apiMock.analyzeMask,
|
||
}));
|
||
|
||
describe('VideoWorkspace', () => {
|
||
beforeEach(() => {
|
||
resetStore();
|
||
vi.clearAllMocks();
|
||
useStore.setState({ currentProject: { id: '1', name: 'Demo', status: 'ready', video_path: 'uploads/demo.mp4' } });
|
||
apiMock.getTemplates.mockResolvedValue([]);
|
||
apiMock.getProjectAnnotations.mockResolvedValue([]);
|
||
apiMock.annotationToMask.mockReturnValue(null);
|
||
apiMock.queuePropagationTask.mockResolvedValue({ id: 31, status: 'queued', progress: 0, message: '自动传播任务已入队' });
|
||
apiMock.getTask.mockResolvedValue({
|
||
id: 31,
|
||
status: 'success',
|
||
progress: 100,
|
||
message: '自动传播完成',
|
||
result: { processed_frame_count: 3, created_annotation_count: 2, completed_steps: 1 },
|
||
});
|
||
apiMock.cancelTask.mockResolvedValue({ id: 31, status: 'cancelled', progress: 100, message: '任务已取消' });
|
||
apiMock.propagateMasks.mockResolvedValue({
|
||
model: 'sam2.1_hiera_tiny',
|
||
direction: 'forward',
|
||
source_frame_id: 10,
|
||
processed_frame_count: 3,
|
||
created_annotation_count: 2,
|
||
annotations: [],
|
||
});
|
||
apiMock.getAiModelStatus.mockResolvedValue({
|
||
selected_model: 'sam2.1_hiera_tiny',
|
||
gpu: { available: false, device: 'cpu', name: null, torch_available: true },
|
||
models: [
|
||
{ id: 'sam2.1_hiera_tiny', label: 'SAM 2.1 Tiny', available: true, loaded: false, device: 'cpu', supports: [], message: 'ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
|
||
],
|
||
});
|
||
apiMock.analyzeMask.mockResolvedValue({
|
||
confidence: 0.7,
|
||
confidence_source: 'model_score',
|
||
topology_anchor_count: 0,
|
||
topology_anchors: [],
|
||
area: 0.1,
|
||
bbox: [0, 0, 0.1, 0.1],
|
||
source: 'test',
|
||
message: 'ok',
|
||
});
|
||
});
|
||
|
||
it('loads project frames into the workspace store', async () => {
|
||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||
]);
|
||
|
||
render(<VideoWorkspace />);
|
||
|
||
await waitFor(() => expect(useStore.getState().frames).toEqual([
|
||
{ id: '10', projectId: '1', index: 0, url: '/frame.jpg', width: 640, height: 360 },
|
||
]));
|
||
expect(screen.getByText('Demo')).toBeInTheDocument();
|
||
expect(apiMock.parseMedia).not.toHaveBeenCalled();
|
||
expect(apiMock.getProjectAnnotations).toHaveBeenCalledWith('1');
|
||
});
|
||
|
||
it('exposes workspace undo/redo buttons and keyboard shortcuts without hijacking inputs', async () => {
|
||
const mask = {
|
||
id: 'mask-undo',
|
||
frameId: '10',
|
||
pathData: 'M 0 0 Z',
|
||
label: 'Draft',
|
||
color: '#06b6d4',
|
||
};
|
||
useStore.setState({
|
||
currentProject: null,
|
||
masks: [mask],
|
||
maskHistory: [[]],
|
||
maskFuture: [],
|
||
});
|
||
|
||
render(<VideoWorkspace />);
|
||
fireEvent.click(screen.getByRole('button', { name: '撤销操作' }));
|
||
|
||
expect(useStore.getState().masks).toEqual([]);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '重做操作' }));
|
||
expect(useStore.getState().masks).toEqual([mask]);
|
||
|
||
fireEvent.keyDown(window, { key: 'z', ctrlKey: true });
|
||
expect(useStore.getState().masks).toEqual([]);
|
||
|
||
fireEvent.keyDown(window, { key: 'z', ctrlKey: true, shiftKey: true });
|
||
expect(useStore.getState().masks).toEqual([mask]);
|
||
|
||
fireEvent.keyDown(screen.getByLabelText('传播起始帧'), { key: 'z', ctrlKey: true });
|
||
expect(useStore.getState().masks).toEqual([mask]);
|
||
});
|
||
|
||
it('auto-dismisses short workspace operation messages without blocking later actions', async () => {
|
||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||
]);
|
||
|
||
render(<VideoWorkspace />);
|
||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
|
||
|
||
vi.useFakeTimers();
|
||
fireEvent.click(screen.getByRole('button', { name: '结构化归档保存' }));
|
||
expect(screen.getByText('没有待保存标注')).toBeInTheDocument();
|
||
|
||
act(() => {
|
||
vi.advanceTimersByTime(3600);
|
||
});
|
||
|
||
expect(screen.queryByText('没有待保存标注')).not.toBeInTheDocument();
|
||
expect(screen.getByRole('button', { name: '结构化归档保存' })).not.toBeDisabled();
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it('does not auto-generate frames when a media project has no frames yet', async () => {
|
||
apiMock.getProjectFrames.mockResolvedValueOnce([]);
|
||
|
||
render(<VideoWorkspace />);
|
||
|
||
await waitFor(() => expect(apiMock.getProjectFrames).toHaveBeenCalledWith('1'));
|
||
expect(apiMock.parseMedia).not.toHaveBeenCalled();
|
||
expect(apiMock.getTask).not.toHaveBeenCalled();
|
||
expect(useStore.getState().frames).toEqual([]);
|
||
expect(await screen.findByText('该项目已导入视频但尚未生成帧,请在项目库点击“生成帧”')).toBeInTheDocument();
|
||
});
|
||
|
||
it('hydrates saved annotations after loading frames', async () => {
|
||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||
]);
|
||
apiMock.getProjectAnnotations.mockResolvedValueOnce([{ id: 99, frame_id: 10 }]);
|
||
apiMock.annotationToMask.mockReturnValueOnce({
|
||
id: 'annotation-99',
|
||
annotationId: '99',
|
||
frameId: '10',
|
||
saved: true,
|
||
pathData: 'M 0 0 Z',
|
||
label: 'Saved',
|
||
color: '#06b6d4',
|
||
});
|
||
|
||
render(<VideoWorkspace />);
|
||
|
||
await waitFor(() => expect(useStore.getState().masks).toEqual([
|
||
expect.objectContaining({ id: 'annotation-99', saved: true }),
|
||
]));
|
||
});
|
||
|
||
it('preserves unsaved AI masks when hydrating saved annotations after entering the workspace', async () => {
|
||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||
]);
|
||
apiMock.getProjectAnnotations.mockResolvedValueOnce([{ id: 99, frame_id: 10 }]);
|
||
apiMock.annotationToMask.mockReturnValueOnce({
|
||
id: 'annotation-99',
|
||
annotationId: '99',
|
||
frameId: '10',
|
||
saved: true,
|
||
pathData: 'M 0 0 Z',
|
||
label: 'Saved',
|
||
color: '#06b6d4',
|
||
});
|
||
useStore.setState({
|
||
activeTool: 'edit_polygon',
|
||
selectedMaskIds: ['ai-mask'],
|
||
masks: [{
|
||
id: 'ai-mask',
|
||
frameId: '10',
|
||
pathData: 'M 10 10 L 40 10 L 40 40 Z',
|
||
label: 'AI Mask',
|
||
color: '#06b6d4',
|
||
segmentation: [[10, 10, 40, 10, 40, 40]],
|
||
saveStatus: 'draft',
|
||
saved: false,
|
||
metadata: { source: 'ai_segmentation' },
|
||
}],
|
||
});
|
||
|
||
render(<VideoWorkspace />);
|
||
|
||
await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual([
|
||
'ai-mask',
|
||
'annotation-99',
|
||
]));
|
||
expect(useStore.getState().selectedMaskIds).toEqual(['ai-mask']);
|
||
expect(useStore.getState().activeTool).toBe('edit_polygon');
|
||
});
|
||
|
||
it('saves pending masks through the archive button', async () => {
|
||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||
]);
|
||
apiMock.getProjectAnnotations
|
||
.mockResolvedValueOnce([])
|
||
.mockResolvedValueOnce([{ id: 5, frame_id: 10 }]);
|
||
apiMock.buildAnnotationPayload.mockReturnValueOnce({ project_id: 1, frame_id: 10, mask_data: { polygons: [] } });
|
||
apiMock.saveAnnotation.mockResolvedValueOnce({ id: 5 });
|
||
apiMock.annotationToMask.mockReturnValueOnce({
|
||
id: 'annotation-5',
|
||
annotationId: '5',
|
||
frameId: '10',
|
||
saved: true,
|
||
saveStatus: 'saved',
|
||
pathData: 'M 0 0 Z',
|
||
label: 'Saved AI Mask',
|
||
color: '#06b6d4',
|
||
});
|
||
|
||
render(<VideoWorkspace />);
|
||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
|
||
act(() => {
|
||
useStore.setState({
|
||
activeTemplateId: '2',
|
||
masks: [{
|
||
id: 'mask-1',
|
||
frameId: '10',
|
||
pathData: 'M 0 0 Z',
|
||
label: 'AI Mask',
|
||
color: '#06b6d4',
|
||
segmentation: [[0, 0, 10, 0, 10, 10]],
|
||
bbox: [0, 0, 10, 10],
|
||
}],
|
||
});
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '结构化归档保存' }));
|
||
|
||
await waitFor(() => expect(apiMock.saveAnnotation).toHaveBeenCalledWith({
|
||
project_id: 1,
|
||
frame_id: 10,
|
||
mask_data: { polygons: [] },
|
||
}));
|
||
expect(apiMock.buildAnnotationPayload).toHaveBeenCalledWith(
|
||
'1',
|
||
expect.objectContaining({ id: 'mask-1' }),
|
||
expect.objectContaining({ id: '10' }),
|
||
'2',
|
||
);
|
||
await waitFor(() => expect(useStore.getState().masks).toEqual([
|
||
expect.objectContaining({ id: 'annotation-5', saved: true, saveStatus: 'saved' }),
|
||
]));
|
||
expect(useStore.getState().masks.some((mask) => mask.id === 'mask-1')).toBe(false);
|
||
});
|
||
|
||
it('updates dirty saved masks through the archive button', async () => {
|
||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||
]);
|
||
apiMock.buildAnnotationPayload.mockReturnValueOnce({
|
||
project_id: 1,
|
||
frame_id: 10,
|
||
template_id: 2,
|
||
mask_data: { polygons: [], label: '胆囊' },
|
||
});
|
||
apiMock.updateAnnotation.mockResolvedValueOnce({ id: 99 });
|
||
|
||
render(<VideoWorkspace />);
|
||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
|
||
act(() => {
|
||
useStore.setState({
|
||
activeTemplateId: '2',
|
||
masks: [{
|
||
id: 'annotation-99',
|
||
annotationId: '99',
|
||
frameId: '10',
|
||
pathData: 'M 0 0 Z',
|
||
label: '胆囊',
|
||
color: '#ff0000',
|
||
saveStatus: 'dirty',
|
||
segmentation: [[0, 0, 10, 0, 10, 10]],
|
||
bbox: [0, 0, 10, 10],
|
||
}],
|
||
});
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '结构化归档保存' }));
|
||
|
||
await waitFor(() => expect(apiMock.updateAnnotation).toHaveBeenCalledWith('99', {
|
||
template_id: 2,
|
||
mask_data: { polygons: [], label: '胆囊' },
|
||
points: undefined,
|
||
bbox: undefined,
|
||
}));
|
||
expect(apiMock.saveAnnotation).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('deletes saved annotations when clearing current-frame masks', async () => {
|
||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||
]);
|
||
apiMock.deleteAnnotation.mockResolvedValueOnce(undefined);
|
||
|
||
render(<VideoWorkspace />);
|
||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
|
||
act(() => {
|
||
useStore.setState({
|
||
masks: [
|
||
{
|
||
id: 'annotation-99',
|
||
annotationId: '99',
|
||
frameId: '10',
|
||
pathData: 'M 0 0 Z',
|
||
label: 'Saved',
|
||
color: '#06b6d4',
|
||
saved: true,
|
||
saveStatus: 'saved',
|
||
},
|
||
{
|
||
id: 'draft-1',
|
||
frameId: '10',
|
||
pathData: 'M 1 1 Z',
|
||
label: 'Draft',
|
||
color: '#ff0000',
|
||
},
|
||
],
|
||
});
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '清空遮罩' }));
|
||
|
||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
|
||
expect(useStore.getState().masks).toEqual([]);
|
||
});
|
||
|
||
it('clears masks across the selected frame range', async () => {
|
||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
|
||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
|
||
]);
|
||
apiMock.deleteAnnotation.mockResolvedValue(undefined);
|
||
|
||
render(<VideoWorkspace />);
|
||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
|
||
act(() => {
|
||
useStore.setState({
|
||
masks: [
|
||
{ id: 'annotation-99', annotationId: '99', frameId: '10', pathData: 'M 0 0 Z', label: 'Saved 1', color: '#06b6d4', saved: true, saveStatus: 'saved' },
|
||
{ id: 'draft-1', frameId: '11', pathData: 'M 1 1 Z', label: 'Draft', color: '#ff0000' },
|
||
{ id: 'annotation-100', annotationId: '100', frameId: '12', pathData: 'M 2 2 Z', label: 'Saved 2', color: '#00ff00', saved: true, saveStatus: 'saved' },
|
||
],
|
||
selectedMaskIds: ['draft-1', 'annotation-100'],
|
||
});
|
||
});
|
||
|
||
fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '1' } });
|
||
fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '2' } });
|
||
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
|
||
|
||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
|
||
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('100');
|
||
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['annotation-100']);
|
||
expect(useStore.getState().selectedMaskIds).not.toContain('draft-1');
|
||
expect(screen.getByText('已清空第 1-2 帧的 2 个遮罩,其中后端标注 1 个')).toBeInTheDocument();
|
||
});
|
||
|
||
it('auto-saves pending masks before exporting COCO', async () => {
|
||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||
]);
|
||
apiMock.buildAnnotationPayload.mockReturnValueOnce({ project_id: 1, frame_id: 10, mask_data: { polygons: [] } });
|
||
apiMock.saveAnnotation.mockResolvedValueOnce({ id: 5 });
|
||
apiMock.exportCoco.mockResolvedValueOnce(new Blob(['{}'], { type: 'application/json' }));
|
||
|
||
render(<VideoWorkspace />);
|
||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
|
||
act(() => {
|
||
useStore.setState({
|
||
masks: [{
|
||
id: 'mask-1',
|
||
frameId: '10',
|
||
pathData: 'M 0 0 Z',
|
||
label: 'AI Mask',
|
||
color: '#06b6d4',
|
||
segmentation: [[0, 0, 10, 0, 10, 10]],
|
||
}],
|
||
});
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '导出 JSON 标注集' }));
|
||
|
||
await waitFor(() => expect(apiMock.saveAnnotation).toHaveBeenCalled());
|
||
expect(apiMock.exportCoco).toHaveBeenCalledWith('1');
|
||
});
|
||
|
||
it('auto-saves pending masks before exporting PNG masks', async () => {
|
||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||
]);
|
||
apiMock.buildAnnotationPayload.mockReturnValueOnce({ project_id: 1, frame_id: 10, mask_data: { polygons: [] } });
|
||
apiMock.saveAnnotation.mockResolvedValueOnce({ id: 5 });
|
||
apiMock.exportMasks.mockResolvedValueOnce(new Blob(['zip'], { type: 'application/zip' }));
|
||
|
||
render(<VideoWorkspace />);
|
||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
|
||
act(() => {
|
||
useStore.setState({
|
||
masks: [{
|
||
id: 'mask-1',
|
||
frameId: '10',
|
||
pathData: 'M 0 0 Z',
|
||
label: 'AI Mask',
|
||
color: '#06b6d4',
|
||
segmentation: [[0, 0, 10, 0, 10, 10]],
|
||
}],
|
||
});
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '导出 PNG Mask ZIP' }));
|
||
|
||
await waitFor(() => expect(apiMock.saveAnnotation).toHaveBeenCalled());
|
||
expect(apiMock.exportMasks).toHaveBeenCalledWith('1');
|
||
});
|
||
|
||
it('imports a GT mask for the current frame and hydrates saved annotations', async () => {
|
||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||
]);
|
||
apiMock.importGtMask.mockResolvedValueOnce([{ id: 88, frame_id: 10 }]);
|
||
apiMock.getProjectAnnotations
|
||
.mockResolvedValueOnce([])
|
||
.mockResolvedValueOnce([{ id: 88, frame_id: 10 }]);
|
||
apiMock.annotationToMask.mockReturnValueOnce({
|
||
id: 'annotation-88',
|
||
annotationId: '88',
|
||
frameId: '10',
|
||
saved: true,
|
||
pathData: 'M 0 0 Z',
|
||
label: 'GT Mask',
|
||
color: '#22c55e',
|
||
});
|
||
|
||
render(<VideoWorkspace />);
|
||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
|
||
|
||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||
const file = new File(['mask'], 'mask.png', { type: 'image/png' });
|
||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||
|
||
await waitFor(() => expect(apiMock.importGtMask).toHaveBeenCalledWith(file, '1', '10'));
|
||
await waitFor(() => expect(useStore.getState().masks).toEqual([
|
||
expect.objectContaining({ id: 'annotation-88', label: 'GT Mask' }),
|
||
]));
|
||
});
|
||
|
||
it('auto-propagates reference-frame masks through the configured frame range', async () => {
|
||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||
]);
|
||
const seedPayload = {
|
||
project_id: 1,
|
||
frame_id: 10,
|
||
template_id: 2,
|
||
mask_data: {
|
||
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
|
||
label: '胆囊',
|
||
color: '#ff0000',
|
||
class: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 },
|
||
},
|
||
bbox: [0.1, 0.1, 0.2, 0.2],
|
||
};
|
||
apiMock.getProjectAnnotations
|
||
.mockResolvedValueOnce([])
|
||
.mockResolvedValue([{ id: 5, frame_id: 10 }]);
|
||
apiMock.buildAnnotationPayload.mockReturnValue(seedPayload);
|
||
apiMock.saveAnnotation.mockResolvedValueOnce({ id: 5 });
|
||
apiMock.annotationToMask.mockReturnValue({
|
||
id: 'annotation-5',
|
||
annotationId: '5',
|
||
frameId: '10',
|
||
saved: true,
|
||
saveStatus: 'saved',
|
||
pathData: 'M 0 0 Z',
|
||
label: '胆囊',
|
||
color: '#ff0000',
|
||
segmentation: [[64, 36, 192, 36, 192, 108]],
|
||
bbox: [64, 36, 128, 72],
|
||
});
|
||
|
||
render(<VideoWorkspace />);
|
||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
|
||
act(() => {
|
||
useStore.setState({
|
||
aiModel: 'sam2.1_hiera_tiny',
|
||
activeTemplateId: '2',
|
||
masks: [{
|
||
id: 'mask-1',
|
||
frameId: '10',
|
||
pathData: 'M 0 0 Z',
|
||
label: '胆囊',
|
||
color: '#ff0000',
|
||
segmentation: [[64, 36, 192, 36, 192, 108]],
|
||
bbox: [64, 36, 128, 72],
|
||
}],
|
||
});
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
|
||
expect(apiMock.queuePropagationTask).not.toHaveBeenCalled();
|
||
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
|
||
|
||
await waitFor(() => expect(apiMock.saveAnnotation).toHaveBeenCalledTimes(1));
|
||
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledWith({
|
||
project_id: 1,
|
||
frame_id: 10,
|
||
model: 'sam2.1_hiera_tiny',
|
||
include_source: false,
|
||
save_annotations: true,
|
||
steps: [{
|
||
direction: 'forward',
|
||
max_frames: 2,
|
||
seed: {
|
||
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
|
||
bbox: [0.1, 0.1, 0.2, 0.2],
|
||
points: undefined,
|
||
label: '胆囊',
|
||
color: '#ff0000',
|
||
class_metadata: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 },
|
||
template_id: 2,
|
||
source_mask_id: 'annotation-5',
|
||
source_annotation_id: 5,
|
||
},
|
||
}],
|
||
}));
|
||
await waitFor(() => expect(screen.getByText('已自动传播 1 个参考 mask,处理 3 帧次,删除旧区域 0 个,保存 2 个区域')).toBeInTheDocument());
|
||
expect(screen.getByTestId('propagation-history-segment')).toHaveAttribute('title', 'SAM 2.1 Tiny 自动传播:第 1-2 帧');
|
||
});
|
||
|
||
it('uses the separately selected propagation weight when queueing propagation', async () => {
|
||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||
]);
|
||
apiMock.buildAnnotationPayload.mockReturnValueOnce({
|
||
project_id: 1,
|
||
frame_id: 10,
|
||
mask_data: {
|
||
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
|
||
label: '胆囊',
|
||
color: '#ff0000',
|
||
},
|
||
bbox: [0.1, 0.1, 0.2, 0.2],
|
||
});
|
||
|
||
render(<VideoWorkspace />);
|
||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
|
||
act(() => {
|
||
useStore.setState({
|
||
aiModel: 'sam2.1_hiera_tiny',
|
||
masks: [{
|
||
id: 'annotation-6',
|
||
annotationId: '6',
|
||
frameId: '10',
|
||
pathData: 'M 0 0 Z',
|
||
label: '胆囊',
|
||
color: '#ff0000',
|
||
segmentation: [[64, 36, 192, 36, 192, 108]],
|
||
bbox: [64, 36, 128, 72],
|
||
}],
|
||
});
|
||
});
|
||
|
||
const propagationWeightSelect = screen.getByLabelText('传播权重');
|
||
expect(propagationWeightSelect).toHaveClass('bg-[#050809]');
|
||
expect(within(propagationWeightSelect).getByRole('option', { name: 'tiny' })).toHaveClass('text-cyan-100');
|
||
fireEvent.change(propagationWeightSelect, { target: { value: 'sam2.1_hiera_small' } });
|
||
expect(propagationWeightSelect).toHaveValue('sam2.1_hiera_small');
|
||
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
|
||
|
||
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledWith(expect.objectContaining({
|
||
model: 'sam2.1_hiera_small',
|
||
})));
|
||
await waitFor(() => expect(screen.getByText('已自动传播 1 个参考 mask,处理 3 帧次,删除旧区域 0 个,保存 2 个区域')).toBeInTheDocument());
|
||
});
|
||
|
||
it('shows propagation task progress and reports empty results', async () => {
|
||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||
]);
|
||
apiMock.buildAnnotationPayload.mockReturnValueOnce({
|
||
project_id: 1,
|
||
frame_id: 10,
|
||
mask_data: {
|
||
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
|
||
label: '胆囊',
|
||
color: '#ff0000',
|
||
},
|
||
bbox: [0.1, 0.1, 0.2, 0.2],
|
||
});
|
||
apiMock.queuePropagationTask.mockResolvedValueOnce({ id: 44, status: 'queued', progress: 0, message: '自动传播任务已入队' });
|
||
apiMock.getTask.mockResolvedValueOnce({
|
||
id: 44,
|
||
status: 'success',
|
||
progress: 100,
|
||
message: '自动传播完成,但没有生成新的 mask',
|
||
result: { processed_frame_count: 2, created_annotation_count: 0, completed_steps: 1 },
|
||
});
|
||
|
||
render(<VideoWorkspace />);
|
||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
|
||
act(() => {
|
||
useStore.setState({
|
||
masks: [{
|
||
id: 'annotation-7',
|
||
annotationId: '7',
|
||
frameId: 10 as unknown as string,
|
||
pathData: 'M 0 0 Z',
|
||
label: '胆囊',
|
||
color: '#ff0000',
|
||
segmentation: [[64, 36, 192, 36, 192, 108]],
|
||
bbox: [64, 36, 128, 72],
|
||
}],
|
||
});
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
|
||
|
||
const progressPanel = await screen.findByLabelText('自动传播进度');
|
||
expect(progressPanel).toBeInTheDocument();
|
||
expect(within(progressPanel).getByText('0%')).toBeInTheDocument();
|
||
|
||
expect(await screen.findByText(/没有生成新的 mask/)).toBeInTheDocument();
|
||
});
|
||
|
||
it('lets users select the propagation range on the timeline before queueing', async () => {
|
||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
|
||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
|
||
{ id: 13, project_id: 1, frame_index: 3, image_url: '/frame-3.jpg', width: 640, height: 360 },
|
||
{ id: 14, project_id: 1, frame_index: 4, image_url: '/frame-4.jpg', width: 640, height: 360 },
|
||
]);
|
||
apiMock.buildAnnotationPayload.mockReturnValueOnce({
|
||
project_id: 1,
|
||
frame_id: 10,
|
||
mask_data: {
|
||
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
|
||
label: '胆囊',
|
||
color: '#ff0000',
|
||
},
|
||
bbox: [0.1, 0.1, 0.2, 0.2],
|
||
});
|
||
|
||
render(<VideoWorkspace />);
|
||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(5));
|
||
act(() => {
|
||
useStore.setState({
|
||
masks: [{
|
||
id: 'annotation-8',
|
||
annotationId: '8',
|
||
frameId: '10',
|
||
pathData: 'M 0 0 Z',
|
||
label: '胆囊',
|
||
color: '#ff0000',
|
||
segmentation: [[64, 36, 192, 36, 192, 108]],
|
||
bbox: [64, 36, 128, 72],
|
||
}],
|
||
});
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
|
||
const processingBar = screen.getByLabelText('视频处理进度条');
|
||
vi.spyOn(processingBar, 'getBoundingClientRect').mockReturnValue({
|
||
left: 0,
|
||
right: 100,
|
||
top: 0,
|
||
bottom: 10,
|
||
width: 100,
|
||
height: 10,
|
||
x: 0,
|
||
y: 0,
|
||
toJSON: () => ({}),
|
||
});
|
||
fireEvent.pointerDown(processingBar, { clientX: 25, pointerId: 1 });
|
||
fireEvent.pointerMove(processingBar, { clientX: 100, pointerId: 1 });
|
||
fireEvent.pointerUp(processingBar, { clientX: 100, pointerId: 1 });
|
||
|
||
expect(screen.getByLabelText('传播起始帧')).toHaveValue(2);
|
||
expect(screen.getByLabelText('传播结束帧')).toHaveValue(5);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
|
||
|
||
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledWith(expect.objectContaining({
|
||
frame_id: 10,
|
||
steps: [expect.objectContaining({ direction: 'forward', max_frames: 5 })],
|
||
})));
|
||
});
|
||
|
||
it('auto-propagates all reference-frame masks in both directions inside the selected range', async () => {
|
||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
|
||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
|
||
]);
|
||
apiMock.getTask.mockResolvedValue({
|
||
id: 31,
|
||
status: 'success',
|
||
progress: 100,
|
||
message: '自动传播完成',
|
||
result: {
|
||
processed_frame_count: 8,
|
||
created_annotation_count: 4,
|
||
completed_steps: 4,
|
||
},
|
||
});
|
||
apiMock.buildAnnotationPayload.mockImplementation((_projectId, mask) => (
|
||
mask.id === 'annotation-10'
|
||
? {
|
||
project_id: 1,
|
||
frame_id: 11,
|
||
mask_data: {
|
||
polygons: [[[0.4, 0.4], [0.5, 0.4], [0.5, 0.5]]],
|
||
label: '肝脏',
|
||
color: '#00ff00',
|
||
},
|
||
bbox: [0.4, 0.4, 0.1, 0.1],
|
||
}
|
||
: {
|
||
project_id: 1,
|
||
frame_id: 11,
|
||
mask_data: {
|
||
polygons: [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2]]],
|
||
label: '胆囊',
|
||
color: '#ff0000',
|
||
},
|
||
bbox: [0.1, 0.1, 0.1, 0.1],
|
||
}
|
||
));
|
||
|
||
render(<VideoWorkspace />);
|
||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
|
||
act(() => {
|
||
useStore.setState({
|
||
currentFrameIndex: 1,
|
||
masks: [
|
||
{
|
||
id: 'annotation-9',
|
||
annotationId: '9',
|
||
frameId: '11',
|
||
pathData: 'M 0 0 Z',
|
||
label: '胆囊',
|
||
color: '#ff0000',
|
||
segmentation: [[64, 36, 128, 36, 128, 72]],
|
||
},
|
||
{
|
||
id: 'annotation-10',
|
||
annotationId: '10',
|
||
frameId: '11',
|
||
pathData: 'M 1 1 Z',
|
||
label: '肝脏',
|
||
color: '#00ff00',
|
||
segmentation: [[256, 144, 320, 144, 320, 180]],
|
||
},
|
||
],
|
||
});
|
||
});
|
||
|
||
fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '1' } });
|
||
fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '3' } });
|
||
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
|
||
|
||
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledTimes(1));
|
||
const queuedPayload = apiMock.queuePropagationTask.mock.calls[0][0];
|
||
expect(queuedPayload.steps).toEqual([
|
||
expect.objectContaining({ direction: 'backward', max_frames: 2, seed: expect.objectContaining({ label: '胆囊' }) }),
|
||
expect.objectContaining({ direction: 'forward', max_frames: 2, seed: expect.objectContaining({ label: '胆囊' }) }),
|
||
expect.objectContaining({ direction: 'backward', max_frames: 2, seed: expect.objectContaining({ label: '肝脏' }) }),
|
||
expect.objectContaining({ direction: 'forward', max_frames: 2, seed: expect.objectContaining({ label: '肝脏' }) }),
|
||
]);
|
||
await waitFor(() => expect(screen.getByText('已自动传播 2 个参考 mask,处理 8 帧次,删除旧区域 0 个,保存 4 个区域')).toBeInTheDocument());
|
||
});
|
||
});
|