import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { MouseEvent as ReactMouseEvent } from "react"; import { useQuery } from "@tanstack/react-query"; import { useParams, useSearchParams } from "react-router"; import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { uploadMediaFile } from "@/services/file-storage"; import { readLocalRuntimeBootstrapState } from "@/services/local-runtime-bootstrap"; import { createCanvasGenerationLiveProjectAdapter, registerCanvasGenerationLiveProject } from "@/services/canvas-generation-consumer"; import { getActiveUserScope } from "@/lib/user-scope"; import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources"; import copyToClipboard from "copy-to-clipboard"; import { nanoid } from "nanoid"; import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme"; import { persistCanvasMediaPerformanceMode, readCanvasMediaPerformanceMode } from "@/lib/canvas/canvas-performance-mode"; import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary"; import { refreshCanvasCharacterReferenceNodes } from "@/lib/canvas/canvas-character-reference"; import { shouldAutoConnectCanvasRuntime } from "@/lib/canvas/local-runtime-connection"; import { useAssetStore } from "@/stores/use-asset-store"; import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store"; import { ensureCanvasNodeAsset } from "@/services/project-asset-sync"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; import { App } from "antd"; import { getNodeSpec } from "@/constant/canvas"; import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer"; import { CanvasConfigNodePanel } from "@/components/canvas/canvas-config-node-panel"; import { CanvasAssistantPanel } from "@/components/canvas/canvas-assistant-panel"; import { AssistantPanelColumn, getPanelWidthBounds } from "./canvas-assistant-panel-column"; import { CanvasActiveTaskPanel } from "@/components/canvas/canvas-active-task-panel"; import { CanvasAssetTray } from "@/components/canvas/canvas-asset-tray"; import { CanvasProjectSidebar } from "@/components/canvas/canvas-project-sidebar"; import { CanvasProjectAssetModal } from "@/components/canvas/canvas-project-asset-modal"; import { CanvasCharacterReferenceNodeContent } from "@/components/canvas/canvas-character-reference-node"; import { CanvasCharacterReferenceModal } from "@/components/canvas/canvas-character-reference-modal"; import { WorkspaceState } from "@/components/layout/workspace-state"; import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal"; import { createStyleProfileSnapshot, resolveStyleProfile, serializeStyleProfile } from "@/lib/canvas/style-profile"; import { CanvasNodeToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-toolbar"; import { CanvasSubtitleDialog } from "@/components/canvas/canvas-subtitle-dialog"; import { CanvasVideoSegmentDialog } from "@/components/canvas/canvas-video-segment-dialog"; import { CanvasTimelineDialog } from "@/components/canvas/canvas-timeline-dialog"; import { syncNodeSubtitlesToTimeline } from "@/lib/timeline/timeline-build"; import type { TimelineDirectMedia } from "@/types/timeline"; import { CanvasNodeAnglePanel } from "@/components/canvas/canvas-node-angle-dialog"; import { CanvasTextEditorModal } from "@/components/canvas/canvas-text-editor-modal"; import { CanvasNodeSearchModal } from "@/components/canvas/canvas-node-search-modal"; import { CanvasStylePickerModal } from "@/components/canvas/canvas-style-picker-modal"; import { CanvasFileDropOverlay } from "@/components/canvas/canvas-file-drop-overlay"; import { CanvasUploadModal } from "@/components/canvas/canvas-upload-modal"; import { InfiniteCanvas } from "@/components/canvas/infinite-canvas"; import { Minimap } from "@/components/canvas/canvas-mini-map"; import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; import { CanvasToolbar } from "@/components/canvas/canvas-toolbar"; import { AssetPickerModal, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal"; import { getProject } from "@/services/api/projects"; import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls"; import { CanvasShareModal } from "@/components/canvas/canvas-share-modal"; import { CanvasScriptEditor, CanvasScriptNodeContent, STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardMinNodeHeight, storyboardTableHeight } from "@/components/canvas/canvas-script-node"; import { CanvasDirectorNodePanel } from "@/components/canvas/director/canvas-director-node-panel"; import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal"; import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel"; import { useFocusMode } from "@/hooks/use-focus-mode"; import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays"; import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer"; import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry"; import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry"; import { createCanvasNode, getInputSummary, isHiddenBatchChild, persistCanvasWorkspaceMode, readCanvasWorkspaceMode } from "@/lib/canvas/canvas-project-domain"; import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff"; import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection"; import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress"; import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback"; import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation"; import { CanvasTopBar } from "./canvas-project-top-bar"; import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar"; import { CanvasProjectContextMenu } from "./canvas-project-context-menu"; import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs"; import { CanvasProjectSelectionToolbar } from "./canvas-project-selection-toolbar"; import { CanvasProjectStatusDialogs } from "./canvas-project-status-dialogs"; import { CanvasProjectWorldLayers } from "./canvas-project-world-layers"; import { CanvasRefreshShell } from "./canvas-refresh-shell"; import type { CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel"; import { CanvasEmotionWorkspace } from "@/components/canvas/canvas-emotion-workspace"; import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage"; import { useCanvasConnectionController } from "./use-canvas-connection-controller"; import { useCanvasAgentOperations } from "./use-canvas-agent-operations"; import { useCanvasAssistantVisibility } from "./use-canvas-assistant-visibility"; import { useCanvasActiveTasks } from "./use-canvas-active-tasks"; import { useCanvasStyleWorkflow } from "./use-canvas-style-workflow"; import { useCanvasDirector } from "./use-canvas-director"; import { useCanvasGeneration } from "./use-canvas-generation"; import { useCanvasGenerationBatches } from "./use-canvas-generation-batches"; import { useCanvasGenerationExecutor, type CanvasNodeGenerationOptions } from "./use-canvas-generation-executor"; import { useCanvasGenerationRetry } from "./use-canvas-generation-retry"; import { useCanvasHistory } from "./use-canvas-history"; import { useCanvasKeyboard } from "./use-canvas-keyboard"; import { useCanvasMediaTools } from "./use-canvas-media-tools"; import { useCanvasNodeEditor } from "./use-canvas-node-editor"; import { useCanvasNodeOperations } from "./use-canvas-node-operations"; import { useCanvasProjectLifecycle } from "./use-canvas-project-lifecycle"; import { useCanvasRenderModel } from "./use-canvas-render-model"; import { useCanvasSelectionController } from "./use-canvas-selection-controller"; import { useCanvasShortDrama } from "./use-canvas-short-drama"; import { useCanvasStoryboard } from "./use-canvas-storyboard"; import { useCanvasUpload } from "./use-canvas-upload"; import { useCanvasViewportController } from "./use-canvas-viewport-controller"; import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasMediaPerformanceMode, type StoryboardColumn, type StoryboardShotCount, type StoryboardShotDuration, type CanvasWorkflowKind, type CanvasWorkspaceMode, type CanvasToolMode, type ContextMenuState, type Position, type ViewportTransform, } from "@/types/canvas"; import type { ReferenceImage } from "@/types/image"; const CanvasDirectorWorkbench = lazy(() => import("@/components/canvas/director/canvas-director-workbench").then((module) => ({ default: module.CanvasDirectorWorkbench }))); const CanvasDrawingEditorModal = lazy(() => import("@/components/canvas/canvas-drawing-editor-modal").then((module) => ({ default: module.CanvasDrawingEditorModal }))); const NODE_STATUS_SUCCESS = "success" as const; const EMPTY_RESOURCE_REFERENCES: CanvasResourceReference[] = []; function visibleGenerationBatch(node: CanvasNodeData) { const batches = node.metadata?.generationBatches || []; for (let index = batches.length - 1; index >= 0; index -= 1) { if (batches[index].status === "queued" || batches[index].status === "running") return batches[index]; } return batches.at(-1); } export default function CanvasPage() { const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); if (!mounted) return ; return ; } function InfiniteCanvasPage() { const { message } = App.useApp(); const params = useParams<{ id: string }>(); const [searchParams, setSearchParams] = useSearchParams(); const projectId = params.id || ""; const canvasStorageScope = getActiveUserScope(); const localAgentConnected = useCanvasAgentStore((state) => state.connected); const localAgentActivity = useCanvasAgentStore((state) => state.activity); const localAgentEnabled = useCanvasAgentStore((state) => state.enabled); const containerRef = useRef(null); const didInitialCenterRef = useRef(false); const toolbarHideTimerRef = useRef | null>(null); const assetHandoffRef = useRef(""); const config = useConfigStore((state) => state.config); const effectiveConfig = useEffectiveConfig(); const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); const assets = useAssetStore((state) => state.assets); const assetsHydrated = useAssetStore((state) => state.hydrated); const cleanupAssetImages = useAssetStore((state) => state.cleanupImages); const theme = canvasThemes[useThemeStore((state) => state.theme)]; const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine); const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled); const [nodes, setNodes] = useState([]); const [connections, setConnections] = useState([]); const [chatSessions, setChatSessions] = useState([]); const [activeChatId, setActiveChatId] = useState(null); const [viewport, setViewport] = useState({ x: 0, y: 0, k: 1 }); const [size, setSize] = useState({ width: 1200, height: 720 }); const [selectedNodeIds, setSelectedNodeIds] = useState>(new Set()); const [selectedConnectionId, setSelectedConnectionId] = useState(null); const [hoveredNodeId, setHoveredNodeId] = useState(null); const [contextMenu, setContextMenu] = useState(null); const [isMiniMapOpen, setIsMiniMapOpen] = useState(false); const [backgroundMode, setBackgroundMode] = useState("dots"); const [showImageInfo, setShowImageInfo] = useState(false); const [canvasTool, setCanvasTool] = useState("move"); const [mediaPerformanceMode, setMediaPerformanceMode] = useState(readCanvasMediaPerformanceMode); const [projectLoaded, setProjectLoaded] = useState(false); const [workspaceMode, setWorkspaceMode] = useState(readCanvasWorkspaceMode); const [clearConfirmOpen, setClearConfirmOpen] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false); const [nodeSearchOpen, setNodeSearchOpen] = useState(false); const [toolbarNodeId, setToolbarNodeId] = useState(null); const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false); const [dialogNodeId, setDialogNodeId] = useState(null); const [textEditorNodeId, setTextEditorNodeId] = useState(null); const [characterReferenceNodeId, setCharacterReferenceNodeId] = useState(null); const [drawingNodeId, setDrawingNodeId] = useState(null); const [stylePickerOpen, setStylePickerOpen] = useState(false); const [projectAssetOpen, setProjectAssetOpen] = useState(false); const [projectAssetInitialCategory, setProjectAssetInitialCategory] = useState("all"); const [projectAssetInitialFolderId, setProjectAssetInitialFolderId] = useState("all"); const [projectAssetInsertPosition, setProjectAssetInsertPosition] = useState(); const [infoNodeId, setInfoNodeId] = useState(null); const [subtitleNodeId, setSubtitleNodeId] = useState(null); const [timelineNodeId, setTimelineNodeId] = useState(null); const [superResolveNodeId, setSuperResolveNodeId] = useState(null); const [previewNodeId, setPreviewNodeId] = useState(null); const [scriptEditorNodeId, setScriptEditorNodeId] = useState(null); const [scriptScrollTopById, setScriptScrollTopById] = useState>({}); const [directorNodeId, setDirectorNodeId] = useState(null); const [versionCompareRootId, setVersionCompareRootId] = useState(null); const codexAutoConnect = shouldAutoConnectCanvasRuntime(searchParams); const codexCompactAgent = codexAutoConnect && readLocalRuntimeBootstrapState().legacyDeepLinkRejected; const [titleEditing, setTitleEditing] = useState(false); const [titleDraft, setTitleDraft] = useState(""); const [shortcutRequestNonce, setShortcutRequestNonce] = useState(0); const [cinematicAgentEntry, setCinematicAgentEntry] = useState(false); // 面板初始宽度根据视口宽度动态选择,避免小屏幕上初始就过宽 const [assistantWidth, setAssistantWidth] = useState(() => { const vw = typeof window !== "undefined" ? window.innerWidth : 1280; if (vw < 768) return 300; if (vw < 1024) return 360; if (vw < 1440) return 440; return 520; }); // 窗口跨越断点时把面板宽度 clamp 到当前断点的合理区间,避免宽屏值在窄屏挤压画布 useEffect(() => { const clamp = () => { const { min, max } = getPanelWidthBounds(); setAssistantWidth((prev) => (prev < min ? min : prev > max ? max : prev)); }; window.addEventListener("resize", clamp); return () => window.removeEventListener("resize", clamp); }, []); const { agentMode, assistantClosing, assistantMounted, assistantOpen, closeAgent, openAgent, setAgentMode } = useCanvasAssistantVisibility(); const { tasks: activeTasks } = useCanvasActiveTasks(projectId, projectLoaded); const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode(); const [focusDockRevealed, setFocusDockRevealed] = useState(false); useEffect(() => { persistCanvasWorkspaceMode(workspaceMode); }, [workspaceMode]); useEffect(() => { persistCanvasMediaPerformanceMode(mediaPerformanceMode); }, [mediaPerformanceMode]); useEffect(() => { didInitialCenterRef.current = false; }, [projectId]); useEffect(() => { const openSearch = (event: KeyboardEvent) => { if (!(event.metaKey || event.ctrlKey) || event.key.toLocaleLowerCase() !== "k") return; const target = event.target; if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || (target instanceof HTMLElement && target.isContentEditable)) return; event.preventDefault(); setNodeSearchOpen(true); }; window.addEventListener("keydown", openSearch); return () => window.removeEventListener("keydown", openSearch); }, []); const nodesRef = useRef(nodes); const connectionsRef = useRef(connections); const chatSessionsRef = useRef(chatSessions); const activeChatIdRef = useRef(activeChatId); const selectedNodeIdsRef = useRef(selectedNodeIds); const viewportRef = useRef(viewport); const generateNodeRef = useRef<((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasNodeGenerationOptions) => Promise) | null>(null); useEffect(() => { if (!projectId) return; return registerCanvasGenerationLiveProject({ scope: canvasStorageScope, projectId, adapter: createCanvasGenerationLiveProjectAdapter({ nodesRef, connectionsRef, chatSessionsRef, activeChatIdRef, setNodes, setConnections, setChatSessions, setActiveChatId }), }); }, [canvasStorageScope, projectId]); const { getHistoryCleanupContext, historyPausedRef, historyState, redoCanvas, resetHistory, undoCanvas } = useCanvasHistory({ projectLoaded, nodes, connections, chatSessions, activeChatId, backgroundMode, showImageInfo, setNodes, setConnections, setChatSessions, setActiveChatId, setBackgroundMode, setShowImageInfo, setSelectedNodeIds, setSelectedConnectionId, setContextMenu, }); const cleanupCanvasFiles = useCallback( (extra?: unknown) => { cleanupAssetImages({ extra, ...getHistoryCleanupContext() }); }, [cleanupAssetImages, getHistoryCleanupContext], ); const { addedSkills, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, saveCanvasProject, updateProject } = useCanvasProjectLifecycle({ projectId, projectLoaded, nodes, connections, chatSessions, activeChatId, backgroundMode, showImageInfo, viewport, nodesRef, connectionsRef, viewportRef, historyPausedRef, setNodes, setConnections, setChatSessions, setActiveChatId, setBackgroundMode, setShowImageInfo, setViewport, setProjectLoaded, resetHistory, cleanupAssetImages, cleanupCanvasFiles, }); const linkedProjectId = shortDramaEnabled ? currentProject?.projectId || "" : ""; const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) }); const refetchLinkedProject = linkedProjectQuery.refetch; const archiveNodesToLinkedFolder = useCallback((folder: CanvasNodeData, droppedNodes: CanvasNodeData[]) => { const folderId = folder.metadata?.folder?.assetFolderId; const domainProjectId = folder.metadata?.folder?.projectId || linkedProjectId; if (!folderId || !domainProjectId || !droppedNodes.length) return; void Promise.all(droppedNodes.map((node) => ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, folderId, node, source: "canvas-manual" }))) .then((results) => { const archivedByNodeId = new Map(droppedNodes.map((node, index) => [node.id, { assetId: results[index].assetId, content: node.metadata?.content, previousAssetId: node.metadata?.assetId }])); setNodes((current) => current.map((node) => { const archived = archivedByNodeId.get(node.id); if (!archived || node.metadata?.content !== archived.content || node.metadata?.assetId !== archived.previousAssetId) return node; return { ...node, metadata: { ...node.metadata, assetId: archived.assetId } }; })); void refetchLinkedProject(); message.success(`已归档到“${folder.title}”`); }) .catch((error) => message.error(error instanceof Error ? error.message : "素材归档失败")); }, [linkedProjectId, message, projectId, refetchLinkedProject, setNodes]); useEffect(() => { if (!projectLoaded || !linkedProjectQuery.data) return; setNodes((current) => refreshCanvasCharacterReferenceNodes(current, linkedProjectQuery.data.assets)); }, [linkedProjectQuery.data, projectLoaded, setNodes]); const canvasContext = useMemo(() => summarizeCanvasContext(nodes, selectedNodeIds, linkedProjectQuery.data?.units), [linkedProjectQuery.data?.units, nodes, selectedNodeIds]); const { applyGenerationTaskResult, bindGenerationTask, cancelNodeTask, confirmStopGeneration, finishGenerationRequest, openNodeTaskDetails, runningNodeId, setRunningNodeId, setTaskDetail, startGenerationRequest, taskDetail, taskDetailLoading, taskDetailLogs, } = useCanvasGeneration({ projectId, domainProjectId: linkedProjectId, projectLoaded, nodes, nodesRef, setNodes }); useEffect(() => { if (!projectLoaded || !codexAutoConnect) return; if (codexCompactAgent) { setAgentMode("local"); return; } openAgent("local"); }, [codexAutoConnect, codexCompactAgent, openAgent, projectLoaded, setAgentMode]); // 沉浸专注进入时收起智能体与小地图、重置 Dock 唤出态;仅响应「进入」瞬间,避免关闭专注内主动唤出的面板。 const prevFocusModeRef = useRef(focusMode); useEffect(() => { const enteredFocus = focusMode && !prevFocusModeRef.current; prevFocusModeRef.current = focusMode; if (!enteredFocus) return; closeAgent(); setIsMiniMapOpen(false); setFocusDockRevealed(false); }, [closeAgent, focusMode]); useEffect(() => { if (!dialogNodeId) setNodeImageSettingsOpen(false); }, [dialogNodeId]); useLayoutEffect(() => { nodesRef.current = nodes; connectionsRef.current = connections; chatSessionsRef.current = chatSessions; activeChatIdRef.current = activeChatId; selectedNodeIdsRef.current = selectedNodeIds; viewportRef.current = viewport; }, [activeChatId, chatSessions, nodes, connections, selectedNodeIds, viewport]); useEffect(() => { if (!projectLoaded) return; const el = containerRef.current; if (!el) return; const updateSize = () => { const rect = el.getBoundingClientRect(); setSize((current) => (current.width === rect.width && current.height === rect.height ? current : { width: rect.width, height: rect.height })); if (!didInitialCenterRef.current) { didInitialCenterRef.current = true; const current = viewportRef.current; if (current.x === 0 && current.y === 0 && current.k === 1) { const centered = { x: rect.width / 2, y: rect.height / 2, k: 1 }; viewportRef.current = centered; setViewport(centered); } } }; updateSize(); const resizeObserver = new ResizeObserver(updateSize); resizeObserver.observe(el); return () => resizeObserver.disconnect(); }, [projectLoaded]); const { fitCanvasContent, fitCanvasSelection, focusCanvasImageNode, focusCanvasNode, getCanvasCenter, handleCanvasDoubleClick, handleViewportChange, handleViewportPreviewChange, previewViewport, resetViewport, screenToCanvas, setZoomScale, zoomCanvasIn, zoomCanvasOut, zoomToActualSize, } = useCanvasViewportController({ containerRef, size, viewportRef, nodesRef, selectedNodeIdsRef, setViewport, setSelectedNodeIds, setSelectedConnectionId, setContextMenu, setDialogNodeId, setToolbarNodeId, }); useEffect(() => { const project = linkedProjectQuery.data?.project; const preset = resolveProjectCanvasStyle(project?.stylePresetId, project?.styleProfileJson); if (!projectLoaded || !preset) return; const profile = resolveStyleProfile(project?.stylePresetId, project?.styleProfileJson, preset.profile || createStyleProfileSnapshot(preset)); if (!profile) return; const current = nodesRef.current.find((node) => node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "styleboard"); const nextMetadata = { content: profile.prompt, prompt: profile.prompt, status: NODE_STATUS_SUCCESS, workflowKind: "styleboard" as const, workflowTitle: "项目画风", workflowDescription: profile.description, stylePresetId: profile.presetId, styleProfileJson: serializeStyleProfile(profile), fontSize: 14, locked: true, }; if (current) { if (current.metadata?.stylePresetId === profile.presetId && current.metadata?.content === profile.prompt && current.metadata?.styleProfileJson === nextMetadata.styleProfileJson && current.metadata?.locked) return; setNodes((nodes) => nodes.map((node) => (node.id === current.id ? { ...node, title: `项目画风 · ${profile.title}`, metadata: { ...node.metadata, ...nextMetadata } } : node))); return; } const node = createCanvasNode(CanvasNodeType.Text, getCanvasCenter(), nextMetadata); node.title = `项目画风 · ${profile.title}`; node.width = 420; node.height = 240; setNodes((nodes) => [...nodes, node]); }, [getCanvasCenter, linkedProjectQuery.data?.project, projectLoaded, setNodes]); const { assetPickerOpen, closeUploadModal, closeAssetPicker, createVideoNodeFromBlob, createImageAssetNode, fileDropActive, handleAssetInsert, handleDrop, handleFileDragEnter, handleFileDragLeave, handleFileDragOver, handleImageInputChange, handleProjectAssetsInsert, handleProjectChapterInsert, handleUploadFiles, handleUploadRequest, imageInputRef, openAssetsAtPosition, pasteAssistantImage, pasteSystemClipboard, startUploadStatus, uploadModalOpen, uploadTimelineMedia, uploadStatus, } = useCanvasUpload({ canvasId: projectId, domainProjectId: linkedProjectId, nodesRef, selectedNodeIdsRef, getCanvasCenter, screenToCanvas, setNodes, setSelectedNodeIds, setSelectedConnectionId, setContextMenu, setDialogNodeId, }); useEffect(() => { if (!projectLoaded || !assetsHydrated || searchParams.get("mode") !== "handoff") return; const attempt = canvasAssetHandoffAttempt(assets, searchParams); const { assetIds, payloads } = attempt; if (!assetIds.length) return; const assetReadiness = assetIds .map((assetId) => { const asset = assets.find((candidate) => candidate.id === assetId); return `${assetId}:${asset?.kind || "missing"}`; }) .join("|"); const handoffKey = `${projectId}:${assetReadiness}`; if (assetHandoffRef.current === handoffKey) return; assetHandoffRef.current = handoffKey; if (attempt.kind === "retry") return; const pendingPayloads = uninsertedCanvasAssetHandoffPayloads(nodesRef.current, payloads); const persistHandoff = async (createdNodes: CanvasNodeData[]) => { const finalized = await finalizeCanvasAssetHandoff({ searchParams, currentNodes: nodesRef.current, createdNodes, persist: async (nextNodes) => { nodesRef.current = nextNodes; updateProject(projectId, { nodes: nextNodes }); await flushCanvasStorePersistence(); }, }); setSearchParams(finalized.searchParams, { replace: true }); }; const insertion = pendingPayloads.length ? handleProjectAssetsInsert(pendingPayloads) : Promise.resolve([] as CanvasNodeData[]); void insertion.then(persistHandoff).catch(() => { assetHandoffRef.current = ""; }); }, [assets, assetsHydrated, handleProjectAssetsInsert, message, nodesRef, projectId, projectLoaded, searchParams, setSearchParams, updateProject]); // 时间线弹窗内新增素材的回填通道:素材库/上传创建节点后由弹窗通过 ref 加入草稿。 const timelineAddNodeRef = useRef<((node: CanvasNodeData) => void) | null>(null); // 时间线作用域直连媒体入轨通道:素材库/项目资产/本地上传不落画布,仅加入时间线草稿。 const timelineMediaAddRef = useRef<((media: TimelineDirectMedia) => void) | null>(null); // 素材库与项目资产弹窗的插入作用域:时间线弹窗内打开时为 timeline,其余为 canvas。 const [assetInsertScope, setAssetInsertScope] = useState<"canvas" | "timeline">("canvas"); const [projectAssetScope, setProjectAssetScope] = useState<"canvas" | "timeline">("canvas"); // InsertAssetPayload → 直连媒体:仅音视频支持直接入轨;图片/文本/角色返回 null(避免在画布重复出现)。 const payloadToTimelineMedia = (payload: InsertAssetPayload): TimelineDirectMedia | null => { const randomSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; if (payload.kind === "video") { return { id: payload.assetId || `asset-${randomSuffix}`, kind: "video", title: payload.title, storageKey: payload.storageKey, url: payload.url, width: payload.width, height: payload.height, durationMs: payload.durationMs, bytes: payload.bytes, mimeType: payload.mimeType, }; } if (payload.kind === "audio") { return { id: payload.assetId || `asset-${randomSuffix}`, kind: "audio", title: payload.title, storageKey: payload.storageKey, url: payload.url, durationMs: payload.durationMs, bytes: payload.bytes, mimeType: payload.mimeType }; } return null; }; const handleTimelineAssetInsert = useCallback( async (payload: InsertAssetPayload) => { if (assetInsertScope === "timeline") { const media = payloadToTimelineMedia(payload); if (media) { timelineMediaAddRef.current?.(media); closeAssetPicker(); } else { message.info("图片/文本素材暂不支持直接入轨,请先在画布中添加节点"); } return; } const node = await handleAssetInsert(payload, { openDialog: false }); if (node) timelineAddNodeRef.current?.(node); }, [assetInsertScope, closeAssetPicker, handleAssetInsert], ); // 项目资产库引入到时间线:复用现有引入逻辑,把创建出的节点回填到弹窗草稿。 const handleTimelineProjectAssetsInsert = useCallback( async (payloads: InsertAssetPayload[]) => { if (projectAssetScope === "timeline") { let inserted = 0; for (const payload of payloads) { const media = payloadToTimelineMedia(payload); if (media) { timelineMediaAddRef.current?.(media); inserted += 1; } } if (inserted < payloads.length) message.info("图片/文本/角色素材暂不支持直接入轨,仅音视频素材已加入时间线"); return; } const created = await handleProjectAssetsInsert(payloads, projectAssetInsertPosition); created.forEach((node) => timelineAddNodeRef.current?.(node)); }, [handleProjectAssetsInsert, message, projectAssetInsertPosition, projectAssetScope], ); const openProjectAssets = useCallback( (initialCategory = "all", position?: Position, scope: "canvas" | "timeline" = "canvas", initialFolderId = "all") => { setProjectAssetScope(scope); setProjectAssetInitialCategory(initialCategory); setProjectAssetInitialFolderId(initialFolderId); setProjectAssetInsertPosition(position); setProjectAssetOpen(true); // 资产与项目实时同步:打开弹窗前刷新关联短剧项目资产,避免缓存导致资产列表空白/过期。 if (linkedProjectId) void refetchLinkedProject(); }, [linkedProjectId, refetchLinkedProject], ); // 素材库打开入口:画布作用域(工具栏/空态/侧栏)与时间线作用域(时间线弹窗)分别标记插入目标。 const openCanvasAssetLibrary = useCallback( (position?: Position) => { setAssetInsertScope("canvas"); openAssetsAtPosition(position); }, [openAssetsAtPosition], ); const openTimelineAssetLibrary = useCallback(() => { setAssetInsertScope("timeline"); openAssetsAtPosition(); }, [openAssetsAtPosition]); const closeProjectAssets = useCallback(() => { setProjectAssetOpen(false); setProjectAssetInsertPosition(undefined); setProjectAssetInitialFolderId("all"); }, []); const { angleNodeId, emotionNodeId, annotationNodeId, createImageReversePromptNodes, generatePortraitTextureNode, cropImageNode, cropNodeId, closeSegmentDialog, extractAudioFromVideo, extractVideoLastFrame, extractingVideoFrameNodeId, generateAngleNode, generateEmotionNode, handleSegmentConfirm, maskEditImageNode, maskEditNodeId, mergeSelectedVideos, mergeVideosByIds, mergeVideoProgress, saveAnnotatedImageNode, segmentDialogMode, segmentDialogNodeId, segmentRunningMode, setSegmentDialogNodeId, setAngleNodeId, setEmotionNodeId, setAnnotationNodeId, setCropNodeId, setMaskEditNodeId, setSplitNodeId, setUpscaleNodeId, splitImageNode, splitNodeId, trimVideoAndRegenerate, upscaleImageNode, upscaleNodeId, } = useCanvasMediaTools({ projectId, domainProjectId: linkedProjectId, nodesRef, connectionsRef, selectedNodeIdsRef, setNodes, setConnections, setSelectedNodeIds, setSelectedConnectionId, setDialogNodeId, setContextMenu, setHoveredNodeId, setToolbarNodeId, setRunningNodeId, startUploadStatus, startGenerationRequest, finishGenerationRequest, bindGenerationTask, onGenerateVideoNode: (nodeId, mode, prompt) => generateNodeRef.current?.(nodeId, mode, prompt), }); const handleNodesDeleted = useCallback( (removedIds: Set, nextNodes: CanvasNodeData[], removedNodes: CanvasNodeData[]) => { const clearDeletedId = (current: string | null) => (current && removedIds.has(current) ? null : current); setHoveredNodeId(clearDeletedId); setToolbarNodeId(clearDeletedId); setDialogNodeId(clearDeletedId); setTextEditorNodeId(clearDeletedId); setCharacterReferenceNodeId(clearDeletedId); setDrawingNodeId(clearDeletedId); setInfoNodeId(clearDeletedId); setSubtitleNodeId(clearDeletedId); setSegmentDialogNodeId(clearDeletedId); setCropNodeId(clearDeletedId); setMaskEditNodeId(clearDeletedId); setAnnotationNodeId(clearDeletedId); setSplitNodeId(clearDeletedId); setUpscaleNodeId(clearDeletedId); setAngleNodeId(clearDeletedId); setEmotionNodeId(clearDeletedId); setSuperResolveNodeId(clearDeletedId); setPreviewNodeId(clearDeletedId); setRunningNodeId(clearDeletedId); setScriptEditorNodeId(clearDeletedId); setDirectorNodeId(clearDeletedId); setVersionCompareRootId(clearDeletedId); setScriptScrollTopById((current) => Object.fromEntries(Object.entries(current).filter(([id]) => !removedIds.has(id)))); setContextMenu((current) => (current?.type === "node" && removedIds.has(current.nodeId) ? null : current)); const removedDrawingIds = removedNodes.flatMap((node) => (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId ? [node.metadata.drawingId] : [])); if (removedDrawingIds.length) { void Promise.all(removedDrawingIds.map((drawingId) => removeCanvasDrawing(projectId, drawingId))).catch(() => message.warning("绘图节点已删除,但本地绘图缓存清理失败")); } cleanupCanvasFiles({ projectId, nodes: nextNodes, chatSessions }); }, [chatSessions, cleanupCanvasFiles, message, projectId, setAngleNodeId, setAnnotationNodeId, setCropNodeId, setEmotionNodeId, setMaskEditNodeId, setSegmentDialogNodeId, setSplitNodeId, setUpscaleNodeId, setRunningNodeId], ); const { alignSelectedNodes, arrangeSelectedNodes, copyNodesToClipboard, copySelectedNodes, createFolder, createNode, createReferenceGroup, createStoryboardGroup, deleteConnection, deleteNodes, duplicateNode, hasCopiedNodes, pasteCopiedNodes, restoreCopiedNodesFromText, releaseCopiedNodesPastePriority, setPrimaryVersion, shouldPreferCopiedNodes, toggleNodeLocked, } = useCanvasNodeOperations({ projectId, defaultDrawingEngine, nodesRef, connectionsRef, selectedNodeIdsRef, getCanvasCenter, setNodes, setConnections, setSelectedNodeIds, setSelectedConnectionId, setContextMenu, setDialogNodeId, onNodesDeleted: handleNodesDeleted, }); const { cancelPendingConnectionCreate, closeConnectionCreateMenu, connectionTargetAnchorRatio, connectionTargetNodeId, connectingParams, createConnectedNode, getConnectionCreateDisabledReason, handleConnectStart, handleBatchConnectionTargetClick, batchConnectionPreview, beginBatchConnectionMode, startBatchConnection, mouseWorld, pendingConnectionCreate, setConnecting, } = useCanvasConnectionController({ projectId, config: effectiveConfig, defaultDrawingEngine, nodesRef, connectionsRef, viewportRef, scriptScrollTopById, screenToCanvas, setNodes, setConnections, setSelectedNodeIds, setSelectedConnectionId, setContextMenu, setDialogNodeId, setDrawingNodeId, }); const batchSourceNodeIds = useMemo(() => nodes .filter((node) => selectedNodeIds.has(node.id) && !batchSourceRestriction(node)) .map((node) => node.id), [nodes, selectedNodeIds]); const handleCanvasSelectionStart = useCallback(() => { setContextMenu(null); setDialogNodeId(null); }, []); const handleNodeInteractionStart = useCallback((selectionModifier: boolean) => { setContextMenu(null); setHoveredNodeId(null); setToolbarNodeId(null); if (selectionModifier) setDialogNodeId(null); }, []); const handleSelectedNodeClick = useCallback((node: CanvasNodeData) => { if (node.type === CanvasNodeType.Drawing) { setDialogNodeId(null); setDrawingNodeId(node.id); } else if (node.type === CanvasNodeType.Script) { setDialogNodeId(null); } else if (node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Frame) { setDialogNodeId((current) => (current === node.id ? current : null)); } else { setDialogNodeId(node.id); } }, []); const handleCanvasDeselect = useCallback(() => { setContextMenu(null); setHoveredNodeId(null); setToolbarNodeId(null); setDialogNodeId(null); }, []); const { alignmentGuides, cancelSelectionBox, deselectCanvas, dragPreview, frameDropTargetId, handleCanvasMouseDown, handleNodeMouseDown, isNodeDragging, nodeDraggingRef, selectionBoundsElementRef, selectionBox } = useCanvasSelectionController({ containerRef, nodesRef, viewportRef, selectedNodeIdsRef, historyPausedRef, screenToCanvas, setNodes, setSelectedNodeIds, setSelectedConnectionId, cancelPendingConnectionCreate, onCanvasSelectionStart: handleCanvasSelectionStart, onNodeInteractionStart: handleNodeInteractionStart, onNodeClick: handleSelectedNodeClick, onBatchConnectionTarget: handleBatchConnectionTargetClick, onLinkedFolderDrop: archiveNodesToLinkedFolder, onDeselect: handleCanvasDeselect, onSelectionBoxEnd: () => setCanvasTool((tool) => (tool === "box-select" ? "move" : tool)), }); const keepNodeToolbar = useCallback( (nodeId: string) => { if (nodeDraggingRef.current || nodeImageSettingsOpen) return; if (toolbarHideTimerRef.current) { clearTimeout(toolbarHideTimerRef.current); toolbarHideTimerRef.current = null; } setToolbarNodeId(nodeId); }, [nodeImageSettingsOpen], ); const hideNodeToolbar = useCallback(() => { if (toolbarHideTimerRef.current) clearTimeout(toolbarHideTimerRef.current); toolbarHideTimerRef.current = setTimeout(() => { setToolbarNodeId(null); toolbarHideTimerRef.current = null; }, 120); }, []); const { collapsingBatchIds, downloadNodeImage, handleConfigNodeChange, handleFolderStyleChange, handleFolderThemeChange, handleFontSizeChange, handleNodeContentChange, handleNodePromptChange, handleNodeResize, handleNodeTitleChange, openingBatchIds, saveNodeAsset, setBatchPrimary, toggleBatchExpanded, toggleFrameCollapsed, toggleNodeFreeResize, } = useCanvasNodeEditor({ canvasId: projectId, domainProjectId: linkedProjectId, nodesRef, setNodes, setSelectedNodeIds, setSelectedConnectionId, setDialogNodeId, setToolbarNodeId, setHoveredNodeId, }); const handleProjectFolderInsert = useCallback((folderId: string) => { const folder = linkedProjectQuery.data?.assetFolders.find((item) => item.id === folderId); if (!folder || !linkedProjectId) throw new Error("素材文件夹已不存在,请刷新后重试"); const style: CanvasFolderStyle = folder.style === "stacked" || folder.style === "midnight" || folder.style === "paper" || folder.style === "cinema" || folder.style === "compact" ? folder.style : "glass"; const theme: CanvasFolderTheme = folder.theme === "obsidian" || folder.theme === "ember" || folder.theme === "pearl" ? folder.theme : "aurora"; createFolder(projectAssetInsertPosition, { id: folder.id, projectId: linkedProjectId, title: folder.name, style, theme, createdAt: folder.createdAt }); }, [createFolder, linkedProjectId, linkedProjectQuery.data?.assetFolders, projectAssetInsertPosition]); const handleFrameToggle = useCallback((nodeId: string) => { const node = nodesRef.current.find((item) => item.id === nodeId); const linkedFolderId = node?.metadata?.folder?.assetFolderId; if (linkedFolderId) { openProjectAssets("all", node ? { x: node.position.x + node.width + 40, y: node.position.y } : undefined, "canvas", linkedFolderId); return; } toggleFrameCollapsed(nodeId); }, [nodesRef, openProjectAssets, toggleFrameCollapsed]); const linkedFolderPreviewNodesById = useMemo(() => { const result = new Map(); const localById = new Map(assets.map((asset) => [asset.id, asset])); for (const asset of linkedProjectQuery.data?.assets || []) { if (!asset.folderId) continue; const local = localById.get(asset.id); const characterCover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0]; const type = asset.category === "character" || asset.mediaType === "image" ? CanvasNodeType.Image : asset.mediaType === "video" ? CanvasNodeType.Video : asset.mediaType === "audio" ? CanvasNodeType.Audio : CanvasNodeType.Text; const remoteResourceId = resourceIdFromStorageKey(asset.storageKey); const content = characterCover ? resourceFileUrl(characterCover.resourceId) : local?.kind === "image" ? local.data.dataUrl || local.coverUrl : local?.kind === "video" || local?.kind === "audio" ? local.data.url : local?.kind === "text" ? local.data.content : remoteResourceId ? resourceFileUrl(remoteResourceId) : asset.previewText || ""; const preview: CanvasNodeData = { id: asset.id, type, title: asset.title, position: { x: 0, y: 0 }, width: 240, height: 160, metadata: { assetId: asset.id, content } }; const current = result.get(asset.folderId) || []; current.push(preview); result.set(asset.folderId, current); } return result; }, [assets, linkedProjectQuery.data?.assets]); useEffect(() => { const folders = linkedProjectQuery.data?.assetFolders; if (!folders?.length) return; const byId = new Map(folders.map((folder) => [folder.id, folder])); setNodes((current) => { let changed = false; const next = current.map((node) => { const folderId = node.metadata?.folder?.assetFolderId; const folder = folderId ? byId.get(folderId) : undefined; if (!folder) return node; const style: CanvasFolderStyle = folder.style === "stacked" || folder.style === "midnight" || folder.style === "paper" || folder.style === "cinema" || folder.style === "compact" ? folder.style : "glass"; const theme: CanvasFolderTheme = folder.theme === "obsidian" || folder.theme === "ember" || folder.theme === "pearl" ? folder.theme : "aurora"; if (node.title === folder.name && node.metadata?.folder?.style === style && node.metadata?.folder?.theme === theme) return node; changed = true; return { ...node, title: folder.name, metadata: { ...node.metadata, folder: { ...node.metadata!.folder!, style, theme, themeCover: undefined } } }; }); return changed ? next : current; }); }, [linkedProjectQuery.data?.assetFolders, setNodes]); const { activeDirectorScene, activeNodeId, activeScriptNode, activeStylePresetId, angleNode, emotionNode, annotationNode, batchChildCountById, batchMotionById, canvasImageNodes, configInputsById, connectionLayerBounds, contextMenuNode, cropNode, displayConnections, frameChildrenById, imageAssets, infoNode, maskEditNode, mentionReferencesByNodeId, nodeById, previewNode, reduceMediaEffects, relatedHighlight, resourceReferenceByNodeId, selectedNodeBounds, selectedVideoNodes, skillMentionReferences, splitNode, superResolveNode, toolbarNode, upscaleNode, versionCompareNodes, visibleNodes, } = useCanvasRenderModel({ nodes, connections, assets, viewport, viewportSize: size, mediaPerformanceMode, selectedNodeIds, hoveredNodeId, dragPreview, collapsingBatchIds, addedSkills, directorScenes: currentProject?.directorScenes, infoNodeId, cropNodeId, maskEditNodeId, annotationNodeId, splitNodeId, upscaleNodeId, superResolveNodeId, angleNodeId, emotionNodeId, previewNodeId, contextMenu, versionCompareRootId, directorNodeId, scriptEditorNodeId, dialogNodeId, }); const dialogNode = dialogNodeId ? nodeById.get(dialogNodeId) || null : null; const subtitleNode = subtitleNodeId ? nodeById.get(subtitleNodeId) || null : null; const timelineNode = timelineNodeId ? nodeById.get(timelineNodeId) || null : null; const segmentNode = segmentDialogNodeId ? nodeById.get(segmentDialogNodeId) || null : null; const textEditorNode = textEditorNodeId ? nodeById.get(textEditorNodeId) || null : null; const characterReferenceNode = characterReferenceNodeId ? nodeById.get(characterReferenceNodeId) || null : null; const drawingNode = drawingNodeId ? nodeById.get(drawingNodeId) || null : null; const pendingConnectionSourceNode = pendingConnectionCreate?.connection.handleType === "source" ? nodeById.get(pendingConnectionCreate.connection.nodeId) : null; const canCreateDrawingFromConnection = !pendingConnectionCreate?.batchSourceNodeIds?.length && pendingConnectionSourceNode?.type === CanvasNodeType.Image && Boolean(pendingConnectionSourceNode.metadata?.content); const openTextNodeEditor = useCallback((node: CanvasNodeData) => { if (node.type !== CanvasNodeType.Text) return; setSelectedNodeIds(new Set([node.id])); setSelectedConnectionId(null); setContextMenu(null); setDialogNodeId(null); setToolbarNodeId(null); if (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId) { setCharacterReferenceNodeId(node.id); return; } setTextEditorNodeId(node.id); }, []); const openDrawingNode = useCallback((node: CanvasNodeData) => { if (node.type !== CanvasNodeType.Drawing) return; setSelectedNodeIds(new Set([node.id])); setSelectedConnectionId(null); setContextMenu(null); setDialogNodeId(null); setToolbarNodeId(null); setDrawingNodeId(node.id); }, []); const { agentSnapshot, agentUndoCount, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasAgentOperations({ projectId, domainProjectId: currentProject?.projectId, projectTitle: currentProject?.title || "未命名画布", nodes, connections, selectedNodeIds, viewport, nodesRef, connectionsRef, selectedNodeIdsRef, viewportRef, generateNodeRef, setNodes, setConnections, setSelectedNodeIds, setSelectedConnectionId, setViewport, setContextMenu, focusSelection: fitCanvasSelection, }); const { selectCanvasStyle, styleApplying } = useCanvasStyleWorkflow({ domainProjectId: currentProject?.projectId, nodesRef, selectedNodeIdsRef, getCanvasCenter, setNodes, setSelectedNodeIds, setSelectedConnectionId, setDialogNodeId, setStylePickerOpen, }); const { applyDirectorOutput, createDirectorShot, openDirectorWorkbench, saveDirectorScene } = useCanvasDirector({ projectId, directorNodeId, directorScenes: currentProject?.directorScenes || [], nodesRef, connectionsRef, getCanvasCenter, setNodes, setConnections, setSelectedNodeIds, setSelectedConnectionId, setDirectorNodeId, updateProject, }); const { activateStep: activateShortDramaStep, createPipeline: createShortDramaPipeline, guideCollapsed: shortDramaGuideCollapsed, openStoryInput, progress: shortDramaProgress, setGuideCollapsed: setShortDramaGuideCollapsed, skipGuide: skipShortDramaGuide, } = useCanvasShortDrama({ nodes, connections, nodesRef, connectionsRef, selectedNodeIdsRef, getCanvasCenter, setNodes, setConnections, setSelectedNodeIds, setSelectedConnectionId, setStylePickerOpen, fitCanvasSelection, focusCanvasNode, openTextEditor: openTextNodeEditor, }); const shortDramaGuide = shortDramaEnabled && !currentProject?.projectId && shortDramaProgress.active ? { progress: shortDramaProgress, collapsed: shortDramaGuideCollapsed, onToggle: () => setShortDramaGuideCollapsed((value) => !value) } : undefined; const clearCanvas = useCallback(() => { const drawingIds = nodesRef.current.flatMap((node) => (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId ? [node.metadata.drawingId] : [])); if (drawingIds.length) { void Promise.all(drawingIds.map((drawingId) => removeCanvasDrawing(projectId, drawingId))).catch(() => message.warning("画布已清空,但部分本地绘图缓存清理失败")); } setNodes([]); setConnections([]); setTextEditorNodeId(null); setDrawingNodeId(null); setInfoNodeId(null); setSubtitleNodeId(null); setCropNodeId(null); setMaskEditNodeId(null); setAnnotationNodeId(null); setAngleNodeId(null); setEmotionNodeId(null); setPreviewNodeId(null); setRunningNodeId(null); deselectCanvas(); setClearConfirmOpen(false); clearCanvasFiles(); }, [clearCanvasFiles, deselectCanvas, message, nodesRef, projectId, setEmotionNodeId]); useCanvasKeyboard({ nodesRef, selectedNodeIdsRef, selectedConnectionId, setSelectedNodeIds, setSelectedConnectionId, setContextMenu, setShortcutRequestNonce, setInfoNodeId, setCropNodeId, setMaskEditNodeId, setAnnotationNodeId, saveCanvasProject, zoomToActualSize, fitCanvasContent, fitCanvasSelection, undoCanvas, redoCanvas, cancelSelectionBox, copySelectedNodes, pasteCopiedNodes, restoreCopiedNodesFromText, shouldPreferCopiedNodes, pasteSystemClipboard, deleteNodes, deleteConnection, deselectCanvas, zoomCanvasIn, zoomCanvasOut, focusMode, exitFocusMode, toggleFocusMode, beginBatchConnection: () => beginBatchConnectionMode(Array.from(selectedNodeIdsRef.current)), }); const handleAssistantSessionsChange = useCallback((sessions: CanvasAssistantSession[], activeId: string | null) => { chatSessionsRef.current = sessions; activeChatIdRef.current = activeId; setChatSessions(sessions); setActiveChatId(activeId); }, []); const startTitleEditing = useCallback(() => { setTitleDraft(currentProject?.title || "未命名画布"); setTitleEditing(true); }, [currentProject?.title]); const finishTitleEditing = useCallback(() => { const nextTitle = titleDraft.trim(); if (nextTitle) renameCurrentProject(nextTitle); setTitleEditing(false); }, [renameCurrentProject, titleDraft]); const pasteAtPosition = useCallback( (position: Position) => { if (shouldPreferCopiedNodes() && pasteCopiedNodes(position)) return; void (async () => { try { // 标记写入成功时仍优先系统图片,兼容截图和从外部应用复制的媒体。 const handled = await pasteSystemClipboard(position); if (!handled) pasteCopiedNodes(position); } catch { if (!pasteCopiedNodes(position)) message.warning("无法读取剪贴板内容"); } })(); }, [message, pasteCopiedNodes, pasteSystemClipboard, shouldPreferCopiedNodes], ); const copyNodeContentToClipboard = useCallback( async (node: CanvasNodeData | null) => { releaseCopiedNodesPastePriority(); const content = node?.metadata?.content; if (!node || !content) { message.warning("没有可复制的内容"); return; } try { if (node.type === CanvasNodeType.Image && typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) { const response = await fetch(content); const blob = await response.blob(); await navigator.clipboard.write([new ClipboardItem({ [blob.type || "image/png"]: blob })]); message.success("图片已复制"); return; } if (!navigator.clipboard?.writeText) { message.warning("当前浏览器不支持写入剪贴板"); return; } await navigator.clipboard.writeText(content); message.success(node.type === CanvasNodeType.Text ? "文本已复制" : "内容链接已复制"); } catch { message.error("复制失败,请检查浏览器剪贴板权限"); } }, [message, releaseCopiedNodesPastePriority], ); const copyNodeMediaUrlToClipboard = useCallback( async (node: CanvasNodeData | null) => { releaseCopiedNodesPastePriority(); try { const storageKey = node?.metadata?.storageKey; const content = node?.metadata?.content?.trim(); const resourceId = resourceIdFromStorageKey(storageKey); const mediaPath = content && !content.startsWith("data:") && !content.startsWith("blob:") ? content : resourceId ? resourceFileUrl(resourceId) : ""; const mediaURL = mediaPath ? new URL(mediaPath, window.location.href).toString() : ""; if (!mediaURL) throw new Error("当前媒体只有本地内容,没有可复制的地址"); if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(mediaURL); else if (!(await copyToClipboard(mediaURL))) throw new Error("当前浏览器不支持写入剪贴板"); message.success(node?.type === CanvasNodeType.Video ? "视频地址已复制" : "图片地址已复制"); } catch (error) { message.error(error instanceof Error ? error.message : "媒体地址复制失败"); } }, [message, releaseCopiedNodesPastePriority], ); const handleCanvasContextMenu = useCallback( (event: ReactMouseEvent) => { const target = event.target instanceof Element ? event.target : null; if (target?.closest("[data-node-id],[data-connection-id]")) return; event.preventDefault(); event.stopPropagation(); if (target?.closest("[data-canvas-no-zoom],.ant-modal,.ant-popover,.ant-dropdown")) { setContextMenu(null); return; } closeConnectionCreateMenu(); setContextMenu({ type: "canvas", x: event.clientX, y: event.clientY, position: screenToCanvas(event.clientX, event.clientY) }); }, [closeConnectionCreateMenu, screenToCanvas], ); const handleNodeContextMenu = useCallback( (event: ReactMouseEvent, id: string) => { event.preventDefault(); event.stopPropagation(); setSelectedNodeIds(new Set([id])); setSelectedConnectionId(null); closeConnectionCreateMenu(); setToolbarNodeId(null); setDialogNodeId(null); setContextMenu({ type: "node", x: event.clientX, y: event.clientY, nodeId: id }); }, [closeConnectionCreateMenu], ); const handleGenerateNode = useCanvasGenerationExecutor({ projectId, domainProjectId: currentProject?.projectId, addedSkills, assets, nodesRef, connectionsRef, setNodes, setConnections, setSelectedNodeIds, setSelectedConnectionId, setDialogNodeId, setRunningNodeId, startGenerationRequest, finishGenerationRequest, bindGenerationTask, applyGenerationTaskResult, }); useEffect(() => { generateNodeRef.current = handleGenerateNode; }, [handleGenerateNode]); const { cancelSubmittedBatchItem, enqueueGenerationBatch, retryFailedBatchItems, stopRemainingBatchItems } = useCanvasGenerationBatches({ projectId, projectLoaded, nodes, nodesRef, setNodes, handleGenerateNode, }); const { addScriptRow, createAndGenerateScriptVideos, createScriptActionBoards, createScriptImageNodes, createScriptVideoNodes, generateScriptImages, generateScriptRows, generateScriptVideos, removeScriptRow, replaceScriptRows, updateScriptRow } = useCanvasStoryboard({ projectId, nodesRef, connectionsRef, setNodes, setConnections, setSelectedNodeIds, enqueueGenerationBatch, }); const handleRetryNode = useCanvasGenerationRetry({ projectId, domainProjectId: currentProject?.projectId, addedSkills, assets, nodesRef, connectionsRef, setNodes, setRunningNodeId, startGenerationRequest, finishGenerationRequest, bindGenerationTask, applyGenerationTaskResult, }); const reconcileImageBatchRootNode = useCallback((rootId: string) => { setNodes((current) => { const root = current.find((item) => item.id === rootId); if (!root) return current; const reconciled = reconcileImageBatchRoot(root, current); return current.map((item) => item.id === root.id ? reconciled : item); }); }, [setNodes]); const retryImageBatchChildren = useCallback((rootId: string, children: CanvasNodeData[]) => { const childIds = children.map((child) => child.id); setNodes((current) => markImageBatchRetrying(rootId, childIds, current)); void Promise.allSettled(children.map(async (child) => { await handleRetryNode(child); setNodes((current) => current.map((item) => item.id === child.id ? restoreUnsubmittedImageBatchChild(item, child) : item)); })).finally(() => reconcileImageBatchRootNode(rootId)); }, [handleRetryNode, reconcileImageBatchRootNode, setNodes]); const generateImageFromTextNode = useCallback( (node: CanvasNodeData) => { const prompt = (node.metadata?.content || node.metadata?.prompt || "").trim(); if (!prompt) { message.warning("文本节点为空,无法生图"); return; } const sourceNode = nodesRef.current.find((item) => item.id === node.id); if (!sourceNode) return; const nodeSize = getNodeSpec(CanvasNodeType.Image); const imageNode = createCanvasNode( CanvasNodeType.Image, { x: sourceNode.position.x + sourceNode.width + 96 + nodeSize.width / 2, y: sourceNode.position.y + sourceNode.height / 2, }, { prompt: `@[node:${sourceNode.id}]`, composerContent: `@[node:${sourceNode.id}]`, model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, quality: effectiveConfig.quality, transparentBackground: effectiveConfig.transparentBackground, count: getGenerationCount(effectiveConfig.canvasImageCount || effectiveConfig.count), }, ); imageNode.title = "图片生成"; const connection = { id: nanoid(), fromNodeId: sourceNode.id, toNodeId: imageNode.id }; const nextNodes = nodesRef.current.map((item) => (item.id === sourceNode.id ? { ...item, metadata: { ...item.metadata, content: prompt, richText: undefined, prompt, status: NODE_STATUS_SUCCESS } } : item)).concat(imageNode); const nextConnections = [...connectionsRef.current, connection]; nodesRef.current = nextNodes; connectionsRef.current = nextConnections; setNodes(nextNodes); setConnections(nextConnections); setSelectedNodeIds(new Set([imageNode.id])); setSelectedConnectionId(null); setDialogNodeId(imageNode.id); }, [effectiveConfig, message], ); const renderCanvasNodePanel = useCallback( (panelNode: CanvasNodeData) => { if (panelNode.type === CanvasNodeType.Script || panelNode.type === CanvasNodeType.Drawing) return null; return panelNode.type === CanvasNodeType.Config ? ( handleConfigNodeChange(panelNode.id, { composerContent })} onMetadataChange={(patch) => handleConfigNodeChange(panelNode.id, patch)} onClose={() => setDialogNodeId(null)} /> ) : ( { setNodeImageSettingsOpen(open); if (open) setToolbarNodeId(null); }} /> ); }, [configInputsById, confirmStopGeneration, handleConfigNodeChange, handleGenerateNode, handleNodePromptChange, mentionReferencesByNodeId, runningNodeId, skillMentionReferences, workspaceMode], ); const renderCanvasNodeContent = useCallback( (contentNode: CanvasNodeData) => { if (contentNode.metadata?.workflowKind === "character" && contentNode.metadata.characterAssetId) { return ; } if (contentNode.metadata?.workflowKind === "styleboard" && !contentNode.metadata.content) { return setStylePickerOpen(true)} />; } if (contentNode.metadata?.workflowKind === "story_input") { return openStoryInput(contentNode.id)} />; } if (contentNode.type === CanvasNodeType.Script) { const pipeline = deriveStoryboardPipelineProgress(contentNode, nodesRef.current, connectionsRef.current); return ( setScriptEditorNodeId(contentNode.id)} onCreateImageNodes={() => createScriptImageNodes(contentNode.id)} onCreateVideoNodes={() => createScriptVideoNodes(contentNode.id)} onGenerateImages={(rowIds) => void generateScriptImages(contentNode.id, rowIds)} onGenerateVideos={(rowIds) => (contentNode.metadata?.storyboardVideoInputMode === "keyframe" ? void generateScriptVideos(contentNode.id, rowIds) : void createAndGenerateScriptVideos(contentNode.id, rowIds))} onVideoInputModeChange={(storyboardVideoInputMode) => handleConfigNodeChange(contentNode.id, { storyboardVideoInputMode })} onMergeVideos={() => void mergeVideosByIds(pipeline.successfulVideoNodeIds)} onCreateActionBoards={() => void createScriptActionBoards(contentNode.id)} onRetryBatch={(batchId) => retryFailedBatchItems(contentNode.id, batchId)} onRetryBatchItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)} onStopBatch={(batchId) => stopRemainingBatchItems(contentNode.id, batchId)} onCancelBatchItem={(batchId, itemId) => cancelSubmittedBatchItem(contentNode.id, batchId, itemId)} onAddRow={() => addScriptRow(contentNode.id)} onRemoveRow={(rowId) => removeScriptRow(contentNode.id, rowId)} onUpdateRow={(rowId, patch) => updateScriptRow(contentNode.id, rowId, patch)} onPromptChange={(composerContent) => handleConfigNodeChange(contentNode.id, { composerContent })} onGenerateScript={(prompt) => void generateScriptRows(contentNode.id, prompt)} onModelChange={(model) => handleConfigNodeChange(contentNode.id, { model })} onShotDurationChange={(duration: StoryboardShotDuration) => handleConfigNodeChange(contentNode.id, { storyboardShotDuration: duration })} onShotCountChange={(count: StoryboardShotCount) => handleConfigNodeChange(contentNode.id, { storyboardShotCount: count })} workspaceMode={workspaceMode} onComposerHeightChange={(height) => { if (contentNode.metadata?.storyboardComposerHeight === height) return; handleConfigNodeChange(contentNode.id, { storyboardComposerHeight: height }); const minHeight = storyboardMinNodeHeight(height); if (contentNode.height < minHeight) handleNodeResize(contentNode.id, contentNode.width, minHeight); }} onConnectStart={(event, rowId, handleType) => handleConnectStart(event, contentNode.id, handleType, rowId === "context" ? "storyboard:context" : `row:${rowId}`)} onScrollTopChange={(scrollTop) => setScriptScrollTopById((current) => (current[contentNode.id] === scrollTop ? current : { ...current, [contentNode.id]: scrollTop }))} /> ); } if (contentNode.metadata?.directorSceneId) { return ( scene.id === contentNode.metadata?.directorSceneId) || null} previewUrl={nodesRef.current.find((item) => item.id === contentNode.metadata?.directorPreviewNodeId)?.metadata?.content} professional={workspaceMode === "professional"} onOpen={() => openDirectorWorkbench(contentNode.id)} /> ); } return ( setDialogNodeId((current) => (current === contentNode.id ? null : contentNode.id))} onStop={confirmStopGeneration} onGenerate={(nodeId) => { const target = nodesRef.current.find((item) => item.id === nodeId); void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.composerContent ?? target?.metadata?.prompt ?? ""); }} workspaceMode={workspaceMode} /> ); }, [ addScriptRow, cancelSubmittedBatchItem, configInputsById, confirmStopGeneration, createAndGenerateScriptVideos, createScriptActionBoards, createScriptImageNodes, createScriptVideoNodes, currentProject?.directorScenes, generateScriptImages, generateScriptRows, generateScriptVideos, handleConfigNodeChange, handleConnectStart, handleGenerateNode, handleNodeResize, mentionReferencesByNodeId, mergeVideosByIds, openDirectorWorkbench, openStoryInput, removeScriptRow, retryFailedBatchItems, runningNodeId, stopRemainingBatchItems, updateScriptRow, viewport.k, workspaceMode, ], ); const handleCanvasNodeHoverStart = useCallback( (nodeId: string) => { if (nodeDraggingRef.current) return; setHoveredNodeId(nodeId); keepNodeToolbar(nodeId); }, [keepNodeToolbar], ); const handleCanvasNodeHoverEnd = useCallback( (nodeId: string) => { setHoveredNodeId((current) => (current === nodeId ? null : current)); hideNodeToolbar(); }, [hideNodeToolbar], ); const retryCanvasNode = useCallback( (node: CanvasNodeData) => { if (node.type === CanvasNodeType.Script) { const prompt = (node.metadata?.composerContent || node.metadata?.prompt || "").trim(); if (!prompt) { message.warning("分镜脚本缺少剧情内容,无法重试"); return; } void generateScriptRows(node.id, prompt); return; } if (node.type === CanvasNodeType.Image && node.metadata?.isBatchRoot) { const failedChildren = failedImageBatchChildren(node, nodesRef.current); if (!failedChildren.length) { message.info("当前批次没有需要重试的失败图片"); return; } message.info(`正在重试 ${failedChildren.length} 个失败图片`); retryImageBatchChildren(node.id, failedChildren); return; } if (node.type === CanvasNodeType.Image && node.metadata?.batchRootId) { retryImageBatchChildren(node.metadata.batchRootId, [node]); return; } void handleRetryNode(node); }, [generateScriptRows, handleRetryNode, message, nodesRef, retryImageBatchChildren], ); const openCanvasNodeTaskDetails = useCallback( (node: CanvasNodeData) => { void openNodeTaskDetails(node); }, [openNodeTaskDetails], ); const openCanvasNodeVersions = useCallback((node: CanvasNodeData) => setVersionCompareRootId(node.metadata?.versionOfNodeId || node.id), []); const viewCanvasNodeImage = useCallback((node: CanvasNodeData) => setPreviewNodeId(node.id), []); const editCanvasDirector = useCallback((node: CanvasNodeData) => openDirectorWorkbench(node.id), [openDirectorWorkbench]); const locateProjectStyleNode = useCallback(() => { const styleNode = nodesRef.current.find((node) => node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "styleboard"); if (!styleNode) { message.info("项目画风节点正在同步,请稍后再试"); return; } focusCanvasNode(styleNode.id); }, [focusCanvasNode, message, nodesRef]); const emptyCanvasState = nodes.length ? null : !shortDramaEnabled ? ( handleUploadRequest()} onAddText={() => createNode(CanvasNodeType.Text)} /> ) : currentProject?.projectId ? ( { const first = linkedProjectQuery.data?.units.slice().sort((left, right) => left.position - right.position)[0]; if (first) void handleProjectChapterInsert({ id: first.id, projectId: currentProject.projectId!, title: first.title, position: first.position }); }} onOpenAssets={() => openProjectAssets()} onAddText={() => createNode(CanvasNodeType.Text)} /> ) : ( { setCinematicAgentEntry(true); setAgentMode("online"); openAgent("online"); }} onUpload={() => handleUploadRequest()} onAddText={() => createNode(CanvasNodeType.Text)} onAddScript={() => createNode(CanvasNodeType.Script)} /> ); if (!projectLoaded) return ; return ( <> 跳转到画布主内容
{!focusMode && shortDramaEnabled && currentProject?.projectId ? ( openProjectAssets()} /> ) : null}
{!focusMode ? ( setTitleEditing(false)} canUndo={historyState.canUndo} canRedo={historyState.canRedo} onCreateProject={createAndOpenProject} onDeleteProject={deleteCurrentProject} onImportImage={() => handleUploadRequest()} onUndo={undoCanvas} onRedo={redoCanvas} onShare={() => setShareModalOpen(true)} agentOpen={assistantOpen} compactAgentStatus={codexCompactAgent ? { connected: localAgentConnected, enabled: localAgentEnabled, activity: localAgentActivity } : undefined} onToggleAgent={() => (assistantOpen ? closeAgent() : openAgent())} shortcutRequestNonce={shortcutRequestNonce} mediaPerformanceMode={mediaPerformanceMode} onMediaPerformanceModeChange={setMediaPerformanceMode} onOpenSearch={() => setNodeSearchOpen(true)} projectContext={ shortDramaEnabled && currentProject?.projectId ? { ...canvasContext, projectId: currentProject.projectId, projectName: linkedProjectQuery.data?.project.name || currentProject.title, } : undefined } onEnterFocusMode={enterFocusMode} shortDramaGuide={shortDramaGuide} /> ) : null} setNodeSearchOpen(false)} onFocus={(nodeId) => { const target = nodeById.get(nodeId); const parent = target?.parentId ? nodeById.get(target.parentId) : null; if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id); const batchRoot = target?.metadata?.batchRootId ? nodeById.get(target.metadata.batchRootId) : null; if (batchRoot && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id); const selection = new Set([nodeId]); selectedNodeIdsRef.current = selection; setSelectedNodeIds(selection); setSelectedConnectionId(null); focusCanvasNode(nodeId); }} /> {!focusMode && shortDramaGuide ? ( ) : null} setShareModalOpen(false)} beforeCreate={saveCanvasProject} /> setStylePickerOpen(false)} onSelect={selectCanvasStyle} />
} onViewportChange={handleViewportChange} onViewportPreviewChange={handleViewportPreviewChange} onCanvasMouseDown={handleCanvasMouseDown} boxSelectEnabled={canvasTool === "box-select"} onCanvasDoubleClick={handleCanvasDoubleClick} onCanvasDeselect={deselectCanvas} onContextMenu={handleCanvasContextMenu} onDrop={handleDrop} onFileDragEnter={handleFileDragEnter} onFileDragLeave={handleFileDragLeave} onFileDragOver={handleFileDragOver} > { setSelectedConnectionId(connectionId); setSelectedNodeIds(new Set()); setContextMenu(null); }} onConnectionContextMenu={(event, connectionId) => { setSelectedConnectionId(connectionId); setSelectedNodeIds(new Set()); closeConnectionCreateMenu(); setContextMenu({ type: "connection", x: event.clientX, y: event.clientY, connectionId }); }} onNodeMouseDown={handleNodeMouseDown} onNodeHoverStart={handleCanvasNodeHoverStart} onNodeHoverEnd={handleCanvasNodeHoverEnd} onConnectStart={handleConnectStart} onNodeResize={handleNodeResize} onToggleFrame={handleFrameToggle} onFolderStyleChange={handleFolderStyleChange} onFolderThemeChange={handleFolderThemeChange} onNodeTitleChange={handleNodeTitleChange} onNodeContextMenu={handleNodeContextMenu} onNodeContentChange={handleNodeContentChange} onToggleBatch={toggleBatchExpanded} onSetBatchPrimary={setBatchPrimary} onRetry={retryCanvasNode} onCancelTask={cancelNodeTask} onOpenTaskDetails={openCanvasNodeTaskDetails} onOpenVersions={openCanvasNodeVersions} onViewImage={viewCanvasNodeImage} onReplaceMedia={(node) => handleUploadRequest(node.id)} onOpenTextEditor={openTextNodeEditor} onOpenDirector={editCanvasDirector} onOpenDrawing={openDrawingNode} onStartBatchConnection={startBatchConnection} /> {focusMode ? ( setFocusDockRevealed((value) => !value)} onToggleAgent={() => (assistantOpen ? closeAgent() : openAgent())} onExit={exitFocusMode} onZoomIn={zoomCanvasIn} onZoomOut={zoomCanvasOut} onFit={resetViewport} /> ) : null} {emptyCanvasState} {!focusMode || focusDockRevealed ? ( createNode(CanvasNodeType.Image)} onAddVideo={() => createNode(CanvasNodeType.Video)} onAddAudio={() => createNode(CanvasNodeType.Audio)} onAddText={() => createNode(CanvasNodeType.Text)} onChooseStyle={() => setStylePickerOpen(true)} onAddScript={() => createNode(CanvasNodeType.Script)} onAddFrame={() => createNode(CanvasNodeType.Frame)} onAddFolder={createFolder} onAddDrawing={() => createNode(CanvasNodeType.Drawing)} onOpenDirector={() => createDirectorShot()} onUndo={undoCanvas} onRedo={redoCanvas} onUpload={() => handleUploadRequest()} onDelete={() => deleteNodes(new Set(selectedNodeIds))} onClear={() => setClearConfirmOpen(true)} onDeselect={deselectCanvas} onBackgroundModeChange={setBackgroundMode} onShowImageInfoChange={setShowImageInfo} onOpenMyAssets={() => { openCanvasAssetLibrary(); }} onOpenProjectCharacters={() => openProjectAssets("character")} /> ) : null}
{assistantMounted ? ( {(resizing) => ( setCinematicAgentEntry(false)} resizing={resizing} /> )} ) : null}
{angleNode?.metadata?.content ? ( setAngleNodeId(null)} onConfirm={(params) => { void generateAngleNode(angleNode, params); }} /> ) : null} {emotionNode?.metadata?.content ? ( setEmotionNodeId(null)} onConfirm={(payload: CanvasImageEmotionPayload) => { void generateEmotionNode(emotionNode, payload); }} /> ) : null} {dialogNode && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox ? ( {renderCanvasNodePanel(dialogNode)} ) : null} {pendingConnectionCreate ? ( getConnectionCreateDisabledReason(type, pendingConnectionCreate)} onCreate={(type) => void createConnectedNode(type, pendingConnectionCreate)} onClose={cancelPendingConnectionCreate} /> ) : null} {selectedNodeBounds && !selectionBox && !isNodeDragging ? ( beginBatchConnectionMode(Array.from(selectedNodeIds))} onMergeVideos={() => void mergeSelectedVideos()} /> ) : null} {uploadStatus ? : null} {mergeVideoProgress ? : null} {lastAgentChange ? ( { undoAgentOps(); }} onClose={dismissLastAgentChange} /> ) : null} (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId ? openTextNodeEditor(node) : setInfoNodeId(node.id))} onEditText={openTextNodeEditor} onDecreaseFont={(node) => handleFontSizeChange(node.id, Math.max(10, (node.metadata?.fontSize || 14) - 2))} onIncreaseFont={(node) => handleFontSizeChange(node.id, Math.min(32, (node.metadata?.fontSize || 14) + 2))} onToggleDialog={(node) => setDialogNodeId((current) => (current === node.id ? null : node.id))} onGenerateImage={generateImageFromTextNode} onUpload={(node) => handleUploadRequest(node.id)} onDownload={downloadNodeImage} onSaveAsset={(node) => void saveNodeAsset(node)} onAnnotate={(node) => setAnnotationNodeId(node.id)} onMaskEdit={(node) => setMaskEditNodeId(node.id)} onEmotion={(node) => { setDialogNodeId(null); setEmotionNodeId((current) => (current === node.id ? null : node.id)); }} onPortraitTexture={generatePortraitTextureNode} onCrop={(node) => setCropNodeId(node.id)} onSplit={(node) => setSplitNodeId(node.id)} onUpscale={(node) => setUpscaleNodeId(node.id)} onSuperResolve={(node) => setSuperResolveNodeId(node.id)} onAngle={(node) => { setDialogNodeId(null); setAngleNodeId((current) => (current === node.id ? null : node.id)); }} onViewImage={(node) => setPreviewNodeId(node.id)} onExtractVideoLastFrame={(node) => void extractVideoLastFrame(node)} onExtractAudioFromVideo={(node) => void extractAudioFromVideo(node)} onTrimVideoRegenerate={(node) => void trimVideoAndRegenerate(node)} onSubtitles={(node) => setSubtitleNodeId(node.id)} onTimeline={(node) => setTimelineNodeId(node.id)} extractingVideoFrame={toolbarNode?.id === extractingVideoFrameNodeId} extractingAudio={segmentRunningMode === "audio"} trimmingVideo={segmentRunningMode === "video"} onReversePrompt={createImageReversePromptNodes} onRetry={retryCanvasNode} onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)} onToggleLocked={(node) => toggleNodeLocked(node.id)} onDelete={(node) => deleteNodes(new Set([node.id]))} /> {isMiniMapOpen && !focusMode ? : null} {!focusMode ? (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} > setIsMiniMapOpen((value) => !value)} onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)} /> void createImageAssetNode(asset)} onFocusCanvasImage={focusCanvasImageNode} />
) : null} setContextMenu(null)} onAddNode={(type, position) => createNode(type, position)} onAddFolder={createFolder} onChooseStyle={() => setStylePickerOpen(true)} onOpenDirector={createDirectorShot} onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)} onOpenAssets={openCanvasAssetLibrary} onOpenProjectCharacters={(position) => openProjectAssets("character", position)} onUndo={undoCanvas} onRedo={redoCanvas} onPaste={pasteAtPosition} onCopyNode={(nodeId) => copyNodesToClipboard(new Set([nodeId]))} onDuplicate={duplicateNode} onDeleteNode={(nodeId) => deleteNodes(new Set([nodeId]))} onDeleteConnection={deleteConnection} onSaveAsset={(node) => { void saveNodeAsset(node); }} onViewMedia={(node) => setPreviewNodeId(node.id)} onEditText={openTextNodeEditor} onOpenDrawing={openDrawingNode} onGenerateImage={generateImageFromTextNode} onCopyContent={(node) => { void copyNodeContentToClipboard(node); }} onCopyMediaUrl={(node) => { void copyNodeMediaUrlToClipboard(node); }} onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })} onToggleFrame={(node) => handleFrameToggle(node.id)} /> setInfoNodeId(null)} onMetadataChange={handleConfigNodeChange} /> {subtitleNode ? ( setSubtitleNodeId(null)} onSave={(nodeId, patch) => { handleConfigNodeChange(nodeId, patch); const currentTimeline = currentProject?.timeline; if (currentTimeline) { const next = syncNodeSubtitlesToTimeline(currentTimeline, nodeId, patch.subtitleEntries || []); if (next !== currentTimeline) updateProject(projectId, { timeline: next }); } }} /> ) : null} {segmentNode && segmentDialogMode ? ( void handleSegmentConfirm(segmentNode, params)} /> ) : null} {timelineNode ? ( setTimelineNodeId(null)} onOpenSubtitleDialog={(subNodeId) => { setTimelineNodeId(null); setSubtitleNodeId(subNodeId); }} onSave={(next) => updateProject(projectId, { timeline: next })} onSaveSubtitles={(subNodeId, entries) => handleConfigNodeChange(subNodeId, { subtitleEntries: entries, ...(entries.length ? {} : { subtitleHighlights: [] }), subtitleUpdatedAt: new Date().toISOString(), }) } onOpenAssetLibrary={openTimelineAssetLibrary} onOpenProjectAssets={() => openProjectAssets("all", undefined, "timeline")} onUploadLocalFiles={uploadTimelineMedia} addNodeToTimelineRef={timelineAddNodeRef} addMediaToTimelineRef={timelineMediaAddRef} onCreateAssembledNode={createVideoNodeFromBlob} /> ) : null} setCharacterReferenceNodeId(null)} /> setTextEditorNodeId(null)} onSave={(nodeId, title, content, richText) => { setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, title, metadata: { ...node.metadata, content, richText } } : node))); }} /> {drawingNode ? ( } > setDrawingNodeId(null)} onSaved={(nodeId, summary) => { setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, drawingEngine: summary.engine, drawingRevision: summary.revision, drawingUpdatedAt: summary.updatedAt, drawingShapeCount: summary.shapeCount, drawingPageCount: summary.pageCount, }, } : node, ), ); message.success("绘图已保存"); }} /> ) : null} setScriptEditorNodeId(null)} onUpdateRows={(rows) => activeScriptNode && replaceScriptRows(activeScriptNode.id, rows)} onVisibleColumnsChange={(visibleColumns: StoryboardColumn[]) => { if (!activeScriptNode || !visibleColumns.length) return; setNodes((prev) => prev.map((node) => node.id === activeScriptNode.id ? { ...node, metadata: { ...node.metadata, storyboard: { rows: node.metadata?.storyboard?.rows || [], visibleColumns, referenceNodeIds: node.metadata?.storyboard?.referenceNodeIds || [] } } } : node, ), ); }} onGenerateImages={(rowIds) => activeScriptNode && void generateScriptImages(activeScriptNode.id, rowIds)} onGenerateVideos={(rowIds) => { if (!activeScriptNode) return; if (activeScriptNode.metadata?.storyboardVideoInputMode === "keyframe") void generateScriptVideos(activeScriptNode.id, rowIds); else void createAndGenerateScriptVideos(activeScriptNode.id, rowIds); }} onVideoInputModeChange={(storyboardVideoInputMode) => activeScriptNode && handleConfigNodeChange(activeScriptNode.id, { storyboardVideoInputMode })} /> {directorNodeId && activeDirectorScene ? ( } > node.type === CanvasNodeType.Image && Boolean(node.metadata?.content))} onClose={() => setDirectorNodeId(null)} onChange={saveDirectorScene} onApply={applyDirectorOutput} /> ) : null} setVersionCompareRootId(null)} onSetPrimary={setPrimaryVersion} onFocus={(nodeId) => { setVersionCompareRootId(null); focusCanvasNode(nodeId); }} /> setCropNodeId(null)} onCloseAnnotation={() => setAnnotationNodeId(null)} onCloseMaskEdit={() => setMaskEditNodeId(null)} onCloseSplit={() => setSplitNodeId(null)} onCloseUpscale={() => setUpscaleNodeId(null)} onCrop={(node, crop) => void cropImageNode(node, crop)} onAnnotate={(node, dataUrl) => void saveAnnotatedImageNode(node, dataUrl)} onMaskEdit={(node, payload) => void maskEditImageNode(node, payload)} onSplit={(node, params) => void splitImageNode(node, params)} onUpscale={(node, params) => void upscaleImageNode(node, params)} /> setTaskDetail(null)} superResolveNode={superResolveNode} onCloseSuperResolve={() => setSuperResolveNodeId(null)} previewNode={previewNode} onClosePreview={() => setPreviewNodeId(null)} clearConfirmOpen={clearConfirmOpen} onCancelClear={() => setClearConfirmOpen(false)} onConfirmClear={clearCanvas} /> {codexCompactAgent && !assistantMounted ? ( ) : null}
); }