forked from admin/zeropost-engine
d1e6e2ef4a
- app_settings: AI_IMAGE_BASE_URL → https://plus.nyxos.workers.dev/v1 - app_settings: AI_IMAGE_FALLBACK_BASE_URL/API_KEY → aiguoguo (резерв) - config/index.js: загружает imageFallbackBaseUrl + imageFallbackApiKey - covers.js: generateCoverViaImageGenerations пробует Nyxos, при 5xx/timeout автоматически переключается на aiguoguo
499 lines
23 KiB
JavaScript
499 lines
23 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const axios = require('axios');
|
|
const config = require('../config');
|
|
const { query } = require('../config/db');
|
|
const localGen = require('./localCoverGenerator');
|
|
const aiUsage = require('./aiUsage');
|
|
|
|
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
|
|
|
|
// Опциональная оптимизация — если sharp есть, конвертим в WebP
|
|
let sharp = null;
|
|
try { sharp = require('sharp'); } catch {}
|
|
|
|
if (!fs.existsSync(UPLOADS_DIR)) {
|
|
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
|
}
|
|
|
|
/**
|
|
* 6 визуальных стилей обложек.
|
|
* Выбор детерминированный по articleId — одна статья всегда получает
|
|
* один стиль при регенерации, но разные статьи выглядят по-разному.
|
|
*/
|
|
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, содержание по теме статьи.
|
|
*/
|
|
/**
|
|
* Промпт для обложки.
|
|
* Приоритет: rubric.prompt → channelStyle.image_style → COVER_STYLES rotation.
|
|
* Рубрика полностью задаёт визуальный язык — ограничения внутри неё.
|
|
*/
|
|
function buildCoverPrompt({ title, tags = [], articleId = 0, channelStyle = null, rubric = null }) {
|
|
const subject = title.replace(/[«»\\\":?!.]/g, '').slice(0, 100);
|
|
const tagHint = tags.slice(0, 2).join(', ');
|
|
|
|
// Если рубрика выбрана — она задаёт весь визуальный язык
|
|
if (rubric?.prompt) {
|
|
return `${rubric.prompt}
|
|
|
|
Article subject: "${subject}".${tagHint ? ` Theme: ${tagHint}.` : ''}
|
|
Wide 16:9 format. No text, no letters, no logos, no identifiable real human faces.`;
|
|
}
|
|
|
|
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', 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: ${styleDesc}.
|
|
${paletteDesc ? `Color palette: ${paletteDesc}.` : ''}
|
|
Mood: ${moodDesc}.
|
|
Composition: ${compositionDesc}
|
|
${tagHint ? `Theme cues (subtle, abstract): ${tagHint}.` : ''}
|
|
${channelStyle?.image_prompt_instructions ? `\nChannel visual guidelines: ${channelStyle.image_prompt_instructions}` : ''}
|
|
|
|
Strictly: no text, no letters, no logos, no identifiable real human faces.`;
|
|
}
|
|
|
|
/**
|
|
* Генерирует картинку через /v1/responses + image_generation tool на GPT-5.
|
|
* Это работает даже когда /v1/images/generations отдаёт unavailable.
|
|
*/
|
|
async function generateCoverViaResponses({ prompt }) {
|
|
const model = config.ai.imageModelViaResponses || '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.\n\n${prompt}`;
|
|
|
|
const started = Date.now();
|
|
let res;
|
|
try {
|
|
res = await axios.post(
|
|
`${config.ai.baseUrl}/responses`,
|
|
{
|
|
model,
|
|
input: wrappedInput,
|
|
tools: [{ type: 'image_generation' }],
|
|
tool_choice: { type: 'image_generation' },
|
|
},
|
|
{
|
|
headers: { Authorization: `Bearer ${config.ai.apiKey}` },
|
|
timeout: 300_000, // GPT-5 reasoning + image — медленно, до 5 минут
|
|
}
|
|
);
|
|
} catch (err) {
|
|
aiUsage.log({
|
|
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
|
|
requestType: 'image_via_responses',
|
|
model, imageCount: 1,
|
|
durationMs: Date.now() - started, succeeded: false,
|
|
errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500),
|
|
}).catch(() => {});
|
|
throw err;
|
|
}
|
|
|
|
const output = res.data?.output || [];
|
|
const imgCall = output.find(o => o.type === 'image_generation_call');
|
|
if (!imgCall) {
|
|
aiUsage.log({
|
|
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
|
|
requestType: 'image_via_responses', model, imageCount: 1,
|
|
durationMs: Date.now() - started, succeeded: false,
|
|
errorMessage: 'No image_generation_call in response output',
|
|
}).catch(() => {});
|
|
throw new Error('No image_generation_call in response output');
|
|
}
|
|
if (!imgCall.result) {
|
|
aiUsage.log({
|
|
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
|
|
requestType: 'image_via_responses', model, imageCount: 1,
|
|
durationMs: Date.now() - started, succeeded: false,
|
|
errorMessage: `image_generation_call without result, status=${imgCall.status}`,
|
|
}).catch(() => {});
|
|
throw new Error(`image_generation_call without result, status=${imgCall.status}`);
|
|
}
|
|
|
|
aiUsage.log({
|
|
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
|
|
requestType: 'image_via_responses', model, imageCount: 1,
|
|
requestId: res.data?.id,
|
|
durationMs: Date.now() - started, succeeded: true,
|
|
}).catch(() => {});
|
|
|
|
const bytes = Buffer.from(imgCall.result, 'base64');
|
|
return {
|
|
bytes,
|
|
format: imgCall.output_format || 'png',
|
|
size: imgCall.size,
|
|
revisedPrompt: imgCall.revised_prompt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Старый путь — на случай если шлюз внезапно починят и захотим вернуться.
|
|
*/
|
|
async function generateCoverViaImagesEndpoint({ prompt }) {
|
|
const model = config.ai.models?.image || 'gpt-image-1';
|
|
const started = Date.now();
|
|
let res;
|
|
try {
|
|
res = await axios.post(
|
|
`${config.ai.baseUrl}/images/generations`,
|
|
{ model, prompt, n: 1, size: '1536x1024' },
|
|
{
|
|
headers: { Authorization: `Bearer ${config.ai.apiKey}` },
|
|
timeout: 120_000,
|
|
}
|
|
);
|
|
} catch (err) {
|
|
aiUsage.log({
|
|
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
|
|
requestType: 'image', model, imageCount: 1,
|
|
durationMs: Date.now() - started, succeeded: false,
|
|
errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500),
|
|
}).catch(() => {});
|
|
throw err;
|
|
}
|
|
const item = res.data?.data?.[0];
|
|
if (!item) {
|
|
aiUsage.log({
|
|
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
|
|
requestType: 'image', model, imageCount: 1,
|
|
durationMs: Date.now() - started, succeeded: false,
|
|
errorMessage: 'Empty image response',
|
|
}).catch(() => {});
|
|
throw new Error('Empty image response');
|
|
}
|
|
aiUsage.log({
|
|
provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl),
|
|
requestType: 'image', model, imageCount: 1,
|
|
durationMs: Date.now() - started, succeeded: true,
|
|
}).catch(() => {});
|
|
if (item.b64_json) return { bytes: Buffer.from(item.b64_json, 'base64'), format: 'png' };
|
|
if (item.url) {
|
|
const r = await axios.get(item.url, { responseType: 'arraybuffer', timeout: 60_000 });
|
|
return { bytes: Buffer.from(r.data), format: 'png' };
|
|
}
|
|
throw new Error('No image data');
|
|
}
|
|
|
|
/**
|
|
* Основной путь — /images/generations (aiguoguo199.com или любой OpenAI-совместимый).
|
|
* Более стабильный чем /responses, поддерживает gpt-image-1-mini/gpt-image-2.
|
|
*/
|
|
async function generateCoverViaImageGenerations({ prompt }) {
|
|
const model = config.ai.imageModel || 'gpt-image-2';
|
|
|
|
async function tryProvider(baseUrl, apiKey) {
|
|
const started = Date.now();
|
|
try {
|
|
const res = await axios.post(
|
|
`${baseUrl}/images/generations`,
|
|
{ model, prompt: prompt.slice(0, 4000), n: 1, size: '1024x1024', response_format: 'url' },
|
|
{ headers: { Authorization: `Bearer ${apiKey}` }, timeout: 120_000 }
|
|
);
|
|
const item = res.data?.data?.[0];
|
|
if (!item) throw new Error('No image data in response');
|
|
aiUsage.log({ provider: aiUsage.providerFromBaseUrl(baseUrl), requestType: 'image', model, imageCount: 1, durationMs: Date.now()-started, succeeded: true }).catch(() => {});
|
|
if (item.url) {
|
|
const r = await axios.get(item.url, { responseType: 'arraybuffer', timeout: 60_000 });
|
|
return { bytes: Buffer.from(r.data), format: 'png' };
|
|
}
|
|
if (item.b64_json) return { bytes: Buffer.from(item.b64_json, 'base64'), format: 'png' };
|
|
throw new Error('No url or b64_json in response');
|
|
} catch (err) {
|
|
aiUsage.log({ provider: aiUsage.providerFromBaseUrl(baseUrl), requestType: 'image', model, imageCount: 1, durationMs: Date.now()-started, succeeded: false, errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500) }).catch(() => {});
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Основной: Nyxos Plus
|
|
try {
|
|
return await tryProvider(config.ai.imageBaseUrl, config.ai.imageApiKey);
|
|
} catch (err) {
|
|
const status = err.response?.status;
|
|
if (!status || status >= 500) {
|
|
console.warn(`[Cover] primary failed (${status||'timeout'}), trying fallback aiguoguo...`);
|
|
return await tryProvider(config.ai.imageFallbackBaseUrl, config.ai.imageFallbackApiKey);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Резервный путь — Pollinations.AI (https://pollinations.ai).
|
|
* 100% бесплатно, без API ключа, без регистрации.
|
|
* GET запрос → JPEG обложка за ~1-2 секунды.
|
|
* Используется только когда aiprimetech.io недоступен.
|
|
*/
|
|
async function generateCoverViaPollinations({ prompt }) {
|
|
// Pollinations: простой GET по URL, сразу возвращает бинарный JPEG
|
|
const encoded = encodeURIComponent(prompt.slice(0, 1000)); // лимит на длину URL
|
|
const url = `https://image.pollinations.ai/prompt/${encoded}?width=1600&height=900&model=flux&nologo=true`;
|
|
const res = await axios.get(url, {
|
|
responseType: 'arraybuffer',
|
|
timeout: 90_000, // Pollinations иногда медленный при нагрузке
|
|
headers: { 'User-Agent': 'ZeroPost/1.0 blog-cover-generator' },
|
|
});
|
|
if (!res.data || res.data.byteLength < 5000) {
|
|
throw new Error(`Pollinations returned too small response: ${res.data?.byteLength} bytes`);
|
|
}
|
|
return {
|
|
bytes: Buffer.from(res.data),
|
|
format: 'jpg',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Главный путь генерации. Использует /v1/responses, при ошибке падает в legacy.
|
|
*/
|
|
|
|
/**
|
|
* Выбирает наиболее подходящую рубрику для обложки статьи.
|
|
* Дешёвый haiku-вызов: ~50 токенов. При ошибке — случайная рубрика.
|
|
*/
|
|
async function selectRubric({ title, tags = [], rubrics }) {
|
|
if (!rubrics || rubrics.length === 0) return null;
|
|
if (rubrics.length === 1) return rubrics[0];
|
|
|
|
const rubricList = rubrics.map((r, i) => `${i}. ${r.id}: ${r.desc}`).join('\n');
|
|
const userMsg = `Article title: "${title}"\nTags: ${tags.join(', ') || 'none'}\n\nRubrics:\n${rubricList}\n\nRespond with ONLY the index number (0-${rubrics.length - 1}) of the best matching rubric.`;
|
|
|
|
try {
|
|
const res = await axios.post(
|
|
`${config.ai.baseUrl}/chat/completions`,
|
|
{
|
|
model: config.ai.models.post || 'claude-haiku-4-5-20251001',
|
|
max_tokens: 5,
|
|
temperature: 0,
|
|
messages: [
|
|
{ role: 'system', content: 'You select a visual style for article cover images. Reply with only a single digit index number.' },
|
|
{ role: 'user', content: userMsg },
|
|
],
|
|
},
|
|
{ headers: { Authorization: `Bearer ${config.ai.apiKey}` }, timeout: 10000 }
|
|
);
|
|
const raw = res.data?.choices?.[0]?.message?.content?.trim() || '0';
|
|
const idx = parseInt(raw.replace(/\D/g, '')) || 0;
|
|
const safeIdx = Math.min(Math.max(idx, 0), rubrics.length - 1);
|
|
return rubrics[safeIdx];
|
|
} catch (err) {
|
|
console.warn('[Cover] selectRubric failed, using random:', err.message.slice(0, 80));
|
|
return rubrics[Math.floor(Math.random() * rubrics.length)];
|
|
}
|
|
}
|
|
|
|
async function generateCover({ articleId, title, tags = [], channelId = null }) {
|
|
// Подгружаем настройки канала включая рубрики
|
|
let channelStyle = null;
|
|
if (channelId) {
|
|
try {
|
|
const r = await query('SELECT image_style, image_palette, image_custom_colors, image_prompt_instructions, image_rubrics 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);
|
|
}
|
|
}
|
|
|
|
// Выбираем рубрику если они заданы
|
|
let selectedRubric = null;
|
|
let styleName;
|
|
const rubrics = channelStyle?.image_rubrics;
|
|
if (Array.isArray(rubrics) && rubrics.length > 0) {
|
|
selectedRubric = await selectRubric({ title, tags, rubrics });
|
|
styleName = selectedRubric?.id || 'rubric';
|
|
console.log(`[Cover] article=${articleId} channel=${channelId} rubric=${styleName}`);
|
|
} else {
|
|
const styleIdx = pickStyleIndex(articleId);
|
|
styleName = channelStyle?.image_style || COVER_STYLES[styleIdx].name;
|
|
console.log(`[Cover] article=${articleId} channel=${channelId || 'none'} style=${styleName}`);
|
|
}
|
|
|
|
const prompt = buildCoverPrompt({ title, tags, articleId, channelStyle, rubric: selectedRubric });
|
|
|
|
let img;
|
|
let usedPath = 'images-generations';
|
|
|
|
// Цепочка: 1) aiguoguo /images/generations (2 попытки) → 2) aiprimetech /responses → 3) legacy → 4) local SVG
|
|
try {
|
|
try {
|
|
try {
|
|
img = await generateCoverViaImageGenerations({ prompt });
|
|
} catch (err) {
|
|
// Ретрай только при временных ошибках провайдера (5xx)
|
|
const status = err.response?.status;
|
|
if (status >= 500 && status < 600) {
|
|
console.warn(`[Cover] /images/generations ${status} — retry in 12s...`);
|
|
await new Promise(r => setTimeout(r, 12000));
|
|
img = await generateCoverViaImageGenerations({ prompt });
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn(`[Cover] /images/generations failed: ${(err.response?.data?.error?.message || err.message).slice(0, 150)}`);
|
|
try {
|
|
img = await generateCoverViaResponses({ prompt });
|
|
usedPath = 'responses';
|
|
} catch (err2) {
|
|
console.warn(`[Cover] /responses failed: ${(err2.response?.data?.error?.message || err2.message).slice(0, 150)}`);
|
|
try {
|
|
img = await generateCoverViaImagesEndpoint({ prompt });
|
|
usedPath = 'images-legacy';
|
|
} catch (err3) {
|
|
console.warn(`[Cover] legacy failed: ${(err3.response?.data?.error?.message || err3.message).slice(0, 150)}`);
|
|
throw new Error('all_external_failed');
|
|
}
|
|
}
|
|
}
|
|
} catch (outerErr) {
|
|
// Все внешние API упали — local SVG
|
|
console.log(`[Cover] article=${articleId} → local SVG (all external APIs unavailable)`);
|
|
const localUrl = await localGen.generateLocalCover({ articleId, title, category: tags?.[0] || '' });
|
|
await query('UPDATE articles SET cover_url=$1, updated_at=NOW() WHERE id=$2', [localUrl, articleId]);
|
|
return localUrl;
|
|
}
|
|
|
|
// Сохраняем оригинал
|
|
const tsKey = `${articleId}-${Date.now()}`;
|
|
const ext = img.format || 'png';
|
|
const originalName = `cover-${tsKey}.${ext}`;
|
|
const originalPath = path.join(UPLOADS_DIR, originalName);
|
|
fs.writeFileSync(originalPath, img.bytes);
|
|
|
|
// Оптимизация — если sharp есть, делаем WebP в подходящем размере
|
|
let publicUrl = `/uploads/${originalName}`;
|
|
let optimizedSize = null;
|
|
if (sharp) {
|
|
try {
|
|
const webpName = `cover-${tsKey}.webp`;
|
|
const webpPath = path.join(UPLOADS_DIR, webpName);
|
|
await sharp(img.bytes)
|
|
.resize(1600, null, { withoutEnlargement: true })
|
|
.webp({ quality: 84 })
|
|
.toFile(webpPath);
|
|
const stat = fs.statSync(webpPath);
|
|
optimizedSize = stat.size;
|
|
publicUrl = `/uploads/${webpName}`;
|
|
} catch (e) {
|
|
console.warn(`[Cover] sharp optimization skipped: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
await query(`UPDATE articles SET cover_url=$1, updated_at=NOW() WHERE id=$2`, [publicUrl, articleId]);
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Дофоновая попытка сгенерировать обложки для статей без cover_url.
|
|
*/
|
|
async function backfillCovers({ limit = 3 } = {}) {
|
|
const { rows } = await query(
|
|
`SELECT id, title, tags FROM articles
|
|
WHERE cover_url IS NULL AND status='published'
|
|
ORDER BY published_at DESC LIMIT $1`,
|
|
[limit]
|
|
);
|
|
let ok = 0, fail = 0;
|
|
const results = [];
|
|
for (const a of rows) {
|
|
try {
|
|
const url = await generateCover({ articleId: a.id, title: a.title, tags: a.tags || [] });
|
|
ok++;
|
|
results.push({ id: a.id, status: 'ok', url });
|
|
} catch (err) {
|
|
fail++;
|
|
results.push({ id: a.id, status: 'fail', error: err.message.slice(0, 150) });
|
|
}
|
|
}
|
|
return { processed: rows.length, ok, fail, results };
|
|
}
|
|
|
|
module.exports = { generateCover, backfillCovers, buildCoverPrompt, pickStyleIndex, COVER_STYLES, UPLOADS_DIR };
|