feat: image rubrics with AI selection for cover variety

- channel_style.image_rubrics (JSONB): 6 рубрик для ZeroPost-блога
  (tech-photo, 3d-device, code-screen, data-flow, ai-neural, cinematic-tech)
- selectRubric(): haiku выбирает рубрику по заголовку+тегам статьи
- generateCover(): загружает rubrics из БД, вызывает selectRubric перед генерацией
- buildCoverPrompt(): принимает rubric — рубрика задаёт весь визуальный язык
- Убраны лишние ограничения (no circuit boards, no glowing nodes, no brains)
  из базового промпта — теперь только: no text, no logos, no real faces
This commit is contained in:
Ник (Claude)
2026-06-09 11:36:19 +03:00
parent b2d20b9646
commit 5576665c02
2 changed files with 98 additions and 13 deletions
+32
View File
@@ -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); });
+66 -13
View File
@@ -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 }) { function buildCoverPrompt({ title, tags = [], articleId = 0, channelStyle = null, rubric = null }) {
const subject = title.replace(/[«»\":?!.]/g, '').slice(0, 100); const subject = title.replace(/[«»\\\":?!.]/g, '').slice(0, 100);
const tagHint = tags.slice(0, 2).join(', '); 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; let styleDesc, paletteDesc, moodDesc, compositionDesc;
const csStyle = channelStyle?.image_style; 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' }, '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' }, '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' }, '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' }, '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' }, '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.'; styleDesc = s.style; paletteDesc = s.palette; moodDesc = s.mood; compositionDesc = s.composition + '. Wide 16:9 format.';
} }
// Цветовая палитра из настроек канала перекрывает дефолт
if (channelStyle?.image_custom_colors) { if (channelStyle?.image_custom_colors) {
paletteDesc = `custom brand palette: ${channelStyle.image_custom_colors}`; paletteDesc = `custom brand palette: ${channelStyle.image_custom_colors}`;
} else if (channelStyle?.image_palette && channelStyle.image_palette !== 'auto') { } else if (channelStyle?.image_palette && channelStyle.image_palette !== 'auto') {
@@ -126,10 +134,10 @@ Style: ${styleDesc}.
${paletteDesc ? `Color palette: ${paletteDesc}.` : ''} ${paletteDesc ? `Color palette: ${paletteDesc}.` : ''}
Mood: ${moodDesc}. Mood: ${moodDesc}.
Composition: ${compositionDesc} 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}` : ''} ${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. * Главный путь генерации. Использует /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 }) { async function generateCover({ articleId, title, tags = [], channelId = null }) {
// Подгружаем настройки изображений канала, если channelId передан // Подгружаем настройки канала включая рубрики
let channelStyle = null; let channelStyle = null;
if (channelId) { if (channelId) {
try { 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; channelStyle = r.rows[0] || null;
} catch (err) { } catch (err) {
console.warn('[Cover] channel_style load failed, using defaults:', err.message); console.warn('[Cover] channel_style load failed, using defaults:', err.message);
} }
} }
const prompt = buildCoverPrompt({ title, tags, articleId, channelStyle }); // Выбираем рубрику если они заданы
const styleIdx = pickStyleIndex(articleId); let selectedRubric = null;
const styleName = channelStyle?.image_style || COVER_STYLES[styleIdx].name; const rubrics = channelStyle?.image_rubrics;
console.log(`[Cover] article=${articleId} channel=${channelId || 'none'} style=${styleName}`); 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 img;
let usedPath = 'images-generations'; let usedPath = 'images-generations';