banatie-service/apps/api-service/src/services/ImageGenService.ts

426 lines
14 KiB
TypeScript

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<ImageGenerationResult> {
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<T>(
promise: Promise<T>,
timeoutMs: number,
operationName: string
): Promise<T> {
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, 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,
}));
}
}