modelbeast/server/main.py
MODELBEAST 605b1ae347 Phase 1 + framework: settings/secrets, queue lanes, job mgmt, inbox, 8 new operators
Framework:
- server/settings.py: key/value settings + secrets, env-injected into operator
  subprocesses, secret values masked in API and redacted from job logs
- runner: gpu/cpu/net concurrency lanes, job cancel/retry/delete, multi-input,
  graceful 'not installed' error when a tool venv is missing
- db: settings table, asset_ids column (migrated), MODELBEAST_DATA test override
- main: settings + job-action endpoints, inbox watch folder auto-ingest
- store: operators can tag output asset kind (splat, colmap_dataset)

Operators (11 total):
- fal_trellis/trellis2/hunyuan3d/rodin via shared _lib/fal_common.py (verified
  params + endpoint ids; recursive result-URL extractor handles per-endpoint keys)
- sf3d, trellis_mac: local MPS image-to-3D, installed with Metal kernels built,
  gated on owner HuggingFace auth
- colmap_poses (COLMAP 4.x + GLOMAP global mapper), brush_train (native Metal 3DGS)
- Scan pipeline validated end-to-end through the UI: frames -> colmap (48/48
  registered, 0.6px) -> brush -> splat.ply -> in-app SplatViewer

Frontend:
- Settings modal, operator gating (lock + disabled run when requires_env unmet),
  job cancel/retry/delete, Compare grid (multi-select side-by-side viewers),
  SplatViewer (gaussian-splats-3d, Ply format forced for extensionless URLs)

Tooling: scripts/install_{colmap,brush,sf3d,trellis_mac}.sh; vendor/ + venvs/
gitignored; tests/smoke.sh (12 checks passing); BENCHMARKS.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:42:27 +10:00

188 lines
6.5 KiB
Python

import asyncio
import json
from pathlib import Path
from fastapi import FastAPI, File, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from . import db, registry, settings as settings_mod, store
from .runner import runner
app = FastAPI(title="MODELBEAST")
WEB_DIST = db.ROOT / "web" / "dist"
INBOX = db.DATA / "inbox"
@app.on_event("startup")
async def startup():
runner.operators = registry.load_operators()
con = db.connect()
app.state.con = con
runner.get_settings = lambda: settings_mod.get_all(con)
asyncio.create_task(runner.worker())
asyncio.create_task(watch_inbox())
# -- operators ----------------------------------------------------------------
@app.get("/api/operators")
def list_operators():
return [{k: v for k, v in op.items() if k != "dir"} for op in runner.operators.values()]
# -- settings -----------------------------------------------------------------
@app.get("/api/settings")
def get_settings():
s = settings_mod.get_all(app.state.con)
view = settings_mod.public_view(s)
view["_secret_keys"] = sorted(settings_mod.SECRET_KEYS)
view["_env_keys"] = sorted(settings_mod.ENV_MAP.keys())
# env var names currently satisfied — lets the UI gate operators
view["_env_set"] = sorted(env for key, env in settings_mod.ENV_MAP.items() if s.get(key))
return view
@app.put("/api/settings")
def put_settings(payload: dict):
# ignore masked secret placeholders so re-saving the form doesn't wipe a key
clean = {k: v for k, v in payload.items()
if not (k in settings_mod.SECRET_KEYS and set(str(v)) <= {""})}
settings_mod.set_many(app.state.con, clean)
return get_settings()
# -- assets ---------------------------------------------------------------------
@app.get("/api/assets")
def list_assets():
return store.list_assets(app.state.con)
@app.post("/api/assets")
async def upload_asset(file: UploadFile = File(...)):
data = await file.read()
if not data:
raise HTTPException(400, "empty file")
return store.register_upload(app.state.con, file.filename or "unnamed", data)
@app.delete("/api/assets/{asset_id}")
def delete_asset(asset_id: str):
if not store.delete_asset(app.state.con, asset_id):
raise HTTPException(404, "no such asset")
return {"ok": True}
@app.get("/api/assets/{asset_id}/file")
def asset_file(asset_id: str, member: str | None = None):
asset = store.get_asset(app.state.con, asset_id)
if not asset:
raise HTTPException(404, "no such asset")
path = Path(asset["path"])
if member:
target = (path / member).resolve()
if not str(target).startswith(str(path.resolve())) or not target.is_file():
raise HTTPException(404, "no such member")
return FileResponse(target)
if path.is_dir():
files = sorted(p.name for p in path.iterdir() if p.is_file())
return JSONResponse({"folder": True, "files": files})
return FileResponse(path, filename=asset["name"])
# -- jobs -----------------------------------------------------------------------
@app.get("/api/jobs")
def list_jobs():
return runner.list_jobs(app.state.con)
@app.post("/api/jobs")
async def create_job(payload: dict):
operator = payload.get("operator")
if operator not in runner.operators:
raise HTTPException(400, f"unknown operator: {operator}")
asset_ids = payload.get("asset_ids")
if not asset_ids:
asset_ids = [payload["asset_id"]] if payload.get("asset_id") else []
for aid in asset_ids:
if not store.get_asset(app.state.con, aid):
raise HTTPException(400, f"unknown asset: {aid}")
params = payload.get("params") or {}
job = runner.create_job(app.state.con, operator, asset_ids, params)
await runner.broadcast({"type": "job", "job": job})
return job
@app.get("/api/jobs/{job_id}")
def get_job(job_id: str):
job = runner.get_job(app.state.con, job_id)
if not job:
raise HTTPException(404, "no such job")
return job
@app.post("/api/jobs/{job_id}/cancel")
async def cancel_job(job_id: str):
if not await runner.cancel(app.state.con, job_id):
raise HTTPException(400, "job not cancellable")
return {"ok": True}
@app.post("/api/jobs/{job_id}/retry")
async def retry_job(job_id: str):
job = runner.get_job(app.state.con, job_id)
if not job:
raise HTTPException(404, "no such job")
new = runner.create_job(app.state.con, job["operator"],
job.get("asset_ids") or [], job["params"])
await runner.broadcast({"type": "job", "job": new})
return new
@app.delete("/api/jobs/{job_id}")
def delete_job(job_id: str):
if not runner.delete_job(app.state.con, job_id):
raise HTTPException(400, "job running or missing")
return {"ok": True}
# -- inbox watch folder ---------------------------------------------------------
async def watch_inbox():
"""Ingest files dropped into data/inbox/ once their size is stable (avoids
grabbing partial copies). Lets UE/Blender/rsync drop outputs in for pickup."""
INBOX.mkdir(parents=True, exist_ok=True)
seen: dict[str, int] = {}
while True:
await asyncio.sleep(3)
try:
for p in INBOX.iterdir():
if p.name.startswith(".") or not p.exists():
continue
size = p.stat().st_size if p.is_file() else sum(
f.stat().st_size for f in p.rglob("*") if f.is_file())
if seen.get(str(p)) == size and size > 0:
asset = store.register_file(app.state.con, p, move=True,
meta={"source": "inbox"})
seen.pop(str(p), None)
await runner.broadcast({"type": "assets_changed", "assets": [asset["id"]]})
else:
seen[str(p)] = size
except Exception as e:
print(f"[inbox] {e}")
# -- websocket ---------------------------------------------------------------
@app.websocket("/ws")
async def ws(websocket: WebSocket):
await websocket.accept()
runner.subscribers.add(websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
runner.subscribers.discard(websocket)
# -- frontend (built) -----------------------------------------------------------
if WEB_DIST.exists():
app.mount("/", StaticFiles(directory=WEB_DIST, html=True), name="web")