// Circle Suite — Video Maker (L3). Takes the team's master angle video and // appends a personalized end card: the member's name, their invite link, and // their QR code. Zero AI cost — this is pure compositing. // // Why this shape: Marty's ElevenLabs voice and credits stay his. Members are // not generating new narration; they are getting the approved master with // their own call-to-action welded onto the end, so every share is on-brand. 'use strict'; const fs = require('fs'); const path = require('path'); const { execFile } = require('child_process'); const qrcode = require('./public/qrlib.js'); let DATA_DIR = null, PUBLIC_DIR = null; function init(opts) { DATA_DIR = opts.dataDir; PUBLIC_DIR = opts.publicDir; } function outDir() { const d = path.join(DATA_DIR, 'videos'); try { fs.mkdirSync(d, { recursive: true }); } catch (e) {} return d; } const ANGLES = { pocket: { label: 'Pocket change', file: 'rmc-pocket-change-b403fb2f75.mp4' }, two: { label: 'You only need two', file: 'rmc-two-people-4102f2d719.mp4' }, phone: { label: 'Runs from your phone', file: 'rmc-phone-32ac648844.mp4' }, graveyard: { label: 'Side-hustle graveyard',file: 'rmc-graveyard-290fabee9d.mp4' }, overview: { label: 'General overview', file: 'rmc-combined-16284edbab.mp4' } }; const CARD_SECONDS = 6; // QR straight to a P6 PPM — ffmpeg reads PPM natively, so no image library and // no headless browser is needed on the server. function writeQrPpm(text, file, scale, quiet) { scale = scale || 10; quiet = quiet == null ? 4 : quiet; const q = qrcode(0, 'M'); q.addData(text); q.make(); const n = q.getModuleCount(); const side = (n + quiet * 2) * scale; const header = Buffer.from('P6\n' + side + ' ' + side + '\n255\n', 'ascii'); const px = Buffer.alloc(side * side * 3, 255); // white ground for (let r = 0; r < n; r++) { for (let c = 0; c < n; c++) { if (!q.isDark(r, c)) continue; const x0 = (c + quiet) * scale, y0 = (r + quiet) * scale; for (let y = y0; y < y0 + scale; y++) { let o = (y * side + x0) * 3; for (let x = 0; x < scale; x++) { px[o] = 0; px[o + 1] = 0; px[o + 2] = 0; o += 3; } } } } fs.writeFileSync(file, Buffer.concat([header, px])); return side; } // ffmpeg drawtext is picky: escape the characters that mean something to it. function dt(s) { return String(s || '').replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'").replace(/%/g, '\\%'); } // A path inside a filter expression needs its colons escaped too — harmless on // Linux (no colons), required on Windows drive letters when testing locally. function dtPath(p) { return String(p || '').replace(/\\/g, '/').replace(/:/g, '\\:'); } // Alpine puts DejaVu in /usr/share/fonts/dejavu; Debian uses truetype/dejavu. // Pick a real file rather than trusting ffmpeg's undefined fallback. let _font = null; function pickFont() { if (_font) return _font; const cands = [ '/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', '/usr/share/fonts/dejavu/DejaVuSans.ttf', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf' ]; for (const c of cands) { try { if (fs.existsSync(c)) { _font = c; return c; } } catch (e) {} } _font = cands[0]; return _font; } function ffmpegBin() { return process.env.FFMPEG_BIN || 'ffmpeg'; } function render(opts) { return new Promise(function (resolve, reject) { const angle = ANGLES[opts.angle] ? opts.angle : 'overview'; const master = path.join(PUBLIC_DIR, 'v', ANGLES[angle].file); if (!fs.existsSync(master)) return reject(new Error('That angle video is missing.')); const id = String(opts.id).replace(/\D/g, ''); const link = opts.link; const name = (opts.name || '').slice(0, 42); const qrFile = path.join(outDir(), id + '-qr.ppm'); const out = path.join(outDir(), id + '-' + angle + '.mp4'); try { writeQrPpm(link, qrFile, 10, 4); } catch (e) { return reject(new Error('Could not build your QR code.')); } const font = process.env.FF_FONT || pickFont(); const shortLink = link.replace(/^https?:\/\//, ''); const headline = name ? (name + ' invited you') : 'You were invited'; // End card: brand-navy plate, headline, link, QR bottom-right, disclaimer. const card = "[2:v]drawtext=fontfile='" + dtPath(font) + "':text='" + dt(headline) + "':fontcolor=white:fontsize=76:x=(w-text_w)/2:y=210," + "drawtext=fontfile='" + dtPath(font) + "':text='" + dt('Scan or visit') + "':fontcolor=0x9fb4c4:fontsize=38:x=(w-text_w)/2:y=340," + "drawtext=fontfile='" + dtPath(font) + "':text='" + dt(shortLink) + "':fontcolor=0xf3be43:fontsize=54:x=(w-text_w)/2:y=410," + "drawtext=fontfile='" + dtPath(font) + "':text='" + dt('No income is guaranteed. Cryptocurrency involves risk.') + "':fontcolor=0x7d93a5:fontsize=26:x=(w-text_w)/2:y=h-70[plate];" + "[1:v]scale=330:330[qr];" + "[plate][qr]overlay=(W-w)/2:560[card];" + "[0:v]scale=1920:1080,setsar=1,fps=30,format=yuv420p[v0];" + "[card]format=yuv420p[v1];" + "[v0][0:a][v1][3:a]concat=n=2:v=1:a=1[outv][outa]"; const args = [ '-y', '-i', master, '-loop', '1', '-t', String(CARD_SECONDS), '-i', qrFile, '-f', 'lavfi', '-t', String(CARD_SECONDS), '-i', 'color=c=0x0b1d2e:s=1920x1080:r=30', '-f', 'lavfi', '-t', String(CARD_SECONDS), '-i', 'anullsrc=channel_layout=stereo:sample_rate=48000', '-filter_complex', card, '-map', '[outv]', '-map', '[outa]', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '24', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', out ]; execFile(ffmpegBin(), args, { maxBuffer: 1024 * 1024 * 12, timeout: 600000 }, function (err, so, se) { try { fs.unlinkSync(qrFile); } catch (e) {} if (err) return reject(new Error('Render failed: ' + String(se || err.message).split('\n').slice(-3).join(' ').slice(0, 240))); if (!fs.existsSync(out)) return reject(new Error('Render produced no file.')); resolve({ file: out, bytes: fs.statSync(out).size, angle: angle }); }); }); } function existing(id, angle) { const f = path.join(outDir(), String(id).replace(/\D/g, '') + '-' + angle + '.mp4'); try { const st = fs.statSync(f); return { file: f, bytes: st.size, at: st.mtimeMs }; } catch (e) { return null; } } module.exports = { init, render, existing, ANGLES, writeQrPpm, outDir };