// Video Maker (Circuit and up, Marty 2026-09-14): the promo videos re-rendered with the member's own end // card (username, invite link, QR) so the last four seconds carry their link, not the company's. // ffmpeg + a DejaVu font in the image (Dockerfile). One job at a time; sources are the hosted promo // videos in the Spaces bucket; output is hosted at promo/made//.mp4 and cached forever // (a member's link never changes). Jobs live in DATA_DIR/video-jobs.json. const fs = require('fs'); const path = require('path'); const https = require('https'); const os = require('os'); const { spawn } = require('child_process'); let R = null; // { dataDir, spaces, accounts } let QR = null; try { QR = require('qrcode'); } catch (e) {} const BASE = 'https://coolify-saasytop.nyc3.digitaloceanspaces.com/promo/'; const SOURCES = [ { slug: 'instant', title: 'Paid before the page reloads (16:9)', url: BASE + 'instant.mp4', kind: 'landscape' }, { slug: 'instant-portrait', title: 'Paid before the page reloads (9:16)', url: BASE + 'instant-portrait.mp4', kind: 'portrait' }, { slug: 'adspend', title: 'You were buying traffic anyway (16:9)', url: BASE + 'adspend.mp4', kind: 'landscape' }, { slug: 'adspend-portrait', title: 'You were buying traffic anyway (9:16)', url: BASE + 'adspend-portrait.mp4', kind: 'portrait' }, { slug: 'free', title: 'Watch first, spend never (16:9)', url: BASE + 'free.mp4', kind: 'landscape' }, { slug: 'free-portrait', title: 'Watch first, spend never (9:16)', url: BASE + 'free-portrait.mp4', kind: 'portrait' }, { slug: 'ledger', title: 'No back office. No payday. (16:9)', url: BASE + 'ledger.mp4', kind: 'landscape' }, { slug: 'ledger-portrait', title: 'No back office. No payday. (9:16)', url: BASE + 'ledger-portrait.mp4', kind: 'portrait' }, { slug: 'two', title: 'Two buyers open level two (16:9)', url: BASE + 'two.mp4', kind: 'landscape' }, { slug: 'two-portrait', title: 'Two buyers open level two (9:16)', url: BASE + 'two-portrait.mp4', kind: 'portrait' }, { slug: 's01-instant', title: 'Short: Paid before the page reloads', url: BASE + 'shorts/s01-instant.mp4', kind: 'portrait' }, { slug: 's02-free', title: 'Short: Try it without spending a dollar', url: BASE + 'shorts/s02-free.mp4', kind: 'portrait' }, { slug: 's03-two', title: 'Short: Two buyers open level two', url: BASE + 'shorts/s03-two.mp4', kind: 'portrait' }, { slug: 's04-ledger', title: 'Short: Just a public ledger', url: BASE + 'shorts/s04-ledger.mp4', kind: 'portrait' }, { slug: 's05-adspend', title: 'Short: The ad spend pays you back', url: BASE + 'shorts/s05-adspend.mp4', kind: 'portrait' }, { slug: 's06-passive', title: 'Short: Is it passive income? The honest answer', url: BASE + 'shorts/s06-passive.mp4', kind: 'portrait' }, { slug: 's07-immutable', title: 'Short: Nobody can change the split', url: BASE + 'shorts/s07-immutable.mp4', kind: 'portrait' }, { slug: 's08-network', title: 'Short: One budget, the whole network', url: BASE + 'shorts/s08-network.mp4', kind: 'portrait' }, { slug: 's09-tank', title: 'Short: The holding tank', url: BASE + 'shorts/s09-tank.mp4', kind: 'portrait' }, { slug: 's10-leader', title: 'Short: The leader play', url: BASE + 'shorts/s10-leader.mp4', kind: 'portrait' } ]; const FILE = () => path.join(R.dataDir, 'video-jobs.json'); function jobs() { try { return JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { return []; } } function saveJobs(j) { try { fs.writeFileSync(FILE(), JSON.stringify(j.slice(-500))); } catch (e) {} } function init(refs) { R = refs; setInterval(() => tick().catch(e => console.error('videomaker', e.message)), 5000); } let FONT = undefined; // Alpine puts ttf-dejavu under /usr/share/fonts/ttf-dejavu (older) or /dejavu (newer): search once function font() { if (FONT !== undefined) return FONT; FONT = null; 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 (e.name === 'DejaVuSans-Bold.ttf') return f; } return null; }; FONT = walk('/usr/share/fonts'); return FONT; } // progress: 0-100 plus a short stage label, written to the jobs file so the page can poll it function setProgress(id, pct, stage) { const j = jobs(); const k = j.find(x => x.id === id); if (!k) return; k.pct = Math.max(k.pct || 0, Math.min(100, Math.round(pct))); if (stage) k.stage = stage; saveJobs(j); } function durationOf(file) { return new Promise(resolve => { const p = spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file]); let out = ''; p.stdout.on('data', c => out += c); p.on('close', () => resolve(parseFloat(out) || 0)); p.on('error', () => resolve(0)); }); } function available() { return !!(font() && R && R.spaces && R.spaces.enabled()); } function run(cmd, args, timeoutMs, onOut) { return new Promise((resolve, reject) => { const p = spawn(cmd, args, { stdio: ['ignore', onOut ? 'pipe' : 'ignore', 'pipe'] }); let err = ''; if (onOut) p.stdout.on('data', c => { try { onOut(String(c)); } catch (e) {} }); const t = setTimeout(() => { p.kill('SIGKILL'); reject(new Error(cmd + ' timeout')); }, timeoutMs || 240000); p.stderr.on('data', c => { err += c; if (err.length > 20000) err = err.slice(-10000); }); p.on('error', e => { clearTimeout(t); reject(e); }); p.on('close', code => { clearTimeout(t); code === 0 ? resolve() : reject(new Error(cmd + ' exit ' + code + ': ' + err.slice(-400))); }); }); } function download(url, file) { return new Promise((resolve, reject) => { const out = fs.createWriteStream(file); https.get(url, res => { if (res.statusCode !== 200) return reject(new Error('download ' + res.statusCode)); res.pipe(out); out.on('finish', () => out.close(resolve)); }).on('error', reject); }); } function probe(file) { return new Promise((resolve, reject) => { const p = spawn('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', file]); let out = ''; p.stdout.on('data', c => out += c); p.on('close', () => { const m = /(\d+),(\d+)/.exec(out); m ? resolve({ w: Number(m[1]), h: Number(m[2]) }) : reject(new Error('probe failed')); }); }); } const ffText = s => String(s).replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'").replace(/%/g, '\\%'); // list for the UI: every source with the member's finished video, if made function queuedAhead(job) { return jobs().filter(x => x.status === 'queued' && x.at < job.at).length; } function list(username) { const done = {}; for (const j of jobs()) if (j.username === username && j.status === 'done') done[j.slug] = j; const pending = {}; for (const j of jobs()) if (j.username === username && (j.status === 'queued' || j.status === 'working')) pending[j.slug] = j; return SOURCES.map(s => ({ slug: s.slug, title: s.title, kind: s.kind, url: done[s.slug] ? done[s.slug].url : null, madeAt: done[s.slug] ? done[s.slug].doneAt : null, status: pending[s.slug] ? pending[s.slug].status : (done[s.slug] ? 'done' : 'none'), pct: pending[s.slug] ? (pending[s.slug].status === 'queued' ? 0 : pending[s.slug].pct || 0) : 0, stage: pending[s.slug] ? (pending[s.slug].status === 'queued' ? 'Waiting for the renderer' + (queuedAhead(pending[s.slug]) ? ' (' + queuedAhead(pending[s.slug]) + ' ahead of you)' : '') : pending[s.slug].stage || 'Starting') : null })); } function enqueue(email, username, slug) { if (!available()) return { error: 'The Video Maker is not set up on this server yet.' }; const src = SOURCES.find(s => s.slug === slug); if (!src) return { error: 'Pick a video.' }; const j = jobs(); if (j.find(x => x.username === username && x.slug === slug && (x.status === 'queued' || x.status === 'working'))) return { ok: true, status: 'queued' }; const d = j.find(x => x.username === username && x.slug === slug && x.status === 'done'); if (d) return { ok: true, status: 'done', url: d.url }; if (j.filter(x => x.status === 'queued').length > 30) return { error: 'The render queue is full right now. Try again in a few minutes.' }; j.push({ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), email, username, slug, status: 'queued', at: Date.now() }); saveJobs(j); return { ok: true, status: 'queued', ahead: j.filter(x => x.status === 'queued').length - 1 }; } let busy = false; async function tick() { if (busy || !R || !available()) return; const j = jobs(); const job = j.find(x => x.status === 'queued'); if (!job) return; busy = true; try { job.status = 'working'; job.startedAt = Date.now(); saveJobs(j); const url = await render(job); const jj = jobs(); const k = jj.find(x => x.id === job.id); if (k) { k.status = 'done'; k.url = url; k.doneAt = Date.now(); saveJobs(jj); } console.log('videomaker done', job.username, job.slug, url); } catch (e) { const jj = jobs(); const k = jj.find(x => x.id === job.id); if (k) { k.status = 'failed'; k.error = String(e.message || e).slice(0, 200); k.doneAt = Date.now(); saveJobs(jj); } console.error('videomaker failed', job.username, job.slug, e.message); } finally { busy = false; } } async function render(job) { const src = SOURCES.find(s => s.slug === job.slug); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-')); const srcFile = path.join(dir, 'src.mp4'), qrFile = path.join(dir, 'qr.png'), endFile = path.join(dir, 'end.mp4'), outFile = path.join(dir, 'out.mp4'); try { setProgress(job.id, 3, 'Fetching the video'); await download(src.url, srcFile); const { w, h } = await probe(srcFile); const total = (await durationOf(srcFile)) + 4.5; setProgress(job.id, 20, 'Drawing your end card'); const link = 'instantadpay.com/join/' + job.username; await QR.toFile(qrFile, 'https://' + link, { width: Math.round(Math.min(w, h) * 0.34), margin: 1, color: { dark: '#061c17', light: '#ffffff' } }); const F = font(); const portrait = h > w; const big = Math.round(Math.min(w, h) * (portrait ? 0.075 : 0.07)), mid = Math.round(big * 0.62), small = Math.round(big * 0.48); const qrSize = Math.round(Math.min(w, h) * 0.34); // every line is sized to fit 90% of the frame width (DejaVu Bold averages ~0.62em per glyph); portrait splits the long lines in two const maxW = Math.round(w * 0.9); const fit = (t, max) => Math.max(18, Math.min(max, Math.floor(maxW / (t.length * 0.62)))); const titles = portrait ? ['Join my line', 'on InstantAdPay'] : ['Join my line on InstantAdPay']; const foots = portrait ? ['Free to join. Paid in the same transaction.', 'Not investment advice.'] : ['Free to join. Paid in the same transaction. Not investment advice.']; const bigS = Math.min(...titles.map(t => fit(t, big))), midS = fit('@' + job.username, mid), smallS = fit(link, small), footS = Math.min(...foots.map(t => fit(t, Math.round(small * 0.8)))); const yTitle = Math.round(h * (portrait ? 0.16 : 0.14)), yQr = Math.round(h * 0.30), yName = yQr + qrSize + Math.round(h * 0.03), yLink = yName + midS + Math.round(h * 0.015), yFootEnd = h - Math.round(h * 0.08); const draw = (t, color, size, y) => 'drawtext=fontfile=' + F + ":text='" + ffText(t) + "':fontcolor=" + color + ':fontsize=' + size + ':x=(w-text_w)/2:y=' + y; const steps = []; titles.forEach((t, i) => steps.push(draw(t, '0x43e8c3', bigS, yTitle + Math.round(i * bigS * 1.2)))); steps.push(draw('@' + job.username, '0xffffff', midS, yName)); steps.push(draw(link, '0xffd15c', smallS, yLink)); foots.forEach((t, i) => steps.push(draw(t, '0x8fd8c4', footS, yFootEnd - Math.round((foots.length - 1 - i) * footS * 1.35)))); const filters = [ '[0:v][1:v]overlay=(W-w)/2:' + yQr + '[b]', '[b]' + steps.join(',') + ',format=yuv420p[v]' ].join(';'); await run('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=0x061c17:s=' + w + 'x' + h + ':r=30:d=4.5', '-i', qrFile, '-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=stereo', '-filter_complex', filters, '-map', '[v]', '-map', '2:a', '-t', '4.5', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '24', '-c:a', 'aac', '-shortest', endFile], 120000); setProgress(job.id, 30, 'Stitching your card onto the video'); await run('ffmpeg', ['-y', '-loglevel', 'error', '-progress', 'pipe:1', '-i', srcFile, '-i', endFile, '-filter_complex', '[0:v]fps=30,scale=' + w + ':' + h + ',format=yuv420p,setsar=1[v0];[0:a]aformat=sample_rates=44100:channel_layouts=stereo[a0];[1:v]fps=30,scale=' + w + ':' + h + ',format=yuv420p,setsar=1[v1];[1:a]aformat=sample_rates=44100:channel_layouts=stereo[a1];[v0][a0][v1][a1]concat=n=2:v=1:a=1[v][a]', '-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '25', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', outFile], 420000, out => { const m = /out_time_ms=(\d+)/g; let last = null, x; while ((x = m.exec(out))) last = x[1]; if (last && total) setProgress(job.id, 30 + 60 * Math.min(1, (Number(last) / 1e6) / total)); }); setProgress(job.id, 92, 'Uploading'); const key = 'promo/made/' + job.username + '/' + job.slug + '.mp4'; return await R.spaces.put(key, fs.readFileSync(outFile), 'video/mp4'); } finally { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) {} } } module.exports = { init, list, enqueue, available, SOURCES };