import {
  BarsOutlined,
  BgColorsOutlined,
  CheckOutlined,
  ClockCircleOutlined,
  CloseOutlined,
  CopyOutlined,
  DeleteOutlined,
  DownloadOutlined,
  DownOutlined,
  EditOutlined,
  FileImageOutlined,
  FileTextOutlined,
  FolderOpenOutlined,
  PictureOutlined,
  ReloadOutlined,
  SaveOutlined,
  SearchOutlined,
  SendOutlined,
  ThunderboltOutlined,
  UploadOutlined,
  VideoCameraOutlined,
} from "@ant-design/icons";
import {
  ReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import "../../styles/pages/canvas.css";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
import { communityClient } from "../../api/communityClient";
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import {
  DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
  DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS,
} from "../../hooks/useSmoothedProgress";
import type { WebCanvasWorkflow } from "../../types";
import type { AssetLibraryCategory } from "../assets/localAssetStore";
import {
  buildCanvasCommunityCaseInput,
  buildCanvasWorkflowJson,
  buildWorkflowFileName,
  textToDataUrl,
} from "./canvasCommunityPublish";
import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence";
import { normalizeCanvasWorkflowSchema } from "./canvasWorkflowSchema";
import { createBlankWorkflow } from "../../data/workflows";
import { useCanvasHistory, type CanvasHistorySnapshot } from "./useCanvasHistory";
import { useCanvasKeyboard } from "./useCanvasKeyboard";
import { useCanvasNodeDrag } from "./useCanvasNodeDrag";
import { useCanvasGeneration, addCanvasGenKeepalive, removeCanvasGenKeepalive } from "./useCanvasGeneration";
import { useCanvasAssetSummary, useCanvasVisibleNodes } from "./useCanvasDerivedState";
import {
  toHappyHorseDisplayModel,
} from "../../utils/happyHorseRouting";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolicy";
import { filterImageModelOptionsForSession } from "../../utils/imageModelVisibility";
import { translateTaskError } from "../../utils/translateTaskError";
import type {
  CanvasAssetSaveSource,
  CanvasCopiedNode,
  CanvasConnectorDrag,
  CanvasConnectorFollowOffset,
  CanvasFloatingMenuPosition,
  CanvasImageFocusDrag,
  CanvasImageFocusSelection,
  CanvasImageNode,
  CanvasImageReferenceItem,
  CanvasManualLink,
  CanvasNodeBounds,
  CanvasNodeKind,
  CanvasNodePackage,
  CanvasNodePort,
  CanvasNodeSize,
  CanvasOption,
  CanvasPageProps,
  CanvasPoint,
  CanvasProjectSaveState,
  CanvasSelectedNode,
  CanvasStyleCase,
  CanvasStylePickerTab,
  CanvasPromptMentionOption,
  CanvasPromptMentionState,
  CanvasTextNode,
  CanvasVideoNode,
  CanvasViewport,
} from "./canvasTypes";
import {
  assetLibraryCategories,
  canvasAutoSaveDebounceMs,
  canvasAutoSaveIdleTimeoutMs,
  canvasNodeClickMoveThreshold,
  canvasStylePickerCategories,
  canvasStylePickerTabs,
  connectorAnchorOutset,
  connectorFollowRadius,
  connectorFollowStrength,
  connectorMaxFollowOffset,
  defaultImageModel,
  defaultTextModelId,
  defaultVideoModel,
  imageFocusRatioOptions,
  imageModelOptions as fallbackCanvasImageModelOptions,
  imageRatioOptions,
  videoDurationOptions,
  videoRatioOptions,
} from "./canvasConstants";
import {
  applyImageFocusRatioFromTopLeft,
  blobToDataUrl,
  buildCopyTitle,
  clampCanvasPercent,
  clampCanvasNodeSize,
  clampCanvasViewportZoom,
  communityCaseToCanvasStyleCase,
  createCanvasNodeSize,
  createStyleReferenceFromCase,
  delay,
  doCanvasRectsIntersect,
  getCanvasLinkIdentity,
  getCanvasNodeSideDirection,
  getCanvasPortIdentity,
  getCanvasSelectionKey,
  getDefaultImageQuality,
  getDefaultVideoQuality,
  getImageQualityOptions,
  getVideoQualityOptions,
  hasCanvasOptionValue,
  normalizeCanvasGenerationProgress,
  normalizeCanvasLinkPorts,
  normalizeCanvasSelectionRect,
  normalizeImageFocusSelectionFromAnchor,
  positionFloatingMenu,
  resolveImageQuality,
  resolveVideoQuality,
  waitForImageTaskResult,
  waitForVideoTaskResult,
} from "./canvasUtils";
import {
  createImageNodesFromWorkflow,
  createManualLinksFromWorkflow,
  createNodePackagesFromWorkflow,
  createTextNodesFromWorkflow,
  createVideoNodesFromWorkflow,
  createWorkflowPackagesFromCanvasPackages,
  formatCanvasProjectUpdatedAt,
  resolveAssetCategory,
} from "./canvasWorkflowDeserialize";
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
import { CanvasMarkingPopover } from "./CanvasMarkingPopover";
import { CanvasPromptMentionTextarea, CanvasTextPromptComposer } from "./CanvasTextPromptComposer";
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing";

const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({
  value: option.value,
  label: option.label,
}));

// --- Canvas generation keep-alive (survives page refresh / view switch) ---

const MENTION_TOKEN_RE = /@(?:图片|视频|文本)\d+/g;

function buildNodeMentionOptions(
  kind: CanvasNodeKind,
  nodeId: string,
  imageNodes: CanvasImageNode[],
  videoNodes: CanvasVideoNode[],
  textNodes: CanvasTextNode[],
  getIncoming: (kind: CanvasNodeKind, nid: string) => CanvasNodePort[],
  manualLinks: CanvasManualLink[],
): CanvasPromptMentionOption[] {
  const incomingPorts = getIncoming(kind, nodeId);
  const outgoingPorts: CanvasNodePort[] = [];
  manualLinks.forEach((link) => {
    if (link.from.kind === kind && link.from.nodeId === nodeId) {
      outgoingPorts.push(link.to);
    } else if (link.to.kind === kind && link.to.nodeId === nodeId) {
      outgoingPorts.push(link.from);
    }
  });
  // Source property links
  if (kind === "text") {
    imageNodes.forEach((img) => {
      if (img.sourceTextNodeId && img.sourceTextNodeId === nodeId) {
        outgoingPorts.push({ kind: "image", nodeId: img.id, side: "left", slot: "center" });
      }
    });
    videoNodes.forEach((vid) => {
      if (vid.sourceTextNodeId && vid.sourceTextNodeId === nodeId) {
        outgoingPorts.push({ kind: "video", nodeId: vid.id, side: "left", slot: "center" });
      }
    });
  } else if (kind === "image") {
    const self = imageNodes.find((n) => n.id === nodeId);
    if (self?.sourceTextNodeId) {
      outgoingPorts.push({ kind: "text", nodeId: self.sourceTextNodeId, side: "right", slot: "center" });
    }
    if (self?.sourceImageNodeId) {
      outgoingPorts.push({ kind: "image", nodeId: self.sourceImageNodeId, side: "right", slot: "center" });
    }
  } else if (kind === "video") {
    const self = videoNodes.find((n) => n.id === nodeId);
    if (self?.sourceTextNodeId) {
      outgoingPorts.push({ kind: "text", nodeId: self.sourceTextNodeId, side: "right", slot: "center" });
    }
  }
  const allPorts = [...incomingPorts, ...outgoingPorts];
  const options: CanvasPromptMentionOption[] = [];
  let imageIdx = 0;
  let videoIdx = 0;
  let textIdx = 0;
  const seen = new Set<string>();

  for (const port of allPorts) {
    if (seen.has(`${port.kind}:${port.nodeId}`)) continue;
    seen.add(`${port.kind}:${port.nodeId}`);

    if (port.kind === "image") {
      const node = imageNodes.find((n) => n.id === port.nodeId);
      imageIdx++;
      const label = `图片${imageIdx}`;
      options.push({
        token: `@${label}`,
        label,
        kind: "image",
        nodeId: port.nodeId,
        nodeTitle: node?.title || label,
        previewUrl: node?.imageUrl,
        searchText: `${label} ${node?.title || ""} ${node?.prompt || ""}`.toLowerCase(),
      });
    } else if (port.kind === "video") {
      const node = videoNodes.find((n) => n.id === port.nodeId);
      videoIdx++;
      const label = `视频${videoIdx}`;
      options.push({
        token: `@${label}`,
        label,
        kind: "video",
        nodeId: port.nodeId,
        nodeTitle: node?.title || label,
        previewUrl: node?.videoUrl,
        searchText: `${label} ${node?.title || ""} ${node?.prompt || ""}`.toLowerCase(),
      });
    } else if (port.kind === "text") {
      const node = textNodes.find((n) => n.id === port.nodeId);
      textIdx++;
      const label = `文本${textIdx}`;
      options.push({
        token: `@${label}`,
        label,
        kind: "text",
        nodeId: port.nodeId,
        nodeTitle: node?.title || label,
        searchText: `${label} ${node?.title || ""} ${node?.prompt || ""}`.toLowerCase(),
      });
    }
  }
  return options;
}

const CAMERA_MOTION_PRESETS = [
  { value: "", label: "无运镜" },
  { value: "push-in", label: "推近", prompt: "镜头缓慢推近主体" },
  { value: "pull-out", label: "拉远", prompt: "镜头缓慢拉远，展现全景" },
  { value: "pan-left", label: "左移", prompt: "镜头水平向左平移" },
  { value: "pan-right", label: "右移", prompt: "镜头水平向右平移" },
  { value: "tilt-up", label: "上仰", prompt: "镜头向上仰拍" },
  { value: "tilt-down", label: "下俯", prompt: "镜头向下俯拍" },
  { value: "orbit", label: "环绕", prompt: "镜头环绕主体360度旋转" },
  { value: "handheld", label: "手持", prompt: "手持摄影风格，轻微晃动增加真实感" },
  { value: "fixed", label: "固定机位", prompt: "固定机位，无镜头运动" },
];

function getCameraMotionPrompt(value: string): string {
  return CAMERA_MOTION_PRESETS.find((p) => p.value === value)?.prompt || "";
}

function CanvasPage({
  workflow: rawWorkflow,
  projectId,
  projects = [],
  projectsLoaded = true,
  onOpenCommunity,
  onOpenProject,
  onStartCreate,
  isAuthenticated,
  session,
  onOpenLogin,
  onSaveWorkflow,
  onCreateTask,
}: CanvasPageProps) {
  const workflow = rawWorkflow || createBlankWorkflow();
  const [contextMenu, setContextMenu] = useState<CanvasFloatingMenuPosition | null>(null);
  const [nodeMenu, setNodeMenu] = useState<CanvasFloatingMenuPosition | null>(null);
  const [textNodeMenu, setTextNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null);
  const [textNodes, setTextNodes] = useState<CanvasTextNode[]>([]);
  const [canvasSelectMenu, setCanvasSelectMenu] = useState<string | null>(null);
  const [copiedCanvasNode, setCopiedCanvasNode] = useState<CanvasCopiedNode | null>(null);
  const [imageNodeMenu, setImageNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null);
  const [imageNodes, setImageNodes] = useState<CanvasImageNode[]>([]);
  const [imageLoadErrors, setImageLoadErrors] = useState<Record<string, string>>({});
  const [imageFocusNodeId, setImageFocusNodeId] = useState<string | null>(null);
  const [imageFocusDraft, setImageFocusDraft] = useState<CanvasImageFocusSelection | null>(null);
  const [imageFocusDrag, setImageFocusDrag] = useState<CanvasImageFocusDrag | null>(null);
  const [canvasToolModal, setCanvasToolModal] = useState<{ tool: "multiGrid" | "upscale" | "inpaint"; imageNode: CanvasImageNode } | null>(null);
  const [stylePickerImageNodeId, setStylePickerImageNodeId] = useState<string | null>(null);
  const [stylePickerCases, setStylePickerCases] = useState<CanvasStyleCase[]>([]);
  const [stylePickerLoading, setStylePickerLoading] = useState(false);
  const [stylePickerError, setStylePickerError] = useState<string | null>(null);
  const [stylePickerReloadToken, setStylePickerReloadToken] = useState(0);
  const [stylePickerTab, setStylePickerTab] = useState<CanvasStylePickerTab>("square");
  const [stylePickerCategory, setStylePickerCategory] = useState(canvasStylePickerCategories[0]);
  const [stylePickerSearch, setStylePickerSearch] = useState("");
  const [recentStyleCases, setRecentStyleCases] = useState<CanvasStyleCase[]>([]);
  const [styleSelectionToast, setStyleSelectionToast] = useState<string | null>(null);
  const [recentProjectsOpen, setRecentProjectsOpen] = useState(false);
  const [currentProjectTitle, setCurrentProjectTitle] = useState(workflow.title || "未命名项目");
  const [projectNameDraft, setProjectNameDraft] = useState(workflow.title || "未命名项目");
  const [projectNameEditing, setProjectNameEditing] = useState(false);
  const [videoNodeMenu, setVideoNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null);
  const [videoNodes, setVideoNodes] = useState<CanvasVideoNode[]>([]);
  const [canvasImageModelOptions, setCanvasImageModelOptions] = useState<CanvasOption[]>(fallbackCanvasImageModelOptions);
  const [canvasVideoModelOptions, setCanvasVideoModelOptions] = useState<CanvasOption[]>(canvasEnterpriseVideoModelOptions);
  const [selectedNode, setSelectedNode] = useState<CanvasSelectedNode | null>(null);
  const [selectedNodes, setSelectedNodes] = useState<CanvasSelectedNode[]>([]);
  const [selectionContextMenu, setSelectionContextMenu] = useState<CanvasFloatingMenuPosition | null>(null);
  const [nodePackages, setNodePackages] = useState<CanvasNodePackage[]>([]);
  const [selectedPackageId, setSelectedPackageId] = useState<string | null>(null);
  const [canvasViewport, setCanvasViewport] = useState<CanvasViewport>({ x: 0, y: 0, zoom: 1 });
  const [manualLinks, setManualLinks] = useState<CanvasManualLink[]>([]);
  const [pendingLinkPort, setPendingLinkPort] = useState<CanvasNodePort | null>(null);
  const [pendingLinkPreviewPoint, setPendingLinkPreviewPoint] = useState<CanvasPoint | null>(null);
  const [connectorFollowOffsets, setConnectorFollowOffsets] = useState<Record<string, CanvasConnectorFollowOffset>>({});
  const [connectorDrag, setConnectorDrag] = useState<CanvasConnectorDrag | null>(null);
  const [connectionDropMenu, setConnectionDropMenu] = useState<{ left: number; top: number; originLeft: number; originTop: number; sourcePort: CanvasNodePort } | null>(null);
  const pendingAutoConnectRef = useRef<CanvasNodePort | null>(null);
  const [pendingImagePosition, setPendingImagePosition] = useState({ x: 0, y: 0 });
  const [pendingImageNodeId, setPendingImageNodeId] = useState<string | null>(null);
  const [pendingImageToImageNodeId, setPendingImageToImageNodeId] = useState<string | null>(null);
  const [markingPopoverNodeId, setMarkingPopoverNodeId] = useState<string | null>(null);
  const [cameraMotionDropdownNodeId, setCameraMotionDropdownNodeId] = useState<string | null>(null);
  const [saveAssetSource, setSaveAssetSource] = useState<CanvasAssetSaveSource | null>(null);
  const [saveAssetOpen, setSaveAssetOpen] = useState(false);
  const [assetName, setAssetName] = useState("文本素材");
  const [assetCategory, setAssetCategory] = useState("");
  const [assetCategoryOpen, setAssetCategoryOpen] = useState(false);
  const [coverSourceOpen, setCoverSourceOpen] = useState(false);
  const [coverLibraryOpen, setCoverLibraryOpen] = useState(false);
  const [assetSaveMode, setAssetSaveMode] = useState<"create" | "existing">("create");
  const [assetCoverUrl, setAssetCoverUrl] = useState("");
  const [serverAssets, setServerAssets] = useState<ServerAssetItem[]>([]);
  const [assetLibraryNotice, setAssetLibraryNotice] = useState<string | null>(null);
  const [isSavingAsset, setIsSavingAsset] = useState(false);
  const [selectedExistingCategory, setSelectedExistingCategory] = useState("");
  const coverFileInputRef = useRef<HTMLInputElement>(null);
  const canvasUploadInputRef = useRef<HTMLInputElement>(null);
  const imageNodeInputRef = useRef<HTMLInputElement>(null);
  const canvasRef = useRef<HTMLElement>(null);
  const canvasDragCounterRef = useRef(0);
  const [isCanvasDragging, setIsCanvasDragging] = useState(false);
  const suppressNextPaneClickRef = useRef(false);
  const canvasAutoSaveTimerRef = useRef<number | null>(null);
  const canvasAutoSaveIdleHandleRef = useRef<number | null>(null);
  const canvasAutoSaveRetryTimerRef = useRef<number | null>(null);
  const canvasAutoSaveInFlightRef = useRef(false);
  const canvasAutoSavePendingRef = useRef(false);
  const lastAutoSavedWorkflowFingerprintRef = useRef("");
  const canvasAutoSaveHydrationRef = useRef(true);
  const textNodeMentionFocusTimerRef = useRef<number | null>(null);
  const textNodeIdRef = useRef(9);
  const imageNodeIdRef = useRef(1);
  const videoNodeIdRef = useRef(1);
  const objectUrlsRef = useRef(new Set<string>());
  const trackObjectUrl = (file: Blob) => {
    const url = URL.createObjectURL(file);
    objectUrlsRef.current.add(url);
    return url;
  };

  const { pushSnapshot, undo, redo } = useCanvasHistory();
  const {
    textGenerationState, imageGenerationState, videoGenerationState,
    generationToast, setGenerationToast,
    imageGenerationInFlightRef, videoGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
    imageGenerationAbortRef, videoGenerationAbortRef,
    canvasGenKeepaliveRestoredRef,
    setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
    restoreKeepaliveTasks, resetGenerationState,
  } = useCanvasGeneration({ setImageNodes, setVideoNodes });
  const isDirtyRef = useRef(false);
  const [spacePanning, setSpacePanning] = useState(false);

  const textNodesRef = useRef(textNodes);
  textNodesRef.current = textNodes;
  const imageNodesRef = useRef(imageNodes);
  imageNodesRef.current = imageNodes;
  const videoNodesRef = useRef(videoNodes);
  videoNodesRef.current = videoNodes;
  const nodePackagesRef = useRef(nodePackages);
  nodePackagesRef.current = nodePackages;
  const zoomRef = useRef(canvasViewport.zoom);
  zoomRef.current = canvasViewport.zoom;
  const dragCallbacksRef = useRef<import("./useCanvasNodeDrag").CanvasNodeDragCallbacks>({
    pushHistorySnapshot: () => {},
    clearCanvasSelection: () => {},
    selectCanvasNode: () => {},
    applyCanvasSelection: () => {},
    getCanvasPointFromClient: () => ({ x: 0, y: 0 }),
    getNodesInSelectionRect: () => [],
    expandCanvasNodePackage: () => {},
  });

  const {
    textNodeDrag, setTextNodeDrag,
    imageNodeDrag, setImageNodeDrag,
    videoNodeDrag, setVideoNodeDrag,
    packageDrag, setPackageDrag,
    selectionDrag, setSelectionDrag,
    nodeResizeDrag,
    canvasPanDrag, setCanvasPanDrag,
    alignGuides,
    handleNodeResizeStart,
  } = useCanvasNodeDrag({
    zoomRef,
    textNodesRef,
    imageNodesRef,
    videoNodesRef,
    nodePackagesRef,
    setTextNodes,
    setImageNodes,
    setVideoNodes,
    setNodePackages,
    setCanvasViewport,
    callbacksRef: dragCallbacksRef,
    suppressNextPaneClickRef,
  });

  useEffect(() => {
    let cancelled = false;

    if (!isAuthenticated) {
      setCanvasImageModelOptions(fallbackCanvasImageModelOptions);
      setCanvasVideoModelOptions(canvasEnterpriseVideoModelOptions);
      return () => {
        cancelled = true;
      };
    }

    modelCapabilitiesClient
      .get()
      .then((capabilities) => {
        if (cancelled) return;
        setCanvasImageModelOptions(capabilities.imageModels.length ? capabilities.imageModels : fallbackCanvasImageModelOptions);
        setCanvasVideoModelOptions(capabilities.videoModels.length ? capabilities.videoModels : canvasEnterpriseVideoModelOptions);
      })
      .catch(() => {
        if (cancelled) return;
        setCanvasImageModelOptions(fallbackCanvasImageModelOptions);
        setCanvasVideoModelOptions(canvasEnterpriseVideoModelOptions);
      });

    return () => {
      cancelled = true;
    };
  }, [isAuthenticated]);

  const visibleImageModelOptions = useMemo(
    () => filterImageModelOptionsForSession(canvasImageModelOptions, session),
    [canvasImageModelOptions, session],
  );
  const fallbackVisibleImageModel = visibleImageModelOptions[0]?.value || defaultImageModel;
  const resolveVisibleImageModel = useCallback(
    (model: string | undefined | null) =>
      hasCanvasOptionValue(visibleImageModelOptions, model || "") ? String(model) : fallbackVisibleImageModel,
    [fallbackVisibleImageModel, visibleImageModelOptions],
  );

  // @-mention state per text node
  const [textNodeMentionStates, setTextNodeMentionStates] = useState<Record<string, CanvasPromptMentionState>>({});

  const closeTextNodeMention = (nodeId: string) => {
    setTextNodeMentionStates((prev) => ({ ...prev, [nodeId]: { open: false, query: "", start: 0, caret: 0, activeIndex: 0 } }));
  };

  const insertTextNodeMention = (nodeId: string, option: CanvasPromptMentionOption, textarea: HTMLTextAreaElement | null, kind?: CanvasNodeKind) => {
    const state = textNodeMentionStates[nodeId];
    if (!state) return;
    const value = textarea?.value || "";
    const nextValue = `${value.slice(0, state.start)}${option.token} ${value.slice(state.caret)}`;
    const nextCaret = state.start + option.token.length + 1;
    if (kind === "image") updateImageNodePrompt(nodeId, nextValue);
    else if (kind === "video") updateVideoNodePrompt(nodeId, nextValue);
    else updateTextNodePrompt(nodeId, nextValue);
    closeTextNodeMention(nodeId);
    if (textNodeMentionFocusTimerRef.current !== null) {
      window.clearTimeout(textNodeMentionFocusTimerRef.current);
    }
    textNodeMentionFocusTimerRef.current = window.setTimeout(() => {
      textNodeMentionFocusTimerRef.current = null;
      if (textarea) {
        textarea.focus();
        textarea.setSelectionRange(nextCaret, nextCaret);
      }
    }, 0);
  };

  const getHistorySnapshot = useCallback((): CanvasHistorySnapshot => ({
    textNodes: textNodes.map((n) => ({ ...n })),
    imageNodes: imageNodes.map((n) => ({ ...n })),
    videoNodes: videoNodes.map((n) => ({ ...n })),
    manualLinks: manualLinks.map((l) => ({ ...l })),
    nodePackages: nodePackages.map((p) => ({ ...p, nodeIds: [...p.nodeIds] })),
  }), [textNodes, imageNodes, videoNodes, manualLinks, nodePackages]);

  const pushHistorySnapshot = useCallback(() => {
    pushSnapshot(getHistorySnapshot());
    isDirtyRef.current = true;
  }, [pushSnapshot, getHistorySnapshot]);

  const applyHistorySnapshot = useCallback((snapshot: CanvasHistorySnapshot) => {
    setTextNodes(snapshot.textNodes as typeof textNodes);
    setImageNodes(snapshot.imageNodes as typeof imageNodes);
    setVideoNodes(snapshot.videoNodes as typeof videoNodes);
    setManualLinks(snapshot.manualLinks as typeof manualLinks);
    setNodePackages(snapshot.nodePackages as typeof nodePackages);
  }, []);

  // Viewport changes are browsing behavior, not content changes — don't trigger save
  // (viewport is still persisted in the snapshot when a content change triggers save)

  // Auto-save status indicator
  const [autoSaveStatus, setAutoSaveStatus] = useState<"saved" | "saving" | "error" | "idle">("idle");
  const autoSaveStatusTimerRef = useRef<number | null>(null);

  useEffect(() => {
    const objectUrls = objectUrlsRef.current;
    return () => {
      if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current);
      if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
      if (autoSaveStatusTimerRef.current !== null) window.clearTimeout(autoSaveStatusTimerRef.current);
      if (textNodeMentionFocusTimerRef.current !== null) window.clearTimeout(textNodeMentionFocusTimerRef.current);
      if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) {
        window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
      }
      objectUrls.forEach((url) => URL.revokeObjectURL(url));
      objectUrls.clear();
    };
  }, []);

  // Save immediately when user leaves page or switches tab (placed after runCanvasAutoSave definition)
  // — see useEffect below near runCanvasAutoSave

  const { canvasAssets, assetCountsByCategory } = useCanvasAssetSummary(serverAssets);
  const shouldShowEmptyProjectState =
    projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0;
  const isWaitingForProjects = isAuthenticated && !projectsLoaded;
  const [projectSaveState, setProjectSaveState] = useState<CanvasProjectSaveState>({
    status: "idle",
    message: "",
  });
  const [communityPublishState, setCommunityPublishState] = useState<CanvasProjectSaveState>({
    status: "idle",
    message: "",
  });

  useEffect(() => {
    const normalizedWorkflow = normalizeCanvasWorkflowSchema(workflow);
    const nextTextNodes = createTextNodesFromWorkflow(normalizedWorkflow);
    const nextImageNodes = createImageNodesFromWorkflow(normalizedWorkflow);
    const nextVideoNodes = createVideoNodesFromWorkflow(normalizedWorkflow);

    resetGenerationState();
    videoGenerationInFlightRef.current.clear();
    setTextNodes(nextTextNodes);
    setImageNodes(nextImageNodes);
    setImageLoadErrors({});
    setImageFocusNodeId(null);
    setImageFocusDraft(null);
    setImageFocusDrag(null);
    setStylePickerImageNodeId(null);
    setVideoNodes(nextVideoNodes);
    setManualLinks(createManualLinksFromWorkflow(normalizedWorkflow));
    setNodePackages(createNodePackagesFromWorkflow(normalizedWorkflow));
    setSelectedNode(null);
    setSelectedNodes([]);
    setSelectedPackageId(null);
    setPendingLinkPort(null);
    setPendingLinkPreviewPoint(null);
    setConnectorDrag(null);
    setCanvasViewport(normalizedWorkflow.viewport || { x: 0, y: 0, zoom: 1 });
    setRecentProjectsOpen(false);
    setCurrentProjectTitle(workflow.title || "未命名项目");
    setProjectNameDraft(workflow.title || "未命名项目");
    setProjectNameEditing(false);
    setProjectSaveState({ status: "idle", message: "" });
    setCommunityPublishState({ status: "idle", message: "" });
    canvasAutoSaveHydrationRef.current = true;
    lastAutoSavedWorkflowFingerprintRef.current = buildCanvasWorkflowJson(normalizedWorkflow);
    canvasAutoSavePendingRef.current = false;
    textNodeIdRef.current = nextTextNodes.length + 1;
    imageNodeIdRef.current = nextImageNodes.length + 1;
    videoNodeIdRef.current = nextVideoNodes.length + 1;

    // Reset keepalive flag so tasks can be restored for this project
    canvasGenKeepaliveRestoredRef.current = false;
    if (projectId && isAuthenticated) {
      restoreKeepaliveTasks(projectId, nextImageNodes, nextVideoNodes);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [workflow.id, workflow.nodes, projectId]);

  useEffect(() => {
    if (!isAuthenticated) {
      setServerAssets([]);
      setAssetLibraryNotice(null);
      return;
    }

    let cancelled = false;
    assetClient
      .list()
      .then((items) => {
        if (cancelled) return;
        setServerAssets(items);
        setAssetLibraryNotice(items.length ? null : "服务器资产库暂无内容");
      })
      .catch((error) => {
        if (cancelled) return;
        setServerAssets([]);
        setAssetLibraryNotice(error instanceof Error ? error.message : "服务器资产库暂时不可用");
      });

    return () => {
      cancelled = true;
    };
  }, [isAuthenticated]);

  useEffect(() => {
    if (!stylePickerImageNodeId) return;

    let cancelled = false;
    setStylePickerLoading(true);
    setStylePickerError(null);
    communityClient
      .listApprovedCases({ limit: 120, tag: "画布页面社区", sort: "latest" })
      .then((items) => {
        if (cancelled) return;
        const seen = new Set<string>();
        const cases = items.flatMap((item) => {
          const styleCase = communityCaseToCanvasStyleCase(item);
          if (!styleCase || seen.has(styleCase.id)) return [];
          seen.add(styleCase.id);
          return [styleCase];
        });
        setStylePickerCases(cases);
      })
      .catch((error) => {
        if (cancelled) return;
        setStylePickerCases([]);
        setStylePickerError(error instanceof Error ? error.message : "社区风格暂时加载失败");
      })
      .finally(() => {
        if (!cancelled) setStylePickerLoading(false);
      });

    return () => {
      cancelled = true;
    };
  }, [stylePickerImageNodeId, stylePickerReloadToken]);

  useEffect(() => {
    if (!stylePickerImageNodeId) return undefined;
    const handleKeyDown = (event: globalThis.KeyboardEvent) => {
      if (event.key === "Escape") {
        setStylePickerImageNodeId(null);
      }
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => {
      window.removeEventListener("keydown", handleKeyDown);
    };
  }, [stylePickerImageNodeId]);

  useEffect(() => {
    if (!styleSelectionToast) return undefined;
    const timer = window.setTimeout(() => setStyleSelectionToast(null), 2200);
    return () => window.clearTimeout(timer);
  }, [styleSelectionToast]);

  useEffect(() => {
    if (!recentProjectsOpen) return undefined;
    const handleKeyDown = (event: globalThis.KeyboardEvent) => {
      if (event.key === "Escape") {
        setRecentProjectsOpen(false);
      }
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [recentProjectsOpen]);

  useEffect(() => {
    if (!imageFocusDrag) return;

    const updateDraftFromClientPoint = (clientX: number, clientY: number) => {
      const { rect } = imageFocusDrag;
      const end = {
        x: clampCanvasPercent(((clientX - rect.left) / rect.width) * 100),
        y: clampCanvasPercent(((clientY - rect.top) / rect.height) * 100),
      };
      const ratio = imageFocusDraft?.ratio || "16:9";
      const nextSelection = normalizeImageFocusSelectionFromAnchor(
        { x: imageFocusDrag.startX, y: imageFocusDrag.startY },
        end,
        ratio,
        rect.width / rect.height,
      );
      if (nextSelection.width < 1 || nextSelection.height < 1) {
        setImageFocusDraft(null);
        return;
      }
      setImageFocusDraft(nextSelection);
    };

    const handleMove = (event: globalThis.MouseEvent) => {
      updateDraftFromClientPoint(event.clientX, event.clientY);
    };
    const handleUp = (event: globalThis.MouseEvent) => {
      updateDraftFromClientPoint(event.clientX, event.clientY);
      setImageFocusDrag(null);
    };

    window.addEventListener("mousemove", handleMove);
    window.addEventListener("mouseup", handleUp);
    return () => {
      window.removeEventListener("mousemove", handleMove);
      window.removeEventListener("mouseup", handleUp);
    };
  }, [imageFocusDrag, imageFocusDraft?.ratio]);

  useEffect(() => {
    if (!imageFocusNodeId) return undefined;
    const handleKeyDown = (event: globalThis.KeyboardEvent) => {
      if (event.key === "Escape") {
        setImageFocusNodeId(null);
        setImageFocusDraft(null);
        setImageFocusDrag(null);
      }
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [imageFocusNodeId]);

  const saveCanvasAssetToServer = async (type: AssetLibraryCategory) => {
    if (!isAuthenticated) {
      onOpenLogin();
      return;
    }

    setIsSavingAsset(true);
    setAssetLibraryNotice(null);
    try {
      const savedAsset = await assetClient.create({
        type,
        name: saveAssetSource?.name || assetName || "画布素材",
        description: saveAssetSource?.description || "从画布保存的素材。",
        url: saveAssetSource?.url,
        imageUrl: saveAssetSource?.imageUrl || assetCoverUrl,
        tags: [
          saveAssetSource?.kind === "image"
            ? "图片节点"
            : saveAssetSource?.kind === "video"
              ? "视频节点"
              : "文本节点",
        ],
        status: "ready",
        sourceProjectId: projectId || workflow.id,
      });
      setServerAssets((current) => [savedAsset, ...current.filter((asset) => asset.id !== savedAsset.id)]);
      setSaveAssetOpen(false);
      setSaveAssetSource(null);
      setSelectedExistingCategory("");
      setAssetCategoryOpen(false);
      setCoverSourceOpen(false);
      setCoverLibraryOpen(false);
    } catch (error) {
      setAssetLibraryNotice(error instanceof Error ? error.message : "保存到服务器资产库失败");
    } finally {
      setIsSavingAsset(false);
    }
  };

  const getTextNodePositionFromClient = (clientX: number, clientY: number) => {
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return { x: 0, y: 0 };

    const worldX = (clientX - rect.left - canvasViewport.x) / canvasViewport.zoom;
    const worldY = (clientY - rect.top - canvasViewport.y) / canvasViewport.zoom;
    return {
      x: worldX - rect.width / 2,
      y: worldY - rect.height / 2,
    };
  };

  const getCanvasPointFromClient = (clientX: number, clientY: number) => {
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return { x: 0, y: 0 };

    return {
      x: clientX - rect.left,
      y: clientY - rect.top,
    };
  };

  const getCanvasWorldPointFromClient = (clientX: number, clientY: number) => {
    const point = getCanvasPointFromClient(clientX, clientY);
    return {
      x: (point.x - canvasViewport.x) / canvasViewport.zoom,
      y: (point.y - canvasViewport.y) / canvasViewport.zoom,
    };
  };

  const isActiveSelectedNode = (kind: CanvasNodeKind, id: string) =>
    selectedNode?.kind === kind && selectedNode.id === id;

  const isSelectedNode = (kind: CanvasNodeKind, id: string) =>
    selectedNodes.some((node) => node.kind === kind && node.id === id);

  const isMultiSelectedNode = (kind: CanvasNodeKind, id: string) =>
    selectedNodes.length > 1 && isSelectedNode(kind, id);

  const findNodePackage = (kind: CanvasNodeKind, id: string): CanvasNodePackage | null =>
    nodePackages.find((pkg) =>
      pkg.nodeIds.some((node) => node.kind === kind && node.id === id)
    ) || null;

  const applyCanvasSelection = (nodes: CanvasSelectedNode[]) => {
    const uniqueNodes = Array.from(
      new Map(nodes.map((node) => [getCanvasSelectionKey(node), node])).values()
    );
    setSelectedPackageId(null);
    setSelectedNodes(uniqueNodes);
    setSelectedNode(uniqueNodes.length === 1 ? uniqueNodes[0] : null);
  };

  const clearCanvasSelection = () => {
    setSelectedNodes([]);
    setSelectedNode(null);
    setSelectedPackageId(null);
    setSelectionContextMenu(null);
  };

  const selectCanvasNode = (kind: CanvasNodeKind, id: string, addToSelection = false) => {
    if (addToSelection) {
      const key = getCanvasSelectionKey({ kind, id });
      const exists = selectedNodes.some((n) => getCanvasSelectionKey(n) === key);
      if (exists) {
        const next = selectedNodes.filter((n) => getCanvasSelectionKey(n) !== key);
        applyCanvasSelection(next);
      } else {
        applyCanvasSelection([...selectedNodes, { kind, id }]);
      }
      return;
    }
    applyCanvasSelection([{ kind, id }]);
  };

  const openCanvasSelectionContextMenu = (clientX: number, clientY: number) => {
    const menuPosition = positionFloatingMenu(clientX, clientY, 260, 180, 6);
    setSelectionContextMenu({
      ...menuPosition,
      originLeft: clientX,
      originTop: clientY,
    });
    setContextMenu(null);
    setNodeMenu(null);
    closeNodeContextMenus();
    setCanvasSelectMenu(null);
  };

  const getCanvasSelectedPackageCount = (nodes = selectedNodes) => {
    if (!nodes.length) return 0;
    const selectedKeys = new Set(nodes.map((node) => getCanvasSelectionKey(node)));
    return nodePackages.filter((nodePackage) =>
      nodePackage.nodeIds.some((node) => selectedKeys.has(getCanvasSelectionKey(node)))
    ).length;
  };

  const openCanvasNodePackageContextMenu = (
    nodePackage: CanvasNodePackage,
    clientX: number,
    clientY: number
  ) => {
    setSelectedNodes([]);
    setSelectedNode(null);
    setSelectedPackageId(nodePackage.id);
    openCanvasSelectionContextMenu(clientX, clientY);
  };

  const handleCanvasNodePackagePointer = (
    event: MouseEvent<HTMLElement>,
    nodePackage: CanvasNodePackage
  ) => {
    event.preventDefault();
    event.stopPropagation();
    openCanvasNodePackageContextMenu(nodePackage, event.clientX, event.clientY);
  };

  const startPackageDrag = (
    event: React.MouseEvent<HTMLElement>,
    nodePackage: CanvasNodePackage,
    collapsed: boolean
  ) => {
    if (event.button !== 0) return;
    event.preventDefault();
    event.stopPropagation();

    const textOrigins: Record<string, CanvasPoint> = {};
    const imageOrigins: Record<string, CanvasPoint> = {};
    const videoOrigins: Record<string, CanvasPoint> = {};

    for (const member of nodePackage.nodeIds) {
      if (member.kind === "text") {
        const node = textNodes.find((n) => n.id === member.id);
        if (node) textOrigins[node.id] = { ...node.position };
      } else if (member.kind === "image") {
        const node = imageNodes.find((n) => n.id === member.id);
        if (node) imageOrigins[node.id] = { ...node.position };
      } else if (member.kind === "video") {
        const node = videoNodes.find((n) => n.id === member.id);
        if (node) videoOrigins[node.id] = { ...node.position };
      }
    }

    setSelectedPackageId(nodePackage.id);
    setPackageDrag({
      packageId: nodePackage.id,
      collapsed,
      startX: event.clientX,
      startY: event.clientY,
      hasMoved: false,
      textOrigins,
      imageOrigins,
      videoOrigins,
      collapsedBounds: collapsed ? nodePackage.collapsedBounds : undefined,
    });
  };

  const startSelectedNodesDrag = (event: React.MouseEvent<HTMLElement>) => {
    if (event.button !== 0 || selectedNodes.length < 2) return;
    event.preventDefault();
    event.stopPropagation();

    const textOrigins: Record<string, CanvasPoint> = {};
    const imageOrigins: Record<string, CanvasPoint> = {};
    const videoOrigins: Record<string, CanvasPoint> = {};

    for (const member of selectedNodes) {
      if (member.kind === "text") {
        const node = textNodes.find((n) => n.id === member.id);
        if (node) textOrigins[node.id] = { ...node.position };
      } else if (member.kind === "image") {
        const node = imageNodes.find((n) => n.id === member.id);
        if (node) imageOrigins[node.id] = { ...node.position };
      } else if (member.kind === "video") {
        const node = videoNodes.find((n) => n.id === member.id);
        if (node) videoOrigins[node.id] = { ...node.position };
      }
    }

    setPackageDrag({
      packageId: `__selection_drag_${Date.now()}`,
      collapsed: false,
      startX: event.clientX,
      startY: event.clientY,
      hasMoved: false,
      textOrigins,
      imageOrigins,
      videoOrigins,
    });
  };

  const packageSelectedCanvasNodes = () => {
    const uniqueNodes = Array.from(
      new Map(selectedNodes.map((node) => [getCanvasSelectionKey(node), node])).values()
    );
    if (uniqueNodes.length < 2) return;
    pushHistorySnapshot();
    const nextPackage: CanvasNodePackage = {
      id: `canvas-node-package-${Date.now()}`,
      title: `打包节点 ${nodePackages.length + 1}`,
      nodeIds: uniqueNodes,
      updatedAt: new Date().toISOString(),
    };
    setNodePackages((currentPackages) => [...currentPackages, nextPackage]);
    setSelectedNodes([]);
    setSelectedNode(null);
    setSelectedPackageId(nextPackage.id);
    setSelectionContextMenu(null);
    setStyleSelectionToast(`已打包 ${uniqueNodes.length} 个节点`);
  };

  const unpackageSelectedCanvasNodes = () => {
    if (selectedPackageId) {
      pushHistorySnapshot();
      const activePackage = nodePackages.find((nodePackage) => nodePackage.id === selectedPackageId);
      setNodePackages((currentPackages) =>
        currentPackages.filter((nodePackage) => nodePackage.id !== selectedPackageId)
      );
      setSelectedNodes([]);
      setSelectedNode(null);
      setSelectedPackageId(null);
      setSelectionContextMenu(null);
      setStyleSelectionToast(activePackage ? `已取消打包「${activePackage.title}」` : "已取消打包");
      return;
    }

    const uniqueNodes = Array.from(
      new Map(selectedNodes.map((node) => [getCanvasSelectionKey(node), node])).values()
    );
    if (!uniqueNodes.length) {
      setSelectionContextMenu(null);
      return;
    }

    const selectedKeys = new Set(uniqueNodes.map((node) => getCanvasSelectionKey(node)));
    const affectedPackages = nodePackages.filter((nodePackage) =>
      nodePackage.nodeIds.some((node) => selectedKeys.has(getCanvasSelectionKey(node)))
    );
    if (!affectedPackages.length) {
      setSelectionContextMenu(null);
      setStyleSelectionToast("当前节点未被打包");
      return;
    }

    setNodePackages((currentPackages) =>
      currentPackages
        .map((nodePackage) => {
          if (!nodePackage.nodeIds.some((node) => selectedKeys.has(getCanvasSelectionKey(node)))) {
            return nodePackage;
          }
          const remainingNodeIds = nodePackage.nodeIds.filter(
            (node) => !selectedKeys.has(getCanvasSelectionKey(node))
          );
          return remainingNodeIds.length >= 2 ? { ...nodePackage, nodeIds: remainingNodeIds } : null;
        })
        .filter((nodePackage): nodePackage is CanvasNodePackage => Boolean(nodePackage))
    );
    setSelectionContextMenu(null);
    setStyleSelectionToast(`已取消 ${affectedPackages.length} 个打包关联`);
  };

  const collapseSelectedCanvasPackage = () => {
    if (!selectedPackageId) return;
    const activePackage = nodePackages.find((nodePackage) => nodePackage.id === selectedPackageId);
    if (!activePackage) return;
    const collapsedBounds = getCanvasNodePackageBounds(activePackage) || activePackage.collapsedBounds;
    setNodePackages((currentPackages) =>
      currentPackages.map((nodePackage) =>
        nodePackage.id === selectedPackageId
          ? {
              ...nodePackage,
              collapsed: true,
              collapsedBounds,
              updatedAt: new Date().toISOString(),
            }
          : nodePackage
      )
    );
    setSelectedNodes([]);
    setSelectedNode(null);
    setSelectionContextMenu(null);
    setStyleSelectionToast(`已折叠「${activePackage.title}」`);
  };

  const expandCanvasNodePackage = (nodePackage: CanvasNodePackage) => {
    setNodePackages((currentPackages) =>
      currentPackages.map((currentPackage) =>
        currentPackage.id === nodePackage.id
          ? {
              ...currentPackage,
              collapsed: false,
              updatedAt: new Date().toISOString(),
            }
          : currentPackage
      )
    );
    setSelectedNodes([]);
    setSelectedNode(null);
    setSelectedPackageId(nodePackage.id);
    setSelectionContextMenu(null);
    setStyleSelectionToast(`已展开「${nodePackage.title}」`);
  };

  const addTextNode = (source?: CanvasTextNode, position = { x: 0, y: 0 }) => {
    const nodeNumber = textNodeIdRef.current;
    textNodeIdRef.current += 1;
    pushHistorySnapshot();
    const node: CanvasTextNode = {
      id: `text-node-${nodeNumber}-${Date.now()}`,
      title: source ? buildCopyTitle(source.title) : `文本节点 ${nodeNumber}`,
      prompt: source?.prompt ?? "",
      content: source?.content ?? "",
      isEditingContent: false,
      isComposerOpen: source?.isComposerOpen ?? !source?.content,
      selectedModelId: defaultTextModelId,
      position,
      size: source?.size ?? createCanvasNodeSize("text"),
    };

    setTextNodes((currentNodes) => [...currentNodes, node]);
    setSelectedNode({ kind: "text", id: node.id });
  };

  const duplicateTextNode = (source: CanvasTextNode, position = { x: source.position.x + 120, y: source.position.y }) => {
    addTextNode(source, position);
  };

  const updateTextNodePrompt = (nodeId: string, prompt: string) => {
    setTextNodes((currentNodes) =>
      currentNodes.map((node) =>
        node.id === nodeId
          ? {
              ...node,
              prompt,
            }
          : node
      )
    );
  };

  const updateTextNodeContent = (nodeId: string, content: string) => {
    setTextNodes((currentNodes) =>
      currentNodes.map((node) => (node.id === nodeId ? { ...node, content } : node))
    );
  };

  const setTextNodeContentEditing = (nodeId: string, isEditingContent: boolean) => {
    setTextNodes((currentNodes) =>
      currentNodes.map((node) => (node.id === nodeId ? { ...node, isEditingContent } : node))
    );
  };

  const finishTextNodeContentEditing = (nodeId: string) => {
    setTextNodes((currentNodes) =>
      currentNodes.map((node) =>
        node.id === nodeId
          ? {
              ...node,
              isEditingContent: false,
              isComposerOpen: node.content.trim() ? false : node.isComposerOpen,
            }
          : node
      )
    );
  };

  const handleGenerateTextNode = async (nodeId: string) => {
    const textNode = textNodes.find((node) => node.id === nodeId);
    if (!textNode || textGenerationInFlightRef.current.has(nodeId)) return;

    let prompt = getEffectiveNodePrompt("text", nodeId, textNode.prompt);
    // Resolve @-mentions: @图片1 → 第1张图片
    if (MENTION_TOKEN_RE.test(prompt)) {
      const mentionOptions = buildNodeMentionOptions("text", nodeId, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
      prompt = prompt.replace(MENTION_TOKEN_RE, (match) => {
        const opt = mentionOptions.find((o) => o.token === match);
        if (!opt) return match;
        const num = match.match(/\d+/)![0];
        const kindMap: Record<string, string> = { image: "张图片", video: "段视频", text: "个文本" };
        return `第${num}${kindMap[opt.kind] || "个节点"}`;
      });
    }
    if (!prompt) {
      setTextGenerationStatus(nodeId, { status: "error", message: "请输入提示词或连接上游节点" });
      return;
    }

    if (!isAuthenticated) {
      onOpenLogin();
      return;
    }

    const controller = new AbortController();
    let streamedText = "";
    textGenerationInFlightRef.current.add(nodeId);
    textGenerationAbortControllersRef.current.set(nodeId, controller);
    setTextGenerationStatus(nodeId, { status: "running", message: "AI 正在生成文本" });
    setTextNodes((currentNodes) =>
      currentNodes.map((node) =>
        node.id === nodeId
          ? { ...node, content: "", isEditingContent: false }
          : node
      )
    );

    try {
      await aiGenerationClient.streamChat(
        {
          model: defaultTextModelId,
          messages: [{ role: "user", content: prompt }],
          temperature: 0.7,
        },
        (chunk) => {
          if (controller.signal.aborted) return;
          streamedText += chunk;
          setTextNodes((currentNodes) =>
            currentNodes.map((node) =>
              node.id === nodeId
                ? { ...node, content: streamedText, isEditingContent: false }
                : node
            )
          );
        },
        controller.signal,
      );

      if (controller.signal.aborted) return;
      const finalText = streamedText.trim();
      if (!finalText) {
        throw new Error("AI 没有返回文本，请换个提示词再试");
      }
      setTextNodes((currentNodes) =>
        currentNodes.map((node) =>
          node.id === nodeId
            ? { ...node, content: finalText, isEditingContent: false }
            : node
        )
      );
      setTextGenerationStatus(nodeId, { status: "success", message: "文本生成完成" });
    } catch (error) {
      if (controller.signal.aborted) return;
      setTextGenerationStatus(nodeId, {
        status: "error",
        message: error instanceof Error ? error.message : "文本生成失败",
      });
    } finally {
      textGenerationInFlightRef.current.delete(nodeId);
      textGenerationAbortControllersRef.current.delete(nodeId);
    }
  };

  const reversePromptFromLinkedNode = async (source: CanvasTextNode) => {
    const sourceLinkedImages = imageNodes.filter((img) => img.sourceTextNodeId === source.id && img.imageUrl);
    const manualLinkedImageIds = new Set(
      manualLinks
        .filter(
          (link) =>
            (link.from.kind === "image" && link.to.kind === "text" && link.to.nodeId === source.id) ||
            (link.from.kind === "text" && link.to.kind === "image" && link.from.nodeId === source.id)
        )
        .map((link) => (link.from.kind === "image" ? link.from.nodeId : link.to.nodeId))
    );
    const manualLinkedImages = imageNodes.filter(
      (img) => manualLinkedImageIds.has(img.id) && img.imageUrl
    );
    const allLinkedImages = [...sourceLinkedImages, ...manualLinkedImages];

    if (!allLinkedImages.length) {
      setAssetLibraryNotice("请先将图片链接到文本节点后再反推提示词");
      return;
    }

    const linkedImage = allLinkedImages[0];
    const imageUrl = linkedImage.imageUrl!;

    setTextNodes((nodes) =>
      nodes.map((n) => n.id === source.id ? { ...n, content: "正在分析图片，生成提示词...", isEditingContent: false } : n)
    );

    try {
      const result = await aiGenerationClient.chatCompletion({
        model: "qwen3.6-plus",
        messages: [
          {
            role: "user",
            content: [
              { type: "image_url", image_url: { url: imageUrl } },
              { type: "text", text: "请详细描述这张图片的内容，用于 AI 绘画提示词。包含：主体描述、构图方式、光影氛围、色彩风格、镜头语言。用英文输出，简洁精准，适合直接作为 Stable Diffusion 或 Midjourney 提示词使用。" },
            ],
          },
        ],
        temperature: 0.3,
      });

      const prompt = result.trim() || "（AI 未返回有效描述）";
      setTextNodes((nodes) =>
        nodes.map((n) => n.id === source.id ? { ...n, content: prompt, prompt: "", isEditingContent: false } : n)
      );
    } catch (error) {
      const msg = error instanceof Error ? error.message : "反推提示词失败";
      setTextNodes((nodes) =>
        nodes.map((n) => n.id === source.id ? { ...n, content: `反推失败：${msg}`, isEditingContent: false } : n)
      );
    }
  };

  const addVideoNodeFromText = (source: CanvasTextNode) => {
    const nodeNumber = videoNodeIdRef.current;
    videoNodeIdRef.current += 1;
    const prompt = source.content || source.prompt || "";
    const node: CanvasVideoNode = {
      id: `video-node-${nodeNumber}-${Date.now()}`,
      title: `视频节点 ${nodeNumber}`,
      prompt,
      model: defaultVideoModel,
      aspectRatio: "16:9",
      resolution: getDefaultVideoQuality(defaultVideoModel),
      duration: "5",
      videoMode: "text2video",
      sourceTextNodeId: source.id,
      position: {
        x: source.position.x + 520,
        y: source.position.y,
      },
      size: createCanvasNodeSize("video"),
    };

    setVideoNodes((currentNodes) => [...currentNodes, node]);
    setSelectedNode({ kind: "video", id: node.id });
  };

  const addVideoNode = (position = { x: 0, y: 0 }) => {
    const nodeNumber = videoNodeIdRef.current;
    videoNodeIdRef.current += 1;
    const node: CanvasVideoNode = {
      id: `video-node-${nodeNumber}-${Date.now()}`,
      title: `视频节点 ${nodeNumber}`,
      prompt: "",
      model: defaultVideoModel,
      aspectRatio: "16:9",
      resolution: getDefaultVideoQuality(defaultVideoModel),
      duration: "5",
      videoMode: "text2video",
      sourceTextNodeId: "",
      position,
      size: createCanvasNodeSize("video"),
    };

    setVideoNodes((currentNodes) => [...currentNodes, node]);
    setSelectedNode({ kind: "video", id: node.id });
  };

  const duplicateVideoNode = (source: CanvasVideoNode, position = { x: source.position.x + 120, y: source.position.y }) => {
    const nodeNumber = videoNodeIdRef.current;
    videoNodeIdRef.current += 1;
    const node: CanvasVideoNode = {
      ...source,
      id: `video-node-${nodeNumber}-${Date.now()}`,
      title: buildCopyTitle(source.title),
      position: { ...position },
      size: { ...source.size },
    };

    setVideoNodes((currentNodes) => [...currentNodes, node]);
    setSelectedNode({ kind: "video", id: node.id });
  };

  const updateVideoNodePrompt = (nodeId: string, prompt: string) => {
    setVideoNodes((currentNodes) =>
      currentNodes.map((node) => (node.id === nodeId ? { ...node, prompt } : node))
    );
  };

  const updateVideoNodeSetting = (
    nodeId: string,
    patch: Partial<Pick<CanvasVideoNode, "model" | "aspectRatio" | "resolution" | "duration" | "videoMode">>
  ) => {
    setVideoNodes((currentNodes) =>
      currentNodes.map((node) => {
        if (node.id !== nodeId) return node;
        const nextModel = patch.model ?? node.model;
        return {
          ...node,
          ...patch,
          resolution:
            patch.model && !patch.resolution
              ? getDefaultVideoQuality(nextModel)
              : patch.resolution ?? resolveVideoQuality(nextModel, node.resolution),
        };
      })
    );
  };

  const addImageNode = (
    imageUrl = "",
    fileName = "本地图片",
    position = { x: 0, y: 0 },
    options?: { title?: string; sourceImageNodeId?: string; sourceTextNodeId?: string }
  ) => {
    const nodeNumber = imageNodeIdRef.current;
    imageNodeIdRef.current += 1;
    const node: CanvasImageNode = {
      id: `image-node-${nodeNumber}-${Date.now()}`,
      title: options?.title ?? `图片节点 ${nodeNumber}`,
      prompt: "",
      imageUrl,
      model: fallbackVisibleImageModel,
      aspectRatio: "16:9",
      imageSize: getDefaultImageQuality(fallbackVisibleImageModel),
      fileName,
      sourceImageNodeId: options?.sourceImageNodeId,
      sourceTextNodeId: options?.sourceTextNodeId,
      position,
      size: createCanvasNodeSize("image"),
    };

    setImageNodes((currentNodes) => [...currentNodes, node]);
    setSelectedNode({ kind: "image", id: node.id });
  };

  const duplicateImageNode = (source: CanvasImageNode, position = { x: source.position.x + 120, y: source.position.y }) => {
    const nodeNumber = imageNodeIdRef.current;
    imageNodeIdRef.current += 1;
    const node: CanvasImageNode = {
      ...source,
      id: `image-node-${nodeNumber}-${Date.now()}`,
      title: buildCopyTitle(source.title),
      position: { ...position },
      size: { ...source.size },
    };

    setImageNodes((currentNodes) => [...currentNodes, node]);
    setSelectedNode({ kind: "image", id: node.id });
  };

  const duplicateCopiedCanvasNode = (source: CanvasCopiedNode, position: CanvasPoint) => {
    if (source.kind === "text") {
      duplicateTextNode(source.node, position);
      return;
    }
    if (source.kind === "image") {
      duplicateImageNode(source.node, position);
      return;
    }
    duplicateVideoNode(source.node, position);
  };

  const updateImageNodePrompt = (nodeId: string, prompt: string) => {
    setImageNodes((currentNodes) =>
      currentNodes.map((node) => (node.id === nodeId ? { ...node, prompt } : node))
    );
  };

  const updateImageNodeSetting = (
    nodeId: string,
    patch: Partial<Pick<CanvasImageNode, "model" | "aspectRatio" | "imageSize">>
  ) => {
    setImageNodes((currentNodes) =>
      currentNodes.map((node) => {
        if (node.id !== nodeId) return node;
        const nextModel = resolveVisibleImageModel(patch.model ?? node.model);
        return {
          ...node,
          ...patch,
          model: nextModel,
          imageSize:
            patch.model && !patch.imageSize
              ? getDefaultImageQuality(nextModel)
              : patch.imageSize ?? resolveImageQuality(nextModel, node.imageSize),
        };
      })
    );
  };

  const isHttpUrl = (url: string | undefined) => Boolean(url && /^https?:\/\//i.test(url));
  const isDataImageUrl = (url: string | undefined) => Boolean(url && /^data:image\/[^;,]+;base64,/i.test(url));
  const isBlobUrl = (url: string | undefined) => Boolean(url && /^blob:/i.test(url));
  const isCanvasImageReferenceUrl = (url: string | undefined): url is string =>
    Boolean(url && (isHttpUrl(url) || isDataImageUrl(url) || isBlobUrl(url)));

  const getCanvasNodePositionX = (port: CanvasNodePort) => {
    if (port.kind === "text") return textNodes.find((node) => node.id === port.nodeId)?.position.x ?? null;
    if (port.kind === "image") return imageNodes.find((node) => node.id === port.nodeId)?.position.x ?? null;
    return videoNodes.find((node) => node.id === port.nodeId)?.position.x ?? null;
  };

  const normalizeCanvasLinkForFlow = (first: CanvasNodePort, second: CanvasNodePort) => {
    const sideValidatedLink = normalizeCanvasLinkPorts(first, second);
    if (!sideValidatedLink) return null;

    const firstX = getCanvasNodePositionX(first);
    const secondX = getCanvasNodePositionX(second);
    if (firstX !== null && secondX !== null && firstX !== secondX) {
      const source = firstX < secondX ? first : second;
      const target = source === first ? second : first;
      return {
        from: { ...source, side: "right" as const },
        to: { ...target, side: "left" as const },
      };
    }

    return {
      from: { ...sideValidatedLink.from, side: "right" as const },
      to: { ...sideValidatedLink.to, side: "left" as const },
    };
  };

  const getIncomingCanvasPorts = (kind: CanvasNodeKind, nodeId: string): CanvasNodePort[] => {
    const ports: CanvasNodePort[] = [];
    const addPort = (port: CanvasNodePort | null | undefined) => {
      if (!port) return;
      if (ports.some((item) => getCanvasPortIdentity(item) === getCanvasPortIdentity(port))) return;
      ports.push(port);
    };

    if (kind === "image") {
      const node = imageNodes.find((item) => item.id === nodeId);
      if (node?.sourceImageNodeId) {
        addPort({ kind: "image", nodeId: node.sourceImageNodeId, side: "right", slot: "center" });
      }
      if (node?.sourceTextNodeId) {
        addPort({ kind: "text", nodeId: node.sourceTextNodeId, side: "right", slot: "center" });
      }
    }

    if (kind === "video") {
      const node = videoNodes.find((item) => item.id === nodeId);
      if (node?.sourceTextNodeId) {
        addPort({ kind: "text", nodeId: node.sourceTextNodeId, side: "right", slot: "center" });
      }
    }

    manualLinks.forEach((link) => {
      const normalizedLink = normalizeCanvasLinkForFlow(link.from, link.to);
      if (!normalizedLink) return;
      if (normalizedLink.to.kind === kind && normalizedLink.to.nodeId === nodeId) {
        addPort(normalizedLink.from);
      }
    });

    return ports;
  };

  const getNodePromptContent = (
    kind: CanvasNodeKind,
    nodeId: string,
    visited = new Set<string>()
  ): string => {
    const key = `${kind}:${nodeId}`;
    if (visited.has(key)) return "";
    visited.add(key);

    if (kind === "text") {
      const node = textNodes.find((item) => item.id === nodeId);
      return node?.content.trim() || node?.prompt.trim() || "";
    }

    if (kind === "image") {
      const node = imageNodes.find((item) => item.id === nodeId);
      const ownPrompt = node?.prompt.trim();
      if (ownPrompt) return ownPrompt;
    }

    if (kind === "video") {
      const node = videoNodes.find((item) => item.id === nodeId);
      const ownPrompt = node?.prompt.trim();
      if (ownPrompt) return ownPrompt;
    }

    return getIncomingCanvasPorts(kind, nodeId)
      .map((port) => getNodePromptContent(port.kind, port.nodeId, visited))
      .filter(Boolean)
      .join("\n");
  };

  const getEffectiveNodePrompt = (kind: CanvasNodeKind, nodeId: string, explicitPrompt: string) =>
    explicitPrompt.trim() || getIncomingCanvasPorts(kind, nodeId)
      .map((port) => getNodePromptContent(port.kind, port.nodeId))
      .filter(Boolean)
      .join("\n");

  const getConnectedImageReferenceItems = (
    kind: CanvasNodeKind,
    nodeId: string,
    ownImageNode?: CanvasImageNode
  ) => {
    const items: CanvasImageReferenceItem[] = [];
    const seen = new Set<string>();
    const addImageNode = (node: CanvasImageNode | undefined) => {
      const imageUrl = node?.imageUrl?.trim();
      if (!node || !isCanvasImageReferenceUrl(imageUrl)) return;
      const identity = `${node.id}:${imageUrl}`;
      if (seen.has(identity)) return;
      seen.add(identity);
      items.push({
        nodeId: node.id,
        title: node.title,
        imageUrl,
        fileName: node.fileName,
      });
    };

    addImageNode(ownImageNode);
    getIncomingCanvasPorts(kind, nodeId).forEach((port) => {
      if (port.kind !== "image") return;
      const imageNode = imageNodes.find((node) => node.id === port.nodeId);
      addImageNode(imageNode);
    });

    return items;
  };

  const getConnectedImageReferenceUrls = (
    kind: CanvasNodeKind,
    nodeId: string,
    ownImageNode?: CanvasImageNode
  ) => {
    const urls: string[] = [];
    getConnectedImageReferenceItems(kind, nodeId, ownImageNode).forEach((item) => {
      if (!isHttpUrl(item.imageUrl) || urls.includes(item.imageUrl)) return;
      urls.push(item.imageUrl);
    });
    return urls;
  };

  const persistResolvedImageReferenceUrl = (nodeId: string, imageUrl: string) => {
    setImageNodes((currentNodes) =>
      currentNodes.map((node) => (node.id === nodeId && node.imageUrl !== imageUrl ? { ...node, imageUrl } : node))
    );
  };

  const uploadCanvasImageReference = async (item: CanvasImageReferenceItem) => {
    const rawUrl = item.imageUrl.trim();
    if (isHttpUrl(rawUrl)) return rawUrl;

    let dataUrl = rawUrl;
    let mimeType = rawUrl.match(/^data:([^;,]+);base64,/i)?.[1] || "image/png";
    if (isBlobUrl(rawUrl)) {
      const response = await fetch(rawUrl);
      if (!response.ok) {
        throw new Error(`图片节点「${item.title}」读取失败，请重新上传后再生成。`);
      }
      const blob = await response.blob();
      if (!blob.type.startsWith("image/")) {
        throw new Error(`图片节点「${item.title}」不是可用的图片文件。`);
      }
      mimeType = blob.type || mimeType;
      dataUrl = await blobToDataUrl(blob);
    }

    if (!isDataImageUrl(dataUrl)) {
      throw new Error(`图片节点「${item.title}」不是可提交的图片地址，请重新上传。`);
    }

    const uploaded = await aiGenerationClient.uploadAsset({
      dataUrl,
      name: item.fileName || item.title || "canvas-reference.png",
      mimeType,
    });
    const uploadedUrl = uploaded.url || uploaded.signedUrl;
    if (!uploadedUrl) {
      throw new Error(`图片节点「${item.title}」上传失败，请稍后重试。`);
    }
    persistResolvedImageReferenceUrl(item.nodeId, uploadedUrl);
    return uploadedUrl;
  };

  const resolveConnectedImageReferenceUrls = async (
    kind: CanvasNodeKind,
    nodeId: string,
    ownImageNode?: CanvasImageNode
  ) => {
    const items = getConnectedImageReferenceItems(kind, nodeId, ownImageNode);
    const results = await Promise.all(items.map((item) => uploadCanvasImageReference(item)));
    const seen = new Set<string>();
    return results.filter((url): url is string => {
      if (!url || seen.has(url)) return false;
      seen.add(url);
      return true;
    });
  };

  const handleGenerateImageNode = async (nodeId: string) => {
    const imageNode = imageNodes.find((node) => node.id === nodeId);
    if (!imageNode) return;

    if (imageGenerationInFlightRef.current.has(nodeId)) return;

    const referenceItems = getConnectedImageReferenceItems("image", nodeId, imageNode);
    const basePrompt = getEffectiveNodePrompt("image", nodeId, imageNode.prompt) || (referenceItems.length ? "根据参考图片生成图片" : "");
    const markingSuffix = imageNode.marking ? `\n标记: ${imageNode.marking}` : "";
    const prompt = basePrompt + markingSuffix;
    if (!prompt) {
      setImageGenerationStatus(nodeId, { status: "error", message: "请输入提示词或连接上游节点" });
      return;
    }

    if (!isAuthenticated) {
      onOpenLogin();
      return;
    }

    if (!onCreateTask) {
      setImageGenerationStatus(nodeId, { status: "error", message: "生成入口暂不可用" });
      return;
    }

    const model = resolveVisibleImageModel(imageNode.model || defaultImageModel);
    const ratio = imageNode.aspectRatio || "16:9";
    const quality = resolveImageQuality(model, imageNode.imageSize || "");

    imageGenerationInFlightRef.current.add(nodeId);
    const abortRef = { current: false };
    imageGenerationAbortRef.current.set(nodeId, abortRef);
    setImageGenerationStatus(nodeId, { status: "submitting", message: "正在提交生成", progress: 8 });
    setGenerationToast("图片正在生成");

    let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
    try {
      const referenceUrls = await resolveConnectedImageReferenceUrls("image", nodeId, imageNode);
      if (abortRef.current) return;
      const taskInput: CreatePreviewTaskInput = {
        title: imageNode.title || "图片节点生成",
        type: "image",
        prompt,
        params: {
          projectId: projectId || undefined,
          model,
          ratio,
          quality,
          gridMode: "single",
          referenceUrls: referenceUrls.length ? referenceUrls : undefined,
        },
      };
      task = await onCreateTask(taskInput);
      if (task.status === "failed") {
        throw new Error(translateTaskError(task.errorMessage));
      }
      if (task.status === "completed" && !task.outputUrl) {
        throw new Error("图片生成任务已完成，但服务器没有返回结果地址，请稍后重试");
      }
      addCanvasGenKeepalive(task.id, nodeId, "image", projectId || "");
      setImageGenerationStatus(nodeId, { status: "running", message: "图片生成中", progress: Math.max(18, Number(task.progress || 0)) });
      const outputUrl =
        task.outputUrl ||
        (await waitForImageTaskResult(task.id, (status) => {
          const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
          const statusLabel =
            status.status === "pending"
              ? "图片排队中"
              : status.status === "running"
                ? "图片生成中"
                : status.status === "completed"
                  ? "图片生成完成"
                  : "图片生成失败";
          setImageGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
        }, abortRef));
      if (abortRef.current || !outputUrl) return;
      setImageGenerationStatus(nodeId, { status: "success", message: "生成完成", progress: 100 });
      removeCanvasGenKeepalive(task.id);
      const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
        url: outputUrl,
        mediaType: "image/png",
        resultType: "image",
        taskId: task.id,
        originalUrl: outputUrl,
      });
      setImageNodes((currentNodes) =>
        currentNodes.map((node) =>
          node.id === nodeId
            ? {
                ...node,
                imageUrl: outputUrl,
                assetRef: immediateAssetRef,
                taskRef: {
                  taskId: task!.id,
                  status: "completed",
                  resultUrl: outputUrl,
                  updatedAt: new Date().toISOString(),
                },
                fileName: `${imageNode.title || "图片节点"}-${ratio}-${quality}`,
              }
            : node
        )
      );
      let durableAssetRef = immediateAssetRef;
      try {
        durableAssetRef = await persistCanvasGeneratedResultAsset({
        title: imageNode.title || "image-node",
        url: outputUrl,
        mediaType: "image/png",
        resultType: "image",
        taskId: task.id,
        originalUrl: outputUrl,
      });
      } catch {
        durableAssetRef = immediateAssetRef;
      }
      if (durableAssetRef.url !== outputUrl || durableAssetRef.ossKey) {
        setImageNodes((currentNodes) =>
          currentNodes.map((node) =>
            node.id === nodeId
              ? {
                  ...node,
                  imageUrl: durableAssetRef.url,
                  assetRef: durableAssetRef,
                  taskRef: {
                    taskId: task!.id,
                    status: "completed",
                    resultUrl: durableAssetRef.url,
                    updatedAt: new Date().toISOString(),
                  },
                  fileName: `${imageNode.title || "图片节点"}-${ratio}-${quality}`,
                }
              : node
          )
        );
      }
    } catch (error) {
      if (abortRef.current) return;
      setImageGenerationStatus(nodeId, {
        status: "error",
        message: error instanceof Error ? error.message : "图片生成失败",
      });
    } finally {
      imageGenerationInFlightRef.current.delete(nodeId);
      imageGenerationAbortRef.current.delete(nodeId);
      if (task?.id && !abortRef.current) removeCanvasGenKeepalive(task.id);
    }
  };

  const getVideoFrameMode = (videoMode: CanvasVideoNode["videoMode"]) =>
    videoMode === "firstlast" ? "start-end" : "omni";

  const handleGenerateVideoNode = async (nodeId: string) => {
    const videoNode = videoNodes.find((node) => node.id === nodeId);
    if (!videoNode || videoGenerationInFlightRef.current.has(nodeId)) return;

    const basePrompt = getEffectiveNodePrompt("video", nodeId, videoNode.prompt);
    const extraParts: string[] = [];
    if (videoNode.marking) extraParts.push(`标记: ${videoNode.marking}`);
    const cameraPrompt = getCameraMotionPrompt(videoNode.cameraMotion || "");
    if (cameraPrompt) extraParts.push(`运镜: ${cameraPrompt}`);
    const prompt = extraParts.length > 0 ? `${basePrompt || ""}\n${extraParts.join("\n")}`.trim() : basePrompt;
    const referenceItems = getConnectedImageReferenceItems("video", nodeId);
    if (videoNode.videoMode === "img2video" && referenceItems.length === 0) {
      setVideoGenerationStatus(nodeId, { status: "error", message: "图生视频需要先连接至少一个有图片的图片节点" });
      return;
    }
    if (!prompt && referenceItems.length === 0) {
      setVideoGenerationStatus(nodeId, { status: "error", message: "请输入提示词或连接图片节点" });
      return;
    }

    if (!isAuthenticated) {
      onOpenLogin();
      return;
    }

    if (!onCreateTask) {
      setVideoGenerationStatus(nodeId, { status: "error", message: "生成入口暂不可用" });
      return;
    }

    const model = toHappyHorseDisplayModel(videoNode.model || defaultVideoModel);
    const ratio = videoNode.aspectRatio || "16:9";
    const quality = resolveVideoQuality(model, videoNode.resolution || "");
    const duration = Number(videoNode.duration) || 4;

    videoGenerationInFlightRef.current.add(nodeId);
    const abortRef = { current: false };
    videoGenerationAbortRef.current.set(nodeId, abortRef);
    setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 });
    setGenerationToast("视频正在生成");

    let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
    try {
      const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId);
      if (abortRef.current) return;
      if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) {
        throw new Error("图生视频需要先连接至少一个可用的图片节点");
      }
      const requestModel = resolveVideoRequestModel({ model, referenceUrls });
      task = await onCreateTask({
        title: videoNode.title || "视频节点生成",
        type: "video",
        prompt: prompt || "根据参考图片生成视频",
        params: {
          projectId: projectId || undefined,
          model: requestModel,
          ratio,
          quality,
          resolution: quality,
          duration,
          frameMode: getVideoFrameMode(videoNode.videoMode),
          referenceUrls: referenceUrls.length ? referenceUrls : undefined,
          muted: false,
          hasReferenceVideo: false,
        },
      });
      if (task.status === "failed") {
        throw new Error(translateTaskError(task.errorMessage));
      }
      if (task.status === "completed" && !task.outputUrl) {
        throw new Error("视频生成任务已完成，但服务器没有返回结果地址，请稍后重试");
      }
      const taskId = task.id;
      addCanvasGenKeepalive(taskId, nodeId, "video", projectId || "");
      setVideoGenerationStatus(nodeId, { status: "running", message: "视频生成中", progress: Math.max(18, Number(task.progress || 0)) });
      const outputUrl =
        task.outputUrl ||
        (await waitForVideoTaskResult(taskId, (status) => {
          const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
          const statusLabel =
            status.status === "pending"
              ? "视频排队中"
              : status.status === "running"
                ? "视频生成中"
                : status.status === "completed"
                  ? "视频生成完成"
                  : "视频生成失败";
          setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
        }, abortRef));
      if (abortRef.current || !outputUrl) return;
      setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 });
      removeCanvasGenKeepalive(taskId);
      const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
        url: outputUrl,
        mediaType: "video/mp4",
        resultType: "video",
        taskId,
        originalUrl: outputUrl,
      });
      setVideoNodes((currentNodes) =>
        currentNodes.map((node) =>
          node.id === nodeId
            ? {
                ...node,
                videoUrl: outputUrl,
                assetRef: immediateAssetRef,
                taskRef: {
                  taskId,
                  status: "completed",
                  resultUrl: outputUrl,
                  updatedAt: new Date().toISOString(),
                },
              }
            : node
        )
      );
      const assetRef = await persistCanvasGeneratedResultAsset({
        title: videoNode.title || "video-node",
        url: outputUrl,
        mediaType: "video/mp4",
        resultType: "video",
        taskId,
        originalUrl: outputUrl,
      });
      await delay(420);
      if (assetRef.url !== outputUrl || assetRef.ossKey) {
        setVideoNodes((currentNodes) =>
          currentNodes.map((node) =>
            node.id === nodeId
              ? {
                  ...node,
                  videoUrl: assetRef.url,
                  assetRef,
                  taskRef: {
                    taskId,
                    status: "completed",
                    resultUrl: assetRef.url,
                    updatedAt: new Date().toISOString(),
                  },
                }
              : node
          )
        );
      }
    } catch (error) {
      if (abortRef.current) return;
      setVideoGenerationStatus(nodeId, {
        status: "error",
        message: error instanceof Error ? error.message : "视频生成失败",
      });
    } finally {
      videoGenerationInFlightRef.current.delete(nodeId);
      videoGenerationAbortRef.current.delete(nodeId);
      if (task?.id && !abortRef.current) removeCanvasGenKeepalive(task.id);
    }
  };

  const handleImageFileSelected = (
    event: ChangeEvent<HTMLInputElement>,
    position: { x: number; y: number }
  ) => {
    const file = event.target.files?.[0];
    event.target.value = "";
    if (!file) return;
    const imageUrl = trackObjectUrl(file);
    if (pendingImageToImageNodeId) {
      const sourceNode = imageNodes.find((node) => node.id === pendingImageToImageNodeId);
      if (sourceNode) {
        setImageNodes((currentNodes) =>
          currentNodes.map((node) =>
            node.id === sourceNode.id
              ? { ...node, imageUrl, fileName: file.name, focusSelection: undefined, assetRef: null, taskRef: null }
              : node
          )
        );
        addImageNode(
          "",
          "图生图",
          { x: sourceNode.position.x + 520, y: sourceNode.position.y },
          { title: "图生图", sourceImageNodeId: sourceNode.id }
        );
      }
      setPendingImageToImageNodeId(null);
    } else if (pendingImageNodeId) {
      setImageNodes((currentNodes) =>
        currentNodes.map((node) =>
          node.id === pendingImageNodeId
            ? { ...node, imageUrl, fileName: file.name, focusSelection: undefined, assetRef: null, taskRef: null }
            : node
        )
      );
      selectCanvasNode("image", pendingImageNodeId);
      setPendingImageNodeId(null);
    } else {
      addImageNode(imageUrl, file.name, position);
    }
    setContextMenu(null);
    setNodeMenu(null);
  };

  // ── Canvas drag-and-drop file upload ──────────────────────────────
  const handleCanvasDragEnter = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    canvasDragCounterRef.current += 1;
    if (canvasDragCounterRef.current === 1) {
      setIsCanvasDragging(true);
    }
  }, []);

  const handleCanvasDragLeave = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    canvasDragCounterRef.current -= 1;
    if (canvasDragCounterRef.current <= 0) {
      canvasDragCounterRef.current = 0;
      setIsCanvasDragging(false);
    }
  }, []);

  const handleCanvasDragOver = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
  }, []);

  const handleCanvasDrop = useCallback(
    (e: React.DragEvent) => {
      e.preventDefault();
      e.stopPropagation();
      canvasDragCounterRef.current = 0;
      setIsCanvasDragging(false);
      const files = Array.from(e.dataTransfer.files).filter(
        (f) => f.type.startsWith("image/")
      );
      if (files.length === 0) return;

      const rect = canvasRef.current?.getBoundingClientRect();
      if (!rect) return;
      const dropPosition = {
        x: (e.clientX - rect.left - canvasViewport.x) / canvasViewport.zoom,
        y: (e.clientY - rect.top - canvasViewport.y) / canvasViewport.zoom,
      };

      let offsetX = 0;
      let offsetY = 0;
      for (const file of files) {
        const imageUrl = trackObjectUrl(file);
        addImageNode(imageUrl, file.name, {
          x: dropPosition.x + offsetX,
          y: dropPosition.y + offsetY,
        });
        offsetX += 60;
        offsetY += 60;
      }
      setContextMenu(null);
      setNodeMenu(null);
    },
    [canvasViewport.x, canvasViewport.y, canvasViewport.zoom, addImageNode],
  );

  // ── Text composer drag-and-drop ──────────────────────────────────
  const [textComposerDragNodeId, setTextComposerDragNodeId] = useState<string | null>(null);
  const textComposerDragCounterRef = useRef(0);

  const handleTextComposerDragEnter = useCallback((_e: React.DragEvent, nodeId: string) => {
    _e.preventDefault();
    _e.stopPropagation();
    textComposerDragCounterRef.current += 1;
    if (textComposerDragCounterRef.current === 1) {
      setTextComposerDragNodeId(nodeId);
    }
  }, []);

  const handleTextComposerDragLeave = useCallback((_e: React.DragEvent) => {
    _e.preventDefault();
    _e.stopPropagation();
    textComposerDragCounterRef.current -= 1;
    if (textComposerDragCounterRef.current <= 0) {
      textComposerDragCounterRef.current = 0;
      setTextComposerDragNodeId(null);
    }
  }, []);

  const handleTextComposerDragOver = useCallback((_e: React.DragEvent) => {
    _e.preventDefault();
    _e.stopPropagation();
  }, []);

  const handleTextComposerDrop = useCallback(
    (e: React.DragEvent, sourceNode: CanvasTextNode) => {
      e.preventDefault();
      e.stopPropagation();
      textComposerDragCounterRef.current = 0;
      setTextComposerDragNodeId(null);
      const files = Array.from(e.dataTransfer.files).filter(
        (f) => f.type.startsWith("image/")
      );
      if (files.length === 0) return;

      let offsetX = 0;
      let offsetY = 0;
      for (const file of files) {
        const imageUrl = trackObjectUrl(file);
        addImageNode(imageUrl, file.name, {
          x: sourceNode.position.x + sourceNode.size.width + 40 + offsetX,
          y: sourceNode.position.y + offsetY,
        }, { sourceTextNodeId: sourceNode.id });
        offsetX += 60;
        offsetY += 60;
      }
    },
    [addImageNode],
  );

  const activeTextNode = textNodeMenu
    ? textNodes.find((node) => node.id === textNodeMenu.nodeId) ?? null
    : null;
  const activeImageNode = imageNodeMenu
    ? imageNodes.find((node) => node.id === imageNodeMenu.nodeId) ?? null
    : null;
  const activeVideoNode = videoNodeMenu
    ? videoNodes.find((node) => node.id === videoNodeMenu.nodeId) ?? null
    : null;

  const openImageStylePicker = (nodeId: string) => {
    selectCanvasNode("image", nodeId);
    setCanvasSelectMenu(null);
    setContextMenu(null);
    setNodeMenu(null);
    setTextNodeMenu(null);
    setImageNodeMenu(null);
    setVideoNodeMenu(null);
    setStylePickerTab("square");
    setStylePickerCategory(canvasStylePickerCategories[0]);
    setStylePickerSearch("");
    setStylePickerImageNodeId(nodeId);
  };

  const handleSelectImageStyle = (styleCase: CanvasStyleCase) => {
    if (!stylePickerImageNodeId) return;
    const styleReference = createStyleReferenceFromCase(styleCase);
    setImageNodes((currentNodes) =>
      currentNodes.map((node) =>
        node.id === stylePickerImageNodeId
          ? { ...node, styleReference }
          : node
      )
    );
    setRecentStyleCases((currentCases) => [
      styleCase,
      ...currentCases.filter((item) => item.id !== styleCase.id),
    ].slice(0, 24));
    setStyleSelectionToast(`已选择「${styleCase.title}」风格`);
    setStylePickerImageNodeId(null);
  };

  const openImageFocusMode = (node: CanvasImageNode) => {
    selectCanvasNode("image", node.id);
    setCanvasSelectMenu(null);
    setContextMenu(null);
    setNodeMenu(null);
    setTextNodeMenu(null);
    setImageNodeMenu(null);
    setVideoNodeMenu(null);
    setStylePickerImageNodeId(null);
    setImageFocusNodeId(node.id);
    setImageFocusDraft(node.focusSelection ?? null);
    setImageFocusDrag(null);
  };

  const cancelImageFocusMode = () => {
    setImageFocusNodeId(null);
    setImageFocusDraft(null);
    setImageFocusDrag(null);
  };

  const confirmImageFocusMode = () => {
    if (!imageFocusNodeId || !imageFocusDraft) {
      cancelImageFocusMode();
      return;
    }
    setImageNodes((currentNodes) =>
      currentNodes.map((node) =>
        node.id === imageFocusNodeId
          ? { ...node, focusSelection: imageFocusDraft }
          : node
      )
    );
    setStyleSelectionToast("已确认聚焦区域");
    cancelImageFocusMode();
  };

  const handleImageFocusDragStart = (event: MouseEvent<HTMLDivElement>, nodeId: string) => {
    if (event.button !== 0) return;
    event.preventDefault();
    event.stopPropagation();
    const rect = event.currentTarget.getBoundingClientRect();
    const startX = clampCanvasPercent(((event.clientX - rect.left) / rect.width) * 100);
    const startY = clampCanvasPercent(((event.clientY - rect.top) / rect.height) * 100);
    selectCanvasNode("image", nodeId);
    setImageFocusNodeId(nodeId);
    setCanvasSelectMenu(null);
    setContextMenu(null);
    setNodeMenu(null);
    setTextNodeMenu(null);
    setImageNodeMenu(null);
    setVideoNodeMenu(null);
    setImageFocusDrag({
      nodeId,
      startX,
      startY,
      rect: {
        left: rect.left,
        top: rect.top,
        width: rect.width,
        height: rect.height,
      },
    });
    setImageFocusDraft({
      x: startX,
      y: startY,
      width: 0,
      height: 0,
      ratio: imageFocusDraft?.ratio || "16:9",
    });
  };

  const handleImageFocusRatioChange = (ratio: string) => {
    if (!imageFocusDraft) return;
    const targetNode = imageFocusNodeId
      ? imageNodes.find((node) => node.id === imageFocusNodeId)
      : null;
    const containerRatio = targetNode ? targetNode.size.width / targetNode.size.height : 16 / 9;
    setImageFocusDraft(applyImageFocusRatioFromTopLeft(imageFocusDraft, ratio, containerRatio));
  };

  const stylePickerNode = stylePickerImageNodeId
    ? imageNodes.find((node) => node.id === stylePickerImageNodeId) ?? null
    : null;
  const stylePickerSourceCases =
    stylePickerTab === "recent"
      ? recentStyleCases
      : stylePickerTab === "favorites"
        ? stylePickerCases.filter((item) => item.isFavorited)
        : stylePickerCases;
  const stylePickerQuery = stylePickerSearch.trim().toLowerCase();
  const stylePickerVisibleCases = stylePickerSourceCases.filter((item) => {
    const matchesCategory =
      stylePickerCategory === canvasStylePickerCategories[0] ||
      item.keywords.includes(stylePickerCategory.toLowerCase());
    const matchesQuery = !stylePickerQuery || item.keywords.includes(stylePickerQuery);
    return matchesCategory && matchesQuery;
  });

  const getCanvasNodeRect = (position: CanvasPoint, size: CanvasNodeSize) => {
    const rect = canvasRef.current?.getBoundingClientRect();
    const canvasWidth = rect?.width ?? 0;
    const canvasHeight = rect?.height ?? 0;
    return {
      left: canvasWidth / 2 + position.x - size.width / 2,
      top: canvasHeight / 2 + position.y - size.height / 2,
      width: size.width,
      height: size.height,
    };
  };

  const getCanvasNodeScreenRect = (position: CanvasPoint, size: CanvasNodeSize) => {
    const worldRect = getCanvasNodeRect(position, size);
    return {
      left: canvasViewport.x + worldRect.left * canvasViewport.zoom,
      top: canvasViewport.y + worldRect.top * canvasViewport.zoom,
      width: worldRect.width * canvasViewport.zoom,
      height: worldRect.height * canvasViewport.zoom,
    };
  };

  const getNodesInSelectionRect = (selectionRect: { left: number; top: number; width: number; height: number }) => [
    ...textNodes.flatMap((node) =>
      doCanvasRectsIntersect(selectionRect, getCanvasNodeScreenRect(node.position, node.size))
        ? [{ kind: "text" as const, id: node.id }]
        : []
    ),
    ...imageNodes.flatMap((node) =>
      doCanvasRectsIntersect(selectionRect, getCanvasNodeScreenRect(node.position, node.size))
        ? [{ kind: "image" as const, id: node.id }]
        : []
    ),
    ...videoNodes.flatMap((node) =>
      doCanvasRectsIntersect(selectionRect, getCanvasNodeScreenRect(node.position, node.size))
        ? [{ kind: "video" as const, id: node.id }]
        : []
    ),
  ];

  dragCallbacksRef.current = {
    pushHistorySnapshot,
    clearCanvasSelection,
    selectCanvasNode,
    applyCanvasSelection,
    getCanvasPointFromClient,
    getNodesInSelectionRect,
    expandCanvasNodePackage,
    onBeforeResize: () => {
      setCanvasSelectMenu(null);
      setImageFocusNodeId(null);
      setImageFocusDraft(null);
      setImageFocusDrag(null);
    },
  };

  const getCanvasNodeBoundsFromSelection = (node: CanvasSelectedNode): CanvasNodeBounds | null => {
    if (node.kind === "text") {
      const textNode = textNodes.find((item) => item.id === node.id);
      return textNode ? getCanvasNodeScreenRect(textNode.position, textNode.size) : null;
    }
    if (node.kind === "image") {
      const imageNode = imageNodes.find((item) => item.id === node.id);
      return imageNode ? getCanvasNodeScreenRect(imageNode.position, imageNode.size) : null;
    }
    const videoNode = videoNodes.find((item) => item.id === node.id);
    return videoNode ? getCanvasNodeScreenRect(videoNode.position, videoNode.size) : null;
  };

  const mergeCanvasNodeBounds = (bounds: CanvasNodeBounds[]): CanvasNodeBounds | null => {
    if (!bounds.length) return null;
    const left = Math.min(...bounds.map((item) => item.left));
    const top = Math.min(...bounds.map((item) => item.top));
    const right = Math.max(...bounds.map((item) => item.left + item.width));
    const bottom = Math.max(...bounds.map((item) => item.top + item.height));
    return {
      left,
      top,
      width: right - left,
      height: bottom - top,
    };
  };

  const getCanvasSelectedNodesBounds = () =>
    selectedNodes.length > 1 && !selectedPackageId
      ? mergeCanvasNodeBounds(
          selectedNodes
            .map((node) => getCanvasNodeBoundsFromSelection(node))
            .filter((bounds): bounds is CanvasNodeBounds => Boolean(bounds))
        )
      : null;

  const getCanvasNodePackageBounds = (nodePackage: CanvasNodePackage) =>
    mergeCanvasNodeBounds(
      nodePackage.nodeIds
        .map((node) => getCanvasNodeBoundsFromSelection(node))
        .filter((bounds): bounds is CanvasNodeBounds => Boolean(bounds))
    );

  const getCanvasNodePackageBoundsWithMeta = () =>
    nodePackages
      .filter((nodePackage) => !nodePackage.collapsed)
      .map((nodePackage) => {
        const bounds = getCanvasNodePackageBounds(nodePackage);
        return bounds ? { nodePackage, bounds } : null;
      })
      .filter(
        (item): item is { nodePackage: CanvasNodePackage; bounds: CanvasNodeBounds } =>
          Boolean(item)
      );

  const getCanvasCollapsedPackageCardsWithMeta = () =>
    nodePackages
      .filter((nodePackage) => nodePackage.collapsed)
      .map((nodePackage) => {
        const bounds = nodePackage.collapsedBounds || getCanvasNodePackageBounds(nodePackage);
        return bounds ? { nodePackage, bounds } : null;
      })
      .filter(
        (item): item is { nodePackage: CanvasNodePackage; bounds: CanvasNodeBounds } =>
          Boolean(item)
      );

  const isPendingPort = (port: CanvasNodePort) =>
    pendingLinkPort?.kind === port.kind &&
    pendingLinkPort.nodeId === port.nodeId &&
    pendingLinkPort.side === port.side &&
    pendingLinkPort.slot === port.slot;

  const connectorButtonClassName = (
    baseClassName: string,
    port: CanvasNodePort
  ) => `${baseClassName} studio-canvas-node-connector--${port.side} studio-canvas-node-connector--${port.slot}${isPendingPort(port) ? " is-linking" : ""}`;

  const connectorPortKey = (port: CanvasNodePort) =>
    `${port.kind}-${port.nodeId}-${port.side}-${port.slot}`;

  const canConnectCanvasPorts = (from: CanvasNodePort, to: CanvasNodePort) =>
    Boolean(normalizeCanvasLinkForFlow(from, to));

  const resetConnectorFollow = (port: CanvasNodePort) => {
    const portKey = connectorPortKey(port);
    setConnectorFollowOffsets((currentOffsets) => {
      if (!currentOffsets[portKey]) return currentOffsets;
      const { [portKey]: _removed, ...nextOffsets } = currentOffsets;
      return nextOffsets;
    });
  };

  const updateConnectorFollow = (
    port: CanvasNodePort,
    event: MouseEvent<HTMLButtonElement>
  ) => {
    const rect = event.currentTarget.getBoundingClientRect();
    const deltaX = event.clientX - (rect.left + rect.width / 2);
    const deltaY = event.clientY - (rect.top + rect.height / 2);
    const distance = Math.hypot(deltaX, deltaY);
    if (distance > connectorFollowRadius) {
      resetConnectorFollow(port);
      return;
    }

    const followScale = Math.min(connectorMaxFollowOffset, distance * connectorFollowStrength) / Math.max(distance, 1);
    const portKey = connectorPortKey(port);
    setConnectorFollowOffsets((currentOffsets) => ({
      ...currentOffsets,
      [portKey]: {
        x: deltaX * followScale,
        y: deltaY * followScale,
      },
    }));
  };

  const renderConnectorButton = (port: CanvasNodePort, baseClassName: string) => {
    const portKey = connectorPortKey(port);
    const followOffset = connectorFollowOffsets[portKey] ?? { x: 0, y: 0 };

    return (
      <button
        type="button"
        className={connectorButtonClassName(baseClassName, port)}
        data-canvas-port-kind={port.kind}
        data-canvas-port-node-id={port.nodeId}
        data-canvas-port-side={port.side}
        data-canvas-port-slot={port.slot}
        onMouseDown={(event) => handleConnectorDragStart(event, port)}
        onMouseMove={(event) => {
          if (!connectorDrag) updateConnectorFollow(port, event);
        }}
        onMouseLeave={() => resetConnectorFollow(port)}
        onClick={(event) => {
          event.preventDefault();
          event.stopPropagation();
        }}
        style={{
          "--connector-follow-x": `${followOffset.x}px`,
          "--connector-follow-y": `${followOffset.y}px`,
        } as CSSProperties}
      >
        <span>+</span>
      </button>
    );
  };

  const closeNodeContextMenus = () => {
    setTextNodeMenu(null);
    setImageNodeMenu(null);
    setVideoNodeMenu(null);
  };

  const renderCanvasNodeContextMenu = ({
    left,
    top,
    saveAssetSource,
    assetName,
    assetCoverUrl = "",
    copyNode,
    duplicateNode,
    deleteNode,
  }: {
    left: number;
    top: number;
    saveAssetSource: CanvasAssetSaveSource;
    assetName: string;
    assetCoverUrl?: string;
    copyNode: () => void;
    duplicateNode: () => void;
    deleteNode: () => void;
  }) => (
    <div
      className="studio-canvas-node-context-menu"
      style={{ left, top }}
      role="menu"
      onClick={(event) => event.stopPropagation()}
      onContextMenu={(event) => event.preventDefault()}
    >
      <button
        type="button"
        role="menuitem"
        onClick={(event) => {
          event.stopPropagation();
          setSaveAssetSource(saveAssetSource);
          setAssetName(assetName);
          setAssetCoverUrl(assetCoverUrl);
          setAssetSaveMode(saveAssetSource.kind === "text" ? "create" : "existing");
          setSelectedExistingCategory("");
          closeNodeContextMenus();
          setSaveAssetOpen(true);
        }}
      >
        保存到我的素材
      </button>
      <button type="button" role="menuitem" disabled>创建主体</button>
      <span className="studio-canvas-node-context-menu__divider" />
      <button
        type="button"
        role="menuitem"
        onClick={(event) => {
          event.stopPropagation();
          copyNode();
          closeNodeContextMenus();
        }}
      >
        <span>复制节点 <span className="studio-canvas-node-context-menu__hint">?</span></span>
        <kbd>⌘C</kbd>
      </button>
      <button
        type="button"
        role="menuitem"
        onClick={(event) => {
          event.stopPropagation();
          duplicateNode();
          closeNodeContextMenus();
        }}
      >
        <span>创建副本 <span className="studio-canvas-node-context-menu__hint">?</span></span>
      </button>
      <button type="button" role="menuitem" disabled>
        <span>粘贴</span>
        <kbd>⌘V</kbd>
      </button>
      <button
        type="button"
        role="menuitem"
        onClick={(event) => {
          event.stopPropagation();
          deleteNode();
          closeNodeContextMenus();
        }}
      >
        <span>删除</span>
        <kbd>⌘⌫</kbd>
      </button>
      <span className="studio-canvas-node-context-menu__divider" />
      <button
        type="button"
        role="menuitem"
        className="studio-canvas-node-context-menu__primary"
        onClick={(event) => {
          event.stopPropagation();
          copyNode();
          closeNodeContextMenus();
        }}
      >
        复制到剪贴板
      </button>
    </div>
  );

  const getNodePortPoint = (port: CanvasNodePort) => {
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return null;
    const canvasCenterX = rect.width / 2;
    const canvasCenterY = rect.height / 2;

    if (port.kind === "text") {
      const node = textNodes.find((item) => item.id === port.nodeId);
      if (!node) return null;
      return {
        x:
          canvasCenterX +
          node.position.x +
          getCanvasNodeSideDirection(port.side) * (node.size.width / 2 + connectorAnchorOutset),
        y: canvasCenterY + node.position.y,
      };
    }

    if (port.kind === "image") {
      const node = imageNodes.find((item) => item.id === port.nodeId);
      if (!node) return null;
      return {
        x:
          canvasCenterX +
          node.position.x +
          getCanvasNodeSideDirection(port.side) * (node.size.width / 2 + connectorAnchorOutset),
        y: canvasCenterY + node.position.y,
      };
    }

    const node = videoNodes.find((item) => item.id === port.nodeId);
    if (!node) return null;
    return {
      x:
        canvasCenterX +
        node.position.x +
        getCanvasNodeSideDirection(port.side) * (node.size.width / 2 + connectorAnchorOutset),
      y: canvasCenterY + node.position.y,
    };
  };

  const buildLinkFromPorts = (id: string, from: CanvasNodePort, to: CanvasNodePort) => {
    const source = getNodePortPoint(from);
    const target = getNodePortPoint(to);
    if (!source || !target) return null;

    return {
      id,
      sourceX: source.x,
      sourceY: source.y,
      targetX: target.x,
      targetY: target.y,
      sourceSide: from.side,
      targetSide: to.side,
      sourceKind: from.kind,
      sourceNodeId: from.nodeId,
      targetKind: to.kind,
      targetNodeId: to.nodeId,
    };
  };

  const isSameCanvasPort = (first: CanvasNodePort, second: CanvasNodePort) =>
    first.nodeId === second.nodeId &&
    first.kind === second.kind &&
    first.side === second.side &&
    first.slot === second.slot;

  const connectCanvasPorts = (from: CanvasNodePort, to: CanvasNodePort) => {
    if (isSameCanvasPort(from, to)) return;

    const normalizedLink = normalizeCanvasLinkForFlow(from, to);
    if (normalizedLink) {
      pushHistorySnapshot();
      const linkIdentity = getCanvasLinkIdentity(normalizedLink.from, normalizedLink.to);
      setManualLinks((currentLinks) => {
        const hasExistingLink = currentLinks.some((link) => {
          const normalizedExistingLink = normalizeCanvasLinkForFlow(link.from, link.to);
          return normalizedExistingLink
            ? getCanvasLinkIdentity(normalizedExistingLink.from, normalizedExistingLink.to) === linkIdentity
            : false;
        });
        return hasExistingLink
          ? currentLinks
          : [
              ...currentLinks,
              {
                id: `manual-link-${Date.now()}`,
                from: normalizedLink.from,
                to: normalizedLink.to,
              },
            ];
      });
    }
    setPendingLinkPort(null);
    setPendingLinkPreviewPoint(null);
  };

  const getCanvasPortFromElement = (element: Element | null): CanvasNodePort | null => {
    const target = element?.closest<HTMLElement>(
      "[data-canvas-port-kind][data-canvas-port-node-id][data-canvas-port-side][data-canvas-port-slot]",
    );
    if (!target) return null;

    const kind = target.dataset.canvasPortKind;
    const nodeId = target.dataset.canvasPortNodeId;
    const side = target.dataset.canvasPortSide;
    const slot = target.dataset.canvasPortSlot;
    if ((kind !== "text" && kind !== "image" && kind !== "video") || !nodeId) return null;
    if (side !== "left" && side !== "right") return null;
    if (slot !== "center") return null;
    return { kind, nodeId, side, slot };
  };

  const handleConnectorDragStart = (event: MouseEvent<HTMLButtonElement>, port: CanvasNodePort) => {
    if (event.button !== 0) return;
    event.preventDefault();
    event.stopPropagation();
    setConnectorDrag({
      port,
      startX: event.clientX,
      startY: event.clientY,
      hasMoved: false,
    });
    setPendingLinkPort(null);
    setPendingLinkPreviewPoint(null);
    setContextMenu(null);
    setNodeMenu(null);
    setTextNodeMenu(null);
    setImageNodeMenu(null);
    setVideoNodeMenu(null);
    setCanvasSelectMenu(null);
  };

  const removeCanvasLink = (linkId: string) => {
    pushHistorySnapshot();
    setManualLinks((currentLinks) => currentLinks.filter((link) => link.id !== linkId));
    setImageNodes((currentNodes) =>
      currentNodes.map((node) => {
        if (node.sourceImageNodeId && `${node.sourceImageNodeId}-${node.id}` === linkId) {
          return { ...node, sourceImageNodeId: undefined };
        }
        if (node.sourceTextNodeId && `${node.sourceTextNodeId}-${node.id}` === linkId) {
          return { ...node, sourceTextNodeId: undefined };
        }
        return node;
      })
    );
    setVideoNodes((currentNodes) =>
      currentNodes.map((node) =>
        node.sourceTextNodeId && `${node.sourceTextNodeId}-${node.id}` === linkId
          ? { ...node, sourceTextNodeId: "" }
          : node
      )
    );
    setPendingLinkPort(null);
    setPendingLinkPreviewPoint(null);
    setConnectorDrag(null);
  };

  const {
    isNodeCollapsedInPackage,
    visibleTextNodes,
    visibleImageNodes,
    visibleVideoNodes,
  } = useCanvasVisibleNodes({
    textNodes,
    imageNodes,
    videoNodes,
    nodePackages,
  });
  const isLinkCollapsedInPackage = (link: { sourceKind: CanvasNodeKind; sourceNodeId: string; targetKind: CanvasNodeKind; targetNodeId: string }) =>
    isNodeCollapsedInPackage(link.sourceKind, link.sourceNodeId) ||
    isNodeCollapsedInPackage(link.targetKind, link.targetNodeId);

  const nodeLinks = [
    ...imageNodes.flatMap((imageNode) => {
      if (imageNode.sourceImageNodeId) {
        const link = buildLinkFromPorts(
          `${imageNode.sourceImageNodeId}-${imageNode.id}`,
          { kind: "image", nodeId: imageNode.sourceImageNodeId, side: "right", slot: "center" },
          { kind: "image", nodeId: imageNode.id, side: "left", slot: "center" }
        );
        return link ? [link] : [];
      }
      if (!imageNode.sourceTextNodeId) return [];
      const link = buildLinkFromPorts(
        `${imageNode.sourceTextNodeId}-${imageNode.id}`,
        { kind: "text", nodeId: imageNode.sourceTextNodeId, side: "right", slot: "center" },
        { kind: "image", nodeId: imageNode.id, side: "left", slot: "center" }
      );
      return link ? [link] : [];
    }),
    ...videoNodes.flatMap((videoNode) => {
      const link = buildLinkFromPorts(
        `${videoNode.sourceTextNodeId}-${videoNode.id}`,
        { kind: "text", nodeId: videoNode.sourceTextNodeId, side: "right", slot: "center" },
        { kind: "video", nodeId: videoNode.id, side: "left", slot: "center" }
      );
      return link ? [link] : [];
    }),
    ...manualLinks.flatMap((link) => {
      const normalizedLink = normalizeCanvasLinkForFlow(link.from, link.to);
      if (!normalizedLink) return [];
      const positionedLink = buildLinkFromPorts(link.id, normalizedLink.from, normalizedLink.to);
      return positionedLink ? [positionedLink] : [];
    }),
  ].filter((link) => !isLinkCollapsedInPackage(link));
  const pendingLinkPreview =
    pendingLinkPort && pendingLinkPreviewPoint
      ? (() => {
          const source = getNodePortPoint(pendingLinkPort);
          return source
            ? {
                id: "pending-link-preview",
                sourceX: source.x,
                sourceY: source.y,
                targetX: pendingLinkPreviewPoint.x,
                targetY: pendingLinkPreviewPoint.y,
                sourceSide: pendingLinkPort.side,
                targetSide: null,
              }
            : null;
        })()
      : connectionDropMenu
        ? (() => {
            const source = getNodePortPoint(connectionDropMenu.sourcePort);
            const target = getCanvasWorldPointFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
            return source
              ? {
                  id: "pending-link-preview",
                  sourceX: source.x,
                  sourceY: source.y,
                  targetX: target.x,
                  targetY: target.y,
                  sourceSide: connectionDropMenu.sourcePort.side,
                  targetSide: null,
                }
              : null;
          })()
        : null;

  const openCanvasAddNodeMenu = useCallback((clientX: number, clientY: number) => {
    const menuPosition = positionFloatingMenu(clientX, clientY, 260, 390, 0);
    clearCanvasSelection();
    setSelectionDrag(null);
    setContextMenu(null);
    setSelectionContextMenu(null);
    setTextNodeMenu(null);
    setImageNodeMenu(null);
    setVideoNodeMenu(null);
    setCanvasSelectMenu(null);
    setNodeMenu({
      ...menuPosition,
      originLeft: clientX,
      originTop: clientY,
    });
  }, []);

  const handlePaneContextMenu = useCallback((event: MouseEvent | globalThis.MouseEvent) => {
    event.preventDefault();
    openCanvasAddNodeMenu(event.clientX, event.clientY);
  }, [openCanvasAddNodeMenu]);

  const handleCanvasContextMenu = (event: MouseEvent<HTMLElement>) => {
    if (
      event.target instanceof Element &&
      event.target.closest(
        ".studio-canvas-text-node, .studio-canvas-image-node, .studio-canvas-video-node, .studio-canvas-node-package-box, .studio-canvas-node-package-card, .react-flow__controls, .studio-canvas-project-bar, .studio-canvas-recent-drawer, .studio-canvas-context-menu, .studio-canvas-node-context-menu, .studio-canvas-selection-context-menu, .studio-canvas-add-node-menu, .studio-canvas-save-asset, textarea, input, button, [role='menu']"
      )
    ) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    const clickPoint = getCanvasPointFromClient(event.clientX, event.clientY);
    const expandedBounds = getCanvasNodePackageBoundsWithMeta();
    for (const { nodePackage, bounds } of expandedBounds) {
      const boxLeft = bounds.left - 18;
      const boxTop = bounds.top - 34;
      const boxRight = boxLeft + bounds.width + 36;
      const boxBottom = boxTop + bounds.height + 54;
      if (
        clickPoint.x >= boxLeft &&
        clickPoint.x <= boxRight &&
        clickPoint.y >= boxTop &&
        clickPoint.y <= boxBottom
      ) {
        openCanvasNodePackageContextMenu(nodePackage, event.clientX, event.clientY);
        return;
      }
    }
    const collapsedCards = getCanvasCollapsedPackageCardsWithMeta();
    for (const { nodePackage, bounds } of collapsedCards) {
      const cardW = Math.min(360, Math.max(240, bounds.width));
      const cardH = Math.max(112, Math.min(150, bounds.height || 132));
      if (
        clickPoint.x >= bounds.left &&
        clickPoint.x <= bounds.left + cardW &&
        clickPoint.y >= bounds.top &&
        clickPoint.y <= bounds.top + cardH
      ) {
        openCanvasNodePackageContextMenu(nodePackage, event.clientX, event.clientY);
        return;
      }
    }
    const selBounds = getCanvasSelectedNodesBounds();
    if (selBounds) {
      const selLeft = selBounds.left - 10;
      const selTop = selBounds.top - 30;
      const selRight = selLeft + selBounds.width + 20;
      const selBottom = selTop + selBounds.height + 40;
      if (
        clickPoint.x >= selLeft &&
        clickPoint.x <= selRight &&
        clickPoint.y >= selTop &&
        clickPoint.y <= selBottom
      ) {
        openCanvasSelectionContextMenu(event.clientX, event.clientY);
        return;
      }
    }

    openCanvasAddNodeMenu(event.clientX, event.clientY);
  };

  const handlePaneClick = useCallback(() => {
    if (suppressNextPaneClickRef.current) {
      suppressNextPaneClickRef.current = false;
      return;
    }
    clearCanvasSelection();
    setPendingLinkPort(null);
    setPendingLinkPreviewPoint(null);
    setConnectorDrag(null);
    setCanvasSelectMenu(null);
    setRecentProjectsOpen(false);
  }, []);

  useEffect(() => {
    if (!contextMenu && !nodeMenu && !textNodeMenu && !imageNodeMenu && !videoNodeMenu && !selectionContextMenu && !canvasSelectMenu && !connectionDropMenu) return;
    const closeMenu = () => {
      setContextMenu(null);
      setNodeMenu(null);
      setTextNodeMenu(null);
      setImageNodeMenu(null);
      setVideoNodeMenu(null);
      setSelectionContextMenu(null);
      setCanvasSelectMenu(null);
      if (connectionDropMenu) {
        setConnectionDropMenu(null);
        setPendingLinkPort(null);
        setPendingLinkPreviewPoint(null);
      }
    };
    const raf = requestAnimationFrame(() => {
      window.addEventListener("click", closeMenu);
      window.addEventListener("keydown", closeMenu);
    });
    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener("click", closeMenu);
      window.removeEventListener("keydown", closeMenu);
    };
  }, [contextMenu, nodeMenu, textNodeMenu, imageNodeMenu, videoNodeMenu, selectionContextMenu, canvasSelectMenu, connectionDropMenu]);

  useEffect(() => {
    if (!connectorDrag) return;

    const clearPendingConnector = () => {
      setPendingLinkPort(null);
      setPendingLinkPreviewPoint(null);
    };

    const handleMove = (event: globalThis.MouseEvent) => {
      const hasDragged =
        Math.hypot(event.clientX - connectorDrag.startX, event.clientY - connectorDrag.startY) > canvasNodeClickMoveThreshold;
      if (!hasDragged && !connectorDrag.hasMoved) return;

      if (!connectorDrag.hasMoved) {
        setConnectorDrag((currentDrag) =>
          currentDrag && isSameCanvasPort(currentDrag.port, connectorDrag.port)
            ? { ...currentDrag, hasMoved: true }
            : currentDrag
        );
        setPendingLinkPort(connectorDrag.port);
        clearCanvasSelection();
      }
      setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
    };

    const handleUp = (event: globalThis.MouseEvent) => {
      const hasDragged =
        connectorDrag.hasMoved ||
        Math.hypot(event.clientX - connectorDrag.startX, event.clientY - connectorDrag.startY) > canvasNodeClickMoveThreshold;

      if (hasDragged) {
        const targetPort = getCanvasPortFromElement(document.elementFromPoint(event.clientX, event.clientY));
        if (targetPort) {
          connectCanvasPorts(connectorDrag.port, targetPort);
        } else {
          const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, -40);
          setConnectionDropMenu({
            ...menuPosition,
            originLeft: event.clientX,
            originTop: event.clientY,
            sourcePort: connectorDrag.port,
          });
          setPendingLinkPort(null);
          setPendingLinkPreviewPoint(null);
        }
      } else {
        clearPendingConnector();
      }
      setConnectorDrag(null);
    };

    window.addEventListener("mousemove", handleMove);
    window.addEventListener("mouseup", handleUp);
    return () => {
      window.removeEventListener("mousemove", handleMove);
      window.removeEventListener("mouseup", handleUp);
    };
  }, [connectorDrag]);

  useEffect(() => {
    const sourcePort = pendingAutoConnectRef.current;
    if (!sourcePort || !selectedNode) return;
    pendingAutoConnectRef.current = null;
    const targetSide = sourcePort.side === "right" ? "left" : "right";
    connectCanvasPorts(sourcePort, { kind: selectedNode.kind, nodeId: selectedNode.id, side: targetSide, slot: "center" });
  }, [selectedNode]);

  const handleCanvasMouseMove = (event: MouseEvent<HTMLElement>) => {
    if (!pendingLinkPort || connectionDropMenu) return;
    setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
  };

  const handleCanvasWheel = (event: WheelEvent<HTMLElement>) => {
    if (!canvasRef.current) return;
    if (
      event.target instanceof Element &&
      event.target.closest(
        "textarea, input, .canvas-select-chip__dropdown, .studio-canvas-context-menu, .studio-canvas-node-context-menu, .studio-canvas-add-node-menu, .studio-canvas-save-asset, .studio-canvas-style-picker, .studio-canvas-recent-drawer"
      )
    ) {
      return;
    }

    const point = getCanvasPointFromClient(event.clientX, event.clientY);
    const zoomDelta = event.deltaY < 0 ? 1.08 : 0.92;
    setCanvasViewport((viewport) => {
      const nextZoom = clampCanvasViewportZoom(viewport.zoom * zoomDelta);
      if (nextZoom === viewport.zoom) return viewport;
      const worldX = (point.x - viewport.x) / viewport.zoom;
      const worldY = (point.y - viewport.y) / viewport.zoom;
      return {
        zoom: nextZoom,
        x: point.x - worldX * nextZoom,
        y: point.y - worldY * nextZoom,
      };
    });
  };

  const getAllCanvasNodeBounds = (): CanvasNodeBounds | null => {
    const allBounds: CanvasNodeBounds[] = [];
    for (const node of textNodes) allBounds.push(getCanvasNodeScreenRect(node.position, node.size));
    for (const node of imageNodes) allBounds.push(getCanvasNodeScreenRect(node.position, node.size));
    for (const node of videoNodes) allBounds.push(getCanvasNodeScreenRect(node.position, node.size));
    return mergeCanvasNodeBounds(allBounds);
  };

  const fitCanvasView = () => {
    const bounds = getAllCanvasNodeBounds();
    if (!bounds || !canvasRef.current) return;
    const rect = canvasRef.current.getBoundingClientRect();
    const padding = 80;
    const availW = rect.width - padding * 2;
    const availH = rect.height - padding * 2;
    const zoom = clampCanvasViewportZoom(Math.min(availW / bounds.width, availH / bounds.height));
    const cx = bounds.left + bounds.width / 2;
    const cy = bounds.top + bounds.height / 2;
    setCanvasViewport({
      zoom,
      x: rect.width / 2 - cx * zoom,
      y: rect.height / 2 - cy * zoom,
    });
  };

  const zoomCanvasIn = () => {
    if (!canvasRef.current) return;
    const rect = canvasRef.current.getBoundingClientRect();
    const center = { x: rect.width / 2, y: rect.height / 2 };
    setCanvasViewport((vp) => {
      const next = clampCanvasViewportZoom(vp.zoom * 1.25);
      if (next === vp.zoom) return vp;
      const wx = (center.x - vp.x) / vp.zoom;
      const wy = (center.y - vp.y) / vp.zoom;
      return { zoom: next, x: center.x - wx * next, y: center.y - wy * next };
    });
  };

  const zoomCanvasOut = () => {
    if (!canvasRef.current) return;
    const rect = canvasRef.current.getBoundingClientRect();
    const center = { x: rect.width / 2, y: rect.height / 2 };
    setCanvasViewport((vp) => {
      const next = clampCanvasViewportZoom(vp.zoom * 0.8);
      if (next === vp.zoom) return vp;
      const wx = (center.x - vp.x) / vp.zoom;
      const wy = (center.y - vp.y) / vp.zoom;
      return { zoom: next, x: center.x - wx * next, y: center.y - wy * next };
    });
  };

  const resetCanvasZoom = () => {
    if (!canvasRef.current) return;
    const rect = canvasRef.current.getBoundingClientRect();
    setCanvasViewport((vp) => {
      const wx = (rect.width / 2 - vp.x) / vp.zoom;
      const wy = (rect.height / 2 - vp.y) / vp.zoom;
      return { zoom: 1, x: rect.width / 2 - wx, y: rect.height / 2 - wy };
    });
  };

  const handleCanvasDoubleClick = (event: MouseEvent<HTMLElement>) => {
    if (event.button !== 0 || spacePanning || imageFocusNodeId) return;
    const target = event.target instanceof Element ? event.target : null;
    if (target?.closest("textarea, input, button, [role='menu'], .studio-canvas-text-node, .studio-canvas-image-node, .studio-canvas-video-node, .studio-canvas-node-package-box__label, .react-flow__controls, .studio-canvas-project-bar, .studio-canvas-recent-drawer, .studio-canvas-context-menu, .studio-canvas-node-context-menu, .studio-canvas-add-node-menu, .studio-canvas-save-asset, .studio-canvas-zoom-controls")) return;
    const worldPoint = getCanvasWorldPointFromClient(event.clientX, event.clientY);
    addTextNode(undefined, worldPoint);
  };

  const shouldStartSelectionDrag = (target: EventTarget | null) =>
    target instanceof Element &&
    Boolean(target.closest(".react-flow__pane, .studio-canvas, .studio-canvas-world, .studio-canvas-node-links")) &&
    !target.closest(
      ".studio-canvas-text-node, .studio-canvas-image-node, .studio-canvas-video-node, .studio-canvas-node-package-box, .studio-canvas-node-package-card, .studio-canvas-zoom-controls, .studio-canvas-project-bar, .studio-canvas-recent-drawer, .studio-canvas-context-menu, .studio-canvas-node-context-menu, .studio-canvas-selection-context-menu, .studio-canvas-add-node-menu, .studio-canvas-save-asset, textarea, input, button, [role='menu']"
    );

  const startProjectNameEditing = () => {
    setProjectNameDraft(currentProjectTitle);
    setProjectNameEditing(true);
    setRecentProjectsOpen(false);
  };

  const cancelProjectNameEditing = () => {
    setProjectNameDraft(currentProjectTitle);
    setProjectNameEditing(false);
  };

  const commitProjectNameEditing = () => {
    const nextTitle = projectNameDraft.trim() || "未命名项目";
    setCurrentProjectTitle(nextTitle);
    setProjectNameDraft(nextTitle);
    setProjectNameEditing(false);
    setProjectSaveState((current) =>
      current.status === "saving" ? current : { status: "idle", message: "项目名称已更新，记得保存" },
    );
  };

  const buildCanvasWorkflowSnapshot = (): WebCanvasWorkflow => {
    const workflowNodes: WebCanvasWorkflow["nodes"] = [
      ...textNodes.map((node) => ({
        id: node.id,
        kind: "text" as const,
        label: node.title,
        detail: node.content || node.prompt || "",
        position: { ...node.position },
        size: { ...node.size },
        params: {
          prompt: node.prompt,
          content: node.content,
          model: node.selectedModelId,
        },
      })),
      ...imageNodes.map((node) => ({
        id: node.id,
        kind: "image" as const,
        label: node.title,
        detail: node.prompt || "",
        position: { ...node.position },
        size: { ...node.size },
        previewUrl: node.assetRef?.url || node.imageUrl || undefined,
        assetRef: node.assetRef || null,
        taskRef: node.taskRef || null,
        params: {
          prompt: node.prompt,
          fileName: node.fileName,
          model: node.model,
          aspectRatio: node.aspectRatio,
          imageSize: node.imageSize,
          styleReference: node.styleReference,
          focusSelection: node.focusSelection,
          marking: node.marking,
        },
        metadata: {
          fileName: node.fileName,
          model: node.model,
          aspectRatio: node.aspectRatio,
          imageSize: node.imageSize,
          styleReference: node.styleReference,
          focusSelection: node.focusSelection,
          marking: node.marking,
        },
      })),
      ...videoNodes.map((node) => ({
        id: node.id,
        kind: "video" as const,
        label: node.title,
        detail: node.prompt || "",
        position: { ...node.position },
        size: { ...node.size },
        previewUrl: node.assetRef?.url || node.videoUrl || undefined,
        assetRef: node.assetRef || null,
        taskRef: node.taskRef || null,
        params: {
          model: node.model,
          aspectRatio: node.aspectRatio,
          resolution: node.resolution,
          duration: node.duration,
          videoMode: node.videoMode,
          marking: node.marking,
          cameraMotion: node.cameraMotion,
        },
        metadata: {
          model: node.model,
          aspectRatio: node.aspectRatio,
          resolution: node.resolution,
          duration: node.duration,
          videoMode: node.videoMode,
          marking: node.marking,
          cameraMotion: node.cameraMotion,
        },
      })),
    ];
    const nodeIds = new Set(workflowNodes.map((node) => node.id));
    const edgeMap = new Map<string, WebCanvasWorkflow["edges"][number]>();
    const addEdge = (id: string, source: string, target: string, label = "连接") => {
      if (!nodeIds.has(source) || !nodeIds.has(target) || source === target) return;
      const edgeId = id || `${source}-${target}`;
      edgeMap.set(edgeId, { id: edgeId, source, target, label, animated: true });
    };

    imageNodes.forEach((node) => {
      if (node.sourceImageNodeId) addEdge(`${node.sourceImageNodeId}-${node.id}`, node.sourceImageNodeId, node.id, "图生图");
      if (node.sourceTextNodeId) addEdge(`${node.sourceTextNodeId}-${node.id}`, node.sourceTextNodeId, node.id, "生成图片");
    });
    videoNodes.forEach((node) => {
      if (node.sourceTextNodeId) addEdge(`${node.sourceTextNodeId}-${node.id}`, node.sourceTextNodeId, node.id, "生成视频");
    });
    manualLinks.forEach((link) => {
      const normalizedLink = normalizeCanvasLinkForFlow(link.from, link.to);
      if (normalizedLink) addEdge(link.id, normalizedLink.from.nodeId, normalizedLink.to.nodeId);
    });

    const firstVideoNode = videoNodes[0];
    const firstImageNode = imageNodes[0];
    return normalizeCanvasWorkflowSchema({
      ...workflow,
      title: currentProjectTitle.trim() || workflow.title || "未命名项目",
      settings: {
        model: firstVideoNode?.model || firstImageNode?.model || workflow.settings.model,
        ratio: firstVideoNode?.aspectRatio || firstImageNode?.aspectRatio || workflow.settings.ratio,
        duration: firstVideoNode ? `${firstVideoNode.duration}s` : workflow.settings.duration,
        resolution: firstVideoNode?.resolution || firstImageNode?.imageSize || workflow.settings.resolution,
      },
      viewport: { ...canvasViewport },
      nodes: workflowNodes,
      edges: Array.from(edgeMap.values()),
      packages: createWorkflowPackagesFromCanvasPackages(nodePackages),
    });
  };

  const runCanvasAutoSave = useCallback(async () => {
    if (!isAuthenticated || !onSaveWorkflow || shouldShowEmptyProjectState) return;
    if (canvasAutoSaveInFlightRef.current) {
      canvasAutoSavePendingRef.current = true;
      return;
    }

    const snapshot = buildCanvasWorkflowSnapshot();
    const fingerprint = buildCanvasWorkflowJson(snapshot);
    if (fingerprint === lastAutoSavedWorkflowFingerprintRef.current) return;

    canvasAutoSaveInFlightRef.current = true;
    setAutoSaveStatus("saving");
    try {
      await onSaveWorkflow(snapshot, { silent: true, reason: "autosave" });
      lastAutoSavedWorkflowFingerprintRef.current = fingerprint;
      isDirtyRef.current = false;
      setAutoSaveStatus("saved");
      if (autoSaveStatusTimerRef.current) window.clearTimeout(autoSaveStatusTimerRef.current);
      autoSaveStatusTimerRef.current = window.setTimeout(() => setAutoSaveStatus("idle"), 3000);
    } catch (error) {
      console.warn("[Canvas autosave] failed", error);
      setAutoSaveStatus("error");
    } finally {
      canvasAutoSaveInFlightRef.current = false;
      if (canvasAutoSavePendingRef.current) {
        canvasAutoSavePendingRef.current = false;
        if (canvasAutoSaveRetryTimerRef.current !== null) {
          window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
        }
        canvasAutoSaveRetryTimerRef.current = window.setTimeout(() => {
          canvasAutoSaveRetryTimerRef.current = null;
          void runCanvasAutoSave();
        }, canvasAutoSaveIdleTimeoutMs);
      }
    }
  }, [
    isAuthenticated,
    onSaveWorkflow,
    projectId,
    shouldShowEmptyProjectState,
    textNodes,
    imageNodes,
    videoNodes,
    manualLinks,
    nodePackages,
    currentProjectTitle,
    workflow,
  ]);

  // Save immediately when user leaves page or switches tab
  useEffect(() => {
    const handleVisibilityChange = () => {
      if (document.visibilityState === "hidden" && isDirtyRef.current) {
        void runCanvasAutoSave();
      }
    };
    const handleBeforeUnload = (e: BeforeUnloadEvent) => {
      if (isDirtyRef.current) {
        void runCanvasAutoSave();
        e.preventDefault();
        e.returnValue = "";
      }
    };
    document.addEventListener("visibilitychange", handleVisibilityChange);
    window.addEventListener("beforeunload", handleBeforeUnload);
    return () => {
      document.removeEventListener("visibilitychange", handleVisibilityChange);
      window.removeEventListener("beforeunload", handleBeforeUnload);
    };
  }, [runCanvasAutoSave]);

  useEffect(() => {
    if (canvasAutoSaveTimerRef.current !== null) {
      window.clearTimeout(canvasAutoSaveTimerRef.current);
      canvasAutoSaveTimerRef.current = null;
    }
    if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) {
      window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
      canvasAutoSaveIdleHandleRef.current = null;
    }

    if (!isAuthenticated || !onSaveWorkflow || shouldShowEmptyProjectState) return undefined;

    if (canvasAutoSaveHydrationRef.current) {
      canvasAutoSaveHydrationRef.current = false;
      lastAutoSavedWorkflowFingerprintRef.current = buildCanvasWorkflowJson(buildCanvasWorkflowSnapshot());
      return undefined;
    }

    canvasAutoSaveTimerRef.current = window.setTimeout(() => {
      canvasAutoSaveTimerRef.current = null;
      const requestIdleCallback = (window as unknown as {
        requestIdleCallback?: typeof window.requestIdleCallback;
      }).requestIdleCallback?.bind(window);
      if (requestIdleCallback) {
        canvasAutoSaveIdleHandleRef.current = requestIdleCallback(
          () => void runCanvasAutoSave(),
          { timeout: canvasAutoSaveIdleTimeoutMs }
        );
        return;
      }
      if (canvasAutoSaveRetryTimerRef.current !== null) {
        window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
      }
      canvasAutoSaveRetryTimerRef.current = window.setTimeout(() => {
        canvasAutoSaveRetryTimerRef.current = null;
        void runCanvasAutoSave();
      }, canvasAutoSaveIdleTimeoutMs);
    }, canvasAutoSaveDebounceMs);

    return () => {
      if (canvasAutoSaveTimerRef.current !== null) {
        window.clearTimeout(canvasAutoSaveTimerRef.current);
        canvasAutoSaveTimerRef.current = null;
      }
      if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) {
        window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
        canvasAutoSaveIdleHandleRef.current = null;
      }
      if (canvasAutoSaveRetryTimerRef.current !== null) {
        window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
        canvasAutoSaveRetryTimerRef.current = null;
      }
    };
  }, [
    isAuthenticated,
    onSaveWorkflow,
    projectId,
    shouldShowEmptyProjectState,
    textNodes,
    imageNodes,
    videoNodes,
    manualLinks,
    nodePackages,
    currentProjectTitle,
    canvasViewport,
    runCanvasAutoSave,
  ]);

  const handleSaveProject = async () => {
    if (!isAuthenticated) {
      onOpenLogin();
      return;
    }
    if (!onSaveWorkflow || projectSaveState.status === "saving") return;

    setProjectSaveState({ status: "saving", message: "正在保存到服务器..." });
    try {
      await onSaveWorkflow(buildCanvasWorkflowSnapshot(), { reason: "manual" });
      isDirtyRef.current = false;
      setProjectSaveState({ status: "success", message: "已保存到服务器" });
    } catch (error) {
      setProjectSaveState({
        status: "error",
        message: error instanceof Error ? error.message : "保存失败，请稍后重试",
      });
    }
  };

  const handleExportWorkflowJson = () => {
    const snapshot = buildCanvasWorkflowSnapshot();
    const workflowJson = buildCanvasWorkflowJson(snapshot);
    const fileName = buildWorkflowFileName(snapshot.title);
    const blob = new Blob([workflowJson], { type: "application/json;charset=utf-8" });
    const objectUrl = URL.createObjectURL(blob);
    const anchor = document.createElement("a");
    anchor.href = objectUrl;
    anchor.download = fileName;
    document.body.appendChild(anchor);
    anchor.click();
    anchor.remove();
    window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
    setCommunityPublishState({ status: "success", message: `已导出 ${fileName}` });
  };

  const handlePublishWorkflowToCommunity = async () => {
    if (communityPublishState.status === "saving") return;
    if (!isAuthenticated) {
      onOpenLogin();
      return;
    }
    if (!onSaveWorkflow) {
      setCommunityPublishState({ status: "error", message: "保存入口暂不可用，无法提交审核" });
      return;
    }

    const snapshot = buildCanvasWorkflowSnapshot();
    if (!snapshot.nodes.length) {
      setCommunityPublishState({ status: "error", message: "画布为空，请先添加节点后再提交审核" });
      return;
    }

    const fileName = buildWorkflowFileName(snapshot.title);
    const workflowJson = buildCanvasWorkflowJson(snapshot);
    setCommunityPublishState({ status: "saving", message: "正在保存并提交社区审核..." });
    setProjectSaveState({ status: "saving", message: "提交前正在保存当前画布..." });

    let projectSaved = false;
    try {
      const savedProject = await onSaveWorkflow(snapshot, { reason: "publish" });
      projectSaved = true;
      setProjectSaveState({ status: "success", message: "已保存到服务器" });

      const uploadedWorkflow = await aiGenerationClient.uploadAsset({
        dataUrl: textToDataUrl(workflowJson, "application/json"),
        name: fileName,
        mimeType: "application/json",
        scope: "community-case-workflow",
      });

      await communityClient.publishCase(
        buildCanvasCommunityCaseInput({
          workflow: snapshot,
          projectId: savedProject?.id || projectId || snapshot.id,
          uploadedWorkflow: {
            url: uploadedWorkflow.url,
            ossKey: uploadedWorkflow.ossKey,
            fileName,
          },
        }),
      );

      setCommunityPublishState({ status: "success", message: "已提交社区审核，通过后会显示在社区页面" });
    } catch (error) {
      if (!projectSaved) {
        setProjectSaveState({
          status: "error",
          message: error instanceof Error ? error.message : "保存失败，请稍后重试",
        });
      }
      setCommunityPublishState({
        status: "error",
        message: error instanceof Error ? error.message : "提交审核失败，请稍后重试",
      });
    }
  };

  const handleCanvasMouseDown = (event: MouseEvent<HTMLElement>) => {
    if (event.button === 1) {
      event.preventDefault();
      event.stopPropagation();
      setCanvasPanDrag({
        startX: event.clientX,
        startY: event.clientY,
        originX: canvasViewport.x,
        originY: canvasViewport.y,
      });
      setSelectionDrag(null);
      setPendingLinkPort(null);
      setPendingLinkPreviewPoint(null);
      setConnectorDrag(null);
      return;
    }
    if (event.button === 0 && imageFocusNodeId) {
      event.preventDefault();
      event.stopPropagation();
      cancelImageFocusMode();
      return;
    }
    if (event.button === 0 && spacePanning) {
      event.preventDefault();
      event.stopPropagation();
      setCanvasPanDrag({
        startX: event.clientX,
        startY: event.clientY,
        originX: canvasViewport.x,
        originY: canvasViewport.y,
      });
      return;
    }
    if (event.button !== 0) return;

    const target = event.target instanceof HTMLElement ? event.target : null;
    if (
      !target?.closest(
        ".studio-canvas-text-node, .studio-canvas-image-node, .studio-canvas-video-node, .studio-canvas-node-package-box__label, .react-flow__controls, .studio-canvas-project-bar, .studio-canvas-recent-drawer, .studio-canvas-context-menu, .studio-canvas-node-context-menu, .studio-canvas-selection-context-menu, .studio-canvas-add-node-menu, .studio-canvas-save-asset, textarea, input, button, [role='menu']"
      )
    ) {
      const clickPoint = getCanvasPointFromClient(event.clientX, event.clientY);
      const expandedBounds = getCanvasNodePackageBoundsWithMeta();
      for (const { nodePackage, bounds } of expandedBounds) {
        const boxLeft = bounds.left - 18;
        const boxTop = bounds.top - 34;
        const boxRight = boxLeft + bounds.width + 36;
        const boxBottom = boxTop + bounds.height + 54;
        if (
          clickPoint.x >= boxLeft &&
          clickPoint.x <= boxRight &&
          clickPoint.y >= boxTop &&
          clickPoint.y <= boxBottom
        ) {
          event.preventDefault();
          event.stopPropagation();
          startPackageDrag(event, nodePackage, false);
          return;
        }
      }
      const collapsedCards = getCanvasCollapsedPackageCardsWithMeta();
      for (const { nodePackage, bounds } of collapsedCards) {
        const cardW = Math.min(360, Math.max(240, bounds.width));
        const cardH = Math.max(112, Math.min(150, bounds.height || 132));
        if (
          clickPoint.x >= bounds.left &&
          clickPoint.x <= bounds.left + cardW &&
          clickPoint.y >= bounds.top &&
          clickPoint.y <= bounds.top + cardH
        ) {
          event.preventDefault();
          event.stopPropagation();
          startPackageDrag(event, nodePackage, true);
          return;
        }
      }
      const selBounds = getCanvasSelectedNodesBounds();
      if (selBounds) {
        const selLeft = selBounds.left - 10;
        const selTop = selBounds.top - 30;
        const selRight = selLeft + selBounds.width + 20;
        const selBottom = selTop + selBounds.height + 40;
        if (
          clickPoint.x >= selLeft &&
          clickPoint.x <= selRight &&
          clickPoint.y >= selTop &&
          clickPoint.y <= selBottom
        ) {
          event.preventDefault();
          event.stopPropagation();
          startSelectedNodesDrag(event);
          return;
        }
      }
    }

    if (!shouldStartSelectionDrag(event.target)) return;

    event.preventDefault();
    event.stopPropagation();
    const point = getCanvasPointFromClient(event.clientX, event.clientY);
    setSelectionDrag({ start: point, current: point, hasMoved: false });
    clearCanvasSelection();
    setPendingLinkPort(null);
    setPendingLinkPreviewPoint(null);
    setConnectorDrag(null);
    setContextMenu(null);
    setNodeMenu(null);
    setTextNodeMenu(null);
    setImageNodeMenu(null);
    setVideoNodeMenu(null);
    setCanvasSelectMenu(null);
    setImageFocusNodeId(null);
    setImageFocusDraft(null);
    setImageFocusDrag(null);
  };

  const handleCanvasAuxClick = (event: MouseEvent<HTMLElement>) => {
    if (event.button !== 1) return;
    event.preventDefault();
    event.stopPropagation();
  };

  useCanvasKeyboard({
    onDelete: () => {
      if (!selectedNodes.length && !selectedNode && !selectedPackageId) return;
      pushHistorySnapshot();
      if (selectedPackageId) {
        setNodePackages((pkgs) => pkgs.filter((p) => p.id !== selectedPackageId));
        setSelectedPackageId(null);
        return;
      }
      const toDelete = selectedNode ? [selectedNode] : selectedNodes;
      const textIds = new Set<string>();
      const imageIds = new Set<string>();
      const videoIds = new Set<string>();
      for (const node of toDelete) {
        if (node.kind === "text") textIds.add(node.id);
        else if (node.kind === "image") imageIds.add(node.id);
        else if (node.kind === "video") videoIds.add(node.id);
      }
      if (textIds.size) setTextNodes((nodes) => nodes.filter((n) => !textIds.has(n.id)));
      if (imageIds.size) setImageNodes((nodes) => nodes.filter((n) => !imageIds.has(n.id)));
      if (videoIds.size) setVideoNodes((nodes) => nodes.filter((n) => !videoIds.has(n.id)));
      clearCanvasSelection();
    },
    onUndo: () => {
      const snapshot = undo(getHistorySnapshot());
      if (snapshot) applyHistorySnapshot(snapshot);
    },
    onRedo: () => {
      const snapshot = redo(getHistorySnapshot());
      if (snapshot) applyHistorySnapshot(snapshot);
    },
    onSelectAll: () => {
      const all: CanvasSelectedNode[] = [
        ...textNodes.map((n) => ({ kind: "text" as const, id: n.id })),
        ...imageNodes.map((n) => ({ kind: "image" as const, id: n.id })),
        ...videoNodes.map((n) => ({ kind: "video" as const, id: n.id })),
      ];
      setSelectedNodes(all);
      setSelectedNode(null);
    },
    onCopy: () => {
      if (!selectedNode) return;
      if (selectedNode.kind === "text") {
        const node = textNodes.find((n) => n.id === selectedNode.id);
        if (node) setCopiedCanvasNode({ kind: "text", node });
      } else if (selectedNode.kind === "image") {
        const node = imageNodes.find((n) => n.id === selectedNode.id);
        if (node) setCopiedCanvasNode({ kind: "image", node });
      } else if (selectedNode.kind === "video") {
        const node = videoNodes.find((n) => n.id === selectedNode.id);
        if (node) setCopiedCanvasNode({ kind: "video", node });
      }
    },
    onPaste: () => {
      if (!copiedCanvasNode) return;
      const offset = 40;
      const pos = { x: copiedCanvasNode.node.position.x + offset, y: copiedCanvasNode.node.position.y + offset };
      duplicateCopiedCanvasNode(copiedCanvasNode, pos);
    },
    onDuplicate: () => {
      if (!selectedNode) return;
      const offset = 40;
      if (selectedNode.kind === "text") {
        const node = textNodes.find((n) => n.id === selectedNode.id);
        if (node) duplicateTextNode(node, { x: node.position.x + offset, y: node.position.y + offset });
      } else if (selectedNode.kind === "image") {
        const node = imageNodes.find((n) => n.id === selectedNode.id);
        if (node) duplicateImageNode(node, { x: node.position.x + offset, y: node.position.y + offset });
      } else if (selectedNode.kind === "video") {
        const node = videoNodes.find((n) => n.id === selectedNode.id);
        if (node) duplicateVideoNode(node, { x: node.position.x + offset, y: node.position.y + offset });
      }
    },
    onEscape: () => {
      clearCanvasSelection();
      setContextMenu(null);
      setNodeMenu(null);
      setTextNodeMenu(null);
      setImageNodeMenu(null);
      setVideoNodeMenu(null);
      setSelectionContextMenu(null);
      setCanvasSelectMenu(null);
    },
    setSpacePanning: setSpacePanning,
    isInputFocused: () => {
      const el = document.activeElement;
      return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement;
    },
  });

  const isCanvasNodeMoving = Boolean(
    textNodeDrag?.hasMoved ||
      imageNodeDrag?.hasMoved ||
      videoNodeDrag?.hasMoved ||
      selectionDrag?.hasMoved ||
      nodeResizeDrag
  );
  const selectionRect = selectionDrag?.hasMoved
    ? normalizeCanvasSelectionRect(selectionDrag.start, selectionDrag.current)
    : null;
  const selectedNodesBounds = getCanvasSelectedNodesBounds();
  const nodePackageBounds = getCanvasNodePackageBoundsWithMeta();
  const collapsedPackageCards = getCanvasCollapsedPackageCardsWithMeta();
  const activePackage = selectedPackageId
    ? nodePackages.find((nodePackage) => nodePackage.id === selectedPackageId) || null
    : null;
  const selectedPackageCount = selectedPackageId ? 1 : getCanvasSelectedPackageCount();
  const activeProjectNotice = communityPublishState.message ? communityPublishState : projectSaveState;

  return (
    <WorkspacePageShell title="画布" fullWidth className="canvas-page page-motion">
      <div className={`studio-tool-layout studio-tool-layout--no-top studio-tool-layout--no-left studio-tool-layout--no-right studio-tool-layout--canvas${(shouldShowEmptyProjectState || isWaitingForProjects) ? " studio-tool-layout--canvas-empty" : ""}`}>
        <section
          className={`studio-canvas${pendingLinkPort ? " is-linking" : ""}${isCanvasDragging ? " is-canvas-dragging" : ""}${(shouldShowEmptyProjectState || isWaitingForProjects) ? " is-empty-projects" : ""}`}
          ref={canvasRef}
          onAuxClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasAuxClick}
          onContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? (event) => event.preventDefault() : handleCanvasContextMenu}
          onMouseDownCapture={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseDown}
          onDoubleClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDoubleClick}
          onMouseMove={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseMove}
          onWheel={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasWheel}
          onDragEnter={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDragEnter}
          onDragOver={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDragOver}
          onDragLeave={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDragLeave}
          onDrop={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDrop}
          style={{
            "--canvas-bg-size": `${34 * canvasViewport.zoom}px`,
            "--canvas-bg-dot": `${1.35 * canvasViewport.zoom}px`,
            "--canvas-bg-x": `${canvasViewport.x}px`,
            "--canvas-bg-y": `${canvasViewport.y}px`,
            cursor: canvasPanDrag ? "grabbing" : spacePanning ? "grab" : undefined,
          } as CSSProperties}
        >
          <input
            ref={canvasUploadInputRef}
            type="file"
            accept="image/*"
            className="studio-canvas-hidden-input"
            onChange={(event) => handleImageFileSelected(event, pendingImagePosition)}
          />
          <input
            ref={imageNodeInputRef}
            type="file"
            accept="image/*"
            className="studio-canvas-hidden-input"
            onChange={(event) => handleImageFileSelected(event, pendingImagePosition)}
          />
          {(!shouldShowEmptyProjectState || isWaitingForProjects) ? (
          <div className="studio-canvas-project-bar" onMouseDown={(event) => event.stopPropagation()}>
            <div className="studio-canvas-project-bar__identity">
              {projectNameEditing ? (
                <form
                  className="studio-canvas-project-bar__name-form"
                  onSubmit={(event) => {
                    event.preventDefault();
                    commitProjectNameEditing();
                  }}
                >
                  <input
                    value={projectNameDraft}
                    autoFocus
                    aria-label="编辑项目名称"
                    onChange={(event) => setProjectNameDraft(event.target.value)}
                    onKeyDown={(event) => {
                      if (event.key === "Escape") {
                        event.preventDefault();
                        cancelProjectNameEditing();
                      }
                    }}
                  />
                  <button type="submit" aria-label="确认项目名称">
                    <CheckOutlined />
                  </button>
                  <button type="button" aria-label="取消编辑项目名称" onClick={cancelProjectNameEditing}>
                    <CloseOutlined />
                  </button>
                </form>
              ) : (
                <div className="studio-canvas-project-bar__name" title={currentProjectTitle}>
                  <span>{currentProjectTitle}</span>
                </div>
              )}
              <span className={`studio-canvas-project-bar__status is-${activeProjectNotice.status}`}>
                {activeProjectNotice.message || (projectId ? "服务器项目" : "未保存项目")}
              </span>
            </div>
            <button
              type="button"
              className="studio-canvas-project-bar__rename"
              aria-label="编辑项目名称"
              title="编辑项目名称"
              onClick={startProjectNameEditing}
            >
              <EditOutlined />
            </button>
            <button
              type="button"
              className={`studio-canvas-project-bar__recent${recentProjectsOpen ? " is-active" : ""}`}
              aria-controls="studio-canvas-recent-drawer"
              aria-expanded={recentProjectsOpen}
              onClick={(event) => {
                event.stopPropagation();
                setRecentProjectsOpen((current) => !current);
              }}
            >
              <ClockCircleOutlined />
              <span>最近项目</span>
              {projects.length ? <em>{projects.length}</em> : null}
            </button>
            <button
              type="button"
              className="studio-canvas-project-bar__export"
              aria-label="导出工作流 JSON"
              title="导出工作流 JSON"
              onClick={handleExportWorkflowJson}
            >
              <DownloadOutlined />
              <span>导出 JSON</span>
            </button>
            <button
              type="button"
              className="studio-canvas-project-bar__save"
              disabled={projectSaveState.status === "saving"}
              onClick={() => void handleSaveProject()}
            >
              <SaveOutlined />
              {projectSaveState.status === "saving" ? "保存中" : "保存"}
            </button>
            <span className={`studio-canvas-project-bar__autosave-status studio-canvas-project-bar__autosave-status--${autoSaveStatus}`}>
              {autoSaveStatus === "saving" ? "保存中..." : autoSaveStatus === "saved" ? "已保存" : autoSaveStatus === "error" ? "保存失败" : ""}
            </span>
            <button
              type="button"
              className="studio-canvas-project-bar__publish"
              disabled={communityPublishState.status === "saving"}
              onClick={() => void handlePublishWorkflowToCommunity()}
            >
              <UploadOutlined />
              <span>{communityPublishState.status === "saving" ? "提交中" : "提交审核"}</span>
            </button>
          </div>
          ) : null}
          {(!shouldShowEmptyProjectState || isWaitingForProjects) && recentProjectsOpen ? (
            <aside
              id="studio-canvas-recent-drawer"
              className="studio-canvas-recent-drawer"
              aria-label="最近项目"
              onMouseDown={(event) => event.stopPropagation()}
              onClick={(event) => event.stopPropagation()}
            >
              <div className="studio-canvas-recent-drawer__list">
                {projects.length ? (
                  projects.map((project, index) => (
                    <button
                      key={project.id}
                      type="button"
                      className="studio-canvas-recent-project"
                      disabled={!onOpenProject}
                      onClick={() => {
                        setRecentProjectsOpen(false);
                        onOpenProject?.(project);
                      }}
                    >
                      {project.thumbnailUrl ? (
                        <img src={project.thumbnailUrl} alt="" />
                      ) : (
                        <span className="studio-canvas-recent-project__thumb">
                          <FolderOpenOutlined />
                        </span>
                      )}
                      <span className="studio-canvas-recent-project__body">
                        <strong>{project.source === "server" ? project.name : `预览项目 ${index + 1}`}</strong>
                        <small>{project.description || formatCanvasProjectUpdatedAt(project.updatedAt)}</small>
                        <em>
                          {project.storyboardCount} 剧本 · {project.imageCount} 图 · {project.videoCount} 视频
                        </em>
                      </span>
                    </button>
                  ))
                ) : (
                  <div className="studio-canvas-recent-drawer__empty">
                    <FileImageOutlined />
                    <strong>暂无最近项目</strong>
                    <span>{isAuthenticated ? "社区最近项目为空" : "登录后可查看最近项目"}</span>
                  </div>
                )}
              </div>
              <button
                type="button"
                className="studio-canvas-recent-drawer__community"
                onClick={() => {
                  setRecentProjectsOpen(false);
                  onOpenCommunity();
                }}
              >
                打开社区
              </button>
            </aside>
          ) : null}
          <ReactFlow
            nodes={[]}
            edges={[]}
            nodesDraggable={false}
            nodesConnectable={false}
            elementsSelectable={false}
            minZoom={0.3}
            maxZoom={1.6}
            panOnDrag={false}
            panOnScroll={false}
            zoomOnDoubleClick={false}
            zoomOnPinch={false}
            zoomOnScroll={false}
            proOptions={{ hideAttribution: true }}
            onPaneClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneClick}
            onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
          />
          <div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
            <button type="button" title="缩小" aria-label="缩小" onClick={zoomCanvasOut}>−</button>
            <button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" aria-label="重置缩放" onClick={resetCanvasZoom}>
              {Math.round(canvasViewport.zoom * 100)}%
            </button>
            <button type="button" title="放大" aria-label="放大" onClick={zoomCanvasIn}>+</button>
            <button type="button" title="适应视图" aria-label="适应视图" onClick={fitCanvasView}>⊡</button>
          </div>
          {(shouldShowEmptyProjectState || isWaitingForProjects) ? (
            <div
              className="studio-canvas-empty-projects"
              role="status"
              aria-live="polite"
              onMouseDown={(event) => event.stopPropagation()}
              onContextMenu={(event) => {
                event.preventDefault();
                event.stopPropagation();
              }}
            >
              {isWaitingForProjects ? (
                <>
                  <div className="studio-canvas-loading-spinner" />
                  <strong>正在加载项目数据…</strong>
                </>
              ) : (
                <>
                  <strong>没有画布项目，是否需要新建画布？</strong>
                  <button
                    type="button"
                    className="studio-canvas-empty-projects__button"
                    onClick={(event) => {
                      event.stopPropagation();
                      if (onStartCreate) {
                        onStartCreate();
                        return;
                      }
                      onOpenLogin();
                    }}
                  >
                    新建画布
                  </button>
                </>
              )}
            </div>
          ) : null}
          {selectionRect ? (
            <div
              className="studio-canvas-selection-box"
              style={{
                left: selectionRect.left,
                top: selectionRect.top,
                width: selectionRect.width,
                height: selectionRect.height,
              }}
            />
          ) : null}
          {nodePackageBounds.map(({ nodePackage, bounds }) => (
            <div
              key={nodePackage.id}
              className={`studio-canvas-node-package-box${selectedPackageId === nodePackage.id ? " is-selected" : ""}${packageDrag?.packageId === nodePackage.id ? " is-dragging" : ""}`}
              style={{
                left: bounds.left - 18,
                top: bounds.top - 34,
                width: bounds.width + 36,
                height: bounds.height + 54,
              }}
              onMouseDown={(event) => startPackageDrag(event, nodePackage, false)}
              onContextMenu={(event) => handleCanvasNodePackagePointer(event, nodePackage)}
            >
              <button
                type="button"
                className="studio-canvas-node-package-box__label"
                onMouseDown={(event) => {
                  event.preventDefault();
                  event.stopPropagation();
                }}
                onClick={(event) => handleCanvasNodePackagePointer(event, nodePackage)}
                onContextMenu={(event) => handleCanvasNodePackagePointer(event, nodePackage)}
              >
                <strong>{nodePackage.title}</strong>
                <em>{nodePackage.nodeIds.length} 个节点</em>
              </button>
            </div>
          ))}
          {collapsedPackageCards.map(({ nodePackage, bounds }) => (
            <button
              key={nodePackage.id}
              type="button"
              className={`studio-canvas-node-package-card${packageDrag?.packageId === nodePackage.id ? " is-dragging" : ""}`}
              style={{
                left: bounds.left,
                top: bounds.top,
                width: Math.min(360, Math.max(240, bounds.width)),
                height: Math.max(112, Math.min(150, bounds.height || 132)),
              }}
              onMouseDown={(event) => startPackageDrag(event, nodePackage, true)}
              onContextMenu={(event) => handleCanvasNodePackagePointer(event, nodePackage)}
            >
              <span className="studio-canvas-node-package-card__label">{nodePackage.title}</span>
              <span className="studio-canvas-node-package-card__meta">{nodePackage.nodeIds.length} 个节点</span>
            </button>
          ))}
          {selectedNodesBounds ? (
            <div
              className="studio-canvas-selection-summary"
              style={{
                left: selectedNodesBounds.left - 10,
                top: selectedNodesBounds.top - 30,
                width: selectedNodesBounds.width + 20,
                height: selectedNodesBounds.height + 40,
              }}
            >
              <span className="studio-canvas-selection-summary__label">
                已选中 {selectedNodes.length} 个节点
              </span>
            </div>
          ) : null}
          <div
            className="studio-canvas-world"
            style={{
              transform: `translate(${canvasViewport.x}px, ${canvasViewport.y}px) scale(${canvasViewport.zoom})`,
            }}
          >
          {alignGuides.length > 0 && (
            <div className="studio-canvas-align-guides" aria-hidden="true">
              {alignGuides.map((guide, i) => (
                <div
                  key={`${guide.axis}-${guide.position}-${i}`}
                  className={`studio-canvas-align-guide studio-canvas-align-guide--${guide.axis}`}
                  style={guide.axis === "x" ? { left: guide.position } : { top: guide.position }}
                />
              ))}
            </div>
          )}
          {nodeLinks.length || pendingLinkPreview ? (
            <svg className="studio-canvas-node-links" aria-hidden="true">
              {nodeLinks.map((link) => {
                const controlOffset = Math.max(120, Math.abs(link.targetX - link.sourceX) * 0.42);
                const sourceControlX =
                  link.sourceX + getCanvasNodeSideDirection(link.sourceSide) * controlOffset;
                const targetControlX =
                  link.targetX + getCanvasNodeSideDirection(link.targetSide) * controlOffset;
                return (
                  <g key={link.id}>
                    <path
                      className="studio-canvas-node-link-hit"
                      d={`M ${link.sourceX} ${link.sourceY} C ${sourceControlX} ${link.sourceY}, ${targetControlX} ${link.targetY}, ${link.targetX} ${link.targetY}`}
                      onContextMenu={(event) => {
                        event.preventDefault();
                        event.stopPropagation();
                        removeCanvasLink(link.id);
                      }}
                    />
                    <circle cx={link.sourceX} cy={link.sourceY} r="8" />
                    <circle cx={link.targetX} cy={link.targetY} r="8" />
                  </g>
                );
              })}
              {pendingLinkPreview ? (
                <g className="studio-canvas-node-link-preview">
                  {(() => {
                    const controlOffset = Math.max(
                      120,
                      Math.abs(pendingLinkPreview.targetX - pendingLinkPreview.sourceX) * 0.42
                    );
                    const sourceDirection = getCanvasNodeSideDirection(pendingLinkPreview.sourceSide);
                    const sourceControlX = pendingLinkPreview.sourceX + sourceDirection * controlOffset;
                    const targetControlX = pendingLinkPreview.targetX - sourceDirection * controlOffset;
                    return (
                      <>
                        <path
                          d={`M ${pendingLinkPreview.sourceX} ${pendingLinkPreview.sourceY} C ${sourceControlX} ${pendingLinkPreview.sourceY}, ${targetControlX} ${pendingLinkPreview.targetY}, ${pendingLinkPreview.targetX} ${pendingLinkPreview.targetY}`}
                        />
                        <circle cx={pendingLinkPreview.sourceX} cy={pendingLinkPreview.sourceY} r="8" />
                        <circle cx={pendingLinkPreview.targetX} cy={pendingLinkPreview.targetY} r="6" />
                      </>
                    );
                  })()}
                </g>
              ) : null}
            </svg>
          ) : null}
          {visibleTextNodes.map((textNode) => {
            const textNodeSelected = isSelectedNode("text", textNode.id);
            const textNodeActive = isActiveSelectedNode("text", textNode.id);
            const textNodeResizing = nodeResizeDrag?.kind === "text" && nodeResizeDrag.nodeId === textNode.id;
            const textTaskState = textGenerationState[textNode.id];
            const textNodeGenerating = textTaskState?.status === "running";
            const textNodeCanGenerate = Boolean(getEffectiveNodePrompt("text", textNode.id, textNode.prompt));
            const textNodeDisplayContent =
              textNode.content ||
              (textTaskState?.status === "running"
                ? "AI 正在写入文本..."
                : textTaskState?.status === "error"
                  ? textTaskState.message
                  : "");
            return (
            <div
              className={`studio-canvas-text-node${textNodeDrag?.nodeId === textNode.id ? " is-dragging" : ""}${textNodeSelected ? " is-selected" : ""}${textNodeResizing ? " is-resizing" : ""}`}
              key={textNode.id}
              style={{
                "--text-node-x": `${textNode.position.x}px`,
                "--text-node-y": `${textNode.position.y}px`,
                "--canvas-node-width": `${textNode.size.width}px`,
                "--canvas-node-height": `${textNode.size.height}px`,
              } as CSSProperties}
            >
              <div className="studio-canvas-text-node__scaled">
                <div className="studio-canvas-text-node__title">
                  <FileTextOutlined />
                  <span>{textNode.title}</span>
                </div>
                <div
                  className="studio-canvas-text-node__card"
                  onContextMenu={(event) => {
                    event.preventDefault();
                    event.stopPropagation();
                    const pkg = findNodePackage("text", textNode.id);
                    if (pkg) {
                      openCanvasNodePackageContextMenu(pkg, event.clientX, event.clientY);
                      return;
                    }
                    if (isMultiSelectedNode("text", textNode.id)) {
                      openCanvasSelectionContextMenu(event.clientX, event.clientY);
                      return;
                    }
                    selectCanvasNode("text", textNode.id);
                    setContextMenu(null);
                    setNodeMenu(null);
                    setSelectionContextMenu(null);
                    setTextNodeMenu({ left: event.clientX, top: event.clientY, nodeId: textNode.id });
                    setImageNodeMenu(null);
                    setVideoNodeMenu(null);
                  }}
                  onMouseDown={(event) => {
                    if (event.button !== 0) return;
                    event.preventDefault();
                    event.stopPropagation();
                    if (!textNodeSelected) clearCanvasSelection();
                    setContextMenu(null);
                    setNodeMenu(null);
                    setTextNodeMenu(null);
                    setImageNodeMenu(null);
                    setVideoNodeMenu(null);
                    setCanvasSelectMenu(null);
                    setTextNodeDrag({
                      nodeId: textNode.id,
                      startX: event.clientX,
                      startY: event.clientY,
                      originX: textNode.position.x,
                      originY: textNode.position.y,
                      hasMoved: false,
                    });
                  }}
                >
                  {textNode.isEditingContent ? (
                    <textarea
                      className="studio-canvas-text-node__inline-input"
                      value={textNode.content}
                      autoFocus
                      onMouseDown={(event) => event.stopPropagation()}
                      onClick={(event) => event.stopPropagation()}
                      onChange={(event) => updateTextNodeContent(textNode.id, event.target.value)}
                      onBlur={() => finishTextNodeContentEditing(textNode.id)}
                      placeholder="请编写您的内容"
                    />
                  ) : textNodeDisplayContent ? (
                    <button
                      type="button"
                      className="studio-canvas-text-node__content"
                      onMouseDown={(event) => event.stopPropagation()}
                      onClick={(event) => {
                        event.stopPropagation();
                        selectCanvasNode("text", textNode.id);
                        setTextNodeContentEditing(textNode.id, true);
                      }}
                    >
                      {textNodeDisplayContent}
                    </button>
                  ) : (
                    <>
                      <div className="studio-canvas-text-node__glyph" aria-hidden="true">
                        <span />
                        <span />
                        <span />
                        <span />
                      </div>
                      <div className="studio-canvas-text-node__suggestions">
                        <span>尝试:</span>
                        <button
                          type="button"
                          onMouseDown={(event) => event.stopPropagation()}
                          onClick={(event) => {
                            event.stopPropagation();
                            setTextNodeContentEditing(textNode.id, true);
                          }}
                        >
                          <FileTextOutlined /> 请编写您的内容
                        </button>
                        <button
                          type="button"
                          onMouseDown={(event) => event.stopPropagation()}
                          onClick={(event) => {
                            event.stopPropagation();
                            addVideoNodeFromText(textNode);
                          }}
                        >
                          <VideoCameraOutlined /> 文生视频
                        </button>
                        <button
                          type="button"
                          onMouseDown={(event) => event.stopPropagation()}
                          onClick={(event) => {
                            event.stopPropagation();
                            reversePromptFromLinkedNode(textNode);
                          }}
                        >
                          <FileImageOutlined /> 根据图片反推提示词
                        </button>
                      </div>
                    </>
                  )}
                  {renderConnectorButton({ kind: "text", nodeId: textNode.id, side: "left", slot: "center" }, "studio-canvas-text-node__connector")}
                  {renderConnectorButton({ kind: "text", nodeId: textNode.id, side: "right", slot: "center" }, "studio-canvas-text-node__connector")}
                  <button
                    type="button"
                    className="studio-canvas-node-resize-handle"
                    aria-label="Resize text node"
                    onMouseDown={(event) => handleNodeResizeStart(event, "text", textNode.id, textNode.size)}
                  />
                </div>
                {textNodeActive && !isCanvasNodeMoving ? (
                  <CanvasTextPromptComposer
                    nodeId={textNode.id}
                    prompt={textNode.prompt}
                    canGenerate={textNodeCanGenerate}
                    isGenerating={textNodeGenerating}
                    mentionOptions={buildNodeMentionOptions("text", textNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
                    mentionState={textNodeMentionStates[textNode.id]}
                    onPromptChange={updateTextNodePrompt}
                    onMentionStateChange={setTextNodeMentionStates}
                    onCloseMention={closeTextNodeMention}
                    onInsertMention={insertTextNodeMention}
                    onGenerate={handleGenerateTextNode}
                  />
                ) : null}
              </div>
            </div>
            );
          })}
          {visibleImageNodes.map((imageNode) => {
            const imageNodeSelected = isSelectedNode("image", imageNode.id);
            const imageNodeActive = isActiveSelectedNode("image", imageNode.id);
            const imageNodeResizing = nodeResizeDrag?.kind === "image" && nodeResizeDrag.nodeId === imageNode.id;
            const imageTaskState = imageGenerationState[imageNode.id];
            const imageNodeGenerating =
              imageTaskState?.status === "submitting" || imageTaskState?.status === "running";
            const imageNodeProgress = normalizeCanvasGenerationProgress(imageTaskState);
            const imageNodeProgressVisible =
              imageNodeGenerating || (imageTaskState?.status === "success" && !imageNode.imageUrl);
            const imageNodeCanGenerate = Boolean(
              getEffectiveNodePrompt("image", imageNode.id, imageNode.prompt) ||
                getConnectedImageReferenceItems("image", imageNode.id, imageNode).length
            );
            const imageNodeFocusActive = imageFocusNodeId === imageNode.id;
            const imageFocusToolActive = Boolean(imageFocusNodeId);
            const imageNodeLoadFailed = Boolean(
              imageNode.imageUrl && imageLoadErrors[imageNode.id] === imageNode.imageUrl
            );
            const imageFocusSelection = imageNodeFocusActive ? imageFocusDraft ?? imageNode.focusSelection ?? null : null;
            const imageFocusSelectionReady = Boolean(
              imageFocusSelection && imageFocusSelection.width >= 2 && imageFocusSelection.height >= 2
            );
            const imageFocusSelectionStyle = imageFocusSelection
              ? {
                  "--focus-x": `${imageFocusSelection.x}%`,
                  "--focus-y": `${imageFocusSelection.y}%`,
                  "--focus-w": `${imageFocusSelection.width}%`,
                  "--focus-h": `${imageFocusSelection.height}%`,
                } as CSSProperties
              : undefined;
            return (
            <div
              className={`studio-canvas-image-node${imageNodeDrag?.nodeId === imageNode.id ? " is-dragging" : ""}${imageNodeSelected ? " is-selected" : ""}${imageNodeResizing ? " is-resizing" : ""}${imageFocusToolActive ? " is-focus-tool-active" : ""}${imageNodeFocusActive ? " is-focus-selecting" : ""}`}
              key={imageNode.id}
              style={{
                "--image-node-x": `${imageNode.position.x}px`,
                "--image-node-y": `${imageNode.position.y}px`,
                "--canvas-node-width": `${imageNode.size.width}px`,
                "--canvas-node-height": `${imageNode.size.height}px`,
              } as CSSProperties}
            >
              <div className="studio-canvas-image-node__scaled">
                {imageNodeSelected && imageNode.imageUrl && (
                  <CanvasNodeToolbar
                    actions={[
                      { key: "regenerate", label: "重绘", icon: <ReloadOutlined />, loading: imageNodeGenerating },
                      { key: "upscale", label: "超分", icon: <ThunderboltOutlined />, disabled: imageNodeGenerating },
                      { key: "save", label: "保存", icon: <SaveOutlined />, disabled: imageNodeGenerating },
                    ]}
                    onAction={(key) => {
                      if (key === "regenerate") void handleGenerateImageNode(imageNode.id);
                      if (key === "save") {
                        setSaveAssetSource({
                          kind: "image",
                          name: imageNode.fileName || imageNode.title,
                          description: imageNode.prompt || "从画布图片节点保存的素材。",
                          imageUrl: imageNode.imageUrl,
                        });
                        setAssetName(imageNode.fileName || imageNode.title);
                        setAssetCoverUrl(imageNode.imageUrl || "");
                        setAssetSaveMode("existing");
                        setSelectedExistingCategory("");
                        setSaveAssetOpen(true);
                      }
                      if (key === "upscale") setCanvasToolModal({ tool: "upscale", imageNode });
                    }}
                    moreActions={[
                      { key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !imageNode.imageUrl },
                      { key: "download", label: "下载", icon: <DownloadOutlined />, disabled: !imageNode.imageUrl },
                      { key: "duplicate", label: "创建副本", icon: <PictureOutlined /> },
                      { key: "delete", label: "删除", icon: <DeleteOutlined /> },
                    ]}
                    onMoreAction={(key) => {
                      if (key === "copy" && imageNode.imageUrl) void navigator.clipboard.writeText(imageNode.imageUrl);
                      if (key === "download" && imageNode.imageUrl) {
                        const a = document.createElement("a");
                        a.href = imageNode.imageUrl;
                        a.download = imageNode.fileName || `${imageNode.title}.png`;
                        a.target = "_blank";
                        document.body.appendChild(a);
                        a.click();
                        document.body.removeChild(a);
                      }
                      if (key === "duplicate") duplicateImageNode(imageNode);
                      if (key === "delete") setImageNodes((currentNodes) => currentNodes.filter((n) => n.id !== imageNode.id));
                    }}
                  />
                )}
                <div className="studio-canvas-image-node__title">
                  <FileImageOutlined />
                  <span>{imageNode.title}</span>
                </div>
                <button
                  type="button"
                  className="studio-canvas-image-node__upload"
                  onMouseDown={(event) => event.stopPropagation()}
                  onClick={(event) => {
                    event.stopPropagation();
                    selectCanvasNode("image", imageNode.id);
                    setPendingImageNodeId(imageNode.id);
                    setPendingImagePosition(imageNode.position);
                    imageNodeInputRef.current?.click();
                  }}
                >
                  <UploadOutlined /> 上传
                </button>
                <div
                  className="studio-canvas-image-node__card"
                  onContextMenu={(event) => {
                    event.preventDefault();
                    event.stopPropagation();
                    const pkg = findNodePackage("image", imageNode.id);
                    if (pkg) {
                      openCanvasNodePackageContextMenu(pkg, event.clientX, event.clientY);
                      return;
                    }
                    if (isMultiSelectedNode("image", imageNode.id)) {
                      openCanvasSelectionContextMenu(event.clientX, event.clientY);
                      return;
                    }
                    selectCanvasNode("image", imageNode.id);
                    setContextMenu(null);
                    setNodeMenu(null);
                    setSelectionContextMenu(null);
                    setTextNodeMenu(null);
                    setVideoNodeMenu(null);
                    setImageNodeMenu({ left: event.clientX, top: event.clientY, nodeId: imageNode.id });
                  }}
                  onMouseDown={(event) => {
                    if (event.button !== 0) return;
                    if (imageFocusNodeId) {
                      handleImageFocusDragStart(event, imageNode.id);
                      return;
                    }
                    event.preventDefault();
                    event.stopPropagation();
                    if (!imageNodeSelected) clearCanvasSelection();
                    setContextMenu(null);
                    setNodeMenu(null);
                    setTextNodeMenu(null);
                    setImageNodeMenu(null);
                    setVideoNodeMenu(null);
                    setCanvasSelectMenu(null);
                    setImageNodeDrag({
                      nodeId: imageNode.id,
                      startX: event.clientX,
                      startY: event.clientY,
                      originX: imageNode.position.x,
                      originY: imageNode.position.y,
                      hasMoved: false,
                    });
                  }}
                >
                  {imageNode.imageUrl && !imageNodeLoadFailed ? (
                    <img
                      src={imageNode.imageUrl}
                      alt={imageNode.fileName || imageNode.title}
                      onLoad={(e) => {
                        setImageLoadErrors((current) => {
                          if (current[imageNode.id] !== imageNode.imageUrl) return current;
                          const next = { ...current };
                          delete next[imageNode.id];
                          return next;
                        });
                        const img = e.currentTarget;
                        const nw = img.naturalWidth;
                        const nh = img.naturalHeight;
                        if (nw > 0 && nh > 0) {
                          const titleHeight = 36;
                          const nodeWidth = imageNode.size.width;
                          const newHeight = Math.round((nodeWidth * nh) / nw) + titleHeight;
                          const clamped = clampCanvasNodeSize("image", nodeWidth, newHeight);
                          if (clamped.height !== imageNode.size.height) {
                            setImageNodes((cur) => cur.map((n) =>
                              n.id === imageNode.id ? { ...n, size: clamped } : n
                            ));
                          }
                        }
                      }}
                      onError={() => {
                        setImageLoadErrors((current) => ({ ...current, [imageNode.id]: imageNode.imageUrl }));
                      }}
                    />
                  ) : (
                    <div
                      className={`studio-canvas-image-node__placeholder${imageNodeLoadFailed ? " is-error" : ""}`}
                      role={imageNodeLoadFailed ? "status" : undefined}
                      aria-hidden={imageNodeLoadFailed ? undefined : true}
                    >
                      <FileImageOutlined className="studio-canvas-image-node__placeholder-icon" />
                      {imageNodeLoadFailed ? (
                        <div className="studio-canvas-image-node__placeholder-copy">
                          <strong>图片链接已失效</strong>
                          <small>原始临时地址已过期，请重新上传或重新生成。</small>
                        </div>
                      ) : null}
                    </div>
                  )}
                  {imageNodeProgressVisible ? (
                    <CanvasSmoothedProgressRing
                      progress={imageNodeProgress}
                      status={imageTaskState?.status || "running"}
                      message={imageTaskState?.message || "图片生成中"}
                      expectedDurationMs={DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS}
                    />
                  ) : null}
                  {imageNodeFocusActive && imageFocusSelectionReady ? (
                    <div
                      className={`studio-canvas-image-focus-layer${imageFocusDrag?.nodeId === imageNode.id ? " is-dragging" : ""}`}
                      style={imageFocusSelectionStyle}
                      onMouseDown={(event) => handleImageFocusDragStart(event, imageNode.id)}
                    >
                      <span className="studio-canvas-image-focus-layer__shade is-top" />
                      <span className="studio-canvas-image-focus-layer__shade is-bottom" />
                      <span className="studio-canvas-image-focus-layer__shade is-left" />
                      <span className="studio-canvas-image-focus-layer__shade is-right" />
                      <div className="studio-canvas-image-focus-layer__box" aria-hidden="true">
                        {["nw", "n", "ne", "e", "se", "s", "sw", "w"].map((handle) => (
                          <span key={handle} className={`studio-canvas-image-focus-layer__handle is-${handle}`} />
                        ))}
                      </div>
                    </div>
                  ) : null}
                  {renderConnectorButton({ kind: "image", nodeId: imageNode.id, side: "left", slot: "center" }, "studio-canvas-image-node__connector")}
                  {renderConnectorButton({ kind: "image", nodeId: imageNode.id, side: "right", slot: "center" }, "studio-canvas-image-node__connector")}
                  <button
                    type="button"
                    className="studio-canvas-node-resize-handle"
                    aria-label="Resize image node"
                    onMouseDown={(event) => handleNodeResizeStart(event, "image", imageNode.id, imageNode.size)}
                  />
                </div>
                {imageNodeFocusActive && imageFocusSelectionReady ? (
                  <div
                    className="studio-canvas-image-focus-toolbar"
                    onMouseDown={(event) => event.stopPropagation()}
                    onClick={(event) => event.stopPropagation()}
                  >
                    <div className="studio-canvas-image-focus-toolbar__ratios">
                      {imageFocusRatioOptions.map((option) => (
                        <button
                          key={option.value}
                          type="button"
                          className={imageFocusSelection?.ratio === option.value ? "is-active" : ""}
                          onClick={() => handleImageFocusRatioChange(option.value)}
                        >
                          {option.label}
                        </button>
                      ))}
                    </div>
                    <button type="button" className="studio-canvas-image-focus-toolbar__cancel" onClick={cancelImageFocusMode}>
                      取消
                    </button>
                    <button type="button" className="studio-canvas-image-focus-toolbar__confirm" onClick={confirmImageFocusMode}>
                      确认
                    </button>
                  </div>
                ) : null}
                {imageNodeActive && !isCanvasNodeMoving && !imageNodeFocusActive ? (
                <div className="studio-canvas-image-composer">
                  <div className="studio-canvas-image-composer__tools">
                    <button
                      type="button"
                      className={`studio-canvas-image-composer__style-button${imageNode.styleReference ? " has-style" : ""}`}
                      title={imageNode.styleReference ? `已选风格：${imageNode.styleReference.title}` : "选择社区风格"}
                      onMouseDown={(event) => event.stopPropagation()}
                      onClick={(event) => {
                        event.preventDefault();
                        event.stopPropagation();
                        openImageStylePicker(imageNode.id);
                      }}
                    >
                      {imageNode.styleReference ? (
                        <span className="studio-canvas-image-composer__style-thumb">
                          <img src={imageNode.styleReference.imageUrl} alt={imageNode.styleReference.title} />
                        </span>
                      ) : (
                        <BgColorsOutlined />
                      )}
                      <span className="studio-canvas-image-composer__style-label">
                        {imageNode.styleReference?.title || "风格"}
                      </span>
                    </button>
                    <button
                      type="button"
                      className={imageNode.marking ? "is-active" : ""}
                      title={imageNode.marking ? `标记: ${imageNode.marking}` : "添加标记描述"}
                      onMouseDown={(event) => event.stopPropagation()}
                      onClick={(event) => {
                        event.preventDefault();
                        event.stopPropagation();
                        setMarkingPopoverNodeId(markingPopoverNodeId === imageNode.id ? null : imageNode.id);
                        setCameraMotionDropdownNodeId(null);
                      }}
                    >
                      <FileImageOutlined /><span>标记</span>
                    </button>
                    {markingPopoverNodeId === imageNode.id ? (
                      <CanvasMarkingPopover
                        value={imageNode.marking}
                        placeholder="描述标记内容，如：主角站在桥上，远处是城市天际线"
                        onChange={(value) => {
                          setImageNodes((nodes) =>
                            nodes.map((node) => (node.id === imageNode.id ? { ...node, marking: value } : node)),
                          );
                        }}
                        onClear={() => {
                          setImageNodes((nodes) =>
                            nodes.map((node) => (node.id === imageNode.id ? { ...node, marking: "" } : node)),
                          );
                        }}
                        onDone={() => setMarkingPopoverNodeId(null)}
                      />
                    ) : null}
                    <button
                      type="button"
                      title="多宫格生成"
                      disabled={!imageNode.imageUrl}
                      onMouseDown={(event) => event.stopPropagation()}
                      onClick={(event) => {
                        event.preventDefault();
                        event.stopPropagation();
                        setCanvasToolModal({ tool: "multiGrid", imageNode });
                      }}
                    >
                      <BarsOutlined /><span>多宫格</span>
                    </button>
                    <button
                      type="button"
                      title="图片超分辨率"
                      disabled={!imageNode.imageUrl}
                      onMouseDown={(event) => event.stopPropagation()}
                      onClick={(event) => {
                        event.preventDefault();
                        event.stopPropagation();
                        setCanvasToolModal({ tool: "upscale", imageNode });
                      }}
                    >
                      <ThunderboltOutlined /><span>超分</span>
                    </button>
                    <button
                      type="button"
                      title="局部重绘"
                      disabled={!imageNode.imageUrl}
                      onMouseDown={(event) => event.stopPropagation()}
                      onClick={(event) => {
                        event.preventDefault();
                        event.stopPropagation();
                        setCanvasToolModal({ tool: "inpaint", imageNode });
                      }}
                    >
                      <EditOutlined /><span>局部重绘</span>
                    </button>
                    <button type="button" className="studio-canvas-image-composer__expand" aria-label="展开">↗</button>
                  </div>
                  <CanvasPromptMentionTextarea
                    nodeId={imageNode.id}
                    value={imageNode.prompt}
                    placeholder="描述你想要生成的画面内容，按/呼出指令，@引用素材"
                    mentionOptions={buildNodeMentionOptions("image", imageNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
                    mentionState={textNodeMentionStates[imageNode.id]}
                    mentionKind="image"
                    onPromptChange={updateImageNodePrompt}
                    onMentionStateChange={setTextNodeMentionStates}
                    onCloseMention={closeTextNodeMention}
                    onInsertMention={insertTextNodeMention}
                  />
                  <div className="studio-canvas-image-composer__footer">
                    <CanvasSelectChip
                      ariaLabel="选择生图模型"
                      className="canvas-select-chip--model studio-canvas-composer-chip"
                      value={resolveVisibleImageModel(imageNode.model || defaultImageModel)}
                      options={visibleImageModelOptions}
                      open={canvasSelectMenu === `${imageNode.id}:image-model`}
                      onToggle={() =>
                        setCanvasSelectMenu((current) =>
                          current === `${imageNode.id}:image-model` ? null : `${imageNode.id}:image-model`
                        )
                      }
                      onChange={(value) => {
                        updateImageNodeSetting(imageNode.id, { model: value });
                        setCanvasSelectMenu(null);
                      }}
                    />
                    <CanvasSelectChip
                      ariaLabel="选择图片比例"
                      className="studio-canvas-composer-chip studio-canvas-composer-chip--compact"
                      value={imageNode.aspectRatio || "16:9"}
                      options={imageRatioOptions}
                      open={canvasSelectMenu === `${imageNode.id}:image-ratio`}
                      onToggle={() =>
                        setCanvasSelectMenu((current) =>
                          current === `${imageNode.id}:image-ratio` ? null : `${imageNode.id}:image-ratio`
                        )
                      }
                      onChange={(value) => {
                        updateImageNodeSetting(imageNode.id, { aspectRatio: value });
                        setCanvasSelectMenu(null);
                      }}
                    />
                    <CanvasSelectChip
                      ariaLabel="选择图片清晰度"
                      className="studio-canvas-composer-chip studio-canvas-composer-chip--mini"
                      value={resolveImageQuality(resolveVisibleImageModel(imageNode.model || defaultImageModel), imageNode.imageSize || "")}
                      options={getImageQualityOptions(resolveVisibleImageModel(imageNode.model || defaultImageModel))}
                      open={canvasSelectMenu === `${imageNode.id}:image-size`}
                      onToggle={() =>
                        setCanvasSelectMenu((current) =>
                          current === `${imageNode.id}:image-size` ? null : `${imageNode.id}:image-size`
                        )
                      }
                      onChange={(value) => {
                        updateImageNodeSetting(imageNode.id, { imageSize: value });
                        setCanvasSelectMenu(null);
                      }}
                    />
                    <button
                      type="button"
                      className={`studio-canvas-text-composer__send studio-canvas-generate-button${imageNodeCanGenerate && !imageNodeGenerating ? " is-ready" : ""}`}
                      title={imageNodeGenerating ? "生成中" : "生成"}
                      disabled={imageNodeGenerating || !imageNodeCanGenerate}
                      aria-busy={imageNodeGenerating}
                      onMouseDown={(event) => event.stopPropagation()}
                      onClick={(event) => {
                        event.stopPropagation();
                        if (!imageNodeGenerating && imageNodeCanGenerate) {
                          void handleGenerateImageNode(imageNode.id);
                        }
                      }}
                    >
                      <SendOutlined />
                    </button>
                  </div>
                </div>
                ) : null}
              </div>
            </div>
            );
          })}
          {visibleVideoNodes.map((videoNode) => {
            const videoNodeSelected = isSelectedNode("video", videoNode.id);
            const videoNodeActive = isActiveSelectedNode("video", videoNode.id);
            const videoNodeResizing = nodeResizeDrag?.kind === "video" && nodeResizeDrag.nodeId === videoNode.id;
            const videoTaskState = videoGenerationState[videoNode.id];
            const videoNodeGenerating =
              videoTaskState?.status === "submitting" || videoTaskState?.status === "running";
            const videoNodeProgress = normalizeCanvasGenerationProgress(videoTaskState);
            const videoNodeProgressVisible =
              videoNodeGenerating || (videoTaskState?.status === "success" && !videoNode.videoUrl);
            const videoNodeCanGenerate = Boolean(
              getEffectiveNodePrompt("video", videoNode.id, videoNode.prompt) ||
                getConnectedImageReferenceItems("video", videoNode.id).length
            );
            return (
            <div
              className={`studio-canvas-video-node${videoNodeDrag?.nodeId === videoNode.id ? " is-dragging" : ""}${videoNodeSelected ? " is-selected" : ""}${videoNodeResizing ? " is-resizing" : ""}`}
              key={videoNode.id}
              style={{
                "--video-node-x": `${videoNode.position.x}px`,
                "--video-node-y": `${videoNode.position.y}px`,
                "--canvas-node-width": `${videoNode.size.width}px`,
                "--canvas-node-height": `${videoNode.size.height}px`,
              } as CSSProperties}
            >
              <div className="studio-canvas-video-node__scaled">
                {videoNodeSelected && videoNode.videoUrl && (
                  <CanvasNodeToolbar
                    actions={[
                      { key: "regenerate", label: "重新生成", icon: <ReloadOutlined />, loading: videoNodeGenerating },
                      { key: "save", label: "保存", icon: <SaveOutlined />, disabled: videoNodeGenerating },
                    ]}
                    onAction={(key) => {
                      if (key === "regenerate") void handleGenerateVideoNode(videoNode.id);
                      if (key === "save") {
                        setSaveAssetSource({
                          kind: "video",
                          name: videoNode.title,
                          description: videoNode.prompt || "从画布视频节点保存的素材。",
                          url: videoNode.videoUrl,
                        });
                        setAssetName(videoNode.title);
                        setAssetCoverUrl("");
                        setAssetSaveMode("existing");
                        setSelectedExistingCategory("");
                        setSaveAssetOpen(true);
                      }
                    }}
                    moreActions={[
                      { key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !videoNode.videoUrl },
                      { key: "download", label: "下载", icon: <DownloadOutlined />, disabled: !videoNode.videoUrl },
                      { key: "duplicate", label: "创建副本", icon: <PictureOutlined /> },
                      { key: "delete", label: "删除", icon: <DeleteOutlined /> },
                    ]}
                    onMoreAction={(key) => {
                      if (key === "copy" && videoNode.videoUrl) void navigator.clipboard.writeText(videoNode.videoUrl);
                      if (key === "download" && videoNode.videoUrl) {
                        const a = document.createElement("a");
                        a.href = videoNode.videoUrl;
                        a.download = `${videoNode.title}.mp4`;
                        a.target = "_blank";
                        document.body.appendChild(a);
                        a.click();
                        document.body.removeChild(a);
                      }
                      if (key === "duplicate") duplicateVideoNode(videoNode);
                      if (key === "delete") setVideoNodes((currentNodes) => currentNodes.filter((n) => n.id !== videoNode.id));
                    }}
                  />
                )}
                <div className="studio-canvas-video-node__title">
                  <VideoCameraOutlined />
                  <span>{videoNode.title}</span>
                </div>
                <div
                  className="studio-canvas-video-node__preview"
                  onContextMenu={(event) => {
                    event.preventDefault();
                    event.stopPropagation();
                    const pkg = findNodePackage("video", videoNode.id);
                    if (pkg) {
                      openCanvasNodePackageContextMenu(pkg, event.clientX, event.clientY);
                      return;
                    }
                    if (isMultiSelectedNode("video", videoNode.id)) {
                      openCanvasSelectionContextMenu(event.clientX, event.clientY);
                      return;
                    }
                    selectCanvasNode("video", videoNode.id);
                    setContextMenu(null);
                    setNodeMenu(null);
                    setSelectionContextMenu(null);
                    setTextNodeMenu(null);
                    setImageNodeMenu(null);
                    setVideoNodeMenu({ left: event.clientX, top: event.clientY, nodeId: videoNode.id });
                  }}
                  onMouseDown={(event) => {
                    if (event.button !== 0) return;
                    event.preventDefault();
                    event.stopPropagation();
                    if (!videoNodeSelected) clearCanvasSelection();
                    setContextMenu(null);
                    setNodeMenu(null);
                    setTextNodeMenu(null);
                    setImageNodeMenu(null);
                    setVideoNodeMenu(null);
                    setCanvasSelectMenu(null);
                    setVideoNodeDrag({
                      nodeId: videoNode.id,
                      startX: event.clientX,
                      startY: event.clientY,
                      originX: videoNode.position.x,
                      originY: videoNode.position.y,
                      hasMoved: false,
                    });
                  }}
                >
                  {videoNode.videoUrl ? (
                    <CanvasNodeVideoPlayer src={videoNode.videoUrl} title={videoNode.title} onVideoMeta={(vw, vh) => {
                      if (vw > 0 && vh > 0) {
                        const titleHeight = 36;
                        const nodeWidth = videoNode.size.width;
                        const newHeight = Math.round((nodeWidth * vh) / vw) + titleHeight;
                        const clamped = clampCanvasNodeSize("video", nodeWidth, newHeight);
                        if (clamped.height !== videoNode.size.height) {
                          setVideoNodes((cur) => cur.map((n) =>
                            n.id === videoNode.id ? { ...n, size: clamped } : n
                          ));
                        }
                      }
                    }} />
                  ) : (
                    <span className="studio-canvas-video-node__play"><VideoCameraOutlined /></span>
                  )}
                  {videoNodeProgressVisible ? (
                    <CanvasSmoothedProgressRing
                      progress={videoNodeProgress}
                      status={videoTaskState?.status || "running"}
                      message={videoTaskState?.message || "视频生成中"}
                      expectedDurationMs={DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS}
                    />
                  ) : null}
                  {renderConnectorButton({ kind: "video", nodeId: videoNode.id, side: "left", slot: "center" }, "studio-canvas-video-node__connector")}
                  {renderConnectorButton({ kind: "video", nodeId: videoNode.id, side: "right", slot: "center" }, "studio-canvas-video-node__connector")}
                  <button
                    type="button"
                    className="studio-canvas-node-resize-handle"
                    aria-label="Resize video node"
                    onMouseDown={(event) => handleNodeResizeStart(event, "video", videoNode.id, videoNode.size)}
                  />
                </div>
                {videoNodeActive && !isCanvasNodeMoving ? (
                <div className="studio-canvas-video-composer">
                  <div className="studio-canvas-video-composer__tabs studio-canvas-video-composer__mode-tabs">
                    <button
                      type="button"
                      className={videoNode.videoMode === "text2video" ? "is-active" : ""}
                      onMouseDown={(event) => event.stopPropagation()}
                      onClick={() => updateVideoNodeSetting(videoNode.id, { videoMode: "text2video" })}
                    >
                      文生视频
                    </button>
                    <button
                      type="button"
                      className={videoNode.videoMode === "img2video" ? "is-active" : ""}
                      onMouseDown={(event) => event.stopPropagation()}
                      onClick={() => updateVideoNodeSetting(videoNode.id, { videoMode: "img2video" })}
                    >
                      图生视频
                    </button>
                    <button
                      type="button"
                      className={videoNode.videoMode === "firstlast" ? "is-active" : ""}
                      onMouseDown={(event) => event.stopPropagation()}
                      onClick={() => updateVideoNodeSetting(videoNode.id, { videoMode: "firstlast" })}
                    >
                      首尾帧
                    </button>
                  </div>
                  <div className="studio-canvas-video-composer__tools studio-canvas-video-composer__feature-tools">
                    <button
                      type="button"
                      className={videoNode.marking ? "is-active" : ""}
                      onMouseDown={(event) => event.stopPropagation()}
                      onClick={(event) => {
                        event.preventDefault();
                        event.stopPropagation();
                        setMarkingPopoverNodeId(markingPopoverNodeId === videoNode.id ? null : videoNode.id);
                        setCameraMotionDropdownNodeId(null);
                      }}
                    >
                      标记{videoNode.marking ? " ✓" : ""}
                    </button>
                    <button
                      type="button"
                      className={videoNode.cameraMotion ? "is-active" : ""}
                      onMouseDown={(event) => event.stopPropagation()}
                      onClick={(event) => {
                        event.preventDefault();
                        event.stopPropagation();
                        setCameraMotionDropdownNodeId(cameraMotionDropdownNodeId === videoNode.id ? null : videoNode.id);
                        setMarkingPopoverNodeId(null);
                      }}
                    >
                      运镜{videoNode.cameraMotion ? ` ${CAMERA_MOTION_PRESETS.find((p) => p.value === videoNode.cameraMotion)?.label || ""}` : ""}
                    </button>
                    {markingPopoverNodeId === videoNode.id ? (
                      <CanvasMarkingPopover
                        value={videoNode.marking}
                        placeholder="描述标记内容，如：主角在城市街头行走"
                        onChange={(value) => {
                          setVideoNodes((nodes) =>
                            nodes.map((node) => (node.id === videoNode.id ? { ...node, marking: value } : node)),
                          );
                        }}
                        onClear={() => {
                          setVideoNodes((nodes) =>
                            nodes.map((node) => (node.id === videoNode.id ? { ...node, marking: "" } : node)),
                          );
                        }}
                        onDone={() => setMarkingPopoverNodeId(null)}
                      />
                    ) : null}
                    {cameraMotionDropdownNodeId === videoNode.id && (
                      <div
                        className="studio-canvas-camera-dropdown"
                        onMouseDown={(e) => e.stopPropagation()}
                        onClick={(e) => e.stopPropagation()}
                      >
                        {CAMERA_MOTION_PRESETS.map((preset) => (
                          <button
                            key={preset.value}
                            type="button"
                            className={`studio-canvas-camera-dropdown__item${videoNode.cameraMotion === preset.value ? " is-active" : ""}`}
                            onClick={() => {
                              setVideoNodes((nodes) =>
                                nodes.map((n) => (n.id === videoNode.id ? { ...n, cameraMotion: preset.value } : n)),
                              );
                              setCameraMotionDropdownNodeId(null);
                            }}
                          >
                            {preset.label}
                          </button>
                        ))}
                      </div>
                    )}
                    <button type="button">角色库</button>
                    <button type="button" className="is-active">文本</button>
                  </div>
                  <CanvasPromptMentionTextarea
                    nodeId={videoNode.id}
                    value={videoNode.prompt}
                    placeholder="根据文字描述生成视频。"
                    mentionOptions={buildNodeMentionOptions("video", videoNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
                    mentionState={textNodeMentionStates[videoNode.id]}
                    mentionKind="video"
                    onPromptChange={updateVideoNodePrompt}
                    onMentionStateChange={setTextNodeMentionStates}
                    onCloseMention={closeTextNodeMention}
                    onInsertMention={insertTextNodeMention}
                  />
                  <div className="studio-canvas-video-composer__footer studio-canvas-video-composer__settings">
                    <CanvasSelectChip
                      ariaLabel="选择视频模型"
                      className="canvas-select-chip--model studio-canvas-composer-chip"
                      value={toHappyHorseDisplayModel(videoNode.model || defaultVideoModel)}
                      options={canvasVideoModelOptions}
                      open={canvasSelectMenu === `${videoNode.id}:video-model`}
                      onToggle={() =>
                        setCanvasSelectMenu((current) =>
                          current === `${videoNode.id}:video-model` ? null : `${videoNode.id}:video-model`
                        )
                      }
                      onChange={(value) => {
                        updateVideoNodeSetting(videoNode.id, { model: value });
                        setCanvasSelectMenu(null);
                      }}
                    />
                    <CanvasSelectChip
                      ariaLabel="选择视频比例"
                      className="studio-canvas-composer-chip studio-canvas-composer-chip--compact"
                      value={videoNode.aspectRatio || "16:9"}
                      options={videoRatioOptions}
                      open={canvasSelectMenu === `${videoNode.id}:video-ratio`}
                      onToggle={() =>
                        setCanvasSelectMenu((current) =>
                          current === `${videoNode.id}:video-ratio` ? null : `${videoNode.id}:video-ratio`
                        )
                      }
                      onChange={(value) => {
                        updateVideoNodeSetting(videoNode.id, { aspectRatio: value });
                        setCanvasSelectMenu(null);
                      }}
                    />
                    <CanvasSelectChip
                      ariaLabel="选择视频清晰度"
                      className="studio-canvas-composer-chip studio-canvas-composer-chip--compact"
                      value={resolveVideoQuality(videoNode.model || defaultVideoModel, videoNode.resolution || "")}
                      options={getVideoQualityOptions(videoNode.model || defaultVideoModel)}
                      open={canvasSelectMenu === `${videoNode.id}:video-resolution`}
                      onToggle={() =>
                        setCanvasSelectMenu((current) =>
                          current === `${videoNode.id}:video-resolution` ? null : `${videoNode.id}:video-resolution`
                        )
                      }
                      onChange={(value) => {
                        updateVideoNodeSetting(videoNode.id, { resolution: value });
                        setCanvasSelectMenu(null);
                      }}
                    />
                    <CanvasSelectChip
                      ariaLabel="选择视频时长"
                      className="studio-canvas-composer-chip studio-canvas-composer-chip--mini"
                      value={videoNode.duration || "4"}
                      options={videoDurationOptions}
                      open={canvasSelectMenu === `${videoNode.id}:video-duration`}
                      onToggle={() =>
                        setCanvasSelectMenu((current) =>
                          current === `${videoNode.id}:video-duration` ? null : `${videoNode.id}:video-duration`
                        )
                      }
                      onChange={(value) => {
                        updateVideoNodeSetting(videoNode.id, { duration: value });
                        setCanvasSelectMenu(null);
                      }}
                    />
                    <button
                      type="button"
                      className={`studio-canvas-generate-button${videoNodeCanGenerate && !videoNodeGenerating ? " is-ready" : ""}`}
                      title={videoNodeGenerating ? "生成中" : "生成"}
                      disabled={videoNodeGenerating || !videoNodeCanGenerate}
                      aria-busy={videoNodeGenerating}
                      onMouseDown={(event) => event.stopPropagation()}
                      onClick={(event) => {
                        event.stopPropagation();
                        if (!videoNodeGenerating && videoNodeCanGenerate) {
                          void handleGenerateVideoNode(videoNode.id);
                        }
                      }}
                    >
                      <SendOutlined />
                    </button>
                  </div>
                </div>
                ) : null}
              </div>
            </div>
            );
          })}
          </div>
          {textNodeMenu && activeTextNode
            ? renderCanvasNodeContextMenu({
                left: textNodeMenu.left,
                top: textNodeMenu.top,
                saveAssetSource: {
                  kind: "text",
                  name: activeTextNode.title,
                  description: activeTextNode.prompt || "从画布文本节点保存的素材。",
                },
                assetName: activeTextNode.title,
                copyNode: () => setCopiedCanvasNode({ kind: "text", node: activeTextNode }),
                duplicateNode: () => duplicateTextNode(activeTextNode),
                deleteNode: () => setTextNodes((currentNodes) => currentNodes.filter((node) => node.id !== activeTextNode.id)),
              })
            : null}
          {imageNodeMenu && activeImageNode
            ? renderCanvasNodeContextMenu({
                left: imageNodeMenu.left,
                top: imageNodeMenu.top,
                saveAssetSource: {
                  kind: "image",
                  name: activeImageNode.fileName || activeImageNode.title,
                  description: activeImageNode.prompt || "从画布图片节点保存的素材。",
                  imageUrl: activeImageNode.imageUrl,
                },
                assetName: activeImageNode.fileName || activeImageNode.title,
                assetCoverUrl: activeImageNode.imageUrl,
                copyNode: () => setCopiedCanvasNode({ kind: "image", node: activeImageNode }),
                duplicateNode: () => duplicateImageNode(activeImageNode),
                deleteNode: () => setImageNodes((currentNodes) => currentNodes.filter((node) => node.id !== activeImageNode.id)),
              })
            : null}
          {videoNodeMenu && activeVideoNode
            ? renderCanvasNodeContextMenu({
                left: videoNodeMenu.left,
                top: videoNodeMenu.top,
                saveAssetSource: {
                  kind: "video",
                  name: activeVideoNode.title,
                  description: activeVideoNode.prompt || "从画布视频节点保存的素材。",
                  url: activeVideoNode.videoUrl,
                },
                assetName: activeVideoNode.title,
                copyNode: () => setCopiedCanvasNode({ kind: "video", node: activeVideoNode }),
                duplicateNode: () => duplicateVideoNode(activeVideoNode),
                deleteNode: () => setVideoNodes((currentNodes) => currentNodes.filter((node) => node.id !== activeVideoNode.id)),
              })
            : null}
          {selectionContextMenu ? (
            <div
              className="studio-canvas-selection-context-menu"
              style={{ left: selectionContextMenu.left, top: selectionContextMenu.top }}
              role="menu"
              aria-label={activePackage ? "打包节点操作" : "已选节点操作"}
              onClick={(event) => event.stopPropagation()}
              onContextMenu={(event) => event.preventDefault()}
            >
              {activePackage ? (
                <>
                  <div className="studio-canvas-selection-context-menu__title">
                    <strong>{activePackage.title}</strong>
                    <span>{activePackage.nodeIds.length} 个节点</span>
                  </div>
                  <button
                    type="button"
                    role="menuitem"
                    className="studio-canvas-selection-context-menu__primary"
                    onClick={(event) => {
                      event.stopPropagation();
                      collapseSelectedCanvasPackage();
                    }}
                  >
                    <span>折叠打包</span>
                    <kbd>Fold</kbd>
                  </button>
                  {selectedPackageCount > 0 ? (
                    <button
                      type="button"
                      role="menuitem"
                      className="studio-canvas-selection-context-menu__unpackage"
                      onClick={(event) => {
                        event.stopPropagation();
                        unpackageSelectedCanvasNodes();
                      }}
                    >
                      <span>取消打包</span>
                      <kbd>Ungroup</kbd>
                    </button>
                  ) : null}
                  <button
                    type="button"
                    role="menuitem"
                    onClick={(event) => {
                      event.stopPropagation();
                      clearCanvasSelection();
                    }}
                  >
                    <span>取消选择</span>
                    <kbd>Esc</kbd>
                  </button>
                </>
              ) : (
                <>
                  <div className="studio-canvas-selection-context-menu__title">
                    <strong>已选节点</strong>
                    <span>{selectedNodes.length} 个</span>
                  </div>
                  <button
                    type="button"
                    role="menuitem"
                    className="studio-canvas-selection-context-menu__primary"
                    disabled={selectedNodes.length < 2}
                    onClick={(event) => {
                      event.stopPropagation();
                      packageSelectedCanvasNodes();
                    }}
                  >
                    <span>打包节点</span>
                    <kbd>Group</kbd>
                  </button>
                  {selectedPackageCount > 0 ? (
                    <button
                      type="button"
                      role="menuitem"
                      className="studio-canvas-selection-context-menu__unpackage"
                      onClick={(event) => {
                        event.stopPropagation();
                        unpackageSelectedCanvasNodes();
                      }}
                    >
                      <span>取消打包</span>
                      <kbd>Ungroup</kbd>
                    </button>
                  ) : null}
                  <button
                    type="button"
                    role="menuitem"
                    onClick={(event) => {
                      event.stopPropagation();
                      clearCanvasSelection();
                    }}
                  >
                    <span>取消选择</span>
                    <kbd>Esc</kbd>
                  </button>
                </>
              )}
            </div>
          ) : null}
          {saveAssetOpen ? (
            <div className="studio-canvas-save-asset" role="dialog" aria-modal="true" aria-label="创建素材文件夹">
              <div className="studio-canvas-save-asset__modal">
                <header className="studio-canvas-save-asset__head">
                  <button
                    type="button"
                    className={assetSaveMode === "create" ? "is-active" : ""}
                    onClick={() => {
                      setAssetSaveMode("create");
                      setCoverLibraryOpen(false);
                    }}
                  >
                    创建素材文件夹
                  </button>
                  <button
                    type="button"
                    className={assetSaveMode === "existing" ? "is-active" : ""}
                    onClick={() => {
                      setAssetSaveMode("existing");
                      setCoverSourceOpen(false);
                      setCoverLibraryOpen(false);
                    }}
                  >
                    添加到现有素材文件
                  </button>
                  <button type="button" className="studio-canvas-save-asset__close" aria-label="关闭" onClick={() => setSaveAssetOpen(false)}>
                    <CloseOutlined />
                  </button>
                </header>
                {assetSaveMode === "create" ? (
                  <>
                    <div className="studio-canvas-save-asset__body">
                      <section className="studio-canvas-save-asset__cover">
                        <div className="studio-canvas-save-asset__label">封面</div>
                        <div className={`studio-canvas-save-asset__placeholder${assetCoverUrl ? " has-cover" : ""}`}>
                          {assetCoverUrl ? <img src={assetCoverUrl} alt="素材封面" /> : <span>暂无封面</span>}
                          <button type="button" onClick={() => setCoverSourceOpen((open) => !open)}><UploadOutlined /> 更换封面</button>
                          {coverSourceOpen ? (
                            <div className="studio-canvas-save-asset__cover-menu">
                              <button type="button" onClick={() => coverFileInputRef.current?.click()}>本地上传</button>
                              <button
                                type="button"
                                onClick={() => {
                                  setCoverSourceOpen(false);
                                  setCoverLibraryOpen(true);
                                }}
                              >
                                历史图库
                              </button>
                            </div>
                          ) : null}
                          {coverLibraryOpen ? (
                            <div className="studio-canvas-save-asset__cover-library">
                              {canvasAssets.length ? canvasAssets.slice(0, 6).map((asset) => (
                                <button
                                  type="button"
                                  key={asset.id}
                                  onClick={() => {
                                    setAssetCoverUrl(asset.imageUrl);
                                    setCoverLibraryOpen(false);
                                  }}
                                >
                                  <img src={asset.imageUrl} alt={asset.name} />
                                  <span>{asset.name}</span>
                                </button>
                              )) : <span>{assetLibraryNotice || "服务器资产库暂无封面"}</span>}
                            </div>
                          ) : null}
                          <input
                            ref={coverFileInputRef}
                            type="file"
                            accept="image/*"
                            className="studio-canvas-save-asset__file-input"
                            onChange={(event) => {
                              const file = event.target.files?.[0];
                              if (!file) return;
                              setAssetCoverUrl(trackObjectUrl(file));
                              setCoverSourceOpen(false);
                            }}
                          />
                        </div>
                      </section>
                      <section className="studio-canvas-save-asset__form">
                        <label>
                          <span>名称 <b>*</b></span>
                          <input value={assetName} onChange={(event) => setAssetName(event.target.value)} />
                        </label>
                        <label>
                          <span>分类 <b>*</b></span>
                          <button
                            type="button"
                            className={`studio-canvas-save-asset__select${assetCategoryOpen ? " is-open" : ""}`}
                            onClick={() => setAssetCategoryOpen((open) => !open)}
                          >
                            <span>{assetCategory || "请选择"}</span>
                            <DownOutlined />
                          </button>
                          {assetCategoryOpen ? (
                            <div className="studio-canvas-save-asset__select-menu">
                              {["其它", "人物", "场景", "物品", "风格", "音效"].map((category) => (
                                <button
                                  type="button"
                                  key={category}
                                  className={assetCategory === category ? "is-selected" : ""}
                                  onClick={() => {
                                    setAssetCategory(category);
                                    setAssetCategoryOpen(false);
                                  }}
                                >
                                  {category}
                                </button>
                              ))}
                            </div>
                          ) : null}
                        </label>
                      </section>
                    </div>
                    <button
                      type="button"
                      className="studio-canvas-save-asset__create"
                      disabled={isSavingAsset}
                      onClick={() => void saveCanvasAssetToServer(resolveAssetCategory(assetCategory))}
                    >
                      {isSavingAsset ? "保存中" : "确认"}
                    </button>
                  </>
                ) : (
                  <>
                    <div className="studio-canvas-save-asset__existing">
                      <div className="studio-canvas-save-asset__existing-title">选择要加入的资产库分类</div>
                      <div className="studio-canvas-save-asset__existing-grid">
                        {assetLibraryCategories.map((category) => (
                          <button
                            type="button"
                            key={category.key}
                            className={selectedExistingCategory === category.key ? "is-selected" : ""}
                            onClick={() => setSelectedExistingCategory(category.key)}
                          >
                            {category.label}
                            <span>{assetCountsByCategory.get(category.key) ?? 0} 个素材</span>
                          </button>
                        ))}
                      </div>
                    </div>
                    <button
                      type="button"
                      className="studio-canvas-save-asset__create"
                      disabled={!selectedExistingCategory || isSavingAsset}
                      onClick={() => {
                        if (!selectedExistingCategory) return;
                        void saveCanvasAssetToServer(selectedExistingCategory as AssetLibraryCategory);
                      }}
                    >
                      {isSavingAsset ? "保存中" : "确认"}
                    </button>
                  </>
                )}
              </div>
            </div>
          ) : null}
          {contextMenu ? (
            <div
              className="studio-canvas-context-menu"
              style={{ left: contextMenu.left, top: contextMenu.top }}
              role="menu"
              onClick={(event) => event.stopPropagation()}
              onContextMenu={(event) => event.preventDefault()}
            >
              <button
                type="button"
                role="menuitem"
                onClick={(event) => {
                  event.stopPropagation();
                  if (!contextMenu) return;
                  setPendingImagePosition(getTextNodePositionFromClient(contextMenu.originLeft, contextMenu.originTop));
                  canvasUploadInputRef.current?.click();
                }}
              >
                上传
              </button>
              <button type="button" role="menuitem" disabled>保存到我的素材</button>
              <button
                type="button"
                role="menuitem"
                onClick={(event) => {
                  event.stopPropagation();
                  if (!contextMenu) return;
                  const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 260, 390, 0);
                  setContextMenu(null);
                  setNodeMenu({
                    ...menuPosition,
                    originLeft: contextMenu.originLeft,
                    originTop: contextMenu.originTop,
                  });
                }}
              >
                添加节点
              </button>
              <span className="studio-canvas-context-menu__divider" />
              <button type="button" role="menuitem">
                <span>撤销</span>
                <kbd>⌘Z</kbd>
              </button>
              <button type="button" role="menuitem" disabled>
                <span>重做</span>
                <kbd>⇧⌘Z</kbd>
              </button>
              <span className="studio-canvas-context-menu__divider" />
              <button
                type="button"
                role="menuitem"
                disabled={!copiedCanvasNode}
                onClick={(event) => {
                  event.stopPropagation();
                  if (!copiedCanvasNode || !contextMenu) return;
                  duplicateCopiedCanvasNode(
                    copiedCanvasNode,
                    getTextNodePositionFromClient(contextMenu.originLeft, contextMenu.originTop)
                  );
                  setContextMenu(null);
                }}
              >
                <span>粘贴</span>
                <kbd>⌘V</kbd>
              </button>
            </div>
          ) : null}
          {nodeMenu ? (
            <div
              className="studio-canvas-add-node-menu"
              style={{ left: nodeMenu.left, top: nodeMenu.top }}
              role="menu"
              onClick={(event) => event.stopPropagation()}
              onContextMenu={(event) => event.preventDefault()}
            >
              <div className="studio-canvas-add-node-menu__title">添加节点</div>
              <button
                type="button"
                role="menuitem"
                onClick={(event) => {
                  event.stopPropagation();
                  if (!nodeMenu) return;
                  setNodeMenu(null);
                  setContextMenu(null);
                  addTextNode(undefined, getTextNodePositionFromClient(nodeMenu.originLeft, nodeMenu.originTop));
                }}
              >
                <span className="studio-canvas-add-node-menu__icon"><BarsOutlined /></span>
                <span>文本</span>
              </button>
              <button
                type="button"
                role="menuitem"
                onClick={(event) => {
                  event.stopPropagation();
                  if (!nodeMenu) return;
                  addImageNode("", "图片节点", getTextNodePositionFromClient(nodeMenu.originLeft, nodeMenu.originTop));
                  setNodeMenu(null);
                  setContextMenu(null);
                }}
              >
                <span className="studio-canvas-add-node-menu__icon"><FileImageOutlined /></span>
                <span>图片</span>
              </button>
              <button
                type="button"
                role="menuitem"
                onClick={(event) => {
                  event.stopPropagation();
                  if (!nodeMenu) return;
                  addVideoNode(getTextNodePositionFromClient(nodeMenu.originLeft, nodeMenu.originTop));
                  setNodeMenu(null);
                  setContextMenu(null);
                }}
              >
                <span className="studio-canvas-add-node-menu__icon"><VideoCameraOutlined /></span>
                <span>视频</span>
              </button>
              <div className="studio-canvas-add-node-menu__title studio-canvas-add-node-menu__title--section">添加资源</div>
              <button
                type="button"
                role="menuitem"
                onClick={(event) => {
                  event.stopPropagation();
                  if (!nodeMenu) return;
                  setPendingImagePosition(getTextNodePositionFromClient(nodeMenu.originLeft, nodeMenu.originTop));
                  imageNodeInputRef.current?.click();
                }}
              >
                <span className="studio-canvas-add-node-menu__icon"><UploadOutlined /></span>
                <span>上传</span>
              </button>
            </div>
          ) : null}
          {connectionDropMenu ? (
            <div
              className="studio-canvas-add-node-menu"
              style={{ left: connectionDropMenu.left, top: connectionDropMenu.top }}
              role="menu"
              onClick={(event) => event.stopPropagation()}
              onContextMenu={(event) => event.preventDefault()}
            >
              <div className="studio-canvas-add-node-menu__title">新建节点并连接</div>
              <button
                type="button"
                role="menuitem"
                onClick={(event) => {
                  event.stopPropagation();
                  if (!connectionDropMenu) return;
                  const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
                  pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
                  addTextNode(undefined, pos);
                  setConnectionDropMenu(null);
                }}
              >
                <span className="studio-canvas-add-node-menu__icon"><BarsOutlined /></span>
                <span>文本</span>
              </button>
              <button
                type="button"
                role="menuitem"
                onClick={(event) => {
                  event.stopPropagation();
                  if (!connectionDropMenu) return;
                  const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
                  pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
                  addImageNode("", "图片节点", pos);
                  setConnectionDropMenu(null);
                }}
              >
                <span className="studio-canvas-add-node-menu__icon"><FileImageOutlined /></span>
                <span>图片</span>
              </button>
              <button
                type="button"
                role="menuitem"
                onClick={(event) => {
                  event.stopPropagation();
                  if (!connectionDropMenu) return;
                  const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
                  pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
                  addVideoNode(pos);
                  setConnectionDropMenu(null);
                }}
              >
                <span className="studio-canvas-add-node-menu__icon"><VideoCameraOutlined /></span>
                <span>视频</span>
              </button>
            </div>
          ) : null}
          {stylePickerImageNodeId ? (
            <div
              className="studio-canvas-style-picker"
              role="dialog"
              aria-modal="true"
              aria-label="选择社区风格"
              onMouseDown={(event) => event.stopPropagation()}
              onClick={(event) => event.stopPropagation()}
              onContextMenu={(event) => event.preventDefault()}
            >
              <button
                type="button"
                className="studio-canvas-style-picker__backdrop"
                aria-label="关闭风格选择"
                onClick={() => setStylePickerImageNodeId(null)}
              />
              <div className="studio-canvas-style-picker__panel">
                <header className="studio-canvas-style-picker__header">
                  <nav className="studio-canvas-style-picker__tabs" aria-label="风格来源">
                    {canvasStylePickerTabs.map((tab) => (
                      <button
                        key={tab.key}
                        type="button"
                        className={stylePickerTab === tab.key ? "is-active" : ""}
                        onClick={() => setStylePickerTab(tab.key)}
                      >
                        {tab.label}
                      </button>
                    ))}
                  </nav>
                  <label className="studio-canvas-style-picker__search">
                    <SearchOutlined />
                    <input
                      value={stylePickerSearch}
                      placeholder="搜索作品名称、作者、标签"
                      onChange={(event) => setStylePickerSearch(event.target.value)}
                    />
                  </label>
                  <button
                    type="button"
                    className="studio-canvas-style-picker__community-link"
                    onClick={() => {
                      setStylePickerImageNodeId(null);
                      onOpenCommunity();
                    }}
                  >
                    打开社区
                  </button>
                  <button
                    type="button"
                    className="studio-canvas-style-picker__close"
                    aria-label="关闭"
                    onClick={() => setStylePickerImageNodeId(null)}
                  >
                    <CloseOutlined />
                  </button>
                </header>
                <div className="studio-canvas-style-picker__subbar">
                  <div className="studio-canvas-style-picker__categories" role="tablist" aria-label="风格分类">
                    {canvasStylePickerCategories.map((category) => (
                      <button
                        key={category}
                        type="button"
                        className={stylePickerCategory === category ? "is-active" : ""}
                        onClick={() => setStylePickerCategory(category)}
                      >
                        {category}
                      </button>
                    ))}
                  </div>
                  <div className="studio-canvas-style-picker__filters" aria-hidden="true">
                    <span>推荐</span>
                  </div>
                </div>
                <main className="studio-canvas-style-picker__body">
                  {stylePickerLoading ? (
                    <div className="studio-canvas-style-picker__grid" aria-busy="true">
                      {Array.from({ length: 18 }).map((_, index) => (
                        <div className="studio-canvas-style-picker__skeleton" key={index} />
                      ))}
                    </div>
                  ) : stylePickerError ? (
                    <div className="studio-canvas-style-picker__empty">
                      <strong>社区风格加载失败</strong>
                      <span>{stylePickerError}</span>
                      <button type="button" onClick={() => setStylePickerReloadToken((token) => token + 1)}>
                        重试
                      </button>
                    </div>
                  ) : stylePickerVisibleCases.length ? (
                    <div className="studio-canvas-style-picker__grid">
                      {stylePickerVisibleCases.map((item) => (
                        <button
                          type="button"
                          key={item.id}
                          className={`studio-canvas-style-picker__card${stylePickerNode?.styleReference?.id === item.id ? " is-selected" : ""}`}
                          onClick={() => handleSelectImageStyle(item)}
                        >
                          <span className="studio-canvas-style-picker__image">
                            <img src={item.imageUrl} alt={item.title} loading="lazy" />
                            <em>{Math.max(item.favoriteCount, item.likeCount, 0)}</em>
                          </span>
                          <strong>{item.title}</strong>
                          <small>{item.author}</small>
                        </button>
                      ))}
                    </div>
                  ) : (
                    <div className="studio-canvas-style-picker__empty">
                      <strong>没有匹配的社区作品</strong>
                      <span>换个分类或关键词再试试。</span>
                    </div>
                  )}
                </main>
              </div>
            </div>
          ) : null}
          {styleSelectionToast ? (
            <div className="studio-canvas-style-toast" role="status">
              {styleSelectionToast}
            </div>
          ) : null}
          {generationToast ? (
            <div className="studio-canvas-generation-toast" role="status">
              {generationToast}
            </div>
          ) : null}
        </section>

      </div>
      {canvasToolModal && (
        <div className="studio-canvas-tool-modal-overlay" onClick={() => setCanvasToolModal(null)}>
          <div className="studio-canvas-tool-modal" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label={canvasToolModal.tool === "multiGrid" ? "多宫格" : canvasToolModal.tool === "upscale" ? "超分" : "局部重绘"}>
            <header className="studio-canvas-tool-modal__header">
              <h3>{canvasToolModal.tool === "multiGrid" ? "多宫格生成" : canvasToolModal.tool === "upscale" ? "图片超分" : "局部重绘"}</h3>
              <button type="button" aria-label="关闭" onClick={() => setCanvasToolModal(null)}><CloseOutlined /></button>
            </header>
            <div className="studio-canvas-tool-modal__body">
              {canvasToolModal.tool === "multiGrid" && (
                <CanvasMultiGridPanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
              )}
              {canvasToolModal.tool === "upscale" && (
                <CanvasUpscalePanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
              )}
              {canvasToolModal.tool === "inpaint" && (
                <CanvasInpaintPanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
              )}
            </div>
          </div>
        </div>
      )}
    </WorkspacePageShell>
  );
}

export default CanvasPage;
