Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9e5a099
Merge pull request #87 from lightningpixel/main
lightningpixel Apr 7, 2026
0e6baa8
fix(workflow): fix workflow run
Apr 9, 2026
8ec8657
Merge pull request #89 from lightningpixel/fix/fix-workflow-run
lightningpixel Apr 9, 2026
a7f9d11
feature(generate): add light settings
Apr 9, 2026
2af158b
Merge pull request #90 from lightningpixel/feature/add-light-settings
lightningpixel Apr 9, 2026
c677fca
Add copy button error generate
Lorchie Apr 9, 2026
a82d3dd
TextNode grows with textarea content (autoHeight)
Lorchie Apr 9, 2026
c1e1ed9
Merge pull request #91 from lightningpixel/feature/copy-error-generation
lightningpixel Apr 9, 2026
93d31a4
Merge pull request #92 from lightningpixel/feature/node-resize-fixes-…
lightningpixel Apr 9, 2026
806d14d
tech(settings): add huggingface token
Apr 13, 2026
cfb4364
Merge pull request #95 from lightningpixel/tech/add-setting-huggingfa…
lightningpixel Apr 13, 2026
f59aff0
fix(project): fix tar import
Apr 17, 2026
17e1a18
Merge pull request #96 from lightningpixel/fix/fix-tar-import
lightningpixel Apr 17, 2026
562be6c
feat(workflow): add /workflow-runs headless execution surface
Apr 17, 2026
6eac771
Merge pull request #97 from lightningpixel/feat/workflow-runs-headles…
lightningpixel Apr 17, 2026
9250862
tech(improve): remove logging message and fix double side material
Apr 19, 2026
b6fdbfe
Merge pull request #99 from lightningpixel/tech/remove-logging-messag…
lightningpixel Apr 19, 2026
c5f658a
Update Discord server link in README
lightningpixel Apr 19, 2026
198b93a
tech(architecture): add embedded python
Apr 21, 2026
f95b471
Merge pull request #103 from lightningpixel/tech/add-embedded-python
lightningpixel Apr 21, 2026
151f902
fix(generate): remove ambient lighting
Apr 21, 2026
0422d0d
Merge pull request #104 from lightningpixel/fix/remove-ambient-lighting
lightningpixel Apr 21, 2026
e084558
dump version 0.3.2
Apr 21, 2026
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
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ jobs:
- name: Install dependencies
run: npm install

- name: Download Python embed
run: node scripts/download-python-embed.js

- name: Build & Package (Linux)
run: npm run build && npx electron-builder --linux --publish never

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Modly supports external AI model extensions. Each extension is a GitHub reposito

### Community

Join the [Discord server](https://discord.gg/FjzjRgweVk) to stay up to date with the latest news, report bugs, and share feedback.
Join the [Discord server](https://discord.gg/BvjDCvS3yr) to stay up to date with the latest news, report bugs, and share feedback.

---

Expand Down
15 changes: 12 additions & 3 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
Modly FastAPI backend.
Runs locally within the Electron app to provide AI inference endpoints.
"""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi import HTTPException

from routers import generation, model, optimize, status, settings, extensions, export
from routers import generation, model, optimize, status, settings, extensions, export, workflow_runs


@asynccontextmanager
Expand All @@ -21,9 +22,16 @@ async def lifespan(app: FastAPI):
generator_registry.unload_all()


class _StatusFilter(logging.Filter):
def filter(self, record):
return "/generate/status/" not in record.getMessage()

logging.getLogger("uvicorn.access").addFilter(_StatusFilter())


app = FastAPI(
title="Modly API",
version="0.3.1",
version="0.3.2",
lifespan=lifespan,
)

Expand All @@ -40,7 +48,8 @@ async def lifespan(app: FastAPI):
app.include_router(generation.router, prefix="/generate")
app.include_router(optimize.router, prefix="/optimize")
app.include_router(extensions.router, prefix="/extensions")
app.include_router(export.router, prefix="/export")
app.include_router(export.router, prefix="/export")
app.include_router(workflow_runs.router, prefix="/workflow-runs")

# Serve generated files from workspace — dynamic so path changes take effect immediately
@app.get("/workspace/{full_path:path}")
Expand Down
14 changes: 10 additions & 4 deletions api/routers/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,20 @@ async def unload_model(model_id: str):


@router.get("/hf-download")
async def hf_download(repo_id: str, model_id: str, skip_prefixes: Optional[str] = None):
async def hf_download(repo_id: str, model_id: str, skip_prefixes: Optional[str] = None, token: Optional[str] = None):
"""
Streams a HuggingFace Hub model download via SSE.
Downloads into MODELS_DIR / model_id applying the filtering
declared in the extension manifest (hf_skip_prefixes).

skip_prefixes: JSON-encoded list of path prefixes to exclude (passed from Electron).
token: HuggingFace access token for gated repos (passed from Electron settings).
Falls back to registry manifest if not provided.

SSE format: data: {"percent": 0-100, "file": "...", "status": "..."}
"""
import json as _json
import os
dest_dir = str(MODELS_DIR / model_id)
# Prefer skip_prefixes passed directly from the client (authoritative, no registry dep)
if skip_prefixes:
Expand All @@ -93,19 +95,22 @@ async def hf_download(repo_id: str, model_id: str, skip_prefixes: Optional[str]
except KeyError:
skip_list = []

# Token: explicit param > env var
hf_token = token or os.environ.get("HUGGING_FACE_HUB_TOKEN") or os.environ.get("HF_TOKEN") or None

async def stream():
loop = asyncio.get_running_loop()

def _fmt(data: dict) -> str:
return f"data: {json.dumps(data)}\n\n"

try:
yield _fmt({"percent": 0, "status": "Listing repository files"})
yield _fmt({"percent": 0, "status": "Listing repository files..."})

def _list_files():
from huggingface_hub import list_repo_files
return [
f for f in list_repo_files(repo_id)
f for f in list_repo_files(repo_id, token=hf_token)
if not any(f.startswith(p) for p in skip_list)
]

Expand All @@ -116,7 +121,7 @@ def _list_files():
yield _fmt({"error": f"No files found in HuggingFace repo: {repo_id}"})
return

yield _fmt({"percent": 1, "status": f"Downloading {total} files"})
yield _fmt({"percent": 1, "status": f"Downloading {total} files..."})

from huggingface_hub import hf_hub_download

Expand All @@ -127,6 +132,7 @@ def _dl(f=filename):
filename=f,
local_dir=dest_dir,
local_dir_use_symlinks=False,
token=hf_token,
)

await loop.run_in_executor(None, _dl)
Expand Down
20 changes: 20 additions & 0 deletions api/routers/settings.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from fastapi import APIRouter
from pydantic import BaseModel
from pathlib import Path
Expand All @@ -13,6 +14,10 @@ class PathsUpdate(BaseModel):
workspace_dir: Optional[str] = None


class TokenUpdate(BaseModel):
token: str


@router.get("/paths")
async def get_paths():
return {
Expand All @@ -31,3 +36,18 @@ async def update_paths(body: PathsUpdate):
"models_dir": str(reg_module.MODELS_DIR),
"workspace_dir": str(reg_module.WORKSPACE_DIR),
}


@router.post("/hf-token")
async def update_hf_token(body: TokenUpdate):
"""
Update the HuggingFace token in this process's environment so that
extension subprocesses spawned after this call inherit the new token.
"""
if body.token:
os.environ["HUGGING_FACE_HUB_TOKEN"] = body.token
os.environ["HF_TOKEN"] = body.token
else:
os.environ.pop("HUGGING_FACE_HUB_TOKEN", None)
os.environ.pop("HF_TOKEN", None)
return {"ok": True}
107 changes: 107 additions & 0 deletions api/routers/workflow_runs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import json
import threading
import uuid
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, UploadFile
from pydantic import BaseModel

from routers.generation import _cancel_events, _cancelled, _jobs, _run_generation
from schemas.generation import JobStatus
from services.generator_registry import generator_registry

router = APIRouter(tags=["workflow-runs"])


class WorkflowRunStatus(BaseModel):
run_id: str
status: str
progress: int = 0
step: Optional[str] = None
output_url: Optional[str] = None
error: Optional[str] = None
scene_candidate: Optional[dict] = None


@router.post("/from-image")
async def create_run_from_image(
background_tasks: BackgroundTasks,
image: UploadFile = File(...),
model_id: str = Form("sf3d"),
params: str = Form("{}"),
):
if not image.content_type or not image.content_type.startswith("image/"):
raise HTTPException(400, "File must be an image")

try:
generator_registry.get_generator(model_id)
except ValueError as e:
raise HTTPException(400, str(e))

generator_registry.switch_model(model_id)

try:
model_params = json.loads(params)
except (json.JSONDecodeError, TypeError):
model_params = {}

full_params = {
"remesh": "quad",
"enable_texture": False,
"texture_resolution": 1024,
**model_params,
}

job_id = str(uuid.uuid4())
image_bytes = await image.read()

_jobs[job_id] = JobStatus(job_id=job_id, status="pending", progress=0)
_cancel_events[job_id] = threading.Event()

background_tasks.add_task(_run_generation, job_id, image_bytes, full_params, "Default")

return {"run_id": job_id, "status": "pending"}


@router.get("/{run_id}", response_model=WorkflowRunStatus)
async def get_run(run_id: str):
job = _jobs.get(run_id)
if not job:
raise HTTPException(404, f"Run {run_id} not found")

scene_candidate = None
if job.status == "done" and job.output_url:
scene_candidate = {"workspace_path": job.output_url.removeprefix("/workspace/")}

return WorkflowRunStatus(
run_id=job.job_id,
status=job.status,
progress=job.progress,
step=job.step,
output_url=job.output_url,
error=job.error,
scene_candidate=scene_candidate,
)


@router.post("/{run_id}/cancel")
async def cancel_run(run_id: str):
job = _jobs.get(run_id)
if not job:
raise HTTPException(404, f"Run {run_id} not found")

_cancelled.add(run_id)
if run_id in _cancel_events:
_cancel_events[run_id].set()
if job.status in ("pending", "running"):
job.status = "cancelled"

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}
12 changes: 10 additions & 2 deletions api/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,16 @@ def main() -> None:
send({"type": "ready", "params_schema": 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 to find the correct node for multi-node extensions:
# MODEL_DIR is set by ExtensionProcess to MODELS_DIR/ext_id/node_id,
# so its last component matches the node id.
nodes = manifest.get("nodes") or []
node = {}
if nodes and _MODEL_DIR_OVERRIDE:
node_id = Path(_MODEL_DIR_OVERRIDE).name
node = next((n for n in nodes if n.get("id") == node_id), nodes[0])
elif nodes:
node = nodes[0]

# Use MODEL_DIR env var (set by ExtensionProcess) when available so the
# generator uses the exact same path that is_downloaded() checks against.
Expand Down
11 changes: 6 additions & 5 deletions api/services/generator_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,12 @@ def get_active(self) -> BaseGenerator:
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()
# Let the subprocess handle its own download logic during
# load() — some extensions (e.g. mv-adapter) need custom
# multi-repo downloads that the standard HF endpoint can't do.
pass
else:
gen._auto_download()
gen.load()
return gen

Expand Down
30 changes: 26 additions & 4 deletions electron/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { join } from 'path'
import { rm as rmAsync, readFile, writeFile, mkdir, readdir, rename, cp } from 'fs/promises'
import { existsSync, readdirSync, statSync } from 'fs'
import axios from 'axios'
import tar from 'tar'
import * as tar from 'tar'
import { PythonBridge, API_BASE_URL } from './python-bridge'
import {
isModelDownloaded,
Expand Down Expand Up @@ -344,13 +344,33 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
apiUrl: API_BASE_URL
}))

// Settings
// Settings — seed HF token into main-process env at startup
{
const initialToken = getSettings(app.getPath('userData')).hfToken ?? ''
if (initialToken) {
process.env['HUGGING_FACE_HUB_TOKEN'] = initialToken
process.env['HF_TOKEN'] = initialToken
}
}

ipcMain.handle('settings:get', () => {
return getSettings(app.getPath('userData'))
})

ipcMain.handle('settings:set', (_event, patch: { modelsDir?: string; workspaceDir?: string; extensionsDir?: string }) => {
return setSettings(app.getPath('userData'), patch)
ipcMain.handle('settings:set', async (_event, patch: { modelsDir?: string; workspaceDir?: string; extensionsDir?: string; hfToken?: string }) => {
const updated = setSettings(app.getPath('userData'), patch)
// Keep main-process env in sync so child processes spawned after token change inherit it
if (patch.hfToken !== undefined) {
process.env['HUGGING_FACE_HUB_TOKEN'] = patch.hfToken
process.env['HF_TOKEN'] = patch.hfToken
// Also push the token into the live FastAPI process env so extension
// subprocesses spawned by ExtensionProcess._build_env() pick it up
// without requiring a full app restart.
try {
await axios.post(`${API_BASE_URL}/settings/hf-token`, { token: patch.hfToken }, { timeout: 3000 })
} catch { /* FastAPI may not be running yet — ignore */ }
}
return updated
})

// Directory picker
Expand Down Expand Up @@ -510,6 +530,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
id: string
name?: string
input?: 'mesh' | 'image' | 'text'
inputs?: ('mesh' | 'image' | 'text')[]
output?: 'mesh' | 'image' | 'text'
params_schema?: unknown[]
hf_repo?: string
Expand All @@ -534,6 +555,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
id: n.id,
name: n.name ?? n.id,
input: n.input ?? 'image' as const,
inputs: n.inputs,
output: n.output ?? 'mesh' as const,
paramsSchema: n.params_schema ?? [],
hfRepo: n.hf_repo,
Expand Down
6 changes: 6 additions & 0 deletions electron/main/model-downloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
*/
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { join } from 'path'
import { getSettings } from './settings-store'
import { app } from 'electron'

export interface DownloadProgress {
percent: number
Expand Down Expand Up @@ -116,6 +118,10 @@ export async function downloadModelFromHF(
if (skipPrefixes && skipPrefixes.length > 0) {
url += `&skip_prefixes=${encodeURIComponent(JSON.stringify(skipPrefixes))}`
}
const hfToken = getSettings(app.getPath('userData')).hfToken
if (hfToken) {
url += `&token=${encodeURIComponent(hfToken)}`
}

const res = await net.fetch(url)
if (!res.ok) throw new Error(`HuggingFace download failed: HTTP ${res.status}`)
Expand Down
Loading
Loading