From cfa6a264b014f49da4be502f6205589a949d2e4a Mon Sep 17 00:00:00 2001 From: shrimbly Date: Wed, 18 Feb 2026 08:56:59 +1300 Subject: [PATCH] feat: add generateAudio executor and register in workflow store Create generateAudioExecutor.ts following the video executor pattern, handling text-to-audio generation with history tracking and file saving. Register in workflowStore for both executeWorkflow and regenerateNode. Co-Authored-By: Claude Opus 4.6 --- src/store/execution/generateAudioExecutor.ts | 208 +++++++++++++++++++ src/store/execution/index.ts | 3 + src/store/workflowStore.ts | 6 + 3 files changed, 217 insertions(+) create mode 100644 src/store/execution/generateAudioExecutor.ts diff --git a/src/store/execution/generateAudioExecutor.ts b/src/store/execution/generateAudioExecutor.ts new file mode 100644 index 00000000..8b76024c --- /dev/null +++ b/src/store/execution/generateAudioExecutor.ts @@ -0,0 +1,208 @@ +/** + * GenerateAudio Executor + * + * Unified executor for generateAudio (TTS) nodes. + * Used by both executeWorkflow and regenerateNode. + */ + +import type { GenerateAudioNodeData } from "@/types"; +import { buildGenerateHeaders } from "@/store/utils/buildApiHeaders"; +import type { NodeExecutionContext } from "./types"; + +export interface GenerateAudioOptions { + /** When true, falls back to stored inputPrompt if no connections provide it. */ + useStoredFallback?: boolean; +} + +export async function executeGenerateAudio( + ctx: NodeExecutionContext, + options: GenerateAudioOptions = {} +): Promise { + const { + node, + getConnectedInputs, + updateNodeData, + getFreshNode, + signal, + providerSettings, + addIncurredCost, + generationsPath, + getNodes, + trackSaveGeneration, + } = ctx; + + const { useStoredFallback = false } = options; + + const { text: connectedText, dynamicInputs } = getConnectedInputs(node.id); + + // Get fresh node data from store + const freshNode = getFreshNode(node.id); + const nodeData = (freshNode?.data || node.data) as GenerateAudioNodeData; + + // Determine text input + let text: string | null; + + if (useStoredFallback) { + text = connectedText ?? nodeData.inputPrompt; + const hasPrompt = text || dynamicInputs.prompt; + if (!hasPrompt) { + updateNodeData(node.id, { + status: "error", + error: "Missing text input for audio generation", + }); + throw new Error("Missing text input for audio generation"); + } + } else { + text = connectedText; + const hasPrompt = text || dynamicInputs.prompt; + if (!hasPrompt) { + updateNodeData(node.id, { + status: "error", + error: "Missing text input for audio generation", + }); + throw new Error("Missing text input for audio generation"); + } + } + + if (!nodeData.selectedModel?.modelId) { + updateNodeData(node.id, { + status: "error", + error: "No model selected", + }); + throw new Error("No model selected"); + } + + updateNodeData(node.id, { + inputPrompt: text, + status: "loading", + error: null, + }); + + const provider = nodeData.selectedModel.provider; + const headers = buildGenerateHeaders(provider, providerSettings); + + const requestPayload = { + images: [], + prompt: text, + selectedModel: nodeData.selectedModel, + parameters: nodeData.parameters, + dynamicInputs, + mediaType: "audio" as const, + }; + + try { + const response = await fetch("/api/generate", { + method: "POST", + headers, + body: JSON.stringify(requestPayload), + ...(signal ? { signal } : {}), + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `HTTP ${response.status}`; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.error || errorMessage; + } catch { + if (errorText) errorMessage += ` - ${errorText.substring(0, 200)}`; + } + + updateNodeData(node.id, { + status: "error", + error: errorMessage, + }); + throw new Error(errorMessage); + } + + const result = await response.json(); + + // Handle audio response (audio or audioUrl field) + const audioData = result.audio || result.audioUrl; + if (result.success && audioData) { + const timestamp = Date.now(); + const audioId = `${timestamp}`; + + // Add to node's audio history + const newHistoryItem = { + id: audioId, + timestamp, + prompt: text || "", + model: nodeData.selectedModel?.modelId || "", + }; + const updatedHistory = [newHistoryItem, ...(nodeData.audioHistory || [])].slice(0, 50); + + updateNodeData(node.id, { + outputAudio: audioData, + status: "complete", + error: null, + audioHistory: updatedHistory, + selectedAudioHistoryIndex: 0, + }); + + // Track cost + if (nodeData.selectedModel?.provider === "fal" && nodeData.selectedModel?.pricing) { + addIncurredCost(nodeData.selectedModel.pricing.amount); + } + + // Auto-save to generations folder if configured + if (generationsPath) { + const savePromise = fetch("/api/save-generation", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + directoryPath: generationsPath, + audio: audioData, + prompt: text, + imageId: audioId, + }), + }) + .then((res) => res.json()) + .then((saveResult) => { + if (saveResult.success && saveResult.imageId && saveResult.imageId !== audioId) { + const currentNode = getNodes().find((n) => n.id === node.id); + if (currentNode) { + const currentData = currentNode.data as GenerateAudioNodeData; + const histCopy = [...(currentData.audioHistory || [])]; + const entryIndex = histCopy.findIndex((h) => h.id === audioId); + if (entryIndex !== -1) { + histCopy[entryIndex] = { ...histCopy[entryIndex], id: saveResult.imageId }; + updateNodeData(node.id, { audioHistory: histCopy }); + } + } + } + }) + .catch((err) => { + console.error("Failed to save audio generation:", err); + }); + + trackSaveGeneration(audioId, savePromise); + } + } else { + updateNodeData(node.id, { + status: "error", + error: result.error || "Audio generation failed", + }); + throw new Error(result.error || "Audio generation failed"); + } + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw error; + } + + let errorMessage = "Audio generation failed"; + if (error instanceof TypeError && error.message.includes("NetworkError")) { + errorMessage = "Network error. Check your connection and try again."; + } else if (error instanceof TypeError) { + errorMessage = `Network error: ${error.message}`; + } else if (error instanceof Error) { + errorMessage = error.message; + } + + updateNodeData(node.id, { + status: "error", + error: errorMessage, + }); + throw new Error(errorMessage); + } +} diff --git a/src/store/execution/index.ts b/src/store/execution/index.ts index bac0ad55..c7f1af5e 100644 --- a/src/store/execution/index.ts +++ b/src/store/execution/index.ts @@ -27,6 +27,9 @@ export type { GenerateVideoOptions } from "./generateVideoExecutor"; export { executeGenerate3D } from "./generate3dExecutor"; export type { Generate3DOptions } from "./generate3dExecutor"; +export { executeGenerateAudio } from "./generateAudioExecutor"; +export type { GenerateAudioOptions } from "./generateAudioExecutor"; + export { executeLlmGenerate } from "./llmGenerateExecutor"; export type { LlmGenerateOptions } from "./llmGenerateExecutor"; diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 8d57e4b2..435f9498 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -68,6 +68,7 @@ import { executeNanoBanana, executeGenerateVideo, executeGenerate3D, + executeGenerateAudio, executeLlmGenerate, executeSplitGrid, executeVideoStitch, @@ -893,6 +894,9 @@ export const useWorkflowStore = create((set, get) => ({ case "generate3d": await executeGenerate3D(executionCtx); break; + case "generateAudio": + await executeGenerateAudio(executionCtx); + break; case "llmGenerate": await executeLlmGenerate(executionCtx); break; @@ -1048,6 +1052,8 @@ export const useWorkflowStore = create((set, get) => ({ await executeGenerateVideo(executionCtx, regenOptions); } else if (node.type === "generate3d") { await executeGenerate3D(executionCtx, regenOptions); + } else if (node.type === "generateAudio") { + await executeGenerateAudio(executionCtx, regenOptions); } else if (node.type === "splitGrid") { await executeSplitGrid(executionCtx); } else if (node.type === "videoStitch") {