Files
Pre_Seg_Server/src/components/VideoWorkspace.tsx
admin 1971640a67 隐藏传播进度重复提示
- 传播进度蓝色面板显示任务 message 时隐藏顶栏左侧灰色 statusMessage,避免同一传播文字重复出现。

- 补充 VideoWorkspace 回归测试,确认传播运行提示只出现一次且位于进度面板内。

- 更新前端审计、设计冻结、测试计划和项目指南文档。
2026-05-04 04:44:32 +08:00

2487 lines
110 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { 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>
);
}