- 传播进度蓝色面板显示任务 message 时隐藏顶栏左侧灰色 statusMessage,避免同一传播文字重复出现。 - 补充 VideoWorkspace 回归测试,确认传播运行提示只出现一次且位于进度面板内。 - 更新前端审计、设计冻结、测试计划和项目指南文档。
2487 lines
110 KiB
TypeScript
2487 lines
110 KiB
TypeScript
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<number>,
|
||
totalFrames: number,
|
||
): PropagationHistorySegment[] => (
|
||
segments.flatMap((segment) => {
|
||
const start = Math.max(1, Math.min(segment.startFrame, segment.endFrame));
|
||
const end = Math.min(totalFrames, Math.max(segment.startFrame, segment.endFrame));
|
||
const chunks: PropagationHistorySegment[] = [];
|
||
let chunkStart: number | null = null;
|
||
|
||
for (let frameNumber = start; frameNumber <= end; frameNumber += 1) {
|
||
if (activeFrameNumbers.has(frameNumber)) {
|
||
chunkStart ??= frameNumber;
|
||
continue;
|
||
}
|
||
if (chunkStart !== null) {
|
||
const chunkEnd = frameNumber - 1;
|
||
chunks.push({
|
||
...segment,
|
||
id: chunkStart === start && chunkEnd === end ? segment.id : `${segment.id}-${chunkStart}-${chunkEnd}`,
|
||
startFrame: chunkStart,
|
||
endFrame: chunkEnd,
|
||
});
|
||
chunkStart = null;
|
||
}
|
||
}
|
||
|
||
if (chunkStart !== null) {
|
||
chunks.push({
|
||
...segment,
|
||
id: chunkStart === start ? segment.id : `${segment.id}-${chunkStart}-${end}`,
|
||
startFrame: chunkStart,
|
||
endFrame: end,
|
||
});
|
||
}
|
||
return chunks;
|
||
})
|
||
);
|
||
|
||
const propagationHistoryEqual = (
|
||
left: PropagationHistorySegment[],
|
||
right: PropagationHistorySegment[],
|
||
) => (
|
||
left.length === right.length
|
||
&& left.every((segment, index) => {
|
||
const other = right[index];
|
||
return other
|
||
&& segment.id === other.id
|
||
&& segment.startFrame === other.startFrame
|
||
&& segment.endFrame === other.endFrame
|
||
&& segment.colorIndex === other.colorIndex
|
||
&& segment.label === other.label;
|
||
})
|
||
);
|
||
|
||
const isPropagatedMask = (mask: Mask) => {
|
||
const source = typeof mask.metadata?.source === 'string' ? mask.metadata.source.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<string> => {
|
||
const metadata = mask.metadata || {};
|
||
const tokens = new Set<string>([`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<string> => {
|
||
const selectedMasks = selectedIds
|
||
.map((id) => allMasks.find((mask) => mask.id === id))
|
||
.filter((mask): mask is Mask => Boolean(mask));
|
||
const selectedTokens = new Set<string>();
|
||
selectedMasks.forEach((mask) => {
|
||
propagationLineageTokens(mask).forEach((token) => selectedTokens.add(token));
|
||
});
|
||
if (selectedTokens.size === 0) return new Set(selectedIds);
|
||
return new Set(
|
||
allMasks
|
||
.filter((mask) => {
|
||
const candidateTokens = propagationLineageTokens(mask);
|
||
return [...candidateTokens].some((token) => selectedTokens.has(token));
|
||
})
|
||
.map((mask) => mask.id),
|
||
);
|
||
};
|
||
|
||
const expandedPropagationDeletionMaskIds = (selectedIds: string[], allMasks: Mask[]): Set<string> => {
|
||
const selectedIdSet = new Set(selectedIds);
|
||
const chainIds = findPropagationChainMaskIds(selectedIds, allMasks);
|
||
return new Set(
|
||
allMasks
|
||
.filter((mask) => selectedIdSet.has(mask.id) || (chainIds.has(mask.id) && isPropagatedMask(mask)))
|
||
.map((mask) => mask.id),
|
||
);
|
||
};
|
||
|
||
const booleanOperationLabel = (operation: BooleanFrameRangeRequest['operation']) => (
|
||
operation === 'area_merge' ? '区域合并' : '重叠区域去除'
|
||
);
|
||
|
||
const persistentMaskMetadata = (metadata?: Record<string, unknown>) => {
|
||
if (!metadata) return {};
|
||
const {
|
||
geometry_smoothing: _geometrySmoothing,
|
||
geometry_smoothing_preview: _geometrySmoothingPreview,
|
||
...rest
|
||
} = metadata;
|
||
return rest;
|
||
};
|
||
|
||
const isNotFoundError = (error: unknown) => (
|
||
typeof error === 'object'
|
||
&& error !== null
|
||
&& (
|
||
('response' in error
|
||
&& typeof (error as { response?: { status?: unknown } }).response === 'object'
|
||
&& (error as { response?: { status?: unknown } }).response?.status === 404)
|
||
|| ('status' in error && (error as { status?: unknown }).status === 404)
|
||
)
|
||
);
|
||
|
||
const deleteAnnotationIfExists = async (annotationId: string) => {
|
||
try {
|
||
await deleteAnnotation(annotationId);
|
||
} catch (error) {
|
||
if (!isNotFoundError(error)) throw error;
|
||
}
|
||
};
|
||
|
||
const deleteAnnotationsIfExist = async (annotationIds: string[], 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<HTMLInputElement>(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<string[]>(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<ExportScope>('current');
|
||
const [exportOutputs, setExportOutputs] = useState<SegmentationExportOutput[]>([
|
||
'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<File | null>(null);
|
||
const [gtMaskPreview, setGtMaskPreview] = useState<GtMaskPreviewState | null>(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<RangeSelectionMode>(null);
|
||
const [pendingCurrentClearConfirm, setPendingCurrentClearConfirm] = useState<CurrentClearConfirmState | null>(null);
|
||
const [pendingClearPropagationRangeRequest, setPendingClearPropagationRangeRequest] = useState<ClearPropagationRangeRequestState | null>(null);
|
||
const [pendingClearPropagationRangeConfirm, setPendingClearPropagationRangeConfirm] = useState<ClearPropagationRangeConfirmState | null>(null);
|
||
const [pendingClearManualFrameConfirm, setPendingClearManualFrameConfirm] = useState<ClearManualFrameConfirmState | null>(null);
|
||
const [pendingBooleanRangeRequest, setPendingBooleanRangeRequest] = useState<BooleanFrameRangeRequest | null>(null);
|
||
const [pendingBooleanRangeConfirm, setPendingBooleanRangeConfirm] = useState<BooleanRangeConfirmState | null>(null);
|
||
const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false);
|
||
const [propagationProgress, setPropagationProgress] = useState<PropagationProgress>(null);
|
||
const [propagationTaskId, setPropagationTaskId] = useState<number | null>(null);
|
||
const [propagationWeight, setPropagationWeight] = useState<AiModelId>(aiModel || DEFAULT_AI_MODEL_ID);
|
||
const [hasCustomPropagationWeight, setHasCustomPropagationWeight] = useState(false);
|
||
const [propagationHistory, setPropagationHistory] = useState<PropagationHistorySegment[]>([]);
|
||
|
||
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<typeof mask> => 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<ExportPreviewPolygon[]>(() => {
|
||
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<number>();
|
||
masks.forEach((mask) => {
|
||
if (!isPropagatedMask(mask)) return;
|
||
const frameNumber = frameNumberById.get(String(mask.frameId));
|
||
if (frameNumber) activePropagatedFrameNumbers.add(frameNumber);
|
||
});
|
||
const nextHistory = prunePropagationHistoryByActiveFrames(
|
||
propagationHistory,
|
||
activePropagatedFrameNumbers,
|
||
totalFrames,
|
||
);
|
||
if (!propagationHistoryEqual(propagationHistory, nextHistory)) {
|
||
setPropagationHistory(nextHistory);
|
||
}
|
||
}, [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<typeof item> => 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<typeof item> => Boolean(item));
|
||
|
||
if (createItems.length === 0 && updateItems.length === 0) {
|
||
setStatusMessage('没有可保存的标注数据');
|
||
return 0;
|
||
}
|
||
|
||
let existingAnnotationIds: Set<string> | 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<number>();
|
||
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<HTMLInputElement>) => {
|
||
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<string, unknown>).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<ReturnType<typeof buildSeedPayload>> } => 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 (
|
||
<div className="w-full h-full flex flex-col bg-[#0a0a0a]">
|
||
{/* Top Header / Status bar */}
|
||
<div className="h-14 border-b border-white/5 bg-[#111] flex items-center justify-between px-6 shrink-0">
|
||
<div className="flex items-center gap-4">
|
||
<h2 className="text-xs font-semibold uppercase tracking-widest text-gray-400">核心分割工作区</h2>
|
||
<div className="h-4 w-px bg-white/10"></div>
|
||
<span className="text-sm text-white font-mono">{currentProject?.name || '未选择项目'}</span>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
{statusMessage && !propagationProgress && (
|
||
<span className="text-[10px] text-gray-500 font-mono max-w-48 truncate" title={statusMessage}>
|
||
{statusMessage}
|
||
</span>
|
||
)}
|
||
{propagationProgress && (
|
||
<div
|
||
className="w-56 rounded-md border border-blue-500/20 bg-blue-500/5 px-2 py-1"
|
||
aria-label="自动传播进度"
|
||
title={`已处理 ${propagationProgress.processedCount} 帧次,已保存 ${propagationProgress.createdCount} 个区域`}
|
||
>
|
||
<div className="mb-1 flex items-center justify-between gap-2 text-[10px] font-mono text-blue-200">
|
||
<span className="truncate">{propagationProgress.label}</span>
|
||
<span>{propagationPercent}%</span>
|
||
</div>
|
||
<div className="h-1.5 overflow-hidden rounded-full bg-zinc-700">
|
||
<div
|
||
className="h-full rounded-full bg-blue-400 transition-all"
|
||
style={{ width: `${propagationPercent}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-1 py-1">
|
||
<button
|
||
type="button"
|
||
onClick={undoMasks}
|
||
disabled={maskHistory.length === 0}
|
||
aria-label="撤销操作"
|
||
title="撤销操作 (Ctrl+Z)"
|
||
className="h-7 px-2 rounded text-gray-300 hover:bg-amber-400/10 hover:text-white inline-flex items-center gap-1.5 text-xs transition-colors disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-gray-400 disabled:cursor-not-allowed"
|
||
>
|
||
<Undo size={15} className="text-amber-300 drop-shadow-[0_0_6px_rgba(251,191,36,0.45)]" />
|
||
撤销
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={redoMasks}
|
||
disabled={maskFuture.length === 0}
|
||
aria-label="重做操作"
|
||
title="重做操作 (Ctrl+Shift+Z / Ctrl+Y)"
|
||
className="h-7 px-2 rounded text-gray-300 hover:bg-indigo-400/10 hover:text-white inline-flex items-center gap-1.5 text-xs transition-colors disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-gray-400 disabled:cursor-not-allowed"
|
||
>
|
||
<Redo size={15} className="text-indigo-300 drop-shadow-[0_0_6px_rgba(165,180,252,0.45)]" />
|
||
重做
|
||
</button>
|
||
</div>
|
||
{showPropagationControls && (
|
||
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-2 py-1">
|
||
<span className="text-[10px] text-gray-500 whitespace-nowrap">传播权重</span>
|
||
<select
|
||
aria-label="传播权重"
|
||
value={propagationWeight}
|
||
onChange={(event) => {
|
||
setHasCustomPropagationWeight(true);
|
||
setPropagationWeight(event.target.value as AiModelId);
|
||
}}
|
||
disabled={isPropagating || isSaving || isExporting || isImportingGt}
|
||
className="h-6 w-24 rounded border border-cyan-500/20 bg-[#050809] px-1 text-[10px] text-cyan-100 outline-none focus:border-cyan-400/70 disabled:opacity-40"
|
||
>
|
||
{SAM2_MODEL_OPTIONS.map((option) => (
|
||
<option key={option.id} value={option.id} className="bg-[#050809] text-cyan-100">
|
||
{option.shortLabel}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
{showPropagationControls && (
|
||
<div
|
||
className="flex items-center gap-2 rounded-md border border-cyan-500/20 bg-cyan-500/[0.06] px-2 py-1 text-[10px] text-cyan-100"
|
||
title="向前表示更早帧,向后表示更晚帧"
|
||
>
|
||
<span className="font-semibold">{propagationWeightLabel}</span>
|
||
<span className="text-gray-500">|</span>
|
||
<span>向前 {propagationBackwardFrameCount} 帧</span>
|
||
<span>向后 {propagationForwardFrameCount} 帧</span>
|
||
</div>
|
||
)}
|
||
<input
|
||
ref={gtMaskInputRef}
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/bmp,image/tiff"
|
||
className="hidden"
|
||
onChange={handleImportGtMask}
|
||
/>
|
||
{pendingGtImportFile && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4">
|
||
<div className="w-full max-w-xl rounded-md border border-white/10 bg-[#151515] p-4 shadow-2xl shadow-black/60">
|
||
<div className="text-sm font-semibold text-white">导入 GT Mask</div>
|
||
<div className="mt-2 text-xs leading-5 text-gray-400">
|
||
GT 图片必须是 8-bit 灰度 maskid 图,或 8-bit RGB 三通道完全相同的 [X,X,X] maskid 图;0 为背景,X 为 1-255 的类别 maskid。尺寸不一致时会按当前帧长宽自动最近邻拉伸。
|
||
</div>
|
||
<div className="mt-3 rounded border border-white/10 bg-black/20 px-3 py-2 text-[11px] text-gray-500">
|
||
{pendingGtImportFile.name}
|
||
</div>
|
||
<div className="mt-3 rounded border border-white/10 bg-black/20 p-2">
|
||
<div className="mb-2 flex items-center justify-between text-[10px] text-gray-500">
|
||
<span>导入结果预览</span>
|
||
{gtMaskPreview?.status === 'ready' && gtMaskPreview.maskIds && (
|
||
<span>maskid: {gtMaskPreview.maskIds.join(', ')}</span>
|
||
)}
|
||
</div>
|
||
{gtMaskPreview?.status === 'loading' && (
|
||
<div className="flex h-40 items-center justify-center text-xs text-gray-400">正在解析 GT Mask...</div>
|
||
)}
|
||
{gtMaskPreview?.status === 'error' && (
|
||
<div className="flex min-h-28 items-center justify-center rounded bg-red-500/10 px-3 text-center text-xs leading-5 text-red-100">
|
||
{gtMaskPreview.message}
|
||
</div>
|
||
)}
|
||
{gtMaskPreview?.status === 'ready' && (
|
||
<div className="space-y-2">
|
||
{gtMaskPreview.overlayDataUrl ? (
|
||
<div className="relative mx-auto aspect-video w-full overflow-hidden rounded bg-black">
|
||
{currentFrame?.url ? (
|
||
<img
|
||
src={currentFrame.url}
|
||
alt="当前帧预览"
|
||
className="h-full w-full object-contain opacity-80"
|
||
/>
|
||
) : (
|
||
<div className="flex h-full items-center justify-center text-[10px] text-gray-600">暂无当前帧底图</div>
|
||
)}
|
||
<img
|
||
src={gtMaskPreview.overlayDataUrl}
|
||
alt="GT Mask 导入结果预览"
|
||
className="pointer-events-none absolute inset-0 h-full w-full object-contain"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="flex h-32 items-center justify-center rounded bg-black/30 px-3 text-center text-xs leading-5 text-gray-400">
|
||
{gtMaskPreview.message || '当前环境无法生成图像预览,导入时仍会由后端校验 GT Mask 格式。'}
|
||
</div>
|
||
)}
|
||
<div className="text-[11px] leading-5 text-gray-400">
|
||
{gtMaskPreview.message}
|
||
</div>
|
||
{gtMaskPreview.unknownMaskIds && gtMaskPreview.unknownMaskIds.length > 0 && (
|
||
<div className="rounded border border-amber-500/20 bg-amber-500/10 px-2 py-1.5 text-[11px] text-amber-100">
|
||
检测到未定义 maskid:{gtMaskPreview.unknownMaskIds.join(', ')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => executeImportGtMask(pendingGtImportFile, 'discard')}
|
||
disabled={gtMaskPreview?.status !== 'ready'}
|
||
className="rounded border border-red-500/25 bg-red-500/10 px-3 py-2 text-xs text-red-100 hover:bg-red-500/20"
|
||
>
|
||
舍弃未知类别
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => executeImportGtMask(pendingGtImportFile, 'undefined')}
|
||
disabled={gtMaskPreview?.status !== 'ready'}
|
||
className="rounded border border-cyan-500/30 bg-cyan-500/15 px-3 py-2 text-xs text-cyan-100 hover:bg-cyan-500/25"
|
||
>
|
||
导入为未定义
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setPendingGtImportFile(null);
|
||
setGtMaskPreview(null);
|
||
}}
|
||
className="mt-3 w-full rounded border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-gray-300 hover:bg-white/10"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{showFrameRangeControls && (
|
||
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-2 py-1">
|
||
<span className="text-[10px] text-gray-500 whitespace-nowrap">参考帧 {currentFrameNumber || 0}</span>
|
||
<span className="text-[10px] text-gray-600">帧</span>
|
||
<input
|
||
aria-label="传播起始帧"
|
||
type="number"
|
||
min={1}
|
||
max={Math.max(totalFrames, 1)}
|
||
value={propagationStartFrame}
|
||
onChange={(event) => 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"
|
||
/>
|
||
<span className="text-[10px] text-gray-600">-</span>
|
||
<input
|
||
aria-label="传播结束帧"
|
||
type="number"
|
||
min={1}
|
||
max={Math.max(totalFrames, 1)}
|
||
value={propagationEndFrame}
|
||
onChange={(event) => 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"
|
||
/>
|
||
</div>
|
||
)}
|
||
{rangeSelectionMode === 'boolean' && pendingBooleanRangeRequest && (
|
||
<button
|
||
onClick={handleConfirmBooleanFrameRangeOperation}
|
||
disabled={frames.length === 0 || isSaving || isExporting || isImportingGt || isPropagating}
|
||
title={`按当前起止帧执行${booleanOperationLabel(pendingBooleanRangeRequest.operation)}`}
|
||
className="px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/25 rounded-md text-xs transition-colors text-emerald-200 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
确认{booleanOperationLabel(pendingBooleanRangeRequest.operation)}
|
||
</button>
|
||
)}
|
||
{rangeSelectionMode === 'clear' && pendingClearPropagationRangeRequest && (
|
||
<button
|
||
onClick={handleConfirmClearPropagationFrameRange}
|
||
disabled={frames.length === 0 || isSaving || isExporting || isImportingGt || isPropagating}
|
||
title="按当前起止帧清空传播链遮罩"
|
||
className="px-3 py-1.5 bg-red-500/10 hover:bg-red-500/20 border border-red-500/25 rounded-md text-xs transition-colors text-red-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
确认清空
|
||
</button>
|
||
)}
|
||
{showPropagationControls && (
|
||
<button
|
||
onClick={handleAutoPropagate}
|
||
disabled={frames.length === 0 || isSaving || isExporting || isImportingGt || isPropagating}
|
||
className="px-4 py-1.5 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/25 rounded-md text-xs transition-colors text-cyan-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
{isPropagating ? '传播中...' : '开始传播'}
|
||
</button>
|
||
)}
|
||
{isPropagationRangeSelecting && (
|
||
<button
|
||
onClick={handleCancelPropagationRangeSelection}
|
||
disabled={isPropagating}
|
||
className="px-3 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/25 rounded-md text-xs transition-colors text-amber-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
取消选区
|
||
</button>
|
||
)}
|
||
{propagationTaskId && (
|
||
<button
|
||
onClick={handleCancelPropagation}
|
||
className="px-3 py-1.5 bg-red-500/10 hover:bg-red-500/20 border border-red-500/20 rounded-md text-xs transition-colors text-red-200"
|
||
>
|
||
取消传播
|
||
</button>
|
||
)}
|
||
<div className="relative">
|
||
<button
|
||
onClick={() => {
|
||
setIsExportMenuOpen((value) => {
|
||
const nextValue = !value;
|
||
if (!nextValue && rangeSelectionMode === 'export') {
|
||
setIsPropagationRangeSelecting(false);
|
||
setRangeSelectionMode(null);
|
||
setStatusMessage('已关闭导出范围选择');
|
||
}
|
||
return nextValue;
|
||
});
|
||
}}
|
||
disabled={!currentProject?.id || isExporting || isSaving || isPropagating}
|
||
aria-haspopup="menu"
|
||
aria-expanded={isExportMenuOpen}
|
||
className="inline-flex items-center gap-1.5 px-4 py-1.5 bg-emerald-500/20 hover:bg-emerald-500/30 border border-emerald-400/40 rounded-md text-xs font-medium transition-colors text-emerald-50 shadow-sm shadow-emerald-950/30 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
<FileDown aria-hidden="true" className="h-3.5 w-3.5 text-emerald-200" />
|
||
<span>{isExporting ? '导出中...' : '分割结果导出'}</span>
|
||
</button>
|
||
{isExportMenuOpen && (
|
||
<div
|
||
role="menu"
|
||
className="absolute right-0 top-full z-30 mt-2 w-80 rounded-md border border-white/10 bg-[#151515] p-3 shadow-2xl shadow-black/50"
|
||
>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<div className="mb-1 text-[10px] text-gray-500">导出范围</div>
|
||
<div className="grid grid-cols-3 gap-1">
|
||
{[
|
||
['all', '整体视频'],
|
||
['range', '特定范围帧'],
|
||
['current', '当前图片'],
|
||
].map(([value, label]) => (
|
||
<button
|
||
key={value}
|
||
type="button"
|
||
onClick={() => handleExportScopeChange(value as ExportScope)}
|
||
className={cn(
|
||
'h-7 rounded border px-2 text-[10px] transition-colors',
|
||
exportScope === value
|
||
? 'border-cyan-500/60 bg-cyan-500/15 text-cyan-100'
|
||
: 'border-white/10 bg-white/[0.03] text-gray-400 hover:bg-white/10 hover:text-gray-200',
|
||
)}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{exportScope === 'range' && (
|
||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||
<input
|
||
aria-label="导出起始帧"
|
||
type="number"
|
||
min={1}
|
||
max={Math.max(totalFrames, 1)}
|
||
value={exportStartFrame}
|
||
onChange={(event) => 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"
|
||
/>
|
||
<span className="text-[10px] text-gray-600">至</span>
|
||
<input
|
||
aria-label="导出结束帧"
|
||
type="number"
|
||
min={1}
|
||
max={Math.max(totalFrames, 1)}
|
||
value={exportEndFrame}
|
||
onChange={(event) => 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"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<div className="mb-1 text-[10px] text-gray-500">导出内容</div>
|
||
<div className="grid grid-cols-2 gap-1">
|
||
{[
|
||
['separate', '分开 Mask'],
|
||
['gt_label', 'GT_label 黑白'],
|
||
['pro_label', 'Pro_label 彩色'],
|
||
['mix_label', 'Mix_label 叠加'],
|
||
].map(([value, label]) => (
|
||
<button
|
||
key={value}
|
||
type="button"
|
||
onClick={() => toggleExportOutput(value as SegmentationExportOutput)}
|
||
aria-pressed={exportOutputs.includes(value as SegmentationExportOutput)}
|
||
className={cn(
|
||
'h-7 rounded border px-2 text-[10px] transition-colors',
|
||
exportOutputs.includes(value as SegmentationExportOutput)
|
||
? 'border-cyan-500/60 bg-cyan-500/15 text-cyan-100'
|
||
: 'border-white/10 bg-white/[0.03] text-gray-400 hover:bg-white/10 hover:text-gray-200',
|
||
)}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{exportOutputs.includes('mix_label') && (
|
||
<div className="space-y-2 rounded border border-white/10 bg-black/20 p-2">
|
||
<div className="flex items-center justify-between">
|
||
<label htmlFor="export-mix-opacity" className="text-[10px] text-gray-500">
|
||
Mix_label 遮罩透明度
|
||
</label>
|
||
<span className="text-[10px] font-mono text-cyan-300">{exportMixOpacity.toFixed(2)}</span>
|
||
</div>
|
||
<input
|
||
id="export-mix-opacity"
|
||
aria-label="Mix_label 遮罩透明度"
|
||
type="range"
|
||
min={0}
|
||
max={1}
|
||
step={0.05}
|
||
value={exportMixOpacity}
|
||
onChange={(event) => setExportMixOpacity(Number(event.target.value))}
|
||
className="w-full accent-cyan-500"
|
||
/>
|
||
<div className="overflow-hidden rounded border border-white/10 bg-[#050505]">
|
||
<div className="px-2 py-1 text-[10px] text-gray-500">当前/待导出第一帧遮罩可视化效果示意</div>
|
||
<div className="relative mx-auto mb-2 aspect-video w-[92%] overflow-hidden rounded bg-black">
|
||
{exportPreviewFrame?.url ? (
|
||
<img
|
||
src={exportPreviewFrame.url}
|
||
alt="待导出第一帧预览"
|
||
className="h-full w-full object-contain"
|
||
/>
|
||
) : (
|
||
<div className="flex h-full items-center justify-center text-[10px] text-gray-600">暂无帧</div>
|
||
)}
|
||
{exportPreviewFrame && exportPreviewPolygons.length > 0 && (
|
||
<svg
|
||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||
viewBox={`0 0 ${exportPreviewFrame.width} ${exportPreviewFrame.height}`}
|
||
preserveAspectRatio="xMidYMid meet"
|
||
aria-hidden="true"
|
||
>
|
||
{exportPreviewPolygons.map((polygon) => (
|
||
<polygon
|
||
key={polygon.id}
|
||
points={polygon.points}
|
||
fill={polygon.color}
|
||
fillOpacity={exportMixOpacity}
|
||
stroke={polygon.color}
|
||
strokeOpacity={Math.min(exportMixOpacity + 0.25, 1)}
|
||
strokeWidth={2}
|
||
/>
|
||
))}
|
||
</svg>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={handleExportSegmentationResults}
|
||
disabled={isExporting || !currentProject?.id || exportOutputs.length === 0 || (exportScope === 'current' && !currentFrame?.id)}
|
||
className="h-8 w-full rounded bg-cyan-600 text-xs font-medium text-white transition-colors hover:bg-cyan-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
开始导出
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={!currentProject?.id || isSaving || isExporting || isPropagating}
|
||
className="px-4 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white text-xs font-medium rounded-md transition-shadow shadow-lg shadow-cyan-900/20 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
{isSaving ? '保存中...' : pendingAnnotationChangeCount > 0 ? `保存 ${pendingAnnotationChangeCount} 个改动` : '已全部保存'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main Workspace Area */}
|
||
<div className="flex-1 flex overflow-hidden">
|
||
<ToolsPalette
|
||
activeTool={activeTool}
|
||
setActiveTool={setActiveTool}
|
||
onTriggerAI={onNavigateToAI}
|
||
onAutoPropagate={() => 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}
|
||
/>
|
||
|
||
<div className="flex-1 relative flex items-center justify-center p-8 bg-[#151515] overflow-hidden">
|
||
<div className="relative w-full h-full bg-[#1e1e1e] border border-white/5 shadow-2xl rounded-sm">
|
||
<CanvasArea
|
||
activeTool={activeTool}
|
||
frame={currentFrame}
|
||
currentFrameNumber={currentFrameNumber || 0}
|
||
totalFrames={totalFrames}
|
||
clearSelectionSignal={clearSelectionSignal}
|
||
onRequestDeleteMasks={(maskIds) => void handleDeleteSelectedMasks(maskIds)}
|
||
onRequestBooleanFrameRange={handleBooleanFrameRangeRequest}
|
||
onBooleanOperationStart={clearPendingBooleanRangeSelection}
|
||
onDeleteMaskAnnotations={handleDeleteMaskAnnotations}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<OntologyInspector />
|
||
</div>
|
||
|
||
{pendingCurrentClearConfirm && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
|
||
<div className="w-full max-w-lg rounded-lg border border-red-400/25 bg-[#151515] p-5 shadow-2xl">
|
||
<h2 className="text-lg font-semibold text-white">选择清空范围</h2>
|
||
<p className="mt-2 text-sm leading-relaxed text-gray-300">
|
||
{pendingCurrentClearConfirm.scopeLabel}存在自动传播结果。
|
||
请选择只清空当前帧、按帧范围清空,或同时清空同一传播链上的所有帧。
|
||
</p>
|
||
<div className="mt-3 rounded-md border border-white/10 bg-white/[0.03] p-3 text-xs leading-relaxed text-gray-400">
|
||
当前范围:{pendingCurrentClearConfirm.currentMaskCount} 个 mask;当前范围 + 传播链:{pendingCurrentClearConfirm.propagatedMaskCount} 个 mask。
|
||
</div>
|
||
<div className="mt-5 grid grid-cols-4 gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPendingCurrentClearConfirm(null)}
|
||
className="rounded border border-white/10 px-2 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
|
||
disabled={isSaving}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void executeClearCurrentMasks(pendingCurrentClearConfirm.currentMaskIds, pendingCurrentClearConfirm.scopeLabel)}
|
||
className="rounded border border-red-400/30 bg-red-500/10 px-2 py-2 text-xs font-semibold text-red-100 hover:bg-red-500/20 disabled:opacity-60"
|
||
disabled={isSaving}
|
||
>
|
||
只清当前帧
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleStartClearPropagationRange(pendingCurrentClearConfirm)}
|
||
className="rounded border border-amber-400/30 bg-amber-500/10 px-2 py-2 text-xs font-semibold text-amber-100 hover:bg-amber-500/20 disabled:opacity-60"
|
||
disabled={isSaving}
|
||
>
|
||
按帧范围选择
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => requestClearMasksWithManualFrameConfirm(
|
||
pendingCurrentClearConfirm.propagatedMaskIds,
|
||
'当前帧及传播链',
|
||
{
|
||
manualFrameScopeIds: propagationSpanFrameIdsForClearRequest(pendingCurrentClearConfirm),
|
||
manualFrameTargetMaskIds: pendingCurrentClearConfirm.operation === 'delete'
|
||
? chainMaskIdsForClearRequest(pendingCurrentClearConfirm)
|
||
: undefined,
|
||
manualFrameIncludeMode: pendingCurrentClearConfirm.operation === 'delete'
|
||
? 'target-masks'
|
||
: 'all-frame-masks',
|
||
},
|
||
)}
|
||
className="rounded bg-red-500 px-2 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:opacity-60"
|
||
disabled={isSaving}
|
||
>
|
||
清空所有传播帧
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{pendingClearManualFrameConfirm && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
|
||
<div className="w-full max-w-md rounded-lg border border-amber-400/25 bg-[#151515] p-5 shadow-2xl">
|
||
<h2 className="text-lg font-semibold text-white">是否删除人工/AI 标注帧</h2>
|
||
<p className="mt-2 text-sm leading-relaxed text-gray-300">
|
||
本次清空范围包含第 {pendingClearManualFrameConfirm.manualFrameNumbers.join('、') || '-'} 帧等人工/AI 标注帧。
|
||
是否同时删除这些帧中的遮罩?
|
||
</p>
|
||
<p className="mt-2 text-xs leading-relaxed text-amber-100/70">
|
||
{pendingClearManualFrameConfirm.manualFrameIncludeMode === 'target-masks'
|
||
? '选择删除时只会删除这些帧中本次选中或同传播链对应的遮罩;选择保留时,这些人工/AI 标注帧会整帧保留,只清空其它自动传播帧。'
|
||
: '选择删除时会删除这些帧中的全部遮罩;选择保留时,这些人工/AI 标注帧会整帧保留,只清空其它自动传播帧。'}
|
||
</p>
|
||
<div className="mt-5 grid grid-cols-3 gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPendingClearManualFrameConfirm(null)}
|
||
disabled={isSaving}
|
||
className="rounded border border-white/10 px-2 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleResolveClearManualFrameConfirm(false)}
|
||
disabled={isSaving}
|
||
className="rounded border border-amber-400/30 bg-amber-500/10 px-2 py-2 text-xs font-semibold text-amber-100 hover:bg-amber-500/20 disabled:opacity-60"
|
||
>
|
||
否,保留人工帧
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleResolveClearManualFrameConfirm(true)}
|
||
disabled={isSaving}
|
||
className="rounded bg-red-500 px-2 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:cursor-wait disabled:opacity-50"
|
||
>
|
||
是,删除人工帧
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{pendingClearPropagationRangeConfirm && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
|
||
<div className="w-full max-w-md rounded-lg border border-red-400/25 bg-[#151515] p-5 shadow-2xl">
|
||
<h2 className="text-lg font-semibold text-white">确认清空传播帧</h2>
|
||
<p className="mt-2 text-sm leading-relaxed text-gray-300">
|
||
将在第 {pendingClearPropagationRangeConfirm.rangeStartIndex + 1}-{pendingClearPropagationRangeConfirm.rangeEndIndex + 1} 帧范围内,
|
||
清空同一传播链上的 {pendingClearPropagationRangeConfirm.targetMaskIds.length} 个遮罩。
|
||
</p>
|
||
<p className="mt-2 text-xs leading-relaxed text-red-100/70">
|
||
无关的人工标注或其它 AI 推理结果不会被纳入本次清空。
|
||
</p>
|
||
<div className="mt-5 flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPendingClearPropagationRangeConfirm(null)}
|
||
disabled={isSaving}
|
||
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void executeClearPropagationFrameRange(pendingClearPropagationRangeConfirm)}
|
||
disabled={isSaving}
|
||
className="rounded bg-red-500 px-3 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:cursor-wait disabled:opacity-50"
|
||
>
|
||
确认清空
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{pendingBooleanRangeConfirm && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
|
||
<div className="w-full max-w-md rounded-lg border border-emerald-400/25 bg-[#151515] p-5 shadow-2xl">
|
||
<h2 className="text-lg font-semibold text-white">确认{booleanOperationLabel(pendingBooleanRangeConfirm.request.operation)}</h2>
|
||
<p className="mt-2 text-sm leading-relaxed text-gray-300">
|
||
将在第 {pendingBooleanRangeConfirm.rangeStartIndex + 1}-{pendingBooleanRangeConfirm.rangeEndIndex + 1} 帧范围内,
|
||
对 {pendingBooleanRangeConfirm.targetFrameIds.length} 帧存在对应传播链的区域执行{booleanOperationLabel(pendingBooleanRangeConfirm.request.operation)}。
|
||
</p>
|
||
<p className="mt-2 text-xs leading-relaxed text-emerald-100/70">
|
||
本操作会保留传播帧原有来源属性,只把几何结果标记为待保存。
|
||
</p>
|
||
<div className="mt-5 flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPendingBooleanRangeConfirm(null)}
|
||
disabled={isSaving}
|
||
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void executeBooleanFrameRangeOperation(pendingBooleanRangeConfirm)}
|
||
disabled={isSaving}
|
||
className="rounded bg-emerald-500 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-400 disabled:cursor-wait disabled:opacity-50"
|
||
>
|
||
确认{booleanOperationLabel(pendingBooleanRangeConfirm.request.operation)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Bottom Timeline */}
|
||
<FrameTimeline
|
||
propagationRange={visibleTimelineRange}
|
||
propagationHistory={propagationHistory}
|
||
propagationRangeSelectionActive={isPropagationRangeSelecting}
|
||
propagationRangeDisabled={isPropagating || isSaving || isExporting || isImportingGt}
|
||
onPropagationRangeChange={handlePropagationRangeChange}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|