3364 lines
182 KiB
TypeScript
3364 lines
182 KiB
TypeScript
import { CanvasWorkspacePanel } from "@/components/canvas/canvas-workspace-panel";
|
|
import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
|
|
import { createCanvasStateWriter } from "@/lib/canvas/canvas-editor-state";
|
|
import { canCancelGenerationTask } from "@/lib/generation-task-display";
|
|
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import type { MouseEvent as ReactMouseEvent } from "react";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Link, useParams, useSearchParams } from "react-router";
|
|
import { loadAssetsForUse } from "@/services/user-data-sync";
|
|
import { canvasAssetHandoffIds } from "@/lib/canvas/canvas-asset-handoff";
|
|
import { useEffectiveConfig } from "@/stores/use-config-store";
|
|
import { createCanvasGenerationLiveProjectAdapter, registerCanvasGenerationLiveProject } from "@/services/canvas-generation-consumer";
|
|
import { getActiveUserScope } from "@/lib/user-scope";
|
|
import { getResourceAccess, resolveResourceAccessURL, resourceFileUrl, resourceIdFromStorageKey, syncResourceToArkPrivateAsset } from "@/services/api/resources";
|
|
import { uploadImage } from "@/services/image-storage";
|
|
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
|
|
import { isCanvasImageSourceNode } from "@/lib/canvas/canvas-image-source";
|
|
import copyToClipboard from "copy-to-clipboard";
|
|
import { nanoid } from "nanoid";
|
|
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, writeCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
|
|
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
|
|
import { persistCanvasMediaPerformanceMode, readCanvasMediaPerformanceMode } from "@/lib/canvas/canvas-performance-mode";
|
|
import { filterCanvasDisplayConnections, persistCanvasHideNodeConnections, readCanvasHideNodeConnections } from "@/lib/canvas/canvas-connection-visibility";
|
|
import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary";
|
|
import { refreshCanvasCharacterReferenceNodes } from "@/lib/canvas/canvas-character-reference";
|
|
import { useAssetStore } from "@/stores/use-asset-store";
|
|
import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store";
|
|
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
|
|
import { useCanvasThemeStore, useCanvasThemeScope } from "@/stores/canvas/use-canvas-theme-store";
|
|
import { useUserStore } from "@/stores/use-user-store";
|
|
import { App, Button } from "antd";
|
|
import { ArrowLeftRight } from "lucide-react";
|
|
import { AppModal } from "@/components/ui/product/app-modal";
|
|
import { getNodeSpec } from "@/constant/canvas";
|
|
import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer";
|
|
import { CanvasConfigNodePanel } from "@/components/canvas/canvas-config-node-panel";
|
|
import { CanvasCloudAgentPanel } from "@/components/canvas/canvas-cloud-agent-panel";
|
|
import { CanvasActiveTaskPanel } from "@/components/canvas/canvas-active-task-panel";
|
|
import { CanvasProjectSidebar } from "@/components/canvas/canvas-project-sidebar";
|
|
import { CanvasProjectAssetModal } from "@/components/canvas/canvas-project-asset-modal";
|
|
import { CanvasCharacterLibraryModal } from "@/components/canvas/canvas-character-library-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 { CanvasVideoFrameDialog } from "@/components/canvas/canvas-video-frame-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 { CanvasNodeAnglePanel } from "@/components/canvas/canvas-node-angle-dialog";
|
|
import { CanvasNodeLightingPanel } from "@/components/canvas/canvas-node-lighting-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 { CanvasPrevisTemplateModal } from "@/components/canvas/previs/canvas-previs-template-modal";
|
|
import { CanvasFileDropOverlay } from "@/components/canvas/canvas-file-drop-overlay";
|
|
import { CanvasUploadModal } from "@/components/canvas/canvas-upload-modal";
|
|
import { CanvasPanoramaConfigModal } from "@/components/canvas/canvas-panorama-config-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 { handleListGenerate } from "./list-mode-generator";
|
|
import { CanvasToolbar } from "@/components/canvas/canvas-toolbar";
|
|
import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands";
|
|
import { AssetPickerModal } from "@/components/canvas/asset-picker-modal";
|
|
import { getProject, listCharacters, type ProjectAsset } from "@/services/api/projects";
|
|
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
|
|
import { CanvasShareModal } from "@/components/canvas/canvas-share-modal";
|
|
import { CanvasScriptEditor, CanvasScriptNodeContent } from "@/components/canvas/canvas-script-node";
|
|
import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node";
|
|
import { BatchGenerationSettingsDialog } from "@/components/canvas/batch-generation-settings-dialog";
|
|
import { batchReferenceColumns, promoteLegacyBatchTableSize } from "@/lib/canvas/canvas-batch-table";
|
|
import { storyboardMinNodeHeight } from "@/lib/canvas/canvas-storyboard-layout";
|
|
import { CanvasPrevisNodePanel } from "@/components/canvas/previs/canvas-previs-node-panel";
|
|
import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal";
|
|
import { useFocusMode } from "@/hooks/use-focus-mode";
|
|
import { connectCanvasTextMention } from "@/lib/canvas/canvas-text-mention";
|
|
import { writeCanvasNodePrompt } from "@/lib/canvas/canvas-node-prompt";
|
|
import {
|
|
applyCanvasConnectionPromptSync,
|
|
buildCanvasAgentMentionReferences,
|
|
canvasResourceMentionToken,
|
|
buildCanvasNodeMentionReferenceMap,
|
|
buildCanvasResourceReferences,
|
|
getContextResourceNodes,
|
|
normalizeCanvasNodeMentionTokens,
|
|
reorderCanvasResourceConnections,
|
|
replaceCanvasReferenceMentions,
|
|
type CanvasResourceReference,
|
|
} from "@/lib/canvas/canvas-resource-references";
|
|
import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays";
|
|
import { CanvasOverlayLayerContainer, CanvasOverlayLayerProvider } from "@/components/canvas/canvas-overlay-layer";
|
|
import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer";
|
|
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
|
|
import { resolveCanvasEmptyStateKind } from "@/lib/canvas/canvas-starter";
|
|
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
|
|
import { cinematicStoryboardColumns, createCanvasNode, getInputSummary, isHiddenBatchChild } from "@/lib/canvas/canvas-project-domain";
|
|
import { bindStoryboardKeyframes, storyboardKeyframeTime, storyboardRowsFromBatchTable } from "@/lib/canvas/canvas-batch-storyboard";
|
|
import { stampCanvasNodeChanges, updateCanvasNode, updateCanvasNodes } from "@/lib/canvas/canvas-node-timestamps";
|
|
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 { CanvasOperationChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
|
|
import { getGenerationCount } from "@/lib/canvas/canvas-project-generation";
|
|
import { cancelGenerationTask } from "@/services/api/task-center";
|
|
import { CanvasSyncStatus } from "./canvas-sync-status";
|
|
import { CanvasVersionHistory, useCanvasVersionHistory } from "./canvas-version-history";
|
|
import { CanvasVersionPreview } from "./canvas-version-preview";
|
|
import { CanvasTopBar } from "./canvas-project-top-bar";
|
|
import { LibTVImportDialog } from "./components/libtv-import-dialog";
|
|
import { TapNowImportDialog } from "./components/tapnow-import-dialog";
|
|
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 { CanvasNodeActionContext, type CanvasNodeActionContextValue } from "@/components/canvas/canvas-node-action-context";
|
|
import { bringCanvasNodeToFront, type CanvasNodeStackOrder } from "@/lib/canvas/canvas-node-stack-order";
|
|
import { AiArtCritiqueModal } from "@/components/canvas/art-critique/ai-art-critique-modal";
|
|
import { CanvasNodeGraphContext, type CanvasNodeGraphContextValue } from "@/components/canvas/canvas-node-graph-context";
|
|
import { CanvasRefreshShell } from "./canvas-refresh-shell";
|
|
import { queryGenerationTask } from "@/services/api/task-center";
|
|
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 { useCanvasOperationHistory } from "./use-canvas-operation-history";
|
|
import { useCanvasAssistantVisibility } from "./use-canvas-assistant-visibility";
|
|
import { useCanvasActiveTasks } from "./use-canvas-active-tasks";
|
|
import { useCanvasStyleWorkflow } from "./use-canvas-style-workflow";
|
|
import { useCanvasPrevis } from "./use-canvas-previs";
|
|
import { useCanvasGeneration } from "./use-canvas-generation";
|
|
import { useCanvasGenerationBatches } from "./use-canvas-generation-batches";
|
|
import { useCanvasBatchTable } from "./use-canvas-batch-table";
|
|
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 { useCanvasTimelineAssetInsert } from "./use-canvas-timeline-asset-insert";
|
|
import { useCanvasViewportController } from "./use-canvas-viewport-controller";
|
|
import {
|
|
CanvasNodeType,
|
|
type CanvasAssistantSession,
|
|
type CanvasConnection,
|
|
type CanvasFolderStyle,
|
|
type CanvasFolderTheme,
|
|
type CanvasNodeData,
|
|
type CanvasNodeMetadata,
|
|
type CanvasMediaPerformanceMode,
|
|
type StoryboardColumn,
|
|
type StoryboardShotCount,
|
|
type StoryboardShotDuration,
|
|
type CanvasWorkspaceMode,
|
|
type CanvasToolMode,
|
|
type ContextMenuState,
|
|
type Position,
|
|
type ViewportTransform,
|
|
} from "@/types/canvas";
|
|
import { ART_CRITIQUE_NODE_TYPE } from "@/lib/art-critique/contracts";
|
|
import { copyImageToSystemClipboard } from "./canvas-clipboard";
|
|
|
|
const CanvasPrevisWorkbench = lazy(() => import("@/components/canvas/previs/canvas-previs-workbench").then((module) => ({ default: module.CanvasPrevisWorkbench })));
|
|
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() {
|
|
useCanvasThemeScope();
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
if (!mounted) return <CanvasRefreshShell />;
|
|
|
|
return <InfiniteCanvasPage />;
|
|
}
|
|
|
|
function InfiniteCanvasPage() {
|
|
// 命令式确认必须走 App.useApp().modal;静态 Modal.confirm 拿不到主题和 App 上下文。
|
|
const { message, modal } = App.useApp();
|
|
const queryClient = useQueryClient();
|
|
const params = useParams<{ id: string }>();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const projectId = params.id || "";
|
|
const canvasStorageScope = getActiveUserScope();
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const didInitialCenterRef = useRef(false);
|
|
const toolbarHideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const assetHandoffRef = useRef("");
|
|
|
|
const effectiveConfig = useEffectiveConfig();
|
|
const assets = useAssetStore((state) => state.assets);
|
|
const assetsHydrated = useAssetStore((state) => state.hydrated);
|
|
const cleanupAssetImages = useAssetStore((state) => state.cleanupImages);
|
|
const colorTheme = useCanvasThemeStore((state) => state.theme);
|
|
const setTheme = useCanvasThemeStore((state) => state.setTheme);
|
|
const theme = canvasThemes[colorTheme];
|
|
const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine);
|
|
const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled);
|
|
const previsOnboardingScope = useUserStore((state) => state.user?.id?.trim() || "");
|
|
const nodesRef = useRef<CanvasNodeData[]>([]);
|
|
const [nodes, setNodesState] = useState<CanvasNodeData[]>([]);
|
|
const setNodes = useMemo(() => createCanvasStateWriter(nodesRef, setNodesState, stampCanvasNodeChanges), []);
|
|
const [nodeStackOrder, setNodeStackOrder] = useState<CanvasNodeStackOrder>([]);
|
|
const bringNodeToFront = useCallback((nodeId: string) => {
|
|
setNodeStackOrder((current) => bringCanvasNodeToFront(current, nodeId));
|
|
}, []);
|
|
const [connections, setConnectionsState] = useState<CanvasConnection[]>([]);
|
|
const connectionsRef = useRef<CanvasConnection[]>([]);
|
|
const setConnections = useMemo(() => createCanvasStateWriter(connectionsRef, setConnectionsState), []);
|
|
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);
|
|
// 每次发送带递增 id:重复发送同一节点时文本相同,仍需触发一次追加。
|
|
const [agentPrefillRequest, setAgentPrefillRequest] = useState<{ id: number; text: string } | null>(null);
|
|
const [isMiniMapOpen, setIsMiniMapOpen] = useState(false);
|
|
const [canvasAppearance, setCanvasAppearance] = useState<CanvasAppearance>(() => canvasAppearanceForTheme(colorTheme));
|
|
const [backgroundMode, setBackgroundMode] = useState<CanvasBackgroundMode>(DEFAULT_CANVAS_BACKGROUND_MODE);
|
|
const [showImageInfo, setShowImageInfo] = useState(false);
|
|
const [canvasTool, setCanvasTool] = useState<CanvasToolMode>("box-select");
|
|
const [mediaPerformanceMode, setMediaPerformanceMode] = useState<CanvasMediaPerformanceMode>(readCanvasMediaPerformanceMode);
|
|
const [hideNodeConnections, setHideNodeConnections] = useState(readCanvasHideNodeConnections);
|
|
const [projectLoaded, setProjectLoaded] = useState(false);
|
|
const workspaceMode: CanvasWorkspaceMode = "professional";
|
|
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
|
const [shareModalOpen, setShareModalOpen] = useState(false);
|
|
const [tapNowImportOpen, setTapNowImportOpen] = useState(false);
|
|
const [nodeSearchOpen, setNodeSearchOpen] = useState(false);
|
|
const [toolbarNodeId, setToolbarNodeId] = useState<string | null>(null);
|
|
const [arkPrivateAssetUploadNodeId, setArkPrivateAssetUploadNodeId] = 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 [characterLibraryOpen, setCharacterLibraryOpen] = useState(false);
|
|
const [characterLibraryPosition, setCharacterLibraryPosition] = useState<Position | undefined>();
|
|
const [drawingNodeId, setDrawingNodeId] = useState<string | null>(null);
|
|
const [stylePickerOpen, setStylePickerOpen] = useState(false);
|
|
// 新建预演台镜头必须先选模板:null 表示未在选择中,undefined position 表示用画布中心。
|
|
const [previsTemplateRequest, setPrevisTemplateRequest] = useState<{ position?: Position } | null>(null);
|
|
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 [artCritiqueNodeId, setArtCritiqueNodeId] = useState<string | null>(null);
|
|
const artCritiqueRunningRef = useRef(false);
|
|
const [artCritiqueStartRequest, setArtCritiqueStartRequest] = useState<{ nodeId: string; id: string; restart: boolean } | null>(null);
|
|
const [scriptScrollTopById, setScriptScrollTopById] = useState<Record<string, number>>({});
|
|
const [previsNodeId, setPrevisNodeId] = useState<string | null>(null);
|
|
const [versionCompareRootId, setVersionCompareRootId] = useState<string | null>(null);
|
|
const [libTVImportOpen, setLibTVImportOpen] = useState(false);
|
|
const [titleEditing, setTitleEditing] = useState(false);
|
|
const [titleDraft, setTitleDraft] = useState("");
|
|
const [shortcutRequestNonce, setShortcutRequestNonce] = useState(0);
|
|
const [cinematicAgentEntry, setCinematicAgentEntry] = useState(false);
|
|
const { assistantOpen, closeAgent, openAgent: openAssistant } = useCanvasAssistantVisibility();
|
|
const agentMentionReferences = useMemo(() => buildCanvasAgentMentionReferences(nodes), [nodes]);
|
|
|
|
const { tasks: activeTasks } = useCanvasActiveTasks(projectId, projectLoaded);
|
|
const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode();
|
|
const [focusDockRevealed, setFocusDockRevealed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
persistCanvasMediaPerformanceMode(mediaPerformanceMode);
|
|
}, [mediaPerformanceMode]);
|
|
|
|
useEffect(() => {
|
|
persistCanvasHideNodeConnections(hideNodeConnections);
|
|
}, [hideNodeConnections]);
|
|
|
|
useEffect(() => {
|
|
didInitialCenterRef.current = false;
|
|
setNodeStackOrder([]);
|
|
}, [projectId]);
|
|
|
|
useEffect(() => {
|
|
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
setNodeStackOrder((current) => {
|
|
const next = current.filter((nodeId) => nodeIds.has(nodeId));
|
|
return next.length === current.length ? current : next;
|
|
});
|
|
}, [nodes]);
|
|
|
|
const chatSessionsRef = useRef(chatSessions);
|
|
const activeChatIdRef = useRef(activeChatId);
|
|
const selectedNodeIdsRef = useRef(selectedNodeIds);
|
|
const viewportRef = useRef(viewport);
|
|
const [workspaceOpen, setWorkspaceOpen] = useState(false);
|
|
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 resolvedCanvasAppearance = useMemo(() => resolveCanvasAppearance(canvasAppearance, colorTheme), [canvasAppearance, colorTheme]);
|
|
const applyCanvasAppearance = useCallback(
|
|
(next: CanvasAppearance) => {
|
|
const fallback = canvasAppearanceBaseTheme(next, colorTheme);
|
|
const normalized = normalizeCanvasAppearance(next, fallback);
|
|
setCanvasAppearance(normalized);
|
|
setTheme(canvasAppearanceBaseTheme(normalized, fallback));
|
|
},
|
|
[colorTheme, setTheme],
|
|
);
|
|
const saveCanvasAppearanceDefault = useCallback(
|
|
(next: CanvasAppearance) => {
|
|
writeCanvasAppearanceDefault({ appearance: next, backgroundMode });
|
|
message.success("已保存为当前账号在本机的新建画布默认外观");
|
|
},
|
|
[backgroundMode, message],
|
|
);
|
|
|
|
const { getHistoryCleanupContext, historyPausedRef, historyState, redoCanvas, resetHistory, undoCanvas } = useCanvasHistory({
|
|
projectLoaded,
|
|
nodes,
|
|
connections,
|
|
chatSessions,
|
|
activeChatId,
|
|
canvasAppearance,
|
|
backgroundMode,
|
|
showImageInfo,
|
|
setNodes,
|
|
setConnections,
|
|
setChatSessions,
|
|
setActiveChatId,
|
|
applyCanvasAppearance,
|
|
setBackgroundMode,
|
|
setShowImageInfo,
|
|
setSelectedNodeIds,
|
|
setSelectedConnectionId,
|
|
setContextMenu,
|
|
});
|
|
|
|
const cleanupCanvasFiles = useCallback(
|
|
(extra?: unknown) => {
|
|
cleanupAssetImages({ extra, ...getHistoryCleanupContext() });
|
|
},
|
|
[cleanupAssetImages, getHistoryCleanupContext],
|
|
);
|
|
|
|
const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, reloadLatestCanvasProject, restoreCanvasProjectVersion, saveCanvasProject, forceSaveCanvasProject, updateProject } = useCanvasProjectLifecycle({
|
|
projectId,
|
|
projectLoaded,
|
|
nodes,
|
|
connections,
|
|
chatSessions,
|
|
activeChatId,
|
|
canvasAppearance,
|
|
backgroundMode,
|
|
showImageInfo,
|
|
viewport,
|
|
nodesRef,
|
|
connectionsRef,
|
|
chatSessionsRef,
|
|
activeChatIdRef,
|
|
viewportRef,
|
|
historyPausedRef,
|
|
setNodes,
|
|
setConnections,
|
|
setChatSessions,
|
|
setActiveChatId,
|
|
setCanvasAppearance,
|
|
setBackgroundMode,
|
|
setShowImageInfo,
|
|
setViewport,
|
|
setProjectLoaded,
|
|
resetHistory,
|
|
cleanupAssetImages,
|
|
cleanupCanvasFiles,
|
|
});
|
|
|
|
const versions = useCanvasVersionHistory(projectId, restoreCanvasProjectVersion);
|
|
const openVersions = () => { closeAgent(); setVersionCompareRootId(null); versions.show(); };
|
|
const openAgent = useCallback(() => { versions.close(); openAssistant(); }, [versions.close, openAssistant]);
|
|
|
|
const sendSelectionToAgent = useCallback((nodeId?: string) => {
|
|
const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current);
|
|
const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference));
|
|
if (!references.length) return;
|
|
if (nodeId && !selectedNodeIdsRef.current.has(nodeId)) {
|
|
const selection = new Set([nodeId]);
|
|
selectedNodeIdsRef.current = selection;
|
|
setSelectedNodeIds(selection);
|
|
}
|
|
const text = `${references.map(canvasResourceMentionToken).join(" ")} `;
|
|
setAgentPrefillRequest((current) => ({ id: (current?.id ?? 0) + 1, text }));
|
|
openAgent();
|
|
setContextMenu(null);
|
|
}, [agentMentionReferences, openAgent]);
|
|
// 修复素材关联仍遵守当前画布版本,不能替用户确认覆盖云端的新内容。
|
|
const confirmForceSaveCanvas = useCallback(() => {
|
|
modal.confirm({
|
|
title: "修复素材关联并保存?",
|
|
content: "核对画布媒体与素材库的关联,补齐缺失素材后保存。若云端已有新版本,会保留本地草稿并提示加载最新版。",
|
|
okText: "修复并保存",
|
|
cancelText: "取消",
|
|
onOk: () => forceSaveCanvasProject(),
|
|
});
|
|
}, [forceSaveCanvasProject, modal]);
|
|
|
|
const applyLibTVImport = useCallback(
|
|
async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => {
|
|
const previousNodes = nodesRef.current;
|
|
const previousConnections = connectionsRef.current;
|
|
const nextNodes = [...nodesRef.current, ...importedNodes];
|
|
const nextConnections = [...connectionsRef.current, ...importedConnections];
|
|
nodesRef.current = nextNodes;
|
|
connectionsRef.current = nextConnections;
|
|
setNodes(nextNodes);
|
|
setConnections(nextConnections);
|
|
const saved = await saveCanvasProject({ requireRemote: false });
|
|
if (!saved) {
|
|
nodesRef.current = previousNodes;
|
|
connectionsRef.current = previousConnections;
|
|
setNodes(previousNodes);
|
|
setConnections(previousConnections);
|
|
throw new Error("画布保存失败,已撤销本次 LibTV 导入");
|
|
}
|
|
},
|
|
[saveCanvasProject, setConnections, setNodes],
|
|
);
|
|
const applyTapNowImport = useCallback(
|
|
async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => {
|
|
const previousNodes = nodesRef.current;
|
|
const previousConnections = connectionsRef.current;
|
|
const nextNodes = [...nodesRef.current, ...importedNodes];
|
|
const nextConnections = [...connectionsRef.current, ...importedConnections];
|
|
nodesRef.current = nextNodes;
|
|
connectionsRef.current = nextConnections;
|
|
setNodes(nextNodes);
|
|
setConnections(nextConnections);
|
|
const saved = await saveCanvasProject({ requireRemote: false });
|
|
if (!saved) {
|
|
nodesRef.current = previousNodes;
|
|
connectionsRef.current = previousConnections;
|
|
setNodes(previousNodes);
|
|
setConnections(previousConnections);
|
|
throw new Error("画布保存失败,已撤销本次 TapNow 导入");
|
|
}
|
|
},
|
|
[saveCanvasProject, setConnections, setNodes],
|
|
);
|
|
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 canvasCharacterIds = useMemo(() => {
|
|
const ids = new Set<string>();
|
|
for (const node of nodes) {
|
|
const assetId = node.metadata?.workflowKind === "character" ? node.metadata.characterAssetId?.trim() : "";
|
|
if (assetId) ids.add(assetId);
|
|
}
|
|
return [...ids].sort();
|
|
}, [nodes]);
|
|
const characterCardsQuery = useQuery({ queryKey: ["characters", "canvas-refresh", canvasCharacterIds], queryFn: () => listCharacters({ ids: canvasCharacterIds }), enabled: projectLoaded && canvasCharacterIds.length > 0 });
|
|
useEffect(() => {
|
|
if (!projectLoaded || !characterCardsQuery.data) return;
|
|
const assets = characterCardsQuery.data.characters.map((item) => ({ ...item.asset, character: item.character })) as ProjectAsset[];
|
|
setNodes((current) => refreshCanvasCharacterReferenceNodes(current, assets));
|
|
}, [characterCardsQuery.data, projectLoaded, setNodes]);
|
|
const canvasContext = useMemo(() => summarizeCanvasContext(nodes, selectedNodeIds, linkedProjectQuery.data?.units), [linkedProjectQuery.data?.units, nodes, selectedNodeIds]);
|
|
// 扩展节点(对比/图表/调色)要读自己的上游才能渲染,经 Context 下发;
|
|
// 取上游复用 canvas-resource-references 的实现,别在这里另写一份。必须 memo——
|
|
// 每帧新对象会让所有节点跟着重渲染,错题本里多条崩溃都出在画布高频更新。
|
|
const nodeGraphContext = useMemo<CanvasNodeGraphContextValue>(() => ({ getUpstreamNodes: (nodeId: string) => getContextResourceNodes(nodeId, nodes, connections) }), [connections, nodes]);
|
|
|
|
const { applyGenerationTaskResult, bindGenerationTask, finishGenerationRequest, openNodeTaskDetails, runningNodeId, setRunningNodeId, setTaskDetail, startGenerationRequest, taskDetail, taskDetailLoading, taskDetailLogs } = useCanvasGeneration({
|
|
projectId,
|
|
domainProjectId: linkedProjectId,
|
|
projectLoaded,
|
|
nodes,
|
|
nodesRef,
|
|
setNodes,
|
|
});
|
|
|
|
const cancelCanvasTask = useCallback(
|
|
(task: import("@/services/api/task-center").GenerationTask) => {
|
|
if (!canCancelGenerationTask(task)) {
|
|
message.info("第三方请求已提交,任务将继续创作,不能取消");
|
|
return;
|
|
}
|
|
modal.confirm({
|
|
title: "取消生成任务?",
|
|
content: "任务会立即停止本地执行;如果已经提交到上游,系统会继续核对取消结果和积分状态。",
|
|
okText: "取消任务",
|
|
okButtonProps: { danger: true },
|
|
cancelText: "继续等待",
|
|
onOk: async () => {
|
|
const latestTask = taskDetail?.id === task.id ? taskDetail : task;
|
|
if (!canCancelGenerationTask(latestTask)) {
|
|
message.info("第三方请求已提交,任务将继续创作,不能取消");
|
|
return;
|
|
}
|
|
try {
|
|
const next = await cancelGenerationTask(task.id);
|
|
const node = nodesRef.current.find((item) => item.metadata?.taskId === task.id);
|
|
if (node) bindGenerationTask(node.id, next);
|
|
setTaskDetail((current) => (current?.id === task.id ? next : current));
|
|
await queryClient.invalidateQueries({ queryKey: ["canvas-active-tasks", projectId] });
|
|
message.success("任务已取消");
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : "取消任务失败");
|
|
}
|
|
},
|
|
});
|
|
},
|
|
[bindGenerationTask, message, modal, nodesRef, projectId, queryClient, setTaskDetail, taskDetail],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const sessionId = searchParams.get("conversation");
|
|
if (!projectLoaded || !sessionId) return;
|
|
if (!chatSessions.some((session) => session.id === sessionId)) {
|
|
message.warning("未找到要接续的会话,请从首页重新进入。");
|
|
} else {
|
|
activeChatIdRef.current = sessionId;
|
|
setActiveChatId(sessionId);
|
|
openAgent();
|
|
}
|
|
const next = new URLSearchParams(searchParams);
|
|
next.delete("conversation");
|
|
setSearchParams(next, { replace: true });
|
|
}, [projectLoaded, chatSessions, searchParams, setSearchParams, openAgent, message]);
|
|
|
|
useEffect(() => {
|
|
if (!projectLoaded || searchParams.get("agent") !== "1") return;
|
|
openAgent();
|
|
const next = new URLSearchParams(searchParams);
|
|
next.delete("agent");
|
|
setSearchParams(next, { replace: true });
|
|
}, [projectLoaded, searchParams, setSearchParams, openAgent]);
|
|
|
|
// 沉浸专注进入时收起智能体与小地图、重置 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(() => {
|
|
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,
|
|
screenToCanvas,
|
|
setZoomScale,
|
|
zoomCanvasIn,
|
|
zoomCanvasOut,
|
|
zoomToActualSize,
|
|
} = useCanvasViewportController({
|
|
agentCreatedNodes,
|
|
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,
|
|
fileDropActive,
|
|
handleAssetsInsert,
|
|
handleDrop,
|
|
handleFileDragEnter,
|
|
handleFileDragLeave,
|
|
handleFileDragOver,
|
|
handleImageInputChange,
|
|
handleProjectAssetsInsert,
|
|
handleProjectChapterInsert,
|
|
handleUploadFiles,
|
|
handleUploadRequest,
|
|
imageInputRef,
|
|
openAssetsAtPosition,
|
|
pasteSystemClipboard,
|
|
replaceNodeMedia,
|
|
createFileNode,
|
|
startUploadStatus,
|
|
uploadModalOpen,
|
|
uploadTimelineMedia,
|
|
uploadStatus,
|
|
} = useCanvasUpload({
|
|
canvasId: projectId,
|
|
domainProjectId: linkedProjectId,
|
|
nodesRef,
|
|
selectedNodeIdsRef,
|
|
getCanvasCenter,
|
|
screenToCanvas,
|
|
setNodes,
|
|
setSelectedNodeIds,
|
|
setSelectedConnectionId,
|
|
setContextMenu,
|
|
setDialogNodeId,
|
|
});
|
|
const replaceCanvasNodeMedia = useCallback((node: CanvasNodeData) => handleUploadRequest(node.id), [handleUploadRequest]);
|
|
const {
|
|
timelineAddNodeRef,
|
|
timelineMediaAddRef,
|
|
assetInsertScope,
|
|
projectAssetScope,
|
|
projectAssetOpen,
|
|
projectAssetInitialCategory,
|
|
projectAssetInitialFolderId,
|
|
projectAssetInsertPosition,
|
|
handleLibraryAssetsInsert,
|
|
handleTimelineProjectAssetsInsert,
|
|
openProjectAssets,
|
|
openCanvasAssetLibrary,
|
|
openTimelineAssetLibrary,
|
|
closeProjectAssets,
|
|
} = useCanvasTimelineAssetInsert({
|
|
linkedProjectId,
|
|
refetchLinkedProject,
|
|
handleAssetsInsert,
|
|
handleProjectAssetsInsert,
|
|
openAssetsAtPosition,
|
|
});
|
|
const openCharacterLibrary = useCallback((position?: Position) => {
|
|
setCharacterLibraryPosition(position);
|
|
setCharacterLibraryOpen(true);
|
|
setContextMenu(null);
|
|
}, []);
|
|
const selectedCharacterImage = nodes.find((node) => selectedNodeIds.has(node.id) && node.type === CanvasNodeType.Image && resourceIdFromStorageKey(node.metadata?.storageKey));
|
|
const selectedCharacterAudio = nodes.find((node) => selectedNodeIds.has(node.id) && node.type === CanvasNodeType.Audio && resourceIdFromStorageKey(node.metadata?.storageKey));
|
|
|
|
useEffect(() => {
|
|
if (!projectLoaded || searchParams.get("mode") !== "handoff") return;
|
|
void loadAssetsForUse(canvasAssetHandoffIds(searchParams)).catch((error) => message.error(error instanceof Error ? error.message : "转入素材读取失败"));
|
|
}, [projectLoaded, searchParams, message]);
|
|
|
|
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]);
|
|
|
|
const {
|
|
angleNodeId,
|
|
lightingNodeId,
|
|
emotionNodeId,
|
|
annotationNodeId,
|
|
annotationEditNodeId,
|
|
createImageReversePromptNodes,
|
|
openPortraitTextureEditor,
|
|
cropImageNode,
|
|
cropNodeId,
|
|
closeFrameDialog,
|
|
closeSegmentDialog,
|
|
extractAudioFromVideo,
|
|
extractVideoFrames,
|
|
extractingVideoFramesNodeId,
|
|
frameDialogNodeId,
|
|
generateNineGridNode,
|
|
generateAngleNode,
|
|
generateLightingNode,
|
|
openPanoramaConfig,
|
|
createPanoramaViewerWithConfig,
|
|
addPanoramaCaptureNode,
|
|
panoramaConfigNodeId,
|
|
setPanoramaConfigNodeId,
|
|
generateEmotionNode,
|
|
handleSegmentConfirm,
|
|
maskEditImageNode,
|
|
maskEditNodeId,
|
|
imageEditNodeId,
|
|
imageEditPreset,
|
|
layerDecompositionNodeId,
|
|
textEditNodeId,
|
|
mergeSelectedVideos,
|
|
mergeVideosByIds,
|
|
mergeVideoProgress,
|
|
saveAnnotatedImageNode,
|
|
segmentDialogMode,
|
|
segmentDialogNodeId,
|
|
segmentRunningMode,
|
|
setFrameDialogNodeId,
|
|
setSegmentDialogNodeId,
|
|
setAngleNodeId,
|
|
setLightingNodeId,
|
|
setEmotionNodeId,
|
|
setAnnotationNodeId,
|
|
setAnnotationEditNodeId,
|
|
setCropNodeId,
|
|
setMaskEditNodeId,
|
|
setImageEditNodeId,
|
|
setImageEditPreset,
|
|
openBackgroundRemoval,
|
|
openLayerDecomposition,
|
|
decomposeImageLayers,
|
|
setLayerDecompositionNodeId,
|
|
setTextEditNodeId,
|
|
openTextEditNode,
|
|
openAnnotationEditNode,
|
|
detectImageText,
|
|
editTextImageNode,
|
|
editAnnotatedImageNode,
|
|
editImageNode,
|
|
setUpscaleNodeId,
|
|
splitImageNode,
|
|
openVideoFrameExtractor,
|
|
openVideoSegmentExtractor,
|
|
upscaleImageNode,
|
|
upscaleNodeId,
|
|
} = useCanvasMediaTools({
|
|
projectId,
|
|
domainProjectId: linkedProjectId,
|
|
nodesRef,
|
|
connectionsRef,
|
|
selectedNodeIdsRef,
|
|
setNodes,
|
|
setConnections,
|
|
setSelectedNodeIds,
|
|
setSelectedConnectionId,
|
|
setDialogNodeId,
|
|
setContextMenu,
|
|
setHoveredNodeId,
|
|
setToolbarNodeId,
|
|
setRunningNodeId,
|
|
startUploadStatus,
|
|
startGenerationRequest,
|
|
finishGenerationRequest,
|
|
bindGenerationTask,
|
|
});
|
|
|
|
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);
|
|
setFrameDialogNodeId(clearDeletedId);
|
|
setSegmentDialogNodeId(clearDeletedId);
|
|
setCropNodeId(clearDeletedId);
|
|
setMaskEditNodeId(clearDeletedId);
|
|
setAnnotationNodeId(clearDeletedId);
|
|
setUpscaleNodeId(clearDeletedId);
|
|
setAngleNodeId(clearDeletedId);
|
|
setLightingNodeId(clearDeletedId);
|
|
setEmotionNodeId(clearDeletedId);
|
|
setSuperResolveNodeId(clearDeletedId);
|
|
setPreviewNodeId(clearDeletedId);
|
|
setRunningNodeId(clearDeletedId);
|
|
setScriptEditorNodeId(clearDeletedId);
|
|
setArtCritiqueNodeId(clearDeletedId);
|
|
setPrevisNodeId(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,
|
|
setArtCritiqueNodeId,
|
|
setCropNodeId,
|
|
setEmotionNodeId,
|
|
setFrameDialogNodeId,
|
|
setLightingNodeId,
|
|
setMaskEditNodeId,
|
|
setSegmentDialogNodeId,
|
|
setUpscaleNodeId,
|
|
setRunningNodeId,
|
|
],
|
|
);
|
|
|
|
const {
|
|
alignSelectedNodes,
|
|
autoArrangeCanvasNodes,
|
|
arrangeSelectedNodes,
|
|
spreadSelectedNodes,
|
|
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 handleReplaceNodeReference = useCallback(
|
|
(targetNodeId: string, oldReference: { id: string; nodeId?: string; label?: string; title?: string }, sourceNodeId: string) => {
|
|
const sourceNode = nodesRef.current.find((n) => n.id === sourceNodeId);
|
|
if (!sourceNode || sourceNode.id === oldReference.nodeId) return;
|
|
|
|
const previousNodes = nodesRef.current;
|
|
const previousConnections = connectionsRef.current;
|
|
|
|
const configNodeId = previousConnections.find((c) => c.fromNodeId === targetNodeId && previousNodes.find((n) => n.id === c.toNodeId)?.type === CanvasNodeType.Config)?.toNodeId;
|
|
const receiverId = configNodeId || targetNodeId;
|
|
|
|
let rewired = false;
|
|
const nextConnections = previousConnections.map((c) => {
|
|
if (!rewired && c.fromNodeId === oldReference.nodeId && (c.toNodeId === targetNodeId || c.toNodeId === configNodeId)) {
|
|
rewired = true;
|
|
return { ...c, fromNodeId: sourceNodeId };
|
|
}
|
|
return c;
|
|
});
|
|
|
|
if (!rewired) {
|
|
nextConnections.push({
|
|
id: nanoid(),
|
|
fromNodeId: sourceNodeId,
|
|
toNodeId: receiverId,
|
|
});
|
|
}
|
|
|
|
const nextReferencesMap = buildCanvasNodeMentionReferenceMap(previousNodes, nextConnections, previousNodes);
|
|
const targetNextReferences = nextReferencesMap.get(targetNodeId) || [];
|
|
const newRef = targetNextReferences.find((r) => r.nodeId === sourceNodeId);
|
|
|
|
const targetNode = previousNodes.find((n) => n.id === targetNodeId);
|
|
let nextNodes = previousNodes;
|
|
if (targetNode && newRef) {
|
|
const currentPrompt = targetNode.metadata?.composerContent ?? targetNode.metadata?.prompt ?? "";
|
|
const replacementToken = `@${newRef.label}`;
|
|
const updatedPrompt = replaceCanvasReferenceMentions(currentPrompt, oldReference, replacementToken, sourceNode.title ? sourceNode.title : newRef.label);
|
|
|
|
nextNodes = previousNodes.map((n) => (n.id === targetNodeId ? writeCanvasNodePrompt(n, updatedPrompt) : n));
|
|
}
|
|
|
|
nodesRef.current = nextNodes;
|
|
connectionsRef.current = nextConnections;
|
|
setNodes(nextNodes);
|
|
setConnections(nextConnections);
|
|
message.success(`已将参考图「${oldReference.label || "参考图"}」替换为「${sourceNode.title || "新图片"}」,提示词已同步更新`);
|
|
},
|
|
[connectionsRef, message, nodesRef, setConnections, setNodes],
|
|
);
|
|
|
|
const handleReplaceNodeReferenceFiles = useCallback(
|
|
(_targetNodeId: string, oldReference: { id: string; nodeId?: string; label?: string; title?: string }, files: File[]) => {
|
|
const file = files.find((f) => f.type.startsWith("image/"));
|
|
if (!file || !oldReference.nodeId) return;
|
|
void replaceNodeMedia(oldReference.nodeId, file).then((success: boolean) => {
|
|
if (success) {
|
|
message.success("参考图片已替换");
|
|
}
|
|
});
|
|
},
|
|
[message, replaceNodeMedia],
|
|
);
|
|
|
|
const {
|
|
cancelPendingConnectionCreate,
|
|
closeConnectionCreateMenu,
|
|
connectionTargetAnchorRatio,
|
|
connectionTargetNodeId,
|
|
connectionApproach,
|
|
connectionReplaceHover,
|
|
connectingParams,
|
|
createConnectedNode,
|
|
getConnectionCreateDisabledReason,
|
|
handleConnectStart,
|
|
handleConnectDrop,
|
|
handleBatchConnectionTargetClick,
|
|
batchConnectionPreview,
|
|
beginBatchConnectionMode,
|
|
startBatchConnection,
|
|
mouseWorld,
|
|
pendingConnectionCreate,
|
|
} = useCanvasConnectionController({
|
|
projectId,
|
|
config: effectiveConfig,
|
|
defaultDrawingEngine,
|
|
nodesRef,
|
|
connectionsRef,
|
|
viewportRef,
|
|
scriptScrollTopById,
|
|
screenToCanvas,
|
|
setNodes,
|
|
setConnections,
|
|
setSelectedNodeIds,
|
|
setSelectedConnectionId,
|
|
setContextMenu,
|
|
setDialogNodeId,
|
|
setDrawingNodeId,
|
|
onReplaceReference: handleReplaceNodeReference,
|
|
});
|
|
|
|
const batchSourceNodeIds = useMemo(() => nodes.filter((node) => selectedNodeIds.has(node.id) && !batchSourceRestriction(node)).map((node) => node.id), [nodes, selectedNodeIds]);
|
|
|
|
const handleCanvasSelectionStart = useCallback(() => {
|
|
setContextMenu(null);
|
|
}, []);
|
|
|
|
const handleNodeInteractionStart = useCallback((selectionModifier: boolean) => {
|
|
setContextMenu(null);
|
|
setHoveredNodeId(null);
|
|
setToolbarNodeId(null);
|
|
if (selectionModifier) setDialogNodeId(null);
|
|
}, []);
|
|
|
|
const handleSelectedNodeClick = useCallback(
|
|
(node: CanvasNodeData) => {
|
|
// Selection is transient, but the LibTV-style paint order survives
|
|
// deselection so a clicked lower node stays above its neighbours.
|
|
if (node.type !== CanvasNodeType.Frame) bringNodeToFront(node.id);
|
|
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.metadata?.workflowKind === "character" && node.metadata.characterAssetId) {
|
|
setDialogNodeId(null);
|
|
setCharacterReferenceNodeId(node.id);
|
|
} else if (node.type === CanvasNodeType.Text) {
|
|
setDialogNodeId(node.id);
|
|
} else if (node.type === CanvasNodeType.Frame) {
|
|
setDialogNodeId((current) => (current === node.id ? current : null));
|
|
} else if (node.type === ART_CRITIQUE_NODE_TYPE) {
|
|
setDialogNodeId(null);
|
|
setArtCritiqueNodeId(node.id);
|
|
} else if (node.type === CanvasNodeType.Panorama) {
|
|
// 全景节点是纯查看器,没有可编辑提示词,不弹提示词面板。
|
|
setDialogNodeId(null);
|
|
} else {
|
|
// 选择参考媒体时保留当前工作流配置面板,避免点击图片后配置“返回/消失”。
|
|
// 没有工作流配置面板时,媒体节点仍按原逻辑打开自己的面板。
|
|
setDialogNodeId((current) => {
|
|
const currentNode = current ? nodesRef.current.find((item) => item.id === current) : undefined;
|
|
return currentNode?.type === CanvasNodeType.Config ? current : node.id;
|
|
});
|
|
}
|
|
},
|
|
[bringNodeToFront, nodesRef],
|
|
);
|
|
|
|
const handleNodeBringToFront = useCallback(
|
|
(nodeId: string) => {
|
|
const node = nodesRef.current.find((item) => item.id === nodeId);
|
|
if (node && node.type !== CanvasNodeType.Frame) bringNodeToFront(nodeId);
|
|
},
|
|
[bringNodeToFront, nodesRef],
|
|
);
|
|
|
|
const handleNodeDragEnd = useCallback(
|
|
(nodeId: string) => {
|
|
const node = nodesRef.current.find((item) => item.id === nodeId);
|
|
if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.Panorama || node.type === ART_CRITIQUE_NODE_TYPE || (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId)) {
|
|
setDialogNodeId(null);
|
|
return;
|
|
}
|
|
// A drag selects a new node even though it is not a click. Keep the
|
|
// generation editor bound to the node most recently moved so a stale
|
|
// panel from the previous node cannot reappear after mouse-up.
|
|
setDialogNodeId(node.id);
|
|
},
|
|
[nodesRef],
|
|
);
|
|
|
|
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,
|
|
onNodeBringToFront: handleNodeBringToFront,
|
|
onNodeClick: handleSelectedNodeClick,
|
|
onNodeDragEnd: handleNodeDragEnd,
|
|
onBatchConnectionTarget: handleBatchConnectionTargetClick,
|
|
onLinkedFolderDrop: archiveNodesToLinkedFolder,
|
|
onDeselect: handleCanvasDeselect,
|
|
});
|
|
|
|
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,
|
|
canvasTitle: currentProject?.title || "未命名画布",
|
|
domainProjectId: linkedProjectId,
|
|
nodesRef,
|
|
setNodes,
|
|
setSelectedNodeIds,
|
|
setSelectedConnectionId,
|
|
setDialogNodeId,
|
|
setToolbarNodeId,
|
|
setHoveredNodeId,
|
|
});
|
|
|
|
const handleRemoveNodeReference = useCallback(
|
|
(targetNodeId: string, reference: CanvasResourceReference) => {
|
|
const referenceNodeId = reference.nodeId;
|
|
if (!referenceNodeId) return;
|
|
// 生成节点可能通过配置节点接收参考,只移除参考来源边,保留目标到配置节点的主链。
|
|
const previousNodes = nodesRef.current;
|
|
const previousConnections = connectionsRef.current;
|
|
const configNodeId = previousConnections.find((connection) => {
|
|
if (connection.fromNodeId !== targetNodeId) return false;
|
|
return previousNodes.find((node) => node.id === connection.toNodeId)?.type === CanvasNodeType.Config;
|
|
})?.toNodeId;
|
|
const removedConnectionIds = new Set(previousConnections.filter((connection) => connection.fromNodeId === referenceNodeId && (connection.toNodeId === targetNodeId || connection.toNodeId === configNodeId)).map((connection) => connection.id));
|
|
if (!removedConnectionIds.size) return;
|
|
const nextConnections = previousConnections.filter((connection) => !removedConnectionIds.has(connection.id));
|
|
const nextNodes = applyCanvasConnectionPromptSync(previousNodes, previousConnections, previousNodes, nextConnections);
|
|
if (nextNodes !== previousNodes) {
|
|
nodesRef.current = nextNodes;
|
|
setNodes(nextNodes);
|
|
}
|
|
connectionsRef.current = nextConnections;
|
|
setConnections(nextConnections);
|
|
setSelectedConnectionId((current) => (current && removedConnectionIds.has(current) ? null : current));
|
|
},
|
|
[connectionsRef, nodesRef, setConnections, setNodes, setSelectedConnectionId],
|
|
);
|
|
|
|
const handleReorderNodeReferences = useCallback(
|
|
(targetNodeId: string, orderedNodeIds: string[]) => {
|
|
const previousNodes = nodesRef.current;
|
|
const previousConnections = connectionsRef.current;
|
|
const nextConnections = reorderCanvasResourceConnections(targetNodeId, orderedNodeIds, previousNodes, previousConnections);
|
|
if (nextConnections === previousConnections) return;
|
|
const nextNodes = applyCanvasConnectionPromptSync(previousNodes, previousConnections, previousNodes, nextConnections);
|
|
if (nextNodes !== previousNodes) {
|
|
nodesRef.current = nextNodes;
|
|
setNodes(nextNodes);
|
|
}
|
|
connectionsRef.current = nextConnections;
|
|
setConnections(nextConnections);
|
|
},
|
|
[connectionsRef, nodesRef, setConnections, setNodes],
|
|
);
|
|
|
|
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 storageKey = characterCover
|
|
? `resource:${characterCover.resourceId}`
|
|
: local?.kind === "image" || local?.kind === "video" || local?.kind === "audio"
|
|
? local.data.storageKey
|
|
: remoteResourceId
|
|
? asset.storageKey
|
|
: undefined;
|
|
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, storageKey } };
|
|
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 {
|
|
activePrevisScene,
|
|
activeNodeId,
|
|
activeScriptNode,
|
|
activeStylePresetId,
|
|
angleNode,
|
|
lightingNode,
|
|
emotionNode,
|
|
annotationNode,
|
|
batchChildCountById,
|
|
batchMotionById,
|
|
configInputsById,
|
|
connectionLayerBounds,
|
|
contextMenuNode,
|
|
cropNode,
|
|
displayConnections,
|
|
frameChildrenById,
|
|
infoNode,
|
|
maskEditNode,
|
|
imageEditNode,
|
|
mentionReferencesByNodeId,
|
|
nodeById,
|
|
nodeRenderLODById,
|
|
previewNode,
|
|
reduceMediaEffects,
|
|
relatedHighlight,
|
|
resourceReferenceByNodeId,
|
|
selectedNodeBounds,
|
|
selectedVideoNodes,
|
|
skillMentionReferences,
|
|
superResolveNode,
|
|
toolbarNode,
|
|
upscaleNode,
|
|
versionCompareNodes,
|
|
visibleNodes,
|
|
} = useCanvasRenderModel({
|
|
nodes,
|
|
connections,
|
|
assets,
|
|
viewport,
|
|
viewportSize: size,
|
|
mediaPerformanceMode,
|
|
selectedNodeIds,
|
|
hoveredNodeId,
|
|
dragPreview,
|
|
collapsingBatchIds,
|
|
addedSkills,
|
|
previsScenes: currentProject?.previsScenes,
|
|
infoNodeId,
|
|
cropNodeId,
|
|
maskEditNodeId,
|
|
imageEditNodeId,
|
|
annotationNodeId,
|
|
splitNodeId: null,
|
|
upscaleNodeId,
|
|
superResolveNodeId,
|
|
angleNodeId,
|
|
lightingNodeId,
|
|
emotionNodeId,
|
|
previewNodeId,
|
|
contextMenu,
|
|
versionCompareRootId,
|
|
previsNodeId,
|
|
scriptEditorNodeId,
|
|
dialogNodeId,
|
|
});
|
|
|
|
const visibleDisplayConnections = useMemo(
|
|
() => filterCanvasDisplayConnections(displayConnections, {
|
|
enabled: hideNodeConnections,
|
|
hoveredNodeId,
|
|
selectedNodeIds,
|
|
activeNodeId,
|
|
selectedConnectionId,
|
|
}),
|
|
[activeNodeId, displayConnections, hideNodeConnections, hoveredNodeId, selectedConnectionId, selectedNodeIds],
|
|
);
|
|
useEffect(() => {
|
|
setNodes((current) => {
|
|
let changed = false;
|
|
const next = current.map((node) => {
|
|
const references = mentionReferencesByNodeId.get(node.id);
|
|
const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt;
|
|
if (!references?.length || !savedPrompt?.includes("@[node:")) return node;
|
|
const normalizedPrompt = normalizeCanvasNodeMentionTokens(savedPrompt, references);
|
|
if (normalizedPrompt === savedPrompt) return node;
|
|
changed = true;
|
|
return {
|
|
...node,
|
|
metadata: node.metadata?.composerContent !== undefined ? { ...node.metadata, composerContent: normalizedPrompt } : { ...node.metadata, prompt: normalizedPrompt },
|
|
};
|
|
});
|
|
return changed ? next : current;
|
|
});
|
|
}, [mentionReferencesByNodeId, setNodes]);
|
|
const dialogNode = dialogNodeId ? nodeById.get(dialogNodeId) || null : null;
|
|
// dragPreview is published on the same pointer-down frame as isNodeDragging.
|
|
// Treat either signal as moving so floating editors disappear before the
|
|
// first preview transform is painted and never affect drag layout.
|
|
const isCanvasNodeMoving = isNodeDragging || Boolean(dragPreview?.nodeIds.size);
|
|
const subtitleNode = subtitleNodeId ? nodeById.get(subtitleNodeId) || null : null;
|
|
const timelineNode = timelineNodeId ? nodeById.get(timelineNodeId) || null : null;
|
|
const frameNode = frameDialogNodeId ? nodeById.get(frameDialogNodeId) || 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 artCritiqueNode = artCritiqueNodeId ? nodeById.get(artCritiqueNodeId) || null : null;
|
|
const artCritiqueInputs = artCritiqueNode
|
|
? connections
|
|
.filter((connection) => connection.toNodeId === artCritiqueNode.id)
|
|
.sort((left, right) => left.id.localeCompare(right.id))
|
|
.map((connection) => nodeById.get(connection.fromNodeId))
|
|
.filter((node): node is CanvasNodeData => Boolean(node))
|
|
: [];
|
|
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 && node.type !== CanvasNodeType.Markdown) 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 openArtCritique = useCallback((node: CanvasNodeData) => {
|
|
if (node.type !== ART_CRITIQUE_NODE_TYPE) return;
|
|
setSelectedNodeIds(new Set([node.id]));
|
|
setSelectedConnectionId(null);
|
|
setContextMenu(null);
|
|
setDialogNodeId(null);
|
|
setToolbarNodeId(null);
|
|
setArtCritiqueNodeId(node.id);
|
|
}, []);
|
|
const duplicateNodeFromContent = useCallback((node: CanvasNodeData) => duplicateNode(node.id), [duplicateNode]);
|
|
const deleteNodeFromContent = useCallback((node: CanvasNodeData) => deleteNodes(new Set([node.id])), [deleteNodes]);
|
|
const updateNodeFromContent = useCallback((nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => {
|
|
setNodesState((current) => {
|
|
const next = updateCanvasNode(current, nodeId, update);
|
|
nodesRef.current = next;
|
|
return next;
|
|
});
|
|
}, []);
|
|
const pendingMediaUpdatesRef = useRef(new Map<string, (node: CanvasNodeData) => CanvasNodeData>());
|
|
const mediaUpdateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const updateMediaNodeFromContent = useCallback((nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => {
|
|
const previous = pendingMediaUpdatesRef.current.get(nodeId);
|
|
pendingMediaUpdatesRef.current.set(nodeId, previous ? (node) => update(previous(node)) : update);
|
|
if (mediaUpdateTimerRef.current) return;
|
|
mediaUpdateTimerRef.current = setTimeout(() => {
|
|
const updates = pendingMediaUpdatesRef.current;
|
|
pendingMediaUpdatesRef.current = new Map();
|
|
mediaUpdateTimerRef.current = null;
|
|
if (!updates.size) return;
|
|
setNodesState((current) => {
|
|
const next = updateCanvasNodes(current, updates);
|
|
nodesRef.current = next;
|
|
return next;
|
|
});
|
|
}, 120);
|
|
}, []);
|
|
const updateNodeMetadataFromContent = useCallback(
|
|
(nodeId: string, patch: CanvasNodeMetadata) => {
|
|
updateNodeFromContent(nodeId, (node) => ({ ...node, metadata: { ...node.metadata, ...patch } }));
|
|
},
|
|
[updateNodeFromContent],
|
|
);
|
|
const canvasNodeActions = useMemo<CanvasNodeActionContextValue>(
|
|
() => ({
|
|
upload: replaceCanvasNodeMedia,
|
|
download: downloadNodeImage,
|
|
duplicate: duplicateNodeFromContent,
|
|
deleteNode: deleteNodeFromContent,
|
|
updateMetadata: updateNodeMetadataFromContent,
|
|
updateNode: updateNodeFromContent,
|
|
updateMediaNode: updateMediaNodeFromContent,
|
|
openArtCritique,
|
|
addPanoramaCaptureNode,
|
|
}),
|
|
[addPanoramaCaptureNode, deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, replaceCanvasNodeMedia, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent],
|
|
);
|
|
const { dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasOperationHistory({
|
|
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({
|
|
canvasId: projectId,
|
|
domainProjectId: currentProject?.projectId,
|
|
nodesRef,
|
|
selectedNodeIdsRef,
|
|
getCanvasCenter,
|
|
setNodes,
|
|
setSelectedNodeIds,
|
|
setSelectedConnectionId,
|
|
setDialogNodeId,
|
|
setStylePickerOpen,
|
|
});
|
|
|
|
const { applyPrevisOutput, createPrevisShot, openPrevisWorkbench, savePrevisScene } = useCanvasPrevis({
|
|
projectId,
|
|
domainProjectId: currentProject?.projectId,
|
|
previsNodeId,
|
|
previsScenes: currentProject?.previsScenes || [],
|
|
nodesRef,
|
|
connectionsRef,
|
|
getCanvasCenter,
|
|
setNodes,
|
|
setConnections,
|
|
setSelectedNodeIds,
|
|
setSelectedConnectionId,
|
|
setPrevisNodeId,
|
|
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);
|
|
setLightingNodeId(null);
|
|
setEmotionNodeId(null);
|
|
setPreviewNodeId(null);
|
|
setRunningNodeId(null);
|
|
setArtCritiqueNodeId(null);
|
|
deselectCanvas();
|
|
setClearConfirmOpen(false);
|
|
clearCanvasFiles();
|
|
}, [clearCanvasFiles, deselectCanvas, message, nodesRef, projectId, setEmotionNodeId]);
|
|
|
|
useCanvasKeyboard({
|
|
enabled: projectLoaded && !versions.preview,
|
|
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,
|
|
onOpenSearch: () => setNodeSearchOpen(true),
|
|
beginBatchConnection: () => beginBatchConnectionMode(Array.from(selectedNodeIdsRef.current)),
|
|
});
|
|
|
|
|
|
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 copyingNodeContentRef = useRef(false);
|
|
const copyNodeContentToClipboard = useCallback(
|
|
async (node: CanvasNodeData | null) => {
|
|
if (copyingNodeContentRef.current) return;
|
|
copyingNodeContentRef.current = true;
|
|
releaseCopiedNodesPastePriority();
|
|
if (!node) {
|
|
copyingNodeContentRef.current = false;
|
|
message.warning("没有可复制的内容");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const content = node.metadata?.content?.trim();
|
|
const resourceId = resourceIdFromStorageKey(node.metadata?.storageKey);
|
|
// Resource-backed media must use the central access contract. This keeps
|
|
// copy operations on the configured CDN/OSS URL instead of copying the
|
|
// platform file endpoint or a stale URL persisted in canvas metadata.
|
|
const copySource = resourceId
|
|
? resolveResourceAccessURL((await getResourceAccess(`resource:${resourceId}`, "copy")).url)
|
|
: content || "";
|
|
if (!copySource) throw new Error("没有可复制的内容");
|
|
if (node.type === CanvasNodeType.Image) {
|
|
try {
|
|
await copyImageToSystemClipboard(copySource, node.metadata?.storageKey);
|
|
message.success("图片已复制到剪贴板");
|
|
return;
|
|
} catch (imageErr) {
|
|
const fallbackUrl = new URL(copySource, window.location.href).toString();
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(fallbackUrl).catch(() => undefined);
|
|
message.info("由于浏览器未获得焦点,已为您复制图片地址");
|
|
return;
|
|
}
|
|
if (await copyToClipboard(fallbackUrl)) {
|
|
message.info("由于浏览器未获得焦点,已为您复制图片地址");
|
|
return;
|
|
}
|
|
throw imageErr;
|
|
}
|
|
}
|
|
|
|
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(copySource);
|
|
else if (!copyToClipboard(copySource)) throw new Error("当前浏览器不支持写入剪贴板");
|
|
message.success(node.type === CanvasNodeType.Text ? "文本已复制" : "内容链接已复制");
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : "复制失败,请检查浏览器剪贴板权限");
|
|
} finally {
|
|
copyingNodeContentRef.current = false;
|
|
}
|
|
},
|
|
[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 : "";
|
|
const mediaURL = resourceId
|
|
? resolveResourceAccessURL((await getResourceAccess(`resource:${resourceId}`, "copy")).url)
|
|
: 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 uploadNodeImageToArkPrivateAsset = useCallback(
|
|
async (node: CanvasNodeData) => {
|
|
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) {
|
|
message.warning("请选择一张可用图片后再上传");
|
|
return;
|
|
}
|
|
if (arkPrivateAssetUploadNodeId === node.id) return;
|
|
const feedbackKey = `ark-private-asset-${node.id}`;
|
|
setArkPrivateAssetUploadNodeId(node.id);
|
|
message.loading({ key: feedbackKey, content: "正在保存并上传到方舟素材库...", duration: 0 });
|
|
try {
|
|
let resourceID = resourceIdFromStorageKey(node.metadata.storageKey);
|
|
let persistedNode = node;
|
|
if (!resourceID) {
|
|
const uploaded = await uploadImage(node.metadata.content);
|
|
resourceID = resourceIdFromStorageKey(uploaded.storageKey);
|
|
if (!resourceID) throw new Error("图片未能保存到系统素材库,请检查对象存储配置后重试");
|
|
handleConfigNodeChange(node.id, imageMetadata(uploaded));
|
|
persistedNode = { ...node, metadata: { ...node.metadata, ...imageMetadata(uploaded) } };
|
|
}
|
|
const asset = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId: currentProject?.projectId, node: persistedNode, source: "canvas-manual" });
|
|
handleConfigNodeChange(node.id, { assetId: asset.assetId });
|
|
await syncResourceToArkPrivateAsset(resourceID);
|
|
message.success({ key: feedbackKey, content: "已同步到方舟素材库,Seedance 将自动复用该素材", duration: 4 });
|
|
} catch (error) {
|
|
message.error({ key: feedbackKey, content: error instanceof Error ? error.message : "上传到方舟素材库失败", duration: 5 });
|
|
} finally {
|
|
setArkPrivateAssetUploadNodeId((current) => (current === node.id ? null : current));
|
|
}
|
|
},
|
|
[arkPrivateAssetUploadNodeId, currentProject?.projectId, handleConfigNodeChange, message, projectId],
|
|
);
|
|
|
|
const confirmUploadNodeImageToArkPrivateAsset = useCallback(
|
|
(node: CanvasNodeData) => {
|
|
modal.confirm({
|
|
title: "上传到方舟素材库",
|
|
content: "仅可上传你拥有肖像、版权或其他合法使用权的图片。方舟审核通过后,Seedance 会使用受控素材标识生成视频。",
|
|
okText: "确认拥有使用权并上传",
|
|
cancelText: "取消",
|
|
onOk: () => uploadNodeImageToArkPrivateAsset(node),
|
|
});
|
|
},
|
|
[modal, uploadNodeImageToArkPrivateAsset],
|
|
);
|
|
|
|
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((current) => {
|
|
if (current.has(id) && current.size > 1) return current;
|
|
return 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 { enqueueGenerationBatch, retryFailedBatchItems, stopRemainingBatchItems } = useCanvasGenerationBatches({
|
|
projectId,
|
|
projectLoaded,
|
|
nodes,
|
|
nodesRef,
|
|
setNodes,
|
|
handleGenerateNode,
|
|
});
|
|
|
|
const {
|
|
addReferenceColumn: addBatchReferenceColumn,
|
|
addRow: addBatchRow,
|
|
batchGenDialogConfig,
|
|
batchGenDialogConcurrency,
|
|
batchGenDialogOpen,
|
|
batchGenDialogRowCount,
|
|
closeBatchGenDialog,
|
|
confirmBatchGenDialog,
|
|
fillRowsFromConnections,
|
|
generateRows: generateBatchRows,
|
|
moveReferenceCell: moveBatchReferenceCell,
|
|
patchTable: patchBatchTable,
|
|
removeReferenceColumn: removeBatchReferenceColumn,
|
|
removeRow: removeBatchRow,
|
|
reorderReferenceColumns: reorderBatchReferenceColumns,
|
|
syncRowsFromConnections,
|
|
updateRow: updateBatchRow,
|
|
} = useCanvasBatchTable({
|
|
nodesRef,
|
|
connectionsRef,
|
|
setNodes,
|
|
setConnections,
|
|
setSelectedNodeIds,
|
|
enqueueGenerationBatch,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!projectLoaded) return;
|
|
setNodes((current) => {
|
|
let changed = false;
|
|
const next = current.map((node) => {
|
|
const promoted = promoteLegacyBatchTableSize(node);
|
|
if (promoted !== node) changed = true;
|
|
return promoted;
|
|
});
|
|
return changed ? next : current;
|
|
});
|
|
nodesRef.current.filter((node) => node.type === CanvasNodeType.BatchTable).forEach((node) => {
|
|
syncRowsFromConnections(node.id, true);
|
|
});
|
|
}, [connections, projectLoaded, setNodes, syncRowsFromConnections]);
|
|
|
|
const handleUploadBatchReference = useCallback(async (tableNodeId: string, rowId: string, columnIndex: number, file: File) => {
|
|
const tableNode = nodesRef.current.find((item) => item.id === tableNodeId);
|
|
const table = tableNode?.metadata?.batchTable;
|
|
const row = table?.rows.find((item) => item.id === rowId);
|
|
if (!tableNode || !table || !row) return;
|
|
const existingId = row.inputNodeIds[columnIndex];
|
|
if (existingId) {
|
|
await replaceNodeMedia(existingId, file);
|
|
return;
|
|
}
|
|
const rowIndex = table.rows.findIndex((item) => item.id === rowId);
|
|
const uploadedNodeId = await createFileNode(file, {
|
|
x: tableNode.position.x - 240,
|
|
y: tableNode.position.y + 170 + Math.max(0, rowIndex) * 220,
|
|
});
|
|
if (!uploadedNodeId) return;
|
|
const inputNodeIds = Array.from({ length: Math.max(batchReferenceColumns(table).length, columnIndex + 1) }, (_, index) => row.inputNodeIds[index] || "");
|
|
inputNodeIds[columnIndex] = uploadedNodeId;
|
|
updateBatchRow(tableNodeId, rowId, { inputNodeIds });
|
|
message.success(`已上传并填入参考图 ${columnIndex + 1}`);
|
|
}, [createFileNode, message, nodesRef, replaceNodeMedia, updateBatchRow]);
|
|
|
|
const { addScriptRow, createAndGenerateScriptVideos, createScriptActionBoards, createScriptImageNodes, createScriptVideoNodes, generateScriptImages, generateScriptRows, generateScriptVideos, removeScriptRow, replaceScriptRows, updateScriptRow } =
|
|
useCanvasStoryboard({
|
|
projectId,
|
|
addedSkills,
|
|
nodesRef,
|
|
connectionsRef,
|
|
setNodes,
|
|
setConnections,
|
|
setSelectedNodeIds,
|
|
enqueueGenerationBatch,
|
|
});
|
|
|
|
const createStoryboardFromBatchTable = useCallback(async (tableNodeId: string) => {
|
|
const tableNode = nodesRef.current.find((node) => node.id === tableNodeId && node.type === CanvasNodeType.BatchTable);
|
|
const table = tableNode?.metadata?.batchTable;
|
|
if (!tableNode || !table || table.contentKind !== "storyboard") return;
|
|
const rows = storyboardRowsFromBatchTable(table);
|
|
if (!rows.length) return message.warning("当前表格没有可用的分镜行");
|
|
const sourceIds = Array.from(new Set([
|
|
...(table.storyboardSourceNodeIds || []),
|
|
...connectionsRef.current.filter((connection) => connection.toNodeId === tableNodeId && connection.relation !== "batch-output").map((connection) => connection.fromNodeId),
|
|
])).filter((id) => nodesRef.current.some((node) => node.id === id));
|
|
const sourceVideo = sourceIds.map((id) => nodesRef.current.find((node) => node.id === id)).find((node): node is CanvasNodeData => node?.type === CanvasNodeType.Video);
|
|
const keyframeTimesMs = rows.map(storyboardKeyframeTime).filter((value): value is number => value !== undefined);
|
|
const useKeyframes = Boolean(sourceVideo && keyframeTimesMs.length);
|
|
const scriptSpec = getNodeSpec(CanvasNodeType.Script);
|
|
const scriptNode = createCanvasNode(CanvasNodeType.Script, {
|
|
x: tableNode.position.x + tableNode.width + 160 + scriptSpec.width / 2,
|
|
y: tableNode.position.y + Math.max(tableNode.height, scriptSpec.height) / 2,
|
|
}, {
|
|
storyboard: { rows, visibleColumns: cinematicStoryboardColumns(), referenceNodeIds: sourceIds },
|
|
storyboardVideoInputMode: useKeyframes ? "keyframe" : "direct",
|
|
workflowKind: "storyboard",
|
|
workflowTitle: table.storyboardTitle || "视频脚本",
|
|
composerContent: table.storyboardTitle || "来自多维表格的视频分镜脚本",
|
|
status: "idle",
|
|
});
|
|
scriptNode.title = table.storyboardTitle || "视频脚本";
|
|
const nextConnections = [
|
|
...connectionsRef.current,
|
|
...sourceIds.map((sourceId) => ({ id: nanoid(), fromNodeId: sourceId, toNodeId: scriptNode.id, toHandleId: "storyboard:context", relation: "storyboard-asset-reference" as const })),
|
|
];
|
|
const nextNodes = [...nodesRef.current, scriptNode];
|
|
nodesRef.current = nextNodes;
|
|
connectionsRef.current = nextConnections;
|
|
setNodes(nextNodes);
|
|
setConnections(nextConnections);
|
|
setSelectedNodeIds(new Set([scriptNode.id]));
|
|
setDialogNodeId(null);
|
|
if (sourceVideo && keyframeTimesMs.length) {
|
|
const frameNodes = await extractVideoFrames(sourceVideo, { timesMs: keyframeTimesMs });
|
|
if (frameNodes.length) {
|
|
const withFrames = nodesRef.current.map((node) => node.id === scriptNode.id ? { ...node, metadata: { ...node.metadata, storyboard: { ...node.metadata?.storyboard, rows: bindStoryboardKeyframes(node.metadata?.storyboard?.rows || rows, frameNodes, sourceVideo.id), visibleColumns: cinematicStoryboardColumns(), referenceNodeIds: sourceIds } } } : node);
|
|
nodesRef.current = withFrames;
|
|
setNodes(withFrames);
|
|
message.success(`已创建视频脚本并自动抽取 ${frameNodes.length} 个关键帧,点击“生成视频”继续`);
|
|
} else {
|
|
message.warning("视频脚本已创建,但关键帧抽取失败;请检查视频是否已保存或允许跨域读取");
|
|
}
|
|
} else {
|
|
message.success(`已创建视频脚本:${rows.length} 个镜头`);
|
|
}
|
|
}, [connectionsRef, extractVideoFrames, message, nodesRef, setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]);
|
|
|
|
const handleRetryNode = useCanvasGenerationRetry({
|
|
projectId,
|
|
domainProjectId: currentProject?.projectId,
|
|
addedSkills,
|
|
assets,
|
|
nodesRef,
|
|
connectionsRef,
|
|
setNodes,
|
|
setRunningNodeId,
|
|
startGenerationRequest,
|
|
finishGenerationRequest,
|
|
bindGenerationTask,
|
|
applyGenerationTaskResult,
|
|
});
|
|
const reloadCanvasNodeResource = useCallback(
|
|
async (node: CanvasNodeData) => {
|
|
const taskId = node.metadata?.taskId;
|
|
if (!taskId || !node.metadata?.resourceReloadAvailable) return;
|
|
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: "loading", taskStage: "正在重新加载资源", errorDetails: undefined } } : item)));
|
|
try {
|
|
const task = await queryGenerationTask(taskId);
|
|
if (task.status !== "succeeded") throw new Error("原生成任务尚未成功,无法重新加载资源");
|
|
await applyGenerationTaskResult(node.id, task);
|
|
} catch (error) {
|
|
setNodes((current) =>
|
|
current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: "error", errorDetails: error instanceof Error ? error.message : "资源重新加载失败", resourceReloadAvailable: true } } : item)),
|
|
);
|
|
}
|
|
},
|
|
[applyGenerationTaskResult, setNodes],
|
|
);
|
|
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: "@文本1",
|
|
composerContent: "@文本1",
|
|
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
|
|
projectId={projectId}
|
|
node={panelNode}
|
|
isRunning={isCanvasNodeGenerating(panelNode, runningNodeId)}
|
|
mentionReferences={[
|
|
...(mentionReferencesByNodeId.get(panelNode.id) || EMPTY_RESOURCE_REFERENCES),
|
|
...buildCanvasResourceReferences(nodesRef.current, connectionsRef.current).filter((reference) => reference.kind === "text" && reference.nodeId !== panelNode.id && !(mentionReferencesByNodeId.get(panelNode.id) || []).some((active) => active.nodeId === reference.nodeId)),
|
|
]}
|
|
onAddReference={(nodeId, reference) => {
|
|
if (reference.active || reference.assetId || reference.kind === "skill") return reference;
|
|
if (reference.kind !== "text" || !reference.nodeId || reference.nodeId === nodeId) return undefined;
|
|
try {
|
|
const linked = connectCanvasTextMention(nodesRef.current, connectionsRef.current, nodeId, reference.nodeId, nanoid());
|
|
nodesRef.current = linked.nodes;
|
|
connectionsRef.current = linked.connections;
|
|
setNodes(linked.nodes);
|
|
setConnections(linked.connections);
|
|
return linked.reference;
|
|
} catch (cause) {
|
|
message.warning(cause instanceof Error ? cause.message : "文本引用失败");
|
|
return undefined;
|
|
}
|
|
}}
|
|
onPromptChange={handleNodePromptChange}
|
|
onConfigChange={handleConfigNodeChange}
|
|
onGenerate={handleGenerateNode}
|
|
onRemoveReference={handleRemoveNodeReference}
|
|
onReorderReferences={handleReorderNodeReferences}
|
|
onReplaceReference={handleReplaceNodeReference}
|
|
onReplaceReferenceFiles={handleReplaceNodeReferenceFiles}
|
|
onClose={() => setDialogNodeId(null)}
|
|
onNodeMouseDown={handleNodeMouseDown}
|
|
workspaceMode={workspaceMode}
|
|
onImageSettingsOpenChange={(open) => {
|
|
setNodeImageSettingsOpen(open);
|
|
if (open) setToolbarNodeId(null);
|
|
}}
|
|
onListGenerate={(nodeId, listPrompt) => {
|
|
void handleListGenerate({
|
|
sourceNodeId: nodeId,
|
|
prompt: listPrompt,
|
|
nodes: nodesRef.current,
|
|
connections: connectionsRef.current,
|
|
config: effectiveConfig,
|
|
projectId,
|
|
setNodes,
|
|
setConnections,
|
|
setRunningNodeId,
|
|
setDialogNodeId,
|
|
});
|
|
}}
|
|
/>
|
|
);
|
|
},
|
|
[
|
|
configInputsById,
|
|
effectiveConfig,
|
|
handleConfigNodeChange,
|
|
handleGenerateNode,
|
|
handleNodePromptChange,
|
|
handleRemoveNodeReference,
|
|
handleReorderNodeReferences,
|
|
handleReplaceNodeReference,
|
|
handleReplaceNodeReferenceFiles,
|
|
mentionReferencesByNodeId,
|
|
message,
|
|
projectId,
|
|
runningNodeId,
|
|
setConnections,
|
|
setDialogNodeId,
|
|
setNodes,
|
|
setRunningNodeId,
|
|
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.BatchTable) {
|
|
return (
|
|
<CanvasBatchTableNodeContent
|
|
node={contentNode}
|
|
nodes={nodesRef.current}
|
|
connections={connections}
|
|
batch={visibleGenerationBatch(contentNode)}
|
|
theme={theme}
|
|
onPatchTable={(patch) => patchBatchTable(contentNode.id, patch)}
|
|
onAddRow={() => addBatchRow(contentNode.id)}
|
|
onRemoveRow={(rowId) => removeBatchRow(contentNode.id, rowId)}
|
|
onUpdateRow={(rowId, patch) => updateBatchRow(contentNode.id, rowId, patch)}
|
|
onFillRows={() => fillRowsFromConnections(contentNode.id)}
|
|
onGenerate={(rowIds) => void generateBatchRows(contentNode.id, rowIds)}
|
|
onCreateStoryboard={() => createStoryboardFromBatchTable(contentNode.id)}
|
|
onRetryItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)}
|
|
onAddReferenceColumn={() => addBatchReferenceColumn(contentNode.id)}
|
|
onRemoveReferenceColumn={() => removeBatchReferenceColumn(contentNode.id)}
|
|
onFocusOutput={(nodeId) => focusCanvasImageNode(nodeId)}
|
|
onReorderReferenceColumns={(fromColumnId, toColumnId) => reorderBatchReferenceColumns(contentNode.id, fromColumnId, toColumnId)}
|
|
onMoveReferenceCell={(sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex) => moveBatchReferenceCell(contentNode.id, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex)}
|
|
onUploadReference={(rowId, columnIndex, file) => { void handleUploadBatchReference(contentNode.id, rowId, columnIndex, file); }}
|
|
onConnectStart={(event, handleId) => handleConnectStart(event, contentNode.id, "target", handleId)}
|
|
onConnectDrop={(event, handleId) => handleConnectDrop(event, contentNode.id, handleId)}
|
|
/>
|
|
);
|
|
}
|
|
if (contentNode.type === CanvasNodeType.Script) {
|
|
const pipeline = deriveStoryboardPipelineProgress(contentNode, nodesRef.current, connectionsRef.current);
|
|
return (
|
|
<CanvasScriptNodeContent
|
|
node={contentNode}
|
|
nodes={nodesRef.current}
|
|
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)}
|
|
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?.previsSceneId) {
|
|
return (
|
|
<CanvasPrevisNodePanel
|
|
node={contentNode}
|
|
scene={currentProject?.previsScenes?.find((scene) => scene.id === contentNode.metadata?.previsSceneId) || null}
|
|
readNodeContent={(nodeId) => {
|
|
const previewNode = nodeId ? nodesRef.current.find((item) => item.id === nodeId) : undefined;
|
|
return previewNode ? { content: previewNode.metadata?.content, storageKey: previewNode.metadata?.storageKey } : undefined;
|
|
}}
|
|
professional={workspaceMode === "professional"}
|
|
onOpen={() => openPrevisWorkbench(contentNode.id)}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<CanvasConfigNodePanel
|
|
node={contentNode}
|
|
isRunning={isCanvasNodeGenerating(contentNode, runningNodeId)}
|
|
inputSummary={getInputSummary(configInputsById.get(contentNode.id) || [])}
|
|
onConfigChange={handleConfigNodeChange}
|
|
onComposerToggle={() => setDialogNodeId((current) => (current === contentNode.id ? null : contentNode.id))}
|
|
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}
|
|
/>
|
|
);
|
|
},
|
|
[
|
|
addBatchReferenceColumn,
|
|
addBatchRow,
|
|
addScriptRow,
|
|
configInputsById,
|
|
connections,
|
|
createAndGenerateScriptVideos,
|
|
createScriptActionBoards,
|
|
createStoryboardFromBatchTable,
|
|
createScriptImageNodes,
|
|
createScriptVideoNodes,
|
|
currentProject?.previsScenes,
|
|
fillRowsFromConnections,
|
|
focusCanvasImageNode,
|
|
generateBatchRows,
|
|
generateScriptImages,
|
|
generateScriptRows,
|
|
generateScriptVideos,
|
|
handleUploadBatchReference,
|
|
moveBatchReferenceCell,
|
|
handleConfigNodeChange,
|
|
handleConnectDrop,
|
|
handleConnectStart,
|
|
handleGenerateNode,
|
|
handleNodeResize,
|
|
mentionReferencesByNodeId,
|
|
mergeVideosByIds,
|
|
openPrevisWorkbench,
|
|
openStoryInput,
|
|
patchBatchTable,
|
|
removeBatchReferenceColumn,
|
|
removeBatchRow,
|
|
reorderBatchReferenceColumns,
|
|
replaceCanvasNodeMedia,
|
|
removeScriptRow,
|
|
retryFailedBatchItems,
|
|
runningNodeId,
|
|
stopRemainingBatchItems,
|
|
theme,
|
|
updateBatchRow,
|
|
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 editCanvasPrevis = useCallback((node: CanvasNodeData) => openPrevisWorkbench(node.id), [openPrevisWorkbench]);
|
|
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 freeformCreateCommands = useCanvasCreateCommands({
|
|
workspaceMode,
|
|
isProjectLinked: Boolean(shortDramaEnabled && currentProject?.projectId),
|
|
handlers: {
|
|
onAddText: () => createNode(CanvasNodeType.Text),
|
|
onAddImage: () => createNode(CanvasNodeType.Image),
|
|
onAddVideo: () => createNode(CanvasNodeType.Video),
|
|
onAddAudio: () => createNode(CanvasNodeType.Audio),
|
|
onAddScript: () => createNode(CanvasNodeType.Script),
|
|
onAddFrame: () => createNode(CanvasNodeType.Frame),
|
|
onAddFolder: createFolder,
|
|
onAddDrawing: () => createNode(CanvasNodeType.Drawing),
|
|
onAddWorkflow: () => createNode(CanvasNodeType.Config),
|
|
onAddExtensionNode: (type) => createNode(type),
|
|
onChooseStyle: () => setStylePickerOpen(true),
|
|
onOpenPrevis: () => setPrevisTemplateRequest({}),
|
|
onUpload: () => handleUploadRequest(),
|
|
onOpenMyAssets: () => openCanvasAssetLibrary(),
|
|
onOpenProjectCharacters: () => openCharacterLibrary(),
|
|
},
|
|
});
|
|
const emptyStateKind = resolveCanvasEmptyStateKind({
|
|
nodeCount: nodes.length,
|
|
shortDramaEnabled,
|
|
isProjectLinked: Boolean(currentProject?.projectId),
|
|
starterMode: currentProject?.starterMode,
|
|
});
|
|
const emptyCanvasState =
|
|
emptyStateKind === "freeform" ? (
|
|
<CanvasFreeformEmptyState commands={freeformCreateCommands} />
|
|
) : emptyStateKind === "linked" ? (
|
|
<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: linkedProjectId, title: first.title, position: first.position });
|
|
}}
|
|
onOpenAssets={() => openProjectAssets()}
|
|
onAddText={() => createNode(CanvasNodeType.Text)}
|
|
/>
|
|
) : emptyStateKind === "guided" ? (
|
|
<CanvasShortDramaEmptyState
|
|
onCreatePipeline={createShortDramaPipeline}
|
|
onOpenAgent={() => {
|
|
setCinematicAgentEntry(true);
|
|
openAgent();
|
|
}}
|
|
onStartFreeform={() => updateProject(projectId, { starterMode: "freeform" })}
|
|
onUpload={() => handleUploadRequest()}
|
|
onAddText={() => createNode(CanvasNodeType.Text)}
|
|
onAddScript={() => createNode(CanvasNodeType.Script)}
|
|
/>
|
|
) : null;
|
|
if (!projectLoaded && loadError)
|
|
return (
|
|
<main className="flex h-full flex-col items-center justify-center gap-4">
|
|
<p role="alert">{loadError}</p>
|
|
<Button onClick={retryLoad}>重新加载</Button>
|
|
<Link to="/canvas">返回画布库</Link>
|
|
</main>
|
|
);
|
|
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={`relative flex h-full min-h-0 overflow-hidden outline-none ${!focusMode && !versions.preview ? `canvas-main-with-workspace ${workspaceOpen ? "canvas-workspace-expanded" : ""}` : ""}`} style={{ background: resolvedCanvasAppearance.background, color: theme.node.text }}>
|
|
{!focusMode && !versions.preview ? <CanvasWorkspacePanel key={projectId} open={workspaceOpen} onOpen={() => setWorkspaceOpen(true)} onInsertAssets={handleProjectAssetsInsert} projectId={projectId} nodes={nodes} selectedNodeIds={selectedNodeIds} onClose={() => setWorkspaceOpen(false)} onAssets={() => openCanvasAssetLibrary()} onProjectAssets={currentProject?.projectId ? () => openProjectAssets() : undefined} onCancelTask={cancelCanvasTask} 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);
|
|
}} /> : null}
|
|
{!workspaceOpen && !focusMode && !versions.preview && shortDramaEnabled && currentProject?.projectId ? (
|
|
<CanvasProjectSidebar projectId={currentProject.projectId} detail={linkedProjectQuery.data} onAddChapter={handleProjectChapterInsert} onLocateStyle={locateProjectStyleNode} onOpenAssets={() => openProjectAssets()} />
|
|
) : null}
|
|
<CanvasOverlayLayerProvider>
|
|
<div className="canvas-editor-shell relative flex min-w-0 flex-1">
|
|
<section data-canvas-editor inert={Boolean(versions.preview)} style={{ visibility: versions.preview ? "hidden" : undefined, opacity: versions.preview ? 0 : undefined }} className="relative min-w-0 flex-1 flex flex-col min-h-0 overflow-hidden">
|
|
{!focusMode ? (
|
|
<CanvasTopBar
|
|
syncStatus={<CanvasSyncStatus projectId={projectId} onLoadLatest={reloadLatestCanvasProject} onOpenVersions={openVersions} />}
|
|
versionsOpen={versions.open}
|
|
onToggleVersions={() => { closeAgent(); setVersionCompareRootId(null); versions.toggle(); }}
|
|
title={currentProject?.title || "未命名画布"}
|
|
titleDraft={titleDraft}
|
|
isTitleEditing={titleEditing}
|
|
onTitleDraftChange={setTitleDraft}
|
|
onStartTitleEditing={startTitleEditing}
|
|
onFinishTitleEditing={finishTitleEditing}
|
|
onCancelTitleEditing={() => setTitleEditing(false)}
|
|
canUndo={historyState.canUndo}
|
|
canRedo={historyState.canRedo}
|
|
onCreateProject={createAndOpenProject}
|
|
onDeleteProject={deleteCurrentProject}
|
|
onSave={() => void saveCanvasProject()}
|
|
onForceSave={confirmForceSaveCanvas}
|
|
onImportImage={() => handleUploadRequest()}
|
|
onImportLibTV={() => setLibTVImportOpen(true)}
|
|
onImportTapNow={() => setTapNowImportOpen(true)}
|
|
onUndo={undoCanvas}
|
|
onRedo={redoCanvas}
|
|
onShare={() => setShareModalOpen(true)}
|
|
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} />
|
|
<LibTVImportDialog open={libTVImportOpen} projectId={projectId} viewport={viewport} viewportSize={size} onClose={() => setLibTVImportOpen(false)} onApply={applyLibTVImport} />
|
|
<TapNowImportDialog open={tapNowImportOpen} projectId={projectId} viewport={viewport} viewportSize={size} onClose={() => setTapNowImportOpen(false)} onApply={applyTapNowImport} />
|
|
|
|
<CanvasStylePickerModal open={stylePickerOpen} value={activeStylePresetId} applying={styleApplying} onClose={() => setStylePickerOpen(false)} onSelect={selectCanvasStyle} />
|
|
|
|
<CanvasPrevisTemplateModal open={Boolean(previsTemplateRequest)} onClose={() => setPrevisTemplateRequest(null)} onSelect={(templateId) => createPrevisShot(templateId, previsTemplateRequest?.position)} />
|
|
|
|
<div className="relative flex min-h-0 min-w-0 flex-1">
|
|
<div className="relative min-w-0 flex-1 overflow-hidden">
|
|
<InfiniteCanvas
|
|
interactive={!versions.preview}
|
|
containerRef={containerRef}
|
|
viewport={viewport}
|
|
appearance={canvasAppearance}
|
|
backgroundMode={backgroundMode}
|
|
graphicsLayer={
|
|
<CanvasLeaferGraphicsLayer
|
|
containerRef={containerRef}
|
|
viewport={viewport}
|
|
theme={theme}
|
|
displayConnections={visibleDisplayConnections}
|
|
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}
|
|
>
|
|
<CanvasNodeActionContext.Provider value={canvasNodeActions}>
|
|
<CanvasNodeGraphContext.Provider value={nodeGraphContext}>
|
|
<CanvasProjectWorldLayers
|
|
connectionApproach={connectionApproach}
|
|
projectId={projectId}
|
|
viewportScale={viewport.k}
|
|
connectionLayerBounds={connectionLayerBounds}
|
|
displayConnections={visibleDisplayConnections}
|
|
selectedConnectionId={selectedConnectionId}
|
|
relatedConnectionIds={relatedHighlight.connectionIds}
|
|
scriptScrollTopById={scriptScrollTopById}
|
|
connectingParams={connectingParams}
|
|
mouseWorld={mouseWorld}
|
|
connectionTargetNodeId={connectionTargetNodeId}
|
|
nodeById={nodeById}
|
|
visibleNodes={visibleNodes}
|
|
nodeRenderLODById={nodeRenderLODById}
|
|
nodeStackOrder={nodeStackOrder}
|
|
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}
|
|
onReloadResource={reloadCanvasNodeResource}
|
|
onOpenTaskDetails={openCanvasNodeTaskDetails}
|
|
onOpenVersions={openCanvasNodeVersions}
|
|
onViewImage={viewCanvasNodeImage}
|
|
onReplaceMedia={replaceCanvasNodeMedia}
|
|
onOpenTextEditor={openTextNodeEditor}
|
|
onOpenPrevis={editCanvasPrevis}
|
|
onOpenDrawing={openDrawingNode}
|
|
onStartBatchConnection={startBatchConnection}
|
|
/>
|
|
</CanvasNodeGraphContext.Provider>
|
|
</CanvasNodeActionContext.Provider>
|
|
</InfiniteCanvas>
|
|
|
|
<CanvasActiveTaskPanel tasks={activeTasks} onCancelTask={cancelCanvasTask} topInset={focusMode ? "var(--space-3)" : "var(--canvas-topbar-offset)"} />
|
|
|
|
{focusMode ? (
|
|
<CanvasFocusModeBar
|
|
syncStatus={<CanvasSyncStatus projectId={projectId} onLoadLatest={reloadLatestCanvasProject} onOpenVersions={openVersions} />}
|
|
versionsOpen={versions.open}
|
|
onToggleVersions={() => { closeAgent(); versions.toggle(); }}
|
|
dockRevealed={focusDockRevealed}
|
|
zoomPercent={viewport.k}
|
|
onToggleDock={() => setFocusDockRevealed((value) => !value)}
|
|
onExit={exitFocusMode}
|
|
onZoomIn={zoomCanvasIn}
|
|
onZoomOut={zoomCanvasOut}
|
|
onFit={fitCanvasContent}
|
|
/>
|
|
) : 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}
|
|
appearance={canvasAppearance}
|
|
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)}
|
|
onAddExtensionNode={(type) => createNode(type)}
|
|
onAddWorkflow={() => createNode(CanvasNodeType.Config)}
|
|
onOpenPrevis={() => setPrevisTemplateRequest({})}
|
|
onUndo={undoCanvas}
|
|
onRedo={redoCanvas}
|
|
onUpload={() => handleUploadRequest()}
|
|
onDelete={() => deleteNodes(new Set(selectedNodeIds))}
|
|
onClear={() => setClearConfirmOpen(true)}
|
|
onDeselect={deselectCanvas}
|
|
onAppearanceChange={applyCanvasAppearance}
|
|
onSaveAppearanceDefault={saveCanvasAppearanceDefault}
|
|
onBackgroundModeChange={setBackgroundMode}
|
|
onShowImageInfoChange={setShowImageInfo}
|
|
onOpenWorkspace={() => setWorkspaceOpen(value => !value)}
|
|
onOpenMyAssets={() => {
|
|
openCanvasAssetLibrary();
|
|
}}
|
|
onOpenProjectCharacters={() => openCharacterLibrary()}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className={versions.open ? "hidden" : "contents"}>
|
|
<CanvasCloudAgentPanel canvasId={projectId} domainProjectId={currentProject?.projectId} canvasNodes={nodes} runningNodeId={runningNodeId} nodeCount={nodes.length} selectedNodeIds={Array.from(selectedNodeIds)} references={agentMentionReferences} prefillRequest={agentPrefillRequest} open={assistantOpen} onOpen={openAgent} onCollapse={closeAgent} onFocusNode={(nodeId) => {
|
|
const currentNodes = nodesRef.current;
|
|
const target = currentNodes.find((node) => node.id === nodeId);
|
|
if (!target) { message.info("该节点已删除或尚未同步到画布"); return; }
|
|
|
|
const parent = target.parentId ? currentNodes.find((node) => node.id === target.parentId) : null;
|
|
if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id);
|
|
|
|
const batchRootId = target.metadata?.batchRootId;
|
|
const batchRoot = batchRootId ? currentNodes.find((node) => node.id === batchRootId) : null;
|
|
if (batchRoot && isHiddenBatchChild(target, currentNodes) && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id);
|
|
|
|
focusCanvasNode(nodeId);
|
|
}} />
|
|
</div>
|
|
</div>
|
|
|
|
{angleNode?.metadata?.content ? (
|
|
<CanvasNodePanelOverlay
|
|
node={angleNode}
|
|
viewport={viewport}
|
|
containerRef={containerRef}
|
|
panelWidth={640}
|
|
panelHeight={540}
|
|
allowOverflow
|
|
dragOffset={dragPreview?.nodeIds.has(angleNode.id) ? { x: dragPreview.x, y: dragPreview.y } : null}
|
|
isDragging={isNodeDragging && Boolean(dragPreview?.nodeIds.has(angleNode.id))}
|
|
>
|
|
<CanvasNodeAnglePanel
|
|
dataUrl={angleNode.metadata.content}
|
|
onClose={() => setAngleNodeId(null)}
|
|
onConfirm={(params) => {
|
|
void generateAngleNode(angleNode, params);
|
|
}}
|
|
/>
|
|
</CanvasNodePanelOverlay>
|
|
) : null}
|
|
|
|
{lightingNode?.metadata?.content ? (
|
|
<AppModal flush open centered title={null} closable={false} footer={null} width={720} onCancel={() => setLightingNodeId(null)}>
|
|
<CanvasNodeLightingPanel
|
|
dataUrl={lightingNode.metadata.content}
|
|
onClose={() => setLightingNodeId(null)}
|
|
onConfirm={(options, prompt) => {
|
|
generateLightingNode(lightingNode, options, prompt);
|
|
}}
|
|
/>
|
|
</AppModal>
|
|
) : null}
|
|
|
|
{emotionNode?.metadata?.content && !isCanvasNodeMoving ? (
|
|
<CanvasEmotionWorkspace
|
|
node={emotionNode}
|
|
viewport={viewport}
|
|
containerRef={containerRef}
|
|
dragOffset={dragPreview?.nodeIds.has(emotionNode.id) ? { x: dragPreview.x, y: dragPreview.y } : null}
|
|
isDragging={isNodeDragging && Boolean(dragPreview?.nodeIds.has(emotionNode.id))}
|
|
onClose={() => setEmotionNodeId(null)}
|
|
onConfirm={(payload: CanvasImageEmotionPayload) => {
|
|
void generateEmotionNode(emotionNode, payload);
|
|
}}
|
|
/>
|
|
) : null}
|
|
|
|
{dialogNode &&
|
|
!isCanvasImageSourceNode(dialogNode) &&
|
|
!dialogNode.metadata?.fileUpload &&
|
|
dialogNode.type !== CanvasNodeType.Script &&
|
|
dialogNode.type !== CanvasNodeType.BatchTable &&
|
|
dialogNode.type !== CanvasNodeType.Drawing &&
|
|
dialogNode.type !== CanvasNodeType.Panorama &&
|
|
!(dialogNode.metadata?.workflowKind === "character" && dialogNode.metadata.characterAssetId) &&
|
|
!dialogNode.metadata?.previsSceneId &&
|
|
!selectionBox &&
|
|
!isCanvasNodeMoving ? (
|
|
<CanvasNodePanelOverlay
|
|
node={dialogNode}
|
|
viewport={viewport}
|
|
containerRef={containerRef}
|
|
allowOverflow={dialogNode.type !== CanvasNodeType.Config}
|
|
dragOffset={dragPreview?.nodeIds.has(dialogNode.id) ? { x: dragPreview.x, y: dragPreview.y } : null}
|
|
isDragging={isNodeDragging && Boolean(dragPreview?.nodeIds.has(dialogNode.id))}
|
|
>
|
|
{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}
|
|
|
|
{connectionReplaceHover ? (
|
|
<div
|
|
className="pointer-events-none fixed z-[var(--z-dialog-popover)] flex select-none items-center gap-1.5 rounded-full border border-white/15 bg-black/60 px-2.5 py-1 text-[11px] font-medium text-white/90 shadow-[0_8px_24px_rgba(0,0,0,0.4)] backdrop-blur-md transition-all"
|
|
style={{
|
|
left: connectionReplaceHover.clientX + 14,
|
|
top: connectionReplaceHover.clientY + 14,
|
|
transform: "translateY(-50%)",
|
|
}}
|
|
>
|
|
<ArrowLeftRight className="size-3 text-blue-400" />
|
|
<span>松开替换</span>
|
|
<span className="rounded bg-white/15 px-1.5 py-0.5 text-[10px] font-semibold text-blue-200">@{connectionReplaceHover.referenceLabel}</span>
|
|
</div>
|
|
) : null}
|
|
|
|
{selectedNodeBounds && !selectionBox && !isCanvasNodeMoving ? (
|
|
<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()}
|
|
onSendSelectionToAgent={() => sendSelectionToAgent()}
|
|
/>
|
|
) : null}
|
|
|
|
{uploadStatus ? <CanvasUploadStatusToast status={uploadStatus} theme={theme} /> : null}
|
|
{mergeVideoProgress ? <CanvasMergeStatusToast progress={mergeVideoProgress} theme={theme} /> : null}
|
|
{lastAgentChange ? (
|
|
<CanvasOperationChangeToast
|
|
change={lastAgentChange}
|
|
theme={theme}
|
|
onView={viewLastAgentChange}
|
|
onUndo={() => {
|
|
undoAgentOps();
|
|
}}
|
|
onClose={dismissLastAgentChange}
|
|
/>
|
|
) : null}
|
|
|
|
<CanvasNodeToolbar
|
|
node={isCanvasNodeMoving || nodeImageSettingsOpen || emotionNodeId || angleNodeId ? 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)}
|
|
onAnnotationEdit={openAnnotationEditNode}
|
|
onMaskEdit={(node) => setMaskEditNodeId(node.id)}
|
|
onRemoveBackground={openBackgroundRemoval}
|
|
onLayerDecomposition={openLayerDecomposition}
|
|
onTextEdit={openTextEditNode}
|
|
onEmotion={(node) => {
|
|
setDialogNodeId(null);
|
|
setEmotionNodeId((current) => (current === node.id ? null : node.id));
|
|
}}
|
|
onPortraitTexture={openPortraitTextureEditor}
|
|
onCrop={(node) => setCropNodeId(node.id)}
|
|
onSplit={(node, params) => void splitImageNode(node, params)}
|
|
onUpscale={(node) => setUpscaleNodeId(node.id)}
|
|
onSuperResolve={(node) => setSuperResolveNodeId(node.id)}
|
|
onAngle={(node) => {
|
|
setDialogNodeId(null);
|
|
setAngleNodeId((current) => (current === node.id ? null : node.id));
|
|
}}
|
|
onLighting={(node) => {
|
|
setDialogNodeId(null);
|
|
setLightingNodeId((current) => (current === node.id ? null : node.id));
|
|
}}
|
|
onPanorama={openPanoramaConfig}
|
|
onViewImage={(node) => setPreviewNodeId(node.id)}
|
|
onExtractVideoFrames={openVideoFrameExtractor}
|
|
onExtractAudioFromVideo={(node) => void extractAudioFromVideo(node)}
|
|
onTrimVideoSegments={openVideoSegmentExtractor}
|
|
onSubtitles={(node) => setSubtitleNodeId(node.id)}
|
|
onTimeline={(node) => setTimelineNodeId(node.id)}
|
|
extractingVideoFrames={toolbarNode?.id === extractingVideoFramesNodeId}
|
|
extractingAudio={segmentRunningMode === "audio"}
|
|
trimmingVideo={segmentRunningMode === "video"}
|
|
onNineGrid={(node, id, label, icon) => void generateNineGridNode(node, id, label, icon)}
|
|
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 ? (
|
|
<CanvasOverlayLayerContainer
|
|
overlayId="asset-tray"
|
|
fallbackZIndex="var(--z-panel)"
|
|
className="canvas-workspace-zoom-controls absolute bottom-[calc(var(--canvas-inset-y)+var(--space-16))] left-[var(--canvas-inset-x)] 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}
|
|
onFitContent={fitCanvasContent}
|
|
onAutoArrange={autoArrangeCanvasNodes}
|
|
hideNodeConnections={hideNodeConnections}
|
|
onHideNodeConnectionsChange={setHideNodeConnections}
|
|
isMiniMapOpen={isMiniMapOpen}
|
|
onToggleMiniMap={() => setIsMiniMapOpen((value) => !value)}
|
|
onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)}
|
|
/>
|
|
</CanvasOverlayLayerContainer>
|
|
) : null}
|
|
|
|
<CanvasProjectContextMenu
|
|
menu={contextMenu}
|
|
node={contextMenuNode}
|
|
workspaceMode={workspaceMode}
|
|
isProjectLinked={Boolean(currentProject?.projectId)}
|
|
canUndo={historyState.canUndo}
|
|
canRedo={historyState.canRedo}
|
|
canPaste={hasCopiedNodes || Boolean(navigator.clipboard)}
|
|
selectedCount={selectedNodeIds.size}
|
|
screenToCanvas={screenToCanvas}
|
|
onClose={() => setContextMenu(null)}
|
|
onAddNode={(type, position) => createNode(type, position)}
|
|
onAddFolder={createFolder}
|
|
onChooseStyle={() => setStylePickerOpen(true)}
|
|
onOpenPrevis={(position) => setPrevisTemplateRequest({ position })}
|
|
onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)}
|
|
onOpenAssets={openCanvasAssetLibrary}
|
|
onOpenProjectCharacters={(position) => openCharacterLibrary(position)}
|
|
onUndo={undoCanvas}
|
|
onRedo={redoCanvas}
|
|
onPaste={pasteAtPosition}
|
|
onCopyNode={(nodeId) => copyNodesToClipboard(new Set([nodeId]))}
|
|
onCreateGenerationCopy={(nodeId) => duplicateNode(nodeId, "copy")}
|
|
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);
|
|
}}
|
|
onUploadToArkPrivateAsset={confirmUploadNodeImageToArkPrivateAsset}
|
|
onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })}
|
|
onToggleFrame={(node) => handleFrameToggle(node.id)}
|
|
onSpreadSelection={spreadSelectedNodes}
|
|
onCopySelection={copySelectedNodes}
|
|
onDeleteSelection={() => deleteNodes(selectedNodeIds)}
|
|
onSendToAgent={() => sendSelectionToAgent(contextMenu?.type === "node" && selectedNodeIds.size <= 1 ? contextMenu.nodeId : undefined)}
|
|
/>
|
|
|
|
<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}
|
|
|
|
{frameNode ? <CanvasVideoFrameDialog node={frameNode} open={Boolean(frameNode)} onClose={closeFrameDialog} onConfirm={(params) => void extractVideoFrames(frameNode, params)} /> : 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} canvasNodes={nodes} open={Boolean(characterReferenceNode)} onClose={() => setCharacterReferenceNodeId(null)} onUpdated={(detail) => setNodes((current) => refreshCanvasCharacterReferenceNodes(current, [{ ...detail.asset, character: detail.character }]))} />
|
|
|
|
<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}
|
|
|
|
<AiArtCritiqueModal
|
|
startRequestId={artCritiqueStartRequest && artCritiqueStartRequest.nodeId === artCritiqueNode?.id ? artCritiqueStartRequest.id : undefined}
|
|
restartRequested={artCritiqueStartRequest?.nodeId === artCritiqueNode?.id && artCritiqueStartRequest?.restart}
|
|
onRunningChange={(running) => {
|
|
artCritiqueRunningRef.current = running;
|
|
}}
|
|
node={artCritiqueNode}
|
|
upstreamNodes={artCritiqueInputs}
|
|
open={Boolean(artCritiqueNode)}
|
|
onClose={() => setArtCritiqueNodeId(null)}
|
|
onUpdateState={(nodeId, state) => handleConfigNodeChange(nodeId, { artCritique: state })}
|
|
/>
|
|
|
|
<CanvasPanoramaConfigModal
|
|
open={Boolean(panoramaConfigNodeId)}
|
|
onCancel={() => setPanoramaConfigNodeId(null)}
|
|
onConfirm={(composedPrompt, config) => {
|
|
const node = nodes.find((n) => n.id === panoramaConfigNodeId);
|
|
if (node) {
|
|
createPanoramaViewerWithConfig(node, composedPrompt, config);
|
|
}
|
|
}}
|
|
onCopyPrompt={(prompt) => {
|
|
void navigator.clipboard?.writeText(prompt).then(() => message.success("已复制全景提示词"));
|
|
}}
|
|
previewImageUrl={panoramaConfigNodeId ? nodes.find((n) => n.id === panoramaConfigNodeId)?.metadata?.content : undefined}
|
|
nodes={nodes}
|
|
/>
|
|
|
|
<CanvasScriptEditor
|
|
node={activeScriptNode}
|
|
nodes={nodes}
|
|
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 })}
|
|
/>
|
|
|
|
{previsNodeId && activePrevisScene ? (
|
|
<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>
|
|
}
|
|
>
|
|
<CanvasPrevisWorkbench
|
|
open
|
|
canvasId={projectId}
|
|
scene={activePrevisScene}
|
|
imageNodes={nodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey))}
|
|
onClose={() => setPrevisNodeId(null)}
|
|
onChange={savePrevisScene}
|
|
onApply={applyPrevisOutput}
|
|
onDeleteImageNode={(nodeId) => deleteNodes(new Set([nodeId]))}
|
|
onFlush={() => flushCanvasStorePersistence()}
|
|
onboardingScope={previsOnboardingScope}
|
|
/>
|
|
</Suspense>
|
|
) : null}
|
|
|
|
<CanvasVersionCompareModal
|
|
open={Boolean(versionCompareRootId)}
|
|
versions={versionCompareNodes}
|
|
onClose={() => setVersionCompareRootId(null)}
|
|
onSetPrimary={setPrimaryVersion}
|
|
onFocus={(nodeId) => {
|
|
setVersionCompareRootId(null);
|
|
focusCanvasNode(nodeId);
|
|
}}
|
|
/>
|
|
|
|
<CanvasProjectMediaDialogs
|
|
cropNode={cropNode}
|
|
annotationNode={annotationNode}
|
|
annotationEditNode={annotationEditNodeId ? nodeById.get(annotationEditNodeId) || null : null}
|
|
maskEditNode={maskEditNode}
|
|
imageEditNode={imageEditNode}
|
|
layerDecompositionNode={layerDecompositionNodeId ? nodeById.get(layerDecompositionNodeId) || null : null}
|
|
textEditNode={textEditNodeId ? nodeById.get(textEditNodeId) || null : null}
|
|
imageEditPreset={imageEditPreset}
|
|
upscaleNode={upscaleNode}
|
|
onCloseCrop={() => setCropNodeId(null)}
|
|
onCloseAnnotation={() => setAnnotationNodeId(null)}
|
|
onCloseAnnotationEdit={() => setAnnotationEditNodeId(null)}
|
|
onCloseMaskEdit={() => setMaskEditNodeId(null)}
|
|
onCloseImageEdit={() => { setImageEditNodeId(null); setImageEditPreset(null); }}
|
|
onCloseLayerDecomposition={() => setLayerDecompositionNodeId(null)}
|
|
onCloseTextEdit={() => setTextEditNodeId(null)}
|
|
onCloseUpscale={() => setUpscaleNodeId(null)}
|
|
onCrop={(node, crop) => void cropImageNode(node, crop)}
|
|
onAnnotate={(node, dataUrl) => void saveAnnotatedImageNode(node, dataUrl)}
|
|
onAnnotationEdit={(node, payload) => void editAnnotatedImageNode(node, payload)}
|
|
onMaskEdit={(node, payload) => void maskEditImageNode(node, payload)}
|
|
onImageOperation={(node, payload) => void editImageNode(node, payload)}
|
|
onLayerDecomposition={(node, payload) => void decomposeImageLayers(node, payload)}
|
|
onDetectText={() => {
|
|
const node = textEditNodeId ? nodeById.get(textEditNodeId) : null;
|
|
return node ? detectImageText(node) : Promise.reject(new Error("图片节点已不存在"));
|
|
}}
|
|
onTextEdit={(node, payload) => void editTextImageNode(node, payload)}
|
|
onUpscale={(node, params) => void upscaleImageNode(node, params)}
|
|
config={effectiveConfig}
|
|
/>
|
|
|
|
<CanvasProjectStatusDialogs
|
|
theme={theme}
|
|
task={taskDetail}
|
|
taskLogs={taskDetailLogs}
|
|
taskLoading={taskDetailLoading}
|
|
onCloseTask={() => setTaskDetail(null)}
|
|
onCancelTask={cancelCanvasTask}
|
|
superResolveNode={superResolveNode}
|
|
onCloseSuperResolve={() => setSuperResolveNodeId(null)}
|
|
previewNode={previewNode}
|
|
onClosePreview={() => setPreviewNodeId(null)}
|
|
clearConfirmOpen={clearConfirmOpen}
|
|
onCancelClear={() => setClearConfirmOpen(false)}
|
|
onConfirmClear={clearCanvas}
|
|
/>
|
|
|
|
<BatchGenerationSettingsDialog
|
|
open={batchGenDialogOpen}
|
|
config={batchGenDialogConfig}
|
|
rowCount={batchGenDialogRowCount}
|
|
concurrency={batchGenDialogConcurrency}
|
|
onClose={closeBatchGenDialog}
|
|
onConfirm={confirmBatchGenDialog}
|
|
/>
|
|
|
|
<AssetPickerModal open={assetPickerOpen} multiple={assetInsertScope === "canvas"} onInsert={handleLibraryAssetsInsert} onClose={closeAssetPicker} />
|
|
<CanvasCharacterLibraryModal
|
|
open={characterLibraryOpen}
|
|
imageResourceId={resourceIdFromStorageKey(selectedCharacterImage?.metadata?.storageKey) || undefined}
|
|
audioResourceId={resourceIdFromStorageKey(selectedCharacterAudio?.metadata?.storageKey) || undefined}
|
|
imageTitle={selectedCharacterImage?.title}
|
|
audioTitle={selectedCharacterAudio?.title}
|
|
onClose={() => setCharacterLibraryOpen(false)}
|
|
onInsert={async (payloads) => {
|
|
await handleProjectAssetsInsert(payloads, characterLibraryPosition);
|
|
}}
|
|
/>
|
|
<CanvasProjectAssetModal
|
|
open={projectAssetOpen}
|
|
detail={linkedProjectQuery.data}
|
|
initialCategory={projectAssetInitialCategory}
|
|
initialFolderId={projectAssetInitialFolderId}
|
|
onClose={closeProjectAssets}
|
|
onInsert={handleTimelineProjectAssetsInsert}
|
|
onInsertFolder={projectAssetScope === "canvas" ? handleProjectFolderInsert : undefined}
|
|
/>
|
|
</section>
|
|
{versions.preview ? <CanvasVersionPreview key={versions.preview.key} preview={versions.preview} onReturn={versions.returnToCurrent} onShowVersions={versions.show} /> : null}
|
|
</div>
|
|
</CanvasOverlayLayerProvider>
|
|
<CanvasVersionHistory history={versions} />
|
|
</main>
|
|
</>
|
|
);
|
|
}
|