rotogod/web/app.js
m3ultra 96bba8e538 feat: browser upload (raw-body, 8g cap) + click-to-download exports
Makes the digalot.fyi/rotogod/ proxy actually usable away from the box:
⬆ uploads land in samples/uploads and open; output list links through
GET /api/download/{name}. nginx side: 8g body cap, request buffering off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 13:30:49 +10:00

330 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ----------
function applyMeta(m) {
meta = m;
$('#path').value = m.path;
localStorage.setItem('rotogod_path', m.path);
cur = 0; inF = 0; outF = m.nframes - 1;
roto = { active: false }; clicks = {}; renderObjects();
cv.width = m.width; cv.height = m.height;
$('#scrub').max = m.nframes - 1;
$('#thumbs').src = `api/thumbs?n=60&v=${Date.now()}`;
showFrame(0);
toast(`${m.width}×${m.height} · ${m.fps.toFixed(2)}fps · ${m.nframes}f`);
}
async function loadClip() {
const path = $('#path').value.trim();
if (!path) return toast('enter a clip path', true);
stopPlay();
try {
applyMeta(await api('api/open', { path }));
} catch (e) { toast(e.message, true); }
}
async function uploadClip(f) {
stopPlay();
toast(`uploading ${f.name}`);
try {
const r = await fetch('api/upload?name=' + encodeURIComponent(f.name), { method: 'POST', body: f });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
applyMeta(await r.json());
} catch (e) { toast(e.message, true); }
}
// ---------- 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 = `<span class="swatch" style="background:${PALETTE[(id - 1) % PALETTE.length]}"></span>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 a = document.createElement('a');
a.href = 'api/download/' + encodeURIComponent(p.split('/').pop());
a.textContent = p.split('/').pop();
a.title = p;
$('#outlist').prepend(a);
}
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(); });
$('#upload').onclick = () => $('#file').click();
$('#file').addEventListener('change', e => {
if (e.target.files[0]) uploadClip(e.target.files[0]);
e.target.value = '';
});
$('#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);
}
})();