You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
277 lines
7.9 KiB
277 lines
7.9 KiB
/**
|
|
* Execution Utilities
|
|
*
|
|
* Pure utility functions used by the workflow execution engine.
|
|
* Extracted from workflowStore.ts for testability and reuse.
|
|
*/
|
|
|
|
import { WorkflowNode, WorkflowEdge, WorkflowNodeData } from "@/types";
|
|
import { getSourceOutput } from "./connectedInputs";
|
|
|
|
// Concurrency settings
|
|
export const CONCURRENCY_SETTINGS_KEY = "node-banana-concurrency-limit";
|
|
export const DEFAULT_MAX_CONCURRENT_CALLS = 3;
|
|
|
|
/**
|
|
* Load concurrency setting from localStorage
|
|
*/
|
|
export const loadConcurrencySetting = (): number => {
|
|
if (typeof window === "undefined") return DEFAULT_MAX_CONCURRENT_CALLS;
|
|
const stored = localStorage.getItem(CONCURRENCY_SETTINGS_KEY);
|
|
if (stored) {
|
|
const parsed = parseInt(stored, 10);
|
|
if (!isNaN(parsed) && parsed >= 1 && parsed <= 10) {
|
|
return parsed;
|
|
}
|
|
}
|
|
return DEFAULT_MAX_CONCURRENT_CALLS;
|
|
};
|
|
|
|
/**
|
|
* Save concurrency setting to localStorage
|
|
*/
|
|
export const saveConcurrencySetting = (value: number): void => {
|
|
if (typeof window === "undefined") return;
|
|
localStorage.setItem(CONCURRENCY_SETTINGS_KEY, String(value));
|
|
};
|
|
|
|
/**
|
|
* Level grouping for parallel execution
|
|
*/
|
|
export interface LevelGroup {
|
|
level: number;
|
|
nodeIds: string[];
|
|
}
|
|
|
|
/**
|
|
* Groups nodes by dependency level using Kahn's algorithm variant.
|
|
* Nodes at the same level can be executed in parallel.
|
|
* Level 0 = nodes with no incoming edges (roots)
|
|
* Level N = nodes whose dependencies are all at levels < N
|
|
*/
|
|
export function groupNodesByLevel(
|
|
nodes: WorkflowNode[],
|
|
edges: WorkflowEdge[]
|
|
): LevelGroup[] {
|
|
// Calculate in-degree for each node
|
|
const inDegree = new Map<string, number>();
|
|
const adjList = new Map<string, string[]>();
|
|
|
|
nodes.forEach((n) => {
|
|
inDegree.set(n.id, 0);
|
|
adjList.set(n.id, []);
|
|
});
|
|
|
|
edges.forEach((e) => {
|
|
inDegree.set(e.target, (inDegree.get(e.target) || 0) + 1);
|
|
adjList.get(e.source)?.push(e.target);
|
|
});
|
|
|
|
// BFS with level tracking (Kahn's algorithm variant)
|
|
const levels: LevelGroup[] = [];
|
|
let currentLevel = nodes
|
|
.filter((n) => inDegree.get(n.id) === 0)
|
|
.map((n) => n.id);
|
|
|
|
let levelNum = 0;
|
|
while (currentLevel.length > 0) {
|
|
levels.push({ level: levelNum, nodeIds: [...currentLevel] });
|
|
|
|
const nextLevel: string[] = [];
|
|
for (const nodeId of currentLevel) {
|
|
for (const child of adjList.get(nodeId) || []) {
|
|
if (!inDegree.has(child)) continue; // skip orphan edge targets
|
|
const newDegree = inDegree.get(child)! - 1;
|
|
inDegree.set(child, newDegree);
|
|
if (newDegree === 0) {
|
|
nextLevel.push(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
currentLevel = nextLevel;
|
|
levelNum++;
|
|
}
|
|
|
|
return levels;
|
|
}
|
|
|
|
/**
|
|
* Chunk an array into smaller arrays of specified size
|
|
*/
|
|
export function chunk<T>(array: T[], size: number): T[][] {
|
|
if (!Number.isFinite(size) || size < 1) {
|
|
throw new Error("Invalid chunk size: must be a positive integer");
|
|
}
|
|
const chunks: T[][] = [];
|
|
for (let i = 0; i < array.length; i += size) {
|
|
chunks.push(array.slice(i, i + size));
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
/**
|
|
* Revoke a blob URL if the value is one, to free the underlying memory.
|
|
*/
|
|
export function revokeBlobUrl(url: string | null | undefined): void {
|
|
if (url && url.startsWith('blob:')) {
|
|
try { URL.revokeObjectURL(url); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all imageRefs from nodes (used when saving to a different directory)
|
|
*/
|
|
export function clearNodeImageRefs(nodes: WorkflowNode[]): WorkflowNode[] {
|
|
return nodes.map(node => {
|
|
const data = { ...node.data } as Record<string, unknown>;
|
|
|
|
// Revoke blob URLs for video/3D outputs before clearing
|
|
revokeBlobUrl(data.outputVideo as string | undefined);
|
|
revokeBlobUrl(data.glbUrl as string | undefined);
|
|
|
|
// Clear all ref fields regardless of node type (match any key ending in Ref or Refs)
|
|
for (const key of Object.keys(data)) {
|
|
if (/Refs?$/.test(key)) {
|
|
delete data[key];
|
|
}
|
|
}
|
|
|
|
return { ...node, data: data as WorkflowNodeData } as WorkflowNode;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if adding an edge from sourceId to targetId would create a cycle.
|
|
* Uses iterative DFS to check if targetId can reach sourceId through existing edges.
|
|
*/
|
|
export function wouldCreateCycle(
|
|
sourceId: string,
|
|
targetId: string,
|
|
edges: WorkflowEdge[]
|
|
): boolean {
|
|
// Self-loop check
|
|
if (sourceId === targetId) return true;
|
|
|
|
// Build adjacency list (edge.source → edge.target)
|
|
const adjList = new Map<string, string[]>();
|
|
edges.forEach((edge) => {
|
|
if (!adjList.has(edge.source)) {
|
|
adjList.set(edge.source, []);
|
|
}
|
|
adjList.get(edge.source)!.push(edge.target);
|
|
});
|
|
|
|
// DFS from targetId to see if we can reach sourceId
|
|
const visited = new Set<string>();
|
|
const stack = [targetId];
|
|
|
|
while (stack.length > 0) {
|
|
const current = stack.pop()!;
|
|
if (current === sourceId) return true;
|
|
if (visited.has(current)) continue;
|
|
|
|
visited.add(current);
|
|
const neighbors = adjList.get(current) || [];
|
|
for (const neighbor of neighbors) {
|
|
if (!visited.has(neighbor)) {
|
|
stack.push(neighbor);
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Find all nodes that are part of a loop body.
|
|
* Returns the intersection of nodes reachable forward from loopTarget
|
|
* and nodes reachable backward from loopSource.
|
|
*/
|
|
export function findLoopSubgraph(
|
|
loopSource: string,
|
|
loopTarget: string,
|
|
forwardEdges: WorkflowEdge[]
|
|
): string[] {
|
|
// Build adjacency lists
|
|
const forward = new Map<string, string[]>();
|
|
const backward = new Map<string, string[]>();
|
|
|
|
forwardEdges.forEach((edge) => {
|
|
if (!forward.has(edge.source)) {
|
|
forward.set(edge.source, []);
|
|
}
|
|
forward.get(edge.source)!.push(edge.target);
|
|
|
|
if (!backward.has(edge.target)) {
|
|
backward.set(edge.target, []);
|
|
}
|
|
backward.get(edge.target)!.push(edge.source);
|
|
});
|
|
|
|
// BFS forward from loopTarget
|
|
const forwardReachable = new Set<string>();
|
|
const forwardQueue = [loopTarget];
|
|
while (forwardQueue.length > 0) {
|
|
const current = forwardQueue.shift()!;
|
|
if (forwardReachable.has(current)) continue;
|
|
forwardReachable.add(current);
|
|
|
|
const neighbors = forward.get(current) || [];
|
|
for (const neighbor of neighbors) {
|
|
if (!forwardReachable.has(neighbor)) {
|
|
forwardQueue.push(neighbor);
|
|
}
|
|
}
|
|
}
|
|
|
|
// BFS backward from loopSource
|
|
const backwardReachable = new Set<string>();
|
|
const backwardQueue = [loopSource];
|
|
while (backwardQueue.length > 0) {
|
|
const current = backwardQueue.shift()!;
|
|
if (backwardReachable.has(current)) continue;
|
|
backwardReachable.add(current);
|
|
|
|
const neighbors = backward.get(current) || [];
|
|
for (const neighbor of neighbors) {
|
|
if (!backwardReachable.has(neighbor)) {
|
|
backwardQueue.push(neighbor);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return intersection
|
|
const intersection = Array.from(forwardReachable).filter((node) =>
|
|
backwardReachable.has(node)
|
|
);
|
|
return intersection;
|
|
}
|
|
|
|
/**
|
|
* Copy output data from source node to target node input field.
|
|
* Used for loop edges to transfer data from loop end back to loop start.
|
|
*/
|
|
export function copyLoopOutput(
|
|
sourceNode: WorkflowNode,
|
|
sourceHandle: string | null,
|
|
targetNode: WorkflowNode,
|
|
targetHandle: string | null,
|
|
updateNodeData: (nodeId: string, data: Partial<WorkflowNodeData>) => void
|
|
): void {
|
|
const { type, value } = getSourceOutput(sourceNode, sourceHandle);
|
|
|
|
// If value is null, do nothing
|
|
if (value === null) return;
|
|
|
|
// Map output type to target input field based on node type
|
|
if (type === "image" && targetNode.type === "imageInput") {
|
|
updateNodeData(targetNode.id, { image: value });
|
|
} else if (type === "video" && targetNode.type === "videoInput") {
|
|
updateNodeData(targetNode.id, { video: value });
|
|
} else if (type === "text" && targetNode.type === "prompt") {
|
|
updateNodeData(targetNode.id, { prompt: value });
|
|
} else if (type === "audio" && targetNode.type === "audioInput") {
|
|
updateNodeData(targetNode.id, { audioFile: value });
|
|
}
|
|
}
|
|
|