增加清空传播帧人工帧确认

- DEL 和清空遮罩在清空所有传播帧时检测人工/AI 标注帧并二次确认
- 按帧范围清空传播链时检测范围内人工/AI 标注帧,支持选择否后整帧保留
- 保留人工帧时只清其它自动传播帧,避免人工帧被局部掏空
- 补充清空所有传播帧和范围清空的人工帧保留回归测试
- 更新项目指南、实现地图、前端审计、需求冻结、设计冻结和测试计划文档
This commit is contained in:
2026-05-04 01:26:42 +08:00
parent 5603872821
commit 45839a2e4c
8 changed files with 247 additions and 15 deletions

View File

@@ -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 },

View File

@@ -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">