feat: 6 cover styles, deterministic pick by articleId
This commit is contained in:
+83
-16
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user