diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 414c0a7d5..d4fa69de9 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -3,27 +3,24 @@ import { Bell, Box, + ChevronDown, Clock3, Copy, Crown, - Eye, - FileImage, FileText, - FolderOpen, GitBranch, Headphones, HelpCircle, - KeyRound, + Image as ImageIcon, Layers, Loader2, - Lock, - Minus, MousePointer2, Music2, Plus, Save, Search, Share2, + Sparkles, UserRound, Video, X, @@ -31,14 +28,18 @@ import { type LucideIcon, } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { toast } from "sonner"; import capturedCanvasModules from "@/artifacts/liblib-frontend-capture-ed9a2c12570a49b9a1186017b2c38b52/canvas-modules.json"; -import { CanvasWorkspace } from "@/components/canvas/CanvasWorkspace"; -import { canvasService } from "@/services/canvas"; -import { useCanvasStore, canvasSelectors } from "@/store/canvas"; import { cn } from "@/lib/utils"; -import type { CanvasNode, Position, WorkflowNodeData } from "@/types/canvas"; +import { canvasService } from "@/services/canvas"; type ActivePanel = | "add" @@ -77,361 +78,184 @@ type CapturedTool = { }; }; -type TargetNodeKind = "image" | "video" | "text" | "audio" | "compose" | "workflow" | "preview" | "save"; - -interface LiblibVideoCanvasReplicaProps { - canvasId: string; - isLoading?: boolean; -} - -const capturedTools = ((capturedCanvasModules as any).tools || []) as CapturedTool[]; -const capturedCounts = (capturedCanvasModules as any).counts || {}; -const capturedWorkflows = ((capturedCanvasModules as any).workflows || []) as Array<{ +type WorkflowPreset = { id?: string | number; + uuid?: string; name?: string; title?: string; description?: string; -}>; + cover?: string; +}; + +type CloneNodeKind = "input" | "model" | "text" | "audio" | "workflow" | "preview" | "save"; + +type CloneNode = { + id: string; + kind: CloneNodeKind; + title: string; + subtitle?: string; + x: number; + y: number; + width: number; + height: number; + accent: string; + tool?: CapturedTool; + workflow?: WorkflowPreset; + inputType?: "image" | "video" | "audio" | "text"; + draftValues?: Record; +}; + +type CloneEdge = { + id: string; + source: string; + target: string; +}; + +type DragState = + | { + kind: "node"; + id: string; + startX: number; + startY: number; + nodeX: number; + nodeY: number; + } + | { + kind: "canvas"; + startX: number; + startY: number; + viewportX: number; + viewportY: number; + }; + +type LiblibVideoCanvasReplicaProps = { + canvasId: string; + isLoading?: boolean; +}; + +const capturedTools = ((capturedCanvasModules as any).tools || []) as CapturedTool[]; +const capturedCounts = (capturedCanvasModules as any).counts || {}; +const capturedWorkflows = ((capturedCanvasModules as any).workflows || []) as WorkflowPreset[]; const TOOL_TYPE_META: Record< string, - { label: string; icon: LucideIcon; color: string; nodeKind: TargetNodeKind } + { label: string; icon: LucideIcon; accent: string; inputType?: CloneNode["inputType"] } > = { - image: { label: "图片", icon: FileImage, color: "#70d6ff", nodeKind: "image" }, - video: { label: "视频", icon: Video, color: "#9b8cff", nodeKind: "video" }, - text: { label: "文本", icon: FileText, color: "#ffd166", nodeKind: "text" }, - audio: { label: "音频", icon: Music2, color: "#80ed99", nodeKind: "audio" }, - compose: { label: "组合", icon: GitBranch, color: "#ff8fab", nodeKind: "compose" }, + image: { label: "图片", icon: ImageIcon, accent: "#8b96ff", inputType: "image" }, + video: { label: "视频", icon: Video, accent: "#8b96ff", inputType: "video" }, + text: { label: "文本", icon: FileText, accent: "#f0d06b", inputType: "text" }, + audio: { label: "音频", icon: Music2, accent: "#9ee6bb", inputType: "audio" }, + compose: { label: "组合", icon: GitBranch, accent: "#ffb8c8" }, }; -const STARTER_CARDS: Array<{ - title: string; - subtitle: string; - nodes: Array<{ label: string; kind: TargetNodeKind }>; - tone: string; -}> = [ +const PANEL_TABS = ["全部", "图片", "视频", "文本", "音频", "组合"] as const; + +const STARTER_CARDS = [ { title: "故事脚本生成", subtitle: "脚本到分镜", - nodes: [ - { label: "故事脚本", kind: "text" }, - { label: "分镜拆解", kind: "compose" }, - { label: "视频生成", kind: "video" }, - { label: "预览", kind: "preview" }, - ], - tone: "from-slate-800 via-zinc-900 to-black", + coverIndex: 0, + steps: ["故事脚本", "多模态文本模型Pro", "可灵 3.0", "预览"], }, { title: "角色三视图", subtitle: "角色资产沉淀", - nodes: [ - { label: "角色描述", kind: "text" }, - { label: "角色三视图", kind: "image" }, - { label: "角色视频", kind: "video" }, - { label: "保存", kind: "save" }, - ], - tone: "from-red-950 via-zinc-900 to-black", + coverIndex: 4, + steps: ["角色描述", "角色三视图", "全能图片模型V2", "保存"], }, { title: "首帧图生视频", subtitle: "参考图到成片", - nodes: [ - { label: "首帧图", kind: "image" }, - { label: "镜头提示词", kind: "text" }, - { label: "图生视频", kind: "video" }, - { label: "预览", kind: "preview" }, - ], - tone: "from-stone-800 via-zinc-900 to-black", + coverIndex: 10, + steps: ["上传图片", "首帧图", "可灵 3.0", "预览"], }, { title: "音频生视频", subtitle: "音频驱动画面", - nodes: [ - { label: "音频输入", kind: "audio" }, - { label: "画面描述", kind: "text" }, - { label: "音频生视频", kind: "video" }, - { label: "预览", kind: "preview" }, - ], - tone: "from-emerald-950 via-zinc-900 to-black", + coverIndex: 17, + steps: ["上传音频", "音频主视频", "Wan 2.6", "预览"], }, ]; -const TOOLBAR_ITEMS: Array<{ - id: Exclude; - label: string; - icon: LucideIcon; -}> = [ +const TOOLBAR_ITEMS: Array<{ id: Exclude; label: string; icon: LucideIcon }> = [ { id: "add", label: "添加模块", icon: Plus }, { id: "workflow", label: "工作流", icon: GitBranch }, { id: "assets", label: "素材", icon: Layers }, - { id: "history", label: "历史记录", icon: Clock3 }, + { id: "history", label: "历史", icon: Clock3 }, { id: "help", label: "帮助", icon: HelpCircle }, { id: "support", label: "客服", icon: Headphones }, ]; -const PANEL_TABS = ["全部", "图片", "视频", "文本", "音频", "组合"] as const; +const DEFAULT_VIEWPORT = { x: 0, y: 0, zoom: 1 }; -function getToolType(tool: CapturedTool) { - return String(tool.type || "video").trim() || "video"; +function getToolType(tool?: CapturedTool) { + return String(tool?.type || "video").trim() || "video"; } -function getToolLabel(tool: CapturedTool) { - return String(tool.modelName || tool.scene || tool.modelKey || tool.toolKey || "未命名模块").trim(); +function getToolLabel(tool?: CapturedTool) { + return String(tool?.modelName || tool?.scene || tool?.modelKey || tool?.toolKey || "未命名模块").trim(); } -function inferNodeKindForTool(tool: CapturedTool): TargetNodeKind { - const type = getToolType(tool); - const modes = (tool.modes || []).map((mode) => String(mode).toLowerCase()); - const name = `${tool.modelName || ""} ${tool.scene || ""} ${tool.toolKey || ""}`.toLowerCase(); - - if (type === "audio") return "audio"; - if (type === "text") return "text"; - if (type === "compose") return "compose"; - if (name.includes("保存")) return "save"; - if (name.includes("预览")) return "preview"; - if (type === "image" && modes.some((mode) => mode.includes("video"))) return "video"; - if (type === "image") return "image"; - return "video"; +function getToolModes(tool?: CapturedTool) { + return (tool?.modes || []).map(String).filter(Boolean).slice(0, 4); } -function getToolModes(tool: CapturedTool) { - return (tool.modes || []).map(String).filter(Boolean).slice(0, 3); -} - -function getVisibleOrigin(viewport: { x: number; y: number; zoom: number }) { - return { - x: (-viewport.x + 320) / viewport.zoom, - y: (-viewport.y + 220) / viewport.zoom, - }; -} - -function patchNodeForCapturedTool(node: CanvasNode, tool: CapturedTool): Partial { - const label = getToolLabel(tool); - const metadata = { - ...((node.data as any)?.metadata || {}), - source: "liblib-capture", - backendStatus: "placeholder", - liblibTool: { - type: tool.type || "", - toolKey: tool.toolKey || "", - modelKey: tool.modelKey || "", - modelName: tool.modelName || "", - vendor: tool.vendor || "", - scene: tool.scene || "", - modes: tool.modes || [], - settings: tool.settings || [], - advancedSettings: tool.advancedSettings || [], - propertyKeys: tool.propertyKeys || [], - properties: tool.metadata?.properties || {}, - config: tool.metadata?.config || {}, - generateTypes: tool.generateTypes || tool.metadata?.generateTypes || {}, - rules: tool.rules || tool.metadata?.rules || [], - }, - }; - - if (node.type === "workflow") { - const data = node.data as WorkflowNodeData; - return { - data: { - ...data, - title: label, - description: `${tool.vendor || "LibLib"} / ${tool.scene || tool.modelKey || "模型模块"}`, - summary: "前端模块已迁移,真实生成接口待后续接入。", - metadata, - }, - }; - } - - if (node.type === "image") { - return { - data: { - ...(node.data as any), - title: label, - prompt: "上传或连接上游图片后可接入生成。", - status: "pending", - metadata, - }, - }; - } - - if (node.type === "video") { - return { - data: { - ...(node.data as any), - prompt: "上传或连接上游视频后可接入生成。", - status: "pending", - metadata, - }, - }; - } - - if (node.type === "text") { - return { - data: { - ...(node.data as any), - content: `${label}\n\n在这里输入提示词、脚本或约束。`, - metadata, - }, - }; - } - - return { - data: { - ...(node.data as any), - metadata, - }, - }; -} - -function buildTargetCanvasNode({ - description, - kind, - metadata, - position, - title, -}: { - description?: string; - kind: TargetNodeKind; - metadata?: Record; - position: Position; - title: string; -}): Omit { - const baseMetadata = { - source: "liblib-target-replica", - backendStatus: "placeholder", - ...metadata, - }; - - if (kind === "image") { - return { - type: "image", - position, - size: { width: 260, height: 220 }, - data: { - src: "", - status: "pending", - title, - prompt: description || "图片模块前端已就绪,生成接口待接入。", - note: "上传、参考图、结果图能力后续接入。", - metadata: baseMetadata, - }, - connections: [], - }; - } - - if (kind === "video") { - return { - type: "video", - position, - size: { width: 300, height: 220 }, - data: { - src: "", - status: "pending", - prompt: description || "视频模块前端已就绪,生成接口待接入。", - metadata: baseMetadata, - }, - connections: [], - }; - } - - if (kind === "text") { - return { - type: "text", - position, - size: { width: 300, height: 170 }, - data: { - content: `${title}\n\n输入提示词、脚本、镜头描述或参数。`, - fontSize: 15, - color: "#f4f4f5", - lineHeight: 1.55, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.12)", - borderRadius: 8, - padding: 18, - backgroundColor: "rgba(31,31,31,0.96)", - metadata: baseMetadata, - }, - connections: [], - }; - } - - const iconTitle = - kind === "audio" - ? "音频模块" - : kind === "preview" - ? "预览模块" - : kind === "save" - ? "保存模块" - : "工作流模块"; - - return { - type: "workflow", - position, - size: { width: kind === "compose" ? 340 : 280, height: 170 }, - data: { - templateType: `liblib-${kind}-${title}`, - title, - description: description || iconTitle, - summary: "前端节点已迁移,后端能力留空待补。", - status: "pending", - group: kind === "audio" ? "generation" : kind === "save" || kind === "preview" ? "output" : "tool", - accentColor: - kind === "audio" - ? "#80ed99" - : kind === "save" || kind === "preview" - ? "#70d6ff" - : "#ff8fab", - metadata: baseMetadata, - }, - connections: [], - }; -} - -function getNodeLiblibTool(node: CanvasNode | null) { - const metadata = ((node?.data as any)?.metadata || {}) as Record; - return (metadata.liblibTool || null) as - | (CapturedTool & { - properties?: Record; - config?: Record; - generateTypes?: unknown; - rules?: unknown; - }) - | null; -} - -function getNodeDraftValues(node: CanvasNode | null) { - const metadata = ((node?.data as any)?.metadata || {}) as Record; - return (metadata.draftValues || {}) as Record; -} - -function getPropertyEntries(tool: ReturnType) { - const properties = tool?.properties; +function getToolProperties(tool?: CapturedTool) { + const properties = tool?.metadata?.properties; if (!properties || typeof properties !== "object") return []; return Object.entries(properties).filter(([key]) => key !== "modeType"); } +function getToolRules(tool?: CapturedTool) { + const rules = tool?.rules || tool?.metadata?.rules; + return Array.isArray(rules) ? rules : []; +} + +function requiresMedia(tool?: CapturedTool) { + return getToolRules(tool).some((rule) => { + if (!rule || typeof rule !== "object") return false; + const required = (rule as { require?: unknown }).require; + return Array.isArray(required) && required.map(String).includes("media"); + }); +} + function getPropertyLabel(key: string, config: unknown) { if (config && typeof config === "object" && "displayName" in config) { - const displayName = (config as { displayName?: unknown }).displayName; - if (typeof displayName === "string" && displayName.trim()) return displayName; + const value = (config as { displayName?: unknown }).displayName; + if (typeof value === "string" && value.trim()) return value; } const labels: Record = { count: "生成数量", + duration: "时长", + enableSound: "生成音效", frameRate: "帧率", + instrumental: "纯音乐", lora: "风格", - media: "素材", prompt: "提示词", - quality: "分辨率", + quality: "清晰度", ratio: "比例", resolution: "分辨率", searchable: "联网搜索", - seed: "种子", + search_enabled: "联网搜索", + slowMotion: "慢动作", + slowmo: "减速强度", + specifiedModel: "模型", + stability: "稳定性", style: "模型", template: "风格", + voice: "声音", }; return labels[key] || key; } +function getOptions(config: unknown) { + if (Array.isArray(config)) return config; + if (config && typeof config === "object" && Array.isArray((config as { enum?: unknown[] }).enum)) { + return (config as { enum: unknown[] }).enum; + } + return []; +} + function getOptionValue(option: unknown) { if (option && typeof option === "object" && "value" in option) { return String((option as { value?: unknown }).value ?? ""); @@ -447,196 +271,185 @@ function getOptionLabel(option: unknown) { return getOptionValue(option); } -function normalizeEnumOptions(config: unknown) { - if (Array.isArray(config)) return config; - if (config && typeof config === "object" && Array.isArray((config as { enum?: unknown[] }).enum)) { - return (config as { enum: unknown[] }).enum; - } - return []; -} - -function getDefaultPropertyValue(key: string, config: unknown) { +function getDefaultValue(key: string, config: unknown) { if (config && typeof config === "object" && "default" in config) { return String((config as { default?: unknown }).default ?? ""); } - const options = normalizeEnumOptions(config); + const options = getOptions(config); if (options.length > 0) return getOptionValue(options[0]); - if (typeof config === "boolean") return config ? "true" : "false"; + if (typeof config === "boolean") return config ? "1" : "0"; if (key === "prompt") return ""; return ""; } -function rulesRequireMedia(tool: ReturnType) { - const rules = tool?.rules; - if (!Array.isArray(rules)) return false; - return rules.some((rule) => { - if (!rule || typeof rule !== "object") return false; - const required = (rule as { require?: unknown }).require; - return Array.isArray(required) && required.map(String).includes("media"); - }); +function makeId(prefix: string) { + return `${prefix}-${crypto.randomUUID()}`; } -function GlassButton({ - active, - children, - className, - disabled, - onClick, - title, -}: { - active?: boolean; - children: React.ReactNode; - className?: string; - disabled?: boolean; - onClick?: () => void; - title?: string; -}) { - return ( - - ); +function serializeNodes(nodes: CloneNode[]) { + return nodes.map((node) => ({ + id: node.id, + type: "workflow", + position: { x: node.x, y: node.y }, + size: { width: node.width, height: node.height }, + data: { + title: node.title, + description: node.subtitle || "", + status: "pending", + templateType: `libtv-${node.kind}`, + metadata: { + liblibCloneNode: node, + }, + }, + connections: [], + createdAt: new Date(), + updatedAt: new Date(), + })); } -function FloatingModal({ - children, - onClose, - title, - width = 520, -}: { - children: React.ReactNode; - onClose: () => void; - title: string; - width?: number; -}) { - return ( -
-
-
-

{title}

- -
-
{children}
-
-
- ); +function serializeEdges(edges: CloneEdge[]) { + return edges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + type: "default", + })); } -export function LiblibVideoCanvasReplica({ - canvasId, - isLoading = false, -}: LiblibVideoCanvasReplicaProps) { +function restoreNodes(canvasData: unknown): CloneNode[] { + const nodes = (canvasData as { nodes?: unknown[] } | null)?.nodes; + if (!Array.isArray(nodes)) return []; + return nodes + .map((item) => { + const metadata = ((item as any)?.data?.metadata || {}) as Record; + const clone = metadata.liblibCloneNode as CloneNode | undefined; + return clone && typeof clone.id === "string" ? clone : null; + }) + .filter(Boolean) as CloneNode[]; +} + +function restoreEdges(canvasData: unknown): CloneEdge[] { + const edges = (canvasData as { edges?: unknown[] } | null)?.edges; + if (!Array.isArray(edges)) return []; + return edges + .map((edge) => ({ + id: String((edge as any).id || makeId("edge")), + source: String((edge as any).source || ""), + target: String((edge as any).target || ""), + })) + .filter((edge) => edge.source && edge.target); +} + +function createInputNode(inputType: CloneNode["inputType"], x: number, y: number): CloneNode { + const title = + inputType === "video" + ? "上传视频" + : inputType === "audio" + ? "上传音频" + : inputType === "text" + ? "输入文本" + : "上传图片"; + return { + id: makeId("input"), + kind: "input", + title, + subtitle: + inputType === "video" + ? "添加视频素材、参考视频或首尾帧" + : inputType === "audio" + ? "添加音乐、语音或音效参考" + : inputType === "text" + ? "输入脚本、分镜或提示词" + : "添加参考图、角色图或关键帧素材", + inputType, + x, + y, + width: 158, + height: 158, + accent: "#8790ff", + }; +} + +function createToolNode(tool: CapturedTool, x: number, y: number): CloneNode { + const type = getToolType(tool); + const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video; + const isAudio = type === "audio"; + const isText = type === "text"; + return { + id: makeId("tool"), + kind: isAudio ? "audio" : isText ? "text" : "model", + title: getToolLabel(tool), + subtitle: tool.vendor || meta.label, + x, + y, + width: isText ? 210 : 194, + height: isText ? 122 : 116, + accent: meta.accent, + tool, + draftValues: {}, + }; +} + +function createWorkflowNode(workflow: WorkflowPreset, x: number, y: number): CloneNode { + return { + id: makeId("workflow"), + kind: "workflow", + title: String(workflow.name || workflow.title || "工作流预设"), + subtitle: workflow.description || "LibTV workflow", + x, + y, + width: 230, + height: 116, + accent: "#8b96ff", + workflow, + draftValues: {}, + }; +} + +function createSimpleNode(title: string, kind: CloneNodeKind, x: number, y: number): CloneNode { + return { + id: makeId(kind), + kind, + title, + subtitle: kind === "preview" ? "预览结果" : kind === "save" ? "保存到本地" : "输入内容", + x, + y, + width: kind === "text" ? 210 : 194, + height: kind === "text" ? 122 : 116, + accent: kind === "save" || kind === "preview" ? "#8b96ff" : "#f0d06b", + inputType: kind === "text" ? "text" : undefined, + draftValues: {}, + }; +} + +export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: LiblibVideoCanvasReplicaProps) { const router = useRouter(); - const nodes = useCanvasStore(canvasSelectors.nodes); - const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds); - const viewport = useCanvasStore(canvasSelectors.viewport); - const tool = useCanvasStore(canvasSelectors.tool); - const projectTitle = useCanvasStore((state) => state.projectTitle); - const setProject = useCanvasStore((state) => state.setProject); - const setProjectTitle = useCanvasStore((state) => state.setProjectTitle); - const setTool = useCanvasStore((state) => state.setTool); - const setViewport = useCanvasStore((state) => state.setViewport); - const addNode = useCanvasStore((state) => state.addNode); - const addEdge = useCanvasStore((state) => state.addEdge); - const updateNode = useCanvasStore((state) => state.updateNode); - const selectNodes = useCanvasStore((state) => state.selectNodes); - const clearSelection = useCanvasStore((state) => state.clearSelection); - const loadCanvas = useCanvasStore((state) => state.loadCanvas); - const clearCanvas = useCanvasStore((state) => state.clearCanvas); - const getCanvasState = useCanvasStore((state) => state.getCanvasState); - const [activePanel, setActivePanel] = useState(null); - const [toolSearch, setToolSearch] = useState(""); - const [toolTab, setToolTab] = useState<(typeof PANEL_TABS)[number]>("全部"); + const canvasRef = useRef(null); + const dragRef = useRef(null); + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [viewport, setViewport] = useState(DEFAULT_VIEWPORT); + const [projectTitle, setProjectTitle] = useState("未命名"); + const [draftTitle, setDraftTitle] = useState("未命名"); const [isTitleEditing, setIsTitleEditing] = useState(false); - const [draftTitle, setDraftTitle] = useState(projectTitle || "未命名"); + 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 [shareEnabled, setShareEnabled] = useState(false); const [publishPublic, setPublishPublic] = useState(false); const selectedNode = useMemo( - () => nodes.find((node) => selectedNodeIds.includes(node.id)) || null, - [nodes, selectedNodeIds] + () => nodes.find((node) => node.id === selectedNodeId) || null, + [nodes, selectedNodeId] ); - const isBusy = isLoading || isProjectLoading; - - useEffect(() => { - if (!isTitleEditing) { - setDraftTitle(projectTitle || "未命名视频画布"); - } - }, [isTitleEditing, projectTitle]); - - useEffect(() => { - let active = true; - - async function loadTargetCanvas() { - setIsProjectLoading(true); - clearCanvas(); - - const result = await canvasService.getProject(canvasId); - if (!active) return; - - if (result.success && result.data) { - setProject(canvasId, result.data.title || "未命名视频画布"); - if (result.data.canvasData) { - loadCanvas(result.data.canvasData); - } - setIsProjectLoading(false); - return; - } - - setProject(canvasId, "未命名视频画布"); - await canvasService - .saveCanvasState( - canvasId, - { - nodes: [], - edges: [], - selectedNodeIds: [], - viewport: { x: 0, y: 0, zoom: 1 }, - tool: "select", - }, - "video" - ) - .catch(() => undefined); - if (active) { - setIsProjectLoading(false); - } - } - - void loadTargetCanvas(); - - return () => { - active = false; - }; - }, [canvasId, clearCanvas, loadCanvas, setProject]); const filteredTools = useMemo(() => { - const normalizedSearch = toolSearch.trim().toLowerCase(); - return capturedTools.filter((item) => { - const type = getToolType(item); + const search = toolSearch.trim().toLowerCase(); + return capturedTools.filter((tool) => { + const type = getToolType(tool); const tabMatched = toolTab === "全部" || (toolTab === "图片" && type === "image") || @@ -645,201 +458,219 @@ export function LiblibVideoCanvasReplica({ (toolTab === "音频" && type === "audio") || (toolTab === "组合" && type === "compose"); if (!tabMatched) return false; - if (!normalizedSearch) return true; - return [ - item.modelName, - item.vendor, - item.scene, - item.toolKey, - item.modelKey, - ...(item.modes || []), - ] + if (!search) return true; + return [tool.modelName, tool.vendor, tool.scene, tool.modelKey, tool.toolKey, ...(tool.modes || [])] .filter(Boolean) .join(" ") .toLowerCase() - .includes(normalizedSearch); + .includes(search); }); }, [toolSearch, toolTab]); + useEffect(() => { + let active = true; + + async function loadProject() { + setIsProjectLoading(true); + const result = await canvasService.getProject(canvasId); + if (!active) return; + if (result.success && result.data) { + setProjectTitle(result.data.title || "未命名"); + setDraftTitle(result.data.title || "未命名"); + const restoredNodes = restoreNodes(result.data.canvasData); + const restoredEdges = restoreEdges(result.data.canvasData); + setNodes(restoredNodes); + setEdges(restoredEdges); + } else { + setProjectTitle("未命名"); + setDraftTitle("未命名"); + await canvasService.saveCanvasState( + canvasId, + { + nodes: [], + edges: [], + selectedNodeIds: [], + viewport: DEFAULT_VIEWPORT, + tool: "select", + }, + "video" + ); + } + setIsProjectLoading(false); + } + + void loadProject(); + return () => { + active = false; + }; + }, [canvasId]); + const saveCanvas = useCallback(async () => { setIsSaving(true); - try { - const result = await canvasService.saveCanvasState(canvasId, getCanvasState(), "video"); - if (!result.success) { - toast.error(result.error || "保存失败"); - return; - } - toast.success("视频画布已保存"); - } finally { - setIsSaving(false); + const result = await canvasService.saveCanvasState( + canvasId, + { + nodes: serializeNodes(nodes) as any, + edges: serializeEdges(edges) as any, + selectedNodeIds: selectedNodeId ? [selectedNodeId] : [], + viewport, + tool: "select", + }, + "video" + ); + if (result.success) { + toast.success("已保存"); + } else { + toast.error(result.error || "保存失败"); } - }, [canvasId, getCanvasState]); + setIsSaving(false); + }, [canvasId, edges, nodes, selectedNodeId, viewport]); const commitTitle = useCallback(async () => { const nextTitle = draftTitle.trim() || "未命名"; setProjectTitle(nextTitle); + setDraftTitle(nextTitle); setIsTitleEditing(false); await canvasService.updateProject(canvasId, { title: nextTitle }, "video"); - }, [canvasId, draftTitle, setProjectTitle]); + }, [canvasId, draftTitle]); - const getPlacement = useCallback( - (size: { width: number; height: number }, index = 0): Position => { - const origin = getVisibleOrigin(viewport); - const selected = selectedNode; - if (selected) { - return { - x: selected.position.x + selected.size.width + 180 + index * 48, - y: selected.position.y + selected.size.height / 2 - size.height / 2 + index * 36, - }; + const addNodes = useCallback((nextNodes: CloneNode[], nextEdges: CloneEdge[]) => { + setNodes((current) => [...current, ...nextNodes]); + setEdges((current) => [...current, ...nextEdges]); + const last = nextNodes[nextNodes.length - 1]; + 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)); } - return { - x: origin.x + index * 110, - y: origin.y + index * 24, - }; + + addNodes(newNodes, newEdges); + setActivePanel(null); + toast.success(`已添加 ${getToolLabel(tool)}`); }, - [selectedNode, viewport] + [addNodes, nodes.length] ); - const createTargetNode = useCallback( - ( - payload: { - description?: string; - kind: TargetNodeKind; - metadata?: Record; - title: string; - }, - options?: { - position?: Position; - connectFromSelection?: boolean; - patchTool?: CapturedTool; - index?: number; - } - ) => { - const size = - payload.kind === "video" - ? { width: 300, height: 220 } - : payload.kind === "image" - ? { width: 260, height: 220 } - : payload.kind === "text" - ? { width: 300, height: 170 } - : { width: payload.kind === "compose" ? 340 : 280, height: 170 }; - const nodeConfig = buildTargetCanvasNode({ - ...payload, - position: options?.position || getPlacement(size, options?.index || 0), - }); - const nodeId = addNode(nodeConfig); - const sourceNodeId = - options?.connectFromSelection !== false && selectedNode ? selectedNode.id : null; - if (sourceNodeId && sourceNodeId !== nodeId) { - addEdge({ source: sourceNodeId, target: nodeId, type: "default" }); - } - if (options?.patchTool) { - const createdNode = useCanvasStore.getState().nodes.find((node) => node.id === nodeId); - if (createdNode) { - updateNode(nodeId, patchNodeForCapturedTool(createdNode, options.patchTool)); - } - } - selectNodes([nodeId]); - return nodeId; + const addWorkflowPreset = useCallback( + (workflow: WorkflowPreset, index = 0) => { + const node = createWorkflowNode(workflow, 440 + index * 24, 350 + index * 18); + addNodes([node], []); + setActivePanel(null); + toast.success("已添加工作流"); }, - [addEdge, addNode, getPlacement, selectNodes, selectedNode, updateNode] - ); - - const createToolNode = useCallback( - (capturedTool: CapturedTool) => { - const kind = inferNodeKindForTool(capturedTool); - createTargetNode( - { - kind, - title: getToolLabel(capturedTool), - description: `${capturedTool.vendor || "LibLib"} / ${capturedTool.scene || capturedTool.modelKey || "目标模块"}`, - metadata: { - source: "liblib-tool-spec", - }, - }, - { patchTool: capturedTool } - ); - toast.success(`已添加 ${getToolLabel(capturedTool)}`); - }, - [createTargetNode] + [addNodes] ); const createStarter = useCallback( (starter: (typeof STARTER_CARDS)[number]) => { - const origin = getVisibleOrigin(viewport); - const nodeIds = starter.nodes.map((item, index) => { - const position = { - x: origin.x + index * 390, - y: origin.y + (index % 2) * 18, - }; - return addNode( - buildTargetCanvasNode({ - kind: item.kind, - title: item.label, - description: starter.title, - metadata: { - source: "liblib-starter-card", - starterTitle: starter.title, - }, - position, - }) + const baseX = 380; + const baseY = 325; + const newNodes = starter.steps.map((title, index) => { + if (/上传图片|首帧图/.test(title)) return createInputNode("image", baseX + index * 270, baseY); + if (/上传音频|音频/.test(title) && index === 0) return createInputNode("audio", baseX + index * 270, baseY); + if (/脚本|描述/.test(title)) return createSimpleNode(title, "text", baseX + index * 270, baseY + 18); + if (/预览/.test(title)) return createSimpleNode(title, "preview", baseX + index * 270, baseY + 18); + if (/保存/.test(title)) return createSimpleNode(title, "save", baseX + index * 270, baseY + 18); + const matchedTool = capturedTools.find((tool) => getToolLabel(tool).includes(title) || title.includes(getToolLabel(tool))); + return matchedTool + ? createToolNode(matchedTool, baseX + index * 270, baseY + 18) + : createSimpleNode(title, "workflow", baseX + index * 270, baseY + 18); + }); + const newEdges = newNodes.slice(1).map((node, index) => ({ + id: makeId("edge"), + source: newNodes[index].id, + target: node.id, + })); + addNodes(newNodes, newEdges); + }, + [addNodes] + ); + + const updateNodeDraft = useCallback((key: string, value: unknown) => { + setNodes((current) => + current.map((node) => + node.id === selectedNodeId + ? { ...node, draftValues: { ...(node.draftValues || {}), [key]: value } } + : node + ) + ); + }, [selectedNodeId]); + + const startNodeDrag = useCallback((event: ReactPointerEvent, node: CloneNode) => { + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + setSelectedNodeId(node.id); + dragRef.current = { + kind: "node", + id: node.id, + startX: event.clientX, + startY: event.clientY, + nodeX: node.x, + nodeY: node.y, + }; + }, []); + + const startCanvasDrag = useCallback((event: ReactPointerEvent) => { + if ((event.target as HTMLElement).closest("[data-canvas-overlay]")) return; + setSelectedNodeId(null); + dragRef.current = { + kind: "canvas", + startX: event.clientX, + startY: event.clientY, + viewportX: viewport.x, + viewportY: viewport.y, + }; + }, [viewport.x, viewport.y]); + + const onPointerMove = useCallback( + (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + if (drag.kind === "node") { + const dx = (event.clientX - drag.startX) / viewport.zoom; + const dy = (event.clientY - drag.startY) / viewport.zoom; + setNodes((current) => + current.map((node) => + node.id === drag.id + ? { ...node, x: Math.round(drag.nodeX + dx), y: Math.round(drag.nodeY + dy) } + : node + ) ); - }); - nodeIds.slice(1).forEach((nodeId, index) => { - const source = nodeIds[index]; - if (source && nodeId) { - addEdge({ source, target: nodeId, type: "default" }); - } - }); - if (nodeIds.length > 0) { - selectNodes([nodeIds[nodeIds.length - 1]]); + return; } - toast.success(`已创建 ${starter.title}`); + setViewport((current) => ({ + ...current, + x: Math.round(drag.viewportX + event.clientX - drag.startX), + y: Math.round(drag.viewportY + event.clientY - drag.startY), + })); }, - [addEdge, addNode, selectNodes, viewport] + [viewport.zoom] ); - const createWorkflowPresetNode = useCallback( - (workflow: (typeof capturedWorkflows)[number]) => { - createTargetNode( - { - kind: "workflow", - title: String(workflow.name || workflow.title || "工作流预设"), - description: String(workflow.description || "目标前端预设,后端执行待接入。"), - metadata: { - source: "liblib-workflow-preset", - workflowId: workflow.id || "", - workflowUuid: (workflow as any).uuid || "", - cover: (workflow as any).cover || "", - }, - }, - { connectFromSelection: true } - ); - toast.success("已添加目标工作流预设"); - }, - [createTargetNode] - ); + const onPointerUp = useCallback(() => { + dragRef.current = null; + }, []); - const updateSelectedDraftValue = useCallback( - (key: string, value: unknown) => { - if (!selectedNode) return; - const data = selectedNode.data as Record; - const metadata = (data.metadata || {}) as Record; - updateNode(selectedNode.id, { - data: { - ...data, - metadata: { - ...metadata, - draftValues: { - ...((metadata.draftValues || {}) as Record), - [key]: value, - }, - }, - } as unknown as CanvasNode["data"], - }); - }, - [selectedNode, updateNode] - ); + const zoomBy = useCallback((delta: number) => { + setViewport((current) => ({ + ...current, + zoom: Math.min(2.4, Math.max(0.3, Number((current.zoom + delta).toFixed(2)))), + })); + }, []); const copyShareLink = useCallback(async () => { const url = @@ -851,727 +682,771 @@ export function LiblibVideoCanvasReplica({ toast.success("分享链接已复制"); }, [canvasId]); - const openPanel = useCallback((panel: Exclude) => { - setActivePanel((current) => (current === panel ? null : panel)); - }, []); + const isBusy = isLoading || isProjectLoading; return ( -
-
- { - clearSelection(); - setActivePanel("add"); - setViewport({ - x: viewport.x, - y: viewport.y, - zoom: viewport.zoom, - }); - }} - showEmptyState={false} - showZoomControls={false} - workspaceLabel="视频画布" - workspaceVariant="video" - /> +
+
+
+ +
+ + {nodes.map((node) => ( + startNodeDrag(event, node)} + /> + ))}
- {isBusy ? ( -
-
- - 正在打开视频画布... -
-
- ) : null} - -
-
- - -
- - {isTitleEditing ? ( - void commitTitle()} - onChange={(event) => setDraftTitle(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") void commitTitle(); - if (event.key === "Escape") setIsTitleEditing(false); - }} - value={draftTitle} - /> - ) : ( - - )} -
- -
- openPanel("skills")}>LibTV Skills - void saveCanvas()} title="保存"> - {isSaving ? : } - - openPanel("share")} title="分享"> - - - openPanel("notifications")} title="通知"> - - - openPanel("member")} - > - - 会员超市 - - openPanel("member")}> - - 会员特惠 - - openPanel("account")} title="账号"> - - -
-
- - - - {activePanel === "add" ? ( -
-
-
-
-
添加功能模块
-
- 已迁移 {capturedCounts.tools || capturedTools.length} 个工具配置 -
-
- -
- -
- - setToolSearch(event.target.value)} - placeholder="搜索模型、厂商、模式" - value={toolSearch} - /> -
- -
- {PANEL_TABS.map((tab) => ( - - ))} -
-
- -
-
- {filteredTools.map((capturedTool) => { - const type = getToolType(capturedTool); - const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video; - const Icon = meta.icon; - const modes = getToolModes(capturedTool); - return ( - - ); - })} -
-
-
- ) : null} - - {activePanel === "workflow" ? ( -
-
-
-
目标工作流预设
-
- 抓取到 {capturedWorkflows.length || 25} 个预设,后端执行先留空 -
-
- -
-
-
- {capturedWorkflows.map((workflow) => { - const cover = String((workflow as any).cover || "").trim(); - return ( - - ); - })} -
-
-
- ) : null} - - {nodes.length === 0 && activePanel !== "add" && activePanel !== "workflow" ? ( -
-
- + {nodes.length === 0 && !activePanel ? ( +
+
+ 双击画布 自由生成节点
- {STARTER_CARDS.map((card) => ( - - ))} + {STARTER_CARDS.map((card) => { + const cover = capturedWorkflows[card.coverIndex]?.cover || capturedWorkflows[0]?.cover || ""; + return ( + + ); + })}
) : null} -
- - - -
- - - -
+ setActivePanel("account")} + onBell={() => setActivePanel("notifications")} + onCommitTitle={commitTitle} + onDraftTitleChange={setDraftTitle} + onMember={() => setActivePanel("member")} + onSave={saveCanvas} + onShare={() => setActivePanel("share")} + onSkills={() => setActivePanel("skills")} + onStartTitleEdit={() => setIsTitleEditing(true)} + projectTitle={projectTitle} + routerBack={() => router.push("/")} + /> + + setActivePanel((current) => current === panel ? null : panel)} /> + + setViewport(DEFAULT_VIEWPORT)} + onZoomIn={() => zoomBy(0.1)} + onZoomOut={() => zoomBy(-0.1)} + /> + + {activePanel === "add" ? ( + setActivePanel(null)} + onSearchChange={setToolSearch} + onTabChange={setToolTab} + /> + ) : null} + + {activePanel === "workflow" ? ( + setActivePanel(null)} + /> + ) : null} {selectedNode ? ( - setSelectedNodeId(null)} /> ) : null} {activePanel === "assets" ? ( - setActivePanel(null)} title="素材库"> - - + setActivePanel(null)} title="素材库"> + + ) : null} {activePanel === "history" ? ( - setActivePanel(null)} title="历史记录"> + setActivePanel(null)} title="历史记录">
{nodes.length === 0 ? ( - + ) : ( - nodes.slice(-12).reverse().map((node) => ( + nodes.slice(-10).reverse().map((node) => ( )) )}
-
+ ) : null} {activePanel === "help" ? ( - setActivePanel(null)} title="快捷键"> + setActivePanel(null)} title="快捷键">
{[ - ["V", "选择工具"], - ["H / 空格拖动", "平移画布"], - ["L", "工作流连线"], - ["⌘ / Ctrl + S", "保存"], - ["双击空白", "打开添加模块"], - ["双击连线", "删除连线"], - ].map(([key, label]) => ( + ["V", "选择"], + ["Space", "拖动画布"], + ["双击画布", "自由生成节点"], + ["连接点拖拽", "连接模块"], + ["⌘ / Ctrl + S", "保存画布"], + ].map(([key, value]) => (
- {label} - {key} + {value} + {key}
))}
-
+ ) : null} {activePanel === "support" ? ( - setActivePanel(null)} title="支持中心"> - - + setActivePanel(null)} title="支持中心"> + + ) : null} {activePanel === "skills" ? ( - setActivePanel(null)} title="LibTV Skills"> + setActivePanel(null)} title="LibTV Skills">
- -
-
能力覆盖
-
- {Object.entries(capturedCounts.byType || {}).map(([type, count]) => { - const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video; - return ( -
-
{String(count)}
-
{meta.label}
-
- ); - })} -
+ +
+ {Object.entries(capturedCounts.byType || {}).map(([type, count]) => { + const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video; + return ( +
+
{String(count)}
+
{meta.label}
+
+ ); + })}
- + ) : null} {activePanel === "share" ? ( - setActivePanel(null)} title="发布与分享"> + setActivePanel(null)} title="分享">
-
-
-
分享链接
-
- 打开后可查看当前视频画布,权限控制后续接入。 -
-
- -
-
-
- 启用分享 +
分享链接
+
复制当前画布链接,权限控制待接入。
+ setShareEnabled((value) => !value)} />
-
+ ) : null} {activePanel === "publish" ? ( - setActivePanel(null)} title="发布作品到视频画布广场" width={560}> -
-
- - -
- -