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 contextlib
import os import os
import re
import threading import threading
import cv2 import cv2
import numpy as np import numpy as np
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse, Response
from pydantic import BaseModel from pydantic import BaseModel
@ -98,6 +99,41 @@ def open_clip(body: OpenBody):
return meta() 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") @app.get("/api/meta")
def meta(): def meta():
c = clip() c = clip()

View File

@ -112,21 +112,36 @@ function updateLabels() {
// ---------- clip ---------- // ---------- 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() { async function loadClip() {
const path = $('#path').value.trim(); const path = $('#path').value.trim();
if (!path) return toast('enter a clip path', true); if (!path) return toast('enter a clip path', true);
stopPlay(); stopPlay();
try { try {
meta = await api('api/open', { path }); applyMeta(await api('api/open', { path }));
} catch (e) { return toast(e.message, true); } } catch (e) { toast(e.message, true); }
localStorage.setItem('rotogod_path', path); }
cur = 0; inF = 0; outF = meta.nframes - 1;
roto = { active: false }; clicks = {}; renderObjects(); async function uploadClip(f) {
cv.width = meta.width; cv.height = meta.height; stopPlay();
$('#scrub').max = meta.nframes - 1; toast(`uploading ${f.name}`);
$('#thumbs').src = `api/thumbs?n=60&v=${Date.now()}`; try {
await showFrame(0); const r = await fetch('api/upload?name=' + encodeURIComponent(f.name), { method: 'POST', body: f });
toast(`${meta.width}×${meta.height} · ${meta.fps.toFixed(2)}fps · ${meta.nframes}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 ---------- // ---------- playback / scrub ----------
@ -203,9 +218,11 @@ async function track(fromHere) {
// ---------- exports ---------- // ---------- exports ----------
function addOutput(p) { function addOutput(p) {
const d = document.createElement('div'); const a = document.createElement('a');
d.textContent = p; a.href = 'api/download/' + encodeURIComponent(p.split('/').pop());
$('#outlist').prepend(d); a.textContent = p.split('/').pop();
a.title = p;
$('#outlist').prepend(a);
} }
async function exportRoto(kind) { async function exportRoto(kind) {
@ -231,6 +248,11 @@ async function exportTrim() {
$('#load').onclick = loadClip; $('#load').onclick = loadClip;
$('#path').addEventListener('keydown', e => { if (e.key === 'Enter') 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; $('#play').onclick = togglePlay;
$('#prev').onclick = () => { stopPlay(); showFrame(cur - 1); }; $('#prev').onclick = () => { stopPlay(); showFrame(cur - 1); };
$('#next').onclick = () => { stopPlay(); showFrame(cur + 1); }; $('#next').onclick = () => { stopPlay(); showFrame(cur + 1); };

View File

@ -11,6 +11,8 @@
<h1>ROTO<span>GOD</span></h1> <h1>ROTO<span>GOD</span></h1>
<input id="path" placeholder="/absolute/path/to/clip.mp4" spellcheck="false"> <input id="path" placeholder="/absolute/path/to/clip.mp4" spellcheck="false">
<button id="load">Load</button> <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="engine" class="badge">engine: …</span>
<span id="toast"></span> <span id="toast"></span>
</header> </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.active { border-color: var(--accent); background: #14303f; }
.obj .swatch { width: 10px; height: 10px; border-radius: 50%; } .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; } 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; } #transport { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }