feat: image generation через GPT-5 /v1/responses + image_generation tool

Старый endpoint /v1/images/generations на gpt-image-* возвращает temporarily unavailable
уже несколько часов, а тот же ключ через /v1/responses на GPT-5 успешно генерирует картинки.

- covers.js полностью переписан: generateCoverViaResponses как основной путь
- tool_choice: image_generation — заставляем модель ВСЕГДА вызывать инструмент
- wrappedInput: явная подсказка чтобы GPT не отвечала текстом
- legacy fallback: если /responses упал — пробуем старый /v1/images/generations
- sharp оптимизация: оригинал PNG → WebP 1600w q84 (уменьшение в ~30 раз)
- timeout до 5 минут — GPT-5 с reasoning + image это долго
This commit is contained in:
Alexey Pavlov
2026-05-31 11:14:36 +03:00
parent 116f15bf21
commit 5472603a85
3 changed files with 653 additions and 53 deletions
+123 -50
View File
@@ -6,84 +6,154 @@ const { query } = require('../config/db');
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 });
}
/**
* Генерирует промпт для обложки на основе темы и тегов.
* Стиль фиксированный — абстрактная геометрия в emerald-палитре.
* Промпт для обложки в стиле сайта.
*/
function buildCoverPrompt({ title, tags = [] }) {
const subject = title.replace(/[«»":?!.]/g, '').slice(0, 80);
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}".
Style: flat geometric shapes, smooth flowing curves, isometric or layered planes.
Color palette: emerald green (#10b981, #34d399), soft teal, warm off-white background (#fafaf9), subtle dark accents.
Mood: clean, modern, calm, intellectual.
Composition: balanced, plenty of negative space, no text, no letters, no people, no logos.
${tagHint ? `Theme cues: ${tagHint}.` : ''}
High quality vector-like editorial illustration in the style of Stripe Press, Linear, Notion blog covers.`;
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}.` : ''}
Strictly: no text, no letters, no logos, no people's faces, no robots, no brains, no glowing nodes, no circuit boards.`;
}
/**
* Запрашивает картинку у gpt-image модели, сохраняет файл локально.
* Возвращает публичный URL.
* Генерирует картинку через /v1/responses + image_generation tool на GPT-5.
* Это работает даже когда /v1/images/generations отдаёт unavailable.
*/
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 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.imageApiKey}` },
timeout: 300_000, // GPT-5 reasoning + image — медленно, до 5 минут
}
);
const output = res.data?.output || [];
const imgCall = output.find(o => o.type === 'image_generation_call');
if (!imgCall) {
throw new Error('No image_generation_call in response output');
}
if (!imgCall.result) {
throw new Error(`image_generation_call without result, status=${imgCall.status}`);
}
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 res = await axios.post(
`${config.ai.baseUrl}/images/generations`,
{ model, prompt, n: 1, size: '1536x1024' },
{
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
timeout: 120_000,
}
);
const item = res.data?.data?.[0];
if (!item) throw new Error('Empty image response');
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');
}
/**
* Главный путь генерации. Использует /v1/responses, при ошибке падает в legacy.
*/
async function generateCover({ articleId, title, tags = [] }) {
const prompt = buildCoverPrompt({ title, tags });
const model = config.ai.models.image || 'gpt-image-1';
let imgData;
let img;
let usedPath = 'responses';
try {
const res = await axios.post(
`${config.ai.baseUrl}/images/generations`,
{
model,
prompt,
n: 1,
size: '1536x1024',
},
{
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
timeout: 120000,
}
);
imgData = res.data.data?.[0];
img = await generateCoverViaResponses({ prompt });
} catch (err) {
const msg = err.response?.data?.error?.message || err.message;
console.warn(`[Cover] generation failed for article ${articleId}:`, msg.slice(0, 200));
throw new Error(msg);
console.warn(`[Cover] /responses path failed: ${msg.slice(0, 200)}`);
// Пробуем legacy
try {
img = await generateCoverViaImagesEndpoint({ prompt });
usedPath = 'images-legacy';
} catch (err2) {
const msg2 = err2.response?.data?.error?.message || err2.message;
console.warn(`[Cover] legacy path failed too: ${msg2.slice(0, 200)}`);
throw new Error(`Both image paths failed: ${msg}`);
}
}
if (!imgData) throw new Error('Empty image response');
// Сохраняем оригинал
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);
// Получаем bytes — либо из b64, либо скачиваем по URL
let bytes;
if (imgData.b64_json) {
bytes = Buffer.from(imgData.b64_json, 'base64');
} else if (imgData.url) {
const resp = await axios.get(imgData.url, { responseType: 'arraybuffer', timeout: 60000 });
bytes = Buffer.from(resp.data);
} else {
throw new Error('No image data in response');
// Оптимизация — если 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}`);
}
}
const filename = `cover-${articleId}-${Date.now()}.png`;
const filepath = path.join(UPLOADS_DIR, filename);
fs.writeFileSync(filepath, bytes);
const publicUrl = `/uploads/${filename}`;
await query(`UPDATE articles SET cover_url=$1, updated_at=NOW() WHERE id=$2`, [publicUrl, articleId]);
console.log(`[Cover] saved ${publicUrl} (${(bytes.length / 1024).toFixed(0)} KB)`);
console.log(`[Cover] saved ${publicUrl} via ${usedPath} (${(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(
@@ -93,15 +163,18 @@ async function backfillCovers({ limit = 3 } = {}) {
[limit]
);
let ok = 0, fail = 0;
const results = [];
for (const a of rows) {
try {
await generateCover({ articleId: a.id, title: a.title, tags: a.tags || [] });
const url = await generateCover({ articleId: a.id, title: a.title, tags: a.tags || [] });
ok++;
} catch {
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 };
return { processed: rows.length, ok, fail, results };
}
module.exports = { generateCover, backfillCovers, buildCoverPrompt, UPLOADS_DIR };