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 = (
+
+ );
+
+ 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 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 = (
-
- );
-
- 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 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({