341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
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("编组节点暂不支持执行");
|
||
}
|