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>
This commit is contained in:
m3ultra 2026-07-27 13:30:49 +10:00
parent 874aaaefa3
commit 96bba8e538
4 changed files with 77 additions and 15 deletions

View File

@ -2,11 +2,12 @@
import contextlib
import os
import re
import threading
import cv2
import numpy as np
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel
@ -98,6 +99,41 @@ def open_clip(body: OpenBody):
return meta()
UPLOAD_CAP = 8 * 2 ** 30 # 8GB — plenty for ProRes sources on this box
@app.post("/api/upload")
async def upload(request: Request, name: str = "clip.mp4"):
"""Raw-body clip upload from the browser; lands in samples/uploads and opens."""
safe = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(name)) or "clip.mp4"
updir = os.path.join(ROOT, "samples", "uploads")
os.makedirs(updir, exist_ok=True)
dest = os.path.join(updir, safe)
size = 0
try:
with open(dest, "wb") as f:
async for chunk in request.stream():
size += len(chunk)
if size > UPLOAD_CAP:
raise HTTPException(413, "upload too large")
f.write(chunk)
except HTTPException:
os.remove(dest)
raise
if size == 0:
os.remove(dest)
raise HTTPException(400, "empty upload")
return open_clip(OpenBody(path=dest))
@app.get("/api/download/{name}")
def download(name: str):
p = os.path.join(export.OUTPUT_DIR, os.path.basename(name))
if not os.path.isfile(p):
raise HTTPException(404)
return FileResponse(p, filename=os.path.basename(p))
@app.get("/api/meta")
def meta():
c = clip()

View File

@ -112,21 +112,36 @@ function updateLabels() {
// ---------- 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 {
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`);
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 ----------
@ -203,9 +218,11 @@ async function track(fromHere) {
// ---------- exports ----------
function addOutput(p) {
const d = document.createElement('div');
d.textContent = p;
$('#outlist').prepend(d);
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) {
@ -231,6 +248,11 @@ async function exportTrim() {
$('#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); };

View File

@ -11,6 +11,8 @@
<h1>ROTO<span>GOD</span></h1>
<input id="path" placeholder="/absolute/path/to/clip.mp4" spellcheck="false">
<button id="load">Load</button>
<input type="file" id="file" accept="video/*,.mp4,.mov,.mkv,.webm" hidden>
<button id="upload" title="upload a clip from this device"></button>
<span id="engine" class="badge">engine: …</span>
<span id="toast"></span>
</header>

View File

@ -57,7 +57,9 @@ progress { width: 100%; height: 6px; accent-color: var(--accent); }
.obj.active { border-color: var(--accent); background: #14303f; }
.obj .swatch { width: 10px; height: 10px; border-radius: 50%; }
#outlist div { padding: 3px 0; border-bottom: 1px dotted var(--line); word-break: break-all; font-size: 11px; }
#outlist a { display: block; color: var(--accent); text-decoration: none; padding: 3px 0;
border-bottom: 1px dotted var(--line); word-break: break-all; font-size: 11px; }
#outlist a:hover { text-decoration: underline; }
footer { border-top: 1px solid var(--line); background: var(--panel); padding: 6px 14px 10px; }
#transport { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }