Badge cards: seven AI artworks for the hunting achievements, the hunter's name stamped on the ribbon server-side (ffmpeg), public share pages /b/<who>/<id> with OG cards, badge strip + share modal + unlock celebration on the board, Telegram photo post gated by OUTBOUND
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
// Badge cards: the AI artwork for each achievement (public/badges/badge-<id>.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/<who>-<id>.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 };
|
||||
Reference in New Issue
Block a user