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.`; // Используем Nyxos /images/generations (первичный провайдер) // с fallback на aiguoguo — тот же путь что для обложек статей const model = config.ai.imageModel || 'gpt-image-2'; async function tryProvider(baseUrl, apiKey) { const started = Date.now(); try { const res = await axios.post( `${baseUrl}/images/generations`, { model, prompt: prompt.slice(0, 4000), n: 1, size: '1024x1024', response_format: 'url' }, { headers: { Authorization: `Bearer ${apiKey}` }, timeout: 120_000 } ); const item = res.data?.data?.[0]; if (!item) throw new Error('No image data'); aiUsage.log({ provider: aiUsage.providerFromBaseUrl(baseUrl), requestType: 'image', model, 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: aiUsage.providerFromBaseUrl(baseUrl), requestType: 'image', model, 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 tryProvider(config.ai.imageBaseUrl, config.ai.imageApiKey); } catch (err) { const status = err.response?.status; if (!status || status >= 500) { console.warn('[postImages] primary failed, trying fallback...'); bytes = await tryProvider(config.ai.imageFallbackBaseUrl, config.ai.imageFallbackApiKey); } 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 };