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 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, 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(), visiting = new Set(); 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 { 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 = { ...request.params, prompt: withConnectedText(String(request.params.prompt || ""), nodes), }; if (request.kind === "image") return { ...p, referenceImage: images[0], referenceImages: images.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("编组节点暂不支持执行"); }