From 4ae5f2c00182032f869794d3ed6f8ed823e5131e Mon Sep 17 00:00:00 2001 From: Chris OBryan <13701027+cobryan05@users.noreply.github.com> Date: Sun, 24 May 2026 10:35:03 -0500 Subject: [PATCH 1/3] Fix GPU stats crash when no graphics controllers are present graphics.controllers[0] can be undefined on systems with no GPU or when systeminformation returns an empty array. Using it as the initial accumulator in reduce() caused an unhandled TypeError that took down the /api/system/stats endpoint. Default to {} so the reduce always has a safe starting value. --- server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.js b/server.js index fb9eeb4..ef59751 100644 --- a/server.js +++ b/server.js @@ -5438,7 +5438,7 @@ app.get('/api/system/stats', async (req, res) => { // This works regardless of whether it's NVIDIA, AMD, or Intel Arc. const gpu = graphics.controllers.reduce((prev, current) => { return (current.vram > (prev.vram || 0)) ? current : prev; - }, graphics.controllers[0]); + }, graphics.controllers[0] || {}); // 2. Universal Mapping: Check for both 'memoryUsed' (NVIDIA style) // and 'vramUsage' (AMD/Standard style) From 8cf4ca1304ae9ed962b8380f9cefbc8188d4eeff Mon Sep 17 00:00:00 2001 From: Chris OBryan <13701027+cobryan05@users.noreply.github.com> Date: Sun, 24 May 2026 10:37:16 -0500 Subject: [PATCH 2/3] Use relative /backend/* paths for all API and asset URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded http://localhost:3001/... URLs with /backend/... throughout the frontend. Vite is configured to proxy /backend/* to the Express backend (port 3001), so the app works with any host or port without rebuilding. - vite.config.js: add /backend proxy, explicit port 3000, build source maps - All src files: localhost:3001 → /backend API base and asset URLs --- src/components/AssetSelectorModal.jsx | 2 +- src/components/Footer.jsx | 2 +- src/context/ProjectContext.jsx | 2 +- src/context/SettingsContext.jsx | 2 +- src/pages/GraphPage.jsx | 2 +- src/pages/KanbanPage.jsx | 6 ++--- src/pages/MeshEditorPage.jsx | 10 ++++---- src/utils/meshTexturing.js | 2 +- vite.config.js | 33 ++++++++++++++++++++++++++- 9 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/components/AssetSelectorModal.jsx b/src/components/AssetSelectorModal.jsx index 3e40340..f521813 100644 --- a/src/components/AssetSelectorModal.jsx +++ b/src/components/AssetSelectorModal.jsx @@ -10,7 +10,7 @@ function formatDimensions(width, height) { function getAssetPreviewUrl(filename) { if (!filename) return null; - return `http://localhost:3001/assets/${encodeURI(filename)}`; + return `/backend/assets/${encodeURI(filename)}`; } const ASSETS_PER_PAGE = 20; diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx index de4a9d6..1475bf4 100644 --- a/src/components/Footer.jsx +++ b/src/components/Footer.jsx @@ -7,7 +7,7 @@ export default function Footer({ variant = 'default', onChangeLogClick }) { useEffect(() => { const fetchStats = async () => { try { - const response = await fetch('http://localhost:3001/api/system/stats'); + const response = await fetch('/backend/api/system/stats'); const data = await response.json(); setStats(data); } catch (err) { diff --git a/src/context/ProjectContext.jsx b/src/context/ProjectContext.jsx index 2a78f05..aa91598 100644 --- a/src/context/ProjectContext.jsx +++ b/src/context/ProjectContext.jsx @@ -2,7 +2,7 @@ import { createContext, useContext, useState, useEffect, useCallback } from 'react' const ProjectContext = createContext(null) -const API_BASE = 'http://localhost:3001/api' +const API_BASE = '/backend/api' export function ProjectProvider({ children }) { const [projects, setProjects] = useState([]) diff --git a/src/context/SettingsContext.jsx b/src/context/SettingsContext.jsx index bfea33e..ee5ee80 100644 --- a/src/context/SettingsContext.jsx +++ b/src/context/SettingsContext.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react' import { SettingsContext } from './SettingsContext.shared' -const API_BASE = 'http://localhost:3001/api' +const API_BASE = '/backend/api' const DEFAULT_CUSTOM_API_TYPE = 'image-generation' function normalizeCustomApiType(type) { diff --git a/src/pages/GraphPage.jsx b/src/pages/GraphPage.jsx index 25922de..44881c1 100644 --- a/src/pages/GraphPage.jsx +++ b/src/pages/GraphPage.jsx @@ -345,7 +345,7 @@ function getAssetPreviewUrl(filename) { return null } - return `http://localhost:3001/assets/${encodeURI(filename)}` + return `/backend/assets/${encodeURI(filename)}` } function appendCacheBust(url, cacheKey) { diff --git a/src/pages/KanbanPage.jsx b/src/pages/KanbanPage.jsx index bf608df..c6e3690 100644 --- a/src/pages/KanbanPage.jsx +++ b/src/pages/KanbanPage.jsx @@ -138,7 +138,7 @@ function buildMeshEditorPath(asset, projectId, returnTo) { const query = new URLSearchParams({ assetId: String(asset?.id || ''), filePath: asset?.filePath || asset?.filename || '', - url: asset?.filename ? `http://localhost:3001/assets/${encodeURI(asset.filename)}` : '', + url: asset?.filename ? `/backend/assets/${encodeURI(asset.filename)}` : '', name: asset?.name || 'Mesh', projectId: projectId ? String(projectId) : '', returnTo: returnTo || '' @@ -354,7 +354,7 @@ export default function KanbanPage() { return asset } - const assetUrl = `http://localhost:3001/assets/${encodeURI(asset.filename)}` + const assetUrl = `/backend/assets/${encodeURI(asset.filename)}` const response = await fetch(assetUrl) if (!response.ok) { @@ -1194,7 +1194,7 @@ export default function KanbanPage() { return null } - return `http://localhost:3001/assets/${encodeURI(filename)}` + return `/backend/assets/${encodeURI(filename)}` } const formatAssetDimensions = (width, height) => { diff --git a/src/pages/MeshEditorPage.jsx b/src/pages/MeshEditorPage.jsx index 698d8b4..bb4ed1b 100644 --- a/src/pages/MeshEditorPage.jsx +++ b/src/pages/MeshEditorPage.jsx @@ -2974,7 +2974,7 @@ export default function MeshEditorPage() { if (paintBrushSource === 'asset' && paintBrushAsset) { sourceUrl = paintBrushAsset.url || (paintBrushAsset.filename - ? `http://localhost:3001/assets/${encodeURI(paintBrushAsset.filename)}` + ? `/backend/assets/${encodeURI(paintBrushAsset.filename)}` : null); } else if (paintBrushSource === 'computer' && paintBrushFile) { objectUrl = URL.createObjectURL(paintBrushFile); @@ -3743,7 +3743,7 @@ export default function MeshEditorPage() { if (sculptStampSource === 'asset' && sculptStampAsset) { sourceUrl = sculptStampAsset.url || (sculptStampAsset.filename - ? `http://localhost:3001/assets/${encodeURI(sculptStampAsset.filename)}` + ? `/backend/assets/${encodeURI(sculptStampAsset.filename)}` : null); } else if (sculptStampSource === 'computer' && sculptStampFile) { objectUrl = URL.createObjectURL(sculptStampFile); @@ -5784,7 +5784,7 @@ export default function MeshEditorPage() { }) try { - const assetUrl = savedAsset?.filename ? `http://localhost:3001/assets/${encodeURI(savedAsset.filename)}` : '' + const assetUrl = savedAsset?.filename ? `/backend/assets/${encodeURI(savedAsset.filename)}` : '' const response = assetUrl ? await fetch(assetUrl) : null if (response?.ok) { const blob = await response.blob() @@ -5860,7 +5860,7 @@ export default function MeshEditorPage() { if (saveMode === 'version' && savedAsset?.id) { const nextSearchParams = new URLSearchParams(searchParams) const savedFilename = savedAsset.filename || (savedAsset.filePath ? savedAsset.filePath.replace(/^data\/assets\//, '') : '') - const savedUrl = savedFilename ? `http://localhost:3001/assets/${encodeURI(savedFilename)}` : modelUrl + const savedUrl = savedFilename ? `/backend/assets/${encodeURI(savedFilename)}` : modelUrl nextSearchParams.set('assetId', String(savedAsset.id)) nextSearchParams.set('filePath', savedAsset.filePath || '') @@ -6748,7 +6748,7 @@ export default function MeshEditorPage() { let file = null; if (config.type === 'asset') { // Build asset URL - const url = config.filePath ? `http://localhost:3001/assets/${encodeURI(config.filePath.replace(/^data\/assets\//, ''))}` : null; + const url = config.filePath ? `/backend/assets/${encodeURI(config.filePath.replace(/^data\/assets\//, ''))}` : null; if (!url) throw new Error(`Asset ${config.assetName} has no file path`); const response = await fetch(url); if (!response.ok) throw new Error(`Failed to load asset ${config.assetName}`); diff --git a/src/utils/meshTexturing.js b/src/utils/meshTexturing.js index 109e415..9474560 100644 --- a/src/utils/meshTexturing.js +++ b/src/utils/meshTexturing.js @@ -1340,7 +1340,7 @@ export function buildAssetUrl(asset) { .replace(/^data\/assets\//, '') .replace(/^assets\//, '') - return `http://localhost:3001/assets/${encodeURI(normalizedPath)}` + return `/backend/assets/${encodeURI(normalizedPath)}` } export function createTexturePaintWorkflowDraft(workflow) { diff --git a/vite.config.js b/vite.config.js index 8b0f57b..a2f83dd 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,38 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -// https://vite.dev/config/ +// /backend/* is proxied to the Express backend so the frontend uses relative +// paths rather than hardcoded localhost:3001 URLs. +const backendTarget = process.env.VITE_BACKEND_URL || 'http://127.0.0.1:3001'; + +const backendProxy = { + target: backendTarget, + changeOrigin: true, + secure: false, + rewrite: (path) => path.replace(/^\/backend/, ''), +} + export default defineConfig({ + base: '/', plugins: [react()], + server: { + host: 'localhost', + port: 3000, + strictPort: true, + proxy: { + '/backend': backendProxy, + } + }, + preview: { + host: 'localhost', + port: 3000, + strictPort: true, + proxy: { + '/backend': backendProxy, + } + }, + build: { + minify: false, + sourcemap: true, + }, }) From 4b4563c0a1c3f492c8de05c9a2f5bc5f4677a5d1 Mon Sep 17 00:00:00 2001 From: Chris OBryan <13701027+cobryan05@users.noreply.github.com> Date: Sun, 24 May 2026 10:37:48 -0500 Subject: [PATCH 3/3] Add Docker support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dockerfile builds a self-contained image that runs both the Express backend and the Vite preview server under supervisord. No GPU/CUDA required — all inference is offloaded to ComfyUI or external APIs. - Dockerfile: multi-stage node:20-alpine build; builder stage installs deps and compiles the frontend, runner stage copies artifacts - docker-compose.yml: default port mappings 3000:3000 (app) and commented out 9001 (supervisord web UI) with a comment to customize as needed - supervisord: runs backend (port 3001) and frontend (port 3000) as separate programs with stdout/stderr to console - vite.config.js: bind to 0.0.0.0 and allow any Host header when RUNNING_IN_DOCKER=1 - server.js: import ws (used for WebSocket proxying. Not sure if this affects other builds?) - README.md: add Docker quick-start section; note GPU stats unavailable in the lightweight Alpine (non-CUDA) image. --- .dockerignore | 13 ++++++++++ .gitignore | 3 +++ Dockerfile | 64 ++++++++++++++++++++++++++++++++++++++++++++++ README.md | 52 +++++++++++++++++++++++++++++++++++++ docker-compose.yml | 20 +++++++++++++++ package-lock.json | 23 ++++++++++++++++- package.json | 3 ++- server.js | 1 + supervisord.conf | 40 +++++++++++++++++++++++++++++ vite.config.js | 20 +++++++++------ 10 files changed, 229 insertions(+), 10 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 supervisord.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4bacc2c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# App dependencies and build artifacts - rebuilt inside the image +node_modules/ +dist/ +.cache/ +.git/ + +# Persistent runtime data - mounted as volumes, never baked into the image +data/ +output/ +models/ +cache/ + +npm-debug.log diff --git a/.gitignore b/.gitignore index bfca108..1d0c001 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ dist/ # Local database and assets (Portable Data) data/ +output/ +models/ +cache/ # OS files .DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4b19a67 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,64 @@ +# ────────────────────────────────────────────────────────────────────────────── +# Stage 1: Build & Compile Native Addons +# ────────────────────────────────────────────────────────────────────────────── +FROM docker.io/library/node:20-alpine AS builder + +WORKDIR /build + +# Install build tools required for compiling native C/C++ node modules +RUN apk add --no-cache python3 make g++ gcc libc-dev + +# Copy manifests first to optimize Docker layer caching +COPY package*.json ./ + +# Install ALL dependencies (including devDependencies like Vite) +RUN npm ci --include=dev --build-from-source + +# Copy the rest of the application source code +COPY . . + +# Run your frontend production build step step +RUN npm run build + + +# ────────────────────────────────────────────────────────────────────────────── +# Stage 2: Final Runtime (Retaining Dev Deps for Debugging) +# ────────────────────────────────────────────────────────────────────────────── +FROM docker.io/library/node:20-alpine AS runner + +ENV DEBIAN_FRONTEND=noninteractive \ + NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=3001 + +# Install final runtime system utilities +RUN apk add --no-cache \ + ca-certificates \ + ffmpeg \ + sqlite-dev \ + supervisor \ + bash # Added bash to make exec/debugging inside the container much nicer + +WORKDIR /app + +# Ensure persistent directories exist +RUN mkdir -p \ + /app/data \ + /app/output \ + /app/models \ + /app/cache \ + /var/log/supervisor + +# Copy your source repository structure +COPY . /app/ + +# Bring over the unpruned node_modules, binaries, and production build assets +COPY --from=builder /build/node_modules /app/node_modules +COPY --from=builder /build/dist /app/dist + +# Ensure supervisor can find the configuration +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf + +EXPOSE 3000 9001 + +CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"] \ No newline at end of file diff --git a/README.md b/README.md index c04387e..8fa9505 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,58 @@ npm run dev > [!NOTE] > This starts the backend server on `http://localhost:3001` and the Vite frontend development server. +### 🐳 Docker + +Run the full stack in a self-contained container — no local Node.js install required. +No GPU required — all inference is offloaded to ComfyUI or external APIs. + +> [!NOTE] +> The Docker image uses a lightweight Alpine base (no CUDA drivers). GPU hardware +> stats in the system metrics footer will not be available inside the container. + +**Prerequisites:** Docker and Docker Compose. + +```bash +# 1. Clone the repository +git clone https://github.com/visualbruno/3DGenStudio.git +cd 3DGenStudio + +# 2. Build the image (installs deps & compiles the frontend inside the container) +docker compose build + +# 3. Start the stack +docker compose up +``` + +The app will be available at `http://localhost:3000`. + +> [!NOTE] +> `data/`, `output/`, `models/`, and `cache/` are mounted as volumes so your +> projects and generated assets persist across container restarts. + +#### Changing the port + +Edit the `ports` section in `docker-compose.yml`. The format is `"HOST:CONTAINER"` — +only the host-side number needs to change: + +```yaml +ports: + - "8300:3000" # app now reachable at http://localhost:8300 + - "9001:9001" # supervisord web UI (optional, remove to hide it) +``` + +#### Restricting access to localhost only + +By default Docker binds to `0.0.0.0`, making the app reachable from other machines +on your network. To allow only the local machine to connect, prefix the host port +with `127.0.0.1:`: + +```yaml +ports: + - "127.0.0.1:3000:3000" # app: localhost only + - "127.0.0.1:9001:9001" # supervisord web UI: localhost only +``` + ### Configuration Open the application and configure your services in the settings area: - `ComfyUI` path / host / port diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4b44af4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + 3dgenstudio: + build: + context: . + dockerfile: Dockerfile + + container_name: 3dgenstudio + + ports: + # Customize host port mappings here as needed + - "3000:3000" # App (frontend) + # - "9001:9001" # Supervisord web UI, optional + + volumes: + - ./data:/app/data + - ./output:/app/output + - ./models:/app/models + - ./cache:/app/cache + + restart: unless-stopped diff --git a/package-lock.json b/package-lock.json index 1d0d1d4..df6314a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,8 @@ "tencentcloud-sdk-nodejs-intl-en": "^3.0.1352", "three": "^0.183.2", "three-bvh-csg": "^0.0.18", - "three-mesh-bvh": "^0.9.9" + "three-mesh-bvh": "^0.9.9", + "ws": "^8.18.0" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -5956,6 +5957,26 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index ec79d47..6f24567 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "tencentcloud-sdk-nodejs-intl-en": "^3.0.1352", "three": "^0.183.2", "three-bvh-csg": "^0.0.18", - "three-mesh-bvh": "^0.9.9" + "three-mesh-bvh": "^0.9.9", + "ws": "^8.18.0" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/server.js b/server.js index ef59751..5cd5983 100644 --- a/server.js +++ b/server.js @@ -2,6 +2,7 @@ import express from 'express'; import cors from 'cors'; import multer from 'multer'; import path from 'path'; +import WebSocket from 'ws'; import { Buffer } from 'buffer'; import { randomUUID } from 'crypto'; import { createAssetEditRecord, createBrushChildRecord, resolveProjectImageSource, resolveProjectMeshSource } from './storage.js'; diff --git a/supervisord.conf b/supervisord.conf new file mode 100644 index 0000000..52d3aea --- /dev/null +++ b/supervisord.conf @@ -0,0 +1,40 @@ +[supervisord] +nodaemon=true +user=root +logfile=/dev/null +pidfile=/tmp/supervisord.pid + +[program:backend] +directory=/app +command=node server.js +autostart=true +autorestart=true +startsecs=5 +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/fd/2 +stderr_logfile_maxbytes=0 +environment=PORT="3001",HOST="0.0.0.0",NODE_ENV="production" + +[program:frontend] +directory=/app +command=/app/node_modules/.bin/vite preview --port 3000 +autostart=true +autorestart=true +startsecs=5 +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/fd/2 +stderr_logfile_maxbytes=0 +environment=NODE_ENV="production",RUNNING_IN_DOCKER="1" + +[inet_http_server] +port=9001 +;username= +;password= + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=http://127.0.0.1:9001 diff --git a/vite.config.js b/vite.config.js index a2f83dd..4835d9a 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,10 +1,12 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -// /backend/* is proxied to the Express backend so the frontend uses relative -// paths rather than hardcoded localhost:3001 URLs. +// /backend/* on the frontend is proxied to the backend const backendTarget = process.env.VITE_BACKEND_URL || 'http://127.0.0.1:3001'; +// Bind to all interfaces and allow any Host header only inside a container. +const inDocker = process.env.RUNNING_IN_DOCKER === '1'; + const backendProxy = { target: backendTarget, changeOrigin: true, @@ -16,7 +18,7 @@ export default defineConfig({ base: '/', plugins: [react()], server: { - host: 'localhost', + host: inDocker ? '0.0.0.0' : 'localhost', port: 3000, strictPort: true, proxy: { @@ -24,15 +26,17 @@ export default defineConfig({ } }, preview: { - host: 'localhost', + host: inDocker ? '0.0.0.0' : 'localhost', port: 3000, strictPort: true, + allowedHosts: inDocker ? true : undefined, proxy: { '/backend': backendProxy, } }, - build: { - minify: false, - sourcemap: true, - }, + // uncomment for debugging + // build: { + // minify: false, + // sourcemap: true, + // }, })