From 16ff3772aae55d6c6277b07ffd27cc660546d0bc Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Thu, 26 Mar 2026 11:37:34 +0100 Subject: [PATCH] fix(models): fix extension trusted blocked --- api/services/generators/hunyuan3d.py | 250 ----------- api/services/generators/hunyuan3d_mini.py | 400 ------------------ api/services/generators/instantmesh.py | 57 --- api/services/generators/sf3d.py | 212 ---------- api/services/generators/triposr.py | 60 --- package.json | 2 +- src/areas/models/components/ExtensionCard.tsx | 8 +- 7 files changed, 5 insertions(+), 984 deletions(-) delete mode 100644 api/services/generators/hunyuan3d.py delete mode 100644 api/services/generators/hunyuan3d_mini.py delete mode 100644 api/services/generators/instantmesh.py delete mode 100644 api/services/generators/sf3d.py delete mode 100644 api/services/generators/triposr.py diff --git a/api/services/generators/hunyuan3d.py b/api/services/generators/hunyuan3d.py deleted file mode 100644 index 5f63570..0000000 --- a/api/services/generators/hunyuan3d.py +++ /dev/null @@ -1,250 +0,0 @@ -""" -Hunyuan3DGenerator — adapter for Hunyuan3D-2.1 (tencent/Hunyuan3D-2.1). - -Target : high-end PCs, ≥10 GB VRAM (shape-only; PBR texture requires ≥21 GB). -Pipeline : image → rembg → DiT flow-matching → octree VAE decode → GLB -Reference : https://github.com/Tencent-Hunyuan/Hunyuan3D-2.1 - -GitHub repo structure: - Hunyuan3D-2.1-main/ - └── hy3dshape/ ← external folder (added to sys.path) - └── hy3dshape/ ← importable Python package - ├── __init__.py - └── pipelines.py ← Hunyuan3DDiTFlowMatchingPipeline -""" -import io -import sys -import time -import threading -import uuid -import zipfile -from pathlib import Path -from typing import Callable, Optional - -from PIL import Image - -from .base import BaseGenerator, smooth_progress - -_HF_REPO_ID = "tencent/Hunyuan3D-2.1" -_GITHUB_ZIP = "https://github.com/Tencent-Hunyuan/Hunyuan3D-2.1/archive/refs/heads/main.zip" - - -class Hunyuan3DGenerator(BaseGenerator): - MODEL_ID = "hunyuan3d" - DISPLAY_NAME = "Hunyuan3D 2.1" - VRAM_GB = 10 # shape-only pipeline - - # ------------------------------------------------------------------ # - # Lifecycle - # ------------------------------------------------------------------ # - - def is_downloaded(self) -> bool: - """Shape weights are present if the DIT folder exists.""" - return (self.model_dir / "hunyuan3d-dit-v2-1").exists() - - def load(self) -> None: - if self._model is not None: - return - - # Fallback download if weights are missing - # (the primary path goes through the SSE endpoint /model/hf-download) - if not self.is_downloaded(): - self._download_weights() - - # Ensure the hy3dshape package is importable - self._ensure_hy3dshape() - - import torch - from hy3dshape.pipelines import Hunyuan3DDiTFlowMatchingPipeline - - device = "cuda" if torch.cuda.is_available() else "cpu" - dtype = torch.float16 if device == "cuda" else torch.float32 - - print(f"[Hunyuan3DGenerator] Loading pipeline from {self.model_dir}…") - pipeline = Hunyuan3DDiTFlowMatchingPipeline.from_pretrained( - str(self.model_dir), - torch_dtype=dtype, - ) - pipeline.to(device) # modifies in place, does not return self - self._model = pipeline - print(f"[Hunyuan3DGenerator] Loaded on {device}.") - - # ------------------------------------------------------------------ # - # Inference - # ------------------------------------------------------------------ # - - def generate( - self, - image_bytes: bytes, - params: dict, - progress_cb: Optional[Callable[[int, str], None]] = None, - ) -> Path: - import torch - - num_steps = int(params.get("num_inference_steps", 50)) - vert_count = int(params.get("vertex_count", 0)) - - # Step 1 — background removal - self._report(progress_cb, 5, "Removing background…") - image = self._preprocess(image_bytes) - - # Step 2 — shape generation (long, no internal callbacks) - self._report(progress_cb, 12, "Generating 3D shape…") - stop_evt = threading.Event() - if progress_cb: - t = threading.Thread( - target=smooth_progress, - args=(progress_cb, 12, 82, "Generating 3D shape…", stop_evt), - daemon=True, - ) - t.start() - - try: - with torch.no_grad(): - outputs = self._model( - image=image, - num_inference_steps=num_steps, - ) - mesh = outputs[0] # trimesh.Trimesh - finally: - stop_evt.set() - - # Step 3 — optional decimation to the target vertex count - if vert_count > 0 and hasattr(mesh, "vertices") and len(mesh.vertices) > vert_count: - self._report(progress_cb, 85, "Optimizing mesh…") - mesh = self._decimate(mesh, vert_count) - - # Step 4 — GLB export - self._report(progress_cb, 93, "Exporting GLB…") - self.outputs_dir.mkdir(parents=True, exist_ok=True) - name = f"{int(time.time())}_{uuid.uuid4().hex[:8]}.glb" - path = self.outputs_dir / name - mesh.export(str(path)) - - self._report(progress_cb, 100, "Done") - return path - - # ------------------------------------------------------------------ # - # Internal helpers - # ------------------------------------------------------------------ # - - def _preprocess(self, image_bytes: bytes) -> Image.Image: - import rembg - return rembg.remove(Image.open(io.BytesIO(image_bytes))).convert("RGBA") - - def _decimate(self, mesh, target_vertices: int): - """Simplifies the mesh to target_vertices (approximation via face count).""" - target_faces = max(4, target_vertices * 2) - try: - return mesh.simplify_quadric_decimation(target_faces) - except Exception as exc: - print(f"[Hunyuan3DGenerator] Decimation skipped: {exc}") - return mesh - - def _download_weights(self) -> None: - """Downloads shape weights from HuggingFace Hub (without the PBR texture model).""" - from huggingface_hub import snapshot_download - print(f"[Hunyuan3DGenerator] Downloading {_HF_REPO_ID} (shape weights)…") - snapshot_download( - repo_id=_HF_REPO_ID, - local_dir=str(self.model_dir), - ignore_patterns=[ - "hunyuan3d-paintpbr-v2-1/**", # texture model (21 GB VRAM, not required) - "*.md", "LICENSE", "Notice.txt", ".gitattributes", - ], - ) - print("[Hunyuan3DGenerator] Download complete.") - - def _ensure_hy3dshape(self) -> None: - """ - Makes hy3dshape importable. - - The package is in the GitHub repo (not on HuggingFace or PyPI). - Structure after archive extraction: - model_dir/_hy3dshape/ - └── hy3dshape/ ← added to sys.path - └── hy3dshape/ ← importable package - ├── __init__.py - └── pipelines.py - """ - try: - import hy3dshape # noqa: F401 - return # already importable - except ImportError: - pass - - outer = self.model_dir / "_hy3dshape" / "hy3dshape" - if not outer.exists(): - self._download_hy3dshape() - - if str(outer) not in sys.path: - sys.path.insert(0, str(outer)) - - try: - import hy3dshape # noqa: F401 - except ImportError as exc: - raise RuntimeError( - f"hy3dshape still not importable after extraction to {outer}.\n" - f"Check the folder contents.\n{exc}" - ) from exc - - def _download_hy3dshape(self) -> None: - """ - Extracts the hy3dshape/ folder from the GitHub repo ZIP archive. - - The GitHub repo has the following structure: - Hunyuan3D-2.1-main/ - └── hy3dshape/ ← this is extracted - └── hy3dshape/ ← Python package - ├── __init__.py - └── pipelines.py - - After extraction: - model_dir/_hy3dshape/hy3dshape/hy3dshape/__init__.py - """ - import urllib.request - - dest = self.model_dir / "_hy3dshape" - dest.mkdir(parents=True, exist_ok=True) - - print(f"[Hunyuan3DGenerator] Downloading hy3dshape source from GitHub…") - with urllib.request.urlopen(_GITHUB_ZIP, timeout=180) as resp: - data = resp.read() - print("[Hunyuan3DGenerator] Extracting hy3dshape…") - - prefix = "Hunyuan3D-2.1-main/hy3dshape/" - strip = "Hunyuan3D-2.1-main/" - - with zipfile.ZipFile(io.BytesIO(data)) as zf: - for member in zf.namelist(): - if not member.startswith(prefix): - continue - rel = member[len(strip):] # e.g. "hy3dshape/pipelines.py" - target = dest / rel - if member.endswith("/"): - target.mkdir(parents=True, exist_ok=True) - else: - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(zf.read(member)) - - print(f"[Hunyuan3DGenerator] hy3dshape extracted to {dest}.") - - # ------------------------------------------------------------------ # - # Parameter schema - # ------------------------------------------------------------------ # - - @classmethod - def params_schema(cls) -> list: - return [ - { - "id": "num_inference_steps", - "label": "Quality", - "type": "select", - "default": 50, - "options": [ - {"value": 20, "label": "Fast (20 steps)"}, - {"value": 50, "label": "Balanced (50 steps)"}, - {"value": 100, "label": "High (100 steps)"}, - ], - }, - ] diff --git a/api/services/generators/hunyuan3d_mini.py b/api/services/generators/hunyuan3d_mini.py deleted file mode 100644 index eb1e766..0000000 --- a/api/services/generators/hunyuan3d_mini.py +++ /dev/null @@ -1,400 +0,0 @@ -""" -Hunyuan3DMiniGenerator — adapter for Hunyuan3D-2mini (tencent/Hunyuan3D-2mini). - -Target : consumer PCs, ≥6 GB VRAM (shape-only). -Model : 0.6B parameters (vs 3.3B for 2.1), fast, lightweight. -Pipeline : image → rembg → DiT flow-matching → GLB -Package : hy3dgen (github.com/Tencent/Hunyuan3D-2, ≠ hy3dshape from v2.1) - -Reference: https://huggingface.co/tencent/Hunyuan3D-2mini -""" -import io -import os -import sys -import tempfile -import time -import threading -import uuid -import zipfile -from pathlib import Path -from typing import Callable, Optional - -from PIL import Image - -from .base import BaseGenerator, smooth_progress - -_HF_REPO_ID = "tencent/Hunyuan3D-2mini" -_SUBFOLDER = "hunyuan3d-dit-v2-mini" -_GITHUB_ZIP = "https://github.com/Tencent/Hunyuan3D-2/archive/refs/heads/main.zip" -_PAINT_HF_REPO = "tencent/Hunyuan3D-2" -_PAINT_SUBFOLDER = "hunyuan3d-paint-v2-0-turbo" - - -class Hunyuan3DMiniGenerator(BaseGenerator): - MODEL_ID = "hunyuan3d-mini" - DISPLAY_NAME = "Hunyuan3D 2 Mini" - VRAM_GB = 6 - - # ------------------------------------------------------------------ # - # Lifecycle - # ------------------------------------------------------------------ # - - def is_downloaded(self) -> bool: - return (self.model_dir / _SUBFOLDER).exists() - - def load(self) -> None: - if self._model is not None: - return - - if not self.is_downloaded(): - self._download_weights() - - self._ensure_hy3dgen() - - import torch - from hy3dgen.shapegen import Hunyuan3DDiTFlowMatchingPipeline - - device = "cuda" if torch.cuda.is_available() else "cpu" - dtype = torch.float16 if device == "cuda" else torch.float32 - - print(f"[Hunyuan3DMiniGenerator] Loading pipeline from {self.model_dir}…") - pipeline = Hunyuan3DDiTFlowMatchingPipeline.from_pretrained( - str(self.model_dir), - subfolder=_SUBFOLDER, - use_safetensors=True, - device=device, - dtype=dtype, - ) - self._model = pipeline - print(f"[Hunyuan3DMiniGenerator] Loaded on {device}.") - - def unload(self) -> None: - super().unload() - try: - import torch - if torch.cuda.is_available(): - torch.cuda.empty_cache() - except ImportError: - pass - - # ------------------------------------------------------------------ # - # Inference - # ------------------------------------------------------------------ # - - def generate( - self, - image_bytes: bytes, - params: dict, - progress_cb: Optional[Callable[[int, str], None]] = None, - ) -> Path: - import torch - - num_steps = int(params.get("num_inference_steps", 30)) - vert_count = int(params.get("vertex_count", 0)) - enable_texture = bool(params.get("enable_texture", False)) - octree_res = int(params.get("octree_resolution", 380)) - guidance_scale = float(params.get("guidance_scale", 5.5)) - seed = int(params.get("seed", -1)) - - # Step 1 — background removal - self._report(progress_cb, 5, "Removing background…") - image = self._preprocess(image_bytes) - - # Step 2 — shape generation - # If texture is enabled, reserve 5-70% for shape and 70-95% for texture - shape_end = 70 if enable_texture else 82 - self._report(progress_cb, 12, "Generating 3D shape…") - stop_evt = threading.Event() - if progress_cb: - t = threading.Thread( - target=smooth_progress, - args=(progress_cb, 12, shape_end, "Generating 3D shape…", stop_evt), - daemon=True, - ) - t.start() - - try: - with torch.no_grad(): - import torch - generator = torch.Generator().manual_seed(seed) if seed >= 0 else None - outputs = self._model( - image=image, - num_inference_steps=num_steps, - octree_resolution=octree_res, - guidance_scale=guidance_scale, - num_chunks=4000, - generator=generator, - output_type="trimesh", - ) - mesh = outputs[0] - finally: - stop_evt.set() - - # Step 3 — texture (optional) - if enable_texture: - # Unload the shape model to free VRAM before texturing - self._report(progress_cb, 72, "Freeing VRAM for texture model…") - self._model = None - if torch.cuda.is_available(): - torch.cuda.empty_cache() - - mesh = self._run_texture(mesh, image, progress_cb) - else: - # Decimate only if no texture (texture needs the full mesh) - if vert_count > 0 and hasattr(mesh, "vertices") and len(mesh.vertices) > vert_count: - self._report(progress_cb, 85, "Optimizing mesh…") - mesh = self._decimate(mesh, vert_count) - - # Step 4 — GLB export - self._report(progress_cb, 96, "Exporting GLB…") - self.outputs_dir.mkdir(parents=True, exist_ok=True) - name = f"{int(time.time())}_{uuid.uuid4().hex[:8]}.glb" - path = self.outputs_dir / name - mesh.export(str(path)) - - self._report(progress_cb, 100, "Done") - return path - - # ------------------------------------------------------------------ # - # Helpers - # ------------------------------------------------------------------ # - - def _preprocess(self, image_bytes: bytes) -> Image.Image: - import rembg - return rembg.remove(Image.open(io.BytesIO(image_bytes))).convert("RGBA") - - def _run_texture(self, mesh, image: "Image.Image", progress_cb=None): - """ - Generates PBR textures on the mesh using Hunyuan3DPaintPipeline. - - Prerequisites: compiled C++ extensions. - If missing, a RuntimeError explains how to compile them. - """ - import torch - - # Check that C++ extensions are available - self._check_texgen_extensions() - - # Download texture model weights if missing - self._report(progress_cb, 73, "Preparing texture model…") - self._ensure_paint_weights() - - # Load the texture pipeline - self._report(progress_cb, 78, "Loading texture model…") - from hy3dgen.texgen import Hunyuan3DPaintPipeline - - paint_dir = self.model_dir / "_paint_weights" - paint_pipeline = Hunyuan3DPaintPipeline.from_pretrained( - str(paint_dir), subfolder=_PAINT_SUBFOLDER - ) - - # Reduce render resolution to speed up generation - # (2048→1024 = 4× fewer pixels per view × 6 views) - from hy3dgen.texgen.differentiable_renderer.mesh_render import MeshRender - paint_pipeline.config.render_size = 1024 - paint_pipeline.config.texture_size = 1024 - paint_pipeline.render = MeshRender(default_resolution=1024, texture_size=1024) - - # Save the preprocessed image to a temporary file - # (the pipeline expects a path or a PIL Image depending on the version) - tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False) - try: - image.save(tmp.name) - tmp.close() - - self._report(progress_cb, 83, "Generating textures…") - with torch.no_grad(): - result = paint_pipeline(mesh, image=tmp.name) - finally: - os.unlink(tmp.name) - # Free VRAM after texturing - del paint_pipeline - if torch.cuda.is_available(): - torch.cuda.empty_cache() - - # The pipeline may return a mesh directly or a list - return result[0] if isinstance(result, (list, tuple)) else result - - def _check_texgen_extensions(self) -> None: - """ - Checks that the C++ extensions for texture generation are compiled. - Raises a RuntimeError with compilation instructions if missing. - """ - try: - from hy3dgen.texgen import Hunyuan3DPaintPipeline # noqa: F401 - except (ImportError, OSError) as exc: - base = self.model_dir / "_hy3dgen" / "hy3dgen" / "texgen" - raise RuntimeError( - "The C++ extensions for texture generation are not compiled.\n" - "Compile them with:\n\n" - f" cd \"{base / 'custom_rasterizer'}\"\n" - f" python setup.py install\n\n" - f" cd \"{base / 'differentiable_renderer'}\"\n" - f" python setup.py install\n\n" - f"Original error: {exc}" - ) from exc - - def _ensure_paint_weights(self) -> None: - """Downloads texture model weights from tencent/Hunyuan3D-2 if missing.""" - paint_dir = self.model_dir / "_paint_weights" - # Both subfolders are required: paint (diffusion) + delight (shadow removal) - if (paint_dir / _PAINT_SUBFOLDER).exists() and (paint_dir / "hunyuan3d-delight-v2-0").exists(): - return - - from huggingface_hub import snapshot_download - print(f"[Hunyuan3DMiniGenerator] Downloading paint model ({_PAINT_HF_REPO})…") - snapshot_download( - repo_id=_PAINT_HF_REPO, - local_dir=str(paint_dir), - ignore_patterns=[ - # Keep: hunyuan3d-paint-v2-0-turbo/ + hunyuan3d-delight-v2-0/ + config.json - "hunyuan3d-dit-v2-0/**", - "hunyuan3d-dit-v2-0-fast/**", - "hunyuan3d-dit-v2-0-turbo/**", - "hunyuan3d-vae-v2-0/**", - "hunyuan3d-vae-v2-0-turbo/**", - "hunyuan3d-vae-v2-0-withencoder/**", - "hunyuan3d-paint-v2-0/**", # standard — not required with turbo - "assets/**", - "*.md", "LICENSE", "NOTICE", ".gitattributes", - ], - ) - print("[Hunyuan3DMiniGenerator] Paint model downloaded.") - - def _decimate(self, mesh, target_vertices: int): - target_faces = max(4, target_vertices * 2) - try: - return mesh.simplify_quadric_decimation(target_faces) - except Exception as exc: - print(f"[Hunyuan3DMiniGenerator] Decimation skipped: {exc}") - return mesh - - def _download_weights(self) -> None: - from huggingface_hub import snapshot_download - print(f"[Hunyuan3DMiniGenerator] Downloading {_HF_REPO_ID} (base variant)…") - snapshot_download( - repo_id=_HF_REPO_ID, - local_dir=str(self.model_dir), - ignore_patterns=[ - # Turbo/fast/encoder variants — not required for base inference - "hunyuan3d-dit-v2-mini-fast/**", - "hunyuan3d-dit-v2-mini-turbo/**", - "hunyuan3d-vae-v2-mini-turbo/**", - "hunyuan3d-vae-v2-mini-withencoder/**", - "*.md", "LICENSE", "NOTICE", ".gitattributes", - ], - ) - print("[Hunyuan3DMiniGenerator] Download complete.") - - def _ensure_hy3dgen(self) -> None: - """ - Makes hy3dgen importable. - - hy3dgen comes from the GitHub repo Tencent/Hunyuan3D-2 (different from the 2.1 repo). - Structure after extraction: - model_dir/_hy3dgen/ ← added to sys.path - └── hy3dgen/ ← importable package - ├── __init__.py - └── shapegen/ - └── __init__.py - """ - try: - from hy3dgen.shapegen import Hunyuan3DDiTFlowMatchingPipeline # noqa: F401 - return - except ImportError: - pass - - src_dir = self.model_dir / "_hy3dgen" - if not (src_dir / "hy3dgen").exists(): - self._download_hy3dgen(src_dir) - - if str(src_dir) not in sys.path: - sys.path.insert(0, str(src_dir)) - - try: - from hy3dgen.shapegen import Hunyuan3DDiTFlowMatchingPipeline # noqa: F401 - except ImportError as exc: - raise RuntimeError( - f"hy3dgen still not importable after extraction to {src_dir}.\n" - f"Check the folder contents.\n{exc}" - ) from exc - - def _download_hy3dgen(self, dest: Path) -> None: - """ - Extracts hy3dgen/ from the GitHub repo ZIP archive Tencent/Hunyuan3D-2. - - Structure in the ZIP: - Hunyuan3D-2-main/ - └── hy3dgen/ ← extracted to dest/hy3dgen/ - ├── __init__.py - └── shapegen/ - - After extraction, dest/ will contain hy3dgen/ → importable via sys.path. - """ - import urllib.request - - dest.mkdir(parents=True, exist_ok=True) - print("[Hunyuan3DMiniGenerator] Downloading hy3dgen source from GitHub…") - with urllib.request.urlopen(_GITHUB_ZIP, timeout=180) as resp: - data = resp.read() - print("[Hunyuan3DMiniGenerator] Extracting hy3dgen…") - - prefix = "Hunyuan3D-2-main/hy3dgen/" - strip = "Hunyuan3D-2-main/" - - with zipfile.ZipFile(io.BytesIO(data)) as zf: - for member in zf.namelist(): - if not member.startswith(prefix): - continue - rel = member[len(strip):] # e.g. "hy3dgen/shapegen/__init__.py" - target = dest / rel - if member.endswith("/"): - target.mkdir(parents=True, exist_ok=True) - else: - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(zf.read(member)) - - print(f"[Hunyuan3DMiniGenerator] hy3dgen extracted to {dest}.") - - @classmethod - def params_schema(cls) -> list: - return [ - { - "id": "num_inference_steps", - "label": "Quality", - "type": "select", - "default": 30, - "options": [ - {"value": 10, "label": "Fast (10 steps)"}, - {"value": 30, "label": "Balanced (30 steps)"}, - {"value": 50, "label": "High (50 steps)"}, - ], - }, - { - "id": "octree_resolution", - "label": "Mesh Resolution", - "type": "select", - "default": 380, - "options": [ - {"value": 256, "label": "Low (256)"}, - {"value": 380, "label": "Medium (380)"}, - {"value": 512, "label": "High (512)"}, - ], - }, - { - "id": "guidance_scale", - "label": "Guidance Scale", - "type": "float", - "default": 5.5, - "min": 1.0, - "max": 10.0, - }, - { - "id": "seed", - "label": "Seed", - "type": "int", - "default": -1, - "min": -1, - "max": 2147483647, - }, - ] diff --git a/api/services/generators/instantmesh.py b/api/services/generators/instantmesh.py deleted file mode 100644 index b8601f0..0000000 --- a/api/services/generators/instantmesh.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -InstantMeshGenerator — adapter for InstantMesh (TencentARC/InstantMesh). -Target: mid-range/high-end PCs, ~16 GB VRAM. -Pipeline: image → Zero123++ (multi-view) → reconstruction → GLB - -TODO: implement load() and generate() when InstantMesh support is added. - Reference: https://github.com/TencentARC/InstantMesh -""" -from pathlib import Path -from typing import Callable, Optional - -from .base import BaseGenerator - - -class InstantMeshGenerator(BaseGenerator): - MODEL_ID = "instantmesh" - DISPLAY_NAME = "InstantMesh" - VRAM_GB = 16 - - def is_downloaded(self) -> bool: - return self.model_dir.exists() and any(self.model_dir.iterdir()) - - def load(self) -> None: - raise NotImplementedError("InstantMesh support not yet implemented.") - - def generate( - self, - image_bytes: bytes, - params: dict, - progress_cb: Optional[Callable[[int, str], None]] = None, - ) -> Path: - raise NotImplementedError("InstantMesh support not yet implemented.") - - @classmethod - def params_schema(cls) -> list: - return [ - { - "id": "num_views", - "label": "Number of Views", - "type": "select", - "default": 6, - "options": [ - {"value": 4, "label": "4 views (faster)"}, - {"value": 6, "label": "6 views (better)"}, - ], - }, - { - "id": "num_inference_steps", - "label": "Inference Steps", - "type": "select", - "default": 75, - "options": [ - {"value": 30, "label": "Fast (30 steps)"}, - {"value": 75, "label": "Balanced (75 steps)"}, - ], - }, - ] diff --git a/api/services/generators/sf3d.py b/api/services/generators/sf3d.py deleted file mode 100644 index 105df06..0000000 --- a/api/services/generators/sf3d.py +++ /dev/null @@ -1,212 +0,0 @@ -""" -SF3DGenerator — adapter for StableFast3D (stabilityai/stable-fast-3d). -Target: low-end PCs, ~4 GB VRAM. -""" -import io -import sys -import time -import threading -import uuid -import zipfile -from pathlib import Path -from typing import Callable, Optional - -from PIL import Image - -from .base import BaseGenerator, smooth_progress - -_GITHUB_ZIP = "https://github.com/Stability-AI/stable-fast-3d/archive/refs/heads/main.zip" - - -class SF3DGenerator(BaseGenerator): - MODEL_ID = "sf3d" - DISPLAY_NAME = "StableFast3D" - VRAM_GB = 4 - - # ------------------------------------------------------------------ # - - def is_downloaded(self) -> bool: - return self.model_dir.exists() and any(self.model_dir.iterdir()) - - def load(self) -> None: - if self._model is not None: - return - if not self.is_downloaded(): - raise RuntimeError( - f"SF3D not found in {self.model_dir}. " - "Please download it from the app first." - ) - - self._ensure_sf3d_source() - - weight_candidates = list(self.model_dir.glob("*.safetensors")) - if not weight_candidates: - raise RuntimeError(f"No .safetensors file found in {self.model_dir}") - - config_path = self.model_dir / "config.yaml" - if not config_path.exists(): - raise RuntimeError(f"config.yaml not found in {self.model_dir}") - - import torch - from sf3d.system import SF3D - - print(f"[SF3DGenerator] Loading from {self.model_dir}…") - model = SF3D.from_pretrained( - str(self.model_dir), - config_name="config.yaml", - weight_name=weight_candidates[0].name, - ) - model.eval() - - # Fix 2: move to the correct device once at load time - device = "cuda" if torch.cuda.is_available() else "cpu" - model.to(device) - - self._model = model - print(f"[SF3DGenerator] Loaded on {device}.") - - # ------------------------------------------------------------------ # - - def generate( - self, - image_bytes: bytes, - params: dict, - progress_cb: Optional[Callable[[int, str], None]] = None, - ) -> Path: - import torch - - vertex_count = int(params.get("vertex_count", 10000)) - remesh = str(params.get("remesh", "quad")) - - try: - from uv_unwrapper import Unwrapper # noqa: F401 - texturing_available = True - except ImportError: - texturing_available = False - - enable_texture = str(params.get("enable_texture", "true")).lower() == "true" - texture_resolution = max(64, min(2048, int(params.get("texture_resolution", 512)))) - bake_res = texture_resolution if (texturing_available and enable_texture) else 0 - - # Step 1: background removal - self._report(progress_cb, 5, "Removing background…") - image = self._preprocess(image_bytes) - - # Step 2: neural inference (long operation, no internal callback) - # A thread is started to smoothly increment the progress bar in the meantime. - self._report(progress_cb, 10, "Running neural inference…") - stop_event = threading.Event() - - if progress_cb: - smooth_thread = threading.Thread( - target=smooth_progress, - args=(progress_cb, 10, 70, "Running neural inference…", stop_event), - daemon=True, - ) - smooth_thread.start() - - try: - with torch.no_grad(): - mesh, _ = self._model.run_image( - image, - bake_resolution=bake_res, - remesh=remesh, - vertex_count=vertex_count, - ) - finally: - stop_event.set() - - # Step 3: remesh + export - self._report(progress_cb, 75, "Remeshing geometry…") - self._report(progress_cb, 90, "Exporting GLB…") - - self.outputs_dir.mkdir(parents=True, exist_ok=True) - output_name = f"{int(time.time())}_{uuid.uuid4().hex[:8]}.glb" - output_path = self.outputs_dir / output_name - mesh.export(str(output_path)) - - self._report(progress_cb, 100, "Done") - return output_path - - # ------------------------------------------------------------------ # - - @classmethod - def params_schema(cls) -> list: - return [ - { - "id": "vertex_count", - "label": "Mesh Quality", - "type": "select", - "default": 10000, - "options": [ - {"value": 5000, "label": "Low (5k)"}, - {"value": 10000, "label": "Medium (10k)"}, - {"value": 20000, "label": "High (20k)"}, - ], - }, - { - "id": "remesh", - "label": "Remesh", - "type": "select", - "default": "quad", - "options": [ - {"value": "quad", "label": "Quad"}, - {"value": "triangle", "label": "Triangle"}, - {"value": "none", "label": "None"}, - ], - }, - ] - - # ------------------------------------------------------------------ # - - def _ensure_sf3d_source(self) -> None: - try: - from sf3d.system import SF3D # noqa: F401 - return - except ImportError: - pass - - src_dir = self.model_dir / "_sf3d_source" - if not (src_dir / "sf3d").exists(): - self._download_sf3d_source(src_dir) - - if str(src_dir) not in sys.path: - sys.path.insert(0, str(src_dir)) - - try: - from sf3d.system import SF3D # noqa: F401 - except ImportError as exc: - raise RuntimeError( - f"sf3d still not importable after extraction to {src_dir}.\n{exc}" - ) from exc - - def _download_sf3d_source(self, dest: Path) -> None: - import urllib.request - - dest.mkdir(parents=True, exist_ok=True) - print("[SF3DGenerator] Downloading sf3d source from GitHub…") - with urllib.request.urlopen(_GITHUB_ZIP, timeout=180) as resp: - data = resp.read() - print("[SF3DGenerator] Extracting sf3d source…") - - prefix = "stable-fast-3d-main/sf3d/" - strip = "stable-fast-3d-main/" - - with zipfile.ZipFile(io.BytesIO(data)) as zf: - for member in zf.namelist(): - if not member.startswith(prefix): - continue - rel = member[len(strip):] - target = dest / rel - if member.endswith("/"): - target.mkdir(parents=True, exist_ok=True) - else: - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(zf.read(member)) - - print(f"[SF3DGenerator] sf3d source extracted to {dest}.") - - def _preprocess(self, image_bytes: bytes) -> Image.Image: - import rembg - image = Image.open(io.BytesIO(image_bytes)) - return rembg.remove(image).convert("RGBA") diff --git a/api/services/generators/triposr.py b/api/services/generators/triposr.py deleted file mode 100644 index 9fe5ef7..0000000 --- a/api/services/generators/triposr.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -TripoSRGenerator — adapter for TripoSR (VAST-AI-Research/TripoSR). -Target: mid-range PCs, ~6 GB VRAM. - -TODO: implement load() and generate() when TripoSR support is added. - Reference: https://github.com/VAST-AI-Research/TripoSR -""" -from pathlib import Path -from typing import Callable, Optional - -from .base import BaseGenerator - - -class TripoSRGenerator(BaseGenerator): - # outputs_dir is managed by BaseGenerator — no need to override __init__ - MODEL_ID = "triposr" - DISPLAY_NAME = "TripoSR" - VRAM_GB = 6 - - def is_downloaded(self) -> bool: - return self.model_dir.exists() and any(self.model_dir.iterdir()) - - def load(self) -> None: - raise NotImplementedError("TripoSR support not yet implemented.") - - def generate( - self, - image_bytes: bytes, - params: dict, - progress_cb: Optional[Callable[[int, str], None]] = None, - ) -> Path: - raise NotImplementedError("TripoSR support not yet implemented.") - - @classmethod - def params_schema(cls) -> list: - # TripoSR shares the same parameters as SF3D - return [ - { - "id": "vertex_count", - "label": "Mesh Quality", - "type": "select", - "default": 10000, - "options": [ - {"value": 5000, "label": "Low (5k)"}, - {"value": 10000, "label": "Medium (10k)"}, - {"value": 20000, "label": "High (20k)"}, - ], - }, - { - "id": "remesh", - "label": "Remesh", - "type": "select", - "default": "quad", - "options": [ - {"value": "quad", "label": "Quad"}, - {"value": "triangle", "label": "Triangle"}, - {"value": "none", "label": "None"}, - ], - }, - ] diff --git a/package.json b/package.json index 8cceeef..2695367 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modly", - "version": "0.1.3", + "version": "0.2.0", "description": "Local AI-powered 3D mesh generation from images", "main": "./out/main/index.js", "author": "Modly", diff --git a/src/areas/models/components/ExtensionCard.tsx b/src/areas/models/components/ExtensionCard.tsx index 2133a88..6e1a904 100644 --- a/src/areas/models/components/ExtensionCard.tsx +++ b/src/areas/models/components/ExtensionCard.tsx @@ -146,11 +146,11 @@ export function ExtensionCard({ ext, installedIds, downloading, loadError, disab ) : (