完善遮罩删除范围选择

- 删除/清空已保存标注前预检后端 annotation id,跳过本地陈旧 id,避免重复 DELETE 产生 404 控制台红字

- 左侧工具栏新增 DEL 删除选中遮罩入口,调整清空遮罩弹窗文案为“清空所有传播帧”,并加入按帧范围选择入口

- 区域合并和重叠区域去除在存在传播帧时弹出当前帧/所有传播帧选择,传播帧同步后保留原 lineage metadata

- 多 polygon 或分离区域组成的 mask 选中后显示全部顶点与插点手柄,同帧传播链分散 mask 点选时联动高亮

- 调整工具栏分组分隔线位置,只在清空遮罩下方保留 tool-group-separator 测试标记

- 更新 VideoWorkspace、CanvasArea、ToolsPalette 回归测试和相关项目文档
This commit is contained in:
2026-05-03 22:14:00 +08:00
parent 275be62db5
commit 5ae1d15336
12 changed files with 215 additions and 55 deletions

View File

@@ -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();

View File

@@ -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>
);
}

View File

@@ -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');
});

View File

@@ -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}

View File

@@ -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();
});

View File

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