限定DEL人工帧删除范围

- 为清空/删除传播链确认区分 clear 与 delete 操作来源
- DEL/Delete 确认删除人工或 AI 帧时只删除选中或同传播链对应 mask
- 保持清空遮罩操作确认后可按原逻辑清空人工/AI 帧范围
- 调整人工/AI 帧确认弹窗文案,避免误导为 DEL 会整帧清空
- 补充 VideoWorkspace 回归测试,覆盖同一人工帧其它 mask 不被 DEL 删除
- 更新项目指南和设计冻结文档
This commit is contained in:
2026-05-04 02:36:10 +08:00
parent 0485ce4d92
commit 2f55ecfe6a
4 changed files with 105 additions and 8 deletions

View File

@@ -735,6 +735,65 @@ describe('VideoWorkspace', () => {
expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1']);
});
it('deletes only the selected manual mask when DEL includes manual frames', 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: 'Selected Seed',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
},
{
id: 'annotation-2',
annotationId: '2',
frameId: '10',
pathData: 'M 5 5 Z',
label: 'Other manual mask',
color: '#f97316',
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' },
},
],
selectedMaskIds: ['annotation-1'],
});
});
fireEvent.click(screen.getByRole('button', { name: 'DEL' }));
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');
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('2');
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['annotation-2']);
});
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 },

View File

@@ -43,7 +43,9 @@ type PropagationHistorySegment = {
label: string;
};
type RangeSelectionMode = 'propagation' | 'export' | 'boolean' | 'clear' | null;
type ClearOperationKind = 'clear' | 'delete';
type CurrentClearConfirmState = {
operation: ClearOperationKind;
currentFrameNumber: number;
scopeLabel: string;
currentMaskIds: string[];
@@ -64,6 +66,7 @@ type ClearManualFrameConfirmState = {
allMaskIds: string[];
autoOnlyMaskIds: string[];
manualFrameNumbers: number[];
manualFrameIncludeMode: 'all-frame-masks' | 'target-masks';
messageScope: string;
resetRangeAfterClear: boolean;
};
@@ -911,6 +914,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
}
setPendingCurrentClearConfirm({
operation: 'clear',
currentFrameNumber,
scopeLabel,
currentMaskIds,
@@ -948,6 +952,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
}
setPendingCurrentClearConfirm({
operation: 'delete',
currentFrameNumber,
scopeLabel,
currentMaskIds,
@@ -968,13 +973,20 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const requestClearMasksWithManualFrameConfirm = useCallback((
maskIdsToClear: string[],
messageScope: string,
options?: { manualFrameScopeIds?: string[]; resetRangeAfterClear?: boolean },
options?: {
manualFrameScopeIds?: string[];
manualFrameTargetMaskIds?: string[];
manualFrameIncludeMode?: 'all-frame-masks' | 'target-masks';
resetRangeAfterClear?: boolean;
},
) => {
const latestMasks = useStore.getState().masks;
const targetMaskIdSet = new Set(maskIdsToClear);
const manualTargetMaskIdSet = new Set([...(options?.manualFrameTargetMaskIds || []), ...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 includeAllManualFrameMasks = options?.manualFrameIncludeMode !== 'target-masks';
const manualFrameIds = new Set(
latestMasks
.filter((mask) => manualScopeFrameIds.has(String(mask.frameId)) && !isPropagatedMask(mask))
@@ -993,7 +1005,10 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
.filter((frameNumber): frameNumber is number => Boolean(frameNumber))
.sort((a, b) => a - b);
const manualFrameMaskIds = latestMasks
.filter((mask) => manualFrameIds.has(String(mask.frameId)))
.filter((mask) => (
manualFrameIds.has(String(mask.frameId))
&& (includeAllManualFrameMasks || manualTargetMaskIdSet.has(mask.id))
))
.map((mask) => mask.id);
const autoOnlyMaskIds = targetMasks
.filter((mask) => !manualFrameIds.has(String(mask.frameId)))
@@ -1005,6 +1020,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
allMaskIds,
autoOnlyMaskIds,
manualFrameNumbers,
manualFrameIncludeMode: includeAllManualFrameMasks ? 'all-frame-masks' : 'target-masks',
messageScope,
resetRangeAfterClear: Boolean(options?.resetRangeAfterClear),
});
@@ -1029,6 +1045,11 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
return frames.slice(startIndex, endIndex + 1).map((frame) => String(frame.id));
}, [frameNumberById, frames]);
const chainMaskIdsForClearRequest = useCallback((request: CurrentClearConfirmState): string[] => {
const latestMasks = useStore.getState().masks;
return Array.from(findPropagationChainMaskIds(request.currentMaskIds, latestMasks));
}, []);
const handleResolveClearManualFrameConfirm = useCallback(async (includeManualFrames: boolean) => {
if (!pendingClearManualFrameConfirm) return;
const targetMaskIds = includeManualFrames
@@ -1122,10 +1143,17 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
requestClearMasksWithManualFrameConfirm(
confirmState.targetMaskIds,
`${confirmState.rangeStartIndex + 1}-${confirmState.rangeEndIndex + 1} 帧传播链`,
{ manualFrameScopeIds, resetRangeAfterClear: true },
{
manualFrameScopeIds,
manualFrameTargetMaskIds: confirmState.request.operation === 'delete'
? chainMaskIdsForClearRequest(confirmState.request)
: undefined,
manualFrameIncludeMode: confirmState.request.operation === 'delete' ? 'target-masks' : 'all-frame-masks',
resetRangeAfterClear: true,
},
);
setPendingClearPropagationRangeConfirm(null);
}, [frames, requestClearMasksWithManualFrameConfirm]);
}, [chainMaskIdsForClearRequest, frames, requestClearMasksWithManualFrameConfirm]);
const handleBooleanFrameRangeRequest = useCallback((request: BooleanFrameRangeRequest) => {
const candidateFrameNumbers = request.candidateFrameIds
@@ -2307,7 +2335,15 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
onClick={() => requestClearMasksWithManualFrameConfirm(
pendingCurrentClearConfirm.propagatedMaskIds,
'当前帧及传播链',
{ manualFrameScopeIds: propagationSpanFrameIdsForClearRequest(pendingCurrentClearConfirm) },
{
manualFrameScopeIds: propagationSpanFrameIdsForClearRequest(pendingCurrentClearConfirm),
manualFrameTargetMaskIds: pendingCurrentClearConfirm.operation === 'delete'
? chainMaskIdsForClearRequest(pendingCurrentClearConfirm)
: undefined,
manualFrameIncludeMode: pendingCurrentClearConfirm.operation === 'delete'
? 'target-masks'
: 'all-frame-masks',
},
)}
className="rounded bg-red-500 px-2 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:opacity-60"
disabled={isSaving}
@@ -2328,7 +2364,9 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
</p>
<p className="mt-2 text-xs leading-relaxed text-amber-100/70">
/AI
{pendingClearManualFrameConfirm.manualFrameIncludeMode === 'target-masks'
? '选择删除时只会删除这些帧中本次选中或同传播链对应的遮罩;选择保留时,这些人工/AI 标注帧会整帧保留,只清空其它自动传播帧。'
: '选择删除时会删除这些帧中的全部遮罩;选择保留时,这些人工/AI 标注帧会整帧保留,只清空其它自动传播帧。'}
</p>
<div className="mt-5 grid grid-cols-3 gap-2">
<button