be2d2719e5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
137 lines
6.4 KiB
JavaScript
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 };
|