diff --git a/src/components/__tests__/SplitGridNode.test.tsx b/src/components/__tests__/SplitGridNode.test.tsx index 7d02f29a..b2d02003 100644 --- a/src/components/__tests__/SplitGridNode.test.tsx +++ b/src/components/__tests__/SplitGridNode.test.tsx @@ -52,7 +52,7 @@ describe("SplitGridNode", () => { groups: {}, nodes: [], edges: [], - getConnectedInputs: vi.fn(() => ({ images: [], videos: [], audio: [], model3d: null, text: null, dynamicInputs: {}, easeCurve: null })), + getConnectedInputs: vi.fn(() => ({ images: [], videos: [], audio: [], model3d: null, text: null, textItems: [], dynamicInputs: {}, easeCurve: null })), getNodesWithComments: vi.fn(() => []), markCommentViewed: vi.fn(), setNavigationTarget: vi.fn(), @@ -276,7 +276,7 @@ describe("SplitGridNode", () => { groups: {}, nodes: [], edges: [], - getConnectedInputs: vi.fn(() => ({ images: [], videos: [], audio: [], model3d: null, text: null, dynamicInputs: {}, easeCurve: null })), + getConnectedInputs: vi.fn(() => ({ images: [], videos: [], audio: [], model3d: null, text: null, textItems: [], dynamicInputs: {}, easeCurve: null })), getNodesWithComments: vi.fn(() => []), markCommentViewed: vi.fn(), setNavigationTarget: vi.fn(), diff --git a/src/components/nodes/ArrayNode.tsx b/src/components/nodes/ArrayNode.tsx index bce74194..59fb0713 100644 --- a/src/components/nodes/ArrayNode.tsx +++ b/src/components/nodes/ArrayNode.tsx @@ -226,21 +226,40 @@ export function ArrayNode({ id, data, selected }: NodeProps) { {/* Single text output point (each outgoing edge receives a separate item) */} - {/* Auto-route button - floating absolute positioned */} - + {/* Header buttons - floating absolute positioned */} +
+ {/* Batch mode toggle */} + + + {/* Auto-route button (hidden in batch mode) */} + {!nodeData.batchMode && ( + + )} +
@@ -352,9 +371,11 @@ export function ArrayNode({ id, data, selected }: NodeProps) { )}
- {nodeData.selectedOutputIndex !== null - ? `Next wire uses item ${nodeData.selectedOutputIndex + 1}` - : "No selection: wires advance in order from item 1"} + {nodeData.batchMode + ? "Batch: all items sent to downstream node" + : nodeData.selectedOutputIndex !== null + ? `Next wire uses item ${nodeData.selectedOutputIndex + 1}` + : "No selection: wires advance in order from item 1"}
diff --git a/src/store/utils/connectedInputs.ts b/src/store/utils/connectedInputs.ts index 6099f09d..d5250e21 100644 --- a/src/store/utils/connectedInputs.ts +++ b/src/store/utils/connectedInputs.ts @@ -39,6 +39,7 @@ export interface ConnectedInputs { audio: string[]; model3d: string | null; text: string | null; + textItems: string[]; // All items from array batch mode (empty when not in batch) dynamicInputs: Record; easeCurve: { bezierHandles: [number, number, number, number]; easingPreset: string | null; outputDuration: number } | null; } @@ -169,13 +170,14 @@ export function getConnectedInputsPure( dimmedNodeIds?: Set ): ConnectedInputs { const _visited = visited || new Set(); - if (_visited.has(nodeId)) return { images: [], videos: [], audio: [], model3d: null, text: null, dynamicInputs: {}, easeCurve: null }; + if (_visited.has(nodeId)) return { images: [], videos: [], audio: [], model3d: null, text: null, textItems: [], dynamicInputs: {}, easeCurve: null }; _visited.add(nodeId); const images: string[] = []; const videos: string[] = []; const audio: string[] = []; let model3d: string | null = null; let text: string | null = null; + const textItems: string[] = []; const dynamicInputs: Record = {}; let easeCurve: ConnectedInputs["easeCurve"] = null; @@ -226,6 +228,18 @@ export function getConnectedInputsPure( // Skip dimmed source nodes — their data should not flow downstream if (dimmedNodeIds && dimmedNodeIds.has(sourceNode.id)) return; + // Array batch mode — send all items as textItems instead of a single item + if (sourceNode.type === "array" && (edge.data as Record | undefined)?.arrayBatchAll === true) { + const arrayData = sourceNode.data as ArrayNodeData; + const items = arrayData.outputItems; + if (items.length > 0) { + textItems.push(...items); + // Set text to first item for backward compatibility + if (text === null) text = items[0]; + } + return; // Skip normal getSourceOutput processing + } + // Router passthrough — traverse upstream to find actual data source if (sourceNode.type === "router") { const routerInputs = passthroughCache.get(sourceNode.id) @@ -369,7 +383,7 @@ export function getConnectedInputsPure( } } - return { images, videos, audio, model3d, text, dynamicInputs, easeCurve }; + return { images, videos, audio, model3d, text, textItems, dynamicInputs, easeCurve }; } /** diff --git a/src/store/utils/nodeDefaults.ts b/src/store/utils/nodeDefaults.ts index a0269ae1..3a2f9c27 100644 --- a/src/store/utils/nodeDefaults.ts +++ b/src/store/utils/nodeDefaults.ts @@ -125,6 +125,7 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => { regexPattern: "", trimItems: true, removeEmpty: true, + batchMode: false, selectedOutputIndex: null, outputItems: [], outputText: "[]", diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index cecf73ca..993dc773 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -60,7 +60,7 @@ import { chunk, clearNodeImageRefs, } from "./utils/executionUtils"; -import { getConnectedInputsPure, validateWorkflowPure } from "./utils/connectedInputs"; +import { getConnectedInputsPure, validateWorkflowPure, type ConnectedInputs } from "./utils/connectedInputs"; import { evaluateRule } from "./utils/ruleEvaluation"; import { computeDimmedNodes } from "./utils/dimmingUtils"; import { @@ -96,7 +96,7 @@ export { CONCURRENCY_SETTINGS_KEY } from "./utils/executionUtils"; async function evaluateAndExecuteConditionalSwitch( node: WorkflowNode, executionCtx: NodeExecutionContext, - getConnectedInputs: (nodeId: string) => { text: string | null; images: string[]; videos: string[]; audio: string[]; model3d: string | null; dynamicInputs: Record; easeCurve: { bezierHandles: [number, number, number, number]; easingPreset: string | null; outputDuration: number } | null }, + getConnectedInputs: (nodeId: string) => ConnectedInputs, updateNodeData: (nodeId: string, data: Partial) => void, ): Promise { const condInputs = getConnectedInputs(node.id); @@ -144,6 +144,13 @@ function buildConnectionEdgeData( // Array node uses a single output handle; assign each edge a stable item index. if (sourceNode?.type === "array" && (connection.sourceHandle || "text") === "text") { const sourceData = sourceNode.data as Record; + + // Batch mode: all items sent through a single connection + if (sourceData.batchMode === true) { + baseData.arrayBatchAll = true; + return baseData; + } + const selectedIndex = sourceData.selectedOutputIndex; const outputItems = Array.isArray(sourceData.outputItems) ? sourceData.outputItems : []; const outputCount = outputItems.length; @@ -276,7 +283,7 @@ interface WorkflowStore { // Helpers getNodeById: (id: string) => WorkflowNode | undefined; - getConnectedInputs: (nodeId: string) => { images: string[]; videos: string[]; audio: string[]; model3d: string | null; text: string | null; dynamicInputs: Record; easeCurve: { bezierHandles: [number, number, number, number]; easingPreset: string | null; outputDuration: number } | null }; + getConnectedInputs: (nodeId: string) => ConnectedInputs; validateWorkflow: () => { valid: boolean; errors: string[] }; // Global Image History @@ -1314,6 +1321,76 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ const executionCtx = get()._buildExecutionContext(node, signal); + // Batch mode: for generate-type nodes, detect textItems and loop through them + const batchNodeTypes = new Set(["nanoBanana", "generateVideo", "generateAudio", "llmGenerate"]); + if (node.type && batchNodeTypes.has(node.type)) { + const connectedInputs = get().getConnectedInputs(node.id); + if (connectedInputs.textItems.length > 0) { + const items = connectedInputs.textItems; + const totalItems = items.length; + + for (let i = 0; i < totalItems; i++) { + // Check abort signal before each iteration + if (signal.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + + // Update status with batch progress + get().updateNodeData(node.id, { + status: "loading", + error: null, + } as Partial); + + logger.info('node.execution', `Batch ${i + 1} of ${totalItems}`, { + nodeId: node.id, + nodeType: node.type, + batchIndex: i, + batchTotal: totalItems, + }); + + // Create a wrapped context where getConnectedInputs returns + // the current item as `text` and clears `textItems` + const batchCtx: NodeExecutionContext = { + ...executionCtx, + getConnectedInputs: (nodeId: string) => { + const inputs = get().getConnectedInputs(nodeId); + return { + ...inputs, + text: items[i], + textItems: [], // Clear so executors don't see batch + }; + }, + }; + + // Execute the appropriate executor + switch (node.type) { + case "nanoBanana": + await executeNanoBanana(batchCtx); + break; + case "generateVideo": + await executeGenerateVideo(batchCtx); + break; + case "generateAudio": + await executeGenerateAudio(batchCtx); + break; + case "llmGenerate": + await executeLlmGenerate(batchCtx); + break; + } + + // After each iteration (except last), reset status to loading for next iteration + if (i < totalItems - 1) { + get().updateNodeData(node.id, { + status: "loading", + } as Partial); + } + } + + // Batch complete — skip the normal switch below + return; + } + } + switch (node.type) { case "imageInput": // Data source node - no execution needed diff --git a/src/types/nodes.ts b/src/types/nodes.ts index db12b047..18568bce 100644 --- a/src/types/nodes.ts +++ b/src/types/nodes.ts @@ -110,6 +110,7 @@ export interface ArrayNodeData extends BaseNodeData { regexPattern: string; trimItems: boolean; removeEmpty: boolean; + batchMode: boolean; // When true, all items are sent as a batch to downstream generate nodes selectedOutputIndex: number | null; outputItems: string[]; outputText: string | null; // JSON array string for the primary text output