Achievement badge image rendered on the server with ffmpeg (a phone canvas sent a blank card); admin re-post route
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
// 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 };
|
||||||
@@ -36,6 +36,7 @@ const updates = require('./updates');
|
|||||||
const audit = require('./audit'); // counter audit: views vs delivery logs, charges vs shows (Marty, 2026-09-15) // member update emails from Admin > Releases (Marty, 2026-09-14)
|
const audit = require('./audit'); // counter audit: views vs delivery logs, charges vs shows (Marty, 2026-09-15) // member update emails from Admin > Releases (Marty, 2026-09-14)
|
||||||
const leaderboard = require('./leaderboard');
|
const leaderboard = require('./leaderboard');
|
||||||
const toolkit = require('./toolkit');
|
const toolkit = require('./toolkit');
|
||||||
|
const badge = require('./badge'); // achievement badge image drawn on the server with ffmpeg (2026-09-16)
|
||||||
const snapshot = require('./snapshot'); // daily growth snapshot -> Telegram payments feed (Marty, 2026-09-15)
|
const snapshot = require('./snapshot'); // daily growth snapshot -> Telegram payments feed (Marty, 2026-09-15)
|
||||||
const pipeline = require('./pipeline'); // sponsor follow-up board (coming soon until site setting pipelineMode = on) (Marty, 2026-09-15)
|
const pipeline = require('./pipeline'); // sponsor follow-up board (coming soon until site setting pipelineMode = on) (Marty, 2026-09-15)
|
||||||
const videomaker = require('./videomaker'); // Circuit tool: promo videos with the member's own end card (ffmpeg in the image) // badge-gated promo toolkit + AI Copy Engine (Surge and up) (Marty, 2026-09-14) // referral contest: /leaderboard, Overview card, weekly + monthly winners (Marty, 2026-09-14) // release notes + roadmap: /whats-new, Overview card, Admin > Releases (Marty, 2026-09-14) // blog -> Blotato -> X + Instagram on publish (Marty, 2026-09-13) // admin member card: search, drilldown, edits (Marty, 2026-09-13) // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12)
|
const videomaker = require('./videomaker'); // Circuit tool: promo videos with the member's own end card (ffmpeg in the image) // badge-gated promo toolkit + AI Copy Engine (Surge and up) (Marty, 2026-09-14) // referral contest: /leaderboard, Overview card, weekly + monthly winners (Marty, 2026-09-14) // release notes + roadmap: /whats-new, Overview card, Admin > Releases (Marty, 2026-09-14) // blog -> Blotato -> X + Instagram on publish (Marty, 2026-09-13) // admin member card: search, drilldown, edits (Marty, 2026-09-13) // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12)
|
||||||
@@ -372,6 +373,7 @@ async function boot() {
|
|||||||
// follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot)
|
// follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot)
|
||||||
coach.init({ dataDir: DATA_DIR, chain, accounts, mailer, ads, tank, lb: () => leaderboard, siteConfig });
|
coach.init({ dataDir: DATA_DIR, chain, accounts, mailer, ads, tank, lb: () => leaderboard, siteConfig });
|
||||||
pipeline.init({ dataDir: DATA_DIR, accounts, coach, chain });
|
pipeline.init({ dataDir: DATA_DIR, accounts, coach, chain });
|
||||||
|
badge.init({ publicDir: PUBLIC_DIR });
|
||||||
snapshot.init({ dataDir: DATA_DIR, db, chain, siteConfig, send: async (c, t, th) => { await telegramSend(c, t, th); return true; } });
|
snapshot.init({ dataDir: DATA_DIR, db, chain, siteConfig, send: async (c, t, th) => { await telegramSend(c, t, th); return true; } });
|
||||||
setInterval(() => snapshot.tick().catch(e => console.error('snapshot', e.message)), 10 * 60 * 1000);
|
setInterval(() => snapshot.tick().catch(e => console.error('snapshot', e.message)), 10 * 60 * 1000);
|
||||||
tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://instantadpay.com' });
|
tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://instantadpay.com' });
|
||||||
@@ -1548,6 +1550,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const link = a && a.username ? 'instantadpay.com/join/' + a.username : 'instantadpay.com';
|
const link = a && a.username ? 'instantadpay.com/join/' + a.username : 'instantadpay.com';
|
||||||
const [label, sub] = BADGE_META[key];
|
const [label, sub] = BADGE_META[key];
|
||||||
const caption = '\u{1F3C6} <b>InstantAdPay</b> \u00b7 <b>' + who.replace(/[<>&]/g, '') + '</b> unlocked <b>' + label + '</b>: ' + sub + '\n' + link;
|
const caption = '\u{1F3C6} <b>InstantAdPay</b> \u00b7 <b>' + who.replace(/[<>&]/g, '') + '</b> unlocked <b>' + label + '</b>: ' + sub + '\n' + link;
|
||||||
|
// the server draws the card (a phone's canvas can come out blank: cryptomonk, 2026-09-16); the upload is the fallback
|
||||||
|
try { const srv = await badge.render(key, a && a.username ? a.username : (a && a.memberId ? 'member #' + a.memberId : '')); if (srv) jpeg = srv; } catch (e) {}
|
||||||
const sc = siteConfig(); let sent = 0;
|
const sc = siteConfig(); let sent = 0;
|
||||||
if (sc.telegramBotToken && sc.telegramEchoChatId) {
|
if (sc.telegramBotToken && sc.telegramEchoChatId) {
|
||||||
if (await telegramSendPhoto(sc.telegramEchoChatId, jpeg, caption, sc.telegramEchoTopicId)) sent++; // payments topic
|
if (await telegramSendPhoto(sc.telegramEchoChatId, jpeg, caption, sc.telegramEchoTopicId)) sent++; // payments topic
|
||||||
@@ -1569,9 +1573,27 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (!(await ads.milestonesOf(s.email)).includes(key)) return json(res, 400, { error: 'You have not unlocked that badge yet.' });
|
if (!(await ads.milestonesOf(s.email)).includes(key)) return json(res, 400, { error: 'You have not unlocked that badge yet.' });
|
||||||
const a = await accounts.byEmail(s.email);
|
const a = await accounts.byEmail(s.email);
|
||||||
if (!a || !a.username) return json(res, 400, { error: 'Pick a username first; the share page carries it.' });
|
if (!a || !a.username) return json(res, 400, { error: 'Pick a username first; the share page carries it.' });
|
||||||
|
try { const srv = await badge.render(key, a.username); if (srv) jpeg = srv; } catch (e) {}
|
||||||
fs.writeFileSync(path.join(UPLOADS_DIR, 'badge-' + a.username + '-' + key + '.jpg'), jpeg);
|
fs.writeFileSync(path.join(UPLOADS_DIR, 'badge-' + a.username + '-' + key + '.jpg'), jpeg);
|
||||||
return json(res, 200, { ok: true, page: 'https://instantadpay.com/b/' + a.username + '/' + key, image: 'https://instantadpay.com/badge-img/' + a.username + '/' + key + '.jpg' });
|
return json(res, 200, { ok: true, page: 'https://instantadpay.com/b/' + a.username + '/' + key, image: 'https://instantadpay.com/badge-img/' + a.username + '/' + key + '.jpg' });
|
||||||
}
|
}
|
||||||
|
if (p === '/api/admin/badge-repost' && req.method === 'POST') {
|
||||||
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||||
|
const b = await readBody(req); const key = String(b.key || ''); const a = await accounts.byEmail(String(b.email || '').toLowerCase());
|
||||||
|
if (!a || !BADGE_META[key]) return json(res, 400, { error: 'Unknown member or badge.' });
|
||||||
|
if (!(await ads.milestonesOf(a.email)).includes(key)) return json(res, 400, { error: 'That member has not unlocked that badge.' });
|
||||||
|
const jpeg = await badge.render(key, a.username || ('member #' + a.memberId)); if (!jpeg) return json(res, 500, { error: 'Render failed.' });
|
||||||
|
const who = a.username ? '@' + a.username : 'member #' + a.memberId; const link = a.username ? 'instantadpay.com/join/' + a.username : 'instantadpay.com';
|
||||||
|
const [label, sub] = BADGE_META[key];
|
||||||
|
const caption = '\u{1F3C6} <b>InstantAdPay</b> \u00b7 <b>' + who.replace(/[<>&]/g, '') + '</b> unlocked <b>' + label + '</b>: ' + sub + '\n' + link;
|
||||||
|
const sc = siteConfig(); let sent = 0;
|
||||||
|
if (sc.telegramBotToken && sc.telegramEchoChatId) {
|
||||||
|
if (await telegramSendPhoto(sc.telegramEchoChatId, jpeg, caption, sc.telegramEchoTopicId)) sent++;
|
||||||
|
if (String(sc.telegramBadgeGeneral || '1') !== '0' && b.general !== false && await telegramSendPhoto(sc.telegramEchoChatId, jpeg, caption, null)) sent++;
|
||||||
|
}
|
||||||
|
const log = badgeLog(); const mine = log[a.email] || {}; mine[key] = { ts: Date.now(), sent, repost: true }; log[a.email] = mine; try { fs.writeFileSync(BADGE_LOG(), JSON.stringify(log)); } catch (e) {}
|
||||||
|
return json(res, 200, { ok: true, sent, bytes: jpeg.length });
|
||||||
|
}
|
||||||
if (p === '/api/my/badge-posted' && req.method === 'GET') { // which of my badges are already on Telegram
|
if (p === '/api/my/badge-posted' && req.method === 'GET') { // which of my badges are already on Telegram
|
||||||
const s = await auth.fromRequest(req);
|
const s = await auth.fromRequest(req);
|
||||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||||
|
|||||||
Reference in New Issue
Block a user