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.
252 lines
8.0 KiB
252 lines
8.0 KiB
/**
|
|
* GenerateAudio Executor
|
|
*
|
|
* Unified executor for generateAudio (TTS) nodes.
|
|
* Used by both executeWorkflow and regenerateNode.
|
|
*/
|
|
|
|
import type { GenerateAudioNodeData, SelectedModel } from "@/types";
|
|
import { buildGenerateHeaders } from "@/store/utils/buildApiHeaders";
|
|
import { pollGenerateTask } from "./pollTaskCompletion";
|
|
import { runWithFallback } from "./runWithFallback";
|
|
import type { NodeExecutionContext } from "./types";
|
|
import { uploadPopiserverDynamicInputsForGeneration } from "@/utils/temporaryImageUpload";
|
|
import { buildDefaultParametersFromSchema, mergeSchemaDefaults } from "@/utils/modelSchemaDefaults";
|
|
|
|
export interface GenerateAudioOptions {
|
|
/** When true, falls back to stored inputPrompt if no connections provide it. */
|
|
useStoredFallback?: boolean;
|
|
}
|
|
|
|
function firstTextValue(value: string | string[] | undefined): string | null {
|
|
if (Array.isArray(value)) return value.find((item) => item.trim().length > 0) ?? null;
|
|
return value && value.trim().length > 0 ? value : null;
|
|
}
|
|
|
|
export async function executeGenerateAudio(
|
|
ctx: NodeExecutionContext,
|
|
_options: GenerateAudioOptions = {}
|
|
): Promise<void> {
|
|
const {
|
|
node,
|
|
getConnectedInputs,
|
|
updateNodeData,
|
|
getFreshNode,
|
|
signal,
|
|
providerSettings,
|
|
addIncurredCost,
|
|
generationsPath,
|
|
getNodes,
|
|
trackSaveGeneration,
|
|
} = ctx;
|
|
|
|
void _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;
|
|
|
|
const schemaPrompt = firstTextValue(dynamicInputs.prompt) ?? firstTextValue(dynamicInputs.text);
|
|
const text = connectedText ?? schemaPrompt ?? nodeData.inputPrompt ?? null;
|
|
|
|
if (!text) {
|
|
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 runOnce = async (modelToUse: SelectedModel, parametersOverride?: Record<string, unknown>): Promise<void> => {
|
|
const provider = modelToUse.provider;
|
|
const headers = buildGenerateHeaders(provider, providerSettings);
|
|
const preparedDynamicInputs = provider === "popiserver"
|
|
? await uploadPopiserverDynamicInputsForGeneration(dynamicInputs)
|
|
: { dynamicInputs, cleanupIds: [] };
|
|
|
|
const requestPayload = {
|
|
images: [],
|
|
prompt: text,
|
|
selectedModel: modelToUse,
|
|
parameters: mergeSchemaDefaults(
|
|
buildDefaultParametersFromSchema(nodeData.parameterSchema ?? []),
|
|
parametersOverride ?? nodeData.parameters
|
|
),
|
|
dynamicInputs: preparedDynamicInputs.dynamicInputs,
|
|
mediaType: "audio" as const,
|
|
};
|
|
|
|
let errorHandled = false;
|
|
|
|
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;
|
|
if (errorJson.requestId) {
|
|
errorMessage = `${errorMessage} (request ${errorJson.requestId})`;
|
|
}
|
|
} catch {
|
|
if (errorText) errorMessage += ` - ${errorText.substring(0, 200)}`;
|
|
}
|
|
|
|
updateNodeData(node.id, {
|
|
status: "error",
|
|
error: errorMessage,
|
|
});
|
|
errorHandled = true;
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
let result = await response.json();
|
|
|
|
// Handle polling response (long-running Kie tasks)
|
|
if (result.polling) {
|
|
result = await pollGenerateTask({
|
|
taskId: result.taskId,
|
|
provider: result.pollProvider,
|
|
modelId: result.pollModelId,
|
|
modelName: result.pollModelName,
|
|
mediaType: result.pollMediaType,
|
|
headers,
|
|
signal,
|
|
});
|
|
|
|
if (!result.success) {
|
|
updateNodeData(node.id, {
|
|
status: "error",
|
|
error: result.error || "Audio generation failed",
|
|
});
|
|
errorHandled = true;
|
|
throw new Error(result.error || "Audio generation failed");
|
|
}
|
|
}
|
|
|
|
// 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: modelToUse.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 (modelToUse.provider === "fal" && modelToUse.pricing) {
|
|
addIncurredCost(modelToUse.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",
|
|
});
|
|
errorHandled = true;
|
|
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;
|
|
}
|
|
|
|
if (!errorHandled) {
|
|
updateNodeData(node.id, {
|
|
status: "error",
|
|
error: errorMessage,
|
|
});
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
};
|
|
|
|
await runWithFallback({
|
|
nodeId: node.id,
|
|
primary: nodeData.selectedModel,
|
|
fallback: nodeData.fallbackModel,
|
|
fallbackParameters: nodeData.fallbackParameters,
|
|
updateNodeData,
|
|
runOnce,
|
|
clearOutput: { outputAudio: null },
|
|
});
|
|
}
|
|
|