import { GoogleGenAI } from '@google/genai'; // eslint-disable-next-line @typescript-eslint/no-var-requires const mime = require('mime') as any; import sizeOf from 'image-size'; import { ImageGenerationOptions, ImageGenerationResult, ReferenceImage, GeneratedImageData, GeminiParams, } from '../types/api'; import { StorageFactory } from './StorageFactory'; import { TTILogger, TTILogEntry } from './TTILogger'; import { NetworkErrorDetector } from '../utils/NetworkErrorDetector'; import { GeminiErrorDetector } from '../utils/GeminiErrorDetector'; import { ERROR_MESSAGES } from '../utils/constants/errors'; export class ImageGenService { private ai: GoogleGenAI; private primaryModel = 'gemini-2.5-flash-image'; private static GEMINI_TIMEOUT_MS = 90_000; // 90 seconds constructor(apiKey: string) { if (!apiKey) { throw new Error('Gemini API key is required'); } this.ai = new GoogleGenAI({ apiKey }); } /** * Generate an image from text prompt with optional reference images * This method separates image generation from storage for clear error handling */ async generateImage(options: ImageGenerationOptions): Promise { const { prompt, imageId, referenceImages, aspectRatio, orgSlug, projectSlug, meta } = options; // Use default values if not provided const finalOrgSlug = orgSlug || process.env['DEFAULT_ORG_SLUG'] || 'default'; const finalProjectSlug = projectSlug || process.env['DEFAULT_PROJECT_SLUG'] || 'main'; const finalAspectRatio = aspectRatio || '16:9'; // Default to widescreen // Step 1: Generate image from Gemini AI let generatedData: GeneratedImageData; let geminiParams: GeminiParams; try { const aiResult = await this.generateImageWithAI( prompt, referenceImages, finalAspectRatio, finalOrgSlug, finalProjectSlug, meta, ); generatedData = aiResult.generatedData; geminiParams = aiResult.geminiParams; } catch (error) { // Generation failed - return explicit error return { success: false, model: this.primaryModel, error: error instanceof Error ? error.message : 'Image generation failed', errorType: 'generation', }; } // Step 2: Save generated image to storage // Path format: {orgSlug}/{projectSlug}/img/{imageId} try { const storageService = await StorageFactory.getInstance(); // Original filename for metadata (e.g., "my-image.png") const originalFilename = `generated-image.${generatedData.fileExtension}`; const uploadResult = await storageService.uploadFile({ orgSlug: finalOrgSlug, projectSlug: finalProjectSlug, imageId, buffer: generatedData.buffer, contentType: generatedData.mimeType, originalFilename, }); if (uploadResult.success) { return { success: true, imageId: uploadResult.filename, filepath: uploadResult.path, url: uploadResult.url, size: uploadResult.size, model: this.primaryModel, geminiParams, generatedImageData: generatedData, ...(generatedData.description && { description: generatedData.description, }), }; } else { // Storage failed but image was generated return { success: false, model: this.primaryModel, geminiParams, error: `Image generated successfully but storage failed: ${uploadResult.error || 'Unknown storage error'}`, errorType: 'storage', generatedImageData: generatedData, ...(generatedData.description && { description: generatedData.description, }), }; } } catch (error) { // Storage exception - image was generated but couldn't be saved return { success: false, model: this.primaryModel, geminiParams, error: `Image generated successfully but storage failed: ${error instanceof Error ? error.message : 'Unknown storage error'}`, errorType: 'storage', generatedImageData: generatedData, ...(generatedData.description && { description: generatedData.description, }), }; } } /** * Generate image using Gemini AI - isolated from storage logic * @throws Error if generation fails */ private async generateImageWithAI( prompt: string, referenceImages: ReferenceImage[] | undefined, aspectRatio: string, orgSlug: string, projectSlug: string, meta?: { tags?: string[] }, ): Promise<{ generatedData: GeneratedImageData; geminiParams: GeminiParams; }> { const contentParts: any[] = []; // Add reference images if provided if (referenceImages && referenceImages.length > 0) { for (const refImage of referenceImages) { contentParts.push({ inlineData: { mimeType: refImage.mimetype, data: refImage.buffer.toString('base64'), }, }); } } // Add text prompt contentParts.push({ text: prompt, }); // CRITICAL: Calculate exact values before SDK call // These exact objects will be passed to both SDK and logger const contents = [ { role: 'user' as const, parts: contentParts, }, ]; const config = { responseModalities: ['IMAGE', 'TEXT'], imageConfig: { aspectRatio, }, }; // Capture Gemini SDK parameters for debugging const geminiParams: GeminiParams = { model: this.primaryModel, config, contentsStructure: { role: 'user', partsCount: contentParts.length, hasReferenceImages: !!(referenceImages && referenceImages.length > 0), }, }; // Log TTI request BEFORE SDK call - using exact same values const ttiLogger = TTILogger.getInstance(); const logEntry: TTILogEntry = { timestamp: new Date().toISOString(), orgId: orgSlug, projectId: projectSlug, prompt, model: this.primaryModel, config, ...(meta && { meta }), ...(referenceImages && referenceImages.length > 0 && { referenceImages: referenceImages.map((img) => ({ mimetype: img.mimetype, size: img.buffer.length, originalname: img.originalname, })), }), }; ttiLogger.log(logEntry); try { // Use the EXACT same config and contents objects calculated above // Wrap with timeout to prevent hanging requests const response = await this.withTimeout( this.ai.models.generateContent({ model: this.primaryModel, config, contents, }), ImageGenService.GEMINI_TIMEOUT_MS, 'Gemini image generation' ); // Log response structure for debugging GeminiErrorDetector.logResponseStructure(response as any); // Check promptFeedback for blocked prompts FIRST if ((response as any).promptFeedback?.blockReason) { const errorResult = GeminiErrorDetector.analyzeResponse(response as any); console.error( `[ImageGenService] Prompt blocked:`, GeminiErrorDetector.formatForLogging(errorResult!) ); throw new Error(errorResult!.message); } // Check if we have candidates if (!response.candidates || !response.candidates[0]) { const errorResult = GeminiErrorDetector.analyzeResponse(response as any); console.error(`[ImageGenService] No candidates in response`); throw new Error(errorResult?.message || 'No response candidates from Gemini AI'); } const candidate = response.candidates[0]; // Check finishReason for non-STOP completions if (candidate.finishReason && candidate.finishReason !== 'STOP') { const errorResult = GeminiErrorDetector.analyzeResponse(response as any); console.error( `[ImageGenService] Non-STOP finish reason:`, GeminiErrorDetector.formatForLogging(errorResult!) ); throw new Error(errorResult!.message); } // Check content exists if (!candidate.content) { console.error(`[ImageGenService] No content in candidate`); throw new Error('No content in Gemini AI response'); } const content = candidate.content; let generatedDescription: string | undefined; let imageData: { buffer: Buffer; mimeType: string } | null = null; // Extract image data and description from response for (const part of content.parts || []) { if (part.inlineData) { const buffer = Buffer.from(part.inlineData.data || '', 'base64'); const mimeType = part.inlineData.mimeType || 'image/png'; imageData = { buffer, mimeType }; } else if (part.text) { generatedDescription = part.text; } } if (!imageData) { // Log what we got instead of image const partTypes = (content.parts || []).map((p: any) => p.inlineData ? 'image' : p.text ? 'text' : 'other' ); console.error(`[ImageGenService] No image data in response. Parts: [${partTypes.join(', ')}]`); throw new Error( `${ERROR_MESSAGES.GEMINI_NO_IMAGE}. Response contained: ${partTypes.join(', ') || 'nothing'}` ); } const fileExtension = mime.getExtension(imageData.mimeType) || 'png'; // Extract image dimensions from buffer let width = 1024; // Default fallback let height = 1024; // Default fallback try { const dimensions = sizeOf(imageData.buffer); if (dimensions.width && dimensions.height) { width = dimensions.width; height = dimensions.height; } } catch (error) { console.warn('Failed to extract image dimensions, using defaults:', error); } const generatedData: GeneratedImageData = { buffer: imageData.buffer, mimeType: imageData.mimeType, fileExtension, width, height, ...(generatedDescription && { description: generatedDescription }), }; return { generatedData, geminiParams, }; } catch (error) { // Check for rate limit (HTTP 429) const err = error as { status?: number; message?: string }; if (err.status === 429) { const geminiError = GeminiErrorDetector.classifyApiError(error); console.error( `[ImageGenService] Rate limit:`, GeminiErrorDetector.formatForLogging(geminiError) ); throw new Error(geminiError.message); } // Check for timeout if (error instanceof Error && error.message.includes('timed out')) { console.error( `[ImageGenService] Timeout after ${ImageGenService.GEMINI_TIMEOUT_MS}ms:`, error.message ); throw new Error( `${ERROR_MESSAGES.GEMINI_TIMEOUT} after ${ImageGenService.GEMINI_TIMEOUT_MS / 1000} seconds` ); } // Check for other API errors with status codes if (err.status) { const geminiError = GeminiErrorDetector.classifyApiError(error); console.error( `[ImageGenService] API error:`, GeminiErrorDetector.formatForLogging(geminiError) ); throw new Error(geminiError.message); } // Enhanced error detection with network diagnostics if (error instanceof Error) { // Classify the error and check for network issues (only on failure) const errorAnalysis = await NetworkErrorDetector.classifyError(error, 'Gemini API'); // Log the detailed error for debugging console.error(`[ImageGenService] ${NetworkErrorDetector.formatErrorForLogging(errorAnalysis)}`); // Throw user-friendly error message throw new Error(errorAnalysis.userMessage); } throw new Error('Gemini AI generation failed: Unknown error'); } } /** * Wrap a promise with timeout */ private async withTimeout( promise: Promise, timeoutMs: number, operationName: string ): Promise { let timeoutId: NodeJS.Timeout; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error(`${operationName} timed out after ${timeoutMs}ms`)); }, timeoutMs); }); try { const result = await Promise.race([promise, timeoutPromise]); clearTimeout(timeoutId!); return result; } catch (error) { clearTimeout(timeoutId!); throw error; } } static validateReferenceImages(files: Express.Multer.File[]): { valid: boolean; error?: string; } { if (files.length > 3) { return { valid: false, error: 'Maximum 3 reference images allowed' }; } const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp']; const maxSize = 5 * 1024 * 1024; // 5MB for (const file of files) { if (!allowedTypes.includes(file.mimetype)) { return { valid: false, error: `Unsupported file type: ${file.mimetype}. Allowed: PNG, JPEG, WebP`, }; } if (file.size > maxSize) { return { valid: false, error: `File ${file.originalname} is too large. Maximum size: 5MB`, }; } } return { valid: true }; } static convertFilesToReferenceImages(files: Express.Multer.File[]): ReferenceImage[] { return files.map((file) => ({ buffer: file.buffer, mimetype: file.mimetype, originalname: file.originalname, })); } }