From c449794779331638913e02431a34335c9ef76fa2 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Sat, 28 Mar 2026 18:11:26 +0100 Subject: [PATCH 01/35] tech(archi): rework extension archi --- api/requirements.txt | 32 +--- api/routers/extensions.py | 62 +++++++- api/routers/generation.py | 10 +- api/runner.py | 176 ++++++++++++++++++++ api/services/extension_process.py | 248 +++++++++++++++++++++++++++++ api/services/generator_registry.py | 120 +++++--------- electron/main/ipc-handlers.ts | 17 +- src/areas/models/ModelsPage.tsx | 11 +- 8 files changed, 562 insertions(+), 114 deletions(-) create mode 100644 api/runner.py create mode 100644 api/services/extension_process.py diff --git a/api/requirements.txt b/api/requirements.txt index 6947586..a3888d5 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,36 +1,14 @@ -# PyTorch CUDA — index primaire pour torch/torchvision -# cu128 = CUDA 12.8 (driver ≥520, RTX 30xx/40xx/50xx) -# Changer cu128 → cu126 si le driver est plus ancien ---index-url https://download.pytorch.org/whl/cu128 ---extra-index-url https://pypi.org/simple - # Web server fastapi==0.115.6 uvicorn[standard]==0.34.0 python-multipart==0.0.20 -# AI / ML -torch>=2.1.0 -torchvision>=0.16.0 -huggingface_hub>=0.27.0 -hf_xet>=0.1.0 -diffusers>=0.32.0 -transformers>=4.48.0,<5.0.0 -accelerate>=1.3.0 -pillow>=11.0.0 +# Mesh processing (optimize + export endpoints) trimesh>=4.5.0 -numpy>=1.26.0 -rembg>=2.0.0 # background removal (SF3D + Hunyuan3D) -onnxruntime-gpu>=1.17.0 # runtime ONNX requis par rembg (GPU-accelerated) -einops>=0.7.0 # tensor ops used by Hunyuan3D / hy3dshape pipeline -pymeshlab>=2023.12 # mesh processing required by hy3dshape postprocessors -xatlas>=0.0.8 # UV unwrapping for Hunyuan3D texture generation -pygltflib>=1.15.0 # GLB/GLTF I/O for Hunyuan3D texture pipeline +pymeshlab>=2023.12 -opencv-python-headless>=4.8.0 # required by hy3dgen.shapegen.preprocessors (cv2) -peft>=0.10.0 # LoRA/adapter support required by TripoSG transformer -scikit-image>=0.21.0 # image processing utilities required by TripoSG +# Model downloads (HuggingFace) +huggingface_hub>=0.27.0 +hf_xet>=0.1.0 -# Utils -aiofiles==24.1.0 cryptography>=42.0.0 diff --git a/api/routers/extensions.py b/api/routers/extensions.py index 67758a2..2313d3b 100644 --- a/api/routers/extensions.py +++ b/api/routers/extensions.py @@ -1,4 +1,7 @@ -from fastapi import APIRouter +import asyncio +import subprocess +import sys +from fastapi import APIRouter, HTTPException router = APIRouter(tags=["extensions"]) @@ -18,8 +21,65 @@ async def reload_extensions(): } +@router.post("/setup/{ext_id}") +async def setup_extension(ext_id: str): + """ + Creates the isolated venv for an extension by running its setup.py. + Called automatically after installing an extension from GitHub. + Runs setup.py with Modly's embedded Python and the detected GPU SM. + """ + from services.generator_registry import EXTENSIONS_DIR + + if EXTENSIONS_DIR is None or not EXTENSIONS_DIR.exists(): + raise HTTPException(400, "EXTENSIONS_DIR not configured") + + ext_dir = EXTENSIONS_DIR / ext_id + setup_py = ext_dir / "setup.py" + + if not ext_dir.exists(): + raise HTTPException(404, f"Extension '{ext_id}' not found in {EXTENSIONS_DIR}") + if not setup_py.exists(): + # No setup.py → legacy extension, nothing to do + return {"status": "skipped", "reason": "no setup.py"} + + # Detect GPU compute capability + gpu_sm = _detect_gpu_sm() + + # Run setup.py using Modly's embedded Python (sys.executable) + loop = asyncio.get_running_loop() + result = await loop.run_in_executor( + None, + lambda: subprocess.run( + [sys.executable, str(setup_py), sys.executable, str(ext_dir), str(gpu_sm)], + capture_output=True, + text=True, + ) + ) + + if result.returncode != 0: + raise HTTPException(500, f"setup.py failed:\n{result.stderr}") + + return { + "status": "ok", + "gpu_sm": gpu_sm, + "output": result.stdout, + } + + @router.get("/errors") async def extension_errors(): """Returns extension loading errors (invalid manifest, failed import, etc.).""" from services.generator_registry import generator_registry return generator_registry.load_errors() + + +def _detect_gpu_sm() -> int: + """Returns GPU compute capability as integer (e.g. 86 for SM 8.6), or 0 if no GPU.""" + try: + import torch + if torch.cuda.is_available(): + major, minor = torch.cuda.get_device_capability(0) + return major * 10 + minor + except Exception: + pass + return 0 diff --git a/api/routers/generation.py b/api/routers/generation.py index 77b9420..4f5f549 100644 --- a/api/routers/generation.py +++ b/api/routers/generation.py @@ -149,9 +149,13 @@ def progress_cb(pct: int, step: str = "") -> None: if job_id in _cancelled: return - job.status = "done" - job.progress = 100 - job.output_url = f"/workspace/{collection}/{output_path.name}" + job.status = "done" + job.progress = 100 + try: + rel = output_path.relative_to(WORKSPACE_DIR) + job.output_url = f"/workspace/{rel.as_posix()}" + except ValueError: + job.output_url = f"/workspace/{collection}/{output_path.name}" except GenerationCancelled: job.status = "cancelled" diff --git a/api/runner.py b/api/runner.py new file mode 100644 index 0000000..30ae611 --- /dev/null +++ b/api/runner.py @@ -0,0 +1,176 @@ +""" +Modly Extension Runner — generic subprocess entry point. + +Runs inside the extension's own venv. Loaded by ExtensionProcess via: + {venv_python} {runner_path} + +Environment variables (set by ExtensionProcess): + EXTENSION_DIR — absolute path to the extension directory + MODELS_DIR — where model weights are stored + WORKSPACE_DIR — where generated files are saved + MODLY_API_DIR — path to Modly's api/ dir (so generator.py can import + from services.generators.base) + +Protocol: newline-delimited JSON on stdin/stdout. +Stderr is captured separately by ExtensionProcess for logging. +""" +import sys +import json +import os +import traceback +import base64 +import threading +import importlib.util +from pathlib import Path + +# ------------------------------------------------------------------ # +# Env +# ------------------------------------------------------------------ # + +EXT_DIR = Path(os.environ["EXTENSION_DIR"]) +MODELS_DIR = Path(os.environ.get("MODELS_DIR", Path.home() / ".modly" / "models")) +WORKSPACE_DIR = Path(os.environ.get("WORKSPACE_DIR", Path.home() / ".modly" / "workspace")) +MODLY_API_DIR = os.environ.get("MODLY_API_DIR", "") + +# Inject Modly's api/ so generator.py can do: +# from services.generators.base import BaseGenerator, ... +if MODLY_API_DIR and MODLY_API_DIR not in sys.path: + sys.path.insert(0, MODLY_API_DIR) + +# Inject ext dir so generator.py can import local vendor modules +if str(EXT_DIR) not in sys.path: + sys.path.insert(0, str(EXT_DIR)) + + +# ------------------------------------------------------------------ # +# Protocol helpers +# ------------------------------------------------------------------ # + +def send(msg: dict) -> None: + sys.stdout.write(json.dumps(msg) + "\n") + sys.stdout.flush() + + +def recv(): + """Yields parsed JSON messages from stdin, one per line.""" + for raw in sys.stdin: + raw = raw.strip() + if raw: + try: + yield json.loads(raw) + except json.JSONDecodeError as exc: + send({"type": "log", "level": "error", + "message": f"Runner: invalid JSON on stdin: {exc}"}) + + +# ------------------------------------------------------------------ # +# Generator loader +# ------------------------------------------------------------------ # + +def load_generator(manifest: dict): + """Dynamically load the generator class from generator.py.""" + spec = importlib.util.spec_from_file_location( + "generator", EXT_DIR / "generator.py" + ) + mod = importlib.util.module_from_spec(spec) + sys.modules["generator"] = mod + spec.loader.exec_module(mod) + return getattr(mod, manifest["generator_class"]) + + +# ------------------------------------------------------------------ # +# Main loop +# ------------------------------------------------------------------ # + +def main() -> None: + manifest = json.loads((EXT_DIR / "manifest.json").read_text(encoding="utf-8")) + model_id = manifest["id"] + + try: + GenClass = load_generator(manifest) + except Exception: + send({"type": "error", "id": None, + "message": "Failed to load generator class", + "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: + schema = manifest.get("params_schema", []) + send({"type": "ready", "params_schema": schema}) + + gen = GenClass(MODELS_DIR / model_id, WORKSPACE_DIR) + gen.hf_repo = manifest.get("hf_repo", "") + gen.hf_skip_prefixes = manifest.get("hf_skip_prefixes", []) + gen.download_check = manifest.get("download_check", "") + gen._params_schema = manifest.get("params_schema", []) + + # Active cancel events keyed by request id + _cancel: dict[str, threading.Event] = {} + + for msg in recv(): + action = msg.get("action") + rid = msg.get("id") + + try: + # ---- load ------------------------------------------------ + if action == "load": + gen.load() + send({"type": "loaded"}) + + # ---- generate -------------------------------------------- + elif action == "generate": + cancel_evt = threading.Event() + _cancel[rid] = cancel_evt + image_bytes = base64.b64decode(msg["image_b64"]) + params = msg.get("params", {}) + if msg.get("outputs_dir"): + gen.outputs_dir = Path(msg["outputs_dir"]) + gen.outputs_dir.mkdir(parents=True, exist_ok=True) + + def progress_cb(pct: int, step: str = "") -> None: + send({"type": "progress", "id": rid, "pct": pct, "step": step}) + + try: + output_path = gen.generate(image_bytes, params, progress_cb, cancel_evt) + send({"type": "done", "id": rid, "output_path": str(output_path)}) + except Exception as exc: + # Detect GenerationCancelled by name to avoid import issues + if type(exc).__name__ == "GenerationCancelled": + send({"type": "cancelled", "id": rid}) + else: + send({"type": "error", "id": rid, + "message": str(exc), + "traceback": traceback.format_exc()}) + finally: + _cancel.pop(rid, None) + + # ---- cancel ---------------------------------------------- + elif action == "cancel": + evt = _cancel.get(rid) + if evt: + evt.set() + + # ---- unload ---------------------------------------------- + elif action == "unload": + gen.unload() + send({"type": "unloaded"}) + + # ---- shutdown -------------------------------------------- + elif action == "shutdown": + gen.unload() + break + + except Exception: + send({"type": "error", "id": rid, + "message": "Unexpected runner error", + "traceback": traceback.format_exc()}) + + +if __name__ == "__main__": + main() diff --git a/api/services/extension_process.py b/api/services/extension_process.py new file mode 100644 index 0000000..e6efd29 --- /dev/null +++ b/api/services/extension_process.py @@ -0,0 +1,248 @@ +""" +ExtensionProcess — manages a generator running in an isolated subprocess. + +Each extension runs in its own venv via runner.py. +Communication is done via newline-delimited JSON on stdin/stdout. + +Interface is intentionally compatible with direct BaseGenerator usage +so GeneratorRegistry can treat both transparently. +""" +import base64 +import json +import os +import platform +import queue +import subprocess +import sys +import threading +import uuid +from pathlib import Path +from typing import Callable, Optional + +_RUNNER_PATH = Path(__file__).parent.parent / "runner.py" + + +def _venv_python(ext_dir: Path) -> Path: + """Returns the path to the venv's Python executable.""" + if platform.system() == "Windows": + return ext_dir / "venv" / "Scripts" / "python.exe" + return ext_dir / "venv" / "bin" / "python" + + +class ExtensionProcess: + """ + Wraps an extension subprocess. Presents the same interface as a + direct generator (load / unload / generate / is_loaded / params_schema). + """ + + def __init__(self, ext_dir: Path, manifest: dict) -> None: + self.ext_dir = ext_dir + self.manifest = manifest + self.model_dir = None # set by registry after init + self.outputs_dir = None # set by registry after init + + self._proc: Optional[subprocess.Popen] = None + self._queue: queue.Queue = queue.Queue() + self._lock: threading.Lock = threading.Lock() + self._loaded: bool = False + + # Mirrors BaseGenerator attributes used by the registry + self.hf_repo = manifest.get("hf_repo", "") + self.hf_skip_prefixes = manifest.get("hf_skip_prefixes", []) + self.download_check = manifest.get("download_check", "") + self._params_schema = manifest.get("params_schema", []) + + # Public metadata + self.MODEL_ID = manifest.get("id", "") + self.DISPLAY_NAME = manifest.get("name", "") + self.VRAM_GB = manifest.get("vram_gb", 0) + + # ------------------------------------------------------------------ # + # Subprocess lifecycle + # ------------------------------------------------------------------ # + + def _build_env(self) -> dict: + from services.generator_registry import MODELS_DIR, WORKSPACE_DIR + env = os.environ.copy() + env["EXTENSION_DIR"] = str(self.ext_dir) + env["MODELS_DIR"] = str(MODELS_DIR) + env["WORKSPACE_DIR"] = str(WORKSPACE_DIR) + env["MODLY_API_DIR"] = str(Path(__file__).parent.parent) + return env + + def _start(self) -> None: + """Launch the subprocess and wait for the 'ready' signal.""" + python = _venv_python(self.ext_dir) + if not python.exists(): + raise RuntimeError( + f"[{self.MODEL_ID}] venv not found at {python}. " + "Run the extension's setup.py first." + ) + + self._proc = subprocess.Popen( + [str(python), str(_RUNNER_PATH)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + env=self._build_env(), + ) + + # Background thread: read stdout → queue + reader = threading.Thread(target=self._read_loop, daemon=True) + reader.start() + + # Background thread: forward stderr to our stderr + stderr_fwd = threading.Thread(target=self._stderr_loop, daemon=True) + stderr_fwd.start() + + # Wait for ready — runner sends params_schema in this message + msg = self._recv(timeout=None) + if msg.get("type") != "ready": + self._proc.kill() + raise RuntimeError(f"[{self.MODEL_ID}] Expected 'ready', got: {msg}") + + # Override params_schema with what the generator class actually declares + if msg.get("params_schema"): + self._params_schema = msg["params_schema"] + + print(f"[ExtensionProcess] {self.MODEL_ID} subprocess started (pid {self._proc.pid})") + + def _read_loop(self) -> None: + """Continuously reads stdout and pushes parsed JSON to the queue.""" + try: + for line in self._proc.stdout: + line = line.strip() + if line: + try: + self._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) + + def _send(self, msg: dict) -> None: + with self._lock: + self._proc.stdin.write(json.dumps(msg) + "\n") + self._proc.stdin.flush() + + def _recv(self, timeout: float | None = 120.0) -> dict: + try: + msg = self._queue.get(timeout=timeout) + except queue.Empty: + raise TimeoutError(f"[{self.MODEL_ID}] No response from subprocess after {timeout}s") + if msg is None: + raise RuntimeError(f"[{self.MODEL_ID}] Subprocess died unexpectedly") + return msg + + def _ensure_started(self) -> None: + if self._proc is None or self._proc.poll() is not None: + self._start() + + # ------------------------------------------------------------------ # + # BaseGenerator-compatible interface + # ------------------------------------------------------------------ # + + def is_downloaded(self) -> bool: + if self.download_check: + return (self.model_dir / self.download_check).exists() + return self.model_dir.exists() and any(self.model_dir.iterdir()) + + def is_loaded(self) -> bool: + return self._loaded and self._proc is not None and self._proc.poll() is None + + def load(self) -> None: + self._ensure_started() + self._send({"action": "load"}) + + msg = self._recv(timeout=None) # model load can be arbitrarily slow + if msg.get("type") == "loaded": + self._loaded = True + elif msg.get("type") == "error": + raise RuntimeError(msg.get("traceback") or msg.get("message")) + else: + raise RuntimeError(f"[{self.MODEL_ID}] Unexpected response to load: {msg}") + + def unload(self) -> None: + if self._proc and self._proc.poll() is None: + try: + self._send({"action": "unload"}) + self._recv(timeout=30.0) + except Exception: + pass + self._loaded = False + + def generate( + self, + image_bytes: bytes, + params: dict, + progress_cb: Optional[Callable[[int, str], None]] = None, + cancel_event: Optional[threading.Event] = None, + ) -> Path: + from services.generators.base import GenerationCancelled + + req_id = str(uuid.uuid4()) + self._send({ + "action": "generate", + "id": req_id, + "image_b64": base64.b64encode(image_bytes).decode(), + "params": params, + "outputs_dir": str(self.outputs_dir) if self.outputs_dir else 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"): + raise GenerationCancelled() + + # Poll queue with short timeout so we can re-check cancel_event + try: + msg = self._queue.get(timeout=0.5) + except queue.Empty: + continue + + if msg is None: + raise RuntimeError(f"[{self.MODEL_ID}] Subprocess died during generation") + + t = msg.get("type") + + if t == "progress": + if progress_cb: + progress_cb(msg.get("pct", 0), msg.get("step", "")) + + elif t == "done": + return Path(msg["output_path"]) + + elif t == "error": + raise RuntimeError(msg.get("traceback") or msg.get("message", "Unknown error")) + + elif t == "cancelled": + raise GenerationCancelled() + + elif t == "log": + print(f"[{self.MODEL_ID}] {msg.get('message', '')}", file=sys.stderr) + + 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: + try: + self._send({"action": "shutdown"}) + self._proc.wait(timeout=15) + except Exception: + self._proc.kill() + self._loaded = False + self._proc = None diff --git a/api/services/generator_registry.py b/api/services/generator_registry.py index 01854b1..cadca29 100644 --- a/api/services/generator_registry.py +++ b/api/services/generator_registry.py @@ -7,7 +7,6 @@ - generator.py (class extending BaseGenerator) No other file needs to be modified. """ -import base64 import importlib.util import json import os @@ -16,50 +15,7 @@ from typing import Dict, Optional, Tuple from services.generators.base import BaseGenerator - -# ------------------------------------------------------------------ # -# Signature verification -# ------------------------------------------------------------------ # - -_PUBLIC_KEY_PATH = Path(__file__).parent.parent / "resources" / "public_key.pem" - - -def _verify_signature(generator_path: Path, manifest: dict) -> tuple: - """ - Verifies the signature of a generator.py file against the manifest. - - Returns (is_verified: bool, status: str) where status is one of: - "verified" — signature present and valid - "unsigned" — no signature in manifest (third-party extension) - "invalid" — signature present but verification failed (tampered file) - "error" — verification could not be performed - """ - signature_b64 = manifest.get("signature") - - if not signature_b64: - return False, "unsigned" - - if not _PUBLIC_KEY_PATH.exists(): - print(f"[Registry] WARNING: public_key.pem not found at {_PUBLIC_KEY_PATH}, skipping verification") - return False, "error" - - try: - from cryptography.exceptions import InvalidSignature - from cryptography.hazmat.primitives.serialization import load_pem_public_key - - public_key = load_pem_public_key(_PUBLIC_KEY_PATH.read_bytes()) - signature = base64.b64decode(signature_b64) - generator_content = generator_path.read_bytes().replace(b"\r\n", b"\n") - - try: - public_key.verify(signature, generator_content) - return True, "verified" - except InvalidSignature: - return False, "invalid" - - except Exception as exc: - print(f"[Registry] WARNING: Signature verification error: {exc}") - return False, "error" +from services.extension_process import ExtensionProcess, _venv_python # ------------------------------------------------------------------ # # Global paths @@ -117,26 +73,28 @@ def _discover_extensions() -> Dict[str, Tuple[type, dict]]: ext_id = manifest["id"] class_name = manifest["generator_class"] - # Verify signature before loading - is_verified, sig_status = _verify_signature(generator_path, manifest) - manifest["_verified"] = is_verified - manifest["_sig_status"] = sig_status - - if sig_status == "invalid": - print( - f"[Registry] SECURITY: Extension '{ext_dir.name}' has an INVALID signature. " - "The generator.py may have been tampered with. Skipping." - ) + # --- Subprocess mode (new): venv present → use ExtensionProcess --- + if _venv_python(ext_dir).exists(): + variants = [v for v in manifest.get("models", []) if v.get("id") and v.get("hf_repo")] + if variants: + for variant in variants: + variant_manifest = { + **manifest, + "id": variant["id"], + "name": variant.get("name", variant["id"]), + "hf_repo": variant["hf_repo"], + } + for field in ("hf_skip_prefixes", "download_check"): + if field in variant: + variant_manifest[field] = variant[field] + result[variant["id"]] = (None, variant_manifest, ext_dir) + print(f"[Registry] Loaded subprocess variant: {variant['id']} (from '{ext_id}')") + else: + result[ext_id] = (None, manifest, ext_dir) + print(f"[Registry] Loaded subprocess extension: {ext_id}") continue - elif sig_status == "unsigned": - print( - f"[Registry] WARNING: Extension '{ext_dir.name}' is unsigned " - "(unverified third-party extension). Loading with caution." - ) - elif sig_status == "verified": - print(f"[Registry] OK: Extension '{ext_dir.name}' signature verified.") - - # Dynamically load the generator.py module + + # --- Direct mode (legacy): no venv → instantiate generator.py directly --- module_name = f"extensions.{ext_id}.generator" spec = importlib.util.spec_from_file_location(module_name, generator_path) module = importlib.util.module_from_spec(spec) @@ -159,10 +117,10 @@ def _discover_extensions() -> Dict[str, Tuple[type, dict]]: for field in ("hf_skip_prefixes", "download_check"): if field in variant: variant_manifest[field] = variant[field] - result[variant["id"]] = (cls, variant_manifest) + result[variant["id"]] = (cls, variant_manifest, None) print(f"[Registry] Loaded extension variant: {variant['id']} (from '{ext_id}')") else: - result[ext_id] = (cls, manifest) + result[ext_id] = (cls, manifest, None) print(f"[Registry] Loaded extension: {ext_id} ({class_name})") except Exception as exc: @@ -186,17 +144,24 @@ def initialize(self) -> None: """Discovers and instantiates all extensions. Call at startup.""" extensions = _discover_extensions() - for model_id, (cls, manifest) in extensions.items(): + for model_id, entry in extensions.items(): + cls, manifest, ext_dir = entry try: - gen = cls(MODELS_DIR / model_id, WORKSPACE_DIR) - # Inject manifest fields onto the generator - gen.hf_repo = manifest.get("hf_repo", "") - gen.hf_skip_prefixes = manifest.get("hf_skip_prefixes", []) - gen.download_check = manifest.get("download_check", "") - gen._params_schema = manifest.get("params_schema", []) + if cls is None: + # Subprocess mode: wrap in ExtensionProcess + gen = ExtensionProcess(ext_dir, manifest) + gen.model_dir = MODELS_DIR / model_id + gen.outputs_dir = WORKSPACE_DIR + else: + # Legacy direct mode + gen = cls(MODELS_DIR / model_id, WORKSPACE_DIR) + gen.hf_repo = manifest.get("hf_repo", "") + gen.hf_skip_prefixes = manifest.get("hf_skip_prefixes", []) + gen.download_check = manifest.get("download_check", "") + gen._params_schema = manifest.get("params_schema", []) + self._generators[model_id] = gen self._manifests[model_id] = manifest - # Clear any previous error for this extension self._errors.pop(model_id, None) except Exception as exc: msg = f"Failed to instantiate generator '{model_id}': {exc}" @@ -307,8 +272,6 @@ def all_status(self) -> list: "downloaded": gen.is_downloaded(), "loaded": gen.is_loaded(), "active": model_id == self._active_id, - "verified": manifest.get("_verified", False), - "sig_status": manifest.get("_sig_status", "unsigned"), }) return result @@ -341,7 +304,10 @@ def update_paths(self, models_dir: Optional[Path], workspace_dir: Optional[Path] def unload_all(self) -> None: for gen in self._generators.values(): - gen.unload() + if isinstance(gen, ExtensionProcess): + gen.stop() + else: + gen.unload() # Singleton diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 831dda3..abea1df 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -514,7 +514,22 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe } await cp(extractDir, destDir, { recursive: true }) - // 6. Hot-reload Python registry + // 6. Run setup.py to create the extension's isolated venv (if any) + if (existsSync(join(destDir, 'setup.py'))) { + emit({ step: 'setting_up' }) + try { + await axios.post( + `${API_BASE_URL}/extensions/setup/${manifest.id}`, + {}, + { timeout: 20 * 60 * 1000 } // 20 min — PyTorch download can be slow + ) + } catch (setupErr: any) { + const detail = setupErr?.response?.data?.detail ?? setupErr?.message ?? 'Unknown error' + throw new Error(`Extension setup failed: ${detail}`) + } + } + + // 7. Hot-reload Python registry try { await axios.post(`${API_BASE_URL}/extensions/reload`, {}, { timeout: 10_000 }) } catch { /* Python might not be running yet */ } diff --git a/src/areas/models/ModelsPage.tsx b/src/areas/models/ModelsPage.tsx index 12198ae..4de767e 100644 --- a/src/areas/models/ModelsPage.tsx +++ b/src/areas/models/ModelsPage.tsx @@ -140,11 +140,12 @@ export default function ModelsPage(): JSX.Element { function installProgressLabel(): string { if (!installProgress) return '' switch (installProgress.step) { - case 'downloading': return `Downloading… ${installProgress.percent ?? 0}%` - case 'extracting': return 'Extracting…' - case 'validating': return 'Validating…' - case 'done': return 'Installed!' - default: return '' + case 'downloading': return `Downloading… ${installProgress.percent ?? 0}%` + case 'extracting': return 'Extracting…' + case 'validating': return 'Validating…' + case 'setting_up': return 'Setting up environment…' + case 'done': return 'Installed!' + default: return '' } } From 9d237f2489ec8ea21737466272bcf70e41f0fb2f Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Sat, 28 Mar 2026 18:33:53 +0100 Subject: [PATCH 02/35] add workflow system --- electron/main/ipc-handlers.ts | 77 +++ electron/preload/index.ts | 9 + src/areas/workflows/WorkflowsPage.tsx | 594 +++++++++++++++++++++++ src/shared/components/layout/Sidebar.tsx | 13 + src/shared/router/routes.tsx | 10 +- src/shared/stores/navStore.ts | 2 +- src/shared/stores/workflowsStore.ts | 73 +++ src/shared/types/electron.d.ts | 24 + 8 files changed, 797 insertions(+), 5 deletions(-) create mode 100644 src/areas/workflows/WorkflowsPage.tsx create mode 100644 src/shared/stores/workflowsStore.ts diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index abea1df..7f0c14b 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -607,4 +607,81 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe return { success: false, error: String(err) } } }) + + // ── Workflows ──────────────────────────────────────────────────────────── + + function workflowsDir(): string { + const dir = join(app.getPath('userData'), 'workflows') + if (!existsSync(dir)) require('fs').mkdirSync(dir, { recursive: true }) + return dir + } + + ipcMain.handle('workflows:list', async () => { + const dir = workflowsDir() + const files = readdirSync(dir).filter(f => f.endsWith('.json')) + const workflows = [] + for (const file of files) { + try { + const raw = await readFile(join(dir, file), 'utf-8') + workflows.push(JSON.parse(raw)) + } catch { /* skip corrupted files */ } + } + return workflows.sort((a: { updatedAt?: string }, b: { updatedAt?: string }) => + (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') + ) + }) + + ipcMain.handle('workflows:save', async (_, workflow: { id: string; [key: string]: unknown }) => { + try { + const path = join(workflowsDir(), `${workflow.id}.json`) + await writeFile(path, JSON.stringify(workflow, null, 2), 'utf-8') + return { success: true } + } catch (err) { + return { success: false, error: String(err) } + } + }) + + ipcMain.handle('workflows:delete', async (_, id: string) => { + try { + await rmAsync(join(workflowsDir(), `${id}.json`), { force: true }) + return { success: true } + } catch (err) { + return { success: false, error: String(err) } + } + }) + + ipcMain.handle('workflows:import', async () => { + const win = getWindow() + const result = await dialog.showOpenDialog(win!, { + title: 'Import Workflow', + filters: [{ name: 'Workflow', extensions: ['json'] }], + properties: ['openFile'], + }) + if (result.canceled || result.filePaths.length === 0) return { success: false } + try { + const raw = await readFile(result.filePaths[0], 'utf-8') + const workflow = JSON.parse(raw) + if (!workflow.id || !workflow.nodes) return { success: false, error: 'Invalid workflow file' } + await writeFile(join(workflowsDir(), `${workflow.id}.json`), JSON.stringify(workflow, null, 2), 'utf-8') + return { success: true, workflow } + } catch (err) { + return { success: false, error: String(err) } + } + }) + + ipcMain.handle('workflows:export', async (_, workflow: { id: string; name?: string; [key: string]: unknown }) => { + const win = getWindow() + const result = await dialog.showSaveDialog(win!, { + title: 'Export Workflow', + defaultPath: `${workflow.name ?? workflow.id}.json`, + filters: [{ name: 'Workflow', extensions: ['json'] }], + }) + if (result.canceled || !result.filePath) return { success: false } + try { + await writeFile(result.filePath, JSON.stringify(workflow, null, 2), 'utf-8') + return { success: true } + } catch (err) { + return { success: false, error: String(err) } + } + }) } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index ba80015..44eeb29 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -150,6 +150,15 @@ contextBridge.exposeInMainWorld('electron', { offInstallProgress: () => ipcRenderer.removeAllListeners('extensions:installProgress'), }, + // Workflows + workflows: { + list: (): Promise => ipcRenderer.invoke('workflows:list'), + save: (workflow: { id: string; [key: string]: unknown }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('workflows:save', workflow), + delete: (id: string): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('workflows:delete', id), + import: (): Promise<{ success: boolean; error?: string; workflow?: unknown }> => ipcRenderer.invoke('workflows:import'), + export: (workflow: { id: string; name?: string; [key: string]: unknown }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('workflows:export', workflow), + }, + // Auto-updater updater: { check: (): Promise<{ success: boolean }> => diff --git a/src/areas/workflows/WorkflowsPage.tsx b/src/areas/workflows/WorkflowsPage.tsx new file mode 100644 index 0000000..4b7d340 --- /dev/null +++ b/src/areas/workflows/WorkflowsPage.tsx @@ -0,0 +1,594 @@ +import { useEffect, useState } from 'react' +import { useWorkflowsStore } from '@shared/stores/workflowsStore' +import { useExtensionsStore } from '@shared/stores/extensionsStore' +import type { Workflow, WorkflowBlock } from '@shared/types/electron.d' + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function newId(): string { + return crypto.randomUUID() +} + +function newWorkflow(): Workflow { + const now = new Date().toISOString() + return { + id: newId(), + name: 'New Workflow', + description: '', + input: 'image', + blocks: [], + createdAt: now, + updatedAt: now, + } +} + +function newBlock(extensionId: string): WorkflowBlock { + return { id: newId(), extension: extensionId, enabled: true, params: {} } +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function WorkflowCard({ + workflow, active, onClick, +}: { workflow: Workflow; active: boolean; onClick: () => void }) { + return ( + + ) +} + +function BlockRow({ + block, extensionName, onToggle, onRemove, onMoveUp, onMoveDown, isFirst, isLast, +}: { + block: WorkflowBlock + extensionName: string + onToggle: () => void + onRemove: () => void + onMoveUp: () => void + onMoveDown: () => void + isFirst: boolean + isLast: boolean +}) { + return ( +
+ {/* Enabled toggle */} + + + {/* Extension name */} +
+

{extensionName}

+

{block.extension}

+
+ + {/* Reorder */} +
+ + +
+ + {/* Remove */} + +
+ ) +} + +// ─── Add Block Picker ───────────────────────────────────────────────────────── + +function AddBlockPicker({ + usedExtensions, + onSelect, + onClose, +}: { + usedExtensions: string[] + onSelect: (extensionId: string) => void + onClose: () => void +}) { + const extensions = useExtensionsStore((s) => s.extensions) + + // Flatten variants: each model variant is a potential block + const options = extensions.flatMap((ext) => + ext.models.map((m) => ({ id: m.id, name: `${ext.name} — ${m.name}`, extName: ext.name })) + ) + + return ( +
+ {options.length === 0 ? ( +

No extensions installed.

+ ) : ( +
+ {options.map((opt) => ( + + ))} +
+ )} + +
+ ) +} + +// ─── Workflow Editor ────────────────────────────────────────────────────────── + +function WorkflowEditor({ + workflow, + onSave, + onDelete, + onExport, +}: { + workflow: Workflow + onSave: (w: Workflow) => void + onDelete: () => void + onExport: () => void +}) { + const [draft, setDraft] = useState(workflow) + const [showPicker, setShowPicker] = useState(false) + const [dirty, setDirty] = useState(false) + const extensions = useExtensionsStore((s) => s.extensions) + + useEffect(() => { + setDraft(workflow) + setDirty(false) + }, [workflow.id]) + + function patch(partial: Partial) { + setDraft((d) => ({ ...d, ...partial })) + setDirty(true) + } + + function patchBlock(blockId: string, partial: Partial) { + setDraft((d) => ({ + ...d, + blocks: d.blocks.map((b) => b.id === blockId ? { ...b, ...partial } : b), + })) + setDirty(true) + } + + function addBlock(extensionId: string) { + setDraft((d) => ({ ...d, blocks: [...d.blocks, newBlock(extensionId)] })) + setDirty(true) + } + + function removeBlock(blockId: string) { + setDraft((d) => ({ ...d, blocks: d.blocks.filter((b) => b.id !== blockId) })) + setDirty(true) + } + + function moveBlock(blockId: string, direction: 'up' | 'down') { + setDraft((d) => { + const blocks = [...d.blocks] + const idx = blocks.findIndex((b) => b.id === blockId) + const swap = direction === 'up' ? idx - 1 : idx + 1 + if (swap < 0 || swap >= blocks.length) return d; + [blocks[idx], blocks[swap]] = [blocks[swap], blocks[idx]] + return { ...d, blocks } + }) + setDirty(true) + } + + function handleSave() { + const saved = { ...draft, updatedAt: new Date().toISOString() } + onSave(saved) + setDirty(false) + } + + function extensionName(extensionId: string): string { + for (const ext of extensions) { + const model = ext.models.find((m) => m.id === extensionId) + if (model) return `${ext.name} — ${model.name}` + } + return extensionId + } + + const usedIds = draft.blocks.map((b) => b.extension) + + return ( +
+ {/* Toolbar */} +
+

Edit Workflow

+
+ + + {dirty && ( + + )} +
+
+ + {/* Form */} +
+ + {/* Metadata */} +
+
+
+ + patch({ name: e.target.value })} + className="w-full bg-zinc-800/60 border border-zinc-700 rounded-lg px-3 py-1.5 text-xs text-zinc-200 focus:outline-none focus:border-accent/60" + placeholder="Workflow name" + /> +
+
+ + +
+
+
+ + patch({ description: e.target.value })} + className="w-full bg-zinc-800/60 border border-zinc-700 rounded-lg px-3 py-1.5 text-xs text-zinc-200 focus:outline-none focus:border-accent/60" + placeholder="Optional description" + /> +
+
+ + {/* Blocks */} +
+
+ +
+ + {showPicker && ( + setShowPicker(false)} + /> + )} +
+
+ + {draft.blocks.length === 0 ? ( +
+ + + + + +

No blocks yet — add one above

+
+ ) : ( +
+ {draft.blocks.map((block, idx) => ( + patchBlock(block.id, { enabled: !block.enabled })} + onRemove={() => removeBlock(block.id)} + onMoveUp={() => moveBlock(block.id, 'up')} + onMoveDown={() => moveBlock(block.id, 'down')} + isFirst={idx === 0} + isLast={idx === draft.blocks.length - 1} + /> + ))} +
+ )} +
+
+
+ ) +} + +// ─── Tab bar ────────────────────────────────────────────────────────────────── + +function TabBar({ + openIds, + activeId, + workflows, + onActivate, + onClose, + onNew, +}: { + openIds: string[] + activeId: string | null + workflows: Workflow[] + onActivate: (id: string) => void + onClose: (id: string) => void + onNew: () => void +}) { + return ( +
+ {openIds.map((id) => { + const wf = workflows.find((w) => w.id === id) + const active = id === activeId + return ( +
onActivate(id)} + > + + {wf?.name || 'Untitled'} + + +
+ ) + })} + + {/* New tab button */} + +
+ ) +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function WorkflowsPage(): JSX.Element { + const { workflows, loading, activeId, load, save, remove, importFile, exportFile, setActive } = useWorkflowsStore() + const loadExtensions = useExtensionsStore((s) => s.loadExtensions) + const [openIds, setOpenIds] = useState([]) + + useEffect(() => { + load() + loadExtensions() + }, []) + + // Keep openIds in sync: remove tabs for deleted workflows + useEffect(() => { + const validIds = workflows.map((w) => w.id) + setOpenIds((prev) => prev.filter((id) => validIds.includes(id))) + }, [workflows]) + + function openTab(id: string) { + setOpenIds((prev) => prev.includes(id) ? prev : [...prev, id]) + setActive(id) + } + + function closeTab(id: string) { + const idx = openIds.indexOf(id) + const next = openIds[idx + 1] ?? openIds[idx - 1] ?? null + setOpenIds((prev) => prev.filter((i) => i !== id)) + setActive(next) + } + + const activeWorkflow = workflows.find((w) => w.id === activeId) ?? null + + async function handleCreate() { + const wf = newWorkflow() + await save(wf) + openTab(wf.id) + } + + async function handleDelete() { + if (!activeWorkflow) return + await remove(activeWorkflow.id) + } + + async function handleImport() { + const result = await importFile() + if (result.success && result.workflow) { + openTab((result.workflow as Workflow).id) + } + } + + return ( +
+ + {/* Left panel — workflow list */} +
+ {/* Header */} +
+

Workflows

+
+ + +
+
+ + {/* List */} +
+ {loading ? ( +

Loading…

+ ) : workflows.length === 0 ? ( +
+ + + + +

No workflows yet.
Create one to get started.

+
+ ) : ( + workflows.map((wf) => ( + openTab(wf.id)} + /> + )) + )} +
+
+ + {/* Right panel — tabs + editor */} +
+ + +
+ {activeWorkflow ? ( + exportFile(activeWorkflow)} + /> + ) : ( +
+ + + + +
+

Open a workflow

+

or create a new one

+
+ +
+ )} +
+
+
+ ) +} diff --git a/src/shared/components/layout/Sidebar.tsx b/src/shared/components/layout/Sidebar.tsx index 00d6964..bc81123 100644 --- a/src/shared/components/layout/Sidebar.tsx +++ b/src/shared/components/layout/Sidebar.tsx @@ -11,6 +11,19 @@ const NAV_ITEMS: { id: Page; label: string; icon: JSX.Element }[] = [ ) }, + { + id: 'workflows', + label: 'Workflows', + icon: ( + + + + + + + + ) + }, { id: 'models', label: 'Models', diff --git a/src/shared/router/routes.tsx b/src/shared/router/routes.tsx index f8e14eb..922a98d 100644 --- a/src/shared/router/routes.tsx +++ b/src/shared/router/routes.tsx @@ -1,10 +1,11 @@ import { lazy } from 'react' import type { Page } from '@shared/stores/navStore' -const GeneratePage = lazy(() => import('@areas/generate/GeneratePage')) -const ModelsPage = lazy(() => import('@areas/models/ModelsPage')) -const WorkspacePage = lazy(() => import('@areas/workspace/WorkspacePage')) -const SettingsPage = lazy(() => import('@areas/settings/SettingsPage')) +const GeneratePage = lazy(() => import('@areas/generate/GeneratePage')) +const WorkflowsPage = lazy(() => import('@areas/workflows/WorkflowsPage')) +const ModelsPage = lazy(() => import('@areas/models/ModelsPage')) +const WorkspacePage = lazy(() => import('@areas/workspace/WorkspacePage')) +const SettingsPage = lazy(() => import('@areas/settings/SettingsPage')) export interface RouteConfig { component: React.ComponentType @@ -13,6 +14,7 @@ export interface RouteConfig { export const ROUTES: Record = { generate: { component: GeneratePage, wrapperClass: 'flex flex-1 overflow-hidden' }, + workflows: { component: WorkflowsPage, wrapperClass: 'flex flex-1 overflow-hidden' }, models: { component: ModelsPage, wrapperClass: 'flex-1 overflow-y-auto' }, workspace: { component: WorkspacePage, wrapperClass: 'flex flex-1 overflow-hidden' }, settings: { component: SettingsPage, wrapperClass: 'flex-1 overflow-hidden' }, diff --git a/src/shared/stores/navStore.ts b/src/shared/stores/navStore.ts index 1495e68..cdce059 100644 --- a/src/shared/stores/navStore.ts +++ b/src/shared/stores/navStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' -export type Page = 'generate' | 'models' | 'workspace' | 'settings' +export type Page = 'generate' | 'workflows' | 'models' | 'workspace' | 'settings' interface NavState { currentPage: Page diff --git a/src/shared/stores/workflowsStore.ts b/src/shared/stores/workflowsStore.ts new file mode 100644 index 0000000..43b1f59 --- /dev/null +++ b/src/shared/stores/workflowsStore.ts @@ -0,0 +1,73 @@ +import { create } from 'zustand' +import type { Workflow } from '@shared/types/electron.d' + +interface WorkflowsStore { + workflows: Workflow[] + loading: boolean + activeId: string | null + + load: () => Promise + save: (workflow: Workflow) => Promise<{ success: boolean; error?: string }> + remove: (id: string) => Promise<{ success: boolean; error?: string }> + importFile: () => Promise<{ success: boolean; error?: string }> + exportFile: (workflow: Workflow) => Promise<{ success: boolean; error?: string }> + setActive: (id: string | null) => void +} + +export const useWorkflowsStore = create((set, get) => ({ + workflows: [], + loading: false, + activeId: null, + + async load() { + set({ loading: true }) + try { + const list = await window.electron.workflows.list() + set({ workflows: list, loading: false }) + } catch { + set({ loading: false }) + } + }, + + async save(workflow) { + const result = await window.electron.workflows.save(workflow) + if (result.success) { + set((s) => { + const filtered = s.workflows.filter((w) => w.id !== workflow.id) + return { workflows: [workflow, ...filtered] } + }) + } + return result + }, + + async remove(id) { + const result = await window.electron.workflows.delete(id) + if (result.success) { + set((s) => ({ + workflows: s.workflows.filter((w) => w.id !== id), + activeId: s.activeId === id ? null : s.activeId, + })) + } + return result + }, + + async importFile() { + const result = await window.electron.workflows.import() + if (result.success && result.workflow) { + const wf = result.workflow as Workflow + set((s) => { + const filtered = s.workflows.filter((w) => w.id !== wf.id) + return { workflows: [wf, ...filtered], activeId: wf.id } + }) + } + return result + }, + + async exportFile(workflow) { + return window.electron.workflows.export(workflow) + }, + + setActive(id) { + set({ activeId: id }) + }, +})) diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index 708fa63..dfa1b5b 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -1,6 +1,23 @@ // Type declarations for the Electron API exposed via preload export {} +export interface WorkflowBlock { + id: string + extension: string + enabled: boolean + params: Record +} + +export interface Workflow { + id: string + name: string + description: string + input: 'image' | 'text' + blocks: WorkflowBlock[] + createdAt: string + updatedAt: string +} + declare global { interface Window { electron: { @@ -83,6 +100,13 @@ declare global { onError: (cb: (data: { message: string }) => void) => void offError: () => void } + workflows: { + list: () => Promise + save: (workflow: Workflow) => Promise<{ success: boolean; error?: string }> + delete: (id: string) => Promise<{ success: boolean; error?: string }> + import: () => Promise<{ success: boolean; error?: string; workflow?: Workflow }> + export: (workflow: Workflow) => Promise<{ success: boolean; error?: string }> + } updater: { check: () => Promise<{ success: boolean }> quitAndInstall: () => Promise From 0c5824e8dd5bc28145162bd298a296c22d79fd0a Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Sat, 28 Mar 2026 19:27:12 +0100 Subject: [PATCH 03/35] feat(workflow): improve workflow page --- src/areas/workflows/WorkflowsPage.tsx | 1098 +++++++++++++++++-------- src/shared/stores/workflowsStore.ts | 6 +- 2 files changed, 760 insertions(+), 344 deletions(-) diff --git a/src/areas/workflows/WorkflowsPage.tsx b/src/areas/workflows/WorkflowsPage.tsx index 4b7d340..d6dc648 100644 --- a/src/areas/workflows/WorkflowsPage.tsx +++ b/src/areas/workflows/WorkflowsPage.tsx @@ -1,9 +1,185 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useWorkflowsStore } from '@shared/stores/workflowsStore' import { useExtensionsStore } from '@shared/stores/extensionsStore' +import { useNavStore } from '@shared/stores/navStore' import type { Workflow, WorkflowBlock } from '@shared/types/electron.d' -// ─── Helpers ───────────────────────────────────────────────────────────────── +// ─── Mock extensions ────────────────────────────────────────────────────────── + +interface ParamSchema { + id: string + label: string + type: 'select' | 'int' | 'float' + default: number | string + options?: { value: number | string; label: string }[] + min?: number + max?: number + step?: number +} + +interface MockExtension { + id: string + name: string + description: string + category: 'preprocessor' | 'generator' | 'postprocessor' + input: 'image' | 'text' | 'mesh' + output: 'image' | 'mesh' + params: ParamSchema[] +} + +const MOCK_EXTENSIONS: MockExtension[] = [ + { + id: 'background-removal', + name: 'Background Removal', + description: 'Remove background from image using rembg', + category: 'preprocessor', + input: 'image', + output: 'image', + params: [ + { id: 'model', label: 'Model', type: 'select', default: 'u2net', + options: [{ value: 'u2net', label: 'u2net' }, { value: 'isnet', label: 'ISNet' }, { value: 'birefnet', label: 'BiRefNet' }] }, + ], + }, + { + id: 'triposr', + name: 'TripoSR', + description: 'Fast single-image 3D reconstruction (~6GB VRAM)', + category: 'generator', + input: 'image', + output: 'mesh', + params: [ + { id: 'foreground_ratio', label: 'Foreground Ratio', type: 'float', default: 0.85, min: 0.5, max: 1.0, step: 0.05 }, + { id: 'resolution', label: 'Resolution', type: 'int', default: 256, min: 128, max: 512 }, + ], + }, + { + id: 'triposg', + name: 'TripoSG', + description: 'High-quality image-to-3D via flow matching (~8GB VRAM)', + category: 'generator', + input: 'image', + output: 'mesh', + params: [ + { id: 'num_inference_steps', label: 'Steps', type: 'int', default: 50, min: 8, max: 50 }, + { id: 'guidance_scale', label: 'CFG Scale', type: 'float', default: 7.0, min: 0.0, max: 20.0, step: 0.5 }, + { id: 'seed', label: 'Seed', type: 'int', default: 42, min: 0, max: 2147483647 }, + { id: 'decoder', label: 'Decoder', type: 'select', default: 'DiffDMC', + options: [{ value: 'DiffDMC', label: 'DiffDMC' }, { value: 'marching_cubes', label: 'Marching Cubes' }] }, + ], + }, + { + id: 'hunyuan3d-mini', + name: 'Hunyuan3D Mini', + description: 'Lightweight 0.6B model, fast generation', + category: 'generator', + input: 'image', + output: 'mesh', + params: [ + { id: 'quality', label: 'Quality', type: 'select', default: 30, + options: [{ value: 10, label: 'Fast' }, { value: 30, label: 'Balanced' }, { value: 50, label: 'High' }] }, + { id: 'octree_resolution', label: 'Mesh Resolution', type: 'select', default: 380, + options: [{ value: 256, label: 'Low' }, { value: 380, label: 'Medium' }, { value: 512, label: 'High' }] }, + { id: 'guidance_scale', label: 'CFG Scale', type: 'float', default: 5.5, min: 1.0, max: 10.0, step: 0.5 }, + { id: 'seed', label: 'Seed', type: 'int', default: 42, min: 0, max: 2147483647 }, + ], + }, + { + id: 'hunyuan3d-mini-turbo', + name: 'Hunyuan3D Mini Turbo', + description: 'Faster variant of Hunyuan3D Mini', + category: 'generator', + input: 'image', + output: 'mesh', + params: [ + { id: 'guidance_scale', label: 'CFG Scale', type: 'float', default: 5.5, min: 1.0, max: 10.0, step: 0.5 }, + { id: 'seed', label: 'Seed', type: 'int', default: 42, min: 0, max: 2147483647 }, + ], + }, + { + id: 'trellis-2', + name: 'TRELLIS 2', + description: 'High-fidelity image-to-3D with PBR textures (~24GB VRAM)', + category: 'generator', + input: 'image', + output: 'mesh', + params: [ + { id: 'steps', label: 'Steps', type: 'int', default: 50, min: 10, max: 100 }, + { id: 'guidance_scale', label: 'CFG Scale', type: 'float', default: 7.5, min: 1.0, max: 15.0, step: 0.5 }, + { id: 'seed', label: 'Seed', type: 'int', default: 42, min: 0, max: 2147483647 }, + ], + }, + { + id: 'mesh-optimizer', + name: 'Mesh Optimizer', + description: 'Reduce polygon count while preserving shape', + category: 'postprocessor', + input: 'mesh', + output: 'mesh', + params: [ + { id: 'target_faces', label: 'Target Faces', type: 'int', default: 50000, min: 1000, max: 500000 }, + { id: 'method', label: 'Method', type: 'select', default: 'quadric', + options: [{ value: 'quadric', label: 'Quadric' }, { value: 'angle', label: 'Angle' }] }, + ], + }, + { + id: 'texture-baker', + name: 'Texture Baker', + description: 'Bake and optimize PBR textures on the mesh', + category: 'postprocessor', + input: 'mesh', + output: 'mesh', + params: [ + { id: 'resolution', label: 'Resolution', type: 'select', default: 1024, + options: [{ value: 512, label: '512px' }, { value: 1024, label: '1024px' }, { value: 2048, label: '2048px' }] }, + { id: 'format', label: 'Format', type: 'select', default: 'png', + options: [{ value: 'png', label: 'PNG' }, { value: 'jpg', label: 'JPG' }] }, + ], + }, +] + +function getMockExtension(id: string): MockExtension | undefined { + return MOCK_EXTENSIONS.find((e) => e.id === id) +} + +// ─── Category styles ────────────────────────────────────────────────────────── + +const CATEGORY_STYLES: Record = { + preprocessor: { + border: 'border-l-sky-500', + bg: 'bg-sky-500/10', + text: 'text-sky-400', + dot: 'bg-sky-500', + glowBorder: 'border-sky-500/20', + glowBorderHover: 'hover:border-sky-500/45', + glowShadow: 'shadow-sky-500/10', + gradient: 'from-sky-500/8', + chipBg: 'bg-sky-500/10', + }, + generator: { + border: 'border-l-violet-500', + bg: 'bg-violet-500/10', + text: 'text-violet-400', + dot: 'bg-violet-500', + glowBorder: 'border-violet-500/20', + glowBorderHover: 'hover:border-violet-500/45', + glowShadow: 'shadow-violet-500/10', + gradient: 'from-violet-500/8', + chipBg: 'bg-violet-500/10', + }, + postprocessor: { + border: 'border-l-emerald-500', + bg: 'bg-emerald-500/10', + text: 'text-emerald-400', + dot: 'bg-emerald-500', + glowBorder: 'border-emerald-500/20', + glowBorderHover: 'hover:border-emerald-500/45', + glowShadow: 'shadow-emerald-500/10', + gradient: 'from-emerald-500/8', + chipBg: 'bg-emerald-500/10', + }, +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── function newId(): string { return crypto.randomUUID() @@ -11,272 +187,533 @@ function newId(): string { function newWorkflow(): Workflow { const now = new Date().toISOString() - return { - id: newId(), - name: 'New Workflow', - description: '', - input: 'image', - blocks: [], - createdAt: now, - updatedAt: now, - } + return { id: newId(), name: 'New Workflow', description: '', input: 'image', blocks: [], createdAt: now, updatedAt: now } } function newBlock(extensionId: string): WorkflowBlock { return { id: newId(), extension: extensionId, enabled: true, params: {} } } -// ─── Sub-components ─────────────────────────────────────────────────────────── +// ─── Block card ─────────────────────────────────────────────────────────────── + +function ParamControl({ param, value, onChange }: { + param: ParamSchema + value: number | string + onChange: (v: number | string) => void +}) { + const inputClass = "w-full bg-zinc-800 border border-zinc-700 rounded-lg px-2 py-1 text-[11px] text-zinc-200 focus:outline-none focus:border-accent/60" + + if (param.type === 'select') { + return ( + + ) + } -function WorkflowCard({ - workflow, active, onClick, -}: { workflow: Workflow; active: boolean; onClick: () => void }) { return ( - + onChange(param.type === 'float' ? parseFloat(e.target.value) : parseInt(e.target.value, 10))} + className={inputClass} + /> ) } -function BlockRow({ - block, extensionName, onToggle, onRemove, onMoveUp, onMoveDown, isFirst, isLast, +function BlockCard({ + block, onToggle, onRemove, onPatchParam, }: { - block: WorkflowBlock - extensionName: string - onToggle: () => void - onRemove: () => void - onMoveUp: () => void - onMoveDown: () => void - isFirst: boolean - isLast: boolean + block: WorkflowBlock + onToggle: () => void + onRemove: () => void + onPatchParam: (key: string, value: number | string) => void }) { + const ext = getMockExtension(block.extension) + const category = ext?.category ?? 'generator' + const styles = CATEGORY_STYLES[category] + const categoryLabel = category === 'preprocessor' ? 'Preprocessor' : category === 'generator' ? 'Generator' : 'Post-processor' + + const [expanded, setExpanded] = useState(true) + const hasParams = ext && ext.params.length > 0 + return ( -
- {/* Enabled toggle */} +
{ + e.dataTransfer.setData(BLOCK_DRAG_KEY, block.id) + e.dataTransfer.effectAllowed = 'move' + }} + className={`group relative w-full rounded-lg border border-zinc-800 bg-zinc-900 transition-colors hover:border-zinc-700 cursor-grab active:cursor-grabbing ${!block.enabled ? 'opacity-40' : ''}`} + > + + {/* Remove button — half outside top-right corner, hover only */} - {/* Extension name */} -
-

{extensionName}

-

{block.extension}

-
+ {/* Header row */} +
- {/* Reorder */} -
- + {/* Left: dot + name + toggle */} +
+
+
+

{ext?.name ?? block.extension}

+

{ext?.description ?? ''}

+
+ +
+ + {/* Chevron — far right */}
- {/* Remove */} - + {/* Params */} + {expanded && ( +
+ {hasParams ? ext.params.map((param) => { + const val = (block.params[param.id] ?? param.default) as number | string + return ( +
+ +
+ onPatchParam(param.id, v)} /> +
+
+ ) + }) : ( +

No parameters

+ )} +
+ )} +
+ ) +} + +// ─── Connector arrow ────────────────────────────────────────────────────────── + +function Connector() { + return ( +
+
+ + +
) } -// ─── Add Block Picker ───────────────────────────────────────────────────────── +// ─── Add block picker ───────────────────────────────────────────────────────── function AddBlockPicker({ - usedExtensions, - onSelect, - onClose, + usedIds, onSelect, onClose, }: { - usedExtensions: string[] - onSelect: (extensionId: string) => void - onClose: () => void + usedIds: string[] + onSelect: (id: string) => void + onClose: () => void }) { - const extensions = useExtensionsStore((s) => s.extensions) + const ref = useRef(null) - // Flatten variants: each model variant is a potential block - const options = extensions.flatMap((ext) => - ext.models.map((m) => ({ id: m.id, name: `${ext.name} — ${m.name}`, extName: ext.name })) + useEffect(() => { + function onClickOutside(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) onClose() + } + document.addEventListener('mousedown', onClickOutside) + return () => document.removeEventListener('mousedown', onClickOutside) + }, [onClose]) + + const groups: Record = { + preprocessor: MOCK_EXTENSIONS.filter((e) => e.category === 'preprocessor'), + generator: MOCK_EXTENSIONS.filter((e) => e.category === 'generator'), + postprocessor: MOCK_EXTENSIONS.filter((e) => e.category === 'postprocessor'), + } + + const groupLabels: Record = { + preprocessor: 'Preprocessors', + generator: 'Generators', + postprocessor: 'Post-processors', + } + + return ( +
+
+

Add a block

+
+
+ {(Object.keys(groups) as MockExtension['category'][]).map((cat) => ( +
+

+ {groupLabels[cat]} +

+ {groups[cat].map((ext) => { + const used = usedIds.includes(ext.id) + return ( + + ) + })} +
+ ))} +
+
) +} + +// ─── Pipeline canvas ────────────────────────────────────────────────────────── +const DRAG_KEY = 'modly/extension-id' +const BLOCK_DRAG_KEY = 'modly/block-id' + +function DropZone({ index, active, onDrop, onDragOver, onDragLeave }: { + index: number + active: boolean + onDrop: (index: number, id: string, type: 'extension' | 'block') => void + onDragOver: (index: number) => void + onDragLeave: () => void +}) { return ( -
- {options.length === 0 ? ( -

No extensions installed.

- ) : ( -
- {options.map((opt) => ( - - ))} +
{ e.preventDefault(); onDragOver(index) }} + onDragLeave={onDragLeave} + onDrop={(e) => { + e.preventDefault() + const extId = e.dataTransfer.getData(DRAG_KEY) + const blockId = e.dataTransfer.getData(BLOCK_DRAG_KEY) + if (extId) onDrop(index, extId, 'extension') + else if (blockId) onDrop(index, blockId, 'block') + }} + > +
+
+
+ Drop here
+
+ {active &&
} + {!active && ( + + + )} -
) } -// ─── Workflow Editor ────────────────────────────────────────────────────────── +function PipelineCanvas({ + draft, onPatch, onAddBlock, onInsertBlock, onRemoveBlock, onReorderBlock, onPatchBlock, +}: { + draft: Workflow + onPatch: (p: Partial) => void + onAddBlock: (id: string) => void + onInsertBlock: (id: string, atIndex: number) => void + onRemoveBlock: (id: string) => void + onReorderBlock: (id: string, toIndex: number) => void + onPatchBlock: (id: string, p: Partial) => void +}) { + const [showPicker, setShowPicker] = useState(false) + const [activeDropZone, setActiveDropZone] = useState(null) + const usedIds = draft.blocks.map((b) => b.extension) + + function handleDrop(index: number, id: string, type: 'extension' | 'block') { + setActiveDropZone(null) + if (type === 'extension') { + if (usedIds.includes(id)) return + onInsertBlock(id, index) + } else { + onReorderBlock(id, index) + } + } + + return ( +
+ + {/* Input block */} +
+
+ {/* Header */} +
+
+
+ + + +
+ Input +
+ {/* Type toggle */} +
+ {(['image', 'text'] as const).map((t) => ( + + ))} +
+
+ + {/* Content area */} + {draft.input === 'image' ? ( +
+ + + + + + Drop an image here or click to browse +
+ ) : ( +