const fs = require('fs'); const path = require('path'); const axios = require('axios'); const config = require('../config'); const aiUsage = require('./aiUsage'); const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads'; let sharp = null; try { sharp = require('sharp'); } catch {} if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true }); /** * Стили картинок к постам — словарь для перевода в промпт. */ const IMAGE_STYLES = { 'realistic-photo': { label: 'Реалистичное фото', prompt: 'photorealistic, high-quality photography, natural lighting, professional camera shot, sharp focus, realistic textures', }, 'flat-illustration': { label: 'Плоская иллюстрация', prompt: 'flat vector illustration, clean geometric shapes, modern editorial style, smooth gradients, minimal', }, '3d-render': { label: '3D рендер', prompt: '3D render, soft studio lighting, isometric perspective, smooth surfaces, modern materials, Pixar-like quality', }, 'cartoon': { label: 'Мультяшный', prompt: 'cartoon illustration, bold outlines, vibrant colors, expressive characters, comic book style', }, 'minimal': { label: 'Минимализм', prompt: 'extremely minimalist composition, single focal element, lots of negative space, monochrome or duotone', }, 'abstract': { label: 'Абстракция', prompt: 'abstract artwork, layered shapes, conceptual composition, no literal objects, mood and texture focused', }, 'sketch': { label: 'Эскиз / скетч', prompt: 'hand-drawn sketch style, pencil and ink, loose lines, notebook page aesthetic', }, 'cyberpunk': { label: 'Киберпанк', prompt: 'cyberpunk aesthetic, neon lights, futuristic city, dark atmospheric mood, blade runner vibe', }, }; const IMAGE_PALETTES = { 'auto': '', 'dark': 'dark color palette, deep blues, blacks, subtle highlights', 'light': 'light color palette, soft whites, pastels, airy mood', 'warm': 'warm color palette, oranges, reds, golden tones', 'cool': 'cool color palette, blues, teals, purples', 'mono': 'monochromatic palette, single hue with shades', 'vibrant': 'vibrant saturated colors, high energy palette', }; /** * Генерирует картинку к посту через GPT-5 /v1/responses + image_generation. */ async function generatePostImage({ post, channel, style = {} }) { // Если задано несколько стилей через запятую — случайно выбираем один const styleList = (style.image_style || 'flat-illustration') .split(',').map(s => s.trim()).filter(s => s && s !== 'auto'); const pickedStyle = styleList[Math.floor(Math.random() * styleList.length)] || 'flat-illustration'; const imageStyle = IMAGE_STYLES[pickedStyle] || IMAGE_STYLES['flat-illustration']; const palette = style.image_custom_colors ? `custom brand palette: ${style.image_custom_colors}` : IMAGE_PALETTES[style.image_palette] || ''; // Извлекаем суть поста для промпта (первые 250 символов) const postExcerpt = post.replace(/[#*_`>]/g, '').slice(0, 250); const prompt = `Editorial illustration for a social media post. Topic essence: "${postExcerpt}" Style: ${imageStyle.prompt}. ${palette ? `Color palette: ${palette}.` : ''} Channel context: ${channel.niche || channel.name}. ${style.image_prompt_instructions ? `\nChannel visual guidelines: ${style.image_prompt_instructions}` : ''} Composition: 16:9 wide format, balanced, suitable for social media. Strictly: no text, no letters, no logos, no faces of real people.`; // RouterAI /responses (primary) → Nyxos /images/generations (fallback) const model = config.ai.routeraiImageModel || 'openai/gpt-5-image-mini'; async function tryRouterAI() { const started = Date.now(); try { const res = await axios.post(`${config.ai.routeraiBaseUrl}/responses`, { model, input: `Use the image_generation tool to create this illustration. Only call the tool, no text.\n\n${prompt.slice(0, 3000)}`, tools: [{ type: 'image_generation', quality: 'low' }], tool_choice: { type: 'image_generation' }, }, { headers: { Authorization: `Bearer ${config.ai.routeraiApiKey}` }, timeout: 120_000 }); const imgCall = (res.data?.output || []).find(o => o.type === 'image_generation_call'); if (!imgCall?.result) throw new Error('No image in RouterAI response'); aiUsage.log({ provider: 'routerai', requestType: 'image_via_responses', model, imageCount: 1, meta: { channel_id: channel.id }, durationMs: Date.now()-started, succeeded: true }).catch(() => {}); return Buffer.from(imgCall.result, 'base64'); } catch (err) { aiUsage.log({ provider: 'routerai', requestType: 'image_via_responses', model, imageCount: 1, meta: { channel_id: channel.id }, durationMs: Date.now()-started, succeeded: false, errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500) }).catch(() => {}); throw err; } } async function tryNyxos() { const m = config.ai.imageModel || 'gpt-image-2'; const started = Date.now(); try { const res = await axios.post(`${config.ai.imageFallbackBaseUrl}/images/generations`, { model: m, prompt: prompt.slice(0, 4000), n: 1, size: '1024x1024', response_format: 'url' }, { headers: { Authorization: `Bearer ${config.ai.imageFallbackApiKey}` }, timeout: 90_000 } ); const item = res.data?.data?.[0]; if (!item) throw new Error('No image data'); aiUsage.log({ provider: 'nyxos', requestType: 'image', model: m, imageCount: 1, meta: { channel_id: channel.id }, succeeded: true }).catch(() => {}); if (item.url) { const r = await axios.get(item.url, { responseType: 'arraybuffer', timeout: 60_000 }); return Buffer.from(r.data); } if (item.b64_json) return Buffer.from(item.b64_json, 'base64'); throw new Error('No url or b64_json'); } catch (err) { aiUsage.log({ provider: 'nyxos', requestType: 'image', model: m, imageCount: 1, meta: { channel_id: channel.id }, succeeded: false, errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500) }).catch(() => {}); throw err; } } let bytes; try { bytes = await tryRouterAI(); } catch (err) { const status = err.response?.status; if (!status || status >= 500) { console.warn('[postImages] RouterAI failed, trying Nyxos fallback...'); bytes = await tryNyxos(); } else { throw err; } } const tsKey = `post-${channel.id}-${Date.now()}`; const ext = 'png'; // Оптимизация через sharp если есть let publicUrl; if (sharp) { const webpName = `${tsKey}.webp`; await sharp(bytes) .resize(1600, null, { withoutEnlargement: true }) .webp({ quality: 84 }) .toFile(path.join(UPLOADS_DIR, webpName)); publicUrl = `/uploads/${webpName}`; } else { const name = `${tsKey}.${ext}`; fs.writeFileSync(path.join(UPLOADS_DIR, name), bytes); publicUrl = `/uploads/${name}`; } return { url: publicUrl, style: style.image_style, palette: style.image_palette }; } module.exports = { generatePostImage, IMAGE_STYLES, IMAGE_PALETTES };