diff --git a/Dockerfile b/Dockerfile index 7057669..e61d546 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,6 @@ 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 COPY . . RUN mkdir -p /app/data diff --git a/public/suite-video.html b/public/suite-video.html new file mode 100644 index 0000000..faab356 --- /dev/null +++ b/public/suite-video.html @@ -0,0 +1,60 @@ +Video Maker | The Circle Suite + + + +
+
CIRCLE SUITE · LEVEL 3 · FABRICA
+

Video Maker.

+

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.
+
+ + +
+
+
+
✅ Your video is ready
+
+ ⬇ Download the MP4 +
+
+ +

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 };