优化工作区传播和清空交互

- 手工多边形、矩形和圆在未选语义分类时默认归入 maskid:0 的待分类类别。

- 后端自动传播按来源 annotation/mask/seed key 区分同类多实例,避免多个同类型 mask 传播时互相清理。

- 左侧工具栏在橡皮擦下方新增彩色 AI 自动传播入口,传播权重和范围控件只在进入传播后显示。

- 移除顶栏重复的清空片段遮罩入口,并取消当前清空/DEL 弹窗中的按帧范围清空路径。

- Canvas 右下角显示当前帧:XX/XXX,并调整布尔操作浮层位置避免重叠。

- 更新前端和后端回归测试,覆盖待分类默认、工具栏自动传播和同类多实例传播。

- 同步 AGENTS 与 doc 文档,说明新的工具栏、清空和传播行为。
This commit is contained in:
2026-05-04 00:26:11 +08:00
parent 061f4ed25b
commit 093ef6c63a
14 changed files with 307 additions and 534 deletions

View File

@@ -42,16 +42,7 @@ type PropagationHistorySegment = {
colorIndex: number;
label: string;
};
type RangeSelectionMode = 'propagation' | 'clear' | 'export' | 'boolean' | null;
type ClearRangeMode = 'all' | 'propagated_only';
type ClearRangeConfirmState = {
frameIdsToClear: string[];
annotationIds: string[];
maskCount: number;
rangeStartIndex: number;
rangeEndIndex: number;
mode: ClearRangeMode;
};
type RangeSelectionMode = 'propagation' | 'export' | 'boolean' | null;
type CurrentClearConfirmState = {
currentFrameNumber: number;
scopeLabel: string;
@@ -197,38 +188,6 @@ const normalizeMaskAgainstTemplates = (mask: Mask, templates: Template[]): Mask
};
};
const trimPropagationHistoryByClearedRange = (
segments: PropagationHistorySegment[],
clearStartFrame: number,
clearEndFrame: number,
): PropagationHistorySegment[] => {
const start = Math.min(clearStartFrame, clearEndFrame);
const end = Math.max(clearStartFrame, clearEndFrame);
return segments.flatMap((segment) => {
const segmentStart = Math.min(segment.startFrame, segment.endFrame);
const segmentEnd = Math.max(segment.startFrame, segment.endFrame);
if (segmentEnd < start || segmentStart > end) return [segment];
const next: PropagationHistorySegment[] = [];
if (segmentStart < start) {
next.push({
...segment,
id: `${segment.id}-before-${start}`,
startFrame: segmentStart,
endFrame: start - 1,
});
}
if (segmentEnd > end) {
next.push({
...segment,
id: `${segment.id}-after-${end}`,
startFrame: end + 1,
endFrame: segmentEnd,
});
}
return next;
});
};
const prunePropagationHistoryByActiveFrames = (
segments: PropagationHistorySegment[],
activeFrameNumbers: Set<number>,
@@ -517,8 +476,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const [propagationEndFrame, setPropagationEndFrame] = useState(1);
const [isPropagationRangeSelecting, setIsPropagationRangeSelecting] = useState(false);
const [rangeSelectionMode, setRangeSelectionMode] = useState<RangeSelectionMode>(null);
const [clearRangeMode, setClearRangeMode] = useState<ClearRangeMode>('all');
const [pendingClearRangeConfirm, setPendingClearRangeConfirm] = useState<ClearRangeConfirmState | null>(null);
const [pendingCurrentClearConfirm, setPendingCurrentClearConfirm] = useState<CurrentClearConfirmState | null>(null);
const [pendingBooleanRangeRequest, setPendingBooleanRangeRequest] = useState<BooleanFrameRangeRequest | null>(null);
const [pendingBooleanRangeConfirm, setPendingBooleanRangeConfirm] = useState<BooleanRangeConfirmState | null>(null);
@@ -947,90 +904,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
});
}, [currentFrame, currentFrameNumber, executeClearCurrentMasks]);
const executeClearFrameRange = useCallback(async (request: ClearRangeConfirmState) => {
const frameIdsToClear = new Set(request.frameIdsToClear);
setIsSaving(true);
setStatusMessage(request.annotationIds.length > 0
? `正在删除第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的已保存标注...`
: `正在清空第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的本地遮罩...`);
try {
await deleteAnnotationsIfExist(request.annotationIds, currentProject?.id);
const latestMasks = useStore.getState().masks;
const clearedMaskIds = new Set(
latestMasks
.filter((mask) => frameIdsToClear.has(String(mask.frameId)))
.filter((mask) => request.mode === 'all' || isPropagatedMask(mask))
.map((mask) => mask.id),
);
setMasks(latestMasks.filter((mask) => !clearedMaskIds.has(mask.id)));
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !clearedMaskIds.has(id)));
setPropagationHistory((previous) => trimPropagationHistoryByClearedRange(previous, request.rangeStartIndex + 1, request.rangeEndIndex + 1));
setStatusMessage(request.mode === 'propagated_only'
? `已清空第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的 ${request.maskCount} 个自动传播遮罩,其中后端标注 ${request.annotationIds.length} 个,人工/AI 标注帧已保留`
: `已清空第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的 ${request.maskCount} 个遮罩,其中后端标注 ${request.annotationIds.length}`);
setIsPropagationRangeSelecting(false);
setRangeSelectionMode(null);
setHasExplicitPropagationRange(false);
setPendingClearRangeConfirm(null);
} catch (err) {
console.error('Delete range annotations failed:', err);
setStatusMessage('批量清空失败,请检查后端服务');
} finally {
setIsSaving(false);
}
}, [currentProject?.id, setMasks, setSelectedMaskIds]);
const handleClearFrameRangeMasks = useCallback(async () => {
if (rangeSelectionMode !== 'clear') {
setIsPropagationRangeSelecting(true);
setRangeSelectionMode('clear');
setClearRangeMode('all');
setStatusMessage('请选择清空模式,并在播放进度条或视频处理进度条上点击/拖拽选择清空起止帧,再点击“确认清空”');
return;
}
if (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 frameIdsToClear = new Set(
frames.slice(rangeStartIndex, rangeEndIndex + 1).map((frame) => String(frame.id)),
);
const masksInRange = masks.filter((mask) => frameIdsToClear.has(String(mask.frameId)));
const rangeMasks = clearRangeMode === 'propagated_only'
? masksInRange.filter(isPropagatedMask)
: masksInRange;
if (rangeMasks.length === 0) {
setStatusMessage(clearRangeMode === 'propagated_only'
? `${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧没有可清空的自动传播遮罩`
: `${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧没有可清空的遮罩`);
return;
}
const hasManualOrAiAnnotatedFrames = clearRangeMode === 'all' && rangeMasks.some((mask) => !isPropagatedMask(mask));
const annotationIds = Array.from(new Set(
rangeMasks
.map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId)),
));
const request = {
frameIdsToClear: Array.from(frameIdsToClear),
annotationIds,
maskCount: rangeMasks.length,
rangeStartIndex,
rangeEndIndex,
mode: clearRangeMode,
};
if (hasManualOrAiAnnotatedFrames) {
setPendingClearRangeConfirm(request);
return;
}
await executeClearFrameRange(request);
}, [clearRangeMode, executeClearFrameRange, frames, masks, propagationEndFrame, propagationStartFrame, rangeSelectionMode, totalFrames]);
const handleBooleanFrameRangeRequest = useCallback((request: BooleanFrameRangeRequest) => {
const candidateFrameNumbers = request.candidateFrameIds
.map((frameId) => frameNumberById.get(String(frameId)))
@@ -1415,11 +1288,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setPropagationStartFrame(nextStart);
setPropagationEndFrame(nextEnd);
setHasExplicitPropagationRange(true);
const actionLabel = rangeSelectionMode === 'clear'
? '清空范围'
: rangeSelectionMode === 'boolean'
? '布尔操作范围'
: '自动传播范围';
const actionLabel = rangeSelectionMode === 'boolean' ? '布尔操作范围' : '自动传播范围';
setStatusMessage(`已选择${actionLabel}:第 ${Math.min(nextStart, nextEnd)}-${Math.max(nextStart, nextEnd)}`);
}, [clampFrameNumber, rangeSelectionMode]);
@@ -1601,6 +1470,9 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setIsPropagating(false);
setPropagationProgress(null);
setPropagationTaskId(null);
setIsPropagationRangeSelecting(false);
setRangeSelectionMode(null);
setHasExplicitPropagationRange(false);
}
};
@@ -1638,10 +1510,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setStatusMessage('已取消导出范围选择');
return;
}
if (previousMode === 'clear') {
setStatusMessage('已取消清空片段范围选择');
return;
}
if (previousMode === 'boolean') {
setStatusMessage('已取消布尔操作范围选择');
return;
@@ -1673,6 +1541,8 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const propagationPercent = propagationProgress
? Math.round((propagationProgress.completedSteps / Math.max(propagationProgress.totalSteps, 1)) * 100)
: 0;
const showPropagationControls = rangeSelectionMode === 'propagation' || isPropagating || Boolean(propagationTaskId);
const showFrameRangeControls = showPropagationControls || rangeSelectionMode === 'boolean';
const selectedRangeStartFrame = Math.min(propagationStartFrame, propagationEndFrame);
const selectedRangeEndFrame = Math.max(propagationStartFrame, propagationEndFrame);
const propagationBackwardFrameCount = Math.max(0, currentFrameNumber - selectedRangeStartFrame);
@@ -1735,26 +1605,28 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
</button>
</div>
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-2 py-1">
<span className="text-[10px] text-gray-500 whitespace-nowrap"></span>
<select
aria-label="传播权重"
value={propagationWeight}
onChange={(event) => {
setHasCustomPropagationWeight(true);
setPropagationWeight(event.target.value as AiModelId);
}}
disabled={isPropagating || isSaving || isExporting || isImportingGt}
className="h-6 w-24 rounded border border-cyan-500/20 bg-[#050809] px-1 text-[10px] text-cyan-100 outline-none focus:border-cyan-400/70 disabled:opacity-40"
>
{SAM2_MODEL_OPTIONS.map((option) => (
<option key={option.id} value={option.id} className="bg-[#050809] text-cyan-100">
{option.shortLabel}
</option>
))}
</select>
</div>
{rangeSelectionMode === 'propagation' && (
{showPropagationControls && (
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-2 py-1">
<span className="text-[10px] text-gray-500 whitespace-nowrap"></span>
<select
aria-label="传播权重"
value={propagationWeight}
onChange={(event) => {
setHasCustomPropagationWeight(true);
setPropagationWeight(event.target.value as AiModelId);
}}
disabled={isPropagating || isSaving || isExporting || isImportingGt}
className="h-6 w-24 rounded border border-cyan-500/20 bg-[#050809] px-1 text-[10px] text-cyan-100 outline-none focus:border-cyan-400/70 disabled:opacity-40"
>
{SAM2_MODEL_OPTIONS.map((option) => (
<option key={option.id} value={option.id} className="bg-[#050809] text-cyan-100">
{option.shortLabel}
</option>
))}
</select>
</div>
)}
{showPropagationControls && (
<div
className="flex items-center gap-2 rounded-md border border-cyan-500/20 bg-cyan-500/[0.06] px-2 py-1 text-[10px] text-cyan-100"
title="向前表示更早帧,向后表示更晚帧"
@@ -1863,32 +1735,34 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
</div>
</div>
)}
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-2 py-1">
<span className="text-[10px] text-gray-500 whitespace-nowrap"> {currentFrameNumber || 0}</span>
<span className="text-[10px] text-gray-600"></span>
<input
aria-label="传播起始帧"
type="number"
min={1}
max={Math.max(totalFrames, 1)}
value={propagationStartFrame}
onChange={(event) => handlePropagationStartInput(Number(event.target.value))}
disabled={isPropagating || isSaving || isExporting || isImportingGt || totalFrames === 0}
className="h-6 w-14 rounded bg-black/20 border border-white/10 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40"
/>
<span className="text-[10px] text-gray-600">-</span>
<input
aria-label="传播结束帧"
type="number"
min={1}
max={Math.max(totalFrames, 1)}
value={propagationEndFrame}
onChange={(event) => handlePropagationEndInput(Number(event.target.value))}
disabled={isPropagating || isSaving || isExporting || isImportingGt || totalFrames === 0}
className="h-6 w-14 rounded bg-black/20 border border-white/10 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40"
/>
</div>
{rangeSelectionMode === 'boolean' && pendingBooleanRangeRequest ? (
{showFrameRangeControls && (
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-2 py-1">
<span className="text-[10px] text-gray-500 whitespace-nowrap"> {currentFrameNumber || 0}</span>
<span className="text-[10px] text-gray-600"></span>
<input
aria-label="传播起始帧"
type="number"
min={1}
max={Math.max(totalFrames, 1)}
value={propagationStartFrame}
onChange={(event) => handlePropagationStartInput(Number(event.target.value))}
disabled={isPropagating || isSaving || isExporting || isImportingGt || totalFrames === 0}
className="h-6 w-14 rounded bg-black/20 border border-white/10 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40"
/>
<span className="text-[10px] text-gray-600">-</span>
<input
aria-label="传播结束帧"
type="number"
min={1}
max={Math.max(totalFrames, 1)}
value={propagationEndFrame}
onChange={(event) => handlePropagationEndInput(Number(event.target.value))}
disabled={isPropagating || isSaving || isExporting || isImportingGt || totalFrames === 0}
className="h-6 w-14 rounded bg-black/20 border border-white/10 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40"
/>
</div>
)}
{rangeSelectionMode === 'boolean' && pendingBooleanRangeRequest && (
<button
onClick={handleConfirmBooleanFrameRangeOperation}
disabled={frames.length === 0 || isSaving || isExporting || isImportingGt || isPropagating}
@@ -1897,53 +1771,16 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
>
{booleanOperationLabel(pendingBooleanRangeRequest.operation)}
</button>
) : (
)}
{showPropagationControls && (
<button
onClick={handleClearFrameRangeMasks}
onClick={handleAutoPropagate}
disabled={frames.length === 0 || isSaving || isExporting || isImportingGt || isPropagating}
title={clearRangeMode === 'propagated_only' ? '按当前起止帧清空自动传播遮罩,保留人工/AI 标注帧' : '按当前起止帧清空这一段视频内的全部遮罩'}
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-200 disabled:opacity-40 disabled:cursor-not-allowed"
className="px-4 py-1.5 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/25 rounded-md text-xs transition-colors text-cyan-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
{rangeSelectionMode === 'clear' ? '确认清空' : '清空片段遮罩'}
{isPropagating ? '传播中...' : '开始传播'}
</button>
)}
{rangeSelectionMode === 'clear' && (
<div className="flex items-center gap-1 rounded-md border border-red-500/20 bg-red-500/[0.04] px-1 py-1">
<button
type="button"
onClick={() => setClearRangeMode('all')}
aria-pressed={clearRangeMode === 'all'}
className={cn(
'h-6 rounded px-2 text-[10px] transition-colors',
clearRangeMode === 'all'
? 'bg-red-500/25 text-red-100'
: 'text-gray-400 hover:bg-white/10 hover:text-gray-200',
)}
>
</button>
<button
type="button"
onClick={() => setClearRangeMode('propagated_only')}
aria-pressed={clearRangeMode === 'propagated_only'}
className={cn(
'h-6 rounded px-2 text-[10px] transition-colors',
clearRangeMode === 'propagated_only'
? 'bg-blue-500/25 text-blue-100'
: 'text-gray-400 hover:bg-white/10 hover:text-gray-200',
)}
>
/AI
</button>
</div>
)}
<button
onClick={handleAutoPropagate}
disabled={!currentProject?.id || !currentFrame?.id || isSaving || isExporting || isImportingGt || isPropagating}
className="px-4 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-md text-xs transition-colors text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{isPropagating ? '传播中...' : rangeSelectionMode === 'propagation' ? '开始传播' : '自动传播'}
</button>
{isPropagationRangeSelecting && (
<button
onClick={handleCancelPropagationRangeSelection}
@@ -2147,9 +1984,12 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
activeTool={activeTool}
setActiveTool={setActiveTool}
onTriggerAI={onNavigateToAI}
onAutoPropagate={() => void handleAutoPropagate()}
onImportGtMask={() => gtMaskInputRef.current?.click()}
onDeleteMasks={handleDeleteSelectedMasks}
onClearMasks={handleClearCurrentFrameMasks}
canAutoPropagate={Boolean(currentProject?.id && currentFrame?.id) && !isSaving && !isExporting && !isImportingGt}
isPropagating={isPropagating}
canImportGtMask={Boolean(currentProject?.id && currentFrame?.id) && !isSaving && !isExporting && !isPropagating}
isImportingGtMask={isImportingGt}
/>
@@ -2159,6 +1999,8 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
<CanvasArea
activeTool={activeTool}
frame={currentFrame}
currentFrameNumber={currentFrameNumber || 0}
totalFrames={totalFrames}
onRequestDeleteMasks={(maskIds) => void handleDeleteSelectedMasks(maskIds)}
onRequestBooleanFrameRange={handleBooleanFrameRangeRequest}
onDeleteMaskAnnotations={handleDeleteMaskAnnotations}
@@ -2189,17 +2031,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
>
</button>
<button
type="button"
onClick={() => {
setPendingCurrentClearConfirm(null);
void handleClearFrameRangeMasks();
}}
className="rounded border border-amber-400/30 bg-amber-500/10 px-3 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.currentMaskIds, pendingCurrentClearConfirm.scopeLabel)}
@@ -2221,42 +2052,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
</div>
)}
{pendingClearRangeConfirm && (
<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">/AI </h2>
<p className="mt-2 text-sm leading-relaxed text-gray-300">
{pendingClearRangeConfirm.rangeStartIndex + 1}-{pendingClearRangeConfirm.rangeEndIndex + 1} AI mask
{pendingClearRangeConfirm.maskCount}
</p>
<p className="mt-2 text-xs leading-relaxed text-red-200/70">
/AI
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setPendingClearRangeConfirm(null);
setStatusMessage('已取消清空片段遮罩');
}}
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 executeClearFrameRange(pendingClearRangeConfirm)}
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"
>
/AI
</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">