Video Maker: live progress bar (stage labels + ffmpeg -progress percent, queue position), page polls every 2.5s

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-14 09:04:38 -05:00
parent d57fc40299
commit 32028b1e8e
4 changed files with 24 additions and 10 deletions
+18 -6
View File
@@ -39,11 +39,17 @@ function saveJobs(j) { try { fs.writeFileSync(FILE(), JSON.stringify(j.slice(-50
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) {
function run(cmd, args, timeoutMs, onOut) {
return new Promise((resolve, reject) => {
const p = spawn(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'] }); let err = '';
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); });
@@ -65,10 +71,11 @@ function probe(file) {
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') }));
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.' };
@@ -101,8 +108,10 @@ async function render(job) {
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 { 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);
@@ -124,9 +133,12 @@ async function render(job) {
'[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);
await run('ffmpeg', ['-y', '-loglevel', 'error', '-i', srcFile, '-i', endFile, '-filter_complex',
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);
'-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) {} }