a370b8f7d8
- Персонаж Зеро: 23 позы (zeroCharacter.js), скрипты генерации - Auto-publish статей в TG: multipart upload, кнопки, режим alternating Zero/cover - Fallback цепочка обложек: aiprimetech gpt-5.5 → Pollinations → local SVG (6 палитр) - Auto-series: Claude haiku определяет серию для каждой статьи автоматически - Channel stats: подписчики, история, delta 24h/7d - Photo-search: Yandex API, профили доменов, Redis лимиты - Scheduled posts runner: backfill, preview, queue, cancel - promptBuilder: author_persona Зеро, голос от первого лица - Fixes: dollar-placeholder bugs в PATCH channels/autogen, listArticles фильтры - AI model: gpt-5.5 для image generation
152 lines
8.1 KiB
JavaScript
152 lines
8.1 KiB
JavaScript
// Генерирует полный набор поз персонажа Зеро.
|
|
// Картинки сохраняются в /var/www/zeropost-uploads/zero-{name}.webp (без timestamp — фиксированные имена)
|
|
// чтобы engine мог потом выбирать по имени.
|
|
//
|
|
// Запуск: cd /var/www/zeropost-engine && node scripts/generate-zero-poses.js
|
|
// Прогресс пишется в /tmp/zero-poses.log
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const axios = require('axios');
|
|
|
|
const ROOT = '/var/www/zeropost-engine';
|
|
process.chdir(ROOT);
|
|
require('dotenv').config({ path: path.join(ROOT, '.env') });
|
|
const config = require(path.join(ROOT, 'src/config'));
|
|
|
|
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
|
|
let sharp = null;
|
|
try { sharp = require('sharp'); } catch {}
|
|
|
|
// Базовое описание персонажа — повторяется в каждом промпте.
|
|
const CHARACTER_BASE = `
|
|
Character: a small, friendly mascot named Zero.
|
|
Body: a soft rounded square shape (like a plump pixel or chubby tetris block).
|
|
Color: bright emerald green (#10b981) with slight gradient to teal, soft and matte.
|
|
Face: two simple round black dot eyes, a small mouth expressing the current emotion.
|
|
Personality: friendly, curious, enthusiastic, a bit nerdy.
|
|
Style: clean modern vector illustration, flat design with soft shading, no outlines, consistent with previous Zero illustrations.
|
|
Background: warm off-white (#fafaf9) with subtle geometric shapes in light teal/emerald, like Notion/Linear/Anthropic editorial illustrations.
|
|
Composition: 1:1 square format with comfortable padding, character is the main focus.
|
|
Strictly: no text, no letters, no logos, no humans, no realistic robots, no glowing nodes, no circuits.
|
|
`.trim();
|
|
|
|
const POSES = [
|
|
// Категорийные
|
|
{ name: 'tools', desc: 'Zero stands next to a small open toolbox filled with stylized abstract tools (wrench, paintbrush, screwdriver shapes — all geometric). Curious smile, leaning slightly forward. Represents the AI Tools category.' },
|
|
{ name: 'lock', desc: 'Zero stands next to a large stylized padlock (closed, in soft red/coral accent color). Serious focused expression, alert eyes. Small shield shape floats nearby. Represents the cybersecurity category.' },
|
|
{ name: 'gears', desc: 'Zero next to two large interlocking gears that turn together (gears in soft amber/cream color). Content smile, slight tilt of body, gears form a small mechanical system. Represents the automation category.' },
|
|
|
|
// Эмоциональные
|
|
{ name: 'eureka', desc: 'Zero has just discovered something exciting. One small stick arm raised in the air with a soft glowing dot/lightbulb shape above the head. Eyes wide and sparkly, big enthusiastic smile. Stylized geometric sparkles around.' },
|
|
{ name: 'confused', desc: 'Zero scratches the top of its head with one small stick arm. Eyes slightly squinted, mouth a small flat wavy line, tiny question marks floating to the side. Stuck on a problem.' },
|
|
{ name: 'facepalm', desc: 'Zero covers part of its face with one small stick arm. Eyes closed or downcast, mouth a flat resigned line. Slightly slumped posture. The moment when something goes wrong.' },
|
|
{ name: 'victory', desc: 'Zero jumps with both small stick arms raised triumphantly. Eyes shining bright, huge happy grin. Small geometric celebration confetti or rays around. It worked!' },
|
|
{ name: 'tired', desc: 'Zero sits on the floor holding a steaming coffee cup with both stick arms. Slightly droopy eyes, small content but tired smile. Maybe small zZz shape floating above. End of a long debugging session.' },
|
|
|
|
// Активности
|
|
{ name: 'reading', desc: 'Zero sits cross-legged reading an open book (geometric book shape in cream color). Eyes focused on the pages, peaceful happy expression, small reading-glasses shape floating optional. Quiet study moment.' },
|
|
{ name: 'magnifier', desc: 'Zero holds up a large magnifying glass with one stick arm, examining something carefully. One eye looks through the magnifier (enlarged through the lens, dot-eye gets bigger). Investigative curious expression.' },
|
|
{ name: 'chart', desc: 'Zero stands next to a small bar chart or upward line graph (geometric, emerald/teal bars). One stick arm raised pointing at the rising line. Pleased confident smile. Data-driven moment.' },
|
|
{ name: 'meditate', desc: 'Zero sits cross-legged in a meditation pose, eyes peacefully closed (small curved lines instead of dots), small content smile. Subtle aura/circle around it in soft teal. Reflective moment.' },
|
|
{ name: 'present', desc: 'Zero stands next to a small whiteboard/easel with simple abstract diagram (lines, dots, geometric shapes — no text or letters). One stick arm pointing at the board with a small pointer stick. Teaching pose, friendly explanatory expression.' },
|
|
];
|
|
|
|
const ATTEMPTS = 5;
|
|
const LOG_PATH = '/tmp/zero-poses.log';
|
|
|
|
function log(msg) {
|
|
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
fs.appendFileSync(LOG_PATH, line);
|
|
console.log(msg);
|
|
}
|
|
|
|
async function generateOne(prompt, attempt = 1) {
|
|
const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2';
|
|
const wrapped = `Use the image_generation tool to create the following illustration. Do not write any text response, only call the tool.\n\n${prompt}`;
|
|
try {
|
|
const res = await axios.post(
|
|
`${config.ai.baseUrl}/responses`,
|
|
{
|
|
model, input: wrapped,
|
|
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 || !imgCall.result) {
|
|
if (attempt < ATTEMPTS) {
|
|
log(` retry ${attempt + 1}/${ATTEMPTS} (no image_generation_call)`);
|
|
return generateOne(prompt, attempt + 1);
|
|
}
|
|
throw new Error(`No image after ${ATTEMPTS} attempts`);
|
|
}
|
|
return Buffer.from(imgCall.result, 'base64');
|
|
} catch (err) {
|
|
if (attempt < ATTEMPTS) {
|
|
const msg = err.response?.data?.error?.message || err.message;
|
|
log(` retry ${attempt + 1}/${ATTEMPTS} (${msg.slice(0, 60)})`);
|
|
return generateOne(prompt, attempt + 1);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function processAndSave(bytes, name) {
|
|
const outPath = path.join(UPLOADS_DIR, `zero-${name}.webp`);
|
|
if (sharp) {
|
|
await sharp(bytes)
|
|
.resize(1024, 1024, { fit: 'cover' })
|
|
.webp({ quality: 88 })
|
|
.toFile(outPath);
|
|
} else {
|
|
fs.writeFileSync(outPath, bytes);
|
|
}
|
|
return outPath;
|
|
}
|
|
|
|
(async () => {
|
|
fs.writeFileSync(LOG_PATH, '');
|
|
log(`[zero-poses] starting generation of ${POSES.length} poses`);
|
|
const startTime = Date.now();
|
|
const results = { done: [], failed: [] };
|
|
|
|
for (const p of POSES) {
|
|
// Skip if already exists
|
|
const expected = path.join(UPLOADS_DIR, `zero-${p.name}.webp`);
|
|
if (fs.existsSync(expected)) {
|
|
log(`[${p.name}] already exists, skipping`);
|
|
results.done.push({ name: p.name, path: expected, skipped: true });
|
|
continue;
|
|
}
|
|
|
|
const tStart = Date.now();
|
|
try {
|
|
log(`[${p.name}] starting...`);
|
|
const prompt = `${CHARACTER_BASE}\n\nPose: ${p.desc}`;
|
|
const bytes = await generateOne(prompt);
|
|
const out = await processAndSave(bytes, p.name);
|
|
const elapsed = ((Date.now() - tStart) / 1000).toFixed(0);
|
|
log(`[${p.name}] ✓ saved → ${out} (${elapsed}s)`);
|
|
results.done.push({ name: p.name, path: out });
|
|
} catch (err) {
|
|
const msg = err.response?.data?.error?.message || err.message;
|
|
log(`[${p.name}] ✗ FAILED: ${msg}`);
|
|
results.failed.push({ name: p.name, error: msg });
|
|
}
|
|
}
|
|
|
|
const totalMin = ((Date.now() - startTime) / 60000).toFixed(1);
|
|
log(`[zero-poses] DONE in ${totalMin}min. Done: ${results.done.length}/${POSES.length}, failed: ${results.failed.length}`);
|
|
fs.writeFileSync('/tmp/zero-poses-result.json', JSON.stringify(results, null, 2));
|
|
process.exit(0);
|
|
})().catch(e => {
|
|
log(`[zero-poses] FATAL: ${e.message}`);
|
|
process.exit(1);
|
|
});
|