feat: channel image style settings wired to cover/post generation

covers.js:
- buildCoverPrompt() принимает channelStyle: использует image_style из
  канала (abstract/3d-render/minimal/etc.) вместо дефолтного ротационного
  COVER_STYLES. image_palette и image_custom_colors перекрывают цвета.
  image_prompt_instructions добавляется как Channel visual guidelines.
- generateCover() принимает channelId, загружает channel_style из БД.

postImages.js:
- image_prompt_instructions добавляется в промпт постовой картинки.

articles.js:
- generateCover вызывается с channelId=1 (системный блог-канал zeropost.ru).

services/channels.js:
- updateChannel whitelist расширен: добавлены image_enabled, image_style,
  image_palette, image_custom_colors, image_prompt_instructions.
  Раньше эти поля молча игнорировались при PATCH канала.

DB:
- ALTER TABLE channel_style ADD COLUMN image_prompt_instructions TEXT;
- Системный канал id=1 получил хорошие дефолты: style=abstract,
  palette=dark, instructions=Modern tech editorial blog cover...
This commit is contained in:
Ник (Claude)
2026-06-09 10:48:38 +03:00
parent 449d1fa728
commit 95578af261
4 changed files with 61 additions and 12 deletions
+1
View File
@@ -169,6 +169,7 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
articleId: artRows[0].id,
title: artRows[0].title,
tags: artRows[0].tags || [],
channelId: 1, // системный блог-канал zeropost.ru — использует его image-настройки
}).catch(err => console.warn('[Article] cover bg failed:', err.message.slice(0,200)));
// Авто-публикация в каналы (если статья опубликована)
+3 -1
View File
@@ -133,7 +133,9 @@ async function updateChannel(channelId, userId, data) {
if (style && Object.keys(style).length) {
const fields = ['tone', 'tone_custom', 'formality', 'humor', 'post_length',
'structure', 'emoji_level', 'hashtags_mode', 'cta_mode',
'example_posts', 'banned_words', 'banned_topics', 'expertise'];
'example_posts', 'banned_words', 'banned_topics', 'expertise',
'image_enabled', 'image_style', 'image_palette',
'image_custom_colors', 'image_prompt_instructions'];
const updates = fields.filter(f => style[f] !== undefined);
if (updates.length) {
const setClauses = updates.map((f, i) => {
+56 -11
View File
@@ -81,19 +81,53 @@ function pickStyleIndex(articleId) {
/**
* Промпт для обложки — стиль выбирается по articleId, содержание по теме статьи.
*/
function buildCoverPrompt({ title, tags = [], articleId = 0 }) {
/**
* Промпт для обложки.
* Приоритет: channelStyle.image_prompt_instructions → channelStyle.image_style → COVER_STYLES rotation.
*/
function buildCoverPrompt({ title, tags = [], articleId = 0, channelStyle = null }) {
const subject = title.replace(/[«»\":?!.]/g, '').slice(0, 100);
const tagHint = tags.slice(0, 2).join(', ');
const styleIdx = pickStyleIndex(articleId);
const s = COVER_STYLES[styleIdx];
let styleDesc, paletteDesc, moodDesc, compositionDesc;
const csStyle = channelStyle?.image_style;
const STYLE_MAP = {
'realistic-photo': { style: 'photorealistic, high-quality photography, natural lighting, sharp focus, realistic textures', 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, minimal', mood: 'clean, modern', comp: 'balanced, centered focal point' },
'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' },
'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' },
};
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, bright highlights', light: 'light palette, soft whites, pastels', warm: 'warm palette, oranges, reds, gold', cool: 'cool palette, blues, teals, purples', mono: 'monochromatic, single hue with shades', vibrant: 'vibrant saturated colors, high energy' };
paletteDesc = PALETTES[channelStyle.image_palette] || null;
}
return `Abstract editorial cover illustration for an article titled "${subject}".
Style: ${s.style}.
Color palette: ${s.palette}.
Mood: ${s.mood}.
Composition: ${s.composition}. Wide 16:9 format.
Style: ${styleDesc}.
${paletteDesc ? `Color palette: ${paletteDesc}.` : ''}
Mood: ${moodDesc}.
Composition: ${compositionDesc}
${tagHint ? `Theme cues (subtle, abstract — not literal): ${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.`;
}
@@ -303,11 +337,22 @@ async function generateCoverViaPollinations({ prompt }) {
/**
* Главный путь генерации. Использует /v1/responses, при ошибке падает в legacy.
*/
async function generateCover({ articleId, title, tags = [] }) {
const prompt = buildCoverPrompt({ title, tags, articleId });
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]);
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 = COVER_STYLES[styleIdx].name;
console.log(`[Cover] article=${articleId} style=${styleIdx}:${styleName}`);
const styleName = channelStyle?.image_style || COVER_STYLES[styleIdx].name;
console.log(`[Cover] article=${articleId} channel=${channelId || 'none'} style=${styleName}`);
let img;
let usedPath = 'images-generations';
+1
View File
@@ -76,6 +76,7 @@ async function generatePostImage({ post, channel, style = {} }) {
Style: ${imageStyle.prompt}.
${palette ? `Color palette: ${palette}.` : ''}
Channel context: ${channel.niche || channel.name}.
${style.image_prompt_instructions ? `\nChannel visual guidelines: ${style.image_prompt_instructions}` : ''}
Composition: 16:9 wide format, balanced, suitable for social media.
Strictly: no text, no letters, no logos, no faces of real people.`;