forked from admin/zeropost-engine
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:
@@ -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
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user