同步传播链编辑并保护模板切换

- 修改激活模板时,如果当前项目已有 mask,先提示确认并清空所有本地 mask 和已保存后端标注;无 mask 项目可直接切换。

- 模板库详情页将“+ 新建分类”改为带编辑图标的“编辑模板”,并打开完整模板编辑弹窗。

- 区域合并会按 propagation lineage 找到其它传播帧的对应主区域和参与区域,逐帧执行 union,只删除实际参与合并的对应 mask。

- 重叠区域去除会按 propagation lineage 同步到其它传播帧的对应区域,保留参与扣除 mask,不再只改当前帧。

- 当前帧清空遮罩会同步删除这些 mask 的关联自动传播结果,并新增左侧工具栏清空入口。

- 传播链同步编辑保留 source、source_annotation_id、source_mask_id、propagation_seed_key 等 metadata,避免时间轴帧属性变色。

- 补充模板切换确认、模板编辑按钮、左侧清空入口、传播链合并/去除和清空传播链的前端回归测试。

- 更新 AGENTS、接口契约、冻结需求、设计冻结和测试计划文档。
This commit is contained in:
2026-05-03 19:10:12 +08:00
parent 2da73f9acd
commit 4d6bbf2b80
15 changed files with 524 additions and 93 deletions

View File

@@ -280,6 +280,61 @@ const isPropagatedMask = (mask: Mask) => {
|| mask.metadata?.propagation_seed_key !== undefined;
};
const metadataNumber = (value: unknown): number | null => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
};
const propagationSourceMaskTokens = (value: unknown): string[] => {
if (typeof value !== 'string' || value.length === 0) return [];
const tokens = [`mask:${value}`];
const annotationMatch = value.match(/^annotation-(\d+)$/);
if (annotationMatch) tokens.push(`annotation:${annotationMatch[1]}`);
return tokens;
};
const propagationLineageTokens = (mask: Mask): Set<string> => {
const metadata = mask.metadata || {};
const tokens = new Set<string>([`mask:${mask.id}`]);
if (mask.annotationId) tokens.add(`annotation:${mask.annotationId}`);
const sourceAnnotationId = metadataNumber(metadata.source_annotation_id);
if (sourceAnnotationId !== null) tokens.add(`annotation:${sourceAnnotationId}`);
propagationSourceMaskTokens(metadata.source_mask_id).forEach((token) => tokens.add(token));
if (typeof metadata.propagation_seed_key === 'string' && metadata.propagation_seed_key.length > 0) {
tokens.add(`seed-key:${metadata.propagation_seed_key}`);
}
return tokens;
};
const findPropagationChainMaskIds = (selectedIds: string[], allMasks: Mask[]): Set<string> => {
const selectedMasks = selectedIds
.map((id) => allMasks.find((mask) => mask.id === id))
.filter((mask): mask is Mask => Boolean(mask));
const selectedTokens = new Set<string>();
selectedMasks.forEach((mask) => {
propagationLineageTokens(mask).forEach((token) => selectedTokens.add(token));
});
if (selectedTokens.size === 0) return new Set(selectedIds);
return new Set(
allMasks
.filter((mask) => {
const candidateTokens = propagationLineageTokens(mask);
return [...candidateTokens].some((token) => selectedTokens.has(token));
})
.map((mask) => mask.id),
);
};
const expandedPropagationDeletionMaskIds = (selectedIds: string[], allMasks: Mask[]): Set<string> => {
const selectedIdSet = new Set(selectedIds);
const chainIds = findPropagationChainMaskIds(selectedIds, allMasks);
return new Set(
allMasks
.filter((mask) => selectedIdSet.has(mask.id) || (chainIds.has(mask.id) && isPropagatedMask(mask)))
.map((mask) => mask.id),
);
};
const persistentMaskMetadata = (metadata?: Record<string, unknown>) => {
if (!metadata) return {};
const {
@@ -732,25 +787,30 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const handleClearCurrentFrameMasks = useCallback(async () => {
if (!currentFrame) return;
const frameMasks = masks.filter((mask) => mask.frameId === currentFrame.id);
const annotationIds = frameMasks
.map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId));
const maskIdsToClear = expandedPropagationDeletionMaskIds(frameMasks.map((mask) => mask.id), masks);
const masksToClear = masks.filter((mask) => maskIdsToClear.has(mask.id));
const annotationIds = Array.from(new Set(
masksToClear
.map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId)),
));
setIsSaving(true);
setStatusMessage(annotationIds.length > 0 ? '正在删除已保存标注...' : '正在清空本帧遮罩...');
setStatusMessage(annotationIds.length > 0 ? '正在删除已保存标注和关联传播帧...' : '正在清空本帧遮罩和关联传播帧...');
try {
await deleteAnnotationsIfExist(annotationIds);
setMasks(masks.filter((mask) => mask.frameId !== currentFrame.id));
setMasks(masks.filter((mask) => !maskIdsToClear.has(mask.id)));
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !maskIdsToClear.has(id)));
setStatusMessage(annotationIds.length > 0
? `已删除 ${annotationIds.length} 个后端标注`
: '已清空本帧未保存遮罩');
? `已删除 ${annotationIds.length} 个后端标注,已同步清空关联传播帧`
: '已清空本帧未保存遮罩和关联传播帧');
} catch (err) {
console.error('Delete annotations failed:', err);
setStatusMessage('删除失败,请检查后端服务');
} finally {
setIsSaving(false);
}
}, [currentFrame, masks, setMasks]);
}, [currentFrame, masks, setMasks, setSelectedMaskIds]);
const executeClearFrameRange = useCallback(async (request: ClearRangeConfirmState) => {
const frameIdsToClear = new Set(request.frameIdsToClear);
@@ -1844,6 +1904,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setActiveTool={setActiveTool}
onTriggerAI={onNavigateToAI}
onImportGtMask={() => gtMaskInputRef.current?.click()}
onClearMasks={handleClearCurrentFrameMasks}
canImportGtMask={Boolean(currentProject?.id && currentFrame?.id) && !isSaving && !isExporting && !isPropagating}
isImportingGtMask={isImportingGt}
/>