const $ = s => document.querySelector(s); const sleep = ms => new Promise(r => setTimeout(r, ms)); const PALETTE = ['#7ad0ff', '#ff8a65', '#81ff8d', '#ff79c6', '#ffd86b', '#a78bfa']; const cv = $('#cv'), ctx = cv.getContext('2d'); let meta = null, cur = 0, inF = 0, outF = 0; let roto = { active: false }; let objects = [1], activeObj = 1; let clicks = {}; // obj -> frame -> {points:[[x,y]..], labels:[..]} let playTimer = null; let frameToken = 0, overlayV = 0; // ---------- api / ui plumbing ---------- async function api(path, body) { const r = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : undefined); if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail || r.statusText); } return r.json(); } let toastTimer = null; function toast(msg, err) { const t = $('#toast'); t.textContent = msg; t.className = err ? 'err' : ''; t.style.opacity = 1; clearTimeout(toastTimer); toastTimer = setTimeout(() => t.style.opacity = 0, err ? 6000 : 3000); } function loadImg(src) { return new Promise((res, rej) => { const im = new Image(); im.onload = () => res(im); im.onerror = rej; im.src = src; }); } async function pollJob(id, onprog) { for (;;) { const j = await api(`api/job/${id}`); $('#prog').value = j.progress; $('#progText').textContent = j.status === 'running' ? (j.message || j.kind) : ''; if (onprog) await onprog(j); if (j.status === 'done') { $('#prog').value = 0; return j; } if (j.status === 'error') { $('#prog').value = 0; throw new Error((j.error || 'job failed').trim().split('\n').pop()); } await sleep(300); } } // ---------- frame display ---------- async function showFrame(i) { if (!meta) return; cur = Math.max(0, Math.min(i, meta.nframes - 1)); $('#scrub').value = cur; updateLabels(); const tok = ++frameToken; try { const img = await loadImg(`api/frame/${cur}?w=1280`); if (tok !== frameToken) return; ctx.drawImage(img, 0, 0, cv.width, cv.height); if (roto.active && $('#showOverlay').checked && cur >= roto.in_f && cur <= roto.out_f) { try { const ov = await loadImg(`api/roto/overlay/${cur}.png?v=${overlayV}`); if (tok !== frameToken) return; ctx.drawImage(ov, 0, 0, cv.width, cv.height); } catch (e) { /* no overlay yet */ } drawClickMarkers(); } } catch (e) { /* frame fetch raced a reload */ } } function drawClickMarkers() { for (const obj of objects) { const c = (clicks[obj] || {})[cur]; if (!c) continue; c.points.forEach(([x, y], k) => { ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fillStyle = c.labels[k] ? '#28d478' : '#ff5252'; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke(); }); } } function updateLabels() { if (!meta) return; $('#frameLabel').textContent = `${cur} / ${meta.nframes - 1} · ${(cur / meta.fps).toFixed(2)}s`; $('#rangeInfo').textContent = roto.active ? `session: ${roto.in_f} → ${roto.out_f}` : `range: ${inF} → ${outF} (${outF - inF + 1}f)`; const n = meta.nframes; $('#shadeL').style.width = `${inF / n * 100}%`; $('#shadeR').style.width = `${(n - 1 - outF) / n * 100}%`; $('#playhead').style.left = `${cur / Math.max(n - 1, 1) * 100}%`; } // ---------- clip ---------- async function loadClip() { const path = $('#path').value.trim(); if (!path) return toast('enter a clip path', true); stopPlay(); try { meta = await api('api/open', { path }); } catch (e) { return toast(e.message, true); } localStorage.setItem('rotogod_path', path); cur = 0; inF = 0; outF = meta.nframes - 1; roto = { active: false }; clicks = {}; renderObjects(); cv.width = meta.width; cv.height = meta.height; $('#scrub').max = meta.nframes - 1; $('#thumbs').src = `api/thumbs?n=60&v=${Date.now()}`; await showFrame(0); toast(`${meta.width}×${meta.height} · ${meta.fps.toFixed(2)}fps · ${meta.nframes}f`); } // ---------- playback / scrub ---------- function stopPlay() { if (playTimer) { clearInterval(playTimer); playTimer = null; $('#play').textContent = '▶'; } } function togglePlay() { if (!meta) return; if (playTimer) return stopPlay(); $('#play').textContent = '⏸'; const loopIn = outF > inF ? inF : 0, loopOut = outF > inF ? outF : meta.nframes - 1; playTimer = setInterval(() => { showFrame(cur >= loopOut ? loopIn : cur + 1); }, 1000 / meta.fps); } // ---------- roto ---------- function renderObjects() { const box = $('#objects'); box.innerHTML = ''; for (const id of objects) { const el = document.createElement('div'); el.className = 'obj' + (id === activeObj ? ' active' : ''); el.innerHTML = `obj ${id}`; el.onclick = () => { activeObj = id; renderObjects(); }; box.appendChild(el); } } async function startRoto() { if (!meta) return toast('load a clip first', true); stopPlay(); try { const { job } = await api('api/roto/start', { in_f: inF, out_f: outF }); await pollJob(job); roto = await api('api/roto/status'); clicks = {}; overlayV++; await showFrame(Math.max(roto.in_f, Math.min(cur, roto.out_f))); toast('session ready — click the actor'); } catch (e) { toast(e.message, true); } } async function sendClick(x, y, label) { clicks[activeObj] = clicks[activeObj] || {}; const c = clicks[activeObj][cur] = clicks[activeObj][cur] || { points: [], labels: [] }; c.points.push([Math.round(x), Math.round(y)]); c.labels.push(label); try { await api('api/roto/click', { frame: cur, obj: activeObj, points: c.points, labels: c.labels }); overlayV++; await showFrame(cur); } catch (e) { c.points.pop(); c.labels.pop(); toast(e.message, true); } } async function track(fromHere) { stopPlay(); try { const { job } = await api('api/roto/propagate', { start: fromHere ? cur : null }); let tick = 0; await pollJob(job, async () => { if (++tick % 2 === 0) { overlayV++; await showFrame(cur); } }); overlayV++; await showFrame(cur); toast('tracked — scrub to check, click bad frames to fix'); } catch (e) { toast(e.message, true); } } // ---------- exports ---------- function addOutput(p) { const d = document.createElement('div'); d.textContent = p; $('#outlist').prepend(d); } async function exportRoto(kind) { try { const { job } = await api('api/export', { kind, feather: +$('#feather').value }); const j = await pollJob(job); addOutput(j.result); toast(`${kind} done`); } catch (e) { toast(e.message, true); } } async function exportTrim() { if (!meta) return; try { const { job } = await api('api/trim', { in_f: inF, out_f: outF, exact: $('#exactTrim').checked }); const j = await pollJob(job); addOutput(j.result); toast('trim done'); } catch (e) { toast(e.message, true); } } // ---------- events ---------- $('#load').onclick = loadClip; $('#path').addEventListener('keydown', e => { if (e.key === 'Enter') loadClip(); }); $('#play').onclick = togglePlay; $('#prev').onclick = () => { stopPlay(); showFrame(cur - 1); }; $('#next').onclick = () => { stopPlay(); showFrame(cur + 1); }; $('#prev10').onclick = () => { stopPlay(); showFrame(cur - 10); }; $('#next10').onclick = () => { stopPlay(); showFrame(cur + 10); }; $('#setIn').onclick = () => { inF = Math.min(cur, outF); updateLabels(); }; $('#setOut').onclick = () => { outF = Math.max(cur, inF); updateLabels(); }; $('#scrub').addEventListener('input', e => { stopPlay(); showFrame(+e.target.value); }); $('#startRoto').onclick = startRoto; $('#resetRoto').onclick = async () => { await api('api/roto/reset', {}); roto = { active: false }; clicks = {}; updateLabels(); showFrame(cur); toast('session dropped'); }; $('#addObj').onclick = () => { objects.push(objects.length + 1); activeObj = objects.length; renderObjects(); }; $('#track').onclick = () => track(false); $('#trackHere').onclick = () => track(true); $('#showOverlay').onchange = () => showFrame(cur); $('#trim').onclick = exportTrim; document.querySelectorAll('.exp').forEach(b => b.onclick = () => exportRoto(b.dataset.kind)); cv.addEventListener('pointerdown', e => { if (!meta) return; if (!roto.active) return toast('set in/out then Start roto'); if (cur < roto.in_f || cur > roto.out_f) return toast('outside session range', true); stopPlay(); const r = cv.getBoundingClientRect(); const x = (e.clientX - r.left) / r.width * meta.width; const y = (e.clientY - r.top) / r.height * meta.height; sendClick(x, y, (e.button === 2 || e.altKey) ? 0 : 1); }); cv.addEventListener('contextmenu', e => e.preventDefault()); $('#stage').addEventListener('wheel', e => { if (!meta) return; e.preventDefault(); stopPlay(); showFrame(cur + Math.sign(e.deltaY)); }, { passive: false }); $('#timeline').addEventListener('pointerdown', e => { if (!meta) return; stopPlay(); const r = e.currentTarget.getBoundingClientRect(); showFrame(Math.round((e.clientX - r.left) / r.width * (meta.nframes - 1))); }); document.addEventListener('keydown', e => { if (!meta || /INPUT/.test(e.target.tagName)) return; const step = e.shiftKey ? 10 : 1; if (e.key === 'ArrowLeft') { stopPlay(); showFrame(cur - step); } else if (e.key === 'ArrowRight') { stopPlay(); showFrame(cur + step); } else if (e.key === 'i') { inF = Math.min(cur, outF); updateLabels(); } else if (e.key === 'o') { outF = Math.max(cur, inF); updateLabels(); } else if (e.key === ' ') { e.preventDefault(); togglePlay(); } }); // ---------- boot ---------- renderObjects(); $('#path').value = localStorage.getItem('rotogod_path') || ''; (async function engineBadge() { for (;;) { try { const s = await api('api/status'); $('#engine').textContent = s.engine.startsWith('ready') ? s.engine : `engine: ${s.engine}`; if (s.engine.startsWith('ready') || s.engine.startsWith('error')) return; } catch (e) { /* server booting */ } await sleep(1500); } })();