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:
+3
-2
@@ -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 = '<p class="muted small">The Video Maker is warming up on the server. Check back shortly.</p>'; return; }
|
||||
if (!r.username) { box.innerHTML = '<p class="muted small">Pick a username first (Profile); it goes on the end card.</p>'; return; }
|
||||
try {
|
||||
const v = await (await fetch('/api/my/toolkit/videos')).json(); if (v.error) return;
|
||||
box.innerHTML = v.list.map(x => '<div class="tk-vid"><b>' + tkEsc(x.title) + '</b>' + (x.status === 'done' ? '<a class="btn small" href="' + tkEsc(x.url) + '" target="_blank" rel="noopener" download>Download</a> <button type="button" class="btn small sec" data-copyvid="' + tkEsc(x.url) + '">Copy link</button>' : x.status === 'queued' || x.status === 'working' ? '<span class="st">Rendering, about a minute. It appears here when done.</span>' : '<button type="button" class="btn small sec" data-mkvid="' + tkEsc(x.slug) + '">Make mine</button>') + '</div>').join('');
|
||||
if (v.list.some(x => x.status === 'queued' || x.status === 'working')) setTimeout(() => loadTkVideos(r), 15000);
|
||||
box.innerHTML = v.list.map(x => '<div class="tk-vid"><b>' + tkEsc(x.title) + '</b>' + (x.status === 'done' ? '<a class="btn small" href="' + tkEsc(x.url) + '" target="_blank" rel="noopener" download>Download</a> <button type="button" class="btn small sec" data-copyvid="' + tkEsc(x.url) + '">Copy link</button>' : x.status === 'queued' || x.status === 'working' ? '<div class="tk-bar"><i style="width:' + (x.pct || 0) + '%"></i></div><span class="st">' + tkEsc(x.stage || 'Starting') + (x.status === 'working' ? ' · ' + (x.pct || 0) + '%' : '') + '</span>' : '<button type="button" class="btn small sec" data-mkvid="' + tkEsc(x.slug) + '">Make mine</button>') + '</div>').join('');
|
||||
clearTimeout(tkVidTimer); if (v.list.some(x => x.status === 'queued' || x.status === 'working')) tkVidTimer = setTimeout(() => loadTkVideos(r), 2500);
|
||||
} catch (e) {}
|
||||
}
|
||||
async function loadTkSplit() {
|
||||
|
||||
@@ -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}
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
<title>Member area | InstantAdPay</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260914c">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260914d">
|
||||
</head>
|
||||
<body class="bo-body">
|
||||
|
||||
@@ -984,7 +984,7 @@
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/wallet.js?v=20260911a"></script>
|
||||
<script src="/assets/promo.js?v=20260911a"></script>
|
||||
<script src="/assets/my.js?v=20260914f"></script>
|
||||
<script src="/assets/my.js?v=20260914g"></script>
|
||||
<script src="/assets/chat.js?v=20260907l"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+18
-6
@@ -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) {} }
|
||||
|
||||
Reference in New Issue
Block a user