- 新增基于 JWT 当前用户的登录恢复、角色权限、用户管理、审计日志和演示出厂重置后台接口与前端管理页。 - 重串 GT_label 导出和 GT Mask 导入逻辑:导出保留类别真实 maskid,导入仅接受灰度或 RGB 等通道 maskid 图,支持未知 maskid 策略、尺寸最近邻拉伸和导入预览。 - 统一分割结果导出体验:默认当前帧,按项目抽帧顺序和 XhXXmXXsXXXms 时间戳命名 ZIP 与图片,补齐 GT/Pro/Mix/分开 Mask 输出和映射 JSON。 - 调整工作区左侧工具栏:移除创建点/线段入口,新增画笔、橡皮擦及尺寸控制,并按绘制、布尔、导入/AI 工具分组分隔。 - 扩展 Canvas 编辑能力:画笔按语义分类绘制并可自动并入连通选中 mask,橡皮擦对选中区域扣除,优化布尔操作、选区、撤销重做和保存状态联动。 - 优化自动传播时间轴显示:同一蓝色系按传播新旧递进变暗,老传播记录达到阈值后统一旧记录色,并维护范围选择与清空后的历史显示。 - 将 AI 智能分割入口替换为更明确的 AI 元素图标,并同步侧栏、工作区和 AI 页面入口表现。 - 完善模板分类、maskid 工具函数、分类树联动、遮罩透明度、边缘平滑和传播链同步相关前端状态。 - 扩展后端项目、媒体、任务、Dashboard、模板和传播 runner 的用户隔离、任务控制、进度事件与兼容处理。 - 补充前后端测试,覆盖用户管理、GT_label 往返导入导出、GT Mask 校验和预览、画笔/橡皮擦、时间轴传播历史、导出范围、WebSocket 与 API 封装。 - 更新 AGENTS、README 和 doc 文档,记录当前接口契约、实现状态、测试计划、安装说明和 maskid/GT_label 规则。
751 lines
31 KiB
TypeScript
751 lines
31 KiB
TypeScript
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, smoothMaskGeometry, updateTemplate, type MaskAnalysisResult, type SmoothMaskGeometryResult } from '../lib/api';
|
||
import { 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 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 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 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;
|
||
}, []);
|
||
|
||
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 maxZ = templateClasses.length > 0 ? Math.max(...templateClasses.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;
|
||
}
|
||
|
||
const reordered = [...allClasses];
|
||
const [source] = reordered.splice(sourceIndex, 1);
|
||
reordered.splice(targetIndex, 0, source);
|
||
const nextClasses = normalizeClassMaskIds(
|
||
reordered.map((item, index) => ({
|
||
...item,
|
||
zIndex: (reordered.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) => {
|
||
setActiveTemplateId(e.target.value || null);
|
||
setActiveClass(null);
|
||
}}
|
||
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}
|
||
ref={(node) => {
|
||
if (node) {
|
||
classButtonRefs.current.set(cls.id, node);
|
||
} else {
|
||
classButtonRefs.current.delete(cls.id);
|
||
}
|
||
}}
|
||
onClick={() => handleSelectClass(cls)}
|
||
onDragStart={(event) => {
|
||
setDragClassId(cls.id);
|
||
event.dataTransfer.effectAllowed = 'move';
|
||
event.dataTransfer.setData('text/plain', cls.id);
|
||
}}
|
||
onDragOver={(event) => {
|
||
if (!dragClassId || dragClassId === cls.id) 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',
|
||
)}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<GripVertical size={13} className="text-gray-600 group-hover:text-gray-400" 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>
|
||
</div>
|
||
);
|
||
}
|