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

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

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

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

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

93 lines
3.3 KiB
Python

"""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)")