Files
Pre_Seg_Server/src/components/VideoWorkspace.test.tsx
admin 4899c8a08a feat: 完善分割工作区交互与传播去重
功能增加:点击 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,记录当前功能、接口契约、需求设计冻结和测试覆盖。
2026-05-02 06:45:47 +08:00

824 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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());
});
});