"use client"; import { AlertCircle, AlertTriangle, ArrowLeft, Bot, Box, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, CheckCircle2, Download, Eye, FileImage, FileText, Film, FolderOpen, Flag, GitBranch, Grid3X3, Hand, Heart, HelpCircle, ImagePlus, Layers, Lightbulb, Loader2, type LucideIcon, MessageCircle, MonitorSmartphone, MousePointer, Music2, Palette, Pencil, PenTool, Pin, Plus, Redo2, Save, Scan, Scissors, Settings, Sparkles, Star, Table2, Tag, Trash2, Type, Undo2, Upload, UserRound, Video, Wand2, Zap, ZoomIn, ZoomOut, } from "lucide-react"; import dynamic from "next/dynamic"; import { useTheme } from "next-themes"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { type ChangeEvent, type ComponentType, type PointerEvent as ReactPointerEvent, startTransition, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { toast } from "sonner"; import useSWR from "swr"; // 深色主题组件 V2 (新版布局) import type { AgentGenerationWorkspaceDarkV2Props } from "@/components/agents/AgentGenerationWorkspaceDarkV2"; import { AgentWorkflowBrandStorePanel, AgentWorkflowPanelDarkV2, } from "@/components/agents/AgentWorkflowPanelDarkV2"; import { type AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanelNew"; import { CanvasAddImageDialog, type CanvasAddImageDialogImage, } from "@/components/canvas/CanvasAddImageDialog"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { ClientErrorBoundary } from "@/components/ui/client-error-boundary"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { createEmptyAgentGenerationProjectPersistedState, createEmptyAgentGenerationProjectWorkspaceState, createEmptyAgentWorkflowPersistedState, normalizeAgentGenerationProjectPersistedState, normalizeAgentGenerationProjectReplacementResultState, } from "@/lib/agents/generation-project-state"; import { createEmptyAgentModeWorkspaceState, normalizeAgentModeWorkspaceState, } from "@/lib/agents/mode-workspace-state"; import { normalizeAgentPresetModeConfig } from "@/lib/agents/preset-mode-config"; import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config"; import { compactVideoCanvasMarkers, VIDEO_CANVAS_MARKER_COLORS, VIDEO_CANVAS_MARKER_METADATA_KEY, VIDEO_CANVAS_PRIORITY_MARKERS, VIDEO_CANVAS_SYMBOL_MARKERS, VIDEO_CANVAS_TASK_MARKERS, normalizeVideoCanvasMarkers, type VideoCanvasMarkerKey, type VideoCanvasMarkerState, type VideoCanvasMarkerValue, type VideoCanvasSymbolMarkerId, } from "@/lib/canvas/video-canvas-markers"; import { LOCAL_UPLOAD_PENDING_METADATA_KEY, LOCAL_UPLOAD_PREVIEW_METADATA_KEY, } from "@/lib/canvas/local-upload-state"; import { getCanvasToolbarItem, getDefaultCanvasToolbarItem, isCanvasToolbarItemEnabled, normalizeCanvasToolbarConfig, type CanvasToolbarConfig, type CanvasToolbarItemConfig, type CanvasToolbarItemKey, } from "@/lib/canvas-toolbar-config"; import type { CanvasAgentFeatureSettings } from "@/lib/canvas/agent-feature-flags"; import { buildAgentWorkflowComposerContext, } from "@/lib/agents/workflow-composer"; import { normalizeStyleProPersistedState } from "@/lib/style-pro/config"; import { createEmptyAgentWorkflowMaterialSelections, normalizeAgentWorkflowMaterialSelections, } from "@/lib/agents/workflow-material-config"; import { defaultAgentWorkflowUiConfig, flattenVisibleAgentWorkflowSteps, getEnabledAgentWorkflowStepTree, getAgentWorkflowBusinessModeSettings, getAgentWorkflowPageFeatureSettings, sanitizeAgentWorkflowSelections, } from "@/lib/agents/workflow-ui-config"; import { loadCanvasChatPanelModule, preloadCanvasChatPanel, } from "@/components/canvas/canvas-chat-panel-loader"; import { preloadCanvasChatData } from "@/lib/client/canvas-chat-preload"; import { cn, fetcher } from "@/lib/utils"; import { buildVideoWorkflowCanvasNode, getVideoWorkflowNodeDefinition, getVideoWorkflowQuickConnectOutputs, getVideoWorkflowSectionNodes, inferVideoWorkflowTemplateType, VIDEO_WORKFLOW_LIBRARY_SECTIONS, type VideoWorkflowTemplateType, } from "@/lib/video-workflow/catalog"; import { buildWorkflowPrompt, collectWorkflowMedia, collectWorkflowStoryboardShots, collectWorkflowStructuredData, createWorkflowGraphContext, getBestWorkflowPreviewMedia, getDirectDownstreamNodes, inferVideoGenerationModeFromMedia, summarizeWorkflowInputs, type WorkflowAssetRecord, type WorkflowCharacterRecord, type WorkflowMediaReference, type WorkflowSceneRecord, } from "@/lib/video-workflow/runtime"; import { agentProjectService } from "@/services/agent-projects"; import { canvasService, imageGenerationService, videoGenerationService, workflowAnalysisService, } from "@/services/canvas"; import { canvasSelectors, useCanvasStore } from "@/store/canvas"; import type { AgentGenerationProject, AgentGenerationProjectPersistedState, AgentGenerationProjectReplacementResultState, AgentGenerationProjectResultCollections, AgentGenerationProjectWorkspaceAttachment, AgentGenerationProjectWorkspaceState, AgentGenerationResultModeType, AgentModeWorkspaceState, AgentPreset, AgentWorkflowComposerContext, AgentWorkflowMaterialSelections, AgentWorkflowPersistedState, AgentWorkflowSelectedBrandModel, AgentWorkflowShowcaseConfig, AgentWorkflowStepKey, AgentWorkflowUiConfig, } from "@/types/agent"; import type { AgentSlotNodeData, CanvasEdge, CanvasInitialProject, CanvasNode, CanvasState, CanvasWorkspaceType, Position, ToolType, WorkflowNodeData, } from "@/types/canvas"; const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000; const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000; const CANVAS_SURFACE_BACKGROUND = "#f4f4f5"; const fetchCanvasSiteSettings = async (url: string) => { const separator = url.includes("?") ? "&" : "?"; const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort(); }, CANVAS_SITE_SETTINGS_TIMEOUT_MS); const response = await fetch(`${url}${separator}t=${Date.now()}`, { cache: "no-store", signal: controller.signal, }).finally(() => { clearTimeout(timeoutId); }); if (!response.ok) { throw new Error("Failed to fetch canvas site settings"); } return response.json(); }; function CanvasLazyPanelLoader({ label, className, }: { label: string; className?: string; }) { return (
{label}
); } const CanvasWorkspace = dynamic( () => import("@/components/canvas/CanvasWorkspace").then( (module) => module.CanvasWorkspace ), { loading: () => , ssr: false, } ); const CanvasChatPanel = dynamic( () => loadCanvasChatPanelModule().then( (module) => module.CanvasChatPanel ), { loading: () => , ssr: false, } ); const CanvasFileListPanel = dynamic( () => import("@/components/canvas/CanvasFileListPanel").then( (module) => module.CanvasFileListPanel ), { loading: () => , ssr: false, } ); const AgentCanvasTopNavDark = dynamic( () => import("@/components/agents/AgentCanvasTopNavDark").then( (module) => module.AgentCanvasTopNavDark ), { loading: () =>
, ssr: false, } ); const AGENT_WORKSPACE_LOAD_TIMEOUT_MS = 15_000; let cachedAgentGenerationWorkspace: | ComponentType | null = null; let agentGenerationWorkspaceLoadPromise: | Promise> | null = null; function loadAgentGenerationWorkspaceDarkV2() { if (cachedAgentGenerationWorkspace) { return Promise.resolve(cachedAgentGenerationWorkspace); } if (!agentGenerationWorkspaceLoadPromise) { agentGenerationWorkspaceLoadPromise = import( "@/components/agents/AgentGenerationWorkspaceDarkV2" ) .then((module) => { cachedAgentGenerationWorkspace = module.AgentGenerationWorkspaceDarkV2; return cachedAgentGenerationWorkspace; }) .catch((error) => { agentGenerationWorkspaceLoadPromise = null; throw error; }); } return agentGenerationWorkspaceLoadPromise; } function resetAgentGenerationWorkspaceLoader() { cachedAgentGenerationWorkspace = null; agentGenerationWorkspaceLoadPromise = null; } function AgentGenerationWorkspaceDarkV2( props: AgentGenerationWorkspaceDarkV2Props ) { const [WorkspaceComponent, setWorkspaceComponent] = useState< ComponentType | null >(() => cachedAgentGenerationWorkspace); const [loadError, setLoadError] = useState(null); const [showRecovery, setShowRecovery] = useState(false); const [retryToken, setRetryToken] = useState(0); useEffect(() => { if (WorkspaceComponent) return; let cancelled = false; setLoadError(null); setShowRecovery(false); const timeoutId = window.setTimeout(() => { if (!cancelled) { setShowRecovery(true); } }, AGENT_WORKSPACE_LOAD_TIMEOUT_MS); loadAgentGenerationWorkspaceDarkV2() .then((component) => { if (cancelled) return; setWorkspaceComponent(() => component); setLoadError(null); setShowRecovery(false); }) .catch((error) => { if (cancelled) return; const normalizedError = error instanceof Error ? error : new Error(String(error)); setLoadError(normalizedError); setShowRecovery(true); }) .finally(() => { window.clearTimeout(timeoutId); }); return () => { cancelled = true; window.clearTimeout(timeoutId); }; }, [WorkspaceComponent, retryToken]); if (WorkspaceComponent) { return ; } return (
{showRecovery ? (

工作台资源还没有完成加载,可能是浏览器缓存了旧版本资源或当前网络较慢。可以先重试加载,仍不恢复时刷新页面即可继续当前项目。

{loadError?.message ? (

{loadError.message}

) : null}
{props.onOpenCanvasEditor ? ( ) : null}
) : null}
); } const AgentProjectListSheet = dynamic( () => import("@/components/agents/AgentProjectListSheet").then( (module) => module.AgentProjectListSheet ), { loading: () => null, ssr: false, } ); const AgentWorkflowBrandModelPicker = dynamic( () => import("@/components/agents/AgentWorkflowBrandModelPicker").then( (module) => module.AgentWorkflowBrandModelPicker ), { loading: () => , ssr: false, } ); const AgentWorkflowMaterialSelectionPanel = dynamic( () => import("@/components/agents/AgentWorkflowMaterialSelectionPanel").then( (module) => module.AgentWorkflowMaterialSelectionPanel ), { loading: () => , ssr: false, } ); interface PageProps { params: Promise<{ id: string }>; initialProject?: CanvasInitialProject | null; } // 图片尺寸预设 const SIZE_PRESETS = [ { label: "1:1", value: "1:1", width: 1024, height: 1024 }, { label: "3:2", value: "3:2", width: 1536, height: 1024 }, { label: "2:3", value: "2:3", width: 1024, height: 1536 }, { label: "4:3", value: "4:3", width: 1365, height: 1024 }, { label: "3:4", value: "3:4", width: 1024, height: 1365 }, { label: "16:9", value: "16:9", width: 1820, height: 1024 }, { label: "9:16", value: "9:16", width: 1024, height: 1820 }, ]; interface VideoCanvasProjectExportPayload { format: "video-canvas-project"; version: 1; exportedAt: string; workspaceType: CanvasWorkspaceType; project: { id: string; title: string; }; canvasData: CanvasState; } interface VideoCanvasInsertMenuState { screenPosition: Position; canvasPosition: Position; sourceNodeId?: string | null; openLeft?: boolean; } interface VideoCanvasInsertMenuChild { id: string; label: string; templateType: VideoWorkflowTemplateType; } interface VideoCanvasInsertMenuItem { id: string; label: string; icon: LucideIcon; templateType?: VideoWorkflowTemplateType; children?: VideoCanvasInsertMenuChild[]; accent?: "highlight"; } interface VideoCanvasSidebarGroup { id: string; label: string; description: string; icon: LucideIcon; nodeTypes: VideoWorkflowTemplateType[]; } const IMPORTABLE_CANVAS_NODE_TYPES = new Set([ "message", "image", "video", "text", "workflow", "scene-fusion", "model3d", "world3d", ]); const IMPORTABLE_CANVAS_TOOL_TYPES = new Set([ "select", "pan", "draw", "text", ]); const DEFAULT_CANVAS_TEXT_COLOR = "#111827"; const VIDEO_CANVAS_INSERT_MENU_ITEMS: VideoCanvasInsertMenuItem[] = [ { id: "image", label: "图片", icon: FileImage, templateType: "input-image" }, { id: "text", label: "文字", icon: FileText, templateType: "text-node" }, { id: "video-frame", label: "视频抽帧", icon: Film, templateType: "video-input", }, { id: "video-analyze", label: "视频拆解", icon: Scissors, templateType: "video-analyze", }, { id: "storyboard", label: "分镜表", icon: GitBranch, children: [ { id: "storyboard-node", label: "分镜节点", templateType: "storyboard-node", }, { id: "storyboard-chart-node", label: "分镜图表", templateType: "storyboard-chart-node", }, ], }, { id: "table", label: "表格编辑", icon: Table2, templateType: "table-editor-node", }, { id: "doodle", label: "涂鸦画板", icon: PenTool, templateType: "doodle-canvas", }, { id: "paint", label: "绘画", icon: Palette, templateType: "gen-image" }, { id: "video", label: "视频", icon: Video, templateType: "gen-video" }, { id: "music", label: "音乐", icon: Music2, templateType: "gen-music" }, { id: "agent", label: "Agent", icon: Bot, templateType: "custom-agent" }, { id: "comfy", label: "Comfy UI", icon: Box, children: [ { id: "rh-comfy", label: "Comfy 工作流", templateType: "rh-comfy" }, { id: "rh-app", label: "RH 应用", templateType: "rh-app" }, ], }, { id: "compare", label: "对比", icon: Layers, templateType: "image-compare" }, { id: "inpaint", label: "局部重绘", icon: Scissors, children: [ { id: "inpaint-crop", label: "裁剪局部重绘", templateType: "inpaint-crop", }, { id: "inpaint-stitch", label: "无缝拼接", templateType: "inpaint-stitch", }, ], }, { id: "super-resolution", label: "智能超清", icon: Sparkles, templateType: "jimeng-super-resolution", }, { id: "motion", label: "动作迁移", icon: Clapperboard, templateType: "motion-control", }, { id: "preview", label: "预览", icon: Eye, templateType: "preview" }, { id: "save", label: "保存", icon: Save, templateType: "local-save" }, { id: "director", label: "导演模式", icon: Wand2, accent: "highlight", children: [ { id: "professional-camera", label: "专业镜头", templateType: "professional-camera", }, { id: "camera-movement", label: "镜头运动", templateType: "camera-movement", }, ], }, ]; const VIDEO_WORKFLOW_QUICK_CONNECT_ICON_MAP: Partial< Record > = { "text-node": FileText, "input-image": FileImage, "video-input": Film, "novel-input": FileText, "video-analyze": Scissors, "storyboard-node": GitBranch, "storyboard-chart-node": GitBranch, "table-editor-node": Table2, "gen-image": Palette, "gen-video": Video, "gen-music": Music2, "motion-control": Clapperboard, "camera-movement": Clapperboard, "professional-camera": Wand2, "inpaint-crop": Scissors, "inpaint-stitch": Scissors, "local-save": Save, preview: Eye, "custom-agent": Bot, "rh-app": Box, "rh-comfy": Box, "jimeng-super-resolution": Sparkles, "doodle-canvas": PenTool, "canvas-node": Layers, "image-compare": Layers, }; function getVideoWorkflowQuickConnectIcon(type: VideoWorkflowTemplateType) { return VIDEO_WORKFLOW_QUICK_CONNECT_ICON_MAP[type] || Plus; } type RealtimeCanvasModeIconConfig = { realtimeIconUrl: string; enhanceIconUrl: string; realtimeLabel: string; enhanceLabel: string; }; const DEFAULT_REALTIME_CANVAS_MODE_LABEL = "实时画布模式"; const DEFAULT_ENHANCE_CANVAS_MODE_LABEL = "图像增强模式"; function normalizeRealtimeCanvasModeDisplayConfig( value: unknown ): RealtimeCanvasModeIconConfig { const raw = value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; const normalizeUrl = (input: unknown) => String(input || "").trim(); const normalizeLabel = (input: unknown, fallback: string) => { const label = String(input || "") .trim() .slice(0, 32); return label || fallback; }; return { realtimeIconUrl: normalizeUrl(raw.realtimeIconUrl), enhanceIconUrl: normalizeUrl(raw.enhanceIconUrl), realtimeLabel: normalizeLabel( raw.realtimeLabel, DEFAULT_REALTIME_CANVAS_MODE_LABEL ), enhanceLabel: normalizeLabel( raw.enhanceLabel, DEFAULT_ENHANCE_CANVAS_MODE_LABEL ), }; } function resolveCanvasToolbarItemWithFallback( config: CanvasToolbarConfig, key: CanvasToolbarItemKey, fallback?: Partial ): CanvasToolbarItemConfig { const item = getCanvasToolbarItem(config, key); const defaultItem = getDefaultCanvasToolbarItem(key); return { label: item.label !== defaultItem.label ? item.label : fallback?.label || item.label, iconUrl: item.iconUrl || fallback?.iconUrl || "", enabled: item.enabled, }; } const VIDEO_CANVAS_SIDEBAR_GROUPS: VideoCanvasSidebarGroup[] = [ { id: "input", label: "输入层", description: "文本、图片、视频、小说", icon: FileImage, nodeTypes: ["text-node", "input-image", "video-input", "novel-input"], }, { id: "analysis", label: "分析层", description: "视频拆解、角色场景抽取", icon: Scan, nodeTypes: [ "video-analyze", "extract-characters-scenes", "character-description", "scene-description", "create-character", "create-scene", ], }, { id: "storyboard", label: "分镜层", description: "分镜节点、分镜图表、表格", icon: GitBranch, nodeTypes: [ "storyboard-node", "storyboard-chart-node", "table-editor-node", "camera-movement", "professional-camera", ], }, { id: "generation", label: "生成层", description: "生图、生视频、音乐、超清", icon: Sparkles, nodeTypes: [ "gen-image", "gen-video", "gen-music", "jimeng-super-resolution", "generate-character-image", "generate-character-video", "generate-scene-image", "generate-scene-video", "motion-control", ], }, { id: "tool", label: "工具层", description: "局部重绘、Comfy、Agent", icon: Wand2, nodeTypes: [ "inpaint-crop", "inpaint-stitch", "rh-comfy", "rh-app", "custom-agent", "doodle-canvas", "canvas-node", "image-compare", ], }, { id: "output", label: "输出层", description: "预览、保存", icon: Save, nodeTypes: ["preview", "local-save"], }, ]; const VIDEO_CANVAS_SIDEBAR_GROUP_STYLES: Record< VideoCanvasSidebarGroup["id"], { iconBg: string; iconBorder: string; iconText: string; rail: string; } > = { input: { iconBg: "#eef3ff", iconBorder: "#dbe4ff", iconText: "#5b76fe", rail: "#dde5ff", }, analysis: { iconBg: "#effaf4", iconBorder: "#d9efe3", iconText: "#46b078", rail: "#dcefe5", }, storyboard: { iconBg: "#fff7e8", iconBorder: "#f7e7c8", iconText: "#ca8f2d", rail: "#f0dfbc", }, generation: { iconBg: "#fff1f4", iconBorder: "#f6dbe2", iconText: "#dd5b7b", rail: "#f1d9e0", }, tool: { iconBg: "#f3f4f8", iconBorder: "#e3e6ef", iconText: "#63708a", rail: "#e2e5ed", }, output: { iconBg: "#eef7ff", iconBorder: "#dbe9fb", iconText: "#4d8ed8", rail: "#d8e6f8", }, }; function isPlainObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function sanitizeCanvasStateForPersist(state: CanvasState): CanvasState { return { ...state, nodes: state.nodes.map((node) => { if (node.type !== "image") return node; const data = node.data as Record; const metadata = isPlainObject(data.metadata) ? { ...(data.metadata as Record) } : {}; const localPreviewUrl = String( metadata[LOCAL_UPLOAD_PREVIEW_METADATA_KEY] || "" ); const src = String(data.src || ""); if (!src.startsWith("blob:") && !localPreviewUrl.startsWith("blob:")) { return node; } delete metadata[LOCAL_UPLOAD_PREVIEW_METADATA_KEY]; delete metadata[LOCAL_UPLOAD_PENDING_METADATA_KEY]; return { ...node, data: { ...data, src: "", status: data.status === "failed" ? "failed" : "generating", metadata, } as CanvasNode["data"], }; }), }; } function normalizeFiniteNumber(value: unknown, fallback: number) { const numeric = Number(value); return Number.isFinite(numeric) ? numeric : fallback; } function normalizeImportedDate(value: unknown) { const parsed = new Date(String(value || "").trim()); return Number.isNaN(parsed.getTime()) ? new Date() : parsed; } function sanitizeExportFileName(value: string) { return ( String(value || "") .trim() .replace(/[\\/:*?"<>|]+/g, "-") .replace(/\s+/g, "-") .slice(0, 60) || "video-canvas-project" ); } function normalizeImportedCanvasNode( value: unknown, index: number ): CanvasNode | null { if (!isPlainObject(value)) { return null; } const rawType = String(value.type || "").trim() as CanvasNode["type"]; if (!IMPORTABLE_CANVAS_NODE_TYPES.has(rawType)) { return null; } const position = isPlainObject(value.position) ? value.position : {}; const size = isPlainObject(value.size) ? value.size : {}; return { id: String(value.id || `imported-node-${index + 1}`), type: rawType, position: { x: normalizeFiniteNumber(position.x, 0), y: normalizeFiniteNumber(position.y, 0), }, size: { width: Math.max(normalizeFiniteNumber(size.width, 320), 120), height: Math.max(normalizeFiniteNumber(size.height, 240), 120), }, data: isPlainObject(value.data) ? (value.data as unknown as CanvasNode["data"]) : ({} as CanvasNode["data"]), connections: Array.isArray(value.connections) ? value.connections .map((item) => String(item || "").trim()) .filter(Boolean) : [], parentId: String(value.parentId || "").trim() || undefined, createdAt: normalizeImportedDate(value.createdAt), updatedAt: normalizeImportedDate(value.updatedAt), }; } function normalizeImportedCanvasState(payload: unknown): { title?: string; workspaceType?: CanvasWorkspaceType; canvasData: CanvasState; } | null { if (!isPlainObject(payload)) { return null; } const rawCanvasData = isPlainObject(payload.canvasData) ? payload.canvasData : payload; if (!isPlainObject(rawCanvasData)) { return null; } const nodes = Array.isArray(rawCanvasData.nodes) ? rawCanvasData.nodes .map((item, index) => normalizeImportedCanvasNode(item, index)) .filter((item): item is CanvasNode => Boolean(item)) : []; const nodeIds = new Set(nodes.map((node) => node.id)); const edges = Array.isArray(rawCanvasData.edges) ? rawCanvasData.edges .map((item, index) => { if (!isPlainObject(item)) return null; const source = String(item.source || "").trim(); const target = String(item.target || "").trim(); if ( !source || !target || !nodeIds.has(source) || !nodeIds.has(target) ) { return null; } return { id: String(item.id || `imported-edge-${index + 1}`), source, target, type: String(item.type || "").trim() === "conversation" || String(item.type || "").trim() === "reference" ? (String(item.type || "").trim() as CanvasEdge["type"]) : "default", } as CanvasEdge; }) .filter((item): item is CanvasEdge => item !== null) : []; const viewport = isPlainObject(rawCanvasData.viewport) ? rawCanvasData.viewport : {}; const selectedNodeIds = Array.isArray(rawCanvasData.selectedNodeIds) ? rawCanvasData.selectedNodeIds .map((item) => String(item || "").trim()) .filter((item) => nodeIds.has(item)) : []; const toolValue = String(rawCanvasData.tool || "").trim() as ToolType; const resolvedWorkspaceType = ["canvas", "agent", "video"].includes( String( payload.workspaceType || (isPlainObject(payload.project) ? payload.project.workspaceType : "") || "" ) ) ? (String( payload.workspaceType || (isPlainObject(payload.project) ? payload.project.workspaceType : "") || "" ) as CanvasWorkspaceType) : undefined; const projectTitle = String( (isPlainObject(payload.project) ? payload.project.title : "") || payload.title || "" ).trim(); return { title: projectTitle || undefined, workspaceType: resolvedWorkspaceType, canvasData: { nodes, edges, selectedNodeIds, viewport: { x: normalizeFiniteNumber(viewport.x, 0), y: normalizeFiniteNumber(viewport.y, 0), zoom: Math.min( Math.max(normalizeFiniteNumber(viewport.zoom, 1), 0.1), 4 ), }, tool: IMPORTABLE_CANVAS_TOOL_TYPES.has(toolValue) ? toolValue : "select", suppressedMediaKeys: Array.isArray(rawCanvasData.suppressedMediaKeys) ? rawCanvasData.suppressedMediaKeys .map((item) => String(item || "").trim()) .filter(Boolean) : [], workflowState: isPlainObject(rawCanvasData.workflowState) ? (rawCanvasData.workflowState as unknown as CanvasState["workflowState"]) : undefined, styleProState: normalizeStyleProPersistedState(rawCanvasData.styleProState), }, }; } const FLOATING_PANEL_WIDTH = 400; const FLOATING_PANEL_MAX_VIEWPORT_RATIO = 0.75; const FLOATING_PANEL_WIDTH_STORAGE_KEY = "canvas.chatPanel.width.v1"; const FLOATING_PANEL_INSET = 16; const DOCKED_PANEL_INSET = 0; const FLOATING_UI_GAP = 16; const FLOATING_BOTTOM_INSET = 20; const FLOATING_TOOLBAR_ESTIMATED_WIDTH = 680; const FLOATING_TOOLBAR_SIDE_DOCK_CLEARANCE = 48; const FLOATING_TOOLBAR_SIDE_DOCK_VISIBLE_CANVAS_RATIO = 0.58; const WORKFLOW_AGENT_CONTEXT_START = "【视频画布同步上下文】"; const WORKFLOW_AGENT_CONTEXT_END = "【/视频画布同步上下文】"; const AGENT_RESULT_MODE_LABELS: Record = { scene_generation: "场景结果", detail_generation: "细节结果", detail_replacement: "细节替换", sku_replacement: "SKU 替换", }; function getMaxFloatingPanelWidth(viewportWidth: number) { if (viewportWidth <= 0) { return FLOATING_PANEL_WIDTH; } const viewportLimitedMax = Math.max( 0, viewportWidth - DOCKED_PANEL_INSET ); const ratioLimitedMax = viewportLimitedMax * FLOATING_PANEL_MAX_VIEWPORT_RATIO; return Math.max( FLOATING_PANEL_WIDTH, Math.min(viewportLimitedMax, ratioLimitedMax) ); } function clampFloatingPanelWidth(width: number, viewportWidth: number) { if (!Number.isFinite(width)) { return FLOATING_PANEL_WIDTH; } return Math.min( Math.max(width, FLOATING_PANEL_WIDTH), getMaxFloatingPanelWidth(viewportWidth) ); } const VIDEO_CANVAS_PANEL_CLASS = "pointer-events-auto overflow-hidden rounded-[24px] border border-[#dbe2ee] bg-white text-[#16181d] shadow-[0_0_0_1px_rgba(219,226,238,0.42),0_24px_52px_rgba(22,24,29,0.08)] dark:border-zinc-800 dark:bg-zinc-950"; const VIDEO_CANVAS_PANEL_BADGE_CLASS = "border-[#d7dff0] bg-[#f6f8ff] text-[#5b6678] dark:border-zinc-800 dark:bg-zinc-900"; const VIDEO_CANVAS_SUBSECTION_CLASS = "rounded-[18px] border border-[#e2e8f2] bg-[#f8faff] p-3.5 dark:border-zinc-800 dark:bg-zinc-900"; const VIDEO_CANVAS_TOOLBAR_CLASS = "flex items-center gap-1.5 rounded-[26px] border border-[#dbe2ee] bg-white px-2.5 py-1.5 shadow-[0_0_0_1px_rgba(219,226,238,0.42),0_20px_42px_rgba(22,24,29,0.08)] dark:border-zinc-800 dark:bg-zinc-950"; const VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS = "h-9 w-9 rounded-[14px] border border-transparent text-[#5b6678] hover:border-[#d9e1f2] hover:bg-[#f7f9ff] hover:text-[#16181d] dark:hover:border-zinc-800 dark:hover:bg-zinc-900"; const VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS = "border-[#d6def7] bg-[#eef2ff] text-[#3954db] shadow-[0_0_0_1px_rgba(91,118,254,0.14)] hover:bg-[#e7edff] dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-900"; const VIDEO_CANVAS_COMPACT_CONTROLS_CLASS = "rounded-full border border-[#dbe2ee] bg-white px-2 py-1 shadow-[0_0_0_1px_rgba(219,226,238,0.36),0_12px_26px_rgba(22,24,29,0.06)] dark:border-zinc-800 dark:bg-zinc-950"; const VIDEO_CANVAS_MARKER_PANEL_ICON_MAP: Record< VideoCanvasSymbolMarkerId, LucideIcon > = { idea: Lightbulb, question: HelpCircle, warning: AlertTriangle, pin: Pin, heart: Heart, bolt: Zap, }; interface VideoCanvasMarkerPanelProps { hasSelection: boolean; markers: VideoCanvasMarkerState; onApply: (key: VideoCanvasMarkerKey, value: VideoCanvasMarkerValue) => void; onClear: () => void; onClose: () => void; selectedLabel: string; } function VideoCanvasMarkerPanel({ hasSelection, markers, onApply, onClear, onClose, selectedLabel, }: VideoCanvasMarkerPanelProps) { const isActive = (key: VideoCanvasMarkerKey, value: VideoCanvasMarkerValue) => (markers as Record)[key] === value; return (
标记
{hasSelection ? selectedLabel : "先选中一个模块"}
{["标记", "贴纸", "插画"].map((tab) => ( ))}
标签
{VIDEO_CANVAS_MARKER_COLORS.map((entry) => (
优先级
{VIDEO_CANVAS_PRIORITY_MARKERS.map((entry) => ( ))}
任务
{VIDEO_CANVAS_TASK_MARKERS.map((entry) => ( ))}
旗帜
{VIDEO_CANVAS_MARKER_COLORS.map((entry) => ( ))}
星星
{VIDEO_CANVAS_MARKER_COLORS.map((entry) => ( ))}
人像
{VIDEO_CANVAS_MARKER_COLORS.map((entry) => ( ))}
符号
{VIDEO_CANVAS_SYMBOL_MARKERS.map((entry) => { const Icon = VIDEO_CANVAS_MARKER_PANEL_ICON_MAP[entry.id]; return ( ); })}
); } function buildScopedCanvasChatId(projectId: string, scope: "agent-mode") { const normalized = String(projectId || "") .trim() .toLowerCase() .replace(/-/g, ""); if (!/^[0-9a-f]{32}$/.test(normalized)) { return projectId; } const scopeSuffix = scope === "agent-mode" ? "a93e17a60001" : "a93e17a60000"; const scoped = `${normalized.slice(0, 20)}${scopeSuffix}`; return [ scoped.slice(0, 8), scoped.slice(8, 12), scoped.slice(12, 16), scoped.slice(16, 20), scoped.slice(20, 32), ].join("-"); } function buildDefaultAgentProjectTitle() { const now = new Date(); const month = `${now.getMonth() + 1}`.padStart(2, "0"); const day = `${now.getDate()}`.padStart(2, "0"); const hour = `${now.getHours()}`.padStart(2, "0"); const minute = `${now.getMinutes()}`.padStart(2, "0"); return `生图项目 ${month}-${day} ${hour}:${minute}`; } function buildSaveAsAgentProjectTitle(currentTitle?: string | null) { const normalized = String(currentTitle || "").trim(); if (!normalized) { return `${buildDefaultAgentProjectTitle()} 副本`; } if (normalized.endsWith("副本")) { return normalized; } return `${normalized} 副本`; } function areStringArraysEqual(left: string[], right: string[]) { if (left === right) return true; if (left.length !== right.length) return false; for (let index = 0; index < left.length; index += 1) { if (left[index] !== right[index]) { return false; } } return true; } function normalizeResultCollectionUrls(urls: string[] | undefined | null) { return Array.from( new Set( (urls || []) .map((item) => String(item || "").trim()) .filter((item) => /^https?:\/\//i.test(item)) ) ); } function areReplacementResultStatesEqual( left: AgentGenerationProjectReplacementResultState | undefined | null, right: AgentGenerationProjectReplacementResultState | undefined | null ) { return ( JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(left)) === JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(right)) ); } function getReplacementResultUrlCount( state: AgentGenerationProjectPersistedState | undefined | null ) { const normalized = normalizeAgentGenerationProjectReplacementResultState( state?.replacementResultState ); return ( Object.values(normalized.detail_replacement || {}).filter((record) => Boolean(record.url) ).length + Object.values(normalized.sku_replacement || {}).filter((record) => Boolean(record.url) ).length ); } function hasReplacementResultCollectionMismatch( state: AgentGenerationProjectPersistedState | undefined | null ) { const normalized = normalizeAgentGenerationProjectReplacementResultState( state?.replacementResultState ); const resultCollections = state?.resultCollections || {}; const countModeRecords = ( modeType: "detail_replacement" | "sku_replacement" ) => Object.values(normalized[modeType] || {}).filter((record) => Boolean(record.url) ).length; const countModeCollection = ( modeType: "detail_replacement" | "sku_replacement" ) => normalizeResultCollectionUrls(resultCollections[modeType]).length; return (["detail_replacement", "sku_replacement"] as const).some( (modeType) => { const recordCount = countModeRecords(modeType); return recordCount > 0 && countModeCollection(modeType) !== recordCount; } ); } const AGENT_PROJECT_DRAFT_STORAGE_KEY_PREFIX = "agent-project-draft:"; function getAgentProjectDraftStorageKey(projectId: string) { return `${AGENT_PROJECT_DRAFT_STORAGE_KEY_PREFIX}${String(projectId || "").trim()}`; } function scheduleCanvasIdleTask(callback: () => void, timeout = 1200) { if (typeof window === "undefined") { callback(); return () => {}; } const idleWindow = window as Window & { requestIdleCallback?: ( callback: IdleRequestCallback, options?: IdleRequestOptions ) => number; cancelIdleCallback?: (handle: number) => void; }; if (typeof idleWindow.requestIdleCallback === "function") { const idleId = idleWindow.requestIdleCallback(callback, { timeout }); return () => idleWindow.cancelIdleCallback?.(idleId); } const timer = window.setTimeout(callback, Math.min(timeout, 300)); return () => window.clearTimeout(timer); } interface AgentProjectDraftSnapshot { savedAt: number; title?: string | null; thumbnail?: string | null; projectState?: AgentGenerationProjectPersistedState; } function readAgentProjectDraftSnapshot( projectId: string ): AgentProjectDraftSnapshot | null { if (typeof window === "undefined") { return null; } const storageKey = getAgentProjectDraftStorageKey(projectId); if (!storageKey) { return null; } try { const raw = window.localStorage.getItem(storageKey); if (!raw) { return null; } const parsed = JSON.parse(raw) as AgentProjectDraftSnapshot | null; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return null; } return { savedAt: Number.isFinite(Number(parsed.savedAt)) ? Number(parsed.savedAt) : 0, title: String(parsed.title || "").trim() || null, thumbnail: String(parsed.thumbnail || "").trim() || null, projectState: parsed.projectState && typeof parsed.projectState === "object" ? parsed.projectState : undefined, }; } catch (error) { console.warn("[Agent Project] Failed to read local draft:", error); return null; } } function removeAgentProjectDraftSnapshot(projectId: string) { if (typeof window === "undefined") { return; } const storageKey = getAgentProjectDraftStorageKey(projectId); if (!storageKey) { return; } try { window.localStorage.removeItem(storageKey); } catch (error) { console.warn("[Agent Project] Failed to remove local draft:", error); } } function isUnauthorizedApiError(error?: string | null) { return /未登录|unauthorized/i.test(String(error || "").trim()); } function clearAppearanceSummaryFromResultCollection( result: | AgentWorkflowMaterialSelections["primary"]["result"] | null | undefined ) { if (!result) { return result; } let changed = false; const nextImages = (result.images || []).map((image) => { if (!String(image.appearanceSummary || "").trim()) { return image; } changed = true; return { ...image, appearanceSummary: null, }; }); const nextGoodsInfo = result.goodsInfo && String(result.goodsInfo.appearanceSummary || "").trim() ? { ...result.goodsInfo, appearanceSummary: null, } : result.goodsInfo; if (nextGoodsInfo !== result.goodsInfo) { changed = true; } if (!changed) { return result; } return { ...result, images: nextImages, goodsInfo: nextGoodsInfo, }; } function clearAppearanceSummaryFromMaterials( materials: AgentWorkflowMaterialSelections ): AgentWorkflowMaterialSelections { const nextPrimary = clearAppearanceSummaryFromResultCollection( materials.primary.result || null ); const nextSecondary = clearAppearanceSummaryFromResultCollection( materials.secondary.result || null ); if ( nextPrimary === materials.primary.result && nextSecondary === materials.secondary.result ) { return materials; } return { primary: { ...materials.primary, result: nextPrimary || null, }, secondary: { ...materials.secondary, result: nextSecondary || null, }, }; } function areResultCollectionsEqual( left: AgentGenerationProjectResultCollections, right: AgentGenerationProjectResultCollections ) { return ( areStringArraysEqual( normalizeResultCollectionUrls(left.scene_generation), normalizeResultCollectionUrls(right.scene_generation) ) && areStringArraysEqual( normalizeResultCollectionUrls(left.detail_generation), normalizeResultCollectionUrls(right.detail_generation) ) && areStringArraysEqual( normalizeResultCollectionUrls(left.detail_replacement), normalizeResultCollectionUrls(right.detail_replacement) ) && areStringArraysEqual( normalizeResultCollectionUrls(left.sku_replacement), normalizeResultCollectionUrls(right.sku_replacement) ) ); } function getFirstResultUrlFromCollections( collections: AgentGenerationProjectResultCollections ) { return ( normalizeResultCollectionUrls(collections.scene_generation)[0] || normalizeResultCollectionUrls(collections.detail_generation)[0] || normalizeResultCollectionUrls(collections.detail_replacement)[0] || normalizeResultCollectionUrls(collections.sku_replacement)[0] || null ); } function getAllResultUrlsFromCollections( collections: AgentGenerationProjectResultCollections ) { return Array.from( new Set([ ...normalizeResultCollectionUrls(collections.scene_generation), ...normalizeResultCollectionUrls(collections.detail_generation), ...normalizeResultCollectionUrls(collections.detail_replacement), ...normalizeResultCollectionUrls(collections.sku_replacement), ]) ); } function resolveCanvasImageDisplaySize(url: string) { return new Promise<{ height: number; originalHeight: number; originalWidth: number; width: number; }>((resolve) => { if (typeof window === "undefined" || !url) { resolve({ height: 320, originalHeight: 0, originalWidth: 0, width: 320, }); return; } const image = new window.Image(); image.onload = () => { const originalWidth = image.naturalWidth || 320; const originalHeight = image.naturalHeight || 320; const maxSide = 360; const scale = Math.min(1, maxSide / Math.max(originalWidth, originalHeight)); resolve({ height: Math.max(120, Math.round(originalHeight * scale)), originalHeight, originalWidth, width: Math.max(120, Math.round(originalWidth * scale)), }); }; image.onerror = () => { resolve({ height: 320, originalHeight: 0, originalWidth: 0, width: 320, }); }; image.src = url; }); } export default function CanvasEditorLegacyPage({ initialProject = null, params, }: PageProps) { // 使用 useParams 确保客户端导航时能正确获取更新的 id const routeParams = useParams(); const searchParams = useSearchParams(); const routeId = routeParams?.id; const id = Array.isArray(routeId) ? routeId[0] || "" : typeof routeId === "string" ? routeId : ""; const router = useRouter(); const { resolvedTheme } = useTheme(); const mode = searchParams.get("mode"); const requestedWorkspaceType = searchParams.get("workspaceType"); const requestedAgentMode = mode === "agent"; const isVideoMode = !requestedAgentMode && (mode === "video" || requestedWorkspaceType === "video"); const activeCanvasWorkspaceType: CanvasWorkspaceType = isVideoMode ? "video" : "canvas"; const canvasSurfaceBackground = resolvedTheme === "dark" ? "#0a0a0c" : CANVAS_SURFACE_BACKGROUND; const defaultCanvasTitle = isVideoMode ? "未命名视频画布" : "未命名画布"; const workspaceLabel = isVideoMode ? "视频画布" : "画布"; const createNewCanvasUrl = isVideoMode ? "/video-canvas?new=1" : "/canvas?new=1"; const initialProjectForCurrentRoute = initialProject?.id === id ? initialProject : null; const shouldOpenBatchProcessing = searchParams.get("batch") === "1"; const goHome = useCallback(() => { if (typeof window !== "undefined") { window.location.assign("/"); } }, []); const buildWorkspaceProjectUrl = useCallback( (projectId: string) => isVideoMode ? `/canvas/${projectId}?mode=video&workspaceType=video` : `/canvas/${projectId}`, [isVideoMode] ); // Store state const projectTitle = useCanvasStore((state) => state.projectTitle); const viewport = useCanvasStore(canvasSelectors.viewport); const tool = useCanvasStore(canvasSelectors.tool); const canUndo = useCanvasStore(canvasSelectors.canUndo); const canRedo = useCanvasStore(canvasSelectors.canRedo); const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds); const nodes = useCanvasStore(canvasSelectors.nodes); const edges = useCanvasStore(canvasSelectors.edges); // Store actions const setProject = useCanvasStore((state) => state.setProject); const setProjectTitle = useCanvasStore((state) => state.setProjectTitle); const setTool = useCanvasStore((state) => state.setTool); const zoomIn = useCanvasStore((state) => state.zoomIn); const zoomOut = useCanvasStore((state) => state.zoomOut); const setViewport = useCanvasStore((state) => state.setViewport); const undo = useCanvasStore((state) => state.undo); const redo = useCanvasStore((state) => state.redo); const loadCanvas = useCanvasStore((state) => state.loadCanvas); const clearCanvas = useCanvasStore((state) => state.clearCanvas); const getCanvasState = useCanvasStore((state) => state.getCanvasState); const addNode = useCanvasStore((state) => state.addNode); const deleteNode = useCanvasStore((state) => state.deleteNode); const addEdge = useCanvasStore((state) => state.addEdge); const selectNodes = useCanvasStore((state) => state.selectNodes); const updateNode = useCanvasStore((state) => state.updateNode); const pushHistory = useCanvasStore((state) => state.pushHistory); const setGenerating = useCanvasStore((state) => state.setGenerating); const isAgentOperationRefreshActive = useCanvasStore( (state) => state.isAgentOperationRefreshActive ); // Local state const [isLoading, setIsLoading] = useState( () => !initialProjectForCurrentRoute ); const [isSaving, setIsSaving] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false); const [isAddImageDialogOpen, setIsAddImageDialogOpen] = useState(false); const [editTitle, setEditTitle] = useState(""); const safariGestureScaleRef = useRef(1); const isCanvasTextEntryTarget = useCallback((target: EventTarget | null) => { if (!(target instanceof HTMLElement)) { return false; } if (target instanceof HTMLInputElement) { const inputType = String(target.type || "").toLowerCase(); const isTextInput = inputType === "text" || inputType === "search" || inputType === "url" || inputType === "email" || inputType === "password" || inputType === "number"; if (isTextInput) { return true; } } return ( target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || target.isContentEditable || Boolean(target.closest('[contenteditable="true"]')) ); }, []); const applyCanvasViewportZoom = useCallback( ( zoomFactor: number, anchorPoint?: { clientX?: number; clientY?: number; } ) => { const { viewport: currentViewport } = useCanvasStore.getState(); const nextZoom = Math.max( 0.1, Math.min(4, currentViewport.zoom * zoomFactor) ); if (nextZoom === currentViewport.zoom) { return; } const workspaceElement = typeof document !== "undefined" ? document.querySelector( '[data-canvas-workspace-root="true"]' ) : null; let anchorX = window.innerWidth / 2; let anchorY = window.innerHeight / 2; if (workspaceElement) { const rect = workspaceElement.getBoundingClientRect(); const hasClientPoint = typeof anchorPoint?.clientX === "number" && typeof anchorPoint?.clientY === "number"; const clientX = hasClientPoint ? anchorPoint.clientX : null; const clientY = hasClientPoint ? anchorPoint.clientY : null; anchorX = hasClientPoint ? Math.min(Math.max((clientX ?? 0) - rect.left, 0), rect.width) : rect.width / 2; anchorY = hasClientPoint ? Math.min(Math.max((clientY ?? 0) - rect.top, 0), rect.height) : rect.height / 2; } const scaleChange = nextZoom / currentViewport.zoom; const nextX = anchorX - (anchorX - currentViewport.x) * scaleChange; const nextY = anchorY - (anchorY - currentViewport.y) * scaleChange; setViewport({ x: nextX, y: nextY, zoom: nextZoom, }); }, [setViewport] ); const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] = useState(null); const [isPanelOpen, setIsPanelOpen] = useState(true); const [floatingPanelWidth, setFloatingPanelWidth] = useState(FLOATING_PANEL_WIDTH); const [ isFloatingPanelWidthStorageReady, setIsFloatingPanelWidthStorageReady, ] = useState(false); const [isFloatingPanelResizing, setIsFloatingPanelResizing] = useState(false); const [areSecondaryPanelsReady, setAreSecondaryPanelsReady] = useState(false); const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] = useState(false); const [videoModuleSectionOpen, setVideoModuleSectionOpen] = useState< Record >({ input: true, analysis: true, storyboard: true, generation: true, tool: true, output: true, }); const [videoCanvasInsertMenu, setVideoCanvasInsertMenu] = useState(null); const videoCanvasInsertMenuRef = useRef(null); const floatingToolbarContentRef = useRef(null); const [isVideoCanvasFileListOpen, setIsVideoCanvasFileListOpen] = useState(false); const [isVideoCanvasMarkerPanelOpen, setIsVideoCanvasMarkerPanelOpen] = useState(false); // 面板模式: 'chat' 聊天面板, 'fileList' 文件列表 const [panelMode, setPanelMode] = useState<"chat" | "fileList">("chat"); const chatPanelCanvasIdRef = useRef(null); const [viewportWidth, setViewportWidth] = useState(0); const [horizontalFloatingToolbarWidth, setHorizontalFloatingToolbarWidth] = useState(0); const [workflowInputs, setWorkflowInputs] = useState({ primarySku: "", secondarySku: "", brand: "", store: "", officialSecondCategory: "", officialThirdCategory: "", thirdCategory: "", fourthCategory: "", fifthCategory: "", priceBand: "", }); const [workflowMaterials, setWorkflowMaterials] = useState(() => createEmptyAgentWorkflowMaterialSelections() ); const [workflowSelections, setWorkflowSelections] = useState< Partial> >({}); useLayoutEffect(() => { if (!id || isVideoMode || chatPanelCanvasIdRef.current === id) { return; } chatPanelCanvasIdRef.current = id; setPanelMode("chat"); setIsPanelOpen(true); }, [id, isVideoMode]); useEffect(() => { if (!isVideoMode) { setVideoCanvasInsertMenu(null); setIsVideoCanvasFileListOpen(false); setIsVideoCanvasMarkerPanelOpen(false); } }, [isVideoMode]); useEffect(() => { if (!isVideoMode) { setIsVideoModuleSidebarCollapsed(false); } }, [isVideoMode]); const [appearanceRecognitionEnabled, setAppearanceRecognitionEnabled] = useState(false); const [selectedBrandModel, setSelectedBrandModel] = useState(null); const workflowInputsRef = useRef(workflowInputs); const workflowMaterialsRef = useRef(workflowMaterials); const workflowSelectionsRef = useRef(workflowSelections); const appearanceRecognitionEnabledRef = useRef(appearanceRecognitionEnabled); const selectedBrandModelRef = useRef(selectedBrandModel); const [selectedAgentPresetId, setSelectedAgentPresetId] = useState< string | null >(null); const [currentAgentStage, setCurrentAgentStage] = useState< "workflow" | "mode" >("workflow"); const [agentModeState, setAgentModeState] = useState( () => createEmptyAgentModeWorkspaceState() ); const [agentWorkspaceState, setAgentWorkspaceState] = useState(() => createEmptyAgentGenerationProjectWorkspaceState() ); const [agentProjectTitle, setAgentProjectTitle] = useState("未命名生图项目"); const [agentProjectThumbnail, setAgentProjectThumbnail] = useState< string | null >(null); const [agentProjectResultCollections, setAgentProjectResultCollections] = useState({}); const [ agentProjectDeletedResultCollections, setAgentProjectDeletedResultCollections, ] = useState({}); const [ agentProjectReplacementResultState, setAgentProjectReplacementResultState, ] = useState({}); const [isAgentProjectLoading, setIsAgentProjectLoading] = useState(false); const [loadedAgentProjectId, setLoadedAgentProjectId] = useState< string | null >(null); const [isAgentProjectSaving, setIsAgentProjectSaving] = useState(false); const [isSyncingAgentResultsToCanvas, setIsSyncingAgentResultsToCanvas] = useState(false); const [isImportingVideoCanvas, setIsImportingVideoCanvas] = useState(false); const [isAgentProjectSheetOpen, setIsAgentProjectSheetOpen] = useState(false); const [isAgentProjectMenuOpen, setIsAgentProjectMenuOpen] = useState(false); const [isLoginDialogOpen, setIsLoginDialogOpen] = useState(false); const [loginDialogMessage, setLoginDialogMessage] = useState( "当前登录状态已失效,请重新登录后继续操作。" ); const [authSessionChecked, setAuthSessionChecked] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(true); const [workflowHydrationSource, setWorkflowHydrationSource] = useState< "pending" | "project" | "local" | "empty" >("pending"); const requestedAgentProjectId = searchParams.get("agentProjectId"); const agentProjectId = requestedAgentProjectId ? requestedAgentProjectId.trim() : ""; const hasActiveAgentProject = Boolean(agentProjectId); const agentModeChatId = useMemo( () => buildScopedCanvasChatId(agentProjectId || id, "agent-mode"), [agentProjectId, id] ); const canvasSiteSettingsKey = requestedAgentMode ? "/api/site-settings?scope=agent-canvas" : "/api/site-settings?scope=canvas"; const [hasResolvedCanvasSiteSettings, setHasResolvedCanvasSiteSettings] = useState(false); const { data: siteSettingsData } = useSWR<{ siteName?: string; siteLogo?: string | null; siteLogoDark?: string | null; agentEnabled?: boolean; canvasAgentModeEnabled?: boolean; canvasAgentFeatureSettings?: CanvasAgentFeatureSettings; videoCanvasSidebarEnabled?: boolean; layerSplitEnabled?: boolean; angleEditEnabled?: boolean; realtimeCanvasEnabled?: boolean; realtimeCanvasServiceEnabled?: boolean; styleProEnabled?: boolean; enhanceProEnabled?: boolean; tripo3dEnabled?: boolean; worldlabs3dEnabled?: boolean; realtimeCanvasModeIconConfig?: Partial; canvasToolbarConfig?: CanvasToolbarConfig; agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig; agentWorkflowUiConfig?: AgentWorkflowUiConfig; }>(canvasSiteSettingsKey, fetchCanvasSiteSettings, { onSuccess: () => { setHasResolvedCanvasSiteSettings(true); }, onError: () => { setHasResolvedCanvasSiteSettings(true); }, refreshInterval: requestedAgentMode ? 0 : CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS, errorRetryCount: 1, revalidateOnFocus: true, revalidateOnReconnect: true, }); useEffect(() => { setHasResolvedCanvasSiteSettings(false); }, [canvasSiteSettingsKey]); const isLayerSplitEnabled = hasResolvedCanvasSiteSettings && siteSettingsData?.layerSplitEnabled === true; const isAngleEditEnabled = hasResolvedCanvasSiteSettings && siteSettingsData?.angleEditEnabled === true; const isTripo3dEnabled = hasResolvedCanvasSiteSettings && siteSettingsData?.tripo3dEnabled === true; const isWorldlabs3dEnabled = hasResolvedCanvasSiteSettings && siteSettingsData?.worldlabs3dEnabled === true; const { data: sceneFusionConfigData } = useSWR<{ enabled?: boolean; }>("/api/scene-fusion/config", fetcher); // 生成配置 const [prompt, setPrompt] = useState(""); const [negativePrompt, setNegativePrompt] = useState(""); const [aspectRatio, setAspectRatio] = useState("auto"); const [imageCount, setImageCount] = useState(1); const previousNodeIdsRef = useRef>(new Set()); const lastLocalCanvasMutationAtRef = useRef(0); const externalCanvasConflictRef = useRef(false); const externalCanvasConflictNotifiedRef = useRef(false); const previousBrandForModelRef = useRef(""); const autoSelectedBrandModelBrandRef = useRef(""); const autoTitleAttemptedRef = useRef>(new Set()); const autoTitleInFlightRef = useRef(null); const autoThumbnailSyncedRef = useRef(null); const lastLoadedCanvasIdRef = useRef(""); const normalizedVideoNodeSizeIdsRef = useRef>(new Set()); const importVideoCanvasInputRef = useRef(null); const isAgentWorkspaceEnabled = siteSettingsData?.agentEnabled !== false; const isCanvasAgentModeEnabled = siteSettingsData?.canvasAgentModeEnabled !== false; const isVideoModuleSidebarEnabled = siteSettingsData?.videoCanvasSidebarEnabled !== false; const realtimeCanvasModeDisplayConfig = useMemo( () => normalizeRealtimeCanvasModeDisplayConfig( siteSettingsData?.realtimeCanvasModeIconConfig ), [siteSettingsData?.realtimeCanvasModeIconConfig] ); const canvasToolbarConfig = useMemo( () => normalizeCanvasToolbarConfig(siteSettingsData?.canvasToolbarConfig), [siteSettingsData?.canvasToolbarConfig] ); const isAgentMode = isAgentWorkspaceEnabled && requestedAgentMode; useEffect(() => { if (!isAgentMode) return; void loadAgentGenerationWorkspaceDarkV2().catch((error) => { console.warn("[Agent Workspace] preload failed:", error); }); }, [isAgentMode]); const hasLoadedCurrentAgentProject = !requestedAgentMode || !agentProjectId || loadedAgentProjectId === agentProjectId; const resolvedWorkflowUiConfig = useMemo( () => siteSettingsData?.agentWorkflowUiConfig || defaultAgentWorkflowUiConfig, [siteSettingsData?.agentWorkflowUiConfig] ); const workflowPageFeatureSettings = useMemo( () => getAgentWorkflowPageFeatureSettings(resolvedWorkflowUiConfig), [resolvedWorkflowUiConfig] ); const workflowBusinessModeSettings = useMemo( () => getAgentWorkflowBusinessModeSettings(resolvedWorkflowUiConfig), [resolvedWorkflowUiConfig] ); const isWorkflowMaterialStepEnabled = workflowPageFeatureSettings.materialStepEnabled !== false; const isCanvasAddImageMaterialSearchEnabled = isAgentWorkspaceEnabled && isWorkflowMaterialStepEnabled; const isBatchProcessingEnabled = workflowPageFeatureSettings.batchProcessingEnabled !== false && sceneFusionConfigData?.enabled !== false; const isStyleModeToolbarEnabled = isCanvasToolbarItemEnabled( canvasToolbarConfig, "styleMode" ); const isStyleProFeatureEnabled = siteSettingsData?.styleProEnabled !== false; const isStyleProToolbarEnabled = isCanvasToolbarItemEnabled(canvasToolbarConfig, "stylePro") && isStyleProFeatureEnabled; const shouldUseStyleModeButtonForStylePro = !isAgentWorkspaceEnabled && isStyleProFeatureEnabled && isStyleModeToolbarEnabled; const shouldShowStyleModeToolbarButton = isStyleModeToolbarEnabled && (isAgentWorkspaceEnabled || shouldUseStyleModeButtonForStylePro); const shouldShowStandaloneStyleProToolbarButton = isStyleProToolbarEnabled && !shouldUseStyleModeButtonForStylePro; const normalizedBrandModelName = workflowInputs.brand.trim(); const brandModelSettingsKey = requestedAgentMode && normalizedBrandModelName ? `/api/site-settings?scope=agent-brand-model&brand=${encodeURIComponent( normalizedBrandModelName )}` : null; const { data: brandModelSettingsData } = useSWR<{ agentWorkflowBrandModelConfig?: unknown; }>(brandModelSettingsKey, fetchCanvasSiteSettings, { errorRetryCount: 1, revalidateOnFocus: false, }); const sanitizedWorkflowSelections = useMemo( () => sanitizeAgentWorkflowSelections({ config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, selections: workflowSelections, }), [resolvedWorkflowUiConfig, workflowSelections] ); const deferredSanitizedWorkflowSelections = useDeferredValue( sanitizedWorkflowSelections ); const resolvedBrandModelConfig = useMemo( () => normalizeAgentWorkflowBrandModelConfig( brandModelSettingsData?.agentWorkflowBrandModelConfig ), [brandModelSettingsData?.agentWorkflowBrandModelConfig] ); useEffect(() => { if (!isVideoMode || nodes.length === 0) { return; } nodes.forEach((node) => { if (normalizedVideoNodeSizeIdsRef.current.has(node.id)) { return; } normalizedVideoNodeSizeIdsRef.current.add(node.id); const templateType = inferVideoWorkflowTemplateType(node); const definition = templateType ? getVideoWorkflowNodeDefinition(templateType) : null; if (!definition) { return; } const nextWidth = Math.min(node.size.width, definition.size.width); const nextHeight = Math.min(node.size.height, definition.size.height); if (nextWidth === node.size.width && nextHeight === node.size.height) { return; } updateNode(node.id, { size: { width: nextWidth, height: nextHeight, }, }); }); }, [isVideoMode, nodes, updateNode]); // 获取选中的图片节点 const selectedImageNode = selectedNodeIds.length === 1 ? nodes.find((n) => n.id === selectedNodeIds[0] && n.type === "image") : null; const selectedVideoWorkflowNode = useMemo( () => isVideoMode && selectedNodeIds.length === 1 ? nodes.find((node) => node.id === selectedNodeIds[0]) || null : null, [isVideoMode, nodes, selectedNodeIds] ); const selectedVideoWorkflowType = useMemo( () => inferVideoWorkflowTemplateType(selectedVideoWorkflowNode), [selectedVideoWorkflowNode] ); const selectedVideoWorkflowData = useMemo( () => selectedVideoWorkflowNode?.type === "workflow" ? (selectedVideoWorkflowNode.data as WorkflowNodeData) : null, [selectedVideoWorkflowNode] ); const selectedVideoWorkflowDefinition = useMemo( () => selectedVideoWorkflowType ? getVideoWorkflowNodeDefinition(selectedVideoWorkflowType) : null, [selectedVideoWorkflowType] ); const selectedVideoCanvasMarkers = useMemo(() => { const metadata = ( selectedVideoWorkflowNode?.data as { metadata?: unknown } | undefined )?.metadata; return normalizeVideoCanvasMarkers( isPlainObject(metadata) ? metadata[VIDEO_CANVAS_MARKER_METADATA_KEY] : undefined ); }, [selectedVideoWorkflowNode]); const selectedVideoCanvasMarkerLabel = useMemo(() => { if (!selectedVideoWorkflowNode) return "未选择模块"; const data = selectedVideoWorkflowNode.data as { content?: unknown; title?: unknown; }; const title = String(data.title || data.content || "").trim(); if (title) return title.slice(0, 36); return selectedVideoWorkflowDefinition?.label || "当前模块"; }, [selectedVideoWorkflowDefinition?.label, selectedVideoWorkflowNode]); const selectedVideoWorkflowQuickConnects = useMemo( () => selectedVideoWorkflowType ? getVideoWorkflowQuickConnectOutputs(selectedVideoWorkflowType) : [], [selectedVideoWorkflowType] ); const selectedWorkflowQuickConnectAnchor = useMemo(() => { if ( !isVideoMode || !selectedVideoWorkflowNode || selectedVideoWorkflowQuickConnects.length === 0 ) { return null; } const nodeLeft = selectedVideoWorkflowNode.position.x * viewport.zoom + viewport.x; const nodeTop = selectedVideoWorkflowNode.position.y * viewport.zoom + viewport.y; const nodeWidth = selectedVideoWorkflowNode.size.width * viewport.zoom; const anchorX = nodeLeft + nodeWidth; const anchorY = nodeTop + (selectedVideoWorkflowNode.size.height * viewport.zoom) / 2; const openLeft = viewportWidth > 0 && anchorX + 260 > viewportWidth - 32; return { x: anchorX, y: anchorY, openLeft, }; }, [ isVideoMode, selectedVideoWorkflowNode, selectedVideoWorkflowQuickConnects.length, viewport.x, viewport.y, viewport.zoom, viewportWidth, ]); const selectedWorkflowQuickConnectSourceNode = selectedWorkflowQuickConnectAnchor && selectedVideoWorkflowNode ? selectedVideoWorkflowNode : null; const videoModuleSidebarWidth = isVideoMode && isVideoModuleSidebarEnabled ? isVideoModuleSidebarCollapsed ? 58 : 236 : 0; const selectedWorkflowPanelLeft = isVideoMode ? videoModuleSidebarWidth + 24 : 16; const handleDeleteSelectedWorkflowNode = useCallback(() => { if (!selectedVideoWorkflowNode) return; deleteNode(selectedVideoWorkflowNode.id); toast.success( `已删除模块:${selectedVideoWorkflowDefinition?.label || "当前模块"}` ); }, [ deleteNode, selectedVideoWorkflowDefinition?.label, selectedVideoWorkflowNode, ]); const handleApplyVideoCanvasMarker = useCallback( (key: VideoCanvasMarkerKey, value: VideoCanvasMarkerValue) => { if (!selectedVideoWorkflowNode) { toast.info("请先选中一个模块"); return; } const currentData = selectedVideoWorkflowNode.data; const currentMetadata = (currentData as { metadata?: unknown }).metadata; const metadata = isPlainObject(currentMetadata) ? { ...currentMetadata } : {}; const currentMarkers = normalizeVideoCanvasMarkers( metadata[VIDEO_CANVAS_MARKER_METADATA_KEY] ); const nextMarkers: VideoCanvasMarkerState = { ...currentMarkers }; const nextMarkerRecord = nextMarkers as Record; if (nextMarkerRecord[key] === value) { delete nextMarkerRecord[key]; } else { nextMarkerRecord[key] = value; } const compactMarkers = compactVideoCanvasMarkers(nextMarkers); if (Object.keys(compactMarkers).length > 0) { metadata[VIDEO_CANVAS_MARKER_METADATA_KEY] = compactMarkers; } else { delete metadata[VIDEO_CANVAS_MARKER_METADATA_KEY]; } updateNode(selectedVideoWorkflowNode.id, { data: { ...currentData, metadata, } as CanvasNode["data"], }); pushHistory(); }, [pushHistory, selectedVideoWorkflowNode, updateNode] ); const handleClearVideoCanvasMarkers = useCallback(() => { if (!selectedVideoWorkflowNode) { toast.info("请先选中一个模块"); return; } const currentData = selectedVideoWorkflowNode.data; const currentMetadata = (currentData as { metadata?: unknown }).metadata; const metadata = isPlainObject(currentMetadata) ? { ...currentMetadata } : {}; if (!(VIDEO_CANVAS_MARKER_METADATA_KEY in metadata)) { return; } delete metadata[VIDEO_CANVAS_MARKER_METADATA_KEY]; updateNode(selectedVideoWorkflowNode.id, { data: { ...currentData, metadata, } as CanvasNode["data"], }); pushHistory(); toast.success("已清空当前模块标记"); }, [pushHistory, selectedVideoWorkflowNode, updateNode]); const workflowGraph = useMemo( () => createWorkflowGraphContext(nodes, edges), [edges, nodes] ); const selectedWorkflowPrompt = useMemo( () => selectedVideoWorkflowNode ? buildWorkflowPrompt(selectedVideoWorkflowNode.id, workflowGraph) : "", [selectedVideoWorkflowNode, workflowGraph] ); const selectedWorkflowMedia = useMemo( () => selectedVideoWorkflowNode ? collectWorkflowMedia(selectedVideoWorkflowNode.id, workflowGraph) : [], [selectedVideoWorkflowNode, workflowGraph] ); const selectedWorkflowStoryboardShots = useMemo( () => selectedVideoWorkflowNode ? collectWorkflowStoryboardShots( selectedVideoWorkflowNode.id, workflowGraph ) : [], [selectedVideoWorkflowNode, workflowGraph] ); const selectedWorkflowStructuredData = useMemo( () => selectedVideoWorkflowNode ? collectWorkflowStructuredData( selectedVideoWorkflowNode.id, workflowGraph ) : { characters: [] as WorkflowCharacterRecord[], scenes: [] as WorkflowSceneRecord[], characterAssets: [] as WorkflowAssetRecord[], sceneAssets: [] as WorkflowAssetRecord[], relationships: [] as string[], recommendedNextSteps: [] as string[], agentPlan: [] as string[], deliverables: [] as string[], recommendedNodes: [] as string[], }, [selectedVideoWorkflowNode, workflowGraph] ); const selectedWorkflowInputs = useMemo( () => selectedVideoWorkflowNode ? summarizeWorkflowInputs(selectedVideoWorkflowNode.id, workflowGraph) : { promptCount: 0, imageCount: 0, videoCount: 0 }, [selectedVideoWorkflowNode, workflowGraph] ); const linkedAgentWorkflowNode = useMemo(() => { if (!agentProjectId) return null; return ( nodes .filter( (node) => node.type === "workflow" && inferVideoWorkflowTemplateType(node) === "custom-agent" && String( ((node.data as WorkflowNodeData).metadata || {}) ?.linkedAgentProjectId || "" ).trim() === agentProjectId ) .sort((left, right) => { const leftSyncedAt = Number( new Date( String( ( ((left.data as WorkflowNodeData).metadata || {}) as Record< string, any > )?.lastAgentCanvasSyncAt || ( ((left.data as WorkflowNodeData).metadata || {}) as Record< string, any > )?.lastAgentWorkspaceSyncAt || left.updatedAt ) ).getTime() ); const rightSyncedAt = Number( new Date( String( ( ((right.data as WorkflowNodeData).metadata || {}) as Record< string, any > )?.lastAgentCanvasSyncAt || ( ((right.data as WorkflowNodeData).metadata || {}) as Record< string, any > )?.lastAgentWorkspaceSyncAt || right.updatedAt ) ).getTime() ); return rightSyncedAt - leftSyncedAt; })[0] || null ); }, [agentProjectId, nodes]); const linkedAgentWorkflowData = useMemo( () => linkedAgentWorkflowNode?.type === "workflow" ? (linkedAgentWorkflowNode.data as WorkflowNodeData) : null, [linkedAgentWorkflowNode] ); const linkedAgentImportedResultUrls = useMemo(() => { const metadata = ((linkedAgentWorkflowData?.metadata || {}) as Record) || {}; return Array.isArray(metadata.importedAgentResultUrls) ? metadata.importedAgentResultUrls .map((item) => String(item || "").trim()) .filter((item) => /^https?:\/\//i.test(item)) : []; }, [linkedAgentWorkflowData]); const flattenedAgentResultEntries = useMemo( () => ( [ "scene_generation", "detail_generation", "detail_replacement", "sku_replacement", ] as AgentGenerationResultModeType[] ).flatMap((modeType) => normalizeResultCollectionUrls( agentProjectResultCollections[modeType] ).map((url, index) => ({ modeType, url, index, label: AGENT_RESULT_MODE_LABELS[modeType], })) ), [agentProjectResultCollections] ); const pendingAgentCanvasImportCount = useMemo(() => { const existingUrls = new Set( nodes .flatMap((node) => { if (node.type === "image" || node.type === "video") { return [ String((node.data as Record)?.src || "").trim(), ]; } return []; }) .filter((item) => /^https?:\/\//i.test(item)) ); const importedUrls = new Set(linkedAgentImportedResultUrls); return flattenedAgentResultEntries.filter( (item) => !existingUrls.has(item.url) && !importedUrls.has(item.url) ).length; }, [flattenedAgentResultEntries, linkedAgentImportedResultUrls, nodes]); const canSyncAgentResultsToCanvas = Boolean( requestedWorkspaceType === "video" && isAgentMode && linkedAgentWorkflowNode && flattenedAgentResultEntries.length > 0 ); const selectedWorkflowLinkedAgentProject = useMemo(() => { const metadata = ((selectedVideoWorkflowData?.metadata || {}) as Record) || {}; return { id: String(metadata.linkedAgentProjectId || "").trim(), title: String(metadata.linkedAgentProjectTitle || "").trim(), syncedAt: String( metadata.lastAgentWorkspaceSyncAt || metadata.linkedAgentProjectUpdatedAt || "" ).trim(), }; }, [selectedVideoWorkflowData]); const selectedWorkflowAgentResultGroups = useMemo(() => { const metadata = ((selectedVideoWorkflowData?.metadata || {}) as Record) || {}; return Array.isArray(metadata.agentResultGroups) ? metadata.agentResultGroups .map((item) => { const record = (item || {}) as Record; return { modeType: String(record.modeType || "").trim(), label: String(record.label || "").trim(), count: Math.max(Number(record.count || 0), 0), importedCount: Math.max(Number(record.importedCount || 0), 0), }; }) .filter((item) => item.label && item.count > 0) : []; }, [selectedVideoWorkflowData]); const selectedWorkflowAgentResultAssets = useMemo< Array<{ modeType: string; label: string; assets: WorkflowMediaReference[]; }> >(() => { const metadata = ((selectedVideoWorkflowData?.metadata || {}) as Record) || {}; return Array.isArray(metadata.agentResultAssets) ? metadata.agentResultAssets.reduce< Array<{ modeType: string; label: string; assets: WorkflowMediaReference[]; }> >((groups, item) => { const record = (item || {}) as Record; const modeType = String(record.modeType || "").trim(); const label = String(record.label || "").trim(); const assets = Array.isArray(record.assets) ? record.assets.reduce( (nextAssets, asset, index) => { const assetRecord = (asset || {}) as Record; const url = String(assetRecord.url || "").trim(); if (!/^https?:\/\//i.test(url)) return nextAssets; nextAssets.push({ nodeId: selectedVideoWorkflowNode?.id || "", kind: "image" as const, url, title: String( assetRecord.title || `${label || "Agent 结果"} ${index + 1}` ).trim(), prompt: selectedWorkflowPrompt, source: "workflow-result" as const, }); return nextAssets; }, [] ) : []; if (!modeType || !label || assets.length === 0) return groups; groups.push({ modeType, label, assets, }); return groups; }, []) : []; }, [ selectedVideoWorkflowData, selectedVideoWorkflowNode?.id, selectedWorkflowPrompt, ]); const selectedWorkflowAgentResultCount = useMemo( () => selectedWorkflowAgentResultGroups.reduce( (total, item) => total + item.count, 0 ), [selectedWorkflowAgentResultGroups] ); const selectedWorkflowPendingAgentResultCount = useMemo(() => { const metadata = ((selectedVideoWorkflowData?.metadata || {}) as Record) || {}; return Math.max(Number(metadata.pendingAgentResultCount || 0), 0); }, [selectedVideoWorkflowData]); const selectedStoryboardDownstreamNodes = useMemo(() => { if ( !selectedVideoWorkflowNode || selectedVideoWorkflowType !== "storyboard-node" ) { return []; } return getDirectDownstreamNodes(selectedVideoWorkflowNode.id, workflowGraph) .map((node) => ({ node, templateType: node.type === "workflow" ? inferVideoWorkflowTemplateType(node) : null, })) .filter((item) => Boolean(item.templateType)); }, [selectedVideoWorkflowNode, selectedVideoWorkflowType, workflowGraph]); const selectedStoryboardBatchTargets = useMemo( () => selectedStoryboardDownstreamNodes.filter( (item) => item.templateType === "gen-image" || item.templateType === "gen-video" ), [selectedStoryboardDownstreamNodes] ); const selectedStoryboardChartTargets = useMemo( () => selectedStoryboardDownstreamNodes.filter( (item) => item.templateType === "storyboard-chart-node" ), [selectedStoryboardDownstreamNodes] ); const selectedStoryboardCanBatchExecute = useMemo( () => selectedVideoWorkflowType === "storyboard-node" && selectedStoryboardBatchTargets.length > 0 && (selectedWorkflowStoryboardShots.length > 0 || Boolean(selectedWorkflowPrompt.trim())), [ selectedStoryboardBatchTargets.length, selectedVideoWorkflowType, selectedWorkflowPrompt, selectedWorkflowStoryboardShots.length, ] ); const selectedWorkflowIsActionable = useMemo( () => Boolean( selectedVideoWorkflowType && [ "video-analyze", "storyboard-node", "storyboard-chart-node", "table-editor-node", "gen-image", "gen-video", "camera-movement", "canvas-node", "motion-control", "doodle-canvas", "professional-camera", "image-compare", "inpaint-crop", "inpaint-stitch", "jimeng-super-resolution", "gen-music", "rh-app", "rh-comfy", "preview", "local-save", "extract-characters-scenes", "character-description", "scene-description", "create-character", "create-scene", "generate-character-image", "generate-character-video", "generate-scene-image", "generate-scene-video", "custom-agent", ].includes(selectedVideoWorkflowType) ), [selectedVideoWorkflowType] ); const selectedWorkflowActionLabel = useMemo(() => { switch (selectedVideoWorkflowType) { case "video-analyze": return "分析视频"; case "extract-characters-scenes": return "提取角色场景"; case "character-description": return "整理角色描述"; case "scene-description": return "整理场景描述"; case "create-character": return "固化角色资产"; case "create-scene": return "固化场景资产"; case "storyboard-node": return "整理分镜"; case "storyboard-chart-node": return "生成分镜表"; case "table-editor-node": return "整理表格"; case "camera-movement": return "测试运镜"; case "canvas-node": return "整理画板"; case "motion-control": return "生成动作控制"; case "doodle-canvas": return "整理涂鸦画板"; case "professional-camera": return "调整专业机位"; case "gen-image": return "生成图片"; case "gen-video": return "生成视频"; case "image-compare": return "整理对比"; case "inpaint-crop": return "裁剪局部重绘"; case "inpaint-stitch": return "拼接补画"; case "jimeng-super-resolution": return "超分增强"; case "gen-music": return "生成配乐方案"; case "rh-app": return "整理 RH 应用包"; case "rh-comfy": return "整理 Comfy 工作流"; case "generate-character-image": return "生成角色图"; case "generate-character-video": return "生成角色视频"; case "generate-scene-image": return "生成场景图"; case "generate-scene-video": return "生成场景视频"; case "custom-agent": return "更新代理计划"; case "preview": return "刷新预览"; case "local-save": return "下载上游结果"; default: return "执行节点"; } }, [selectedVideoWorkflowType]); const selectedWorkflowActionHint = useMemo(() => { switch (selectedVideoWorkflowType) { case "video-analyze": return "会调用现有视觉分析能力,整理镜头节奏、视觉母题和可继续拆解的镜头建议。"; case "extract-characters-scenes": return "会从脚本、设定和参考图中提取角色与场景线索,作为后续资产节点的输入。"; case "character-description": return "会把上游角色线索整理成更稳定的角色描述,方便继续生成角色图和角色视频。"; case "scene-description": return "会把上游场景线索整理成更稳定的场景描述,方便继续生成环境图和场景视频。"; case "create-character": return "会把上游角色信息固化成可复用角色资产,供角色图和角色视频节点复用。"; case "create-scene": return "会把上游场景信息固化成可复用场景资产,供场景图和场景视频节点复用。"; case "storyboard-node": return "会先整理上游脚本和素材提示,再把镜头草案派发给下游生图或生视频节点。"; case "storyboard-chart-node": return "会把上游分镜草案整理成结构化镜头表,方便继续检查节奏、镜头说明和生成提示。"; case "table-editor-node": return "会把上游脚本、角色、场景或镜头信息整理成表格结构,方便继续转成分镜或资产表。"; case "camera-movement": return "会围绕上游脚本和参考图生成一段运镜测试片段,同时沉淀镜头运动说明。"; case "canvas-node": return "会把上游素材整理成一个结构化画板节点,方便继续做对比、挑图和分支创作。"; case "motion-control": return "会围绕上游脚本和主体动作生成一段动作控制片段,同时沉淀运动节奏说明。"; case "doodle-canvas": return "会把上游创作意图整理成涂鸦/遮罩指令,方便继续接局部重绘或外部涂鸦工作流。"; case "professional-camera": return "会复用专业机位调整能力,对上游图片做一版机位/焦距测试图,也会沉淀镜头参数。"; case "gen-image": return "会复用现有生图能力,把上游脚本和参考图变成新的关键帧结果节点。"; case "gen-video": return "会复用现有视频生成能力,根据上游脚本和参考图产出视频结果节点。"; case "image-compare": return "会把上游图片或视频封面整理成对比卡,方便并排检查版本差异和风格一致性。"; case "inpaint-crop": return "会复用框选抠图能力,先把上游图片裁出适合局部重绘的工作区域。"; case "inpaint-stitch": return "会复用图像拓展能力,对上游图片做一版补边扩图,方便继续拼接或补画。"; case "jimeng-super-resolution": return "会复用当前图像增强链路,对上游图片做一版高清增强并生成新的结果节点。"; case "gen-music": return "会把上游脚本和分镜整理成配乐提示与节奏表,方便继续接音频制作或外部作曲工具。"; case "rh-app": return "会把当前工作流上下文整理成 RH 应用可执行包,沉淀步骤、输入和交付物。"; case "rh-comfy": return "会把当前工作流上下文整理成 Comfy 图谱包,沉淀节点顺序、输入资源和提示词。"; case "generate-character-image": return "会围绕角色资产复用现有生图能力,产出风格稳定的角色关键帧。"; case "generate-character-video": return "会围绕角色资产复用现有视频生成能力,产出角色镜头或动作片段。"; case "generate-scene-image": return "会围绕场景资产复用现有生图能力,产出环境和氛围关键帧。"; case "generate-scene-video": return "会围绕场景资产复用现有视频生成能力,产出环境镜头或转场片段。"; case "custom-agent": return "会基于当前上下文生成一版代理执行计划,也可以一键送入 Agent 工作台继续细化执行。"; case "preview": return "会从上游结果里抓取最近的图片或视频,回填到预览节点中。"; case "local-save": return "会把上游图片或视频打包下载,作为当前流程的本地保存出口。"; default: return ""; } }, [selectedVideoWorkflowType]); const selectedWorkflowPrimaryTaskKey = selectedVideoWorkflowNode ? `${selectedVideoWorkflowNode.id}:primary` : null; const selectedWorkflowBatchTaskKey = selectedVideoWorkflowNode && selectedVideoWorkflowType === "storyboard-node" ? `${selectedVideoWorkflowNode.id}:batch` : null; const selectedWorkflowAgentTaskKey = selectedVideoWorkflowNode && selectedVideoWorkflowType === "custom-agent" ? `${selectedVideoWorkflowNode.id}:agent` : null; const selectedWorkflowBranchTaskKey = selectedVideoWorkflowNode && selectedVideoWorkflowType === "custom-agent" ? `${selectedVideoWorkflowNode.id}:branches` : null; const isSelectedWorkflowPrimaryRunning = Boolean(selectedWorkflowPrimaryTaskKey) && activeWorkflowExecutionTaskKey === selectedWorkflowPrimaryTaskKey; const isSelectedWorkflowBatchRunning = Boolean(selectedWorkflowBatchTaskKey) && activeWorkflowExecutionTaskKey === selectedWorkflowBatchTaskKey; const isSelectedWorkflowAgentRunning = Boolean(selectedWorkflowAgentTaskKey) && activeWorkflowExecutionTaskKey === selectedWorkflowAgentTaskKey; const isSelectedWorkflowBranchRunning = Boolean(selectedWorkflowBranchTaskKey) && activeWorkflowExecutionTaskKey === selectedWorkflowBranchTaskKey; const isAnyWorkflowExecutionRunning = Boolean(activeWorkflowExecutionTaskKey); const resolvedPanelWidth = viewportWidth > 0 ? Math.min( floatingPanelWidth, Math.max(viewportWidth - DOCKED_PANEL_INSET, 0) ) : floatingPanelWidth; const isFloatingSidebarVisible = !isVideoMode && isPanelOpen; const panelOcclusionRight = isFloatingSidebarVisible ? resolvedPanelWidth + DOCKED_PANEL_INSET : 0; const visibleCanvasWidth = viewportWidth > 0 ? Math.max(viewportWidth - panelOcclusionRight, 0) : 0; const horizontalFloatingToolbarWidthForDocking = horizontalFloatingToolbarWidth || FLOATING_TOOLBAR_ESTIMATED_WIDTH; const isFloatingToolbarWidthCrowded = visibleCanvasWidth > 0 && visibleCanvasWidth < horizontalFloatingToolbarWidthForDocking + FLOATING_TOOLBAR_SIDE_DOCK_CLEARANCE * 2; const isFloatingToolbarCanvasCrowded = viewportWidth > 0 && visibleCanvasWidth > 0 && visibleCanvasWidth / viewportWidth <= FLOATING_TOOLBAR_SIDE_DOCK_VISIBLE_CANVAS_RATIO; const shouldDockFloatingToolbarToSide = !isVideoMode && isFloatingSidebarVisible && (isFloatingToolbarWidthCrowded || isFloatingToolbarCanvasCrowded); const floatingToolbarTooltipSide = shouldDockFloatingToolbarToSide ? "right" : "top"; const floatingToolbarDropdownSide = shouldDockFloatingToolbarToSide ? "right" : "bottom"; const floatingToolbarOffset = isFloatingSidebarVisible ? -(panelOcclusionRight / 2) : 0; const compactControlsLeft = FLOATING_UI_GAP; const handleFloatingPanelResizePointerDown = useCallback( (event: ReactPointerEvent) => { if (event.button !== 0) { return; } event.preventDefault(); event.stopPropagation(); const handleElement = event.currentTarget; const pointerId = event.pointerId; const previousCursor = document.body.style.cursor; const previousUserSelect = document.body.style.userSelect; const updateWidthFromClientX = (clientX: number) => { const currentViewportWidth = window.innerWidth; const nextWidth = currentViewportWidth - DOCKED_PANEL_INSET - clientX; setFloatingPanelWidth( clampFloatingPanelWidth(nextWidth, currentViewportWidth) ); }; const cleanup = () => { document.removeEventListener("pointermove", handlePointerMove); document.removeEventListener("pointerup", handlePointerEnd); document.removeEventListener("pointercancel", handlePointerEnd); document.body.style.cursor = previousCursor; document.body.style.userSelect = previousUserSelect; setIsFloatingPanelResizing(false); try { if (handleElement.hasPointerCapture(pointerId)) { handleElement.releasePointerCapture(pointerId); } } catch { // Pointer capture can already be released by the browser. } }; const handlePointerMove = (moveEvent: PointerEvent) => { if (moveEvent.pointerId !== pointerId) { return; } moveEvent.preventDefault(); updateWidthFromClientX(moveEvent.clientX); }; const handlePointerEnd = (endEvent: PointerEvent) => { if (endEvent.pointerId !== pointerId) { return; } cleanup(); }; try { handleElement.setPointerCapture(pointerId); } catch { // Some browsers may reject capture on synthetic pointer events. } document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; setIsFloatingPanelResizing(true); updateWidthFromClientX(event.clientX); document.addEventListener("pointermove", handlePointerMove); document.addEventListener("pointerup", handlePointerEnd); document.addEventListener("pointercancel", handlePointerEnd); }, [] ); const updateCanvasSearchParams = useCallback( (updates: Record) => { const nextParams = new URLSearchParams(searchParams.toString()); Object.entries(updates).forEach(([key, value]) => { if (value === null || value === "") { nextParams.delete(key); } else { nextParams.set(key, value); } }); const nextQuery = nextParams.toString(); router.replace( nextQuery ? `/canvas/${id}?${nextQuery}` : `/canvas/${id}` ); }, [id, router, searchParams] ); const openLoginDialog = useCallback((message?: string) => { setLoginDialogMessage( String(message || "").trim() || "当前登录状态已失效,请重新登录后继续操作。" ); setIsLoginDialogOpen(true); }, []); const redirectToLogin = useCallback(() => { if (typeof window === "undefined") { router.push("/login"); return; } const callbackUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; window.location.assign( `/login?callbackUrl=${encodeURIComponent(callbackUrl)}` ); }, [router]); const checkCanvasAuthSession = useCallback(async () => { try { const response = await fetch("/api/auth/session", { cache: "no-store" }); const session = await response.json().catch(() => null); const authed = Boolean(session?.user?.id); setIsAuthenticated(authed); setAuthSessionChecked(true); if (authed) { setIsLoginDialogOpen(false); } else { openLoginDialog(); } return authed; } catch (error) { console.warn("[Canvas] Failed to verify auth session:", error); setIsAuthenticated(true); setAuthSessionChecked(true); return true; } }, [openLoginDialog]); const handleToggleAgentMode = useCallback(() => { if (isAgentMode) { updateCanvasSearchParams({ mode: null, agentPresetId: null, agentProjectId: null, }); return; } updateCanvasSearchParams({ mode: "agent", agentPresetId: null, agentProjectId: agentProjectId || null, }); setIsPanelOpen(true); setPanelMode("chat"); }, [agentProjectId, isAgentMode, updateCanvasSearchParams]); const handleStyleModeToolbarClick = useCallback(() => { if (isAgentWorkspaceEnabled) { handleToggleAgentMode(); return; } if (isStyleProFeatureEnabled) { router.push(`/canvas/${id}/style-pro`); } }, [ handleToggleAgentMode, id, isAgentWorkspaceEnabled, isStyleProFeatureEnabled, router, ]); const buildCurrentWorkflowPersistedState = useCallback((): AgentWorkflowPersistedState => { const persistedMaterials = appearanceRecognitionEnabled ? workflowMaterials : clearAppearanceSummaryFromMaterials(workflowMaterials); return { inputs: { primarySku: workflowInputs.primarySku.trim(), secondarySku: workflowInputs.secondarySku.trim(), brand: workflowInputs.brand.trim(), store: workflowInputs.store.trim(), officialSecondCategory: workflowInputs.officialSecondCategory.trim(), officialThirdCategory: workflowInputs.officialThirdCategory.trim(), thirdCategory: workflowInputs.thirdCategory.trim(), fourthCategory: workflowInputs.fourthCategory.trim(), fifthCategory: workflowInputs.fifthCategory.trim(), priceBand: workflowInputs.priceBand.trim(), }, materials: persistedMaterials, appearanceRecognitionEnabled, selections: sanitizedWorkflowSelections, selectedBrandModel, selectedAgentPresetId: selectedAgentPresetId || null, currentStage: currentAgentStage, agentModeState, }; }, [ agentModeState, currentAgentStage, appearanceRecognitionEnabled, selectedAgentPresetId, selectedBrandModel, workflowInputs, workflowMaterials, sanitizedWorkflowSelections, ]); useEffect(() => { void checkCanvasAuthSession(); const handleWindowFocus = () => { void checkCanvasAuthSession(); }; const handleVisibilityChange = () => { if (document.visibilityState === "visible") { void checkCanvasAuthSession(); } }; window.addEventListener("focus", handleWindowFocus); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { window.removeEventListener("focus", handleWindowFocus); document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, [checkCanvasAuthSession]); useEffect(() => { preloadCanvasChatPanel(); preloadCanvasChatData(id); return scheduleCanvasIdleTask(() => { void import("@/components/canvas/CanvasFileListPanel"); }, 700); }, [id]); useEffect(() => { const timeoutId = window.setTimeout(() => { setAreSecondaryPanelsReady(true); }, 800); return () => window.clearTimeout(timeoutId); }, []); const hydrateCanvasProject = useCallback( (project: CanvasInitialProject) => { externalCanvasConflictRef.current = false; externalCanvasConflictNotifiedRef.current = false; useCanvasStore.getState().setProject(id, project.title); const projectUpdatedAt = String(project.updatedAt || ""); useCanvasStore .getState() .setCanvasRevision(projectUpdatedAt, projectUpdatedAt); if (project.canvasData) { useCanvasStore.getState().loadCanvas(project.canvasData); const persistedWorkflow = project.canvasData.workflowState; if (!requestedAgentMode && persistedWorkflow) { const nextAppearanceRecognitionEnabled = persistedWorkflow.appearanceRecognitionEnabled === true; const nextWorkflowMaterials = nextAppearanceRecognitionEnabled ? normalizeAgentWorkflowMaterialSelections(persistedWorkflow.materials) : clearAppearanceSummaryFromMaterials( normalizeAgentWorkflowMaterialSelections( persistedWorkflow.materials ) ); setWorkflowInputs({ primarySku: String( persistedWorkflow.inputs?.primarySku || "" ).trim(), secondarySku: String( persistedWorkflow.inputs?.secondarySku || "" ).trim(), brand: String(persistedWorkflow.inputs?.brand || "").trim(), store: String(persistedWorkflow.inputs?.store || "").trim(), officialSecondCategory: String( persistedWorkflow.inputs?.officialSecondCategory || "" ).trim(), officialThirdCategory: String( persistedWorkflow.inputs?.officialThirdCategory || "" ).trim(), thirdCategory: String( persistedWorkflow.inputs?.thirdCategory || "" ).trim(), fourthCategory: String( persistedWorkflow.inputs?.fourthCategory || "" ).trim(), fifthCategory: String( persistedWorkflow.inputs?.fifthCategory || "" ).trim(), priceBand: String( persistedWorkflow.inputs?.priceBand || "" ).trim(), }); setWorkflowMaterials(nextWorkflowMaterials); setAppearanceRecognitionEnabled(nextAppearanceRecognitionEnabled); setWorkflowSelections(persistedWorkflow.selections || {}); setSelectedBrandModel( persistedWorkflow.selectedBrandModel ? { ...persistedWorkflow.selectedBrandModel, brandName: String( persistedWorkflow.selectedBrandModel.brandName || "" ).trim(), id: String( persistedWorkflow.selectedBrandModel.id || "" ).trim(), imageUrl: String( persistedWorkflow.selectedBrandModel.imageUrl || "" ).trim(), assetId: String( persistedWorkflow.selectedBrandModel.assetId || "" ).trim() || null, displayName: String( persistedWorkflow.selectedBrandModel.displayName || "" ).trim() || null, mimeType: String( persistedWorkflow.selectedBrandModel.mimeType || "" ).trim() || null, sortOrder: Number.isFinite( Number(persistedWorkflow.selectedBrandModel.sortOrder) ) ? Math.max( 0, Math.min( 999, Math.round( Number( persistedWorkflow.selectedBrandModel.sortOrder ) ) ) ) : 0, } : null ); setSelectedAgentPresetId( String(persistedWorkflow.selectedAgentPresetId || "").trim() || null ); setCurrentAgentStage( persistedWorkflow.currentStage === "mode" ? "mode" : "workflow" ); setAgentModeState( normalizeAgentModeWorkspaceState(persistedWorkflow.agentModeState) ); setWorkflowHydrationSource("project"); return; } } setWorkflowHydrationSource("empty"); }, [id, requestedAgentMode] ); useLayoutEffect(() => { if (!initialProjectForCurrentRoute) return; if (lastLoadedCanvasIdRef.current === id) return; hydrateCanvasProject(initialProjectForCurrentRoute); setIsLoading(false); lastLoadedCanvasIdRef.current = id; }, [hydrateCanvasProject, id, initialProjectForCurrentRoute]); // 加载画布数据 useEffect(() => { console.log("[Canvas] Loading project:", id); const loadProject = async () => { if (lastLoadedCanvasIdRef.current === id) { setIsLoading(false); return; } setIsLoading(true); if (!requestedAgentMode) { setWorkflowHydrationSource("pending"); } if (!requestedAgentMode) { setWorkflowInputs({ primarySku: "", secondarySku: "", brand: "", store: "", officialSecondCategory: "", officialThirdCategory: "", thirdCategory: "", fourthCategory: "", fifthCategory: "", priceBand: "", }); setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections()); setWorkflowSelections({}); setSelectedBrandModel(null); setSelectedAgentPresetId(null); } if (!requestedAgentMode) { setAgentModeState(createEmptyAgentModeWorkspaceState()); setAgentWorkspaceState(createEmptyAgentGenerationProjectWorkspaceState()); setAgentProjectTitle("未命名生图项目"); setAgentProjectThumbnail(null); setAgentProjectResultCollections({}); setAgentProjectDeletedResultCollections({}); setAgentProjectReplacementResultState({}); setLoadedAgentProjectId(null); } // 🆕 先清空当前画布状态,确保切换时不会显示旧数据 useCanvasStore.getState().clearCanvas(); const result = await canvasService.getProject(id); if (result.success && result.data) { useCanvasStore.getState().setProject(id, result.data.title); useCanvasStore .getState() .setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || "")); if (result.data.canvasData) { useCanvasStore.getState().loadCanvas(result.data.canvasData); const persistedWorkflow = result.data.canvasData.workflowState; if (!requestedAgentMode && persistedWorkflow) { const nextAppearanceRecognitionEnabled = persistedWorkflow.appearanceRecognitionEnabled === true; const nextWorkflowMaterials = nextAppearanceRecognitionEnabled ? normalizeAgentWorkflowMaterialSelections( persistedWorkflow.materials ) : clearAppearanceSummaryFromMaterials( normalizeAgentWorkflowMaterialSelections( persistedWorkflow.materials ) ); setWorkflowInputs({ primarySku: String( persistedWorkflow.inputs?.primarySku || "" ).trim(), secondarySku: String( persistedWorkflow.inputs?.secondarySku || "" ).trim(), brand: String(persistedWorkflow.inputs?.brand || "").trim(), store: String(persistedWorkflow.inputs?.store || "").trim(), officialSecondCategory: String( persistedWorkflow.inputs?.officialSecondCategory || "" ).trim(), officialThirdCategory: String( persistedWorkflow.inputs?.officialThirdCategory || "" ).trim(), thirdCategory: String( persistedWorkflow.inputs?.thirdCategory || "" ).trim(), fourthCategory: String( persistedWorkflow.inputs?.fourthCategory || "" ).trim(), fifthCategory: String( persistedWorkflow.inputs?.fifthCategory || "" ).trim(), priceBand: String( persistedWorkflow.inputs?.priceBand || "" ).trim(), }); setWorkflowMaterials(nextWorkflowMaterials); setAppearanceRecognitionEnabled(nextAppearanceRecognitionEnabled); setWorkflowSelections(persistedWorkflow.selections || {}); setSelectedBrandModel( persistedWorkflow.selectedBrandModel ? { ...persistedWorkflow.selectedBrandModel, brandName: String( persistedWorkflow.selectedBrandModel.brandName || "" ).trim(), id: String( persistedWorkflow.selectedBrandModel.id || "" ).trim(), imageUrl: String( persistedWorkflow.selectedBrandModel.imageUrl || "" ).trim(), assetId: String( persistedWorkflow.selectedBrandModel.assetId || "" ).trim() || null, displayName: String( persistedWorkflow.selectedBrandModel.displayName || "" ).trim() || null, mimeType: String( persistedWorkflow.selectedBrandModel.mimeType || "" ).trim() || null, sortOrder: Number.isFinite( Number(persistedWorkflow.selectedBrandModel.sortOrder) ) ? Math.max( 0, Math.min( 999, Math.round( Number( persistedWorkflow.selectedBrandModel.sortOrder ) ) ) ) : 0, } : null ); setSelectedAgentPresetId( String(persistedWorkflow.selectedAgentPresetId || "").trim() || null ); setCurrentAgentStage( persistedWorkflow.currentStage === "mode" ? "mode" : "workflow" ); setAgentModeState( normalizeAgentModeWorkspaceState(persistedWorkflow.agentModeState) ); setWorkflowHydrationSource("project"); } else { setWorkflowHydrationSource("empty"); } } else { setWorkflowHydrationSource("empty"); } } else { if (isUnauthorizedApiError(result.error)) { setIsAuthenticated(false); openLoginDialog( result.error || "当前登录状态已失效,请重新登录后继续操作。" ); setWorkflowHydrationSource("empty"); setIsLoading(false); lastLoadedCanvasIdRef.current = id; return; } // 画布不存在时,创建一个新画布 console.log("[Canvas] 画布不存在,创建新画布:", id); useCanvasStore .getState() .setProject( id, requestedAgentMode ? "风格化工作台" : defaultCanvasTitle ); setWorkflowHydrationSource("empty"); if (!requestedAgentMode) { // 保存新画布到数据库 try { await canvasService.saveCanvasState( id, { nodes: [], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select", }, activeCanvasWorkspaceType ); } catch (error) { console.error("[Canvas] 创建新画布失败:", error); } } } setIsLoading(false); lastLoadedCanvasIdRef.current = id; }; loadProject(); }, [ activeCanvasWorkspaceType, defaultCanvasTitle, id, openLoginDialog, requestedAgentMode, ]); useEffect(() => { const updateViewportWidth = () => setViewportWidth(window.innerWidth); updateViewportWidth(); window.addEventListener("resize", updateViewportWidth); return () => window.removeEventListener("resize", updateViewportWidth); }, []); useEffect(() => { if (typeof window === "undefined") { setIsFloatingPanelWidthStorageReady(true); return; } const storedWidth = Number( window.localStorage.getItem(FLOATING_PANEL_WIDTH_STORAGE_KEY) ); if (Number.isFinite(storedWidth)) { setFloatingPanelWidth( clampFloatingPanelWidth(storedWidth, window.innerWidth) ); } setIsFloatingPanelWidthStorageReady(true); }, []); useEffect(() => { if (viewportWidth <= 0) { return; } setFloatingPanelWidth((currentWidth) => clampFloatingPanelWidth(currentWidth, viewportWidth) ); }, [viewportWidth]); useEffect(() => { if (typeof window === "undefined") { return; } const toolbarElement = floatingToolbarContentRef.current; if (!toolbarElement) { return; } const measureHorizontalToolbar = () => { if (toolbarElement.dataset.canvasToolbarOrientation !== "horizontal") { return; } const nextWidth = Math.ceil(toolbarElement.getBoundingClientRect().width); if (!Number.isFinite(nextWidth) || nextWidth <= 0) { return; } setHorizontalFloatingToolbarWidth((currentWidth) => Math.abs(currentWidth - nextWidth) > 1 ? nextWidth : currentWidth ); }; measureHorizontalToolbar(); const resizeObserver = new ResizeObserver(measureHorizontalToolbar); resizeObserver.observe(toolbarElement); window.addEventListener("resize", measureHorizontalToolbar); return () => { resizeObserver.disconnect(); window.removeEventListener("resize", measureHorizontalToolbar); }; }, []); useEffect(() => { if ( !isFloatingPanelWidthStorageReady || typeof window === "undefined" ) { return; } window.localStorage.setItem( FLOATING_PANEL_WIDTH_STORAGE_KEY, String(Math.round(floatingPanelWidth)) ); }, [floatingPanelWidth, isFloatingPanelWidthStorageReady]); useEffect(() => { if (!videoCanvasInsertMenu) return; const handlePointerDown = (event: PointerEvent) => { const menu = videoCanvasInsertMenuRef.current; if (menu && event.target instanceof Node && menu.contains(event.target)) { return; } setVideoCanvasInsertMenu(null); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setVideoCanvasInsertMenu(null); } }; document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("pointerdown", handlePointerDown); document.removeEventListener("keydown", handleKeyDown); }; }, [videoCanvasInsertMenu]); useEffect(() => { if (isAgentMode) { setIsPanelOpen(true); setPanelMode("chat"); } }, [isAgentMode]); useEffect(() => { if (isVideoMode || typeof window === "undefined") return; const handleCanvasChatPanelOpenRequest = () => { setPanelMode("chat"); setIsPanelOpen(true); }; window.addEventListener( "canvas-open-chat-panel-request", handleCanvasChatPanelOpenRequest ); return () => { window.removeEventListener( "canvas-open-chat-panel-request", handleCanvasChatPanelOpenRequest ); }; }, [isVideoMode]); useEffect(() => { if (shouldOpenBatchProcessing && isBatchProcessingEnabled) { router.replace(`/canvas/${id}/batch`); } }, [id, isBatchProcessingEnabled, router, shouldOpenBatchProcessing]); const workflowStorageKey = useMemo(() => `canvas-workflow-state:${id}`, [id]); const buildPersistedCanvasData = useCallback(() => { return sanitizeCanvasStateForPersist(getCanvasState()); }, [getCanvasState]); const handleExportVideoCanvasProject = useCallback(() => { if (!isVideoMode || typeof window === "undefined") { return; } const payload: VideoCanvasProjectExportPayload = { format: "video-canvas-project", version: 1, exportedAt: new Date().toISOString(), workspaceType: activeCanvasWorkspaceType, project: { id, title: projectTitle || "未命名视频画布", }, canvasData: buildPersistedCanvasData(), }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8", }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `${sanitizeExportFileName( payload.project.title || "video-canvas-project" )}.json`; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(url); toast.success("已导出当前视频画布工程"); }, [ activeCanvasWorkspaceType, buildPersistedCanvasData, id, isVideoMode, projectTitle, ]); const handleImportVideoCanvasProjectClick = useCallback(() => { if (!isVideoMode) { return; } importVideoCanvasInputRef.current?.click(); }, [isVideoMode]); const handleImportVideoCanvasProjectChange = useCallback( async (event: ChangeEvent) => { const file = event.target.files?.[0] || null; event.target.value = ""; if (!file) { return; } if ( nodes.length > 0 && typeof window !== "undefined" && !window.confirm("导入会替换当前视频画布内容,是否继续?") ) { return; } setIsImportingVideoCanvas(true); try { const rawText = await file.text(); const parsed = JSON.parse(rawText); const normalized = normalizeImportedCanvasState(parsed); if (!normalized) { throw new Error("文件格式无法识别,请导入视频画布导出的 JSON。"); } const nextTitle = normalized.title || projectTitle || (file.name ? file.name.replace(/\.json$/i, "") : "") || "导入的视频画布"; clearCanvas(); loadCanvas(normalized.canvasData); setProjectTitle(nextTitle); const [saveResult, updateResult] = await Promise.all([ canvasService.saveCanvasState( id, normalized.canvasData, activeCanvasWorkspaceType ), canvasService.updateProject( id, { title: nextTitle, }, activeCanvasWorkspaceType ), ]); if (!saveResult.success) { throw new Error(saveResult.error || "导入后保存失败"); } if (!updateResult.success) { console.warn( "[Canvas] import title update failed:", updateResult.error ); } toast.success(`已导入视频画布:${nextTitle}`); } catch (error) { const message = error instanceof Error ? error.message : "导入视频画布失败"; toast.error(message); } finally { setIsImportingVideoCanvas(false); } }, [ activeCanvasWorkspaceType, clearCanvas, id, loadCanvas, nodes.length, projectTitle, setProjectTitle, ] ); const persistedAgentProjectResultState = useMemo(() => { const resultCollections = { scene_generation: normalizeResultCollectionUrls( agentProjectResultCollections.scene_generation ), detail_generation: normalizeResultCollectionUrls( agentProjectResultCollections.detail_generation ), detail_replacement: normalizeResultCollectionUrls( agentProjectResultCollections.detail_replacement ), sku_replacement: normalizeResultCollectionUrls( agentProjectResultCollections.sku_replacement ), }; return { resultCollections, deletedResultCollections: { scene_generation: normalizeResultCollectionUrls( agentProjectDeletedResultCollections.scene_generation ), detail_generation: normalizeResultCollectionUrls( agentProjectDeletedResultCollections.detail_generation ), detail_replacement: normalizeResultCollectionUrls( agentProjectDeletedResultCollections.detail_replacement ), sku_replacement: normalizeResultCollectionUrls( agentProjectDeletedResultCollections.sku_replacement ), }, resultImageUrls: Array.from( new Set([ ...resultCollections.scene_generation, ...resultCollections.detail_generation, ...resultCollections.detail_replacement, ...resultCollections.sku_replacement, ]) ), replacementResultState: normalizeAgentGenerationProjectReplacementResultState( agentProjectReplacementResultState ), }; }, [ agentProjectDeletedResultCollections, agentProjectReplacementResultState, agentProjectResultCollections, ]); const buildPersistedAgentProjectState = useCallback(() => { const persistedMaterials = appearanceRecognitionEnabled ? workflowMaterials : clearAppearanceSummaryFromMaterials(workflowMaterials); const workflowState: AgentWorkflowPersistedState = { inputs: { primarySku: workflowInputs.primarySku.trim(), secondarySku: workflowInputs.secondarySku.trim(), brand: workflowInputs.brand.trim(), store: workflowInputs.store.trim(), officialSecondCategory: workflowInputs.officialSecondCategory.trim(), officialThirdCategory: workflowInputs.officialThirdCategory.trim(), thirdCategory: workflowInputs.thirdCategory.trim(), fourthCategory: workflowInputs.fourthCategory.trim(), fifthCategory: workflowInputs.fifthCategory.trim(), priceBand: workflowInputs.priceBand.trim(), }, materials: persistedMaterials, appearanceRecognitionEnabled, selections: sanitizedWorkflowSelections, selectedBrandModel, selectedAgentPresetId: selectedAgentPresetId || null, currentStage: currentAgentStage, agentModeState, }; return { workflowState, workspaceState: agentWorkspaceState, ...persistedAgentProjectResultState, }; }, [ agentModeState, agentWorkspaceState, currentAgentStage, appearanceRecognitionEnabled, persistedAgentProjectResultState, selectedAgentPresetId, selectedBrandModel, workflowInputs, workflowMaterials, sanitizedWorkflowSelections, ]); const persistedAgentProjectStateRef = useRef( buildPersistedAgentProjectState() ); useEffect(() => { persistedAgentProjectStateRef.current = buildPersistedAgentProjectState(); }, [buildPersistedAgentProjectState]); const writeAgentProjectDraftSnapshot = useCallback( (projectState: AgentGenerationProjectPersistedState) => { if (!isAgentMode || !agentProjectId || typeof window === "undefined") { return; } try { const draftSnapshot: AgentProjectDraftSnapshot = { savedAt: Date.now(), title: agentProjectTitle, thumbnail: agentProjectThumbnail, projectState, }; window.localStorage.setItem( getAgentProjectDraftStorageKey(agentProjectId), JSON.stringify(draftSnapshot) ); } catch (error) { console.warn("[Agent Project] Failed to persist immediate draft:", error); } }, [agentProjectId, agentProjectThumbnail, agentProjectTitle, isAgentMode] ); const patchPersistedAgentWorkflowState = useCallback( (patch: Partial) => { const currentProjectState = persistedAgentProjectStateRef.current || createEmptyAgentGenerationProjectPersistedState(); const currentWorkflowState = currentProjectState.workflowState || createEmptyAgentWorkflowPersistedState(); const nextProjectState: AgentGenerationProjectPersistedState = { ...currentProjectState, workflowState: { ...currentWorkflowState, ...patch, inputs: patch.inputs || currentWorkflowState.inputs, selections: patch.selections || currentWorkflowState.selections, }, }; persistedAgentProjectStateRef.current = nextProjectState; writeAgentProjectDraftSnapshot(nextProjectState); }, [writeAgentProjectDraftSnapshot] ); const hydrateAgentProjectSnapshot = useCallback( (params: { projectId: string; title?: string | null; thumbnail?: string | null; projectState?: AgentGenerationProjectPersistedState | null; }) => { const persistedState = normalizeAgentGenerationProjectPersistedState( params.projectState ); const persistedWorkflow = persistedState.workflowState || createEmptyAgentWorkflowPersistedState(); const nextAppearanceRecognitionEnabled = persistedWorkflow.appearanceRecognitionEnabled === true; const nextWorkflowMaterials = nextAppearanceRecognitionEnabled ? normalizeAgentWorkflowMaterialSelections(persistedWorkflow.materials) : clearAppearanceSummaryFromMaterials( normalizeAgentWorkflowMaterialSelections( persistedWorkflow.materials ) ); const nextWorkflowInputs = { primarySku: String(persistedWorkflow.inputs?.primarySku || "").trim(), secondarySku: String( persistedWorkflow.inputs?.secondarySku || "" ).trim(), brand: String(persistedWorkflow.inputs?.brand || "").trim(), store: String(persistedWorkflow.inputs?.store || "").trim(), officialSecondCategory: String( persistedWorkflow.inputs?.officialSecondCategory || "" ).trim(), officialThirdCategory: String( persistedWorkflow.inputs?.officialThirdCategory || "" ).trim(), thirdCategory: String( persistedWorkflow.inputs?.thirdCategory || "" ).trim(), fourthCategory: String( persistedWorkflow.inputs?.fourthCategory || "" ).trim(), fifthCategory: String( persistedWorkflow.inputs?.fifthCategory || "" ).trim(), priceBand: String(persistedWorkflow.inputs?.priceBand || "").trim(), }; const nextWorkflowSelections = persistedWorkflow.selections || {}; const nextSelectedBrandModel = persistedWorkflow.selectedBrandModel || null; const nextSelectedAgentPresetId = String(persistedWorkflow.selectedAgentPresetId || "").trim() || null; const nextCurrentAgentStage = persistedWorkflow.currentStage === "mode" ? "mode" : "workflow"; const nextAgentModeState = normalizeAgentModeWorkspaceState( persistedWorkflow.agentModeState ); const nextAgentWorkspaceState = persistedState.workspaceState || createEmptyAgentGenerationProjectWorkspaceState(); workflowInputsRef.current = nextWorkflowInputs; workflowMaterialsRef.current = nextWorkflowMaterials; workflowSelectionsRef.current = nextWorkflowSelections; appearanceRecognitionEnabledRef.current = nextAppearanceRecognitionEnabled; selectedBrandModelRef.current = nextSelectedBrandModel; setWorkflowInputs(nextWorkflowInputs); setWorkflowMaterials(nextWorkflowMaterials); setAppearanceRecognitionEnabled(nextAppearanceRecognitionEnabled); setWorkflowSelections(nextWorkflowSelections); setSelectedBrandModel(nextSelectedBrandModel); setSelectedAgentPresetId(nextSelectedAgentPresetId); setCurrentAgentStage(nextCurrentAgentStage); setAgentModeState(nextAgentModeState); setAgentWorkspaceState(nextAgentWorkspaceState); setAgentProjectTitle( String(params.title || "").trim() || "未命名生图项目" ); setAgentProjectThumbnail(String(params.thumbnail || "").trim() || null); setAgentProjectResultCollections(persistedState.resultCollections || {}); setAgentProjectDeletedResultCollections( persistedState.deletedResultCollections || {} ); setAgentProjectReplacementResultState( persistedState.replacementResultState || {} ); setLoadedAgentProjectId(params.projectId); }, [] ); useEffect(() => { if (isAgentMode) return; if (typeof window === "undefined") return; if (workflowHydrationSource !== "empty") return; try { const raw = window.localStorage.getItem(workflowStorageKey); if (!raw) { setWorkflowHydrationSource("local"); return; } const parsed = JSON.parse(raw) as AgentWorkflowPersistedState; const nextAppearanceRecognitionEnabled = parsed.appearanceRecognitionEnabled === true; const nextWorkflowMaterials = nextAppearanceRecognitionEnabled ? normalizeAgentWorkflowMaterialSelections(parsed.materials) : clearAppearanceSummaryFromMaterials( normalizeAgentWorkflowMaterialSelections(parsed.materials) ); setWorkflowInputs({ primarySku: String(parsed.inputs?.primarySku || "").trim(), secondarySku: String(parsed.inputs?.secondarySku || "").trim(), brand: String(parsed.inputs?.brand || "").trim(), store: String(parsed.inputs?.store || "").trim(), officialSecondCategory: String(parsed.inputs?.officialSecondCategory || "").trim(), officialThirdCategory: String(parsed.inputs?.officialThirdCategory || "").trim(), thirdCategory: String(parsed.inputs?.thirdCategory || "").trim(), fourthCategory: String(parsed.inputs?.fourthCategory || "").trim(), fifthCategory: String(parsed.inputs?.fifthCategory || "").trim(), priceBand: String(parsed.inputs?.priceBand || "").trim(), }); setWorkflowMaterials(nextWorkflowMaterials); setAppearanceRecognitionEnabled(nextAppearanceRecognitionEnabled); setWorkflowSelections(parsed.selections || {}); setSelectedBrandModel( parsed.selectedBrandModel ? { ...parsed.selectedBrandModel, brandName: String( parsed.selectedBrandModel.brandName || "" ).trim(), id: String(parsed.selectedBrandModel.id || "").trim(), imageUrl: String(parsed.selectedBrandModel.imageUrl || "").trim(), assetId: String(parsed.selectedBrandModel.assetId || "").trim() || null, displayName: String(parsed.selectedBrandModel.displayName || "").trim() || null, mimeType: String(parsed.selectedBrandModel.mimeType || "").trim() || null, sortOrder: Number.isFinite( Number(parsed.selectedBrandModel.sortOrder) ) ? Math.max( 0, Math.min( 999, Math.round(Number(parsed.selectedBrandModel.sortOrder)) ) ) : 0, } : null ); setSelectedAgentPresetId( String(parsed.selectedAgentPresetId || "").trim() || null ); setCurrentAgentStage( parsed.currentStage === "mode" ? "mode" : "workflow" ); setAgentModeState( normalizeAgentModeWorkspaceState(parsed.agentModeState) ); } catch (error) { console.warn( "[Canvas] Failed to hydrate workflow state from localStorage:", error ); } finally { setWorkflowHydrationSource("local"); } }, [isAgentMode, workflowHydrationSource, workflowStorageKey]); useEffect(() => { if (isAgentMode) return; if (typeof window === "undefined" || workflowHydrationSource === "pending") return; return scheduleCanvasIdleTask(() => { try { const persistedMaterials = appearanceRecognitionEnabled ? workflowMaterials : clearAppearanceSummaryFromMaterials(workflowMaterials); window.localStorage.setItem( workflowStorageKey, JSON.stringify({ inputs: workflowInputs, materials: persistedMaterials, appearanceRecognitionEnabled, selections: sanitizedWorkflowSelections, selectedBrandModel, selectedAgentPresetId, currentAgentStage, agentModeState, }) ); } catch (error) { console.warn("[Canvas] Failed to persist workflow state:", error); } }); }, [ isAgentMode, workflowHydrationSource, workflowInputs, workflowMaterials, appearanceRecognitionEnabled, sanitizedWorkflowSelections, workflowStorageKey, selectedBrandModel, selectedAgentPresetId, currentAgentStage, agentModeState, ]); useEffect(() => { if (!isAgentMode || !agentProjectId || !hasLoadedCurrentAgentProject) return; if (typeof window === "undefined") return; return scheduleCanvasIdleTask(() => { try { const draftSnapshot: AgentProjectDraftSnapshot = { savedAt: Date.now(), title: agentProjectTitle, thumbnail: agentProjectThumbnail, projectState: buildPersistedAgentProjectState(), }; window.localStorage.setItem( getAgentProjectDraftStorageKey(agentProjectId), JSON.stringify(draftSnapshot) ); } catch (error) { console.warn("[Agent Project] Failed to persist local draft:", error); } }); }, [ agentProjectId, agentProjectThumbnail, agentProjectTitle, buildPersistedAgentProjectState, hasLoadedCurrentAgentProject, isAgentMode, ]); const persistAgentProject = useCallback( async ( reason: "autosave" | "manual" | "switch", overrides?: { title?: string | null } ) => { if (!isAgentMode || !agentProjectId) return false; const nextTitle = String(overrides?.title ?? agentProjectTitle ?? "").trim() || buildDefaultAgentProjectTitle(); const result = await agentProjectService.updateProject(agentProjectId, { title: nextTitle, thumbnail: agentProjectThumbnail, projectState: persistedAgentProjectStateRef.current || buildPersistedAgentProjectState(), }); if (!result.success) { if (isUnauthorizedApiError(result.error)) { setIsAuthenticated(false); openLoginDialog( result.error || "当前登录状态已失效,请重新登录后继续操作。" ); } console.error(`[Agent Project] ${reason} save failed:`, result.error); return false; } if (result.data) { setAgentProjectTitle( result.data.title || buildDefaultAgentProjectTitle() ); setAgentProjectThumbnail(result.data.thumbnail || null); } return true; }, [ agentProjectId, agentProjectThumbnail, agentProjectTitle, buildPersistedAgentProjectState, isAgentMode, openLoginDialog, ] ); useEffect(() => { if (!requestedAgentMode || !agentProjectId) return; let cancelled = false; const loadAgentProject = async () => { setIsAgentProjectMenuOpen(false); setIsAgentProjectLoading(true); setLoadedAgentProjectId(null); const localDraft = readAgentProjectDraftSnapshot(agentProjectId); try { const result = await agentProjectService.getProject(agentProjectId); if (!result.success || !result.data) { if (isUnauthorizedApiError(result.error)) { setIsAuthenticated(false); openLoginDialog( result.error || "当前登录状态已失效,请重新登录后继续操作。" ); if (!cancelled && localDraft?.projectState) { hydrateAgentProjectSnapshot({ projectId: agentProjectId, title: localDraft.title, thumbnail: localDraft.thumbnail, projectState: localDraft.projectState, }); } return; } throw new Error(result.error || "加载生图项目失败"); } if (cancelled) return; const serverUpdatedAt = Number(new Date(result.data.updatedAt).getTime()) || 0; const serverReplacementResultCount = getReplacementResultUrlCount( result.data.projectState ); const localReplacementResultCount = getReplacementResultUrlCount( localDraft?.projectState ); const localDraftMissesServerReplacementResults = serverReplacementResultCount > 0 && localReplacementResultCount === 0; const localDraftReplacementResultsDifferFromServer = serverReplacementResultCount > 0 && localReplacementResultCount > 0 && localReplacementResultCount !== serverReplacementResultCount; const localDraftHasStaleReplacementCollections = serverReplacementResultCount > 0 && hasReplacementResultCollectionMismatch(localDraft?.projectState); const shouldUseLocalDraft = Boolean(localDraft?.projectState) && Number(localDraft?.savedAt || 0) > serverUpdatedAt && !localDraftMissesServerReplacementResults && !localDraftReplacementResultsDifferFromServer && !localDraftHasStaleReplacementCollections; if (shouldUseLocalDraft && localDraft?.projectState) { hydrateAgentProjectSnapshot({ projectId: agentProjectId, title: localDraft.title || result.data.title, thumbnail: localDraft.thumbnail || result.data.thumbnail, projectState: localDraft.projectState, }); return; } hydrateAgentProjectSnapshot({ projectId: agentProjectId, title: result.data.title, thumbnail: result.data.thumbnail, projectState: result.data.projectState, }); } catch (error) { if (!cancelled) { hydrateAgentProjectSnapshot({ projectId: agentProjectId, title: localDraft?.title, thumbnail: localDraft?.thumbnail, projectState: localDraft?.projectState || createEmptyAgentGenerationProjectPersistedState(), }); toast.error( error instanceof Error ? error.message : "加载生图项目失败" ); } } finally { if (!cancelled) { setIsAgentProjectLoading(false); } } }; void loadAgentProject(); return () => { cancelled = true; }; }, [ agentProjectId, hydrateAgentProjectSnapshot, openLoginDialog, requestedAgentMode, ]); useEffect(() => { if (isAgentProjectSheetOpen) { setIsAgentProjectMenuOpen(false); } }, [isAgentProjectSheetOpen]); useEffect(() => { if (!isAgentMode) return; if (isLoginDialogOpen || isAgentProjectSheetOpen || isAgentProjectMenuOpen) { return; } const restoreBodyPointerEvents = () => { if (document.body.style.pointerEvents === "none") { document.body.style.pointerEvents = ""; } }; restoreBodyPointerEvents(); const timeoutId = window.setTimeout(restoreBodyPointerEvents, 120); return () => window.clearTimeout(timeoutId); }, [ isAgentMode, isAgentProjectMenuOpen, isAgentProjectSheetOpen, isLoginDialogOpen, ]); useEffect(() => { if ( !isAgentMode || !agentProjectId || isAgentProjectLoading || !hasLoadedCurrentAgentProject || (authSessionChecked && !isAuthenticated) ) { return; } const timer = window.setTimeout(async () => { setIsAgentProjectSaving(true); try { await persistAgentProject("autosave"); } finally { setIsAgentProjectSaving(false); } }, 2500); return () => window.clearTimeout(timer); }, [ agentModeState, agentProjectId, agentProjectReplacementResultState, agentProjectResultCollections, agentProjectThumbnail, agentProjectTitle, agentWorkspaceState, currentAgentStage, isAgentMode, isAgentProjectLoading, hasLoadedCurrentAgentProject, persistAgentProject, appearanceRecognitionEnabled, authSessionChecked, isAuthenticated, selectedAgentPresetId, selectedBrandModel, workflowInputs, workflowMaterials, workflowSelections, ]); useEffect(() => { if ( !isAgentMode || !agentProjectId || isAgentProjectLoading || !hasLoadedCurrentAgentProject ) { return; } const handleBeforeUnload = () => { const draftSnapshot: AgentProjectDraftSnapshot = { savedAt: Date.now(), title: agentProjectTitle, thumbnail: agentProjectThumbnail, projectState: persistedAgentProjectStateRef.current, }; try { window.localStorage.setItem( getAgentProjectDraftStorageKey(agentProjectId), JSON.stringify(draftSnapshot) ); } catch (error) { console.warn( "[Agent Project] Failed to persist local draft before unload:", error ); } try { void fetch(`/api/agent-projects/${agentProjectId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: String(agentProjectTitle || "").trim() || buildDefaultAgentProjectTitle(), thumbnail: agentProjectThumbnail, projectState: persistedAgentProjectStateRef.current, }), keepalive: true, }); } catch (error) { console.error("[Agent Project] beforeunload save failed:", error); } }; window.addEventListener("beforeunload", handleBeforeUnload); window.addEventListener("pagehide", handleBeforeUnload); return () => { window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("pagehide", handleBeforeUnload); }; }, [ agentProjectId, agentProjectThumbnail, agentProjectTitle, hasLoadedCurrentAgentProject, isAgentMode, isAgentProjectLoading, ]); const handleSaveAsAgentProject = useCallback(async () => { const suggestedTitle = buildSaveAsAgentProjectTitle(agentProjectTitle); const promptedTitle = window.prompt( "请输入另存为的新项目名称", suggestedTitle ); if (promptedTitle === null) { return; } const nextTitle = promptedTitle.trim(); if (!nextTitle) { toast.error("项目名称不能为空"); return; } setIsAgentProjectSaving(true); try { if (agentProjectId) { await persistAgentProject("switch"); } const result = await agentProjectService.createProject({ title: nextTitle, thumbnail: agentProjectThumbnail, projectState: buildPersistedAgentProjectState(), }); if (!result.success || !result.data) { if (isUnauthorizedApiError(result.error)) { setIsAuthenticated(false); openLoginDialog( result.error || "当前登录状态已失效,请重新登录后继续操作。" ); } toast.error(result.error || "另存为新项目失败"); return; } toast.success("已另存为新项目"); updateCanvasSearchParams({ mode: "agent", agentProjectId: result.data.id, }); } finally { setIsAgentProjectSaving(false); } }, [ agentProjectId, agentProjectThumbnail, agentProjectTitle, buildPersistedAgentProjectState, openLoginDialog, persistAgentProject, updateCanvasSearchParams, ]); const handleRenameAgentProject = useCallback( async (targetProjectId: string, currentTitle?: string | null) => { const suggestedTitle = String(currentTitle || "").trim() || "未命名生图项目"; const promptedTitle = window.prompt("请输入新的项目名称", suggestedTitle); if (promptedTitle === null) { return; } const nextTitle = promptedTitle.trim(); if (!nextTitle) { toast.error("项目名称不能为空"); return; } const result = await agentProjectService.updateProject(targetProjectId, { title: nextTitle, }); if (!result.success || !result.data) { if (isUnauthorizedApiError(result.error)) { setIsAuthenticated(false); openLoginDialog( result.error || "当前登录状态已失效,请重新登录后继续操作。" ); } toast.error(result.error || "更新生图项目名称失败"); return; } if (targetProjectId === agentProjectId) { setAgentProjectTitle(result.data.title || nextTitle); } toast.success("项目名称已更新"); }, [agentProjectId, openLoginDialog] ); const handleRenameCurrentAgentProject = useCallback(async () => { if (!agentProjectId) { toast.error("当前还没有可重命名的项目"); return; } await handleRenameAgentProject(agentProjectId, agentProjectTitle); }, [agentProjectId, agentProjectTitle, handleRenameAgentProject]); const handleCreateAgentProject = useCallback(async () => { if (isAgentProjectSaving) { return; } setIsAgentProjectSaving(true); try { if (agentProjectId) { await persistAgentProject("switch"); } const result = await agentProjectService.createProject({ title: buildDefaultAgentProjectTitle(), projectState: createEmptyAgentGenerationProjectPersistedState(), }); if (!result.success || !result.data) { if (isUnauthorizedApiError(result.error)) { setIsAuthenticated(false); openLoginDialog( result.error || "当前登录状态已失效,请重新登录后继续操作。" ); } toast.error(result.error || "创建生图项目失败"); return; } toast.success("已新建空白项目"); updateCanvasSearchParams({ mode: "agent", agentProjectId: result.data.id, }); } finally { setIsAgentProjectSaving(false); } }, [ agentProjectId, isAgentProjectSaving, openLoginDialog, persistAgentProject, updateCanvasSearchParams, ]); const handleSelectAgentProject = useCallback( async (nextProjectId: string) => { if (!nextProjectId || nextProjectId === agentProjectId) return; if (agentProjectId) { await persistAgentProject("switch"); } updateCanvasSearchParams({ mode: "agent", agentProjectId: nextProjectId, }); }, [agentProjectId, persistAgentProject, updateCanvasSearchParams] ); const handleDeleteAgentProject = useCallback( async (targetProjectId: string) => { const result = await agentProjectService.deleteProject(targetProjectId); if (!result.success) { if (isUnauthorizedApiError(result.error)) { setIsAuthenticated(false); openLoginDialog( result.error || "当前登录状态已失效,请重新登录后继续操作。" ); } toast.error(result.error || "删除生图项目失败"); return; } removeAgentProjectDraftSnapshot(targetProjectId); if (targetProjectId === agentProjectId) { const listResult = await agentProjectService.getProjects(); if (!listResult.success && isUnauthorizedApiError(listResult.error)) { setIsAuthenticated(false); openLoginDialog( listResult.error || "当前登录状态已失效,请重新登录后继续操作。" ); } const nextProject = listResult.success ? listResult.data?.find( (project) => project.id !== targetProjectId ) || null : null; if (nextProject) { updateCanvasSearchParams({ mode: "agent", agentProjectId: nextProject.id, }); } else { updateCanvasSearchParams({ mode: "agent", agentProjectId: null, }); setIsAgentProjectSheetOpen(true); } } toast.success("生图项目已删除"); }, [agentProjectId, openLoginDialog, updateCanvasSearchParams] ); const handleAgentResultPreviewUrlsChange = useCallback( (modeType: AgentGenerationResultModeType, urls: string[]) => { const normalizedUrls = normalizeResultCollectionUrls(urls); const currentProjectState = persistedAgentProjectStateRef.current || createEmptyAgentGenerationProjectPersistedState(); const currentCollections = currentProjectState.resultCollections || {}; const nextCollections: AgentGenerationProjectResultCollections = { ...currentCollections, [modeType]: normalizedUrls, }; if (!areResultCollectionsEqual(currentCollections, nextCollections)) { const nextProjectState: AgentGenerationProjectPersistedState = { ...currentProjectState, resultCollections: nextCollections, resultImageUrls: getAllResultUrlsFromCollections(nextCollections), }; persistedAgentProjectStateRef.current = nextProjectState; writeAgentProjectDraftSnapshot(nextProjectState); } setAgentProjectResultCollections((current) => areResultCollectionsEqual(current, nextCollections) ? current : nextCollections ); setAgentProjectThumbnail((currentThumbnail) => { const nextThumbnail = normalizedUrls[0] || getFirstResultUrlFromCollections(nextCollections); return currentThumbnail === nextThumbnail ? currentThumbnail : nextThumbnail; }); }, [writeAgentProjectDraftSnapshot] ); const handleAgentReplacementResultStateChange = useCallback( (nextState: AgentGenerationProjectReplacementResultState) => { const normalizedState = normalizeAgentGenerationProjectReplacementResultState(nextState); const currentProjectState = persistedAgentProjectStateRef.current || createEmptyAgentGenerationProjectPersistedState(); if ( !areReplacementResultStatesEqual( currentProjectState.replacementResultState, normalizedState ) ) { const nextProjectState: AgentGenerationProjectPersistedState = { ...currentProjectState, replacementResultState: normalizedState, }; persistedAgentProjectStateRef.current = nextProjectState; writeAgentProjectDraftSnapshot(nextProjectState); } setAgentProjectReplacementResultState((current) => areReplacementResultStatesEqual(current, normalizedState) ? current : normalizedState ); }, [writeAgentProjectDraftSnapshot] ); const handleAgentResultImageDelete = useCallback( (modeType: AgentGenerationResultModeType, imageUrl: string) => { const normalizedUrl = String(imageUrl || "").trim(); if (!/^https?:\/\//i.test(normalizedUrl)) { return; } const nextCollections: AgentGenerationProjectResultCollections = { ...agentProjectResultCollections, [modeType]: normalizeResultCollectionUrls( (agentProjectResultCollections[modeType] || []).filter( (url) => url !== normalizedUrl ) ), }; const nextDeletedCollections: AgentGenerationProjectResultCollections = { ...agentProjectDeletedResultCollections, [modeType]: normalizeResultCollectionUrls([ ...(agentProjectDeletedResultCollections[modeType] || []), normalizedUrl, ]), }; setAgentProjectResultCollections((current) => areResultCollectionsEqual(current, nextCollections) ? current : nextCollections ); setAgentProjectDeletedResultCollections((current) => areResultCollectionsEqual(current, nextDeletedCollections) ? current : nextDeletedCollections ); if (modeType === "detail_replacement" || modeType === "sku_replacement") { setAgentProjectReplacementResultState((current) => { const normalizedState = normalizeAgentGenerationProjectReplacementResultState(current); const currentModeState = normalizedState[modeType] || {}; const nextModeState = Object.fromEntries( Object.entries(currentModeState).filter( ([, record]) => record.url !== normalizedUrl ) ); const nextState: AgentGenerationProjectReplacementResultState = { ...normalizedState, [modeType]: nextModeState, }; if (areReplacementResultStatesEqual(normalizedState, nextState)) { return current; } const nextProjectState: AgentGenerationProjectPersistedState = { ...persistedAgentProjectStateRef.current, replacementResultState: nextState, }; persistedAgentProjectStateRef.current = nextProjectState; writeAgentProjectDraftSnapshot(nextProjectState); return nextState; }); } setAgentProjectThumbnail((current) => { const nextThumbnail = current === normalizedUrl ? getFirstResultUrlFromCollections(nextCollections) : current; return current === nextThumbnail ? current : nextThumbnail; }); }, [ agentProjectDeletedResultCollections, agentProjectResultCollections, writeAgentProjectDraftSnapshot, ] ); useEffect(() => { const normalizedBrand = workflowInputs.brand.trim().toLowerCase(); if (!normalizedBrand) { previousBrandForModelRef.current = ""; autoSelectedBrandModelBrandRef.current = ""; if (selectedBrandModel) { setSelectedBrandModel(null); } return; } const brandChanged = previousBrandForModelRef.current !== normalizedBrand; if (brandChanged) { previousBrandForModelRef.current = normalizedBrand; autoSelectedBrandModelBrandRef.current = ""; } if ( selectedBrandModel && selectedBrandModel.brandName.toLowerCase() !== normalizedBrand ) { setSelectedBrandModel(null); return; } if (selectedBrandModel) { return; } if (autoSelectedBrandModelBrandRef.current === normalizedBrand) { return; } const matchedEntry = resolvedBrandModelConfig.entries.find( (entry) => entry.brandName.trim().toLowerCase() === normalizedBrand ) || null; const defaultBrandModel = matchedEntry?.images[0] || null; if (!defaultBrandModel) { return; } autoSelectedBrandModelBrandRef.current = normalizedBrand; setSelectedBrandModel({ ...defaultBrandModel, brandName: matchedEntry?.brandName || workflowInputs.brand.trim(), }); }, [ resolvedBrandModelConfig.entries, selectedBrandModel, workflowInputs.brand, ]); const persistCanvasState = useCallback( async (reason: "autosave" | "delete" | "beforeunload") => { const canvasData = buildPersistedCanvasData(); const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState(); const result = await canvasService.saveCanvasState( id, canvasData, activeCanvasWorkspaceType, { baseCanvasRevision: canvasRevision, baseCanvasUpdatedAt: canvasUpdatedAt, } ); if (!result.success) { console.error(`[Canvas] ${reason} save failed:`, result.error); if (result.status === 409) { externalCanvasConflictRef.current = true; console.warn("[Canvas] stale save revision handled without user prompt", { reason, canvasUpdatedAt: result.canvasUpdatedAt, }); if (!externalCanvasConflictNotifiedRef.current) { externalCanvasConflictNotifiedRef.current = true; toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑"); } } } else if (result.data?.updatedAt) { externalCanvasConflictRef.current = false; externalCanvasConflictNotifiedRef.current = false; useCanvasStore .getState() .setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt)); } return result.success; }, [activeCanvasWorkspaceType, buildPersistedCanvasData, id] ); // 自动保存逻辑 useEffect(() => { if (isLoading || isAgentMode || isAgentOperationRefreshActive()) return; const saveTimer = setTimeout(async () => { setIsSaving(true); try { await persistCanvasState("autosave"); } catch (error) { console.error("Auto save failed:", error); } setIsSaving(false); }, 3000); // 3秒自动保存 return () => clearTimeout(saveTimer); }, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]); useEffect(() => { if (isLoading || isAgentOperationRefreshActive()) return; lastLocalCanvasMutationAtRef.current = Date.now(); }, [isLoading, isAgentOperationRefreshActive, nodes]); useEffect(() => { if (isLoading || isAgentMode) return; let cancelled = false; const refreshIfExternallyUpdated = async () => { if ( cancelled || document.visibilityState !== "visible" || isSaving || externalCanvasConflictRef.current || Date.now() - lastLocalCanvasMutationAtRef.current < 5000 ) { return; } try { const revisionResponse = await fetch(`/api/canvas/${id}/revision`, { cache: "no-store", }); const revisionPayload = await revisionResponse.json().catch(() => ({})); const remoteRevision = String( revisionPayload?.data?.updatedAt || "" ).trim(); const localRevision = String( useCanvasStore.getState().canvasRevision || "" ).trim(); if ( cancelled || !revisionResponse.ok || !remoteRevision || remoteRevision === localRevision ) { return; } const result = await canvasService.getProject(id); if (cancelled || !result.success || !result.data) return; useCanvasStore.getState().markAgentOperationRefresh(); hydrateCanvasProject(result.data); lastLocalCanvasMutationAtRef.current = Date.now(); toast.success("已同步 OpenClaw 的画布操作"); } catch (error) { console.error("[Canvas] external operation refresh failed:", error); } }; const intervalId = window.setInterval(() => { void refreshIfExternallyUpdated(); }, 4000); const handleFocus = () => void refreshIfExternallyUpdated(); window.addEventListener("focus", handleFocus); return () => { cancelled = true; window.clearInterval(intervalId); window.removeEventListener("focus", handleFocus); }; }, [hydrateCanvasProject, id, isAgentMode, isLoading, isSaving]); // 删除节点时立即保存(按节点ID差集检测),避免删除后快速刷新被回滚 useEffect(() => { if (isLoading || isAgentMode || isAgentOperationRefreshActive()) { previousNodeIdsRef.current = new Set(nodes.map((node) => node.id)); return; } const previousIds = previousNodeIdsRef.current; const currentIds = new Set(nodes.map((node) => node.id)); const removedIds: string[] = []; previousIds.forEach((nodeId) => { if (!currentIds.has(nodeId)) { removedIds.push(nodeId); } }); previousNodeIdsRef.current = currentIds; if (removedIds.length > 0) { const persistAfterDelete = async () => { setIsSaving(true); try { await persistCanvasState("delete"); } catch (error) { console.error("Delete immediate save failed:", error); } setIsSaving(false); }; void persistAfterDelete(); } }, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]); // 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率 useEffect(() => { if (isLoading || isAgentMode) return; const handleBeforeUnload = () => { try { if (isAgentOperationRefreshActive()) return; const canvasData = buildPersistedCanvasData(); const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState(); void fetch(`/api/canvas/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ canvasData, workspaceType: activeCanvasWorkspaceType, baseCanvasRevision: canvasRevision, baseCanvasUpdatedAt: canvasUpdatedAt, }), keepalive: true, }); } catch (error) { console.error("[Canvas] beforeunload save failed:", error); } }; window.addEventListener("beforeunload", handleBeforeUnload); window.addEventListener("pagehide", handleBeforeUnload); return () => { window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("pagehide", handleBeforeUnload); }; }, [ activeCanvasWorkspaceType, buildPersistedCanvasData, id, isAgentOperationRefreshActive, isAgentMode, isLoading, ]); // 智能命名与缩略图逻辑 useEffect(() => { if (isAgentMode) return; const hasDefaultTitle = projectTitle === defaultCanvasTitle || projectTitle === "新画布" || projectTitle === "新视频画布"; if (!hasDefaultTitle || nodes.length === 0) return; const imageNode = nodes.find( (n) => n.type === "image" && (n.data as any)?.src ); const imageSrc = (imageNode?.data as any)?.src; if (!imageSrc || typeof imageSrc !== "string") return; const autoTitleKey = `${id}:${imageSrc}`; if ( autoTitleAttemptedRef.current.has(autoTitleKey) || autoTitleInFlightRef.current === autoTitleKey ) { return; } const autoTitleAndThumbnail = async () => { autoTitleAttemptedRef.current.add(autoTitleKey); autoTitleInFlightRef.current = autoTitleKey; try { if (autoThumbnailSyncedRef.current !== autoTitleKey) { await canvasService.updateProject( id, { thumbnail: imageSrc }, activeCanvasWorkspaceType ); autoThumbnailSyncedRef.current = autoTitleKey; } const res = await fetch("/api/canvas/auto-title", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ imageUrl: imageSrc }), }); if (!res.ok) { throw new Error(`Auto title request failed: ${res.status}`); } const data = await res.json(); if (data.title) { setProjectTitle(data.title); await canvasService.updateProject( id, { title: data.title }, activeCanvasWorkspaceType ); toast.success(`${workspaceLabel}已自动命名为:${data.title}`); } } catch (error) { console.error("Auto title failed:", error); } finally { if (autoTitleInFlightRef.current === autoTitleKey) { autoTitleInFlightRef.current = null; } } }; // 使用防抖,避免频繁触发 const timer = setTimeout(autoTitleAndThumbnail, 5000); // 5秒后触发,给用户一点时间操作 return () => clearTimeout(timer); }, [ activeCanvasWorkspaceType, defaultCanvasTitle, id, isAgentMode, nodes, projectTitle, setProjectTitle, workspaceLabel, ]); // 保存画布 (手动触发保留作为备用,但UI上移除) const handleSave = useCallback(async () => { setIsSaving(true); const canvasData = buildPersistedCanvasData(); const result = await canvasService.saveCanvasState( id, canvasData, activeCanvasWorkspaceType ); if (result.success) { toast.success("保存成功"); } else { toast.error(result.error || "保存失败"); } setIsSaving(false); }, [activeCanvasWorkspaceType, buildPersistedCanvasData, id]); // 更新标题 const handleUpdateTitle = async () => { if (!editTitle.trim() || editTitle === projectTitle) { setIsEditingTitle(false); return; } const result = await canvasService.updateProject( id, { title: editTitle.trim() }, activeCanvasWorkspaceType ); if (result.success) { setProjectTitle(editTitle.trim()); } else { toast.error(result.error || "更新标题失败"); } setIsEditingTitle(false); }; const getVisibleCanvasOrigin = useCallback( () => ({ x: (-viewport.x + 120) / viewport.zoom, y: (-viewport.y + 120) / viewport.zoom, }), [viewport.x, viewport.y, viewport.zoom] ); const guessCanvasAspectRatio = useCallback( (nodeId: string | null | undefined) => { const targetNode = nodeId ? nodes.find((node) => node.id === nodeId) || null : null; if (!targetNode) return "auto"; const currentRatio = targetNode.size.width / Math.max(1, targetNode.size.height); let bestPreset = SIZE_PRESETS[0]; let bestDelta = Number.POSITIVE_INFINITY; SIZE_PRESETS.forEach((preset) => { const ratio = preset.width / preset.height; const delta = Math.abs(ratio - currentRatio); if (delta < bestDelta) { bestDelta = delta; bestPreset = preset; } }); return bestPreset?.value || "auto"; }, [nodes] ); const guessVideoRatio = useCallback( (nodeId: string | null | undefined) => { const targetNode = nodeId ? nodes.find((node) => node.id === nodeId) || null : null; if (!targetNode) return "16:9"; const currentRatio = targetNode.size.width / Math.max(1, targetNode.size.height); const ratios = [ { value: "16:9", width: 16, height: 9 }, { value: "4:3", width: 4, height: 3 }, { value: "1:1", width: 1, height: 1 }, { value: "3:4", width: 3, height: 4 }, { value: "9:16", width: 9, height: 16 }, { value: "21:9", width: 21, height: 9 }, ] as const; let best: (typeof ratios)[number] = ratios[0]; let bestDelta = Number.POSITIVE_INFINITY; ratios.forEach((ratio) => { const delta = Math.abs(ratio.width / ratio.height - currentRatio); if (delta < bestDelta) { bestDelta = delta; best = ratio; } }); return best.value; }, [nodes] ); const patchWorkflowNode = useCallback( ( nodeId: string, patch: Partial & { metadata?: Record; } ) => { const targetNode = useCanvasStore .getState() .nodes.find((node) => node.id === nodeId && node.type === "workflow"); if (!targetNode) return; const currentData = targetNode.data as WorkflowNodeData; updateNode(nodeId, { data: { ...currentData, ...patch, metadata: { ...(currentData.metadata || {}), ...(patch.metadata || {}), }, }, }); }, [updateNode] ); const createWorkflowOutputNode = useCallback( (params: { sourceNodeId: string; anchorNodeId?: string; edgeSourceNodeId?: string; media: WorkflowMediaReference; title: string; prompt: string; offsetX?: number; offsetY?: number; selectAfterCreate?: boolean; }) => { const anchorNodeId = params.anchorNodeId || params.sourceNodeId; const anchorNode = nodes.find((node) => node.id === anchorNodeId); if (!anchorNode) return null; const size = params.media.kind === "video" ? { width: 360, height: 240 } : { width: 320, height: 320 }; const nodeId = addNode({ type: params.media.kind, position: { x: anchorNode.position.x + anchorNode.size.width + 180 + (params.offsetX || 0), y: anchorNode.position.y + anchorNode.size.height / 2 - size.height / 2 + (params.offsetY || 0), }, size, data: params.media.kind === "video" ? { src: params.media.url, status: "completed", prompt: params.prompt, metadata: { sourceWorkflowNodeId: params.sourceNodeId, }, } : { src: params.media.url, status: "completed", title: params.title, prompt: params.prompt, metadata: { sourceWorkflowNodeId: params.sourceNodeId, }, }, connections: [], }); addEdge({ source: params.edgeSourceNodeId || params.sourceNodeId, target: nodeId, type: "default", }); if (params.selectAfterCreate ?? true) { selectNodes([nodeId]); } return nodeId; }, [addEdge, addNode, nodes, selectNodes] ); const syncDirectPreviewNodes = useCallback( ( sourceNodeId: string, media: WorkflowMediaReference, sourcePrompt: string ) => { getDirectDownstreamNodes(sourceNodeId, workflowGraph).forEach((node) => { const downstreamType = inferVideoWorkflowTemplateType(node); if (node.type !== "workflow" || downstreamType !== "preview") return; patchWorkflowNode(node.id, { status: "completed", summary: `已接收${media.kind === "video" ? "视频" : "图片"}预览,可继续检查结果后再保存。`, metadata: { previewMediaUrl: media.url, previewMediaKind: media.kind, previewMediaTitle: media.title || "", sourcePrompt, lastExecutedAt: new Date().toISOString(), }, }); }); }, [patchWorkflowNode, workflowGraph] ); const handleSyncAgentResultsToCanvas = useCallback(async () => { if ( !linkedAgentWorkflowNode || !linkedAgentWorkflowData || !agentProjectId ) { toast.error("当前 Agent 项目还没有关联到视频画布节点。"); return; } const composeText = (parts: unknown[]) => Array.from( new Set(parts.map((item) => String(item || "").trim()).filter(Boolean)) ).join("\n"); const resolveBatchOffset = (index: number) => ({ x: (index % 2) * (320 + 28), y: Math.floor(index / 2) * (320 + 28), }); const existingCanvasUrls = new Set( nodes .flatMap((node) => { if (node.type === "image" || node.type === "video") { return [ String((node.data as Record)?.src || "").trim(), ]; } return []; }) .filter((item) => /^https?:\/\//i.test(item)) ); const importedUrls = new Set(linkedAgentImportedResultUrls); const resultsToImport = flattenedAgentResultEntries.filter( (item) => !existingCanvasUrls.has(item.url) && !importedUrls.has(item.url) ); const nowIso = new Date().toISOString(); const sourceNodeId = linkedAgentWorkflowNode.id; const sourcePrompt = composeText([ String(linkedAgentWorkflowData.summary || "").trim(), String(agentWorkspaceState.input || "").trim(), ]) || "来自 Agent 工作台的结果回流。"; const previewResult = resultsToImport[0] || flattenedAgentResultEntries[0] || null ? { nodeId: sourceNodeId, kind: "image" as const, url: String( (resultsToImport[0] || flattenedAgentResultEntries[0])?.url || "" ).trim(), title: `${String((resultsToImport[0] || flattenedAgentResultEntries[0])?.label || "Agent 结果").trim()} ${Number((resultsToImport[0] || flattenedAgentResultEntries[0])?.index || 0) + 1}`, prompt: composeText([ sourcePrompt, String( (resultsToImport[0] || flattenedAgentResultEntries[0])?.label || "" ).trim(), ]), source: "workflow-result" as const, } : null; const resultGroupSummaries = ( [ "scene_generation", "detail_generation", "detail_replacement", "sku_replacement", ] as AgentGenerationResultModeType[] ).reduce< Array<{ modeType: AgentGenerationResultModeType; label: string; count: number; importedCount: number; }> >((groups, modeType) => { const urls = flattenedAgentResultEntries .filter((item) => item.modeType === modeType) .map((item) => item.url); if (urls.length === 0) return groups; const importedCount = urls.filter((url) => importedUrls.has(url)).length; groups.push({ modeType, label: AGENT_RESULT_MODE_LABELS[modeType], count: urls.length, importedCount, }); return groups; }, []); setIsSyncingAgentResultsToCanvas(true); patchWorkflowNode(sourceNodeId, { status: "generating", summary: resultsToImport.length > 0 ? "正在把 Agent 结果同步回视频画布..." : "当前 Agent 结果已全部回流,正在切回视频画布...", metadata: { lastExecutedAt: nowIso, }, }); try { const createdNodeIds: string[] = []; const importedMediaRecords: Array<{ nodeId: string; media: WorkflowMediaReference; modeType: AgentGenerationResultModeType; }> = []; resultsToImport.forEach((item, index) => { const offset = resolveBatchOffset(index); const promptText = composeText([sourcePrompt, item.label]); const media: WorkflowMediaReference = { nodeId: sourceNodeId, kind: "image", url: item.url, title: `${item.label} ${item.index + 1}`, prompt: promptText, source: "workflow-result", }; const createdNodeId = createWorkflowOutputNode({ sourceNodeId, media, title: media.title || item.label, prompt: promptText, offsetX: offset.x, offsetY: offset.y, selectAfterCreate: false, }); if (!createdNodeId) { return; } const createdNode = useCanvasStore .getState() .nodes.find((node) => node.id === createdNodeId); if (createdNode) { const currentImageData = (createdNode.data || {}) as Record< string, any >; updateNode(createdNodeId, { data: { src: String(currentImageData.src || media.url).trim(), status: currentImageData.status || "completed", title: media.title, prompt: promptText, metadata: { ...((currentImageData.metadata || {}) as Record), sourceWorkflowNodeId: sourceNodeId, sourceAgentProjectId: agentProjectId, sourceAgentResultModeType: item.modeType, importedFromAgentProject: true, }, }, }); } createdNodeIds.push(createdNodeId); importedMediaRecords.push({ nodeId: createdNodeId, media, modeType: item.modeType, }); }); const latestNodes = useCanvasStore.getState().nodes; const allImportedImageNodes = latestNodes.filter((node) => { if (node.type !== "image") return false; const metadata = ((node.data as Record)?.metadata || {}) as Record; return ( String(metadata.sourceWorkflowNodeId || "").trim() === sourceNodeId && metadata.importedFromAgentProject === true && String(metadata.sourceAgentProjectId || "").trim() === agentProjectId ); }); const directOutputNodes = getDirectDownstreamNodes( sourceNodeId, workflowGraph ).filter( (node) => node.type === "workflow" && ["preview", "local-save"].includes( inferVideoWorkflowTemplateType(node) || "" ) ); const syncedAgentAssets = allImportedImageNodes.map((node, index) => { const imageData = (node.data || {}) as Record; return { url: String(imageData.src || "").trim(), title: String(imageData.title || `Agent 结果 ${index + 1}`).trim(), }; }); directOutputNodes.forEach((node) => { const downstreamType = inferVideoWorkflowTemplateType(node); if (downstreamType === "preview") { if (previewResult) { patchWorkflowNode(node.id, { status: "completed", summary: `已同步 ${Math.max( allImportedImageNodes.length, importedMediaRecords.length )} 个 Agent 结果,可继续检查预览。`, metadata: { previewMediaUrl: previewResult.url, previewMediaKind: previewResult.kind, previewMediaTitle: previewResult.title || "", sourcePrompt, lastExecutedAt: nowIso, agentResultCount: allImportedImageNodes.length, }, }); } return; } if (downstreamType === "local-save") { patchWorkflowNode(node.id, { status: "completed", summary: `已接入 ${allImportedImageNodes.length} 个 Agent 结果,可直接下载打包。`, metadata: { sourcePrompt, pendingAssetCount: allImportedImageNodes.length, agentResultCount: allImportedImageNodes.length, agentResultGroups: resultGroupSummaries, syncedAgentAssets, lastExecutedAt: nowIso, }, }); } }); if (previewResult) { syncDirectPreviewNodes(sourceNodeId, previewResult, sourcePrompt); } const mergedImportedUrls = Array.from( new Set([ ...linkedAgentImportedResultUrls, ...resultsToImport.map((item) => item.url), ]) ); patchWorkflowNode(sourceNodeId, { status: "completed", summary: resultsToImport.length > 0 ? `已从 Agent 工作台回流 ${resultsToImport.length} 个结果到视频画布。` : "当前 Agent 结果已全部同步到视频画布。", metadata: { importedAgentResultUrls: mergedImportedUrls, importedAgentResultCount: mergedImportedUrls.length, pendingAgentResultCount: Math.max( flattenedAgentResultEntries.length - mergedImportedUrls.length, 0 ), agentResultAssets: resultGroupSummaries.map((item) => ({ modeType: item.modeType, label: item.label, assets: flattenedAgentResultEntries .filter((entry) => entry.modeType === item.modeType) .map((entry) => ({ url: entry.url, title: `${item.label} ${entry.index + 1}`, })), })), agentResultGroups: resultGroupSummaries.map((item) => ({ ...item, importedCount: flattenedAgentResultEntries .filter((entry) => entry.modeType === item.modeType) .filter((entry) => mergedImportedUrls.includes(entry.url)).length, })), lastAgentCanvasSyncAt: nowIso, lastExecutedAt: nowIso, resultMediaUrl: previewResult?.url || "", resultMediaKind: previewResult?.kind || "", resultMediaTitle: previewResult?.title || "", }, }); if (createdNodeIds.length > 0) { selectNodes(createdNodeIds); } else { selectNodes([sourceNodeId]); } updateCanvasSearchParams({ mode: null, }); toast.success( resultsToImport.length > 0 ? `已回流 ${resultsToImport.length} 个 Agent 结果` : "没有新的 Agent 结果需要回流" ); } catch (error) { const message = error instanceof Error ? error.message : "同步 Agent 结果到视频画布失败"; patchWorkflowNode(sourceNodeId, { status: "failed", summary: message, metadata: { lastExecutedAt: nowIso, lastError: message, }, }); toast.error(message); } finally { setIsSyncingAgentResultsToCanvas(false); } }, [ agentProjectId, agentWorkspaceState.input, createWorkflowOutputNode, flattenedAgentResultEntries, getDirectDownstreamNodes, linkedAgentImportedResultUrls, linkedAgentWorkflowData, linkedAgentWorkflowNode, nodes, patchWorkflowNode, selectNodes, syncDirectPreviewNodes, workflowGraph, updateCanvasSearchParams, updateNode, ]); const syncDirectStoryboardChartNodes = useCallback( ( sourceNodeId: string, shots: Array<{ title: string; description: string }>, sourcePrompt: string ) => { const tableColumns = [ { key: "shot", label: "镜头" }, { key: "title", label: "标题" }, { key: "description", label: "镜头说明" }, { key: "prompt", label: "生成提示" }, ]; const tableRows = shots.slice(0, 12).map((shot, index) => ({ shot: `#${index + 1}`, title: String(shot.title || `镜头 ${index + 1}`).trim(), description: String(shot.description || "").trim(), prompt: [sourcePrompt, shot.title, shot.description] .map((item) => String(item || "").trim()) .filter(Boolean) .filter((item, itemIndex, items) => items.indexOf(item) === itemIndex) .join("\n"), })); getDirectDownstreamNodes(sourceNodeId, workflowGraph).forEach((node) => { const downstreamType = inferVideoWorkflowTemplateType(node); if ( node.type !== "workflow" || downstreamType !== "storyboard-chart-node" ) { return; } patchWorkflowNode(node.id, { status: "completed", summary: `已同步 ${shots.length} 个镜头草案,可继续连接预览节点检查节奏。`, metadata: { storyboardShots: shots, batchShotCount: shots.length, tableColumns, tableRows, sourcePrompt, lastExecutedAt: new Date().toISOString(), storyboardSourceNodeId: sourceNodeId, }, }); }); }, [patchWorkflowNode, workflowGraph] ); const downloadWorkflowAssets = useCallback( (assets: WorkflowMediaReference[], folderLabel: string) => { if (typeof document === "undefined" || assets.length === 0) { return; } const now = new Date(); const folderName = `${folderLabel}-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`; const form = document.createElement("form"); form.method = "POST"; form.action = "/api/canvas/download-zip"; form.style.display = "none"; const payloadInput = document.createElement("input"); payloadInput.type = "hidden"; payloadInput.name = "payload"; payloadInput.value = JSON.stringify({ folderName, files: assets.map((asset, index) => ({ url: asset.url, name: asset.title || `${asset.kind}-result-${index + 1}`, })), }); form.appendChild(payloadInput); document.body.appendChild(form); form.submit(); form.remove(); }, [] ); const buildStoryboardShots = useCallback( ( promptText: string, mediaSummary: { imageCount: number; videoCount: number } ) => { const fragments = Array.from( new Set( promptText .split(/\n+/) .flatMap((line) => line.split(/[。!?!?;;]+/)) .map((item) => item.trim()) .filter(Boolean) ) ); const seeds = fragments.length > 0 ? fragments.slice(0, 4) : [ "围绕主体建立开场氛围", "推进主体动作与情绪", "强化关键细节", "用结尾镜头完成收束", ]; const suffix = mediaSummary.imageCount > 0 ? "保持参考图中的主体与视觉风格一致。" : mediaSummary.videoCount > 0 ? "承接参考视频中的节奏与镜头情绪。" : "保持整体镜头风格统一。"; return seeds.map((item, index) => ({ title: `镜头 ${index + 1}`, description: `${item}${item.endsWith("。") ? "" : "。"}${suffix}`, })); }, [] ); const buildStoryboardShotPrompt = useCallback( (basePrompt: string, shot: { title?: string; description?: string }) => Array.from( new Set( [basePrompt, String(shot.title || ""), String(shot.description || "")] .map((item) => item.trim()) .filter(Boolean) ) ).join("\n"), [] ); const buildWorkflowTextBlock = useCallback((parts: unknown[]) => { return Array.from( new Set(parts.map((item) => String(item || "").trim()).filter(Boolean)) ).join("\n"); }, []); const buildCharacterAssets = useCallback( (characters: WorkflowCharacterRecord[], sourcePrompt: string) => { return characters.slice(0, 6).map((item) => ({ name: item.name, description: buildWorkflowTextBlock([ item.description, item.look ? `外观: ${item.look}` : "", item.wardrobe ? `服装/道具: ${item.wardrobe}` : "", item.signatureAction ? `动作: ${item.signatureAction}` : "", item.traits.length > 0 ? `特质: ${item.traits.join("、")}` : "", ]), prompt: buildWorkflowTextBlock([ sourcePrompt, item.prompt, item.description, item.look, item.wardrobe, item.signatureAction, item.traits.join("、"), ]), })); }, [buildWorkflowTextBlock] ); const buildSceneAssets = useCallback( (scenes: WorkflowSceneRecord[], sourcePrompt: string) => { return scenes.slice(0, 6).map((item) => ({ name: item.name, description: buildWorkflowTextBlock([ item.description, item.mood ? `氛围: ${item.mood}` : "", item.lighting ? `布光: ${item.lighting}` : "", item.props.length > 0 ? `道具: ${item.props.join("、")}` : "", ]), prompt: buildWorkflowTextBlock([ sourcePrompt, item.prompt, item.description, item.mood, item.lighting, item.props.join("、"), ]), })); }, [buildWorkflowTextBlock] ); const buildCharacterDescriptionSummary = useCallback( (characters: WorkflowCharacterRecord[], sourcePrompt: string) => { if (characters.length === 0) return sourcePrompt.trim(); return characters .slice(0, 3) .map((item) => buildWorkflowTextBlock([ item.name, item.description, item.look, item.wardrobe, item.signatureAction, item.traits.join("、"), ]) ) .filter(Boolean) .join("\n\n"); }, [buildWorkflowTextBlock] ); const buildSceneDescriptionSummary = useCallback( (scenes: WorkflowSceneRecord[], sourcePrompt: string) => { if (scenes.length === 0) return sourcePrompt.trim(); return scenes .slice(0, 3) .map((item) => buildWorkflowTextBlock([ item.name, item.description, item.mood, item.lighting, item.props.join("、"), ]) ) .filter(Boolean) .join("\n\n"); }, [buildWorkflowTextBlock] ); const buildStoryboardChartRows = useCallback( ( shots: Array<{ title?: string; description?: string }>, sourcePrompt: string ) => { const normalizedShots = shots .map((shot, index) => ({ title: String(shot.title || `镜头 ${index + 1}`).trim(), description: String(shot.description || "").trim(), })) .filter((shot) => shot.title || shot.description) .slice(0, 12); return { columns: [ { key: "shot", label: "镜头" }, { key: "title", label: "标题" }, { key: "description", label: "镜头说明" }, { key: "prompt", label: "生成提示" }, ], rows: normalizedShots.map((shot, index) => ({ shot: `#${index + 1}`, title: shot.title, description: shot.description, prompt: buildStoryboardShotPrompt(sourcePrompt, shot), })), }; }, [buildStoryboardShotPrompt] ); const buildWorkflowTableState = useCallback( (params: { promptText: string; shots: Array<{ title?: string; description?: string }>; structuredData: typeof selectedWorkflowStructuredData; }) => { if (params.shots.length > 0) { const chart = buildStoryboardChartRows(params.shots, params.promptText); return { summary: `已整理 ${chart.rows.length} 行镜头表,可继续连接分镜节点或生成节点。`, columns: chart.columns, rows: chart.rows, }; } if ( params.structuredData.characterAssets.length > 0 || params.structuredData.characters.length > 0 ) { const characterRows = ( params.structuredData.characterAssets.length > 0 ? params.structuredData.characterAssets : params.structuredData.characters ) .slice(0, 8) .map((item, index) => ({ name: item.name || `角色 ${index + 1}`, description: item.description || "", prompt: item.prompt || "", })); return { summary: `已整理 ${characterRows.length} 行角色资产表,可继续固化角色或生成角色图。`, columns: [ { key: "name", label: "角色" }, { key: "description", label: "描述" }, { key: "prompt", label: "提示词" }, ], rows: characterRows, }; } if ( params.structuredData.sceneAssets.length > 0 || params.structuredData.scenes.length > 0 ) { const sceneRows = ( params.structuredData.sceneAssets.length > 0 ? params.structuredData.sceneAssets : params.structuredData.scenes ) .slice(0, 8) .map((item, index) => ({ name: item.name || `场景 ${index + 1}`, description: item.description || "", prompt: item.prompt || "", })); return { summary: `已整理 ${sceneRows.length} 行场景资产表,可继续固化场景或生成场景图。`, columns: [ { key: "name", label: "场景" }, { key: "description", label: "描述" }, { key: "prompt", label: "提示词" }, ], rows: sceneRows, }; } const promptRows = params.promptText .split(/\n+/) .map((item) => item.trim()) .filter(Boolean) .slice(0, 8) .map((item, index) => ({ section: `段落 ${index + 1}`, content: item, })); return { summary: promptRows.length > 0 ? `已整理 ${promptRows.length} 行脚本表,可继续转成分镜节点。` : "上游还没有可整理成表格的内容。", columns: [ { key: "section", label: "段落" }, { key: "content", label: "内容" }, ], rows: promptRows, }; }, [buildStoryboardChartRows, selectedWorkflowStructuredData] ); const buildWorkflowCompareItems = useCallback( (media: WorkflowMediaReference[]) => { return media .filter((item) => /^https?:\/\//i.test(item.url)) .slice(0, 4) .map((item, index) => ({ label: index === 0 ? "基准" : item.source === "workflow-result" ? `结果 ${index + 1}` : `对照 ${index + 1}`, kind: item.kind, url: item.url, title: String( item.title || item.prompt || `${item.kind === "video" ? "视频" : "图片"} ${index + 1}` ).trim(), })); }, [] ); const buildWorkflowDefaultCropRect = useCallback((promptText: string) => { const normalizedPrompt = String(promptText || "").toLowerCase(); const isPortraitFocus = /人物|角色|主体|局部|特写|脸|肖像|portrait|close/.test(normalizedPrompt); const isObjectFocus = /产品|物体|商品|道具|item|product/.test( normalizedPrompt ); const width = isPortraitFocus ? 0.5 : isObjectFocus ? 0.56 : 0.64; const height = isPortraitFocus ? 0.72 : isObjectFocus ? 0.58 : 0.64; const baseX = /左|left/.test(normalizedPrompt) ? 0.08 : /右|right/.test(normalizedPrompt) ? 1 - width - 0.08 : (1 - width) / 2; const baseY = /上|top|天空/.test(normalizedPrompt) ? 0.08 : /下|bottom|地面/.test(normalizedPrompt) ? 1 - height - 0.08 : (1 - height) / 2; return { x: Number(Math.max(0, Math.min(baseX, 1 - width)).toFixed(4)), y: Number(Math.max(0, Math.min(baseY, 1 - height)).toFixed(4)), width: Number(width.toFixed(4)), height: Number(height.toFixed(4)), }; }, []); const inferWorkflowExtendScales = useCallback((promptText: string) => { const normalizedPrompt = String(promptText || "").toLowerCase(); const match = normalizedPrompt.match(/([1-4](?:\.\d+)?)\s*倍/); let xScale = match ? Number(match[1]) : 1.45; let yScale = match ? Number(match[1]) : 1.45; if ( /左右|横向|宽屏|全景|横幅|拓宽|扩宽|panorama|wide/.test(normalizedPrompt) ) { xScale = Math.max(xScale, 1.7); } if ( /上下|纵向|天空|地面|抬高|向上|向下|tall|vertical/.test(normalizedPrompt) ) { yScale = Math.max(yScale, 1.7); } if (/补边|延展|继续|stitch|outpaint|扩展/.test(normalizedPrompt)) { xScale = Math.max(xScale, 1.6); yScale = Math.max(yScale, 1.6); } return { xScale: Number(Math.min(Math.max(xScale, 1.1), 4).toFixed(2)), yScale: Number(Math.min(Math.max(yScale, 1.1), 4).toFixed(2)), }; }, []); const buildWorkflowCameraPreset = useCallback((promptText: string) => { const normalizedPrompt = String(promptText || "").toLowerCase(); let horizontalAngle = 0; let verticalAngle = 0; let zoom = 5; const notes: string[] = []; if (/左侧|左前|left/.test(normalizedPrompt)) { horizontalAngle = 330; notes.push("偏左机位"); } else if (/右侧|右前|right/.test(normalizedPrompt)) { horizontalAngle = 30; notes.push("偏右机位"); } else if (/背面|后方|rear|back/.test(normalizedPrompt)) { horizontalAngle = 180; notes.push("背向机位"); } else { notes.push("正面机位"); } if (/俯拍|鸟瞰|top|overhead/.test(normalizedPrompt)) { verticalAngle = 38; notes.push("俯拍"); } else if (/仰拍|低机位|low angle/.test(normalizedPrompt)) { verticalAngle = -18; notes.push("仰拍"); } else if (/平视|eye level/.test(normalizedPrompt)) { verticalAngle = 0; notes.push("平视"); } else { verticalAngle = 8; notes.push("轻微抬升"); } if (/特写|close[- ]?up|近景/.test(normalizedPrompt)) { zoom = 7.5; notes.push("特写焦距"); } else if (/远景|全景|wide shot|long shot/.test(normalizedPrompt)) { zoom = 3.5; notes.push("远景焦距"); } else if (/中景|medium shot/.test(normalizedPrompt)) { zoom = 5; notes.push("中景焦距"); } else { zoom = 5.8; notes.push("标准电影镜头"); } return { horizontalAngle, verticalAngle, zoom, notes, }; }, []); const buildWorkflowMovementDirective = useCallback((promptText: string) => { const normalizedPrompt = String(promptText || "").toLowerCase(); const cameraMove = /推进|推近|push|zoom in/.test(normalizedPrompt) ? "镜头缓慢推进,强调主体情绪和细节。" : /拉远|拉开|pull|zoom out/.test(normalizedPrompt) ? "镜头缓慢拉远,交代环境关系和空间层次。" : /环绕|orbit|绕拍/.test(normalizedPrompt) ? "镜头围绕主体轻微环绕,增强立体感与叙事张力。" : /摇镜|pan|横移/.test(normalizedPrompt) ? "镜头横向摇移,顺着主体视线或动作方向展开。" : "镜头以电影化的平滑运动推进,保持主体稳定、节奏自然。"; const motionCue = /奔跑|run|冲刺/.test(normalizedPrompt) ? "主体动作以奔跑推进,节奏偏快。" : /转身|turn/.test(normalizedPrompt) ? "主体动作强调转身和视线转换。" : /抬手|挥手|gesture/.test(normalizedPrompt) ? "主体动作聚焦手部与上半身姿态。" : "主体动作自然、连贯,避免突然跳帧。"; const rhythm = /慢|slow|舒缓/.test(normalizedPrompt) ? "节奏舒缓,过渡柔和。" : /快|fast|激烈/.test(normalizedPrompt) ? "节奏偏快,强调冲击力。" : "节奏中等,适合叙事性镜头。"; return { cameraMove, motionCue, rhythm, cameraFixed: /固定镜头|lock|static/.test(normalizedPrompt), }; }, []); const buildWorkflowMusicPlan = useCallback( ( promptText: string, shots: Array<{ title?: string; description?: string }> ) => { const normalizedPrompt = String(promptText || "").toLowerCase(); const mood = /悬疑|紧张|mystery|suspense/.test(normalizedPrompt) ? "悬疑推进" : /浪漫|温柔|romantic|warm/.test(normalizedPrompt) ? "温柔抒情" : /史诗|宏大|epic/.test(normalizedPrompt) ? "史诗展开" : /治愈|轻松|healing|light/.test(normalizedPrompt) ? "轻盈治愈" : "电影感氛围"; const instrumentation = /电子|synth|edm/.test(normalizedPrompt) ? "电子氛围 + 节奏脉冲" : /钢琴|piano/.test(normalizedPrompt) ? "钢琴主旋律 + 轻弦乐" : /弦乐|string/.test(normalizedPrompt) ? "弦乐铺底 + 低频鼓点" : "氛围铺底 + 轻打击乐"; const cueSeeds = shots.length > 0 ? shots.slice(0, 4).map((shot, index) => ({ cue: `Cue ${index + 1}`, moment: String(shot.title || `镜头 ${index + 1}`).trim(), mood, instrumentation, })) : [ { cue: "Cue 1", moment: "开场建立", mood, instrumentation }, { cue: "Cue 2", moment: "主体推进", mood, instrumentation }, { cue: "Cue 3", moment: "细节强化", mood, instrumentation }, { cue: "Cue 4", moment: "结尾收束", mood, instrumentation }, ]; return { summary: `已整理 ${cueSeeds.length} 段配乐 cue sheet,可继续交给外部音频工具或作曲流程。`, highlights: [ `整体情绪建议:${mood}`, `主乐器建议:${instrumentation}`, "建议保持段落递进,避免配乐情绪和镜头节奏脱节。", ], columns: [ { key: "cue", label: "Cue" }, { key: "moment", label: "对应镜头" }, { key: "mood", label: "情绪" }, { key: "instrumentation", label: "配器" }, ], rows: cueSeeds, }; }, [] ); const buildWorkflowHandoffPlan = useCallback( ( target: "rh-app" | "rh-comfy", params: { promptText: string; shots: Array<{ title?: string; description?: string }>; structuredData: typeof selectedWorkflowStructuredData; media: WorkflowMediaReference[]; } ) => { const targetLabel = target === "rh-comfy" ? "Comfy" : "RH 应用"; const starterSteps = target === "rh-comfy" ? [ "载入输入图片与脚本提示", "整理正负提示词与镜头控制", "连接生图/生视频节点并输出结果", ] : [ "选择对应 RH 工作台模板", "填入脚本、镜头和角色资产", "执行生成并回收结果", ]; const stepRows = starterSteps.map((step, index) => ({ step: `${index + 1}`, action: step, input: index === 0 ? params.media.length > 0 ? `已挂接 ${params.media.length} 个媒体输入` : "以上游脚本文本为主" : index === 1 ? `${Math.max(params.shots.length, 1)} 个镜头 / ${params.structuredData.characters.length} 个角色线索` : "输出到预览 / 保存节点", })); return { summary: `已整理一份可投递到${targetLabel}的工作流包。`, highlights: [ `目标平台:${targetLabel}`, `建议先带入 ${Math.max(params.shots.length, 1)} 个镜头和 ${params.media.length} 个素材输入。`, "这份包更偏流程交付物,方便后续继续在外部工作流里执行。", ], deliverables: [ `${targetLabel} 工作流步骤表`, "关键提示词与镜头说明", "上游素材与参考图清单", ], columns: [ { key: "step", label: "步骤" }, { key: "action", label: "操作" }, { key: "input", label: "输入/说明" }, ], rows: stepRows, }; }, [selectedWorkflowStructuredData] ); const buildWorkflowCanvasBoardState = useCallback( (media: WorkflowMediaReference[], promptText: string) => { const boardItems = buildWorkflowCompareItems(media).slice(0, 6); return { summary: boardItems.length > 0 ? `已整理 ${boardItems.length} 个素材到画板节点,可继续做对比、分支和预览。` : "上游暂时没有可整理进画板的素材。", compareItems: boardItems, columns: [ { key: "slot", label: "槽位" }, { key: "title", label: "素材" }, { key: "kind", label: "类型" }, ], rows: boardItems.map((item, index) => ({ slot: `Board ${index + 1}`, title: item.title, kind: item.kind === "video" ? "视频/封面" : "图片", })), highlights: [ promptText.trim() ? `当前画板主题:${promptText.trim().slice(0, 72)}${promptText.trim().length > 72 ? "..." : ""}` : "当前画板主题还未明确,可继续补充一句创作目标。", "建议把画板作为挑图、对比和后续分支创作的中转节点。", ], }; }, [buildWorkflowCompareItems] ); const buildWorkflowDoodleInstructions = useCallback((promptText: string) => { const normalizedPrompt = String(promptText || "").trim(); const rows = [ { area: /左|left/.test(normalizedPrompt.toLowerCase()) ? "左侧区域" : "主体区域", intent: /替换|replace/.test(normalizedPrompt) ? "替换元素" : "补充结构", note: normalizedPrompt || "用涂鸦勾出主体、边缘和希望补充的视觉结构。", }, { area: /背景|environment|scene/.test(normalizedPrompt.toLowerCase()) ? "背景区域" : "外围区域", intent: /光|氛围|mood/.test(normalizedPrompt.toLowerCase()) ? "调整氛围" : "补充背景", note: "建议用大笔触勾出背景方向、透视和光影分区。", }, { area: "细节区域", intent: "强调重点", note: "对关键细节、轮廓或遮罩边缘做重点标注,便于后续局部重绘。", }, ]; return { summary: "已整理一版涂鸦/遮罩指令,可继续连接局部重绘或外部绘制流程。", highlights: [ "先用粗笔触圈出主体,再补背景方向和细节区。", "涂鸦节点更适合作为局部重绘、补画或外部画板的中间层。", ], columns: [ { key: "area", label: "区域" }, { key: "intent", label: "意图" }, { key: "note", label: "操作说明" }, ], rows, }; }, []); const resolvePreferredCharacterPrompt = useCallback( ( sourcePrompt: string, structuredData: typeof selectedWorkflowStructuredData ) => { return buildWorkflowTextBlock([ sourcePrompt, structuredData.characterAssets[0]?.prompt, structuredData.characters[0]?.prompt, ]); }, [buildWorkflowTextBlock] ); const resolvePreferredScenePrompt = useCallback( ( sourcePrompt: string, structuredData: typeof selectedWorkflowStructuredData ) => { return buildWorkflowTextBlock([ sourcePrompt, structuredData.sceneAssets[0]?.prompt, structuredData.scenes[0]?.prompt, ]); }, [buildWorkflowTextBlock] ); const buildWorkflowAgentProjectTitle = useCallback( (params: { promptText: string; nodeTitle?: string | null; structuredData: typeof selectedWorkflowStructuredData; }) => { const rawNodeTitle = String(params.nodeTitle || "").trim(); const normalizedNodeTitle = rawNodeTitle && !["Agent", "自定义代理", "custom-agent"].includes(rawNodeTitle) ? rawNodeTitle : ""; const characterName = String( params.structuredData.characterAssets[0]?.name || params.structuredData.characters[0]?.name || "" ).trim(); const sceneName = String( params.structuredData.sceneAssets[0]?.name || params.structuredData.scenes[0]?.name || "" ).trim(); const promptSeed = params.promptText .split(/\n+/) .map((item) => item.trim()) .find(Boolean) || "视频画布任务"; const baseTitle = normalizedNodeTitle || [characterName, sceneName].filter(Boolean).join(" / ") || promptSeed; const compactTitle = baseTitle.replace(/\s+/g, " ").trim().slice(0, 40) || "视频画布任务"; return compactTitle.includes("Agent") ? compactTitle : `${compactTitle} Agent`; }, [] ); const buildWorkflowAgentProjectDescription = useCallback( (params: { promptText: string; summary?: string | null; structuredData: typeof selectedWorkflowStructuredData; }) => { return buildWorkflowTextBlock([ params.summary, params.structuredData.deliverables[0], params.structuredData.recommendedNextSteps[0], params.promptText.slice(0, 140), ]).slice(0, 220); }, [buildWorkflowTextBlock] ); const buildWorkflowAgentWorkspaceInput = useCallback( (params: { promptText: string; storyboardShots: Array<{ title?: string; description?: string }>; structuredData: typeof selectedWorkflowStructuredData; }) => { const toLines = (items: string[], limit = 6) => Array.from( new Set( items.map((item) => String(item || "").trim()).filter(Boolean) ) ).slice(0, limit); const buildSection = (label: string, items: string[], limit = 6) => { const lines = toLines(items, limit); if (lines.length === 0) return ""; return `${label}:\n${lines.map((item, index) => `${index + 1}. ${item}`).join("\n")}`; }; const buildNamedSection = ( label: string, items: Array>, limit = 4 ) => { const lines = items .slice(0, limit) .map((item, index) => { const name = String( item.name || item.title || `条目 ${index + 1}` ).trim(); const detail = buildWorkflowTextBlock([ item.description, item.prompt, item.look, item.wardrobe, item.signatureAction, item.mood, item.lighting, Array.isArray(item.traits) ? item.traits.join("、") : "", Array.isArray(item.props) ? item.props.join("、") : "", ]).replace(/\n+/g, " / "); return detail ? `${name}: ${detail}` : name; }) .filter(Boolean); return buildSection(label, lines, limit); }; const shotLines = params.storyboardShots .slice(0, 6) .map((item, index) => buildWorkflowTextBlock([ String(item.title || `镜头 ${index + 1}`).trim(), String(item.description || "").trim(), ]).replace(/\n+/g, " / ") ); return [ buildSection( "任务目标", [params.promptText || "请基于当前视频画布上下文继续推进创作任务。"], 3 ), buildSection("分镜草案", shotLines, 6), buildNamedSection("角色线索", [ ...params.structuredData.characters, ...params.structuredData.characterAssets, ]), buildNamedSection("场景线索", [ ...params.structuredData.scenes, ...params.structuredData.sceneAssets, ]), buildSection("代理计划", params.structuredData.agentPlan, 6), buildSection("交付物", params.structuredData.deliverables, 6), buildSection( "推荐后续动作", [ ...params.structuredData.recommendedNextSteps, ...params.structuredData.recommendedNodes, ], 6 ), ] .filter(Boolean) .join("\n\n"); }, [buildWorkflowTextBlock] ); const buildWorkflowAgentAttachments = useCallback( ( media: WorkflowMediaReference[] ): AgentGenerationProjectWorkspaceAttachment[] => { const seen = new Set(); return media .filter((item) => item.kind === "image") .filter((item) => { const url = String(item.url || "").trim(); if (!url || seen.has(url)) return false; seen.add(url); return true; }) .slice(0, 8) .map((item, index) => ({ url: item.url, name: item.title || `参考图 ${index + 1}`, contentType: "image/png", assetId: null, })); }, [] ); const mergeWorkflowAgentAttachments = useCallback( ( existing: Array< AgentGenerationProjectWorkspaceAttachment | null | undefined >, incoming: AgentGenerationProjectWorkspaceAttachment[] ) => { const seen = new Set(); return [...incoming, ...existing] .map((item) => { if (!item) return null; const url = String(item.url || "").trim(); if (!url || seen.has(url)) return null; seen.add(url); return { url, name: String(item.name || "").trim() || null, contentType: String(item.contentType || "").trim() || null, assetId: String(item.assetId || "").trim() || null, }; }) .filter(Boolean) .slice(0, 16) as AgentGenerationProjectWorkspaceAttachment[]; }, [] ); const mergeWorkflowAgentInput = useCallback( (existingInput: string, syncInput: string) => { const contextBlock = `${WORKFLOW_AGENT_CONTEXT_START}\n${syncInput.trim()}\n${WORKFLOW_AGENT_CONTEXT_END}`.trim(); const normalizedExisting = String(existingInput || "").trim(); if (!normalizedExisting) { return contextBlock; } const startIndex = normalizedExisting.indexOf( WORKFLOW_AGENT_CONTEXT_START ); const endIndex = normalizedExisting.indexOf(WORKFLOW_AGENT_CONTEXT_END); if (startIndex >= 0 && endIndex > startIndex) { const before = normalizedExisting.slice(0, startIndex).trim(); const after = normalizedExisting .slice(endIndex + WORKFLOW_AGENT_CONTEXT_END.length) .trim(); return [before, contextBlock, after].filter(Boolean).join("\n\n"); } return [contextBlock, normalizedExisting].filter(Boolean).join("\n\n"); }, [] ); const handleOpenSelectedWorkflowInAgent = useCallback(async () => { if ( !selectedVideoWorkflowNode || selectedVideoWorkflowType !== "custom-agent" ) { return; } const workflowData = selectedVideoWorkflowData; if (!workflowData) { return; } const nodeId = selectedVideoWorkflowNode.id; const taskKey = `${nodeId}:agent`; const nowIso = new Date().toISOString(); const storyboardShots = selectedWorkflowStoryboardShots.slice(0, 8); const workflowPrompt = selectedWorkflowPrompt.trim() || "请基于当前视频画布中的脚本、分镜、角色和场景资产继续推进创作。"; const syncInput = buildWorkflowAgentWorkspaceInput({ promptText: workflowPrompt, storyboardShots, structuredData: selectedWorkflowStructuredData, }); const incomingAttachments = buildWorkflowAgentAttachments( selectedWorkflowMedia ); const firstIncomingAttachmentNodeId = incomingAttachments[0] ? selectedWorkflowMedia.find( (item) => item.kind === "image" && item.url === incomingAttachments[0]?.url )?.nodeId || null : null; const inferredAspectRatio = incomingAttachments.length > 0 ? guessCanvasAspectRatio(firstIncomingAttachmentNodeId) : selectedWorkflowInputs.videoCount > 0 ? guessVideoRatio( selectedWorkflowMedia.find((item) => item.kind === "video") ?.nodeId || nodeId ) : "auto"; const nextProjectTitle = buildWorkflowAgentProjectTitle({ promptText: workflowPrompt, nodeTitle: workflowData.title, structuredData: selectedWorkflowStructuredData, }); const nextProjectDescription = buildWorkflowAgentProjectDescription({ promptText: workflowPrompt, summary: workflowData.summary, structuredData: selectedWorkflowStructuredData, }); const fallbackWorkflowState = { ...createEmptyAgentGenerationProjectPersistedState().workflowState, ...buildCurrentWorkflowPersistedState(), currentStage: "mode" as const, }; setActiveWorkflowExecutionTaskKey(taskKey); patchWorkflowNode(nodeId, { status: "generating", summary: selectedWorkflowLinkedAgentProject.id ? "正在同步到已关联的 Agent 项目..." : "正在创建 Agent 项目并同步当前上下文...", metadata: { lastExecutedAt: nowIso, }, }); try { let existingProject: AgentGenerationProject | null = null; if (selectedWorkflowLinkedAgentProject.id) { const linkedResult = await agentProjectService.getProject( selectedWorkflowLinkedAgentProject.id ); if (linkedResult.success && linkedResult.data) { existingProject = linkedResult.data; } else if (isUnauthorizedApiError(linkedResult.error)) { setIsAuthenticated(false); openLoginDialog( linkedResult.error || "当前登录状态已失效,请重新登录后继续操作。" ); throw new Error( linkedResult.error || "当前登录状态已失效,请重新登录后继续操作。" ); } } const existingPersistedState = existingProject ? normalizeAgentGenerationProjectPersistedState( existingProject.projectState ) : createEmptyAgentGenerationProjectPersistedState(); const defaultWorkspaceState = createEmptyAgentGenerationProjectWorkspaceState(); const nextWorkspaceState: AgentGenerationProjectWorkspaceState = { ...defaultWorkspaceState, ...(existingPersistedState.workspaceState || {}), input: mergeWorkflowAgentInput( String(existingPersistedState.workspaceState?.input || ""), syncInput ), attachments: mergeWorkflowAgentAttachments( existingPersistedState.workspaceState?.attachments || [], incomingAttachments ), selectedModelId: String( existingPersistedState.workspaceState?.selectedModelId || "" ).trim() || "auto", imageParams: { ...defaultWorkspaceState.imageParams, ...(existingPersistedState.workspaceState?.imageParams || {}), aspectRatio: String( existingPersistedState.workspaceState?.imageParams?.aspectRatio || "" ).trim() || inferredAspectRatio || "auto", }, }; const nextPersistedState: AgentGenerationProjectPersistedState = { ...existingPersistedState, workflowState: existingProject ? { ...(existingPersistedState.workflowState || fallbackWorkflowState), currentStage: "mode", } : fallbackWorkflowState, workspaceState: nextWorkspaceState, resultCollections: existingPersistedState.resultCollections || {}, deletedResultCollections: existingPersistedState.deletedResultCollections || {}, resultImageUrls: existingPersistedState.resultImageUrls || [], replacementResultState: existingPersistedState.replacementResultState || {}, }; const payload = { title: existingProject?.title || nextProjectTitle, description: existingProject?.description || nextProjectDescription || null, thumbnail: incomingAttachments[0]?.url || existingProject?.thumbnail || null, projectState: nextPersistedState, }; const result = existingProject ? await agentProjectService.updateProject(existingProject.id, payload) : await agentProjectService.createProject(payload); if (!result.success || !result.data) { if (isUnauthorizedApiError(result.error)) { setIsAuthenticated(false); openLoginDialog( result.error || "当前登录状态已失效,请重新登录后继续操作。" ); throw new Error( result.error || "当前登录状态已失效,请重新登录后继续操作。" ); } throw new Error(result.error || "同步 Agent 项目失败"); } patchWorkflowNode(nodeId, { status: "completed", summary: existingProject ? "已同步到 Agent 工作台,可继续完善提示词、参考图和执行步骤。" : "已创建 Agent 项目并同步当前上下文,可继续在 Agent 工作台推进。", metadata: { linkedAgentProjectId: result.data.id, linkedAgentProjectTitle: result.data.title || nextProjectTitle, linkedAgentProjectUpdatedAt: String(result.data.updatedAt || nowIso), lastAgentWorkspaceSyncAt: nowIso, lastExecutedAt: nowIso, }, }); setIsPanelOpen(true); setPanelMode("chat"); updateCanvasSearchParams({ mode: "agent", agentProjectId: result.data.id, }); toast.success( existingProject ? "已同步到 Agent 工作台" : "已创建 Agent 项目" ); } catch (error) { const message = error instanceof Error ? error.message : "打开 Agent 工作台失败"; patchWorkflowNode(nodeId, { status: "failed", summary: message, metadata: { lastExecutedAt: nowIso, lastError: message, }, }); toast.error(message); } finally { setActiveWorkflowExecutionTaskKey((current) => current === taskKey ? null : current ); } }, [ buildCurrentWorkflowPersistedState, buildWorkflowAgentAttachments, buildWorkflowAgentProjectDescription, buildWorkflowAgentProjectTitle, buildWorkflowAgentWorkspaceInput, guessCanvasAspectRatio, guessVideoRatio, mergeWorkflowAgentAttachments, mergeWorkflowAgentInput, openLoginDialog, patchWorkflowNode, selectedVideoWorkflowData, selectedVideoWorkflowNode, selectedVideoWorkflowType, selectedWorkflowInputs.videoCount, selectedWorkflowLinkedAgentProject.id, selectedWorkflowMedia, selectedWorkflowPrompt, selectedWorkflowStoryboardShots, selectedWorkflowStructuredData, updateCanvasSearchParams, ]); const getWorkflowBatchOutputOffset = useCallback( (kind: WorkflowMediaReference["kind"], index: number) => { const size = kind === "video" ? { width: 360, height: 240 } : { width: 320, height: 320 }; const columns = 2; return { x: (index % columns) * (size.width + 28), y: Math.floor(index / columns) * (size.height + 28), }; }, [] ); // 添加文本节点 const getVideoWorkflowPlacement = useCallback( (size: { width: number; height: number }) => { if (!isVideoMode || selectedNodeIds.length !== 1) { const origin = getVisibleCanvasOrigin(); return { x: origin.x + Math.random() * 120, y: origin.y + Math.random() * 120, }; } const sourceNode = nodes.find((node) => node.id === selectedNodeIds[0]); if (!sourceNode) { const origin = getVisibleCanvasOrigin(); return { x: origin.x + Math.random() * 120, y: origin.y + Math.random() * 120, }; } return { x: sourceNode.position.x + sourceNode.size.width + 180, y: sourceNode.position.y + sourceNode.size.height / 2 - size.height / 2, }; }, [getVisibleCanvasOrigin, isVideoMode, nodes, selectedNodeIds] ); const createCanvasNode = useCallback( ( nodeConfig: Parameters[0], options?: { connectFromSelection?: boolean; connectFromNodeId?: string | null; selectNewNode?: boolean; } ) => { const connectFromSelection = options?.connectFromSelection ?? true; const selectNewNode = options?.selectNewNode ?? true; const explicitSourceNodeId = options?.connectFromNodeId || null; const nodeId = addNode(nodeConfig); const sourceNodeId = connectFromSelection && isVideoMode ? explicitSourceNodeId || (selectedNodeIds.length === 1 ? selectedNodeIds[0] || null : null) : null; if (sourceNodeId && sourceNodeId !== nodeId) { addEdge({ source: sourceNodeId, target: nodeId, type: "default", }); } if (selectNewNode) { selectNodes([nodeId]); } return nodeId; }, [addEdge, addNode, isVideoMode, selectNodes, selectedNodeIds] ); const handleAddWorkflowNode = useCallback( ( templateType: VideoWorkflowTemplateType, options?: { position?: Position; connectFromSelection?: boolean; connectFromNodeId?: string | null; selectNewNode?: boolean; } ) => { const definition = getVideoWorkflowNodeDefinition(templateType); if (!definition) return; const placement = options?.position || getVideoWorkflowPlacement(definition.size); createCanvasNode(buildVideoWorkflowCanvasNode(templateType, placement), { connectFromSelection: options?.connectFromSelection ?? true, connectFromNodeId: options?.connectFromNodeId ?? null, selectNewNode: options?.selectNewNode ?? true, }); }, [createCanvasNode, getVideoWorkflowPlacement] ); const handleOpenVideoCanvasInsertMenu = useCallback( (payload: VideoCanvasInsertMenuState) => { if (!isVideoMode) return; setVideoCanvasInsertMenu(payload); }, [isVideoMode] ); const handleInsertWorkflowNodeFromMenu = useCallback( (templateType: VideoWorkflowTemplateType) => { if (!videoCanvasInsertMenu) return; const definition = getVideoWorkflowNodeDefinition(templateType); const sourceNode = videoCanvasInsertMenu.sourceNodeId ? nodes.find((node) => node.id === videoCanvasInsertMenu.sourceNodeId) : null; const anchoredPosition = sourceNode && definition ? { x: videoCanvasInsertMenu.openLeft ? sourceNode.position.x - definition.size.width - 180 : sourceNode.position.x + sourceNode.size.width + 180, y: sourceNode.position.y + sourceNode.size.height / 2 - definition.size.height / 2, } : definition ? { x: videoCanvasInsertMenu.canvasPosition.x - Math.min(Math.round(definition.size.width * 0.18), 84), y: videoCanvasInsertMenu.canvasPosition.y - Math.min(Math.round(definition.size.height * 0.12), 42), } : videoCanvasInsertMenu.canvasPosition; handleAddWorkflowNode(templateType, { position: anchoredPosition, connectFromSelection: Boolean(sourceNode), connectFromNodeId: sourceNode?.id || null, }); setVideoCanvasInsertMenu(null); }, [handleAddWorkflowNode, nodes, videoCanvasInsertMenu] ); const handleCreateSelectedAgentResultBranches = useCallback(async () => { if ( !selectedVideoWorkflowNode || selectedVideoWorkflowType !== "custom-agent" || selectedWorkflowAgentResultAssets.length === 0 ) { toast.error("当前自定义代理节点还没有可展开的 Agent 结果分组。"); return; } const sourceNodeId = selectedVideoWorkflowNode.id; const sourceNode = selectedVideoWorkflowNode; const taskKey = `${sourceNodeId}:branches`; const nowIso = new Date().toISOString(); const touchedNodeIds: string[] = []; const findExistingBranchNode = ( modeType: string, branchRole: "preview" | "local-save" ) => nodes.find((node) => { if (node.type !== "workflow") return false; const templateType = inferVideoWorkflowTemplateType(node); if (templateType !== branchRole) return false; const metadata = ((node.data as WorkflowNodeData).metadata || {}) as Record; return ( String(metadata.sourceWorkflowNodeId || "").trim() === sourceNodeId && String(metadata.agentResultModeType || "").trim() === modeType && String(metadata.agentBranchRole || "").trim() === branchRole ); }) || null; setActiveWorkflowExecutionTaskKey(taskKey); patchWorkflowNode(sourceNodeId, { status: "generating", summary: "正在按 Agent 结果分组整理下游分支...", metadata: { lastExecutedAt: nowIso, }, }); try { selectedWorkflowAgentResultAssets.forEach((group, index) => { const previewPosition = { x: sourceNode.position.x + sourceNode.size.width + 220, y: sourceNode.position.y + index * 220, }; const savePosition = { x: previewPosition.x + 520, y: previewPosition.y + 24, }; const previewNode = findExistingBranchNode(group.modeType, "preview") || (() => { const nodeId = createCanvasNode( buildVideoWorkflowCanvasNode("preview", previewPosition), { connectFromSelection: false, selectNewNode: false, } ); return ( useCanvasStore .getState() .nodes.find((node) => node.id === nodeId) || null ); })(); const saveNode = findExistingBranchNode(group.modeType, "local-save") || (() => { const nodeId = createCanvasNode( buildVideoWorkflowCanvasNode("local-save", savePosition), { connectFromSelection: false, selectNewNode: false, } ); return ( useCanvasStore .getState() .nodes.find((node) => node.id === nodeId) || null ); })(); const firstAsset = group.assets[0] || null; const previewNodeId = previewNode?.id || null; const saveNodeId = saveNode?.id || null; if (previewNodeId) { patchWorkflowNode(previewNodeId, { title: `${group.label}预览`, description: `检查 ${group.label} 结果并决定是否继续加工或保存。`, status: "completed", summary: `已整理 ${group.assets.length} 个 ${group.label} 结果,可直接预览。`, metadata: { sourceWorkflowNodeId: sourceNodeId, sourceAgentProjectId: selectedWorkflowLinkedAgentProject.id || agentProjectId, agentResultModeType: group.modeType, agentResultGroupLabel: group.label, agentBranchRole: "preview", syncedAgentAssets: group.assets.map((asset) => ({ url: asset.url, title: asset.title || "", })), previewMediaUrl: firstAsset?.url || "", previewMediaKind: "image", previewMediaTitle: firstAsset?.title || "", sourcePrompt: selectedWorkflowPrompt, lastExecutedAt: nowIso, }, }); addEdge({ source: sourceNodeId, target: previewNodeId, type: "default", }); touchedNodeIds.push(previewNodeId); } if (saveNodeId) { patchWorkflowNode(saveNodeId, { title: `${group.label}保存`, description: `把 ${group.label} 结果直接整理并下载到本地。`, status: "completed", summary: `已整理 ${group.assets.length} 个 ${group.label} 结果,可直接打包下载。`, metadata: { sourceWorkflowNodeId: sourceNodeId, sourceAgentProjectId: selectedWorkflowLinkedAgentProject.id || agentProjectId, agentResultModeType: group.modeType, agentResultGroupLabel: group.label, agentBranchRole: "local-save", syncedAgentAssets: group.assets.map((asset) => ({ url: asset.url, title: asset.title || "", })), pendingAssetCount: group.assets.length, sourcePrompt: selectedWorkflowPrompt, lastExecutedAt: nowIso, }, }); if (previewNodeId) { addEdge({ source: previewNodeId, target: saveNodeId, type: "default", }); } else { addEdge({ source: sourceNodeId, target: saveNodeId, type: "default", }); } touchedNodeIds.push(saveNodeId); } }); patchWorkflowNode(sourceNodeId, { status: "completed", summary: `已按 ${selectedWorkflowAgentResultAssets.length} 组 Agent 结果整理下游分支。`, metadata: { lastAgentBranchSyncAt: nowIso, lastExecutedAt: nowIso, branchResultModeCount: selectedWorkflowAgentResultAssets.length, }, }); if (touchedNodeIds.length > 0) { selectNodes(Array.from(new Set(touchedNodeIds))); } toast.success( `已展开 ${selectedWorkflowAgentResultAssets.length} 组结果分支` ); } catch (error) { const message = error instanceof Error ? error.message : "展开 Agent 结果分支失败"; patchWorkflowNode(sourceNodeId, { status: "failed", summary: message, metadata: { lastExecutedAt: nowIso, lastError: message, }, }); toast.error(message); } finally { setActiveWorkflowExecutionTaskKey((current) => current === taskKey ? null : current ); } }, [ addEdge, agentProjectId, createCanvasNode, nodes, patchWorkflowNode, selectNodes, selectedVideoWorkflowNode, selectedVideoWorkflowType, selectedWorkflowAgentResultAssets, selectedWorkflowLinkedAgentProject.id, selectedWorkflowPrompt, ]); const handleAddTextNode = useCallback(() => { if (isVideoMode) { handleAddWorkflowNode("text-node"); return; } createCanvasNode({ type: "text", position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 }, size: { width: 200, height: 100 }, data: { color: DEFAULT_CANVAS_TEXT_COLOR, content: "双击编辑文本", }, connections: [], }); }, [createCanvasNode, handleAddWorkflowNode, isVideoMode]); const handleCreateBlankImageNode = useCallback(() => { const origin = getVisibleCanvasOrigin(); createCanvasNode({ type: "image", position: { x: origin.x + 40 + Math.random() * 80, y: origin.y + 40 + Math.random() * 80, }, size: { width: 300, height: 300 }, data: { src: "", status: "pending", title: "新图片", prompt: "", }, connections: [], }); }, [createCanvasNode, getVisibleCanvasOrigin]); const handleAddMaterialImagesToCanvas = useCallback( async (images: CanvasAddImageDialogImage[]) => { const validImages = images.filter((image) => String(image.imageUrl || "").trim() ); if (!validImages.length) return; const origin = getVisibleCanvasOrigin(); const columns = Math.min(3, Math.max(1, validImages.length)); const cellWidth = 388; const cellHeight = 388; const createdNodeIds: string[] = []; for (const [index, image] of validImages.entries()) { const dimensions = await resolveCanvasImageDisplaySize(image.imageUrl); const column = index % columns; const row = Math.floor(index / columns); const nodeId = createCanvasNode( { type: "image", position: { x: origin.x + 40 + column * cellWidth, y: origin.y + 40 + row * cellHeight, }, size: { width: dimensions.width, height: dimensions.height, }, data: { src: image.imageUrl, assetId: image.assetId || undefined, status: "completed", title: image.label || image.fileName || "白底图", prompt: image.fileName || image.label || "素材库白底图", metadata: { materialFileName: image.fileName, materialGroup: image.group, materialImageId: image.id, materialKey: image.key, materialMimeType: image.mimeType || undefined, originalHeight: dimensions.originalHeight, originalWidth: dimensions.originalWidth, source: "agent-workflow-material-search", }, }, connections: [], }, { selectNewNode: false } ); createdNodeIds.push(nodeId); } if (createdNodeIds.length > 0) { selectNodes(createdNodeIds); toast.success(`已添加 ${createdNodeIds.length} 张白底图到画布`); } }, [createCanvasNode, getVisibleCanvasOrigin, selectNodes] ); // 添加图片节点 const handleAddImageNode = useCallback(() => { if (isVideoMode) { handleAddWorkflowNode("input-image"); return; } setIsAddImageDialogOpen(true); }, [handleAddWorkflowNode, isVideoMode]); const handleAddAgentSlotNode = useCallback((slotType: AgentSlotNodeData["slotType"]) => { if (isVideoMode) return; const slotDefaults: Record< AgentSlotNodeData["slotType"], { label: string; size: CanvasNode["size"]; aspectRatio: string; } > = { image: { label: "图片槽位", size: { width: 320, height: 320 }, aspectRatio: "1:1", }, video: { label: "视频槽位", size: { width: 360, height: 220 }, aspectRatio: "16:9", }, "ui-screen": { label: "UI 槽位", size: { width: 360, height: 260 }, aspectRatio: "16:9", }, }; const defaults = slotDefaults[slotType]; const origin = getVisibleCanvasOrigin(); createCanvasNode({ type: "agent-slot", position: { x: origin.x + 40, y: origin.y + 40, }, size: defaults.size, data: { slotType, label: defaults.label, status: "empty", aspectRatio: defaults.aspectRatio, fillPolicy: "replace-empty", acceptedSources: ["text", "image", "selection", "annotation"], filledNodeIds: [], metadata: { createdBy: "toolbar", createdSlotType: slotType, createdAt: new Date().toISOString(), }, }, connections: [], }); }, [createCanvasNode, getVisibleCanvasOrigin, isVideoMode]); const handleAddAgentImageSlotNode = useCallback(() => { handleAddAgentSlotNode("image"); }, [handleAddAgentSlotNode]); const handleAddVideoNode = useCallback(() => { if (isVideoMode) { handleAddWorkflowNode("video-input"); return; } createCanvasNode({ type: "video", position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 }, size: { width: 320, height: 220 }, data: { src: "", status: "pending", prompt: "", }, connections: [], }); }, [createCanvasNode, handleAddWorkflowNode, isVideoMode]); const handleRunStoryboardBatch = useCallback(async () => { if ( selectedVideoWorkflowType !== "storyboard-node" || !selectedVideoWorkflowNode ) { return; } const nodeId = selectedVideoWorkflowNode.id; const taskKey = `${nodeId}:batch`; const nowIso = new Date().toISOString(); const imageMedia = selectedWorkflowMedia.filter( (item) => item.kind === "image" ); const primaryImage = imageMedia[0] || null; const shots = selectedWorkflowStoryboardShots.length > 0 ? selectedWorkflowStoryboardShots : selectedWorkflowPrompt.trim() ? buildStoryboardShots(selectedWorkflowPrompt, { imageCount: selectedWorkflowInputs.imageCount, videoCount: selectedWorkflowInputs.videoCount, }) : []; if (selectedStoryboardBatchTargets.length === 0) { toast.error("当前分镜节点右侧还没有连接生成图片或生成视频节点。"); return; } if (shots.length === 0) { toast.error("请先整理分镜,或至少接入一段上游脚本再执行批量生成。"); return; } setActiveWorkflowExecutionTaskKey(taskKey); try { patchWorkflowNode(nodeId, { status: "generating", summary: `正在把 ${shots.length} 个镜头派发到 ${selectedStoryboardBatchTargets.length} 个下游节点...`, metadata: { sourcePrompt: selectedWorkflowPrompt, storyboardShots: shots, batchShotCount: shots.length, batchTargetCount: selectedStoryboardBatchTargets.length, lastExecutedAt: nowIso, lastBatchExecutedAt: nowIso, }, }); if (selectedStoryboardChartTargets.length > 0) { syncDirectStoryboardChartNodes(nodeId, shots, selectedWorkflowPrompt); } const allCreatedNodeIds: string[] = []; let totalSuccessCount = 0; let totalFailureCount = 0; for (const target of selectedStoryboardBatchTargets) { const targetNodeId = target.node.id; const targetType = target.templateType; const targetLabel = targetType === "gen-video" ? "视频镜头" : "关键帧"; patchWorkflowNode(targetNodeId, { status: "generating", summary: `正在批量生成 ${shots.length} 个${targetLabel}...`, metadata: { sourcePrompt: selectedWorkflowPrompt, storyboardShots: shots, batchMode: targetType, batchShotCount: shots.length, lastExecutedAt: nowIso, storyboardSourceNodeId: nodeId, }, }); let successCount = 0; let failureCount = 0; let lastMedia: WorkflowMediaReference | null = null; const failureMessages: string[] = []; for (const shot of shots) { const shotPrompt = buildStoryboardShotPrompt( selectedWorkflowPrompt, shot ); const outputIndex = successCount; try { if (targetType === "gen-image") { const result = await imageGenerationService.generateImage({ prompt: shotPrompt || "基于当前分镜镜头生成一张适合后续视频工作流使用的关键帧。", referenceImage: primaryImage?.url, aspectRatio: guessCanvasAspectRatio( primaryImage?.nodeId || targetNodeId ), imageCount: 1, }); const imageUrl = result.data?.images?.[0]?.originalUrl || result.data?.images?.[0]?.thumbnailUrl || ""; if (!result.success || !imageUrl) { throw new Error(result.error || "图片生成失败"); } const media: WorkflowMediaReference = { nodeId: targetNodeId, kind: "image", url: imageUrl, title: `${shot.title || `镜头 ${outputIndex + 1}`}-关键帧`, prompt: shotPrompt, source: "workflow-result", }; const offset = getWorkflowBatchOutputOffset( media.kind, outputIndex ); const createdNodeId = createWorkflowOutputNode({ sourceNodeId: targetNodeId, anchorNodeId: targetNodeId, edgeSourceNodeId: targetNodeId, media, title: media.title || "生成图片", prompt: shotPrompt, offsetX: offset.x, offsetY: offset.y, selectAfterCreate: false, }); if (createdNodeId) { allCreatedNodeIds.push(createdNodeId); } lastMedia = media; successCount += 1; continue; } const mode = inferVideoGenerationModeFromMedia(imageMedia); const result = await videoGenerationService.generateVideo({ prompt: shotPrompt || "基于当前分镜镜头生成一段镜头感明确、动作自然的视频片段。", mode, images: imageMedia.map((item) => item.url).slice(0, 4), ratio: guessVideoRatio(primaryImage?.nodeId || targetNodeId), duration: 5, returnLastFrame: false, }); const videoUrl = String(result.data?.url || "").trim(); if (!result.success || !videoUrl) { throw new Error(result.error || "视频生成失败"); } const media: WorkflowMediaReference = { nodeId: targetNodeId, kind: "video", url: videoUrl, title: `${shot.title || `镜头 ${outputIndex + 1}`}-视频`, prompt: shotPrompt, source: "workflow-result", }; const offset = getWorkflowBatchOutputOffset( media.kind, outputIndex ); const createdNodeId = createWorkflowOutputNode({ sourceNodeId: targetNodeId, anchorNodeId: targetNodeId, edgeSourceNodeId: targetNodeId, media, title: media.title || "生成视频", prompt: shotPrompt, offsetX: offset.x, offsetY: offset.y, selectAfterCreate: false, }); if (createdNodeId) { allCreatedNodeIds.push(createdNodeId); } lastMedia = media; successCount += 1; } catch (error) { failureCount += 1; failureMessages.push( `${shot.title || `镜头 ${failureCount}`}: ${ error instanceof Error ? error.message : `${targetLabel}生成失败` }` ); } } totalSuccessCount += successCount; totalFailureCount += failureCount; if (lastMedia) { syncDirectPreviewNodes( targetNodeId, lastMedia, lastMedia.prompt || selectedWorkflowPrompt ); } patchWorkflowNode(targetNodeId, { status: successCount > 0 ? "completed" : "failed", summary: successCount > 0 ? failureCount > 0 ? `已完成 ${successCount}/${shots.length} 个${targetLabel},其余 ${failureCount} 个失败。` : `已批量生成 ${successCount} 个${targetLabel}。` : `批量${targetLabel}生成失败,请检查提示词和上游素材。`, metadata: { sourcePrompt: selectedWorkflowPrompt, storyboardShots: shots, batchMode: targetType, batchShotCount: shots.length, batchResultCount: successCount, batchFailedCount: failureCount, batchErrors: failureMessages.slice(0, 4), resultMediaUrl: lastMedia?.url || "", resultMediaKind: lastMedia?.kind || "", resultMediaTitle: lastMedia?.title || "", lastExecutedAt: nowIso, lastBatchExecutedAt: nowIso, storyboardSourceNodeId: nodeId, }, }); } if (allCreatedNodeIds.length > 0) { selectNodes(allCreatedNodeIds); } patchWorkflowNode(nodeId, { status: totalSuccessCount > 0 ? "completed" : "failed", summary: totalSuccessCount > 0 ? totalFailureCount > 0 ? `已派发 ${shots.length} 个镜头,生成 ${totalSuccessCount} 个结果,${totalFailureCount} 个失败。` : `已把 ${shots.length} 个镜头批量派发到下游节点,共生成 ${totalSuccessCount} 个结果。` : "分镜已派发,但下游生成全部失败。", metadata: { sourcePrompt: selectedWorkflowPrompt, storyboardShots: shots, batchShotCount: shots.length, batchTargetCount: selectedStoryboardBatchTargets.length, batchResultCount: totalSuccessCount, batchFailedCount: totalFailureCount, lastExecutedAt: nowIso, lastBatchExecutedAt: nowIso, }, }); if (totalSuccessCount > 0) { toast.success( totalFailureCount > 0 ? `已生成 ${totalSuccessCount} 个结果,${totalFailureCount} 个失败` : `已生成 ${totalSuccessCount} 个分镜结果` ); } else { toast.error("分镜已派发,但下游生成全部失败"); } } catch (error) { const message = error instanceof Error ? error.message : "分镜批量执行失败"; patchWorkflowNode(nodeId, { status: "failed", summary: message, metadata: { lastExecutedAt: nowIso, lastError: message, }, }); toast.error(message); } finally { setActiveWorkflowExecutionTaskKey((current) => current === taskKey ? null : current ); } }, [ buildStoryboardShotPrompt, buildStoryboardShots, createWorkflowOutputNode, getWorkflowBatchOutputOffset, guessCanvasAspectRatio, guessVideoRatio, imageGenerationService, patchWorkflowNode, selectNodes, selectedStoryboardBatchTargets, selectedStoryboardChartTargets.length, selectedVideoWorkflowNode, selectedVideoWorkflowType, selectedWorkflowInputs.imageCount, selectedWorkflowInputs.videoCount, selectedWorkflowMedia, selectedWorkflowPrompt, selectedWorkflowStoryboardShots, syncDirectPreviewNodes, syncDirectStoryboardChartNodes, videoGenerationService, ]); const handleRunSelectedWorkflowNode = useCallback(async () => { if ( !selectedVideoWorkflowNode || !selectedVideoWorkflowType || !selectedWorkflowIsActionable ) { return; } const nodeId = selectedVideoWorkflowNode.id; const taskKey = `${nodeId}:primary`; const imageMedia = selectedWorkflowMedia.filter( (item) => item.kind === "image" ); const videoMedia = selectedWorkflowMedia.filter( (item) => item.kind === "video" ); const primaryImage = imageMedia[0] || null; const nowIso = new Date().toISOString(); setActiveWorkflowExecutionTaskKey(taskKey); try { if (selectedVideoWorkflowType === "storyboard-node") { if (!selectedWorkflowPrompt.trim()) { throw new Error( "请先在上游接入脚本、画面描述或参考素材,再整理分镜。" ); } patchWorkflowNode(nodeId, { status: "generating", summary: "正在整理镜头草案...", metadata: { sourcePrompt: selectedWorkflowPrompt, lastExecutedAt: nowIso, }, }); const shots = buildStoryboardShots(selectedWorkflowPrompt, { imageCount: selectedWorkflowInputs.imageCount, videoCount: selectedWorkflowInputs.videoCount, }); patchWorkflowNode(nodeId, { status: "completed", summary: `已整理 ${shots.length} 个镜头草案,可继续连接生成图片或生成视频。`, metadata: { sourcePrompt: selectedWorkflowPrompt, storyboardShots: shots, batchShotCount: shots.length, lastExecutedAt: nowIso, }, }); syncDirectStoryboardChartNodes(nodeId, shots, selectedWorkflowPrompt); toast.success(`已整理 ${shots.length} 个分镜草案`); return; } if (selectedVideoWorkflowType === "video-analyze") { if ( !selectedWorkflowPrompt.trim() && imageMedia.length === 0 && videoMedia.length === 0 ) { throw new Error( "请先接入参考视频、封面图或脚本说明,再执行视频分析。" ); } patchWorkflowNode(nodeId, { status: "generating", summary: "正在分析视频节奏和镜头结构...", metadata: { sourcePrompt: selectedWorkflowPrompt, lastExecutedAt: nowIso, }, }); const result = await workflowAnalysisService.analyze({ mode: "video-analysis", prompt: selectedWorkflowPrompt, imageUrls: imageMedia.map((item) => item.url).slice(0, 4), videoUrls: videoMedia.map((item) => item.url).slice(0, 4), nodeTitle: (selectedVideoWorkflowNode.data as { title?: string } | undefined) ?.title || "视频分析", }); if (!result.success) { throw new Error(result.error || "视频分析失败"); } const highlights = result.data?.highlights && result.data.highlights.length > 0 ? result.data.highlights : [ `当前串联了 ${selectedWorkflowInputs.videoCount} 个视频素材和 ${selectedWorkflowInputs.imageCount} 个参考图。`, selectedWorkflowPrompt.trim() ? `上游创作意图:${selectedWorkflowPrompt.trim().slice(0, 80)}${selectedWorkflowPrompt.trim().length > 80 ? "..." : ""}` : "上游暂未明确脚本,可先补一段镜头目标或情绪描述。", ]; const shotIdeas = result.data?.shotIdeas && result.data.shotIdeas.length > 0 ? result.data.shotIdeas : buildStoryboardShots(selectedWorkflowPrompt, { imageCount: selectedWorkflowInputs.imageCount, videoCount: selectedWorkflowInputs.videoCount, }); patchWorkflowNode(nodeId, { status: "completed", summary: result.data?.summary || "已整理一版结构化视频分析结果,可继续连接分镜节点或关键帧生成。", metadata: { analysisHighlights: highlights, storyboardShots: shotIdeas, batchShotCount: shotIdeas.length, visualMotifs: result.data?.visualMotifs || [], recommendedNextSteps: result.data?.recommendedNextSteps || [], sourcePrompt: selectedWorkflowPrompt, analysisModel: result.data?.model || "", lastExecutedAt: nowIso, }, }); toast.success("视频分析节点已生成结构化结果"); return; } if (selectedVideoWorkflowType === "extract-characters-scenes") { if (!selectedWorkflowPrompt.trim() && imageMedia.length === 0) { throw new Error("请先接入小说、脚本或参考图,再提取角色与场景。"); } patchWorkflowNode(nodeId, { status: "generating", summary: "正在提取角色、场景与关系线索...", metadata: { sourcePrompt: selectedWorkflowPrompt, lastExecutedAt: nowIso, }, }); const result = await workflowAnalysisService.analyze({ mode: "character-scene-extraction", prompt: selectedWorkflowPrompt, imageUrls: imageMedia.map((item) => item.url).slice(0, 4), nodeTitle: (selectedVideoWorkflowNode.data as { title?: string } | undefined) ?.title || "提取角色场景", }); if (!result.success) { throw new Error(result.error || "角色场景提取失败"); } const characters = Array.isArray(result.data?.characters) ? result.data?.characters : []; const scenes = Array.isArray(result.data?.scenes) ? result.data?.scenes : []; patchWorkflowNode(nodeId, { status: "completed", summary: result.data?.summary || `已提取 ${characters.length} 个角色和 ${scenes.length} 个场景线索。`, metadata: { sourcePrompt: selectedWorkflowPrompt, characters, scenes, relationships: result.data?.relationships || [], recommendedNextSteps: result.data?.recommendedNextSteps || [], analysisModel: result.data?.model || "", lastExecutedAt: nowIso, }, }); toast.success( `已提取 ${characters.length} 个角色和 ${scenes.length} 个场景` ); return; } if (selectedVideoWorkflowType === "character-description") { const descriptionText = buildCharacterDescriptionSummary( selectedWorkflowStructuredData.characters, selectedWorkflowPrompt ); if (!descriptionText.trim()) { throw new Error("上游还没有可整理的角色线索,请先提取角色场景。"); } patchWorkflowNode(nodeId, { status: "completed", summary: `已整理 ${Math.max( selectedWorkflowStructuredData.characters.length, 1 )} 组角色描述,可继续固化角色资产。`, metadata: { sourcePrompt: descriptionText, characters: selectedWorkflowStructuredData.characters, recommendedNextSteps: selectedWorkflowStructuredData.recommendedNextSteps, lastExecutedAt: nowIso, }, }); toast.success("角色描述节点已整理完成"); return; } if (selectedVideoWorkflowType === "scene-description") { const descriptionText = buildSceneDescriptionSummary( selectedWorkflowStructuredData.scenes, selectedWorkflowPrompt ); if (!descriptionText.trim()) { throw new Error("上游还没有可整理的场景线索,请先提取角色场景。"); } patchWorkflowNode(nodeId, { status: "completed", summary: `已整理 ${Math.max( selectedWorkflowStructuredData.scenes.length, 1 )} 组场景描述,可继续固化场景资产。`, metadata: { sourcePrompt: descriptionText, scenes: selectedWorkflowStructuredData.scenes, recommendedNextSteps: selectedWorkflowStructuredData.recommendedNextSteps, lastExecutedAt: nowIso, }, }); toast.success("场景描述节点已整理完成"); return; } if (selectedVideoWorkflowType === "create-character") { const characterAssets = selectedWorkflowStructuredData.characterAssets.length > 0 ? selectedWorkflowStructuredData.characterAssets : buildCharacterAssets( selectedWorkflowStructuredData.characters, selectedWorkflowPrompt ); if (characterAssets.length === 0) { throw new Error("上游还没有可固化的角色信息,请先提取角色场景。"); } patchWorkflowNode(nodeId, { status: "completed", summary: `已固化 ${characterAssets.length} 个角色资产,可继续生成角色图或角色视频。`, metadata: { sourcePrompt: buildWorkflowTextBlock([ selectedWorkflowPrompt, characterAssets.map((item) => item.prompt).join("\n"), ]), characters: selectedWorkflowStructuredData.characters, characterAssets, recommendedNextSteps: ["连接生成角色图片", "连接生成角色视频"], lastExecutedAt: nowIso, }, }); toast.success(`已固化 ${characterAssets.length} 个角色资产`); return; } if (selectedVideoWorkflowType === "create-scene") { const sceneAssets = selectedWorkflowStructuredData.sceneAssets.length > 0 ? selectedWorkflowStructuredData.sceneAssets : buildSceneAssets( selectedWorkflowStructuredData.scenes, selectedWorkflowPrompt ); if (sceneAssets.length === 0) { throw new Error("上游还没有可固化的场景信息,请先提取角色场景。"); } patchWorkflowNode(nodeId, { status: "completed", summary: `已固化 ${sceneAssets.length} 个场景资产,可继续生成场景图或场景视频。`, metadata: { sourcePrompt: buildWorkflowTextBlock([ selectedWorkflowPrompt, sceneAssets.map((item) => item.prompt).join("\n"), ]), scenes: selectedWorkflowStructuredData.scenes, sceneAssets, recommendedNextSteps: ["连接生成场景图片", "连接生成场景视频"], lastExecutedAt: nowIso, }, }); toast.success(`已固化 ${sceneAssets.length} 个场景资产`); return; } if (selectedVideoWorkflowType === "storyboard-chart-node") { const chartShots = selectedWorkflowStoryboardShots.length > 0 ? selectedWorkflowStoryboardShots.map((shot) => ({ title: shot.title, description: shot.description, })) : buildStoryboardShots(selectedWorkflowPrompt, { imageCount: selectedWorkflowInputs.imageCount, videoCount: selectedWorkflowInputs.videoCount, }); if ( chartShots.length === 0 && !selectedWorkflowPrompt.trim() && imageMedia.length === 0 && videoMedia.length === 0 ) { throw new Error("请先接入脚本、分镜或参考素材,再生成分镜表。"); } const chart = buildStoryboardChartRows( chartShots, selectedWorkflowPrompt ); patchWorkflowNode(nodeId, { status: "completed", summary: `已整理 ${chart.rows.length} 行分镜视表,可继续连接预览节点检查镜头节奏。`, metadata: { sourcePrompt: selectedWorkflowPrompt, storyboardShots: chartShots, batchShotCount: chartShots.length, tableColumns: chart.columns, tableRows: chart.rows, previewMediaUrl: primaryImage?.url || "", previewMediaKind: primaryImage?.kind || "image", previewMediaTitle: primaryImage?.title || "分镜参考", lastExecutedAt: nowIso, }, }); toast.success(`已整理 ${chart.rows.length} 行分镜视表`); return; } if (selectedVideoWorkflowType === "table-editor-node") { const tableState = buildWorkflowTableState({ promptText: selectedWorkflowPrompt, shots: selectedWorkflowStoryboardShots.map((shot) => ({ title: shot.title, description: shot.description, })), structuredData: selectedWorkflowStructuredData, }); if (tableState.rows.length === 0) { throw new Error( "上游还没有可整理成表格的内容,请先接入脚本、角色、场景或分镜。" ); } patchWorkflowNode(nodeId, { status: "completed", summary: tableState.summary, metadata: { sourcePrompt: selectedWorkflowPrompt, tableColumns: tableState.columns, tableRows: tableState.rows, lastExecutedAt: nowIso, }, }); toast.success(`已整理 ${tableState.rows.length} 行结构化表格`); return; } if (selectedVideoWorkflowType === "image-compare") { const compareItems = buildWorkflowCompareItems(selectedWorkflowMedia); if (compareItems.length < 2) { throw new Error("请至少接入两个上游图片或视频结果,再整理对比节点。"); } patchWorkflowNode(nodeId, { status: "completed", summary: `已整理 ${compareItems.length} 个结果用于并排对比。`, metadata: { sourcePrompt: selectedWorkflowPrompt, compareItems, previewMediaUrl: compareItems[0]?.url || "", previewMediaKind: compareItems[0]?.kind || "image", previewMediaTitle: compareItems[0]?.title || "对比基准", lastExecutedAt: nowIso, }, }); toast.success(`已整理 ${compareItems.length} 个结果用于对比`); return; } if (selectedVideoWorkflowType === "jimeng-super-resolution") { if (!primaryImage?.url) { throw new Error("请先接入一张图片,再执行超分增强。"); } patchWorkflowNode(nodeId, { status: "generating", summary: "正在生成高清增强版本...", metadata: { sourcePrompt: selectedWorkflowPrompt, sourceMediaUrl: primaryImage.url, lastExecutedAt: nowIso, }, }); const result = await imageGenerationService.enhanceImage( primaryImage.url ); const enhancedUrl = String(result.data?.url || "").trim(); if (!result.success || !enhancedUrl) { throw new Error(result.error || "超分增强失败"); } const promptText = buildWorkflowTextBlock([ selectedWorkflowPrompt, primaryImage.prompt, "高清增强 / 超分修复", ]); const media: WorkflowMediaReference = { nodeId, kind: "image", url: enhancedUrl, title: `${String(primaryImage.title || "高清增强").trim() || "高清增强"}-结果`, prompt: promptText, source: "workflow-result", }; patchWorkflowNode(nodeId, { status: "completed", summary: "已生成一版高清增强结果,可继续预览或下载。", metadata: { sourcePrompt: promptText, resultMediaUrl: media.url, resultMediaKind: media.kind, resultMediaTitle: media.title, previewMediaUrl: media.url, previewMediaKind: media.kind, previewMediaTitle: media.title, sourceMediaUrl: primaryImage.url, lastExecutedAt: nowIso, }, }); createWorkflowOutputNode({ sourceNodeId: nodeId, media, title: media.title || "高清增强", prompt: promptText, }); syncDirectPreviewNodes(nodeId, media, promptText); toast.success("超分增强节点已产出新结果"); return; } if (selectedVideoWorkflowType === "inpaint-crop") { if (!primaryImage?.url) { throw new Error("请先接入一张图片,再执行局部裁剪。"); } const cropRect = buildWorkflowDefaultCropRect(selectedWorkflowPrompt); patchWorkflowNode(nodeId, { status: "generating", summary: "正在裁出局部重绘工作区...", metadata: { sourcePrompt: selectedWorkflowPrompt, cropRect, sourceMediaUrl: primaryImage.url, lastExecutedAt: nowIso, }, }); const result = await imageGenerationService.boxCutout({ imageUrl: primaryImage.url, cropRect, paddingRatio: 0.08, sourceNodeId: primaryImage.nodeId, }); const croppedUrl = String(result.data?.url || "").trim(); if (!result.success || !croppedUrl) { throw new Error(result.error || "局部裁剪失败"); } const promptText = buildWorkflowTextBlock([ selectedWorkflowPrompt, "局部裁剪 / 重绘工作区", ]); const media: WorkflowMediaReference = { nodeId, kind: "image", url: croppedUrl, title: `${String(primaryImage.title || "局部裁剪").trim() || "局部裁剪"}-结果`, prompt: promptText, source: "workflow-result", }; patchWorkflowNode(nodeId, { status: "completed", summary: "已裁出一块适合继续局部重绘的工作区域。", metadata: { sourcePrompt: promptText, cropRect, cropRectApplied: result.data?.cropRectApplied, paddedCropRectApplied: result.data?.paddedCropRectApplied, resultMediaUrl: media.url, resultMediaKind: media.kind, resultMediaTitle: media.title, previewMediaUrl: media.url, previewMediaKind: media.kind, previewMediaTitle: media.title, lastExecutedAt: nowIso, }, }); createWorkflowOutputNode({ sourceNodeId: nodeId, media, title: media.title || "局部裁剪", prompt: promptText, }); syncDirectPreviewNodes(nodeId, media, promptText); toast.success("局部裁剪节点已产出新结果"); return; } if (selectedVideoWorkflowType === "inpaint-stitch") { if (!primaryImage?.url) { throw new Error("请先接入一张图片,再执行拼接补画。"); } const extendScales = inferWorkflowExtendScales(selectedWorkflowPrompt); const promptText = buildWorkflowTextBlock([ selectedWorkflowPrompt, "拼接补画 / 背景延展", ]); patchWorkflowNode(nodeId, { status: "generating", summary: "正在补边扩图,准备继续拼接...", metadata: { sourcePrompt: promptText, extendScales, sourceMediaUrl: primaryImage.url, lastExecutedAt: nowIso, }, }); const result = await imageGenerationService.extendImage( primaryImage.url, { prompt: promptText, xScale: extendScales.xScale, yScale: extendScales.yScale, } ); const extendedUrl = String(result.data?.url || "").trim(); if (!result.success || !extendedUrl) { throw new Error(result.error || "拼接补画失败"); } const media: WorkflowMediaReference = { nodeId, kind: "image", url: extendedUrl, title: `${String(primaryImage.title || "拼接补画").trim() || "拼接补画"}-结果`, prompt: promptText, source: "workflow-result", }; patchWorkflowNode(nodeId, { status: "completed", summary: "已生成一版补边扩图结果,可继续预览或保存。", metadata: { sourcePrompt: promptText, extendScales, resultMediaUrl: media.url, resultMediaKind: media.kind, resultMediaTitle: media.title, previewMediaUrl: media.url, previewMediaKind: media.kind, previewMediaTitle: media.title, lastExecutedAt: nowIso, }, }); createWorkflowOutputNode({ sourceNodeId: nodeId, media, title: media.title || "拼接补画", prompt: promptText, }); syncDirectPreviewNodes(nodeId, media, promptText); toast.success("拼接补画节点已产出新结果"); return; } if (selectedVideoWorkflowType === "professional-camera") { const cameraPreset = buildWorkflowCameraPreset(selectedWorkflowPrompt); const cameraRows = [ { field: "水平角度", value: `${cameraPreset.horizontalAngle}°` }, { field: "垂直角度", value: `${cameraPreset.verticalAngle}°` }, { field: "镜头缩放", value: `${cameraPreset.zoom}` }, { field: "镜头备注", value: cameraPreset.notes.join(" / ") }, ]; if (!primaryImage?.url) { patchWorkflowNode(nodeId, { status: "completed", summary: "已整理一版专业机位参数,可继续接入参考图后生成测试图。", metadata: { sourcePrompt: selectedWorkflowPrompt, analysisHighlights: cameraPreset.notes, tableColumns: [ { key: "field", label: "参数" }, { key: "value", label: "设定" }, ], tableRows: cameraRows, lastExecutedAt: nowIso, }, }); toast.success("已整理专业机位参数"); return; } patchWorkflowNode(nodeId, { status: "generating", summary: "正在生成专业机位测试图...", metadata: { sourcePrompt: selectedWorkflowPrompt, tableColumns: [ { key: "field", label: "参数" }, { key: "value", label: "设定" }, ], tableRows: cameraRows, lastExecutedAt: nowIso, }, }); const result = await imageGenerationService.editAngle({ imageUrl: primaryImage.url, horizontalAngle: cameraPreset.horizontalAngle, verticalAngle: cameraPreset.verticalAngle, zoom: cameraPreset.zoom, }); const angleUrl = String(result.data?.url || "").trim(); if (!result.success || !angleUrl) { throw new Error(result.error || "专业机位测试失败"); } const promptText = buildWorkflowTextBlock([ selectedWorkflowPrompt, result.data?.prompt, cameraPreset.notes.join(" / "), ]); const media: WorkflowMediaReference = { nodeId, kind: "image", url: angleUrl, title: `${String(primaryImage.title || "专业机位").trim() || "专业机位"}-测试图`, prompt: promptText, source: "workflow-result", }; patchWorkflowNode(nodeId, { status: "completed", summary: "已生成一版专业机位测试图,可继续生图或生视频。", metadata: { sourcePrompt: promptText, analysisHighlights: cameraPreset.notes, tableColumns: [ { key: "field", label: "参数" }, { key: "value", label: "设定" }, ], tableRows: cameraRows, resultMediaUrl: media.url, resultMediaKind: media.kind, resultMediaTitle: media.title, previewMediaUrl: media.url, previewMediaKind: media.kind, previewMediaTitle: media.title, lastExecutedAt: nowIso, }, }); createWorkflowOutputNode({ sourceNodeId: nodeId, media, title: media.title || "专业机位测试图", prompt: promptText, }); syncDirectPreviewNodes(nodeId, media, promptText); toast.success("专业机位节点已产出新结果"); return; } if ( selectedVideoWorkflowType === "camera-movement" || selectedVideoWorkflowType === "motion-control" ) { const directive = buildWorkflowMovementDirective( selectedWorkflowPrompt ); const basePrompt = buildWorkflowTextBlock([ selectedWorkflowPrompt, directive.cameraMove, directive.motionCue, directive.rhythm, selectedVideoWorkflowType === "camera-movement" ? "强调镜头运动的电影感和空间推进。" : "强调动作连续性和主体运动控制。", ]); const mode = inferVideoGenerationModeFromMedia(imageMedia); patchWorkflowNode(nodeId, { status: "generating", summary: selectedVideoWorkflowType === "camera-movement" ? "正在生成运镜测试片段..." : "正在生成动作控制片段...", metadata: { sourcePrompt: basePrompt, analysisHighlights: [ directive.cameraMove, directive.motionCue, directive.rhythm, ], tableColumns: [ { key: "field", label: "控制维度" }, { key: "value", label: "说明" }, ], tableRows: [ { field: "镜头运动", value: directive.cameraMove }, { field: "主体动作", value: directive.motionCue }, { field: "节奏", value: directive.rhythm }, ], videoGenerationMode: mode, lastExecutedAt: nowIso, }, }); const result = await videoGenerationService.generateVideo({ prompt: basePrompt, mode, images: imageMedia.map((item) => item.url).slice(0, 4), ratio: guessVideoRatio(primaryImage?.nodeId || nodeId), duration: 5, cameraFixed: directive.cameraFixed, returnLastFrame: false, }); const videoUrl = String(result.data?.url || "").trim(); if (!result.success || !videoUrl) { throw new Error( result.error || (selectedVideoWorkflowType === "camera-movement" ? "运镜测试失败" : "动作控制失败") ); } const media: WorkflowMediaReference = { nodeId, kind: "video", url: videoUrl, title: selectedVideoWorkflowType === "camera-movement" ? "运镜测试-结果" : "动作控制-结果", prompt: basePrompt, source: "workflow-result", }; patchWorkflowNode(nodeId, { status: "completed", summary: selectedVideoWorkflowType === "camera-movement" ? "已生成一段运镜测试片段。" : "已生成一段动作控制片段。", metadata: { sourcePrompt: basePrompt, analysisHighlights: [ directive.cameraMove, directive.motionCue, directive.rhythm, ], tableColumns: [ { key: "field", label: "控制维度" }, { key: "value", label: "说明" }, ], tableRows: [ { field: "镜头运动", value: directive.cameraMove }, { field: "主体动作", value: directive.motionCue }, { field: "节奏", value: directive.rhythm }, ], resultMediaUrl: media.url, resultMediaKind: media.kind, resultMediaTitle: media.title, resultPosterUrl: String( (result.data?.metadata as Record | undefined) ?.lastFrameUrl || (result.data?.metadata as Record | undefined) ?.coverUrl || "" ).trim(), videoGenerationMode: result.data?.mode || mode, lastExecutedAt: nowIso, }, }); createWorkflowOutputNode({ sourceNodeId: nodeId, media, title: media.title || "控制片段", prompt: basePrompt, }); syncDirectPreviewNodes(nodeId, media, basePrompt); toast.success( selectedVideoWorkflowType === "camera-movement" ? "运镜测试节点已产出新结果" : "动作控制节点已产出新结果" ); return; } if (selectedVideoWorkflowType === "canvas-node") { const boardState = buildWorkflowCanvasBoardState( selectedWorkflowMedia, selectedWorkflowPrompt ); if (boardState.compareItems.length === 0) { throw new Error("请先接入图片、视频或结果节点,再整理画板。"); } patchWorkflowNode(nodeId, { status: "completed", summary: boardState.summary, metadata: { sourcePrompt: selectedWorkflowPrompt, compareItems: boardState.compareItems, tableColumns: boardState.columns, tableRows: boardState.rows, analysisHighlights: boardState.highlights, previewMediaUrl: boardState.compareItems[0]?.url || "", previewMediaKind: boardState.compareItems[0]?.kind || "image", previewMediaTitle: boardState.compareItems[0]?.title || "画板主图", lastExecutedAt: nowIso, }, }); toast.success("已整理画板节点"); return; } if (selectedVideoWorkflowType === "doodle-canvas") { const doodleState = buildWorkflowDoodleInstructions( selectedWorkflowPrompt ); patchWorkflowNode(nodeId, { status: "completed", summary: doodleState.summary, metadata: { sourcePrompt: selectedWorkflowPrompt, analysisHighlights: doodleState.highlights, tableColumns: doodleState.columns, tableRows: doodleState.rows, previewMediaUrl: primaryImage?.url || "", previewMediaKind: primaryImage?.kind || "image", previewMediaTitle: primaryImage?.title || "涂鸦参考", lastExecutedAt: nowIso, }, }); toast.success("已整理涂鸦画板指令"); return; } if (selectedVideoWorkflowType === "gen-music") { const musicPlan = buildWorkflowMusicPlan( selectedWorkflowPrompt, selectedWorkflowStoryboardShots.map((shot) => ({ title: shot.title, description: shot.description, })) ); patchWorkflowNode(nodeId, { status: "completed", summary: musicPlan.summary, metadata: { sourcePrompt: selectedWorkflowPrompt, analysisHighlights: musicPlan.highlights, tableColumns: musicPlan.columns, tableRows: musicPlan.rows, deliverables: ["配乐 cue sheet", "情绪与配器建议", "段落节奏建议"], lastExecutedAt: nowIso, }, }); toast.success("已整理一版配乐方案"); return; } if ( selectedVideoWorkflowType === "rh-app" || selectedVideoWorkflowType === "rh-comfy" ) { const handoffPlan = buildWorkflowHandoffPlan( selectedVideoWorkflowType, { promptText: selectedWorkflowPrompt, shots: selectedWorkflowStoryboardShots.map((shot) => ({ title: shot.title, description: shot.description, })), structuredData: selectedWorkflowStructuredData, media: selectedWorkflowMedia, } ); patchWorkflowNode(nodeId, { status: "completed", summary: handoffPlan.summary, metadata: { sourcePrompt: selectedWorkflowPrompt, analysisHighlights: handoffPlan.highlights, deliverables: handoffPlan.deliverables, tableColumns: handoffPlan.columns, tableRows: handoffPlan.rows, lastExecutedAt: nowIso, }, }); toast.success( selectedVideoWorkflowType === "rh-comfy" ? "已整理 Comfy 工作流包" : "已整理 RH 应用包" ); return; } if ( selectedVideoWorkflowType === "gen-image" || selectedVideoWorkflowType === "generate-character-image" || selectedVideoWorkflowType === "generate-scene-image" ) { const promptText = selectedVideoWorkflowType === "generate-character-image" ? resolvePreferredCharacterPrompt( selectedWorkflowPrompt, selectedWorkflowStructuredData ) || "根据角色资产生成一张适合后续视频工作流使用的角色关键帧。" : selectedVideoWorkflowType === "generate-scene-image" ? resolvePreferredScenePrompt( selectedWorkflowPrompt, selectedWorkflowStructuredData ) || "根据场景资产生成一张适合后续视频工作流使用的环境关键帧。" : selectedWorkflowPrompt.trim() || "基于上游脚本与素材生成一张适合视频工作流继续推进的关键帧。"; patchWorkflowNode(nodeId, { status: "generating", summary: selectedVideoWorkflowType === "generate-character-image" ? "正在生成角色关键帧..." : selectedVideoWorkflowType === "generate-scene-image" ? "正在生成场景关键帧..." : "正在生成关键帧...", metadata: { sourcePrompt: promptText, lastExecutedAt: nowIso, }, }); const result = await imageGenerationService.generateImage({ prompt: promptText, referenceImage: primaryImage?.url, aspectRatio: guessCanvasAspectRatio(primaryImage?.nodeId), imageCount: 1, }); const imageUrl = result.data?.images?.[0]?.originalUrl || result.data?.images?.[0]?.thumbnailUrl || ""; if (!result.success || !imageUrl) { throw new Error(result.error || "图片生成失败"); } const media: WorkflowMediaReference = { nodeId, kind: "image", url: imageUrl, title: `${promptText.slice(0, 18) || "关键帧"}-结果图`, prompt: promptText, source: "workflow-result", }; patchWorkflowNode(nodeId, { status: "completed", summary: selectedVideoWorkflowType === "generate-character-image" ? "已根据角色资产生成关键帧。" : selectedVideoWorkflowType === "generate-scene-image" ? "已根据场景资产生成关键帧。" : primaryImage ? "已根据上游脚本和参考图生成关键帧。" : "已根据上游脚本生成关键帧。", metadata: { sourcePrompt: promptText, resultMediaUrl: media.url, resultMediaKind: media.kind, resultMediaTitle: media.title, lastExecutedAt: nowIso, }, }); createWorkflowOutputNode({ sourceNodeId: nodeId, media, title: media.title || "生成图片", prompt: promptText, }); syncDirectPreviewNodes(nodeId, media, promptText); toast.success("图片节点已产出新结果"); return; } if ( selectedVideoWorkflowType === "gen-video" || selectedVideoWorkflowType === "generate-character-video" || selectedVideoWorkflowType === "generate-scene-video" ) { const promptText = selectedVideoWorkflowType === "generate-character-video" ? resolvePreferredCharacterPrompt( selectedWorkflowPrompt, selectedWorkflowStructuredData ) || "根据角色资产生成一段镜头感明确、动作自然的角色视频片段。" : selectedVideoWorkflowType === "generate-scene-video" ? resolvePreferredScenePrompt( selectedWorkflowPrompt, selectedWorkflowStructuredData ) || "根据场景资产生成一段环境氛围明确、转场自然的场景视频片段。" : selectedWorkflowPrompt.trim() || "基于上游脚本与参考图生成一段镜头感明确、动作自然的视频片段。"; const mode = inferVideoGenerationModeFromMedia(imageMedia); patchWorkflowNode(nodeId, { status: "generating", summary: selectedVideoWorkflowType === "generate-character-video" ? "正在生成角色视频片段..." : selectedVideoWorkflowType === "generate-scene-video" ? "正在生成场景视频片段..." : "正在生成视频片段...", metadata: { sourcePrompt: promptText, lastExecutedAt: nowIso, videoGenerationMode: mode, }, }); const result = await videoGenerationService.generateVideo({ prompt: promptText, mode, images: imageMedia.map((item) => item.url).slice(0, 4), ratio: guessVideoRatio(primaryImage?.nodeId || nodeId), duration: 5, returnLastFrame: false, }); const videoUrl = String(result.data?.url || "").trim(); if (!result.success || !videoUrl) { throw new Error(result.error || "视频生成失败"); } const media: WorkflowMediaReference = { nodeId, kind: "video", url: videoUrl, title: `${promptText.slice(0, 18) || "视频"}-结果`, prompt: promptText, source: "workflow-result", }; patchWorkflowNode(nodeId, { status: "completed", summary: selectedVideoWorkflowType === "generate-character-video" ? "已根据角色资产生成角色视频。" : selectedVideoWorkflowType === "generate-scene-video" ? "已根据场景资产生成场景视频。" : mode === "text-to-video" ? "已根据上游脚本生成视频。" : "已结合上游参考图生成视频。", metadata: { sourcePrompt: promptText, resultMediaUrl: media.url, resultMediaKind: media.kind, resultMediaTitle: media.title, resultModel: result.data?.modelDisplayName || "", videoGenerationMode: result.data?.mode || mode, resultPosterUrl: String( (result.data?.metadata as Record | undefined) ?.lastFrameUrl || (result.data?.metadata as Record | undefined) ?.coverUrl || "" ).trim(), lastExecutedAt: nowIso, }, }); createWorkflowOutputNode({ sourceNodeId: nodeId, media, title: media.title || "生成视频", prompt: promptText, }); syncDirectPreviewNodes(nodeId, media, promptText); toast.success("视频节点已产出新结果"); return; } if (selectedVideoWorkflowType === "custom-agent") { if ( !selectedWorkflowPrompt.trim() && imageMedia.length === 0 && videoMedia.length === 0 ) { throw new Error("请先给自定义代理节点提供脚本、参考图或视频上下文。"); } patchWorkflowNode(nodeId, { status: "generating", summary: "正在生成代理执行计划...", metadata: { sourcePrompt: selectedWorkflowPrompt, lastExecutedAt: nowIso, }, }); const result = await workflowAnalysisService.analyze({ mode: "agent-plan", prompt: selectedWorkflowPrompt, imageUrls: imageMedia.map((item) => item.url).slice(0, 4), videoUrls: videoMedia.map((item) => item.url).slice(0, 4), nodeTitle: selectedVideoWorkflowData?.title || "自定义代理", }); if (!result.success) { throw new Error(result.error || "代理计划生成失败"); } patchWorkflowNode(nodeId, { status: "completed", summary: result.data?.summary || "已生成一版代理执行计划,可据此继续组织节点和连接关系。", metadata: { sourcePrompt: selectedWorkflowPrompt, agentPlan: result.data?.agentPlan || [], deliverables: result.data?.deliverables || [], recommendedNodes: result.data?.recommendedNodes || [], analysisModel: result.data?.model || "", lastExecutedAt: nowIso, }, }); toast.success("自定义代理节点已生成计划"); return; } if (selectedVideoWorkflowType === "preview") { const fallbackPreviewMedia = (() => { const metadata = ((selectedVideoWorkflowData?.metadata || {}) as Record< string, any >) || {}; const syncedAsset = Array.isArray(metadata.syncedAgentAssets) ? (metadata.syncedAgentAssets as Array>)[0] || null : null; const previewUrl = String( metadata.previewMediaUrl || syncedAsset?.url || "" ).trim(); if (!/^https?:\/\//i.test(previewUrl)) { return null; } return { nodeId, kind: "image" as const, url: previewUrl, title: String( metadata.previewMediaTitle || syncedAsset?.title || "预览结果" ).trim(), prompt: selectedWorkflowPrompt || String(metadata.sourcePrompt || "").trim(), source: "workflow-preview" as const, }; })(); const previewMedia = getBestWorkflowPreviewMedia(nodeId, workflowGraph) || fallbackPreviewMedia; if (!previewMedia) { throw new Error("预览节点上游还没有可预览的图片或视频。"); } patchWorkflowNode(nodeId, { status: "completed", summary: `已刷新${previewMedia.kind === "video" ? "视频" : "图片"}预览,可继续检查结果。`, metadata: { previewMediaUrl: previewMedia.url, previewMediaKind: previewMedia.kind, previewMediaTitle: previewMedia.title || "", sourcePrompt: selectedWorkflowPrompt || previewMedia.prompt || "", lastExecutedAt: nowIso, }, }); toast.success("预览节点已刷新"); return; } if (selectedVideoWorkflowType === "local-save") { const syncedAgentAssets: WorkflowMediaReference[] = Array.isArray( ((selectedVideoWorkflowData?.metadata || {}) as Record) ?.syncedAgentAssets ) ? ( (( (selectedVideoWorkflowData?.metadata || {}) as Record< string, any > ).syncedAgentAssets || []) as Array> ).reduce((assets, item, index) => { const url = String(item?.url || "").trim(); if (!/^https?:\/\//i.test(url)) { return assets; } assets.push({ nodeId, kind: "image", url, title: String(item?.title || `Agent 结果 ${index + 1}`).trim(), prompt: selectedWorkflowPrompt, source: "workflow-result", }); return assets; }, []) : []; const assetsToDownload = syncedAgentAssets.length > 0 ? syncedAgentAssets : selectedWorkflowMedia.slice(0, 24); if (assetsToDownload.length === 0) { throw new Error("保存节点上游还没有可下载的图片或视频。"); } patchWorkflowNode(nodeId, { status: "generating", summary: "正在整理下载包...", metadata: { lastExecutedAt: nowIso, }, }); downloadWorkflowAssets( assetsToDownload.slice(0, 24), "video-workflow-export" ); patchWorkflowNode(nodeId, { status: "completed", summary: `已发起下载 ${Math.min(assetsToDownload.length, 24)} 个上游结果。`, metadata: { lastExecutedAt: nowIso, lastSavedAt: nowIso, savedAssetCount: Math.min(assetsToDownload.length, 24), }, }); toast.success("已开始下载上游结果"); } } catch (error) { const message = error instanceof Error ? error.message : "节点执行失败"; patchWorkflowNode(nodeId, { status: "failed", summary: message, metadata: { lastExecutedAt: nowIso, lastError: message, }, }); toast.error(message); } finally { setActiveWorkflowExecutionTaskKey((current) => current === taskKey ? null : current ); } }, [ buildCharacterAssets, buildCharacterDescriptionSummary, buildStoryboardChartRows, buildWorkflowCanvasBoardState, buildWorkflowCameraPreset, buildWorkflowCompareItems, buildWorkflowDefaultCropRect, buildWorkflowDoodleInstructions, buildWorkflowHandoffPlan, buildWorkflowMovementDirective, buildWorkflowMusicPlan, buildWorkflowTableState, buildSceneAssets, buildSceneDescriptionSummary, buildStoryboardShots, buildWorkflowTextBlock, createWorkflowOutputNode, downloadWorkflowAssets, guessCanvasAspectRatio, guessVideoRatio, inferWorkflowExtendScales, imageGenerationService, patchWorkflowNode, resolvePreferredCharacterPrompt, resolvePreferredScenePrompt, selectedVideoWorkflowData, selectedVideoWorkflowNode, selectedVideoWorkflowType, selectedWorkflowInputs.imageCount, selectedWorkflowInputs.videoCount, selectedWorkflowIsActionable, selectedWorkflowMedia, selectedWorkflowPrompt, selectedWorkflowStructuredData, syncDirectPreviewNodes, syncDirectStoryboardChartNodes, workflowAnalysisService, workflowGraph, ]); // 处理图片操作(通过事件分发到选中图片节点执行) const handleAction = (action: string) => { if (!selectedImageNode) { toast.error("请先选择一张图片"); return; } if (typeof window !== "undefined") { window.dispatchEvent( new CustomEvent("canvas:image-action", { detail: { nodeId: selectedImageNode.id, action, }, }) ); } }; // AI 生成图片 const handleGenerate = async () => { if (!prompt.trim()) { toast.error("请输入提示词"); return; } setGenerating(true); // 先创建占位节点(显示生成中状态) const placeholderNodeIds: string[] = []; for (let i = 0; i < imageCount; i++) { const nodeId = addNode({ type: "image", position: { x: 100 + (i % 3) * 320, y: 100 + Math.floor(i / 3) * 320, }, size: { width: 300, height: 300 }, data: { src: "", status: "generating", title: prompt.slice(0, 24) || "生成中", prompt, }, connections: [], }); placeholderNodeIds.push(nodeId); } try { // 调用 AI 图片生成 API const result = await imageGenerationService.generateImage({ prompt, negativePrompt, aspectRatio, imageCount, }); if (result.success && result.data?.images) { // 更新节点为生成的图片 result.data.images.forEach((image, index) => { if (placeholderNodeIds[index]) { updateNode(placeholderNodeIds[index], { data: { src: image.originalUrl || image.thumbnailUrl, status: "completed", title: `${prompt.slice(0, 20) || "生成图片"}-${index + 1}`, prompt, }, }); } }); toast.success(`成功生成 ${result.data.images.length} 张图片`); setPrompt(""); // 清空提示词 } else { // 生成失败,更新节点状态 placeholderNodeIds.forEach((nodeId) => { updateNode(nodeId, { data: { src: "", status: "failed", title: "生成失败", prompt, }, }); }); toast.error(result.error || "图片生成失败"); } } catch (error) { console.error("AI 图片生成失败:", error); // 生成失败,更新节点状态 placeholderNodeIds.forEach((nodeId) => { updateNode(nodeId, { data: { src: "", status: "failed", title: "生成失败", prompt, }, }); }); toast.error("图片生成失败,请重试"); } finally { setGenerating(false); } }; // 键盘快捷键 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (useCanvasStore.getState().canvasImageSelectionSession) return; // 如果正在输入则不触发快捷键(包括 input、textarea 和 contentEditable 元素) if (isCanvasTextEntryTarget(e.target)) { return; } // Ctrl/Cmd + S 保存 if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); handleSave(); } // Ctrl/Cmd + A:当已选中图片时,选中画布内全部图片 if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") { const { nodes: currentNodes, selectedNodeIds: currentSelectedNodeIds } = useCanvasStore.getState(); const selectedHasImage = currentSelectedNodeIds.some((selectedId) => currentNodes.some( (node) => node.id === selectedId && node.type === "image" ) ); if (selectedHasImage) { const allImageNodeIds = currentNodes .filter( (node) => node.type === "image" && Boolean((node.data as any)?.src) ) .map((node) => node.id); if (allImageNodeIds.length > 0) { e.preventDefault(); selectNodes(allImageNodeIds); } } } // Ctrl/Cmd + Z 撤销 if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) { e.preventDefault(); undo(); } // Ctrl/Cmd + Shift + Z 重做 if ((e.ctrlKey || e.metaKey) && e.key === "z" && e.shiftKey) { e.preventDefault(); redo(); } // V 选择工具 if (e.key === "v" && !e.ctrlKey && !e.metaKey) { setTool("select"); } // H 平移工具 if (e.key === "h" && !e.ctrlKey && !e.metaKey) { setTool("pan"); } // L 连线工具(仅视频画布) if (isVideoMode && e.key === "l" && !e.ctrlKey && !e.metaKey) { setTool("draw"); } // Delete 删除选中 if (e.key === "Delete" || e.key === "Backspace") { useCanvasStore.getState().deleteSelectedNodes(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [ handleSave, isCanvasTextEntryTarget, isVideoMode, nodes, redo, selectNodes, selectedNodeIds, setTool, undo, ]); useEffect(() => { const isBrowserZoomShortcut = (event: KeyboardEvent) => { if (!(event.ctrlKey || event.metaKey)) { return null; } const { code, key } = event; if ( code === "Equal" || code === "NumpadAdd" || key === "+" || key === "=" ) { return "in"; } if ( code === "Minus" || code === "NumpadSubtract" || key === "-" || key === "_" ) { return "out"; } if (code === "Digit0" || code === "Numpad0" || key === "0") { return "reset"; } return null; }; const handleBrowserZoomKeyDown = (event: KeyboardEvent) => { const zoomAction = isBrowserZoomShortcut(event); if (!zoomAction) { return; } if (event.cancelable) { event.preventDefault(); } if (isCanvasTextEntryTarget(event.target)) { return; } if (zoomAction === "in") { applyCanvasViewportZoom(1.2); return; } if (zoomAction === "out") { applyCanvasViewportZoom(1 / 1.2); return; } setViewport({ x: 0, y: 0, zoom: 1 }); }; const handleBrowserZoomWheel = (event: WheelEvent) => { if (!(event.ctrlKey || event.metaKey)) { return; } if (event.cancelable) { event.preventDefault(); } if (isCanvasTextEntryTarget(event.target)) { return; } const workspaceElement = document.querySelector( '[data-canvas-workspace-root="true"]' ); if ( workspaceElement && event.target instanceof Node && workspaceElement.contains(event.target) ) { return; } applyCanvasViewportZoom(event.deltaY > 0 ? 0.95 : 1.05, { clientX: event.clientX, clientY: event.clientY, }); }; const handleGestureStart = (event: Event) => { safariGestureScaleRef.current = 1; event.preventDefault(); }; const handleGestureChange = (event: Event) => { event.preventDefault(); const gestureEvent = event as Event & { scale?: number; clientX?: number; clientY?: number; target?: EventTarget | null; }; if (isCanvasTextEntryTarget(gestureEvent.target ?? null)) { return; } const currentScale = typeof gestureEvent.scale === "number" && Number.isFinite(gestureEvent.scale) && gestureEvent.scale > 0 ? gestureEvent.scale : 1; const zoomFactor = currentScale / safariGestureScaleRef.current; safariGestureScaleRef.current = currentScale; if (!Number.isFinite(zoomFactor) || zoomFactor <= 0 || zoomFactor === 1) { return; } applyCanvasViewportZoom(zoomFactor, { clientX: gestureEvent.clientX, clientY: gestureEvent.clientY, }); }; const handleGestureEnd = () => { safariGestureScaleRef.current = 1; }; window.addEventListener("keydown", handleBrowserZoomKeyDown, { capture: true, }); window.addEventListener("wheel", handleBrowserZoomWheel, { passive: false, capture: true, }); window.addEventListener("gesturestart", handleGestureStart, { passive: false, }); window.addEventListener("gesturechange", handleGestureChange, { passive: false, }); window.addEventListener("gestureend", handleGestureEnd); return () => { window.removeEventListener("keydown", handleBrowserZoomKeyDown, { capture: true, }); window.removeEventListener("wheel", handleBrowserZoomWheel, { capture: true, }); window.removeEventListener("gesturestart", handleGestureStart); window.removeEventListener("gesturechange", handleGestureChange); window.removeEventListener("gestureend", handleGestureEnd); }; }, [applyCanvasViewportZoom, isCanvasTextEntryTarget, setViewport]); useEffect(() => { const html = document.documentElement; const body = document.body; const prevHtmlOverscrollX = html.style.overscrollBehaviorX; const prevBodyOverscrollX = body.style.overscrollBehaviorX; html.style.overscrollBehaviorX = "none"; body.style.overscrollBehaviorX = "none"; const preventHistorySwipe = (event: WheelEvent) => { if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; if (event.cancelable) { event.preventDefault(); } }; window.addEventListener("wheel", preventHistorySwipe, { passive: false }); return () => { window.removeEventListener("wheel", preventHistorySwipe); html.style.overscrollBehaviorX = prevHtmlOverscrollX; body.style.overscrollBehaviorX = prevBodyOverscrollX; }; }, []); useEffect(() => { const currentSerialized = JSON.stringify(workflowSelections); const sanitizedSerialized = JSON.stringify(sanitizedWorkflowSelections); if (currentSerialized === sanitizedSerialized) { return; } workflowSelectionsRef.current = sanitizedWorkflowSelections; setWorkflowSelections(sanitizedWorkflowSelections); }, [sanitizedWorkflowSelections, workflowSelections]); const [workflowContext, setWorkflowContext] = useState(null); useEffect(() => { let canceled = false; const cancelIdleTask = scheduleCanvasIdleTask(() => { const nextWorkflowContext = buildAgentWorkflowComposerContext({ config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, inputs: workflowInputs, materials: workflowMaterials, selectedBrandModel, appearanceRecognitionEnabled, selections: deferredSanitizedWorkflowSelections, }); if (canceled) { return; } startTransition(() => { setWorkflowContext(nextWorkflowContext); }); }, 450); return () => { canceled = true; cancelIdleTask(); }; }, [ appearanceRecognitionEnabled, deferredSanitizedWorkflowSelections, resolvedWorkflowUiConfig, selectedBrandModel, workflowInputs, workflowMaterials, ]); useEffect(() => { workflowInputsRef.current = workflowInputs; }, [workflowInputs]); useEffect(() => { workflowMaterialsRef.current = workflowMaterials; }, [workflowMaterials]); useEffect(() => { workflowSelectionsRef.current = sanitizedWorkflowSelections; }, [sanitizedWorkflowSelections]); useEffect(() => { appearanceRecognitionEnabledRef.current = appearanceRecognitionEnabled; }, [appearanceRecognitionEnabled]); useEffect(() => { selectedBrandModelRef.current = selectedBrandModel; }, [selectedBrandModel]); const handleWorkflowInputsChange = useCallback( (next: AgentWorkflowInputs) => { workflowInputsRef.current = next; patchPersistedAgentWorkflowState({ inputs: next }); setWorkflowInputs(next); }, [patchPersistedAgentWorkflowState] ); const handleWorkflowMaterialsChange = useCallback( (next: AgentWorkflowMaterialSelections) => { const nextMaterials = appearanceRecognitionEnabledRef.current ? next : clearAppearanceSummaryFromMaterials(next); workflowMaterialsRef.current = nextMaterials; patchPersistedAgentWorkflowState({ materials: nextMaterials }); setWorkflowMaterials(nextMaterials); }, [patchPersistedAgentWorkflowState] ); const handleAppearanceRecognitionEnabledChange = useCallback( (next: boolean) => { appearanceRecognitionEnabledRef.current = next; setAppearanceRecognitionEnabled(next); if (!next) { const clearedMaterials = clearAppearanceSummaryFromMaterials( workflowMaterialsRef.current ); workflowMaterialsRef.current = clearedMaterials; patchPersistedAgentWorkflowState({ appearanceRecognitionEnabled: next, materials: clearedMaterials, }); setWorkflowMaterials(clearedMaterials); } else { patchPersistedAgentWorkflowState({ appearanceRecognitionEnabled: next, materials: workflowMaterialsRef.current, }); } }, [patchPersistedAgentWorkflowState] ); const handleWorkflowSelectionsChange = useCallback( (next: Partial>) => { const normalizedCurrentSelections = sanitizeAgentWorkflowSelections({ config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, selections: workflowSelectionsRef.current, }); const normalizedNextSelections = sanitizeAgentWorkflowSelections({ config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, selections: next, }); const orderedStepKeys = flattenVisibleAgentWorkflowSteps( getEnabledAgentWorkflowStepTree( (resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig).steps ), normalizedCurrentSelections ).map(({ step }) => step.key); const earliestChangedIndex = orderedStepKeys.findIndex((stepKey) => { return ( String(normalizedCurrentSelections[stepKey] || "") !== String(normalizedNextSelections[stepKey] || "") ); }); const changedStepKeys = new Set( orderedStepKeys.filter( (stepKey) => String(normalizedCurrentSelections[stepKey] || "") !== String(normalizedNextSelections[stepKey] || "") ) ); let clearedSelections = { ...normalizedNextSelections }; if (earliestChangedIndex >= 0) { orderedStepKeys.slice(earliestChangedIndex + 1).forEach((stepKey) => { if (!changedStepKeys.has(stepKey)) { delete clearedSelections[stepKey]; } }); } clearedSelections = sanitizeAgentWorkflowSelections({ config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, selections: clearedSelections, }); workflowSelectionsRef.current = clearedSelections; patchPersistedAgentWorkflowState({ selections: clearedSelections }); startTransition(() => { setWorkflowSelections(clearedSelections); }); }, [patchPersistedAgentWorkflowState, resolvedWorkflowUiConfig] ); const handleSelectedBrandModelChange = useCallback( (next: AgentWorkflowSelectedBrandModel | null) => { selectedBrandModelRef.current = next; patchPersistedAgentWorkflowState({ selectedBrandModel: next }); setSelectedBrandModel(next); }, [patchPersistedAgentWorkflowState] ); const getLatestWorkflowContext = useCallback(() => { return buildAgentWorkflowComposerContext({ config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, inputs: workflowInputsRef.current, materials: workflowMaterialsRef.current, selectedBrandModel: selectedBrandModelRef.current, appearanceRecognitionEnabled: appearanceRecognitionEnabledRef.current, selections: workflowSelectionsRef.current, }); }, [resolvedWorkflowUiConfig]); const effectiveSelectedAgentPresetId = null; const { data: selectedAgentPresetResponse } = useSWR<{ success?: boolean; data?: AgentPreset; }>( effectiveSelectedAgentPresetId ? `/api/agents/${effectiveSelectedAgentPresetId}` : null, fetcher ); const selectedAgentPresetData = useMemo( () => selectedAgentPresetResponse?.success && selectedAgentPresetResponse.data ? selectedAgentPresetResponse.data : null, [selectedAgentPresetResponse] ); const selectedAgentPresetModeConfig = useMemo( () => normalizeAgentPresetModeConfig(selectedAgentPresetData?.defaultParams), [selectedAgentPresetData?.defaultParams] ); const materialProductSelectionLimit = useMemo(() => { if (selectedAgentPresetData?.type !== "image") { return null; } if (selectedAgentPresetModeConfig.modeType === "scene_generation") { return selectedAgentPresetModeConfig.scene.productImageSlotCount; } if (selectedAgentPresetModeConfig.modeType === "detail_replacement") { return selectedAgentPresetModeConfig.detail.productImageSlotCount; } if (selectedAgentPresetModeConfig.modeType === "sku_replacement") { return selectedAgentPresetModeConfig.sku.productImageSlotCount; } return 2; }, [selectedAgentPresetData?.type, selectedAgentPresetModeConfig]); const materialModelImageRequirement = useMemo(() => { if (selectedAgentPresetData?.type !== "image") { return 0; } if (selectedAgentPresetModeConfig.modeType === "scene_generation") { return selectedAgentPresetModeConfig.scene.includeModelSlot ? 1 : 0; } if (selectedAgentPresetModeConfig.modeType === "detail_replacement") { return selectedAgentPresetModeConfig.detail.includeModelSlot ? 1 : 0; } if (selectedAgentPresetModeConfig.modeType === "sku_replacement") { return selectedAgentPresetModeConfig.sku.includeModelSlot ? 1 : 0; } return 1; }, [selectedAgentPresetData?.type, selectedAgentPresetModeConfig]); const materialTotalUploadRequirement = useMemo(() => { if (selectedAgentPresetData?.type !== "image") { return null; } const explicitCount = Number(selectedAgentPresetData.requiredUploadCount); if (Number.isFinite(explicitCount) && explicitCount > 0) { return Math.max( materialProductSelectionLimit || 0, Math.max(1, Math.round(explicitCount)) ); } return (materialProductSelectionLimit || 0) + materialModelImageRequirement; }, [ materialModelImageRequirement, materialProductSelectionLimit, selectedAgentPresetData?.requiredUploadCount, selectedAgentPresetData?.type, ]); const resolvedAgentProjectTitle = hasActiveAgentProject ? String(agentProjectTitle || "").trim() || "未命名生图项目" : "未选择项目"; const isDarkCanvasTheme = resolvedTheme === "dark"; const lightCanvasBrandLogo = String(siteSettingsData?.siteLogo || "").trim(); const darkCanvasBrandLogo = String(siteSettingsData?.siteLogoDark || "").trim(); const hasSeparateDarkCanvasBrandLogo = Boolean(darkCanvasBrandLogo) && darkCanvasBrandLogo !== lightCanvasBrandLogo; const canvasBrandLogo = String( isDarkCanvasTheme ? hasSeparateDarkCanvasBrandLogo ? darkCanvasBrandLogo : lightCanvasBrandLogo || darkCanvasBrandLogo : lightCanvasBrandLogo || darkCanvasBrandLogo ).trim(); const brandEntry = (
); const agentDisabledNotice = requestedAgentMode && !isAgentWorkspaceEnabled ? (
风格化功能未开启,请联系管理员在系统设置中开启。
) : null; const agentProjectNavSlot = isAgentMode ? (
{canSyncAgentResultsToCanvas ? ( ) : null}
{isAgentProjectLoading || isAgentProjectSaving ? ( ) : ( )} {resolvedAgentProjectTitle}
{ event.preventDefault(); setIsAgentProjectMenuOpen(false); void handleCreateAgentProject(); }} > 新建项目 { event.preventDefault(); setIsAgentProjectMenuOpen(false); void handleSaveAsAgentProject(); }} > 另存为新项目 { event.preventDefault(); setIsAgentProjectMenuOpen(false); void handleRenameCurrentAgentProject(); }} > 重命名当前项目 { event.preventDefault(); setIsAgentProjectMenuOpen(false); setIsAgentProjectSheetOpen(true); }} > 项目列表 {isAgentProjectSheetOpen ? ( ) : null}
) : null; const loginDialogElement = ( 登录已失效 {loginDialogMessage} ); const projectLoadingBadge = isLoading ? (
正在同步项目数据
) : null; const agentProjectSelectionScreen = isAgentMode && !hasActiveAgentProject ? (

先选择一个生图项目

打开历史项目可以继续沿用之前的生图结果、模式和工作流。只有在你明确点击新建项目时,系统才会创建一个全新的风格化项目。

) : null; const agentProjectOpeningScreen = isAgentMode && hasActiveAgentProject && !hasLoadedCurrentAgentProject ? (
) : null; const workflowStylePanel = useMemo( () => ( ), [ handleWorkflowInputsChange, handleWorkflowMaterialsChange, handleWorkflowSelectionsChange, resolvedWorkflowUiConfig, sanitizedWorkflowSelections, workflowInputs, workflowMaterials, ] ); if (isAgentMode) { if (!hasActiveAgentProject) { return ( <> {agentProjectSelectionScreen} {loginDialogElement} {projectLoadingBadge} ); } if (agentProjectOpeningScreen) { return ( <> {agentProjectOpeningScreen} {loginDialogElement} {projectLoadingBadge} ); } return ( <> } brandStorePanel={ } chatId={agentModeChatId} deletedResultCollections={agentProjectDeletedResultCollections} getLatestWorkflowContext={getLatestWorkflowContext} materialSelectionPanel={ isWorkflowMaterialStepEnabled ? ( ) : null } onAgentModeStateChange={setAgentModeState} onDeleteResultImage={handleAgentResultImageDelete} onOpenCanvasEditor={() => { updateCanvasSearchParams({ mode: null, }); }} onPersistProject={() => persistAgentProject("manual")} onReplacementResultStateChange={handleAgentReplacementResultStateChange} onResultPreviewUrlsChange={handleAgentResultPreviewUrlsChange} onWorkspaceStateChange={setAgentWorkspaceState} projectId={agentProjectId} key={agentProjectId} replacementResultState={agentProjectReplacementResultState} resultCollections={agentProjectResultCollections} selectedAgentPresetId={ isAgentWorkspaceEnabled ? effectiveSelectedAgentPresetId : null } selectedBrandModel={selectedBrandModel} showcaseConfig={siteSettingsData?.agentWorkflowShowcaseConfig} siteLogo={siteSettingsData?.siteLogo} siteLogoDark={siteSettingsData?.siteLogoDark} siteName={siteSettingsData?.siteName} syncGeneratedResultsToCanvas={false} topNavRightSlot={agentProjectNavSlot} workflowContext={workflowContext} workflowBusinessModeSettings={workflowBusinessModeSettings} workflowFeatureSettings={workflowPageFeatureSettings} // 嵌入的风格化选择面板 workflowPromptOptimizationEnabled={appearanceRecognitionEnabled} workflowStylePanel={workflowStylePanel} workspaceState={agentWorkspaceState} /> {loginDialogElement} {projectLoadingBadge} ); } return (
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
{isVideoMode && videoCanvasInsertMenu ? (() => { const menuWidth = 312; const menuLeft = viewportWidth > 0 ? Math.max( 12, Math.min( videoCanvasInsertMenu.screenPosition.x, Math.max(12, viewportWidth - menuWidth - 12) ) ) : videoCanvasInsertMenu.screenPosition.x; const menuTop = Math.max( 12, videoCanvasInsertMenu.screenPosition.y + 14 ); const renderChildButton = (child: VideoCanvasInsertMenuChild) => ( ); const renderInsertItem = ( item: VideoCanvasInsertMenuItem, highlighted = false ) => { const Icon = item.icon; const textClassName = highlighted ? "text-orange-600 dark:text-orange-300" : "text-slate-700 dark:text-slate-100"; const iconClassName = highlighted ? "text-orange-500" : "text-slate-400 dark:text-zinc-500"; if (item.children?.length) { return (
{item.label}
{item.children.map(renderChildButton)}
); } return ( ); }; const highlightedItem = VIDEO_CANVAS_INSERT_MENU_ITEMS.at(-1); return (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} ref={videoCanvasInsertMenuRef} style={{ left: `${menuLeft}px`, top: `${menuTop}px`, width: `${menuWidth}px`, }} >
@{siteSettingsData?.siteName || "视频画布"}
{VIDEO_CANVAS_INSERT_MENU_ITEMS.slice(0, -1).map((item) => renderInsertItem(item) )}
{highlightedItem ? ( <>
{renderInsertItem(highlightedItem, true)} ) : null}
); })() : null} {isVideoMode && isVideoModuleSidebarEnabled ? (
{isVideoModuleSidebarCollapsed ? (
) : (
视频模块
工作流分层
)}
{isVideoModuleSidebarCollapsed ? (
{VIDEO_CANVAS_SIDEBAR_GROUPS.map((group) => { const GroupIcon = group.icon; const groupStyle = VIDEO_CANVAS_SIDEBAR_GROUP_STYLES[group.id]; return ( ); })}
) : (
{VIDEO_CANVAS_SIDEBAR_GROUPS.map((group) => { const GroupIcon = group.icon; const groupStyle = VIDEO_CANVAS_SIDEBAR_GROUP_STYLES[group.id]; return ( setVideoModuleSectionOpen((prev) => ({ ...prev, [group.id]: open, })) } open={videoModuleSectionOpen[group.id] !== false} >
{group.label} {group.description}
{group.nodeTypes.length}
{group.nodeTypes.map((type) => { const definition = getVideoWorkflowNodeDefinition(type); if (!definition) return null; const Icon = getVideoWorkflowQuickConnectIcon(type); return ( ); })}
); })}
)}
) : null} {isVideoMode && selectedWorkflowQuickConnectAnchor && selectedWorkflowQuickConnectSourceNode && selectedVideoWorkflowQuickConnects.length > 0 && !videoCanvasInsertMenu ? (
{selectedVideoWorkflowQuickConnects.map((item, index, items) => { const total = items.length; const y = (index - (total - 1) / 2) * 72; const x = 96 + Math.abs(y) * 0.28; const direction = selectedWorkflowQuickConnectAnchor.openLeft ? -1 : 1; const targetX = x * direction; const cp1X = 36 * direction; const cp2X = (x - 26) * direction; return ( ); })} {selectedVideoWorkflowQuickConnects.map((item, index, items) => { const total = items.length; const y = (index - (total - 1) / 2) * 72; const x = 96 + Math.abs(y) * 0.28; const direction = selectedWorkflowQuickConnectAnchor.openLeft ? -1 : 1; const Icon = getVideoWorkflowQuickConnectIcon(item.type); const definition = getVideoWorkflowNodeDefinition(item.type); return ( ); })}
) : null} {isVideoMode && selectedVideoWorkflowDefinition && selectedWorkflowIsActionable ? (
{selectedWorkflowIsActionable ? (
{selectedVideoWorkflowDefinition.label} {selectedWorkflowActionHint}
执行
提示词 {selectedWorkflowInputs.promptCount} 图片 {selectedWorkflowInputs.imageCount} 视频 {selectedWorkflowInputs.videoCount} {selectedVideoWorkflowType === "storyboard-node" ? ( 分镜 {selectedWorkflowStoryboardShots.length} ) : null} {selectedVideoWorkflowType === "storyboard-node" ? ( 下游 {selectedStoryboardBatchTargets.length} ) : null} {selectedWorkflowStructuredData.characters.length > 0 ? ( 角色 {selectedWorkflowStructuredData.characters.length} ) : null} {selectedWorkflowStructuredData.scenes.length > 0 ? ( 场景 {selectedWorkflowStructuredData.scenes.length} ) : null} {selectedWorkflowStructuredData.characterAssets.length > 0 ? ( 角色资产{" "} {selectedWorkflowStructuredData.characterAssets.length} ) : null} {selectedWorkflowStructuredData.sceneAssets.length > 0 ? ( 场景资产{" "} {selectedWorkflowStructuredData.sceneAssets.length} ) : null} {selectedVideoWorkflowType === "custom-agent" && selectedWorkflowLinkedAgentProject.id ? ( Agent 已连接 ) : null} {selectedVideoWorkflowType === "custom-agent" && selectedWorkflowAgentResultCount > 0 ? ( Agent 结果 {selectedWorkflowAgentResultCount} ) : null} {selectedVideoWorkflowType === "custom-agent" && selectedWorkflowPendingAgentResultCount > 0 ? ( 待回流 {selectedWorkflowPendingAgentResultCount} ) : null}
{selectedWorkflowPrompt.trim() ? selectedWorkflowPrompt : "当前还没有抽取到可执行提示词,先在上游接入脚本、分镜或参考素材。"}
{selectedVideoWorkflowType === "custom-agent" && selectedWorkflowLinkedAgentProject.id ? (
已关联 Agent 项目
{selectedWorkflowLinkedAgentProject.title || selectedWorkflowLinkedAgentProject.id}
) : null}
{selectedVideoWorkflowType === "custom-agent" ? ( ) : null} {selectedVideoWorkflowType === "custom-agent" && selectedWorkflowAgentResultAssets.length > 0 ? ( ) : null} {selectedStoryboardCanBatchExecute ? ( ) : null}
) : null}
) : null} {brandEntry} {agentDisabledNotice} {/* 2. Floating Actions (Top Right) */} {!isVideoMode && !isPanelOpen && (
)} {/* 4. Floating Sidebar (Right) */} {isVideoMode ? null : (
{isPanelOpen ? (
) : null}
{!areSecondaryPanelsReady ? ( ) : panelMode === "chat" ? ( // AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
setIsPanelOpen(false)} onOpenFileList={() => setPanelMode("fileList")} onRequireLogin={openLoginDialog} selectedAgentPresetId={ isCanvasAgentModeEnabled ? selectedAgentPresetId : null } workspaceVariant="canvas" />
) : ( // 文件列表 setPanelMode("chat")} workspaceLabel={workspaceLabel} workspaceType={activeCanvasWorkspaceType} /> )}
)} {isVideoMode && isVideoCanvasFileListOpen ? (
setIsVideoCanvasFileListOpen(false)} workspaceLabel={workspaceLabel} workspaceType={activeCanvasWorkspaceType} />
) : null} {isVideoMode && isVideoCanvasMarkerPanelOpen ? (
setIsVideoCanvasMarkerPanelOpen(false)} selectedLabel={selectedVideoCanvasMarkerLabel} />
) : null} {/* 5. Floating Toolbar (Bottom) */}
{getCanvasToolbarItem(canvasToolbarConfig, "selectTool").label}{" "} (V) {!isVideoMode && isCanvasToolbarItemEnabled( canvasToolbarConfig, "addAgentImageSlot" ) ? ( { getCanvasToolbarItem( canvasToolbarConfig, "addAgentImageSlot" ).label } handleAddAgentImageSlotNode()} > 图片槽位 handleAddAgentSlotNode("video")} > handleAddAgentSlotNode("ui-screen")} > UI 槽位 ) : null} {getCanvasToolbarItem(canvasToolbarConfig, "panTool").label} (H) {isVideoMode ? ( { getCanvasToolbarItem(canvasToolbarConfig, "saveCanvas") .label } ) : null} {isVideoMode ? ( { getCanvasToolbarItem(canvasToolbarConfig, "cloudFiles") .label } ) : null} {isVideoMode ? ( { getCanvasToolbarItem(canvasToolbarConfig, "markerPanel") .label } ) : null} {isVideoMode ? ( { getCanvasToolbarItem( canvasToolbarConfig, "workflowConnection" ).label }{" "} (L) ) : null} {shouldShowStyleModeToolbarButton && !isVideoMode ? ( {getCanvasToolbarItem(canvasToolbarConfig, "styleMode").label} ) : null} {shouldShowStandaloneStyleProToolbarButton && !isVideoMode ? ( {getCanvasToolbarItem(canvasToolbarConfig, "stylePro").label} ) : null} {isBatchProcessingEnabled ? ( { getCanvasToolbarItem(canvasToolbarConfig, "batchProcessing") .label } ) : null} { getCanvasToolbarItem( canvasToolbarConfig, isVideoMode ? "addImageInput" : "addImage" ).label } { getCanvasToolbarItem( canvasToolbarConfig, isVideoMode ? "addScriptNode" : "addText" ).label } {isVideoMode ? ( { getCanvasToolbarItem(canvasToolbarConfig, "addVideoInput") .label } ) : null} {isVideoMode ? (
视频工作流节点库
这里按参考 APP 的节点目录整理,先把结构补齐,再逐步接入具体能力。
{VIDEO_WORKFLOW_LIBRARY_SECTIONS.map((section) => { const sectionNodes = getVideoWorkflowSectionNodes( section.id ); return (
{section.label}
{section.description}
{sectionNodes.map((nodeDefinition) => ( handleAddWorkflowNode(nodeDefinition.type) } >
{nodeDefinition.label} {nodeDefinition.type}
{nodeDefinition.description}
))}
); })}
) : null} {siteSettingsData?.realtimeCanvasEnabled === true && siteSettingsData?.realtimeCanvasServiceEnabled !== false ? ( { resolveCanvasToolbarItemWithFallback( canvasToolbarConfig, "realtimeCanvas", { iconUrl: realtimeCanvasModeDisplayConfig.realtimeIconUrl, label: realtimeCanvasModeDisplayConfig.realtimeLabel, } ).label } ) : null} {siteSettingsData?.realtimeCanvasEnabled === true && siteSettingsData?.realtimeCanvasServiceEnabled !== false ? ( {selectedImageNode ? resolveCanvasToolbarItemWithFallback( canvasToolbarConfig, "enhanceCanvas", { iconUrl: realtimeCanvasModeDisplayConfig.enhanceIconUrl, label: realtimeCanvasModeDisplayConfig.enhanceLabel, } ).label : `进入${ resolveCanvasToolbarItemWithFallback( canvasToolbarConfig, "enhanceCanvas", { iconUrl: realtimeCanvasModeDisplayConfig.enhanceIconUrl, label: realtimeCanvasModeDisplayConfig.enhanceLabel, } ).label }后上传图片`} ) : null} {getCanvasToolbarItem(canvasToolbarConfig, "model3d").label} {isVideoMode ? ( { getCanvasToolbarItem( canvasToolbarConfig, "exportVideoCanvas" ).label } ) : null} {isVideoMode ? ( { getCanvasToolbarItem( canvasToolbarConfig, "importVideoCanvas" ).label } ) : null} { getCanvasToolbarItem( canvasToolbarConfig, isVideoMode ? "newVideoCanvas" : "newCanvas" ).label }
{/* 6. Bottom Left Compact Controls */}
{getCanvasToolbarItem(canvasToolbarConfig, "undo").label} {getCanvasToolbarItem(canvasToolbarConfig, "redo").label}
{Math.round(viewport.zoom * 100)}%
{loginDialogElement} {projectLoadingBadge}
); }