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

317 lines
9.5 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';
export class ImageGenService {
private ai: GoogleGenAI;
private primaryModel = 'gemini-2.5-flash-image';
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, filename, referenceImages, aspectRatio, orgId, projectId, meta } = options;
// Use default values if not provided
const finalOrgId = orgId || process.env['DEFAULT_ORG_ID'] || 'default';
const finalProjectId = projectId || process.env['DEFAULT_PROJECT_ID'] || 'main';
const finalAspectRatio = aspectRatio || '1:1'; // Default to square
// Step 1: Generate image from Gemini AI
let generatedData: GeneratedImageData;
let geminiParams: GeminiParams;
try {
const aiResult = await this.generateImageWithAI(
prompt,
referenceImages,
finalAspectRatio,
finalOrgId,
finalProjectId,
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
try {
const finalFilename = `${filename}.${generatedData.fileExtension}`;
const storageService = await StorageFactory.getInstance();
const uploadResult = await storageService.uploadFile(
finalOrgId,
finalProjectId,
'generated',
finalFilename,
generatedData.buffer,
generatedData.mimeType,
);
if (uploadResult.success) {
return {
success: true,
filename: 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,
orgId: string,
projectId: 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,
projectId,
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
const response = await this.ai.models.generateContent({
model: this.primaryModel,
config,
contents,
});
// Parse response
if (!response.candidates || !response.candidates[0] || !response.candidates[0].content) {
throw new Error('No response received from Gemini AI');
}
const content = response.candidates[0].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) {
throw new Error('No image data received from Gemini AI');
}
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) {
// 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');
}
}
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,
}));
}
}