diff --git a/HANDOFF.md b/HANDOFF.md index 3a51f35..21acbd9 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -11,7 +11,9 @@ - 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. +**fal panel (Fable session, later 2026-07-12):** 5 more operators โ€” `fal_hunyuan3d_v21` (live-verified: dash-variant single-image works ~90s; **v21 multi-view is broken on fal**, keep v2 for that), `fal_bg_remove` (BiRefNet v2 โ€” the imageโ†’3D quality pre-pass), `fal_upscale` (SeedVR), `fal_image_edit` (nano-banana), `fal_text_image` (Ideogram v3, first no-input operator). `fal_common` now collects image outputs (`collect="images"`). UI: operator dropdown grouped by category via optgroup; ops with `accepts: []` run without an asset. Fixed 3 review findings from the Opus commit: deleted-queued-job crash in `_run_lane`, secrets impossible to clear via PUT /api/settings, `register_file` mutating caller's meta. 16 operators total; smoke 12/12. + +**Not yet built (next up):** object_capture (Swift CLI; Xcode present), freemocap, gvhmr_import, blender_retarget (multi-input UI wiring), workflow presets (ยง4.5) โ€” first preset should be `image โ†’ bg_remove โ†’ trellis2/hunyuan_v21` โ€” 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 32d19c0..8eb18e8 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,12 @@ Current operators: | `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]* | +| `fal_trellis` / `fal_trellis2` / `fal_hunyuan3d` / `fal_hunyuan3d_v21` / `fal_rodin` | net | image โ†’ mesh via fal.ai API *[needs FAL_KEY]* | +| `fal_bg_remove` | net | image โ†’ subject cutout (BiRefNet v2) โ€” run before any imageโ†’3D for a big quality jump | +| `fal_upscale` | net | image โ†’ faithful upscale (SeedVR) | +| `fal_image_edit` | net | image + instruction โ†’ edited image (nano-banana) | +| `fal_text_image` | net | prompt โ†’ image (Ideogram v3, readable text) โ€” no input asset needed | + +Recommended fal chain for best imageโ†’3D: `fal_bg_remove` โ†’ (`fal_upscale` if thin) โ†’ `fal_trellis2` or `fal_hunyuan3d_v21`. Note: Hunyuan v21 **multi-view** is broken on fal (verified 2026-07) โ€” v21 is single-image only; use v2 for multi-view. 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/server/main.py b/server/main.py index 098f34e..585ee5a 100644 --- a/server/main.py +++ b/server/main.py @@ -45,8 +45,9 @@ def get_settings(): @app.put("/api/settings") def put_settings(payload: dict): # ignore masked secret placeholders so re-saving the form doesn't wipe a key + # (an empty string is a deliberate clear, not a placeholder) clean = {k: v for k, v in payload.items() - if not (k in settings_mod.SECRET_KEYS and set(str(v)) <= {"โ€ข"})} + if not (k in settings_mod.SECRET_KEYS and v and set(str(v)) <= {"โ€ข"})} settings_mod.set_many(app.state.con, clean) return get_settings() diff --git a/server/operators/_lib/fal_common.py b/server/operators/_lib/fal_common.py index 5a8f5ea..e754fd2 100644 --- a/server/operators/_lib/fal_common.py +++ b/server/operators/_lib/fal_common.py @@ -11,25 +11,26 @@ import urllib.request from pathlib import Path MODEL_EXTS = {".glb", ".gltf", ".obj", ".fbx", ".usdz", ".ply", ".stl", ".zip"} +IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"} def _log(msg): print(msg, flush=True) -def _find_file_urls(obj, acc): +def _find_file_urls(obj, acc, wanted_exts): """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: + if ext in wanted_exts: acc.append((url, ext, obj.get("file_name"))) for v in obj.values(): - _find_file_urls(v, acc) + _find_file_urls(v, acc, wanted_exts) elif isinstance(obj, list): for v in obj: - _find_file_urls(v, acc) + _find_file_urls(v, acc, wanted_exts) def parse_args(): @@ -42,7 +43,9 @@ def parse_args(): def run(endpoint, arguments, outdir, image_path=None, image_arg="image_url", - image_as_list=False, extra_args=None, out_stem="model"): + image_as_list=False, extra_args=None, out_stem="model", collect="models"): + """collect: 'models' (default) downloads 3D files from the result; + 'images' downloads image files (for cutout/upscale/edit/gen endpoints).""" import fal_client if image_path: @@ -73,10 +76,11 @@ def run(endpoint, arguments, outdir, image_path=None, image_arg="image_url", outdir = Path(outdir) (outdir / "fal_result.json").write_text(json.dumps(result, indent=2)) + wanted = IMAGE_EXTS if collect == "images" else MODEL_EXTS urls = [] - _find_file_urls(result, urls) + _find_file_urls(result, urls, wanted) if not urls: - _log("ERROR: no 3D file url found in fal result. Raw result saved to fal_result.json:") + _log(f"ERROR: no {collect} file url found in fal result. Raw result saved to fal_result.json:") _log(json.dumps(result, indent=2)[:2000]) sys.exit(1) diff --git a/server/operators/fal_bg_remove/manifest.json b/server/operators/fal_bg_remove/manifest.json new file mode 100644 index 0000000..8667253 --- /dev/null +++ b/server/operators/fal_bg_remove/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "fal_bg_remove", + "name": "fal ยท Remove Background", + "category": "image-prep", + "description": "Image โ†’ clean subject cutout (transparent PNG) via fal-ai/birefnet/v2 (sub-cent). The single biggest quality lever before imageโ†’3D: run this first, then feed the cutout to TRELLIS/Hunyuan/SF3D so no geometry is wasted on background clutter. Needs FAL_KEY.", + "accepts": ["image"], + "produces": ["image"], + "resources": "net", + "requires_env": ["FAL_KEY"], + "entry": "run.py", + "params_schema": { + "type": "object", + "properties": { + "operating_resolution": {"type": "string", "enum": ["1024x1024", "2048x2048"], "default": "1024x1024", "description": "Processing resolution (2048 = finer edges, slower)"}, + "extra_args": {"type": "string", "default": "", "description": "Raw JSON merged into fal arguments (e.g. model variant). Full control"} + } + } +} diff --git a/server/operators/fal_bg_remove/run.py b/server/operators/fal_bg_remove/run.py new file mode 100644 index 0000000..6fc2511 --- /dev/null +++ b/server/operators/fal_bg_remove/run.py @@ -0,0 +1,17 @@ +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() +stem = Path(a.input[0]).stem if a.input else "cutout" +fal_common.run( + endpoint="fal-ai/birefnet/v2", + arguments={"operating_resolution": p.get("operating_resolution")}, + outdir=a.outdir, + image_path=a.input[0] if a.input else None, + extra_args=p.get("extra_args"), + out_stem=f"{stem}_cutout", + collect="images", +) diff --git a/server/operators/fal_hunyuan3d_v21/manifest.json b/server/operators/fal_hunyuan3d_v21/manifest.json new file mode 100644 index 0000000..62ca95d --- /dev/null +++ b/server/operators/fal_hunyuan3d_v21/manifest.json @@ -0,0 +1,19 @@ +{ + "id": "fal_hunyuan3d_v21", + "name": "fal ยท Hunyuan3D 2.1 (PBR)", + "category": "mesh-gen", + "description": "Image โ†’ GLB with PBR textures via fal-ai/hunyuan3d-v21 (Hunyuan3D 2.1, ~90s, ~$0.05-0.10). Live-verified 2026-07: single-image works; the multi-view variant of v21 is broken on fal (use Hunyuan v2 for multi-view). 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_v21/run.py b/server/operators/fal_hunyuan3d_v21/run.py new file mode 100644 index 0000000..c56b1bb --- /dev/null +++ b/server/operators/fal_hunyuan3d_v21/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-v21", + 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="hunyuan3d21", +) diff --git a/server/operators/fal_image_edit/manifest.json b/server/operators/fal_image_edit/manifest.json new file mode 100644 index 0000000..e882eb8 --- /dev/null +++ b/server/operators/fal_image_edit/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "fal_image_edit", + "name": "fal ยท Edit Image (prompt)", + "category": "image-prep", + "description": "Image + instruction โ†’ edited image via fal-ai/nano-banana/edit (remove objects, fix lighting, change materials โ€” 'remove the price sticker', 'make it studio-lit on white'). Great for cleaning a photo before imageโ†’3D. Needs FAL_KEY.", + "accepts": ["image"], + "produces": ["image"], + "resources": "net", + "requires_env": ["FAL_KEY"], + "entry": "run.py", + "params_schema": { + "type": "object", + "properties": { + "prompt": {"type": "string", "default": "", "description": "Edit instruction (what to change)"}, + "extra_args": {"type": "string", "default": "", "description": "Raw JSON merged into fal arguments โ€” full control over any endpoint param"} + } + } +} diff --git a/server/operators/fal_image_edit/run.py b/server/operators/fal_image_edit/run.py new file mode 100644 index 0000000..189573e --- /dev/null +++ b/server/operators/fal_image_edit/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() +if not p.get("prompt"): + print("ERROR: prompt is required โ€” describe the edit you want") + sys.exit(1) +stem = Path(a.input[0]).stem if a.input else "image" +fal_common.run( + endpoint="fal-ai/nano-banana/edit", + arguments={"prompt": p["prompt"]}, + outdir=a.outdir, + image_path=a.input[0] if a.input else None, + image_arg="image_urls", + image_as_list=True, + extra_args=p.get("extra_args"), + out_stem=f"{stem}_edited", + collect="images", +) diff --git a/server/operators/fal_text_image/manifest.json b/server/operators/fal_text_image/manifest.json new file mode 100644 index 0000000..eed723a --- /dev/null +++ b/server/operators/fal_text_image/manifest.json @@ -0,0 +1,19 @@ +{ + "id": "fal_text_image", + "name": "fal ยท Text โ†’ Image", + "category": "generate", + "description": "Prompt โ†’ image via fal-ai/ideogram/v3 (~$0.03; uniquely renders READABLE text โ€” labels, posters, packaging). No input asset needed. Generate a concept image here, then feed it to a mesh generator. Needs FAL_KEY.", + "accepts": [], + "produces": ["image"], + "resources": "net", + "requires_env": ["FAL_KEY"], + "entry": "run.py", + "params_schema": { + "type": "object", + "properties": { + "prompt": {"type": "string", "default": "", "description": "What to generate"}, + "image_size": {"type": "string", "enum": ["square_hd", "square", "portrait_4_3", "portrait_16_9", "landscape_4_3", "landscape_16_9"], "default": "square_hd", "description": "Output aspect/size"}, + "extra_args": {"type": "string", "default": "", "description": "Raw JSON merged into fal arguments (style, seed, etc.). Full control"} + } + } +} diff --git a/server/operators/fal_text_image/run.py b/server/operators/fal_text_image/run.py new file mode 100644 index 0000000..f2a0727 --- /dev/null +++ b/server/operators/fal_text_image/run.py @@ -0,0 +1,18 @@ +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() +if not p.get("prompt"): + print("ERROR: prompt is required") + sys.exit(1) +fal_common.run( + endpoint="fal-ai/ideogram/v3", + arguments={"prompt": p["prompt"], "image_size": p.get("image_size", "square_hd")}, + outdir=a.outdir, + extra_args=p.get("extra_args"), + out_stem="generated", + collect="images", +) diff --git a/server/operators/fal_upscale/manifest.json b/server/operators/fal_upscale/manifest.json new file mode 100644 index 0000000..d67cab5 --- /dev/null +++ b/server/operators/fal_upscale/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "fal_upscale", + "name": "fal ยท Upscale (SeedVR)", + "category": "image-prep", + "description": "Image โ†’ faithful upscale via fal-ai/seedvr/upscale/image (~$0.001/MP โ€” sharpens without inventing fake detail). Use on thin/blurry inputs before mesh generation or texture work. Needs FAL_KEY.", + "accepts": ["image"], + "produces": ["image"], + "resources": "net", + "requires_env": ["FAL_KEY"], + "entry": "run.py", + "params_schema": { + "type": "object", + "properties": { + "upscale_factor": {"type": "number", "default": 2, "minimum": 1, "maximum": 4, "description": "Upscale multiplier"}, + "extra_args": {"type": "string", "default": "", "description": "Raw JSON merged into fal arguments โ€” full control over any endpoint param"} + } + } +} diff --git a/server/operators/fal_upscale/run.py b/server/operators/fal_upscale/run.py new file mode 100644 index 0000000..45a8ad2 --- /dev/null +++ b/server/operators/fal_upscale/run.py @@ -0,0 +1,17 @@ +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() +stem = Path(a.input[0]).stem if a.input else "image" +fal_common.run( + endpoint="fal-ai/seedvr/upscale/image", + arguments={"upscale_factor": p.get("upscale_factor", 2)}, + outdir=a.outdir, + image_path=a.input[0] if a.input else None, + extra_args=p.get("extra_args"), + out_stem=f"{stem}_upscaled", + collect="images", +) diff --git a/server/runner.py b/server/runner.py index e395f11..8ad345f 100644 --- a/server/runner.py +++ b/server/runner.py @@ -134,11 +134,15 @@ class Runner: task.add_done_callback(self._tasks.discard) async def _run_lane(self, con, job_id: str): + job = self.get_job(con, job_id) + if not job: # deleted while queued + self.cancelled.discard(job_id) + return 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"]) + lane = self.lane_of(job["operator"]) async with self.lanes[lane]: if job_id in self.cancelled: self.cancelled.discard(job_id) diff --git a/server/store.py b/server/store.py index 36a310e..b57f42d 100644 --- a/server/store.py +++ b/server/store.py @@ -31,7 +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 {} + meta = dict(meta or {}) if src.is_dir(): if move: shutil.move(str(src), dest) diff --git a/web/src/App.jsx b/web/src/App.jsx index e5c011a..f40a793 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -139,10 +139,17 @@ export default function App() { const selectedAsset = assets.find((a) => a.id === selected) || null; const envSet = settings._env_set || []; + // no-input operators (accepts: []) are always available; others filter by asset kind const usableOps = useMemo(() => - operators.filter((op) => !selectedAsset || op.accepts.includes(selectedAsset.kind)), + operators.filter((op) => !op.accepts.length || !selectedAsset || op.accepts.includes(selectedAsset.kind)), [operators, selectedAsset]); + const opGroups = useMemo(() => { + const groups = {}; + for (const op of usableOps) (groups[op.category || "other"] ||= []).push(op); + return groups; + }, [usableOps]); const activeOp = operators.find((o) => o.id === opId); + const needsInput = (activeOp?.accepts?.length ?? 0) > 0; const gatedEnv = (activeOp?.requires_env || []).filter((e) => !envSet.includes(e)); const gated = gatedEnv.length > 0; @@ -159,8 +166,8 @@ export default function App() { }, [usableOps]); const launch = async () => { - if (!activeOp || !selectedAsset || gated) return; - await runJob(activeOp.id, selectedAsset.id, params); + if (!activeOp || gated || (needsInput && !selectedAsset)) return; + await runJob(activeOp.id, needsInput ? selectedAsset.id : null, params); refreshJobs(); }; @@ -219,12 +226,17 @@ export default function App() {