diff --git a/rotogod/server.py b/rotogod/server.py index 86a1523..6fb5adc 100644 --- a/rotogod/server.py +++ b/rotogod/server.py @@ -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() diff --git a/web/app.js b/web/app.js index dfec8eb..ddaf3ba 100644 --- a/web/app.js +++ b/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); }; diff --git a/web/index.html b/web/index.html index 3df44cf..1bfea87 100644 --- a/web/index.html +++ b/web/index.html @@ -11,6 +11,8 @@