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 }} /> } > -
+