Files
Pre_Seg_Server/src/components/OntologyInspector.tsx
admin 4d6bbf2b80 同步传播链编辑并保护模板切换
- 修改激活模板时,如果当前项目已有 mask,先提示确认并清空所有本地 mask 和已保存后端标注;无 mask 项目可直接切换。

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

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

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

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

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

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

- 更新 AGENTS、接口契约、冻结需求、设计冻结和测试计划文档。
2026-05-03 19:10:12 +08:00

852 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown, Tag, Eye, Plus, X, Loader2, GripVertical } from 'lucide-react';
import { useStore } from '../store/useStore';
import type { Mask, TemplateClass } from '../store/useStore';
import { cn } from '../lib/utils';
import { getActiveTemplate } from '../lib/templateSelection';
import { analyzeMask, deleteAnnotation, smoothMaskGeometry, updateTemplate, type MaskAnalysisResult, type SmoothMaskGeometryResult } from '../lib/api';
import { isReservedUnclassifiedClass, nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds';
const SMOOTHING_PREVIEW_DEBOUNCE_MS = 220;
const isRequestAbortError = (err: unknown) => {
const error = err as { code?: string; message?: string; name?: string } | null;
const message = error?.message || '';
return error?.code === 'ERR_CANCELED'
|| error?.code === 'ECONNABORTED'
|| error?.name === 'AbortError'
|| /request aborted|aborted|cancell?ed/i.test(message);
};
function metadataNumber(value: unknown): number | null {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function isNotFoundError(error: unknown): boolean {
const maybeError = error as { response?: { status?: number }; status?: number } | null;
return maybeError?.response?.status === 404 || maybeError?.status === 404;
}
async function deleteAnnotationIfExists(annotationId: string) {
try {
await deleteAnnotation(annotationId);
} catch (error) {
if (!isNotFoundError(error)) throw error;
}
}
function 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;
}
function propagationLineageTokens(mask: { id: string; annotationId?: string; metadata?: Record<string, unknown> }): 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;
}
function findPropagationChainMaskIds(selectedMask: Pick<Mask, 'id' | 'annotationId' | 'metadata'>, masks: Mask[]): Set<string> {
const selectedTokens = propagationLineageTokens(selectedMask);
return new Set(
masks
.filter((mask) => Array.from(selectedTokens).some((token) => propagationLineageTokens(mask).has(token)))
.map((mask) => mask.id),
);
}
export function OntologyInspector() {
const templates = useStore((state) => state.templates);
const activeTemplateId = useStore((state) => state.activeTemplateId);
const activeClassId = useStore((state) => state.activeClassId);
const activeClass = useStore((state) => state.activeClass);
const frames = useStore((state) => state.frames);
const currentFrameIndex = useStore((state) => state.currentFrameIndex);
const masks = useStore((state) => state.masks);
const selectedMaskIds = useStore((state) => state.selectedMaskIds);
const maskPreviewOpacity = useStore((state) => state.maskPreviewOpacity);
const setMasks = useStore((state) => state.setMasks);
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
const updateTemplateStore = useStore((state) => state.updateTemplate);
const setActiveTemplateId = useStore((state) => state.setActiveTemplateId);
const setActiveClass = useStore((state) => state.setActiveClass);
const setMaskPreviewOpacity = useStore((state) => state.setMaskPreviewOpacity);
const [showAddForm, setShowAddForm] = useState(false);
const [newClassName, setNewClassName] = useState('');
const [newClassColor, setNewClassColor] = useState('#06b6d4');
const [isSavingClass, setIsSavingClass] = useState(false);
const [classSaveMessage, setClassSaveMessage] = useState('');
const [dragClassId, setDragClassId] = useState<string | null>(null);
const [maskAnalysis, setMaskAnalysis] = useState<MaskAnalysisResult | null>(null);
const [analysisMessage, setAnalysisMessage] = useState('');
const [smoothingStrength, setSmoothingStrength] = useState(0);
const [isPreviewingSmoothing, setIsPreviewingSmoothing] = useState(false);
const [isSmoothingMask, setIsSmoothingMask] = useState(false);
const [pendingTemplateSwitch, setPendingTemplateSwitch] = useState<{ templateId: string | null } | null>(null);
const [isSwitchingTemplate, setIsSwitchingTemplate] = useState(false);
const activeTemplate = getActiveTemplate(templates, activeTemplateId);
const templateClasses = normalizeClassMaskIds(activeTemplate?.classes || []);
const allClasses = [...templateClasses].sort((a, b) => b.zIndex - a.zIndex);
const selectedMask = masks.find((mask) => selectedMaskIds.includes(mask.id)) || null;
const selectedMaskLabel = selectedMask?.className || selectedMask?.label || '未选择';
const currentFrame = frames[currentFrameIndex] || null;
const classButtonRefs = useRef(new Map<string, HTMLButtonElement>());
const skipNextAutoAnalysisRef = useRef(false);
const analysisRequestIdRef = useRef(0);
const smoothingPreviewRef = useRef<{
maskId: string;
baseMask: NonNullable<typeof selectedMask>;
strength: number;
result: SmoothMaskGeometryResult | null;
applied: boolean;
requestId: number;
} | null>(null);
const smoothingRequestIdRef = useRef(0);
const smoothingPreviewTimerRef = useRef<number | null>(null);
const clearSmoothingPreviewTimer = React.useCallback(() => {
if (smoothingPreviewTimerRef.current === null) return;
window.clearTimeout(smoothingPreviewTimerRef.current);
smoothingPreviewTimerRef.current = null;
}, []);
const selectedMaskClass = useMemo(() => {
if (!selectedMask) return null;
const allTemplateClasses = templates.flatMap((template) => (
template.classes.map((templateClass) => ({ template, templateClass }))
));
const selectedName = selectedMask.className || selectedMask.label;
return allTemplateClasses.find(({ templateClass }) => selectedMask.classId && templateClass.id === selectedMask.classId)
|| allTemplateClasses.find(({ templateClass }) => templateClass.name === selectedName && templateClass.color === selectedMask.color)
|| allTemplateClasses.find(({ templateClass }) => templateClass.name === selectedName)
|| null;
}, [selectedMask?.classId, selectedMask?.className, selectedMask?.color, selectedMask?.id, selectedMask?.label, templates]);
useEffect(() => {
if (!selectedMaskClass) return;
if (activeTemplateId !== selectedMaskClass.template.id) {
setActiveTemplateId(selectedMaskClass.template.id);
}
setActiveClass(selectedMaskClass.templateClass);
const timer = window.setTimeout(() => {
const node = classButtonRefs.current.get(selectedMaskClass.templateClass.id);
node?.scrollIntoView?.({ block: 'nearest' });
node?.focus?.({ preventScroll: true });
}, 0);
return () => window.clearTimeout(timer);
}, [activeTemplateId, selectedMaskClass, setActiveClass, setActiveTemplateId]);
const handleSelectClass = (templateClass: TemplateClass) => {
if (activeTemplate && !activeTemplateId) {
setActiveTemplateId(activeTemplate.id);
}
setActiveClass(templateClass);
const selectedIdSet = new Set(selectedMaskIds);
const hasSelectedMasks = masks.some((mask) => selectedIdSet.has(mask.id));
if (!hasSelectedMasks) return;
const templateId = activeTemplate?.id || activeTemplateId || undefined;
const targetIdSet = new Set<string>();
masks
.filter((mask) => selectedIdSet.has(mask.id))
.forEach((mask) => {
findPropagationChainMaskIds(mask, masks).forEach((maskId) => targetIdSet.add(maskId));
});
const updatedMasks = masks.map((mask) => {
if (!targetIdSet.has(mask.id)) return mask;
return {
...mask,
templateId: templateId || mask.templateId,
classId: templateClass.id,
className: templateClass.name,
classZIndex: templateClass.zIndex,
classMaskId: templateClass.maskId,
label: templateClass.name,
color: templateClass.color,
saveStatus: mask.annotationId ? 'dirty' as const : 'draft' as const,
saved: mask.annotationId ? false : mask.saved,
};
});
const selectedMasksOnTop = selectedMaskIds
.map((id) => updatedMasks.find((mask) => mask.id === id))
.filter((mask): mask is (typeof updatedMasks)[number] => Boolean(mask));
setMasks([
...updatedMasks.filter((mask) => !selectedIdSet.has(mask.id)),
...selectedMasksOnTop,
]);
};
const refreshMaskAnalysis = async () => {
const requestId = analysisRequestIdRef.current + 1;
analysisRequestIdRef.current = requestId;
if (!selectedMask || !currentFrame) {
setMaskAnalysis(null);
setAnalysisMessage(selectedMask ? '当前帧信息不可用,无法读取后端属性' : '请选择一个 mask 查看后端属性');
return;
}
setAnalysisMessage('');
try {
const result = await analyzeMask(selectedMask, currentFrame);
if (analysisRequestIdRef.current !== requestId) return;
setMaskAnalysis(result);
setAnalysisMessage(result.message);
} catch (err) {
if (analysisRequestIdRef.current !== requestId || isRequestAbortError(err)) return;
console.error('Mask analysis failed:', err);
setMaskAnalysis(null);
setAnalysisMessage('后端属性读取失败');
}
};
const restoreSmoothingPreview = React.useCallback(() => {
const preview = smoothingPreviewRef.current;
if (!preview || preview.applied) {
smoothingPreviewRef.current = null;
return;
}
const state = useStore.getState();
useStore.setState({
masks: state.masks.map((mask) => (mask.id === preview.maskId ? preview.baseMask : mask)),
selectedMaskIds: state.selectedMaskIds,
});
smoothingPreviewRef.current = null;
}, []);
const applyActiveTemplateChange = React.useCallback((templateId: string | null) => {
setActiveTemplateId(templateId);
setActiveClass(null);
}, [setActiveClass, setActiveTemplateId]);
const requestActiveTemplateChange = React.useCallback((templateId: string | null) => {
if (templateId === (activeTemplateId || null)) return;
if (!activeTemplateId && templateId && templateId === activeTemplate?.id) {
applyActiveTemplateChange(templateId);
return;
}
if (masks.length === 0) {
applyActiveTemplateChange(templateId);
return;
}
setPendingTemplateSwitch({ templateId });
}, [activeTemplate?.id, activeTemplateId, applyActiveTemplateChange, masks.length]);
const confirmActiveTemplateChange = React.useCallback(async () => {
if (!pendingTemplateSwitch) return;
const nextTemplateId = pendingTemplateSwitch.templateId;
const annotationIds = Array.from(new Set(
useStore.getState().masks
.map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId)),
));
setIsSwitchingTemplate(true);
setClassSaveMessage(annotationIds.length > 0 ? '正在清空旧模板标注...' : '正在清空旧模板遮罩...');
try {
await Promise.all(annotationIds.map(deleteAnnotationIfExists));
useStore.getState().setMasks([]);
setSelectedMaskIds([]);
applyActiveTemplateChange(nextTemplateId);
setPendingTemplateSwitch(null);
setClassSaveMessage(annotationIds.length > 0
? `已清空 ${annotationIds.length} 个旧模板标注并切换激活模板`
: '已清空旧模板遮罩并切换激活模板');
} catch (err) {
console.error('Switch active template failed:', err);
setClassSaveMessage('切换模板失败,旧标注未清空');
} finally {
setIsSwitchingTemplate(false);
}
}, [applyActiveTemplateChange, pendingTemplateSwitch, setSelectedMaskIds]);
React.useEffect(() => {
return () => {
analysisRequestIdRef.current += 1;
clearSmoothingPreviewTimer();
restoreSmoothingPreview();
};
}, [clearSmoothingPreviewTimer, restoreSmoothingPreview]);
React.useEffect(() => {
const preview = smoothingPreviewRef.current;
if (preview && preview.maskId !== selectedMask?.id) {
restoreSmoothingPreview();
}
}, [restoreSmoothingPreview, selectedMask?.id]);
React.useEffect(() => {
if (skipNextAutoAnalysisRef.current) {
skipNextAutoAnalysisRef.current = false;
return;
}
void refreshMaskAnalysis();
// selectedMask is intentionally tracked by id and geometry fields to avoid
// re-running analysis for unrelated store changes.
}, [selectedMask?.id, selectedMask?.segmentation, selectedMask?.points, currentFrame?.id]);
React.useEffect(() => {
const smoothing = selectedMask?.metadata?.geometry_smoothing;
const strength = smoothing && typeof smoothing === 'object'
? Number((smoothing as Record<string, unknown>).strength)
: 0;
setSmoothingStrength(Number.isFinite(strength) ? Math.min(Math.max(strength, 0), 100) : 0);
}, [selectedMask?.id]);
const applySmoothingResultToMask = React.useCallback((
mask: Mask,
result: SmoothMaskGeometryResult,
options: { commit: boolean },
): Mask => {
const metadata = { ...(mask.metadata || {}) };
delete metadata.geometry_smoothing_preview;
if (options.commit) {
delete metadata.geometry_smoothing;
} else {
metadata.geometry_smoothing_preview = result.smoothing;
}
return {
...mask,
pathData: result.pathData,
segmentation: result.segmentation,
bbox: result.bbox,
area: result.area,
metadata,
...(options.commit
? {
saveStatus: mask.annotationId ? 'dirty' as const : 'draft' as const,
saved: mask.annotationId ? false : mask.saved,
}
: {}),
};
}, []);
const updateMaskWithSmoothingResult = React.useCallback((
maskId: string,
result: SmoothMaskGeometryResult,
options: { commit: boolean },
) => {
const state = useStore.getState();
const nextMasks = state.masks.map((mask) => (
mask.id === maskId ? applySmoothingResultToMask(mask, result, options) : mask
));
if (options.commit) {
setMasks(nextMasks);
} else {
useStore.setState({ masks: nextMasks });
}
}, [applySmoothingResultToMask, setMasks]);
const applySmoothingResultToAnalysis = React.useCallback((
result: SmoothMaskGeometryResult,
sourceMask: NonNullable<typeof selectedMask>,
suffix: string,
) => {
setMaskAnalysis({
confidence: null,
confidence_source: 'manual_or_imported',
topology_anchor_count: result.topology_anchor_count,
topology_anchors: result.topology_anchors,
area: result.area,
bbox: result.bbox,
source: sourceMask.metadata?.source as string | undefined,
message: result.message,
});
setAnalysisMessage(`${result.message}${suffix}`);
}, []);
const runSmoothingPreview = React.useCallback(async (nextStrength: number) => {
if (!selectedMask || !currentFrame) return;
const existingPreview = smoothingPreviewRef.current?.maskId === selectedMask.id
? smoothingPreviewRef.current
: null;
const baseMask = existingPreview?.baseMask || selectedMask;
const requestId = smoothingRequestIdRef.current + 1;
smoothingRequestIdRef.current = requestId;
if (nextStrength <= 0) {
clearSmoothingPreviewTimer();
smoothingPreviewRef.current = {
maskId: selectedMask.id,
baseMask,
strength: 0,
result: null,
applied: false,
requestId,
};
skipNextAutoAnalysisRef.current = true;
useStore.setState({
masks: useStore.getState().masks.map((mask) => (mask.id === selectedMask.id ? baseMask : mask)),
});
setAnalysisMessage('已预览恢复原始边缘,点击应用后写入当前 mask。');
setIsPreviewingSmoothing(false);
return;
}
setAnalysisMessage('正在生成边缘平滑预览...');
try {
const result = await smoothMaskGeometry(baseMask, currentFrame, nextStrength);
if (smoothingRequestIdRef.current !== requestId) return;
smoothingPreviewRef.current = {
maskId: selectedMask.id,
baseMask,
strength: nextStrength,
result,
applied: false,
requestId,
};
skipNextAutoAnalysisRef.current = true;
updateMaskWithSmoothingResult(selectedMask.id, result, { commit: false });
applySmoothingResultToAnalysis(result, baseMask, ',预览中,点击应用后写入当前 mask。');
} catch (err) {
if (smoothingRequestIdRef.current !== requestId) return;
console.error('Mask smoothing preview failed:', err);
setAnalysisMessage('边缘平滑预览失败,请检查后端服务');
} finally {
if (smoothingRequestIdRef.current === requestId) {
setIsPreviewingSmoothing(false);
}
}
}, [applySmoothingResultToAnalysis, clearSmoothingPreviewTimer, currentFrame, selectedMask, updateMaskWithSmoothingResult]);
const previewSmoothing = React.useCallback((nextStrength: number) => {
setSmoothingStrength(nextStrength);
clearSmoothingPreviewTimer();
if (!selectedMask || !currentFrame) return;
if (nextStrength <= 0) {
void runSmoothingPreview(nextStrength);
return;
}
setIsPreviewingSmoothing(true);
setAnalysisMessage('正在等待停止拖动后生成边缘平滑预览...');
smoothingPreviewTimerRef.current = window.setTimeout(() => {
smoothingPreviewTimerRef.current = null;
void runSmoothingPreview(nextStrength);
}, SMOOTHING_PREVIEW_DEBOUNCE_MS);
}, [clearSmoothingPreviewTimer, currentFrame, runSmoothingPreview, selectedMask]);
const handleApplySmoothing = async () => {
if (!selectedMask || !currentFrame) {
setAnalysisMessage('请选择一个 mask 后再应用边缘平滑');
return;
}
clearSmoothingPreviewTimer();
smoothingRequestIdRef.current += 1;
setIsSmoothingMask(true);
setAnalysisMessage('');
try {
const existingPreview = smoothingPreviewRef.current?.maskId === selectedMask.id
&& smoothingPreviewRef.current.strength === smoothingStrength
? smoothingPreviewRef.current
: null;
const baseMask = existingPreview?.baseMask || selectedMask;
if (smoothingStrength <= 0) {
smoothingPreviewRef.current = null;
setSmoothingStrength(0);
setAnalysisMessage('边缘平滑强度为 0当前 mask 保持原始边缘。');
return;
}
const state = useStore.getState();
const frameById = new Map(state.frames.map((frame) => [String(frame.id), frame]));
const chainMaskIds = findPropagationChainMaskIds(baseMask, state.masks);
chainMaskIds.add(selectedMask.id);
const selectedResult = existingPreview?.result || await smoothMaskGeometry(baseMask, currentFrame, smoothingStrength);
const resultEntries = new Map<string, SmoothMaskGeometryResult>();
resultEntries.set(selectedMask.id, selectedResult);
await Promise.all(
Array.from(chainMaskIds)
.filter((maskId) => maskId !== selectedMask.id)
.map(async (maskId) => {
const mask = state.masks.find((item) => item.id === maskId);
const frame = mask ? frameById.get(String(mask.frameId)) : null;
if (!mask || !frame) return;
resultEntries.set(maskId, await smoothMaskGeometry(mask, frame, smoothingStrength));
}),
);
const latestMasks = useStore.getState().masks;
const historyBaseMasks = latestMasks.map((mask) => (mask.id === selectedMask.id ? baseMask : mask));
useStore.setState({ masks: historyBaseMasks });
const nextMasks = historyBaseMasks.map((mask) => {
const result = resultEntries.get(mask.id);
if (!result) return mask;
return applySmoothingResultToMask(mask, result, { commit: true });
});
skipNextAutoAnalysisRef.current = true;
setMasks(nextMasks);
if (smoothingPreviewRef.current) {
smoothingPreviewRef.current.applied = true;
}
smoothingPreviewRef.current = null;
setSmoothingStrength(0);
applySmoothingResultToAnalysis(
selectedResult,
baseMask,
resultEntries.size > 1
? `,已同步应用到传播链 ${resultEntries.size} 个对应 mask强度已重置为 0请保存后生效`
: ',已变为新的 mask强度已重置为 0请保存后生效',
);
} catch (err) {
console.error('Mask smoothing failed:', err);
setAnalysisMessage('边缘平滑失败,请检查后端服务');
} finally {
setIsSmoothingMask(false);
}
};
const handleAddCustom = async () => {
if (!newClassName.trim()) return;
if (!activeTemplate) {
setClassSaveMessage('请先选择一个模板');
return;
}
const activeClasses = templateClasses.filter((templateClass) => !isReservedUnclassifiedClass(templateClass));
const maxZ = activeClasses.length > 0 ? Math.max(...activeClasses.map((c) => c.zIndex)) : 0;
const newClass: TemplateClass = {
id: `custom-${Date.now()}`,
name: newClassName.trim(),
color: newClassColor,
zIndex: maxZ + 10,
maskId: nextClassMaskId(templateClasses),
category: '自定义',
};
setIsSavingClass(true);
setClassSaveMessage('');
try {
const updated = await updateTemplate(activeTemplate.id, {
name: activeTemplate.name,
description: activeTemplate.description,
classes: normalizeClassMaskIds([...templateClasses, newClass]),
rules: activeTemplate.rules || [],
});
updateTemplateStore(updated);
setActiveTemplateId(updated.id);
handleSelectClass(newClass);
setNewClassName('');
setShowAddForm(false);
setClassSaveMessage('自定义分类已保存到后端模板');
} catch (err) {
console.error('Save custom class failed:', err);
setClassSaveMessage('自定义分类保存失败');
} finally {
setIsSavingClass(false);
}
};
const handleReorderClass = async (sourceClassId: string, targetClassId: string) => {
if (!activeTemplate || sourceClassId === targetClassId) {
setDragClassId(null);
return;
}
const sourceIndex = allClasses.findIndex((item) => item.id === sourceClassId);
const targetIndex = allClasses.findIndex((item) => item.id === targetClassId);
if (sourceIndex < 0 || targetIndex < 0) {
setDragClassId(null);
return;
}
if (isReservedUnclassifiedClass(allClasses[sourceIndex]) || isReservedUnclassifiedClass(allClasses[targetIndex])) {
setDragClassId(null);
return;
}
const reordered = [...allClasses];
const [source] = reordered.splice(sourceIndex, 1);
reordered.splice(targetIndex, 0, source);
const nextClasses = normalizeClassMaskIds(
reordered
.filter((item) => !isReservedUnclassifiedClass(item))
.map((item, index, activeItems) => ({
...item,
zIndex: (activeItems.length - index) * 10,
})),
);
setIsSavingClass(true);
setClassSaveMessage('正在保存分类覆盖顺序...');
try {
const updated = await updateTemplate(activeTemplate.id, {
name: activeTemplate.name,
description: activeTemplate.description,
classes: nextClasses,
rules: activeTemplate.rules || [],
});
updateTemplateStore(updated);
setActiveTemplateId(updated.id);
const zIndexByClassId = new Map(nextClasses.map((item) => [item.id, item.zIndex]));
setMasks(useStore.getState().masks.map((mask) => (
mask.classId && zIndexByClassId.has(mask.classId)
? {
...mask,
classZIndex: zIndexByClassId.get(mask.classId),
saveStatus: mask.annotationId ? 'dirty' as const : mask.saveStatus,
saved: mask.annotationId ? false : mask.saved,
}
: mask
)));
const nextActiveClass = nextClasses.find((item) => item.id === activeClassId);
if (nextActiveClass) setActiveClass(nextActiveClass);
setClassSaveMessage('分类覆盖顺序已保存');
} catch (err) {
console.error('Reorder class failed:', err);
setClassSaveMessage('分类覆盖顺序保存失败');
} finally {
setIsSavingClass(false);
setDragClassId(null);
}
};
return (
<div className="w-60 bg-[#0d0d0d] flex flex-col border-l border-white/5 shrink-0 z-10 overflow-hidden">
<div className="flex-1 overflow-y-auto seg-scrollbar p-4 flex flex-col gap-6">
{/* Template Selector */}
<div>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2"></h3>
<div className="relative">
<select
value={activeTemplate?.id || ''}
onChange={(e) => {
requestActiveTemplateChange(e.target.value || null);
}}
disabled={isSwitchingTemplate}
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-300 appearance-none cursor-pointer focus:outline-none focus:border-cyan-500/50"
>
<option value="">-- --</option>
{templates.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
<ChevronDown size={12} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none" />
</div>
{activeTemplate && (
<div className="mt-2 text-[10px] text-gray-600">
{activeTemplate.classes?.length ?? 0}
</div>
)}
</div>
{/* Workspace Mask Opacity */}
<div>
<div className="mb-2 flex items-center justify-between">
<label htmlFor="workspace-mask-opacity" className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">
</label>
<span className="text-[10px] font-mono text-cyan-400">{maskPreviewOpacity}%</span>
</div>
<input
id="workspace-mask-opacity"
aria-label="遮罩透明度"
type="range"
min={10}
max={100}
step={5}
value={maskPreviewOpacity}
onChange={(event) => setMaskPreviewOpacity(Number(event.target.value))}
className="w-full accent-cyan-500"
/>
</div>
{/* Semantic Classification Tree */}
<div>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3 flex justify-between items-center">
<span></span>
</h3>
<div className="space-y-2">
{allClasses.map(cls => (
<div key={cls.id} className="flex flex-col gap-1">
<button
type="button"
draggable={Boolean(activeTemplate) && !isSavingClass && !isReservedUnclassifiedClass(cls)}
ref={(node) => {
if (node) {
classButtonRefs.current.set(cls.id, node);
} else {
classButtonRefs.current.delete(cls.id);
}
}}
onClick={() => handleSelectClass(cls)}
onDragStart={(event) => {
if (isReservedUnclassifiedClass(cls)) return;
setDragClassId(cls.id);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', cls.id);
}}
onDragOver={(event) => {
if (!dragClassId || dragClassId === cls.id || isReservedUnclassifiedClass(cls)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}}
onDrop={(event) => {
event.preventDefault();
const sourceId = event.dataTransfer.getData('text/plain') || dragClassId;
if (sourceId) void handleReorderClass(sourceId, cls.id);
}}
onDragEnd={() => setDragClassId(null)}
aria-current={activeClassId === cls.id ? 'true' : undefined}
className={cn(
'flex items-center justify-between p-2 rounded bg-white/5 hover:bg-white/10 cursor-pointer group transition-colors text-left border',
activeClassId === cls.id ? 'border-cyan-500/50 bg-cyan-500/10' : 'border-transparent',
dragClassId === cls.id && 'opacity-50',
isReservedUnclassifiedClass(cls) && 'cursor-default',
)}
>
<div className="flex items-center gap-2">
<GripVertical size={13} className={cn("text-gray-600 group-hover:text-gray-400", isReservedUnclassifiedClass(cls) && "text-gray-800 group-hover:text-gray-800")} aria-hidden="true" />
<span className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: cls.color }} />
<span className="text-xs font-medium text-gray-200">{cls.name}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-[10px] text-gray-500 font-mono">maskid:{cls.maskId}</span>
<Eye size={14} className="text-gray-500 group-hover:text-gray-300" />
</div>
</button>
</div>
))}
{allClasses.length === 0 && (
<div className="text-xs text-gray-600 text-center py-4"></div>
)}
</div>
</div>
{/* Add Custom Class */}
<div>
<div className="flex justify-between items-center mb-2">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest"></h3>
<button
onClick={() => setShowAddForm(!showAddForm)}
className="text-cyan-400 hover:text-cyan-300 transition-colors"
>
<Plus size={12} />
</button>
</div>
{showAddForm && (
<div className="bg-[#1a1a1a] border border-white/10 rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2">
<input
type="color"
value={newClassColor}
onChange={(e) => setNewClassColor(e.target.value)}
className="w-8 h-8 rounded bg-transparent border-0 cursor-pointer"
/>
<input
type="text"
value={newClassName}
onChange={(e) => setNewClassName(e.target.value)}
placeholder="分类名称"
className="flex-1 bg-[#111] border border-white/10 rounded px-2 py-1 text-xs text-white"
onKeyDown={(e) => e.key === 'Enter' && handleAddCustom()}
/>
<button onClick={handleAddCustom} className="text-cyan-400 hover:text-cyan-300">
{isSavingClass ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
</button>
<button onClick={() => setShowAddForm(false)} className="text-gray-500 hover:text-gray-300">
<X size={14} />
</button>
</div>
</div>
)}
{classSaveMessage && (
<div className="mt-2 text-[10px] text-gray-500">{classSaveMessage}</div>
)}
</div>
{/* Current Active Object Properties */}
<div className="mt-4 pt-4 border-t border-[#222]">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3"></h3>
<div className="bg-white/5 rounded-lg p-3">
<div className="flex items-center gap-2 mb-3">
<Tag size={12} className="text-cyan-400" />
<span className="text-xs font-semibold text-gray-200">
{selectedMaskLabel}
</span>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-[10px] text-gray-500 uppercase">:</span>
<span className="text-xs font-mono text-gray-300">{maskAnalysis?.topology_anchor_count ?? 0} </span>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label htmlFor="mask-edge-smoothing" className="text-[10px] text-gray-500 uppercase">:</label>
<span className="text-xs font-mono text-gray-300">{smoothingStrength}%</span>
</div>
<input
id="mask-edge-smoothing"
aria-label="边缘平滑强度"
type="range"
min={0}
max={100}
step={5}
value={smoothingStrength}
onChange={(event) => void previewSmoothing(Number(event.target.value))}
disabled={!selectedMask || isSmoothingMask}
className="w-full accent-cyan-500 disabled:opacity-40"
/>
<button
onClick={handleApplySmoothing}
disabled={!selectedMask || !currentFrame || isSmoothingMask || isPreviewingSmoothing}
className="mt-2 w-full bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/20 text-xs text-cyan-100 py-1.5 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{isSmoothingMask ? '平滑中...' : isPreviewingSmoothing ? '预览中...' : '应用边缘平滑'}
</button>
</div>
{analysisMessage && (
<div className="text-[10px] leading-relaxed text-gray-500">{analysisMessage}</div>
)}
</div>
</div>
</div>
</div>
{pendingTemplateSwitch && (
<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-amber-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">
{masks.length} mask mask
</p>
<p className="mt-2 text-xs leading-relaxed text-amber-200/70">
mask
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => setPendingTemplateSwitch(null)}
disabled={isSwitchingTemplate}
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 confirmActiveTemplateChange()}
disabled={isSwitchingTemplate}
className="rounded bg-amber-500 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-400 disabled:opacity-60"
>
{isSwitchingTemplate ? '正在切换...' : '清空并切换'}
</button>
</div>
</div>
</div>
)}
</div>
);
}