import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { FileDown, Redo, Undo } from 'lucide-react'; import { useStore } from '../store/useStore'; import { annotationToMask, buildAnnotationPayload, cancelTask, deleteAnnotation, exportSegmentationResults, getProjectAnnotations, getProjectFrames, getTask, getTemplates, importGtMask, queuePropagationTask, saveAnnotation, type SegmentationExportOutput, updateAnnotation, } from '../lib/api'; import { CanvasArea, type BooleanFrameRangeRequest } from './CanvasArea'; import { ToolsPalette } from './ToolsPalette'; import { OntologyInspector } from './OntologyInspector'; import { FrameTimeline } from './FrameTimeline'; 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'; import { getUndoRedoShortcut } from '../lib/keyboardShortcuts'; type PropagationDirection = 'forward' | 'backward'; type PropagationProgress = { currentStep: number; completedSteps: number; totalSteps: number; processedCount: number; createdCount: number; label: string; } | null; type PropagationHistorySegment = { id: string; startFrame: number; endFrame: number; colorIndex: number; label: string; }; type RangeSelectionMode = 'propagation' | 'export' | 'boolean' | 'clear' | null; type ClearOperationKind = 'clear' | 'delete'; type CurrentClearConfirmState = { operation: ClearOperationKind; currentFrameNumber: number; scopeLabel: string; currentMaskIds: string[]; propagatedMaskIds: string[]; currentMaskCount: number; propagatedMaskCount: number; }; type ClearPropagationRangeRequestState = CurrentClearConfirmState & { candidateFrameIds: string[]; }; type ClearPropagationRangeConfirmState = { request: ClearPropagationRangeRequestState; targetMaskIds: string[]; rangeStartIndex: number; rangeEndIndex: number; }; type ClearManualFrameConfirmState = { allMaskIds: string[]; autoOnlyMaskIds: string[]; manualFrameNumbers: number[]; manualFrameIncludeMode: 'all-frame-masks' | 'target-masks'; messageScope: string; resetRangeAfterClear: boolean; }; type BooleanRangeConfirmState = { request: BooleanFrameRangeRequest; targetFrameIds: string[]; rangeStartIndex: number; rangeEndIndex: number; }; type GtUnknownPolicy = 'discard' | 'undefined'; type ExportScope = 'all' | 'range' | 'current'; type ExportPreviewPolygon = { id: string; color: string; points: string; }; type GtMaskPreviewState = { status: 'loading' | 'ready' | 'error'; fileName: string; width?: number; height?: number; targetWidth?: number; targetHeight?: number; resized?: boolean; maskIds?: number[]; unknownMaskIds?: number[]; overlayDataUrl?: string; message?: string; validationSkipped?: boolean; }; const GT_MASK_REQUIREMENT_MESSAGE = 'GT Mask 图片不符合要求:请上传 8-bit 灰度图,或 8-bit RGB 三通道完全相同的 maskid 图(背景 0,像素值为 1-255 的 maskid)。'; const flatPolygonToSvgPoints = (polygon: number[]) => { const points: string[] = []; for (let index = 0; index < polygon.length - 1; index += 2) { points.push(`${polygon[index]},${polygon[index + 1]}`); } return points.join(' '); }; const parseHexColor = (color: string | undefined, fallback: [number, number, number] = [34, 197, 94]): [number, number, number] => { const raw = (color || '').trim().replace(/^#/, ''); const value = raw.length === 3 ? raw.split('').map((part) => part + part).join('') : raw; if (!/^[0-9a-fA-F]{6}$/.test(value)) return fallback; return [ Number.parseInt(value.slice(0, 2), 16), Number.parseInt(value.slice(2, 4), 16), Number.parseInt(value.slice(4, 6), 16), ]; }; const fallbackMaskColor = (maskId: number): [number, number, number] => { const hue = (maskId * 47) % 360; const chroma = 0.75; const lightness = 0.55; const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1)); const m = lightness - chroma / 2; const [r, g, b] = hue < 60 ? [chroma, x, 0] : hue < 120 ? [x, chroma, 0] : hue < 180 ? [0, chroma, x] : hue < 240 ? [0, x, chroma] : hue < 300 ? [x, 0, chroma] : [chroma, 0, x]; return [ Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255), ]; }; 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 prunePropagationHistoryByActiveFrames = ( segments: PropagationHistorySegment[], activeFrameNumbers: Set, 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.toLowerCase() : ''; return source.includes('propagat') || mask.metadata?.propagated_from_frame_id !== undefined || mask.metadata?.source_annotation_id !== undefined || mask.metadata?.source_mask_id !== undefined || mask.metadata?.propagation_seed_key !== undefined || mask.metadata?.propagation_seed_signature !== 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 => { const metadata = mask.metadata || {}; const tokens = new Set([`mask:${mask.id}`]); if (mask.annotationId) tokens.add(`annotation:${mask.annotationId}`); let hasStablePropagationToken = false; const sourceAnnotationId = metadataNumber(metadata.source_annotation_id); if (sourceAnnotationId !== null) { tokens.add(`annotation:${sourceAnnotationId}`); hasStablePropagationToken = true; } const sourceMaskTokens = propagationSourceMaskTokens(metadata.source_mask_id); if (sourceMaskTokens.length > 0) { sourceMaskTokens.forEach((token) => tokens.add(token)); hasStablePropagationToken = true; } if (typeof metadata.propagation_seed_key === 'string' && metadata.propagation_seed_key.length > 0) { tokens.add(`seed-key:${metadata.propagation_seed_key}`); hasStablePropagationToken = true; } if (typeof metadata.propagation_seed_signature === 'string' && metadata.propagation_seed_signature.length > 0) { tokens.add(`seed-signature:${metadata.propagation_seed_signature}`); hasStablePropagationToken = true; } if (isPropagatedMask(mask) && !hasStablePropagationToken) { const source = typeof metadata.source === 'string' ? metadata.source : ''; const classKey = mask.classId || mask.className || ''; tokens.add([ 'legacy-propagation', source, metadata.propagated_from_frame_id ?? '', metadata.propagated_from_frame_index ?? '', metadata.propagation_direction ?? '', classKey, mask.label || '', mask.color || '', ].join(':')); } return tokens; }; const findPropagationChainMaskIds = (selectedIds: string[], allMasks: Mask[]): Set => { const selectedMasks = selectedIds .map((id) => allMasks.find((mask) => mask.id === id)) .filter((mask): mask is Mask => Boolean(mask)); const selectedTokens = new Set(); 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 => { 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 booleanOperationLabel = (operation: BooleanFrameRangeRequest['operation']) => ( operation === 'area_merge' ? '区域合并' : '重叠区域去除' ); const persistentMaskMetadata = (metadata?: Record) => { 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[], projectId?: string) => { const uniqueAnnotationIds = Array.from(new Set(annotationIds.map(String))); let annotationIdsToDelete = uniqueAnnotationIds; if (projectId && uniqueAnnotationIds.length > 0) { try { const savedAnnotations = await getProjectAnnotations(projectId); const existingIds = new Set(savedAnnotations.map((annotation) => String(annotation.id))); annotationIdsToDelete = uniqueAnnotationIds.filter((annotationId) => existingIds.has(annotationId)); } catch { annotationIdsToDelete = uniqueAnnotationIds; } } if (annotationIdsToDelete.length === 0) return; const results = await Promise.allSettled(annotationIdsToDelete.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; const safeExportFilenamePart = (value: string | undefined, fallback: string) => { const text = (value || '').trim() || fallback; return text .replace(/[\\/:*?"<>|\s]+/g, '_') .replace(/_+/g, '_') .replace(/^[._]+|[._]+$/g, '') || fallback; }; const formatExportTimestamp = (value: number | undefined) => { const totalMs = Math.max(0, Math.round(Number.isFinite(value) ? Number(value) : 0)); const hours = Math.floor(totalMs / 3_600_000); const minutes = Math.floor((totalMs % 3_600_000) / 60_000); const seconds = Math.floor((totalMs % 60_000) / 1_000); const milliseconds = totalMs % 1_000; return [ `${hours}h`, `${String(minutes).padStart(2, '0')}m`, `${String(seconds).padStart(2, '0')}s`, `${String(milliseconds).padStart(3, '0')}ms`, ].join(''); }; const exportProjectFrameNumber = (frame: Frame | undefined, fallbackIndex: number) => ( (frame?.index ?? fallbackIndex) + 1 ); const buildSegmentationExportFilename = ( projectName: string | undefined, projectId: string | undefined, frames: Frame[], scope: ExportScope, startFrame: number, endFrame: number, currentFrame: Frame | null, ) => { const projectPart = safeExportFilenamePart(projectName, `project_${projectId || 'unknown'}`); if (frames.length === 0) { return `${projectPart}_seg_T_0h00m00s000ms-0h00m00s000ms_P_0-0.zip`; } let startIndex = 0; let endIndex = frames.length - 1; if (scope === 'current' && currentFrame) { const currentIndex = frames.findIndex((frame) => String(frame.id) === String(currentFrame.id)); startIndex = currentIndex >= 0 ? currentIndex : 0; endIndex = startIndex; } else if (scope === 'range') { startIndex = Math.min(Math.max(Math.min(startFrame, endFrame) - 1, 0), frames.length - 1); endIndex = Math.min(Math.max(Math.max(startFrame, endFrame) - 1, 0), frames.length - 1); } const firstFrame = frames[startIndex]; const lastFrame = frames[endIndex]; return [ projectPart, '_seg_T_', formatExportTimestamp(firstFrame?.timestampMs), '-', formatExportTimestamp(lastFrame?.timestampMs), '_P_', exportProjectFrameNumber(firstFrame, startIndex), '-', exportProjectFrameNumber(lastFrame, endIndex), '.zip', ].join(''); }; export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }) { const gtMaskInputRef = React.useRef(null); const activeTool = useStore((state) => state.activeTool); const setActiveTool = useStore((state) => state.setActiveTool); const currentProject = useStore((state) => state.currentProject); const frames = useStore((state) => state.frames); const currentFrameIndex = useStore((state) => state.currentFrameIndex); const masks = useStore((state) => state.masks); const maskHistory = useStore((state) => state.maskHistory); const maskFuture = useStore((state) => state.maskFuture); const activeTemplateId = useStore((state) => state.activeTemplateId); const aiModel = useStore((state) => state.aiModel); const selectedMaskIds = useStore((state) => state.selectedMaskIds); const latestSelectedMaskIdsRef = React.useRef(selectedMaskIds); if (selectedMaskIds.length > 0) { latestSelectedMaskIdsRef.current = selectedMaskIds; } const setFrames = useStore((state) => state.setFrames); const setCurrentFrame = useStore((state) => state.setCurrentFrame); const setMasks = useStore((state) => state.setMasks); const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds); const undoMasks = useStore((state) => state.undoMasks); const redoMasks = useStore((state) => state.redoMasks); const [isSaving, setIsSaving] = useState(false); const [isExporting, setIsExporting] = useState(false); const [clearSelectionSignal, setClearSelectionSignal] = useState(0); const [isExportMenuOpen, setIsExportMenuOpen] = useState(false); const [exportScope, setExportScope] = useState('current'); const [exportOutputs, setExportOutputs] = useState([ 'separate', 'gt_label', 'pro_label', 'mix_label', ]); const [exportMixOpacity, setExportMixOpacity] = useState(0.3); const [exportStartFrame, setExportStartFrame] = useState(1); const [exportEndFrame, setExportEndFrame] = useState(1); const [isImportingGt, setIsImportingGt] = useState(false); const [pendingGtImportFile, setPendingGtImportFile] = useState(null); const [gtMaskPreview, setGtMaskPreview] = useState(null); const [isPropagating, setIsPropagating] = useState(false); const [statusMessage, setStatusMessage] = useState(''); const [propagationStartFrame, setPropagationStartFrame] = useState(1); const [propagationEndFrame, setPropagationEndFrame] = useState(1); const [isPropagationRangeSelecting, setIsPropagationRangeSelecting] = useState(false); const [rangeSelectionMode, setRangeSelectionMode] = useState(null); const [pendingCurrentClearConfirm, setPendingCurrentClearConfirm] = useState(null); const [pendingClearPropagationRangeRequest, setPendingClearPropagationRangeRequest] = useState(null); const [pendingClearPropagationRangeConfirm, setPendingClearPropagationRangeConfirm] = useState(null); const [pendingClearManualFrameConfirm, setPendingClearManualFrameConfirm] = useState(null); const [pendingBooleanRangeRequest, setPendingBooleanRangeRequest] = useState(null); const [pendingBooleanRangeConfirm, setPendingBooleanRangeConfirm] = useState(null); const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false); const [propagationProgress, setPropagationProgress] = useState(null); const [propagationTaskId, setPropagationTaskId] = useState(null); const [propagationWeight, setPropagationWeight] = useState(aiModel || DEFAULT_AI_MODEL_ID); const [hasCustomPropagationWeight, setHasCustomPropagationWeight] = useState(false); const [propagationHistory, setPropagationHistory] = useState([]); useEffect(() => { if (!hasCustomPropagationWeight) { setPropagationWeight(aiModel || DEFAULT_AI_MODEL_ID); } }, [aiModel, hasCustomPropagationWeight]); useEffect(() => { setPropagationHistory([]); }, [currentProject?.id]); const propagationWeightLabel = useMemo( () => SAM2_MODEL_OPTIONS.find((option) => option.id === propagationWeight)?.label || propagationWeight, [propagationWeight], ); useEffect(() => { const handleWorkspaceShortcuts = (event: KeyboardEvent) => { const shortcut = getUndoRedoShortcut(event); if (!shortcut) return; event.preventDefault(); if (shortcut === 'undo') undoMasks(); else redoMasks(); }; window.addEventListener('keydown', handleWorkspaceShortcuts, true); return () => window.removeEventListener('keydown', handleWorkspaceShortcuts, true); }, [redoMasks, undoMasks]); const templates = useStore((state) => state.templates); const setTemplates = useStore((state) => state.setTemplates); const hydrateSavedAnnotations = useCallback(async ( projectId: string, projectFrames: Frame[], preserveSelectedIds: string[] = [], excludeUnsavedMaskIds: string[] = [], ) => { 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; const mask = frame ? annotationToMask(annotation, frame) : null; return mask ? normalizeMaskAgainstTemplates(mask, latestTemplates) : null; }) .filter((mask): mask is NonNullable => Boolean(mask)); const currentMasks = useStore.getState().masks; const unsavedMasks = currentMasks.filter((mask) => ( !projectFrameIds.has(mask.frameId) || (!mask.annotationId && !excludedDraftIds.has(mask.id)) )); const mergedMasks = [...unsavedMasks, ...savedMasks]; setMasks(mergedMasks); if (preserveSelectedIds.length > 0) { const mergedMaskIds = new Set(mergedMasks.map((mask) => mask.id)); const nextSelectedIds = preserveSelectedIds.filter((id) => mergedMaskIds.has(id)); if (nextSelectedIds.length > 0) { setSelectedMaskIds(nextSelectedIds); } } }, [setMasks, setSelectedMaskIds, setTemplates]); useEffect(() => { if (!currentProject?.id) return; let cancelled = false; const loadFrames = async () => { const selectedIdsBeforeLoad = latestSelectedMaskIdsRef.current; const stateBeforeLoad = useStore.getState(); const selectedFrameIdBeforeLoad = stateBeforeLoad.masks.find((mask) => ( selectedIdsBeforeLoad.includes(mask.id) && String(mask.frameId) ))?.frameId; const currentFrameBeforeLoad = stateBeforeLoad.frames[stateBeforeLoad.currentFrameIndex]; try { const data = await getProjectFrames(String(currentProject.id)); if (cancelled) return; const mappedFrames = data.map((f) => ({ id: String(f.id), projectId: String(f.project_id), index: f.frame_index, url: f.image_url, width: f.width ?? 0, height: f.height ?? 0, timestampMs: f.timestamp_ms ?? undefined, sourceFrameNumber: f.source_frame_number ?? undefined, })); setFrames(mappedFrames); if (mappedFrames.length === 0) { setCurrentFrame(0); setMasks([]); if (currentProject.status === 'parsing') { setStatusMessage('生成帧任务正在后台运行,可在 Dashboard 查看进度'); } else if (currentProject.video_path) { setStatusMessage('该项目已导入视频但尚未生成帧,请在项目库点击“生成帧”'); } else { setStatusMessage('当前项目没有可显示帧'); } return; } const currentProjectId = String(currentProject.id); const preferredFrameId = selectedFrameIdBeforeLoad || (currentFrameBeforeLoad?.projectId === currentProjectId ? currentFrameBeforeLoad.id : undefined); const preferredIndex = preferredFrameId ? mappedFrames.findIndex((frame) => frame.id === String(preferredFrameId)) : -1; const fallbackIndex = currentFrameBeforeLoad?.projectId === currentProjectId ? Math.min(Math.max(stateBeforeLoad.currentFrameIndex, 0), mappedFrames.length - 1) : 0; setCurrentFrame(preferredIndex >= 0 ? preferredIndex : fallbackIndex); setStatusMessage(''); await hydrateSavedAnnotations(String(currentProject.id), mappedFrames, selectedIdsBeforeLoad); } catch (err) { console.error('Failed to load frames:', err); } }; loadFrames(); return () => { cancelled = true; }; }, [currentProject?.id, currentProject?.video_path, hydrateSavedAnnotations, setFrames, setCurrentFrame]); useEffect(() => { if (templates.length === 0) { getTemplates().then((data) => setTemplates(data)).catch(console.error); } }, [templates.length, setTemplates]); const currentFrame = frames[currentFrameIndex] || null; const totalFrames = frames.length; const activeTemplateForGt = useMemo(() => ( templates.find((template) => String(template.id) === String(activeTemplateId)) || templates[0] || null ), [activeTemplateId, templates]); const gtTemplateClasses = useMemo(() => ( normalizeClassMaskIds(activeTemplateForGt?.classes || []) ), [activeTemplateForGt?.classes]); const frameById = useMemo(() => new Map(frames.map((frame) => [frame.id, frame])), [frames]); const frameNumberById = useMemo(() => new Map(frames.map((frame, index) => [String(frame.id), index + 1])), [frames]); const projectFrameIds = useMemo(() => new Set(frames.map((frame) => frame.id)), [frames]); const currentFrameNumber = currentFrameIndex + 1; const pendingAnnotationChangeCount = useMemo(() => ( masks.filter((mask) => projectFrameIds.has(mask.frameId) && (!mask.annotationId || mask.saveStatus === 'dirty')).length ), [masks, projectFrameIds]); const isWorkspaceBusy = isSaving || isExporting || isImportingGt || isPropagating || Boolean(propagationProgress); const exportPreviewFrame = useMemo(() => { if (exportScope === 'current') return currentFrame; if (exportScope === 'range') { const index = Math.min(Math.max(Math.min(exportStartFrame, exportEndFrame) - 1, 0), Math.max(totalFrames - 1, 0)); return frames[index] || currentFrame; } return frames[0] || currentFrame; }, [currentFrame, exportEndFrame, exportScope, exportStartFrame, frames, totalFrames]); const exportPreviewPolygons = useMemo(() => { if (!exportPreviewFrame) return []; return masks .filter((mask) => String(mask.frameId) === String(exportPreviewFrame.id)) .flatMap((mask) => (mask.segmentation || []).map((polygon, polygonIndex) => ({ id: `${mask.id}-${polygonIndex}`, color: mask.color, points: flatPolygonToSvgPoints(polygon), }))); }, [exportPreviewFrame, masks]); useEffect(() => { if (propagationHistory.length === 0 || frames.length === 0) return; const activePropagatedFrameNumbers = new Set(); 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); } }, [frameNumberById, frames.length, masks, propagationHistory, totalFrames]); useEffect(() => { if (!statusMessage || isWorkspaceBusy || totalFrames === 0) return undefined; const timer = window.setTimeout(() => setStatusMessage(''), STATUS_MESSAGE_TTL_MS); return () => window.clearTimeout(timer); }, [isWorkspaceBusy, statusMessage, totalFrames]); useEffect(() => { if (totalFrames === 0) { setPropagationStartFrame(1); setPropagationEndFrame(1); setExportStartFrame(1); setExportEndFrame(1); return; } setPropagationStartFrame(currentFrameNumber); setPropagationEndFrame(Math.min(totalFrames, currentFrameNumber + 29)); setExportStartFrame(currentFrameNumber); setExportEndFrame(Math.min(totalFrames, currentFrameNumber + 29)); setIsPropagationRangeSelecting(false); setRangeSelectionMode(null); setPendingBooleanRangeRequest(null); setPendingBooleanRangeConfirm(null); setHasExplicitPropagationRange(false); }, [currentFrameNumber, totalFrames]); const savePendingAnnotations = useCallback(async ({ silent = false, frameId }: { silent?: boolean; frameId?: string } = {}) => { if (!currentProject?.id) return 0; 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) { if (!silent) setStatusMessage('没有待保存标注'); return 0; } setIsSaving(true); setStatusMessage('正在保存标注...'); try { const createItems = pendingMasks .map((mask) => { const frame = frameById.get(mask.frameId); const payload = frame ? buildAnnotationPayload(currentProject.id, mask, frame, activeTemplateId) : null; return payload ? { maskId: mask.id, payload } : null; }) .filter((item): item is NonNullable => Boolean(item)); const updateItems = dirtyMasks .map((mask) => { const frame = frameById.get(mask.frameId); const payload = frame ? buildAnnotationPayload(currentProject.id, mask, frame, activeTemplateId) : null; if (!payload || !mask.annotationId) return null; const savedMetadata = persistentMaskMetadata(mask.metadata); const mergedMaskData = { ...savedMetadata, ...payload.mask_data }; const updatePayload = { template_id: payload.template_id, mask_data: mergedMaskData, points: payload.points, bbox: payload.bbox, }; const createPayload = { ...payload, mask_data: mergedMaskData, }; return { maskId: mask.id, annotationId: mask.annotationId, updatePayload, createPayload }; }) .filter((item): item is NonNullable => Boolean(item)); if (createItems.length === 0 && updateItems.length === 0) { setStatusMessage('没有可保存的标注数据'); return 0; } let existingAnnotationIds: Set | null = null; if (updateItems.length > 0) { try { const annotations = await getProjectAnnotations(currentProject.id); existingAnnotationIds = new Set(annotations.map((annotation) => String(annotation.id))); } catch { existingAnnotationIds = null; } } let recreatedMissingCount = 0; const recreatedMaskIds: string[] = []; await Promise.all([ ...createItems.map(({ payload }) => saveAnnotation(payload)), ...updateItems.map(async ({ maskId, annotationId, updatePayload, createPayload }) => { if (existingAnnotationIds && !existingAnnotationIds.has(String(annotationId))) { recreatedMissingCount += 1; recreatedMaskIds.push(maskId); await saveAnnotation(createPayload); return; } try { await updateAnnotation(annotationId, updatePayload); } catch (error) { if (!isNotFoundError(error)) throw error; recreatedMissingCount += 1; recreatedMaskIds.push(maskId); await saveAnnotation(createPayload); } }), ]); await hydrateSavedAnnotations( currentProject.id, frames, useStore.getState().selectedMaskIds, [...createItems.map(({ maskId }) => maskId), ...recreatedMaskIds], ); const savedCount = createItems.length + updateItems.length; setStatusMessage(recreatedMissingCount > 0 ? `已保存 ${savedCount} 个标注,其中 ${recreatedMissingCount} 个本地旧标注已重新创建` : `已保存 ${savedCount} 个标注`); return savedCount; } catch (err) { console.error('Save annotations failed:', err); setStatusMessage('保存失败,请检查后端服务'); throw err; } finally { setIsSaving(false); } }, [activeTemplateId, currentProject?.id, frameById, frames, hydrateSavedAnnotations, projectFrameIds]); const executeClearCurrentMasks = useCallback(async (maskIdsToClear: string[], messageScope: string) => { const latestMasks = useStore.getState().masks; const maskIdSet = new Set(maskIdsToClear); const masksToClear = latestMasks.filter((mask) => maskIdSet.has(mask.id)); if (masksToClear.length === 0) { setStatusMessage('没有可清空的遮罩'); setPendingCurrentClearConfirm(null); setPendingClearPropagationRangeRequest(null); setPendingClearPropagationRangeConfirm(null); setPendingClearManualFrameConfirm(null); return; } const annotationIds = Array.from(new Set( masksToClear .map((mask) => mask.annotationId) .filter((annotationId): annotationId is string => Boolean(annotationId)), )); setIsSaving(true); setStatusMessage(annotationIds.length > 0 ? `正在删除${messageScope}的已保存标注...` : `正在清空${messageScope}的本地遮罩...`); try { await deleteAnnotationsIfExist(annotationIds, currentProject?.id); const afterDeleteMasks = useStore.getState().masks.filter((mask) => !maskIdSet.has(mask.id)); setMasks(afterDeleteMasks); setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !maskIdSet.has(id))); setStatusMessage(annotationIds.length > 0 ? `已清空${messageScope}的 ${masksToClear.length} 个遮罩,其中后端标注 ${annotationIds.length} 个` : `已清空${messageScope}的 ${masksToClear.length} 个本地遮罩`); setPendingCurrentClearConfirm(null); setPendingClearPropagationRangeRequest(null); setPendingClearPropagationRangeConfirm(null); setPendingClearManualFrameConfirm(null); } catch (err) { console.error('Delete annotations failed:', err); setStatusMessage('删除失败,请检查后端服务'); } finally { setIsSaving(false); } }, [currentProject?.id, setMasks, setSelectedMaskIds]); const handleClearCurrentFrameMasks = useCallback(async () => { if (!currentFrame) return; const latestMasks = useStore.getState().masks; const frameMasks = latestMasks.filter((mask) => String(mask.frameId) === String(currentFrame.id)); if (frameMasks.length === 0) { setStatusMessage('当前帧没有可清空的遮罩'); return; } const selectedIdSet = new Set(useStore.getState().selectedMaskIds); const selectedFrameMasks = frameMasks.filter((mask) => selectedIdSet.has(mask.id)); const targetFrameMasks = selectedFrameMasks.length > 0 ? selectedFrameMasks : frameMasks; const scopeLabel = selectedFrameMasks.length > 0 ? `第 ${currentFrameNumber} 帧选中 mask` : `第 ${currentFrameNumber} 帧`; const currentMaskIds = targetFrameMasks.map((mask) => mask.id); const propagatedMaskIds = Array.from(expandedPropagationDeletionMaskIds(currentMaskIds, latestMasks)); const propagatedOutsideCurrentCount = latestMasks.filter((mask) => ( propagatedMaskIds.includes(mask.id) && String(mask.frameId) !== String(currentFrame.id) )).length; if (propagatedOutsideCurrentCount === 0) { await executeClearCurrentMasks(currentMaskIds, scopeLabel); return; } setPendingCurrentClearConfirm({ operation: 'clear', currentFrameNumber, scopeLabel, currentMaskIds, propagatedMaskIds, currentMaskCount: currentMaskIds.length, propagatedMaskCount: propagatedMaskIds.length, }); }, [currentFrame, currentFrameNumber, executeClearCurrentMasks]); const handleClearSelection = useCallback(() => { setSelectedMaskIds([]); setClearSelectionSignal((value) => value + 1); }, [setSelectedMaskIds]); const handleDeleteSelectedMasks = useCallback(async (requestedMaskIds?: string[]) => { if (!currentFrame) return; const latestMasks = useStore.getState().masks; const selectedIdSet = new Set(requestedMaskIds && requestedMaskIds.length > 0 ? requestedMaskIds : useStore.getState().selectedMaskIds); const selectedFrameMasks = latestMasks.filter((mask) => ( String(mask.frameId) === String(currentFrame.id) && selectedIdSet.has(mask.id) )); if (selectedFrameMasks.length === 0) { setStatusMessage('请先选择要删除的遮罩'); return; } const currentMaskIds = selectedFrameMasks.map((mask) => mask.id); const scopeLabel = `第 ${currentFrameNumber} 帧选中 mask`; const propagatedMaskIds = Array.from(expandedPropagationDeletionMaskIds(currentMaskIds, latestMasks)); const propagatedOutsideCurrentCount = latestMasks.filter((mask) => ( propagatedMaskIds.includes(mask.id) && String(mask.frameId) !== String(currentFrame.id) )).length; if (propagatedOutsideCurrentCount === 0) { await executeClearCurrentMasks(currentMaskIds, scopeLabel); return; } setPendingCurrentClearConfirm({ operation: 'delete', currentFrameNumber, scopeLabel, currentMaskIds, propagatedMaskIds, currentMaskCount: currentMaskIds.length, propagatedMaskCount: propagatedMaskIds.length, }); }, [currentFrame, currentFrameNumber, executeClearCurrentMasks]); const resetClearPropagationRangeSelection = useCallback(() => { setPendingClearPropagationRangeConfirm(null); setPendingClearPropagationRangeRequest(null); setIsPropagationRangeSelecting(false); setRangeSelectionMode((currentMode) => (currentMode === 'clear' ? null : currentMode)); setHasExplicitPropagationRange(false); }, []); const requestClearMasksWithManualFrameConfirm = useCallback(( maskIdsToClear: string[], messageScope: string, options?: { manualFrameScopeIds?: string[]; manualFrameTargetMaskIds?: string[]; manualFrameIncludeMode?: 'all-frame-masks' | 'target-masks'; resetRangeAfterClear?: boolean; }, ) => { const latestMasks = useStore.getState().masks; const targetMaskIdSet = new Set(maskIdsToClear); const manualTargetMaskIdSet = new Set([...(options?.manualFrameTargetMaskIds || []), ...maskIdsToClear]); const targetMasks = latestMasks.filter((mask) => targetMaskIdSet.has(mask.id)); const targetFrameIds = new Set(targetMasks.map((mask) => String(mask.frameId))); const manualScopeFrameIds = new Set((options?.manualFrameScopeIds || Array.from(targetFrameIds)).map(String)); const includeAllManualFrameMasks = options?.manualFrameIncludeMode !== 'target-masks'; const manualFrameIds = new Set( latestMasks .filter((mask) => manualScopeFrameIds.has(String(mask.frameId)) && !isPropagatedMask(mask)) .map((mask) => String(mask.frameId)), ); if (manualFrameIds.size === 0) { void executeClearCurrentMasks(maskIdsToClear, messageScope).then(() => { if (options?.resetRangeAfterClear) resetClearPropagationRangeSelection(); }); return; } const manualFrameNumbers = Array.from(manualFrameIds) .map((frameId) => frameNumberById.get(frameId)) .filter((frameNumber): frameNumber is number => Boolean(frameNumber)) .sort((a, b) => a - b); const manualFrameMaskIds = latestMasks .filter((mask) => ( manualFrameIds.has(String(mask.frameId)) && (includeAllManualFrameMasks || manualTargetMaskIdSet.has(mask.id)) )) .map((mask) => mask.id); const autoOnlyMaskIds = targetMasks .filter((mask) => !manualFrameIds.has(String(mask.frameId))) .map((mask) => mask.id); const allMaskIds = Array.from(new Set([...autoOnlyMaskIds, ...manualFrameMaskIds])); setPendingCurrentClearConfirm(null); setPendingClearManualFrameConfirm({ allMaskIds, autoOnlyMaskIds, manualFrameNumbers, manualFrameIncludeMode: includeAllManualFrameMasks ? 'all-frame-masks' : 'target-masks', messageScope, resetRangeAfterClear: Boolean(options?.resetRangeAfterClear), }); }, [executeClearCurrentMasks, frameNumberById, resetClearPropagationRangeSelection]); const propagationSpanFrameIdsForClearRequest = useCallback((request: CurrentClearConfirmState): string[] | undefined => { const latestMasks = useStore.getState().masks; const spanMaskIds = new Set([ ...request.currentMaskIds, ...request.propagatedMaskIds, ...Array.from(findPropagationChainMaskIds(request.currentMaskIds, latestMasks)), ]); const spanFrameNumbers = latestMasks .filter((mask) => spanMaskIds.has(mask.id)) .map((mask) => frameNumberById.get(String(mask.frameId))) .filter((frameNumber): frameNumber is number => Boolean(frameNumber)); if (spanFrameNumbers.length === 0) return undefined; const startIndex = Math.max(0, Math.min(...spanFrameNumbers) - 1); const endIndex = Math.min(frames.length - 1, Math.max(...spanFrameNumbers) - 1); if (endIndex < startIndex) return undefined; return frames.slice(startIndex, endIndex + 1).map((frame) => String(frame.id)); }, [frameNumberById, frames]); const chainMaskIdsForClearRequest = useCallback((request: CurrentClearConfirmState): string[] => { const latestMasks = useStore.getState().masks; return Array.from(findPropagationChainMaskIds(request.currentMaskIds, latestMasks)); }, []); const handleResolveClearManualFrameConfirm = useCallback(async (includeManualFrames: boolean) => { if (!pendingClearManualFrameConfirm) return; const targetMaskIds = includeManualFrames ? pendingClearManualFrameConfirm.allMaskIds : pendingClearManualFrameConfirm.autoOnlyMaskIds; if (targetMaskIds.length === 0) { setPendingClearManualFrameConfirm(null); if (pendingClearManualFrameConfirm.resetRangeAfterClear) resetClearPropagationRangeSelection(); setStatusMessage('已保留人工/AI 标注帧,本次没有其它可清空的自动传播遮罩'); return; } await executeClearCurrentMasks( targetMaskIds, includeManualFrames ? pendingClearManualFrameConfirm.messageScope : `${pendingClearManualFrameConfirm.messageScope}中的自动传播帧`, ); if (pendingClearManualFrameConfirm.resetRangeAfterClear) resetClearPropagationRangeSelection(); }, [executeClearCurrentMasks, pendingClearManualFrameConfirm, resetClearPropagationRangeSelection]); const handleStartClearPropagationRange = useCallback((request: CurrentClearConfirmState) => { const latestMasks = useStore.getState().masks; const propagatedIdSet = new Set(request.propagatedMaskIds); const candidateFrameIds = Array.from(new Set( latestMasks .filter((mask) => propagatedIdSet.has(mask.id)) .map((mask) => String(mask.frameId)), )); const candidateFrameNumbers = candidateFrameIds .map((frameId) => frameNumberById.get(String(frameId))) .filter((frameNumber): frameNumber is number => Boolean(frameNumber)); if (candidateFrameNumbers.length === 0) { setStatusMessage('没有可按帧范围清空的传播帧'); return; } const startFrame = Math.min(...candidateFrameNumbers); const endFrame = Math.max(...candidateFrameNumbers); setPendingCurrentClearConfirm(null); setPendingClearPropagationRangeRequest({ ...request, candidateFrameIds }); setPendingClearPropagationRangeConfirm(null); setPropagationStartFrame(startFrame); setPropagationEndFrame(endFrame); setHasExplicitPropagationRange(true); setIsPropagationRangeSelecting(true); setRangeSelectionMode('clear'); setStatusMessage('请选择清空传播帧范围,再点击“确认清空”'); }, [frameNumberById]); const handleConfirmClearPropagationFrameRange = useCallback(() => { if (!pendingClearPropagationRangeRequest || frames.length === 0) return; const clampRangeFrameNumber = (value: number) => { if (totalFrames <= 0) return 1; return Math.min(Math.max(value, 1), totalFrames); }; const startFrameNumber = clampRangeFrameNumber(propagationStartFrame); const endFrameNumber = clampRangeFrameNumber(propagationEndFrame); const rangeStartIndex = Math.min(startFrameNumber, endFrameNumber) - 1; const rangeEndIndex = Math.max(startFrameNumber, endFrameNumber) - 1; const rangeFrameIds = new Set( frames.slice(rangeStartIndex, rangeEndIndex + 1).map((frame) => String(frame.id)), ); const candidateFrameIdSet = new Set(pendingClearPropagationRangeRequest.candidateFrameIds.map(String)); const latestMasks = useStore.getState().masks; const targetMaskIds = pendingClearPropagationRangeRequest.propagatedMaskIds .filter((maskId) => { const mask = latestMasks.find((item) => item.id === maskId); return Boolean(mask && rangeFrameIds.has(String(mask.frameId)) && candidateFrameIdSet.has(String(mask.frameId))); }); if (targetMaskIds.length === 0) { setStatusMessage(`第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧没有可清空的传播链遮罩`); return; } setPendingClearPropagationRangeConfirm({ request: pendingClearPropagationRangeRequest, targetMaskIds, rangeStartIndex, rangeEndIndex, }); }, [frames, pendingClearPropagationRangeRequest, propagationEndFrame, propagationStartFrame, totalFrames]); const executeClearPropagationFrameRange = useCallback(async (confirmState: ClearPropagationRangeConfirmState) => { const manualFrameScopeIds = frames .slice(confirmState.rangeStartIndex, confirmState.rangeEndIndex + 1) .map((frame) => String(frame.id)); requestClearMasksWithManualFrameConfirm( confirmState.targetMaskIds, `第 ${confirmState.rangeStartIndex + 1}-${confirmState.rangeEndIndex + 1} 帧传播链`, { manualFrameScopeIds, manualFrameTargetMaskIds: confirmState.request.operation === 'delete' ? chainMaskIdsForClearRequest(confirmState.request) : undefined, manualFrameIncludeMode: confirmState.request.operation === 'delete' ? 'target-masks' : 'all-frame-masks', resetRangeAfterClear: true, }, ); setPendingClearPropagationRangeConfirm(null); }, [chainMaskIdsForClearRequest, frames, requestClearMasksWithManualFrameConfirm]); const handleBooleanFrameRangeRequest = useCallback((request: BooleanFrameRangeRequest) => { const candidateFrameNumbers = request.candidateFrameIds .map((frameId) => frameNumberById.get(String(frameId))) .filter((frameNumber): frameNumber is number => Boolean(frameNumber)); if (candidateFrameNumbers.length === 0) { setStatusMessage(`${booleanOperationLabel(request.operation)}没有可按范围处理的传播帧`); return; } const startFrame = Math.min(...candidateFrameNumbers); const endFrame = Math.max(...candidateFrameNumbers); setPendingBooleanRangeRequest(request); setPendingBooleanRangeConfirm(null); setPropagationStartFrame(startFrame); setPropagationEndFrame(endFrame); setHasExplicitPropagationRange(true); setIsPropagationRangeSelecting(true); setRangeSelectionMode('boolean'); setStatusMessage(`请选择${booleanOperationLabel(request.operation)}帧范围,再点击“确认${booleanOperationLabel(request.operation)}”`); }, [frameNumberById]); const clearPendingBooleanRangeSelection = useCallback(() => { setPendingBooleanRangeRequest(null); setPendingBooleanRangeConfirm(null); if (rangeSelectionMode === 'boolean') { setRangeSelectionMode(null); setIsPropagationRangeSelecting(false); setHasExplicitPropagationRange(false); setStatusMessage(''); } }, [rangeSelectionMode]); const handleConfirmBooleanFrameRangeOperation = useCallback(() => { if (!pendingBooleanRangeRequest || frames.length === 0) return; const clampRangeFrameNumber = (value: number) => { if (totalFrames <= 0) return 1; return Math.min(Math.max(value, 1), totalFrames); }; const startFrameNumber = clampRangeFrameNumber(propagationStartFrame); const endFrameNumber = clampRangeFrameNumber(propagationEndFrame); const rangeStartIndex = Math.min(startFrameNumber, endFrameNumber) - 1; const rangeEndIndex = Math.max(startFrameNumber, endFrameNumber) - 1; const rangeFrameIds = new Set( frames.slice(rangeStartIndex, rangeEndIndex + 1).map((frame) => String(frame.id)), ); const targetFrameIds = pendingBooleanRangeRequest.candidateFrameIds .filter((frameId) => rangeFrameIds.has(String(frameId))) .sort((a, b) => (frameNumberById.get(String(a)) || 0) - (frameNumberById.get(String(b)) || 0)); if (targetFrameIds.length === 0) { setStatusMessage(`第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧没有可执行${booleanOperationLabel(pendingBooleanRangeRequest.operation)}的对应传播区域`); return; } setPendingBooleanRangeConfirm({ request: pendingBooleanRangeRequest, targetFrameIds, rangeStartIndex, rangeEndIndex, }); }, [frameNumberById, frames, pendingBooleanRangeRequest, propagationEndFrame, propagationStartFrame, totalFrames]); const executeBooleanFrameRangeOperation = useCallback(async (confirmState: BooleanRangeConfirmState) => { const label = booleanOperationLabel(confirmState.request.operation); setIsSaving(true); setStatusMessage(`正在对第 ${confirmState.rangeStartIndex + 1}-${confirmState.rangeEndIndex + 1} 帧执行${label}...`); try { await confirmState.request.execute(new Set(confirmState.targetFrameIds)); setPendingBooleanRangeConfirm(null); setPendingBooleanRangeRequest(null); setIsPropagationRangeSelecting(false); setRangeSelectionMode(null); setHasExplicitPropagationRange(false); setStatusMessage(`已对第 ${confirmState.rangeStartIndex + 1}-${confirmState.rangeEndIndex + 1} 帧内 ${confirmState.targetFrameIds.length} 帧执行${label}`); } catch (err) { console.error('Boolean frame range operation failed:', err); setStatusMessage(`${label}失败,请检查后端服务`); } finally { setIsSaving(false); } }, []); const handleDeleteMaskAnnotations = useCallback(async (annotationIds: string[]) => { if (annotationIds.length === 0) return; try { await deleteAnnotationsIfExist(annotationIds, currentProject?.id); setStatusMessage(`已删除 ${annotationIds.length} 个标注`); } catch (err) { console.error('Delete annotations failed:', err); setStatusMessage('删除标注失败,请检查后端服务'); throw err; } }, [currentProject?.id]); const handleSave = async () => { try { await savePendingAnnotations(); } catch { // status message is set in savePendingAnnotations } }; const downloadBlob = (blob: Blob, filename: string) => { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); }; const handleExportSegmentationResults = async () => { if (!currentProject?.id) return; setIsExporting(true); setStatusMessage('正在准备分割结果导出...'); try { await savePendingAnnotations({ silent: true }); const start = totalFrames > 0 ? Math.min(Math.max(exportStartFrame, 1), totalFrames) : 1; const end = totalFrames > 0 ? Math.min(Math.max(exportEndFrame, 1), totalFrames) : 1; const exportFilename = buildSegmentationExportFilename( currentProject.name, currentProject.id, frames, exportScope, start, end, currentFrame, ); const blob = await exportSegmentationResults(currentProject.id, { scope: exportScope, outputs: exportOutputs, mixOpacity: exportMixOpacity, startFrame: exportScope === 'range' ? Math.min(start, end) : undefined, endFrame: exportScope === 'range' ? Math.max(start, end) : undefined, frameId: exportScope === 'current' ? currentFrame?.id : undefined, }); downloadBlob(blob, exportFilename); setIsExportMenuOpen(false); if (rangeSelectionMode === 'export') { setIsPropagationRangeSelecting(false); setRangeSelectionMode(null); } setStatusMessage('分割结果 ZIP 已导出'); } catch (err) { console.error('Segmentation results export failed:', err); setStatusMessage('分割结果导出失败,请检查后端服务'); } finally { setIsExporting(false); } }; const prepareGtMaskPreview = useCallback(async (file: File) => { const frameWidth = currentFrame?.width || 0; const frameHeight = currentFrame?.height || 0; setGtMaskPreview({ status: 'loading', fileName: file.name }); if (typeof window.createImageBitmap !== 'function') { setGtMaskPreview({ status: 'ready', fileName: file.name, message: '当前环境无法生成本地预览,导入时仍会由后端校验 GT Mask 格式。', validationSkipped: true, }); return; } try { const bitmap = await window.createImageBitmap(file); const sourceWidth = bitmap.width; const sourceHeight = bitmap.height; const sourceCanvas = document.createElement('canvas'); sourceCanvas.width = sourceWidth; sourceCanvas.height = sourceHeight; const sourceContext = sourceCanvas.getContext('2d', { willReadFrequently: true }); if (!sourceContext) { bitmap.close?.(); setGtMaskPreview({ status: 'ready', fileName: file.name, width: sourceWidth, height: sourceHeight, message: '当前环境无法读取像素生成预览,导入时仍会由后端校验 GT Mask 格式。', validationSkipped: true, }); return; } sourceContext.imageSmoothingEnabled = false; sourceContext.drawImage(bitmap, 0, 0); const sourcePixels = sourceContext.getImageData(0, 0, sourceWidth, sourceHeight).data; const maskIds = new Set(); for (let index = 0; index < sourcePixels.length; index += 4) { const alpha = sourcePixels[index + 3]; if (alpha === 0) continue; const red = sourcePixels[index]; const green = sourcePixels[index + 1]; const blue = sourcePixels[index + 2]; if (red !== green || green !== blue) { bitmap.close?.(); setGtMaskPreview({ status: 'error', fileName: file.name, width: sourceCanvas.width, height: sourceCanvas.height, message: GT_MASK_REQUIREMENT_MESSAGE, }); return; } if (red > 0) maskIds.add(red); } if (maskIds.size === 0) { bitmap.close?.(); setGtMaskPreview({ status: 'error', fileName: file.name, width: sourceCanvas.width, height: sourceCanvas.height, message: 'GT Mask 图片中没有非背景 maskid 区域。', }); return; } const targetWidth = frameWidth > 0 ? frameWidth : sourceWidth; const targetHeight = frameHeight > 0 ? frameHeight : sourceHeight; const targetCanvas = document.createElement('canvas'); targetCanvas.width = targetWidth; targetCanvas.height = targetHeight; const targetContext = targetCanvas.getContext('2d', { willReadFrequently: true }); const overlayCanvas = document.createElement('canvas'); overlayCanvas.width = targetWidth; overlayCanvas.height = targetHeight; const overlayContext = overlayCanvas.getContext('2d'); if (!targetContext || !overlayContext) { bitmap.close?.(); setGtMaskPreview({ status: 'ready', fileName: file.name, width: sourceWidth, height: sourceHeight, targetWidth, targetHeight, resized: sourceWidth !== targetWidth || sourceHeight !== targetHeight, maskIds: Array.from(maskIds).sort((a, b) => a - b), message: '当前环境无法生成图像预览,导入时仍会由后端校验 GT Mask 格式。', validationSkipped: true, }); return; } targetContext.imageSmoothingEnabled = false; targetContext.drawImage(bitmap, 0, 0, targetWidth, targetHeight); bitmap.close?.(); const targetImage = targetContext.getImageData(0, 0, targetWidth, targetHeight); const overlayImage = overlayContext.createImageData(targetWidth, targetHeight); const classesByMaskId = classByMaskId(gtTemplateClasses); for (let index = 0; index < targetImage.data.length; index += 4) { const maskId = targetImage.data[index]; const alpha = targetImage.data[index + 3]; if (maskId <= 0 || alpha === 0) continue; const templateClass = classesByMaskId.get(maskId); const [red, green, blue] = templateClass ? parseHexColor(templateClass.color) : fallbackMaskColor(maskId); overlayImage.data[index] = red; overlayImage.data[index + 1] = green; overlayImage.data[index + 2] = blue; overlayImage.data[index + 3] = 150; } overlayContext.putImageData(overlayImage, 0, 0); const sortedMaskIds = Array.from(maskIds).sort((a, b) => a - b); const unknownMaskIds = gtTemplateClasses.length > 0 ? sortedMaskIds.filter((maskId) => !classesByMaskId.has(maskId)) : []; const resized = sourceWidth !== targetWidth || sourceHeight !== targetHeight; setGtMaskPreview({ status: 'ready', fileName: file.name, width: sourceCanvas.width, height: sourceCanvas.height, targetWidth, targetHeight, resized, maskIds: sortedMaskIds, unknownMaskIds, overlayDataUrl: overlayCanvas.toDataURL('image/png'), message: resized ? `检测到 GT Mask 尺寸 ${sourceCanvas.width}x${sourceCanvas.height},导入时将按当前帧 ${targetWidth}x${targetHeight} 最近邻拉伸适配。` : `检测到 ${sortedMaskIds.length} 个 maskid:${sortedMaskIds.join(', ')}`, }); } catch (err) { console.error('GT mask preview failed:', err); setGtMaskPreview({ status: 'error', fileName: file.name, message: '无法读取 GT Mask 图片,请确认文件是可解码的图片。', }); } }, [currentFrame?.height, currentFrame?.width, gtTemplateClasses]); const executeImportGtMask = async (file: File, unknownColorPolicy: GtUnknownPolicy) => { if (!currentProject?.id || !currentFrame?.id) return; setPendingGtImportFile(null); setGtMaskPreview(null); setIsImportingGt(true); setStatusMessage('正在导入 GT Mask...'); try { const imported = await importGtMask(file, currentProject.id, currentFrame.id, activeTemplateId, { unknownColorPolicy, }); await hydrateSavedAnnotations(currentProject.id, frames); setStatusMessage(`已导入 ${imported.length} 个 GT 区域`); } catch (err) { console.error('GT mask import failed:', err); const detail = (err as any)?.response?.data?.detail; setStatusMessage(detail ? `GT Mask 导入失败:${detail}` : 'GT Mask 导入失败,请检查文件、未知类别策略或后端服务'); } finally { setIsImportingGt(false); } }; const handleImportGtMask = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file || !currentProject?.id || !currentFrame?.id) return; setPendingGtImportFile(file); void prepareGtMaskPreview(file); event.target.value = ''; }; const clampFrameNumber = useCallback((value: number) => { if (totalFrames <= 0) return 1; return Math.min(Math.max(value, 1), totalFrames); }, [totalFrames]); const buildSeedPayload = useCallback((seedMask: Mask) => { if (!currentProject?.id || !currentFrame) return null; const seedPayload = buildAnnotationPayload(currentProject.id, seedMask, currentFrame, activeTemplateId); if (!seedPayload?.mask_data?.polygons?.length && !seedPayload?.bbox) { return null; } const metadataSourceAnnotationId = Number(seedMask.metadata?.source_annotation_id); const sourceAnnotationId = Number.isFinite(metadataSourceAnnotationId) && metadataSourceAnnotationId > 0 ? metadataSourceAnnotationId : seedMask.annotationId && /^\d+$/.test(seedMask.annotationId) ? Number(seedMask.annotationId) : undefined; const metadataSourceMaskId = typeof seedMask.metadata?.source_mask_id === 'string' ? seedMask.metadata.source_mask_id : undefined; const inheritedSeedSignature = typeof seedMask.metadata?.propagation_seed_signature === 'string' ? seedMask.metadata.propagation_seed_signature : undefined; const rawSmoothing = seedPayload.mask_data?.geometry_smoothing || (seedMask.metadata?.geometry_smoothing && typeof seedMask.metadata.geometry_smoothing === 'object' ? seedMask.metadata.geometry_smoothing : undefined); const smoothingStrength = rawSmoothing && typeof rawSmoothing === 'object' ? Number((rawSmoothing as Record).strength) : NaN; const geometrySmoothing = Number.isFinite(smoothingStrength) && smoothingStrength > 0 ? { strength: Math.min(Math.max(smoothingStrength, 0), 100), method: 'chaikin' as const, } : undefined; return { polygons: seedPayload.mask_data?.polygons, holes: seedPayload.mask_data?.holes, bbox: seedPayload.bbox, points: seedPayload.points, label: seedPayload.mask_data?.label, color: seedPayload.mask_data?.color, class_metadata: seedPayload.mask_data?.class, template_id: seedPayload.template_id, source_mask_id: metadataSourceMaskId || seedMask.id, source_annotation_id: sourceAnnotationId, propagation_seed_signature: inheritedSeedSignature, smoothing: geometrySmoothing, }; }, [activeTemplateId, currentFrame, currentProject?.id]); const handlePropagationRangeChange = useCallback((startFrame: number, endFrame: number) => { const nextStart = clampFrameNumber(startFrame); const nextEnd = clampFrameNumber(endFrame); if (rangeSelectionMode === 'export') { setExportStartFrame(nextStart); setExportEndFrame(nextEnd); setStatusMessage(`已选择导出范围:第 ${Math.min(nextStart, nextEnd)}-${Math.max(nextStart, nextEnd)} 帧`); return; } setPropagationStartFrame(nextStart); setPropagationEndFrame(nextEnd); setHasExplicitPropagationRange(true); const actionLabel = rangeSelectionMode === 'boolean' ? '布尔操作范围' : rangeSelectionMode === 'clear' ? '清空传播帧范围' : '自动传播范围'; setStatusMessage(`已选择${actionLabel}:第 ${Math.min(nextStart, nextEnd)}-${Math.max(nextStart, nextEnd)} 帧`); }, [clampFrameNumber, rangeSelectionMode]); const handleExportScopeChange = useCallback((scope: ExportScope) => { setExportScope(scope); if (scope === 'range') { setIsPropagationRangeSelecting(true); setRangeSelectionMode('export'); setStatusMessage('请在播放进度条或视频处理进度条上点击/拖拽选择导出起止帧,也可直接修改导出范围'); return; } if (rangeSelectionMode === 'export') { setIsPropagationRangeSelecting(false); setRangeSelectionMode(null); setStatusMessage(scope === 'current' ? '已切换为导出当前图片' : '已切换为导出整体视频'); } }, [rangeSelectionMode]); const toggleExportOutput = useCallback((output: SegmentationExportOutput) => { setExportOutputs((current) => ( current.includes(output) ? current.filter((item) => item !== output) : [...current, output] )); }, []); const handlePropagationStartInput = (value: number) => { setPropagationStartFrame(clampFrameNumber(value || 1)); setHasExplicitPropagationRange(true); }; const handlePropagationEndInput = (value: number) => { setPropagationEndFrame(clampFrameNumber(value || 1)); setHasExplicitPropagationRange(true); }; const runAutoPropagate = async () => { if (!currentProject?.id || !currentFrame?.id) return; const initialSeedMasks = useStore.getState().masks.filter((mask) => String(mask.frameId) === String(currentFrame.id)); if (initialSeedMasks.length === 0) { setStatusMessage('当前参考帧无遮罩'); return; } const hasUnstableSeedMasks = initialSeedMasks.some((mask) => !mask.annotationId || mask.saveStatus === 'dirty'); if (hasUnstableSeedMasks) { setStatusMessage('正在先保存参考帧 mask,确保二次传播可以替换旧结果...'); 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('当前参考帧无遮罩'); return; } const startFrameNumber = clampFrameNumber(propagationStartFrame); const endFrameNumber = clampFrameNumber(propagationEndFrame); const rangeStartIndex = Math.min(startFrameNumber, endFrameNumber) - 1; const rangeEndIndex = Math.max(startFrameNumber, endFrameNumber) - 1; const propagationDirections: Array<{ direction: PropagationDirection; maxFrames: number }> = []; if (rangeStartIndex < currentFrameIndex) { propagationDirections.push({ direction: 'backward', maxFrames: currentFrameIndex - rangeStartIndex + 1, }); } if (rangeEndIndex > currentFrameIndex) { propagationDirections.push({ direction: 'forward', maxFrames: rangeEndIndex - currentFrameIndex + 1, }); } if (propagationDirections.length === 0) { setStatusMessage('传播范围只包含当前帧,请选择前后至少一帧'); return; } const seeds = seedMasks .map((mask) => ({ mask, seed: buildSeedPayload(mask) })) .filter((item): item is { mask: Mask; seed: NonNullable> } => Boolean(item.seed)); if (seeds.length === 0) { setStatusMessage('所选区域缺少可传播的 polygon 或 bbox'); return; } setIsPropagationRangeSelecting(false); setIsPropagating(true); const totalSteps = seeds.length * propagationDirections.length; setPropagationProgress({ currentStep: 0, completedSteps: 0, totalSteps, processedCount: 0, createdCount: 0, label: '准备传播', }); setStatusMessage(`${propagationWeightLabel} 权重正在以第 ${currentFrameNumber} 帧为参考,自动传播 ${seeds.length} 个 mask 到第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧...`); try { const steps = seeds.flatMap(({ seed }) => ( propagationDirections.map(({ direction, maxFrames }) => ({ seed, direction, max_frames: maxFrames, })) )); const task = await queuePropagationTask({ project_id: Number(currentProject.id), frame_id: Number(currentFrame.id), model: propagationWeight, steps, include_source: false, save_annotations: true, }); setPropagationTaskId(task.id); setStatusMessage(`自动传播任务已入队 #${task.id},可在 Dashboard 查看进度`); let currentTask = task; while (!['success', 'failed', 'cancelled'].includes(currentTask.status)) { await new Promise((resolve) => setTimeout(resolve, PROPAGATION_POLL_INTERVAL_MS)); currentTask = await getTask(task.id); const result = currentTask.result || {}; const completedSteps = Number(result.completed_steps || 0); const processedCount = Number(result.processed_frame_count || 0); const createdCount = Number(result.created_annotation_count || 0); setPropagationProgress({ currentStep: Math.min(completedSteps + 1, totalSteps), completedSteps, totalSteps, processedCount, createdCount, label: currentTask.message || `自动传播任务 #${task.id}`, }); setStatusMessage(currentTask.message || `自动传播任务 #${task.id} 运行中...`); if (createdCount > 0) { await hydrateSavedAnnotations(currentProject.id, frames); } } const result = currentTask.result || {}; const createdCount = Number(result.created_annotation_count || 0); const processedCount = Number(result.processed_frame_count || 0); const skippedCount = Number(result.skipped_seed_count || 0); const deletedCount = Number(result.deleted_annotation_count || 0); await hydrateSavedAnnotations(currentProject.id, frames); if (currentTask.status === 'failed') { setStatusMessage(currentTask.error ? `传播失败:${currentTask.error}` : '传播失败,请检查权重状态或后端日志'); return; } if (currentTask.status === 'cancelled') { setStatusMessage('自动传播任务已取消'); return; } if (processedCount > 0) { setPropagationHistory((previous) => { const nextColorIndex = previous.length > 0 ? previous[previous.length - 1].colorIndex + 1 : 0; return [ ...previous, { id: `propagation-${Date.now()}-${rangeStartIndex + 1}-${rangeEndIndex + 1}`, startFrame: rangeStartIndex + 1, endFrame: rangeEndIndex + 1, colorIndex: nextColorIndex, label: `${propagationWeightLabel} 自动传播:第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧`, }, ].slice(-8); }); } setStatusMessage(createdCount > 0 ? `已自动传播 ${seeds.length} 个参考 mask,处理 ${processedCount} 帧次,删除旧区域 ${deletedCount} 个,保存 ${createdCount} 个区域` : skippedCount > 0 ? `自动传播已完成:${skippedCount} 个未改变 mask 已跳过,没有生成重复区域` : `自动传播已完成,但没有生成新的 mask;请检查参考 mask、传播范围或 ${propagationWeightLabel} 权重状态`); } catch (err) { console.error('Propagation failed:', err); const detail = (err as any)?.response?.data?.detail; setStatusMessage(detail ? `传播失败:${detail}` : '传播失败,请检查权重状态或后端日志'); } finally { setIsPropagating(false); setPropagationProgress(null); setPropagationTaskId(null); setIsPropagationRangeSelecting(false); setRangeSelectionMode(null); setHasExplicitPropagationRange(false); } }; const handleAutoPropagate = async () => { if (rangeSelectionMode !== 'propagation') { if (hasExplicitPropagationRange && !isPropagationRangeSelecting) { setRangeSelectionMode('propagation'); await runAutoPropagate(); return; } setIsPropagationRangeSelecting(true); setRangeSelectionMode('propagation'); setStatusMessage('请在播放进度条或视频处理进度条上点击/拖拽选择传播起止帧,再点击“开始传播”'); return; } if (!hasExplicitPropagationRange && !isPropagationRangeSelecting) { setIsPropagationRangeSelecting(true); setRangeSelectionMode('propagation'); setStatusMessage('请在播放进度条或视频处理进度条上点击/拖拽选择传播起止帧,再点击“开始传播”'); return; } await runAutoPropagate(); }; const handleCancelPropagationRangeSelection = () => { const previousMode = rangeSelectionMode; setIsPropagationRangeSelecting(false); setRangeSelectionMode(null); setPendingBooleanRangeRequest(null); setPendingBooleanRangeConfirm(null); setPendingClearPropagationRangeRequest(null); setPendingClearPropagationRangeConfirm(null); setHasExplicitPropagationRange(false); setPropagationStartFrame(currentFrameNumber || 1); setPropagationEndFrame(Math.min(Math.max(totalFrames, 1), (currentFrameNumber || 1) + 29)); if (previousMode === 'export') { setStatusMessage('已取消导出范围选择'); return; } if (previousMode === 'boolean') { setStatusMessage('已取消布尔操作范围选择'); return; } if (previousMode === 'clear') { setStatusMessage('已取消清空传播帧范围选择'); return; } setStatusMessage('已取消自动传播范围选择'); }; const visibleTimelineRange = rangeSelectionMode === 'export' ? { startFrame: exportStartFrame, endFrame: exportEndFrame, } : { startFrame: propagationStartFrame, endFrame: propagationEndFrame, }; const handleCancelPropagation = async () => { if (!propagationTaskId) return; try { await cancelTask(propagationTaskId); setStatusMessage(`正在取消自动传播任务 #${propagationTaskId}...`); } catch (err) { console.error('Cancel propagation failed:', err); setStatusMessage('取消自动传播失败,请稍后重试'); } }; const propagationPercent = propagationProgress ? Math.round((propagationProgress.completedSteps / Math.max(propagationProgress.totalSteps, 1)) * 100) : 0; const showPropagationControls = rangeSelectionMode === 'propagation' || isPropagating || Boolean(propagationTaskId); const showFrameRangeControls = showPropagationControls || rangeSelectionMode === 'boolean' || rangeSelectionMode === 'clear'; const selectedRangeStartFrame = Math.min(propagationStartFrame, propagationEndFrame); const selectedRangeEndFrame = Math.max(propagationStartFrame, propagationEndFrame); const propagationBackwardFrameCount = Math.max(0, currentFrameNumber - selectedRangeStartFrame); const propagationForwardFrameCount = Math.max(0, selectedRangeEndFrame - currentFrameNumber); return (
{/* Top Header / Status bar */}

核心分割工作区

{currentProject?.name || '未选择项目'}
{statusMessage && !propagationProgress && ( {statusMessage} )} {propagationProgress && (
{propagationProgress.label} {propagationPercent}%
)}
{showPropagationControls && (
传播权重
)} {showPropagationControls && (
{propagationWeightLabel} | 向前 {propagationBackwardFrameCount} 帧 向后 {propagationForwardFrameCount} 帧
)} {pendingGtImportFile && (
导入 GT Mask
GT 图片必须是 8-bit 灰度 maskid 图,或 8-bit RGB 三通道完全相同的 [X,X,X] maskid 图;0 为背景,X 为 1-255 的类别 maskid。尺寸不一致时会按当前帧长宽自动最近邻拉伸。
{pendingGtImportFile.name}
导入结果预览 {gtMaskPreview?.status === 'ready' && gtMaskPreview.maskIds && ( maskid: {gtMaskPreview.maskIds.join(', ')} )}
{gtMaskPreview?.status === 'loading' && (
正在解析 GT Mask...
)} {gtMaskPreview?.status === 'error' && (
{gtMaskPreview.message}
)} {gtMaskPreview?.status === 'ready' && (
{gtMaskPreview.overlayDataUrl ? (
{currentFrame?.url ? ( 当前帧预览 ) : (
暂无当前帧底图
)} GT Mask 导入结果预览
) : (
{gtMaskPreview.message || '当前环境无法生成图像预览,导入时仍会由后端校验 GT Mask 格式。'}
)}
{gtMaskPreview.message}
{gtMaskPreview.unknownMaskIds && gtMaskPreview.unknownMaskIds.length > 0 && (
检测到未定义 maskid:{gtMaskPreview.unknownMaskIds.join(', ')}
)}
)}
)} {showFrameRangeControls && (
参考帧 {currentFrameNumber || 0} handlePropagationStartInput(Number(event.target.value))} disabled={isPropagating || isSaving || isExporting || isImportingGt || totalFrames === 0} className="h-6 w-14 rounded bg-black/20 border border-white/10 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40" /> - handlePropagationEndInput(Number(event.target.value))} disabled={isPropagating || isSaving || isExporting || isImportingGt || totalFrames === 0} className="h-6 w-14 rounded bg-black/20 border border-white/10 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40" />
)} {rangeSelectionMode === 'boolean' && pendingBooleanRangeRequest && ( )} {rangeSelectionMode === 'clear' && pendingClearPropagationRangeRequest && ( )} {showPropagationControls && ( )} {isPropagationRangeSelecting && ( )} {propagationTaskId && ( )}
{isExportMenuOpen && (
导出范围
{[ ['all', '整体视频'], ['range', '特定范围帧'], ['current', '当前图片'], ].map(([value, label]) => ( ))}
{exportScope === 'range' && (
setExportStartFrame(Number(event.target.value))} className="h-8 rounded bg-black/30 border border-white/10 px-2 text-xs text-gray-200 outline-none focus:border-cyan-500/50" /> setExportEndFrame(Number(event.target.value))} className="h-8 rounded bg-black/30 border border-white/10 px-2 text-xs text-gray-200 outline-none focus:border-cyan-500/50" />
)}
导出内容
{[ ['separate', '分开 Mask'], ['gt_label', 'GT_label 黑白'], ['pro_label', 'Pro_label 彩色'], ['mix_label', 'Mix_label 叠加'], ].map(([value, label]) => ( ))}
{exportOutputs.includes('mix_label') && (
{exportMixOpacity.toFixed(2)}
setExportMixOpacity(Number(event.target.value))} className="w-full accent-cyan-500" />
当前/待导出第一帧遮罩可视化效果示意
{exportPreviewFrame?.url ? ( 待导出第一帧预览 ) : (
暂无帧
)} {exportPreviewFrame && exportPreviewPolygons.length > 0 && ( )}
)}
)}
{/* Main Workspace Area */}
void handleAutoPropagate()} onImportGtMask={() => gtMaskInputRef.current?.click()} onClearSelection={handleClearSelection} onDeleteMasks={handleDeleteSelectedMasks} onClearMasks={handleClearCurrentFrameMasks} canAutoPropagate={Boolean(currentProject?.id && currentFrame?.id) && !isSaving && !isExporting && !isImportingGt} isPropagating={isPropagating} canImportGtMask={Boolean(currentProject?.id && currentFrame?.id) && !isSaving && !isExporting && !isPropagating} isImportingGtMask={isImportingGt} />
void handleDeleteSelectedMasks(maskIds)} onRequestBooleanFrameRange={handleBooleanFrameRangeRequest} onBooleanOperationStart={clearPendingBooleanRangeSelection} onDeleteMaskAnnotations={handleDeleteMaskAnnotations} />
{pendingCurrentClearConfirm && (

选择清空范围

{pendingCurrentClearConfirm.scopeLabel}存在自动传播结果。 请选择只清空当前帧、按帧范围清空,或同时清空同一传播链上的所有帧。

当前范围:{pendingCurrentClearConfirm.currentMaskCount} 个 mask;当前范围 + 传播链:{pendingCurrentClearConfirm.propagatedMaskCount} 个 mask。
)} {pendingClearManualFrameConfirm && (

是否删除人工/AI 标注帧

本次清空范围包含第 {pendingClearManualFrameConfirm.manualFrameNumbers.join('、') || '-'} 帧等人工/AI 标注帧。 是否同时删除这些帧中的遮罩?

{pendingClearManualFrameConfirm.manualFrameIncludeMode === 'target-masks' ? '选择删除时只会删除这些帧中本次选中或同传播链对应的遮罩;选择保留时,这些人工/AI 标注帧会整帧保留,只清空其它自动传播帧。' : '选择删除时会删除这些帧中的全部遮罩;选择保留时,这些人工/AI 标注帧会整帧保留,只清空其它自动传播帧。'}

)} {pendingClearPropagationRangeConfirm && (

确认清空传播帧

将在第 {pendingClearPropagationRangeConfirm.rangeStartIndex + 1}-{pendingClearPropagationRangeConfirm.rangeEndIndex + 1} 帧范围内, 清空同一传播链上的 {pendingClearPropagationRangeConfirm.targetMaskIds.length} 个遮罩。

无关的人工标注或其它 AI 推理结果不会被纳入本次清空。

)} {pendingBooleanRangeConfirm && (

确认{booleanOperationLabel(pendingBooleanRangeConfirm.request.operation)}

将在第 {pendingBooleanRangeConfirm.rangeStartIndex + 1}-{pendingBooleanRangeConfirm.rangeEndIndex + 1} 帧范围内, 对 {pendingBooleanRangeConfirm.targetFrameIds.length} 帧存在对应传播链的区域执行{booleanOperationLabel(pendingBooleanRangeConfirm.request.operation)}。

本操作会保留传播帧原有来源属性,只把几何结果标记为待保存。

)} {/* Bottom Timeline */}
); }