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>
  );
}
