diff --git a/src/services/covers.js b/src/services/covers.js index 76fd3e4..e4891b7 100644 --- a/src/services/covers.js +++ b/src/services/covers.js @@ -15,20 +15,85 @@ if (!fs.existsSync(UPLOADS_DIR)) { } /** - * Промпт для обложки в стиле сайта. + * 6 визуальных стилей обложек. + * Выбор детерминированный по articleId — одна статья всегда получает + * один стиль при регенерации, но разные статьи выглядят по-разному. */ -function buildCoverPrompt({ title, tags = [] }) { - const subject = title.replace(/[«»":?!.]/g, '').slice(0, 100); +const COVER_STYLES = [ + { + name: 'emerald-flow', + palette: 'emerald green (#10b981, #34d399) as primary, soft teal, warm off-white background (#fafaf9), subtle dark accents', + style: 'flat geometric shapes, smooth flowing curves, layered planes, vector-clean lines', + mood: 'clean, modern, calm, intellectual — Stripe Press / Linear / Anthropic brand aesthetic', + composition: 'balanced asymmetry, flowing diagonal forms, generous negative space', + }, + { + name: 'midnight-blueprint', + palette: 'deep navy (#0f172a, #1e3a5f) as background, electric blue (#3b82f6, #60a5fa) as accent, crisp white details, subtle cyan highlights', + style: 'precise isometric geometry, technical grid lines, architectural cross-sections, blueprint-inspired forms', + mood: 'focused, technical, precise, trustworthy — like a well-engineered system', + composition: 'structured grid layout, strong geometric hierarchy, tight angular forms', + }, + { + name: 'amber-terrain', + palette: 'warm amber (#f59e0b, #fbbf24) as primary, burnt sienna (#c2410c), cream (#fef3c7) background, deep brown accents', + style: 'organic flowing shapes, topographic contour lines, layered paper-cut planes, earthy textures', + mood: 'warm, grounded, thoughtful, human — like physical craftsmanship', + composition: 'layered depth with overlapping organic planes, horizon-inspired layout', + }, + { + name: 'violet-gradient', + palette: 'deep purple (#7c3aed, #6d28d9) transitioning to lavender (#c4b5fd), pale rose (#fce7f3) background, soft white highlights', + style: 'smooth gradient fields, soft abstract blobs, gentle radial forms, dreamy overlapping circles', + mood: 'creative, forward-thinking, imaginative, innovative', + composition: 'radial soft composition, overlapping translucent shapes, dreamy depth', + }, + { + name: 'monochrome-sharp', + palette: 'near-black (#111827) and pure white (#ffffff) as primary, single bold vivid red (#ef4444) accent used sparingly on one element only', + style: 'bold stark geometry, high contrast hard edges, strong typographic-inspired forms, Swiss design influence', + mood: 'bold, editorial, authoritative, direct — like a magazine cover', + composition: 'strong visual tension, deliberate asymmetry, dominant single focal element', + }, + { + name: 'coral-horizon', + palette: 'coral (#f97316, #fb923c) as primary, dusty rose (#fda4af), soft warm grey (#e5e7eb) background, muted sage green accents', + style: 'rounded soft shapes, gentle gradient washes, warm abstract landscape forms, approachable curves', + mood: 'warm, accessible, optimistic, friendly — welcoming and human', + composition: 'wide horizon-like bands, soft rounded forms, gentle layered gradients', + }, +]; + +/** + * Детерминированный хэш articleId → индекс стиля. + */ +function pickStyleIndex(articleId) { + let hash = 0; + const s = String(articleId); + for (let i = 0; i < s.length; i++) { + hash = (hash * 31 + s.charCodeAt(i)) >>> 0; + } + return hash % COVER_STYLES.length; +} + +/** + * Промпт для обложки — стиль выбирается по articleId, содержание по теме статьи. + */ +function buildCoverPrompt({ title, tags = [], articleId = 0 }) { + const subject = title.replace(/[«»\":?!.]/g, '').slice(0, 100); const tagHint = tags.slice(0, 2).join(', '); - return `Abstract minimalist editorial cover illustration for an article titled "${subject}". + const styleIdx = pickStyleIndex(articleId); + const s = COVER_STYLES[styleIdx]; -Style: flat geometric shapes, smooth flowing curves, isometric or layered planes, vector-clean lines. -Color palette: emerald green (#10b981, #34d399) as primary accent, soft teal, warm off-white background (#fafaf9), subtle dark accents. -Mood: clean, modern, calm, intellectual — in the spirit of Stripe Press covers, Linear marketing illustrations, Anthropic brand visuals. -Composition: balanced, plenty of negative space, 16:9 wide format. -${tagHint ? `Theme cues (subtle, suggestive — not literal): ${tagHint}.` : ''} + return `Abstract editorial cover illustration for an article titled "${subject}". -Strictly: no text, no letters, no logos, no people's faces, no robots, no brains, no glowing nodes, no circuit boards.`; +Style: ${s.style}. +Color palette: ${s.palette}. +Mood: ${s.mood}. +Composition: ${s.composition}. Wide 16:9 format. +${tagHint ? `Theme cues (subtle, abstract — not literal): ${tagHint}.` : ''} + +Strictly: no text, no letters, no logos, no human faces, no robots, no brains, no glowing nodes, no circuit boards, no clocks, no screens.`; } /** @@ -38,9 +103,7 @@ Strictly: no text, no letters, no logos, no people's faces, no robots, no brains async function generateCoverViaResponses({ prompt }) { const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2'; // GPT-5 иногда не вызывает инструмент сама — даём явную инструкцию - const wrappedInput = `Use the image_generation tool to create the following illustration. Do not write any text response, only call the tool. - -${prompt}`; + const wrappedInput = `Use the image_generation tool to create the following illustration. Do not write any text response, only call the tool.\n\n${prompt}`; const res = await axios.post( `${config.ai.baseUrl}/responses`, @@ -100,7 +163,11 @@ async function generateCoverViaImagesEndpoint({ prompt }) { * Главный путь генерации. Использует /v1/responses, при ошибке падает в legacy. */ async function generateCover({ articleId, title, tags = [] }) { - const prompt = buildCoverPrompt({ title, tags }); + // Передаём articleId в buildCoverPrompt для детерминированного выбора стиля + const prompt = buildCoverPrompt({ title, tags, articleId }); + const styleIdx = pickStyleIndex(articleId); + const styleName = COVER_STYLES[styleIdx].name; + console.log(`[Cover] article=${articleId} style=${styleIdx}:${styleName}`); let img; let usedPath = 'responses'; @@ -148,7 +215,7 @@ async function generateCover({ articleId, title, tags = [] }) { await query(`UPDATE articles SET cover_url=$1, updated_at=NOW() WHERE id=$2`, [publicUrl, articleId]); - console.log(`[Cover] saved ${publicUrl} via ${usedPath} (${(img.bytes.length / 1024).toFixed(0)}KB original, ${optimizedSize ? (optimizedSize / 1024).toFixed(0) + 'KB optimized' : 'no opt'})`); + console.log(`[Cover] saved ${publicUrl} via ${usedPath} style=${styleName} (${(img.bytes.length / 1024).toFixed(0)}KB original, ${optimizedSize ? (optimizedSize / 1024).toFixed(0) + 'KB optimized' : 'no opt'})`); return publicUrl; } @@ -177,4 +244,4 @@ async function backfillCovers({ limit = 3 } = {}) { return { processed: rows.length, ok, fail, results }; } -module.exports = { generateCover, backfillCovers, buildCoverPrompt, UPLOADS_DIR }; +module.exports = { generateCover, backfillCovers, buildCoverPrompt, pickStyleIndex, COVER_STYLES, UPLOADS_DIR };