feat: Initial setup of Banatie Image Generation Service
- Complete Express.js server with TypeScript, ESLint, and Prettier - REST API for AI-powered image generation using Gemini Flash Image model - File upload support for reference images (up to 3 files, 5MB each) - Comprehensive error handling and validation middleware - Health check and API info endpoints - Professional development tools configuration - Environment-based configuration with .env support - Graceful server shutdown and logging - Ready for production deployment Endpoints: - GET /health - Server health check - GET /api/info - API information and limits - POST /api/generate - Generate images from text prompts with optional reference images Features: - TypeScript for type safety - ESLint for code quality - Prettier for code formatting - Multer for file uploads - CORS support - Request validation - Error handling - Logging
This commit is contained in:
commit
251ac6e27c
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Server Configuration
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
|
||||||
|
# Gemini AI Configuration
|
||||||
|
GEMINI_API_KEY=your_gemini_api_key_here
|
||||||
|
|
||||||
|
# File Upload Configuration
|
||||||
|
MAX_FILE_SIZE=5242880
|
||||||
|
MAX_FILES=3
|
||||||
|
|
||||||
|
# Directory Configuration
|
||||||
|
RESULTS_DIR=./results
|
||||||
|
UPLOADS_DIR=./uploads/temp
|
||||||
|
|
||||||
|
# Logging Configuration
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids/
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Generated images and uploads
|
||||||
|
results/
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
temp/
|
||||||
|
tmp/
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
export default {
|
||||||
|
semi: true,
|
||||||
|
trailingComma: 'es5',
|
||||||
|
singleQuote: true,
|
||||||
|
printWidth: 100,
|
||||||
|
tabWidth: 2,
|
||||||
|
useTabs: false,
|
||||||
|
bracketSpacing: true,
|
||||||
|
bracketSameLine: false,
|
||||||
|
arrowParens: 'avoid',
|
||||||
|
endOfLine: 'lf',
|
||||||
|
quoteProps: 'as-needed',
|
||||||
|
jsxSingleQuote: true,
|
||||||
|
proseWrap: 'preserve',
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
# Banatie - Nano Banana Image Generation Service
|
||||||
|
|
||||||
|
A REST API service for AI-powered image generation using the Gemini Flash Image model. Banatie provides a simple HTTP interface for generating high-quality images from text prompts with optional reference images.
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js >= 18.0.0
|
||||||
|
- pnpm >= 8.0.0
|
||||||
|
- Gemini API key from [Google AI Studio](https://aistudio.google.com/)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone <repository-url>
|
||||||
|
cd banatie
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Setup environment variables
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your Gemini API key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start development server with auto-reload
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# Start production server
|
||||||
|
pnpm start
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Environment Configuration
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and configure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required
|
||||||
|
GEMINI_API_KEY=your_gemini_api_key_here
|
||||||
|
|
||||||
|
# Optional (with defaults)
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
MAX_FILE_SIZE=5242880 # 5MB
|
||||||
|
MAX_FILES=3
|
||||||
|
RESULTS_DIR=./results
|
||||||
|
UPLOADS_DIR=./uploads/temp
|
||||||
|
LOG_LEVEL=info
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔌 API Endpoints
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
```
|
||||||
|
GET /health
|
||||||
|
```
|
||||||
|
Returns server status and uptime.
|
||||||
|
|
||||||
|
### API Information
|
||||||
|
```
|
||||||
|
GET /api/info
|
||||||
|
```
|
||||||
|
Returns API details and configuration.
|
||||||
|
|
||||||
|
### Generate Image
|
||||||
|
```
|
||||||
|
POST /api/generate
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `prompt` (string, required): Text description for image generation
|
||||||
|
- `filename` (string, optional): Custom filename for the generated image
|
||||||
|
- `referenceImages` (files, optional): Up to 3 reference images (5MB max each)
|
||||||
|
|
||||||
|
**Supported formats:** PNG, JPEG, JPG, WebP
|
||||||
|
|
||||||
|
## 📖 Usage Examples
|
||||||
|
|
||||||
|
### cURL
|
||||||
|
```bash
|
||||||
|
# Basic text-to-image
|
||||||
|
curl -X POST http://localhost:3000/api/generate \
|
||||||
|
-F "prompt=A magical forest with glowing mushrooms" \
|
||||||
|
-F "filename=magical_forest"
|
||||||
|
|
||||||
|
# With reference images
|
||||||
|
curl -X POST http://localhost:3000/api/generate \
|
||||||
|
-F "prompt=Character in medieval armor like the reference" \
|
||||||
|
-F "referenceImages=@./reference.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript/Fetch
|
||||||
|
```javascript
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('prompt', 'A futuristic cityscape at sunset');
|
||||||
|
formData.append('filename', 'futuristic_city');
|
||||||
|
|
||||||
|
const response = await fetch('http://localhost:3000/api/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Format
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Image generated successfully",
|
||||||
|
"data": {
|
||||||
|
"filename": "generated_image_123.png",
|
||||||
|
"filepath": "./results/generated_image_123.png",
|
||||||
|
"description": "Generated image description",
|
||||||
|
"model": "gemini-2.0-flash-exp",
|
||||||
|
"generatedAt": "2024-01-01T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Development Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
pnpm dev # Start with auto-reload
|
||||||
|
pnpm build # Build for production
|
||||||
|
pnpm start # Start production server
|
||||||
|
|
||||||
|
# Code Quality
|
||||||
|
pnpm typecheck # TypeScript type checking
|
||||||
|
pnpm lint # ESLint code linting
|
||||||
|
pnpm lint:fix # Fix ESLint issues
|
||||||
|
pnpm format # Format code with Prettier
|
||||||
|
pnpm format:check # Check code formatting
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pnpm test # Run tests
|
||||||
|
pnpm test:watch # Run tests in watch mode
|
||||||
|
pnpm test:coverage # Run tests with coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
banatie/
|
||||||
|
├── src/
|
||||||
|
│ ├── middleware/ # Express middleware
|
||||||
|
│ ├── routes/ # API route handlers
|
||||||
|
│ ├── services/ # Business logic services
|
||||||
|
│ ├── types/ # TypeScript type definitions
|
||||||
|
│ ├── utils/ # Utility functions
|
||||||
|
│ ├── app.ts # Express app configuration
|
||||||
|
│ └── server.ts # Server entry point
|
||||||
|
├── tests/ # Test files
|
||||||
|
├── results/ # Generated images output
|
||||||
|
├── uploads/ # Temporary file uploads
|
||||||
|
└── logs/ # Application logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
### File Limits
|
||||||
|
- **Max file size**: 5MB per image (configurable)
|
||||||
|
- **Max files**: 3 reference images per request (configurable)
|
||||||
|
- **Request size**: 10MB total request limit
|
||||||
|
|
||||||
|
### Supported Image Formats
|
||||||
|
- PNG
|
||||||
|
- JPEG/JPG
|
||||||
|
- WebP
|
||||||
|
|
||||||
|
## 🚦 Error Handling
|
||||||
|
|
||||||
|
The API provides detailed error responses:
|
||||||
|
|
||||||
|
**Validation Errors (400):**
|
||||||
|
- Missing required parameters
|
||||||
|
- Invalid file formats
|
||||||
|
- File size exceeded
|
||||||
|
- Too many files
|
||||||
|
|
||||||
|
**Server Errors (500):**
|
||||||
|
- Missing API key configuration
|
||||||
|
- Image generation failures
|
||||||
|
- Internal processing errors
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT License - see LICENSE file for details.
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Run tests and linting
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
For issues and questions, please open an issue on the GitHub repository.
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import typescript from '@typescript-eslint/eslint-plugin';
|
||||||
|
import typescriptParser from '@typescript-eslint/parser';
|
||||||
|
import prettier from 'eslint-plugin-prettier';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['**/*.ts', '**/*.tsx'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: typescriptParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'module',
|
||||||
|
project: './tsconfig.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'@typescript-eslint': typescript,
|
||||||
|
prettier: prettier,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...typescript.configs.recommended.rules,
|
||||||
|
...typescript.configs['recommended-requiring-type-checking'].rules,
|
||||||
|
'prettier/prettier': 'error',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'warn',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/prefer-const': 'error',
|
||||||
|
'@typescript-eslint/no-var-requires': 'error',
|
||||||
|
'no-console': 'off', // Allow console for server logging
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'no-var': 'error',
|
||||||
|
'object-shorthand': 'error',
|
||||||
|
'prefer-template': 'error',
|
||||||
|
},
|
||||||
|
ignores: [
|
||||||
|
'node_modules/**',
|
||||||
|
'dist/**',
|
||||||
|
'*.js',
|
||||||
|
'*.mjs',
|
||||||
|
'eslint.config.js'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
{
|
||||||
|
"name": "banatie",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Nano Banana Image Generation Service - REST API for AI-powered image generation using Gemini Flash Image model",
|
||||||
|
"main": "dist/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx --watch src/server.ts",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint src/**/*.ts",
|
||||||
|
"lint:fix": "eslint src/**/*.ts --fix",
|
||||||
|
"format": "prettier --write src/**/*.ts",
|
||||||
|
"format:check": "prettier --check src/**/*.ts",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:coverage": "jest --coverage",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"prebuild": "npm run clean",
|
||||||
|
"prestart": "npm run build"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"image-generation",
|
||||||
|
"ai",
|
||||||
|
"gemini",
|
||||||
|
"nano-banana",
|
||||||
|
"rest-api",
|
||||||
|
"express",
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"packageManager": "pnpm@10.11.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0",
|
||||||
|
"pnpm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google/genai": "^1.17.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.2",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"mime": "^4.1.0",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"express-rate-limit": "^7.4.1",
|
||||||
|
"express-validator": "^7.2.0",
|
||||||
|
"winston": "^3.17.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
|
"@types/node": "^24.3.1",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
||||||
|
"@typescript-eslint/parser": "^8.18.2",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"nodemon": "^3.1.9",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,94 @@
|
||||||
|
import express, { Application } from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { Config } from './types/api';
|
||||||
|
import { generateRouter } from './routes/generate';
|
||||||
|
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
config();
|
||||||
|
|
||||||
|
// Application configuration
|
||||||
|
export const appConfig: Config = {
|
||||||
|
port: parseInt(process.env['PORT'] || '3000'),
|
||||||
|
geminiApiKey: process.env['GEMINI_API_KEY'] || '',
|
||||||
|
resultsDir: './results',
|
||||||
|
uploadsDir: './uploads/temp',
|
||||||
|
maxFileSize: 5 * 1024 * 1024, // 5MB
|
||||||
|
maxFiles: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create Express application
|
||||||
|
export const createApp = (): Application => {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env['CORS_ORIGIN'] || '*',
|
||||||
|
credentials: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
|
||||||
|
// Request ID middleware for logging
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.requestId = Math.random().toString(36).substr(2, 9);
|
||||||
|
res.setHeader('X-Request-ID', req.requestId);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', (_req, res) => {
|
||||||
|
const health = {
|
||||||
|
status: 'healthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
environment: process.env['NODE_ENV'] || 'development',
|
||||||
|
version: process.env['npm_package_version'] || '1.0.0'
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[${health.timestamp}] Health check - ${health.status}`);
|
||||||
|
res.json(health);
|
||||||
|
});
|
||||||
|
|
||||||
|
// API info endpoint
|
||||||
|
app.get('/api/info', (_req, res) => {
|
||||||
|
const info = {
|
||||||
|
name: 'Banatie - Nano Banana Image Generation API',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'REST API service for AI-powered image generation using Gemini Flash Image model',
|
||||||
|
endpoints: {
|
||||||
|
'GET /health': 'Health check',
|
||||||
|
'GET /api/info': 'API information',
|
||||||
|
'POST /api/generate': 'Generate images from text prompt with optional reference images'
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
maxFileSize: `${appConfig.maxFileSize / (1024 * 1024)}MB`,
|
||||||
|
maxFiles: appConfig.maxFiles,
|
||||||
|
supportedFormats: ['PNG', 'JPEG', 'JPG', 'WebP']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[${new Date().toISOString()}] API info requested`);
|
||||||
|
res.json(info);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mount API routes
|
||||||
|
app.use('/api', generateRouter);
|
||||||
|
|
||||||
|
// Error handling middleware (must be last)
|
||||||
|
app.use(notFoundHandler);
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extend Express Request type to include requestId
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { GenerateImageResponse } from '../types/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global error handler for the Express application
|
||||||
|
*/
|
||||||
|
export const errorHandler = (
|
||||||
|
error: Error,
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const requestId = req.requestId || 'unknown';
|
||||||
|
|
||||||
|
// Log the error
|
||||||
|
console.error(`[${timestamp}] [${requestId}] ERROR:`, {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
path: req.path,
|
||||||
|
method: req.method,
|
||||||
|
body: req.body,
|
||||||
|
query: req.query
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't send error response if headers already sent
|
||||||
|
if (res.headersSent) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine error type and status code
|
||||||
|
let statusCode = 500;
|
||||||
|
let errorMessage = 'Internal server error';
|
||||||
|
let errorType = 'INTERNAL_ERROR';
|
||||||
|
|
||||||
|
if (error.name === 'ValidationError') {
|
||||||
|
statusCode = 400;
|
||||||
|
errorMessage = error.message;
|
||||||
|
errorType = 'VALIDATION_ERROR';
|
||||||
|
} else if (error.message.includes('API key') || error.message.includes('authentication')) {
|
||||||
|
statusCode = 401;
|
||||||
|
errorMessage = 'Authentication failed';
|
||||||
|
errorType = 'AUTH_ERROR';
|
||||||
|
} else if (error.message.includes('not found') || error.message.includes('404')) {
|
||||||
|
statusCode = 404;
|
||||||
|
errorMessage = 'Resource not found';
|
||||||
|
errorType = 'NOT_FOUND';
|
||||||
|
} else if (error.message.includes('timeout') || error.message.includes('503')) {
|
||||||
|
statusCode = 503;
|
||||||
|
errorMessage = 'Service temporarily unavailable';
|
||||||
|
errorType = 'SERVICE_UNAVAILABLE';
|
||||||
|
} else if (error.message.includes('overloaded') || error.message.includes('rate limit')) {
|
||||||
|
statusCode = 429;
|
||||||
|
errorMessage = 'Service overloaded, please try again later';
|
||||||
|
errorType = 'RATE_LIMITED';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create error response
|
||||||
|
const errorResponse: GenerateImageResponse = {
|
||||||
|
success: false,
|
||||||
|
message: 'Request failed',
|
||||||
|
error: errorMessage
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add additional debug info in development
|
||||||
|
if (process.env['NODE_ENV'] === 'development') {
|
||||||
|
(errorResponse as any).debug = {
|
||||||
|
originalError: error.message,
|
||||||
|
errorType,
|
||||||
|
requestId,
|
||||||
|
timestamp
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${timestamp}] [${requestId}] Sending error response: ${statusCode} - ${errorMessage}`);
|
||||||
|
|
||||||
|
res.status(statusCode).json(errorResponse);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 404 handler for unmatched routes
|
||||||
|
*/
|
||||||
|
export const notFoundHandler = (req: Request, res: Response) => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const requestId = req.requestId || 'unknown';
|
||||||
|
|
||||||
|
console.log(`[${timestamp}] [${requestId}] 404 - Route not found: ${req.method} ${req.path}`);
|
||||||
|
|
||||||
|
const notFoundResponse: GenerateImageResponse = {
|
||||||
|
success: false,
|
||||||
|
message: 'Route not found',
|
||||||
|
error: `The requested endpoint ${req.method} ${req.path} does not exist`
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(404).json(notFoundResponse);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async error wrapper to catch errors in async route handlers
|
||||||
|
*/
|
||||||
|
export const asyncHandler = (fn: Function) => (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import multer from 'multer';
|
||||||
|
import { Request, RequestHandler } from 'express';
|
||||||
|
|
||||||
|
// Configure multer for memory storage (we'll process files in memory)
|
||||||
|
const storage = multer.memoryStorage();
|
||||||
|
|
||||||
|
// File filter for image types only
|
||||||
|
const fileFilter = (_req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
|
||||||
|
const allowedTypes = [
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/webp'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (allowedTypes.includes(file.mimetype)) {
|
||||||
|
console.log(`[${new Date().toISOString()}] Accepted file: ${file.originalname} (${file.mimetype})`);
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
console.log(`[${new Date().toISOString()}] Rejected file: ${file.originalname} (${file.mimetype})`);
|
||||||
|
cb(new Error(`Unsupported file type: ${file.mimetype}. Allowed: PNG, JPEG, WebP`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configuration constants
|
||||||
|
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
const MAX_FILES = 3;
|
||||||
|
|
||||||
|
// Configure multer with limits and file filtering
|
||||||
|
export const upload = multer({
|
||||||
|
storage: storage,
|
||||||
|
limits: {
|
||||||
|
fileSize: MAX_FILE_SIZE, // 5MB per file
|
||||||
|
files: MAX_FILES, // Maximum 3 files
|
||||||
|
},
|
||||||
|
fileFilter: fileFilter
|
||||||
|
});
|
||||||
|
|
||||||
|
// Middleware for handling reference images
|
||||||
|
export const uploadReferenceImages: RequestHandler = upload.array('referenceImages', MAX_FILES);
|
||||||
|
|
||||||
|
// Error handler for multer errors
|
||||||
|
export const handleUploadErrors = (error: any, _req: Request, res: any, next: any) => {
|
||||||
|
if (error instanceof multer.MulterError) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
console.error(`[${timestamp}] Multer error:`, error.message);
|
||||||
|
|
||||||
|
switch (error.code) {
|
||||||
|
case 'LIMIT_FILE_SIZE':
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `File too large. Maximum size: ${MAX_FILE_SIZE / (1024 * 1024)}MB`,
|
||||||
|
message: 'File upload failed'
|
||||||
|
});
|
||||||
|
|
||||||
|
case 'LIMIT_FILE_COUNT':
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Too many files. Maximum: ${MAX_FILES} files`,
|
||||||
|
message: 'File upload failed'
|
||||||
|
});
|
||||||
|
|
||||||
|
case 'LIMIT_UNEXPECTED_FILE':
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Unexpected file field. Use "referenceImages" for image uploads',
|
||||||
|
message: 'File upload failed'
|
||||||
|
});
|
||||||
|
|
||||||
|
default:
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
message: 'File upload failed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message.includes('Unsupported file type')) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
message: 'File validation failed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass other errors to the next error handler
|
||||||
|
next(error);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
// Validation rules
|
||||||
|
const VALIDATION_RULES = {
|
||||||
|
prompt: {
|
||||||
|
minLength: 3,
|
||||||
|
maxLength: 2000,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
filename: {
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 100,
|
||||||
|
required: true,
|
||||||
|
pattern: /^[a-zA-Z0-9_-]+$/ // Only alphanumeric, underscore, hyphen
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize filename to prevent directory traversal and invalid characters
|
||||||
|
*/
|
||||||
|
export const sanitizeFilename = (filename: string): string => {
|
||||||
|
return filename
|
||||||
|
.replace(/[^a-zA-Z0-9_-]/g, '_') // Replace invalid chars with underscore
|
||||||
|
.replace(/_{2,}/g, '_') // Replace multiple underscores with single
|
||||||
|
.replace(/^_+|_+$/g, '') // Remove leading/trailing underscores
|
||||||
|
.substring(0, 100); // Limit length
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the generate image request
|
||||||
|
*/
|
||||||
|
export const validateGenerateRequest = (
|
||||||
|
req: any,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): void | Response => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const { prompt, filename } = req.body;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
console.log(`[${timestamp}] [${req.requestId}] Validating generate request`);
|
||||||
|
|
||||||
|
// Validate prompt
|
||||||
|
if (!prompt) {
|
||||||
|
errors.push('Prompt is required');
|
||||||
|
} else if (typeof prompt !== 'string') {
|
||||||
|
errors.push('Prompt must be a string');
|
||||||
|
} else if (prompt.trim().length < VALIDATION_RULES.prompt.minLength) {
|
||||||
|
errors.push(`Prompt must be at least ${VALIDATION_RULES.prompt.minLength} characters`);
|
||||||
|
} else if (prompt.length > VALIDATION_RULES.prompt.maxLength) {
|
||||||
|
errors.push(`Prompt must be less than ${VALIDATION_RULES.prompt.maxLength} characters`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate filename
|
||||||
|
if (!filename) {
|
||||||
|
errors.push('Filename is required');
|
||||||
|
} else if (typeof filename !== 'string') {
|
||||||
|
errors.push('Filename must be a string');
|
||||||
|
} else if (filename.trim().length < VALIDATION_RULES.filename.minLength) {
|
||||||
|
errors.push('Filename cannot be empty');
|
||||||
|
} else if (filename.length > VALIDATION_RULES.filename.maxLength) {
|
||||||
|
errors.push(`Filename must be less than ${VALIDATION_RULES.filename.maxLength} characters`);
|
||||||
|
} else if (!VALIDATION_RULES.filename.pattern.test(filename)) {
|
||||||
|
errors.push('Filename can only contain letters, numbers, underscores, and hyphens');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for XSS attempts in prompt
|
||||||
|
const xssPatterns = [
|
||||||
|
/<script/i,
|
||||||
|
/javascript:/i,
|
||||||
|
/on\w+\s*=/i,
|
||||||
|
/<iframe/i,
|
||||||
|
/<object/i,
|
||||||
|
/<embed/i
|
||||||
|
];
|
||||||
|
|
||||||
|
if (prompt && xssPatterns.some(pattern => pattern.test(prompt))) {
|
||||||
|
errors.push('Invalid characters detected in prompt');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log validation results
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.log(`[${timestamp}] [${req.requestId}] Validation failed: ${errors.join(', ')}`);
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Validation failed',
|
||||||
|
message: errors.join(', ')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize filename
|
||||||
|
if (filename) {
|
||||||
|
req.body.filename = sanitizeFilename(filename.trim());
|
||||||
|
if (req.body.filename !== filename.trim()) {
|
||||||
|
console.log(`[${timestamp}] [${req.requestId}] Filename sanitized: "${filename}" -> "${req.body.filename}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim and clean prompt
|
||||||
|
if (prompt) {
|
||||||
|
req.body.prompt = prompt.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${timestamp}] [${req.requestId}] Validation passed`);
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log request details for debugging
|
||||||
|
*/
|
||||||
|
export const logRequestDetails = (
|
||||||
|
req: any,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): void => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const { prompt, filename } = req.body;
|
||||||
|
const files = req.files as Express.Multer.File[] || [];
|
||||||
|
|
||||||
|
console.log(`[${timestamp}] [${req.requestId}] === REQUEST DETAILS ===`);
|
||||||
|
console.log(`[${timestamp}] [${req.requestId}] Method: ${req.method}`);
|
||||||
|
console.log(`[${timestamp}] [${req.requestId}] Path: ${req.path}`);
|
||||||
|
console.log(`[${timestamp}] [${req.requestId}] Prompt: "${prompt?.substring(0, 100)}${prompt?.length > 100 ? '...' : ''}"`);
|
||||||
|
console.log(`[${timestamp}] [${req.requestId}] Filename: "${filename}"`);
|
||||||
|
console.log(`[${timestamp}] [${req.requestId}] Reference files: ${files.length}`);
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
console.log(`[${timestamp}] [${req.requestId}] File ${index + 1}: ${file.originalname} (${file.mimetype}, ${Math.round(file.size / 1024)}KB)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${timestamp}] [${req.requestId}] ===========================`);
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { Response, Router } from 'express';
|
||||||
|
import type { Router as RouterType } from 'express';
|
||||||
|
import { ImageGenService } from '../services/ImageGenService';
|
||||||
|
import { uploadReferenceImages, handleUploadErrors } from '../middleware/upload';
|
||||||
|
import { validateGenerateRequest, logRequestDetails } from '../middleware/validation';
|
||||||
|
import { asyncHandler } from '../middleware/errorHandler';
|
||||||
|
import { GenerateImageResponse } from '../types/api';
|
||||||
|
// Create router
|
||||||
|
export const generateRouter: RouterType = Router();
|
||||||
|
|
||||||
|
// Initialize ImageGenService (will be created in the route handler to avoid circular dependency)
|
||||||
|
let imageGenService: ImageGenService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/generate - Generate image from text prompt with optional reference images
|
||||||
|
*/
|
||||||
|
generateRouter.post('/generate',
|
||||||
|
// File upload middleware
|
||||||
|
uploadReferenceImages,
|
||||||
|
handleUploadErrors,
|
||||||
|
|
||||||
|
// Validation middleware
|
||||||
|
logRequestDetails,
|
||||||
|
validateGenerateRequest,
|
||||||
|
|
||||||
|
// Main handler
|
||||||
|
asyncHandler(async (req: any, res: Response) => {
|
||||||
|
// Initialize service if not already done
|
||||||
|
if (!imageGenService) {
|
||||||
|
const apiKey = process.env['GEMINI_API_KEY'];
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Server configuration error',
|
||||||
|
error: 'GEMINI_API_KEY not configured'
|
||||||
|
} as GenerateImageResponse);
|
||||||
|
}
|
||||||
|
imageGenService = new ImageGenService(apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const requestId = req.requestId;
|
||||||
|
const { prompt, filename } = req.body;
|
||||||
|
const files = req.files as Express.Multer.File[] || [];
|
||||||
|
|
||||||
|
console.log(`[${timestamp}] [${requestId}] Starting image generation process`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate reference images if provided
|
||||||
|
if (files.length > 0) {
|
||||||
|
const validation = ImageGenService.validateReferenceImages(files);
|
||||||
|
if (!validation.valid) {
|
||||||
|
console.log(`[${timestamp}] [${requestId}] Reference image validation failed: ${validation.error}`);
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Reference image validation failed',
|
||||||
|
error: validation.error
|
||||||
|
} as GenerateImageResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${timestamp}] [${requestId}] Reference images validation passed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert files to reference images
|
||||||
|
const referenceImages = files.length > 0
|
||||||
|
? ImageGenService.convertFilesToReferenceImages(files)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Generate the image
|
||||||
|
console.log(`[${timestamp}] [${requestId}] Calling ImageGenService.generateImage()`);
|
||||||
|
|
||||||
|
const result = await imageGenService.generateImage({
|
||||||
|
prompt,
|
||||||
|
filename,
|
||||||
|
...(referenceImages && { referenceImages })
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the result
|
||||||
|
console.log(`[${timestamp}] [${requestId}] Image generation completed:`, {
|
||||||
|
success: result.success,
|
||||||
|
model: result.model,
|
||||||
|
filename: result.filename,
|
||||||
|
hasError: !!result.error
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send response
|
||||||
|
if (result.success) {
|
||||||
|
const successResponse: GenerateImageResponse = {
|
||||||
|
success: true,
|
||||||
|
message: 'Image generated successfully',
|
||||||
|
data: {
|
||||||
|
filename: result.filename!,
|
||||||
|
filepath: result.filepath!,
|
||||||
|
...(result.description && { description: result.description }),
|
||||||
|
model: result.model,
|
||||||
|
generatedAt: timestamp
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[${timestamp}] [${requestId}] Sending success response`);
|
||||||
|
return res.status(200).json(successResponse);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const errorResponse: GenerateImageResponse = {
|
||||||
|
success: false,
|
||||||
|
message: 'Image generation failed',
|
||||||
|
error: result.error || 'Unknown error occurred'
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[${timestamp}] [${requestId}] Sending error response: ${result.error}`);
|
||||||
|
return res.status(500).json(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[${timestamp}] [${requestId}] Unhandled error in generate endpoint:`, error);
|
||||||
|
|
||||||
|
const errorResponse: GenerateImageResponse = {
|
||||||
|
success: false,
|
||||||
|
message: 'Image generation failed',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.status(500).json(errorResponse);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { createApp, appConfig } from './app';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
// Ensure required directories exist
|
||||||
|
const ensureDirectoriesExist = () => {
|
||||||
|
const directories = [
|
||||||
|
appConfig.resultsDir,
|
||||||
|
appConfig.uploadsDir,
|
||||||
|
'./logs'
|
||||||
|
];
|
||||||
|
|
||||||
|
directories.forEach(dir => {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
console.log(`[${new Date().toISOString()}] Created directory: ${dir}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enhanced logging function
|
||||||
|
const log = (level: 'INFO' | 'ERROR' | 'WARN' | 'DEBUG', message: string) => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const logMessage = `[${timestamp}] [${level}] ${message}`;
|
||||||
|
console.log(logMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate environment
|
||||||
|
const validateEnvironment = () => {
|
||||||
|
if (!appConfig.geminiApiKey) {
|
||||||
|
log('ERROR', 'GEMINI_API_KEY environment variable is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
log('INFO', 'Environment validation passed');
|
||||||
|
log('INFO', `Server configuration: port=${appConfig.port}, maxFileSize=${appConfig.maxFileSize}bytes, maxFiles=${appConfig.maxFiles}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
const setupGracefulShutdown = (server: any) => {
|
||||||
|
const shutdown = (signal: string) => {
|
||||||
|
log('INFO', `Received ${signal}, shutting down gracefully...`);
|
||||||
|
|
||||||
|
server.close((err: Error) => {
|
||||||
|
if (err) {
|
||||||
|
log('ERROR', `Error during shutdown: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
log('INFO', 'Server closed successfully');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const startServer = async () => {
|
||||||
|
try {
|
||||||
|
log('INFO', '🚀 Starting Banatie Image Generation Server...');
|
||||||
|
|
||||||
|
// Validate environment and create directories
|
||||||
|
validateEnvironment();
|
||||||
|
ensureDirectoriesExist();
|
||||||
|
|
||||||
|
// Create Express app
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const server = app.listen(appConfig.port, () => {
|
||||||
|
log('INFO', `✅ Server running on port ${appConfig.port}`);
|
||||||
|
log('INFO', `📋 Health check: http://localhost:${appConfig.port}/health`);
|
||||||
|
log('INFO', `📖 API info: http://localhost:${appConfig.port}/api/info`);
|
||||||
|
log('INFO', `🎨 Generate endpoint: http://localhost:${appConfig.port}/api/generate`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup graceful shutdown
|
||||||
|
setupGracefulShutdown(server);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log('ERROR', `Failed to start server: ${error instanceof Error ? error.message : error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
startServer();
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
import { GoogleGenAI } from '@google/genai';
|
||||||
|
import mime from 'mime';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { ImageGenerationOptions, ImageGenerationResult, ReferenceImage } from '../types/api';
|
||||||
|
|
||||||
|
export class ImageGenService {
|
||||||
|
private ai: GoogleGenAI;
|
||||||
|
private primaryModel = 'gemini-2.5-flash-image-preview';
|
||||||
|
private fallbackModel = 'imagen-4.0-generate-001';
|
||||||
|
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
async generateImage(options: ImageGenerationOptions): Promise<ImageGenerationResult> {
|
||||||
|
const { prompt, filename, referenceImages } = options;
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
console.log(`[${timestamp}] Starting image generation: "${prompt.substring(0, 50)}..."`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First try the primary model (Nano Banana)
|
||||||
|
const result = await this.tryGeneration({
|
||||||
|
model: this.primaryModel,
|
||||||
|
config: { responseModalities: ['IMAGE', 'TEXT'] },
|
||||||
|
prompt,
|
||||||
|
filename,
|
||||||
|
...(referenceImages && { referenceImages }),
|
||||||
|
modelName: 'Nano Banana'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to Imagen 4
|
||||||
|
console.log(`[${new Date().toISOString()}] Primary model failed, trying fallback (Imagen 4)...`);
|
||||||
|
|
||||||
|
return await this.tryGeneration({
|
||||||
|
model: this.fallbackModel,
|
||||||
|
config: { responseModalities: ['IMAGE'] },
|
||||||
|
prompt,
|
||||||
|
filename: `${filename}_fallback`,
|
||||||
|
...(referenceImages && { referenceImages }),
|
||||||
|
modelName: 'Imagen 4'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[${new Date().toISOString()}] Image generation failed:`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
model: 'none',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try generation with a specific model
|
||||||
|
*/
|
||||||
|
private async tryGeneration(params: {
|
||||||
|
model: string;
|
||||||
|
config: { responseModalities: string[] };
|
||||||
|
prompt: string;
|
||||||
|
filename: string;
|
||||||
|
referenceImages?: ReferenceImage[];
|
||||||
|
modelName: string;
|
||||||
|
}): Promise<ImageGenerationResult> {
|
||||||
|
|
||||||
|
const { model, config, prompt, filename, referenceImages, modelName } = params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build content parts for the API request
|
||||||
|
const contentParts: any[] = [];
|
||||||
|
|
||||||
|
// Add reference images if provided
|
||||||
|
if (referenceImages && referenceImages.length > 0) {
|
||||||
|
console.log(`[${new Date().toISOString()}] Adding ${referenceImages.length} reference image(s)`);
|
||||||
|
|
||||||
|
for (const refImage of referenceImages) {
|
||||||
|
contentParts.push({
|
||||||
|
inlineData: {
|
||||||
|
mimeType: refImage.mimetype,
|
||||||
|
data: refImage.buffer.toString('base64')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the text prompt
|
||||||
|
contentParts.push({
|
||||||
|
text: prompt
|
||||||
|
});
|
||||||
|
|
||||||
|
const contents = [
|
||||||
|
{
|
||||||
|
role: 'user' as const,
|
||||||
|
parts: contentParts
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log(`[${new Date().toISOString()}] Making API request to ${modelName} (${model})...`);
|
||||||
|
|
||||||
|
const response = await this.ai.models.generateContent({
|
||||||
|
model,
|
||||||
|
config,
|
||||||
|
contents
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[${new Date().toISOString()}] Response received from ${modelName}`);
|
||||||
|
|
||||||
|
if (response.candidates && response.candidates[0] && response.candidates[0].content) {
|
||||||
|
const content = response.candidates[0].content;
|
||||||
|
let generatedDescription = '';
|
||||||
|
let savedImagePath = '';
|
||||||
|
|
||||||
|
for (let index = 0; index < (content.parts?.length || 0); index++) {
|
||||||
|
const part = content.parts?.[index];
|
||||||
|
if (!part) continue;
|
||||||
|
|
||||||
|
if (part.inlineData) {
|
||||||
|
const fileExtension = mime.getExtension(part.inlineData.mimeType || '');
|
||||||
|
const finalFilename = `${filename}.${fileExtension}`;
|
||||||
|
const filepath = path.join('./results', finalFilename);
|
||||||
|
|
||||||
|
console.log(`[${new Date().toISOString()}] Saving image: ${finalFilename}`);
|
||||||
|
|
||||||
|
const buffer = Buffer.from(part.inlineData.data || '', 'base64');
|
||||||
|
await this.saveImageFile(filepath, buffer);
|
||||||
|
|
||||||
|
savedImagePath = filepath;
|
||||||
|
|
||||||
|
} else if (part.text) {
|
||||||
|
generatedDescription = part.text;
|
||||||
|
console.log(`[${new Date().toISOString()}] Generated description: ${part.text.substring(0, 100)}...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedImagePath) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
filename: path.basename(savedImagePath),
|
||||||
|
filepath: savedImagePath,
|
||||||
|
description: generatedDescription,
|
||||||
|
model: modelName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
model: modelName,
|
||||||
|
error: 'No image data received from API'
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[${new Date().toISOString()}] ${modelName} generation failed:`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
model: modelName,
|
||||||
|
error: error instanceof Error ? error.message : 'Generation failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save image buffer to file system
|
||||||
|
*/
|
||||||
|
private async saveImageFile(filepath: string, buffer: Buffer): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Ensure the results directory exists
|
||||||
|
const dir = path.dirname(filepath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFile(filepath, buffer, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(`[${new Date().toISOString()}] Error saving file ${filepath}:`, err);
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
console.log(`[${new Date().toISOString()}] File saved successfully: ${filepath}`);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate reference images
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Express.Multer.File[] to ReferenceImage[]
|
||||||
|
*/
|
||||||
|
static convertFilesToReferenceImages(files: Express.Multer.File[]): ReferenceImage[] {
|
||||||
|
return files.map(file => ({
|
||||||
|
buffer: file.buffer,
|
||||||
|
mimetype: file.mimetype,
|
||||||
|
originalname: file.originalname
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
// API Request/Response types
|
||||||
|
export interface GenerateImageRequest {
|
||||||
|
prompt: string;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateImageResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data?: {
|
||||||
|
filename: string;
|
||||||
|
filepath: string;
|
||||||
|
description?: string;
|
||||||
|
model: string;
|
||||||
|
generatedAt: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extended Express Request with file uploads
|
||||||
|
export interface GenerateImageRequestWithFiles extends Request {
|
||||||
|
body: GenerateImageRequest;
|
||||||
|
files?: Express.Multer.File[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image generation service types
|
||||||
|
export interface ImageGenerationOptions {
|
||||||
|
prompt: string;
|
||||||
|
filename: string;
|
||||||
|
referenceImages?: ReferenceImage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReferenceImage {
|
||||||
|
buffer: Buffer;
|
||||||
|
mimetype: string;
|
||||||
|
originalname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageGenerationResult {
|
||||||
|
success: boolean;
|
||||||
|
filename?: string;
|
||||||
|
filepath?: string;
|
||||||
|
description?: string;
|
||||||
|
model: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logging types
|
||||||
|
export interface LogContext {
|
||||||
|
requestId: string;
|
||||||
|
endpoint: string;
|
||||||
|
method: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment configuration
|
||||||
|
export interface Config {
|
||||||
|
port: number;
|
||||||
|
geminiApiKey: string;
|
||||||
|
resultsDir: string;
|
||||||
|
uploadsDir: string;
|
||||||
|
maxFileSize: number;
|
||||||
|
maxFiles: number;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"allowUnusedLabels": false,
|
||||||
|
"allowUnreachableCode": false,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"],
|
||||||
|
"@/types/*": ["src/types/*"],
|
||||||
|
"@/services/*": ["src/services/*"],
|
||||||
|
"@/middleware/*": ["src/middleware/*"],
|
||||||
|
"@/routes/*": ["src/routes/*"],
|
||||||
|
"@/utils/*": ["src/utils/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"tests/**/*"
|
||||||
|
],
|
||||||
|
"ts-node": {
|
||||||
|
"esm": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue