同步传播链编辑并保护模板切换

- 修改激活模板时,如果当前项目已有 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:
2026-05-03 19:10:12 +08:00
parent 2da73f9acd
commit 4d6bbf2b80
15 changed files with 524 additions and 93 deletions

View File

@@ -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: [

View File

@@ -1436,55 +1436,88 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
const handleBooleanOperation = async () => {
if (!frame || booleanSelectedMasks.length < 2) return;
const primary = booleanSelectedMasks[0];
const primaryGeometry = maskToMultiPolygon(primary);
if (!primaryGeometry) return;
const secondaryMasks = booleanSelectedMasks.slice(1);
const currentFrameId = String(frame.id);
const targetFrameIds = new Set<string>([currentFrameId]);
const clipGeometries = booleanSelectedMasks
.slice(1)
.map(maskToMultiPolygon)
.filter((geometry): geometry is MultiPolygon => Boolean(geometry));
if (clipGeometries.length === 0) return;
const resultGeometry = effectiveTool === 'area_merge'
? polygonClipping.union(primaryGeometry, ...clipGeometries)
: polygonClipping.difference(primaryGeometry, ...clipGeometries);
const resultSegmentation = multiPolygonToSegmentation(resultGeometry);
if (resultSegmentation.length === 0) {
const deleteMaskIds = expandedPropagationDeletionMaskIds([primary.id], masks);
const deleteIds = masks
.filter((mask) => deleteMaskIds.has(mask.id))
.map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId));
setMasks(masks.filter((mask) => !deleteMaskIds.has(mask.id)));
if (deleteIds.length > 0) await onDeleteMaskAnnotations?.(deleteIds);
setSelectedMaskId(null);
setSelectedMaskIds([]);
setSelectedVertexIndex(null);
return;
}
const nextPrimary = updateMaskFromSegmentation(primary, resultSegmentation, {
area: multiPolygonArea(resultGeometry),
hasHoles: multiPolygonHasHoles(resultGeometry),
polygonRingCounts: multiPolygonRingCounts(resultGeometry),
masks.forEach((mask) => {
const targetFrameId = String(mask.frameId);
if (targetFrameId === currentFrameId) return;
const hasPrimary = findLinkedMasksOnFrame([primary.id], masks, targetFrameId).length > 0;
if (!hasPrimary) return;
const hasSecondary = secondaryMasks.some((secondary) => (
findLinkedMasksOnFrame([secondary.id], masks, targetFrameId).length > 0
));
if (hasSecondary) targetFrameIds.add(targetFrameId);
});
const secondaryIds = effectiveTool === 'area_merge'
? expandedPropagationDeletionMaskIds(booleanSelectedMasks.slice(1).map((mask) => mask.id), masks)
: new Set<string>();
const secondaryAnnotationIds = effectiveTool === 'area_merge'
? masks
.filter((mask) => secondaryIds.has(mask.id))
const updatedMasks = new Map<string, Mask>();
const deletedMaskIds = new Set<string>();
const applyOperationForFrame = (targetFrameId: string) => {
const primaryTargetId = targetFrameId === currentFrameId
? primary.id
: findLinkedMasksOnFrame([primary.id], masks, targetFrameId)[0];
const primaryTarget = masks.find((mask) => mask.id === primaryTargetId);
if (!primaryTarget || deletedMaskIds.has(primaryTarget.id)) return;
const primaryGeometry = maskToMultiPolygon(primaryTarget);
if (!primaryGeometry) return;
const secondaryTargetIds = Array.from(new Set(
secondaryMasks.flatMap((secondary) => (
targetFrameId === currentFrameId
? [secondary.id]
: findLinkedMasksOnFrame([secondary.id], masks, targetFrameId)
)),
)).filter((maskId) => maskId !== primaryTarget.id && !deletedMaskIds.has(maskId));
const secondaryTargets = secondaryTargetIds
.map((maskId) => masks.find((mask) => mask.id === maskId))
.filter((mask): mask is Mask => Boolean(mask));
const clipGeometries = secondaryTargets
.map(maskToMultiPolygon)
.filter((geometry): geometry is MultiPolygon => Boolean(geometry));
if (clipGeometries.length === 0) return;
const resultGeometry = effectiveTool === 'area_merge'
? polygonClipping.union(primaryGeometry, ...clipGeometries)
: polygonClipping.difference(primaryGeometry, ...clipGeometries);
const resultSegmentation = multiPolygonToSegmentation(resultGeometry);
if (resultSegmentation.length === 0) {
deletedMaskIds.add(primaryTarget.id);
} else {
updatedMasks.set(primaryTarget.id, updateMaskFromSegmentation(primaryTarget, resultSegmentation, {
area: multiPolygonArea(resultGeometry),
hasHoles: multiPolygonHasHoles(resultGeometry),
polygonRingCounts: multiPolygonRingCounts(resultGeometry),
}));
}
if (effectiveTool === 'area_merge') {
secondaryTargets.forEach((mask) => deletedMaskIds.add(mask.id));
}
};
targetFrameIds.forEach(applyOperationForFrame);
const deletedAnnotationIds = Array.from(new Set(
masks
.filter((mask) => deletedMaskIds.has(mask.id))
.map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId))
: [];
.filter((annotationId): annotationId is string => Boolean(annotationId)),
));
setMasks(masks
.filter((mask) => !secondaryIds.has(mask.id))
.map((mask) => (mask.id === primary.id ? nextPrimary : mask)));
if (secondaryAnnotationIds.length > 0) await onDeleteMaskAnnotations?.(secondaryAnnotationIds);
setSelectedMaskId(primary.id);
setSelectedMaskIds([primary.id]);
.filter((mask) => !deletedMaskIds.has(mask.id))
.map((mask) => updatedMasks.get(mask.id) || mask));
if (deletedAnnotationIds.length > 0) await onDeleteMaskAnnotations?.(deletedAnnotationIds);
if (deletedMaskIds.has(primary.id)) {
setSelectedMaskId(null);
setSelectedMaskIds([]);
} else {
setSelectedMaskId(primary.id);
setSelectedMaskIds([primary.id]);
}
setSelectedVertexIndex(null);
};

View File

@@ -6,12 +6,14 @@ import { OntologyInspector } from './OntologyInspector';
const apiMock = vi.hoisted(() => ({
analyzeMask: vi.fn(),
deleteAnnotation: vi.fn(),
smoothMaskGeometry: vi.fn(),
updateTemplate: vi.fn(),
}));
vi.mock('../lib/api', () => ({
analyzeMask: apiMock.analyzeMask,
deleteAnnotation: apiMock.deleteAnnotation,
smoothMaskGeometry: apiMock.smoothMaskGeometry,
updateTemplate: apiMock.updateTemplate,
}));
@@ -41,6 +43,7 @@ describe('OntologyInspector', () => {
smoothing: { strength: 35, method: 'chaikin' },
message: '已应用边缘平滑强度 35',
});
apiMock.deleteAnnotation.mockResolvedValue(undefined);
useStore.setState({
templates: [
{
@@ -72,6 +75,44 @@ describe('OntologyInspector', () => {
expect(screen.queryByText(/z:/)).not.toBeInTheDocument();
});
it('requires confirmation and clears existing masks before switching templates', async () => {
useStore.setState({
activeTemplateId: 't1',
templates: [
{
id: 't1',
name: '腹腔镜模板',
classes: [{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 }],
rules: [],
},
{
id: 't2',
name: '头颈部模板',
classes: [{ id: 'c2', name: '肿瘤', color: '#00ff00', zIndex: 20 }],
rules: [],
},
],
masks: [
{ id: 'annotation-10', annotationId: '10', frameId: 'f1', pathData: 'M 0 0 Z', label: '胆囊', color: '#ff0000', saveStatus: 'saved', saved: true },
{ id: 'draft-1', frameId: 'f1', pathData: 'M 1 1 Z', label: '草稿', color: '#06b6d4', saveStatus: 'draft' },
],
selectedMaskIds: ['annotation-10'],
});
render(<OntologyInspector />);
fireEvent.change(screen.getByRole('combobox'), { target: { value: 't2' } });
expect(screen.getByText('确认修改激活模板')).toBeInTheDocument();
expect(useStore.getState().activeTemplateId).toBe('t1');
fireEvent.click(screen.getByRole('button', { name: '清空并切换' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10'));
expect(useStore.getState().masks).toEqual([]);
expect(useStore.getState().selectedMaskIds).toEqual([]);
expect(useStore.getState().activeTemplateId).toBe('t2');
});
it('adjusts workspace mask opacity from above the semantic tree', () => {
render(<OntologyInspector />);

View File

@@ -4,7 +4,7 @@ import { useStore } from '../store/useStore';
import type { Mask, TemplateClass } from '../store/useStore';
import { cn } from '../lib/utils';
import { getActiveTemplate } from '../lib/templateSelection';
import { analyzeMask, smoothMaskGeometry, updateTemplate, type MaskAnalysisResult, type SmoothMaskGeometryResult } from '../lib/api';
import { analyzeMask, deleteAnnotation, smoothMaskGeometry, updateTemplate, type MaskAnalysisResult, type SmoothMaskGeometryResult } from '../lib/api';
import { isReservedUnclassifiedClass, nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds';
const SMOOTHING_PREVIEW_DEBOUNCE_MS = 220;
@@ -23,6 +23,19 @@ function metadataNumber(value: unknown): number | null {
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function isNotFoundError(error: unknown): boolean {
const maybeError = error as { response?: { status?: number }; status?: number } | null;
return maybeError?.response?.status === 404 || maybeError?.status === 404;
}
async function deleteAnnotationIfExists(annotationId: string) {
try {
await deleteAnnotation(annotationId);
} catch (error) {
if (!isNotFoundError(error)) throw error;
}
}
function propagationSourceMaskTokens(value: unknown): string[] {
if (typeof value !== 'string' || value.length === 0) return [];
const tokens = [`mask:${value}`];
@@ -70,6 +83,7 @@ export function OntologyInspector() {
const selectedMaskIds = useStore((state) => state.selectedMaskIds);
const maskPreviewOpacity = useStore((state) => state.maskPreviewOpacity);
const setMasks = useStore((state) => state.setMasks);
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
const updateTemplateStore = useStore((state) => state.updateTemplate);
const setActiveTemplateId = useStore((state) => state.setActiveTemplateId);
const setActiveClass = useStore((state) => state.setActiveClass);
@@ -86,6 +100,8 @@ export function OntologyInspector() {
const [smoothingStrength, setSmoothingStrength] = useState(0);
const [isPreviewingSmoothing, setIsPreviewingSmoothing] = useState(false);
const [isSmoothingMask, setIsSmoothingMask] = useState(false);
const [pendingTemplateSwitch, setPendingTemplateSwitch] = useState<{ templateId: string | null } | null>(null);
const [isSwitchingTemplate, setIsSwitchingTemplate] = useState(false);
const activeTemplate = getActiveTemplate(templates, activeTemplateId);
const templateClasses = normalizeClassMaskIds(activeTemplate?.classes || []);
@@ -215,6 +231,51 @@ export function OntologyInspector() {
smoothingPreviewRef.current = null;
}, []);
const applyActiveTemplateChange = React.useCallback((templateId: string | null) => {
setActiveTemplateId(templateId);
setActiveClass(null);
}, [setActiveClass, setActiveTemplateId]);
const requestActiveTemplateChange = React.useCallback((templateId: string | null) => {
if (templateId === (activeTemplateId || null)) return;
if (!activeTemplateId && templateId && templateId === activeTemplate?.id) {
applyActiveTemplateChange(templateId);
return;
}
if (masks.length === 0) {
applyActiveTemplateChange(templateId);
return;
}
setPendingTemplateSwitch({ templateId });
}, [activeTemplate?.id, activeTemplateId, applyActiveTemplateChange, masks.length]);
const confirmActiveTemplateChange = React.useCallback(async () => {
if (!pendingTemplateSwitch) return;
const nextTemplateId = pendingTemplateSwitch.templateId;
const annotationIds = Array.from(new Set(
useStore.getState().masks
.map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId)),
));
setIsSwitchingTemplate(true);
setClassSaveMessage(annotationIds.length > 0 ? '正在清空旧模板标注...' : '正在清空旧模板遮罩...');
try {
await Promise.all(annotationIds.map(deleteAnnotationIfExists));
useStore.getState().setMasks([]);
setSelectedMaskIds([]);
applyActiveTemplateChange(nextTemplateId);
setPendingTemplateSwitch(null);
setClassSaveMessage(annotationIds.length > 0
? `已清空 ${annotationIds.length} 个旧模板标注并切换激活模板`
: '已清空旧模板遮罩并切换激活模板');
} catch (err) {
console.error('Switch active template failed:', err);
setClassSaveMessage('切换模板失败,旧标注未清空');
} finally {
setIsSwitchingTemplate(false);
}
}, [applyActiveTemplateChange, pendingTemplateSwitch, setSelectedMaskIds]);
React.useEffect(() => {
return () => {
analysisRequestIdRef.current += 1;
@@ -563,9 +624,9 @@ export function OntologyInspector() {
<select
value={activeTemplate?.id || ''}
onChange={(e) => {
setActiveTemplateId(e.target.value || null);
setActiveClass(null);
requestActiveTemplateChange(e.target.value || null);
}}
disabled={isSwitchingTemplate}
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-300 appearance-none cursor-pointer focus:outline-none focus:border-cyan-500/50"
>
<option value="">-- --</option>
@@ -754,6 +815,37 @@ export function OntologyInspector() {
</div>
</div>
</div>
{pendingTemplateSwitch && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
<div className="w-full max-w-md rounded-lg border border-amber-400/25 bg-[#151515] p-5 shadow-2xl">
<h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-2 text-sm leading-relaxed text-gray-300">
{masks.length} mask mask
</p>
<p className="mt-2 text-xs leading-relaxed text-amber-200/70">
mask
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => setPendingTemplateSwitch(null)}
disabled={isSwitchingTemplate}
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => void confirmActiveTemplateChange()}
disabled={isSwitchingTemplate}
className="rounded bg-amber-500 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-400 disabled:opacity-60"
>
{isSwitchingTemplate ? '正在切换...' : '清空并切换'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -238,7 +238,7 @@ describe('TemplateRegistry', () => {
expect(screen.queryByPlaceholderText('类别')).not.toBeInTheDocument();
});
it('shows the semantic tree title and opens the add-class modal from the detail view', async () => {
it('shows the semantic tree title and opens the edit-template modal from the detail view', async () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
id: 't1',
@@ -269,7 +269,9 @@ describe('TemplateRegistry', () => {
expect(screen.queryByText(/Painter's Algorithm Weight/)).not.toBeInTheDocument();
expect(screen.queryByText('器官')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /新建分类/ }));
expect(screen.queryByRole('button', { name: /新建分类/ })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '编辑模板' }));
fireEvent.click(screen.getByRole('button', { name: /添加分类/ }));
expect(screen.getByDisplayValue('新类别')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '保存' }));

View File

@@ -138,17 +138,6 @@ export function TemplateRegistry() {
category: '未分类',
});
const openAddClass = (template: Template) => {
const classes = normalizeClassMaskIds(template.classes ? [...template.classes] : []);
const newClass = buildNewClass(classes);
setSelectedTemplate(template);
setEditName(template.name);
setEditDesc(template.description || '');
setEditClasses([...classes, newClass]);
setEditingClassId(newClass.id);
setShowModal(true);
};
const buildTemplatePayload = (template: Template | null, classes: TemplateClass[]) => ({
name: template ? template.name : editName.trim(),
description: template ? template.description || undefined : editDesc.trim() || undefined,
@@ -511,10 +500,10 @@ export function TemplateRegistry() {
</h2>
{activeTemplate && (
<button
onClick={() => openAddClass(activeTemplate)}
onClick={() => openEdit(activeTemplate)}
className="bg-white/5 hover:bg-white/10 border border-white/10 px-4 py-1.5 rounded text-sm text-gray-300 transition-colors flex items-center gap-2"
>
<Plus size={14} />
<Edit3 size={14} />
</button>
)}
</div>

View File

@@ -69,6 +69,15 @@ describe('ToolsPalette', () => {
expect(overlapButton.compareDocumentPosition(importButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('exposes clear mask action in the left toolbar', () => {
const onClearMasks = vi.fn();
render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} onClearMasks={onClearMasks} />);
fireEvent.click(screen.getByTitle('清空遮罩(含传播帧)'));
expect(onClearMasks).toHaveBeenCalled();
});
it('separates drawing, editing, and external action tool groups', () => {
render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} canImportGtMask />);
@@ -76,12 +85,15 @@ describe('ToolsPalette', () => {
const circleButton = screen.getByTitle('创建圆 (O)');
const brushButton = screen.getByTitle('画笔 (B)');
const removeButton = screen.getByTitle('重叠区域去除 (-)');
const clearButton = screen.getByTitle('清空遮罩(含传播帧)');
const importButton = screen.getByTitle('导入 GT Mask');
expect(separators).toHaveLength(2);
expect(circleButton.compareDocumentPosition(separators[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(separators[0].compareDocumentPosition(brushButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(removeButton.compareDocumentPosition(separators[1]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(separators[1].compareDocumentPosition(clearButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(clearButton.compareDocumentPosition(importButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(separators[1].compareDocumentPosition(importButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
separators.forEach((separator) => {
expect(separator).toHaveClass('bg-white/15');

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { MousePointer2, PencilLine, Hexagon, Square, Circle, Brush, Eraser, Combine, Scissors, FileUp } from 'lucide-react';
import { MousePointer2, PencilLine, Hexagon, Square, Circle, Brush, Eraser, Combine, Scissors, FileUp, Trash2 } from 'lucide-react';
import { cn } from '../lib/utils';
import { AiSegmentationIcon } from './AiSegmentationIcon';
import { useStore } from '../store/useStore';
@@ -9,6 +9,7 @@ interface ToolsPaletteProps {
setActiveTool: (tool: string) => void;
onTriggerAI?: () => void;
onImportGtMask?: () => void;
onClearMasks?: () => void;
canImportGtMask?: boolean;
isImportingGtMask?: boolean;
}
@@ -18,6 +19,7 @@ export function ToolsPalette({
setActiveTool,
onTriggerAI,
onImportGtMask,
onClearMasks,
canImportGtMask = false,
isImportingGtMask = false,
}: ToolsPaletteProps) {
@@ -99,6 +101,15 @@ export function ToolsPalette({
)
})}
<button
onClick={onClearMasks}
disabled={!onClearMasks}
title="清空遮罩(含传播帧)"
className="w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 text-red-300 hover:bg-red-500/10 hover:text-red-200 disabled:opacity-35 disabled:cursor-not-allowed"
>
<Trash2 size={16} strokeWidth={2.1} />
</button>
<button
onClick={onImportGtMask}
disabled={!canImportGtMask || isImportingGtMask}

View File

@@ -483,6 +483,52 @@ describe('VideoWorkspace', () => {
expect(useStore.getState().masks).toEqual([]);
});
it('clears linked propagated masks 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 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockResolvedValue(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{
id: 'annotation-1',
annotationId: '1',
frameId: '10',
pathData: 'M 0 0 Z',
label: 'Seed',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
},
{
id: 'annotation-10',
annotationId: '10',
frameId: '11',
pathData: 'M 1 1 Z',
label: 'Propagated',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
},
],
selectedMaskIds: ['annotation-10'],
});
});
fireEvent.click(screen.getByRole('button', { name: '清空遮罩' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('1'));
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10');
expect(useStore.getState().masks).toEqual([]);
expect(useStore.getState().selectedMaskIds).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 },

View File

@@ -280,6 +280,61 @@ const isPropagatedMask = (mask: Mask) => {
|| mask.metadata?.propagation_seed_key !== undefined;
};
const metadataNumber = (value: unknown): number | null => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
};
const propagationSourceMaskTokens = (value: unknown): string[] => {
if (typeof value !== 'string' || value.length === 0) return [];
const tokens = [`mask:${value}`];
const annotationMatch = value.match(/^annotation-(\d+)$/);
if (annotationMatch) tokens.push(`annotation:${annotationMatch[1]}`);
return tokens;
};
const propagationLineageTokens = (mask: Mask): Set<string> => {
const metadata = mask.metadata || {};
const tokens = new Set<string>([`mask:${mask.id}`]);
if (mask.annotationId) tokens.add(`annotation:${mask.annotationId}`);
const sourceAnnotationId = metadataNumber(metadata.source_annotation_id);
if (sourceAnnotationId !== null) tokens.add(`annotation:${sourceAnnotationId}`);
propagationSourceMaskTokens(metadata.source_mask_id).forEach((token) => tokens.add(token));
if (typeof metadata.propagation_seed_key === 'string' && metadata.propagation_seed_key.length > 0) {
tokens.add(`seed-key:${metadata.propagation_seed_key}`);
}
return tokens;
};
const findPropagationChainMaskIds = (selectedIds: string[], allMasks: Mask[]): Set<string> => {
const selectedMasks = selectedIds
.map((id) => allMasks.find((mask) => mask.id === id))
.filter((mask): mask is Mask => Boolean(mask));
const selectedTokens = new Set<string>();
selectedMasks.forEach((mask) => {
propagationLineageTokens(mask).forEach((token) => selectedTokens.add(token));
});
if (selectedTokens.size === 0) return new Set(selectedIds);
return new Set(
allMasks
.filter((mask) => {
const candidateTokens = propagationLineageTokens(mask);
return [...candidateTokens].some((token) => selectedTokens.has(token));
})
.map((mask) => mask.id),
);
};
const expandedPropagationDeletionMaskIds = (selectedIds: string[], allMasks: Mask[]): Set<string> => {
const selectedIdSet = new Set(selectedIds);
const chainIds = findPropagationChainMaskIds(selectedIds, allMasks);
return new Set(
allMasks
.filter((mask) => selectedIdSet.has(mask.id) || (chainIds.has(mask.id) && isPropagatedMask(mask)))
.map((mask) => mask.id),
);
};
const persistentMaskMetadata = (metadata?: Record<string, unknown>) => {
if (!metadata) return {};
const {
@@ -732,25 +787,30 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const handleClearCurrentFrameMasks = useCallback(async () => {
if (!currentFrame) return;
const frameMasks = masks.filter((mask) => mask.frameId === currentFrame.id);
const annotationIds = frameMasks
.map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId));
const maskIdsToClear = expandedPropagationDeletionMaskIds(frameMasks.map((mask) => mask.id), masks);
const masksToClear = masks.filter((mask) => maskIdsToClear.has(mask.id));
const annotationIds = Array.from(new Set(
masksToClear
.map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId)),
));
setIsSaving(true);
setStatusMessage(annotationIds.length > 0 ? '正在删除已保存标注...' : '正在清空本帧遮罩...');
setStatusMessage(annotationIds.length > 0 ? '正在删除已保存标注和关联传播帧...' : '正在清空本帧遮罩和关联传播帧...');
try {
await deleteAnnotationsIfExist(annotationIds);
setMasks(masks.filter((mask) => mask.frameId !== currentFrame.id));
setMasks(masks.filter((mask) => !maskIdsToClear.has(mask.id)));
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !maskIdsToClear.has(id)));
setStatusMessage(annotationIds.length > 0
? `已删除 ${annotationIds.length} 个后端标注`
: '已清空本帧未保存遮罩');
? `已删除 ${annotationIds.length} 个后端标注,已同步清空关联传播帧`
: '已清空本帧未保存遮罩和关联传播帧');
} catch (err) {
console.error('Delete annotations failed:', err);
setStatusMessage('删除失败,请检查后端服务');
} finally {
setIsSaving(false);
}
}, [currentFrame, masks, setMasks]);
}, [currentFrame, masks, setMasks, setSelectedMaskIds]);
const executeClearFrameRange = useCallback(async (request: ClearRangeConfirmState) => {
const frameIdsToClear = new Set(request.frameIdsToClear);
@@ -1844,6 +1904,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setActiveTool={setActiveTool}
onTriggerAI={onNavigateToAI}
onImportGtMask={() => gtMaskInputRef.current?.click()}
onClearMasks={handleClearCurrentFrameMasks}
canImportGtMask={Boolean(currentProject?.id && currentFrame?.id) && !isSaving && !isExporting && !isPropagating}
isImportingGtMask={isImportingGt}
/>