Files
instantadpay/videomaker.js
T

147 lines
13 KiB
JavaScript

// 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/<username>/<slug>.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 };