diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 99d21ddf4..0d1987f16 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -12,16 +12,15 @@ import { Image as ImageIcon, Layers, Loader2, - MousePointer2, Music2, Plus, Play, Save, - Search, + Scissors, Settings2, Share2, - Sparkles, Trash2, + Upload, UserRound, Video, X, @@ -179,15 +178,8 @@ type ComposerSettings = { resolution: string; }; -type CanvasCreateKind = - | "image-input" - | "video-input" - | "audio-input" - | "text-input" - | "image-model" - | "video-model" - | "audio-model" - | "text-model"; +type AddModuleKind = "text" | "image" | "video" | "video-clip" | "audio" | "script"; +type CanvasCreateKind = AddModuleKind; type LiblibVideoCanvasReplicaProps = { canvasId: string; @@ -209,7 +201,20 @@ const TOOL_TYPE_META: Record< compose: { label: "组合", icon: GitBranch, accent: "#ffb8c8" }, }; -const PANEL_TABS = ["全部", "图片", "视频", "文本", "音频", "组合"] as const; +const ADD_NODE_MODULES: Array<{ + badge?: string; + description: string; + icon: LucideIcon; + kind: AddModuleKind; + label: string; +}> = [ + { kind: "text", label: "文本", description: "剧本、广告词、品牌文案", icon: FileText }, + { kind: "image", label: "图片", description: "海报、分镜、角色设计", icon: ImageIcon }, + { kind: "video", label: "视频", description: "创意广告、动画、电影", icon: Video }, + { kind: "video-clip", label: "视频合成", description: "多个视频片段合为一个", icon: Scissors, badge: "Beta" }, + { kind: "audio", label: "音频", description: "音效、配音、音乐", icon: Music2 }, + { kind: "script", label: "脚本", description: "创意脚本、生成故事板", icon: Layers, badge: "Beta" }, +]; const STARTER_CARDS = [ { @@ -645,6 +650,152 @@ function createSimpleNode(title: string, kind: CloneNodeKind, x: number, y: numb }; } +function createTargetModuleNode(kind: AddModuleKind, x: number, y: number, index: number): CloneNode { + if (kind === "script") { + return { + id: makeId("script"), + kind: "script", + title: `脚本 ${index}`, + subtitle: "脚本生成", + inputType: "text", + x, + y, + width: 520, + height: 360, + accent: "#f0d06b", + draftValues: { + action: "SCRIPT_GENERATE", + generatorType: "DEFAULT", + nodeType: "script", + rows: [], + scriptRole: "generator", + viewMode: "table", + }, + }; + } + + if (kind === "video-clip") { + return { + id: makeId("video-clip"), + kind: "model", + title: `视频合成 ${index}`, + subtitle: "视频合成", + inputType: "video", + x, + y, + width: 210, + height: 116, + accent: "#ffb8c8", + draftValues: { + action: "VIDEO_CLIP_RESOURCE", + nodeType: "video-clip", + params: {}, + url: [], + }, + }; + } + + const meta: Record, { + action: string; + accent: string; + kind: CloneNodeKind; + nodeType: NonNullable; + subtitle: string; + title: string; + }> = { + audio: { + action: "AUDIO_GENERATE", + accent: "#9ee6bb", + kind: "audio", + nodeType: "audio", + subtitle: "音频生成", + title: `音频节点 ${index}`, + }, + image: { + action: "IMAGE_GENERATE", + accent: "#8b96ff", + kind: "model", + nodeType: "image", + subtitle: "图片生成", + title: `图片节点 ${index}`, + }, + text: { + action: "TEXT_GENERATE", + accent: "#f0d06b", + kind: "text", + nodeType: "text", + subtitle: "文本生成", + title: `文本节点 ${index}`, + }, + video: { + action: "VIDEO_GENERATE", + accent: "#8b96ff", + kind: "model", + nodeType: "video", + subtitle: "视频生成", + title: `视频节点 ${index}`, + }, + }; + const item = meta[kind]; + return { + id: makeId(kind), + kind: item.kind, + title: item.title, + subtitle: item.subtitle, + inputType: item.nodeType, + x, + y, + width: item.nodeType === "text" ? 210 : 194, + height: item.nodeType === "text" ? 122 : 116, + accent: item.accent, + draftValues: { + action: item.action, + alt: item.nodeType === "image" ? `图片 ${index}` : undefined, + content: item.nodeType === "text" ? [] : undefined, + generatorType: "DEFAULT", + nodeType: item.nodeType, + poster: item.nodeType === "video" ? "" : undefined, + url: item.nodeType === "text" ? undefined : [], + }, + }; +} + +function createUploadedMediaNode(file: File, mediaUrl: string, x: number, y: number, index: number): CloneNode { + const mediaType: NonNullable = file.type.startsWith("video/") + ? "video" + : file.type.startsWith("audio/") + ? "audio" + : "image"; + const label = mediaType === "video" ? "视频" : mediaType === "audio" ? "音频" : "图片"; + const fallbackName = `${label}节点 ${index}`; + const title = file.name.replace(/\.[^.]+$/, "").trim() || fallbackName; + + return { + id: makeId(mediaType), + kind: "media", + title, + subtitle: `${label}资源`, + inputType: mediaType, + x, + y, + width: mediaType === "audio" ? 430 : 420, + height: mediaType === "audio" ? 170 : 280, + accent: mediaType === "audio" ? "#9ee6bb" : mediaType === "video" ? "#8b96ff" : "#8790ff", + draftValues: { + action: `${mediaType.toUpperCase()}_RESOURCE`, + alt: file.name, + generated: false, + mediaType, + mediaUrl, + nodeType: mediaType, + resourceMeta: { + items: [{ name: file.name, size: file.size, type: file.type }], + }, + url: [mediaUrl], + }, + }; +} + function getDraftString(node: CloneNode | null | undefined, key: string) { const value = node?.draftValues?.[key]; return typeof value === "string" ? value : ""; @@ -893,8 +1044,6 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib const [draftTitle, setDraftTitle] = useState("未命名"); const [isTitleEditing, setIsTitleEditing] = useState(false); const [activePanel, setActivePanel] = useState(null); - const [toolTab, setToolTab] = useState<(typeof PANEL_TABS)[number]>("全部"); - const [toolSearch, setToolSearch] = useState(""); const [isProjectLoading, setIsProjectLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [, setShareEnabled] = useState(false); @@ -912,27 +1061,6 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib [nodeContextMenu?.nodeId, nodes] ); - const filteredTools = useMemo(() => { - const search = toolSearch.trim().toLowerCase(); - return capturedTools.filter((tool) => { - const type = getToolType(tool); - const tabMatched = - toolTab === "全部" || - (toolTab === "图片" && type === "image") || - (toolTab === "视频" && type === "video") || - (toolTab === "文本" && type === "text") || - (toolTab === "音频" && type === "audio") || - (toolTab === "组合" && type === "compose"); - if (!tabMatched) return false; - if (!search) return true; - return [tool.modelName, tool.vendor, tool.scene, tool.modelKey, tool.toolKey, ...(tool.modes || [])] - .filter(Boolean) - .join(" ") - .toLowerCase() - .includes(search); - }); - }, [toolSearch, toolTab]); - useEffect(() => { let active = true; @@ -1008,30 +1136,6 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib if (last) setSelectedNodeId(last.id); }, []); - const addToolFlow = useCallback( - (tool: CapturedTool) => { - const type = getToolType(tool); - const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video; - const origin = { x: 420 + nodes.length * 18, y: 330 + nodes.length * 10 }; - const newNodes: CloneNode[] = []; - const newEdges: CloneEdge[] = []; - - if (meta.inputType && type !== "text") { - const input = createInputNode(meta.inputType, origin.x, origin.y); - const model = createToolNode(tool, origin.x + 300, origin.y + 18); - newNodes.push(input, model); - newEdges.push({ id: makeId("edge"), source: input.id, target: model.id }); - } else { - newNodes.push(createToolNode(tool, origin.x + 120, origin.y + 12)); - } - - addNodes(newNodes, newEdges); - setActivePanel(null); - toast.success(`已添加 ${getToolLabel(tool)}`); - }, - [addNodes, nodes.length] - ); - const addWorkflowPreset = useCallback( (workflow: WorkflowPreset, index = 0) => { const node = createWorkflowNode(workflow, 440 + index * 24, 350 + index * 18); @@ -1050,6 +1154,57 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib [viewport.x, viewport.y, viewport.zoom] ); + const addTargetModule = useCallback( + (kind: AddModuleKind, point?: { x: number; y: number }) => { + const center = point || toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2); + const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1); + const node = { + ...created, + x: Math.round(center.x - created.width / 2), + y: Math.round(center.y - created.height / 2), + }; + addNodes([node], []); + setActivePanel(null); + setCanvasCommand(null); + toast.success(`已添加 ${node.title}`); + }, + [addNodes, nodes.length, toCanvasPoint] + ); + + const addUploadedFilesAtPoint = useCallback( + (files: File[], center: { x: number; y: number }) => { + if (files.length === 0) return; + const newNodes = files.slice(0, 10).map((file, index) => { + const created = createUploadedMediaNode(file, URL.createObjectURL(file), 0, 0, nodes.length + index + 1); + return { + ...created, + x: Math.round(center.x - created.width / 2 + index * 450), + y: Math.round(center.y - created.height / 2), + }; + }); + addNodes(newNodes, []); + setActivePanel(null); + setCanvasCommand(null); + toast.success(files.length === 1 ? `已添加 ${newNodes[0]?.title || "素材"}` : `已添加 ${newNodes.length} 个素材`); + }, + [addNodes, nodes.length] + ); + + const addUploadedFiles = useCallback( + (files: File[]) => { + addUploadedFilesAtPoint(files, toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2)); + }, + [addUploadedFilesAtPoint, toCanvasPoint] + ); + + const addCanvasCommandUploadedFiles = useCallback( + (files: File[]) => { + if (!canvasCommand) return; + addUploadedFilesAtPoint(files, { x: canvasCommand.canvasX, y: canvasCommand.canvasY }); + }, + [addUploadedFilesAtPoint, canvasCommand] + ); + const openCanvasCommand = useCallback( (event: ReactMouseEvent) => { if ((event.target as HTMLElement).closest("[data-canvas-overlay]")) return; @@ -1083,26 +1238,17 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib const createFreeNode = useCallback( (kind: CanvasCreateKind) => { if (!canvasCommand) return; - const createAtPoint = (node: CloneNode) => ({ - ...node, - x: Math.round(canvasCommand.canvasX - node.width / 2), - y: Math.round(canvasCommand.canvasY - node.height / 2), - }); - const type = kind.split("-")[0] as NonNullable; - const tool = capturedTools.find((item) => getToolType(item) === type); - const node = - kind.endsWith("input") - ? createAtPoint(createInputNode(type, canvasCommand.canvasX, canvasCommand.canvasY)) - : createAtPoint( - tool - ? createToolNode(tool, canvasCommand.canvasX, canvasCommand.canvasY) - : createSimpleNode(`${TOOL_TYPE_META[type]?.label || "工具"}生成`, type === "text" ? "text" : "model", canvasCommand.canvasX, canvasCommand.canvasY) - ); + const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1); + const node = { + ...created, + x: Math.round(canvasCommand.canvasX - created.width / 2), + y: Math.round(canvasCommand.canvasY - created.height / 2), + }; addNodes([node], []); setCanvasCommand(null); toast.success(`已添加 ${node.title}`); }, - [addNodes, canvasCommand] + [addNodes, canvasCommand, nodes.length] ); const configureNode = useCallback((nodeId: string) => { @@ -1422,10 +1568,11 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib state={canvasCommand} onClose={() => setCanvasCommand(null)} onCreate={createFreeNode} - onOpenAdd={() => { - setActivePanel("add"); + onGallerySelect={() => { + setActivePanel("assets"); setCanvasCommand(null); }} + onUploadFiles={addCanvasCommandUploadedFiles} /> ) : null} @@ -1475,13 +1622,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib {activePanel === "add" ? ( setActivePanel(null)} - onSearchChange={setToolSearch} - onTabChange={setToolTab} + onGallerySelect={() => setActivePanel("assets")} + onUploadFiles={addUploadedFiles} /> ) : null} @@ -3014,80 +3158,107 @@ function ComposerControl({ function CanvasCommandPalette({ onClose, onCreate, - onOpenAdd, + onGallerySelect, + onUploadFiles, state, }: { onClose: () => void; onCreate: (kind: CanvasCreateKind) => void; - onOpenAdd: () => void; + onGallerySelect: () => void; + onUploadFiles: (files: File[]) => void; state: NonNullable; }) { - const items: Array<{ kind: CanvasCreateKind; icon: LucideIcon; label: string; note: string }> = [ - { kind: "image-input", icon: ImageIcon, label: "图片输入", note: "上传参考图/角色图" }, - { kind: "video-input", icon: Video, label: "视频输入", note: "上传视频素材" }, - { kind: "audio-input", icon: Music2, label: "音频输入", note: "上传音乐/语音" }, - { kind: "text-input", icon: FileText, label: "文本输入", note: "脚本/提示词" }, - { kind: "image-model", icon: Sparkles, label: "图片生成", note: "调用图片模型" }, - { kind: "video-model", icon: Play, label: "视频生成", note: "调用视频模型" }, - { kind: "audio-model", icon: Music2, label: "音频生成", note: "调用音频模型" }, - { kind: "text-model", icon: FileText, label: "脚本生成", note: "调用文本模型" }, - ]; + const uploadInputRef = useRef(null); return (
event.stopPropagation()} style={{ - left: `min(${state.screenX}px, calc(100vw - 286px))`, - top: `min(${state.screenY}px, calc(100vh - 384px))`, + left: `min(${state.screenX}px, calc(100vw - 256px))`, + top: `min(${state.screenY}px, calc(100vh - 540px))`, }} > + { + const files = Array.from(event.target.files || []); + if (files.length > 0) onUploadFiles(files); + event.currentTarget.value = ""; + }} + ref={uploadInputRef} + type="file" + />
-
-
- - 自由生成节点 -
-
- X {state.canvasX} / Y {state.canvasY} -
-
+

添加节点

-
- {items.map((item) => { - const Icon = item.icon; - return ( - - ); - })} -
- -
+ + + ); + })} +

添加资源

+
); @@ -3221,7 +3392,8 @@ function CloneCanvasNode({ ); } - const Icon = node.inputType === "audio" ? Music2 : node.inputType === "video" ? Video : node.kind === "text" ? FileText : node.kind === "preview" ? Video : ImageIcon; + const nodeType = getDraftString(node, "nodeType"); + const Icon = nodeType === "video-clip" ? Scissors : node.inputType === "audio" ? Music2 : node.inputType === "video" ? Video : node.kind === "text" ? FileText : node.kind === "preview" ? Video : ImageIcon; const isInput = node.kind === "input"; const isWarm = node.kind === "text" || node.kind === "audio"; const previewUrl = getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage"); @@ -3480,97 +3652,110 @@ function PresetScriptCanvasNode({ node, selected }: { node: CloneNode; selected: } function AddModulePanel({ - filteredTools, - onAddTool, + onAddModule, onClose, - onSearchChange, - onTabChange, - search, - tab, + onGallerySelect, + onUploadFiles, }: { - filteredTools: CapturedTool[]; - onAddTool: (tool: CapturedTool) => void; + onAddModule: (kind: AddModuleKind) => void; onClose: () => void; - onSearchChange: (value: string) => void; - onTabChange: (tab: (typeof PANEL_TABS)[number]) => void; - search: string; - tab: (typeof PANEL_TABS)[number]; + onGallerySelect: () => void; + onUploadFiles: (files: File[]) => void; }) { + const uploadInputRef = useRef(null); + + const triggerUpload = () => { + uploadInputRef.current?.click(); + }; + return (
-
-
-
-
添加节点
-
工具 {capturedCounts.tools || capturedTools.length} / 工作流 {capturedCounts.workflows || capturedWorkflows.length}
-
- -
-
- - onSearchChange(event.target.value)} - placeholder="搜索模型、厂商、模式" - value={search} - /> -
-
- {PANEL_TABS.map((item) => ( - - ))} -
+ { + const files = Array.from(event.target.files || []); + if (files.length > 0) onUploadFiles(files); + event.currentTarget.value = ""; + }} + ref={uploadInputRef} + type="file" + /> +
+

添加节点

+
-
-
- {filteredTools.map((tool) => { - const type = getToolType(tool); - const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video; - const Icon = meta.icon; - return ( -
- - ); - })} -
-
+ ) : null} + + + {item.description} + + + + + ); + })} +

添加资源

+ +
); }