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>
This commit is contained in:
MODELBEAST 2026-07-12 21:42:27 +10:00
parent d0569dc746
commit 605b1ae347
40 changed files with 1533 additions and 141 deletions

8
.gitignore vendored
View File

@ -29,3 +29,11 @@ bin/*.o
# OS
.DS_Store
vendor/
# Brush runtime autotune cache
target/
# Brush prebuilt binary (reinstall via scripts/install_brush.sh)
bin/brush
bin/brush-app-aarch64-apple-darwin/

34
BENCHMARKS.md Normal file
View File

@ -0,0 +1,34 @@
# MODELBEAST Benchmarks (M3 Ultra, 256GB)
First measurements on this machine, recorded 2026-07-12. Fixtures are synthetic
(Blender-rendered Suzanne), so quality numbers are not representative of real
photography — these validate that the pipeline *runs* and how fast.
## Scan track (fully validated, no gated weights)
| Stage | Input | Settings | Result |
|---|---|---|---|
| `colmap_poses` | 48 frames @ 800×600 | sequential matcher, global (GLOMAP) mapper, OPENCV, CPU SIFT | **48/48 images registered, 1479 points, 0.60px mean reprojection error**; global mapper step ~1.0s; full job a few seconds |
| `brush_train` | above colmap_dataset | 1500 steps, max_res 800, sh 2 | ~3060s wall, 465KB splat.ply, renders in the in-app SplatViewer |
A full-quality `brush_train` run is 30000 steps (the default) — expect minutes,
and a much crisper splat than the 1500-step preview above.
## Mesh-gen (installed; first real run blocked on owner HuggingFace auth)
| Operator | Install | Runtime status |
|---|---|---|
| `sf3d` | venv + Metal texture_baker/uv_unwrapper kernels compiled OK; torch 2.13 MPS available | Runs end-to-end; **weights gated**`stabilityai/stable-fast-3d` returns `GatedRepoError` until the owner accepts the license + sets an HF token. Then expect seconds-to-a-minute on MPS. |
| `trellis_mac` | setup.sh built .venv (py3.11) + mtl* Metal kernels; torch 2.13 MPS available | Runs end-to-end; **weights gated** — needs HF access to `facebook/dinov3-vitl16-pretrain-lvd1689m` + `briaai/RMBG-2.0`. Expect ~35 min/gen once authed (M4 Pro reference; M3 Ultra should match or beat). |
| `fal_*` (trellis / trellis2 / hunyuan3d / rodin) | none (API) | Gated on `FAL_KEY`. Verified param surfaces; ~1s1min server-side per fal docs. |
## To unblock the gated local operators
1. Accept the model licenses on HuggingFace (one-time, usually instant):
- https://huggingface.co/stabilityai/stable-fast-3d
- https://huggingface.co/facebook/dinov3-vitl16-pretrain-lvd1689m
- https://huggingface.co/briaai/RMBG-2.0
2. Either `huggingface-cli login` on the machine, or paste an HF token into
Settings → "HuggingFace token" (injected as `HF_TOKEN` for the operators).
## Method
Timings are wall-clock from the job runner (`started_at`→`finished_at`), single
job at a time (gpu lane = 1). Re-run `tests/smoke.sh` for the framework
regression suite (12 checks, ~30s).

View File

@ -1,6 +1,19 @@
# MODELBEAST — Build Handoff Brief
**Audience:** the AI agent (Opus 4.8 / Claude Code) continuing this build. Read this file top to bottom, then read `PLAN.md`, then start at Phase 1. Do not re-research what is already verified here — every tool claim in this doc and PLAN.md was verified against primary sources on 2026-07-12 by research agents.
**Audience:** the AI agent (Opus 4.8 / Claude Code) continuing this build. Read this file top to bottom, then read `PLAN.md`. Do not re-research what is already verified here — every tool claim in this doc and PLAN.md was verified against primary sources on 2026-07-12 by research agents.
## PROGRESS (updated 2026-07-12, Opus 4.8 session)
**Done & verified end-to-end:**
- Git baseline. Framework: settings/secrets (`server/settings.py`, masked, env-injected, log-redacted), queue lanes (gpu=1/cpu=3/net=6), job cancel/retry/delete, multi-input job support, inbox watch folder (`data/inbox/`), `MODELBEAST_DATA` test override. `tests/smoke.sh` = 12 checks passing.
- Operators added (11 total): `fal_trellis`, `fal_trellis2`, `fal_hunyuan3d`, `fal_rodin` (shared `_lib/fal_common.py`, verified params, recursive result-URL extractor), `sf3d`, `trellis_mac` (both **installed** — venvs + Metal kernels built, torch 2.13 MPS; gated on owner HF auth), `colmap_poses`, `brush_train`.
- **Scan pipeline proven through the real UI**: frames → `colmap_poses` (48/48 registered, 0.6px) → `brush_train` → splat.ply → renders in in-app SplatViewer.
- Frontend: Settings modal, operator gating (🔒 + disabled run), job actions, Compare grid (multi-select side-by-side viewers), SplatViewer (`@mkkellogg/gaussian-splats-3d`, format forced to Ply since asset URLs are extensionless), splat/colmap_dataset asset kinds. All browser-verified.
- Install scripts: `scripts/install_{colmap,brush,sf3d,trellis_mac}.sh`. Vendored repos in `vendor/` (gitignored), venvs in `venvs/` and `vendor/trellis-mac/.venv` (gitignored).
**Not yet built (next up):** object_capture (Swift CLI; Xcode present), freemocap, gvhmr_import, blender_retarget (multi-input UI wiring), workflow presets (§4.5), tripo/meshy character APIs (Phase 4), archive_to_m4pro, LLM copilot. See §58 below.
**Owner action to unblock local mesh-gen:** accept HF licenses + set HF token (see BENCHMARKS.md). fal operators need `FAL_KEY` in Settings.
---

View File

@ -10,11 +10,19 @@ Local-first web app that turns videos, images, and 3D files into meshes, splats,
Open http://localhost:8777 (or `http://<tailscale-ip>:8777` from any device on the tailnet).
Drag/drop/paste any video, image, or 3D file; or drop files into `data/inbox/` for auto-ingest. Pick an operator, tune its parameters, run. Add API keys / HuggingFace token under ⚙ Settings. Use ⊞ Compare to view several outputs side by side.
## Develop
- Backend: `server/` — FastAPI + SQLite (`data/modelbeast.db`), job runner executes operators as subprocesses.
- Frontend: `web/` — React + Vite + three.js. After editing: `cd web && npm run build` (the server serves `web/dist`).
- Data: `data/assets/` (asset store), `data/jobs/` (job workdirs). Delete `data/` to reset everything.
- Backend: `server/` — FastAPI + SQLite (`data/modelbeast.db`), job runner runs operators as subprocesses across concurrency lanes (gpu=1, cpu=3, net=6). Settings/secrets in `server/settings.py` (env-injected, log-redacted).
- Frontend: `web/` — React + Vite + three.js + `@mkkellogg/gaussian-splats-3d`. After editing: `cd web && npm run build` (the server serves `web/dist`).
- Data: `data/assets/` (store), `data/jobs/` (job workdirs), `data/inbox/` (watch folder). Delete `data/` to reset. Tests use `MODELBEAST_DATA=<tmp>`.
- Tests: `./tests/smoke.sh` (12 framework checks). Benchmarks in [BENCHMARKS.md](BENCHMARKS.md).
- Heavy tools live in `vendor/` (repos) + `venvs/` (per-tool envs), both gitignored. Reinstall with `scripts/install_*.sh`.
## Setup for the local mesh generators (one-time)
`sf3d` and `trellis_mac` are installed but their weights are HuggingFace-gated. Accept the licenses (stabilityai/stable-fast-3d, facebook/dinov3-vitl16-pretrain-lvd1689m, briaai/RMBG-2.0), then `huggingface-cli login` or paste an HF token in Settings. Cloud `fal_*` operators need `FAL_KEY` in Settings.
## Operators
@ -22,11 +30,18 @@ Each subfolder of `server/operators/` with a `manifest.json` is an operator. The
Contract: the runner invokes `<python> run.py --input <asset> --outdir <jobdir> --params '<json>'`. Write outputs into the outdir; optionally write `result.json` (`{"outputs": [{"path": ..., "name": ..., "meta": ...}]}`) to control what gets registered as assets. stdout/stderr become the job log.
Current operators:
| id | what |
|---|---|
| `ffprobe` | media inspection |
| `ffmpeg_frames` | video → frames (fps, mpdecimate dedupe, blur cull, max cap) |
| `blender_convert` | GLB/GLTF/OBJ/FBX/STL/PLY/USD/BLEND → GLB/FBX/OBJ/USD/USDZ/STL/PLY/BLEND via headless Blender |
Manifest fields: `id, name, category, description, accepts, produces, resources` (gpu/cpu/net lane), `requires_env` (gates the operator until the key is set), `python` (absolute venv path for heavy tools), `params_schema`. Operators can tag output asset kind via `result.json` output `meta.kind` (e.g. `splat`, `colmap_dataset`).
Heavy operators (trellis-mac, SF3D, COLMAP/Brush, FreeMoCap…) get their own uv venv: set `"python": "<venv python path>"` in the manifest. See PLAN.md Phase 14.
Current operators:
| id | lane | what |
|---|---|---|
| `ffprobe` | cpu | media inspection |
| `ffmpeg_frames` | cpu | video → frames (fps, mpdecimate dedupe, blur cull, max cap) |
| `blender_convert` | cpu | universal 3D format conversion via headless Blender |
| `colmap_poses` | cpu | frames → camera poses + sparse cloud (COLMAP 4.x + GLOMAP) |
| `brush_train` | gpu | colmap dataset → 3D gaussian splat (Brush, native Metal) |
| `sf3d` | gpu | image → GLB locally (Stable-Fast-3D, MPS) *[HF-gated]* |
| `trellis_mac` | gpu | image → GLB+PBR locally (TRELLIS.2 MPS port) *[HF-gated]* |
| `fal_trellis` / `fal_trellis2` / `fal_hunyuan3d` / `fal_rodin` | net | image → mesh via fal.ai API *[needs FAL_KEY]* |
Heavy operators get their own uv venv (`"python": "<abs venv path>"` in the manifest). Remaining roadmap (object_capture, freemocap, retargeting, tripo/meshy character APIs, workflow presets, LLM copilot) is in [HANDOFF.md](HANDOFF.md) §58.

View File

@ -4,6 +4,7 @@ version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"aiofiles>=25.1.0",
"fal-client>=1.0.0",
"fastapi>=0.139.0",
"python-multipart>=0.0.32",
"uvicorn[standard]>=0.51.0",

16
scripts/install_brush.sh Executable file
View File

@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Brush — native Apple Silicon 3DGS trainer (prebuilt v0.3.0 binary). No auth.
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p bin && cd bin
URL="https://github.com/ArthurBrussee/brush/releases/download/v0.3.0/brush-app-aarch64-apple-darwin.tar.xz"
echo "[brush] downloading $URL"
curl -fL -o brush-app.tar.xz "$URL"
tar -xf brush-app.tar.xz
rm -f brush-app.tar.xz
# the tarball unpacks to brush-app-aarch64-apple-darwin/brush_app
xattr -dr com.apple.quarantine ./brush-app-aarch64-apple-darwin 2>/dev/null || true
chmod +x ./brush-app-aarch64-apple-darwin/brush_app 2>/dev/null || true
ln -sf brush-app-aarch64-apple-darwin/brush_app ./brush
echo "[brush] installed: $(pwd)/brush -> $(readlink ./brush)"
echo "[brush] done"

9
scripts/install_colmap.sh Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# COLMAP 4.x + integrated GLOMAP global mapper (arm64 bottle). No auth needed.
set -euo pipefail
echo "[colmap] brew install colmap ..."
brew install colmap
echo "[colmap] verifying ..."
colmap -h >/dev/null 2>&1 && echo "[colmap] colmap OK"
colmap global_mapper -h >/dev/null 2>&1 && echo "[colmap] global_mapper (GLOMAP) OK" || echo "[colmap] WARNING: global_mapper subcommand not found"
echo "[colmap] done"

35
scripts/install_sf3d.sh Executable file
View File

@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Stable Fast 3D (SF3D) — Stability, official MPS. Weights gated on HF: the owner
# must `huggingface-cli login` and accept the stabilityai/stable-fast-3d license
# once; otherwise the first run errors with a clear message.
set -euo pipefail
cd "$(dirname "$0")/.."
ROOT=$(pwd)
UV=/opt/homebrew/bin/uv
echo "[sf3d] OpenMP runtime for Metal/C++ kernel compile ..."
brew list libomp >/dev/null 2>&1 || brew install libomp
mkdir -p vendor
[ -d vendor/stable-fast-3d ] || git clone --depth 1 https://github.com/Stability-AI/stable-fast-3d vendor/stable-fast-3d
echo "[sf3d] creating venv (python 3.11) ..."
$UV venv --python 3.11 venvs/sf3d
PY="$ROOT/venvs/sf3d/bin/python"
echo "[sf3d] installing torch + build tooling ..."
$UV pip install --python "$PY" -U "setuptools==69.5.1" wheel ninja
$UV pip install --python "$PY" torch torchvision
echo "[sf3d] installing sf3d requirements ..."
cd vendor/stable-fast-3d
LIBOMP=$(brew --prefix libomp)
export CPPFLAGS="-Xclang -fopenmp -I$LIBOMP/include ${CPPFLAGS:-}"
export LDFLAGS="-L$LIBOMP/lib -lomp ${LDFLAGS:-}"
# --no-build-isolation so the local texture_baker/uv_unwrapper packages can see
# the torch we just installed at build time (they import torch in setup.py).
$UV pip install --python "$PY" --no-build-isolation -r requirements.txt
echo "[sf3d] verifying import ..."
"$PY" -c "import torch; print('[sf3d] torch', torch.__version__, 'mps', torch.backends.mps.is_available())"
echo "[sf3d] done — weights download on first run (needs HF login for stabilityai/stable-fast-3d)"

21
scripts/install_trellis_mac.sh Executable file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env bash
# trellis-mac — TRELLIS.2 MPS/Metal port (local SOTA image-to-3D). Its setup.sh
# creates its own .venv (py3.11), clones the mtl* Metal-kernel deps, and builds
# them. Gated HF models (facebook/dinov3-vitl16-*, briaai/RMBG-2.0) download at
# runtime — the owner must `hf auth login` and request access (usually instant).
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p vendor
[ -d vendor/trellis-mac ] || git clone --depth 1 https://github.com/shivampkumar/trellis-mac.git vendor/trellis-mac
cd vendor/trellis-mac
echo "[trellis] downloading Metal toolchain component (may be a no-op if present) ..."
xcodebuild -downloadComponent MetalToolchain 2>&1 | tail -3 || echo "[trellis] MetalToolchain step skipped/failed — setup.sh may still succeed"
echo "[trellis] running setup.sh (clones mtl* deps, builds Metal kernels, installs torch) ..."
echo "[trellis] this is the long pole (~10-20 min). SKIP_METAL=1 fallback available if it fails."
bash setup.sh
echo "[trellis] verifying ..."
[ -x .venv/bin/python ] && .venv/bin/python -c "import torch; print('[trellis] torch', torch.__version__, 'mps', torch.backends.mps.is_available())"
echo "[trellis] done — first generate.py run downloads gated HF weights (needs hf auth login)"

View File

@ -1,11 +1,13 @@
import json
import os
import sqlite3
import time
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / "data"
# Tests / alternate roots can override the data dir.
DATA = Path(os.environ.get("MODELBEAST_DATA", str(ROOT / "data")))
DB_PATH = DATA / "modelbeast.db"
SCHEMA = """
@ -24,6 +26,7 @@ CREATE TABLE IF NOT EXISTS jobs (
operator TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
asset_id TEXT,
asset_ids TEXT NOT NULL DEFAULT '[]',
params TEXT NOT NULL DEFAULT '{}',
outdir TEXT,
log TEXT NOT NULL DEFAULT '',
@ -32,8 +35,18 @@ CREATE TABLE IF NOT EXISTS jobs (
started_at REAL,
finished_at REAL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
"""
# Columns added after the original Phase 0 schema; applied idempotently so an
# existing data/modelbeast.db upgrades in place.
MIGRATIONS = [
("jobs", "asset_ids", "TEXT NOT NULL DEFAULT '[]'"),
]
def connect() -> sqlite3.Connection:
DATA.mkdir(parents=True, exist_ok=True)
@ -43,9 +56,18 @@ def connect() -> sqlite3.Connection:
con.row_factory = sqlite3.Row
con.execute("PRAGMA journal_mode=WAL")
con.executescript(SCHEMA)
_migrate(con)
return con
def _migrate(con: sqlite3.Connection) -> None:
for table, column, decl in MIGRATIONS:
cols = {r["name"] for r in con.execute(f"PRAGMA table_info({table})")}
if column not in cols:
con.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}")
con.commit()
def new_id() -> str:
return uuid.uuid4().hex[:12]
@ -56,7 +78,7 @@ def now() -> float:
def row_to_dict(row: sqlite3.Row) -> dict:
d = dict(row)
for key in ("meta", "params"):
for key in ("meta", "params", "asset_ids"):
if key in d and isinstance(d[key], str):
try:
d[key] = json.loads(d[key])

View File

@ -6,28 +6,49 @@ from fastapi import FastAPI, File, HTTPException, UploadFile, WebSocket, WebSock
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from . import db, registry, store
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()
app.state.con = db.connect()
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():
ops = []
for op in runner.operators.values():
public = {k: v for k, v in op.items() if k != "dir"}
ops.append(public)
return ops
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 ---------------------------------------------------------------------
@ -57,7 +78,7 @@ def asset_file(asset_id: str, member: str | None = None):
if not asset:
raise HTTPException(404, "no such asset")
path = Path(asset["path"])
if member: # file inside a folder asset (e.g. one frame)
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")
@ -79,11 +100,14 @@ async def create_job(payload: dict):
operator = payload.get("operator")
if operator not in runner.operators:
raise HTTPException(400, f"unknown operator: {operator}")
asset_id = payload.get("asset_id")
if asset_id and not store.get_asset(app.state.con, asset_id):
raise HTTPException(400, "unknown asset")
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_id, params)
job = runner.create_job(app.state.con, operator, asset_ids, params)
await runner.broadcast({"type": "job", "job": job})
return job
@ -96,6 +120,56 @@ def get_job(job_id: str):
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):
@ -103,7 +177,7 @@ async def ws(websocket: WebSocket):
runner.subscribers.add(websocket)
try:
while True:
await websocket.receive_text() # keepalive pings from client
await websocket.receive_text()
except WebSocketDisconnect:
runner.subscribers.discard(websocket)

View File

@ -0,0 +1,92 @@
"""Shared helper for fal.ai operators: upload input image, call an endpoint with
streamed progress, and download every 3D file found in the result.
Runs in the server's own venv (fal-client is dependency-light). Kept generic so
operators only supply endpoint id + argument mapping; unknown/new fal args pass
through via an `extra_args` JSON param so the UI keeps full control."""
import argparse
import json
import sys
import urllib.request
from pathlib import Path
MODEL_EXTS = {".glb", ".gltf", ".obj", ".fbx", ".usdz", ".ply", ".stl", ".zip"}
def _log(msg):
print(msg, flush=True)
def _find_file_urls(obj, acc):
"""Recursively collect (url, ext) for any file-like url in the result JSON."""
if isinstance(obj, dict):
url = obj.get("url")
if isinstance(url, str):
ext = Path(url.split("?")[0]).suffix.lower()
if ext in MODEL_EXTS:
acc.append((url, ext, obj.get("file_name")))
for v in obj.values():
_find_file_urls(v, acc)
elif isinstance(obj, list):
for v in obj:
_find_file_urls(v, acc)
def parse_args():
ap = argparse.ArgumentParser()
ap.add_argument("--input", action="append", default=[])
ap.add_argument("--outdir", required=True)
ap.add_argument("--params", default="{}")
a = ap.parse_args()
return a, json.loads(a.params)
def run(endpoint, arguments, outdir, image_path=None, image_arg="image_url",
image_as_list=False, extra_args=None, out_stem="model"):
import fal_client
if image_path:
_log(f"uploading {Path(image_path).name} to fal ...")
url = fal_client.upload_file(image_path)
arguments[image_arg] = [url] if image_as_list else url
if extra_args:
try:
arguments.update(json.loads(extra_args) if isinstance(extra_args, str) else extra_args)
except ValueError as e:
_log(f"warning: could not parse extra_args ({e}); ignoring")
# drop None values so we send only what the user set
arguments = {k: v for k, v in arguments.items() if v is not None and v != ""}
_log(f"calling {endpoint}")
_log(f"arguments: {json.dumps({k: v for k, v in arguments.items() if k != image_arg})}")
def on_update(update):
for entry in getattr(update, "logs", None) or []:
msg = entry.get("message") if isinstance(entry, dict) else str(entry)
if msg:
_log(f" {msg}")
result = fal_client.subscribe(endpoint, arguments=arguments, with_logs=True,
on_queue_update=on_update)
outdir = Path(outdir)
(outdir / "fal_result.json").write_text(json.dumps(result, indent=2))
urls = []
_find_file_urls(result, urls)
if not urls:
_log("ERROR: no 3D file url found in fal result. Raw result saved to fal_result.json:")
_log(json.dumps(result, indent=2)[:2000])
sys.exit(1)
outputs = []
for i, (url, ext, fname) in enumerate(urls):
name = fname or (f"{out_stem}{ext}" if i == 0 else f"{out_stem}_{i}{ext}")
dest = outdir / name
_log(f"downloading {name} ...")
urllib.request.urlretrieve(url, dest)
outputs.append({"path": name, "meta": {"endpoint": endpoint, "source_url": url}})
(outdir / "result.json").write_text(json.dumps({"outputs": outputs}))
_log(f"done — {len(outputs)} file(s)")

View File

@ -0,0 +1,20 @@
{
"id": "brush_train",
"name": "Brush 3DGS Train",
"category": "scan",
"description": "COLMAP dataset → 3D Gaussian Splat (.ply) via Brush, native Apple Silicon Metal trainer. Output opens in the splat viewer.",
"accepts": ["colmap_dataset"],
"produces": ["splat"],
"resources": "gpu",
"entry": "run.py",
"params_schema": {
"type": "object",
"properties": {
"total_steps": {"type": "integer", "default": 30000, "minimum": 500, "maximum": 100000, "description": "Training steps (30000 = full quality; lower = faster preview)"},
"max_splats": {"type": "integer", "default": 10000000, "minimum": 100000, "maximum": 30000000, "description": "Upper bound on splat count"},
"sh_degree": {"type": "integer", "enum": [0, 1, 2, 3], "default": 3, "description": "Spherical-harmonics degree (view-dependent color detail)"},
"max_resolution": {"type": "integer", "default": 1920, "minimum": 512, "maximum": 4096, "description": "Max image resolution loaded"},
"seed": {"type": "integer", "default": 42, "description": "Random seed"}
}
}
}

View File

@ -0,0 +1,58 @@
import argparse
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
BRUSH = ROOT / "bin" / "brush"
ap = argparse.ArgumentParser()
ap.add_argument("--input", action="append", default=[])
ap.add_argument("--outdir", required=True)
ap.add_argument("--params", default="{}")
a = ap.parse_args()
p = json.loads(a.params)
if not a.input:
print("ERROR: no input colmap dataset")
sys.exit(1)
dataset = Path(a.input[0])
if not BRUSH.exists():
print(f"ERROR: brush not installed at {BRUSH}. Run scripts/install_brush.sh")
sys.exit(1)
# brush wants the dataset root containing images/ + sparse/
if not (dataset / "images").exists():
inner = dataset / "colmap_dataset"
if (inner / "images").exists():
dataset = inner
outdir = Path(a.outdir)
export = outdir / "export"
export.mkdir(parents=True, exist_ok=True)
steps = int(p.get("total_steps", 30000))
cmd = [
str(BRUSH), str(dataset.resolve()),
"--total-steps", str(steps),
"--max-splats", str(p.get("max_splats", 10000000)),
"--sh-degree", str(p.get("sh_degree", 3)),
"--max-resolution", str(p.get("max_resolution", 1920)),
"--seed", str(p.get("seed", 42)),
"--export-every", str(steps), # export once at the end
"--export-path", str(export.resolve()),
"--export-name", "splat.ply",
]
print("+", " ".join(cmd), flush=True)
res = subprocess.run(cmd, stdout=sys.stdout, stderr=subprocess.STDOUT)
if res.returncode != 0:
sys.exit(res.returncode)
plys = sorted(export.rglob("*.ply"))
if not plys:
print("ERROR: brush produced no .ply splat")
sys.exit(1)
(outdir / "result.json").write_text(json.dumps({"outputs": [
{"path": str(plys[-1]), "name": f"{dataset.name}_splat.ply",
"meta": {"kind": "splat", "tool": "brush", "steps": steps}}]}))
print("done:", plys[-1], flush=True)

View File

@ -0,0 +1,19 @@
{
"id": "colmap_poses",
"name": "COLMAP Poses (SfM)",
"category": "scan",
"description": "Frames → camera poses + sparse point cloud via COLMAP 4.x with the GLOMAP global mapper (CPU SIFT on Apple Silicon). Produces a dataset folder ready for Brush 3DGS training.",
"accepts": ["frames"],
"produces": ["colmap_dataset"],
"resources": "cpu",
"entry": "run.py",
"params_schema": {
"type": "object",
"properties": {
"matcher": {"type": "string", "enum": ["sequential", "exhaustive"], "default": "sequential", "description": "sequential = video/orbit; exhaustive = unordered photos (slower)"},
"mapper": {"type": "string", "enum": ["global", "incremental"], "default": "global", "description": "global = GLOMAP (fast); incremental = classic COLMAP"},
"camera_model": {"type": "string", "enum": ["OPENCV", "SIMPLE_RADIAL", "PINHOLE", "SIMPLE_PINHOLE"], "default": "OPENCV", "description": "Camera distortion model"},
"single_camera": {"type": "boolean", "default": true, "description": "All frames share one camera (true for a single moving camera)"}
}
}
}

View File

@ -0,0 +1,76 @@
import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path
ap = argparse.ArgumentParser()
ap.add_argument("--input", action="append", default=[])
ap.add_argument("--outdir", required=True)
ap.add_argument("--params", default="{}")
a = ap.parse_args()
p = json.loads(a.params)
if not a.input:
print("ERROR: no input frames folder")
sys.exit(1)
src = Path(a.input[0])
if not src.is_dir():
print(f"ERROR: input must be a frames folder, got {src}")
sys.exit(1)
outdir = Path(a.outdir)
dataset = outdir / "colmap_dataset"
images = dataset / "images"
images.mkdir(parents=True, exist_ok=True)
frames = sorted([f for f in src.iterdir() if f.suffix.lower() in (".jpg", ".jpeg", ".png")])
if len(frames) < 3:
print(f"ERROR: need >=3 frames, found {len(frames)}")
sys.exit(1)
for f in frames:
shutil.copy2(f, images / f.name)
print(f"copied {len(frames)} frames", flush=True)
db_path = dataset / "database.db"
sparse = dataset / "sparse"
sparse.mkdir(exist_ok=True)
def run(*cmd):
print("+", " ".join(str(c) for c in cmd), flush=True)
r = subprocess.run([str(c) for c in cmd], stdout=sys.stdout, stderr=subprocess.STDOUT)
if r.returncode != 0:
print(f"ERROR: command failed ({r.returncode})")
sys.exit(r.returncode)
run("colmap", "feature_extractor", "--database_path", db_path, "--image_path", images,
"--ImageReader.single_camera", "1" if p.get("single_camera", True) else "0",
"--ImageReader.camera_model", p.get("camera_model", "OPENCV"),
"--FeatureExtraction.use_gpu", "0")
matcher = "sequential_matcher" if p.get("matcher", "sequential") == "sequential" else "exhaustive_matcher"
run("colmap", matcher, "--database_path", db_path, "--FeatureMatching.use_gpu", "0")
if p.get("mapper", "global") == "global":
run("colmap", "global_mapper", "--database_path", db_path,
"--image_path", images, "--output_path", sparse)
else:
run("colmap", "mapper", "--database_path", db_path,
"--image_path", images, "--output_path", sparse)
# report registered images
recon = sparse / "0"
n_reg = 0
if (recon / "images.bin").exists():
n_reg = (recon / "images.bin").stat().st_size # crude presence signal
print(f"reconstruction at {recon}, images.bin present: {(recon / 'images.bin').exists()}", flush=True)
if not (recon / "images.bin").exists() and not (recon / "images.txt").exists():
print("ERROR: COLMAP produced no reconstruction (too few features/matches?)")
sys.exit(1)
(outdir / "result.json").write_text(json.dumps({"outputs": [
{"path": str(dataset), "name": f"{src.name}_colmap",
"meta": {"kind": "colmap_dataset", "frames": len(frames)}}]}))
print("done", flush=True)

View File

@ -0,0 +1,19 @@
{
"id": "fal_hunyuan3d",
"name": "fal · Hunyuan3D v2",
"category": "mesh-gen",
"description": "Image → GLB via fal-ai/hunyuan3d/v2 (~$0.16, +texture ~$0.48). Best open-weights textures. Needs FAL_KEY.",
"accepts": ["image"],
"produces": ["model"],
"resources": "net",
"requires_env": ["FAL_KEY"],
"entry": "run.py",
"params_schema": {
"type": "object",
"properties": {
"seed": {"type": "integer", "description": "Random seed (leave blank for random)"},
"textured_mesh": {"type": "boolean", "default": true, "description": "Generate PBR textures (costs more)"},
"extra_args": {"type": "string", "default": "", "description": "Raw JSON merged into fal arguments — full control over any endpoint param"}
}
}
}

View File

@ -0,0 +1,16 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from _lib import fal_common # noqa: E402
a, p = fal_common.parse_args()
fal_common.run(
endpoint="fal-ai/hunyuan3d/v2",
arguments={"seed": p.get("seed"), "textured_mesh": p.get("textured_mesh", True)},
outdir=a.outdir,
image_path=a.input[0] if a.input else None,
image_arg="input_image_url",
extra_args=p.get("extra_args"),
out_stem="hunyuan3d",
)

View File

@ -0,0 +1,18 @@
{
"id": "fal_rodin",
"name": "fal · Rodin Gen-2 (hero)",
"category": "mesh-gen",
"description": "Image → GLB/USDZ/FBX via fal-ai/hyper3d/rodin (~$0.40, HighPack ~$1.20). Film/hero-grade fidelity with quad topology. Needs FAL_KEY.",
"accepts": ["image"],
"produces": ["model"],
"resources": "net",
"requires_env": ["FAL_KEY"],
"entry": "run.py",
"params_schema": {
"type": "object",
"properties": {
"seed": {"type": "integer", "description": "Random seed (leave blank for random)"},
"extra_args": {"type": "string", "default": "", "description": "Raw JSON merged into fal arguments (quality/geometry/material/tier). Full control over any endpoint param"}
}
}
}

View File

@ -0,0 +1,19 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from _lib import fal_common # noqa: E402
a, p = fal_common.parse_args()
# Rodin on fal takes input images as an array under input_image_urls; fal_common
# uploads to image_arg, so we point image_arg at that field.
fal_common.run(
endpoint="fal-ai/hyper3d/rodin",
arguments={"seed": p.get("seed")},
outdir=a.outdir,
image_path=a.input[0] if a.input else None,
image_arg="input_image_urls",
image_as_list=True,
extra_args=p.get("extra_args"),
out_stem="rodin",
)

View File

@ -0,0 +1,22 @@
{
"id": "fal_trellis",
"name": "fal · TRELLIS (cheap)",
"category": "mesh-gen",
"description": "Image → textured GLB via fal-ai/trellis (~$0.02/run, 2024-tier quality). Cheapest way to get a mesh. Needs FAL_KEY.",
"accepts": ["image"],
"produces": ["model"],
"resources": "net",
"requires_env": ["FAL_KEY"],
"entry": "run.py",
"params_schema": {
"type": "object",
"properties": {
"seed": {"type": "integer", "description": "Random seed (leave blank for random)"},
"ss_sampling_steps": {"type": "integer", "default": 12, "minimum": 1, "maximum": 50, "description": "Sparse-structure sampling steps"},
"slat_sampling_steps": {"type": "integer", "default": 12, "minimum": 1, "maximum": 50, "description": "Structured-latent sampling steps"},
"mesh_simplify": {"type": "number", "default": 0.95, "minimum": 0.5, "maximum": 1.0, "description": "Mesh simplification factor"},
"texture_size": {"type": "integer", "enum": [512, 1024, 2048], "default": 1024, "description": "Baked texture resolution"},
"extra_args": {"type": "string", "default": "", "description": "Raw JSON merged into fal arguments — full control over any endpoint param"}
}
}
}

View File

@ -0,0 +1,22 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from _lib import fal_common # noqa: E402
a, p = fal_common.parse_args()
args = {
"seed": p.get("seed"),
"ss_sampling_steps": p.get("ss_sampling_steps"),
"slat_sampling_steps": p.get("slat_sampling_steps"),
"mesh_simplify": p.get("mesh_simplify"),
"texture_size": p.get("texture_size"),
}
fal_common.run(
endpoint="fal-ai/trellis",
arguments=args,
outdir=a.outdir,
image_path=a.input[0] if a.input else None,
extra_args=p.get("extra_args"),
out_stem="trellis",
)

View File

@ -0,0 +1,22 @@
{
"id": "fal_trellis2",
"name": "fal · TRELLIS.2 (SOTA open)",
"category": "mesh-gen",
"description": "Image → GLB + PBR via fal-ai/trellis-2. SOTA open-weights quality, zero local setup. Price by resolution: 512=$0.25 / 1024=$0.30 / 1536=$0.35. Needs FAL_KEY.",
"accepts": ["image"],
"produces": ["model"],
"resources": "net",
"requires_env": ["FAL_KEY"],
"entry": "run.py",
"params_schema": {
"type": "object",
"properties": {
"resolution": {"type": "integer", "enum": [512, 1024, 1536], "default": 1024, "description": "Output resolution (drives price)"},
"texture_size": {"type": "integer", "enum": [1024, 2048, 4096], "default": 2048, "description": "Baked texture resolution"},
"decimation_target": {"type": "integer", "default": 500000, "minimum": 5000, "maximum": 2000000, "description": "Target vertex count"},
"remesh": {"type": "boolean", "default": true, "description": "Rebuild topology"},
"seed": {"type": "integer", "description": "Random seed (leave blank for random)"},
"extra_args": {"type": "string", "default": "", "description": "Raw JSON merged into fal arguments (ss_/shape_slat_/tex_slat_ guidance controls). Full control"}
}
}
}

View File

@ -0,0 +1,21 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from _lib import fal_common # noqa: E402
a, p = fal_common.parse_args()
fal_common.run(
endpoint="fal-ai/trellis-2",
arguments={
"seed": p.get("seed"),
"resolution": p.get("resolution"),
"texture_size": p.get("texture_size"),
"decimation_target": p.get("decimation_target"),
"remesh": p.get("remesh"),
},
outdir=a.outdir,
image_path=a.input[0] if a.input else None,
extra_args=p.get("extra_args"),
out_stem="trellis2",
)

View File

@ -0,0 +1,20 @@
{
"id": "sf3d",
"name": "SF3D (local, fast draft)",
"category": "mesh-gen",
"description": "Image → textured GLB locally via Stability Stable-Fast-3D on Apple Silicon MPS (seconds-fast draft geometry, UV-unwrapped). Weights need a one-time HuggingFace login for stabilityai/stable-fast-3d.",
"accepts": ["image"],
"produces": ["model"],
"resources": "gpu",
"entry": "run.py",
"python": "/Users/m3ultra/Documents/MODELBEAST/venvs/sf3d/bin/python",
"params_schema": {
"type": "object",
"properties": {
"device": {"type": "string", "enum": ["mps", "cpu"], "default": "mps", "description": "mps = Apple GPU (fast); cpu = fallback"},
"texture_resolution": {"type": "integer", "enum": [512, 1024, 2048], "default": 1024, "description": "Baked texture atlas size"},
"foreground_ratio": {"type": "number", "default": 0.85, "minimum": 0.5, "maximum": 1.0, "description": "How much of the frame the object fills"},
"remesh_option": {"type": "string", "enum": ["none", "triangle", "quad"], "default": "none", "description": "Topology remeshing"}
}
}
}

View File

@ -0,0 +1,51 @@
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
VENDOR = ROOT / "vendor" / "stable-fast-3d"
ap = argparse.ArgumentParser()
ap.add_argument("--input", action="append", default=[])
ap.add_argument("--outdir", required=True)
ap.add_argument("--params", default="{}")
a = ap.parse_args()
p = json.loads(a.params)
if not a.input:
print("ERROR: no input image")
sys.exit(1)
if not (VENDOR / "run.py").exists():
print(f"ERROR: SF3D not installed at {VENDOR}. Run scripts/install_sf3d.sh")
sys.exit(1)
outdir = Path(a.outdir)
work = outdir / "sf3d_out"
work.mkdir(parents=True, exist_ok=True)
cmd = [
sys.executable, "run.py", str(Path(a.input[0]).resolve()),
"--device", p.get("device", "mps"),
"--output-dir", str(work.resolve()),
"--texture-resolution", str(p.get("texture_resolution", 1024)),
"--foreground-ratio", str(p.get("foreground_ratio", 0.85)),
"--remesh_option", p.get("remesh_option", "none"),
]
env = os.environ.copy()
env["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
print("+", " ".join(cmd), flush=True)
res = subprocess.run(cmd, cwd=str(VENDOR), env=env, stdout=sys.stdout, stderr=subprocess.STDOUT)
if res.returncode != 0:
sys.exit(res.returncode)
glbs = sorted(work.rglob("*.glb"))
if not glbs:
print("ERROR: SF3D produced no .glb")
sys.exit(1)
name = f"sf3d_{Path(a.input[0]).stem}.glb"
(outdir / "result.json").write_text(json.dumps(
{"outputs": [{"path": str(glbs[0]), "name": name, "meta": {"tool": "sf3d"}}]}))
print("done:", glbs[0], flush=True)

View File

@ -0,0 +1,20 @@
{
"id": "trellis_mac",
"name": "TRELLIS.2 (local, SOTA)",
"category": "mesh-gen",
"description": "Image → GLB + baked PBR locally via the TRELLIS.2 MPS/Metal port (best open-weights quality on Apple Silicon, ~3-5 min/gen). Weights need a one-time HuggingFace login (facebook/dinov3-vitl16, briaai/RMBG-2.0). Note: 1536 is upstream-only; Mac port maxes at 1024_cascade.",
"accepts": ["image"],
"produces": ["model"],
"resources": "gpu",
"entry": "run.py",
"python": "/Users/m3ultra/Documents/MODELBEAST/vendor/trellis-mac/.venv/bin/python",
"params_schema": {
"type": "object",
"properties": {
"pipeline_type": {"type": "string", "enum": ["512", "1024", "1024_cascade"], "default": "1024", "description": "Resolution tier (1024_cascade = highest quality on Mac)"},
"texture_size": {"type": "integer", "enum": [512, 1024, 2048], "default": 2048, "description": "Baked texture resolution"},
"seed": {"type": "integer", "default": 0, "description": "Random seed"},
"no_texture": {"type": "boolean", "default": false, "description": "Geometry only (skip texture stage, much faster)"}
}
}
}

View File

@ -0,0 +1,50 @@
import argparse
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
VENDOR = ROOT / "vendor" / "trellis-mac"
ap = argparse.ArgumentParser()
ap.add_argument("--input", action="append", default=[])
ap.add_argument("--outdir", required=True)
ap.add_argument("--params", default="{}")
a = ap.parse_args()
p = json.loads(a.params)
if not a.input:
print("ERROR: no input image")
sys.exit(1)
if not (VENDOR / "generate.py").exists():
print(f"ERROR: trellis-mac not installed at {VENDOR}. Run scripts/install_trellis_mac.sh")
sys.exit(1)
outdir = Path(a.outdir)
out_stem = outdir / f"trellis2_{Path(a.input[0]).stem}"
cmd = [
sys.executable, "generate.py", str(Path(a.input[0]).resolve()),
"--pipeline-type", str(p.get("pipeline_type", "1024")),
"--texture-size", str(p.get("texture_size", 2048)),
"--seed", str(p.get("seed", 0)),
"--output", str(out_stem.resolve()),
]
if p.get("no_texture"):
cmd.append("--no-texture")
print("+", " ".join(cmd), flush=True)
res = subprocess.run(cmd, cwd=str(VENDOR), stdout=sys.stdout, stderr=subprocess.STDOUT)
if res.returncode != 0:
sys.exit(res.returncode)
# generate.py writes <output>.glb (and often a .obj); collect any glb under outdir
glbs = sorted(outdir.rglob("*.glb"))
if not glbs:
print("ERROR: trellis-mac produced no .glb")
sys.exit(1)
outputs = [{"path": str(glbs[0]), "name": f"trellis2_{Path(a.input[0]).stem}.glb",
"meta": {"tool": "trellis_mac"}}]
(outdir / "result.json").write_text(json.dumps({"outputs": outputs}))
print("done:", glbs[0], flush=True)

View File

@ -1,28 +1,39 @@
"""Job runner: executes operators as subprocesses, one heavy job at a time.
"""Job runner: executes operators as subprocesses across concurrency lanes.
Operator contract:
<python> run.py --input <asset path> --outdir <job outdir> --params '<json>'
- stdout/stderr are streamed into the job log
<python> run.py --input <path> [--input <path> ...] --outdir <dir> --params '<json>'
- inputs are passed in asset order (single-input ops get one --input)
- stdout/stderr are streamed into the job log (secret values redacted)
- operator writes outputs into outdir and (optionally) outdir/result.json:
{"outputs": [{"path": "relative/or/absolute", "name": "...", "meta": {...}}, ...],
{"outputs": [{"path": "rel/or/abs", "name": "...", "meta": {...}}, ...],
"summary": {...}}
- if result.json is absent, every top-level file in outdir is registered as an asset
- if result.json is absent, every top-level file in outdir is registered
Concurrency lanes (from manifest "resources"): gpu=1 (Metal contention),
cpu=3, net=6. Env vars from settings are injected; "requires_env" gates a job.
"""
import asyncio
import json
import os
import signal
import sys
from pathlib import Path
from . import db, store
from . import db, settings as settings_mod, store
JOBS_DIR = db.DATA / "jobs"
LANE_LIMITS = {"gpu": 1, "cpu": 3, "net": 6}
class Runner:
def __init__(self):
self.queue: asyncio.Queue[str] = asyncio.Queue()
self.subscribers: set = set() # websockets
self.subscribers: set = set()
self.operators: dict[str, dict] = {}
self.lanes: dict[str, asyncio.Semaphore] = {}
self.procs: dict = {} # job_id -> subprocess
self.cancelled: set[str] = set()
self._tasks: set = set()
self.get_settings = lambda: {} # set by main.py
# -- pubsub -------------------------------------------------------------
async def broadcast(self, message: dict):
@ -35,15 +46,22 @@ class Runner:
for ws in dead:
self.subscribers.discard(ws)
def lane_of(self, operator: str) -> str:
op = self.operators.get(operator, {})
lane = op.get("resources", "cpu")
return lane if lane in LANE_LIMITS else "cpu"
# -- job lifecycle --------------------------------------------------------
def create_job(self, con, operator: str, asset_id: str | None, params: dict) -> dict:
def create_job(self, con, operator: str, asset_ids: list[str], params: dict) -> dict:
job_id = db.new_id()
outdir = JOBS_DIR / job_id
outdir.mkdir(parents=True, exist_ok=True)
primary = asset_ids[0] if asset_ids else None
con.execute(
"INSERT INTO jobs (id, operator, status, asset_id, params, outdir, created_at) "
"VALUES (?, ?, 'queued', ?, ?, ?, ?)",
(job_id, operator, asset_id, json.dumps(params), str(outdir), db.now()),
"INSERT INTO jobs (id, operator, status, asset_id, asset_ids, params, outdir, created_at) "
"VALUES (?, ?, 'queued', ?, ?, ?, ?, ?)",
(job_id, operator, primary, json.dumps(asset_ids),
json.dumps(params), str(outdir), db.now()),
)
con.commit()
self.queue.put_nowait(job_id)
@ -57,6 +75,41 @@ class Runner:
rows = con.execute("SELECT * FROM jobs ORDER BY created_at DESC LIMIT 200").fetchall()
return [db.row_to_dict(r) for r in rows]
def input_paths(self, con, job: dict) -> list[str]:
ids = job.get("asset_ids") or ([job["asset_id"]] if job.get("asset_id") else [])
paths = []
for aid in ids:
asset = store.get_asset(con, aid)
if asset:
paths.append(asset["path"])
return paths
async def cancel(self, con, job_id: str) -> bool:
job = self.get_job(con, job_id)
if not job or job["status"] in ("done", "error", "cancelled"):
return False
self.cancelled.add(job_id)
proc = self.procs.get(job_id)
if proc and proc.returncode is None:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
pass
else: # queued but not yet started
await self._update(con, job_id, status="cancelled", finished_at=db.now())
return True
def delete_job(self, con, job_id: str) -> bool:
job = self.get_job(con, job_id)
if not job or job_id in self.procs:
return False
import shutil
if job.get("outdir"):
shutil.rmtree(job["outdir"], ignore_errors=True)
con.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
con.commit()
return True
async def _update(self, con, job_id: str, **fields):
sets = ", ".join(f"{k} = ?" for k in fields)
con.execute(f"UPDATE jobs SET {sets} WHERE id = ?", (*fields.values(), job_id))
@ -67,13 +120,30 @@ class Runner:
# -- worker -----------------------------------------------------------------
async def worker(self):
con = db.connect()
# re-queue jobs that were left running/queued by a previous process
for key in LANE_LIMITS:
self.lanes[key] = asyncio.Semaphore(LANE_LIMITS[key])
# re-queue jobs left running/queued by a previous process
for row in con.execute("SELECT id FROM jobs WHERE status IN ('queued','running')"):
con.execute("UPDATE jobs SET status='queued' WHERE id = ?", (row["id"],))
self.queue.put_nowait(row["id"])
con.commit()
while True:
job_id = await self.queue.get()
task = asyncio.create_task(self._run_lane(con, job_id))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _run_lane(self, con, job_id: str):
if job_id in self.cancelled:
self.cancelled.discard(job_id)
await self._update(con, job_id, status="cancelled", finished_at=db.now())
return
lane = self.lane_of(self.get_job(con, job_id)["operator"])
async with self.lanes[lane]:
if job_id in self.cancelled:
self.cancelled.discard(job_id)
await self._update(con, job_id, status="cancelled", finished_at=db.now())
return
try:
await self._run_job(con, job_id)
except Exception as e:
@ -85,37 +155,67 @@ class Runner:
return
op = self.operators.get(job["operator"])
if not op:
await self._update(con, job_id, status="error", error=f"unknown operator {job['operator']}",
await self._update(con, job_id, status="error",
error=f"unknown operator {job['operator']}", finished_at=db.now())
return
cur_settings = self.get_settings()
env = os.environ.copy()
env.update(settings_mod.env_from_settings(cur_settings))
secrets = settings_mod.secret_values(cur_settings)
missing = [e for e in op.get("requires_env", []) if not env.get(e)]
if missing:
keys = ", ".join(sorted({k for k, v in settings_mod.ENV_MAP.items() if v in missing}))
await self._update(con, job_id, status="error",
error=f"missing setting(s): {keys} — add them in Settings",
finished_at=db.now())
return
asset = store.get_asset(con, job["asset_id"]) if job["asset_id"] else None
outdir = Path(job["outdir"])
entry = Path(op["dir"]) / op.get("entry", "run.py")
python = op.get("python") or sys.executable
cmd = [python, str(entry), "--outdir", str(outdir),
"--params", json.dumps(job["params"])]
if asset:
cmd += ["--input", asset["path"]]
if op.get("python") and not Path(python).exists():
await self._update(con, job_id, status="error", finished_at=db.now(),
error=f"operator not installed — {python} missing. "
f"Run its install script under scripts/.")
return
cmd = [python, str(entry), "--outdir", str(outdir), "--params", json.dumps(job["params"])]
for p in self.input_paths(con, job):
cmd += ["--input", p]
await self._update(con, job_id, status="running", started_at=db.now())
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
start_new_session=True, env=env)
self.procs[job_id] = proc
log_lines: list[str] = []
assert proc.stdout
try:
async for raw in proc.stdout:
line = raw.decode(errors="replace")
log_lines.append(line)
if len(log_lines) % 5 == 0: # don't hammer the DB on chatty tools
await self._update(con, job_id, log="".join(log_lines)[-100_000:])
log_lines.append(raw.decode(errors="replace"))
if len(log_lines) % 5 == 0:
log = settings_mod.redact("".join(log_lines)[-100_000:], secrets)
await self._update(con, job_id, log=log)
code = await proc.wait()
log = "".join(log_lines)[-100_000:]
finally:
self.procs.pop(job_id, None)
log = settings_mod.redact("".join(log_lines)[-100_000:], secrets)
if job_id in self.cancelled:
self.cancelled.discard(job_id)
await self._update(con, job_id, status="cancelled", log=log, finished_at=db.now())
return
if code != 0:
await self._update(con, job_id, status="error", log=log,
error=f"exit code {code}", finished_at=db.now())
return
# register outputs
registered = self._register_outputs(con, outdir, job_id)
await self._update(con, job_id, status="done", log=log, finished_at=db.now())
await self.broadcast({"type": "assets_changed", "job_id": job_id, "assets": registered})
def _register_outputs(self, con, outdir: Path, job_id: str) -> list[str]:
result_path = outdir / "result.json"
registered = []
if result_path.exists():
@ -126,8 +226,7 @@ class Runner:
p = outdir / p
if p.exists():
a = store.register_file(con, p, name=out.get("name"),
parent_job=job_id, move=True,
meta=out.get("meta"))
parent_job=job_id, move=True, meta=out.get("meta"))
registered.append(a["id"])
else:
for p in sorted(outdir.iterdir()):
@ -135,9 +234,7 @@ class Runner:
continue
a = store.register_file(con, p, parent_job=job_id, move=True)
registered.append(a["id"])
await self._update(con, job_id, status="done", log=log, finished_at=db.now())
await self.broadcast({"type": "assets_changed", "job_id": job_id, "assets": registered})
return registered
runner = Runner()

76
server/settings.py Normal file
View File

@ -0,0 +1,76 @@
"""Settings + secrets: a key/value store surfaced to the UI and injected as env
vars into operator subprocesses. Secret values are redacted from job logs."""
# setting key -> environment variable name handed to operator subprocesses
ENV_MAP = {
"fal_key": "FAL_KEY",
"tripo_key": "TRIPO_KEY",
"meshy_key": "MESHY_KEY",
"replicate_token": "REPLICATE_API_TOKEN",
"hf_token": "HF_TOKEN",
"models_dir": "MODELBEAST_MODELS_DIR",
"archive_host": "MODELBEAST_ARCHIVE_HOST",
"archive_path": "MODELBEAST_ARCHIVE_PATH",
}
# keys whose values are secret: rendered as password fields, redacted from logs
SECRET_KEYS = {"fal_key", "tripo_key", "meshy_key", "replicate_token", "hf_token"}
DEFAULTS = {
"archive_host": "m3ultra@100.69.21.128",
"archive_path": "~/modelbeast-archive",
}
def get_all(con) -> dict:
rows = con.execute("SELECT key, value FROM settings").fetchall()
out = dict(DEFAULTS)
for r in rows:
out[r["key"]] = r["value"]
return out
def set_many(con, updates: dict) -> None:
for key, value in updates.items():
con.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, "" if value is None else str(value)),
)
con.commit()
def public_view(settings: dict) -> dict:
"""Mask secret values so the UI can show 'set / unset' without leaking them."""
out = {}
for key, value in settings.items():
if key in SECRET_KEYS:
out[key] = "••••••••" if value else ""
else:
out[key] = value
return out
def env_from_settings(settings: dict) -> dict:
env = {}
for key, env_name in ENV_MAP.items():
value = settings.get(key)
if value:
env[env_name] = value
# HF libraries read several token names
if settings.get("hf_token"):
env["HUGGING_FACE_HUB_TOKEN"] = settings["hf_token"]
return env
def secret_values(settings: dict) -> list[str]:
return [settings[k] for k in SECRET_KEYS if settings.get(k)]
def redact(text: str, secrets: list[str]) -> str:
if not text:
return text
for secret in secrets:
if secret and len(secret) >= 6:
text = text.replace(secret, "••••REDACTED••••")
return text

View File

@ -31,6 +31,7 @@ def register_file(con, src: Path, name: str | None = None, parent_job: str | Non
dest_dir = ASSETS_DIR / asset_id
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / name
meta = meta or {}
if src.is_dir():
if move:
shutil.move(str(src), dest)
@ -45,6 +46,9 @@ def register_file(con, src: Path, name: str | None = None, parent_job: str | Non
shutil.copy2(src, dest)
size = dest.stat().st_size
kind = kind_of(name)
# operators may tag a semantic kind that isn't inferable from the extension
# (e.g. a splat .ply vs a mesh .ply, or a colmap_dataset folder)
kind = meta.pop("kind", kind)
con.execute(
"INSERT INTO assets (id, name, kind, path, size, meta, parent_job, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",

77
tests/smoke.sh Executable file
View File

@ -0,0 +1,77 @@
#!/usr/bin/env bash
# End-to-end smoke test against a throwaway data dir on a scratch port.
# Validates: operators load, settings CRUD, upload, ffprobe/ffmpeg_frames/
# blender_convert run to completion, outputs register, cancel + retry work.
set -euo pipefail
cd "$(dirname "$0")/.."
PORT=${PORT:-8799}
TMP=$(mktemp -d)
BLENDER="/Applications/Blender.app/Contents/MacOS/Blender"
UV=/opt/homebrew/bin/uv
pass=0; fail=0
say() { printf "\n\033[1m== %s ==\033[0m\n" "$1"; }
ok() { printf " \033[32mPASS\033[0m %s\n" "$1"; pass=$((pass+1)); }
bad() { printf " \033[31mFAIL\033[0m %s\n" "$1"; fail=$((fail+1)); }
j() { python3 -c "import json,sys;d=json.load(sys.stdin);print($1)"; }
cleanup() { kill "${SRV:-0}" 2>/dev/null || true; rm -rf "$TMP"; }
trap cleanup EXIT
say "fixtures"
ffmpeg -y -f lavfi -i "testsrc2=size=640x360:rate=30:duration=3" -pix_fmt yuv420p "$TMP/clip.mp4" >/dev/null 2>&1
ok "test video"
"$BLENDER" -b --python-expr "
import bpy
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.mesh.primitive_monkey_add()
bpy.ops.export_scene.gltf(filepath='$TMP/mesh.glb', export_format='GLB')" >/dev/null 2>&1
ok "test glb"
say "start server (MODELBEAST_DATA=$TMP/data, port $PORT)"
MODELBEAST_DATA="$TMP/data" "$UV" run uvicorn server.main:app --host 127.0.0.1 --port "$PORT" >"$TMP/server.log" 2>&1 &
SRV=$!
for i in $(seq 1 40); do curl -sf "http://127.0.0.1:$PORT/api/operators" >/dev/null 2>&1 && break; sleep 0.5; done
B="http://127.0.0.1:$PORT"
say "operators + settings"
NOPS=$(curl -s "$B/api/operators" | j "len(d)")
[ "$NOPS" -ge 3 ] && ok "operators loaded ($NOPS)" || bad "operators ($NOPS)"
curl -s -X PUT "$B/api/settings" -H 'Content-Type: application/json' -d '{"archive_path":"/tmp/xyz"}' >/dev/null
AP=$(curl -s "$B/api/settings" | j "d['archive_path']")
[ "$AP" = "/tmp/xyz" ] && ok "settings persist" || bad "settings ($AP)"
# secret masking
curl -s -X PUT "$B/api/settings" -H 'Content-Type: application/json' -d '{"fal_key":"sk-secret-123456"}' >/dev/null
FK=$(curl -s "$B/api/settings" | j "d['fal_key']")
[ "$FK" = "••••••••" ] && ok "secret masked in GET" || bad "secret leak ($FK)"
say "upload + ffprobe"
VID=$(curl -s -F "file=@$TMP/clip.mp4" "$B/api/assets" | j "d['id']")
GLB=$(curl -s -F "file=@$TMP/mesh.glb" "$B/api/assets" | j "d['id']")
JOB=$(curl -s -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "{\"operator\":\"ffprobe\",\"asset_id\":\"$VID\"}" | j "d['id']")
for i in $(seq 1 30); do S=$(curl -s "$B/api/jobs/$JOB" | j "d['status']"); [ "$S" = done ] || [ "$S" = error ] && break; sleep 0.5; done
[ "$S" = done ] && ok "ffprobe done" || bad "ffprobe ($S)"
say "ffmpeg_frames"
JOB=$(curl -s -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "{\"operator\":\"ffmpeg_frames\",\"asset_id\":\"$VID\",\"params\":{\"fps\":4,\"max_frames\":8}}" | j "d['id']")
for i in $(seq 1 40); do S=$(curl -s "$B/api/jobs/$JOB" | j "d['status']"); [ "$S" = done ] || [ "$S" = error ] && break; sleep 0.5; done
[ "$S" = done ] && ok "ffmpeg_frames done" || bad "ffmpeg_frames ($S)"
NFR=$(curl -s "$B/api/assets" | j "sum(1 for a in d if a['kind']=='frames')")
[ "$NFR" -ge 1 ] && ok "frames asset registered" || bad "no frames asset"
say "blender_convert glb->fbx (bool param regression)"
JOB=$(curl -s -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "{\"operator\":\"blender_convert\",\"asset_id\":\"$GLB\",\"params\":{\"target\":\"fbx\",\"apply_transforms\":true}}" | j "d['id']")
for i in $(seq 1 60); do S=$(curl -s "$B/api/jobs/$JOB" | j "d['status']"); [ "$S" = done ] || [ "$S" = error ] && break; sleep 0.5; done
[ "$S" = done ] && ok "blender_convert done" || bad "blender_convert ($S)"
NFBX=$(curl -s "$B/api/assets" | j "sum(1 for a in d if a['name'].endswith('.fbx'))")
[ "$NFBX" -ge 1 ] && ok "fbx registered" || bad "no fbx"
say "retry + delete"
NEW=$(curl -s -X POST "$B/api/jobs/$JOB/retry" | j "d['id']")
[ -n "$NEW" ] && [ "$NEW" != "$JOB" ] && ok "retry spawns new job" || bad "retry"
sleep 1
DEL=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "$B/api/jobs/$JOB")
[ "$DEL" = 200 ] && ok "delete job" || bad "delete ($DEL)"
printf "\n\033[1mResult: %d passed, %d failed\033[0m\n" "$pass" "$fail"
[ "$fail" -eq 0 ]

126
uv.lock generated
View File

@ -42,6 +42,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
]
[[package]]
name = "asyncstdlib"
version = "3.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/66/7b76f2b48afc5c8b4648500a4ff9869f83528e3d601a19de51da18b5e8a5/asyncstdlib-3.14.0.tar.gz", hash = "sha256:f99396992a3bba7495d8cf6c832bfe8c8727f61213233d3569a5a9cbd1c04385", size = 51074, upload-time = "2026-03-20T21:06:46.025Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/50/463443651ddb0ba66c289d5649369099ab72b4665888b337ed25ea622550/asyncstdlib-3.14.0-py3-none-any.whl", hash = "sha256:cad90c590ce357da6e70c56f0e2ad9e9baaf146a8fc2ede189879da57eb8bd87", size = 44255, upload-time = "2026-03-20T21:06:45.033Z" },
]
[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "click"
version = "8.4.2"
@ -63,6 +81,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fal-client"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
{ name = "asyncstdlib" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "msgpack" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ed/95/17727aba8ac0d7c958bd4823f256c2690fea57173f6748720784750237ad/fal_client-1.0.0.tar.gz", hash = "sha256:ba62cc008a4bbfae2c3cbade6c7a691093e42cfb5a79f7fca4dd61acbd87372a", size = 37713, upload-time = "2026-04-28T22:42:32.469Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/ae/2f1ec97492ced94256acfc39040fe979cb6f96e2ba4602ac59b6fd548538/fal_client-1.0.0-py3-none-any.whl", hash = "sha256:0e8a611fde2363c15cd26de8eadfada72a6fdf5077211e95120d7c870a233b2b", size = 22632, upload-time = "2026-04-28T22:42:31.035Z" },
]
[[package]]
name = "fastapi"
version = "0.139.0"
@ -88,6 +123,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.8.0"
@ -124,6 +172,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "httpx-sse"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
]
[[package]]
name = "idna"
version = "3.18"
@ -139,6 +211,7 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },
{ name = "fal-client" },
{ name = "fastapi" },
{ name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
@ -147,11 +220,64 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "aiofiles", specifier = ">=25.1.0" },
{ name = "fal-client", specifier = ">=1.0.0" },
{ name = "fastapi", specifier = ">=0.139.0" },
{ name = "python-multipart", specifier = ">=0.0.32" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.51.0" },
]
[[package]]
name = "msgpack"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" },
{ url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" },
{ url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" },
{ url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" },
{ url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" },
{ url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" },
{ url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" },
{ url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" },
{ url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" },
{ url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" },
{ url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" },
{ url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" },
{ url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" },
{ url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" },
{ url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" },
{ url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" },
{ url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" },
{ url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" },
{ url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" },
{ url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" },
{ url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" },
{ url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" },
{ url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" },
{ url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" },
{ url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" },
{ url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" },
{ url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" },
{ url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" },
{ url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" },
{ url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" },
{ url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" },
{ url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" },
{ url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" },
{ url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" },
{ url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" },
{ url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" },
{ url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" },
{ url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"

10
web/package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "web",
"version": "0.0.0",
"dependencies": {
"@mkkellogg/gaussian-splats-3d": "^0.4.7",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"three": "^0.185.1"
@ -54,6 +55,15 @@
"tslib": "^2.4.0"
}
},
"node_modules/@mkkellogg/gaussian-splats-3d": {
"version": "0.4.7",
"resolved": "https://registry.npmjs.org/@mkkellogg/gaussian-splats-3d/-/gaussian-splats-3d-0.4.7.tgz",
"integrity": "sha512-0vy9/i9sJLFH/v3WJZ4axCsqjkToe8UsV3xY7bvK5EUC0akiRsWZODoCiSzpxhTLNyzSKTsyQKozIFeNA5RWRA==",
"license": "MIT",
"peerDependencies": {
"three": ">=0.160.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",

View File

@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@mkkellogg/gaussian-splats-3d": "^0.4.7",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"three": "^0.185.1"

View File

@ -1,15 +1,18 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
api, assetFileURL, connectWS, deleteAsset, listAssets, listJobs,
listOperators, runJob, uploadFile,
api, assetFileURL, cancelJob, connectWS, deleteAsset, deleteJob, getSettings,
listAssets, listJobs, listOperators, retryJob, runJob, uploadFile,
} from "./api";
import Viewer from "./Viewer";
import SplatViewer from "./SplatViewer";
import Settings from "./Settings";
const MODEL_EXTS = ["glb", "gltf", "obj", "fbx", "ply", "stl"];
const fmtSize = (n) =>
n > 1e9 ? (n / 1e9).toFixed(1) + " GB" : n > 1e6 ? (n / 1e6).toFixed(1) + " MB"
: n > 1e3 ? (n / 1e3).toFixed(0) + " KB" : n + " B";
const KIND_ICON = { video: "🎬", image: "🖼", model: "🧊", frames: "🎞", other: "📄" };
const KIND_ICON = { video: "🎬", image: "🖼", model: "🧊", splat: "✨", frames: "🎞", colmap_dataset: "📐", folder: "📁", other: "📄" };
const isViewable = (a) => a && (a.kind === "splat" || MODEL_EXTS.includes(a.name.split(".").pop().toLowerCase()));
function ParamForm({ schema, values, onChange }) {
const props = schema?.properties || {};
@ -23,15 +26,15 @@ function ParamForm({ schema, values, onChange }) {
<label key={key} title={def.description || ""}>
<span>{key}</span>
{def.enum ? (
<select value={val} onChange={(e) => set(e.target.value)}>
{def.enum.map((o) => <option key={o}>{o}</option>)}
<select value={val} onChange={(e) => set(maybeNum(def, e.target.value))}>
{def.enum.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
) : def.type === "boolean" ? (
<input type="checkbox" checked={!!val} onChange={(e) => set(e.target.checked)} />
) : def.type === "number" || def.type === "integer" ? (
<input type="number" value={val} step={def.type === "integer" ? 1 : "any"}
min={def.minimum} max={def.maximum}
onChange={(e) => set(def.type === "integer" ? parseInt(e.target.value || 0) : parseFloat(e.target.value || 0))} />
onChange={(e) => set(e.target.value === "" ? "" : (def.type === "integer" ? parseInt(e.target.value) : parseFloat(e.target.value)))} />
) : (
<input type="text" value={val} onChange={(e) => set(e.target.value)} />
)}
@ -41,62 +44,78 @@ function ParamForm({ schema, values, onChange }) {
</div>
);
}
const maybeNum = (def, v) => (def.type === "integer" ? parseInt(v) : def.type === "number" ? parseFloat(v) : v);
function AssetPreview({ asset }) {
const [folder, setFolder] = useState(null);
const ext = asset ? asset.name.split(".").pop().toLowerCase() : "";
useEffect(() => {
setFolder(null);
if (asset && (asset.kind === "frames" || asset.kind === "folder")) {
if (asset && (asset.kind === "frames" || asset.kind === "folder" || asset.kind === "colmap_dataset")) {
api(assetFileURL(asset.id)).then(setFolder).catch(() => {});
}
}, [asset?.id]);
if (!asset) return <div className="preview empty">Select an asset or drop / paste files anywhere</div>;
if (MODEL_EXTS.includes(ext))
return <Viewer url={assetFileURL(asset.id)} ext={ext} />;
if (asset.kind === "image")
return <div className="preview"><img src={assetFileURL(asset.id)} alt={asset.name} /></div>;
if (asset.kind === "video")
return <div className="preview"><video src={assetFileURL(asset.id)} controls /></div>;
if (folder?.files)
if (asset.kind === "splat") return <SplatViewer url={assetFileURL(asset.id)} />;
if (MODEL_EXTS.includes(ext)) return <Viewer url={assetFileURL(asset.id)} ext={ext} />;
if (asset.kind === "image") return <div className="preview"><img src={assetFileURL(asset.id)} alt={asset.name} /></div>;
if (asset.kind === "video") return <div className="preview"><video src={assetFileURL(asset.id)} controls /></div>;
if (folder?.files) {
const imgs = folder.files.filter((f) => /\.(jpg|jpeg|png)$/i.test(f));
return (
<div className="preview grid">
{folder.files.slice(0, 60).map((f) => (
<img key={f} src={assetFileURL(asset.id, f)} loading="lazy" alt={f} />
))}
{folder.files.length > 60 && <span className="dim">+{folder.files.length - 60} more</span>}
{imgs.slice(0, 60).map((f) => <img key={f} src={assetFileURL(asset.id, f)} loading="lazy" alt={f} />)}
{imgs.length > 60 && <span className="dim">+{imgs.length - 60} more</span>}
{!imgs.length && <span className="dim">{folder.files.length} files (no image preview)</span>}
</div>
);
if (ext === "json")
return <JsonPreview url={assetFileURL(asset.id)} />;
return <div className="preview empty">No preview for .{ext}</div>;
}
if (ext === "json") return <JsonPreview url={assetFileURL(asset.id)} />;
return <div className="preview empty">No preview for .{ext} download to use it</div>;
}
function JsonPreview({ url }) {
const [text, setText] = useState("…");
useEffect(() => {
fetch(url).then((r) => r.text()).then((t) => setText(t.slice(0, 20000)));
}, [url]);
useEffect(() => { fetch(url).then((r) => r.text()).then((t) => setText(t.slice(0, 20000))); }, [url]);
return <pre className="preview json">{text}</pre>;
}
function CompareGrid({ assets }) {
return (
<div className="compare-grid">
{assets.map((a) => (
<div key={a.id} className="compare-cell">
<div className="compare-cap">{a.name} · {fmtSize(a.size)}{a.meta?.tool ? ` · ${a.meta.tool}` : ""}</div>
{a.kind === "splat"
? <SplatViewer url={assetFileURL(a.id)} />
: <Viewer url={assetFileURL(a.id)} ext={a.name.split(".").pop().toLowerCase()} />}
</div>
))}
</div>
);
}
export default function App() {
const [assets, setAssets] = useState([]);
const [operators, setOperators] = useState([]);
const [jobs, setJobs] = useState([]);
const [settings, setSettings] = useState({ _env_set: [] });
const [selected, setSelected] = useState(null);
const [opId, setOpId] = useState(null);
const [params, setParams] = useState({});
const [openJob, setOpenJob] = useState(null);
const [showSettings, setShowSettings] = useState(false);
const [compareMode, setCompareMode] = useState(false);
const [compareSel, setCompareSel] = useState([]);
const fileInput = useRef(null);
const refreshAssets = useCallback(() => listAssets().then(setAssets), []);
const refreshJobs = useCallback(() => listJobs().then(setJobs), []);
const refreshSettings = useCallback(() => getSettings().then(setSettings), []);
useEffect(() => {
refreshAssets();
refreshJobs();
refreshAssets(); refreshJobs(); refreshSettings();
listOperators().then(setOperators);
connectWS((msg) => {
if (msg.type === "job") {
@ -110,37 +129,22 @@ export default function App() {
});
}, []);
// drag-drop + paste ingest
useEffect(() => {
const drop = async (e) => {
e.preventDefault();
for (const f of e.dataTransfer?.files || []) await uploadFile(f);
refreshAssets();
};
const paste = async (e) => {
const items = [...(e.clipboardData?.items || [])];
for (const it of items) {
const f = it.getAsFile?.();
if (f) { await uploadFile(f); }
}
refreshAssets();
};
const drop = async (e) => { e.preventDefault(); for (const f of e.dataTransfer?.files || []) await uploadFile(f); refreshAssets(); };
const paste = async (e) => { for (const it of e.clipboardData?.items || []) { const f = it.getAsFile?.(); if (f) await uploadFile(f); } refreshAssets(); };
const prevent = (e) => e.preventDefault();
window.addEventListener("drop", drop);
window.addEventListener("dragover", prevent);
window.addEventListener("paste", paste);
return () => {
window.removeEventListener("drop", drop);
window.removeEventListener("dragover", prevent);
window.removeEventListener("paste", paste);
};
window.addEventListener("drop", drop); window.addEventListener("dragover", prevent); window.addEventListener("paste", paste);
return () => { window.removeEventListener("drop", drop); window.removeEventListener("dragover", prevent); window.removeEventListener("paste", paste); };
}, [refreshAssets]);
const selectedAsset = assets.find((a) => a.id === selected) || null;
const envSet = settings._env_set || [];
const usableOps = useMemo(() =>
operators.filter((op) => !selectedAsset || op.accepts.includes(selectedAsset.kind)),
[operators, selectedAsset]);
const activeOp = operators.find((o) => o.id === opId);
const gatedEnv = (activeOp?.requires_env || []).filter((e) => !envSet.includes(e));
const gated = gatedEnv.length > 0;
useEffect(() => {
if (activeOp) {
@ -150,53 +154,66 @@ export default function App() {
setParams(defaults);
}
}, [opId]);
useEffect(() => {
if (usableOps.length && !usableOps.find((o) => o.id === opId)) setOpId(usableOps[0].id);
}, [usableOps]);
const launch = async () => {
if (!activeOp || !selectedAsset) return;
if (!activeOp || !selectedAsset || gated) return;
await runJob(activeOp.id, selectedAsset.id, params);
refreshJobs();
};
const clickAsset = (a) => {
if (compareMode && isViewable(a)) {
setCompareSel((s) => s.includes(a.id) ? s.filter((x) => x !== a.id) : [...s, a.id]);
} else {
setSelected(a.id);
}
};
const compareAssets = compareSel.map((id) => assets.find((a) => a.id === id)).filter(Boolean);
return (
<div className="layout">
<header>
<h1>MODEL<span>BEAST</span></h1>
<span className="dim">M3 Ultra · 256GB · local-first 3D factory</span>
<div className="spacer" />
<button className={compareMode ? "active" : ""} onClick={() => { setCompareMode(!compareMode); setCompareSel([]); }}>
Compare{compareSel.length ? ` (${compareSel.length})` : ""}
</button>
<button onClick={() => setShowSettings(true)}> Settings</button>
<button onClick={() => fileInput.current.click()}>+ Add files</button>
<input ref={fileInput} type="file" multiple hidden
onChange={async (e) => {
for (const f of e.target.files) await uploadFile(f);
e.target.value = ""; refreshAssets();
}} />
onChange={async (e) => { for (const f of e.target.files) await uploadFile(f); e.target.value = ""; refreshAssets(); }} />
</header>
<aside className="assets">
<h2>Assets</h2>
{assets.map((a) => (
{assets.map((a) => {
const checked = compareSel.includes(a.id);
return (
<div key={a.id}
className={"asset" + (a.id === selected ? " sel" : "")}
onClick={() => setSelected(a.id)}>
className={"asset" + (a.id === selected && !compareMode ? " sel" : "") + (checked ? " checked" : "")}
onClick={() => clickAsset(a)}>
{compareMode && <input type="checkbox" checked={checked} readOnly disabled={!isViewable(a)} />}
<span className="icon">{KIND_ICON[a.kind] || "📄"}</span>
<div className="meta">
<div className="name">{a.name}</div>
<div className="dim">{a.kind} · {fmtSize(a.size)}</div>
</div>
<button className="x" title="delete" onClick={(e) => {
e.stopPropagation();
deleteAsset(a.id).then(refreshAssets);
if (selected === a.id) setSelected(null);
}}>×</button>
<a className="dl" href={assetFileURL(a.id)} download onClick={(e) => e.stopPropagation()} title="download"></a>
<button className="x" title="delete" onClick={(e) => { e.stopPropagation(); deleteAsset(a.id).then(refreshAssets); if (selected === a.id) setSelected(null); }}>×</button>
</div>
))}
);
})}
{!assets.length && <p className="dim">Drop videos, images, or 3D files anywhere.</p>}
</aside>
<main>
<AssetPreview asset={selectedAsset} />
{compareMode && compareAssets.length
? <CompareGrid assets={compareAssets} />
: <AssetPreview asset={selectedAsset} />}
</main>
<aside className="workbench">
@ -205,27 +222,36 @@ export default function App() {
{usableOps.map((op) => <option key={op.id} value={op.id}>{op.name}</option>)}
</select>
{activeOp && <p className="dim">{activeOp.description}</p>}
{gated && <p className="warn">🔒 Needs {gatedEnv.join(", ")} add it in Settings.</p>}
{activeOp && <ParamForm schema={activeOp.params_schema} values={params} onChange={setParams} />}
<button className="go" disabled={!selectedAsset || !activeOp} onClick={launch}>
<button className="go" disabled={!selectedAsset || !activeOp || gated} onClick={launch}>
Run {activeOp?.name || ""}
</button>
<h2>Jobs</h2>
<div className="jobs">
{jobs.map((j) => (
<div key={j.id} className={"job " + j.status}
onClick={() => setOpenJob(openJob === j.id ? null : j.id)}>
<span className="status">{j.status === "running" ? "⏳" : j.status === "done" ? "✅" : j.status === "error" ? "❌" : "🕐"}</span>
<span>{j.operator}</span>
<span className="dim">{j.id}</span>
<div key={j.id} className={"job " + j.status}>
<div className="job-row" onClick={() => setOpenJob(openJob === j.id ? null : j.id)}>
<span className="status">{j.status === "running" ? "⏳" : j.status === "done" ? "✅" : j.status === "error" ? "❌" : j.status === "cancelled" ? "⛔" : "🕐"}</span>
<span className="jop">{j.operator}</span>
<span className="dim jid">{j.id}</span>
<span className="job-actions" onClick={(e) => e.stopPropagation()}>
{j.status === "running" && <button onClick={() => cancelJob(j.id)}>cancel</button>}
{(j.status === "error" || j.status === "done" || j.status === "cancelled") && <button onClick={() => retryJob(j.id).then(refreshJobs)}>retry</button>}
{j.status !== "running" && <button onClick={() => deleteJob(j.id).then(refreshJobs)}>×</button>}
</span>
</div>
{openJob === j.id && (
<pre className="log">{j.error ? `ERROR: ${j.error}\n` : ""}{j.log || "(no output yet)"}</pre>
<pre className="log">{j.error ? `ERROR: ${j.error}\n\n` : ""}{j.log || "(no output yet)"}</pre>
)}
</div>
))}
{!jobs.length && <p className="dim">No jobs yet.</p>}
</div>
</aside>
{showSettings && <Settings onClose={() => setShowSettings(false)} onSaved={setSettings} />}
</div>
);
}

63
web/src/Settings.jsx Normal file
View File

@ -0,0 +1,63 @@
import { useEffect, useState } from "react";
import { getSettings, putSettings } from "./api";
const LABELS = {
fal_key: "fal.ai API key",
tripo_key: "Tripo API key",
meshy_key: "Meshy API key",
replicate_token: "Replicate token",
hf_token: "HuggingFace token (for SF3D / TRELLIS.2 weights)",
models_dir: "Models directory",
archive_host: "Archive host (rsync)",
archive_path: "Archive path",
};
export default function Settings({ onClose, onSaved }) {
const [data, setData] = useState(null);
const [edits, setEdits] = useState({});
const [saving, setSaving] = useState(false);
useEffect(() => { getSettings().then(setData); }, []);
if (!data) return null;
const secretKeys = new Set(data._secret_keys || []);
const keys = data._env_keys || [];
const save = async () => {
setSaving(true);
const fresh = await putSettings(edits);
setData(fresh); setEdits({}); setSaving(false);
onSaved?.(fresh);
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h2>Settings</h2>
<p className="dim">API keys unlock cloud operators. HuggingFace token lets local SF3D / TRELLIS.2 download their gated weights. Secrets are masked and never written to logs.</p>
{keys.map((k) => {
const isSecret = secretKeys.has(k);
const current = data[k] || "";
const placeholder = isSecret && current ? "•••••••• (set — leave blank to keep)" : "";
return (
<label key={k} className="setting">
<span>{LABELS[k] || k}</span>
<input
type={isSecret ? "password" : "text"}
placeholder={placeholder}
defaultValue={isSecret ? "" : current}
onChange={(e) => setEdits({ ...edits, [k]: e.target.value })}
/>
</label>
);
})}
<div className="modal-actions">
<button onClick={onClose}>Close</button>
<button className="go" onClick={save} disabled={saving || !Object.keys(edits).length}>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
);
}

46
web/src/SplatViewer.jsx Normal file
View File

@ -0,0 +1,46 @@
import { useEffect, useRef } from "react";
// Lazy, self-contained gaussian-splat viewer. Loads the heavy library only when
// a splat asset is actually opened.
export default function SplatViewer({ url }) {
const mountRef = useRef(null);
useEffect(() => {
const mount = mountRef.current;
if (!mount || !url) return;
let viewer;
let disposed = false;
(async () => {
const GS = await import("@mkkellogg/gaussian-splats-3d");
if (disposed) return;
viewer = new GS.Viewer({
rootElement: mount,
selfDrivenMode: true,
useBuiltInControls: true,
sharedMemoryForWorkers: false,
});
try {
// our asset URLs have no file extension, so tell the loader it's PLY
await viewer.addSplatScene(url, {
showLoadingUI: true,
progressiveLoad: false,
format: GS.SceneFormat.Ply,
});
if (!disposed) viewer.start();
} catch (e) {
console.error("splat load error", e);
if (mount) mount.innerHTML =
'<div style="color:#8a8aa0;padding:24px">Could not render this splat. Download it to view in a desktop splat viewer.</div>';
}
})();
return () => {
disposed = true;
try { viewer?.dispose?.(); } catch { /* ignore */ }
if (mount) mount.innerHTML = "";
};
}, [url]);
return <div className="viewer" ref={mountRef} />;
}

View File

@ -25,6 +25,18 @@ export const runJob = (operator, asset_id, params) =>
body: JSON.stringify({ operator, asset_id, params }),
});
export const cancelJob = (id) => api(`/api/jobs/${id}/cancel`, { method: "POST" });
export const retryJob = (id) => api(`/api/jobs/${id}/retry`, { method: "POST" });
export const deleteJob = (id) => api(`/api/jobs/${id}`, { method: "DELETE" });
export const getSettings = () => api("/api/settings");
export const putSettings = (updates) =>
api("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
export function connectWS(onMessage) {
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/ws`);

View File

@ -60,15 +60,56 @@ main { grid-area: main; display: flex; min-width: 0; min-height: 0; }
}
.workbench > select { width: 100%; }
.jobs .job {
padding: 8px; border-radius: 8px; margin-bottom: 6px; background: var(--panel);
display: flex; gap: 8px; align-items: center; flex-wrap: wrap; cursor: pointer;
border-left: 3px solid var(--edge); font-size: 13px;
border-radius: 8px; margin-bottom: 6px; background: var(--panel);
border-left: 3px solid var(--edge); font-size: 13px; overflow: hidden;
}
.job-row { display: flex; gap: 8px; align-items: center; padding: 8px; cursor: pointer; }
.job.running { border-left-color: var(--accent); }
.job.done { border-left-color: var(--good); }
.job.error { border-left-color: var(--bad); }
.job.cancelled { border-left-color: var(--dim); }
.job .jop { font-weight: 500; }
.job .jid { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.job-actions { display: flex; gap: 4px; }
.job-actions button { padding: 2px 8px; font-size: 11px; border-radius: 6px; }
.job .log {
flex-basis: 100%; font: 11px/1.5 "SF Mono", Menlo, monospace; color: var(--dim);
max-height: 240px; overflow: auto; white-space: pre-wrap; background: var(--bg);
padding: 8px; border-radius: 6px;
font: 11px/1.5 "SF Mono", Menlo, monospace; color: var(--dim);
max-height: 300px; overflow: auto; white-space: pre-wrap; background: var(--bg);
padding: 8px; margin: 0 8px 8px;
}
/* header */
.spacer { flex: 1; }
header button.active { border-color: var(--accent); color: var(--accent); }
/* asset extras */
.asset input[type="checkbox"] { margin: 0; }
.asset.checked { background: var(--panel2); border-color: var(--good); }
.asset .dl { color: var(--dim); text-decoration: none; padding: 2px 6px; font-size: 15px; }
.asset .dl:hover { color: var(--accent); }
/* run panel */
.warn { color: #ffcf6b; font-size: 12.5px; background: rgba(255,207,107,0.08);
padding: 7px 9px; border-radius: 8px; border: 1px solid rgba(255,207,107,0.25); }
/* compare grid */
.compare-grid { flex: 1; display: grid; gap: 6px; padding: 6px; min-width: 0;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); align-content: stretch; }
.compare-cell { position: relative; background: #101014; border-radius: 8px; overflow: hidden;
display: flex; min-height: 300px; }
.compare-cap { position: absolute; top: 0; left: 0; right: 0; z-index: 2; padding: 6px 10px;
font-size: 11.5px; color: var(--text); background: linear-gradient(#000a, #0000);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* settings modal */
.modal-backdrop { position: fixed; inset: 0; background: #000a; display: flex;
align-items: center; justify-content: center; z-index: 100; }
.modal { background: var(--panel); border: 1px solid var(--edge); border-radius: 14px;
padding: 22px 24px; width: 520px; max-width: 92vw; max-height: 86vh; overflow-y: auto; }
.modal h2 { margin-top: 0; }
.setting { display: flex; justify-content: space-between; align-items: center; gap: 14px; margin: 10px 0; }
.setting span { font-size: 13px; color: var(--dim); flex: 1; }
.setting input { background: var(--panel2); border: 1px solid var(--edge); color: var(--text);
border-radius: 6px; padding: 7px 9px; width: 240px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.modal-actions .go { width: auto; padding: 8px 18px; margin: 0; }