统一工作区清空遮罩入口
- 移除 Canvas 右下角旧清空遮罩和应用分类按钮,清空入口统一到左侧工具栏 - 清空遮罩优先作用于当前帧选中 mask,无选中时作用于当前帧全部 mask - 目标 mask 无传播链结果时直接清当前帧,有传播链结果时弹窗选择只清当前帧、清空传播所有帧或取消 - 保留布尔工具右下角合并/去除操作区,避免旧分类按钮误改整帧 - 更新 Canvas、工具栏、工作区测试,覆盖直接清空、传播链范围选择和取消路径 - 同步更新前端审计、需求冻结、设计冻结、测试计划和 AGENTS 说明
This commit is contained in:
@@ -1380,78 +1380,6 @@ describe('CanvasArea', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('applies the selected class to current-frame masks and linked propagation masks', () => {
|
||||
useStore.setState({
|
||||
activeTemplateId: '2',
|
||||
activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 },
|
||||
activeClassId: 'c1',
|
||||
masks: [
|
||||
{
|
||||
id: 'm1',
|
||||
frameId: 'frame-1',
|
||||
annotationId: '99',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: '旧标签',
|
||||
color: '#06b6d4',
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
frameId: 'frame-2',
|
||||
annotationId: '100',
|
||||
pathData: 'M 1 1 Z',
|
||||
label: '旧传播标签',
|
||||
color: '#06b6d4',
|
||||
metadata: { source_annotation_id: 99, source_mask_id: 'annotation-99' },
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
},
|
||||
{
|
||||
id: 'm3',
|
||||
frameId: 'frame-2',
|
||||
annotationId: '101',
|
||||
pathData: 'M 2 2 Z',
|
||||
label: '无关区域',
|
||||
color: '#ffffff',
|
||||
metadata: { source_annotation_id: 101 },
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<CanvasArea activeTool="move" frame={frame} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用分类' }));
|
||||
|
||||
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
|
||||
templateId: '2',
|
||||
classId: 'c1',
|
||||
className: '胆囊',
|
||||
classZIndex: 20,
|
||||
classMaskId: 1,
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
saveStatus: 'dirty',
|
||||
saved: false,
|
||||
}));
|
||||
expect(useStore.getState().masks[1]).toEqual(expect.objectContaining({
|
||||
classId: 'c1',
|
||||
className: '胆囊',
|
||||
classMaskId: 1,
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
saveStatus: 'dirty',
|
||||
saved: false,
|
||||
}));
|
||||
expect(useStore.getState().masks[2]).toEqual(expect.objectContaining({
|
||||
label: '无关区域',
|
||||
color: '#ffffff',
|
||||
saveStatus: 'saved',
|
||||
saved: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('renders unselected masks by semantic tree layer priority', () => {
|
||||
useStore.setState({
|
||||
selectedMaskIds: [],
|
||||
@@ -1481,18 +1409,17 @@ describe('CanvasArea', () => {
|
||||
expect(paths.map((path) => path.getAttribute('data-fill'))).toEqual(['#22c55e', '#ef4444']);
|
||||
});
|
||||
|
||||
it('delegates clear to the workspace handler so saved annotations can be deleted', () => {
|
||||
const onClearMasks = vi.fn();
|
||||
it('does not render duplicate bottom-right clear or class action buttons', () => {
|
||||
useStore.setState({
|
||||
activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 },
|
||||
masks: [
|
||||
{ id: 'm1', frameId: 'frame-1', pathData: 'M 0 0 Z', label: 'A', color: '#fff' },
|
||||
],
|
||||
});
|
||||
|
||||
render(<CanvasArea activeTool="move" frame={frame} onClearMasks={onClearMasks} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空遮罩' }));
|
||||
render(<CanvasArea activeTool="move" frame={frame} />);
|
||||
|
||||
expect(onClearMasks).toHaveBeenCalled();
|
||||
expect(useStore.getState().masks).toHaveLength(1);
|
||||
expect(screen.queryByRole('button', { name: '清空遮罩' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '应用分类' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { Frame, Mask } from '../store/useStore';
|
||||
interface CanvasAreaProps {
|
||||
activeTool: string;
|
||||
frame: Frame | null;
|
||||
onClearMasks?: () => void;
|
||||
onDeleteMaskAnnotations?: (annotationIds: string[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
@@ -417,7 +416,7 @@ function geometriesOverlap(first: MultiPolygon, second: MultiPolygon): boolean {
|
||||
return polygonClipping.intersection(first, second).length > 0;
|
||||
}
|
||||
|
||||
export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnotations }: CanvasAreaProps) {
|
||||
export function CanvasArea({ activeTool, frame, onDeleteMaskAnnotations }: CanvasAreaProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
|
||||
const [scale, setScale] = useState(1);
|
||||
@@ -448,7 +447,6 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
const masks = useStore((state) => state.masks);
|
||||
const addMask = useStore((state) => state.addMask);
|
||||
const updateMask = useStore((state) => state.updateMask);
|
||||
const clearMasks = useStore((state) => state.clearMasks);
|
||||
const setMasks = useStore((state) => state.setMasks);
|
||||
const setGlobalSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
|
||||
const maskPreviewOpacity = useStore((state) => state.maskPreviewOpacity);
|
||||
@@ -971,37 +969,6 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
}
|
||||
}, [activeClass, activeTemplateId, addMask, aiModel, frame?.height, frame?.id, frame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, masks, samCandidateMaskId, setMasks, updateMask]);
|
||||
|
||||
const handleApplyActiveClass = () => {
|
||||
if (!frame?.id || !activeClass) return;
|
||||
const seedIds = selectedMaskIds.length > 0
|
||||
? selectedMaskIds
|
||||
: frameMasks.map((mask) => mask.id);
|
||||
const targetIds = findPropagationChainMaskIds(seedIds, masks);
|
||||
setMasks(masks.map((mask) => {
|
||||
if (!targetIds.has(mask.id)) return mask;
|
||||
return {
|
||||
...mask,
|
||||
templateId: activeTemplateId || mask.templateId,
|
||||
classId: activeClass.id,
|
||||
className: activeClass.name,
|
||||
classZIndex: activeClass.zIndex,
|
||||
classMaskId: activeClass.maskId,
|
||||
label: activeClass.name,
|
||||
color: activeClass.color,
|
||||
saveStatus: mask.annotationId ? 'dirty' : 'draft',
|
||||
saved: Boolean(mask.annotationId) ? false : mask.saved,
|
||||
};
|
||||
}));
|
||||
};
|
||||
|
||||
const handleClearMasks = () => {
|
||||
if (onClearMasks) {
|
||||
onClearMasks();
|
||||
return;
|
||||
}
|
||||
clearMasks();
|
||||
};
|
||||
|
||||
const deleteMasksById = useCallback((maskIds: string[]) => {
|
||||
if (maskIds.length === 0) return;
|
||||
const idSet = expandedPropagationDeletionMaskIds(maskIds, masks);
|
||||
@@ -1772,36 +1739,20 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
<span>待更新: {dirtyMaskCount}</span>
|
||||
</div>
|
||||
|
||||
{frameMasks.length > 0 && (
|
||||
{frameMasks.length > 0 && isBooleanTool && (
|
||||
<div className="absolute bottom-4 right-4 flex gap-2">
|
||||
{isBooleanTool && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs bg-white/5 text-gray-300 border border-white/10 px-2.5 py-1.5 rounded">
|
||||
已选 {booleanSelectedMasks.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleBooleanOperation}
|
||||
disabled={booleanSelectedMasks.length < 2}
|
||||
className="text-xs bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 border border-emerald-500/20 px-3 py-1.5 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-emerald-500/10"
|
||||
>
|
||||
{effectiveTool === 'area_merge' ? '合并选中' : '从主区域去除'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{activeClass && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs bg-white/5 text-gray-300 border border-white/10 px-2.5 py-1.5 rounded">
|
||||
已选 {booleanSelectedMasks.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleApplyActiveClass}
|
||||
className="text-xs bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/20 px-3 py-1.5 rounded transition-colors"
|
||||
onClick={handleBooleanOperation}
|
||||
disabled={booleanSelectedMasks.length < 2}
|
||||
className="text-xs bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 border border-emerald-500/20 px-3 py-1.5 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-emerald-500/10"
|
||||
>
|
||||
应用分类
|
||||
{effectiveTool === 'area_merge' ? '合并选中' : '从主区域去除'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleClearMasks}
|
||||
className="text-xs bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 px-3 py-1.5 rounded transition-colors"
|
||||
>
|
||||
清空遮罩
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('ToolsPalette', () => {
|
||||
const onClearMasks = vi.fn();
|
||||
render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} onClearMasks={onClearMasks} />);
|
||||
|
||||
fireEvent.click(screen.getByTitle('清空遮罩(含传播帧)'));
|
||||
fireEvent.click(screen.getByTitle('清空遮罩'));
|
||||
|
||||
expect(onClearMasks).toHaveBeenCalled();
|
||||
});
|
||||
@@ -85,7 +85,7 @@ describe('ToolsPalette', () => {
|
||||
const circleButton = screen.getByTitle('创建圆 (O)');
|
||||
const brushButton = screen.getByTitle('画笔 (B)');
|
||||
const removeButton = screen.getByTitle('重叠区域去除 (-)');
|
||||
const clearButton = screen.getByTitle('清空遮罩(含传播帧)');
|
||||
const clearButton = screen.getByTitle('清空遮罩');
|
||||
const importButton = screen.getByTitle('导入 GT Mask');
|
||||
|
||||
expect(separators).toHaveLength(2);
|
||||
|
||||
@@ -104,7 +104,7 @@ export function ToolsPalette({
|
||||
<button
|
||||
onClick={onClearMasks}
|
||||
disabled={!onClearMasks}
|
||||
title="清空遮罩(含传播帧)"
|
||||
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} />
|
||||
|
||||
@@ -615,8 +615,9 @@ describe('VideoWorkspace', () => {
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空遮罩' }));
|
||||
fireEvent.click(screen.getByTitle('清空遮罩'));
|
||||
|
||||
expect(screen.queryByText('选择清空范围')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
|
||||
expect(useStore.getState().masks).toEqual([]);
|
||||
});
|
||||
@@ -659,7 +660,9 @@ describe('VideoWorkspace', () => {
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空遮罩' }));
|
||||
fireEvent.click(screen.getByTitle('清空遮罩'));
|
||||
expect(screen.getByText('选择清空范围')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空传播所有帧' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('1'));
|
||||
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10');
|
||||
@@ -667,6 +670,130 @@ describe('VideoWorkspace', () => {
|
||||
expect(useStore.getState().selectedMaskIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('can clear only the current frame when current masks have propagated 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.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' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle('清空遮罩'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '只清当前帧' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('1'));
|
||||
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('10');
|
||||
expect(useStore.getState().masks).toEqual([
|
||||
expect.objectContaining({ id: 'annotation-10' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears only selected current-frame masks when a selected mask has no propagated 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.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-2', annotationId: '2', frameId: '10', pathData: 'M 2 2 Z', label: 'Standalone', color: '#ff0000', 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-2'],
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle('清空遮罩'));
|
||||
|
||||
expect(screen.queryByText('选择清空范围')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('2'));
|
||||
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('1');
|
||||
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('10');
|
||||
expect(useStore.getState().masks).toEqual([
|
||||
expect.objectContaining({ id: 'annotation-1' }),
|
||||
expect.objectContaining({ id: 'annotation-10' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('can cancel current-frame propagated clear confirmation', 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 },
|
||||
]);
|
||||
|
||||
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' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle('清空遮罩'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||
|
||||
expect(apiMock.deleteAnnotation).not.toHaveBeenCalled();
|
||||
expect(useStore.getState().masks).toHaveLength(2);
|
||||
expect(screen.queryByText('选择清空范围')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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 },
|
||||
|
||||
@@ -52,6 +52,14 @@ type ClearRangeConfirmState = {
|
||||
rangeEndIndex: number;
|
||||
mode: ClearRangeMode;
|
||||
};
|
||||
type CurrentClearConfirmState = {
|
||||
currentFrameNumber: number;
|
||||
scopeLabel: string;
|
||||
currentMaskIds: string[];
|
||||
propagatedMaskIds: string[];
|
||||
currentMaskCount: number;
|
||||
propagatedMaskCount: number;
|
||||
};
|
||||
type GtUnknownPolicy = 'discard' | 'undefined';
|
||||
type ExportScope = 'all' | 'range' | 'current';
|
||||
type ExportPreviewPolygon = {
|
||||
@@ -489,6 +497,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
const [rangeSelectionMode, setRangeSelectionMode] = useState<RangeSelectionMode>(null);
|
||||
const [clearRangeMode, setClearRangeMode] = useState<ClearRangeMode>('all');
|
||||
const [pendingClearRangeConfirm, setPendingClearRangeConfirm] = useState<ClearRangeConfirmState | null>(null);
|
||||
const [pendingCurrentClearConfirm, setPendingCurrentClearConfirm] = useState<CurrentClearConfirmState | null>(null);
|
||||
const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false);
|
||||
const [propagationProgress, setPropagationProgress] = useState<PropagationProgress>(null);
|
||||
const [propagationTaskId, setPropagationTaskId] = useState<number | null>(null);
|
||||
@@ -818,11 +827,15 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
}
|
||||
}, [activeTemplateId, currentProject?.id, frameById, frames, hydrateSavedAnnotations, projectFrameIds]);
|
||||
|
||||
const handleClearCurrentFrameMasks = useCallback(async () => {
|
||||
if (!currentFrame) return;
|
||||
const frameMasks = masks.filter((mask) => mask.frameId === currentFrame.id);
|
||||
const maskIdsToClear = expandedPropagationDeletionMaskIds(frameMasks.map((mask) => mask.id), masks);
|
||||
const masksToClear = masks.filter((mask) => maskIdsToClear.has(mask.id));
|
||||
const executeClearCurrentMasks = useCallback(async (maskIdsToClear: string[], messageScope: string) => {
|
||||
const latestMasks = useStore.getState().masks;
|
||||
const maskIdSet = new Set(maskIdsToClear);
|
||||
const masksToClear = latestMasks.filter((mask) => maskIdSet.has(mask.id));
|
||||
if (masksToClear.length === 0) {
|
||||
setStatusMessage('没有可清空的遮罩');
|
||||
setPendingCurrentClearConfirm(null);
|
||||
return;
|
||||
}
|
||||
const annotationIds = Array.from(new Set(
|
||||
masksToClear
|
||||
.map((mask) => mask.annotationId)
|
||||
@@ -830,21 +843,57 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
));
|
||||
|
||||
setIsSaving(true);
|
||||
setStatusMessage(annotationIds.length > 0 ? '正在删除已保存标注和关联传播帧...' : '正在清空本帧遮罩和关联传播帧...');
|
||||
setStatusMessage(annotationIds.length > 0 ? `正在删除${messageScope}的已保存标注...` : `正在清空${messageScope}的本地遮罩...`);
|
||||
try {
|
||||
await deleteAnnotationsIfExist(annotationIds);
|
||||
setMasks(masks.filter((mask) => !maskIdsToClear.has(mask.id)));
|
||||
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !maskIdsToClear.has(id)));
|
||||
const afterDeleteMasks = useStore.getState().masks.filter((mask) => !maskIdSet.has(mask.id));
|
||||
setMasks(afterDeleteMasks);
|
||||
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !maskIdSet.has(id)));
|
||||
setStatusMessage(annotationIds.length > 0
|
||||
? `已删除 ${annotationIds.length} 个后端标注,已同步清空关联传播帧`
|
||||
: '已清空本帧未保存遮罩和关联传播帧');
|
||||
? `已清空${messageScope}的 ${masksToClear.length} 个遮罩,其中后端标注 ${annotationIds.length} 个`
|
||||
: `已清空${messageScope}的 ${masksToClear.length} 个本地遮罩`);
|
||||
setPendingCurrentClearConfirm(null);
|
||||
} catch (err) {
|
||||
console.error('Delete annotations failed:', err);
|
||||
setStatusMessage('删除失败,请检查后端服务');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [currentFrame, masks, setMasks, setSelectedMaskIds]);
|
||||
}, [setMasks, setSelectedMaskIds]);
|
||||
|
||||
const handleClearCurrentFrameMasks = useCallback(async () => {
|
||||
if (!currentFrame) return;
|
||||
const latestMasks = useStore.getState().masks;
|
||||
const frameMasks = latestMasks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
|
||||
if (frameMasks.length === 0) {
|
||||
setStatusMessage('当前帧没有可清空的遮罩');
|
||||
return;
|
||||
}
|
||||
const selectedIdSet = new Set(useStore.getState().selectedMaskIds);
|
||||
const selectedFrameMasks = frameMasks.filter((mask) => selectedIdSet.has(mask.id));
|
||||
const targetFrameMasks = selectedFrameMasks.length > 0 ? selectedFrameMasks : frameMasks;
|
||||
const scopeLabel = selectedFrameMasks.length > 0 ? `第 ${currentFrameNumber} 帧选中 mask` : `第 ${currentFrameNumber} 帧`;
|
||||
const currentMaskIds = targetFrameMasks.map((mask) => mask.id);
|
||||
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);
|
||||
@@ -1948,7 +1997,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
<CanvasArea
|
||||
activeTool={activeTool}
|
||||
frame={currentFrame}
|
||||
onClearMasks={handleClearCurrentFrameMasks}
|
||||
onDeleteMaskAnnotations={handleDeleteMaskAnnotations}
|
||||
/>
|
||||
</div>
|
||||
@@ -1957,6 +2005,47 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
<OntologyInspector />
|
||||
</div>
|
||||
|
||||
{pendingCurrentClearConfirm && (
|
||||
<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-red-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">
|
||||
{pendingCurrentClearConfirm.scopeLabel}存在自动传播结果。
|
||||
请选择只清空当前帧,还是同时清空同一传播链上的所有帧。
|
||||
</p>
|
||||
<div className="mt-3 rounded-md border border-white/10 bg-white/[0.03] p-3 text-xs leading-relaxed text-gray-400">
|
||||
当前范围:{pendingCurrentClearConfirm.currentMaskCount} 个 mask;当前范围 + 传播链:{pendingCurrentClearConfirm.propagatedMaskCount} 个 mask。
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingCurrentClearConfirm(null)}
|
||||
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
|
||||
disabled={isSaving}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void executeClearCurrentMasks(pendingCurrentClearConfirm.currentMaskIds, pendingCurrentClearConfirm.scopeLabel)}
|
||||
className="rounded border border-red-400/30 bg-red-500/10 px-3 py-2 text-xs font-semibold text-red-100 hover:bg-red-500/20 disabled:opacity-60"
|
||||
disabled={isSaving}
|
||||
>
|
||||
只清当前帧
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void executeClearCurrentMasks(pendingCurrentClearConfirm.propagatedMaskIds, '当前帧及传播链')}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingClearRangeConfirm && (
|
||||
<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-red-400/25 bg-[#151515] p-5 shadow-2xl">
|
||||
|
||||
Reference in New Issue
Block a user