修复布尔范围操作重复执行

- 重新开始区域合并或重叠区域去除时取消旧的顶栏按帧范围请求
- 防止先选择按帧范围、再处理当前帧后还能继续执行旧范围合并
- 补充 VideoWorkspace 回归测试覆盖旧范围请求被取消的路径
- 更新项目指南、设计冻结和测试计划中的互斥交互说明
This commit is contained in:
2026-05-04 01:04:13 +08:00
parent f42bf42989
commit 5603872821
6 changed files with 106 additions and 3 deletions

View File

@@ -24,6 +24,7 @@ interface CanvasAreaProps {
totalFrames?: number;
onRequestDeleteMasks?: (maskIds: string[]) => void;
onRequestBooleanFrameRange?: (request: BooleanFrameRangeRequest) => void;
onBooleanOperationStart?: () => void;
onDeleteMaskAnnotations?: (annotationIds: string[]) => Promise<void> | void;
}
@@ -438,6 +439,7 @@ export function CanvasArea({
totalFrames,
onRequestDeleteMasks,
onRequestBooleanFrameRange,
onBooleanOperationStart,
onDeleteMaskAnnotations,
}: CanvasAreaProps) {
const containerRef = useRef<HTMLDivElement>(null);
@@ -1528,6 +1530,7 @@ export function CanvasArea({
const handleBooleanOperation = async () => {
const frameSelection = collectBooleanOperationFrameIds();
if (!frameSelection) return;
onBooleanOperationStart?.();
const { currentFrameId, targetFrameIds } = frameSelection;
if (targetFrameIds.size > 1) {
setPendingBooleanFrameIds(Array.from(targetFrameIds));

View File

@@ -1207,6 +1207,94 @@ describe('VideoWorkspace', () => {
}));
});
it('cancels a pending boolean frame range when the user starts a new current-frame merge', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ 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 },
]);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{
id: 'annotation-1',
annotationId: '1',
frameId: '10',
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: '10',
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: '11',
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: '11',
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' },
},
],
activeTool: 'area_merge',
});
});
apiMock.getProjectAnnotations.mockResolvedValue([
{ id: 1, frame_id: 10 },
{ id: 2, frame_id: 10 },
{ id: 10, frame_id: 11 },
{ id: 20, frame_id: 11 },
]);
const paths = await screen.findAllByTestId('konva-path');
fireEvent.click(paths[0]);
fireEvent.click(paths[1]);
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
fireEvent.click(screen.getByRole('button', { name: '按帧范围选择' }));
expect(screen.getByRole('button', { name: '确认区域合并' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
expect(screen.queryByRole('button', { name: '确认区域合并' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '只处理当前帧' }));
await waitFor(() => {
const ids = useStore.getState().masks.map((mask) => mask.id).sort();
expect(ids).toEqual(['annotation-1', 'annotation-10', 'annotation-20']);
});
expect(screen.queryByRole('button', { name: '确认区域合并' })).not.toBeInTheDocument();
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('2');
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('20');
});
it('exports only the current frame when current image scope is selected', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },

View File

@@ -1015,6 +1015,17 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setStatusMessage(`请选择${booleanOperationLabel(request.operation)}帧范围,再点击“确认${booleanOperationLabel(request.operation)}`);
}, [frameNumberById]);
const clearPendingBooleanRangeSelection = useCallback(() => {
setPendingBooleanRangeRequest(null);
setPendingBooleanRangeConfirm(null);
if (rangeSelectionMode === 'boolean') {
setRangeSelectionMode(null);
setIsPropagationRangeSelecting(false);
setHasExplicitPropagationRange(false);
setStatusMessage('');
}
}, [rangeSelectionMode]);
const handleConfirmBooleanFrameRangeOperation = useCallback(() => {
if (!pendingBooleanRangeRequest || frames.length === 0) return;
const clampRangeFrameNumber = (value: number) => {
@@ -2114,6 +2125,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
totalFrames={totalFrames}
onRequestDeleteMasks={(maskIds) => void handleDeleteSelectedMasks(maskIds)}
onRequestBooleanFrameRange={handleBooleanFrameRangeRequest}
onBooleanOperationStart={clearPendingBooleanRangeSelection}
onDeleteMaskAnnotations={handleDeleteMaskAnnotations}
/>
</div>