diff --git a/src/app/api/generate/providers/gemini.ts b/src/app/api/generate/providers/gemini.ts new file mode 100644 index 00000000..aa2a3bf8 --- /dev/null +++ b/src/app/api/generate/providers/gemini.ts @@ -0,0 +1,188 @@ +/** + * Gemini Provider for Generate API Route + * + * Handles image generation using Google's Gemini API models. + */ + +import { NextResponse } from "next/server"; +import { GoogleGenAI } from "@google/genai"; +import { GenerateResponse, ModelType } from "@/types"; + +/** + * Map model types to Gemini model IDs + */ +export const MODEL_MAP: Record = { + "nano-banana": "gemini-2.5-flash-image", + "nano-banana-pro": "gemini-3-pro-image-preview", +}; + +/** + * Generate image using Gemini API (legacy/default path) + */ +export async function generateWithGemini( + requestId: string, + apiKey: string, + prompt: string, + images: string[], + model: ModelType, + aspectRatio?: string, + resolution?: string, + useGoogleSearch?: boolean +): Promise> { + console.log(`[API:${requestId}] Gemini generation - Model: ${model}, Images: ${images?.length || 0}, Prompt: ${prompt?.length || 0} chars`); + + // Extract base64 data and MIME types from data URLs + const imageData = (images || []).map((image, idx) => { + if (image.includes("base64,")) { + const [header, data] = image.split("base64,"); + // Extract MIME type from header (e.g., "data:image/png;" -> "image/png") + const mimeMatch = header.match(/data:([^;]+)/); + const mimeType = mimeMatch ? mimeMatch[1] : "image/png"; + console.log(`[API:${requestId}] Image ${idx + 1}: ${mimeType}, ${(data.length / 1024).toFixed(1)}KB`); + return { data, mimeType }; + } + console.log(`[API:${requestId}] Image ${idx + 1}: raw, ${(image.length / 1024).toFixed(1)}KB`); + return { data: image, mimeType: "image/png" }; + }); + + // Initialize Gemini client + const ai = new GoogleGenAI({ apiKey }); + + // Build request parts array with prompt and all images + const requestParts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [ + { text: prompt }, + ...imageData.map(({ data, mimeType }) => ({ + inlineData: { + mimeType, + data, + }, + })), + ]; + + // Build config object based on model capabilities + const config: Record = { + responseModalities: ["IMAGE", "TEXT"], + }; + + // Add imageConfig for both models (both support aspect ratio) + if (aspectRatio) { + config.imageConfig = { + aspectRatio, + }; + } + + // Add resolution only for Nano Banana Pro + if (model === "nano-banana-pro" && resolution) { + if (!config.imageConfig) { + config.imageConfig = {}; + } + (config.imageConfig as Record).imageSize = resolution; + } + + // Add tools array for Google Search (only Nano Banana Pro) + const tools = []; + if (model === "nano-banana-pro" && useGoogleSearch) { + tools.push({ googleSearch: {} }); + } + + console.log(`[API:${requestId}] Config: ${JSON.stringify(config)}`); + + // Make request to Gemini + const geminiStartTime = Date.now(); + + const response = await ai.models.generateContent({ + model: MODEL_MAP[model], + contents: [ + { + role: "user", + parts: requestParts, + }, + ], + config, + ...(tools.length > 0 && { tools }), + }); + + const geminiDuration = Date.now() - geminiStartTime; + console.log(`[API:${requestId}] Gemini API completed in ${geminiDuration}ms`); + + // Extract image from response + const candidates = response.candidates; + + if (!candidates || candidates.length === 0) { + console.error(`[API:${requestId}] No candidates in Gemini response`); + return NextResponse.json( + { + success: false, + error: "No response from AI model", + }, + { status: 500 } + ); + } + + const parts = candidates[0].content?.parts; + console.log(`[API:${requestId}] Response parts: ${parts?.length || 0}`); + + if (!parts) { + console.error(`[API:${requestId}] No parts in Gemini candidate content`); + return NextResponse.json( + { + success: false, + error: "No content in response", + }, + { status: 500 } + ); + } + + // Find image part in response + for (const part of parts) { + if (part.inlineData && part.inlineData.data) { + const mimeType = part.inlineData.mimeType || "image/png"; + const imgData = part.inlineData.data; + const imageSizeKB = (imgData.length / 1024).toFixed(1); + + console.log(`[API:${requestId}] Output image: ${mimeType}, ${imageSizeKB}KB`); + + const dataUrl = `data:${mimeType};base64,${imgData}`; + + const responsePayload = { success: true, image: dataUrl }; + const responseSize = JSON.stringify(responsePayload).length; + const responseSizeMB = (responseSize / (1024 * 1024)).toFixed(2); + + if (responseSize > 4.5 * 1024 * 1024) { + console.warn(`[API:${requestId}] Response size (${responseSizeMB}MB) approaching Next.js 5MB limit`); + } + + console.log(`[API:${requestId}] SUCCESS - Returning ${responseSizeMB}MB payload`); + + // Create response with explicit headers to handle large payloads + const resp = NextResponse.json(responsePayload); + resp.headers.set('Content-Type', 'application/json'); + resp.headers.set('Content-Length', responseSize.toString()); + + return resp; + } + } + + // If no image found, check for text error + for (const part of parts) { + if (part.text) { + console.error(`[API:${requestId}] Gemini returned text instead of image: ${part.text.substring(0, 100)}`); + return NextResponse.json( + { + success: false, + error: `Model returned text instead of image: ${part.text.substring(0, 200)}`, + }, + { status: 500 } + ); + } + } + + console.error(`[API:${requestId}] No image or text found in Gemini response`); + return NextResponse.json( + { + success: false, + error: "No image in response", + }, + { status: 500 } + ); +} diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index 122e03cf..5d9a0a4c 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -11,10 +11,8 @@ * Images are uploaded to fal CDN before submission to avoid payload size issues. */ import { NextRequest, NextResponse } from "next/server"; -import { GoogleGenAI } from "@google/genai"; import { GenerateRequest, GenerateResponse, ModelType, SelectedModel, ProviderType } from "@/types"; -import { GenerationInput, GenerationOutput, ProviderModel } from "@/lib/providers/types"; -import { uploadImageForUrl, shouldUseImageUrl, deleteImages } from "@/lib/images"; +import { GenerationInput, GenerationOutput } from "@/lib/providers/types"; import { validateMediaUrl } from "@/utils/urlValidation"; import { INPUT_PATTERNS, @@ -24,15 +22,11 @@ import { coerceParameterTypes, getInputMappingFromSchema, } from "./schemaUtils"; +import { generateWithGemini } from "./providers/gemini"; export const maxDuration = 300; // 5 minute timeout (Vercel hobby plan limit) export const dynamic = 'force-dynamic'; // Ensure this route is always dynamic -// Map model types to Gemini model IDs -const MODEL_MAP: Record = { - "nano-banana": "gemini-2.5-flash-image", // Updated to correct model name - "nano-banana-pro": "gemini-3-pro-image-preview", -}; /** * Extended request format that supports both legacy and multi-provider requests @@ -44,176 +38,6 @@ interface MultiProviderGenerateRequest extends GenerateRequest { dynamicInputs?: Record; } -/** - * Generate image using Gemini API (legacy/default path) - */ -async function generateWithGemini( - requestId: string, - apiKey: string, - prompt: string, - images: string[], - model: ModelType, - aspectRatio?: string, - resolution?: string, - useGoogleSearch?: boolean -): Promise> { - console.log(`[API:${requestId}] Gemini generation - Model: ${model}, Images: ${images?.length || 0}, Prompt: ${prompt?.length || 0} chars`); - - // Extract base64 data and MIME types from data URLs - const imageData = (images || []).map((image, idx) => { - if (image.includes("base64,")) { - const [header, data] = image.split("base64,"); - // Extract MIME type from header (e.g., "data:image/png;" -> "image/png") - const mimeMatch = header.match(/data:([^;]+)/); - const mimeType = mimeMatch ? mimeMatch[1] : "image/png"; - console.log(`[API:${requestId}] Image ${idx + 1}: ${mimeType}, ${(data.length / 1024).toFixed(1)}KB`); - return { data, mimeType }; - } - console.log(`[API:${requestId}] Image ${idx + 1}: raw, ${(image.length / 1024).toFixed(1)}KB`); - return { data: image, mimeType: "image/png" }; - }); - - // Initialize Gemini client - const ai = new GoogleGenAI({ apiKey }); - - // Build request parts array with prompt and all images - const requestParts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [ - { text: prompt }, - ...imageData.map(({ data, mimeType }) => ({ - inlineData: { - mimeType, - data, - }, - })), - ]; - - // Build config object based on model capabilities - const config: Record = { - responseModalities: ["IMAGE", "TEXT"], - }; - - // Add imageConfig for both models (both support aspect ratio) - if (aspectRatio) { - config.imageConfig = { - aspectRatio, - }; - } - - // Add resolution only for Nano Banana Pro - if (model === "nano-banana-pro" && resolution) { - if (!config.imageConfig) { - config.imageConfig = {}; - } - (config.imageConfig as Record).imageSize = resolution; - } - - // Add tools array for Google Search (only Nano Banana Pro) - const tools = []; - if (model === "nano-banana-pro" && useGoogleSearch) { - tools.push({ googleSearch: {} }); - } - - console.log(`[API:${requestId}] Config: ${JSON.stringify(config)}`); - - // Make request to Gemini - const geminiStartTime = Date.now(); - - const response = await ai.models.generateContent({ - model: MODEL_MAP[model], - contents: [ - { - role: "user", - parts: requestParts, - }, - ], - config, - ...(tools.length > 0 && { tools }), - }); - - const geminiDuration = Date.now() - geminiStartTime; - console.log(`[API:${requestId}] Gemini API completed in ${geminiDuration}ms`); - - // Extract image from response - const candidates = response.candidates; - - if (!candidates || candidates.length === 0) { - console.error(`[API:${requestId}] No candidates in Gemini response`); - return NextResponse.json( - { - success: false, - error: "No response from AI model", - }, - { status: 500 } - ); - } - - const parts = candidates[0].content?.parts; - console.log(`[API:${requestId}] Response parts: ${parts?.length || 0}`); - - if (!parts) { - console.error(`[API:${requestId}] No parts in Gemini candidate content`); - return NextResponse.json( - { - success: false, - error: "No content in response", - }, - { status: 500 } - ); - } - - // Find image part in response - for (const part of parts) { - if (part.inlineData && part.inlineData.data) { - const mimeType = part.inlineData.mimeType || "image/png"; - const imgData = part.inlineData.data; - const imageSizeKB = (imgData.length / 1024).toFixed(1); - - console.log(`[API:${requestId}] Output image: ${mimeType}, ${imageSizeKB}KB`); - - const dataUrl = `data:${mimeType};base64,${imgData}`; - - const responsePayload = { success: true, image: dataUrl }; - const responseSize = JSON.stringify(responsePayload).length; - const responseSizeMB = (responseSize / (1024 * 1024)).toFixed(2); - - if (responseSize > 4.5 * 1024 * 1024) { - console.warn(`[API:${requestId}] Response size (${responseSizeMB}MB) approaching Next.js 5MB limit`); - } - - console.log(`[API:${requestId}] SUCCESS - Returning ${responseSizeMB}MB payload`); - - // Create response with explicit headers to handle large payloads - const resp = NextResponse.json(responsePayload); - resp.headers.set('Content-Type', 'application/json'); - resp.headers.set('Content-Length', responseSize.toString()); - - return resp; - } - } - - // If no image found, check for text error - for (const part of parts) { - if (part.text) { - console.error(`[API:${requestId}] Gemini returned text instead of image: ${part.text.substring(0, 100)}`); - return NextResponse.json( - { - success: false, - error: `Model returned text instead of image: ${part.text.substring(0, 200)}`, - }, - { status: 500 } - ); - } - } - - console.error(`[API:${requestId}] No image or text found in Gemini response`); - return NextResponse.json( - { - success: false, - error: "No image in response", - }, - { status: 500 } - ); -} /**