diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index b4374c7c6..2ce73194b 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -6,19 +6,30 @@ import useSWR from "swr"; import { AlertCircle, ArrowLeft, + Bot, Save, Undo2, Redo2, ZoomIn, ZoomOut, + Clapperboard, MousePointer, Hand, GitBranch, Plus, ImagePlus, + FileImage, + FileText, + Eye, + Film, Type, Loader2, MessageCircle, + Music2, + Palette, + Scissors, + Sparkles, + Table2, Video, Download, Upload, @@ -32,6 +43,7 @@ import { FolderOpen, Crosshair, Scan, + type LucideIcon, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -58,6 +70,9 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { @@ -176,6 +191,7 @@ import type { CanvasNode, CanvasState, CanvasWorkspaceType, + Position, ToolType, WorkflowNodeData, } from "@/types/canvas"; @@ -207,6 +223,26 @@ interface VideoCanvasProjectExportPayload { canvasData: CanvasState; } +interface VideoCanvasInsertMenuState { + screenPosition: Position; + canvasPosition: Position; +} + +interface VideoCanvasInsertMenuChild { + id: string; + label: string; + templateType: VideoWorkflowTemplateType; +} + +interface VideoCanvasInsertMenuItem { + id: string; + label: string; + icon: LucideIcon; + templateType?: VideoWorkflowTemplateType; + children?: VideoCanvasInsertMenuChild[]; + accent?: "highlight"; +} + const IMPORTABLE_CANVAS_NODE_TYPES = new Set([ "message", "image", @@ -225,6 +261,66 @@ const IMPORTABLE_CANVAS_TOOL_TYPES = new Set([ "text", ]); +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" }, + ], + }, +]; + function isPlainObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -732,6 +828,8 @@ export default function CanvasEditorPage({ params }: PageProps) { const [editTitle, setEditTitle] = useState(""); const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] = useState(null); const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode); + const [videoCanvasInsertMenu, setVideoCanvasInsertMenu] = + useState(null); const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false); // 面板模式: 'chat' 聊天面板, 'fileList' 文件列表 const [panelMode, setPanelMode] = useState<'chat' | 'fileList'>('chat'); @@ -752,6 +850,12 @@ export default function CanvasEditorPage({ params }: PageProps) { const [workflowSelections, setWorkflowSelections] = useState< Partial> >({}); + + useEffect(() => { + if (!isVideoMode) { + setVideoCanvasInsertMenu(null); + } + }, [isVideoMode]); const [appearanceRecognitionEnabled, setAppearanceRecognitionEnabled] = useState(true); const [selectedBrandModel, setSelectedBrandModel] = useState(null); @@ -4460,15 +4564,58 @@ export default function CanvasEditorPage({ params }: PageProps) { ); const handleAddWorkflowNode = useCallback( - (templateType: VideoWorkflowTemplateType) => { + ( + templateType: VideoWorkflowTemplateType, + options?: { + position?: Position; + connectFromSelection?: boolean; + selectNewNode?: boolean; + } + ) => { const definition = getVideoWorkflowNodeDefinition(templateType); if (!definition) return; - const placement = getVideoWorkflowPlacement(definition.size); - createCanvasNode(buildVideoWorkflowCanvasNode(templateType, placement)); + const placement = options?.position || getVideoWorkflowPlacement(definition.size); + createCanvasNode(buildVideoWorkflowCanvasNode(templateType, placement), { + connectFromSelection: options?.connectFromSelection ?? true, + 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 anchoredPosition = 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: false, + }); + setVideoCanvasInsertMenu(null); + }, + [handleAddWorkflowNode, videoCanvasInsertMenu] + ); + const handleCreateSelectedAgentResultBranches = useCallback(async () => { if ( !selectedVideoWorkflowNode || @@ -7077,6 +7224,7 @@ export default function CanvasEditorPage({ params }: PageProps) { workspaceLabel={workspaceLabel} workspaceVariant={isVideoMode ? "video" : "canvas"} showEmptyState={!isVideoMode} + onBlankCanvasDoubleClick={handleOpenVideoCanvasInsertMenu} emptyStateDescription={ isVideoMode ? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成" @@ -7085,6 +7233,115 @@ export default function CanvasEditorPage({ params }: PageProps) { /> + {isVideoMode && videoCanvasInsertMenu ? ( + { + if (!open) { + setVideoCanvasInsertMenu(null); + } + }} + modal={false} + > + +