Files
postcast-engine/src/services/postImages.js
T
Ник (Claude) c7f0b3ed4d fix: getChannel arg order, postImages via Nyxos /images/generations
generate.js: getChannel(userId, channelId) → getChannel(channelId, userId)
channels.js: getChannel alias → getFullChannel
postImages.js: убран /responses + gpt-5.5 (не работал на aiprimetech),
  заменён на Nyxos /images/generations с fallback на aiguoguo
2026-06-10 17:45:18 +03:00

148 lines
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const config = require('../config');
const aiUsage = require('./aiUsage');
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
let sharp = null;
try { sharp = require('sharp'); } catch {}
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true });
/**
* Стили картинок к постам — словарь для перевода в промпт.
*/
const IMAGE_STYLES = {
'realistic-photo': {
label: 'Реалистичное фото',
prompt: 'photorealistic, high-quality photography, natural lighting, professional camera shot, sharp focus, realistic textures',
},
'flat-illustration': {
label: 'Плоская иллюстрация',
prompt: 'flat vector illustration, clean geometric shapes, modern editorial style, smooth gradients, minimal',
},
'3d-render': {
label: '3D рендер',
prompt: '3D render, soft studio lighting, isometric perspective, smooth surfaces, modern materials, Pixar-like quality',
},
'cartoon': {
label: 'Мультяшный',
prompt: 'cartoon illustration, bold outlines, vibrant colors, expressive characters, comic book style',
},
'minimal': {
label: 'Минимализм',
prompt: 'extremely minimalist composition, single focal element, lots of negative space, monochrome or duotone',
},
'abstract': {
label: 'Абстракция',
prompt: 'abstract artwork, layered shapes, conceptual composition, no literal objects, mood and texture focused',
},
'sketch': {
label: 'Эскиз / скетч',
prompt: 'hand-drawn sketch style, pencil and ink, loose lines, notebook page aesthetic',
},
'cyberpunk': {
label: 'Киберпанк',
prompt: 'cyberpunk aesthetic, neon lights, futuristic city, dark atmospheric mood, blade runner vibe',
},
};
const IMAGE_PALETTES = {
'auto': '',
'dark': 'dark color palette, deep blues, blacks, subtle highlights',
'light': 'light color palette, soft whites, pastels, airy mood',
'warm': 'warm color palette, oranges, reds, golden tones',
'cool': 'cool color palette, blues, teals, purples',
'mono': 'monochromatic palette, single hue with shades',
'vibrant': 'vibrant saturated colors, high energy palette',
};
/**
* Генерирует картинку к посту через GPT-5 /v1/responses + image_generation.
*/
async function generatePostImage({ post, channel, style = {} }) {
// Если задано несколько стилей через запятую — случайно выбираем один
const styleList = (style.image_style || 'flat-illustration')
.split(',').map(s => s.trim()).filter(s => s && s !== 'auto');
const pickedStyle = styleList[Math.floor(Math.random() * styleList.length)] || 'flat-illustration';
const imageStyle = IMAGE_STYLES[pickedStyle] || IMAGE_STYLES['flat-illustration'];
const palette = style.image_custom_colors
? `custom brand palette: ${style.image_custom_colors}`
: IMAGE_PALETTES[style.image_palette] || '';
// Извлекаем суть поста для промпта (первые 250 символов)
const postExcerpt = post.replace(/[#*_`>]/g, '').slice(0, 250);
const prompt = `Editorial illustration for a social media post. Topic essence: "${postExcerpt}"
Style: ${imageStyle.prompt}.
${palette ? `Color palette: ${palette}.` : ''}
Channel context: ${channel.niche || channel.name}.
${style.image_prompt_instructions ? `\nChannel visual guidelines: ${style.image_prompt_instructions}` : ''}
Composition: 16:9 wide format, balanced, suitable for social media.
Strictly: no text, no letters, no logos, no faces of real people.`;
// Используем Nyxos /images/generations (первичный провайдер)
// с fallback на aiguoguo — тот же путь что для обложек статей
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');
aiUsage.log({ provider: aiUsage.providerFromBaseUrl(baseUrl), requestType: 'image', model, imageCount: 1, meta: { channel_id: channel.id }, succeeded: true }).catch(() => {});
if (item.url) {
const r = await axios.get(item.url, { responseType: 'arraybuffer', timeout: 60_000 });
return Buffer.from(r.data);
}
if (item.b64_json) return Buffer.from(item.b64_json, 'base64');
throw new Error('No url or b64_json');
} catch (err) {
aiUsage.log({ provider: aiUsage.providerFromBaseUrl(baseUrl), requestType: 'image', model, imageCount: 1, meta: { channel_id: channel.id }, succeeded: false, errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500) }).catch(() => {});
throw err;
}
}
let bytes;
try {
bytes = await tryProvider(config.ai.imageBaseUrl, config.ai.imageApiKey);
} catch (err) {
const status = err.response?.status;
if (!status || status >= 500) {
console.warn('[postImages] primary failed, trying fallback...');
bytes = await tryProvider(config.ai.imageFallbackBaseUrl, config.ai.imageFallbackApiKey);
} else { throw err; }
}
const tsKey = `post-${channel.id}-${Date.now()}`;
const ext = 'png';
// Оптимизация через sharp если есть
let publicUrl;
if (sharp) {
const webpName = `${tsKey}.webp`;
await sharp(bytes)
.resize(1600, null, { withoutEnlargement: true })
.webp({ quality: 84 })
.toFile(path.join(UPLOADS_DIR, webpName));
publicUrl = `/uploads/${webpName}`;
} else {
const name = `${tsKey}.${ext}`;
fs.writeFileSync(path.join(UPLOADS_DIR, name), bytes);
publicUrl = `/uploads/${name}`;
}
return { url: publicUrl, style: style.image_style, palette: style.image_palette };
}
module.exports = { generatePostImage, IMAGE_STYLES, IMAGE_PALETTES };