Video Maker ships (L3): ffmpeg end-card composite over the team master videos (member name + link + server-generated QR as PPM), audio preserved, metered; ffmpeg+ttf-dejavu added to the image; tile live

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-28 08:37:59 -05:00
parent 7c3e98eaa9
commit 44816571b6
6 changed files with 315 additions and 2 deletions
+2
View File
@@ -1,4 +1,6 @@
FROM node:22-alpine FROM node:22-alpine
# ffmpeg + a TTF for the Video Maker's end-card drawtext (small: ~40MB total)
RUN apk add --no-cache ffmpeg ttf-dejavu
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN mkdir -p /app/data RUN mkdir -p /app/data
+60
View File
@@ -0,0 +1,60 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><meta name="description" content="Video Maker — the team's promo videos with your own end card: your name, your link, your QR code."><title>Video Maker | The Circle Suite</title><link rel="icon" type="image/png" href="/favicon.png"><link rel="stylesheet" href="/styles.css">
<style>
.vw{max-width:880px;margin:0 auto;padding:24px 20px 70px}
.vw h1{font-size:clamp(26px,4.5vw,36px);margin:6px 0 8px}
.vw .lede{color:var(--muted);line-height:1.6;margin-bottom:18px}
#vdGate{display:none;border:2px solid var(--gold);border-radius:12px;padding:16px 20px;margin-bottom:18px;line-height:1.6}
#vdGate b{color:var(--gold)}
.vd-card{background:var(--card,#0d2236);border:1px solid var(--line,#24425d);border-radius:14px;padding:18px 20px}
.vd-angles{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}
.vd-angle{border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:999px;
padding:7px 14px;font-size:13.5px;font-weight:700;cursor:pointer}
.vd-angle.on{border-color:var(--teal);background:rgba(78,214,203,.12);color:var(--teal)}
label.vd-lab{display:block;font-weight:700;font-size:14px;margin:0 0 6px}
#vdName{width:100%;max-width:340px;background:var(--bg,#0b1d2e);border:1px solid var(--line);
border-radius:10px;color:var(--text);padding:11px 13px;font-size:15px;font-family:inherit}
.vd-hint{color:var(--muted);font-size:12.5px;margin-top:6px}
.vd-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:16px}
#vdMeter{font-size:12.5px;color:var(--muted);margin-left:auto}
#vdMeter b{color:var(--text)}
#vdErr{display:none;color:#f0a05a;margin-top:12px;font-size:14px;line-height:1.55}
#vdOut{display:none;margin-top:18px;border:2px solid var(--teal);border-radius:14px;padding:16px 20px}
#vdOut .ok{font-weight:800;margin-bottom:8px}
.vd-note{margin-top:20px;font-size:12.5px;color:var(--muted);line-height:1.6;border-top:1px solid var(--line);padding-top:12px}
.spin{display:inline-block;width:13px;height:13px;border:2px solid rgba(255,255,255,.25);border-top-color:var(--gold);
border-radius:50%;animation:sp .8s linear infinite;vertical-align:-2px;margin-right:7px}
@keyframes sp{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){.spin{animation:none}}
</style></head>
<body>
<header class="wrap nav"><a class="brand" href="/suite"><img class="brand-mark" src="/logo.jpg" alt="The RM Circle" width="42" height="42"><span><span id="brandName">RM Circle</span><small>Video Maker</small></span></a><div class="nav-actions"><a class="btn btn-secondary hide-mobile" href="/suite">← The Suite</a><a class="btn btn-primary" href="/suite/email">Email Engine</a></div></header>
<main class="vw">
<div class="eyebrow" style="color:var(--gold)">CIRCLE SUITE · LEVEL 3 · FABRICA</div>
<h1>Video <span class="gold">Maker.</span></h1>
<p class="lede">Take the team's produced promo videos and put <strong style="color:var(--text)">your</strong> ending on them — your name, your invite link, and your QR code on screen for six seconds. Download it and post it anywhere.</p>
<div id="vdGate"></div>
<div class="vd-card" id="vdCard">
<label class="vd-lab">Which video?</label>
<div class="vd-angles" id="vdAngles"></div>
<label class="vd-lab" for="vdName">What name should the end card use?</label>
<input type="text" id="vdName" maxlength="42" placeholder="e.g. Marty B.">
<div class="vd-hint">It reads "<span id="vdPrev">Your name</span> invited you" above your link and QR code.</div>
<div class="vd-row">
<button id="vdGo" class="btn btn-primary">🎬 Make my video</button>
<span id="vdMeter"></span>
</div>
<div id="vdErr"></div>
<div id="vdOut">
<div class="ok">✅ Your video is ready</div>
<div class="vd-hint" id="vdSize" style="margin-bottom:10px"></div>
<a id="vdDl" class="btn btn-teal btn-sm" download>⬇ Download the MP4</a>
</div>
</div>
<p class="vd-note"><strong style="color:var(--text)">Teach it forward:</strong> the video does the explaining so your two don't have to — show them how to make their own the day they join. <br>The video itself is the team's produced master; only the ending is yours, so every share stays on-brand and on-message. Independent team resource · No income is guaranteed · Cryptocurrency involves risk.</p>
</main>
<script src="/suite-video.js"></script>
<script src="/chat.js" defer></script>
<script src="/translate.js" defer></script><script src="/tg-app.js" defer></script><script src="/nav-dash.js" defer></script></body></html>
+99
View File
@@ -0,0 +1,99 @@
// Video Maker client. Renders take ~15-40s server-side, so the button holds a
// spinner and the copy warns before the wait rather than after it.
(function () {
'use strict';
var ANGLES = [
{ k: 'two', label: 'You only need two' },
{ k: 'pocket', label: 'Pocket change' },
{ k: 'phone', label: 'Runs from your phone' },
{ k: 'graveyard', label: 'Side-hustle graveyard' },
{ k: 'overview', label: 'General overview' }
];
var $ = function (id) { return document.getElementById(id); };
var angle = 'two', busy = false;
function renderAngles() {
var host = $('vdAngles');
host.innerHTML = '';
ANGLES.forEach(function (x) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'vd-angle' + (x.k === angle ? ' on' : '');
b.textContent = x.label;
b.addEventListener('click', function () { angle = x.k; renderAngles(); });
host.appendChild(b);
});
}
function showMeter(m) {
if (!m || !m.limit) { $('vdMeter').textContent = ''; return; }
$('vdMeter').innerHTML = '<b>' + m.remaining + '</b> of ' + m.limit + ' videos left this month';
}
function gate(msg) {
var g = $('vdGate');
g.style.display = 'block';
g.innerHTML = msg;
$('vdCard').style.opacity = '.55';
$('vdGo').disabled = true;
$('vdName').disabled = true;
}
async function boot() {
renderAngles();
try {
var r = await fetch('/api/public/suite-meters');
if (r.status === 401) { gate('You’re not signed in yet. <a href="/suite" style="color:var(--gold);font-weight:700">Open the Suite</a> and connect the wallet that holds your position, then come back.'); return; }
if (r.status === 403) { gate('The Video Maker isn’t open for this position yet. <a href="/suite" style="color:var(--gold);font-weight:700">See your Suite</a>.'); return; }
if (!r.ok) return;
var d = await r.json();
var m = d.meters && d.meters.video;
if (m && m.reason === 'locked') {
gate('The Video Maker unlocks at <b>Fabrica (level 3)</b>. You’re at level ' + d.level + ' — keep climbing and it opens, along with the Email Engine. <a href="/how-pay-works#exact-costs" style="color:var(--gold);font-weight:700">See what upgrades cost →</a>');
return;
}
showMeter(m);
} catch (e) {}
}
async function run() {
if (busy) return;
busy = true;
$('vdErr').style.display = 'none';
$('vdOut').style.display = 'none';
$('vdGo').disabled = true;
$('vdGo').innerHTML = '<span class="spin"></span>Rendering your video… (about a minute)';
try {
var r = await fetch('/api/public/suite-video', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ angle: angle, name: $('vdName').value.trim() })
});
var d = await r.json();
if (!r.ok) {
$('vdErr').textContent = d.error || 'That didn’t go through — try again.';
$('vdErr').style.display = 'block';
if (d.meter) showMeter(d.meter);
} else {
$('vdDl').href = d.url;
$('vdSize').textContent = 'MP4 · about ' + d.mb + ' MB · ready to post anywhere.';
$('vdOut').style.display = 'block';
showMeter(d.meter);
$('vdOut').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
} catch (e) {
$('vdErr').textContent = 'Connection hiccup — try again.';
$('vdErr').style.display = 'block';
}
busy = false;
$('vdGo').disabled = false;
$('vdGo').textContent = '🎬 Make my video';
}
document.addEventListener('DOMContentLoaded', function () {
boot();
$('vdGo').addEventListener('click', run);
$('vdName').addEventListener('input', function () {
$('vdPrev').textContent = $('vdName').value.trim() || 'Your name';
});
});
})();
+1 -1
View File
@@ -16,7 +16,7 @@
{ lv: 2, ico: '✍️', name: 'Copy Engine', desc: 'An AI copywriter tuned to this business: posts, DMs, follow-ups and objection replies with your link filled in.', href: '/suite/copy', live: true }, { lv: 2, ico: '✍️', name: 'Copy Engine', desc: 'An AI copywriter tuned to this business: posts, DMs, follow-ups and objection replies with your link filled in.', href: '/suite/copy', live: true },
{ lv: 2, ico: '🧱', name: 'Page Builder', desc: 'Answer four questions, get your own hosted invitation page — your story, your angle video, your QR.', href: '/suite/page', live: true }, { lv: 2, ico: '🧱', name: 'Page Builder', desc: 'Answer four questions, get your own hosted invitation page — your story, your angle video, your QR.', href: '/suite/page', live: true },
{ lv: 3, ico: '📧', name: 'Email Engine', desc: 'Welcome series, follow-up sequences and broadcasts in the team voice — exportable to any autoresponder.', href: '/suite/email', live: true }, { lv: 3, ico: '📧', name: 'Email Engine', desc: 'Welcome series, follow-up sequences and broadcasts in the team voice — exportable to any autoresponder.', href: '/suite/email', live: true },
{ lv: 3, ico: '🎥', name: 'Video Maker', desc: 'The team’s master promo videos rendered with your personal end-card — your name, your QR, your link.', live: false }, { lv: 3, ico: '🎥', name: 'Video Maker', desc: 'The team’s master promo videos rendered with your personal end-card — your name, your QR, your link.', href: '/suite/video', live: true },
{ lv: 4, ico: '🗣️', name: 'Voice Profile + Funnels', desc: 'Output that sounds like YOU, plus multi-page funnels on your own team subdomain.', live: false }, { lv: 4, ico: '🗣️', name: 'Voice Profile + Funnels', desc: 'Output that sounds like YOU, plus multi-page funnels on your own team subdomain.', live: false },
{ lv: 5, ico: '🚦', name: 'Traffic Desk', desc: 'Syndicated network display advertising: monthly ad credits on the team’s own network, banner and text placements, rotator priority, and AI campaign packs.', live: false }, { lv: 5, ico: '🚦', name: 'Traffic Desk', desc: 'Syndicated network display advertising: monthly ad credits on the team’s own network, banner and text placements, rotator priority, and AI campaign packs.', live: false },
{ lv: 6, ico: '🏭', name: 'Funnel Factory', desc: 'Complete hosted funnels with A/B variants, replay funnels (your team webinar as an on-demand registration page with a timed CTA), and a lead CRM.', live: false }, { lv: 6, ico: '🏭', name: 'Funnel Factory', desc: 'Complete hosted funnels with A/B variants, replay funnels (your team webinar as an on-demand registration page with a timed CTA), and a lead CRM.', live: false },
+33 -1
View File
@@ -22,6 +22,7 @@ const suiteAI = require('./suite-ai'); suiteAI.init({ dataDir: DATA_DIR });
const suitePages = require('./suite-pages'); suitePages.init({ dataDir: DATA_DIR }); const suitePages = require('./suite-pages'); suitePages.init({ dataDir: DATA_DIR });
const suiteTools = require('./suite-tools'); const suiteTools = require('./suite-tools');
const suiteEmail = require('./suite-email'); const suiteEmail = require('./suite-email');
const suiteVideo = require('./suite-video'); suiteVideo.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR });
const tgbot = require('./tgbot'); const tgbot = require('./tgbot');
tgbot.init({ dataDir: DATA_DIR, chain, getConfig, messages, baseUrl: 'https://rmcircle.team' }); tgbot.init({ dataDir: DATA_DIR, chain, getConfig, messages, baseUrl: 'https://rmcircle.team' });
const SESSION_TTL = 8 * 60 * 60 * 1000; const SESSION_TTL = 8 * 60 * 60 * 1000;
@@ -728,6 +729,37 @@ async function handleApi(req,res,pathname){
}catch(err){ return json(res,502,{error:String(err.message||err)}); } }catch(err){ return json(res,502,{error:String(err.message||err)}); }
} }
// ── Video Maker ──────────────────────────────────────────────────────────
if(req.method==='GET'&&/^\/api\/public\/suite-video-file\/\d{1,15}\/[a-z]{2,12}$/.test(pathname)){
const parts=pathname.split('/'); const vid=parts[4], vangle=parts[5];
const ex=suiteVideo.existing(vid,vangle);
if(!ex)return json(res,404,{error:'No video yet.'});
res.writeHead(200,{'Content-Type':'video/mp4','Content-Length':ex.bytes,
'Content-Disposition':'attachment; filename="rmcircle-'+vangle+'-'+vid+'.mp4"','Cache-Control':'private, max-age=60'});
return require('fs').createReadStream(ex.file).pipe(res);
}
if(req.method==='POST'&&pathname==='/api/public/suite-video'){
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
if(e.error)return json(res,e.code||500,{error:e.error});
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
const b=await bodyJson(req)||{};
const angle=suiteVideo.ANGLES[b.angle]?b.angle:'overview';
const name=String(b.name||'').trim().slice(0,42);
const gate=suiteMeter.check(e.d.id,e.d.level,'video');
if(!gate.allowed){
return json(res,429,{error:gate.reason==='locked'
? 'The Video Maker unlocks at Fabrica (level 3). Your next upgrades open it.'
: 'You have used all '+gate.limit+' video renders this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate});
}
const link='https://rmcircle.team/join/'+e.d.id+(angle!=='overview'?'?v='+angle:'');
try{
const r=await suiteVideo.render({id:e.d.id,angle:angle,name:name,link:link});
suiteMeter.record(e.d.id,'video',1);
return json(res,200,{url:'/api/public/suite-video-file/'+e.d.id+'/'+angle,mb:Math.round(r.bytes/104857.6)/10,
meter:suiteMeter.check(e.d.id,e.d.level,'video')});
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
}
if(req.method==='GET'&&pathname==='/api/public/suite-meters'){ if(req.method==='GET'&&pathname==='/api/public/suite-meters'){
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500})); const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500}));
if(e.error)return json(res,e.code||500,{error:e.error}); if(e.error)return json(res,e.code||500,{error:e.error});
@@ -1061,7 +1093,7 @@ const server=http.createServer(async(req,res)=>{
if((mj=pathname.match(/^\/join\/(\d{1,15})$/)))return serveMemberPage(req,res,path.join(PUBLIC_DIR,'join.html'),'join',mj[1]); if((mj=pathname.match(/^\/join\/(\d{1,15})$/)))return serveMemberPage(req,res,path.join(PUBLIC_DIR,'join.html'),'join',mj[1]);
} }
let file; let file;
if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/app'||pathname==='/app/')file=path.join(PUBLIC_DIR,'app.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(pathname==='/privacy'||pathname==='/privacy/')file=path.join(PUBLIC_DIR,'privacy.html');else if(pathname==='/refunds'||pathname==='/refunds/')file=path.join(PUBLIC_DIR,'refunds.html');else if(pathname==='/how-pay-works'||pathname==='/how-pay-works/')file=path.join(PUBLIC_DIR,'how-pay-works.html');else if(pathname==='/tools'||pathname==='/tools/')file=path.join(PUBLIC_DIR,'tools.html');else if(pathname==='/fast-start'||pathname==='/fast-start/')file=path.join(PUBLIC_DIR,'fast-start.html');else if(pathname==='/flyers'||pathname==='/flyers/')file=path.join(PUBLIC_DIR,'flyers.html');else if(pathname==='/weekly-rhythm'||pathname==='/weekly-rhythm/')file=path.join(PUBLIC_DIR,'weekly-rhythm.html');else if(pathname==='/generation-pay'||pathname==='/generation-pay/')file=path.join(PUBLIC_DIR,'generation-pay.html');else if(pathname==='/suite'||pathname==='/suite/')file=path.join(PUBLIC_DIR,'suite.html');else if(pathname==='/suite/copy'||pathname==='/suite/copy/')file=path.join(PUBLIC_DIR,'suite-copy.html');else if(pathname==='/suite/page'||pathname==='/suite/page/')file=path.join(PUBLIC_DIR,'suite-page.html');else if(pathname==='/suite/email'||pathname==='/suite/email/')file=path.join(PUBLIC_DIR,'suite-email.html');else if(pathname==='/direct-join'||pathname==='/direct-join/'){if(!getSession(req)){res.writeHead(302,{Location:'/admin'});return res.end();}file=path.join(ROOT,'private','direct-join.html');}else if(pathname==='/join-now'||pathname==='/join-now/'){if(!getConfig().dappFallbackPublic){res.writeHead(302,{Location:'/start'});return res.end();}file=path.join(ROOT,'private','join-now.html');}else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/join-now'});return res.end();}else{ if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/app'||pathname==='/app/')file=path.join(PUBLIC_DIR,'app.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(pathname==='/privacy'||pathname==='/privacy/')file=path.join(PUBLIC_DIR,'privacy.html');else if(pathname==='/refunds'||pathname==='/refunds/')file=path.join(PUBLIC_DIR,'refunds.html');else if(pathname==='/how-pay-works'||pathname==='/how-pay-works/')file=path.join(PUBLIC_DIR,'how-pay-works.html');else if(pathname==='/tools'||pathname==='/tools/')file=path.join(PUBLIC_DIR,'tools.html');else if(pathname==='/fast-start'||pathname==='/fast-start/')file=path.join(PUBLIC_DIR,'fast-start.html');else if(pathname==='/flyers'||pathname==='/flyers/')file=path.join(PUBLIC_DIR,'flyers.html');else if(pathname==='/weekly-rhythm'||pathname==='/weekly-rhythm/')file=path.join(PUBLIC_DIR,'weekly-rhythm.html');else if(pathname==='/generation-pay'||pathname==='/generation-pay/')file=path.join(PUBLIC_DIR,'generation-pay.html');else if(pathname==='/suite'||pathname==='/suite/')file=path.join(PUBLIC_DIR,'suite.html');else if(pathname==='/suite/copy'||pathname==='/suite/copy/')file=path.join(PUBLIC_DIR,'suite-copy.html');else if(pathname==='/suite/page'||pathname==='/suite/page/')file=path.join(PUBLIC_DIR,'suite-page.html');else if(pathname==='/suite/email'||pathname==='/suite/email/')file=path.join(PUBLIC_DIR,'suite-email.html');else if(pathname==='/suite/video'||pathname==='/suite/video/')file=path.join(PUBLIC_DIR,'suite-video.html');else if(pathname==='/direct-join'||pathname==='/direct-join/'){if(!getSession(req)){res.writeHead(302,{Location:'/admin'});return res.end();}file=path.join(ROOT,'private','direct-join.html');}else if(pathname==='/join-now'||pathname==='/join-now/'){if(!getConfig().dappFallbackPublic){res.writeHead(302,{Location:'/start'});return res.end();}file=path.join(ROOT,'private','join-now.html');}else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/join-now'});return res.end();}else{
const safe=path.normalize(pathname).replace(/^([.][.][/\\])+/, '').replace(/^[/\\]+/,'');file=path.join(PUBLIC_DIR,safe);if(!file.startsWith(PUBLIC_DIR))file=''; const safe=path.normalize(pathname).replace(/^([.][.][/\\])+/, '').replace(/^[/\\]+/,'');file=path.join(PUBLIC_DIR,safe);if(!file.startsWith(PUBLIC_DIR))file='';
} }
if(file&&staticFile(req,res,file))return;return staticFile(req,res,path.join(PUBLIC_DIR,'404.html'),404); if(file&&staticFile(req,res,file))return;return staticFile(req,res,path.join(PUBLIC_DIR,'404.html'),404);
+120
View File
@@ -0,0 +1,120 @@
// 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, '\\:'); }
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 || '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
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 };