增加清空传播帧人工帧确认
- DEL 和清空遮罩在清空所有传播帧时检测人工/AI 标注帧并二次确认 - 按帧范围清空传播链时检测范围内人工/AI 标注帧,支持选择否后整帧保留 - 保留人工帧时只清其它自动传播帧,避免人工帧被局部掏空 - 补充清空所有传播帧和范围清空的人工帧保留回归测试 - 更新项目指南、实现地图、前端审计、需求冻结、设计冻结和测试计划文档
This commit is contained in:
@@ -679,6 +679,8 @@ describe('VideoWorkspace', () => {
|
||||
fireEvent.click(screen.getByTitle('清空遮罩'));
|
||||
expect(screen.getByText('选择清空范围')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空所有传播帧' }));
|
||||
expect(screen.getByText('是否删除人工/AI 标注帧')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '是,删除人工帧' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('1'));
|
||||
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10');
|
||||
@@ -686,6 +688,53 @@ describe('VideoWorkspace', () => {
|
||||
expect(useStore.getState().selectedMaskIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('can keep manual frames when clearing all propagated masks', 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: '清空所有传播帧' }));
|
||||
expect(screen.getByText('是否删除人工/AI 标注帧')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '否,保留人工帧' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10'));
|
||||
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('1');
|
||||
expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1']);
|
||||
});
|
||||
|
||||
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 },
|
||||
@@ -926,6 +975,71 @@ describe('VideoWorkspace', () => {
|
||||
expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-20']);
|
||||
});
|
||||
|
||||
it('can keep frames with manual masks when clearing a propagated frame range', 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: '2' } });
|
||||
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(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('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 },
|
||||
|
||||
@@ -60,6 +60,13 @@ type ClearPropagationRangeConfirmState = {
|
||||
rangeStartIndex: number;
|
||||
rangeEndIndex: number;
|
||||
};
|
||||
type ClearManualFrameConfirmState = {
|
||||
allMaskIds: string[];
|
||||
autoOnlyMaskIds: string[];
|
||||
manualFrameNumbers: number[];
|
||||
messageScope: string;
|
||||
resetRangeAfterClear: boolean;
|
||||
};
|
||||
type BooleanRangeConfirmState = {
|
||||
request: BooleanFrameRangeRequest;
|
||||
targetFrameIds: string[];
|
||||
@@ -488,6 +495,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
const [pendingCurrentClearConfirm, setPendingCurrentClearConfirm] = useState<CurrentClearConfirmState | null>(null);
|
||||
const [pendingClearPropagationRangeRequest, setPendingClearPropagationRangeRequest] = useState<ClearPropagationRangeRequestState | null>(null);
|
||||
const [pendingClearPropagationRangeConfirm, setPendingClearPropagationRangeConfirm] = useState<ClearPropagationRangeConfirmState | null>(null);
|
||||
const [pendingClearManualFrameConfirm, setPendingClearManualFrameConfirm] = useState<ClearManualFrameConfirmState | null>(null);
|
||||
const [pendingBooleanRangeRequest, setPendingBooleanRangeRequest] = useState<BooleanFrameRangeRequest | null>(null);
|
||||
const [pendingBooleanRangeConfirm, setPendingBooleanRangeConfirm] = useState<BooleanRangeConfirmState | null>(null);
|
||||
const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false);
|
||||
@@ -819,6 +827,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
setPendingCurrentClearConfirm(null);
|
||||
setPendingClearPropagationRangeRequest(null);
|
||||
setPendingClearPropagationRangeConfirm(null);
|
||||
setPendingClearManualFrameConfirm(null);
|
||||
return;
|
||||
}
|
||||
const annotationIds = Array.from(new Set(
|
||||
@@ -840,6 +849,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
setPendingCurrentClearConfirm(null);
|
||||
setPendingClearPropagationRangeRequest(null);
|
||||
setPendingClearPropagationRangeConfirm(null);
|
||||
setPendingClearManualFrameConfirm(null);
|
||||
} catch (err) {
|
||||
console.error('Delete annotations failed:', err);
|
||||
setStatusMessage('删除失败,请检查后端服务');
|
||||
@@ -919,6 +929,76 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
});
|
||||
}, [currentFrame, currentFrameNumber, executeClearCurrentMasks]);
|
||||
|
||||
const resetClearPropagationRangeSelection = useCallback(() => {
|
||||
setPendingClearPropagationRangeConfirm(null);
|
||||
setPendingClearPropagationRangeRequest(null);
|
||||
setIsPropagationRangeSelecting(false);
|
||||
setRangeSelectionMode((currentMode) => (currentMode === 'clear' ? null : currentMode));
|
||||
setHasExplicitPropagationRange(false);
|
||||
}, []);
|
||||
|
||||
const requestClearMasksWithManualFrameConfirm = useCallback((
|
||||
maskIdsToClear: string[],
|
||||
messageScope: string,
|
||||
options?: { 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 manualFrameIds = new Set(
|
||||
latestMasks
|
||||
.filter((mask) => targetFrameIds.has(String(mask.frameId)) && !isPropagatedMask(mask))
|
||||
.map((mask) => String(mask.frameId)),
|
||||
);
|
||||
|
||||
if (manualFrameIds.size === 0) {
|
||||
void executeClearCurrentMasks(maskIdsToClear, messageScope).then(() => {
|
||||
if (options?.resetRangeAfterClear) resetClearPropagationRangeSelection();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const manualFrameNumbers = Array.from(manualFrameIds)
|
||||
.map((frameId) => frameNumberById.get(frameId))
|
||||
.filter((frameNumber): frameNumber is number => Boolean(frameNumber))
|
||||
.sort((a, b) => a - b);
|
||||
const autoOnlyMaskIds = targetMasks
|
||||
.filter((mask) => !manualFrameIds.has(String(mask.frameId)))
|
||||
.map((mask) => mask.id);
|
||||
|
||||
setPendingCurrentClearConfirm(null);
|
||||
setPendingClearManualFrameConfirm({
|
||||
allMaskIds: maskIdsToClear,
|
||||
autoOnlyMaskIds,
|
||||
manualFrameNumbers,
|
||||
messageScope,
|
||||
resetRangeAfterClear: Boolean(options?.resetRangeAfterClear),
|
||||
});
|
||||
}, [executeClearCurrentMasks, frameNumberById, resetClearPropagationRangeSelection]);
|
||||
|
||||
const handleResolveClearManualFrameConfirm = useCallback(async (includeManualFrames: boolean) => {
|
||||
if (!pendingClearManualFrameConfirm) return;
|
||||
const targetMaskIds = includeManualFrames
|
||||
? pendingClearManualFrameConfirm.allMaskIds
|
||||
: pendingClearManualFrameConfirm.autoOnlyMaskIds;
|
||||
|
||||
if (targetMaskIds.length === 0) {
|
||||
setPendingClearManualFrameConfirm(null);
|
||||
if (pendingClearManualFrameConfirm.resetRangeAfterClear) resetClearPropagationRangeSelection();
|
||||
setStatusMessage('已保留人工/AI 标注帧,没有可清空的自动传播遮罩');
|
||||
return;
|
||||
}
|
||||
|
||||
await executeClearCurrentMasks(
|
||||
targetMaskIds,
|
||||
includeManualFrames
|
||||
? pendingClearManualFrameConfirm.messageScope
|
||||
: `${pendingClearManualFrameConfirm.messageScope}中的自动传播帧`,
|
||||
);
|
||||
if (pendingClearManualFrameConfirm.resetRangeAfterClear) resetClearPropagationRangeSelection();
|
||||
}, [executeClearCurrentMasks, pendingClearManualFrameConfirm, resetClearPropagationRangeSelection]);
|
||||
|
||||
const handleStartClearPropagationRange = useCallback((request: CurrentClearConfirmState) => {
|
||||
const latestMasks = useStore.getState().masks;
|
||||
const propagatedIdSet = new Set(request.propagatedMaskIds);
|
||||
@@ -984,16 +1064,13 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
}, [frames, pendingClearPropagationRangeRequest, propagationEndFrame, propagationStartFrame, totalFrames]);
|
||||
|
||||
const executeClearPropagationFrameRange = useCallback(async (confirmState: ClearPropagationRangeConfirmState) => {
|
||||
await executeClearCurrentMasks(
|
||||
requestClearMasksWithManualFrameConfirm(
|
||||
confirmState.targetMaskIds,
|
||||
`第 ${confirmState.rangeStartIndex + 1}-${confirmState.rangeEndIndex + 1} 帧传播链`,
|
||||
{ resetRangeAfterClear: true },
|
||||
);
|
||||
setPendingClearPropagationRangeConfirm(null);
|
||||
setPendingClearPropagationRangeRequest(null);
|
||||
setIsPropagationRangeSelecting(false);
|
||||
setRangeSelectionMode(null);
|
||||
setHasExplicitPropagationRange(false);
|
||||
}, [executeClearCurrentMasks]);
|
||||
}, [requestClearMasksWithManualFrameConfirm]);
|
||||
|
||||
const handleBooleanFrameRangeRequest = useCallback((request: BooleanFrameRangeRequest) => {
|
||||
const candidateFrameNumbers = request.candidateFrameIds
|
||||
@@ -2172,7 +2249,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void executeClearCurrentMasks(pendingCurrentClearConfirm.propagatedMaskIds, '当前帧及传播链')}
|
||||
onClick={() => requestClearMasksWithManualFrameConfirm(pendingCurrentClearConfirm.propagatedMaskIds, '当前帧及传播链')}
|
||||
className="rounded bg-red-500 px-2 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:opacity-60"
|
||||
disabled={isSaving}
|
||||
>
|
||||
@@ -2183,6 +2260,47 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingClearManualFrameConfirm && (
|
||||
<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-amber-400/25 bg-[#151515] p-5 shadow-2xl">
|
||||
<h2 className="text-lg font-semibold text-white">是否删除人工/AI 标注帧</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-300">
|
||||
本次清空范围包含第 {pendingClearManualFrameConfirm.manualFrameNumbers.join('、') || '-'} 帧等人工/AI 标注帧。
|
||||
是否同时删除这些帧中的遮罩?
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-amber-100/70">
|
||||
选择保留时,这些人工/AI 标注帧会整帧保留,只清空其它自动传播帧。
|
||||
</p>
|
||||
<div className="mt-5 grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingClearManualFrameConfirm(null)}
|
||||
disabled={isSaving}
|
||||
className="rounded border border-white/10 px-2 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleResolveClearManualFrameConfirm(false)}
|
||||
disabled={isSaving}
|
||||
className="rounded border border-amber-400/30 bg-amber-500/10 px-2 py-2 text-xs font-semibold text-amber-100 hover:bg-amber-500/20 disabled:opacity-60"
|
||||
>
|
||||
否,保留人工帧
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleResolveClearManualFrameConfirm(true)}
|
||||
disabled={isSaving}
|
||||
className="rounded bg-red-500 px-2 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:cursor-wait disabled:opacity-50"
|
||||
>
|
||||
是,删除人工帧
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingClearPropagationRangeConfirm && (
|
||||
<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