Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions api/routers/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ async def cancel_job(job_id: str):
_cancel_events[job_id].set()
if job.status in ("pending", "running"):
job.status = "cancelled"
# Kill the active generator subprocess immediately so inference stops now.
# _run_generation will catch the resulting exception, see job_id in _cancelled,
# and return cleanly without setting an error status.
try:
gen = generator_registry._generators.get(generator_registry._active_id)
if gen is not None and hasattr(gen, "_proc") and gen._proc and gen._proc.poll() is None:
gen._proc.kill()
gen._loaded = False
gen._proc = None
except Exception:
pass
return {"cancelled": True}


Expand Down
24 changes: 18 additions & 6 deletions api/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
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", "")
# MODEL_DIR is set by ExtensionProcess to match its own model_dir (composite node id path).
# Falls back to MODELS_DIR/manifest_id for standalone/legacy use.
_MODEL_DIR_OVERRIDE = os.environ.get("MODEL_DIR", "")

# Inject Modly's api/ so generator.py can do:
# from services.generators.base import BaseGenerator, ...
Expand Down Expand Up @@ -101,14 +104,23 @@ def main() -> None:
try:
schema = GenClass.params_schema()
except Exception:
schema = manifest.get("params_schema", [])
node0 = (manifest.get("nodes") or [{}])[0]
schema = manifest.get("params_schema", []) or node0.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", [])
# Support both flat manifest (legacy) and nodes[] format.
# Node-level fields take precedence; fall back to top-level for compatibility.
node = (manifest.get("nodes") or [{}])[0]

# 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", [])

# Active cancel events keyed by request id
_cancel: dict[str, threading.Event] = {}
Expand Down
4 changes: 4 additions & 0 deletions api/services/extension_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ 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)
# 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).
if self.model_dir is not None:
env["MODEL_DIR"] = str(self.model_dir)
return env

def _start(self) -> None:
Expand Down
110 changes: 64 additions & 46 deletions api/services/generator_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ def _discover_extensions() -> Dict[str, Tuple[type, dict]]:
"""
Scans EXTENSIONS_DIR to find valid extensions.
Each extension must have manifest.json + generator.py.
Returns {model_id: (GeneratorClass, manifest_dict)}.
Returns {full_id: (GeneratorClass, node_manifest, ext_dir)}
where full_id is "ext_id/node_id".
"""
result: Dict[str, Tuple[type, dict]] = {}

Expand All @@ -73,55 +74,61 @@ def _discover_extensions() -> Dict[str, Tuple[type, dict]]:
ext_id = manifest["id"]
class_name = manifest["generator_class"]

nodes = [n for n in manifest.get("nodes", []) if n.get("id")]

# --- 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

# --- 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)
sys.modules[module_name] = module
spec.loader.exec_module(module)

cls = getattr(module, class_name)

# Multi-variant: register one generator per variant if manifest.models[] is present
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 = {
# Also force subprocess mode for extensions that ship a build_vendor.py
# but whose vendor/ directory hasn't been built yet: this surfaces a
# loadError in the UI (Repair button) so the user can run setup.py.
has_venv = _venv_python(ext_dir).exists()
has_build_vendor = (ext_dir / "build_vendor.py").exists()
vendor_built = (ext_dir / "vendor").exists()
subprocess_mode = has_venv or (has_build_vendor and not vendor_built)

cls_or_None = None
if not subprocess_mode:
# --- Direct mode (legacy): no venv → load 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)
sys.modules[module_name] = module
spec.loader.exec_module(module)
cls_or_None = getattr(module, class_name)

if nodes:
for node in nodes:
node_manifest = {
**manifest,
"id": variant["id"],
"name": variant.get("name", variant["id"]),
"hf_repo": variant["hf_repo"],
"id": f"{ext_id}/{node['id']}",
"ext_id": ext_id,
"node_id": node["id"],
"name": node.get("name", node["id"]),
"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", []),
"input": node.get("input", "image"),
"output": node.get("output", "mesh"),
}
# Per-variant fields override the top-level ones if present
for field in ("hf_skip_prefixes", "download_check"):
if field in variant:
variant_manifest[field] = variant[field]
result[variant["id"]] = (cls, variant_manifest, None)
print(f"[Registry] Loaded extension variant: {variant['id']} (from '{ext_id}')")
full_id = f"{ext_id}/{node['id']}"
result[full_id] = (cls_or_None, node_manifest, ext_dir)
if subprocess_mode:
if has_venv:
print(f"[Registry] Loaded subprocess node: {full_id}")
else:
print(f"[Registry] Node '{full_id}' needs setup (venv missing)")
else:
print(f"[Registry] Loaded node: {full_id} ({class_name})")
else:
result[ext_id] = (cls, manifest, None)
print(f"[Registry] Loaded extension: {ext_id} ({class_name})")
# No nodes defined — register by ext_id as fallback
result[ext_id] = (cls_or_None, manifest, ext_dir)
if subprocess_mode:
if has_venv:
print(f"[Registry] Loaded subprocess extension: {ext_id}")
else:
print(f"[Registry] Extension '{ext_id}' needs setup (venv missing)")
else:
print(f"[Registry] Loaded extension: {ext_id} ({class_name})")

except Exception as exc:
print(f"[Registry] ERROR loading extension '{ext_dir.name}': {exc}")
Expand All @@ -148,6 +155,12 @@ def initialize(self) -> None:
cls, manifest, ext_dir = entry
try:
if cls is None:
# Subprocess mode: venv must exist
if not _venv_python(ext_dir).exists():
raise RuntimeError(
"venv not found — extension needs setup. "
"Click 'Repair' on the Models page to run setup.py."
)
# Subprocess mode: wrap in ExtensionProcess
gen = ExtensionProcess(ext_dir, manifest)
gen.model_dir = MODELS_DIR / model_id
Expand Down Expand Up @@ -213,6 +226,11 @@ def get_active(self) -> BaseGenerator:
gen = self._generators[self._active_id]
if not gen.is_loaded():
if not gen.is_downloaded():
if isinstance(gen, ExtensionProcess):
raise RuntimeError(
f"Model '{self._active_id}' is not downloaded. "
"Please install it from the Models page first."
)
gen._auto_download()
gen.load()
return gen
Expand Down
39 changes: 39 additions & 0 deletions builtin-extensions/mesh-exporter/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"id": "mesh-exporter",
"name": "Mesh Exporter",
"type": "process",
"entry": "processor.js",
"version": "1.0.0",
"author": "Modly",
"description": "Exports the mesh to a chosen format (GLB, STL, OBJ, PLY) at a specified output path.",
"nodes": [
{
"id": "export",
"name": "Export Mesh",
"input": "mesh",
"output": "mesh",
"params_schema": [
{
"id": "export_format",
"label": "Export Format",
"type": "select",
"default": "glb",
"options": [
{ "value": "glb", "label": "GLB (Binary glTF)" },
{ "value": "stl", "label": "STL (3D printing)" },
{ "value": "obj", "label": "OBJ (Wavefront)" },
{ "value": "ply", "label": "PLY (Polygon)" }
],
"tooltip": "Output file format."
},
{
"id": "output_path",
"label": "Output Path",
"type": "string",
"default": "",
"tooltip": "Destination folder. Leave empty to save in the workspace Exports folder."
}
]
}
]
}
8 changes: 8 additions & 0 deletions builtin-extensions/mesh-exporter/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "mesh-exporter",
"version": "1.0.0",
"private": true,
"dependencies": {
"@gltf-transform/core": "^3.9.0"
}
}
Loading