diff --git a/src/services/covers.js b/src/services/covers.js index 850c1ff..aa39b7f 100644 --- a/src/services/covers.js +++ b/src/services/covers.js @@ -5,6 +5,7 @@ const config = require('../config'); const { query } = require('../config/db'); const localGen = require('./localCoverGenerator'); const aiUsage = require('./aiUsage'); +const ai = require('./ai'); const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads'; @@ -122,12 +123,31 @@ function pickStyleIndex(articleId) { /** * Промпт для обложки — стиль выбирается по articleId, содержание по теме статьи. */ +/** + * Концепт обложки от LLM — придумывает конкретную визуальную сцену ПО СМЫСЛУ статьи. + * Дешёвый haiku-вызов. Это устраняет «обложки не по теме»: вместо общей метафоры + * по ключевому слову модель читает заголовок и предлагает сцену именно под эту статью. + * При ошибке возвращает null → buildCoverPrompt откатится на getVisualMetaphor. + */ +async function generateVisualConcept({ title, tags = [], excerpt = '' }) { + try { + const sys = 'You are an art director for a tech/AI blog. Given an article, invent ONE concrete, literal visual scene for its cover that clearly fits THIS article\'s specific topic. One clear subject or visual metaphor, photographable or illustratable, with a setting and lighting. Avoid generic "robot at a desk" clichés. STRICTLY: no text, letters, numbers, logos, UI, or real human faces. Answer in English, 1-2 sentences, only the scene description.'; + const user = `Title: "${title}"\nTags: ${(tags || []).join(', ') || 'none'}${excerpt ? `\nExcerpt: ${excerpt.slice(0, 400)}` : ''}\n\nCover scene:`; + const res = await ai.chat(config.ai.models.post || 'claude-haiku-4-5-20251001', sys, user, { maxTokens: 120, temperature: 0.9 }); + const text = (res?.text || '').trim().replace(/^["']|["']$/g, ''); + return text.length > 10 ? text : null; + } catch (err) { + console.warn('[Cover] visual concept LLM failed, fallback to metaphor:', (err.message || '').slice(0, 80)); + return null; + } +} + /** * Промпт для обложки. * Приоритет: rubric.prompt → channelStyle.image_style → COVER_STYLES rotation. * Рубрика полностью задаёт визуальный язык — ограничения внутри неё. */ -function buildCoverPrompt({ title, tags = [], articleId = 0, channelStyle = null, rubric = null }) { +function buildCoverPrompt({ title, tags = [], articleId = 0, channelStyle = null, rubric = null, concept = null }) { const subject = title.replace(/[«»":?!.]/g, '').slice(0, 100); const tagHint = tags.slice(0, 2).join(', '); @@ -196,7 +216,7 @@ function buildCoverPrompt({ title, tags = [], articleId = 0, channelStyle = null lighting: 'soft side window light', temp: 'warm brick reds and natural wood' }, ]; const scene = SCENES[articleId % SCENES.length]; - const visualConcept = getVisualMetaphor(title, tags, articleId); + const visualConcept = concept || getVisualMetaphor(title, tags, articleId); return `Generate a 16:9 editorial cover photograph or illustration. @@ -557,7 +577,14 @@ async function generateCover({ articleId, title, tags = [], channelId = null }) console.log(`[Cover] article=${articleId} channel=${channelId || 'none'} style=${styleName}`); } - const prompt = buildCoverPrompt({ title, tags, articleId, channelStyle, rubric: selectedRubric }); + // Концепт обложки ПО СМЫСЛУ статьи (LLM). Только без жёсткой рубрики. + let concept = null; + if (!selectedRubric) { + concept = await generateVisualConcept({ title, tags }); + if (concept) console.log(`[Cover] article=${articleId} concept="${concept.slice(0, 90)}"`); + } + + const prompt = buildCoverPrompt({ title, tags, articleId, channelStyle, rubric: selectedRubric, concept }); let img = null; let usedPath = 'routerai';