diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a7f5028..442c725 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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
diff --git a/README.md b/README.md
index d6b3d2d..d201240 100644
--- a/README.md
+++ b/README.md
@@ -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.
---
diff --git a/api/main.py b/api/main.py
index 75e6a6b..eda77fa 100644
--- a/api/main.py
+++ b/api/main.py
@@ -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
@@ -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,
)
@@ -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}")
diff --git a/api/routers/model.py b/api/routers/model.py
index 6ababd5..6d24871 100644
--- a/api/routers/model.py
+++ b/api/routers/model.py
@@ -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:
@@ -93,6 +95,9 @@ 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()
@@ -100,12 +105,12 @@ 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)
]
@@ -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
@@ -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)
diff --git a/api/routers/settings.py b/api/routers/settings.py
index e8444a7..4ad895f 100644
--- a/api/routers/settings.py
+++ b/api/routers/settings.py
@@ -1,3 +1,4 @@
+import os
from fastapi import APIRouter
from pydantic import BaseModel
from pathlib import Path
@@ -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 {
@@ -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}
diff --git a/api/routers/workflow_runs.py b/api/routers/workflow_runs.py
new file mode 100644
index 0000000..efcf3f0
--- /dev/null
+++ b/api/routers/workflow_runs.py
@@ -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}
diff --git a/api/runner.py b/api/runner.py
index 43a040c..e7d46d3 100644
--- a/api/runner.py
+++ b/api/runner.py
@@ -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.
diff --git a/api/services/generator_registry.py b/api/services/generator_registry.py
index 039db3b..de1f20c 100644
--- a/api/services/generator_registry.py
+++ b/api/services/generator_registry.py
@@ -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
diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts
index 6cadd7d..3871b97 100644
--- a/electron/main/ipc-handlers.ts
+++ b/electron/main/ipc-handlers.ts
@@ -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,
@@ -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
@@ -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
@@ -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,
diff --git a/electron/main/model-downloader.ts b/electron/main/model-downloader.ts
index 544adfd..4d6f76c 100644
--- a/electron/main/model-downloader.ts
+++ b/electron/main/model-downloader.ts
@@ -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
@@ -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}`)
diff --git a/electron/main/python-bridge.ts b/electron/main/python-bridge.ts
index d04d028..4e8d436 100644
--- a/electron/main/python-bridge.ts
+++ b/electron/main/python-bridge.ts
@@ -51,12 +51,14 @@ export class PythonBridge {
cwd: apiDir,
env: {
...cleanPythonEnv(),
- PYTHONUNBUFFERED: '1',
+ PYTHONUNBUFFERED: '1',
// No PYTHONPATH needed — the venv's Python has its own isolated site-packages
- MODELS_DIR: this.resolveModelsDir(),
- WORKSPACE_DIR: this.resolveWorkspaceDir(),
- EXTENSIONS_DIR: this.resolveExtensionsDir(),
- SELECTED_MODEL_ID: process.env['SELECTED_MODEL_ID'] ?? '',
+ MODELS_DIR: this.resolveModelsDir(),
+ WORKSPACE_DIR: this.resolveWorkspaceDir(),
+ EXTENSIONS_DIR: this.resolveExtensionsDir(),
+ SELECTED_MODEL_ID: process.env['SELECTED_MODEL_ID'] ?? '',
+ HUGGING_FACE_HUB_TOKEN: this.resolveHfToken(),
+ HF_TOKEN: this.resolveHfToken(),
}
})
@@ -207,4 +209,8 @@ export class PythonBridge {
mkdirSync(s.extensionsDir, { recursive: true })
return s.extensionsDir
}
+
+ private resolveHfToken(): string {
+ return getSettings(app.getPath('userData')).hfToken ?? ''
+ }
}
diff --git a/electron/main/settings-store.ts b/electron/main/settings-store.ts
index 6d421b0..c90599a 100644
--- a/electron/main/settings-store.ts
+++ b/electron/main/settings-store.ts
@@ -7,6 +7,7 @@ export interface AppSettings {
workflowsDir: string
extensionsDir: string
dependenciesDir: string
+ hfToken?: string
}
function settingsPath(userData: string): string {
diff --git a/electron/preload/index.ts b/electron/preload/index.ts
index a360697..09cacf6 100644
--- a/electron/preload/index.ts
+++ b/electron/preload/index.ts
@@ -56,9 +56,9 @@ contextBridge.exposeInMainWorld('electron', {
// Settings
settings: {
- get: (): Promise<{ modelsDir: string; workspaceDir: string; workflowsDir: string; extensionsDir: string }> =>
+ get: (): Promise<{ modelsDir: string; workspaceDir: string; workflowsDir: string; extensionsDir: string; hfToken?: string }> =>
ipcRenderer.invoke('settings:get'),
- set: (patch: { modelsDir?: string; workspaceDir?: string; workflowsDir?: string; extensionsDir?: string }): Promise<{ modelsDir: string; workspaceDir: string; workflowsDir: string; extensionsDir: string }> =>
+ set: (patch: { modelsDir?: string; workspaceDir?: string; workflowsDir?: string; extensionsDir?: string; hfToken?: string }): Promise<{ modelsDir: string; workspaceDir: string; workflowsDir: string; extensionsDir: string; hfToken?: string }> =>
ipcRenderer.invoke('settings:set', patch),
},
diff --git a/package.json b/package.json
index 7f842de..2db4168 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "modly",
- "version": "0.3.1",
+ "version": "0.3.2",
"description": "Local AI-powered 3D mesh generation from images",
"main": "./out/main/index.js",
"author": "Modly",
diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx
index d4889aa..318d235 100644
--- a/src/areas/generate/GeneratePage.tsx
+++ b/src/areas/generate/GeneratePage.tsx
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react'
import { useAppStore } from '@shared/stores/appStore'
import type { GenerationJob } from '@shared/stores/appStore'
import { useApi } from '@shared/hooks/useApi'
+import { ColorPicker } from '@shared/components/ui'
import GenerationHUD from './components/GenerationHUD'
import Viewer3D from './components/Viewer3D'
import WorkflowPanel from './components/WorkflowPanel'
@@ -122,6 +123,87 @@ function DecimatePopover({
)
}
+// ---------------------------------------------------------------------------
+// Light popover
+// ---------------------------------------------------------------------------
+
+export interface LightSettings {
+ mainIntensity: number
+ mainColor: string
+ fillIntensity: number
+ fillColor: string
+}
+
+export const DEFAULT_LIGHT_SETTINGS: LightSettings = {
+ mainIntensity: 1.5,
+ mainColor: '#ffffff',
+ fillIntensity: 0.6,
+ fillColor: '#ffffff',
+}
+
+function LightPopover({
+ settings,
+ onChange,
+ onClose,
+}: {
+ settings: LightSettings
+ onChange: (s: LightSettings) => void
+ onClose: () => void
+}) {
+ function lightRow(
+ label: string,
+ colorKey: keyof LightSettings,
+ intensityKey: keyof LightSettings,
+ max: number,
+ ) {
+ const intensity = settings[intensityKey] as number
+ const color = settings[colorKey] as string
+ return (
+
+
+ onChange({ ...settings, [colorKey]: c })}
+ />
+ {label}
+ {intensity.toFixed(1)}
+
+
onChange({ ...settings, [intensityKey]: parseFloat(e.target.value) })}
+ className="w-full h-1.5 accent-violet-500 cursor-pointer"
+ />
+
+ )
+ }
+
+ return (
+
+
+
Lighting
+
+
+ {lightRow('Sun', 'mainColor', 'mainIntensity', 4)}
+ {lightRow('Fill', 'fillColor', 'fillIntensity', 2)}
+
+
+ )
+}
+
// ---------------------------------------------------------------------------
// Smooth popover
// ---------------------------------------------------------------------------
@@ -191,7 +273,8 @@ function SmoothPopover({
export default function GeneratePage(): JSX.Element {
const [unloadStatus, setUnloadStatus] = useState<'idle' | 'done'>('idle')
const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH)
- const [openPanel, setOpenPanel] = useState<'export' | 'decimate' | 'smooth' | 'import' | null>(null)
+ const [openPanel, setOpenPanel] = useState<'export' | 'decimate' | 'smooth' | 'import' | 'light' | null>(null)
+ const [lightSettings, setLightSettings] = useState(DEFAULT_LIGHT_SETTINGS)
const [decimating, setDecimating] = useState(false)
const [smoothing, setSmoothing] = useState(false)
const [importing, setImporting] = useState(false)
@@ -499,13 +582,46 @@ export default function GeneratePage(): JSX.Element {
/>
)}
+
>
)}
+
+ {/* Light — always visible, pushed to the right */}
+
+
+ {openPanel === 'light' && (
+ setOpenPanel(null)}
+ />
+ )}
+
{/* Viewer area */}
-
+
{/* Free memory — overlay top-left */}
diff --git a/src/areas/generate/components/GenerationHUD.tsx b/src/areas/generate/components/GenerationHUD.tsx
index abef34c..768797a 100644
--- a/src/areas/generate/components/GenerationHUD.tsx
+++ b/src/areas/generate/components/GenerationHUD.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { useGeneration } from '@shared/hooks/useGeneration'
function formatElapsed(seconds: number): string {
@@ -11,6 +11,15 @@ export default function GenerationHUD(): JSX.Element | null {
const { currentJob, reset } = useGeneration()
const [elapsed, setElapsed] = useState(0)
const [tqdmLog, setTqdmLog] = useState
(null)
+ const [copied, setCopied] = useState(false)
+ const copyTimeout = useRef | null>(null)
+
+ function handleCopyError(text: string) {
+ navigator.clipboard.writeText(text)
+ setCopied(true)
+ if (copyTimeout.current) clearTimeout(copyTimeout.current)
+ copyTimeout.current = setTimeout(() => setCopied(false), 2000)
+ }
const status = currentJob?.status
const isActive = status === 'uploading' || status === 'generating'
@@ -90,12 +99,38 @@ export default function GenerationHUD(): JSX.Element | null {
{error}
-
+
+
+ {error && (
+
+ )}
+
)}
diff --git a/src/areas/generate/components/Viewer3D.tsx b/src/areas/generate/components/Viewer3D.tsx
index 0d88583..01c055d 100644
--- a/src/areas/generate/components/Viewer3D.tsx
+++ b/src/areas/generate/components/Viewer3D.tsx
@@ -12,6 +12,8 @@ THREE.Mesh.prototype.raycast = acceleratedRaycast
import { useGeneration } from '@shared/hooks/useGeneration'
import { useAppStore } from '@shared/stores/appStore'
import { ViewerToolbar, type ViewMode } from './ViewerToolbar'
+import type { LightSettings } from '../GeneratePage'
+import { DEFAULT_LIGHT_SETTINGS } from '../GeneratePage'
// ---------------------------------------------------------------------------
// Procedural textures
@@ -145,11 +147,15 @@ function MeshModel({ url, jobId, viewMode, onStats, onSelect }: MeshModelProps):
}
}, [url])
- // Compute BVH on all geometries for fast raycasting (O(log N) vs O(N))
+ // Compute BVH on all geometries for fast raycasting (O(log N) vs O(N)).
+ // Also force DoubleSide on every material so faces with inverted normals
+ // (a known artifact of the flexible-dual-grid mesh decoder) are still visible.
useEffect(() => {
scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
(child.geometry as any).computeBoundsTree()
+ const mats = Array.isArray(child.material) ? child.material : [child.material]
+ mats.forEach((m: THREE.Material) => { m.side = THREE.DoubleSide })
}
})
return () => {
@@ -331,7 +337,7 @@ function EmptyState(): JSX.Element {
// Viewer3D
// ---------------------------------------------------------------------------
-export default function Viewer3D(): JSX.Element {
+export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS }: { lightSettings?: LightSettings }): JSX.Element {
const { currentJob } = useGeneration()
const apiUrl = useAppStore((s) => s.apiUrl)
@@ -403,9 +409,8 @@ export default function Viewer3D(): JSX.Element {
{modelUrl && currentJob ? (
-
-
-
+
+
{
- const sorted = topoSortNodes(workflow.nodes, workflow.edges)
- const extNodes = sorted.filter((n) => n.type === 'extensionNode')
- // Determine initial type from the actual source node in the graph
- const firstSource = sorted.find((n) => n.type === 'imageNode' || n.type === 'meshNode' || n.type === 'textNode')
- let prev: string = firstSource?.type === 'meshNode' ? 'mesh'
- : firstSource?.type === 'textNode' ? 'text'
- : 'image'
+ // Build a map of what type each node produces
+ const nodeOutput = new Map()
+ for (const node of workflow.nodes) {
+ if (node.type === 'imageNode') { nodeOutput.set(node.id, 'image'); continue }
+ if (node.type === 'meshNode') { nodeOutput.set(node.id, 'mesh'); continue }
+ if (node.type === 'textNode') { nodeOutput.set(node.id, 'text'); continue }
+ if (node.type === 'extensionNode') {
+ const ext = getWorkflowExtension(node.data.extensionId ?? '', allExtensions)
+ if (ext) nodeOutput.set(node.id, ext.output)
+ }
+ }
+ // For each extension node, check that every incoming edge carries an accepted type
+ const extNodes = workflow.nodes.filter((n) => n.type === 'extensionNode')
for (const node of extNodes) {
const ext = getWorkflowExtension(node.data.extensionId ?? '', allExtensions)
if (!ext) continue
- if (prev !== ext.input) return true
- prev = ext.output
+ const accepted = ext.inputs ?? [ext.input]
+ for (const edge of workflow.edges) {
+ if (edge.target !== node.id) continue
+ const srcType = nodeOutput.get(edge.source)
+ if (srcType && !accepted.includes(srcType as any)) return true
+ }
}
return false
}, [workflow, allExtensions])
diff --git a/src/areas/settings/SettingsPage.tsx b/src/areas/settings/SettingsPage.tsx
index 7c9952d..a369cdb 100644
--- a/src/areas/settings/SettingsPage.tsx
+++ b/src/areas/settings/SettingsPage.tsx
@@ -1,9 +1,10 @@
import { useState } from 'react'
-import { StorageSection } from './components/StorageSection'
-import { AboutSection } from './components/AboutSection'
-import { LogsSection } from './components/LogsSection'
+import { StorageSection } from './components/StorageSection'
+import { AboutSection } from './components/AboutSection'
+import { LogsSection } from './components/LogsSection'
+import { IntegrationsSection } from './components/IntegrationsSection'
-type Section = 'storage' | 'logs' | 'about'
+type Section = 'storage' | 'integrations' | 'logs' | 'about'
const SECTIONS: { id: Section; label: string; icon: JSX.Element }[] = [
{
@@ -17,6 +18,16 @@ const SECTIONS: { id: Section; label: string; icon: JSX.Element }[] = [
)
},
+ {
+ id: 'integrations',
+ label: 'Integrations',
+ icon: (
+
+ )
+ },
{
id: 'logs',
label: 'Logs',
@@ -74,9 +85,10 @@ export default function SettingsPage(): JSX.Element {
{/* Content */}
- {section === 'storage' &&
}
- {section === 'logs' &&
}
- {section === 'about' &&
}
+ {section === 'storage' &&
}
+ {section === 'integrations' &&
}
+ {section === 'logs' &&
}
+ {section === 'about' &&
}
diff --git a/src/areas/settings/components/IntegrationsSection.tsx b/src/areas/settings/components/IntegrationsSection.tsx
new file mode 100644
index 0000000..cf76790
--- /dev/null
+++ b/src/areas/settings/components/IntegrationsSection.tsx
@@ -0,0 +1,112 @@
+import { useEffect, useState } from 'react'
+import { Section, Card, Row } from '@shared/ui'
+
+export function IntegrationsSection(): JSX.Element {
+ const [token, setToken] = useState('')
+ const [visible, setVisible] = useState(false)
+ const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
+
+ useEffect(() => {
+ window.electron.settings.get().then((s) => {
+ setToken(s.hfToken ?? '')
+ })
+ }, [])
+
+ async function handleSave() {
+ setStatus('saving')
+ try {
+ await window.electron.settings.set({ hfToken: token.trim() })
+ setStatus('saved')
+ setTimeout(() => setStatus('idle'), 2500)
+ } catch {
+ setStatus('error')
+ setTimeout(() => setStatus('idle'), 3000)
+ }
+ }
+
+ async function handleClear() {
+ setToken('')
+ setStatus('saving')
+ try {
+ await window.electron.settings.set({ hfToken: '' })
+ setStatus('saved')
+ setTimeout(() => setStatus('idle'), 2500)
+ } catch {
+ setStatus('error')
+ setTimeout(() => setStatus('idle'), 3000)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
{ setToken(e.target.value); setStatus('idle') }}
+ onKeyDown={(e) => e.key === 'Enter' && handleSave()}
+ placeholder="hf_…"
+ spellCheck={false}
+ className="w-full px-3 py-1.5 pr-8 rounded-lg bg-zinc-800 border border-zinc-700/60 text-xs font-mono text-zinc-200 placeholder:text-zinc-600 focus:outline-none focus:border-zinc-500 transition-colors"
+ />
+
+
+
+ {token && (
+
+ )}
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/areas/workflows/WorkflowsPage.tsx b/src/areas/workflows/WorkflowsPage.tsx
index 66440e2..e2575e6 100644
--- a/src/areas/workflows/WorkflowsPage.tsx
+++ b/src/areas/workflows/WorkflowsPage.tsx
@@ -21,18 +21,19 @@ import type { Workflow, WFNode, WFEdge, WFNodeData } from '@shared/types/electro
import { buildAllWorkflowExtensions, getWorkflowExtension } from './mockExtensions'
import type { WorkflowExtension } from './mockExtensions'
import { useWorkflowRunStore } from './workflowRunStore'
-import ExtensionNode from './nodes/ExtensionNode'
-import ImageNode from './nodes/ImageNode'
-import TextNode from './nodes/TextNode'
-import AddToSceneNode from './nodes/AddToSceneNode'
-import Load3DMeshNode from './nodes/Load3DMeshNode'
-import WorkflowEdge from './nodes/WorkflowEdge'
+import ExtensionNode from './nodes/ExtensionNode'
+import ImageNode from './nodes/ImageNode'
+import TextNode from './nodes/TextNode'
+import AddToSceneNode from './nodes/AddToSceneNode'
+import Load3DMeshNode from './nodes/Load3DMeshNode'
+import PreviewImageNode from './nodes/PreviewImageNode'
+import WorkflowEdge from './nodes/WorkflowEdge'
// ─── Constants ────────────────────────────────────────────────────────────────
const DRAG_KEY = 'modly/extension-id'
const DRAG_NODE_KEY = 'modly/node-type'
-const NODE_TYPES = { extensionNode: ExtensionNode, imageNode: ImageNode, textNode: TextNode, outputNode: AddToSceneNode, meshNode: Load3DMeshNode }
+const NODE_TYPES = { extensionNode: ExtensionNode, imageNode: ImageNode, textNode: TextNode, outputNode: AddToSceneNode, meshNode: Load3DMeshNode, previewNode: PreviewImageNode }
const EDGE_TYPES = { workflowEdge: WorkflowEdge }
const DEFAULT_EDGE_OPTS = { type: 'workflowEdge' }
@@ -149,10 +150,11 @@ const PANEL_MIN = 240
const PANEL_MAX = 860
const PANEL_BUILTIN_NODES = [
- { type: 'imageNode', label: 'Image', color: '#38bdf8', icon: <>> },
- { type: 'textNode', label: 'Text', color: '#fbbf24', icon: <>> },
- { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', icon: <>> },
- { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', icon: <>> },
+ { type: 'imageNode', label: 'Image', color: '#38bdf8', icon: <>> },
+ { type: 'textNode', label: 'Text', color: '#fbbf24', icon: <>> },
+ { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', icon: <>> },
+ { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', icon: <>> },
+ { type: 'previewNode', label: 'Preview Views', color: '#38bdf8', icon: <>> },
]
function ExtGroupHeader({ title, author, expanded, onToggle, count }: { title: string; author?: string; expanded: boolean; onToggle: () => void; count: number }) {
@@ -391,10 +393,11 @@ function PanelToggleIcon({ open }: { open: boolean }) {
// ─── Node palette (Space to open) ────────────────────────────────────────────
const BUILTIN_NODES = [
- { type: 'imageNode', label: 'Image', color: '#38bdf8', description: 'Image input' },
- { type: 'textNode', label: 'Text', color: '#fbbf24', description: 'Text input' },
- { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', description: 'Load a 3D mesh file or use current model' },
- { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', description: 'Output node' },
+ { type: 'imageNode', label: 'Image', color: '#38bdf8', description: 'Image input' },
+ { type: 'textNode', label: 'Text', color: '#fbbf24', description: 'Text input' },
+ { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', description: 'Load a 3D mesh file or use current model' },
+ { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', description: 'Output node — adds the mesh to the 3D scene' },
+ { type: 'previewNode', label: 'Preview Views', color: '#38bdf8', description: 'Displays multi-view image outputs in a 2×3 grid' },
]
type PaletteItem =
@@ -732,6 +735,32 @@ function HelpModal({ onClose }: { onClose: () => void }) {
)
}
+// ─── Connection type helpers ──────────────────────────────────────────────────
+
+function getNodeOutputType(node: Node | undefined, allExts: WorkflowExtension[]): string | undefined {
+ if (!node) return undefined
+ if (node.type === 'imageNode') return 'image'
+ if (node.type === 'meshNode') return 'mesh'
+ if (node.type === 'textNode') return 'text'
+ return allExts.find((e) => e.id === (node.data as WFNodeData)?.extensionId)?.output
+}
+
+function getNodeInputType(
+ node: Node | undefined,
+ targetHandle: string | null | undefined,
+ allExts: WorkflowExtension[],
+): string | undefined {
+ if (!node) return undefined
+ if (node.type === 'outputNode') return 'mesh'
+ if (node.type === 'previewNode') return 'image'
+ const ext = allExts.find((e) => e.id === (node.data as WFNodeData)?.extensionId)
+ if (ext?.inputs && ext.inputs.length > 1 && targetHandle) {
+ const idx = parseInt(targetHandle.replace('input-', ''), 10)
+ return ext.inputs[isNaN(idx) ? 0 : idx] ?? ext.input
+ }
+ return ext?.input
+}
+
// ─── Workflow canvas (inner, requires ReactFlowProvider) ──────────────────────
function WorkflowCanvasInner({
@@ -747,7 +776,7 @@ function WorkflowCanvasInner({
onNew: () => void
onImport: () => void
}) {
- const { screenToFlowPosition, updateNodeData } = useReactFlow()
+ const { screenToFlowPosition, updateNodeData, getNode } = useReactFlow()
const { runState, run: runWorkflow, cancel } = useWorkflowRunStore()
const isRunning = runState.status === 'running'
@@ -838,6 +867,13 @@ function WorkflowCanvasInner({
const canUndo = histIdx > 0
const canRedo = histIdx < historyRef.current.length - 1
+ const isValidConnection = useCallback((connection: Connection) => {
+ const srcType = getNodeOutputType(getNode(connection.source) as Node, allExtensions)
+ const tgtType = getNodeInputType(getNode(connection.target) as Node, connection.targetHandle, allExtensions)
+ if (!srcType || !tgtType) return true // unknown type — allow
+ return srcType === tgtType
+ }, [getNode, allExtensions])
+
const onConnectStart = useCallback((_: React.MouseEvent | React.TouchEvent, params: OnConnectStartParams) => {
pendingConnectionRef.current = params
connectionCompletedRef.current = false
@@ -1133,6 +1169,7 @@ function WorkflowCanvasInner({
onEdgesChange={onEdgesChange}
onConnectStart={onConnectStart}
onConnect={onConnect}
+ isValidConnection={isValidConnection}
onConnectEnd={onConnectEnd}
onEdgeContextMenu={(e, edge) => { e.preventDefault(); setEdges((eds) => eds.filter((ed) => ed.id !== edge.id)) }}
defaultEdgeOptions={DEFAULT_EDGE_OPTS}
diff --git a/src/areas/workflows/mockExtensions.ts b/src/areas/workflows/mockExtensions.ts
index 791f0ff..76e757f 100644
--- a/src/areas/workflows/mockExtensions.ts
+++ b/src/areas/workflows/mockExtensions.ts
@@ -11,6 +11,7 @@ export interface WorkflowExtension {
name: string
description: string
input: 'image' | 'text' | 'mesh'
+ inputs?: ('image' | 'text' | 'mesh')[] // multi-input; overrides input when set
output: 'image' | 'text' | 'mesh'
params: ParamSchema[]
builtin: boolean
@@ -34,6 +35,7 @@ export function buildAllWorkflowExtensions(
name: node.name,
description: ext.description ?? '',
input: node.input,
+ inputs: node.inputs,
output: node.output,
params: node.paramsSchema as ParamSchema[],
builtin: ext.builtin,
@@ -53,6 +55,7 @@ export function buildAllWorkflowExtensions(
name: node.name,
description: ext.description ?? '',
input: node.input,
+ inputs: node.inputs,
output: node.output,
params: node.paramsSchema as ParamSchema[],
builtin: ext.builtin,
diff --git a/src/areas/workflows/nodes/BaseNode.tsx b/src/areas/workflows/nodes/BaseNode.tsx
index 08821c4..856e528 100644
--- a/src/areas/workflows/nodes/BaseNode.tsx
+++ b/src/areas/workflows/nodes/BaseNode.tsx
@@ -1,7 +1,9 @@
-import { useState, useRef, useLayoutEffect } from 'react'
+import { useState, useRef } from 'react'
import { NodeResizer, useReactFlow } from '@xyflow/react'
import type { ReactNode } from 'react'
+const RESIZER_HANDLE_STYLE = { background: 'transparent', border: 'none', width: 12, height: 12 }
+
// ─── Props ────────────────────────────────────────────────────────────────────
export interface BaseNodeProps {
@@ -28,6 +30,7 @@ export interface BaseNodeProps {
// Resize
minWidth?: number // default 180
minHeight?: number // default 60
+ autoHeight?: boolean // when true: node sizes to content, no vertical resize
// Body content (hidden when collapsed)
children?: ReactNode
@@ -45,27 +48,19 @@ export default function BaseNode({
subheader, handles,
minWidth = 180,
minHeight = 60,
+ autoHeight = false,
children,
}: BaseNodeProps) {
const { updateNodeData, deleteElements } = useReactFlow()
const [expanded, setExpanded] = useState(defaultExpanded)
const rootRef = useRef(null)
- const [minW, setMinW] = useState(minWidth)
- const [minH, setMinH] = useState(minHeight)
-
- useLayoutEffect(() => {
- if (rootRef.current) {
- setMinW(rootRef.current.offsetWidth)
- setMinH(rootRef.current.offsetHeight)
- }
- }, [])
const isDisabled = enabled === false
return (
{/* React Flow handles — must live at root level */}
diff --git a/src/areas/workflows/nodes/ExtensionNode.tsx b/src/areas/workflows/nodes/ExtensionNode.tsx
index f386989..d0498ed 100644
--- a/src/areas/workflows/nodes/ExtensionNode.tsx
+++ b/src/areas/workflows/nodes/ExtensionNode.tsx
@@ -119,30 +119,114 @@ function ParamControl({ param, value, onChange }: {
export default function ExtensionNode({ id, data, selected }: { id: string; data: WFNodeData; selected?: boolean }) {
const { updateNodeData } = useReactFlow()
const running = useWorkflowRunStore((s) => s.activeNodeId === id)
- const ioRowRef = useRef
(null)
- const [handleTop, setHandleTop] = useState('50%')
- // Align handles with the IO row
- useLayoutEffect(() => {
- if (ioRowRef.current) {
- const center = ioRowRef.current.offsetTop + ioRowRef.current.offsetHeight / 2
- setHandleTop(`${center}px`)
- }
- }, [])
+ // Refs for handle alignment — support up to 2 inputs
+ const ioRowRef = useRef(null)
+ const ioRow2Ref = useRef(null)
+ const [handleTop, setHandleTop] = useState('50%')
+ const [handle2Top, setHandle2Top] = useState('50%')
const { modelExtensions, processExtensions } = useExtensionsStore()
const ext = buildAllWorkflowExtensions(modelExtensions, processExtensions)
.find((e) => e.id === data.extensionId)
+ const inputs = ext?.inputs // defined → multi-input mode
+ const isMulti = inputs && inputs.length > 1
const isTerminal = ext?.id === 'mesh-exporter'
- const inputColor = HANDLE_COLOR[ext?.input ?? 'image']
const outputColor = HANDLE_COLOR[ext?.output ?? 'mesh']
const hasParams = (ext?.params.length ?? 0) > 0
+ // Align handles with their respective IO rows after mount
+ useLayoutEffect(() => {
+ if (ioRowRef.current) {
+ const center = ioRowRef.current.offsetTop + ioRowRef.current.offsetHeight / 2
+ setHandleTop(`${center}px`)
+ }
+ if (ioRow2Ref.current) {
+ const center = ioRow2Ref.current.offsetTop + ioRow2Ref.current.offsetHeight / 2
+ setHandle2Top(`${center}px`)
+ }
+ }, [isMulti])
+
const patchParam = useCallback((key: string, val: number | string) => {
updateNodeData(id, { params: { ...data.params, [key]: val } })
}, [id, data.params, updateNodeData])
+ // ── IO subheader ─────────────────────────────────────────────────────────
+ const ioSubheader = isMulti ? (
+ // Multi-input layout: one row per input, output on first row
+
+
+
+ {inputs[0]}
+
+ {!isTerminal && (
+ <>
+
+
+ {ext?.output ?? '—'}
+
+ >
+ )}
+
+
+
+ {inputs[1]}
+
+
+
+ ) : (
+ // Single-input layout (existing behavior)
+
+
+ {ext?.input ?? '—'}
+
+ {!isTerminal && (
+ <>
+
+
+ {ext?.output ?? '—'}
+
+ >
+ )}
+
+ )
+
+ // ── Handles ──────────────────────────────────────────────────────────────
+ const handlesEl = (
+ <>
+ {/* Primary input handle */}
+
+ {/* Secondary input handle (multi-input only) */}
+ {isMulti && (
+
+ )}
+ {/* Output handle */}
+ {!isTerminal && (
+
+ )}
+ >
+ )
+
return (
-
- {ext?.input ?? '—'}
-
- {!isTerminal && (
- <>
-
-
- {ext?.output ?? '—'}
-
- >
- )}
-
- }
- handles={<>
-
- {!isTerminal && (
-
- )}
- >}
+ subheader={ioSubheader}
+ handles={handlesEl}
>
{hasParams && (
@@ -185,7 +246,7 @@ export default function ExtensionNode({ id, data, selected }: { id: string; data
const val = (data.params[param.id] ?? param.default) as number | string
return (
-
+
patchParam(param.id, v)} />
diff --git a/src/areas/workflows/nodes/PreviewImageNode.tsx b/src/areas/workflows/nodes/PreviewImageNode.tsx
new file mode 100644
index 0000000..7d7cd46
--- /dev/null
+++ b/src/areas/workflows/nodes/PreviewImageNode.tsx
@@ -0,0 +1,76 @@
+import { Handle, Position, useReactFlow } from '@xyflow/react'
+import { useWorkflowRunStore } from '../workflowRunStore'
+import BaseNode from './BaseNode'
+
+const INPUT_COLOR = '#38bdf8'
+
+/**
+ * Preview node for multi-view image outputs (e.g. MV-Adapter Generate Views).
+ *
+ * Expects a vertical strip PNG where N views are stacked top→bottom.
+ * Displays them in a 2×3 grid using CSS background-position cropping.
+ */
+export default function PreviewImageNode({ id, selected }: { id: string; selected?: boolean }) {
+ const nodeImageOutputs = useWorkflowRunStore((s) => s.nodeImageOutputs)
+ const { getEdges } = useReactFlow()
+
+ // Find the image URL fed into this node (first matching incoming edge)
+ const incomingEdge = getEdges().find((e) => e.target === id)
+ const imageUrl = incomingEdge ? nodeImageOutputs[incomingEdge.source] : undefined
+
+ return (
+
+
+
+
+
+ }
+ subheader={
+
+ image
+ → preview
+
+ }
+ handles={
+
+ }
+ >
+
+ {imageUrl ? (
+
+ {[0, 1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+ ) : (
+
+ Connect a multi-view image to preview.
+
+ )}
+
+
+ )
+}
diff --git a/src/areas/workflows/nodes/TextNode.tsx b/src/areas/workflows/nodes/TextNode.tsx
index fd7cd6d..6e32989 100644
--- a/src/areas/workflows/nodes/TextNode.tsx
+++ b/src/areas/workflows/nodes/TextNode.tsx
@@ -1,4 +1,4 @@
-import { useLayoutEffect, useRef, useState } from 'react'
+import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Handle, Position, useReactFlow } from '@xyflow/react'
import type { WFNodeData } from '@shared/types/electron.d'
import BaseNode from './BaseNode'
@@ -6,8 +6,9 @@ import BaseNode from './BaseNode'
const OUTPUT_COLOR = '#fbbf24'
export default function TextNode({ id, data, selected }: { id: string; data: WFNodeData; selected?: boolean }) {
- const { updateNodeData } = useReactFlow()
- const ioRowRef = useRef
(null)
+ const { updateNodeData, setNodes } = useReactFlow()
+ const ioRowRef = useRef(null)
+ const textareaRef = useRef(null)
const [handleTop, setHandleTop] = useState('50%')
useLayoutEffect(() => {
@@ -17,8 +18,20 @@ export default function TextNode({ id, data, selected }: { id: string; data: WFN
}
}, [])
+ // Clear stored height so React Flow measures content naturally
+ useEffect(() => {
+ setNodes(nodes => nodes.map(n => n.id === id ? { ...n, height: undefined } : n))
+ }, [id, setNodes])
+
const text = (data.params.text as string | undefined) ?? ''
+ useEffect(() => {
+ const ta = textareaRef.current
+ if (!ta) return
+ ta.style.height = 'auto'
+ ta.style.height = `${ta.scrollHeight}px`
+ }, [text])
+
return (
@@ -42,12 +55,14 @@ export default function TextNode({ id, data, selected }: { id: string; data: WFN
style={{ background: OUTPUT_COLOR, width: 14, height: 14, border: '2.5px solid #18181b', top: handleTop }} />
}
>
-
+
diff --git a/src/areas/workflows/nodes/WorkflowEdge.tsx b/src/areas/workflows/nodes/WorkflowEdge.tsx
index ddb5236..02a4052 100644
--- a/src/areas/workflows/nodes/WorkflowEdge.tsx
+++ b/src/areas/workflows/nodes/WorkflowEdge.tsx
@@ -1,4 +1,4 @@
-import { getBezierPath, useReactFlow } from '@xyflow/react'
+import { getBezierPath, useReactFlow, useEdges } from '@xyflow/react'
import type { EdgeProps } from '@xyflow/react'
import { useExtensionsStore } from '@shared/stores/extensionsStore'
import { buildAllWorkflowExtensions } from '../mockExtensions'
@@ -15,12 +15,17 @@ export default function WorkflowEdge({
sourcePosition, targetPosition,
}: EdgeProps) {
const { getNode } = useReactFlow()
+ const edges = useEdges()
const { modelExtensions, processExtensions } = useExtensionsStore()
const allExtensions = buildAllWorkflowExtensions(modelExtensions, processExtensions)
const sourceNode = getNode(source)
const targetNode = getNode(target)
+ // Read targetHandle directly from edge store — reliable regardless of EdgeProps version
+ const thisEdge = edges.find((e) => e.id === id)
+ const targetHandle = thisEdge?.targetHandle
+
const sourceColor = sourceNode?.type === 'imageNode'
? HANDLE_COLOR.image
: sourceNode?.type === 'textNode'
@@ -29,9 +34,21 @@ export default function WorkflowEdge({
? HANDLE_COLOR.mesh
: (HANDLE_COLOR[allExtensions.find((e) => e.id === sourceNode?.data?.extensionId)?.output ?? ''] ?? '#52525b')
+ // For multi-input nodes pick the color of the specific connected handle
+ const targetExt = allExtensions.find((e) => e.id === targetNode?.data?.extensionId)
+ const targetInputType = (() => {
+ if (targetExt?.inputs && targetExt.inputs.length > 1 && targetHandle) {
+ const idx = parseInt(targetHandle.replace('input-', ''), 10)
+ return targetExt.inputs[isNaN(idx) ? 0 : idx] ?? targetExt.input
+ }
+ return targetExt?.input
+ })()
+
const targetColor = targetNode?.type === 'outputNode'
? HANDLE_COLOR.mesh
- : (HANDLE_COLOR[allExtensions.find((e) => e.id === targetNode?.data?.extensionId)?.input ?? ''] ?? '#52525b')
+ : targetNode?.type === 'previewNode'
+ ? HANDLE_COLOR.image
+ : (HANDLE_COLOR[targetInputType ?? ''] ?? '#52525b')
const [edgePath] = getBezierPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition })
const gradientId = `wf-edge-${id}`
diff --git a/src/areas/workflows/useWorkflowRunner.ts b/src/areas/workflows/useWorkflowRunner.ts
index a6c5cb8..5789e9a 100644
--- a/src/areas/workflows/useWorkflowRunner.ts
+++ b/src/areas/workflows/useWorkflowRunner.ts
@@ -80,23 +80,31 @@ export function useWorkflowRunner(allExtensions: WorkflowExtension[]) {
const settings = await window.electron.settings.get()
const workspaceDir = settings.workspaceDir.replace(/\\/g, '/')
- // Track outputs per node so branches each get the correct predecessor output
- const nodeOutputs = new Map
()
+ // Track outputs per node so branches each get the correct predecessor output.
+ // outputType distinguishes image files from mesh files for multi-input routing.
+ const nodeOutputs = new Map()
// Pre-populate source nodes
for (const node of ordered) {
- if (node.type === 'imageNode') nodeOutputs.set(node.id, { filePath: imagePath })
+ if (node.type === 'imageNode') nodeOutputs.set(node.id, { filePath: imagePath, outputType: 'image' })
if (node.type === 'textNode') nodeOutputs.set(node.id, { text: node.data.params?.text as string | undefined })
if (node.type === 'meshNode') {
const source = node.data.params?.source as 'file' | 'current' | undefined
if (source === 'current') {
if (currentMeshUrl) {
- const rel = currentMeshUrl.replace(/^\/workspace\//, '')
- nodeOutputs.set(node.id, { filePath: `${workspaceDir}/${rel}` })
+ let meshFilePath: string
+ if (currentMeshUrl.includes('serve-file?path=')) {
+ const encoded = currentMeshUrl.split('serve-file?path=')[1]
+ meshFilePath = decodeURIComponent(encoded).replace(/\\/g, '/')
+ } else {
+ const rel = currentMeshUrl.replace(/^\/workspace\//, '')
+ meshFilePath = `${workspaceDir}/${rel}`
+ }
+ nodeOutputs.set(node.id, { filePath: meshFilePath, outputType: 'mesh' })
}
} else {
const fp = node.data.params?.filePath as string | undefined
- if (fp) nodeOutputs.set(node.id, { filePath: fp })
+ if (fp) nodeOutputs.set(node.id, { filePath: fp, outputType: 'mesh' })
}
}
}
@@ -107,29 +115,63 @@ export function useWorkflowRunner(allExtensions: WorkflowExtension[]) {
const node = execNodes[i]
const ext = getWorkflowExtension(node.data.extensionId ?? '', allExtensions)
- // Resolve this node's input from its actual predecessors in the graph
+ // Resolve this node's inputs from its actual predecessors in the graph.
+ // For multi-input nodes, route by outputType (image vs mesh).
let nodeInputPath: string | undefined
let nodeInputText: string | undefined
- for (const edge of workflow.edges.filter((e) => e.target === node.id)) {
- const src = nodeOutputs.get(edge.source)
- if (src?.filePath !== undefined) nodeInputPath = src.filePath
- if (src?.text !== undefined) nodeInputText = src.text
- }
- // Fallback: if no edge supplied a file/text, use the previous node's output
- if (nodeInputPath === undefined && nodeInputText === undefined && i > 0) {
- const prev = nodeOutputs.get(execNodes[i - 1].id)
- if (prev?.filePath !== undefined) nodeInputPath = prev.filePath
- if (prev?.text !== undefined) nodeInputText = prev.text
+ let nodeInputMeshPath: string | undefined // for multi-input: the mesh wire
+
+ const incomingEdges = workflow.edges.filter((e) => e.target === node.id)
+ if (ext?.inputs && ext.inputs.length > 1) {
+ // Multi-input: route each edge by the source node's outputType
+ for (const edge of incomingEdges) {
+ const src = nodeOutputs.get(edge.source)
+ if (!src) continue
+ if (src.outputType === 'mesh') nodeInputMeshPath = src.filePath
+ else if (src.outputType === 'image') nodeInputPath = src.filePath
+ else if (src.filePath !== undefined) nodeInputPath = src.filePath
+ if (src.text !== undefined) nodeInputText = src.text
+ }
+ } else {
+ // Single-input: original behaviour
+ for (const edge of incomingEdges) {
+ const src = nodeOutputs.get(edge.source)
+ if (src?.filePath !== undefined) nodeInputPath = src.filePath
+ if (src?.text !== undefined) nodeInputText = src.text
+ }
+ // Fallback: if no edge supplied a file/text, use the previous node's output
+ if (nodeInputPath === undefined && nodeInputText === undefined && i > 0) {
+ const prev = nodeOutputs.get(execNodes[i - 1].id)
+ if (prev?.filePath !== undefined) nodeInputPath = prev.filePath
+ if (prev?.text !== undefined) nodeInputText = prev.text
+ }
}
setRunState((s) => ({ ...s, blockIndex: i, blockProgress: 0, blockStep: 'Starting…' }))
- if (ext?.input === 'image' && ext?.output === 'mesh') {
+ // Model extensions always go through the HTTP API (job queue, progress, GPU).
+ // Process extensions always go through IPC runProcess (CPU, synchronous).
+ const isGeneratorNode = ext?.type === 'model'
+
+ if (isGeneratorNode) {
// ── Generator: call Python FastAPI ──────────────────────────────────
- const base64 = imageData ?? await window.electron.fs.readFileBase64(imagePath)
+ // For multi-input nodes, use the resolved image path (not the global imagePath)
+ const activeImagePath = nodeInputPath ?? imagePath
+ const base64 = imageData && nodeInputPath === undefined
+ ? imageData
+ : await window.electron.fs.readFileBase64(activeImagePath)
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
const blob = new Blob([bytes], { type: 'image/png' })
- const fname = imagePath.split(/[\\/]/).pop() ?? 'image.png'
+ const fname = activeImagePath.split(/[\\/]/).pop() ?? 'image.png'
+
+ // For multi-input nodes: inject the mesh input as params.mesh_path
+ const extraParams: Record = {}
+ if (nodeInputMeshPath) {
+ const norm = nodeInputMeshPath.replace(/\\/g, '/')
+ extraParams.mesh_path = norm.startsWith(workspaceDir)
+ ? norm.slice(workspaceDir.length).replace(/^\//, '')
+ : norm
+ }
const fd = new FormData()
fd.append('image', blob, fname)
@@ -138,7 +180,7 @@ export function useWorkflowRunner(allExtensions: WorkflowExtension[]) {
fd.append('remesh', 'none')
fd.append('enable_texture', 'false')
fd.append('texture_resolution', '1024')
- fd.append('params', JSON.stringify(node.data.params))
+ fd.append('params', JSON.stringify({ ...node.data.params, ...extraParams }))
setRunState((s) => ({ ...s, blockProgress: 5, blockStep: 'Submitting to model…' }))
@@ -195,8 +237,10 @@ export function useWorkflowRunner(allExtensions: WorkflowExtension[]) {
setRunState((s) => ({ ...s, blockProgress: 100, blockStep: 'Done' }))
}
- // Store this node's output so downstream nodes (including other branches) can read it
- nodeOutputs.set(node.id, { filePath: nodeInputPath, text: nodeInputText })
+ // Store this node's output so downstream nodes (including other branches) can read it.
+ // Tag with outputType so multi-input nodes can route by type.
+ const outputType = ext?.output ?? (nodeInputPath ? 'mesh' : undefined)
+ nodeOutputs.set(node.id, { filePath: nodeInputPath, text: nodeInputText, outputType })
}
// Determine outputUrl: prefer what feeds the outputNode (Add to Scene)
diff --git a/src/areas/workflows/workflowRunStore.ts b/src/areas/workflows/workflowRunStore.ts
index 1930907..44b8c3e 100644
--- a/src/areas/workflows/workflowRunStore.ts
+++ b/src/areas/workflows/workflowRunStore.ts
@@ -57,6 +57,8 @@ interface WorkflowRunStore {
runState: WorkflowRunState
activeNodeId: string | null
activeWorkflowId: string | null
+ /** nodeId → workspace URL for image outputs (populated after each run) */
+ nodeImageOutputs: Record
run: (workflow: Workflow, allExtensions: WorkflowExtension[]) => Promise
cancel: () => void
@@ -67,6 +69,7 @@ export const useWorkflowRunStore = create((set) => ({
runState: IDLE,
activeNodeId: null,
activeWorkflowId: null,
+ nodeImageOutputs: {},
async run(workflow, allExtensions) {
_cancel.current = false
@@ -76,13 +79,13 @@ export const useWorkflowRunStore = create((set) => ({
const ordered = topoSort(workflow.nodes, workflow.edges)
const execNodes = ordered.filter((n) => n.type === 'extensionNode' && n.data.enabled)
- // Capture before setCurrentJob overwrites currentJob
const selectedImagePath = appState.selectedImagePath ?? ''
const selectedImageData = appState.selectedImageData ?? undefined
const currentMeshUrl = appState.currentJob?.outputUrl
set({
activeWorkflowId: workflow.id,
+ nodeImageOutputs: {},
runState: { status: 'running', blockIndex: 0, blockTotal: execNodes.length, blockProgress: 0, blockStep: 'Starting…' },
})
@@ -95,17 +98,22 @@ export const useWorkflowRunStore = create((set) => ({
})
try {
- const client = axios.create({ baseURL: apiUrl })
- const settings = await window.electron.settings.get()
+ const client = axios.create({ baseURL: apiUrl })
+ const settings = await window.electron.settings.get()
const workspaceDir = settings.workspaceDir.replace(/\\/g, '/')
- const nodeOutputs = new Map()
+ // Clean up tmp folder from previous run
+ const tmpAbsPath = settings.workspaceDir.replace(/[\\/]+$/, '') + '/tmp'
+ window.electron.fs.deleteDirectory(tmpAbsPath).catch(() => {})
+
+ // nodeId → { filePath, text, outputType }
+ const nodeOutputs = new Map()
// Pre-populate source nodes
for (const node of ordered) {
if (node.type === 'imageNode') {
const fp = node.data.params?.filePath as string | undefined
- nodeOutputs.set(node.id, { filePath: fp ?? selectedImagePath })
+ nodeOutputs.set(node.id, { filePath: fp ?? selectedImagePath, outputType: 'image' })
}
if (node.type === 'textNode') {
nodeOutputs.set(node.id, { text: node.data.params?.text as string | undefined })
@@ -113,11 +121,20 @@ export const useWorkflowRunStore = create((set) => ({
if (node.type === 'meshNode') {
const source = node.data.params?.source as 'file' | 'current' | undefined
if (source === 'current' && currentMeshUrl) {
- const rel = currentMeshUrl.replace(/^\/workspace\//, '')
- nodeOutputs.set(node.id, { filePath: `${workspaceDir}/${rel}` })
+ let meshFilePath: string
+ if (currentMeshUrl.includes('serve-file?path=')) {
+ // URL like /optimize/serve-file?path=D%3A%5C... → extract and decode the real path
+ const encoded = currentMeshUrl.split('serve-file?path=')[1]
+ meshFilePath = decodeURIComponent(encoded).replace(/\\/g, '/')
+ } else {
+ // URL like /workspace/Workflows/file.glb → resolve to absolute path
+ const rel = currentMeshUrl.replace(/^\/workspace\//, '')
+ meshFilePath = `${workspaceDir}/${rel}`
+ }
+ nodeOutputs.set(node.id, { filePath: meshFilePath, outputType: 'mesh' })
} else {
const fp = node.data.params?.filePath as string | undefined
- if (fp) nodeOutputs.set(node.id, { filePath: fp })
+ if (fp) nodeOutputs.set(node.id, { filePath: fp, outputType: 'mesh' })
}
}
}
@@ -128,18 +145,36 @@ export const useWorkflowRunStore = create((set) => ({
const node = execNodes[i]
const ext = getWorkflowExtension(node.data.extensionId ?? '', allExtensions)
- let nodeInputPath: string | undefined
- let nodeInputText: string | undefined
- for (const edge of workflow.edges.filter((e) => e.target === node.id)) {
- const src = nodeOutputs.get(edge.source)
- if (src?.filePath !== undefined) nodeInputPath = src.filePath
- if (src?.text !== undefined) nodeInputText = src.text
- }
- // Fallback: if no edge supplied a file/text, use the previous node's output
- if (nodeInputPath === undefined && nodeInputText === undefined && i > 0) {
- const prev = nodeOutputs.get(execNodes[i - 1].id)
- if (prev?.filePath !== undefined) nodeInputPath = prev.filePath
- if (prev?.text !== undefined) nodeInputText = prev.text
+ // ── Resolve inputs ────────────────────────────────────────────────
+ let nodeInputPath: string | undefined
+ let nodeInputText: string | undefined
+ let nodeInputMeshPath: string | undefined
+
+ const incomingEdges = workflow.edges.filter((e) => e.target === node.id)
+
+ if (ext?.inputs && ext.inputs.length > 1) {
+ // Multi-input: route each incoming edge by the source node's outputType
+ for (const edge of incomingEdges) {
+ const src = nodeOutputs.get(edge.source)
+ if (!src) continue
+ if (src.outputType === 'mesh') nodeInputMeshPath = src.filePath
+ else if (src.outputType === 'image') nodeInputPath = src.filePath
+ else if (src.filePath !== undefined) nodeInputPath = src.filePath
+ if (src.text !== undefined) nodeInputText = src.text
+ }
+ } else {
+ // Single-input
+ for (const edge of incomingEdges) {
+ const src = nodeOutputs.get(edge.source)
+ if (src?.filePath !== undefined) nodeInputPath = src.filePath
+ if (src?.text !== undefined) nodeInputText = src.text
+ }
+ // Fallback to previous node's output
+ if (nodeInputPath === undefined && nodeInputText === undefined && i > 0) {
+ const prev = nodeOutputs.get(execNodes[i - 1].id)
+ if (prev?.filePath !== undefined) nodeInputPath = prev.filePath
+ if (prev?.text !== undefined) nodeInputText = prev.text
+ }
}
set((s) => ({
@@ -147,12 +182,27 @@ export const useWorkflowRunStore = create((set) => ({
runState: { ...s.runState, blockIndex: i, blockProgress: 0, blockStep: 'Starting…' },
}))
- if (ext?.input === 'image' && ext?.output === 'mesh') {
- const imagePath = nodeInputPath ?? selectedImagePath
- const base64 = selectedImageData ?? await window.electron.fs.readFileBase64(imagePath)
- const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
- const blob = new Blob([bytes], { type: 'image/png' })
- const fname = imagePath.split(/[\\/]/).pop() ?? 'image.png'
+ // ── Model extensions → HTTP API ───────────────────────────────────
+ // Process extensions → IPC runProcess
+ const isModelNode = ext?.type === 'model'
+
+ if (isModelNode) {
+ const activeImagePath = nodeInputPath ?? selectedImagePath
+ const base64 = selectedImageData && nodeInputPath === undefined
+ ? selectedImageData
+ : await window.electron.fs.readFileBase64(activeImagePath)
+ const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
+ const blob = new Blob([bytes], { type: 'image/png' })
+ const fname = activeImagePath.split(/[\\/]/).pop() ?? 'image.png'
+
+ // For multi-input nodes: inject mesh path as params.mesh_path
+ const extraParams: Record = {}
+ if (nodeInputMeshPath) {
+ const norm = nodeInputMeshPath.replace(/\\/g, '/')
+ extraParams.mesh_path = norm.startsWith(workspaceDir)
+ ? norm.slice(workspaceDir.length).replace(/^\//, '')
+ : norm
+ }
const fd = new FormData()
fd.append('image', blob, fname)
@@ -161,7 +211,7 @@ export const useWorkflowRunStore = create((set) => ({
fd.append('remesh', 'none')
fd.append('enable_texture', 'false')
fd.append('texture_resolution', '1024')
- fd.append('params', JSON.stringify(node.data.params))
+ fd.append('params', JSON.stringify({ ...node.data.params, ...extraParams }))
set((s) => ({ runState: { ...s.runState, blockProgress: 5, blockStep: 'Submitting to model…' } }))
@@ -204,6 +254,7 @@ export const useWorkflowRunStore = create((set) => ({
}
} else {
+ // ── Process extension → IPC ─────────────────────────────────────
const parts = (node.data.extensionId ?? '').split('/')
const extId = parts[0]
const nodeId = parts[1] ?? ''
@@ -218,10 +269,23 @@ export const useWorkflowRunStore = create((set) => ({
set((s) => ({ runState: { ...s.runState, blockProgress: 100, blockStep: 'Done' } }))
}
- nodeOutputs.set(node.id, { filePath: nodeInputPath, text: nodeInputText })
+ // Store output with type for downstream routing
+ const outputType = ext?.output ?? (nodeInputPath ? 'mesh' : undefined)
+ nodeOutputs.set(node.id, { filePath: nodeInputPath, text: nodeInputText, outputType })
+ }
+
+ // ── Collect image outputs for preview nodes ───────────────────────
+ const imageOutputs: Record = {}
+ for (const [nodeId, out] of nodeOutputs) {
+ if (out.outputType === 'image' && out.filePath) {
+ const norm = out.filePath.replace(/\\/g, '/')
+ if (norm.startsWith(workspaceDir)) {
+ imageOutputs[nodeId] = `/workspace/${norm.slice(workspaceDir.length).replace(/^\//, '')}`
+ }
+ }
}
- // Resolve output URL
+ // ── Resolve final output URL ──────────────────────────────────────
let outputUrl: string | undefined
let outputPath: string | undefined
@@ -252,7 +316,8 @@ export const useWorkflowRunStore = create((set) => ({
}
set({
- activeNodeId: null,
+ activeNodeId: null,
+ nodeImageOutputs: imageOutputs,
runState: {
status: 'done',
blockIndex: execNodes.length > 0 ? execNodes.length - 1 : 0,
@@ -280,10 +345,10 @@ export const useWorkflowRunStore = create((set) => ({
axios.create({ baseURL: apiUrl }).post(`/generate/cancel/${_activeJobId.current}`).catch(() => {})
_activeJobId.current = null
}
- set({ runState: IDLE, activeNodeId: null, activeWorkflowId: null })
+ set({ runState: IDLE, activeNodeId: null, activeWorkflowId: null, nodeImageOutputs: {} })
},
reset() {
- set({ runState: IDLE, activeNodeId: null, activeWorkflowId: null })
+ set({ runState: IDLE, activeNodeId: null, activeWorkflowId: null, nodeImageOutputs: {} })
},
}))
diff --git a/src/shared/components/ui/ColorPicker.tsx b/src/shared/components/ui/ColorPicker.tsx
new file mode 100644
index 0000000..e268083
--- /dev/null
+++ b/src/shared/components/ui/ColorPicker.tsx
@@ -0,0 +1,45 @@
+import { useRef } from 'react'
+
+interface ColorPickerProps {
+ value: string
+ onChange: (color: string) => void
+ size?: 'sm' | 'md'
+}
+
+export function ColorPicker({ value, onChange, size = 'sm' }: ColorPickerProps): JSX.Element {
+ const inputRef = useRef(null)
+
+ const dim = size === 'sm' ? 'w-5 h-5' : 'w-6 h-6'
+
+ return (
+
+ )
+}
diff --git a/src/shared/components/ui/index.ts b/src/shared/components/ui/index.ts
index e33103f..17b6d61 100644
--- a/src/shared/components/ui/index.ts
+++ b/src/shared/components/ui/index.ts
@@ -1,3 +1,4 @@
export { Tooltip } from './Tooltip'
export { FieldLabel } from './FieldLabel'
export { ConfirmModal } from './ConfirmModal'
+export { ColorPicker } from './ColorPicker'
diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts
index 259e12c..acb21ad 100644
--- a/src/shared/types/electron.d.ts
+++ b/src/shared/types/electron.d.ts
@@ -7,6 +7,7 @@ export interface ExtensionNode {
id: string
name: string
input: 'image' | 'text' | 'mesh'
+ inputs?: ('image' | 'text' | 'mesh')[] // multi-input nodes; overrides input when set
output: 'image' | 'text' | 'mesh'
paramsSchema: ParamSchema[]
hfRepo?: string
@@ -133,8 +134,8 @@ declare global {
readScreenshotDataUrl: (filename: string) => Promise
}
settings: {
- get: () => Promise<{ modelsDir: string; workspaceDir: string; workflowsDir: string; extensionsDir: string }>
- set: (patch: { modelsDir?: string; workspaceDir?: string; workflowsDir?: string; extensionsDir?: string }) => Promise<{ modelsDir: string; workspaceDir: string; workflowsDir: string; extensionsDir: string }>
+ get: () => Promise<{ modelsDir: string; workspaceDir: string; workflowsDir: string; extensionsDir: string; hfToken?: string }>
+ set: (patch: { modelsDir?: string; workspaceDir?: string; workflowsDir?: string; extensionsDir?: string; hfToken?: string }) => Promise<{ modelsDir: string; workspaceDir: string; workflowsDir: string; extensionsDir: string; hfToken?: string }>
}
cache: {
clear: () => Promise<{ success: boolean; error?: string }>