// Achievement badge image, rendered on the server with ffmpeg (Marty, 2026-09-16). // The browser used to compose it on a canvas and upload the JPEG; a phone that ran short of canvas // memory (cryptomonk's Spark post) sent a mostly blank card to the group. Now the server draws the // member's name onto the badge art itself, the same way the Video Maker draws end cards, so every // badge looks the same no matter what device unlocked it. The browser upload stays as a fallback. const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); let PUBLIC_DIR = null; const ART = { payouts: { file: 'badge-spark.jpg', ribbonY: 0.728 }, firstBuyer: { file: 'badge-surge.jpg', ribbonY: 0.76 }, level2: { file: 'badge-circuit.jpg', ribbonY: 0.72 }, level3: { file: 'badge-nexus.jpg', ribbonY: 0.73 } }; let FONT; 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 (/DejaVuSans-Bold\.ttf$/i.test(f)) 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, '%%'); function init(opts) { PUBLIC_DIR = opts.publicDir; } // returns a JPEG buffer, or null when ffmpeg or the font is missing (caller falls back to the upload) function render(key, who) { return new Promise(resolve => { const a = ART[key]; const F = font(); if (!a || !F) return resolve(null); const src = path.resolve(PUBLIC_DIR, 'badges', a.file); if (!fs.existsSync(src)) return resolve(null); const name = String(who || '').slice(0, 28); // a Windows font path carries a drive colon that the filter parser trips on: run from the font's folder instead const winPath = /:/.test(F); const fontFile = winPath ? path.basename(F) : F; // 1080-wide art: 59px bold gold text with a dark outline, centred on the ribbon (matches the old canvas layout) const vf = name ? 'drawtext=fontfile=' + fontFile + ":text='" + ffText(name) + "':fontcolor=#ffd15c:fontsize=59:borderw=7:bordercolor=0x04140f@0.9:x=(w-text_w)/2:y=" + a.ribbonY + '*h-text_h/2' : 'null'; const p = spawn('ffmpeg', ['-v', 'error', '-i', src, '-vf', vf, '-frames:v', '1', '-q:v', '3', '-f', 'image2pipe', '-vcodec', 'mjpeg', 'pipe:1'], { stdio: ['ignore', 'pipe', 'pipe'], cwd: winPath ? path.dirname(F) : undefined }); const chunks = []; let err = ''; p.stdout.on('data', c => chunks.push(c)); p.stderr.on('data', c => { err += c; }); p.on('error', () => resolve(null)); p.on('close', code => { const buf = Buffer.concat(chunks); if (code === 0 && buf.length > 2000 && buf[0] === 0xff && buf[1] === 0xd8) resolve(buf); else { console.error('badge render failed', code, err.slice(0, 200)); resolve(null); } }); }); } module.exports = { init, render, font, ART };