diff --git a/public/assets/my.js b/public/assets/my.js index 692d3b2..833e63a 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -1510,14 +1510,15 @@ if (!nex) $('tkNexusLocked').textContent = 'Unlocks at Nexus: five qualifying buyers. Then you get team triage across three levels with one-click nudges, AI-drafted team broadcasts, credit grants to your people, a co-branded join page and your own partner code.'; else { loadTkTeam(); renderTkPartner(r); } } + let tkVidTimer = null; async function loadTkVideos(r) { const box = $('tkVideos'); if (!r.videoMaker) { box.innerHTML = '

The Video Maker is warming up on the server. Check back shortly.

'; return; } if (!r.username) { box.innerHTML = '

Pick a username first (Profile); it goes on the end card.

'; return; } try { const v = await (await fetch('/api/my/toolkit/videos')).json(); if (v.error) return; - box.innerHTML = v.list.map(x => '
' + tkEsc(x.title) + '' + (x.status === 'done' ? 'Download ' : x.status === 'queued' || x.status === 'working' ? 'Rendering, about a minute. It appears here when done.' : '') + '
').join(''); - if (v.list.some(x => x.status === 'queued' || x.status === 'working')) setTimeout(() => loadTkVideos(r), 15000); + box.innerHTML = v.list.map(x => '
' + tkEsc(x.title) + '' + (x.status === 'done' ? 'Download ' : x.status === 'queued' || x.status === 'working' ? '
' + tkEsc(x.stage || 'Starting') + (x.status === 'working' ? ' ยท ' + (x.pct || 0) + '%' : '') + '' : '') + '
').join(''); + clearTimeout(tkVidTimer); if (v.list.some(x => x.status === 'queued' || x.status === 'working')) tkVidTimer = setTimeout(() => loadTkVideos(r), 2500); } catch (e) {} } async function loadTkSplit() { diff --git a/public/assets/site.css b/public/assets/site.css index e186181..e73acf4 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -701,6 +701,7 @@ img{max-width:100%} .tk-hist{border-top:1px solid var(--line);padding:8px 0;font-size:13px} .tk-hist .muted{font-size:12px} .tk-sec{margin-top:18px;padding-top:14px;border-top:1px solid var(--line)} .tk-grid{display:flex;gap:8px;flex-wrap:wrap} .tk-vids{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px} .tk-vid{border:1px solid var(--line);border-radius:10px;padding:10px 12px;font-size:13px} .tk-vid b{display:block;font-weight:600;margin-bottom:4px} .tk-vid .st{font-size:12px;color:var(--muted)} +.tk-bar{height:8px;border-radius:99px;background:rgba(255,255,255,.08);overflow:hidden;margin:6px 0 4px} .tk-bar i{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,#1fb894,#43e8c3);transition:width .6s ease;min-width:4px} .tk-table{width:100%;border-collapse:collapse;font-size:13px} .tk-table th,.tk-table td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)} .tk-table th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)} .tk-table td.n{font-variant-numeric:tabular-nums;text-align:right} .tk-table th.n{text-align:right} .tk-table tr.best td{color:var(--mint)} .tk-team{border:1px solid var(--line);border-radius:10px;padding:8px 12px;margin:6px 0;font-size:13px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;justify-content:space-between} .tk-team .who b{font-weight:600} .tk-team .who span{color:var(--muted);font-size:12px} .tk-team.stalled{border-color:rgba(255,209,92,.45)} .tk-nudge{width:100%;margin:8px 0 0;padding:8px 12px;border:1px dashed var(--line);border-radius:10px;font-size:13px} diff --git a/public/my.html b/public/my.html index 5f474ea..15d35c4 100644 --- a/public/my.html +++ b/public/my.html @@ -5,7 +5,7 @@ Member area | InstantAdPay - + @@ -984,7 +984,7 @@ - + diff --git a/videomaker.js b/videomaker.js index f56e319..0238c6e 100644 --- a/videomaker.js +++ b/videomaker.js @@ -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) {} }