diff --git a/regen-covers.js b/regen-covers.js new file mode 100644 index 0000000..af30181 --- /dev/null +++ b/regen-covers.js @@ -0,0 +1,32 @@ +require('dotenv').config({ path: '/var/www/zeropost-engine/.env' }); +process.chdir('/var/www/zeropost-engine'); + +const covers = require('./src/services/covers'); +const config = require('./src/config'); +const { query } = require('./src/config/db'); + +const DELAY_MS = 8000; +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +(async () => { + await config.reloadAi(); + const { rows } = await query('SELECT id, title, tags FROM articles WHERE cover_url IS NOT NULL ORDER BY id'); + console.log(`Total articles: ${rows.length}`); + + let ok = 0, fail = 0; + for (const a of rows) { + try { + console.log(`\n[${ok+fail+1}/${rows.length}] article=${a.id}: ${a.title.slice(0,60)}`); + const url = await covers.generateCover({ articleId: a.id, title: a.title, tags: a.tags||[], channelId: 1 }); + console.log(` ✓ ${url}`); + ok++; + } catch(e) { + console.log(` ✗ FAIL: ${e.message.slice(0,100)}`); + fail++; + } + if (ok + fail < rows.length) await sleep(DELAY_MS); + } + + console.log(`\nDone: ${ok} OK, ${fail} failed`); + process.exit(0); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/src/services/covers.js b/src/services/covers.js index d2b497d..1b81643 100644 --- a/src/services/covers.js +++ b/src/services/covers.js @@ -83,12 +83,21 @@ function pickStyleIndex(articleId) { */ /** * Промпт для обложки. - * Приоритет: channelStyle.image_prompt_instructions → channelStyle.image_style → COVER_STYLES rotation. + * Приоритет: rubric.prompt → channelStyle.image_style → COVER_STYLES rotation. + * Рубрика полностью задаёт визуальный язык — ограничения внутри неё. */ -function buildCoverPrompt({ title, tags = [], articleId = 0, channelStyle = null }) { - const subject = title.replace(/[«»\":?!.]/g, '').slice(0, 100); +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} + +Article subject: "${subject}".${tagHint ? ` Theme: ${tagHint}.` : ''} +Wide 16:9 format. No text, no letters, no logos, no identifiable real human faces.`; + } + let styleDesc, paletteDesc, moodDesc, compositionDesc; const csStyle = channelStyle?.image_style; @@ -98,7 +107,7 @@ function buildCoverPrompt({ title, tags = [], articleId = 0, channelStyle = null '3d-render': { style: '3D render, soft studio lighting, isometric perspective, smooth surfaces, modern materials, Blender-quality', mood: 'polished, technical, premium', comp: 'three-quarter view, dramatic lighting' }, 'cartoon': { style: 'cartoon illustration, bold outlines, vibrant colors, expressive shapes, comic book style', mood: 'playful, energetic', comp: 'dynamic, high contrast' }, 'minimal': { style: 'extremely minimalist, single focal element, generous negative space, monochrome or duotone', mood: 'calm, sophisticated, editorial', comp: 'single centered element, maximum negative space' }, - 'abstract': { style: 'abstract artwork, layered geometric shapes, conceptual composition, mood and texture focused, no literal objects', mood: 'creative, sophisticated, tech-forward', comp: 'asymmetric layers, visual tension' }, + 'abstract': { style: 'abstract artwork, layered geometric shapes, conceptual composition, mood and texture focused', mood: 'creative, sophisticated, tech-forward', comp: 'asymmetric layers, visual tension' }, 'sketch': { style: 'hand-drawn sketch style, pencil and ink, loose confident lines, editorial illustration', mood: 'authentic, crafted, organic', comp: 'dynamic gestural lines, sketch book aesthetic' }, 'cyberpunk': { style: 'cyberpunk aesthetic, neon glowing lights, futuristic dark atmosphere, Blade Runner vibe', mood: 'bold, futuristic, dark', comp: 'dramatic angles, foreground/background depth' }, }; @@ -112,7 +121,6 @@ function buildCoverPrompt({ title, tags = [], articleId = 0, channelStyle = null 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') { @@ -126,10 +134,10 @@ Style: ${styleDesc}. ${paletteDesc ? `Color palette: ${paletteDesc}.` : ''} Mood: ${moodDesc}. Composition: ${compositionDesc} -${tagHint ? `Theme cues (subtle, abstract — not literal): ${tagHint}.` : ''} +${tagHint ? `Theme cues (subtle, abstract): ${tagHint}.` : ''} ${channelStyle?.image_prompt_instructions ? `\nChannel visual guidelines: ${channelStyle.image_prompt_instructions}` : ''} -Strictly: no text, no letters, no logos, no human faces, no robots, no brains, no glowing nodes, no circuit boards, no clocks, no screens.`; +Strictly: no text, no letters, no logos, no identifiable real human faces.`; } /** @@ -337,22 +345,67 @@ async function generateCoverViaPollinations({ prompt }) { /** * Главный путь генерации. Использует /v1/responses, при ошибке падает в legacy. */ + +/** + * Выбирает наиболее подходящую рубрику для обложки статьи. + * Дешёвый haiku-вызов: ~50 токенов. При ошибке — случайная рубрика. + */ +async function selectRubric({ title, tags = [], rubrics }) { + if (!rubrics || rubrics.length === 0) return null; + if (rubrics.length === 1) return rubrics[0]; + + const rubricList = rubrics.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-${rubrics.length - 1}) of the best matching rubric.`; + + 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), rubrics.length - 1); + return rubrics[safeIdx]; + } catch (err) { + console.warn('[Cover] selectRubric failed, using random:', err.message.slice(0, 80)); + return rubrics[Math.floor(Math.random() * rubrics.length)]; + } +} + async function generateCover({ articleId, title, tags = [], channelId = null }) { - // Подгружаем настройки изображений канала, если channelId передан + // Подгружаем настройки канала включая рубрики let channelStyle = null; if (channelId) { try { - const r = await query('SELECT image_style, image_palette, image_custom_colors, image_prompt_instructions FROM channel_style WHERE channel_id = $1', [channelId]); + 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); } } - const prompt = buildCoverPrompt({ title, tags, articleId, channelStyle }); - const styleIdx = pickStyleIndex(articleId); - const styleName = channelStyle?.image_style || COVER_STYLES[styleIdx].name; - console.log(`[Cover] article=${articleId} channel=${channelId || 'none'} style=${styleName}`); + // Выбираем рубрику если они заданы + let selectedRubric = null; + const rubrics = channelStyle?.image_rubrics; + if (Array.isArray(rubrics) && rubrics.length > 0) { + selectedRubric = await selectRubric({ title, tags, rubrics }); + console.log(`[Cover] article=${articleId} channel=${channelId} rubric=${selectedRubric?.id}`); + } else { + const styleIdx = pickStyleIndex(articleId); + const 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 = 'images-generations';