Take the team's produced promo videos and put your ending on them — your name, your invite link, and your QR code on screen for six seconds. Download it and post it anywhere.
+
+
+
+
+
+
+
+
+
It reads "Your name invited you" above your link and QR code.
Teach it forward: the video does the explaining so your two don't have to — show them how to make their own the day they join. 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.
+
+
+
+
diff --git a/public/suite-video.js b/public/suite-video.js
new file mode 100644
index 0000000..e60094d
--- /dev/null
+++ b/public/suite-video.js
@@ -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 = '' + m.remaining + ' 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. Open the Suite 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. See your Suite.'); 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 Fabrica (level 3). You’re at level ' + d.level + ' — keep climbing and it opens, along with the Email Engine. See what upgrades cost →');
+ 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 = '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';
+ });
+ });
+})();
diff --git a/public/suite.js b/public/suite.js
index 5ec18d6..e4d411a 100644
--- a/public/suite.js
+++ b/public/suite.js
@@ -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: '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: '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: 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 },
diff --git a/server.js b/server.js
index c55e4d9..e9429c9 100644
--- a/server.js
+++ b/server.js
@@ -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 suiteTools = require('./suite-tools');
const suiteEmail = require('./suite-email');
+const suiteVideo = require('./suite-video'); suiteVideo.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR });
const tgbot = require('./tgbot');
tgbot.init({ dataDir: DATA_DIR, chain, getConfig, messages, baseUrl: 'https://rmcircle.team' });
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)}); }
}
+ // ── 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'){
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500}));
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]);
}
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='';
}
if(file&&staticFile(req,res,file))return;return staticFile(req,res,path.join(PUBLIC_DIR,'404.html'),404);
diff --git a/suite-video.js b/suite-video.js
new file mode 100644
index 0000000..e8163d5
--- /dev/null
+++ b/suite-video.js
@@ -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 };