// Badge cards: the AI artwork for each achievement (public/badges/badge-.jpg, 1080x1080, a blank // gold ribbon at ribbonY) with the hunter's name drawn onto the ribbon by ffmpeg on the server, so the // card looks the same whatever device unlocked it (the IAP lesson: phones ran out of canvas memory). // Rendered cards are cached on the volume: DATA_DIR/badges/-.jpg. 'use strict'; const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); let PUBLIC_DIR = null, CACHE_DIR = null, FONT; // per-badge ribbon position (fraction of height); tuned to the artwork const ART = { first: 0.734, hunter: 0.753, tracker: 0.715, sweep: 0.689, lucky: 0.753, streak: 0.702, top: 0.725 }; function init(opts) { PUBLIC_DIR = opts.publicDir; CACHE_DIR = path.join(opts.dataDir, 'badges'); try { fs.mkdirSync(CACHE_DIR, { recursive: true }); } catch (e) {} } function artFile(id) { return path.join(PUBLIC_DIR, 'badges', 'badge-' + id + '.jpg'); } function hasArt(id) { return fs.existsSync(artFile(id)); } function font() { if (FONT !== undefined) return FONT; FONT = process.env.BADGE_FONT || null; if (FONT) return FONT; const walk = d => { let ents = []; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch (e) { return null; } for (const e of ents) { const f = path.join(d, e.name); if (e.isDirectory()) { const r = walk(f); if (r) return r; } else if (/bold\.ttf$/i.test(e.name) || /Bold\.ttf$/.test(e.name)) return f; } return null; }; for (const d of ['/usr/share/fonts', '/usr/local/share/fonts']) { const r = walk(d); if (r) { FONT = r; break; } } if (!FONT && process.platform === 'win32' && fs.existsSync('C:/Windows/Fonts/arialbd.ttf')) FONT = 'C:/Windows/Fonts/arialbd.ttf'; return FONT; } const ffText = t => String(t).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/:/g, '\\:').replace(/%/g, '%%'); const safe = s => String(s || '').toLowerCase().replace(/[^a-z0-9_.-]/g, '').slice(0, 40); // the card for one hunter + badge: cached JPEG path, or null when ffmpeg / font / art is missing function render(id, who) { return new Promise(resolve => { if (!(id in ART) || !hasArt(id)) return resolve(null); const F = font(); if (!F) return resolve(null); const out = path.join(CACHE_DIR, safe(who) + '-' + id + '.jpg'); if (fs.existsSync(out)) return resolve(out); const name = String(who || '').slice(0, 28); const winPath = /:/.test(F); const fontFile = winPath ? path.basename(F) : F; // 1080-wide art: 58px bold gold text with a dark outline, centred on the ribbon const vf = 'drawtext=fontfile=' + fontFile + ":text='" + ffText(name) + "':fontcolor=#1a0f3d:fontsize=60:borderw=3:bordercolor=0xfff1c2@0.85:x=(w-text_w)/2:y=" + ART[id] + '*h-text_h/2'; const p = spawn('ffmpeg', ['-v', 'error', '-y', '-i', artFile(id), '-vf', vf, '-frames:v', '1', '-q:v', '3', out], { stdio: ['ignore', 'ignore', 'pipe'], cwd: winPath ? path.dirname(F) : undefined }); let err = ''; p.stderr.on('data', c => { err += c; }); p.on('error', () => resolve(null)); p.on('close', code => { if (code === 0 && fs.existsSync(out)) resolve(out); else { console.error('badge render failed', code, err.slice(0, 200)); resolve(null); } }); }); } function available() { return new Promise(resolve => { const p = spawn('ffmpeg', ['-version'], { stdio: 'ignore' }); p.on('error', () => resolve(false)); p.on('close', c => resolve(c === 0 && !!font())); }); } module.exports = { init, render, hasArt, available, safe, ART };