// Генерирует полный набор поз персонажа Зеро. // Картинки сохраняются в /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); });