修正清空范围人工帧确认

- 按帧范围清空时检查完整用户选择帧段,确保人工/AI 标注帧会触发二次确认
- 选择删除人工帧时删除这些帧中的全部 mask,选择保留时整帧保留
- 补充人工-only 帧触发弹窗、保留人工帧和删除人工帧的回归测试
- 更新需求冻结和设计冻结文档中的人工帧清空语义
This commit is contained in:
2026-05-04 01:38:18 +08:00
parent a680510db8
commit 3dc6c3402e
4 changed files with 130 additions and 11 deletions

View File

@@ -1035,11 +1035,122 @@ describe('VideoWorkspace', () => {
expect(screen.getByText('是否删除人工/AI 标注帧')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '否,保留人工帧' }));
await waitFor(() => expect(screen.getByText('已保留人工/AI 标注帧,没有可清空的自动传播遮罩')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('已保留人工/AI 标注帧,本次没有其它可清空的自动传播遮罩')).toBeInTheDocument());
expect(apiMock.deleteAnnotation).not.toHaveBeenCalled();
expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10', 'annotation-11', 'annotation-20']);
});
it('asks about manual-only frames inside the selected clear range before clearing propagated masks', 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 },
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockResolvedValue(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
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-11',
annotationId: '11',
frameId: '11',
pathData: 'M 4 4 Z',
label: 'Manual edit',
color: '#f97316',
saved: true,
saveStatus: 'saved',
},
{
id: 'annotation-20',
annotationId: '20',
frameId: '12',
pathData: 'M 2 2 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: '按帧范围选择' }));
fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '2' } });
fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '3' } });
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
const confirmButtons = screen.getAllByRole('button', { name: '确认清空' });
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
expect(screen.getByText('是否删除人工/AI 标注帧')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '否,保留人工帧' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('20'));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('11');
expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-11']);
});
it('deletes all masks on manual frames when the user confirms manual-frame clearing', 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 },
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockResolvedValue(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
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' },
},
{ id: 'annotation-11', annotationId: '11', frameId: '11', pathData: 'M 4 4 Z', label: 'Manual edit', color: '#f97316', saved: true, saveStatus: 'saved' },
{
id: 'annotation-20',
annotationId: '20',
frameId: '12',
pathData: 'M 2 2 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: '按帧范围选择' }));
fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '2' } });
fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '3' } });
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
const confirmButtons = screen.getAllByRole('button', { name: '确认清空' });
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
fireEvent.click(screen.getByRole('button', { name: '是,删除人工帧' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10'));
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('11');
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('20');
expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1']);
});
it('auto-saves pending masks before exporting segmentation results', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },

View File

@@ -943,15 +943,16 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const requestClearMasksWithManualFrameConfirm = useCallback((
maskIdsToClear: string[],
messageScope: string,
options?: { resetRangeAfterClear?: boolean },
options?: { manualFrameScopeIds?: string[]; resetRangeAfterClear?: boolean },
) => {
const latestMasks = useStore.getState().masks;
const targetMaskIdSet = new Set(maskIdsToClear);
const targetMasks = latestMasks.filter((mask) => targetMaskIdSet.has(mask.id));
const targetFrameIds = new Set(targetMasks.map((mask) => String(mask.frameId)));
const manualScopeFrameIds = new Set((options?.manualFrameScopeIds || Array.from(targetFrameIds)).map(String));
const manualFrameIds = new Set(
latestMasks
.filter((mask) => targetFrameIds.has(String(mask.frameId)) && !isPropagatedMask(mask))
.filter((mask) => manualScopeFrameIds.has(String(mask.frameId)) && !isPropagatedMask(mask))
.map((mask) => String(mask.frameId)),
);
@@ -966,13 +967,17 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
.map((frameId) => frameNumberById.get(frameId))
.filter((frameNumber): frameNumber is number => Boolean(frameNumber))
.sort((a, b) => a - b);
const manualFrameMaskIds = latestMasks
.filter((mask) => manualFrameIds.has(String(mask.frameId)))
.map((mask) => mask.id);
const autoOnlyMaskIds = targetMasks
.filter((mask) => !manualFrameIds.has(String(mask.frameId)))
.map((mask) => mask.id);
const allMaskIds = Array.from(new Set([...autoOnlyMaskIds, ...manualFrameMaskIds]));
setPendingCurrentClearConfirm(null);
setPendingClearManualFrameConfirm({
allMaskIds: maskIdsToClear,
allMaskIds,
autoOnlyMaskIds,
manualFrameNumbers,
messageScope,
@@ -989,7 +994,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
if (targetMaskIds.length === 0) {
setPendingClearManualFrameConfirm(null);
if (pendingClearManualFrameConfirm.resetRangeAfterClear) resetClearPropagationRangeSelection();
setStatusMessage('已保留人工/AI 标注帧,没有可清空的自动传播遮罩');
setStatusMessage('已保留人工/AI 标注帧,本次没有其它可清空的自动传播遮罩');
return;
}
@@ -1067,13 +1072,16 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
}, [frames, pendingClearPropagationRangeRequest, propagationEndFrame, propagationStartFrame, totalFrames]);
const executeClearPropagationFrameRange = useCallback(async (confirmState: ClearPropagationRangeConfirmState) => {
const manualFrameScopeIds = frames
.slice(confirmState.rangeStartIndex, confirmState.rangeEndIndex + 1)
.map((frame) => String(frame.id));
requestClearMasksWithManualFrameConfirm(
confirmState.targetMaskIds,
`${confirmState.rangeStartIndex + 1}-${confirmState.rangeEndIndex + 1} 帧传播链`,
{ resetRangeAfterClear: true },
{ manualFrameScopeIds, resetRangeAfterClear: true },
);
setPendingClearPropagationRangeConfirm(null);
}, [requestClearMasksWithManualFrameConfirm]);
}, [frames, requestClearMasksWithManualFrameConfirm]);
const handleBooleanFrameRangeRequest = useCallback((request: BooleanFrameRangeRequest) => {
const candidateFrameNumbers = request.candidateFrameIds
@@ -2272,7 +2280,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
</p>
<p className="mt-2 text-xs leading-relaxed text-amber-100/70">
/AI
/AI
</p>
<div className="mt-5 grid grid-cols-3 gap-2">
<button