upatde: add docker for coolify
This commit is contained in:
+4
-1
@@ -1,4 +1,7 @@
|
|||||||
UPLOAD_SECRET_KEY=your-secret-key-here
|
UPLOAD_SECRET_KEY=your-secret-key-here
|
||||||
GITHUB_TOKEN=ghp_your-github-personal-access-token
|
GITHUB_TOKEN=ghp_your-github-personal-access-token
|
||||||
GIT_BRANCH=ta-branch
|
GIT_BRANCH=main
|
||||||
GIT_REPO_URL=https://github.com/your-org/your-repo.git
|
GIT_REPO_URL=https://github.com/your-org/your-repo.git
|
||||||
|
|
||||||
|
# Optional: path to Blender binary (defaults to "blender" in PATH)
|
||||||
|
# BLENDER_PATH=/usr/bin/blender
|
||||||
|
|||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Asset Bridge 3D — Dockerfile for Coolify
|
||||||
|
# Node 20 Debian · Blender (headless) · Multi-stage build
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- Stage 1: Dependencies ---------------------------------------------------
|
||||||
|
FROM node:20-slim AS deps
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
|
# --- Stage 2: Build ----------------------------------------------------------
|
||||||
|
FROM node:20-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Stage 3: Production -----------------------------------------------------
|
||||||
|
FROM node:20-slim AS runner
|
||||||
|
|
||||||
|
LABEL maintainer="Asset Bridge 3D"
|
||||||
|
LABEL description="Secure 3D asset upload interface with Draco compression and GitHub push"
|
||||||
|
|
||||||
|
# Install Blender (headless) + tini
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
blender \
|
||||||
|
tini \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
# Copy build artifacts
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Copy the Blender compression script
|
||||||
|
COPY --from=builder /app/scripts ./scripts
|
||||||
|
|
||||||
|
# Ensure tmp dir for uploads exists
|
||||||
|
RUN mkdir -p /tmp/assets
|
||||||
|
|
||||||
|
# Copy entrypoint
|
||||||
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--", "/docker-entrypoint.sh"]
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# upload-GLTF
|
# upload-GLTF
|
||||||
|
|
||||||
A secure web interface for uploading 3D assets (GLTF/GLB + textures) and automatically pushing them to a GitHub repository via the GitHub API. Built for La Fabrik Durable.
|
A secure web interface for uploading 3D assets (GLTF/GLB + textures) with automatic Draco compression and GitHub push. Built for La Fabrik Durable.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -8,7 +8,8 @@ A secure web interface for uploading 3D assets (GLTF/GLB + textures) and automat
|
|||||||
- **Three.js** (@react-three/fiber + @react-three/drei) for 3D preview
|
- **Three.js** (@react-three/fiber + @react-three/drei) for 3D preview
|
||||||
- **Tailwind CSS** for styling
|
- **Tailwind CSS** for styling
|
||||||
- **Octokit** for pushing via the GitHub API
|
- **Octokit** for pushing via the GitHub API
|
||||||
- **Vercel** for hosting
|
- **Blender** (headless) for Draco mesh compression
|
||||||
|
- **Coolify** (Docker) for hosting
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ GIT_REPO_URL=https://github.com/your-org/your-repo.git
|
|||||||
| `GITHUB_TOKEN` | GitHub Personal Access Token (scope `repo`) | Yes |
|
| `GITHUB_TOKEN` | GitHub Personal Access Token (scope `repo`) | Yes |
|
||||||
| `GIT_BRANCH` | Target branch (default: main) | No |
|
| `GIT_BRANCH` | Target branch (default: main) | No |
|
||||||
| `GIT_REPO_URL` | Target GitHub repository URL | Yes |
|
| `GIT_REPO_URL` | Target GitHub repository URL | Yes |
|
||||||
|
| `BLENDER_PATH` | Path to Blender binary (default: `blender`) | No |
|
||||||
|
|
||||||
> To create a token: GitHub > Settings > Developer settings > Personal access tokens > Generate new token (classic) with the `repo` scope.
|
> To create a token: GitHub > Settings > Developer settings > Personal access tokens > Generate new token (classic) with the `repo` scope.
|
||||||
|
|
||||||
@@ -48,9 +50,20 @@ npm run dev
|
|||||||
|
|
||||||
Access the app at `http://localhost:3000`
|
Access the app at `http://localhost:3000`
|
||||||
|
|
||||||
### Production (Vercel)
|
> **Note:** Draco compression requires Blender installed locally. If Blender is not available, models are pushed to GitHub without compression.
|
||||||
|
|
||||||
Deploy to Vercel and configure environment variables in the dashboard.
|
### Production (Coolify / Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t upload-gltf .
|
||||||
|
docker run -p 3000:3000 \
|
||||||
|
-e UPLOAD_SECRET_KEY=your-key \
|
||||||
|
-e GITHUB_TOKEN=ghp_xxx \
|
||||||
|
-e GIT_REPO_URL=https://github.com/org/repo.git \
|
||||||
|
upload-gltf
|
||||||
|
```
|
||||||
|
|
||||||
|
The Dockerfile includes Blender headless for automatic Draco compression.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -60,13 +73,15 @@ Deploy to Vercel and configure environment variables in the dashboard.
|
|||||||
- Textures: `roughness`, `normal`, `metalness`, `color`, `displace` (`.png/.jpg/.webp`, optional)
|
- Textures: `roughness`, `normal`, `metalness`, `color`, `displace` (`.png/.jpg/.webp`, optional)
|
||||||
3. The model is displayed in a 3D preview
|
3. The model is displayed in a 3D preview
|
||||||
4. On clicking "Upload & Push to GitHub", each file is sent to the `/api/upload` endpoint
|
4. On clicking "Upload & Push to GitHub", each file is sent to the `/api/upload` endpoint
|
||||||
5. The API validates the file and pushes it directly to the GitHub repo via the API (no git CLI needed)
|
5. For models: the file is written to `/tmp`, compressed with Blender Draco, then the compressed version is pushed to GitHub
|
||||||
|
6. For textures: pushed directly to GitHub without compression
|
||||||
|
7. If Blender is unavailable, the original model is pushed as-is (graceful fallback)
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
app/
|
app/
|
||||||
├── api/upload/route.ts # API: validation + GitHub push via Octokit
|
├── api/upload/route.ts # API: validation + Draco compression + GitHub push
|
||||||
├── globals.css # Tailwind + Google Fonts
|
├── globals.css # Tailwind + Google Fonts
|
||||||
├── layout.tsx # Root layout
|
├── layout.tsx # Root layout
|
||||||
└── page.tsx # Home page
|
└── page.tsx # Home page
|
||||||
@@ -74,6 +89,8 @@ components/
|
|||||||
├── UploadZone.tsx # UI: key input, folder picker, validation, upload
|
├── UploadZone.tsx # UI: key input, folder picker, validation, upload
|
||||||
├── ModelViewer.tsx # Lazy wrapper for the 3D viewer
|
├── ModelViewer.tsx # Lazy wrapper for the 3D viewer
|
||||||
└── SceneViewer.tsx # Three.js Canvas
|
└── SceneViewer.tsx # Three.js Canvas
|
||||||
|
scripts/
|
||||||
|
└── compress.py # Blender Draco compression script
|
||||||
```
|
```
|
||||||
|
|
||||||
## Supported Formats
|
## Supported Formats
|
||||||
|
|||||||
+126
-27
@@ -1,6 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { Octokit } from '@octokit/rest'
|
import { Octokit } from '@octokit/rest'
|
||||||
import { extname, basename } from 'path'
|
import { extname, basename, join } from 'path'
|
||||||
|
import { mkdir, writeFile, readFile, unlink, rm } from 'fs/promises'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { execFile } from 'child_process'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -9,6 +15,8 @@ const MODEL_EXTENSIONS = new Set(['.glb', '.gltf'])
|
|||||||
const TEXTURE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp'])
|
const TEXTURE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp'])
|
||||||
const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS])
|
const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS])
|
||||||
|
|
||||||
|
const TMP_DIR = join('/tmp', 'assets')
|
||||||
|
|
||||||
function sanitizeFilename(name: string): string {
|
function sanitizeFilename(name: string): string {
|
||||||
return basename(name)
|
return basename(name)
|
||||||
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||||
@@ -18,15 +26,14 @@ function sanitizeFilename(name: string): string {
|
|||||||
|
|
||||||
function getOctokit(): Octokit {
|
function getOctokit(): Octokit {
|
||||||
const token = process.env.GITHUB_TOKEN
|
const token = process.env.GITHUB_TOKEN
|
||||||
if (!token) throw new Error('GITHUB_TOKEN non configuré')
|
if (!token) throw new Error('GITHUB_TOKEN non configure')
|
||||||
return new Octokit({ auth: token })
|
return new Octokit({ auth: token })
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRepoUrl(): { owner: string; repo: string } {
|
function parseRepoUrl(): { owner: string; repo: string } {
|
||||||
const url = process.env.GIT_REPO_URL
|
const url = process.env.GIT_REPO_URL
|
||||||
if (!url) throw new Error('GIT_REPO_URL non configuré')
|
if (!url) throw new Error('GIT_REPO_URL non configure')
|
||||||
|
|
||||||
// Support formats: https://github.com/owner/repo.git, owner/repo, git@github.com:owner/repo.git
|
|
||||||
const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.]+)/)
|
const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.]+)/)
|
||||||
const sshMatch = url.match(/github\.com:([^/]+)\/([^/.]+)/)
|
const sshMatch = url.match(/github\.com:([^/]+)\/([^/.]+)/)
|
||||||
const shortMatch = url.match(/^([^/]+)\/([^/]+)$/)
|
const shortMatch = url.match(/^([^/]+)\/([^/]+)$/)
|
||||||
@@ -37,6 +44,48 @@ function parseRepoUrl(): { owner: string; repo: string } {
|
|||||||
return { owner: match[1], repo: match[2] }
|
return { owner: match[1], repo: match[2] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Blender Draco compression
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function compressWithBlender(
|
||||||
|
inputPath: string,
|
||||||
|
outputPath: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const blenderPath = process.env.BLENDER_PATH || 'blender'
|
||||||
|
const scriptPath = join(process.cwd(), 'scripts', 'compress.py')
|
||||||
|
|
||||||
|
if (!existsSync(scriptPath)) {
|
||||||
|
return { success: false, error: 'scripts/compress.py introuvable' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execFileAsync(blenderPath, [
|
||||||
|
'--background',
|
||||||
|
'--python', scriptPath,
|
||||||
|
'--',
|
||||||
|
'-i', inputPath,
|
||||||
|
'-o', outputPath,
|
||||||
|
'--draco-level', '7',
|
||||||
|
'--texture-size', '512',
|
||||||
|
'-q',
|
||||||
|
], { timeout: 120_000 })
|
||||||
|
|
||||||
|
if (!existsSync(outputPath)) {
|
||||||
|
return { success: false, error: 'Blender n\'a pas produit de fichier compresse' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
return { success: false, error: `Compression Blender echouee: ${message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Parse uploaded file from FormData
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function parseUpload(req: NextRequest) {
|
async function parseUpload(req: NextRequest) {
|
||||||
const formData = await req.formData()
|
const formData = await req.formData()
|
||||||
const file = formData.get('file') as File | null
|
const file = formData.get('file') as File | null
|
||||||
@@ -45,14 +94,14 @@ async function parseUpload(req: NextRequest) {
|
|||||||
const textureName = formData.get('textureName') as string | null
|
const textureName = formData.get('textureName') as string | null
|
||||||
|
|
||||||
if (!file || file.size === 0) {
|
if (!file || file.size === 0) {
|
||||||
throw new Error('Aucun fichier reçu')
|
throw new Error('Aucun fichier recu')
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalSafe = sanitizeFilename(file.name)
|
const originalSafe = sanitizeFilename(file.name)
|
||||||
const ext = extname(originalSafe).toLowerCase()
|
const ext = extname(originalSafe).toLowerCase()
|
||||||
|
|
||||||
if (!ALL_ALLOWED_EXTENSIONS.has(ext)) {
|
if (!ALL_ALLOWED_EXTENSIONS.has(ext)) {
|
||||||
throw new Error(`Extension non autorisée: "${ext}"`)
|
throw new Error(`Extension non autorisee: "${ext}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-')
|
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-')
|
||||||
@@ -64,23 +113,26 @@ async function parseUpload(req: NextRequest) {
|
|||||||
filename = originalSafe
|
filename = originalSafe
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isModel = MODEL_EXTENSIONS.has(ext)
|
||||||
const buffer = Buffer.from(await file.arrayBuffer())
|
const buffer = Buffer.from(await file.arrayBuffer())
|
||||||
const content = buffer.toString('base64')
|
|
||||||
const path = `public/assets/${safeFolderName}/${filename}`
|
|
||||||
|
|
||||||
return { filename, content, path, folderName: safeFolderName }
|
return { filename, buffer, safeFolderName, isModel }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pushToGitHub(
|
// ---------------------------------------------------------------------------
|
||||||
|
// Push a single file to GitHub via the API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function pushFileToGitHub(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
content: string,
|
contentBase64: string,
|
||||||
folderName: string
|
folderName: string
|
||||||
): Promise<{ commitUrl: string }> {
|
): Promise<{ commitUrl: string }> {
|
||||||
const octokit = getOctokit()
|
const octokit = getOctokit()
|
||||||
const { owner, repo } = parseRepoUrl()
|
const { owner, repo } = parseRepoUrl()
|
||||||
const branch = process.env.GIT_BRANCH ?? 'main'
|
const branch = process.env.GIT_BRANCH ?? 'main'
|
||||||
|
|
||||||
// 1. Get the current commit SHA on the branch
|
// 1. Get latest commit on branch
|
||||||
const { data: ref } = await octokit.git.getRef({
|
const { data: ref } = await octokit.git.getRef({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
@@ -88,22 +140,22 @@ async function pushToGitHub(
|
|||||||
})
|
})
|
||||||
const latestCommitSha = ref.object.sha
|
const latestCommitSha = ref.object.sha
|
||||||
|
|
||||||
// 2. Get the tree of the latest commit
|
// 2. Get that commit's tree
|
||||||
const { data: commit } = await octokit.git.getCommit({
|
const { data: commit } = await octokit.git.getCommit({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
commit_sha: latestCommitSha,
|
commit_sha: latestCommitSha,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 3. Create a blob for the file
|
// 3. Create blob
|
||||||
const { data: blob } = await octokit.git.createBlob({
|
const { data: blob } = await octokit.git.createBlob({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
content,
|
content: contentBase64,
|
||||||
encoding: 'base64',
|
encoding: 'base64',
|
||||||
})
|
})
|
||||||
|
|
||||||
// 4. Create a new tree with the file added
|
// 4. Create tree
|
||||||
const { data: newTree } = await octokit.git.createTree({
|
const { data: newTree } = await octokit.git.createTree({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
@@ -118,7 +170,7 @@ async function pushToGitHub(
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// 5. Create a new commit
|
// 5. Create commit
|
||||||
const timestamp = new Date().toISOString()
|
const timestamp = new Date().toISOString()
|
||||||
const { data: newCommit } = await octokit.git.createCommit({
|
const { data: newCommit } = await octokit.git.createCommit({
|
||||||
owner,
|
owner,
|
||||||
@@ -128,7 +180,7 @@ async function pushToGitHub(
|
|||||||
parents: [latestCommitSha],
|
parents: [latestCommitSha],
|
||||||
})
|
})
|
||||||
|
|
||||||
// 6. Update the branch reference
|
// 6. Update branch ref
|
||||||
await octokit.git.updateRef({
|
await octokit.git.updateRef({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
@@ -139,49 +191,96 @@ async function pushToGitHub(
|
|||||||
return { commitUrl: newCommit.html_url }
|
return { commitUrl: newCommit.html_url }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
// --- Auth ---
|
||||||
const secret = req.headers.get('x-upload-secret')
|
const secret = req.headers.get('x-upload-secret')
|
||||||
const expectedSecret = process.env.UPLOAD_SECRET_KEY
|
const expectedSecret = process.env.UPLOAD_SECRET_KEY
|
||||||
|
|
||||||
if (!expectedSecret) {
|
if (!expectedSecret) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: 'Configuration serveur incomplète (UPLOAD_SECRET_KEY manquant)' },
|
{ success: false, error: 'Configuration serveur incomplete (UPLOAD_SECRET_KEY manquant)' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!secret || secret !== expectedSecret) {
|
if (!secret || secret !== expectedSecret) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: "Clé d'authentification invalide" },
|
{ success: false, error: "Cle d'authentification invalide" },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Parse upload ---
|
||||||
let filename: string
|
let filename: string
|
||||||
let content: string
|
let buffer: Buffer
|
||||||
let path: string
|
let safeFolderName: string
|
||||||
let folderName: string
|
let isModel: boolean
|
||||||
|
|
||||||
try {
|
try {
|
||||||
;({ filename, content, path, folderName } = await parseUpload(req))
|
;({ filename, buffer, safeFolderName, isModel } = await parseUpload(req))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Write to /tmp ---
|
||||||
|
const tmpFolder = join(TMP_DIR, safeFolderName)
|
||||||
|
await mkdir(tmpFolder, { recursive: true })
|
||||||
|
const tmpFilePath = join(tmpFolder, filename)
|
||||||
|
await writeFile(tmpFilePath, buffer)
|
||||||
|
|
||||||
|
let contentToUpload: Buffer = buffer
|
||||||
|
let compressed = false
|
||||||
|
let compressionError: string | undefined
|
||||||
|
|
||||||
|
// --- Compress model with Blender (if available) ---
|
||||||
|
if (isModel) {
|
||||||
|
const stem = filename.replace(/\.[^.]+$/, '')
|
||||||
|
const compressedPath = join(tmpFolder, `${stem}_compressed.glb`)
|
||||||
|
|
||||||
|
const result = await compressWithBlender(tmpFilePath, compressedPath)
|
||||||
|
|
||||||
|
if (result.success && existsSync(compressedPath)) {
|
||||||
|
contentToUpload = await readFile(compressedPath)
|
||||||
|
compressed = true
|
||||||
|
// Clean up compressed temp file
|
||||||
|
await unlink(compressedPath).catch(() => {})
|
||||||
|
} else {
|
||||||
|
// Blender not available or failed — push original
|
||||||
|
compressionError = result.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up original temp file
|
||||||
|
await unlink(tmpFilePath).catch(() => {})
|
||||||
|
// Clean up folder if empty
|
||||||
|
await rm(tmpFolder, { recursive: true, force: true }).catch(() => {})
|
||||||
|
|
||||||
|
// --- Push to GitHub ---
|
||||||
|
const gitPath = `public/assets/${safeFolderName}/${filename}`
|
||||||
|
const contentBase64 = contentToUpload.toString('base64')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { commitUrl } = await pushToGitHub(path, content, folderName)
|
const { commitUrl } = await pushFileToGitHub(gitPath, contentBase64, safeFolderName)
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
filename,
|
filename,
|
||||||
message: `"${filename}" ajouté au dossier "${folderName}" et poussé sur GitHub.`,
|
compressed,
|
||||||
|
compressionError: compressionError || undefined,
|
||||||
|
message: compressed
|
||||||
|
? `"${filename}" compresse avec Draco et pousse sur GitHub.`
|
||||||
|
: `"${filename}" pousse sur GitHub${compressionError ? ' (sans compression: ' + compressionError + ')' : ''}.`,
|
||||||
commitUrl,
|
commitUrl,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur GitHub inconnue'
|
const message = err instanceof Error ? err.message : 'Erreur GitHub inconnue'
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: `Push GitHub échoué: ${message}` },
|
{ success: false, error: `Push GitHub echoue: ${message}` },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[entrypoint] Starting Asset Bridge 3D..."
|
||||||
|
|
||||||
|
# Ensure tmp directory for uploads exists
|
||||||
|
mkdir -p /tmp/assets
|
||||||
|
|
||||||
|
echo "[entrypoint] Ready. Launching application..."
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
import type { NextConfig } from 'next'
|
import type { NextConfig } from 'next'
|
||||||
|
|
||||||
const nextConfig: NextConfig = {}
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Blender Draco Compression Script
|
||||||
|
CLI tool to compress 3D meshes with Draco compression using Blender
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
blender --background --python compress.py -- [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-i, --input FILE Input file (required in advanced mode)
|
||||||
|
-o, --output FILE Output file (default: input_compressed.glb)
|
||||||
|
--draco-level LEVEL Draco compression level 0-10 (default: 7)
|
||||||
|
--resize-textures / --no-resize Enable/disable texture resizing (default: enabled)
|
||||||
|
--texture-size SIZE Max texture size in pixels (default: 512)
|
||||||
|
--batch Batch mode: input is a directory
|
||||||
|
--output-dir DIR Output directory for batch mode
|
||||||
|
--format FORMAT Output format: glb or gltf (default: glb)
|
||||||
|
-q, --quiet Quiet mode (less output)
|
||||||
|
-h, --help Show this help message
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Simple mode (all defaults)
|
||||||
|
blender --background --python compress.py -- input.glb
|
||||||
|
|
||||||
|
# Advanced mode
|
||||||
|
blender --background --python compress.py -- -i input.glb -o output.glb --draco-level 10
|
||||||
|
|
||||||
|
# Batch mode
|
||||||
|
blender --background --python compress.py -- --batch ./models/ --output-dir ./compressed/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import io
|
||||||
|
import argparse
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
try:
|
||||||
|
import bpy_types
|
||||||
|
except ImportError:
|
||||||
|
bpy_types = None
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_IMPORT_FORMATS = {
|
||||||
|
'.glb': 'gltf',
|
||||||
|
'.gltf': 'gltf',
|
||||||
|
'.obj': 'obj',
|
||||||
|
'.ply': 'ply',
|
||||||
|
'.stl': 'stl',
|
||||||
|
'.x3d': 'x3d',
|
||||||
|
'.wrl': 'x3d',
|
||||||
|
'.3ds': '3ds',
|
||||||
|
'.fbx': 'fbx',
|
||||||
|
'.dae': 'dae',
|
||||||
|
}
|
||||||
|
|
||||||
|
SUPPORTED_OUTPUT_FORMATS = ['glb', 'gltf']
|
||||||
|
|
||||||
|
|
||||||
|
def file_name(filepath):
|
||||||
|
return os.path.split(filepath)[1]
|
||||||
|
|
||||||
|
|
||||||
|
def file_suffix(filepath):
|
||||||
|
return os.path.splitext(file_name(filepath))[1].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def dir_path(filepath):
|
||||||
|
return os.path.split(filepath)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def get_import_operator(suffix):
|
||||||
|
operators = {
|
||||||
|
'gltf': bpy.ops.import_scene.gltf,
|
||||||
|
'obj': bpy.ops.import_scene.obj,
|
||||||
|
'ply': bpy.ops.import_mesh.ply,
|
||||||
|
'stl': bpy.ops.import_mesh.stl,
|
||||||
|
'x3d': bpy.ops.import_scene.x3d,
|
||||||
|
'3ds': bpy.ops.import_scene.fbx,
|
||||||
|
'fbx': bpy.ops.import_scene.fbx,
|
||||||
|
'dae': bpy.ops.import_scene.dae,
|
||||||
|
}
|
||||||
|
return operators.get(suffix)
|
||||||
|
|
||||||
|
|
||||||
|
def get_output_extension(format_type):
|
||||||
|
return '.glb' if format_type == 'glb' else '.gltf'
|
||||||
|
|
||||||
|
|
||||||
|
def import_mesh(filepath):
|
||||||
|
suffix = file_suffix(filepath)
|
||||||
|
if suffix not in SUPPORTED_IMPORT_FORMATS:
|
||||||
|
raise ValueError(f"Unsupported input format: {suffix}")
|
||||||
|
|
||||||
|
format_type = SUPPORTED_IMPORT_FORMATS[suffix]
|
||||||
|
import_op = get_import_operator(format_type)
|
||||||
|
|
||||||
|
if import_op is None:
|
||||||
|
raise ValueError(f"Cannot import {suffix} format")
|
||||||
|
|
||||||
|
stdout_buffer = io.StringIO()
|
||||||
|
with redirect_stdout(stdout_buffer):
|
||||||
|
import_op(filepath=str(filepath))
|
||||||
|
|
||||||
|
output = stdout_buffer.getvalue()
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def clear_scene():
|
||||||
|
bpy.ops.object.select_all(action='SELECT')
|
||||||
|
bpy.ops.object.delete()
|
||||||
|
return len(bpy.data.objects) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def resize_textures(target_size):
|
||||||
|
resized_count = 0
|
||||||
|
|
||||||
|
for image in bpy.data.images:
|
||||||
|
if image.size[0] > target_size or image.size[1] > target_size:
|
||||||
|
old_width = image.size[0]
|
||||||
|
old_height = image.size[1]
|
||||||
|
|
||||||
|
scale = min(target_size / old_width, target_size / old_height)
|
||||||
|
new_width = int(old_width * scale)
|
||||||
|
new_height = int(old_height * scale)
|
||||||
|
|
||||||
|
image.scale(new_width, new_height)
|
||||||
|
resized_count += 1
|
||||||
|
print(f" Resized '{image.name}': {old_width}x{old_height} -> {new_width}x{new_height}")
|
||||||
|
|
||||||
|
return resized_count
|
||||||
|
|
||||||
|
|
||||||
|
def export_mesh(filepath, draco_level=7, format_type='glb'):
|
||||||
|
export_kwargs = {
|
||||||
|
'filepath': str(filepath),
|
||||||
|
'export_draco_mesh_compression_enable': True,
|
||||||
|
'export_draco_mesh_compression_level': draco_level,
|
||||||
|
'export_format': 'GLB' if format_type == 'glb' else 'GLTF_SEPARATE',
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout_buffer = io.StringIO()
|
||||||
|
with redirect_stdout(stdout_buffer):
|
||||||
|
bpy.ops.export_scene.gltf(**export_kwargs)
|
||||||
|
|
||||||
|
return stdout_buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_output(input_path, format_type='glb'):
|
||||||
|
input_file = Path(input_path)
|
||||||
|
suffix = get_output_extension(format_type)
|
||||||
|
return str(input_file.parent / f"{input_file.stem}_compressed{suffix}")
|
||||||
|
|
||||||
|
|
||||||
|
def process_file(input_path, output_path=None, draco_level=7,
|
||||||
|
resize_textures_flag=True, texture_size=512,
|
||||||
|
format_type='glb', quiet=False):
|
||||||
|
if not quiet:
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f"Processing: {input_path}")
|
||||||
|
|
||||||
|
if not os.path.exists(input_path):
|
||||||
|
raise FileNotFoundError(f"Input file not found: {input_path}")
|
||||||
|
|
||||||
|
if not quiet:
|
||||||
|
original_size = os.path.getsize(input_path)
|
||||||
|
print(f"Original size: {original_size / 1024:.2f} KB")
|
||||||
|
|
||||||
|
if not clear_scene():
|
||||||
|
raise RuntimeError("Failed to clear Blender scene")
|
||||||
|
|
||||||
|
if not quiet:
|
||||||
|
print("Importing mesh...")
|
||||||
|
|
||||||
|
import_output = import_mesh(input_path)
|
||||||
|
|
||||||
|
if len(bpy.data.objects) == 0:
|
||||||
|
raise RuntimeError(f"No objects imported from {input_path}")
|
||||||
|
|
||||||
|
if not quiet:
|
||||||
|
mesh_count = sum(1 for obj in bpy.data.objects if isinstance(obj.data, bpy.types.Mesh))
|
||||||
|
print(f"Imported {mesh_count} mesh(es)")
|
||||||
|
|
||||||
|
if resize_textures_flag:
|
||||||
|
if not quiet:
|
||||||
|
print(f"Resizing textures (max: {texture_size}px)...")
|
||||||
|
resized = resize_textures(texture_size)
|
||||||
|
if not quiet and resized > 0:
|
||||||
|
print(f"Resized {resized} texture(s)")
|
||||||
|
|
||||||
|
if output_path is None:
|
||||||
|
output_path = get_default_output(input_path, format_type)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True)
|
||||||
|
|
||||||
|
if not quiet:
|
||||||
|
print(f"Exporting with Draco compression (level={draco_level})...")
|
||||||
|
|
||||||
|
export_output = export_mesh(output_path, draco_level, format_type)
|
||||||
|
|
||||||
|
if not os.path.exists(output_path):
|
||||||
|
raise RuntimeError(f"Export failed: {output_path} not created")
|
||||||
|
|
||||||
|
final_size = os.path.getsize(output_path)
|
||||||
|
|
||||||
|
if not quiet:
|
||||||
|
original_size = os.path.getsize(input_path) if os.path.exists(input_path) else 0
|
||||||
|
reduction = ((original_size - final_size) / original_size * 100) if original_size > 0 else 0
|
||||||
|
print(f"\nOutput: {output_path}")
|
||||||
|
print(f"Final size: {final_size / 1024:.2f} KB")
|
||||||
|
if original_size > 0:
|
||||||
|
print(f"Reduction: {reduction:.1f}%")
|
||||||
|
print("Compression complete!")
|
||||||
|
|
||||||
|
return output_path, final_size
|
||||||
|
|
||||||
|
|
||||||
|
def process_batch(input_dir, output_dir=None, draco_level=7,
|
||||||
|
resize_textures_flag=True, texture_size=512,
|
||||||
|
format_type='glb', quiet=False):
|
||||||
|
if not os.path.exists(input_dir):
|
||||||
|
raise FileNotFoundError(f"Input directory not found: {input_dir}")
|
||||||
|
|
||||||
|
if output_dir:
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
files_found = []
|
||||||
|
for ext in SUPPORTED_IMPORT_FORMATS.keys():
|
||||||
|
files_found.extend(Path(input_dir).glob(f"*{ext}"))
|
||||||
|
files_found.extend(Path(input_dir).glob(f"*{ext.upper()}"))
|
||||||
|
|
||||||
|
files_found = sorted(set(files_found))
|
||||||
|
|
||||||
|
if not files_found:
|
||||||
|
print(f"No supported files found in {input_dir}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not quiet:
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f"BATCH MODE")
|
||||||
|
print(f"Input directory: {input_dir}")
|
||||||
|
print(f"Files found: {len(files_found)}")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for i, file_path in enumerate(files_found, 1):
|
||||||
|
if output_dir:
|
||||||
|
input_file = Path(file_path)
|
||||||
|
suffix = get_output_extension(format_type)
|
||||||
|
output_path = os.path.join(output_dir, f"{input_file.stem}_compressed{suffix}")
|
||||||
|
else:
|
||||||
|
output_path = None
|
||||||
|
|
||||||
|
if not quiet:
|
||||||
|
print(f"\n[{i}/{len(files_found)}]")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result_path, _ = process_file(
|
||||||
|
str(file_path),
|
||||||
|
output_path=output_path,
|
||||||
|
draco_level=draco_level,
|
||||||
|
resize_textures_flag=resize_textures_flag,
|
||||||
|
texture_size=texture_size,
|
||||||
|
format_type=format_type,
|
||||||
|
quiet=quiet
|
||||||
|
)
|
||||||
|
results.append((str(file_path), result_path, True, None))
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
if not quiet:
|
||||||
|
print(f"ERROR: {error_msg}")
|
||||||
|
results.append((str(file_path), None, False, error_msg))
|
||||||
|
|
||||||
|
success_count = sum(1 for _, _, success, _ in results if success)
|
||||||
|
fail_count = len(results) - success_count
|
||||||
|
|
||||||
|
if not quiet:
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f"BATCH COMPLETE")
|
||||||
|
print(f"Total files: {len(results)}")
|
||||||
|
print(f"Success: {success_count}")
|
||||||
|
print(f"Failed: {fail_count}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
argv = sys.argv
|
||||||
|
if "--" not in argv:
|
||||||
|
argv = []
|
||||||
|
else:
|
||||||
|
argv = argv[argv.index("--") + 1:]
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Compress 3D meshes with Draco compression using Blender',
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
# Simple mode (all defaults)
|
||||||
|
blender --background --python compress.py -- input.glb
|
||||||
|
|
||||||
|
# With options
|
||||||
|
blender --background --python compress.py -- -i input.glb -o output.glb --draco-level 10
|
||||||
|
|
||||||
|
# Batch mode
|
||||||
|
blender --background --python compress.py -- --batch ./models/ --output-dir ./compressed/
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'input',
|
||||||
|
nargs='?',
|
||||||
|
help='Input file or directory (for batch mode)'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'-i', '--input',
|
||||||
|
dest='input_file',
|
||||||
|
help='Input file (alternative to positional argument)'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'-o', '--output',
|
||||||
|
dest='output',
|
||||||
|
help='Output file (default: input_compressed.glb)'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--draco-level',
|
||||||
|
type=int,
|
||||||
|
default=7,
|
||||||
|
choices=range(0, 11),
|
||||||
|
help='Draco compression level 0-10 (default: 7)'
|
||||||
|
)
|
||||||
|
|
||||||
|
resize_group = parser.add_mutually_exclusive_group()
|
||||||
|
resize_group.add_argument(
|
||||||
|
'--resize-textures',
|
||||||
|
action='store_true',
|
||||||
|
default=True,
|
||||||
|
help='Enable texture resizing (default: enabled)'
|
||||||
|
)
|
||||||
|
resize_group.add_argument(
|
||||||
|
'--no-resize',
|
||||||
|
action='store_false',
|
||||||
|
dest='resize_textures',
|
||||||
|
help='Disable texture resizing'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--texture-size',
|
||||||
|
type=int,
|
||||||
|
default=512,
|
||||||
|
help='Max texture size in pixels (default: 512)'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--batch',
|
||||||
|
action='store_true',
|
||||||
|
help='Batch mode: process all files in input directory'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--output-dir', '-d',
|
||||||
|
dest='output_dir',
|
||||||
|
help='Output directory for batch mode'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--format', '-f',
|
||||||
|
choices=SUPPORTED_OUTPUT_FORMATS,
|
||||||
|
default='glb',
|
||||||
|
help='Output format (default: glb)'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'-q', '--quiet',
|
||||||
|
action='store_true',
|
||||||
|
help='Quiet mode (less output)'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
input_path = args.input or args.input_file
|
||||||
|
|
||||||
|
if not input_path:
|
||||||
|
parser.print_help()
|
||||||
|
print("\nError: Input file or directory is required")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.batch or os.path.isdir(input_path):
|
||||||
|
results = process_batch(
|
||||||
|
input_path,
|
||||||
|
output_dir=args.output_dir,
|
||||||
|
draco_level=args.draco_level,
|
||||||
|
resize_textures_flag=args.resize_textures,
|
||||||
|
texture_size=args.texture_size,
|
||||||
|
format_type=args.format,
|
||||||
|
quiet=args.quiet
|
||||||
|
)
|
||||||
|
failed = [r for r in results if not r[2]]
|
||||||
|
if failed:
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
process_file(
|
||||||
|
input_path,
|
||||||
|
output_path=args.output,
|
||||||
|
draco_level=args.draco_level,
|
||||||
|
resize_textures_flag=args.resize_textures,
|
||||||
|
texture_size=args.texture_size,
|
||||||
|
format_type=args.format,
|
||||||
|
quiet=args.quiet
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user