import { readdir, readFile, mkdir, writeFile } from 'fs/promises'; import { join, dirname, basename } from 'path'; import { fileURLToPath } from 'url'; import { existsSync } from 'fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Website CI/Style Guide const CI_STYLE = { colors: { primary: 'sage green (#697565)', background: 'dark moss (#181C14)', accent: 'dark sage (#465B50)', text: 'cream/off-white (#ECDFCC)' }, style: 'minimalist, glass-morphism, organic, professional, modern', aesthetic: 'dark background with sage green and cream accents, subtle gradients, clean lines' }; // Directories const BLOG_POSTS_DIR = join(__dirname, '../blog-posts'); const OUTPUT_DIR = join(__dirname, '../source-images/posts'); /** * Call Google Gemini / Nano Banana Pro Image Generation API */ async function callGeminiImageAPI(prompt, apiKey) { const model = 'gemini-2.0-flash-exp'; // Nano Banana model const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { responseModalities: ['TEXT', 'IMAGE'] } }) }); if (!response.ok) { const error = await response.json().catch(() => ({})); const errorMessage = error.error?.message || response.statusText; throw new Error(`Gemini API Error: ${response.status} - ${errorMessage}`); } return response.json(); } /** * Call OpenAI Image Generation API directly using fetch */ async function callOpenAIImageAPI(prompt, apiKey) { const response = await fetch('https://api.openai.com/v1/images/generations', { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'gpt-image-1.5', prompt: prompt, n: 1, size: '1536x1024', // 3:2 landscape (closest to 16:9 for gpt-image-1.5) quality: 'high' }) }); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(`OpenAI API Error: ${response.status} - ${error.error?.message || response.statusText}`); } return response.json(); } /** * Extract image prompts from a blog post markdown file */ function extractImagePrompts(content, filename) { const prompts = []; // Find the "Bildprompts" section const bildpromptsMatch = content.match(/## Bildprompts[\s\S]*?(?=##|---|\n\n\n|$)/i); if (bildpromptsMatch) { const section = bildpromptsMatch[0]; // Extract prompts in quotes const promptMatches = section.matchAll(/"([^"]+)"/g); for (const match of promptMatches) { prompts.push(match[1]); } } // Fallback: look for image prompt patterns if (prompts.length === 0) { const fallbackMatches = content.matchAll(/\*\*Bild \d+[^*]*\*\*[:\s]*"?([^"\n]+)"?/gi); for (const match of fallbackMatches) { prompts.push(match[1]); } } return prompts; } /** * Adapt a prompt to match the website's CI */ function adaptPromptToCI(originalPrompt, isHeroImage = false) { // Extract the core concept from the original prompt let concept = originalPrompt .replace(/dark blue and cyan/gi, '') .replace(/blue accents/gi, '') .replace(/white background/gi, '') .replace(/bright colors/gi, '') .replace(/neon/gi, '') .replace(/vibrant/gi, '') .replace(/cyan/gi, '') .replace(/8k ultra-realistic/gi, '') .replace(/cinematic lighting/gi, '') .replace(/color scheme/gi, '') .trim(); // Build CI-compliant prompt - REALISTIC style with CI colors const ciPrompt = ` Photorealistic, cinematic image for a professional tech blog. 16:9 wide format, 4K quality. COLOR GRADING (apply as color filter/mood): - Overall mood: Dark, moody atmosphere with deep shadows - Dominant tones: Dark greenish-black (#181C14), muted sage green (#697565) - Accent lighting: Warm cream/off-white (#ECDFCC) highlights - Color temperature: Slightly warm, earthy undertones - NO bright blue, NO cyan, NO neon colors PHOTOGRAPHY STYLE: - Ultra-realistic, photographic quality - Cinematic wide-angle composition - Professional lighting with dramatic shadows - Shallow depth of field where appropriate - High-end commercial photography look - Shot on Sony A7R IV, 24-70mm f/2.8 lens SCENE: ${concept} Create a realistic, professional photograph. The lighting should create a moody, sophisticated atmosphere using the sage green and cream color palette as accent lighting or environmental color. NO text overlays, NO watermarks, NO logos. `.trim(); return ciPrompt; } /** * Generate an image using OpenAI's gpt-image-1.5 */ async function generateImage(prompt, outputPath, apiKey, provider = 'gemini', retries = 3) { for (let attempt = 1; attempt <= retries; attempt++) { try { console.log(` Generating image (attempt ${attempt}/${retries}) via ${provider}...`); console.log(` Prompt: ${prompt.substring(0, 100)}...`); let imageBuffer; if (provider === 'gemini') { // Use Google Gemini / Nano Banana Pro const response = await callGeminiImageAPI(prompt, apiKey); // Extract image from Gemini response const candidate = response.candidates?.[0]; if (!candidate) { throw new Error('No candidates in response'); } const parts = candidate.content?.parts || []; const imagePart = parts.find(p => p.inlineData); if (!imagePart?.inlineData?.data) { // Check if there's text response (might be safety block) const textPart = parts.find(p => p.text); if (textPart) { console.log(` Response text: ${textPart.text.substring(0, 100)}...`); } throw new Error('No image data in Gemini response'); } imageBuffer = Buffer.from(imagePart.inlineData.data, 'base64'); } else { // Use OpenAI const response = await callOpenAIImageAPI(prompt, apiKey); const imageResult = response.data[0]; if (imageResult.b64_json) { imageBuffer = Buffer.from(imageResult.b64_json, 'base64'); } else if (imageResult.url) { console.log(` Downloading from URL...`); const imageResponse = await fetch(imageResult.url); if (!imageResponse.ok) { throw new Error(`Failed to download image: ${imageResponse.status}`); } const arrayBuffer = await imageResponse.arrayBuffer(); imageBuffer = Buffer.from(arrayBuffer); } else { throw new Error('No image data in response'); } } // Save the image await writeFile(outputPath, imageBuffer); console.log(` āœ“ Saved: ${outputPath}`); return true; } catch (error) { console.error(` āœ— Attempt ${attempt} failed:`, error.message); if (attempt === retries) { throw error; } // Wait before retry await new Promise(resolve => setTimeout(resolve, 2000 * attempt)); } } } /** * Process a single blog post */ async function processBlogPost(filename, apiKey, options = {}) { const { dryRun = false, forceRegenerate = false, onlyHero = true, provider = 'gemini' } = options; const filePath = join(BLOG_POSTS_DIR, filename); const content = await readFile(filePath, 'utf-8'); // Extract slug from filename (e.g., "01-agentic-ai-2026.md" -> "01-agentic-ai-2026") const slug = basename(filename, '.md'); // Extract image prompts const prompts = extractImagePrompts(content, filename); if (prompts.length === 0) { console.log(`⚠ No image prompts found in ${filename}`); return { filename, slug, generated: 0, skipped: 0 }; } console.log(`\nšŸ“„ ${filename}`); console.log(` Found ${prompts.length} image prompt(s)`); // Create output directory const postOutputDir = join(OUTPUT_DIR, slug); if (!dryRun) { await mkdir(postOutputDir, { recursive: true }); } let generated = 0; let skipped = 0; // Process prompts (only hero image by default) const promptsToProcess = onlyHero ? [prompts[0]] : prompts; for (let i = 0; i < promptsToProcess.length; i++) { const prompt = promptsToProcess[i]; const imageName = i === 0 ? 'cover.jpg' : `image-${i + 1}.jpg`; const outputPath = join(postOutputDir, imageName); // Check if image already exists if (existsSync(outputPath) && !forceRegenerate) { console.log(` ā­ Skipping ${imageName} (already exists)`); skipped++; continue; } // Adapt prompt to CI const adaptedPrompt = adaptPromptToCI(prompt, i === 0); if (dryRun) { console.log(` [DRY RUN] Would generate: ${imageName}`); console.log(` Original: ${prompt.substring(0, 80)}...`); console.log(` Adapted: ${adaptedPrompt.substring(0, 80)}...`); } else { try { await generateImage(adaptedPrompt, outputPath, apiKey, provider); generated++; // Rate limiting: wait between requests if (i < promptsToProcess.length - 1) { console.log(' Waiting 2s before next image...'); await new Promise(resolve => setTimeout(resolve, 2000)); } } catch (error) { console.error(` āœ— Failed to generate ${imageName}:`, error.message); } } } return { filename, slug, generated, skipped }; } /** * Main function */ async function main() { console.log('šŸŽØ Blog Image Generator'); console.log('========================\n'); // Get API key from environment or command line const apiKey = process.env.OPENAI_API_KEY || process.argv.find(a => a.startsWith('--api-key='))?.split('=')[1]; // Check for API key if (!apiKey) { console.error('āŒ Error: OPENAI_API_KEY not provided'); console.log('\nUsage:'); console.log(' export OPENAI_API_KEY=sk-your-key-here'); console.log(' node scripts/generate-blog-images.js'); console.log('\nOr:'); console.log(' node scripts/generate-blog-images.js --api-key=sk-your-key-here'); process.exit(1); } // Detect provider based on API key format const provider = apiKey.startsWith('AIzaSy') ? 'gemini' : 'openai'; console.log(`Provider: ${provider === 'gemini' ? 'Google Gemini (Nano Banana)' : 'OpenAI'}\n`); // Parse command line arguments const args = process.argv.slice(2); const options = { dryRun: args.includes('--dry-run'), forceRegenerate: args.includes('--force'), onlyHero: !args.includes('--all-images'), single: args.find(a => a.startsWith('--file='))?.split('=')[1], range: args.find(a => a.startsWith('--range='))?.split('=')[1], provider }; if (options.dryRun) { console.log('šŸ” DRY RUN MODE - No images will be generated\n'); } console.log(`CI Style: ${CI_STYLE.style}`); console.log(`Colors: ${Object.values(CI_STYLE.colors).join(', ')}\n`); // Get list of blog posts let files; if (options.single) { files = [options.single]; } else { files = (await readdir(BLOG_POSTS_DIR)) .filter(f => f.endsWith('.md')) .sort(); // Filter by range if specified (e.g., --range=1-10) if (options.range) { const [start, end] = options.range.split('-').map(Number); files = files.filter(f => { const num = parseInt(f.split('-')[0], 10); return num >= start && num <= end; }); } } console.log(`Found ${files.length} blog post(s) to process\n`); // Process each blog post const results = []; for (const file of files) { try { const result = await processBlogPost(file, apiKey, options); results.push(result); } catch (error) { console.error(`āŒ Error processing ${file}:`, error.message); results.push({ filename: file, error: error.message }); } } // Summary console.log('\n========================'); console.log('šŸ“Š Summary\n'); const totalGenerated = results.reduce((sum, r) => sum + (r.generated || 0), 0); const totalSkipped = results.reduce((sum, r) => sum + (r.skipped || 0), 0); const totalErrors = results.filter(r => r.error).length; console.log(`āœ“ Generated: ${totalGenerated} images`); console.log(`ā­ Skipped: ${totalSkipped} images (already exist)`); console.log(`āœ— Errors: ${totalErrors}`); if (!options.dryRun && totalGenerated > 0) { console.log('\nšŸ“ Next steps:'); console.log('1. Review generated images in source-images/posts/'); console.log('2. Run `npm run build:images` to optimize images'); } } // Run main().catch(console.error);