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:
parent
874aaaefa3
commit
96bba8e538
@ -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()
|
||||
|
||||
48
web/app.js
48
web/app.js
@ -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); };
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user