diff --git a/src/services/articles.js b/src/services/articles.js index 92acada..9546cf6 100644 --- a/src/services/articles.js +++ b/src/services/articles.js @@ -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))); // Авто-публикация в каналы (если статья опубликована) diff --git a/src/services/channels.js b/src/services/channels.js index 059c316..2c150a6 100644 --- a/src/services/channels.js +++ b/src/services/channels.js @@ -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) => { diff --git a/src/services/covers.js b/src/services/covers.js index 62dff60..4270b30 100644 --- a/src/services/covers.js +++ b/src/services/covers.js @@ -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'; diff --git a/src/services/postImages.js b/src/services/postImages.js index b156d1f..c98ed5a 100644 --- a/src/services/postImages.js +++ b/src/services/postImages.js @@ -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.`;