修复清空所有传播帧人工帧确认
- 清空所有传播帧时按传播链 seed 到传播结果的完整帧段检查人工/AI 标注帧 - 从传播结果帧触发清空时也会弹出是否删除人工/AI 标注帧确认 - 用户确认删除人工帧时同步删除该跨度内人工/AI 帧的全部遮罩 - 补充 VideoWorkspace 回归测试覆盖中间 AI 标注帧场景 - 更新项目指南和设计冻结文档
This commit is contained in:
@@ -735,6 +735,73 @@ describe('VideoWorkspace', () => {
|
||||
expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1']);
|
||||
});
|
||||
|
||||
it('asks about AI-only frames across the propagation span when clearing all 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 },
|
||||
{ id: 13, project_id: 1, frame_index: 3, image_url: '/frame-3.jpg', width: 640, height: 360 },
|
||||
]);
|
||||
apiMock.deleteAnnotation.mockResolvedValue(undefined);
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(4));
|
||||
act(() => {
|
||||
useStore.setState({
|
||||
currentFrameIndex: 3,
|
||||
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: 'AI edit',
|
||||
color: '#f97316',
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { source: 'ai_segmentation' },
|
||||
},
|
||||
{
|
||||
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' },
|
||||
},
|
||||
{
|
||||
id: 'annotation-30',
|
||||
annotationId: '30',
|
||||
frameId: '13',
|
||||
pathData: 'M 3 3 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(screen.getByText('是否删除人工/AI 标注帧')).toBeInTheDocument();
|
||||
expect(screen.getByText(/第 1、2 帧/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '是,删除人工帧' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('30'));
|
||||
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('20');
|
||||
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('1');
|
||||
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('11');
|
||||
expect(useStore.getState().masks).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 },
|
||||
|
||||
@@ -1007,9 +1007,28 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
manualFrameNumbers,
|
||||
messageScope,
|
||||
resetRangeAfterClear: Boolean(options?.resetRangeAfterClear),
|
||||
});
|
||||
});
|
||||
}, [executeClearCurrentMasks, frameNumberById, resetClearPropagationRangeSelection]);
|
||||
|
||||
const propagationSpanFrameIdsForClearRequest = useCallback((request: CurrentClearConfirmState): string[] | undefined => {
|
||||
const latestMasks = useStore.getState().masks;
|
||||
const spanMaskIds = new Set([
|
||||
...request.currentMaskIds,
|
||||
...request.propagatedMaskIds,
|
||||
...Array.from(findPropagationChainMaskIds(request.currentMaskIds, latestMasks)),
|
||||
]);
|
||||
const spanFrameNumbers = latestMasks
|
||||
.filter((mask) => spanMaskIds.has(mask.id))
|
||||
.map((mask) => frameNumberById.get(String(mask.frameId)))
|
||||
.filter((frameNumber): frameNumber is number => Boolean(frameNumber));
|
||||
|
||||
if (spanFrameNumbers.length === 0) return undefined;
|
||||
const startIndex = Math.max(0, Math.min(...spanFrameNumbers) - 1);
|
||||
const endIndex = Math.min(frames.length - 1, Math.max(...spanFrameNumbers) - 1);
|
||||
if (endIndex < startIndex) return undefined;
|
||||
return frames.slice(startIndex, endIndex + 1).map((frame) => String(frame.id));
|
||||
}, [frameNumberById, frames]);
|
||||
|
||||
const handleResolveClearManualFrameConfirm = useCallback(async (includeManualFrames: boolean) => {
|
||||
if (!pendingClearManualFrameConfirm) return;
|
||||
const targetMaskIds = includeManualFrames
|
||||
@@ -2285,7 +2304,11 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => requestClearMasksWithManualFrameConfirm(pendingCurrentClearConfirm.propagatedMaskIds, '当前帧及传播链')}
|
||||
onClick={() => requestClearMasksWithManualFrameConfirm(
|
||||
pendingCurrentClearConfirm.propagatedMaskIds,
|
||||
'当前帧及传播链',
|
||||
{ manualFrameScopeIds: propagationSpanFrameIdsForClearRequest(pendingCurrentClearConfirm) },
|
||||
)}
|
||||
className="rounded bg-red-500 px-2 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:opacity-60"
|
||||
disabled={isSaving}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user