feat: 6 cover styles, deterministic pick by articleId

This commit is contained in:
Alexey Pavlov
2026-05-31 13:43:18 +03:00
parent 5472603a85
commit 17bc923c59
+83 -16
View File
@@ -15,20 +15,85 @@ if (!fs.existsSync(UPLOADS_DIR)) {
} }
/** /**
* Промпт для обложки в стиле сайта. * 6 визуальных стилей обложек.
* Выбор детерминированный по articleId — одна статья всегда получает
* один стиль при регенерации, но разные статьи выглядят по-разному.
*/ */
function buildCoverPrompt({ title, tags = [] }) { const COVER_STYLES = [
const subject = title.replace(/[«»":?!.]/g, '').slice(0, 100); {
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(', '); 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. return `Abstract editorial cover illustration for an article titled "${subject}".
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}.` : ''}
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 }) { async function generateCoverViaResponses({ prompt }) {
const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2'; const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2';
// GPT-5 иногда не вызывает инструмент сама — даём явную инструкцию // GPT-5 иногда не вызывает инструмент сама — даём явную инструкцию
const wrappedInput = `Use the image_generation tool to create the following illustration. Do not write any text response, only call the tool. 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}`;
${prompt}`;
const res = await axios.post( const res = await axios.post(
`${config.ai.baseUrl}/responses`, `${config.ai.baseUrl}/responses`,
@@ -100,7 +163,11 @@ async function generateCoverViaImagesEndpoint({ prompt }) {
* Главный путь генерации. Использует /v1/responses, при ошибке падает в legacy. * Главный путь генерации. Использует /v1/responses, при ошибке падает в legacy.
*/ */
async function generateCover({ articleId, title, tags = [] }) { 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 img;
let usedPath = 'responses'; 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]); 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; return publicUrl;
} }
@@ -177,4 +244,4 @@ async function backfillCovers({ limit = 3 } = {}) {
return { processed: rows.length, ok, fail, results }; return { processed: rows.length, ok, fail, results };
} }
module.exports = { generateCover, backfillCovers, buildCoverPrompt, UPLOADS_DIR }; module.exports = { generateCover, backfillCovers, buildCoverPrompt, pickStyleIndex, COVER_STYLES, UPLOADS_DIR };