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>
330 lines
11 KiB
JavaScript
330 lines
11 KiB
JavaScript
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);
|
||
}
|
||
})();
|