forked from admin/zeropost-engine
292 lines
16 KiB
JavaScript
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);
|
|
}
|