feat: transformPost (7 actions), post image generation with style/palette, topics ideas endpoint

This commit is contained in:
Alexey Pavlov
2026-05-31 17:32:38 +03:00
parent 53d596ca2e
commit 2137a92b28
3 changed files with 229 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const config = require('../config');
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 imageStyle = IMAGE_STYLES[style.image_style] || 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}.
Composition: 16:9 wide format, balanced, suitable for social media.
Strictly: no text, no letters, no logos, no faces of real people.`;
const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2';
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`,
{
model,
input: wrappedInput,
tools: [{ type: 'image_generation' }],
tool_choice: { type: 'image_generation' },
},
{
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
timeout: 300_000,
}
);
const output = res.data?.output || [];
const imgCall = output.find(o => o.type === 'image_generation_call');
if (!imgCall?.result) throw new Error('No image generated');
const bytes = Buffer.from(imgCall.result, 'base64');
const tsKey = `post-${channel.id}-${Date.now()}`;
const ext = imgCall.output_format || '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 };