diff --git a/.gitignore b/.gitignore index deb5124..6aa4d99 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,11 @@ bin/*.o # OS .DS_Store +vendor/ + +# Brush runtime autotune cache +target/ + +# Brush prebuilt binary (reinstall via scripts/install_brush.sh) +bin/brush +bin/brush-app-aarch64-apple-darwin/ diff --git a/BENCHMARKS.md b/BENCHMARKS.md new file mode 100644 index 0000000..480732b --- /dev/null +++ b/BENCHMARKS.md @@ -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). diff --git a/HANDOFF.md b/HANDOFF.md index 69f05ec..3a51f35 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,6 +1,19 @@ # MODELBEAST — Build Handoff Brief -**Audience:** the AI agent (Opus 4.8 / Claude Code) continuing this build. Read this file top to bottom, then read `PLAN.md`, then start at Phase 1. Do not re-research what is already verified here — every tool claim in this doc and PLAN.md was verified against primary sources on 2026-07-12 by research agents. +**Audience:** the AI agent (Opus 4.8 / Claude Code) continuing this build. Read this file top to bottom, then read `PLAN.md`. Do not re-research what is already verified here — every tool claim in this doc and PLAN.md was verified against primary sources on 2026-07-12 by research agents. + +## PROGRESS (updated 2026-07-12, Opus 4.8 session) + +**Done & verified end-to-end:** +- Git baseline. Framework: settings/secrets (`server/settings.py`, masked, env-injected, log-redacted), queue lanes (gpu=1/cpu=3/net=6), job cancel/retry/delete, multi-input job support, inbox watch folder (`data/inbox/`), `MODELBEAST_DATA` test override. `tests/smoke.sh` = 12 checks passing. +- Operators added (11 total): `fal_trellis`, `fal_trellis2`, `fal_hunyuan3d`, `fal_rodin` (shared `_lib/fal_common.py`, verified params, recursive result-URL extractor), `sf3d`, `trellis_mac` (both **installed** — venvs + Metal kernels built, torch 2.13 MPS; gated on owner HF auth), `colmap_poses`, `brush_train`. +- **Scan pipeline proven through the real UI**: frames → `colmap_poses` (48/48 registered, 0.6px) → `brush_train` → splat.ply → renders in in-app SplatViewer. +- Frontend: Settings modal, operator gating (🔒 + disabled run), job actions, Compare grid (multi-select side-by-side viewers), SplatViewer (`@mkkellogg/gaussian-splats-3d`, format forced to Ply since asset URLs are extensionless), splat/colmap_dataset asset kinds. All browser-verified. +- Install scripts: `scripts/install_{colmap,brush,sf3d,trellis_mac}.sh`. Vendored repos in `vendor/` (gitignored), venvs in `venvs/` and `vendor/trellis-mac/.venv` (gitignored). + +**Not yet built (next up):** object_capture (Swift CLI; Xcode present), freemocap, gvhmr_import, blender_retarget (multi-input UI wiring), workflow presets (§4.5), tripo/meshy character APIs (Phase 4), archive_to_m4pro, LLM copilot. See §5–8 below. + +**Owner action to unblock local mesh-gen:** accept HF licenses + set HF token (see BENCHMARKS.md). fal operators need `FAL_KEY` in Settings. --- diff --git a/README.md b/README.md index 28c2305..32d19c0 100644 --- a/README.md +++ b/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://:8777` from any device on the tailnet). +Drag/drop/paste any video, image, or 3D file; or drop files into `data/inbox/` for auto-ingest. Pick an operator, tune its parameters, run. Add API keys / HuggingFace token under ⚙ Settings. Use ⊞ Compare to view several outputs side by side. + ## Develop -- Backend: `server/` — FastAPI + SQLite (`data/modelbeast.db`), job runner executes operators as subprocesses. -- Frontend: `web/` — React + Vite + three.js. After editing: `cd web && npm run build` (the server serves `web/dist`). -- Data: `data/assets/` (asset store), `data/jobs/` (job workdirs). Delete `data/` to reset everything. +- Backend: `server/` — FastAPI + SQLite (`data/modelbeast.db`), job runner runs operators as subprocesses across concurrency lanes (gpu=1, cpu=3, net=6). Settings/secrets in `server/settings.py` (env-injected, log-redacted). +- Frontend: `web/` — React + Vite + three.js + `@mkkellogg/gaussian-splats-3d`. After editing: `cd web && npm run build` (the server serves `web/dist`). +- Data: `data/assets/` (store), `data/jobs/` (job workdirs), `data/inbox/` (watch folder). Delete `data/` to reset. Tests use `MODELBEAST_DATA=`. +- Tests: `./tests/smoke.sh` (12 framework checks). Benchmarks in [BENCHMARKS.md](BENCHMARKS.md). +- Heavy tools live in `vendor/` (repos) + `venvs/` (per-tool envs), both gitignored. Reinstall with `scripts/install_*.sh`. + +## Setup for the local mesh generators (one-time) + +`sf3d` and `trellis_mac` are installed but their weights are HuggingFace-gated. Accept the licenses (stabilityai/stable-fast-3d, facebook/dinov3-vitl16-pretrain-lvd1689m, briaai/RMBG-2.0), then `huggingface-cli login` or paste an HF token in Settings. Cloud `fal_*` operators need `FAL_KEY` in Settings. ## Operators @@ -22,11 +30,18 @@ Each subfolder of `server/operators/` with a `manifest.json` is an operator. The Contract: the runner invokes ` run.py --input --outdir --params ''`. Write outputs into the outdir; optionally write `result.json` (`{"outputs": [{"path": ..., "name": ..., "meta": ...}]}`) to control what gets registered as assets. stdout/stderr become the job log. -Current operators: -| id | what | -|---|---| -| `ffprobe` | media inspection | -| `ffmpeg_frames` | video → frames (fps, mpdecimate dedupe, blur cull, max cap) | -| `blender_convert` | GLB/GLTF/OBJ/FBX/STL/PLY/USD/BLEND → GLB/FBX/OBJ/USD/USDZ/STL/PLY/BLEND via headless Blender | +Manifest fields: `id, name, category, description, accepts, produces, resources` (gpu/cpu/net lane), `requires_env` (gates the operator until the key is set), `python` (absolute venv path for heavy tools), `params_schema`. Operators can tag output asset kind via `result.json` output `meta.kind` (e.g. `splat`, `colmap_dataset`). -Heavy operators (trellis-mac, SF3D, COLMAP/Brush, FreeMoCap…) get their own uv venv: set `"python": ""` 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": ""` 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. diff --git a/pyproject.toml b/pyproject.toml index 14b98b2..cfd2102 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" requires-python = ">=3.12" dependencies = [ "aiofiles>=25.1.0", + "fal-client>=1.0.0", "fastapi>=0.139.0", "python-multipart>=0.0.32", "uvicorn[standard]>=0.51.0", diff --git a/scripts/install_brush.sh b/scripts/install_brush.sh new file mode 100755 index 0000000..6aa6bce --- /dev/null +++ b/scripts/install_brush.sh @@ -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" diff --git a/scripts/install_colmap.sh b/scripts/install_colmap.sh new file mode 100755 index 0000000..ad50f95 --- /dev/null +++ b/scripts/install_colmap.sh @@ -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" diff --git a/scripts/install_sf3d.sh b/scripts/install_sf3d.sh new file mode 100755 index 0000000..93917ae --- /dev/null +++ b/scripts/install_sf3d.sh @@ -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)" diff --git a/scripts/install_trellis_mac.sh b/scripts/install_trellis_mac.sh new file mode 100755 index 0000000..5482007 --- /dev/null +++ b/scripts/install_trellis_mac.sh @@ -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)" diff --git a/server/db.py b/server/db.py index c256d31..b0b2be5 100644 --- a/server/db.py +++ b/server/db.py @@ -1,11 +1,13 @@ import json +import os import sqlite3 import time import uuid from pathlib import Path ROOT = Path(__file__).resolve().parent.parent -DATA = ROOT / "data" +# Tests / alternate roots can override the data dir. +DATA = Path(os.environ.get("MODELBEAST_DATA", str(ROOT / "data"))) DB_PATH = DATA / "modelbeast.db" SCHEMA = """ @@ -24,6 +26,7 @@ CREATE TABLE IF NOT EXISTS jobs ( operator TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued', asset_id TEXT, + asset_ids TEXT NOT NULL DEFAULT '[]', params TEXT NOT NULL DEFAULT '{}', outdir TEXT, log TEXT NOT NULL DEFAULT '', @@ -32,8 +35,18 @@ CREATE TABLE IF NOT EXISTS jobs ( started_at REAL, finished_at REAL ); +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' +); """ +# Columns added after the original Phase 0 schema; applied idempotently so an +# existing data/modelbeast.db upgrades in place. +MIGRATIONS = [ + ("jobs", "asset_ids", "TEXT NOT NULL DEFAULT '[]'"), +] + def connect() -> sqlite3.Connection: DATA.mkdir(parents=True, exist_ok=True) @@ -43,9 +56,18 @@ def connect() -> sqlite3.Connection: con.row_factory = sqlite3.Row con.execute("PRAGMA journal_mode=WAL") con.executescript(SCHEMA) + _migrate(con) return con +def _migrate(con: sqlite3.Connection) -> None: + for table, column, decl in MIGRATIONS: + cols = {r["name"] for r in con.execute(f"PRAGMA table_info({table})")} + if column not in cols: + con.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}") + con.commit() + + def new_id() -> str: return uuid.uuid4().hex[:12] @@ -56,7 +78,7 @@ def now() -> float: def row_to_dict(row: sqlite3.Row) -> dict: d = dict(row) - for key in ("meta", "params"): + for key in ("meta", "params", "asset_ids"): if key in d and isinstance(d[key], str): try: d[key] = json.loads(d[key]) diff --git a/server/main.py b/server/main.py index a2f9a6e..098f34e 100644 --- a/server/main.py +++ b/server/main.py @@ -6,28 +6,49 @@ from fastapi import FastAPI, File, HTTPException, UploadFile, WebSocket, WebSock from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from . import db, registry, store +from . import db, registry, settings as settings_mod, store from .runner import runner app = FastAPI(title="MODELBEAST") WEB_DIST = db.ROOT / "web" / "dist" +INBOX = db.DATA / "inbox" @app.on_event("startup") async def startup(): runner.operators = registry.load_operators() - app.state.con = db.connect() + con = db.connect() + app.state.con = con + runner.get_settings = lambda: settings_mod.get_all(con) asyncio.create_task(runner.worker()) + asyncio.create_task(watch_inbox()) # -- operators ---------------------------------------------------------------- @app.get("/api/operators") def list_operators(): - ops = [] - for op in runner.operators.values(): - public = {k: v for k, v in op.items() if k != "dir"} - ops.append(public) - return ops + return [{k: v for k, v in op.items() if k != "dir"} for op in runner.operators.values()] + + +# -- settings ----------------------------------------------------------------- +@app.get("/api/settings") +def get_settings(): + s = settings_mod.get_all(app.state.con) + view = settings_mod.public_view(s) + view["_secret_keys"] = sorted(settings_mod.SECRET_KEYS) + view["_env_keys"] = sorted(settings_mod.ENV_MAP.keys()) + # env var names currently satisfied — lets the UI gate operators + view["_env_set"] = sorted(env for key, env in settings_mod.ENV_MAP.items() if s.get(key)) + return view + + +@app.put("/api/settings") +def put_settings(payload: dict): + # ignore masked secret placeholders so re-saving the form doesn't wipe a key + clean = {k: v for k, v in payload.items() + if not (k in settings_mod.SECRET_KEYS and set(str(v)) <= {"•"})} + settings_mod.set_many(app.state.con, clean) + return get_settings() # -- assets --------------------------------------------------------------------- @@ -57,7 +78,7 @@ def asset_file(asset_id: str, member: str | None = None): if not asset: raise HTTPException(404, "no such asset") path = Path(asset["path"]) - if member: # file inside a folder asset (e.g. one frame) + if member: target = (path / member).resolve() if not str(target).startswith(str(path.resolve())) or not target.is_file(): raise HTTPException(404, "no such member") @@ -79,11 +100,14 @@ async def create_job(payload: dict): operator = payload.get("operator") if operator not in runner.operators: raise HTTPException(400, f"unknown operator: {operator}") - asset_id = payload.get("asset_id") - if asset_id and not store.get_asset(app.state.con, asset_id): - raise HTTPException(400, "unknown asset") + asset_ids = payload.get("asset_ids") + if not asset_ids: + asset_ids = [payload["asset_id"]] if payload.get("asset_id") else [] + for aid in asset_ids: + if not store.get_asset(app.state.con, aid): + raise HTTPException(400, f"unknown asset: {aid}") params = payload.get("params") or {} - job = runner.create_job(app.state.con, operator, asset_id, params) + job = runner.create_job(app.state.con, operator, asset_ids, params) await runner.broadcast({"type": "job", "job": job}) return job @@ -96,6 +120,56 @@ def get_job(job_id: str): return job +@app.post("/api/jobs/{job_id}/cancel") +async def cancel_job(job_id: str): + if not await runner.cancel(app.state.con, job_id): + raise HTTPException(400, "job not cancellable") + return {"ok": True} + + +@app.post("/api/jobs/{job_id}/retry") +async def retry_job(job_id: str): + job = runner.get_job(app.state.con, job_id) + if not job: + raise HTTPException(404, "no such job") + new = runner.create_job(app.state.con, job["operator"], + job.get("asset_ids") or [], job["params"]) + await runner.broadcast({"type": "job", "job": new}) + return new + + +@app.delete("/api/jobs/{job_id}") +def delete_job(job_id: str): + if not runner.delete_job(app.state.con, job_id): + raise HTTPException(400, "job running or missing") + return {"ok": True} + + +# -- inbox watch folder --------------------------------------------------------- +async def watch_inbox(): + """Ingest files dropped into data/inbox/ once their size is stable (avoids + grabbing partial copies). Lets UE/Blender/rsync drop outputs in for pickup.""" + INBOX.mkdir(parents=True, exist_ok=True) + seen: dict[str, int] = {} + while True: + await asyncio.sleep(3) + try: + for p in INBOX.iterdir(): + if p.name.startswith(".") or not p.exists(): + continue + size = p.stat().st_size if p.is_file() else sum( + f.stat().st_size for f in p.rglob("*") if f.is_file()) + if seen.get(str(p)) == size and size > 0: + asset = store.register_file(app.state.con, p, move=True, + meta={"source": "inbox"}) + seen.pop(str(p), None) + await runner.broadcast({"type": "assets_changed", "assets": [asset["id"]]}) + else: + seen[str(p)] = size + except Exception as e: + print(f"[inbox] {e}") + + # -- websocket --------------------------------------------------------------- @app.websocket("/ws") async def ws(websocket: WebSocket): @@ -103,7 +177,7 @@ async def ws(websocket: WebSocket): runner.subscribers.add(websocket) try: while True: - await websocket.receive_text() # keepalive pings from client + await websocket.receive_text() except WebSocketDisconnect: runner.subscribers.discard(websocket) diff --git a/server/operators/_lib/fal_common.py b/server/operators/_lib/fal_common.py new file mode 100644 index 0000000..5a8f5ea --- /dev/null +++ b/server/operators/_lib/fal_common.py @@ -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)") diff --git a/server/operators/brush_train/manifest.json b/server/operators/brush_train/manifest.json new file mode 100644 index 0000000..64ebd78 --- /dev/null +++ b/server/operators/brush_train/manifest.json @@ -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"} + } + } +} diff --git a/server/operators/brush_train/run.py b/server/operators/brush_train/run.py new file mode 100644 index 0000000..ec40aaa --- /dev/null +++ b/server/operators/brush_train/run.py @@ -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) diff --git a/server/operators/colmap_poses/manifest.json b/server/operators/colmap_poses/manifest.json new file mode 100644 index 0000000..e094292 --- /dev/null +++ b/server/operators/colmap_poses/manifest.json @@ -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)"} + } + } +} diff --git a/server/operators/colmap_poses/run.py b/server/operators/colmap_poses/run.py new file mode 100644 index 0000000..587b4b8 --- /dev/null +++ b/server/operators/colmap_poses/run.py @@ -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) diff --git a/server/operators/fal_hunyuan3d/manifest.json b/server/operators/fal_hunyuan3d/manifest.json new file mode 100644 index 0000000..f042c86 --- /dev/null +++ b/server/operators/fal_hunyuan3d/manifest.json @@ -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"} + } + } +} diff --git a/server/operators/fal_hunyuan3d/run.py b/server/operators/fal_hunyuan3d/run.py new file mode 100644 index 0000000..1329298 --- /dev/null +++ b/server/operators/fal_hunyuan3d/run.py @@ -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", +) diff --git a/server/operators/fal_rodin/manifest.json b/server/operators/fal_rodin/manifest.json new file mode 100644 index 0000000..4b93e69 --- /dev/null +++ b/server/operators/fal_rodin/manifest.json @@ -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"} + } + } +} diff --git a/server/operators/fal_rodin/run.py b/server/operators/fal_rodin/run.py new file mode 100644 index 0000000..790ea94 --- /dev/null +++ b/server/operators/fal_rodin/run.py @@ -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", +) diff --git a/server/operators/fal_trellis/manifest.json b/server/operators/fal_trellis/manifest.json new file mode 100644 index 0000000..a9f3613 --- /dev/null +++ b/server/operators/fal_trellis/manifest.json @@ -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"} + } + } +} diff --git a/server/operators/fal_trellis/run.py b/server/operators/fal_trellis/run.py new file mode 100644 index 0000000..0aed908 --- /dev/null +++ b/server/operators/fal_trellis/run.py @@ -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", +) diff --git a/server/operators/fal_trellis2/manifest.json b/server/operators/fal_trellis2/manifest.json new file mode 100644 index 0000000..2440bc1 --- /dev/null +++ b/server/operators/fal_trellis2/manifest.json @@ -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"} + } + } +} diff --git a/server/operators/fal_trellis2/run.py b/server/operators/fal_trellis2/run.py new file mode 100644 index 0000000..9c9d001 --- /dev/null +++ b/server/operators/fal_trellis2/run.py @@ -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", +) diff --git a/server/operators/sf3d/manifest.json b/server/operators/sf3d/manifest.json new file mode 100644 index 0000000..f0c12a0 --- /dev/null +++ b/server/operators/sf3d/manifest.json @@ -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"} + } + } +} diff --git a/server/operators/sf3d/run.py b/server/operators/sf3d/run.py new file mode 100644 index 0000000..9ec767e --- /dev/null +++ b/server/operators/sf3d/run.py @@ -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) diff --git a/server/operators/trellis_mac/manifest.json b/server/operators/trellis_mac/manifest.json new file mode 100644 index 0000000..d5a039d --- /dev/null +++ b/server/operators/trellis_mac/manifest.json @@ -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)"} + } + } +} diff --git a/server/operators/trellis_mac/run.py b/server/operators/trellis_mac/run.py new file mode 100644 index 0000000..1c92c19 --- /dev/null +++ b/server/operators/trellis_mac/run.py @@ -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 .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) diff --git a/server/runner.py b/server/runner.py index a14c431..e395f11 100644 --- a/server/runner.py +++ b/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: - run.py --input --outdir --params '' - - stdout/stderr are streamed into the job log + run.py --input [--input ...] --outdir --params '' + - inputs are passed in asset order (single-input ops get one --input) + - stdout/stderr are streamed into the job log (secret values redacted) - operator writes outputs into outdir and (optionally) outdir/result.json: - {"outputs": [{"path": "relative/or/absolute", "name": "...", "meta": {...}}, ...], + {"outputs": [{"path": "rel/or/abs", "name": "...", "meta": {...}}, ...], "summary": {...}} - - if result.json is absent, every top-level file in outdir is registered as an asset + - if result.json is absent, every top-level file in outdir is registered +Concurrency lanes (from manifest "resources"): gpu=1 (Metal contention), +cpu=3, net=6. Env vars from settings are injected; "requires_env" gates a job. """ import asyncio import json +import os +import signal import sys from pathlib import Path -from . import db, store +from . import db, settings as settings_mod, store JOBS_DIR = db.DATA / "jobs" +LANE_LIMITS = {"gpu": 1, "cpu": 3, "net": 6} class Runner: def __init__(self): self.queue: asyncio.Queue[str] = asyncio.Queue() - self.subscribers: set = set() # websockets + self.subscribers: set = set() self.operators: dict[str, dict] = {} + self.lanes: dict[str, asyncio.Semaphore] = {} + self.procs: dict = {} # job_id -> subprocess + self.cancelled: set[str] = set() + self._tasks: set = set() + self.get_settings = lambda: {} # set by main.py # -- pubsub ------------------------------------------------------------- async def broadcast(self, message: dict): @@ -35,15 +46,22 @@ class Runner: for ws in dead: self.subscribers.discard(ws) + def lane_of(self, operator: str) -> str: + op = self.operators.get(operator, {}) + lane = op.get("resources", "cpu") + return lane if lane in LANE_LIMITS else "cpu" + # -- job lifecycle -------------------------------------------------------- - def create_job(self, con, operator: str, asset_id: str | None, params: dict) -> dict: + def create_job(self, con, operator: str, asset_ids: list[str], params: dict) -> dict: job_id = db.new_id() outdir = JOBS_DIR / job_id outdir.mkdir(parents=True, exist_ok=True) + primary = asset_ids[0] if asset_ids else None con.execute( - "INSERT INTO jobs (id, operator, status, asset_id, params, outdir, created_at) " - "VALUES (?, ?, 'queued', ?, ?, ?, ?)", - (job_id, operator, asset_id, json.dumps(params), str(outdir), db.now()), + "INSERT INTO jobs (id, operator, status, asset_id, asset_ids, params, outdir, created_at) " + "VALUES (?, ?, 'queued', ?, ?, ?, ?, ?)", + (job_id, operator, primary, json.dumps(asset_ids), + json.dumps(params), str(outdir), db.now()), ) con.commit() self.queue.put_nowait(job_id) @@ -57,6 +75,41 @@ class Runner: rows = con.execute("SELECT * FROM jobs ORDER BY created_at DESC LIMIT 200").fetchall() return [db.row_to_dict(r) for r in rows] + def input_paths(self, con, job: dict) -> list[str]: + ids = job.get("asset_ids") or ([job["asset_id"]] if job.get("asset_id") else []) + paths = [] + for aid in ids: + asset = store.get_asset(con, aid) + if asset: + paths.append(asset["path"]) + return paths + + async def cancel(self, con, job_id: str) -> bool: + job = self.get_job(con, job_id) + if not job or job["status"] in ("done", "error", "cancelled"): + return False + self.cancelled.add(job_id) + proc = self.procs.get(job_id) + if proc and proc.returncode is None: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + except (ProcessLookupError, PermissionError): + pass + else: # queued but not yet started + await self._update(con, job_id, status="cancelled", finished_at=db.now()) + return True + + def delete_job(self, con, job_id: str) -> bool: + job = self.get_job(con, job_id) + if not job or job_id in self.procs: + return False + import shutil + if job.get("outdir"): + shutil.rmtree(job["outdir"], ignore_errors=True) + con.execute("DELETE FROM jobs WHERE id = ?", (job_id,)) + con.commit() + return True + async def _update(self, con, job_id: str, **fields): sets = ", ".join(f"{k} = ?" for k in fields) con.execute(f"UPDATE jobs SET {sets} WHERE id = ?", (*fields.values(), job_id)) @@ -67,13 +120,30 @@ class Runner: # -- worker ----------------------------------------------------------------- async def worker(self): con = db.connect() - # re-queue jobs that were left running/queued by a previous process + for key in LANE_LIMITS: + self.lanes[key] = asyncio.Semaphore(LANE_LIMITS[key]) + # re-queue jobs left running/queued by a previous process for row in con.execute("SELECT id FROM jobs WHERE status IN ('queued','running')"): con.execute("UPDATE jobs SET status='queued' WHERE id = ?", (row["id"],)) self.queue.put_nowait(row["id"]) con.commit() while True: job_id = await self.queue.get() + task = asyncio.create_task(self._run_lane(con, job_id)) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + + async def _run_lane(self, con, job_id: str): + if job_id in self.cancelled: + self.cancelled.discard(job_id) + await self._update(con, job_id, status="cancelled", finished_at=db.now()) + return + lane = self.lane_of(self.get_job(con, job_id)["operator"]) + async with self.lanes[lane]: + if job_id in self.cancelled: + self.cancelled.discard(job_id) + await self._update(con, job_id, status="cancelled", finished_at=db.now()) + return try: await self._run_job(con, job_id) except Exception as e: @@ -85,37 +155,67 @@ class Runner: return op = self.operators.get(job["operator"]) if not op: - await self._update(con, job_id, status="error", error=f"unknown operator {job['operator']}", + await self._update(con, job_id, status="error", + error=f"unknown operator {job['operator']}", finished_at=db.now()) + return + + cur_settings = self.get_settings() + env = os.environ.copy() + env.update(settings_mod.env_from_settings(cur_settings)) + secrets = settings_mod.secret_values(cur_settings) + + missing = [e for e in op.get("requires_env", []) if not env.get(e)] + if missing: + keys = ", ".join(sorted({k for k, v in settings_mod.ENV_MAP.items() if v in missing})) + await self._update(con, job_id, status="error", + error=f"missing setting(s): {keys} — add them in Settings", finished_at=db.now()) return - asset = store.get_asset(con, job["asset_id"]) if job["asset_id"] else None + outdir = Path(job["outdir"]) entry = Path(op["dir"]) / op.get("entry", "run.py") python = op.get("python") or sys.executable - cmd = [python, str(entry), "--outdir", str(outdir), - "--params", json.dumps(job["params"])] - if asset: - cmd += ["--input", asset["path"]] + if op.get("python") and not Path(python).exists(): + await self._update(con, job_id, status="error", finished_at=db.now(), + error=f"operator not installed — {python} missing. " + f"Run its install script under scripts/.") + return + cmd = [python, str(entry), "--outdir", str(outdir), "--params", json.dumps(job["params"])] + for p in self.input_paths(con, job): + cmd += ["--input", p] await self._update(con, job_id, status="running", started_at=db.now()) proc = await asyncio.create_subprocess_exec( - *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT) + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + start_new_session=True, env=env) + self.procs[job_id] = proc log_lines: list[str] = [] assert proc.stdout - async for raw in proc.stdout: - line = raw.decode(errors="replace") - log_lines.append(line) - if len(log_lines) % 5 == 0: # don't hammer the DB on chatty tools - await self._update(con, job_id, log="".join(log_lines)[-100_000:]) - code = await proc.wait() - log = "".join(log_lines)[-100_000:] + try: + async for raw in proc.stdout: + log_lines.append(raw.decode(errors="replace")) + if len(log_lines) % 5 == 0: + log = settings_mod.redact("".join(log_lines)[-100_000:], secrets) + await self._update(con, job_id, log=log) + code = await proc.wait() + finally: + self.procs.pop(job_id, None) + log = settings_mod.redact("".join(log_lines)[-100_000:], secrets) + if job_id in self.cancelled: + self.cancelled.discard(job_id) + await self._update(con, job_id, status="cancelled", log=log, finished_at=db.now()) + return if code != 0: await self._update(con, job_id, status="error", log=log, error=f"exit code {code}", finished_at=db.now()) return - # register outputs + registered = self._register_outputs(con, outdir, job_id) + await self._update(con, job_id, status="done", log=log, finished_at=db.now()) + await self.broadcast({"type": "assets_changed", "job_id": job_id, "assets": registered}) + + def _register_outputs(self, con, outdir: Path, job_id: str) -> list[str]: result_path = outdir / "result.json" registered = [] if result_path.exists(): @@ -126,8 +226,7 @@ class Runner: p = outdir / p if p.exists(): a = store.register_file(con, p, name=out.get("name"), - parent_job=job_id, move=True, - meta=out.get("meta")) + parent_job=job_id, move=True, meta=out.get("meta")) registered.append(a["id"]) else: for p in sorted(outdir.iterdir()): @@ -135,9 +234,7 @@ class Runner: continue a = store.register_file(con, p, parent_job=job_id, move=True) registered.append(a["id"]) - - await self._update(con, job_id, status="done", log=log, finished_at=db.now()) - await self.broadcast({"type": "assets_changed", "job_id": job_id, "assets": registered}) + return registered runner = Runner() diff --git a/server/settings.py b/server/settings.py new file mode 100644 index 0000000..7ac178f --- /dev/null +++ b/server/settings.py @@ -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 diff --git a/server/store.py b/server/store.py index 382e378..36a310e 100644 --- a/server/store.py +++ b/server/store.py @@ -31,6 +31,7 @@ def register_file(con, src: Path, name: str | None = None, parent_job: str | Non dest_dir = ASSETS_DIR / asset_id dest_dir.mkdir(parents=True, exist_ok=True) dest = dest_dir / name + meta = meta or {} if src.is_dir(): if move: shutil.move(str(src), dest) @@ -45,6 +46,9 @@ def register_file(con, src: Path, name: str | None = None, parent_job: str | Non shutil.copy2(src, dest) size = dest.stat().st_size kind = kind_of(name) + # operators may tag a semantic kind that isn't inferable from the extension + # (e.g. a splat .ply vs a mesh .ply, or a colmap_dataset folder) + kind = meta.pop("kind", kind) con.execute( "INSERT INTO assets (id, name, kind, path, size, meta, parent_job, created_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100755 index 0000000..a7f0d6a --- /dev/null +++ b/tests/smoke.sh @@ -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 ] diff --git a/uv.lock b/uv.lock index 5fa2f70..e01f98c 100644 --- a/uv.lock +++ b/uv.lock @@ -42,6 +42,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, ] +[[package]] +name = "asyncstdlib" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/66/7b76f2b48afc5c8b4648500a4ff9869f83528e3d601a19de51da18b5e8a5/asyncstdlib-3.14.0.tar.gz", hash = "sha256:f99396992a3bba7495d8cf6c832bfe8c8727f61213233d3569a5a9cbd1c04385", size = 51074, upload-time = "2026-03-20T21:06:46.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/50/463443651ddb0ba66c289d5649369099ab72b4665888b337ed25ea622550/asyncstdlib-3.14.0-py3-none-any.whl", hash = "sha256:cad90c590ce357da6e70c56f0e2ad9e9baaf146a8fc2ede189879da57eb8bd87", size = 44255, upload-time = "2026-03-20T21:06:45.033Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + [[package]] name = "click" version = "8.4.2" @@ -63,6 +81,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "fal-client" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "asyncstdlib" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "msgpack" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/95/17727aba8ac0d7c958bd4823f256c2690fea57173f6748720784750237ad/fal_client-1.0.0.tar.gz", hash = "sha256:ba62cc008a4bbfae2c3cbade6c7a691093e42cfb5a79f7fca4dd61acbd87372a", size = 37713, upload-time = "2026-04-28T22:42:32.469Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/ae/2f1ec97492ced94256acfc39040fe979cb6f96e2ba4602ac59b6fd548538/fal_client-1.0.0-py3-none-any.whl", hash = "sha256:0e8a611fde2363c15cd26de8eadfada72a6fdf5077211e95120d7c870a233b2b", size = 22632, upload-time = "2026-04-28T22:42:31.035Z" }, +] + [[package]] name = "fastapi" version = "0.139.0" @@ -88,6 +123,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + [[package]] name = "httptools" version = "0.8.0" @@ -124,6 +172,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, ] +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -139,6 +211,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, + { name = "fal-client" }, { name = "fastapi" }, { name = "python-multipart" }, { name = "uvicorn", extra = ["standard"] }, @@ -147,11 +220,64 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "aiofiles", specifier = ">=25.1.0" }, + { name = "fal-client", specifier = ">=1.0.0" }, { name = "fastapi", specifier = ">=0.139.0" }, { name = "python-multipart", specifier = ">=0.0.32" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.51.0" }, ] +[[package]] +name = "msgpack" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, + { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" diff --git a/web/package-lock.json b/web/package-lock.json index 7fb1f2c..e9bbd6c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "web", "version": "0.0.0", "dependencies": { + "@mkkellogg/gaussian-splats-3d": "^0.4.7", "react": "^19.2.7", "react-dom": "^19.2.7", "three": "^0.185.1" @@ -54,6 +55,15 @@ "tslib": "^2.4.0" } }, + "node_modules/@mkkellogg/gaussian-splats-3d": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@mkkellogg/gaussian-splats-3d/-/gaussian-splats-3d-0.4.7.tgz", + "integrity": "sha512-0vy9/i9sJLFH/v3WJZ4axCsqjkToe8UsV3xY7bvK5EUC0akiRsWZODoCiSzpxhTLNyzSKTsyQKozIFeNA5RWRA==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.160.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", diff --git a/web/package.json b/web/package.json index a182833..0cd36fc 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@mkkellogg/gaussian-splats-3d": "^0.4.7", "react": "^19.2.7", "react-dom": "^19.2.7", "three": "^0.185.1" diff --git a/web/src/App.jsx b/web/src/App.jsx index 2852f67..e5c011a 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,15 +1,18 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - api, assetFileURL, connectWS, deleteAsset, listAssets, listJobs, - listOperators, runJob, uploadFile, + api, assetFileURL, cancelJob, connectWS, deleteAsset, deleteJob, getSettings, + listAssets, listJobs, listOperators, retryJob, runJob, uploadFile, } from "./api"; import Viewer from "./Viewer"; +import SplatViewer from "./SplatViewer"; +import Settings from "./Settings"; const MODEL_EXTS = ["glb", "gltf", "obj", "fbx", "ply", "stl"]; const fmtSize = (n) => n > 1e9 ? (n / 1e9).toFixed(1) + " GB" : n > 1e6 ? (n / 1e6).toFixed(1) + " MB" : n > 1e3 ? (n / 1e3).toFixed(0) + " KB" : n + " B"; -const KIND_ICON = { video: "🎬", image: "🖼", model: "🧊", frames: "🎞", other: "📄" }; +const KIND_ICON = { video: "🎬", image: "🖼", model: "🧊", splat: "✨", frames: "🎞", colmap_dataset: "📐", folder: "📁", other: "📄" }; +const isViewable = (a) => a && (a.kind === "splat" || MODEL_EXTS.includes(a.name.split(".").pop().toLowerCase())); function ParamForm({ schema, values, onChange }) { const props = schema?.properties || {}; @@ -23,15 +26,15 @@ function ParamForm({ schema, values, onChange }) {