同步传播链编辑并保护模板切换
- 修改激活模板时,如果当前项目已有 mask,先提示确认并清空所有本地 mask 和已保存后端标注;无 mask 项目可直接切换。 - 模板库详情页将“+ 新建分类”改为带编辑图标的“编辑模板”,并打开完整模板编辑弹窗。 - 区域合并会按 propagation lineage 找到其它传播帧的对应主区域和参与区域,逐帧执行 union,只删除实际参与合并的对应 mask。 - 重叠区域去除会按 propagation lineage 同步到其它传播帧的对应区域,保留参与扣除 mask,不再只改当前帧。 - 当前帧清空遮罩会同步删除这些 mask 的关联自动传播结果,并新增左侧工具栏清空入口。 - 传播链同步编辑保留 source、source_annotation_id、source_mask_id、propagation_seed_key 等 metadata,避免时间轴帧属性变色。 - 补充模板切换确认、模板编辑按钮、左侧清空入口、传播链合并/去除和清空传播链的前端回归测试。 - 更新 AGENTS、接口契约、冻结需求、设计冻结和测试计划文档。
This commit is contained in:
@@ -921,6 +921,75 @@ describe('CanvasArea', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('merges corresponding propagated masks on other frames without dropping the propagation lineage', async () => {
|
||||
const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined);
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{
|
||||
id: 'annotation-1',
|
||||
annotationId: '1',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 10 10 L 60 10 L 60 60 L 10 60 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[10, 10, 60, 10, 60, 60, 10, 60]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
},
|
||||
{
|
||||
id: 'annotation-2',
|
||||
annotationId: '2',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 50 50 L 100 50 L 100 100 L 50 100 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[50, 50, 100, 50, 100, 100, 50, 100]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
},
|
||||
{
|
||||
id: 'annotation-10',
|
||||
annotationId: '10',
|
||||
frameId: 'frame-2',
|
||||
pathData: 'M 12 12 L 62 12 L 62 62 L 12 62 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[12, 12, 62, 12, 62, 62, 12, 62]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
|
||||
},
|
||||
{
|
||||
id: 'annotation-20',
|
||||
annotationId: '20',
|
||||
frameId: 'frame-2',
|
||||
pathData: 'M 52 52 L 102 52 L 102 102 L 52 102 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[52, 52, 102, 52, 102, 102, 52, 102]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2, source_mask_id: 'annotation-2', propagation_seed_key: 'annotation:2' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<CanvasArea activeTool="area_merge" frame={frame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
|
||||
const paths = screen.getAllByTestId('konva-path');
|
||||
fireEvent.click(paths[0]);
|
||||
fireEvent.click(paths[1]);
|
||||
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
|
||||
|
||||
await waitFor(() => expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(expect.arrayContaining(['2', '20'])));
|
||||
const masks = useStore.getState().masks;
|
||||
expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10']);
|
||||
expect(masks.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({
|
||||
saveStatus: 'dirty',
|
||||
saved: false,
|
||||
metadata: expect.objectContaining({ source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('removes overlap from the primary selected mask with polygon difference', () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
@@ -967,6 +1036,78 @@ describe('CanvasArea', () => {
|
||||
expect(useStore.getState().masks[1].id).toBe('m2');
|
||||
});
|
||||
|
||||
it('removes overlap from corresponding propagated masks while preserving secondary masks', async () => {
|
||||
const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined);
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{
|
||||
id: 'annotation-1',
|
||||
annotationId: '1',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 10 10 L 90 10 L 90 70 L 10 70 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[10, 10, 90, 10, 90, 70, 10, 70]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
},
|
||||
{
|
||||
id: 'annotation-2',
|
||||
annotationId: '2',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 50 30 L 120 30 L 120 80 L 50 80 Z',
|
||||
label: 'B',
|
||||
color: '#ff0000',
|
||||
segmentation: [[50, 30, 120, 30, 120, 80, 50, 80]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
},
|
||||
{
|
||||
id: 'annotation-10',
|
||||
annotationId: '10',
|
||||
frameId: 'frame-2',
|
||||
pathData: 'M 12 12 L 92 12 L 92 72 L 12 72 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[12, 12, 92, 12, 92, 72, 12, 72]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
|
||||
},
|
||||
{
|
||||
id: 'annotation-20',
|
||||
annotationId: '20',
|
||||
frameId: 'frame-2',
|
||||
pathData: 'M 52 32 L 122 32 L 122 82 L 52 82 Z',
|
||||
label: 'B',
|
||||
color: '#ff0000',
|
||||
segmentation: [[52, 32, 122, 32, 122, 82, 52, 82]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2, source_mask_id: 'annotation-2', propagation_seed_key: 'annotation:2' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<CanvasArea activeTool="area_remove" frame={frame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
|
||||
const paths = screen.getAllByTestId('konva-path');
|
||||
fireEvent.click(paths[0]);
|
||||
fireEvent.click(paths[1]);
|
||||
fireEvent.click(screen.getByRole('button', { name: '从主区域去除' }));
|
||||
|
||||
await waitFor(() => expect(useStore.getState().masks.find((mask) => mask.id === 'annotation-10')?.saveStatus).toBe('dirty'));
|
||||
expect(onDeleteMaskAnnotations).not.toHaveBeenCalled();
|
||||
expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10', 'annotation-2', 'annotation-20']);
|
||||
expect(useStore.getState().masks.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({
|
||||
saved: false,
|
||||
metadata: expect.objectContaining({ source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1 }),
|
||||
}));
|
||||
expect(useStore.getState().masks.find((mask) => mask.id === 'annotation-20')).toEqual(expect.objectContaining({
|
||||
saveStatus: 'saved',
|
||||
metadata: expect.objectContaining({ source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('renders inner overlap removal as a hole in the primary mask', () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
|
||||
Reference in New Issue
Block a user