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 fb9eeb4..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'; @@ -5438,7 +5439,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) 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/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 8b0f57b..4835d9a 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,42 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -// https://vite.dev/config/ +// /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, + secure: false, + rewrite: (path) => path.replace(/^\/backend/, ''), +} + export default defineConfig({ + base: '/', plugins: [react()], + server: { + host: inDocker ? '0.0.0.0' : 'localhost', + port: 3000, + strictPort: true, + proxy: { + '/backend': backendProxy, + } + }, + preview: { + host: inDocker ? '0.0.0.0' : 'localhost', + port: 3000, + strictPort: true, + allowedHosts: inDocker ? true : undefined, + proxy: { + '/backend': backendProxy, + } + }, + // uncomment for debugging + // build: { + // minify: false, + // sourcemap: true, + // }, })