9994 lines
345 KiB
TypeScript
9994 lines
345 KiB
TypeScript
"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,
|
||
getAgentWorkflowPageFeatureSettings,
|
||
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: () => (
|
||
<div className="flex h-full w-full items-center justify-center bg-background text-muted-foreground">
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
正在加载画布...
|
||
</div>
|
||
),
|
||
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<CanvasNode["type"]>([
|
||
"message",
|
||
"image",
|
||
"video",
|
||
"text",
|
||
"workflow",
|
||
"scene-fusion",
|
||
"model3d",
|
||
"world3d",
|
||
]);
|
||
|
||
const IMPORTABLE_CANVAS_TOOL_TYPES = new Set<ToolType>([
|
||
"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<VideoWorkflowTemplateType, LucideIcon>
|
||
> = {
|
||
"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<string, unknown> {
|
||
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<AgentGenerationResultModeType, string> =
|
||
{
|
||
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<string, unknown>)[key] === value;
|
||
|
||
return (
|
||
<div className="flex h-full flex-col overflow-hidden rounded-[28px] border border-[#dbe2ee] bg-white text-[#16181d] shadow-[0_0_0_1px_rgba(219,226,238,0.38),0_28px_80px_rgba(22,24,29,0.14)] dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100">
|
||
<div className="flex items-center justify-between border-b border-[#eef1f7] px-4 py-3 dark:border-zinc-800">
|
||
<div className="min-w-0">
|
||
<div className="font-semibold text-sm">标记</div>
|
||
<div className="mt-0.5 truncate text-[11px] text-[#8b94a6]">
|
||
{hasSelection ? selectedLabel : "先选中一个模块"}
|
||
</div>
|
||
</div>
|
||
<Button
|
||
className="h-8 w-8 rounded-full text-[#7a8497] hover:bg-[#f5f7fb]"
|
||
onClick={onClose}
|
||
size="icon"
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-1 border-b border-[#eef1f7] p-2 dark:border-zinc-800">
|
||
{["标记", "贴纸", "插画"].map((tab) => (
|
||
<button
|
||
className={cn(
|
||
"h-8 rounded-[12px] text-[12px] font-medium transition-colors",
|
||
tab === "标记"
|
||
? "bg-[#16181d] text-white shadow-sm dark:bg-zinc-100 dark:text-zinc-950"
|
||
: "text-[#8b94a6] hover:bg-[#f7f9ff]"
|
||
)}
|
||
disabled={tab !== "标记"}
|
||
key={tab}
|
||
type="button"
|
||
>
|
||
{tab}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<ScrollArea className="min-h-0 flex-1">
|
||
<div
|
||
className={cn(
|
||
"space-y-5 p-4",
|
||
!hasSelection && "pointer-events-none opacity-45"
|
||
)}
|
||
>
|
||
<section>
|
||
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
标签
|
||
</div>
|
||
<div className="grid grid-cols-7 gap-2">
|
||
{VIDEO_CANVAS_MARKER_COLORS.map((entry) => (
|
||
<button
|
||
aria-label={`标签 ${entry.label}`}
|
||
className={cn(
|
||
"h-8 w-8 rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||
isActive("tagColor", entry.id) &&
|
||
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||
)}
|
||
key={entry.id}
|
||
onClick={() => onApply("tagColor", entry.id)}
|
||
style={{ backgroundColor: entry.color }}
|
||
type="button"
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
优先级
|
||
</div>
|
||
<div className="grid grid-cols-7 gap-2">
|
||
{VIDEO_CANVAS_PRIORITY_MARKERS.map((entry) => (
|
||
<button
|
||
className={cn(
|
||
"flex h-8 w-8 items-center justify-center rounded-full border border-white font-semibold text-[13px] text-white shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||
isActive("priority", entry.value) &&
|
||
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||
)}
|
||
key={entry.value}
|
||
onClick={() => onApply("priority", entry.value)}
|
||
style={{ backgroundColor: entry.color }}
|
||
type="button"
|
||
>
|
||
{entry.value}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
任务
|
||
</div>
|
||
<div className="grid grid-cols-6 gap-2">
|
||
{VIDEO_CANVAS_TASK_MARKERS.map((entry) => (
|
||
<button
|
||
className={cn(
|
||
"flex h-9 w-9 items-center justify-center rounded-full border border-white p-[3px] shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||
isActive("task", entry.id) &&
|
||
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||
)}
|
||
key={entry.id}
|
||
onClick={() => onApply("task", entry.id)}
|
||
style={{
|
||
background: `conic-gradient(#10b981 ${entry.progress * 3.6}deg, #dfe6f3 0deg)`,
|
||
}}
|
||
title={entry.label}
|
||
type="button"
|
||
>
|
||
<span className="flex h-full w-full items-center justify-center rounded-full bg-white text-[#10b981]">
|
||
{entry.progress >= 100 ? (
|
||
<CheckCircle2 className="h-4 w-4" />
|
||
) : null}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
旗帜
|
||
</div>
|
||
<div className="grid grid-cols-7 gap-2">
|
||
{VIDEO_CANVAS_MARKER_COLORS.map((entry) => (
|
||
<button
|
||
className={cn(
|
||
"flex h-8 w-8 items-center justify-center rounded-full border border-white text-white shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||
isActive("flagColor", entry.id) &&
|
||
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||
)}
|
||
key={entry.id}
|
||
onClick={() => onApply("flagColor", entry.id)}
|
||
style={{ backgroundColor: entry.color }}
|
||
title={entry.label}
|
||
type="button"
|
||
>
|
||
<Flag className="h-4 w-4" fill="currentColor" />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
星星
|
||
</div>
|
||
<div className="grid grid-cols-7 gap-2">
|
||
{VIDEO_CANVAS_MARKER_COLORS.map((entry) => (
|
||
<button
|
||
className={cn(
|
||
"flex h-8 w-8 items-center justify-center rounded-full border border-white text-white shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||
isActive("starColor", entry.id) &&
|
||
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||
)}
|
||
key={entry.id}
|
||
onClick={() => onApply("starColor", entry.id)}
|
||
style={{ backgroundColor: entry.color }}
|
||
title={entry.label}
|
||
type="button"
|
||
>
|
||
<Star className="h-4 w-4" fill="currentColor" />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
人像
|
||
</div>
|
||
<div className="grid grid-cols-7 gap-2">
|
||
{VIDEO_CANVAS_MARKER_COLORS.map((entry) => (
|
||
<button
|
||
className={cn(
|
||
"flex h-8 w-8 items-center justify-center rounded-full border border-white text-white shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||
isActive("avatarColor", entry.id) &&
|
||
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||
)}
|
||
key={entry.id}
|
||
onClick={() => onApply("avatarColor", entry.id)}
|
||
style={{ backgroundColor: entry.color }}
|
||
title={entry.label}
|
||
type="button"
|
||
>
|
||
<UserRound className="h-4 w-4" />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
符号
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{VIDEO_CANVAS_SYMBOL_MARKERS.map((entry) => {
|
||
const Icon = VIDEO_CANVAS_MARKER_PANEL_ICON_MAP[entry.id];
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"flex items-center gap-2 rounded-[14px] border border-[#e5eaf4] bg-white px-3 py-2 text-left text-[12px] font-medium text-[#4a5365] shadow-[0_8px_18px_rgba(15,23,42,0.05)] transition-all hover:border-[#cfdcff] hover:bg-[#f7f9ff]",
|
||
isActive("symbol", entry.id) &&
|
||
"border-[#9aaeff] bg-[#f3f6ff] text-[#3954db] ring-2 ring-[#5b76fe]/20"
|
||
)}
|
||
key={entry.id}
|
||
onClick={() => onApply("symbol", entry.id)}
|
||
type="button"
|
||
>
|
||
<span
|
||
className="flex h-7 w-7 items-center justify-center rounded-full"
|
||
style={{ backgroundColor: entry.soft, color: entry.color }}
|
||
>
|
||
<Icon className="h-3.5 w-3.5" />
|
||
</span>
|
||
{entry.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</ScrollArea>
|
||
|
||
<div className="border-t border-[#eef1f7] p-3 dark:border-zinc-800">
|
||
<Button
|
||
className="h-10 w-full rounded-[16px] border border-[#e3e8f3] bg-white text-[#5f687a] shadow-none hover:bg-[#f7f9ff]"
|
||
disabled={!hasSelection}
|
||
onClick={onClear}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
清空当前模块标记
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<string | null>(null);
|
||
const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode);
|
||
const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] =
|
||
useState(false);
|
||
const [videoModuleSectionOpen, setVideoModuleSectionOpen] = useState<
|
||
Record<string, boolean>
|
||
>({
|
||
input: true,
|
||
analysis: true,
|
||
storyboard: true,
|
||
generation: true,
|
||
tool: true,
|
||
output: true,
|
||
});
|
||
const [videoCanvasInsertMenu, setVideoCanvasInsertMenu] =
|
||
useState<VideoCanvasInsertMenuState | null>(null);
|
||
const videoCanvasInsertMenuRef = useRef<HTMLDivElement | null>(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<AgentWorkflowInputs>({
|
||
primarySku: "",
|
||
secondarySku: "",
|
||
brand: "",
|
||
store: "",
|
||
thirdCategory: "",
|
||
fourthCategory: "",
|
||
fifthCategory: "",
|
||
priceBand: "",
|
||
});
|
||
const [workflowMaterials, setWorkflowMaterials] =
|
||
useState<AgentWorkflowMaterialSelections>(() =>
|
||
createEmptyAgentWorkflowMaterialSelections()
|
||
);
|
||
const [workflowSelections, setWorkflowSelections] = useState<
|
||
Partial<Record<AgentWorkflowStepKey, string>>
|
||
>({});
|
||
|
||
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<AgentWorkflowSelectedBrandModel | null>(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<AgentModeWorkspaceState>(
|
||
() => createEmptyAgentModeWorkspaceState()
|
||
);
|
||
const [agentWorkspaceState, setAgentWorkspaceState] =
|
||
useState<AgentGenerationProjectWorkspaceState>(() =>
|
||
createEmptyAgentGenerationProjectWorkspaceState()
|
||
);
|
||
const [agentProjectTitle, setAgentProjectTitle] = useState("未命名生图项目");
|
||
const [agentProjectThumbnail, setAgentProjectThumbnail] = useState<
|
||
string | null
|
||
>(null);
|
||
const [agentProjectResultCollections, setAgentProjectResultCollections] =
|
||
useState<AgentGenerationProjectResultCollections>({});
|
||
const [
|
||
agentProjectDeletedResultCollections,
|
||
setAgentProjectDeletedResultCollections,
|
||
] = useState<AgentGenerationProjectResultCollections>({});
|
||
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?: {
|
||
pageFeatureSettings?: {
|
||
presetStepEnabled?: boolean;
|
||
materialStepEnabled?: boolean;
|
||
psdTemplateEnabled?: boolean;
|
||
};
|
||
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<Set<string>>(new Set());
|
||
const previousBrandForModelRef = useRef("");
|
||
const autoSelectedBrandModelBrandRef = useRef("");
|
||
const autoTitleAttemptedRef = useRef<Set<string>>(new Set());
|
||
const autoTitleInFlightRef = useRef<string | null>(null);
|
||
const autoThumbnailSyncedRef = useRef<string | null>(null);
|
||
const lastLoadedCanvasIdRef = useRef<string>("");
|
||
const normalizedVideoNodeSizeIdsRef = useRef<Set<string>>(new Set());
|
||
const importVideoCanvasInputRef = useRef<HTMLInputElement | null>(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 workflowPageFeatureSettings = useMemo(
|
||
() => getAgentWorkflowPageFeatureSettings(resolvedWorkflowUiConfig),
|
||
[resolvedWorkflowUiConfig]
|
||
);
|
||
const isWorkflowPresetStepEnabled =
|
||
workflowPageFeatureSettings.presetStepEnabled !== false;
|
||
const isWorkflowMaterialStepEnabled =
|
||
workflowPageFeatureSettings.materialStepEnabled !== false;
|
||
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<string, unknown>;
|
||
|
||
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<string, any>) || {};
|
||
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<string, any>)?.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<string, any>) ||
|
||
{};
|
||
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<string, any>) ||
|
||
{};
|
||
return Array.isArray(metadata.agentResultGroups)
|
||
? metadata.agentResultGroups
|
||
.map((item) => {
|
||
const record = (item || {}) as Record<string, any>;
|
||
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<string, any>) ||
|
||
{};
|
||
return Array.isArray(metadata.agentResultAssets)
|
||
? metadata.agentResultAssets.reduce<
|
||
Array<{
|
||
modeType: string;
|
||
label: string;
|
||
assets: WorkflowMediaReference[];
|
||
}>
|
||
>((groups, item) => {
|
||
const record = (item || {}) as Record<string, any>;
|
||
const modeType = String(record.modeType || "").trim();
|
||
const label = String(record.label || "").trim();
|
||
const assets = Array.isArray(record.assets)
|
||
? record.assets.reduce<WorkflowMediaReference[]>(
|
||
(nextAssets, asset, index) => {
|
||
const assetRecord = (asset || {}) as Record<string, any>;
|
||
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<string, any>) ||
|
||
{};
|
||
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<string, string | null>) => {
|
||
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<AgentWorkflowPersistedState>(
|
||
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<HTMLInputElement>) => {
|
||
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<WorkflowNodeData> & {
|
||
metadata?: Record<string, any>;
|
||
}
|
||
) => {
|
||
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<string, any>)?.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<string, any>),
|
||
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<string, any>)?.metadata ||
|
||
{}) as Record<string, any>;
|
||
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<string, any>;
|
||
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<Record<string, any>>,
|
||
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<string>();
|
||
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<string>();
|
||
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<typeof addNode>[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<string, any>;
|
||
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<string, any> | undefined)
|
||
?.lastFrameUrl ||
|
||
(result.data?.metadata as Record<string, any> | 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<string, any> | undefined)
|
||
?.lastFrameUrl ||
|
||
(result.data?.metadata as Record<string, any> | 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<Record<string, any>>)[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<string, any>)
|
||
?.syncedAgentAssets
|
||
)
|
||
? (
|
||
((
|
||
(selectedVideoWorkflowData?.metadata || {}) as Record<
|
||
string,
|
||
any
|
||
>
|
||
).syncedAgentAssets || []) as Array<Record<string, any>>
|
||
).reduce<WorkflowMediaReference[]>((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<AgentWorkflowComposerContext | null>(() => {
|
||
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<Record<AgentWorkflowStepKey, string>>) => {
|
||
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 effectiveSelectedAgentPresetId = isWorkflowPresetStepEnabled
|
||
? selectedAgentPresetId
|
||
: null;
|
||
const { data: selectedAgentPresetResponse } = useSWR<{
|
||
success?: boolean;
|
||
data?: AgentPreset;
|
||
}>(
|
||
effectiveSelectedAgentPresetId ? `/api/agents/${effectiveSelectedAgentPresetId}` : null,
|
||
fetcher
|
||
);
|
||
const selectedAgentPresetData = useMemo(
|
||
() =>
|
||
selectedAgentPresetResponse?.success && selectedAgentPresetResponse.data
|
||
? selectedAgentPresetResponse.data
|
||
: null,
|
||
[selectedAgentPresetResponse]
|
||
);
|
||
const selectedAgentPresetModeConfig = useMemo(
|
||
() =>
|
||
normalizeAgentPresetModeConfig(selectedAgentPresetData?.defaultParams),
|
||
[selectedAgentPresetData?.defaultParams]
|
||
);
|
||
const materialProductSelectionLimit = useMemo(() => {
|
||
if (selectedAgentPresetData?.type !== "image") {
|
||
return null;
|
||
}
|
||
if (selectedAgentPresetModeConfig.modeType === "scene_generation") {
|
||
return selectedAgentPresetModeConfig.scene.productImageSlotCount;
|
||
}
|
||
if (selectedAgentPresetModeConfig.modeType === "detail_replacement") {
|
||
return selectedAgentPresetModeConfig.detail.productImageSlotCount;
|
||
}
|
||
if (selectedAgentPresetModeConfig.modeType === "sku_replacement") {
|
||
return selectedAgentPresetModeConfig.sku.productImageSlotCount;
|
||
}
|
||
return 2;
|
||
}, [selectedAgentPresetData?.type, selectedAgentPresetModeConfig]);
|
||
const materialModelImageRequirement = useMemo(() => {
|
||
if (selectedAgentPresetData?.type !== "image") {
|
||
return 0;
|
||
}
|
||
if (selectedAgentPresetModeConfig.modeType === "scene_generation") {
|
||
return selectedAgentPresetModeConfig.scene.includeModelSlot ? 1 : 0;
|
||
}
|
||
if (selectedAgentPresetModeConfig.modeType === "detail_replacement") {
|
||
return selectedAgentPresetModeConfig.detail.includeModelSlot ? 1 : 0;
|
||
}
|
||
if (selectedAgentPresetModeConfig.modeType === "sku_replacement") {
|
||
return selectedAgentPresetModeConfig.sku.includeModelSlot ? 1 : 0;
|
||
}
|
||
return 1;
|
||
}, [selectedAgentPresetData?.type, selectedAgentPresetModeConfig]);
|
||
const materialTotalUploadRequirement = useMemo(() => {
|
||
if (selectedAgentPresetData?.type !== "image") {
|
||
return null;
|
||
}
|
||
const explicitCount = Number(selectedAgentPresetData.requiredUploadCount);
|
||
if (Number.isFinite(explicitCount) && explicitCount > 0) {
|
||
return Math.max(
|
||
materialProductSelectionLimit || 0,
|
||
Math.max(1, Math.round(explicitCount))
|
||
);
|
||
}
|
||
return (materialProductSelectionLimit || 0) + materialModelImageRequirement;
|
||
}, [
|
||
materialModelImageRequirement,
|
||
materialProductSelectionLimit,
|
||
selectedAgentPresetData?.requiredUploadCount,
|
||
selectedAgentPresetData?.type,
|
||
]);
|
||
const workflowProgress = useMemo(
|
||
() =>
|
||
getAgentWorkflowProgress({
|
||
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
|
||
selections: sanitizedWorkflowSelections,
|
||
}),
|
||
[resolvedWorkflowUiConfig, sanitizedWorkflowSelections]
|
||
);
|
||
const resolvedAgentProjectTitle = hasActiveAgentProject
|
||
? String(agentProjectTitle || "").trim() || "未命名生图项目"
|
||
: "未选择项目";
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center bg-muted/30">
|
||
<div className="flex flex-col items-center gap-3">
|
||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||
<span className="text-muted-foreground text-sm">加载中...</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const brandEntry = (
|
||
<div
|
||
className="absolute top-4 left-4 z-20 rounded-full px-1 py-1 shadow-none"
|
||
style={{ backgroundColor: "var(--background)" }}
|
||
>
|
||
<Button
|
||
className="h-9 gap-2 rounded-full border-0 bg-transparent px-2.5 shadow-none hover:bg-transparent hover:text-foreground"
|
||
onClick={goHome}
|
||
variant="ghost"
|
||
>
|
||
{siteSettingsData?.siteLogo || siteSettingsData?.siteLogoDark ? (
|
||
<img
|
||
alt={siteSettingsData?.siteName || "返回首页"}
|
||
className="h-7 w-7 object-contain"
|
||
draggable={false}
|
||
src={
|
||
siteSettingsData?.siteLogo || siteSettingsData?.siteLogoDark || ""
|
||
}
|
||
/>
|
||
) : (
|
||
<Grid3X3 className="h-5 w-5" />
|
||
)}
|
||
<span className="max-w-[180px] truncate font-semibold text-base text-foreground">
|
||
{siteSettingsData?.siteName || "返回首页"}
|
||
</span>
|
||
</Button>
|
||
</div>
|
||
);
|
||
|
||
const agentDisabledNotice =
|
||
requestedAgentMode && !isAgentWorkspaceEnabled ? (
|
||
<div className="-translate-x-1/2 absolute top-4 left-1/2 z-20">
|
||
<div className="inline-flex items-center gap-2 rounded-full border border-amber-200 bg-amber-50 px-4 py-2 text-amber-800 text-sm shadow-sm dark:border-amber-900/60 dark:bg-amber-950/80 dark:text-amber-200">
|
||
<AlertCircle className="h-4 w-4" />
|
||
<span>风格化功能未开启,请联系管理员在系统设置中开启。</span>
|
||
</div>
|
||
</div>
|
||
) : null;
|
||
|
||
const agentProjectNavSlot = isAgentMode ? (
|
||
<div className="flex items-center gap-2">
|
||
{canSyncAgentResultsToCanvas ? (
|
||
<Button
|
||
className="h-9 gap-2 border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
|
||
disabled={
|
||
isAgentProjectLoading ||
|
||
isAgentProjectSaving ||
|
||
isSyncingAgentResultsToCanvas
|
||
}
|
||
onClick={() => {
|
||
void handleSyncAgentResultsToCanvas();
|
||
}}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
{isSyncingAgentResultsToCanvas ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<GitBranch className="h-4 w-4" />
|
||
)}
|
||
{pendingAgentCanvasImportCount > 0
|
||
? `回流到视频画布 (${pendingAgentCanvasImportCount})`
|
||
: "返回视频画布"}
|
||
</Button>
|
||
) : null}
|
||
|
||
<div className="hidden h-9 min-w-0 max-w-[180px] items-center gap-2 rounded-[12px] border border-white/10 bg-white/[0.03] px-2.5 sm:flex">
|
||
{isAgentProjectLoading || isAgentProjectSaving ? (
|
||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-emerald-300" />
|
||
) : (
|
||
<span className="h-2 w-2 shrink-0 rounded-full bg-emerald-400 shadow-[0_0_0_3px_rgba(16,185,129,0.12)]" />
|
||
)}
|
||
<span
|
||
className="truncate font-medium text-[13px] text-white"
|
||
title={resolvedAgentProjectTitle}
|
||
>
|
||
{resolvedAgentProjectTitle}
|
||
</span>
|
||
</div>
|
||
|
||
<DropdownMenu
|
||
onOpenChange={setIsAgentProjectMenuOpen}
|
||
open={isAgentProjectMenuOpen}
|
||
>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
className="h-9 gap-2 border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
|
||
disabled={isAgentProjectLoading}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
{isAgentProjectSaving ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<FolderOpen className="h-4 w-4" />
|
||
)}
|
||
项目操作
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="end"
|
||
className="w-48 border-white/10 bg-[#0b0d0f] text-white"
|
||
>
|
||
<DropdownMenuItem
|
||
className="cursor-pointer text-white focus:bg-white/10 focus:text-white"
|
||
disabled={isAgentProjectLoading || isAgentProjectSaving}
|
||
onSelect={(event) => {
|
||
event.preventDefault();
|
||
setIsAgentProjectMenuOpen(false);
|
||
void handleCreateAgentProject();
|
||
}}
|
||
>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
新建项目
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
className="cursor-pointer text-white focus:bg-white/10 focus:text-white"
|
||
disabled={isAgentProjectLoading || isAgentProjectSaving}
|
||
onSelect={(event) => {
|
||
event.preventDefault();
|
||
setIsAgentProjectMenuOpen(false);
|
||
void handleSaveAsAgentProject();
|
||
}}
|
||
>
|
||
<Save className="mr-2 h-4 w-4" />
|
||
另存为新项目
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
className="cursor-pointer text-white focus:bg-white/10 focus:text-white"
|
||
disabled={
|
||
!hasActiveAgentProject ||
|
||
isAgentProjectLoading ||
|
||
isAgentProjectSaving
|
||
}
|
||
onSelect={(event) => {
|
||
event.preventDefault();
|
||
setIsAgentProjectMenuOpen(false);
|
||
void handleRenameCurrentAgentProject();
|
||
}}
|
||
>
|
||
<Pencil className="mr-2 h-4 w-4" />
|
||
重命名当前项目
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator className="bg-white/10" />
|
||
<DropdownMenuItem
|
||
className="cursor-pointer text-white focus:bg-white/10 focus:text-white"
|
||
onSelect={(event) => {
|
||
event.preventDefault();
|
||
setIsAgentProjectMenuOpen(false);
|
||
setIsAgentProjectSheetOpen(true);
|
||
}}
|
||
>
|
||
<Grid3X3 className="mr-2 h-4 w-4" />
|
||
项目列表
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
<AgentProjectListSheet
|
||
currentProjectId={agentProjectId || null}
|
||
onCreateProject={handleCreateAgentProject}
|
||
onDeleteProject={handleDeleteAgentProject}
|
||
onOpenChange={setIsAgentProjectSheetOpen}
|
||
onRenameProject={handleRenameAgentProject}
|
||
onSelectProject={handleSelectAgentProject}
|
||
open={isAgentProjectSheetOpen}
|
||
/>
|
||
</div>
|
||
) : null;
|
||
|
||
const loginDialogElement = (
|
||
<Dialog onOpenChange={setIsLoginDialogOpen} open={isLoginDialogOpen}>
|
||
<DialogContent className="max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>登录已失效</DialogTitle>
|
||
<DialogDescription>{loginDialogMessage}</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter className="gap-2 sm:justify-end">
|
||
<Button
|
||
onClick={() => {
|
||
void checkCanvasAuthSession();
|
||
}}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
刷新状态
|
||
</Button>
|
||
<Button onClick={redirectToLogin} type="button">
|
||
去登录
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
|
||
const agentProjectSelectionScreen =
|
||
isAgentMode && !hasActiveAgentProject ? (
|
||
<div className="min-h-screen bg-[#050607] text-white">
|
||
<AgentCanvasTopNavDark
|
||
activeKey="agent"
|
||
rightSlot={agentProjectNavSlot}
|
||
siteLogo={siteSettingsData?.siteLogo}
|
||
siteLogoDark={siteSettingsData?.siteLogoDark}
|
||
siteName={siteSettingsData?.siteName}
|
||
/>
|
||
<main className="flex min-h-screen items-center justify-center px-6 pt-24 pb-8">
|
||
<div className="w-full max-w-[860px] rounded-[36px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.04)_0%,rgba(255,255,255,0.02)_100%)] p-8 shadow-[0_24px_80px_rgba(0,0,0,0.35)] md:p-10">
|
||
<div className="mx-auto max-w-[620px] text-center">
|
||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl border border-emerald-400/20 bg-emerald-500/[0.10] text-emerald-200">
|
||
<FolderOpen className="h-8 w-8" />
|
||
</div>
|
||
<h1 className="mt-6 font-semibold text-3xl text-white tracking-[0.02em] md:text-4xl">
|
||
先选择一个生图项目
|
||
</h1>
|
||
<p className="mt-4 text-sm text-white/55 leading-7 md:text-base">
|
||
打开历史项目可以继续沿用之前的生图结果、模式和工作流。只有在你明确点击新建项目时,系统才会创建一个全新的风格化项目。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="mt-8 grid gap-4 md:grid-cols-2">
|
||
<button
|
||
className="rounded-[28px] border border-white/10 bg-black/30 p-5 text-left transition-colors hover:border-white/18 hover:bg-white/[0.04]"
|
||
onClick={() => setIsAgentProjectSheetOpen(true)}
|
||
type="button"
|
||
>
|
||
<div className="flex items-center gap-3 text-emerald-200">
|
||
<Grid3X3 className="h-5 w-5" />
|
||
<span className="font-medium text-sm">打开历史项目</span>
|
||
</div>
|
||
<div className="mt-3 text-sm text-white/55 leading-6">
|
||
从项目列表里继续昨天或今天的风格化项目,避免生成结果分散在多个空项目中。
|
||
</div>
|
||
</button>
|
||
|
||
<button
|
||
className="rounded-[28px] border border-emerald-400/20 bg-emerald-500/[0.10] p-5 text-left transition-colors hover:border-emerald-300/35 hover:bg-emerald-500/[0.16]"
|
||
onClick={() => {
|
||
void handleCreateAgentProject();
|
||
}}
|
||
type="button"
|
||
>
|
||
<div className="flex items-center gap-3 text-emerald-100">
|
||
<Plus className="h-5 w-5" />
|
||
<span className="font-medium text-sm">新建空白项目</span>
|
||
</div>
|
||
<div className="mt-3 text-emerald-50/70 text-sm leading-6">
|
||
仅在你要开始一条全新的场景生图流程时再创建,避免项目列表里自动冒出一堆无效项目。
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
) : null;
|
||
|
||
if (isAgentMode) {
|
||
if (!hasActiveAgentProject) {
|
||
return (
|
||
<>
|
||
{agentProjectSelectionScreen}
|
||
{loginDialogElement}
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<AgentGenerationWorkspaceDarkV2
|
||
agentModeState={agentModeState}
|
||
brandModelPanel={
|
||
<AgentWorkflowBrandModelPicker
|
||
brand={workflowInputs.brand}
|
||
onSelectBrandModel={handleSelectedBrandModelChange}
|
||
selectedBrandModel={selectedBrandModel}
|
||
theme="dark"
|
||
/>
|
||
}
|
||
brandStorePanel={
|
||
isWorkflowPresetStepEnabled ? (
|
||
<AgentWorkflowBrandStorePanel
|
||
config={resolvedWorkflowUiConfig}
|
||
inputs={workflowInputs}
|
||
onInputsChange={handleWorkflowInputsChange}
|
||
psdTemplateCategoryTagIndex={
|
||
siteSettingsData?.agentPsdTemplateCategoryTagIndex
|
||
}
|
||
theme="dark"
|
||
/>
|
||
) : null
|
||
}
|
||
chatId={agentModeChatId}
|
||
deletedResultCollections={agentProjectDeletedResultCollections}
|
||
getLatestWorkflowContext={getLatestWorkflowContext}
|
||
materialSelectionPanel={
|
||
isWorkflowMaterialStepEnabled ? (
|
||
<AgentWorkflowMaterialSelectionPanel
|
||
appearanceRecognitionEnabled={appearanceRecognitionEnabled}
|
||
disabled={isWorkflowPresetStepEnabled && !selectedAgentPresetId}
|
||
inputs={workflowInputs}
|
||
materials={workflowMaterials}
|
||
modelImageRequirement={materialModelImageRequirement}
|
||
onAppearanceRecognitionEnabledChange={
|
||
handleAppearanceRecognitionEnabledChange
|
||
}
|
||
onInputsChange={handleWorkflowInputsChange}
|
||
onMaterialsChange={handleWorkflowMaterialsChange}
|
||
productSelectionLimit={materialProductSelectionLimit}
|
||
theme="dark"
|
||
totalUploadRequirement={materialTotalUploadRequirement}
|
||
/>
|
||
) : null
|
||
}
|
||
onAgentModeStateChange={setAgentModeState}
|
||
onClearPreset={() => setSelectedAgentPresetId(null)}
|
||
onDeleteResultImage={handleAgentResultImageDelete}
|
||
onOpenCanvasEditor={() => {
|
||
updateCanvasSearchParams({
|
||
mode: null,
|
||
});
|
||
}}
|
||
onResultPreviewUrlsChange={handleAgentResultPreviewUrlsChange}
|
||
onWorkspaceStateChange={setAgentWorkspaceState}
|
||
presetPanel={
|
||
isWorkflowPresetStepEnabled ? (
|
||
<AgentPresetPanelDarkV2
|
||
embedded
|
||
embeddedEntityLabel="生图模式"
|
||
onSelectPreset={setSelectedAgentPresetId}
|
||
selectedPresetId={selectedAgentPresetId}
|
||
/>
|
||
) : null
|
||
}
|
||
projectId={agentProjectId}
|
||
resultCollections={agentProjectResultCollections}
|
||
selectedAgentPresetId={
|
||
isAgentWorkspaceEnabled ? effectiveSelectedAgentPresetId : null
|
||
}
|
||
selectedBrandModel={selectedBrandModel}
|
||
showcaseConfig={siteSettingsData?.agentWorkflowShowcaseConfig}
|
||
siteLogo={siteSettingsData?.siteLogo}
|
||
siteLogoDark={siteSettingsData?.siteLogoDark}
|
||
siteName={siteSettingsData?.siteName}
|
||
syncGeneratedResultsToCanvas={false}
|
||
topNavRightSlot={agentProjectNavSlot}
|
||
workflowContext={workflowContext}
|
||
workflowFeatureSettings={workflowPageFeatureSettings}
|
||
// 嵌入的风格化选择面板
|
||
workflowPromptOptimizationEnabled={appearanceRecognitionEnabled}
|
||
workflowStylePanel={
|
||
<AgentWorkflowPanelDarkV2
|
||
config={resolvedWorkflowUiConfig}
|
||
embedded
|
||
inputs={workflowInputs}
|
||
materials={workflowMaterials}
|
||
onInputsChange={handleWorkflowInputsChange}
|
||
onMaterialsChange={handleWorkflowMaterialsChange}
|
||
onSelectionsChange={handleWorkflowSelectionsChange}
|
||
psdTemplateCategoryTagIndex={
|
||
siteSettingsData?.agentPsdTemplateCategoryTagIndex
|
||
}
|
||
selections={sanitizedWorkflowSelections}
|
||
showBrandStoreSection={false}
|
||
/>
|
||
}
|
||
workspaceState={agentWorkspaceState}
|
||
/>
|
||
{loginDialogElement}
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<TooltipProvider>
|
||
<div className="relative h-dvh w-full overflow-hidden overscroll-none bg-background">
|
||
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
|
||
<div
|
||
className="absolute inset-0 z-0"
|
||
data-canvas-stage="true"
|
||
key={`workspace-${id}`}
|
||
>
|
||
<CanvasWorkspace
|
||
emptyStateDescription={
|
||
isVideoMode
|
||
? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成"
|
||
: '点击顶部"添加"按钮开始创作'
|
||
}
|
||
occlusionRight={panelOcclusionRight}
|
||
onBlankCanvasDoubleClick={handleOpenVideoCanvasInsertMenu}
|
||
showEmptyState={!isVideoMode}
|
||
workspaceLabel={workspaceLabel}
|
||
workspaceVariant={isVideoMode ? "video" : "canvas"}
|
||
layerSplitEnabled={siteSettingsData?.layerSplitEnabled === true}
|
||
/>
|
||
</div>
|
||
|
||
{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) => (
|
||
<button
|
||
className="flex w-full items-center rounded-xl px-3 py-2 text-left font-medium text-[13px] text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-950 dark:text-slate-200 dark:hover:bg-zinc-900"
|
||
key={child.id}
|
||
onClick={() =>
|
||
handleInsertWorkflowNodeFromMenu(child.templateType)
|
||
}
|
||
type="button"
|
||
>
|
||
{child.label}
|
||
</button>
|
||
);
|
||
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 (
|
||
<div className="rounded-2xl" key={item.id}>
|
||
<div
|
||
className={cn(
|
||
"flex items-center gap-3 rounded-2xl px-4 py-3 font-semibold text-[15px]",
|
||
textClassName
|
||
)}
|
||
>
|
||
<Icon className={cn("h-5 w-5", iconClassName)} />
|
||
<span className="flex-1">{item.label}</span>
|
||
<ChevronRight className="h-4 w-4 opacity-60" />
|
||
</div>
|
||
<div className="ml-8 mr-2 mb-1 grid gap-0.5 border-slate-100 border-l pl-2 dark:border-zinc-800">
|
||
{item.children.map(renderChildButton)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-left font-medium text-[15px] transition-colors hover:bg-slate-50 dark:hover:bg-zinc-900",
|
||
textClassName
|
||
)}
|
||
key={item.id}
|
||
onClick={() =>
|
||
item.templateType &&
|
||
handleInsertWorkflowNodeFromMenu(item.templateType)
|
||
}
|
||
type="button"
|
||
>
|
||
<Icon className={cn("h-5 w-5", iconClassName)} />
|
||
<span>{item.label}</span>
|
||
</button>
|
||
);
|
||
};
|
||
const highlightedItem = VIDEO_CANVAS_INSERT_MENU_ITEMS.at(-1);
|
||
|
||
return (
|
||
<div
|
||
className="fixed z-[70] max-h-[calc(100vh-24px)] overflow-y-auto rounded-[28px] border border-slate-200 bg-white p-2.5 text-foreground shadow-[0_28px_80px_-32px_rgba(15,23,42,0.35)] dark:border-zinc-800 dark:bg-zinc-950"
|
||
data-canvas-overlay="true"
|
||
onClick={(event) => event.stopPropagation()}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
ref={videoCanvasInsertMenuRef}
|
||
style={{
|
||
left: `${menuLeft}px`,
|
||
top: `${menuTop}px`,
|
||
width: `${menuWidth}px`,
|
||
}}
|
||
>
|
||
<div className="px-4 py-3 font-semibold text-[14px] text-slate-500 dark:text-zinc-400">
|
||
@{siteSettingsData?.siteName || "视频画布"}
|
||
</div>
|
||
|
||
<div className="grid gap-0.5">
|
||
{VIDEO_CANVAS_INSERT_MENU_ITEMS.slice(0, -1).map((item) =>
|
||
renderInsertItem(item)
|
||
)}
|
||
</div>
|
||
|
||
{highlightedItem ? (
|
||
<>
|
||
<div className="mx-3 my-2 h-px bg-slate-200 dark:bg-zinc-800" />
|
||
{renderInsertItem(highlightedItem, true)}
|
||
</>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})()
|
||
: null}
|
||
|
||
{isVideoMode && isVideoModuleSidebarEnabled ? (
|
||
<div
|
||
className="pointer-events-auto absolute top-16 left-4 z-20"
|
||
style={{ width: `${videoModuleSidebarWidth}px` }}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"overflow-hidden rounded-[24px] border border-black/10 bg-white shadow-[0_10px_30px_rgba(15,23,42,0.06)] dark:border-zinc-800 dark:bg-zinc-950",
|
||
isVideoModuleSidebarCollapsed ? "w-[58px]" : "w-[236px]"
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"flex items-center border-b border-black/5 px-2.5 py-2.5 dark:border-zinc-800",
|
||
isVideoModuleSidebarCollapsed
|
||
? "relative justify-center"
|
||
: "justify-between"
|
||
)}
|
||
>
|
||
{isVideoModuleSidebarCollapsed ? (
|
||
<div className="flex w-full justify-center">
|
||
<span className="flex h-9 w-9 items-center justify-center rounded-[14px] border border-black/10 bg-[#f7f8fb] text-[#5b76fe]">
|
||
<Layers className="h-4 w-4" />
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-2.5">
|
||
<span className="flex h-9 w-9 items-center justify-center rounded-[14px] border border-black/10 bg-[#f7f8fb] text-[#5b76fe]">
|
||
<Layers className="h-4 w-4" />
|
||
</span>
|
||
<div>
|
||
<div className="text-[15px] font-semibold text-[#16181d]">
|
||
视频模块
|
||
</div>
|
||
<div className="text-[12px] text-[#7a766f]">
|
||
工作流分层
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<Button
|
||
className={cn(
|
||
"h-8 w-8 rounded-[12px] text-[#7a766f] hover:bg-[#f6f5f4] hover:text-[#16181d] dark:hover:bg-zinc-900",
|
||
isVideoModuleSidebarCollapsed && "absolute top-2.5 right-2.5"
|
||
)}
|
||
onClick={() =>
|
||
setIsVideoModuleSidebarCollapsed((prev) => !prev)
|
||
}
|
||
size="icon"
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
{isVideoModuleSidebarCollapsed ? (
|
||
<ChevronRight className="h-4 w-4" />
|
||
) : (
|
||
<ChevronLeft className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
</div>
|
||
|
||
{isVideoModuleSidebarCollapsed ? (
|
||
<div className="flex flex-col items-center gap-1.5 px-2 py-2">
|
||
{VIDEO_CANVAS_SIDEBAR_GROUPS.map((group) => {
|
||
const GroupIcon = group.icon;
|
||
const groupStyle =
|
||
VIDEO_CANVAS_SIDEBAR_GROUP_STYLES[group.id];
|
||
return (
|
||
<button
|
||
className="flex h-10 w-10 items-center justify-center rounded-[12px] border border-transparent bg-transparent transition-colors hover:bg-[#f6f5f4] dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100"
|
||
key={group.id}
|
||
onClick={() => {
|
||
setIsVideoModuleSidebarCollapsed(false);
|
||
setVideoModuleSectionOpen((prev) => ({
|
||
...prev,
|
||
[group.id]: true,
|
||
}));
|
||
}}
|
||
title={group.label}
|
||
type="button"
|
||
style={{ color: groupStyle.iconText }}
|
||
>
|
||
<GroupIcon className="h-4 w-4" />
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<ScrollArea className="max-h-[calc(100vh-164px)]">
|
||
<div className="space-y-2 p-2.5">
|
||
{VIDEO_CANVAS_SIDEBAR_GROUPS.map((group) => {
|
||
const GroupIcon = group.icon;
|
||
const groupStyle =
|
||
VIDEO_CANVAS_SIDEBAR_GROUP_STYLES[group.id];
|
||
return (
|
||
<Collapsible
|
||
key={group.id}
|
||
onOpenChange={(open) =>
|
||
setVideoModuleSectionOpen((prev) => ({
|
||
...prev,
|
||
[group.id]: open,
|
||
}))
|
||
}
|
||
open={videoModuleSectionOpen[group.id] !== false}
|
||
>
|
||
<div className="overflow-hidden rounded-[16px] border border-black/5 bg-[#fbfbfa] dark:border-zinc-800 dark:bg-zinc-900">
|
||
<CollapsibleTrigger className="flex w-full items-center justify-between gap-3 px-2.5 py-2.5 text-left transition-colors hover:bg-black/[0.018]">
|
||
<div className="flex min-w-0 items-center gap-2.5">
|
||
<span
|
||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[12px] border dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-300"
|
||
style={{
|
||
backgroundColor: groupStyle.iconBg,
|
||
borderColor: groupStyle.iconBorder,
|
||
color: groupStyle.iconText,
|
||
}}
|
||
>
|
||
<GroupIcon className="h-4 w-4" />
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<span className="block truncate text-[14px] font-semibold text-[#16181d]">
|
||
{group.label}
|
||
</span>
|
||
<span className="block truncate text-[11px] text-[#8b867e]">
|
||
{group.description}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<span className="rounded-full border border-black/8 bg-white px-2 py-0.5 text-[11px] text-[#7a766f] dark:border-zinc-800 dark:bg-zinc-950">
|
||
{group.nodeTypes.length}
|
||
</span>
|
||
<ChevronDown
|
||
className={cn(
|
||
"h-4 w-4 shrink-0 text-[#7a766f] transition-transform",
|
||
videoModuleSectionOpen[group.id] !== false
|
||
? "rotate-0"
|
||
: "-rotate-90"
|
||
)}
|
||
/>
|
||
</div>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent>
|
||
<div className="border-t border-black/5 px-2.5 pb-2.5 pt-1.5 dark:border-zinc-800">
|
||
<div
|
||
className="ml-4 space-y-0.5 border-l pl-3"
|
||
style={{ borderColor: groupStyle.rail }}
|
||
>
|
||
{group.nodeTypes.map((type) => {
|
||
const definition =
|
||
getVideoWorkflowNodeDefinition(type);
|
||
if (!definition) return null;
|
||
const Icon =
|
||
getVideoWorkflowQuickConnectIcon(type);
|
||
return (
|
||
<button
|
||
className="flex w-full items-center gap-2 rounded-[12px] border border-transparent bg-transparent px-2 py-1.5 text-left transition-all hover:bg-white hover:shadow-[0_1px_2px_rgba(15,23,42,0.04)] dark:hover:bg-zinc-950"
|
||
key={type}
|
||
onClick={() =>
|
||
handleAddWorkflowNode(type)
|
||
}
|
||
title={definition.label}
|
||
type="button"
|
||
>
|
||
<span
|
||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[10px] border border-transparent dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300"
|
||
style={{
|
||
color: groupStyle.iconText,
|
||
backgroundColor: groupStyle.iconBg,
|
||
}}
|
||
>
|
||
<Icon className="h-3.5 w-3.5" />
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<span className="block truncate text-[13px] font-medium text-[#23211e]">
|
||
{definition.label}
|
||
</span>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</CollapsibleContent>
|
||
</div>
|
||
</Collapsible>
|
||
);
|
||
})}
|
||
</div>
|
||
</ScrollArea>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{isVideoMode &&
|
||
selectedWorkflowQuickConnectAnchor &&
|
||
selectedWorkflowQuickConnectSourceNode &&
|
||
selectedVideoWorkflowQuickConnects.length > 0 &&
|
||
!videoCanvasInsertMenu ? (
|
||
<div
|
||
className="pointer-events-none absolute z-20"
|
||
style={{
|
||
left: selectedWorkflowQuickConnectAnchor.x,
|
||
top: selectedWorkflowQuickConnectAnchor.y,
|
||
}}
|
||
>
|
||
<svg
|
||
className="pointer-events-none absolute top-0 left-0 overflow-visible"
|
||
height="1"
|
||
width="1"
|
||
>
|
||
{selectedVideoWorkflowQuickConnects.map((item, index, items) => {
|
||
const total = items.length;
|
||
const y = (index - (total - 1) / 2) * 72;
|
||
const x = 96 + Math.abs(y) * 0.28;
|
||
const direction = selectedWorkflowQuickConnectAnchor.openLeft
|
||
? -1
|
||
: 1;
|
||
const targetX = x * direction;
|
||
const cp1X = 36 * direction;
|
||
const cp2X = (x - 26) * direction;
|
||
return (
|
||
<path
|
||
d={`M 0 0 C ${cp1X} 0, ${cp2X} ${y}, ${targetX} ${y}`}
|
||
fill="none"
|
||
key={`quick-connect-line-${item.type}`}
|
||
stroke="rgba(91,118,254,0.92)"
|
||
strokeLinecap="round"
|
||
strokeWidth="2.25"
|
||
/>
|
||
);
|
||
})}
|
||
</svg>
|
||
|
||
<button
|
||
aria-label="展开关联模块"
|
||
className="-translate-x-1/2 -translate-y-1/2 pointer-events-auto absolute top-0 left-0 flex h-14 w-14 items-center justify-center rounded-[20px] border border-[#d7dff1] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_18px_34px_rgba(22,24,29,0.12)] transition-colors hover:bg-[#f5f8ff] hover:text-[#3954db] dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-300 dark:hover:bg-zinc-900 dark:hover:text-zinc-100"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setVideoCanvasInsertMenu({
|
||
screenPosition: {
|
||
x: selectedWorkflowQuickConnectAnchor.x,
|
||
y: selectedWorkflowQuickConnectAnchor.y,
|
||
},
|
||
canvasPosition: {
|
||
x:
|
||
selectedWorkflowQuickConnectSourceNode.position.x +
|
||
selectedWorkflowQuickConnectSourceNode.size.width,
|
||
y:
|
||
selectedWorkflowQuickConnectSourceNode.position.y +
|
||
selectedWorkflowQuickConnectSourceNode.size.height / 2,
|
||
},
|
||
sourceNodeId: selectedWorkflowQuickConnectSourceNode.id,
|
||
openLeft: selectedWorkflowQuickConnectAnchor.openLeft,
|
||
});
|
||
}}
|
||
type="button"
|
||
>
|
||
<Plus className="h-8 w-8" />
|
||
</button>
|
||
|
||
{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 (
|
||
<button
|
||
aria-label={item.label}
|
||
className="-translate-x-1/2 -translate-y-1/2 pointer-events-auto absolute flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dbe2ee] bg-white text-[#566377] shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_14px_26px_rgba(22,24,29,0.1)] transition-all hover:scale-[1.03] hover:border-[#cfdcff] hover:bg-[#f5f8ff] hover:text-[#3954db] dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-300 dark:hover:bg-zinc-900 dark:hover:text-zinc-100"
|
||
key={`quick-connect-node-${item.type}`}
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const position = definition
|
||
? {
|
||
x: selectedWorkflowQuickConnectAnchor.openLeft
|
||
? selectedWorkflowQuickConnectSourceNode.position.x -
|
||
definition.size.width -
|
||
180
|
||
: selectedWorkflowQuickConnectSourceNode.position.x +
|
||
selectedWorkflowQuickConnectSourceNode.size.width +
|
||
180,
|
||
y:
|
||
selectedWorkflowQuickConnectSourceNode.position.y +
|
||
selectedWorkflowQuickConnectSourceNode.size.height / 2 -
|
||
definition.size.height / 2 +
|
||
y / viewport.zoom,
|
||
}
|
||
: null;
|
||
handleAddWorkflowNode(item.type, {
|
||
connectFromSelection: true,
|
||
connectFromNodeId: selectedWorkflowQuickConnectSourceNode.id,
|
||
...(position ? { position } : {}),
|
||
});
|
||
}}
|
||
style={{
|
||
left: `${x * direction}px`,
|
||
top: `${y}px`,
|
||
}}
|
||
title={item.label}
|
||
type="button"
|
||
>
|
||
<Icon className="h-6 w-6" />
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
|
||
{isVideoMode &&
|
||
selectedVideoWorkflowDefinition &&
|
||
selectedWorkflowIsActionable ? (
|
||
<div
|
||
className="pointer-events-none absolute top-16 z-20 flex w-[min(360px,calc(100vw-32px))] flex-col gap-3"
|
||
style={{ left: `${selectedWorkflowPanelLeft}px` }}
|
||
>
|
||
{selectedWorkflowIsActionable ? (
|
||
<Card className={VIDEO_CANVAS_PANEL_CLASS}>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<CardTitle className="text-sm">
|
||
{selectedVideoWorkflowDefinition.label}
|
||
</CardTitle>
|
||
<CardDescription className="mt-1 line-clamp-1 text-xs">
|
||
{selectedWorkflowActionHint}
|
||
</CardDescription>
|
||
</div>
|
||
<Badge
|
||
className={VIDEO_CANVAS_PANEL_BADGE_CLASS}
|
||
variant="outline"
|
||
>
|
||
执行
|
||
</Badge>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3 pb-4">
|
||
<div className="flex flex-wrap gap-2">
|
||
<Badge
|
||
className={VIDEO_CANVAS_PANEL_BADGE_CLASS}
|
||
variant="outline"
|
||
>
|
||
提示词 {selectedWorkflowInputs.promptCount}
|
||
</Badge>
|
||
<Badge
|
||
className={VIDEO_CANVAS_PANEL_BADGE_CLASS}
|
||
variant="outline"
|
||
>
|
||
图片 {selectedWorkflowInputs.imageCount}
|
||
</Badge>
|
||
<Badge
|
||
className={VIDEO_CANVAS_PANEL_BADGE_CLASS}
|
||
variant="outline"
|
||
>
|
||
视频 {selectedWorkflowInputs.videoCount}
|
||
</Badge>
|
||
{selectedVideoWorkflowType === "storyboard-node" ? (
|
||
<Badge
|
||
className={VIDEO_CANVAS_PANEL_BADGE_CLASS}
|
||
variant="outline"
|
||
>
|
||
分镜 {selectedWorkflowStoryboardShots.length}
|
||
</Badge>
|
||
) : null}
|
||
{selectedVideoWorkflowType === "storyboard-node" ? (
|
||
<Badge
|
||
className={VIDEO_CANVAS_PANEL_BADGE_CLASS}
|
||
variant="outline"
|
||
>
|
||
下游 {selectedStoryboardBatchTargets.length}
|
||
</Badge>
|
||
) : null}
|
||
{selectedWorkflowStructuredData.characters.length > 0 ? (
|
||
<Badge
|
||
className={VIDEO_CANVAS_PANEL_BADGE_CLASS}
|
||
variant="outline"
|
||
>
|
||
角色 {selectedWorkflowStructuredData.characters.length}
|
||
</Badge>
|
||
) : null}
|
||
{selectedWorkflowStructuredData.scenes.length > 0 ? (
|
||
<Badge
|
||
className={VIDEO_CANVAS_PANEL_BADGE_CLASS}
|
||
variant="outline"
|
||
>
|
||
场景 {selectedWorkflowStructuredData.scenes.length}
|
||
</Badge>
|
||
) : null}
|
||
{selectedWorkflowStructuredData.characterAssets.length >
|
||
0 ? (
|
||
<Badge
|
||
className={VIDEO_CANVAS_PANEL_BADGE_CLASS}
|
||
variant="outline"
|
||
>
|
||
角色资产{" "}
|
||
{selectedWorkflowStructuredData.characterAssets.length}
|
||
</Badge>
|
||
) : null}
|
||
{selectedWorkflowStructuredData.sceneAssets.length > 0 ? (
|
||
<Badge
|
||
className={VIDEO_CANVAS_PANEL_BADGE_CLASS}
|
||
variant="outline"
|
||
>
|
||
场景资产{" "}
|
||
{selectedWorkflowStructuredData.sceneAssets.length}
|
||
</Badge>
|
||
) : null}
|
||
{selectedVideoWorkflowType === "custom-agent" &&
|
||
selectedWorkflowLinkedAgentProject.id ? (
|
||
<Badge
|
||
className={VIDEO_CANVAS_PANEL_BADGE_CLASS}
|
||
variant="outline"
|
||
>
|
||
Agent 已连接
|
||
</Badge>
|
||
) : null}
|
||
{selectedVideoWorkflowType === "custom-agent" &&
|
||
selectedWorkflowAgentResultCount > 0 ? (
|
||
<Badge
|
||
className={VIDEO_CANVAS_PANEL_BADGE_CLASS}
|
||
variant="outline"
|
||
>
|
||
Agent 结果 {selectedWorkflowAgentResultCount}
|
||
</Badge>
|
||
) : null}
|
||
{selectedVideoWorkflowType === "custom-agent" &&
|
||
selectedWorkflowPendingAgentResultCount > 0 ? (
|
||
<Badge
|
||
className={VIDEO_CANVAS_PANEL_BADGE_CLASS}
|
||
variant="outline"
|
||
>
|
||
待回流 {selectedWorkflowPendingAgentResultCount}
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className={VIDEO_CANVAS_SUBSECTION_CLASS}>
|
||
<div className="line-clamp-3 whitespace-pre-line text-foreground/85 text-xs leading-5">
|
||
{selectedWorkflowPrompt.trim()
|
||
? selectedWorkflowPrompt
|
||
: "当前还没有抽取到可执行提示词,先在上游接入脚本、分镜或参考素材。"}
|
||
</div>
|
||
</div>
|
||
|
||
{selectedVideoWorkflowType === "custom-agent" &&
|
||
selectedWorkflowLinkedAgentProject.id ? (
|
||
<div className={VIDEO_CANVAS_SUBSECTION_CLASS}>
|
||
<div className="text-[11px] text-muted-foreground uppercase tracking-[0.16em]">
|
||
已关联 Agent 项目
|
||
</div>
|
||
<div className="mt-2 font-medium text-foreground text-sm">
|
||
{selectedWorkflowLinkedAgentProject.title ||
|
||
selectedWorkflowLinkedAgentProject.id}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="grid gap-2">
|
||
<Button
|
||
className="w-full rounded-[18px] bg-[#5b76fe] text-white shadow-[0_16px_30px_-18px_rgba(91,118,254,0.56)] hover:bg-[#4965ef]"
|
||
disabled={isAnyWorkflowExecutionRunning}
|
||
onClick={() => void handleRunSelectedWorkflowNode()}
|
||
type="button"
|
||
>
|
||
{isSelectedWorkflowPrimaryRunning ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
执行中...
|
||
</>
|
||
) : (
|
||
selectedWorkflowActionLabel
|
||
)}
|
||
</Button>
|
||
|
||
{selectedVideoWorkflowType === "custom-agent" ? (
|
||
<Button
|
||
className="w-full rounded-[18px] border border-[#dbe2ee] bg-white text-[#202430] hover:bg-[#f7f9ff] dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-900"
|
||
disabled={isAnyWorkflowExecutionRunning}
|
||
onClick={() => void handleOpenSelectedWorkflowInAgent()}
|
||
type="button"
|
||
variant="secondary"
|
||
>
|
||
{isSelectedWorkflowAgentRunning ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
同步 Agent 项目中...
|
||
</>
|
||
) : selectedWorkflowLinkedAgentProject.id ? (
|
||
"继续 Agent 项目"
|
||
) : (
|
||
"送入 Agent 工作台"
|
||
)}
|
||
</Button>
|
||
) : null}
|
||
|
||
{selectedVideoWorkflowType === "custom-agent" &&
|
||
selectedWorkflowAgentResultAssets.length > 0 ? (
|
||
<Button
|
||
className="w-full rounded-[18px] border border-[#dbe2ee] bg-white text-[#202430] hover:bg-[#f7f9ff] dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-900"
|
||
disabled={isAnyWorkflowExecutionRunning}
|
||
onClick={() =>
|
||
void handleCreateSelectedAgentResultBranches()
|
||
}
|
||
type="button"
|
||
variant="secondary"
|
||
>
|
||
{isSelectedWorkflowBranchRunning ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
展开结果分支中...
|
||
</>
|
||
) : (
|
||
`展开结果分支 (${selectedWorkflowAgentResultAssets.length})`
|
||
)}
|
||
</Button>
|
||
) : null}
|
||
|
||
{selectedStoryboardCanBatchExecute ? (
|
||
<Button
|
||
className="w-full rounded-[18px] border border-[#dbe2ee] bg-white text-[#202430] hover:bg-[#f7f9ff] dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-900"
|
||
disabled={isAnyWorkflowExecutionRunning}
|
||
onClick={() => void handleRunStoryboardBatch()}
|
||
type="button"
|
||
variant="secondary"
|
||
>
|
||
{isSelectedWorkflowBatchRunning ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
批量执行中...
|
||
</>
|
||
) : (
|
||
`批量执行下游 (${selectedStoryboardBatchTargets.length})`
|
||
)}
|
||
</Button>
|
||
) : null}
|
||
|
||
<Button
|
||
className="w-full rounded-[18px] border border-[#f0d1d8] bg-[#fff7f8] text-[#d24f63] shadow-none hover:bg-[#ffeff2]"
|
||
disabled={isAnyWorkflowExecutionRunning}
|
||
onClick={handleDeleteSelectedWorkflowNode}
|
||
type="button"
|
||
variant="destructive"
|
||
>
|
||
<Trash2 className="mr-2 h-4 w-4" />
|
||
删除当前模块
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{brandEntry}
|
||
{agentDisabledNotice}
|
||
|
||
{/* 2. Floating Actions (Top Right) */}
|
||
{!isVideoMode && !isPanelOpen && (
|
||
<div
|
||
className="absolute z-20"
|
||
style={{ top: FLOATING_PANEL_INSET, right: FLOATING_PANEL_INSET }}
|
||
>
|
||
<Button
|
||
className={cn(
|
||
"h-10 rounded-full px-3 font-medium text-sm",
|
||
isVideoMode
|
||
? "border border-border/60 bg-background/94 text-foreground shadow-sm backdrop-blur-xl hover:bg-background supports-[backdrop-filter]:bg-background/82"
|
||
: "border bg-background/90 text-muted-foreground shadow-sm backdrop-blur-md hover:bg-background"
|
||
)}
|
||
onClick={() => setIsPanelOpen(true)}
|
||
variant="ghost"
|
||
>
|
||
<MessageCircle className="mr-1.5 h-4 w-4" />
|
||
对话
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 4. Floating Sidebar (Right) */}
|
||
{isVideoMode ? null : (
|
||
<div
|
||
className={cn(
|
||
"absolute z-10 transition-all duration-300 ease-in-out",
|
||
isPanelOpen
|
||
? "translate-x-0 opacity-100"
|
||
: "pointer-events-none translate-x-[120%] opacity-0"
|
||
)}
|
||
data-canvas-right-panel="true"
|
||
style={{
|
||
top: DOCKED_PANEL_INSET,
|
||
right: DOCKED_PANEL_INSET,
|
||
bottom: DOCKED_PANEL_INSET,
|
||
width: FLOATING_PANEL_WIDTH,
|
||
maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET}px)`,
|
||
}}
|
||
>
|
||
<div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950">
|
||
{panelMode === "chat" ? (
|
||
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
|
||
<div className="flex-1 overflow-hidden" key={`chat-${id}`}>
|
||
<CanvasChatPanel
|
||
agentEnabled={isCanvasAgentModeEnabled}
|
||
chatId={`canvas-${id}`}
|
||
chatScope="canvas"
|
||
onClose={() => setIsPanelOpen(false)}
|
||
onOpenFileList={() => setPanelMode("fileList")}
|
||
selectedAgentPresetId={
|
||
isCanvasAgentModeEnabled ? selectedAgentPresetId : null
|
||
}
|
||
workspaceVariant="canvas"
|
||
/>
|
||
</div>
|
||
) : (
|
||
// 文件列表
|
||
<CanvasFileListPanel
|
||
buildProjectUrl={buildWorkspaceProjectUrl}
|
||
createUrl={createNewCanvasUrl}
|
||
currentId={id}
|
||
onClose={() => setPanelMode("chat")}
|
||
workspaceLabel={workspaceLabel}
|
||
workspaceType={activeCanvasWorkspaceType}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isVideoMode && isVideoCanvasFileListOpen ? (
|
||
<div
|
||
className="absolute z-30 overflow-hidden rounded-[28px] border border-border/70 bg-background/95 shadow-[0_24px_80px_rgba(15,23,42,0.18)] backdrop-blur-xl"
|
||
style={{
|
||
top: DOCKED_PANEL_INSET,
|
||
right: DOCKED_PANEL_INSET,
|
||
bottom: `${FLOATING_BOTTOM_INSET + 76}px`,
|
||
width: 380,
|
||
maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET * 2}px)`,
|
||
}}
|
||
>
|
||
<CanvasFileListPanel
|
||
buildProjectUrl={buildWorkspaceProjectUrl}
|
||
createUrl={createNewCanvasUrl}
|
||
currentId={id}
|
||
onClose={() => setIsVideoCanvasFileListOpen(false)}
|
||
workspaceLabel={workspaceLabel}
|
||
workspaceType={activeCanvasWorkspaceType}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{isVideoMode && isVideoCanvasMarkerPanelOpen ? (
|
||
<div
|
||
className="absolute z-30"
|
||
data-canvas-overlay="true"
|
||
style={{
|
||
top: DOCKED_PANEL_INSET,
|
||
right: DOCKED_PANEL_INSET,
|
||
bottom: `${FLOATING_BOTTOM_INSET + 76}px`,
|
||
width: 324,
|
||
maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET * 2}px)`,
|
||
}}
|
||
>
|
||
<VideoCanvasMarkerPanel
|
||
hasSelection={Boolean(selectedVideoWorkflowNode)}
|
||
markers={selectedVideoCanvasMarkers}
|
||
onApply={handleApplyVideoCanvasMarker}
|
||
onClear={handleClearVideoCanvasMarkers}
|
||
onClose={() => setIsVideoCanvasMarkerPanelOpen(false)}
|
||
selectedLabel={selectedVideoCanvasMarkerLabel}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* 5. Floating Toolbar (Bottom) */}
|
||
<div
|
||
className="-translate-x-1/2 absolute left-1/2 z-20"
|
||
data-canvas-floating-toolbar="true"
|
||
style={{ bottom: `${FLOATING_BOTTOM_INSET}px` }}
|
||
>
|
||
<div
|
||
className={cn(
|
||
isVideoMode
|
||
? VIDEO_CANVAS_TOOLBAR_CLASS
|
||
: "flex items-center gap-1 rounded-[24px] border bg-white px-2.5 py-1.5 shadow-2xl dark:bg-zinc-950"
|
||
)}
|
||
style={{ marginLeft: `${floatingToolbarOffset}px` }}
|
||
>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
isVideoMode
|
||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||
: "h-9 w-9 rounded-xl",
|
||
tool === "select" &&
|
||
(isVideoMode
|
||
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||
: "bg-primary text-primary-foreground hover:bg-primary/90")
|
||
)}
|
||
onClick={() => setTool("select")}
|
||
size="icon"
|
||
variant={tool === "select" ? "secondary" : "ghost"}
|
||
>
|
||
<MousePointer className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">选择 (V)</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
isVideoMode
|
||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||
: "h-9 w-9 rounded-xl",
|
||
tool === "pan" &&
|
||
(isVideoMode
|
||
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||
: "bg-primary text-primary-foreground hover:bg-primary/90")
|
||
)}
|
||
onClick={() => setTool("pan")}
|
||
size="icon"
|
||
variant={tool === "pan" ? "secondary" : "ghost"}
|
||
>
|
||
<Hand className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">平移 (H)</TooltipContent>
|
||
</Tooltip>
|
||
|
||
{isVideoMode ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS}
|
||
disabled={isSaving}
|
||
onClick={handleSave}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
{isSaving ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Save className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">保存到云端</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
|
||
{isVideoMode ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS}
|
||
onClick={() => {
|
||
setIsVideoCanvasMarkerPanelOpen(false);
|
||
setIsVideoCanvasFileListOpen(true);
|
||
}}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<FolderOpen className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">云文件列表</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
|
||
{isVideoMode ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS,
|
||
isVideoCanvasMarkerPanelOpen &&
|
||
VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||
)}
|
||
onClick={() => {
|
||
setIsVideoCanvasFileListOpen(false);
|
||
setIsVideoCanvasMarkerPanelOpen((open) => !open);
|
||
}}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<Tag className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">标记</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
|
||
{isVideoMode ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
isVideoMode
|
||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||
: "h-9 w-9 rounded-xl",
|
||
tool === "draw" &&
|
||
(isVideoMode
|
||
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||
: "bg-primary text-primary-foreground hover:bg-primary/90")
|
||
)}
|
||
onClick={() => setTool("draw")}
|
||
size="icon"
|
||
variant={tool === "draw" ? "secondary" : "ghost"}
|
||
>
|
||
<GitBranch className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">工作流连线 (L)</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
|
||
{isAgentWorkspaceEnabled && !isVideoMode ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
isVideoMode
|
||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||
: "h-9 w-9 rounded-xl",
|
||
isAgentMode &&
|
||
(isVideoMode
|
||
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||
: "bg-primary text-primary-foreground hover:bg-primary/90")
|
||
)}
|
||
onClick={handleToggleAgentMode}
|
||
size="icon"
|
||
variant={isAgentMode ? "secondary" : "ghost"}
|
||
>
|
||
<Wand2 className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">风格化模式</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
isVideoMode
|
||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||
: "h-9 w-9 rounded-xl"
|
||
)}
|
||
onClick={() => setSceneFusionDialogOpen(true)}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<Layers className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">批量处理</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
isVideoMode
|
||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||
: "h-9 w-9 rounded-xl"
|
||
)}
|
||
onClick={handleAddImageNode}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<ImagePlus className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">
|
||
{isVideoMode ? "添加图片输入" : "添加图片"}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
isVideoMode
|
||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||
: "h-9 w-9 rounded-xl"
|
||
)}
|
||
onClick={handleAddTextNode}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<Type className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">
|
||
{isVideoMode ? "添加脚本节点" : "添加文本"}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
|
||
{isVideoMode ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS}
|
||
onClick={handleAddVideoNode}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<Video className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">添加视频输入</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
|
||
{isVideoMode ? (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
className={VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<Grid3X3 className="h-4 w-4" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="center"
|
||
className="w-[420px] rounded-2xl border bg-background p-0 shadow-2xl"
|
||
side="top"
|
||
>
|
||
<div className="border-b px-4 py-3">
|
||
<div className="font-semibold text-foreground text-sm">
|
||
视频工作流节点库
|
||
</div>
|
||
<div className="mt-1 text-muted-foreground text-xs">
|
||
这里按参考 APP
|
||
的节点目录整理,先把结构补齐,再逐步接入具体能力。
|
||
</div>
|
||
</div>
|
||
<ScrollArea className="max-h-[60vh]">
|
||
<div className="space-y-4 p-3">
|
||
{VIDEO_WORKFLOW_LIBRARY_SECTIONS.map((section) => {
|
||
const sectionNodes = getVideoWorkflowSectionNodes(
|
||
section.id
|
||
);
|
||
return (
|
||
<div className="space-y-2" key={section.id}>
|
||
<div className="px-2">
|
||
<div className="font-semibold text-muted-foreground text-xs uppercase tracking-[0.16em]">
|
||
{section.label}
|
||
</div>
|
||
<div className="mt-1 text-muted-foreground text-xs">
|
||
{section.description}
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-1">
|
||
{sectionNodes.map((nodeDefinition) => (
|
||
<DropdownMenuItem
|
||
className="flex items-start gap-3 rounded-xl px-3 py-3"
|
||
key={nodeDefinition.type}
|
||
onClick={() =>
|
||
handleAddWorkflowNode(nodeDefinition.type)
|
||
}
|
||
>
|
||
<div
|
||
className="mt-0.5 h-2.5 w-2.5 rounded-full"
|
||
style={{
|
||
backgroundColor:
|
||
nodeDefinition.accentColor,
|
||
}}
|
||
/>
|
||
<div className="flex-1">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="font-medium text-foreground text-sm">
|
||
{nodeDefinition.label}
|
||
</span>
|
||
<span className="font-mono text-[10px] text-muted-foreground">
|
||
{nodeDefinition.type}
|
||
</span>
|
||
</div>
|
||
<div className="mt-1 text-muted-foreground text-xs leading-5">
|
||
{nodeDefinition.description}
|
||
</div>
|
||
</div>
|
||
</DropdownMenuItem>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</ScrollArea>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
) : null}
|
||
|
||
{siteSettingsData?.realtimeCanvasEnabled === true &&
|
||
siteSettingsData?.realtimeCanvasServiceEnabled !== false ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
isVideoMode
|
||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||
: "h-9 w-9 rounded-xl"
|
||
)}
|
||
onClick={() => router.push(`/canvas/${id}/realtime`)}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<PenTool className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">实时画布</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
|
||
{siteSettingsData?.realtimeCanvasEnabled === true &&
|
||
siteSettingsData?.realtimeCanvasServiceEnabled !== false ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
isVideoMode
|
||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||
: "h-9 w-9 rounded-xl"
|
||
)}
|
||
onClick={() => router.push(`/canvas/${id}/realtime?mode=enhance-pro`)}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<Sparkles className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">
|
||
{selectedImageNode
|
||
? "图像增强 Pro"
|
||
: "进入图像增强 Pro 后上传图片"}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
isVideoMode
|
||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||
: "h-9 w-9 rounded-xl"
|
||
)}
|
||
onClick={() => router.push(`/canvas/${id}/model3d`)}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<Box className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">3D生成</TooltipContent>
|
||
</Tooltip>
|
||
|
||
{isVideoMode ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS}
|
||
onClick={handleExportVideoCanvasProject}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">导出视频画布</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
|
||
{isVideoMode ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS}
|
||
disabled={isImportingVideoCanvas}
|
||
onClick={handleImportVideoCanvasProjectClick}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
{isImportingVideoCanvas ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Upload className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">导入视频画布</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
isVideoMode
|
||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||
: "h-9 w-9 rounded-xl"
|
||
)}
|
||
onClick={() => router.push(createNewCanvasUrl)}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">新建{workspaceLabel}</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
<input
|
||
accept="application/json,.json"
|
||
className="hidden"
|
||
onChange={handleImportVideoCanvasProjectChange}
|
||
ref={importVideoCanvasInputRef}
|
||
type="file"
|
||
/>
|
||
|
||
{/* 6. Bottom Left Compact Controls */}
|
||
<div
|
||
className="absolute z-20"
|
||
style={{
|
||
bottom: `${FLOATING_BOTTOM_INSET + 4}px`,
|
||
left: `${compactControlsLeft + 8}px`,
|
||
}}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"flex items-center gap-1 text-muted-foreground",
|
||
isVideoMode && VIDEO_CANVAS_COMPACT_CONTROLS_CLASS
|
||
)}
|
||
>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
"h-6 w-6 p-0 text-muted-foreground hover:bg-transparent hover:text-foreground",
|
||
isVideoMode ? "rounded-full" : "rounded-none"
|
||
)}
|
||
disabled={!canUndo}
|
||
onClick={undo}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<Undo2 className="h-3 w-3" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">撤销</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
"h-6 w-6 p-0 text-muted-foreground hover:bg-transparent hover:text-foreground",
|
||
isVideoMode ? "rounded-full" : "rounded-none"
|
||
)}
|
||
disabled={!canRedo}
|
||
onClick={redo}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<Redo2 className="h-3 w-3" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">重做</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<div className="mx-1 h-4 w-px bg-border/70" />
|
||
|
||
<Button
|
||
className={cn(
|
||
"h-6 w-6 p-0 text-muted-foreground hover:bg-transparent hover:text-foreground",
|
||
isVideoMode ? "rounded-full" : "rounded-none"
|
||
)}
|
||
onClick={zoomOut}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<ZoomOut className="h-3.5 w-3.5" />
|
||
</Button>
|
||
<span className="w-10 text-center font-medium text-[10px] text-muted-foreground tabular-nums">
|
||
{Math.round(viewport.zoom * 100)}%
|
||
</span>
|
||
<Button
|
||
className={cn(
|
||
"h-6 w-6 p-0 text-muted-foreground hover:bg-transparent hover:text-foreground",
|
||
isVideoMode ? "rounded-full" : "rounded-none"
|
||
)}
|
||
onClick={zoomIn}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<ZoomIn className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 7. 物品拾取模式覆盖层 */}
|
||
<ObjectPickerOverlay />
|
||
<SceneFusionDialog
|
||
onBatchAddToCanvas={handleBatchAddToCanvas}
|
||
onOpenChange={setSceneFusionDialogOpen}
|
||
open={sceneFusionDialogOpen}
|
||
/>
|
||
{loginDialogElement}
|
||
</div>
|
||
</TooltipProvider>
|
||
);
|
||
}
|