import { and, eq } from "drizzle-orm"; import { db } from "@/lib/db"; import { canvasProject } from "@/lib/db/schema"; import { AgentRunError } from "./store"; import { restoreVideoRuntimeNodes, serializeVideoRuntimeNode, type VideoRuntimeNode, } from "./video-protocol"; export async function readOwnedVideoCanvas(userId: string, projectId: string) { const [project] = await db .select({ id: canvasProject.id, userId: canvasProject.userId, workspaceType: canvasProject.workspaceType, canvasData: canvasProject.canvasData, updatedAt: canvasProject.updatedAt, }) .from(canvasProject) .where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId))) .limit(1); if (!project || project.workspaceType !== "video") throw new AgentRunError("视频画布不存在", 404); return { project, nodes: restoreVideoRuntimeNodes(project.canvasData) }; } /** Append under a row lock. Never replace the user's existing nodes, edges or viewport. */ export async function appendVideoAgentNode( userId: string, projectId: string, node: VideoRuntimeNode, sourceNodeIds: string[], ) { return db.transaction(async (tx) => { const [project] = await tx .select({ id: canvasProject.id, userId: canvasProject.userId, workspaceType: canvasProject.workspaceType, canvasData: canvasProject.canvasData, updatedAt: canvasProject.updatedAt, }) .from(canvasProject) .where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId))) .for("update"); if (!project || project.workspaceType !== "video") throw new AgentRunError("视频画布不存在", 404); const data = project.canvasData || { nodes: [] }; if (data.nodes.some((item) => item.id === node.id)) return node.id; const existing = restoreVideoRuntimeNodes(data); if (sourceNodeIds.some((id) => !existing.some((item) => item.id === id))) throw new AgentRunError("参考节点已被删除,请重新选择素材。"); const right = Math.max(0, ...existing.map((item) => item.x + item.width)); const placed = { ...node, x: right + 72, y: existing.find((item) => sourceNodeIds.includes(item.id))?.y ?? 160, }; await tx .update(canvasProject) .set({ canvasData: { ...data, nodes: [...data.nodes, serializeVideoRuntimeNode(placed)], edges: [ ...(data.edges || []), ...sourceNodeIds.map((id) => ({ id: `${node.id}:${id}`, source: id, target: node.id, type: "default", })), ], }, updatedAt: new Date(), }) .where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId))); return node.id; }); } /** Replace only a known generated video's URL after the existing storage archiver finishes. */ export async function replaceVideoAgentUrl( userId: string, projectId: string, nodeId: string, temporaryUrl: string, permanentUrl: string, ) { await db.transaction(async (tx) => { const [project] = await tx .select({ canvasData: canvasProject.canvasData }) .from(canvasProject) .where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId))) .for("update"); if (!project?.canvasData) return; const data = project.canvasData; const nodes = data.nodes.map((saved) => { if (saved.id !== nodeId) return saved; const clone = saved.data?.metadata?.liblibCloneNode as VideoRuntimeNode | undefined; if (!clone || clone.draftValues?.mediaUrl !== temporaryUrl) return saved; return { ...saved, data: { ...saved.data, metadata: { ...saved.data.metadata, liblibCloneNode: { ...clone, draftValues: { ...clone.draftValues, mediaUrl: permanentUrl, url: [permanentUrl], temporaryVideoUrl: temporaryUrl, }, }, }, }, }; }); await tx .update(canvasProject) .set({ canvasData: { ...data, nodes }, updatedAt: new Date() }) .where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId))); }); } export async function saveVideoAgentCanvas( userId: string, projectId: string, baseRevision: string, data: NonNullable, ) { return db.transaction(async (tx) => { const [project] = await tx .select({ workspaceType: canvasProject.workspaceType, canvasData: canvasProject.canvasData, updatedAt: canvasProject.updatedAt, }) .from(canvasProject) .where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId))) .for("update"); if (!project || project.workspaceType !== "video") throw new AgentRunError("视频画布不存在", 404); if (project.updatedAt.getTime() !== new Date(baseRevision).getTime()) throw new AgentRunError("画布已有新结果,请等待同步后重试,避免覆盖新生成的素材。"); const { canvasAgentRun } = await import("@/lib/db/schema"); const { gt } = await import("drizzle-orm"); const [active] = await tx .select({ id: canvasAgentRun.id }) .from(canvasAgentRun) .where( and( eq(canvasAgentRun.userId, userId), eq(canvasAgentRun.projectId, projectId), eq(canvasAgentRun.status, "running"), gt(canvasAgentRun.updatedAt, new Date(Date.now() - 90_000)), ), ) .limit(1); if (active) throw new AgentRunError("当前视频任务还在执行,请使用补充要求。"); await tx .update(canvasProject) .set({ canvasData: { ...project.canvasData, ...data }, updatedAt: new Date() }) .where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId))); }); } export type VideoCanvasMutation = { action: | "rename" | "move" | "duplicate" | "delete" | "connect" | "disconnect" | "note" | "text" | "create-text" | "arrange" | "viewport"; nodeIds: string[]; text?: string; x?: number; y?: number; zoom?: number; }; export async function mutateVideoAgentCanvas( userId: string, projectId: string, operationId: string, input: VideoCanvasMutation, ) { return db.transaction(async (tx) => { const [project] = await tx .select({ workspaceType: canvasProject.workspaceType, canvasData: canvasProject.canvasData }) .from(canvasProject) .where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId))) .for("update"); if (!project || project.workspaceType !== "video") throw new AgentRunError("视频画布不存在", 404); const data = project.canvasData || { nodes: [] }; const previous = (data as typeof data & { agentOperationIds?: string[] }).agentOperationIds || []; if (previous.includes(operationId)) return { status: "succeeded", createdNodeIds: [], updatedNodeIds: input.nodeIds, deletedNodeIds: [], }; let nodes = restoreVideoRuntimeNodes(data), edges = [...(data.edges || [])]; if (input.nodeIds.some((id) => !nodes.some((node) => node.id === id))) throw new AgentRunError("目标节点不属于当前视频画布", 404); const ids = new Set(input.nodeIds), createdNodeIds: string[] = [], deletedNodeIds: string[] = []; if (!["create-text", "viewport", "arrange"].includes(input.action) && !ids.size) throw new Error("请指定目标节点"); if (input.action === "delete") { deletedNodeIds.push(...ids); nodes = nodes.filter((node) => !ids.has(node.id)); edges = edges.filter((edge) => !ids.has(edge.source) && !ids.has(edge.target)); } if (input.action === "duplicate" || input.action === "create-text") { const source = input.action === "create-text" ? [ { id: "new", kind: "script", title: "文本", x: 160, y: 160, width: 360, height: 240, accent: "#8790ff", inputType: "text" as const, draftValues: { action: "TEXT_RESOURCE", nodeType: "text", scriptRole: "resource", content: [input.text || ""], }, }, ] : nodes.filter((node) => ids.has(node.id)); for (const [index, node] of source.entries()) { const id = `agent-copy-${operationId.replace(/[^a-z0-9]/gi, "").slice(-48)}-${index}`; nodes.push({ ...structuredClone(node), id, title: input.action === "duplicate" ? `${node.title}-副本` : (input.text || "文本").slice(0, 60), x: (input.x ?? node.x) + 48, y: (input.y ?? node.y) + 48, draftValues: { ...node.draftValues, canvasAgentOperationId: operationId }, }); createdNodeIds.push(id); } } if (input.action === "connect") { if (input.nodeIds.length !== 2) throw new Error("连线需要来源和目标两个节点"); if ( !edges.some((edge) => edge.source === input.nodeIds[0] && edge.target === input.nodeIds[1]) ) edges.push({ id: `agent-edge-${operationId}`, source: input.nodeIds[0], target: input.nodeIds[1], type: "default", }); } if (input.action === "disconnect") { if (input.nodeIds.length !== 2) throw new Error("移除连线需要来源和目标两个节点"); edges = edges.filter( (edge) => !(edge.source === input.nodeIds[0] && edge.target === input.nodeIds[1]), ); } nodes = nodes.map((node, index) => { if (input.action === "arrange" && (!ids.size || ids.has(node.id))) return { ...node, x: 160 + (index % 4) * 440, y: 160 + Math.floor(index / 4) * 340 }; if (!ids.has(node.id)) return node; if (input.action === "rename") return { ...node, title: input.text?.trim() || node.title }; if (input.action === "move") return { ...node, x: input.x ?? node.x, y: input.y ?? node.y }; if (input.action === "note") return { ...node, draftValues: { ...node.draftValues, note: input.text || "" } }; if (input.action === "text") { if (node.inputType !== "text" && !["text", "script"].includes(node.kind)) throw new Error("修改文本只能作用于文本或脚本节点"); return { ...node, draftValues: { ...node.draftValues, content: [input.text || ""] } }; } return node; }); const nextData = { ...data, nodes: [ ...data.nodes.filter( (saved) => !restoreVideoRuntimeNodes(data).some((node) => node.id === saved.id), ), ...nodes.map(serializeVideoRuntimeNode), ], edges, agentOperationIds: [...previous, operationId].slice(-500), ...(input.action === "viewport" ? { viewport: { x: input.x ?? data.viewport?.x ?? 0, y: input.y ?? data.viewport?.y ?? 0, zoom: input.zoom ?? data.viewport?.zoom ?? 1, }, } : {}), }; await tx .update(canvasProject) .set({ canvasData: nextData, updatedAt: new Date() }) .where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId))); return { status: "succeeded", createdNodeIds, deletedNodeIds, updatedNodeIds: input.action === "arrange" && !ids.size ? nodes.map((node) => node.id) : input.nodeIds, }; }); }