完善项目导入、模板与分割工作区交互

- 增强 DICOM/视频项目导入与演示数据:DICOM 按文件名自然顺序处理,导入后展示上传与解析任务进度,恢复演示出厂设置保留演示视频和演示 DICOM 项目,并补充 demo media seed 逻辑。

- 完善项目管理:项目支持重命名、删除、复制,删除使用站内确认弹窗,复制支持新项目重置和全内容复制,DICOM 项目不显示生成帧入口。

- 完善 GT Mask 与导出链路:只支持 8-bit maskid 图导入,非法/全背景图明确拒绝,尺寸自动适配,高精度 polygon 回显;统一导出默认当前帧,GT_label 使用 uint8 和真实 maskid,待分类 maskid 0 与背景一致。

- 完善分割工作区交互:新增画笔和橡皮擦并支持尺寸控制,移除创建点/线段入口,工具栏按类别分隔,AI 智能分割使用明确 AI 图标,取消黄色 seed point,清空/删除传播 mask 后同步清理空帧时间轴状态。

- 完善传播与时间轴:自动传播使用 SAM 2.1 权重任务,参考帧无遮罩时提示,传播历史按同一蓝色系递进变暗,删除/清空传播链时保留人工或独立 AI 标注来源。

- 完善模板库:新增头颈部 CT 分割默认模板,所有模板保留 maskid 0 待分类,支持鼠标复制模板、拖拽层级、JSON 批量导入预览、删除 label 和站内删除确认。

- 完善用户与高风险确认:用户改密码、删除用户、恢复演示出厂设置和清空人工/AI 标注帧均改为站内确认交互,避免浏览器原生 prompt/confirm。

- 补充前后端测试与文档:更新项目、模板、GT 导入、导出、传播、DICOM、用户管理等测试,并同步 README、AGENTS 和 doc 下实现/契约/测试计划文档。
This commit is contained in:
2026-05-03 17:11:59 +08:00
parent afcddfaeb9
commit 481ffa5b67
47 changed files with 3650 additions and 676 deletions

View File

@@ -22,7 +22,7 @@ import { ToolsPalette } from './ToolsPalette';
import { OntologyInspector } from './OntologyInspector';
import { FrameTimeline } from './FrameTimeline';
import { ModelStatusBadge } from './ModelStatusBadge';
import { DEFAULT_AI_MODEL_ID, SAM2_MODEL_OPTIONS, type AiModelId, type Frame, type Mask, type TemplateClass } from '../store/useStore';
import { DEFAULT_AI_MODEL_ID, SAM2_MODEL_OPTIONS, type AiModelId, type Frame, type Mask, type Template, type TemplateClass } from '../store/useStore';
import { cn } from '../lib/utils';
import { normalizeClassMaskIds } from '../lib/maskIds';
@@ -44,6 +44,14 @@ type PropagationHistorySegment = {
};
type RangeSelectionMode = 'propagation' | 'clear' | 'export' | null;
type ClearRangeMode = 'all' | 'propagated_only';
type ClearRangeConfirmState = {
frameIdsToClear: string[];
annotationIds: string[];
maskCount: number;
rangeStartIndex: number;
rangeEndIndex: number;
mode: ClearRangeMode;
};
type GtUnknownPolicy = 'discard' | 'undefined';
type ExportScope = 'all' | 'range' | 'current';
type ExportPreviewPolygon = {
@@ -66,7 +74,7 @@ type GtMaskPreviewState = {
validationSkipped?: boolean;
};
const GT_MASK_REQUIREMENT_MESSAGE = 'GT Mask 图片不符合要求:请上传灰度图,或 RGB 三通道完全相同的 maskid 图(背景 0像素值为 maskid。';
const GT_MASK_REQUIREMENT_MESSAGE = 'GT Mask 图片不符合要求:请上传 8-bit 灰度图,或 8-bit RGB 三通道完全相同的 maskid 图(背景 0像素值为 1-255 的 maskid。';
const flatPolygonToSvgPoints = (polygon: number[]) => {
const points: string[] = [];
@@ -115,6 +123,66 @@ const classByMaskId = (classes: TemplateClass[]) => new Map(
normalizeClassMaskIds(classes).map((templateClass) => [Number(templateClass.maskId), templateClass]),
);
const UNCLASSIFIED_MASK_LABEL = '待分类';
const UNCLASSIFIED_MASK_COLOR = '#9ca3af';
const normalizeMaskAgainstTemplates = (mask: Mask, templates: Template[]): Mask => {
const hasClassReference = Boolean(mask.classId || mask.className || mask.classMaskId !== undefined);
if (!hasClassReference || mask.classMaskId === 0) return mask;
const template = mask.templateId
? templates.find((item) => String(item.id) === String(mask.templateId))
: null;
if (!template) return mask;
const classes = normalizeClassMaskIds(template.classes || []);
let matchedClass: TemplateClass | undefined;
if (mask.classId) {
matchedClass = classes.find((templateClass) => templateClass.id === mask.classId);
} else if (mask.classMaskId !== undefined) {
matchedClass = classes.find((templateClass) => Number(templateClass.maskId) === Number(mask.classMaskId));
} else if (mask.className) {
matchedClass = classes.find((templateClass) => (
templateClass.name === mask.className
&& (!mask.color || templateClass.color.toLowerCase() === mask.color.toLowerCase())
)) || classes.find((templateClass) => templateClass.name === mask.className);
}
if (matchedClass) {
return {
...mask,
classId: matchedClass.id,
className: matchedClass.name,
classZIndex: matchedClass.zIndex,
classMaskId: matchedClass.maskId,
label: matchedClass.name,
color: matchedClass.color,
};
}
return {
...mask,
classId: undefined,
className: UNCLASSIFIED_MASK_LABEL,
classZIndex: undefined,
classMaskId: 0,
label: UNCLASSIFIED_MASK_LABEL,
color: UNCLASSIFIED_MASK_COLOR,
saveStatus: mask.annotationId ? 'dirty' : 'draft',
saved: mask.annotationId ? false : mask.saved,
metadata: {
...(mask.metadata || {}),
needs_classification: true,
stale_class: {
id: mask.classId,
name: mask.className || mask.label,
maskId: mask.classMaskId,
color: mask.color,
},
},
};
};
const trimPropagationHistoryByClearedRange = (
segments: PropagationHistorySegment[],
clearStartFrame: number,
@@ -147,6 +215,62 @@ const trimPropagationHistoryByClearedRange = (
});
};
const prunePropagationHistoryByActiveFrames = (
segments: PropagationHistorySegment[],
activeFrameNumbers: Set<number>,
totalFrames: number,
): PropagationHistorySegment[] => (
segments.flatMap((segment) => {
const start = Math.max(1, Math.min(segment.startFrame, segment.endFrame));
const end = Math.min(totalFrames, Math.max(segment.startFrame, segment.endFrame));
const chunks: PropagationHistorySegment[] = [];
let chunkStart: number | null = null;
for (let frameNumber = start; frameNumber <= end; frameNumber += 1) {
if (activeFrameNumbers.has(frameNumber)) {
chunkStart ??= frameNumber;
continue;
}
if (chunkStart !== null) {
const chunkEnd = frameNumber - 1;
chunks.push({
...segment,
id: chunkStart === start && chunkEnd === end ? segment.id : `${segment.id}-${chunkStart}-${chunkEnd}`,
startFrame: chunkStart,
endFrame: chunkEnd,
});
chunkStart = null;
}
}
if (chunkStart !== null) {
chunks.push({
...segment,
id: chunkStart === start ? segment.id : `${segment.id}-${chunkStart}-${end}`,
startFrame: chunkStart,
endFrame: end,
});
}
return chunks;
})
);
const propagationHistoryEqual = (
left: PropagationHistorySegment[],
right: PropagationHistorySegment[],
) => (
left.length === right.length
&& left.every((segment, index) => {
const other = right[index];
return other
&& segment.id === other.id
&& segment.startFrame === other.startFrame
&& segment.endFrame === other.endFrame
&& segment.colorIndex === other.colorIndex
&& segment.label === other.label;
})
);
const isPropagatedMask = (mask: Mask) => {
const source = typeof mask.metadata?.source === 'string' ? mask.metadata.source : '';
return source.includes('_propagation')
@@ -156,6 +280,43 @@ const isPropagatedMask = (mask: Mask) => {
|| mask.metadata?.propagation_seed_key !== undefined;
};
const persistentMaskMetadata = (metadata?: Record<string, unknown>) => {
if (!metadata) return {};
const {
geometry_smoothing: _geometrySmoothing,
geometry_smoothing_preview: _geometrySmoothingPreview,
...rest
} = metadata;
return rest;
};
const isNotFoundError = (error: unknown) => (
typeof error === 'object'
&& error !== null
&& (
('response' in error
&& typeof (error as { response?: { status?: unknown } }).response === 'object'
&& (error as { response?: { status?: unknown } }).response?.status === 404)
|| ('status' in error && (error as { status?: unknown }).status === 404)
)
);
const deleteAnnotationIfExists = async (annotationId: string) => {
try {
await deleteAnnotation(annotationId);
} catch (error) {
if (!isNotFoundError(error)) throw error;
}
};
const deleteAnnotationsIfExist = async (annotationIds: string[]) => {
const results = await Promise.allSettled(annotationIds.map((annotationId) => deleteAnnotationIfExists(annotationId)));
const firstFailure = results.find((result): result is PromiseRejectedResult => (
result.status === 'rejected' && !isNotFoundError(result.reason)
));
if (firstFailure) throw firstFailure.reason;
};
const PROPAGATION_POLL_INTERVAL_MS = 250;
const STATUS_MESSAGE_TTL_MS = 3600;
@@ -272,6 +433,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
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 [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false);
const [propagationProgress, setPropagationProgress] = useState<PropagationProgress>(null);
const [propagationTaskId, setPropagationTaskId] = useState<number | null>(null);
@@ -317,6 +479,9 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
return () => window.removeEventListener('keydown', handleWorkspaceShortcuts);
}, [redoMasks, undoMasks]);
const templates = useStore((state) => state.templates);
const setTemplates = useStore((state) => state.setTemplates);
const hydrateSavedAnnotations = useCallback(async (
projectId: string,
projectFrames: Frame[],
@@ -326,11 +491,17 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const frameById = new Map(projectFrames.map((frame) => [frame.id, frame]));
const projectFrameIds = new Set(projectFrames.map((frame) => frame.id));
const excludedDraftIds = new Set(excludeUnsavedMaskIds);
let latestTemplates = useStore.getState().templates;
if (latestTemplates.length === 0) {
latestTemplates = await getTemplates();
setTemplates(latestTemplates);
}
const annotations = await getProjectAnnotations(projectId);
const savedMasks = annotations
.map((annotation) => {
const frame = annotation.frame_id ? frameById.get(String(annotation.frame_id)) : null;
return frame ? annotationToMask(annotation, frame) : null;
const mask = frame ? annotationToMask(annotation, frame) : null;
return mask ? normalizeMaskAgainstTemplates(mask, latestTemplates) : null;
})
.filter((mask): mask is NonNullable<typeof mask> => Boolean(mask));
const currentMasks = useStore.getState().masks;
@@ -346,7 +517,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setSelectedMaskIds(nextSelectedIds);
}
}
}, [setMasks, setSelectedMaskIds]);
}, [setMasks, setSelectedMaskIds, setTemplates]);
useEffect(() => {
if (!currentProject?.id) return;
@@ -408,9 +579,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
return () => { cancelled = true; };
}, [currentProject?.id, currentProject?.video_path, hydrateSavedAnnotations, setFrames, setCurrentFrame]);
const templates = useStore((state) => state.templates);
const setTemplates = useStore((state) => state.setTemplates);
useEffect(() => {
if (templates.length === 0) {
getTemplates().then((data) => setTemplates(data)).catch(console.error);
@@ -451,6 +619,25 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
})));
}, [exportPreviewFrame, masks]);
useEffect(() => {
if (propagationHistory.length === 0 || frames.length === 0) return;
const frameNumberById = new Map(frames.map((frame, index) => [String(frame.id), index + 1]));
const activePropagatedFrameNumbers = new Set<number>();
masks.forEach((mask) => {
if (!isPropagatedMask(mask)) return;
const frameNumber = frameNumberById.get(String(mask.frameId));
if (frameNumber) activePropagatedFrameNumbers.add(frameNumber);
});
const nextHistory = prunePropagationHistoryByActiveFrames(
propagationHistory,
activePropagatedFrameNumbers,
totalFrames,
);
if (!propagationHistoryEqual(propagationHistory, nextHistory)) {
setPropagationHistory(nextHistory);
}
}, [frames, masks, propagationHistory, totalFrames]);
useEffect(() => {
if (!statusMessage || isWorkspaceBusy || totalFrames === 0) return undefined;
const timer = window.setTimeout(() => setStatusMessage(''), STATUS_MESSAGE_TTL_MS);
@@ -474,9 +661,13 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setHasExplicitPropagationRange(false);
}, [currentFrameNumber, totalFrames]);
const savePendingAnnotations = useCallback(async ({ silent = false } = {}) => {
const savePendingAnnotations = useCallback(async ({ silent = false, frameId }: { silent?: boolean; frameId?: string } = {}) => {
if (!currentProject?.id) return 0;
const projectMasks = masks.filter((mask) => projectFrameIds.has(mask.frameId));
const latestMasks = useStore.getState().masks;
const projectMasks = latestMasks.filter((mask) => (
projectFrameIds.has(mask.frameId)
&& (!frameId || String(mask.frameId) === String(frameId))
));
const pendingMasks = projectMasks.filter((mask) => !mask.annotationId);
const dirtyMasks = projectMasks.filter((mask) => mask.annotationId && mask.saveStatus === 'dirty');
if (pendingMasks.length === 0 && dirtyMasks.length === 0) {
@@ -500,13 +691,10 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const frame = frameById.get(mask.frameId);
const payload = frame ? buildAnnotationPayload(currentProject.id, mask, frame, activeTemplateId) : null;
if (!payload || !mask.annotationId) return null;
const propagationLineage = {
...(mask.metadata?.source_annotation_id !== undefined ? { source_annotation_id: mask.metadata.source_annotation_id } : {}),
...(mask.metadata?.source_mask_id !== undefined ? { source_mask_id: mask.metadata.source_mask_id } : {}),
};
const savedMetadata = persistentMaskMetadata(mask.metadata);
const updatePayload = {
template_id: payload.template_id,
mask_data: { ...payload.mask_data, ...propagationLineage },
mask_data: { ...savedMetadata, ...payload.mask_data },
points: payload.points,
bbox: payload.bbox,
};
@@ -539,7 +727,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
} finally {
setIsSaving(false);
}
}, [activeTemplateId, currentProject?.id, frameById, frames, hydrateSavedAnnotations, masks, projectFrameIds]);
}, [activeTemplateId, currentProject?.id, frameById, frames, hydrateSavedAnnotations, projectFrameIds]);
const handleClearCurrentFrameMasks = useCallback(async () => {
if (!currentFrame) return;
@@ -551,7 +739,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setIsSaving(true);
setStatusMessage(annotationIds.length > 0 ? '正在删除已保存标注...' : '正在清空本帧遮罩...');
try {
await Promise.all(annotationIds.map((annotationId) => deleteAnnotation(annotationId)));
await deleteAnnotationsIfExist(annotationIds);
setMasks(masks.filter((mask) => mask.frameId !== currentFrame.id));
setStatusMessage(annotationIds.length > 0
? `已删除 ${annotationIds.length} 个后端标注`
@@ -564,6 +752,39 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
}
}, [currentFrame, masks, setMasks]);
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);
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);
}
}, [setMasks, setSelectedMaskIds]);
const handleClearFrameRangeMasks = useCallback(async () => {
if (rangeSelectionMode !== 'clear') {
setIsPropagationRangeSelecting(true);
@@ -595,57 +816,34 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
return;
}
const hasManualOrAiAnnotatedFrames = clearRangeMode === 'all' && rangeMasks.some((mask) => !isPropagatedMask(mask));
if (hasManualOrAiAnnotatedFrames) {
const confirmed = window.confirm('是否清除“人工/AI标注帧”\n该范围包含人工绘制或 AI 智能分割生成的 mask确认后这些 mask 也会被删除。');
if (!confirmed) {
setStatusMessage('已取消清空片段遮罩');
return;
}
}
const annotationIds = Array.from(new Set(
rangeMasks
.map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId)),
));
setIsSaving(true);
setStatusMessage(annotationIds.length > 0
? `正在删除第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的已保存标注...`
: `正在清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的本地遮罩...`);
try {
await Promise.all(annotationIds.map((annotationId) => deleteAnnotation(annotationId)));
const latestMasks = useStore.getState().masks;
const clearedMaskIds = new Set(
latestMasks
.filter((mask) => frameIdsToClear.has(String(mask.frameId)))
.filter((mask) => clearRangeMode === '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, rangeStartIndex + 1, rangeEndIndex + 1));
setStatusMessage(clearRangeMode === 'propagated_only'
? `已清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的 ${rangeMasks.length} 个自动传播遮罩,其中后端标注 ${annotationIds.length} 个,人工/AI 标注帧已保留`
: `已清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的 ${rangeMasks.length} 个遮罩,其中后端标注 ${annotationIds.length}`);
setIsPropagationRangeSelecting(false);
setRangeSelectionMode(null);
setHasExplicitPropagationRange(false);
} catch (err) {
console.error('Delete range annotations failed:', err);
setStatusMessage('批量清空失败,请检查后端服务');
} finally {
setIsSaving(false);
const request = {
frameIdsToClear: Array.from(frameIdsToClear),
annotationIds,
maskCount: rangeMasks.length,
rangeStartIndex,
rangeEndIndex,
mode: clearRangeMode,
};
if (hasManualOrAiAnnotatedFrames) {
setPendingClearRangeConfirm(request);
return;
}
}, [clearRangeMode, frames, masks, propagationEndFrame, propagationStartFrame, rangeSelectionMode, setMasks, setSelectedMaskIds, totalFrames]);
await executeClearFrameRange(request);
}, [clearRangeMode, executeClearFrameRange, frames, masks, propagationEndFrame, propagationStartFrame, rangeSelectionMode, totalFrames]);
const handleDeleteMaskAnnotations = useCallback(async (annotationIds: string[]) => {
if (annotationIds.length === 0) return;
try {
await Promise.all(annotationIds.map((annotationId) => deleteAnnotation(annotationId)));
setStatusMessage(`已删除 ${annotationIds.length}被合并标注`);
await deleteAnnotationsIfExist(annotationIds);
setStatusMessage(`已删除 ${annotationIds.length} 个标注`);
} catch (err) {
console.error('Delete merged annotations failed:', err);
setStatusMessage('合并后删除标注失败,请检查后端服务');
console.error('Delete annotations failed:', err);
setStatusMessage('删除标注失败,请检查后端服务');
throw err;
}
}, []);
@@ -990,21 +1188,21 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const runAutoPropagate = async () => {
if (!currentProject?.id || !currentFrame?.id) return;
const initialSeedMasks = masks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
const initialSeedMasks = useStore.getState().masks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
if (initialSeedMasks.length === 0) {
setStatusMessage('请先在当前参考帧创建或保存至少一个 mask');
setStatusMessage('当前参考帧无遮罩');
return;
}
const hasUnstableSeedMasks = initialSeedMasks.some((mask) => !mask.annotationId || mask.saveStatus === 'dirty');
if (hasUnstableSeedMasks) {
setStatusMessage('正在先保存参考帧 mask确保二次传播可以替换旧结果...');
await savePendingAnnotations({ silent: true });
await savePendingAnnotations({ silent: true, frameId: currentFrame.id });
}
const seedMasks = useStore.getState().masks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
if (seedMasks.length === 0) {
setStatusMessage('参考帧 mask 保存后未能回显,请先检查归档保存是否成功');
setStatusMessage('当前参考帧无遮罩');
return;
}
@@ -1285,7 +1483,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
<div className="w-full max-w-xl rounded-md border border-white/10 bg-[#151515] p-4 shadow-2xl shadow-black/60">
<div className="text-sm font-semibold text-white"> GT Mask</div>
<div className="mt-2 text-xs leading-5 text-gray-400">
GT maskid RGB [X,X,X] maskid 0 X maskid
GT 8-bit maskid 8-bit RGB [X,X,X] maskid 0 X 1-255 maskid
</div>
<div className="mt-3 rounded border border-white/10 bg-black/20 px-3 py-2 text-[11px] text-gray-500">
{pendingGtImportFile.name}
@@ -1663,6 +1861,42 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
<OntologyInspector />
</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>
)}
{/* Bottom Timeline */}
<FrameTimeline
propagationRange={visibleTimelineRange}