331 lines
12 KiB
TypeScript
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,
|
|
};
|
|
});
|
|
}
|