优化传播范围清空交互

- 将布尔操作的选择范围弹窗改为四个操作按钮同一行展示
- 将 DEL 和清空遮罩的传播链确认改为单层弹窗,提供取消、只清当前帧、按帧范围选择和清空所有传播帧
- 为传播链清空新增时间轴范围选择、顶栏确认清空和最终确认流程,只删除所选范围内同传播链遮罩
- 补充 Canvas 和 VideoWorkspace 回归测试,覆盖按钮布局和按帧范围清空传播链
- 更新前端审计、需求冻结、设计冻结、测试计划和项目指南文档
This commit is contained in:
2026-05-04 00:57:55 +08:00
parent 2fe4623cae
commit f42bf42989
10 changed files with 256 additions and 29 deletions

View File

@@ -42,7 +42,7 @@ type PropagationHistorySegment = {
colorIndex: number;
label: string;
};
type RangeSelectionMode = 'propagation' | 'export' | 'boolean' | null;
type RangeSelectionMode = 'propagation' | 'export' | 'boolean' | 'clear' | null;
type CurrentClearConfirmState = {
currentFrameNumber: number;
scopeLabel: string;
@@ -51,6 +51,15 @@ type CurrentClearConfirmState = {
currentMaskCount: number;
propagatedMaskCount: number;
};
type ClearPropagationRangeRequestState = CurrentClearConfirmState & {
candidateFrameIds: string[];
};
type ClearPropagationRangeConfirmState = {
request: ClearPropagationRangeRequestState;
targetMaskIds: string[];
rangeStartIndex: number;
rangeEndIndex: number;
};
type BooleanRangeConfirmState = {
request: BooleanFrameRangeRequest;
targetFrameIds: string[];
@@ -477,6 +486,8 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const [isPropagationRangeSelecting, setIsPropagationRangeSelecting] = useState(false);
const [rangeSelectionMode, setRangeSelectionMode] = useState<RangeSelectionMode>(null);
const [pendingCurrentClearConfirm, setPendingCurrentClearConfirm] = useState<CurrentClearConfirmState | null>(null);
const [pendingClearPropagationRangeRequest, setPendingClearPropagationRangeRequest] = useState<ClearPropagationRangeRequestState | null>(null);
const [pendingClearPropagationRangeConfirm, setPendingClearPropagationRangeConfirm] = useState<ClearPropagationRangeConfirmState | null>(null);
const [pendingBooleanRangeRequest, setPendingBooleanRangeRequest] = useState<BooleanFrameRangeRequest | null>(null);
const [pendingBooleanRangeConfirm, setPendingBooleanRangeConfirm] = useState<BooleanRangeConfirmState | null>(null);
const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false);
@@ -806,6 +817,8 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
if (masksToClear.length === 0) {
setStatusMessage('没有可清空的遮罩');
setPendingCurrentClearConfirm(null);
setPendingClearPropagationRangeRequest(null);
setPendingClearPropagationRangeConfirm(null);
return;
}
const annotationIds = Array.from(new Set(
@@ -825,6 +838,8 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
? `已清空${messageScope}${masksToClear.length} 个遮罩,其中后端标注 ${annotationIds.length}`
: `已清空${messageScope}${masksToClear.length} 个本地遮罩`);
setPendingCurrentClearConfirm(null);
setPendingClearPropagationRangeRequest(null);
setPendingClearPropagationRangeConfirm(null);
} catch (err) {
console.error('Delete annotations failed:', err);
setStatusMessage('删除失败,请检查后端服务');
@@ -904,6 +919,82 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
});
}, [currentFrame, currentFrameNumber, executeClearCurrentMasks]);
const handleStartClearPropagationRange = useCallback((request: CurrentClearConfirmState) => {
const latestMasks = useStore.getState().masks;
const propagatedIdSet = new Set(request.propagatedMaskIds);
const candidateFrameIds = Array.from(new Set(
latestMasks
.filter((mask) => propagatedIdSet.has(mask.id))
.map((mask) => String(mask.frameId)),
));
const candidateFrameNumbers = candidateFrameIds
.map((frameId) => frameNumberById.get(String(frameId)))
.filter((frameNumber): frameNumber is number => Boolean(frameNumber));
if (candidateFrameNumbers.length === 0) {
setStatusMessage('没有可按帧范围清空的传播帧');
return;
}
const startFrame = Math.min(...candidateFrameNumbers);
const endFrame = Math.max(...candidateFrameNumbers);
setPendingCurrentClearConfirm(null);
setPendingClearPropagationRangeRequest({ ...request, candidateFrameIds });
setPendingClearPropagationRangeConfirm(null);
setPropagationStartFrame(startFrame);
setPropagationEndFrame(endFrame);
setHasExplicitPropagationRange(true);
setIsPropagationRangeSelecting(true);
setRangeSelectionMode('clear');
setStatusMessage('请选择清空传播帧范围,再点击“确认清空”');
}, [frameNumberById]);
const handleConfirmClearPropagationFrameRange = useCallback(() => {
if (!pendingClearPropagationRangeRequest || frames.length === 0) return;
const clampRangeFrameNumber = (value: number) => {
if (totalFrames <= 0) return 1;
return Math.min(Math.max(value, 1), totalFrames);
};
const startFrameNumber = clampRangeFrameNumber(propagationStartFrame);
const endFrameNumber = clampRangeFrameNumber(propagationEndFrame);
const rangeStartIndex = Math.min(startFrameNumber, endFrameNumber) - 1;
const rangeEndIndex = Math.max(startFrameNumber, endFrameNumber) - 1;
const rangeFrameIds = new Set(
frames.slice(rangeStartIndex, rangeEndIndex + 1).map((frame) => String(frame.id)),
);
const candidateFrameIdSet = new Set(pendingClearPropagationRangeRequest.candidateFrameIds.map(String));
const latestMasks = useStore.getState().masks;
const targetMaskIds = pendingClearPropagationRangeRequest.propagatedMaskIds
.filter((maskId) => {
const mask = latestMasks.find((item) => item.id === maskId);
return Boolean(mask && rangeFrameIds.has(String(mask.frameId)) && candidateFrameIdSet.has(String(mask.frameId)));
});
if (targetMaskIds.length === 0) {
setStatusMessage(`${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧没有可清空的传播链遮罩`);
return;
}
setPendingClearPropagationRangeConfirm({
request: pendingClearPropagationRangeRequest,
targetMaskIds,
rangeStartIndex,
rangeEndIndex,
});
}, [frames, pendingClearPropagationRangeRequest, propagationEndFrame, propagationStartFrame, totalFrames]);
const executeClearPropagationFrameRange = useCallback(async (confirmState: ClearPropagationRangeConfirmState) => {
await executeClearCurrentMasks(
confirmState.targetMaskIds,
`${confirmState.rangeStartIndex + 1}-${confirmState.rangeEndIndex + 1} 帧传播链`,
);
setPendingClearPropagationRangeConfirm(null);
setPendingClearPropagationRangeRequest(null);
setIsPropagationRangeSelecting(false);
setRangeSelectionMode(null);
setHasExplicitPropagationRange(false);
}, [executeClearCurrentMasks]);
const handleBooleanFrameRangeRequest = useCallback((request: BooleanFrameRangeRequest) => {
const candidateFrameNumbers = request.candidateFrameIds
.map((frameId) => frameNumberById.get(String(frameId)))
@@ -1288,7 +1379,11 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setPropagationStartFrame(nextStart);
setPropagationEndFrame(nextEnd);
setHasExplicitPropagationRange(true);
const actionLabel = rangeSelectionMode === 'boolean' ? '布尔操作范围' : '自动传播范围';
const actionLabel = rangeSelectionMode === 'boolean'
? '布尔操作范围'
: rangeSelectionMode === 'clear'
? '清空传播帧范围'
: '自动传播范围';
setStatusMessage(`已选择${actionLabel}:第 ${Math.min(nextStart, nextEnd)}-${Math.max(nextStart, nextEnd)}`);
}, [clampFrameNumber, rangeSelectionMode]);
@@ -1503,6 +1598,8 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setRangeSelectionMode(null);
setPendingBooleanRangeRequest(null);
setPendingBooleanRangeConfirm(null);
setPendingClearPropagationRangeRequest(null);
setPendingClearPropagationRangeConfirm(null);
setHasExplicitPropagationRange(false);
setPropagationStartFrame(currentFrameNumber || 1);
setPropagationEndFrame(Math.min(Math.max(totalFrames, 1), (currentFrameNumber || 1) + 29));
@@ -1514,6 +1611,10 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setStatusMessage('已取消布尔操作范围选择');
return;
}
if (previousMode === 'clear') {
setStatusMessage('已取消清空传播帧范围选择');
return;
}
setStatusMessage('已取消自动传播范围选择');
};
@@ -1542,7 +1643,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
? Math.round((propagationProgress.completedSteps / Math.max(propagationProgress.totalSteps, 1)) * 100)
: 0;
const showPropagationControls = rangeSelectionMode === 'propagation' || isPropagating || Boolean(propagationTaskId);
const showFrameRangeControls = showPropagationControls || rangeSelectionMode === 'boolean';
const showFrameRangeControls = showPropagationControls || rangeSelectionMode === 'boolean' || rangeSelectionMode === 'clear';
const selectedRangeStartFrame = Math.min(propagationStartFrame, propagationEndFrame);
const selectedRangeEndFrame = Math.max(propagationStartFrame, propagationEndFrame);
const propagationBackwardFrameCount = Math.max(0, currentFrameNumber - selectedRangeStartFrame);
@@ -1772,6 +1873,16 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
{booleanOperationLabel(pendingBooleanRangeRequest.operation)}
</button>
)}
{rangeSelectionMode === 'clear' && pendingClearPropagationRangeRequest && (
<button
onClick={handleConfirmClearPropagationFrameRange}
disabled={frames.length === 0 || isSaving || isExporting || isImportingGt || isPropagating}
title="按当前起止帧清空传播链遮罩"
className="px-3 py-1.5 bg-red-500/10 hover:bg-red-500/20 border border-red-500/25 rounded-md text-xs transition-colors text-red-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
)}
{showPropagationControls && (
<button
onClick={handleAutoPropagate}
@@ -2013,20 +2124,20 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
{pendingCurrentClearConfirm && (
<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">
<div className="w-full max-w-lg rounded-lg border border-red-400/25 bg-[#151515] p-5 shadow-2xl">
<h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-2 text-sm leading-relaxed text-gray-300">
{pendingCurrentClearConfirm.scopeLabel}
</p>
<div className="mt-3 rounded-md border border-white/10 bg-white/[0.03] p-3 text-xs leading-relaxed text-gray-400">
{pendingCurrentClearConfirm.currentMaskCount} mask + {pendingCurrentClearConfirm.propagatedMaskCount} mask
</div>
<div className="mt-5 flex flex-wrap justify-end gap-2">
<div className="mt-5 grid grid-cols-4 gap-2">
<button
type="button"
onClick={() => setPendingCurrentClearConfirm(null)}
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
className="rounded border border-white/10 px-2 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
disabled={isSaving}
>
@@ -2034,15 +2145,23 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
<button
type="button"
onClick={() => void executeClearCurrentMasks(pendingCurrentClearConfirm.currentMaskIds, pendingCurrentClearConfirm.scopeLabel)}
className="rounded border border-red-400/30 bg-red-500/10 px-3 py-2 text-xs font-semibold text-red-100 hover:bg-red-500/20 disabled:opacity-60"
className="rounded border border-red-400/30 bg-red-500/10 px-2 py-2 text-xs font-semibold text-red-100 hover:bg-red-500/20 disabled:opacity-60"
disabled={isSaving}
>
</button>
<button
type="button"
onClick={() => handleStartClearPropagationRange(pendingCurrentClearConfirm)}
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"
disabled={isSaving}
>
</button>
<button
type="button"
onClick={() => void executeClearCurrentMasks(pendingCurrentClearConfirm.propagatedMaskIds, '当前帧及传播链')}
className="rounded bg-red-500 px-3 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:opacity-60"
className="rounded bg-red-500 px-2 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:opacity-60"
disabled={isSaving}
>
@@ -2052,6 +2171,39 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
</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">
<h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-2 text-sm leading-relaxed text-gray-300">
{pendingClearPropagationRangeConfirm.rangeStartIndex + 1}-{pendingClearPropagationRangeConfirm.rangeEndIndex + 1}
{pendingClearPropagationRangeConfirm.targetMaskIds.length}
</p>
<p className="mt-2 text-xs leading-relaxed text-red-100/70">
AI
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => setPendingClearPropagationRangeConfirm(null)}
disabled={isSaving}
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => void executeClearPropagationFrameRange(pendingClearPropagationRangeConfirm)}
disabled={isSaving}
className="rounded bg-red-500 px-3 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:cursor-wait disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)}
{pendingBooleanRangeConfirm && (
<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-emerald-400/25 bg-[#151515] p-5 shadow-2xl">