优化传播范围清空交互
- 将布尔操作的选择范围弹窗改为四个操作按钮同一行展示 - 将 DEL 和清空遮罩的传播链确认改为单层弹窗,提供取消、只清当前帧、按帧范围选择和清空所有传播帧 - 为传播链清空新增时间轴范围选择、顶栏确认清空和最终确认流程,只删除所选范围内同传播链遮罩 - 补充 Canvas 和 VideoWorkspace 回归测试,覆盖按钮布局和按帧范围清空传播链 - 更新前端审计、需求冻结、设计冻结、测试计划和项目指南文档
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user