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

652 lines
36 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const axios = require('axios');
const config = require('../config');
const { query } = require('../config/db');
const localGen = require('./localCoverGenerator');
const aiUsage = require('./aiUsage');
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
// Опциональная оптимизация — если sharp есть, конвертим в WebP
let sharp = null;
try { sharp = require('sharp'); } catch {}
if (!fs.existsSync(UPLOADS_DIR)) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
}
/**
* 6 визуальных стилей обложек.
* Выбор детерминированный по articleId — одна статья всегда получает
* один стиль при регенерации, но разные статьи выглядят по-разному.
*/
const COVER_STYLES = [
{
name: 'amber-terrain',
palette: 'warm amber (#f59e0b, #fbbf24) primary, burnt sienna (#c2410c), cream (#fef3c7) background, deep brown accents',
style: 'organic flowing shapes, topographic contour lines, layered paper-cut planes, earthy textures',
mood: 'warm, grounded, thoughtful, human',
composition: 'layered depth with overlapping organic planes, horizon-inspired layout',
},
{
name: 'violet-gradient',
palette: 'deep purple (#7c3aed, #6d28d9) to lavender (#c4b5fd), pale rose background, white highlights',
style: 'smooth gradient fields, soft abstract blobs, gentle radial forms, dreamy overlapping circles',
mood: 'creative, forward-thinking, imaginative, innovative',
composition: 'radial soft composition, overlapping translucent shapes, dreamy depth',
},
{
name: 'monochrome-sharp',
palette: 'near-black (#111827) and white primary, single vivid red (#ef4444) accent on one element only',
style: 'bold stark geometry, high contrast hard edges, Swiss design influence, graphic poster style',
mood: 'bold, editorial, authoritative, direct',
composition: 'strong visual tension, deliberate asymmetry, dominant single focal element',
},
{
name: 'coral-horizon',
palette: 'coral (#f97316, #fb923c) primary, dusty rose, warm grey background, sage green accents',
style: 'rounded soft shapes, gentle gradient washes, warm abstract landscape forms, approachable curves',
mood: 'warm, accessible, optimistic, friendly',
composition: 'wide horizon-like bands, soft rounded forms, gentle layered gradients',
},
{
name: 'neon-circuits',
palette: 'deep black (#050505) background, electric cyan (#00f5ff) and magenta (#ff00e4) glowing lines, white highlights',
style: 'glowing circuit board traces, neural network node patterns, light trails, digital matrix aesthetic',
mood: 'futuristic, technical, high-energy, cutting-edge AI',
composition: 'diagonal flowing lines from corner to corner, perspective vanishing point, depth grid',
},
{
name: 'blueprint-tech',
palette: 'deep navy (#0a1628) background, bright white line art, cyan (#38bdf8) highlights, gold accents',
style: 'technical blueprint drawing style, precise geometric wireframes, schematic diagrams, engineering aesthetic',
mood: 'precise, methodical, professional, architectural',
composition: 'centered technical diagram, radiating lines, architectural cross-section feel',
},
{
name: 'glass-morphism',
palette: 'soft blue-grey background, frosted white panels, electric blue (#3b82f6) accents, subtle shadows',
style: 'frosted glass panels layered with blur, translucent overlapping shapes, light refraction, modern UI feel',
mood: 'modern, clean, sophisticated, premium tech',
composition: 'overlapping glass cards at angles, light source from top-left, subtle shadows',
},
{
name: 'retro-wave',
palette: 'deep purple-black sky (#12032e), hot pink (#ff2d78) and electric blue (#00b4ff) grid, orange (#ff6b35) sun',
style: 'synthwave 80s aesthetic, chrome style, sunset grid perspective, VHS vibe',
mood: 'nostalgic-futuristic, energetic, bold, iconic',
composition: 'infinite perspective grid to horizon, oversized sun, dramatic sky gradient',
},
{
name: 'zen-minimal',
palette: 'off-white (#fafaf9) background, single ink-black focal element, dusty sage subtle accent',
style: 'Japanese minimalism, single brushstroke gesture, generous negative space, ink wash',
mood: 'calm, contemplative, refined, timeless',
composition: 'single isolated element in lower-third, 80% empty space, asymmetric zen balance',
},
{
name: 'data-cosmos',
palette: 'deep space black background, galaxy purple to teal nebula, white star points',
style: 'data visualization aesthetic, floating 3D scatter plots, constellation connections, cosmic scale',
mood: 'vast, intellectual, data-driven, awe-inspiring',
composition: 'scattered nodes connected by thin lines, clusters, perspective depth, cosmic scale',
},
{
name: 'editorial-ink',
palette: 'cream (#fef9ef) background, deep charcoal ink, muted rust single highlight',
style: 'editorial illustration, hatching textures, woodblock print aesthetic, newspaper editorial',
mood: 'journalistic, authentic, handcrafted, trustworthy',
composition: 'dominant illustration element with organic borders, imperfect hand-drawn feel',
},
{
name: 'teal-architecture',
palette: 'white background, architectural teal (#0f766e, #0d9488) primary, charcoal structural lines',
style: 'architectural isometric illustration, precise 3D geometric forms, modern building aesthetic, clean vectors',
mood: 'structured, innovative, professional, spatial',
composition: 'isometric 3D perspective, modular stacked forms, clean technical beauty',
},
];
/**
* Детерминированный хэш articleId → индекс стиля.
*/
function pickStyleIndex(articleId) {
let hash = 0;
const s = String(articleId);
for (let i = 0; i < s.length; i++) {
hash = (hash * 31 + s.charCodeAt(i)) >>> 0;
}
return hash % COVER_STYLES.length;
}
/**
* Промпт для обложки — стиль выбирается по articleId, содержание по теме статьи.
*/
/**
* Промпт для обложки.
* Приоритет: rubric.prompt → channelStyle.image_style → COVER_STYLES rotation.
* Рубрика полностью задаёт визуальный язык — ограничения внутри неё.
*/
function buildCoverPrompt({ title, tags = [], articleId = 0, channelStyle = null, rubric = null }) {
const subject = title.replace(/[«»":?!.]/g, '').slice(0, 100);
const tagHint = tags.slice(0, 2).join(', ');
if (rubric?.prompt) {
return `${rubric.prompt}\n\nArticle subject: "${subject}".${tagHint ? ` Theme: ${tagHint}.` : ''}\nWide 16:9 format. No text, no letters, no logos, no identifiable real human faces.`;
}
let styleDesc, paletteDesc, moodDesc, compositionDesc;
const rawStyle = channelStyle?.image_style || '';
const styleList = rawStyle.split(',').map(s => s.trim()).filter(s => s && s !== 'auto');
const csStyle = styleList.length > 0 ? styleList[Math.floor(Math.random() * styleList.length)] : null;
const STYLE_MAP = {
'realistic-photo': { style: 'photorealistic, high-quality photography, natural lighting, sharp focus', mood: 'professional, realistic', comp: 'rule of thirds, natural depth of field' },
'flat-illustration': { style: 'flat vector illustration, clean geometric shapes, modern editorial style, smooth gradients', mood: 'clean, modern', comp: 'balanced, centered focal point' },
'3d-render': { style: '3D render, soft studio lighting, isometric perspective, smooth surfaces, modern materials', mood: 'polished, technical, premium', comp: 'three-quarter view, dramatic lighting' },
'cartoon': { style: 'cartoon illustration, bold outlines, vibrant colors, expressive shapes', mood: 'playful, energetic', comp: 'dynamic, high contrast' },
'minimal': { style: 'extremely minimalist, single focal element, generous negative space, duotone', mood: 'calm, sophisticated', comp: 'single centered element, maximum negative space' },
'abstract': { style: 'abstract artwork, layered geometric shapes, conceptual composition', mood: 'creative, tech-forward', comp: 'asymmetric layers, visual tension' },
'sketch': { style: 'hand-drawn sketch style, pencil and ink, loose confident lines', mood: 'authentic, organic', comp: 'dynamic gestural lines' },
'cyberpunk': { style: 'cyberpunk aesthetic, neon glowing lights, futuristic dark atmosphere', mood: 'bold, futuristic, dark', comp: 'dramatic angles, foreground/background depth' },
};
if (csStyle && csStyle !== 'auto' && STYLE_MAP[csStyle]) {
const s = STYLE_MAP[csStyle];
styleDesc = s.style; moodDesc = s.mood; compositionDesc = s.comp + '. Wide 16:9 format.';
paletteDesc = null;
} else {
const s = COVER_STYLES[pickStyleIndex(articleId)];
styleDesc = s.style; paletteDesc = s.palette; moodDesc = s.mood; compositionDesc = s.composition + '. Wide 16:9 format.';
}
if (channelStyle?.image_custom_colors) {
paletteDesc = `custom brand palette: ${channelStyle.image_custom_colors}`;
} else if (channelStyle?.image_palette && channelStyle.image_palette !== 'auto') {
const PALETTES = { dark: 'dark palette, deep blues and blacks', light: 'light palette, soft whites, pastels', warm: 'warm palette, oranges, reds, gold', cool: 'cool palette, blues, teals, purples', mono: 'monochromatic', vibrant: 'vibrant saturated colors' };
paletteDesc = PALETTES[channelStyle.image_palette] || null;
}
// Выбираем сцену — конкретное место + свет + цвет
// Привязаны к articleId чтобы каждая статья имела уникальный антураж
const SCENES = [
{ setting: 'warm oak desktop surface, afternoon sunlight through window from left casting long shadows',
lighting: 'golden hour, soft shadows', temp: 'warm amber tones' },
{ setting: 'white marble surface, clean studio setup, diffused overhead light',
lighting: 'flat professional studio lighting', temp: 'cool whites and greys' },
{ setting: 'dark slate table, single focused spotlight from above',
lighting: 'dramatic single point light, deep shadows', temp: 'high contrast, near-monochrome' },
{ setting: 'weathered wooden workbench outdoors, overcast daylight',
lighting: 'soft even overcast light, no harsh shadows', temp: 'muted natural greens and browns' },
{ setting: 'black velvet surface in studio, rim lighting from behind',
lighting: 'rim lit, glowing edges, dark center', temp: 'rich blacks with gold highlights' },
{ setting: 'blue morning mist, outdoor scene at dawn, dew on surfaces',
lighting: 'early morning blue hour, soft mist', temp: 'cool blues and pale greys' },
{ setting: 'terracotta-tiled floor, Mediterranean afternoon, dappled shade',
lighting: 'dappled sunlight through leaves', temp: 'warm sienna and ochre tones' },
{ setting: 'glass surface over city lights at night, reflections below',
lighting: 'city glow from below, urban night', temp: 'multicolored bokeh, deep navy' },
{ setting: 'rough concrete surface, industrial warehouse, fluorescent light',
lighting: 'harsh overhead fluorescent, cool and flat', temp: 'grey concrete tones, accent cyan' },
{ setting: 'antique wooden library floor, surrounded by stacked books, candlelight',
lighting: 'warm candlelight from one side', temp: 'deep amber and aged parchment' },
{ setting: 'frosted glass surface, winter morning, ice crystals at edges',
lighting: 'diffused winter morning light', temp: 'icy blues and whites' },
{ setting: 'warm brick wall background, artisan workshop, window light',
lighting: 'soft side window light', temp: 'warm brick reds and natural wood' },
];
const scene = SCENES[articleId % SCENES.length];
const visualConcept = getVisualMetaphor(title, tags, articleId);
return `Generate a 16:9 editorial cover photograph or illustration.
SUBJECT: ${visualConcept}
SETTING: ${scene.setting}
LIGHTING: ${scene.lighting}
COLOR TEMPERATURE: ${scene.temp}
${tagHint ? `THEMATIC CONTEXT: ${tagHint}` : ''}
${channelStyle?.image_prompt_instructions ? `CHANNEL STYLE: ${channelStyle.image_prompt_instructions}` : ''}
RULES: photorealistic or illustrated — either works. NO text, NO letters, NO logos, NO human faces.`;
}
/**
* Возвращает уникальную визуальную метафору для обложки.
* articleId гарантирует уникальность даже при одинаковой теме.
* Метафоры — конкретные, материальные, не абстрактные.
*/
function getVisualMetaphor(title, tags = [], articleId = 0) {
const t = (title + ' ' + tags.join(' ')).toLowerCase();
// Чистый index по articleId — каждая новая статья = следующая метафора в цикле
// Гарантирует повторение только через N статей (N = кол-во метафор в категории)
function pick(arr) { return arr[articleId % arr.length]; }
// Тематические паттерны → конкретные визуальные метафоры
const patterns = [
{ kw: ['взлом', 'хакер', 'атак', 'уязвим', 'безопасн', 'hack', 'secur', 'cyber', 'exploit', 'inject', 'фишинг', 'ransomware'],
metaphors: [
'A vintage combination lock under ultraviolet light, tumblers glowing, one tumbler broken open — dark industrial background',
'A glass door with a hairline crack spreading across its surface, red light leaking through the fracture',
'An old steel safe with its door hanging open, interior papers spilling out, emergency light casting harsh shadows',
'A chain with one shattered link, chrome and steel, dramatic spotlight on the break point',
'An antique key duplicating itself in a hall of mirrors, each copy slightly distorted',
'A fingerprint dissolving into scattered pixels under a magnifying glass, neon forensic lab aesthetic',
'A locked filing cabinet surrounded by floating question marks and warning symbols, cinematic lighting',
]
},
{ kw: ['ии', ' ai ', 'нейро', 'llm', 'gpt', 'claude', 'модел', 'neural', 'machine learn', 'deep learn', 'искусственн', 'chatgpt'],
metaphors: [
'A vintage typewriter whose keys are being pressed by invisible force, paper emerging with text forming itself',
'An hourglass where sand flows upward, each grain transforming into a tiny glowing letter',
'A book opening by itself in a dark library, pages turning rapidly, text rearranging mid-air',
'An old brass telescope pointed at a sky full of equations instead of stars',
'A clay sculpture being shaped by invisible hands, smooth surfaces revealing complex geometry underneath',
'A single candle flame in darkness, its shadow casting a complex branching pattern on the wall behind',
'A compass spinning wildly then settling on a new direction, surrounded by scattered maps and charts',
'A chess queen piece casting a shadow of an entire army, dramatic chiaroscuro lighting',
'An optical prism splitting white light into spectrum, mounted on dark velvet, macro photography',
'A moth drawn to a glowing lamp, dozens of moths circling, long exposure photography blur',
'A seed germinating in time-lapse, roots and shoots emerging simultaneously, dark soil background',
'A master key on a ring of different keys, held up to light, warm metal tones',
'A musician tuning an instrument by ear, close-up on hands on strings, warm studio light',
'A cartographer sketching an unmapped territory, hand drawing a coastline into blank space',
]
},
{ kw: ['автомат', 'бот', 'automat', 'workflow', 'pipeline', 'скрипт', 'robot', 'n8n', 'make ', 'zapier', 'automation'],
metaphors: [
'Vintage clockwork mechanism — interlocking brass gears in motion, warm amber light, macro detail',
'A factory assembly line condensed to a tabletop, tiny objects moving through stages, long exposure light trails',
'A domino chain in the moment of falling, each domino a different color, motion blur',
'A switchboard operator desk with hundreds of cables connecting ports, warm retro office lighting',
'Pipes of different materials connecting and merging into one clean output pipe, industrial workshop',
'A rube goldberg sequence frozen in time, multiple contraptions mid-action',
'Time-lapse of a city intersection at night, light trails forming perfect flow patterns',
]
},
{ kw: ['данн', 'аналит', 'data', 'analyt', 'метрик', 'статист', 'chart', 'график', 'seo'],
metaphors: [
'A vintage stock ticker machine printing endless paper tape, trading floor atmosphere',
'A weather station with multiple instruments all pointing to extremes, storm approaching',
'Cartographer desk with overlapping transparent maps, pins and measurements, candlelight',
'A microscope slide with a complex pattern of cells, lab setting, precise clinical lighting',
'Library card catalog system with infinite drawers, one drawer pulled open revealing complex index',
'A piano with sheet music where the notes form bar chart patterns, concert hall lighting',
'A surveyor theodolite in a field, precision instruments against a dramatic sky',
]
},
{ kw: ['дипфейк', 'голос', 'deepfake', 'voice', 'fraud', 'fake', 'мошенн'],
metaphors: [
'A wax museum figure melting slightly at the edges, museum spotlights, uncanny valley',
'Two theatrical masks — comedy and tragedy — but identical, hanging in spotlight',
'A reflection in water that is slightly different from what stands above it, twilight mood',
'Vintage photobooth with curtain closed, multiple shadow silhouettes visible through fabric',
'A hand puppet casting a human-shaped shadow on a wall, single dramatic light source',
'A mirror cracked down the middle, each half showing a different scene',
]
},
{ kw: ['код', 'разработ', 'програм', 'code', 'develop', 'software', 'api', 'github', 'cursor', 'copilot'],
metaphors: [
'A craftsman workbench covered in tools, each precisely placed, wood shavings on floor, workshop window light',
'An architect drafting table with blueprints unrolled, compass and ruler in use, desk lamp',
'A vintage telephone exchange switchboard, operator perspective, warm bulb lights',
'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 a different texture, golden hour',
'An open laptop in a dark room, only the screen light illuminating the workspace, code reflection on glasses',
]
},
{ kw: ['маркетинг', 'контент', 'реклам', 'marketing', 'content', 'growth', 'продвиж', 'аудитор'],
metaphors: [
'A megaphone lying on a table, vintage brass, city map spread underneath it',
'A fishing rod cast into a sea of business cards, one card caught on the hook',
'A vendor market stall being set up, colorful awning, products arranged attractively, morning light',
'Seeds being planted in geometric rows, birds-eye view, garden tools aside, spring light',
'A lighthouse beam sweeping over a foggy harbor, ships turning toward the light',
'A street performer drawing a large crowd, performer perspective from stage, golden sunset',
]
},
{ kw: ['email', 'рассылк', 'письм', 'newsletter', 'inbox'],
metaphors: [
'A vintage mailbox overflowing with envelopes, country road, afternoon light',
'A writing desk with fountain pen mid-sentence on paper, wax seal nearby, window light',
'Hundreds of paper airplanes suspended in air radiating from a single point, white room',
'A postal sorting office with letters on conveyor, warm institutional lighting',
'A message in a bottle washing ashore, ocean at golden hour, single perfect wave',
]
},
{ kw: ['prompt', 'промпт', 'инжиниринг', 'instruct', 'few-shot', 'chain', 'копирайт'],
metaphors: [
'A sculptor chiseling marble, chips flying, emerging form just visible, dramatic studio lighting',
'A translator desk with multiple open dictionaries and handwritten notes between them',
'A cook adjusting seasoning over a pot, herbs and spices scattered artfully on counter',
'A jeweler using loupe to set a tiny gem, precision tools, warm workshop light',
'A conductor baton frozen mid-gesture, orchestra music sheets visible below',
]
},
{ kw: ['vector', 'embed', 'база знаний', 'rag ', 'pinecone', 'weaviate', 'langchain', 'llamaindex'],
metaphors: [
'A vast library card catalog extending to infinite depth, one card pulled mid-search',
'A specimen collection in glass cases, each specimen precisely labeled, natural history museum',
'A wine cellar with bottles organized in a complex system, sommelier consulting notes',
'A large cork pinboard covered in photos and strings connecting them, detective war room',
'A seed bank vault with thousands of labeled drawers, clinical cold light',
]
},
{ kw: ['продуктив', 'задач', 'workflow', 'менеджмент', 'таск', 'task', 'agile', 'sprint'],
metaphors: [
'A physical inbox/outbox tray system stacked high on wooden desk, morning office light',
'A large whiteboard covered in sticky notes being organized by unseen hands',
'A craftsman apron with every tool in its pocket, each in perfect order',
'Calendar pages turning on a desk, one date circled in red, pen nearby',
]
},
];
for (const { kw, metaphors } of patterns) {
if (kw.some(k => t.includes(k))) {
return pick(metaphors);
}
}
// Универсальные метафоры — конкретные и разнообразные
const generic = [
'A single lighthouse on a rocky coast at dusk, warm light in the tower, storm approaching from sea',
'A vintage globe being spun by unseen hands, old map colors, library atmosphere',
'A hand planting a seed in rich dark soil, morning mist in background, close-up',
'An empty stage with a single spotlight on a plain wooden chair, theatre atmosphere',
'A vintage compass on a worn leather journal, mountain wilderness in background',
'A mason jar filled with fireflies, summer night, porch railing, blurred trees behind',
'An old film projector casting beam of light, dust particles visible, cinema darkness',
'A door slightly ajar, warm light escaping, curious perspective from hallway',
'A ladder leaning against a wall reaching above the clouds, surreal realistic style',
'A single match being struck in complete darkness, dramatic flare, close-up',
'A crossroads sign pointing in four different directions, fog, gravel road, dawn',
'A paper boat on still water, single ripple expanding outward, minimalist',
'An old radio being tuned, backlit frequency dial, warm evening light',
'A telescope pointed skyward from a rooftop, city lights below, stars above',
'A bridge in fog, one end visible, other end disappearing into mist, pedestrian perspective',
];
return pick(generic);
}
async function generateCoverViaRouterAI({ prompt }) {
const base = config.ai.routeraiBaseUrl;
const key = config.ai.routeraiApiKey;
const model = config.ai.routeraiModel || 'openai/gpt-5-image-mini';
const started = Date.now();
try {
const res = await axios.post(`${base}/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 ${key}` },
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, durationMs: Date.now()-started, succeeded: true }).catch(() => {});
return { bytes: Buffer.from(imgCall.result, 'base64'), format: imgCall.output_format || 'png' };
} catch (err) {
aiUsage.log({ provider: 'routerai', requestType: 'image_via_responses', model, imageCount: 1, durationMs: Date.now()-started, succeeded: false, errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0,500) }).catch(() => {});
throw err;
}
}
async function generateCoverViaImageGenerations({ prompt }) {
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: 45_000 }
);
const item = res.data?.data?.[0];
if (!item) throw new Error('No image data in response');
aiUsage.log({ provider: aiUsage.providerFromBaseUrl(baseUrl), requestType: 'image', model, imageCount: 1, durationMs: Date.now()-started, succeeded: true }).catch(() => {});
if (item.url) {
const r = await axios.get(item.url, { responseType: 'arraybuffer', timeout: 60_000 });
return { bytes: Buffer.from(r.data), format: 'png' };
}
if (item.b64_json) return { bytes: Buffer.from(item.b64_json, 'base64'), format: 'png' };
throw new Error('No url or b64_json in response');
} catch (err) {
aiUsage.log({ provider: aiUsage.providerFromBaseUrl(baseUrl), requestType: 'image', model, imageCount: 1, durationMs: Date.now()-started, succeeded: false, errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500) }).catch(() => {});
throw err;
}
}
// Основной: RouterAI /responses (стабильный)
try {
return await generateCoverViaRouterAI({ prompt });
} catch (err) {
const status = err.response?.status;
if (!status || status >= 500) {
console.warn(`[Cover] RouterAI failed (${status||'timeout'}), trying Nyxos fallback...`);
return await tryProvider(config.ai.imageFallbackBaseUrl, config.ai.imageFallbackApiKey);
}
throw err;
}
}
/**
* Резервный путь — Pollinations.AI (https://pollinations.ai).
* 100% бесплатно, без API ключа, без регистрации.
* GET запрос → JPEG обложка за ~1-2 секунды.
* Используется только когда aiprimetech.io недоступен.
*/
async function generateCoverViaPollinations({ prompt }) {
// Pollinations: простой GET по URL, сразу возвращает бинарный JPEG
const encoded = encodeURIComponent(prompt.slice(0, 1000)); // лимит на длину URL
const url = `https://image.pollinations.ai/prompt/${encoded}?width=1600&height=900&model=flux&nologo=true`;
const res = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 90_000, // Pollinations иногда медленный при нагрузке
headers: { 'User-Agent': 'ZeroPost/1.0 blog-cover-generator' },
});
if (!res.data || res.data.byteLength < 5000) {
throw new Error(`Pollinations returned too small response: ${res.data?.byteLength} bytes`);
}
return {
bytes: Buffer.from(res.data),
format: 'jpg',
};
}
/**
* Главный путь генерации. Использует /v1/responses, при ошибке падает в legacy.
*/
/**
* Выбирает наиболее подходящую рубрику для обложки статьи.
* Дешёвый haiku-вызов: ~50 токенов. При ошибке — случайная рубрика.
*/
async function selectRubric({ title, tags = [], rubrics, channelId = null }) {
if (!rubrics || rubrics.length === 0) return null;
if (rubrics.length === 1) return rubrics[0];
// Получаем последние использованные рубрики из БД (анти-повтор)
let recentlyUsed = [];
if (channelId) {
try {
const r = await query(
'SELECT last_rubrics_used FROM channel_style WHERE channel_id = $1',
[channelId]
);
recentlyUsed = Array.isArray(r.rows[0]?.last_rubrics_used)
? r.rows[0].last_rubrics_used
: [];
} catch (_) {}
}
// Доступные рубрики = все минус последние 3 использованные
// (если осталось < 2 — берём все, иначе AI выбирает только из свежих)
const recent = recentlyUsed.slice(-3);
let available = rubrics.filter(r => !recent.includes(r.id));
if (available.length < 2) available = rubrics;
const rubricList = available.map((r, i) => `${i}. ${r.id}: ${r.desc}`).join('\n');
const userMsg = `Article title: "${title}"\nTags: ${tags.join(', ') || 'none'}\n\nRubrics:\n${rubricList}\n\nRespond with ONLY the index number (0-${available.length - 1}) of the best matching rubric.`;
let selected = null;
try {
const res = await axios.post(
`${config.ai.baseUrl}/chat/completions`,
{
model: config.ai.models.post || 'claude-haiku-4-5-20251001',
max_tokens: 5,
temperature: 0,
messages: [
{ role: 'system', content: 'You select a visual style for article cover images. Reply with only a single digit index number.' },
{ role: 'user', content: userMsg },
],
},
{ headers: { Authorization: `Bearer ${config.ai.apiKey}` }, timeout: 10000 }
);
const raw = res.data?.choices?.[0]?.message?.content?.trim() || '0';
const idx = parseInt(raw.replace(/\D/g, '')) || 0;
const safeIdx = Math.min(Math.max(idx, 0), available.length - 1);
selected = available[safeIdx];
} catch (err) {
console.warn('[Cover] selectRubric failed, using random:', err.message.slice(0, 80));
selected = available[Math.floor(Math.random() * available.length)];
}
// Записываем в last_rubrics_used (храним последние 5)
if (channelId && selected) {
try {
const newList = [...recentlyUsed, selected.id].slice(-5);
await query(
'UPDATE channel_style SET last_rubrics_used = $1 WHERE channel_id = $2',
[JSON.stringify(newList), channelId]
);
} catch (_) {}
}
return selected;
}
async function generateCover({ articleId, title, tags = [], channelId = null }) {
// Подгружаем настройки канала включая рубрики
let channelStyle = null;
if (channelId) {
try {
const r = await query('SELECT image_style, image_palette, image_custom_colors, image_prompt_instructions, image_rubrics FROM channel_style WHERE channel_id = $1', [channelId]);
channelStyle = r.rows[0] || null;
} catch (err) {
console.warn('[Cover] channel_style load failed, using defaults:', err.message);
}
}
// Выбираем рубрику если они заданы
let selectedRubric = null;
let styleName;
const rubrics = channelStyle?.image_rubrics;
if (Array.isArray(rubrics) && rubrics.length > 0) {
selectedRubric = await selectRubric({ title, tags, rubrics, channelId });
styleName = selectedRubric?.id || 'rubric';
console.log(`[Cover] article=${articleId} channel=${channelId} rubric=${styleName}`);
} else {
const styleIdx = pickStyleIndex(articleId);
styleName = channelStyle?.image_style || COVER_STYLES[styleIdx].name;
console.log(`[Cover] article=${articleId} channel=${channelId || 'none'} style=${styleName}`);
}
const prompt = buildCoverPrompt({ title, tags, articleId, channelStyle, rubric: selectedRubric });
let img;
let usedPath = 'routerai';
// Единственный провайдер картинок: routerai /responses + gpt-5-image-mini
// Цена: ~₽2.72/картинка (4175 image tokens, high quality, quality param не работает)
try {
img = await generateCoverViaRouterAI({ prompt });
} catch (err) {
const status = err.response?.status;
// Ретрай при 5xx
if (!status || (status >= 500 && status < 600)) {
console.warn(`[Cover] routerai attempt 1 failed (${status||'timeout'}), retry in 10s...`);
await new Promise(r => setTimeout(r, 10_000));
try {
img = await generateCoverViaRouterAI({ prompt });
} catch (err2) {
console.warn(`[Cover] routerai attempt 2 failed: ${(err2.response?.data?.error?.message || err2.message).slice(0, 150)}`);
img = null;
}
} else {
console.warn(`[Cover] routerai failed (${status}): ${(err.response?.data?.error?.message || err.message).slice(0, 150)}`);
img = null;
}
}
if (!img) {
// routerai недоступен — fallback на local SVG
console.log(`[Cover] article=${articleId} → local SVG (routerai unavailable)`);
const localUrl = await localGen.generateLocalCover({ articleId, title, category: tags?.[0] || '' });
await query('UPDATE articles SET cover_url=$1, updated_at=NOW() WHERE id=$2', [localUrl, articleId]);
return localUrl;
}
// Сохраняем оригинал
const tsKey = `${articleId}-${Date.now()}`;
const ext = img.format || 'png';
const originalName = `cover-${tsKey}.${ext}`;
const originalPath = path.join(UPLOADS_DIR, originalName);
fs.writeFileSync(originalPath, img.bytes);
// Оптимизация — если sharp есть, делаем WebP в подходящем размере
let publicUrl = `/uploads/${originalName}`;
let optimizedSize = null;
if (sharp) {
try {
const webpName = `cover-${tsKey}.webp`;
const webpPath = path.join(UPLOADS_DIR, webpName);
await sharp(img.bytes)
.resize(1600, null, { withoutEnlargement: true })
.webp({ quality: 84 })
.toFile(webpPath);
const stat = fs.statSync(webpPath);
optimizedSize = stat.size;
publicUrl = `/uploads/${webpName}`;
} catch (e) {
console.warn(`[Cover] sharp optimization skipped: ${e.message}`);
}
}
await query(`UPDATE articles SET cover_url=$1, updated_at=NOW() WHERE id=$2`, [publicUrl, articleId]);
console.log(`[Cover] saved ${publicUrl} via ${usedPath} style=${styleName} (${(img.bytes.length / 1024).toFixed(0)}KB original, ${optimizedSize ? (optimizedSize / 1024).toFixed(0) + 'KB optimized' : 'no opt'})`);
return publicUrl;
}
/**
* Дофоновая попытка сгенерировать обложки для статей без cover_url.
*/
async function backfillCovers({ limit = 3 } = {}) {
const { rows } = await query(
`SELECT id, title, tags FROM articles
WHERE cover_url IS NULL AND status='published'
ORDER BY published_at DESC LIMIT $1`,
[limit]
);
let ok = 0, fail = 0;
const results = [];
for (const a of rows) {
try {
const url = await generateCover({ articleId: a.id, title: a.title, tags: a.tags || [] });
ok++;
results.push({ id: a.id, status: 'ok', url });
} catch (err) {
fail++;
results.push({ id: a.id, status: 'fail', error: err.message.slice(0, 150) });
}
}
return { processed: rows.length, ok, fail, results };
}
module.exports = { generateCover, backfillCovers, buildCoverPrompt, pickStyleIndex, COVER_STYLES, UPLOADS_DIR };