Files

137 lines
6.4 KiB
JavaScript

// 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, '\\:'); }
// Alpine puts DejaVu in /usr/share/fonts/dejavu; Debian uses truetype/dejavu.
// Pick a real file rather than trusting ffmpeg's undefined fallback.
let _font = null;
function pickFont() {
if (_font) return _font;
const cands = [
'/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
'/usr/share/fonts/dejavu/DejaVuSans.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'
];
for (const c of cands) { try { if (fs.existsSync(c)) { _font = c; return c; } } catch (e) {} }
_font = cands[0];
return _font;
}
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 || pickFont();
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 };