同步传播链编辑并保护模板切换
- 修改激活模板时,如果当前项目已有 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:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user