diff --git a/.gitignore b/.gitignore index 75e1c49..91bf9fc 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,13 @@ thirdparty/skintokens/experiments/ thirdparty/skintokens/models/ thirdparty/skintokens/tmp_ckpt/ thirdparty/skintokens/tmp_gradio/ -thirdparty/skintokens/results/ \ No newline at end of file +thirdparty/skintokens/results/ +# Runtime logs and downloaded model artifacts +*.log +*.gguf +*.safetensors +*.ckpt +*.pth +*.onnx +*.bin +*.part diff --git a/package-lock.json b/package-lock.json index 8cbf9bd..46d6f6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "app", - "version": "2.0.0", + "version": "2.1.0", "dependencies": { "@excalidraw/excalidraw": "^0.18.1", "@modelcontextprotocol/sdk": "^1.29.0", @@ -20,6 +20,7 @@ "dexie": "^4.4.2", "dexie-react-hooks": "^4.4.0", "express": "^5.2.1", + "js-yaml": "^4.1.1", "lowdb": "^7.0.1", "multer": "^2.1.1", "react": "^19.2.4", @@ -49,6 +50,10 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", "vite": "^8.0.4" + }, + "engines": { + "node": ">=22.18.0 <23", + "npm": ">=11.18.0 <12" } }, "node_modules/@antfu/install-pkg": { @@ -4483,7 +4488,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -8955,7 +8959,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/package.json b/package.json index 382feb7..1fc9250 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "dexie": "^4.4.2", "dexie-react-hooks": "^4.4.0", "express": "^5.2.1", + "js-yaml": "^4.1.1", "lowdb": "^7.0.1", "multer": "^2.1.1", "react": "^19.2.4", @@ -68,5 +69,13 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", "vite": "^8.0.4" + }, + "volta": { + "node": "22.18.0", + "npm": "11.18.0" + }, + "engines": { + "node": ">=22.18.0 <23", + "npm": ">=11.18.0 <12" } } diff --git a/server.js b/server.js index 74eb10f..b4c9a5e 100644 --- a/server.js +++ b/server.js @@ -16,6 +16,7 @@ import si from 'systeminformation'; import { WebSocket as WsWebSocket } from 'ws'; import tencentcloudSdk from 'tencentcloud-sdk-nodejs-intl-en'; import { mountMcp } from './mcp/http.js'; +import yaml from 'js-yaml'; // Node 20 (bundled by Electron 33) has no global WebSocket, so fall back to the // `ws` package. Newer Node runtimes (dev) expose a global WebSocket we can reuse. @@ -125,7 +126,7 @@ process.on('unhandledRejection', reason => { // Build the externally-reachable base URL ("http://host:port") from the // incoming request so generated asset/media URLs point back at whatever host -// and port the client actually used to reach us — works on another machine or +// and port the client actually used to reach us — works on another machine or // another port without baking "localhost" into responses. function getRequestBaseUrl(req) { return `${req.protocol}://${req.get('host')}`; @@ -134,7 +135,7 @@ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bm const MESH_EXTENSIONS = new Set(['.glb', '.gltf', '.obj', '.fbx', '.stl', '.ply']); const comfyProgressSubscribers = new Map(); const comfyProgressSnapshots = new Map(); -// Subscribers to the multiplexed progress stream — a single connection that +// Subscribers to the multiplexed progress stream — a single connection that // receives progress for every promptId. This keeps a handful of concurrent // workflows from exhausting the browser's ~6 connection-per-origin cap. const comfyProgressGlobalSubscribers = new Set(); @@ -184,11 +185,11 @@ app.use('/wiki-media', express.static(WIKI_MEDIA_DIR)); // Bundled reference animation library (mesh2motion, MIT). Ships with the app // under resources/ (animations = skinned GLBs with clips, animpreviews = mp4s) -// and is served read-only for the mesh-editor Auto Rig → Animations feature. +// and is served read-only for the mesh-editor Auto Rig → Animations feature. const RESOURCES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'resources'); app.use('/resources', express.static(RESOURCES_DIR)); -// Serve the production frontend build (vite build → dist/) from the same +// Serve the production frontend build (vite build → dist/) from the same // origin as the API, so a single `node server.js` can host the whole app on // any machine/port. In development the Vite dev server is used instead, so // dist/ is absent and this is skipped. @@ -229,7 +230,7 @@ app.get('/api/events', (req, res) => { }); }); -// MCP server endpoint (POST /mcp) — lets any MCP client (Claude, ChatGPT, +// MCP server endpoint (POST /mcp) — lets any MCP client (Claude, ChatGPT, // local LLMs) automate the app. Tools loop back through this server's own // REST API, so SQLite stays behind this single process. Gated by // settings.mcp (enabled/token) in mcp/http.js. @@ -539,7 +540,7 @@ function buildWikiPageTree(pages) { return roots; } -// ── Wiki ────────────────────────────────────────────────────────────────── +// ── Wiki ────────────────────────────────────────────────────────────────── // Author mode is unlocked only when the gitignored `.wiki-author` marker file // exists at the project root. Checked live so it can be toggled without a // restart. Read-only installations (end users) never have this file. @@ -909,7 +910,7 @@ function parseComfyWorkflow(workflowJson) { nodeTitle: nodeLabel, classType: node.class_type || 'Unknown', name: sanitizeDisplayName(`${nodeLabel} ${inputKey}`, inputKey), - label: `${nodeLabel} • ${inputKey}`, + label: `${nodeLabel} - ${inputKey}`, type, defaultValue: cloneSerializable(value) }); @@ -923,7 +924,7 @@ function parseComfyWorkflow(workflowJson) { nodeId, nodeTitle: getComfyNodeLabel(nodeId, node), classType: node.class_type || 'Unknown', - label: `${getComfyNodeLabel(nodeId, node)} • ${node.class_type || 'Output'}` + label: `${getComfyNodeLabel(nodeId, node)} - ${node.class_type || 'Output'}` })); return { inputs, outputs }; @@ -1205,7 +1206,7 @@ function createComfyExecutionMonitor(baseUrl, { clientId, promptId, workflowJson isReady = true; publishState({ status: 'connected', - detail: `Connected to ComfyUI • ${totalNodeCount} workflow nodes`, + detail: `Connected to ComfyUI • ${totalNodeCount} workflow nodes`, currentNodeLabel: 'Waiting for execution to start' }); resolve(); @@ -2047,7 +2048,7 @@ async function downloadTripoMeshResult(output = {}) { filename, buffer, isPbr: Boolean(pbrModelUrl), - // Cover render Tripo returns alongside the model — used as the mesh thumbnail + // Cover render Tripo returns alongside the model — used as the mesh thumbnail // for headless generation (rendered_image_url on v3; rendered_image on v2). previewImageUrl: String(output?.rendered_image_url || output?.rendered_image || '').trim() || null }; @@ -2645,7 +2646,7 @@ async function downloadTencentCloudResultFiles(resultFiles = []) { // Mesh thumbnails are normally rendered client-side (WebGL) in the browser; // headless generation (MCP / external API callers) has no browser, so we fall // back to the provider's own cover image. Returns the stored thumbnail filename, -// or null on any failure — a missing thumbnail must never fail mesh generation. +// or null on any failure — a missing thumbnail must never fail mesh generation. async function downloadPreviewThumbnail(previewImageUrl, baseName = 'mesh') { const url = String(previewImageUrl || '').trim(); if (!url) return null; @@ -2707,7 +2708,7 @@ function ensureDesktopService(name, { timeoutMs = 120000 } = {}) { // which runs a Blender subprocess (see app/routes/meshes.py /meshes/thumbnail). // Meshes generated without a browser (ComfyUI / external API over MCP) have no // client-side WebGL thumbnail; this is the fallback when there is no provider -// cover to use. Returns the stored thumbnail filename, or null on any failure — +// cover to use. Returns the stored thumbnail filename, or null on any failure — // the render is best-effort and must never fail mesh generation (e.g. when the // mesh-tools service is not installed or running). async function renderMeshThumbnailViaService(buffer, baseName = 'mesh') { @@ -2715,7 +2716,7 @@ async function renderMeshThumbnailViaService(buffer, baseName = 'mesh') { try { // In the desktop app, start the mesh-tools service on demand if it isn't - // running (best-effort — proceed regardless; a stopped service just yields + // running (best-effort — proceed regardless; a stopped service just yields // no thumbnail, as before). await ensureDesktopService('meshtools'); @@ -3521,7 +3522,7 @@ async function saveWorkflowFile(name, workflowJson) { return workflowFilePath; } -// ─── API ROUTES ─── +// ─── API ROUTES ─── app.get('/api/projects', async (req, res) => { try { @@ -3567,7 +3568,7 @@ app.post('/api/comfyui/workflows/run', workflowExecutionUpload.any(), async (req // a visible Kanban card (see ensureDetachedCard). const persistAssetsDetached = String(req.body.detachedAsset || '').toLowerCase() === 'true'; // Default ON: when no explicit parentAssetId is given, save each output under - // the resolved input asset of the same type — a mesh output becomes a version of + // the resolved input asset of the same type — a mesh output becomes a version of // the input mesh, an image output an edit of the input image. This means MCP // callers get edit/version linkage without tracking parentAssetId (and it does // not depend on the MCP tool version, only on this backend). The app frontend @@ -3747,7 +3748,7 @@ app.post('/api/comfyui/workflows/run', workflowExecutionUpload.any(), async (req // becomes an image edit / mesh version instead of a new root. An explicit // parentAssetId wins when its type matches the output; otherwise, when // autoParentFromInputs is set, the output is saved under a resolved input asset - // of the same type (the source it was derived from). No match → new root asset. + // of the same type (the source it was derived from). No match → new root asset. const explicitParentType = normalizedParentAssetId ? String((await getAssetRecordById(normalizedParentAssetId))?.assetTypeName || '').toLowerCase() : null; @@ -3832,7 +3833,7 @@ app.post('/api/comfyui/workflows/run', workflowExecutionUpload.any(), async (req await fs.writeFile(absoluteFilePath, downloadedFile.buffer); // ComfyUI returns no cover image, so headless mesh outputs have no - // thumbnail — render one via the mesh-tools service (best-effort). + // thumbnail — render one via the mesh-tools service (best-effort). const meshThumbnailFilename = inferredAssetType === 'mesh' ? await renderMeshThumbnailViaService(downloadedFile.buffer, generatedAssetPayload.name) : null; @@ -5739,7 +5740,7 @@ app.post('/api/assets/library/import', libraryImportUpload.any(), async (req, re }); // ------------------------------------------------------------------------- -// Brush child assets — import additional brush PNGs as children of a parent brush +// Brush child assets — import additional brush PNGs as children of a parent brush // ------------------------------------------------------------------------- app.post('/api/assets/library/brush-edits', libraryImportUpload.any(), async (req, res) => { @@ -5805,7 +5806,7 @@ app.post('/api/assets/library/brush-edits', libraryImportUpload.any(), async (re }); // ------------------------------------------------------------------------- -// Paint documents — sidecar layer data for painted meshes +// Paint documents — sidecar layer data for painted meshes // ------------------------------------------------------------------------- function buildPaintDocumentResponse(doc, assetId, baseUrl) { @@ -5913,7 +5914,7 @@ app.put('/api/assets/:assetId/paint-document', paintDocumentUpload.any(), async filePath = toStoredPaintDocPath(assetId, filename); } - if (!filePath) continue; // no file for this layer — skip + if (!filePath) continue; // no file for this layer — skip keptFilenames.add(path.basename(filePath)); persistedLayers.push({ @@ -6271,7 +6272,7 @@ async function proxyMeshTool(operationPath, req, res, { baseUrlBuilder = buildMe // Python service dies, or undici's fetch body timeout fires on a long silent // stage) makes this Readable emit 'error'. `.pipe()` does NOT forward source // errors, so without this handler the unhandled 'error' would crash the whole - // Node process — taking every other endpoint (footer /system/stats, etc.) with + // Node process — taking every other endpoint (footer /system/stats, etc.) with // it. Handle it: log, send a terminal SSE error so the browser stops waiting, // and close cleanly. const source = Readable.fromWeb(upstream.body); @@ -6395,7 +6396,7 @@ app.post('/api/meshes/optimize', meshToolsUpload.single('meshFile'), async (req, await fs.writeFile(inputPath, meshFile.buffer); // -kv keeps source vertex attributes (UVs, normals) even when they look - // "unused" — the editor exports meshes with an untextured placeholder + // "unused" — the editor exports meshes with an untextured placeholder // material, so without -kv gltfpack strips TEXCOORD_0 and the reloaded mesh // loses its UVs (disabling the texture/paint/projection modes). const args = ['-i', inputPath, '-o', outputPath, '-si', String(ratio), '-noq', '-kv']; @@ -7435,7 +7436,7 @@ app.post('/api/settings', async (req, res) => { // Get the primary GPU controller const gpu = graphics.controllers[0] || {}; - + res.json({ cpu: Math.round(cpu.currentLoad), ram: { @@ -7518,7 +7519,7 @@ app.get('/api/system/stats', (req, res) => { res.json(cachedSystemStats); }); -// ─── INITIAL SETUP ─── +// ─── INITIAL SETUP ─── const SETUP_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'setup'); const SETUP_CONFIG_PATH = path.join(SETUP_DIR, 'setup.json'); @@ -7601,6 +7602,40 @@ function resolveComfySubPath(comfyPath, relativePath) { const normalizedRelative = String(relativePath || '').replace(/^[/\\]+/, ''); return path.join(comfyPath, normalizedRelative); } +async function loadComfyExtraModelPaths(comfyPath) { + const configPath = path.join(comfyPath, 'extra_model_paths.yaml'); + try { + const parsed = yaml.load(await fs.readFile(configPath, 'utf-8')); + if (!parsed || typeof parsed !== 'object') return []; + const roots = []; + for (const value of Object.values(parsed)) { + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + const rawBase = typeof value.base_path === 'string' ? value.base_path.trim() : ''; + let base = rawBase ? (path.isAbsolute(rawBase) ? rawBase : path.resolve(comfyPath, rawBase)) : comfyPath; + for (const [key, rel] of Object.entries(value)) { + if (key !== 'base_path' && typeof rel === 'string' && rel.trim()) roots.push({ key: key.toLowerCase(), root: path.resolve(base, rel.trim()) }); + } + } + return roots; + } catch (err) { + if (err.code !== 'ENOENT') console.warn(`Failed to read ComfyUI extra model paths at ${configPath}:`, err.message); + return []; + } +} + +async function findComfyModelFile(comfyPath, relativeDir, fileName, roots) { + let normalized = String(relativeDir || '').replaceAll('\\\\', '/'); + while (normalized.startsWith('/')) normalized = normalized.slice(1); + const parts = normalized.split(/[\\\\/]/).filter(Boolean).map(part => part.toLowerCase()); + const candidates = [resolveComfySubPath(comfyPath, path.join(normalized, fileName)), ...roots.filter(x => parts.includes(x.key)).map(x => path.join(x.root, fileName))]; + for (const candidate of candidates) { + const size = await fileSizeOrNull(candidate); + if (size !== null && size > 0) return { size, path: candidate }; + } + const externalCandidate = roots.filter(x => parts.includes(x.key)).map(x => path.join(x.root, fileName))[0]; + return { size: null, path: externalCandidate || candidates[0] }; +} + async function downloadFileWithProgress(url, destinationPath, onChunk) { const response = await fetch(url); @@ -7639,6 +7674,7 @@ async function downloadFileWithProgress(url, destinationPath, onChunk) { } async function runSetupDownloads(jobId, comfyPath, files) { + const extraModelPaths = await loadComfyExtraModelPaths(comfyPath); const totalExpectedBytes = files.reduce((sum, file) => sum + (Number(file.expectedBytes) || 0), 0); let cumulativeCompletedBytes = 0; @@ -7655,7 +7691,8 @@ async function runSetupDownloads(jobId, comfyPath, files) { for (let index = 0; index < files.length; index += 1) { const file = files[index]; - const destinationPath = resolveComfySubPath(comfyPath, path.join(file.relativeDir, file.fileName)); + const foundPath = await findComfyModelFile(comfyPath, file.relativeDir, file.fileName, extraModelPaths); + const destinationPath = foundPath.path; try { await fs.mkdir(path.dirname(destinationPath), { recursive: true }); @@ -7934,15 +7971,10 @@ app.post('/api/setup/check-files', async (req, res) => { } const results = []; + const extraModelPaths = await loadComfyExtraModelPaths(comfyPath); for (const file of files) { - const absPath = resolveComfySubPath(comfyPath, path.join(file.relativeDir || '', file.fileName || '')); - const size = await fileSizeOrNull(absPath); - results.push({ - relativeDir: file.relativeDir || '', - fileName: file.fileName || '', - exists: size !== null && size > 0, - sizeBytes: size - }); + const found = await findComfyModelFile(comfyPath, file.relativeDir || '', file.fileName || '', extraModelPaths); + results.push({ relativeDir: file.relativeDir || '', fileName: file.fileName || '', exists: found.size !== null, sizeBytes: found.size, resolvedPath: found.size !== null ? found.path : null }); } res.json({ files: results }); @@ -8078,7 +8110,7 @@ async function rewriteAndCopyWikiMedia(content) { try { await fs.copyFile(path.join(WIKI_ASSETS_DIR, fileName), path.join(WIKI_MEDIA_DIR, fileName)); } catch { - // source missing — leave the reference, nothing to copy + // source missing — leave the reference, nothing to copy } const newUrl = `http://localhost:${PORT}/wiki-media/${encodeURIComponent(fileName)}`; result = result.split(match[0]).join(newUrl); @@ -8106,21 +8138,21 @@ async function migrateWikiIfNeeded() { fullPages.push(page); } await importWikiPages(fullPages); - console.log(`📚 Migrated ${fullPages.length} wiki page(s) from the database into the wiki/ folder`); + console.log(`📚 Migrated ${fullPages.length} wiki page(s) from the database into the wiki/ folder`); } else { await seedWikiFiles(); - console.log('📚 Seeded the wiki/ folder with default documentation'); + console.log('📚 Seeded the wiki/ folder with default documentation'); } } // SPA fallback: any GET that isn't an API/asset/media route and didn't match a -// static file is a client-side (react-router) route — serve index.html so deep +// static file is a client-side (react-router) route — serve index.html so deep // links work on a full reload. Registered last so it never shadows real routes. if (HAS_DIST) { app.use((req, res, next) => { if (req.method !== 'GET') return next(); if (/^\/(api|wiki-media|mcp)(\/|$)/.test(req.path)) return next(); - // `/assets/` is a stored asset file (served above, or a genuine 404) — + // `/assets/` is a stored asset file (served above, or a genuine 404) — // never the SPA. But bare `/assets` and `/assets/` ARE the Assets Library // client route, so they must fall through to index.html on a full reload. if (/^\/assets\/.+/.test(req.path)) return next(); @@ -8140,19 +8172,19 @@ initializeStorage().then(async () => { preservedSources: ['Tencent Cloud', 'Tripo AI', 'Hitem3D'] }); if (cleared > 0) { - console.log(`🧹 Cleared ${cleared} stale processing card(s) on startup`); + console.log(`🧹 Cleared ${cleared} stale processing card(s) on startup`); } } catch (err) { console.warn('Failed to clear stale processing cards on startup:', err.message); } app.listen(PORT, () => { - console.log(`🚀 3D Gen Studio Backend running at http://localhost:${PORT}`); - console.log(`📁 Local Workspace: ${DATA_DIR}`); + console.log(`🚀 3D Gen Studio Backend running at http://localhost:${PORT}`); + console.log(`📁 Local Workspace: ${DATA_DIR}`); if (HAS_DIST) { - console.log(`🖥️ Serving bundled UI from dist/ — open http://localhost:${PORT}`); + console.log(`🖥️ Serving bundled UI from dist/ — open http://localhost:${PORT}`); } else { - console.log('ℹ️ No dist/ build found — run "npm run build" to serve the UI from this server.'); + console.log('ℹ️ No dist/ build found — run "npm run build" to serve the UI from this server.'); } }); }); diff --git a/src/components/SetupWizardModal.jsx b/src/components/SetupWizardModal.jsx index 338bc91..8ab1fe8 100644 --- a/src/components/SetupWizardModal.jsx +++ b/src/components/SetupWizardModal.jsx @@ -174,35 +174,45 @@ export default function SetupWizardModal({ onComplete, onClose }) { [filesToDownload] ) + const readyQualityByDiffusion = useMemo(() => { + if (!config) return {} + const ready = {} + for (const diffusion of config.DiffusionModels || []) { + const quality = Object.keys(diffusion.Models || {}).find(candidate => { + const files = buildFileList(config, [{ diffusionName: diffusion.Name, modelQuality: candidate }], config.ComfyUIPaths || {}) + return files.length > 0 && files.every(file => existingFileKeys.has(`${file.relativeDir}::${file.fileName}`)) + }) + if (quality) ready[diffusion.Name] = quality + } + return ready + }, [config, existingFileKeys]) + + useEffect(() => { + if (!config || Object.keys(readyQualityByDiffusion).length === 0) return + setSelectionByName(prev => { + const next = { ...prev } + for (const [name, quality] of Object.entries(readyQualityByDiffusion)) { + if (!next[name]) next[name] = quality + } + return next + }) + }, [config, readyQualityByDiffusion]) + const candidateWorkflows = useMemo(() => { if (!config) return [] const items = [] - for (const sel of selections) { - if (!sel.modelQuality) continue - const diffusion = (config.DiffusionModels || []).find(d => d.Name === sel.diffusionName) - for (const workflow of diffusion?.Workflows || []) { - items.push({ - key: workflow.File, - name: workflow.Name, - workflowFile: workflow.File, - subtitle: diffusion.Name, - diffusionName: diffusion.Name, - modelQuality: sel.modelQuality - }) + for (const diffusion of config.DiffusionModels || []) { + const modelQuality = selectionByName[diffusion.Name] || readyQualityByDiffusion[diffusion.Name] || null + const ready = Boolean(modelQuality && readyQualityByDiffusion[diffusion.Name] === modelQuality) + for (const workflow of diffusion.Workflows || []) { + items.push({ key: workflow.File, name: workflow.Name, workflowFile: workflow.File, subtitle: `${diffusion.Name}${ready ? ' - models detected' : ' - models not detected'}`, diffusionName: diffusion.Name, modelQuality, ready }) } } for (const workflow of config.OtherWorkflows || []) { - items.push({ - key: workflow.File, - name: workflow.Name, - workflowFile: workflow.File, - subtitle: 'Other', - diffusionName: null, - modelQuality: null - }) + items.push({ key: workflow.File, name: workflow.Name, workflowFile: workflow.File, subtitle: 'Other', diffusionName: null, modelQuality: null, ready: true }) } return items - }, [config, selections]) + }, [config, readyQualityByDiffusion, selectionByName]) useEffect(() => { if (stepId !== 'workflows') { @@ -213,7 +223,7 @@ export default function SetupWizardModal({ onComplete, onClose }) { workflowSelectionPrimedRef.current = true const next = {} for (const item of candidateWorkflows) { - next[item.key] = true + next[item.key] = Boolean(item.ready) } setWorkflowSelection(next) }, [stepId, candidateWorkflows])