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:
parent
d0569dc746
commit
605b1ae347
8
.gitignore
vendored
8
.gitignore
vendored
@ -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
34
BENCHMARKS.md
Normal 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 | ~30–60s 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 ~3–5 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; ~1s–1min 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).
|
||||
15
HANDOFF.md
15
HANDOFF.md
@ -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 §5–8 below.
|
||||
|
||||
**Owner action to unblock local mesh-gen:** accept HF licenses + set HF token (see BENCHMARKS.md). fal operators need `FAL_KEY` in Settings.
|
||||
|
||||
---
|
||||
|
||||
|
||||
35
README.md
35
README.md
@ -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 1–4.
|
||||
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) §5–8.
|
||||
|
||||
@ -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
16
scripts/install_brush.sh
Executable 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
9
scripts/install_colmap.sh
Executable 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
35
scripts/install_sf3d.sh
Executable 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
21
scripts/install_trellis_mac.sh
Executable 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)"
|
||||
26
server/db.py
26
server/db.py
@ -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])
|
||||
|
||||
100
server/main.py
100
server/main.py
@ -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)
|
||||
|
||||
|
||||
92
server/operators/_lib/fal_common.py
Normal file
92
server/operators/_lib/fal_common.py
Normal 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)")
|
||||
20
server/operators/brush_train/manifest.json
Normal file
20
server/operators/brush_train/manifest.json
Normal 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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
server/operators/brush_train/run.py
Normal file
58
server/operators/brush_train/run.py
Normal 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)
|
||||
19
server/operators/colmap_poses/manifest.json
Normal file
19
server/operators/colmap_poses/manifest.json
Normal 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)"}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
server/operators/colmap_poses/run.py
Normal file
76
server/operators/colmap_poses/run.py
Normal 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)
|
||||
19
server/operators/fal_hunyuan3d/manifest.json
Normal file
19
server/operators/fal_hunyuan3d/manifest.json
Normal 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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
server/operators/fal_hunyuan3d/run.py
Normal file
16
server/operators/fal_hunyuan3d/run.py
Normal 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",
|
||||
)
|
||||
18
server/operators/fal_rodin/manifest.json
Normal file
18
server/operators/fal_rodin/manifest.json
Normal 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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
server/operators/fal_rodin/run.py
Normal file
19
server/operators/fal_rodin/run.py
Normal 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",
|
||||
)
|
||||
22
server/operators/fal_trellis/manifest.json
Normal file
22
server/operators/fal_trellis/manifest.json
Normal 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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
server/operators/fal_trellis/run.py
Normal file
22
server/operators/fal_trellis/run.py
Normal 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",
|
||||
)
|
||||
22
server/operators/fal_trellis2/manifest.json
Normal file
22
server/operators/fal_trellis2/manifest.json
Normal 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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
server/operators/fal_trellis2/run.py
Normal file
21
server/operators/fal_trellis2/run.py
Normal 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",
|
||||
)
|
||||
20
server/operators/sf3d/manifest.json
Normal file
20
server/operators/sf3d/manifest.json
Normal 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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
51
server/operators/sf3d/run.py
Normal file
51
server/operators/sf3d/run.py
Normal 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)
|
||||
20
server/operators/trellis_mac/manifest.json
Normal file
20
server/operators/trellis_mac/manifest.json
Normal 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)"}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
server/operators/trellis_mac/run.py
Normal file
50
server/operators/trellis_mac/run.py
Normal 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)
|
||||
161
server/runner.py
161
server/runner.py
@ -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
|
||||
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:])
|
||||
code = await proc.wait()
|
||||
log = "".join(log_lines)[-100_000:]
|
||||
try:
|
||||
async for raw in proc.stdout:
|
||||
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()
|
||||
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
76
server/settings.py
Normal 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
|
||||
@ -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
77
tests/smoke.sh
Executable 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
126
uv.lock
generated
@ -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
10
web/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
180
web/src/App.jsx
180
web/src/App.jsx
@ -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) => (
|
||||
<div key={a.id}
|
||||
className={"asset" + (a.id === selected ? " sel" : "")}
|
||||
onClick={() => setSelected(a.id)}>
|
||||
<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>
|
||||
{assets.map((a) => {
|
||||
const checked = compareSel.includes(a.id);
|
||||
return (
|
||||
<div key={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>
|
||||
<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>
|
||||
<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
63
web/src/Settings.jsx
Normal 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
46
web/src/SplatViewer.jsx
Normal 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} />;
|
||||
}
|
||||
@ -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`);
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user