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.
728 lines
24 KiB
728 lines
24 KiB
/**
|
|
* Generate API Route
|
|
*
|
|
* TIMEOUT CONFIGURATION:
|
|
* - maxDuration: Only applies on Vercel, not locally
|
|
* - AbortSignal.timeout: Controls outgoing fetch to providers
|
|
* - For local development, server.requestTimeout must be set in server.js (Node.js default is 5 minutes)
|
|
*
|
|
* FAL.AI QUEUE API NOTE:
|
|
* Uses generateWithFalQueue with async queue submission + polling.
|
|
* Images are uploaded to fal CDN before submission to avoid payload size issues.
|
|
*/
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { GenerateRequest, GenerateResponse, ModelType, SelectedModel, ProviderType } from "@/types";
|
|
import { GenerationInput, ModelCapability } from "@/lib/providers/types";
|
|
import { generateWithGemini, generateWithGeminiVideo } from "./providers/gemini";
|
|
import { generateWithReplicate } from "./providers/replicate";
|
|
import { clearFalInputMappingCache as _clearFalInputMappingCache, generateWithFalQueue } from "./providers/fal";
|
|
import { submitKieTask } from "./providers/kie";
|
|
import { generateWithWaveSpeed } from "./providers/wavespeed";
|
|
import { generateWithNewApiWG } from "./providers/newapiwg";
|
|
import { submitPopiTask } from "./providers/popiserver";
|
|
import { getRequestToken, requireConfiguredValue, requireLogin } from "../_auth";
|
|
import { ApiError, apiErrorResponse } from "../_errors";
|
|
import { resolveUserGatewayApiKey } from "../_gatewayApiKey";
|
|
import { resolveTemporaryImageUrlsInArray } from "@/lib/images/temporaryUrls";
|
|
|
|
// Re-export for backward compatibility (test file imports from route)
|
|
export const clearFalInputMappingCache = _clearFalInputMappingCache;
|
|
|
|
export const maxDuration = 600; // 10 minute timeout for video generation polling
|
|
export const dynamic = 'force-dynamic'; // Ensure this route is always dynamic
|
|
|
|
|
|
/**
|
|
* Extended request format that supports both legacy and multi-provider requests
|
|
*/
|
|
interface MultiProviderGenerateRequest extends GenerateRequest {
|
|
selectedModel?: SelectedModel;
|
|
parameters?: Record<string, unknown>;
|
|
/** Dynamic inputs from schema-based connections (e.g., image_url, tail_image_url, prompt) */
|
|
dynamicInputs?: Record<string, string | string[]>;
|
|
}
|
|
|
|
interface ProviderConnectionParams {
|
|
apiKey?: string;
|
|
baseUrl?: string | null;
|
|
}
|
|
|
|
type RequiredSelectedModel = SelectedModel & {
|
|
modelId: string;
|
|
displayName: string;
|
|
};
|
|
|
|
const providerConnectionConfig: Partial<Record<ProviderType, {
|
|
apiKeyHeader?: string;
|
|
apiKeyEnv?: string;
|
|
baseUrlHeader?: string;
|
|
baseUrlEnv?: string;
|
|
}>> = {
|
|
newapiwg: {
|
|
baseUrlHeader: "X-NewApiWG-Base-URL",
|
|
baseUrlEnv: "NEWAPIWG_BASE_URL",
|
|
},
|
|
gemini: {
|
|
apiKeyHeader: "X-Gemini-API-Key",
|
|
apiKeyEnv: "GEMINI_API_KEY",
|
|
},
|
|
replicate: {
|
|
apiKeyHeader: "X-Replicate-API-Key",
|
|
apiKeyEnv: "REPLICATE_API_KEY",
|
|
},
|
|
fal: {
|
|
apiKeyHeader: "X-Fal-API-Key",
|
|
apiKeyEnv: "FAL_API_KEY",
|
|
},
|
|
kie: {
|
|
apiKeyHeader: "X-Kie-Key",
|
|
apiKeyEnv: "KIE_API_KEY",
|
|
},
|
|
wavespeed: {
|
|
apiKeyHeader: "X-WaveSpeed-Key",
|
|
apiKeyEnv: "WAVESPEED_API_KEY",
|
|
},
|
|
};
|
|
|
|
export function getConnectionParams(request: NextRequest, provider: ProviderType): ProviderConnectionParams {
|
|
const config = providerConnectionConfig[provider];
|
|
if (!config) return {};
|
|
|
|
const apiKey = config.apiKeyHeader && config.apiKeyEnv
|
|
? request.headers.get(config.apiKeyHeader) || process.env[config.apiKeyEnv] || undefined
|
|
: undefined;
|
|
const baseUrl = config.baseUrlHeader && config.baseUrlEnv
|
|
? request.headers.get(config.baseUrlHeader) || process.env[config.baseUrlEnv] || null
|
|
: undefined;
|
|
|
|
return { apiKey, baseUrl };
|
|
}
|
|
|
|
function requireSelectedModel(
|
|
selectedModel: SelectedModel | undefined,
|
|
providerName: string
|
|
): RequiredSelectedModel {
|
|
if (!selectedModel?.modelId || !selectedModel?.displayName) {
|
|
throw ApiError.badRequest(
|
|
`selectedModel with modelId and displayName is required for ${providerName}`
|
|
);
|
|
}
|
|
return selectedModel as RequiredSelectedModel;
|
|
}
|
|
|
|
export function buildMediaResponse(
|
|
output: { type: string; data: string; url?: string },
|
|
outputs?: Array<{ type: string; data: string; url?: string }>
|
|
): NextResponse {
|
|
if (output.type === "3d") {
|
|
return NextResponse.json<GenerateResponse>({
|
|
success: true,
|
|
model3dUrl: output.url,
|
|
contentType: "3d",
|
|
});
|
|
}
|
|
|
|
if (output.type === "video") {
|
|
const isLarge = !output.data && output.url;
|
|
return NextResponse.json<GenerateResponse>({
|
|
success: true,
|
|
video: isLarge ? undefined : output.data,
|
|
videoUrl: isLarge ? output.url : undefined,
|
|
contentType: "video",
|
|
});
|
|
}
|
|
|
|
if (output.type === "audio") {
|
|
const isLarge = !output.data && output.url;
|
|
return NextResponse.json<GenerateResponse>({
|
|
success: true,
|
|
audio: isLarge ? undefined : output.data,
|
|
audioUrl: isLarge ? output.url : undefined,
|
|
contentType: "audio",
|
|
});
|
|
}
|
|
|
|
const image = output.data || output.url;
|
|
const images = outputs
|
|
?.filter((item) => item.type === "image")
|
|
.map((item) => item.data || item.url)
|
|
.filter((item): item is string => typeof item === "string" && item.length > 0);
|
|
|
|
return NextResponse.json<GenerateResponse>({
|
|
success: true,
|
|
image,
|
|
...(images && images.length > 1 ? { images } : {}),
|
|
contentType: "image",
|
|
});
|
|
}
|
|
|
|
function capabilitiesForMediaType(mediaType?: string): ModelCapability[] {
|
|
const map: Record<string, ModelCapability[]> = {
|
|
audio: ["text-to-audio"],
|
|
video: ["text-to-video"],
|
|
"3d": ["text-to-3d"],
|
|
};
|
|
return map[mediaType ?? ""] ?? ["text-to-image"];
|
|
}
|
|
|
|
function mediaTypeForCapabilities(capabilities: string[] | undefined, fallback?: string): string {
|
|
if (fallback) return fallback;
|
|
if (capabilities?.some((capability) => capability.includes("audio"))) return "audio";
|
|
if (capabilities?.some((capability) => capability.includes("video"))) return "video";
|
|
if (capabilities?.some((capability) => capability.includes("3d"))) return "3d";
|
|
return "image";
|
|
}
|
|
|
|
function mergeImageGenerationParameters(
|
|
parameters: Record<string, unknown> | undefined,
|
|
aspectRatio: string | undefined,
|
|
resolution: string | undefined
|
|
): Record<string, unknown> | undefined {
|
|
const merged = { ...(parameters || {}) };
|
|
if (aspectRatio) {
|
|
merged.aspectRatio = aspectRatio;
|
|
merged.aspect_ratio = aspectRatio;
|
|
}
|
|
if (resolution) {
|
|
merged.resolution = resolution;
|
|
merged.imageSize = resolution;
|
|
}
|
|
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
}
|
|
|
|
async function readGenerateRequest(request: NextRequest): Promise<MultiProviderGenerateRequest> {
|
|
try {
|
|
return await request.json();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "";
|
|
if (
|
|
error instanceof SyntaxError ||
|
|
/json|unterminated string|unexpected end/i.test(message)
|
|
) {
|
|
throw ApiError.badRequest(
|
|
"生成请求过大或不完整:参考图数据可能在发送时被截断。请重试,或压缩参考图后再生成。"
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
const requestId = Math.random().toString(36).substring(7);
|
|
console.log(`\n[API:${requestId}] ========== NEW GENERATE REQUEST ==========`);
|
|
|
|
try {
|
|
const body = await readGenerateRequest(request);
|
|
const {
|
|
images,
|
|
prompt,
|
|
model = "nano-banana-pro",
|
|
aspectRatio,
|
|
resolution,
|
|
useGoogleSearch,
|
|
useImageSearch,
|
|
selectedModel,
|
|
parameters,
|
|
dynamicInputs,
|
|
mediaType,
|
|
} = body;
|
|
const provider: ProviderType = selectedModel?.provider || "gemini";
|
|
const requestImages = provider === "newapiwg"
|
|
? images || []
|
|
: resolveTemporaryImageUrlsInArray(images);
|
|
|
|
// Prompt is required unless:
|
|
// - Provided via dynamicInputs
|
|
// - Images are provided (image-to-video/image-to-image models)
|
|
// - Dynamic inputs contain image frames (first_frame, last_frame, etc.)
|
|
const hasPrompt = prompt || (dynamicInputs && (
|
|
typeof dynamicInputs.prompt === 'string'
|
|
? dynamicInputs.prompt
|
|
: Array.isArray(dynamicInputs.prompt) && dynamicInputs.prompt.length > 0
|
|
));
|
|
const hasImages = requestImages.length > 0;
|
|
const hasMediaInputs = dynamicInputs && Object.keys(dynamicInputs).some(key =>
|
|
key.includes('frame') || key.includes('image') || key.includes('video') || key.includes('audio')
|
|
);
|
|
|
|
if (!hasPrompt && !hasImages && !hasMediaInputs) {
|
|
throw ApiError.badRequest("Prompt or image input is required");
|
|
}
|
|
|
|
// Determine which provider to use
|
|
const login = provider !== "newapiwg" ? requireLogin(request) : null;
|
|
const loginToken = login?.token ?? getRequestToken(request);
|
|
console.log(`[API:${requestId}] Provider: ${provider}, Model: ${selectedModel?.modelId || model}`);
|
|
|
|
// Route to appropriate provider
|
|
if (provider === "popiserver") {
|
|
const selected = requireSelectedModel(selectedModel, "Popi Models");
|
|
const selectedCapabilities = selected.capabilities as ModelCapability[] | undefined;
|
|
const genInput: GenerationInput = {
|
|
model: {
|
|
id: selected.modelId,
|
|
name: selected.displayName,
|
|
provider: "popiserver",
|
|
capabilities: selectedCapabilities || capabilitiesForMediaType(mediaType),
|
|
description: null,
|
|
metadata: selected.metadata,
|
|
},
|
|
prompt: prompt || "",
|
|
images: images ? [...images] : [],
|
|
parameters: mergeImageGenerationParameters(parameters, aspectRatio, resolution),
|
|
dynamicInputs,
|
|
};
|
|
|
|
const { taskId } = await submitPopiTask(request, login!.token, genInput);
|
|
return NextResponse.json<GenerateResponse>({
|
|
success: true,
|
|
polling: true,
|
|
taskId,
|
|
pollProvider: "popiserver",
|
|
pollModelId: selected.modelId,
|
|
pollModelName: selected.displayName,
|
|
pollMediaType: mediaTypeForCapabilities(selected.capabilities, mediaType),
|
|
});
|
|
}
|
|
|
|
if (provider === "newapiwg") {
|
|
const selected = requireSelectedModel(selectedModel, "NewApiWG");
|
|
|
|
const { baseUrl: newApiWGBaseUrl } = getConnectionParams(request, "newapiwg");
|
|
const apiKey = await resolveUserGatewayApiKey(request);
|
|
|
|
const genInput: GenerationInput = {
|
|
model: {
|
|
id: selected.modelId,
|
|
name: selected.displayName,
|
|
provider: "newapiwg",
|
|
capabilities: (selected.capabilities as ModelCapability[] | undefined) || capabilitiesForMediaType(mediaType),
|
|
description: null,
|
|
},
|
|
prompt: prompt || "",
|
|
images: requestImages,
|
|
parameters: mergeImageGenerationParameters(parameters, aspectRatio, resolution),
|
|
dynamicInputs,
|
|
};
|
|
|
|
const result = await generateWithNewApiWG(apiKey, newApiWGBaseUrl ?? null, genInput, loginToken);
|
|
if (!result.success) {
|
|
return NextResponse.json<GenerateResponse>(
|
|
{ success: false, requestId, error: result.error || "Generation failed" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
const output = result.outputs?.[0];
|
|
if (!output?.data && !output?.url) {
|
|
return NextResponse.json<GenerateResponse>(
|
|
{ success: false, requestId, error: "No output in generation result" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
return buildMediaResponse(output);
|
|
}
|
|
|
|
if (provider === "replicate") {
|
|
const selected = requireSelectedModel(selectedModel, "Replicate");
|
|
|
|
// User-provided key takes precedence over env variable
|
|
const { apiKey: replicateApiKey } = getConnectionParams(request, "replicate");
|
|
const apiKey = requireConfiguredValue(
|
|
replicateApiKey,
|
|
"Replicate API key not configured. Add REPLICATE_API_KEY to .env.local or configure in Settings."
|
|
);
|
|
|
|
// Keep Data URIs as-is since localhost URLs won't work (provider can't reach them)
|
|
const processedImages = requestImages;
|
|
|
|
// Process dynamicInputs: filter empty values, keep Data URIs
|
|
let processedDynamicInputs: Record<string, string | string[]> | undefined = undefined;
|
|
|
|
if (dynamicInputs) {
|
|
processedDynamicInputs = {};
|
|
for (const key of Object.keys(dynamicInputs)) {
|
|
const value = dynamicInputs[key];
|
|
|
|
// Skip empty/null/undefined values (arrays pass through)
|
|
if (value === null || value === undefined || value === '') {
|
|
continue;
|
|
}
|
|
|
|
// Keep the value as-is (Data URIs work with Replicate)
|
|
processedDynamicInputs[key] = value;
|
|
}
|
|
}
|
|
|
|
// Build generation input
|
|
const genInput: GenerationInput = {
|
|
model: {
|
|
id: selected.modelId,
|
|
name: selected.displayName,
|
|
provider: "replicate",
|
|
capabilities: capabilitiesForMediaType(mediaType),
|
|
description: null,
|
|
},
|
|
prompt: prompt || "",
|
|
images: processedImages,
|
|
parameters,
|
|
dynamicInputs: processedDynamicInputs,
|
|
};
|
|
|
|
const result = await generateWithReplicate(requestId, apiKey, genInput);
|
|
|
|
if (!result.success) {
|
|
return NextResponse.json<GenerateResponse>(
|
|
{
|
|
success: false,
|
|
error: result.error || "Generation failed",
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
// Return first output
|
|
const output = result.outputs?.[0];
|
|
if (!output?.data && !output?.url) {
|
|
return NextResponse.json<GenerateResponse>(
|
|
{ success: false, error: "No output in generation result" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
return buildMediaResponse(output);
|
|
}
|
|
|
|
if (provider === "fal") {
|
|
const selected = requireSelectedModel(selectedModel, "fal.ai");
|
|
|
|
// User-provided key takes precedence over env variable
|
|
const { apiKey: falApiKey } = getConnectionParams(request, "fal");
|
|
|
|
if (!falApiKey) {
|
|
console.warn(`[API:${requestId}] No FAL API key configured. Proceeding without auth (rate-limited).`);
|
|
}
|
|
|
|
// Pass images as-is; generateWithFalQueue uploads base64 to CDN internally
|
|
const processedImages = requestImages;
|
|
|
|
// Process dynamicInputs: filter empty values
|
|
let processedDynamicInputs: Record<string, string | string[]> | undefined = undefined;
|
|
|
|
if (dynamicInputs) {
|
|
processedDynamicInputs = {};
|
|
for (const key of Object.keys(dynamicInputs)) {
|
|
const value = dynamicInputs[key];
|
|
|
|
// Skip empty/null/undefined values (arrays pass through)
|
|
if (value === null || value === undefined || value === '') {
|
|
continue;
|
|
}
|
|
|
|
// Keep the value as-is; CDN upload happens in generateWithFalQueue
|
|
processedDynamicInputs[key] = value;
|
|
}
|
|
}
|
|
|
|
// Build generation input
|
|
const genInput: GenerationInput = {
|
|
model: {
|
|
id: selected.modelId,
|
|
name: selected.displayName,
|
|
provider: "fal",
|
|
capabilities: capabilitiesForMediaType(mediaType),
|
|
description: null,
|
|
},
|
|
prompt: prompt || "",
|
|
images: processedImages,
|
|
parameters,
|
|
dynamicInputs: processedDynamicInputs,
|
|
};
|
|
|
|
const result = await generateWithFalQueue(requestId, falApiKey || null, genInput);
|
|
|
|
if (!result.success) {
|
|
return NextResponse.json<GenerateResponse>(
|
|
{
|
|
success: false,
|
|
error: result.error || "Generation failed",
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
// Return first output
|
|
const output = result.outputs?.[0];
|
|
if (!output?.data && !output?.url) {
|
|
return NextResponse.json<GenerateResponse>(
|
|
{ success: false, error: "No output in generation result" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
return buildMediaResponse(output);
|
|
}
|
|
|
|
if (provider === "kie") {
|
|
const selected = requireSelectedModel(selectedModel, "Kie.ai");
|
|
|
|
// User-provided key takes precedence over env variable
|
|
const { apiKey: kieApiKey } = getConnectionParams(request, "kie");
|
|
const apiKey = requireConfiguredValue(
|
|
kieApiKey,
|
|
"Kie.ai API key not configured. Add KIE_API_KEY to .env.local or configure in Settings."
|
|
);
|
|
|
|
// Process images - Kie requires URLs, we'll upload base64 images in generateWithKie
|
|
const processedImages = requestImages;
|
|
|
|
// Process dynamicInputs: filter empty values
|
|
let processedDynamicInputs: Record<string, string | string[]> | undefined = undefined;
|
|
|
|
if (dynamicInputs) {
|
|
processedDynamicInputs = {};
|
|
for (const key of Object.keys(dynamicInputs)) {
|
|
const value = dynamicInputs[key];
|
|
|
|
// Skip empty/null/undefined values
|
|
if (value === null || value === undefined || value === '') {
|
|
continue;
|
|
}
|
|
|
|
processedDynamicInputs[key] = value;
|
|
}
|
|
}
|
|
|
|
// Build generation input
|
|
const genInput: GenerationInput = {
|
|
model: {
|
|
id: selected.modelId,
|
|
name: selected.displayName,
|
|
provider: "kie",
|
|
capabilities: capabilitiesForMediaType(mediaType),
|
|
description: null,
|
|
},
|
|
prompt: prompt || "",
|
|
images: processedImages,
|
|
parameters,
|
|
dynamicInputs: processedDynamicInputs,
|
|
};
|
|
|
|
// Submit task and return immediately — client polls for completion
|
|
try {
|
|
const { taskId, isVeo } = await submitKieTask(requestId, apiKey, genInput);
|
|
return NextResponse.json<GenerateResponse>({
|
|
success: true,
|
|
polling: true,
|
|
taskId,
|
|
pollProvider: 'kie',
|
|
pollModelId: selected.modelId,
|
|
pollModelName: selected.displayName,
|
|
pollMediaType: mediaType || 'image',
|
|
});
|
|
} catch (error) {
|
|
return NextResponse.json<GenerateResponse>(
|
|
{
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Task submission failed",
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
if (provider === "wavespeed") {
|
|
const selected = requireSelectedModel(selectedModel, "WaveSpeed");
|
|
|
|
// User-provided key takes precedence over env variable
|
|
const { apiKey: wavespeedApiKey } = getConnectionParams(request, "wavespeed");
|
|
const apiKey = requireConfiguredValue(
|
|
wavespeedApiKey,
|
|
"WaveSpeed API key not configured. Add WAVESPEED_API_KEY to .env.local or configure in Settings."
|
|
);
|
|
|
|
// Keep Data URIs as-is since localhost URLs won't work
|
|
const processedImages = requestImages;
|
|
|
|
// Process dynamicInputs: filter empty values
|
|
let processedDynamicInputs: Record<string, string | string[]> | undefined = undefined;
|
|
|
|
if (dynamicInputs) {
|
|
processedDynamicInputs = {};
|
|
for (const key of Object.keys(dynamicInputs)) {
|
|
const value = dynamicInputs[key];
|
|
|
|
// Skip empty/null/undefined values
|
|
if (value === null || value === undefined || value === '') {
|
|
continue;
|
|
}
|
|
|
|
processedDynamicInputs[key] = value;
|
|
}
|
|
}
|
|
|
|
// Build generation input
|
|
const genInput: GenerationInput = {
|
|
model: {
|
|
id: selected.modelId,
|
|
name: selected.displayName,
|
|
provider: "wavespeed",
|
|
capabilities: capabilitiesForMediaType(mediaType),
|
|
description: null,
|
|
},
|
|
prompt: prompt || "",
|
|
images: processedImages,
|
|
parameters,
|
|
dynamicInputs: processedDynamicInputs,
|
|
};
|
|
|
|
const result = await generateWithWaveSpeed(requestId, apiKey, genInput);
|
|
|
|
if (!result.success) {
|
|
return NextResponse.json<GenerateResponse>(
|
|
{
|
|
success: false,
|
|
error: result.error || "Generation failed",
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
// Return first output
|
|
const output = result.outputs?.[0];
|
|
if (!output?.data && !output?.url) {
|
|
return NextResponse.json<GenerateResponse>(
|
|
{ success: false, error: "No output in generation result" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
return buildMediaResponse(output);
|
|
}
|
|
|
|
// Default: Use Gemini
|
|
// User-provided key (from settings) takes precedence over env variable
|
|
const { apiKey: geminiApiKey } = getConnectionParams(request, "gemini");
|
|
|
|
if (!geminiApiKey) {
|
|
throw ApiError.config("API key not configured. Add GEMINI_API_KEY to .env.local or configure in Settings.", 500);
|
|
}
|
|
|
|
// Use selectedModel.modelId if available (new format), fallback to legacy model field
|
|
const geminiModel = (selectedModel?.modelId as ModelType) || model;
|
|
|
|
// Resolve prompt: use top-level prompt, fall back to dynamicInputs.prompt
|
|
// This handles cases where the prompt arrives via dynamicInputs instead of top-level
|
|
let resolvedPrompt = prompt;
|
|
if (!resolvedPrompt && dynamicInputs?.prompt) {
|
|
resolvedPrompt = Array.isArray(dynamicInputs.prompt)
|
|
? dynamicInputs.prompt[0]
|
|
: dynamicInputs.prompt;
|
|
}
|
|
// Validate: if a prompt was provided but isn't a string (corrupted data), return clear error
|
|
// If no prompt provided but images exist, that's valid (image-to-image)
|
|
if (resolvedPrompt !== undefined && resolvedPrompt !== null && typeof resolvedPrompt !== 'string') {
|
|
return NextResponse.json<GenerateResponse>(
|
|
{ success: false, error: "prompt must be a string" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Check if this is a Veo video model request
|
|
if (selectedModel?.modelId?.startsWith("veo-")) {
|
|
// Merge negative prompt from dynamic inputs (connected handle) into parameters
|
|
const veoParams = { ...(parameters || {}) };
|
|
if (dynamicInputs?.negative_prompt) {
|
|
const neg = Array.isArray(dynamicInputs.negative_prompt)
|
|
? dynamicInputs.negative_prompt[0]
|
|
: dynamicInputs.negative_prompt;
|
|
if (neg) veoParams.negativePrompt = neg;
|
|
}
|
|
const result = await generateWithGeminiVideo(
|
|
requestId,
|
|
geminiApiKey,
|
|
selectedModel.modelId,
|
|
resolvedPrompt || "",
|
|
requestImages,
|
|
veoParams,
|
|
);
|
|
|
|
if (!result.success) {
|
|
return NextResponse.json<GenerateResponse>(
|
|
{ success: false, error: result.error || "Video generation failed" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
const output = result.outputs?.[0];
|
|
if (!output?.data && !output?.url) {
|
|
return NextResponse.json<GenerateResponse>(
|
|
{ success: false, error: "No output in video generation result" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
return buildMediaResponse(output);
|
|
}
|
|
|
|
return await generateWithGemini(
|
|
requestId,
|
|
geminiApiKey,
|
|
resolvedPrompt,
|
|
requestImages,
|
|
geminiModel,
|
|
aspectRatio,
|
|
resolution,
|
|
useGoogleSearch,
|
|
useImageSearch
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof ApiError) {
|
|
return apiErrorResponse(error, requestId);
|
|
}
|
|
|
|
// Extract error information
|
|
let errorMessage = "Generation failed";
|
|
let errorDetails = "";
|
|
|
|
if (error instanceof Error) {
|
|
errorMessage = error.message;
|
|
if ("cause" in error && error.cause) {
|
|
errorDetails = JSON.stringify(error.cause);
|
|
}
|
|
}
|
|
|
|
// Try to extract more details from API errors
|
|
if (error && typeof error === "object") {
|
|
const apiError = error as Record<string, unknown>;
|
|
if (apiError.status) {
|
|
errorDetails += ` Status: ${apiError.status}`;
|
|
}
|
|
if (apiError.statusText) {
|
|
errorDetails += ` ${apiError.statusText}`;
|
|
}
|
|
}
|
|
|
|
// Handle rate limiting
|
|
if (errorMessage.includes("429")) {
|
|
return NextResponse.json<GenerateResponse>(
|
|
{
|
|
success: false,
|
|
error: "Rate limit reached. Please wait and try again.",
|
|
},
|
|
{ status: 429 }
|
|
);
|
|
}
|
|
|
|
console.error(`[API:${requestId}] Generation error: ${errorMessage}${errorDetails ? ` (${errorDetails.substring(0, 200)})` : ""}`);
|
|
return NextResponse.json<GenerateResponse>(
|
|
{
|
|
success: false,
|
|
requestId,
|
|
error: errorMessage,
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|