426 lines
14 KiB
TypeScript
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,
|
|
}));
|
|
}
|
|
}
|