rotogod/rotogod/server.py
m3ultra 96bba8e538 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>
2026-07-27 13:30:49 +10:00

316 lines
8.9 KiB
Python

"""rotogod server — FastAPI over one clip + one roto session (single-user tool)."""
import contextlib
import os
import re
import threading
import cv2
import numpy as np
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel
from . import export, jobs, segment
from .video import Clip
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WEB = os.path.join(ROOT, "web")
# object colors, RGB — keep in sync with app.js PALETTE
PALETTE = [(122, 208, 255), (255, 138, 101), (129, 255, 141),
(255, 121, 198), (255, 216, 107), (167, 139, 250)]
STATE: dict = {"clip": None, "session": None}
@contextlib.asynccontextmanager
async def lifespan(app):
if os.environ.get("ROTOGOD_PRELOAD", "1") == "1":
threading.Thread(target=lambda: _safe_load(), daemon=True).start()
yield
def _safe_load():
try:
segment.ENGINE.ensure_loaded()
except Exception:
pass # status carries the error; UI shows it
app = FastAPI(title="rotogod", lifespan=lifespan)
def clip() -> Clip:
c = STATE["clip"]
if c is None:
raise HTTPException(400, "no clip loaded")
return c
def sess() -> segment.RotoSession:
s = STATE["session"]
if s is None:
raise HTTPException(400, "no roto session — set in/out and Start roto")
return s
# -- static ---------------------------------------------------------------
@app.get("/")
def index():
return FileResponse(os.path.join(WEB, "index.html"))
@app.get("/static/{name}")
def static(name: str):
p = os.path.join(WEB, os.path.basename(name))
if not os.path.isfile(p):
raise HTTPException(404)
return FileResponse(p)
# -- clip -----------------------------------------------------------------
class OpenBody(BaseModel):
path: str
# clip paths must live under one of these roots (ship-check #3: no open path oracle)
ROOTS = [os.path.realpath(os.path.expanduser(p))
for p in os.environ.get("ROTOGOD_ROOTS", "~").split(":") if p.strip()]
@app.post("/api/open")
def open_clip(body: OpenBody):
path = os.path.realpath(os.path.expanduser(body.path.strip()))
if not any(path == r or path.startswith(r + os.sep) for r in ROOTS):
raise HTTPException(403, "path outside ROTOGOD_ROOTS")
if not os.path.isfile(path):
raise HTTPException(400, f"not a file: {path}")
old = STATE["clip"]
try:
STATE["clip"] = Clip(path)
except Exception as e:
raise HTTPException(400, f"could not open: {e}")
STATE["session"] = None
if old:
old.close()
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()
return {"path": c.path, "width": c.width, "height": c.height,
"fps": c.fps, "nframes": c.nframes, "duration": c.duration}
@app.get("/api/frame/{idx}")
def frame(idx: int, w: int = 1280):
return Response(clip().jpeg(idx, maxw=w), media_type="image/jpeg")
@app.get("/api/thumbs")
def thumbs(n: int = 60):
return Response(clip().thumb_sprite(n=n), media_type="image/jpeg")
class TrimBody(BaseModel):
in_f: int
out_f: int
exact: bool = True
@app.post("/api/trim")
def trim(body: TrimBody):
c = clip()
job = jobs.start("trim", lambda j: export.trim(
c.path, body.in_f, body.out_f, c.fps, body.exact))
return {"job": job.id}
# -- roto -----------------------------------------------------------------
class RotoStartBody(BaseModel):
in_f: int
out_f: int
@app.post("/api/roto/start")
def roto_start(body: RotoStartBody):
c = clip()
in_f = max(0, body.in_f)
out_f = min(c.nframes - 1, body.out_f)
if out_f <= in_f:
raise HTTPException(400, "out point must be after in point")
if out_f - in_f + 1 > 2000:
raise HTTPException(400, "range > 2000 frames — trim the scene first")
def build(job):
job.message = "decoding frames"
frames = []
for i in range(in_f, out_f + 1):
frames.append(c.frame(i).copy())
job.progress = 0.5 * (i - in_f + 1) / (out_f - in_f + 1)
job.message = "building SAM2 session"
STATE["session"] = segment.RotoSession(frames, in_f, out_f, c.width, c.height)
job.message = "ready"
return {"in_f": in_f, "out_f": out_f, "n": out_f - in_f + 1}
return {"job": jobs.start("roto_start", build).id}
class ClickBody(BaseModel):
frame: int
obj: int
points: list[list[float]] # full set for this frame+obj, video-pixel coords
labels: list[int] # 1 = include, 0 = exclude
@app.post("/api/roto/click")
def roto_click(body: ClickBody):
s = sess()
if s.propagating:
raise HTTPException(409, "propagation running — wait for it to finish")
try:
s.click(body.frame, body.obj, body.points, body.labels)
except ValueError as e:
raise HTTPException(400, str(e))
return {"ok": True}
class PropagateBody(BaseModel):
start: int | None = None # absolute frame; None = from first prompted frame
@app.post("/api/roto/propagate")
def roto_propagate(body: PropagateBody):
s = sess()
if s.propagating:
raise HTTPException(409, "already propagating")
if not s.clicks:
raise HTTPException(400, "no prompts — click the actor first")
def run(job):
def cb(frame_idx, done):
job.progress = min(1.0, done / s.n)
job.message = f"frame {s.in_f + frame_idx}"
s.propagate(start_abs=body.start, progress_cb=cb)
return {"tracked": s.tracked_frames}
return {"job": jobs.start("propagate", run).id}
@app.get("/api/roto/overlay/{frame_idx}.png")
def roto_overlay(frame_idx: int):
s = sess()
try:
li = s.local(frame_idx)
except ValueError as e:
raise HTTPException(400, str(e))
rgba = np.zeros((s.height, s.width, 4), np.uint8)
for oid in s.object_ids():
m = s.get_mask(oid, li)
if m is None:
continue
r, g, b = PALETTE[(oid - 1) % len(PALETTE)]
rgba[m] = (b, g, r, 110)
edges = cv2.findContours(m.astype(np.uint8), cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)[0]
cv2.drawContours(rgba, edges, -1, (b, g, r, 255), 2)
ok, buf = cv2.imencode(".png", rgba)
return Response(buf.tobytes(), media_type="image/png")
@app.get("/api/roto/status")
def roto_status():
s = STATE["session"]
if s is None:
return {"active": False}
return {"active": True, "in_f": s.in_f, "out_f": s.out_f, "n": s.n,
"objects": s.object_ids(), "propagating": s.propagating,
"masked_frames": len({k[1] for k in s.masks})}
@app.post("/api/roto/reset")
def roto_reset():
STATE["session"] = None
return {"ok": True}
class ExportBody(BaseModel):
kind: str = "prores" # prores | webm | matte | green
feather: int = 0
@app.post("/api/export")
def export_roto(body: ExportBody):
c, s = clip(), sess()
if not s.masks:
raise HTTPException(400, "no masks yet — Track first")
def run(job):
def cb(done, total):
job.progress = 0.8 * done / total
job.message = f"rendering {done}/{total}"
job.message = "encoding"
return export.export_roto(c, s, kind=body.kind, feather=body.feather,
progress_cb=cb)
return {"job": jobs.start(f"export_{body.kind}", run).id}
# -- misc -----------------------------------------------------------------
@app.get("/api/job/{job_id}")
def job(job_id: str):
j = jobs.JOBS.get(job_id)
if j is None:
raise HTTPException(404)
return j.to_dict()
@app.get("/api/status")
def status():
c = STATE["clip"]
return {"engine": segment.ENGINE.status,
"model": segment.MODEL_ID,
"clip": meta() if c else None,
"roto": roto_status()}