forked from admin/zeropost-engine
e6c192e806
covers.js: generateCoverViaRouterAI принимает quality='medium' по умолчанию postImages.js: quality='low' для постов TG/VK (₽0.25 vs ₽0.84) Экономия 70% на генерации картинок к постам
163 lines
7.3 KiB
JavaScript
163 lines
7.3 KiB
JavaScript
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 };
|