331 lines
12 KiB
TypeScript

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<typeof canvasProject.$inferSelect.canvasData>,
) {
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,
};
});
}