Files
ttstd-infinite-canvas/web/src/pages/canvas/project.tsx

2376 lines
122 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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 <CanvasRefreshShell />;
return <InfiniteCanvasPage />;
}
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<HTMLDivElement>(null);
const didInitialCenterRef = useRef(false);
const toolbarHideTimerRef = useRef<ReturnType<typeof setTimeout> | 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<CanvasNodeData[]>([]);
const [connections, setConnections] = useState<CanvasConnection[]>([]);
const [chatSessions, setChatSessions] = useState<CanvasAssistantSession[]>([]);
const [activeChatId, setActiveChatId] = useState<string | null>(null);
const [viewport, setViewport] = useState<ViewportTransform>({ x: 0, y: 0, k: 1 });
const [size, setSize] = useState({ width: 1200, height: 720 });
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [isMiniMapOpen, setIsMiniMapOpen] = useState(false);
const [backgroundMode, setBackgroundMode] = useState<CanvasBackgroundMode>("dots");
const [showImageInfo, setShowImageInfo] = useState(false);
const [canvasTool, setCanvasTool] = useState<CanvasToolMode>("move");
const [mediaPerformanceMode, setMediaPerformanceMode] = useState<CanvasMediaPerformanceMode>(readCanvasMediaPerformanceMode);
const [projectLoaded, setProjectLoaded] = useState(false);
const [workspaceMode, setWorkspaceMode] = useState<CanvasWorkspaceMode>(readCanvasWorkspaceMode);
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false);
const [nodeSearchOpen, setNodeSearchOpen] = useState(false);
const [toolbarNodeId, setToolbarNodeId] = useState<string | null>(null);
const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false);
const [dialogNodeId, setDialogNodeId] = useState<string | null>(null);
const [textEditorNodeId, setTextEditorNodeId] = useState<string | null>(null);
const [characterReferenceNodeId, setCharacterReferenceNodeId] = useState<string | null>(null);
const [drawingNodeId, setDrawingNodeId] = useState<string | null>(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<Position | undefined>();
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
const [subtitleNodeId, setSubtitleNodeId] = useState<string | null>(null);
const [timelineNodeId, setTimelineNodeId] = useState<string | null>(null);
const [superResolveNodeId, setSuperResolveNodeId] = useState<string | null>(null);
const [previewNodeId, setPreviewNodeId] = useState<string | null>(null);
const [scriptEditorNodeId, setScriptEditorNodeId] = useState<string | null>(null);
const [scriptScrollTopById, setScriptScrollTopById] = useState<Record<string, number>>({});
const [directorNodeId, setDirectorNodeId] = useState<string | null>(null);
const [versionCompareRootId, setVersionCompareRootId] = useState<string | null>(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<void>) | 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<string>, 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<string, CanvasNodeData[]>();
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 ? (
<CanvasConfigComposer
value={panelNode.metadata?.composerContent ?? panelNode.metadata?.prompt ?? ""}
inputs={configInputsById.get(panelNode.id) || []}
skillReferences={skillMentionReferences}
generationMode={panelNode.metadata?.generationMode}
metadata={panelNode.metadata}
workspaceMode={workspaceMode}
onChange={(composerContent) => handleConfigNodeChange(panelNode.id, { composerContent })}
onMetadataChange={(patch) => handleConfigNodeChange(panelNode.id, patch)}
onClose={() => setDialogNodeId(null)}
/>
) : (
<CanvasNodePromptPanel
node={panelNode}
isRunning={runningNodeId === panelNode.id}
mentionReferences={mentionReferencesByNodeId.get(panelNode.id) || EMPTY_RESOURCE_REFERENCES}
onPromptChange={handleNodePromptChange}
onConfigChange={handleConfigNodeChange}
onGenerate={handleGenerateNode}
onStop={confirmStopGeneration}
workspaceMode={workspaceMode}
onImageSettingsOpenChange={(open) => {
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 <CanvasCharacterReferenceNodeContent node={contentNode} />;
}
if (contentNode.metadata?.workflowKind === "styleboard" && !contentNode.metadata.content) {
return <CanvasStylePlaceholderNodeContent onChoose={() => setStylePickerOpen(true)} />;
}
if (contentNode.metadata?.workflowKind === "story_input") {
return <CanvasStoryInputNodeContent node={contentNode} onEdit={() => openStoryInput(contentNode.id)} />;
}
if (contentNode.type === CanvasNodeType.Script) {
const pipeline = deriveStoryboardPipelineProgress(contentNode, nodesRef.current, connectionsRef.current);
return (
<CanvasScriptNodeContent
node={contentNode}
batch={visibleGenerationBatch(contentNode)}
pipeline={pipeline}
scale={viewport.k}
mentionReferences={mentionReferencesByNodeId.get(contentNode.id) || EMPTY_RESOURCE_REFERENCES}
onOpen={() => 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 (
<CanvasDirectorNodePanel
node={contentNode}
scene={currentProject?.directorScenes?.find((scene) => 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 (
<CanvasConfigNodePanel
node={contentNode}
isRunning={runningNodeId === contentNode.id}
inputSummary={getInputSummary(configInputsById.get(contentNode.id) || [])}
onConfigChange={handleConfigNodeChange}
onComposerToggle={() => 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 ? (
<CanvasFreeformEmptyState onUpload={() => handleUploadRequest()} onAddText={() => createNode(CanvasNodeType.Text)} />
) : currentProject?.projectId ? (
<CanvasLinkedProjectEmptyState
projectName={linkedProjectQuery.data?.project.name || currentProject.title}
hasChapter={Boolean(linkedProjectQuery.data?.units.length)}
onAddFirstChapter={() => {
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)}
/>
) : (
<CanvasShortDramaEmptyState
onCreatePipeline={createShortDramaPipeline}
onOpenAgent={() => {
setCinematicAgentEntry(true);
setAgentMode("online");
openAgent("online");
}}
onUpload={() => handleUploadRequest()}
onAddText={() => createNode(CanvasNodeType.Text)}
onAddScript={() => createNode(CanvasNodeType.Script)}
/>
);
if (!projectLoaded) return <CanvasRefreshShell />;
return (
<>
<a
href="#canvas-main"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[var(--z-toast)] focus:rounded-md focus:border focus:bg-background focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg"
>
跳转到画布主内容
</a>
<main id="canvas-main" tabIndex={-1} className="flex h-full min-h-0 overflow-hidden outline-none" style={{ background: theme.canvas.background, color: theme.node.text }}>
{!focusMode && shortDramaEnabled && currentProject?.projectId ? (
<CanvasProjectSidebar projectId={currentProject.projectId} detail={linkedProjectQuery.data} onAddChapter={handleProjectChapterInsert} onLocateStyle={locateProjectStyleNode} onOpenAssets={() => openProjectAssets()} />
) : null}
<section className="relative min-w-0 flex-1 flex flex-col min-h-0 overflow-hidden">
{!focusMode ? (
<CanvasTopBar
title={currentProject?.title || "未命名画布"}
workspaceMode={workspaceMode}
onWorkspaceModeChange={setWorkspaceMode}
titleDraft={titleDraft}
isTitleEditing={titleEditing}
onTitleDraftChange={setTitleDraft}
onStartTitleEditing={startTitleEditing}
onFinishTitleEditing={finishTitleEditing}
onCancelTitleEditing={() => 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}
<CanvasNodeSearchModal
open={nodeSearchOpen}
nodes={nodes}
onClose={() => 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 ? (
<CanvasShortDramaGuide progress={shortDramaGuide.progress} collapsed={shortDramaGuide.collapsed} onToggle={shortDramaGuide.onToggle} onSkip={skipShortDramaGuide} onStepClick={activateShortDramaStep} />
) : null}
<CanvasShareModal projectId={projectId} open={shareModalOpen} onClose={() => setShareModalOpen(false)} beforeCreate={saveCanvasProject} />
<CanvasStylePickerModal open={stylePickerOpen} value={activeStylePresetId} applying={styleApplying} onClose={() => setStylePickerOpen(false)} onSelect={selectCanvasStyle} />
<div className="relative flex min-h-0 min-w-0 flex-1">
<div className="relative min-w-0 flex-1 overflow-hidden">
<InfiniteCanvas
containerRef={containerRef}
viewport={viewport}
backgroundMode={backgroundMode}
graphicsLayer={
<CanvasLeaferGraphicsLayer
containerRef={containerRef}
viewport={viewport}
theme={theme}
displayConnections={displayConnections}
selectedConnectionId={selectedConnectionId}
relatedConnectionIds={relatedHighlight.connectionIds}
scriptScrollTopById={scriptScrollTopById}
connectingParams={connectingParams}
batchConnectionPreview={batchConnectionPreview}
mouseWorld={mouseWorld}
connectionTargetNodeId={connectionTargetNodeId}
connectionTargetAnchorRatio={connectionTargetAnchorRatio}
nodeById={nodeById}
selectionBox={selectionBox}
selectedNodeBounds={selectedNodeBounds}
alignmentGuides={alignmentGuides}
/>
}
onViewportChange={handleViewportChange}
onViewportPreviewChange={handleViewportPreviewChange}
onCanvasMouseDown={handleCanvasMouseDown}
boxSelectEnabled={canvasTool === "box-select"}
onCanvasDoubleClick={handleCanvasDoubleClick}
onCanvasDeselect={deselectCanvas}
onContextMenu={handleCanvasContextMenu}
onDrop={handleDrop}
onFileDragEnter={handleFileDragEnter}
onFileDragLeave={handleFileDragLeave}
onFileDragOver={handleFileDragOver}
>
<CanvasProjectWorldLayers
projectId={projectId}
viewportScale={viewport.k}
connectionLayerBounds={connectionLayerBounds}
displayConnections={displayConnections}
selectedConnectionId={selectedConnectionId}
relatedConnectionIds={relatedHighlight.connectionIds}
scriptScrollTopById={scriptScrollTopById}
connectingParams={connectingParams}
mouseWorld={mouseWorld}
connectionTargetNodeId={connectionTargetNodeId}
nodeById={nodeById}
visibleNodes={visibleNodes}
frameChildrenById={frameChildrenById}
linkedFolderPreviewNodesById={linkedFolderPreviewNodesById}
dragPreview={dragPreview}
selectedNodeIds={selectedNodeIds}
frameDropTargetId={frameDropTargetId}
relatedNodeIds={relatedHighlight.nodeIds}
activeNodeId={activeNodeId}
selectionBox={selectionBox}
batchChildCountById={batchChildCountById}
collapsingBatchIds={collapsingBatchIds}
openingBatchIds={openingBatchIds}
batchMotionById={batchMotionById}
showImageInfo={showImageInfo}
reduceMediaEffects={reduceMediaEffects}
resourceReferenceByNodeId={resourceReferenceByNodeId}
mentionReferencesByNodeId={mentionReferencesByNodeId}
mediaEffectsDisabledNodeId={emotionNodeId}
selectedNodeBounds={selectedNodeBounds}
batchSourceNodeIds={batchSourceNodeIds}
batchConnectionPreview={batchConnectionPreview}
isNodeDragging={isNodeDragging}
selectionBoundsElementRef={selectionBoundsElementRef}
renderCanvasNodeContent={renderCanvasNodeContent}
onConnectionSelect={(connectionId) => {
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}
/>
</InfiniteCanvas>
<CanvasActiveTaskPanel tasks={activeTasks} topInset={focusMode ? "var(--space-3)" : "var(--canvas-topbar-offset)"} />
{focusMode ? (
<CanvasFocusModeBar
dockRevealed={focusDockRevealed}
agentOpen={assistantOpen}
zoomPercent={viewport.k}
onToggleDock={() => setFocusDockRevealed((value) => !value)}
onToggleAgent={() => (assistantOpen ? closeAgent() : openAgent())}
onExit={exitFocusMode}
onZoomIn={zoomCanvasIn}
onZoomOut={zoomCanvasOut}
onFit={resetViewport}
/>
) : null}
<CanvasFileDropOverlay active={fileDropActive} theme={theme} />
{emptyCanvasState}
{!focusMode || focusDockRevealed ? (
<CanvasToolbar
selectedCount={selectedNodeIds.size}
workspaceMode={workspaceMode}
canvasTool={canvasTool}
onToolChange={setCanvasTool}
isProjectLinked={Boolean(shortDramaEnabled && currentProject?.projectId)}
canUndo={historyState.canUndo}
canRedo={historyState.canRedo}
backgroundMode={backgroundMode}
showImageInfo={showImageInfo}
onAddImage={() => 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}
</div>
{assistantMounted ? (
<AssistantPanelColumn width={assistantWidth} closing={assistantClosing} topInset={focusMode ? "0px" : "var(--canvas-topbar-offset)"} onWidthChange={setAssistantWidth}>
{(resizing) => (
<CanvasAssistantPanel
nodes={nodes}
selectedNodeIds={selectedNodeIds}
snapshot={agentSnapshot}
projectId={projectId}
sessions={chatSessions}
activeSessionId={activeChatId}
onSelectNodeIds={setSelectedNodeIds}
onSessionsChange={handleAssistantSessionsChange}
onApplyOps={applyAgentOps}
canUndoOps={canUndoAgentOps}
undoOpsCount={agentUndoCount}
onUndoOps={undoAgentOps}
onPasteImage={pasteAssistantImage}
agentMode={agentMode}
onAgentModeChange={setAgentMode}
autoConnectLocal={codexAutoConnect}
closing={assistantClosing}
onCollapse={closeAgent}
cinematicEntry={cinematicAgentEntry}
onCinematicEntryConsumed={() => setCinematicAgentEntry(false)}
resizing={resizing}
/>
)}
</AssistantPanelColumn>
) : null}
</div>
{angleNode?.metadata?.content ? (
<CanvasNodePanelOverlay node={angleNode} viewport={viewport} containerRef={containerRef} panelWidth={580} panelHeight={350}>
<CanvasNodeAnglePanel
dataUrl={angleNode.metadata.content}
onClose={() => setAngleNodeId(null)}
onConfirm={(params) => {
void generateAngleNode(angleNode, params);
}}
/>
</CanvasNodePanelOverlay>
) : null}
{emotionNode?.metadata?.content ? (
<CanvasEmotionWorkspace
node={emotionNode}
viewport={viewport}
containerRef={containerRef}
onClose={() => setEmotionNodeId(null)}
onConfirm={(payload: CanvasImageEmotionPayload) => {
void generateEmotionNode(emotionNode, payload);
}}
/>
) : null}
{dialogNode && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox ? (
<CanvasNodePanelOverlay node={dialogNode} viewport={viewport} containerRef={containerRef}>
{renderCanvasNodePanel(dialogNode)}
</CanvasNodePanelOverlay>
) : null}
{pendingConnectionCreate ? (
<CanvasConnectionCreateMenu
pending={pendingConnectionCreate}
viewport={viewport}
viewportSize={size}
containerRef={containerRef}
canCreateDrawing={canCreateDrawingFromConnection}
getDisabledReason={(type) => getConnectionCreateDisabledReason(type, pendingConnectionCreate)}
onCreate={(type) => void createConnectedNode(type, pendingConnectionCreate)}
onClose={cancelPendingConnectionCreate}
/>
) : null}
{selectedNodeBounds && !selectionBox && !isNodeDragging ? (
<CanvasProjectSelectionToolbar
anchorRef={selectionBoundsElementRef}
containerRef={containerRef}
count={selectedNodeBounds.count}
selectedVideoCount={selectedVideoNodes.length}
mergingVideos={Boolean(mergeVideoProgress)}
onAlign={alignSelectedNodes}
onArrange={arrangeSelectedNodes}
onCreateStoryboard={createStoryboardGroup}
onCreateReferenceGroup={createReferenceGroup}
onBatchConnect={() => beginBatchConnectionMode(Array.from(selectedNodeIds))}
onMergeVideos={() => void mergeSelectedVideos()}
/>
) : null}
{uploadStatus ? <CanvasUploadStatusToast status={uploadStatus} theme={theme} /> : null}
{mergeVideoProgress ? <CanvasMergeStatusToast progress={mergeVideoProgress} theme={theme} /> : null}
{lastAgentChange ? (
<CanvasAgentChangeToast
change={lastAgentChange}
theme={theme}
onView={viewLastAgentChange}
onUndo={() => {
undoAgentOps();
}}
onClose={dismissLastAgentChange}
/>
) : null}
<CanvasNodeToolbar
node={isNodeDragging || nodeImageSettingsOpen || emotionNodeId ? null : toolbarNode}
workspaceMode={workspaceMode}
viewport={viewport}
containerRef={containerRef}
onKeep={keepNodeToolbar}
onLeave={hideNodeToolbar}
onInfo={(node) => (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 ? <Minimap nodes={nodes} viewport={viewport} viewportSize={size} canvasContainerRef={containerRef} onViewportPreviewChange={previewViewport} onViewportChange={handleViewportChange} /> : null}
{!focusMode ? (
<div
data-canvas-no-zoom
className="absolute bottom-[calc(var(--canvas-inset-y)+var(--space-16))] left-4 z-[var(--z-panel)] flex items-end gap-2 lg:bottom-[var(--canvas-inset-y)]"
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<CanvasZoomControls
scale={viewport.k}
containerRef={containerRef}
onScaleChange={setZoomScale}
onReset={resetViewport}
isMiniMapOpen={isMiniMapOpen}
onToggleMiniMap={() => setIsMiniMapOpen((value) => !value)}
onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)}
/>
<CanvasAssetTray
assetImages={imageAssets}
canvasImages={canvasImageNodes}
showLibrary={!currentProject?.projectId}
activeNodeId={selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null}
onInsertAssetImage={(asset) => void createImageAssetNode(asset)}
onFocusCanvasImage={focusCanvasImageNode}
/>
</div>
) : null}
<CanvasProjectContextMenu
menu={contextMenu}
node={contextMenuNode}
workspaceMode={workspaceMode}
isProjectLinked={Boolean(currentProject?.projectId)}
canUndo={historyState.canUndo}
canRedo={historyState.canRedo}
canPaste={hasCopiedNodes || Boolean(navigator.clipboard)}
screenToCanvas={screenToCanvas}
onClose={() => 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)}
/>
<CanvasUploadModal open={uploadModalOpen} onClose={closeUploadModal} onUpload={handleUploadFiles} />
<input ref={imageInputRef} type="file" accept="image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav" className="hidden" onChange={handleImageInputChange} />
<CanvasNodeInfoModal node={infoNode} open={Boolean(infoNode)} onClose={() => setInfoNodeId(null)} onMetadataChange={handleConfigNodeChange} />
{subtitleNode ? (
<CanvasSubtitleDialog
node={subtitleNode}
open={Boolean(subtitleNode)}
projectId={projectId}
config={effectiveConfig}
onClose={() => 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 ? (
<CanvasVideoSegmentDialog
node={segmentNode}
nodes={nodes}
connections={connections}
open={Boolean(segmentNode && segmentDialogMode)}
mode={segmentDialogMode}
config={effectiveConfig}
timeline={currentProject?.timeline || null}
onClose={closeSegmentDialog}
onConfirm={(params) => void handleSegmentConfirm(segmentNode, params)}
/>
) : null}
{timelineNode ? (
<CanvasTimelineDialog
node={timelineNode}
open={Boolean(timelineNode)}
nodes={nodes}
timeline={currentProject?.timeline || null}
onClose={() => 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}
<CanvasCharacterReferenceModal node={characterReferenceNode} open={Boolean(characterReferenceNode)} onClose={() => setCharacterReferenceNodeId(null)} />
<CanvasTextEditorModal
node={textEditorNode}
open={Boolean(textEditorNode)}
onClose={() => setTextEditorNodeId(null)}
onSave={(nodeId, title, content, richText) => {
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, title, metadata: { ...node.metadata, content, richText } } : node)));
}}
/>
{drawingNode ? (
<Suspense
fallback={
<div className="fixed inset-0 z-[var(--z-toast)] grid place-items-center px-5" style={{ background: theme.canvas.background, color: theme.node.text }}>
<WorkspaceState icon="loading" title="正在加载绘图编辑器" description="正在准备绘图画布。" />
</div>
}
>
<CanvasDrawingEditorModal
node={drawingNode}
projectId={projectId}
open={Boolean(drawingNode)}
onClose={() => 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("绘图已保存");
}}
/>
</Suspense>
) : null}
<CanvasScriptEditor
node={activeScriptNode}
open={Boolean(activeScriptNode)}
onClose={() => 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 ? (
<Suspense
fallback={
<div className="fixed inset-0 z-[var(--z-toast)] grid place-items-center px-5" style={{ background: theme.canvas.background, color: theme.node.text }}>
<WorkspaceState icon="loading" title="正在加载 3D 导演台" description="准备场景、镜头与空间控制。" />
</div>
}
>
<CanvasDirectorWorkbench
open
scene={activeDirectorScene}
imageNodes={nodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(node.metadata?.content))}
onClose={() => setDirectorNodeId(null)}
onChange={saveDirectorScene}
onApply={applyDirectorOutput}
/>
</Suspense>
) : null}
<CanvasVersionCompareModal
open={Boolean(versionCompareRootId)}
versions={versionCompareNodes}
onClose={() => setVersionCompareRootId(null)}
onSetPrimary={setPrimaryVersion}
onFocus={(nodeId) => {
setVersionCompareRootId(null);
focusCanvasNode(nodeId);
}}
/>
<CanvasProjectMediaDialogs
cropNode={cropNode}
annotationNode={annotationNode}
maskEditNode={maskEditNode}
splitNode={splitNode}
upscaleNode={upscaleNode}
onCloseCrop={() => 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)}
/>
<CanvasProjectStatusDialogs
theme={theme}
task={taskDetail}
taskLogs={taskDetailLogs}
taskLoading={taskDetailLoading}
onCloseTask={() => setTaskDetail(null)}
superResolveNode={superResolveNode}
onCloseSuperResolve={() => setSuperResolveNodeId(null)}
previewNode={previewNode}
onClosePreview={() => setPreviewNodeId(null)}
clearConfirmOpen={clearConfirmOpen}
onCancelClear={() => setClearConfirmOpen(false)}
onConfirmClear={clearCanvas}
/>
<AssetPickerModal open={assetPickerOpen} onInsert={handleTimelineAssetInsert} onClose={closeAssetPicker} />
<CanvasProjectAssetModal open={projectAssetOpen} detail={linkedProjectQuery.data} initialCategory={projectAssetInitialCategory} initialFolderId={projectAssetInitialFolderId} onClose={closeProjectAssets} onInsert={handleTimelineProjectAssetsInsert} onInsertFolder={projectAssetScope === "canvas" ? handleProjectFolderInsert : undefined} />
{codexCompactAgent && !assistantMounted ? (
<CanvasLocalAgentPanel headless snapshot={agentSnapshot} canUndoOps={canUndoAgentOps} undoOpsCount={agentUndoCount} onApplyOps={applyAgentOps} onUndoOps={undoAgentOps} autoConnect={codexAutoConnect} />
) : null}
</section>
</main>
</>
);
}