完善遮罩删除范围选择
- 删除/清空已保存标注前预检后端 annotation id,跳过本地陈旧 id,避免重复 DELETE 产生 404 控制台红字 - 左侧工具栏新增 DEL 删除选中遮罩入口,调整清空遮罩弹窗文案为“清空所有传播帧”,并加入按帧范围选择入口 - 区域合并和重叠区域去除在存在传播帧时弹出当前帧/所有传播帧选择,传播帧同步后保留原 lineage metadata - 多 polygon 或分离区域组成的 mask 选中后显示全部顶点与插点手柄,同帧传播链分散 mask 点选时联动高亮 - 调整工具栏分组分隔线位置,只在清空遮罩下方保留 tool-group-separator 测试标记 - 更新 VideoWorkspace、CanvasArea、ToolsPalette 回归测试和相关项目文档
This commit is contained in:
@@ -871,11 +871,12 @@ describe('CanvasArea', () => {
|
||||
fireEvent.click(paths[1]);
|
||||
const vertexHandles = screen.getAllByTestId('konva-circle')
|
||||
.filter((element) => element.getAttribute('data-fill') === '#ffffff');
|
||||
expect(vertexHandles).toHaveLength(6);
|
||||
fireEvent.mouseUp(vertexHandles[0], { clientX: 120, clientY: 120 });
|
||||
|
||||
expect(useStore.getState().masks[0].segmentation).toEqual([
|
||||
[10, 10, 50, 10, 50, 40],
|
||||
[120, 120, 150, 100, 150, 140],
|
||||
[120, 120, 50, 10, 50, 40],
|
||||
[100, 100, 150, 100, 150, 140],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -979,6 +980,8 @@ describe('CanvasArea', () => {
|
||||
fireEvent.click(paths[0]);
|
||||
fireEvent.click(paths[1]);
|
||||
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
|
||||
expect(screen.getByText('选择操作范围')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '处理所有传播帧' }));
|
||||
|
||||
await waitFor(() => expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(expect.arrayContaining(['2', '20'])));
|
||||
const masks = useStore.getState().masks;
|
||||
@@ -1094,6 +1097,8 @@ describe('CanvasArea', () => {
|
||||
fireEvent.click(paths[0]);
|
||||
fireEvent.click(paths[1]);
|
||||
fireEvent.click(screen.getByRole('button', { name: '从主区域去除' }));
|
||||
expect(screen.getByText('选择操作范围')).toBeInTheDocument();
|
||||
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();
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Frame, Mask } from '../store/useStore';
|
||||
interface CanvasAreaProps {
|
||||
activeTool: string;
|
||||
frame: Frame | null;
|
||||
onRequestDeleteMasks?: (maskIds: string[]) => void;
|
||||
onDeleteMaskAnnotations?: (annotationIds: string[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
@@ -416,7 +417,7 @@ function geometriesOverlap(first: MultiPolygon, second: MultiPolygon): boolean {
|
||||
return polygonClipping.intersection(first, second).length > 0;
|
||||
}
|
||||
|
||||
export function CanvasArea({ activeTool, frame, onDeleteMaskAnnotations }: CanvasAreaProps) {
|
||||
export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onDeleteMaskAnnotations }: CanvasAreaProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
|
||||
const [scale, setScale] = useState(1);
|
||||
@@ -435,6 +436,7 @@ export function CanvasArea({ activeTool, frame, onDeleteMaskAnnotations }: Canva
|
||||
const [selectedMaskIds, setSelectedMaskIds] = useState<string[]>(() => useStore.getState().selectedMaskIds);
|
||||
const [selectedPolygonIndex, setSelectedPolygonIndex] = useState(0);
|
||||
const [selectedVertexIndex, setSelectedVertexIndex] = useState<number | null>(null);
|
||||
const [pendingBooleanFrameIds, setPendingBooleanFrameIds] = useState<string[] | null>(null);
|
||||
const previousFrameIdRef = useRef<string | undefined>(frame?.id);
|
||||
const [isInferencing, setIsInferencing] = useState(false);
|
||||
const [inferenceMessage, setInferenceMessage] = useState('');
|
||||
@@ -645,6 +647,7 @@ export function CanvasArea({ activeTool, frame, onDeleteMaskAnnotations }: Canva
|
||||
setSelectedMaskIds([]);
|
||||
setSelectedPolygonIndex(0);
|
||||
}
|
||||
if (!isBooleanTool) setPendingBooleanFrameIds(null);
|
||||
}, [effectiveTool, isBooleanTool, isPaintTool, isPolygonEditTool, setPaintStrokePoints]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1265,6 +1268,10 @@ export function CanvasArea({ activeTool, frame, onDeleteMaskAnnotations }: Canva
|
||||
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedMask) {
|
||||
event.preventDefault();
|
||||
const ids = selectedMaskIds.length > 0 ? selectedMaskIds : [selectedMask.id];
|
||||
if (onRequestDeleteMasks) {
|
||||
onRequestDeleteMasks(ids);
|
||||
return;
|
||||
}
|
||||
deleteMasksById(ids);
|
||||
return;
|
||||
}
|
||||
@@ -1281,7 +1288,7 @@ export function CanvasArea({ activeTool, frame, onDeleteMaskAnnotations }: Canva
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [deleteMasksById, effectiveTool, finishPolygon, isPolygonEditTool, polygonPoints, selectedMask, selectedMaskIds, selectedPolygonIndex, selectedVertexIndex, updatePolygonMask]);
|
||||
}, [deleteMasksById, effectiveTool, finishPolygon, isPolygonEditTool, onRequestDeleteMasks, polygonPoints, selectedMask, selectedMaskIds, selectedPolygonIndex, selectedVertexIndex, updatePolygonMask]);
|
||||
|
||||
const boxRect = React.useMemo(() => {
|
||||
if (!boxStart || !boxCurrent) return null;
|
||||
@@ -1307,7 +1314,7 @@ export function CanvasArea({ activeTool, frame, onDeleteMaskAnnotations }: Canva
|
||||
const selectedMaskEditableRings = React.useMemo(() => {
|
||||
if (!selectedMask?.segmentation) return [];
|
||||
const hasHoles = Boolean(selectedMask.metadata?.hasHoles);
|
||||
if (!hasHoles) {
|
||||
if (!hasHoles && selectedMask.segmentation.length <= 1) {
|
||||
return [{ polygonIndex: selectedPolygonIndex, points: selectedMaskPoints }];
|
||||
}
|
||||
return selectedMask.segmentation
|
||||
@@ -1330,7 +1337,8 @@ export function CanvasArea({ activeTool, frame, onDeleteMaskAnnotations }: Canva
|
||||
return;
|
||||
}
|
||||
setSelectedMaskId(mask.id);
|
||||
setSelectedMaskIds([mask.id]);
|
||||
const linkedMaskIds = findLinkedMasksOnFrame([mask.id], masks, frame?.id);
|
||||
setSelectedMaskIds(linkedMaskIds.length > 0 ? linkedMaskIds : [mask.id]);
|
||||
setSelectedPolygonIndex(polygonIndex);
|
||||
setSelectedVertexIndex(null);
|
||||
};
|
||||
@@ -1400,7 +1408,7 @@ export function CanvasArea({ activeTool, frame, onDeleteMaskAnnotations }: Canva
|
||||
updatePolygonMask(mask, nextPoints, polygonIndex);
|
||||
};
|
||||
|
||||
const handleBooleanOperation = async () => {
|
||||
const collectBooleanOperationFrameIds = useCallback(() => {
|
||||
if (!frame || booleanSelectedMasks.length < 2) return;
|
||||
const primary = booleanSelectedMasks[0];
|
||||
const secondaryMasks = booleanSelectedMasks.slice(1);
|
||||
@@ -1417,7 +1425,14 @@ export function CanvasArea({ activeTool, frame, onDeleteMaskAnnotations }: Canva
|
||||
));
|
||||
if (hasSecondary) targetFrameIds.add(targetFrameId);
|
||||
});
|
||||
return { currentFrameId, targetFrameIds };
|
||||
}, [booleanSelectedMasks, frame, masks]);
|
||||
|
||||
const runBooleanOperation = useCallback(async (targetFrameIds: Set<string>) => {
|
||||
if (!frame || booleanSelectedMasks.length < 2) return;
|
||||
const primary = booleanSelectedMasks[0];
|
||||
const secondaryMasks = booleanSelectedMasks.slice(1);
|
||||
const currentFrameId = String(frame.id);
|
||||
const updatedMasks = new Map<string, Mask>();
|
||||
const deletedMaskIds = new Set<string>();
|
||||
|
||||
@@ -1486,6 +1501,18 @@ export function CanvasArea({ activeTool, frame, onDeleteMaskAnnotations }: Canva
|
||||
setSelectedMaskIds([primary.id]);
|
||||
}
|
||||
setSelectedVertexIndex(null);
|
||||
setPendingBooleanFrameIds(null);
|
||||
}, [booleanSelectedMasks, effectiveTool, frame, masks, onDeleteMaskAnnotations, setMasks]);
|
||||
|
||||
const handleBooleanOperation = async () => {
|
||||
const frameSelection = collectBooleanOperationFrameIds();
|
||||
if (!frameSelection) return;
|
||||
const { currentFrameId, targetFrameIds } = frameSelection;
|
||||
if (targetFrameIds.size > 1) {
|
||||
setPendingBooleanFrameIds(Array.from(targetFrameIds));
|
||||
return;
|
||||
}
|
||||
await runBooleanOperation(new Set([currentFrameId]));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1755,6 +1782,43 @@ export function CanvasArea({ activeTool, frame, onDeleteMaskAnnotations }: Canva
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingBooleanFrameIds && frame && (
|
||||
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/55 px-4">
|
||||
<div className="w-full max-w-sm rounded-lg border border-emerald-400/25 bg-[#151515] p-4 shadow-2xl">
|
||||
<h2 className="text-sm font-semibold text-white">选择操作范围</h2>
|
||||
<p className="mt-2 text-xs leading-relaxed text-gray-300">
|
||||
当前选中的区域存在自动传播帧。请选择只处理当前帧,还是同步处理同一传播链上的所有帧。
|
||||
</p>
|
||||
<div className="mt-3 rounded-md border border-white/10 bg-white/[0.03] p-2 text-[11px] text-gray-400">
|
||||
将影响 {pendingBooleanFrameIds.length} 帧的对应区域。
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingBooleanFrameIds(null)}
|
||||
className="rounded border border-white/10 px-3 py-1.5 text-xs text-gray-300 hover:bg-white/5"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runBooleanOperation(new Set([String(frame.id)]))}
|
||||
className="rounded border border-emerald-400/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold text-emerald-100 hover:bg-emerald-500/20"
|
||||
>
|
||||
只处理当前帧
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runBooleanOperation(new Set(pendingBooleanFrameIds))}
|
||||
className="rounded bg-emerald-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-400"
|
||||
>
|
||||
处理所有传播帧
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,30 +71,40 @@ describe('ToolsPalette', () => {
|
||||
|
||||
it('exposes clear mask action in the left toolbar', () => {
|
||||
const onClearMasks = vi.fn();
|
||||
render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} onClearMasks={onClearMasks} />);
|
||||
const onDeleteMasks = vi.fn();
|
||||
render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} onClearMasks={onClearMasks} onDeleteMasks={onDeleteMasks} />);
|
||||
|
||||
fireEvent.click(screen.getByTitle('删除选中遮罩 (Del)'));
|
||||
fireEvent.click(screen.getByTitle('清空遮罩'));
|
||||
|
||||
expect(onDeleteMasks).toHaveBeenCalled();
|
||||
expect(onClearMasks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('separates drawing, editing, and external action tool groups', () => {
|
||||
render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} canImportGtMask />);
|
||||
const { container } = render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} canImportGtMask />);
|
||||
|
||||
const separators = screen.getAllByTestId('tool-group-separator');
|
||||
const separators = Array.from(container.querySelectorAll('.h-px'));
|
||||
const externalActionSeparator = screen.getByTestId('tool-group-separator');
|
||||
const circleButton = screen.getByTitle('创建圆 (O)');
|
||||
const brushButton = screen.getByTitle('画笔 (B)');
|
||||
const eraserButton = screen.getByTitle('橡皮擦 (X)');
|
||||
const mergeButton = screen.getByTitle('区域合并 (+)');
|
||||
const removeButton = screen.getByTitle('重叠区域去除 (-)');
|
||||
const deleteButton = screen.getByTitle('删除选中遮罩 (Del)');
|
||||
const clearButton = screen.getByTitle('清空遮罩');
|
||||
const importButton = screen.getByTitle('导入 GT Mask');
|
||||
|
||||
expect(separators).toHaveLength(2);
|
||||
expect(separators).toHaveLength(3);
|
||||
expect(externalActionSeparator).toBe(separators[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();
|
||||
expect(eraserButton.compareDocumentPosition(separators[1]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(separators[1].compareDocumentPosition(mergeButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(removeButton.compareDocumentPosition(deleteButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(deleteButton.compareDocumentPosition(clearButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(clearButton.compareDocumentPosition(separators[2]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(separators[2].compareDocumentPosition(importButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
separators.forEach((separator) => {
|
||||
expect(separator).toHaveClass('bg-white/15');
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ interface ToolsPaletteProps {
|
||||
setActiveTool: (tool: string) => void;
|
||||
onTriggerAI?: () => void;
|
||||
onImportGtMask?: () => void;
|
||||
onDeleteMasks?: () => void;
|
||||
onClearMasks?: () => void;
|
||||
canImportGtMask?: boolean;
|
||||
isImportingGtMask?: boolean;
|
||||
@@ -19,6 +20,7 @@ export function ToolsPalette({
|
||||
setActiveTool,
|
||||
onTriggerAI,
|
||||
onImportGtMask,
|
||||
onDeleteMasks,
|
||||
onClearMasks,
|
||||
canImportGtMask = false,
|
||||
isImportingGtMask = false,
|
||||
@@ -94,13 +96,22 @@ export function ToolsPalette({
|
||||
<div className="mt-1 text-[10px] leading-none text-gray-400">{sizeControl.value}</div>
|
||||
</div>
|
||||
)}
|
||||
{(tool.id === 'create_circle' || tool.id === 'area_remove') && (
|
||||
<div data-testid="tool-group-separator" className="my-1 h-px w-9 bg-white/15" />
|
||||
{(tool.id === 'create_circle' || tool.id === 'eraser') && (
|
||||
<div className="my-1 h-px w-9 bg-white/15" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={onDeleteMasks}
|
||||
disabled={!onDeleteMasks}
|
||||
title="删除选中遮罩 (Del)"
|
||||
className="w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 text-red-200 hover:bg-red-500/10 hover:text-red-100 disabled:opacity-35 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="text-[10px] font-bold leading-none">DEL</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClearMasks}
|
||||
disabled={!onClearMasks}
|
||||
@@ -110,6 +121,8 @@ export function ToolsPalette({
|
||||
<Trash2 size={16} strokeWidth={2.1} />
|
||||
</button>
|
||||
|
||||
<div data-testid="tool-group-separator" className="my-1 h-px w-9 bg-white/15" />
|
||||
|
||||
<button
|
||||
onClick={onImportGtMask}
|
||||
disabled={!canImportGtMask || isImportingGtMask}
|
||||
|
||||
@@ -54,7 +54,11 @@ describe('VideoWorkspace', () => {
|
||||
vi.clearAllMocks();
|
||||
useStore.setState({ currentProject: { id: '1', name: 'Demo', status: 'ready', video_path: 'uploads/demo.mp4' } });
|
||||
apiMock.getTemplates.mockResolvedValue([]);
|
||||
apiMock.getProjectAnnotations.mockResolvedValue([]);
|
||||
apiMock.getProjectAnnotations.mockImplementation(async () => (
|
||||
useStore.getState().masks
|
||||
.filter((mask) => mask.annotationId)
|
||||
.map((mask) => ({ id: Number(mask.annotationId), frame_id: Number(mask.frameId) || mask.frameId }))
|
||||
));
|
||||
apiMock.annotationToMask.mockReturnValue(null);
|
||||
apiMock.queuePropagationTask.mockResolvedValue({ id: 31, status: 'queued', progress: 0, message: '自动传播任务已入队' });
|
||||
apiMock.getTask.mockResolvedValue({
|
||||
@@ -674,7 +678,7 @@ describe('VideoWorkspace', () => {
|
||||
|
||||
fireEvent.click(screen.getByTitle('清空遮罩'));
|
||||
expect(screen.getByText('选择清空范围')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空传播所有帧' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空所有传播帧' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('1'));
|
||||
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10');
|
||||
@@ -866,7 +870,7 @@ describe('VideoWorkspace', () => {
|
||||
{ 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 },
|
||||
]);
|
||||
apiMock.deleteAnnotation.mockRejectedValueOnce({ response: { status: 404 } });
|
||||
apiMock.getProjectAnnotations.mockResolvedValue([]);
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
|
||||
@@ -891,7 +895,8 @@ describe('VideoWorkspace', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认清除人工/AI 标注' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
|
||||
await waitFor(() => expect(useStore.getState().masks).toEqual([]));
|
||||
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('99');
|
||||
expect(useStore.getState().masks).toEqual([]);
|
||||
expect(screen.getByText('已清空第 1-2 帧的 1 个遮罩,其中后端标注 1 个')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -373,8 +373,20 @@ const deleteAnnotationIfExists = async (annotationId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAnnotationsIfExist = async (annotationIds: string[]) => {
|
||||
const results = await Promise.allSettled(annotationIds.map((annotationId) => deleteAnnotationIfExists(annotationId)));
|
||||
const deleteAnnotationsIfExist = async (annotationIds: string[], projectId?: string) => {
|
||||
const uniqueAnnotationIds = Array.from(new Set(annotationIds.map(String)));
|
||||
let annotationIdsToDelete = uniqueAnnotationIds;
|
||||
if (projectId && uniqueAnnotationIds.length > 0) {
|
||||
try {
|
||||
const savedAnnotations = await getProjectAnnotations(projectId);
|
||||
const existingIds = new Set(savedAnnotations.map((annotation) => String(annotation.id)));
|
||||
annotationIdsToDelete = uniqueAnnotationIds.filter((annotationId) => existingIds.has(annotationId));
|
||||
} catch {
|
||||
annotationIdsToDelete = uniqueAnnotationIds;
|
||||
}
|
||||
}
|
||||
if (annotationIdsToDelete.length === 0) return;
|
||||
const results = await Promise.allSettled(annotationIdsToDelete.map((annotationId) => deleteAnnotationIfExists(annotationId)));
|
||||
const firstFailure = results.find((result): result is PromiseRejectedResult => (
|
||||
result.status === 'rejected' && !isNotFoundError(result.reason)
|
||||
));
|
||||
@@ -835,7 +847,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
setIsSaving(true);
|
||||
setStatusMessage(annotationIds.length > 0 ? `正在删除${messageScope}的已保存标注...` : `正在清空${messageScope}的本地遮罩...`);
|
||||
try {
|
||||
await deleteAnnotationsIfExist(annotationIds);
|
||||
await deleteAnnotationsIfExist(annotationIds, currentProject?.id);
|
||||
const afterDeleteMasks = useStore.getState().masks.filter((mask) => !maskIdSet.has(mask.id));
|
||||
setMasks(afterDeleteMasks);
|
||||
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !maskIdSet.has(id)));
|
||||
@@ -849,7 +861,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [setMasks, setSelectedMaskIds]);
|
||||
}, [currentProject?.id, setMasks, setSelectedMaskIds]);
|
||||
|
||||
const handleClearCurrentFrameMasks = useCallback(async () => {
|
||||
if (!currentFrame) return;
|
||||
@@ -885,6 +897,43 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
});
|
||||
}, [currentFrame, currentFrameNumber, executeClearCurrentMasks]);
|
||||
|
||||
const handleDeleteSelectedMasks = useCallback(async (requestedMaskIds?: string[]) => {
|
||||
if (!currentFrame) return;
|
||||
const latestMasks = useStore.getState().masks;
|
||||
const selectedIdSet = new Set(requestedMaskIds && requestedMaskIds.length > 0
|
||||
? requestedMaskIds
|
||||
: useStore.getState().selectedMaskIds);
|
||||
const selectedFrameMasks = latestMasks.filter((mask) => (
|
||||
String(mask.frameId) === String(currentFrame.id)
|
||||
&& selectedIdSet.has(mask.id)
|
||||
));
|
||||
if (selectedFrameMasks.length === 0) {
|
||||
setStatusMessage('请先选择要删除的遮罩');
|
||||
return;
|
||||
}
|
||||
const currentMaskIds = selectedFrameMasks.map((mask) => mask.id);
|
||||
const scopeLabel = `第 ${currentFrameNumber} 帧选中 mask`;
|
||||
const propagatedMaskIds = Array.from(expandedPropagationDeletionMaskIds(currentMaskIds, latestMasks));
|
||||
const propagatedOutsideCurrentCount = latestMasks.filter((mask) => (
|
||||
propagatedMaskIds.includes(mask.id)
|
||||
&& String(mask.frameId) !== String(currentFrame.id)
|
||||
)).length;
|
||||
|
||||
if (propagatedOutsideCurrentCount === 0) {
|
||||
await executeClearCurrentMasks(currentMaskIds, scopeLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingCurrentClearConfirm({
|
||||
currentFrameNumber,
|
||||
scopeLabel,
|
||||
currentMaskIds,
|
||||
propagatedMaskIds,
|
||||
currentMaskCount: currentMaskIds.length,
|
||||
propagatedMaskCount: propagatedMaskIds.length,
|
||||
});
|
||||
}, [currentFrame, currentFrameNumber, executeClearCurrentMasks]);
|
||||
|
||||
const executeClearFrameRange = useCallback(async (request: ClearRangeConfirmState) => {
|
||||
const frameIdsToClear = new Set(request.frameIdsToClear);
|
||||
setIsSaving(true);
|
||||
@@ -892,7 +941,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
? `正在删除第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的已保存标注...`
|
||||
: `正在清空第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的本地遮罩...`);
|
||||
try {
|
||||
await deleteAnnotationsIfExist(request.annotationIds);
|
||||
await deleteAnnotationsIfExist(request.annotationIds, currentProject?.id);
|
||||
const latestMasks = useStore.getState().masks;
|
||||
const clearedMaskIds = new Set(
|
||||
latestMasks
|
||||
@@ -916,7 +965,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [setMasks, setSelectedMaskIds]);
|
||||
}, [currentProject?.id, setMasks, setSelectedMaskIds]);
|
||||
|
||||
const handleClearFrameRangeMasks = useCallback(async () => {
|
||||
if (rangeSelectionMode !== 'clear') {
|
||||
@@ -972,14 +1021,14 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
const handleDeleteMaskAnnotations = useCallback(async (annotationIds: string[]) => {
|
||||
if (annotationIds.length === 0) return;
|
||||
try {
|
||||
await deleteAnnotationsIfExist(annotationIds);
|
||||
await deleteAnnotationsIfExist(annotationIds, currentProject?.id);
|
||||
setStatusMessage(`已删除 ${annotationIds.length} 个标注`);
|
||||
} catch (err) {
|
||||
console.error('Delete annotations failed:', err);
|
||||
setStatusMessage('删除标注失败,请检查后端服务');
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
}, [currentProject?.id]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
@@ -1977,6 +2026,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
setActiveTool={setActiveTool}
|
||||
onTriggerAI={onNavigateToAI}
|
||||
onImportGtMask={() => gtMaskInputRef.current?.click()}
|
||||
onDeleteMasks={handleDeleteSelectedMasks}
|
||||
onClearMasks={handleClearCurrentFrameMasks}
|
||||
canImportGtMask={Boolean(currentProject?.id && currentFrame?.id) && !isSaving && !isExporting && !isPropagating}
|
||||
isImportingGtMask={isImportingGt}
|
||||
@@ -1987,6 +2037,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
<CanvasArea
|
||||
activeTool={activeTool}
|
||||
frame={currentFrame}
|
||||
onRequestDeleteMasks={(maskIds) => void handleDeleteSelectedMasks(maskIds)}
|
||||
onDeleteMaskAnnotations={handleDeleteMaskAnnotations}
|
||||
/>
|
||||
</div>
|
||||
@@ -2015,6 +2066,17 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPendingCurrentClearConfirm(null);
|
||||
void handleClearFrameRangeMasks();
|
||||
}}
|
||||
className="rounded border border-amber-400/30 bg-amber-500/10 px-3 py-2 text-xs font-semibold text-amber-100 hover:bg-amber-500/20 disabled:opacity-60"
|
||||
disabled={isSaving}
|
||||
>
|
||||
按帧范围选择
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void executeClearCurrentMasks(pendingCurrentClearConfirm.currentMaskIds, pendingCurrentClearConfirm.scopeLabel)}
|
||||
@@ -2029,7 +2091,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
className="rounded bg-red-500 px-3 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:opacity-60"
|
||||
disabled={isSaving}
|
||||
>
|
||||
清空传播所有帧
|
||||
清空所有传播帧
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user