From 7bc4e768b662b04d486ac403a1cddedc5e9a00d0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 18 Apr 2026 21:02:39 +0800 Subject: [PATCH] Improve LibTV video canvas replica --- .../[id]/CanvasEditorLegacyPageClient.tsx | 9969 ++++++++++++++++ app/canvas/[id]/CanvasPageClientRouter.tsx | 50 + app/canvas/[id]/page.tsx | 9977 +---------------- .../canvas/LiblibVideoCanvasReplica.tsx | 575 +- 4 files changed, 10564 insertions(+), 10007 deletions(-) create mode 100644 app/canvas/[id]/CanvasEditorLegacyPageClient.tsx create mode 100644 app/canvas/[id]/CanvasPageClientRouter.tsx diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx new file mode 100644 index 000000000..69bddd6c9 --- /dev/null +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -0,0 +1,9969 @@ +"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, + 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 { useParams, useRouter, useSearchParams } from "next/navigation"; +import { + type ChangeEvent, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { toast } from "sonner"; +import useSWR from "swr"; +import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark"; +import { AgentGenerationWorkspaceDarkV2 } from "@/components/agents/AgentGenerationWorkspaceDarkV2"; +import { AgentGenerationWorkspaceNew } from "@/components/agents/AgentGenerationWorkspaceNew"; +// 深色主题组件 V2 (新版布局) +import { AgentPresetPanelDarkV2 } from "@/components/agents/AgentPresetPanelDarkV2"; +import { AgentPresetPanelNew } from "@/components/agents/AgentPresetPanelNew"; +import { AgentProjectListSheet } from "@/components/agents/AgentProjectListSheet"; +import { AgentWorkflowBrandModelPicker } from "@/components/agents/AgentWorkflowBrandModelPicker"; +import { AgentWorkflowMaterialSelectionPanel } from "@/components/agents/AgentWorkflowMaterialSelectionPanel"; +import { + AgentWorkflowBrandStorePanel, + AgentWorkflowPanelDarkV2, +} from "@/components/agents/AgentWorkflowPanelDarkV2"; +import { + type AgentWorkflowInputs, + AgentWorkflowPanelNew, +} from "@/components/agents/AgentWorkflowPanelNew"; +import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel"; +import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel"; +import { ObjectPickerOverlay } from "@/components/canvas/ObjectPickerOverlay"; +import { SceneFusionDialog } from "@/components/scene-fusion-dialog"; +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, +} from "@/lib/agents/generation-project-state"; +import { + createEmptyAgentModeWorkspaceState, + normalizeAgentModeWorkspaceState, +} from "@/lib/agents/mode-workspace-state"; +import { normalizeAgentPresetModeConfig } from "@/lib/agents/preset-mode-config"; +import type { AgentPsdTemplateCategoryTagIndex } from "@/lib/agents/psd-template-category-tag-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 { + buildAgentWorkflowComposerContext, + getAgentWorkflowProgress, +} from "@/lib/agents/workflow-composer"; +import { + createEmptyAgentWorkflowMaterialSelections, + normalizeAgentWorkflowMaterialSelections, +} from "@/lib/agents/workflow-material-config"; +import { + defaultAgentWorkflowUiConfig, + flattenVisibleAgentWorkflowSteps, + getEnabledAgentWorkflowStepTree, + normalizeAgentWorkflowUiConfig, + sanitizeAgentWorkflowSelections, +} from "@/lib/agents/workflow-ui-config"; +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, + AgentGenerationProjectResultCollections, + AgentGenerationProjectWorkspaceAttachment, + AgentGenerationProjectWorkspaceState, + AgentGenerationResultModeType, + AgentModeWorkspaceState, + AgentPreset, + AgentWorkflowComposerContext, + AgentWorkflowMaterialSelections, + AgentWorkflowPersistedState, + AgentWorkflowSelectedBrandModel, + AgentWorkflowShowcaseConfig, + AgentWorkflowStepKey, +} from "@/types/agent"; +import type { + CanvasEdge, + CanvasNode, + CanvasState, + CanvasWorkspaceType, + Position, + ToolType, + WorkflowNodeData, +} from "@/types/canvas"; + +const CanvasWorkspace = dynamic( + () => import("@/components/canvas").then((module) => module.CanvasWorkspace), + { + loading: () => ( +
+ + 正在加载画布... +
+ ), + ssr: false, + } +); + +interface PageProps { + params: Promise<{ id: string }>; +} + +// 图片尺寸预设 +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; +} + +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 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, + }, + }; +} + +function hasMeaningfulWorkflowState( + workflowState: AgentWorkflowPersistedState | null | undefined +) { + if (!workflowState) return false; + + const inputs = workflowState.inputs; + const hasInputs = Boolean( + String(inputs?.primarySku || "").trim() || + String(inputs?.secondarySku || "").trim() || + String(inputs?.brand || "").trim() || + String(inputs?.store || "").trim() || + String(inputs?.thirdCategory || "").trim() || + String(inputs?.fourthCategory || "").trim() || + String(inputs?.fifthCategory || "").trim() || + String(inputs?.priceBand || "").trim() + ); + + const hasSelections = Object.values(workflowState.selections || {}).some( + (value) => Boolean(String(value || "").trim()) + ); + const hasBrandModel = Boolean( + String(workflowState.selectedBrandModel?.imageUrl || "").trim() + ); + const hasPreset = Boolean( + String(workflowState.selectedAgentPresetId || "").trim() + ); + + return hasInputs || hasSelections || hasBrandModel || hasPreset; +} + +const FLOATING_PANEL_WIDTH = 460; +const FLOATING_PANEL_INSET = 16; +const DOCKED_PANEL_INSET = 0; +const FLOATING_UI_GAP = 16; +const FLOATING_BOTTOM_INSET = 20; +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 替换", + }; +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)) + ) + ); +} + +const AGENT_PROJECT_DRAFT_STORAGE_KEY_PREFIX = "agent-project-draft:"; + +function getAgentProjectDraftStorageKey(projectId: string) { + return `${AGENT_PROJECT_DRAFT_STORAGE_KEY_PREFIX}${String(projectId || "").trim()}`; +} + +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 + ); +} + +export default function CanvasEditorLegacyPage({ 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 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 defaultCanvasTitle = isVideoMode ? "未命名视频画布" : "未命名画布"; + const workspaceLabel = isVideoMode ? "视频画布" : "画布"; + const createNewCanvasUrl = isVideoMode + ? "/video-canvas?new=1" + : "/canvas?new=1"; + 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 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); + + // Local state + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [editTitle, setEditTitle] = useState(""); + const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] = + useState(null); + const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode); + 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 [isVideoCanvasFileListOpen, setIsVideoCanvasFileListOpen] = + useState(false); + const [isVideoCanvasMarkerPanelOpen, setIsVideoCanvasMarkerPanelOpen] = + useState(false); + const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false); + // 面板模式: 'chat' 聊天面板, 'fileList' 文件列表 + const [panelMode, setPanelMode] = useState<"chat" | "fileList">("chat"); + const [viewportWidth, setViewportWidth] = useState(0); + const [workflowInputs, setWorkflowInputs] = useState({ + primarySku: "", + secondarySku: "", + brand: "", + store: "", + thirdCategory: "", + fourthCategory: "", + fifthCategory: "", + priceBand: "", + }); + const [workflowMaterials, setWorkflowMaterials] = + useState(() => + createEmptyAgentWorkflowMaterialSelections() + ); + const [workflowSelections, setWorkflowSelections] = useState< + Partial> + >({}); + + 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 [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 { data: siteSettingsData } = useSWR<{ + siteName?: string; + siteLogo?: string | null; + siteLogoDark?: string | null; + agentEnabled?: boolean; + canvasAgentModeEnabled?: boolean; + videoCanvasSidebarEnabled?: boolean; + layerSplitEnabled?: boolean; + realtimeCanvasEnabled?: boolean; + realtimeCanvasServiceEnabled?: boolean; + agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig; + agentWorkflowBrandModelConfig?: unknown; + agentPsdTemplateCategoryTagIndex?: AgentPsdTemplateCategoryTagIndex; + agentWorkflowUiConfig?: { + steps?: Array<{ + key: AgentWorkflowStepKey; + title: string; + description?: string | null; + enabled?: boolean; + sortOrder?: number; + options?: Array<{ + key: string; + buttonName: string; + description?: string | null; + previewImageUrl?: string | null; + enabled?: boolean; + sortOrder?: number; + promptFragment?: string | null; + }>; + }>; + }; + }>("/api/site-settings", 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 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 isAgentMode = isAgentWorkspaceEnabled && requestedAgentMode; + const hasLoadedCurrentAgentProject = + !requestedAgentMode || + !agentProjectId || + loadedAgentProjectId === agentProjectId; + const resolvedWorkflowUiConfig = useMemo( + () => + normalizeAgentWorkflowUiConfig(siteSettingsData?.agentWorkflowUiConfig), + [siteSettingsData?.agentWorkflowUiConfig] + ); + const sanitizedWorkflowSelections = useMemo( + () => + sanitizeAgentWorkflowSelections({ + config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, + selections: workflowSelections, + }), + [resolvedWorkflowUiConfig, workflowSelections] + ); + const resolvedBrandModelConfig = useMemo( + () => + normalizeAgentWorkflowBrandModelConfig( + siteSettingsData?.agentWorkflowBrandModelConfig + ), + [siteSettingsData?.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( + FLOATING_PANEL_WIDTH, + Math.max(viewportWidth - DOCKED_PANEL_INSET, 0) + ) + : FLOATING_PANEL_WIDTH; + const isFloatingSidebarVisible = !isVideoMode && isPanelOpen; + const panelOcclusionRight = isFloatingSidebarVisible + ? resolvedPanelWidth + DOCKED_PANEL_INSET + : 0; + const floatingToolbarOffset = isFloatingSidebarVisible + ? -(panelOcclusionRight / 2) + : 0; + const compactControlsLeft = FLOATING_UI_GAP; + + 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 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(), + 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, + ]); + const currentWorkflowPersistedStateRef = useRef( + buildCurrentWorkflowPersistedState() + ); + + useEffect(() => { + currentWorkflowPersistedStateRef.current = + buildCurrentWorkflowPersistedState(); + }, [buildCurrentWorkflowPersistedState]); + + 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]); + + // 加载画布数据 + useLayoutEffect(() => { + console.log("[Canvas] Loading project:", id); + + const loadProject = async () => { + const preservedWorkflowState = currentWorkflowPersistedStateRef.current; + const shouldPreserveCurrentWorkflowState = + requestedAgentMode && + lastLoadedCanvasIdRef.current === id && + hasMeaningfulWorkflowState(preservedWorkflowState); + + setIsLoading(true); + setWorkflowHydrationSource("pending"); + if (!shouldPreserveCurrentWorkflowState) { + setWorkflowInputs({ + primarySku: "", + secondarySku: "", + brand: "", + store: "", + thirdCategory: "", + fourthCategory: "", + fifthCategory: "", + priceBand: "", + }); + setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections()); + setWorkflowSelections({}); + setSelectedBrandModel(null); + setSelectedAgentPresetId(null); + } + setAgentModeState(createEmptyAgentModeWorkspaceState()); + setAgentWorkspaceState(createEmptyAgentGenerationProjectWorkspaceState()); + setAgentProjectTitle("未命名生图项目"); + setAgentProjectThumbnail(null); + setAgentProjectResultCollections({}); + setAgentProjectDeletedResultCollections({}); + setLoadedAgentProjectId(null); + + // 🆕 先清空当前画布状态,确保切换时不会显示旧数据 + useCanvasStore.getState().clearCanvas(); + + const result = await canvasService.getProject(id); + + if (result.success && result.data) { + useCanvasStore.getState().setProject(id, result.data.title); + 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(), + 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 if (requestedAgentMode && shouldPreserveCurrentWorkflowState) { + 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 (!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 (shouldOpenBatchProcessing) { + setSceneFusionDialogOpen(true); + } + }, [shouldOpenBatchProcessing]); + + const workflowStorageKey = useMemo(() => `canvas-workflow-state:${id}`, [id]); + const buildPersistedCanvasData = useCallback(() => { + return { + ...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 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(), + 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, + 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 + ), + }, + 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([ + ...normalizeResultCollectionUrls( + agentProjectResultCollections.scene_generation + ), + ...normalizeResultCollectionUrls( + agentProjectResultCollections.detail_generation + ), + ...normalizeResultCollectionUrls( + agentProjectResultCollections.detail_replacement + ), + ...normalizeResultCollectionUrls( + agentProjectResultCollections.sku_replacement + ), + ]) + ), + }; + }, [ + agentModeState, + agentProjectDeletedResultCollections, + agentProjectResultCollections, + agentWorkspaceState, + currentAgentStage, + appearanceRecognitionEnabled, + selectedAgentPresetId, + selectedBrandModel, + workflowInputs, + workflowMaterials, + sanitizedWorkflowSelections, + ]); + const persistedAgentProjectStateRef = useRef( + buildPersistedAgentProjectState() + ); + + useEffect(() => { + persistedAgentProjectStateRef.current = buildPersistedAgentProjectState(); + }, [buildPersistedAgentProjectState]); + + 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(), + 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 || {} + ); + 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(), + 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; + + 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; + + 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); + } + }, [ + agentModeState, + agentProjectDeletedResultCollections, + agentProjectId, + agentProjectResultCollections, + agentProjectThumbnail, + agentProjectTitle, + agentWorkspaceState, + appearanceRecognitionEnabled, + buildPersistedAgentProjectState, + currentAgentStage, + hasLoadedCurrentAgentProject, + isAgentMode, + selectedAgentPresetId, + selectedBrandModel, + workflowInputs, + workflowMaterials, + workflowSelections, + ]); + + 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: 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); + try { + const localDraft = readAgentProjectDraftSnapshot(agentProjectId); + 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 shouldUseLocalDraft = + Boolean(localDraft?.projectState) && + Number(localDraft?.savedAt || 0) > serverUpdatedAt; + + 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) { + 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 || + !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, + 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 (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, + }); + }, [ + agentProjectId, + 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); + setAgentProjectResultCollections((current) => { + const nextCollections: AgentGenerationProjectResultCollections = { + ...current, + [modeType]: normalizedUrls, + }; + return areResultCollectionsEqual(current, nextCollections) + ? current + : nextCollections; + }); + setAgentProjectThumbnail((current) => { + const nextThumbnail = + normalizedUrls[0] || + getFirstResultUrlFromCollections({ + ...agentProjectResultCollections, + [modeType]: normalizedUrls, + }); + return current === nextThumbnail ? current : nextThumbnail; + }); + }, + [agentProjectResultCollections] + ); + + 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 + ); + setAgentProjectThumbnail((current) => { + const nextThumbnail = + current === normalizedUrl + ? getFirstResultUrlFromCollections(nextCollections) + : current; + return current === nextThumbnail ? current : nextThumbnail; + }); + }, + [agentProjectDeletedResultCollections, agentProjectResultCollections] + ); + + 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 result = await canvasService.saveCanvasState( + id, + canvasData, + activeCanvasWorkspaceType + ); + if (!result.success) { + console.error(`[Canvas] ${reason} save failed:`, result.error); + } + return result.success; + }, + [activeCanvasWorkspaceType, buildPersistedCanvasData, id] + ); + + // 自动保存逻辑 + useEffect(() => { + if (isLoading || isAgentMode) 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, nodes, isLoading, persistCanvasState]); + + // 删除节点时立即保存(按节点ID差集检测),避免删除后快速刷新被回滚 + useEffect(() => { + if (isLoading || isAgentMode) { + 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, nodes, isLoading, persistCanvasState]); + + // 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率 + useEffect(() => { + if (isLoading || isAgentMode) return; + + const handleBeforeUnload = () => { + try { + const canvasData = buildPersistedCanvasData(); + void fetch(`/api/canvas/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + canvasData, + workspaceType: activeCanvasWorkspaceType, + }), + 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, + 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 || [], + }; + + 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 handleAddImageNode = useCallback(() => { + if (isVideoMode) { + handleAddWorkflowNode("input-image"); + return; + } + + createCanvasNode({ + type: "image", + position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 }, + size: { width: 300, height: 300 }, + data: { + src: "", + status: "pending", + title: "新图片", + prompt: "", + }, + connections: [], + }); + }, [createCanvasNode, handleAddWorkflowNode, isVideoMode]); + + 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 handleBatchAddToCanvas = useCallback( + (imageUrls: string[]) => { + if (!imageUrls || imageUrls.length === 0) { + toast.error("没有可添加到画布的图片"); + return; + } + + const columns = Math.min(4, imageUrls.length); + const startX = 120; + const startY = 120; + const createdNodeIds: string[] = []; + + for (const [index, imageUrl] of imageUrls.entries()) { + const x = startX + (index % columns) * 320; + const y = startY + Math.floor(index / columns) * 320; + const nodeId = addNode({ + type: "image", + position: { x, y }, + size: { width: 300, height: 300 }, + data: { + src: imageUrl, + status: "completed", + title: `批量结果-${index + 1}`, + prompt: "批量处理结果", + }, + connections: [], + }); + createdNodeIds.push(nodeId); + } + + if (createdNodeIds.length > 0) { + selectNodes(createdNodeIds); + } + }, + [addNode, selectNodes] + ); + + // 处理图片操作(通过事件分发到选中图片节点执行) + 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) => { + // 如果正在输入则不触发快捷键(包括 input、textarea 和 contentEditable 元素) + const target = e.target as HTMLElement; + 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; + } + } + if ( + target instanceof HTMLTextAreaElement || + target.isContentEditable || + target.closest('[contenteditable="true"]') + ) { + 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, + isVideoMode, + nodes, + redo, + selectNodes, + selectedNodeIds, + setTool, + undo, + ]); + + 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 = useMemo(() => { + return buildAgentWorkflowComposerContext({ + config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, + inputs: workflowInputs, + materials: workflowMaterials, + selectedBrandModel, + appearanceRecognitionEnabled, + selections: sanitizedWorkflowSelections, + }); + }, [ + appearanceRecognitionEnabled, + resolvedWorkflowUiConfig, + selectedBrandModel, + sanitizedWorkflowSelections, + 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; + setWorkflowInputs(next); + }, + [] + ); + const handleWorkflowMaterialsChange = useCallback( + (next: AgentWorkflowMaterialSelections) => { + const nextMaterials = appearanceRecognitionEnabledRef.current + ? next + : clearAppearanceSummaryFromMaterials(next); + workflowMaterialsRef.current = nextMaterials; + setWorkflowMaterials(nextMaterials); + }, + [] + ); + const handleAppearanceRecognitionEnabledChange = useCallback( + (next: boolean) => { + appearanceRecognitionEnabledRef.current = next; + setAppearanceRecognitionEnabled(next); + + if (!next) { + const clearedMaterials = clearAppearanceSummaryFromMaterials( + workflowMaterialsRef.current + ); + workflowMaterialsRef.current = clearedMaterials; + setWorkflowMaterials(clearedMaterials); + } + }, + [] + ); + 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] || "") + ); + }); + + let clearedSelections = { ...normalizedNextSelections }; + if (earliestChangedIndex >= 0) { + orderedStepKeys.slice(earliestChangedIndex + 1).forEach((stepKey) => { + delete clearedSelections[stepKey]; + }); + } + + clearedSelections = sanitizeAgentWorkflowSelections({ + config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, + selections: clearedSelections, + }); + + workflowSelectionsRef.current = clearedSelections; + setWorkflowSelections(clearedSelections); + }, + [resolvedWorkflowUiConfig] + ); + const handleSelectedBrandModelChange = useCallback( + (next: AgentWorkflowSelectedBrandModel | null) => { + selectedBrandModelRef.current = next; + setSelectedBrandModel(next); + }, + [] + ); + 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 { data: selectedAgentPresetResponse } = useSWR<{ + success?: boolean; + data?: AgentPreset; + }>( + selectedAgentPresetId ? `/api/agents/${selectedAgentPresetId}` : 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 workflowProgress = useMemo( + () => + getAgentWorkflowProgress({ + config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, + selections: sanitizedWorkflowSelections, + }), + [resolvedWorkflowUiConfig, sanitizedWorkflowSelections] + ); + const resolvedAgentProjectTitle = hasActiveAgentProject + ? String(agentProjectTitle || "").trim() || "未命名生图项目" + : "未选择项目"; + + if (isLoading) { + return ( +
+
+ + 加载中... +
+
+ ); + } + + 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); + }} + > + + 项目列表 + + + + +
+ ) : null; + + const loginDialogElement = ( + + + + 登录已失效 + {loginDialogMessage} + + + + + + + + ); + + const agentProjectSelectionScreen = + isAgentMode && !hasActiveAgentProject ? ( +
+ +
+
+
+
+ +
+

+ 先选择一个生图项目 +

+

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

+
+ +
+ + + +
+
+
+
+ ) : null; + + if (isAgentMode) { + if (!hasActiveAgentProject) { + return ( + <> + {agentProjectSelectionScreen} + {loginDialogElement} + + ); + } + + return ( + <> + + } + brandStorePanel={ + + } + chatId={agentModeChatId} + deletedResultCollections={agentProjectDeletedResultCollections} + getLatestWorkflowContext={getLatestWorkflowContext} + materialSelectionPanel={ + + } + onAgentModeStateChange={setAgentModeState} + onClearPreset={() => setSelectedAgentPresetId(null)} + onDeleteResultImage={handleAgentResultImageDelete} + onOpenCanvasEditor={() => { + updateCanvasSearchParams({ + mode: null, + }); + }} + onResultPreviewUrlsChange={handleAgentResultPreviewUrlsChange} + onWorkspaceStateChange={setAgentWorkspaceState} + presetPanel={ + + } + projectId={agentProjectId} + resultCollections={agentProjectResultCollections} + selectedAgentPresetId={ + isAgentWorkspaceEnabled ? selectedAgentPresetId : null + } + selectedBrandModel={selectedBrandModel} + showcaseConfig={siteSettingsData?.agentWorkflowShowcaseConfig} + siteLogo={siteSettingsData?.siteLogo} + siteLogoDark={siteSettingsData?.siteLogoDark} + siteName={siteSettingsData?.siteName} + syncGeneratedResultsToCanvas={false} + topNavRightSlot={agentProjectNavSlot} + workflowContext={workflowContext} + // 嵌入的风格化选择面板 + workflowPromptOptimizationEnabled={appearanceRecognitionEnabled} + workflowStylePanel={ + + } + workspaceState={agentWorkspaceState} + /> + {loginDialogElement} + + ); + } + + 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 : ( +
+
+ {panelMode === "chat" ? ( + // AI 对话内容 - 使用 key 强制在 id 变化时重新挂载 +
+ setIsPanelOpen(false)} + onOpenFileList={() => setPanelMode("fileList")} + 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) */} +
+
+ + + + + 选择 (V) + + + + + + + 平移 (H) + + + {isVideoMode ? ( + + + + + 保存到云端 + + ) : null} + + {isVideoMode ? ( + + + + + 云文件列表 + + ) : null} + + {isVideoMode ? ( + + + + + 标记 + + ) : null} + + {isVideoMode ? ( + + + + + 工作流连线 (L) + + ) : null} + + {isAgentWorkspaceEnabled && !isVideoMode ? ( + + + + + 风格化模式 + + ) : null} + + + + + + 批量处理 + + + + + + + + {isVideoMode ? "添加图片输入" : "添加图片"} + + + + + + + + + {isVideoMode ? "添加脚本节点" : "添加文本"} + + + + {isVideoMode ? ( + + + + + 添加视频输入 + + ) : 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 ? ( + + + + + 实时画布 + + ) : null} + + {siteSettingsData?.realtimeCanvasEnabled === true && + siteSettingsData?.realtimeCanvasServiceEnabled !== false ? ( + + + + + + {selectedImageNode + ? "图像增强 Pro" + : "进入图像增强 Pro 后上传图片"} + + + ) : null} + + + + + + 3D生成 + + + {isVideoMode ? ( + + + + + 导出视频画布 + + ) : null} + + {isVideoMode ? ( + + + + + 导入视频画布 + + ) : null} + + + + + + 新建{workspaceLabel} + +
+
+ + + + {/* 6. Bottom Left Compact Controls */} +
+
+ + + + + 撤销 + + + + + + + 重做 + + +
+ + + + {Math.round(viewport.zoom * 100)}% + + +
+
+ + {/* 7. 物品拾取模式覆盖层 */} + + + {loginDialogElement} +
+ + ); +} diff --git a/app/canvas/[id]/CanvasPageClientRouter.tsx b/app/canvas/[id]/CanvasPageClientRouter.tsx new file mode 100644 index 000000000..2ab577fc7 --- /dev/null +++ b/app/canvas/[id]/CanvasPageClientRouter.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import dynamic from "next/dynamic"; +import { useParams, useSearchParams } from "next/navigation"; +import { useMemo } from "react"; + +const CanvasEditorLegacyPage = dynamic(() => import("./CanvasEditorLegacyPageClient"), { + loading: () => , + ssr: false, +}); + +const LiblibVideoCanvasReplica = dynamic( + () => import("@/components/canvas/LiblibVideoCanvasReplica").then((module) => module.LiblibVideoCanvasReplica), + { + loading: () => , + ssr: false, + } +); + +export function CanvasPageClientRouter({ initialId }: { initialId: string }) { + const routeParams = useParams(); + const searchParams = useSearchParams(); + const routeId = routeParams?.id; + const id = Array.isArray(routeId) ? routeId[0] || initialId : typeof routeId === "string" ? routeId : initialId; + const mode = searchParams.get("mode"); + const requestedWorkspaceType = searchParams.get("workspaceType"); + const requestedAgentMode = mode === "agent"; + const isVideoMode = !requestedAgentMode && (mode === "video" || requestedWorkspaceType === "video"); + const legacyParams = useMemo(() => Promise.resolve({ id }), [id]); + + if (!id) { + return ; + } + + if (isVideoMode) { + return ; + } + + return ; +} + +function CanvasRouteLoader({ label }: { label: string }) { + return ( +
+ + {label} +
+ ); +} diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index 53e604ea7..2f24e9fb0 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -1,9975 +1,20 @@ -"use client"; +import { Loader2 } from "lucide-react"; +import { Suspense } from "react"; +import { CanvasPageClientRouter } from "./CanvasPageClientRouter"; -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, - 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 { useParams, useRouter, useSearchParams } from "next/navigation"; -import { - type ChangeEvent, - use, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; -import { toast } from "sonner"; -import useSWR from "swr"; -import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark"; -import { AgentGenerationWorkspaceDarkV2 } from "@/components/agents/AgentGenerationWorkspaceDarkV2"; -import { AgentGenerationWorkspaceNew } from "@/components/agents/AgentGenerationWorkspaceNew"; -// 深色主题组件 V2 (新版布局) -import { AgentPresetPanelDarkV2 } from "@/components/agents/AgentPresetPanelDarkV2"; -import { AgentPresetPanelNew } from "@/components/agents/AgentPresetPanelNew"; -import { AgentProjectListSheet } from "@/components/agents/AgentProjectListSheet"; -import { AgentWorkflowBrandModelPicker } from "@/components/agents/AgentWorkflowBrandModelPicker"; -import { AgentWorkflowMaterialSelectionPanel } from "@/components/agents/AgentWorkflowMaterialSelectionPanel"; -import { - AgentWorkflowBrandStorePanel, - AgentWorkflowPanelDarkV2, -} from "@/components/agents/AgentWorkflowPanelDarkV2"; -import { - type AgentWorkflowInputs, - AgentWorkflowPanelNew, -} from "@/components/agents/AgentWorkflowPanelNew"; -import { CanvasWorkspace } from "@/components/canvas"; -import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel"; -import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel"; -import { LiblibVideoCanvasReplica } from "@/components/canvas/LiblibVideoCanvasReplica"; -import { ObjectPickerOverlay } from "@/components/canvas/ObjectPickerOverlay"; -import { SceneFusionDialog } from "@/components/scene-fusion-dialog"; -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, -} from "@/lib/agents/generation-project-state"; -import { - createEmptyAgentModeWorkspaceState, - normalizeAgentModeWorkspaceState, -} from "@/lib/agents/mode-workspace-state"; -import { normalizeAgentPresetModeConfig } from "@/lib/agents/preset-mode-config"; -import type { AgentPsdTemplateCategoryTagIndex } from "@/lib/agents/psd-template-category-tag-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 { - buildAgentWorkflowComposerContext, - getAgentWorkflowProgress, -} from "@/lib/agents/workflow-composer"; -import { - createEmptyAgentWorkflowMaterialSelections, - normalizeAgentWorkflowMaterialSelections, -} from "@/lib/agents/workflow-material-config"; -import { - defaultAgentWorkflowUiConfig, - flattenVisibleAgentWorkflowSteps, - getEnabledAgentWorkflowStepTree, - normalizeAgentWorkflowUiConfig, - sanitizeAgentWorkflowSelections, -} from "@/lib/agents/workflow-ui-config"; -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, - AgentGenerationProjectResultCollections, - AgentGenerationProjectWorkspaceAttachment, - AgentGenerationProjectWorkspaceState, - AgentGenerationResultModeType, - AgentModeWorkspaceState, - AgentPreset, - AgentWorkflowComposerContext, - AgentWorkflowMaterialSelections, - AgentWorkflowPersistedState, - AgentWorkflowSelectedBrandModel, - AgentWorkflowShowcaseConfig, - AgentWorkflowStepKey, -} from "@/types/agent"; -import type { - CanvasEdge, - CanvasNode, - CanvasState, - CanvasWorkspaceType, - Position, - ToolType, - WorkflowNodeData, -} from "@/types/canvas"; - -interface PageProps { - params: Promise<{ id: string }>; -} - -// 图片尺寸预设 -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; -} - -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 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) { +export default function CanvasEditorPage() { 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, - }, - }; -} - -function hasMeaningfulWorkflowState( - workflowState: AgentWorkflowPersistedState | null | undefined -) { - if (!workflowState) return false; - - const inputs = workflowState.inputs; - const hasInputs = Boolean( - String(inputs?.primarySku || "").trim() || - String(inputs?.secondarySku || "").trim() || - String(inputs?.brand || "").trim() || - String(inputs?.store || "").trim() || - String(inputs?.thirdCategory || "").trim() || - String(inputs?.fourthCategory || "").trim() || - String(inputs?.fifthCategory || "").trim() || - String(inputs?.priceBand || "").trim() - ); - - const hasSelections = Object.values(workflowState.selections || {}).some( - (value) => Boolean(String(value || "").trim()) - ); - const hasBrandModel = Boolean( - String(workflowState.selectedBrandModel?.imageUrl || "").trim() - ); - const hasPreset = Boolean( - String(workflowState.selectedAgentPresetId || "").trim() - ); - - return hasInputs || hasSelections || hasBrandModel || hasPreset; -} - -const FLOATING_PANEL_WIDTH = 460; -const FLOATING_PANEL_INSET = 16; -const DOCKED_PANEL_INSET = 0; -const FLOATING_UI_GAP = 16; -const FLOATING_BOTTOM_INSET = 20; -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 替换", - }; -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; - +function CanvasPageLoader() { 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)) - ) - ); -} - -const AGENT_PROJECT_DRAFT_STORAGE_KEY_PREFIX = "agent-project-draft:"; - -function getAgentProjectDraftStorageKey(projectId: string) { - return `${AGENT_PROJECT_DRAFT_STORAGE_KEY_PREFIX}${String(projectId || "").trim()}`; -} - -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 - ); -} - -export default function CanvasEditorPage({ params }: PageProps) { - const routeParams = useParams(); - const searchParams = useSearchParams(); - const id = (routeParams?.id as string) || use(params).id; - const mode = searchParams.get("mode"); - const requestedWorkspaceType = searchParams.get("workspaceType"); - const requestedAgentMode = mode === "agent"; - const isVideoMode = - !requestedAgentMode && - (mode === "video" || requestedWorkspaceType === "video"); - - if (isVideoMode) { - return ; - } - - return ; -} - -function CanvasEditorLegacyPage({ params }: PageProps) { - // 使用 useParams 确保客户端导航时能正确获取更新的 id - const routeParams = useParams(); - const searchParams = useSearchParams(); - const id = (routeParams?.id as string) || use(params).id; - const router = useRouter(); - 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 defaultCanvasTitle = isVideoMode ? "未命名视频画布" : "未命名画布"; - const workspaceLabel = isVideoMode ? "视频画布" : "画布"; - const createNewCanvasUrl = isVideoMode - ? "/video-canvas?new=1" - : "/canvas?new=1"; - 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 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); - - // Local state - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - const [isEditingTitle, setIsEditingTitle] = useState(false); - const [editTitle, setEditTitle] = useState(""); - const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] = - useState(null); - const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode); - 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 [isVideoCanvasFileListOpen, setIsVideoCanvasFileListOpen] = - useState(false); - const [isVideoCanvasMarkerPanelOpen, setIsVideoCanvasMarkerPanelOpen] = - useState(false); - const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false); - // 面板模式: 'chat' 聊天面板, 'fileList' 文件列表 - const [panelMode, setPanelMode] = useState<"chat" | "fileList">("chat"); - const [viewportWidth, setViewportWidth] = useState(0); - const [workflowInputs, setWorkflowInputs] = useState({ - primarySku: "", - secondarySku: "", - brand: "", - store: "", - thirdCategory: "", - fourthCategory: "", - fifthCategory: "", - priceBand: "", - }); - const [workflowMaterials, setWorkflowMaterials] = - useState(() => - createEmptyAgentWorkflowMaterialSelections() - ); - const [workflowSelections, setWorkflowSelections] = useState< - Partial> - >({}); - - 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 [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 { data: siteSettingsData } = useSWR<{ - siteName?: string; - siteLogo?: string | null; - siteLogoDark?: string | null; - agentEnabled?: boolean; - canvasAgentModeEnabled?: boolean; - videoCanvasSidebarEnabled?: boolean; - layerSplitEnabled?: boolean; - realtimeCanvasEnabled?: boolean; - realtimeCanvasServiceEnabled?: boolean; - agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig; - agentWorkflowBrandModelConfig?: unknown; - agentPsdTemplateCategoryTagIndex?: AgentPsdTemplateCategoryTagIndex; - agentWorkflowUiConfig?: { - steps?: Array<{ - key: AgentWorkflowStepKey; - title: string; - description?: string | null; - enabled?: boolean; - sortOrder?: number; - options?: Array<{ - key: string; - buttonName: string; - description?: string | null; - previewImageUrl?: string | null; - enabled?: boolean; - sortOrder?: number; - promptFragment?: string | null; - }>; - }>; - }; - }>("/api/site-settings", 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 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 isAgentMode = isAgentWorkspaceEnabled && requestedAgentMode; - const hasLoadedCurrentAgentProject = - !requestedAgentMode || - !agentProjectId || - loadedAgentProjectId === agentProjectId; - const resolvedWorkflowUiConfig = useMemo( - () => - normalizeAgentWorkflowUiConfig(siteSettingsData?.agentWorkflowUiConfig), - [siteSettingsData?.agentWorkflowUiConfig] - ); - const sanitizedWorkflowSelections = useMemo( - () => - sanitizeAgentWorkflowSelections({ - config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, - selections: workflowSelections, - }), - [resolvedWorkflowUiConfig, workflowSelections] - ); - const resolvedBrandModelConfig = useMemo( - () => - normalizeAgentWorkflowBrandModelConfig( - siteSettingsData?.agentWorkflowBrandModelConfig - ), - [siteSettingsData?.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( - FLOATING_PANEL_WIDTH, - Math.max(viewportWidth - DOCKED_PANEL_INSET, 0) - ) - : FLOATING_PANEL_WIDTH; - const isFloatingSidebarVisible = !isVideoMode && isPanelOpen; - const panelOcclusionRight = isFloatingSidebarVisible - ? resolvedPanelWidth + DOCKED_PANEL_INSET - : 0; - const floatingToolbarOffset = isFloatingSidebarVisible - ? -(panelOcclusionRight / 2) - : 0; - const compactControlsLeft = FLOATING_UI_GAP; - - 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 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(), - 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, - ]); - const currentWorkflowPersistedStateRef = useRef( - buildCurrentWorkflowPersistedState() - ); - - useEffect(() => { - currentWorkflowPersistedStateRef.current = - buildCurrentWorkflowPersistedState(); - }, [buildCurrentWorkflowPersistedState]); - - 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]); - - // 加载画布数据 - useLayoutEffect(() => { - console.log("[Canvas] Loading project:", id); - - const loadProject = async () => { - const preservedWorkflowState = currentWorkflowPersistedStateRef.current; - const shouldPreserveCurrentWorkflowState = - requestedAgentMode && - lastLoadedCanvasIdRef.current === id && - hasMeaningfulWorkflowState(preservedWorkflowState); - - setIsLoading(true); - setWorkflowHydrationSource("pending"); - if (!shouldPreserveCurrentWorkflowState) { - setWorkflowInputs({ - primarySku: "", - secondarySku: "", - brand: "", - store: "", - thirdCategory: "", - fourthCategory: "", - fifthCategory: "", - priceBand: "", - }); - setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections()); - setWorkflowSelections({}); - setSelectedBrandModel(null); - setSelectedAgentPresetId(null); - } - setAgentModeState(createEmptyAgentModeWorkspaceState()); - setAgentWorkspaceState(createEmptyAgentGenerationProjectWorkspaceState()); - setAgentProjectTitle("未命名生图项目"); - setAgentProjectThumbnail(null); - setAgentProjectResultCollections({}); - setAgentProjectDeletedResultCollections({}); - setLoadedAgentProjectId(null); - - // 🆕 先清空当前画布状态,确保切换时不会显示旧数据 - useCanvasStore.getState().clearCanvas(); - - const result = await canvasService.getProject(id); - - if (result.success && result.data) { - useCanvasStore.getState().setProject(id, result.data.title); - 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(), - 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 if (requestedAgentMode && shouldPreserveCurrentWorkflowState) { - 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 (!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 (shouldOpenBatchProcessing) { - setSceneFusionDialogOpen(true); - } - }, [shouldOpenBatchProcessing]); - - const workflowStorageKey = useMemo(() => `canvas-workflow-state:${id}`, [id]); - const buildPersistedCanvasData = useCallback(() => { - return { - ...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 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(), - 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, - 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 - ), - }, - 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([ - ...normalizeResultCollectionUrls( - agentProjectResultCollections.scene_generation - ), - ...normalizeResultCollectionUrls( - agentProjectResultCollections.detail_generation - ), - ...normalizeResultCollectionUrls( - agentProjectResultCollections.detail_replacement - ), - ...normalizeResultCollectionUrls( - agentProjectResultCollections.sku_replacement - ), - ]) - ), - }; - }, [ - agentModeState, - agentProjectDeletedResultCollections, - agentProjectResultCollections, - agentWorkspaceState, - currentAgentStage, - appearanceRecognitionEnabled, - selectedAgentPresetId, - selectedBrandModel, - workflowInputs, - workflowMaterials, - sanitizedWorkflowSelections, - ]); - const persistedAgentProjectStateRef = useRef( - buildPersistedAgentProjectState() - ); - - useEffect(() => { - persistedAgentProjectStateRef.current = buildPersistedAgentProjectState(); - }, [buildPersistedAgentProjectState]); - - 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(), - 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 || {} - ); - 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(), - 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; - - 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; - - 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); - } - }, [ - agentModeState, - agentProjectDeletedResultCollections, - agentProjectId, - agentProjectResultCollections, - agentProjectThumbnail, - agentProjectTitle, - agentWorkspaceState, - appearanceRecognitionEnabled, - buildPersistedAgentProjectState, - currentAgentStage, - hasLoadedCurrentAgentProject, - isAgentMode, - selectedAgentPresetId, - selectedBrandModel, - workflowInputs, - workflowMaterials, - workflowSelections, - ]); - - 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: 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); - try { - const localDraft = readAgentProjectDraftSnapshot(agentProjectId); - 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 shouldUseLocalDraft = - Boolean(localDraft?.projectState) && - Number(localDraft?.savedAt || 0) > serverUpdatedAt; - - 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) { - 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 || - !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, - 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 (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, - }); - }, [ - agentProjectId, - 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); - setAgentProjectResultCollections((current) => { - const nextCollections: AgentGenerationProjectResultCollections = { - ...current, - [modeType]: normalizedUrls, - }; - return areResultCollectionsEqual(current, nextCollections) - ? current - : nextCollections; - }); - setAgentProjectThumbnail((current) => { - const nextThumbnail = - normalizedUrls[0] || - getFirstResultUrlFromCollections({ - ...agentProjectResultCollections, - [modeType]: normalizedUrls, - }); - return current === nextThumbnail ? current : nextThumbnail; - }); - }, - [agentProjectResultCollections] - ); - - 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 - ); - setAgentProjectThumbnail((current) => { - const nextThumbnail = - current === normalizedUrl - ? getFirstResultUrlFromCollections(nextCollections) - : current; - return current === nextThumbnail ? current : nextThumbnail; - }); - }, - [agentProjectDeletedResultCollections, agentProjectResultCollections] - ); - - 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 result = await canvasService.saveCanvasState( - id, - canvasData, - activeCanvasWorkspaceType - ); - if (!result.success) { - console.error(`[Canvas] ${reason} save failed:`, result.error); - } - return result.success; - }, - [activeCanvasWorkspaceType, buildPersistedCanvasData, id] - ); - - // 自动保存逻辑 - useEffect(() => { - if (isLoading || isAgentMode) 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, nodes, isLoading, persistCanvasState]); - - // 删除节点时立即保存(按节点ID差集检测),避免删除后快速刷新被回滚 - useEffect(() => { - if (isLoading || isAgentMode) { - 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, nodes, isLoading, persistCanvasState]); - - // 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率 - useEffect(() => { - if (isLoading || isAgentMode) return; - - const handleBeforeUnload = () => { - try { - const canvasData = buildPersistedCanvasData(); - void fetch(`/api/canvas/${id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - canvasData, - workspaceType: activeCanvasWorkspaceType, - }), - 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, - 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 || [], - }; - - 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 handleAddImageNode = useCallback(() => { - if (isVideoMode) { - handleAddWorkflowNode("input-image"); - return; - } - - createCanvasNode({ - type: "image", - position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 }, - size: { width: 300, height: 300 }, - data: { - src: "", - status: "pending", - title: "新图片", - prompt: "", - }, - connections: [], - }); - }, [createCanvasNode, handleAddWorkflowNode, isVideoMode]); - - 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 handleBatchAddToCanvas = useCallback( - (imageUrls: string[]) => { - if (!imageUrls || imageUrls.length === 0) { - toast.error("没有可添加到画布的图片"); - return; - } - - const columns = Math.min(4, imageUrls.length); - const startX = 120; - const startY = 120; - const createdNodeIds: string[] = []; - - for (const [index, imageUrl] of imageUrls.entries()) { - const x = startX + (index % columns) * 320; - const y = startY + Math.floor(index / columns) * 320; - const nodeId = addNode({ - type: "image", - position: { x, y }, - size: { width: 300, height: 300 }, - data: { - src: imageUrl, - status: "completed", - title: `批量结果-${index + 1}`, - prompt: "批量处理结果", - }, - connections: [], - }); - createdNodeIds.push(nodeId); - } - - if (createdNodeIds.length > 0) { - selectNodes(createdNodeIds); - } - }, - [addNode, selectNodes] - ); - - // 处理图片操作(通过事件分发到选中图片节点执行) - 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) => { - // 如果正在输入则不触发快捷键(包括 input、textarea 和 contentEditable 元素) - const target = e.target as HTMLElement; - 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; - } - } - if ( - target instanceof HTMLTextAreaElement || - target.isContentEditable || - target.closest('[contenteditable="true"]') - ) { - 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, - isVideoMode, - nodes, - redo, - selectNodes, - selectedNodeIds, - setTool, - undo, - ]); - - 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 = useMemo(() => { - return buildAgentWorkflowComposerContext({ - config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, - inputs: workflowInputs, - materials: workflowMaterials, - selectedBrandModel, - appearanceRecognitionEnabled, - selections: sanitizedWorkflowSelections, - }); - }, [ - appearanceRecognitionEnabled, - resolvedWorkflowUiConfig, - selectedBrandModel, - sanitizedWorkflowSelections, - 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; - setWorkflowInputs(next); - }, - [] - ); - const handleWorkflowMaterialsChange = useCallback( - (next: AgentWorkflowMaterialSelections) => { - const nextMaterials = appearanceRecognitionEnabledRef.current - ? next - : clearAppearanceSummaryFromMaterials(next); - workflowMaterialsRef.current = nextMaterials; - setWorkflowMaterials(nextMaterials); - }, - [] - ); - const handleAppearanceRecognitionEnabledChange = useCallback( - (next: boolean) => { - appearanceRecognitionEnabledRef.current = next; - setAppearanceRecognitionEnabled(next); - - if (!next) { - const clearedMaterials = clearAppearanceSummaryFromMaterials( - workflowMaterialsRef.current - ); - workflowMaterialsRef.current = clearedMaterials; - setWorkflowMaterials(clearedMaterials); - } - }, - [] - ); - 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] || "") - ); - }); - - let clearedSelections = { ...normalizedNextSelections }; - if (earliestChangedIndex >= 0) { - orderedStepKeys.slice(earliestChangedIndex + 1).forEach((stepKey) => { - delete clearedSelections[stepKey]; - }); - } - - clearedSelections = sanitizeAgentWorkflowSelections({ - config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, - selections: clearedSelections, - }); - - workflowSelectionsRef.current = clearedSelections; - setWorkflowSelections(clearedSelections); - }, - [resolvedWorkflowUiConfig] - ); - const handleSelectedBrandModelChange = useCallback( - (next: AgentWorkflowSelectedBrandModel | null) => { - selectedBrandModelRef.current = next; - setSelectedBrandModel(next); - }, - [] - ); - 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 { data: selectedAgentPresetResponse } = useSWR<{ - success?: boolean; - data?: AgentPreset; - }>( - selectedAgentPresetId ? `/api/agents/${selectedAgentPresetId}` : 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 workflowProgress = useMemo( - () => - getAgentWorkflowProgress({ - config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, - selections: sanitizedWorkflowSelections, - }), - [resolvedWorkflowUiConfig, sanitizedWorkflowSelections] - ); - const resolvedAgentProjectTitle = hasActiveAgentProject - ? String(agentProjectTitle || "").trim() || "未命名生图项目" - : "未选择项目"; - - if (isLoading) { - return ( -
-
- - 加载中... -
-
- ); - } - - 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); - }} - > - - 项目列表 - - - - -
- ) : null; - - const loginDialogElement = ( - - - - 登录已失效 - {loginDialogMessage} - - - - - - - - ); - - const agentProjectSelectionScreen = - isAgentMode && !hasActiveAgentProject ? ( -
- -
-
-
-
- -
-

- 先选择一个生图项目 -

-

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

-
- -
- - - -
-
-
-
- ) : null; - - if (isAgentMode) { - if (!hasActiveAgentProject) { - return ( - <> - {agentProjectSelectionScreen} - {loginDialogElement} - - ); - } - - return ( - <> - - } - brandStorePanel={ - - } - chatId={agentModeChatId} - deletedResultCollections={agentProjectDeletedResultCollections} - getLatestWorkflowContext={getLatestWorkflowContext} - materialSelectionPanel={ - - } - onAgentModeStateChange={setAgentModeState} - onClearPreset={() => setSelectedAgentPresetId(null)} - onDeleteResultImage={handleAgentResultImageDelete} - onOpenCanvasEditor={() => { - updateCanvasSearchParams({ - mode: null, - }); - }} - onResultPreviewUrlsChange={handleAgentResultPreviewUrlsChange} - onWorkspaceStateChange={setAgentWorkspaceState} - presetPanel={ - - } - projectId={agentProjectId} - resultCollections={agentProjectResultCollections} - selectedAgentPresetId={ - isAgentWorkspaceEnabled ? selectedAgentPresetId : null - } - selectedBrandModel={selectedBrandModel} - showcaseConfig={siteSettingsData?.agentWorkflowShowcaseConfig} - siteLogo={siteSettingsData?.siteLogo} - siteLogoDark={siteSettingsData?.siteLogoDark} - siteName={siteSettingsData?.siteName} - syncGeneratedResultsToCanvas={false} - topNavRightSlot={agentProjectNavSlot} - workflowContext={workflowContext} - // 嵌入的风格化选择面板 - workflowPromptOptimizationEnabled={appearanceRecognitionEnabled} - workflowStylePanel={ - - } - workspaceState={agentWorkspaceState} - /> - {loginDialogElement} - - ); - } - - 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 : ( -
-
- {panelMode === "chat" ? ( - // AI 对话内容 - 使用 key 强制在 id 变化时重新挂载 -
- setIsPanelOpen(false)} - onOpenFileList={() => setPanelMode("fileList")} - 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) */} -
-
- - - - - 选择 (V) - - - - - - - 平移 (H) - - - {isVideoMode ? ( - - - - - 保存到云端 - - ) : null} - - {isVideoMode ? ( - - - - - 云文件列表 - - ) : null} - - {isVideoMode ? ( - - - - - 标记 - - ) : null} - - {isVideoMode ? ( - - - - - 工作流连线 (L) - - ) : null} - - {isAgentWorkspaceEnabled && !isVideoMode ? ( - - - - - 风格化模式 - - ) : null} - - - - - - 批量处理 - - - - - - - - {isVideoMode ? "添加图片输入" : "添加图片"} - - - - - - - - - {isVideoMode ? "添加脚本节点" : "添加文本"} - - - - {isVideoMode ? ( - - - - - 添加视频输入 - - ) : 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 ? ( - - - - - 实时画布 - - ) : null} - - {siteSettingsData?.realtimeCanvasEnabled === true && - siteSettingsData?.realtimeCanvasServiceEnabled !== false ? ( - - - - - - {selectedImageNode - ? "图像增强 Pro" - : "进入图像增强 Pro 后上传图片"} - - - ) : null} - - - - - - 3D生成 - - - {isVideoMode ? ( - - - - - 导出视频画布 - - ) : null} - - {isVideoMode ? ( - - - - - 导入视频画布 - - ) : null} - - - - - - 新建{workspaceLabel} - -
-
- - - - {/* 6. Bottom Left Compact Controls */} -
-
- - - - - 撤销 - - - - - - - 重做 - - -
- - - - {Math.round(viewport.zoom * 100)}% - - -
-
- - {/* 7. 物品拾取模式覆盖层 */} - - - {loginDialogElement} -
- - ); -} diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index ac21bcfc5..d4053af84 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -200,10 +200,29 @@ type TargetModuleActionLabel = | "音频生视频" | "首尾帧生成视频" | "首帧生成视频" - | "脚本生成分镜脚本" + | "剧本生成分镜脚本" | "视频参考生成分镜脚本" | "角色生成分镜脚本"; +type ImageToolbarAction = + | "panorama" + | "orbit" + | "lighting" + | "grid" + | "hd" + | "crop" + | "annotate" + | "rotate" + | "download" + | "preview"; + +type ComposerSubmitPayload = { + prompt: string; + references: string[]; + settings: ComposerSettings; + slashScene: string | null; +}; + type LiblibVideoCanvasReplicaProps = { canvasId: string; isLoading?: boolean; @@ -305,6 +324,9 @@ const DEFAULT_VIEWPORT = { x: 0, y: 0, zoom: 1 }; const PRESET_VIEWPORT = { x: 144, y: 100, zoom: 0.77 }; const PRESET_WORKFLOW_CENTER = { x: 1024, y: 512 }; const CANVAS_EXTENT = 20000; +const OFFICIAL_NODE_GAP = 100; +const OFFICIAL_TEXT_RESOURCE_HEIGHT = 232; +const OFFICIAL_TEXT_RESOURCE_WIDTH = 350; const TARGET_AVATAR = "https://liblibai-online.liblib.cloud/web/1656e2f1b3c74f1ba823b610f1b2cbdf/0c3eb5e2-1dc0-405c-a823-07b2570237f8.jpg?x-oss-process=image/resize,w_100,m_lfit/format,webp"; @@ -617,10 +639,10 @@ function restoreNodes(canvasData: unknown): CloneNode[] { const nodes = (canvasData as { nodes?: unknown[] } | null)?.nodes; if (!Array.isArray(nodes)) return []; return nodes - .map((item) => { + .map((item, index) => { const metadata = ((item as any)?.data?.metadata || {}) as Record; const clone = metadata.liblibCloneNode as CloneNode | undefined; - return clone && typeof clone.id === "string" ? clone : null; + return clone && typeof clone.id === "string" ? normalizeRestoredNode(clone, index + 1) : null; }) .filter(Boolean) as CloneNode[]; } @@ -724,7 +746,7 @@ function createTargetModuleNode(kind: AddModuleKind, x: number, y: number, index return { id: makeId("script"), kind: "script", - title: "脚本生成器", + title: `脚本 ${index}`, subtitle: "脚本生成", inputType: "text", x, @@ -915,9 +937,9 @@ function createMediaResourceNode({ function createTextResourceNode({ content, - height = 430, + height = OFFICIAL_TEXT_RESOURCE_HEIGHT, title, - width = 560, + width = OFFICIAL_TEXT_RESOURCE_WIDTH, x, y, }: { @@ -954,6 +976,13 @@ function getDraftString(node: CloneNode | null | undefined, key: string) { return typeof value === "string" ? value : ""; } +function getNodeMediaUrl(node: CloneNode | null | undefined) { + const direct = getDraftString(node, "mediaUrl") || getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage"); + if (direct) return direct; + const urls = node?.draftValues?.url; + return Array.isArray(urls) && typeof urls[0] === "string" ? urls[0] : ""; +} + function getPresetMode(node: CloneNode | null | undefined): ActiveStarterScene { const value = getDraftString(node, "targetPreset"); if ( @@ -986,6 +1015,109 @@ function getTargetModuleKind(node: CloneNode): AddModuleKind | null { return null; } +function extractTrailingNumber(value: string | undefined) { + const match = String(value || "").match(/(\d+)\s*$/); + return match ? Number.parseInt(match[1], 10) : null; +} + +function inferLegacyTargetModuleKind(node: CloneNode): AddModuleKind | null { + if (node.kind === "media" || getPresetMode(node)) return null; + const action = getDraftString(node, "action"); + if (action.endsWith("_RESOURCE") || action === "TEXT_RESOURCE") return null; + + const nodeType = getDraftString(node, "nodeType"); + if (nodeType === "text" || nodeType === "image" || nodeType === "video" || nodeType === "audio" || nodeType === "script") { + return nodeType; + } + if (nodeType === "video-clip" || nodeType === "video_clip" || nodeType === "videoGroup") return "video-clip"; + + const haystack = `${node.title} ${node.subtitle || ""} ${node.kind} ${node.inputType || ""} ${action}`.toLowerCase(); + if (/脚本|script/.test(haystack)) return "script"; + if (/视频合成|视频组|video-clip|video_clip|video_group|clip/.test(haystack)) return "video-clip"; + if (/音频|audio/.test(haystack)) return "audio"; + if (/图片|image/.test(haystack)) return "image"; + if (/视频|video/.test(haystack)) return "video"; + if (/文本|文字|text/.test(haystack)) return "text"; + return null; +} + +function normalizeTextResourceNode(node: CloneNode): CloneNode { + const shouldUseOfficialSize = + Boolean(node.draftValues?.manualEditing) || /^(文本|文本生成|文本节点)/.test(String(node.title || "")); + return { + ...node, + kind: "script", + title: node.title && !/文本生成|文本节点/.test(node.title) ? node.title : "文本", + subtitle: "文本资源", + inputType: "text", + width: shouldUseOfficialSize ? OFFICIAL_TEXT_RESOURCE_WIDTH : node.width > 260 ? node.width : OFFICIAL_TEXT_RESOURCE_WIDTH, + height: shouldUseOfficialSize ? OFFICIAL_TEXT_RESOURCE_HEIGHT : node.height > 120 ? node.height : OFFICIAL_TEXT_RESOURCE_HEIGHT, + accent: "#f0d06b", + draftValues: { + ...(node.draftValues || {}), + action: "TEXT_RESOURCE", + content: Array.isArray(node.draftValues?.content) ? node.draftValues?.content : [], + nodeType: "text", + scriptRole: "resource", + viewMode: undefined, + }, + }; +} + +function shouldResetTargetModuleTitle(title: string | undefined, kind: AddModuleKind) { + const value = String(title || "").trim(); + if (!value) return true; + const legacyTitlePattern = + /^(文本|文本生成|文本节点|图片|图片生成|图片节点|视频|视频生成|视频节点|音频|音频生成|音频节点|脚本|脚本生成|脚本生成器|视频合成|视频合成节点)(\s*\d+)?$/; + if (!legacyTitlePattern.test(value)) return false; + if (kind === "video-clip") return !/^视频合成\s*\d+$/.test(value); + const officialPrefix = + kind === "text" + ? "文本节点" + : kind === "image" + ? "图片节点" + : kind === "video" + ? "视频节点" + : kind === "audio" + ? "音频节点" + : "脚本"; + return !new RegExp(`^${officialPrefix}\\s*\\d+$`).test(value); +} + +function normalizeTargetModuleNode(node: CloneNode, kind: AddModuleKind, fallbackIndex: number): CloneNode { + const index = extractTrailingNumber(node.title) || fallbackIndex; + const official = createTargetModuleNode(kind, node.x, node.y, index); + const isOfficialShape = Math.abs(node.width - official.width) <= 2 && Math.abs(node.height - official.height) <= 2; + const resetTitle = shouldResetTargetModuleTitle(node.title, kind); + + if (isOfficialShape && !resetTitle) return node; + + return { + ...official, + id: node.id, + x: node.x, + y: node.y, + title: resetTitle ? official.title : node.title, + draftValues: { + ...(official.draftValues || {}), + ...(node.draftValues || {}), + nodeType: kind, + }, + }; +} + +function normalizeRestoredNode(node: CloneNode, fallbackIndex: number): CloneNode { + const action = getDraftString(node, "action"); + if (action === "TEXT_RESOURCE" && !getPresetMode(node)) { + return normalizeTextResourceNode(node); + } + + const kind = getTargetModuleKind(node) || inferLegacyTargetModuleKind(node); + if (!kind) return node; + + return normalizeTargetModuleNode(node, kind, fallbackIndex); +} + function createPresetMediaNode({ action, generated = false, @@ -1223,6 +1355,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib const [canvasCommand, setCanvasCommand] = useState(null); const [nodeContextMenu, setNodeContextMenu] = useState(null); const [connectionMenu, setConnectionMenu] = useState(null); + const [previewNode, setPreviewNode] = useState(null); const selectedNode = useMemo( () => nodes.find((node) => node.id === selectedNodeId) || null, @@ -1446,7 +1579,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib const anchor = nodes.find((node) => node.id === connectionMenu.nodeId); if (!anchor) return; const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1); - const gap = 120; + const gap = OFFICIAL_NODE_GAP; const node = connectionMenu.side === "source" ? { @@ -1476,8 +1609,8 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib const anchor = nodes.find((node) => node.id === nodeId); if (!anchor) return; - const leftX = Math.round(anchor.x - 560 - 120); - const rightX = Math.round(anchor.x + anchor.width + 120); + const leftOfAnchor = (width: number) => Math.round(anchor.x - width - OFFICIAL_NODE_GAP); + const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP); const centerY = (height: number) => Math.round(anchor.y + anchor.height / 2 - height / 2); const nextNodes: CloneNode[] = []; const nextEdges: CloneEdge[] = []; @@ -1512,7 +1645,22 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib switch (actionLabel) { case "自己编写内容": { - draftUpdates = { action: "TEXT_RESOURCE", content: [], manualEditing: true }; + anchorUpdates = { + kind: "script", + title: "文本", + subtitle: "文本资源", + inputType: "text", + width: OFFICIAL_TEXT_RESOURCE_WIDTH, + height: OFFICIAL_TEXT_RESOURCE_HEIGHT, + }; + draftUpdates = { + action: "TEXT_RESOURCE", + content: [], + manualEditing: true, + nodeType: "text", + scriptRole: "resource", + viewMode: undefined, + }; toast.success("已切换为文本编写节点"); break; } @@ -1528,7 +1676,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib title: "图片", width: 420, height: 280, - x: leftX, + x: leftOfAnchor(420), y: centerY(280), }); draftUpdates = { @@ -1609,7 +1757,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib width: 430, height: 252, x: anchor.x, - y: Math.round(anchor.y + 170 + 120), + y: Math.round(anchor.y + 170 + OFFICIAL_NODE_GAP), }); nextNodes.push(video, image); nextEdges.push( @@ -1628,7 +1776,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib title: "首帧", width: 420, height: 280, - x: leftX, + x: leftOfAnchor(420), y: centerY(280), }) ); @@ -1643,7 +1791,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib title: "首帧", width: 420, height: 240, - x: leftX, + x: leftOfAnchor(420), y: Math.round(anchor.y + 18), }); const last = createMediaResourceNode({ @@ -1652,7 +1800,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib title: "尾帧", width: 420, height: 240, - x: leftX, + x: leftOfAnchor(420), y: Math.round(anchor.y + 286), }); connectToAnchor(first); @@ -1660,8 +1808,15 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib selectedId = anchor.id; break; } - case "脚本生成分镜脚本": { - connectToAnchor(createTextResourceNode({ content: STORY_SCRIPT_TEXT, title: "文本", x: leftX, y: centerY(430) })); + case "剧本生成分镜脚本": { + connectToAnchor( + createTextResourceNode({ + content: STORY_SCRIPT_TEXT, + title: "文本", + x: leftOfAnchor(OFFICIAL_TEXT_RESOURCE_WIDTH), + y: centerY(OFFICIAL_TEXT_RESOURCE_HEIGHT), + }) + ); selectedId = anchor.id; break; } @@ -1676,7 +1831,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib title: "视频", width: 560, height: 315, - x: leftX, + x: leftOfAnchor(560), y: centerY(315), }) ); @@ -1691,7 +1846,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib title: "角色 1", width: 300, height: 300, - x: leftX, + x: leftOfAnchor(300), y: Math.round(anchor.y + 10), }); const second = createMediaResourceNode({ @@ -1700,7 +1855,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib title: "角色 2", width: 300, height: 300, - x: leftX, + x: leftOfAnchor(300), y: Math.round(anchor.y + 330), }); connectToAnchor(first); @@ -1740,6 +1895,217 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib [nodes] ); + const runSelectedImageToolAction = useCallback( + (nodeId: string, action: ImageToolbarAction, option?: string) => { + const anchor = nodes.find((node) => node.id === nodeId); + if (!anchor) return; + const sourceUrl = getNodeMediaUrl(anchor) || CHARACTER_IMAGE_URL; + + if (action === "download") { + if (!sourceUrl) { + toast.error("当前素材没有可下载地址"); + return; + } + const link = document.createElement("a"); + link.href = sourceUrl; + link.download = `${anchor.title || "libtv-image"}.png`; + link.rel = "noreferrer"; + document.body.appendChild(link); + link.click(); + link.remove(); + toast.success("已开始下载当前素材"); + return; + } + + if (action === "preview") { + setPreviewNode(anchor); + return; + } + + if (action === "annotate") { + setNodes((current) => + current.map((node) => + node.id === anchor.id + ? { + ...node, + draftValues: { + ...(node.draftValues || {}), + annotationMode: true, + annotations: node.draftValues?.annotations || [{ label: "主体", x: 42, y: 46 }], + }, + } + : node + ) + ); + setSelectedNodeId(anchor.id); + toast.success("已进入标注模式"); + return; + } + + if (action === "rotate") { + const currentRotation = Number(anchor.draftValues?.rotation || 0); + setNodes((current) => + current.map((node) => + node.id === anchor.id + ? { + ...node, + draftValues: { + ...(node.draftValues || {}), + rotation: (currentRotation + 90) % 360, + }, + } + : node + ) + ); + setSelectedNodeId(anchor.id); + toast.success("已旋转当前素材"); + return; + } + + const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP); + const centerY = (height: number) => Math.round(anchor.y + anchor.height / 2 - height / 2); + const nextNodes: CloneNode[] = []; + const nextEdges: CloneEdge[] = []; + + if (action === "crop") { + const parsed = Number.parseInt(option || "9", 10); + const tileCount = Number.isFinite(parsed) && parsed > 0 ? parsed : 9; + const columns = Math.ceil(Math.sqrt(tileCount)); + const tileSize = 180; + const gap = 26; + const rows = Math.ceil(tileCount / columns); + const startY = Math.round(anchor.y + anchor.height / 2 - (rows * tileSize + (rows - 1) * gap) / 2); + + Array.from({ length: tileCount }).forEach((_, index) => { + const tile = createMediaResourceNode({ + action: "IMAGE_RESOURCE", + height: tileSize, + mediaType: "image", + mediaUrl: sourceUrl, + prompt: option || "宫格切分", + title: `切分 ${index + 1}`, + width: tileSize, + x: rightX + (index % columns) * (tileSize + gap), + y: startY + Math.floor(index / columns) * (tileSize + gap), + }); + nextNodes.push(tile); + nextEdges.push({ id: makeId("edge"), source: anchor.id, target: tile.id }); + }); + + setNodes((current) => [...current, ...nextNodes]); + setEdges((current) => [...current, ...nextEdges]); + setSelectedNodeId(anchor.id); + setCanvasCommand(null); + setNodeContextMenu(null); + setConnectionMenu(null); + setActivePanel(null); + toast.success(`已创建「${option || "宫格切分"}」结果节点`); + return; + } + + const labels: Record, string> = { + grid: "九宫格", + hd: "高清", + lighting: "打光", + orbit: "多角度", + panorama: "720 全景", + }; + const title = labels[action]; + const created = createTargetModuleNode("image", rightX, 0, nodes.length + 1); + const prompt = option?.trim() || title; + const resultNode: CloneNode = { + ...created, + title, + y: centerY(created.height), + draftValues: { + ...(created.draftValues || {}), + generatorType: action === "hd" ? "ENHANCE" : "DEFAULT", + params: { + mode: action, + prompt, + sourceNodeId: anchor.id, + sourceUrl, + }, + sourceNodeId: anchor.id, + sourceUrl, + toolbarOption: option || "", + }, + }; + + setNodes((current) => [...current, resultNode]); + setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: resultNode.id }]); + setSelectedNodeId(anchor.id); + setCanvasCommand(null); + setNodeContextMenu(null); + setConnectionMenu(null); + setActivePanel(null); + toast.success(`已创建「${title}」工作流节点`); + }, + [nodes] + ); + + const updateTextResourceContent = useCallback((nodeId: string, content: string) => { + setNodes((current) => + current.map((node) => + node.id === nodeId + ? { + ...node, + draftValues: { + ...(node.draftValues || {}), + content: content ? [content] : [], + }, + } + : node + ) + ); + }, []); + + const submitSelectedImagePrompt = useCallback( + (nodeId: string, payload: ComposerSubmitPayload) => { + const anchor = nodes.find((node) => node.id === nodeId); + if (!anchor) return; + const sourceUrl = getNodeMediaUrl(anchor) || CHARACTER_IMAGE_URL; + const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP); + const created = createTargetModuleNode("image", rightX, 0, nodes.length + 1); + const prompt = payload.prompt.trim() || payload.slashScene || "生成角色三视图"; + const resultNode: CloneNode = { + ...created, + title: payload.slashScene || "图片生成", + y: Math.round(anchor.y + anchor.height / 2 - created.height / 2), + draftValues: { + ...(created.draftValues || {}), + params: { + prompt, + references: payload.references, + settings: payload.settings, + slashScene: payload.slashScene, + sourceNodeId: anchor.id, + sourceUrl, + }, + sourceNodeId: anchor.id, + sourceUrl, + }, + }; + const referenceEdges = payload.references + .filter((referenceId) => referenceId !== anchor.id && nodes.some((node) => node.id === referenceId)) + .map((referenceId) => ({ id: makeId("edge"), source: referenceId, target: resultNode.id })); + + setNodes((current) => [...current, resultNode]); + setEdges((current) => [ + ...current, + { id: makeId("edge"), source: anchor.id, target: resultNode.id }, + ...referenceEdges, + ]); + setSelectedNodeId(anchor.id); + setCanvasCommand(null); + setNodeContextMenu(null); + setConnectionMenu(null); + setActivePanel(null); + toast.success("已按当前提示词创建图片生成节点"); + }, + [nodes] + ); + const createFreeNode = useCallback( (kind: CanvasCreateKind) => { if (!canvasCommand) return; @@ -2034,6 +2400,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib onModuleAction={(actionLabel) => runTargetModuleAction(node.id, actionLabel)} onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)} onPointerDown={(event) => startNodeDrag(event, node)} + onTextResourceChange={(content) => updateTextResourceContent(node.id, content)} /> ))}
@@ -2116,8 +2483,15 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib /> ) : null} - {selectedPresetMode && selectedPresetAnchor ? ( - + {selectedPresetMode && selectedPresetAnchor && selectedPresetControlNode ? ( + submitSelectedImagePrompt(selectedPresetControlNode.id, payload)} + onImageToolAction={(action, option) => runSelectedImageToolAction(selectedPresetControlNode.id, action, option)} + selectedNode={selectedPresetControlNode} + /> ) : null} setActivePanel(null)} /> ) : null} + {previewNode ? ( + setPreviewNode(null)} title={previewNode.title || "预览"} width={920}> + + + ) : null} + {isBusy ? (
@@ -2656,16 +3036,27 @@ function PresetWorkflowControls({ anchor, mode, nodes, + onComposerSubmit, + onImageToolAction, + selectedNode, }: { anchor: PresetControlAnchor; mode: Exclude; nodes: CloneNode[]; + onComposerSubmit: (payload: ComposerSubmitPayload) => void; + onImageToolAction: (action: ImageToolbarAction, option?: string) => void; + selectedNode: CloneNode; }) { if (mode === "character-three-view") { return (
- - + +
); } @@ -2740,8 +3131,21 @@ function PresetWorkflowControls({ ); } -function CharacterPresetToolbar({ anchor }: { anchor?: PresetControlAnchor }) { +function CharacterPresetToolbar({ + anchor, + onToolAction, +}: { + anchor?: PresetControlAnchor; + onToolAction: (action: ImageToolbarAction, option?: string) => void; +}) { const [toolbarMenu, setToolbarMenu] = useState(null); + const iconActions: Array<{ action: ImageToolbarAction; icon: string; label: string }> = [ + { action: "annotate", icon: "⌁", label: "标注" }, + { action: "rotate", icon: "↔", label: "旋转" }, + { action: "download", icon: "↓", label: "下载" }, + { action: "preview", icon: "↗", label: "预览" }, + ]; + const activeToolbarMenu = toolbarMenu; return ( <> @@ -2761,18 +3165,29 @@ function CharacterPresetToolbar({ anchor }: { anchor?: PresetControlAnchor }) { setToolbarMenu((current) => current === "hd" ? null : "hd")} /> setToolbarMenu((current) => current === "crop" ? null : "crop")} />
- {["⌁", "↔", "↓", "↗"].map((item, index) => ( + {iconActions.map((item) => ( ))}
- {toolbarMenu ? setToolbarMenu(null)} /> : null} + {activeToolbarMenu ? ( + setToolbarMenu(null)} + onSelect={(option) => { + onToolAction(activeToolbarMenu, option); + setToolbarMenu(null); + }} + /> + ) : null} ); } @@ -3217,11 +3632,14 @@ function SceneToolbarPopover({ anchor, menu, onClose, + onSelect, }: { anchor?: PresetControlAnchor; menu: Exclude; onClose: () => void; + onSelect?: (option?: string) => void; }) { + const [lightingPrompt, setLightingPrompt] = useState(""); const quickOptions: Record, string[]> = { crop: ["4 宫格切分", "9 宫格切分", "16 宫格切分", "25 宫格切分"], grid: ["2 x 2", "3 x 3", "4 x 4", "自定义宫格"], @@ -3247,11 +3665,13 @@ function SceneToolbarPopover({