修正清空范围人工帧确认
- 按帧范围清空时检查完整用户选择帧段,确保人工/AI 标注帧会触发二次确认 - 选择删除人工帧时删除这些帧中的全部 mask,选择保留时整帧保留 - 补充人工-only 帧触发弹窗、保留人工帧和删除人工帧的回归测试 - 更新需求冻结和设计冻结文档中的人工帧清空语义
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user