Files
zeropost-engine/src/services/postImages.js
T

292 lines
16 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 = {} }) {
// Если задано несколько стилей через запятую — случайно выбираем один.
// Если стиль не задан или 'auto' — ротация из трёх редакторских стилей.
const DEFAULT_ROTATION = 'realistic-photo,3d-render,flat-illustration';
const rawStyle = style.image_style && style.image_style !== 'auto' ? style.image_style : DEFAULT_ROTATION;
const styleList = rawStyle.split(',').map(s => s.trim()).filter(Boolean);
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 visualConcept = getPostVisualConcept(post, channel);
// Антураж + свет — случайный выбор при каждой генерации (намеренно, не детерминированно)
const SCENES = [
{ setting: 'warm oak desktop surface, afternoon sunlight from left window', lighting: 'golden hour soft shadows', temp: 'warm amber' },
{ setting: 'white marble surface, clean studio', lighting: 'flat professional studio', temp: 'cool whites' },
{ setting: 'dark slate table, single focused overhead spotlight', lighting: 'dramatic single point', temp: 'high contrast' },
{ setting: 'weathered wooden workbench, overcast daylight', lighting: 'soft even overcast', temp: 'muted naturals' },
{ setting: 'black velvet surface, rim lighting from behind', lighting: 'rim lit glowing edges', temp: 'rich blacks gold' },
{ setting: 'glass surface over city lights at night', lighting: 'city glow from below', temp: 'multicolor bokeh' },
{ setting: 'antique library floor, surrounded by books, candlelight', lighting: 'warm candlelight side', temp: 'amber parchment' },
{ setting: 'frosted glass, winter morning, ice crystals at edges', lighting: 'diffused winter morning', temp: 'icy blues whites' },
{ setting: 'concrete urban rooftop at golden hour, city skyline behind', lighting: 'backlit warm haze', temp: 'golden urban' },
{ setting: 'minimalist white shelf, single object lit from above', lighting: 'clean overhead spotlight', temp: 'pure whites' },
{ setting: 'old wooden table in a sunlit greenhouse, plants around', lighting: 'dappled greenhouse light', temp: 'fresh greens warm' },
];
const scene = SCENES[Math.floor(Math.random() * SCENES.length)];
const prompt = `Generate a 16:9 editorial illustration for a social media post.
VISUAL CONCEPT: ${visualConcept}
SETTING: ${scene.setting}
LIGHTING: ${scene.lighting}
COLOR TEMPERATURE: ${scene.temp}
${style.image_custom_colors ? `BRAND PALETTE: ${style.image_custom_colors}` : (palette ? `PALETTE: ${palette}` : '')}
STYLE: ${imageStyle.prompt}
${style.image_prompt_instructions ? `CHANNEL STYLE: ${style.image_prompt_instructions}` : ''}
RULES: no text, no letters, no logos, no real human faces.`;
// Единственный провайдер: routerai /responses + gpt-5-image-mini
// Цена: ~₽2.72/картинка. quality параметр не работает, всегда high.
const model = config.ai.routeraiModel || 'openai/gpt-5-image-mini';
async function generateViaRouterAI() {
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' }],
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;
}
}
let bytes;
try {
bytes = await generateViaRouterAI();
} catch (err) {
const status = err.response?.status;
if (!status || (status >= 500 && status < 600)) {
console.warn(`[postImages] routerai attempt 1 failed (${status||'timeout'}), retry in 10s...`);
await new Promise(r => setTimeout(r, 10_000));
bytes = await generateViaRouterAI(); // бросит если снова упадёт
} 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 };
/**
* Извлекает визуальный концепт из текста поста.
* Конкретные, материальные образы — не абстрактные.
*/
function getPostVisualConcept(post, channel) {
const t = post.toLowerCase();
const niche = (channel?.niche || '').toLowerCase();
const combined = t + ' ' + niche;
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
const patterns = [
{
kw: ['ии', ' ai ', 'нейро', 'llm', 'gpt', 'claude', 'chatgpt', 'искусственн', 'neural'],
concepts: [
'A vintage typewriter with keys pressing by invisible force, paper emerging with glowing text',
'An old brass compass spinning and settling on a new direction, surrounded by scattered maps',
'A seed germinating in dark soil, roots and shoots emerging simultaneously, close-up macro',
'A master key held up to warm light, intricate cuts visible, golden bokeh background',
'A book opening by itself, pages turning rapidly, text rearranging mid-air in warm library',
'An optical prism splitting white light into full spectrum, mounted on dark velvet surface',
'A chess board mid-game, one piece hovering in the air about to move, dramatic side light',
'An hourglass frozen mid-flow, sand suspended in air, dark moody background',
'A single neuron with glowing dendrites branching outward, macro medical illustration style',
'A telescope pointed at a star map, constellation lines drawn in light, observatory dome open',
'A maze viewed from above, a single glowing path found through it, aerial minimalist',
'A blank canvas with a single brushstroke that transforms into a landscape, studio light',
'Two puzzle pieces clicking together mid-air, warm backlight, close-up macro',
'A vintage radio with dials, sound waves visible as light trails, dark wood surface',
'An open toolbox with glowing tools arranged precisely, overhead industrial light',
'A library ladder reaching impossibly high shelves disappearing into mist, warm amber',
],
},
{
kw: ['автомат', 'бот', 'automat', 'workflow', 'n8n', 'zapier', 'make', 'скрипт'],
concepts: [
'Vintage clockwork mechanism — interlocking brass gears in motion, macro close-up, amber light',
'A domino chain in the moment of falling, each piece a different color, motion blur',
'Factory assembly line condensed to a tabletop, small objects moving through stages, long exposure',
'A rube goldberg sequence frozen mid-action, multiple contraptions in motion',
'Time-lapse of a city intersection at night, light trails forming perfect flow patterns',
],
},
{
kw: ['взлом', 'хакер', 'безопасн', 'фишинг', 'вирус', 'cyber', 'hack', 'secur'],
concepts: [
'A vintage combination lock under dramatic side lighting, tumblers visible, dark background',
'A glass door with a hairline crack spreading, red emergency light leaking through fracture',
'An old steel safe door hanging slightly open, papers spilling out, harsh spotlight',
'A chain with one shattered link, chrome and steel, dramatic spotlight on break point',
],
},
{
kw: ['код', 'разработ', 'програм', 'code', 'develop', 'software', 'api', 'github'],
concepts: [
'A craftsman workbench covered in precision tools, each perfectly placed, workshop window light',
'An architect drafting table with blueprints unrolled, compass and ruler in use, desk lamp',
'Knitting needles mid-row on a complex pattern, wool threads crossing precisely, natural light',
'A mason building a wall one brick at a time, each brick different texture, golden hour',
],
},
{
kw: ['маркетинг', 'реклам', 'продвиж', 'seo', 'контент', 'growth', 'аудитор'],
concepts: [
'A megaphone lying on a table, vintage brass, city map spread underneath it',
'Seeds being planted in geometric rows, birds-eye view, garden tools aside, spring light',
'A lighthouse beam sweeping over foggy harbor, ships turning toward the light',
'A vendor market stall being set up attractively, colorful awning, morning light',
],
},
{
kw: ['деньг', 'финанс', 'инвест', 'бизнес', 'прибыл', 'доход', 'money', 'business'],
concepts: [
'A vintage scale perfectly balanced with different objects on each side, warm studio light',
'Stack of different vintage coins photographed from above, macro, warm lighting',
'A piggy bank on a wooden surface with a single coin mid-air above it, soft focus',
'Growing seedlings in small pots arranged by height, morning light through window',
],
},
{
kw: ['обучен', 'курс', 'урок', 'учеб', 'знан', 'навык', 'learn', 'educat'],
concepts: [
'Open textbook with handwritten notes in margins, pencil resting on page, desk lamp',
'Stack of colorful books with a cup of coffee, cozy reading corner, soft morning light',
'A graduation mortarboard on stack of books, warm sunlight from side',
'Hands writing in a notebook, pen visible, blurred background of bookshelf',
],
},
{
kw: ['здоровь', 'спорт', 'фитнес', 'еда', 'питан', 'health', 'fit', 'food'],
concepts: [
'Fresh vegetables arranged artfully on white surface, overhead shot, natural light',
'Running shoes on wooden floor, morning light casting long shadows',
'A glass of water with ice and mint, condensation visible, clean white background',
'Yoga mat rolled out near window with morning light streaming in',
],
},
];
for (const { kw, concepts } of patterns) {
if (kw.some(k => combined.includes(k))) {
return pick(concepts);
}
}
// Универсальные — нейтральные но конкретные
const generic = [
'A single lighthouse on rocky coast at dusk, warm light in tower, dramatic sky',
'An empty stage with single spotlight on plain wooden chair, theatre atmosphere',
'A vintage compass on worn leather journal, mountain wilderness background',
'A door slightly ajar with warm light escaping, curious hallway perspective',
'A single match being struck in complete darkness, dramatic flare close-up',
'A crossroads sign in fog, gravel road, dawn light breaking through',
'A paper boat on still water, single ripple expanding outward, minimalist',
'An old film projector casting beam of light, dust particles visible, cinema',
'A telescope pointed skyward from rooftop, city lights below, stars above',
'A bridge disappearing into morning fog, pedestrian perspective',
'A ceramic coffee cup with steam rising, morning light through window',
'An open notebook with a pen and fern plant, flat lay, natural light',
];
return pick(generic);
}