Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ dist/

# Local database and assets (Portable Data)
data/
output/
models/
cache/

# OS files
.DS_Store
Expand Down
64 changes: 64 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 22 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/components/AssetSelectorModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/components/Footer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/context/ProjectContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([])
Expand Down
2 changes: 1 addition & 1 deletion src/context/SettingsContext.jsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/pages/GraphPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions src/pages/KanbanPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 || ''
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) => {
Expand Down
10 changes: 5 additions & 5 deletions src/pages/MeshEditorPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 || '')
Expand Down Expand Up @@ -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}`);
Expand Down
2 changes: 1 addition & 1 deletion src/utils/meshTexturing.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading