From 3f20868ad4532ca5c8374c1485eb4a079edf40ae Mon Sep 17 00:00:00 2001 From: sketchasketch Date: Mon, 27 Apr 2026 22:24:34 -0400 Subject: [PATCH] feat(mac): add Apple Silicon support and runtime hardening --- .gitignore | 2 + README.md | 27 +- api/routers/model.py | 308 +++++++++++++++++- api/routers/optimize.py | 46 ++- api/runner.py | 54 +-- api/services/extension_process.py | 124 +++++-- api/services/generator_registry.py | 3 +- api/tests/test_extension_process.py | 25 ++ api/tests/test_runner.py | 68 ++++ api/texture_baker/setup.py | 26 +- api/uv_unwrapper/setup.py | 15 +- arch/decisions/APPLE-SILICON-SUPPORT.md | 114 +++++++ arch/decisions/README.md | 10 + .../main/extension-install-utils.test.mjs | 62 ++++ electron/main/extension-install-utils.ts | 54 +++ electron/main/index.ts | 20 +- electron/main/ipc-handlers.ts | 258 +++++++++++++-- electron/main/model-downloader.ts | 38 ++- electron/main/python-bridge.ts | 53 ++- electron/preload/index.ts | 31 +- package.json | 17 +- src/App.tsx | 3 + src/areas/generate/GeneratePage.tsx | 19 +- .../generate/components/GenerationHUD.tsx | 48 ++- .../generate/components/GenerationOptions.tsx | 4 +- src/areas/generate/components/Viewer3D.tsx | 40 ++- .../generate/components/WorkflowPanel.tsx | 53 ++- src/areas/models/ModelsPage.tsx | 62 +++- src/areas/models/components/ExtensionCard.tsx | 114 ++++++- .../components/PerformanceSection.tsx | 5 +- src/areas/setup/FirstRunSetup.tsx | 76 +++-- src/areas/workflows/WorkflowsPage.tsx | 38 ++- src/areas/workflows/mockExtensions.ts | 16 +- src/areas/workflows/nodes/ExtensionNode.tsx | 28 +- .../nodes/mesh-remesher/manifest.json | 6 +- src/areas/workflows/preflight.ts | 112 +++++++ src/areas/workflows/useWorkflowRunner.ts | 25 +- src/areas/workflows/workflowRunStore.ts | 28 +- .../components/layout/MemoryIndicator.tsx | 66 ++++ src/shared/components/layout/TopBar.tsx | 74 +++-- src/shared/components/ui/Toast.tsx | 44 +++ src/shared/components/ui/index.ts | 1 + src/shared/stores/appStore.ts | 23 +- src/shared/stores/extensionsStore.ts | 71 ++-- src/shared/types/electron.d.ts | 29 +- 45 files changed, 1992 insertions(+), 348 deletions(-) create mode 100644 api/tests/test_extension_process.py create mode 100644 api/tests/test_runner.py create mode 100644 arch/decisions/APPLE-SILICON-SUPPORT.md create mode 100644 arch/decisions/README.md create mode 100644 electron/main/extension-install-utils.test.mjs create mode 100644 electron/main/extension-install-utils.ts create mode 100644 src/areas/workflows/preflight.ts create mode 100644 src/shared/components/layout/MemoryIndicator.tsx create mode 100644 src/shared/components/ui/Toast.tsx diff --git a/.gitignore b/.gitignore index d7178cd..ce6c416 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ Thumbs.db .env .env.local +# TypeScript incremental build output +*.tsbuildinfo diff --git a/README.md b/README.md index 50738c1..94cc8d8 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Local, open source, AI-powered image-to-3D mesh generation.** Turn any photo into a 3D model using open source AI models running entirely on your GPU. -Modly is a desktop application for Windows and Linux (macOS coming soon) +Modly is a desktop application for Windows, Linux, and Apple Silicon macOS. > Created by [Lightning Pixel](https://github.com/lightningpixel) @@ -19,7 +19,7 @@ Modly is a desktop application for Windows and Linux (macOS coming soon) ## Download -Head to the [Releases](../../releases/latest) page to download the latest installer for Windows or Linux. +Head to the [Releases](../../releases/latest) page to download the latest installer for Windows, Linux, or Apple Silicon macOS. Alternatively, you can clone the repository and run the app directly without installing: @@ -27,7 +27,7 @@ Alternatively, you can clone the repository and run the app directly without ins # Windows launcher.bat -# Linux +# Linux / macOS ./launcher.sh ``` @@ -59,11 +59,28 @@ pip install -r requirements.txt npm run dev ``` +### 4. Test + +```bash +npm test +./node_modules/.bin/tsc --noEmit -p tsconfig.node.json +npm run build +``` + +## Platform notes + +- macOS support targets Apple Silicon only. +- macOS uses native window controls. Windows and Linux keep the existing custom controls. +- The top bar includes a live RAM indicator sourced from the main process. +- Workflow wiring is validated before run; invalid graphs stay in place and surface inline/toast warnings instead of dropping the current mesh view. +- Package Apple Silicon macOS with `npm run package:mac`. +- Imported meshes can be smoothed and decimated in-app; optimized results are written back into the workspace. + --- ## Extension system -Modly supports external AI model extensions. Each extension is a GitHub repository containing a `manifest.json` and a `generator.py`. +Modly supports external model and process extensions. Each extension is a GitHub repository containing a `manifest.json` plus the runtime entry files required by its type. ### Official extensions @@ -85,7 +102,7 @@ Modly supports external AI model extensions. Each extension is a GitHub reposito ![Enter extension URL](docs/install-extension.png) -**3.** Once the extension is installed, download the model or one of its variants. +**3.** If the extension exposes model nodes, download the model or one of its variants. Process extensions are ready once installation and setup complete. ![Install models](docs/install-models.png) diff --git a/api/routers/model.py b/api/routers/model.py index 6d24871..547edc4 100644 --- a/api/routers/model.py +++ b/api/routers/model.py @@ -1,6 +1,14 @@ import asyncio import json +import time +import os +import socket +import shutil +import threading +from pathlib import Path from typing import Optional +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from services.generator_registry import generator_registry, MODELS_DIR @@ -8,6 +16,32 @@ router = APIRouter(tags=["model"]) +class DownloadPaused(Exception): + pass + + +class DownloadCancelled(Exception): + pass + + +_download_controls: dict[str, dict[str, threading.Event]] = {} + + +def _download_control(model_id: str) -> dict[str, threading.Event]: + control = _download_controls.get(model_id) + if control is None: + control = {"pause": threading.Event(), "cancel": threading.Event()} + _download_controls[model_id] = control + return control + + +def _check_download_control(control: dict[str, threading.Event]) -> None: + if control["cancel"].is_set(): + raise DownloadCancelled() + if control["pause"].is_set(): + raise DownloadPaused() + + @router.get("/status") async def model_status(): """Status of the active model.""" @@ -67,16 +101,37 @@ async def unload_model(model_id: str): return {"unloaded": True} # already not loaded, that's fine +@router.post("/hf-download/pause") +async def pause_hf_download(model_id: str): + control = _download_control(model_id) + control["pause"].set() + return {"paused": True} + + +@router.post("/hf-download/cancel") +async def cancel_hf_download(model_id: str): + control = _download_control(model_id) + control["cancel"].set() + return {"cancelled": True} + + @router.get("/hf-download") -async def hf_download(repo_id: str, model_id: str, skip_prefixes: Optional[str] = None, token: Optional[str] = None): +async def hf_download( + repo_id: str, + model_id: str, + skip_prefixes: Optional[str] = None, + include_prefixes: Optional[str] = None, + token: Optional[str] = None, +): """ Streams a HuggingFace Hub model download via SSE. Downloads into MODELS_DIR / model_id applying the filtering - declared in the extension manifest (hf_skip_prefixes). + declared in the extension manifest (hf_skip_prefixes / hf_include_prefixes). - skip_prefixes: JSON-encoded list of path prefixes to exclude (passed from Electron). - token: HuggingFace access token for gated repos (passed from Electron settings). - Falls back to registry manifest if not provided. + skip_prefixes: JSON-encoded list of path prefixes to exclude. + include_prefixes: JSON-encoded list of path prefixes to include (whitelist). + token: HuggingFace access token for gated repos (from Electron settings). + All three fall back to the extension's manifest / environment when not supplied. SSE format: data: {"percent": 0-100, "file": "...", "status": "..."} """ @@ -95,8 +150,22 @@ async def hf_download(repo_id: str, model_id: str, skip_prefixes: Optional[str] except KeyError: skip_list = [] - # Token: explicit param > env var + if include_prefixes: + try: + include_list = _json.loads(include_prefixes) + except Exception: + include_list = [] + else: + try: + include_list = generator_registry.get_manifest(model_id).get("hf_include_prefixes", []) + except KeyError: + include_list = [] + + # Token: explicit query param > env var > None hf_token = token or os.environ.get("HUGGING_FACE_HUB_TOKEN") or os.environ.get("HF_TOKEN") or None + control = _download_control(model_id) + control["pause"].clear() + control["cancel"].clear() async def stream(): loop = asyncio.get_running_loop() @@ -106,11 +175,13 @@ def _fmt(data: dict) -> str: try: yield _fmt({"percent": 0, "status": "Listing repository files..."}) + _check_download_control(control) def _list_files(): from huggingface_hub import list_repo_files return [ f for f in list_repo_files(repo_id, token=hf_token) + if (not include_list or any(f.startswith(p) for p in include_list)) if not any(f.startswith(p) for p in skip_list) ] @@ -123,29 +194,234 @@ def _list_files(): yield _fmt({"percent": 1, "status": f"Downloading {total} files..."}) - from huggingface_hub import hf_hub_download + from huggingface_hub import hf_hub_url for i, filename in enumerate(files): - def _dl(f=filename): - hf_hub_download( - repo_id=repo_id, - filename=f, - local_dir=dest_dir, - local_dir_use_symlinks=False, + _check_download_control(control) + yield _fmt({ + "percent": 1 + round(i / total * 94), + "file": filename, + "fileIndex": i + 1, + "totalFiles": total, + "status": f"Starting {filename}", + "bytesDownloaded": 0, + "stalledSeconds": 0, + }) + + base_pct = 1 + round(i / total * 94) + queue: asyncio.Queue[dict] = asyncio.Queue() + + def _progress(msg: dict) -> None: + loop.call_soon_threadsafe(queue.put_nowait, msg) + + url = hf_hub_url(repo_id=repo_id, filename=filename) + dl_future = loop.run_in_executor( + None, + lambda: _download_file_streamed( + url=url, + filename=filename, + dest_dir=dest_dir, + file_index=i + 1, + total_files=total, + base_percent=base_pct, + progress_cb=_progress, + control=control, token=hf_token, - ) + ), + ) + + while not dl_future.done(): + try: + msg = await asyncio.wait_for(queue.get(), timeout=2.0) + except asyncio.TimeoutError: + continue + else: + yield _fmt(msg) - await loop.run_in_executor(None, _dl) + final_size = await dl_future + _check_download_control(control) # Reserve 1-95 for file downloads, leave 95-100 for finalisation pct = 1 + round((i + 1) / total * 94) - yield _fmt({"percent": pct, "file": filename, "fileIndex": i + 1, "totalFiles": total}) + yield _fmt({ + "percent": pct, + "file": filename, + "fileIndex": i + 1, + "totalFiles": total, + "status": "Downloaded", + "bytesDownloaded": final_size, + "stalledSeconds": 0, + }) yield _fmt({"percent": 100, "status": "done"}) + except DownloadPaused: + yield _fmt({"paused": True, "status": "paused"}) + except DownloadCancelled: + shutil.rmtree(dest_dir, ignore_errors=True) + yield _fmt({"cancelled": True, "status": "cancelled"}) except Exception as exc: yield _fmt({"error": str(exc)}) + finally: + _download_controls.pop(model_id, None) return StreamingResponse(stream(), media_type="text/event-stream") +def _download_file_streamed( + *, + url: str, + filename: str, + dest_dir: str, + file_index: int, + total_files: int, + base_percent: int, + progress_cb, + control: dict[str, threading.Event], + token: Optional[str] = None, +) -> int: + final_path = Path(dest_dir) / filename + temp_path = final_path.with_suffix(final_path.suffix + ".part") + final_path.parent.mkdir(parents=True, exist_ok=True) + + if final_path.exists(): + return final_path.stat().st_size + + # Explicit token (from caller) > env vars > none + hf_token = ( + token + or os.environ.get("HF_TOKEN") + or os.environ.get("HUGGINGFACE_HUB_TOKEN") + or os.environ.get("HUGGING_FACE_HUB_TOKEN") + ) + headers = {"User-Agent": "modly/0.3.1"} + if hf_token: + headers["Authorization"] = f"Bearer {hf_token}" + + retries = 3 + backoff = 2.0 + last_error: Exception | None = None + + for attempt in range(1, retries + 1): + try: + _check_download_control(control) + existing_bytes = temp_path.stat().st_size if temp_path.exists() else 0 + request_headers = dict(headers) + request_url = url + if existing_bytes > 0: + request_url = _resolve_direct_download_url(url, headers) + request_headers["Range"] = f"bytes={existing_bytes}-" + + request = Request(request_url, headers=request_headers) + with urlopen(request, timeout=30) as response: + resumed = existing_bytes > 0 and getattr(response, "status", None) == 206 + if existing_bytes > 0 and not resumed: + temp_path.unlink(missing_ok=True) + existing_bytes = 0 + + total_bytes = _response_total_bytes(response.headers, existing_bytes if resumed else 0) + bytes_downloaded = existing_bytes + last_emit = 0.0 + chunk_size = 1024 * 1024 + mode = "ab" if resumed else "wb" + + progress_cb({ + "percent": base_percent, + "file": filename, + "fileIndex": file_index, + "totalFiles": total_files, + "status": _download_status(bytes_downloaded, total_bytes, attempt, retries, resumed=resumed), + "bytesDownloaded": bytes_downloaded, + "totalBytes": total_bytes, + "stalledSeconds": 0, + }) + + with open(temp_path, mode) as out: + while True: + _check_download_control(control) + try: + chunk = response.read(chunk_size) + except socket.timeout as exc: + raise TimeoutError(f"Timed out while downloading {filename}") from exc + + if not chunk: + break + + out.write(chunk) + bytes_downloaded += len(chunk) + + now = time.monotonic() + if now - last_emit >= 0.5: + progress_cb({ + "percent": base_percent, + "file": filename, + "fileIndex": file_index, + "totalFiles": total_files, + "status": _download_status(bytes_downloaded, total_bytes, attempt, retries, resumed=resumed), + "bytesDownloaded": bytes_downloaded, + "totalBytes": total_bytes, + "stalledSeconds": 0, + }) + last_emit = now + + temp_path.replace(final_path) + return bytes_downloaded + + except (HTTPError, URLError, TimeoutError, OSError) as exc: + last_error = exc + preserved_bytes = temp_path.stat().st_size if temp_path.exists() else 0 + progress_cb({ + "percent": base_percent, + "file": filename, + "fileIndex": file_index, + "totalFiles": total_files, + "status": f"Retrying after error ({attempt}/{retries})…", + "bytesDownloaded": preserved_bytes, + "stalledSeconds": 0, + }) + if attempt >= retries: + break + time.sleep(backoff) + backoff *= 2 + + raise RuntimeError(f"Failed to download {filename}: {last_error}") + + +def _resolve_direct_download_url(url: str, headers: dict[str, str]) -> str: + request = Request(url, headers=headers) + with urlopen(request, timeout=30) as response: + return response.geturl() + + +def _parse_content_length(raw: Optional[str]) -> Optional[int]: + if not raw: + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + +def _download_status(downloaded: int, total: Optional[int], attempt: int, retries: int, resumed: bool = False) -> str: + prefix = "Resuming…" if resumed and downloaded > 0 else "Downloading…" + if total and total > 0: + pct = min(100, round(downloaded / total * 100)) + return f"{prefix} {pct}%" + if retries > 1 and attempt > 1: + return f"{prefix} retry {attempt}/{retries}" + return prefix + + +def _response_total_bytes(headers, already_downloaded: int) -> Optional[int]: + content_range = headers.get("Content-Range") + if content_range and "/" in content_range: + total_raw = content_range.split("/")[-1].strip() + try: + return int(total_raw) + except (TypeError, ValueError): + pass + + content_length = _parse_content_length(headers.get("Content-Length")) + if content_length is None: + return None + return already_downloaded + content_length diff --git a/api/routers/optimize.py b/api/routers/optimize.py index f68e261..49f4b39 100644 --- a/api/routers/optimize.py +++ b/api/routers/optimize.py @@ -39,17 +39,28 @@ def _require_pymeshlab(): raise HTTPException(503, "pymeshlab is unavailable on this system (DLL blocked by Windows Application Control policy)") +def _resolve_input_path(raw_path: str) -> Path: + candidate = Path(raw_path) + if candidate.is_absolute(): + resolved = candidate.resolve() + if not resolved.exists(): + raise HTTPException(404, f"File not found: {raw_path}") + return resolved + + resolved = (WORKSPACE_DIR / raw_path).resolve() + if not str(resolved).startswith(str(WORKSPACE_DIR.resolve())): + raise HTTPException(400, "Invalid path") + if not resolved.exists(): + raise HTTPException(404, f"File not found: {raw_path}") + return resolved + + @router.post("/mesh") def optimize_mesh(body: OptimizeRequest): _require_pymeshlab() target_faces = max(100, min(500_000, body.target_faces)) - # Security: prevent path traversal - input_path = (WORKSPACE_DIR / body.path).resolve() - if not str(input_path).startswith(str(WORKSPACE_DIR.resolve())): - raise HTTPException(400, "Invalid path") - if not input_path.exists(): - raise HTTPException(404, f"File not found: {body.path}") + input_path = _resolve_input_path(body.path) tmp_dir = tempfile.mkdtemp() try: @@ -59,13 +70,14 @@ def optimize_mesh(body: OptimizeRequest): stem = input_path.stem output_name = f"{stem}_opt{target_faces}.glb" - output_path = input_path.parent / output_name + output_dir = input_path.parent if str(input_path).startswith(str(WORKSPACE_DIR.resolve())) else WORKSPACE_DIR / "Workflows" + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / output_name result.export(str(output_path)) - # Reconstruct the collection name from the path - collection_name = body.path.split("/")[0] face_count = len(result.faces) - return {"url": f"/workspace/{collection_name}/{output_name}", "face_count": face_count} + rel = output_path.relative_to(WORKSPACE_DIR).as_posix() + return {"url": f"/workspace/{rel}", "face_count": face_count} def _has_texture(geom: trimesh.Trimesh) -> bool: @@ -162,11 +174,7 @@ def smooth_mesh(body: SmoothRequest): _require_pymeshlab() iterations = max(1, min(20, body.iterations)) - input_path = (WORKSPACE_DIR / body.path).resolve() - if not str(input_path).startswith(str(WORKSPACE_DIR.resolve())): - raise HTTPException(400, "Invalid path") - if not input_path.exists(): - raise HTTPException(404, f"File not found: {body.path}") + input_path = _resolve_input_path(body.path) tmp_dir = tempfile.mkdtemp() try: @@ -176,11 +184,13 @@ def smooth_mesh(body: SmoothRequest): stem = input_path.stem output_name = f"{stem}_smooth{iterations}.glb" - output_path = input_path.parent / output_name + output_dir = input_path.parent if str(input_path).startswith(str(WORKSPACE_DIR.resolve())) else WORKSPACE_DIR / "Workflows" + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / output_name result.export(str(output_path)) - collection_name = body.path.split("/")[0] - return {"url": f"/workspace/{collection_name}/{output_name}"} + rel = output_path.relative_to(WORKSPACE_DIR).as_posix() + return {"url": f"/workspace/{rel}"} def _smooth(input_path: str, iterations: int, tmp_dir: str) -> trimesh.Trimesh: diff --git a/api/runner.py b/api/runner.py index e7d46d3..12f2bf2 100644 --- a/api/runner.py +++ b/api/runner.py @@ -81,6 +81,30 @@ def load_generator(manifest: dict): return getattr(mod, manifest["generator_class"]) +def _select_node(manifest: dict, model_dir_override: str) -> dict: + nodes = manifest.get("nodes") or [] + if nodes and model_dir_override: + node_id = Path(model_dir_override).name + return next((n for n in nodes if n.get("id") == node_id), nodes[0]) + if nodes: + return nodes[0] + return {} + + +def _resolve_ready_schema(GenClass, node: dict, manifest: dict) -> list: + try: + return GenClass.params_schema() + except Exception: + return node.get("params_schema") or manifest.get("params_schema", []) + + +def _apply_manifest_metadata(gen, manifest: dict, node: dict) -> None: + gen.hf_repo = node.get("hf_repo") or manifest.get("hf_repo", "") + gen.hf_skip_prefixes = node.get("hf_skip_prefixes") or manifest.get("hf_skip_prefixes", []) + gen.download_check = node.get("download_check") or manifest.get("download_check", "") + gen._params_schema = node.get("params_schema") or manifest.get("params_schema", []) + + # ------------------------------------------------------------------ # # Main loop # ------------------------------------------------------------------ # @@ -97,38 +121,24 @@ def main() -> None: "traceback": traceback.format_exc()}) return - # Announce readiness and send params_schema so ExtensionProcess - # can serve it without needing to query the subprocess later. - # We try to get it from the generator class (may be a classmethod), - # falling back to the manifest field. - try: - schema = GenClass.params_schema() - except Exception: - node0 = (manifest.get("nodes") or [{}])[0] - schema = manifest.get("params_schema", []) or node0.get("params_schema", []) - send({"type": "ready", "params_schema": schema}) - # Support both flat manifest (legacy) and nodes[] format. # Use MODEL_DIR to find the correct node for multi-node extensions: # MODEL_DIR is set by ExtensionProcess to MODELS_DIR/ext_id/node_id, # so its last component matches the node id. - nodes = manifest.get("nodes") or [] - node = {} - if nodes and _MODEL_DIR_OVERRIDE: - node_id = Path(_MODEL_DIR_OVERRIDE).name - node = next((n for n in nodes if n.get("id") == node_id), nodes[0]) - elif nodes: - node = nodes[0] + node = _select_node(manifest, _MODEL_DIR_OVERRIDE) + + # Announce readiness and send params_schema so ExtensionProcess + # can serve it without needing to query the subprocess later. + # We try to get it from the generator class (may be a classmethod), + # falling back to the selected node, then to the top-level manifest. + send({"type": "ready", "params_schema": _resolve_ready_schema(GenClass, node, manifest)}) # Use MODEL_DIR env var (set by ExtensionProcess) when available so the # generator uses the exact same path that is_downloaded() checks against. # Falls back to MODELS_DIR/manifest_id for legacy / standalone use. model_dir = Path(_MODEL_DIR_OVERRIDE) if _MODEL_DIR_OVERRIDE else MODELS_DIR / model_id gen = GenClass(model_dir, WORKSPACE_DIR) - gen.hf_repo = manifest.get("hf_repo", "") or node.get("hf_repo", "") - gen.hf_skip_prefixes = manifest.get("hf_skip_prefixes", []) or node.get("hf_skip_prefixes", []) - gen.download_check = manifest.get("download_check", "") or node.get("download_check", "") - gen._params_schema = manifest.get("params_schema", []) or node.get("params_schema", []) + _apply_manifest_metadata(gen, manifest, node) # Active cancel events keyed by request id _cancel: dict[str, threading.Event] = {} diff --git a/api/services/extension_process.py b/api/services/extension_process.py index fc6b3e4..d063fc7 100644 --- a/api/services/extension_process.py +++ b/api/services/extension_process.py @@ -73,8 +73,11 @@ def _build_env(self) -> dict: env["MODELS_DIR"] = str(MODELS_DIR) env["WORKSPACE_DIR"] = str(WORKSPACE_DIR) env["MODLY_API_DIR"] = str(Path(__file__).parent.parent) + if sys.platform == "darwin": + env.setdefault("NUMBA_DISABLE_JIT", "1") # Pass the exact model_dir so runner.py doesn't have to re-derive it # from manifest["id"] (which is the ext_id, not the composite node id). + # runner.py extracts the node id from MODEL_DIR's trailing path component. if self.model_dir is not None: env["MODEL_DIR"] = str(self.model_dir) # Extension venvs are based on python-embed which ships without a CA bundle. @@ -97,7 +100,10 @@ def _start(self) -> None: ) for attempt in range(3): - self._queue = queue.Queue() + # Use a fresh queue per subprocess lifetime so late messages from an + # older reader thread cannot poison startup for the new process. + run_queue: queue.Queue = queue.Queue() + self._queue = run_queue self._proc = subprocess.Popen( [str(python), str(_RUNNER_PATH)], stdin=subprocess.PIPE, @@ -109,11 +115,11 @@ def _start(self) -> None: ) # Background thread: read stdout → queue - reader = threading.Thread(target=self._read_loop, daemon=True) + reader = threading.Thread(target=self._read_loop, args=(self._proc, run_queue), daemon=True) reader.start() # Background thread: forward stderr to our stderr - stderr_fwd = threading.Thread(target=self._stderr_loop, daemon=True) + stderr_fwd = threading.Thread(target=self._stderr_loop, args=(self._proc,), daemon=True) stderr_fwd.start() # Wait for ready — runner sends params_schema in this message @@ -175,23 +181,46 @@ def _install_missing_package(self, python: Path, module_name: str, package_name: f"for missing module '{module_name}'.\n{details[-2000:]}" ) from exc - def _read_loop(self) -> None: + def _read_loop(self, proc: subprocess.Popen, msg_queue: queue.Queue) -> None: """Continuously reads stdout and pushes parsed JSON to the queue.""" try: - for line in self._proc.stdout: + for line in proc.stdout: line = line.strip() if line: try: - self._queue.put(json.loads(line)) + msg_queue.put(json.loads(line)) except json.JSONDecodeError: print(f"[{self.MODEL_ID}] bad JSON: {line}", file=sys.stderr) finally: - self._queue.put(None) # sentinel: process is done - - def _stderr_loop(self) -> None: - """Forwards subprocess stderr to the main process stderr.""" - for line in self._proc.stderr: - print(f"[{self.MODEL_ID}] {line}", end="", file=sys.stderr) + msg_queue.put(None) # sentinel: process is done + + def _stderr_loop(self, proc: subprocess.Popen) -> None: + """Forward subprocess stderr to the main process stderr, emitting + one line every time we see EITHER '\\n' or '\\r'. tqdm writes live + progress updates with '\\r' only, so a newline-only iterator would + buffer every tick until the loop exits with '\\n' — which is why + the HUD's log pane went dark during multi-minute volume decode. + + No per-line extension-id prefix: the HUD log pane is a single + truncated line, and eating 20 characters with "[modly-hy3d2-mac] " + hides the tail of the tqdm bar the user actually wants to read. + """ + stream = proc.stderr + if stream is None: + return + buf = [] + while True: + ch = stream.read(1) + if not ch: + if buf: + print(''.join(buf), file=sys.stderr, flush=True) + return + if ch in ("\r", "\n"): + if buf: + print(''.join(buf), file=sys.stderr, flush=True) + buf = [] + else: + buf.append(ch) def _send(self, msg: dict) -> None: with self._lock: @@ -262,14 +291,45 @@ def generate( "outputs_dir": str(self.outputs_dir) if self.outputs_dir else None, }) + # Grace period after sending a cooperative cancel before hard-killing + # the subprocess. Long enough to let generators that check cancel_event + # between steps shut down cleanly, short enough that the user isn't + # left staring at a stuck UI when the subprocess is blocked inside a + # native call (octree decode, marching cubes, etc.) that ignores stdin. + CANCEL_GRACE_SECONDS = 3.0 + + cancel_sent_at: Optional[float] = None while True: # Check for cancellation if cancel_event and cancel_event.is_set(): - self._send({"action": "cancel", "id": req_id}) - # Drain until the subprocess acknowledges - while True: - msg = self._recv(timeout=30.0) - if msg.get("type") in ("cancelled", "done", "error"): + if cancel_sent_at is None: + # First observation of the cancel — ask the subprocess to stop. + try: + self._send({"action": "cancel", "id": req_id}) + except Exception: + pass + import time + cancel_sent_at = time.monotonic() + else: + import time + if time.monotonic() - cancel_sent_at >= CANCEL_GRACE_SECONDS: + # Grace period expired — the subprocess is not + # responding (almost certainly stuck in native code). + # Hard-kill it and drop our state so the next + # generation forces a fresh load. + try: + if self._proc and self._proc.poll() is None: + self._proc.kill() + self._proc.wait(timeout=5.0) + except Exception: + pass + self._loaded = False + self._proc = None + print( + f"[ExtensionProcess] {self.MODEL_ID} subprocess killed " + f"after {CANCEL_GRACE_SECONDS}s grace; model will reload on next run", + file=sys.stderr, + ) raise GenerationCancelled() # Poll queue with short timeout so we can re-check cancel_event @@ -303,12 +363,28 @@ def params_schema(self) -> list: return self._params_schema def stop(self) -> None: - """Gracefully shut down the subprocess.""" - if self._proc and self._proc.poll() is None: + """Hard-stop the subprocess. + + Used by Free Memory / unload_all. Cooperative shutdown was the wrong + semantics here: torch.mps.empty_cache() does not reliably release + wired Metal pages, so only process exit actually returns the memory + to the OS. We SIGKILL, reap the zombie, and drop our refs so the + next load() starts a fresh subprocess. + """ + proc = self._proc + self._proc = None + self._loaded = False + if proc and proc.poll() is None: try: - self._send({"action": "shutdown"}) - self._proc.wait(timeout=15) + proc.kill() + proc.wait(timeout=5) except Exception: - self._proc.kill() - self._loaded = False - self._proc = None + pass + self._drain_queue() + + def _drain_queue(self) -> None: + while not self._queue.empty(): + try: + self._queue.get_nowait() + except queue.Empty: + break diff --git a/api/services/generator_registry.py b/api/services/generator_registry.py index de1f20c..b36c6c9 100644 --- a/api/services/generator_registry.py +++ b/api/services/generator_registry.py @@ -106,7 +106,8 @@ def _discover_extensions() -> Dict[str, Tuple[type, dict]]: "hf_repo": node.get("hf_repo", ""), "download_check": node.get("download_check", ""), "hf_skip_prefixes": node.get("hf_skip_prefixes", []), - "params_schema": node.get("params_schema", []), + "hf_include_prefixes": node.get("hf_include_prefixes", []), + "params_schema": node.get("params_schema", manifest.get("params_schema", [])), "input": node.get("input", "image"), "output": node.get("output", "mesh"), } diff --git a/api/tests/test_extension_process.py b/api/tests/test_extension_process.py new file mode 100644 index 0000000..cbe7562 --- /dev/null +++ b/api/tests/test_extension_process.py @@ -0,0 +1,25 @@ +import io +import queue +import unittest + +from services.extension_process import ExtensionProcess + + +class ExtensionProcessTests(unittest.TestCase): + def test_read_loop_writes_sentinel_to_own_queue_only(self) -> None: + proc = ExtensionProcess(ext_dir=None, manifest={"id": "demo"}) # type: ignore[arg-type] + + old_queue: queue.Queue = queue.Queue() + new_queue: queue.Queue = queue.Queue() + proc._queue = new_queue + + fake_proc = type("FakeProc", (), {"stdout": io.StringIO("")})() + + proc._read_loop(fake_proc, old_queue) + + self.assertFalse(old_queue.empty()) + self.assertTrue(new_queue.empty()) + + +if __name__ == "__main__": + unittest.main() diff --git a/api/tests/test_runner.py b/api/tests/test_runner.py new file mode 100644 index 0000000..ada9dfc --- /dev/null +++ b/api/tests/test_runner.py @@ -0,0 +1,68 @@ +import unittest +import os +import tempfile +import importlib +from pathlib import Path + + +_tmp_ext_dir = tempfile.mkdtemp(prefix="modly-runner-test-") +Path(_tmp_ext_dir, "manifest.json").write_text("{}", encoding="utf-8") +os.environ.setdefault("EXTENSION_DIR", _tmp_ext_dir) + +runner = importlib.import_module("runner") +_apply_manifest_metadata = runner._apply_manifest_metadata +_resolve_ready_schema = runner._resolve_ready_schema +_select_node = runner._select_node + + +class RunnerTests(unittest.TestCase): + def test_select_node_uses_model_dir_override(self) -> None: + manifest = { + "nodes": [ + {"id": "fast", "params_schema": [{"id": "a"}]}, + {"id": "quality", "params_schema": [{"id": "b"}]}, + ] + } + + node = _select_node(manifest, str(Path("/tmp/ext/quality"))) + + self.assertEqual(node["id"], "quality") + + def test_ready_schema_falls_back_to_selected_node_schema(self) -> None: + class GenClass: + @classmethod + def params_schema(cls): + raise RuntimeError("not available") + + manifest = {"params_schema": [{"id": "manifest"}]} + node = {"params_schema": [{"id": "node"}]} + + schema = _resolve_ready_schema(GenClass, node, manifest) + + self.assertEqual(schema, [{"id": "node"}]) + + def test_apply_manifest_metadata_prefers_node_specific_values(self) -> None: + gen = type("Gen", (), {})() + manifest = { + "hf_repo": "top/repo", + "hf_skip_prefixes": ["top/"], + "download_check": "top/file", + "params_schema": [{"id": "top"}], + } + node = { + "hf_repo": "node/repo", + "hf_skip_prefixes": ["node/"], + "download_check": "node/file", + "params_schema": [{"id": "node"}], + } + + _apply_manifest_metadata(gen, manifest, node) + + self.assertEqual(gen.hf_repo, "node/repo") + self.assertEqual(gen.hf_skip_prefixes, ["node/"]) + self.assertEqual(gen.download_check, "node/file") + self.assertEqual(gen._params_schema, [{"id": "node"}]) + + +if __name__ == "__main__": + unittest.main() diff --git a/api/texture_baker/setup.py b/api/texture_baker/setup.py index 1b45cf2..e5158cb 100644 --- a/api/texture_baker/setup.py +++ b/api/texture_baker/setup.py @@ -14,6 +14,7 @@ library_name = "texture_baker" IS_WINDOWS = platform.system() == "Windows" +IS_MACOS = platform.system() == "Darwin" def get_extensions(): @@ -39,6 +40,18 @@ def get_extensions(): if debug_mode: cxx_flags += ["/Z7"] extra_link_args += ["/DEBUG"] + elif IS_MACOS: + # Prefer a conservative Apple Silicon toolchain over OpenMP-specific flags. + cxx_flags = [ + "-O3" if not debug_mode else "-O0", + "-fdiagnostics-color=always", + "-mmacosx-version-min=11.0", + ] + if use_native_arch: + cxx_flags.append("-mcpu=apple-m1") + if debug_mode: + cxx_flags += ["-g", "-UNDEBUG"] + extra_link_args += ["-O0", "-g"] else: # GCC/Clang flags cxx_flags = [ @@ -91,8 +104,17 @@ def get_extensions(): sources += glob.glob( os.path.join(this_dir, library_name, "csrc", "**", "*.mm"), recursive=True ) - extra_compile_args.update({"cxx": ["-O3", "-arch", "arm64", "-mmacosx-version-min=10.15"]}) - extra_link_args += ["-arch", "arm64"] + if IS_MACOS: + if "-arch" not in extra_link_args: + extra_link_args += [ + "-arch", + "arm64", + "-framework", + "Metal", + "-framework", + "Foundation", + ] + cxx_flags.extend(["-arch", "arm64"]) extensions.append( extension( diff --git a/api/uv_unwrapper/setup.py b/api/uv_unwrapper/setup.py index bff9eec..8284ed9 100644 --- a/api/uv_unwrapper/setup.py +++ b/api/uv_unwrapper/setup.py @@ -12,6 +12,7 @@ library_name = "uv_unwrapper" IS_WINDOWS = platform.system() == "Windows" +IS_MACOS = platform.system() == "Darwin" def get_extensions(): @@ -19,8 +20,7 @@ def get_extensions(): if debug_mode: print("Compiling in debug mode") - is_mac = True if torch.backends.mps.is_available() else False - use_native_arch = not is_mac and not IS_WINDOWS and os.getenv("USE_NATIVE_ARCH", "1") == "1" + use_native_arch = not IS_MACOS and not IS_WINDOWS and os.getenv("USE_NATIVE_ARCH", "1") == "1" extension = CppExtension extra_link_args = [] @@ -31,12 +31,13 @@ def get_extensions(): if debug_mode: cxx_flags += ["/Z7", "/UNDEBUG"] extra_link_args += ["-O0"] - elif is_mac: + elif IS_MACOS: cxx_flags = [ "-O3" if not debug_mode else "-O0", "-fdiagnostics-color=always", - "-Xclang -fopenmp", - "-mmacosx-version-min=10.15", + "-mmacosx-version-min=11.0", + "-arch", + "arm64", ] if debug_mode: cxx_flags += ["-g", "-UNDEBUG"] @@ -74,9 +75,7 @@ def get_extensions(): define_macros=define_macros, extra_compile_args=extra_compile_args, extra_link_args=extra_link_args, - libraries=["c10", "torch", "torch_cpu", "torch_python"] + ["omp"] - if is_mac - else [], + libraries=["c10", "torch", "torch_cpu", "torch_python"] if IS_MACOS else [], ) ) diff --git a/arch/decisions/APPLE-SILICON-SUPPORT.md b/arch/decisions/APPLE-SILICON-SUPPORT.md new file mode 100644 index 0000000..c49c153 --- /dev/null +++ b/arch/decisions/APPLE-SILICON-SUPPORT.md @@ -0,0 +1,114 @@ +# APPLE-SILICON-SUPPORT + +- Status: proposed +- Date: 2026-04-23 + +## Decision + +Modly supports macOS on Apple Silicon (`darwin/arm64`) as a first-class +platform. This ADR consolidates the runtime, packaging, extension, and +workflow rules needed to run the image-to-mesh pipeline reliably on 16 GB +unified-memory Macs. + +Scope and operating rules: + +- macOS support targets Apple Silicon only. See `package.json:99`. +- Intel macOS, universal binaries, and Rosetta fallback are out of scope. +- Model weights stay separate from extension code and are installed per node. See `api/routers/model.py:76` and `api/runner.py:101`. +- The Mac workflow is sequential and memory-budgeted: one heavy generative + stage resident at a time. See `src/areas/workflows/workflowRunStore.ts:185`. +- Download/install state must be observable and resumable. See `electron/main/model-downloader.ts:116`. +- Releasing GPU memory means terminating the owning subprocess. See `api/services/extension_process.py:205` and `electron/main/index.ts:105`. +- Generation progress and cancel behavior must remain visible and responsive in + the UI. See `api/services/extension_process.py:135` and `src/areas/workflows/workflowRunStore.ts:232`. + +## Context + +Apple Silicon changes the constraints under which Modly runs: + +- Unified memory means overlapping heavy GPU stages can destabilize the whole + machine on 16 GB systems. +- Metal/MPS memory is not returned predictably by Python-side cleanup alone; + process exit is the reliable release boundary. +- Large model downloads need byte-level visibility, stall detection, and proper + resume behavior to avoid appearing hung or silently reinstalling from zero. +- Extension manifests now need per-node distribution metadata because one + extension can expose multiple model variants that share code but differ in + weights, defaults, and required artifacts. +- Workflow graphs need preflight validation before execution so invalid wiring + is reported without replacing the current mesh view with a terminal error + state. +- The renderer needs progress text that stays live through long native phases + and a cancel path that clears UI state immediately even if backend teardown + takes longer. + +## Consequences + +- Packaging: + Modly packages macOS as an Apple Silicon build path only, including the + embedded Python runtime in the app bundle. See `package.json:99`. + +- Extension and model distribution: + Extension payloads contain code, manifests, setup scripts, and lightweight + assets. Model nodes declare their own `download_check`, can narrow downloads + with `hf_include_prefixes` and `hf_skip_prefixes`, and may provide + node-specific `params_schema` and `param_defaults` with top-level fallback. + See `api/routers/model.py:76`, `api/runner.py:84`, and + `electron/main/model-downloader.ts:116`. + +- Runtime selection and defaults: + The runner resolves the active node from `MODEL_DIR` so multi-node extensions + use the correct schema, model directory, and node-specific metadata. Workflow + submission merges displayed parameter defaults under user overrides before the + request reaches Python. See `api/runner.py:84` and + `src/areas/workflows/workflowRunStore.ts:207`. + +- Mesh optimization path handling: + Smooth and decimate operations accept both workspace-relative meshes and + imported absolute-path meshes, then write optimized output back into the + workspace so the result remains visible and reusable in the app. See + `api/routers/optimize.py:42` and `src/areas/generate/GeneratePage.tsx:331`. + +- Memory-budgeted workflow: + Heavy stages hand off through files and unload before the next heavy stage + begins. CPU-oriented stages can run between GPU-heavy stages without + competing for MPS residency. See `electron/main/index.ts:105`, + `api/services/extension_process.py:214`, and + `src/areas/workflows/workflowRunStore.ts:142`. + +- Download behavior: + The downloader emits byte-level progress, file context, and stall state. + Partial downloads are preserved as `.part` files. Resume is attempted against + the resolved final URL so `Range` works even when upstream redirects to a CDN. + Install completion is verified by the declared `download_check`, not by + directory existence alone. See `electron/main/model-downloader.ts:10`, + `electron/main/model-downloader.ts:31`, and `api/routers/model.py:84`. + +- Subprocess lifecycle: + Extension subprocesses are owned as a full process tree. On Unix, the Python + bridge runs as its own process-group leader and Modly kills the process group + on quit. Free-memory/unload operations hard-stop the subprocess. Cancel first + sends a cooperative request, then escalates to a kill after a short grace + period if native code is still blocking. See `api/services/extension_process.py:78` + and `electron/main/index.ts:112`. + +- Observability and UX: + Generator stderr stays available for tqdm-style progress parsing, long phases + surface readable status text, and cancel clears renderer job state + immediately. Workflow editors run a preflight pass before execution and + surface wiring problems through inline warnings and toasts instead of + replacing the current mesh view. Error output in the HUD remains + copyable/selectable. The top bar includes a live RAM indicator backed by a + main-process `system:memory` IPC call; macOS uses `vm_stat` to approximate + Activity Monitor's "Memory Used" semantics and other platforms fall back to + `total - free`. macOS uses native window controls instead of custom + right-side controls. See + `api/services/extension_process.py:135`, + `src/areas/workflows/preflight.ts:51`, + `src/areas/generate/components/WorkflowPanel.tsx:425`, + `src/areas/workflows/WorkflowsPage.tsx:847`, + `src/shared/components/ui/Toast.tsx:4`, + `electron/main/ipc-handlers.ts:372`, + `src/shared/components/layout/MemoryIndicator.tsx:9`, + `src/shared/components/layout/TopBar.tsx:4`, and + `src/areas/setup/FirstRunSetup.tsx:258`. diff --git a/arch/decisions/README.md b/arch/decisions/README.md new file mode 100644 index 0000000..063df57 --- /dev/null +++ b/arch/decisions/README.md @@ -0,0 +1,10 @@ +# Architecture Decisions + +This directory stores architecture decision records for Modly. + +The current Apple Silicon support work is documented in one consolidated ADR so +the platform scope, runtime assumptions, and operational constraints stay in a +single reviewable document. + +Current ADRs: +- [APPLE-SILICON-SUPPORT](./APPLE-SILICON-SUPPORT.md) diff --git a/electron/main/extension-install-utils.test.mjs b/electron/main/extension-install-utils.test.mjs new file mode 100644 index 0000000..5a7e10d --- /dev/null +++ b/electron/main/extension-install-utils.test.mjs @@ -0,0 +1,62 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildSync } from 'esbuild' +import { createRequire } from 'node:module' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +function loadModule() { + const outfile = join(mkdtempSync(join(tmpdir(), 'modly-ext-test-')), 'extension-install-utils.cjs') + const require = createRequire(import.meta.url) + const result = buildSync({ + entryPoints: [resolve('electron/main/extension-install-utils.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + }) + writeFileSync(outfile, result.outputFiles[0].text, 'utf8') + return require(outfile) +} + +test('validateInstallManifest accepts legacy flat model manifests', () => { + const mod = loadModule() + + const validated = mod.validateInstallManifest( + { id: 'legacy-model', generator_class: 'Generator' }, + { + hasEntryFile: () => false, + hasGeneratorFile: () => true, + }, + 'repository', + ) + + assert.equal(validated.id, 'legacy-model') + assert.equal(validated.isProcess, false) + assert.equal(validated.hasNodes, false) +}) + +test('validateInstallManifest still rejects missing process entry files', () => { + const mod = loadModule() + + assert.throws( + () => mod.validateInstallManifest( + { id: 'proc', type: 'process', entry: 'processor.py' }, + { + hasEntryFile: () => false, + hasGeneratorFile: () => false, + }, + 'selected folder', + ), + /entry file "processor\.py" missing from selected folder/, + ) +}) + +test('python process setup failures are treated as fatal', () => { + const mod = loadModule() + + assert.equal(mod.isSetupFailureFatal({ isProcess: true, isPythonProcess: true }), true) + assert.equal(mod.isSetupFailureFatal({ isProcess: true, isPythonProcess: false }), false) + assert.equal(mod.isSetupFailureFatal({ isProcess: false, isPythonProcess: false }), true) +}) diff --git a/electron/main/extension-install-utils.ts b/electron/main/extension-install-utils.ts new file mode 100644 index 0000000..73e5cfa --- /dev/null +++ b/electron/main/extension-install-utils.ts @@ -0,0 +1,54 @@ +export interface InstallManifest { + id?: string + type?: 'model' | 'process' + entry?: string + generator_class?: string + nodes?: Array<{ id?: string }> +} + +export interface ValidatedInstallManifest { + id: string + isProcess: boolean + isPythonProcess: boolean + entryFile: string + hasNodes: boolean +} + +export function validateInstallManifest( + manifest: InstallManifest, + opts: { + hasEntryFile: (entryFile: string) => boolean + hasGeneratorFile: () => boolean + }, + sourceLabel: string, +): ValidatedInstallManifest { + if (!manifest.id) throw new Error('manifest.json: required field "id" missing') + + const isProcess = manifest.type === 'process' + const entryFile = manifest.entry ?? 'processor.js' + const nodes = Array.isArray(manifest.nodes) ? manifest.nodes.filter((node) => node?.id) : [] + + if (isProcess) { + if (!opts.hasEntryFile(entryFile)) { + throw new Error(`manifest.json: entry file "${entryFile}" missing from ${sourceLabel}`) + } + } else { + if (!opts.hasGeneratorFile()) throw new Error(`generator.py missing from ${sourceLabel}`) + if (!manifest.generator_class) throw new Error('manifest.json: required field "generator_class" missing') + } + + return { + id: manifest.id, + isProcess, + isPythonProcess: isProcess && entryFile.endsWith('.py'), + entryFile, + hasNodes: nodes.length > 0, + } +} + +export function isSetupFailureFatal(kind: { + isProcess: boolean + isPythonProcess: boolean +}): boolean { + return !kind.isProcess || kind.isPythonProcess +} diff --git a/electron/main/index.ts b/electron/main/index.ts index 19b75df..6ef0b46 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -33,6 +33,21 @@ function createWindow(): void { mainWindow?.show() }) + mainWindow.webContents.on('before-input-event', (event, input) => { + const isMacQuitShortcut = + process.platform === 'darwin' && + input.type === 'keyDown' && + input.key.toLowerCase() === 'q' && + input.meta && + !input.control && + !input.alt + + if (isMacQuitShortcut) { + event.preventDefault() + app.quit() + } + }) + mainWindow.webContents.setWindowOpenHandler((details) => { shell.openExternal(details.url) return { action: 'deny' } @@ -88,7 +103,10 @@ app.whenReady().then(async () => { }) app.on('window-all-closed', () => { - if (process.platform !== 'darwin') app.quit() + // Modly holds a multi-GB Python subprocess; leaving it running in the + // Dock after the window closes (the Mac default) is the wrong behavior + // for this app. Closing the window means quit. + app.quit() }) app.on('before-quit', (event) => { diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index ac868a0..3c5e4b9 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -6,6 +6,8 @@ import { rm as rmAsync, readFile, writeFile, mkdir, readdir, rename, cp } from ' import { existsSync, readdirSync, statSync } from 'fs' import axios from 'axios' import * as tar from 'tar' +import * as os from 'os' +import { promisify } from 'util' import { PythonBridge, API_BASE_URL } from './python-bridge' import { isModelDownloaded, @@ -17,16 +19,26 @@ import { checkSetupNeeded, markSetupDone, runFullSetup, getVenvPythonExe, ensure import { logger } from './logger' import { getProcessRunner, getPythonProcessRunner, getExtPythonExe, terminateProcessRunner, terminateAllProcessRunners } from './process-runner' import { getBuiltinExtensionsDir } from './builtin-sync' -import { spawn } from 'child_process' +import { spawn, execFile } from 'child_process' import { assertSafeExtensionId, buildExtensionBackupPath, resolveExtensionPathWithinRoot } from './extension-path-guard' +import { isSetupFailureFatal, validateInstallManifest } from './extension-install-utils' type WindowGetter = () => BrowserWindow | null +const pExecFile = promisify(execFile) // ─── GPU detect (best-effort, no Python required) ───────────────────────────── -interface GpuInfo { sm: number; cudaVersion: number } +interface GpuInfo { + sm: number + cudaVersion: number + accelerator: 'cuda' | 'mps' | 'cpu' +} function detectGpuInfo(): Promise { + if (process.platform === 'darwin' && process.arch === 'arm64') { + return Promise.resolve({ sm: 0, cudaVersion: 0, accelerator: 'mps' }) + } + return new Promise((resolve) => { // Query compute cap + driver version in one call const proc = spawn('nvidia-smi', ['--query-gpu=compute_cap,driver_version', '--format=csv,noheader'], { @@ -53,12 +65,12 @@ function detectGpuInfo(): Promise { else if (driverMajor >= 530) cudaVersion = 121 else if (driverMajor >= 525) cudaVersion = 120 else if (driverMajor >= 520) cudaVersion = 118 - resolve({ sm: isNaN(sm) ? 86 : sm, cudaVersion }) + resolve({ sm: isNaN(sm) ? 86 : sm, cudaVersion, accelerator: 'cuda' }) } else { - resolve({ sm: 86, cudaVersion: 118 }) + resolve({ sm: 0, cudaVersion: 0, accelerator: 'cpu' }) } }) - proc.on('error', () => resolve({ sm: 86, cudaVersion: 118 })) + proc.on('error', () => resolve({ sm: 0, cudaVersion: 0, accelerator: 'cpu' })) }) } @@ -76,8 +88,92 @@ function runExtensionSetup( const pythonExe = getVenvPythonExe(userData) const setupPy = join(extDir, 'setup.py') - const args = JSON.stringify({ python_exe: pythonExe, ext_dir: extDir, gpu_sm: gpuSm, cuda_version: cudaVersion }) - const proc = spawn(pythonExe, [setupPy, args], { + const accelerator = process.platform === 'darwin' && process.arch === 'arm64' ? 'mps' : gpuSm > 0 ? 'cuda' : 'cpu' + const args = JSON.stringify({ + python_exe: pythonExe, + ext_dir: extDir, + gpu_sm: gpuSm, + cuda_version: cudaVersion, + accelerator, + platform: process.platform, + arch: process.arch, + }) + const launcher = ` +import runpy +import subprocess +import sys + +setup_py = sys.argv[1] +setup_args = sys.argv[2:] + +_original_run = subprocess.run +_original_check_call = subprocess.check_call +_original_check_output = subprocess.check_output + +def _is_cuda_torch_index(value): + return isinstance(value, str) and value.startswith("https://download.pytorch.org/whl/cu") + +def _mentions_torch(command): + if not isinstance(command, (list, tuple)): + return False + return any(str(part).startswith(("torch==", "torchvision==", "torchaudio==")) for part in command) + +def _rewrite_command(command): + if sys.platform != "darwin" or not _mentions_torch(command): + return command + if not isinstance(command, (list, tuple)): + return command + + rewritten = [] + changed = False + i = 0 + while i < len(command): + part = command[i] + text = str(part) + if text in ("--index-url", "-i", "--extra-index-url") and i + 1 < len(command) and _is_cuda_torch_index(str(command[i + 1])): + changed = True + i += 2 + continue + if text.startswith("--index-url=") or text.startswith("--extra-index-url="): + value = text.split("=", 1)[1] + if _is_cuda_torch_index(value): + changed = True + i += 1 + continue + rewritten.append(part) + i += 1 + + if changed: + print("[Modly setup compat] Removed CUDA-only PyTorch index on macOS; pip will use macOS wheels.", file=sys.stderr) + return rewritten + return command + +def _patched_run(*args, **kwargs): + args = list(args) + if args: + args[0] = _rewrite_command(args[0]) + return _original_run(*args, **kwargs) + +def _patched_check_call(*args, **kwargs): + args = list(args) + if args: + args[0] = _rewrite_command(args[0]) + return _original_check_call(*args, **kwargs) + +def _patched_check_output(*args, **kwargs): + args = list(args) + if args: + args[0] = _rewrite_command(args[0]) + return _original_check_output(*args, **kwargs) + +subprocess.run = _patched_run +subprocess.check_call = _patched_check_call +subprocess.check_output = _patched_check_output + +sys.argv = [setup_py] + setup_args +runpy.run_path(setup_py, run_name="__main__") +` + const proc = spawn(pythonExe, ['-c', launcher, setupPy, args], { stdio: ['ignore', 'pipe', 'pipe'], }) @@ -145,7 +241,12 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe ipcMain.handle('setup:check', async () => { const userData = app.getPath('userData') const defaultDataDir = join(app.getPath('documents'), 'Modly') - return { needed: checkSetupNeeded(userData), defaultDataDir } + return { + needed: checkSetupNeeded(userData), + defaultDataDir, + platform: process.platform, + arch: process.arch, + } }) ipcMain.handle('setup:saveDataDir', async (_event, { baseDir }: { baseDir: string }) => { @@ -315,22 +416,65 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe return listDownloadedModels(modelsDir) }) - ipcMain.handle('model:isDownloaded', (_, modelId: string): boolean => { + ipcMain.handle('model:isDownloaded', (_, modelId: string, downloadCheck?: string): boolean => { const modelsDir = getSettings(app.getPath('userData')).modelsDir - return isModelDownloaded(modelsDir, modelId) + return isModelDownloaded(modelsDir, modelId, downloadCheck) }) ipcMain.handle('model:activeDownloads', () => [...activeDownloads.entries()].map(([modelId, progress]) => ({ modelId, ...progress })) ) - ipcMain.handle('model:download', async (event, { repoId, modelId, skipPrefixes }: { repoId: string; modelId: string; skipPrefixes?: string[] }) => { + ipcMain.handle('model:download', async ( + event, + { repoId, modelId, skipPrefixes, includePrefixes }: { repoId: string; modelId: string; skipPrefixes?: string[]; includePrefixes?: string[] }, + ) => { + if (activeDownloads.has(modelId)) { + return { success: false, error: 'Download already in progress' } + } activeDownloads.set(modelId, { percent: 0 }) try { await downloadModelFromHF(repoId, modelId, (progress) => { activeDownloads.set(modelId, progress) event.sender.send('model:downloadProgress', { modelId, ...progress }) - }, skipPrefixes) + }, skipPrefixes, includePrefixes) + return { success: true } + } catch (err: any) { + const message = err?.message ?? String(err) + if (message.includes('paused')) { + event.sender.send('model:downloadProgress', { modelId, percent: 0, status: 'paused', paused: true }) + return { success: false, paused: true } + } + if (message.includes('cancelled')) { + event.sender.send('model:downloadProgress', { modelId, percent: 0, status: 'cancelled', cancelled: true }) + return { success: false, cancelled: true } + } + return { success: false, error: String(err) } + } finally { + activeDownloads.delete(modelId) + } + }) + + ipcMain.handle('model:pauseDownload', async (_, modelId: string): Promise<{ success: boolean; error?: string }> => { + try { + await axios.post(`${API_BASE_URL}/model/hf-download/pause`, null, { + params: { model_id: modelId }, + timeout: 5000, + }) + return { success: true } + } catch (err) { + return { success: false, error: String(err) } + } + }) + + ipcMain.handle('model:cancelDownload', async (_, modelId: string): Promise<{ success: boolean; error?: string }> => { + try { + await axios.post(`${API_BASE_URL}/model/hf-download/cancel`, null, { + params: { model_id: modelId }, + timeout: 5000, + }) + const modelDir = join(getSettings(app.getPath('userData')).modelsDir, modelId) + await rmAsync(modelDir, { recursive: true, force: true }) return { success: true } } catch (err) { return { success: false, error: String(err) } @@ -370,11 +514,46 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe ipcMain.handle('shell:openExternal', (_, url: string) => shell.openExternal(url)) // App info + // System memory (used/available/total bytes). + // On macOS, matches Activity Monitor's "Memory Used": + // used = wired + active + compressed. + ipcMain.handle('system:memory', async () => { + const total = os.totalmem() + + if (process.platform === 'darwin') { + try { + const { stdout } = await pExecFile('vm_stat', []) + const pageSizeMatch = stdout.match(/page size of (\d+) bytes/) + const pageSize = pageSizeMatch ? parseInt(pageSizeMatch[1]!, 10) : 16384 + + const pagesFor = (label: string): number => { + const m = stdout.match(new RegExp(`${label}:\\s+(\\d+)`)) + return m ? parseInt(m[1]!, 10) : 0 + } + + const active = pagesFor('Pages active') + const wired = pagesFor('Pages wired down') + const compressed = pagesFor('Pages occupied by compressor') + + const used = (active + wired + compressed) * pageSize + const available = Math.max(0, total - used) + return { total, used, available } + } catch { + // Fall back to total - free outside Activity Monitor semantics. + } + } + + const free = os.freemem() + return { total, used: total - free, available: free } + }) + ipcMain.handle('app:info', () => ({ version: app.getVersion(), userData: app.getPath('userData'), modelsDir: getSettings(app.getPath('userData')).modelsDir, - apiUrl: API_BASE_URL + apiUrl: API_BASE_URL, + platform: process.platform, + arch: process.arch, })) // Settings — seed HF token into main-process env at startup @@ -559,6 +738,9 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe // extension type type?: 'model' | 'process' entry?: string + // Optional top-level fallbacks — applied to each node if not set on the node + params_schema?: unknown[] + param_defaults?: Record nodes?: { id: string name?: string @@ -566,9 +748,11 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe inputs?: ('mesh' | 'image' | 'text')[] output?: 'mesh' | 'image' | 'text' params_schema?: unknown[] + param_defaults?: Record hf_repo?: string download_check?: string hf_skip_prefixes?: string[] + hf_include_prefixes?: string[] }[] } @@ -590,10 +774,12 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe input: n.input ?? 'image' as const, inputs: n.inputs, output: n.output ?? 'mesh' as const, - paramsSchema: n.params_schema ?? [], + paramsSchema: n.params_schema ?? parsed.params_schema ?? [], + paramDefaults: { ...(parsed.param_defaults ?? {}), ...(n.param_defaults ?? {}) }, hfRepo: n.hf_repo, downloadCheck: n.download_check, hfSkipPrefixes: n.hf_skip_prefixes, + hfIncludePrefixes: n.hf_include_prefixes, })) if (parsed.type === 'process') { @@ -696,25 +882,17 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe const manifestRaw = await readFile(manifestPath, 'utf-8') const manifest = JSON.parse(manifestRaw) as ParsedManifest - if (!manifest.id) throw new Error('manifest.json: required field "id" missing') - const extensionId = assertSafeExtensionId(manifest.id) + const { id: rawManifestId, isProcess, entryFile, isPythonProcess, hasNodes } = validateInstallManifest( + manifest, + { + hasEntryFile: (candidate) => existsSync(join(extractDir, candidate)), + hasGeneratorFile: () => existsSync(join(extractDir, 'generator.py')), + }, + 'repository', + ) + if (!hasNodes) throw new Error('manifest.json: required field "nodes" missing or empty') + const extensionId = assertSafeExtensionId(rawManifestId) manifest.id = extensionId - if (!manifest.nodes?.length) throw new Error('manifest.json: required field "nodes" missing or empty') - - const isProcess = manifest.type === 'process' - const entryFile = manifest.entry ?? 'processor.js' - const isPythonProcess = isProcess && entryFile.endsWith('.py') - - if (isProcess) { - // Process extension validation - if (!existsSync(join(extractDir, entryFile))) - throw new Error(`manifest.json: entry file "${entryFile}" missing from repository`) - } else { - // Model extension validation - const generatorPath = join(extractDir, 'generator.py') - if (!existsSync(generatorPath)) throw new Error('generator.py missing from repository') - if (!manifest.generator_class) throw new Error('manifest.json: required field "generator_class" missing') - } // Override source field with the actual GitHub URL so trust is based on origin manifest.source = `https://github.com/${owner}/${repo}` @@ -754,10 +932,18 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe if (existsSync(join(destDir, 'setup.py'))) { emit({ step: 'setting_up', message: 'Setting up Python environment…' }) const { sm: gpuSm, cudaVersion } = await detectGpuInfo() - await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => { - logger.info(`[ext-setup] ${line}`) - emit({ step: 'setting_up', message: line }) - }) + try { + await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => { + logger.info(`[ext-setup] ${line}`) + emit({ step: 'setting_up', message: line }) + }) + } catch (err) { + if (isSetupFailureFatal({ isProcess, isPythonProcess })) { + throw new Error(`Extension setup failed: ${err}`) + } + logger.warn(`[ext-setup] setup.py failed: ${err}`) + emit({ step: 'setting_up', message: `Warning: setup failed — ${err}` }) + } } } else if (isProcess) { // 6b. JS process extension: npm install if package.json present diff --git a/electron/main/model-downloader.ts b/electron/main/model-downloader.ts index 4d6f76c..8c5571d 100644 --- a/electron/main/model-downloader.ts +++ b/electron/main/model-downloader.ts @@ -13,6 +13,9 @@ export interface DownloadProgress { fileIndex?: number totalFiles?: number status?: string + bytesDownloaded?: number + totalBytes?: number + stalledSeconds?: number } export type ProgressCallback = (progress: DownloadProgress) => void @@ -25,9 +28,12 @@ const PYTHON_API_URL = process.env['PYTHON_API_URL'] ?? 'http://127.0.0.1:8765' /** * Check if a model is already downloaded (directory exists and is non-empty). */ -export function isModelDownloaded(modelsDir: string, modelId: string): boolean { +export function isModelDownloaded(modelsDir: string, modelId: string, downloadCheck?: string): boolean { const modelDir = join(modelsDir, modelId) if (!existsSync(modelDir)) return false + if (downloadCheck && downloadCheck.trim()) { + return existsSync(join(modelDir, downloadCheck)) + } try { return readdirSync(modelDir).length > 0 } catch { @@ -112,12 +118,17 @@ export async function downloadModelFromHF( modelId: string, onProgress: ProgressCallback, skipPrefixes?: string[], + includePrefixes?: string[], ): Promise { const { net } = require('electron') + const STALL_TIMEOUT_MS = 120_000 let url = `${PYTHON_API_URL}/model/hf-download?repo_id=${encodeURIComponent(repoId)}&model_id=${encodeURIComponent(modelId)}` if (skipPrefixes && skipPrefixes.length > 0) { url += `&skip_prefixes=${encodeURIComponent(JSON.stringify(skipPrefixes))}` } + if (includePrefixes && includePrefixes.length > 0) { + url += `&include_prefixes=${encodeURIComponent(JSON.stringify(includePrefixes))}` + } const hfToken = getSettings(app.getPath('userData')).hfToken if (hfToken) { url += `&token=${encodeURIComponent(hfToken)}` @@ -131,8 +142,17 @@ export async function downloadModelFromHF( const reader = res.body.getReader() let buffer = '' + async function readWithTimeout() { + return await Promise.race([ + reader.read(), + new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Model download stalled for ${Math.round(STALL_TIMEOUT_MS / 1000)}s`)), STALL_TIMEOUT_MS) + }), + ]) + } + while (true) { - const { done, value } = await reader.read() + const { done, value } = await readWithTimeout() if (done) break buffer += decoder.decode(value, { stream: true }) @@ -149,10 +169,22 @@ export async function downloadModelFromHF( fileIndex: data.fileIndex, totalFiles: data.totalFiles, status: data.status, + bytesDownloaded: data.bytesDownloaded, + totalBytes: data.totalBytes, + stalledSeconds: data.stalledSeconds, }) + if (data.paused) throw new Error('Model download paused') + if (data.cancelled) throw new Error('Model download cancelled') if (data.error) throw new Error(`HF download error: ${data.error}`) } catch (e) { - if (e instanceof Error && e.message.startsWith('HF download error:')) throw e + if ( + e instanceof Error && + ( + e.message.startsWith('HF download error:') || + e.message === 'Model download paused' || + e.message === 'Model download cancelled' + ) + ) throw e } } } diff --git a/electron/main/python-bridge.ts b/electron/main/python-bridge.ts index f0451ce..94dcfb6 100644 --- a/electron/main/python-bridge.ts +++ b/electron/main/python-bridge.ts @@ -51,15 +51,21 @@ export class PythonBridge { cwd: apiDir, env: { ...cleanPythonEnv(), - PYTHONUNBUFFERED: '1', - // No PYTHONPATH needed — the venv's Python has its own isolated site-packages - MODELS_DIR: this.resolveModelsDir(), - WORKSPACE_DIR: this.resolveWorkspaceDir(), - EXTENSIONS_DIR: this.resolveExtensionsDir(), - ...(process.env['SELECTED_MODEL_ID'] ? { SELECTED_MODEL_ID: process.env['SELECTED_MODEL_ID'] } : {}), - HUGGING_FACE_HUB_TOKEN: this.resolveHfToken(), - HF_TOKEN: this.resolveHfToken(), - } + PYTHONUNBUFFERED: '1', + // No PYTHONPATH needed - the venv's Python has its own isolated site-packages + MODELS_DIR: this.resolveModelsDir(), + WORKSPACE_DIR: this.resolveWorkspaceDir(), + EXTENSIONS_DIR: this.resolveExtensionsDir(), + SELECTED_MODEL_ID: process.env['SELECTED_MODEL_ID'] ?? '', + HUGGING_FACE_HUB_TOKEN: this.resolveHfToken(), + HF_TOKEN: this.resolveHfToken(), + }, + // On Unix, put the bridge in its own process group so every subprocess + // it spawns (extension runners, etc.) inherits that group. On shutdown + // we SIGKILL the whole group (negative PID) to take them all out + // together — otherwise children get reparented to launchd and keep + // holding MPS-wired memory until the user kills them manually. + detached: process.platform !== 'win32', }) this.process.stdout?.on('data', (data) => { @@ -82,7 +88,13 @@ export class PythonBridge { this.ready = false this.process = null if (wasReady && !this.intentionalStop) { - this.getWindow()?.webContents.send('python:crashed', { code }) + const getWindow = this.getWindow + if (!getWindow) return + const win = getWindow() + const contents = win?.webContents + if (contents && !contents.isDestroyed()) { + contents.send('python:crashed', { code }) + } } }) @@ -97,8 +109,17 @@ export class PythonBridge { if (process.platform === 'win32') { const { execSync } = require('child_process') try { execSync(`taskkill /PID ${proc.pid} /T /F`) } catch {} - } else { - proc.kill('SIGTERM') + } else if (proc.pid) { + // Kill the entire process group (negative PID) so extension subprocesses + // die with the bridge instead of being orphaned to launchd. SIGKILL + // rather than SIGTERM: on app quit we want immediate release of Metal + // wired memory, not a polite request the subprocess might ignore while + // it finishes an operation. + try { + process.kill(-proc.pid, 'SIGKILL') + } catch { + try { proc.kill('SIGKILL') } catch {} + } } console.log('[PythonBridge] Stopped') } @@ -114,7 +135,13 @@ export class PythonBridge { private emitTqdmLog(raw: string): void { if (/INFO/.test(raw)) return if (!raw.trim()) return - this.getWindow()?.webContents.send('python:log', raw.trim()) + const getWindow = this.getWindow + if (!getWindow) return + const win = getWindow() + const contents = win?.webContents + if (contents && !contents.isDestroyed()) { + contents.send('python:log', raw.trim()) + } } isReady(): boolean { return this.ready } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index c5e455b..c223d8b 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -14,6 +14,12 @@ contextBridge.exposeInMainWorld('electron', { openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), }, + // System info + system: { + memory: (): Promise<{ total: number; used: number; available: number }> => + ipcRenderer.invoke('system:memory'), + }, + // Python / FastAPI bridge python: { start: (): Promise<{ success: boolean; port?: number; error?: string }> => @@ -78,13 +84,28 @@ contextBridge.exposeInMainWorld('electron', { model: { export: (args: { outputUrl: string; format: string }) => ipcRenderer.invoke('model:export', args), listDownloaded: () => ipcRenderer.invoke('model:listDownloaded'), - isDownloaded: (modelId: string) => ipcRenderer.invoke('model:isDownloaded', modelId), - download: (repoId: string, modelId: string, skipPrefixes?: string[]) => ipcRenderer.invoke('model:download', { repoId, modelId, skipPrefixes }), + isDownloaded: (modelId: string, downloadCheck?: string) => ipcRenderer.invoke('model:isDownloaded', modelId, downloadCheck), + download: (repoId: string, modelId: string, skipPrefixes?: string[], includePrefixes?: string[]) => + ipcRenderer.invoke('model:download', { repoId, modelId, skipPrefixes, includePrefixes }), + pauseDownload: (modelId: string) => ipcRenderer.invoke('model:pauseDownload', modelId), + cancelDownload: (modelId: string) => ipcRenderer.invoke('model:cancelDownload', modelId), delete: (modelId: string) => ipcRenderer.invoke('model:delete', modelId), unloadAll: () => ipcRenderer.invoke('model:unloadAll'), showInFolder: (modelId: string) => ipcRenderer.invoke('model:showInFolder', modelId), activeDownloads: (): Promise<{ modelId: string; percent: number; file?: string; fileIndex?: number; totalFiles?: number }[]> => ipcRenderer.invoke('model:activeDownloads'), - onProgress: (cb: (data: { modelId: string; percent: number; file?: string; fileIndex?: number; totalFiles?: number; status?: string }) => void) => { + onProgress: (cb: (data: { + modelId: string + percent: number + file?: string + fileIndex?: number + totalFiles?: number + status?: string + bytesDownloaded?: number + totalBytes?: number + stalledSeconds?: number + paused?: boolean + cancelled?: boolean + }) => void) => { ipcRenderer.on('model:downloadProgress', (_event, data) => cb(data)) }, offProgress: () => ipcRenderer.removeAllListeners('model:downloadProgress') @@ -92,7 +113,7 @@ contextBridge.exposeInMainWorld('electron', { // App metadata app: { - info: (): Promise<{ version: string; userData: string; modelsDir: string; apiUrl: string }> => + info: (): Promise<{ version: string; userData: string; modelsDir: string; apiUrl: string; platform: string; arch: string }> => ipcRenderer.invoke('app:info'), onError: (cb: (message: string) => void) => { ipcRenderer.on('app:error', (_event, message) => cb(message)) @@ -191,7 +212,7 @@ contextBridge.exposeInMainWorld('electron', { // First-run setup setup: { - check: (): Promise<{ needed: boolean; defaultDataDir: string }> => + check: (): Promise<{ needed: boolean; defaultDataDir: string; platform: string; arch: string }> => ipcRenderer.invoke('setup:check'), run: (): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('setup:run'), diff --git a/package.json b/package.json index c81592a..8828919 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "build": "node scripts/build-builtins.mjs && electron-vite build", "preview": "electron-vite preview", "prepare-resources": "node scripts/download-python-embed.js", + "test": "cd api && python3 -m unittest discover -s tests && cd .. && node --test electron/main/*.test.mjs", "package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder", + "package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64", "lint": "eslint ." }, "dependencies": { @@ -63,7 +65,10 @@ "filter": [ "**/*", "!.venv/**/*", - "!__pycache__/**/*" + "!__pycache__/**/*", + "!**/build/**/*", + "!**/*.pyd", + "!**/*.egg-info/**/*" ] }, { @@ -93,7 +98,15 @@ }, "mac": { "target": "dmg", - "icon": "resources/icons/icon.icns" + "icon": "resources/icons/icon.icns", + "artifactName": "${productName}-${version}-arm64.${ext}", + "category": "public.app-category.graphics-design", + "extraResources": [ + { + "from": "resources/python-embed", + "to": "python-embed" + } + ] }, "linux": { "target": "AppImage", diff --git a/src/App.tsx b/src/App.tsx index 82180ee..9ef3edc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import FirstRunSetup from '@areas/setup/FirstRunSetup' import MainLayout from '@shared/components/layout/MainLayout' import { UpdateModal } from '@shared/components/ui/UpdateModal' import { ErrorModal } from '@shared/components/ui/ErrorModal' +import { Toast } from '@shared/components/ui/Toast' export default function App(): JSX.Element { const { checkSetup, setupStatus, initApp, backendStatus, showError } = useAppStore() @@ -41,12 +42,14 @@ export default function App(): JSX.Element { onDismiss={() => setUpdateVersion(null)} /> )} + ) return ( <> + ) diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index 318d235..bb9db4d 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -285,6 +285,7 @@ export default function GeneratePage(): JSX.Element { ) const currentJob = useAppStore((s) => s.currentJob) const apiUrl = useAppStore((s) => s.apiUrl) + const showError = useAppStore((s) => s.showError) const updateCurrentJob = useAppStore((s) => s.updateCurrentJob) const setCurrentJob = useAppStore((s) => s.setCurrentJob) const meshStats = useAppStore((s) => s.meshStats) @@ -327,6 +328,16 @@ export default function GeneratePage(): JSX.Element { link.click() } + function getOptimizePath(url: string): string { + if (url.startsWith('/workspace/')) { + return url.slice('/workspace/'.length) + } + if (url.startsWith('/optimize/serve-file?path=')) { + return decodeURIComponent(url.split('path=')[1] ?? '') + } + return url + } + async function handleImportMesh() { const filePath = await window.electron.fs.selectMeshFile() if (!filePath) return @@ -354,11 +365,13 @@ export default function GeneratePage(): JSX.Element { if (!currentJob?.outputUrl) return setSmoothing(true) try { - const path = currentJob.outputUrl.replace('/workspace/', '') + const path = getOptimizePath(currentJob.outputUrl) const { url } = await smoothMesh(path, iterations) updateCurrentJob({ outputUrl: url }) pushMeshUrl(url) setOpenPanel(null) + } catch (err) { + showError(err instanceof Error ? err.message : String(err)) } finally { setSmoothing(false) } @@ -368,11 +381,13 @@ export default function GeneratePage(): JSX.Element { if (!currentJob?.outputUrl) return setDecimating(true) try { - const path = currentJob.outputUrl.replace('/workspace/', '') + const path = getOptimizePath(currentJob.outputUrl) const { url } = await optimizeMesh(path, targetFaces) updateCurrentJob({ outputUrl: url }) pushMeshUrl(url) setOpenPanel(null) + } catch (err) { + showError(err instanceof Error ? err.message : String(err)) } finally { setDecimating(false) } diff --git a/src/areas/generate/components/GenerationHUD.tsx b/src/areas/generate/components/GenerationHUD.tsx index 768797a..6a9314e 100644 --- a/src/areas/generate/components/GenerationHUD.tsx +++ b/src/areas/generate/components/GenerationHUD.tsx @@ -7,6 +7,41 @@ function formatElapsed(seconds: number): string { return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` } +// Convert tqdm bar text to a clean "Verbing (N%)" form. Examples: +// "Diffusion Sampling: 60%|████| 3/5 [..." → "Diffusing (60%)" +// "Volume Decoding: 42%|██▏ | 1752/4200 [..." → "Decoding (42%)" +// "Loading pipeline components...: 100%|███| 4/4" → "Loading (100%)" +// Anything that isn't a tqdm progress line returns null so the caller +// leaves the HUD's sub-line untouched instead of filling it with noise. +const PHASE_VERB: Array<[RegExp, string]> = [ + [/diffusion|sampling/i, 'Generating'], + [/volume|flashvdm|decoding/i, 'Decoding'], + [/surface|extract/i, 'Extracting'], + [/loading|download/i, 'Loading'], +] + +function parseTqdmLine(line: string): string | null { + const m = line.match(/^(.+?):\s*(\d+)%\|/) + if (!m) return null + const desc = m[1].replace(/\.+$/, '').trim() + const pct = m[2] + const verb = + PHASE_VERB.find(([re]) => re.test(desc))?.[1] ?? + desc.replace(/^./, (c) => c.toUpperCase()) + return `${verb} (${pct}%)` +} + +function parseProgressFromStderr(chunk: string): string | null { + // One chunk may contain multiple tqdm ticks separated by \r or \n; + // show the most recent tqdm-like line. + const lines = chunk.split(/[\r\n]+/).filter(Boolean) + for (let i = lines.length - 1; i >= 0; i--) { + const parsed = parseTqdmLine(lines[i]!) + if (parsed) return parsed + } + return null +} + export default function GenerationHUD(): JSX.Element | null { const { currentJob, reset } = useGeneration() const [elapsed, setElapsed] = useState(0) @@ -37,11 +72,16 @@ export default function GenerationHUD(): JSX.Element | null { } }, [isActive, currentJob?.createdAt]) - // tqdm log listener + // tqdm log listener — parse to "Verbing (N%)" and skip non-progress noise useEffect(() => { if (isActive) { setTqdmLog(null) - window.electron.python.onLog((line) => setTqdmLog(line)) + window.electron.python.onLog((line) => { + const parsed = parseProgressFromStderr(line) + if (parsed !== null) { + setTqdmLog((prev) => (prev === parsed ? prev : parsed)) + } + }) return () => { window.electron.python.offLog(); setTqdmLog(null) } } }, [isActive]) @@ -96,9 +136,9 @@ export default function GenerationHUD(): JSX.Element | null { Generation failed -

+

               {error}
-            

+
) : ( - diff --git a/src/areas/models/ModelsPage.tsx b/src/areas/models/ModelsPage.tsx index 793592b..620b8e7 100644 --- a/src/areas/models/ModelsPage.tsx +++ b/src/areas/models/ModelsPage.tsx @@ -33,7 +33,17 @@ export default function ModelsPage(): JSX.Element { // Model weight state (needed for node install status + uninstall cleanup) const [installedVariantIds, setInstalledVariantIds] = useState([]) - const [downloading, setDownloading] = useState>({}) + const [downloading, setDownloading] = useState>({}) // Uninstall modal state const [uninstallTarget, setUninstallTarget] = useState(null) @@ -56,7 +66,7 @@ export default function ModelsPage(): JSX.Element { for (const node of ext.nodes) { if (!node.hfRepo) continue const fullId = `${ext.id}/${node.id}` - const ok = await window.electron.model.isDownloaded(fullId) + const ok = await window.electron.model.isDownloaded(fullId, node.downloadCheck) if (ok) ids.push(fullId) } } @@ -76,8 +86,28 @@ export default function ModelsPage(): JSX.Element { } refreshInstalledIds(exts) }) - window.electron.model.onProgress(({ modelId: id, percent, file, fileIndex, totalFiles }) => { - setDownloading((prev) => ({ ...prev, [id]: { percent, file, fileIndex, totalFiles } })) + window.electron.model.onProgress(({ modelId: id, percent, file, fileIndex, totalFiles, status, bytesDownloaded, totalBytes, stalledSeconds, paused, cancelled }) => { + if (cancelled) { + setDownloading((prev) => { const n = { ...prev }; delete n[id]; return n }) + return + } + setDownloading((prev) => { + const current = prev[id] + return { + ...prev, + [id]: { + percent: paused ? (current?.percent ?? percent) : percent, + file: file ?? current?.file, + fileIndex: fileIndex ?? current?.fileIndex, + totalFiles: totalFiles ?? current?.totalFiles, + status, + bytesDownloaded: bytesDownloaded ?? current?.bytesDownloaded, + totalBytes: totalBytes ?? current?.totalBytes, + stalledSeconds: stalledSeconds ?? current?.stalledSeconds, + paused, + }, + } + }) if (percent === 100) { const exts = useExtensionsStore.getState().modelExtensions refreshInstalledIds(exts).then(() => { @@ -170,9 +200,12 @@ export default function ModelsPage(): JSX.Element {

Extensions

-
@@ -351,13 +384,22 @@ export default function ModelsPage(): JSX.Element { } onInstall={(node: ExtensionNode, fullId: string) => { if (!node.hfRepo) return - setDownloading((prev) => ({ ...prev, [fullId]: { percent: 0 } })) - window.electron.model.download(node.hfRepo!, fullId, node.hfSkipPrefixes).then((result: { success: boolean }) => { - if (!result.success) { + setDownloading((prev) => ({ ...prev, [fullId]: { ...(prev[fullId] ?? { percent: 0 }), paused: false, status: 'Starting…' } })) + window.electron.model.download(node.hfRepo!, fullId, node.hfSkipPrefixes, node.hfIncludePrefixes).then((result: { success: boolean; paused?: boolean; cancelled?: boolean }) => { + if (!result.success && !result.paused && !result.cancelled) { + setGhErr('Download failed') setDownloading((prev) => { const n = { ...prev }; delete n[fullId]; return n }) } }) }} + onPauseDownload={async (fullId) => { + setDownloading((prev) => prev[fullId] ? ({ ...prev, [fullId]: { ...prev[fullId], paused: true, status: 'Pausing…' } }) : prev) + await window.electron.model.pauseDownload(fullId) + }} + onCancelDownload={async (fullId) => { + setDownloading((prev) => { const n = { ...prev }; delete n[fullId]; return n }) + await window.electron.model.cancelDownload(fullId) + }} onUninstallNode={async (fullId: string) => { await window.electron.model.delete(fullId) refreshInstalledIds(useExtensionsStore.getState().modelExtensions) diff --git a/src/areas/models/components/ExtensionCard.tsx b/src/areas/models/components/ExtensionCard.tsx index 0686c7c..85f03aa 100644 --- a/src/areas/models/components/ExtensionCard.tsx +++ b/src/areas/models/components/ExtensionCard.tsx @@ -6,10 +6,22 @@ export type { ExtensionNode } from '@shared/types/electron.d' interface Props { ext: AnyExtension installedIds: string[] - downloading: Record + downloading: Record loadError?: string disabled?: boolean onInstall: (node: import('@shared/types/electron.d').ExtensionNode, fullId: string) => void + onPauseDownload?: (fullId: string) => void + onCancelDownload?: (fullId: string) => void onUninstall: (extId: string) => void onUninstallNode?: (fullId: string) => void onRepaired?: () => void @@ -20,7 +32,18 @@ const TYPE_BADGE: Record = { process: { label: 'Process', cls: 'bg-emerald-500/15 border-emerald-500/25 text-emerald-400' }, } -export function ExtensionCard({ ext, installedIds, downloading, loadError, disabled, onInstall, onUninstall, onUninstallNode, onRepaired }: Props): JSX.Element { +function TruncatedText({ + content, + className, +}: { + content?: string + className: string +}): JSX.Element { + const text = content?.trim() || '—' + return {text} +} + +export function ExtensionCard({ ext, installedIds, downloading, loadError, disabled, onInstall, onPauseDownload, onCancelDownload, onUninstall, onUninstallNode, onRepaired }: Props): JSX.Element { const [repairing, setRepairing] = useState(false) const [repairError, setRepairError] = useState(null) @@ -38,6 +61,18 @@ export function ExtensionCard({ ext, installedIds, downloading, loadError, disab } } + function formatBytes(bytes?: number): string { + if (!bytes || bytes <= 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB'] + let value = bytes + let idx = 0 + while (value >= 1024 && idx < units.length - 1) { + value /= 1024 + idx += 1 + } + return `${value >= 10 || idx === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[idx]}` + } + return (
@@ -53,7 +88,7 @@ export function ExtensionCard({ ext, installedIds, downloading, loadError, disab
-

{ext.name}

+ {/* Type badge */} @@ -143,16 +178,19 @@ export function ExtensionCard({ ext, installedIds, downloading, loadError, disab const dlInfo = downloading[fullId] const isDownloading = dlInfo !== undefined const dlPercent = dlInfo?.percent ?? 0 - const dlFile = dlInfo?.file?.split('/').pop() + const dlFile = dlInfo?.file const dlFileIndex = dlInfo?.fileIndex const dlTotalFiles = dlInfo?.totalFiles + const dlStatus = dlInfo?.status + const dlBytes = dlInfo?.bytesDownloaded + const dlTotalBytes = dlInfo?.totalBytes + const dlStalled = dlInfo?.stalledSeconds ?? 0 + const dlPaused = dlInfo?.paused ?? false return (
{/* Node name */} - - {node.name} - + {/* I/O types */}
@@ -175,26 +213,76 @@ export function ExtensionCard({ ext, installedIds, downloading, loadError, disab ) : isDownloading ? (
- - {dlFile ?? 'Downloading…'} - + {dlFileIndex && dlTotalFiles ? `${dlFileIndex}/${dlTotalFiles} · ${dlPercent}%` : `${dlPercent}%`}
+
+ + = 30 ? 'text-amber-400' : 'text-zinc-600'}`}> + {dlStalled >= 30 ? `No progress ${dlStalled}s` : formatBytes(dlBytes)} + +
+
+ 0 + ? `${formatBytes(dlBytes)} / ${formatBytes(dlTotalBytes)}` + : formatBytes(dlBytes)} + className="text-[9px] text-zinc-600 truncate" + /> + {dlTotalBytes && dlTotalBytes > 0 && ( + + {Math.min(100, Math.round(((dlBytes ?? 0) / dlTotalBytes) * 100))}% + + )} +
+
+ + +
) : installed ? (
- {node.name} + {onUninstallNode && ( )}
diff --git a/src/areas/settings/components/PerformanceSection.tsx b/src/areas/settings/components/PerformanceSection.tsx index 867b428..b84586b 100644 --- a/src/areas/settings/components/PerformanceSection.tsx +++ b/src/areas/settings/components/PerformanceSection.tsx @@ -15,8 +15,9 @@ export function PerformanceSection(): JSX.Element { updateNodeData(id, { extensionId: e.target.value })} + className={inputCls} + > + {siblingVariants.map((variant) => ( + + ))} + +
+
+ )} {ext!.params.filter(isVisible).map((param) => { const val = (data.params[param.id] ?? param.default) as number | string return ( diff --git a/src/areas/workflows/nodes/mesh-remesher/manifest.json b/src/areas/workflows/nodes/mesh-remesher/manifest.json index 7eec7e0..2f4c72b 100644 --- a/src/areas/workflows/nodes/mesh-remesher/manifest.json +++ b/src/areas/workflows/nodes/mesh-remesher/manifest.json @@ -5,7 +5,7 @@ "entry": "processor.py", "version": "1.0.0", "author": "Modly", - "description": "Remeshes a mesh to triangle or quad topology using isotropic remeshing.", + "description": "Regularizes mesh topology with isotropic remeshing. Quad mode is quad-dominant cleanup, not character retopology.", "nodes": [ { "id": "remesh", @@ -20,10 +20,10 @@ "default": "triangle", "options": [ { "value": "triangle", "label": "Triangle" }, - { "value": "quad", "label": "Quad" }, + { "value": "quad", "label": "Quad-Dominant" }, { "value": "none", "label": "None" } ], - "tooltip": "Triangle produces a clean uniform triangulation. Quad attempts a quad-dominant mesh. None passes the mesh through unchanged." + "tooltip": "Triangle produces a clean uniform triangulation. Quad-Dominant attempts more regular polygon flow for cleanup, but it is not character-oriented retopology. None passes the mesh through unchanged." }, { "id": "target_edge_length", diff --git a/src/areas/workflows/preflight.ts b/src/areas/workflows/preflight.ts new file mode 100644 index 0000000..9ee9813 --- /dev/null +++ b/src/areas/workflows/preflight.ts @@ -0,0 +1,112 @@ +import type { Workflow, WFNode } from '@shared/types/electron.d' +import { getWorkflowExtension, type WorkflowExtension } from './mockExtensions' + +type DataType = 'image' | 'text' | 'mesh' + +export interface WorkflowPreflightIssue { + key: string + message: string + nodeId?: string +} + +function nodeLabel(node: WFNode, allExtensions: WorkflowExtension[]): string { + if (node.type === 'imageNode') return 'Image' + if (node.type === 'textNode') return 'Text' + if (node.type === 'meshNode') return 'Load 3D Mesh' + if (node.type === 'outputNode') return 'Add to Scene' + if (node.type === 'previewNode') return 'Preview Views' + if (node.type === 'extensionNode') { + return getWorkflowExtension(node.data.extensionId ?? '', allExtensions)?.name ?? 'Extension' + } + return 'Node' +} + +function formatType(type: DataType): string { + if (type === 'mesh') return 'mesh' + if (type === 'image') return 'image' + return 'text' +} + +function formatRequiredTypes(types: DataType[]): string { + if (types.length === 1) return formatType(types[0]) + if (types.length === 2) return `${formatType(types[0])} and ${formatType(types[1])}` + return `${types.slice(0, -1).map(formatType).join(', ')}, and ${formatType(types[types.length - 1])}` +} + +function getNodeOutputType(node: WFNode, allExtensions: WorkflowExtension[]): DataType | undefined { + if (node.type === 'imageNode') return 'image' + if (node.type === 'textNode') return 'text' + if (node.type === 'meshNode' || node.type === 'outputNode') return 'mesh' + if (node.type === 'previewNode') return 'image' + if (node.type === 'extensionNode') { + return getWorkflowExtension(node.data.extensionId ?? '', allExtensions)?.output + } + return undefined +} + +function pushIssue(issues: WorkflowPreflightIssue[], issue: WorkflowPreflightIssue): void { + if (!issues.some((existing) => existing.key === issue.key)) issues.push(issue) +} + +export function validateWorkflowPreflight( + workflow: Workflow, + allExtensions: WorkflowExtension[], + options?: { currentMeshUrl?: string | null }, +): WorkflowPreflightIssue[] { + const issues: WorkflowPreflightIssue[] = [] + const nodeMap = new Map(workflow.nodes.map((node) => [node.id, node])) + const outputTypes = new Map() + + for (const node of workflow.nodes) { + outputTypes.set(node.id, getNodeOutputType(node, allExtensions)) + } + + for (const node of workflow.nodes) { + if (node.type === 'meshNode' && node.data.params?.source === 'current' && !options?.currentMeshUrl) { + pushIssue(issues, { + key: `${node.id}:current-mesh`, + nodeId: node.id, + message: `${nodeLabel(node, allExtensions)} is set to Current Scene, but no mesh is loaded.`, + }) + } + + if (node.type !== 'extensionNode') continue + + const ext = getWorkflowExtension(node.data.extensionId ?? '', allExtensions) + if (!ext) { + pushIssue(issues, { + key: `${node.id}:missing-extension`, + nodeId: node.id, + message: `${nodeLabel(node, allExtensions)} is unavailable. Reload extensions or remove the node.`, + }) + continue + } + + const incomingEdges = workflow.edges.filter((edge) => edge.target === node.id) + const requiredTypes = [...new Set((ext.inputs ?? [ext.input]) as DataType[])] + + for (const requiredType of requiredTypes) { + const hasMatchingInput = incomingEdges.some((edge) => outputTypes.get(edge.source) === requiredType) + if (!hasMatchingInput) { + pushIssue(issues, { + key: `${node.id}:missing:${requiredType}`, + nodeId: node.id, + message: `${ext.name} needs an incoming ${formatType(requiredType)} connection.`, + }) + } + } + + for (const edge of incomingEdges) { + const sourceNode = nodeMap.get(edge.source) + const sourceType = outputTypes.get(edge.source) + if (!sourceNode || !sourceType || requiredTypes.includes(sourceType)) continue + pushIssue(issues, { + key: `${node.id}:type:${edge.id}`, + nodeId: node.id, + message: `${ext.name} expects ${formatRequiredTypes(requiredTypes)}, but ${nodeLabel(sourceNode, allExtensions)} outputs ${formatType(sourceType)}.`, + }) + } + } + + return issues +} diff --git a/src/areas/workflows/useWorkflowRunner.ts b/src/areas/workflows/useWorkflowRunner.ts index 5789e9a..091ea16 100644 --- a/src/areas/workflows/useWorkflowRunner.ts +++ b/src/areas/workflows/useWorkflowRunner.ts @@ -139,12 +139,6 @@ export function useWorkflowRunner(allExtensions: WorkflowExtension[]) { if (src?.filePath !== undefined) nodeInputPath = src.filePath if (src?.text !== undefined) nodeInputText = src.text } - // Fallback: if no edge supplied a file/text, use the previous node's output - if (nodeInputPath === undefined && nodeInputText === undefined && i > 0) { - const prev = nodeOutputs.get(execNodes[i - 1].id) - if (prev?.filePath !== undefined) nodeInputPath = prev.filePath - if (prev?.text !== undefined) nodeInputText = prev.text - } } setRunState((s) => ({ ...s, blockIndex: i, blockProgress: 0, blockStep: 'Starting…' })) @@ -173,6 +167,14 @@ export function useWorkflowRunner(allExtensions: WorkflowExtension[]) { : norm } + // Merge schema defaults (with per-variant paramDefaults already applied) + // under user overrides so Python receives the effective values, not an + // empty dict that falls back to hardcoded defaults in the generator. + const schemaDefaults = Object.fromEntries( + (ext.params ?? []).map((p) => [p.id, p.default]), + ) + const effectiveParams = { ...schemaDefaults, ...(node.data.params ?? {}) } + const fd = new FormData() fd.append('image', blob, fname) fd.append('model_id', node.data.extensionId ?? '') @@ -180,7 +182,7 @@ export function useWorkflowRunner(allExtensions: WorkflowExtension[]) { fd.append('remesh', 'none') fd.append('enable_texture', 'false') fd.append('texture_resolution', '1024') - fd.append('params', JSON.stringify({ ...node.data.params, ...extraParams })) + fd.append('params', JSON.stringify({ ...effectiveParams, ...extraParams })) setRunState((s) => ({ ...s, blockProgress: 5, blockStep: 'Submitting to model…' })) @@ -223,6 +225,15 @@ export function useWorkflowRunner(allExtensions: WorkflowExtension[]) { } else { // ── Process extension ──────────────────────────────────────────────── + if (ext?.input === 'mesh' && !nodeInputPath) { + throw new Error(`${ext.name} needs an incoming mesh connection`) + } + if (ext?.input === 'image' && !nodeInputPath) { + throw new Error(`${ext.name} needs an incoming image connection`) + } + if (ext?.input === 'text' && !nodeInputText) { + throw new Error(`${ext.name} needs an incoming text connection`) + } const parts = (node.data.extensionId ?? '').split('/') const extId = parts[0] const nodeId = parts[1] ?? '' diff --git a/src/areas/workflows/workflowRunStore.ts b/src/areas/workflows/workflowRunStore.ts index 0b955ea..d58ac48 100644 --- a/src/areas/workflows/workflowRunStore.ts +++ b/src/areas/workflows/workflowRunStore.ts @@ -184,12 +184,6 @@ export const useWorkflowRunStore = create((set) => ({ if (src?.filePath !== undefined) nodeInputPath = src.filePath if (src?.text !== undefined) nodeInputText = src.text } - // Fallback to previous node's output - if (nodeInputPath === undefined && nodeInputText === undefined && i > 0) { - const prev = nodeOutputs.get(execNodes[i - 1].id) - if (prev?.filePath !== undefined) nodeInputPath = prev.filePath - if (prev?.text !== undefined) nodeInputText = prev.text - } } set((s) => ({ @@ -234,6 +228,14 @@ export const useWorkflowRunStore = create((set) => ({ : norm } + // Merge schema defaults (with per-variant paramDefaults already applied) + // under user overrides so Python receives the effective values, not an + // empty dict that falls back to hardcoded defaults in the generator. + const schemaDefaults = Object.fromEntries( + (ext.params ?? []).map((p) => [p.id, p.default]), + ) + const effectiveParams = { ...schemaDefaults, ...(node.data.params ?? {}) } + const fd = new FormData() fd.append('image', blob, fname) fd.append('model_id', node.data.extensionId ?? '') @@ -241,7 +243,7 @@ export const useWorkflowRunStore = create((set) => ({ fd.append('remesh', 'none') fd.append('enable_texture', 'false') fd.append('texture_resolution', '1024') - fd.append('params', JSON.stringify({ ...node.data.params, ...extraParams })) + fd.append('params', JSON.stringify({ ...effectiveParams, ...extraParams })) set((s) => ({ runState: { ...s.runState, blockProgress: 5, blockStep: 'Submitting to model…' } })) @@ -285,6 +287,15 @@ export const useWorkflowRunStore = create((set) => ({ } else { // ── Process extension → IPC ───────────────────────────────────── + if (ext?.input === 'mesh' && !nodeInputPath) { + throw new Error(`${ext.name} needs an incoming mesh connection`) + } + if (ext?.input === 'image' && !nodeInputPath) { + throw new Error(`${ext.name} needs an incoming image connection`) + } + if (ext?.input === 'text' && !nodeInputText) { + throw new Error(`${ext.name} needs an incoming text connection`) + } const parts = (node.data.extensionId ?? '').split('/') const extId = parts[0] const nodeId = parts[1] ?? '' @@ -392,6 +403,9 @@ export const useWorkflowRunStore = create((set) => ({ _activeJobId.current = null } set({ runState: IDLE, activeNodeId: null, activeWorkflowId: null, nodeImageOutputs: {} }) + // Clear the generation HUD so it doesn't show stale progress after cancel. + // The backend's subprocess hard-kill is asynchronous; the UI shouldn't wait. + useAppStore.getState().setCurrentJob(null) }, reset() { diff --git a/src/shared/components/layout/MemoryIndicator.tsx b/src/shared/components/layout/MemoryIndicator.tsx new file mode 100644 index 0000000..fe09eac --- /dev/null +++ b/src/shared/components/layout/MemoryIndicator.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react' + +const GB = 1024 ** 3 + +function fmtGB(bytes: number): string { + return (bytes / GB).toFixed(1) +} + +export default function MemoryIndicator(): JSX.Element | null { + const [mem, setMem] = useState<{ total: number; used: number; available: number } | null>(null) + + useEffect(() => { + let cancelled = false + const tick = async () => { + try { + const next = await window.electron.system.memory() + if (!cancelled) setMem(next) + } catch { + // Renderer should not break if memory sampling fails. + } + } + tick() + const id = setInterval(tick, 2000) + return () => { + cancelled = true + clearInterval(id) + } + }, []) + + if (!mem) return null + + const pct = mem.total > 0 ? Math.min(100, Math.round((mem.used / mem.total) * 100)) : 0 + + let barColor = 'bg-emerald-500' + let textColor = 'text-zinc-300' + if (pct >= 90) { + barColor = 'bg-red-500' + textColor = 'text-red-300' + } else if (pct >= 75) { + barColor = 'bg-amber-500' + textColor = 'text-amber-300' + } + + const tooltip = + `Used: ${fmtGB(mem.used)} GB\n` + + `Available: ${fmtGB(mem.available)} GB\n` + + `Total: ${fmtGB(mem.total)} GB` + + return ( +
+ RAM +
+
+
+ + {fmtGB(mem.used)} / {fmtGB(mem.total)} GB + +
+ ) +} diff --git a/src/shared/components/layout/TopBar.tsx b/src/shared/components/layout/TopBar.tsx index 411d8d1..b70daa5 100644 --- a/src/shared/components/layout/TopBar.tsx +++ b/src/shared/components/layout/TopBar.tsx @@ -1,7 +1,9 @@ import { useAppStore } from '@shared/stores/appStore' +import MemoryIndicator from './MemoryIndicator' export default function TopBar(): JSX.Element { - const { patchUpdateReady } = useAppStore() + const { patchUpdateReady, platform } = useAppStore() + const isMac = platform === 'darwin' const handleMinimize = () => window.electron.window.minimize() const handleMaximize = () => window.electron.window.maximize() @@ -10,7 +12,7 @@ export default function TopBar(): JSX.Element { return (
{/* App name */} -
+
@@ -28,10 +30,12 @@ export default function TopBar(): JSX.Element { Modly
- {/* Spacer */}
+ {/* Memory indicator */} + + {/* Patch update badge */} {patchUpdateReady && (
@@ -45,37 +49,39 @@ export default function TopBar(): JSX.Element {
)} - {/* Window controls */} -
- - - -
+ {/* Window controls — Mac uses the native traffic-light buttons on the left */} + {!isMac && ( +
+ + + +
+ )}
) } diff --git a/src/shared/components/ui/Toast.tsx b/src/shared/components/ui/Toast.tsx new file mode 100644 index 0000000..7ad2e70 --- /dev/null +++ b/src/shared/components/ui/Toast.tsx @@ -0,0 +1,44 @@ +import { useEffect } from 'react' +import { useAppStore } from '@shared/stores/appStore' + +export function Toast(): JSX.Element | null { + const { toast, hideToast } = useAppStore() + + useEffect(() => { + if (!toast) return + const timer = window.setTimeout(() => hideToast(), toast.durationMs ?? 2800) + return () => window.clearTimeout(timer) + }, [toast?.id, toast?.durationMs, hideToast]) + + if (!toast) return null + + return ( +
+
+
+
+ + + + + +
+
+

Workflow Check

+

{toast.message}

+
+ +
+
+
+ ) +} diff --git a/src/shared/components/ui/index.ts b/src/shared/components/ui/index.ts index 17b6d61..a31414a 100644 --- a/src/shared/components/ui/index.ts +++ b/src/shared/components/ui/index.ts @@ -2,3 +2,4 @@ export { Tooltip } from './Tooltip' export { FieldLabel } from './FieldLabel' export { ConfirmModal } from './ConfirmModal' export { ColorPicker } from './ColorPicker' +export { Toast } from './Toast' diff --git a/src/shared/stores/appStore.ts b/src/shared/stores/appStore.ts index 1328eca..1995f85 100644 --- a/src/shared/stores/appStore.ts +++ b/src/shared/stores/appStore.ts @@ -36,6 +36,12 @@ export interface GenerationOptions { modelParams: Record } +export interface AppToast { + id: number + message: string + durationMs?: number +} + const DEFAULT_OPTIONS: GenerationOptions = { modelId: '', remesh: 'quad', @@ -73,6 +79,8 @@ interface AppState { setupProgress: SetupProgress | null setupError: string | null defaultDataDir: string + platform: string + arch: string checkSetup: () => Promise runSetup: () => Promise saveDataDir: (baseDir: string) => Promise @@ -86,6 +94,11 @@ interface AppState { showError: (message: string) => void hideError: () => void + // Toast + toast: AppToast | null + showToast: (message: string, durationMs?: number) => void + hideToast: () => void + // Mesh URL history (undo/redo) meshHistory: string[] historyIndex: number @@ -112,11 +125,13 @@ export const useAppStore = create()( setupProgress: null, setupError: null, defaultDataDir: '', + platform: '', + arch: '', checkSetup: async () => { set({ setupStatus: 'checking' }) - const { needed, defaultDataDir } = await window.electron.setup.check() - set({ setupStatus: needed ? 'needed' : 'done', defaultDataDir }) + const { needed, defaultDataDir, platform, arch } = await window.electron.setup.check() + set({ setupStatus: needed ? 'needed' : 'done', defaultDataDir, platform, arch }) }, saveDataDir: async (baseDir: string) => { @@ -152,6 +167,10 @@ export const useAppStore = create()( showError: (message) => set({ errorModal: message }), hideError: () => set({ errorModal: null }), + toast: null, + showToast: (message, durationMs) => set({ toast: { id: Date.now(), message, durationMs } }), + hideToast: () => set({ toast: null }), + meshHistory: [], historyIndex: -1, diff --git a/src/shared/stores/extensionsStore.ts b/src/shared/stores/extensionsStore.ts index bf9fdc7..e750f0a 100644 --- a/src/shared/stores/extensionsStore.ts +++ b/src/shared/stores/extensionsStore.ts @@ -59,6 +59,43 @@ export const useExtensionsStore = create((set, get) => ({ // ── Install from GitHub ──────────────────────────────────────────────────── async installFromGitHub(url: string) { + return installExtension(() => window.electron.extensions.installFromGitHub(url), set) + }, + + // ── Uninstall ────────────────────────────────────────────────────────────── + + async uninstall(extensionId: string) { + const result = await window.electron.extensions.uninstall(extensionId) + if (result.success) { + set((state) => ({ + modelExtensions: state.modelExtensions.filter((e) => e.id !== extensionId), + processExtensions: state.processExtensions.filter((e) => e.id !== extensionId), + })) + } + return result + }, + + // ── Reload (rescan extensions dir + Python registry) ────────────────────── + + async reload() { + const result = await window.electron.extensions.reload() + if (result.success) { + set({ loadErrors: result.errors ?? {} }) + } + await get().loadExtensions() + }, + + // ── Helpers ──────────────────────────────────────────────────────────────── + + clearInstallState() { + set({ installProgress: null, installError: null }) + }, +})) + +async function installExtension( + invoke: () => Promise<{ success: boolean; error?: string; extension?: AnyExtension; extensionId?: string }>, + set: (partial: Partial | ((state: ExtensionsStore) => Partial)) => void, +) { set({ installProgress: { step: 'downloading', percent: 0 }, installError: null }) window.electron.extensions.onInstallProgress((data) => { @@ -70,7 +107,7 @@ export const useExtensionsStore = create((set, get) => ({ }) try { - const result = await window.electron.extensions.installFromGitHub(url) + const result = await invoke() if (result.success && result.extension) { const ext = result.extension as AnyExtension @@ -103,34 +140,4 @@ export const useExtensionsStore = create((set, get) => ({ } finally { window.electron.extensions.offInstallProgress() } - }, - - // ── Uninstall ────────────────────────────────────────────────────────────── - - async uninstall(extensionId: string) { - const result = await window.electron.extensions.uninstall(extensionId) - if (result.success) { - set((state) => ({ - modelExtensions: state.modelExtensions.filter((e) => e.id !== extensionId), - processExtensions: state.processExtensions.filter((e) => e.id !== extensionId), - })) - } - return result - }, - - // ── Reload (rescan extensions dir + Python registry) ────────────────────── - - async reload() { - const result = await window.electron.extensions.reload() - if (result.success) { - set({ loadErrors: result.errors ?? {} }) - } - await get().loadExtensions() - }, - - // ── Helpers ──────────────────────────────────────────────────────────────── - - clearInstallState() { - set({ installProgress: null, installError: null }) - }, -})) +} diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index f5d5ca8..4f4ae89 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -10,9 +10,11 @@ export interface ExtensionNode { inputs?: ('image' | 'text' | 'mesh')[] // multi-input nodes; overrides input when set output: 'image' | 'text' | 'mesh' paramsSchema: ParamSchema[] + paramDefaults?: Record hfRepo?: string downloadCheck?: string hfSkipPrefixes?: string[] + hfIncludePrefixes?: string[] } export interface ModelExtension { @@ -109,6 +111,9 @@ declare global { shell: { openExternal: (url: string) => Promise } + system: { + memory: () => Promise<{ total: number; used: number; available: number }> + } window: { minimize: () => void maximize: () => void @@ -147,12 +152,26 @@ declare global { model: { export: (args: { outputUrl: string; format: string }) => Promise<{ success: boolean; error?: string }> listDownloaded: () => Promise<{ id: string; name: string; size_gb: number }[]> - isDownloaded: (modelId: string) => Promise - download: (repoId: string, modelId: string, skipPrefixes?: string[]) => Promise<{ success: boolean; error?: string }> + isDownloaded: (modelId: string, downloadCheck?: string) => Promise + download: (repoId: string, modelId: string, skipPrefixes?: string[], includePrefixes?: string[]) => Promise<{ success: boolean; error?: string }> + pauseDownload: (modelId: string) => Promise<{ success: boolean; error?: string }> + cancelDownload: (modelId: string) => Promise<{ success: boolean; error?: string }> delete: (modelId: string) => Promise<{ success: boolean; error?: string }> unloadAll: () => Promise<{ success: boolean; error?: string }> showInFolder: (modelId: string) => Promise - onProgress: (cb: (data: { modelId: string; percent: number; file?: string; fileIndex?: number; totalFiles?: number; status?: string }) => void) => void + onProgress: (cb: (data: { + modelId: string + percent: number + file?: string + fileIndex?: number + totalFiles?: number + status?: string + bytesDownloaded?: number + totalBytes?: number + stalledSeconds?: number + paused?: boolean + cancelled?: boolean + }) => void) => void offProgress: () => void } app: { @@ -161,6 +180,8 @@ declare global { userData: string modelsDir: string apiUrl: string + platform: string + arch: string }> onError: (cb: (message: string) => void) => void offError: () => void @@ -181,7 +202,7 @@ declare global { deleteJob: (collection: string, filename: string) => Promise } setup: { - check: () => Promise<{ needed: boolean; defaultDataDir: string }> + check: () => Promise<{ needed: boolean; defaultDataDir: string; platform: string; arch: string }> run: () => Promise<{ success: boolean; error?: string }> saveDataDir: (baseDir: string) => Promise onProgress: (cb: (data: { step: string; percent: number; currentPackage?: string }) => void) => void