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
|
# OS
|
||||||
.DS_Store
|
.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
|
# 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).
|
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
|
## Develop
|
||||||
|
|
||||||
- Backend: `server/` — FastAPI + SQLite (`data/modelbeast.db`), job runner executes operators as subprocesses.
|
- 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. After editing: `cd web && npm run build` (the server serves `web/dist`).
|
- 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/` (asset store), `data/jobs/` (job workdirs). Delete `data/` to reset everything.
|
- 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
|
## 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.
|
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:
|
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`).
|
||||||
| 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 |
|
|
||||||
|
|
||||||
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"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiofiles>=25.1.0",
|
"aiofiles>=25.1.0",
|
||||||
|
"fal-client>=1.0.0",
|
||||||
"fastapi>=0.139.0",
|
"fastapi>=0.139.0",
|
||||||
"python-multipart>=0.0.32",
|
"python-multipart>=0.0.32",
|
||||||
"uvicorn[standard]>=0.51.0",
|
"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 json
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
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"
|
DB_PATH = DATA / "modelbeast.db"
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
@ -24,6 +26,7 @@ CREATE TABLE IF NOT EXISTS jobs (
|
|||||||
operator TEXT NOT NULL,
|
operator TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'queued',
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
asset_id TEXT,
|
asset_id TEXT,
|
||||||
|
asset_ids TEXT NOT NULL DEFAULT '[]',
|
||||||
params TEXT NOT NULL DEFAULT '{}',
|
params TEXT NOT NULL DEFAULT '{}',
|
||||||
outdir TEXT,
|
outdir TEXT,
|
||||||
log TEXT NOT NULL DEFAULT '',
|
log TEXT NOT NULL DEFAULT '',
|
||||||
@ -32,8 +35,18 @@ CREATE TABLE IF NOT EXISTS jobs (
|
|||||||
started_at REAL,
|
started_at REAL,
|
||||||
finished_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:
|
def connect() -> sqlite3.Connection:
|
||||||
DATA.mkdir(parents=True, exist_ok=True)
|
DATA.mkdir(parents=True, exist_ok=True)
|
||||||
@ -43,9 +56,18 @@ def connect() -> sqlite3.Connection:
|
|||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
con.execute("PRAGMA journal_mode=WAL")
|
con.execute("PRAGMA journal_mode=WAL")
|
||||||
con.executescript(SCHEMA)
|
con.executescript(SCHEMA)
|
||||||
|
_migrate(con)
|
||||||
return 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:
|
def new_id() -> str:
|
||||||
return uuid.uuid4().hex[:12]
|
return uuid.uuid4().hex[:12]
|
||||||
|
|
||||||
@ -56,7 +78,7 @@ def now() -> float:
|
|||||||
|
|
||||||
def row_to_dict(row: sqlite3.Row) -> dict:
|
def row_to_dict(row: sqlite3.Row) -> dict:
|
||||||
d = dict(row)
|
d = dict(row)
|
||||||
for key in ("meta", "params"):
|
for key in ("meta", "params", "asset_ids"):
|
||||||
if key in d and isinstance(d[key], str):
|
if key in d and isinstance(d[key], str):
|
||||||
try:
|
try:
|
||||||
d[key] = json.loads(d[key])
|
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.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import db, registry, store
|
from . import db, registry, settings as settings_mod, store
|
||||||
from .runner import runner
|
from .runner import runner
|
||||||
|
|
||||||
app = FastAPI(title="MODELBEAST")
|
app = FastAPI(title="MODELBEAST")
|
||||||
WEB_DIST = db.ROOT / "web" / "dist"
|
WEB_DIST = db.ROOT / "web" / "dist"
|
||||||
|
INBOX = db.DATA / "inbox"
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup():
|
async def startup():
|
||||||
runner.operators = registry.load_operators()
|
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(runner.worker())
|
||||||
|
asyncio.create_task(watch_inbox())
|
||||||
|
|
||||||
|
|
||||||
# -- operators ----------------------------------------------------------------
|
# -- operators ----------------------------------------------------------------
|
||||||
@app.get("/api/operators")
|
@app.get("/api/operators")
|
||||||
def list_operators():
|
def list_operators():
|
||||||
ops = []
|
return [{k: v for k, v in op.items() if k != "dir"} for op in runner.operators.values()]
|
||||||
for op in runner.operators.values():
|
|
||||||
public = {k: v for k, v in op.items() if k != "dir"}
|
|
||||||
ops.append(public)
|
# -- settings -----------------------------------------------------------------
|
||||||
return ops
|
@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 ---------------------------------------------------------------------
|
# -- assets ---------------------------------------------------------------------
|
||||||
@ -57,7 +78,7 @@ def asset_file(asset_id: str, member: str | None = None):
|
|||||||
if not asset:
|
if not asset:
|
||||||
raise HTTPException(404, "no such asset")
|
raise HTTPException(404, "no such asset")
|
||||||
path = Path(asset["path"])
|
path = Path(asset["path"])
|
||||||
if member: # file inside a folder asset (e.g. one frame)
|
if member:
|
||||||
target = (path / member).resolve()
|
target = (path / member).resolve()
|
||||||
if not str(target).startswith(str(path.resolve())) or not target.is_file():
|
if not str(target).startswith(str(path.resolve())) or not target.is_file():
|
||||||
raise HTTPException(404, "no such member")
|
raise HTTPException(404, "no such member")
|
||||||
@ -79,11 +100,14 @@ async def create_job(payload: dict):
|
|||||||
operator = payload.get("operator")
|
operator = payload.get("operator")
|
||||||
if operator not in runner.operators:
|
if operator not in runner.operators:
|
||||||
raise HTTPException(400, f"unknown operator: {operator}")
|
raise HTTPException(400, f"unknown operator: {operator}")
|
||||||
asset_id = payload.get("asset_id")
|
asset_ids = payload.get("asset_ids")
|
||||||
if asset_id and not store.get_asset(app.state.con, asset_id):
|
if not asset_ids:
|
||||||
raise HTTPException(400, "unknown asset")
|
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 {}
|
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})
|
await runner.broadcast({"type": "job", "job": job})
|
||||||
return job
|
return job
|
||||||
|
|
||||||
@ -96,6 +120,56 @@ def get_job(job_id: str):
|
|||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/jobs/{job_id}/cancel")
|
||||||
|
async def cancel_job(job_id: str):
|
||||||
|
if not await runner.cancel(app.state.con, job_id):
|
||||||
|
raise HTTPException(400, "job not cancellable")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/jobs/{job_id}/retry")
|
||||||
|
async def retry_job(job_id: str):
|
||||||
|
job = runner.get_job(app.state.con, job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404, "no such job")
|
||||||
|
new = runner.create_job(app.state.con, job["operator"],
|
||||||
|
job.get("asset_ids") or [], job["params"])
|
||||||
|
await runner.broadcast({"type": "job", "job": new})
|
||||||
|
return new
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/jobs/{job_id}")
|
||||||
|
def delete_job(job_id: str):
|
||||||
|
if not runner.delete_job(app.state.con, job_id):
|
||||||
|
raise HTTPException(400, "job running or missing")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# -- inbox watch folder ---------------------------------------------------------
|
||||||
|
async def watch_inbox():
|
||||||
|
"""Ingest files dropped into data/inbox/ once their size is stable (avoids
|
||||||
|
grabbing partial copies). Lets UE/Blender/rsync drop outputs in for pickup."""
|
||||||
|
INBOX.mkdir(parents=True, exist_ok=True)
|
||||||
|
seen: dict[str, int] = {}
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
try:
|
||||||
|
for p in INBOX.iterdir():
|
||||||
|
if p.name.startswith(".") or not p.exists():
|
||||||
|
continue
|
||||||
|
size = p.stat().st_size if p.is_file() else sum(
|
||||||
|
f.stat().st_size for f in p.rglob("*") if f.is_file())
|
||||||
|
if seen.get(str(p)) == size and size > 0:
|
||||||
|
asset = store.register_file(app.state.con, p, move=True,
|
||||||
|
meta={"source": "inbox"})
|
||||||
|
seen.pop(str(p), None)
|
||||||
|
await runner.broadcast({"type": "assets_changed", "assets": [asset["id"]]})
|
||||||
|
else:
|
||||||
|
seen[str(p)] = size
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[inbox] {e}")
|
||||||
|
|
||||||
|
|
||||||
# -- websocket ---------------------------------------------------------------
|
# -- websocket ---------------------------------------------------------------
|
||||||
@app.websocket("/ws")
|
@app.websocket("/ws")
|
||||||
async def ws(websocket: WebSocket):
|
async def ws(websocket: WebSocket):
|
||||||
@ -103,7 +177,7 @@ async def ws(websocket: WebSocket):
|
|||||||
runner.subscribers.add(websocket)
|
runner.subscribers.add(websocket)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
await websocket.receive_text() # keepalive pings from client
|
await websocket.receive_text()
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
runner.subscribers.discard(websocket)
|
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:
|
Operator contract:
|
||||||
<python> run.py --input <asset path> --outdir <job outdir> --params '<json>'
|
<python> run.py --input <path> [--input <path> ...] --outdir <dir> --params '<json>'
|
||||||
- stdout/stderr are streamed into the job log
|
- 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:
|
- 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": {...}}
|
"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 asyncio
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import db, store
|
from . import db, settings as settings_mod, store
|
||||||
|
|
||||||
JOBS_DIR = db.DATA / "jobs"
|
JOBS_DIR = db.DATA / "jobs"
|
||||||
|
LANE_LIMITS = {"gpu": 1, "cpu": 3, "net": 6}
|
||||||
|
|
||||||
|
|
||||||
class Runner:
|
class Runner:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.queue: asyncio.Queue[str] = asyncio.Queue()
|
self.queue: asyncio.Queue[str] = asyncio.Queue()
|
||||||
self.subscribers: set = set() # websockets
|
self.subscribers: set = set()
|
||||||
self.operators: dict[str, dict] = {}
|
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 -------------------------------------------------------------
|
# -- pubsub -------------------------------------------------------------
|
||||||
async def broadcast(self, message: dict):
|
async def broadcast(self, message: dict):
|
||||||
@ -35,15 +46,22 @@ class Runner:
|
|||||||
for ws in dead:
|
for ws in dead:
|
||||||
self.subscribers.discard(ws)
|
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 --------------------------------------------------------
|
# -- 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()
|
job_id = db.new_id()
|
||||||
outdir = JOBS_DIR / job_id
|
outdir = JOBS_DIR / job_id
|
||||||
outdir.mkdir(parents=True, exist_ok=True)
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
primary = asset_ids[0] if asset_ids else None
|
||||||
con.execute(
|
con.execute(
|
||||||
"INSERT INTO jobs (id, operator, status, asset_id, params, outdir, created_at) "
|
"INSERT INTO jobs (id, operator, status, asset_id, asset_ids, params, outdir, created_at) "
|
||||||
"VALUES (?, ?, 'queued', ?, ?, ?, ?)",
|
"VALUES (?, ?, 'queued', ?, ?, ?, ?, ?)",
|
||||||
(job_id, operator, asset_id, json.dumps(params), str(outdir), db.now()),
|
(job_id, operator, primary, json.dumps(asset_ids),
|
||||||
|
json.dumps(params), str(outdir), db.now()),
|
||||||
)
|
)
|
||||||
con.commit()
|
con.commit()
|
||||||
self.queue.put_nowait(job_id)
|
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()
|
rows = con.execute("SELECT * FROM jobs ORDER BY created_at DESC LIMIT 200").fetchall()
|
||||||
return [db.row_to_dict(r) for r in rows]
|
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):
|
async def _update(self, con, job_id: str, **fields):
|
||||||
sets = ", ".join(f"{k} = ?" for k in fields)
|
sets = ", ".join(f"{k} = ?" for k in fields)
|
||||||
con.execute(f"UPDATE jobs SET {sets} WHERE id = ?", (*fields.values(), job_id))
|
con.execute(f"UPDATE jobs SET {sets} WHERE id = ?", (*fields.values(), job_id))
|
||||||
@ -67,13 +120,30 @@ class Runner:
|
|||||||
# -- worker -----------------------------------------------------------------
|
# -- worker -----------------------------------------------------------------
|
||||||
async def worker(self):
|
async def worker(self):
|
||||||
con = db.connect()
|
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')"):
|
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"],))
|
con.execute("UPDATE jobs SET status='queued' WHERE id = ?", (row["id"],))
|
||||||
self.queue.put_nowait(row["id"])
|
self.queue.put_nowait(row["id"])
|
||||||
con.commit()
|
con.commit()
|
||||||
while True:
|
while True:
|
||||||
job_id = await self.queue.get()
|
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:
|
try:
|
||||||
await self._run_job(con, job_id)
|
await self._run_job(con, job_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -85,37 +155,67 @@ class Runner:
|
|||||||
return
|
return
|
||||||
op = self.operators.get(job["operator"])
|
op = self.operators.get(job["operator"])
|
||||||
if not op:
|
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())
|
finished_at=db.now())
|
||||||
return
|
return
|
||||||
asset = store.get_asset(con, job["asset_id"]) if job["asset_id"] else None
|
|
||||||
outdir = Path(job["outdir"])
|
outdir = Path(job["outdir"])
|
||||||
entry = Path(op["dir"]) / op.get("entry", "run.py")
|
entry = Path(op["dir"]) / op.get("entry", "run.py")
|
||||||
python = op.get("python") or sys.executable
|
python = op.get("python") or sys.executable
|
||||||
cmd = [python, str(entry), "--outdir", str(outdir),
|
if op.get("python") and not Path(python).exists():
|
||||||
"--params", json.dumps(job["params"])]
|
await self._update(con, job_id, status="error", finished_at=db.now(),
|
||||||
if asset:
|
error=f"operator not installed — {python} missing. "
|
||||||
cmd += ["--input", asset["path"]]
|
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())
|
await self._update(con, job_id, status="running", started_at=db.now())
|
||||||
proc = await asyncio.create_subprocess_exec(
|
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] = []
|
log_lines: list[str] = []
|
||||||
assert proc.stdout
|
assert proc.stdout
|
||||||
async for raw in proc.stdout:
|
try:
|
||||||
line = raw.decode(errors="replace")
|
async for raw in proc.stdout:
|
||||||
log_lines.append(line)
|
log_lines.append(raw.decode(errors="replace"))
|
||||||
if len(log_lines) % 5 == 0: # don't hammer the DB on chatty tools
|
if len(log_lines) % 5 == 0:
|
||||||
await self._update(con, job_id, log="".join(log_lines)[-100_000:])
|
log = settings_mod.redact("".join(log_lines)[-100_000:], secrets)
|
||||||
code = await proc.wait()
|
await self._update(con, job_id, log=log)
|
||||||
log = "".join(log_lines)[-100_000:]
|
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:
|
if code != 0:
|
||||||
await self._update(con, job_id, status="error", log=log,
|
await self._update(con, job_id, status="error", log=log,
|
||||||
error=f"exit code {code}", finished_at=db.now())
|
error=f"exit code {code}", finished_at=db.now())
|
||||||
return
|
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"
|
result_path = outdir / "result.json"
|
||||||
registered = []
|
registered = []
|
||||||
if result_path.exists():
|
if result_path.exists():
|
||||||
@ -126,8 +226,7 @@ class Runner:
|
|||||||
p = outdir / p
|
p = outdir / p
|
||||||
if p.exists():
|
if p.exists():
|
||||||
a = store.register_file(con, p, name=out.get("name"),
|
a = store.register_file(con, p, name=out.get("name"),
|
||||||
parent_job=job_id, move=True,
|
parent_job=job_id, move=True, meta=out.get("meta"))
|
||||||
meta=out.get("meta"))
|
|
||||||
registered.append(a["id"])
|
registered.append(a["id"])
|
||||||
else:
|
else:
|
||||||
for p in sorted(outdir.iterdir()):
|
for p in sorted(outdir.iterdir()):
|
||||||
@ -135,9 +234,7 @@ class Runner:
|
|||||||
continue
|
continue
|
||||||
a = store.register_file(con, p, parent_job=job_id, move=True)
|
a = store.register_file(con, p, parent_job=job_id, move=True)
|
||||||
registered.append(a["id"])
|
registered.append(a["id"])
|
||||||
|
return registered
|
||||||
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})
|
|
||||||
|
|
||||||
|
|
||||||
runner = Runner()
|
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 = ASSETS_DIR / asset_id
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
dest = dest_dir / name
|
dest = dest_dir / name
|
||||||
|
meta = meta or {}
|
||||||
if src.is_dir():
|
if src.is_dir():
|
||||||
if move:
|
if move:
|
||||||
shutil.move(str(src), dest)
|
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)
|
shutil.copy2(src, dest)
|
||||||
size = dest.stat().st_size
|
size = dest.stat().st_size
|
||||||
kind = kind_of(name)
|
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(
|
con.execute(
|
||||||
"INSERT INTO assets (id, name, kind, path, size, meta, parent_job, created_at) "
|
"INSERT INTO assets (id, name, kind, path, size, meta, parent_job, created_at) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
"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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.4.2"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.139.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "httptools"
|
name = "httptools"
|
||||||
version = "0.8.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.18"
|
version = "3.18"
|
||||||
@ -139,6 +211,7 @@ version = "0.1.0"
|
|||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
|
{ name = "fal-client" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
@ -147,11 +220,64 @@ dependencies = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiofiles", specifier = ">=25.1.0" },
|
{ name = "aiofiles", specifier = ">=25.1.0" },
|
||||||
|
{ name = "fal-client", specifier = ">=1.0.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.139.0" },
|
{ name = "fastapi", specifier = ">=0.139.0" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.32" },
|
{ name = "python-multipart", specifier = ">=0.0.32" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.51.0" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.13.4"
|
version = "2.13.4"
|
||||||
|
|||||||
10
web/package-lock.json
generated
10
web/package-lock.json
generated
@ -8,6 +8,7 @@
|
|||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@mkkellogg/gaussian-splats-3d": "^0.4.7",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"three": "^0.185.1"
|
"three": "^0.185.1"
|
||||||
@ -54,6 +55,15 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@mkkellogg/gaussian-splats-3d": "^0.4.7",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"three": "^0.185.1"
|
"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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
api, assetFileURL, connectWS, deleteAsset, listAssets, listJobs,
|
api, assetFileURL, cancelJob, connectWS, deleteAsset, deleteJob, getSettings,
|
||||||
listOperators, runJob, uploadFile,
|
listAssets, listJobs, listOperators, retryJob, runJob, uploadFile,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import Viewer from "./Viewer";
|
import Viewer from "./Viewer";
|
||||||
|
import SplatViewer from "./SplatViewer";
|
||||||
|
import Settings from "./Settings";
|
||||||
|
|
||||||
const MODEL_EXTS = ["glb", "gltf", "obj", "fbx", "ply", "stl"];
|
const MODEL_EXTS = ["glb", "gltf", "obj", "fbx", "ply", "stl"];
|
||||||
const fmtSize = (n) =>
|
const fmtSize = (n) =>
|
||||||
n > 1e9 ? (n / 1e9).toFixed(1) + " GB" : n > 1e6 ? (n / 1e6).toFixed(1) + " MB"
|
n > 1e9 ? (n / 1e9).toFixed(1) + " GB" : n > 1e6 ? (n / 1e6).toFixed(1) + " MB"
|
||||||
: n > 1e3 ? (n / 1e3).toFixed(0) + " KB" : n + " B";
|
: 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 }) {
|
function ParamForm({ schema, values, onChange }) {
|
||||||
const props = schema?.properties || {};
|
const props = schema?.properties || {};
|
||||||
@ -23,15 +26,15 @@ function ParamForm({ schema, values, onChange }) {
|
|||||||
<label key={key} title={def.description || ""}>
|
<label key={key} title={def.description || ""}>
|
||||||
<span>{key}</span>
|
<span>{key}</span>
|
||||||
{def.enum ? (
|
{def.enum ? (
|
||||||
<select value={val} onChange={(e) => set(e.target.value)}>
|
<select value={val} onChange={(e) => set(maybeNum(def, e.target.value))}>
|
||||||
{def.enum.map((o) => <option key={o}>{o}</option>)}
|
{def.enum.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||||
</select>
|
</select>
|
||||||
) : def.type === "boolean" ? (
|
) : def.type === "boolean" ? (
|
||||||
<input type="checkbox" checked={!!val} onChange={(e) => set(e.target.checked)} />
|
<input type="checkbox" checked={!!val} onChange={(e) => set(e.target.checked)} />
|
||||||
) : def.type === "number" || def.type === "integer" ? (
|
) : def.type === "number" || def.type === "integer" ? (
|
||||||
<input type="number" value={val} step={def.type === "integer" ? 1 : "any"}
|
<input type="number" value={val} step={def.type === "integer" ? 1 : "any"}
|
||||||
min={def.minimum} max={def.maximum}
|
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)} />
|
<input type="text" value={val} onChange={(e) => set(e.target.value)} />
|
||||||
)}
|
)}
|
||||||
@ -41,62 +44,78 @@ function ParamForm({ schema, values, onChange }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const maybeNum = (def, v) => (def.type === "integer" ? parseInt(v) : def.type === "number" ? parseFloat(v) : v);
|
||||||
|
|
||||||
function AssetPreview({ asset }) {
|
function AssetPreview({ asset }) {
|
||||||
const [folder, setFolder] = useState(null);
|
const [folder, setFolder] = useState(null);
|
||||||
const ext = asset ? asset.name.split(".").pop().toLowerCase() : "";
|
const ext = asset ? asset.name.split(".").pop().toLowerCase() : "";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFolder(null);
|
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(() => {});
|
api(assetFileURL(asset.id)).then(setFolder).catch(() => {});
|
||||||
}
|
}
|
||||||
}, [asset?.id]);
|
}, [asset?.id]);
|
||||||
|
|
||||||
if (!asset) return <div className="preview empty">Select an asset — or drop / paste files anywhere</div>;
|
if (!asset) return <div className="preview empty">Select an asset — or drop / paste files anywhere</div>;
|
||||||
if (MODEL_EXTS.includes(ext))
|
if (asset.kind === "splat") return <SplatViewer url={assetFileURL(asset.id)} />;
|
||||||
return <Viewer url={assetFileURL(asset.id)} ext={ext} />;
|
if (MODEL_EXTS.includes(ext)) return <Viewer url={assetFileURL(asset.id)} ext={ext} />;
|
||||||
if (asset.kind === "image")
|
if (asset.kind === "image") return <div className="preview"><img src={assetFileURL(asset.id)} alt={asset.name} /></div>;
|
||||||
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 (asset.kind === "video")
|
if (folder?.files) {
|
||||||
return <div className="preview"><video src={assetFileURL(asset.id)} controls /></div>;
|
const imgs = folder.files.filter((f) => /\.(jpg|jpeg|png)$/i.test(f));
|
||||||
if (folder?.files)
|
|
||||||
return (
|
return (
|
||||||
<div className="preview grid">
|
<div className="preview grid">
|
||||||
{folder.files.slice(0, 60).map((f) => (
|
{imgs.slice(0, 60).map((f) => <img key={f} src={assetFileURL(asset.id, f)} loading="lazy" alt={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>}
|
||||||
{folder.files.length > 60 && <span className="dim">+{folder.files.length - 60} more</span>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (ext === "json")
|
}
|
||||||
return <JsonPreview url={assetFileURL(asset.id)} />;
|
if (ext === "json") return <JsonPreview url={assetFileURL(asset.id)} />;
|
||||||
return <div className="preview empty">No preview for .{ext}</div>;
|
return <div className="preview empty">No preview for .{ext} — download to use it</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function JsonPreview({ url }) {
|
function JsonPreview({ url }) {
|
||||||
const [text, setText] = useState("…");
|
const [text, setText] = useState("…");
|
||||||
useEffect(() => {
|
useEffect(() => { fetch(url).then((r) => r.text()).then((t) => setText(t.slice(0, 20000))); }, [url]);
|
||||||
fetch(url).then((r) => r.text()).then((t) => setText(t.slice(0, 20000)));
|
|
||||||
}, [url]);
|
|
||||||
return <pre className="preview json">{text}</pre>;
|
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() {
|
export default function App() {
|
||||||
const [assets, setAssets] = useState([]);
|
const [assets, setAssets] = useState([]);
|
||||||
const [operators, setOperators] = useState([]);
|
const [operators, setOperators] = useState([]);
|
||||||
const [jobs, setJobs] = useState([]);
|
const [jobs, setJobs] = useState([]);
|
||||||
|
const [settings, setSettings] = useState({ _env_set: [] });
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [opId, setOpId] = useState(null);
|
const [opId, setOpId] = useState(null);
|
||||||
const [params, setParams] = useState({});
|
const [params, setParams] = useState({});
|
||||||
const [openJob, setOpenJob] = useState(null);
|
const [openJob, setOpenJob] = useState(null);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [compareMode, setCompareMode] = useState(false);
|
||||||
|
const [compareSel, setCompareSel] = useState([]);
|
||||||
const fileInput = useRef(null);
|
const fileInput = useRef(null);
|
||||||
|
|
||||||
const refreshAssets = useCallback(() => listAssets().then(setAssets), []);
|
const refreshAssets = useCallback(() => listAssets().then(setAssets), []);
|
||||||
const refreshJobs = useCallback(() => listJobs().then(setJobs), []);
|
const refreshJobs = useCallback(() => listJobs().then(setJobs), []);
|
||||||
|
const refreshSettings = useCallback(() => getSettings().then(setSettings), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshAssets();
|
refreshAssets(); refreshJobs(); refreshSettings();
|
||||||
refreshJobs();
|
|
||||||
listOperators().then(setOperators);
|
listOperators().then(setOperators);
|
||||||
connectWS((msg) => {
|
connectWS((msg) => {
|
||||||
if (msg.type === "job") {
|
if (msg.type === "job") {
|
||||||
@ -110,37 +129,22 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// drag-drop + paste ingest
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const drop = async (e) => {
|
const drop = async (e) => { e.preventDefault(); for (const f of e.dataTransfer?.files || []) await uploadFile(f); refreshAssets(); };
|
||||||
e.preventDefault();
|
const paste = async (e) => { for (const it of e.clipboardData?.items || []) { const f = it.getAsFile?.(); if (f) await uploadFile(f); } refreshAssets(); };
|
||||||
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 prevent = (e) => e.preventDefault();
|
const prevent = (e) => e.preventDefault();
|
||||||
window.addEventListener("drop", drop);
|
window.addEventListener("drop", drop); window.addEventListener("dragover", prevent); window.addEventListener("paste", paste);
|
||||||
window.addEventListener("dragover", prevent);
|
return () => { window.removeEventListener("drop", drop); window.removeEventListener("dragover", prevent); window.removeEventListener("paste", paste); };
|
||||||
window.addEventListener("paste", paste);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("drop", drop);
|
|
||||||
window.removeEventListener("dragover", prevent);
|
|
||||||
window.removeEventListener("paste", paste);
|
|
||||||
};
|
|
||||||
}, [refreshAssets]);
|
}, [refreshAssets]);
|
||||||
|
|
||||||
const selectedAsset = assets.find((a) => a.id === selected) || null;
|
const selectedAsset = assets.find((a) => a.id === selected) || null;
|
||||||
|
const envSet = settings._env_set || [];
|
||||||
const usableOps = useMemo(() =>
|
const usableOps = useMemo(() =>
|
||||||
operators.filter((op) => !selectedAsset || op.accepts.includes(selectedAsset.kind)),
|
operators.filter((op) => !selectedAsset || op.accepts.includes(selectedAsset.kind)),
|
||||||
[operators, selectedAsset]);
|
[operators, selectedAsset]);
|
||||||
const activeOp = operators.find((o) => o.id === opId);
|
const activeOp = operators.find((o) => o.id === opId);
|
||||||
|
const gatedEnv = (activeOp?.requires_env || []).filter((e) => !envSet.includes(e));
|
||||||
|
const gated = gatedEnv.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeOp) {
|
if (activeOp) {
|
||||||
@ -150,53 +154,66 @@ export default function App() {
|
|||||||
setParams(defaults);
|
setParams(defaults);
|
||||||
}
|
}
|
||||||
}, [opId]);
|
}, [opId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (usableOps.length && !usableOps.find((o) => o.id === opId)) setOpId(usableOps[0].id);
|
if (usableOps.length && !usableOps.find((o) => o.id === opId)) setOpId(usableOps[0].id);
|
||||||
}, [usableOps]);
|
}, [usableOps]);
|
||||||
|
|
||||||
const launch = async () => {
|
const launch = async () => {
|
||||||
if (!activeOp || !selectedAsset) return;
|
if (!activeOp || !selectedAsset || gated) return;
|
||||||
await runJob(activeOp.id, selectedAsset.id, params);
|
await runJob(activeOp.id, selectedAsset.id, params);
|
||||||
refreshJobs();
|
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 (
|
return (
|
||||||
<div className="layout">
|
<div className="layout">
|
||||||
<header>
|
<header>
|
||||||
<h1>MODEL<span>BEAST</span></h1>
|
<h1>MODEL<span>BEAST</span></h1>
|
||||||
<span className="dim">M3 Ultra · 256GB · local-first 3D factory</span>
|
<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>
|
<button onClick={() => fileInput.current.click()}>+ Add files</button>
|
||||||
<input ref={fileInput} type="file" multiple hidden
|
<input ref={fileInput} type="file" multiple hidden
|
||||||
onChange={async (e) => {
|
onChange={async (e) => { for (const f of e.target.files) await uploadFile(f); e.target.value = ""; refreshAssets(); }} />
|
||||||
for (const f of e.target.files) await uploadFile(f);
|
|
||||||
e.target.value = ""; refreshAssets();
|
|
||||||
}} />
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<aside className="assets">
|
<aside className="assets">
|
||||||
<h2>Assets</h2>
|
<h2>Assets</h2>
|
||||||
{assets.map((a) => (
|
{assets.map((a) => {
|
||||||
<div key={a.id}
|
const checked = compareSel.includes(a.id);
|
||||||
className={"asset" + (a.id === selected ? " sel" : "")}
|
return (
|
||||||
onClick={() => setSelected(a.id)}>
|
<div key={a.id}
|
||||||
<span className="icon">{KIND_ICON[a.kind] || "📄"}</span>
|
className={"asset" + (a.id === selected && !compareMode ? " sel" : "") + (checked ? " checked" : "")}
|
||||||
<div className="meta">
|
onClick={() => clickAsset(a)}>
|
||||||
<div className="name">{a.name}</div>
|
{compareMode && <input type="checkbox" checked={checked} readOnly disabled={!isViewable(a)} />}
|
||||||
<div className="dim">{a.kind} · {fmtSize(a.size)}</div>
|
<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>
|
</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>}
|
{!assets.length && <p className="dim">Drop videos, images, or 3D files anywhere.</p>}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<AssetPreview asset={selectedAsset} />
|
{compareMode && compareAssets.length
|
||||||
|
? <CompareGrid assets={compareAssets} />
|
||||||
|
: <AssetPreview asset={selectedAsset} />}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside className="workbench">
|
<aside className="workbench">
|
||||||
@ -205,27 +222,36 @@ export default function App() {
|
|||||||
{usableOps.map((op) => <option key={op.id} value={op.id}>{op.name}</option>)}
|
{usableOps.map((op) => <option key={op.id} value={op.id}>{op.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
{activeOp && <p className="dim">{activeOp.description}</p>}
|
{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} />}
|
{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 || ""}
|
▶ Run {activeOp?.name || ""}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h2>Jobs</h2>
|
<h2>Jobs</h2>
|
||||||
<div className="jobs">
|
<div className="jobs">
|
||||||
{jobs.map((j) => (
|
{jobs.map((j) => (
|
||||||
<div key={j.id} className={"job " + j.status}
|
<div key={j.id} className={"job " + j.status}>
|
||||||
onClick={() => setOpenJob(openJob === j.id ? null : j.id)}>
|
<div className="job-row" onClick={() => setOpenJob(openJob === j.id ? null : j.id)}>
|
||||||
<span className="status">{j.status === "running" ? "⏳" : j.status === "done" ? "✅" : j.status === "error" ? "❌" : "🕐"}</span>
|
<span className="status">{j.status === "running" ? "⏳" : j.status === "done" ? "✅" : j.status === "error" ? "❌" : j.status === "cancelled" ? "⛔" : "🕐"}</span>
|
||||||
<span>{j.operator}</span>
|
<span className="jop">{j.operator}</span>
|
||||||
<span className="dim">{j.id}</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 && (
|
{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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!jobs.length && <p className="dim">No jobs yet.</p>}
|
{!jobs.length && <p className="dim">No jobs yet.</p>}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
{showSettings && <Settings onClose={() => setShowSettings(false)} onSaved={setSettings} />}
|
||||||
</div>
|
</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 }),
|
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) {
|
export function connectWS(onMessage) {
|
||||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||||
const ws = new WebSocket(`${proto}://${location.host}/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%; }
|
.workbench > select { width: 100%; }
|
||||||
.jobs .job {
|
.jobs .job {
|
||||||
padding: 8px; border-radius: 8px; margin-bottom: 6px; background: var(--panel);
|
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; overflow: hidden;
|
||||||
border-left: 3px solid var(--edge); font-size: 13px;
|
|
||||||
}
|
}
|
||||||
|
.job-row { display: flex; gap: 8px; align-items: center; padding: 8px; cursor: pointer; }
|
||||||
.job.running { border-left-color: var(--accent); }
|
.job.running { border-left-color: var(--accent); }
|
||||||
.job.done { border-left-color: var(--good); }
|
.job.done { border-left-color: var(--good); }
|
||||||
.job.error { border-left-color: var(--bad); }
|
.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 {
|
.job .log {
|
||||||
flex-basis: 100%; font: 11px/1.5 "SF Mono", Menlo, monospace; color: var(--dim);
|
font: 11px/1.5 "SF Mono", Menlo, monospace; color: var(--dim);
|
||||||
max-height: 240px; overflow: auto; white-space: pre-wrap; background: var(--bg);
|
max-height: 300px; overflow: auto; white-space: pre-wrap; background: var(--bg);
|
||||||
padding: 8px; border-radius: 6px;
|
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