Browse Source
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 <noreply@anthropic.com>handoff-20260429-1057
3 changed files with 217 additions and 0 deletions
@ -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<void> { |
||||
|
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); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue