ai-chatbot/lib/video-canvas/group-execution.ts

341 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
assertConnectedInputsReady,
connectedInputNodes,
connectedText,
connectionTypeError,
videoInputLimitError,
withConnectedText,
type ConnectionNode,
type CanvasConnection,
} from "./connections";
import { groupMembers, isCanvasGroup, type GroupNode } from "./groups";
import { hasRecoverableCanvasTask } from "./task-recovery";
import { scopeTVstarShotInputs } from "./tvstar-shot-input";
import type { CanvasTaskRequest, CanvasGenerationTask } from "./task-contract";
export function executableGroupNode(node: GroupNode) {
const d = node.draftValues || {},
action = String(d.action || "");
if (isCanvasGroup(node) || node.kind?.startsWith("material-")) return false;
if (
d.generationTaskId ||
d.audioParams ||
/GENERATE|UPSCALE|STORY_ANALYSIS|SMART_EDIT/.test(action)
)
return true;
if (
/_RESOURCE$/.test(action) ||
(node.kind === "text" &&
connectedText(node) &&
!d.params?.prompt &&
!d.prompt)
)
return false;
return (
["model", "workflow", "script", "preset"].includes(node.kind || "") ||
!!d.params?.prompt
);
}
export function compileGroupRun(
nodes: GroupNode[],
edges: CanvasConnection[],
groupId: string,
projectId: string,
prepare: (node: GroupNode) => Pick<CanvasTaskRequest, "kind" | "params">,
uuid: () => string
) {
const members = groupMembers(nodes, groupId).filter(executableGroupNode);
if (!members.length) throw Error("编组没有可执行节点");
if (members.length > 40) throw Error("整组最多执行 40 个节点");
const runId = uuid(),
ids = new Map(members.map((n) => [n.id, uuid()]));
const requests: CanvasTaskRequest[] = members.map((node) => {
const inputs = connectedInputNodes(nodes, edges, node.id).map((source) => {
const error = connectionTypeError(source, node);
if (error) throw Error(`${node.title}:${error}`);
if (!ids.has(source.id)) assertConnectedInputsReady([source]);
if (source.draftValues?.gridSplit?.selectedIndices?.length)
throw Error("编组执行前请将选中的宫格保存为独立图片节点");
return { node: source, requestId: ids.get(source.id) };
});
return {
...prepare(node),
requestId: ids.get(node.id)!,
projectId,
node: node as CanvasTaskRequest["node"],
edges: edges.filter((e) => e.target === node.id),
workflow: { runId, groupId, inputs },
};
});
const expanded = requests.flatMap((request) => {
if (request.kind !== "smart-edit" || request.params.autoRender !== true)
return [request];
const planNodeId = `group-plan-${uuid()}`,
planRequestId = uuid(),
planNode = {
...request.node,
id: planNodeId,
title: `${request.node.title} · 选片方案`,
};
const { autoRender, ...params } = request.params;
const plan = {
...request,
node: planNode,
requestId: planRequestId,
params,
hiddenNodeIds: [planNodeId],
edges: request.edges.map((e) => ({
...e,
id: uuid(),
target: planNodeId,
})),
};
const render = {
...request,
params: { operation: "render" },
hiddenNodeIds: [planNodeId],
workflow: {
...request.workflow!,
inputs: [
{
node: planNode,
requestId: planRequestId,
output: "edit-plan" as const,
},
],
},
edges: [{ id: uuid(), source: planNodeId, target: request.node.id }],
};
return [plan, render];
});
if (expanded.length > 40)
throw Error("包含选片阶段后整组超过 40 个任务,请拆分编组");
validateGroupBatch(expanded);
return expanded;
}
export function validateGroupBatch(requests: CanvasTaskRequest[]) {
if (!requests.length || requests.length > 40) throw Error("整组任务数量无效");
const first = requests[0],
map = new Map(requests.map((r) => [r.requestId, r]));
if (
map.size !== requests.length ||
new Set(requests.map((r) => r.node.id)).size !== requests.length
)
throw Error("重复的整组任务");
for (const r of requests) {
if (
!r.workflow ||
r.projectId !== first.projectId ||
r.workflow.runId !== first.workflow?.runId ||
r.workflow.groupId !== first.workflow?.groupId
)
throw Error("整组任务不属于同一画布和编组");
for (const input of r.workflow.inputs) {
if (
!r.edges.some(
(e) => e.source === input.node.id && e.target === r.node.id
)
)
throw Error("依赖缺少对应连线");
if (input.output === "edit-plan") {
const source = input.requestId ? map.get(input.requestId) : null;
if (
r.kind !== "smart-edit" ||
r.params.operation !== "render" ||
source?.kind !== "smart-edit" ||
source.params.operation !== "plan"
)
throw Error("剪辑方案依赖无效");
} else {
const error = connectionTypeError(input.node, r.node);
if (error) throw Error(error);
}
if (
input.requestId &&
map.get(input.requestId)?.node.id !== input.node.id
)
throw Error("依赖不属于本次编组执行");
if (!input.requestId) assertConnectedInputsReady([input.node]);
}
if (
r.edges.some(
(e) =>
e.target !== r.node.id ||
!r.workflow!.inputs.some((i) => i.node.id === e.source)
)
)
throw Error("连线缺少对应输入");
}
const visited = new Set<string>(),
visiting = new Set<string>();
const visit = (id: string) => {
if (visiting.has(id)) throw Error("编组存在循环依赖");
if (visited.has(id)) return;
visiting.add(id);
for (const input of map.get(id)!.workflow!.inputs)
if (input.requestId) visit(input.requestId);
visiting.delete(id);
visited.add(id);
};
for (const id of map.keys()) visit(id);
}
export function groupDependencyState(
request: CanvasTaskRequest,
tasks: CanvasGenerationTask[]
) {
const dependencies =
request.workflow?.inputs
.filter((i) => i.requestId)
.map((i) =>
tasks.find(
(t) =>
t.requestId === i.requestId &&
t.request.workflow?.runId === request.workflow?.runId
)
) || [];
if (
dependencies.some(
(t) =>
!t ||
["failed", "cancelled"].includes(t.status) ||
(t.status === "interrupted" && !hasRecoverableCanvasTask(t))
)
)
return "failed";
return dependencies.every((t) => t?.status === "succeeded")
? "ready"
: "waiting";
}
export function resolveGroupInputs(
request: CanvasTaskRequest,
tasks: CanvasGenerationTask[]
): Record<string, unknown> {
if (!request.workflow) return request.params;
if (groupDependencyState(request, tasks) !== "ready")
throw Error("编组上游任务尚未成功完成");
const planInput = request.workflow.inputs.find(
(i) => i.output === "edit-plan"
);
if (planInput) {
const plan = tasks.find((t) => t.requestId === planInput.requestId)?.result
?.plans?.[0];
if (!plan) throw Error("上游未返回剪辑方案");
return { ...request.params, plan };
}
const nodes = request.workflow.inputs.map((input) => {
if (!input.requestId) return input.node;
const task = tasks.find((t) => t.requestId === input.requestId)!;
const data = task.result || {};
const urls =
task.request.kind === "image" || task.request.kind === "image-operation"
? (data.images || [])
.map((i: any) => i.originalUrl || i.thumbnailUrl)
.filter(Boolean)
: data.url
? [data.url]
: [];
const text = data.raw || data.summary || (data.agentPlan || []).join("\n");
return {
...input.node,
draftValues: {
...input.node.draftValues,
generationStatus: "complete",
mediaUrl: urls[0],
url: urls,
content: text,
rows: data.analysisRows || data.shotIdeas,
},
};
});
assertConnectedInputsReady(nodes);
const urls = (type: string) =>
nodes
.filter((n) => n.inputType === type)
.flatMap((n) =>
n.draftValues?.url?.length
? n.draftValues.url
: [
n.draftValues?.mediaUrl ||
n.draftValues?.resultImage ||
n.draftValues?.thumbnailUrl,
]
)
.filter(Boolean);
const roleImages = nodes
.filter((n) => n.kind === "material-role" && n.inputType !== "image")
.map((n) => n.draftValues?.thumbnailUrl || n.draftValues?.mediaUrl)
.filter(Boolean);
const images = [...urls("image"), ...roleImages],
videos = urls("video"),
audio = urls("audio");
const p: Record<string, unknown> = {
...request.params,
prompt: withConnectedText(String(request.params.prompt || ""), scopeTVstarShotInputs(request.node, request.kind, nodes)),
};
if (request.kind === "image") {
const configuredImages = [
typeof p.referenceImage === "string" ? p.referenceImage : "",
...(Array.isArray(p.referenceImages) ? p.referenceImages.filter((url): url is string => typeof url === "string") : []),
].filter(Boolean);
const allImages = Array.from(new Set([...images, ...configuredImages]));
return {
...p,
referenceImage: allImages[0],
referenceImages: allImages.slice(1),
};
}
if (request.kind === "video") {
const error = videoInputLimitError(String(p.mode), images.length);
if (error) throw Error(error);
return {
...p,
prompt: [
p.prompt,
...nodes.map(
(n) => n.draftValues?.rolePrompt || n.draftValues?.effectPrompt
),
]
.filter(Boolean)
.join("\n\n"),
images,
audioUrls: audio,
};
}
if (request.kind === "analysis")
return { ...p, imageUrls: images, videoUrls: videos };
if (request.kind === "audio")
return {
...p,
text: [request.params.text, ...nodes.map(connectedText)]
.filter(Boolean)
.join("\n\n"),
};
if (request.kind === "video-upscale") {
if (videos.length !== 1) throw Error("视频高清需要一个上游视频");
return { ...p, url: videos[0] };
}
if (request.kind === "image-operation") {
if (images.length !== 1) throw Error("图片处理需要一个上游图片");
return { ...p, imageUrl: images[0] };
}
if (request.kind === "smart-edit") {
if (request.params.operation === "plan") {
if (!videos.length) throw Error("智能剪辑没有已就绪的视频输入");
return {
...p,
assets: videos.map((url: string, i: number) => ({
id: `group-asset-${i + 1}`,
name:
nodes.filter((n) => n.inputType === "video")[i]?.title ||
`素材 ${i + 1}`,
url,
})),
};
}
const plan = request.params.plan as any;
if (videos.some((url) => !plan?.assets?.some((a: any) => a.url === url)))
throw Error("剪辑方案与当前连线素材不一致,请重新确认方案");
return p;
}
throw Error("编组节点暂不支持执行");
}