652 lines
36 KiB
JavaScript
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 };
|